📝

VS CodeとDockerである程度快適なZennの執筆環境を構築する

2023/06/11に公開

これはなに

VS CodeとDockerでZennの記事の執筆環境を構築したときのメモ。

つくった環境

  • zenn cliを利用して、ローカルでプレビューしながら記事を執筆する
  • textlintで内容を校正する
  • zennのためのスニペットを使えるようにする
  • GitHubにpushしてZennに記事を公開する
  • 以上の環境をDockerで構築する

ホスト環境

  • Visual Studio Code (VS Code)
  • Windows 11 + WSL2 (Ubuntu)
  • Docker Desktop for Windows

VS CodeとDockerが使えれば、上記の環境でなくても動くと思われる。

前提条件

  • VS Codeをインストールしている
  • Dockerを使えるようにしている

VS Codeに拡張機能を入れる

VS CodeにDev Containersという拡張機能を入れる。

Dockerコンテナをつくるための準備をする

拡張機能Dev Containersを利用して、Dockerfileをビルドしアタッチする。

まず、.devcontainerディレクトリを作成し、その下にDockerfiledevcontainer.jsonファイルを作成する。

.devcontainer
├── Dockerfile
└── devcontainer.json

Dockerfile

次に、作成したDockerfileに以下を記述する。Dockerfileでは、localeとtimezoneを日本に設定し、git、zenn cli、textlint(+ルール)をインストールする。

.devcontainer/Dockerfile
ARG NPM_VERSION="9.7.1"
ARG ZENN_CLI_VERSION="0.1.143"

FROM node:18-bullseye-slim

EXPOSE 8000

WORKDIR /workspaces/

# Set environment variables for locale and timezone
ENV LANG ja_JP.UTF-8
ENV LANGUAGE ja_JP:ja
ENV LC_ALL ja_JP.UTF-8
ENV TZ Asia/Tokyo

