SvelteKitで作ったWebアプリケーションで、ドキュメントページを作成したいことがある。
+page.svelteにHTMLとして書くのは面倒だし、やっぱりできればMarkdownで書きたい。

純粋なMarkdownを書きたいだけなら、marked等で変換してページに埋め込むだけで事足りるが、コンテンツ内にインタラクティブなデモ(.svelteコンポーネント)を載せたいとなると、MDXみたいな仕組みが欲しくなる。

そんなとき、Svelte版MDXのような位置付けであるmdsvexというライブラリがある。

SvelteKitでmdsvexを使う

mdsvexでは、.svxという拡張子のファイルにMarkdownとsvelteコンポーネントを混在させることができる。

NOTE

.svxファイルでシンタックスハイライトを効かせたい場合はVS Codeの拡張機能が必要

---
layout: guide
title: PCCSとは?
---
 
<script lang="ts">
  import ToneImageDiagram from "$lib/components/index/ToneImageDiagram.svelte"
</script>
 
## PCCSとは?
 
PCCSは、色のイメージや配色を考えるときに便利な色の表し方です。
PCCSでは、1つの色を色相とトーンによって表します。
 
- 色相:赤・黄・緑・青・紫など、どの色みをもたせるか
- トーン:どんなイメージをもたせるか
 
<ToneImageDiagram />
 
たとえば、dトーンは明度が低めなのでやや暗く、彩度も中間くらいであまり鮮やかとはいえず、「くすんだ」といったイメージになります。
 
PCCSでは、トーンを調整することで、色のイメージを調整することができます。

sv addコマンドでSvelteKitにmdsvexを導入すると、routes配下に置いた+page.svxもページとして機能するようになる。

// svelte.config.js
import { mdsvex } from "mdsvex"
import { fileURLToPath } from "url"
 
/** @type {import('@sveltejs/kit').Config} */
const config = {
  // ...
  preprocess: [
    mdsvex({
      layout: {
        guide: fileURLToPath(new URL("./src/lib/layouts/guide.svelte", import.meta.url))
      },
      remarkPlugins: []
    })
  ],
  extensions: [".svelte", ".svx"]
}
 
export default config

remark-directiveを使いたいが…

mdsvexでも、remarkプラグインやrehypeプラグインを使うことができる。

しかし、なんでもかんでも動くわけではなく、特に行き詰まったのがremark-directiveの導入だった。

remark-directiveは、Markdown内に:::で囲まれたカスタム構文を定義できるremarkプラグインで、Markdown内にHTMLタグを直接書かずに、特定のクラス等をもつ要素を生成できるようになる。

たとえば、Markdown内の特定の要素にクラスを当てたり、HTMLタグで囲んでレイアウトを制御したい場合、通常は次のようにHTMLを直接書く必要があるが…

<main id="readme">
  Lorem ipsum.
  <hr class="red" />
  A
  <i>lovely</i>
  language know as
  <abbr title="HyperText Markup Language">HTML</abbr>
  .
</main>

remark-directiveを使えば、次のように書けるようになる。

