kitfly 0.1.2

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.
Files changed (62) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/LICENSE +21 -0
  3. package/README.md +136 -0
  4. package/VERSION +1 -0
  5. package/package.json +63 -0
  6. package/schemas/README.md +32 -0
  7. package/schemas/site.schema.json +5 -0
  8. package/schemas/theme.schema.json +5 -0
  9. package/schemas/v0/site.schema.json +172 -0
  10. package/schemas/v0/theme.schema.json +210 -0
  11. package/scripts/build-all.ts +121 -0
  12. package/scripts/build.ts +601 -0
  13. package/scripts/bundle.ts +781 -0
  14. package/scripts/dev.ts +777 -0
  15. package/scripts/generate-checksums.sh +78 -0
  16. package/scripts/release/export-release-key.sh +28 -0
  17. package/scripts/release/release-guard-tag-version.sh +79 -0
  18. package/scripts/release/sign-release-assets.sh +123 -0
  19. package/scripts/release/upload-release-assets.sh +76 -0
  20. package/scripts/release/upload-release-provenance.sh +52 -0
  21. package/scripts/release/verify-public-key.sh +48 -0
  22. package/scripts/release/verify-signatures.sh +117 -0
  23. package/scripts/version-sync.ts +82 -0
  24. package/src/__tests__/build.test.ts +240 -0
  25. package/src/__tests__/bundle.test.ts +786 -0
  26. package/src/__tests__/cli.test.ts +706 -0
  27. package/src/__tests__/crucible.test.ts +1043 -0
  28. package/src/__tests__/engine.test.ts +157 -0
  29. package/src/__tests__/init.test.ts +450 -0
  30. package/src/__tests__/pipeline.test.ts +1087 -0
  31. package/src/__tests__/productbook.test.ts +1206 -0
  32. package/src/__tests__/runbook.test.ts +974 -0
  33. package/src/__tests__/server-registry.test.ts +1251 -0
  34. package/src/__tests__/servicebook.test.ts +1248 -0
  35. package/src/__tests__/shared.test.ts +2005 -0
  36. package/src/__tests__/styles.test.ts +14 -0
  37. package/src/__tests__/theme-schema.test.ts +47 -0
  38. package/src/__tests__/theme.test.ts +554 -0
  39. package/src/cli.ts +582 -0
  40. package/src/commands/init.ts +92 -0
  41. package/src/commands/update.ts +444 -0
  42. package/src/engine.ts +20 -0
  43. package/src/logger.ts +15 -0
  44. package/src/migrations/0000_schema_versioning.ts +67 -0
  45. package/src/migrations/0001_server_port.ts +52 -0
  46. package/src/migrations/0002_brand_logo.ts +49 -0
  47. package/src/migrations/index.ts +26 -0
  48. package/src/migrations/schema.ts +24 -0
  49. package/src/server-registry.ts +405 -0
  50. package/src/shared.ts +1239 -0
  51. package/src/site/styles.css +931 -0
  52. package/src/site/template.html +193 -0
  53. package/src/templates/crucible.ts +1163 -0
  54. package/src/templates/driver.ts +876 -0
  55. package/src/templates/handbook.ts +339 -0
  56. package/src/templates/minimal.ts +139 -0
  57. package/src/templates/pipeline.ts +966 -0
  58. package/src/templates/productbook.ts +1032 -0
  59. package/src/templates/runbook.ts +829 -0
  60. package/src/templates/schema.ts +119 -0
  61. package/src/templates/servicebook.ts +1242 -0
  62. package/src/theme.ts +245 -0
