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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 43 additions & 14 deletions packages/parser/src/parser/rules/block/module/include/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

/**
Expand Down Expand Up @@ -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;
Expand Down
60 changes: 47 additions & 13 deletions tests/unit/module/include/resolve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("<<x>>]]]");
expect(resolveIncludes(source, fetcher)).toBe("<<x]]]>>");
});

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("<<x]>> 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("<<--]>>");
});
});

Expand Down
77 changes: 77 additions & 0 deletions tests/unit/parser/resolve/include-comment-integration.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading