From a8889db168730967417f87a2659e7c078b4ab3b5 Mon Sep 17 00:00:00 2001 From: r74tech Date: Thu, 8 Oct 2026 01:07:37 +0900 Subject: [PATCH 1/8] =?UTF-8?q?fix:=20div=E3=82=92=E3=81=BE=E3=81=9F?= =?UTF-8?q?=E3=81=90span=E3=81=8C[[/div]]=E3=82=92=E9=A3=B2=E3=81=BF?= =?UTF-8?q?=E8=BE=BC=E3=81=BFdiv=E3=81=8C=E9=96=89=E3=81=98=E3=81=AA?= =?UTF-8?q?=E3=81=84=E5=95=8F=E9=A1=8C=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wikidotはspanの組をdivより先に決め、出力をHTML Tidyで修復するため、 spanはdivの境界で閉じて同じ属性で開き直される。パース前にトークン列を 同じ形に書き換え、[[/div]]がspanの中身にならないようにした。 Fixes #78 --- packages/parser/src/parser/parse/context.ts | 5 +- .../parser/rules/inline/span/block-split.ts | 270 ++++++++++++ tests/fixtures/span/div-boundary/README.md | 14 + .../div-boundary/expected-diagnostics.json | 1 + .../fixtures/span/div-boundary/expected.json | 403 ++++++++++++++++++ tests/fixtures/span/div-boundary/input.ftml | 28 ++ tests/fixtures/span/div-boundary/output.html | 1 + tests/fixtures/span/div-boundary/wikidot.html | 27 ++ tests/unit/parser/span-div-boundary.test.ts | 59 +++ 9 files changed, 807 insertions(+), 1 deletion(-) create mode 100644 packages/parser/src/parser/rules/inline/span/block-split.ts create mode 100644 tests/fixtures/span/div-boundary/README.md create mode 100644 tests/fixtures/span/div-boundary/expected-diagnostics.json create mode 100644 tests/fixtures/span/div-boundary/expected.json create mode 100644 tests/fixtures/span/div-boundary/input.ftml create mode 100644 tests/fixtures/span/div-boundary/output.html create mode 100644 tests/fixtures/span/div-boundary/wikidot.html create mode 100644 tests/unit/parser/span-div-boundary.test.ts diff --git a/packages/parser/src/parser/parse/context.ts b/packages/parser/src/parser/parse/context.ts index 8a466be1..c0ba29fe 100644 --- a/packages/parser/src/parser/parse/context.ts +++ b/packages/parser/src/parser/parse/context.ts @@ -1,6 +1,7 @@ import { DEFAULT_SETTINGS, type PageRef } from "@wdprlib/ast"; import type { Token } from "../../lexer"; import { blockFallbackRule, blockRules, inlineRules, type ParseContext } from "../rules"; +import { splitSpansAtDivBoundaries } from "../rules/inline/span/block-split"; import type { ParserOptions } from "./options"; export function createParseContext( @@ -8,7 +9,7 @@ export function createParseContext( options: ParserOptions = {}, deferInclude?: (location: PageRef) => boolean, ): ParseContext { - return { + const ctx: ParseContext = { tokens, pos: 0, version: options.version ?? "wikidot", @@ -29,4 +30,6 @@ export function createParseContext( footnoteBlockParsed: false, }, }; + ctx.tokens = splitSpansAtDivBoundaries(ctx); + return ctx; } diff --git a/packages/parser/src/parser/rules/inline/span/block-split.ts b/packages/parser/src/parser/rules/inline/span/block-split.ts new file mode 100644 index 00000000..cd628d10 --- /dev/null +++ b/packages/parser/src/parser/rules/inline/span/block-split.ts @@ -0,0 +1,270 @@ +/** + * Split `[[span]]` pairs at the `[[div]]` boundaries they cross. + * + * Wikidot pairs `[[span]]` with `[[/span]]` before it parses `[[div]]`, so a + * span may open inside a div and close after it. HTML Tidy then repairs the + * output by closing the span at each block boundary and reopening it with the + * same attributes on the other side. Rewriting the token stream the same way + * keeps every span local to one block, so the regular span and div rules + * produce the repaired structure. + * + * @module + */ + +import type { Token } from "../../../../lexer"; +import type { ParseContext } from "../../types"; +import { codeBlockRule } from "../../block/code"; +import { isDivClose } from "../../block/div/close"; +import { parseDivOpen } from "../../block/div/open"; +import { mathBlockRule } from "../../block/math"; +import { createOpaqueProbe, opaqueRuleEnd } from "../../opaque-probe"; +import { linkTripleRule } from "../link-triple"; +import { protectedInlineRegionEnd } from "../raw/end"; +import { parseCloseSpan, parseSpanOpener } from "./syntax"; + +// These constructs are replaced before Span in Wikidot's processing order. +const opaqueRules = [codeBlockRule, mathBlockRule, linkTripleRule]; + +type BoundaryKind = "open" | "close"; + +interface Segment { + start: number; + end: number; + /** Boundary the segment starts after; undefined for the first segment. */ + after?: BoundaryKind; +} + +interface OpenSpan { + openStart: number; + openEnd: number; + segmentStart: number; + after?: BoundaryKind; + segments: Segment[]; +} + +interface CrossingSpan extends OpenSpan { + closeStart: number; + closeEnd: number; +} + +interface Edits { + closers: Map; + openers: Map; + deleted: Set; +} + +/** + * Return a token stream in which no `[[span]]` pair crosses a div boundary. + * The original array is returned when nothing needs splitting. + */ +export function splitSpansAtDivBoundaries(ctx: ParseContext): Token[] { + if (!hasSpanOpener(ctx.tokens)) return ctx.tokens; + const crossing = findCrossingSpans(createOpaqueProbe(ctx)); + if (crossing.length === 0) return ctx.tokens; + return applyEdits(ctx.tokens, planEdits(ctx.tokens, crossing)); +} + +function hasSpanOpener(tokens: readonly Token[]): boolean { + for (let pos = 0; pos < tokens.length - 1; pos++) { + if (tokens[pos]!.type === "BLOCK_OPEN" && tokens[pos + 1]!.value.toLowerCase() === "span") { + return true; + } + } + return false; +} + +function findCrossingSpans(probe: ParseContext): CrossingSpan[] { + const tokens = probe.tokens; + const stack: OpenSpan[] = []; + const crossing: CrossingSpan[] = []; + let divDepth = 0; + + const crossBoundary = (start: number, end: number, kind: BoundaryKind): void => { + for (const span of stack) { + span.segments.push({ start: span.segmentStart, end: start, after: span.after }); + span.segmentStart = end; + span.after = kind; + } + }; + + for (let pos = 0; pos < tokens.length; pos++) { + const token = tokens[pos]!; + + const protectedEnd = protectedInlineRegionEnd(tokens, pos, tokens.length); + if (protectedEnd > pos) { + pos = protectedEnd - 1; + continue; + } + const opaqueEnd = opaqueRuleEnd(probe, pos, opaqueRules, true); + if (opaqueEnd > pos) { + pos = opaqueEnd - 1; + continue; + } + + if (token.type === "BLOCK_OPEN") { + probe.pos = pos; + // Footnote precedes Span and keeps its body out of the outer source. + if (tokens[pos + 1]?.value.toLowerCase() === "footnote") { + pos = footnoteEnd(probe, pos) - 1; + continue; + } + const opener = parseSpanOpener(probe); + if (opener.success && opener.blockName === "span") { + stack.push({ openStart: pos, openEnd: opener.pos, segmentStart: opener.pos, segments: [] }); + pos = opener.pos - 1; + continue; + } + const div = parseDivOpen(probe); + if (div?.hasRequiredNewline && token.lineStart) { + divDepth++; + crossBoundary(pos, div.bodyStart, "open"); + pos = div.bodyStart - 1; + } + continue; + } + + if (token.type !== "BLOCK_END_OPEN") continue; + const close = parseCloseSpan(probe, pos); + if (close.success) { + const span = stack.pop(); + if (span && span.segments.length > 0) { + span.segments.push({ start: span.segmentStart, end: pos, after: span.after }); + crossing.push({ ...span, closeStart: pos, closeEnd: pos + close.consumed }); + } + pos += close.consumed - 1; + continue; + } + probe.pos = pos; + if (divDepth > 0 && isDivClose(probe)) { + divDepth--; + const end = tokens[pos + 2]?.type === "BLOCK_CLOSE" ? pos + 3 : pos + 2; + crossBoundary(pos, end, "close"); + pos = end - 1; + } + } + + return crossing; +} + +function footnoteEnd(probe: ParseContext, start: number): number { + const tokens = probe.tokens; + for (let pos = start + 2; pos < tokens.length; pos++) { + if ( + tokens[pos]?.type === "BLOCK_END_OPEN" && + tokens[pos + 1]?.value.toLowerCase() === "footnote" && + tokens[pos + 2]?.type === "BLOCK_CLOSE" + ) { + return pos + 3; + } + } + return start + 1; +} + +function planEdits(tokens: readonly Token[], crossing: CrossingSpan[]): Edits { + const edits: Edits = { closers: new Map(), openers: new Map(), deleted: new Set() }; + const add = (map: Map, pos: number, span: CrossingSpan): void => { + const list = map.get(pos); + if (list) list.push(span); + else map.set(pos, [span]); + }; + + for (const span of crossing) { + const last = span.segments.length - 1; + span.segments.forEach((segment, index) => { + // Tidy keeps a trailing line break inside the reopened span, but drops + // spans that only wrap whitespace between blocks. + const hasContent = segmentHasContent(tokens, segment, index === last && index > 0); + if (index === 0) { + if (hasContent) add(edits.closers, closePoint(tokens, segment), span); + else deleteRange(edits, span.openStart, span.openEnd); + return; + } + if (!hasContent) { + if (index === last) deleteRange(edits, span.closeStart, span.closeEnd); + return; + } + add(edits.openers, openPoint(tokens, segment), span); + if (index !== last) add(edits.closers, closePoint(tokens, segment), span); + }); + } + + return edits; +} + +function segmentHasContent( + tokens: readonly Token[], + segment: Segment, + newlineIsContent: boolean, +): boolean { + for (let pos = segment.start; pos < segment.end; pos++) { + const type = tokens[pos]!.type; + if (type === "WHITESPACE") continue; + if (type === "NEWLINE" && !newlineIsContent) continue; + return true; + } + return false; +} + +/** Close before the whitespace that separates the content from the boundary. */ +function closePoint(tokens: readonly Token[], segment: Segment): number { + let pos = segment.end; + while (pos > segment.start && isBlank(tokens[pos - 1]!)) pos--; + return pos; +} + +/** + * Reopen right after `[[/div]]` so the following newline stays inside the + * span, as Wikidot does, and at the first content inside a div body. + */ +function openPoint(tokens: readonly Token[], segment: Segment): number { + let pos = segment.start; + while ( + pos < segment.end && + (tokens[pos]!.type === "WHITESPACE" || + (segment.after === "open" && tokens[pos]!.type === "NEWLINE")) + ) { + pos++; + } + return pos; +} + +function isBlank(token: Token): boolean { + return token.type === "WHITESPACE" || token.type === "NEWLINE"; +} + +function deleteRange(edits: Edits, start: number, end: number): void { + for (let pos = start; pos < end; pos++) edits.deleted.add(pos); +} + +function applyEdits(tokens: readonly Token[], edits: Edits): Token[] { + const result: Token[] = []; + for (let pos = 0; pos < tokens.length; pos++) { + const token = tokens[pos]!; + const inserted: Token[] = []; + // Close inner spans first and reopen outer spans first. + const closers = edits.closers.get(pos)?.sort((a, b) => b.openStart - a.openStart) ?? []; + for (const span of closers) inserted.push(...closeTokens(tokens, span)); + const openers = edits.openers.get(pos)?.sort((a, b) => a.openStart - b.openStart) ?? []; + for (const span of openers) inserted.push(...tokens.slice(span.openStart, span.openEnd)); + + if (inserted.length > 0) { + inserted.forEach((synthetic, index) => { + result.push({ + ...synthetic, + position: { start: token.position.start, end: token.position.start }, + lineStart: index === 0 && token.lineStart, + }); + }); + if (!edits.deleted.has(pos)) result.push({ ...token, lineStart: false }); + continue; + } + if (!edits.deleted.has(pos)) result.push(token); + } + return result; +} + +function closeTokens(tokens: readonly Token[], span: CrossingSpan): Token[] { + const close = tokens.slice(span.closeStart, span.closeEnd); + if (close[close.length - 1]?.type === "BLOCK_CLOSE") return close; + return [...close, { ...close[0]!, type: "BLOCK_CLOSE", value: "]]" }]; +} diff --git a/tests/fixtures/span/div-boundary/README.md b/tests/fixtures/span/div-boundary/README.md new file mode 100644 index 00000000..a75bff55 --- /dev/null +++ b/tests/fixtures/span/div-boundary/README.md @@ -0,0 +1,14 @@ +`wikidot.html` は 2026-10-08 に SCP-JP の `edit/PagePreviewModule` へ +`input.ftml` を渡して取得した実機出力です。ページの保存は行っていません。 + +Wikidot は `[[span]]` と `[[/span]]` の組を `[[div]]` より先に決め、 +出力を HTML Tidy で修復します。そのため span は div の境界で閉じられ、 +境界の後ろで同じ属性のまま開き直されます。`[[/div]]` は span の中身にならず、 +div は閉じます。 + +`output.html` との差は、div 直後の本文を `

