kitfly 0.2.1 → 0.2.4

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 (132) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +38 -21
  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/gantt-widget.md +468 -0
  9. package/dist/_raw/content/reference/glossary.md +25 -1
  10. package/dist/_raw/content/reference/key-concepts.md +30 -2
  11. package/dist/_raw/content/reference/plugins.md +170 -1
  12. package/dist/_raw/docs/decisions/ADR-0006-data-driven-content.md +350 -0
  13. package/dist/content/deployment/preflight.html +11 -8
  14. package/dist/content/deployment/recipes/aws-s3.html +11 -8
  15. package/dist/content/deployment/recipes/cloudflare-pages.html +11 -8
  16. package/dist/content/deployment/recipes/cloudflare-r2.html +11 -8
  17. package/dist/content/deployment/recipes/fly-io.html +11 -8
  18. package/dist/content/deployment/recipes/github-pages.html +11 -8
  19. package/dist/content/deployment/recipes/netlify.html +11 -8
  20. package/dist/content/deployment/recipes/vercel.html +11 -8
  21. package/dist/content/deployment/secrets-and-env-vars.html +11 -8
  22. package/dist/content/deployment.html +11 -8
  23. package/dist/content/guide/approaches.html +11 -8
  24. package/dist/content/guide/branding.html +509 -0
  25. package/dist/content/guide/data-driven-content.html +542 -0
  26. package/dist/content/guide/features.html +11 -8
  27. package/dist/content/guide/getting-started.html +11 -8
  28. package/dist/content/guide/kitfly-overview.html +11 -8
  29. package/dist/content/reference/configuration.html +136 -11
  30. package/dist/content/reference/design-catalog.html +11 -8
  31. package/dist/content/reference/environment-variables.html +51 -10
  32. package/dist/content/reference/gantt-widget.html +899 -0
  33. package/dist/content/reference/glossary.html +25 -10
  34. package/dist/content/reference/key-concepts.html +34 -11
  35. package/dist/content/reference/plugins.html +261 -10
  36. package/dist/content/reference/slides-authoring-guidelines.html +11 -8
  37. package/dist/content/reference/structure.html +11 -8
  38. package/dist/content/reference.html +11 -8
  39. package/dist/content/templates/crucible.html +11 -8
  40. package/dist/content/templates/handbook.html +11 -8
  41. package/dist/content/templates/minimal.html +11 -8
  42. package/dist/content/templates/overview.html +11 -8
  43. package/dist/content/templates/pipeline.html +11 -8
  44. package/dist/content/templates/productbook.html +11 -8
  45. package/dist/content/templates/runbook.html +11 -8
  46. package/dist/content/templates/servicebook.html +11 -8
  47. package/dist/content-index.json +37 -2
  48. package/dist/docs/decisions/ADR-0001-minimalist-site-code.html +11 -8
  49. package/dist/docs/decisions/ADR-0002-ai-accessibility.html +11 -8
  50. package/dist/docs/decisions/ADR-0003-single-file-bundle.html +11 -8
  51. package/dist/docs/decisions/ADR-0004-bun-runtime.html +11 -8
  52. package/dist/docs/decisions/ADR-0005-plugin-contract-and-distribution.html +11 -8
  53. package/dist/docs/decisions/ADR-0006-data-driven-content.html +751 -0
  54. package/dist/docs/decisions/DDR-0001-viewport-locked-layout.html +11 -8
  55. package/dist/docs/decisions/DDR-0002-theme-system.html +11 -8
  56. package/dist/docs/decisions/DDR-0003-bounded-logo-slot.html +11 -8
  57. package/dist/docs/decisions/DDR-0004-slides-rendering-model.html +11 -8
  58. package/dist/docs/decisions/DDR-0005-deterministic-layout-boundary.html +11 -8
  59. package/dist/docs/userguide/cli/build.html +11 -8
  60. package/dist/docs/userguide/cli/bundle.html +11 -8
  61. package/dist/docs/userguide/cli/dev.html +11 -8
  62. package/dist/docs/userguide/cli/init.html +11 -8
  63. package/dist/docs/userguide/cli/servers.html +11 -8
  64. package/dist/docs/userguide/cli/stop.html +11 -8
  65. package/dist/docs/userguide/cli/update.html +11 -8
  66. package/dist/docs/userguide/cli/version.html +11 -8
  67. package/dist/docs/userguide/cli.html +11 -8
  68. package/dist/docs/userguide/sharing.html +11 -8
  69. package/dist/index.html +11 -8
  70. package/dist/llms.txt +3 -3
  71. package/dist/provenance.json +4 -5
  72. package/dist/reports/license-inventory.csv +199 -0
  73. package/dist/schemas/plugin-registry.schema.html +11 -8
  74. package/dist/schemas/plugin-schemas-notes.html +11 -8
  75. package/dist/schemas/plugin.schema.html +11 -8
  76. package/dist/schemas/plugins.schema.html +11 -8
  77. package/dist/schemas/v0/common.schema.html +15 -12
  78. package/dist/schemas/v0/plugin-registry.schema.html +14 -11
  79. package/dist/schemas/v0/plugin.schema.html +14 -11
  80. package/dist/schemas/v0/plugins.schema.html +14 -11
  81. package/dist/schemas/v0/site.schema.html +68 -9
  82. package/dist/schemas/v0/theme.schema.html +22 -19
  83. package/dist/schemas.html +11 -8
  84. package/dist/styles.css +39 -4
  85. package/package.json +1 -1
  86. package/plugins-dist/latex-runtime.js +140 -0
  87. package/plugins-dist/latex.js +178 -0
  88. package/plugins-dist/planning-visuals.css +261 -0
  89. package/plugins-dist/planning-visuals.js +669 -0
  90. package/plugins-dist/slides-charts-lite-runtime.js +179 -0
  91. package/plugins-dist/slides-charts-lite.js +198 -0
  92. package/registry/plugins.yaml +40 -1
  93. package/schemas/v0/site.schema.json +56 -0
  94. package/scripts/build-all.ts +5 -0
  95. package/scripts/build.ts +264 -80
  96. package/scripts/bundle.ts +188 -17
  97. package/scripts/dev.ts +294 -171
  98. package/scripts/embed-docs.ts +119 -0
  99. package/src/__tests__/brief.test.ts +151 -0
  100. package/src/__tests__/build.test.ts +293 -1
  101. package/src/__tests__/bundle.test.ts +195 -0
  102. package/src/__tests__/docs.test.ts +117 -0
  103. package/src/__tests__/fixtures/fences/planning-visuals/invalid/bad-month-format.md +10 -0
  104. package/src/__tests__/fixtures/fences/planning-visuals/invalid/marker-format-mismatch.md +13 -0
  105. package/src/__tests__/fixtures/fences/planning-visuals/invalid/milestone-format-mismatch.md +13 -0
  106. package/src/__tests__/fixtures/fences/planning-visuals/invalid/missing-tracks.md +5 -0
  107. package/src/__tests__/fixtures/fences/planning-visuals/invalid/track-reversed.md +10 -0
  108. package/src/__tests__/fixtures/fences/planning-visuals/valid/markers-basic.md +15 -0
  109. package/src/__tests__/fixtures/fences/planning-visuals/valid/markers-no-milestones.md +13 -0
  110. package/src/__tests__/fixtures/fences/planning-visuals/valid/month-basic.md +16 -0
  111. package/src/__tests__/fixtures/fences/planning-visuals/valid/no-milestones.md +10 -0
  112. package/src/__tests__/fixtures/fences/planning-visuals/valid/week-basic.md +20 -0
  113. package/src/__tests__/init.test.ts +51 -2
  114. package/src/__tests__/latex-runtime.bun.test.ts +35 -0
  115. package/src/__tests__/planning-visuals-fence-contract.test.ts +28 -0
  116. package/src/__tests__/planning-visuals-runtime-regressions.bun.test.ts +68 -0
  117. package/src/__tests__/planning-visuals-runtime.bun.test.ts +192 -0
  118. package/src/__tests__/shared.test.ts +719 -1
  119. package/src/__tests__/slides-charts-lite-runtime.bun.test.ts +45 -0
  120. package/src/cli.ts +124 -22
  121. package/src/commands/docs.ts +71 -0
  122. package/src/commands/init.ts +1 -1
  123. package/src/generated/embedded-docs.ts +2384 -0
  124. package/src/server-registry.ts +50 -10
  125. package/src/shared.ts +1174 -43
  126. package/src/site/styles.css +39 -4
  127. package/src/site/template.html +5 -2
  128. package/src/templates/brief.ts +486 -0
  129. package/src/templates/deck.ts +59 -0
  130. package/src/templates/driver.ts +46 -13
  131. package/src/templates/handbook.ts +32 -0
  132. 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,14 +29,18 @@ 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