package/src/shared.ts ADDED
@@ -0,0 +1,1239 @@
1
+ /**
2
+ * Shared utilities for Kitfly scripts (dev, build, bundle)
3
+ *
4
+ * This module contains common functions used across multiple scripts
5
+ * to reduce duplication and ensure consistency.
6
+ */
7
+
8
+ import { readdir, readFile, stat } from "node:fs/promises";
9
+ import { join, resolve, sep } from "node:path";
10
+ import { ENGINE_SITE_DIR, siteOverridePath } from "./engine.ts";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Type definitions
14
+ // ---------------------------------------------------------------------------
15
+
16
+ export interface SiteSection {
17
+ name: string;
18
+ path: string;
19
+ files?: string[];
20
+ maxDepth?: number; // Max directory depth for auto-discovery (default: 4, max: 10)
21
+ exclude?: string[]; // Glob patterns to exclude from auto-discovery
22
+ }
23
+
24
+ export interface SiteBrand {
25
+ name: string;
26
+ url: string;
27
+ external?: boolean;
28
+ logo?: string; // Path to logo image (default: assets/brand/logo.png)
29
+ favicon?: string; // Path to favicon (default: assets/brand/favicon.png)
30
+ logoType?: "icon" | "wordmark"; // icon = square, wordmark = wide
31
+ }
32
+
33
+ export interface KitflyBrand {
34
+ readonly name: string;
35
+ readonly url: string;
36
+ readonly logo: string;
37
+ readonly favicon: string;
38
+ }
39
+
40
+ export const KITFLY_BRAND: Readonly<KitflyBrand> = {
41
+ name: "Kitfly",
42
+ url: "https://kitfly.dev",
43
+ logo: "assets/brand/kitfly-neon-128.png",
44
+ favicon: "assets/brand/kitfly-favicon-32.png",
45
+ } as const;
46
+
47
+ export interface FooterLink {
48
+ text: string;
49
+ url: string;
50
+ }
51
+
52
+ export interface SiteFooter {
53
+ copyright?: string;
54
+ copyrightUrl?: string;
55
+ links?: FooterLink[];
56
+ attribution?: boolean;
57
+ // social?: SocialLinks; // Reserved for future
58
+ }
59
+
60
+ export interface SiteServer {
61
+ port?: number; // Default dev server port
62
+ host?: string; // Default dev server host
63
+ }
64
+
65
+ export interface SiteConfig {
66
+ docroot: string;
67
+ title: string;
68
+ version?: string;
69
+ home?: string;
70
+ brand: SiteBrand;
71
+ sections: SiteSection[];
72
+ footer?: SiteFooter;
73
+ server?: SiteServer;
74
+ }
75
+
76
+ export interface Provenance {
77
+ version?: string;
78
+ buildDate: string;
79
+ gitCommit: string;
80
+ gitCommitDate: string;
81
+ gitBranch: string;
82
+ }
83
+
84
+ export interface ContentFile {
85
+ path: string;
86
+ urlPath: string;
87
+ section: string;
88
+ sectionBase?: string;
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Environment and CLI helpers
93
+ // ---------------------------------------------------------------------------
94
+
95
+ export function envString(name: string, fallback: string): string {
96
+ return process.env[name] ?? fallback;
97
+ }
98
+
99
+ export function envInt(name: string, fallback: number): number {
100
+ const val = process.env[name];
101
+ if (!val) return fallback;
102
+ const parsed = parseInt(val, 10);
103
+ return Number.isNaN(parsed) ? fallback : parsed;
104
+ }
105
+
106
+ export function envBool(name: string, fallback: boolean): boolean {
107
+ const val = process.env[name]?.toLowerCase();
108
+ if (!val) return fallback;
109
+ if (["true", "1", "yes"].includes(val)) return true;
110
+ if (["false", "0", "no"].includes(val)) return false;
111
+ return fallback;
112
+ }
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Network utilities
116
+ // ---------------------------------------------------------------------------
117
+
118
+ /**
119
+ * Check if a port is available by attempting to connect to it.
120
+ * Returns true if port is free, false if in use.
121
+ */
122
+ export async function isPortAvailable(port: number, host = "localhost"): Promise<boolean> {
123
+ return new Promise((resolve) => {
124
+ const net = require("node:net");
125
+ const socket = new net.Socket();
126
+
127
+ socket.setTimeout(1000);
128
+
129
+ socket.on("connect", () => {
130
+ socket.destroy();
131
+ resolve(false); // Port is in use (connection succeeded)
132
+ });
133
+
134
+ socket.on("timeout", () => {
135
+ socket.destroy();
136
+ resolve(true); // Timeout = likely no one listening
137
+ });
138
+
139
+ socket.on("error", (err: NodeJS.ErrnoException) => {
140
+ socket.destroy();
141
+ if (err.code === "ECONNREFUSED") {
142
+ resolve(true); // Connection refused = port is free
143
+ } else {
144
+ resolve(true); // Other errors = assume free
145
+ }
146
+ });
147
+
148
+ socket.connect(port, host);
149
+ });
150
+ }
151
+
152
+ /**
153
+ * Check port and exit with error if in use.
154
+ * Call this before starting a server.
155
+ */
156
+ export async function checkPortOrExit(port: number, host = "localhost"): Promise<void> {
157
+ const available = await isPortAvailable(port, host);
158
+ if (!available) {
159
+ console.error(`\x1b[31mError: Port ${port} is already in use\x1b[0m\n`);
160
+ console.error(`Another process is listening on ${host}:${port}.`);
161
+ console.error(`\nOptions:`);
162
+ console.error(` • Use a different port: --port ${port + 1}`);
163
+ console.error(` • Stop the other process first`);
164
+ process.exit(1);
165
+ }
166
+ }
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // YAML/Config parsing
170
+ // ---------------------------------------------------------------------------
171
+
172
+ export function parseYaml(content: string): Record<string, unknown> {
173
+ const result: Record<string, unknown> = {};
174
+ const lines = content.split("\n");
175
+
176
+ function stripInlineComment(raw: string): string {
177
+ let inSingle = false;
178
+ let inDouble = false;
179
+ let escaped = false;
180
+ for (let i = 0; i < raw.length; i++) {
181
+ const ch = raw[i];
182
+ if (escaped) {
183
+ escaped = false;
184
+ continue;
185
+ }
186
+ if (ch === "\\") {
187
+ escaped = true;
188
+ continue;
189
+ }
190
+ if (!inDouble && ch === "'") {
191
+ inSingle = !inSingle;
192
+ continue;
193
+ }
194
+ if (!inSingle && ch === '"') {
195
+ inDouble = !inDouble;
196
+ continue;
197
+ }
198
+ if (!inSingle && !inDouble && ch === "#") {
199
+ // YAML inline comment (outside quotes)
200
+ return raw.slice(0, i).trimEnd();
201
+ }
202
+ }
203
+ return raw.trimEnd();
204
+ }
205
+
206
+ // Stack tracks current object context with its base indentation
207
+ const stack: { obj: Record<string, unknown>; indent: number }[] = [{ obj: result, indent: -2 }];
208
+
209
+ for (let i = 0; i < lines.length; i++) {
210
+ const line = lines[i];
211
+ // Skip comments and empty lines
212
+ if (line.trim().startsWith("#") || line.trim() === "") continue;
213
+
214
+ const indent = line.search(/\S/);
215
+ const trimmed = line.trim();
216
+
217
+ // Pop stack when we dedent
218
+ while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
219
+ stack.pop();
220
+ }
221
+
222
+ // Array item (starts with "- ")
223
+ if (trimmed.startsWith("- ")) {
224
+ const afterDash = trimmed.slice(2);
225
+ const colonIndex = afterDash.indexOf(":");
226
+
227
+ if (colonIndex > 0) {
228
+ // Object in array: "- name: value"
229
+ const key = afterDash.slice(0, colonIndex).trim();
230
+ const val = stripInlineComment(afterDash.slice(colonIndex + 1).trim());
231
+
232
+ // Create new object for this array item
233
+ const obj: Record<string, unknown> = {};
234
+
235
+ // Handle inline array value like files: ["a", "b"]
236
+ if (val.startsWith("[") && val.endsWith("]")) {
237
+ const arrContent = val.slice(1, -1);
238
+ obj[key] = arrContent.split(",").map((s) => stripQuotes(s.trim()));
239
+ } else if (val === "") {
240
+ // Nested structure will follow
241
+ obj[key] = null; // Placeholder
242
+ } else {
243
+ obj[key] = parseValue(val);
244
+ }
245
+
246
+ // Find the array in parent
247
+ const parent = stack[stack.length - 1].obj;
248
+ const arrays = Object.entries(parent).filter(([, v]) => Array.isArray(v));
249
+ if (arrays.length > 0) {
250
+ const [, arr] = arrays[arrays.length - 1];
251
+ (arr as unknown[]).push(obj);
252
+ }
253
+
254
+ // Push this object onto stack for subsequent properties
255
+ stack.push({ obj, indent });
256
+ } else {
257
+ // Simple array item: "- value"
258
+ const parent = stack[stack.length - 1].obj;
259
+ const arrays = Object.entries(parent).filter(([, v]) => Array.isArray(v));
260
+ if (arrays.length > 0) {
261
+ const [, arr] = arrays[arrays.length - 1];
262
+ (arr as unknown[]).push(stripQuotes(stripInlineComment(afterDash.trim())));
263
+ }
264
+ }
265
+ continue;
266
+ }
267
+
268
+ // Key: value pair
269
+ const colonIndex = trimmed.indexOf(":");
270
+ if (colonIndex > 0) {
271
+ const key = trimmed.slice(0, colonIndex).trim();
272
+ const value = stripInlineComment(trimmed.slice(colonIndex + 1).trim());
273
+ const parent = stack[stack.length - 1].obj;
274
+
275
+ if (value === "") {
276
+ // Check if next non-empty line is an array or object
277
+ let nextIdx = i + 1;
278
+ while (nextIdx < lines.length && lines[nextIdx].trim() === "") nextIdx++;
279
+
280
+ if (nextIdx < lines.length && lines[nextIdx].trim().startsWith("- ")) {
281
+ // It's an array
282
+ parent[key] = [];
283
+ } else {
284
+ // It's a nested object
285
+ const nested: Record<string, unknown> = {};
286
+ parent[key] = nested;
287
+ stack.push({ obj: nested, indent });
288
+ }
289
+ } else if (value.startsWith("[") && value.endsWith("]")) {
290
+ // Inline array
291
+ const arrContent = value.slice(1, -1);
292
+ parent[key] = arrContent.split(",").map((s) => stripQuotes(s.trim()));
293
+ } else {
294
+ parent[key] = parseValue(value);
295
+ }
296
+ }
297
+ }
298
+
299
+ return result;
300
+ }
301
+
302
+ export function parseValue(value: string): unknown {
303
+ const stripped = stripQuotes(value);
304
+ if (stripped === "true") return true;
305
+ if (stripped === "false") return false;
306
+ return stripped;
307
+ }
308
+
309
+ export function stripQuotes(value: string): string {
310
+ if (
311
+ (value.startsWith('"') && value.endsWith('"')) ||
312
+ (value.startsWith("'") && value.endsWith("'"))
313
+ ) {
314
+ return value.slice(1, -1);
315
+ }
316
+ return value;
317
+ }
318
+
319
+ // ---------------------------------------------------------------------------
320
+ // File utilities
321
+ // ---------------------------------------------------------------------------
322
+
323
+ export async function exists(path: string): Promise<boolean> {
324
+ try {
325
+ await stat(path);
326
+ return true;
327
+ } catch {
328
+ return false;
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Validate path doesn't escape repo root
334
+ * @param root - The root directory
335
+ * @param docroot - The document root relative to ROOT
336
+ * @param requestedPath - The requested path
337
+ * @param logErrors - Whether to log errors (default: false)
338
+ * @returns The resolved path or null if invalid
339
+ */
340
+ export function validatePath(
341
+ root: string,
342
+ docroot: string,
343
+ requestedPath: string,
344
+ logErrors = false,
345
+ ): string | null {
346
+ const resolved = resolve(root, docroot, requestedPath);
347
+ const normalizedRoot = resolve(root);
348
+ if (!resolved.startsWith(`${normalizedRoot}${sep}`) && resolved !== normalizedRoot) {
349
+ if (logErrors) {
350
+ console.error(`Path escapes repo root: ${requestedPath}`);
351
+ }
352
+ return null;
353
+ }
354
+ return resolved;
355
+ }
356
+
357
+ /**
358
+ * Normalize a resolved path to a URL-safe path relative to ROOT
359
+ * Handles ../docs/decisions -> docs/decisions
360
+ */
361
+ export function toUrlPath(root: string, resolvedPath: string): string {
362
+ const normalizedRoot = resolve(root);
363
+ if (resolvedPath.startsWith(`${normalizedRoot}${sep}`)) {
364
+ return resolvedPath.slice(normalizedRoot.length + 1).replaceAll("\\", "/");
365
+ }
366
+ return resolvedPath;
367
+ }
368
+
369
+ export async function resolveTemplatePath(siteRoot: string): Promise<string> {
370
+ const override = siteOverridePath(siteRoot, "template.html");
371
+ if (await exists(override)) return override;
372
+ return join(ENGINE_SITE_DIR, "template.html");
373
+ }
374
+
375
+ export async function resolveStylesPath(siteRoot: string): Promise<string> {
376
+ const override = siteOverridePath(siteRoot, "styles.css");
377
+ if (await exists(override)) return override;
378
+ return join(ENGINE_SITE_DIR, "styles.css");
379
+ }
380
+
381
+ // ---------------------------------------------------------------------------
382
+ // Markdown utilities
383
+ // ---------------------------------------------------------------------------
384
+
385
+ export function parseFrontmatter(content: string): {
386
+ frontmatter: Record<string, unknown>;
387
+ body: string;
388
+ } {
389
+ const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
390
+ if (!match) {
391
+ return { frontmatter: {}, body: content };
392
+ }
393
+
394
+ const frontmatter: Record<string, unknown> = {};
395
+ const lines = match[1].split("\n");
396
+ for (const line of lines) {
397
+ const colonIndex = line.indexOf(":");
398
+ if (colonIndex > 0) {
399
+ const key = line.slice(0, colonIndex).trim();
400
+ let value = line.slice(colonIndex + 1).trim();
401
+ // Remove quotes if present
402
+ if (
403
+ (value.startsWith('"') && value.endsWith('"')) ||
404
+ (value.startsWith("'") && value.endsWith("'"))
405
+ ) {
406
+ value = value.slice(1, -1);
407
+ }
408
+ frontmatter[key] = value;
409
+ }
410
+ }
411
+
412
+ return { frontmatter, body: match[2] };
413
+ }
414
+
415
+ export function slugify(text: string): string {
416
+ return text
417
+ .toLowerCase()
418
+ .replace(/[^\w\s-]/g, "")
419
+ .replace(/\s+/g, "-")
420
+ .replace(/-+/g, "-")
421
+ .trim();
422
+ }
423
+
424
+ // ---------------------------------------------------------------------------
425
+ // Navigation/template building
426
+ // ---------------------------------------------------------------------------
427
+
428
+ /** Hard ceiling — guards against symlink loops or pathological trees */
429
+ const MAX_DISCOVERY_DEPTH = 10;
430
+
431
+ /** Default depth for auto-discovered sections (keeps nav manageable) */
432
+ const DEFAULT_DISCOVERY_DEPTH = 4;
433
+
434
+ /**
435
+ * Recursively collect content files from a directory.
436
+ */
437
+ async function walkContentDir(
438
+ dir: string,
439
+ urlBase: string,
440
+ section: string,
441
+ sectionBase: string,
442
+ relBase: string,
443
+ depth: number,
444
+ maxDepth: number,
445
+ exclude: string[],
446
+ files: ContentFile[],
447
+ ): Promise<void> {
448
+ if (depth > maxDepth) return;
449
+
450
+ let entries: import("node:fs").Dirent[];
451
+ try {
452
+ entries = await readdir(dir, { withFileTypes: true });
453
+ } catch {
454
+ return;
455
+ }
456
+
457
+ // Sort for consistent cross-platform ordering
458
+ entries.sort((a, b) => a.name.localeCompare(b.name));
459
+
460
+ for (const entry of entries) {
461
+ if (entry.name.startsWith(".")) continue;
462
+ const relPath = relBase ? `${relBase}/${entry.name}` : entry.name;
463
+ if (matchesExclude(relPath, exclude)) continue;
464
+
465
+ if (entry.isDirectory()) {
466
+ await walkContentDir(
467
+ join(dir, entry.name),
468
+ `${urlBase}/${entry.name}`,
469
+ section,
470
+ sectionBase,
471
+ relPath,
472
+ depth + 1,
473
+ maxDepth,
474
+ exclude,
475
+ files,
476
+ );
477
+ } else if (entry.isFile()) {
478
+ const name = entry.name;
479
+ if (!name.endsWith(".md") && !name.endsWith(".yaml") && !name.endsWith(".json")) continue;
480
+
481
+ const stem = name.replace(/\.(md|yaml|json)$/, "");
482
+ let urlPath: string;
483
+ if (stem.toLowerCase() === "index" || stem.toLowerCase() === "readme") {
484
+ // Use directory path as URL — parent dir name becomes display name
485
+ urlPath = urlBase;
486
+ } else {
487
+ urlPath = `${urlBase}/${stem}`;
488
+ }
489
+
490
+ files.push({
491
+ path: join(dir, name),
492
+ urlPath,
493
+ section,
494
+ sectionBase,
495
+ });
496
+ }
497
+ }
498
+ }
499
+
500
+ function matchesExclude(name: string, patterns: string[]): boolean {
501
+ if (patterns.length === 0) return false;
502
+ return patterns.some((pattern) => globMatch(pattern, name));
503
+ }
504
+
505
+ function globMatch(pattern: string, str: string): boolean {
506
+ const escapeRegex = (s: string) => s.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
507
+ let regexPattern = "";
508
+ for (const ch of pattern) {
509
+ if (ch === "*") regexPattern += "[^/]*";
510
+ else if (ch === "?") regexPattern += "[^/]";
511
+ else regexPattern += escapeRegex(ch);
512
+ }
513
+ const regex = `^${regexPattern}$`;
514
+ return new RegExp(regex).test(str);
515
+ }
516
+
517
+ /**
518
+ * Collect all content files based on config
519
+ */
520
+ export async function collectFiles(root: string, config: SiteConfig): Promise<ContentFile[]> {
521
+ const files: ContentFile[] = [];
522
+
523
+ for (const section of config.sections) {
524
+ const sectionPath = validatePath(root, config.docroot, section.path);
525
+ if (!sectionPath) continue;
526
+
527
+ // Compute URL base from resolved path (handles ../docs/decisions -> docs/decisions)
528
+ const urlBase = toUrlPath(root, sectionPath);
529
+
530
+ if (section.files) {
531
+ // Explicit file list (for root-level sections like Overview)
532
+ for (const file of section.files) {
533
+ const filePath = join(sectionPath, file);
534
+ try {
535
+ await stat(filePath);
536
+ const name = file.replace(/\.(md|yaml|json)$/, "");
537
+ files.push({
538
+ path: filePath,
539
+ urlPath: name.toLowerCase(),
540
+ section: section.name,
541
+ sectionBase: urlBase,
542
+ });
543
+ } catch {
544
+ // Skip if doesn't exist
545
+ }
546
+ }
547
+ } else {
548
+ // Auto-discover from directory (recursive)
549
+ const maxDepth = Math.min(
550
+ typeof section.maxDepth === "number" ? section.maxDepth : DEFAULT_DISCOVERY_DEPTH,
551
+ MAX_DISCOVERY_DEPTH,
552
+ );
553
+ await walkContentDir(
554
+ sectionPath,
555
+ urlBase,
556
+ section.name,
557
+ urlBase,
558
+ "",
559
+ 0,
560
+ maxDepth,
561
+ section.exclude ?? [],
562
+ files,
563
+ );
564
+ }
565
+ }
566
+
567
+ return files;
568
+ }
569
+
570
+ // ---------------------------------------------------------------------------
571
+ // Hierarchical navigation tree
572
+ // ---------------------------------------------------------------------------
573
+
574
+ interface NavNode {
575
+ name: string;
576
+ urlPath: string | null; // null for dirs without index.md
577
+ children: NavNode[];
578
+ }
579
+
580
+ /**
581
+ * Derive section URL base from a group of files (fallback when sectionBase not set)
582
+ */
583
+ function findSectionBase(urlPaths: string[]): string {
584
+ if (urlPaths.length === 0) return "";
585
+ if (urlPaths.length === 1) {
586
+ const parts = urlPaths[0].split("/");
587
+ return parts.length > 1 ? parts.slice(0, -1).join("/") : "";
588
+ }
589
+ const split = urlPaths.map((p) => p.split("/"));
590
+ let commonLen = 0;
591
+ for (let i = 0; i < Math.min(...split.map((s) => s.length)); i++) {
592
+ if (split.every((s) => s[i] === split[0][i])) commonLen = i + 1;
593
+ else break;
594
+ }
595
+ return split[0].slice(0, commonLen).join("/");
596
+ }
597
+
598
+ /**
599
+ * Build a NavNode tree from a flat file list for one section.
600
+ * Returns the tree and the section root index urlPath (if any).
601
+ */
602
+ function buildNavTree(
603
+ files: ContentFile[],
604
+ sectionBase: string,
605
+ ): { tree: NavNode[]; indexUrlPath: string | null } {
606
+ const root: NavNode = { name: "", urlPath: null, children: [] };
607
+ let indexUrlPath: string | null = null;
608
+
609
+ for (const file of files) {
610
+ const rel =
611
+ file.urlPath.length > sectionBase.length ? file.urlPath.slice(sectionBase.length + 1) : "";
612
+
613
+ if (rel === "") {
614
+ // Section root index — becomes section header link
615
+ indexUrlPath = file.urlPath;
616
+ continue;
617
+ }
618
+
619
+ const segments = rel.split("/");
620
+ let node = root;
621
+
622
+ for (let i = 0; i < segments.length; i++) {
623
+ const seg = segments[i];
624
+ const isLeaf = i === segments.length - 1;
625
+
626
+ if (isLeaf) {
627
+ const existing = node.children.find((c) => c.name === seg);
628
+ if (existing && existing.children.length > 0 && !existing.urlPath) {
629
+ // Directory node exists without a link — this is its index file
630
+ existing.urlPath = file.urlPath;
631
+ } else {
632
+ node.children.push({ name: seg, urlPath: file.urlPath, children: [] });
633
+ }
634
+ } else {
635
+ let child = node.children.find((c) => c.name === seg);
636
+ if (!child) {
637
+ child = { name: seg, urlPath: null, children: [] };
638
+ node.children.push(child);
639
+ }
640
+ node = child;
641
+ }
642
+ }
643
+ }
644
+
645
+ return { tree: root.children, indexUrlPath };
646
+ }
647
+
648
+ function nodeContainsPath(node: NavNode, urlPath: string): boolean {
649
+ if (node.urlPath === urlPath) return true;
650
+ return node.children.some((c) => nodeContainsPath(c, urlPath));
651
+ }
652
+
653
+ /**
654
+ * Render a NavNode tree to HTML with collapsible groups.
655
+ */
656
+ function renderNavTree(
657
+ nodes: NavNode[],
658
+ currentUrlPath: string | null,
659
+ makeHref: (urlPath: string) => string,
660
+ ): string {
661
+ if (nodes.length === 0) return "";
662
+
663
+ let html = "<ul>";
664
+ for (const node of nodes) {
665
+ if (node.children.length > 0) {
666
+ // Directory with children — collapsible group
667
+ const isOpen = currentUrlPath ? nodeContainsPath(node, currentUrlPath) : false;
668
+ const open = isOpen ? " open" : "";
669
+ html += `<li><details${open}><summary class="nav-group">`;
670
+ if (node.urlPath) {
671
+ const active = currentUrlPath === node.urlPath ? ' class="active"' : "";
672
+ html += `<a href="${makeHref(node.urlPath)}"${active}>${node.name}</a>`;
673
+ } else {
674
+ html += node.name;
675
+ }
676
+ html += `</summary>`;
677
+ html += renderNavTree(node.children, currentUrlPath, makeHref);
678
+ html += `</details></li>`;
679
+ } else {
680
+ // Leaf file
681
+ const active = currentUrlPath === node.urlPath ? ' class="active"' : "";
682
+ html += `<li><a href="${makeHref(node.urlPath ?? "")}"${active}>${node.name}</a></li>`;
683
+ }
684
+ }
685
+ html += "</ul>";
686
+ return html;
687
+ }
688
+
689
+ /**
690
+ * Build section nav HTML using tree renderer.
691
+ * Shared logic for buildNavSimple and buildNavStatic.
692
+ */
693
+ export function buildSectionNav(
694
+ sectionFiles: Map<string, ContentFile[]>,
695
+ config: SiteConfig,
696
+ currentUrlPath: string | null,
697
+ makeHref: (urlPath: string) => string,
698
+ ): string {
699
+ let html = "";
700
+
701
+ // Render sections in config order
702
+ for (const section of config.sections) {
703
+ const items = sectionFiles.get(section.name);
704
+ if (!items || items.length === 0) continue;
705
+
706
+ const sBase = items[0]?.sectionBase ?? findSectionBase(items.map((f) => f.urlPath));
707
+ const { tree, indexUrlPath } = buildNavTree(items, sBase);
708
+
709
+ // Section header — clickable if section has root index
710
+ if (indexUrlPath) {
711
+ const active = currentUrlPath === indexUrlPath ? ' class="active"' : "";
712
+ html += `<li><a href="${makeHref(indexUrlPath)}"${active} class="nav-section">${section.name}</a>`;
713
+ } else {
714
+ html += `<li><span class="nav-section">${section.name}</span>`;
715
+ }
716
+
717
+ html += renderNavTree(tree, currentUrlPath, makeHref);
718
+ html += `</li>`;
719
+ }
720
+
721
+ return html;
722
+ }
723
+
724
+ /**
725
+ * Build navigation HTML for dev server (simple, no path prefix)
726
+ */
727
+ export function buildNavSimple(
728
+ files: ContentFile[],
729
+ config: SiteConfig,
730
+ currentUrlPath?: string,
731
+ ): string {
732
+ // Group files by section
733
+ const sectionFiles = new Map<string, ContentFile[]>();
734
+ for (const file of files) {
735
+ if (!sectionFiles.has(file.section)) {
736
+ sectionFiles.set(file.section, []);
737
+ }
738
+ sectionFiles.get(file.section)?.push(file);
739
+ }
740
+
741
+ const makeHref = (urlPath: string) => `/${urlPath}`;
742
+ let html = "<ul>";
743
+
744
+ if (config.home) {
745
+ html += `<li><a href="/" class="nav-home">Home</a></li>`;
746
+ }
747
+
748
+ html += buildSectionNav(sectionFiles, config, currentUrlPath ?? null, makeHref);
749
+ html += "</ul>";
750
+
751
+ return html;
752
+ }
753
+
754
+ /**
755
+ * Build navigation HTML for static build (with path prefix and active state)
756
+ */
757
+ export function buildNavStatic(
758
+ files: ContentFile[],
759
+ currentKey: string,
760
+ config: SiteConfig,
761
+ pathPrefix: string,
762
+ ): string {
763
+ // Group files by section
764
+ const sectionFiles = new Map<string, ContentFile[]>();
765
+ for (const file of files) {
766
+ if (!sectionFiles.has(file.section)) {
767
+ sectionFiles.set(file.section, []);
768
+ }
769
+ sectionFiles.get(file.section)?.push(file);
770
+ }
771
+
772
+ const makeHref = (urlPath: string) => `${pathPrefix}${urlPath}.html`;
773
+ let html = "<ul>";
774
+
775
+ if (config.home) {
776
+ const homeActive = currentKey === "" ? ' class="active"' : "";
777
+ html += `<li><a href="${pathPrefix}index.html"${homeActive} class="nav-home">Home</a></li>`;
778
+ }
779
+
780
+ html += buildSectionNav(sectionFiles, config, currentKey || null, makeHref);
781
+ html += "</ul>";
782
+
783
+ return html;
784
+ }
785
+
786
+ /**
787
+ * Extract TOC from rendered HTML
788
+ */
789
+ export function buildToc(html: string): string {
790
+ const headings: { level: number; id: string; text: string }[] = [];
791
+ const regex = /<h([23])\s+id="([^"]+)"[^>]*>([^<]+)<\/h[23]>/gi;
792
+ let match: RegExpExecArray | null = null;
793
+ while (true) {
794
+ match = regex.exec(html);
795
+ if (match === null) break;
796
+ headings.push({
797
+ level: parseInt(match[1], 10),
798
+ id: match[2],
799
+ text: match[3].trim(),
800
+ });
801
+ }
802
+
803
+ if (headings.length < 2) return "";
804
+
805
+ let tocHtml = '<aside class="toc"><span class="toc-title">On this page</span><ul>';
806
+ for (const h of headings) {
807
+ const levelClass = h.level === 3 ? ' class="toc-h3"' : "";
808
+ tocHtml += `<li${levelClass}><a href="#${h.id}">${h.text}</a></li>`;
809
+ }
810
+ tocHtml += "</ul></aside>";
811
+ return tocHtml;
812
+ }
813
+
814
+ /**
815
+ * Find the first file in a section that matches the given path prefix
816
+ */
817
+ function findFirstFileInSection(files: ContentFile[], pathPrefix: string): ContentFile | undefined {
818
+ return files.find((file) => file.urlPath.startsWith(`${pathPrefix}/`));
819
+ }
820
+
821
+ /**
822
+ * Build breadcrumbs for dev server (simple, no path prefix)
823
+ * Links to first file in each section instead of non-existent index pages
824
+ */
825
+ export function buildBreadcrumbsSimple(
826
+ urlPath: string,
827
+ files: ContentFile[],
828
+ _config: SiteConfig,
829
+ ): string {
830
+ const parts = urlPath.split("/").filter(Boolean);
831
+ if (parts.length <= 1) return "";
832
+
833
+ let html = '<nav class="breadcrumbs">';
834
+ let path = "";
835
+ for (let i = 0; i < parts.length - 1; i++) {
836
+ path += (path ? "/" : "") + parts[i];
837
+ const name = parts[i].charAt(0).toUpperCase() + parts[i].slice(1);
838
+
839
+ // Find first file in this section to link to
840
+ const firstFile = findFirstFileInSection(files, path);
841
+ const href = firstFile ? `/${firstFile.urlPath}` : `/${path}/`;
842
+
843
+ html += `<a href="${href}">${name}</a><span class="separator">›</span>`;
844
+ }
845
+ html += `<span>${parts[parts.length - 1]}</span>`;
846
+ html += "</nav>";
847
+ return html;
848
+ }
849
+
850
+ /**
851
+ * Build breadcrumbs for static build (with path prefix)
852
+ * Links to first file in each section instead of non-existent index pages
853
+ */
854
+ export function buildBreadcrumbsStatic(
855
+ urlKey: string,
856
+ pathPrefix: string,
857
+ files: ContentFile[],
858
+ _config: SiteConfig,
859
+ ): string {
860
+ const parts = urlKey.split("/").filter(Boolean);
861
+ if (parts.length <= 1) return "";
862
+
863
+ let html = '<nav class="breadcrumbs">';
864
+ let path = "";
865
+ for (let i = 0; i < parts.length - 1; i++) {
866
+ path += (path ? "/" : "") + parts[i];
867
+ const name = parts[i].charAt(0).toUpperCase() + parts[i].slice(1);
868
+
869
+ // Find first file in this section to link to
870
+ const firstFile = findFirstFileInSection(files, path);
871
+ const href = firstFile
872
+ ? `${pathPrefix}${firstFile.urlPath}.html`
873
+ : `${pathPrefix}${path}/index.html`;
874
+
875
+ html += `<a href="${href}">${name}</a><span class="separator">›</span>`;
876
+ }
877
+ html += `<span>${parts[parts.length - 1]}</span>`;
878
+ html += "</nav>";
879
+ return html;
880
+ }
881
+
882
+ /**
883
+ * Build page meta (last updated date)
884
+ */
885
+ export function buildPageMeta(frontmatter: Record<string, unknown>): string {
886
+ const lastUpdated = frontmatter.last_updated as string | undefined;
887
+ if (!lastUpdated) return "";
888
+ const formatted = formatDate(lastUpdated);
889
+ return `<div class="page-meta">Last updated: ${formatted}</div>`;
890
+ }
891
+
892
+ /**
893
+ * Build footer HTML from provenance
894
+ */
895
+ export function buildFooter(provenance: Provenance, config: SiteConfig): string {
896
+ const commitDate = formatDate(provenance.gitCommitDate);
897
+ const publishYear = Number.isNaN(new Date(provenance.gitCommitDate).getTime())
898
+ ? new Date().getFullYear().toString()
899
+ : new Date(provenance.gitCommitDate).getUTCFullYear().toString();
900
+ const footer = config.footer || {};
901
+ const copyrightText = footer.copyright
902
+ ? escapeHtml(footer.copyright)
903
+ : `© ${publishYear} ${escapeHtml(config.brand.name)}`;
904
+ const copyrightHtml = footer.copyrightUrl
905
+ ? `<a href="${escapeHtml(footer.copyrightUrl)}" class="footer-link">${copyrightText}</a>`
906
+ : copyrightText;
907
+ const hasCustomLinks = Array.isArray(footer.links);
908
+ const brandLinkText = /^https?:\/\//.test(config.brand.url)
909
+ ? config.brand.url.replace(/^https?:\/\//, "")
910
+ : config.brand.name;
911
+ const linksHtml = hasCustomLinks
912
+ ? (footer.links ?? [])
913
+ .map(
914
+ (link) =>
915
+ `<a href="${escapeHtml(link.url)}" class="footer-link">${escapeHtml(link.text)}</a>`,
916
+ )
917
+ .join('<span class="footer-separator">·</span>')
918
+ : `<a href="${escapeHtml(config.brand.url)}" class="footer-link"${config.brand.external ? ' target="_blank" rel="noopener"' : ""}>${escapeHtml(brandLinkText)}</a>`;
919
+ const attributionEnabled = footer.attribution !== false;
920
+ const versionHtml = provenance.version
921
+ ? `<span class="footer-version">v${escapeHtml(provenance.version)}</span>
922
+ <span class="footer-separator">·</span>`
923
+ : "";
924
+
925
+ return `
926
+ <footer class="site-footer">
927
+ <div class="footer-content">
928
+ <div class="footer-left">
929
+ ${versionHtml}
930
+ <span class="footer-commit" title="Commit: ${escapeHtml(provenance.gitCommit)}">Published ${commitDate}</span>
931
+ </div>
932
+ <div class="footer-center">
933
+ <span class="footer-copyright">${copyrightHtml}</span>
934
+ ${linksHtml ? `<span class="footer-separator">·</span>${linksHtml}` : ""}
935
+ </div>
936
+ ${
937
+ attributionEnabled
938
+ ? `<div class="footer-right">
939
+ <a href="${KITFLY_BRAND.url}" class="footer-link">Built with ${KITFLY_BRAND.name}</a>
940
+ </div>`
941
+ : ""
942
+ }
943
+ </div>
944
+ </footer>`;
945
+ }
946
+
947
+ /**
948
+ * Build bundle footer HTML.
949
+ */
950
+ export function buildBundleFooter(version: string | undefined, config: SiteConfig): string {
951
+ const footer = config.footer || {};
952
+ const copyrightText = footer.copyright
953
+ ? escapeHtml(footer.copyright)
954
+ : `© ${new Date().getFullYear()} ${escapeHtml(config.brand.name)}`;
955
+ const copyrightHtml = footer.copyrightUrl
956
+ ? `<a href="${escapeHtml(footer.copyrightUrl)}" class="footer-link">${copyrightText}</a>`
957
+ : copyrightText;
958
+ const hasCustomLinks = Array.isArray(footer.links);
959
+ const brandLinkText = /^https?:\/\//.test(config.brand.url)
960
+ ? config.brand.url.replace(/^https?:\/\//, "")
961
+ : config.brand.name;
962
+ const linksHtml = hasCustomLinks
963
+ ? (footer.links ?? [])
964
+ .map(
965
+ (link) =>
966
+ `<a href="${escapeHtml(link.url)}" class="footer-link">${escapeHtml(link.text)}</a>`,
967
+ )
968
+ .join('<span class="footer-separator">·</span>')
969
+ : `<a href="${escapeHtml(config.brand.url)}" class="footer-link"${config.brand.external ? ' target="_blank" rel="noopener"' : ""}>${escapeHtml(brandLinkText)}</a>`;
970
+ const attributionEnabled = footer.attribution !== false;
971
+ const versionHtml = version
972
+ ? `<span class="footer-version">v${escapeHtml(version)}</span>
973
+ <span class="footer-separator">·</span>`
974
+ : "";
975
+
976
+ return `
977
+ <footer class="site-footer">
978
+ <div class="footer-content">
979
+ <div class="footer-left">
980
+ ${versionHtml}
981
+ <span class="footer-commit">Published (offline bundle)</span>
982
+ </div>
983
+ <div class="footer-center">
984
+ <span class="footer-copyright">${copyrightHtml}</span>
985
+ ${linksHtml ? `<span class="footer-separator">·</span>${linksHtml}` : ""}
986
+ </div>
987
+ ${
988
+ attributionEnabled
989
+ ? `<div class="footer-right">
990
+ <a href="${KITFLY_BRAND.url}" class="footer-link">Built with ${KITFLY_BRAND.name}</a>
991
+ </div>`
992
+ : ""
993
+ }
994
+ </div>
995
+ </footer>`;
996
+ }
997
+
998
+ // ---------------------------------------------------------------------------
999
+ // Formatting
1000
+ // ---------------------------------------------------------------------------
1001
+
1002
+ export function escapeHtml(text: string): string {
1003
+ return text
1004
+ .replace(/&/g, "&amp;")
1005
+ .replace(/</g, "&lt;")
1006
+ .replace(/>/g, "&gt;")
1007
+ .replace(/"/g, "&quot;")
1008
+ .replace(/'/g, "&#039;");
1009
+ }
1010
+
1011
+ /**
1012
+ * Format date for display (YYYY-MM-DD for consistency)
1013
+ */
1014
+ export function formatDate(isoDate: string): string {
1015
+ if (isoDate === "unknown" || isoDate === "dev") return isoDate;
1016
+ try {
1017
+ const date = new Date(isoDate);
1018
+ return date.toISOString().split("T")[0];
1019
+ } catch {
1020
+ return isoDate;
1021
+ }
1022
+ }
1023
+
1024
+ // ---------------------------------------------------------------------------
1025
+ // Provenance
1026
+ // ---------------------------------------------------------------------------
1027
+
1028
+ /**
1029
+ * Get git information
1030
+ * @param root - The root directory for git commands
1031
+ * @param devMode - If true, use "dev"/"local" defaults; if false, use "unknown" defaults
1032
+ */
1033
+ export async function getGitInfo(
1034
+ root: string,
1035
+ devMode = false,
1036
+ ): Promise<{
1037
+ commit: string;
1038
+ commitDate: string;
1039
+ branch: string;
1040
+ }> {
1041
+ const defaultInfo = devMode
1042
+ ? {
1043
+ commit: "dev",
1044
+ commitDate: new Date().toISOString(),
1045
+ branch: "local",
1046
+ }
1047
+ : {
1048
+ commit: "unknown",
1049
+ commitDate: "unknown",
1050
+ branch: "unknown",
1051
+ };
1052
+
1053
+ try {
1054
+ async function runGit(args: string[]): Promise<string> {
1055
+ const proc = Bun.spawn(["git", ...args], {
1056
+ cwd: root,
1057
+ stdout: "pipe",
1058
+ stderr: "ignore",
1059
+ });
1060
+ const out = (await new Response(proc.stdout).text()).trim();
1061
+ const code = await proc.exited;
1062
+ return code === 0 ? out : "";
1063
+ }
1064
+
1065
+ const commit = await runGit(["rev-parse", "--short", "HEAD"]);
1066
+ const commitDate = await runGit(["log", "-1", "--format=%cI"]);
1067
+ const branch = await runGit(["rev-parse", "--abbrev-ref", "HEAD"]);
1068
+
1069
+ return {
1070
+ commit: commit || defaultInfo.commit,
1071
+ commitDate: commitDate || defaultInfo.commitDate,
1072
+ branch: branch || defaultInfo.branch,
1073
+ };
1074
+ } catch {
1075
+ return defaultInfo;
1076
+ }
1077
+ }
1078
+
1079
+ export async function resolveSiteVersion(
1080
+ root: string,
1081
+ configuredVersion?: string,
1082
+ ): Promise<string | undefined> {
1083
+ if (typeof configuredVersion === "string" && configuredVersion.trim() !== "") {
1084
+ return configuredVersion.trim();
1085
+ }
1086
+
1087
+ try {
1088
+ const proc = Bun.spawn(["git", "describe", "--tags", "--exact-match", "HEAD"], {
1089
+ cwd: root,
1090
+ stdout: "pipe",
1091
+ stderr: "ignore",
1092
+ });
1093
+ const out = (await new Response(proc.stdout).text()).trim();
1094
+ const code = await proc.exited;
1095
+ if (code === 0 && out) {
1096
+ return out.replace(/^v/, "");
1097
+ }
1098
+ } catch {
1099
+ // No git tag fallback available
1100
+ }
1101
+
1102
+ return undefined;
1103
+ }
1104
+
1105
+ /**
1106
+ * Generate provenance information
1107
+ * @param root - The root directory
1108
+ * @param devMode - If true, use dev-friendly defaults
1109
+ */
1110
+ export async function generateProvenance(
1111
+ root: string,
1112
+ devMode = false,
1113
+ siteVersion?: string,
1114
+ ): Promise<Provenance> {
1115
+ const version = await resolveSiteVersion(root, siteVersion);
1116
+ const gitInfo = await getGitInfo(root, devMode);
1117
+
1118
+ return {
1119
+ version,
1120
+ buildDate: new Date().toISOString(),
1121
+ gitCommit: gitInfo.commit,
1122
+ gitCommitDate: gitInfo.commitDate,
1123
+ gitBranch: gitInfo.branch,
1124
+ };
1125
+ }
1126
+
1127
+ // ---------------------------------------------------------------------------
1128
+ // Site configuration
1129
+ // ---------------------------------------------------------------------------
1130
+
1131
+ function normalizeFooter(footer: unknown): SiteFooter | undefined {
1132
+ if (!footer || typeof footer !== "object") return undefined;
1133
+ const raw = footer as Record<string, unknown>;
1134
+ let links: FooterLink[] | undefined;
1135
+
1136
+ if (Array.isArray(raw.links)) {
1137
+ links = raw.links
1138
+ .filter(
1139
+ (link): link is FooterLink =>
1140
+ typeof link === "object" &&
1141
+ link !== null &&
1142
+ typeof (link as Record<string, unknown>).text === "string" &&
1143
+ typeof (link as Record<string, unknown>).url === "string",
1144
+ )
1145
+ .slice(0, 10);
1146
+
1147
+ if (raw.links.length > 10) {
1148
+ console.warn("⚠ site.yaml footer.links supports at most 10 links; truncating extras.");
1149
+ }
1150
+ }
1151
+
1152
+ return {
1153
+ copyright: typeof raw.copyright === "string" ? raw.copyright : undefined,
1154
+ copyrightUrl: typeof raw.copyrightUrl === "string" ? raw.copyrightUrl : undefined,
1155
+ links,
1156
+ attribution: typeof raw.attribution === "boolean" ? raw.attribution : undefined,
1157
+ };
1158
+ }
1159
+
1160
+ /**
1161
+ * Load site configuration with fallback chain
1162
+ * @param root - The root directory
1163
+ * @param defaultTitle - Default title if no config found (default: "Getting Started")
1164
+ */
1165
+ export async function loadSiteConfig(
1166
+ root: string,
1167
+ defaultTitle = "Getting Started",
1168
+ ): Promise<SiteConfig> {
1169
+ // Try site.yaml first
1170
+ try {
1171
+ const configPath = join(root, "site.yaml");
1172
+ const content = await readFile(configPath, "utf-8");
1173
+ const parsed = parseYaml(content) as unknown as SiteConfig;
1174
+ const parsedRecord = parsed as unknown as Record<string, unknown>;
1175
+
1176
+ // Validate required fields
1177
+ if (!parsed.title || !parsed.brand || !parsed.sections) {
1178
+ throw new Error("site.yaml missing required fields: title, brand, sections");
1179
+ }
1180
+
1181
+ return {
1182
+ docroot: parsed.docroot || ".",
1183
+ title: parsed.title,
1184
+ version: typeof parsedRecord.version === "string" ? parsedRecord.version : undefined,
1185
+ home: parsed.home as string | undefined,
1186
+ brand: {
1187
+ ...parsed.brand,
1188
+ logo: parsed.brand.logo || "assets/brand/logo.png",
1189
+ favicon: parsed.brand.favicon || "assets/brand/favicon.png",
1190
+ logoType: parsed.brand.logoType || "icon",
1191
+ },
1192
+ sections: parsed.sections,
1193
+ footer: normalizeFooter(parsedRecord.footer),
1194
+ server: parsed.server,
1195
+ };
1196
+ } catch (e) {
1197
+ if ((e as NodeJS.ErrnoException).code !== "ENOENT") {
1198
+ throw e;
1199
+ }
1200
+ }
1201
+
1202
+ // Fallback: check for content/ directory
1203
+ try {
1204
+ const contentDir = join(root, "content");
1205
+ await stat(contentDir);
1206
+
1207
+ // Auto-discover sections from subdirectories
1208
+ const entries = await readdir(contentDir, { withFileTypes: true });
1209
+ const sections: SiteSection[] = [];
1210
+
1211
+ for (const entry of entries) {
1212
+ if (entry.isDirectory()) {
1213
+ sections.push({
1214
+ name: entry.name.charAt(0).toUpperCase() + entry.name.slice(1),
1215
+ path: entry.name,
1216
+ });
1217
+ }
1218
+ }
1219
+
1220
+ if (sections.length > 0) {
1221
+ return {
1222
+ docroot: "content",
1223
+ title: "Documentation",
1224
+ brand: { name: "Docs", url: "/" },
1225
+ sections,
1226
+ };
1227
+ }
1228
+ } catch {
1229
+ // content/ doesn't exist
1230
+ }
1231
+
1232
+ // Final fallback
1233
+ return {
1234
+ docroot: ".",
1235
+ title: defaultTitle,
1236
+ brand: { name: "Handbook", url: "/" },
1237
+ sections: [],
1238
+ };
1239
+ }