之前一篇寫用Hugo打造blog有提到, 寫了一個小工具來產生open graph image, 但由於這個是一個command line工具, 而我又是把它放在 git pre-commit 去觸發並寫回檔案, 感覺不是那麼乾淨, 因此我又有另一個想法想把它寫成一個服務, 順便又思考了一下, 怎樣的圖片比較適合OG(Open Graph)

為什麼要設定 Open Graph Image

這邊並沒有要探討Open Graph是什麼, 怎麼去使用, 這應該可以找到其他相關的文章或是參考官網 https://ope.me/

目前OG會影響到的, 大概就是被分享到社群的文章/網頁, Facebook跟Twitter都支援OG來產生分享到Timeline上的預覽, Twitter則是另外支援它自己的Twitter Card, 而這其中 og:image 會影響到分享後的版面

首先, 這邊先介紹一下工具, 如果要先預覽Facebook分享出去的結果可以使用Facebook提供的 分享偵錯工具, Twitter部分則可使用Card validator

先來看看沒設定og:image的版面是怎樣一個狀況?

OG No Image

Twitter card No Image

前面一個是分享到Facebook上的版面, 後一個則是在Twitter上會看到的, 很明顯的版面偏小, 不起眼, 分享出去後應該也引不起使用者點擊的慾望

這邊Twitter跟Facebook不太一樣的地方是, Facebook決定大小版面是以og:image的大小來決定的, 大版面的圖片需要有1200x630的解析度, 但對Twitter來說, 你如果設定Twitter card的型別是 “summary_large_image” , 那就會選擇使用大版面來顯示, 因此如果圖不是很大的話, 會像下個範例:

OG Small

Twitter card small Image

這邊Twitter card使用的是 “summary_large_image” , 因此可以看到結果(第二張), 版面是較大的版面, 但圖片的部分就糊的有點慘了, 而且, 重點被截到了, Facebook圖雖沒那麼糊, 但版面依然很小

現在應該很多網站都有注意使用了大圖來做為og:image了, 效果就像是這樣:

OG Big

這樣版面是大得很明顯了, 但似乎有點問題, 這邊用的是LOGO, 但它賣的是"【JOYOUNG 九陽】LINE FRIENDS系列真空悶燒罐 熊大", 沒商品圖片, 我不知道大家是怎看, 至少, 不吸引我

再來看另一個例子:

OG PCHOME

看的到商品, 但部分字被遮住了, 而且"限時優惠"這個是有時效性的, 被分享出去的圖片是不會被改變的

那我自己之前的blog文章呢? 我最早設定的邏輯是這樣的, 如果文章內有圖, 就挑第一張圖, 把它放大到1200x630的規格, 如果沒有圖, 就拿標題做一張圖(前面提到的小工具)

OG Blog

OG Blog

OG Blog

OG Blog

我的文章大多偏技術面的文章, 這裡面有些是用到了流程圖, 看起來沒啥太大問題, 有些就沒那麼優了, 再來看看文章內沒有圖的狀況

OG Blog

自從用了這版本後, 覺得好像這樣會比較清楚一點, 與其去選一張跟內容沒那麼相關, 品質又沒保證的, 還不如使用標題來的直觀一點

但之前的小工具, 需要綁在git pre-commit, 我換台電腦就又得設定一次, 也是有點不方便, 重新來寫一個版本, 跑在heroku上, 反正文章沒那麼多, Facebook又不會常常跑來抓, Free dyno就夠用了

下面就來把這版本:Better OG 幾個實作來做一個介紹, 原始碼都在Github上, 因為都是用現成的package來簡單達成的, 所以我沒想把code整理當成一個可以直接發行的版本, 單純當範例, 大家有用到的片段可以直接拿去使用

純文字的OG Image

想達成的效果就跟前面提到這張圖一樣

OG Blog

使用text2img

這一部分算最簡單的, 就是前面的文章有提到的text2img, 它的設計也就是為了產生og:image, 所以很簡單就可以應用在這邊了, 我是用我自己改過的版本:

func (bog *BetterOG) drawText(text string) (*bytes.Buffer, error) {
	buf := bytes.NewBuffer(make([]byte, 0))
	if decoded, err := base64.RawURLEncoding.DecodeString(text); err == nil {
		text = string(decoded)
	} else {
		return nil, err
	}

	var err error
	var img image.Image
	if img, err = bog.drawer.Draw(text); err == nil {
		if err = jpeg.Encode(buf, img, &jpeg.Options{Quality: 90}); err == nil {
			return buf, nil
		}
	}

	return nil, err
}

因為我是要直接放在URL上, 像https://og.jln.co/t/W-ethuiomF3liKnnlKhheGlvcy1tb2NrLWFkYXB0ZXLngrpheGlvc-aPkOS-m-a4rOippueUqOeahOWBh-izh-aWmQ, 所以文字部分就用base64先編碼過(必須要用RawURLEncoding)

Unit test

這邊為了測試畫出來的文字是不是正確, 所以就引入了gosseract, gosseract是tesseract-ocr的go封裝, tesseract-ocr算是很老牌的OCR了, 辨識率還不錯, 不過老實說, 這邊只是想玩玩gosseract XD

