From 68cee5aa5e17dbd71989cededb46ea30e6ff466a Mon Sep 17 00:00:00 2001 From: Yusuke Hirao Date: Tue, 11 Aug 2026 12:18:44 +0900 Subject: [PATCH] feat(site-migrator)!: extract BlockTargetAdapter to decouple BurgerEditor conversion extractPages/migrate no longer hardcode BurgerEditor's classify/rewriteRefs/render/ downloadFiles pipeline. They now delegate to a required `adapter` option (BlockTargetAdapter), with burgerEditorAdapter providing the existing BurgerEditor behavior as the built-in implementation. Archive access, anatomist wiring, id assignment, frontmatter generation, and same-origin ref rewriting stay as adapter-agnostic shared logic, so a future non-BurgerEditor target can reuse them without depending on @burger-editor/core. BREAKING CHANGE: `adapter` is now a required option on `extractPages` and `migrate`. Existing callers (including `dz-migrate`) must pass `burgerEditorAdapter` explicitly. No migration guide is provided (package is still 5.x-alpha and unused by any consumer). --- packages/@d-zero/site-migrator/README.md | 29 ++- packages/@d-zero/site-migrator/src/adapter.ts | 100 ++++++++ packages/@d-zero/site-migrator/src/cli.ts | 2 + packages/@d-zero/site-migrator/src/index.ts | 8 + .../@d-zero/site-migrator/src/migrate.spec.ts | 51 ++++ packages/@d-zero/site-migrator/src/migrate.ts | 22 +- .../page-extractor/burger-editor-adapter.ts | 77 ++++++ .../src/page-extractor/extract-pages.spec.ts | 123 +++++++++- .../src/page-extractor/extract-pages.ts | 221 ++++++++++-------- 9 files changed, 525 insertions(+), 108 deletions(-) create mode 100644 packages/@d-zero/site-migrator/src/adapter.ts create mode 100644 packages/@d-zero/site-migrator/src/page-extractor/burger-editor-adapter.ts diff --git a/packages/@d-zero/site-migrator/README.md b/packages/@d-zero/site-migrator/README.md index d29fbea9..24fc89a5 100644 --- a/packages/@d-zero/site-migrator/README.md +++ b/packages/@d-zero/site-migrator/README.md @@ -1,6 +1,6 @@ # `@d-zero/site-migrator` -`.nitpicker` アーカイブを入力とするウェブサイト移植ツールキット。サブリソースのローカル DL、ページ HTML のレイアウト剥がしと BurgerEditor ブロックへの変換、`.nitpicker` DB のページメタと採番した整数 id を YAML frontmatter として prepend する CLI、同一オリジン参照を後段パイプライン向けに書き換えるリライタ、および周辺ユーティリティ関数群を提供する。 +`.nitpicker` アーカイブを入力とするウェブサイト移植ツールキット。サブリソースのローカル DL、ページ HTML のレイアウト剥がしとブロック CMS 向け構造化データへの変換(既定は BurgerEditor ブロック、`BlockTargetAdapter` で差し替え可能)、`.nitpicker` DB のページメタと採番した整数 id を YAML frontmatter として prepend する CLI、同一オリジン参照を後段パイプライン向けに書き換えるリライタ、および周辺ユーティリティ関数群を提供する。 ## Installation @@ -39,6 +39,7 @@ import { rewriteAssetRefs, extractMainContent, extractPages, + burgerEditorAdapter, formatFrontmatter, splitTitle, assignPageIds, @@ -54,7 +55,8 @@ import { | 関数 | 概要 | | ----------------------- | --------------------------------------------------------------------------------------------------- | -| `migrate` | アーカイブを開き、リソース DL とページ抽出を並列実行する全体フロー | +| `migrate` | アーカイブを開き、リソース DL とページ抽出を並列実行する全体フロー。`adapter` オプション必須 | +| `burgerEditorAdapter` | `migrate`/`extractPages` の `adapter` に渡す、BurgerEditor 向け既定の `BlockTargetAdapter` 実装 | | `parseIncludePattern` | `--include` 生値 1 個を pathname プレフィックス/完全一致パターンへ解釈する純関数 | | `filterUrlsByInclude` | `--include` 値のリストでページ URL リストを絞り込む純関数。未マッチ値があれば `IncludeNoMatchError` | | `openArchive` | `.nitpicker` を開いてセッションを返す(要 `close()`) | @@ -66,7 +68,7 @@ import { | `urlToOutputPath` | URL を `` 配下のローカルパスへ変換 | | `rewriteAssetRefs` | HTML 内のアセット参照を resolver で書き換える(streaming) | | `extractMainContent` | レイアウト共通部分を剥がして本文要素の `outerHTML` を返す | -| `extractPages` | ページ一覧に `extractMainContent` + `getFrontmatter` を適用して書き出す | +| `extractPages` | ページ一覧に `extractMainContent` + `getFrontmatter` を適用して書き出す。`adapter` オプション必須 | | `formatFrontmatter` | `Frontmatter` を後段パイプライン互換の `---\n…\n---\n` YAML ブロック文字列にする | | `splitTitle` | タイトル文字列を `|` / `\|` で分割し `{title, rawTitle?}` を返す純関数 | | `assignPageIds` | URL リストから ディレクトリグループ採番ルールに従って `Map` を組み立てる純関数 | @@ -76,6 +78,21 @@ import { `Frontmatter` の出力構造は [`./src/types.ts`](./src/types.ts) を参照。`title` / `og.title` / `twitter.title` は `|` `|` で分割して最初の非空セグメントを採用し、分割が起きたときだけ `rawTitle` 等に元文字列を保持する。 +### 変換先ブロック CMS の差し替え(`BlockTargetAdapter`) + +`extractPages`/`migrate` はレイアウト解析結果(anatomist 由来の `LayoutAnalysisResult`)をどう構造化データへ変換しどう HTML にレンダリングするかを一切知らず、必須オプション `adapter`(`BlockTargetAdapter`)に委譲する。fetch・main 判定・anatomist 呼び出し・id 採番・frontmatter 生成・同一オリジン参照の `{{}}` 解決・書き出しは変換先に依存しない共通処理として `extractPages` 自身が担う。 + +アダプタは 4 メソッドで構成される。 + +| メソッド | 必須 | 役割 | +| --------------- | ---- | ------------------------------------------------------------------------------------- | +| `classify` | 必須 | anatomist のレイアウト解析結果から `TBlocks` を組み立てる。構造化できなければ `fatal` | +| `rewriteRefs` | 必須 | `TBlocks` 内の同一オリジン URL を `pageIdLookup` で書き換える | +| `render` | 必須 | `TBlocks` を最終的なラッパー HTML 文字列へレンダリングする | +| `downloadFiles` | 任意 | コーパス全体を 1 回のバッチで扱い、ダウンロード対象アイテムの重複 DL を回避する | + +BurgerEditor 向けの既定実装が `burgerEditorAdapter` で、`dz-migrate` CLI はこれを固定で使う。プログラマティック API では別のブロック CMS 向けに独自の `BlockTargetAdapter` 実装を渡せる(型と `@example` は [`./src/adapter.ts`](./src/adapter.ts) を参照)。 + ### `extractMainContent` のヒューリスティクス 精緻な構造推論はせず、以下の優先順位で「ページ内にちょうど 1 個だけ存在する」要素を本文として採用する。 @@ -137,11 +154,11 @@ import { `extractPages` は `extractMainContent` と `getFrontmatter` を並列実行し、main 要素が見つかったページには続けて BurgerEditor ブロック変換パイプライン(後述)を適用したうえで、生成した YAML ブロックを本文の先頭に prepend してから書き出す。整数 id は常に付与されるので「DB 行なし」のページでも `---\nid: \n---\n` ブロックは出る。`getFrontmatter` が例外を投げた場合は fail-soft で id-only frontmatter と本文を書き出し、`onResult` の outcome に `metaError` を載せて警告する(`migrate()` レポートでは `pagesMetaFailed` として集計される)。 -### BurgerEditor ブロック変換パイプライン(`dz-migrate` のデフォルト動作) +### BurgerEditor ブロック変換パイプライン(`burgerEditorAdapter` の内部実装) -`.nitpicker` アーカイブベースのレイアウト剥がしだけでは `data-bge-*` マーカーが無く、BurgerEditor 上では「1 個の wysiwyg フォールバックブロック」としてしか扱えない。site-migrator の存在意義は既存サイトを BurgerEditor で編集可能なブロック構造に変換することなので、このブロック変換はオプトインフラグではなく `extractPages` / `dz-migrate` の既定動作になっている(`--content-class` は必須オプションだが、指定すれば必ず変換が走る)。 +`.nitpicker` アーカイブベースのレイアウト剥がしだけでは `data-bge-*` マーカーが無く、BurgerEditor 上では「1 個の wysiwyg フォールバックブロック」としてしか扱えない。site-migrator の存在意義は既存サイトを BurgerEditor で編集可能なブロック構造に変換することなので、このブロック変換はオプトインフラグではなく `dz-migrate` の既定動作になっている(`--content-class` は必須オプションだが、指定すれば必ず変換が走る。`dz-migrate` は `adapter` に `burgerEditorAdapter` を固定で渡す)。 -処理は次の要素で構成される(いずれも `src/page-extractor/` 配下、統合前は内部 API だったが `extractPages` に組み込まれた現在も `index.ts` からは export していない): +処理は次の要素で構成される(いずれも `src/page-extractor/` 配下)。`resolvePageLayouts`/`mergeMainContent`/`isMainConsistent` は `extractPages` 自身が変換先非依存の共通処理として直接呼び、`classifyBlockItem`/`layoutToBlockData`/`rewriteBlockRefs`/`renderBlocks` は前節の `burgerEditorAdapter`(`index.ts` から export 済み)が内部で呼ぶ。いずれの個別関数自体も `index.ts` からは export していない: | 関数 | 概要 | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | diff --git a/packages/@d-zero/site-migrator/src/adapter.ts b/packages/@d-zero/site-migrator/src/adapter.ts new file mode 100644 index 00000000..33d37fb5 --- /dev/null +++ b/packages/@d-zero/site-migrator/src/adapter.ts @@ -0,0 +1,100 @@ +import type { DownloadResult } from './downloader/download-resources.js'; +import type { PageIdLookup } from './page-extractor/rewrite-page-refs.js'; +import type { LayoutAnalysisResult } from '@d-zero/anatomist/types'; + +/** + * {@link BlockTargetAdapter.classify}の戻り値。`main`要素は検出できたが変換先の構造化 + * データを組み立てられない場合は`fatal`(呼び出し側はページ全体を無変換HTMLへフォール + * バックする)。`partial`はブロック単位の低信頼度フォールバックが一部混じっているが + * ページ全体は諦めていない状態(アダプタ実装ごとの意味は`converted`/`partial`の閾値含め + * 実装依存)。 + */ +export type ClassifyResult = + { kind: 'fatal'; error: Error } | { kind: 'converted' | 'partial'; blocks: TBlocks }; + +export interface RewriteRefsResult { + readonly blocks: TBlocks; + /** + * 個別アイテムの参照書き換えが失敗した箇所の記録(fail-soft)。どのアイテムが失敗した + * か(インデックス等)の詳細はアダプタ実装依存のため、`message`に整形済みで含める + * こと — `extractPages`側は`error.message`だけを見て1ページ分の`Error`に集約する。 + */ + readonly errors: readonly Error[]; +} + +export interface DownloadFilesContext { + readonly outputDir: string; + /** 通常のリソースDLで既にカバー済みの絶対URL集合。二重DL回避用。 */ + readonly knownResourceUrls: ReadonlySet; + readonly limit?: number; + readonly signal?: AbortSignal; + readonly onResult?: (event: DownloadResult) => void; +} + +/** + * nitpickerアーカイブ由来のanatomistレイアウト解析結果を、特定のブロックCMS(BurgerEditor + * 等)向けの構造化データへ変換するためのプラガブルなインターフェース。`extractPages`/ + * `migrate`はこのインターフェースの向こう側の型(`TBlocks`)を一切知らず、fetch/main判定/ + * anatomist呼び出し/id採番/frontmatter生成/書き出しといった変換先非依存の足回りだけを担当 + * する。BurgerEditor向けの既定実装は{@link import('./page-extractor/burger-editor-adapter.js').burgerEditorAdapter}を参照。 + * @example + * ```ts + * // 全ページを固定のwysiwygテキストへ倒すだけの最小アダプタ。 + * const wysiwygOnlyAdapter: BlockTargetAdapter = { + * classify: () => ({ kind: 'converted', blocks: 'plain text only' }), + * rewriteRefs: async (blocks) => ({ blocks, errors: [] }), + * render: async (blocks, contentClass) => `
${blocks}
`, + * }; + * + * await migrate({ + * archivePath: 'site.nitpicker', + * outputDir: './htdocs', + * contentClass: 'js-editable-area', + * adapter: wysiwygOnlyAdapter, + * }); + * ``` + */ +export interface BlockTargetAdapter { + /** + * anatomistのレイアウト解析結果(同一URL・複数ビューポート分)から`TBlocks`を組み立てる。 + * `main`要素の検出自体(`extractMainContent`とanatomist検出結果の整合性)は呼び出し側 + * (`extractPages`)が事前にチェック済みで、ここでは渡されない — このメソッドは + * 「構造化できるかどうか」だけを判定すればよい。 + * @param layoutResults + */ + classify(layoutResults: readonly LayoutAnalysisResult[]): ClassifyResult; + + /** + * `TBlocks`内の同一オリジンURL参照を`pageIdLookup`で書き換える(ページ参照は既知なら + * `{{}}`化、それ以外はroot-relative化)。 + * @param blocks + * @param baseUrl + * @param pageIdLookup + */ + rewriteRefs( + blocks: TBlocks, + baseUrl: string, + pageIdLookup: PageIdLookup, + ): Promise>; + + /** + * `TBlocks`を最終的なラッパーHTML文字列へレンダリングする。呼び出し側は返り値の + * `wrapperHtml`の子要素だけを取り出し、既存main要素の子として差し替える + * (`mergeMainContent`、`contentClass`は同時にmain要素自身へ付与される)。 + * @param blocks + * @param contentClass + */ + render(blocks: TBlocks, contentClass: string): Promise; + + /** + * 任意。コーパス全体を1回のバッチで扱い、`TBlocks`内のダウンロード対象アイテムの + * 重複DLを回避しつつ実ファイルサイズ等を書き戻す。`blocksByUrl`内のアイテムは直接 + * mutateしてよい(同一オブジェクト参照のため下流に自動反映される)。省略時は何もしない。 + * @param blocksByUrl + * @param ctx + */ + downloadFiles?( + blocksByUrl: ReadonlyMap, + ctx: DownloadFilesContext, + ): Promise; +} diff --git a/packages/@d-zero/site-migrator/src/cli.ts b/packages/@d-zero/site-migrator/src/cli.ts index e4cd4be4..9f08e50d 100644 --- a/packages/@d-zero/site-migrator/src/cli.ts +++ b/packages/@d-zero/site-migrator/src/cli.ts @@ -3,6 +3,7 @@ import { parseArgs } from 'node:util'; import { IncludeNoMatchError, parseIncludePattern } from './include-filter.js'; import { migrate } from './migrate.js'; +import { burgerEditorAdapter } from './page-extractor/burger-editor-adapter.js'; const USAGE = `Usage: dz-migrate -o --content-class [--layout-json ] [--limit ] [--extract-limit ] [--include ]... @@ -130,6 +131,7 @@ async function main(argv: readonly string[]): Promise { archivePath, outputDir, contentClass, + adapter: burgerEditorAdapter, layoutJsonPath, downloadLimit, extractLimit, diff --git a/packages/@d-zero/site-migrator/src/index.ts b/packages/@d-zero/site-migrator/src/index.ts index b3518c6c..9abe861a 100644 --- a/packages/@d-zero/site-migrator/src/index.ts +++ b/packages/@d-zero/site-migrator/src/index.ts @@ -1,3 +1,10 @@ +export type { + BlockTargetAdapter, + ClassifyResult, + DownloadFilesContext, + RewriteRefsResult, +} from './adapter.js'; + export { openArchive } from './archive/open-archive.js'; export { listInternalPages } from './archive/list-internal-pages.js'; export { listInternalResources } from './archive/list-internal-resources.js'; @@ -28,6 +35,7 @@ export { export { splitTitle } from './html/split-title.js'; export type { TitlePair } from './html/split-title.js'; +export { burgerEditorAdapter } from './page-extractor/burger-editor-adapter.js'; export { extractPages } from './page-extractor/extract-pages.js'; export type { ExtractPageItem, diff --git a/packages/@d-zero/site-migrator/src/migrate.spec.ts b/packages/@d-zero/site-migrator/src/migrate.spec.ts index 3c6de015..1751c60a 100644 --- a/packages/@d-zero/site-migrator/src/migrate.spec.ts +++ b/packages/@d-zero/site-migrator/src/migrate.spec.ts @@ -1,3 +1,4 @@ +import type { BlockTargetAdapter } from './adapter.js'; import type { LayoutBlock } from '@d-zero/anatomist/types'; import { mkdtemp, readFile, rm } from 'node:fs/promises'; @@ -7,6 +8,7 @@ import path from 'node:path'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { IncludeNoMatchError, InvalidIncludeValueError } from './include-filter.js'; +import { burgerEditorAdapter } from './page-extractor/burger-editor-adapter.js'; vi.mock('./archive/open-archive.js', () => ({ openArchive: vi.fn(), @@ -187,6 +189,7 @@ describe('migrate', () => { archivePath: '/tmp/fake.nitpicker', outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, }); expect(report).toEqual({ @@ -269,6 +272,7 @@ describe('migrate', () => { archivePath: '/tmp/fake.nitpicker', outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, }); expect(report).toMatchObject({ @@ -298,6 +302,7 @@ describe('migrate', () => { archivePath: '/tmp/fake.nitpicker', outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, onResource: (event) => resources.push(event), onPage: (event) => pages.push(event), }); @@ -339,6 +344,7 @@ describe('migrate', () => { archivePath: '/tmp/fake.nitpicker', outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, }); expect(callOrder).toEqual(['fetch', 'getPageHtml']); @@ -365,6 +371,7 @@ describe('migrate', () => { archivePath: '/tmp/fake.nitpicker', outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, }); const written = await readFile(path.join(outputDir, 'p1.html'), 'utf8'); @@ -402,6 +409,7 @@ describe('migrate', () => { archivePath: '/tmp/fake.nitpicker', outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, }); const written = await readFile(path.join(outputDir, 'index.html'), 'utf8'); @@ -424,6 +432,7 @@ describe('migrate', () => { archivePath: '/tmp/fake.nitpicker', outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, }); expect(report).toMatchObject({ @@ -448,6 +457,7 @@ describe('migrate', () => { archivePath: '/tmp/fake.nitpicker', outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, }); expect(report).toMatchObject({ @@ -468,6 +478,7 @@ describe('migrate', () => { archivePath: '/tmp/fake.nitpicker', outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, }), ).rejects.toThrow('listing crashed'); expect(closeMock).toHaveBeenCalledTimes(1); @@ -501,6 +512,7 @@ describe('migrate', () => { archivePath: '/tmp/fake.nitpicker', outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, include: ['/about/'], }); @@ -530,6 +542,7 @@ describe('migrate', () => { archivePath: '/tmp/fake.nitpicker', outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, include: ['/index.html'], }); @@ -559,6 +572,7 @@ describe('migrate', () => { archivePath: '/tmp/fake.nitpicker', outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, include: ['/nope/'], }), ).rejects.toThrow(IncludeNoMatchError); @@ -580,6 +594,7 @@ describe('migrate', () => { archivePath: '/tmp/fake.nitpicker', outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, include: ['news/'], }), ).rejects.toThrow(InvalidIncludeValueError); @@ -606,6 +621,7 @@ describe('migrate', () => { archivePath: '/tmp/fake.nitpicker', outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, include: [], }); @@ -633,6 +649,7 @@ describe('migrate', () => { archivePath: '/tmp/fake.nitpicker', outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, include: ['/about/'], onInclude: (event) => events.push(event), }); @@ -643,8 +660,42 @@ describe('migrate', () => { archivePath: '/tmp/fake.nitpicker', outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, onInclude: (event) => events.push(event), }); expect(events).toStrictEqual([]); }); + + test('forwards options.adapter through to extractPages instead of hardcoding burgerEditorAdapter', async () => { + listInternalResourcesMock.mockReturnValue(iter([])); + listInternalPagesMock.mockReturnValue(iter([{ url: 'https://example.com/p' }])); + vi.stubGlobal('fetch', vi.fn()); + getPageHtmlMock.mockResolvedValueOnce( + '

x

', + ); + + // A non-BurgerEditor adapter (`TBlocks` is `string`, not `BlockData[]`). + // If `migrate()` ever hardcoded `burgerEditorAdapter` internally instead of + // forwarding `options.adapter` to `extractPages`, every other test in this + // file (which all pass `burgerEditorAdapter`) would still pass — only this + // test, asserting on the fake adapter's own markup, would catch it. + const fakeAdapter: BlockTargetAdapter = { + classify: () => ({ kind: 'converted', blocks: 'FAKE-BLOCKS' }), + rewriteRefs: (blocks) => Promise.resolve({ blocks, errors: [] }), + render: (blocks, contentClass) => + Promise.resolve(`
${blocks}
`), + }; + + await migrate({ + archivePath: '/tmp/fake.nitpicker', + outputDir, + contentClass: CONTENT_CLASS, + adapter: fakeAdapter, + }); + + const written = await readFile(path.join(outputDir, 'p.html'), 'utf8'); + expect(written).toBe( + `---\nid: 5\n---\n
FAKE-BLOCKS
`, + ); + }); }); diff --git a/packages/@d-zero/site-migrator/src/migrate.ts b/packages/@d-zero/site-migrator/src/migrate.ts index 128c04e7..240073ac 100644 --- a/packages/@d-zero/site-migrator/src/migrate.ts +++ b/packages/@d-zero/site-migrator/src/migrate.ts @@ -1,3 +1,4 @@ +import type { BlockTargetAdapter } from './adapter.js'; import type { DownloadItem, DownloadResult } from './downloader/download-resources.js'; import type { ExtractPageItem, @@ -11,14 +12,19 @@ import { downloadResources } from './downloader/download-resources.js'; import { filterUrlsByInclude } from './include-filter.js'; import { extractPages } from './page-extractor/extract-pages.js'; -export interface MigrateOptions { +export interface MigrateOptions { archivePath: string; outputDir: string; /** - * BurgerEditorの`editableArea`セレクタに対応させるクラス名。生成したブロック群を - * 埋め込む既存main要素自身の`classList`に追加する。移行先サイトのBurgerEditor設定に - * 依存する値であり、決め打ちのデフォルトを持たせると気づかれないまま不整合な出力を - * 生成しうるため必須({@link import('./page-extractor/extract-pages.js').ExtractPagesOptions.contentClass}参照)。 + * anatomistのレイアウト解析結果を変換先のブロックCMS向け構造化データへ変換する + * アダプタ。BurgerEditor向けには`burgerEditorAdapter`を渡す。 + */ + adapter: BlockTargetAdapter; + /** + * `adapter.render`に転送されるクラス名。生成したブロック群を埋め込む既存main要素 + * 自身の`classList`に追加する。移行先の変換対象設定に依存する値であり、決め打ちの + * デフォルトを持たせると気づかれないまま不整合な出力を生成しうるため必須 + * ({@link import('./page-extractor/extract-pages.js').ExtractPagesOptions.contentClass}参照)。 */ contentClass: string; /** @@ -111,11 +117,14 @@ export interface MigrateReport { * abort the whole migration. * @param options */ -export async function migrate(options: MigrateOptions): Promise { +export async function migrate( + options: MigrateOptions, +): Promise { const { archivePath, outputDir, contentClass, + adapter, layoutJsonPath, downloadLimit, extractLimit, @@ -202,6 +211,7 @@ export async function migrate(options: MigrateOptions): Promise { idUrls: allPageUrls, outputDir, contentClass, + adapter, layoutJsonPath, knownResourceUrls, limit: extractLimit, diff --git a/packages/@d-zero/site-migrator/src/page-extractor/burger-editor-adapter.ts b/packages/@d-zero/site-migrator/src/page-extractor/burger-editor-adapter.ts new file mode 100644 index 00000000..95c25a49 --- /dev/null +++ b/packages/@d-zero/site-migrator/src/page-extractor/burger-editor-adapter.ts @@ -0,0 +1,77 @@ +import type { + BlockTargetAdapter, + ClassifyResult, + RewriteRefsResult, +} from '../adapter.js'; +import type { BlockData } from '@burger-editor/core'; + +import { downloadBlockFiles } from './download-block-files.js'; +import { layoutToBlockData } from './layout-to-block-data.js'; +import { renderBlocks } from './render-blocks.js'; +import { rewriteBlockRefs } from './rewrite-block-refs.js'; + +/** + * `@burger-editor/core`/`@burger-editor/blocks`を使ってページを既存サイトから + * BurgerEditorの`data-bge-*`ブロック構造へ変換する、`BlockTargetAdapter`の既定実装。 + * `extractPages`/`migrate`のBurgerEditor向け利用(`dz-migrate` CLI含む)はこれを渡す。 + * @example + * ```ts + * import { burgerEditorAdapter, migrate } from '@d-zero/site-migrator'; + * + * await migrate({ + * archivePath: 'site.nitpicker', + * outputDir: './htdocs', + * contentClass: 'js-bge-content', + * adapter: burgerEditorAdapter, + * }); + * ``` + */ +export const burgerEditorAdapter: BlockTargetAdapter = { + classify(layoutResults): ClassifyResult { + const { blocks, fallbacks } = layoutToBlockData(layoutResults); + if (blocks.length === 0) { + return { + kind: 'fatal', + error: new Error( + 'レイアウト解析でブロック化可能なmain要素の子構造が見つかりませんでした', + ), + }; + } + return { kind: fallbacks.length > 0 ? 'partial' : 'converted', blocks }; + }, + + async rewriteRefs( + blocks, + baseUrl, + pageIdLookup, + ): Promise> { + const rewritten = await rewriteBlockRefs({ blocks, baseUrl, pageIdLookup }); + return { + blocks: rewritten.blocks, + // `RewriteBlockRefsError`のblock/row/itemインデックスは`BlockTargetAdapter`の + // 公開契約には出てこない(`errors: readonly Error[]`)ため、ここで整形済み + // メッセージを持つ`Error`へ変換してから返す。 + errors: rewritten.errors.map( + (e) => + new Error( + `block ${e.blockIndex}/row ${e.rowIndex}/item ${e.itemIndex}: ${e.error.message}`, + ), + ), + }; + }, + + async render(blocks, contentClass): Promise { + return renderBlocks(blocks, { contentClass }); + }, + + async downloadFiles(blocksByUrl, ctx): Promise { + await downloadBlockFiles({ + blocksByUrl, + outputDir: ctx.outputDir, + knownResourceUrls: ctx.knownResourceUrls, + limit: ctx.limit, + onResult: ctx.onResult, + signal: ctx.signal, + }); + }, +}; diff --git a/packages/@d-zero/site-migrator/src/page-extractor/extract-pages.spec.ts b/packages/@d-zero/site-migrator/src/page-extractor/extract-pages.spec.ts index 58a9f635..d7ad3c1d 100644 --- a/packages/@d-zero/site-migrator/src/page-extractor/extract-pages.spec.ts +++ b/packages/@d-zero/site-migrator/src/page-extractor/extract-pages.spec.ts @@ -1,3 +1,4 @@ +import type { BlockTargetAdapter } from '../adapter.js'; import type { ArchiveSession } from '../types.js'; import type { LayoutBlock } from '@d-zero/anatomist/types'; @@ -7,6 +8,7 @@ import path from 'node:path'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { burgerEditorAdapter } from './burger-editor-adapter.js'; import { extractPages, type ExtractPageResult } from './extract-pages.js'; type RewritePageRefsModule = typeof import('./rewrite-page-refs.js'); @@ -169,6 +171,7 @@ describe('extractPages', () => { items: [{ url: 'https://example.com/about/' }], outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, limit: 1, onResult: (event) => results.push(event), }); @@ -206,6 +209,7 @@ describe('extractPages', () => { items: [{ url: 'https://example.com/x' }], outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, limit: 1, onResult: (event) => results.push(event), }); @@ -232,6 +236,7 @@ describe('extractPages', () => { items: [{ url: 'https://example.com/nope' }], outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, limit: 1, onResult: (event) => results.push(event), }); @@ -249,6 +254,7 @@ describe('extractPages', () => { items: [{ url: 'https://example.com/x' }], outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, limit: 1, onResult: (event) => results.push(event), }); @@ -267,6 +273,7 @@ describe('extractPages', () => { items: [], outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, limit: 1, }); expect(getPageHtmlMock).not.toHaveBeenCalled(); @@ -286,6 +293,7 @@ describe('extractPages', () => { ], outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, limit: 2, onResult: (event) => results.push(event), }); @@ -318,6 +326,7 @@ describe('extractPages', () => { items: [{ url: 'https://example.com/p' }], outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, limit: 1, }); @@ -340,6 +349,7 @@ describe('extractPages', () => { items: [{ url: 'https://example.com/p' }], outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, limit: 1, onResult: (event) => results.push(event), }); @@ -358,6 +368,7 @@ describe('extractPages', () => { items: [{ url: 'https://example.com/p' }], outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, limit: 1, }); @@ -380,6 +391,7 @@ describe('extractPages', () => { items: [{ url: 'https://example.com/p' }], outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, limit: 1, onResult: (event) => results.push(event), }); @@ -412,6 +424,7 @@ describe('extractPages', () => { ], outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, limit: 2, }); @@ -433,6 +446,7 @@ describe('extractPages', () => { idUrls: ['https://example.com/index.html', 'https://example.com/about/'], outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, limit: 1, }); @@ -452,6 +466,7 @@ describe('extractPages', () => { items: [{ url: 'https://example.com/index.html' }], outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, limit: 1, }); @@ -469,6 +484,7 @@ describe('extractPages', () => { idUrls: ['https://example.com/other.html'], outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, limit: 1, }); @@ -496,6 +512,7 @@ describe('extractPages', () => { items: [{ url: 'https://example.com/p' }], outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, limit: 1, onResult: (event) => results.push(event), }); @@ -506,7 +523,7 @@ describe('extractPages', () => { outcome: 'extracted', rewriteError: { message: - 'rewriteBlockRefs failed for 1 item(s): block 0/row 0/item 0: parse5 boom', + 'adapter.rewriteRefs failed for 1 item(s): block 0/row 0/item 0: parse5 boom', }, }); const written = await readFile(path.join(outputDir, 'p.html'), 'utf8'); @@ -525,6 +542,7 @@ describe('extractPages', () => { items: [{ url: 'https://example.com/p' }], outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, limit: 1, onResult: (event) => results.push(event), }); @@ -547,6 +565,7 @@ describe('extractPages', () => { items: [{ url: 'https://example.com/sub/page.html' }], outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, limit: 1, }); @@ -564,6 +583,7 @@ describe('extractPages', () => { items: [{ url: 'https://example.com/x' }], outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, limit: 1, signal: controller.signal, onResult: (event) => results.push(event), @@ -589,6 +609,7 @@ describe('extractPages', () => { ], outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, limit: 2, }); @@ -619,6 +640,7 @@ describe('extractPages', () => { ], outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, limit: 2, }); @@ -636,6 +658,7 @@ describe('extractPages', () => { items: [{ url: 'https://example.com/a' }, { url: 'https://example.com/b' }], outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, limit: 2, }); @@ -657,6 +680,7 @@ describe('extractPages', () => { items: [{ url: 'https://example.com/a' }], outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, limit: 1, }); @@ -680,6 +704,7 @@ describe('extractPages', () => { items: [{ url: 'https://example.com/a' }], outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, layoutJsonPath: '/tmp/layout.jsonl', limit: 1, }); @@ -729,6 +754,7 @@ describe('extractPages', () => { items: [{ url: 'https://example.com/p' }], outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, limit: 1, onResult: (event) => results.push(event), }); @@ -750,6 +776,7 @@ describe('extractPages', () => { items: [{ url: 'https://example.com/p' }], outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, limit: 1, onResult: (event) => results.push(event), }); @@ -796,6 +823,7 @@ describe('extractPages', () => { items: [{ url: 'https://example.com/p' }], outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, layoutJsonPath: '/tmp/layout.jsonl', limit: 1, onResult: (event) => results.push(event), @@ -841,6 +869,7 @@ describe('extractPages', () => { items: [{ url: 'https://example.com/p' }], outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, layoutJsonPath: '/tmp/layout.jsonl', limit: 1, onResult: (event) => results.push(event), @@ -887,6 +916,7 @@ describe('extractPages', () => { items: [{ url: 'https://example.com/p' }], outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, limit: 1, onResult: (event) => results.push(event), }); @@ -924,6 +954,7 @@ describe('extractPages', () => { items: [{ url: 'https://example.com/p' }], outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, limit: 1, onResult: (event) => results.push(event), }); @@ -945,6 +976,7 @@ describe('extractPages', () => { items: [{ url: 'https://example.com/p' }], outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, limit: 1, onResult: (event) => results.push(event), }); @@ -970,6 +1002,7 @@ describe('extractPages', () => { items: [{ url: 'https://example.com/p' }], outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, limit: 1, onResult: (event) => results.push(event), }); @@ -998,6 +1031,7 @@ describe('extractPages', () => { items: [{ url: 'https://example.com/p' }], outputDir, contentClass: CONTENT_CLASS, + adapter: burgerEditorAdapter, limit: 1, onResult: (event) => results.push(event), }); @@ -1011,3 +1045,90 @@ describe('extractPages', () => { }); }); }); + +describe('extractPages with a non-BurgerEditor adapter', () => { + // `burgerEditorAdapter`と異なる`TBlocks`(ここでは単純な文字列)を使うフェイクアダプタで + // パイプラインを回し、`extractPages`本体がBurgerEditorの型・関数に一切依存していない + // (`options.adapter`だけを介して変換先を差し替えられる)ことを検証する。 + let outputDir = ''; + + beforeEach(async () => { + outputDir = await mkdtemp(path.join(tmpdir(), 'site-migrator-fake-adapter-')); + getPageHtmlMock.mockReset(); + getFrontmatterMock.mockReset(); + resolvePageLayoutsMock.mockReset(); + getFrontmatterMock.mockResolvedValue(null); + mockConvertedLayout(); + }); + + afterEach(async () => { + await rm(outputDir, { recursive: true, force: true }); + }); + + test('classify/rewriteRefs/renderを経由してBurgerEditor以外の出力を書き出せる', async () => { + getPageHtmlMock.mockResolvedValueOnce( + docWith('

hello

'), + ); + + const fakeAdapter: BlockTargetAdapter = { + classify: () => ({ kind: 'converted', blocks: 'FAKE-BLOCKS' }), + rewriteRefs: (blocks) => + Promise.resolve({ blocks: `${blocks}-rewritten`, errors: [] }), + render: (blocks, contentClass) => + Promise.resolve(`
${blocks}
`), + }; + + const results: ExtractPageResult[] = []; + await extractPages({ + session: FAKE_SESSION, + items: [{ url: 'https://example.com/about/' }], + outputDir, + contentClass: CONTENT_CLASS, + adapter: fakeAdapter, + limit: 1, + onResult: (event) => results.push(event), + }); + + expect(results).toMatchObject([ + { + url: 'https://example.com/about/', + outcome: 'extracted', + blockConversion: 'converted', + }, + ]); + const written = await readFile(path.join(outputDir, 'about', 'index.html'), 'utf8'); + expect(written).toBe( + `---\nid: 10000\n---\n
FAKE-BLOCKS-rewritten
`, + ); + }); + + test('adapter.classifyがfatalを返した場合は元の完全なHTMLへフォールバックする', async () => { + const original = docWith('

hello

'); + getPageHtmlMock.mockResolvedValueOnce(original); + + const fakeAdapter: BlockTargetAdapter = { + classify: () => ({ kind: 'fatal', error: new Error('fake adapter refuses') }), + rewriteRefs: (blocks) => Promise.resolve({ blocks, errors: [] }), + render: (blocks, contentClass) => + Promise.resolve(`
${blocks}
`), + }; + + const results: ExtractPageResult[] = []; + await extractPages({ + session: FAKE_SESSION, + items: [{ url: 'https://example.com/p' }], + outputDir, + contentClass: CONTENT_CLASS, + adapter: fakeAdapter, + limit: 1, + onResult: (event) => results.push(event), + }); + + expect(results[0]).toMatchObject({ + outcome: 'extracted', + blockConversion: 'fallback', + }); + const written = await readFile(path.join(outputDir, 'p.html'), 'utf8'); + expect(written).toBe(`---\nid: 5\n---\n${original}`); + }); +}); diff --git a/packages/@d-zero/site-migrator/src/page-extractor/extract-pages.ts b/packages/@d-zero/site-migrator/src/page-extractor/extract-pages.ts index a95d1074..11b201d7 100644 --- a/packages/@d-zero/site-migrator/src/page-extractor/extract-pages.ts +++ b/packages/@d-zero/site-migrator/src/page-extractor/extract-pages.ts @@ -1,7 +1,7 @@ +import type { BlockTargetAdapter } from '../adapter.js'; import type { DownloadResult } from '../downloader/download-resources.js'; import type { ExtractMainCriterion } from '../html/extract-main-content.js'; import type { ArchiveSession, Frontmatter } from '../types.js'; -import type { BlockData } from '@burger-editor/core'; import { mkdir, writeFile } from 'node:fs/promises'; import path from 'node:path'; @@ -19,18 +19,11 @@ import { parseMainTag } from '../html/parse-main-tag.js'; import { assignPageIds } from './assign-page-ids.js'; import { isMainConsistent } from './check-main-consistency.js'; -import { downloadBlockFiles } from './download-block-files.js'; -import { - DEFAULT_PRIMARY_VIEWPORT_NAME, - layoutToBlockData, - selectPrimary, -} from './layout-to-block-data.js'; -import { renderBlocks } from './render-blocks.js'; +import { DEFAULT_PRIMARY_VIEWPORT_NAME, selectPrimary } from './layout-to-block-data.js'; import { resolvePageLayouts, type ResolvePageLayoutResult, } from './resolve-page-layout.js'; -import { rewriteBlockRefs, type RewriteBlockRefsError } from './rewrite-block-refs.js'; import { buildPageIdLookup, rewritePageRefs, @@ -45,10 +38,15 @@ export interface ExtractPageItem { url: string; } -export interface ExtractPagesOptions { +export interface ExtractPagesOptions { session: ArchiveSession; items: readonly ExtractPageItem[]; outputDir: string; + /** + * anatomistのレイアウト解析結果を変換先のブロックCMS向け構造化データへ変換する + * アダプタ。BurgerEditor向けには`burgerEditorAdapter`を渡す。 + */ + adapter: BlockTargetAdapter; /** * {@link assignPageIds}の採番母集合となるURL一覧。省略時は`items`のURLから * 採番する(従来互換)。`items`のスーパーセットであること — 呼び出し側が @@ -61,9 +59,10 @@ export interface ExtractPagesOptions { */ idUrls?: readonly string[]; /** - * BurgerEditorの`editableArea`セレクタに対応させるクラス名。生成したブロック群を - * 埋め込む既存main要素自身の`classList`に追加する(新規ラッパー要素は追加しない)。 - * 移行先サイトのBurgerEditor設定に依存する値であり、決め打ちのデフォルトを持たせると + * `adapter.render`に転送されるクラス名。生成したブロック群を埋め込む既存main要素 + * 自身の`classList`に追加する(新規ラッパー要素は追加しない、{@link mergeMainContent} + * 参照)。BurgerEditor向け(`burgerEditorAdapter`)では`editableArea`セレクタに対応 + * させる値。移行先の変換対象設定に依存する値であり、決め打ちのデフォルトを持たせると * 気づかれないまま不整合な出力を生成しうるため必須。 */ contentClass: string; @@ -106,7 +105,7 @@ export type ExtractPageResult = blockConversionError?: Error; /** * Present only when {@link rewritePageRefs} (unconverted/fallback pages) - * or {@link rewriteBlockRefs} (converted/partial pages, one aggregate + * or `adapter.rewriteRefs` (converted/partial pages, one aggregate * `Error` per page even when multiple items failed) threw. The page * body was still written (fail-soft), but with original asset / page * references instead of the rewritten ones for the affected part. @@ -148,41 +147,43 @@ interface FatalBlockOutcome { readonly error: Error; } -interface ResolvedBlockOutcome { +interface ResolvedBlockOutcome { readonly kind: 'converted' | 'partial'; - readonly blocks: BlockData[]; + readonly blocks: TBlocks; } -type BlockOutcome = FatalBlockOutcome | ResolvedBlockOutcome; +type BlockOutcome = FatalBlockOutcome | ResolvedBlockOutcome; /** * For each page URL, reads the HTML snapshot from the archive, strips the * shared layout via {@link extractMainContent}, reads the per-page metadata * from the `.nitpicker` DB via {@link getFrontmatter}, converts the page's - * anatomist layout analysis into BurgerEditor blocks (see below), rewrites - * same-origin URL references via {@link rewritePageRefs}, and writes the - * result to disk under `outputDir` mirroring the URL pathname. + * anatomist layout analysis into the caller's `options.adapter` block + * structure (see below), rewrites same-origin URL references via + * {@link rewritePageRefs}, and writes the result to disk under `outputDir` + * mirroring the URL pathname. * * Mirrors {@link import('../downloader/download-resources.js').downloadResources} * in shape: failures (including pages absent from the archive) are surfaced via * `onResult`, never thrown, so a single bad page does not abort the run. * - * ## BurgerEditorブロック変換パイプライン(親Issue #977) + * ## ブロック変換パイプライン(アダプタ経由、親Issue #977) * * 全ページの`getPageHtml`+`extractMainContent`+`getFrontmatter`を並列実行し、 * main要素が見つかったページ(`extracted`候補)と見つからなかったページ(`fallback`)を * 仕分ける。main候補となった全URLはまとめて**1回**の{@link resolvePageLayouts}呼び出しに * 渡す — ページごとに呼ぶとPuppeteerブラウザをページ数だけlaunch/closeすることになり * 実運用サイト規模では致命的に遅くなるため、ブラウザを1回だけ起動して使い回す。続けて - * 各ページについて{@link layoutToBlockData}でBurgerEditorの`BlockData[]`へ変換し、 - * {@link rewriteBlockRefs}で`BlockData[]`内の同一オリジンURL(wysiwyg内の``等、 - * button.link、image.path[]、download-file.path)を書き換えてから{@link renderBlocks}で - * `data-bge-*`付きHTMLへ変換し、{@link mergeMainContent}でラッパー要素を挟まずに既存main - * 要素へ埋め込み、frontmatter→書き出し、という既存の後段パイプラインへ合流させる。 + * 各ページについて`options.adapter.classify`で変換先の構造化データ(`TBlocks`)へ変換し、 + * `options.adapter.rewriteRefs`で構造化データ内の同一オリジンURLを書き換えてから + * `options.adapter.render`でラッパーHTMLへ変換し、{@link mergeMainContent}でラッパー要素を + * 挟まずに既存main要素へ埋め込み、frontmatter→書き出し、という既存の後段パイプラインへ + * 合流させる。main要素検出自体(`extractMainContent`とanatomist検出結果の整合性)は + * `options.adapter`の関与なくこの関数自身が判定する({@link isMainConsistent}参照)。 * - * ブロック変換したページの本文はこの時点で既に`rewriteBlockRefs`により書き換え済みのため、 - * 後段の`rewritePageRefs`(本文全体への同一オリジン参照書き換え)は**適用しない** — - * 適用すると`rewriteBlockRefs`が既に埋め込んだ`{{}}`トークンを`rewritePageRefs`が + * ブロック変換したページの本文はこの時点で既に`adapter.rewriteRefs`により書き換え済みの + * ため、後段の`rewritePageRefs`(本文全体への同一オリジン参照書き換え)は**適用しない** — + * 適用すると`adapter.rewriteRefs`が既に埋め込んだ`{{}}`トークンを`rewritePageRefs`が * 通常URLとして再解釈し、`%7B%7B%7D%7D`のようなpercent-encode文字列へ壊してしまう * (ブロック変換が効かなかったページ・main非検出ページは元HTMLに`{{}}`token が * 存在しないため、従来通り`rewritePageRefs`を適用する)。 @@ -190,9 +191,10 @@ type BlockOutcome = FatalBlockOutcome | ResolvedBlockOutcome; * ### ページ単位の致命的フォールバック * * 以下のいずれかに該当する場合、そのページのブロック変換は諦め、`blockConversion: - * 'fallback'`として**ページ全体の元の完全なHTML**(`data-bge-*`マーカー無し)を書き出す - * (品質判断によるページ全体フォールバックは行わない — 個別ブロックの低信頼度は - * `blockConversion: 'partial'`として扱い、ページ全体は諦めない): + * 'fallback'`として**ページ全体の元の完全なHTML**({@link toRawFallback}が組み立てる、 + * adapter未介入のプレーンHTML)を書き出す(品質判断によるページ全体フォールバックは + * 行わない — 個別ブロックの低信頼度は`blockConversion: 'partial'`として扱い、ページ全体 + * は諦めない): * * - {@link resolvePageLayouts}がそのページについて完全に失敗した(`missing` outcome。 * ライブURL到達不可等) @@ -202,9 +204,9 @@ type BlockOutcome = FatalBlockOutcome | ResolvedBlockOutcome; * `extractMainContent`のマッチ結果から構築したセレクタを`mainContentSelector`として * 渡し、anatomistに同じ要素を解析させているため構造的に整合が保証されており、この * チェックは行わない) - * - {@link layoutToBlockData}が空の`blocks`を返した(anatomist側で`root`が - * 見つからなかった — mainは検出できたがブロック化できる構造が無い) - * - {@link renderBlocks}または{@link mergeMainContent}が例外を投げた + * - `options.adapter.classify`が`{kind: 'fatal'}`を返した(構造化できる構造が見つから + * なかった等、理由はアダプタ実装依存) + * - `options.adapter.render`または{@link mergeMainContent}が例外を投げた * * Output layout per file: * @@ -213,28 +215,31 @@ type BlockOutcome = FatalBlockOutcome | ResolvedBlockOutcome; * non-empty meta from the DB. The id is the only mandatory field. * - The body that follows depends on the outcome: * - `extracted`(`blockConversion: 'converted'`/`'partial'`)— main要素の`outerHTML` - * フラグメント。子要素はBurgerEditorブロック群に置き換わり、`contentClass`が + * フラグメント。子要素はadapterが生成したブロック群に置き換わり、`contentClass`が * main要素自身の`classList`に追加されている。 * - `extracted`(`blockConversion: 'fallback'`)/`fallback` — the entire original * document, DOCTYPE included. * - In both cases same-origin URLs are rewritten: `` / `
` * pointing at a known page → `{{}}`; other same-origin * asset references → root-relative paths. Cross-origin URLs are left - * untouched. ブロック変換したページ(`converted`/`partial`)は`rewriteBlockRefs`が - * `button.link`をページ参照、`image.path[]`/`download-file.path`をアセット参照として - * 個別に扱う(詳細は{@link rewriteBlockRefs}のJSDoc参照)。 + * untouched. ブロック変換したページ(`converted`/`partial`)は`adapter.rewriteRefs`が + * アイテム種別ごとにページ参照/アセット参照を判定する(詳細は使用中のアダプタ実装の + * JSDoc参照。例: {@link import('./burger-editor-adapter.js').burgerEditorAdapter})。 * * Rewrite failure is fail-soft: the original HTML body is written and * `rewriteError` is set on the result so the caller can log a warning without * losing the page. * @param options */ -export async function extractPages(options: ExtractPagesOptions): Promise { +export async function extractPages( + options: ExtractPagesOptions, +): Promise { const { session, items, outputDir, contentClass, + adapter, layoutJsonPath, idUrls, knownResourceUrls = new Set(), @@ -380,27 +385,26 @@ export async function extractPages(options: ExtractPagesOptions): Promise }); } - // --- Resolve each matched page's BlockData (or the fatal reason it - // can't be produced), independent of rendering. --- - const blockOutcomeByUrl = new Map(); + // --- Resolve each matched page's TBlocks (or the fatal reason it can't be + // produced), independent of rendering. --- + const blockOutcomeByUrl = new Map>(); for (const state of matchedStates) { blockOutcomeByUrl.set( state.entry.url, - resolveBlockOutcome(state, layoutResultsByUrl), + resolveBlockOutcome(state, layoutResultsByUrl, adapter), ); } // --- download-file dedupe + real DL, batched across every page that has a - // usable BlockData tree (fatal pages have nothing to scan). --- - const blocksByUrl = new Map(); + // usable TBlocks tree (fatal pages have nothing to scan). --- + const blocksByUrl = new Map(); for (const [url, outcome] of blockOutcomeByUrl) { if (outcome.kind !== 'fatal') { blocksByUrl.set(url, outcome.blocks); } } if (blocksByUrl.size > 0) { - await downloadBlockFiles({ - blocksByUrl, + await adapter.downloadFiles?.(blocksByUrl, { outputDir, knownResourceUrls, limit, @@ -416,11 +420,12 @@ export async function extractPages(options: ExtractPagesOptions): Promise const { entry } = state; try { if (!state.matched) { - let bodyHtml = state.extractedHtml; + const fallback = toRawFallback(state); + let bodyHtml = fallback.html; let rewriteError: Error | undefined; try { bodyHtml = await rewritePageRefs({ - html: state.extractedHtml, + html: fallback.html, baseUrl: entry.url, pageIdLookup, }); @@ -449,6 +454,7 @@ export async function extractPages(options: ExtractPagesOptions): Promise contentClass, entry.url, pageIdLookup, + adapter, ); let bodyHtml = built.body; @@ -499,25 +505,29 @@ export async function extractPages(options: ExtractPagesOptions): Promise /** * `resolvePageLayouts`の結果と`extractMainContent`の結果から、そのページのブロック変換が * 続行可能かを判定する。続行不能(致命的)と判定した場合は理由を`Error`として保持する。 + * `TBlocks`の形に依存しないmain要素検出結果の整合チェック(#978)はこの関数自身が行い、 + * それを通過したものだけを`adapter.classify`(アダプタ実装依存の構造化可否判定)へ渡す。 * - * main要素検出結果の整合チェック(#978)は`resolved-from-json`のときのみ行う。 - * `resolved-live`はライブ解析の呼び出し時点で`extractMainContent`のマッチ結果から - * 構築したセレクタを`mainContentSelector`として渡し、anatomistに同じ要素を解析させて - * いるため(`extract-pages.ts`の`resolvePageLayouts`呼び出し箇所参照)、構造的に整合が - * 保証されており追加チェックは不要。 + * main要素検出結果の整合チェックは`resolved-from-json`のときのみ行う。`resolved-live`は + * ライブ解析の呼び出し時点で`extractMainContent`のマッチ結果から構築したセレクタを + * `mainContentSelector`として渡し、anatomistに同じ要素を解析させているため + * (`extract-pages.ts`の`resolvePageLayouts`呼び出し箇所参照)、構造的に整合が保証されて + * おり追加チェックは不要。 * @param state * @param state.entry * @param state.entry.url * @param state.extractedHtml * @param layoutResultsByUrl + * @param adapter */ -function resolveBlockOutcome( +function resolveBlockOutcome( state: { readonly entry: { readonly url: string }; readonly extractedHtml: string; }, layoutResultsByUrl: ReadonlyMap, -): BlockOutcome { + adapter: BlockTargetAdapter, +): BlockOutcome { const layoutEvent = layoutResultsByUrl.get(state.entry.url); if (!layoutEvent) { return { @@ -530,8 +540,8 @@ function resolveBlockOutcome( } const { results, outcome } = layoutEvent; - const primary = selectPrimary(results, DEFAULT_PRIMARY_VIEWPORT_NAME); if (outcome === 'resolved-from-json') { + const primary = selectPrimary(results, DEFAULT_PRIMARY_VIEWPORT_NAME); const matchedTag = parseMainTag(state.extractedHtml); if (!isMainConsistent(matchedTag, primary?.root ?? null)) { return { @@ -543,64 +553,88 @@ function resolveBlockOutcome( } } - const { blocks, fallbacks } = layoutToBlockData(results); - if (blocks.length === 0) { - return { - kind: 'fatal', - error: new Error( - 'レイアウト解析でブロック化可能なmain要素の子構造が見つかりませんでした', - ), - }; - } + return adapter.classify(results); +} + +/** + * 「変換が一切行われない」2つのケース(main非検出/main検出はできたがadapterが構造化 + * できなかった)を統一的に表す内部ヘルパー値(adapterの公開型ではなく、この関数自身の + * 実装詳細)。常に元の完全なHTML(DOCTYPE含む)を持つ。 + */ +interface RawFallback { + readonly html: string; +} - return { kind: fallbacks.length > 0 ? 'partial' : 'converted', blocks }; +/** + * @param state + * @param state.originalHtml + * @param state.extractedHtml + * @param state.matched + */ +function toRawFallback(state: { + readonly originalHtml: string; + readonly extractedHtml: string; + readonly matched: boolean; +}): RawFallback { + // main非検出時(`!matched`)は`extractMainContent`が不一致で`extractedHtml`に元のHTML + // 全体そのものを返すため、`originalHtml`/`extractedHtml`のどちらを使っても値は一致する + // が、意図を明示するため分岐する。 + return { html: state.matched ? state.originalHtml : state.extractedHtml }; } /** * `blockOutcome`から本文HTMLと`blockConversion`分類を組み立てる。ブロック変換できた - * ページ(`converted`/`partial`)は`renderBlocks`を呼ぶ**前**に{@link rewriteBlockRefs}で - * `BlockData[]`内の同一オリジンURLを書き換えるため、返す`body`はこの時点で既に - * 書き換え済み(`alreadyRewritten: true`)— 呼び出し側は`rewritePageRefs`を`body`全体へ - * 重ねて適用してはならない(`{{}}`トークンの二重処理による文字化けを防ぐため)。 - * `renderBlocks`/`mergeMainContent`が例外を投げた場合もここでfatalとして扱い、ページ全体の - * 元の完全なHTMLへフォールバックする(この場合`alreadyRewritten: false`— 元の完全なHTMLは - * 未書き換えのため、呼び出し側の従来通りの`rewritePageRefs`適用が必要)。 + * ページ(`converted`/`partial`)は`adapter.render`を呼ぶ**前**に`adapter.rewriteRefs`で + * `TBlocks`内の同一オリジンURLを書き換えるため、返す`body`はこの時点で既に書き換え済み + * (`alreadyRewritten: true`)— 呼び出し側は`rewritePageRefs`を`body`全体へ重ねて適用しては + * ならない(`{{}}`トークンの二重処理による文字化けを防ぐため)。`adapter.render`/ + * {@link mergeMainContent}が例外を投げた場合もここでfatalとして扱い、{@link toRawFallback} + * が組み立てるページ全体の元の完全なHTMLへフォールバックする(この場合 + * `alreadyRewritten: false`— 元の完全なHTMLは未書き換えのため、呼び出し側の従来通りの + * `rewritePageRefs`適用が必要)。 * @param state * @param state.originalHtml * @param state.extractedHtml + * @param state.matched * @param blockOutcome * @param contentClass * @param baseUrl * @param pageIdLookup + * @param adapter */ -async function buildExtractedBody( - state: { readonly originalHtml: string; readonly extractedHtml: string }, - blockOutcome: BlockOutcome, +async function buildExtractedBody( + state: { + readonly originalHtml: string; + readonly extractedHtml: string; + readonly matched: boolean; + }, + blockOutcome: BlockOutcome, contentClass: string, baseUrl: string, pageIdLookup: PageIdLookup, + adapter: BlockTargetAdapter, ): Promise<{ body: string; blockConversion: 'converted' | 'partial' | 'fallback'; blockConversionError?: Error; alreadyRewritten: boolean; - blockRewriteErrors?: readonly RewriteBlockRefsError[]; + blockRewriteErrors?: readonly Error[]; }> { if (blockOutcome.kind === 'fatal') { return { - body: state.originalHtml, + body: toRawFallback(state).html, blockConversion: 'fallback', blockConversionError: blockOutcome.error, alreadyRewritten: false, }; } try { - const rewritten = await rewriteBlockRefs({ - blocks: blockOutcome.blocks, + const rewritten = await adapter.rewriteRefs( + blockOutcome.blocks, baseUrl, pageIdLookup, - }); - const wrapperHtml = await renderBlocks(rewritten.blocks, { contentClass }); + ); + const wrapperHtml = await adapter.render(rewritten.blocks, contentClass); const merged = mergeMainContent({ mainHtml: state.extractedHtml, wrapperHtml, @@ -614,7 +648,7 @@ async function buildExtractedBody( }; } catch (error) { return { - body: state.originalHtml, + body: toRawFallback(state).html, blockConversion: 'fallback', blockConversionError: toError(error), alreadyRewritten: false, @@ -623,23 +657,20 @@ async function buildExtractedBody( } /** - * {@link rewriteBlockRefs}が返す複数の項目単位のエラーを、既存の`rewriteError?: Error` - * (1ページにつき1個)という`ExtractPageResult`の形へ合わせるため単一の`Error`へ集約する。 + * `adapter.rewriteRefs`が返す複数の項目単位のエラー(どのアイテムが失敗したかの詳細は + * `error.message`にアダプタ側が整形済みで含める契約、{@link BlockTargetAdapter}参照)を、 + * 既存の`rewriteError?: Error`(1ページにつき1個)という`ExtractPageResult`の形へ合わせる + * ため単一の`Error`へ集約する。 * @param errors */ function aggregateBlockRewriteErrors( - errors: readonly RewriteBlockRefsError[] | undefined, + errors: readonly Error[] | undefined, ): Error | undefined { if (!errors || errors.length === 0) { return undefined; } - const detail = errors - .map( - (e) => - `block ${e.blockIndex}/row ${e.rowIndex}/item ${e.itemIndex}: ${e.error.message}`, - ) - .join('; '); - return new Error(`rewriteBlockRefs failed for ${errors.length} item(s): ${detail}`); + const detail = errors.map((e) => e.message).join('; '); + return new Error(`adapter.rewriteRefs failed for ${errors.length} item(s): ${detail}`); } /**