Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 11 additions & 0 deletions scripts/smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/']) {
Expand All @@ -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);
`,
);
Expand Down
45 changes: 38 additions & 7 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand All @@ -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,
Expand Down
51 changes: 22 additions & 29 deletions src/links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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,
Expand Down
17 changes: 5 additions & 12 deletions src/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" ||
Expand Down Expand Up @@ -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)),
};
}
4 changes: 1 addition & 3 deletions src/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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({
Expand Down
47 changes: 47 additions & 0 deletions tests/compile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,53 @@ async function fixture(files: Record<string, string>): Promise<string> {
}

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":
Expand Down
46 changes: 46 additions & 0 deletions tests/markdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
`<script>top</script>

Before <b>bold</b> after.

:::note[Remember]
<script>nested</script>

First

<div>removed</div>

Last
:::

<script>end</script>`,
"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: '<p>Before bold after.</p>\n<aside class="mdd-note">\n<p class="mdd-component-label"><strong>Remember</strong></p>\n<p>First</p>\n<p>Last</p>\n</aside>',
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(
Expand Down
Loading