func TestDrawText(t *testing.T) {
	server, err := NewServer(":8888", text2img.Params{
		FontPath: "../../fonts/SourceHanSansTC-VF.ttf",
	})

	assert.NoError(t, err)

	client := gosseract.NewClient()
	defer client.Close()

	buf, err := server.drawText(base64.URLEncoding.EncodeToString([]byte("For testing")))
	assert.NoError(t, err)
	client.SetImageFromBytes(buf.Bytes())
	text, _ := client.Text()
	assert.Equal(t, "For testing", text)
}

使用網頁截圖來當OG Image

這種類型應該比較常在ptt分享文章上看到, 像這樣

OG ptt

這也是不錯的做法

使用chromedp來擷取網頁畫面

chromedp是透過chrome debug protocol 來操作Chrome的, 這邊就很適合從程式來操作截取網頁畫面, 只是擷取畫面, 還算蠻簡單的:

func Capture(encodedurl string) ([]byte, error) {
	var err error
	var decoded []byte

	if decoded, err = base64.RawURLEncoding.DecodeString(encodedurl); err == nil {
		url := string(decoded)
		log.Printf("capture URL:%s\n", url)

		ctx, _ := chromedp.NewExecAllocator(context.Background(), chromedp.NoSandbox)
		ctx, cancel := chromedp.NewContext(
			ctx,
			// chromedp.WithDebugf(log.Printf),
		)

		defer cancel()

		var buf []byte

		if err = chromedp.Run(ctx, chromedp.Tasks{
			chromedp.EmulateViewport(1200, 630),
			chromedp.Navigate(url),
			FullScreenshotInViewport(&buf, 90),
		}); err != nil {
			return nil, err
		}

		return buf, nil
	}
	return nil, err
}

這邊要擷取的URL一樣是透過base64編碼完放在url傳過來的, 因為我們要的圖大小是1200x630, 所以這邊的View port就設定成那個大小, 有一個比較要特別注意的是, 跟原本chromdp範例不同的地方是, 這邊要用ctx, _ := chromedp.NewExecAllocator(context.Background(), chromedp.NoSandbox) “NoSandbox” 的模式來初始chrome, 要不然無法在heroku下跑

這邊並不是使用chrome.FullScreenshot來擷取畫面, 取而代之的是用自己寫的FullScreenshotInViewport

func FullScreenshotInViewport(res *[]byte, quality int) chromedp.EmulateAction {
	if res == nil {
		panic("res cannot be nil")
	}
	return chromedp.ActionFunc(func(ctx context.Context) error {
		format := page.CaptureScreenshotFormatJpeg

		var err error
		// capture screenshot
		*res, err = page.CaptureScreenshot().
			WithCaptureBeyondViewport(true).
			WithFormat(format).
			WithQuality(int64(quality)).WithClip(&page.Viewport{
			X:      0,
			Y:      0,
			Width:  1200,
			Height: 630,
			Scale:  1,
		}).Do(ctx)

		if err != nil {
			return err
		}
		return nil
	})
}

其實這支是抄自chrome.FullScreenshot, 但不一樣的是WithClip這邊的寬高用的是1200x630, 這是因為原本chrome.FullScreenshot會抓整頁完整頁面, 要多長有多長, 結果寬度雖是1200, 但長度可能超長, 這樣的比例可能也會被Facebook視為要用小版面來顯示

佈署到heroku

chromedp是沒辦法單獨運作的, 必須要有chrome才可以正常運作, 如果要部屬到heroku, heroku的環境上是沒裝chrome的, 這該怎麼辦?

一種方式是建立自己的Build Pack, 裝個chrome跑headless mode, 不過這條路太麻煩, 不是我選擇的路

一個是在heroku上用docker image來跑, 這樣只要包裝好一個docker image就搞定了, 簡單

為了這目的, 可以選用Headless shell這個docker image當作base image, 這個image已經等同包裝好一個headless chrome了

FROM chromedp/headless-shell:latest
...
# Install dumb-init or tini
RUN apt install dumb-init
# or RUN apt install tini
...
ENTRYPOINT ["dumb-init", "--"]
# or ENTRYPOINT ["tini", "--"]
CMD ["/path/to/your/program"]

這邊的dumb-init, tini是必須的, 因為這個container不只會跑一個procewss, 包含chrome是兩個, 所以不包這個的話, 在結束container時會有zombie process造成container無法被結束

有黑貓就加分!

不過, 也不是每個網頁都像ptt那樣適合用截圖來做og:image, 後來想了一下, 我想要的大概介於兩者之間, 有文字, 但不太單調的版面, 像這樣

OG cat

那其實這也不難, 做一個網頁範本, 用前面的截圖的方法截出來就好了, 那這也是目前最後使用的版本

小收尾

順便做了幾個小收尾

  1. 除了Facebook bot跟Twitter bot外, 不可以抓到產生的圖, 這是為了以防有人偷用, 用UA去判斷
  2. 加上cache-control, cdn-cache-control header, 前面再擋一層cloudflare

碰到的問題

目前碰到的主要問題有兩個

  1. heroku的free dyno冷啟動至少要6秒, 如果網頁又太慢, 那很有可能造成Facebook bot或Twitter bot timeout
  2. 最最詭異的部分是, Facebook部分, 字形跑掉了, 比照前面那張貓圖跟下面這張, 會發現"Julian Shen"的字形跑掉了, 前面那個是Twitter抓出來的, 後者是Facebook, 明明就同一個URL, 同一個browser render, 如果直接看那張圖字形也是正常的, 但Facebook不知道哪抓來的靈異照片 OG cat

Apache Thrift的官網上, 有提供了如何在nodejs下呼叫Thrift client的範例

