@wgtechlabs/mdd-engine 0.1.0-dev.8a1ef77 → 0.1.0-pr.9d5492b

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 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,39 @@ 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
+ // [{ 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
+
75
106
  ## Markdown and components
76
107
 
77
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`:
@@ -121,7 +152,7 @@ bun run coverage
121
152
  bun audit
122
153
  ```
123
154
 
124
- `bun run build` emits Node ESM and TypeScript declarations to `dist/`. `bun run smoke` creates a package archive, installs it into an isolated consumer, and runs it with the current Node binary. It checks that Bun is unavailable inside the consumer and that all three base-path fixtures compile. On POSIX systems, `MDD_TEST_NODE_BINARIES` can contain colon-separated Node binary paths to exercise the same archive across versions.
155
+ `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.
125
156
 
126
157
  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.
127
158
 
@@ -0,0 +1,3 @@
1
+ import type { Diagnostic, Footer } from "./types.js";
2
+ /** The shared footer is data, never an article or executable author markup. */
3
+ export declare function parseFooter(source: string, file: string, diagnostics: Diagnostic[]): Footer | undefined;
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 type { Asset, CompileOptions, CompileResult, Diagnostic, Heading, Metadata, NavigationItem, Page, Site, Theme, } from "./types.js";
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
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
  };
@@ -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
@@ -46,6 +46,10 @@ const htmlRenderer = unified()
46
46
  .use(rehypeStringify);
47
47
  const markdownRenderer = unified().use(remarkGfm).use(remarkStringify);
48
48
  const componentNames = new Set(["note", "tip", "warning", "details"]);
49
+ /** Shared syntax parser; callers validate the allowed document context. */
50
+ export function parseMarkdown(source) {
51
+ return parser.parse(source);
52
+ }
49
53
  /** Keep the same first-wins, reachable definitions that the HTML renderer uses. */
50
54
  function retainReferencedDefinitions(tree) {
51
55
  const definitions = new Map();
@@ -99,7 +103,7 @@ function unsafeUrl(url, image) {
99
103
  !(image ? ["http", "https"] : ["http", "https", "mailto", "tel"]).includes(scheme));
100
104
  }
101
105
  export function parseDocument(source, file, diagnostics) {
102
- const tree = parser.parse(source);
106
+ const tree = parseMarkdown(source);
103
107
  retainReferencedDefinitions(tree);
104
108
  const metadata = {};
105
109
  const headings = [];
package/dist/project.d.ts CHANGED
@@ -5,6 +5,7 @@ export interface Project {
5
5
  title?: string;
6
6
  theme: Theme;
7
7
  files: string[];
8
+ footer?: string;
8
9
  }
9
10
  export declare class AuthoringError extends Error {
10
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 { root, contentsRoot, title, theme, files };
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,4 @@
1
+ import { type SearchIndex } from "./search.js";
2
+ import type { Site } from "./types.js";
3
+ /** Build only from successfully compiled pages; no source paths or HTML are indexed. */
4
+ export declare function createSearchIndex(site: Site): SearchIndex;
@@ -0,0 +1,62 @@
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 { validateSearchIndex, } from "./search.js";
7
+ const parser = unified().use(remarkParse).use(remarkGfm);
8
+ const blockTypes = new Set(["paragraph", "code", "tableCell", "listItem"]);
9
+ function indexPage(page) {
10
+ const sections = [{ title: "", url: page.url, text: "" }];
11
+ let headingIndex = 0;
12
+ const chunks = [[]];
13
+ visit(parser.parse(page.markdown), (node) => {
14
+ if (node.type === "definition" || node.type === "html")
15
+ return SKIP;
16
+ if (node.type === "heading") {
17
+ const heading = page.headings[headingIndex++];
18
+ if (!heading ||
19
+ heading.text !== nodeText(node) ||
20
+ heading.depth !== node.depth) {
21
+ throw new TypeError("Search indexing requires matching compiled Markdown and headings.");
22
+ }
23
+ sections.push({
24
+ title: heading.text,
25
+ url: `${page.url}#${encodeURIComponent(heading.id)}`,
26
+ text: "",
27
+ });
28
+ chunks.push([]);
29
+ return SKIP;
30
+ }
31
+ const text = chunks[chunks.length - 1];
32
+ if (!text)
33
+ return;
34
+ if (blockTypes.has(node.type) || node.type === "break")
35
+ text.push(" ");
36
+ if (node.type === "text" ||
37
+ node.type === "code" ||
38
+ node.type === "inlineCode")
39
+ text.push(node.value);
40
+ if ((node.type === "image" || node.type === "imageReference") && node.alt)
41
+ text.push(node.alt);
42
+ });
43
+ if (headingIndex !== page.headings.length) {
44
+ throw new TypeError("Search indexing requires matching compiled Markdown and headings.");
45
+ }
46
+ sections.forEach((entry, index) => {
47
+ entry.text = (chunks[index] ?? []).join("").replace(/\s+/gu, " ").trim();
48
+ });
49
+ return {
50
+ url: page.url,
51
+ title: page.title,
52
+ description: page.description ?? "",
53
+ sections,
54
+ };
55
+ }
56
+ /** Build only from successfully compiled pages; no source paths or HTML are indexed. */
57
+ export function createSearchIndex(site) {
58
+ const index = { version: 1, pages: site.pages.map(indexPage) };
59
+ index.pages.sort((a, b) => (a.url < b.url ? -1 : a.url > b.url ? 1 : 0));
60
+ validateSearchIndex(index);
61
+ return index;
62
+ }
@@ -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.0-dev.8a1ef77",
3
+ "version": "0.1.0-pr.9d5492b",
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",