虎の穴開発室ブログ

虎の穴ラボ株式会社所属のエンジニアが書く技術ブログです

MENU

Markdownでカスタム構文が使える技術同人誌の執筆環境を作ってみた!!

※ 本記事は予約投稿です。

こんにちは、虎の穴ラボの古賀です。

みなさま連休中はいかがお過ごしでしょうか? おそらく、私は家族サービスに勤しんでいます。

この記事は「虎の穴ラボ夏のアドベントカレンダー」16日目の記事です。
15日目はMさんによる「見た目でわかるビジュアルネタ5連発」が投稿されました。
17日目は辻村さんによる「本番環境に寄り添った開発用Docker環境の構築手法」が投稿されます。こちらもぜひご覧ください。

私のブログのテーマは「取り組みたい技術の成果発表」になります。

目次

取り組みたい技術の成果発表とは?!

虎の穴ラボでは半期に一度、みんなが取り組みたい技術を募集します。

フルリモートになって誰が何に興味をもっているかわかりづらくなったため、虎の穴ラボで行っている取り組みになります。

詳しくは、下記の記事を御覧ください。

toranoana-lab.hatenablog.com

その「みんなが取り組みたい技術」に記載したアンケートについて、成果を発表するのが今回のブログになります。

私は「技術書典13は新刊だしたい」と記載しました。

はじめに

私は、最近はプライベートで技術書典11から技術同人誌を出しています。

技術書典13にもなにか新刊を出したくて案も固まってきましたが、VivlioStyleでHTMLを書く手間はなるべく省きたいので、今回の「Markdownでカスタム構文が使える同人誌執筆の環境」を作ることにしました。

VivlioStyleを使うと、馴染みのあるMarkdown(HTML/CSS)で同人誌が作れるので、Create Bookの環境をベースにしました。

これで新刊はまだだせてないですが、新刊を出すためのプロセスの成果として発表させていただきたいと思います。

vivliostyle.org

全体像

今回、作った環境はこのような全体像になります。

今後もカスタム構文は増やしたい、かつ引数を渡して動作を変えたりしたいので、 カスタム構文をHTMLに置き換えるところは既存のテンプレートエンジンの「Handlebars.js」を使って、汎用性をもたせて環境構築の手間も省きました。

handlebarsjs.com

今回は、新しくチャット風の吹き出しを挿入するカスタム構文を作りました。 下記のようなMarkdownファイルを作ります。

# なにかのタイトルを入れる

こんにちは!

{{chat
    (chat-header 'はじまりはじまりー')
    (chat-left 'さんぷるだよ!' 'めいどちゃ' 'balloon-indigo-200' './../images/maid-engineers/1_Introduction_reversed_icon.png')
    (repeat-chat-left 'さんぷるだよ!' 'balloon-indigo-200')
    (chat-right 'よろしくお願いします~!' 'めいどちゃ' 'balloon-fuchsia-200' './../images/maid-engineers/2_Introduction2_reversed_icon.png')
    (repeat-chat-right '<img src="./../images/screenshots/AMEasagirirabe_TP_V.jpg">' 'balloon-fuchsia-200')
}}

