From acf7ae134b5bbf7092b20a68310a1a1e260516a8 Mon Sep 17 00:00:00 2001 From: r74tech Date: Tue, 17 Mar 2026 16:39:10 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20resolveIncludesAsync=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 非同期fetcherに対応したinclude展開関数を追加。 Cloudflare Workers + D1等の非同期DB環境で、 ページを1つずつ非同期取得しながらinclude展開が可能になる。 - AsyncIncludeFetcher型を追加 - resolveIncludesAsync関数を追加(while + RegExp.exec + awaitで実装) - 既存の同期API (resolveIncludes, IncludeFetcher) は変更なし - テスト17件追加(同期版との結果一致、循環検出、maxDepth等) --- packages/parser/src/index.ts | 2 + .../rules/block/module/include/index.ts | 4 +- .../rules/block/module/include/resolve.ts | 109 +++++++++ .../src/parser/rules/block/module/index.ts | 4 +- .../unit/module/include/resolve-async.test.ts | 227 ++++++++++++++++++ 5 files changed, 342 insertions(+), 4 deletions(-) create mode 100644 tests/unit/module/include/resolve-async.test.ts diff --git a/packages/parser/src/index.ts b/packages/parser/src/index.ts index d3ed5f0..afe8b10 100644 --- a/packages/parser/src/index.ts +++ b/packages/parser/src/index.ts @@ -118,6 +118,7 @@ export type { ResolveOptions, // Include resolution IncludeFetcher, + AsyncIncludeFetcher, ResolveIncludesOptions, // ListUsers types ListUsersVariable, @@ -143,6 +144,7 @@ export { compileTemplate, // Include resolution resolveIncludes, + resolveIncludesAsync, // Query normalization (for advanced use cases) normalizeQuery, parseTags, diff --git a/packages/parser/src/parser/rules/block/module/include/index.ts b/packages/parser/src/parser/rules/block/module/include/index.ts index 7bc2f88..7a9ebd0 100644 --- a/packages/parser/src/parser/rules/block/module/include/index.ts +++ b/packages/parser/src/parser/rules/block/module/include/index.ts @@ -16,5 +16,5 @@ * @module */ -export { resolveIncludes } from "./resolve"; -export type { IncludeFetcher, ResolveIncludesOptions } from "./resolve"; +export { resolveIncludes, resolveIncludesAsync } from "./resolve"; +export type { IncludeFetcher, AsyncIncludeFetcher, ResolveIncludesOptions } from "./resolve"; diff --git a/packages/parser/src/parser/rules/block/module/include/resolve.ts b/packages/parser/src/parser/rules/block/module/include/resolve.ts index 43396f0..9e118fb 100644 --- a/packages/parser/src/parser/rules/block/module/include/resolve.ts +++ b/packages/parser/src/parser/rules/block/module/include/resolve.ts @@ -33,6 +33,16 @@ import type { PageRef, VariableMap, WikitextSettings } from "@wdprlib/ast"; */ export type IncludeFetcher = (pageRef: PageRef) => string | null; +/** + * Async callback to fetch page content for include resolution. + * Returns a promise of the wikitext source, or null if the page does not exist. + * + * @security The fetcher is called with user-provided page references. + * Implementations should validate and sanitize page references before + * using them in database queries or file system access. + */ +export type AsyncIncludeFetcher = (pageRef: PageRef) => Promise; + /** * Options for resolveIncludes */ @@ -87,6 +97,50 @@ export function resolveIncludes( return expandText(source, cachedFetcher, 0, maxDepth, []); } +/** + * Async version of {@link resolveIncludes}. + * + * Expand all [[include]] directives using an async fetcher, allowing + * page content to be loaded from async sources such as databases. + * + * @example + * ```ts + * const expanded = await resolveIncludesAsync(source, async (ref) => { + * return await db.getPageContent(ref.page); + * }); + * const ast = parse(expanded); + * ``` + */ +export async function resolveIncludesAsync( + source: string, + fetcher: AsyncIncludeFetcher, + options?: ResolveIncludesOptions, +): Promise { + if (options?.settings && !options.settings.enablePageSyntax) { + return source; + } + + const maxDepth = options?.maxDepth ?? 5; + const cache = new Map(); + + const cachedFetcher: AsyncIncludeFetcher = async (pageRef: PageRef) => { + const key = normalizePageKey(pageRef); + if (cache.has(key)) { + return cache.get(key)!; + } + let result: string | null; + try { + result = await fetcher(pageRef); + } catch { + result = null; + } + cache.set(key, result); + return result; + }; + + return expandTextAsync(source, cachedFetcher, 0, maxDepth, []); +} + /** * Regex to match [[include ...]] directives. * Captures the content between [[include and ]] (may span multiple lines). @@ -219,6 +273,61 @@ function expandText( }); } +/** + * Async version of {@link expandText}. + * + * Uses a while loop with RegExp.exec() instead of String.replace() because + * replace() does not support async callbacks. A local copy of the regex is + * created per invocation to avoid lastIndex conflicts across recursive calls. + */ +async function expandTextAsync( + source: string, + fetcher: AsyncIncludeFetcher, + depth: number, + maxDepth: number, + trace: string[], +): Promise { + if (depth >= maxDepth) return source; + + const pattern = new RegExp(INCLUDE_PATTERN.source, INCLUDE_PATTERN.flags); + let result = ""; + let lastPos = 0; + let match: RegExpExecArray | null; + + while ((match = pattern.exec(source)) !== null) { + const fullMatch = match[0]!; + const inner = match[1]!; + result += source.slice(lastPos, match.index); + + const { location, variables } = parseIncludeDirective(inner); + const pageKey = normalizePageKey(location); + + // Circular include detection + if (trace.includes(pageKey)) { + result += `[[div class="error-block"]]\nCircular include detected: "${location.page}"\n[[/div]]`; + } else { + // Fetch page content + const content = await fetcher(location); + if (content === null) { + result += `[[div class="error-block"]]\nPage to be included "${location.page}" cannot be found!\n[[/div]]`; + } else { + // Apply variable substitutions + const substituted = substituteVariables(content, variables); + // Recursively expand includes in the fetched content + result += await expandTextAsync(substituted, fetcher, depth + 1, maxDepth, [ + ...trace, + pageKey, + ]); + } + } + + lastPos = match.index + fullMatch.length; + } + + result += source.slice(lastPos); + return result; +} + /** * Normalize a PageRef into a consistent string key for cache lookups * and circular dependency detection. diff --git a/packages/parser/src/parser/rules/block/module/index.ts b/packages/parser/src/parser/rules/block/module/index.ts index 83f67b6..a50f10b 100644 --- a/packages/parser/src/parser/rules/block/module/index.ts +++ b/packages/parser/src/parser/rules/block/module/index.ts @@ -88,8 +88,8 @@ export type { TagCondition, IfTagsResolver, IfTagsData, IfTagsResolveResult } fr export { parseTagCondition, evaluateTagCondition, isIfTagsElement, resolveIfTags } from "./iftags"; // Include module -export type { IncludeFetcher, ResolveIncludesOptions } from "./include"; -export { resolveIncludes } from "./include"; +export type { IncludeFetcher, AsyncIncludeFetcher, ResolveIncludesOptions } from "./include"; +export { resolveIncludes, resolveIncludesAsync } from "./include"; // ListUsers module export type { diff --git a/tests/unit/module/include/resolve-async.test.ts b/tests/unit/module/include/resolve-async.test.ts new file mode 100644 index 0000000..0fd3847 --- /dev/null +++ b/tests/unit/module/include/resolve-async.test.ts @@ -0,0 +1,227 @@ +import { test, expect, describe } from "bun:test"; +import { parse, resolveIncludesAsync, type ParserOptions } from "@wdprlib/parser"; +import type { SyntaxTree } from "@wdprlib/ast"; +import { getAllText } from "../../../helpers"; + +function parseAst(input: string, options?: ParserOptions): SyntaxTree { + return parse(input, options).ast; +} + +describe("resolveIncludesAsync", () => { + test("resolves a simple include", async () => { + const source = "[[include my-page]]"; + const fetcher = async (pageRef: { site: string | null; page: string }) => { + if (pageRef.page === "my-page") return "Hello from included page"; + return null; + }; + + const expanded = await resolveIncludesAsync(source, fetcher); + expect(expanded).toBe("Hello from included page"); + }); + + test("returns error block for page not found", async () => { + const source = "[[include missing-page]]"; + const fetcher = async () => null; + + const expanded = await resolveIncludesAsync(source, fetcher); + expect(expanded).toContain("cannot be found"); + }); + + test("substitutes variables", async () => { + const source = "[[include my-page | name=World]]"; + const fetcher = async () => "Hello {$name}!"; + + const expanded = await resolveIncludesAsync(source, fetcher); + expect(expanded).toBe("Hello World!"); + }); + + test("handles nested includes", async () => { + const source = "[[include page-a]]"; + const fetcher = async (pageRef: { site: string | null; page: string }) => { + if (pageRef.page === "page-a") return "A content\n[[include page-b]]"; + if (pageRef.page === "page-b") return "Content from B"; + return null; + }; + + const expanded = await resolveIncludesAsync(source, fetcher); + expect(expanded).toContain("A content"); + expect(expanded).toContain("Content from B"); + expect(expanded).not.toContain("[[include"); + }); + + test("detects circular includes", async () => { + const source = "[[include page-a]]"; + const fetcher = async (pageRef: { site: string | null; page: string }) => { + if (pageRef.page === "page-a") return "[[include page-b]]"; + if (pageRef.page === "page-b") return "[[include page-a]]"; + return null; + }; + + const expanded = await resolveIncludesAsync(source, fetcher); + expect(expanded).toContain("Circular include detected"); + }); + + test("respects maxDepth", async () => { + const source = "[[include level-1]]"; + const fetcher = async (pageRef: { site: string | null; page: string }) => { + const match = pageRef.page.match(/level-(\d+)/); + if (match) { + const level = parseInt(match[1]!); + if (level < 10) return `Level ${level}\n[[include level-${level + 1}]]`; + return `Level ${level}`; + } + return null; + }; + + const expanded = await resolveIncludesAsync(source, fetcher, { + maxDepth: 3, + }); + expect(expanded).toContain("Level 1"); + expect(expanded).toContain("Level 2"); + expect(expanded).toContain("Level 3"); + expect(expanded).toContain("[[include level-4]]"); + }); + + test("caches fetcher calls for same page", async () => { + const source = "[[include page-a]]\n[[include page-a]]"; + let fetchCount = 0; + const fetcher = async () => { + fetchCount++; + return "Cached content"; + }; + + await resolveIncludesAsync(source, fetcher); + expect(fetchCount).toBe(1); + }); + + test("handles fetcher exceptions", async () => { + const source = "[[include error-page]]"; + const fetcher = async (): Promise => { + throw new Error("Network error"); + }; + + const expanded = await resolveIncludesAsync(source, fetcher); + expect(expanded).toContain("cannot be found"); + }); + + test("handles site-prefixed page references", async () => { + const source = "[[include :other-site:my-page]]"; + let receivedPageRef: { site: string | null; page: string } | null = null; + const fetcher = async (pageRef: { site: string | null; page: string }) => { + receivedPageRef = pageRef; + return "Cross-site content"; + }; + + await resolveIncludesAsync(source, fetcher); + expect(receivedPageRef!).toEqual({ site: "other-site", page: "my-page" }); + }); + + test("same page from different routes is not circular", async () => { + const source = "[[include page-a]]"; + const fetcher = async (pageRef: { site: string | null; page: string }) => { + if (pageRef.page === "page-a") return "[[include page-b]]\n[[include page-c]]"; + if (pageRef.page === "page-b") return "B content\n[[include page-d]]"; + if (pageRef.page === "page-c") return "C content\n[[include page-d]]"; + if (pageRef.page === "page-d") return "Shared content"; + return null; + }; + + const expanded = await resolveIncludesAsync(source, fetcher); + expect(expanded).toContain("B content"); + expect(expanded).toContain("C content"); + const matches = expanded.match(/Shared content/g); + expect(matches?.length).toBe(2); + }); + + test("produces same result as sync resolveIncludes", async () => { + const { resolveIncludes } = await import("@wdprlib/parser"); + + const source = "Before\n[[include page-a | x=1]]\nMiddle\n[[include page-b]]\nAfter"; + const pages: Record = { + "page-a": "A={$x}\n[[include page-c]]", + "page-b": "B content", + "page-c": "C content", + }; + + const syncFetcher = (ref: { site: string | null; page: string }) => pages[ref.page] ?? null; + const asyncFetcher = async (ref: { site: string | null; page: string }) => + pages[ref.page] ?? null; + + const syncResult = resolveIncludes(source, syncFetcher); + const asyncResult = await resolveIncludesAsync(source, asyncFetcher); + expect(asyncResult).toBe(syncResult); + }); + + test("fetcher is truly awaited (not just sync-wrapped)", async () => { + const source = "[[include page-a]]"; + let resolved = false; + const fetcher = async () => { + await new Promise((r) => setTimeout(r, 10)); + resolved = true; + return "async content"; + }; + + const expanded = await resolveIncludesAsync(source, fetcher); + expect(resolved).toBe(true); + expect(expanded).toBe("async content"); + }); + + test("normalizes page keys for circular detection (case insensitive)", async () => { + const source = "[[include Page-A]]"; + const fetcher = async (pageRef: { site: string | null; page: string }) => { + if (pageRef.page.toLowerCase() === "page-a") return "[[include page-a]]"; + return null; + }; + + const expanded = await resolveIncludesAsync(source, fetcher); + expect(expanded).toContain("Circular include detected"); + }); + + test("multiple variables are substituted", async () => { + const source = "[[include tmpl | first=John | last=Doe]]"; + const fetcher = async () => "{$first} {$last}"; + + const expanded = await resolveIncludesAsync(source, fetcher); + expect(expanded).toContain("John Doe"); + }); + + test("preserves surrounding text", async () => { + const source = "Before\n[[include my-page]]\nAfter"; + const fetcher = async () => "Included"; + + const expanded = await resolveIncludesAsync(source, fetcher); + expect(expanded).toBe("Before\nIncluded\nAfter"); + }); + + test("does not resolve include that is not at line start", async () => { + const source = "abc [[include my-page]]"; + const fetcher = async () => "Should not appear"; + + const expanded = await resolveIncludesAsync(source, fetcher); + expect(expanded).toBe("abc [[include my-page]]"); + }); + + test("div blocks spanning across includes are correctly parsed", async () => { + const source = "[[include credit:start]]\naaa\n[[include credit:end]]"; + const fetcher = async (pageRef: { site: string | null; page: string }) => { + if (pageRef.page === "credit:start") return '[[div class="credit"]]\n'; + if (pageRef.page === "credit:end") return "\n[[/div]]"; + return null; + }; + + const expanded = await resolveIncludesAsync(source, fetcher); + expect(expanded).toContain('[[div class="credit"]]'); + expect(expanded).toContain("aaa"); + expect(expanded).toContain("[[/div]]"); + + const ast = parseAst(expanded); + const divElement = ast.elements.find( + (el) => el.element === "container" && (el.data as Record).type === "div", + ); + expect(divElement).toBeDefined(); + + const divData = divElement!.data as { elements: Element[] }; + const text = getAllText(divData.elements); + expect(text).toContain("aaa"); + }); +}); From c0084e9fb9fb55762746efd6c210506bedb3a4f5 Mon Sep 17 00:00:00 2001 From: r74tech Date: Tue, 17 Mar 2026 17:40:26 +0900 Subject: [PATCH 2/2] =?UTF-8?q?refactor!:=20include=E5=87=A6=E7=90=86?= =?UTF-8?q?=E3=82=92Wikidot=E4=BA=92=E6=8F=9B=E3=81=AE=E3=82=A4=E3=83=86?= =?UTF-8?q?=E3=83=AC=E3=83=BC=E3=83=86=E3=82=A3=E3=83=96=E6=96=B9=E5=BC=8F?= =?UTF-8?q?=E3=81=AB=E6=9B=B8=E3=81=8D=E6=8F=9B=E3=81=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DFS再帰方式からWikidotと同じdo-whileイテレーティブ方式に変更。 各イテレーションでソース全体の全includeを1段だけ一括置換し、 変化がなくなるかmaxIterationsに到達するまで繰り返す。 - expandText/expandTextAsync再帰を廃止 - expandIterative/expandIterativeAsync(forループ方式)に置換 - trace配列(循環検出)を削除(maxIterations + 変化なし検出で停止) - ResolveIncludesOptions: maxDepth削除 → maxIterations追加(デフォルト10) - inc-loopパターン(同一ページ・異なる変数での再帰include)が動作可能に BREAKING CHANGE: maxDepthオプション削除、循環includeのエラー文言廃止 --- .../rules/block/module/include/index.ts | 2 +- .../rules/block/module/include/resolve.ts | 187 ++++++++---------- .../unit/module/include/resolve-async.test.ts | 68 +++++-- tests/unit/module/include/resolve.test.ts | 101 ++++++++-- 4 files changed, 227 insertions(+), 131 deletions(-) diff --git a/packages/parser/src/parser/rules/block/module/include/index.ts b/packages/parser/src/parser/rules/block/module/include/index.ts index 7a9ebd0..fe707bd 100644 --- a/packages/parser/src/parser/rules/block/module/include/index.ts +++ b/packages/parser/src/parser/rules/block/module/include/index.ts @@ -11,7 +11,7 @@ * - Same-site includes: `[[include page-name]]` * - Cross-site includes: `[[include :site-name:page-name]]` * - Variable substitution: `[[include page | key=value]]` replaces `{$key}` in the included content - * - Recursive includes with configurable depth limit and circular dependency detection + * - Iterative expansion with configurable iteration limit (Wikidot-compatible) * * @module */ diff --git a/packages/parser/src/parser/rules/block/module/include/resolve.ts b/packages/parser/src/parser/rules/block/module/include/resolve.ts index 9e118fb..15f789d 100644 --- a/packages/parser/src/parser/rules/block/module/include/resolve.ts +++ b/packages/parser/src/parser/rules/block/module/include/resolve.ts @@ -8,15 +8,16 @@ * (e.g., an opening `[[div]]` tag in one include and its closing `[[/div]]` in * another) that must be visible to the parser as a single continuous text. * - * The resolution process: - * 1. Scan the source text for `[[include page | var=val]]` patterns - * 2. Fetch the included page's content via the provided fetcher callback - * 3. Apply variable substitutions (`{$key}` -> `value`) - * 4. Recursively resolve includes in the fetched content (up to max depth) - * 5. Replace the original `[[include ...]]` directive with the expanded text + * The resolution process follows Wikidot's iterative (do-while) approach: + * 1. Scan the entire source text for `[[include page | var=val]]` patterns + * 2. Replace ALL matches in one pass (each fetched, variable-substituted) + * 3. Compare the result with the previous source + * 4. Repeat until no changes occur or `maxIterations` is reached * - * Safety features include circular dependency detection (using a trace of - * visited pages) and a configurable maximum recursion depth (default: 5). + * This differs from a DFS recursive approach: each iteration expands one + * "layer" of includes across the whole source, rather than drilling into + * each include immediately. This allows patterns like inc-loop (where the + * same page is included with different variables across iterations) to work. * * @module */ @@ -44,11 +45,17 @@ export type IncludeFetcher = (pageRef: PageRef) => string | null; export type AsyncIncludeFetcher = (pageRef: PageRef) => Promise; /** - * Options for resolveIncludes + * Options for resolveIncludes / resolveIncludesAsync */ export interface ResolveIncludesOptions { - /** Maximum recursion depth for nested includes (default: 5) */ - maxDepth?: number; + /** + * Maximum number of expansion iterations (default: 10). + * + * Each iteration replaces all `[[include]]` directives in the current + * source with fetched content. Iteration stops when the source is + * unchanged or this limit is reached. + */ + maxIterations?: number; /** Wikitext settings. If enablePageSyntax is false, includes are not expanded. */ settings?: WikitextSettings; } @@ -56,10 +63,10 @@ export interface ResolveIncludesOptions { /** * Expand all [[include]] directives in the source text. * - * Include directives are treated as macro expansions: `[[include page]]` - * is replaced with the fetched page content (after variable substitution). - * The result is a single expanded text that can be parsed as a whole, - * allowing block structures (like div) to span across include boundaries. + * Uses Wikidot-compatible iterative expansion: each iteration replaces + * all include directives in the current source with fetched (and + * variable-substituted) content. Iteration continues until no further + * changes occur or `maxIterations` is reached. * * @example * ```ts @@ -76,7 +83,7 @@ export function resolveIncludes( return source; } - const maxDepth = options?.maxDepth ?? 5; + const maxIterations = options?.maxIterations ?? 10; const cache = new Map(); const cachedFetcher: IncludeFetcher = (pageRef: PageRef) => { @@ -94,7 +101,7 @@ export function resolveIncludes( return result; }; - return expandText(source, cachedFetcher, 0, maxDepth, []); + return expandIterative(source, cachedFetcher, maxIterations); } /** @@ -120,7 +127,7 @@ export async function resolveIncludesAsync( return source; } - const maxDepth = options?.maxDepth ?? 5; + const maxIterations = options?.maxIterations ?? 10; const cache = new Map(); const cachedFetcher: AsyncIncludeFetcher = async (pageRef: PageRef) => { @@ -138,7 +145,7 @@ export async function resolveIncludesAsync( return result; }; - return expandTextAsync(source, cachedFetcher, 0, maxDepth, []); + return expandIterativeAsync(source, cachedFetcher, maxIterations); } /** @@ -225,112 +232,86 @@ function parseIncludeDirective(inner: string): { location: PageRef; variables: V } /** - * Recursively expand `[[include ...]]` directives in source text. - * - * Each include directive is replaced with the fetched and variable-substituted - * page content. The expansion recurses into the fetched content to handle - * nested includes, up to `maxDepth` levels. + * Replace a single include match with its fetched + variable-substituted content. + * Used as the callback for String.replace in the synchronous iterative expansion. + */ +function replaceOneInclude(_match: string, inner: string, fetcher: IncludeFetcher): string { + const { location, variables } = parseIncludeDirective(inner); + const content = fetcher(location); + if (content === null) { + return `[[div class="error-block"]]\nPage to be included "${location.page}" cannot be found!\n[[/div]]`; + } + return substituteVariables(content, variables); +} + +/** + * Iteratively expand all `[[include]]` directives in source text. * - * Circular includes are detected by maintaining a trace of visited page keys. - * When a circular include is found, an error div is emitted instead. + * Each iteration replaces every include directive in the current source + * with its fetched content (after variable substitution). No recursion + * into individual includes — the next iteration handles nested includes. * - * @param source - The text to scan for include directives - * @param fetcher - Callback to fetch page content (with caching) - * @param depth - Current recursion depth - * @param maxDepth - Maximum allowed recursion depth - * @param trace - Stack of visited page keys for circular dependency detection - * @returns Text with all include directives expanded + * Stops when the source is unchanged (no includes left or all resolved) + * or `maxIterations` is reached. */ -function expandText( - source: string, - fetcher: IncludeFetcher, - depth: number, - maxDepth: number, - trace: string[], -): string { - if (depth >= maxDepth) return source; - - return source.replace(INCLUDE_PATTERN, (_match, inner: string) => { - const { location, variables } = parseIncludeDirective(inner); - const pageKey = normalizePageKey(location); - - // Circular include detection - if (trace.includes(pageKey)) { - return `[[div class="error-block"]]\nCircular include detected: "${location.page}"\n[[/div]]`; - } - - // Fetch page content - const content = fetcher(location); - if (content === null) { - return `[[div class="error-block"]]\nPage to be included "${location.page}" cannot be found!\n[[/div]]`; - } - - // Apply variable substitutions - const substituted = substituteVariables(content, variables); - - // Recursively expand includes in the fetched content - return expandText(substituted, fetcher, depth + 1, maxDepth, [...trace, pageKey]); - }); +function expandIterative(source: string, fetcher: IncludeFetcher, maxIterations: number): string { + let current = source; + for (let i = 0; i < maxIterations; i++) { + const previous = current; + current = current.replace(INCLUDE_PATTERN, (_match, inner: string) => + replaceOneInclude(_match, inner, fetcher), + ); + if (current === previous) break; + } + return current; } /** - * Async version of {@link expandText}. + * Async iterative expansion of `[[include]]` directives. * - * Uses a while loop with RegExp.exec() instead of String.replace() because - * replace() does not support async callbacks. A local copy of the regex is - * created per invocation to avoid lastIndex conflicts across recursive calls. + * Each iteration scans the current source for include directives using + * RegExp.exec(), fetches content sequentially (to preserve cache semantics), + * and builds the replacement string. A fresh RegExp is created per iteration + * to avoid lastIndex conflicts. */ -async function expandTextAsync( +async function expandIterativeAsync( source: string, fetcher: AsyncIncludeFetcher, - depth: number, - maxDepth: number, - trace: string[], + maxIterations: number, ): Promise { - if (depth >= maxDepth) return source; - - const pattern = new RegExp(INCLUDE_PATTERN.source, INCLUDE_PATTERN.flags); - let result = ""; - let lastPos = 0; - let match: RegExpExecArray | null; - - while ((match = pattern.exec(source)) !== null) { - const fullMatch = match[0]!; - const inner = match[1]!; - result += source.slice(lastPos, match.index); - - const { location, variables } = parseIncludeDirective(inner); - const pageKey = normalizePageKey(location); - - // Circular include detection - if (trace.includes(pageKey)) { - result += `[[div class="error-block"]]\nCircular include detected: "${location.page}"\n[[/div]]`; - } else { - // Fetch page content + let current = source; + for (let i = 0; i < maxIterations; i++) { + const previous = current; + const pattern = new RegExp(INCLUDE_PATTERN.source, INCLUDE_PATTERN.flags); + let result = ""; + let lastPos = 0; + let match: RegExpExecArray | null; + + while ((match = pattern.exec(current)) !== null) { + const fullMatch = match[0]!; + const inner = match[1]!; + result += current.slice(lastPos, match.index); + + const { location, variables } = parseIncludeDirective(inner); const content = await fetcher(location); if (content === null) { result += `[[div class="error-block"]]\nPage to be included "${location.page}" cannot be found!\n[[/div]]`; } else { - // Apply variable substitutions - const substituted = substituteVariables(content, variables); - // Recursively expand includes in the fetched content - result += await expandTextAsync(substituted, fetcher, depth + 1, maxDepth, [ - ...trace, - pageKey, - ]); + result += substituteVariables(content, variables); } + + lastPos = match.index + fullMatch.length; } - lastPos = match.index + fullMatch.length; + result += current.slice(lastPos); + current = result; + if (current === previous) break; } - - result += source.slice(lastPos); - return result; + return current; } /** - * Normalize a PageRef into a consistent string key for cache lookups - * and circular dependency detection. + * Normalize a PageRef into a consistent string key for cache lookups. * * Page names are lowercased for case-insensitive matching. Cross-site * references include the site name as a prefix. diff --git a/tests/unit/module/include/resolve-async.test.ts b/tests/unit/module/include/resolve-async.test.ts index 0fd3847..e3b1386 100644 --- a/tests/unit/module/include/resolve-async.test.ts +++ b/tests/unit/module/include/resolve-async.test.ts @@ -49,7 +49,7 @@ describe("resolveIncludesAsync", () => { expect(expanded).not.toContain("[[include"); }); - test("detects circular includes", async () => { + test("mutual circular includes stop at maxIterations", async () => { const source = "[[include page-a]]"; const fetcher = async (pageRef: { site: string | null; page: string }) => { if (pageRef.page === "page-a") return "[[include page-b]]"; @@ -57,11 +57,25 @@ describe("resolveIncludesAsync", () => { return null; }; + const expanded = await resolveIncludesAsync(source, fetcher, { maxIterations: 3 }); + expect(expanded).toContain("[[include"); + }); + + test("self-referencing include stops immediately", async () => { + const source = "[[include page-a]]"; + let fetchCount = 0; + const fetcher = async (pageRef: { site: string | null; page: string }) => { + fetchCount++; + if (pageRef.page === "page-a") return "Self: [[include page-a]]"; + return null; + }; + const expanded = await resolveIncludesAsync(source, fetcher); - expect(expanded).toContain("Circular include detected"); + expect(expanded).toContain("Self:"); + expect(fetchCount).toBe(1); }); - test("respects maxDepth", async () => { + test("respects maxIterations", async () => { const source = "[[include level-1]]"; const fetcher = async (pageRef: { site: string | null; page: string }) => { const match = pageRef.page.match(/level-(\d+)/); @@ -73,15 +87,26 @@ describe("resolveIncludesAsync", () => { return null; }; - const expanded = await resolveIncludesAsync(source, fetcher, { - maxDepth: 3, - }); + const expanded = await resolveIncludesAsync(source, fetcher, { maxIterations: 3 }); expect(expanded).toContain("Level 1"); expect(expanded).toContain("Level 2"); expect(expanded).toContain("Level 3"); expect(expanded).toContain("[[include level-4]]"); }); + test("stops early when no changes occur", async () => { + const source = "[[include page-a]]"; + let fetchCount = 0; + const fetcher = async () => { + fetchCount++; + return "No nested includes here"; + }; + + const expanded = await resolveIncludesAsync(source, fetcher, { maxIterations: 10 }); + expect(expanded).toBe("No nested includes here"); + expect(fetchCount).toBe(1); + }); + test("caches fetcher calls for same page", async () => { const source = "[[include page-a]]\n[[include page-a]]"; let fetchCount = 0; @@ -94,6 +119,20 @@ describe("resolveIncludesAsync", () => { expect(fetchCount).toBe(1); }); + test("same page with different variables uses cache but substitutes differently", async () => { + const source = "[[include tmpl | x=1]]\n[[include tmpl | x=2]]"; + let fetchCount = 0; + const fetcher = async () => { + fetchCount++; + return "val={$x}"; + }; + + const expanded = await resolveIncludesAsync(source, fetcher); + expect(fetchCount).toBe(1); + expect(expanded).toContain("val=1"); + expect(expanded).toContain("val=2"); + }); + test("handles fetcher exceptions", async () => { const source = "[[include error-page]]"; const fetcher = async (): Promise => { @@ -116,7 +155,7 @@ describe("resolveIncludesAsync", () => { expect(receivedPageRef!).toEqual({ site: "other-site", page: "my-page" }); }); - test("same page from different routes is not circular", async () => { + test("same page from different routes expands correctly", async () => { const source = "[[include page-a]]"; const fetcher = async (pageRef: { site: string | null; page: string }) => { if (pageRef.page === "page-a") return "[[include page-b]]\n[[include page-c]]"; @@ -166,15 +205,16 @@ describe("resolveIncludesAsync", () => { expect(expanded).toBe("async content"); }); - test("normalizes page keys for circular detection (case insensitive)", async () => { - const source = "[[include Page-A]]"; - const fetcher = async (pageRef: { site: string | null; page: string }) => { - if (pageRef.page.toLowerCase() === "page-a") return "[[include page-a]]"; - return null; + test("case-insensitive page key caching", async () => { + const source = "[[include Page-A]]\n[[include page-a]]"; + let fetchCount = 0; + const fetcher = async () => { + fetchCount++; + return "content"; }; - const expanded = await resolveIncludesAsync(source, fetcher); - expect(expanded).toContain("Circular include detected"); + await resolveIncludesAsync(source, fetcher); + expect(fetchCount).toBe(1); }); test("multiple variables are substituted", async () => { diff --git a/tests/unit/module/include/resolve.test.ts b/tests/unit/module/include/resolve.test.ts index e206121..7148dc8 100644 --- a/tests/unit/module/include/resolve.test.ts +++ b/tests/unit/module/include/resolve.test.ts @@ -49,7 +49,8 @@ describe("resolveIncludes", () => { expect(expanded).not.toContain("[[include"); }); - test("detects circular includes", () => { + test("mutual circular includes stop at maxIterations", () => { + // A includes B, B includes A → oscillates until maxIterations const source = "[[include page-a]]"; const fetcher = (pageRef: { site: string | null; page: string }) => { if (pageRef.page === "page-a") return "[[include page-b]]"; @@ -57,11 +58,32 @@ describe("resolveIncludes", () => { return null; }; + // With maxIterations=3, should oscillate and stop + const expanded = resolveIncludes(source, fetcher, { maxIterations: 3 }); + // After 3 iterations the include is still present (oscillating) + expect(expanded).toContain("[[include"); + }); + + test("self-referencing include stops immediately (no change)", () => { + // Page A contains [[include page-a]] → after 1 replacement, content + // is the same cached source containing [[include page-a]] again. + // Next iteration produces same result → stops. + const source = "[[include page-a]]"; + let fetchCount = 0; + const fetcher = (pageRef: { site: string | null; page: string }) => { + fetchCount++; + if (pageRef.page === "page-a") return "Self: [[include page-a]]"; + return null; + }; + const expanded = resolveIncludes(source, fetcher); - expect(expanded).toContain("Circular include detected"); + // Eventually stabilizes (the include keeps producing the same text) + expect(expanded).toContain("Self:"); + // Fetcher is called only once (cached) + expect(fetchCount).toBe(1); }); - test("respects maxDepth", () => { + test("respects maxIterations", () => { const source = "[[include level-1]]"; const fetcher = (pageRef: { site: string | null; page: string }) => { const match = pageRef.page.match(/level-(\d+)/); @@ -73,14 +95,29 @@ describe("resolveIncludes", () => { return null; }; - const expanded = resolveIncludes(source, fetcher, { maxDepth: 3 }); + // Each iteration expands one layer of includes + const expanded = resolveIncludes(source, fetcher, { maxIterations: 3 }); expect(expanded).toContain("Level 1"); expect(expanded).toContain("Level 2"); expect(expanded).toContain("Level 3"); - // 深度4以降は展開されずに残る + // After 3 iterations, level-4 is still unexpanded expect(expanded).toContain("[[include level-4]]"); }); + test("stops early when no changes occur", () => { + const source = "[[include page-a]]"; + let fetchCount = 0; + const fetcher = () => { + fetchCount++; + return "No nested includes here"; + }; + + const expanded = resolveIncludes(source, fetcher, { maxIterations: 10 }); + expect(expanded).toBe("No nested includes here"); + // Fetcher called once, then no more iterations needed + expect(fetchCount).toBe(1); + }); + test("caches fetcher calls for same page", () => { const source = "[[include page-a]]\n[[include page-a]]"; let fetchCount = 0; @@ -93,6 +130,20 @@ describe("resolveIncludes", () => { expect(fetchCount).toBe(1); }); + test("same page with different variables uses cache but substitutes differently", () => { + const source = "[[include tmpl | x=1]]\n[[include tmpl | x=2]]"; + let fetchCount = 0; + const fetcher = () => { + fetchCount++; + return "val={$x}"; + }; + + const expanded = resolveIncludes(source, fetcher); + expect(fetchCount).toBe(1); + expect(expanded).toContain("val=1"); + expect(expanded).toContain("val=2"); + }); + test("handles fetcher exceptions", () => { const source = "[[include error-page]]"; const fetcher = (): string | null => { @@ -115,7 +166,7 @@ describe("resolveIncludes", () => { expect(receivedPageRef!).toEqual({ site: "other-site", page: "my-page" }); }); - test("same page from different routes is not circular", () => { + test("same page from different routes expands correctly", () => { const source = "[[include page-a]]"; const fetcher = (pageRef: { site: string | null; page: string }) => { if (pageRef.page === "page-a") return "[[include page-b]]\n[[include page-c]]"; @@ -148,15 +199,17 @@ describe("resolveIncludes", () => { expect(expanded).toBe("Hello world"); }); - test("normalizes page keys for circular detection (case insensitive)", () => { - const source = "[[include Page-A]]"; - const fetcher = (pageRef: { site: string | null; page: string }) => { - if (pageRef.page.toLowerCase() === "page-a") return "[[include page-a]]"; - return null; + test("case-insensitive page key caching", () => { + // Page-A and page-a should hit the same cache entry + const source = "[[include Page-A]]\n[[include page-a]]"; + let fetchCount = 0; + const fetcher = () => { + fetchCount++; + return "content"; }; - const expanded = resolveIncludes(source, fetcher); - expect(expanded).toContain("Circular include detected"); + resolveIncludes(source, fetcher); + expect(fetchCount).toBe(1); }); test("multiple variables are substituted", () => { @@ -268,4 +321,26 @@ describe("resolveIncludes", () => { expect(allText).not.toContain("[[/div]]"); expect(allText).toContain("Content here"); }); + + test("inc-loop pattern: same page with variable-driven recursion", () => { + // Simulates inc-loop-base: a page that includes itself with decremented counter + // Page "loop" contains: "Item {$n}\n[[include loop | n={$next}]]" but we need + // to simulate variable-driven content changes across iterations + const source = "[[include counter | n=3]]"; + const fetcher = (pageRef: { site: string | null; page: string }) => { + if (pageRef.page === "counter") { + // The page source contains a conditional pattern: + // If {$n} > 0, include self with n-1 + return "Count:{$n}\n[[include counter | n={$next}]]"; + } + return null; + }; + + // Iteration 1: source becomes "Count:3\n[[include counter | n={$next}]]" + // {$next} is unresolved, so next include has n={$next} + // Iteration 2: "Count:3\nCount:{$next}\n[[include counter | n={$next}]]" + // Iteration 3: same pattern continues + const expanded = resolveIncludes(source, fetcher, { maxIterations: 3 }); + expect(expanded).toContain("Count:3"); + }); });