MkDocs との出会い

以前の記事 で紹介した Portal ですが、そのサイトは Hugo という静的サイトジェネレータを使用して作成していました。

Hugo 側の機能で多言語サイトへの対応ができるなど、非常に高性能でよかったのですが、watchモードで動かしている時によくバグってしまい、イライラが溜まってしまったので別のジェネレータを探しました。

そこで見つけたのが MkDocs というジェネレータです。この MkDocs ように用意されている Material Design のテーマ が非常にいい感じだったので、エイヤッという感じで乗り換えてみました。

しかし、MkDocs 自体には Hugo のような複数言語サイトへの対応の機能はないので、そこをいい感じにしつつ、ついでで Push すると GitHub Pages (gh-pagesブランチ)にも自動で Push してくれるようにするまで行いました。

このプロジェクトで使用したので、必要があればここを見てください

1. 多言語サイトを無理やり作る

多言語サイトの表現方法は思いつく感じ以下のようなものがあります。

  • サブドメインに ja とかがつく
  • URLのパスの一部に ja とかがつく
  • クエリストリングに lang=ja みたい感じでつく

よくあるのは1つ目と2つ目だと思います。

今回は、サブドメインだとCDN周りの設定が面倒になってくるので、2番目の方法を採用しました。(Hugoもこの方法だったというのもあります。)

デフォルトのフォルダ構成

1
2
3
4
5
6
7
8
9
sites
├── assets
├── index.html
├── search
├── sitemap.xml
└── sub-folder
    ├── index.html
    └── sub-sub-folder
        └── index.html

こんな感じに、ページとして表示可能なパスの場合は、index.html が置かれています。

今回、URLのパスのプレフィックスとして ja がうまく付くようにフォルダ構成をいじります。

1
2
3
4
5
http://example.com/
http://example.com/ja

http://example.com/sub-folder/
http://example.com/ja/sub-folder/

ちょうどこんな感じのURLの対応になります。

このアクセスを可能にするためのフォルダの構成はこうなります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
sites
├── assets
├── index.html
├── search
├── sitemap.xml
├── ja
│   ├── index.html
│   └── sub-folder
│       ├── index.html
│       └── sub-sub-folder
│           └── index.html
└── sub-folder
    ├── index.html
    └── sub-sub-folder
        └── index.html</pre>

ja フォルダ以下に、 assetssitemap.xml 以外のファイルが同じように配置されている状況になれば良さそうです。( assets も配置してもいいですが、中身が同じで無駄なので省きます。)

今回の肝

Hugo のように1つの構成でやるのは無理なので、2つ以上の構成を用意して、外部コマンドで合体させる必要があります。

ただし、ここに sitemap.xml が関わってくるので、いい感じにこれもマージしないといけません。

サイトマップさえなければシェルスクリプトでも良かったのですが、XMLを行数だけであつかうのは怖いので、今回は Gradle を使用していい感じにマージさせるようにします。

build.gradle

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
import com.sun.org.apache.xml.internal.serialize.OutputFormat
import com.sun.org.apache.xml.internal.serialize.XMLSerializer
import org.gradle.internal.os.OperatingSystem
import org.w3c.dom.Document
import org.w3c.dom.Node
import org.w3c.dom.NodeList

import javax.xml.parsers.DocumentBuilder
import javax.xml.parsers.DocumentBuilderFactory
import javax.xml.xpath.XPath
import javax.xml.xpath.XPathConstants
import javax.xml.xpath.XPathExpression
import javax.xml.xpath.XPathFactory
import java.nio.file.Files

task build {
    doLast {
        build()
    }
}

def build() {
    File[] dirs = new File("${project.rootDir}/src/sites").listFiles()
    List&lt;File&gt; sites = new ArrayList&lt;&gt;()
    for (File f : dirs) {
        if (!f.isDirectory()) continue
        sites.add(f)
    }
    for (File f : sites) {
        println("Building site for '${f.name}'")
        buildSite(f.name)
    }
    def docsDirPath = new File("${project.rootDir}/public").toPath()
    def docsTempDirPath = new File("${project.rootDir}/public_tmp").toPath()
    docsTempDirPath.toFile().deleteDir()
    println("Deleting previous output dir...")
    Files.createDirectory(docsTempDirPath)
    if (Files.exists(docsDirPath.resolve(".git"))) {
        Files.move(docsDirPath.resolve(".git"), docsTempDirPath.resolve(".git"))
    }
    if (Files.exists(docsDirPath.resolve("CNAME"))) {
        Files.move(docsDirPath.resolve("CNAME"), docsTempDirPath.resolve("CNAME"))
    }
    docsDirPath.toFile().deleteDir()
    println("Moving artifact to output dir...")
    def defaultSiteDir = new File("${project.rootDir}/src/sites/default/site").toPath()
    println("  Moving 'default'")
    Files.move(defaultSiteDir, docsDirPath)
    def docsSitemapPath = docsDirPath.resolve("sitemap.xml")
    for (File f : sites) {
        if (f.name == "default") continue
        def sitePath = f.toPath().resolve("site")
        println("  Moving '${f.name}'")
        println("    Deleting duplicated assets")
        sitePath.resolve("assets").deleteDir()
        sitePath.resolve("search").deleteDir()
        println("    Merging sitemap")
        def sitemapPath = sitePath.resolve("sitemap.xml")
        mergeXml(sitemapPath, docsSitemapPath)
        Files.delete(sitemapPath)
        Files.move(sitePath, docsDirPath.resolve(f.name))
    }
    if (Files.exists(docsTempDirPath.resolve(".git"))) {
        Files.move(docsTempDirPath.resolve(".git"), docsDirPath.resolve(".git"))
    }
    if (Files.exists(docsTempDirPath.resolve("CNAME"))) {
        Files.move(docsTempDirPath.resolve("CNAME"), docsDirPath.resolve("CNAME"))
    }
    docsTempDirPath.toFile().deleteDir()
}