38
42
  collectFiles,
43
+ collectPlanningVisualsContainmentWarnings,
39
44
  collectSlides,
40
45
  envBool,
41
46
  envInt,
@@ -43,23 +48,29 @@ import {
43
48
  envString,
44
49
  // Formatting
45
50
  escapeHtml,
51
+ filterByProfile,
52
+ filterUnknownPlanningVisualsTypeDiagnostics,
46
53
  filterUnknownSlidesVisualsTypeDiagnostics,
47
54
  // Provenance
48
55
  generateProvenance,
56
+ loadDataBindings,
49
57
  // YAML/Config parsing
50
58
  loadSiteConfig,
59
+ mergeFrontmatterWithBody,
51
60
  type Provenance,
61
+ pagePathForData,
52
62
  // Markdown utilities
53
63
  parseFrontmatter,
54
64
  parseYaml,
65
+ resolveBindings,
55
66
  resolveStylesPath,
56
67
  resolveTemplatePath,
57
68
  rewriteRelativeAssetUrls,
58
- // Types
69
+ runPrebuildHooks,
59
70
  type SiteConfig,
60
71
  slugify,
61
- toUrlPath,
62
72
  validatePath,
73
+ validatePlanningVisualsFences,
63
74
  validateSlidesVisualsFences,
64
75
  } from "../src/shared.ts";
