kitfly 0.1.2 → 0.2.0

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 (194) hide show
  1. package/CHANGELOG.md +34 -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/structure.md +166 -0
  25. package/dist/_raw/content/reference.md +19 -0
  26. package/dist/_raw/content/templates/crucible.md +192 -0
  27. package/dist/_raw/content/templates/handbook.md +83 -0
  28. package/dist/_raw/content/templates/minimal.md +138 -0
  29. package/dist/_raw/content/templates/overview.md +187 -0
  30. package/dist/_raw/content/templates/pipeline.md +151 -0
  31. package/dist/_raw/content/templates/productbook.md +187 -0
  32. package/dist/_raw/content/templates/runbook.md +193 -0
  33. package/dist/_raw/content/templates/servicebook.md +163 -0
  34. package/dist/_raw/docs/decisions/ADR-0001-minimalist-site-code.md +118 -0
  35. package/dist/_raw/docs/decisions/ADR-0002-ai-accessibility.md +153 -0
  36. package/dist/_raw/docs/decisions/ADR-0003-single-file-bundle.md +93 -0
  37. package/dist/_raw/docs/decisions/ADR-0004-bun-runtime.md +98 -0
  38. package/dist/_raw/docs/decisions/ADR-0005-plugin-contract-and-distribution.md +110 -0
  39. package/dist/_raw/docs/decisions/DDR-0001-viewport-locked-layout.md +111 -0
  40. package/dist/_raw/docs/decisions/DDR-0002-theme-system.md +131 -0
  41. package/dist/_raw/docs/decisions/DDR-0003-bounded-logo-slot.md +106 -0
  42. package/dist/_raw/docs/decisions/DDR-0004-slides-rendering-model.md +113 -0
  43. package/dist/_raw/docs/decisions/DDR-0005-deterministic-layout-boundary.md +107 -0
  44. package/dist/_raw/docs/userguide/cli/build.md +85 -0
  45. package/dist/_raw/docs/userguide/cli/bundle.md +81 -0
  46. package/dist/_raw/docs/userguide/cli/dev.md +92 -0
  47. package/dist/_raw/docs/userguide/cli/init.md +116 -0
  48. package/dist/_raw/docs/userguide/cli/servers.md +69 -0
  49. package/dist/_raw/docs/userguide/cli/stop.md +76 -0
  50. package/dist/_raw/docs/userguide/cli/update.md +78 -0
  51. package/dist/_raw/docs/userguide/cli/version.md +65 -0
  52. package/dist/_raw/docs/userguide/cli.md +34 -0
  53. package/dist/_raw/docs/userguide/sharing.md +94 -0
  54. package/dist/_raw/schemas/plugin-schemas-notes.md +71 -0
  55. package/dist/_raw/schemas.md +42 -0
  56. package/dist/assets/brand/kitfly-favicon-32.png +0 -0
  57. package/dist/assets/brand/kitfly-icon-64.png +0 -0
  58. package/dist/assets/brand/kitfly-logo-128.png +0 -0
  59. package/dist/assets/brand/kitfly-logo-512.png +0 -0
  60. package/dist/assets/brand/kitfly-logo.svg +12132 -0
  61. package/dist/assets/brand/kitfly-neon-128.png +0 -0
  62. package/dist/assets/brand/kitfly-neon-192.png +0 -0
  63. package/dist/assets/brand/kitfly-neon-256.png +0 -0
  64. package/dist/assets/brand/kitfly-neon.png +0 -0
  65. package/dist/assets/brand/palette.md +75 -0
  66. package/dist/content/deployment/index.html +11 -0
  67. package/dist/content/deployment/preflight.html +418 -0
  68. package/dist/content/deployment/recipes/aws-s3.html +421 -0
  69. package/dist/content/deployment/recipes/cloudflare-pages.html +372 -0
  70. package/dist/content/deployment/recipes/cloudflare-r2.html +443 -0
  71. package/dist/content/deployment/recipes/fly-io.html +356 -0
  72. package/dist/content/deployment/recipes/github-pages.html +414 -0
  73. package/dist/content/deployment/recipes/index.html +11 -0
  74. package/dist/content/deployment/recipes/netlify.html +394 -0
  75. package/dist/content/deployment/recipes/vercel.html +382 -0
  76. package/dist/content/deployment/secrets-and-env-vars.html +380 -0
  77. package/dist/content/deployment.html +426 -0
  78. package/dist/content/guide/approaches.html +501 -0
  79. package/dist/content/guide/features.html +436 -0
  80. package/dist/content/guide/getting-started.html +403 -0
  81. package/dist/content/guide/index.html +11 -0
  82. package/dist/content/guide/kitfly-overview.html +544 -0
  83. package/dist/content/index.html +11 -0
  84. package/dist/content/reference/configuration.html +580 -0
  85. package/dist/content/reference/design-catalog.html +449 -0
  86. package/dist/content/reference/environment-variables.html +367 -0
  87. package/dist/content/reference/glossary.html +368 -0
  88. package/dist/content/reference/index.html +11 -0
  89. package/dist/content/reference/key-concepts.html +399 -0
  90. package/dist/content/reference/plugins.html +491 -0
  91. package/dist/content/reference/structure.html +463 -0
  92. package/dist/content/reference.html +334 -0
  93. package/dist/content/templates/crucible.html +546 -0
  94. package/dist/content/templates/handbook.html +405 -0
  95. package/dist/content/templates/index.html +11 -0
  96. package/dist/content/templates/minimal.html +447 -0
  97. package/dist/content/templates/overview.html +558 -0
  98. package/dist/content/templates/pipeline.html +494 -0
  99. package/dist/content/templates/productbook.html +540 -0
  100. package/dist/content/templates/runbook.html +543 -0
  101. package/dist/content/templates/servicebook.html +523 -0
  102. package/dist/content-index.json +540 -0
  103. package/dist/docs/decisions/ADR-0001-minimalist-site-code.html +491 -0
  104. package/dist/docs/decisions/ADR-0002-ai-accessibility.html +434 -0
  105. package/dist/docs/decisions/ADR-0003-single-file-bundle.html +412 -0
  106. package/dist/docs/decisions/ADR-0004-bun-runtime.html +409 -0
  107. package/dist/docs/decisions/ADR-0005-plugin-contract-and-distribution.html +402 -0
  108. package/dist/docs/decisions/DDR-0001-viewport-locked-layout.html +459 -0
  109. package/dist/docs/decisions/DDR-0002-theme-system.html +452 -0
  110. package/dist/docs/decisions/DDR-0003-bounded-logo-slot.html +423 -0
  111. package/dist/docs/decisions/DDR-0004-slides-rendering-model.html +399 -0
  112. package/dist/docs/decisions/DDR-0005-deterministic-layout-boundary.html +422 -0
  113. package/dist/docs/decisions/index.html +11 -0
  114. package/dist/docs/userguide/cli/build.html +408 -0
  115. package/dist/docs/userguide/cli/bundle.html +419 -0
  116. package/dist/docs/userguide/cli/dev.html +428 -0
  117. package/dist/docs/userguide/cli/index.html +11 -0
  118. package/dist/docs/userguide/cli/init.html +436 -0
  119. package/dist/docs/userguide/cli/servers.html +393 -0
  120. package/dist/docs/userguide/cli/stop.html +408 -0
  121. package/dist/docs/userguide/cli/update.html +406 -0
  122. package/dist/docs/userguide/cli/version.html +406 -0
  123. package/dist/docs/userguide/cli.html +386 -0
  124. package/dist/docs/userguide/index.html +11 -0
  125. package/dist/docs/userguide/sharing.html +465 -0
  126. package/dist/index.html +387 -0
  127. package/dist/llms.txt +18 -0
  128. package/dist/provenance.json +7 -0
  129. package/dist/schemas/index.html +11 -0
  130. package/dist/schemas/plugin-registry.schema.html +327 -0
  131. package/dist/schemas/plugin-schemas-notes.html +364 -0
  132. package/dist/schemas/plugin.schema.html +327 -0
  133. package/dist/schemas/plugins.schema.html +327 -0
  134. package/dist/schemas/v0/common.schema.html +386 -0
  135. package/dist/schemas/v0/index.html +11 -0
  136. package/dist/schemas/v0/plugin-registry.schema.html +547 -0
  137. package/dist/schemas/v0/plugin.schema.html +497 -0
  138. package/dist/schemas/v0/plugins.schema.html +406 -0
  139. package/dist/schemas/v0/site.schema.html +541 -0
  140. package/dist/schemas/v0/theme.schema.html +615 -0
  141. package/dist/schemas.html +351 -0
  142. package/dist/styles.css +1262 -0
  143. package/package.json +4 -2
  144. package/plugins-dist/callouts.css +32 -0
  145. package/plugins-dist/callouts.js +46 -0
  146. package/plugins-dist/slides-visuals.css +224 -0
  147. package/plugins-dist/slides-visuals.js +598 -0
  148. package/registry/plugins.yaml +35 -0
  149. package/schemas/README.md +10 -0
  150. package/schemas/plugin-registry.schema.json +5 -0
  151. package/schemas/plugin-schemas-notes.md +71 -0
  152. package/schemas/plugin.schema.json +5 -0
  153. package/schemas/plugins.schema.json +5 -0
  154. package/schemas/v0/common.schema.json +64 -0
  155. package/schemas/v0/plugin-registry.schema.json +225 -0
  156. package/schemas/v0/plugin.schema.json +175 -0
  157. package/schemas/v0/plugins.schema.json +84 -0
  158. package/schemas/v0/site.schema.json +56 -9
  159. package/schemas/v0/theme.schema.json +105 -22
  160. package/scripts/build.ts +155 -3
  161. package/scripts/bundle.ts +258 -95
  162. package/scripts/dev.ts +203 -1
  163. package/src/__tests__/build.test.ts +158 -1
  164. package/src/__tests__/bundle.test.ts +31 -0
  165. package/src/__tests__/cli.test.ts +14 -3
  166. package/src/__tests__/fixtures/fences/slides-visuals/invalid/bad-list-indent.md +5 -0
  167. package/src/__tests__/fixtures/fences/slides-visuals/invalid/blank-line.md +5 -0
  168. package/src/__tests__/fixtures/fences/slides-visuals/invalid/compare-object-items.md +9 -0
  169. package/src/__tests__/fixtures/fences/slides-visuals/invalid/indented-fence.md +4 -0
  170. package/src/__tests__/fixtures/fences/slides-visuals/invalid/stat-grid-missing-fields.md +5 -0
  171. package/src/__tests__/fixtures/fences/slides-visuals/invalid/unknown-type.md +3 -0
  172. package/src/__tests__/fixtures/fences/slides-visuals/valid/compare.md +10 -0
  173. package/src/__tests__/fixtures/fences/slides-visuals/valid/comparison-table.md +14 -0
  174. package/src/__tests__/fixtures/fences/slides-visuals/valid/funnel.md +7 -0
  175. package/src/__tests__/fixtures/fences/slides-visuals/valid/kpi.md +5 -0
  176. package/src/__tests__/fixtures/fences/slides-visuals/valid/layer-cake.md +6 -0
  177. package/src/__tests__/fixtures/fences/slides-visuals/valid/pyramid.md +6 -0
  178. package/src/__tests__/fixtures/fences/slides-visuals/valid/quadrant-grid.md +8 -0
  179. package/src/__tests__/fixtures/fences/slides-visuals/valid/scorecard.md +13 -0
  180. package/src/__tests__/fixtures/fences/slides-visuals/valid/stat-grid.md +8 -0
  181. package/src/__tests__/init.test.ts +35 -0
  182. package/src/__tests__/plugin-loader.test.ts +221 -0
  183. package/src/__tests__/shared.test.ts +428 -0
  184. package/src/__tests__/slides-visuals-fence-contract.test.ts +28 -0
  185. package/src/__tests__/slides-visuals-runtime-regressions.bun.test.ts +114 -0
  186. package/src/__tests__/styles.test.ts +35 -0
  187. package/src/cli.ts +9 -4
  188. package/src/plugin-loader.ts +245 -0
  189. package/src/shared.ts +614 -7
  190. package/src/site/styles.css +331 -0
  191. package/src/site/template.html +66 -5
  192. package/src/templates/deck.ts +186 -0
  193. package/src/templates/driver.ts +11 -1
  194. package/src/templates/minimal.ts +1 -0