def buildSite(String name) {
    if (isWindows()) {
        runCommand("cd /d \"${project.rootDir}/src/sites/$name/\" & mkdocs build")
    } else {
        runCommand("cd \"${project.rootDir}/src/sites/$name/\"; mkdocs build")
    }
}

static def runCommand(String commandRaw) {
    def command
    if (isWindows()) {
        command = ["cmd", "/c", "\"$commandRaw\""]
    } else {
        command = ["sh", "-c", commandRaw]
    }
    def p = command.execute()
    BufferedReader reader = new BufferedReader(new InputStreamReader(p.getInputStream()))
    String line
    while ((line = reader.readLine()) != null) {
        println(line)
    }
    def exitValue = p.waitFor()
    if (exitValue != 0) {
        throw new RuntimeException("An error has occurred while running command. ${p.err.text}")
    }
}

static def mergeXml(java.nio.file.Path source, java.nio.file.Path base) {
    DocumentBuilder builder = DocumentBuilderFactory.newInstance().newDocumentBuilder()
    Document baseXml = builder.parse(base.newInputStream())
    Document importXml = builder.parse(source.newInputStream())

    XPath xpath = XPathFactory.newInstance().newXPath()
    XPathExpression ex = xpath.compile("/*/*")
    NodeList importXmlNodeList = (NodeList) ex.evaluate(importXml, XPathConstants.NODESET)

    Node rootNode = baseXml.getDocumentElement()
    for (int i = 0; i &lt; importXmlNodeList.getLength(); i++) {
        Node importNode = baseXml.importNode(importXmlNodeList.item(i), true) as Node
        rootNode.appendChild(importNode)
    }

    OutputFormat format = new OutputFormat(baseXml)
    format.setIndenting(true)
    format.setIndent(2)
    Writer out = new FileWriter(base.toFile())
    XMLSerializer serializer = new XMLSerializer(out, format)
    serializer.serialize(baseXml)
}

static def isWindows() {
    return OperatingSystem.current().isWindows()
}

フォルダ構成

1
2
3
src/sites/default : デフォルトの構成
src/sites/** : **をプレフィックスにもつ構成
public/ : 合体させたデータ(gh-pages用)

主にこんな感じです。

default で指定されたものをビルドして public フォルダに移動、後はサブの構成をどんどんビルドして public フォルダに追加していくだけです。

追加するときには前述の余分なものが入らないように削除を行っています。

これで、./gradlew build と叩けば公開用のフォルダが出来上がります。

2. GitHub Pages に自動デプロイする

GitHub Pages のホスティング方法は何種類かありますが、今回は gh-pages ブランチに自動でコミット・プッシュが行なわれるようにしたいとおもいます。

CI周りには安定の CircleCI を使います。

Docker

CircleCI2 で、Docker周りが充実しましたが、公式で提供されているのはたいてい1言語だけをサポートしたものだけしか用意されていません。

今回のプロジェクトでは、Python(MkDocs)とJava(Gradle)が必要になるので、カスタムのDockerイメージを作成してやっちゃいたいと思います。

とはいっても、Hub に公開されているPythonとJavaのDockerfileをガッチャンコして、足りないものを追加でインストールするようにしただけです。

実際のDockerfileは以下で公開しています。

https://github.com/PortalMC/www.portalmc.org/blob/master/deploy/docker/Dockerfile

config.yml

1
2
3
4
5
6
7
8
9
version: 2
jobs:
  build:
    docker:
      - image: portalmc/web-builder
    steps:
      - checkout
      - add_ssh_keys
      - run: ./deploy/scripts/deploy_production.sh

リポジトリにプッシュする関係上、リポジトリへの書き込み権限を持った Deploy Key の追加と、ステップ中で add_ssh_keys を呼ぶ必要があります。

特に add_ssh_keys を追加するのが公式のドキュメントのわかりやすい部分に書いていないので結構な時間を取られました。。。

run で実行しているシェルスクリプトがビルドやgitの操作を行う本体になります。

スクリプトはこちらで紹介されているものをほぼそのまま使用しています。

gh-pages 用のリポジトリのinit時に CNAME ファイルを追加する部分と、ビルドコマンドが変わっています。

https://github.com/PortalMC/www.portalmc.org/blob/46994ce3096700d63e752f461befc94c01067fb8/deploy/scripts/deploy_production.sh

あとは CircleCI側でプロジェクトを有効にすれば、Pushと同時にサイトの更新も自動で行われるようになります。

おわりに

本来の使い方から外れるととたんにめんどくなるのがつらい。世知辛い。