From 00fed4d02dc037a4c678db2ee7fa31b837a81dd7 Mon Sep 17 00:00:00 2001 From: Waren Gonzaga Date: Tue, 6 Oct 2026 18:57:29 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A7=20update:=20streamline=20compilati?= =?UTF-8?q?on=20and=20report=20excessive=20nesting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 ++ scripts/smoke.mjs | 11 +++++++++ src/index.ts | 45 +++++++++++++++++++++++++++++++------ src/links.ts | 51 ++++++++++++++++++------------------------ src/markdown.ts | 17 +++++--------- src/project.ts | 4 +--- tests/compile.test.ts | 47 ++++++++++++++++++++++++++++++++++++++ tests/markdown.test.ts | 46 +++++++++++++++++++++++++++++++++++++ 8 files changed, 172 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 6f0c0cf..1f478c8 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,8 @@ if (!result.site) { `site` is absent whenever authoring errors exist. Diagnostics contain a stable code, severity, message, and source location where available. Unexpected filesystem failures reject the promise with context. Identical inputs produce identical output. +Excessive Markdown nesting that exceeds the runtime's call stack produces a `CONTENT_TOO_DEEP` diagnostic identifying the source file. + The result includes: - `pages`: source, route, public URL, title, description, article HTML, readable Markdown, headings, and navigation metadata. diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index c230bc9..b647ef3 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -59,6 +59,7 @@ try { path.join(temp, "consumer.mjs"), ` import assert from 'node:assert/strict'; +import { rm, writeFile } from 'node:fs/promises'; import { compileProject } from '@wgtechlabs/mdd-engine'; assert.equal(typeof globalThis.Bun, 'undefined'); for (const basePath of ['/', '/docs/', '/repository/docs/']) { @@ -69,6 +70,16 @@ for (const basePath of ['/', '/docs/', '/repository/docs/']) { assert(result.site.pages.find(p => p.route === '/').html.includes(basePath + 'guide/install/#mdd-install')); assert.deepEqual(await compileProject({projectDir: process.cwd(), basePath}), result); } +// Exercise rendering overflow and parsing overflow in each real Node runtime. +for (const depth of [2_000, 10_000]) { + await writeFile('mdd/contents/deep.md', '> '.repeat(depth) + 'Nested text'); + const result = await compileProject({projectDir: process.cwd()}); + assert.equal(result.site, undefined); + assert.deepEqual(result.diagnostics.map(({code, file}) => ({code, file})), [ + {code: 'CONTENT_TOO_DEEP', file: 'mdd/contents/deep.md'}, + ]); +} +await rm('mdd/contents/deep.md'); console.log('Packed consumer passed on Node ' + process.version); `, ); diff --git a/src/index.ts b/src/index.ts index a08ce7b..7147cbe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,10 @@ import { readFile } from "node:fs/promises"; import { resolveLinks, type SourcePage } from "./links.js"; -import { parseDocument, renderDocument } from "./markdown.js"; +import { + type ParsedDocument, + parseDocument, + renderDocument, +} from "./markdown.js"; import { loadProject, relativeSource } from "./project.js"; import { navigationFor, @@ -39,6 +43,22 @@ const reserved = new Set([ "404.html", ]); +function nestingDiagnostic(error: unknown, file: string): Diagnostic { + // V8 reports this error for excessive nesting in both parsing and rendering. + if ( + !(error instanceof RangeError) || + error.message !== "Maximum call stack size exceeded" + ) + throw error; + return { + severity: "error", + code: "CONTENT_TOO_DEEP", + message: + "Markdown nesting is too deep; reduce nested blocks or formatting.", + file, + }; +} + /** Compile a local checkout; never fetch repositories, execute author code, or emit files. */ export async function compileProject( options: CompileOptions, @@ -84,11 +104,14 @@ export async function compileProject( file: source, }); routes.set(key, source); - const document = parseDocument( - await readFile(file, "utf8"), - source, - diagnostics, - ); + const text = await readFile(file, "utf8"); + let document: ParsedDocument; + try { + document = parseDocument(text, source, diagnostics); + } catch (error) { + diagnostics.push(nestingDiagnostic(error, source)); + return { diagnostics }; + } sources.push({ file, relative, route, document }); } const assets = await resolveLinks(sources, project, basePath, diagnostics); @@ -97,7 +120,15 @@ export async function compileProject( const pages: Page[] = []; for (const source of sources) { const { document } = source; - const rendered = await renderDocument(document); + let rendered: { html: string; markdown: string }; + try { + rendered = await renderDocument(document); + } catch (error) { + diagnostics.push( + nestingDiagnostic(error, relativeSource(project.root, source.file)), + ); + return { diagnostics }; + } pages.push({ source: relativeSource(project.root, source.file), route: source.route, diff --git a/src/links.ts b/src/links.ts index e1b8c87..16bcff7 100644 --- a/src/links.ts +++ b/src/links.ts @@ -97,9 +97,11 @@ export async function resolveLinks( ); let target = !decoded ? page : byFile.get(targetFile); const extension = path.extname(decoded).toLowerCase(); - let assetExists = false; + let asset = decoded.endsWith("/") ? undefined : assets.get(targetFile); + let assetExists = asset !== undefined; if ( !target && + !assetExists && !decoded.endsWith("/") && assetExtensions.has(extension) ) { @@ -165,23 +167,25 @@ export async function resolveLinks( "Images must reference a supported raster image", ); } - if (!assetExists) await safeFile(project.contentsRoot, targetFile); - const source = relativeSource(project.root, targetFile); - const destination = `_assets/${relativeSource(project.contentsRoot, targetFile)}`; - const key = destination.normalize("NFC").toLowerCase(); - const existing = destinations.get(key); - if (existing && existing !== source) - throw new AuthoringError( - "ASSET_COLLISION", - `Asset output collides with ${existing}`, - ); - destinations.set(key, source); - const asset = { - source, - destination, - url: publicUrl(basePath, encodePath(destination)), - }; - assets.set(source, asset); + if (!asset) { + if (!assetExists) await safeFile(project.contentsRoot, targetFile); + const source = relativeSource(project.root, targetFile); + const destination = `_assets/${relativeSource(project.contentsRoot, targetFile)}`; + const key = destination.normalize("NFC").toLowerCase(); + const existing = destinations.get(key); + if (existing && existing !== source) + throw new AuthoringError( + "ASSET_COLLISION", + `Asset output collides with ${existing}`, + ); + destinations.set(key, source); + asset = { + source, + destination, + url: publicUrl(basePath, encodePath(destination)), + }; + assets.set(targetFile, asset); + } node.url = asset.url + query + (hash ? `#${encodeURIComponent(hash)}` : ""); } catch (error) { @@ -200,17 +204,6 @@ export async function resolveLinks( message: `Malformed URL encoding in '${url}'`, ...location, }); - else if ( - error instanceof Error && - "code" in error && - error.code === "ENOENT" - ) - diagnostics.push({ - severity: "error", - code: "MISSING_ASSET", - message: `Asset not found: '${url}'`, - ...location, - }); else throw new Error(`Unable to resolve '${url}' in ${location.file}`, { cause: error, diff --git a/src/markdown.ts b/src/markdown.ts index a72e4e9..af21a93 100644 --- a/src/markdown.ts +++ b/src/markdown.ts @@ -200,6 +200,10 @@ export function parseDocument( "Raw HTML is not supported; use Markdown or an mdd component.", node, ); + if (parent && index !== undefined) { + parent.children.splice(index, 1); + return [SKIP, index]; + } } if ( (node.type === "link" || @@ -301,19 +305,8 @@ export async function renderDocument( document: ParsedDocument, ): Promise<{ html: string; markdown: string }> { const htmlTree = await htmlRenderer.run(document.tree); - const markdownTree = structuredClone(document.tree); - visit(markdownTree, (node, index, parent) => { - if ( - (node.type === "html" || node.type === "yaml") && - parent && - index !== undefined - ) { - parent.children.splice(index, 1); - return [SKIP, index]; - } - }); return { html: String(htmlRenderer.stringify(htmlTree)), - markdown: String(markdownRenderer.stringify(markdownTree)), + markdown: String(markdownRenderer.stringify(document.tree)), }; } diff --git a/src/project.ts b/src/project.ts index a144622..1796448 100644 --- a/src/project.ts +++ b/src/project.ts @@ -4,9 +4,7 @@ import type { CompileOptions, Diagnostic, Theme } from "./types.js"; export interface Project { root: string; - mddRoot: string; contentsRoot: string; - themesRoot: string; title?: string; theme: Theme; files: string[]; @@ -380,7 +378,7 @@ export async function loadProject( } if (diagnostics.some((diagnostic) => diagnostic.severity === "error")) return undefined; - return { root, mddRoot, contentsRoot, themesRoot, title, theme, files }; + return { root, contentsRoot, title, theme, files }; } catch (error) { if (!(error instanceof AuthoringError)) throw error; diagnostics.push({ diff --git a/tests/compile.test.ts b/tests/compile.test.ts index 6d4b388..a0a05d3 100644 --- a/tests/compile.test.ts +++ b/tests/compile.test.ts @@ -23,6 +23,53 @@ async function fixture(files: Record): Promise { } describe("compileProject", () => { + test("reuses shared assets without losing link suffixes or retaining stale files", async () => { + const projectDir = await fixture({ + "contents/index.md": "# Home\n\n![Logo](logo.png?size=1#preview)", + "contents/guide/index.md": + "# Guide\n\n![Logo](../logo.png?size=2)\n\n[Download](/logo.png#download)", + "contents/logo.png": "image", + }); + const { site, diagnostics } = await compileProject({ + projectDir, + basePath: "/docs/", + }); + expect(diagnostics).toEqual([]); + expect(site?.assets).toEqual([ + { + source: "mdd/contents/logo.png", + destination: "_assets/logo.png", + url: "/docs/_assets/logo.png", + }, + ]); + const html = site?.pages.map((page) => page.html).join("\n"); + expect(html).toContain('src="/docs/_assets/logo.png?size=1#preview"'); + expect(html).toContain('src="/docs/_assets/logo.png?size=2"'); + expect(html).toContain('href="/docs/_assets/logo.png#download"'); + + await rm(path.join(projectDir, "mdd/contents/logo.png")); + const next = await compileProject({ projectDir }); + expect(next.site).toBeUndefined(); + expect(next.diagnostics.map(({ code }) => code)).toEqual([ + "MISSING_ASSET", + "MISSING_ASSET", + "MISSING_ASSET", + ]); + }); + + test("still rejects an image of an asset previously used as a download", async () => { + const projectDir = await fixture({ + "contents/index.md": + "# Home\n\n[Download](manual.pdf)\n\n![Preview](manual.pdf)", + "contents/manual.pdf": "document", + }); + const result = await compileProject({ projectDir }); + expect(result.site).toBeUndefined(); + expect(result.diagnostics).toMatchObject([ + { code: "INVALID_IMAGE", file: "mdd/contents/index.md", line: 5 }, + ]); + }); + test("ignores unused local reference definitions and their assets", async () => { const projectDir = await fixture({ "contents/index.md": diff --git a/tests/markdown.test.ts b/tests/markdown.test.ts index 28ae740..6c4a415 100644 --- a/tests/markdown.test.ts +++ b/tests/markdown.test.ts @@ -113,6 +113,52 @@ Pay attention. expect(diagnostics[0]).toMatchObject({ code, file: "page.md", line: 1 }); }); + test("discards raw HTML without losing locations, content, or Markdown spacing", async () => { + const diagnostics: Diagnostic[] = []; + const document = parseDocument( + ` + +Before bold after. + +:::note[Remember] + + +First + +
removed
+ +Last +::: + +`, + "page.md", + diagnostics, + ); + expect(diagnostics).toEqual( + [ + [1, 1], + [3, 8], + [3, 15], + [6, 1], + [10, 1], + [15, 1], + ].map(([line, column]) => ({ + severity: "error", + code: "RAW_HTML", + message: "Raw HTML is not supported; use Markdown or an mdd component.", + file: "page.md", + line, + column, + })), + ); + const before = structuredClone(document.tree); + expect(await renderDocument(document)).toEqual({ + html: '

Before bold after.

\n', + markdown: "Before bold after.\n\n> **Remember**\n>\n> First\n>\n> Last\n", + }); + expect(document.tree).toEqual(before); + }); + test("rejects executable URLs in inline, image, and reference links", async () => { const diagnostics: Diagnostic[] = []; const document = parseDocument(