@tenphi/docs 0.1.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.
@@ -0,0 +1,736 @@
1
+ import { normalizeDocsConfig } from "./config/index.js";
2
+ import { cloneAst, parseMarkdown, removeRenderedTitle, serializeMarkdown, stripLeadingBadgeBlock } from "./markdown/index.js";
3
+ import { createHash } from "node:crypto";
4
+ import { lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
5
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
+ import matter from "gray-matter";
7
+ import { glob } from "tinyglobby";
8
+ import { visit } from "unist-util-visit";
9
+ import { homedir } from "node:os";
10
+ import npa from "npm-package-arg";
11
+ import pacote from "pacote";
12
+ //#region src/npm/index.ts
13
+ const DEFAULT_REGISTRY = "https://registry.npmjs.org/";
14
+ const LOCK_FILE = "tasty-docs.lock.json";
15
+ async function resolvePackageLock(requested, options = {}) {
16
+ const parsed = npa(requested);
17
+ if (![
18
+ "tag",
19
+ "version",
20
+ "range"
21
+ ].includes(parsed.type)) throw new Error(`Only npm registry package specifiers are supported (received ${parsed.type}).`);
22
+ const registry = options.registry ?? DEFAULT_REGISTRY;
23
+ const manifest = await pacote.manifest(requested, {
24
+ registry,
25
+ ...options.cacheDir ? { cache: options.cacheDir } : {},
26
+ fullMetadata: true
27
+ });
28
+ if (!manifest.name || !manifest.version || !manifest._integrity) throw new Error(`Registry metadata for ${requested} did not include version and integrity.`);
29
+ return {
30
+ requested,
31
+ resolved: `${manifest.name}@${manifest.version}`,
32
+ registry,
33
+ integrity: manifest._integrity
34
+ };
35
+ }
36
+ async function readDocsLock(root) {
37
+ try {
38
+ const value = JSON.parse(await readFile(join(root, LOCK_FILE), "utf8"));
39
+ validateLock(value);
40
+ return value;
41
+ } catch (error) {
42
+ if (isMissing(error)) return void 0;
43
+ throw error;
44
+ }
45
+ }
46
+ async function writeDocsLock(root, lock) {
47
+ validateLock(lock);
48
+ await writeFile(join(root, LOCK_FILE), `${JSON.stringify(lock, null, 2)}\n`, "utf8");
49
+ }
50
+ function validateLock(lock) {
51
+ if (lock.schemaVersion !== 1 || !Array.isArray(lock.sources)) throw new Error("Unsupported or invalid tasty-docs.lock.json.");
52
+ for (const source of lock.sources) {
53
+ if (!source.requested || !source.resolved || !source.registry || !source.integrity) throw new Error("Every lock source requires requested, resolved, registry, and integrity.");
54
+ if (npa(source.resolved).type !== "version") throw new Error(`Locked source must use an exact version: ${source.resolved}.`);
55
+ if (source.vendored && (source.vendored.startsWith("/") || source.vendored.split(/[\\/]/).includes(".."))) throw new Error(`Vendored package path must stay within the project: ${source.vendored}.`);
56
+ }
57
+ }
58
+ async function materializePackage(source, config, projectRoot) {
59
+ if (source.vendored) {
60
+ if (!projectRoot) throw new Error(`Vendored source ${source.resolved} requires a project root.`);
61
+ const vendored = resolve(projectRoot, source.vendored);
62
+ if (!inside$1(projectRoot, vendored)) throw new Error(`Vendored package path escapes the project root: ${source.vendored}.`);
63
+ if ((await readFile(join(vendored, ".tasty-docs-integrity"), "utf8")).trim() !== source.integrity) throw new Error(`Vendored package integrity marker does not match ${source.resolved}.`);
64
+ await validateExtractedTree(vendored, config);
65
+ return vendored;
66
+ }
67
+ const cacheRoot = resolve(config.cacheDir || join(homedir(), ".cache", "tasty-docs"), "artifacts");
68
+ const key = createHash("sha256").update(source.integrity).digest("hex");
69
+ const destination = join(cacheRoot, key);
70
+ const marker = join(destination, ".tasty-docs-integrity");
71
+ try {
72
+ if ((await readFile(marker, "utf8")).trim() === source.integrity) return destination;
73
+ } catch (error) {
74
+ if (!isMissing(error)) throw error;
75
+ }
76
+ await mkdir(cacheRoot, { recursive: true });
77
+ const temporary = await mkdtemp(join(cacheRoot, ".extract-"));
78
+ try {
79
+ const tarball = await pacote.tarball(source.resolved, {
80
+ registry: source.registry,
81
+ integrity: source.integrity,
82
+ cache: join(cacheRoot, "_cacache")
83
+ });
84
+ if (tarball.byteLength > config.maxArtifactBytes) throw new Error(`Package artifact is ${tarball.byteLength} bytes; limit is ${config.maxArtifactBytes}.`);
85
+ await pacote.extract(source.resolved, temporary, {
86
+ registry: source.registry,
87
+ integrity: source.integrity,
88
+ cache: join(cacheRoot, "_cacache")
89
+ });
90
+ await validateExtractedTree(temporary, config);
91
+ await writeFile(join(temporary, ".tasty-docs-integrity"), `${source.integrity}\n`);
92
+ await rm(destination, {
93
+ recursive: true,
94
+ force: true
95
+ });
96
+ await rename(temporary, destination);
97
+ return destination;
98
+ } catch (error) {
99
+ await rm(temporary, {
100
+ recursive: true,
101
+ force: true
102
+ });
103
+ throw error;
104
+ }
105
+ }
106
+ async function validateExtractedTree(root, config) {
107
+ let files = 0;
108
+ let bytes = 0;
109
+ const pending = [root];
110
+ while (pending.length > 0) {
111
+ const directory = pending.pop();
112
+ if (!directory) break;
113
+ for (const name of await readdir(directory)) {
114
+ if (name === ".tasty-docs-integrity") continue;
115
+ const path = join(directory, name);
116
+ const info = await lstat(path);
117
+ const rel = relative(root, path);
118
+ if (rel.startsWith(`..${sep}`) || rel === "..") throw new Error(`Package path escapes artifact root: ${rel}.`);
119
+ if (rel.split(sep).length > config.maxPathDepth) throw new Error(`Package path exceeds maximum depth: ${rel}.`);
120
+ if (info.isSymbolicLink()) throw new Error(`Package symlinks are not allowed: ${rel}.`);
121
+ if (info.isDirectory()) pending.push(path);
122
+ else if (info.isFile()) {
123
+ files += 1;
124
+ bytes += info.size;
125
+ if (info.size > config.maxAssetBytes) throw new Error(`Package file exceeds maximum size: ${rel}.`);
126
+ if (files > config.maxFiles || bytes > config.maxUnpackedBytes) throw new Error("Package exceeds configured file-count or unpacked-size limit.");
127
+ } else throw new Error(`Unsupported package entry type: ${rel}.`);
128
+ }
129
+ }
130
+ }
131
+ async function discoverPackage(root) {
132
+ const manifest = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
133
+ const hints = manifest.tastyDocs;
134
+ const homeCandidates = [
135
+ hints?.index,
136
+ "README.md",
137
+ "readme.md"
138
+ ].filter((candidate) => Boolean(candidate));
139
+ let home;
140
+ for (const candidate of homeCandidates) try {
141
+ if ((await stat(join(root, candidate))).isFile()) {
142
+ home = candidate;
143
+ break;
144
+ }
145
+ } catch (error) {
146
+ if (!isMissing(error)) throw error;
147
+ }
148
+ const patterns = hints?.include?.length ? hints.include : ["docs/**/*.{md,mdx}", "docs/**/*.{png,jpg,jpeg,gif,webp,avif,svg,pdf,txt,zip}"];
149
+ const discovered = await glob(patterns, {
150
+ cwd: root,
151
+ onlyFiles: true,
152
+ dot: false,
153
+ ignore: hints?.exclude ?? []
154
+ });
155
+ const pages = discovered.filter((path) => /\.mdx?$/i.test(path));
156
+ if (home && !pages.includes(home)) pages.unshift(home);
157
+ const assets = discovered.filter((path) => !/\.mdx?$/i.test(path));
158
+ return {
159
+ root,
160
+ manifest,
161
+ ...home ? { home } : {},
162
+ pages,
163
+ assets
164
+ };
165
+ }
166
+ function packageNameFromSpecifier(specifier) {
167
+ const parsed = npa(specifier);
168
+ if (!parsed.name) throw new Error(`Invalid npm package specifier: ${specifier}.`);
169
+ return parsed.name;
170
+ }
171
+ function lockForSource(lock, requested) {
172
+ const match = lock?.sources.find((source) => source.requested === requested || packageNameFromSpecifier(source.requested) === packageNameFromSpecifier(requested));
173
+ if (!match) throw new Error(`Package source ${requested} is not locked. Run "tasty-docs update" to create ${LOCK_FILE}.`);
174
+ return match;
175
+ }
176
+ function defaultLock(sources) {
177
+ return {
178
+ schemaVersion: 1,
179
+ sources
180
+ };
181
+ }
182
+ function isMissing(error) {
183
+ return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
184
+ }
185
+ function inside$1(root, path) {
186
+ const rel = relative(resolve(root), resolve(path));
187
+ return rel === "" || !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel);
188
+ }
189
+ //#endregion
190
+ //#region src/graph/index.ts
191
+ const MARKDOWN_EXTENSIONS = [".md", ".mdx"];
192
+ const FRONTMATTER_KEYS = /* @__PURE__ */ new Set([
193
+ "title",
194
+ "description",
195
+ "slug",
196
+ "draft",
197
+ "sidebar",
198
+ "toc",
199
+ "editUrl",
200
+ "prev",
201
+ "next",
202
+ "search",
203
+ "head"
204
+ ]);
205
+ async function createDocsGraph(options = {}) {
206
+ const root = resolve(options.root ?? process.cwd());
207
+ const config = normalizeDocsConfig(options.config);
208
+ const diagnostics = [];
209
+ const collected = await collectSources(root, config, options.lock ?? await readDocsLock(root), diagnostics);
210
+ const entries = [];
211
+ const routeMap = /* @__PURE__ */ new Map();
212
+ const absoluteMap = /* @__PURE__ */ new Map();
213
+ const sourceMap = /* @__PURE__ */ new Map();
214
+ for (const source of collected) {
215
+ const entry = await readEntry(source, config, diagnostics);
216
+ if (!entry) continue;
217
+ const existing = routeMap.get(entry.route);
218
+ if (existing) {
219
+ diagnostics.push({
220
+ code: "DOCS_DUPLICATE_ROUTE",
221
+ severity: "error",
222
+ message: `Route ${entry.route} is owned by both ${existing.sourcePath} and ${entry.sourcePath}.`,
223
+ file: entry.sourcePath,
224
+ related: [{
225
+ file: existing.sourcePath,
226
+ message: "First route owner."
227
+ }]
228
+ });
229
+ continue;
230
+ }
231
+ routeMap.set(entry.route, entry);
232
+ absoluteMap.set(normalizeFs(entry.absolutePath), entry);
233
+ sourceMap.set(entry.sourcePath, entry);
234
+ sourceMap.set(entry.id, entry);
235
+ entries.push(entry);
236
+ }
237
+ for (const entry of entries) await transformEntry(entry, absoluteMap, routeMap, config, diagnostics);
238
+ validateNavigation(config.navigation.items ?? [], routeMap, diagnostics);
239
+ entries.sort((left, right) => left.route.localeCompare(right.route));
240
+ return {
241
+ root,
242
+ config,
243
+ entries,
244
+ routes: entries.map((entry) => ({
245
+ route: entry.route,
246
+ entryId: entry.id,
247
+ sourcePath: entry.sourcePath,
248
+ title: entry.title
249
+ })),
250
+ assets: entries.flatMap((entry) => entry.assets),
251
+ diagnostics,
252
+ entryByRoute(route) {
253
+ return routeMap.get(normalizeRoute(route));
254
+ },
255
+ entryBySource(sourcePath) {
256
+ return sourceMap.get(sourcePath);
257
+ }
258
+ };
259
+ }
260
+ async function collectSources(root, config, lock, diagnostics) {
261
+ const declarations = config.content.sources?.length ? config.content.sources : await conventionSources(root);
262
+ const results = [];
263
+ const identities = /* @__PURE__ */ new Set();
264
+ for (const declaration of declarations) try {
265
+ const found = await collectDeclaration(root, declaration, config, lock);
266
+ if (found.length === 0) diagnostics.push({
267
+ code: "DOCS_SOURCE_NOT_FOUND",
268
+ severity: "error",
269
+ message: `Source did not match any files: ${sourceLabel(declaration)}.`
270
+ });
271
+ for (const source of found) {
272
+ const identity = normalizeFs(source.absolutePath);
273
+ if (identities.has(identity)) continue;
274
+ identities.add(identity);
275
+ results.push(source);
276
+ }
277
+ } catch (error) {
278
+ diagnostics.push({
279
+ code: error instanceof OutsideRootError ? "DOCS_SOURCE_OUTSIDE_ROOT" : "DOCS_SOURCE_NOT_FOUND",
280
+ severity: "error",
281
+ message: errorMessage(error)
282
+ });
283
+ }
284
+ return results;
285
+ }
286
+ async function conventionSources(root) {
287
+ const sources = [];
288
+ if (await isFile(resolve(root, "README.md"))) sources.push({
289
+ file: "README.md",
290
+ route: "/"
291
+ });
292
+ if (await isDirectory(resolve(root, "docs"))) sources.push({
293
+ glob: "docs/**/*.{md,mdx}",
294
+ base: "docs"
295
+ });
296
+ return sources;
297
+ }
298
+ async function collectDeclaration(root, declaration, config, lock) {
299
+ if ("package" in declaration) {
300
+ const packageLock = lockForSource(lock, declaration.package);
301
+ const sourceRoot = await materializePackage(packageLock, config.build, root);
302
+ const discovery = await discoverPackage(sourceRoot);
303
+ const patterns = declaration.include?.length ? declaration.include : discovery.pages;
304
+ const files = declaration.include?.length ? await glob(patterns, {
305
+ cwd: sourceRoot,
306
+ onlyFiles: true,
307
+ ignore: declaration.exclude ?? []
308
+ }) : discovery.pages.filter((path) => !(declaration.exclude ?? []).includes(path));
309
+ const index = declaration.index ?? discovery.home;
310
+ return files.filter((path) => MARKDOWN_EXTENSIONS.includes(extname(path).toLowerCase())).map((path) => ({
311
+ absolutePath: resolve(sourceRoot, path),
312
+ sourcePath: path,
313
+ sourceRoot,
314
+ route: path === index ? normalizeRoute(declaration.routeBase ?? "/") : routeForPath(path, "docs", declaration.routeBase),
315
+ trust: declaration.trust ?? "markdown",
316
+ packageLock
317
+ }));
318
+ }
319
+ if ("file" in declaration) {
320
+ const absolutePath = resolveSourcePath(root, declaration.file, config.content.allowOutsideRoot);
321
+ if (!await isFile(absolutePath)) return [];
322
+ return [{
323
+ absolutePath,
324
+ sourcePath: toPosix(relative(root, absolutePath)),
325
+ sourceRoot: root,
326
+ ...declaration.route ? { route: declaration.route } : {},
327
+ ...declaration.title ? { title: declaration.title } : {},
328
+ ...declaration.description ? { description: declaration.description } : {},
329
+ trust: "mdx"
330
+ }];
331
+ }
332
+ const patterns = Array.isArray(declaration.glob) ? declaration.glob : [declaration.glob];
333
+ return (await glob(patterns, {
334
+ cwd: root,
335
+ onlyFiles: true,
336
+ dot: false,
337
+ ignore: [
338
+ "**/_*/**",
339
+ "**/_*",
340
+ ...declaration.exclude ?? []
341
+ ]
342
+ })).map((path) => {
343
+ const absolutePath = resolveSourcePath(root, path, config.content.allowOutsideRoot);
344
+ return {
345
+ absolutePath,
346
+ sourcePath: toPosix(relative(root, absolutePath)),
347
+ sourceRoot: root,
348
+ route: routeForPath(path, declaration.base, declaration.routeBase),
349
+ trust: "mdx"
350
+ };
351
+ });
352
+ }
353
+ async function readEntry(source, config, diagnostics) {
354
+ if (extname(source.sourcePath).toLowerCase() === ".mdx" && source.trust !== "mdx") {
355
+ diagnostics.push({
356
+ code: "DOCS_UNTRUSTED_MDX",
357
+ severity: "error",
358
+ message: `Package MDX requires trust: 'mdx': ${source.sourcePath}.`,
359
+ file: source.sourcePath,
360
+ hint: "Keep package sources in Markdown-safe mode or explicitly trust this locked artifact."
361
+ });
362
+ return;
363
+ }
364
+ const original = await readFile(source.absolutePath, "utf8");
365
+ const parsedMatter = matter(original);
366
+ const frontmatter = parsedMatter.data;
367
+ for (const key of Object.keys(parsedMatter.data)) if (!FRONTMATTER_KEYS.has(key)) diagnostics.push({
368
+ code: "DOCS_FRONTMATTER_INVALID",
369
+ severity: "error",
370
+ message: `Unknown frontmatter key "${key}".`,
371
+ file: source.sourcePath
372
+ });
373
+ const parsed = parseMarkdown(parsedMatter.content);
374
+ const route = normalizeRoute(frontmatter.slug ?? source.route ?? routeForPath(source.sourcePath));
375
+ const title = frontmatter.title ?? source.title ?? parsed.firstHeading ?? titleFromFile(source.sourcePath);
376
+ const description = frontmatter.description ?? source.description ?? parsed.description;
377
+ const duplicateTitles = /* @__PURE__ */ new Map();
378
+ for (const heading of parsed.headings) {
379
+ const count = (duplicateTitles.get(heading.text) ?? 0) + 1;
380
+ duplicateTitles.set(heading.text, count);
381
+ if (count > 1) diagnostics.push({
382
+ code: "DOCS_HEADING_DUPLICATE",
383
+ severity: "warning",
384
+ message: `Repeated heading "${heading.text}" receives the generated ID "${heading.slug}".`,
385
+ file: source.sourcePath,
386
+ ...heading.line ? { line: heading.line } : {}
387
+ });
388
+ }
389
+ return {
390
+ id: `${source.packageLock?.resolved ?? "local"}:${source.sourcePath}`,
391
+ sourcePath: source.sourcePath,
392
+ absolutePath: source.absolutePath,
393
+ sourceRoot: source.sourceRoot,
394
+ route,
395
+ title,
396
+ ...description ? { description } : {},
397
+ frontmatter,
398
+ headings: parsed.headings,
399
+ body: parsedMatter.content,
400
+ transformedBody: parsedMatter.content,
401
+ ast: parsed.ast,
402
+ links: [],
403
+ assets: [],
404
+ trust: source.trust,
405
+ ...source.packageLock ? { package: {
406
+ requested: source.packageLock.requested,
407
+ resolved: source.packageLock.resolved
408
+ } } : {}
409
+ };
410
+ }
411
+ async function transformEntry(entry, absoluteMap, routeMap, config, diagnostics) {
412
+ const ast = cloneAst(entry.ast);
413
+ if (config.markdown.stripLeadingBadges) stripLeadingBadgeBlock(ast);
414
+ removeRenderedTitle(ast, entry.title);
415
+ visit(ast, (node) => {
416
+ if (node.type === "html" && entry.trust === "markdown") {
417
+ if (/<\s*script\b|\son[a-z]+\s*=|javascript:/i.test(node.value)) {
418
+ diagnostics.push({
419
+ code: "DOCS_UNSAFE_HTML",
420
+ severity: "error",
421
+ message: "Script-capable HTML is not allowed in package Markdown.",
422
+ file: entry.sourcePath,
423
+ ...node.position?.start.line ? { line: node.position.start.line } : {}
424
+ });
425
+ node.value = "";
426
+ }
427
+ }
428
+ });
429
+ const linkTasks = [];
430
+ visit(ast, (node) => {
431
+ if (node.type === "link") linkTasks.push(rewriteLink(node, entry, absoluteMap, routeMap, config, diagnostics));
432
+ else if (node.type === "image") linkTasks.push(rewriteAsset(node, entry, config, diagnostics));
433
+ });
434
+ await Promise.all(linkTasks);
435
+ entry.ast = ast;
436
+ entry.transformedBody = serializeMarkdown(ast);
437
+ }
438
+ async function rewriteLink(node, entry, absoluteMap, routeMap, config, diagnostics) {
439
+ const reference = {
440
+ original: node.url,
441
+ ...lineData(node)
442
+ };
443
+ entry.links.push(reference);
444
+ if (unsafeProtocol(node.url)) {
445
+ diagnostic(diagnostics, "DOCS_LINK_UNSAFE", `Unsafe URL protocol: ${node.url}.`, entry, node);
446
+ return;
447
+ }
448
+ if (isExternal(node.url) || node.url.startsWith("#")) return;
449
+ const { pathname, query, fragment } = splitReference(node.url);
450
+ if (pathname.startsWith("/")) {
451
+ const target = routeMap.get(normalizeRoute(pathname));
452
+ if (!target) missingLink(diagnostics, entry, node, node.url, config);
453
+ else validateFragment(fragment, target, entry, node, diagnostics, config);
454
+ return;
455
+ }
456
+ const decoded = safeDecode(pathname);
457
+ const targetPath = resolve(dirname(entry.absolutePath), decoded);
458
+ if (!inside(entry.sourceRoot, targetPath)) {
459
+ diagnostic(diagnostics, "DOCS_SOURCE_OUTSIDE_ROOT", `Link escapes its allowed source root: ${node.url}.`, entry, node);
460
+ return;
461
+ }
462
+ const target = findDocument(targetPath, absoluteMap);
463
+ if (!target) {
464
+ if (await isFile(targetPath)) return;
465
+ missingLink(diagnostics, entry, node, node.url, config);
466
+ return;
467
+ }
468
+ node.url = `${withBase(target.route, config.build.base)}${query}${fragment ? `#${fragment}` : ""}`;
469
+ reference.resolved = node.url;
470
+ reference.targetSource = target.sourcePath;
471
+ if (fragment) reference.fragment = fragment;
472
+ validateFragment(fragment, target, entry, node, diagnostics, config);
473
+ }
474
+ async function rewriteAsset(node, entry, config, diagnostics) {
475
+ const asset = {
476
+ original: node.url,
477
+ ...lineData(node)
478
+ };
479
+ entry.assets.push(asset);
480
+ if (isExternal(node.url)) return;
481
+ if (unsafeProtocol(node.url)) {
482
+ diagnostic(diagnostics, "DOCS_ASSET_UNSAFE", `Unsafe asset URL: ${node.url}.`, entry, node);
483
+ return;
484
+ }
485
+ const { pathname, query, fragment } = splitReference(node.url);
486
+ const absolute = resolve(dirname(entry.absolutePath), safeDecode(pathname));
487
+ if (!inside(entry.sourceRoot, absolute)) {
488
+ diagnostic(diagnostics, "DOCS_SOURCE_OUTSIDE_ROOT", `Asset escapes its allowed source root: ${node.url}.`, entry, node);
489
+ return;
490
+ }
491
+ try {
492
+ const info = await stat(absolute);
493
+ if (!info.isFile()) throw new Error("not a file");
494
+ if (info.size > config.build.maxAssetBytes) throw new Error(`asset exceeds ${config.build.maxAssetBytes} bytes`);
495
+ const hash = createHash("sha256").update(await readFile(absolute)).digest("hex").slice(0, 12);
496
+ const publicPath = withBase(`/_tasty-assets/${hash}-${basename(absolute)}`, config.build.base);
497
+ node.url = `${publicPath}${query}${fragment ? `#${fragment}` : ""}`;
498
+ Object.assign(asset, {
499
+ resolved: node.url,
500
+ sourcePath: absolute,
501
+ publicPath,
502
+ hash,
503
+ bytes: info.size
504
+ });
505
+ } catch (error) {
506
+ diagnostic(diagnostics, "DOCS_ASSET_NOT_FOUND", `Asset not found or invalid: ${node.url} (${errorMessage(error)}).`, entry, node);
507
+ }
508
+ }
509
+ function findDocument(path, absoluteMap) {
510
+ const candidates = [
511
+ path,
512
+ ...MARKDOWN_EXTENSIONS.map((extension) => `${path}${extension}`),
513
+ ...MARKDOWN_EXTENSIONS.map((extension) => resolve(path, `README${extension}`)),
514
+ ...MARKDOWN_EXTENSIONS.map((extension) => resolve(path, `index${extension}`))
515
+ ];
516
+ for (const candidate of candidates) {
517
+ const entry = absoluteMap.get(normalizeFs(candidate));
518
+ if (entry) return entry;
519
+ }
520
+ }
521
+ function validateFragment(fragment, target, source, node, diagnostics, config) {
522
+ if (!fragment) return;
523
+ const decoded = safeDecode(fragment);
524
+ if (!target.headings.some((heading) => heading.slug === decoded)) diagnostics.push({
525
+ code: "DOCS_FRAGMENT_NOT_FOUND",
526
+ severity: config.build.ci ? "error" : "warning",
527
+ message: `Heading fragment #${fragment} does not exist on ${target.route}.`,
528
+ file: source.sourcePath,
529
+ ...lineData(node),
530
+ hint: `Known headings: ${target.headings.map((heading) => `#${heading.slug}`).join(", ") || "(none)"}.`
531
+ });
532
+ }
533
+ function missingLink(diagnostics, entry, node, url, config) {
534
+ diagnostics.push({
535
+ code: "DOCS_LINK_NOT_FOUND",
536
+ severity: config.build.strict ? "error" : "warning",
537
+ message: `Internal link target not found: ${url}.`,
538
+ file: entry.sourcePath,
539
+ ...lineData(node)
540
+ });
541
+ }
542
+ function validateNavigation(items, routes, diagnostics) {
543
+ for (const item of items) if (typeof item === "string") {
544
+ if (!routes.has(normalizeRoute(item))) diagnostics.push({
545
+ code: "DOCS_NAV_TARGET_NOT_FOUND",
546
+ severity: "error",
547
+ message: `Navigation target does not exist: ${item}.`
548
+ });
549
+ } else if ("items" in item) validateNavigation(item.items, routes, diagnostics);
550
+ else if ("link" in item && item.link.startsWith("/") && !routes.has(normalizeRoute(item.link))) diagnostics.push({
551
+ code: "DOCS_NAV_TARGET_NOT_FOUND",
552
+ severity: "error",
553
+ message: `Navigation target does not exist: ${item.link}.`
554
+ });
555
+ }
556
+ function normalizeRoute(route) {
557
+ const segments = (route.split(/[?#]/, 1)[0]?.replace(/\\/g, "/").replace(/\/{2,}/g, "/") ?? "/").split("/").filter(Boolean);
558
+ if (segments.some((segment) => segment === "..")) throw new Error(`Route may not contain "..": ${route}.`);
559
+ const normalized = `/${segments.join("/")}`;
560
+ return normalized === "/" ? "/" : normalized.replace(/\/$/, "");
561
+ }
562
+ function routeForPath(path, base, routeBase) {
563
+ let relativePath = toPosix(path);
564
+ if (base) {
565
+ const normalizedBase = toPosix(base).replace(/^\.\//, "").replace(/\/$/, "");
566
+ if (relativePath === normalizedBase) relativePath = "";
567
+ else if (relativePath.startsWith(`${normalizedBase}/`)) relativePath = relativePath.slice(normalizedBase.length + 1);
568
+ }
569
+ relativePath = relativePath.replace(/\.(md|mdx)$/i, "");
570
+ relativePath = relativePath.replace(/(^|\/)README$/i, "$1").replace(/(^|\/)index$/i, "$1");
571
+ return normalizeRoute(`${routeBase ?? ""}/${relativePath}`);
572
+ }
573
+ function resolveSourcePath(root, path, allowOutsideRoot) {
574
+ const absolute = isAbsolute(path) ? resolve(path) : resolve(root, path);
575
+ if (!allowOutsideRoot && !inside(root, absolute)) throw new OutsideRootError(path);
576
+ return absolute;
577
+ }
578
+ function inside(root, path) {
579
+ const rel = relative(resolve(root), resolve(path));
580
+ return rel === "" || !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel);
581
+ }
582
+ function splitReference(url) {
583
+ const hashIndex = url.indexOf("#");
584
+ const fragment = hashIndex >= 0 ? url.slice(hashIndex + 1) : "";
585
+ const withoutHash = hashIndex >= 0 ? url.slice(0, hashIndex) : url;
586
+ const queryIndex = withoutHash.indexOf("?");
587
+ return {
588
+ pathname: queryIndex >= 0 ? withoutHash.slice(0, queryIndex) : withoutHash,
589
+ query: queryIndex >= 0 ? withoutHash.slice(queryIndex) : "",
590
+ fragment
591
+ };
592
+ }
593
+ function isExternal(url) {
594
+ return /^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(url);
595
+ }
596
+ function unsafeProtocol(url) {
597
+ return /^(?:javascript|vbscript|data):/i.test(url.trim());
598
+ }
599
+ function withBase(route, base) {
600
+ return `${base === "/" ? "" : `/${base.replace(/^\/+|\/+$/g, "")}`}${normalizeRoute(route)}` || "/";
601
+ }
602
+ function titleFromFile(path) {
603
+ return basename(path, extname(path)).replace(/^README$/i, basename(dirname(path)) || "Documentation").replace(/[-_]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
604
+ }
605
+ function diagnostic(diagnostics, code, message, entry, node) {
606
+ diagnostics.push({
607
+ code,
608
+ severity: "error",
609
+ message,
610
+ file: entry.sourcePath,
611
+ ...lineData(node)
612
+ });
613
+ }
614
+ function lineOf(node) {
615
+ return node.position?.start.line;
616
+ }
617
+ function lineData(node) {
618
+ const line = lineOf(node);
619
+ return line === void 0 ? {} : { line };
620
+ }
621
+ function safeDecode(value) {
622
+ try {
623
+ return decodeURIComponent(value);
624
+ } catch {
625
+ return value;
626
+ }
627
+ }
628
+ function sourceLabel(source) {
629
+ if ("file" in source) return source.file;
630
+ if ("glob" in source) return Array.isArray(source.glob) ? source.glob.join(", ") : source.glob;
631
+ return source.package;
632
+ }
633
+ function normalizeFs(path) {
634
+ return resolve(path);
635
+ }
636
+ function toPosix(path) {
637
+ return path.split(sep).join("/");
638
+ }
639
+ async function isFile(path) {
640
+ try {
641
+ return (await stat(path)).isFile();
642
+ } catch {
643
+ return false;
644
+ }
645
+ }
646
+ async function isDirectory(path) {
647
+ try {
648
+ return (await stat(path)).isDirectory();
649
+ } catch {
650
+ return false;
651
+ }
652
+ }
653
+ function errorMessage(error) {
654
+ return error instanceof Error ? error.message : String(error);
655
+ }
656
+ var OutsideRootError = class extends Error {
657
+ constructor(path) {
658
+ super(`Source path is outside the repository root: ${path}.`);
659
+ this.name = "OutsideRootError";
660
+ }
661
+ };
662
+ //#endregion
663
+ //#region src/validation/index.ts
664
+ var DocsValidationError = class extends Error {
665
+ diagnostics;
666
+ constructor(diagnostics) {
667
+ super(formatDiagnostics(diagnostics));
668
+ this.name = "DocsValidationError";
669
+ this.diagnostics = diagnostics;
670
+ }
671
+ };
672
+ function validateDocs(graph) {
673
+ return [...graph.diagnostics];
674
+ }
675
+ function assertValidDocs(graph) {
676
+ const errors = graph.diagnostics.filter((diagnostic) => diagnostic.severity === "error");
677
+ if (errors.length > 0) throw new DocsValidationError(errors);
678
+ }
679
+ function formatDiagnostics(diagnostics, json = false) {
680
+ if (json) return JSON.stringify(diagnostics, null, 2);
681
+ return diagnostics.map((diagnostic) => {
682
+ const location = diagnostic.file ? `${diagnostic.file}${diagnostic.line ? `:${diagnostic.line}` : ""}: ` : "";
683
+ const hint = diagnostic.hint ? `\n hint: ${diagnostic.hint}` : "";
684
+ return `${diagnostic.severity.toUpperCase()} ${diagnostic.code} ${location}${diagnostic.message}${hint}`;
685
+ }).join("\n");
686
+ }
687
+ //#endregion
688
+ //#region src/content/index.ts
689
+ function createDocsLoader(config, options = {}) {
690
+ return {
691
+ name: "@tenphi/docs",
692
+ async load(context) {
693
+ const graph = await createDocsGraph({
694
+ ...options,
695
+ ...config ? { config } : {}
696
+ });
697
+ assertValidDocs(graph);
698
+ context.store.clear();
699
+ for (const entry of graph.entries) {
700
+ const loaderEntry = toLoaderEntry(entry);
701
+ const data = context.parseData ? await context.parseData({
702
+ id: loaderEntry.id,
703
+ data: loaderEntry.data,
704
+ filePath: entry.sourcePath
705
+ }) : loaderEntry.data;
706
+ context.store.set({
707
+ ...loaderEntry,
708
+ data,
709
+ filePath: entry.sourcePath
710
+ });
711
+ }
712
+ context.logger?.info(`Loaded ${graph.entries.length} Tasty Docs pages.`);
713
+ }
714
+ };
715
+ }
716
+ function toLoaderEntry(entry) {
717
+ return {
718
+ id: entry.route === "/" ? "index" : entry.route.slice(1),
719
+ data: {
720
+ title: entry.title,
721
+ draft: entry.frontmatter.draft ?? false,
722
+ ...entry.description ? { description: entry.description } : {},
723
+ ...entry.frontmatter,
724
+ tastyDocs: {
725
+ sourcePath: entry.sourcePath,
726
+ route: entry.route,
727
+ headings: entry.headings
728
+ }
729
+ },
730
+ body: entry.transformedBody
731
+ };
732
+ }
733
+ //#endregion
734
+ export { writeDocsLock as _, validateDocs as a, routeForPath as c, lockForSource as d, materializePackage as f, validateLock as g, resolvePackageLock as h, formatDiagnostics as i, defaultLock as l, readDocsLock as m, DocsValidationError as n, createDocsGraph as o, packageNameFromSpecifier as p, assertValidDocs as r, normalizeRoute as s, createDocsLoader as t, discoverPackage as u };
735
+
736
+ //# sourceMappingURL=content-DStZeziu.js.map