From 786eab525a8d910f161b338d36d9db544ce44bee Mon Sep 17 00:00:00 2001 From: r74tech Date: Sun, 7 Jun 2026 19:44:34 +0900 Subject: [PATCH] =?UTF-8?q?fix(parser):=20[[include]]=20=E3=81=AE=E7=B5=82?= =?UTF-8?q?=E7=AB=AF=20]]=20=E7=9B=B4=E5=BE=8C=E3=81=AE=E4=BD=99=E5=88=86?= =?UTF-8?q?=E3=81=AA=20]=20=E3=82=92=20value=20=E5=81=B4=E3=81=AB=E5=90=B8?= =?UTF-8?q?=E5=8F=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3.1.1 で「最後の引数値が ] で終わる multi-line include の終端を見つける」 修正を入れたが、close を「最初の ]]」で行う実装になっており、残りの `]` が include の外に raw text として漏れ出ていた。 Wikidot 本家は greedy に「最後の ]] で close、間の ] は最終 attribute value に 吸収」を行う ([[include foo|key=val|bar=--]|]] の改行省略形 [[include foo|key=val|bar=--]]] で bar=--] となる)。これを再現するため、 ]] で depth=0 になった時点で直後の ] を while ループで読み飛ばし、close 位置を 最後の ]] の終わりに合わせるよう変更。 副次的に 3.1.1 で導入した isRestOfLineBlank の ] 許容は不要になるため revert。 --- .../rules/block/module/include/resolve.ts | 57 ++++++++++---- tests/unit/module/include/resolve.test.ts | 60 +++++++++++---- .../include-comment-integration.test.ts | 77 +++++++++++++++++++ 3 files changed, 167 insertions(+), 27 deletions(-) create mode 100644 tests/unit/parser/resolve/include-comment-integration.test.ts 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 694a703..1afc69a 100644 --- a/packages/parser/src/parser/rules/block/module/include/resolve.ts +++ b/packages/parser/src/parser/rules/block/module/include/resolve.ts @@ -170,28 +170,35 @@ interface IncludeDirectiveMatch { inner: string; } +/** + * Returns `true` when the directive's inner content (the text between + * `[[include ` and the closing `]]`) carries an attribute section — + * either pipe-delimited (`|key=value`) or space-separated after the + * page name (`tmpl key=value`). A bare page name with no following + * argument is reported as having no attributes so that a stray `]` + * after the closing `]]` is not absorbed into the page reference. + */ +function hasAttributes(innerSoFar: string): boolean { + if (innerSoFar.includes("|")) return true; + const trimmed = innerSoFar.trimStart(); + // Match the first whitespace run; anything non-whitespace after it + // counts as a space-separated parameter segment. + const ws = trimmed.search(/\s/); + if (ws === -1) return false; + return trimmed.slice(ws).trim().length > 0; +} + /** * Returns `true` when everything between `pos` and the next newline (or - * end of string) is whitespace or stray `]` characters — i.e. `pos` sits - * at the end of its line, optionally trailed by extra `]` that spilled - * over from the directive's last attribute value. - * - * Wikidot wikitext occasionally contains directives whose final attribute - * value ends in `]`, e.g. `[[include foo |key=--]|]]`. When the closing - * `]]` is written without a separator (`[[include foo |key=--]]]`), the - * first `]]` after the value is the directive terminator and the - * remaining `]` belongs outside the directive. Without tolerating those - * trailing `]`, the scanner would fail to close the directive at line - * end and drop the whole include to raw text. + * end of string) is whitespace — i.e. `pos` sits at the end of its line. */ function isRestOfLineBlank(source: string, pos: number): boolean { for (let i = pos; i < source.length; i++) { const ch = source[i]; if (ch === "\n") return true; - if (ch === " " || ch === "\t" || ch === "\r" || ch === "]") continue; - return false; + if (ch !== " " && ch !== "\t" && ch !== "\r") return false; } - return true; // reached EOF with only whitespace / trailing `]` + return true; // reached EOF with only whitespace } /** @@ -263,6 +270,28 @@ function scanIncludeDirectives(source: string): IncludeDirectiveMatch[] { depth--; i += 2; if (depth <= 0) { + // Wikidot is greedy *within an attribute value*: any `]` + // that immediately follows the `]]` driving depth to zero + // belongs to the directive's final attribute value (e.g. + // `[[include foo |k=--]]]` keeps `--]` as the value). + // Only extend the close when the directive actually has an + // attribute section — a plain `[[include my-page]]]` must + // resolve `my-page` and leave the trailing `]` outside, + // otherwise the page name would absorb the bracket and the + // fetch would fail. + // + // A directive has attributes when it contains a `|` segment + // separator, OR when the page-name token is followed by + // additional non-whitespace content (space-separated + // parameters like `[[include foo bar=baz]]`). Using `=` + // alone is unsafe because page names may legitimately + // contain `=` (`[[include foo=bar]]`). + const innerSoFar = source.slice(contentStart, closeStart); + if (hasAttributes(innerSoFar)) { + while (i < source.length && source[i] === "]") { + i++; + } + } const onOpenerLine = firstNewline === -1 || closeStart < firstNewline; if (onOpenerLine || isRestOfLineBlank(source, i)) { closeEnd = i; diff --git a/tests/unit/module/include/resolve.test.ts b/tests/unit/module/include/resolve.test.ts index bd5aad0..419105e 100644 --- a/tests/unit/module/include/resolve.test.ts +++ b/tests/unit/module/include/resolve.test.ts @@ -301,27 +301,61 @@ describe("resolveIncludes", () => { expect(resolveIncludes(source, fetcher)).toBe("<<[[[a]]]>>\n<<[[[b]]]>>"); }); - test("multi-line directive closes when the terminating ]] is trailed by stray ]", () => { + test("multi-line directive captures trailing ] into the final attribute value", () => { // When a final attribute value ends in `]` and the directive's - // closing `]]` follows without a separator (`--]]]`), the first - // `]]` terminates the directive (capturing `--` as the value); - // the trailing `]` outside the directive is preserved as raw text. + // closing `]]` follows without a separator (`--]]]`), Wikidot + // greedily consumes the trailing `]` into the value — so the + // attribute is `--]` and the close lands on the last `]]`. const source = "[[include tmpl\n|cap= --]]]"; - expect(resolveIncludes(source, fetcher)).toBe("<<-->>]"); + expect(resolveIncludes(source, fetcher)).toBe("<<--]>>"); }); - test("multi-line directive with stray ]] tail and following content", () => { - // The trailing `]` after the directive close must remain in the - // output (not be swallowed by the directive). + test("multi-line directive with trailing ] followed by other content", () => { + // After the greedy close, source past the directive is preserved + // intact (no stray `]` left outside). const source = "[[include tmpl\n|cap= --]]]\n\nafter"; - expect(resolveIncludes(source, fetcher)).toBe("<<-->>]\n\nafter"); + expect(resolveIncludes(source, fetcher)).toBe("<<--]>>\n\nafter"); }); - test("multi-line directive with multiple stray ] characters", () => { - // `]]]]]` at the end: the first `]]` drives depth to zero; the - // remaining `]]]` is plain text outside the directive. + test("multi-line directive collapses an entire ]]]+ run into the close", () => { + // `]]]]]`: the close consumes all trailing `]`, so the value + // captures `x]]]` and nothing is left outside the directive. const source = "[[include tmpl\n|cap= x]]]]]"; - expect(resolveIncludes(source, fetcher)).toBe("<>]]]"); + expect(resolveIncludes(source, fetcher)).toBe("<>"); + }); + + test("inline directive captures trailing ] before whitespace into the value", () => { + // Same greedy behaviour on a single-line directive: the `]` after + // `]]` is consumed into the value, not left outside. + const source = "[[include tmpl |cap=x]]] rest"; + expect(resolveIncludes(source, fetcher)).toBe("<> rest"); + }); + + test("plain include (no attributes) keeps a trailing ] outside the directive", () => { + // Without an attribute section the page name must not absorb a + // stray `]`; otherwise the fetcher would look up `tmpl]`. + // (`{$cap}` stays literal because no `cap=` was supplied.) + const source = "[[include tmpl]]]"; + expect(resolveIncludes(source, fetcher)).toBe("<<{$cap}>>]"); + }); + + test("plain include whose page name contains = keeps a trailing ] outside", () => { + // `=` may legitimately occur inside a page name; presence of `=` + // alone must not enable greedy absorption. + const custom = (ref: { site: string | null; page: string }) => + ref.page === "foo=bar" ? "OK" : `MISS:${ref.page}`; + const source = "[[include foo=bar]]]"; + expect(resolveIncludes(source, custom)).toBe("OK]"); + }); + + test("space-separated parameters do trigger greedy absorption", () => { + // `[[include foo bar=--]]]`: the space after the page name marks + // an attribute section, so greedy absorption applies and the + // attribute captures `--]`. + const custom = (ref: { site: string | null; page: string }) => + ref.page === "foo" ? `<<${"{$bar}"}>>` : null; + const source = "[[include foo bar=--]]]"; + expect(resolveIncludes(source, custom)).toBe("<<--]>>"); }); }); diff --git a/tests/unit/parser/resolve/include-comment-integration.test.ts b/tests/unit/parser/resolve/include-comment-integration.test.ts new file mode 100644 index 0000000..92f427a --- /dev/null +++ b/tests/unit/parser/resolve/include-comment-integration.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "bun:test"; +import { parse, resolveIncludes } from "@wdprlib/parser"; +import { renderToHtml } from "@wdprlib/render"; + +/** + * End-to-end coverage for the real-world idiom that motivated the + * `]]]+` greedy close in `resolveIncludes`. + * + * A template wraps optional content in a Wikidot comment so the content + * shows up only when the caller supplies a value that closes the comment + * early. The template body looks like: + * + * ``` + * [!-- {$a} + * + * VISIBLE + * + * [!----] + * ``` + * + * Wikidot's `[!--` comment closes at the *first* `--]`. The caller toggles + * the section by either: + * + * 1. Passing `a=--]` (typically written as `|a=--]]]` so the directive's + * closing `]]` follows without a separator): substituted into the + * template it becomes `[!-- --]\n\nVISIBLE\n\n[!----]`. The first `--]` + * closes the comment immediately, so `VISIBLE` shows up. + * 2. Leaving `a` unset: the template ships with `{$a}` literal in place, + * so the comment opens with `[!-- {$a}\n\nVISIBLE\n\n[!--` and only + * closes at the `--]` inside `[!----]`, swallowing `VISIBLE`. + * + * The point of the test is that both branches require the include layer + * to capture `--]` as the attribute value (and *not* leave a stray `]` + * outside the directive that would break the comment markup). + */ +describe("include + comment-wrap toggle idiom", () => { + const TEMPLATE = `[!-- {$a} + +VISIBLE + +[!----] + +after`; + + function pipeline(source: string): string { + const expanded = resolveIncludes(source, (ref) => { + return ref.page === "tmpl" ? TEMPLATE : null; + }); + const { ast } = parse(expanded); + return renderToHtml(ast); + } + + it("a=--]]] (caller supplies --] to close the comment early): VISIBLE shows", () => { + const html = pipeline("[[include tmpl|a=--]]]"); + expect(html).toContain("VISIBLE"); + expect(html).toContain("after"); + }); + + it("a unset: comment swallows the section, VISIBLE is hidden", () => { + const html = pipeline("[[include tmpl]]"); + expect(html).not.toContain("VISIBLE"); + expect(html).toContain("after"); + }); + + it("multi-line variant with several attributes preserves --] as the final value", () => { + // `[[include tmpl|other=x|a=--]]]` — same idiom but with an extra + // attribute before the trailing `--]`. The greedy close must apply + // to the *final* value only and not accidentally eat the earlier + // `|other=x|` section. + const src = `[[include tmpl +|other=x +|a=--]]]`; + const html = pipeline(src); + expect(html).toContain("VISIBLE"); + expect(html).toContain("after"); + }); +});