blog.syfm

徒然なるままに考えていることなどを書いていくブログ

esa、Google Cloud Functions、Hugo、Netlify で簡易 CMS をつくる

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 を生成した後にサイトへデプロイします。 これらを図に表すと以下のような感じになります。

f:id:ktr_0731:20190304184259j:plain

esa の Webhook は以下のように設定します。ブログポストとして使うカテゴリ以下に対して Webhook を有効にします。(ここでは /blog 以下を対象にしています。) URL には Google Cloud Functions の関数への URL (後述) を指定します。

f:id:ktr_0731:20190303003311p:plain

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 でやることは以下のページそのままなので割愛します。

gohugo.io

まとめ

esaCMS 的に使うことで Git 操作なしにブログの更新ができるようになりました。
今回紹介した、 Cloud Functions で使用しているソースは非常に単純なものなので、必要に応じて変更してください。