Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/@d-zero/site-migrator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
assignPageIds,
buildPageIdLookup,
rewritePageRefs,
resolveIdTemplate,
} from '@d-zero/site-migrator';
```

Expand All @@ -64,6 +65,7 @@ import {
| `assignPageIds` | URL リストから ディレクトリグループ採番ルールに従って `Map<url, id>` を組み立てる純関数 |
| `buildPageIdLookup` | `assignPageIds` の結果から `rewritePageRefs` 用ルックアップ表を一度だけ構築する純関数 |
| `rewritePageRefs` | 同一オリジンの asset/page 参照を root-relative path / `{{<id>}}` テンプレートに書き換える |
| `resolveIdTemplate` | `{{<id>}}` token を id→URL マップで実 URL に解決する純関数(後段ビルドツール用) |

`Frontmatter` の出力構造は [`./src/types.ts`](./src/types.ts) を参照。`title` / `og.title` / `twitter.title` は `|` `|` で分割して最初の非空セグメントを採用し、分割が起きたときだけ `rawTitle` 等に元文字列を保持する。

Expand Down Expand Up @@ -112,6 +114,14 @@ import {

`rewritePageRefs` が例外を投げた場合は fail-soft で書き換え前の HTML を出力し、`onResult` の `extracted` / `fallback` outcome に `rewriteError` フィールドを乗せてレポートする(`migrate()` の `pagesRewriteFailed` で集計)。フロントマター付与・id 採番は HTML 書き換えと独立して走るので、片方が失敗してももう片方は影響を受けない。

### `{{<id>}}` token の解決(後段ビルドツール)

`rewritePageRefs` が残す `{{<id>}}` 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 は発生しない。
Expand Down
2 changes: 2 additions & 0 deletions packages/@d-zero/site-migrator/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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: '<a href="{{42}}">about</a>',
idMap: new Map([[42, '/about/']]),
});
expect(out).toBe('<a href="/about/">about</a>');
});

test('preserves the trailing ?query#fragment that follows the token', () => {
const out = resolveIdTemplate({
html: '<a href="{{42}}?q=foo#top">about</a>',
idMap: new Map([[42, '/about/']]),
});
expect(out).toBe('<a href="/about/?q=foo#top">about</a>');
});

test('leaves the token verbatim and notifies onUnresolved when the id is missing', () => {
const seen: number[] = [];
const out = resolveIdTemplate({
html: '<a href="{{999}}">missing</a>',
idMap: new Map([[42, '/about/']]),
onUnresolved: (id) => seen.push(id),
});
expect(out).toBe('<a href="{{999}}">missing</a>');
expect(seen).toStrictEqual([999]);
});

test('handles multiple tokens including duplicates', () => {
const onUnresolved = vi.fn();
const out = resolveIdTemplate({
html: '<a href="{{10}}">x</a><a href="{{20}}">y</a><a href="{{10}}">z</a>',
idMap: new Map([
[10, '/x/'],
[20, '/y/'],
]),
onUnresolved,
});
expect(out).toBe('<a href="/x/">x</a><a href="/y/">y</a><a href="/x/">z</a>');
expect(onUnresolved).not.toHaveBeenCalled();
});

test('does not touch non-digit mustache tokens', () => {
const out = resolveIdTemplate({
html: '<p>Hello {{name}}, see {{42}}</p>',
idMap: new Map([[42, '/page/']]),
});
expect(out).toBe('<p>Hello {{name}}, see /page/</p>');
});

test('treats id 0 as a real key (no falsy-zero confusion)', () => {
const out = resolveIdTemplate({
html: '<a href="{{0}}">root</a>',
idMap: new Map([[0, '/zero/']]),
});
expect(out).toBe('<a href="/zero/">root</a>');
});

test('returns the html unchanged when no tokens exist', () => {
const html = '<p>no tokens here</p>';
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]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
export interface ResolveIdTemplateOptions {
/** HTML (or any text) containing `{{<id>}}` tokens to resolve. */
readonly html: string;
/** Mapping from page id to its resolved URL. */
readonly idMap: ReadonlyMap<number, string>;
/**
* 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 `{{<id>}}` tokens that {@link rewritePageRefs} emits. The

Check warning on line 15 in packages/@d-zero/site-migrator/src/page-extractor/resolve-id-template.ts

View workflow job for this annotation

GitHub Actions / test (macOS-latest, 24)

The type 'assignPageIds' is undefined

Check warning on line 15 in packages/@d-zero/site-migrator/src/page-extractor/resolve-id-template.ts

View workflow job for this annotation

GitHub Actions / test (macOS-latest, 24)

The type 'rewritePageRefs' is undefined
* 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 `{{<id>}}` token in `html` with the URL registered for that
* id in `idMap`. Unknown ids are left as `{{<id>}}` 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`)

Check warning on line 29 in packages/@d-zero/site-migrator/src/page-extractor/resolve-id-template.ts

View workflow job for this annotation

GitHub Actions / test (macOS-latest, 24)

The type 'rewritePageRefs' is undefined
* sit *outside* the token and survive unchanged — the resolver swaps only the
* `{{42}}` portion, so the resulting `<resolved>/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: '<a href="{{42}}?q=foo#top">about</a>',
* idMap: new Map([[42, '/about/']]),
* });
* // → '<a href="/about/?q=foo#top">about</a>'
*/
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;
});
}
Loading