` で包むかどうかです。 +実機は `

` で包まずに `
` から始めますが、WDPR は `

` で包みます。 +この差は span を含まない `[[div]]` の直後でも同じように出ます。 + +入れ子の span が両方とも div をまたぐ場合、実機は外側の span だけを +開き直し、WDPR は両方を開き直します。この入力は本 fixture に含めません。 diff --git a/tests/fixtures/span/div-boundary/expected-diagnostics.json b/tests/fixtures/span/div-boundary/expected-diagnostics.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/tests/fixtures/span/div-boundary/expected-diagnostics.json @@ -0,0 +1 @@ +[] diff --git a/tests/fixtures/span/div-boundary/expected.json b/tests/fixtures/span/div-boundary/expected.json new file mode 100644 index 00000000..05475e36 --- /dev/null +++ b/tests/fixtures/span/div-boundary/expected.json @@ -0,0 +1,403 @@ +{ + "elements": [ + { + "element": "container", + "data": { + "type": "div", + "attributes": {}, + "elements": [ + { + "element": "container", + "data": { + "type": "paragraph", + "attributes": {}, + "elements": [ + { + "element": "container", + "data": { + "type": "span", + "attributes": { + "class": "k" + }, + "elements": [ + { + "element": "text", + "data": "a" + } + ] + } + } + ] + } + } + ] + } + }, + { + "element": "container", + "data": { + "type": "paragraph", + "attributes": {}, + "elements": [ + { + "element": "container", + "data": { + "type": "span", + "attributes": { + "class": "k" + }, + "elements": [ + { + "element": "line-break" + } + ] + } + } + ] + } + }, + { + "element": "container", + "data": { + "type": "div", + "attributes": {}, + "elements": [ + { + "element": "container", + "data": { + "type": "paragraph", + "attributes": {}, + "elements": [ + { + "element": "container", + "data": { + "type": "span", + "attributes": { + "style": "color:red" + }, + "elements": [ + { + "element": "text", + "data": "b" + } + ] + } + } + ] + } + } + ] + } + }, + { + "element": "container", + "data": { + "type": "paragraph", + "attributes": {}, + "elements": [ + { + "element": "container", + "data": { + "type": "span", + "attributes": { + "style": "color:red" + }, + "elements": [ + { + "element": "line-break" + }, + { + "element": "text", + "data": "c" + }, + { + "element": "text", + "data": " " + }, + { + "element": "container", + "data": { + "type": "bold", + "attributes": {}, + "elements": [ + { + "element": "text", + "data": "d" + } + ] + } + }, + { + "element": "line-break" + } + ] + } + } + ] + } + }, + { + "element": "container", + "data": { + "type": "paragraph", + "attributes": {}, + "elements": [ + { + "element": "container", + "data": { + "type": "span", + "attributes": { + "class": "k" + }, + "elements": [ + { + "element": "text", + "data": "e" + } + ] + } + } + ] + } + }, + { + "element": "container", + "data": { + "type": "div", + "attributes": {}, + "elements": [ + { + "element": "container", + "data": { + "type": "paragraph", + "attributes": {}, + "elements": [ + { + "element": "container", + "data": { + "type": "span", + "attributes": { + "class": "k" + }, + "elements": [ + { + "element": "text", + "data": "f" + } + ] + } + } + ] + } + } + ] + } + }, + { + "element": "container", + "data": { + "type": "paragraph", + "attributes": {}, + "elements": [ + { + "element": "container", + "data": { + "type": "span", + "attributes": { + "class": "k" + }, + "elements": [ + { + "element": "line-break" + }, + { + "element": "text", + "data": "g" + } + ] + } + } + ] + } + }, + { + "element": "container", + "data": { + "type": "div", + "attributes": {}, + "elements": [ + { + "element": "container", + "data": { + "type": "div", + "attributes": {}, + "elements": [ + { + "element": "container", + "data": { + "type": "paragraph", + "attributes": {}, + "elements": [ + { + "element": "container", + "data": { + "type": "span", + "attributes": { + "class": "k" + }, + "elements": [ + { + "element": "text", + "data": "h" + } + ] + } + } + ] + } + } + ] + } + }, + { + "element": "container", + "data": { + "type": "paragraph", + "attributes": {}, + "elements": [ + { + "element": "container", + "data": { + "type": "span", + "attributes": { + "class": "k" + }, + "elements": [ + { + "element": "line-break" + }, + { + "element": "text", + "data": "i" + } + ] + } + } + ] + } + } + ] + } + }, + { + "element": "container", + "data": { + "type": "paragraph", + "attributes": {}, + "elements": [ + { + "element": "container", + "data": { + "type": "span", + "attributes": { + "class": "k" + }, + "elements": [ + { + "element": "line-break" + }, + { + "element": "text", + "data": "j" + }, + { + "element": "line-break" + } + ] + } + } + ] + } + }, + { + "element": "container", + "data": { + "type": "div", + "attributes": {}, + "elements": [ + { + "element": "container", + "data": { + "type": "paragraph", + "attributes": {}, + "elements": [ + { + "element": "text", + "data": "k" + }, + { + "element": "text", + "data": " " + }, + { + "element": "container", + "data": { + "type": "span", + "attributes": { + "class": "k" + }, + "elements": [ + { + "element": "text", + "data": "l" + } + ] + } + } + ] + } + } + ] + } + }, + { + "element": "container", + "data": { + "type": "paragraph", + "attributes": {}, + "elements": [ + { + "element": "container", + "data": { + "type": "span", + "attributes": { + "class": "k" + }, + "elements": [ + { + "element": "text", + "data": "m" + } + ] + } + }, + { + "element": "text", + "data": " " + }, + { + "element": "text", + "data": "n" + } + ] + } + }, + { + "element": "footnote-block", + "data": { + "title": null, + "hide": false + } + } + ] +} diff --git a/tests/fixtures/span/div-boundary/input.ftml b/tests/fixtures/span/div-boundary/input.ftml new file mode 100644 index 00000000..4bd83258 --- /dev/null +++ b/tests/fixtures/span/div-boundary/input.ftml @@ -0,0 +1,28 @@ +[[div]] +[[span class="k"]]a +[[/div]] +[[/span]] + +[[div]] +[[span style="color:red"]]b +[[/div]] +c **d** +[[/span]] + +[[span class="k"]]e +[[div]] +f +[[/div]] +g[[/span]] + +[[div]] +[[div]] +[[span class="k"]]h +[[/div]] +i +[[/div]] +j +[[/span]] + +[[div]] +k [[span class="k"]]l [[/div]] m[[/span]] n \ No newline at end of file diff --git a/tests/fixtures/span/div-boundary/output.html b/tests/fixtures/span/div-boundary/output.html new file mode 100644 index 00000000..50e4e314 --- /dev/null +++ b/tests/fixtures/span/div-boundary/output.html @@ -0,0 +1 @@ +

a


b


c d

e

f


g

h


i


j

k l

m n

diff --git a/tests/fixtures/span/div-boundary/wikidot.html b/tests/fixtures/span/div-boundary/wikidot.html new file mode 100644 index 00000000..a8bc1092 --- /dev/null +++ b/tests/fixtures/span/div-boundary/wikidot.html @@ -0,0 +1,27 @@ +
+

a

+
+
+
+

b

+
+
+c d
+

e

+
+

f

+
+
+g
+
+
+

h

+
+
+i
+
+j
+
+

k l

+
+m n diff --git a/tests/unit/parser/span-div-boundary.test.ts b/tests/unit/parser/span-div-boundary.test.ts new file mode 100644 index 00000000..0f04fafe --- /dev/null +++ b/tests/unit/parser/span-div-boundary.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test"; +import { parse } from "@wdprlib/parser"; +import { renderToHtml } from "@wdprlib/render"; + +function render(source: string): { html: string; messages: string[] } { + const { ast, diagnostics } = parse(source); + return { html: renderToHtml(ast), messages: diagnostics.map((d) => d.message) }; +} + +describe("span crossing div boundaries", () => { + test("closes the div that the span opened in", () => { + const { html, messages } = render("[[div]]\n[[span]]a\n[[/div]]\n[[/span]]"); + + expect(html).toBe("

a


"); + expect(messages).toEqual([]); + }); + + test("keeps divs balanced when a stray [[/span]] appears much later", () => { + const filler = "[[div]]\nx\n[[/div]]\n".repeat(20); + const source = `[[div]]\n[[span]]a\n[[/div]]\n${filler}[[/span]]`; + const { html, messages } = render(source); + + expect(html).not.toContain("[["); + expect(html.match(/
/g)).toHaveLength(21); + expect(messages).toEqual([]); + }); + + test("reopens the span with its attributes inside a following div", () => { + const { html } = render('[[span class="k"]]e\n[[div]]\nf\n[[/div]]\ng[[/span]]'); + + expect(html).toBe( + '

e

f

' + + '


g

', + ); + }); + + test("ignores [[/div]] inside a code block", () => { + const { html } = render( + "[[div]]\n[[span]]a\n[[code]]\n[[/div]]\n[[/code]]\nb[[/span]]\n[[/div]]", + ); + + expect(html.startsWith("

a")).toBe(true); + expect(html.match(/

/g)).toHaveLength(1); + }); + + // Wikidot handles span_ in a separate rule whose body hides [[/div]] from Div. + test("does not split span_", () => { + const { html } = render("[[div]]\n[[span_]]a\n[[/div]]\nb\n[[/span]]"); + + expect(html).toContain("[[/div]]"); + }); + + test("handles many spans across many divs in linear time", () => { + const unit = '[[div]]\n[[span class="k"]]a\n[[/div]]\nb\n[[/span]]\n\n'; + const { html } = render(unit.repeat(2_000)); + + expect(html.match(/
/g)).toHaveLength(2_000); + }, 5_000); +}); From 47e00705db70d06e435e97107d145273241e3dc3 Mon Sep 17 00:00:00 2001 From: r74tech Date: Thu, 8 Oct 2026 01:13:50 +0900 Subject: [PATCH 2/8] =?UTF-8?q?fix:=20span=E5=88=86=E5=89=B2=E3=81=A7?= =?UTF-8?q?=E3=82=A4=E3=83=B3=E3=83=87=E3=83=B3=E3=83=88=E4=BB=98=E3=81=8D?= =?UTF-8?q?div=E3=83=BBspan=5F=E3=83=BB=E6=9C=AA=E9=96=89=E3=81=98footnote?= =?UTF-8?q?=E3=82=92=E6=AD=A3=E3=81=97=E3=81=8F=E6=89=B1=E3=81=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../parser/rules/inline/span/block-split.ts | 51 +++++++++++++++---- tests/unit/parser/span-div-boundary.test.ts | 22 ++++++++ 2 files changed, 62 insertions(+), 11 deletions(-) diff --git a/packages/parser/src/parser/rules/inline/span/block-split.ts b/packages/parser/src/parser/rules/inline/span/block-split.ts index cd628d10..183aeb00 100644 --- a/packages/parser/src/parser/rules/inline/span/block-split.ts +++ b/packages/parser/src/parser/rules/inline/span/block-split.ts @@ -18,6 +18,7 @@ import { isDivClose } from "../../block/div/close"; import { parseDivOpen } from "../../block/div/open"; import { mathBlockRule } from "../../block/math"; import { createOpaqueProbe, opaqueRuleEnd } from "../../opaque-probe"; +import { parseFootnoteOpen } from "../footnote/open"; import { linkTripleRule } from "../link-triple"; import { protectedInlineRegionEnd } from "../raw/end"; import { parseCloseSpan, parseSpanOpener } from "./syntax"; @@ -35,6 +36,8 @@ interface Segment { } interface OpenSpan { + /** `[[span_]]` shares the close tag but hides its body from Div in Wikidot. */ + strip: boolean; openStart: number; openEnd: number; segmentStart: number; @@ -78,6 +81,9 @@ function findCrossingSpans(probe: ParseContext): CrossingSpan[] { const stack: OpenSpan[] = []; const crossing: CrossingSpan[] = []; let divDepth = 0; + let stripDepth = 0; + // A footnote without a close here has none at any later position either. + let footnoteCloseMissingFrom = tokens.length; const crossBoundary = (start: number, end: number, kind: BoundaryKind): void => { for (const span of stack) { @@ -104,18 +110,33 @@ function findCrossingSpans(probe: ParseContext): CrossingSpan[] { if (token.type === "BLOCK_OPEN") { probe.pos = pos; // Footnote precedes Span and keeps its body out of the outer source. - if (tokens[pos + 1]?.value.toLowerCase() === "footnote") { - pos = footnoteEnd(probe, pos) - 1; + const footnote = parseFootnoteOpen(probe); + if (footnote) { + const end = + footnote.bodyStart < footnoteCloseMissingFrom + ? footnoteCloseEnd(tokens, footnote.bodyStart) + : -1; + if (end < 0) footnoteCloseMissingFrom = Math.min(footnoteCloseMissingFrom, pos); + else pos = end - 1; continue; } const opener = parseSpanOpener(probe); - if (opener.success && opener.blockName === "span") { - stack.push({ openStart: pos, openEnd: opener.pos, segmentStart: opener.pos, segments: [] }); + if (opener.success) { + const strip = opener.blockName === "span_"; + if (strip) stripDepth++; + stack.push({ + strip, + openStart: pos, + openEnd: opener.pos, + segmentStart: opener.pos, + segments: [], + }); pos = opener.pos - 1; continue; } + if (stripDepth > 0) continue; const div = parseDivOpen(probe); - if (div?.hasRequiredNewline && token.lineStart) { + if (div?.hasRequiredNewline && startsLine(tokens, pos)) { divDepth++; crossBoundary(pos, div.bodyStart, "open"); pos = div.bodyStart - 1; @@ -127,7 +148,8 @@ function findCrossingSpans(probe: ParseContext): CrossingSpan[] { const close = parseCloseSpan(probe, pos); if (close.success) { const span = stack.pop(); - if (span && span.segments.length > 0) { + if (span?.strip) stripDepth--; + if (span && !span.strip && span.segments.length > 0) { span.segments.push({ start: span.segmentStart, end: pos, after: span.after }); crossing.push({ ...span, closeStart: pos, closeEnd: pos + close.consumed }); } @@ -135,7 +157,7 @@ function findCrossingSpans(probe: ParseContext): CrossingSpan[] { continue; } probe.pos = pos; - if (divDepth > 0 && isDivClose(probe)) { + if (stripDepth === 0 && divDepth > 0 && isDivClose(probe)) { divDepth--; const end = tokens[pos + 2]?.type === "BLOCK_CLOSE" ? pos + 3 : pos + 2; crossBoundary(pos, end, "close"); @@ -146,9 +168,16 @@ function findCrossingSpans(probe: ParseContext): CrossingSpan[] { return crossing; } -function footnoteEnd(probe: ParseContext, start: number): number { - const tokens = probe.tokens; - for (let pos = start + 2; pos < tokens.length; pos++) { +/** Div opens where block parsing can start: after indentation at most. */ +function startsLine(tokens: readonly Token[], pos: number): boolean { + let prev = pos - 1; + while (prev >= 0 && tokens[prev]!.type === "WHITESPACE") prev--; + return prev < 0 || tokens[prev]!.type === "NEWLINE"; +} + +/** End of the first `[[/footnote]]`, or -1 when the footnote never closes. */ +function footnoteCloseEnd(tokens: readonly Token[], start: number): number { + for (let pos = start; pos < tokens.length; pos++) { if ( tokens[pos]?.type === "BLOCK_END_OPEN" && tokens[pos + 1]?.value.toLowerCase() === "footnote" && @@ -157,7 +186,7 @@ function footnoteEnd(probe: ParseContext, start: number): number { return pos + 3; } } - return start + 1; + return -1; } function planEdits(tokens: readonly Token[], crossing: CrossingSpan[]): Edits { diff --git a/tests/unit/parser/span-div-boundary.test.ts b/tests/unit/parser/span-div-boundary.test.ts index 0f04fafe..8804f41d 100644 --- a/tests/unit/parser/span-div-boundary.test.ts +++ b/tests/unit/parser/span-div-boundary.test.ts @@ -50,6 +50,28 @@ describe("span crossing div boundaries", () => { expect(html).toContain("[[/div]]"); }); + test("counts indented nested divs", () => { + const { html } = render("[[div]]\n[[span]]a\n [[div]]\nb\n[[/div]]\nc[[/span]]\n[[/div]]"); + + expect(html).toBe( + "

a

b

" + + "


c

", + ); + }); + + test("pairs [[/span]] with an inner span_ before the outer span", () => { + const source = "[[span class=outer]]a[[span_]]b\n[[div]]\nc[[/span]]\n[[/div]]\nd[[/span]]"; + const { html } = render(source); + + expect(html.match(/class="outer"/g)).toHaveLength(1); + }); + + test("skips repeated unclosed footnotes in linear time", () => { + const source = "[[span]]a[[/span]]\n" + "[[footnote x]]".repeat(16_000); + + expect(() => parse(source)).not.toThrow(); + }, 1_000); + test("handles many spans across many divs in linear time", () => { const unit = '[[div]]\n[[span class="k"]]a\n[[/div]]\nb\n[[/span]]\n\n'; const { html } = render(unit.repeat(2_000)); From 412e92fe35a2df5e4dd31482c489ed1a00db188f Mon Sep 17 00:00:00 2001 From: r74tech Date: Thu, 8 Oct 2026 01:16:41 +0900 Subject: [PATCH 3/8] =?UTF-8?q?fix:=20span=E5=88=86=E5=89=B2=E3=81=A7footn?= =?UTF-8?q?ote=E3=81=AE=E9=96=89=E3=81=98=E3=82=BF=E3=82=B0=E3=81=A8html?= =?UTF-8?q?=E3=83=96=E3=83=AD=E3=83=83=E3=82=AF=E3=82=92=E6=97=A2=E5=AD=98?= =?UTF-8?q?=E3=81=AE=E8=A6=8F=E5=89=87=E3=81=A9=E3=81=8A=E3=82=8A=E3=81=AB?= =?UTF-8?q?=E6=89=B1=E3=81=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../parser/rules/inline/footnote/boundary.ts | 36 ++++++++++++++++++ .../parser/rules/inline/span/block-split.ts | 38 ++++++------------- tests/unit/parser/span-div-boundary.test.ts | 17 +++++++++ 3 files changed, 64 insertions(+), 27 deletions(-) diff --git a/packages/parser/src/parser/rules/inline/footnote/boundary.ts b/packages/parser/src/parser/rules/inline/footnote/boundary.ts index 83304243..082f275d 100644 --- a/packages/parser/src/parser/rules/inline/footnote/boundary.ts +++ b/packages/parser/src/parser/rules/inline/footnote/boundary.ts @@ -54,3 +54,39 @@ export function findFootnoteEnd(ctx: ParseContext, start: number): number { } return ctx.tokens.length; } + +/** + * Map each footnote opener to the end of its close in one pass, using the same + * protected regions and nesting as {@link findFootnoteEnd}. + */ +export function matchFootnoteCloses(ctx: ParseContext): Map { + const probe = createOpaqueProbe(ctx); + const matches = new Map(); + const openers: number[] = []; + for (let pos = 0; pos < ctx.tokens.length; pos++) { + if (ctx.tokens[pos]?.type === "EOF") break; + const protectedEnd = protectedInlineRegionEnd(ctx.tokens, pos, ctx.tokens.length); + if (protectedEnd > pos) { + pos = protectedEnd - 1; + continue; + } + + probe.pos = pos; + const close = tryConsumeFootnoteClose(probe, pos); + if (close) { + const open = openers.pop(); + if (open !== undefined) matches.set(open, pos + close.consumed); + pos += close.consumed - 1; + continue; + } + const open = parseFootnoteOpen(probe); + if (open) { + openers.push(pos); + pos += open.consumed - 1; + continue; + } + const opaqueEnd = opaqueRuleEnd(probe, pos, opaqueRules); + if (opaqueEnd > pos) pos = opaqueEnd - 1; + } + return matches; +} diff --git a/packages/parser/src/parser/rules/inline/span/block-split.ts b/packages/parser/src/parser/rules/inline/span/block-split.ts index 183aeb00..1c8f02ce 100644 --- a/packages/parser/src/parser/rules/inline/span/block-split.ts +++ b/packages/parser/src/parser/rules/inline/span/block-split.ts @@ -16,15 +16,16 @@ import type { ParseContext } from "../../types"; import { codeBlockRule } from "../../block/code"; import { isDivClose } from "../../block/div/close"; import { parseDivOpen } from "../../block/div/open"; +import { htmlBlockRule } from "../../block/html"; import { mathBlockRule } from "../../block/math"; import { createOpaqueProbe, opaqueRuleEnd } from "../../opaque-probe"; -import { parseFootnoteOpen } from "../footnote/open"; +import { matchFootnoteCloses } from "../footnote/boundary"; import { linkTripleRule } from "../link-triple"; import { protectedInlineRegionEnd } from "../raw/end"; import { parseCloseSpan, parseSpanOpener } from "./syntax"; // These constructs are replaced before Span in Wikidot's processing order. -const opaqueRules = [codeBlockRule, mathBlockRule, linkTripleRule]; +const opaqueRules = [codeBlockRule, htmlBlockRule, mathBlockRule, linkTripleRule]; type BoundaryKind = "open" | "close"; @@ -82,8 +83,7 @@ function findCrossingSpans(probe: ParseContext): CrossingSpan[] { const crossing: CrossingSpan[] = []; let divDepth = 0; let stripDepth = 0; - // A footnote without a close here has none at any later position either. - let footnoteCloseMissingFrom = tokens.length; + let footnoteCloses: Map | undefined; const crossBoundary = (start: number, end: number, kind: BoundaryKind): void => { for (const span of stack) { @@ -110,15 +110,13 @@ function findCrossingSpans(probe: ParseContext): CrossingSpan[] { if (token.type === "BLOCK_OPEN") { probe.pos = pos; // Footnote precedes Span and keeps its body out of the outer source. - const footnote = parseFootnoteOpen(probe); - if (footnote) { - const end = - footnote.bodyStart < footnoteCloseMissingFrom - ? footnoteCloseEnd(tokens, footnote.bodyStart) - : -1; - if (end < 0) footnoteCloseMissingFrom = Math.min(footnoteCloseMissingFrom, pos); - else pos = end - 1; - continue; + if (tokens[pos + 1]?.value.toLowerCase() === "footnote") { + footnoteCloses ??= matchFootnoteCloses(probe); + const end = footnoteCloses.get(pos); + if (end !== undefined) { + pos = end - 1; + continue; + } } const opener = parseSpanOpener(probe); if (opener.success) { @@ -175,20 +173,6 @@ function startsLine(tokens: readonly Token[], pos: number): boolean { return prev < 0 || tokens[prev]!.type === "NEWLINE"; } -/** End of the first `[[/footnote]]`, or -1 when the footnote never closes. */ -function footnoteCloseEnd(tokens: readonly Token[], start: number): number { - for (let pos = start; pos < tokens.length; pos++) { - if ( - tokens[pos]?.type === "BLOCK_END_OPEN" && - tokens[pos + 1]?.value.toLowerCase() === "footnote" && - tokens[pos + 2]?.type === "BLOCK_CLOSE" - ) { - return pos + 3; - } - } - return -1; -} - function planEdits(tokens: readonly Token[], crossing: CrossingSpan[]): Edits { const edits: Edits = { closers: new Map(), openers: new Map(), deleted: new Set() }; const add = (map: Map, pos: number, span: CrossingSpan): void => { diff --git a/tests/unit/parser/span-div-boundary.test.ts b/tests/unit/parser/span-div-boundary.test.ts index 8804f41d..e5ea1314 100644 --- a/tests/unit/parser/span-div-boundary.test.ts +++ b/tests/unit/parser/span-div-boundary.test.ts @@ -66,6 +66,23 @@ describe("span crossing div boundaries", () => { expect(html.match(/class="outer"/g)).toHaveLength(1); }); + test("finds the footnote close outside raw text", () => { + const source = + "[[span class=k]]a[[footnote]]@@[[/footnote]]@@\n[[div]]\nb\n[[/div]]\n[[/footnote]]c[[/span]]"; + const { html, messages } = render(source); + + expect(html).toContain("

b

"); + expect(messages).toEqual([]); + }); + + test("ignores div tags inside an html block", () => { + const source = + "[[div]]\n[[span class=k]]a\n[[html]]\n[[/div]]\n[[/html]]\nb[[/span]]\n[[/div]]"; + const { html } = render(source); + + expect(html.match(/class="k"/g)).toHaveLength(1); + }); + test("skips repeated unclosed footnotes in linear time", () => { const source = "[[span]]a[[/span]]\n" + "[[footnote x]]".repeat(16_000); From cb242118de0072ca5dbe86d8de26b1eac9e74a75 Mon Sep 17 00:00:00 2001 From: r74tech Date: Thu, 8 Oct 2026 01:19:15 +0900 Subject: [PATCH 4/8] =?UTF-8?q?refactor:=20span=E5=88=86=E5=89=B2=E3=81=A7?= =?UTF-8?q?html=E3=83=96=E3=83=AD=E3=83=83=E3=82=AF=E3=82=92=E8=AA=AD?= =?UTF-8?q?=E3=81=BF=E9=A3=9B=E3=81=B0=E3=81=99=E5=87=A6=E7=90=86=E3=82=92?= =?UTF-8?q?=E5=8F=96=E3=82=8A=E9=99=A4=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../parser/src/parser/rules/inline/span/block-split.ts | 3 +-- tests/unit/parser/span-div-boundary.test.ts | 8 -------- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/packages/parser/src/parser/rules/inline/span/block-split.ts b/packages/parser/src/parser/rules/inline/span/block-split.ts index 1c8f02ce..7110aeca 100644 --- a/packages/parser/src/parser/rules/inline/span/block-split.ts +++ b/packages/parser/src/parser/rules/inline/span/block-split.ts @@ -16,7 +16,6 @@ import type { ParseContext } from "../../types"; import { codeBlockRule } from "../../block/code"; import { isDivClose } from "../../block/div/close"; import { parseDivOpen } from "../../block/div/open"; -import { htmlBlockRule } from "../../block/html"; import { mathBlockRule } from "../../block/math"; import { createOpaqueProbe, opaqueRuleEnd } from "../../opaque-probe"; import { matchFootnoteCloses } from "../footnote/boundary"; @@ -25,7 +24,7 @@ import { protectedInlineRegionEnd } from "../raw/end"; import { parseCloseSpan, parseSpanOpener } from "./syntax"; // These constructs are replaced before Span in Wikidot's processing order. -const opaqueRules = [codeBlockRule, htmlBlockRule, mathBlockRule, linkTripleRule]; +const opaqueRules = [codeBlockRule, mathBlockRule, linkTripleRule]; type BoundaryKind = "open" | "close"; diff --git a/tests/unit/parser/span-div-boundary.test.ts b/tests/unit/parser/span-div-boundary.test.ts index e5ea1314..16727f9d 100644 --- a/tests/unit/parser/span-div-boundary.test.ts +++ b/tests/unit/parser/span-div-boundary.test.ts @@ -75,14 +75,6 @@ describe("span crossing div boundaries", () => { expect(messages).toEqual([]); }); - test("ignores div tags inside an html block", () => { - const source = - "[[div]]\n[[span class=k]]a\n[[html]]\n[[/div]]\n[[/html]]\nb[[/span]]\n[[/div]]"; - const { html } = render(source); - - expect(html.match(/class="k"/g)).toHaveLength(1); - }); - test("skips repeated unclosed footnotes in linear time", () => { const source = "[[span]]a[[/span]]\n" + "[[footnote x]]".repeat(16_000); From bc5a883f884be2345d0e0bbb4e293dcfbb53a425 Mon Sep 17 00:00:00 2001 From: r74tech Date: Fri, 9 Oct 2026 21:30:52 +0900 Subject: [PATCH 5/8] =?UTF-8?q?fix:=20footnote=E3=81=AE=E5=85=A5=E3=82=8C?= =?UTF-8?q?=E5=AD=90=E3=81=AB=E4=B8=8A=E9=99=90=E3=82=92=E8=A8=AD=E3=81=91?= =?UTF-8?q?=E3=80=81=E9=96=89=E3=81=98=E3=81=AA=E3=81=84footnote=E3=81=AE?= =?UTF-8?q?=E9=80=A3=E7=B6=9A=E3=81=A7=E3=82=B9=E3=82=BF=E3=83=83=E3=82=AF?= =?UTF-8?q?=E6=BA=A2=E3=82=8C=E3=81=99=E3=82=8B=E5=95=8F=E9=A1=8C=E3=82=92?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/parser/src/parser/rules/contracts/scope.ts | 2 ++ .../src/parser/rules/inline/footnote/content.ts | 1 + .../parser/src/parser/rules/inline/footnote/index.ts | 8 +++++++- tests/unit/parser/footnote-content.test.ts | 11 +++++++++++ 4 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/parser/src/parser/rules/contracts/scope.ts b/packages/parser/src/parser/rules/contracts/scope.ts index ed0d4861..1ee40ad6 100644 --- a/packages/parser/src/parser/rules/contracts/scope.ts +++ b/packages/parser/src/parser/rules/contracts/scope.ts @@ -34,4 +34,6 @@ export interface ScopeContext { * the original primitive semantics while avoiding rollback-unsafe shared state. */ readonly footnoteBlockParsed: boolean; + /** Number of enclosing footnote bodies. */ + readonly footnoteDepth?: number; } diff --git a/packages/parser/src/parser/rules/inline/footnote/content.ts b/packages/parser/src/parser/rules/inline/footnote/content.ts index 823faada..92d8fec1 100644 --- a/packages/parser/src/parser/rules/inline/footnote/content.ts +++ b/packages/parser/src/parser/rules/inline/footnote/content.ts @@ -29,6 +29,7 @@ export function parseFootnoteContent(ctx: ParseContext, startPos: number): Footn inlineEnd: undefined, tableFormatting: undefined, blockCloseCondition: undefined, + footnoteDepth: (ctx.scope.footnoteDepth ?? 0) + 1, }, }; const result = parseBlocksUntil(bodyCtx, () => false); diff --git a/packages/parser/src/parser/rules/inline/footnote/index.ts b/packages/parser/src/parser/rules/inline/footnote/index.ts index a2d3af69..7f1e0863 100644 --- a/packages/parser/src/parser/rules/inline/footnote/index.ts +++ b/packages/parser/src/parser/rules/inline/footnote/index.ts @@ -11,6 +11,12 @@ import { parseFootnoteContent } from "./content"; import { buildFootnoteChildren } from "./elements"; import { parseFootnoteOpen } from "./open"; +/** + * Each nested body is parsed recursively, so deeper footnotes stay as text + * instead of overflowing the call stack. + */ +const MAX_FOOTNOTE_NESTING = 64; + export const footnoteRule: InlineRule = { name: "footnote", startTokens: ["BLOCK_OPEN"], @@ -22,7 +28,7 @@ export const footnoteRule: InlineRule = { } const openResult = parseFootnoteOpen(ctx); - if (!openResult) { + if (!openResult || (ctx.scope.footnoteDepth ?? 0) >= MAX_FOOTNOTE_NESTING) { return { success: false }; } diff --git a/tests/unit/parser/footnote-content.test.ts b/tests/unit/parser/footnote-content.test.ts index bff94b08..2f4f7912 100644 --- a/tests/unit/parser/footnote-content.test.ts +++ b/tests/unit/parser/footnote-content.test.ts @@ -155,6 +155,17 @@ test("nested footnote boundaries preserve the existing registration order", () = expect(renderToHtml(ast)).toContain(">1after

"); }); +test("footnotes nested beyond the limit stay as text", () => { + const { ast } = parse("[[footnote]]".repeat(65) + "x" + "[[/footnote]]".repeat(65)); + expect(ast.footnotes).toHaveLength(64); + expect(renderToHtml(ast)).toContain("[[footnote]]x[[/footnote]]"); +}); + +test("many unclosed footnotes parse without overflowing the stack", () => { + const { ast } = parse("[[footnote]]".repeat(16_000)); + expect(ast.footnotes).toHaveLength(64); +}, 5_000); + test.each([ ["[[html]]hidden[[/footnote]][[/html]]\nend", 1], ["[[html]]hidden\n\nend", 2], From e53dc49963e38def63ca8498c5d03a7edfce4ecf Mon Sep 17 00:00:00 2001 From: r74tech Date: Fri, 9 Oct 2026 21:56:03 +0900 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20=E7=A9=BA=E8=A1=8C=E3=81=A7=E5=88=86?= =?UTF-8?q?=E5=89=B2=E3=81=97=E3=81=9Fspan=E3=81=AE2=E3=81=A4=E7=9B=AE?= =?UTF-8?q?=E4=BB=A5=E9=99=8D=E3=81=AB=E3=82=82=E5=B1=9E=E6=80=A7=E3=82=92?= =?UTF-8?q?=E5=BC=95=E3=81=8D=E7=B6=99=E3=81=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/parser/src/parser/rules/inline/span/elements.ts | 2 +- tests/unit/parser/span-paragraph.test.ts | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/parser/src/parser/rules/inline/span/elements.ts b/packages/parser/src/parser/rules/inline/span/elements.ts index 1b2595ef..f052ac35 100644 --- a/packages/parser/src/parser/rules/inline/span/elements.ts +++ b/packages/parser/src/parser/rules/inline/span/elements.ts @@ -91,7 +91,7 @@ function buildSplitSpans( element: "container" as const, data: { type: "span" as const, - attributes: index === 0 ? attributes : {}, + attributes, elements: segment, _splitByBlankLine: index > 0, }, diff --git a/tests/unit/parser/span-paragraph.test.ts b/tests/unit/parser/span-paragraph.test.ts index 21152a17..46f7f291 100644 --- a/tests/unit/parser/span-paragraph.test.ts +++ b/tests/unit/parser/span-paragraph.test.ts @@ -72,3 +72,10 @@ test("literal div examples do not mark their paragraphs as malformed", () => { "[[span_]][[/span]]\n[[/div]]", ); }); + +// Wikidot's HTML Tidy reopens the span with the same attributes, including the id. +test("spans split by a blank line keep their attributes", () => { + expect(html('[[span id="x" style="color:red"]]a\n\nb[[/span]]')).toBe( + '

a

b

', + ); +}); From 5814a3be99bf58d433b54e199ee94db22e5f8574 Mon Sep 17 00:00:00 2001 From: r74tech Date: Fri, 9 Oct 2026 21:56:03 +0900 Subject: [PATCH 7/8] =?UTF-8?q?fix:=20span=E5=88=86=E5=89=B2=E3=81=A7?= =?UTF-8?q?=E8=A1=8C=E9=A0=AD=E3=81=AEhtml=E3=83=96=E3=83=AD=E3=83=83?= =?UTF-8?q?=E3=82=AF=E3=81=AE=E6=9C=AC=E6=96=87=E3=82=92=E8=AA=AD=E3=81=BF?= =?UTF-8?q?=E9=A3=9B=E3=81=B0=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/parser/rules/block/html/body.ts | 2 +- .../parser/rules/inline/span/block-split.ts | 29 +++++++++++++++++++ tests/unit/parser/span-div-boundary.test.ts | 15 ++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/packages/parser/src/parser/rules/block/html/body.ts b/packages/parser/src/parser/rules/block/html/body.ts index e7110ab5..b6450692 100644 --- a/packages/parser/src/parser/rules/block/html/body.ts +++ b/packages/parser/src/parser/rules/block/html/body.ts @@ -98,7 +98,7 @@ export function consumeHtmlClose(ctx: ParseContext, startPos: number): number { return consumed; } -function isHtmlCloseAt(ctx: ParseContext, pos: number): boolean { +export function isHtmlCloseAt(ctx: ParseContext, pos: number): boolean { if (ctx.tokens[pos]?.type !== "BLOCK_END_OPEN") { return false; } diff --git a/packages/parser/src/parser/rules/inline/span/block-split.ts b/packages/parser/src/parser/rules/inline/span/block-split.ts index 7110aeca..662763b1 100644 --- a/packages/parser/src/parser/rules/inline/span/block-split.ts +++ b/packages/parser/src/parser/rules/inline/span/block-split.ts @@ -15,6 +15,8 @@ import type { Token } from "../../../../lexer"; import type { ParseContext } from "../../types"; import { codeBlockRule } from "../../block/code"; import { isDivClose } from "../../block/div/close"; +import { isHtmlCloseAt } from "../../block/html/body"; +import { parseHtmlOpen } from "../../block/html/open"; import { parseDivOpen } from "../../block/div/open"; import { mathBlockRule } from "../../block/math"; import { createOpaqueProbe, opaqueRuleEnd } from "../../opaque-probe"; @@ -83,6 +85,7 @@ function findCrossingSpans(probe: ParseContext): CrossingSpan[] { let divDepth = 0; let stripDepth = 0; let footnoteCloses: Map | undefined; + let htmlCloses: Int32Array | undefined; const crossBoundary = (start: number, end: number, kind: BoundaryKind): void => { for (const span of stack) { @@ -117,6 +120,15 @@ function findCrossingSpans(probe: ParseContext): CrossingSpan[] { continue; } } + // An HTML block keeps its body verbatim, but only where a block can start. + if (startsLine(tokens, pos) && parseHtmlOpen(probe, pos)) { + htmlCloses ??= findNextHtmlCloses(probe); + const end = htmlCloses[pos]!; + if (end >= 0) { + pos = end - 1; + continue; + } + } const opener = parseSpanOpener(probe); if (opener.success) { const strip = opener.blockName === "span_"; @@ -165,6 +177,23 @@ function findCrossingSpans(probe: ParseContext): CrossingSpan[] { return crossing; } +/** End of the first `[[/html]]` at or after each position, or -1 when none follows. */ +function findNextHtmlCloses(probe: ParseContext): Int32Array { + const tokens = probe.tokens; + const ends = new Int32Array(tokens.length + 1).fill(-1); + for (let pos = tokens.length - 1; pos >= 0; pos--) { + probe.pos = pos; + if (!isHtmlCloseAt(probe, pos)) { + ends[pos] = ends[pos + 1]!; + continue; + } + let end = pos + 1; + while (tokens[end] && tokens[end]!.type !== "BLOCK_CLOSE") end++; + ends[pos] = end + 1; + } + return ends; +} + /** Div opens where block parsing can start: after indentation at most. */ function startsLine(tokens: readonly Token[], pos: number): boolean { let prev = pos - 1; diff --git a/tests/unit/parser/span-div-boundary.test.ts b/tests/unit/parser/span-div-boundary.test.ts index 16727f9d..b5d8ba86 100644 --- a/tests/unit/parser/span-div-boundary.test.ts +++ b/tests/unit/parser/span-div-boundary.test.ts @@ -75,6 +75,21 @@ describe("span crossing div boundaries", () => { expect(messages).toEqual([]); }); + test("ignores div tags inside an html block", () => { + const source = + "[[div]]\n[[span class=k]]a\n[[html]]\n[[/div]]\n[[/html]]\nb[[/span]]\n[[/div]]"; + const { html } = render(source); + + expect(html.match(/class="k"/g)).toHaveLength(1); + }); + + test("does not treat an inline [[html]] as a block", () => { + const source = "[[div]]\n[[span class=k]]a x[[html]][[/span]][[/html]]\n[[/div]]\nb[[/span]]"; + const { html } = render(source); + + expect(html.endsWith("

b

")).toBe(true); + }); + test("skips repeated unclosed footnotes in linear time", () => { const source = "[[span]]a[[/span]]\n" + "[[footnote x]]".repeat(16_000); From 34163dc2bf48ae5677b862fb9f22b2b914c5897d Mon Sep 17 00:00:00 2001 From: r74tech Date: Fri, 9 Oct 2026 23:34:46 +0900 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20html=E3=81=AE=E9=96=89=E3=81=98?= =?UTF-8?q?=E3=82=BF=E3=82=B0=E8=A1=A8=E3=82=92=E4=BD=9C=E3=82=8B=E3=81=A8?= =?UTF-8?q?=E3=81=8D=E3=81=AB=E5=85=B1=E6=9C=89=E3=81=AE=E8=B5=B0=E6=9F=BB?= =?UTF-8?q?=E4=BD=8D=E7=BD=AE=E3=82=92=E6=9B=B8=E3=81=8D=E6=8F=9B=E3=81=88?= =?UTF-8?q?=E3=81=AA=E3=81=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../parser/src/parser/rules/inline/span/block-split.ts | 1 - tests/unit/parser/span-div-boundary.test.ts | 7 +++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/parser/src/parser/rules/inline/span/block-split.ts b/packages/parser/src/parser/rules/inline/span/block-split.ts index 662763b1..882ca39c 100644 --- a/packages/parser/src/parser/rules/inline/span/block-split.ts +++ b/packages/parser/src/parser/rules/inline/span/block-split.ts @@ -182,7 +182,6 @@ function findNextHtmlCloses(probe: ParseContext): Int32Array { const tokens = probe.tokens; const ends = new Int32Array(tokens.length + 1).fill(-1); for (let pos = tokens.length - 1; pos >= 0; pos--) { - probe.pos = pos; if (!isHtmlCloseAt(probe, pos)) { ends[pos] = ends[pos + 1]!; continue; diff --git a/tests/unit/parser/span-div-boundary.test.ts b/tests/unit/parser/span-div-boundary.test.ts index b5d8ba86..c1fd0b6b 100644 --- a/tests/unit/parser/span-div-boundary.test.ts +++ b/tests/unit/parser/span-div-boundary.test.ts @@ -90,6 +90,13 @@ describe("span crossing div boundaries", () => { expect(html.endsWith("

b

")).toBe(true); }); + test("keeps the span when an unclosed [[html]] follows", () => { + const source = "[[span class=k]]a\n[[div]]\nb\n[[html]]\nc[[/span]]\n[[/div]]"; + const { html } = render(source); + + expect(html).toContain('

b
[[html]]
c

'); + }); + test("skips repeated unclosed footnotes in linear time", () => { const source = "[[span]]a[[/span]]\n" + "[[footnote x]]".repeat(16_000);