@tenphi/docs 0.13.0 → 0.16.0
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 +22 -0
- package/dist/config/index.d.ts +5 -3
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +2 -240
- package/dist/config/schema.json +715 -63
- package/dist/config-CmoSqvii.js +774 -0
- package/dist/config-CmoSqvii.js.map +1 -0
- package/dist/content/index.d.ts +1 -1
- package/dist/content/index.js +1 -1
- package/dist/content-Btz1o52C.js +1340 -0
- package/dist/content-Btz1o52C.js.map +1 -0
- package/dist/index.d.ts +27 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +41 -199
- package/dist/index.js.map +1 -1
- package/dist/markdown/index.d.ts +7 -3
- package/dist/markdown/index.d.ts.map +1 -1
- package/dist/markdown/index.js +10 -7
- package/dist/markdown/index.js.map +1 -1
- package/dist/testing/index.js +1 -1
- package/dist/{types-m7SvwRsi.d.ts → types-CH6E2v-J.d.ts} +95 -27
- package/dist/types-CH6E2v-J.d.ts.map +1 -0
- package/package.json +11 -3
- package/dist/config/index.js.map +0 -1
- package/dist/content-oJ_TI-Qv.js +0 -786
- package/dist/content-oJ_TI-Qv.js.map +0 -1
- package/dist/types-m7SvwRsi.d.ts.map +0 -1
|
@@ -0,0 +1,1340 @@
|
|
|
1
|
+
import { i as normalizeDocsConfig } from "./config-CmoSqvii.js";
|
|
2
|
+
import { cloneAst, parseMarkdown, removeRenderedTitle, serializeMarkdown, stripLeadingBadgeBlock } from "./markdown/index.js";
|
|
3
|
+
import { lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
8
|
+
import { promisify } from "node:util";
|
|
9
|
+
import { glob } from "tinyglobby";
|
|
10
|
+
import { visit } from "unist-util-visit";
|
|
11
|
+
import { parse } from "yaml";
|
|
12
|
+
import { fromHtml } from "hast-util-from-html";
|
|
13
|
+
import { defaultSchema, sanitize } from "hast-util-sanitize";
|
|
14
|
+
import { toHtml } from "hast-util-to-html";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import npa from "npm-package-arg";
|
|
17
|
+
import pacote from "pacote";
|
|
18
|
+
//#region src/html/index.ts
|
|
19
|
+
const schema = {
|
|
20
|
+
...defaultSchema,
|
|
21
|
+
strip: [
|
|
22
|
+
"script",
|
|
23
|
+
"style",
|
|
24
|
+
"iframe",
|
|
25
|
+
"object",
|
|
26
|
+
"embed",
|
|
27
|
+
"template"
|
|
28
|
+
],
|
|
29
|
+
protocols: {
|
|
30
|
+
...defaultSchema.protocols,
|
|
31
|
+
href: [...defaultSchema.protocols?.href ?? [], "source"]
|
|
32
|
+
},
|
|
33
|
+
attributes: {
|
|
34
|
+
...defaultSchema.attributes,
|
|
35
|
+
"*": defaultSchema.attributes?.["*"]?.filter((attribute) => typeof attribute !== "string" || ![
|
|
36
|
+
"color",
|
|
37
|
+
"bgColor",
|
|
38
|
+
"align",
|
|
39
|
+
"border",
|
|
40
|
+
"cellPadding",
|
|
41
|
+
"cellSpacing"
|
|
42
|
+
].includes(attribute)) ?? []
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
/** Sanitize raw Markdown HTML while preserving separate inline opening/closing tags. */
|
|
46
|
+
async function sanitizeMarkup(html, rewrite) {
|
|
47
|
+
const closing = /^<\/([a-z][a-z\d-]*)\s*>$/i.exec(html.trim());
|
|
48
|
+
if (closing) {
|
|
49
|
+
const tag = closing[1]?.toLowerCase() ?? "";
|
|
50
|
+
return schema.tagNames?.includes(tag) ? `</${tag}>` : "";
|
|
51
|
+
}
|
|
52
|
+
const tree = sanitize(fromHtml(html, { fragment: true }), schema);
|
|
53
|
+
const tasks = [];
|
|
54
|
+
visit(tree, "element", (element) => {
|
|
55
|
+
for (const [property, kind] of [["href", "link"], ["src", "image"]]) {
|
|
56
|
+
const value = element.properties[property];
|
|
57
|
+
if (typeof value !== "string") continue;
|
|
58
|
+
tasks.push(rewrite(value, kind).then((resolved) => {
|
|
59
|
+
element.properties[property] = resolved;
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
await Promise.all(tasks);
|
|
64
|
+
let result = toHtml(tree);
|
|
65
|
+
const opening = /^<([a-z][a-z\d-]*)(?:\s+(?:[^<>"']|"[^"]*"|'[^']*')*)?\s*>$/i.exec(html.trim());
|
|
66
|
+
const first = tree.children[0];
|
|
67
|
+
if (opening && tree.children.length === 1 && first?.type === "element" && first.tagName === opening[1]?.toLowerCase() && first.children.length === 0) result = result.replace(new RegExp(`</${first.tagName}>$`), "");
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
//#endregion
|
|
71
|
+
//#region src/npm/index.ts
|
|
72
|
+
const DEFAULT_REGISTRY = "https://registry.npmjs.org/";
|
|
73
|
+
const LOCK_FILE = "cookbook.lock.json";
|
|
74
|
+
async function resolvePackageLock(requested, options = {}) {
|
|
75
|
+
const parsed = npa(requested);
|
|
76
|
+
if (![
|
|
77
|
+
"tag",
|
|
78
|
+
"version",
|
|
79
|
+
"range"
|
|
80
|
+
].includes(parsed.type)) throw new Error(`Only npm registry package specifiers are supported (received ${parsed.type}).`);
|
|
81
|
+
const registry = options.registry ?? DEFAULT_REGISTRY;
|
|
82
|
+
const manifest = await pacote.manifest(requested, {
|
|
83
|
+
registry,
|
|
84
|
+
...options.cacheDir ? { cache: options.cacheDir } : {},
|
|
85
|
+
fullMetadata: true
|
|
86
|
+
});
|
|
87
|
+
if (!manifest.name || !manifest.version || !manifest._integrity) throw new Error(`Registry metadata for ${requested} did not include version and integrity.`);
|
|
88
|
+
return {
|
|
89
|
+
requested,
|
|
90
|
+
resolved: `${manifest.name}@${manifest.version}`,
|
|
91
|
+
registry,
|
|
92
|
+
integrity: manifest._integrity
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
async function readDocsLock(root) {
|
|
96
|
+
try {
|
|
97
|
+
const value = JSON.parse(await readFile(join(root, LOCK_FILE), "utf8"));
|
|
98
|
+
validateLock(value);
|
|
99
|
+
return value;
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (isMissing(error)) return void 0;
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
async function writeDocsLock(root, lock) {
|
|
106
|
+
validateLock(lock);
|
|
107
|
+
const temporary = await mkdtemp(join(root, ".cookbook-lock-"));
|
|
108
|
+
try {
|
|
109
|
+
await writeFile(join(temporary, LOCK_FILE), `${JSON.stringify(lock, null, 2)}\n`, "utf8");
|
|
110
|
+
await rename(join(temporary, LOCK_FILE), join(root, LOCK_FILE));
|
|
111
|
+
} finally {
|
|
112
|
+
await rm(temporary, {
|
|
113
|
+
recursive: true,
|
|
114
|
+
force: true
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** Reconcile declarations, including the first lock and removed sources. */
|
|
119
|
+
async function reconcileDocsLock(config, existing, options = {}) {
|
|
120
|
+
const configured = (config.content?.sources ?? []).filter((source) => "package" in source);
|
|
121
|
+
const unique = /* @__PURE__ */ new Map();
|
|
122
|
+
for (const declaration of configured) {
|
|
123
|
+
const previous = unique.get(declaration.package);
|
|
124
|
+
if (previous?.registry && declaration.registry && previous.registry !== declaration.registry) throw new Error(`Package source ${declaration.package} declares conflicting registries.`);
|
|
125
|
+
unique.set(declaration.package, {
|
|
126
|
+
...declaration,
|
|
127
|
+
...previous?.registry ? { registry: previous.registry } : {}
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
const declarations = [...unique.values()];
|
|
131
|
+
const selectors = options.packages ?? [];
|
|
132
|
+
for (const selector of selectors) if (!declarations.some((source) => source.package === selector || packageNameFromSpecifier(source.package) === selector)) throw new Error(`No configured package source matches "${selector}".`);
|
|
133
|
+
const sources = [];
|
|
134
|
+
for (const declaration of declarations) {
|
|
135
|
+
const current = existing?.sources.find((source) => source.requested === declaration.package);
|
|
136
|
+
const selected = selectors.length === 0 || selectors.some((selector) => selector === declaration.package || selector === packageNameFromSpecifier(declaration.package));
|
|
137
|
+
if (!selected && current && (!declaration.registry || declaration.registry === current.registry)) {
|
|
138
|
+
sources.push(current);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (!selected) throw new Error(`Package source ${declaration.package} is not locked. Run "cookbook update" without a package selector.`);
|
|
142
|
+
const previous = current ?? existing?.sources.find((source) => packageNameFromSpecifier(source.requested) === packageNameFromSpecifier(declaration.package));
|
|
143
|
+
const registry = declaration.registry ?? previous?.registry;
|
|
144
|
+
const next = await (options.resolve ?? resolvePackageLock)(declaration.package, registry ? { registry } : {});
|
|
145
|
+
if (previous?.vendored) next.vendored = next.integrity === previous.integrity ? previous.vendored : `.cookbook/vendor/${createHash("sha256").update(next.integrity).digest("hex")}`;
|
|
146
|
+
sources.push(next);
|
|
147
|
+
}
|
|
148
|
+
return defaultLock(sources);
|
|
149
|
+
}
|
|
150
|
+
function validateLock(lock) {
|
|
151
|
+
if (lock.schemaVersion !== 1 || !Array.isArray(lock.sources)) throw new Error("Unsupported or invalid cookbook.lock.json.");
|
|
152
|
+
for (const source of lock.sources) {
|
|
153
|
+
if (!source.requested || !source.resolved || !source.registry || !source.integrity) throw new Error("Every lock source requires requested, resolved, registry, and integrity.");
|
|
154
|
+
if (npa(source.resolved).type !== "version") throw new Error(`Locked source must use an exact version: ${source.resolved}.`);
|
|
155
|
+
if (source.vendored && (source.vendored.startsWith("/") || source.vendored.split(/[\\/]/).includes(".."))) throw new Error(`Vendored package path must stay within the project: ${source.vendored}.`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
async function materializePackage(source, config, projectRoot) {
|
|
159
|
+
if (source.vendored) {
|
|
160
|
+
if (!projectRoot) throw new Error(`Vendored source ${source.resolved} requires a project root.`);
|
|
161
|
+
const vendored = resolve(projectRoot, source.vendored);
|
|
162
|
+
if (!inside$1(projectRoot, vendored)) throw new Error(`Vendored package path escapes the project root: ${source.vendored}.`);
|
|
163
|
+
if ((await readFile(join(vendored, ".cookbook-integrity"), "utf8")).trim() !== source.integrity) throw new Error(`Vendored package integrity marker does not match ${source.resolved}.`);
|
|
164
|
+
await validateExtractedTree(vendored, config);
|
|
165
|
+
return vendored;
|
|
166
|
+
}
|
|
167
|
+
const cacheRoot = resolve(config.cacheDir || join(homedir(), ".cache", "cookbook"), "artifacts");
|
|
168
|
+
const key = createHash("sha256").update(source.integrity).digest("hex");
|
|
169
|
+
const destination = join(cacheRoot, key);
|
|
170
|
+
const marker = join(destination, ".cookbook-integrity");
|
|
171
|
+
try {
|
|
172
|
+
if ((await readFile(marker, "utf8")).trim() === source.integrity) {
|
|
173
|
+
await validateExtractedTree(destination, config);
|
|
174
|
+
return destination;
|
|
175
|
+
}
|
|
176
|
+
} catch (error) {
|
|
177
|
+
if (!isMissing(error)) throw error;
|
|
178
|
+
}
|
|
179
|
+
await mkdir(cacheRoot, { recursive: true });
|
|
180
|
+
const temporary = await mkdtemp(join(cacheRoot, ".extract-"));
|
|
181
|
+
try {
|
|
182
|
+
const tarball = await pacote.tarball(source.resolved, {
|
|
183
|
+
registry: source.registry,
|
|
184
|
+
integrity: source.integrity,
|
|
185
|
+
cache: join(cacheRoot, "_cacache")
|
|
186
|
+
});
|
|
187
|
+
if (tarball.byteLength > config.maxArtifactBytes) throw new Error(`Package artifact is ${tarball.byteLength} bytes; limit is ${config.maxArtifactBytes}.`);
|
|
188
|
+
await pacote.extract(source.resolved, temporary, {
|
|
189
|
+
registry: source.registry,
|
|
190
|
+
integrity: source.integrity,
|
|
191
|
+
cache: join(cacheRoot, "_cacache")
|
|
192
|
+
});
|
|
193
|
+
await validateExtractedTree(temporary, config);
|
|
194
|
+
await writeFile(join(temporary, ".cookbook-integrity"), `${source.integrity}\n`);
|
|
195
|
+
await rm(destination, {
|
|
196
|
+
recursive: true,
|
|
197
|
+
force: true
|
|
198
|
+
});
|
|
199
|
+
await rename(temporary, destination);
|
|
200
|
+
return destination;
|
|
201
|
+
} catch (error) {
|
|
202
|
+
await rm(temporary, {
|
|
203
|
+
recursive: true,
|
|
204
|
+
force: true
|
|
205
|
+
});
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
async function validateExtractedTree(root, config) {
|
|
210
|
+
let files = 0;
|
|
211
|
+
let bytes = 0;
|
|
212
|
+
const pending = [root];
|
|
213
|
+
while (pending.length > 0) {
|
|
214
|
+
const directory = pending.pop();
|
|
215
|
+
if (!directory) break;
|
|
216
|
+
for (const name of await readdir(directory)) {
|
|
217
|
+
if (name === ".cookbook-integrity") continue;
|
|
218
|
+
const path = join(directory, name);
|
|
219
|
+
const info = await lstat(path);
|
|
220
|
+
const rel = relative(root, path);
|
|
221
|
+
if (rel.startsWith(`..${sep}`) || rel === "..") throw new Error(`Package path escapes artifact root: ${rel}.`);
|
|
222
|
+
if (rel.split(sep).length > config.maxPathDepth) throw new Error(`Package path exceeds maximum depth: ${rel}.`);
|
|
223
|
+
if (info.isSymbolicLink()) throw new Error(`Package symlinks are not allowed: ${rel}.`);
|
|
224
|
+
if (info.isDirectory()) pending.push(path);
|
|
225
|
+
else if (info.isFile()) {
|
|
226
|
+
files += 1;
|
|
227
|
+
bytes += info.size;
|
|
228
|
+
if (info.size > config.maxAssetBytes) throw new Error(`Package file exceeds maximum size: ${rel}.`);
|
|
229
|
+
if (files > config.maxFiles || bytes > config.maxUnpackedBytes) throw new Error("Package exceeds configured file-count or unpacked-size limit.");
|
|
230
|
+
} else throw new Error(`Unsupported package entry type: ${rel}.`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
async function discoverPackage(root) {
|
|
235
|
+
const manifest = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
|
|
236
|
+
const hints = manifest.cookbook;
|
|
237
|
+
const homeCandidates = [
|
|
238
|
+
hints?.index,
|
|
239
|
+
"README.md",
|
|
240
|
+
"readme.md"
|
|
241
|
+
].filter((candidate) => Boolean(candidate));
|
|
242
|
+
let home;
|
|
243
|
+
for (const candidate of homeCandidates) {
|
|
244
|
+
assertSafePackagePath(candidate, "cookbook.index");
|
|
245
|
+
try {
|
|
246
|
+
const path = resolve(root, candidate);
|
|
247
|
+
if (!inside$1(root, path)) throw new Error(`cookbook.index escapes the package root: ${candidate}.`);
|
|
248
|
+
if ((await stat(path)).isFile()) {
|
|
249
|
+
home = candidate;
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
} catch (error) {
|
|
253
|
+
if (!isMissing(error)) throw error;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const patterns = hints?.include?.length ? hints.include : ["docs/**/*.{md,mdx}", "docs/**/*.{png,jpg,jpeg,gif,webp,avif,svg,pdf,txt,zip}"];
|
|
257
|
+
for (const pattern of patterns) assertSafePackagePath(pattern, "cookbook.include");
|
|
258
|
+
for (const pattern of hints?.exclude ?? []) assertSafePackagePath(pattern, "cookbook.exclude");
|
|
259
|
+
const discovered = await glob(patterns, {
|
|
260
|
+
cwd: root,
|
|
261
|
+
onlyFiles: true,
|
|
262
|
+
dot: false,
|
|
263
|
+
ignore: hints?.exclude ?? []
|
|
264
|
+
});
|
|
265
|
+
for (const path of discovered) {
|
|
266
|
+
assertSafePackagePath(path, "discovered package path");
|
|
267
|
+
if (!inside$1(root, resolve(root, path))) throw new Error(`Discovered path escapes the package root: ${path}.`);
|
|
268
|
+
}
|
|
269
|
+
const pages = discovered.filter((path) => /\.mdx?$/i.test(path));
|
|
270
|
+
if (home && !pages.includes(home)) pages.unshift(home);
|
|
271
|
+
const assets = discovered.filter((path) => !/\.mdx?$/i.test(path));
|
|
272
|
+
return {
|
|
273
|
+
root,
|
|
274
|
+
manifest,
|
|
275
|
+
...home ? { home } : {},
|
|
276
|
+
pages,
|
|
277
|
+
assets
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
function packageNameFromSpecifier(specifier) {
|
|
281
|
+
const parsed = npa(specifier);
|
|
282
|
+
if (!parsed.name) throw new Error(`Invalid npm package specifier: ${specifier}.`);
|
|
283
|
+
return parsed.name;
|
|
284
|
+
}
|
|
285
|
+
function lockForSource(lock, requested) {
|
|
286
|
+
const exact = lock?.sources.find((source) => source.requested === requested);
|
|
287
|
+
if (exact) return exact;
|
|
288
|
+
throw new Error(`Package source ${requested} is not locked. Run "cookbook update" to reconcile ${LOCK_FILE} with docs.config.ts.`);
|
|
289
|
+
}
|
|
290
|
+
/** Reject paths and glob patterns that could address files outside a package. */
|
|
291
|
+
function assertSafePackagePath(path, field) {
|
|
292
|
+
const normalized = path.replaceAll("\\", "/");
|
|
293
|
+
if (path.trim() === "" || path.includes("\0") || isAbsolute(path) || normalized.startsWith("/") || /^[a-z]:\//i.test(normalized) || normalized.includes("..")) throw new Error(`${field} must stay within the package root: ${path}.`);
|
|
294
|
+
}
|
|
295
|
+
function defaultLock(sources) {
|
|
296
|
+
return {
|
|
297
|
+
schemaVersion: 1,
|
|
298
|
+
sources
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
function isMissing(error) {
|
|
302
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
303
|
+
}
|
|
304
|
+
function inside$1(root, path) {
|
|
305
|
+
const rel = relative(resolve(root), resolve(path));
|
|
306
|
+
return rel === "" || !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel);
|
|
307
|
+
}
|
|
308
|
+
//#endregion
|
|
309
|
+
//#region src/graph/index.ts
|
|
310
|
+
const MARKDOWN_EXTENSIONS = [".md", ".mdx"];
|
|
311
|
+
const execFileAsync = promisify(execFile);
|
|
312
|
+
const FRONTMATTER_KEYS = /* @__PURE__ */ new Set([
|
|
313
|
+
"aliases",
|
|
314
|
+
"title",
|
|
315
|
+
"description",
|
|
316
|
+
"slug",
|
|
317
|
+
"draft",
|
|
318
|
+
"sidebar",
|
|
319
|
+
"tableOfContents",
|
|
320
|
+
"editUrl",
|
|
321
|
+
"template",
|
|
322
|
+
"hero",
|
|
323
|
+
"lastUpdated",
|
|
324
|
+
"prev",
|
|
325
|
+
"next",
|
|
326
|
+
"banner",
|
|
327
|
+
"pagefind",
|
|
328
|
+
"head"
|
|
329
|
+
]);
|
|
330
|
+
async function createDocsGraph(options = {}) {
|
|
331
|
+
const root = resolve(options.root ?? process.cwd());
|
|
332
|
+
const normalized = normalizeDocsConfig(options.config);
|
|
333
|
+
const config = options.base ? {
|
|
334
|
+
...normalized,
|
|
335
|
+
build: {
|
|
336
|
+
...normalized.build,
|
|
337
|
+
base: options.base
|
|
338
|
+
}
|
|
339
|
+
} : normalized;
|
|
340
|
+
const diagnostics = [];
|
|
341
|
+
const collected = await collectSources(root, config, options.lock ?? await readDocsLock(root), diagnostics);
|
|
342
|
+
const entries = [];
|
|
343
|
+
const routeMap = /* @__PURE__ */ new Map();
|
|
344
|
+
const absoluteMap = /* @__PURE__ */ new Map();
|
|
345
|
+
const sourceMap = /* @__PURE__ */ new Map();
|
|
346
|
+
const repositoryMap = /* @__PURE__ */ new Map();
|
|
347
|
+
for (const source of collected) {
|
|
348
|
+
const entry = await readEntry(source, config, diagnostics);
|
|
349
|
+
if (!entry) continue;
|
|
350
|
+
const existing = routeMap.get(entry.route);
|
|
351
|
+
if (existing) {
|
|
352
|
+
diagnostics.push({
|
|
353
|
+
code: "DOCS_DUPLICATE_ROUTE",
|
|
354
|
+
severity: "error",
|
|
355
|
+
message: `Route ${entry.route} is owned by both ${existing.sourcePath} and ${entry.sourcePath}.`,
|
|
356
|
+
file: entry.sourcePath,
|
|
357
|
+
related: [{
|
|
358
|
+
file: existing.sourcePath,
|
|
359
|
+
message: "First route owner."
|
|
360
|
+
}]
|
|
361
|
+
});
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
routeMap.set(entry.route, entry);
|
|
365
|
+
const absolute = normalizeFs(entry.absolutePath);
|
|
366
|
+
absoluteMap.set(absolute, [...absoluteMap.get(absolute) ?? [], entry]);
|
|
367
|
+
sourceMap.set(entry.sourcePath, [...sourceMap.get(entry.sourcePath) ?? [], entry]);
|
|
368
|
+
sourceMap.set(entry.id, [entry]);
|
|
369
|
+
if (source.repository) repositoryMap.set(entry.id, source.repository);
|
|
370
|
+
entries.push(entry);
|
|
371
|
+
}
|
|
372
|
+
if (entries.length === 0) diagnostics.push({
|
|
373
|
+
code: "DOCS_NO_PAGES",
|
|
374
|
+
severity: "error",
|
|
375
|
+
message: "No documentation pages were found. Add README.md, docs/**/*.md, or configure content.sources."
|
|
376
|
+
});
|
|
377
|
+
const redirects = resolveRedirects(config.redirects, entries, routeMap, diagnostics);
|
|
378
|
+
for (const [alias, target] of Object.entries(redirects)) {
|
|
379
|
+
const entry = routeMap.get(target);
|
|
380
|
+
if (entry) routeMap.set(alias, entry);
|
|
381
|
+
}
|
|
382
|
+
for (const entry of entries) await transformEntry(entry, absoluteMap, routeMap, repositoryMap, config, diagnostics);
|
|
383
|
+
validateNavigation(config.navigation.items ?? [], routeMap, diagnostics);
|
|
384
|
+
for (const tab of config.navigation.tabs ?? []) {
|
|
385
|
+
validateNavigation([{
|
|
386
|
+
label: tab.label,
|
|
387
|
+
link: tab.link
|
|
388
|
+
}], routeMap, diagnostics);
|
|
389
|
+
validateNavigation(tab.items ?? [], routeMap, diagnostics);
|
|
390
|
+
}
|
|
391
|
+
entries.sort((left, right) => left.route.localeCompare(right.route));
|
|
392
|
+
return {
|
|
393
|
+
redirects,
|
|
394
|
+
root,
|
|
395
|
+
config,
|
|
396
|
+
entries,
|
|
397
|
+
routes: entries.map((entry) => ({
|
|
398
|
+
route: entry.route,
|
|
399
|
+
entryId: entry.id,
|
|
400
|
+
sourcePath: entry.sourcePath,
|
|
401
|
+
title: entry.title,
|
|
402
|
+
...entry.frontmatter.sidebar !== void 0 ? { sidebar: entry.frontmatter.sidebar } : {}
|
|
403
|
+
})),
|
|
404
|
+
assets: Array.from(new Map(entries.flatMap((entry) => entry.assets).map((asset) => [asset.publicPath ?? `${asset.original}:${asset.line ?? ""}`, asset])).values()),
|
|
405
|
+
diagnostics,
|
|
406
|
+
entryByRoute(route) {
|
|
407
|
+
return routeMap.get(normalizeRoute(route));
|
|
408
|
+
},
|
|
409
|
+
entryBySource(sourcePath, sourceId) {
|
|
410
|
+
const matches = sourceMap.get(sourcePath) ?? [];
|
|
411
|
+
return sourceId ? matches.find((entry) => entry.sourceId === sourceId) : matches.length === 1 ? matches[0] : void 0;
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
function resolveRedirects(configured, entries, routes, diagnostics) {
|
|
416
|
+
const requested = /* @__PURE__ */ new Map();
|
|
417
|
+
const error = (message) => diagnostics.push({
|
|
418
|
+
code: "DOCS_REDIRECT_INVALID",
|
|
419
|
+
severity: "error",
|
|
420
|
+
message
|
|
421
|
+
});
|
|
422
|
+
const add = (from, to) => {
|
|
423
|
+
try {
|
|
424
|
+
const alias = normalizeRoute(from);
|
|
425
|
+
const target = normalizeRoute(to);
|
|
426
|
+
if (routes.has(alias) || requested.has(alias)) error(`Redirect ${alias} conflicts with an existing page or redirect.`);
|
|
427
|
+
else requested.set(alias, target);
|
|
428
|
+
} catch (cause) {
|
|
429
|
+
error(errorMessage(cause));
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
for (const [from, to] of Object.entries(configured)) add(from, to);
|
|
433
|
+
for (const entry of entries) for (const alias of entry.frontmatter.aliases ?? []) add(`${entry.routeBase}/${alias}`, entry.route);
|
|
434
|
+
const result = {};
|
|
435
|
+
for (const [alias, target] of requested) {
|
|
436
|
+
const seen = /* @__PURE__ */ new Set([alias]);
|
|
437
|
+
let current = target;
|
|
438
|
+
while (requested.has(current) && !seen.has(current)) {
|
|
439
|
+
seen.add(current);
|
|
440
|
+
current = requested.get(current);
|
|
441
|
+
}
|
|
442
|
+
if (seen.has(current)) error(`Redirect cycle includes ${alias}.`);
|
|
443
|
+
else if (!routes.has(current)) error(`Redirect ${alias} points to missing document ${current}.`);
|
|
444
|
+
else result[alias] = current;
|
|
445
|
+
}
|
|
446
|
+
return result;
|
|
447
|
+
}
|
|
448
|
+
async function collectSources(root, config, lock, diagnostics) {
|
|
449
|
+
const declarations = config.content.sources ?? await conventionSources(root);
|
|
450
|
+
const results = [];
|
|
451
|
+
const identities = /* @__PURE__ */ new Set();
|
|
452
|
+
const sourceIds = /* @__PURE__ */ new Set();
|
|
453
|
+
for (const [index, declaration] of declarations.entries()) {
|
|
454
|
+
const sourceId = declaration.id ?? `source-${index + 1}`;
|
|
455
|
+
if (sourceIds.has(sourceId)) {
|
|
456
|
+
diagnostics.push({
|
|
457
|
+
code: "DOCS_DUPLICATE_SOURCE",
|
|
458
|
+
severity: "error",
|
|
459
|
+
message: `Source id ${sourceId} is declared more than once.`
|
|
460
|
+
});
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
sourceIds.add(sourceId);
|
|
464
|
+
try {
|
|
465
|
+
const found = await collectDeclaration(root, declaration, config, lock, sourceId);
|
|
466
|
+
if (found.length === 0) diagnostics.push({
|
|
467
|
+
code: "DOCS_SOURCE_NOT_FOUND",
|
|
468
|
+
severity: "error",
|
|
469
|
+
message: `Source did not match any files: ${sourceLabel(declaration)}.`
|
|
470
|
+
});
|
|
471
|
+
for (const source of found) {
|
|
472
|
+
const identity = `${sourceId}:${normalizeFs(source.absolutePath)}`;
|
|
473
|
+
if (identities.has(identity)) continue;
|
|
474
|
+
identities.add(identity);
|
|
475
|
+
results.push(source);
|
|
476
|
+
}
|
|
477
|
+
} catch (error) {
|
|
478
|
+
diagnostics.push({
|
|
479
|
+
code: error instanceof OutsideRootError ? "DOCS_SOURCE_OUTSIDE_ROOT" : "DOCS_SOURCE_NOT_FOUND",
|
|
480
|
+
severity: "error",
|
|
481
|
+
message: errorMessage(error)
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return results;
|
|
486
|
+
}
|
|
487
|
+
async function conventionSources(root) {
|
|
488
|
+
const sources = [];
|
|
489
|
+
if (await isFile(resolve(root, "README.md"))) sources.push({
|
|
490
|
+
file: "README.md",
|
|
491
|
+
route: "/"
|
|
492
|
+
});
|
|
493
|
+
if (await isDirectory(resolve(root, "docs"))) sources.push({
|
|
494
|
+
glob: "docs/**/*.{md,mdx}",
|
|
495
|
+
base: "docs"
|
|
496
|
+
});
|
|
497
|
+
return sources;
|
|
498
|
+
}
|
|
499
|
+
async function collectDeclaration(root, declaration, config, lock, sourceId) {
|
|
500
|
+
if ("package" in declaration) {
|
|
501
|
+
const packageLock = lockForSource(lock, declaration.package);
|
|
502
|
+
if (declaration.registry && packageLock.registry !== declaration.registry) throw new Error(`Registry changed for ${declaration.package}. Run "cookbook update".`);
|
|
503
|
+
const sourceRoot = await materializePackage(packageLock, config.build, root);
|
|
504
|
+
const discovery = await discoverPackage(sourceRoot);
|
|
505
|
+
const repository = config.content.localizeRepositoryLinks ? repositoryMetadata(discovery.manifest.repository) : void 0;
|
|
506
|
+
const patterns = declaration.include?.length ? declaration.include : discovery.pages;
|
|
507
|
+
for (const pattern of patterns) assertSafePackagePath(pattern, "Package source include");
|
|
508
|
+
for (const pattern of declaration.exclude ?? []) assertSafePackagePath(pattern, "Package source exclude");
|
|
509
|
+
if (declaration.index) assertSafePackagePath(declaration.index, "Package source index");
|
|
510
|
+
const files = await glob(patterns, {
|
|
511
|
+
cwd: sourceRoot,
|
|
512
|
+
onlyFiles: true,
|
|
513
|
+
ignore: declaration.exclude ?? []
|
|
514
|
+
});
|
|
515
|
+
const index = declaration.index ?? discovery.home;
|
|
516
|
+
return files.filter((path) => MARKDOWN_EXTENSIONS.includes(extname(path).toLowerCase())).map((path) => {
|
|
517
|
+
assertSafePackagePath(path, "Discovered package source");
|
|
518
|
+
const absolutePath = resolve(sourceRoot, path);
|
|
519
|
+
if (!inside(sourceRoot, absolutePath)) throw new OutsideRootError(path);
|
|
520
|
+
return {
|
|
521
|
+
sourceId,
|
|
522
|
+
...declaration.routeBase ? { routeBase: declaration.routeBase } : {},
|
|
523
|
+
absolutePath,
|
|
524
|
+
sourcePath: path,
|
|
525
|
+
sourceRoot,
|
|
526
|
+
route: path === index ? normalizeRoute(declaration.routeBase ?? "/") : routeForPath(path, "docs", declaration.routeBase),
|
|
527
|
+
trust: declaration.trust ?? "markdown",
|
|
528
|
+
packageLock,
|
|
529
|
+
...repository ? { repository } : {}
|
|
530
|
+
};
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
const sourceRoot = resolve(root, declaration.root ?? ".");
|
|
534
|
+
const repository = config.content.localizeRepositoryLinks ? repositoryMetadata(config.site.repository) : void 0;
|
|
535
|
+
if ("file" in declaration) {
|
|
536
|
+
const absolutePath = resolveSourcePath(sourceRoot, declaration.file, config.content.allowOutsideRoot);
|
|
537
|
+
if (!await isFile(absolutePath)) return [];
|
|
538
|
+
return [{
|
|
539
|
+
sourceId,
|
|
540
|
+
...declaration.routeBase ? { routeBase: declaration.routeBase } : {},
|
|
541
|
+
absolutePath,
|
|
542
|
+
sourcePath: toPosix(relative(sourceRoot, absolutePath)),
|
|
543
|
+
editPath: toPosix(relative(inside(root, absolutePath) ? root : sourceRoot, absolutePath)),
|
|
544
|
+
sourceRoot,
|
|
545
|
+
route: declaration.route ? normalizeRoute(`${declaration.routeBase ?? ""}/${declaration.route}`) : routeForPath(declaration.file, void 0, declaration.routeBase),
|
|
546
|
+
...declaration.title ? { title: declaration.title } : {},
|
|
547
|
+
...declaration.description ? { description: declaration.description } : {},
|
|
548
|
+
...declaration.navigation !== void 0 ? { navigation: declaration.navigation } : {},
|
|
549
|
+
trust: "mdx",
|
|
550
|
+
...repository ? { repository } : {}
|
|
551
|
+
}];
|
|
552
|
+
}
|
|
553
|
+
const patterns = Array.isArray(declaration.glob) ? declaration.glob : [declaration.glob];
|
|
554
|
+
return (await glob(patterns, {
|
|
555
|
+
cwd: sourceRoot,
|
|
556
|
+
onlyFiles: true,
|
|
557
|
+
dot: false,
|
|
558
|
+
ignore: [
|
|
559
|
+
"**/_*/**",
|
|
560
|
+
"**/_*",
|
|
561
|
+
...declaration.exclude ?? []
|
|
562
|
+
]
|
|
563
|
+
})).map((path) => {
|
|
564
|
+
const absolutePath = resolveSourcePath(sourceRoot, path, config.content.allowOutsideRoot);
|
|
565
|
+
return {
|
|
566
|
+
sourceId,
|
|
567
|
+
...declaration.routeBase ? { routeBase: declaration.routeBase } : {},
|
|
568
|
+
absolutePath,
|
|
569
|
+
sourcePath: toPosix(relative(sourceRoot, absolutePath)),
|
|
570
|
+
editPath: toPosix(relative(inside(root, absolutePath) ? root : sourceRoot, absolutePath)),
|
|
571
|
+
sourceRoot,
|
|
572
|
+
route: routeForPath(path, declaration.base, declaration.routeBase),
|
|
573
|
+
...declaration.navigation === false ? { navigation: false } : {},
|
|
574
|
+
trust: "mdx",
|
|
575
|
+
...repository ? { repository } : {}
|
|
576
|
+
};
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
async function readEntry(source, config, diagnostics) {
|
|
580
|
+
if (extname(source.sourcePath).toLowerCase() === ".mdx" && source.trust !== "mdx") {
|
|
581
|
+
diagnostics.push({
|
|
582
|
+
code: "DOCS_UNTRUSTED_MDX",
|
|
583
|
+
severity: "error",
|
|
584
|
+
message: `Package MDX requires trust: 'mdx': ${source.sourcePath}.`,
|
|
585
|
+
file: source.sourcePath,
|
|
586
|
+
hint: "Keep package sources in Markdown-safe mode or explicitly trust this locked artifact."
|
|
587
|
+
});
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
const original = await readFile(source.absolutePath, "utf8");
|
|
591
|
+
let parsedMatter;
|
|
592
|
+
try {
|
|
593
|
+
parsedMatter = parseFrontmatter(original);
|
|
594
|
+
} catch (error) {
|
|
595
|
+
diagnostics.push({
|
|
596
|
+
code: "DOCS_FRONTMATTER_INVALID",
|
|
597
|
+
severity: "error",
|
|
598
|
+
message: `Could not parse frontmatter: ${errorMessage(error)}.`,
|
|
599
|
+
file: source.sourcePath
|
|
600
|
+
});
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
const frontmatter = parsedMatter.data;
|
|
604
|
+
if (frontmatter.sidebar === void 0 && source.navigation !== void 0) frontmatter.sidebar = source.navigation;
|
|
605
|
+
const diagnosticCount = diagnostics.length;
|
|
606
|
+
const metadata = {};
|
|
607
|
+
for (const key of Object.keys(frontmatter)) if (!FRONTMATTER_KEYS.has(key)) {
|
|
608
|
+
if (config.content.frontmatter !== "reject") {
|
|
609
|
+
metadata[key] = frontmatter[key];
|
|
610
|
+
delete frontmatter[key];
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
diagnostics.push({
|
|
614
|
+
code: "DOCS_FRONTMATTER_INVALID",
|
|
615
|
+
severity: "error",
|
|
616
|
+
message: `Unknown frontmatter key "${key}".`,
|
|
617
|
+
file: source.sourcePath
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
validateFrontmatter(frontmatter, source.sourcePath, diagnostics);
|
|
621
|
+
if (diagnostics.length > diagnosticCount) return void 0;
|
|
622
|
+
const mdx = extname(source.sourcePath).toLowerCase() === ".mdx";
|
|
623
|
+
const parsed = parseMarkdown(parsedMatter.content, { mdx });
|
|
624
|
+
const route = normalizeRoute(frontmatter.slug !== void 0 ? `${source.routeBase ?? ""}/${frontmatter.slug}` : source.route ?? routeForPath(source.sourcePath));
|
|
625
|
+
const title = frontmatter.title ?? source.title ?? parsed.firstHeading ?? titleFromFile(source.sourcePath);
|
|
626
|
+
const description = frontmatter.description ?? source.description ?? parsed.description;
|
|
627
|
+
await resolvePageMetadata(frontmatter, source, config);
|
|
628
|
+
const duplicateTitles = /* @__PURE__ */ new Map();
|
|
629
|
+
for (const heading of parsed.headings) {
|
|
630
|
+
const count = (duplicateTitles.get(heading.text) ?? 0) + 1;
|
|
631
|
+
duplicateTitles.set(heading.text, count);
|
|
632
|
+
if (count > 1) diagnostics.push({
|
|
633
|
+
code: "DOCS_HEADING_DUPLICATE",
|
|
634
|
+
severity: "warning",
|
|
635
|
+
message: `Repeated heading "${heading.text}" receives the generated ID "${heading.slug}".`,
|
|
636
|
+
file: source.sourcePath,
|
|
637
|
+
...heading.line ? { line: heading.line } : {}
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
return {
|
|
641
|
+
id: `${source.sourceId}:${source.sourcePath}`,
|
|
642
|
+
sourceId: source.sourceId,
|
|
643
|
+
routeBase: source.routeBase ?? "/",
|
|
644
|
+
metadata,
|
|
645
|
+
sourcePath: source.sourcePath,
|
|
646
|
+
absolutePath: source.absolutePath,
|
|
647
|
+
sourceRoot: source.sourceRoot,
|
|
648
|
+
route,
|
|
649
|
+
title,
|
|
650
|
+
...description ? { description } : {},
|
|
651
|
+
frontmatter,
|
|
652
|
+
headings: parsed.headings,
|
|
653
|
+
body: parsedMatter.content,
|
|
654
|
+
transformedBody: parsedMatter.content,
|
|
655
|
+
ast: parsed.ast,
|
|
656
|
+
links: [],
|
|
657
|
+
assets: [],
|
|
658
|
+
trust: source.trust,
|
|
659
|
+
...source.packageLock ? { package: {
|
|
660
|
+
requested: source.packageLock.requested,
|
|
661
|
+
resolved: source.packageLock.resolved
|
|
662
|
+
} } : {}
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
async function resolvePageMetadata(frontmatter, source, config) {
|
|
666
|
+
if (frontmatter.editUrl === void 0 && config.editLink) frontmatter.editUrl = `${config.editLink.baseUrl.endsWith("/") ? config.editLink.baseUrl : `${config.editLink.baseUrl}/`}${(source.editPath ?? source.sourcePath).replace(/^\/+/, "")}`;
|
|
667
|
+
if (frontmatter.lastUpdated === false) return;
|
|
668
|
+
if (frontmatter.lastUpdated === void 0 && config.lastUpdated === false) return;
|
|
669
|
+
if (frontmatter.lastUpdated instanceof Date) return;
|
|
670
|
+
try {
|
|
671
|
+
const { stdout } = await execFileAsync("git", [
|
|
672
|
+
"log",
|
|
673
|
+
"-1",
|
|
674
|
+
"--format=%ct",
|
|
675
|
+
"--",
|
|
676
|
+
source.absolutePath
|
|
677
|
+
], { cwd: source.sourceRoot });
|
|
678
|
+
const timestamp = Number(stdout.trim());
|
|
679
|
+
if (Number.isFinite(timestamp) && timestamp > 0) frontmatter.lastUpdated = /* @__PURE__ */ new Date(timestamp * 1e3);
|
|
680
|
+
} catch {}
|
|
681
|
+
}
|
|
682
|
+
async function transformEntry(entry, absoluteMap, routeMap, repositoryMap, config, diagnostics) {
|
|
683
|
+
sanitizeUntrustedFrontmatter(entry, config, diagnostics);
|
|
684
|
+
const ast = cloneAst(entry.ast);
|
|
685
|
+
if (config.markdown.stripLeadingBadges) stripLeadingBadgeBlock(ast);
|
|
686
|
+
removeRenderedTitle(ast, entry.title);
|
|
687
|
+
const htmlTasks = [];
|
|
688
|
+
visit(ast, "html", (node) => {
|
|
689
|
+
const policy = rawHtmlPolicy(entry, config);
|
|
690
|
+
if (policy === "allow") return;
|
|
691
|
+
if (policy === "sanitize") {
|
|
692
|
+
htmlTasks.push(sanitizeMarkup(node.value, async (url, kind) => {
|
|
693
|
+
const reference = {
|
|
694
|
+
type: "link",
|
|
695
|
+
url,
|
|
696
|
+
children: [],
|
|
697
|
+
...node.position ? { position: node.position } : {}
|
|
698
|
+
};
|
|
699
|
+
if (kind === "link") await rewriteLink(reference, entry, absoluteMap, routeMap, repositoryMap, config, diagnostics);
|
|
700
|
+
else {
|
|
701
|
+
const image = {
|
|
702
|
+
type: "image",
|
|
703
|
+
url,
|
|
704
|
+
alt: "",
|
|
705
|
+
...node.position ? { position: node.position } : {}
|
|
706
|
+
};
|
|
707
|
+
await rewriteAsset(image, entry, config, diagnostics);
|
|
708
|
+
reference.url = image.url;
|
|
709
|
+
}
|
|
710
|
+
return reference.url;
|
|
711
|
+
}).then((html) => {
|
|
712
|
+
node.value = html;
|
|
713
|
+
}));
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
diagnostics.push({
|
|
717
|
+
code: policy === "reject" ? "DOCS_RAW_HTML_REJECTED" : "DOCS_RAW_HTML_STRIPPED",
|
|
718
|
+
severity: policy === "reject" ? "error" : "warning",
|
|
719
|
+
message: `Raw HTML was ${policy === "reject" ? "rejected" : "removed"}: ${entry.sourcePath}.`,
|
|
720
|
+
file: entry.sourcePath,
|
|
721
|
+
...lineData(node)
|
|
722
|
+
});
|
|
723
|
+
node.value = "";
|
|
724
|
+
});
|
|
725
|
+
await Promise.all(htmlTasks);
|
|
726
|
+
const referenceKinds = /* @__PURE__ */ new Map();
|
|
727
|
+
visit(ast, (node) => {
|
|
728
|
+
if (node.type !== "linkReference" && node.type !== "imageReference") return;
|
|
729
|
+
const key = node.identifier.toLowerCase();
|
|
730
|
+
const kind = node.type === "imageReference" ? "image" : "link";
|
|
731
|
+
const previous = referenceKinds.get(key);
|
|
732
|
+
referenceKinds.set(key, previous && previous !== kind ? "mixed" : kind);
|
|
733
|
+
});
|
|
734
|
+
const linkTasks = [];
|
|
735
|
+
visit(ast, (node) => {
|
|
736
|
+
if (node.type === "link") linkTasks.push(rewriteLink(node, entry, absoluteMap, routeMap, repositoryMap, config, diagnostics));
|
|
737
|
+
else if (node.type === "image") linkTasks.push(rewriteAsset(node, entry, config, diagnostics));
|
|
738
|
+
else if (node.type === "definition") {
|
|
739
|
+
const kind = referenceKinds.get(node.identifier.toLowerCase());
|
|
740
|
+
if (kind === "image") linkTasks.push(rewriteAsset(node, entry, config, diagnostics));
|
|
741
|
+
else if (kind === "link") linkTasks.push(rewriteLink(node, entry, absoluteMap, routeMap, repositoryMap, config, diagnostics));
|
|
742
|
+
else if (kind === "mixed") linkTasks.push(rewriteDefinition(node, entry, absoluteMap, routeMap, repositoryMap, config, diagnostics));
|
|
743
|
+
}
|
|
744
|
+
});
|
|
745
|
+
await Promise.all(linkTasks);
|
|
746
|
+
await rewriteFrontmatterReferences(entry, absoluteMap, routeMap, repositoryMap, config, diagnostics);
|
|
747
|
+
entry.ast = ast;
|
|
748
|
+
entry.transformedBody = serializeMarkdown(ast, { mdx: extname(entry.sourcePath).toLowerCase() === ".mdx" });
|
|
749
|
+
}
|
|
750
|
+
function sanitizeUntrustedFrontmatter(entry, config, diagnostics) {
|
|
751
|
+
if (entry.trust !== "markdown") return;
|
|
752
|
+
let removed = false;
|
|
753
|
+
const plainText = (value) => {
|
|
754
|
+
const sanitized = value.replace(/<!--[\s\S]*?-->|<[^>]*>/g, "");
|
|
755
|
+
if (sanitized !== value) removed = true;
|
|
756
|
+
return sanitized;
|
|
757
|
+
};
|
|
758
|
+
entry.title = plainText(entry.title);
|
|
759
|
+
if (entry.frontmatter.title !== void 0) entry.frontmatter.title = plainText(entry.frontmatter.title);
|
|
760
|
+
const hero = entry.frontmatter.hero;
|
|
761
|
+
if (hero?.title !== void 0) hero.title = plainText(hero.title);
|
|
762
|
+
if (hero?.tagline !== void 0) hero.tagline = plainText(hero.tagline);
|
|
763
|
+
if (hero?.image && "html" in hero.image) {
|
|
764
|
+
delete hero.image;
|
|
765
|
+
removed = true;
|
|
766
|
+
}
|
|
767
|
+
if (entry.frontmatter.banner) entry.frontmatter.banner.content = plainText(entry.frontmatter.banner.content);
|
|
768
|
+
if (entry.frontmatter.head) {
|
|
769
|
+
delete entry.frontmatter.head;
|
|
770
|
+
removed = true;
|
|
771
|
+
}
|
|
772
|
+
if (removed) diagnostics.push({
|
|
773
|
+
code: config.markdown.rawHtml === "reject" ? "DOCS_RAW_HTML_REJECTED" : "DOCS_UNTRUSTED_HTML",
|
|
774
|
+
severity: config.markdown.rawHtml === "reject" ? "error" : "warning",
|
|
775
|
+
message: config.markdown.rawHtml === "reject" ? `HTML-capable frontmatter is not allowed by markdown.rawHtml: ${entry.sourcePath}.` : "HTML-capable frontmatter was sanitized in package Markdown. Use trust: 'mdx' only for packages you trust.",
|
|
776
|
+
file: entry.sourcePath
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
function rawHtmlPolicy(entry, config) {
|
|
780
|
+
if (config.markdown.rawHtml === "reject") return "reject";
|
|
781
|
+
if (config.markdown.rawHtml === "strip") return "strip";
|
|
782
|
+
return entry.trust === "markdown" ? "sanitize" : config.markdown.rawHtml;
|
|
783
|
+
}
|
|
784
|
+
async function rewriteFrontmatterReferences(entry, absoluteMap, routeMap, repositoryMap, config, diagnostics) {
|
|
785
|
+
for (const action of entry.frontmatter.hero?.actions ?? []) {
|
|
786
|
+
const node = {
|
|
787
|
+
type: "link",
|
|
788
|
+
url: action.link,
|
|
789
|
+
children: []
|
|
790
|
+
};
|
|
791
|
+
await rewriteLink(node, entry, absoluteMap, routeMap, repositoryMap, config, diagnostics);
|
|
792
|
+
action.link = node.url;
|
|
793
|
+
}
|
|
794
|
+
const image = entry.frontmatter.hero?.image;
|
|
795
|
+
if (!image || "html" in image) return;
|
|
796
|
+
if ("file" in image) {
|
|
797
|
+
image.file = await rewriteFrontmatterAsset(image.file, entry, config, diagnostics);
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
const [dark, light] = await Promise.all([rewriteFrontmatterAsset(image.dark, entry, config, diagnostics), rewriteFrontmatterAsset(image.light, entry, config, diagnostics)]);
|
|
801
|
+
image.dark = dark;
|
|
802
|
+
image.light = light;
|
|
803
|
+
}
|
|
804
|
+
async function rewriteFrontmatterAsset(url, entry, config, diagnostics) {
|
|
805
|
+
const asset = await materializeAsset(url, entry, config, diagnostics, {});
|
|
806
|
+
entry.assets.push(asset);
|
|
807
|
+
return asset.resolved ?? url;
|
|
808
|
+
}
|
|
809
|
+
async function rewriteLink(node, entry, absoluteMap, routeMap, repositoryMap, config, diagnostics) {
|
|
810
|
+
const reference = {
|
|
811
|
+
original: node.url,
|
|
812
|
+
...lineData(node)
|
|
813
|
+
};
|
|
814
|
+
entry.links.push(reference);
|
|
815
|
+
if (node.url.startsWith("source:")) {
|
|
816
|
+
const { pathname, query, fragment } = splitReference(node.url.slice(7));
|
|
817
|
+
const slash = pathname.indexOf("/");
|
|
818
|
+
const sourceId = slash < 0 ? pathname : pathname.slice(0, slash);
|
|
819
|
+
const sourcePath = safeDecode(pathname.slice(slash + 1));
|
|
820
|
+
const target = [...absoluteMap.values()].flat().find((candidate) => candidate.sourceId === sourceId && candidate.sourcePath === sourcePath);
|
|
821
|
+
if (!target || slash < 0) missingLink(diagnostics, entry, node, node.url, config);
|
|
822
|
+
else {
|
|
823
|
+
node.url = `${withBase(target.route, config.build.base)}${query}${fragment ? `#${fragment}` : ""}`;
|
|
824
|
+
reference.resolved = node.url;
|
|
825
|
+
reference.targetSource = target.sourcePath;
|
|
826
|
+
validateFragment(fragment, target, entry, node, diagnostics, config);
|
|
827
|
+
}
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
if (unsafeProtocol(node.url)) {
|
|
831
|
+
diagnostic(diagnostics, "DOCS_LINK_UNSAFE", `Unsafe URL protocol: ${node.url}.`, entry, node);
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
if (isExternal(node.url)) {
|
|
835
|
+
const target = config.content.localizeRepositoryLinks ? localizedRepositoryTarget(node.url, entry, absoluteMap, repositoryMap.get(entry.id)) : void 0;
|
|
836
|
+
if (!target) return;
|
|
837
|
+
const { query, fragment } = splitReference(node.url);
|
|
838
|
+
node.url = `${withBase(target.route, config.build.base)}${query}${fragment ? `#${fragment}` : ""}`;
|
|
839
|
+
reference.resolved = node.url;
|
|
840
|
+
reference.targetSource = target.sourcePath;
|
|
841
|
+
if (fragment) reference.fragment = fragment;
|
|
842
|
+
validateFragment(fragment, target, entry, node, diagnostics, config);
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
if (node.url.startsWith("#")) return;
|
|
846
|
+
const { pathname, query, fragment } = splitReference(node.url);
|
|
847
|
+
if (pathname.startsWith("/")) {
|
|
848
|
+
const target = routeMap.get(normalizeRoute(pathname));
|
|
849
|
+
if (!target) missingLink(diagnostics, entry, node, node.url, config);
|
|
850
|
+
else {
|
|
851
|
+
node.url = `${withBase(target.route, config.build.base)}${query}${fragment ? `#${fragment}` : ""}`;
|
|
852
|
+
reference.resolved = node.url;
|
|
853
|
+
reference.targetSource = target.sourcePath;
|
|
854
|
+
if (fragment) reference.fragment = fragment;
|
|
855
|
+
validateFragment(fragment, target, entry, node, diagnostics, config);
|
|
856
|
+
}
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
const decoded = safeDecode(pathname);
|
|
860
|
+
const targetPath = resolve(dirname(entry.absolutePath), decoded);
|
|
861
|
+
if (!inside(entry.sourceRoot, targetPath)) {
|
|
862
|
+
diagnostic(diagnostics, "DOCS_SOURCE_OUTSIDE_ROOT", `Link escapes its allowed source root: ${node.url}.`, entry, node);
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
const target = findDocument(targetPath, absoluteMap, entry.sourceId);
|
|
866
|
+
if (!target) {
|
|
867
|
+
if (documentMatches(targetPath, absoluteMap).length > 1) {
|
|
868
|
+
diagnostic(diagnostics, "DOCS_AMBIGUOUS_LINK", `Link matches multiple source mounts: ${node.url}. Use source:<id>/<path> to choose a source.`, entry, node);
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
if (await isFile(targetPath)) {
|
|
872
|
+
const asset = await materializeAsset(node.url, entry, config, diagnostics, node);
|
|
873
|
+
if (asset.resolved) {
|
|
874
|
+
node.url = asset.resolved;
|
|
875
|
+
reference.resolved = asset.resolved;
|
|
876
|
+
}
|
|
877
|
+
entry.assets.push(asset);
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
missingLink(diagnostics, entry, node, node.url, config);
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
node.url = `${withBase(target.route, config.build.base)}${query}${fragment ? `#${fragment}` : ""}`;
|
|
884
|
+
reference.resolved = node.url;
|
|
885
|
+
reference.targetSource = target.sourcePath;
|
|
886
|
+
if (fragment) reference.fragment = fragment;
|
|
887
|
+
validateFragment(fragment, target, entry, node, diagnostics, config);
|
|
888
|
+
}
|
|
889
|
+
async function rewriteAsset(node, entry, config, diagnostics) {
|
|
890
|
+
const asset = await materializeAsset(node.url, entry, config, diagnostics, node);
|
|
891
|
+
entry.assets.push(asset);
|
|
892
|
+
if (asset.resolved) node.url = asset.resolved;
|
|
893
|
+
}
|
|
894
|
+
async function materializeAsset(url, entry, config, diagnostics, node) {
|
|
895
|
+
const asset = {
|
|
896
|
+
original: url,
|
|
897
|
+
...lineData(node)
|
|
898
|
+
};
|
|
899
|
+
if (unsafeProtocol(url)) {
|
|
900
|
+
diagnostic(diagnostics, "DOCS_ASSET_UNSAFE", `Unsafe asset URL: ${url}.`, entry, node);
|
|
901
|
+
return asset;
|
|
902
|
+
}
|
|
903
|
+
if (isExternal(url) || url.startsWith("#")) return asset;
|
|
904
|
+
const { pathname, query, fragment } = splitReference(url);
|
|
905
|
+
const absolute = resolve(dirname(entry.absolutePath), safeDecode(pathname));
|
|
906
|
+
if (!inside(entry.sourceRoot, absolute)) {
|
|
907
|
+
diagnostic(diagnostics, "DOCS_SOURCE_OUTSIDE_ROOT", `Asset escapes its allowed source root: ${url}.`, entry, node);
|
|
908
|
+
return asset;
|
|
909
|
+
}
|
|
910
|
+
try {
|
|
911
|
+
const info = await stat(absolute);
|
|
912
|
+
if (!info.isFile()) throw new Error("not a file");
|
|
913
|
+
if (info.size > config.build.maxAssetBytes) throw new Error(`asset exceeds ${config.build.maxAssetBytes} bytes`);
|
|
914
|
+
const hash = createHash("sha256").update(await readFile(absolute)).digest("hex").slice(0, 12);
|
|
915
|
+
const publicPath = withBase(`/_tasty-assets/${hash}-${basename(absolute)}`, config.build.base);
|
|
916
|
+
const resolved = `${publicPath}${query}${fragment ? `#${fragment}` : ""}`;
|
|
917
|
+
Object.assign(asset, {
|
|
918
|
+
resolved,
|
|
919
|
+
sourcePath: absolute,
|
|
920
|
+
publicPath,
|
|
921
|
+
hash,
|
|
922
|
+
bytes: info.size
|
|
923
|
+
});
|
|
924
|
+
} catch (error) {
|
|
925
|
+
diagnostic(diagnostics, "DOCS_ASSET_NOT_FOUND", `Asset not found or invalid: ${url} (${errorMessage(error)}).`, entry, node);
|
|
926
|
+
}
|
|
927
|
+
return asset;
|
|
928
|
+
}
|
|
929
|
+
async function rewriteDefinition(node, entry, absoluteMap, routeMap, repositoryMap, config, diagnostics) {
|
|
930
|
+
const { pathname } = splitReference(node.url);
|
|
931
|
+
if (documentMatches(resolve(dirname(entry.absolutePath), safeDecode(pathname)), absoluteMap).length > 0 || node.url.startsWith("source:") || pathname.startsWith("/")) await rewriteLink(node, entry, absoluteMap, routeMap, repositoryMap, config, diagnostics);
|
|
932
|
+
else await rewriteAsset(node, entry, config, diagnostics);
|
|
933
|
+
}
|
|
934
|
+
function findDocument(path, absoluteMap, sourceId) {
|
|
935
|
+
const matches = documentMatches(path, absoluteMap);
|
|
936
|
+
return matches.find((item) => item.sourceId === sourceId) ?? (matches.length === 1 ? matches[0] : void 0);
|
|
937
|
+
}
|
|
938
|
+
function documentMatches(path, absoluteMap) {
|
|
939
|
+
const candidates = [
|
|
940
|
+
path,
|
|
941
|
+
...MARKDOWN_EXTENSIONS.map((extension) => `${path}${extension}`),
|
|
942
|
+
...MARKDOWN_EXTENSIONS.map((extension) => resolve(path, `README${extension}`)),
|
|
943
|
+
...MARKDOWN_EXTENSIONS.map((extension) => resolve(path, `index${extension}`))
|
|
944
|
+
];
|
|
945
|
+
for (const candidate of candidates) {
|
|
946
|
+
const matches = absoluteMap.get(normalizeFs(candidate)) ?? [];
|
|
947
|
+
if (matches.length) return matches;
|
|
948
|
+
}
|
|
949
|
+
return [];
|
|
950
|
+
}
|
|
951
|
+
function validateFragment(fragment, target, source, node, diagnostics, config) {
|
|
952
|
+
if (!fragment) return;
|
|
953
|
+
const decoded = safeDecode(fragment);
|
|
954
|
+
if (!target.headings.some((heading) => heading.slug === decoded)) diagnostics.push({
|
|
955
|
+
code: "DOCS_FRAGMENT_NOT_FOUND",
|
|
956
|
+
severity: config.build.ci ? "error" : "warning",
|
|
957
|
+
message: `Heading fragment #${fragment} does not exist on ${target.route}.`,
|
|
958
|
+
file: source.sourcePath,
|
|
959
|
+
...lineData(node),
|
|
960
|
+
hint: `Known headings: ${target.headings.map((heading) => `#${heading.slug}`).join(", ") || "(none)"}.`
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
function missingLink(diagnostics, entry, node, url, config) {
|
|
964
|
+
diagnostics.push({
|
|
965
|
+
code: "DOCS_LINK_NOT_FOUND",
|
|
966
|
+
severity: config.build.strict ? "error" : "warning",
|
|
967
|
+
message: `Internal link target not found: ${url}.`,
|
|
968
|
+
file: entry.sourcePath,
|
|
969
|
+
...lineData(node)
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
function validateNavigation(items, routes, diagnostics) {
|
|
973
|
+
for (const item of items) {
|
|
974
|
+
if (typeof item === "string") {
|
|
975
|
+
if (!routes.has(normalizeRoute(item))) diagnostics.push({
|
|
976
|
+
code: "DOCS_NAV_TARGET_NOT_FOUND",
|
|
977
|
+
severity: "error",
|
|
978
|
+
message: `Navigation target does not exist: ${item}.`
|
|
979
|
+
});
|
|
980
|
+
} else if (item.link?.startsWith("/") && !routes.has(normalizeRoute(item.link))) diagnostics.push({
|
|
981
|
+
code: "DOCS_NAV_TARGET_NOT_FOUND",
|
|
982
|
+
severity: "error",
|
|
983
|
+
message: `Navigation target does not exist: ${item.link}.`
|
|
984
|
+
});
|
|
985
|
+
if (typeof item !== "string" && "items" in item) validateNavigation(item.items, routes, diagnostics);
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
function normalizeRoute(route) {
|
|
989
|
+
const segments = (route.split(/[?#]/, 1)[0]?.replace(/\\/g, "/").replace(/\/{2,}/g, "/") ?? "/").split("/").filter(Boolean);
|
|
990
|
+
if (segments.some((segment) => segment === "..")) throw new Error(`Route may not contain "..": ${route}.`);
|
|
991
|
+
const normalized = `/${segments.join("/")}`;
|
|
992
|
+
return normalized === "/" ? "/" : normalized.replace(/\/$/, "");
|
|
993
|
+
}
|
|
994
|
+
function routeForPath(path, base, routeBase) {
|
|
995
|
+
let relativePath = toPosix(path);
|
|
996
|
+
if (base) {
|
|
997
|
+
const normalizedBase = toPosix(base).replace(/^\.\//, "").replace(/\/$/, "");
|
|
998
|
+
if (relativePath === normalizedBase) relativePath = "";
|
|
999
|
+
else if (relativePath.startsWith(`${normalizedBase}/`)) relativePath = relativePath.slice(normalizedBase.length + 1);
|
|
1000
|
+
}
|
|
1001
|
+
relativePath = relativePath.replace(/\.(md|mdx)$/i, "");
|
|
1002
|
+
relativePath = relativePath.replace(/(^|\/)README$/i, "$1").replace(/(^|\/)index$/i, "$1");
|
|
1003
|
+
return normalizeRoute(`${routeBase ?? ""}/${relativePath}`);
|
|
1004
|
+
}
|
|
1005
|
+
function resolveSourcePath(root, path, allowOutsideRoot) {
|
|
1006
|
+
const absolute = isAbsolute(path) ? resolve(path) : resolve(root, path);
|
|
1007
|
+
if (!allowOutsideRoot && !inside(root, absolute)) throw new OutsideRootError(path);
|
|
1008
|
+
return absolute;
|
|
1009
|
+
}
|
|
1010
|
+
function inside(root, path) {
|
|
1011
|
+
const rel = relative(resolve(root), resolve(path));
|
|
1012
|
+
return rel === "" || !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel);
|
|
1013
|
+
}
|
|
1014
|
+
function splitReference(url) {
|
|
1015
|
+
const hashIndex = url.indexOf("#");
|
|
1016
|
+
const fragment = hashIndex >= 0 ? url.slice(hashIndex + 1) : "";
|
|
1017
|
+
const withoutHash = hashIndex >= 0 ? url.slice(0, hashIndex) : url;
|
|
1018
|
+
const queryIndex = withoutHash.indexOf("?");
|
|
1019
|
+
return {
|
|
1020
|
+
pathname: queryIndex >= 0 ? withoutHash.slice(0, queryIndex) : withoutHash,
|
|
1021
|
+
query: queryIndex >= 0 ? withoutHash.slice(queryIndex) : "",
|
|
1022
|
+
fragment
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
function isExternal(url) {
|
|
1026
|
+
return /^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(url);
|
|
1027
|
+
}
|
|
1028
|
+
function localizedRepositoryTarget(value, entry, entries, metadata) {
|
|
1029
|
+
if (!metadata) return void 0;
|
|
1030
|
+
let link;
|
|
1031
|
+
let repository;
|
|
1032
|
+
try {
|
|
1033
|
+
link = new URL(value);
|
|
1034
|
+
repository = new URL(metadata.url);
|
|
1035
|
+
} catch {
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
const repositoryPath = repository.pathname.replace(/\/+$/, "");
|
|
1039
|
+
if (link.origin !== repository.origin || !link.pathname.startsWith(`${repositoryPath}/`)) return;
|
|
1040
|
+
const repositoryRelative = safeDecode(link.pathname.slice(repositoryPath.length + 1));
|
|
1041
|
+
if (!/^(?:-\/(?:blob|tree|raw)|blob|tree|raw|src)\//.test(repositoryRelative)) return;
|
|
1042
|
+
const directory = metadata.directory?.replace(/^\/+|\/+$/g, "");
|
|
1043
|
+
return [...entries.values()].flat().filter((candidate) => candidate.sourceRoot === entry.sourceRoot).flatMap((candidate) => {
|
|
1044
|
+
const sourcePath = candidate.sourcePath.replace(/^\/+/, "");
|
|
1045
|
+
const paths = [sourcePath];
|
|
1046
|
+
if (/(?:^|\/)(?:README|index)\.mdx?$/i.test(sourcePath)) paths.push(sourcePath.replace(/(?:^|\/)(?:README|index)\.mdx?$/i, ""));
|
|
1047
|
+
const matched = paths.flatMap((path) => [path, ...directory ? [`${directory}/${path}`] : []]).map((path) => path.replace(/\/+$/, "")).filter(Boolean).find((path) => repositoryRelative === path || repositoryRelative.endsWith(`/${path}`));
|
|
1048
|
+
return matched ? [{
|
|
1049
|
+
candidate,
|
|
1050
|
+
length: matched.length
|
|
1051
|
+
}] : [];
|
|
1052
|
+
}).sort((left, right) => Number(right.candidate.sourceId === entry.sourceId) - Number(left.candidate.sourceId === entry.sourceId) || right.length - left.length)[0]?.candidate;
|
|
1053
|
+
}
|
|
1054
|
+
function repositoryMetadata(repository) {
|
|
1055
|
+
let raw = typeof repository === "string" ? repository : repository?.url;
|
|
1056
|
+
if (!raw) return void 0;
|
|
1057
|
+
raw = raw.replace(/^git\+/, "");
|
|
1058
|
+
const scp = /^git@([^:]+):(.+)$/.exec(raw);
|
|
1059
|
+
if (scp) raw = `https://${scp[1]}/${scp[2]}`;
|
|
1060
|
+
try {
|
|
1061
|
+
let url = new URL(raw);
|
|
1062
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
1063
|
+
if (!url.hostname || !["git:", "ssh:"].includes(url.protocol)) return;
|
|
1064
|
+
url = new URL(`https://${url.hostname}${url.pathname}`);
|
|
1065
|
+
}
|
|
1066
|
+
url.search = "";
|
|
1067
|
+
url.hash = "";
|
|
1068
|
+
url.pathname = url.pathname.replace(/\.git\/?$/, "").replace(/\/+$/, "");
|
|
1069
|
+
const directory = typeof repository === "object" ? repository.directory?.replaceAll("\\", "/").replace(/^\/+|\/+$/g, "") : void 0;
|
|
1070
|
+
return {
|
|
1071
|
+
url: url.href.replace(/\/$/, ""),
|
|
1072
|
+
...directory ? { directory } : {}
|
|
1073
|
+
};
|
|
1074
|
+
} catch {
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
function unsafeProtocol(url) {
|
|
1079
|
+
return /^(?:javascript|vbscript|data):/i.test(url.trim());
|
|
1080
|
+
}
|
|
1081
|
+
function withBase(route, base) {
|
|
1082
|
+
return `${base === "/" ? "" : `/${base.replace(/^\/+|\/+$/g, "")}`}${normalizeRoute(route)}` || "/";
|
|
1083
|
+
}
|
|
1084
|
+
function titleFromFile(path) {
|
|
1085
|
+
return basename(path, extname(path)).replace(/^README$/i, basename(dirname(path)) || "Documentation").replace(/[-_]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
|
1086
|
+
}
|
|
1087
|
+
function diagnostic(diagnostics, code, message, entry, node) {
|
|
1088
|
+
diagnostics.push({
|
|
1089
|
+
code,
|
|
1090
|
+
severity: "error",
|
|
1091
|
+
message,
|
|
1092
|
+
file: entry.sourcePath,
|
|
1093
|
+
...lineData(node)
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
function lineOf(node) {
|
|
1097
|
+
return node.position?.start.line;
|
|
1098
|
+
}
|
|
1099
|
+
function lineData(node) {
|
|
1100
|
+
const line = lineOf(node);
|
|
1101
|
+
return line === void 0 ? {} : { line };
|
|
1102
|
+
}
|
|
1103
|
+
function safeDecode(value) {
|
|
1104
|
+
try {
|
|
1105
|
+
return decodeURIComponent(value);
|
|
1106
|
+
} catch {
|
|
1107
|
+
return value;
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
function sourceLabel(source) {
|
|
1111
|
+
if ("file" in source) return source.file;
|
|
1112
|
+
if ("glob" in source) return Array.isArray(source.glob) ? source.glob.join(", ") : source.glob;
|
|
1113
|
+
return source.package;
|
|
1114
|
+
}
|
|
1115
|
+
function normalizeFs(path) {
|
|
1116
|
+
return resolve(path);
|
|
1117
|
+
}
|
|
1118
|
+
function toPosix(path) {
|
|
1119
|
+
return path.split(sep).join("/");
|
|
1120
|
+
}
|
|
1121
|
+
async function isFile(path) {
|
|
1122
|
+
try {
|
|
1123
|
+
return (await stat(path)).isFile();
|
|
1124
|
+
} catch {
|
|
1125
|
+
return false;
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
async function isDirectory(path) {
|
|
1129
|
+
try {
|
|
1130
|
+
return (await stat(path)).isDirectory();
|
|
1131
|
+
} catch {
|
|
1132
|
+
return false;
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
function errorMessage(error) {
|
|
1136
|
+
return error instanceof Error ? error.message : String(error);
|
|
1137
|
+
}
|
|
1138
|
+
var OutsideRootError = class extends Error {
|
|
1139
|
+
constructor(path) {
|
|
1140
|
+
super(`Source path is outside the repository root: ${path}.`);
|
|
1141
|
+
this.name = "OutsideRootError";
|
|
1142
|
+
}
|
|
1143
|
+
};
|
|
1144
|
+
function parseFrontmatter(source) {
|
|
1145
|
+
const match = /^(?:\uFEFF)?---[\t ]*\r?\n([\s\S]*?)\r?\n(?:---|\.\.\.)[\t ]*(?:\r?\n|$)/.exec(source);
|
|
1146
|
+
if (!match) return {
|
|
1147
|
+
data: {},
|
|
1148
|
+
content: source
|
|
1149
|
+
};
|
|
1150
|
+
const value = parse(match[1] ?? "");
|
|
1151
|
+
if (value !== null && !isPlainRecord(value)) throw new Error("Markdown frontmatter must be a YAML object.");
|
|
1152
|
+
const data = value ?? {};
|
|
1153
|
+
if (typeof data.lastUpdated === "string" && !Number.isNaN(Date.parse(data.lastUpdated))) data.lastUpdated = new Date(data.lastUpdated);
|
|
1154
|
+
return {
|
|
1155
|
+
data,
|
|
1156
|
+
content: source.slice(match[0].length)
|
|
1157
|
+
};
|
|
1158
|
+
}
|
|
1159
|
+
function isPlainRecord(value) {
|
|
1160
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1161
|
+
}
|
|
1162
|
+
function validateFrontmatter(value, file, diagnostics) {
|
|
1163
|
+
const record = value;
|
|
1164
|
+
const error = (message) => diagnostics.push({
|
|
1165
|
+
code: "DOCS_FRONTMATTER_INVALID",
|
|
1166
|
+
severity: "error",
|
|
1167
|
+
message,
|
|
1168
|
+
file
|
|
1169
|
+
});
|
|
1170
|
+
if (value.aliases !== void 0 && (!Array.isArray(value.aliases) || value.aliases.some((alias) => typeof alias !== "string"))) error("aliases must be an array of route strings.");
|
|
1171
|
+
for (const key of [
|
|
1172
|
+
"title",
|
|
1173
|
+
"description",
|
|
1174
|
+
"slug"
|
|
1175
|
+
]) if (record[key] !== void 0 && typeof record[key] !== "string") error(`${key} must be a string.`);
|
|
1176
|
+
for (const key of ["draft", "pagefind"]) if (record[key] !== void 0 && typeof record[key] !== "boolean") error(`${key} must be a boolean.`);
|
|
1177
|
+
if (record.template !== void 0 && record.template !== "doc" && record.template !== "splash") error("template must be \"doc\" or \"splash\".");
|
|
1178
|
+
for (const key of ["sidebar", "tableOfContents"]) {
|
|
1179
|
+
const entry = record[key];
|
|
1180
|
+
if (entry !== void 0 && entry !== false && !isPlainRecord(entry)) error(`${key} must be false or an object.`);
|
|
1181
|
+
}
|
|
1182
|
+
if (isPlainRecord(record.sidebar)) {
|
|
1183
|
+
validateOptionalStrings(record.sidebar, ["label", "group"], "sidebar", error);
|
|
1184
|
+
if (record.sidebar.order !== void 0 && typeof record.sidebar.order !== "number") error("sidebar.order must be a number.");
|
|
1185
|
+
}
|
|
1186
|
+
if (isPlainRecord(record.tableOfContents)) for (const key of ["minHeadingLevel", "maxHeadingLevel"]) {
|
|
1187
|
+
const level = record.tableOfContents[key];
|
|
1188
|
+
if (level !== void 0 && (!Number.isInteger(level) || Number(level) < 1 || Number(level) > 6)) error(`tableOfContents.${key} must be an integer from 1 to 6.`);
|
|
1189
|
+
}
|
|
1190
|
+
if (record.editUrl !== void 0 && record.editUrl !== false && typeof record.editUrl !== "string") error("editUrl must be false or a string.");
|
|
1191
|
+
if (record.lastUpdated !== void 0 && typeof record.lastUpdated !== "boolean" && !(record.lastUpdated instanceof Date)) error("lastUpdated must be a date or boolean.");
|
|
1192
|
+
if (record.hero !== void 0) {
|
|
1193
|
+
if (!isPlainRecord(record.hero)) error("hero must be an object.");
|
|
1194
|
+
else validateHero(record.hero, error);
|
|
1195
|
+
}
|
|
1196
|
+
if (record.banner !== void 0) {
|
|
1197
|
+
if (!isPlainRecord(record.banner)) error("banner must be an object.");
|
|
1198
|
+
else if (typeof record.banner.content !== "string") error("banner.content must be a string.");
|
|
1199
|
+
}
|
|
1200
|
+
for (const key of ["prev", "next"]) {
|
|
1201
|
+
const adjacent = record[key];
|
|
1202
|
+
if (adjacent !== void 0 && adjacent !== false && typeof adjacent !== "string" && !isPlainRecord(adjacent)) error(`${key} must be false, a string, or an object.`);
|
|
1203
|
+
else if (isPlainRecord(adjacent)) validateOptionalStrings(adjacent, ["link", "label"], key, error);
|
|
1204
|
+
}
|
|
1205
|
+
if (record.head !== void 0 && (!Array.isArray(record.head) || !record.head.every(isPlainRecord))) error("head must be an array of objects.");
|
|
1206
|
+
}
|
|
1207
|
+
function validateHero(hero, error) {
|
|
1208
|
+
validateOptionalStrings(hero, ["title", "tagline"], "hero", error);
|
|
1209
|
+
if (hero.image !== void 0) {
|
|
1210
|
+
if (!isPlainRecord(hero.image)) error("hero.image must be an object.");
|
|
1211
|
+
else {
|
|
1212
|
+
const image = hero.image;
|
|
1213
|
+
if ([
|
|
1214
|
+
"html",
|
|
1215
|
+
"file",
|
|
1216
|
+
"dark"
|
|
1217
|
+
].filter((key) => image[key] !== void 0).length !== 1) error("hero.image must define exactly one of html, file, or dark/light.");
|
|
1218
|
+
for (const key of [
|
|
1219
|
+
"html",
|
|
1220
|
+
"file",
|
|
1221
|
+
"dark",
|
|
1222
|
+
"light",
|
|
1223
|
+
"alt"
|
|
1224
|
+
]) if (image[key] !== void 0 && typeof image[key] !== "string") error(`hero.image.${key} must be a string.`);
|
|
1225
|
+
if (image.dark !== void 0 && typeof image.light !== "string") error("hero.image.light must accompany hero.image.dark.");
|
|
1226
|
+
if (image.light !== void 0 && typeof image.dark !== "string") error("hero.image.dark must accompany hero.image.light.");
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
if (hero.actions !== void 0) {
|
|
1230
|
+
if (!Array.isArray(hero.actions)) error("hero.actions must be an array.");
|
|
1231
|
+
else for (const [index, action] of hero.actions.entries()) {
|
|
1232
|
+
if (!isPlainRecord(action)) {
|
|
1233
|
+
error(`hero.actions[${index}] must be an object.`);
|
|
1234
|
+
continue;
|
|
1235
|
+
}
|
|
1236
|
+
for (const key of ["text", "link"]) if (typeof action[key] !== "string") error(`hero.actions[${index}].${key} must be a string.`);
|
|
1237
|
+
if (action.variant !== void 0 && ![
|
|
1238
|
+
"primary",
|
|
1239
|
+
"secondary",
|
|
1240
|
+
"minimal"
|
|
1241
|
+
].includes(String(action.variant))) error(`hero.actions[${index}].variant must be primary, secondary, or minimal.`);
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
function validateOptionalStrings(record, keys, prefix, error) {
|
|
1246
|
+
for (const key of keys) if (record[key] !== void 0 && typeof record[key] !== "string") error(`${prefix}.${key} must be a string.`);
|
|
1247
|
+
}
|
|
1248
|
+
//#endregion
|
|
1249
|
+
//#region src/validation/index.ts
|
|
1250
|
+
var DocsValidationError = class extends Error {
|
|
1251
|
+
diagnostics;
|
|
1252
|
+
constructor(diagnostics) {
|
|
1253
|
+
super(formatDiagnostics(diagnostics));
|
|
1254
|
+
this.name = "DocsValidationError";
|
|
1255
|
+
this.diagnostics = diagnostics;
|
|
1256
|
+
}
|
|
1257
|
+
};
|
|
1258
|
+
function validateDocs(graph) {
|
|
1259
|
+
return [...graph.diagnostics];
|
|
1260
|
+
}
|
|
1261
|
+
function assertValidDocs(graph) {
|
|
1262
|
+
const errors = graph.diagnostics.filter((diagnostic) => diagnostic.severity === "error");
|
|
1263
|
+
if (errors.length > 0) throw new DocsValidationError(errors);
|
|
1264
|
+
}
|
|
1265
|
+
function formatDiagnostics(diagnostics, json = false) {
|
|
1266
|
+
if (json) return JSON.stringify(diagnostics, null, 2);
|
|
1267
|
+
return diagnostics.map((diagnostic) => {
|
|
1268
|
+
const location = diagnostic.file ? `${diagnostic.file}${diagnostic.line ? `:${diagnostic.line}` : ""}: ` : "";
|
|
1269
|
+
const hint = diagnostic.hint ? `\n hint: ${diagnostic.hint}` : "";
|
|
1270
|
+
return `${diagnostic.severity.toUpperCase()} ${diagnostic.code} ${location}${diagnostic.message}${hint}`;
|
|
1271
|
+
}).join("\n");
|
|
1272
|
+
}
|
|
1273
|
+
//#endregion
|
|
1274
|
+
//#region src/content/index.ts
|
|
1275
|
+
function createDocsLoader(config, options = {}) {
|
|
1276
|
+
return {
|
|
1277
|
+
name: "@tenphi/docs",
|
|
1278
|
+
async load(context) {
|
|
1279
|
+
const graph = await createDocsGraph({
|
|
1280
|
+
...options,
|
|
1281
|
+
...config ? { config } : {}
|
|
1282
|
+
});
|
|
1283
|
+
assertValidDocs(graph);
|
|
1284
|
+
context.store.clear();
|
|
1285
|
+
for (const entry of graph.entries) {
|
|
1286
|
+
const loaderEntry = toLoaderEntry(entry);
|
|
1287
|
+
const filePath = starlightFilePath(entry, context.config);
|
|
1288
|
+
const data = context.parseData ? await context.parseData({
|
|
1289
|
+
id: loaderEntry.id,
|
|
1290
|
+
data: loaderEntry.data,
|
|
1291
|
+
filePath
|
|
1292
|
+
}) : loaderEntry.data;
|
|
1293
|
+
const rendered = await context.renderMarkdown?.(entry.transformedBody, { fileURL: pathToFileURL(entry.absolutePath) });
|
|
1294
|
+
context.store.set({
|
|
1295
|
+
...loaderEntry,
|
|
1296
|
+
data,
|
|
1297
|
+
filePath,
|
|
1298
|
+
...context.generateDigest ? { digest: context.generateDigest(entry.transformedBody) } : {},
|
|
1299
|
+
...rendered ? {
|
|
1300
|
+
rendered,
|
|
1301
|
+
assetImports: rendered.metadata?.imagePaths ?? []
|
|
1302
|
+
} : {}
|
|
1303
|
+
});
|
|
1304
|
+
}
|
|
1305
|
+
context.logger?.info(`Loaded ${graph.entries.length} Cookbook pages.`);
|
|
1306
|
+
}
|
|
1307
|
+
};
|
|
1308
|
+
}
|
|
1309
|
+
function starlightFilePath(entry, config) {
|
|
1310
|
+
const id = entry.route === "/" ? "index" : entry.route.slice(1);
|
|
1311
|
+
const extension = extname(entry.sourcePath).toLowerCase() === ".mdx" ? ".mdx" : ".md";
|
|
1312
|
+
if (!config) return `src/content/docs/${id}${extension}`;
|
|
1313
|
+
const root = toPath(config.root);
|
|
1314
|
+
const srcDir = toPath(config.srcDir);
|
|
1315
|
+
return relative(root, join(srcDir, "content", "docs", `${id}${extension}`)).split(sep).join("/");
|
|
1316
|
+
}
|
|
1317
|
+
function toPath(value) {
|
|
1318
|
+
return typeof value === "string" ? value : fileURLToPath(value);
|
|
1319
|
+
}
|
|
1320
|
+
function toLoaderEntry(entry) {
|
|
1321
|
+
return {
|
|
1322
|
+
id: entry.route === "/" ? "index" : entry.route.slice(1),
|
|
1323
|
+
data: {
|
|
1324
|
+
title: entry.title,
|
|
1325
|
+
draft: entry.frontmatter.draft ?? false,
|
|
1326
|
+
...entry.description ? { description: entry.description } : {},
|
|
1327
|
+
...entry.frontmatter,
|
|
1328
|
+
cookbook: {
|
|
1329
|
+
sourcePath: entry.sourcePath,
|
|
1330
|
+
route: entry.route,
|
|
1331
|
+
headings: entry.headings
|
|
1332
|
+
}
|
|
1333
|
+
},
|
|
1334
|
+
body: entry.transformedBody
|
|
1335
|
+
};
|
|
1336
|
+
}
|
|
1337
|
+
//#endregion
|
|
1338
|
+
export { resolvePackageLock as _, validateDocs as a, routeForPath as c, discoverPackage as d, lockForSource as f, reconcileDocsLock as g, readDocsLock as h, formatDiagnostics as i, assertSafePackagePath as l, packageNameFromSpecifier as m, DocsValidationError as n, createDocsGraph as o, materializePackage as p, assertValidDocs as r, normalizeRoute as s, createDocsLoader as t, defaultLock as u, validateLock as v, writeDocsLock as y };
|
|
1339
|
+
|
|
1340
|
+
//# sourceMappingURL=content-Btz1o52C.js.map
|