kitfly 0.2.1 → 0.2.3

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 (108) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +25 -10
  3. package/VERSION +1 -1
  4. package/dist/_raw/content/guide/branding.md +146 -0
  5. package/dist/_raw/content/guide/data-driven-content.md +204 -0
  6. package/dist/_raw/content/reference/configuration.md +145 -7
  7. package/dist/_raw/content/reference/environment-variables.md +26 -1
  8. package/dist/_raw/content/reference/glossary.md +25 -1
  9. package/dist/_raw/content/reference/key-concepts.md +30 -2
  10. package/dist/_raw/content/reference/plugins.md +14 -0
  11. package/dist/_raw/docs/decisions/ADR-0006-data-driven-content.md +350 -0
  12. package/dist/content/deployment/preflight.html +10 -6
  13. package/dist/content/deployment/recipes/aws-s3.html +10 -6
  14. package/dist/content/deployment/recipes/cloudflare-pages.html +10 -6
  15. package/dist/content/deployment/recipes/cloudflare-r2.html +10 -6
  16. package/dist/content/deployment/recipes/fly-io.html +10 -6
  17. package/dist/content/deployment/recipes/github-pages.html +10 -6
  18. package/dist/content/deployment/recipes/netlify.html +10 -6
  19. package/dist/content/deployment/recipes/vercel.html +10 -6
  20. package/dist/content/deployment/secrets-and-env-vars.html +10 -6
  21. package/dist/content/deployment.html +10 -6
  22. package/dist/content/guide/approaches.html +10 -6
  23. package/dist/content/guide/branding.html +510 -0
  24. package/dist/content/guide/data-driven-content.html +543 -0
  25. package/dist/content/guide/features.html +10 -6
  26. package/dist/content/guide/getting-started.html +10 -6
  27. package/dist/content/guide/kitfly-overview.html +10 -6
  28. package/dist/content/reference/configuration.html +135 -9
  29. package/dist/content/reference/design-catalog.html +10 -6
  30. package/dist/content/reference/environment-variables.html +50 -8
  31. package/dist/content/reference/glossary.html +24 -8
  32. package/dist/content/reference/key-concepts.html +33 -9
  33. package/dist/content/reference/plugins.html +22 -7
  34. package/dist/content/reference/slides-authoring-guidelines.html +10 -6
  35. package/dist/content/reference/structure.html +10 -6
  36. package/dist/content/reference.html +10 -6
  37. package/dist/content/templates/crucible.html +10 -6
  38. package/dist/content/templates/handbook.html +10 -6
  39. package/dist/content/templates/minimal.html +10 -6
  40. package/dist/content/templates/overview.html +10 -6
  41. package/dist/content/templates/pipeline.html +10 -6
  42. package/dist/content/templates/productbook.html +10 -6
  43. package/dist/content/templates/runbook.html +10 -6
  44. package/dist/content/templates/servicebook.html +10 -6
  45. package/dist/content-index.json +29 -2
  46. package/dist/docs/decisions/ADR-0001-minimalist-site-code.html +10 -6
  47. package/dist/docs/decisions/ADR-0002-ai-accessibility.html +10 -6
  48. package/dist/docs/decisions/ADR-0003-single-file-bundle.html +10 -6
  49. package/dist/docs/decisions/ADR-0004-bun-runtime.html +10 -6
  50. package/dist/docs/decisions/ADR-0005-plugin-contract-and-distribution.html +10 -6
  51. package/dist/docs/decisions/ADR-0006-data-driven-content.html +752 -0
  52. package/dist/docs/decisions/DDR-0001-viewport-locked-layout.html +10 -6
  53. package/dist/docs/decisions/DDR-0002-theme-system.html +10 -6
  54. package/dist/docs/decisions/DDR-0003-bounded-logo-slot.html +10 -6
  55. package/dist/docs/decisions/DDR-0004-slides-rendering-model.html +10 -6
  56. package/dist/docs/decisions/DDR-0005-deterministic-layout-boundary.html +10 -6
  57. package/dist/docs/userguide/cli/build.html +10 -6
  58. package/dist/docs/userguide/cli/bundle.html +10 -6
  59. package/dist/docs/userguide/cli/dev.html +10 -6
  60. package/dist/docs/userguide/cli/init.html +10 -6
  61. package/dist/docs/userguide/cli/servers.html +10 -6
  62. package/dist/docs/userguide/cli/stop.html +10 -6
  63. package/dist/docs/userguide/cli/update.html +10 -6
  64. package/dist/docs/userguide/cli/version.html +10 -6
  65. package/dist/docs/userguide/cli.html +10 -6
  66. package/dist/docs/userguide/sharing.html +10 -6
  67. package/dist/index.html +10 -6
  68. package/dist/llms.txt +3 -3
  69. package/dist/provenance.json +4 -4
  70. package/dist/schemas/plugin-registry.schema.html +10 -6
  71. package/dist/schemas/plugin-schemas-notes.html +10 -6
  72. package/dist/schemas/plugin.schema.html +10 -6
  73. package/dist/schemas/plugins.schema.html +10 -6
  74. package/dist/schemas/v0/common.schema.html +14 -10
  75. package/dist/schemas/v0/plugin-registry.schema.html +13 -9
  76. package/dist/schemas/v0/plugin.schema.html +13 -9
  77. package/dist/schemas/v0/plugins.schema.html +13 -9
  78. package/dist/schemas/v0/site.schema.html +67 -7
  79. package/dist/schemas/v0/theme.schema.html +21 -17
  80. package/dist/schemas.html +10 -6
  81. package/dist/styles.css +39 -4
  82. package/package.json +1 -1
  83. package/plugins-dist/latex-runtime.js +140 -0
  84. package/plugins-dist/latex.js +178 -0
  85. package/plugins-dist/slides-charts-lite-runtime.js +179 -0
  86. package/plugins-dist/slides-charts-lite.js +198 -0
  87. package/registry/plugins.yaml +25 -0
  88. package/schemas/v0/site.schema.json +56 -0
  89. package/scripts/build.ts +191 -69
  90. package/scripts/bundle.ts +118 -10
  91. package/scripts/dev.ts +245 -166
  92. package/src/__tests__/brief.test.ts +151 -0
  93. package/src/__tests__/build.test.ts +169 -1
  94. package/src/__tests__/bundle.test.ts +134 -0
  95. package/src/__tests__/init.test.ts +51 -2
  96. package/src/__tests__/latex-runtime.bun.test.ts +35 -0
  97. package/src/__tests__/shared.test.ts +598 -1
  98. package/src/__tests__/slides-charts-lite-runtime.bun.test.ts +45 -0
  99. package/src/cli.ts +11 -4
  100. package/src/commands/init.ts +1 -1
  101. package/src/shared.ts +725 -18
  102. package/src/site/styles.css +39 -4
  103. package/src/site/template.html +5 -2
  104. package/src/templates/brief.ts +486 -0
  105. package/src/templates/deck.ts +59 -0
  106. package/src/templates/driver.ts +46 -13
  107. package/src/templates/handbook.ts +32 -0
  108. package/src/templates/runbook.ts +32 -0