package/scripts/dev.ts CHANGED
@@ -14,20 +14,23 @@
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 { loadPluginInjections } from "../src/plugin-loader.ts";
21
22
  import {
22
23
  buildBreadcrumbsSimple,
23
24
  buildFooter,
24
25
  buildNavSimple,
25
26
  buildPageMeta,
27
+ buildSlideNav,
26
28
  buildToc,
27
29
  // Network utilities
28
30
  checkPortOrExit,
29
31
  // Navigation/template building
30
32
  collectFiles,
33
+ collectSlides,
31
34
  envBool,
32
35
  envInt,
33
36
  // Config helpers
@@ -41,13 +44,16 @@ import {
41
44
  type Provenance,
42
45
  // Markdown utilities
43
46
  parseFrontmatter,
47
+ parseYaml,
44
48
  resolveStylesPath,
45
49
  resolveTemplatePath,
50
+ rewriteRelativeAssetUrls,
46
51
  // Types
47
52
  type SiteConfig,
48
53
  slugify,
49
54
  toUrlPath,
50
55
  validatePath,
56
+ validateSlidesVisualsFences,
51
57
  } from "../src/shared.ts";
52
58
  import { generateThemeCSS, getPrismUrls, loadTheme, type Theme } from "../src/theme.ts";
53
59
 
@@ -187,6 +193,72 @@ marked.use({ renderer });
187
193
  // Track connected clients for hot reload
188
194
  const clients: Set<ReadableStreamDefaultController> = new Set();
189
195
 
196
+ let pluginCache: { key: string; head: string; bodyEnd: string } | null = null;
197
+
198
+ async function isSlidesVisualsEnabled(): Promise<boolean> {
199
+ const configPath = join(ROOT, "kitfly.plugins.yaml");
200
+ try {
201
+ const raw = await readFile(configPath, "utf-8");
202
+ const parsed = parseYaml(raw) as unknown as Record<string, unknown>;
203
+ const plugins = Array.isArray(parsed?.plugins) ? (parsed.plugins as unknown[]) : [];
204
+ return plugins.some((p) => typeof p === "string" && p.startsWith("slides-visuals@"));
205
+ } catch {
206
+ return false;
207
+ }
208
+ }
209
+
210
+ async function getPluginInjectionsCached(
211
+ mode: "docs" | "slides",
212
+ ): Promise<{ head: string; bodyEnd: string }> {
213
+ const configPath = join(ROOT, "kitfly.plugins.yaml");
214
+ let configMtime = "missing";
215
+ try {
216
+ configMtime = String((await stat(configPath)).mtimeMs);
217
+ } catch {
218
+ return { head: "", bodyEnd: "" };
219
+ }
220
+
221
+ const siteRegistryPath = join(ROOT, "registry", "plugins.yaml");
222
+ let registryMtime = "none";
223
+ try {
224
+ registryMtime = String((await stat(siteRegistryPath)).mtimeMs);
225
+ } catch {
226
+ // Uses engine registry by default.
227
+ }
228
+
229
+ let pluginAssetsMtime = "none";
230
+ try {
231
+ const mtimes: number[] = [];
232
+ const dirs = [join(ROOT, "plugins-dist"), join(ENGINE_SITE_DIR, "..", "plugins-dist")];
233
+ for (const dir of dirs) {
234
+ try {
235
+ const entries = await readdir(dir);
236
+ for (const name of entries) {
237
+ if (!/\.(js|css)$/i.test(name)) continue;
238
+ try {
239
+ mtimes.push((await stat(join(dir, name))).mtimeMs);
240
+ } catch {
241
+ // ignore
242
+ }
243
+ }
244
+ } catch {
245
+ // ignore
246
+ }
247
+ }
248
+ pluginAssetsMtime = mtimes.length ? String(Math.max(...mtimes)) : "none";
249
+ } catch {
250
+ // ignore
251
+ }
252
+
253
+ const key = `${mode}:${configMtime}:${registryMtime}:${pluginAssetsMtime}`;
254
+ if (pluginCache && pluginCache.key === key) {
255
+ return { head: pluginCache.head, bodyEnd: pluginCache.bodyEnd };
256
+ }
257
+ const injected = await loadPluginInjections({ root: ROOT, mode });
258
+ pluginCache = { key, head: injected.head, bodyEnd: injected.bodyEnd };
259
+ return injected;
260
+ }
261
+
190
262
  // Convert markdown to HTML with template
191
263
  async function renderPage(
192
264
  filePath: string,
@@ -234,6 +306,7 @@ async function renderPage(
234
306
  const themeCSS = generateThemeCSS(theme);
235
307
  const prismUrls = getPrismUrls(theme);
236
308
  const pathPrefix = "/";
309
+ const plugins = await getPluginInjectionsCached(config.mode === "slides" ? "slides" : "docs");
237
310
 
238
311
  const hotReloadScript = `
239
312
  <script>
@@ -243,12 +316,15 @@ async function renderPage(
243
316
  </script>`;
244
317
 
245
318
  const logoClass = config.brand.logoType === "wordmark" ? "logo-wordmark" : "logo-icon";
319
+ const brandInitial = escapeHtml(config.brand.name.trim().charAt(0).toUpperCase() || "K");
246
320
 
247
321
  return template
322
+ .replace("{{BODY_CLASS}}", "mode-docs")
248
323
  .replace(/\{\{PATH_PREFIX\}\}/g, pathPrefix)
249
324
  .replace(/\{\{BRAND_URL\}\}/g, config.brand.url)
250
325
  .replace(/\{\{BRAND_TARGET\}\}/g, brandTarget)
251
326
  .replace(/\{\{BRAND_NAME\}\}/g, config.brand.name)
327
+ .replace(/\{\{BRAND_INITIAL\}\}/g, brandInitial)
252
328
  .replace(/\{\{BRAND_LOGO\}\}/g, config.brand.logo || "assets/brand/logo.png")
253
329
  .replace(/\{\{BRAND_FAVICON\}\}/g, config.brand.favicon || "assets/brand/favicon.png")
254
330
  .replace(/\{\{BRAND_LOGO_CLASS\}\}/g, logoClass)
@@ -263,6 +339,118 @@ async function renderPage(
263
339
  .replace("{{TOC}}", toc)
264
340
  .replace("{{FOOTER}}", footer)
265
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);
347
+ }
348
+
349
+ async function renderSlidesPage(
350
+ provenance: Provenance,
351
+ config: SiteConfig,
352
+ theme: Theme,
353
+ ): Promise<string> {
354
+ const uiVersion = provenance.version ? `v${provenance.version}` : "unversioned";
355
+ const template = await readFile(await resolveTemplatePath(ROOT), "utf-8");
356
+ const files = await collectFiles(ROOT, config);
357
+ const slides = await collectSlides(files);
358
+
359
+ if (slides.length === 0) {
360
+ return renderGettingStarted(provenance, config, theme);
361
+ }
362
+ const pathPrefix = "/";
363
+ const validateFences = await isSlidesVisualsEnabled();
364
+
365
+ const sections = await Promise.all(
366
+ slides.map(async (slide, i) => {
367
+ let inner = "";
368
+ if (slide.kind === "markdown") {
369
+ if (validateFences) {
370
+ const diagnostics = validateSlidesVisualsFences(slide.body);
371
+ if (diagnostics.length) {
372
+ const msg = diagnostics
373
+ .slice(0, 12)
374
+ .map((d) => ` - ${slide.sourcePath}:${d.line} ${d.message}`)
375
+ .join("\n");
376
+ throw new Error(`slides-visuals fence contract violations:\n${msg}`);
377
+ }
378
+ }
379
+ inner = marked.parse(slide.body) as string;
380
+ } else if (slide.kind === "yaml") {
381
+ inner = `<pre><code class="language-yaml">${escapeHtml(slide.body)}</code></pre>`;
382
+ } else {
383
+ let prettyJson = slide.body;
384
+ try {
385
+ prettyJson = JSON.stringify(JSON.parse(slide.body), null, 2);
386
+ } catch {
387
+ // Use original if not valid JSON
388
+ }
389
+ inner = `<pre><code class="language-json">${escapeHtml(prettyJson)}</code></pre>`;
390
+ }
391
+ inner = rewriteRelativeAssetUrls(inner, slide.sourceUrlPath, pathPrefix);
392
+
393
+ const classToken = slide.className ? ` ${slide.className}` : "";
394
+ const activeClass = i === 0 ? " active" : "";
395
+ return `<section id="${slide.id}" class="slide${classToken}${activeClass}" data-slide-index="${i}">${inner}</section>`;
396
+ }),
397
+ );
398
+
399
+ const htmlContent = `
400
+ <div class="slides-shell" style="--slide-aspect: ${config.aspect || "16/9"}">
401
+ <div class="slide-viewport">
402
+ <div class="slide-frame">
403
+ ${sections.join("\n")}
404
+ </div>
405
+ </div>
406
+ <div class="slide-nav" aria-label="Slide navigation">
407
+ <button class="slide-prev" type="button" aria-label="Previous slide">Prev</button>
408
+ <span class="slide-counter">1 / ${slides.length}</span>
409
+ <button class="slide-next" type="button" aria-label="Next slide">Next</button>
410
+ <div class="slide-progress" role="presentation">
411
+ <span class="slide-progress-bar" style="width: ${(1 / slides.length) * 100}%"></span>
412
+ </div>
413
+ </div>
414
+ </div>`;
415
+
416
+ const nav = buildSlideNav(slides, config, "slide-1");
417
+ const footer = buildFooter(provenance, config);
418
+ const brandTarget = config.brand.external ? ' target="_blank" rel="noopener"' : "";
419
+ const themeCSS = generateThemeCSS(theme);
420
+ const prismUrls = getPrismUrls(theme);
421
+ const hotReloadScript = `
422
+ <script>
423
+ const es = new EventSource('/__reload');
424
+ es.onmessage = () => location.reload();
425
+ es.onerror = () => setTimeout(() => location.reload(), 1000);
426
+ </script>`;
427
+ const plugins = await getPluginInjectionsCached("slides");
428
+ const logoClass = config.brand.logoType === "wordmark" ? "logo-wordmark" : "logo-icon";
429
+ const brandInitial = escapeHtml(config.brand.name.trim().charAt(0).toUpperCase() || "K");
430
+
431
+ return template
432
+ .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)
445
+ .replace("{{BREADCRUMBS}}", "")
446
+ .replace("{{PAGE_META}}", "")
447
+ .replace("{{NAV}}", nav)
448
+ .replace("{{CONTENT}}", htmlContent)
449
+ .replace("{{TOC}}", "")
450
+ .replace("{{FOOTER}}", footer)
451
+ .replace("{{THEME_CSS}}", themeCSS)
452
+ .replace("{{PLUGIN_HEAD}}", plugins.head)
453
+ .replace("{{PLUGIN_BODY_END}}", plugins.bodyEnd)
266
454
  .replace("{{PRISM_LIGHT_URL}}", prismUrls.light)
267
455
  .replace("{{PRISM_DARK_URL}}", prismUrls.dark)
268
456
  .replace("{{HOT_RELOAD_SCRIPT}}", hotReloadScript);
@@ -303,6 +491,7 @@ sections:
303
491
  const themeCSS = generateThemeCSS(theme);
304
492
  const prismUrls = getPrismUrls(theme);
305
493
  const pathPrefix = "/";
494
+ const plugins = await getPluginInjectionsCached(config.mode === "slides" ? "slides" : "docs");
306
495
 
307
496
  const hotReloadScript = `
308
497
  <script>
@@ -312,12 +501,15 @@ sections:
312
501
  </script>`;
313
502
 
314
503
  const logoClass = config.brand.logoType === "wordmark" ? "logo-wordmark" : "logo-icon";
504
+ const brandInitial = escapeHtml(config.brand.name.trim().charAt(0).toUpperCase() || "K");
315
505
 
316
506
  return template
507
+ .replace("{{BODY_CLASS}}", "mode-docs")
317
508
  .replace(/\{\{PATH_PREFIX\}\}/g, pathPrefix)
318
509
  .replace(/\{\{BRAND_URL\}\}/g, config.brand.url)
319
510
  .replace(/\{\{BRAND_TARGET\}\}/g, brandTarget)
320
511
  .replace(/\{\{BRAND_NAME\}\}/g, config.brand.name)
512
+ .replace(/\{\{BRAND_INITIAL\}\}/g, brandInitial)
321
513
  .replace(/\{\{BRAND_LOGO\}\}/g, config.brand.logo || "assets/brand/logo.png")
322
514
  .replace(/\{\{BRAND_FAVICON\}\}/g, config.brand.favicon || "assets/brand/favicon.png")
323
515
  .replace(/\{\{BRAND_LOGO_CLASS\}\}/g, logoClass)
@@ -332,6 +524,8 @@ sections:
332
524
  .replace("{{TOC}}", "")
333
525
  .replace("{{FOOTER}}", buildFooter(provenance, config))
334
526
  .replace("{{THEME_CSS}}", themeCSS)
527
+ .replace("{{PLUGIN_HEAD}}", plugins.head)
528
+ .replace("{{PLUGIN_BODY_END}}", plugins.bodyEnd)
335
529
  .replace("{{PRISM_LIGHT_URL}}", prismUrls.light)
336
530
  .replace("{{PRISM_DARK_URL}}", prismUrls.dark)
337
531
  .replace("{{HOT_RELOAD_SCRIPT}}", hotReloadScript);
@@ -610,6 +804,14 @@ async function main() {
610
804
  });
611
805
  }
