@wgtechlabs/mdd-engine 0.1.0-dev.3c34eeb

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Waren Gonzaga, WG Technology Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,133 @@
1
+ # mdd-engine
2
+
3
+ The headless Markdown documentation compiler behind mdd. Give it a local project; get validated pages, navigation, assets, and article HTML that any frontend can consume.
4
+
5
+ Built with TypeScript and Bun. Runs on Node.js 22, 24, and 26 without Bun. The default is the latest Node LTS, currently pinned to **24.21.0**.
6
+
7
+ > Build Flow's default channels publish development, PR, and manual preview packages alongside regular releases to npm and GitHub Packages. See [build channels and releasing](docs/RELEASING.md).
8
+
9
+ ## A documentation project
10
+
11
+ ```text
12
+ my-project/
13
+ mdd/
14
+ config.json # optional
15
+ contents/
16
+ index.md # required homepage
17
+ get-started/
18
+ installation.md
19
+ assets/
20
+ logo.png
21
+ themes/
22
+ custom/
23
+ theme.css
24
+ theme.js # optional; engine never executes it
25
+ ```
26
+
27
+ Ordinary Markdown works without configuration. Folder names become routes and navigation groups. For example, `get-started/installation.md` becomes `/get-started/installation/`; a folder's `index.md` becomes its landing page.
28
+
29
+ ```json
30
+ {
31
+ "title": "My Project",
32
+ "paths": { "contents": "./contents", "themes": "./themes" },
33
+ "theme": "custom"
34
+ }
35
+ ```
36
+
37
+ All settings are optional. Custom paths are relative to `mdd/config.json` and must stay within the checkout. Selecting a custom theme requires its `theme.css`. The engine returns theme file locations for the website layer to apply. Plugin loading is deferred.
38
+
39
+ ## Compile without a website
40
+
41
+ Install the regular package with `bun add @wgtechlabs/mdd-engine`. After a successful preview publication, use `bun add @wgtechlabs/mdd-engine@dev` for development builds or `bun add @wgtechlabs/mdd-engine@pr` for PRs targeting `dev`. Preview tags track the most recently published package in their channel; install an exact version to test a particular PR. You can also install a locally packed copy.
42
+
43
+ In your Node project:
44
+
45
+ ```js
46
+ import { compileProject } from '@wgtechlabs/mdd-engine';
47
+
48
+ const result = await compileProject({
49
+ projectDir: '/absolute/path/to/my-project',
50
+ mddDir: 'mdd',
51
+ basePath: '/docs/'
52
+ });
53
+
54
+ if (!result.site) {
55
+ console.error(result.diagnostics);
56
+ process.exitCode = 1;
57
+ } else {
58
+ console.log(result.site.pages[0].html);
59
+ }
60
+ ```
61
+
62
+ `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.
63
+
64
+ Excessive Markdown nesting that exceeds the runtime's call stack produces a `CONTENT_TOO_DEEP` diagnostic identifying the source file.
65
+
66
+ The result includes:
67
+
68
+ - `pages`: source, route, public URL, title, description, article HTML, readable Markdown, headings, and navigation metadata.
69
+ - `navigation`: sorted folder/page tree, with optional landing URLs.
70
+ - `assets`: referenced content files with checkout-relative sources, documentation-root-relative destinations, and public URLs.
71
+ - `theme`: the built-in default or selected theme file locations.
72
+
73
+ Paths in the model use forward slashes. Resolve sources against the same `projectDir` used to compile. Asset destinations are filesystem paths, while asset URLs and page routes are URL-encoded. Decode page-route segments when deriving output directories. Prefix asset destinations with the documentation output directory when exporting; do not prefix them with a GitHub repository name a second time. The engine emits no files and starts no server.
74
+
75
+ ## Markdown and components
76
+
77
+ Supported Markdown includes headings, lists, code fences, links, images, GFM tables, task lists, and strikethrough. Optional YAML frontmatter supports `title`, `description`, `navTitle`, and finite numeric `order`:
78
+
79
+ ```markdown
80
+ ---
81
+ title: Install mdd
82
+ navTitle: Installation
83
+ order: 1
84
+ ---
85
+ # Installation
86
+
87
+ > [!NOTE]
88
+ > **Before you start**
89
+ >
90
+ > You need a local documentation project.
91
+
92
+ :::details[More information]
93
+ Ordinary **Markdown** works inside components.
94
+ :::
95
+ ```
96
+
97
+ Use GitHub-style alerts with `NOTE`, `TIP`, `IMPORTANT`, `WARNING`, or `CAUTION`. Put the exact uppercase marker on the opening line of a blockquote at the document root; nested blockquotes remain ordinary quotes. Alerts become semantic `aside` elements with `mdd-alert` and `mdd-<type>` classes. Their readable Markdown preserves the `> [!TYPE]` marker. The engine supplies meaning and labels; the reader composes the article, and themes supply icons and colors.
98
+
99
+ `:::details[More information]` remains supported and becomes `details`/`summary`; its label is optional, attributes are errors, and its readable Markdown uses a blockquote with a bold label. The old `:::note`, `:::tip`, and `:::warning` directives now fail with a `REMOVED_COMPONENT` migration diagnostic. See [alerts and migration](docs/ALERTS.md) for all five types, theme hooks, and how to preserve custom titles and bodies.
100
+
101
+ Title precedence is frontmatter title, first H1, then readable filename. Navigation uses `navTitle` when supplied. Explicit `order` sorts first; remaining siblings sort deterministically by label and path.
102
+
103
+ Local `.md` links, extensionless routes, reference links, and images resolve from their source document. A leading slash addresses the documentation root. `basePath` prefixes public links, including `/docs/` and `/repository/docs/`. When a file-style URL matches both an existing supported asset and a page route, the asset wins: `chart.png` selects the image, while `/chart.png/` explicitly selects the page. If no regular asset exists, dotted page routes still resolve. External links are preserved without network requests. Headings have `mdd-`-prefixed GitHub-style slugs; author links such as `#installation` are rewritten to `#mdd-installation`. Duplicate headings receive `-1`, `-2`, and subsequent suffixes.
104
+
105
+ Reference definitions follow Markdown's first-definition-wins rule. Unused definitions and unreferenced footnotes are omitted from normalized output and do not contribute assets, headings, or link-validation errors. Referenced footnotes, including notes reached through other notes, retain their content and validation.
106
+
107
+ ## Content boundaries
108
+
109
+ Raw HTML, executable URL schemes, path escapes, unknown configuration, missing local targets, and route collisions fail compilation. MDX is not evaluated; expressions are inert Markdown text, and JSX-style HTML is rejected. Hidden/sensitive paths are excluded. Content discovery rejects symlinks to avoid aliases and traversal; configured roots are resolved and checked against the checkout. Source files must remain stable while compilation and export run.
110
+
111
+ Referenced content assets are limited to PNG, JPEG, GIF, WebP, AVIF, ICO, PDF, and plain text. SVG, HTML, JavaScript, and CSS are not content assets in this first version. Custom theme CSS/JavaScript is declared separately and must be treated as trusted by the website layer. Asset bytes are not transformed or sanitized; hosts should serve correct media types with `nosniff` and downloads with suitable disposition.
112
+
113
+ Generated paths `_assets/`, `_mdd/`, `markdown/`, `mdd-build.json`, `llms.txt`, `robots.txt`, `sitemap.xml`, and `404.html` are reserved. Keep generated output outside the content root.
114
+
115
+ A complete [example project](examples/basic/mdd/contents/index.md) is included under `examples/basic`. Pass that directory as `projectDir` to compile it.
116
+
117
+ ## Develop
118
+
119
+ ```sh
120
+ nvm use
121
+ bun install --frozen-lockfile
122
+ bun run check
123
+ bun run coverage
124
+ bun audit
125
+ ```
126
+
127
+ `bun run build` emits Node ESM and TypeScript declarations to `dist/`. `bun run smoke` creates a package archive, installs it into an isolated consumer, and runs it with the current Node binary. It checks that Bun is unavailable inside the consumer and that all three base-path fixtures compile. On POSIX systems, `MDD_TEST_NODE_BINARIES` can contain colon-separated Node binary paths to exercise the same archive across versions.
128
+
129
+ Follow [Clean Workflow](AGENTS.md), [contributing](CONTRIBUTING.md), and the [engine contract](docs/SPEC.md). See [verification](docs/VERIFICATION.md) for the checks performed during bootstrap.
130
+
131
+ ## License
132
+
133
+ Licensed under the [MIT License](LICENSE).
@@ -0,0 +1,8 @@
1
+ import type { Root } from "mdast";
2
+ declare module "mdast" {
3
+ interface TextData {
4
+ mddAlertMarker?: true;
5
+ }
6
+ }
7
+ /** Recognize alerts only at the document root, using unescaped source syntax. */
8
+ export declare function transformAlerts(tree: Root, source: string): void;
package/dist/alerts.js ADDED
@@ -0,0 +1,56 @@
1
+ /** Recognize alerts only at the document root, using unescaped source syntax. */
2
+ export function transformAlerts(tree, source) {
3
+ for (const node of tree.children) {
4
+ if (node.type !== "blockquote")
5
+ continue;
6
+ const paragraph = node.children[0];
7
+ const first = paragraph?.type === "paragraph" ? paragraph.children[0] : undefined;
8
+ const offset = paragraph?.position?.start.offset;
9
+ if (paragraph?.type !== "paragraph" ||
10
+ first?.type !== "text" ||
11
+ offset === undefined ||
12
+ paragraph.position?.start.line !== node.position?.start.line)
13
+ continue;
14
+ const marker = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\][\t ]*(?:\r\n|\r|\n|$)/.exec(source.slice(offset));
15
+ const kind = marker?.[1];
16
+ if (!kind)
17
+ continue;
18
+ first.value = first.value.slice(kind.length + 3);
19
+ const bodyStart = paragraph.children[0];
20
+ if (bodyStart?.type === "text") {
21
+ bodyStart.value = bodyStart.value.replace(/^[\t ]*(?:\r\n|\r|\n)?/, "");
22
+ if (!bodyStart.value)
23
+ paragraph.children.shift();
24
+ }
25
+ if (paragraph.children[0]?.type === "break")
26
+ paragraph.children.shift();
27
+ if (!paragraph.children.length)
28
+ node.children.shift();
29
+ const type = kind.toLowerCase();
30
+ const label = type.charAt(0).toUpperCase() + type.slice(1);
31
+ const markerText = {
32
+ type: "text",
33
+ value: `[!${kind}]`,
34
+ data: { mddAlertMarker: true },
35
+ };
36
+ node.children.unshift({
37
+ type: "paragraph",
38
+ children: [markerText],
39
+ data: {
40
+ hProperties: { className: ["mdd-component-label"] },
41
+ hChildren: [
42
+ {
43
+ type: "element",
44
+ tagName: "strong",
45
+ properties: {},
46
+ children: [{ type: "text", value: label }],
47
+ },
48
+ ],
49
+ },
50
+ });
51
+ node.data = {
52
+ hName: "aside",
53
+ hProperties: { className: ["mdd-alert", `mdd-${type}`] },
54
+ };
55
+ }
56
+ }
@@ -0,0 +1,4 @@
1
+ import type { CompileOptions, CompileResult } from "./types.js";
2
+ export type { Asset, CompileOptions, CompileResult, Diagnostic, Heading, Metadata, NavigationItem, Page, Site, Theme, } from "./types.js";
3
+ /** Compile a local checkout; never fetch repositories, execute author code, or emit files. */
4
+ export declare function compileProject(options: CompileOptions): Promise<CompileResult>;
package/dist/index.js ADDED
@@ -0,0 +1,129 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { resolveLinks } from "./links.js";
3
+ import { parseDocument, renderDocument, } from "./markdown.js";
4
+ import { loadProject, relativeSource } from "./project.js";
5
+ import { navigationFor, normalizeBasePath, publicUrl, routeFor, } from "./routes.js";
6
+ const reserved = new Set([
7
+ "_assets",
8
+ "_mdd",
9
+ "markdown",
10
+ "mdd-build.json",
11
+ "llms.txt",
12
+ "sitemap.xml",
13
+ "robots.txt",
14
+ "404.html",
15
+ ]);
16
+ function nestingDiagnostic(error, file) {
17
+ // V8 reports this error for excessive nesting in both parsing and rendering.
18
+ if (!(error instanceof RangeError) ||
19
+ error.message !== "Maximum call stack size exceeded")
20
+ throw error;
21
+ return {
22
+ severity: "error",
23
+ code: "CONTENT_TOO_DEEP",
24
+ message: "Markdown nesting is too deep; reduce nested blocks or formatting.",
25
+ file,
26
+ };
27
+ }
28
+ /** Compile a local checkout; never fetch repositories, execute author code, or emit files. */
29
+ export async function compileProject(options) {
30
+ const diagnostics = [];
31
+ let basePath;
32
+ try {
33
+ basePath = normalizeBasePath(options.basePath ?? "/");
34
+ }
35
+ catch (error) {
36
+ return {
37
+ diagnostics: [
38
+ {
39
+ severity: "error",
40
+ code: "INVALID_BASE_PATH",
41
+ message: error instanceof Error ? error.message : "Invalid basePath",
42
+ },
43
+ ],
44
+ };
45
+ }
46
+ const project = await loadProject(options, diagnostics);
47
+ if (!project)
48
+ return { diagnostics };
49
+ const sources = [];
50
+ const routes = new Map();
51
+ for (const file of project.files) {
52
+ const relative = relativeSource(project.contentsRoot, file);
53
+ const source = relativeSource(project.root, file);
54
+ const route = routeFor(relative);
55
+ const key = decodeURIComponent(route).normalize("NFC").toLowerCase();
56
+ if (reserved.has(key.split("/")[1] ?? "")) {
57
+ diagnostics.push({
58
+ severity: "error",
59
+ code: "RESERVED_ROUTE",
60
+ message: `Route '${route}' is reserved for generated output`,
61
+ file: source,
62
+ });
63
+ }
64
+ const conflict = routes.get(key);
65
+ if (conflict)
66
+ diagnostics.push({
67
+ severity: "error",
68
+ code: "ROUTE_COLLISION",
69
+ message: `Route '${route}' collides with ${conflict}`,
70
+ file: source,
71
+ });
72
+ routes.set(key, source);
73
+ const text = await readFile(file, "utf8");
74
+ let document;
75
+ try {
76
+ document = parseDocument(text, source, diagnostics);
77
+ }
78
+ catch (error) {
79
+ diagnostics.push(nestingDiagnostic(error, source));
80
+ return { diagnostics };
81
+ }
82
+ sources.push({ file, relative, route, document });
83
+ }
84
+ const assets = await resolveLinks(sources, project, basePath, diagnostics);
85
+ if (diagnostics.some((item) => item.severity === "error"))
86
+ return { diagnostics };
87
+ const pages = [];
88
+ for (const source of sources) {
89
+ const { document } = source;
90
+ let rendered;
91
+ try {
92
+ rendered = await renderDocument(document);
93
+ }
94
+ catch (error) {
95
+ diagnostics.push(nestingDiagnostic(error, relativeSource(project.root, source.file)));
96
+ return { diagnostics };
97
+ }
98
+ pages.push({
99
+ source: relativeSource(project.root, source.file),
100
+ route: source.route,
101
+ url: publicUrl(basePath, source.route),
102
+ title: document.title,
103
+ ...(document.metadata.description !== undefined
104
+ ? { description: document.metadata.description }
105
+ : {}),
106
+ ...rendered,
107
+ headings: document.headings,
108
+ navigation: {
109
+ title: document.metadata.navTitle ?? document.title,
110
+ ...(document.metadata.order !== undefined
111
+ ? { order: document.metadata.order }
112
+ : {}),
113
+ },
114
+ });
115
+ }
116
+ return {
117
+ site: {
118
+ title: project.title ??
119
+ pages.find((page) => page.route === "/")?.title ??
120
+ "Documentation",
121
+ basePath,
122
+ pages,
123
+ navigation: navigationFor(pages, sources.map((source) => source.relative)),
124
+ assets,
125
+ theme: project.theme,
126
+ },
127
+ diagnostics,
128
+ };
129
+ }
@@ -0,0 +1,10 @@
1
+ import type { ParsedDocument } from "./markdown.js";
2
+ import { type Project } from "./project.js";
3
+ import type { Asset, Diagnostic } from "./types.js";
4
+ export interface SourcePage {
5
+ file: string;
6
+ relative: string;
7
+ route: string;
8
+ document: ParsedDocument;
9
+ }
10
+ export declare function resolveLinks(pages: SourcePage[], project: Project, basePath: string, diagnostics: Diagnostic[]): Promise<Asset[]>;
package/dist/links.js ADDED
@@ -0,0 +1,154 @@
1
+ import path from "node:path";
2
+ import { visit } from "unist-util-visit";
3
+ import { AuthoringError, isWithin, relativeSource, safeFile, } from "./project.js";
4
+ import { encodePath, publicUrl } from "./routes.js";
5
+ // Active file formats belong to an explicitly selected theme, never content assets.
6
+ const assetExtensions = new Set([
7
+ ".png",
8
+ ".jpg",
9
+ ".jpeg",
10
+ ".gif",
11
+ ".webp",
12
+ ".avif",
13
+ ".ico",
14
+ ".pdf",
15
+ ".txt",
16
+ ]);
17
+ export async function resolveLinks(pages, project, basePath, diagnostics) {
18
+ const byFile = new Map(pages.map((page) => [page.file, page]));
19
+ const byRoute = new Map(pages.map((page) => [decodeURIComponent(page.route), page]));
20
+ const assets = new Map();
21
+ const destinations = new Map();
22
+ for (const page of pages) {
23
+ const links = [];
24
+ const imageReferences = new Set();
25
+ visit(page.document.tree, (node) => {
26
+ if (node.type === "imageReference")
27
+ imageReferences.add(node.identifier);
28
+ if (node.type === "link" ||
29
+ node.type === "image" ||
30
+ node.type === "definition")
31
+ links.push(node);
32
+ });
33
+ for (const node of links) {
34
+ const isImage = node.type === "image" ||
35
+ (node.type === "definition" && imageReferences.has(node.identifier));
36
+ const url = node.url;
37
+ // Protocol validation is owned by the parser. Do not fetch external targets.
38
+ if (/^[a-z][a-z\d+.-]*:/i.test(url) || url.startsWith("//"))
39
+ continue;
40
+ const location = {
41
+ file: relativeSource(project.root, page.file),
42
+ line: node.position?.start.line,
43
+ column: node.position?.start.column,
44
+ };
45
+ try {
46
+ const hashIndex = url.indexOf("#");
47
+ const beforeHash = hashIndex < 0 ? url : url.slice(0, hashIndex);
48
+ const hash = hashIndex < 0 ? "" : decodeURIComponent(url.slice(hashIndex + 1));
49
+ const queryIndex = beforeHash.indexOf("?");
50
+ const pathname = queryIndex < 0 ? beforeHash : beforeHash.slice(0, queryIndex);
51
+ const query = queryIndex < 0 ? "" : beforeHash.slice(queryIndex);
52
+ const decoded = decodeURIComponent(pathname);
53
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: Local paths cannot include controls.
54
+ if (/[\\\u0000-\u001f\u007f]/u.test(decoded))
55
+ throw new AuthoringError("INVALID_LINK", "Local links cannot contain backslashes or control characters");
56
+ const targetFile = decoded.startsWith("/")
57
+ ? path.resolve(project.contentsRoot, `.${decoded}`)
58
+ : path.resolve(path.dirname(page.file), decoded || path.basename(page.file));
59
+ if (!isWithin(project.contentsRoot, targetFile))
60
+ throw new AuthoringError("PATH_ESCAPE", "Local links must stay inside the content root");
61
+ let target = !decoded ? page : byFile.get(targetFile);
62
+ const extension = path.extname(decoded).toLowerCase();
63
+ let asset = decoded.endsWith("/") ? undefined : assets.get(targetFile);
64
+ let assetExists = asset !== undefined;
65
+ if (!target &&
66
+ !assetExists &&
67
+ !decoded.endsWith("/") &&
68
+ assetExtensions.has(extension)) {
69
+ try {
70
+ await safeFile(project.contentsRoot, targetFile);
71
+ assetExists = true;
72
+ }
73
+ catch (error) {
74
+ // An absent file or directory may still name a dotted page route.
75
+ // Safety and operational errors must never become route fallbacks.
76
+ if (!(error instanceof AuthoringError) ||
77
+ !["PATH_NOT_FOUND", "INVALID_FILE_TYPE"].includes(error.code))
78
+ throw error;
79
+ }
80
+ }
81
+ if (!target && !assetExists) {
82
+ const relative = relativeSource(project.contentsRoot, targetFile);
83
+ const route = relative ? `/${relative.replace(/\/$/, "")}/` : "/";
84
+ target = byRoute.get(route);
85
+ }
86
+ if (target) {
87
+ if (isImage)
88
+ throw new AuthoringError("INVALID_IMAGE", "Images must reference an asset, not a Markdown page");
89
+ let anchor = "";
90
+ if (hash) {
91
+ const heading = target.document.headings.find((item) => item.id === hash || item.id === `mdd-${hash}`);
92
+ if (!heading)
93
+ throw new AuthoringError("MISSING_ANCHOR", `No heading '${hash}' in ${target.relative}`);
94
+ anchor = `#${encodeURIComponent(heading.id)}`;
95
+ }
96
+ node.url = publicUrl(basePath, target.route) + query + anchor;
97
+ continue;
98
+ }
99
+ if (decoded.toLowerCase().endsWith(".md") ||
100
+ decoded.endsWith("/") ||
101
+ !decoded ||
102
+ !path.extname(decoded)) {
103
+ throw new AuthoringError("MISSING_LINK", `No documentation page matches '${url}'`);
104
+ }
105
+ if (!assetExtensions.has(extension)) {
106
+ throw new AuthoringError("UNSUPPORTED_ASSET", `Unsupported asset '${decoded}'. Use a raster image, PDF, or plain text file`);
107
+ }
108
+ if (isImage && [".pdf", ".txt"].includes(extension)) {
109
+ throw new AuthoringError("INVALID_IMAGE", "Images must reference a supported raster image");
110
+ }
111
+ if (!asset) {
112
+ if (!assetExists)
113
+ await safeFile(project.contentsRoot, targetFile);
114
+ const source = relativeSource(project.root, targetFile);
115
+ const destination = `_assets/${relativeSource(project.contentsRoot, targetFile)}`;
116
+ const key = destination.normalize("NFC").toLowerCase();
117
+ const existing = destinations.get(key);
118
+ if (existing && existing !== source)
119
+ throw new AuthoringError("ASSET_COLLISION", `Asset output collides with ${existing}`);
120
+ destinations.set(key, source);
121
+ asset = {
122
+ source,
123
+ destination,
124
+ url: publicUrl(basePath, encodePath(destination)),
125
+ };
126
+ assets.set(targetFile, asset);
127
+ }
128
+ node.url =
129
+ asset.url + query + (hash ? `#${encodeURIComponent(hash)}` : "");
130
+ }
131
+ catch (error) {
132
+ if (error instanceof AuthoringError)
133
+ diagnostics.push({
134
+ severity: "error",
135
+ code: error.code === "PATH_NOT_FOUND" ? "MISSING_ASSET" : error.code,
136
+ message: error.message,
137
+ ...location,
138
+ });
139
+ else if (error instanceof URIError)
140
+ diagnostics.push({
141
+ severity: "error",
142
+ code: "INVALID_LINK",
143
+ message: `Malformed URL encoding in '${url}'`,
144
+ ...location,
145
+ });
146
+ else
147
+ throw new Error(`Unable to resolve '${url}' in ${location.file}`, {
148
+ cause: error,
149
+ });
150
+ }
151
+ }
152
+ }
153
+ return [...assets.values()].sort((a, b) => a.source < b.source ? -1 : a.source > b.source ? 1 : 0);
154
+ }
@@ -0,0 +1,13 @@
1
+ import type { Root } from "mdast";
2
+ import type { Diagnostic, Heading, Metadata } from "./types.js";
3
+ export interface ParsedDocument {
4
+ tree: Root;
5
+ metadata: Metadata;
6
+ title: string;
7
+ headings: Heading[];
8
+ }
9
+ export declare function parseDocument(source: string, file: string, diagnostics: Diagnostic[]): ParsedDocument;
10
+ export declare function renderDocument(document: ParsedDocument): Promise<{
11
+ html: string;
12
+ markdown: string;
13
+ }>;
@@ -0,0 +1,266 @@
1
+ import { basename, extname } from "node:path";
2
+ import GithubSlugger from "github-slugger";
3
+ import { toString as nodeText } from "mdast-util-to-string";
4
+ import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
5
+ import rehypeStringify from "rehype-stringify";
6
+ import remarkDirective from "remark-directive";
7
+ import remarkFrontmatter from "remark-frontmatter";
8
+ import remarkGfm from "remark-gfm";
9
+ import remarkParse from "remark-parse";
10
+ import remarkRehype from "remark-rehype";
11
+ import remarkStringify from "remark-stringify";
12
+ import { unified } from "unified";
13
+ import { SKIP, visit } from "unist-util-visit";
14
+ import { parseDocument as parseYaml } from "yaml";
15
+ import { transformAlerts } from "./alerts.js";
16
+ const parser = unified()
17
+ .use(remarkParse)
18
+ .use(remarkGfm)
19
+ .use(remarkFrontmatter, ["yaml"])
20
+ .use(remarkDirective);
21
+ const htmlRenderer = unified()
22
+ .use(remarkRehype)
23
+ .use(rehypeSanitize, {
24
+ ...defaultSchema,
25
+ // Only generated, mdd-prefixed heading IDs enter the document.
26
+ clobberPrefix: "",
27
+ tagNames: [...(defaultSchema.tagNames ?? []), "aside"],
28
+ attributes: {
29
+ ...defaultSchema.attributes,
30
+ aside: [["className", /^mdd-/]],
31
+ details: [["className", "mdd-details"]],
32
+ p: [
33
+ ...(defaultSchema.attributes?.p ?? []),
34
+ ["className", "mdd-component-label"],
35
+ ],
36
+ summary: [
37
+ ...(defaultSchema.attributes?.summary ?? []),
38
+ ["className", "mdd-component-label"],
39
+ ],
40
+ },
41
+ protocols: {
42
+ ...defaultSchema.protocols,
43
+ href: ["http", "https", "mailto", "tel"],
44
+ src: ["http", "https"],
45
+ },
46
+ })
47
+ .use(rehypeStringify);
48
+ const markdownRenderer = unified()
49
+ .use(remarkGfm)
50
+ .use(remarkStringify, {
51
+ handlers: {
52
+ text(node, _parent, state, info) {
53
+ // Only generated alert markers bypass normal Markdown escaping.
54
+ return node.data?.mddAlertMarker === true
55
+ ? node.value
56
+ : state.safe(node.value, info);
57
+ },
58
+ },
59
+ });
60
+ const removedNotices = new Set(["note", "tip", "warning"]);
61
+ /** Keep the same first-wins, reachable definitions that the HTML renderer uses. */
62
+ function retainReferencedDefinitions(tree) {
63
+ const definitions = new Map();
64
+ const footnotes = new Map();
65
+ visit(tree, (node) => {
66
+ if (node.type === "definition") {
67
+ const id = node.identifier.toUpperCase();
68
+ if (!definitions.has(id))
69
+ definitions.set(id, node);
70
+ }
71
+ else if (node.type === "footnoteDefinition") {
72
+ const id = node.identifier.toUpperCase();
73
+ if (!footnotes.has(id))
74
+ footnotes.set(id, node);
75
+ }
76
+ });
77
+ const usedDefinitions = new Set();
78
+ const usedFootnotes = new Set();
79
+ const pending = [tree];
80
+ for (const content of pending) {
81
+ visit(content, (node, index, parent) => {
82
+ // Definitions are global, even when nested inside an unused footnote.
83
+ // Detach them now and append only reachable definitions after the body.
84
+ if ((node.type === "definition" || node.type === "footnoteDefinition") &&
85
+ parent &&
86
+ index !== undefined) {
87
+ parent.children.splice(index, 1);
88
+ return [SKIP, index];
89
+ }
90
+ if (node.type === "linkReference" || node.type === "imageReference") {
91
+ const definition = definitions.get(node.identifier.toUpperCase());
92
+ if (definition)
93
+ usedDefinitions.add(definition);
94
+ }
95
+ else if (node.type === "footnoteReference") {
96
+ const footnote = footnotes.get(node.identifier.toUpperCase());
97
+ if (footnote && !usedFootnotes.has(footnote)) {
98
+ usedFootnotes.add(footnote);
99
+ pending.push(footnote);
100
+ }
101
+ }
102
+ });
103
+ }
104
+ tree.children.push(...usedDefinitions, ...usedFootnotes);
105
+ }
106
+ function unsafeUrl(url, image) {
107
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: Reject schemes hidden by URL control characters.
108
+ const normalized = url.replace(/[\u0000-\u0020\u007f-\u009f]/g, "");
109
+ const scheme = /^([a-z][a-z\d+.-]*):/i.exec(normalized)?.[1]?.toLowerCase();
110
+ return Boolean(scheme &&
111
+ !(image ? ["http", "https"] : ["http", "https", "mailto", "tel"]).includes(scheme));
112
+ }
113
+ export function parseDocument(source, file, diagnostics) {
114
+ const tree = parser.parse(source);
115
+ transformAlerts(tree, source);
116
+ retainReferencedDefinitions(tree);
117
+ const metadata = {};
118
+ const headings = [];
119
+ const slugger = new GithubSlugger();
120
+ const imageReferences = new Set();
121
+ visit(tree, "imageReference", (node) => {
122
+ imageReferences.add(node.identifier.toLowerCase());
123
+ });
124
+ const report = (code, message, node) => {
125
+ diagnostics.push({
126
+ severity: "error",
127
+ code,
128
+ message,
129
+ file,
130
+ line: node.position?.start.line,
131
+ column: node.position?.start.column,
132
+ });
133
+ };
134
+ const frontmatter = tree.children[0];
135
+ if (frontmatter?.type === "yaml") {
136
+ try {
137
+ const yaml = parseYaml(frontmatter.value);
138
+ if (yaml.errors.length || yaml.warnings.length) {
139
+ throw new Error([...yaml.errors, ...yaml.warnings]
140
+ .map((error) => error.message)
141
+ .join("; "));
142
+ }
143
+ const value = yaml.toJS({ maxAliasCount: 0 });
144
+ if ((value === null && yaml.contents !== null) ||
145
+ (value !== null && (typeof value !== "object" || Array.isArray(value)))) {
146
+ throw new Error("Frontmatter must be a mapping of metadata fields.");
147
+ }
148
+ for (const [key, entry] of Object.entries(value ?? {})) {
149
+ if (key === "order") {
150
+ if (typeof entry !== "number" || !Number.isFinite(entry)) {
151
+ throw new Error("Frontmatter order must be a finite number.");
152
+ }
153
+ metadata.order = entry;
154
+ }
155
+ else if (key === "title" ||
156
+ key === "description" ||
157
+ key === "navTitle") {
158
+ if (typeof entry !== "string" || !entry.trim()) {
159
+ throw new Error(`Frontmatter ${key} must be a nonempty string.`);
160
+ }
161
+ metadata[key] = entry.trim();
162
+ }
163
+ else {
164
+ throw new Error(`Unknown frontmatter field: ${key}.`);
165
+ }
166
+ }
167
+ }
168
+ catch (error) {
169
+ report("INVALID_FRONTMATTER", error instanceof Error ? error.message : "Invalid YAML frontmatter.", frontmatter);
170
+ }
171
+ tree.children.shift();
172
+ }
173
+ visit(tree, (node, index, parent) => {
174
+ if (node.type === "html") {
175
+ report("RAW_HTML", "Raw HTML is not supported; use Markdown or an mdd component.", node);
176
+ if (parent && index !== undefined) {
177
+ parent.children.splice(index, 1);
178
+ return [SKIP, index];
179
+ }
180
+ }
181
+ if ((node.type === "link" ||
182
+ node.type === "image" ||
183
+ node.type === "definition") &&
184
+ unsafeUrl(node.url, node.type === "image" ||
185
+ (node.type === "definition" &&
186
+ imageReferences.has(node.identifier.toLowerCase())))) {
187
+ report("UNSAFE_URL", "Use a relative URL or an allowed HTTP, HTTPS, mailto, or tel URL.", node);
188
+ node.url = "";
189
+ }
190
+ if (node.type === "heading") {
191
+ const text = nodeText(node);
192
+ const id = `mdd-${slugger.slug(text)}`;
193
+ node.data = { ...node.data, hProperties: { id } };
194
+ headings.push({ depth: node.depth, text, id });
195
+ }
196
+ if (node.type !== "containerDirective" &&
197
+ node.type !== "leafDirective" &&
198
+ node.type !== "textDirective") {
199
+ return;
200
+ }
201
+ if (removedNotices.has(node.name)) {
202
+ report("REMOVED_COMPONENT", `The ${node.name} directive was removed; use > [!${node.name.toUpperCase()}] followed by quoted body lines. Keep any optional custom title as bold text in the alert body.`, node);
203
+ return;
204
+ }
205
+ if (node.type !== "containerDirective" || node.name !== "details") {
206
+ report("UNKNOWN_COMPONENT", `Unsupported component ${node.name}; use a GitHub alert or a details container.`, node);
207
+ return;
208
+ }
209
+ if (Object.keys(node.attributes ?? {}).length) {
210
+ report("INVALID_COMPONENT", `The ${node.name} component does not support attributes.`, node);
211
+ }
212
+ if (index === undefined || !parent)
213
+ return;
214
+ const first = node.children[0];
215
+ const defaultLabel = "Details";
216
+ const label = first?.type === "paragraph" && first.data?.directiveLabel
217
+ ? first
218
+ : {
219
+ type: "paragraph",
220
+ children: [
221
+ {
222
+ type: "text",
223
+ value: defaultLabel,
224
+ },
225
+ ],
226
+ };
227
+ if (label !== first)
228
+ node.children.unshift(label);
229
+ if (!nodeText(label).trim()) {
230
+ label.children = [{ type: "text", value: defaultLabel }];
231
+ }
232
+ label.children = [{ type: "strong", children: label.children }];
233
+ label.data = {
234
+ hName: "summary",
235
+ hProperties: { className: ["mdd-component-label"] },
236
+ };
237
+ parent.children[index] = {
238
+ type: "blockquote",
239
+ children: node.children,
240
+ position: node.position,
241
+ data: {
242
+ hName: "details",
243
+ hProperties: { className: ["mdd-details"] },
244
+ },
245
+ };
246
+ // Revisit the replacement so nested content is validated exactly once.
247
+ return [SKIP, index];
248
+ });
249
+ const filename = basename(file, extname(file)).replace(/[-_]+/g, " ");
250
+ const fallbackTitle = filename.charAt(0).toUpperCase() + filename.slice(1);
251
+ return {
252
+ tree,
253
+ metadata,
254
+ title: metadata.title ??
255
+ headings.find((heading) => heading.depth === 1)?.text ??
256
+ fallbackTitle,
257
+ headings,
258
+ };
259
+ }
260
+ export async function renderDocument(document) {
261
+ const htmlTree = await htmlRenderer.run(document.tree);
262
+ return {
263
+ html: String(htmlRenderer.stringify(htmlTree)),
264
+ markdown: String(markdownRenderer.stringify(document.tree)),
265
+ };
266
+ }
@@ -0,0 +1,16 @@
1
+ import type { CompileOptions, Diagnostic, Theme } from "./types.js";
2
+ export interface Project {
3
+ root: string;
4
+ contentsRoot: string;
5
+ title?: string;
6
+ theme: Theme;
7
+ files: string[];
8
+ }
9
+ export declare class AuthoringError extends Error {
10
+ readonly code: string;
11
+ constructor(code: string, message: string);
12
+ }
13
+ export declare function relativeSource(root: string, file: string): string;
14
+ export declare function isWithin(root: string, file: string): boolean;
15
+ export declare function safeFile(root: string, file: string): Promise<string>;
16
+ export declare function loadProject(options: CompileOptions, diagnostics: Diagnostic[]): Promise<Project | undefined>;
@@ -0,0 +1,252 @@
1
+ import { lstat, readdir, readFile, realpath, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ export class AuthoringError extends Error {
4
+ code;
5
+ constructor(code, message) {
6
+ super(message);
7
+ this.code = code;
8
+ this.name = "AuthoringError";
9
+ }
10
+ }
11
+ export function relativeSource(root, file) {
12
+ return path.relative(root, file).split(path.sep).join("/");
13
+ }
14
+ export function isWithin(root, file) {
15
+ const relative = path.relative(root, file);
16
+ return (relative !== ".." &&
17
+ !relative.startsWith(`..${path.sep}`) &&
18
+ !path.isAbsolute(relative));
19
+ }
20
+ function protectedName(name) {
21
+ return (name.startsWith(".") ||
22
+ /^(?:node_modules|credentials?(?:\..*)?|secrets?(?:\..*)?|id_(?:rsa|dsa|ecdsa|ed25519)(?:\..*)?)$/i.test(name) ||
23
+ /\.(?:pem|key|p12|pfx)$/i.test(name));
24
+ }
25
+ function assertPath(root, file) {
26
+ if (!isWithin(root, file)) {
27
+ throw new AuthoringError("PATH_OUTSIDE_PROJECT", `Path escapes its allowed root: ${file}`);
28
+ }
29
+ if (relativeSource(root, file).split("/").some(protectedName)) {
30
+ throw new AuthoringError("PROTECTED_PATH", `Hidden or sensitive paths cannot be used: ${file}`);
31
+ }
32
+ }
33
+ function missing(error) {
34
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
35
+ }
36
+ async function checkedPath(root, file, allowMissing = false) {
37
+ const absolute = path.resolve(root, file);
38
+ assertPath(root, absolute);
39
+ let resolved;
40
+ try {
41
+ resolved = await realpath(absolute);
42
+ }
43
+ catch (error) {
44
+ if (error instanceof Error && "code" in error) {
45
+ if (error.code === "ELOOP")
46
+ throw new AuthoringError("SYMLINK_CYCLE", `Path contains a symlink cycle: ${absolute}`);
47
+ if (error.code === "ENOTDIR")
48
+ throw new AuthoringError("INVALID_DIRECTORY", `A path component is not a directory: ${absolute}`);
49
+ }
50
+ if (missing(error)) {
51
+ if (!allowMissing || absolute === root) {
52
+ throw new AuthoringError("PATH_NOT_FOUND", `Path does not exist: ${absolute}`);
53
+ }
54
+ const entry = await lstat(absolute).catch((inspectionError) => {
55
+ if (missing(inspectionError))
56
+ return undefined;
57
+ throw new Error(`Cannot inspect path ${absolute}`, {
58
+ cause: inspectionError,
59
+ });
60
+ });
61
+ if (entry?.isSymbolicLink())
62
+ throw new AuthoringError("BROKEN_SYMLINK", `Path is a broken symlink: ${absolute}`);
63
+ // Resolve existing ancestors too: an absent leaf must not conceal a symlink escape.
64
+ resolved = path.join(await checkedPath(root, path.dirname(absolute), true), path.basename(absolute));
65
+ }
66
+ else {
67
+ throw new Error(`Cannot resolve path ${absolute}`, { cause: error });
68
+ }
69
+ }
70
+ assertPath(root, resolved);
71
+ return resolved;
72
+ }
73
+ export async function safeFile(root, file) {
74
+ const resolved = await checkedPath(root, file);
75
+ try {
76
+ if (!(await stat(resolved)).isFile()) {
77
+ throw new AuthoringError("INVALID_FILE_TYPE", `Expected a regular file: ${file}`);
78
+ }
79
+ }
80
+ catch (error) {
81
+ if (error instanceof AuthoringError)
82
+ throw error;
83
+ if (missing(error))
84
+ throw new AuthoringError("PATH_NOT_FOUND", `File does not exist: ${file}`);
85
+ throw new Error(`Cannot inspect file ${file}`, { cause: error });
86
+ }
87
+ return resolved;
88
+ }
89
+ async function directory(root, file, optional = false) {
90
+ const resolved = await checkedPath(root, file, optional);
91
+ try {
92
+ if (!(await stat(resolved)).isDirectory()) {
93
+ throw new AuthoringError("INVALID_DIRECTORY", `Expected a directory: ${file}`);
94
+ }
95
+ }
96
+ catch (error) {
97
+ if (error instanceof AuthoringError)
98
+ throw error;
99
+ if (optional && missing(error))
100
+ return resolved;
101
+ throw new Error(`Cannot inspect directory ${file}`, { cause: error });
102
+ }
103
+ return resolved;
104
+ }
105
+ function object(value, label, fields) {
106
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
107
+ throw new AuthoringError("INVALID_CONFIG", `${label} must be an object.`);
108
+ }
109
+ const record = value;
110
+ for (const key of Object.keys(record)) {
111
+ if (!fields.includes(key))
112
+ throw new AuthoringError("UNKNOWN_CONFIG_FIELD", `Unsupported ${label} field: ${key}`);
113
+ }
114
+ return record;
115
+ }
116
+ function text(value, label) {
117
+ if (typeof value !== "string" || !value.trim() || value.includes("\0")) {
118
+ throw new AuthoringError("INVALID_CONFIG", `${label} must be a non-empty string.`);
119
+ }
120
+ return value;
121
+ }
122
+ function relativePath(value, label) {
123
+ const result = text(value, label);
124
+ if (path.isAbsolute(result) ||
125
+ path.win32.isAbsolute(result) ||
126
+ result.includes("\\")) {
127
+ throw new AuthoringError("INVALID_CONFIG_PATH", `${label} must be a relative path using forward slashes.`);
128
+ }
129
+ return result;
130
+ }
131
+ async function optionalFile(root, file) {
132
+ try {
133
+ await lstat(file);
134
+ }
135
+ catch (error) {
136
+ if (missing(error))
137
+ return undefined;
138
+ throw new Error(`Cannot inspect optional file ${file}`, { cause: error });
139
+ }
140
+ return safeFile(root, file);
141
+ }
142
+ async function scan(root) {
143
+ const files = [];
144
+ async function walk(directory) {
145
+ const entries = await readdir(directory, { withFileTypes: true }).catch((error) => {
146
+ throw new Error(`Cannot read content directory ${directory}`, {
147
+ cause: error,
148
+ });
149
+ });
150
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
151
+ for (const entry of entries) {
152
+ if (protectedName(entry.name))
153
+ continue;
154
+ const file = path.join(directory, entry.name);
155
+ // Reject aliases during discovery so routes are unambiguous and directory cycles impossible.
156
+ if (entry.isSymbolicLink()) {
157
+ throw new AuthoringError("CONTENT_SYMLINK", `Content discovery does not follow symlinks: ${file}`);
158
+ }
159
+ if (entry.isDirectory())
160
+ await walk(file);
161
+ else if (path.extname(entry.name).toLowerCase() === ".md")
162
+ files.push(await safeFile(root, file));
163
+ }
164
+ }
165
+ await walk(root);
166
+ return files.sort();
167
+ }
168
+ export async function loadProject(options, diagnostics) {
169
+ let source;
170
+ try {
171
+ const root = await realpath(options.projectDir).catch((error) => {
172
+ if (missing(error))
173
+ throw new AuthoringError("PROJECT_NOT_FOUND", `Project directory does not exist: ${options.projectDir}`);
174
+ throw new Error(`Cannot resolve project directory ${options.projectDir}`, { cause: error });
175
+ });
176
+ const mddRoot = await directory(root, relativePath(options.mddDir ?? "mdd", "mddDir"));
177
+ const configPath = path.join(mddRoot, "config.json");
178
+ source = relativeSource(root, configPath);
179
+ const configFile = await optionalFile(root, configPath);
180
+ let config = {};
181
+ if (configFile) {
182
+ const json = await readFile(configFile, "utf8").catch((error) => {
183
+ throw new Error(`Cannot read configuration ${configFile}`, {
184
+ cause: error,
185
+ });
186
+ });
187
+ let parsed;
188
+ try {
189
+ parsed = JSON.parse(json);
190
+ }
191
+ catch {
192
+ throw new AuthoringError("INVALID_CONFIG_JSON", "config.json must contain valid JSON.");
193
+ }
194
+ config = object(parsed, "config", ["title", "paths", "theme"]);
195
+ }
196
+ const title = config.title === undefined ? undefined : text(config.title, "title");
197
+ const paths = config.paths === undefined
198
+ ? {}
199
+ : object(config.paths, "paths", ["contents", "themes"]);
200
+ const contentsRoot = await directory(root, path.resolve(mddRoot, relativePath(paths.contents === undefined ? "contents" : paths.contents, "paths.contents")));
201
+ const themesRoot = await directory(root, path.resolve(mddRoot, relativePath(paths.themes === undefined ? "themes" : paths.themes, "paths.themes")), true);
202
+ if (contentsRoot === root || themesRoot === root) {
203
+ throw new AuthoringError("INVALID_CONTENT_ROOT", "Content and theme paths must select a directory inside the project, not the whole checkout.");
204
+ }
205
+ if (isWithin(contentsRoot, themesRoot) ||
206
+ isWithin(themesRoot, contentsRoot)) {
207
+ throw new AuthoringError("OVERLAPPING_ROOTS", "Content and theme directories must not overlap.");
208
+ }
209
+ const name = text(config.theme === undefined ? "default" : config.theme, "theme");
210
+ if (name === "." ||
211
+ name === ".." ||
212
+ name.includes("/") ||
213
+ name.includes("\\") ||
214
+ protectedName(name)) {
215
+ throw new AuthoringError("INVALID_THEME", "theme must name a single, non-hidden folder inside the themes directory.");
216
+ }
217
+ let theme = { name: "default" };
218
+ if (name !== "default") {
219
+ const themeRoot = await directory(root, path.join(themesRoot, name));
220
+ if (!isWithin(themesRoot, themeRoot))
221
+ throw new AuthoringError("PATH_OUTSIDE_PROJECT", "Selected theme escapes the themes directory.");
222
+ const css = await safeFile(themeRoot, path.join(themeRoot, "theme.css"));
223
+ const js = await optionalFile(themeRoot, path.join(themeRoot, "theme.js"));
224
+ theme = {
225
+ name,
226
+ directory: relativeSource(root, themeRoot),
227
+ css: relativeSource(root, css),
228
+ ...(js ? { js: relativeSource(root, js) } : {}),
229
+ };
230
+ }
231
+ source = relativeSource(root, contentsRoot);
232
+ const files = await scan(contentsRoot);
233
+ if (!files.includes(path.join(contentsRoot, "index.md"))) {
234
+ source = relativeSource(root, path.join(contentsRoot, "index.md"));
235
+ throw new AuthoringError("MISSING_HOME", "The content root must contain an index.md homepage.");
236
+ }
237
+ if (diagnostics.some((diagnostic) => diagnostic.severity === "error"))
238
+ return undefined;
239
+ return { root, contentsRoot, title, theme, files };
240
+ }
241
+ catch (error) {
242
+ if (!(error instanceof AuthoringError))
243
+ throw error;
244
+ diagnostics.push({
245
+ severity: "error",
246
+ code: error.code,
247
+ message: error.message,
248
+ ...(source ? { file: source } : {}),
249
+ });
250
+ return undefined;
251
+ }
252
+ }
@@ -0,0 +1,7 @@
1
+ import type { NavigationItem, Page } from "./types.js";
2
+ export declare function encodePath(value: string): string;
3
+ export declare function normalizeBasePath(value: string): string;
4
+ export declare function routeFor(source: string): string;
5
+ export declare function publicUrl(basePath: string, route: string): string;
6
+ export declare function readableName(name: string): string;
7
+ export declare function navigationFor(pages: Page[], relativeFiles: string[]): NavigationItem[];
package/dist/routes.js ADDED
@@ -0,0 +1,89 @@
1
+ import path from "node:path";
2
+ export function encodePath(value) {
3
+ return value.split("/").map(encodeURIComponent).join("/");
4
+ }
5
+ export function normalizeBasePath(value) {
6
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: Reject control characters at the URL boundary.
7
+ const invalidPath = /[?#\\\u0000-\u0020]/u.test(value);
8
+ if (invalidPath || value.includes(":") || value.startsWith("//")) {
9
+ throw new Error("basePath must be a URL path without a host, query, fragment, or whitespace");
10
+ }
11
+ const parts = value.split("/").filter(Boolean);
12
+ const decoded = parts.map((part) => decodeURIComponent(part));
13
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: Reject decoded controls and path separators.
14
+ const invalidSegment = /[/\\\u0000-\u0020]/u;
15
+ if (decoded.some((part) => part === "." || part === ".." || invalidSegment.test(part))) {
16
+ throw new Error("basePath must not contain traversal or encoded separators");
17
+ }
18
+ return parts.length ? `/${decoded.map(encodeURIComponent).join("/")}/` : "/";
19
+ }
20
+ export function routeFor(source) {
21
+ const stem = source.slice(0, -3);
22
+ const route = stem === "index" ? "" : stem.replace(/\/index$/, "");
23
+ return route ? `/${encodePath(route)}/` : "/";
24
+ }
25
+ export function publicUrl(basePath, route) {
26
+ return basePath + route.replace(/^\//, "");
27
+ }
28
+ export function readableName(name) {
29
+ return name
30
+ .replace(/[-_]/g, " ")
31
+ .replace(/^./u, (char) => char.toUpperCase());
32
+ }
33
+ export function navigationFor(pages, relativeFiles) {
34
+ const root = { key: "", title: "", children: new Map() };
35
+ pages.forEach((page, index) => {
36
+ const source = relativeFiles[index];
37
+ if (!source)
38
+ return;
39
+ const segments = source.split("/");
40
+ const file = segments.pop();
41
+ let parent = root;
42
+ for (const folder of segments) {
43
+ let group = parent.children.get(folder);
44
+ if (!group) {
45
+ group = {
46
+ key: folder,
47
+ title: readableName(folder),
48
+ children: new Map(),
49
+ };
50
+ parent.children.set(folder, group);
51
+ }
52
+ parent = group;
53
+ }
54
+ if (file?.slice(0, -3) === "index" && parent !== root) {
55
+ parent.page = page;
56
+ parent.title = page.navigation.title;
57
+ }
58
+ else {
59
+ const key = source === "index.md"
60
+ ? ""
61
+ : path.posix.basename(source, path.posix.extname(source));
62
+ parent.children.set(key, {
63
+ key,
64
+ title: page.navigation.title,
65
+ page,
66
+ children: new Map(),
67
+ });
68
+ }
69
+ });
70
+ function sorted(entry) {
71
+ return [...entry.children.values()]
72
+ .sort((a, b) => {
73
+ const order = (a.page?.navigation.order ?? Infinity) -
74
+ (b.page?.navigation.order ?? Infinity);
75
+ if (order && !Number.isNaN(order))
76
+ return order;
77
+ return compare(a.title, b.title) || compare(a.key, b.key);
78
+ })
79
+ .map((child) => ({
80
+ title: child.title,
81
+ ...(child.page ? { route: child.page.route, url: child.page.url } : {}),
82
+ ...(child.children.size ? { children: sorted(child) } : {}),
83
+ }));
84
+ }
85
+ return sorted(root);
86
+ }
87
+ function compare(a, b) {
88
+ return a < b ? -1 : a > b ? 1 : 0;
89
+ }
@@ -0,0 +1,76 @@
1
+ export interface Diagnostic {
2
+ severity: "error" | "warning";
3
+ code: string;
4
+ message: string;
5
+ file?: string;
6
+ line?: number;
7
+ column?: number;
8
+ }
9
+ export interface Metadata {
10
+ title?: string;
11
+ description?: string;
12
+ navTitle?: string;
13
+ order?: number;
14
+ }
15
+ export interface Heading {
16
+ depth: number;
17
+ text: string;
18
+ id: string;
19
+ }
20
+ export interface Page {
21
+ /** POSIX path relative to projectDir. */
22
+ source: string;
23
+ /** Encoded, trailing-slash path relative to the documentation root. */
24
+ route: string;
25
+ url: string;
26
+ title: string;
27
+ description?: string;
28
+ html: string;
29
+ markdown: string;
30
+ headings: Heading[];
31
+ navigation: {
32
+ title: string;
33
+ order?: number;
34
+ };
35
+ }
36
+ export interface NavigationItem {
37
+ title: string;
38
+ route?: string;
39
+ url?: string;
40
+ children?: NavigationItem[];
41
+ }
42
+ export interface Asset {
43
+ /** POSIX path relative to projectDir; consumers resolve against the same checkout. */
44
+ source: string;
45
+ /** Filesystem output path relative to the documentation root; not URL-encoded. */
46
+ destination: string;
47
+ url: string;
48
+ }
49
+ export type Theme = {
50
+ name: "default";
51
+ } | {
52
+ name: string;
53
+ directory: string;
54
+ css: string;
55
+ js?: string;
56
+ };
57
+ export interface Site {
58
+ title: string;
59
+ basePath: string;
60
+ pages: Page[];
61
+ navigation: NavigationItem[];
62
+ assets: Asset[];
63
+ theme: Theme;
64
+ }
65
+ export interface CompileOptions {
66
+ projectDir: string;
67
+ mddDir?: string;
68
+ basePath?: string;
69
+ }
70
+ export type CompileResult = {
71
+ site: Site;
72
+ diagnostics: Diagnostic[];
73
+ } | {
74
+ site?: undefined;
75
+ diagnostics: Diagnostic[];
76
+ };
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@wgtechlabs/mdd-engine",
3
+ "version": "0.1.0-dev.3c34eeb",
4
+ "description": "Headless Markdown documentation compiler for mdd",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/wgtechlabs/mdd-engine.git"
10
+ },
11
+ "homepage": "https://github.com/wgtechlabs/mdd-engine#readme",
12
+ "bugs": "https://github.com/wgtechlabs/mdd-engine/issues",
13
+ "engines": {
14
+ "node": ">=22.0.0"
15
+ },
16
+ "packageManager": "bun@1.3.10",
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "import": "./dist/index.js"
21
+ }
22
+ },
23
+ "types": "./dist/index.d.ts",
24
+ "files": [
25
+ "dist",
26
+ "README.md",
27
+ "LICENSE"
28
+ ],
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "scripts": {
33
+ "lint": "biome check src tests scripts package.json tsconfig.json tsconfig.build.json biome.json",
34
+ "format": "biome check --write src tests scripts package.json tsconfig.json tsconfig.build.json biome.json",
35
+ "typecheck": "tsc --noEmit",
36
+ "test": "bun test",
37
+ "coverage": "bun test --coverage",
38
+ "build": "tsc -p tsconfig.build.json",
39
+ "smoke": "node scripts/smoke.mjs",
40
+ "check": "bun run lint && bun run typecheck && bun test && bun run build && bun run smoke"
41
+ },
42
+ "dependencies": {
43
+ "github-slugger": "2.0.0",
44
+ "mdast-util-to-string": "4.0.0",
45
+ "rehype-sanitize": "6.0.0",
46
+ "rehype-stringify": "10.0.1",
47
+ "remark-directive": "4.0.0",
48
+ "remark-frontmatter": "5.0.0",
49
+ "remark-gfm": "4.0.1",
50
+ "remark-parse": "11.0.0",
51
+ "remark-rehype": "11.1.2",
52
+ "remark-stringify": "11.0.0",
53
+ "unified": "11.0.5",
54
+ "unist-util-visit": "5.1.0",
55
+ "yaml": "2.9.1"
56
+ },
57
+ "devDependencies": {
58
+ "@biomejs/biome": "2.5.15",
59
+ "@types/bun": "1.4.2",
60
+ "@types/hast": "3.0.5",
61
+ "@types/mdast": "4.0.4",
62
+ "@types/node": "22",
63
+ "typescript": "7.0.2"
64
+ }
65
+ }