這邊這個範例其實針對的是javascript, 而其由Thrift idl產生javascript的指令是:

thrift -r --gen js:node tutorial.thrift

但如果我們想要用typescript來寫呢? 這邊產生的程式碼就沒有適用於typescript的封裝, 那這個thrift generator有沒支援typescript呢? 如果我們用 thrift --help 來看看它的說明:

  js (Javascript):
    jquery:          Generate jQuery compatible code.
    node:            Generate node.js compatible code.
    ts:              Generate TypeScript definition files.
    with_ns:         Create global namespace objects when using node.js
    es6:             Create ES6 code with Promises
    thrift_package_output_directory=<path>:
                     Generate episode file and use the <path> as prefix
    imports=<paths_to_modules>:
                     ':' separated list of paths of modules that has episode files in their root   

除了我們剛剛用的js:node外, 還有一個js:ts, 似乎好像是有支援, 但你如果直接用thrift -r --gen js:ts tutorial.thrift ,它產生的typescript code是給browser用的, 並非給nodejs用的, 這怎回事?難道就不能兼顧嗎?其實可以, 如果你去看這段程式碼, 就會發現答案是用-gen js:node,ts, 範例沒寫, help也沒寫清楚

假設我們有一個範例叫sample.thrift:

service SampleService {
    string hello(1: i64 a, 2: i64 b)
    void hello2()
}

那我們用這個指令

thrift -s -gen js:node,ts sample.ts

那就會在gen-nodejs產生以下四個檔

  • sample_types.d.ts
  • sample_types.js
  • SampleService.d.ts
  • SampleService.js

那我們如何在我們程式裡面呼叫Thrift client呢?參考以下範例:

import {createConnection, TFramedTransport, TBinaryProtocol, createClient, Connection} from "thrift";
import { Client } from "./gen-nodejs/SampleService";
import Int64 = require('node-int64');

const conn:Connection = createConnection("localhost", 8080, {
    transport : TFramedTransport,
    protocol : TBinaryProtocol
  });

const client:Client = createClient(Client, conn);
(async () => {
    console.log(await client.hello(new Int64(11), new Int64(34)));
    conn.end();
  })()

對照一下原本javascript版本:

const thrift = require('thrift');

const SampleService = require('./gen-nodejs/SampleService');

var transport = thrift.TFramedTransport;
var protocol = thrift.TBinaryProtocol;

var connection = thrift.createConnection("localhost", 8080, {
    transport : transport,
    protocol : protocol
  });

var client = thrift.createClient(SampleService, connection);

client.hello(1, 2).then(resp => {
    console.log(resp);
}).fin(() => {
    connection.end();
});

相較之下, typescript的版本好像好讀一些

在寫網路相關應用的時候, 應該常常會碰到需要去解析JSON格式的資料, 而Go在這邊也已經內建了一個蠻方便的套件 - “encoding/json” 可以讓我們輕易地來處理這類型的資料

先來看看下面這段範例:

package main

import (
	"fmt"
	"encoding/json"
)

type Sample struct {
   Name string
   Age int 
}

const sampleData = `{
   "name":"julian",
   "age": -1
}`

func main() {
	var sample Sample
	err := json.Unmarshal([]byte(sampleData), &sample)
	
	if err != nil {
	   panic(err)
	}
	fmt.Println(sample)
}

[執行]

從這個範例可以看到, 我們可以用很簡單的程式碼, 把下面這段JSON內容給對應到Sample這個結構裡面

{
   "name":"julian",
   "age": -1
}

但這邊其實有一個問題, 如果你把這個JSON資料, 改成下面這樣子:

{
   "name":"julian",
   "age": "-1"
}

這在現實世界應該蠻常看到的, 只是多加個雙引號而已, 大家應該也會預期這邊應該也會沒問題的解析出一樣的結果吧? 但你如果實際改了資料執行看看, 你得到的結果應該會是:

panic: json: cannot unmarshal string into Go struct field Sample.Age of type int

goroutine 1 [running]:
main.main()
	/tmp/sandbox247755092/prog.go:23 +0x162

這其實是 “encoding/json” 把雙引號的內容都當作字串來看, 所以當我要把它塞到一個 int 欄位時, 就會出事了

解決方法有好幾種, 下面就一一來看看:

用Field tag來解決

如果把Sample的定義改成下面這樣:

type Sample struct {
   Name string
   Age int `json:",string"`
}

[執行範例]

喔耶, 沒問題了耶!!!可以正常的解出資料了耶!! 慢著, 先別高興太早!! 再試試把雙引號拿掉看看(參考這個範例)

呃, 是怎樣啦!! 換成這個錯誤了!!

panic: json: invalid use of ,string struct tag, trying to unmarshal unquoted value into int

goroutine 1 [running]:
main.main()
	/tmp/sandbox912379425/prog.go:23 +0x162

在現實案例中, 的確是有可能碰到有時送來的資料有雙引號, 有時候沒有, 這方法是沒法一次滿足的

利用Unmarshaler界面來解決

“encoding/json” 是可以讓開發者自行指定怎去解析JSON內容的, 只需要定義一個自定義的型別並實做Unmarshaler界面就可以了, 為了解決這個問題, 我們可以定義一個新的MyInt的型別, 並幫它實做UnmarshalJSON的方法, 參考下面範例:

type MyInt int