カスタム構文にはシングルクォーテーション区切りで引数も指定できます。 上記の(chat-leftから始まる構文では、吹き出しの内容と吹き出しの色、チャットアイコンの画像ファイルのパスなどを引数で指定できるようにしました。

その後、カスタム構文をHTMLに変換する処理が動き、下記のMarkdown+HTMLのファイルが生成されます。

# なにかのタイトルを入れる

こんにちは!


<ul class="chat">
  
<li class="chat-header">
  <div>はじまりはじまりー</div>
</li>

<li class="chat-left">
  <div class="chat-faceicon">
    <img src="./../images/maid-engineers/1_Introduction_reversed_icon.png" />
    <span class="chat-faceicon-name">めいどちゃ</span>
  </div>
  <div class="chat-contents">
    <div class="balloon-indigo-200">
      <div class="trianle-left"></div>
      <div class="balloon-contents">さんぷるだよ!</div>
    </div>
  </div>
</li>

<li class="chat-left-invisible">
  <div class="chat-faceicon">
    <img />
    <span class="chat-faceicon-name"></span>
  </div>
  <div class="chat-contents">
    <div class="balloon-indigo-200">
      <div class="trianle-left"></div>
      <div class="balloon-contents">さんぷるだよ!</div>
    </div>
  </div>
</li>

<li class="chat-right">
  <div class="chat-contents">
    <div class="balloon-fuchsia-200">
      <div class="trianle-right"></div>
      <div class="balloon-contents">よろしくお願いします~!</div>
    </div>
  </div>
  <div class="chat-faceicon">
    <img src="./../images/maid-engineers/2_Introduction2_reversed_icon.png" />
    <span class="chat-faceicon-name">めいどちゃ</span>
  </div>
</li>

<li class="chat-right-invisible">
  <div class="chat-contents">
    <div class="balloon-fuchsia-200">
    <div class="trianle-right"></div>
    <div class="balloon-contents"><img src="./../images/screenshots/AMEasagirirabe_TP_V.jpg"></div>
  </div>
  </div>
  <div class="chat-faceicon">
    <img src="./../images/happy.png" />
    <span class="chat-faceicon-name"></span>
  </div>
</li>

</ul>

Markdown+HTMLのファイルをVivlioStyleで読み込むと、このように出力されます。

ファイルの更新を検知してから{{chat 〜}}のカスタム構文を「Handlebars.js」でHTMLに置き換えていて、 その置き換えをVivlioStyleも検知してリロードしてくれます。

その結果、ホットリロード的な動きもできています。

カスタム構文が使える同人誌執筆の環境を作る

Tailwind CSSの導入をする

まず、HTMLのCSS記述量を削減するために導入しました。

下記の様なpackage.jsonのスクリプトを書いて、CSSをTailwind CSSで記述できるようにしました。

{
  // 省略
  "scripts": {
    "printed": "vivliostyle build --press-ready --style ./css/tailwind.dist.css",
    "preview": "vivliostyle preview --style ./css/tailwind.dist.css",
    "dev:vv": "npm run preview",
    "dev:tw": "npx tailwindcss -i ./css/tailwind.src.css -o ./css/tailwind.dist.css --watch --jit",
    "build:vv": "vivliostyle build --style ./css/tailwind.dist.css",
    "build:tw": "npx tailwindcss -i ./css/tailwind.src.css -o ./css/tailwind.dist.css --jit",
  },
  // 省略

上記の〜:twnpx tailwindcss 〜の部分では、Tailwind CLIでTailwind CSSの記述を通常のCSSへ変換しています。

VivlioStyleは、Webで使えるCSSがそのまま使えないこともあります。

その場合は別途調べる必要がありますが、CSS記述量の削減という目的は達成できました。

テンプレートエンジンのHandlebars.jsについて

今回使うHandlebars.jsはMustache.jsとほぼ互換性があるテンプレートエンジンです。 Mustache.jsに比べていくつか機能が追加されていて、その中の構文をネストする機能({{}}の中に()でさらに子供の構文が書ける機能)を使いたいため、こちらの方を選択しました。

github.com

github.com

ちなみに、Mustache.jsはAMPのテンプレートエンジンとして利用されています。

今回はHandlebars.jsでカスタム構文をHTMLに変換する処理を作ります。

Handlebars.jsのカスタム構文を作る

下記のHandlebars.registerHelper(〜部分がカスタム構文の定義で、第一引数の名前で{{chat 〜}}などと定義すると、第二引数の処理で返したHTMLに置き換わります。

Handlebars.registerHelper('chat', function (...children) {
  return new Handlebars.SafeString(`
<ul class="chat">
  ${children.length > 0 ? children.slice(0, children.length - 1).join('') : ''}
</ul>
`)
})

このchatカスタム構文は、ulタグの中に子供のカスタム構文の結果を追加するようなイメージになります。

Handlebars.jsでカスタム構文をHTMLに変換する

preCompile関数で対象のファイルをHandlebars.jsでコンパイル(カスタム構文をHTMLに変換したMarkdownを作成)します。

const preCompile = (src, dist) => {
  const markdown = fs.readFileSync(src)
  const template = Handlebars.compile(markdown.toString())
  const result = template({})
  fs.writeFileSync(dist, result)
}

ファイルの更新を監視する

コンパイルが必要なMarkdownファイルは、定数に定義しておきます。 1冊の本でMarkdownファイルは多くても10個ぐらいなので、自動的にコンパイル対象にする処理はないです。

Node.jsのfs.watch関数で、ファイルの更新を監視できます。

const preCompilerList = [
  { src: 'src/introduction.md', dist: 'docs/introduction.dist.md'},
  { src: 'src/characters.md', dist: 'docs/characters.dist.md'},
]

// ファイルの更新監視を設定する
preCompilerList.forEach(({ src, dist }) => {
  preCompile(src, dist)
  console.log('complete!!! init preCompile.', src, dist)
  fs.watch(src, { persistent: true, recursive: false }, function(event, filename) {
    preCompile(src, dist)
    console.log('complete!!! preCompile.', event + ' to ' + filename)
  })  
})

ソースコード

下記が今回、カスタム構文をHTMLに変換するために作成したpre-compiler.mjsのソースコードになります。

// pre-compiler.mjs
import Handlebars from 'handlebars'
import fs from 'fs'

const preCompilerList = [
  { src: 'src/introduction.md', dist: 'docs/introduction.dist.md'},
  { src: 'src/characters.md', dist: 'docs/characters.dist.md'},
]

// カスタム構文の定義をする
// chat
Handlebars.registerHelper('chat', function (...children) {
  return new Handlebars.SafeString(`
<ul class="chat">
  ${children.length > 0 ? children.slice(0, children.length - 1).join('') : ''}
</ul>
`)
})
// chat-header
Handlebars.registerHelper('chat-header', function (title) {
  return new Handlebars.SafeString(`
<li class="chat-header">
  <div>${title}</div>
</li>
`)
})
// chat-left
Handlebars.registerHelper('chat-left', function (message, name, className, faceiconPath) {
  return new Handlebars.SafeString(`
<li class="chat-left">
  <div class="chat-faceicon">
    <img src="${faceiconPath}" />
    <span class="chat-faceicon-name">${name}</span>
  </div>
  <div class="chat-contents">
    <div class="${className}">
      <div class="trianle-left"></div>
      <div class="balloon-contents">${message}</div>
    </div>
  </div>
</li>
`)
})
// chat-right
Handlebars.registerHelper('chat-right', function (message, name, className, faceiconPath) {
  return new Handlebars.SafeString(`
<li class="chat-right">
  <div class="chat-contents">
    <div class="${className}">
      <div class="trianle-right"></div>
      <div class="balloon-contents">${message}</div>
    </div>
  </div>
  <div class="chat-faceicon">
    <img src="${faceiconPath}" />
    <span class="chat-faceicon-name">${name}</span>
  </div>
</li>
`)
})
// repeat-chat-right
Handlebars.registerHelper('repeat-chat-right', function (message, className) {
  return new Handlebars.SafeString(`
<li class="chat-right-invisible">
  <div class="chat-contents">
    <div class="${className}">
    <div class="trianle-right"></div>
    <div class="balloon-contents">${message}</div>
  </div>
  </div>
  <div class="chat-faceicon">
    <img src="./../images/happy.png" />
    <span class="chat-faceicon-name"></span>
  </div>
</li>
`)
})
// repeat-chat-left
Handlebars.registerHelper('repeat-chat-left', function (message, className) {
  return new Handlebars.SafeString(`
<li class="chat-left-invisible">
  <div class="chat-faceicon">
    <img />
    <span class="chat-faceicon-name"></span>
  </div>
  <div class="chat-contents">
    <div class="${className}">
      <div class="trianle-left"></div>
      <div class="balloon-contents">${message}</div>
    </div>
  </div>
</li>
`)
})
// page-break
Handlebars.registerHelper('page-break', function () {
  return new Handlebars.SafeString(`
<div style="break-before: page;"></div>
`)
})

console.log('complete!!! register helpers.')

const preCompile = (src, dist) => {
  const markdown = fs.readFileSync(src)
  const template = Handlebars.compile(markdown.toString())
  const result = template({})
  fs.writeFileSync(dist, result)
}

// ファイルの更新監視を設定する
preCompilerList.forEach(({ src, dist }) => {
  preCompile(src, dist)
  console.log('complete!!! init preCompile.', src, dist)
  fs.watch(src, { persistent: true, recursive: false }, function(event, filename) {
    preCompile(src, dist)
    console.log('complete!!! preCompile.', event + ' to ' + filename)
  })  
})

さいごに、package.jsonにpre-compiler.mjsを実行する処理を追記すれば完成です。

{
  // 省略
  "scripts": {
    "dev": "concurrently \"npm run pre-compiler:debug\" \"npm run dev:vv\" \"npm run dev:tw\"",
    "build": "concurrently \"npm run pre-compiler\" \"npm run build:vv\" \"npm run build:tw\"",
    "pre-compiler:debug": "nodemon node --experimental-modules pre-compiler.mjs",
    "pre-compiler": "node --experimental-modules pre-compiler.mjs"
    // 省略
  },
  // 省略

npm run devコマンドやnpm run buildで、pre-compiler.mjsとVivlioStyleのプレビュー、Tailwind CSSのビルドが並行で動くようになります。

※ 並行で動かすためにconcurrentlyモジュールを使っています。

まとめ

最後まで読んでいただき、ありがとうございます。

技術書典13に出す予定の同人誌については、最近、誘惑に負けて深夜までサンブレイク的な狩りにいってしまったり、日中は子育て等でなかなか時間が取れなく、 未完成のままいつの間にかあと残り2か月を切った状況ですが、ちゃんと完成させたいと考えています。

わたしは、連休中は同人誌を書きます! 皆様も良い週末をお過ごしください!

P.S.

虎の穴ラボでは、私たちと一緒に新しいオタク向けサービスを作る仲間を募集しています。
詳しい採用情報は以下をご覧ください。

yumenosora.co.jp