65
76
  import { generateThemeCSS, getPrismUrls, loadTheme, type Theme } from "../src/theme.ts";
@@ -73,6 +84,7 @@ let HOST = DEFAULT_HOST;
73
84
  let ROOT = process.cwd();
74
85
  let OPEN_BROWSER = true;
75
86
  let LOG_FORMAT = ""; // "structured" when invoked by CLI daemon
87
+ let ACTIVE_PROFILE: string | undefined;
76
88
 
77
89
  // Structured logger for daemon mode — set during main() init.
78
90
  // When null, all output goes through console.log (standalone mode).
@@ -82,6 +94,32 @@ let daemonLog: {
82
94
  error: (msg: string) => void;
83
95
  } | null = null;
84
96
 
97
+ async function applyDataBindingsToMarkdown(
98
+ rawMarkdown: string,
99
+ filePath: string,
100
+ config: SiteConfig,
101
+ ): Promise<{ frontmatter: Record<string, unknown>; body: string }> {
102
+ const parsed = parseFrontmatter(rawMarkdown);
103
+ const dataRef = typeof parsed.frontmatter.data === "string" ? parsed.frontmatter.data.trim() : "";
104
+ if (!dataRef) return parsed;
105
+
106
+ const pagePath = pagePathForData(ROOT, config.docroot, filePath);
107
+ const bindings = await loadDataBindings(dataRef, pagePath, ROOT, config.docroot, config.dataroot);
108
+ return {
109
+ frontmatter: parsed.frontmatter,
110
+ body: resolveBindings(parsed.body, bindings, pagePath),
111
+ };
112
+ }
113
+
114
+ async function applyDataBindingsForSlides(
115
+ rawMarkdown: string,
116
+ filePath: string,
117
+ config: SiteConfig,
118
+ ): Promise<string> {
119
+ const resolved = await applyDataBindingsToMarkdown(rawMarkdown, filePath, config);
120
+ return mergeFrontmatterWithBody(rawMarkdown, resolved.body);
121
+ }
122
+
85
123
  function isPluginLoaderError(error: unknown): error is Error {
86
124
  return (
87
125
  error instanceof PluginConfigError ||
@@ -158,6 +196,7 @@ interface ParsedArgs {
158
196
  open?: boolean;
159
197
  folder?: string;
160
198
  logFormat?: string;
199
+ profile?: string;
161
200
  }
162
201
 
163
202
  function parseArgs(argv: string[]): ParsedArgs {
@@ -175,6 +214,9 @@ function parseArgs(argv: string[]): ParsedArgs {
175
214
  } else if (arg === "--log-format") {
176
215
  result.logFormat = next;
177
216
  i++;
217
+ } else if (arg === "--profile" && next && !next.startsWith("-")) {
218
+ result.profile = next;
219
+ i++;
178
220
  } else if (arg === "--open" || arg === "-o") {
179
221
  result.open = true;
180
222
  } else if (arg === "--no-open") {
@@ -192,6 +234,7 @@ function getConfig(): {
192
234
  open: boolean;
193
235
  folder?: string;
194
236
  logFormat?: string;
237
+ profile?: string;
195
238
  } {
196
239
  const args = parseArgs(process.argv.slice(2));
197
240
  return {
@@ -200,9 +243,15 @@ function getConfig(): {
200
243
  open: args.open ?? envBool("KITFLY_DEV_OPEN", true),
201
244
  folder: args.folder,
202
245
  logFormat: args.logFormat,
246
+ profile: args.profile ?? process.env.KITFLY_PROFILE,
203
247
  };
204
248
  }
205
249
 
250
+ async function getFilteredFiles(config: SiteConfig): Promise<ContentFile[]> {
251
+ const files = await collectFiles(ROOT, config);
252
+ return filterByProfile(files, ACTIVE_PROFILE, config.profiles);
253
+ }
254
+
206
255
  function getContentType(filePath: string): string {
207
256
  const ext = extname(filePath).toLowerCase();
208
257
  switch (ext) {
@@ -256,15 +305,23 @@ const clients: Set<ReadableStreamDefaultController> = new Set();
256
305
 
257
306
  let pluginCache: { key: string; head: string; bodyEnd: string } | null = null;
258
307
 
259
- async function isSlidesVisualsEnabled(): Promise<boolean> {
308
+ async function getFenceValidationFlags(): Promise<{
309
+ slidesVisuals: boolean;
310
+ planningVisuals: boolean;
311
+ }> {
260
312
  const configPath = join(ROOT, "kitfly.plugins.yaml");
261
313
  try {
262
314
  const raw = await readFile(configPath, "utf-8");
263
315
  const parsed = parseYaml(raw) as unknown as Record<string, unknown>;
264
316
  const plugins = Array.isArray(parsed?.plugins) ? (parsed.plugins as unknown[]) : [];
265
- return plugins.some((p) => typeof p === "string" && p.startsWith("slides-visuals@"));
317
+ return {
318
+ slidesVisuals: plugins.some((p) => typeof p === "string" && p.startsWith("slides-visuals@")),
319
+ planningVisuals: plugins.some(
320
+ (p) => typeof p === "string" && p.startsWith("planning-visuals@"),
321
+ ),
322
+ };
266
323
  } catch {
267
- return false;
324
+ return { slidesVisuals: false, planningVisuals: false };
268
325
  }
269
326
  }
270
327
 
@@ -349,24 +406,41 @@ async function renderPage(
349
406
  }
350
407
  htmlContent = `<h1>${title}</h1>\n<pre><code class="language-json">${escapeHtml(prettyJson)}</code></pre>`;
351
408
  } else {
352
- const { frontmatter, body } = parseFrontmatter(content);
409
+ const { frontmatter, body } = await applyDataBindingsToMarkdown(content, filePath, config);
353
410
  if (frontmatter.title) {
354
411
  title = frontmatter.title as string;
355
412
  }
356
413
  pageMeta = buildPageMeta(frontmatter);
414
+ const fenceValidation = await getFenceValidationFlags();
415
+ if (fenceValidation.planningVisuals) {
416
+ const diagnostics = filterUnknownPlanningVisualsTypeDiagnostics(
417
+ validatePlanningVisualsFences(body),
418
+ );
419
+ if (diagnostics.length) {
420
+ const msg = diagnostics
421
+ .slice(0, 12)
422
+ .map((d) => ` - ${filePath}:${d.line} ${d.message}`)
423
+ .join("\n");
424
+ throw new Error(`planning-visuals fence contract violations:\n${msg}`);
425
+ }
426
+ const warnings = collectPlanningVisualsContainmentWarnings(body);
427
+ for (const warning of warnings.slice(0, 12)) {
428
+ logWarn(`${filePath}:${warning.line} ${warning.message}`);
429
+ }
430
+ }
357
431
  htmlContent = marked.parse(body) as string;
358
432
  }
359
433
 
360
- const files = await collectFiles(ROOT, config);
434
+ const files = await getFilteredFiles(config);
361
435
  const currentUrlPath = urlPath.slice(1).replace(/\.html$/, "");
436
+ const pathPrefix = "/";
362
437
  const nav = buildNavSimple(files, config, currentUrlPath);
363
- const footer = buildFooter(provenance, config);
438
+ const footer = buildFooter(provenance, config, pathPrefix);
364
439
  const breadcrumbs = buildBreadcrumbsSimple(urlPath, files, config);
365
440
  const toc = buildToc(htmlContent);
366
441
  const brandTarget = config.brand.external ? ' target="_blank" rel="noopener"' : "";
367
442
  const themeCSS = generateThemeCSS(theme);
368
443
  const prismUrls = getPrismUrls(theme);
369
- const pathPrefix = "/";
370
444
  const plugins = await getPluginInjectionsCached(config.mode === "slides" ? "slides" : "docs");
371
445
 
372
446
  const hotReloadScript = `
@@ -378,33 +452,51 @@ async function renderPage(
378
452
 
379
453
  const logoClass = config.brand.logoType === "wordmark" ? "logo-wordmark" : "logo-icon";
380
454
  const brandInitial = escapeHtml(config.brand.name.trim().charAt(0).toUpperCase() || "K");
455
+ const mobileLogoHtml = buildLogoImgHtml({
456
+ logo: config.brand.logo || "assets/brand/logo.png",
457
+ logoDark: config.brand.logoDark,
458
+ alt: config.brand.name,
459
+ className: `logo-img ${logoClass}`,
460
+ pathPrefix,
461
+ onerrorFallback: true,
462
+ });
463
+ const sidebarLogoHtml = buildLogoImgHtml({
464
+ logo: config.brand.logo || "assets/brand/logo.png",
465
+ logoDark: config.brand.logoDark,
466
+ alt: config.brand.name,
467
+ className: "logo-img",
468
+ pathPrefix,
469
+ onerrorFallback: true,
470
+ });
381
471
 
382
472
  return template
383
473
  .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);
474
+ .replace(/\{\{PATH_PREFIX\}\}/g, () => pathPrefix)
475
+ .replace(/\{\{BRAND_URL\}\}/g, () => config.brand.url)
476
+ .replace(/\{\{BRAND_TARGET\}\}/g, () => brandTarget)
477
+ .replace(/\{\{BRAND_NAME\}\}/g, () => config.brand.name)
478
+ .replace(/\{\{BRAND_INITIAL\}\}/g, () => brandInitial)
479
+ .replace("{{MOBILE_BRAND_LOGO_IMG}}", () => mobileLogoHtml)
480
+ .replace("{{SIDEBAR_BRAND_LOGO_IMG}}", () => sidebarLogoHtml)
481
+ .replace(/\{\{BRAND_LOGO\}\}/g, () => config.brand.logo || "assets/brand/logo.png")
482
+ .replace(/\{\{BRAND_FAVICON\}\}/g, () => config.brand.favicon || "assets/brand/favicon.png")
483
+ .replace(/\{\{BRAND_LOGO_CLASS\}\}/g, () => logoClass)
484
+ .replace(/\{\{SITE_TITLE\}\}/g, () => config.title)
485
+ .replace("{{TITLE}}", () => title)
486
+ .replace("{{VERSION}}", () => uiVersion)
487
+ .replace("{{BRANCH}}", () => provenance.gitBranch)
488
+ .replace("{{BREADCRUMBS}}", () => breadcrumbs)
489
+ .replace("{{PAGE_META}}", () => pageMeta)
490
+ .replace("{{NAV}}", () => nav)
491
+ .replace("{{CONTENT}}", () => htmlContent)
492
+ .replace("{{TOC}}", () => toc)
493
+ .replace("{{FOOTER}}", () => footer)
494
+ .replace("{{THEME_CSS}}", () => themeCSS)
495
+ .replace("{{PLUGIN_HEAD}}", () => plugins.head)
496
+ .replace("{{PLUGIN_BODY_END}}", () => plugins.bodyEnd)
497
+ .replace("{{PRISM_LIGHT_URL}}", () => prismUrls.light)
498
+ .replace("{{PRISM_DARK_URL}}", () => prismUrls.dark)
499
+ .replace("{{HOT_RELOAD_SCRIPT}}", () => hotReloadScript);
408
500
  }
409
501
 
410
502
  async function renderSlidesPage(
@@ -414,20 +506,22 @@ async function renderSlidesPage(
414
506
  ): Promise<string> {
415
507
  const uiVersion = provenance.version ? `v${provenance.version}` : "unversioned";
416
508
  const template = await readFile(await resolveTemplatePath(ROOT), "utf-8");
417
- const files = await collectFiles(ROOT, config);
418
- const slides = await collectSlides(files);
509
+ const files = await getFilteredFiles(config);
510
+ const slides = await collectSlides(files, {
511
+ markdownTransform: (raw, file) => applyDataBindingsForSlides(raw, file.path, config),
512
+ });
419
513
 
420
514
  if (slides.length === 0) {
421
515
  return renderGettingStarted(provenance, config, theme);
422
516
  }
423
517
  const pathPrefix = "/";
424
- const validateFences = await isSlidesVisualsEnabled();
518
+ const fenceValidation = await getFenceValidationFlags();
425
519
 
426
520
  const sections = await Promise.all(
427
521
  slides.map(async (slide, i) => {
428
522
  let inner = "";
429
523
  if (slide.kind === "markdown") {
430
- if (validateFences) {
524
+ if (fenceValidation.slidesVisuals) {
431
525
  const diagnostics = filterUnknownSlidesVisualsTypeDiagnostics(
432
526
  validateSlidesVisualsFences(slide.body),
433
527
  );
@@ -439,6 +533,22 @@ async function renderSlidesPage(
439
533
  throw new Error(`slides-visuals fence contract violations:\n${msg}`);
440
534
  }
441
535
  }
536
+ if (fenceValidation.planningVisuals) {
537
+ const diagnostics = filterUnknownPlanningVisualsTypeDiagnostics(
538
+ validatePlanningVisualsFences(slide.body),
539
+ );
540
+ if (diagnostics.length) {
541
+ const msg = diagnostics
542
+ .slice(0, 12)
543
+ .map((d) => ` - ${slide.sourcePath}:${d.line} ${d.message}`)
544
+ .join("\n");
545
+ throw new Error(`planning-visuals fence contract violations:\n${msg}`);
546
+ }
547
+ const warnings = collectPlanningVisualsContainmentWarnings(slide.body);
548
+ for (const warning of warnings.slice(0, 12)) {
549
+ logWarn(`${slide.sourcePath}:${warning.line} ${warning.message}`);
550
+ }
551
+ }
442
552
  inner = marked.parse(slide.body) as string;
443
553
  } else if (slide.kind === "yaml") {
444
554
  inner = `<pre><code class="language-yaml">${escapeHtml(slide.body)}</code></pre>`;
@@ -476,8 +586,8 @@ async function renderSlidesPage(
476
586
  </div>
477
587
  </div>`;
478
588
 
479
- const nav = buildSlideNav(slides, config, "slide-1");
480
- const footer = buildFooter(provenance, config);
589
+ const nav = buildSlideNavHierarchical(slides, config, "slide-1");
590
+ const footer = buildFooter(provenance, config, pathPrefix);
481
591
  const brandTarget = config.brand.external ? ' target="_blank" rel="noopener"' : "";
482
592
  const themeCSS = generateThemeCSS(theme);
483
593
  const prismUrls = getPrismUrls(theme);
@@ -490,33 +600,51 @@ async function renderSlidesPage(
490
600
  const plugins = await getPluginInjectionsCached("slides");
491
601
  const logoClass = config.brand.logoType === "wordmark" ? "logo-wordmark" : "logo-icon";
492
602
  const brandInitial = escapeHtml(config.brand.name.trim().charAt(0).toUpperCase() || "K");
603
+ const mobileLogoHtml = buildLogoImgHtml({
604
+ logo: config.brand.logo || "assets/brand/logo.png",
605
+ logoDark: config.brand.logoDark,
606
+ alt: config.brand.name,
607
+ className: `logo-img ${logoClass}`,
608
+ pathPrefix,
609
+ onerrorFallback: true,
610
+ });
611
+ const sidebarLogoHtml = buildLogoImgHtml({
612
+ logo: config.brand.logo || "assets/brand/logo.png",
613
+ logoDark: config.brand.logoDark,
614
+ alt: config.brand.name,
615
+ className: "logo-img",
616
+ pathPrefix,
617
+ onerrorFallback: true,
618
+ });
493
619
 
494
620
  return template
495
621
  .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)
622
+ .replace(/\{\{PATH_PREFIX\}\}/g, () => pathPrefix)
623
+ .replace(/\{\{BRAND_URL\}\}/g, () => config.brand.url)
624
+ .replace(/\{\{BRAND_TARGET\}\}/g, () => brandTarget)
625
+ .replace(/\{\{BRAND_NAME\}\}/g, () => config.brand.name)
626
+ .replace(/\{\{BRAND_INITIAL\}\}/g, () => brandInitial)
627
+ .replace("{{MOBILE_BRAND_LOGO_IMG}}", () => mobileLogoHtml)
628
+ .replace("{{SIDEBAR_BRAND_LOGO_IMG}}", () => sidebarLogoHtml)
629
+ .replace(/\{\{BRAND_LOGO\}\}/g, () => config.brand.logo || "assets/brand/logo.png")
630
+ .replace(/\{\{BRAND_FAVICON\}\}/g, () => config.brand.favicon || "assets/brand/favicon.png")
631
+ .replace(/\{\{BRAND_LOGO_CLASS\}\}/g, () => logoClass)
632
+ .replace(/\{\{SITE_TITLE\}\}/g, () => config.title)
633
+ .replace("{{TITLE}}", () => config.title)
634
+ .replace("{{VERSION}}", () => uiVersion)
635
+ .replace("{{BRANCH}}", () => provenance.gitBranch)
508
636
  .replace("{{BREADCRUMBS}}", "")
509
637
  .replace("{{PAGE_META}}", "")
510
- .replace("{{NAV}}", nav)
511
- .replace("{{CONTENT}}", htmlContent)
638
+ .replace("{{NAV}}", () => nav)
639
+ .replace("{{CONTENT}}", () => htmlContent)
512
640
  .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);
641
+ .replace("{{FOOTER}}", () => footer)
642
+ .replace("{{THEME_CSS}}", () => themeCSS)
643
+ .replace("{{PLUGIN_HEAD}}", () => plugins.head)
644
+ .replace("{{PLUGIN_BODY_END}}", () => plugins.bodyEnd)
645
+ .replace("{{PRISM_LIGHT_URL}}", () => prismUrls.light)
646
+ .replace("{{PRISM_DARK_URL}}", () => prismUrls.dark)
647
+ .replace("{{HOT_RELOAD_SCRIPT}}", () => hotReloadScript);
520
648
  }
521
649
 
522
650
  // Render Getting Started page when no config
@@ -565,33 +693,51 @@ sections:
565
693
 
566
694
  const logoClass = config.brand.logoType === "wordmark" ? "logo-wordmark" : "logo-icon";
567
695
  const brandInitial = escapeHtml(config.brand.name.trim().charAt(0).toUpperCase() || "K");
696
+ const mobileLogoHtml = buildLogoImgHtml({
697
+ logo: config.brand.logo || "assets/brand/logo.png",
698
+ logoDark: config.brand.logoDark,
699
+ alt: config.brand.name,
700
+ className: `logo-img ${logoClass}`,
701
+ pathPrefix,
702
+ onerrorFallback: true,
703
+ });
704
+ const sidebarLogoHtml = buildLogoImgHtml({
705
+ logo: config.brand.logo || "assets/brand/logo.png",
706
+ logoDark: config.brand.logoDark,
707
+ alt: config.brand.name,
708
+ className: "logo-img",
709
+ pathPrefix,
710
+ onerrorFallback: true,
711
+ });
568
712
 
569
713
  return template
570
714
  .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)
715
+ .replace(/\{\{PATH_PREFIX\}\}/g, () => pathPrefix)
716
+ .replace(/\{\{BRAND_URL\}\}/g, () => config.brand.url)
717
+ .replace(/\{\{BRAND_TARGET\}\}/g, () => brandTarget)
718
+ .replace(/\{\{BRAND_NAME\}\}/g, () => config.brand.name)
719
+ .replace(/\{\{BRAND_INITIAL\}\}/g, () => brandInitial)
720
+ .replace("{{MOBILE_BRAND_LOGO_IMG}}", () => mobileLogoHtml)
721
+ .replace("{{SIDEBAR_BRAND_LOGO_IMG}}", () => sidebarLogoHtml)
722
+ .replace(/\{\{BRAND_LOGO\}\}/g, () => config.brand.logo || "assets/brand/logo.png")
723
+ .replace(/\{\{BRAND_FAVICON\}\}/g, () => config.brand.favicon || "assets/brand/favicon.png")
724
+ .replace(/\{\{BRAND_LOGO_CLASS\}\}/g, () => logoClass)
725
+ .replace(/\{\{SITE_TITLE\}\}/g, () => config.title)
580
726
  .replace("{{TITLE}}", "Getting Started")
581
- .replace("{{VERSION}}", uiVersion)
582
- .replace("{{BRANCH}}", provenance.gitBranch)
727
+ .replace("{{VERSION}}", () => uiVersion)
728
+ .replace("{{BRANCH}}", () => provenance.gitBranch)
583
729
  .replace("{{BREADCRUMBS}}", "")
584
730
  .replace("{{PAGE_META}}", "")
585
731
  .replace("{{NAV}}", "<ul></ul>")
586
- .replace("{{CONTENT}}", htmlContent)
732
+ .replace("{{CONTENT}}", () => htmlContent)
587
733
  .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);
734
+ .replace("{{FOOTER}}", () => buildFooter(provenance, config, pathPrefix))
735
+ .replace("{{THEME_CSS}}", () => themeCSS)
736
+ .replace("{{PLUGIN_HEAD}}", () => plugins.head)
737
+ .replace("{{PLUGIN_BODY_END}}", () => plugins.bodyEnd)
738
+ .replace("{{PRISM_LIGHT_URL}}", () => prismUrls.light)
739
+ .replace("{{PRISM_DARK_URL}}", () => prismUrls.dark)
740
+ .replace("{{HOT_RELOAD_SCRIPT}}", () => hotReloadScript);
595
741
  }
596
742
 
597
743
  async function tryServeFile(filePath: string): Promise<Response | null> {
@@ -626,9 +772,11 @@ async function tryServeContentAsset(
626
772
  }
627
773
 
628
774
  // 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
-
775
+ async function findFile(
776
+ urlPath: string,
777
+ config: SiteConfig,
778
+ files: ContentFile[],
779
+ ): Promise<string | null> {
632
780
  // Remove leading slash and .html extension (for compatibility with built links)
633
781
  const path = urlPath.slice(1).replace(/\.html$/, "") || "";
634
782
 
@@ -636,84 +784,20 @@ async function findFile(urlPath: string, config: SiteConfig): Promise<string | n
636
784
  if (!path) {
637
785
  if (config.home) {
638
786
  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
- }
787
+ const homeFile = homePath ? files.find((file) => file.path === homePath) : undefined;
788
+ if (homeFile) return homeFile.path;
647
789
  }
648
790
  // Fallback to first file
649
- const files = await collectFiles(ROOT, config);
650
791
  return files.length > 0 ? files[0].path : null;
651
792
  }
652
793
 
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
- }
794
+ const directMatch = files.find((file) => file.urlPath === path);
795
+ if (directMatch) return directMatch.path;
796
+
797
+ const sectionIndex = files.find(
798
+ (file) => file.urlPath === `${path}/index` || file.urlPath === path,
799
+ );
800
+ if (sectionIndex) return sectionIndex.path;
717
801
 
718
802
  return null;
719
803
  }
@@ -750,17 +834,35 @@ function startWatcher(config: SiteConfig) {
750
834
  for (const dir of watchDirs) {
751
835
  try {
752
836
  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
- }
837
+ if (!filename) return;
838
+ void (async () => {
839
+ try {
840
+ let hooksRan = 0;
841
+ if (config.prebuild?.length) {
842
+ hooksRan = await runPrebuildHooks(
843
+ config.prebuild,
844
+ ROOT,
845
+ "dev",
846
+ ACTIVE_PROFILE,
847
+ config.dataroot || "data",
848
+ filename,
849
+ );
850
+ }
851
+ const shouldReload =
852
+ hooksRan > 0 ||
853
+ filename.endsWith(".md") ||
854
+ filename.endsWith(".yaml") ||
855
+ filename.endsWith(".json") ||
856
+ filename.endsWith(".html") ||
857
+ filename.endsWith(".css");
858
+ if (shouldReload) {
859
+ logInfo(`File changed: ${filename}`);
860
+ notifyReload();
861
+ }
862
+ } catch (error) {
863
+ logWarn(error instanceof Error ? error.message : String(error));
864
+ }
865
+ })();
764
866
  });
765
867
  } catch {
766
868
  // Directory doesn't exist, skip
@@ -784,6 +886,10 @@ async function main() {
784
886
  // Load configuration
785
887
  const config = await loadSiteConfig(ROOT);
786
888
  logInfo(`Loaded config: "${config.title}" (${config.sections.length} sections)`);
889
+ if (config.prebuild?.length) {
890
+ await runPrebuildHooks(config.prebuild, ROOT, "dev", ACTIVE_PROFILE, config.dataroot || "data");
891
+ logInfo(`Ran prebuild hooks (${config.prebuild.length})`);
892
+ }
787
893
 
788
894
  // Apply server config from site.yaml if CLI didn't override
789
895
  if (config.server?.port && PORT === DEFAULT_PORT) {
@@ -858,7 +964,7 @@ async function main() {
858
964
  if (assetResponse) return assetResponse;
859
965
 
860
966
  // Check for content
861
- const files = await collectFiles(ROOT, config);
967
+ const files = await getFilteredFiles(config);
862
968
  if (files.length === 0) {
863
969
  // No content - render Getting Started page
864
970
  const html = await renderGettingStarted(provenance, config, theme);
@@ -876,7 +982,7 @@ async function main() {
876
982
  }
877
983
 
878
984
  // Find and render markdown/yaml file
879
- const filePath = await findFile(url.pathname, config);
985
+ const filePath = await findFile(url.pathname, config, files);
880
986
  if (filePath) {
881
987
  // If this is an index/readme file and the URL lacks a trailing slash,
882
988
  // redirect so relative links resolve correctly (BUG-003)
@@ -998,9 +1104,22 @@ async function main() {
998
1104
  }
999
1105
  }
1000
1106
 
1001
- // Open browser (macOS)
1107
+ // Open browser (cross-platform)
1002
1108
  if (OPEN_BROWSER) {
1003
- Bun.spawn(["open", serverUrl]);
1109
+ try {
1110
+ if (process.platform === "win32") {
1111
+ // cmd.exe built-in: start
1112
+ // Empty title argument avoids treating URL as window title.
1113
+ Bun.spawn(["cmd", "/c", "start", "", serverUrl]);
1114
+ } else if (process.platform === "darwin") {
1115
+ Bun.spawn(["open", serverUrl]);
1116
+ } else {
1117
+ // Most Linux distros
1118
+ Bun.spawn(["xdg-open", serverUrl]);
1119
+ }
1120
+ } catch {
1121
+ // Non-fatal: server is already running; user can open manually.
1122
+ }
1004
1123
  }
1005
1124
  }
1006
1125
 
@@ -1011,6 +1130,7 @@ export interface DevOptions {
1011
1130
  host?: string;
1012
1131
  open?: boolean;
1013
1132
  logFormat?: string;
1133
+ profile?: string;
1014
1134
  }
1015
1135
 
1016
1136
  export async function dev(options: DevOptions = {}) {
@@ -1029,6 +1149,7 @@ export async function dev(options: DevOptions = {}) {
1029
1149
  if (options.logFormat) {
1030
1150
  LOG_FORMAT = options.logFormat;
1031
1151
  }
1152
+ ACTIVE_PROFILE = options.profile;
1032
1153
  await main();
1033
1154
  }
1034
1155
 
@@ -1042,6 +1163,7 @@ Usage: bun run dev [folder] [options]
1042
1163
  Options:
1043
1164
  -p, --port <number> Port to serve on [env: KITFLY_DEV_PORT] [default: ${DEFAULT_PORT}]
1044
1165
  -H, --host <string> Host to bind to [env: KITFLY_DEV_HOST] [default: ${DEFAULT_HOST}]
1166
+ --profile <name> Active content profile [env: KITFLY_PROFILE]
1045
1167
  -o, --open Open browser on start [env: KITFLY_DEV_OPEN] [default: true]
1046
1168
  --no-open Don't open browser
1047
1169
  --help Show this help message
@@ -1063,5 +1185,6 @@ Examples:
1063
1185
  host: cfg.host,
1064
1186
  open: cfg.open,
1065
1187
  logFormat: cfg.logFormat,
1188
+ profile: cfg.profile,
1066
1189
  }).catch(console.error);
1067
1190
  }