@wgtechlabs/mdd-engine 0.1.0-dev.3c34eeb → 0.1.0-dev.8884a23
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 +50 -1
- package/dist/alerts.d.ts +1 -1
- package/dist/alerts.js +1 -1
- package/dist/endpoints.d.ts +3 -0
- package/dist/endpoints.js +68 -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 +16 -0
- package/dist/markdown.d.ts +2 -0
- package/dist/markdown.js +26 -3
- package/dist/project.d.ts +1 -0
- package/dist/project.js +18 -3
- package/dist/search-index.d.ts +4 -0
- package/dist/search-index.js +98 -0
- package/dist/search.d.ts +52 -0
- package/dist/search.js +485 -0
- package/dist/types.d.ts +10 -0
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -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/
|
|
@@ -69,9 +70,43 @@ The result includes:
|
|
|
69
70
|
- `navigation`: sorted folder/page tree, with optional landing URLs.
|
|
70
71
|
- `assets`: referenced content files with checkout-relative sources, documentation-root-relative destinations, and public URLs.
|
|
71
72
|
- `theme`: the built-in default or selected theme file locations.
|
|
73
|
+
- `footer`: optional shared footer source and validated social-link data.
|
|
72
74
|
|
|
73
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.
|
|
74
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
|
+
// Multiple heading hits, breadcrumbs, and ranges for UI highlights.
|
|
89
|
+
console.log(search(restored, 'instalation', {
|
|
90
|
+
mode: 'sections', fuzzy: true, limit: 8,
|
|
91
|
+
}));
|
|
92
|
+
// [{ title, url, section?, excerpt, score }]
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
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).
|
|
96
|
+
|
|
97
|
+
## Shared social footer
|
|
98
|
+
|
|
99
|
+
Write social links once in `mdd/footer.md`:
|
|
100
|
+
|
|
101
|
+
```markdown
|
|
102
|
+
:::socials
|
|
103
|
+
- [GitHub](https://github.com/wgtechlabs)
|
|
104
|
+
- [Community](https://example.org/community)
|
|
105
|
+
:::
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
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).
|
|
109
|
+
|
|
75
110
|
## Markdown and components
|
|
76
111
|
|
|
77
112
|
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`:
|
|
@@ -98,6 +133,20 @@ Use GitHub-style alerts with `NOTE`, `TIP`, `IMPORTANT`, `WARNING`, or `CAUTION`
|
|
|
98
133
|
|
|
99
134
|
`:::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
135
|
|
|
136
|
+
Document an API endpoint with a leaf directive, then use ordinary Markdown for parameters and request/response examples:
|
|
137
|
+
|
|
138
|
+
```markdown
|
|
139
|
+
## Retrieve a widget
|
|
140
|
+
|
|
141
|
+
::endpoint{method="GET" path="/v1/widgets/{id}"}
|
|
142
|
+
|
|
143
|
+
| Parameter | Type | Description |
|
|
144
|
+
| --- | --- | --- |
|
|
145
|
+
| `id` | string | Widget identifier. |
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
The required `method` accepts GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, TRACE, or CONNECT, normalizing lowercase/mixed case to uppercase. The required `path` starts with a single `/` and contains no whitespace or control characters; braces and query punctuation remain literal text. Labels, extra attributes, and inline/container forms are errors. The engine emits a `div.mdd-endpoint` containing `strong.mdd-endpoint-method.mdd-method-get` (or the corresponding lowercase method) and `code.mdd-endpoint-path`. Normalized Markdown contains `**GET**` followed by the code-formatted path, so the signature stays readable and searchable. Endpoint paths are not rewritten with the documentation base path. This is documentation only: no HTTP requests run. MDD and its themes provide presentation.
|
|
149
|
+
|
|
101
150
|
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
151
|
|
|
103
152
|
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.
|
|
@@ -124,7 +173,7 @@ bun run coverage
|
|
|
124
173
|
bun audit
|
|
125
174
|
```
|
|
126
175
|
|
|
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
|
|
176
|
+
`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.
|
|
128
177
|
|
|
129
178
|
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
179
|
|
package/dist/alerts.d.ts
CHANGED
package/dist/alerts.js
CHANGED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const methods = new Set([
|
|
2
|
+
"GET",
|
|
3
|
+
"HEAD",
|
|
4
|
+
"POST",
|
|
5
|
+
"PUT",
|
|
6
|
+
"PATCH",
|
|
7
|
+
"DELETE",
|
|
8
|
+
"OPTIONS",
|
|
9
|
+
"TRACE",
|
|
10
|
+
"CONNECT",
|
|
11
|
+
]);
|
|
12
|
+
/** Endpoint signatures are documentation text, never links or executable requests. */
|
|
13
|
+
export function endpointParagraph(node, source, report) {
|
|
14
|
+
if (node.type !== "leafDirective") {
|
|
15
|
+
report('Use the leaf form ::endpoint{method="GET" path="/example"}.');
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const offset = node.position?.start.offset;
|
|
19
|
+
if (node.children.length ||
|
|
20
|
+
(offset !== undefined && source.startsWith("::endpoint[", offset))) {
|
|
21
|
+
report("The endpoint component does not support a label.");
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const attributes = node.attributes ?? {};
|
|
25
|
+
if (Object.keys(attributes).some((key) => key !== "method" && key !== "path")) {
|
|
26
|
+
report("The endpoint component supports only method and path attributes.");
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const method = (attributes.method ?? "").toUpperCase();
|
|
30
|
+
if (!methods.has(method)) {
|
|
31
|
+
report("The endpoint method must be GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, TRACE, or CONNECT.");
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const path = attributes.path ?? "";
|
|
35
|
+
if (!path.startsWith("/") ||
|
|
36
|
+
path.startsWith("//") ||
|
|
37
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: Endpoint text cannot contain invisible controls or whitespace.
|
|
38
|
+
/[\s\u0000-\u001f\u007f-\u009f]/u.test(path)) {
|
|
39
|
+
report("The endpoint path must start with a single / and contain no whitespace or control characters.");
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
type: "paragraph",
|
|
44
|
+
position: node.position,
|
|
45
|
+
// Tight lists unwrap ordinary paragraphs; retain the signature's block hook.
|
|
46
|
+
data: { hName: "div", hProperties: { className: ["mdd-endpoint"] } },
|
|
47
|
+
children: [
|
|
48
|
+
{
|
|
49
|
+
type: "strong",
|
|
50
|
+
data: {
|
|
51
|
+
hProperties: {
|
|
52
|
+
className: [
|
|
53
|
+
"mdd-endpoint-method",
|
|
54
|
+
`mdd-method-${method.toLowerCase()}`,
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
children: [{ type: "text", value: method }],
|
|
59
|
+
},
|
|
60
|
+
{ type: "text", value: " " },
|
|
61
|
+
{
|
|
62
|
+
type: "inlineCode",
|
|
63
|
+
value: path,
|
|
64
|
+
data: { hProperties: { className: ["mdd-endpoint-path"] } },
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
};
|
|
68
|
+
}
|
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 SearchMatch, 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
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",
|
|
@@ -46,6 +49,18 @@ export async function compileProject(options) {
|
|
|
46
49
|
const project = await loadProject(options, diagnostics);
|
|
47
50
|
if (!project)
|
|
48
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
|
+
}
|
|
49
64
|
const sources = [];
|
|
50
65
|
const routes = new Map();
|
|
51
66
|
for (const file of project.files) {
|
|
@@ -123,6 +138,7 @@ export async function compileProject(options) {
|
|
|
123
138
|
navigation: navigationFor(pages, sources.map((source) => source.relative)),
|
|
124
139
|
assets,
|
|
125
140
|
theme: project.theme,
|
|
141
|
+
...(footer ? { footer } : {}),
|
|
126
142
|
},
|
|
127
143
|
diagnostics,
|
|
128
144
|
};
|
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
|
@@ -13,6 +13,7 @@ import { unified } from "unified";
|
|
|
13
13
|
import { SKIP, visit } from "unist-util-visit";
|
|
14
14
|
import { parseDocument as parseYaml } from "yaml";
|
|
15
15
|
import { transformAlerts } from "./alerts.js";
|
|
16
|
+
import { endpointParagraph } from "./endpoints.js";
|
|
16
17
|
const parser = unified()
|
|
17
18
|
.use(remarkParse)
|
|
18
19
|
.use(remarkGfm)
|
|
@@ -29,10 +30,20 @@ const htmlRenderer = unified()
|
|
|
29
30
|
...defaultSchema.attributes,
|
|
30
31
|
aside: [["className", /^mdd-/]],
|
|
31
32
|
details: [["className", "mdd-details"]],
|
|
33
|
+
div: [["className", "mdd-endpoint"]],
|
|
32
34
|
p: [
|
|
33
35
|
...(defaultSchema.attributes?.p ?? []),
|
|
34
36
|
["className", "mdd-component-label"],
|
|
35
37
|
],
|
|
38
|
+
strong: [
|
|
39
|
+
...(defaultSchema.attributes?.strong ?? []),
|
|
40
|
+
[
|
|
41
|
+
"className",
|
|
42
|
+
"mdd-endpoint-method",
|
|
43
|
+
/^mdd-method-(get|head|post|put|patch|delete|options|trace|connect)$/,
|
|
44
|
+
],
|
|
45
|
+
],
|
|
46
|
+
code: [["className", /^language-./, "mdd-endpoint-path"]],
|
|
36
47
|
summary: [
|
|
37
48
|
...(defaultSchema.attributes?.summary ?? []),
|
|
38
49
|
["className", "mdd-component-label"],
|
|
@@ -51,13 +62,17 @@ const markdownRenderer = unified()
|
|
|
51
62
|
handlers: {
|
|
52
63
|
text(node, _parent, state, info) {
|
|
53
64
|
// Only generated alert markers bypass normal Markdown escaping.
|
|
54
|
-
return node.data?.
|
|
65
|
+
return node.data?.mddAlertLabel !== undefined
|
|
55
66
|
? node.value
|
|
56
67
|
: state.safe(node.value, info);
|
|
57
68
|
},
|
|
58
69
|
},
|
|
59
70
|
});
|
|
60
71
|
const removedNotices = new Set(["note", "tip", "warning"]);
|
|
72
|
+
/** Shared syntax parser; callers validate the allowed document context. */
|
|
73
|
+
export function parseMarkdown(source) {
|
|
74
|
+
return parser.parse(source);
|
|
75
|
+
}
|
|
61
76
|
/** Keep the same first-wins, reachable definitions that the HTML renderer uses. */
|
|
62
77
|
function retainReferencedDefinitions(tree) {
|
|
63
78
|
const definitions = new Map();
|
|
@@ -111,7 +126,7 @@ function unsafeUrl(url, image) {
|
|
|
111
126
|
!(image ? ["http", "https"] : ["http", "https", "mailto", "tel"]).includes(scheme));
|
|
112
127
|
}
|
|
113
128
|
export function parseDocument(source, file, diagnostics) {
|
|
114
|
-
const tree =
|
|
129
|
+
const tree = parseMarkdown(source);
|
|
115
130
|
transformAlerts(tree, source);
|
|
116
131
|
retainReferencedDefinitions(tree);
|
|
117
132
|
const metadata = {};
|
|
@@ -202,8 +217,16 @@ export function parseDocument(source, file, diagnostics) {
|
|
|
202
217
|
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
218
|
return;
|
|
204
219
|
}
|
|
220
|
+
if (node.name === "endpoint") {
|
|
221
|
+
const paragraph = endpointParagraph(node, source, (message) => report("INVALID_COMPONENT", message, node));
|
|
222
|
+
if (paragraph && parent && index !== undefined) {
|
|
223
|
+
parent.children[index] = paragraph;
|
|
224
|
+
return [SKIP, index];
|
|
225
|
+
}
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
205
228
|
if (node.type !== "containerDirective" || node.name !== "details") {
|
|
206
|
-
report("UNKNOWN_COMPONENT", `Unsupported component ${node.name}; use a GitHub alert or
|
|
229
|
+
report("UNKNOWN_COMPONENT", `Unsupported component ${node.name}; use a GitHub alert, details container, or endpoint leaf.`, node);
|
|
207
230
|
return;
|
|
208
231
|
}
|
|
209
232
|
if (Object.keys(node.attributes ?? {}).length) {
|
package/dist/project.d.ts
CHANGED
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,98 @@
|
|
|
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, breadcrumbs) {
|
|
11
|
+
const sections = [{ title: "", url: page.url, text: "" }];
|
|
12
|
+
const ancestors = [];
|
|
13
|
+
let headingIndex = 0;
|
|
14
|
+
const chunks = [[]];
|
|
15
|
+
const tree = parser.parse(page.markdown);
|
|
16
|
+
transformAlerts(tree, page.markdown);
|
|
17
|
+
visit(tree, (node) => {
|
|
18
|
+
if (node.type === "definition" || node.type === "html")
|
|
19
|
+
return SKIP;
|
|
20
|
+
if (node.type === "heading") {
|
|
21
|
+
const heading = page.headings[headingIndex++];
|
|
22
|
+
if (!heading ||
|
|
23
|
+
heading.text !== nodeText(node) ||
|
|
24
|
+
heading.depth !== node.depth) {
|
|
25
|
+
throw new TypeError("Search indexing requires matching compiled Markdown and headings.");
|
|
26
|
+
}
|
|
27
|
+
while (ancestors.length &&
|
|
28
|
+
(ancestors.at(-1)?.depth ?? 0) >= heading.depth)
|
|
29
|
+
ancestors.pop();
|
|
30
|
+
sections.push({
|
|
31
|
+
title: heading.text,
|
|
32
|
+
url: `${page.url}#${encodeURIComponent(heading.id)}`,
|
|
33
|
+
text: "",
|
|
34
|
+
breadcrumbs: ancestors
|
|
35
|
+
.filter((ancestor) => !(ancestor.depth === 1 &&
|
|
36
|
+
ancestor.text.normalize("NFKC").toLowerCase() ===
|
|
37
|
+
page.title.normalize("NFKC").toLowerCase()))
|
|
38
|
+
.map((ancestor) => ancestor.text),
|
|
39
|
+
});
|
|
40
|
+
ancestors.push(heading);
|
|
41
|
+
chunks.push([]);
|
|
42
|
+
return SKIP;
|
|
43
|
+
}
|
|
44
|
+
const text = chunks[chunks.length - 1];
|
|
45
|
+
if (!text)
|
|
46
|
+
return;
|
|
47
|
+
if (blockTypes.has(node.type) || node.type === "break")
|
|
48
|
+
text.push(" ");
|
|
49
|
+
if (node.type === "text" ||
|
|
50
|
+
node.type === "code" ||
|
|
51
|
+
node.type === "inlineCode")
|
|
52
|
+
text.push(node.type === "text"
|
|
53
|
+
? (node.data?.mddAlertLabel ?? node.value)
|
|
54
|
+
: node.value);
|
|
55
|
+
if ((node.type === "image" || node.type === "imageReference") && node.alt)
|
|
56
|
+
text.push(node.alt);
|
|
57
|
+
});
|
|
58
|
+
if (headingIndex !== page.headings.length) {
|
|
59
|
+
throw new TypeError("Search indexing requires matching compiled Markdown and headings.");
|
|
60
|
+
}
|
|
61
|
+
sections.forEach((entry, index) => {
|
|
62
|
+
entry.text = (chunks[index] ?? []).join("").replace(/\s+/gu, " ").trim();
|
|
63
|
+
});
|
|
64
|
+
return {
|
|
65
|
+
url: page.url,
|
|
66
|
+
title: page.title,
|
|
67
|
+
description: page.description ?? "",
|
|
68
|
+
breadcrumbs,
|
|
69
|
+
sections,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/** Build only from successfully compiled pages; no source paths or HTML are indexed. */
|
|
73
|
+
export function createSearchIndex(site) {
|
|
74
|
+
const navigation = new Map();
|
|
75
|
+
const pending = site.navigation.map((item) => ({
|
|
76
|
+
item,
|
|
77
|
+
ancestors: [],
|
|
78
|
+
}));
|
|
79
|
+
while (pending.length) {
|
|
80
|
+
const entry = pending.pop();
|
|
81
|
+
if (!entry)
|
|
82
|
+
break;
|
|
83
|
+
const { item, ancestors } = entry;
|
|
84
|
+
if (ancestors.length > 64)
|
|
85
|
+
throw new TypeError("Search navigation exceeds 64 ancestor labels.");
|
|
86
|
+
if (item.url)
|
|
87
|
+
navigation.set(item.url, ancestors);
|
|
88
|
+
for (const child of item.children ?? [])
|
|
89
|
+
pending.push({ item: child, ancestors: [...ancestors, item.title] });
|
|
90
|
+
}
|
|
91
|
+
const index = {
|
|
92
|
+
version: 1,
|
|
93
|
+
pages: site.pages.map((page) => indexPage(page, navigation.get(page.url) ?? [])),
|
|
94
|
+
};
|
|
95
|
+
index.pages.sort((a, b) => (a.url < b.url ? -1 : a.url > b.url ? 1 : 0));
|
|
96
|
+
validateSearchIndex(index);
|
|
97
|
+
return index;
|
|
98
|
+
}
|
package/dist/search.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
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
|
+
/** Navigation ancestor labels, excluding this page's own label. */
|
|
11
|
+
breadcrumbs?: string[];
|
|
12
|
+
sections: SearchSection[];
|
|
13
|
+
}
|
|
14
|
+
export interface SearchSection {
|
|
15
|
+
/** Empty for content before the first heading. */
|
|
16
|
+
title: string;
|
|
17
|
+
url: string;
|
|
18
|
+
text: string;
|
|
19
|
+
/** Ancestor headings, excluding this heading and a duplicate page-title H1. */
|
|
20
|
+
breadcrumbs?: string[];
|
|
21
|
+
}
|
|
22
|
+
export interface SearchOptions {
|
|
23
|
+
/** Maximum results, from 0 to 100. Defaults to 10. */
|
|
24
|
+
limit?: number;
|
|
25
|
+
/** Defaults to one result per page; sections returns independent heading hits. */
|
|
26
|
+
mode?: "pages" | "sections";
|
|
27
|
+
/** Allow one single-edit query correction per result. Requires sections mode. */
|
|
28
|
+
fuzzy?: boolean;
|
|
29
|
+
}
|
|
30
|
+
/** UTF-16 offsets into the original displayed field; end is exclusive. */
|
|
31
|
+
export type SearchMatch = [start: number, end: number];
|
|
32
|
+
export interface SearchResult {
|
|
33
|
+
kind: "page" | "section";
|
|
34
|
+
title: string;
|
|
35
|
+
url: string;
|
|
36
|
+
pageUrl: string;
|
|
37
|
+
breadcrumbs: string[];
|
|
38
|
+
/** Present when the destination is a compiled heading. */
|
|
39
|
+
section?: string;
|
|
40
|
+
excerpt: string;
|
|
41
|
+
score: number;
|
|
42
|
+
matches: {
|
|
43
|
+
title: SearchMatch[];
|
|
44
|
+
section: SearchMatch[];
|
|
45
|
+
excerpt: SearchMatch[];
|
|
46
|
+
breadcrumbs: SearchMatch[][];
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** Validate deserialized data before any result can become a link. */
|
|
50
|
+
export declare function validateSearchIndex(value: unknown): asserts value is SearchIndex;
|
|
51
|
+
/** Query without filesystem, network, or DOM access; page mode preserves legacy ranking. */
|
|
52
|
+
export declare function search(index: SearchIndex, query: string, options?: SearchOptions): SearchResult[];
|
package/dist/search.js
ADDED
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
function record(value) {
|
|
2
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
3
|
+
}
|
|
4
|
+
function breadcrumbs(value, maximum) {
|
|
5
|
+
if (value === undefined)
|
|
6
|
+
return true;
|
|
7
|
+
if (!Array.isArray(value) || value.length > maximum)
|
|
8
|
+
return false;
|
|
9
|
+
// Iterate holes too: a sparse array does not contain only string labels.
|
|
10
|
+
for (const label of value)
|
|
11
|
+
if (typeof label !== "string")
|
|
12
|
+
return false;
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
function pageUrl(value) {
|
|
16
|
+
if (typeof value !== "string" ||
|
|
17
|
+
!value.startsWith("/") ||
|
|
18
|
+
value.startsWith("//") ||
|
|
19
|
+
!value.endsWith("/") ||
|
|
20
|
+
/[?#\\\s\p{Cc}]/u.test(value))
|
|
21
|
+
return false;
|
|
22
|
+
try {
|
|
23
|
+
const parsed = new URL(value, "https://mdd.invalid");
|
|
24
|
+
return (parsed.pathname === value &&
|
|
25
|
+
value.split("/").every((part) => {
|
|
26
|
+
const decoded = decodeURIComponent(part);
|
|
27
|
+
return (decoded !== "." && decoded !== ".." && !/[/\\\p{Cc}]/u.test(decoded));
|
|
28
|
+
}));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function sectionUrl(value, page) {
|
|
35
|
+
if (value === page)
|
|
36
|
+
return true;
|
|
37
|
+
if (typeof value !== "string" || !value.startsWith(`${page}#`))
|
|
38
|
+
return false;
|
|
39
|
+
try {
|
|
40
|
+
const fragment = value.slice(page.length + 1);
|
|
41
|
+
const id = decodeURIComponent(fragment);
|
|
42
|
+
return (id.startsWith("mdd-") &&
|
|
43
|
+
!/\p{Cc}/u.test(id) &&
|
|
44
|
+
fragment === encodeURIComponent(id));
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Validate deserialized data before any result can become a link. */
|
|
51
|
+
export function validateSearchIndex(value) {
|
|
52
|
+
if (!record(value) || value.version !== 1 || !Array.isArray(value.pages)) {
|
|
53
|
+
throw new TypeError("Invalid search index: expected version 1 and pages.");
|
|
54
|
+
}
|
|
55
|
+
const urls = new Set();
|
|
56
|
+
for (const page of value.pages) {
|
|
57
|
+
if (!record(page) ||
|
|
58
|
+
!pageUrl(page.url) ||
|
|
59
|
+
urls.has(page.url) ||
|
|
60
|
+
typeof page.title !== "string" ||
|
|
61
|
+
typeof page.description !== "string" ||
|
|
62
|
+
!breadcrumbs(page.breadcrumbs, 64) ||
|
|
63
|
+
!Array.isArray(page.sections))
|
|
64
|
+
throw new TypeError("Invalid search index page.");
|
|
65
|
+
urls.add(page.url);
|
|
66
|
+
const sections = new Set();
|
|
67
|
+
for (const section of page.sections) {
|
|
68
|
+
if (!record(section) ||
|
|
69
|
+
!sectionUrl(section.url, page.url) ||
|
|
70
|
+
sections.has(section.url) ||
|
|
71
|
+
typeof section.title !== "string" ||
|
|
72
|
+
typeof section.text !== "string" ||
|
|
73
|
+
!breadcrumbs(section.breadcrumbs, 6))
|
|
74
|
+
throw new TypeError("Invalid search index section.");
|
|
75
|
+
sections.add(section.url);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function normalize(value) {
|
|
80
|
+
return value
|
|
81
|
+
.normalize("NFKC")
|
|
82
|
+
.toLowerCase()
|
|
83
|
+
.replace(/[^\p{L}\p{N}\p{M}_]+/gu, " ")
|
|
84
|
+
.trim();
|
|
85
|
+
}
|
|
86
|
+
function contains(value, pattern) {
|
|
87
|
+
return pattern.whole
|
|
88
|
+
? ` ${value} `.includes(` ${pattern.text} `)
|
|
89
|
+
: value.includes(pattern.text);
|
|
90
|
+
}
|
|
91
|
+
function mergeRanges(ranges) {
|
|
92
|
+
ranges.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
93
|
+
const merged = [];
|
|
94
|
+
for (const range of ranges) {
|
|
95
|
+
const previous = merged.at(-1);
|
|
96
|
+
if (previous && range[0] <= previous[1])
|
|
97
|
+
previous[1] = Math.max(previous[1], range[1]);
|
|
98
|
+
else
|
|
99
|
+
merged.push([...range]);
|
|
100
|
+
}
|
|
101
|
+
return merged;
|
|
102
|
+
}
|
|
103
|
+
/** Map normalized matches to complete original graphemes, including NFKC expansions. */
|
|
104
|
+
function matchRanges(value, patterns, firstOnly = false) {
|
|
105
|
+
const normalized = value.normalize("NFKC").toLowerCase();
|
|
106
|
+
const found = [];
|
|
107
|
+
for (const pattern of patterns) {
|
|
108
|
+
let offset = 0;
|
|
109
|
+
while (offset < normalized.length) {
|
|
110
|
+
const start = normalized.indexOf(pattern.text, offset);
|
|
111
|
+
if (start < 0)
|
|
112
|
+
break;
|
|
113
|
+
const end = start + pattern.text.length;
|
|
114
|
+
// One neighboring code point needs at most two UTF-16 units. Avoid scanning
|
|
115
|
+
// a growing prefix for each occurrence, or any boundary work for substrings.
|
|
116
|
+
if (!pattern.whole ||
|
|
117
|
+
(!/[\p{L}\p{N}\p{M}_]$/u.test(normalized.slice(Math.max(0, start - 2), start)) &&
|
|
118
|
+
!/^[\p{L}\p{N}\p{M}_]/u.test(normalized.slice(end, end + 2)))) {
|
|
119
|
+
found.push([start, end]);
|
|
120
|
+
if (firstOnly)
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
offset = start + 1;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
let ranges = mergeRanges(found);
|
|
127
|
+
if (!ranges.length)
|
|
128
|
+
return [];
|
|
129
|
+
if (firstOnly)
|
|
130
|
+
ranges = ranges.slice(0, 1);
|
|
131
|
+
const groups = [];
|
|
132
|
+
for (const { segment, index } of new Intl.Segmenter(undefined, {
|
|
133
|
+
granularity: "grapheme",
|
|
134
|
+
}).segment(value)) {
|
|
135
|
+
let group = {
|
|
136
|
+
text: segment.normalize("NFKC"),
|
|
137
|
+
start: index,
|
|
138
|
+
end: index + segment.length,
|
|
139
|
+
};
|
|
140
|
+
// NFKC can combine adjacent graphemes, e.g. compatibility Hangul ㄱㅏ → 가.
|
|
141
|
+
while (groups.length) {
|
|
142
|
+
const previous = groups.at(-1);
|
|
143
|
+
if (!previous)
|
|
144
|
+
break;
|
|
145
|
+
const joined = previous.text + group.text;
|
|
146
|
+
const combined = joined.normalize("NFKC");
|
|
147
|
+
if (combined === joined)
|
|
148
|
+
break;
|
|
149
|
+
groups.pop();
|
|
150
|
+
group = { text: combined, start: previous.start, end: group.end };
|
|
151
|
+
}
|
|
152
|
+
groups.push(group);
|
|
153
|
+
}
|
|
154
|
+
// Whole-field lowercase retains contextual letters such as final sigma.
|
|
155
|
+
// Per-group lowercase supplies only lengths, including expansions like İ.
|
|
156
|
+
const mapped = [];
|
|
157
|
+
let offset = 0;
|
|
158
|
+
let rangeIndex = 0;
|
|
159
|
+
let originalStart = 0;
|
|
160
|
+
for (const group of groups) {
|
|
161
|
+
const next = offset + group.text.toLowerCase().length;
|
|
162
|
+
let range = ranges[rangeIndex];
|
|
163
|
+
while (range && range[0] < next) {
|
|
164
|
+
if (range[0] >= offset)
|
|
165
|
+
originalStart = group.start;
|
|
166
|
+
if (range[1] > next)
|
|
167
|
+
break;
|
|
168
|
+
mapped.push([originalStart, group.end]);
|
|
169
|
+
range = ranges[++rangeIndex];
|
|
170
|
+
}
|
|
171
|
+
if (!range)
|
|
172
|
+
break;
|
|
173
|
+
offset = next;
|
|
174
|
+
}
|
|
175
|
+
return mergeRanges(mapped);
|
|
176
|
+
}
|
|
177
|
+
function excerpt(value, patterns) {
|
|
178
|
+
const text = value.replace(/\s+/gu, " ").trim();
|
|
179
|
+
const characters = Array.from(text);
|
|
180
|
+
if (characters.length <= 160)
|
|
181
|
+
return text;
|
|
182
|
+
const match = matchRanges(text, patterns, true)[0];
|
|
183
|
+
const matchStart = match ? Array.from(text.slice(0, match[0])).length : 0;
|
|
184
|
+
const matchLength = match
|
|
185
|
+
? Array.from(text.slice(match[0], match[1])).length
|
|
186
|
+
: 0;
|
|
187
|
+
// Reserve room for both ellipses and the matched term before adding context.
|
|
188
|
+
const context = Math.min(40, Math.max(0, 158 - matchLength));
|
|
189
|
+
const start = Math.min(Math.max(0, matchStart - context), characters.length - 159);
|
|
190
|
+
let end = Math.min(characters.length, start + 160 - (start > 0 ? 1 : 0));
|
|
191
|
+
if (end < characters.length)
|
|
192
|
+
end--;
|
|
193
|
+
const startOffset = characters.slice(0, start).join("").length;
|
|
194
|
+
const endOffset = characters.slice(0, end).join("").length;
|
|
195
|
+
// Move clipped edges inward to complete graphemes without exceeding the cap.
|
|
196
|
+
let safeStart = text.length;
|
|
197
|
+
let safeEnd = 0;
|
|
198
|
+
for (const { segment, index } of new Intl.Segmenter(undefined, {
|
|
199
|
+
granularity: "grapheme",
|
|
200
|
+
}).segment(text)) {
|
|
201
|
+
if (index >= startOffset && safeStart === text.length)
|
|
202
|
+
safeStart = index;
|
|
203
|
+
if (index + segment.length <= endOffset)
|
|
204
|
+
safeEnd = index + segment.length;
|
|
205
|
+
if (index >= endOffset)
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
return `${safeStart > 0 ? "…" : ""}${text.slice(safeStart, safeEnd)}${safeEnd < text.length ? "…" : ""}`;
|
|
209
|
+
}
|
|
210
|
+
function candidateUrl(candidate) {
|
|
211
|
+
return candidate.destination?.url ?? candidate.page.url;
|
|
212
|
+
}
|
|
213
|
+
function compareCandidates(a, b) {
|
|
214
|
+
const aUrl = candidateUrl(a);
|
|
215
|
+
const bUrl = candidateUrl(b);
|
|
216
|
+
return (b.rank - a.rank ||
|
|
217
|
+
b.score - a.score ||
|
|
218
|
+
(aUrl < bUrl ? -1 : aUrl > bUrl ? 1 : 0));
|
|
219
|
+
}
|
|
220
|
+
function sourceText(sources, patterns, fallback) {
|
|
221
|
+
return (sources.find((text) => {
|
|
222
|
+
const normalized = normalize(text);
|
|
223
|
+
return patterns.some((pattern) => contains(normalized, pattern));
|
|
224
|
+
}) ??
|
|
225
|
+
sources.find((text) => text) ??
|
|
226
|
+
fallback);
|
|
227
|
+
}
|
|
228
|
+
/** Preserve page-mode weighting, cross-section coverage, and destination selection. */
|
|
229
|
+
function pageCandidates(index, terms) {
|
|
230
|
+
const phrase = terms.join(" ");
|
|
231
|
+
const patterns = terms.map((text) => ({ text }));
|
|
232
|
+
const results = [];
|
|
233
|
+
for (const page of index.pages) {
|
|
234
|
+
const title = normalize(page.title);
|
|
235
|
+
const description = normalize(page.description);
|
|
236
|
+
const sections = page.sections.map((section) => ({
|
|
237
|
+
section,
|
|
238
|
+
title: normalize(section.title),
|
|
239
|
+
text: normalize(section.text),
|
|
240
|
+
}));
|
|
241
|
+
const weights = terms.map((term) => {
|
|
242
|
+
if (title.includes(term))
|
|
243
|
+
return 8;
|
|
244
|
+
if (sections.some((section) => section.title.includes(term)))
|
|
245
|
+
return 4;
|
|
246
|
+
if (description.includes(term))
|
|
247
|
+
return 2;
|
|
248
|
+
return sections.some((section) => section.text.includes(term)) ? 1 : 0;
|
|
249
|
+
});
|
|
250
|
+
if (weights.some((weight) => weight === 0))
|
|
251
|
+
continue;
|
|
252
|
+
const metadataMatch = terms.every((term) => title.includes(term) || description.includes(term));
|
|
253
|
+
const matches = sections
|
|
254
|
+
.map((entry) => ({
|
|
255
|
+
...entry,
|
|
256
|
+
score: terms.reduce((score, term) => score +
|
|
257
|
+
(entry.title.includes(term)
|
|
258
|
+
? 4
|
|
259
|
+
: entry.text.includes(term)
|
|
260
|
+
? 1
|
|
261
|
+
: 0), 0),
|
|
262
|
+
}))
|
|
263
|
+
.filter((entry) => terms.every((term) => title.includes(term) ||
|
|
264
|
+
description.includes(term) ||
|
|
265
|
+
entry.title.includes(term) ||
|
|
266
|
+
entry.text.includes(term)));
|
|
267
|
+
// A stable sort preserves document order when multiple sections tie.
|
|
268
|
+
matches.sort((a, b) => b.score - a.score);
|
|
269
|
+
const best = matches[0]?.section;
|
|
270
|
+
results.push({
|
|
271
|
+
page,
|
|
272
|
+
destination: metadataMatch ? undefined : best,
|
|
273
|
+
source: sourceText([
|
|
274
|
+
best?.text ?? "",
|
|
275
|
+
page.description,
|
|
276
|
+
...page.sections.map((section) => section.text),
|
|
277
|
+
], patterns, page.title),
|
|
278
|
+
score: weights.reduce((sum, weight) => sum + weight, 0) +
|
|
279
|
+
(title === phrase
|
|
280
|
+
? 24
|
|
281
|
+
: sections.some((section) => section.title === phrase)
|
|
282
|
+
? 12
|
|
283
|
+
: 0),
|
|
284
|
+
rank: 0,
|
|
285
|
+
patterns,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
return results;
|
|
289
|
+
}
|
|
290
|
+
/** Single insertion, deletion, substitution, or adjacent transposition, on code points. */
|
|
291
|
+
function singleEdit(query, token) {
|
|
292
|
+
if (token.length > 66)
|
|
293
|
+
return false;
|
|
294
|
+
const text = Array.from(token);
|
|
295
|
+
if (Math.abs(query.length - text.length) > 1)
|
|
296
|
+
return false;
|
|
297
|
+
let start = 0;
|
|
298
|
+
while (start < query.length && query[start] === text[start])
|
|
299
|
+
start++;
|
|
300
|
+
if (start === Math.min(query.length, text.length))
|
|
301
|
+
return true;
|
|
302
|
+
if (query.length === text.length) {
|
|
303
|
+
if (query.slice(start + 1).join("") === text.slice(start + 1).join(""))
|
|
304
|
+
return true;
|
|
305
|
+
return (query[start] === text[start + 1] &&
|
|
306
|
+
query[start + 1] === text[start] &&
|
|
307
|
+
query.slice(start + 2).join("") === text.slice(start + 2).join(""));
|
|
308
|
+
}
|
|
309
|
+
return query.length > text.length
|
|
310
|
+
? query.slice(start + 1).join("") === text.slice(start).join("")
|
|
311
|
+
: query.slice(start).join("") === text.slice(start + 1).join("");
|
|
312
|
+
}
|
|
313
|
+
function fieldScore(fields, pattern) {
|
|
314
|
+
return Math.max(0, ...fields
|
|
315
|
+
.filter((field) => contains(field.text, pattern))
|
|
316
|
+
.map((field) => field.weight));
|
|
317
|
+
}
|
|
318
|
+
function matchFields(fields, terms, fuzzy, required = []) {
|
|
319
|
+
const patterns = terms.map((text) => ({ text }));
|
|
320
|
+
const weights = patterns.map((pattern) => fieldScore(fields, pattern));
|
|
321
|
+
const missing = weights.flatMap((weight, index) => (weight ? [] : [index]));
|
|
322
|
+
if (!missing.length)
|
|
323
|
+
return {
|
|
324
|
+
patterns,
|
|
325
|
+
score: weights.reduce((a, b) => a + b, 0),
|
|
326
|
+
corrected: false,
|
|
327
|
+
};
|
|
328
|
+
const missingIndex = missing[0];
|
|
329
|
+
if (!fuzzy || missing.length !== 1 || missingIndex === undefined)
|
|
330
|
+
return;
|
|
331
|
+
const query = Array.from(terms[missingIndex] ?? "");
|
|
332
|
+
if (query.length < 4 || query.length > 32)
|
|
333
|
+
return;
|
|
334
|
+
const needsOwnCorrection = required.length > 0 &&
|
|
335
|
+
!patterns.some((pattern) => fieldScore(required, pattern));
|
|
336
|
+
let correction;
|
|
337
|
+
let correctionWeight = 0;
|
|
338
|
+
for (const field of fields) {
|
|
339
|
+
if (field.weight < correctionWeight)
|
|
340
|
+
continue;
|
|
341
|
+
for (const token of field.text.split(" ")) {
|
|
342
|
+
if (singleEdit(query, token) &&
|
|
343
|
+
(!needsOwnCorrection ||
|
|
344
|
+
fieldScore(required, { text: token, whole: true }) > 0) &&
|
|
345
|
+
(field.weight > correctionWeight ||
|
|
346
|
+
correction === undefined ||
|
|
347
|
+
token < correction)) {
|
|
348
|
+
correction = token;
|
|
349
|
+
correctionWeight = field.weight;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (!correction)
|
|
354
|
+
return;
|
|
355
|
+
patterns[missingIndex] = { text: correction, whole: true };
|
|
356
|
+
weights[missingIndex] = correctionWeight;
|
|
357
|
+
return {
|
|
358
|
+
patterns,
|
|
359
|
+
score: weights.reduce((a, b) => a + b, 0),
|
|
360
|
+
corrected: true,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
/** Score only page metadata plus a single coherent section, never sibling bodies. */
|
|
364
|
+
function sectionCandidates(index, terms, fuzzy) {
|
|
365
|
+
const phrase = terms.join(" ");
|
|
366
|
+
const results = [];
|
|
367
|
+
for (const page of index.pages) {
|
|
368
|
+
const title = normalize(page.title);
|
|
369
|
+
const metadata = [
|
|
370
|
+
{ text: title, weight: 8 },
|
|
371
|
+
{ text: normalize(page.description), weight: 2 },
|
|
372
|
+
];
|
|
373
|
+
const pageMatch = matchFields(metadata, terms, fuzzy);
|
|
374
|
+
let pageCandidate;
|
|
375
|
+
if (pageMatch) {
|
|
376
|
+
const exact = title === phrase;
|
|
377
|
+
pageCandidate = {
|
|
378
|
+
page,
|
|
379
|
+
source: sourceText([page.description, ...page.sections.map((section) => section.text)], pageMatch.patterns, page.title),
|
|
380
|
+
score: pageMatch.score + (exact ? 24 : 0),
|
|
381
|
+
rank: pageMatch.corrected ? 0 : exact ? 2 : 1,
|
|
382
|
+
patterns: pageMatch.patterns,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
for (const section of page.sections) {
|
|
386
|
+
const heading = normalize(section.title);
|
|
387
|
+
const body = normalize(section.text);
|
|
388
|
+
const ownFields = [
|
|
389
|
+
{ text: heading, weight: 4 },
|
|
390
|
+
{ text: body, weight: 1 },
|
|
391
|
+
];
|
|
392
|
+
const match = matchFields([...metadata, ...ownFields], terms, fuzzy, ownFields);
|
|
393
|
+
if (!match?.patterns.some((pattern) => fieldScore(ownFields, pattern)))
|
|
394
|
+
continue;
|
|
395
|
+
// Repeated page headings without their own matching body add no useful destination.
|
|
396
|
+
if (pageMatch &&
|
|
397
|
+
heading === title &&
|
|
398
|
+
!match.patterns.some((pattern) => contains(body, pattern)))
|
|
399
|
+
continue;
|
|
400
|
+
const exact = heading === phrase;
|
|
401
|
+
const candidate = {
|
|
402
|
+
page,
|
|
403
|
+
destination: section.url === page.url ? undefined : section,
|
|
404
|
+
source: sourceText([section.text, page.description], match.patterns, section.title || page.title),
|
|
405
|
+
score: match.score + (exact ? 12 : 0),
|
|
406
|
+
rank: match.corrected ? 0 : exact ? 2 : 1,
|
|
407
|
+
patterns: match.patterns,
|
|
408
|
+
};
|
|
409
|
+
if (candidate.destination)
|
|
410
|
+
results.push(candidate);
|
|
411
|
+
else if (!pageCandidate ||
|
|
412
|
+
compareCandidates(candidate, pageCandidate) < 0)
|
|
413
|
+
pageCandidate = candidate;
|
|
414
|
+
}
|
|
415
|
+
if (pageCandidate)
|
|
416
|
+
results.push(pageCandidate);
|
|
417
|
+
}
|
|
418
|
+
return results;
|
|
419
|
+
}
|
|
420
|
+
// Greater than the largest raw score: 32 terms × weight 8 + title bonus 24.
|
|
421
|
+
const sectionTierWeight = 281;
|
|
422
|
+
function displayResult(candidate) {
|
|
423
|
+
const { page, destination, patterns } = candidate;
|
|
424
|
+
const isSection = destination !== undefined && destination.url !== page.url;
|
|
425
|
+
const trail = isSection
|
|
426
|
+
? [
|
|
427
|
+
...(page.breadcrumbs ?? []),
|
|
428
|
+
page.title,
|
|
429
|
+
...(destination.breadcrumbs ?? []),
|
|
430
|
+
]
|
|
431
|
+
: [...(page.breadcrumbs ?? [])];
|
|
432
|
+
const snippet = excerpt(candidate.source, patterns);
|
|
433
|
+
return {
|
|
434
|
+
kind: isSection ? "section" : "page",
|
|
435
|
+
title: page.title,
|
|
436
|
+
pageUrl: page.url,
|
|
437
|
+
url: candidateUrl(candidate),
|
|
438
|
+
...(destination?.title ? { section: destination.title } : {}),
|
|
439
|
+
breadcrumbs: trail,
|
|
440
|
+
excerpt: snippet,
|
|
441
|
+
score: candidate.rank * sectionTierWeight + candidate.score,
|
|
442
|
+
matches: {
|
|
443
|
+
title: matchRanges(page.title, patterns),
|
|
444
|
+
section: matchRanges(destination?.title ?? "", patterns),
|
|
445
|
+
excerpt: matchRanges(snippet, patterns),
|
|
446
|
+
breadcrumbs: trail.map((label) => matchRanges(label, patterns)),
|
|
447
|
+
},
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
/** Query without filesystem, network, or DOM access; page mode preserves legacy ranking. */
|
|
451
|
+
export function search(index, query, options = {}) {
|
|
452
|
+
validateSearchIndex(index);
|
|
453
|
+
if (options === null || typeof options !== "object" || Array.isArray(options))
|
|
454
|
+
throw new TypeError("Search options must be an object.");
|
|
455
|
+
const limit = options.limit ?? 10;
|
|
456
|
+
const mode = options.mode === undefined ? "pages" : options.mode;
|
|
457
|
+
const fuzzy = options.fuzzy === undefined ? false : options.fuzzy;
|
|
458
|
+
if (!Number.isInteger(limit) || limit < 0 || limit > 100)
|
|
459
|
+
throw new RangeError("Search limit must be an integer from 0 to 100.");
|
|
460
|
+
if (mode !== "pages" && mode !== "sections")
|
|
461
|
+
throw new RangeError("Search mode must be pages or sections.");
|
|
462
|
+
if (typeof fuzzy !== "boolean")
|
|
463
|
+
throw new TypeError("Search fuzzy option must be a boolean.");
|
|
464
|
+
if (fuzzy && mode !== "sections")
|
|
465
|
+
throw new RangeError("Fuzzy search requires sections mode.");
|
|
466
|
+
if (typeof query !== "string")
|
|
467
|
+
throw new TypeError("Search query must be a string.");
|
|
468
|
+
if (query.length > 512)
|
|
469
|
+
throw new RangeError("Search query must not exceed 512 characters.");
|
|
470
|
+
const terms = [...new Set(normalize(query).split(" ").filter(Boolean))];
|
|
471
|
+
if (terms.length > 32)
|
|
472
|
+
throw new RangeError("Search query must not exceed 32 unique terms.");
|
|
473
|
+
if (!terms.length || !limit)
|
|
474
|
+
return [];
|
|
475
|
+
const results = mode === "sections"
|
|
476
|
+
? sectionCandidates(index, terms, false)
|
|
477
|
+
: pageCandidates(index, terms);
|
|
478
|
+
if (fuzzy && results.length < limit) {
|
|
479
|
+
const literalUrls = new Set(results.map(candidateUrl));
|
|
480
|
+
results.push(...sectionCandidates(index, terms, true).filter((candidate) => candidate.rank === 0 && !literalUrls.has(candidateUrl(candidate))));
|
|
481
|
+
}
|
|
482
|
+
results.sort(compareCandidates);
|
|
483
|
+
// Original-text mapping and excerpt windows are computed only for final hits.
|
|
484
|
+
return results.slice(0, limit).map(displayResult);
|
|
485
|
+
}
|
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.0-dev.
|
|
3
|
+
"version": "0.1.0-dev.8884a23",
|
|
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",
|