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 configremark-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"}.
:::しかし、mdsvexのremarkPluginsとしてremark-directiveを設定しても、上記のような構文が正しく変換されない問題に直面した。
mdsvexの依存パッケージが古い問題
mdsvexは現在もメンテナンスされているものの、全体には手が回っていない印象がある。
特に、mdsvexが依存しているremark関連のパッケージ(remark-parseやunifiedなど)が古いバージョンのままなので、最新の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.jsonのoverrides
まず最初に試みたのが、mdsvexが依存するremark-parseのバージョンをoverridesで上書きすることだった。(若干投げやり)
{
// ...
"overrides": {
"mdsvex": {
"remark-parse": "^11.0.0"
}
}
}が、問題の解消には至らず。
mdsvexの依存関係を見てみると、remark-parseはdependenciesではなくdevDependenciesに入っているため、overridesで上書きしようがなかった。
自作remark-directiveでmicromark拡張を噛ませる
ちょっと昔の記憶を辿ると、Astroでもremark-directiveがうまく動かない問題を経験したことがあったな…と思い出した。
その時の暫定的な解決策として、remark-directive自体を自作したことがある。
mdsvexの中のremark-parseがmicromark拡張を呼び出してくれないのなら、自前のremark-directiveでmicromark拡張を呼び出せばよいのでは?
ということで、過去に書いた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.jsのremarkPluginsにこの自作プラグインを設定することで、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に置き換えればよいだけなので、当面はこんな感じで…