From a24265f325e9869891c0adbd32deec3ebd2f85ce Mon Sep 17 00:00:00 2001 From: Yusuke Hirao Date: Thu, 18 Jun 2026 23:20:54 +0900 Subject: [PATCH] feat(site-migrator): add resolveIdTemplate pure function for downstream id token resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rewritePageRefs が同一オリジンのページ参照に対して残す `{{}}` token を、後段 ビルドツール側で実 URL に解決するための純関数を追加する。site-migrator 自身は 出力ディレクトリ構造やルーティング規約を知らないため、解決ロジックはここで API として用意して呼び出し側に委ねる。 scaffold + kamado への組み込みは別 PR で対応。 --- packages/@d-zero/site-migrator/README.md | 10 +++ packages/@d-zero/site-migrator/src/index.ts | 2 + .../resolve-id-template.spec.ts | 78 +++++++++++++++++++ .../src/page-extractor/resolve-id-template.ts | 58 ++++++++++++++ 4 files changed, 148 insertions(+) create mode 100644 packages/@d-zero/site-migrator/src/page-extractor/resolve-id-template.spec.ts create mode 100644 packages/@d-zero/site-migrator/src/page-extractor/resolve-id-template.ts diff --git a/packages/@d-zero/site-migrator/README.md b/packages/@d-zero/site-migrator/README.md index da504d01..6d0f6d69 100644 --- a/packages/@d-zero/site-migrator/README.md +++ b/packages/@d-zero/site-migrator/README.md @@ -41,6 +41,7 @@ import { assignPageIds, buildPageIdLookup, rewritePageRefs, + resolveIdTemplate, } from '@d-zero/site-migrator'; ``` @@ -64,6 +65,7 @@ import { | `assignPageIds` | URL リストから ディレクトリグループ採番ルールに従って `Map` を組み立てる純関数 | | `buildPageIdLookup` | `assignPageIds` の結果から `rewritePageRefs` 用ルックアップ表を一度だけ構築する純関数 | | `rewritePageRefs` | 同一オリジンの asset/page 参照を root-relative path / `{{}}` テンプレートに書き換える | +| `resolveIdTemplate` | `{{}}` token を id→URL マップで実 URL に解決する純関数(後段ビルドツール用) | `Frontmatter` の出力構造は [`./src/types.ts`](./src/types.ts) を参照。`title` / `og.title` / `twitter.title` は `|` `|` で分割して最初の非空セグメントを採用し、分割が起きたときだけ `rawTitle` 等に元文字列を保持する。 @@ -112,6 +114,14 @@ import { `rewritePageRefs` が例外を投げた場合は fail-soft で書き換え前の HTML を出力し、`onResult` の `extracted` / `fallback` outcome に `rewriteError` フィールドを乗せてレポートする(`migrate()` の `pagesRewriteFailed` で集計)。フロントマター付与・id 採番は HTML 書き換えと独立して走るので、片方が失敗してももう片方は影響を受けない。 +### `{{}}` token の解決(後段ビルドツール) + +`rewritePageRefs` が残す `{{}}` token は、後段のビルドツール(scaffold + kamado を想定)が `resolveIdTemplate` を通して実 URL に置換する想定。site-migrator 自身は URL を知らない(出力ディレクトリ構造やビルドツールのルーティング規約は site-migrator のスコープ外)ので、解決はビルド側の責務に切り出した。 + +`resolveIdTemplate({ html, idMap, onUnresolved? })` は純関数で、`{{42}}?q=foo#frag` のような末尾 `?query#fragment` は `idMap.get(42)` の URL の後ろにそのまま連結される(同じ URL に再度 `?` が含まれる場合のマージはしない — 必要なら呼び出し側で前処理する)。未解決の id は `{{42}}` のまま残し `onUnresolved(42)` を呼ぶことで、リンク切れがログ/例外で早期検出できる。 + +ビルドツール側の組み込みは別 PR で対応する。 + ### Frontmatter(DB ベース) `getFrontmatter` は `.nitpicker` DB の flat meta カラムを `Frontmatter` 型にマップする。`title` / `og.title` / `twitter.title` は `splitTitle` を通り、`|` / `|` で先頭セグメントが抜き出されたら元文字列を `rawTitle` 等に保持する(DB に rawTitle カラムが存在しないため `title` 全文から生成)。空文字列 / null / whitespace-only カラムは出力から落とすので、`description: ""` のような placeholder は発生しない。 diff --git a/packages/@d-zero/site-migrator/src/index.ts b/packages/@d-zero/site-migrator/src/index.ts index 463fccdb..9db6a6a3 100644 --- a/packages/@d-zero/site-migrator/src/index.ts +++ b/packages/@d-zero/site-migrator/src/index.ts @@ -43,6 +43,8 @@ export type { PageIdLookup, RewritePageRefsOptions, } from './page-extractor/rewrite-page-refs.js'; +export { resolveIdTemplate } from './page-extractor/resolve-id-template.js'; +export type { ResolveIdTemplateOptions } from './page-extractor/resolve-id-template.js'; export { migrate } from './migrate.js'; export type { MigrateOptions, MigrateReport } from './migrate.js'; diff --git a/packages/@d-zero/site-migrator/src/page-extractor/resolve-id-template.spec.ts b/packages/@d-zero/site-migrator/src/page-extractor/resolve-id-template.spec.ts new file mode 100644 index 00000000..5dadeaa1 --- /dev/null +++ b/packages/@d-zero/site-migrator/src/page-extractor/resolve-id-template.spec.ts @@ -0,0 +1,78 @@ +import { describe, expect, test, vi } from 'vitest'; + +import { resolveIdTemplate } from './resolve-id-template.js'; + +describe('resolveIdTemplate', () => { + test('swaps a single {{id}} token for the mapped URL', () => { + const out = resolveIdTemplate({ + html: 'about', + idMap: new Map([[42, '/about/']]), + }); + expect(out).toBe('about'); + }); + + test('preserves the trailing ?query#fragment that follows the token', () => { + const out = resolveIdTemplate({ + html: 'about', + idMap: new Map([[42, '/about/']]), + }); + expect(out).toBe('about'); + }); + + test('leaves the token verbatim and notifies onUnresolved when the id is missing', () => { + const seen: number[] = []; + const out = resolveIdTemplate({ + html: 'missing', + idMap: new Map([[42, '/about/']]), + onUnresolved: (id) => seen.push(id), + }); + expect(out).toBe('missing'); + expect(seen).toStrictEqual([999]); + }); + + test('handles multiple tokens including duplicates', () => { + const onUnresolved = vi.fn(); + const out = resolveIdTemplate({ + html: 'xyz', + idMap: new Map([ + [10, '/x/'], + [20, '/y/'], + ]), + onUnresolved, + }); + expect(out).toBe('xyz'); + expect(onUnresolved).not.toHaveBeenCalled(); + }); + + test('does not touch non-digit mustache tokens', () => { + const out = resolveIdTemplate({ + html: '