func (m *MyInt) UnmarshalJSON(data []byte) error {
	str := string(data)
	
	if unquoted, err := strconv.Unquote(str); err == nil {
	   str = unquoted
	}
	
	result, err := strconv.Atoi(str)
	if err != nil {
		return err
	}
	*m = MyInt(result)
	return nil
}

type Sample struct {
	Name string
	Age  MyInt
}

[執行範例]

在Sample這個結構中的Age欄位, 從原本的int改成MyInt, 這樣json.Unmarshal碰到Age這欄位的話, 就會用MyIntUnmarshalJSON方法去解析

這方法是麻煩了點, 而且可能針對不同型別要去個別做, 但卻是可以同時處理掉前述兩種型態的資料, 程式沒那好看就是了

使用json.Number

“encoding/json” 裡其實還提供一個資料型態json.Number, 這個應該是這個問題的比較正規的解法了, 把Sample的定義改成下面這樣:

type Sample struct {
	Name string
	Age  json.Number
}

[執行範例]

這方法也是可以無誤的解析兩種的型態, 然後當你要取用Age這欄位的int型態時, 你可以用sample.Age.Int64()去取得, 要多一層是麻煩點

那它是怎做到的呢? 來看一下它的原始碼:

// A Number represents a JSON number literal.
type Number string

// String returns the literal text of the number.
func (n Number) String() string { return string(n) }

// Float64 returns the number as a float64.
func (n Number) Float64() (float64, error) {
	return strconv.ParseFloat(string(n), 64)
}

// Int64 returns the number as an int64.
func (n Number) Int64() (int64, error) {
	return strconv.ParseInt(string(n), 10, 64)
}

會發現, 其實沒啥了不起的, Number本身就是一個string, 所以它是當字串在處理, 而當你需要數字表示(int或float)時, 再呼叫ParseInt或ParseFloat來處理

使用 Jsoniter 來取代 “encoding/json”

Jsoniter是一個號稱比原生的***“encoding/json”***效能還要來的更好的JSON處理套件, 除了Go的版本外, 它也有Java的版本

效能不在這邊的討論範圍, 但除效能外, 它也是最簡單解決這問題的方法, 先來看看完整的程式碼吧:

package main

import (
	"fmt"
	jsoniter "github.com/json-iterator/go"
	extra "github.com/json-iterator/go/extra"
)

type Sample struct {
	Name string
	Age  int
}

const sampleData = `{
   "name":"julian",
   "age": 45
}`

const sampleData2 = `{
   "name":"julian",
   "age": "45"
}`

func main() {
	extra.RegisterFuzzyDecoders()
	var sample Sample
	err := jsoniter.Unmarshal([]byte(sampleData), &sample)

	if err != nil {
		panic(err)
	}
	fmt.Println(sample)

	err = jsoniter.Unmarshal([]byte(sampleData2), &sample)

	if err != nil {
		panic(err)
	}
	fmt.Println(sample)

}

[執行範例]

為啥說是最簡單的方法呢? 首先, 它有跟 “encoding/json” 完全一樣的使用方法, 把套件換掉後, 程式幾乎一樣, 所以也不需要改啥程式, 但如果只有改這樣, 會發現問題都還是在, 並沒有解決掉, 這時候就要帶入它額外的功能 FuzzyDecoders 了, FuzzyDecoders是在它額外的套件裡面, 所以只需要加入import就可以用了

import extra "github.com/json-iterator/go/extra"

然後在開始使用前, 註冊 FuzzyDecoders 即可(extra.RegisterFuzzyDecoders()), 這樣, 不管有沒雙引號都不會有問題了!!

大概有三年沒寫Blog了, 最近覺得年紀大了, 變得越來越會忘東忘西的, 是有必要強迫自己寫一些東西了

每次荒廢了很久後重新執筆, 好像就會習慣換個系統, 並且做個紀錄, 像是之前 - 這篇 XD 這次…也不要例外好了, 雖然之前用 Jekyll + GitHub Page還算堪用, 但就是覺得它不是很快(是太慢了), 加上這麼久沒用了, 也忘了差不多了, 還是拋棄它吧

GitHub是不錯的免費空間, 所以還是沿用吧, 把 Jekyll 換掉應該就差不多了, 這樣的話找另外一套靜態網頁產生器就足夠了, 也不用特地架server, domain name也沿用, 那這次該換甚麼呢? 最近這幾年, 比較迷 Go, 所以沒多考慮, 打算就採用用Go寫的 Hugo, 當然希望以前的內容還是可以承繼下來, 之前在Jekyll上的功能能延續自然就更好了, 廢話說太多了, 廢話說太多了, 先來看一下這次做的改變

安裝 Hugo

Hugo是一套用Go寫的Open source靜態網頁產生器, 特點就是快, 非常快, 使用方法也很簡單, 如果你在Mac底下, 也像我一樣用 Homebrew, 那只要執行下面的指令:

brew install hugo

不過最近我在家的工作環境比較常是Windows底下用WSL(Windows subsystem for Linux)下開發東西,這邊就不得不誇誇微軟了,有了WSL後,我在Windows下工作,也跟我在Mac上一樣順手,WSL說穿了就是一個Linux環境, 我用的是Ubuntu, 不過我是想在Linux下也是用 我是想在Linux下也是用 Homebrew 來安裝, 不想用apt(習慣了), 這時候就可以用下面的指令來安裝 Homebrew

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install.sh)"

移植原本Jekyll上的內容

