Vivliostyleが拓くCSS組版の可能性

Vivliostyleに特化したMarkdown - VFMの使い

本記事ではVivliostyle用のMarkdownとして開発されている、VFM(Vivliostyle Flavored Markdown)について解説します。

MarkdownとGFM⁠VFMの関係

構造化された文書を記述するためのマークアップ言語としてHTML/XMLがあります。これらは優れた表現力を持つ反面、記法として手動で書くには煩雑です。この課題を解決するための簡潔なマークアップ言語として、Markdownが登場しました。

以下はHTMLとMarkdownで同じ文書を記述した比較例です。

HTML
<h1>Title</h1>
<p>The quick brown fox jumps over the lazy dog.</p>
Markdown
# Title

The quick brown fox jumps over the lazy dog.

MarkdownはHTML/XMLへの変換を想定しています。そのため人間が書く際には簡潔なMarkdownを採用し、表示や印刷など複雑で厳密な構造化が必要な場合はHTML/XMLに変換する運用が一般的です。

ただしMarkdownには標準仕様がありません。そのため方言が乱立することになりました。この課題を解決するためにCommonMarkが提案され、現在はこれをベースにしたGFM(GitHub Flavored Markdown)が広く普及しています。

ここでVFM (Vivliostyle Flavored Markdown)の登場です。

Vivliostyleは組版の構造定義にHTMLを採用しています。しかし前述のように人間が記述する難しさが課題でした。そこでこれを解決しつつ、書籍組版ができる豊富な表現力を持ったHTMLを生成するためにVFMが提案されました。開発はuetchyさんにより2020/1に開始され、現在は主に筆者akabekoがメンテナンスしています。

VFMを提案するにあたり、完全な新規方言とせずGFMをベースとすることにしました。広く普及しているGFMの知見を流用でき、そのうえで必要に応じて構文や機能を追加する方針としています。

VFM の拡張構文

VFMはGFMをベースに拡張構文を追加しています。その中でもとりわけ重要で特徴的なものを紹介します。

すべての構文について知りたい場合は以下を参照してください。

文書のメタデータ定義 - Frontmatter

Markdownは構造化された文書を記述できますが、文書そのもののメタデータを記述する構文がありません。Markdown単体で利用するなら問題ありませんが、HTML/XML変換などの二次利用では不便です。

例えば著者の異なる複数のMarkdownがあり、それらから著者の索引を生成したいとします。その場合、Markdownとは別に著者を定義する仕組みが必要です。

この課題を解決するためにVFMはFrontmatterを提供します。------の区間にYAML形式でメタデータを記述可能です。以下の例では文書のタイトルと著者を定義しています。

---
title: "第2回 Vivliostyleに特化したMarkdown - VFMの使い方"
author: "akabeko"
---

これを HTML に変換すると以下のようになります。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>第2回 Vivliostyleに特化したMarkdown - VFMの使い方</title>
    <meta name="author" content="akabeko" />
  </head>
  <body></body>
</html>

HTMLとしてメタデータが出力されるため、二次利用が容易になります。<head>以外にも<html><body>の属性を定義できます。これを利用することで文書ごとに異なるスタイルを定義することも可能です。

Vivliostyle CLIには複数文書をMarkdownファイルとして分割し、それらを結合して印刷する機能があります。その際、Frontmatterは文書ごとに異なるメタデータを定義する手段として有用です。

他にも強力な機能があります。文書ごとのカスタマイズが必要な場合、まずはFrontmatterから検討することをお勧めします。

脚注 - Footnotes

FootnotesはPandocの脚注を踏襲した構文です。

VFMはGitHubリポジトリで開発しています[^1]

