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/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/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/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/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/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..882ca39c --- /dev/null +++ b/packages/parser/src/parser/rules/inline/span/block-split.ts @@ -0,0 +1,310 @@ +/** + * 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 { 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"; +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]; + +type BoundaryKind = "open" | "close"; + +interface Segment { + start: number; + end: number; + /** Boundary the segment starts after; undefined for the first segment. */ + after?: BoundaryKind; +} + +interface OpenSpan { + /** `[[span_]]` shares the close tag but hides its body from Div in Wikidot. */ + strip: boolean; + 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; + 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) { + 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") { + footnoteCloses ??= matchFootnoteCloses(probe); + const end = footnoteCloses.get(pos); + if (end !== undefined) { + pos = end - 1; + 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_"; + 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 && startsLine(tokens, pos)) { + 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?.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 }); + } + pos += close.consumed - 1; + continue; + } + probe.pos = pos; + if (stripDepth === 0 && 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; +} + +/** 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--) { + 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; + while (prev >= 0 && tokens[prev]!.type === "WHITESPACE") prev--; + return prev < 0 || tokens[prev]!.type === "NEWLINE"; +} + +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/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/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/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], 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..c1fd0b6b --- /dev/null +++ b/tests/unit/parser/span-div-boundary.test.ts @@ -0,0 +1,112 @@ +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("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("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("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("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); + + 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)); + + expect(html.match(/
/g)).toHaveLength(2_000); + }, 5_000); +}); 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

', + ); +});