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