@wgtechlabs/mdd-engine 0.1.0 → 0.1.1-dev.51a1fca
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/README.md +45 -7
- package/dist/alerts.d.ts +8 -0
- package/dist/alerts.js +56 -0
- package/dist/footer.d.ts +3 -0
- package/dist/footer.js +82 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +46 -3
- package/dist/links.js +20 -25
- package/dist/markdown.d.ts +2 -0
- package/dist/markdown.js +35 -19
- package/dist/project.d.ts +1 -2
- package/dist/project.js +18 -3
- package/dist/search-index.d.ts +4 -0
- package/dist/search-index.js +67 -0
- package/dist/search.d.ts +33 -0
- package/dist/search.js +153 -0
- package/dist/types.d.ts +10 -0
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ The headless Markdown documentation compiler behind mdd. Give it a local project
|
|
|
4
4
|
|
|
5
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
6
|
|
|
7
|
-
>
|
|
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
8
|
|
|
9
9
|
## A documentation project
|
|
10
10
|
|
|
@@ -12,6 +12,7 @@ Built with TypeScript and Bun. Runs on Node.js 22, 24, and 26 without Bun. The d
|
|
|
12
12
|
my-project/
|
|
13
13
|
mdd/
|
|
14
14
|
config.json # optional
|
|
15
|
+
footer.md # optional shared social links
|
|
15
16
|
contents/
|
|
16
17
|
index.md # required homepage
|
|
17
18
|
get-started/
|
|
@@ -38,7 +39,9 @@ All settings are optional. Custom paths are relative to `mdd/config.json` and mu
|
|
|
38
39
|
|
|
39
40
|
## Compile without a website
|
|
40
41
|
|
|
41
|
-
After
|
|
42
|
+
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.
|
|
43
|
+
|
|
44
|
+
In your Node project:
|
|
42
45
|
|
|
43
46
|
```js
|
|
44
47
|
import { compileProject } from '@wgtechlabs/mdd-engine';
|
|
@@ -59,15 +62,47 @@ if (!result.site) {
|
|
|
59
62
|
|
|
60
63
|
`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.
|
|
61
64
|
|
|
65
|
+
Excessive Markdown nesting that exceeds the runtime's call stack produces a `CONTENT_TOO_DEEP` diagnostic identifying the source file.
|
|
66
|
+
|
|
62
67
|
The result includes:
|
|
63
68
|
|
|
64
69
|
- `pages`: source, route, public URL, title, description, article HTML, readable Markdown, headings, and navigation metadata.
|
|
65
70
|
- `navigation`: sorted folder/page tree, with optional landing URLs.
|
|
66
71
|
- `assets`: referenced content files with checkout-relative sources, documentation-root-relative destinations, and public URLs.
|
|
67
72
|
- `theme`: the built-in default or selected theme file locations.
|
|
73
|
+
- `footer`: optional shared footer source and validated social-link data.
|
|
68
74
|
|
|
69
75
|
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.
|
|
70
76
|
|
|
77
|
+
## Headless search
|
|
78
|
+
|
|
79
|
+
Build a serializable index from a successful compilation:
|
|
80
|
+
|
|
81
|
+
```js
|
|
82
|
+
import { createSearchIndex } from '@wgtechlabs/mdd-engine';
|
|
83
|
+
import { search } from '@wgtechlabs/mdd-engine/search';
|
|
84
|
+
|
|
85
|
+
const index = createSearchIndex(result.site);
|
|
86
|
+
const restored = JSON.parse(JSON.stringify(index));
|
|
87
|
+
console.log(search(restored, 'installation', { limit: 10 }));
|
|
88
|
+
// [{ title, url, section?, excerpt, score }]
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The dependency-free `/search` entry point works in browsers and Node. Results reuse compiled page URLs and heading anchors, respect the public base path, and contain plain text for the consuming interface to display safely. MDD owns exporting/loading the index and presenting search controls. See [search matching, limits, and validation](docs/SEARCH.md).
|
|
92
|
+
|
|
93
|
+
## Shared social footer
|
|
94
|
+
|
|
95
|
+
Write social links once in `mdd/footer.md`:
|
|
96
|
+
|
|
97
|
+
```markdown
|
|
98
|
+
:::socials
|
|
99
|
+
- [GitHub](https://github.com/wgtechlabs)
|
|
100
|
+
- [Community](https://example.org/community)
|
|
101
|
+
:::
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The engine returns these as `site.footer.socials`, with plain labels and validated absolute HTTPS URLs. It does not render a footer or select icons. MDD composes the main footer and its theme styles it. This file is excluded from pages, navigation, and search; `socials` is not an article component. See the [footer format and boundaries](docs/FOOTER.md).
|
|
105
|
+
|
|
71
106
|
## Markdown and components
|
|
72
107
|
|
|
73
108
|
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`:
|
|
@@ -80,16 +115,19 @@ order: 1
|
|
|
80
115
|
---
|
|
81
116
|
# Installation
|
|
82
117
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
118
|
+
> [!NOTE]
|
|
119
|
+
> **Before you start**
|
|
120
|
+
>
|
|
121
|
+
> You need a local documentation project.
|
|
86
122
|
|
|
87
123
|
:::details[More information]
|
|
88
124
|
Ordinary **Markdown** works inside components.
|
|
89
125
|
:::
|
|
90
126
|
```
|
|
91
127
|
|
|
92
|
-
Use `
|
|
128
|
+
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.
|
|
129
|
+
|
|
130
|
+
`:::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.
|
|
93
131
|
|
|
94
132
|
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.
|
|
95
133
|
|
|
@@ -117,7 +155,7 @@ bun run coverage
|
|
|
117
155
|
bun audit
|
|
118
156
|
```
|
|
119
157
|
|
|
120
|
-
`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
|
|
158
|
+
`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 compilation, footer data, and serialized search at all three base paths without Bun in the runtime, and bundles the isolated search entry for browsers. On POSIX systems, `MDD_TEST_NODE_BINARIES` can contain colon-separated Node binary paths to exercise the same archive across versions.
|
|
121
159
|
|
|
122
160
|
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.
|
|
123
161
|
|
package/dist/alerts.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Root } from "mdast";
|
|
2
|
+
declare module "mdast" {
|
|
3
|
+
interface TextData {
|
|
4
|
+
mddAlertLabel?: string;
|
|
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: { mddAlertLabel: label },
|
|
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
|
+
}
|
package/dist/footer.d.ts
ADDED
package/dist/footer.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { parseMarkdown } from "./markdown.js";
|
|
2
|
+
function socialUrl(value) {
|
|
3
|
+
// Reject URL normalization that could hide a scheme, credentials, or controls.
|
|
4
|
+
if (!/^https:\/\/[^/?#]/i.test(value) || /[\s\\\p{Cc}\p{Cf}]/u.test(value))
|
|
5
|
+
return undefined;
|
|
6
|
+
try {
|
|
7
|
+
const url = new URL(value);
|
|
8
|
+
if (url.protocol !== "https:" ||
|
|
9
|
+
!url.hostname ||
|
|
10
|
+
url.username ||
|
|
11
|
+
url.password)
|
|
12
|
+
return undefined;
|
|
13
|
+
return url.href;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** The shared footer is data, never an article or executable author markup. */
|
|
20
|
+
export function parseFooter(source, file, diagnostics) {
|
|
21
|
+
if (!source.trim())
|
|
22
|
+
return undefined;
|
|
23
|
+
const tree = parseMarkdown(source);
|
|
24
|
+
const report = (code, message, node) => {
|
|
25
|
+
diagnostics.push({
|
|
26
|
+
severity: "error",
|
|
27
|
+
code,
|
|
28
|
+
message,
|
|
29
|
+
file,
|
|
30
|
+
line: node.position?.start.line,
|
|
31
|
+
column: node.position?.start.column,
|
|
32
|
+
});
|
|
33
|
+
};
|
|
34
|
+
const component = tree.children[0];
|
|
35
|
+
if (tree.children.length !== 1 ||
|
|
36
|
+
component?.type !== "containerDirective" ||
|
|
37
|
+
component.name !== "socials" ||
|
|
38
|
+
Object.keys(component.attributes ?? {}).length) {
|
|
39
|
+
report("INVALID_FOOTER", "Use a single :::socials block without attributes in footer.md.", component ?? tree);
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
const list = component.children[0];
|
|
43
|
+
if (component.children.length !== 1 ||
|
|
44
|
+
list?.type !== "list" ||
|
|
45
|
+
list.ordered ||
|
|
46
|
+
!list.children.length) {
|
|
47
|
+
report("INVALID_FOOTER", "The socials block must contain an unordered list of labeled Markdown links.", list ?? component);
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
const socials = [];
|
|
51
|
+
for (const item of list.children) {
|
|
52
|
+
const paragraph = item.children[0];
|
|
53
|
+
const link = paragraph?.type === "paragraph" ? paragraph.children[0] : undefined;
|
|
54
|
+
if (item.children.length !== 1 ||
|
|
55
|
+
item.checked != null ||
|
|
56
|
+
paragraph?.type !== "paragraph" ||
|
|
57
|
+
paragraph.children.length !== 1 ||
|
|
58
|
+
link?.type !== "link" ||
|
|
59
|
+
!link.children.length ||
|
|
60
|
+
link.children.some((child) => child.type !== "text")) {
|
|
61
|
+
report("INVALID_SOCIAL_LINK", "Each item must be one Markdown link with a plain text label, without tasks or nested content.", item);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const label = link.children
|
|
65
|
+
.map((child) => (child.type === "text" ? child.value : ""))
|
|
66
|
+
.join("")
|
|
67
|
+
.trim();
|
|
68
|
+
if (!label) {
|
|
69
|
+
report("INVALID_SOCIAL_LINK", "Social links need a nonempty label.", link);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const url = socialUrl(link.url);
|
|
73
|
+
if (!url) {
|
|
74
|
+
report("UNSAFE_SOCIAL_URL", "Social links must use absolute HTTPS URLs without credentials, whitespace, controls, or backslashes.", link);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
socials.push({ label, url });
|
|
78
|
+
}
|
|
79
|
+
return socials.length === list.children.length
|
|
80
|
+
? { source: file, socials }
|
|
81
|
+
: undefined;
|
|
82
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { CompileOptions, CompileResult } from "./types.js";
|
|
2
|
-
export
|
|
2
|
+
export { type SearchIndex, type SearchOptions, type SearchPage, type SearchResult, type SearchSection, search, validateSearchIndex, } from "./search.js";
|
|
3
|
+
export { createSearchIndex } from "./search-index.js";
|
|
4
|
+
export type { Asset, CompileOptions, CompileResult, Diagnostic, Footer, Heading, Metadata, NavigationItem, Page, Site, SocialLink, Theme, } from "./types.js";
|
|
3
5
|
/** Compile a local checkout; never fetch repositories, execute author code, or emit files. */
|
|
4
6
|
export declare function compileProject(options: CompileOptions): Promise<CompileResult>;
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { parseFooter } from "./footer.js";
|
|
2
3
|
import { resolveLinks } from "./links.js";
|
|
3
|
-
import { parseDocument, renderDocument } from "./markdown.js";
|
|
4
|
+
import { parseDocument, renderDocument, } from "./markdown.js";
|
|
4
5
|
import { loadProject, relativeSource } from "./project.js";
|
|
5
6
|
import { navigationFor, normalizeBasePath, publicUrl, routeFor, } from "./routes.js";
|
|
7
|
+
export { search, validateSearchIndex, } from "./search.js";
|
|
8
|
+
export { createSearchIndex } from "./search-index.js";
|
|
6
9
|
const reserved = new Set([
|
|
7
10
|
"_assets",
|
|
8
11
|
"_mdd",
|
|
@@ -13,6 +16,18 @@ const reserved = new Set([
|
|
|
13
16
|
"robots.txt",
|
|
14
17
|
"404.html",
|
|
15
18
|
]);
|
|
19
|
+
function nestingDiagnostic(error, file) {
|
|
20
|
+
// V8 reports this error for excessive nesting in both parsing and rendering.
|
|
21
|
+
if (!(error instanceof RangeError) ||
|
|
22
|
+
error.message !== "Maximum call stack size exceeded")
|
|
23
|
+
throw error;
|
|
24
|
+
return {
|
|
25
|
+
severity: "error",
|
|
26
|
+
code: "CONTENT_TOO_DEEP",
|
|
27
|
+
message: "Markdown nesting is too deep; reduce nested blocks or formatting.",
|
|
28
|
+
file,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
16
31
|
/** Compile a local checkout; never fetch repositories, execute author code, or emit files. */
|
|
17
32
|
export async function compileProject(options) {
|
|
18
33
|
const diagnostics = [];
|
|
@@ -34,6 +49,18 @@ export async function compileProject(options) {
|
|
|
34
49
|
const project = await loadProject(options, diagnostics);
|
|
35
50
|
if (!project)
|
|
36
51
|
return { diagnostics };
|
|
52
|
+
let footer;
|
|
53
|
+
if (project.footer) {
|
|
54
|
+
const source = relativeSource(project.root, project.footer);
|
|
55
|
+
const text = await readFile(project.footer, "utf8");
|
|
56
|
+
try {
|
|
57
|
+
footer = parseFooter(text, source, diagnostics);
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
diagnostics.push(nestingDiagnostic(error, source));
|
|
61
|
+
return { diagnostics };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
37
64
|
const sources = [];
|
|
38
65
|
const routes = new Map();
|
|
39
66
|
for (const file of project.files) {
|
|
@@ -58,7 +85,15 @@ export async function compileProject(options) {
|
|
|
58
85
|
file: source,
|
|
59
86
|
});
|
|
60
87
|
routes.set(key, source);
|
|
61
|
-
const
|
|
88
|
+
const text = await readFile(file, "utf8");
|
|
89
|
+
let document;
|
|
90
|
+
try {
|
|
91
|
+
document = parseDocument(text, source, diagnostics);
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
diagnostics.push(nestingDiagnostic(error, source));
|
|
95
|
+
return { diagnostics };
|
|
96
|
+
}
|
|
62
97
|
sources.push({ file, relative, route, document });
|
|
63
98
|
}
|
|
64
99
|
const assets = await resolveLinks(sources, project, basePath, diagnostics);
|
|
@@ -67,7 +102,14 @@ export async function compileProject(options) {
|
|
|
67
102
|
const pages = [];
|
|
68
103
|
for (const source of sources) {
|
|
69
104
|
const { document } = source;
|
|
70
|
-
|
|
105
|
+
let rendered;
|
|
106
|
+
try {
|
|
107
|
+
rendered = await renderDocument(document);
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
diagnostics.push(nestingDiagnostic(error, relativeSource(project.root, source.file)));
|
|
111
|
+
return { diagnostics };
|
|
112
|
+
}
|
|
71
113
|
pages.push({
|
|
72
114
|
source: relativeSource(project.root, source.file),
|
|
73
115
|
route: source.route,
|
|
@@ -96,6 +138,7 @@ export async function compileProject(options) {
|
|
|
96
138
|
navigation: navigationFor(pages, sources.map((source) => source.relative)),
|
|
97
139
|
assets,
|
|
98
140
|
theme: project.theme,
|
|
141
|
+
...(footer ? { footer } : {}),
|
|
99
142
|
},
|
|
100
143
|
diagnostics,
|
|
101
144
|
};
|
package/dist/links.js
CHANGED
|
@@ -60,8 +60,10 @@ export async function resolveLinks(pages, project, basePath, diagnostics) {
|
|
|
60
60
|
throw new AuthoringError("PATH_ESCAPE", "Local links must stay inside the content root");
|
|
61
61
|
let target = !decoded ? page : byFile.get(targetFile);
|
|
62
62
|
const extension = path.extname(decoded).toLowerCase();
|
|
63
|
-
let
|
|
63
|
+
let asset = decoded.endsWith("/") ? undefined : assets.get(targetFile);
|
|
64
|
+
let assetExists = asset !== undefined;
|
|
64
65
|
if (!target &&
|
|
66
|
+
!assetExists &&
|
|
65
67
|
!decoded.endsWith("/") &&
|
|
66
68
|
assetExtensions.has(extension)) {
|
|
67
69
|
try {
|
|
@@ -106,21 +108,23 @@ export async function resolveLinks(pages, project, basePath, diagnostics) {
|
|
|
106
108
|
if (isImage && [".pdf", ".txt"].includes(extension)) {
|
|
107
109
|
throw new AuthoringError("INVALID_IMAGE", "Images must reference a supported raster image");
|
|
108
110
|
}
|
|
109
|
-
if (!
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
+
}
|
|
124
128
|
node.url =
|
|
125
129
|
asset.url + query + (hash ? `#${encodeURIComponent(hash)}` : "");
|
|
126
130
|
}
|
|
@@ -139,15 +143,6 @@ export async function resolveLinks(pages, project, basePath, diagnostics) {
|
|
|
139
143
|
message: `Malformed URL encoding in '${url}'`,
|
|
140
144
|
...location,
|
|
141
145
|
});
|
|
142
|
-
else if (error instanceof Error &&
|
|
143
|
-
"code" in error &&
|
|
144
|
-
error.code === "ENOENT")
|
|
145
|
-
diagnostics.push({
|
|
146
|
-
severity: "error",
|
|
147
|
-
code: "MISSING_ASSET",
|
|
148
|
-
message: `Asset not found: '${url}'`,
|
|
149
|
-
...location,
|
|
150
|
-
});
|
|
151
146
|
else
|
|
152
147
|
throw new Error(`Unable to resolve '${url}' in ${location.file}`, {
|
|
153
148
|
cause: error,
|
package/dist/markdown.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ export interface ParsedDocument {
|
|
|
6
6
|
title: string;
|
|
7
7
|
headings: Heading[];
|
|
8
8
|
}
|
|
9
|
+
/** Shared syntax parser; callers validate the allowed document context. */
|
|
10
|
+
export declare function parseMarkdown(source: string): Root;
|
|
9
11
|
export declare function parseDocument(source: string, file: string, diagnostics: Diagnostic[]): ParsedDocument;
|
|
10
12
|
export declare function renderDocument(document: ParsedDocument): Promise<{
|
|
11
13
|
html: string;
|
package/dist/markdown.js
CHANGED
|
@@ -12,6 +12,7 @@ import remarkStringify from "remark-stringify";
|
|
|
12
12
|
import { unified } from "unified";
|
|
13
13
|
import { SKIP, visit } from "unist-util-visit";
|
|
14
14
|
import { parseDocument as parseYaml } from "yaml";
|
|
15
|
+
import { transformAlerts } from "./alerts.js";
|
|
15
16
|
const parser = unified()
|
|
16
17
|
.use(remarkParse)
|
|
17
18
|
.use(remarkGfm)
|
|
@@ -44,8 +45,23 @@ const htmlRenderer = unified()
|
|
|
44
45
|
},
|
|
45
46
|
})
|
|
46
47
|
.use(rehypeStringify);
|
|
47
|
-
const markdownRenderer = unified()
|
|
48
|
-
|
|
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?.mddAlertLabel !== undefined
|
|
55
|
+
? node.value
|
|
56
|
+
: state.safe(node.value, info);
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
const removedNotices = new Set(["note", "tip", "warning"]);
|
|
61
|
+
/** Shared syntax parser; callers validate the allowed document context. */
|
|
62
|
+
export function parseMarkdown(source) {
|
|
63
|
+
return parser.parse(source);
|
|
64
|
+
}
|
|
49
65
|
/** Keep the same first-wins, reachable definitions that the HTML renderer uses. */
|
|
50
66
|
function retainReferencedDefinitions(tree) {
|
|
51
67
|
const definitions = new Map();
|
|
@@ -99,7 +115,8 @@ function unsafeUrl(url, image) {
|
|
|
99
115
|
!(image ? ["http", "https"] : ["http", "https", "mailto", "tel"]).includes(scheme));
|
|
100
116
|
}
|
|
101
117
|
export function parseDocument(source, file, diagnostics) {
|
|
102
|
-
const tree =
|
|
118
|
+
const tree = parseMarkdown(source);
|
|
119
|
+
transformAlerts(tree, source);
|
|
103
120
|
retainReferencedDefinitions(tree);
|
|
104
121
|
const metadata = {};
|
|
105
122
|
const headings = [];
|
|
@@ -160,6 +177,10 @@ export function parseDocument(source, file, diagnostics) {
|
|
|
160
177
|
visit(tree, (node, index, parent) => {
|
|
161
178
|
if (node.type === "html") {
|
|
162
179
|
report("RAW_HTML", "Raw HTML is not supported; use Markdown or an mdd component.", node);
|
|
180
|
+
if (parent && index !== undefined) {
|
|
181
|
+
parent.children.splice(index, 1);
|
|
182
|
+
return [SKIP, index];
|
|
183
|
+
}
|
|
163
184
|
}
|
|
164
185
|
if ((node.type === "link" ||
|
|
165
186
|
node.type === "image" ||
|
|
@@ -181,8 +202,12 @@ export function parseDocument(source, file, diagnostics) {
|
|
|
181
202
|
node.type !== "textDirective") {
|
|
182
203
|
return;
|
|
183
204
|
}
|
|
184
|
-
if (
|
|
185
|
-
report("
|
|
205
|
+
if (removedNotices.has(node.name)) {
|
|
206
|
+
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);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (node.type !== "containerDirective" || node.name !== "details") {
|
|
210
|
+
report("UNKNOWN_COMPONENT", `Unsupported component ${node.name}; use a GitHub alert or a details container.`, node);
|
|
186
211
|
return;
|
|
187
212
|
}
|
|
188
213
|
if (Object.keys(node.attributes ?? {}).length) {
|
|
@@ -191,7 +216,7 @@ export function parseDocument(source, file, diagnostics) {
|
|
|
191
216
|
if (index === undefined || !parent)
|
|
192
217
|
return;
|
|
193
218
|
const first = node.children[0];
|
|
194
|
-
const defaultLabel =
|
|
219
|
+
const defaultLabel = "Details";
|
|
195
220
|
const label = first?.type === "paragraph" && first.data?.directiveLabel
|
|
196
221
|
? first
|
|
197
222
|
: {
|
|
@@ -210,7 +235,7 @@ export function parseDocument(source, file, diagnostics) {
|
|
|
210
235
|
}
|
|
211
236
|
label.children = [{ type: "strong", children: label.children }];
|
|
212
237
|
label.data = {
|
|
213
|
-
hName:
|
|
238
|
+
hName: "summary",
|
|
214
239
|
hProperties: { className: ["mdd-component-label"] },
|
|
215
240
|
};
|
|
216
241
|
parent.children[index] = {
|
|
@@ -218,8 +243,8 @@ export function parseDocument(source, file, diagnostics) {
|
|
|
218
243
|
children: node.children,
|
|
219
244
|
position: node.position,
|
|
220
245
|
data: {
|
|
221
|
-
hName:
|
|
222
|
-
hProperties: { className: [
|
|
246
|
+
hName: "details",
|
|
247
|
+
hProperties: { className: ["mdd-details"] },
|
|
223
248
|
},
|
|
224
249
|
};
|
|
225
250
|
// Revisit the replacement so nested content is validated exactly once.
|
|
@@ -238,17 +263,8 @@ export function parseDocument(source, file, diagnostics) {
|
|
|
238
263
|
}
|
|
239
264
|
export async function renderDocument(document) {
|
|
240
265
|
const htmlTree = await htmlRenderer.run(document.tree);
|
|
241
|
-
const markdownTree = structuredClone(document.tree);
|
|
242
|
-
visit(markdownTree, (node, index, parent) => {
|
|
243
|
-
if ((node.type === "html" || node.type === "yaml") &&
|
|
244
|
-
parent &&
|
|
245
|
-
index !== undefined) {
|
|
246
|
-
parent.children.splice(index, 1);
|
|
247
|
-
return [SKIP, index];
|
|
248
|
-
}
|
|
249
|
-
});
|
|
250
266
|
return {
|
|
251
267
|
html: String(htmlRenderer.stringify(htmlTree)),
|
|
252
|
-
markdown: String(markdownRenderer.stringify(
|
|
268
|
+
markdown: String(markdownRenderer.stringify(document.tree)),
|
|
253
269
|
};
|
|
254
270
|
}
|
package/dist/project.d.ts
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import type { CompileOptions, Diagnostic, Theme } from "./types.js";
|
|
2
2
|
export interface Project {
|
|
3
3
|
root: string;
|
|
4
|
-
mddRoot: string;
|
|
5
4
|
contentsRoot: string;
|
|
6
|
-
themesRoot: string;
|
|
7
5
|
title?: string;
|
|
8
6
|
theme: Theme;
|
|
9
7
|
files: string[];
|
|
8
|
+
footer?: string;
|
|
10
9
|
}
|
|
11
10
|
export declare class AuthoringError extends Error {
|
|
12
11
|
readonly code: string;
|
package/dist/project.js
CHANGED
|
@@ -139,7 +139,7 @@ async function optionalFile(root, file) {
|
|
|
139
139
|
}
|
|
140
140
|
return safeFile(root, file);
|
|
141
141
|
}
|
|
142
|
-
async function scan(root) {
|
|
142
|
+
async function scan(root, excludedFile) {
|
|
143
143
|
const files = [];
|
|
144
144
|
async function walk(directory) {
|
|
145
145
|
const entries = await readdir(directory, { withFileTypes: true }).catch((error) => {
|
|
@@ -156,6 +156,8 @@ async function scan(root) {
|
|
|
156
156
|
if (entry.isSymbolicLink()) {
|
|
157
157
|
throw new AuthoringError("CONTENT_SYMLINK", `Content discovery does not follow symlinks: ${file}`);
|
|
158
158
|
}
|
|
159
|
+
if (file === excludedFile)
|
|
160
|
+
continue;
|
|
159
161
|
if (entry.isDirectory())
|
|
160
162
|
await walk(file);
|
|
161
163
|
else if (path.extname(entry.name).toLowerCase() === ".md")
|
|
@@ -174,6 +176,12 @@ export async function loadProject(options, diagnostics) {
|
|
|
174
176
|
throw new Error(`Cannot resolve project directory ${options.projectDir}`, { cause: error });
|
|
175
177
|
});
|
|
176
178
|
const mddRoot = await directory(root, relativePath(options.mddDir ?? "mdd", "mddDir"));
|
|
179
|
+
const footerPath = path.join(mddRoot, "footer.md");
|
|
180
|
+
source = relativeSource(root, footerPath);
|
|
181
|
+
const footer = await optionalFile(root, footerPath);
|
|
182
|
+
if (footer && (await lstat(footerPath)).isSymbolicLink()) {
|
|
183
|
+
throw new AuthoringError("FOOTER_SYMLINK", "The shared footer must be a regular file, not a symlink.");
|
|
184
|
+
}
|
|
177
185
|
const configPath = path.join(mddRoot, "config.json");
|
|
178
186
|
source = relativeSource(root, configPath);
|
|
179
187
|
const configFile = await optionalFile(root, configPath);
|
|
@@ -229,14 +237,21 @@ export async function loadProject(options, diagnostics) {
|
|
|
229
237
|
};
|
|
230
238
|
}
|
|
231
239
|
source = relativeSource(root, contentsRoot);
|
|
232
|
-
const files = await scan(contentsRoot);
|
|
240
|
+
const files = await scan(contentsRoot, footer ?? footerPath);
|
|
233
241
|
if (!files.includes(path.join(contentsRoot, "index.md"))) {
|
|
234
242
|
source = relativeSource(root, path.join(contentsRoot, "index.md"));
|
|
235
243
|
throw new AuthoringError("MISSING_HOME", "The content root must contain an index.md homepage.");
|
|
236
244
|
}
|
|
237
245
|
if (diagnostics.some((diagnostic) => diagnostic.severity === "error"))
|
|
238
246
|
return undefined;
|
|
239
|
-
return {
|
|
247
|
+
return {
|
|
248
|
+
root,
|
|
249
|
+
contentsRoot,
|
|
250
|
+
title,
|
|
251
|
+
theme,
|
|
252
|
+
files,
|
|
253
|
+
...(footer ? { footer } : {}),
|
|
254
|
+
};
|
|
240
255
|
}
|
|
241
256
|
catch (error) {
|
|
242
257
|
if (!(error instanceof AuthoringError))
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { toString as nodeText } from "mdast-util-to-string";
|
|
2
|
+
import remarkGfm from "remark-gfm";
|
|
3
|
+
import remarkParse from "remark-parse";
|
|
4
|
+
import { unified } from "unified";
|
|
5
|
+
import { SKIP, visit } from "unist-util-visit";
|
|
6
|
+
import { transformAlerts } from "./alerts.js";
|
|
7
|
+
import { validateSearchIndex, } from "./search.js";
|
|
8
|
+
const parser = unified().use(remarkParse).use(remarkGfm);
|
|
9
|
+
const blockTypes = new Set(["paragraph", "code", "tableCell", "listItem"]);
|
|
10
|
+
function indexPage(page) {
|
|
11
|
+
const sections = [{ title: "", url: page.url, text: "" }];
|
|
12
|
+
let headingIndex = 0;
|
|
13
|
+
const chunks = [[]];
|
|
14
|
+
const tree = parser.parse(page.markdown);
|
|
15
|
+
transformAlerts(tree, page.markdown);
|
|
16
|
+
visit(tree, (node) => {
|
|
17
|
+
if (node.type === "definition" || node.type === "html")
|
|
18
|
+
return SKIP;
|
|
19
|
+
if (node.type === "heading") {
|
|
20
|
+
const heading = page.headings[headingIndex++];
|
|
21
|
+
if (!heading ||
|
|
22
|
+
heading.text !== nodeText(node) ||
|
|
23
|
+
heading.depth !== node.depth) {
|
|
24
|
+
throw new TypeError("Search indexing requires matching compiled Markdown and headings.");
|
|
25
|
+
}
|
|
26
|
+
sections.push({
|
|
27
|
+
title: heading.text,
|
|
28
|
+
url: `${page.url}#${encodeURIComponent(heading.id)}`,
|
|
29
|
+
text: "",
|
|
30
|
+
});
|
|
31
|
+
chunks.push([]);
|
|
32
|
+
return SKIP;
|
|
33
|
+
}
|
|
34
|
+
const text = chunks[chunks.length - 1];
|
|
35
|
+
if (!text)
|
|
36
|
+
return;
|
|
37
|
+
if (blockTypes.has(node.type) || node.type === "break")
|
|
38
|
+
text.push(" ");
|
|
39
|
+
if (node.type === "text" ||
|
|
40
|
+
node.type === "code" ||
|
|
41
|
+
node.type === "inlineCode")
|
|
42
|
+
text.push(node.type === "text"
|
|
43
|
+
? (node.data?.mddAlertLabel ?? node.value)
|
|
44
|
+
: node.value);
|
|
45
|
+
if ((node.type === "image" || node.type === "imageReference") && node.alt)
|
|
46
|
+
text.push(node.alt);
|
|
47
|
+
});
|
|
48
|
+
if (headingIndex !== page.headings.length) {
|
|
49
|
+
throw new TypeError("Search indexing requires matching compiled Markdown and headings.");
|
|
50
|
+
}
|
|
51
|
+
sections.forEach((entry, index) => {
|
|
52
|
+
entry.text = (chunks[index] ?? []).join("").replace(/\s+/gu, " ").trim();
|
|
53
|
+
});
|
|
54
|
+
return {
|
|
55
|
+
url: page.url,
|
|
56
|
+
title: page.title,
|
|
57
|
+
description: page.description ?? "",
|
|
58
|
+
sections,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/** Build only from successfully compiled pages; no source paths or HTML are indexed. */
|
|
62
|
+
export function createSearchIndex(site) {
|
|
63
|
+
const index = { version: 1, pages: site.pages.map(indexPage) };
|
|
64
|
+
index.pages.sort((a, b) => (a.url < b.url ? -1 : a.url > b.url ? 1 : 0));
|
|
65
|
+
validateSearchIndex(index);
|
|
66
|
+
return index;
|
|
67
|
+
}
|
package/dist/search.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Versioned, JSON-serializable input for the browser-safe search API. */
|
|
2
|
+
export interface SearchIndex {
|
|
3
|
+
version: 1;
|
|
4
|
+
pages: SearchPage[];
|
|
5
|
+
}
|
|
6
|
+
export interface SearchPage {
|
|
7
|
+
url: string;
|
|
8
|
+
title: string;
|
|
9
|
+
description: string;
|
|
10
|
+
sections: SearchSection[];
|
|
11
|
+
}
|
|
12
|
+
export interface SearchSection {
|
|
13
|
+
/** Empty for content before the first heading. */
|
|
14
|
+
title: string;
|
|
15
|
+
url: string;
|
|
16
|
+
text: string;
|
|
17
|
+
}
|
|
18
|
+
export interface SearchOptions {
|
|
19
|
+
/** Maximum results, from 0 to 100. Defaults to 10. */
|
|
20
|
+
limit?: number;
|
|
21
|
+
}
|
|
22
|
+
export interface SearchResult {
|
|
23
|
+
title: string;
|
|
24
|
+
url: string;
|
|
25
|
+
/** Present when the destination is a compiled heading. */
|
|
26
|
+
section?: string;
|
|
27
|
+
excerpt: string;
|
|
28
|
+
score: number;
|
|
29
|
+
}
|
|
30
|
+
/** Validate deserialized data before any result can become a link. */
|
|
31
|
+
export declare function validateSearchIndex(value: unknown): asserts value is SearchIndex;
|
|
32
|
+
/** Return at most one hit per page, without filesystem, network, or DOM access. */
|
|
33
|
+
export declare function search(index: SearchIndex, query: string, options?: SearchOptions): SearchResult[];
|
package/dist/search.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
function record(value) {
|
|
2
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
3
|
+
}
|
|
4
|
+
function pageUrl(value) {
|
|
5
|
+
if (typeof value !== "string" ||
|
|
6
|
+
!value.startsWith("/") ||
|
|
7
|
+
value.startsWith("//") ||
|
|
8
|
+
!value.endsWith("/") ||
|
|
9
|
+
/[?#\\\s\p{Cc}]/u.test(value))
|
|
10
|
+
return false;
|
|
11
|
+
try {
|
|
12
|
+
const parsed = new URL(value, "https://mdd.invalid");
|
|
13
|
+
return (parsed.pathname === value &&
|
|
14
|
+
value.split("/").every((part) => {
|
|
15
|
+
const decoded = decodeURIComponent(part);
|
|
16
|
+
return (decoded !== "." && decoded !== ".." && !/[/\\\p{Cc}]/u.test(decoded));
|
|
17
|
+
}));
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function sectionUrl(value, page) {
|
|
24
|
+
if (value === page)
|
|
25
|
+
return true;
|
|
26
|
+
if (typeof value !== "string" || !value.startsWith(`${page}#`))
|
|
27
|
+
return false;
|
|
28
|
+
try {
|
|
29
|
+
const fragment = value.slice(page.length + 1);
|
|
30
|
+
const id = decodeURIComponent(fragment);
|
|
31
|
+
return (id.startsWith("mdd-") &&
|
|
32
|
+
!/\p{Cc}/u.test(id) &&
|
|
33
|
+
fragment === encodeURIComponent(id));
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** Validate deserialized data before any result can become a link. */
|
|
40
|
+
export function validateSearchIndex(value) {
|
|
41
|
+
if (!record(value) || value.version !== 1 || !Array.isArray(value.pages)) {
|
|
42
|
+
throw new TypeError("Invalid search index: expected version 1 and pages.");
|
|
43
|
+
}
|
|
44
|
+
const urls = new Set();
|
|
45
|
+
for (const page of value.pages) {
|
|
46
|
+
if (!record(page) ||
|
|
47
|
+
!pageUrl(page.url) ||
|
|
48
|
+
urls.has(page.url) ||
|
|
49
|
+
typeof page.title !== "string" ||
|
|
50
|
+
typeof page.description !== "string" ||
|
|
51
|
+
!Array.isArray(page.sections))
|
|
52
|
+
throw new TypeError("Invalid search index page.");
|
|
53
|
+
urls.add(page.url);
|
|
54
|
+
const sections = new Set();
|
|
55
|
+
for (const section of page.sections) {
|
|
56
|
+
if (!record(section) ||
|
|
57
|
+
!sectionUrl(section.url, page.url) ||
|
|
58
|
+
sections.has(section.url) ||
|
|
59
|
+
typeof section.title !== "string" ||
|
|
60
|
+
typeof section.text !== "string")
|
|
61
|
+
throw new TypeError("Invalid search index section.");
|
|
62
|
+
sections.add(section.url);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function normalize(value) {
|
|
67
|
+
return value
|
|
68
|
+
.normalize("NFKC")
|
|
69
|
+
.toLowerCase()
|
|
70
|
+
.replace(/[^\p{L}\p{N}\p{M}_]+/gu, " ")
|
|
71
|
+
.trim();
|
|
72
|
+
}
|
|
73
|
+
function excerpt(value) {
|
|
74
|
+
const characters = Array.from(value.replace(/\s+/gu, " ").trim());
|
|
75
|
+
return characters.length > 160
|
|
76
|
+
? `${characters.slice(0, 159).join("")}…`
|
|
77
|
+
: characters.join("");
|
|
78
|
+
}
|
|
79
|
+
/** Return at most one hit per page, without filesystem, network, or DOM access. */
|
|
80
|
+
export function search(index, query, options = {}) {
|
|
81
|
+
validateSearchIndex(index);
|
|
82
|
+
const limit = options.limit ?? 10;
|
|
83
|
+
if (!Number.isInteger(limit) || limit < 0 || limit > 100) {
|
|
84
|
+
throw new RangeError("Search limit must be an integer from 0 to 100.");
|
|
85
|
+
}
|
|
86
|
+
if (typeof query !== "string")
|
|
87
|
+
throw new TypeError("Search query must be a string.");
|
|
88
|
+
if (query.length > 512)
|
|
89
|
+
throw new RangeError("Search query must not exceed 512 characters.");
|
|
90
|
+
const terms = [...new Set(normalize(query).split(" ").filter(Boolean))];
|
|
91
|
+
const phrase = terms.join(" ");
|
|
92
|
+
if (terms.length > 32)
|
|
93
|
+
throw new RangeError("Search query must not exceed 32 unique terms.");
|
|
94
|
+
if (!terms.length || !limit)
|
|
95
|
+
return [];
|
|
96
|
+
const results = [];
|
|
97
|
+
for (const page of index.pages) {
|
|
98
|
+
const title = normalize(page.title);
|
|
99
|
+
const description = normalize(page.description);
|
|
100
|
+
const sections = page.sections.map((section) => ({
|
|
101
|
+
section,
|
|
102
|
+
title: normalize(section.title),
|
|
103
|
+
text: normalize(section.text),
|
|
104
|
+
}));
|
|
105
|
+
const weights = terms.map((term) => {
|
|
106
|
+
if (title.includes(term))
|
|
107
|
+
return 8;
|
|
108
|
+
if (sections.some((section) => section.title.includes(term)))
|
|
109
|
+
return 4;
|
|
110
|
+
if (description.includes(term))
|
|
111
|
+
return 2;
|
|
112
|
+
return sections.some((section) => section.text.includes(term)) ? 1 : 0;
|
|
113
|
+
});
|
|
114
|
+
if (weights.some((weight) => weight === 0))
|
|
115
|
+
continue;
|
|
116
|
+
const metadataMatch = terms.every((term) => title.includes(term) || description.includes(term));
|
|
117
|
+
const matches = sections
|
|
118
|
+
.map((entry) => ({
|
|
119
|
+
...entry,
|
|
120
|
+
score: terms.reduce((score, term) => score +
|
|
121
|
+
(entry.title.includes(term)
|
|
122
|
+
? 4
|
|
123
|
+
: entry.text.includes(term)
|
|
124
|
+
? 1
|
|
125
|
+
: 0), 0),
|
|
126
|
+
}))
|
|
127
|
+
.filter((entry) => terms.every((term) => title.includes(term) ||
|
|
128
|
+
description.includes(term) ||
|
|
129
|
+
entry.title.includes(term) ||
|
|
130
|
+
entry.text.includes(term)));
|
|
131
|
+
// A stable sort preserves document order when multiple sections tie.
|
|
132
|
+
matches.sort((a, b) => b.score - a.score);
|
|
133
|
+
const best = matches[0]?.section;
|
|
134
|
+
const destination = metadataMatch ? undefined : best;
|
|
135
|
+
results.push({
|
|
136
|
+
title: page.title,
|
|
137
|
+
url: destination?.url ?? page.url,
|
|
138
|
+
...(destination?.title ? { section: destination.title } : {}),
|
|
139
|
+
excerpt: excerpt(best?.text ||
|
|
140
|
+
page.description ||
|
|
141
|
+
page.sections.find((section) => section.text)?.text ||
|
|
142
|
+
page.title),
|
|
143
|
+
score: weights.reduce((sum, weight) => sum + weight, 0) +
|
|
144
|
+
(title === phrase
|
|
145
|
+
? 24
|
|
146
|
+
: sections.some((section) => section.title === phrase)
|
|
147
|
+
? 12
|
|
148
|
+
: 0),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
results.sort((a, b) => b.score - a.score || (a.url < b.url ? -1 : a.url > b.url ? 1 : 0));
|
|
152
|
+
return results.slice(0, limit);
|
|
153
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -54,6 +54,15 @@ export type Theme = {
|
|
|
54
54
|
css: string;
|
|
55
55
|
js?: string;
|
|
56
56
|
};
|
|
57
|
+
export interface SocialLink {
|
|
58
|
+
label: string;
|
|
59
|
+
url: string;
|
|
60
|
+
}
|
|
61
|
+
export interface Footer {
|
|
62
|
+
/** POSIX path relative to projectDir; not a documentation page. */
|
|
63
|
+
source: string;
|
|
64
|
+
socials: SocialLink[];
|
|
65
|
+
}
|
|
57
66
|
export interface Site {
|
|
58
67
|
title: string;
|
|
59
68
|
basePath: string;
|
|
@@ -61,6 +70,7 @@ export interface Site {
|
|
|
61
70
|
navigation: NavigationItem[];
|
|
62
71
|
assets: Asset[];
|
|
63
72
|
theme: Theme;
|
|
73
|
+
footer?: Footer;
|
|
64
74
|
}
|
|
65
75
|
export interface CompileOptions {
|
|
66
76
|
projectDir: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wgtechlabs/mdd-engine",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1-dev.51a1fca",
|
|
4
4
|
"description": "Headless Markdown documentation compiler for mdd",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -18,6 +18,10 @@
|
|
|
18
18
|
".": {
|
|
19
19
|
"types": "./dist/index.d.ts",
|
|
20
20
|
"import": "./dist/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./search": {
|
|
23
|
+
"types": "./dist/search.d.ts",
|
|
24
|
+
"import": "./dist/search.js"
|
|
21
25
|
}
|
|
22
26
|
},
|
|
23
27
|
"types": "./dist/index.d.ts",
|