kitfly 0.2.0 → 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 (126) hide show
  1. package/CHANGELOG.md +68 -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/content/reference/slides-authoring-guidelines.md +129 -0
  12. package/dist/_raw/content/reference.md +1 -0
  13. package/dist/_raw/docs/decisions/ADR-0006-data-driven-content.md +350 -0
  14. package/dist/content/deployment/preflight.html +10 -6
  15. package/dist/content/deployment/recipes/aws-s3.html +10 -6
  16. package/dist/content/deployment/recipes/cloudflare-pages.html +10 -6
  17. package/dist/content/deployment/recipes/cloudflare-r2.html +10 -6
  18. package/dist/content/deployment/recipes/fly-io.html +10 -6
  19. package/dist/content/deployment/recipes/github-pages.html +10 -6
  20. package/dist/content/deployment/recipes/netlify.html +10 -6
  21. package/dist/content/deployment/recipes/vercel.html +10 -6
  22. package/dist/content/deployment/secrets-and-env-vars.html +10 -6
  23. package/dist/content/deployment.html +10 -6
  24. package/dist/content/guide/approaches.html +10 -6
  25. package/dist/content/guide/branding.html +510 -0
  26. package/dist/content/guide/data-driven-content.html +543 -0
  27. package/dist/content/guide/features.html +10 -6
  28. package/dist/content/guide/getting-started.html +10 -6
  29. package/dist/content/guide/kitfly-overview.html +10 -6
  30. package/dist/content/reference/configuration.html +135 -9
  31. package/dist/content/reference/design-catalog.html +10 -6
  32. package/dist/content/reference/environment-variables.html +50 -8
  33. package/dist/content/reference/glossary.html +24 -8
  34. package/dist/content/reference/key-concepts.html +33 -9
  35. package/dist/content/reference/plugins.html +22 -7
  36. package/dist/content/reference/slides-authoring-guidelines.html +422 -0
  37. package/dist/content/reference/structure.html +10 -6
  38. package/dist/content/reference.html +11 -6
  39. package/dist/content/templates/crucible.html +10 -6
  40. package/dist/content/templates/handbook.html +10 -6
  41. package/dist/content/templates/minimal.html +10 -6
  42. package/dist/content/templates/overview.html +10 -6
  43. package/dist/content/templates/pipeline.html +10 -6
  44. package/dist/content/templates/productbook.html +10 -6
  45. package/dist/content/templates/runbook.html +10 -6
  46. package/dist/content/templates/servicebook.html +10 -6
  47. package/dist/content-index.json +38 -2
  48. package/dist/docs/decisions/ADR-0001-minimalist-site-code.html +10 -6
  49. package/dist/docs/decisions/ADR-0002-ai-accessibility.html +10 -6
  50. package/dist/docs/decisions/ADR-0003-single-file-bundle.html +10 -6
  51. package/dist/docs/decisions/ADR-0004-bun-runtime.html +10 -6
  52. package/dist/docs/decisions/ADR-0005-plugin-contract-and-distribution.html +10 -6
  53. package/dist/docs/decisions/ADR-0006-data-driven-content.html +752 -0
  54. package/dist/docs/decisions/DDR-0001-viewport-locked-layout.html +10 -6
  55. package/dist/docs/decisions/DDR-0002-theme-system.html +10 -6
  56. package/dist/docs/decisions/DDR-0003-bounded-logo-slot.html +10 -6
  57. package/dist/docs/decisions/DDR-0004-slides-rendering-model.html +10 -6
  58. package/dist/docs/decisions/DDR-0005-deterministic-layout-boundary.html +10 -6
  59. package/dist/docs/userguide/cli/build.html +10 -6
  60. package/dist/docs/userguide/cli/bundle.html +10 -6
  61. package/dist/docs/userguide/cli/dev.html +10 -6
  62. package/dist/docs/userguide/cli/init.html +10 -6
  63. package/dist/docs/userguide/cli/servers.html +10 -6
  64. package/dist/docs/userguide/cli/stop.html +10 -6
  65. package/dist/docs/userguide/cli/update.html +10 -6
  66. package/dist/docs/userguide/cli/version.html +10 -6
  67. package/dist/docs/userguide/cli.html +10 -6
  68. package/dist/docs/userguide/sharing.html +10 -6
  69. package/dist/index.html +10 -6
  70. package/dist/llms.txt +3 -3
  71. package/dist/provenance.json +4 -4
  72. package/dist/schemas/plugin-registry.schema.html +10 -6
  73. package/dist/schemas/plugin-schemas-notes.html +10 -6
  74. package/dist/schemas/plugin.schema.html +10 -6
  75. package/dist/schemas/plugins.schema.html +10 -6
  76. package/dist/schemas/v0/common.schema.html +14 -10
  77. package/dist/schemas/v0/plugin-registry.schema.html +13 -9
  78. package/dist/schemas/v0/plugin.schema.html +13 -9
  79. package/dist/schemas/v0/plugins.schema.html +13 -9
  80. package/dist/schemas/v0/site.schema.html +67 -7
  81. package/dist/schemas/v0/theme.schema.html +21 -17
  82. package/dist/schemas.html +10 -6
  83. package/dist/styles.css +39 -4
  84. package/package.json +1 -1
  85. package/plugins-dist/latex-runtime.js +140 -0
  86. package/plugins-dist/latex.js +178 -0
  87. package/plugins-dist/slides-charts-lite-runtime.js +179 -0
  88. package/plugins-dist/slides-charts-lite.js +198 -0
  89. package/plugins-dist/slides-visuals.css +166 -0
  90. package/plugins-dist/slides-visuals.js +124 -33
  91. package/registry/plugins.yaml +30 -5
  92. package/schemas/v0/site.schema.json +56 -0
  93. package/scripts/build.ts +195 -70
  94. package/scripts/bundle.ts +122 -11
  95. package/scripts/dev.ts +345 -178
  96. package/src/__tests__/brief.test.ts +151 -0
  97. package/src/__tests__/build.test.ts +234 -4
  98. package/src/__tests__/bundle.test.ts +134 -0
  99. package/src/__tests__/dev-plugin-errors.test.ts +20 -0
  100. package/src/__tests__/fixtures/fences/slides-visuals/invalid/flow-branching-no-source.md +5 -0
  101. package/src/__tests__/fixtures/fences/slides-visuals/invalid/flow-converging-no-target.md +6 -0
  102. package/src/__tests__/fixtures/fences/slides-visuals/invalid/staircase-empty-steps.md +3 -0
  103. package/src/__tests__/fixtures/fences/slides-visuals/invalid/timeline-horizontal-no-events.md +2 -0
  104. package/src/__tests__/fixtures/fences/slides-visuals/valid/flow-branching-no-split.md +7 -0
  105. package/src/__tests__/fixtures/fences/slides-visuals/valid/flow-branching.md +8 -0
  106. package/src/__tests__/fixtures/fences/slides-visuals/valid/flow-converging-no-merge.md +7 -0
  107. package/src/__tests__/fixtures/fences/slides-visuals/valid/flow-converging.md +8 -0
  108. package/src/__tests__/fixtures/fences/slides-visuals/valid/staircase-down.md +7 -0
  109. package/src/__tests__/fixtures/fences/slides-visuals/valid/staircase.md +8 -0
  110. package/src/__tests__/fixtures/fences/slides-visuals/valid/timeline-horizontal.md +9 -0
  111. package/src/__tests__/fixtures/fences/slides-visuals/valid/timeline-vertical.md +10 -0
  112. package/src/__tests__/init.test.ts +51 -2
  113. package/src/__tests__/latex-runtime.bun.test.ts +35 -0
  114. package/src/__tests__/shared.test.ts +621 -1
  115. package/src/__tests__/slides-charts-lite-runtime.bun.test.ts +45 -0
  116. package/src/__tests__/slides-visuals-runtime-regressions.bun.test.ts +33 -0
  117. package/src/cli.ts +11 -4
  118. package/src/commands/init.ts +1 -1
  119. package/src/shared.ts +761 -18
  120. package/src/site/styles.css +39 -4
  121. package/src/site/template.html +5 -2
  122. package/src/templates/brief.ts +486 -0
  123. package/src/templates/deck.ts +59 -0
  124. package/src/templates/driver.ts +46 -13
  125. package/src/templates/handbook.ts +32 -0
  126. 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