package/scripts/dev.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  * Options:
7
7
  * -p, --port <number> Port to serve on [env: KITFLY_DEV_PORT] [default: 3333]
8
8
  * -H, --host <string> Host to bind to [env: KITFLY_DEV_HOST] [default: localhost]
9
+ * --profile <name> Active content profile [env: KITFLY_PROFILE]
9
10
  * -o, --open Open browser on start [env: KITFLY_DEV_OPEN] [default: true]
10
11
  * --no-open Don't open browser
11
12
  * --help Show help message
@@ -28,10 +29,13 @@ import {
28
29
  import {
29
30
  buildBreadcrumbsSimple,
30
31
  buildFooter,
32
+ buildLogoImgHtml,
31
33
  buildNavSimple,
32
34
  buildPageMeta,
33
- buildSlideNav,
35
+ buildSlideNavHierarchical,
34
36
  buildToc,
37
+ // Types
38
+ type ContentFile,
35
39
  // Network utilities
36
40
  checkPortOrExit,
37
41
  // Navigation/template building
@@ -43,22 +47,26 @@ import {
43
47
  envString,
44
48
  // Formatting
45
49
  escapeHtml,
50
+ filterByProfile,
46
51
  filterUnknownSlidesVisualsTypeDiagnostics,
47
52
  // Provenance
48
53
  generateProvenance,
54
+ loadDataBindings,
49
55
  // YAML/Config parsing
50
56
  loadSiteConfig,
57
+ mergeFrontmatterWithBody,
51
58
  type Provenance,
59
+ pagePathForData,
52
60
  // Markdown utilities
53
61
  parseFrontmatter,
54
62
  parseYaml,
63
+ resolveBindings,
55
64
  resolveStylesPath,
56
65
  resolveTemplatePath,
57
66
  rewriteRelativeAssetUrls,
58
- // Types
67
+ runPrebuildHooks,
59
68
  type SiteConfig,
60
69
  slugify,
61
- toUrlPath,
62
70
  validatePath,
63
71
  validateSlidesVisualsFences,
64
72
  } from "../src/shared.ts";
@@ -73,6 +81,7 @@ let HOST = DEFAULT_HOST;
73
81
  let ROOT = process.cwd();
74
82
  let OPEN_BROWSER = true;
75
83
  let LOG_FORMAT = ""; // "structured" when invoked by CLI daemon
84
+ let ACTIVE_PROFILE: string | undefined;
76
85
 
77
86
  // Structured logger for daemon mode — set during main() init.
78
87
  // When null, all output goes through console.log (standalone mode).
@@ -82,6 +91,32 @@ let daemonLog: {
82
91
  error: (msg: string) => void;
83
92
  } | null = null;
84
93
 
94
+ async function applyDataBindingsToMarkdown(
95
+ rawMarkdown: string,
96
+ filePath: string,
97
+ config: SiteConfig,
98
+ ): Promise<{ frontmatter: Record<string, unknown>; body: string }> {
99
+ const parsed = parseFrontmatter(rawMarkdown);
100
+ const dataRef = typeof parsed.frontmatter.data === "string" ? parsed.frontmatter.data.trim() : "";
101
+ if (!dataRef) return parsed;
102
+
103
+ const pagePath = pagePathForData(ROOT, config.docroot, filePath);
104
+ const bindings = await loadDataBindings(dataRef, pagePath, ROOT, config.docroot, config.dataroot);
105
+ return {
106
+ frontmatter: parsed.frontmatter,
107
+ body: resolveBindings(parsed.body, bindings, pagePath),
108
+ };
109
+ }
110
+
111
+ async function applyDataBindingsForSlides(
112
+ rawMarkdown: string,
113
+ filePath: string,
114
+ config: SiteConfig,
115
+ ): Promise<string> {
116
+ const resolved = await applyDataBindingsToMarkdown(rawMarkdown, filePath, config);
117
+ return mergeFrontmatterWithBody(rawMarkdown, resolved.body);
118
+ }
119
+
85
120
  function isPluginLoaderError(error: unknown): error is Error {
86
121
  return (
87
122
  error instanceof PluginConfigError ||
@@ -158,6 +193,7 @@ interface ParsedArgs {
158
193
  open?: boolean;
159
194
  folder?: string;
160
195
  logFormat?: string;
196
+ profile?: string;
161
197
  }
162
198
 
163
199
  function parseArgs(argv: string[]): ParsedArgs {
@@ -175,6 +211,9 @@ function parseArgs(argv: string[]): ParsedArgs {
175
211
  } else if (arg === "--log-format") {
176
212
  result.logFormat = next;
177
213
  i++;
214
+ } else if (arg === "--profile" && next && !next.startsWith("-")) {
215
+ result.profile = next;
216
+ i++;
178
217
  } else if (arg === "--open" || arg === "-o") {
179
218
  result.open = true;
180
219
  } else if (arg === "--no-open") {
@@ -192,6 +231,7 @@ function getConfig(): {
192
231
  open: boolean;
193
232
  folder?: string;
194
233
  logFormat?: string;
234
+ profile?: string;
195
235
  } {
196
236
  const args = parseArgs(process.argv.slice(2));
197
237
  return {
@@ -200,9 +240,15 @@ function getConfig(): {
200
240
  open: args.open ?? envBool("KITFLY_DEV_OPEN", true),
201
241
  folder: args.folder,
202
242
  logFormat: args.logFormat,
243
+ profile: args.profile ?? process.env.KITFLY_PROFILE,
203
244
  };
204
245
  }
205
246
 
247
+ async function getFilteredFiles(config: SiteConfig): Promise<ContentFile[]> {
248
+ const files = await collectFiles(ROOT, config);
249
+ return filterByProfile(files, ACTIVE_PROFILE, config.profiles);
250
+ }
251
+
206
252
  function getContentType(filePath: string): string {
207
253
  const ext = extname(filePath).toLowerCase();
208
254
  switch (ext) {
@@ -349,7 +395,7 @@ async function renderPage(
349
395
  }
350
396
  htmlContent = `<h1>${title}</h1>\n<pre><code class="language-json">${escapeHtml(prettyJson)}</code></pre>`;
351
397
  } else {
352
- const { frontmatter, body } = parseFrontmatter(content);
398
+ const { frontmatter, body } = await applyDataBindingsToMarkdown(content, filePath, config);
353
399
  if (frontmatter.title) {
354
400
  title = frontmatter.title as string;
355
401
  }
@@ -357,16 +403,16 @@ async function renderPage(
357
403
  htmlContent = marked.parse(body) as string;
358
404
  }
359
405
 
360
- const files = await collectFiles(ROOT, config);
406
+ const files = await getFilteredFiles(config);
361
407
  const currentUrlPath = urlPath.slice(1).replace(/\.html$/, "");
408
+ const pathPrefix = "/";
362
409
  const nav = buildNavSimple(files, config, currentUrlPath);
363
- const footer = buildFooter(provenance, config);
410
+ const footer = buildFooter(provenance, config, pathPrefix);
364
411
  const breadcrumbs = buildBreadcrumbsSimple(urlPath, files, config);
365
412
  const toc = buildToc(htmlContent);
366
413
  const brandTarget = config.brand.external ? ' target="_blank" rel="noopener"' : "";
367
414
  const themeCSS = generateThemeCSS(theme);
368
415
  const prismUrls = getPrismUrls(theme);
369
- const pathPrefix = "/";
370
416
  const plugins = await getPluginInjectionsCached(config.mode === "slides" ? "slides" : "docs");
371
417
 
372
418
  const hotReloadScript = `
@@ -378,33 +424,51 @@ async function renderPage(
378
424
 
379
425
  const logoClass = config.brand.logoType === "wordmark" ? "logo-wordmark" : "logo-icon";
380
426
  const brandInitial = escapeHtml(config.brand.name.trim().charAt(0).toUpperCase() || "K");
427
+ const mobileLogoHtml = buildLogoImgHtml({
428
+ logo: config.brand.logo || "assets/brand/logo.png",
429
+ logoDark: config.brand.logoDark,
430
+ alt: config.brand.name,
431
+ className: `logo-img ${logoClass}`,
432
+ pathPrefix,
433
+ onerrorFallback: true,
434
+ });
435
+ const sidebarLogoHtml = buildLogoImgHtml({
436
+ logo: config.brand.logo || "assets/brand/logo.png",
437
+ logoDark: config.brand.logoDark,
438
+ alt: config.brand.name,
439
+ className: "logo-img",
440
+ pathPrefix,
441
+ onerrorFallback: true,
442
+ });
381
443
 
382
444
  return template
383
445
  .replace("{{BODY_CLASS}}", "mode-docs")
384
- .replace(/\{\{PATH_PREFIX\}\}/g, pathPrefix)
385
- .replace(/\{\{BRAND_URL\}\}/g, config.brand.url)
386
- .replace(/\{\{BRAND_TARGET\}\}/g, brandTarget)
387
- .replace(/\{\{BRAND_NAME\}\}/g, config.brand.name)
388
- .replace(/\{\{BRAND_INITIAL\}\}/g, brandInitial)
389
- .replace(/\{\{BRAND_LOGO\}\}/g, config.brand.logo || "assets/brand/logo.png")
390
- .replace(/\{\{BRAND_FAVICON\}\}/g, config.brand.favicon || "assets/brand/favicon.png")
391
- .replace(/\{\{BRAND_LOGO_CLASS\}\}/g, logoClass)
392
- .replace(/\{\{SITE_TITLE\}\}/g, config.title)
393
- .replace("{{TITLE}}", title)
394
- .replace("{{VERSION}}", uiVersion)
395
- .replace("{{BRANCH}}", provenance.gitBranch)
396
- .replace("{{BREADCRUMBS}}", breadcrumbs)
397
- .replace("{{PAGE_META}}", pageMeta)
398
- .replace("{{NAV}}", nav)
399
- .replace("{{CONTENT}}", htmlContent)
400
- .replace("{{TOC}}", toc)
401
- .replace("{{FOOTER}}", footer)
402
- .replace("{{THEME_CSS}}", themeCSS)
403
- .replace("{{PLUGIN_HEAD}}", plugins.head)
404
- .replace("{{PLUGIN_BODY_END}}", plugins.bodyEnd)
405
- .replace("{{PRISM_LIGHT_URL}}", prismUrls.light)
406
- .replace("{{PRISM_DARK_URL}}", prismUrls.dark)
407
- .replace("{{HOT_RELOAD_SCRIPT}}", hotReloadScript);
446
+ .replace(/\{\{PATH_PREFIX\}\}/g, () => pathPrefix)
447
+ .replace(/\{\{BRAND_URL\}\}/g, () => config.brand.url)
448
+ .replace(/\{\{BRAND_TARGET\}\}/g, () => brandTarget)
449
+ .replace(/\{\{BRAND_NAME\}\}/g, () => config.brand.name)
450
+ .replace(/\{\{BRAND_INITIAL\}\}/g, () => brandInitial)
451
+ .replace("{{MOBILE_BRAND_LOGO_IMG}}", () => mobileLogoHtml)
452
+ .replace("{{SIDEBAR_BRAND_LOGO_IMG}}", () => sidebarLogoHtml)
453
+ .replace(/\{\{BRAND_LOGO\}\}/g, () => config.brand.logo || "assets/brand/logo.png")
454
+ .replace(/\{\{BRAND_FAVICON\}\}/g, () => config.brand.favicon || "assets/brand/favicon.png")
455
+ .replace(/\{\{BRAND_LOGO_CLASS\}\}/g, () => logoClass)
456
+ .replace(/\{\{SITE_TITLE\}\}/g, () => config.title)
457
+ .replace("{{TITLE}}", () => title)
458
+ .replace("{{VERSION}}", () => uiVersion)
459
+ .replace("{{BRANCH}}", () => provenance.gitBranch)
460
+ .replace("{{BREADCRUMBS}}", () => breadcrumbs)
461
+ .replace("{{PAGE_META}}", () => pageMeta)
462
+ .replace("{{NAV}}", () => nav)
463
+ .replace("{{CONTENT}}", () => htmlContent)
464
+ .replace("{{TOC}}", () => toc)
465
+ .replace("{{FOOTER}}", () => footer)
466
+ .replace("{{THEME_CSS}}", () => themeCSS)
467
+ .replace("{{PLUGIN_HEAD}}", () => plugins.head)
468
+ .replace("{{PLUGIN_BODY_END}}", () => plugins.bodyEnd)
469
+ .replace("{{PRISM_LIGHT_URL}}", () => prismUrls.light)
470
+ .replace("{{PRISM_DARK_URL}}", () => prismUrls.dark)
471
+ .replace("{{HOT_RELOAD_SCRIPT}}", () => hotReloadScript);
408
472
  }
409
473
 
410
474
  async function renderSlidesPage(
@@ -414,8 +478,10 @@ async function renderSlidesPage(
414
478
  ): Promise<string> {
415
479
  const uiVersion = provenance.version ? `v${provenance.version}` : "unversioned";
416
480
  const template = await readFile(await resolveTemplatePath(ROOT), "utf-8");
417
- const files = await collectFiles(ROOT, config);
418
- const slides = await collectSlides(files);
481
+ const files = await getFilteredFiles(config);
482
+ const slides = await collectSlides(files, {
483
+ markdownTransform: (raw, file) => applyDataBindingsForSlides(raw, file.path, config),
484
+ });
419
485
 
420
486
  if (slides.length === 0) {
421
487
  return renderGettingStarted(provenance, config, theme);
@@ -476,8 +542,8 @@ async function renderSlidesPage(
476
542
  </div>
477
543
  </div>`;
478
544
 
479
- const nav = buildSlideNav(slides, config, "slide-1");
480
- const footer = buildFooter(provenance, config);
545
+ const nav = buildSlideNavHierarchical(slides, config, "slide-1");
546
+ const footer = buildFooter(provenance, config, pathPrefix);
481
547
  const brandTarget = config.brand.external ? ' target="_blank" rel="noopener"' : "";
482
548
  const themeCSS = generateThemeCSS(theme);
483
549
  const prismUrls = getPrismUrls(theme);
@@ -490,33 +556,51 @@ async function renderSlidesPage(
490
556
  const plugins = await getPluginInjectionsCached("slides");
491
557
  const logoClass = config.brand.logoType === "wordmark" ? "logo-wordmark" : "logo-icon";
492
558
  const brandInitial = escapeHtml(config.brand.name.trim().charAt(0).toUpperCase() || "K");
559
+ const mobileLogoHtml = buildLogoImgHtml({
560
+ logo: config.brand.logo || "assets/brand/logo.png",
561
+ logoDark: config.brand.logoDark,
562
+ alt: config.brand.name,
563
+ className: `logo-img ${logoClass}`,
564
+ pathPrefix,
565
+ onerrorFallback: true,
566
+ });
567
+ const sidebarLogoHtml = buildLogoImgHtml({
568
+ logo: config.brand.logo || "assets/brand/logo.png",
569
+ logoDark: config.brand.logoDark,
570
+ alt: config.brand.name,
571
+ className: "logo-img",
572
+ pathPrefix,
573
+ onerrorFallback: true,
574
+ });
493
575
 
494
576
  return template
495
577
  .replace("{{BODY_CLASS}}", "mode-slides")
496
- .replace(/\{\{PATH_PREFIX\}\}/g, pathPrefix)
497
- .replace(/\{\{BRAND_URL\}\}/g, config.brand.url)
498
- .replace(/\{\{BRAND_TARGET\}\}/g, brandTarget)
499
- .replace(/\{\{BRAND_NAME\}\}/g, config.brand.name)
500
- .replace(/\{\{BRAND_INITIAL\}\}/g, brandInitial)
501
- .replace(/\{\{BRAND_LOGO\}\}/g, config.brand.logo || "assets/brand/logo.png")
502
- .replace(/\{\{BRAND_FAVICON\}\}/g, config.brand.favicon || "assets/brand/favicon.png")
503
- .replace(/\{\{BRAND_LOGO_CLASS\}\}/g, logoClass)
504
- .replace(/\{\{SITE_TITLE\}\}/g, config.title)
505
- .replace("{{TITLE}}", config.title)
506
- .replace("{{VERSION}}", uiVersion)
507
- .replace("{{BRANCH}}", provenance.gitBranch)
578
+ .replace(/\{\{PATH_PREFIX\}\}/g, () => pathPrefix)
579
+ .replace(/\{\{BRAND_URL\}\}/g, () => config.brand.url)
580
+ .replace(/\{\{BRAND_TARGET\}\}/g, () => brandTarget)
581
+ .replace(/\{\{BRAND_NAME\}\}/g, () => config.brand.name)
582
+ .replace(/\{\{BRAND_INITIAL\}\}/g, () => brandInitial)
583
+ .replace("{{MOBILE_BRAND_LOGO_IMG}}", () => mobileLogoHtml)
584
+ .replace("{{SIDEBAR_BRAND_LOGO_IMG}}", () => sidebarLogoHtml)
585
+ .replace(/\{\{BRAND_LOGO\}\}/g, () => config.brand.logo || "assets/brand/logo.png")
586
+ .replace(/\{\{BRAND_FAVICON\}\}/g, () => config.brand.favicon || "assets/brand/favicon.png")
587
+ .replace(/\{\{BRAND_LOGO_CLASS\}\}/g, () => logoClass)
588
+ .replace(/\{\{SITE_TITLE\}\}/g, () => config.title)
589
+ .replace("{{TITLE}}", () => config.title)
590
+ .replace("{{VERSION}}", () => uiVersion)
591
+ .replace("{{BRANCH}}", () => provenance.gitBranch)
508
592
  .replace("{{BREADCRUMBS}}", "")
509
593
  .replace("{{PAGE_META}}", "")
510
- .replace("{{NAV}}", nav)
511
- .replace("{{CONTENT}}", htmlContent)
594
+ .replace("{{NAV}}", () => nav)
595
+ .replace("{{CONTENT}}", () => htmlContent)
512
596
  .replace("{{TOC}}", "")
513
- .replace("{{FOOTER}}", footer)
514
- .replace("{{THEME_CSS}}", themeCSS)
515
- .replace("{{PLUGIN_HEAD}}", plugins.head)
516
- .replace("{{PLUGIN_BODY_END}}", plugins.bodyEnd)
517
- .replace("{{PRISM_LIGHT_URL}}", prismUrls.light)
518
- .replace("{{PRISM_DARK_URL}}", prismUrls.dark)
519
- .replace("{{HOT_RELOAD_SCRIPT}}", hotReloadScript);
597
+ .replace("{{FOOTER}}", () => footer)
598
+ .replace("{{THEME_CSS}}", () => themeCSS)
599
+ .replace("{{PLUGIN_HEAD}}", () => plugins.head)
600
+ .replace("{{PLUGIN_BODY_END}}", () => plugins.bodyEnd)
601
+ .replace("{{PRISM_LIGHT_URL}}", () => prismUrls.light)
602
+ .replace("{{PRISM_DARK_URL}}", () => prismUrls.dark)
603
+ .replace("{{HOT_RELOAD_SCRIPT}}", () => hotReloadScript);
520
604
  }
521
605
 
522
606
  // Render Getting Started page when no config
@@ -565,33 +649,51 @@ sections:
565
649
 
566
650
  const logoClass = config.brand.logoType === "wordmark" ? "logo-wordmark" : "logo-icon";
567
651
  const brandInitial = escapeHtml(config.brand.name.trim().charAt(0).toUpperCase() || "K");
652
+ const mobileLogoHtml = buildLogoImgHtml({
653
+ logo: config.brand.logo || "assets/brand/logo.png",
654
+ logoDark: config.brand.logoDark,
655
+ alt: config.brand.name,
656
+ className: `logo-img ${logoClass}`,
657
+ pathPrefix,
658
+ onerrorFallback: true,
659
+ });
660
+ const sidebarLogoHtml = buildLogoImgHtml({
661
+ logo: config.brand.logo || "assets/brand/logo.png",
662
+ logoDark: config.brand.logoDark,
663
+ alt: config.brand.name,
664
+ className: "logo-img",
665
+ pathPrefix,
666
+ onerrorFallback: true,
667
+ });
568
668
 
569
669
  return template
570
670
  .replace("{{BODY_CLASS}}", "mode-docs")
571
- .replace(/\{\{PATH_PREFIX\}\}/g, pathPrefix)
572
- .replace(/\{\{BRAND_URL\}\}/g, config.brand.url)
573
- .replace(/\{\{BRAND_TARGET\}\}/g, brandTarget)
574
- .replace(/\{\{BRAND_NAME\}\}/g, config.brand.name)
575
- .replace(/\{\{BRAND_INITIAL\}\}/g, brandInitial)
576
- .replace(/\{\{BRAND_LOGO\}\}/g, config.brand.logo || "assets/brand/logo.png")
577
- .replace(/\{\{BRAND_FAVICON\}\}/g, config.brand.favicon || "assets/brand/favicon.png")
578
- .replace(/\{\{BRAND_LOGO_CLASS\}\}/g, logoClass)
579
- .replace(/\{\{SITE_TITLE\}\}/g, config.title)
671
+ .replace(/\{\{PATH_PREFIX\}\}/g, () => pathPrefix)
672
+ .replace(/\{\{BRAND_URL\}\}/g, () => config.brand.url)
673
+ .replace(/\{\{BRAND_TARGET\}\}/g, () => brandTarget)
674
+ .replace(/\{\{BRAND_NAME\}\}/g, () => config.brand.name)
675
+ .replace(/\{\{BRAND_INITIAL\}\}/g, () => brandInitial)
676
+ .replace("{{MOBILE_BRAND_LOGO_IMG}}", () => mobileLogoHtml)
677
+ .replace("{{SIDEBAR_BRAND_LOGO_IMG}}", () => sidebarLogoHtml)
678
+ .replace(/\{\{BRAND_LOGO\}\}/g, () => config.brand.logo || "assets/brand/logo.png")
679
+ .replace(/\{\{BRAND_FAVICON\}\}/g, () => config.brand.favicon || "assets/brand/favicon.png")
680
+ .replace(/\{\{BRAND_LOGO_CLASS\}\}/g, () => logoClass)
681
+ .replace(/\{\{SITE_TITLE\}\}/g, () => config.title)
580
682
  .replace("{{TITLE}}", "Getting Started")
581
- .replace("{{VERSION}}", uiVersion)
582
- .replace("{{BRANCH}}", provenance.gitBranch)
683
+ .replace("{{VERSION}}", () => uiVersion)
684
+ .replace("{{BRANCH}}", () => provenance.gitBranch)
583
685
  .replace("{{BREADCRUMBS}}", "")
584
686
  .replace("{{PAGE_META}}", "")
585
687
  .replace("{{NAV}}", "<ul></ul>")
586
- .replace("{{CONTENT}}", htmlContent)
688
+ .replace("{{CONTENT}}", () => htmlContent)
587
689
  .replace("{{TOC}}", "")
588
- .replace("{{FOOTER}}", buildFooter(provenance, config))
589
- .replace("{{THEME_CSS}}", themeCSS)
590
- .replace("{{PLUGIN_HEAD}}", plugins.head)
591
- .replace("{{PLUGIN_BODY_END}}", plugins.bodyEnd)
592
- .replace("{{PRISM_LIGHT_URL}}", prismUrls.light)
593
- .replace("{{PRISM_DARK_URL}}", prismUrls.dark)
594
- .replace("{{HOT_RELOAD_SCRIPT}}", hotReloadScript);
690
+ .replace("{{FOOTER}}", () => buildFooter(provenance, config, pathPrefix))
691
+ .replace("{{THEME_CSS}}", () => themeCSS)
692
+ .replace("{{PLUGIN_HEAD}}", () => plugins.head)
693
+ .replace("{{PLUGIN_BODY_END}}", () => plugins.bodyEnd)
694
+ .replace("{{PRISM_LIGHT_URL}}", () => prismUrls.light)
695
+ .replace("{{PRISM_DARK_URL}}", () => prismUrls.dark)
696
+ .replace("{{HOT_RELOAD_SCRIPT}}", () => hotReloadScript);
595
697
  }
596
698
 
597
699
  async function tryServeFile(filePath: string): Promise<Response | null> {
@@ -626,9 +728,11 @@ async function tryServeContentAsset(
626
728
  }
627
729
 
628
730
  // Find file for a URL path
629
- async function findFile(urlPath: string, config: SiteConfig): Promise<string | null> {
630
- const { stat } = await import("node:fs/promises");
631
-
731
+ async function findFile(
732
+ urlPath: string,
733
+ config: SiteConfig,
734
+ files: ContentFile[],
735
+ ): Promise<string | null> {
632
736
  // Remove leading slash and .html extension (for compatibility with built links)
633
737
  const path = urlPath.slice(1).replace(/\.html$/, "") || "";
634
738
 
@@ -636,84 +740,20 @@ async function findFile(urlPath: string, config: SiteConfig): Promise<string | n
636
740
  if (!path) {
637
741
  if (config.home) {
638
742
  const homePath = validatePath(ROOT, config.docroot, config.home, true);
639
- if (homePath) {
640
- try {
641
- await stat(homePath);
642
- return homePath;
643
- } catch {
644
- // Home file not found, fall through
645
- }
646
- }
743
+ const homeFile = homePath ? files.find((file) => file.path === homePath) : undefined;
744
+ if (homeFile) return homeFile.path;
647
745
  }
648
746
  // Fallback to first file
649
- const files = await collectFiles(ROOT, config);
650
747
  return files.length > 0 ? files[0].path : null;
651
748
  }
652
749
 
653
- // Check configured sections
654
- for (const section of config.sections) {
655
- const sectionPath = validatePath(ROOT, config.docroot, section.path, true);
656
- if (!sectionPath) continue;
657
-
658
- if (section.files) {
659
- // Check explicit files
660
- for (const file of section.files) {
661
- const name = file.replace(/\.(md|yaml|json)$/, "").toLowerCase();
662
- if (name === path) {
663
- const filePath = join(sectionPath, file);
664
- try {
665
- await stat(filePath);
666
- return filePath;
667
- } catch {
668
- // Continue
669
- }
670
- }
671
- }
672
- } else {
673
- // Check directory for matching file (supports nested paths)
674
- const urlBase = toUrlPath(ROOT, sectionPath);
675
- if (path.startsWith(`${urlBase}/`) || path === urlBase) {
676
- const relPath = path === urlBase ? "" : path.slice(urlBase.length + 1);
677
- // Guard against path traversal
678
- if (relPath.includes("..")) continue;
679
- const extensions = [".md", ".yaml", ".json"];
680
-
681
- if (relPath === "") {
682
- // Section root URL — try index file
683
- for (const ext of extensions) {
684
- const filePath = join(sectionPath, `index${ext}`);
685
- try {
686
- await stat(filePath);
687
- return filePath;
688
- } catch {
689
- // Continue
690
- }
691
- }
692
- } else {
693
- // Try direct file match at nested path
694
- for (const ext of extensions) {
695
- const filePath = join(sectionPath, relPath + ext);
696
- try {
697
- await stat(filePath);
698
- return filePath;
699
- } catch {
700
- // Continue
701
- }
702
- }
703
- // Try as directory with index file
704
- for (const ext of extensions) {
705
- const filePath = join(sectionPath, relPath, `index${ext}`);
706
- try {
707
- await stat(filePath);
708
- return filePath;
709
- } catch {
710
- // Continue
711
- }
712
- }
713
- }
714
- }
715
- }
716
- }
750
+ const directMatch = files.find((file) => file.urlPath === path);
751
+ if (directMatch) return directMatch.path;
752
+
753
+ const sectionIndex = files.find(
754
+ (file) => file.urlPath === `${path}/index` || file.urlPath === path,
755
+ );
756
+ if (sectionIndex) return sectionIndex.path;
717
757
 
718
758
  return null;
719
759
  }
@@ -750,17 +790,35 @@ function startWatcher(config: SiteConfig) {
750
790
  for (const dir of watchDirs) {
751
791
  try {
752
792
  watch(dir, { recursive: true }, (_event, filename) => {
753
- if (
754
- filename &&
755
- (filename.endsWith(".md") ||
756
- filename.endsWith(".yaml") ||
757
- filename.endsWith(".json") ||
758
- filename.endsWith(".html") ||
759
- filename.endsWith(".css"))
760
- ) {
761
- logInfo(`File changed: ${filename}`);
762
- notifyReload();
763
- }
793
+ if (!filename) return;
794
+ void (async () => {
795
+ try {
796
+ let hooksRan = 0;
797
+ if (config.prebuild?.length) {
798
+ hooksRan = await runPrebuildHooks(
799
+ config.prebuild,
800
+ ROOT,
801
+ "dev",
802
+ ACTIVE_PROFILE,
803
+ config.dataroot || "data",
804
+ filename,
805
+ );
806
+ }
807
+ const shouldReload =
808
+ hooksRan > 0 ||
809
+ filename.endsWith(".md") ||
810
+ filename.endsWith(".yaml") ||
811
+ filename.endsWith(".json") ||
812
+ filename.endsWith(".html") ||
813
+ filename.endsWith(".css");
814
+ if (shouldReload) {
815
+ logInfo(`File changed: ${filename}`);
816
+ notifyReload();
817
+ }
818
+ } catch (error) {
819
+ logWarn(error instanceof Error ? error.message : String(error));
820
+ }
821
+ })();
764
822
  });
765
823
  } catch {
766
824
  // Directory doesn't exist, skip
@@ -784,6 +842,10 @@ async function main() {
784
842
  // Load configuration
785
843
  const config = await loadSiteConfig(ROOT);
786
844
  logInfo(`Loaded config: "${config.title}" (${config.sections.length} sections)`);
845
+ if (config.prebuild?.length) {
846
+ await runPrebuildHooks(config.prebuild, ROOT, "dev", ACTIVE_PROFILE, config.dataroot || "data");
847
+ logInfo(`Ran prebuild hooks (${config.prebuild.length})`);
848
+ }
787
849
 
788
850
  // Apply server config from site.yaml if CLI didn't override
789
851
  if (config.server?.port && PORT === DEFAULT_PORT) {
@@ -858,7 +920,7 @@ async function main() {
858
920
  if (assetResponse) return assetResponse;
859
921
 
860
922
  // Check for content
861
- const files = await collectFiles(ROOT, config);
923
+ const files = await getFilteredFiles(config);
862
924
  if (files.length === 0) {
863
925
  // No content - render Getting Started page
864
926
  const html = await renderGettingStarted(provenance, config, theme);
@@ -876,7 +938,7 @@ async function main() {
876
938
  }
877
939
 
878
940
  // Find and render markdown/yaml file
879
- const filePath = await findFile(url.pathname, config);
941
+ const filePath = await findFile(url.pathname, config, files);
880
942
  if (filePath) {
881
943
  // If this is an index/readme file and the URL lacks a trailing slash,
882
944
  // redirect so relative links resolve correctly (BUG-003)
@@ -998,9 +1060,22 @@ async function main() {
998
1060
  }
999
1061
  }
1000
1062
 
1001
- // Open browser (macOS)
1063
+ // Open browser (cross-platform)
1002
1064
  if (OPEN_BROWSER) {
1003
- Bun.spawn(["open", serverUrl]);
1065
+ try {
1066
+ if (process.platform === "win32") {
1067
+ // cmd.exe built-in: start
1068
+ // Empty title argument avoids treating URL as window title.
1069
+ Bun.spawn(["cmd", "/c", "start", "", serverUrl]);
1070
+ } else if (process.platform === "darwin") {
1071
+ Bun.spawn(["open", serverUrl]);
1072
+ } else {
1073
+ // Most Linux distros
1074
+ Bun.spawn(["xdg-open", serverUrl]);
1075
+ }
1076
+ } catch {
1077
+ // Non-fatal: server is already running; user can open manually.
1078
+ }
1004
1079
  }
1005
1080
  }
1006
1081
 
@@ -1011,6 +1086,7 @@ export interface DevOptions {
1011
1086
  host?: string;
1012
1087
  open?: boolean;
1013
1088
  logFormat?: string;
1089
+ profile?: string;
1014
1090
  }
1015
1091
 
1016
1092
  export async function dev(options: DevOptions = {}) {
@@ -1029,6 +1105,7 @@ export async function dev(options: DevOptions = {}) {
1029
1105
  if (options.logFormat) {
1030
1106
  LOG_FORMAT = options.logFormat;
1031
1107
  }
1108
+ ACTIVE_PROFILE = options.profile;
1032
1109
  await main();
1033
1110
  }
1034
1111
 
@@ -1042,6 +1119,7 @@ Usage: bun run dev [folder] [options]
1042
1119
  Options:
1043
1120
  -p, --port <number> Port to serve on [env: KITFLY_DEV_PORT] [default: ${DEFAULT_PORT}]
1044
1121
  -H, --host <string> Host to bind to [env: KITFLY_DEV_HOST] [default: ${DEFAULT_HOST}]
1122
+ --profile <name> Active content profile [env: KITFLY_PROFILE]
1045
1123
  -o, --open Open browser on start [env: KITFLY_DEV_OPEN] [default: true]
1046
1124
  --no-open Don't open browser
1047
1125
  --help Show this help message
@@ -1063,5 +1141,6 @@ Examples:
1063
1141
  host: cfg.host,
1064
1142
  open: cfg.open,
1065
1143
  logFormat: cfg.logFormat,
1144
+ profile: cfg.profile,
1066
1145
  }).catch(console.error);
1067
1146
  }