Astro で Standard ML のシンタクスハイライト

技術ブログを書いていると,さまざまなプログラミング言語のコードスニペットを掲載したくなります. しかし,メジャーな言語は標準で対応していても,Standard ML (SML) のような特定の言語は,デフォルトでは適切にハイライトされないことがあります.

この記事では,Astro が内部で使用するシンタクスハイライトライブラリ Shiki を活用し, Standard ML 独自の文法定義ファイルを TextMate で作成して,Astro に登録する手順を述べます.

Astro におけるシンタクスハイライトの仕組み

Astro は Markdown やコードブロックのシンタックスハイライトに Shiki というライブラリを標準で使用しています.

Shiki には以下のような特徴があります:

  • VSCode と同じエンジン
    • Visual Studio Code と同じ TextMate 文法を解析するエンジンを使用しており VSCode と同等のハイライトが可能
  • TextMate による文法定義
    • 言語の構文 (キーワード, 演算子, コメント, …) は .tmLanguage.json という JSON 形式の文法定義ファイルによって定義
    • JSON ファイルの中で「どの文字列がキーワードか」「どれがコメントか」などを正規表現で定義し,それぞれにスコープ名 (e.g. keyword.control.sml) を割り当て

Standard ML を認識されたければ,Standard ML 用の .tmLangage.json を作成して,Astro (Shiki) に登録すればよいです.

Standard ML 用の文法ファイル

まず,プロジェクトの任意の場所 (e.g. /src/grammars/) に sml.tmLanguage.json というファイルを作成します.

全体構造

ファイルの大枠は以下のようになります:

{
  "name": "Standard ML",
  "scopeName": "source.sml",
  "aliases": ["sml", "standard-ml", "standardml"],
  "patterns": [
    // マッチングルールを記述
  ],
  "repository": {
    // ルールの定義を記述
  },
}
  • name:言語の表示名です
  • scopeName:文法全体を示す一意な識別子です.慣習的に source.(言語名) とします
  • aliases:Markdown のコードブロックで ```sml ... ``` のように指定するためのエイリアスです
  • patterns:どんな順番でルールをチェックしていくかを定義します
  • repositorypatterns から参照される各ルールの具体的な定義 (正規表現など) を記述します

パターンの定義

コードは上から順に解析されるため patterns にルールを記述する順番は重要です. 例えば,キーワードより先にコメントをチェックしないと,コメント内のキーワードまでハイライトされてしまう可能性があります.

{
  ...
  "patterns": [
    { "include": "#comments" },   // 1. コメント
    { "include": "#keywords" },   // 2. キーワード
    { "include": "#strings" },    // 3. 文字列
    { "include": "#numbers" },    // 4. 数値
    { "include": "#operators" },  // 5. 演算子
    { "include": "#types" }       // 6. 型
  ],
  "repository": {
    // ...各定義...
  }
}

{ "include": "#comments" } は「repository の中にある comments という名前の定義を参照して」という意味です.

具体的な定義

各構文要素を正規表現で repository 内に定義していきます.

The Definition of Standard ML に倣えばより正確ですが,まずはハイライトに最低限のものを定義します.

キーワード

予約語を定義します.

"keywords": {
  "patterns": [
    {
      "name": "keyword.control.sml",
      "match": "\\b(if|then|else|case|of|fn|fun|val|let|in|end|open|...)\\b"
    }
  ]
}
  • name: "keyword.control.sml": スコープ名
    • Shiki はこのスコープ名に対して,テーマ (e.g.: github-dark) が指定する色を割り当てます
  • match:: 正規表現
    • \b: 単語境界 (word boundary)
      • if という単語にマッチさせつつ “different” に含まれる “if” にマッチさせないため,単語の前後を \b で囲みます
    • (if|then|else|...): | で区切られたいずれかの単語にマッチします

コメント (ネスト対応)

SML のブロックコメント (* ... *) は入れ子にできます.TextMate の begin / end と再帰的な include を使います.

"comments": {
  "patterns": [
    {
      "name": "comment.block.sml",
      "begin": "\\(\\*",      // "(*" で開始 (括弧はエスケープ)
      "end": "\\*\\)",        // "*)" で終了 (アスタリスクもエスケープ)
      "patterns": [
        { "include": "#comments" } // 自分自身を再帰的に含める
      ]
    }
  ]
}
  • beginend で囲まれた範囲が comment.block.sml スコープになります
  • 内部で定義した patterns#comments を再び include します
  • (* A (* B *) C *) のようなネスト構造を正しく解析できます

文字列とエスケープシーケンス

文字列 "..." と,その内部の \"\n といったエスケープシーケンスを認識させます.

"strings": {
  "patterns": [
    {
      "name": "string.quoted.double.sml",
      "begin": "\"", // " で開始
      "end": "\"",   // " で終了
      "patterns": [
        {
          "name": "constant.character.escape.sml",
          "match": "\\\\." // "\" の後に任意の1文字が続くパターン
        }
      ]
    }
  ]
}
  • beginend で文字列全体を string.quoted.double.sml と定義します
  • その内部 (patterns) で \\. (\ 自体をエスケープするため \\ となり任意の文字 . が続く) にマッチする部分を,より詳細な constant.character.escape.sml スコープとして定義しています

