♦️

Laikaを使用したScala製ドキュメントツールの多言語対応

2024/10/18に公開

はじめに

今回は筆者が作成しているOSSプロジェクトのドキュメントをLaikaに書き換えた際に、多言語対応のドキュメントを作成する方法を調査した結果を共有します。

多言語対応と記載していますが、i18nなどを使用して自動的に多言語対応を行うわけではなく、Laikaの標準機能を使用してドキュメントを言語ごとに作成・管理する方法についての説明となりますので、ご了承ください。

今回作成した内容は以下リポジトリで公開しています。

https://github.com/takapi327/laika-sandbox

Laikaとは

Laikaは、Scala開発者のための強力で柔軟なドキュメント生成ツールです。軽量マークアップ言語を変換し、サイトやe-book (EPUB & PDF) を生成する多機能なツールキットとして設計されています。

https://typelevel.org/Laika/

LaikaはTypelevelプロジェクトの一部であり、TypelevelはさまざまなScalaのライブラリを提供するコミュニティです。

https://typelevel.org/

Laikaには、次のような特徴があります。

豊富なフォーマットサポート

  • Markdown(GitHub Flavor含む)
  • reStructuredText
  • HTML、EPUB、PDFなど多彩な出力形式

高度な機能

  • 内部リンクの自動検証
  • ナビゲーションツリーや目次の自動生成
  • 多言語シンタックスハイライト

柔軟なカスタマイズ

  • テーマやテンプレートのカスタマイズ
  • 独自のマークアップ言語や出力形式の追加が可能

LaikaはAPIも提供しており、ScalaのコードからLaikaを使用してドキュメントを生成することもできます。

import laika.api.*
import laika.format.*

val transformer = Transformer
  .from(Markdown)
  .to(HTML)
  .using(Markdown.GitHubFlavor)
  .build

このように、Laikaを使用することで、Scalaのコードからドキュメントを生成することも可能です。

val result = transformer.transform("hello *there*")
// result: Either[errors.TransformationError, String] = Right(
//   "<p>hello <em>there</em></p>"
// )

また、Laikaはsbtプラグインも提供しており、sbtプロジェクトからLaikaを使用してドキュメントを生成することもできます。

今回はこのsbtプラグインを使用して、Laikaを使った多言語対応のドキュメントを作成する方法について説明します。

Laikaの使い方は以下ブログ記事も参考になります。

https://blog.3qe.us/entry/2024/02/15/220718

プロジェクトの作成

まず、公式ドキュメントを参考にLaikaを使用して多言語対応のドキュメントを作成するためのプロジェクトを作成します。

sbt自体初めての方は以下の記事も参考になるかと思います。

https://blog.3qe.us/entry/2024/04/17/213142

sbtプロジェクトのproject/plugins.sbtにLaikaのsbtプラグインを追加します。

addSbtPlugin("org.typelevel" % "laika-sbt" % "1.2.0")

次に、プロジェクトのbuild.sbtでプラグインを有効にします。

enablePlugins(LaikaPlugin)

これでプロジェクトの準備が整いました。

ドキュメントの作成

デフォルトではLaika sbtプラグインはsrc/docsをドキュメントディレクトリだと認識するため、src/docsディレクトリを作成します。

$ mkdir -p src/docs

src/docsディレクトリにindex.mdを作成し、以下のように記述します。

# Hello, Laika!

実際にドキュメントを生成してみましょう。

$ sbt laikaSite

target/docs/siteディレクトリにHTML形式のドキュメントが生成されます。生成されたドキュメントをブラウザで開くことでドキュメントの内容を確認することができますが、Laikaにはプレビューサーバーも用意されています。

$ sbt laikaPreview

このコマンドを実行すると、http://localhost:4242でプレビューサーバーが起動します。ブラウザでhttp://localhost:4242にアクセスすることでドキュメントを確認することができます。

このプレビューサーバーは、ドキュメントの変更を検知して自動的に再生成するため、ドキュメントの作成を効率的に行うことができます。

先ほどsrc/docs/index.mdに記述した内容がHTML形式に変換されましたが、Laikaはファイルの拡張子によって処理が異なります。

  • マークアップ・ファイル: 拡張子が.md.markdown、または.rstのファイルは、ファイル名はそのままに.htmlへ(出力フォーマットに応じて)レンダリングされます。
  • 設定ファイル: 各ディレクトリには、ナビゲーションの順番や章のタイトルなどを指定するためのオプションのdirectory.confファイルを含めることができます。
  • テンプレートファイル: default.template.<suffix>という名前で、サフィックスが出力形式 (例えば.html) に一致するデフォルトのテンプレートをディレクトリごとに提供することができます。
  • 静的ファイル: CSS、JavaScript、画像など、他のすべてのファイルは、同じディレクトリ構造と同じファイル名でターゲットにコピーされます。