@@ -18,14 +19,23 @@ import { readdir, readFile, stat } from "node:fs/promises";
18
19
  import { basename, extname, join, resolve } from "node:path";
19
20
  import { marked, Renderer } from "marked";
20
21
  import { ENGINE_ASSETS_DIR, ENGINE_SITE_DIR } from "../src/engine.ts";
21
- import { loadPluginInjections } from "../src/plugin-loader.ts";
22
+ import {
23
+ loadPluginInjections,
24
+ PluginConfigError,
25
+ PluginIntegrityError,
26
+ PluginNetworkError,
27
+ PluginPolicyError,
28
+ } from "../src/plugin-loader.ts";
22
29
  import {
23
30
  buildBreadcrumbsSimple,
24
31
  buildFooter,
32
+ buildLogoImgHtml,
25
33
  buildNavSimple,
26
34
  buildPageMeta,
27
- buildSlideNav,
35
+ buildSlideNavHierarchical,
28
36
  buildToc,
37
+ // Types
38
+ type ContentFile,
29
39
  // Network utilities
30
40
  checkPortOrExit,
31
41
  // Navigation/template building
@@ -37,21 +47,26 @@ import {
37
47
  envString,
38
48
  // Formatting
39
49
  escapeHtml,
50
+ filterByProfile,
51
+ filterUnknownSlidesVisualsTypeDiagnostics,
40
52
  // Provenance
41
53
  generateProvenance,
54
+ loadDataBindings,
42
55
  // YAML/Config parsing
43
56
  loadSiteConfig,
57
+ mergeFrontmatterWithBody,
44
58
  type Provenance,
59
+ pagePathForData,
45
60
  // Markdown utilities
46
61
  parseFrontmatter,
47
62
  parseYaml,
63
+ resolveBindings,
48
64
  resolveStylesPath,
49
65
  resolveTemplatePath,
50
66
  rewriteRelativeAssetUrls,
51
- // Types
67
+ runPrebuildHooks,
52
68
  type SiteConfig,
53
69
  slugify,
54
- toUrlPath,
55
70
  validatePath,
56
71
  validateSlidesVisualsFences,
57
72
  } from "../src/shared.ts";
@@ -66,6 +81,7 @@ let HOST = DEFAULT_HOST;
66
81
  let ROOT = process.cwd();
67
82
  let OPEN_BROWSER = true;
68
83
  let LOG_FORMAT = ""; // "structured" when invoked by CLI daemon
84
+ let ACTIVE_PROFILE: string | undefined;
69
85
 
70
86
  // Structured logger for daemon mode — set during main() init.
71
87
  // When null, all output goes through console.log (standalone mode).
@@ -75,6 +91,86 @@ let daemonLog: {
75
91
  error: (msg: string) => void;
76
92
  } | null = null;
77
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
+
120
+ function isPluginLoaderError(error: unknown): error is Error {
121
+ return (
122
+ error instanceof PluginConfigError ||
123
+ error instanceof PluginIntegrityError ||
124
+ error instanceof PluginPolicyError ||
125
+ error instanceof PluginNetworkError
126
+ );
127
+ }
128
+
129
+ function pluginVersionMismatchHint(message: string): string {
130
+ const m = message.match(/^Plugin ([a-z0-9-]+) version mismatch: ([^ ]+) != ([^ ]+)$/i);
131
+ if (!m) return "";
132
+ const pluginId = m[1];
133
+ const expected = m[3];
134
+ return `Update <code>kitfly.plugins.yaml</code> to <code>${pluginId}@${expected}</code>, then refresh.`;
135
+ }
136
+
137
+ export function buildDevPluginErrorHtml(message: string): string {
138
+ const hint = pluginVersionMismatchHint(message);
139
+ const safeMessage = escapeHtml(message);
140
+ const hintBlock = hint
141
+ ? `<p>${hint}</p>`
142
+ : "<p>Check <code>kitfly.plugins.yaml</code> and <code>registry/plugins.yaml</code>, then refresh.</p>";
143
+ return `<!doctype html>
144
+ <html lang="en">
145
+ <head>
146
+ <meta charset="utf-8">
147
+ <meta name="viewport" content="width=device-width, initial-scale=1">
148
+ <title>Plugin Configuration Error</title>
149
+ <style>
150
+ body { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; margin: 0; background: #0b1020; color: #e8ecf3; }
151
+ main { max-width: 820px; margin: 8vh auto; padding: 1.25rem; }
152
+ .card { background: #131a2e; border: 1px solid #2a3557; border-radius: 12px; padding: 1rem 1.1rem; }
153
+ h1 { margin: 0 0 0.75rem; font-size: 1.25rem; }
154
+ p, li { line-height: 1.5; }
155
+ code { background: #0e1528; padding: 0.08rem 0.3rem; border-radius: 6px; border: 1px solid #2a3557; }
156
+ pre { margin: 0.8rem 0 0; padding: 0.75rem; background: #0e1528; border: 1px solid #2a3557; border-radius: 8px; overflow: auto; }
157
+ .muted { color: #b5bfd2; font-size: 0.92rem; }
158
+ </style>
159
+ </head>
160
+ <body>
161
+ <main>
162
+ <div class="card">
163
+ <h1>Plugin setup error</h1>
164
+ <p>Kitfly could not load one or more plugins for dev preview.</p>
165
+ ${hintBlock}
166
+ <pre><code>${safeMessage}</code></pre>
167
+ <p class="muted">After updating config, refresh this page. No dev server restart required.</p>
168
+ </div>
169
+ </main>
170
+ </body>
171
+ </html>`;
172
+ }
173
+
78
174
  /** Log info — uses structured logger in daemon mode, console.log otherwise */
79
175
  function logInfo(msg: string): void {
80
176
  if (daemonLog) daemonLog.info(msg);
@@ -97,6 +193,7 @@ interface ParsedArgs {
97
193
  open?: boolean;
98
194
  folder?: string;
99
195
  logFormat?: string;
196
+ profile?: string;
100
197
  }
101
198
 
102
199
  function parseArgs(argv: string[]): ParsedArgs {
@@ -114,6 +211,9 @@ function parseArgs(argv: string[]): ParsedArgs {
114
211
  } else if (arg === "--log-format") {
115
212
  result.logFormat = next;
116
213
  i++;
214
+ } else if (arg === "--profile" && next && !next.startsWith("-")) {
215
+ result.profile = next;
216
+ i++;
117
217
  } else if (arg === "--open" || arg === "-o") {
118
218
  result.open = true;
119
219
  } else if (arg === "--no-open") {
@@ -131,6 +231,7 @@ function getConfig(): {
131
231
  open: boolean;
132
232
  folder?: string;
133
233
  logFormat?: string;
234
+ profile?: string;
134
235
  } {
135
236
  const args = parseArgs(process.argv.slice(2));
136
237
  return {
@@ -139,9 +240,15 @@ function getConfig(): {
139
240
  open: args.open ?? envBool("KITFLY_DEV_OPEN", true),
140
241
  folder: args.folder,
141
242
  logFormat: args.logFormat,
243
+ profile: args.profile ?? process.env.KITFLY_PROFILE,
142
244
  };
143
245
  }
144
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
+
145
252
  function getContentType(filePath: string): string {
146
253
  const ext = extname(filePath).toLowerCase();
147
254
  switch (ext) {
@@ -288,7 +395,7 @@ async function renderPage(
288
395
  }
289
396
  htmlContent = `<h1>${title}</h1>\n<pre><code class="language-json">${escapeHtml(prettyJson)}</code></pre>`;
290
397
  } else {
291
- const { frontmatter, body } = parseFrontmatter(content);
398
+ const { frontmatter, body } = await applyDataBindingsToMarkdown(content, filePath, config);
292
399
  if (frontmatter.title) {
293
400
  title = frontmatter.title as string;
294
401
  }
@@ -296,16 +403,16 @@ async function renderPage(
296
403
  htmlContent = marked.parse(body) as string;
297
404
  }
298
405
 
299
- const files = await collectFiles(ROOT, config);
406
+ const files = await getFilteredFiles(config);
300
407
  const currentUrlPath = urlPath.slice(1).replace(/\.html$/, "");
408
+ const pathPrefix = "/";
301
409
  const nav = buildNavSimple(files, config, currentUrlPath);
302
- const footer = buildFooter(provenance, config);
410
+ const footer = buildFooter(provenance, config, pathPrefix);
303
411
  const breadcrumbs = buildBreadcrumbsSimple(urlPath, files, config);
304
412
  const toc = buildToc(htmlContent);
305
413
  const brandTarget = config.brand.external ? ' target="_blank" rel="noopener"' : "";
306
414
  const themeCSS = generateThemeCSS(theme);
307
415
  const prismUrls = getPrismUrls(theme);
308
- const pathPrefix = "/";
309
416
  const plugins = await getPluginInjectionsCached(config.mode === "slides" ? "slides" : "docs");
310
417
 
311
418
  const hotReloadScript = `
@@ -317,33 +424,51 @@ async function renderPage(
317
424
 
318
425
  const logoClass = config.brand.logoType === "wordmark" ? "logo-wordmark" : "logo-icon";
319
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
+ });
320
443
 
321
444
  return template
322
445
  .replace("{{BODY_CLASS}}", "mode-docs")
323
- .replace(/\{\{PATH_PREFIX\}\}/g, pathPrefix)
324
- .replace(/\{\{BRAND_URL\}\}/g, config.brand.url)
325
- .replace(/\{\{BRAND_TARGET\}\}/g, brandTarget)
326
- .replace(/\{\{BRAND_NAME\}\}/g, config.brand.name)
327
- .replace(/\{\{BRAND_INITIAL\}\}/g, brandInitial)
328
- .replace(/\{\{BRAND_LOGO\}\}/g, config.brand.logo || "assets/brand/logo.png")
329
- .replace(/\{\{BRAND_FAVICON\}\}/g, config.brand.favicon || "assets/brand/favicon.png")
330
- .replace(/\{\{BRAND_LOGO_CLASS\}\}/g, logoClass)
331
- .replace(/\{\{SITE_TITLE\}\}/g, config.title)
332
- .replace("{{TITLE}}", title)
333
- .replace("{{VERSION}}", uiVersion)
334
- .replace("{{BRANCH}}", provenance.gitBranch)
335
- .replace("{{BREADCRUMBS}}", breadcrumbs)
336
- .replace("{{PAGE_META}}", pageMeta)
337
- .replace("{{NAV}}", nav)
338
- .replace("{{CONTENT}}", htmlContent)
339
- .replace("{{TOC}}", toc)
340
- .replace("{{FOOTER}}", footer)
341
- .replace("{{THEME_CSS}}", themeCSS)
342
- .replace("{{PLUGIN_HEAD}}", plugins.head)
343
- .replace("{{PLUGIN_BODY_END}}", plugins.bodyEnd)
344
- .replace("{{PRISM_LIGHT_URL}}", prismUrls.light)
345
- .replace("{{PRISM_DARK_URL}}", prismUrls.dark)
346
- .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);
347
472
  }
348
473
 
349
474
  async function renderSlidesPage(
@@ -353,8 +478,10 @@ async function renderSlidesPage(
353
478
  ): Promise<string> {
354
479
  const uiVersion = provenance.version ? `v${provenance.version}` : "unversioned";
355
480
  const template = await readFile(await resolveTemplatePath(ROOT), "utf-8");
356
- const files = await collectFiles(ROOT, config);
357
- 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
+ });
358
485
 
359
486
  if (slides.length === 0) {
360
487
  return renderGettingStarted(provenance, config, theme);
@@ -367,7 +494,9 @@ async function renderSlidesPage(
367
494
  let inner = "";
368
495
  if (slide.kind === "markdown") {
369
496
  if (validateFences) {
370
- const diagnostics = validateSlidesVisualsFences(slide.body);
497
+ const diagnostics = filterUnknownSlidesVisualsTypeDiagnostics(
498
+ validateSlidesVisualsFences(slide.body),
499
+ );
371
500
  if (diagnostics.length) {
372
501
  const msg = diagnostics
373
502
  .slice(0, 12)
@@ -413,8 +542,8 @@ async function renderSlidesPage(
413
542
  </div>
414
543
  </div>`;
415
544
 
416
- const nav = buildSlideNav(slides, config, "slide-1");
417
- const footer = buildFooter(provenance, config);
545
+ const nav = buildSlideNavHierarchical(slides, config, "slide-1");
546
+ const footer = buildFooter(provenance, config, pathPrefix);
418
547
  const brandTarget = config.brand.external ? ' target="_blank" rel="noopener"' : "";
419
548
  const themeCSS = generateThemeCSS(theme);
420
549
  const prismUrls = getPrismUrls(theme);
@@ -427,33 +556,51 @@ async function renderSlidesPage(
427
556
  const plugins = await getPluginInjectionsCached("slides");
428
557
  const logoClass = config.brand.logoType === "wordmark" ? "logo-wordmark" : "logo-icon";
429
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
+ });
430
575
 
431
576
  return template
432
577
  .replace("{{BODY_CLASS}}", "mode-slides")
433
- .replace(/\{\{PATH_PREFIX\}\}/g, pathPrefix)
434
- .replace(/\{\{BRAND_URL\}\}/g, config.brand.url)
435
- .replace(/\{\{BRAND_TARGET\}\}/g, brandTarget)
436
- .replace(/\{\{BRAND_NAME\}\}/g, config.brand.name)
437
- .replace(/\{\{BRAND_INITIAL\}\}/g, brandInitial)
438
- .replace(/\{\{BRAND_LOGO\}\}/g, config.brand.logo || "assets/brand/logo.png")
439
- .replace(/\{\{BRAND_FAVICON\}\}/g, config.brand.favicon || "assets/brand/favicon.png")
440
- .replace(/\{\{BRAND_LOGO_CLASS\}\}/g, logoClass)
441
- .replace(/\{\{SITE_TITLE\}\}/g, config.title)
442
- .replace("{{TITLE}}", config.title)
443
- .replace("{{VERSION}}", uiVersion)
444
- .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)
445
592
  .replace("{{BREADCRUMBS}}", "")
446
593
  .replace("{{PAGE_META}}", "")
447
- .replace("{{NAV}}", nav)
448
- .replace("{{CONTENT}}", htmlContent)
594
+ .replace("{{NAV}}", () => nav)
595
+ .replace("{{CONTENT}}", () => htmlContent)
449
596
  .replace("{{TOC}}", "")
450
- .replace("{{FOOTER}}", footer)
451
- .replace("{{THEME_CSS}}", themeCSS)
452
- .replace("{{PLUGIN_HEAD}}", plugins.head)
453
- .replace("{{PLUGIN_BODY_END}}", plugins.bodyEnd)
454
- .replace("{{PRISM_LIGHT_URL}}", prismUrls.light)
455
- .replace("{{PRISM_DARK_URL}}", prismUrls.dark)
456
- .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);
457
604
  }
458
605
 
459
606
  // Render Getting Started page when no config
@@ -502,33 +649,51 @@ sections:
502
649
 
503
650
  const logoClass = config.brand.logoType === "wordmark" ? "logo-wordmark" : "logo-icon";
504
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
+ });
505
668
 
506
669
  return template
507
670
  .replace("{{BODY_CLASS}}", "mode-docs")
508
- .replace(/\{\{PATH_PREFIX\}\}/g, pathPrefix)
509
- .replace(/\{\{BRAND_URL\}\}/g, config.brand.url)
510
- .replace(/\{\{BRAND_TARGET\}\}/g, brandTarget)
511
- .replace(/\{\{BRAND_NAME\}\}/g, config.brand.name)
512
- .replace(/\{\{BRAND_INITIAL\}\}/g, brandInitial)
513
- .replace(/\{\{BRAND_LOGO\}\}/g, config.brand.logo || "assets/brand/logo.png")
514
- .replace(/\{\{BRAND_FAVICON\}\}/g, config.brand.favicon || "assets/brand/favicon.png")
515
- .replace(/\{\{BRAND_LOGO_CLASS\}\}/g, logoClass)
516
- .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)
517
682
  .replace("{{TITLE}}", "Getting Started")