這是整個所有過程中最簡單的事了, 當你裝好Hugo後只要執行

hugo import jekyll jekyll_root_path target_path

這邊的jekyll_root_path是你原始jekyll內容的路徑, 它會自動幫你轉換所有的內容到新路徑, 又快又簡單

移植完內容後, 可以到新的目錄執行hugo server先來預覽結果…不過, 慢著~~看起來好像跟想像的不同!因為我們還沒套用主題跟做相關設定

套用原本的主題

雖然換了新系統, 不過我還是希望風格可以接近之前的, Jekyll 是可以套用主題的, 而我之前套用的主題是改自 hpstr 這個主題的(也只是換一下標頭途而已)

還好Hugo上也是有人幫忙移植了 Hugo版hpstr

Hugo的主題(themes)是被放在themes的目錄下, 所以新增一個新的theme很簡單, 只要把theme下載回來解開放在那目錄就好, 如果要試試套用的結果, 就執行

hugo server --theme hpstr

這邊hpstr是theme的名字, 不過到這邊為止, 都只是預覽而已, 真正產生網頁時都還不會套用theme

設定網站

剛剛有說過, 這時候如果去產生網頁, 並不會真的套用你所想要的theme, 必須先做好設定才可以

在剛剛從Jekyll移植過來的目錄裡面, 已經有產生了一個 config.yaml 的設定檔, 如果希望產生網頁時套用我們剛剛選用的hpstr, 那只要在 config.yaml 裡加上一行 theme: hpstr就好

這個config.yaml長的就像這樣:

baseURL: http://blog.jln.co
disablePathToLower: true
languageCode: en-us
title: Le murmure de Julian
theme: hpstr

這個設定檔格式是以yaml格式儲存的, 但它其實也支援了 TOML, JSON等其他的格式, 這是因為, 創作了Hugo的大神 spf13 同時也是著名 Go 套件 viper的作者, 相信很多寫Go的攻城獅們很多都使用過 viper 來讀取設定檔吧

這邊因為某些原因, 我後來決定不用yaml格式來存我的設定, 而是改用TOML, 因此我把config.yaml, 轉換成 config.toml , 以下是我最近一個版本的設定檔:

baseURL= "https://blog.jln.co"
disablePathToLower= true
languageCode="zh-tw"
title= "Le murmure de Julian"
theme="hpstr"
googleAnalytics = "UA-79243751-1"
PygmentsCodeFences = true
Papagination.pagerSizeginate = 5
hasCJKLanguage = true
enableEmoji = true

[params]
        subtitle = "朱隸安貓囈語錄"
        images = ["/images/avatar.png"]
        [params.author]
                name = "Julian Shen"
                avatar = "/images/avatar.png"
                bio = "Softward developer"
                github = "julianshen"
                email = "[email protected]"
                linkedin = "julianshen"
                instagram = "julianshen"
        [params.image]
                feature = "/images/bkg2.jpg"

[outputFormats.RSS]
    mediatype = "application/rss"
    baseName = "feed"

[permalinks]
    post = "/:slug/"

這邊有些設定的意義, 容後再說(我沒忘的話), 但基本上有這些:

  • baseURL - 你網站的URL
  • languageCode - 語系
  • title - 標題
  • theme - 主題, 前面說過了
  • googleAnalytics - GA的Tracking ID
  • hasCJKLanguage - 這個設定關係到算字數,閱讀時間的

(好像都講得差不多了)

維持原本的URL格式

我原本URL格式是長這樣:

http://blog.jln.co/筆記Vue.js-Slot的應用/

但Hugo實際產生的格式是這樣

http://blog.jln.co/post/筆記Vue.js-Slot的應用/

原本的網站其實已經有被搜尋引擎爬過了, 所以我並不想改變URL格式, 因此我在設定檔內加入

[permalinks]
    post = "/:slug/"

這邊就是為了設定文章URL的格式, 當然不只有這邊可以設定, 也不是只有 slug 這個變數可以用, 詳細方法可以參考 這篇說明

RSS的位置

Hugo產生的網站也是會包含RSS的連結, 但它預設是放在index.xml, 但在我舊有用Jekyll產生的網站, 其實是放在feed.xml, 所以我在 IFTTT的設定是feed.xml, 這邊我也是不想變動, 所以加入了以下設定

[outputFormats.RSS]
    mediatype = "application/rss"
    baseName = "feed"

這樣你就會發現RSS不再是放在index.xml了, 而是放在feed.xml

Shortcodes

在Jekyll上有一個非常好用的東西, 比如說你在文章內加入 {% youtube FhoPTyMUgX0 %} 最後產生的網頁就會自動嵌入對應的Youtube影片, 如果是用 {% gist julianshen/229f4ac32b3893816bd7636b96fe6f7d %} , 那就會嵌入對應的gist程式碼

這如果在Hugo上沒有, 就頭痛了, 還好, 也是存在的, 它叫 shortcodes

雖然功能一樣, 但格式是不同的, 以剛剛兩個例子來說, 它就分別變成 (把%%改成{})

  • { {< youtube FhoPTyMUgX0 >} }
  • { {< gist julianshen 229f4ac32b3893816bd7636b96fe6f7d >} }

流程圖嵌入

之前我寫過一篇 “替Jekyll的markdown加上簡易流程圖功能”, 採用的是Jekyll-mermaid

Jekyll-mermaid是透過mermaid-js讓我們可以很簡單的在文章內加入流程圖