また、Laikaはsrc/docs/ディレクトリに別のディレクトリを作成することで、章分けを行うこともできます。

$ mkdir -p src/docs/chapter1
$ touch src/docs/chapter1/index.md

試しにsrc/docs/chapter1/index.mdに以下の内容を記述してみましょう。

# はじめに

再度sbt laikaSiteを実行すると、src/docs/chapter1/index.mdがHTML形式に変換され、target/docs/site/chapter1/index.htmlに出力されます。

$ sbt laikaSite

プレビューサーバーを起動して、http://localhost:4242/chapter1にアクセスすることで、src/docs/chapter1/index.mdの内容を確認することができます。

$ sbt laikaPreview

Laikaはディレクトリ構造からサイドナビゲーションを自動生成するため、章分けを行うことでドキュメントのナビゲーションを行いやすくすることができます。

しかし、このサイドナビゲーションはデフォルトでは、ディレクトリ名を大文字にしてそのまま表示し、そこに含まれるコンテンツはドキュメントのタイトルが表示されるだけです。
また、表示される順番もデフォルトではディレクトリ名のアルファベット順になります。

  • 設定ファイル: 各ディレクトリには、ナビゲーションの順番や章のタイトルなどを指定するためのオプションのdirectory.confファイルを含めることができます。

このようなデフォルトの挙動を変更するためには、directory.confファイルを使用します。

src/docs/chapter1/directory.confを作成し、以下のように記述します。

laika.title = チャプター1

すると、サイドナビゲーションにチャプター1と表示されるようになります。

このように、ディレクトリ構造を活用してドキュメントを章分けし、設定ファイルを使用することでカスタマイズを行いながらドキュメントを作成するこが可能です。

Laikaは他にもテーマを自作したり、バージョニングを行ったりと、さまざまな機能を提供しています。しかし、今回の主題である多言語対応については、筆者の調べた限りではLaikaの公式ドキュメントには記載がなく、おそらくサポートされていない機能のようです。

そこで、ようやくLaikaを使用して多言語対応のドキュメントを作成する方法について説明します。

多言語対応のドキュメントを作成

Laikaは拡張性が高くテーマなども自作できるため、おそらく多言語化に対応したテーマなどを自作することで多言語対応のドキュメントを作成することが可能だと思われます。しかし、今回はなるべく自作をせずにLaika標準の機能を使用して多言語対応のドキュメントを作成したいので、以下の方法で多言語対応のドキュメントを作成します。

  • 言語ごとにドキュメントを生成する
  • Metadataを使用して言語を指定する
  • テンプレートを使用して言語を切り替える
  • ナビゲーションを言語別に分ける

言語ごとにドキュメントを生成する

Laikaはディレクトリを分割することで、章ごとに区切ってドキュメントを生成することができると説明しましたが、この章ごとの区切りを言語ごとに分けることで多言語対応のドキュメントを作成します。

今回は日本語と英語の2言語に対応したドキュメントを作成します。

src/docs/enディレクトリを作成し、src/docs/en/index.mdに以下の内容を記述します。

# Hello, Laika!

同様に、src/docs/jaディレクトリを作成し、src/docs/ja/index.mdに以下の内容を記述します。

# こんにちは、Laika!

これで準備が整いました。sbt laikaSiteを実行すると、src/docs/en/index.mdsrc/docs/ja/index.mdがそれぞれHTML形式に変換され、target/docs/site/en/index.htmltarget/docs/site/ja/index.htmlに出力されます。

$ sbt laikaSite

プレビューサーバーを起動して、http://localhost:4242/enhttp://localhost:4242/jaにアクセスすることで、それぞれの言語のドキュメントを確認することができます。

$ sbt laikaPreview

言語ごとにディレクトリを分けることで、それぞれの言語ごとにドキュメントを作成することができました。

しかし、現状ではサイドナビゲーションにENJAと表示されてしまい、ドキュメントの数が増えると対応する言語の数だけサイドナビゲーションが2倍3倍と増えていってしまいます。

