kitfly 0.1.2 → 0.2.1

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 (209) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +63 -16
  3. package/VERSION +1 -1
  4. package/dist/_raw/content/deployment/preflight.md +134 -0
  5. package/dist/_raw/content/deployment/recipes/aws-s3.md +128 -0
  6. package/dist/_raw/content/deployment/recipes/cloudflare-pages.md +73 -0
  7. package/dist/_raw/content/deployment/recipes/cloudflare-r2.md +156 -0
  8. package/dist/_raw/content/deployment/recipes/fly-io.md +57 -0
  9. package/dist/_raw/content/deployment/recipes/github-pages.md +112 -0
  10. package/dist/_raw/content/deployment/recipes/netlify.md +99 -0
  11. package/dist/_raw/content/deployment/recipes/vercel.md +88 -0
  12. package/dist/_raw/content/deployment/secrets-and-env-vars.md +75 -0
  13. package/dist/_raw/content/deployment.md +128 -0
  14. package/dist/_raw/content/guide/approaches.md +182 -0
  15. package/dist/_raw/content/guide/features.md +121 -0
  16. package/dist/_raw/content/guide/getting-started.md +112 -0
  17. package/dist/_raw/content/guide/kitfly-overview.md +209 -0
  18. package/dist/_raw/content/reference/configuration.md +259 -0
  19. package/dist/_raw/content/reference/design-catalog.md +167 -0
  20. package/dist/_raw/content/reference/environment-variables.md +66 -0
  21. package/dist/_raw/content/reference/glossary.md +92 -0
  22. package/dist/_raw/content/reference/key-concepts.md +118 -0
  23. package/dist/_raw/content/reference/plugins.md +220 -0
  24. package/dist/_raw/content/reference/slides-authoring-guidelines.md +129 -0
  25. package/dist/_raw/content/reference/structure.md +166 -0
  26. package/dist/_raw/content/reference.md +20 -0
  27. package/dist/_raw/content/templates/crucible.md +192 -0
  28. package/dist/_raw/content/templates/handbook.md +83 -0
  29. package/dist/_raw/content/templates/minimal.md +138 -0
  30. package/dist/_raw/content/templates/overview.md +187 -0
  31. package/dist/_raw/content/templates/pipeline.md +151 -0
  32. package/dist/_raw/content/templates/productbook.md +187 -0
  33. package/dist/_raw/content/templates/runbook.md +193 -0
  34. package/dist/_raw/content/templates/servicebook.md +163 -0
  35. package/dist/_raw/docs/decisions/ADR-0001-minimalist-site-code.md +118 -0
  36. package/dist/_raw/docs/decisions/ADR-0002-ai-accessibility.md +153 -0
  37. package/dist/_raw/docs/decisions/ADR-0003-single-file-bundle.md +93 -0
  38. package/dist/_raw/docs/decisions/ADR-0004-bun-runtime.md +98 -0
  39. package/dist/_raw/docs/decisions/ADR-0005-plugin-contract-and-distribution.md +110 -0
  40. package/dist/_raw/docs/decisions/DDR-0001-viewport-locked-layout.md +111 -0
  41. package/dist/_raw/docs/decisions/DDR-0002-theme-system.md +131 -0
  42. package/dist/_raw/docs/decisions/DDR-0003-bounded-logo-slot.md +106 -0
  43. package/dist/_raw/docs/decisions/DDR-0004-slides-rendering-model.md +113 -0
  44. package/dist/_raw/docs/decisions/DDR-0005-deterministic-layout-boundary.md +107 -0
  45. package/dist/_raw/docs/userguide/cli/build.md +85 -0
  46. package/dist/_raw/docs/userguide/cli/bundle.md +81 -0
  47. package/dist/_raw/docs/userguide/cli/dev.md +92 -0
  48. package/dist/_raw/docs/userguide/cli/init.md +116 -0
  49. package/dist/_raw/docs/userguide/cli/servers.md +69 -0
  50. package/dist/_raw/docs/userguide/cli/stop.md +76 -0
  51. package/dist/_raw/docs/userguide/cli/update.md +78 -0
  52. package/dist/_raw/docs/userguide/cli/version.md +65 -0
  53. package/dist/_raw/docs/userguide/cli.md +34 -0
  54. package/dist/_raw/docs/userguide/sharing.md +94 -0
  55. package/dist/_raw/schemas/plugin-schemas-notes.md +71 -0
  56. package/dist/_raw/schemas.md +42 -0
  57. package/dist/assets/brand/kitfly-favicon-32.png +0 -0
  58. package/dist/assets/brand/kitfly-icon-64.png +0 -0
  59. package/dist/assets/brand/kitfly-logo-128.png +0 -0
  60. package/dist/assets/brand/kitfly-logo-512.png +0 -0
  61. package/dist/assets/brand/kitfly-logo.svg +12132 -0
  62. package/dist/assets/brand/kitfly-neon-128.png +0 -0
  63. package/dist/assets/brand/kitfly-neon-192.png +0 -0
  64. package/dist/assets/brand/kitfly-neon-256.png +0 -0
  65. package/dist/assets/brand/kitfly-neon.png +0 -0
  66. package/dist/assets/brand/palette.md +75 -0
  67. package/dist/content/deployment/index.html +11 -0
  68. package/dist/content/deployment/preflight.html +418 -0
  69. package/dist/content/deployment/recipes/aws-s3.html +421 -0
  70. package/dist/content/deployment/recipes/cloudflare-pages.html +372 -0
  71. package/dist/content/deployment/recipes/cloudflare-r2.html +443 -0
  72. package/dist/content/deployment/recipes/fly-io.html +356 -0
  73. package/dist/content/deployment/recipes/github-pages.html +414 -0
  74. package/dist/content/deployment/recipes/index.html +11 -0
  75. package/dist/content/deployment/recipes/netlify.html +394 -0
  76. package/dist/content/deployment/recipes/vercel.html +382 -0
  77. package/dist/content/deployment/secrets-and-env-vars.html +380 -0
  78. package/dist/content/deployment.html +426 -0
  79. package/dist/content/guide/approaches.html +501 -0
  80. package/dist/content/guide/features.html +436 -0
  81. package/dist/content/guide/getting-started.html +403 -0
  82. package/dist/content/guide/index.html +11 -0
  83. package/dist/content/guide/kitfly-overview.html +544 -0
  84. package/dist/content/index.html +11 -0
  85. package/dist/content/reference/configuration.html +580 -0
  86. package/dist/content/reference/design-catalog.html +449 -0
  87. package/dist/content/reference/environment-variables.html +367 -0
  88. package/dist/content/reference/glossary.html +368 -0
  89. package/dist/content/reference/index.html +11 -0
  90. package/dist/content/reference/key-concepts.html +399 -0
  91. package/dist/content/reference/plugins.html +491 -0
  92. package/dist/content/reference/slides-authoring-guidelines.html +418 -0
  93. package/dist/content/reference/structure.html +463 -0
  94. package/dist/content/reference.html +335 -0
  95. package/dist/content/templates/crucible.html +546 -0
  96. package/dist/content/templates/handbook.html +405 -0
  97. package/dist/content/templates/index.html +11 -0
  98. package/dist/content/templates/minimal.html +447 -0
  99. package/dist/content/templates/overview.html +558 -0
  100. package/dist/content/templates/pipeline.html +494 -0
  101. package/dist/content/templates/productbook.html +540 -0
  102. package/dist/content/templates/runbook.html +543 -0
  103. package/dist/content/templates/servicebook.html +523 -0
  104. package/dist/content-index.json +549 -0
  105. package/dist/docs/decisions/ADR-0001-minimalist-site-code.html +491 -0
  106. package/dist/docs/decisions/ADR-0002-ai-accessibility.html +434 -0
  107. package/dist/docs/decisions/ADR-0003-single-file-bundle.html +412 -0
  108. package/dist/docs/decisions/ADR-0004-bun-runtime.html +409 -0
  109. package/dist/docs/decisions/ADR-0005-plugin-contract-and-distribution.html +402 -0
  110. package/dist/docs/decisions/DDR-0001-viewport-locked-layout.html +459 -0
  111. package/dist/docs/decisions/DDR-0002-theme-system.html +452 -0
  112. package/dist/docs/decisions/DDR-0003-bounded-logo-slot.html +423 -0
  113. package/dist/docs/decisions/DDR-0004-slides-rendering-model.html +399 -0
  114. package/dist/docs/decisions/DDR-0005-deterministic-layout-boundary.html +422 -0
  115. package/dist/docs/decisions/index.html +11 -0
  116. package/dist/docs/userguide/cli/build.html +408 -0
  117. package/dist/docs/userguide/cli/bundle.html +419 -0
  118. package/dist/docs/userguide/cli/dev.html +428 -0
  119. package/dist/docs/userguide/cli/index.html +11 -0
  120. package/dist/docs/userguide/cli/init.html +436 -0
  121. package/dist/docs/userguide/cli/servers.html +393 -0
  122. package/dist/docs/userguide/cli/stop.html +408 -0
  123. package/dist/docs/userguide/cli/update.html +406 -0
  124. package/dist/docs/userguide/cli/version.html +406 -0
  125. package/dist/docs/userguide/cli.html +386 -0
  126. package/dist/docs/userguide/index.html +11 -0
  127. package/dist/docs/userguide/sharing.html +465 -0
  128. package/dist/index.html +387 -0
  129. package/dist/llms.txt +18 -0
  130. package/dist/provenance.json +7 -0
  131. package/dist/schemas/index.html +11 -0
  132. package/dist/schemas/plugin-registry.schema.html +327 -0
  133. package/dist/schemas/plugin-schemas-notes.html +364 -0
  134. package/dist/schemas/plugin.schema.html +327 -0
  135. package/dist/schemas/plugins.schema.html +327 -0
  136. package/dist/schemas/v0/common.schema.html +386 -0
  137. package/dist/schemas/v0/index.html +11 -0
  138. package/dist/schemas/v0/plugin-registry.schema.html +547 -0
  139. package/dist/schemas/v0/plugin.schema.html +497 -0
  140. package/dist/schemas/v0/plugins.schema.html +406 -0
  141. package/dist/schemas/v0/site.schema.html +541 -0
  142. package/dist/schemas/v0/theme.schema.html +615 -0
  143. package/dist/schemas.html +351 -0
  144. package/dist/styles.css +1262 -0
  145. package/package.json +4 -2
  146. package/plugins-dist/callouts.css +32 -0
  147. package/plugins-dist/callouts.js +46 -0
  148. package/plugins-dist/slides-visuals.css +390 -0
  149. package/plugins-dist/slides-visuals.js +689 -0
  150. package/registry/plugins.yaml +35 -0
  151. package/schemas/README.md +10 -0
  152. package/schemas/plugin-registry.schema.json +5 -0
  153. package/schemas/plugin-schemas-notes.md +71 -0
  154. package/schemas/plugin.schema.json +5 -0
  155. package/schemas/plugins.schema.json +5 -0
  156. package/schemas/v0/common.schema.json +64 -0
  157. package/schemas/v0/plugin-registry.schema.json +225 -0
  158. package/schemas/v0/plugin.schema.json +175 -0
  159. package/schemas/v0/plugins.schema.json +84 -0
  160. package/schemas/v0/site.schema.json +56 -9
  161. package/schemas/v0/theme.schema.json +105 -22
  162. package/scripts/build.ts +158 -3
  163. package/scripts/bundle.ts +261 -95
  164. package/scripts/dev.ts +301 -11
  165. package/src/__tests__/build.test.ts +220 -1
  166. package/src/__tests__/bundle.test.ts +31 -0
  167. package/src/__tests__/cli.test.ts +14 -3
  168. package/src/__tests__/dev-plugin-errors.test.ts +20 -0
  169. package/src/__tests__/fixtures/fences/slides-visuals/invalid/bad-list-indent.md +5 -0
  170. package/src/__tests__/fixtures/fences/slides-visuals/invalid/blank-line.md +5 -0
  171. package/src/__tests__/fixtures/fences/slides-visuals/invalid/compare-object-items.md +9 -0
  172. package/src/__tests__/fixtures/fences/slides-visuals/invalid/flow-branching-no-source.md +5 -0
  173. package/src/__tests__/fixtures/fences/slides-visuals/invalid/flow-converging-no-target.md +6 -0
  174. package/src/__tests__/fixtures/fences/slides-visuals/invalid/indented-fence.md +4 -0
  175. package/src/__tests__/fixtures/fences/slides-visuals/invalid/staircase-empty-steps.md +3 -0
  176. package/src/__tests__/fixtures/fences/slides-visuals/invalid/stat-grid-missing-fields.md +5 -0
  177. package/src/__tests__/fixtures/fences/slides-visuals/invalid/timeline-horizontal-no-events.md +2 -0
  178. package/src/__tests__/fixtures/fences/slides-visuals/invalid/unknown-type.md +3 -0
  179. package/src/__tests__/fixtures/fences/slides-visuals/valid/compare.md +10 -0
  180. package/src/__tests__/fixtures/fences/slides-visuals/valid/comparison-table.md +14 -0
  181. package/src/__tests__/fixtures/fences/slides-visuals/valid/flow-branching-no-split.md +7 -0
  182. package/src/__tests__/fixtures/fences/slides-visuals/valid/flow-branching.md +8 -0
  183. package/src/__tests__/fixtures/fences/slides-visuals/valid/flow-converging-no-merge.md +7 -0
  184. package/src/__tests__/fixtures/fences/slides-visuals/valid/flow-converging.md +8 -0
  185. package/src/__tests__/fixtures/fences/slides-visuals/valid/funnel.md +7 -0
  186. package/src/__tests__/fixtures/fences/slides-visuals/valid/kpi.md +5 -0
  187. package/src/__tests__/fixtures/fences/slides-visuals/valid/layer-cake.md +6 -0
  188. package/src/__tests__/fixtures/fences/slides-visuals/valid/pyramid.md +6 -0
  189. package/src/__tests__/fixtures/fences/slides-visuals/valid/quadrant-grid.md +8 -0
  190. package/src/__tests__/fixtures/fences/slides-visuals/valid/scorecard.md +13 -0
  191. package/src/__tests__/fixtures/fences/slides-visuals/valid/staircase-down.md +7 -0
  192. package/src/__tests__/fixtures/fences/slides-visuals/valid/staircase.md +8 -0
  193. package/src/__tests__/fixtures/fences/slides-visuals/valid/stat-grid.md +8 -0
  194. package/src/__tests__/fixtures/fences/slides-visuals/valid/timeline-horizontal.md +9 -0
  195. package/src/__tests__/fixtures/fences/slides-visuals/valid/timeline-vertical.md +10 -0
  196. package/src/__tests__/init.test.ts +35 -0
  197. package/src/__tests__/plugin-loader.test.ts +221 -0
  198. package/src/__tests__/shared.test.ts +451 -0
  199. package/src/__tests__/slides-visuals-fence-contract.test.ts +28 -0
  200. package/src/__tests__/slides-visuals-runtime-regressions.bun.test.ts +147 -0
  201. package/src/__tests__/styles.test.ts +35 -0
  202. package/src/cli.ts +9 -4
  203. package/src/plugin-loader.ts +245 -0
  204. package/src/shared.ts +650 -7
  205. package/src/site/styles.css +331 -0
  206. package/src/site/template.html +66 -5
  207. package/src/templates/deck.ts +186 -0
  208. package/src/templates/driver.ts +11 -1
  209. package/src/templates/minimal.ts +1 -0