# Install packages, set locale and timezone in a single RUN command
RUN apt update -y \
    && apt install -y --no-install-recommends \
    locales \
    git \
    ca-certificates \
    && sed -i -e 's/# \(ja_JP.UTF-8\)/\1/' /etc/locale.gen \
    && locale-gen \
    && ln -sf /usr/share/zoneinfo/$TZ /etc/localtime \
    && rm -rf /var/lib/apt/lists/*

# Install zenn-cli
RUN npm install -g npm@${NPM_VERSION} \
    && npm init --yes \
    && npm install zenn-cli@${ZENN_CLI_VERSION}

# Install textlint
RUN npm install --save-dev \
    textlint \
    textlint-rule-preset-ja-technical-writing \
    textlint-rule-no-mixed-zenkaku-and-hankaku-alphabet \
    textlint-rule-prefer-tari-tari \
    @textlint-ja/textlint-rule-no-insert-dropping-sa \
    textlint-rule-ja-no-orthographic-variants \
    textlint-rule-preset-ja-spacing \
    textlint-rule-ja-no-inappropriate-words \
    @textlint-ja/textlint-rule-no-dropping-i \
    @textlint-ja/textlint-rule-no-insert-re \
    textlint-rule-no-doubled-conjunction \
    textlint-rule-ja-hiragana-keishikimeishi \
    textlint-rule-ja-hiragana-fukushi \
    textlint-rule-ja-hiragana-hojodoushi \
    @textlint-ja/textlint-rule-no-synonyms sudachi-synonyms-dictionary \
    textlint-filter-rule-allowlist

ENV PATH $PATH:/workspaces/zenn-docs/scripts/

USER node

Dockerfileにおいて、nodenpmzenn cliのバージョンは、それぞれ最新のものにするとよい。

devcontainer.json

続けて、作成したdevcontainer.jsonに以下の内容を記述する。

.devcontainer/devcontainer.json
{
  "name": "zenn-docs",
  "build": {
    "dockerfile": "./Dockerfile",
    "context": ".."
  },
  "forwardPorts": [
    8000
  ],
  "postCreateCommand": "chmod -R +x scripts",
  "customizations": {
    "vscode": {
      "extensions": [
        "taichi.vscode-textlint"
      ]
    }
  }
}

執筆環境に追加するVS Codeの拡張機能を指定する

devcontainer.jsonextensionsでVS Codeの拡張機能を指定すると、その拡張機能が必ず自動でDockerコンテナへインストールされる。

.devcontainer/devcontainer.json
{
  // ...
  "customizations": {
    "vscode": {
      "extensions": [
        "taichi.vscode-textlint"
      ]
    }
  }
}

また、.vscode/extensions.jsonに以下のように拡張機能を指定すると、コンテナへアタッチした際に推奨される拡張機能(@recommended)として認識される。

.vscode/extensions.json
{
  "recommendations": [
    "mohammadbaqer.better-folding",
    "johnharrison.bongocat-buddy",
    "streetsidesoftware.code-spell-checker",
    "igorsbitnev.error-gutters",
    "saikou9901.evilinspector",
    "mhutchie.git-graph",
    "donjayamanne.githistory",
    "fabiospampinato.vscode-highlight",
    "oderwat.indent-rainbow",
    "yzhang.markdown-all-in-one",
    "davidanson.vscode-markdownlint",
    "gera2ld.markmap-vscode",
    "christian-kohler.path-intellisense",
    "gruntfuggly.todo-tree",
    "shardulm94.trailing-spaces",
    "tonybaloney.vscode-pets"
  ]
}

これらのうち好きな方法で必要な拡張機能を指定する[1]

textlintの設定ファイルを追加する

.textlintrcファイルを作成し、textlintを実行するためのルールの設定を記述する。
ところどころZenn独自の記述のためのルールを追加している。

.textlintrc
{
  "filters": {
    "allowlist": {
      "allow": [
        "/:*details (.*?)/"
      ]
    }
  },
  "rules": {
    "preset-ja-technical-writing": {
      "no-exclamation-question-mark": false,
      "ja-no-mixed-period": {
        "allowPeriodMarks": [
          ":::",
          "::::"
        ],
        "checkFootnote": true
      },
      "sentence-length":{
        "skipPatterns":[
          "/:{3,}message/",
          "/:{3,}message alert/",
          "/:{3,}details/",
          "/:{3,}",
          "/\\[(.*?)\\]\\((.*?)\\)/"
        ]
      }
    },
    "no-mixed-zenkaku-and-hankaku-alphabet": true,
    "prefer-tari-tari": true,
    "@textlint-ja/textlint-rule-no-insert-dropping-sa": true,
    "ja-no-orthographic-variants": true,
    "preset-ja-spacing": true,
    "ja-no-inappropriate-words": true,
    "@textlint-ja/textlint-rule-no-dropping-i": true,
    "@textlint-ja/textlint-rule-no-insert-re": true,
    "no-doubled-conjunction": true,
    "ja-hiragana-keishikimeishi": true,
    "ja-hiragana-fukushi": {
      "rulePath": "./fukushi.yml"
    },
    "ja-hiragana-hojodoushi": true,
    "@textlint-ja/no-synonyms": true
  }
}

新規作成のためのスクリプトを用意する

zenn cliでは新規作成のコマンドが用意されているが、個人的なこだわりを反映できるスクリプトを別途用意する。
scriptsディレクトリを作成し、そこにスクリプトファイルを作成していく。

新規記事作成スクリプト

zenn cliでは、新規記事は次のnpxコマンドで生成できる。

npx zenn new:article --slug my-first-article --title タイトル --type tech

しかし、個人的にslugは英数字とハイフンのみ(アンダーバーを許可しない)ようにしたかったため、以下のarticleスクリプトを作成した。

scripts/article
#!/bin/bash

# 関数定義
set -euo pipefail
error() {
  local exit_code="${1:-1}" # Default exit status 1
  shift
  printf "\033[31;1m%s\033[0m\n" "!!!ERROR!!! $@" >&2
  exit "$exit_code"
}

# 引数をチェックする
set -eo pipefail
if [ -z "${1-}" ]; then
  error 1 "引数が1つ必要です。"
fi

set -euo pipefail
readonly SLUG=$1

# 引数がパターンに一致するかチェックする
# 小文字の半角英数字(a-z0-9)またはハイフン(-)の12〜50字の組み合わせ
readonly PATTERN="^[a-z0-9-]{12,50}$"
if ! [[ "${SLUG}" =~ $PATTERN ]]; then
  error 2 "slugの値(${SLUG})が不正です。小文字の半角英数字(a-z0-9)またはハイフン(-)の12〜50字の組み合わせにしてください。"
fi

# すでに存在するかチェック
readonly FILE_PATH=articles/"${SLUG}.md"
if [ -e "$FILE_PATH" ]; then
  error 3 "そのsulgの記事はすでに存在しています。"
fi

# 記事を作成する
npx zenn new:article --slug "${SLUG}" --title タイトル --type tech

次のように利用する。

article orig-slug

新規本作成スクリプト

zenn cliでは、新規本は次のnpxコマンドで生成できる。

npx zenn new:book --slug my-first-book

しかし、個人的にslugは英数字とハイフンのみ(アンダーバーを許可しない)ようにしたいのと、デフォルトの構成をカスタマイズしたかったため、以下のbookスクリプトを作成した。

scripts/book
#!/bin/bash

# 関数定義
set -euo pipefail
error() {
  local exit_code="${1:-1}" # Default exit status 1
  shift
  printf "\033[31;1m%s\033[0m\n" "!!!ERROR!!! $@" >&2
  exit "$exit_code"
}

# 引数をチェックする
set -eo pipefail
if [ -z "${1-}" ]; then
  error 1 "引数が1つ必要です。"
fi

set -euo pipefail
readonly SLUG=$1

# 引数がパターンに一致するかチェックする
# 小文字の半角英数字(a-z0-9)またはハイフン(-)の12〜50字の組み合わせ
readonly PATTERN="^[a-z0-9-]{12,50}$"
if ! [[ "${SLUG}" =~ $PATTERN ]]; then
  error 2 "slugの値(${SLUG})が不正です。小文字の半角英数字(a-z0-9)またはハイフン(-)の12〜50字の組み合わせにしてください。"
fi

# すでに存在するかチェック
readonly BOOK_DIR_PATH=books/"${SLUG}"
if [ -d "$BOOK_DIR_PATH" ]; then
  error 3 "そのsulgの本はすでに存在しています。"
fi

# templatesから本を生成する
if ! mkdir "${BOOK_DIR_PATH}"; then
  error 4 "ディレクトリの作成に失敗しました : ${BOOK_DIR_PATH}"
fi

if ! cp -r templates/book/* "${BOOK_DIR_PATH}"/; then
  error 5 "ファイルのコピーに失敗しました : templates/book/* to ${BOOK_DIR_PATH}/"
fi

本のテンプレートファイルは以下の構造で作成した。

.
├── scripts
│   └── book
└── templates
    └── book
        ├── config.yaml
        ├── about.md
        └── references.md
templates/book/config.yaml
title: ""
summary: ""
topics: [] # (5つまで)
published: false
toc_depth: 2
price: 0 # 有料の場合200〜5000で100円単位
chapters:
  - about
  - references
templates/book/about.md
---
title: "はじめに | これはなに"
free: true
---
templates/book/references.md
---
title: "参考文献・URL"
free: true
---

次のように利用する。

book orig-slug

プレビュー表示スクリプト

zenn cliでは、新規本は次のnpxコマンドでプレビューを表示できる。

npx zenn preview

scriptsディレクトリにpreviewスクリプトを作成すると、npx zenn previewの代わりにpreviewだけでプレビューを表示できるので楽である。

scripts/preview
#!/bin/bash
set -euo pipefail

npx zenn preview

次のように利用する。

preview

記事のためのスニペットを作成する

Zenn独自のMarkdown記法をスニペットとして登録しておく。

作成したスニペット(長いのでたたむ)

今回はワークスペースにだけスニペットを登録するようにした。

.vscode/markdown.code-snippets
{
  "zenn_image": {
    "scope": "markdown",
    "prefix": "zenn_img",
    "body": [
      "![$1]($2 =450x)",
      "*$1*",
    ],
    "description": "Image in zenn.",
  },
  "zenn_image-url": {
    "scope": "markdown",
    "prefix": "zenn_img-url",
    "body": [
      "![$1]($2 =450x)(link)",
      "*$1*",
    ],
    "description": "Image with link in zenn.",
  },
  "zenn_codeblock": {
    "scope": "markdown",
    "prefix": "zenn_codeblock",
    "body": [
      "```$1:$2",
      "$3",
      "```",
    ],
    "description": "Code block with filename in zenn.",
  },
  "zenn_codeblock-diff": {
    "scope": "markdown",
    "prefix": "zenn_codeblock-diff",
    "body": [
      "```diff $1:$2",
      "$3",
      "```",
    ],
    "description": "Code block with filename and diff in zenn.",
  },
  "zenn_message_warning": {
    "scope": "markdown",
    "prefix": "zenn_message_warning",
    "body": [
      ":::message",
      "$1",
      ":::",
    ],
    "description": "Warning message in zenn.",
  },
  "zenn_message_alert": {
    "scope": "markdown",
    "prefix": "zenn_message_alert",
    "body": [
      ":::message alert",
      "$1",
      ":::",
    ],
    "description": "Alert message in zenn.",
  },
  "zenn_details": {
    "scope": "markdown",
    "prefix": "zenn_details",
    "body": [
      ":::details $1",
      "$2",
      ":::",
    ],
    "description": "Details in zenn.",
  },
  "zenn_url_card": {
    "scope": "markdown",
    "prefix": "zenn_url_card",
    "body": [
      "@[card]($1)",
    ],
    "description": "Url card in zenn.",
  },
  "zenn_github": {
    "scope": "markdown",
    "prefix": "zenn_github",
    "body": [
      "$1#L1-L3",
    ],
    "description": "GitHub text in zenn.",
  },
  "zenn_title": {
    "scope": "markdown",
    "prefix": "zenn_title",
    "body": [
      "---",
      "title: \"$1\"",
      "---",
      "",
      "# $2",
    ],
    "description": "Article title in zenn.",
  },
  "zenn_published_at": {
    "scope": "yaml",
    "prefix": "zenn_published_at",
    "body": [
      "published_at: $CURRENT_YEAR-$CURRENT_MONTH-$CURRENT_DATE 08:00",
    ],
    "description": "Add time of publication to zenn article.",
  },
}

Dockerコンテナをビルドしアタッチする

VS Codeで"Ctrl+p"を押してコマンドパレットを開き、Dev Containers: Open Folder in Containerを実行する。これにより、devcontainer.jsonに従って自動でDockerfileがビルドされ、作成されたコンテナにVS Codeがアタッチされる。

Zenn cliのセットアップを実行する

最初だけ、zenn cliのセットアップコマンドを実行する。

npx zenn init

すると、articlesbooksなど必要なファイル群が生成される。

動作確認

記事を作る

article my-first-article

本を作る

book my-first-book

プレビューする

preview

http://localhost:8000/へアクセスすると、プレビューが表示される。

GitHubとZennを連携する

Zenn公式の出している記事に従って、GitHubにリポジトリを作成し、Zennと連携しておく。

連携に成功すると以下の画面になる。

GitHubとZennの連携に成功した画面
GitHubとZennの連携に成功した画面

GitHubにpushする

ここまで作成したローカルプロジェクトをgit管理下に置く。

git init

これまでの変更をaddしてcommitする。下記は例。

git add .
git commit -m ":tada: Initial commit"

リモートリポジトリを先ほどZennと連携したリポジトリに設定し、pushする。

git remote add origin https://github.com/NakuRei/zenn-docs.git
git branch -M main
git push -u origin main

初回以降はgit pushだけでリモートリポジトリにpushできる。

新しい記事を作成してpushし、Zennにデプロイされていれば成功。

参考文献・URL

脚注
  1. ちなみに、筆者は環境に必須の拡張機能だけdevcontainer.jsonextensionsに記述し、任意の拡張機能は.vscode/extensions.jsonに記述している。 ↩︎

Discussion