diff --git a/examples/wdmock-cf/apps/main/src/services/pipeline.ts b/examples/wdmock-cf/apps/main/src/services/pipeline.ts index 4116c48..081871e 100644 --- a/examples/wdmock-cf/apps/main/src/services/pipeline.ts +++ b/examples/wdmock-cf/apps/main/src/services/pipeline.ts @@ -47,7 +47,7 @@ export async function renderPage( const expanded = resolveIncludes(source, (pageRef: PageRef) => { return pageSourceMap.get(pageRef.page) ?? null; }); - const resolved = parse(expanded); + const { ast: resolved, diagnostics: _diagnostics = [] } = parse(expanded); const { requirements, compiledListPagesTemplates } = extractDataRequirements(resolved); @@ -66,7 +66,7 @@ export async function renderPage( getPageTags: () => pageTags, }, { - parse, + parse: (input: string) => parse(input).ast, compiledListPagesTemplates, requirements, urlPath: options?.urlPath, diff --git a/packages/ast/src/diagnostic.ts b/packages/ast/src/diagnostic.ts new file mode 100644 index 0000000..60cf42f --- /dev/null +++ b/packages/ast/src/diagnostic.ts @@ -0,0 +1,104 @@ +/** + * Diagnostic types for reporting parse-time issues. + * + * When the parser encounters syntactically questionable or invalid markup + * (e.g. an unclosed `[[div]]` block), it records a {@link Diagnostic} rather + * than throwing an error. The parser is lenient: it always produces an AST, + * even when diagnostics are present. + * + * Diagnostics are returned alongside the AST via {@link ParseResult}. + * + * @since 2.0.0 + * @module + */ + +import type { Position } from "./position"; +import type { SyntaxTree } from "./element"; + +/** + * Severity level of a diagnostic. + * + * - `"error"` — the markup is structurally broken (e.g. inline `[[div]]` + * without a newline after `]]`). + * - `"warning"` — the markup is likely unintentional but the parser can + * recover (e.g. a missing `[[/div]]` close tag). + * - `"info"` — informational hints (e.g. deprecated syntax). + * + * @since 2.0.0 + * @group Diagnostics + */ +export type DiagnosticSeverity = "error" | "warning" | "info"; + +/** + * A single diagnostic emitted during parsing. + * + * Each diagnostic pinpoints a source location via {@link Position} and + * carries a machine-readable {@link Diagnostic.code | code} string for + * programmatic filtering (e.g. `"unclosed-block"`, `"inline-block-element"`). + * + * @example + * ```ts + * import { parse } from "@wdprlib/parser"; + * + * const { ast, diagnostics } = parse("[[div]]\nHello"); + * for (const d of diagnostics) { + * console.log(`[${d.severity}] ${d.message} (line ${d.position.start.line})`); + * } + * ``` + * + * @since 2.0.0 + * @group Diagnostics + */ +export interface Diagnostic { + /** How severe the issue is. */ + severity: DiagnosticSeverity; + + /** + * Machine-readable identifier for the diagnostic kind. + * + * Current codes: + * - `"unclosed-block"` — a block element has no matching close tag. + * - `"inline-block-element"` — a block element (e.g. `[[div]]`) is used + * inline without the required trailing newline. + */ + code: string; + + /** Human-readable description of the issue. */ + message: string; + + /** Source range where the issue was detected. */ + position: Position; + + /** + * An optional related source range that provides additional context + * (e.g. the opening tag position when reporting a missing close tag). + */ + relatedPosition?: Position; +} + +/** + * The result of parsing a Wikidot markup string. + * + * Contains both the parsed AST and any diagnostics emitted during parsing. + * The AST is always produced, even when diagnostics are present — the parser + * is lenient and recovers from errors. + * + * @example + * ```ts + * import { parse } from "@wdprlib/parser"; + * + * const result = parse("**bold** and //italic//"); + * console.log(result.ast.elements); // AST nodes + * console.log(result.diagnostics); // [] (no issues) + * ``` + * + * @since 2.0.0 + * @group Diagnostics + */ +export interface ParseResult { + /** The parsed syntax tree. */ + ast: SyntaxTree; + + /** Diagnostics emitted during parsing (empty when the input is clean). */ + diagnostics: Diagnostic[]; +} diff --git a/packages/ast/src/index.ts b/packages/ast/src/index.ts index e257077..7c59541 100644 --- a/packages/ast/src/index.ts +++ b/packages/ast/src/index.ts @@ -104,6 +104,9 @@ export { isParagraphSafe, } from "./element"; +// Diagnostics +export type { Diagnostic, DiagnosticSeverity, ParseResult } from "./diagnostic"; + // Constants export { STYLE_SLOT_PREFIX } from "./constants"; diff --git a/packages/parser/src/index.ts b/packages/parser/src/index.ts index d95a2ae..d3ed5f0 100644 --- a/packages/parser/src/index.ts +++ b/packages/parser/src/index.ts @@ -11,7 +11,7 @@ * ```ts * import { parse } from "@wdprlib/parser"; * - * const ast = parse("**bold** and //italic//"); + * const { ast, diagnostics } = parse("**bold** and //italic//"); * ``` * * For server-side module resolution, see {@link extractDataRequirements}, @@ -58,6 +58,10 @@ export type { DateItem, Embed, TocEntry, + // Diagnostics + Diagnostic, + DiagnosticSeverity, + ParseResult, } from "@wdprlib/ast"; export { createPoint, diff --git a/packages/parser/src/parser/parse.ts b/packages/parser/src/parser/parse.ts index 75e9aab..5c85277 100644 --- a/packages/parser/src/parser/parse.ts +++ b/packages/parser/src/parser/parse.ts @@ -1,11 +1,15 @@ import type { Token } from "../lexer"; import { tokenize } from "../lexer"; import { preprocess } from "./preprocess"; -import type { Element, SyntaxTree, WikitextSettings } from "@wdprlib/ast"; +import type { Element, SyntaxTree, WikitextSettings, ParseResult } from "@wdprlib/ast"; import { DEFAULT_SETTINGS } from "@wdprlib/ast"; import { blockRules, blockFallbackRule, inlineRules, type ParseContext } from "./rules"; import { canApplyBlockRule } from "./rules/block/utils"; -import { mergeSpanStripParagraphs, cleanInternalFlags } from "./postprocess"; +import { + mergeSpanStripParagraphs, + cleanInternalFlags, + suppressDivAdjacentParagraphs, +} from "./postprocess"; import { buildTableOfContents } from "./toc"; /** @@ -69,6 +73,8 @@ export class Parser { // State flags footnoteBlockParsed: false, bibcites: [], + // Diagnostics + diagnostics: [], // Rules (injected to avoid circular dependency) blockRules, blockFallbackRule, @@ -77,9 +83,12 @@ export class Parser { } /** - * Parse tokens into SyntaxTree + * Parse tokens into a {@link ParseResult} containing the AST and + * any diagnostics emitted during parsing. + * + * @since 2.0.0 */ - parse(): SyntaxTree { + parse(): ParseResult { const children: Element[] = []; while (!this.isAtEnd()) { @@ -90,8 +99,11 @@ export class Parser { // Post-process: merge paragraphs that contain span_ (paragraph strip mode) const mergedChildren = mergeSpanStripParagraphs(children); + // Wikidot: paragraphs directly adjacent to div blocks lose

wrapping + const divProcessed = suppressDivAdjacentParagraphs(mergedChildren); + // Clean internal flags from AST - const cleanedChildren = cleanInternalFlags(mergedChildren); + const cleanedChildren = cleanInternalFlags(divProcessed); // Add footnote-block at the end if not present const hasFootnoteBlock = cleanedChildren.some((el) => el.element === "footnote-block"); @@ -126,7 +138,7 @@ export class Parser { result["html-blocks"] = this.ctx.htmlBlocks; } - return result; + return { ast: result, diagnostics: this.ctx.diagnostics }; } /** @@ -211,9 +223,18 @@ export class Parser { } /** - * Parse source string into SyntaxTree + * Parse a Wikidot markup string into an AST with diagnostics. + * + * @example + * ```ts + * import { parse } from "@wdprlib/parser"; + * + * const { ast, diagnostics } = parse("**bold** and //italic//"); + * ``` + * + * @since 2.0.0 */ -export function parse(source: string, options?: ParserOptions): SyntaxTree { +export function parse(source: string, options?: ParserOptions): ParseResult { const preprocessed = preprocess(source); const tokens = tokenize(preprocessed, { trackPositions: options?.trackPositions }); return new Parser(tokens, options).parse(); diff --git a/packages/parser/src/parser/postprocess/divAdjacentParagraph.ts b/packages/parser/src/parser/postprocess/divAdjacentParagraph.ts new file mode 100644 index 0000000..f0ec630 --- /dev/null +++ b/packages/parser/src/parser/postprocess/divAdjacentParagraph.ts @@ -0,0 +1,76 @@ +/** + * + * Post-processing pass: suppress paragraph wrapping adjacent to div containers. + * + * In Wikidot, when a paragraph is a direct sibling of a `

` block (no other + * block elements between them), the `

` wrapping is removed and the inner + * elements are promoted to the parent level. + * + * When the unwrapped paragraph follows a div, a line-break element is prepended + * to represent the newline between the closing `

` and the bare text. + * + * Examples: + * `[[div]]inline[[/div]]\n[[div]]\n[[/div]]` → no `

` (adjacent to div) + * `[[div]]inline[[/div]]\n> a\n[[div]]\n[[/div]]` → has `

` (blockquote between) + * + * @module + */ +import type { Element, ContainerData } from "@wdprlib/ast"; + +function isParagraphContainer(el: Element | undefined): boolean { + if (!el || el.element !== "container") return false; + return (el.data as ContainerData).type === "paragraph"; +} + +function isDivContainer(el: Element | undefined): boolean { + if (!el || el.element !== "container") return false; + return (el.data as ContainerData).type === "div"; +} + +/** + * At a single nesting level, unwrap paragraph containers that are directly + * adjacent to div containers. A line-break is prepended when the paragraph + * follows a div. + */ +function suppressAtLevel(elements: Element[]): Element[] { + if (elements.length <= 1) return elements; + + const unwrap = new Array(elements.length).fill(false); + + for (let i = 0; i < elements.length; i++) { + if (!isParagraphContainer(elements[i])) continue; + const prevIsDiv = i > 0 && isDivContainer(elements[i - 1]); + const nextIsDiv = i < elements.length - 1 && isDivContainer(elements[i + 1]); + if (prevIsDiv || nextIsDiv) { + unwrap[i] = true; + } + } + + const result: Element[] = []; + for (let i = 0; i < elements.length; i++) { + const el = elements[i]; + if (!el) continue; + + if (unwrap[i] && el.element === "container") { + const inner = (el.data as ContainerData).elements; + if (i > 0 && isDivContainer(elements[i - 1])) { + result.push({ element: "line-break" }); + } + result.push(...inner); + } else { + result.push(el); + } + } + + return result; +} + +/** + * Suppress paragraph wrapping adjacent to div containers. + * + * Applied only at the top level. Inside div containers, paragraphs adjacent + * to nested divs retain their `

` wrapping (matching Wikidot behavior). + */ +export function suppressDivAdjacentParagraphs(elements: Element[]): Element[] { + return suppressAtLevel(elements); +} diff --git a/packages/parser/src/parser/postprocess/index.ts b/packages/parser/src/parser/postprocess/index.ts index 508971b..d2f23bc 100644 --- a/packages/parser/src/parser/postprocess/index.ts +++ b/packages/parser/src/parser/postprocess/index.ts @@ -12,3 +12,4 @@ */ export { mergeSpanStripParagraphs, cleanInternalFlags } from "./spanStrip"; +export { suppressDivAdjacentParagraphs } from "./divAdjacentParagraph"; diff --git a/packages/parser/src/parser/rules/block/align.ts b/packages/parser/src/parser/rules/block/align.ts index 4c7d22f..8940d85 100644 --- a/packages/parser/src/parser/rules/block/align.ts +++ b/packages/parser/src/parser/rules/block/align.ts @@ -240,8 +240,19 @@ export const alignRule: BlockRule = { consumed += bodyResult.consumed; pos += bodyResult.consumed; - // Consume closing tag + // Check for missing close tag + const directionSymbol = { left: "<", right: ">", center: "=", justify: "==" }[direction]; const closeCheck = isAlignClose({ ...ctx, pos }, direction); + if (!closeCheck.match) { + ctx.diagnostics.push({ + severity: "warning", + code: "unclosed-block", + message: `Missing closing tag [[/${directionSymbol}]] for [[${directionSymbol}]]`, + position: openToken.position, + }); + } + + // Consume closing tag if (closeCheck.match) { consumed += closeCheck.consumed; pos += closeCheck.consumed; diff --git a/packages/parser/src/parser/rules/block/bibliography.ts b/packages/parser/src/parser/rules/block/bibliography.ts index 9a91247..6107c02 100644 --- a/packages/parser/src/parser/rules/block/bibliography.ts +++ b/packages/parser/src/parser/rules/block/bibliography.ts @@ -321,6 +321,12 @@ export const bibliographyRule: BlockRule = { // Require closing tag - without it, fail to prevent consuming entire document if (!foundClose) { + ctx.diagnostics.push({ + severity: "warning", + code: "unclosed-block", + message: "Missing closing tag [[/bibliography]] for [[bibliography]]", + position: openToken.position, + }); return { success: false }; } diff --git a/packages/parser/src/parser/rules/block/block-list.ts b/packages/parser/src/parser/rules/block/block-list.ts index 2d874de..8dd0cb2 100644 --- a/packages/parser/src/parser/rules/block/block-list.ts +++ b/packages/parser/src/parser/rules/block/block-list.ts @@ -300,6 +300,19 @@ function parseLiItem( } } + // Diagnostic for missing [[/li]] + if (!isLiClose(ctx, pos)) { + ctx.diagnostics.push({ + severity: "warning", + code: "unclosed-block", + message: "Missing closing tag [[/li]] for [[li]]", + position: ctx.tokens[startPos]?.position ?? { + start: { line: 0, column: 0, offset: 0 }, + end: { line: 0, column: 0, offset: 0 }, + }, + }); + } + // Consume [[/li]] if present if (isLiClose(ctx, pos)) { const closeConsumed = consumeCloseTag(ctx, pos); @@ -443,6 +456,7 @@ function parseListBlock( // Parse list items const items: ListItem[] = []; + let foundListClose = false; while (pos < ctx.tokens.length) { const token = ctx.tokens[pos]; @@ -450,6 +464,7 @@ function parseListBlock( // Check for [[/ul]] or [[/ol]] close if (isListClose(ctx, pos, listType)) { + foundListClose = true; const closeConsumed = consumeCloseTag(ctx, pos); consumed += closeConsumed; break; @@ -606,6 +621,18 @@ function parseListBlock( } } + if (!foundListClose) { + ctx.diagnostics.push({ + severity: "warning", + code: "unclosed-block", + message: `Missing closing tag [[/${listType}]] for [[${listType}]]`, + position: ctx.tokens[startPos]?.position ?? { + start: { line: 0, column: 0, offset: 0 }, + end: { line: 0, column: 0, offset: 0 }, + }, + }); + } + const listData: ListData = { type: listType === "ol" ? "numbered" : "bullet", attributes: attrResult.attrs, diff --git a/packages/parser/src/parser/rules/block/code.ts b/packages/parser/src/parser/rules/block/code.ts index 696690a..dcc862a 100644 --- a/packages/parser/src/parser/rules/block/code.ts +++ b/packages/parser/src/parser/rules/block/code.ts @@ -110,6 +110,7 @@ export const codeBlockRule: BlockRule = { // Collect raw content until [[/code]] let codeContent = ""; + let foundClose = closingSwallowed; while (!closingSwallowed && pos < ctx.tokens.length) { const token = ctx.tokens[pos]; @@ -121,6 +122,7 @@ export const codeBlockRule: BlockRule = { if (token.type === "BLOCK_END_OPEN") { const closeNameResult = parseBlockName(ctx, pos + 1); if (closeNameResult && closeNameResult.name === "code") { + foundClose = true; // Skip [[/code]] pos++; // [[/ consumed++; @@ -146,6 +148,16 @@ export const codeBlockRule: BlockRule = { consumed++; } + // Diagnostic for missing close tag + if (!foundClose) { + ctx.diagnostics.push({ + severity: "warning", + code: "unclosed-block", + message: "Missing closing tag [[/code]] for [[code]]", + position: openToken.position, + }); + } + // Trim trailing newline from content codeContent = codeContent.replace(/\n$/, ""); diff --git a/packages/parser/src/parser/rules/block/collapsible.ts b/packages/parser/src/parser/rules/block/collapsible.ts index 2a8037c..ba61360 100644 --- a/packages/parser/src/parser/rules/block/collapsible.ts +++ b/packages/parser/src/parser/rules/block/collapsible.ts @@ -255,6 +255,9 @@ export const collapsibleRule: BlockRule = { pos++; consumed++; + // Record opening tag position for diagnostics + const openPosition = openToken.position; + const hasNewlineAfterOpen = ctx.tokens[pos]?.type === "NEWLINE"; if (hasNewlineAfterOpen) { pos++; @@ -322,6 +325,16 @@ export const collapsibleRule: BlockRule = { bodyElements = mergeParagraphs(bodyResult.elements); } + // Check for missing close tag + if (!isCollapsibleClose(ctx, pos)) { + ctx.diagnostics.push({ + severity: "warning", + code: "unclosed-block", + message: "Missing closing tag [[/collapsible]] for [[collapsible]]", + position: openPosition, + }); + } + // Consume [[/collapsible]] if (isCollapsibleClose(ctx, pos)) { const closeConsumed = consumeCloseTag(ctx, pos); diff --git a/packages/parser/src/parser/rules/block/comment.ts b/packages/parser/src/parser/rules/block/comment.ts index d3668bd..8284b30 100644 --- a/packages/parser/src/parser/rules/block/comment.ts +++ b/packages/parser/src/parser/rules/block/comment.ts @@ -57,7 +57,8 @@ export const blockCommentRule: BlockRule = { } if (token.type === "EOF") { - // Unterminated comment - fail + // Unterminated comment — let the inline comment rule emit the diagnostic + // to avoid duplication when the paragraph fallback retries this token. return { success: false }; } @@ -65,6 +66,8 @@ export const blockCommentRule: BlockRule = { consumed++; } + // Unterminated comment — let the inline comment rule emit the diagnostic + // to avoid duplication when the paragraph fallback retries this token. return { success: false }; }, }; diff --git a/packages/parser/src/parser/rules/block/div.ts b/packages/parser/src/parser/rules/block/div.ts index 400eb8a..bf52daa 100644 --- a/packages/parser/src/parser/rules/block/div.ts +++ b/packages/parser/src/parser/rules/block/div.ts @@ -84,9 +84,31 @@ export const divRule: BlockRule = { if (ctx.tokens[pos]?.type !== "NEWLINE") { return consumeFailedDiv(ctx); } + + // Wikidot matches [[div]]/[[/div]] pairs from outside-in. When there are + // more opens than closes, the innermost excess opens become text. We enforce + // this with a "closes budget": the number of additional nested divs that can + // open. When budget reaches 0, this div cannot open. + if (ctx.divClosesBudget === 0) { + return { success: false }; + } + pos++; consumed++; + // Record opening tag position for diagnostics + const openPosition = openToken.position; + + // Calculate closes budget for nested divs in the body. + // Count [[/div]] from body start to scope boundary, subtract 1 (for self). + let bodyBudget: number | undefined; + if (ctx.divClosesBudget !== undefined) { + bodyBudget = ctx.divClosesBudget - 1; + } else { + const closesInScope = countDivCloses(ctx, pos); + bodyBudget = closesInScope > 0 ? closesInScope - 1 : 0; + } + // Close condition for [[/div]] const closeCondition = (checkCtx: ParseContext): boolean => { const token = checkCtx.tokens[checkCtx.pos]; @@ -99,7 +121,7 @@ export const divRule: BlockRule = { return false; }; - const bodyCtx: ParseContext = { ...ctx, pos }; + const bodyCtx: ParseContext = { ...ctx, pos, divClosesBudget: bodyBudget }; let children: Element[]; if (paragraphStrip) { @@ -117,6 +139,16 @@ export const divRule: BlockRule = { children = bodyResult.elements; } + // Check for missing close tag + if (ctx.tokens[pos]?.type !== "BLOCK_END_OPEN") { + ctx.diagnostics.push({ + severity: "warning", + code: "unclosed-block", + message: `Missing closing tag [[/div]] for [[${blockName}]]`, + position: openPosition, + }); + } + // Consume [[/div]] if (ctx.tokens[pos]?.type === "BLOCK_END_OPEN") { pos++; @@ -153,6 +185,25 @@ export const divRule: BlockRule = { }, }; +/** + * Counts `[[/div]]` close tags from a given position to the end of the + * token stream. Used to calculate the nesting budget for div blocks. + */ +function countDivCloses(ctx: ParseContext, startPos: number): number { + let count = 0; + for (let i = startPos; i < ctx.tokens.length; i++) { + const t = ctx.tokens[i]; + if (!t || t.type === "EOF") break; + if (t.type === "BLOCK_END_OPEN") { + const nameResult = parseBlockName(ctx, i + 1); + if (nameResult?.name === "div") { + count++; + } + } + } + return count; +} + /** * Handles the case where `[[div]]` fails as a block element because * the closing `]]` is not followed by a NEWLINE. @@ -175,11 +226,32 @@ function consumeFailedDiv(ctx: ParseContext): RuleResult { let lastClosePos = -1; let lastCloseConsumed = 0; - // Find the last [[/div]] in the contiguous block + // Find the last [[/div]] before the next valid div block. + // A valid div block is [[div]]/[[div_]] at line start followed by ]] + NEWLINE. + // When a valid div block is found, stop scanning — it should be parsed as a + // separate block element, not absorbed into this failed div's text. let scanPos = pos; while (scanPos < ctx.tokens.length) { const t = ctx.tokens[scanPos]; if (!t || t.type === "EOF") break; + + // Check for a valid div block opening (skip the initial failed div at pos) + if (t.type === "BLOCK_OPEN" && t.lineStart && scanPos > pos) { + const nameResult = parseBlockName(ctx, scanPos + 1); + if (nameResult?.name === "div" || nameResult?.name === "div_") { + let checkPos = scanPos + 1 + nameResult.consumed; + const attrResult = parseAttributes(ctx, checkPos); + checkPos += attrResult.consumed; + if (ctx.tokens[checkPos]?.type === "BLOCK_CLOSE") { + checkPos++; + if (ctx.tokens[checkPos]?.type === "NEWLINE" || ctx.tokens[checkPos]?.type === "EOF") { + // Valid div block found — stop scanning here + break; + } + } + } + } + if (t.type === "BLOCK_END_OPEN") { const nameResult = parseBlockName(ctx, scanPos + 1); if (nameResult?.name === "div") { @@ -200,6 +272,27 @@ function consumeFailedDiv(ctx: ParseContext): RuleResult { return { success: false }; } + // Emit diagnostics for all inline [[div]] patterns in the absorbed range. + // The initial [[div]] at ctx.pos is always included; any additional [[div]] + // patterns within the range also get diagnostics. + const endPosForDiag = lastClosePos; + for (let diagPos = ctx.pos; diagPos < endPosForDiag; diagPos++) { + const t = ctx.tokens[diagPos]; + if (t?.type === "BLOCK_OPEN") { + const nameResult = parseBlockName(ctx, diagPos + 1); + if (nameResult?.name === "div" || nameResult?.name === "div_") { + if (t.position) { + ctx.diagnostics.push({ + severity: "error", + code: "inline-block-element", + message: `[[${nameResult.name}]] must be followed by a newline to be a block element`, + position: t.position, + }); + } + } + } + } + // Consume everything from current position to after the last [[/div]] const endPos = lastClosePos + lastCloseConsumed; while (pos < endPos && pos < ctx.tokens.length) { diff --git a/packages/parser/src/parser/rules/block/embed-block.ts b/packages/parser/src/parser/rules/block/embed-block.ts index 4336d53..5697a14 100644 --- a/packages/parser/src/parser/rules/block/embed-block.ts +++ b/packages/parser/src/parser/rules/block/embed-block.ts @@ -96,6 +96,12 @@ export const embedBlockRule: BlockRule = { // Require closing tag - without it, fail to prevent consuming entire document if (!foundClose) { + ctx.diagnostics.push({ + severity: "warning", + code: "unclosed-block", + message: `Missing closing tag [[/${blockName}]] for [[${blockName}]]`, + position: openToken.position, + }); return { success: false }; } diff --git a/packages/parser/src/parser/rules/block/html.ts b/packages/parser/src/parser/rules/block/html.ts index 21b400d..1fffdb9 100644 --- a/packages/parser/src/parser/rules/block/html.ts +++ b/packages/parser/src/parser/rules/block/html.ts @@ -89,6 +89,12 @@ export const htmlBlockRule: BlockRule = { // If no closing tag found, fail (Wikidot treats unclosed [[html]] as text) if (!foundClose) { + ctx.diagnostics.push({ + severity: "warning", + code: "unclosed-block", + message: "Missing closing tag [[/html]] for [[html]]", + position: openToken.position, + }); return { success: false }; } diff --git a/packages/parser/src/parser/rules/block/iftags.ts b/packages/parser/src/parser/rules/block/iftags.ts index fcb173b..4080f76 100644 --- a/packages/parser/src/parser/rules/block/iftags.ts +++ b/packages/parser/src/parser/rules/block/iftags.ts @@ -102,6 +102,16 @@ export const iftagsRule: BlockRule = { consumed += bodyResult.consumed; pos += bodyResult.consumed; + // Check for missing close tag + if (ctx.tokens[pos]?.type !== "BLOCK_END_OPEN") { + ctx.diagnostics.push({ + severity: "warning", + code: "unclosed-block", + message: "Missing closing tag [[/iftags]] for [[iftags]]", + position: openToken.position, + }); + } + // Consume [[/iftags]] if (ctx.tokens[pos]?.type === "BLOCK_END_OPEN") { pos++; diff --git a/packages/parser/src/parser/rules/block/math.ts b/packages/parser/src/parser/rules/block/math.ts index d98fe15..697118c 100644 --- a/packages/parser/src/parser/rules/block/math.ts +++ b/packages/parser/src/parser/rules/block/math.ts @@ -129,6 +129,16 @@ export const mathBlockRule: BlockRule = { consumed++; } + // Diagnostic for missing close tag + if (ctx.tokens[pos]?.type !== "BLOCK_END_OPEN") { + ctx.diagnostics.push({ + severity: "warning", + code: "unclosed-block", + message: "Missing closing tag [[/math]] for [[math]]", + position: openToken.position, + }); + } + // Consume [[/math]] if (ctx.tokens[pos]?.type === "BLOCK_END_OPEN") { pos++; diff --git a/packages/parser/src/parser/rules/block/module.ts b/packages/parser/src/parser/rules/block/module.ts index e4bbb75..bbaf504 100644 --- a/packages/parser/src/parser/rules/block/module.ts +++ b/packages/parser/src/parser/rules/block/module.ts @@ -72,6 +72,7 @@ export const moduleRule: BlockRule = { consumed++; let bodyContent = ""; + let foundClose = false; while (pos < ctx.tokens.length) { const token = ctx.tokens[pos]; if (!token || token.type === "EOF") { @@ -84,6 +85,7 @@ export const moduleRule: BlockRule = { closeNameResult && (closeNameResult.name === "module" || closeNameResult.name === "module654") ) { + foundClose = true; pos++; consumed++; pos += closeNameResult.consumed; @@ -105,6 +107,15 @@ export const moduleRule: BlockRule = { consumed++; } + if (!foundClose) { + ctx.diagnostics.push({ + severity: "warning", + code: "unclosed-block", + message: "Missing closing tag [[/module]] for [[module]]", + position: openToken.position, + }); + } + if (bodyContent.trim()) { body = bodyContent.trim(); } diff --git a/packages/parser/src/parser/rules/block/orphan-li.ts b/packages/parser/src/parser/rules/block/orphan-li.ts index d6bdde8..6a26558 100644 --- a/packages/parser/src/parser/rules/block/orphan-li.ts +++ b/packages/parser/src/parser/rules/block/orphan-li.ts @@ -159,6 +159,12 @@ export const orphanLiRule: BlockRule = { // Require closing tag - without it, fail to prevent consuming entire document if (!foundClose) { + ctx.diagnostics.push({ + severity: "warning", + code: "unclosed-block", + message: "Missing closing tag [[/li]] for [[li]]", + position: openToken.position, + }); return { success: false }; } diff --git a/packages/parser/src/parser/rules/block/table-block.ts b/packages/parser/src/parser/rules/block/table-block.ts index 1322d40..a30e58a 100644 --- a/packages/parser/src/parser/rules/block/table-block.ts +++ b/packages/parser/src/parser/rules/block/table-block.ts @@ -83,6 +83,7 @@ export const tableBlockRule: BlockRule = { // Parse rows const rows: TableRow[] = []; + let foundTableClose = false; while (pos < ctx.tokens.length) { // Skip whitespace and newlines @@ -100,6 +101,7 @@ export const tableBlockRule: BlockRule = { if (token.type === "BLOCK_END_OPEN") { const closeNameResult = parseBlockName(ctx, pos + 1); if (closeNameResult?.name === "table") { + foundTableClose = true; // Consume [[/table]] pos++; // [[/ consumed++; @@ -136,6 +138,15 @@ export const tableBlockRule: BlockRule = { consumed++; } + if (!foundTableClose) { + ctx.diagnostics.push({ + severity: "warning", + code: "unclosed-block", + message: "Missing closing tag [[/table]] for [[table]]", + position: openToken.position, + }); + } + // Wikidot behavior: empty tables or tables with only empty rows are not parsed // They should be treated as plain text instead const hasValidContent = rows.some((row) => row.cells.length > 0); @@ -209,6 +220,7 @@ function parseRow(ctx: ParseContext, startPos: number): { row: TableRow; consume // Parse cells const cells: TableCell[] = []; + let foundRowClose = false; while (pos < ctx.tokens.length) { // Skip whitespace and newlines @@ -226,6 +238,7 @@ function parseRow(ctx: ParseContext, startPos: number): { row: TableRow; consume if (token.type === "BLOCK_END_OPEN") { const closeNameResult = parseBlockName(ctx, pos + 1); if (closeNameResult?.name === "row") { + foundRowClose = true; // Consume [[/row]] pos++; consumed++; @@ -262,6 +275,18 @@ function parseRow(ctx: ParseContext, startPos: number): { row: TableRow; consume consumed++; } + if (!foundRowClose) { + ctx.diagnostics.push({ + severity: "warning", + code: "unclosed-block", + message: "Missing closing tag [[/row]] for [[row]]", + position: ctx.tokens[startPos]?.position ?? { + start: { line: 0, column: 0, offset: 0 }, + end: { line: 0, column: 0, offset: 0 }, + }, + }); + } + return { row: { attributes: attrResult.attrs, @@ -365,6 +390,19 @@ function parseCell( pos += bodyResult.consumed; const hadParagraphBreaks = bodyResult.hadParagraphBreaks; + // Check for missing close tag + if (ctx.tokens[pos]?.type !== "BLOCK_END_OPEN") { + ctx.diagnostics.push({ + severity: "warning", + code: "unclosed-block", + message: `Missing closing tag [[/${closeName}]] for [[${closeName}]]`, + position: ctx.tokens[startPos]?.position ?? { + start: { line: 0, column: 0, offset: 0 }, + end: { line: 0, column: 0, offset: 0 }, + }, + }); + } + // Consume [[/cell]] or [[/hcell]] if (ctx.tokens[pos]?.type === "BLOCK_END_OPEN") { pos++; diff --git a/packages/parser/src/parser/rules/block/tabview.ts b/packages/parser/src/parser/rules/block/tabview.ts index 5751e6b..f3006bb 100644 --- a/packages/parser/src/parser/rules/block/tabview.ts +++ b/packages/parser/src/parser/rules/block/tabview.ts @@ -124,6 +124,19 @@ function parseTab(ctx: ParseContext): { tab: TabData; consumed: number } | null consumed += bodyResult.consumed; pos += bodyResult.consumed; + // Check for missing close tag + if (ctx.tokens[pos]?.type !== "BLOCK_END_OPEN") { + ctx.diagnostics.push({ + severity: "warning", + code: "unclosed-block", + message: "Missing closing tag [[/tab]] for [[tab]]", + position: ctx.tokens[ctx.pos]?.position ?? { + start: { line: 0, column: 0, offset: 0 }, + end: { line: 0, column: 0, offset: 0 }, + }, + }); + } + // Consume [[/tab]] if (ctx.tokens[pos]?.type === "BLOCK_END_OPEN") { pos++; @@ -226,6 +239,11 @@ export const tabviewRule: BlockRule = { const tabCtx: ParseContext = { ...ctx, pos }; while (pos < ctx.tokens.length) { + // Check for EOF + if (ctx.tokens[pos]?.type === "EOF") { + break; + } + // Check for closing [[/tabview]] or [[/tabs]] if (ctx.tokens[pos]?.type === "BLOCK_END_OPEN") { const closeNameResult = parseBlockName(ctx, pos + 1); @@ -253,6 +271,23 @@ export const tabviewRule: BlockRule = { } } + // Check for missing close tag + const hasTabviewClose = + ctx.tokens[pos]?.type === "BLOCK_END_OPEN" && + (() => { + const n = parseBlockName(ctx, pos + 1); + const name = n?.name.toLowerCase(); + return name === "tabview" || name === "tabs"; + })(); + if (!hasTabviewClose) { + ctx.diagnostics.push({ + severity: "warning", + code: "unclosed-block", + message: `Missing closing tag [[/${blockName}]] for [[${blockName}]]`, + position: openToken.position, + }); + } + // Consume [[/tabview]] or [[/tabs]] if (ctx.tokens[pos]?.type === "BLOCK_END_OPEN") { pos++; diff --git a/packages/parser/src/parser/rules/inline/anchor.ts b/packages/parser/src/parser/rules/inline/anchor.ts index 402c91e..74e3506 100644 --- a/packages/parser/src/parser/rules/inline/anchor.ts +++ b/packages/parser/src/parser/rules/inline/anchor.ts @@ -274,6 +274,12 @@ export const anchorRule: InlineRule = { } if (!foundClose) { + ctx.diagnostics.push({ + severity: "warning", + code: "unclosed-block", + message: `Missing closing tag [[/a]] for [[${nameResult.name}]]`, + position: openToken.position, + }); return { success: false }; } diff --git a/packages/parser/src/parser/rules/inline/comment.ts b/packages/parser/src/parser/rules/inline/comment.ts index 57de05c..406eee8 100644 --- a/packages/parser/src/parser/rules/inline/comment.ts +++ b/packages/parser/src/parser/rules/inline/comment.ts @@ -18,6 +18,7 @@ */ import type { Element } from "@wdprlib/ast"; import type { InlineRule, ParseContext, RuleResult } from "../types"; +import { currentToken } from "../types"; /** * Inline rule for parsing `[!-- comment --]` syntax. @@ -41,6 +42,7 @@ export const commentRule: InlineRule = { * or `{ success: false }` if the comment is unterminated */ parse(ctx: ParseContext): RuleResult { + const openToken = currentToken(ctx); let pos = ctx.pos + 1; // skip [!-- let consumed = 1; @@ -64,6 +66,12 @@ export const commentRule: InlineRule = { if (token.type === "EOF") { // Unterminated comment - fail + ctx.diagnostics.push({ + severity: "warning", + code: "unclosed-comment", + message: "Unterminated comment: missing closing --]", + position: openToken.position, + }); return { success: false }; } @@ -71,6 +79,12 @@ export const commentRule: InlineRule = { consumed++; } + ctx.diagnostics.push({ + severity: "warning", + code: "unclosed-comment", + message: "Unterminated comment: missing closing --]", + position: openToken.position, + }); return { success: false }; }, }; diff --git a/packages/parser/src/parser/rules/inline/footnote.ts b/packages/parser/src/parser/rules/inline/footnote.ts index 43c507b..6e8c080 100644 --- a/packages/parser/src/parser/rules/inline/footnote.ts +++ b/packages/parser/src/parser/rules/inline/footnote.ts @@ -88,6 +88,7 @@ export const footnoteRule: InlineRule = { // - After blank line: content wrapped in

tag const paragraphs: Element[][] = [[]]; let currentParagraph = 0; + let foundClose = false; while (pos < ctx.tokens.length) { const token = ctx.tokens[pos]; @@ -99,6 +100,7 @@ export const footnoteRule: InlineRule = { if (token.type === "BLOCK_END_OPEN") { const closeNameResult = parseBlockName(ctx, pos + 1); if (closeNameResult && closeNameResult.name === "footnote") { + foundClose = true; // Skip [[/footnote]] pos++; // [[/ consumed++; @@ -195,6 +197,15 @@ export const footnoteRule: InlineRule = { } } + if (!foundClose) { + ctx.diagnostics.push({ + severity: "warning", + code: "unclosed-block", + message: "Missing closing tag [[/footnote]] for [[footnote]]", + position: openToken.position, + }); + } + // Store footnote content in context ctx.footnotes.push(children); diff --git a/packages/parser/src/parser/rules/inline/size.ts b/packages/parser/src/parser/rules/inline/size.ts index 1a5461e..de32f38 100644 --- a/packages/parser/src/parser/rules/inline/size.ts +++ b/packages/parser/src/parser/rules/inline/size.ts @@ -175,6 +175,7 @@ export const sizeRule: InlineRule = { // Parse inline content until [[/size]] const children: Element[] = []; + let foundClose = false; while (pos < ctx.tokens.length) { const token = ctx.tokens[pos]; @@ -186,6 +187,7 @@ export const sizeRule: InlineRule = { if (token.type === "BLOCK_END_OPEN") { const closeNameResult = parseBlockName(ctx, pos + 1); if (closeNameResult && closeNameResult.name === "size") { + foundClose = true; // Skip [[/size]] pos++; // [[/ consumed++; @@ -215,6 +217,15 @@ export const sizeRule: InlineRule = { } } + if (!foundClose) { + ctx.diagnostics.push({ + severity: "warning", + code: "unclosed-block", + message: "Missing closing tag [[/size]] for [[size]]", + position: openToken.position, + }); + } + return { success: true, elements: [ diff --git a/packages/parser/src/parser/rules/inline/span.ts b/packages/parser/src/parser/rules/inline/span.ts index 715f4a0..d547ae9 100644 --- a/packages/parser/src/parser/rules/inline/span.ts +++ b/packages/parser/src/parser/rules/inline/span.ts @@ -229,6 +229,12 @@ export const spanRule: InlineRule = { // If we didn't find [[/span]], this is not a valid span if (!foundClose) { + ctx.diagnostics.push({ + severity: "warning", + code: "unclosed-block", + message: `Missing closing tag [[/span]] for [[${blockName}]]`, + position: openToken.position, + }); return { success: false }; } diff --git a/packages/parser/src/parser/rules/types.ts b/packages/parser/src/parser/rules/types.ts index 39a9fd0..e7f1d0d 100644 --- a/packages/parser/src/parser/rules/types.ts +++ b/packages/parser/src/parser/rules/types.ts @@ -1,5 +1,5 @@ import type { Token, TokenType } from "../../lexer"; -import type { Version, WikitextSettings } from "@wdprlib/ast"; +import type { Version, WikitextSettings, Diagnostic } from "@wdprlib/ast"; import type { Element, CodeBlockData, TocEntry } from "@wdprlib/ast"; /** @@ -26,6 +26,12 @@ export interface ParseContext { inlineRules: InlineRule[]; // Close condition for current block (passed to paragraph parser) blockCloseCondition?: (ctx: ParseContext) => boolean; + // Diagnostics collected during parsing + diagnostics: Diagnostic[]; + // Budget for div nesting: tracks how many more nested divs can open. + // When 0, div rule fails (innermost excess opens become text). + // undefined means "not yet calculated" (top-level or non-div context). + divClosesBudget?: number; } /** diff --git a/tests/fixtures/div/fail/expected-diagnostics.json b/tests/fixtures/div/fail/expected-diagnostics.json new file mode 100644 index 0000000..866496d --- /dev/null +++ b/tests/fixtures/div/fail/expected-diagnostics.json @@ -0,0 +1,36 @@ +[ + { + "severity": "error", + "code": "inline-block-element", + "message": "[[div]] must be followed by a newline to be a block element", + "position": { + "start": { + "line": 1, + "column": 1, + "offset": 0 + }, + "end": { + "line": 1, + "column": 3, + "offset": 2 + } + } + }, + { + "severity": "error", + "code": "inline-block-element", + "message": "[[div]] must be followed by a newline to be a block element", + "position": { + "start": { + "line": 3, + "column": 1, + "offset": 23 + }, + "end": { + "line": 3, + "column": 3, + "offset": 25 + } + } + } +] diff --git a/tests/fixtures/div/fail/expected.json b/tests/fixtures/div/fail/expected.json index b8a81f9..578396a 100644 --- a/tests/fixtures/div/fail/expected.json +++ b/tests/fixtures/div/fail/expected.json @@ -1,129 +1,276 @@ { "elements": [ + { + "element": "text", + "data": "[[" + }, + { + "element": "text", + "data": "div" + }, + { + "element": "text", + "data": "]]" + }, + { + "element": "text", + "data": "inline" + }, + { + "element": "text", + "data": "[[/" + }, + { + "element": "text", + "data": "div" + }, + { + "element": "text", + "data": "]]" + }, + { + "element": "text", + "data": "[[" + }, + { + "element": "text", + "data": "div" + }, + { + "element": "text", + "data": "]]" + }, + { + "element": "text", + "data": "inline" + }, + { + "element": "text", + "data": "," + }, + { + "element": "text", + "data": " " + }, + { + "element": "text", + "data": "closing" + }, + { + "element": "text", + "data": " " + }, + { + "element": "text", + "data": "tag" + }, + { + "element": "text", + "data": " " + }, + { + "element": "text", + "data": "is" + }, + { + "element": "text", + "data": " " + }, + { + "element": "text", + "data": "on" + }, + { + "element": "text", + "data": " " + }, + { + "element": "text", + "data": "a" + }, + { + "element": "text", + "data": " " + }, + { + "element": "text", + "data": "new" + }, + { + "element": "text", + "data": " " + }, + { + "element": "text", + "data": "line" + }, + { + "element": "line-break" + }, + { + "element": "text", + "data": "[[/" + }, + { + "element": "text", + "data": "div" + }, + { + "element": "text", + "data": "]]" + }, { "element": "container", "data": { - "type": "paragraph", + "type": "div", "attributes": {}, "elements": [ { - "element": "text", - "data": "[[" - }, - { - "element": "text", - "data": "div" - }, - { - "element": "text", - "data": "]]" - }, - { - "element": "text", - "data": "inline" - }, - { - "element": "text", - "data": "[[/" - }, - { - "element": "text", - "data": "div" - }, - { - "element": "text", - "data": "]]" - }, - { - "element": "text", - "data": "[[" - }, - { - "element": "text", - "data": "div" - }, - { - "element": "text", - "data": "]]" - }, - { - "element": "text", - "data": "inline" - }, - { - "element": "text", - "data": "," - }, - { - "element": "text", - "data": " " - }, - { - "element": "text", - "data": "closing" - }, - { - "element": "text", - "data": " " - }, - { - "element": "text", - "data": "tag" - }, - { - "element": "text", - "data": " " - }, - { - "element": "text", - "data": "is" - }, - { - "element": "text", - "data": " " - }, - { - "element": "text", - "data": "on" - }, - { - "element": "text", - "data": " " - }, - { - "element": "text", - "data": "a" - }, - { - "element": "text", - "data": " " - }, - { - "element": "text", - "data": "new" - }, - { - "element": "text", - "data": " " - }, - { - "element": "text", - "data": "line" - }, - { - "element": "line-break" - }, - { - "element": "text", - "data": "[[/" - }, - { - "element": "text", - "data": "div" - }, + "element": "container", + "data": { + "type": "div", + "attributes": {}, + "elements": [ + { + "element": "container", + "data": { + "type": "paragraph", + "attributes": {}, + "elements": [ + { + "element": "text", + "data": "div" + }, + { + "element": "text", + "data": " " + }, + { + "element": "text", + "data": "opening" + }, + { + "element": "text", + "data": " " + }, + { + "element": "text", + "data": "tag" + }, + { + "element": "text", + "data": " " + }, + { + "element": "text", + "data": "is" + }, + { + "element": "text", + "data": " " + }, + { + "element": "text", + "data": "missing" + } + ] + } + } + ] + } + } + ] + } + }, + { + "element": "line-break" + }, + { + "element": "text", + "data": "[[/" + }, + { + "element": "text", + "data": "div" + }, + { + "element": "text", + "data": "]]" + }, + { + "element": "container", + "data": { + "type": "div", + "attributes": {}, + "elements": [ { - "element": "text", - "data": "]]" + "element": "container", + "data": { + "type": "div", + "attributes": {}, + "elements": [ + { + "element": "container", + "data": { + "type": "paragraph", + "attributes": {}, + "elements": [ + { + "element": "text", + "data": "[[" + }, + { + "element": "text", + "data": "div" + }, + { + "element": "text", + "data": "]]" + }, + { + "element": "line-break" + }, + { + "element": "text", + "data": "div" + }, + { + "element": "text", + "data": " " + }, + { + "element": "text", + "data": "closing" + }, + { + "element": "text", + "data": " " + }, + { + "element": "text", + "data": "tag" + }, + { + "element": "text", + "data": " " + }, + { + "element": "text", + "data": "is" + }, + { + "element": "text", + "data": " " + }, + { + "element": "text", + "data": "missing" + } + ] + } + } + ] + } } ] } diff --git a/tests/fixtures/div/fail/input.ftml b/tests/fixtures/div/fail/input.ftml index 480c7fa..69ccf25 100644 --- a/tests/fixtures/div/fail/input.ftml +++ b/tests/fixtures/div/fail/input.ftml @@ -2,4 +2,22 @@ [[div]]inline, closing tag is on a new line -[[/div]] \ No newline at end of file +[[/div]] + + + +[[div]] +[[div]] +div opening tag is missing +[[/div]] +[[/div]] +[[/div]] + + + +[[div]] +[[div]] +[[div]] +div closing tag is missing +[[/div]] +[[/div]] diff --git a/tests/fixtures/div/fail/output.html b/tests/fixtures/div/fail/output.html index 6677e4b..56d0ece 100644 --- a/tests/fixtures/div/fail/output.html +++ b/tests/fixtures/div/fail/output.html @@ -1,2 +1,15 @@ -

[[div]]inline[[/div]][[div]]inline, closing tag is on a new line
-[[/div]]

+[[div]]inline[[/div]][[div]]inline, closing tag is on a new line
+[[/div]] +
+
+

div opening tag is missing

+
+
+
+[[/div]] +
+
+

[[div]]
+div closing tag is missing

+
+
\ No newline at end of file diff --git a/tests/fixtures/html/fail/expected-diagnostics.json b/tests/fixtures/html/fail/expected-diagnostics.json new file mode 100644 index 0000000..d6859e5 --- /dev/null +++ b/tests/fixtures/html/fail/expected-diagnostics.json @@ -0,0 +1,11 @@ +[ + { + "severity": "warning", + "code": "unclosed-block", + "message": "Missing closing tag [[/html]] for [[html]]", + "position": { + "start": { "line": 5, "column": 1, "offset": 55 }, + "end": { "line": 5, "column": 3, "offset": 57 } + } + } +] diff --git a/tests/integration/fixture-parser.test.ts b/tests/integration/fixture-parser.test.ts index 471913a..e79b967 100644 --- a/tests/integration/fixture-parser.test.ts +++ b/tests/integration/fixture-parser.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; import { parse } from "@wdprlib/parser"; -import type { SyntaxTree } from "@wdprlib/ast"; +import type { SyntaxTree, Diagnostic } from "@wdprlib/ast"; import * as fs from "fs"; import * as path from "path"; @@ -32,6 +32,7 @@ interface FixtureCase { name: string; inputPath: string; expectedPath: string | null; + diagnosticsPath: string | null; } function findFixtureCases(dir: string, category = ""): FixtureCase[] { @@ -51,11 +52,13 @@ function findFixtureCases(dir: string, category = ""): FixtureCase[] { cases.push(...findFixtureCases(fullPath, newCategory)); } else if (entry.name === "input.ftml") { const expectedPath = path.join(dir, "expected.json"); + const diagnosticsPath = path.join(dir, "expected-diagnostics.json"); cases.push({ category, name: path.basename(dir), inputPath: fullPath, expectedPath: fs.existsSync(expectedPath) ? expectedPath : null, + diagnosticsPath: fs.existsSync(diagnosticsPath) ? diagnosticsPath : null, }); } } @@ -117,7 +120,7 @@ describe("Parser Fixture Tests", () => { for (const testCase of casesWithExpected) { it(`[${testCase.category}] AST should match expected`, () => { const input = loadInput(testCase.inputPath); - const result = parse(input); + const result = parse(input).ast; const expected = loadExpected(testCase.expectedPath!); expect(result).toEqual(expected); @@ -125,6 +128,20 @@ describe("Parser Fixture Tests", () => { } }); + describe("Diagnostics verification", () => { + const casesWithDiagnostics = includedCases.filter((c) => c.diagnosticsPath !== null); + for (const testCase of casesWithDiagnostics) { + it(`[${testCase.category}] diagnostics should match expected`, () => { + const input = loadInput(testCase.inputPath); + const result = parse(input); + const expected: Diagnostic[] = JSON.parse( + fs.readFileSync(testCase.diagnosticsPath!, "utf-8"), + ); + expect(result.diagnostics).toEqual(expected); + }); + } + }); + describe("Coverage check", () => { it("all fixtures should have expected.json (unless explicitly excluded)", () => { if (casesRequiringExpected.length > 0) { diff --git a/tests/unit/module/include/resolve.test.ts b/tests/unit/module/include/resolve.test.ts index a099acf..83af69d 100644 --- a/tests/unit/module/include/resolve.test.ts +++ b/tests/unit/module/include/resolve.test.ts @@ -1,7 +1,12 @@ import { test, expect, describe } from "bun:test"; -import { parse, resolveIncludes } from "@wdprlib/parser"; +import { parse, resolveIncludes, type ParserOptions } from "@wdprlib/parser"; +import type { SyntaxTree } from "@wdprlib/ast"; import { getAllText } from "../../../helpers"; +function parseAst(input: string, options?: ParserOptions): SyntaxTree { + return parse(input, options).ast; +} + describe("resolveIncludes", () => { test("resolves a simple include", () => { const source = "[[include my-page]]"; @@ -219,7 +224,7 @@ describe("resolveIncludes", () => { expect(expanded).toContain("[[/div]]"); // パースすると正しいAST構造になる - const ast = parse(expanded); + const ast = parseAst(expanded); const divElement = ast.elements.find( (el) => el.element === "container" && (el.data as Record).type === "div", ); @@ -232,7 +237,7 @@ describe("resolveIncludes", () => { test("complex credit include with nested divs", () => { const creditStart = `[[div_ class="creditRate creditModule"]]\n[[div_ class="rateBox"]]\n[[div_ class="rate-box-with-credit-button"]]\n[[/div]]\n[[/div]]\n[[/div]]\n[[div class="credit"]]\n`; - const creditEnd = `\n[[/div]]`; + const creditEnd = "\n[[/div]]"; const source = "[[include credit:start]]\nContent here\n[[include credit:end]]"; const fetcher = (pageRef: { site: string | null; page: string }) => { @@ -242,7 +247,7 @@ describe("resolveIncludes", () => { }; const expanded = resolveIncludes(source, fetcher); - const ast = parse(expanded); + const ast = parseAst(expanded); const allText = getAllText(ast.elements); expect(allText).not.toContain("[[/div]]"); expect(allText).toContain("Content here"); diff --git a/tests/unit/parser/diagnostics.test.ts b/tests/unit/parser/diagnostics.test.ts new file mode 100644 index 0000000..44a656a --- /dev/null +++ b/tests/unit/parser/diagnostics.test.ts @@ -0,0 +1,364 @@ +import { describe, expect, it } from "bun:test"; +import { parse } from "@wdprlib/parser"; +import type { Diagnostic } from "@wdprlib/ast"; + +function getDiagnostics(input: string): Diagnostic[] { + return parse(input).diagnostics; +} + +function getCodes(input: string): string[] { + return getDiagnostics(input).map((d) => d.code); +} + +describe("Diagnostics", () => { + describe("clean input produces no diagnostics", () => { + it("empty string", () => { + expect(getDiagnostics("")).toEqual([]); + }); + + it("plain text", () => { + expect(getDiagnostics("Hello world")).toEqual([]); + }); + + it("properly closed div", () => { + expect(getDiagnostics("[[div]]\nContent\n[[/div]]")).toEqual([]); + }); + + it("properly closed collapsible", () => { + expect(getDiagnostics("[[collapsible]]\nContent\n[[/collapsible]]")).toEqual([]); + }); + + it("properly closed tabview with tab", () => { + expect(getDiagnostics("[[tabview]]\n[[tab Title]]\nContent\n[[/tab]]\n[[/tabview]]")).toEqual( + [], + ); + }); + + it("properly closed align", () => { + expect(getDiagnostics("[[=]]\nCentered\n[[/=]]")).toEqual([]); + }); + + it("properly closed iftags", () => { + expect(getDiagnostics("[[iftags +scp]]\nContent\n[[/iftags]]")).toEqual([]); + }); + }); + + describe("unclosed-block", () => { + it("unclosed div", () => { + const diags = getDiagnostics("[[div]]\nContent without close tag"); + expect(diags).toHaveLength(1); + expect(diags[0]!.severity).toBe("warning"); + expect(diags[0]!.code).toBe("unclosed-block"); + expect(diags[0]!.message).toContain("[[div]]"); + }); + + it("unclosed div_", () => { + const diags = getDiagnostics("[[div_]]\nContent"); + expect(diags).toHaveLength(1); + expect(diags[0]!.code).toBe("unclosed-block"); + }); + + it("unclosed collapsible", () => { + const diags = getDiagnostics("[[collapsible]]\nContent without close"); + expect(diags).toHaveLength(1); + expect(diags[0]!.severity).toBe("warning"); + expect(diags[0]!.code).toBe("unclosed-block"); + expect(diags[0]!.message).toContain("[[collapsible]]"); + }); + + it("unclosed iftags", () => { + const diags = getDiagnostics("[[iftags +scp]]\nContent"); + expect(diags).toHaveLength(1); + expect(diags[0]!.code).toBe("unclosed-block"); + expect(diags[0]!.message).toContain("[[iftags]]"); + }); + + it("unclosed center align", () => { + const diags = getDiagnostics("[[=]]\nCentered text"); + expect(diags).toHaveLength(1); + expect(diags[0]!.code).toBe("unclosed-block"); + expect(diags[0]!.message).toContain("[[=]]"); + }); + + it("unclosed right align", () => { + const diags = getDiagnostics("[[>]]\nRight aligned"); + expect(diags).toHaveLength(1); + expect(diags[0]!.code).toBe("unclosed-block"); + expect(diags[0]!.message).toContain("[[>]]"); + }); + + it("unclosed justify align", () => { + const diags = getDiagnostics("[[==]]\nJustified text"); + expect(diags).toHaveLength(1); + expect(diags[0]!.code).toBe("unclosed-block"); + expect(diags[0]!.message).toContain("[[==]]"); + }); + + it("nested div with inner unclosed — budget blocks excess open", () => { + const input = "[[div]]\n[[div]]\nInner content\n[[/div]]"; + const diags = getDiagnostics(input); + // 2 opens, 1 close: budget blocks the inner open → it becomes text. + // No unclosed-block because the outer div closes normally. + expect(diags.some((d) => d.code === "unclosed-block")).toBe(false); + }); + + it("unclosed code", () => { + const diags = getDiagnostics("[[code]]\nsome code here"); + expect(diags).toHaveLength(1); + expect(diags[0]!.code).toBe("unclosed-block"); + expect(diags[0]!.message).toContain("[[code]]"); + }); + + it("unclosed math", () => { + const diags = getDiagnostics("[[math]]\nx^2 + y^2 = z^2"); + expect(diags).toHaveLength(1); + expect(diags[0]!.code).toBe("unclosed-block"); + expect(diags[0]!.message).toContain("[[math]]"); + }); + + it("unclosed html", () => { + const diags = getDiagnostics("[[html]]\n

hello

"); + expect(diags).toHaveLength(1); + expect(diags[0]!.code).toBe("unclosed-block"); + expect(diags[0]!.message).toContain("[[html]]"); + }); + + it("unclosed embed", () => { + const diags = getDiagnostics("[[embed]]\n"); + expect(diags).toHaveLength(1); + expect(diags[0]!.code).toBe("unclosed-block"); + expect(diags[0]!.message).toContain("[[embed]]"); + }); + + it("unclosed module CSS", () => { + const diags = getDiagnostics("[[module CSS]]\n.foo { color: red; }"); + expect(diags).toHaveLength(1); + expect(diags[0]!.code).toBe("unclosed-block"); + expect(diags[0]!.message).toContain("[[module]]"); + }); + + it("unclosed anchor", () => { + const diags = getDiagnostics('[[a href="#"]]link text without close'); + expect(diags).toHaveLength(1); + expect(diags[0]!.code).toBe("unclosed-block"); + expect(diags[0]!.message).toContain("[[/a]]"); + }); + + it("unclosed footnote", () => { + const diags = getDiagnostics("[[footnote]]note without close"); + expect(diags).toHaveLength(1); + expect(diags[0]!.code).toBe("unclosed-block"); + expect(diags[0]!.message).toContain("[[footnote]]"); + }); + + it("unclosed span", () => { + const diags = getDiagnostics("[[span]]text without close"); + expect(diags).toHaveLength(1); + expect(diags[0]!.code).toBe("unclosed-block"); + expect(diags[0]!.message).toContain("[[span]]"); + }); + + it("unclosed size", () => { + const diags = getDiagnostics("[[size 120%]]large text without close"); + expect(diags).toHaveLength(1); + expect(diags[0]!.code).toBe("unclosed-block"); + expect(diags[0]!.message).toContain("[[size]]"); + }); + + it("unclosed bibliography", () => { + const diags = getDiagnostics("[[bibliography]]\n: ref1 : Some reference"); + expect(diags).toHaveLength(1); + expect(diags[0]!.code).toBe("unclosed-block"); + expect(diags[0]!.message).toContain("[[bibliography]]"); + }); + + it("unclosed tabview", () => { + const diags = getDiagnostics("[[tabview]]\n[[tab Title]]\nContent\n[[/tab]]"); + expect(diags.some((d) => d.code === "unclosed-block")).toBe(true); + }); + + it("unclosed tab", () => { + const diags = getDiagnostics("[[tabview]]\n[[tab Title]]\nContent\n[[/tabview]]"); + expect(diags.some((d) => d.code === "unclosed-block")).toBe(true); + }); + + it("unclosed ul", () => { + const diags = getDiagnostics("[[ul]]\n[[li]]Item[[/li]]"); + expect(diags.some((d) => d.code === "unclosed-block")).toBe(true); + }); + + it("unclosed ol", () => { + const diags = getDiagnostics("[[ol]]\n[[li]]Item[[/li]]"); + expect(diags.some((d) => d.code === "unclosed-block")).toBe(true); + }); + + it("unclosed table", () => { + const diags = getDiagnostics("[[table]]\n[[row]]\n[[cell]]Content[[/cell]]\n[[/row]]"); + expect(diags.some((d) => d.code === "unclosed-block")).toBe(true); + }); + + it("unclosed row", () => { + const diags = getDiagnostics("[[table]]\n[[row]]\n[[cell]]Content[[/cell]]\n[[/table]]"); + expect(diags.some((d) => d.code === "unclosed-block")).toBe(true); + }); + + it("unclosed cell", () => { + const diags = getDiagnostics("[[table]]\n[[row]]\n[[cell]]Content\n[[/row]]\n[[/table]]"); + expect(diags.some((d) => d.code === "unclosed-block")).toBe(true); + }); + + it("unclosed hcell", () => { + const diags = getDiagnostics("[[table]]\n[[row]]\n[[hcell]]Header\n[[/row]]\n[[/table]]"); + expect(diags.some((d) => d.code === "unclosed-block")).toBe(true); + }); + + it("unclosed embedvideo", () => { + const diags = getDiagnostics("[[embedvideo]]\n"); + expect(diags).toHaveLength(1); + expect(diags[0]!.code).toBe("unclosed-block"); + }); + + it("unclosed embedaudio", () => { + const diags = getDiagnostics("[[embedaudio]]\n"); + expect(diags).toHaveLength(1); + expect(diags[0]!.code).toBe("unclosed-block"); + }); + + it("unclosed orphan li", () => { + const diags = getDiagnostics("[[li]]content without close"); + expect(diags.some((d) => d.code === "unclosed-block")).toBe(true); + }); + + it("unclosed li inside list", () => { + const diags = getDiagnostics("[[ul]]\n[[li]]Item\n[[/ul]]"); + expect(diags.some((d) => d.code === "unclosed-block" && d.message.includes("[[/li]]"))).toBe( + true, + ); + }); + + it("unclosed left align", () => { + const diags = getDiagnostics("[[<]]\nLeft aligned"); + expect(diags).toHaveLength(1); + expect(diags[0]!.code).toBe("unclosed-block"); + expect(diags[0]!.message).toContain("[[<]]"); + }); + }); + + describe("unclosed-comment", () => { + it("unterminated block comment", () => { + const diags = getDiagnostics("[!-- unclosed comment"); + expect(diags.some((d) => d.code === "unclosed-comment")).toBe(true); + }); + + it("unterminated inline comment", () => { + const diags = getDiagnostics("text [!-- unclosed"); + expect(diags.some((d) => d.code === "unclosed-comment")).toBe(true); + }); + }); + + describe("clean inline input produces no diagnostics", () => { + it("properly closed span", () => { + expect(getDiagnostics("[[span]]text[[/span]]")).toEqual([]); + }); + + it("properly closed size", () => { + expect(getDiagnostics("[[size 120%]]text[[/size]]")).toEqual([]); + }); + + it("properly closed code", () => { + expect(getDiagnostics("[[code]]\nfoo\n[[/code]]")).toEqual([]); + }); + + it("properly closed math", () => { + expect(getDiagnostics("[[math]]\nx^2\n[[/math]]")).toEqual([]); + }); + + it("properly closed html", () => { + expect(getDiagnostics("[[html]]\n

hi

\n[[/html]]")).toEqual([]); + }); + + it("properly closed anchor", () => { + expect(getDiagnostics('[[a href="#"]]link[[/a]]')).toEqual([]); + }); + + it("properly closed footnote", () => { + expect(getDiagnostics("[[footnote]]note[[/footnote]]")).toEqual([]); + }); + + it("properly closed bibliography", () => { + expect(getDiagnostics("[[bibliography]]\n: ref1 : Reference\n[[/bibliography]]")).toEqual([]); + }); + + it("properly closed comment", () => { + expect(getDiagnostics("[!-- comment --]")).toEqual([]); + }); + + it("properly closed block list", () => { + expect(getDiagnostics("[[ul]]\n[[li]]Item[[/li]]\n[[/ul]]")).toEqual([]); + }); + + it("properly closed ol", () => { + expect(getDiagnostics("[[ol]]\n[[li]]Item[[/li]]\n[[/ol]]")).toEqual([]); + }); + + it("properly closed table", () => { + expect( + getDiagnostics("[[table]]\n[[row]]\n[[cell]]Content[[/cell]]\n[[/row]]\n[[/table]]"), + ).toEqual([]); + }); + + it("properly closed embed", () => { + expect(getDiagnostics("[[embed]]\n\n[[/embed]]")).toEqual([]); + }); + + it("properly closed module CSS", () => { + expect(getDiagnostics("[[module CSS]]\n.foo { color: red; }\n[[/module]]")).toEqual([]); + }); + }); + + describe("inline-block-element", () => { + it("inline div without newline after ]]", () => { + const diags = getDiagnostics("[[div]]inline text[[/div]]"); + expect(diags).toHaveLength(1); + expect(diags[0]!.severity).toBe("error"); + expect(diags[0]!.code).toBe("inline-block-element"); + }); + }); + + describe("position tracking", () => { + it("reports correct line number for unclosed div", () => { + const input = "First line\n\n[[div]]\nContent"; + const diags = getDiagnostics(input); + expect(diags).toHaveLength(1); + expect(diags[0]!.position.start.line).toBe(3); + }); + + it("reports correct line for inline div", () => { + const input = "[[div]]inline[[/div]]"; + const diags = getDiagnostics(input); + expect(diags).toHaveLength(1); + expect(diags[0]!.position.start.line).toBe(1); + }); + }); + + describe("multiple diagnostics", () => { + it("multiple unclosed blocks", () => { + const input = "[[div]]\n[[collapsible]]\nContent"; + const codes = getCodes(input); + // Both div and collapsible are unclosed + expect(codes.filter((c) => c === "unclosed-block").length).toBeGreaterThanOrEqual(1); + }); + }); + + describe("AST is still produced with diagnostics", () => { + it("excess div opens become text, all opened divs close normally", () => { + const result = parse( + "[[div]]\n[[div]]\n[[div]]\n[[div]]\n[[div]]\nSome content\n[[/div]]\n[[/div]]\n[[/div]]", + ); + // 5 opens, 3 closes: budget blocks the 4th and 5th opens → text. + // The 3 opened divs match the 3 closes → no diagnostics. + expect(result.ast.elements.length).toBeGreaterThan(0); + expect(result.diagnostics.length).toBe(0); + }); + }); +}); diff --git a/tests/unit/parser/line-break.test.ts b/tests/unit/parser/line-break.test.ts index 76cf62c..6a51b92 100644 --- a/tests/unit/parser/line-break.test.ts +++ b/tests/unit/parser/line-break.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from "bun:test"; -import { parse } from "@wdprlib/parser"; +import { parse, type ParserOptions } from "@wdprlib/parser"; import type { Element, SyntaxTree } from "@wdprlib/ast"; +function parseAst(input: string, options?: ParserOptions): SyntaxTree { + return parse(input, options).ast; +} + /** * Line Break Unit Tests * @@ -33,7 +37,7 @@ function collectText(elements: Element[]): string { describe("Line Break", () => { describe("single newline within paragraph", () => { it("inserts line-break between text nodes for single newline", () => { - const doc = parse("line1\nline2"); + const doc = parseAst("line1\nline2"); const content = getContentElements(doc); expect(content).toHaveLength(1); @@ -48,7 +52,7 @@ describe("Line Break", () => { }); it("inserts multiple line-breaks for multiple newlines", () => { - const doc = parse("line1\nline2\nline3"); + const doc = parseAst("line1\nline2\nline3"); const content = getContentElements(doc); expect(content).toHaveLength(1); @@ -62,7 +66,7 @@ describe("Line Break", () => { describe("blank line creates new paragraph", () => { it("creates separate paragraphs for blank line", () => { - const doc = parse("paragraph1\n\nparagraph2"); + const doc = parseAst("paragraph1\n\nparagraph2"); const content = getContentElements(doc); expect(content).toHaveLength(2); @@ -83,7 +87,7 @@ describe("Line Break", () => { describe("no line-break before block elements", () => { it("paragraph ends without line-break when followed by list", () => { - const doc = parse("text\n* item"); + const doc = parseAst("text\n* item"); const content = getContentElements(doc); expect(content).toHaveLength(2); @@ -96,7 +100,7 @@ describe("Line Break", () => { }); it("paragraph ends without line-break when followed by heading", () => { - const doc = parse("text\n+ Heading"); + const doc = parseAst("text\n+ Heading"); const content = getContentElements(doc); expect(content).toHaveLength(2); @@ -111,7 +115,7 @@ describe("Line Break", () => { }); it("paragraph ends without line-break when followed by blockquote", () => { - const doc = parse("text\n> quoted"); + const doc = parseAst("text\n> quoted"); const content = getContentElements(doc); expect(content).toHaveLength(2); @@ -123,7 +127,7 @@ describe("Line Break", () => { }); it("paragraph ends without line-break when followed by horizontal rule", () => { - const doc = parse("text\n----"); + const doc = parseAst("text\n----"); const content = getContentElements(doc); expect(content).toHaveLength(2); @@ -135,7 +139,7 @@ describe("Line Break", () => { }); it("paragraph ends without line-break when followed by table", () => { - const doc = parse("text\n|| cell ||"); + const doc = parseAst("text\n|| cell ||"); const content = getContentElements(doc); expect(content).toHaveLength(2); @@ -149,7 +153,7 @@ describe("Line Break", () => { describe("combined cases", () => { it("handles line-breaks within paragraph followed by block element", () => { - const doc = parse("line1\nline2\n* item\n\nnewpara"); + const doc = parseAst("line1\nline2\n* item\n\nnewpara"); const content = getContentElements(doc); expect(content).toHaveLength(3); diff --git a/tests/unit/parser/parser.test.ts b/tests/unit/parser/parser.test.ts index 7ccd8b4..e3a97bd 100644 --- a/tests/unit/parser/parser.test.ts +++ b/tests/unit/parser/parser.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from "bun:test"; -import { parse } from "@wdprlib/parser"; +import { parse, type ParserOptions } from "@wdprlib/parser"; import type { Element, SyntaxTree } from "@wdprlib/ast"; +function parseAst(input: string, options?: ParserOptions): SyntaxTree { + return parse(input, options).ast; +} + /** * Parser Unit Tests * @@ -34,12 +38,12 @@ function getContentElements(doc: SyntaxTree): Element[] { describe("Parser", () => { describe("document structure", () => { it("empty string produces only footnote-block", () => { - const doc = parse(""); + const doc = parseAst(""); expect(doc.elements).toEqual([FOOTNOTE_BLOCK]); }); it("single text produces paragraph with text and footnote-block", () => { - const doc = parse("Hello"); + const doc = parseAst("Hello"); expect(doc.elements).toEqual([ { element: "container", @@ -56,7 +60,7 @@ describe("Parser", () => { describe("paragraph separation", () => { it("blank line creates separate paragraphs", () => { - const doc = parse("First\n\nSecond"); + const doc = parseAst("First\n\nSecond"); const content = getContentElements(doc); expect(content).toEqual([ @@ -80,7 +84,7 @@ describe("Parser", () => { }); it("multiple blank lines are treated as single separator", () => { - const doc = parse("First\n\n\n\nSecond"); + const doc = parseAst("First\n\n\n\nSecond"); const content = getContentElements(doc); expect(content).toHaveLength(2); @@ -91,14 +95,14 @@ describe("Parser", () => { describe("horizontal rule", () => { it("---- produces horizontal-rule", () => { - const doc = parse("----"); + const doc = parseAst("----"); const content = getContentElements(doc); expect(content).toEqual([{ element: "horizontal-rule" }]); }); it("longer dashes also produce horizontal-rule", () => { - const doc = parse("--------"); + const doc = parseAst("--------"); const content = getContentElements(doc); expect(content).toEqual([{ element: "horizontal-rule" }]); @@ -107,7 +111,7 @@ describe("Parser", () => { describe("unclosed inline formatting", () => { it("unclosed ** is treated as separate text nodes", () => { - const doc = parse("**unclosed"); + const doc = parseAst("**unclosed"); const content = getContentElements(doc); expect(content).toEqual([ @@ -126,7 +130,7 @@ describe("Parser", () => { }); it("unclosed // is treated as separate text nodes", () => { - const doc = parse("//unclosed"); + const doc = parseAst("//unclosed"); const content = getContentElements(doc); expect(content).toEqual([ @@ -145,7 +149,7 @@ describe("Parser", () => { }); it("unclosed @@ is treated as separate text nodes", () => { - const doc = parse("@@unclosed"); + const doc = parseAst("@@unclosed"); const content = getContentElements(doc); expect(content).toEqual([ @@ -166,7 +170,7 @@ describe("Parser", () => { describe("raw escape special cases", () => { it("@@@@ produces text with @@", () => { - const doc = parse("@@@@"); + const doc = parseAst("@@@@"); const content = getContentElements(doc); expect(content).toEqual([ @@ -182,7 +186,7 @@ describe("Parser", () => { }); it("@@@@@ produces text with single @", () => { - const doc = parse("@@@@@"); + const doc = parseAst("@@@@@"); const content = getContentElements(doc); expect(content).toEqual([ @@ -198,7 +202,7 @@ describe("Parser", () => { }); it("@@@@@@ produces text with @@", () => { - const doc = parse("@@@@@@"); + const doc = parseAst("@@@@@@"); const content = getContentElements(doc); expect(content).toEqual([ @@ -216,7 +220,7 @@ describe("Parser", () => { describe("comment", () => { it("comment is discarded from output", () => { - const doc = parse("[!-- comment --]visible"); + const doc = parseAst("[!-- comment --]visible"); const content = getContentElements(doc); expect(content).toEqual([ @@ -232,7 +236,7 @@ describe("Parser", () => { }); it("comment between text is removed", () => { - const doc = parse("before[!-- hidden --]after"); + const doc = parseAst("before[!-- hidden --]after"); const content = getContentElements(doc); expect(content).toEqual([ @@ -251,7 +255,7 @@ describe("Parser", () => { }); it("multi-line comment is removed", () => { - const doc = parse("[!-- line 1\nline 2 --]after"); + const doc = parseAst("[!-- line 1\nline 2 --]after"); const content = getContentElements(doc); expect(content).toEqual([ @@ -269,7 +273,7 @@ describe("Parser", () => { describe("color without pipe separator", () => { it("##red## without pipe is treated as separate text nodes", () => { - const doc = parse("##red##"); + const doc = parseAst("##red##"); const content = getContentElements(doc); expect(content).toEqual([ @@ -291,7 +295,7 @@ describe("Parser", () => { describe("fake anchor link", () => { it("[# label] produces javascript:; link with type anchor", () => { - const doc = parse("[# Click me]"); + const doc = parseAst("[# Click me]"); const content = getContentElements(doc); expect(content).toEqual([ @@ -320,7 +324,7 @@ describe("Parser", () => { describe("mixed block elements", () => { it("heading followed by paragraph", () => { - const doc = parse("+ Title\n\nContent"); + const doc = parseAst("+ Title\n\nContent"); const content = getContentElements(doc); expect(content).toHaveLength(2); @@ -341,7 +345,7 @@ describe("Parser", () => { }); it("list followed by paragraph", () => { - const doc = parse("* Item\n\nParagraph"); + const doc = parseAst("* Item\n\nParagraph"); const content = getContentElements(doc); expect(content).toHaveLength(2); @@ -356,7 +360,7 @@ describe("Parser", () => { }); it("different list types create separate lists", () => { - const doc = parse("* Bullet\n# Number"); + const doc = parseAst("* Bullet\n# Number"); const content = getContentElements(doc); expect(content).toHaveLength(2); @@ -385,7 +389,7 @@ describe("Parser", () => { // data-* attributes should not be accepted (Wikidot behavior) // Importantly, they should not bypass security by splitting into separate attributes it("data-src does not override src or become separate attribute", () => { - const doc = parse('[[image foo.jpg data-src="evil.jpg"]]'); + const doc = parseAst('[[image foo.jpg data-src="evil.jpg"]]'); const content = getContentElements(doc); expect(content).toHaveLength(1); @@ -398,7 +402,7 @@ describe("Parser", () => { }); it("data--src (double hyphen) does not bypass to separate src", () => { - const doc = parse('[[image foo.jpg data--src="evil.jpg"]]'); + const doc = parseAst('[[image foo.jpg data--src="evil.jpg"]]'); const content = getContentElements(doc); expect(content).toHaveLength(1); @@ -410,7 +414,7 @@ describe("Parser", () => { }); it("data---src (triple hyphen) does not bypass to separate src", () => { - const doc = parse('[[image foo.jpg data---src="evil.jpg"]]'); + const doc = parseAst('[[image foo.jpg data---src="evil.jpg"]]'); const content = getContentElements(doc); expect(content).toHaveLength(1); @@ -421,7 +425,7 @@ describe("Parser", () => { }); it("data----src (quadruple hyphen) does not bypass to separate src", () => { - const doc = parse('[[image foo.jpg data----src="evil.jpg"]]'); + const doc = parseAst('[[image foo.jpg data----src="evil.jpg"]]'); const content = getContentElements(doc); expect(content).toHaveLength(1); @@ -432,7 +436,7 @@ describe("Parser", () => { }); it("valid image attributes are preserved", () => { - const doc = parse('[[image foo.jpg alt="Description" width="100"]]'); + const doc = parseAst('[[image foo.jpg alt="Description" width="100"]]'); const content = getContentElements(doc); expect(content).toHaveLength(1); diff --git a/tests/unit/parser/resolve/styles-iftags.test.ts b/tests/unit/parser/resolve/styles-iftags.test.ts index e15417c..2c656c3 100644 --- a/tests/unit/parser/resolve/styles-iftags.test.ts +++ b/tests/unit/parser/resolve/styles-iftags.test.ts @@ -1,15 +1,19 @@ import { describe, expect, it } from "bun:test"; -import { parse, resolveModules, resolveIncludes } from "@wdprlib/parser"; +import { parse, resolveModules, resolveIncludes, type ParserOptions } from "@wdprlib/parser"; import type { SyntaxTree, Element } from "@wdprlib/ast"; import type { DataProvider } from "../../../../packages/parser/src/parser/rules/block/module/types-common"; import type { ResolveOptions } from "../../../../packages/parser/src/parser/rules/block/module/resolve"; +function parseAst(input: string, options?: ParserOptions): SyntaxTree { + return parse(input, options).ast; +} + /** * Helper: create minimal ResolveOptions */ function createResolveOptions(overrides: Partial = {}): ResolveOptions { return { - parse, + parse: (input: string) => parse(input).ast, compiledListPagesTemplates: new Map(), requirements: {}, ...overrides, @@ -52,7 +56,7 @@ function hasStyleElements(elements: Element[]): boolean { describe("resolve: style collection", () => { it("collects top-level style elements into SyntaxTree.styles", async () => { - const ast = parse("[[module css]]\n.blue { color: blue; }\n[[/module]]"); + const ast = parseAst("[[module css]]\n.blue { color: blue; }\n[[/module]]"); const resolved = await resolveWithoutTags(ast); expect(resolved.styles).toEqual([".blue { color: blue; }"]); @@ -69,7 +73,7 @@ describe("resolve: style collection", () => { ".b { color: blue; }", "[[/module]]", ].join("\n"); - const ast = parse(input); + const ast = parseAst(input); const resolved = await resolveWithoutTags(ast); expect(resolved.styles).toEqual([".a { color: red; }", ".b { color: blue; }"]); @@ -84,7 +88,7 @@ describe("resolve: style collection", () => { "[[/module]]", "[[/div]]", ].join("\n"); - const ast = parse(input); + const ast = parseAst(input); const resolved = await resolveWithoutTags(ast); expect(resolved.styles).toEqual([".nested { margin: 0; }"]); @@ -92,14 +96,14 @@ describe("resolve: style collection", () => { }); it("does not set styles field when no style elements exist", async () => { - const ast = parse("Hello world"); + const ast = parseAst("Hello world"); const resolved = await resolveWithoutTags(ast); expect(resolved.styles).toBeUndefined(); }); it("collects empty style (module css with no body)", async () => { - const ast = parse("[[module css]]\n[[/module]]"); + const ast = parseAst("[[module css]]\n[[/module]]"); const resolved = await resolveWithoutTags(ast); expect(resolved.styles).toEqual([""]); @@ -109,7 +113,7 @@ describe("resolve: style collection", () => { describe("resolve: iftags", () => { it("includes elements when tag condition matches", async () => { const input = "[[iftags +fruit]]\nApple\n[[/iftags]]"; - const ast = parse(input); + const ast = parseAst(input); const resolved = await resolveWithTags(ast, ["fruit"]); // iftags element should be removed, content should be at top level @@ -122,7 +126,7 @@ describe("resolve: iftags", () => { it("excludes elements when tag condition does not match", async () => { const input = "[[iftags +fruit]]\nApple\n[[/iftags]]"; - const ast = parse(input); + const ast = parseAst(input); const resolved = await resolveWithTags(ast, ["vegetable"]); const text = getAllText(resolved.elements); @@ -131,7 +135,7 @@ describe("resolve: iftags", () => { it("keeps iftags unresolved when no tags callback provided", async () => { const input = "[[iftags +fruit]]\nApple\n[[/iftags]]"; - const ast = parse(input); + const ast = parseAst(input); const resolved = await resolveWithoutTags(ast); const hasIfTags = resolved.elements.some((el) => el.element === "if-tags"); @@ -146,7 +150,7 @@ describe("resolve: iftags", () => { "[[/module]]", "[[/iftags]]", ].join("\n"); - const ast = parse(input); + const ast = parseAst(input); const resolved = await resolveWithoutTags(ast); // tree.styles should contain only a slot placeholder, not the actual CSS @@ -168,7 +172,7 @@ describe("resolve: iftags", () => { "[[/module]]", "[[/iftags]]", ].join("\n"); - const ast = parse(input); + const ast = parseAst(input); const resolved = await resolveWithTags(ast, ["fruit"]); expect(resolved.styles).toEqual(["body { color: red; }"]); @@ -183,7 +187,7 @@ describe("resolve: iftags", () => { "[[/module]]", "[[/iftags]]", ].join("\n"); - const ast = parse(input); + const ast = parseAst(input); const resolved = await resolveWithTags(ast, ["vegetable"]); expect(resolved.styles).toBeUndefined(); @@ -202,7 +206,7 @@ describe("resolve: iftags", () => { "[[/module]]", "[[/iftags]]", ].join("\n"); - const ast = parse(input); + const ast = parseAst(input); const resolved = await resolveWithTags(ast, ["fruit"]); expect(resolved.styles).toEqual([".fruit { color: green; }"]); @@ -217,7 +221,7 @@ describe("resolve: iftags", () => { "Hidden content", "[[/iftags]]", ].join("\n"); - const ast = parse(input); + const ast = parseAst(input); const resolved = await resolveWithTags(ast, ["fruit"]); expect(resolved.styles).toBeUndefined(); @@ -227,7 +231,7 @@ describe("resolve: iftags", () => { it("excludes elements with empty condition even with no page tags", async () => { const input = "[[iftags]]\nHidden\n[[/iftags]]"; - const ast = parse(input); + const ast = parseAst(input); const resolved = await resolveWithTags(ast, []); const text = getAllText(resolved.elements); @@ -244,7 +248,7 @@ describe("resolve: iftags", () => { "[[/div]]", "[[/iftags]]", ].join("\n"); - const ast = parse(input); + const ast = parseAst(input); const resolved = await resolveWithoutTags(ast); // tree.styles should contain only a slot placeholder, not the actual CSS @@ -258,7 +262,7 @@ describe("resolve: iftags", () => { it("handles negated tag conditions", async () => { const input = "[[iftags -admin]]\nPublic content\n[[/iftags]]"; - const ast = parse(input); + const ast = parseAst(input); const resolved = await resolveWithTags(ast, ["fruit"]); const text = getAllText(resolved.elements); @@ -277,7 +281,7 @@ describe("resolve: include with styles", () => { }; const expanded = resolveIncludes(input, fetcher); - const resolved = parse(expanded); + const resolved = parseAst(expanded); const finalResolved = await resolveWithoutTags(resolved); expect(finalResolved.styles).toEqual([".included { margin: 0; }"]); @@ -297,7 +301,7 @@ describe("resolve: include with styles", () => { }; const expanded = resolveIncludes(input, fetcher); - const resolved = parse(expanded); + const resolved = parseAst(expanded); const finalResolved = await resolveWithoutTags(resolved); expect(finalResolved.styles).toEqual([".a { color: red; }", ".b { color: blue; }"]); @@ -315,7 +319,7 @@ describe("resolve: include with styles", () => { }; const expanded = resolveIncludes(input, fetcher); - const withIncludes = parse(expanded); + const withIncludes = parseAst(expanded); const resolved = await resolveWithTags(withIncludes, ["component"]); expect(resolved.styles).toEqual([".theme { background: black; }"]); @@ -337,7 +341,7 @@ describe("resolve → render: CSS order consistency", () => { "[[/iftags]]", ].join("\n"); - const ast = parse(input); + const ast = parseAst(input); // Path A: resolved with tags (iftags evaluated at resolve time) const resolvedWithTags = await resolveWithTags(ast, ["x"]); @@ -375,7 +379,7 @@ describe("resolve → render: CSS order consistency", () => { "[[/module]]", ].join("\n"); - const ast = parse(input); + const ast = parseAst(input); const resolvedWithTags = await resolveWithTags(ast, ["x"]); const htmlA = renderToHtml(resolvedWithTags, { diff --git a/tests/unit/parser/settings.test.ts b/tests/unit/parser/settings.test.ts index adc7e73..1618c58 100644 --- a/tests/unit/parser/settings.test.ts +++ b/tests/unit/parser/settings.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from "bun:test"; -import { parse, resolveIncludes, createSettings } from "@wdprlib/parser"; +import { parse, resolveIncludes, createSettings, type ParserOptions } from "@wdprlib/parser"; import type { Element, SyntaxTree, WikitextSettings } from "@wdprlib/ast"; +function parseAst(input: string, options?: ParserOptions): SyntaxTree { + return parse(input, options).ast; +} + function getContentElements(doc: SyntaxTree): Element[] { return doc.elements.filter((el) => el.element !== "footnote-block"); } @@ -14,21 +18,21 @@ const draftSettings: WikitextSettings = createSettings("draft"); describe("WikitextSettings - Parser", () => { describe("enablePageSyntax = true (page mode)", () => { it("parses [[include]]", () => { - const doc = parse("[[include component:box]]", { settings: pageSettings }); + const doc = parseAst("[[include component:box]]", { settings: pageSettings }); const elements = getContentElements(doc); expect(elements.length).toBe(1); expect(elements[0]!.element).toBe("include"); }); it("parses [[module Rate]]", () => { - const doc = parse("[[module Rate]]", { settings: pageSettings }); + const doc = parseAst("[[module Rate]]", { settings: pageSettings }); const elements = getContentElements(doc); expect(elements.length).toBe(1); expect(elements[0]!.element).toBe("module"); }); it("parses [[toc]]", () => { - const doc = parse("[[toc]]", { settings: pageSettings }); + const doc = parseAst("[[toc]]", { settings: pageSettings }); const elements = getContentElements(doc); expect(elements.length).toBe(1); expect(elements[0]!.element).toBe("table-of-contents"); @@ -37,21 +41,21 @@ describe("WikitextSettings - Parser", () => { describe("enablePageSyntax = true (draft mode)", () => { it("parses [[include]]", () => { - const doc = parse("[[include component:box]]", { settings: draftSettings }); + const doc = parseAst("[[include component:box]]", { settings: draftSettings }); const elements = getContentElements(doc); expect(elements.length).toBe(1); expect(elements[0]!.element).toBe("include"); }); it("parses [[module Rate]]", () => { - const doc = parse("[[module Rate]]", { settings: draftSettings }); + const doc = parseAst("[[module Rate]]", { settings: draftSettings }); const elements = getContentElements(doc); expect(elements.length).toBe(1); expect(elements[0]!.element).toBe("module"); }); it("parses [[toc]]", () => { - const doc = parse("[[toc]]", { settings: draftSettings }); + const doc = parseAst("[[toc]]", { settings: draftSettings }); const elements = getContentElements(doc); expect(elements.length).toBe(1); expect(elements[0]!.element).toBe("table-of-contents"); @@ -60,25 +64,25 @@ describe("WikitextSettings - Parser", () => { describe("enablePageSyntax = false (forum-post mode)", () => { it("treats [[include]] as plain text", () => { - const doc = parse("[[include component:box]]", { settings: forumSettings }); + const doc = parseAst("[[include component:box]]", { settings: forumSettings }); const elements = getContentElements(doc); expect(elements.every((el) => el.element !== "include")).toBe(true); }); it("treats [[module Rate]] as plain text", () => { - const doc = parse("[[module Rate]]", { settings: forumSettings }); + const doc = parseAst("[[module Rate]]", { settings: forumSettings }); const elements = getContentElements(doc); expect(elements.every((el) => el.element !== "module")).toBe(true); }); it("treats [[toc]] as plain text", () => { - const doc = parse("[[toc]]", { settings: forumSettings }); + const doc = parseAst("[[toc]]", { settings: forumSettings }); const elements = getContentElements(doc); expect(elements.every((el) => el.element !== "table-of-contents")).toBe(true); }); it("treats [[f { - const doc = parse("[[f el.element !== "table-of-contents")).toBe(true); }); @@ -86,13 +90,13 @@ describe("WikitextSettings - Parser", () => { describe("enablePageSyntax = false (direct-message mode)", () => { it("treats [[include]] as plain text", () => { - const doc = parse("[[include component:box]]", { settings: dmSettings }); + const doc = parseAst("[[include component:box]]", { settings: dmSettings }); const elements = getContentElements(doc); expect(elements.every((el) => el.element !== "include")).toBe(true); }); it("treats [[module Rate]] as plain text", () => { - const doc = parse("[[module Rate]]", { settings: dmSettings }); + const doc = parseAst("[[module Rate]]", { settings: dmSettings }); const elements = getContentElements(doc); expect(elements.every((el) => el.element !== "module")).toBe(true); }); @@ -100,21 +104,21 @@ describe("WikitextSettings - Parser", () => { describe("default behavior (no settings specified)", () => { it("parses [[include]] by default (page mode)", () => { - const doc = parse("[[include component:box]]"); + const doc = parseAst("[[include component:box]]"); const elements = getContentElements(doc); expect(elements.length).toBe(1); expect(elements[0]!.element).toBe("include"); }); it("parses [[module Rate]] by default", () => { - const doc = parse("[[module Rate]]"); + const doc = parseAst("[[module Rate]]"); const elements = getContentElements(doc); expect(elements.length).toBe(1); expect(elements[0]!.element).toBe("module"); }); it("parses [[toc]] by default", () => { - const doc = parse("[[toc]]"); + const doc = parseAst("[[toc]]"); const elements = getContentElements(doc); expect(elements.length).toBe(1); expect(elements[0]!.element).toBe("table-of-contents"); @@ -145,7 +149,7 @@ describe("WikitextSettings - Parser", () => { describe("non-page syntax is unaffected", () => { it("parses bold in forum-post mode", () => { - const doc = parse("**bold text**", { settings: forumSettings }); + const doc = parseAst("**bold text**", { settings: forumSettings }); const elements = getContentElements(doc); expect(elements.length).toBe(1); const para = elements[0]!; @@ -153,13 +157,13 @@ describe("WikitextSettings - Parser", () => { }); it("parses links in forum-post mode", () => { - const doc = parse("[[[page-name]]]", { settings: forumSettings }); + const doc = parseAst("[[[page-name]]]", { settings: forumSettings }); const elements = getContentElements(doc); expect(elements.length).toBe(1); }); it("parses code blocks in forum-post mode", () => { - const doc = parse("[[code]]\nfoo\n[[/code]]", { settings: forumSettings }); + const doc = parseAst("[[code]]\nfoo\n[[/code]]", { settings: forumSettings }); const elements = getContentElements(doc); expect(elements.some((el) => el.element === "code")).toBe(true); });