型変数

ML 特有の 'a'key といった型変数を認識させます.

"types": {
  "patterns": [
    // ... int, bool などの定義 ...
    {
      "name": "storage.type.sml",
      "match": "'[a-zA-Z][a-zA-Z0-9_]*" // ' で始まる識別子
    }
  ]
}

' で始まり,英字が1文字続き,その後は英数字または _ が続くパターンとして定義しています.

設定した文法の読み込み

作成した sml.tmLanguage.json をAstro (Shiki) に登録します.

astro.config.mjs を以下のように編集します:

import { defineConfig } from 'astro/config';
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';

// ESモジュールでは __dirname が使えないため、現在のファイルパスから作成
const __dirname = dirname(fileURLToPath(import.meta.url));

// 1. JSONファイルを同期的に読み込む
const smlGrammar = JSON.parse(
  readFileSync(join(__dirname, 'src/grammars/sml.tmLanguage.json'), 'utf-8')
);

// [https://astro.build/config](https://astro.build/config)
export default defineConfig({
  // ... 他の設定 ...

  markdown: {
    shikiConfig: {
      // 2. Shikiに使用するカラーテーマを選択
      theme: 'github-dark',

      // 3. カスタム言語を配列で渡す
      langs: [smlGrammar],

      // (オプション) 長い行を折り返す
      wrap: true,
    },
  },
});

Astro の設定ファイル (astro.config.mjs) はビルド時にのみ読み込まれるものです. したがって,この設定ファイル自体で非同期処理 (async/awaitfs.promises.readFile) を使うことはできません.

そのため,ファイル読み込みは同期的に行う fs.readFileSync を使用します.ビルド時のみの処理なので,パフォーマンス上の問題はありません.

動作の流れ

以下の流れでハイライトが適用されます.

ビルド時

  • Astro が astro.config.mjs を読み込む
  • sml.tmLanguage.jsonreadFileSync で読み込まれて JSON オブジェクトとしてパースされる
  • shikiConfig.langs に渡された smlGrammar オブジェクトが Shiki に登録される

Markdown パース時

  • Astro が Markdown ファイル内の sml ... ブロックを見つける
  • Shiki は sml というエイリアス (aliases で定義) をキーに,登録された smlGrammar を呼び出す
    • 文法定義の正規表現に基づき,コードをトークン (キーワード / 数値 / 演算子など) に分割する

HTML 出力時

  • Shiki は,各トークンを <span> タグで囲み,sml.tmLanguage.json で定義したスコープ名 (e.g.: keyword.control.sml) を CSS クラスとして付与した HTML を生成
    • 例えば fun factorial 0 = 1 というコードは,以下のような HTML に変換される
<pre class="shiki github-dark">
  <code>
    <span class="keyword.control.sml">fun</span>
    <span class="entity.name.function.sml"> factorial</span>
    <span class="constant.numeric.sml"> 0</span>
    <span class="keyword.operator.sml"> =</span>
    <span class="constant.numeric.sml"> 1</span>
  </code>
</pre>

スタイル適用

  • shikiConfig.theme で指定したテーマ (e.g.: github-dark) の CSS が具体的な色を割り当ててブラウザで美しく表示する
    • .keyword.control.sml には「紫」で .constant.numeric.sml には「青」など

コードサンプル

このサイトには,ユーザ定義した smlGrammar が適用されています.たとえば,以下のように表示されます.

structure Fibonacci =
  struct
    local
      fun natsUpTo n = if n < 0 then NONE
                                else SOME (List.tabulate (n + 1, fn m => m))
    in
      fun fib n =
        let
          fun fib' x _ 0 = x
            | fib' x y m = fib' y (x + y) (m - 1)
        in
          if n < 0 then NONE
                   else SOME (fib' 0 1 n)
        end
      fun printFib n =
        case fib n of
          SOME m => print ("fib_" ^ Int.toString n ^ " = " ^ Int.toString m ^ "\n")
        | NONE   => print "Too Small!!\n"
      fun printFibs n = app printFib ((valOf o natsUpTo) n)
                        handle Option   => print "Too Small!!\n"
                             | Overflow => print "Too Big!!\n"
    end
  end

fun readInt () = TextIO.scanStream (Int.scan StringCvt.DEC) TextIO.stdIn
val _ = case readInt () of SOME n => Fibonacci.printFibs n
                         | NONE   => print "Non-integer Value!!\n"

まとめ

Astro と Shiki の仕組みを理解することで Standard ML のようなカスタム言語でも,カスタムハイライトを簡単に導入できました.

  • TextMate: .tmLanguage.json という形の JSON ファイルを作成
  • 正規表現: キーワード / コメント (特にネスト) / 型変数などを定義
  • Astro Config: astro.config.mjs で文法ファイルを fs.readFileSync を使って読み込み shikiConfig.langs に登録

この方法は SML に限らず,Astro が標準でサポートしていない他の言語 (独自の DSL など) にも応用できます.

ぜひあなたのブログでもお気に入りの言語を美しく表示させてみてください.