Hugo は、Markdown ファイルを元にブログポストの HTML を静的に生成することのできるツールです。Hugo で生成した HTML を Netlify や GitHub Pages にデプロイすることでブログをホストするためのサーバを用意せずにブログを公開することができます。
しかし、Hugo はデプロイのトリガーとして基本的に Git リポジトリへの push を必要とします。PC でブログを書いている場合はあまり気にならないかもしれませんが、スマホで雑にポストを書きたい時は Git 操作をするのは簡単ではありません。
そこで、esa でポストを管理しつつ、デプロイのトリガーまでできるしくみをシュッと作ってみました。
esa には Webhook が用意されており、ポストの変更に合わせて発火することができます。また、ポストの状態として WIP と (ほとんど) 完成が用意されているため、これを利用して下書き機能として利用できそうです。タグ機能もそのままポストに付けられたタグとして利用できるでしょう。
esa で設定した Webhook の発火により、Cloud Functions が起動し、変更された Markdown のコンテンツを Git リポジトリに適用し、push します。push により、Netlify が発火し、HTML を生成した後にサイトへデプロイします。 これらを図に表すと以下のような感じになります。
esa の Webhook は以下のように設定します。ブログポストとして使うカテゴリ以下に対して Webhook を有効にします。(ここでは /blog
以下を対象にしています。)
URL
には Google Cloud Functions の関数への URL (後述) を指定します。
Google Cloud Functions に登録する関数の例は以下のようになります。GitHub 上のブログコンテンツを管理するリポジトリをローカルへクローンし、Webhook から受け取ったコンテンツをコミットします。そしてそのコミットを GitHub へ push しています。
package p import ( "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "strings" "time" "github.com/pkg/errors" "gopkg.in/src-d/go-billy.v4/memfs" git "gopkg.in/src-d/go-git.v4" "gopkg.in/src-d/go-git.v4/plumbing/object" githttp "gopkg.in/src-d/go-git.v4/plumbing/transport/http" "gopkg.in/src-d/go-git.v4/storage/memory" ) const ( kindCreate = "post_create" kindUpdate = "post_update" kindDelete = "post_delete" ) var tmpl = `--- title: "%s" date: %s type: posts draft: false tags: %s --- %s ` var ( repoURL string githubToken string githubName string authorName string authorEmail string ) var auth *githttp.BasicAuth type request struct { Kind string `json:"kind"` Post post `json:"post"` } type post struct { Name string `json:"name"` BodyMarkdown string `json:"body_md"` WIP bool `json:"wip"` URL string `json:"url"` } func init() { if repoURL = os.Getenv("REPO_URL"); repoURL == "" { panic("$REPO_URL must be required") } if githubToken = os.Getenv("GITHUB_TOKEN"); githubToken == "" { fmt.Fprintln(os.Stderr, "$GITHUB_TOKEN is not found") } if githubName = os.Getenv("GITHUB_NAME"); githubName == "" { panic("$GITHUB_NAME must be required") } if authorName = os.Getenv("AUTHOR_NAME"); authorName == "" { fmt.Fprintln(os.Stderr, "$AUTHOR_NAME is not found") } if authorEmail = os.Getenv("AUTHOR_EMAIL"); authorEmail == "" { fmt.Fprintln(os.Stderr, "$AUTHOR_EMAIL is not found") } if githubToken != "" { auth = &githttp.BasicAuth{ Username: githubName, Password: githubToken, } } } func UpdatePost(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() body, err := decodeRequestBody(r.Body) if err != nil { fmt.Fprintf(w, "failed to get the request body: %s", err) w.WriteHeader(http.StatusBadRequest) return } if body.Post.WIP { w.WriteHeader(http.StatusNotModified) return } title, tags := decodeName(body.Post.Name) switch body.Kind { case kindCreate, kindUpdate: var tagStr string for _, tag := range tags { tagStr += " - " + tag + "\n" } content := fmt.Sprintf(tmpl, title, time.Now().Format(time.RFC3339), tagStr, body.Post.BodyMarkdown) if err := flush(title, content); err != nil { panic(err) } fmt.Fprintln(w, "done") case kindDelete: if err := remove(title); err != nil { panic(err) } fmt.Fprintln(w, "done") } } func flush(title, body string) error { fs := memfs.New() repo, err := git.Clone(memory.NewStorage(), fs, &git.CloneOptions{URL: repoURL, Auth: auth}) if err != nil { return errors.Wrap(err, "failed to clone the blog repository") } fname := fmt.Sprintf("content/posts/%s.md", strings.Replace(title, " ", "-", -1)) f, err := fs.Create(fname) if err != nil { return errors.Wrap(err, "failed to write content to the file") } defer f.Close() _, err = io.WriteString(f, body) if err != nil { return errors.Wrap(err, "failed to write content to the file") } w, err := repo.Worktree() if err != nil { return errors.Wrap(err, "failed to get the work tree") } _, err = w.Add(fname) if err != nil { return errors.Wrap(err, "failed to add the written file") } _, err = w.Commit(fmt.Sprintf("create or update %s", fname), &git.CommitOptions{ Author: &object.Signature{ Name: authorName, Email: authorEmail, When: time.Now(), }, }) if err != nil { return errors.Wrap(err, "failed to commit the changes") } if err := repo.Push(&git.PushOptions{Auth: auth}); err != nil { return errors.Wrap(err, "failed to push the committed changes") } return nil } func remove(title string) error { fs := memfs.New() repo, err := git.Clone(memory.NewStorage(), fs, &git.CloneOptions{URL: repoURL, Auth: auth}) if err != nil { return errors.Wrap(err, "failed to clone the blog repository") } fname := fmt.Sprintf("content/posts/%s.md", strings.Replace(title, " ", "-", -1)) err = fs.Remove(fname) if err != nil { return errors.Wrap(err, "failed to remove the post") } w, err := repo.Worktree() if err != nil { return errors.Wrap(err, "failed to get the work tree") } _, err = w.Add(fname) if err != nil { return errors.Wrap(err, "failed to add the removed file") } _, err = w.Commit(fmt.Sprintf("delete %s", fname), &git.CommitOptions{ Author: &object.Signature{ Name: authorName, Email: authorEmail, When: time.Now(), }, }) if err != nil { return errors.Wrap(err, "failed to commit the changes") } if err := repo.Push(&git.PushOptions{Auth: auth}); err != nil { return errors.Wrap(err, "failed to push the committed changes") } return nil } func decodeRequestBody(b io.Reader) (*request, error) { var r request if err := json.NewDecoder(b).Decode(&r); err != nil { return nil, err } return &r, nil } func decodeName(n string) (string, []string) { n = filepath.Base(n) prev := strings.Index(n, "#") if prev == -1 { return n, nil } var tags []string var title string title = strings.TrimSpace(n[:prev]) for { current := strings.Index(n[prev+1:], "#") if current == -1 { tags = append(tags, strings.TrimSpace(n[prev+1:])) return title, tags } tags = append(tags, strings.TrimSpace(n[prev+1:prev+current+1])) prev += current + 1 } return title, tags }
UpdatePost
関数を実行する関数に指定し、URL を先程の esa の Webhook に登録します。
Netlify ⇔ Hugo でやることは以下のページそのままなので割愛します。
まとめ
esa を CMS 的に使うことで Git 操作なしにブログの更新ができるようになりました。
今回紹介した、 Cloud Functions で使用しているソースは非常に単純なものなので、必要に応じて変更してください。