言語は最初に選択して、その後はその言語のドキュメントのみを表示するようにしたいですよね。次に、Metadataを使用して言語をそれぞれ指定する方法について説明します。

Metadataを使用して言語を指定する

LaikaはMarkdownファイルのメタデータを使用して、ドキュメントの設定を行うことができます。メタデータは特殊な({%...%}で囲まれた)形式で記述し、ファイルの先頭に記述します。

src/docs/en/index.mdに以下のように記述します。

{%
laika.metadata.language = en
%}

# Hello, Laika!

同様に、src/docs/ja/index.mdに以下のように記述します。

{%
laika.metadata.language = ja
%}

# こんにちは、Laika!

これでドキュメントごとにメタデータを指定することができました。現時点ではこのメタデータはLaikaによって特別な処理をされるわけではないため、このメタデータを使用して言語を切り替えるための処理を追加する必要があります。

次に、テンプレートを使用して言語を切り替える方法について説明します。

テンプレートを使用して言語を切り替える

  • テンプレートファイル: default.template.<suffix>という名前で、サフィックスが出力形式 (例えば.html) に一致するデフォルトのテンプレートをディレクトリごとに提供することができます。

Laikaはテンプレートを使用して、ドキュメントのレイアウトをカスタマイズすることができます。テンプレートは出力形式ごとに用意されており、HTML形式の場合はdefault.template.htmlが使用されます。

src/docs/default.template.htmlを作成し、以下の内容を記述します。

default.template.html
<!DOCTYPE html>
<html lang="${?laika.metadata.language}">

@:include(helium.site.templates.head)

<body>

@:include(helium.site.templates.topNav)

<nav id="sidebar">

  <div class="row">
    @:for(helium.site.topNavigation.phoneLinks)
    ${_}
    @:@
  </div>

  @:navigationTree {
  entries = ${helium.site.mainNavigation.prependLinks} [
  { target = "/", excludeRoot = true }
  ] ${helium.site.mainNavigation.appendLinks}
  }

</nav>

<div id="container">

  @:include(helium.site.templates.pageNav)

  <main class="content">

    ${cursor.currentDocument.content}

    @:include(helium.site.templates.footer)

  </main>

</div>

</body>

</html>

これで、sbt laikaSiteを実行しプレビューサーバーを起動してみてください。先ほどまでと同じ画面が表示されるはずです。

$ sbt laikaSite
$ sbt laikaPreview

しかし、このテンプレートには一箇所だけ言語を変更している箇所があります。

<html lang="${?laika.metadata.language}">

この${?laika.metadata.language}はLaikaのメタデータを参照して言語を取得しています。このようにしてメタデータを使用することで、言語を切り替えることができます。

実際に、http://localhost:4242/enhttp://localhost:4242/jaにアクセスし、開発ツールを開きHTMLを確認すると、<html lang="en"><html lang="ja">がそれぞれ表示されていることが確認できるはずです。

英語

日本語

このようにして、Laikaではメタデータの値をテンプレート内で参照することができ、ドキュメントごとにそれぞれ異なった値を設定することができます。

これで言語を切り替えることができました。しかし、現状ではサイドナビゲーションは言語ごとに分かれていないため、次にナビゲーションを言語別に分ける方法について説明します。

ナビゲーションを言語別に分ける

Laikaはナビゲーションを自動生成する機能を提供していますが、そのナビゲーションは先ほどのdefault.template.htmlでカスタマイズすることができます。

ナビゲーションを言語別に分けるためには、言語ごとにナビゲーションを生成する必要があります。Laikaはナビゲーションを生成する際に、helium.site.mainNavigationという変数を使用しています。

このhelium.site.mainNavigationはナビゲーションのエントリーを保持する変数で、この変数を使用してナビゲーションを生成しています。

現在のsrc/docs/default.template.htmlではナビゲーション部分は以下のようになっています。

<nav id="sidebar">
  ...

  @:navigationTree {
  entries = ${helium.site.mainNavigation.prependLinks} [
  { target = "/", excludeRoot = true }
  ] ${helium.site.mainNavigation.appendLinks}
  }

</nav>

この@:navigationTreeはナビゲーションを生成するためのマクロで、entriesにナビゲーションのエントリーを指定することでナビゲーションを生成しています。

今回は言語用のドキュメントは何らかの方法で言語を選択したら表示されるようにし、サイドナビゲーションは言語に応じて表示されるようにします。

そのため、言語選択前はサイドナビゲーションに言語それぞれのナビゲーションは表示されないようにしたいです。