[^1]: [VFM](https://github.com/vivliostyle/vfm)

これは以下のHTMLに変換されます。

<p>
  VFMはGitHubリポジトリで開発しています<a id="fnref1" href="#fn1" class="footnote-ref" role="doc-noteref"><sup>1</sup></a>
</p>
<section class="footnotes" role="doc-endnotes">
  <hr />
  <ol>
    <li id="fn1" role="doc-endnote">
      <a href="https://github.com/vivliostyle/vfm">VFM</a
      ><a href="#fnref1" class="footnote-back" role="doc-backlink"></a>
    </li>
  </ol>
</section>

HTMLとしては複雑ですが、文中の<a>と脚注<section>内の項目を相互リンクしているだけです。複数の脚注を考慮して、属性値の番号は1〜Nで自動採番されます。

数式 - Math equation

Math equationはMathJaxと組み合わせて数式を組版するための構文です。

MathJaxの数式はインラインとディスプレイの2種類です。VFMでは$囲いならインライン数式、$$をディスプレイ数式として扱います。囲われた区間にはTeX/LaTeX形式で数式を記述してください。

inline:$x = y$

display: $$1 + 1 = 2$$

これは以下のHTMLに変換されます。

<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <script async src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.9/MathJax.js?config=TeX-MML-AM_CHTML"></script>
  </head>
  <body>
    <p>inline: <span class="math inline" data-math-typeset="true>"\(x = y\)</span></p>
    <p>display: <span class="math display" data-math-typeset="true">$$1 + 1 = 2$$</span></p>
  </body>
</html>

VFMでは標準でMath equationが有効化されており、文書内にこの記法を検出するとCDN配布されたMathJax参照<script>も出力します。Math equationが不要な場合はFrontmatterのvfm/mathで無効化してください。

---
vfm:
  math: false
---

この機能は数式を含む論文執筆などに役立つでしょう。基本的な文章はMarkdown、数式が必要になったら部分的にTeX/LaTeXで記述できます。

ルビ - Ruby

RubyはHTMLの<ruby>要素を記述するための構文です。Markdown方言としてもいくつか先行例があり、VFMではでんでんマークダウンの構文を参考にしました。

This is {Ruby|ルビ}

これは以下のHTMLに変換されます。

This is <ruby>Ruby<rt>ルビ</rt></ruby>

ルビは意外に利用したくなるものです。特に日本語は表意文字である漢字を多用するため、適切な表音を伝えるためにも読み仮名の併記は有用です。

HTML埋め込み - Raw HTML

GFM+VFM拡張構文でも表現力が足りない場合、HTMLをそのまま埋め込めます。

<div class="custom">
  <p>Hey</p>
</div>

VFMによるHTML埋め込み処理はブロック単位です。そのため空行を挟むことでMarkdownと組み合わせられます。

<div class="custom">

# Heading

</div>

これは以下のHTMLに変換されます。

<div class="custom">
  <section class="level1" aria-labelledby="heading">
    <h1 id="heading">Heading</h1>
  </section>
</div>

よくある利用例としては<table>埋め込みがあります。Markdown のテーブル構文がサポートするのは簡素なヘッダーとボディ構造だけです。そのため<th><tbody>側に配置したり、rowspancolspanによる結合を指定できません。このような場合、HTML埋め込みを利用することで<table>そのままの表現力を持たせられます。

セクション分け - Sectionization

見出しのレベルを基準に、階層化されたセクションを生成します。

  • 見出しのレベル(見出し行の先頭の#の数)に応じてセクションを分けて階層化します
  • 見出しの行が#ではじまり、同数以上の#で終わる場合はセクションを分けません
    • ### Not Sectionize ###(同じ数の#で囲まれている)の場合、セクションを分けません
    • ### Sectionize ##(閉じの#の数が足りない)の場合、セクションを分けます
  • #だけからなる行により、#の数と一致するレベルのセクションを終了させます
    • 例:### Heading 3で開始したセクションは###で終了させます
  • 親がblockquoteの場合はセクションを分けません
  • 見出しのレベルへ一致するように、セクションのlevelNクラスを設定します
  • 見出しのid属性値をセクションのaria-labelledby属性へ値をコピーします

複雑なルールに見えますが、基本的には文書構造の階層にそった見出しを書くことで、そのままセクション分けと階層化がおこなわれます。

# Plain

# Introduction {#intro}

# Welcome {.title}

# Level 1

## Level 2

### Level 3

##

Level 2 was ended by `##`.

## Not Sectionize {.just-a-heading} ##

> # Not Sectionize in Blockquote

これは以下のHTMLに変換されます。

<section class="level1" aria-labelledby="plain">
  <h1 id="plain">Plain</h1>
</section>
<section class="level1" aria-labelledby="intro">
  <h1 id="intro">Introduction</h1>
</section>
<section class="level1" aria-labelledby="welcome">
  <h1 class="title" id="welcome">Welcome</h1>
</section>
<section class="level1" aria-labelledby="level-1">
  <h1 id="level-1">Level 1</h1>
  <section class="level2" aria-labelledby="level-2">
    <h2 id="level-2">Level 2</h2>
    <section class="level3" aria-labelledby="level-3">
      <h3 id="level-3">Level 3</h3>
    </section>
  </section>
  <p>Level 2 was ended by <code>##</code>.</p>
  <h2 class="just-a-heading" id="not-sectionize">Not Sectionize</h2>
  <blockquote>
    <h1 id="not-sectionize-in-blockquote">Not Sectionize in Blockquote</h1>
  </blockquote>
</section>

見出しのレベルと文書の階層構造を一致させ、HTMLの<section>として階層化されます。CSSクラスとしてもレベルが指定されるため、階層ごとにスタイルを定義しやすくなります。

以下はHTMLサンプルに対するCSSセレクターの定義例です。

body > section {
}

section:has(> #intro) {
}

section:has(> h1.title) {
}

.level1 {
}
.level2 {
}

blockquote > h1 {
}

書籍の組版では章節項のような階層構造がよく登場します。これらには個別のスタイルを定義したくなるものです。例えば章から節、項へと下るにつれて文字サイズや配色、インデントの強弱を調整することがあります。VFMのセクション分けはこのような要求に応えるための機能です。

VFMの利用方法

VFMはVivliostyle CLI経由で間接的に利用する方法と、Node.jsパッケージとして直接利用する方法があります。

間接利用については以下を参照してください。Create Bookで作成されたプロジェクトにはVivliostyle CLIとVFMが含まれ、CLIが自動的にVFMによる変換を実行します。

本記事では普段、あまり触れられることのない直接利用について解説します。VFMはNode.jsパッケージとして配布されています。

VFMが提供する機能としてはCLIとAPIがあります。

CLI

CLIを試したい場合はNode.jsとnpmコマンドが利用可能な環境で、以下のようにグローバル インストールしてください。

$ npm install -g @vivliostyle/vfm

これでターミナルからvfmコマンドが利用可能になります。試しに以下を実行してvfmのヘルプ表示を確認してみてください。

$ vfm --help

vfmコマンドのデータ入力形式はファイルと文字列に対応しています。ファイルの場合は引数にパスを指定してください。

$ vfm index.md

文字列は標準入力から渡します。

$ echo "# Hello" | vfm

これらを実行すると標準出力にHTMLが出力されます。

CLIは|で結果を他のコマンドに渡したり、>によりファイルへ書き出すことも可能です。既存のCLIツールやシェルスクリプトにMarkdown→HTML変換を組み込みたくなった際、利用してみてください。

API

APIを利用したい場合はNode.jsプロジェクトにnpmコマンドでインストールしてください。

$ npm install @vivliostyle/vfm

MarkdownをHTML文字列に変換したい場合はstringify関数を利用します。

import { stringify } from "@vivliostyle/vfm";

console.log(
  stringify(`
# はじめに

{Vivliostyle|ビブリオスタイル}の世界へようこそ。
`)
);

このスクリプトを実行すると、変換されたHTMLが標準出力に表示されます。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>はじめに</title>
    <meta name="viewport" content="width=device-width, initial-scale=1" />
  </head>
  <body>
    <section class="level1" aria-labelledby="はじめに">
      <h1 id="はじめに">はじめに</h1>
      <p>
        <ruby>Vivliostyle<rt>ビブリオスタイル</rt></ruby>の世界へようこそ。
      </p>
    </section>
  </body>
</html>

stringify関数は内部でVFM関数を呼び出しています。

/**
 * Convert markdown to a stringify (HTML).
 * @param markdownString Markdown string.
 * @param options Options.
 * @returns HTML string.
 */
export function stringify(
  markdownString: string,
  options: StringifyMarkdownOptions = {},
  metadata: Metadata = readMetadata(markdownString)
): string {
  const processor = VFM(options, metadata);
  const vfile = processor.processSync(markdownString);
  debug(vfile.data);
  return String(vfile);
}

stringify関数を呼び出すとunifiedProcessorを返します。VFMを更に拡張して構文を追加したい場合、Processoruseメソッドを利用してください。

VFMの公開関数としてもうひとつ、Markdownのメタデータを読むためにreadMetadata関数を提供しています。試しに本記事のFrontmatterサンプルを読み込んでみましょう。

import { readMetadata } from "@vivliostyle/vfm";

console.dir(
  readMetadata(
    `
---
title: "第2回 Vivliostyleに特化したMarkdown - VFMの使い方"
author: "akabeko"
---
`
  ),
  { depth: null }
);

すると以下の出力を得られます。

{
  title: '第2回 Vivliostyleに特化したMarkdown - VFMの使い方',
  meta: [
    [
      { name: 'name', value: 'author' },
      { name: 'content', value: 'akabeko' }
    ]
  ]
}

メタデータは文書横断の処理に役立ちます。例えば索引や奥付の生成、独自にmetaとして定義した値で条件分岐するなどが考えられます。ブログを自作する際に記事のカテゴリーやタグを定義して一覧ページ生成に応用するのもよさそうです。

また、メタデータはstringify関数の第3引数として指定可能です。そのためstringifyによるHTML変換をカスタマイズできます。

VFMの設計と課題

記事の締めくくりとして、VFMの設計と課題について解説します。設計に興味がある、またはVFMの開発に参加したい場合は参考にしてください。

AST処理

VFMのMarkdownとHTML処理はunifiedに基づきます。unifiedは様々な言語処理を統一されたAST(Abstract Syntax Tree = 抽象構文木)として扱うためのプラットフォームです。ASTは通常の構文木から不要な情報を取り除いたものです。例えばMarkdownの見出しは以下となります。

# Title

ASTでは見出しを示す行頭の# は不要なので取り除かれ、代わりに構文処理として便利な情報が追加されます。

{
  type: "heading",
  depth: 1,
  children: [{ type: "text", value: "Title" }]
}

unifiedをNode.jsパッケージとして利用する場合、ASTはこのようにJavaScriptのオブジェクトとして表現されます。そのため参照や操作も直感的におこなえます。とはいえすべてを自前処理するのは難しいため、VFMでは以下を利用しています。

  • remarkjs/remark
    • MarkdownをMDAST(MarkdownのAST)として処理
    • GFMやFrontmatterなどもremrakプラグインを利用
  • remarkjs/remark-rehype
    • MDASTをHAST(HTMLのAST)に変換して処理
    • remarkと組み合わせて利用
  • uniste
    • unist = Universal Syntax Tree
    • MDASTやHAST操作を便利にするためのライブラリー群
    • 処理対象のASTから特定の構文を探して置換するなどの機能を提供

remarkはMarkdownの拡張構文を追加するためにプラグイン機能を提供しており、VFMでもそれに基づき実装された構文があります。しかしこの方法には課題もあります。

VFM の設計的な課題

unifiedとremarkを組み合わせて利用する場合、Markdownの構文拡張は以下に大別されます。

  1. remarkプラグインとして実装
  2. unifiedとしてMDAST処理を自前で実装

VFMでは基本的に1を採用しています。しかしVFMのそれはremark 12までの設計となっており13以降には対応していません。remark 13からMarkdwon解析エンジンが刷新されており、結果としてremarkプラグインの互換性もなくなりました。プラグインのインターフェース変更も相当に大きく、再設計が必要なレベルです。そのため開発リソースの限られているVFMとしては追従が難しい状況となっています。

この課題について、構文拡張を前述の方針2へ転換することで解決を図ろうとしています。remarkはそれ自体が提供する機能と標準プラグイン利用に限定し、remarkプラグインとして実装した構文拡張はMDASTを自前で処理します。

例えば本記事でVFM構文として紹介したルビ。これはremarkプラグインとして実装されていますが、MDASTの見出しや段落のvalueを処理してルビを入れ子にします。大まかには以下のようになるでしょう。MDASTとしての処理なので、remarkへ依存しなくて済みます。

  1. MDASTからtype: headingなどを探す
  2. childrenからtype: "text"を探す
  3. valueからルビ構文を探す
  4. children[ルビ検出直前text, ルビ, ルビ検出以降text]に調整

VFMは開発者を常に募集中です。本記事で興味を持たれた方、以下のリポジトリでお待ちしています!

おすすめ記事

記事・ニュース一覧