package/scripts/dev.ts CHANGED
@@ -14,26 +14,36 @@
14
14
  */
15
15
 
16
16
  import { watch } from "node:fs";
17
- import { readFile } from "node:fs/promises";
17
+ import { readdir, readFile, stat } from "node:fs/promises";
18
18
  import { basename, extname, join, resolve } from "node:path";
19
19
  import { marked, Renderer } from "marked";
20
20
  import { ENGINE_ASSETS_DIR, ENGINE_SITE_DIR } from "../src/engine.ts";
21
+ import {
22
+ loadPluginInjections,
23
+ PluginConfigError,
24
+ PluginIntegrityError,
25
+ PluginNetworkError,
26
+ PluginPolicyError,
27
+ } from "../src/plugin-loader.ts";
21
28
  import {
22
29
  buildBreadcrumbsSimple,
23
30
  buildFooter,
24
31
  buildNavSimple,
25
32
  buildPageMeta,
33
+ buildSlideNav,
26
34
  buildToc,
27
35
  // Network utilities
28
36
  checkPortOrExit,
29
37
  // Navigation/template building
30
38
  collectFiles,
39
+ collectSlides,
31
40
  envBool,
32
41
  envInt,
33
42
  // Config helpers
34
43
  envString,
35
44
  // Formatting
36
45
  escapeHtml,
46
+ filterUnknownSlidesVisualsTypeDiagnostics,
37
47
  // Provenance
38
48
  generateProvenance,
39
49
  // YAML/Config parsing
@@ -41,13 +51,16 @@ import {
41
51
  type Provenance,
42
52
  // Markdown utilities
43
53
  parseFrontmatter,
54
+ parseYaml,
44
55
  resolveStylesPath,
45
56
  resolveTemplatePath,
57
+ rewriteRelativeAssetUrls,
46
58
  // Types
47
59
  type SiteConfig,
48
60
  slugify,
49
61
  toUrlPath,
50
62
  validatePath,
63
+ validateSlidesVisualsFences,
51
64
  } from "../src/shared.ts";
52
65
  import { generateThemeCSS, getPrismUrls, loadTheme, type Theme } from "../src/theme.ts";
53
66
 
@@ -69,6 +82,60 @@ let daemonLog: {
69
82
  error: (msg: string) => void;
70
83
  } | null = null;
71
84
 
85
+ function isPluginLoaderError(error: unknown): error is Error {
86
+ return (
87
+ error instanceof PluginConfigError ||
88
+ error instanceof PluginIntegrityError ||
89
+ error instanceof PluginPolicyError ||
90
+ error instanceof PluginNetworkError
91
+ );
92
+ }
93
+
94
+ function pluginVersionMismatchHint(message: string): string {
95
+ const m = message.match(/^Plugin ([a-z0-9-]+) version mismatch: ([^ ]+) != ([^ ]+)$/i);
96
+ if (!m) return "";
97
+ const pluginId = m[1];
98
+ const expected = m[3];
99
+ return `Update <code>kitfly.plugins.yaml</code> to <code>${pluginId}@${expected}</code>, then refresh.`;
100
+ }
101
+
102
+ export function buildDevPluginErrorHtml(message: string): string {
103
+ const hint = pluginVersionMismatchHint(message);
104
+ const safeMessage = escapeHtml(message);
105
+ const hintBlock = hint
106
+ ? `<p>${hint}</p>`
107
+ : "<p>Check <code>kitfly.plugins.yaml</code> and <code>registry/plugins.yaml</code>, then refresh.</p>";
108
+ return `<!doctype html>
109
+ <html lang="en">
110
+ <head>
111
+ <meta charset="utf-8">
112
+ <meta name="viewport" content="width=device-width, initial-scale=1">
113
+ <title>Plugin Configuration Error</title>
114
+ <style>
115
+ body { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; margin: 0; background: #0b1020; color: #e8ecf3; }
116
+ main { max-width: 820px; margin: 8vh auto; padding: 1.25rem; }
117
+ .card { background: #131a2e; border: 1px solid #2a3557; border-radius: 12px; padding: 1rem 1.1rem; }
118
+ h1 { margin: 0 0 0.75rem; font-size: 1.25rem; }
119
+ p, li { line-height: 1.5; }
120
+ code { background: #0e1528; padding: 0.08rem 0.3rem; border-radius: 6px; border: 1px solid #2a3557; }
121
+ pre { margin: 0.8rem 0 0; padding: 0.75rem; background: #0e1528; border: 1px solid #2a3557; border-radius: 8px; overflow: auto; }
122
+ .muted { color: #b5bfd2; font-size: 0.92rem; }
123
+ </style>
124
+ </head>
125
+ <body>
126
+ <main>
127
+ <div class="card">
128
+ <h1>Plugin setup error</h1>
129
+ <p>Kitfly could not load one or more plugins for dev preview.</p>
130
+ ${hintBlock}
131
+ <pre><code>${safeMessage}</code></pre>
132
+ <p class="muted">After updating config, refresh this page. No dev server restart required.</p>
133
+ </div>
134
+ </main>
135
+ </body>
136
+ </html>`;
137
+ }
138
+
72
139
  /** Log info — uses structured logger in daemon mode, console.log otherwise */
73
140
  function logInfo(msg: string): void {
74
141
  if (daemonLog) daemonLog.info(msg);
@@ -187,6 +254,72 @@ marked.use({ renderer });
187
254
  // Track connected clients for hot reload
188
255
  const clients: Set<ReadableStreamDefaultController> = new Set();
189
256
 
257
+ let pluginCache: { key: string; head: string; bodyEnd: string } | null = null;
258
+
259
+ async function isSlidesVisualsEnabled(): Promise<boolean> {
260
+ const configPath = join(ROOT, "kitfly.plugins.yaml");
261
+ try {
262
+ const raw = await readFile(configPath, "utf-8");
263
+ const parsed = parseYaml(raw) as unknown as Record<string, unknown>;
264
+ const plugins = Array.isArray(parsed?.plugins) ? (parsed.plugins as unknown[]) : [];
265
+ return plugins.some((p) => typeof p === "string" && p.startsWith("slides-visuals@"));
266
+ } catch {
267
+ return false;
268
+ }
269
+ }
270
+
271
+ async function getPluginInjectionsCached(
272
+ mode: "docs" | "slides",
273
+ ): Promise<{ head: string; bodyEnd: string }> {
274
+ const configPath = join(ROOT, "kitfly.plugins.yaml");
275
+ let configMtime = "missing";
276
+ try {
277
+ configMtime = String((await stat(configPath)).mtimeMs);
278
+ } catch {
279
+ return { head: "", bodyEnd: "" };
280
+ }
281
+
282
+ const siteRegistryPath = join(ROOT, "registry", "plugins.yaml");
283
+ let registryMtime = "none";
284
+ try {
285
+ registryMtime = String((await stat(siteRegistryPath)).mtimeMs);
286
+ } catch {
287
+ // Uses engine registry by default.
288
+ }
289
+
290
+ let pluginAssetsMtime = "none";
291
+ try {
292
+ const mtimes: number[] = [];
293
+ const dirs = [join(ROOT, "plugins-dist"), join(ENGINE_SITE_DIR, "..", "plugins-dist")];
294
+ for (const dir of dirs) {
295
+ try {
296
+ const entries = await readdir(dir);
297
+ for (const name of entries) {
298
+ if (!/\.(js|css)$/i.test(name)) continue;
299
+ try {
300
+ mtimes.push((await stat(join(dir, name))).mtimeMs);
301
+ } catch {
302
+ // ignore
303
+ }
304
+ }
305
+ } catch {
306
+ // ignore
307
+ }
308
+ }
309
+ pluginAssetsMtime = mtimes.length ? String(Math.max(...mtimes)) : "none";
310
+ } catch {
311
+ // ignore
312
+ }
313
+
314
+ const key = `${mode}:${configMtime}:${registryMtime}:${pluginAssetsMtime}`;
315
+ if (pluginCache && pluginCache.key === key) {
316
+ return { head: pluginCache.head, bodyEnd: pluginCache.bodyEnd };
317
+ }
318
+ const injected = await loadPluginInjections({ root: ROOT, mode });
319
+ pluginCache = { key, head: injected.head, bodyEnd: injected.bodyEnd };
320
+ return injected;
321
+ }
322
+
190
323
  // Convert markdown to HTML with template
191
324
  async function renderPage(
192
325
  filePath: string,
@@ -234,6 +367,7 @@ async function renderPage(
234
367
  const themeCSS = generateThemeCSS(theme);
235
368
  const prismUrls = getPrismUrls(theme);
236
369
  const pathPrefix = "/";
370
+ const plugins = await getPluginInjectionsCached(config.mode === "slides" ? "slides" : "docs");
237
371
 
238
372
  const hotReloadScript = `
239
373
  <script>
@@ -243,12 +377,15 @@ async function renderPage(
243
377
  </script>`;
244
378
 
245
379
  const logoClass = config.brand.logoType === "wordmark" ? "logo-wordmark" : "logo-icon";
380
+ const brandInitial = escapeHtml(config.brand.name.trim().charAt(0).toUpperCase() || "K");
246
381
 
247
382
  return template
383
+ .replace("{{BODY_CLASS}}", "mode-docs")
248
384
  .replace(/\{\{PATH_PREFIX\}\}/g, pathPrefix)
249
385
  .replace(/\{\{BRAND_URL\}\}/g, config.brand.url)
250
386
  .replace(/\{\{BRAND_TARGET\}\}/g, brandTarget)
251
387
  .replace(/\{\{BRAND_NAME\}\}/g, config.brand.name)
388
+ .replace(/\{\{BRAND_INITIAL\}\}/g, brandInitial)
252
389
  .replace(/\{\{BRAND_LOGO\}\}/g, config.brand.logo || "assets/brand/logo.png")
253
390
  .replace(/\{\{BRAND_FAVICON\}\}/g, config.brand.favicon || "assets/brand/favicon.png")
254
391
  .replace(/\{\{BRAND_LOGO_CLASS\}\}/g, logoClass)
@@ -263,6 +400,120 @@ async function renderPage(
263
400
  .replace("{{TOC}}", toc)
264
401
  .replace("{{FOOTER}}", footer)
265
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);
408
+ }
409
+
410
+ async function renderSlidesPage(
411
+ provenance: Provenance,
412
+ config: SiteConfig,
413
+ theme: Theme,
414
+ ): Promise<string> {
415
+ const uiVersion = provenance.version ? `v${provenance.version}` : "unversioned";
416
+ const template = await readFile(await resolveTemplatePath(ROOT), "utf-8");
417
+ const files = await collectFiles(ROOT, config);
418
+ const slides = await collectSlides(files);
419
+
420
+ if (slides.length === 0) {
421
+ return renderGettingStarted(provenance, config, theme);
422
+ }
423
+ const pathPrefix = "/";
424
+ const validateFences = await isSlidesVisualsEnabled();
425
+
426
+ const sections = await Promise.all(
427
+ slides.map(async (slide, i) => {
428
+ let inner = "";
429
+ if (slide.kind === "markdown") {
430
+ if (validateFences) {
431
+ const diagnostics = filterUnknownSlidesVisualsTypeDiagnostics(
432
+ validateSlidesVisualsFences(slide.body),
433
+ );
434
+ if (diagnostics.length) {
435
+ const msg = diagnostics
436
+ .slice(0, 12)
437
+ .map((d) => ` - ${slide.sourcePath}:${d.line} ${d.message}`)
438
+ .join("\n");
439
+ throw new Error(`slides-visuals fence contract violations:\n${msg}`);
440
+ }
441
+ }
442
+ inner = marked.parse(slide.body) as string;
443
+ } else if (slide.kind === "yaml") {
444
+ inner = `<pre><code class="language-yaml">${escapeHtml(slide.body)}</code></pre>`;
445
+ } else {
446
+ let prettyJson = slide.body;
447
+ try {
448
+ prettyJson = JSON.stringify(JSON.parse(slide.body), null, 2);
449
+ } catch {
450
+ // Use original if not valid JSON
451
+ }
452
+ inner = `<pre><code class="language-json">${escapeHtml(prettyJson)}</code></pre>`;
453
+ }
454
+ inner = rewriteRelativeAssetUrls(inner, slide.sourceUrlPath, pathPrefix);
455
+
456
+ const classToken = slide.className ? ` ${slide.className}` : "";
457
+ const activeClass = i === 0 ? " active" : "";
458
+ return `<section id="${slide.id}" class="slide${classToken}${activeClass}" data-slide-index="${i}">${inner}</section>`;
459
+ }),
460
+ );
461
+
462
+ const htmlContent = `
463
+ <div class="slides-shell" style="--slide-aspect: ${config.aspect || "16/9"}">
464
+ <div class="slide-viewport">
465
+ <div class="slide-frame">
466
+ ${sections.join("\n")}
467
+ </div>
468
+ </div>
469
+ <div class="slide-nav" aria-label="Slide navigation">
470
+ <button class="slide-prev" type="button" aria-label="Previous slide">Prev</button>
471
+ <span class="slide-counter">1 / ${slides.length}</span>
472
+ <button class="slide-next" type="button" aria-label="Next slide">Next</button>
473
+ <div class="slide-progress" role="presentation">
474
+ <span class="slide-progress-bar" style="width: ${(1 / slides.length) * 100}%"></span>
475
+ </div>
476
+ </div>
477
+ </div>`;
478
+
479
+ const nav = buildSlideNav(slides, config, "slide-1");
480
+ const footer = buildFooter(provenance, config);
481
+ const brandTarget = config.brand.external ? ' target="_blank" rel="noopener"' : "";
482
+ const themeCSS = generateThemeCSS(theme);
483
+ const prismUrls = getPrismUrls(theme);
484
+ const hotReloadScript = `
485
+ <script>
486
+ const es = new EventSource('/__reload');
487
+ es.onmessage = () => location.reload();
488
+ es.onerror = () => setTimeout(() => location.reload(), 1000);
489
+ </script>`;
490
+ const plugins = await getPluginInjectionsCached("slides");
491
+ const logoClass = config.brand.logoType === "wordmark" ? "logo-wordmark" : "logo-icon";
492
+ const brandInitial = escapeHtml(config.brand.name.trim().charAt(0).toUpperCase() || "K");
493
+
494
+ return template
495
+ .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)
508
+ .replace("{{BREADCRUMBS}}", "")
509
+ .replace("{{PAGE_META}}", "")
510
+ .replace("{{NAV}}", nav)
511
+ .replace("{{CONTENT}}", htmlContent)
512
+ .replace("{{TOC}}", "")
513
+ .replace("{{FOOTER}}", footer)
514
+ .replace("{{THEME_CSS}}", themeCSS)
515
+ .replace("{{PLUGIN_HEAD}}", plugins.head)
516
+ .replace("{{PLUGIN_BODY_END}}", plugins.bodyEnd)
266
517
  .replace("{{PRISM_LIGHT_URL}}", prismUrls.light)
267
518
  .replace("{{PRISM_DARK_URL}}", prismUrls.dark)
268
519
  .replace("{{HOT_RELOAD_SCRIPT}}", hotReloadScript);
@@ -303,6 +554,7 @@ sections:
303
554
  const themeCSS = generateThemeCSS(theme);
304
555
  const prismUrls = getPrismUrls(theme);
305
556
  const pathPrefix = "/";
557
+ const plugins = await getPluginInjectionsCached(config.mode === "slides" ? "slides" : "docs");
306
558
 
307
559
  const hotReloadScript = `
308
560
  <script>
@@ -312,12 +564,15 @@ sections:
312
564
  </script>`;
313
565
 
314
566
  const logoClass = config.brand.logoType === "wordmark" ? "logo-wordmark" : "logo-icon";
567
+ const brandInitial = escapeHtml(config.brand.name.trim().charAt(0).toUpperCase() || "K");
315
568
 
316
569
  return template
570
+ .replace("{{BODY_CLASS}}", "mode-docs")
317
571
  .replace(/\{\{PATH_PREFIX\}\}/g, pathPrefix)
318
572
  .replace(/\{\{BRAND_URL\}\}/g, config.brand.url)
319
573
  .replace(/\{\{BRAND_TARGET\}\}/g, brandTarget)
320
574
  .replace(/\{\{BRAND_NAME\}\}/g, config.brand.name)
575
+ .replace(/\{\{BRAND_INITIAL\}\}/g, brandInitial)
321
576
  .replace(/\{\{BRAND_LOGO\}\}/g, config.brand.logo || "assets/brand/logo.png")
322
577
  .replace(/\{\{BRAND_FAVICON\}\}/g, config.brand.favicon || "assets/brand/favicon.png")
323
578
  .replace(/\{\{BRAND_LOGO_CLASS\}\}/g, logoClass)
@@ -332,6 +587,8 @@ sections:
332
587
  .replace("{{TOC}}", "")
333
588
  .replace("{{FOOTER}}", buildFooter(provenance, config))
334
589
  .replace("{{THEME_CSS}}", themeCSS)
590
+ .replace("{{PLUGIN_HEAD}}", plugins.head)
591
+ .replace("{{PLUGIN_BODY_END}}", plugins.bodyEnd)
335
592
  .replace("{{PRISM_LIGHT_URL}}", prismUrls.light)
336
593
  .replace("{{PRISM_DARK_URL}}", prismUrls.dark)
337
594
  .replace("{{HOT_RELOAD_SCRIPT}}", hotReloadScript);
@@ -610,6 +867,14 @@ async function main() {
610
867
  });
611
868
  }
612
869
 
870
+ // Slides mode renders as a single-page deck with hash routing
871
+ if (config.mode === "slides") {
872
+ const html = await renderSlidesPage(provenance, config, theme);
873
+ return new Response(html, {
874
+ headers: { "Content-Type": "text/html" },
875
+ });
876
+ }
877
+
613
878
  // Find and render markdown/yaml file
614
879
  const filePath = await findFile(url.pathname, config);
615
880
  if (filePath) {
@@ -652,19 +917,44 @@ async function main() {
652
917
  return new Response("Not found", { status: 404 });
653
918
  }
654
919
 
655
- // Wrap with request logging middleware when in structured log mode
656
- const fetch = daemonLog
657
- ? async (req: Request) => {
658
- const start = performance.now();
659
- const response = await handleRequest(req);
920
+ // Wrap with request logging + friendly plugin errors.
921
+ const fetch = async (req: Request) => {
922
+ const start = performance.now();
923
+ const url = new URL(req.url);
924
+ try {
925
+ const response = await handleRequest(req);
926
+ if (daemonLog && url.pathname !== "/__reload") {
660
927
  const duration = (performance.now() - start).toFixed(0);
661
- const url = new URL(req.url);
662
- if (url.pathname !== "/__reload") {
663
- daemonLog?.info(`${req.method} ${url.pathname} ${response.status} ${duration}ms`);
928
+ daemonLog.info(`${req.method} ${url.pathname} ${response.status} ${duration}ms`);
929
+ }
930
+ return response;
931
+ } catch (error) {
932
+ const duration = (performance.now() - start).toFixed(0);
933
+ const message = error instanceof Error ? error.message : String(error);
934
+ if (isPluginLoaderError(error)) {
935
+ if (daemonLog && url.pathname !== "/__reload") {
936
+ daemonLog.warn(
937
+ `${req.method} ${url.pathname} 500 ${duration}ms plugin error: ${message}`,
938
+ );
939
+ } else if (!daemonLog) {
940
+ logWarn(`Plugin error: ${message}`);
664
941
  }
665
- return response;
942
+ return new Response(buildDevPluginErrorHtml(message), {
943
+ status: 500,
944
+ headers: { "Content-Type": "text/html; charset=utf-8" },
945
+ });
946
+ }
947
+ if (daemonLog && url.pathname !== "/__reload") {
948
+ daemonLog.error(`${req.method} ${url.pathname} 500 ${duration}ms ${message}`);
949
+ } else if (!daemonLog) {
950
+ console.error(error);
666
951
  }
667
- : handleRequest;
952
+ return new Response("Internal server error", {
953
+ status: 500,
954
+ headers: { "Content-Type": "text/plain; charset=utf-8" },
955
+ });
956
+ }
957
+ };
668
958
 
669
959
  // Create server
670
960
  Bun.serve({
@@ -5,6 +5,7 @@
5
5
  * create a temp site directory -> run build() -> verify output files
6
6
  */
7
7
 
8
+ import { createHash } from "node:crypto";
8
9
  import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
9
10
  import { tmpdir } from "node:os";
10
11
  import { join } from "node:path";
@@ -31,8 +32,10 @@ async function writeSiteYaml(dir: string, extra: Record<string, unknown> = {}):
31
32
  const sections = extra.sections ?? " - name: Docs\n path: docs";
32
33
  const title = extra.title ?? "Test Site";
33
34
  const version = extra.version ? `version: ${extra.version}\n` : "";
35
+ const mode = extra.mode ? `mode: ${extra.mode}\n` : "";
36
+ const aspect = extra.aspect ? `aspect: ${extra.aspect}\n` : "";
34
37
  const home = extra.home ? `home: ${extra.home}\n` : "";
35
- const yaml = `title: ${title}\n${version}brand:\n${brand}\n${home}sections:\n${sections}\n`;
38
+ const yaml = `title: ${title}\n${version}${mode}${aspect}brand:\n${brand}\n${home}sections:\n${sections}\n`;
36
39
  await writeFile(join(dir, "site.yaml"), yaml);
37
40
  }
38
41
 
@@ -43,6 +46,10 @@ async function writeMd(dir: string, relPath: string, content: string): Promise<v
43
46
  await writeFile(fullPath, content);
44
47
  }
45
48
 
49
+ function sha256Hex(text: string): string {
50
+ return createHash("sha256").update(new TextEncoder().encode(text)).digest("hex");
51
+ }
52
+
46
53
  // ---------------------------------------------------------------------------
47
54
  // Cleanup
48
55
  // ---------------------------------------------------------------------------
@@ -209,6 +216,41 @@ describe("build", () => {
209
216
  expect(html).toContain("unversioned");
210
217
  });
211
218
 
219
+ it("builds a single-page hash-routed deck when mode is slides", async () => {
220
+ const siteDir = await makeTempDir();
221
+ const outDir = "out";
222
+ await writeSiteYaml(siteDir, {
223
+ mode: "slides",
224
+ aspect: '"4/3"',
225
+ sections: " - name: Slides\n path: slides",
226
+ });
227
+ await writeMd(
228
+ siteDir,
229
+ "slides/deck.md",
230
+ `---
231
+ title: Intro
232
+ ---
233
+
234
+ # Intro
235
+ ![Diagram](./img/diagram.png)
236
+ [Report](../files/report.pdf)
237
+ --- slide ---
238
+ # Next`,
239
+ );
240
+
241
+ await build({ folder: siteDir, out: outDir });
242
+
243
+ const html = await readFile(join(siteDir, outDir, "index.html"), "utf-8");
244
+ expect(html).toContain('class="mode-slides"');
245
+ expect(html).toContain('id="slide-1"');
246
+ expect(html).toContain('id="slide-2"');
247
+ expect(html).toContain('href="#slide-1"');
248
+ expect(html).toContain('href="#slide-2"');
249
+ expect(html).toContain('src="./slides/img/diagram.png"');
250
+ expect(html).toContain('href="./files/report.pdf"');
251
+ expect(await exists(join(siteDir, outDir, "slides", "deck.html"))).toBe(false);
252
+ });
253
+
212
254
  it("generates AI accessibility files (content-index.json, llms.txt, _raw/)", async () => {
213
255
  const siteDir = await makeTempDir();
214
256
  const outDir = "out";
@@ -237,4 +279,181 @@ describe("build", () => {
237
279
  const rawEntries = await readdir(join(dist, "_raw"), { recursive: true });
238
280
  expect(rawEntries.length).toBeGreaterThan(0);
239
281
  });
282
+
283
+ it("injects enabled plugins into generated HTML", async () => {
284
+ const siteDir = await makeTempDir();
285
+ const outDir = "out";
286
+ await writeSiteYaml(siteDir);
287
+ await writeMd(siteDir, "docs/page.md", "> NOTE: Hello\n\nBody");
288
+
289
+ // Local plugin assets + registry
290
+ const js = "console.log('callouts');";
291
+ const css = ".kitfly-callout{border-left:6px solid red;}";
292
+ await mkdir(join(siteDir, "plugins-dist"), { recursive: true });
293
+ await writeFile(join(siteDir, "plugins-dist", "callouts.js"), js, "utf-8");
294
+ await writeFile(join(siteDir, "plugins-dist", "callouts.css"), css, "utf-8");
295
+
296
+ await mkdir(join(siteDir, "registry"), { recursive: true });
297
+ await writeFile(
298
+ join(siteDir, "registry", "plugins.yaml"),
299
+ `version: 1
300
+ updated: "2026-02-12"
301
+ baseUrl: ""
302
+ plugins:
303
+ callouts:
304
+ name: "Callout Boxes"
305
+ description: "Test callouts"
306
+ version: "0.2.0"
307
+ contract: "1"
308
+ kitfly: ">=0.2.0 <1.0.0"
309
+ license: MIT
310
+ verified: true
311
+ assets:
312
+ js: "plugins-dist/callouts.js"
313
+ css: "plugins-dist/callouts.css"
314
+ assetSha256:
315
+ js: "sha256:${sha256Hex(js)}"
316
+ css: "sha256:${sha256Hex(css)}"
317
+ `,
318
+ "utf-8",
319
+ );
320
+
321
+ await writeFile(
322
+ join(siteDir, "kitfly.plugins.yaml"),
323
+ "plugins:\n - callouts@0.2.0\n",
324
+ "utf-8",
325
+ );
326
+
327
+ await build({ folder: siteDir, out: outDir });
328
+
329
+ const html = await readFile(join(siteDir, outDir, "index.html"), "utf-8");
330
+ expect(html).toContain('data-kitfly-plugin="callouts@0.2.0"');
331
+ expect(html).toContain(css);
332
+ expect(html).toContain(js);
333
+ });
334
+
335
+ it("injects slides-only plugins when mode=slides", async () => {
336
+ const siteDir = await makeTempDir();
337
+ const outDir = "out";
338
+ await writeSiteYaml(siteDir, { mode: "slides" });
339
+ await writeMd(
340
+ siteDir,
341
+ "docs/deck.md",
342
+ `# Title
343
+
344
+ :::kpi
345
+ label: Uptime
346
+ value: 99.95%
347
+ trend: +0.3%
348
+ :::
349
+ `,
350
+ );
351
+
352
+ const js = "console.log('slides visuals');";
353
+ const css = ".kitfly-visual{border:1px solid red;}";
354
+ await mkdir(join(siteDir, "plugins-dist"), { recursive: true });
355
+ await writeFile(join(siteDir, "plugins-dist", "slides-visuals.js"), js, "utf-8");
356
+ await writeFile(join(siteDir, "plugins-dist", "slides-visuals.css"), css, "utf-8");
357
+
358
+ await mkdir(join(siteDir, "registry"), { recursive: true });
359
+ await writeFile(
360
+ join(siteDir, "registry", "plugins.yaml"),
361
+ `version: 1
362
+ updated: "2026-02-13"
363
+ baseUrl: ""
364
+ plugins:
365
+ slides-visuals:
366
+ name: "Slides Visuals"
367
+ description: "Test visuals"
368
+ version: "0.2.1"
369
+ contract: "1"
370
+ kitfly: ">=0.2.0 <1.0.0"
371
+ license: MIT
372
+ verified: true
373
+ modes: ["slides"]
374
+ assets:
375
+ js: "plugins-dist/slides-visuals.js"
376
+ css: "plugins-dist/slides-visuals.css"
377
+ assetSha256:
378
+ js: "sha256:${sha256Hex(js)}"
379
+ css: "sha256:${sha256Hex(css)}"
380
+ `,
381
+ "utf-8",
382
+ );
383
+
384
+ await writeFile(
385
+ join(siteDir, "kitfly.plugins.yaml"),
386
+ "plugins:\n - slides-visuals@0.2.1\n",
387
+ "utf-8",
388
+ );
389
+
390
+ await build({ folder: siteDir, out: outDir });
391
+
392
+ const html = await readFile(join(siteDir, outDir, "index.html"), "utf-8");
393
+ expect(html).toContain('data-kitfly-plugin="slides-visuals@0.2.1"');
394
+ expect(html).toContain(css);
395
+ expect(html).toContain(js);
396
+ });
397
+
398
+ it("ignores unknown slides-visuals block types while enforcing known contracts", async () => {
399
+ const siteDir = await makeTempDir();
400
+ const outDir = "out";
401
+ await writeSiteYaml(siteDir, { mode: "slides" });
402
+ await writeMd(
403
+ siteDir,
404
+ "docs/deck.md",
405
+ `# Title
406
+
407
+ :::future-thing
408
+ note: this should pass through
409
+ :::
410
+
411
+ :::kpi
412
+ label: Uptime
413
+ value: 99.95%
414
+ :::
415
+ `,
416
+ );
417
+
418
+ const js = "console.log('slides visuals');";
419
+ const css = ".kitfly-visual{border:1px solid red;}";
420
+ await mkdir(join(siteDir, "plugins-dist"), { recursive: true });
421
+ await writeFile(join(siteDir, "plugins-dist", "slides-visuals.js"), js, "utf-8");
422
+ await writeFile(join(siteDir, "plugins-dist", "slides-visuals.css"), css, "utf-8");
423
+ await mkdir(join(siteDir, "registry"), { recursive: true });
424
+ await writeFile(
425
+ join(siteDir, "registry", "plugins.yaml"),
426
+ `version: 1
427
+ updated: "2026-02-15"
428
+ baseUrl: ""
429
+ plugins:
430
+ slides-visuals:
431
+ name: "Slides Visuals"
432
+ description: "Test visuals"
433
+ version: "0.2.1"
434
+ contract: "1"
435
+ kitfly: ">=0.2.0 <1.0.0"
436
+ license: MIT
437
+ verified: true
438
+ modes: ["slides"]
439
+ assets:
440
+ js: "plugins-dist/slides-visuals.js"
441
+ css: "plugins-dist/slides-visuals.css"
442
+ assetSha256:
443
+ js: "sha256:${sha256Hex(js)}"
444
+ css: "sha256:${sha256Hex(css)}"
445
+ `,
446
+ "utf-8",
447
+ );
448
+ await writeFile(
449
+ join(siteDir, "kitfly.plugins.yaml"),
450
+ "plugins:\n - slides-visuals@0.2.1\n",
451
+ "utf-8",
452
+ );
453
+
454
+ await expect(build({ folder: siteDir, out: outDir })).resolves.toBeUndefined();
455
+ const html = await readFile(join(siteDir, outDir, "index.html"), "utf-8");
456
+ expect(html).toContain('data-kitfly-plugin="slides-visuals@0.2.1"');
457
+ expect(html).toContain("future-thing");
458
+ });
240
459
  });