Hello {{name}}, see {{42}}

', + idMap: new Map([[42, '/page/']]), + }); + expect(out).toBe('

Hello {{name}}, see /page/

'); + }); + + test('treats id 0 as a real key (no falsy-zero confusion)', () => { + const out = resolveIdTemplate({ + html: 'root', + idMap: new Map([[0, '/zero/']]), + }); + expect(out).toBe('root'); + }); + + test('returns the html unchanged when no tokens exist', () => { + const html = '

no tokens here

'; + const out = resolveIdTemplate({ html, idMap: new Map([[42, '/x/']]) }); + expect(out).toBe(html); + }); + + test('calls onUnresolved once per occurrence, even when the same id repeats', () => { + const seen: number[] = []; + resolveIdTemplate({ + html: '{{1}}-{{1}}-{{2}}', + idMap: new Map(), + onUnresolved: (id) => seen.push(id), + }); + expect(seen).toStrictEqual([1, 1, 2]); + }); +}); diff --git a/packages/@d-zero/site-migrator/src/page-extractor/resolve-id-template.ts b/packages/@d-zero/site-migrator/src/page-extractor/resolve-id-template.ts new file mode 100644 index 00000000..bc5bd8b1 --- /dev/null +++ b/packages/@d-zero/site-migrator/src/page-extractor/resolve-id-template.ts @@ -0,0 +1,58 @@ +export interface ResolveIdTemplateOptions { + /** HTML (or any text) containing `{{}}` tokens to resolve. */ + readonly html: string; + /** Mapping from page id to its resolved URL. */ + readonly idMap: ReadonlyMap; + /** + * Invoked once per unresolved token. The token is left untouched in the + * output; the callback is the channel callers use to warn / collect / + * escalate without coupling the resolver to any specific log sink. + */ + readonly onUnresolved?: (id: number) => void; +} + +/** + * Matches the `{{}}` tokens that {@link rewritePageRefs} emits. The + * integer body is captured so the resolver never has to re-parse it. Non-digit + * mustaches (e.g. `{{name}}` from other templating layers) are deliberately + * skipped — id tokens are always digits because they originate from + * {@link assignPageIds}. + */ +const TOKEN_RE = /\{\{(\d+)\}\}/g; + +/** + * Replaces every `{{}}` token in `html` with the URL registered for that + * id in `idMap`. Unknown ids are left as `{{}}` so the broken link is + * visible at runtime instead of disappearing silently; `onUnresolved` is the + * notification channel for those. + * + * Side-channel suffixes added by {@link rewritePageRefs} (`{{42}}?q=foo#frag`) + * sit *outside* the token and survive unchanged — the resolver swaps only the + * `{{42}}` portion, so the resulting `/about/?q=foo#frag` is + * concatenated for free. If the resolved URL itself contains a query (e.g. + * `/list?p=1`) and the source token also carried `?q=foo`, the concatenation + * yields two `?` separators; callers that need to merge query strings must do + * it themselves before calling. + * + * Pure function — no I/O, no globals — so it composes equally well in build + * hooks, dev-server transforms, or tests. + * @param options + * @example + * resolveIdTemplate({ + * html: 'about', + * idMap: new Map([[42, '/about/']]), + * }); + * // → 'about' + */ +export function resolveIdTemplate(options: ResolveIdTemplateOptions): string { + const { html, idMap, onUnresolved } = options; + return html.replaceAll(TOKEN_RE, (match, idDigits: string) => { + const id = Number(idDigits); + const url = idMap.get(id); + if (url === undefined) { + onUnresolved?.(id); + return match; + } + return url; + }); +}