:::main{#readme}
 
Lorem:br
ipsum.
 
::hr{.red}
 
A :i[lovely] language know as :abbr[HTML]{title="HyperText Markup Language"}.
 
:::

しかし、mdsvexremarkPluginsとしてremark-directiveを設定しても、上記のような構文が正しく変換されない問題に直面した。

mdsvexの依存パッケージが古い問題

mdsvexは現在もメンテナンスされているものの、全体には手が回っていない印象がある。
特に、mdsvexが依存しているremark関連のパッケージ(remark-parseunifiedなど)が古いバージョンのままなので、最新のremarkプラグインが正常に動作しない問題がある。

Claude Codeに調べてもらった結果:

remark-directive v4 は micromark 拡張でパーサーを拡張しますが、mdsvex 0.12.x はバンドルした旧 remark-parse(micromark非対応)を使うため、remarkPlugins 経由では構文が認識されません。

Claude Codeは、カスタムディレクティブをすべて正規表現で定義するようにし、mdsvexに食わせる前にHTMLに変換したら?という提案をしてくれた。

解決策:Svelte マークアッププリプロセッサ

  • src/lib/preprocessors/svx-directives.ts を新規作成
  • mdsvex が処理する前に .svx ファイルのディレクティブ記法を HTML に変換 > - HTML ブロック内ではマークダウンのインライン処理が走らないため、code<code> の変換も内部で実施
  • svelte.config.js では svxDirectives()mdsvex() より前に配置

とはいえ、カスタムディレクティブのバリエーションを増やすたびに正規表現の定義を追加するのはちょっと…

remarkの枠組みから逸れた完全独自の仕組みを作るよりも、いずれは正規のremark-directiveにすぐに置き換えられるような、剥がしやすい仕組みでどうにかしたい。

失敗:package.jsonoverrides

まず最初に試みたのが、mdsvexが依存するremark-parseのバージョンをoverridesで上書きすることだった。(若干投げやり)

package.json
{
  // ...
  "overrides": {
    "mdsvex": {
      "remark-parse": "^11.0.0"
    }
  }
}

が、問題の解消には至らず。

mdsvexの依存関係を見てみると、remark-parsedependenciesではなくdevDependenciesに入っているため、overridesで上書きしようがなかった。

自作remark-directiveでmicromark拡張を噛ませる

ちょっと昔の記憶を辿ると、Astroでもremark-directiveがうまく動かない問題を経験したことがあったな…と思い出した。
その時の暫定的な解決策として、remark-directive自体を自作したことがある。

mdsvexの中のremark-parsemicromark拡張を呼び出してくれないのなら、自前のremark-directivemicromark拡張を呼び出せばよいのでは?

ということで、過去に書いたsrc/lib/remark-mdx-directive.tsを参考に、svelte対応版を実装してみた。

import { fromMarkdown } from "mdast-util-from-markdown"
import { directive } from "micromark-extension-directive"
import { directiveFromMarkdown } from "mdast-util-directive"
import { frontmatter } from "micromark-extension-frontmatter"
import { frontmatterFromMarkdown } from "mdast-util-frontmatter"
import type { Processor } from "unified"
import type { Node } from "unist"
 
export default function remarkDirective(this: Processor) {
  // mdsvex bundles an old unified that only reads `this.Parser` (uppercase).
  // Using `this.parser` (the non-deprecated form) breaks directive parsing
  // because mdsvex never picks it up. Suppress the deprecation warning here.
  this.Parser = function (doc: string) {
    // fromMarkdown (CommonMark) cannot parse Svelte component attributes that
    // use {expr} syntax — it fails to recognize the tag as HTML and wraps it
    // in a <p>, causing a Svelte compile error on the " inside {[...]}.
    //
    // Fix: stash every self-closing component line before parsing, replace it
    // with a plain <div> HTML block that fromMarkdown preserves as-is, then
    // restore the originals after the AST is built.
    const stash: string[] = []
 
    const prepared = doc.replace(/^(<[A-Z][^\n]*\{[^\n]*\/>)\s*$/gm, (_, tag) => {
      const i = stash.length
      stash.push(tag)
      return `<div data-svx="${i}"></div>`
    })
 
    const tree = fromMarkdown(prepared, "utf-8", {
      extensions: [frontmatter(["yaml"]), directive()],
      mdastExtensions: [frontmatterFromMarkdown(["yaml"]), directiveFromMarkdown()]
    })
 
    if (stash.length > 0) restore(tree, stash)
 
    return tree
  }
}
 
function restore(node: Node, stash: string[]): void {
  const n = node as unknown as Record<string, unknown>
  if (n["type"] === "html" && typeof n["value"] === "string") {
    n["value"] = (n["value"] as string).replace(/<div data-svx="(\d+)"><\/div>/g, (_, i: string) => stash[+i])
  }
  if (Array.isArray(n["children"])) {
    for (const child of n["children"] as Node[]) restore(child, stash)
  }
}

MDX版と変わったのは次の点:

  • svelteコンポーネントタグが埋め込まれた状態でmicromark-extension-directiveなどに渡してもパースエラーになるため、正規表現でコンポーネントタグをスタッシュしておいて、ASTができた後に元に戻すようにしている。
  • frontmatterも同様にそのままだとパースエラーになるため、micromark-extension-frontmatterを挟んで、frontmatterも解釈できるようにしている。

また、this.Parserのところで非推奨警告が出るが、これは仕方なくそのままにしている。最新のunifiedではthis.parserが推奨されているが、mdsvexは古いバージョンを使っているため、this.parserに注入したところで、mdsvexはこの処理を使ってはくれない。

svelte.config.jsremarkPluginsにこの自作プラグインを設定することで、remark-directiveの構文が正しく解釈されるようになった。

// svelte.config.js
import { mdsvex } from "mdsvex"
import { fileURLToPath } from "url"
import remarkDirective from "./src/lib/remark/directive.ts"
import remarkGuideDirectives from "./src/lib/remark/custom-directives.ts"
 
/** @type {import('./src/lib/remark/custom-directives.ts').DirectiveConfigMap} */
const guideDirectives = {
  container: [
    { name: "tips", tag: "div", classes: ["tips"] },
    { name: "example", tag: "div", classes: ["example"] },
    { name: "term-card", tag: "div", classes: ["term-card"] }
  ],
  leaf: [],
  text: [{ name: "mark", tag: "span", classes: ["mark", "-brackets"] }]
}
 
/** @type {import('@sveltejs/kit').Config} */
const config = {
  preprocess: [
    mdsvex({
      layout: {
        guide: fileURLToPath(new URL("./src/lib/layouts/guide.svelte", import.meta.url))
      },
      remarkPlugins: [remarkDirective, [remarkGuideDirectives, guideDirectives]]
    })
  ],
  extensions: [".svelte", ".svx"]
}
 
export default config

ちょっと無理やりな感じもするが、mdsvexが最新のremarkに追従するようになったら、自作のremarkDirectiveを正規のremark-directiveに置き換えればよいだけなので、当面はこんな感じで…