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
@@ -0,0 +1,601 @@
1
+ /**
2
+ * Build static site from markdown files
3
+ *
4
+ * Usage: bun run build [folder] [options]
5
+ *
6
+ * Options:
7
+ * -o, --out <dir> Output directory [env: KITFLY_BUILD_OUT] [default: dist]
8
+ * --raw Include raw markdown files [env: KITFLY_BUILD_RAW] [default: true]
9
+ * --no-raw Don't include raw markdown files
10
+ * --help Show help message
11
+ *
12
+ * Outputs to dist/ directory by default.
13
+ */
14
+
15
+ import { copyFile, cp, mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
16
+ import { basename, dirname, extname, join, resolve } from "node:path";
17
+ import { marked, Renderer } from "marked";
18
+ import { ENGINE_ASSETS_DIR } from "../src/engine.ts";
19
+ import {
20
+ buildBreadcrumbsStatic,
21
+ buildFooter,
22
+ buildNavStatic,
23
+ buildPageMeta,
24
+ buildToc,
25
+ type ContentFile,
26
+ // Navigation/template building
27
+ collectFiles,
28
+ envBool,
29
+ // Config helpers
30
+ envString,
31
+ // Formatting
32
+ escapeHtml,
33
+ // File utilities
34
+ exists,
35
+ // Provenance
36
+ generateProvenance,
37
+ // YAML/Config parsing
38
+ loadSiteConfig,
39
+ type Provenance,
40
+ // Markdown utilities
41
+ parseFrontmatter,
42
+ resolveStylesPath,
43
+ resolveTemplatePath,
44
+ // Types
45
+ type SiteConfig,
46
+ slugify,
47
+ validatePath,
48
+ } from "../src/shared.ts";
49
+ import { generateThemeCSS, getPrismUrls, loadTheme, type Theme } from "../src/theme.ts";
50
+
51
+ // Defaults
52
+ const DEFAULT_OUT = "dist";
53
+
54
+ let ROOT = process.cwd();
55
+ let OUT_DIR = DEFAULT_OUT;
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // CLI argument parsing
59
+ // ---------------------------------------------------------------------------
60
+
61
+ interface ParsedArgs {
62
+ folder?: string;
63
+ out?: string;
64
+ raw?: boolean;
65
+ }
66
+
67
+ function parseArgs(argv: string[]): ParsedArgs {
68
+ const result: ParsedArgs = {};
69
+ for (let i = 0; i < argv.length; i++) {
70
+ const arg = argv[i];
71
+ const next = argv[i + 1];
72
+
73
+ if ((arg === "--out" || arg === "-o") && next && !next.startsWith("-")) {
74
+ result.out = next;
75
+ i++;
76
+ } else if (arg === "--raw") {
77
+ result.raw = true;
78
+ } else if (arg === "--no-raw") {
79
+ result.raw = false;
80
+ } else if (!arg.startsWith("-") && !result.folder) {
81
+ result.folder = arg;
82
+ }
83
+ }
84
+ return result;
85
+ }
86
+
87
+ function getConfig(): { folder?: string; out: string; raw: boolean } {
88
+ const args = parseArgs(process.argv.slice(2));
89
+ return {
90
+ folder: args.folder,
91
+ out: args.out ?? envString("KITFLY_BUILD_OUT", DEFAULT_OUT),
92
+ raw: args.raw ?? envBool("KITFLY_BUILD_RAW", true),
93
+ };
94
+ }
95
+
96
+ async function resolveSiteAssetsDir(siteRoot: string): Promise<string | null> {
97
+ const overrideDir = join(siteRoot, "assets");
98
+ if (await exists(overrideDir)) return overrideDir;
99
+ return null;
100
+ }
101
+
102
+ function computePathPrefix(urlKey: string): string {
103
+ const clean = urlKey.replace(/^\/+/, "").replace(/\.html$/, "");
104
+ if (!clean) return "./";
105
+ const depth = Math.max(0, clean.split("/").length - 1);
106
+ return depth === 0 ? "./" : "../".repeat(depth);
107
+ }
108
+
109
+ async function copyStaticAssetsFromDir(srcDir: string, destDir: string): Promise<void> {
110
+ try {
111
+ await mkdir(destDir, { recursive: true });
112
+ const entries = await readdir(srcDir, { withFileTypes: true });
113
+ for (const entry of entries) {
114
+ const srcPath = join(srcDir, entry.name);
115
+ const destPath = join(destDir, entry.name);
116
+
117
+ if (entry.isDirectory()) {
118
+ // Skip hidden folders
119
+ if (entry.name.startsWith(".")) continue;
120
+ await copyStaticAssetsFromDir(srcPath, destPath);
121
+ continue;
122
+ }
123
+
124
+ if (!entry.isFile()) continue;
125
+ const ext = extname(entry.name).toLowerCase();
126
+ if (ext === ".md" || ext === ".yaml" || ext === ".yml") continue;
127
+
128
+ await mkdir(dirname(destPath), { recursive: true });
129
+ await copyFile(srcPath, destPath);
130
+ }
131
+ } catch {
132
+ // Skip missing/unreadable directories
133
+ }
134
+ }
135
+
136
+ // Configure marked with custom renderer for mermaid support and heading IDs
137
+ const renderer = new Renderer();
138
+ const originalCode = renderer.code.bind(renderer);
139
+ renderer.code = (code: { type: "code"; raw: string; text: string; lang?: string }) => {
140
+ if (code.lang === "mermaid") {
141
+ // Store source in data attribute for theme toggle re-rendering
142
+ const escaped = code.text.replace(/"/g, "&quot;");
143
+ return `<pre class="mermaid" data-mermaid-source="${escaped}">${code.text}</pre>`;
144
+ }
145
+ return originalCode(code);
146
+ };
147
+ renderer.heading = ({ text, depth }: { text: string; depth: number }) => {
148
+ const plain = text.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
149
+ const id = slugify(plain);
150
+ const inner = marked.parseInline(text) as string;
151
+ return `<h${depth} id="${id}">${inner}</h${depth}>\n`;
152
+ };
153
+ marked.use({ renderer });
154
+
155
+ // Render a single file
156
+ async function renderFile(
157
+ filePath: string,
158
+ urlKey: string,
159
+ template: string,
160
+ files: ContentFile[],
161
+ provenance: Provenance,
162
+ config: SiteConfig,
163
+ theme: Theme,
164
+ ): Promise<string> {
165
+ const uiVersion = provenance.version ? `v${provenance.version}` : "unversioned";
166
+ const content = await readFile(filePath, "utf-8");
167
+
168
+ let title = basename(filePath, extname(filePath));
169
+ let htmlContent: string;
170
+ let pageMeta = "";
171
+
172
+ if (filePath.endsWith(".yaml")) {
173
+ htmlContent = `<h1>${title}</h1>\n<pre><code class="language-yaml">${escapeHtml(content)}</code></pre>`;
174
+ } else if (filePath.endsWith(".json")) {
175
+ // Render JSON as code block (pretty-printed)
176
+ let prettyJson = content;
177
+ try {
178
+ prettyJson = JSON.stringify(JSON.parse(content), null, 2);
179
+ } catch {
180
+ // Use original if not valid JSON
181
+ }
182
+ htmlContent = `<h1>${title}</h1>\n<pre><code class="language-json">${escapeHtml(prettyJson)}</code></pre>`;
183
+ } else {
184
+ const { frontmatter, body } = parseFrontmatter(content);
185
+ if (frontmatter.title) {
186
+ title = frontmatter.title as string;
187
+ }
188
+ pageMeta = buildPageMeta(frontmatter);
189
+ htmlContent = marked.parse(body) as string;
190
+ }
191
+
192
+ const pathPrefix = computePathPrefix(urlKey);
193
+ const nav = buildNavStatic(files, urlKey, config, pathPrefix);
194
+ const footer = buildFooter(provenance, config);
195
+ const breadcrumbs = buildBreadcrumbsStatic(urlKey, pathPrefix, files, config);
196
+ const toc = buildToc(htmlContent);
197
+ const brandTarget = config.brand.external ? ' target="_blank" rel="noopener"' : "";
198
+ const themeCSS = generateThemeCSS(theme);
199
+ const prismUrls = getPrismUrls(theme);
200
+ const logoClass = config.brand.logoType === "wordmark" ? "logo-wordmark" : "logo-icon";
201
+
202
+ return template
203
+ .replace(/\{\{PATH_PREFIX\}\}/g, pathPrefix)
204
+ .replace(/\{\{BRAND_URL\}\}/g, config.brand.url)
205
+ .replace(/\{\{BRAND_TARGET\}\}/g, brandTarget)
206
+ .replace(/\{\{BRAND_NAME\}\}/g, config.brand.name)
207
+ .replace(/\{\{BRAND_LOGO\}\}/g, config.brand.logo || "assets/brand/logo.png")
208
+ .replace(/\{\{BRAND_FAVICON\}\}/g, config.brand.favicon || "assets/brand/favicon.png")
209
+ .replace(/\{\{BRAND_LOGO_CLASS\}\}/g, logoClass)
210
+ .replace(/\{\{SITE_TITLE\}\}/g, config.title)
211
+ .replace("{{TITLE}}", title)
212
+ .replace("{{VERSION}}", uiVersion)
213
+ .replace("{{BRANCH}}", provenance.gitBranch)
214
+ .replace("{{BREADCRUMBS}}", breadcrumbs)
215
+ .replace("{{PAGE_META}}", pageMeta)
216
+ .replace("{{NAV}}", nav)
217
+ .replace("{{CONTENT}}", htmlContent)
218
+ .replace("{{TOC}}", toc)
219
+ .replace("{{FOOTER}}", footer)
220
+ .replace("{{THEME_CSS}}", themeCSS)
221
+ .replace("{{PRISM_LIGHT_URL}}", prismUrls.light)
222
+ .replace("{{PRISM_DARK_URL}}", prismUrls.dark)
223
+ .replace("{{HOT_RELOAD_SCRIPT}}", "");
224
+ }
225
+
226
+ // Render Getting Started page when no config
227
+ function renderGettingStarted(
228
+ template: string,
229
+ provenance: Provenance,
230
+ config: SiteConfig,
231
+ theme: Theme,
232
+ ): string {
233
+ const uiVersion = provenance.version ? `v${provenance.version}` : "unversioned";
234
+ const htmlContent = `
235
+ <h1>Getting Started</h1>
236
+ <p>Welcome! To configure your kitfly site, create a <code>site.yaml</code> file in the repository root:</p>
237
+ <pre><code class="language-yaml"># yaml-language-server: $schema=./schemas/v0/site.schema.json
238
+ schemaVersion: "0.1.0"
239
+ docroot: "."
240
+ title: "My Docs"
241
+
242
+ brand:
243
+ name: "My Brand"
244
+ url: "https://example.com"
245
+ external: true
246
+
247
+ sections:
248
+ - name: "Overview"
249
+ path: "."
250
+ files: ["README.md"]
251
+ - name: "Guides"
252
+ path: "guides"
253
+ </code></pre>
254
+ <p>Or create a <code>content/</code> directory with subdirectories for auto-discovery.</p>
255
+ `;
256
+
257
+ const brandTarget = config.brand.external ? ' target="_blank" rel="noopener"' : "";
258
+ const themeCSS = generateThemeCSS(theme);
259
+ const prismUrls = getPrismUrls(theme);
260
+ const pathPrefix = "./";
261
+ const logoClass = config.brand.logoType === "wordmark" ? "logo-wordmark" : "logo-icon";
262
+
263
+ return template
264
+ .replace(/\{\{PATH_PREFIX\}\}/g, pathPrefix)
265
+ .replace(/\{\{BRAND_URL\}\}/g, config.brand.url)
266
+ .replace(/\{\{BRAND_TARGET\}\}/g, brandTarget)
267
+ .replace(/\{\{BRAND_NAME\}\}/g, config.brand.name)
268
+ .replace(/\{\{BRAND_LOGO\}\}/g, config.brand.logo || "assets/brand/logo.png")
269
+ .replace(/\{\{BRAND_FAVICON\}\}/g, config.brand.favicon || "assets/brand/favicon.png")
270
+ .replace(/\{\{BRAND_LOGO_CLASS\}\}/g, logoClass)
271
+ .replace(/\{\{SITE_TITLE\}\}/g, config.title)
272
+ .replace("{{TITLE}}", "Getting Started")
273
+ .replace("{{VERSION}}", uiVersion)
274
+ .replace("{{BRANCH}}", provenance.gitBranch)
275
+ .replace("{{BREADCRUMBS}}", "")
276
+ .replace("{{PAGE_META}}", "")
277
+ .replace("{{NAV}}", "<ul></ul>")
278
+ .replace("{{CONTENT}}", htmlContent)
279
+ .replace("{{TOC}}", "")
280
+ .replace("{{FOOTER}}", buildFooter(provenance, config))
281
+ .replace("{{THEME_CSS}}", themeCSS)
282
+ .replace("{{PRISM_LIGHT_URL}}", prismUrls.light)
283
+ .replace("{{PRISM_DARK_URL}}", prismUrls.dark)
284
+ .replace("{{HOT_RELOAD_SCRIPT}}", "");
285
+ }
286
+
287
+ // Export for CLI usage
288
+ export interface BuildOptions {
289
+ folder?: string;
290
+ out?: string;
291
+ raw?: boolean; // Include raw markdown files (default: true)
292
+ }
293
+
294
+ let INCLUDE_RAW = true;
295
+
296
+ export async function build(options: BuildOptions = {}) {
297
+ if (options.folder) {
298
+ ROOT = resolve(process.cwd(), options.folder);
299
+ }
300
+ if (options.out) {
301
+ OUT_DIR = options.out;
302
+ }
303
+ if (options.raw === false) {
304
+ INCLUDE_RAW = false;
305
+ }
306
+ await buildSite();
307
+ }
308
+
309
+ // Rename internal function
310
+ async function buildSite() {
311
+ const DIST = join(ROOT, OUT_DIR);
312
+
313
+ console.log("Building site...\n");
314
+
315
+ // Load configuration
316
+ const config = await loadSiteConfig(ROOT);
317
+ console.log(` ✓ Loaded config: "${config.title}" (${config.sections.length} sections)`);
318
+
319
+ // Load theme
320
+ const theme = await loadTheme(ROOT);
321
+ console.log(` ✓ Loaded theme: "${theme.name || "default"}"`);
322
+
323
+ // Create dist directory
324
+ await mkdir(DIST, { recursive: true });
325
+
326
+ // Generate provenance (build mode)
327
+ const provenance = await generateProvenance(ROOT, false, config.version);
328
+ await writeFile(join(DIST, "provenance.json"), JSON.stringify(provenance, null, 2));
329
+ console.log(
330
+ ` ✓ provenance.json (${provenance.version ? `v${provenance.version}` : "unversioned"}, ${provenance.gitCommit})`,
331
+ );
332
+
333
+ // Read template
334
+ const template = await readFile(await resolveTemplatePath(ROOT), "utf-8");
335
+
336
+ // Copy CSS
337
+ const css = await readFile(await resolveStylesPath(ROOT), "utf-8");
338
+ await writeFile(join(DIST, "styles.css"), css);
339
+ console.log(" ✓ styles.css");
340
+
341
+ // Copy engine assets, then overlay site assets if present
342
+ try {
343
+ await stat(ENGINE_ASSETS_DIR);
344
+ await cp(ENGINE_ASSETS_DIR, join(DIST, "assets"), { recursive: true });
345
+ console.log(" ✓ assets/ (engine)");
346
+ } catch {
347
+ // No engine assets, skip
348
+ }
349
+
350
+ const siteAssetsDir = await resolveSiteAssetsDir(ROOT);
351
+ if (siteAssetsDir) {
352
+ try {
353
+ await cp(siteAssetsDir, join(DIST, "assets"), { recursive: true });
354
+ console.log(" ✓ assets/ (site override)");
355
+ } catch {
356
+ // Skip
357
+ }
358
+ }
359
+
360
+ // Copy non-markdown assets referenced by docs (images, PDFs, etc.)
361
+ for (const section of config.sections) {
362
+ const sectionSrc = validatePath(ROOT, config.docroot, section.path);
363
+ if (!sectionSrc) continue;
364
+ const sectionDest = section.path === "." ? DIST : join(DIST, section.path);
365
+ await copyStaticAssetsFromDir(sectionSrc, sectionDest);
366
+ }
367
+
368
+ // Collect and render all files
369
+ const files = await collectFiles(ROOT, config);
370
+
371
+ if (files.length === 0) {
372
+ // No content - render Getting Started page
373
+ const html = renderGettingStarted(template, provenance, config, theme);
374
+ await writeFile(join(DIST, "index.html"), html);
375
+ console.log(" ✓ index.html (Getting Started)");
376
+ console.log(`\n\x1b[33mNo content found. Create site.yaml or content/ directory.\x1b[0m`);
377
+ return;
378
+ }
379
+
380
+ for (const file of files) {
381
+ const html = await renderFile(
382
+ file.path,
383
+ file.urlPath,
384
+ template,
385
+ files,
386
+ provenance,
387
+ config,
388
+ theme,
389
+ );
390
+
391
+ // Create output path
392
+ const outPath = join(DIST, `${file.urlPath}.html`);
393
+ await mkdir(dirname(outPath), { recursive: true });
394
+ await writeFile(outPath, html);
395
+
396
+ console.log(` ✓ ${file.urlPath}.html`);
397
+ }
398
+
399
+ // Create index.html
400
+ if (config.home) {
401
+ // Render dedicated home page
402
+ const homePath = validatePath(ROOT, config.docroot, config.home);
403
+ if (homePath) {
404
+ try {
405
+ await stat(homePath);
406
+ const homeHtml = await renderFile(homePath, "", template, files, provenance, config, theme);
407
+ await writeFile(join(DIST, "index.html"), homeHtml);
408
+ console.log(` ✓ index.html (from ${config.home})`);
409
+ } catch {
410
+ console.warn(` ⚠ Home page ${config.home} not found, using first file`);
411
+ const firstFile = files[0];
412
+ const indexHtml = await renderFile(
413
+ firstFile.path,
414
+ "",
415
+ template,
416
+ files,
417
+ provenance,
418
+ config,
419
+ theme,
420
+ );
421
+ await writeFile(join(DIST, "index.html"), indexHtml);
422
+ console.log(" ✓ index.html");
423
+ }
424
+ }
425
+ } else {
426
+ // Fallback: copy first file as index
427
+ const firstFile = files[0];
428
+ const indexHtml = await renderFile(
429
+ firstFile.path,
430
+ "",
431
+ template,
432
+ files,
433
+ provenance,
434
+ config,
435
+ theme,
436
+ );
437
+ await writeFile(join(DIST, "index.html"), indexHtml);
438
+ console.log(" ✓ index.html");
439
+ }
440
+
441
+ // Create section redirect files (for breadcrumb navigation)
442
+ const sectionFirstFile: Map<string, string> = new Map();
443
+ for (const file of files) {
444
+ if (!sectionFirstFile.has(file.section)) {
445
+ const parts = file.urlPath.split("/");
446
+ if (parts.length > 1) {
447
+ const sectionPath = parts.slice(0, -1).join("/");
448
+ sectionFirstFile.set(sectionPath, file.urlPath);
449
+ }
450
+ }
451
+ }
452
+
453
+ for (const [sectionPath, firstFilePath] of sectionFirstFile) {
454
+ const targetName = firstFilePath.split("/").pop() || firstFilePath;
455
+ const targetHref = `./${targetName}.html`;
456
+ const redirectHtml = `<!DOCTYPE html>
457
+ <html>
458
+ <head>
459
+ <meta charset="UTF-8">
460
+ <meta http-equiv="refresh" content="0; url=${targetHref}">
461
+ <title>Redirecting...</title>
462
+ </head>
463
+ <body>
464
+ <p>Redirecting to <a href="${targetHref}">${targetName}</a>...</p>
465
+ </body>
466
+ </html>`;
467
+ const redirectPath = join(DIST, sectionPath, "index.html");
468
+ await mkdir(dirname(redirectPath), { recursive: true });
469
+ await writeFile(redirectPath, redirectHtml);
470
+ console.log(` ✓ ${sectionPath}/index.html (redirect)`);
471
+ }
472
+
473
+ // Generate AI accessibility files
474
+ await generateAIAccessibility(DIST, files, config, provenance);
475
+
476
+ console.log(`\n\x1b[32mBuild complete! Output in ${OUT_DIR}/\x1b[0m`);
477
+ console.log(`\nTo view locally: open ${OUT_DIR}/index.html`);
478
+ }
479
+
480
+ // Generate AI accessibility files: content-index.json, llms.txt, and optionally _raw/
481
+ async function generateAIAccessibility(
482
+ dist: string,
483
+ files: ContentFile[],
484
+ config: SiteConfig,
485
+ provenance: Provenance,
486
+ ) {
487
+ // 1. Generate content-index.json
488
+ const contentIndex = {
489
+ version: provenance.version,
490
+ generated: new Date().toISOString(),
491
+ title: config.title,
492
+ baseUrl: "/",
493
+ rawMarkdownPath: INCLUDE_RAW ? "/_raw" : null,
494
+ pages: await Promise.all(
495
+ files.map(async (file) => {
496
+ let title = basename(file.path).replace(/\.(md|yaml|json)$/, "");
497
+ let description: string | undefined;
498
+
499
+ // Try to extract title and description from frontmatter
500
+ if (file.path.endsWith(".md")) {
501
+ try {
502
+ const content = await readFile(file.path, "utf-8");
503
+ const { frontmatter } = parseFrontmatter(content);
504
+ if (frontmatter.title) title = frontmatter.title as string;
505
+ if (frontmatter.description) description = frontmatter.description as string;
506
+ } catch {
507
+ // Use defaults
508
+ }
509
+ }
510
+
511
+ return {
512
+ path: `/${file.urlPath}`,
513
+ htmlPath: `/${file.urlPath}.html`,
514
+ rawPath: INCLUDE_RAW ? `/_raw/${file.urlPath}.md` : undefined,
515
+ title,
516
+ section: file.section,
517
+ source: file.path.replace(`${ROOT}/`, ""),
518
+ description,
519
+ };
520
+ }),
521
+ ),
522
+ };
523
+
524
+ await writeFile(join(dist, "content-index.json"), JSON.stringify(contentIndex, null, 2));
525
+ console.log(" ✓ content-index.json (AI accessibility)");
526
+
527
+ // 2. Generate llms.txt
528
+ const llmsTxt = `# llms.txt - AI agent guidance for ${config.title}
529
+ # Learn more: https://llmstxt.org/
530
+
531
+ # Site metadata
532
+ name: ${config.title}
533
+ version: ${provenance.version}
534
+ generated: ${new Date().toISOString()}
535
+
536
+ # Content discovery
537
+ content-index: /content-index.json
538
+ ${INCLUDE_RAW ? "raw-markdown: /_raw/{path}.md" : "# raw-markdown: disabled"}
539
+
540
+ # Preferred format for content consumption
541
+ preferred-format: markdown
542
+
543
+ # Site structure
544
+ sections: ${config.sections.map((s) => s.name).join(", ")}
545
+ total-pages: ${files.length}
546
+ `;
547
+
548
+ await writeFile(join(dist, "llms.txt"), llmsTxt);
549
+ console.log(" ✓ llms.txt (AI accessibility)");
550
+
551
+ // 3. Copy raw markdown files to _raw/ if enabled
552
+ if (INCLUDE_RAW) {
553
+ const rawDir = join(dist, "_raw");
554
+ await mkdir(rawDir, { recursive: true });
555
+
556
+ for (const file of files) {
557
+ if (file.path.endsWith(".md")) {
558
+ try {
559
+ const content = await readFile(file.path, "utf-8");
560
+ const rawPath = join(rawDir, `${file.urlPath}.md`);
561
+ await mkdir(dirname(rawPath), { recursive: true });
562
+ await writeFile(rawPath, content);
563
+ } catch {
564
+ // Skip if can't read
565
+ }
566
+ }
567
+ }
568
+ console.log(" ✓ _raw/ (raw markdown for AI agents)");
569
+ }
570
+ }
571
+
572
+ // Run directly if executed as script
573
+ if (import.meta.main) {
574
+ // Check for help flag
575
+ if (process.argv.includes("--help")) {
576
+ console.log(`
577
+ Usage: bun run build [folder] [options]
578
+
579
+ Options:
580
+ -o, --out <dir> Output directory [env: KITFLY_BUILD_OUT] [default: ${DEFAULT_OUT}]
581
+ --raw Include raw markdown files [env: KITFLY_BUILD_RAW] [default: true]
582
+ --no-raw Don't include raw markdown files
583
+ --help Show this help message
584
+
585
+ Examples:
586
+ bun run build
587
+ bun run build ./docs
588
+ bun run build --out ./public
589
+ bun run build ./docs --out ./public --no-raw
590
+ KITFLY_BUILD_OUT=public bun run build
591
+ `);
592
+ process.exit(0);
593
+ }
594
+
595
+ const cfg = getConfig();
596
+ build({
597
+ folder: cfg.folder,
598
+ out: cfg.out,
599
+ raw: cfg.raw,
600
+ }).catch(console.error);
601
+ }