但在Hugo,並沒有這樣的plugin, 所以必須要用別的方式達成

首先, 先到 themes底下你的主題(這邊是hpstr)的layouts/partials找看看有沒head.html, 在裡面加入一行

<script async src="https://unpkg.com/[email protected]/dist/mermaid.min.js"></script>

這是為了在每個網頁都可以載入mermaid.js, 再來就是在內容目錄下的layouts/shortcodes裡面建立一個 mermain.html (注意喔, 不是在theme底下那個layouts喔), 內容如下:

<div class="mermaid">
  {{.Inner}}
</div>

這是建立一個新的shortcodes叫mermaid, 內容會轉化成一個div, 這div class是mermaid, mermaid.js會透過class找到這個div, 並將裡面內容轉成流程圖, 因此, 你就可以用這樣的shortcodes:

 { {<mermaid>}}
 graph TD;
    A-->B;
    A-->C;
    B-->D;
    C-->D;
 { {</mermaid>}}

畫出一個這樣的圖

graph TD; A-->B; A-->C; B-->D; C-->D;

把原本轉過來的文章都改成新的shortcodes就大功告成了

Open Graph

Open Graph很重要, 它決定了你的文章被分享到社群網路上的樣子, 長得太不起眼, 沒人會注意, 一篇文章的OG, 差不多會長像這樣:

<meta property="og:title" content="[Blog] 替Jekyll的markdown加上簡易流程圖功能">
<meta property="og:description" content="對一個developer的blog來說, 流程圖似乎是蠻需要的, 比較能夠清楚來解釋一些東西, 但每個東西都轉圖檔還蠻麻煩的, 下面介紹一個有用的J"><meta property="og:type" content="article">
<meta property="og:url" content="https://blog.jln.co/Blog-%E6%9B%BFJekyll%E7%9A%84markdown%E5%8A%A0%E4%B8%8A%E7%B0%A1%E6%98%93%E6%B5%81%E7%A8%8B%E5%9C%96%E5%8A%9F%E8%83%BD/">
<meta property="og:image" content="https://blog.jln.co/images/posts/2016-08-31-%5Bblog%5D-%E6%9B%BFjekyll%E7%9A%84markdown%E5%8A%A0%E4%B8%8A%E7%B0%A1%E6%98%93%E6%B5%81%E7%A8%8B%E5%9C%96%E5%8A%9F%E8%83%BD.md.jpg">

這邊, og:titleog:image 很重要的, 沒有圖, 就非常不起眼了, 標題不吸引人也是很不起眼, 所以我們要讓每篇文章都有對應的圖

如果我們去看 Hugo 的 Open graph template原始檔

{{ with $.Params.images }}{{ range first 6 . -}}
<meta property="og:image" content="{{ . | absURL }}" />
{{ end }}{{ else -}}
{{- $images := $.Resources.ByType "image" -}}
{{- $featured := $images.GetMatch "*feature*" -}}
{{- if not $featured }}{{ $featured = $images.GetMatch "{*cover*,*thumbnail*}" }}{{ end -}}
{{- with $featured -}}
<meta property="og:image" content="{{ $featured.Permalink }}"/>
{{ else -}}
{{- with $.Site.Params.images -}}
<meta property="og:image" content="{{ index . 0 | absURL }}"/>
{{ end }}{{ end }}{{ end }}

這邊就不解釋 Hugo的tempalte語法了, 直接講答案, 跟 og:image 相關的設定是:

  • 文章Front matter內設定的圖片(images)
  • 文章內名字含有"feature", “cover”, “thumbnail” 的圖片
  • 網站設定(config.toml)裡params.images的第一張 (等於就是用這張當預設圖了)

由此可知, 只要在config.toml 裡面放上這設定

[params]
        subtitle = "朱隸安貓囈語錄"
        images = ["/images/avatar.png"]

那在文章沒半張圖時, /images/avatar.png就會是預設圖

如果我想自己在文章中指定圖片呢? 我們在新增文章後, 每篇文章都會有這樣的表頭, 叫做front matter, Hugo支援yaml, toml, json等各種front matter格式, 以下這個由 ---分隔表頭跟文章的, 用的就是yaml格式

 ---
 date: "2017-01-21T00:22:49Z"
 images:
 - /images/posts/2017-01-21-在heroku上用apt-get安裝套件.md.jpg
 tags:
 - server
 - heroku
 title: 在Heroku上用apt-get安裝套件
 ---

這邊就用了images屬性來設定了這篇文章的 og:image

優化社群分享

上面提到了設定Open graph相關內容的基本, 不過, 還有一個問題

Facebook會針對你提供的圖片大小來決定分享出去的版面設計, 可以參考這邊, 最大版面用的圖是1200x630, 不然分享出去的就小小一塊沒人看到了

另外一個問題是, 如果你沒在front matter設定圖片, 那OG就會使用網站預設圖, 或是文章中含有或是文章中含有"feature", “cover”, “thumbnail”, 而這邊front matter上放的圖片還是自己放的, 如果不小心忘了, 絕大多數是忘了, 那可能每篇文章用的都是一樣的圖, 比較單調, 更慘的是, 萬一設的預設圖太小(像我一樣), 或是根本沒設, 那分享出去的版面就會很不顯眼

雖說會去掃文章中的圖片, 找出含有"feature", “cover”, “thumbnail” 的圖片, 跟 Jekyll Auto image 有點類似(不過Auto image不會限制檔名), 當然你也可以把內建的open graph template拿出來改, 放到自己主題layouts下