518
- .replace("{{VERSION}}", uiVersion)
519
- .replace("{{BRANCH}}", provenance.gitBranch)
683
+ .replace("{{VERSION}}", () => uiVersion)
684
+ .replace("{{BRANCH}}", () => provenance.gitBranch)
520
685
  .replace("{{BREADCRUMBS}}", "")
521
686
  .replace("{{PAGE_META}}", "")
522
687
  .replace("{{NAV}}", "<ul></ul>")
523
- .replace("{{CONTENT}}", htmlContent)
688
+ .replace("{{CONTENT}}", () => htmlContent)
524
689
  .replace("{{TOC}}", "")
525
- .replace("{{FOOTER}}", buildFooter(provenance, config))
526
- .replace("{{THEME_CSS}}", themeCSS)
527
- .replace("{{PLUGIN_HEAD}}", plugins.head)
528
- .replace("{{PLUGIN_BODY_END}}", plugins.bodyEnd)
529
- .replace("{{PRISM_LIGHT_URL}}", prismUrls.light)
530
- .replace("{{PRISM_DARK_URL}}", prismUrls.dark)
531
- .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);
532
697
  }
533
698
 
534
699
  async function tryServeFile(filePath: string): Promise<Response | null> {
@@ -563,9 +728,11 @@ async function tryServeContentAsset(
563
728
  }
564
729
 
565
730
  // Find file for a URL path
566
- async function findFile(urlPath: string, config: SiteConfig): Promise<string | null> {
567
- const { stat } = await import("node:fs/promises");
568
-
731
+ async function findFile(
732
+ urlPath: string,
733
+ config: SiteConfig,
734
+ files: ContentFile[],
735
+ ): Promise<string | null> {
569
736
  // Remove leading slash and .html extension (for compatibility with built links)
570
737
  const path = urlPath.slice(1).replace(/\.html$/, "") || "";
571
738
 
@@ -573,84 +740,20 @@ async function findFile(urlPath: string, config: SiteConfig): Promise<string | n
573
740
  if (!path) {
574
741
  if (config.home) {
575
742
  const homePath = validatePath(ROOT, config.docroot, config.home, true);
576
- if (homePath) {
577
- try {
578
- await stat(homePath);
579
- return homePath;
580
- } catch {
581
- // Home file not found, fall through
582
- }
583
- }
743
+ const homeFile = homePath ? files.find((file) => file.path === homePath) : undefined;
744
+ if (homeFile) return homeFile.path;
584
745
  }
585
746
  // Fallback to first file
586
- const files = await collectFiles(ROOT, config);
587
747
  return files.length > 0 ? files[0].path : null;
588
748
  }
589
749
 
590
- // Check configured sections
591
- for (const section of config.sections) {
592
- const sectionPath = validatePath(ROOT, config.docroot, section.path, true);
593
- if (!sectionPath) continue;
594
-
595
- if (section.files) {
596
- // Check explicit files
597
- for (const file of section.files) {
598
- const name = file.replace(/\.(md|yaml|json)$/, "").toLowerCase();
599
- if (name === path) {
600
- const filePath = join(sectionPath, file);
601
- try {
602
- await stat(filePath);
603
- return filePath;
604
- } catch {
605
- // Continue
606
- }
607
- }
608
- }
609
- } else {
610
- // Check directory for matching file (supports nested paths)
611
- const urlBase = toUrlPath(ROOT, sectionPath);
612
- if (path.startsWith(`${urlBase}/`) || path === urlBase) {
613
- const relPath = path === urlBase ? "" : path.slice(urlBase.length + 1);
614
- // Guard against path traversal
615
- if (relPath.includes("..")) continue;
616
- const extensions = [".md", ".yaml", ".json"];
617
-
618
- if (relPath === "") {
619
- // Section root URL — try index file
620
- for (const ext of extensions) {
621
- const filePath = join(sectionPath, `index${ext}`);
622
- try {
623
- await stat(filePath);
624
- return filePath;
625
- } catch {
626
- // Continue
627
- }
628
- }
629
- } else {
630
- // Try direct file match at nested path
631
- for (const ext of extensions) {
632
- const filePath = join(sectionPath, relPath + ext);
633
- try {
634
- await stat(filePath);
635
- return filePath;
636
- } catch {
637
- // Continue
638
- }
639
- }
640
- // Try as directory with index file
641
- for (const ext of extensions) {
642
- const filePath = join(sectionPath, relPath, `index${ext}`);
643
- try {
644
- await stat(filePath);
645
- return filePath;
646
- } catch {
647
- // Continue
648
- }
649
- }
650
- }
651
- }
652
- }
653
- }
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;
654
757
 
655
758
  return null;
656
759
  }
@@ -687,17 +790,35 @@ function startWatcher(config: SiteConfig) {
687
790
  for (const dir of watchDirs) {
688
791
  try {
689
792
  watch(dir, { recursive: true }, (_event, filename) => {
690
- if (
691
- filename &&
692
- (filename.endsWith(".md") ||
693
- filename.endsWith(".yaml") ||
694
- filename.endsWith(".json") ||
695
- filename.endsWith(".html") ||
696
- filename.endsWith(".css"))
697
- ) {
698
- logInfo(`File changed: ${filename}`);
699
- notifyReload();
700
- }
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
+ })();
701
822
  });
702
823
  } catch {
703
824
  // Directory doesn't exist, skip
@@ -721,6 +842,10 @@ async function main() {
721
842
  // Load configuration
722
843
  const config = await loadSiteConfig(ROOT);
723
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
+ }
724
849
 
725
850
  // Apply server config from site.yaml if CLI didn't override
726
851
  if (config.server?.port && PORT === DEFAULT_PORT) {
@@ -795,7 +920,7 @@ async function main() {
795
920
  if (assetResponse) return assetResponse;
796
921
 
797
922
  // Check for content
798
- const files = await collectFiles(ROOT, config);
923
+ const files = await getFilteredFiles(config);
799
924
  if (files.length === 0) {
800
925
  // No content - render Getting Started page
801
926
  const html = await renderGettingStarted(provenance, config, theme);
@@ -813,7 +938,7 @@ async function main() {
813
938
  }
814
939
 
815
940
  // Find and render markdown/yaml file
816
- const filePath = await findFile(url.pathname, config);
941
+ const filePath = await findFile(url.pathname, config, files);
817
942
  if (filePath) {
818
943
  // If this is an index/readme file and the URL lacks a trailing slash,
819
944
  // redirect so relative links resolve correctly (BUG-003)
@@ -854,19 +979,44 @@ async function main() {
854
979
  return new Response("Not found", { status: 404 });
855
980
  }
856
981
 
857
- // Wrap with request logging middleware when in structured log mode
858
- const fetch = daemonLog
859
- ? async (req: Request) => {
860
- const start = performance.now();
861
- const response = await handleRequest(req);
982
+ // Wrap with request logging + friendly plugin errors.
983
+ const fetch = async (req: Request) => {
984
+ const start = performance.now();
985
+ const url = new URL(req.url);
986
+ try {
987
+ const response = await handleRequest(req);
988
+ if (daemonLog && url.pathname !== "/__reload") {
862
989
  const duration = (performance.now() - start).toFixed(0);
863
- const url = new URL(req.url);
864
- if (url.pathname !== "/__reload") {
865
- daemonLog?.info(`${req.method} ${url.pathname} ${response.status} ${duration}ms`);
990
+ daemonLog.info(`${req.method} ${url.pathname} ${response.status} ${duration}ms`);
991
+ }
992
+ return response;
993
+ } catch (error) {
994
+ const duration = (performance.now() - start).toFixed(0);
995
+ const message = error instanceof Error ? error.message : String(error);
996
+ if (isPluginLoaderError(error)) {
997
+ if (daemonLog && url.pathname !== "/__reload") {
998
+ daemonLog.warn(
999
+ `${req.method} ${url.pathname} 500 ${duration}ms plugin error: ${message}`,
1000
+ );
1001
+ } else if (!daemonLog) {
1002
+ logWarn(`Plugin error: ${message}`);
866
1003
  }
867
- return response;
1004
+ return new Response(buildDevPluginErrorHtml(message), {
1005
+ status: 500,
1006
+ headers: { "Content-Type": "text/html; charset=utf-8" },
1007
+ });
1008
+ }
1009
+ if (daemonLog && url.pathname !== "/__reload") {
1010
+ daemonLog.error(`${req.method} ${url.pathname} 500 ${duration}ms ${message}`);
1011
+ } else if (!daemonLog) {
1012
+ console.error(error);
868
1013
  }
869
- : handleRequest;
1014
+ return new Response("Internal server error", {
1015
+ status: 500,
1016
+ headers: { "Content-Type": "text/plain; charset=utf-8" },
1017
+ });
1018
+ }
1019
+ };
870
1020
 
871
1021
  // Create server
872
1022
  Bun.serve({
@@ -910,9 +1060,22 @@ async function main() {
910
1060
  }
911
1061
  }
912
1062
 
913
- // Open browser (macOS)
1063
+ // Open browser (cross-platform)
914
1064
  if (OPEN_BROWSER) {
915
- 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
+ }
916
1079
  }
917
1080
  }
918
1081
 
@@ -923,6 +1086,7 @@ export interface DevOptions {
923
1086
  host?: string;
924
1087
  open?: boolean;
925
1088
  logFormat?: string;
1089
+ profile?: string;
926
1090
  }
927
1091
 
928
1092
  export async function dev(options: DevOptions = {}) {
@@ -941,6 +1105,7 @@ export async function dev(options: DevOptions = {}) {
941
1105
  if (options.logFormat) {
942
1106
  LOG_FORMAT = options.logFormat;
943
1107
  }
1108
+ ACTIVE_PROFILE = options.profile;
944
1109
  await main();
945
1110
  }
946
1111
 
@@ -954,6 +1119,7 @@ Usage: bun run dev [folder] [options]
954
1119
  Options:
955
1120
  -p, --port <number> Port to serve on [env: KITFLY_DEV_PORT] [default: ${DEFAULT_PORT}]
956
1121
  -H, --host <string> Host to bind to [env: KITFLY_DEV_HOST] [default: ${DEFAULT_HOST}]
1122
+ --profile <name> Active content profile [env: KITFLY_PROFILE]
957
1123
  -o, --open Open browser on start [env: KITFLY_DEV_OPEN] [default: true]
958
1124
  --no-open Don't open browser
959
1125
  --help Show this help message
@@ -975,5 +1141,6 @@ Examples:
975
1141
  host: cfg.host,
976
1142
  open: cfg.open,
977
1143
  logFormat: cfg.logFormat,
1144
+ profile: cfg.profile,
978
1145
  }).catch(console.error);
979
1146
  }