言語用のメタデータに応じて出しわけを行ってもいいのですが、今回は言語選択前のドキュメントは言語別に用意するものとは別でかつ英語としたいため、言語用のメタデータとは別にルート用のメタデータ (isRootPath)を使用することにします。

先ほどの、default.template.htmlを以下のように修正します。

<nav id="sidebar">
  ...

  @:if(laika.metadata.isRootPath)
  @:navigationTree {
  entries = ${helium.site.mainNavigation.prependLinks} [
  { target = "/", excludeRoot = true, depth = 1 }
  ] ${helium.site.mainNavigation.appendLinks}
  }
  @:else
  @:navigationTree {
  entries = ${helium.site.mainNavigation.prependLinks} [
  { target = /${laika.metadata.language}/, excludeRoot = true, excludeSections = ${helium.site.mainNavigation.excludeSections}, depth = ${helium.site.mainNavigation.depth} },
  ] ${helium.site.mainNavigation.appendLinks}
  }
  @:@
</nav>

Laikaはテンプレートファイル内で条件分岐を行うためのマクロを提供しており、@:if@:elseを使用することで条件分岐を行うことができます。

この機能を利用して、今回は以下のような設定でナビゲーションを生成しています。

  • laika.metadata.isRootPathtrueの場合は、ルート用のナビゲーションを生成
    • ルート用のナビゲーションは深さを1にして、ルート以外のエントリーを表示しないようにしています
  • laika.metadata.isRootPathfalseの場合は、言語用のナビゲーションを生成
    • 言語用のナビゲーションはLaikaデフォルトのメタデータを使用して深さなどの設定を行っています
    • target = /${laika.metadata.language}/として言語ごとにナビゲーションを生成しています

テンプレートファイルを修正したら、ルート用のメタデータをsrc/docs/index.mdに追加します。

{%
laika.metadata {
  language = en
  isRootPath = true
}
%}

# Hello, Laika!

これで、言語選択前のドキュメントは言語別に用意するものとは別で、サイドナビゲーションは言語に応じて表示されるようになりました。

再度、sbt laikaSiteを実行しプレビューサーバーを起動してみてください。

$ sbt laikaSite
$ sbt laikaPreview

サイドナビゲーションから言語の項目が消えています。

それぞれの言語のパスにアクセスすると、言語ごとのナビゲーションが表示されていることが確認できるはずです。

英語

http://localhost:4242/en

日本語

http://localhost:4242/ja

このままでは、パスを知っていないと言語を切り替えることができないため、それぞれの言語に対応したリンクを用意する必要があります。

ルート用のドキュメントに以下のようなリンクを追加します。

{%
laika.metadata {
  language = en
  isRootPath = true
}
%}

# Hello, Laika!

- [English](en/index.md)
- [Japanese](ja/index.md)

これで、言語を切り替えるためのリンクが表示されるようになり言語ごとにドキュメントを切り替えることができるようになりました。

まとめ

今回の方法だと言語ごとにそれぞれドキュメントを作成し管理する必要がありますが、Laikaの柔軟性を活かして多言語対応のドキュメントを作成することができました。

ただ現状の構成だと、一度言語を選択するとその言語のドキュメントしか表示されないため、それぞれのドキュメントで言語を切り替えるためのリンクを用意する必要があったりと、ユーザビリティの面で改善の余地があると感じました。

Laikaはバージョニングなどの機能を提供しており以下のようにヘッダーでバージョンを切り替えることができるため、テーマなどを自作してみるとよりユーザビリティの高いドキュメントを作成することができるかもしれません。

まだまだLaikaの機能は多岐にわたり、今回紹介した機能以外にもさまざまな機能が提供されています。ドキュメントのバージョニングは自身のOSSでも導入してみたい機能の一つであるため、こちらも調査が終わり対応出来次第ブログにて紹介したいと考えています。

PS. LaikaはTypelevelコミュニティのプロジェクトだと説明しましたが、Typelevelにはsbt-typelevelというプロジェクトもあり、このプロジェクトはLaikaを使用してドキュメントを生成する機能も提供しています。このプロジェクトを使用するとTypelevel用のテーマやテンプレートを使用することができるため、Typelevelプロジェクトを開発している場合はsbt-typelevelを使用することでより効率的にドキュメントを作成することができるかもしれません。

https://typelevel.org/sbt-typelevel/

GitHubで編集を提案
nextbeat Tech Blog

Discussion