如果你要改用自己的, 該怎做呢? 首先就是把內建的opengraph.html和twittercard.html拷貝一份到主題的layout/partials目錄下改, 並且也是去改layouts/partials/head.html, 你會在head.html發現這樣的內容(以hpstr為例):

<!-- Open Graph and Twitter cards -->
{{ template "_internal/opengraph.html" . }}
{{ template "_internal/twitter_cards.html" . }}

把這兩個路徑改成自己的就好了

還不滿足!!因為還是有些問題:

  • 文章內的圖片可能本來就不大, 小於1200x630
  • 文章內的圖片是外連的, 有可能之後消失不見
  • 文章內根本沒有圖片

所以我希望:

  • 被用在 og:image的圖片要被放大裁切到1200x630的比例
  • 這個圖片要放本地不能外連
  • 沒有圖的狀況…..產生一個給它用….用標題文字來當圖, 大小也要1200x630

在這需求之下, 我自己就寫了一個工具叫 ogp, 原始碼如下:

這工具做的是

graph LR A["分離Front matter跟文章內容"] --> I{是否含images屬性} I -->|有| H I -->|無| B["掃描文章內含的圖片"] B --> C{是否有圖} C -->|有| D[將圖片縮放並裁減] C -->|無| E[產生文字圖片] D --> F[在Front matter插入images屬性] E --> F F --> G[寫回原文] G --> H[結束]

這邊套用了幾個套件來處理,

  • Hugo本身的 Page parser, 用來處理Front matter跟Content分離, 由於Hugo本身就是用Go寫的, 所以很輕易地可以用 import "github.com/gohugoio/hugo/parser/pageparser"來使用
  • “gopkg.in/yaml.v2”, 用來輸出yaml的
  • “github.com/yuin/goldmark”, 這是Hugo用的Mark down parser, 我這邊用來找出所有的圖片, 如果是外站的就下載回來
  • “github.com/h2non/bimg”, 這是一個基於libvips的圖片處理套件, 安裝上會需要libvips, 這邊就不詳述, 以後如果有時間再另外寫一篇好了
  • “github.com/Iwark/text2img”, 這有點有趣, 我本來想自己寫一個把標題轉成圖片的來當作og:image的材料的, 沒想到真有人已經做了, 這邊我是自己fork回來做了些小修正

自動化處理文章的og:img

有了上面的ogp後, 就可以"手動"來產生文章的og:image了, 當然, 這完全不方便, 我每次寫完一篇新文章後, 我就得自己手動跑一次 ogp, 身為一個懶惰的攻城獅(做了前面這麼多還懶惰?), 當然要想想怎麼來自動化囉!!

這邊想到的方式是利用 git-hooks, 甚麼是git-hooks呢?簡單的說, 就是用來在你做git操作時, 可以讓你觸發某些動作, 例如我這邊要用到的pre-commit和post-commit, pre-commit是在你下commit命令後但還沒真正做commit動作前觸發, post-commit則是在動作發生之後

要使用git-hooks的話, 要把你要執行的寫成腳本(script)放到 .git/hooks/ 目錄下, 例如, pre-commit的腳本的檔名就是 .git/hooks/pre-commit, 記得要把這檔案用 chmod +x 把權限改成可執行, 下面就是我用的pre-commit

#!/usr/bin/perl 
use File::Basename;
use File::Copy;
use Cwd;
use utf8;

open(FH,"git diff --cached --name-status |") or die $!;

while (my $line = <FH>) {
   $line =~ /^(\w)\s+(.+)/;
   if ($1 ne "D"){
      my $f = $2;
      $f =~ tr/"//d;
      ($name,$path,$suffix) = fileparse($f,  ,qr"\..[^.]*$");
      if($suffix =~ /\.md|\.MD/) {
          my $dir = getcwd;
          print "$f\n";
          system("docker run -v $dir:/blog julianshen/ogpp '$name$suffix' > '/tmp/$name$suffix'");
          copy("/tmp/$name$suffix", "$dir/$f") or die "error moving file from /tmp/$name$suffix to $dir/$f:$!";
      }
   }
}
close(FH)

(更新: 後來因為有檔名處理問題, 改寫了這個perl的版本)

這邊所做的動作是找到這次有變更的文章(副檔名.md), 然後針對每個檔去跑ogp產生 og:image (我這邊把ogp包成docker image方便使用)

前面我說到, 會利用到 pre-commitpost-commit, 那 post-commit 又是用到哪個地方呢? 在 pre-commit這邊, 我用了 ogp 有產生了新檔案(圖檔), 但對於git來說, 這個檔案並不在stage中(git add後), 所以當做完commit後, 這個檔案並不會被放進去, 因此我們得找一個方式把它一起放進去

如果你有發現到在pre-commit這腳本中, 在跑完 ogp後有加了一個 touch .commit的動作, 這目的就是為了來解決前面所說的問題, 因為有產生新的檔案, 所以這邊建立一個 .commit的檔案來標記一下有新檔案產生, 這檔案本身沒太大意義, 而是為了在後面 post-commit使用

那, 我的 post-commit的腳本就會長成這樣:

#!/bin/bash
echo "Post commit"
if [ -e .commit ]
    then
    echo "Add rest files"
    rm .commit
    git add .
    git commit --amend -C HEAD --no-verify
fi
exit 0

