KFM Phase 1 実装ノート(github profile)
task 本文(GitHub issue 形式)のレンダラ KFM (Koyori Flavored Markdown) の Phase 1 実装を記す。 Phase 1 の実体は github profile(複製レンダラ) = GFM + GitHub alerts + コードブロック着色(starry-night)+ 安全 core。 本書は出荷した実装の説明であり、設計判断の根拠は設計書(別管理)を正とする。
Phase 1 の出荷範囲はレンダラ(renderDescription)の提供と、タスク詳細ページへの
UI 接続まで。本番の呼び出し元はタスク詳細の +data.ts(サーバ)で、描画済み HTML と
描画元テキスト(stale 照合キー descriptionSource)を +Page.vue 経由で
TaskDetailHub.vue へ渡し、最新の task.description と厳密一致するときだけ v-html
する(不一致はプレーンテキスト表示へフォールバック)。サイドカー CSS 4 本も
TaskDetailHub.vue が import 済み。後述の「利用方法」はこの接続実体の要約である。
接続済みの消費側はこのタスク詳細ページ一つだけで、他の本文表示箇所
(タスク一覧の分割ビューのペイン・コメント本文など)は未接続の残件である。
構成
apps/frontend/src/lib/
remark-gfm/ GFM 層の薄いラッパ + GFM 由来 class の sanitize スキーマ
index.ts
content-class.ts 器クラス kfm-content の単一ソース(GFM CSS の scope)
style.css サイドカー CSS(リスト/blockquote/リンク。preflight 対策。
全ルール .kfm-content 子孫限定)
remark-koyori-alerts/ KFM 拡張第一号: GitHub alerts (> [!NOTE] 等) → callout
index.ts 自前 transformer(GitHub alerts の境界規則)
style.css サイドカー CSS(アイコンは名前空間クラス・inline style 不使用)
rehype-starry-night/ コードブロック着色 (rehype-starry-night の薄いラッパ)
index.ts createRehypeStarryNight(starry-night 実体の factory closure
共有・失敗回収)
schema.ts pl-* class の sanitize スキーマ
style.css サイドカー CSS(light シート固定 + .dark ブリッジ)
remark-kfm-mermaid/ mermaid フェンス → client 描画 custom element
index.ts remark 変換と sanitize スキーマ
element.ts 遅延ロード・SVG 描画・再接続処理
style.css JS 無効/描画失敗時のソース表示フォールバック
markup-renderer/ KFM コア
index.ts composition root(renderDescription singleton・公開 API)
_renderer.ts createRenderer(controlled pipeline・profile memoize)
_sanitize.ts DOMPurify 設定(構造専任・registry 方式)
_cache.ts L1 キャッシュ(full-text キー)
_config.ts 多層 config 解決(Phase 1 はコード既定+system 層)
_client-registry.ts カスタム要素の client 登録(kfm-mermaid)
apps/frontend/src/pages/
+client.ts client 専用 entry(カスタム要素登録の呼び出し口)
index.ts のみが外部 API(_*.ts は内部)。コアはプラグインを import せず、
composition root が remark 層と sanitize スキーマを注入する。
ただし client registry は例外で、composition root から再エクスポートしてはならない。
+client.ts は _client-registry.ts を直接 import し、サーバ用レンダラ一式が client bundle へ
混入するのを防ぐ。この非再エクスポート規約は bundle 境界であり、公開 API の整理ではない。
同じ bundle 境界として、composition root は starry-night seam を import() で遅延ロードする。
静的 import / re-export に戻すと common 文法一式を含む約 417.5 KB raw が root の静的グラフへ
加わるため、kfm-cache.test.ts が副作用 import を含む静的参照の不在と実行時 import を固定する。
パイプライン
入力テキスト → 改行 LF 正規化 → remark-parse → remark-gfm → remark-koyori-alerts
→ remark-kfm-mermaid → remark-rehype → rehype-scope-footnote-label(scope 付きのみ)
→ rehype-starry-night → rehype-stringify → DOMPurify → HTML 文字列 → <div v-html>
-
allowDangerousHtmlは使わない。mdast の生htmlノードは remark-rehype 既定で消える。 プラグインはdata.hName/hPropertiesの型付き emit のみ行う。 -
processor は profile ごとに 1 回だけ build して memoize する。ただし memoize されるのは scope なし(既定
clobberPrefix)の経路のみ。scope 付き描画は毎回 build する——scope の 値空間(comment id 等)は非有界で、singleton に溜めるとメモリが漏れるため (_renderer.tsのgetProcessorの分岐)。 ただし build のたびに高い初期化が走るわけではない。starry-night の WASM 読み込みと 文法登録はプラグイン factory の closure に共有してあり、構築回数に比例しない。 -
描画の既定 profile は composition root が
CreateRendererOptions.defaultProfileへcontentConfig.defaultProfileを渡して接続する。未指定時の fallback はgithub。
GitHub alerts(remark-koyori-alerts)
GitHub 完全互換の境界仕様をテストで固定している:
-
5 種のみ(NOTE / TIP / IMPORTANT / WARNING / CAUTION)・type は case-insensitive
-
マーカーは blockquote 先頭行に単独。同一行に後続テキストがあれば通常 blockquote
-
ネスト不可。alert 内の alert と、通常 blockquote 内の alert の両側とも、内側は通常 blockquote のままにする。不正 type も通常 blockquote へフォールバックする
-
出力:
div.kfm-alert.kfm-alert--{type}+p.kfm-alert__title。inline style は一切出さない -
アイコン・配色は
style.cssの名前空間クラスで当てる(消費側で明示 import するサイドカー方式)
mermaid 図(remark-kfm-mermaid)
設計上の決定(変更するときは理由ごと書き換える):
-
client 専用描画 — SSR は不活性
<kfm-mermaid>+エスケープ済みソーステキスト だけを出す。mermaid は本質的にブラウザ描画(レイアウト計測に DOM が要る)であり、 SVG をサーバで焼く方式は採らない。mermaid 本体(重量)は custom element の connectedCallback 内 dynamic import のみで参照し、図が実在するページでだけ落ちてくる -
securityLevel は strict 固定(緩める変更は禁止)。
suppressErrorRenderingで 構文エラー時のエラー図 DOM 注入も止め、失敗は状態値で表す。なお strict でもclick A "https://…"の URL リンクは<a>として出力される(strict が殺すのは callback 実行であってリンクではない) -
shadow DOM は成功時のみ張る — 失敗時に張ると light DOM のソーステキスト (JS 無効時・失敗時のフォールバック表示)まで隠れるため。描画成功の SVG は shadow に入れ、ページ側 CSS から隔離する
-
完了シグナルは
data-kfm-mermaidの二値(rendered/error。属性なし=未処理)。 VRT / E2E はこの属性の出現を待つ(時間待ち禁止の口)。状態を増やす変更は 待ち側(story play・VRT)を全部数えてから -
挿入前の最終防御は sink と同じ HTML パース(
element.tsのparseSafeSvg)。 XML パーサでの検査は、mermaid の正常出力(click 付き flowchart の<a xlink:href>はxmlns:xlink宣言なし)を偽陽性で落とし、逆に HTML 再パースで 構造が変わる mXSS を素通しする。検査を通った同一ノードをそのまま挿入する -
href スキーム拒否は要素種別で分ける —
<a>(遷移 sink)と画像以外の href は 実行可能スキーム(javascript:/vbscript:/data:全体)を拒否。<image>/<img>は画像として解釈される sink でありdata:image/だけを許す。data:一律拒否は不可: C4 図は Person アイコンを<image xlink:href="data:image/png;base64,…">で埋める(mermaid@11.16.1 実測)ため、 一律拒否は C4 図全体を error へ倒す(DOMPurify 側の「data: は画像系のみ」既定とも整合) -
一時描画コンテナは自要素内の不可視ノード — render 第三引数を省くと mermaid が 一時ノードを document.body 末尾へ置き図が一瞬露出するため、接続中の自要素内へ置いて 成否にかかわらず撤去する。副作用が二つあり両方に手当て済み: 描画ソースは textContent でなく直下 text node のみから組む(描画中の再接続でコンテナ中身がソースへ混入しない)。 テキスト計測は祖先 CSS を継承するため、非 rendered 状態専用サイドカー CSS の
white-space: preだけはコンテナ側で明示的に打ち消す(計測と表示の非対称を断つ) -
テーマは描画時スナップショット —
.darkancestor を描画時に一度だけ見る。 描画後のテーマ切替に追従はしない(Phase 1 の割り切り)
サニタイズ(_sanitize.ts)
DOMPurify は HTML 構造の allowlist に専念する:
-
FORBID_ATTR: ['style']— inline style は経路を問わず落とす -
class は既知トークン完全一致 allowlist(
afterSanitizeAttributesフック)。 許可集合は各プラグインが export するSanitizeSchemaをcreateRenderer({ sanitizeSchemas })で合成した registry が単一ソース -
動的 class は
SanitizeSchema.classPatternsで明示した固定形だけを許可する。Phase 1 では コードフェンスのlanguage-*に限定し、任意のアプリ class を通す汎用パターンにはしない -
CUSTOM_ELEMENT_HANDLINGは registry 登録制(現在は kfm-mermaid を許可、属性は許可しない)。allowCustomizedBuiltInElements: falseでis=""経路を封鎖 -
classPatternsの正規表現にg/yフラグは使えない(lastIndex状態で.test()の 判定が呼び出し履歴により反転するため、registry 組立時に fail-fast で throw する)
URI とリンク属性の契約(DOMPurify 既定に依拠する部分も仕様として明記する):
-
data:URI は画像系タグ(img等)のsrcに限り通る(DOMPurify 既定)。a hrefのdata:は通らない。GitHub 同様、埋め込み画像データの表示は許容する。 サイズ上限等の資源制約は KFM 層では課さない(必要になれば入力長制限側で扱う) -
ユーザーリンクの rel 硬化(
rel="nofollow noopener noreferrer"等)は Phase 1 では 行わない。target="_blank"を一切出力しないため reverse tabnabbing の経路が無く、 同一タブ遷移ではnoopenerは不要。targetを導入する変更は rel 硬化とセットで行う こと(リンクがtarget/relを持たない現状はテストで固定済み)。nofollowは SEO ポリシーの問題であり、必要になった時点で rehype 層として追加する
DOMPurify を最終段に置くのは、remark プラグインが emit したものを含む最終 HTML 文字列を、
v-html へ渡す直前の一つの境界で検査するためである。class allowlist 用フックは DOMPurify の
モジュール singleton を汚染しないよう sanitize 呼び出し中だけ登録し、finally で撤去する。
キャッシュ(_cache.ts)
-
renderDescriptionはモジュール singleton = プロセス全体(SSR では全リクエスト・全 tenant)で 共有される。この前提で L1 のキーは入力本文そのもの(full-text)。ハッシュ化は禁止 (32-bit ハッシュは誕生日境界 ≈ 77,000 件で衝突し、別 tenant 本文の HTML を返す漏えいになる) -
キー前置部 = pipeline fingerprint + profile + scope + 解決済み content-scope config。 scope をキーに載せることは安全条件の一部——落とすと
clobberPrefixの違う HTML を 取り違え、別断片の脚注 id が付いた HTML を返す。 fingerprint は plugin 列と sanitize スキーマから導出し、構成変更で旧 HTML が自動失効する -
fingerprint が観測する非配列 plugin は
plugin.nameのみで、closure に閉じた設定値は見えない。 factory に options を追加する場合は、設定の直列化とキャッシュキー分離を同じ変更で実装する -
lru-cacheはmax+maxSize+sizeCalculation(UTF-8 バイト長)で有界 -
L2(ブラウザ永続)は不採用。必要性を計測してから設計する
SSR / Hydration 契約
-
サーバ生成 HTML を唯一の入力とする。ページの
+data.tsでdescriptionHtml: await renderDescription(text)を実行し、描画元テキストdescriptionSource(renderDescription へ渡した入力そのもの)と対でpageContext.dataに載せる。消費側は descriptionSource がクライアントの持つ 最新の生テキスト(task.description)と厳密一致するときだけ descriptionHtml をv-htmlへ流し、不一致(保存直後・reload 失敗・他者更新で HTML が古い)は プレーンテキスト表示へフォールバックする。descriptionHtml を単独で受けて 無条件にv-htmlする消費側を作ってはならない -
編集保存後に KFM 表示へ戻す手段は、同一 URL へ
navigate(href, { keepScrollPosition: true, overwriteLastHistoryEntry: true })で+data.tsを再実行すること(reload()はスクロール位置を維持できないため使わない)。overwriteLastHistoryEntry: trueは Vike が history の新規 push をせず現在エントリを 置き換えるため、保存のたびにブラウザ戻るが 1 段増えない(keepScrollPositionだけでは 履歴が積み上がる)。実装の単一ソースはtask-description-navigation.tsのTASK_DESCRIPTION_REFRESH_NAVIGATE_OPTIONS。 失敗時は descriptionSource 照合不一致によりプレーンテキスト表示のまま残る -
入力長の上限は 65536 文字(GitHub issue 本文の上限と同値。KFM Phase 1 = github profile の複製レンダラ)。上限は消費側の
+data.tsがrenderDescriptionを呼ぶ前に敷き、超過はdescriptionHtml: null= プレーンテキスト表示へ倒す (エラー表示は出さない)。renderDescription の CPU も L1 キャッシュ (full-text キー)のメモリも本文長に比例するため、SSR に非有界の入力を 入れない。新しい消費側(分割ビューのペイン・コメント本文等)を接続するときも 同じ上限を写すこと。 非対称に注意: backend は本文長を制限しないため、上限超の本文も保存はできる(表示だけプレーンへ倒れる) -
SSR の消費側で KFM 描画前に backend 取得が必要なら、直列リクエストごとの timeout ではなく、取得全体で共有する有界な fetch 予算を設けること。予算超過は
descriptionHtml: nullとしてプレーンテキスト表示へ倒し、ページ本体の表示・編集を 巻き添えにしない。これは backend の取得待ちだけの上限であり、SSR 全体の完了時間を 保証するものではない -
同一ページに複数の KFM 断片(タスク本文+コメント等)を並べる場合は、断片ごとに 決定的な scope を渡す:
renderDescription(text, { scope: 'comment-42' })。 ランダムにしないのは同一入力→同一 HTML(L1 キャッシュ・SSR/CSR 同一性)を保つため。 -
scope は
[A-Za-z0-9_-]+のみ許可し、それ以外は throw する。加えて-区切り セグメントとしてfn/fnrefを含む scope(fn-1、a-fn-b等)も throw する (脚注 id はuser-content-<scope>-fn-<label>で label は利用者入力のため、細工した label と別 scope の id が一致し得る。scope 側からセグメントを禁止すれば衝突しない) -
scope 付き描画では脚注 id(
fn-*/fnref-*)に加え、footnote-labelと それを指すaria-describedbyも scope 付きになる。scope 無しの既定描画は GitHub 互換の固定footnote-labelを保つ -
入口で
\r\nと単独\rを\nへ正規化する(キー構築より前)。正規化しないと alert のマーカー照合が CRLF 本文で成立せず、LF 版と CRLF 版が別 HTML・ 別キャッシュエントリになる -
クライアントは再パース・再サニタイズしない(DOMPurify はサーバで一度だけ)
-
カスタム要素の登録は
src/pages/+client.ts(client 専用 entry)から行い、関数側にもcustomElements不在ガードを持つ二重防御。main.ts は存在せず、+onCreateApp.tsは SSR でも走るため使わない
利用方法
// +data.ts(サーバ側)— 実体は tasks/@taskId/+data.ts。scope はタスク UUID で決定的。
// HTML は描画元テキストと必ず対で返す(消費側の stale 照合キー)
import { renderDescription } from '@/lib/markup-renderer';
const descriptionHtml = await renderDescription(task.description, { scope: `task-${task.id}` });
return { descriptionHtml, descriptionSource: task.description };
// 消費側レイアウトで alert / GFM / 着色 CSS を明示 import
import '@/lib/remark-koyori-alerts/style.css';
import '@/lib/remark-gfm/style.css';
import '@/lib/rehype-starry-night/style.css';
import '@/lib/remark-kfm-mermaid/style.css';
<!-- 消費側コンポーネント: 描画元が最新の生テキストと厳密一致するときだけ v-html する。
器に kfm-content を付ける(GFM CSS の scope) -->
<div
v-if="descriptionHtml && descriptionSource === task.description"
class="kfm-content"
v-html="descriptionHtml"
/>
<!-- 不一致・HTML なしはプレーンテキスト表示へフォールバック -->
<p v-else>{{ ERROR }}</p>
四つのサイドカー CSS は消費契約の前提が異なる:
-
alerts CSS は import のみで当たる — レンダラ自身が名前空間クラス (
.kfm-alert等)を emit し、CSS がそれを直接指すため器は不要 -
GFM CSS は import + 器クラスの二点契約 — GFM 出力は素の ul/ol/blockquote/a/del で掴む class が無く、bare 要素へ当てるとアプリ全体へ漏れるため、全ルールが
.kfm-content子孫限定。器クラスを付け忘れると一行も当たらない -
着色 CSS も import のみで当たる — starry-night が emit する
pl-*名前空間クラスを 直接指す。実体は upstream の light シート固定 +.darkブリッジ(アプリの class 戦略ダークに追従。OS 設定連動の both.css は使わない — 発火条件を.darkの一系統に畳み、OS ダーク × アプリライトでコードだけ暗転する継ぎ目を防ぐ) -
mermaid CSS は import のみで当たる — JS 無効時または描画失敗時の light DOM ソースを
white-space: preと横スクロールで読める状態に保つ
着色 transformer の初期化・変換が reject した場合、renderDescription も reject する。
未着色コードへ部分フォールバックはせず、+data.ts が本文 HTML を await する標準構成では
ページデータ生成が失敗するため、コードブロックだけでなく本文全体が描画されない。呼出側が
独自に継続表示させる場合は、失敗を握り潰さず本文全体の明示的なエラー表示へ切り替えること。
器クラスの単一ソースは remark-gfm/content-class.ts(KFM_CONTENT_CLASS)。CSS との
scope 一致は kfm-gfm-css-contract.test.ts が強制し、story の器も同じ定数を使う
(VRT baseline の器 = 本番の器)。
拡張の口(seam)
将来のフレーバー追加は以下の 3 点に閉じる。コア・sanitize・cache・SSR 契約は共有のまま:
_renderer.tsのKfmProfileunion にプロファイル名を足す- composition root(
index.ts)のprofilesにそのプロファイルの remark 層を注入する - 追加プラグインの
SanitizeSchema(class / タグ / 属性)をsanitizeSchemasに合流させる
カスタム要素は _client-registry.ts の定義配列に追加し、対応プラグインの SanitizeSchema.tags /
attrs と三点を揃える。多層 config(_config.ts)は解決層を重ねる形で拡張する。
追加プラグインは生 HTML や inline style を出さず、data.hName / hProperties の型付き emit と
対応する SanitizeSchema を同じ変更で提供する。新しい class は完全一致 token を原則とし、
動的な値が必要な場合だけ、許容字種を狭く固定した classPatterns と陽性・陰性試験を加える。
テスト
src/lib/__tests__/kfm-*.test.ts(ファイル数・テスト数は増え続けるため書かない。
現在値は pnpm test:unit の出力を正とする):
-
kfm-renderer.test.ts— GFM 基本・alerts 境界・安全 core・決定性・profile fail-closed -
kfm-sanitize.test.ts— FORBID style・class 完全一致・XSS 基本・カスタム要素 registry -
kfm-cache.test.ts— djb2 衝突ペアの実衝突証明つき full-text キー検証・fingerprint 分離 -
kfm-client-registry.test.ts— SSR ガード(customElements 不在で no-op)・二重 define 安全 -
kfm-gfm-css-contract.test.ts— KFM サイドカー CSS(remark-* / rehype-*)を scope 方式で 全件分類し、器クラスまたは各プラグインの emit 名前空間から逸脱しないことを固定 -
kfm-code-highlight.test.ts— 着色の境界仕様(言語別 pl-*・style 属性禁止・未知言語 フォールバック・注入ペイロード封じ・sanitize 整合) -
kfm-starry-night-init-count.test.ts— 文法初期化「回数」の機械計数(N scope 描画で 初期化 1 回・旧配線(factory 直挿し)が N 回になる陽性対照つき) -
kfm-starry-night-upstream-contract.test.ts— upstream 実体(theme.js の classes 値域・ light/both/dark CSS)と sanitize 許可・サイドカー style.css の契約固定 -
kfm-processor-memoize.test.ts— processor 構築回数の機械計数(既定 prefix は memoize・ scope 付きは都度構築だが初期化回数とは独立) -
kfm-starry-night-init-failure.test.ts— 初期化失敗(poisoned promise)を捨てて次描画で 作り直す回収経路 -
kfm-story-fixtures.test.ts— story fixture の drift 検査・孤立 rendered/*.html の検出 -
kfm-mermaid.test.ts— mermaid フェンス変換(remark 層)と custom element(client 層・ mermaid mock)の契約固定(strict 固定・.dark テーマ切替・SVG 挿入前検査・error フォールバック) -
kfm-mermaid-fixtures.test.ts— mermaid story fixture(rendered/mermaid-*.html)の drift 検査
セキュリティ上の要点(inline style 禁止・full-text キー・client ガード)はいずれも 「その規約を破る変更を入れるとテストが落ちる」形で書かれている。
story fixture
KFM の Storybook story (stories/kfm/*) は本番と同じ「サーバ生成 HTML を v-html するだけ」
の同期描画で VRT baseline を決定的にする。器も本番と同じ .kfm-content
(remark-gfm/content-class.ts の定数を import)で、GFM サイドカー CSS が本番と同じ条件で
当たる。fixture の運用は次の四点:
-
入力の単一ソースは
src/lib/kfm-story-fixtures/inputs.tsとinputs-mermaid.ts(キー 1 つ = fixture 1 枚 = story 1 つが基本。他枝と fixture 名を重ねないため mermaid 分はmermaid-接頭辞の別ファイル・drift 検査はkfm-mermaid-fixtures.test.ts) -
rendered/*.htmlはrenderDescriptionの事前生成物で、手で書き換えない -
再生成は
pnpm test:unit --update(kfm-story-fixtures.test.tsのtoMatchFileSnapshotが drift を CI で強制) -
vite.config.tsのfmt.ignorePatternsからrendered/**を外すと drift 検査が偽陽性で落ちる(生成 HTML の整形差分を formatter が触るため)