612
806
 
807
+ // Slides mode renders as a single-page deck with hash routing
808
+ if (config.mode === "slides") {
809
+ const html = await renderSlidesPage(provenance, config, theme);
810
+ return new Response(html, {
811
+ headers: { "Content-Type": "text/html" },
812
+ });
813
+ }
814
+
613
815
  // Find and render markdown/yaml file
614
816
  const filePath = await findFile(url.pathname, config);
615
817
  if (filePath) {
@@ -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,119 @@ 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.0"
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.0\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.0"');
394
+ expect(html).toContain(css);
395
+ expect(html).toContain(js);
396
+ });
240
397
  });
@@ -648,6 +648,12 @@ describe("buildBundleSidebarHeader", () => {
648
648
  expect(source).toContain(`\${themeCSS}`);
649
649
  });
650
650
 
651
+ it("bundle script keeps docs-mode smooth anchor scrolling", async () => {
652
+ const source = await readFile(`${process.cwd()}/scripts/bundle.ts`, "utf-8");
653
+ expect(source).toContain("if (!shell) {");
654
+ expect(source).toContain("scrollIntoView({ behavior: 'smooth', block: 'start' });");
655
+ });
656
+
651
657
  it("shows version label with v prefix when version is provided", () => {
652
658
  const config: SiteConfig = {
653
659
  docroot: ".",
@@ -684,6 +690,31 @@ describe("buildBundleSidebarHeader", () => {
684
690
  expect(html).toContain('alt="My Company"');
685
691
  });
686
692
 
693
+ it("includes initial fallback metadata and onerror handler", () => {
694
+ const config: SiteConfig = {
695
+ docroot: ".",
696
+ title: "Test",
697
+ brand: { name: "Acme", url: "/" },
698
+ sections: [],
699
+ };
700
+
701
+ const html = buildBundleSidebarHeader(config, "1.0", "logo.png");
702
+ expect(html).toContain('data-initial="A"');
703
+ expect(html).toContain("classList.add('logo-fallback')");
704
+ });
705
+
706
+ it("escapes initial fallback character in data attribute", () => {
707
+ const config: SiteConfig = {
708
+ docroot: ".",
709
+ title: "Test",
710
+ brand: { name: '"quoted', url: "/" },
711
+ sections: [],
712
+ };
713
+
714
+ const html = buildBundleSidebarHeader(config, "1.0", "logo.png");
715
+ expect(html).toContain('data-initial="&quot;"');
716
+ });
717
+
687
718
  it("links brand to brand URL", () => {
688
719
  const config: SiteConfig = {
689
720
  docroot: ".",
@@ -54,7 +54,7 @@ kitfly v${version} - Turn your writing into a website
54
54
  Usage:
55
55
  kitfly dev [folder] Start dev server with hot reload
56
56
  kitfly build [folder] Build static site to dist/
57
- kitfly bundle [folder] Build single-file HTML bundle
57
+ kitfly bundle [folder] Build single-file HTML bundle to bundles/
58
58
  kitfly init [name] Create new project from template
59
59
  kitfly servers List running dev servers
60
60
  kitfly stop <port|all> Stop dev server(s)
@@ -68,11 +68,15 @@ Dev options:
68
68
  --json Output JSON (implies --daemon)
69
69
  --no-open Don't open browser
70
70
 
71
- Build/bundle options:
71
+ Build options:
72
72
  --out <dir> Output directory [env: KITFLY_BUILD_OUT] (default: dist)
73
- --name <file> Bundle filename (default: bundle.html)
74
73
  --no-raw Don't include raw markdown
75
74
 
75
+ Bundle options:
76
+ --out <dir> Output directory [env: KITFLY_BUNDLE_OUT] (default: bundles)
77
+ --name <file> Bundle filename (default: bundle.html)
78
+ --no-raw Don't include raw markdown [env: KITFLY_BUNDLE_RAW]
79
+
76
80
  Stop options:
77
81
  --force Skip graceful shutdown, kill immediately
78
82
 
@@ -84,6 +88,7 @@ Examples:
84
88
  kitfly stop 4000
85
89
  kitfly stop all
86
90
  kitfly build ./docs --out ./public
91
+ kitfly bundle ./docs --out ./bundles --name docs.html
87
92
  kitfly init my-handbook
88
93
 
89
94
  Documentation: https://kitfly.app
@@ -647,6 +652,12 @@ describe("command argument defaults", () => {
647
652
  const name = (flags.name as string) || "bundle.html";
648
653
  expect(name).toBe("bundle.html");
649
654
  });
655
+
656
+ it("bundle defaults out to bundles", () => {
657
+ const { flags } = routeCommand(["bundle"]);
658
+ const out = (flags.out as string) || "bundles";
659
+ expect(out).toBe("bundles");
660
+ });
650
661
  });
651
662
 
652
663
  describe("daemon mode detection", () => {
@@ -0,0 +1,5 @@
1
+ :::stat-grid
2
+ metrics:
3
+ - label: Users
4
+ value: 1,234
5
+ :::
@@ -0,0 +1,5 @@
1
+ :::kpi
2
+ label: A
3
+
4
+ value: B
5
+ :::
@@ -0,0 +1,9 @@
1
+ :::compare
2
+ left-title: "Build"
3
+ right-title: "Buy"
4
+ left:
5
+ - label: "Control"
6
+ value: "High"
7
+ right:
8
+ - "Fast to ship"
9
+ :::
@@ -0,0 +1,4 @@
1
+ :::kpi
2
+ label: A
3
+ value: B
4
+ :::
@@ -0,0 +1,5 @@
1
+ :::stat-grid
2
+ metrics:
3
+ - label: "Users"
4
+ - label: "Revenue"
5
+ :::
@@ -0,0 +1,3 @@
1
+ :::mystery
2
+ key: value
3
+ :::
@@ -0,0 +1,10 @@
1
+ :::compare
2
+ left-title: "Pros"
3
+ right-title: "Cons"
4
+ left:
5
+ - "Fast"
6
+ - "Simple"
7
+ right:
8
+ - "Limited"
9
+ - "Opinionated"
10
+ :::
@@ -0,0 +1,14 @@
1
+ :::comparison-table
2
+ headers:
3
+ - Feature
4
+ - Us
5
+ - Competitor A
6
+ - Competitor B
7
+ rows:
8
+ - ["Real-time sync", "Yes", "Yes", "No"]
9
+ - ["Offline mode", "Yes", "No", "Yes"]
10
+ - ["Plugin system", "Yes (v0.2.0)", "No", "Limited"]
11
+ - ["Self-hosted option", "Yes", "No", "No"]
12
+ - ["AI generation", "Yes", "Beta", "No"]
13
+ - ["Price (team/mo)", "$49", "$79", "$39"]
14
+ :::
@@ -0,0 +1,7 @@
1
+ :::funnel
2
+ stages:
3
+ - "Leads (1,200)"
4
+ - "Qualified (420)"
5
+ - "Proposals (120)"
6
+ - "Closed (47)"
7
+ :::
@@ -0,0 +1,5 @@
1
+ :::kpi
2
+ label: "Deals Closed"
3
+ value: "$8.1M"
4
+ trend: "+12%"
5
+ :::
@@ -0,0 +1,6 @@
1
+ :::layer-cake
2
+ layers:
3
+ - "Product"
4
+ - "Platform"
5
+ - "Infrastructure"
6
+ :::
@@ -0,0 +1,6 @@
1
+ :::pyramid
2
+ levels:
3
+ - "Vision"
4
+ - "Strategy"
5
+ - "Execution"
6
+ :::