很簡單, 只要有 .commit存在, 就會去多做一次git addgit commit把新的檔案放進去

要注意, 這兩個hooks都只在本地端作用, 如果要讓它其他電腦也會有作用, 要記得複製過去才會有用

產生網頁並佈署

寫完文章後, 由於原始文章是 mark down 語法寫的, 如果沒把它轉成網頁是沒辦法在瀏覽器上看的, 產生網頁很簡單, 執行

hugo --minify

這就可以了, 當然你如果要簡單的執行 hugo不加任何參數也行, 產生的靜態網頁, 就會放到 public 這個目錄下

所以把 public目錄裡面的所有內容, 都放到你github page的repository下就好了!

慢著!!!慢著!!! 都手動嗎???

懶惰的攻城獅會同意嗎??? 沒關係, 接下來我介紹一下怎把它自動化

使用GitHub Actions來自動化流程

GitHub Actions是GitHub的持續集成(Continue Integration, 簡稱CI)的服務, 可能有不少朋友已經知道CI是甚麼了, 這邊不多做介紹, 實際上使用過GitHub Actions後會發現, 它簡單而且強大

由於我這個blog的內容都是放在GitHub page, 而且原始檔也是放在GitHub, 所以使用GitHub Actions來自動化建置頁面跟佈署看來也很理所當然

GitHub Actions的工作流程(workflow)設定檔都放在.github/workflows底下, 所以只要新增一個就可以使用了, 這邊要特別注意一下, 如果你用GitHub tokens來存取這目錄, 需要特別有workflow權限, 通常如果你clone你的repository時是用http的話, 那可能就無法更動到這個檔(無法push), 建議是使用ssh

我發現, 寫到這邊, 篇幅已經非常的長了, 如果要仔細介紹一下這工具的話, 那可能還要很長的篇幅來說, 所以這邊就不仔細介紹了, 底下分享一下我用的設定:

# This is a basic workflow to help you get started with Actions

name: CI

# Controls when the action will run. Triggers the workflow on push or pull request
# events but only for the master branch
on:
  push:
    paths: ["content/**", ".github/workflows/main.yml", "config.toml"]

# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
  # This workflow contains a single job called "build"
  build:
    # The type of runner that the job will run on
    runs-on: ubuntu-latest

    # Steps represent a sequence of tasks that will be executed as part of the job
    steps:
    # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
    - uses: actions/checkout@v2

    - name: Hugo setup
      uses: peaceiris/[email protected]
      with:
        # The Hugo version to download (if necessary) and use. Example: 0.58.2
        hugo-version: latest
        # Download (if necessary) and use Hugo extended version. Example: true
        extended: false
    
    - name: Build pages
      run: hugo --minify

    - name: Deploy
      uses: peaceiris/actions-gh-pages@v3
      with:
        personal_token: ${{ secrets.PERSONAL_TOKEN }}
        external_repository: julianshen/julianshen.github.com
        publish_branch: master
        publish_dir: ./public
        cname: blog.jln.co

如果你用過另一個CI工具 drone 的話, 你會發現, 對比起來, GitHub Actions的彈性更大, 更方便, 觸發建置的條件不一定要是某個git事件(一般是push, pr), 檔案的更動也可以觸發,檔案的更動也可以觸發, 以這段為例:

on:
  push:
    paths: ["content/**", ".github/workflows/main.yml", "config.toml"]

這就是指 content目錄下, 以及workflow或Hugo設定有改變後, 就會觸發

至於Hugo的建置跟發布, 就在請大家自己看設定檔吧, 其實很簡單的

總結

好就沒寫blog, 也好久沒寫長文了, 我發現我廢話一樣多, 不過這邊也花了很長時間弄, 所以也多了一點

還有一些沒寫上, 例如說我現在是用vscode打這篇文章, 搭配hugoify這個plugin, 並且同時用瀏覽器預覽, 雖然整套工具很geek, 不過弄好後還蠻好玩的就是了

nodejs 8開始, nodejs可以不再需要一定跟V8綁在一起了, 之後底層的Engine可以換成微軟的查克拉(node-chakracore), 甚至是Mozilla的SpiderMonkey(spidernode)

node-chakracore的官方文件用的version managent是nvs, 而我習慣用的nvm則是尚未支援查克拉版本的安裝, 如果用nvm想嚐鮮的話, 要用下面的方式:

NVM_NODEJS_ORG_MIRROR=https://nodejs.org/download/chakracore-nightly/ nvm i node

這樣就會幫你裝最新版本的nodejs nightly (with Chakra Core), 以我剛剛執行的結果, 它就幫我裝了v9.0.0-nightly20170617021fbca6bc, 當然如果想用8而不是9一樣可以用:

NVM_NODEJS_ORG_MIRROR=https://nodejs.org/download/chakracore-nightly/ nvm list-remote

這樣就可以看有哪些版本可以安裝

裝完之後可以用nvm aliasv9.0.0-nightly20170617021fbca6bc取一個代號, 如nvm alias v9.0.0-nightly20170617021fbca6bc chakra9, 這樣之後就可以用nvm use chakra9來切換到這個版本, 這樣就搞定了! 歡迎來到木葉忍者村!!

另外, 補充一點, 怎知道用的版本是查克拉版的呢?

執行: node -e "console.log('Hello from Node.js ' + process.jsEngine)"

如果是查克拉版的會顯示: Hello from Node.js chakracore

如果是v8的則是: Hello from Node.js undefined