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
@@ -14,14 +14,17 @@ import {
14
14
  buildNavSimple,
15
15
  buildNavStatic,
16
16
  buildPageMeta,
17
+ buildSlideNav,
17
18
  buildToc,
18
19
  type ContentFile,
19
20
  collectFiles,
21
+ collectSlides,
20
22
  envBool,
21
23
  envInt,
22
24
  envString,
23
25
  escapeHtml,
24
26
  exists,
27
+ filterUnknownSlidesVisualsTypeDiagnostics,
25
28
  formatDate,
26
29
  generateProvenance,
27
30
  getGitInfo,
@@ -32,11 +35,15 @@ import {
32
35
  parseValue,
33
36
  parseYaml,
34
37
  resolveSiteVersion,
38
+ rewriteRelativeAssetUrls,
35
39
  type SiteConfig,
40
+ segmentSlides,
36
41
  slugify,
42
+ splitSlides,
37
43
  stripQuotes,
38
44
  toUrlPath,
39
45
  validatePath,
46
+ validateSlidesVisualsFences,
40
47
  } from "../shared.ts";
41
48
 
42
49
  describe("slugify", () => {
@@ -88,6 +95,287 @@ description: A test page
88
95
  expect(Object.keys(frontmatter)).toHaveLength(0);
89
96
  expect(body).toBe(content);
90
97
  });
98
+
99
+ it("extracts frontmatter with leading whitespace (delimiter-split slides)", () => {
100
+ const content = `\n\n ---\n title: Slide Two\n ---\n\n# Two`;
101
+ const { frontmatter, body } = parseFrontmatter(content);
102
+ expect(frontmatter.title).toBe("Slide Two");
103
+ expect(body.trim()).toBe("# Two");
104
+ });
105
+ });
106
+
107
+ describe("splitSlides", () => {
108
+ it("splits markdown on explicit slide delimiter", () => {
109
+ const input = `# One
110
+
111
+ --- slide ---
112
+
113
+ # Two`;
114
+ const slides = splitSlides(input);
115
+ expect(slides).toHaveLength(2);
116
+ expect(slides[0]).toContain("# One");
117
+ expect(slides[1]).toContain("# Two");
118
+ });
119
+
120
+ it("does not split on plain horizontal rules", () => {
121
+ const input = `# One
122
+
123
+ ---
124
+
125
+ Still one slide`;
126
+ const slides = splitSlides(input);
127
+ expect(slides).toHaveLength(1);
128
+ });
129
+
130
+ it("ignores delimiter text inside fenced code blocks", () => {
131
+ const input = `# One
132
+
133
+ \`\`\`md
134
+ --- slide ---
135
+ \`\`\`
136
+
137
+ --- slide ---
138
+
139
+ # Two`;
140
+ const slides = splitSlides(input);
141
+ expect(slides).toHaveLength(2);
142
+ expect(slides[0]).toContain("```md");
143
+ expect(slides[0]).toContain("--- slide ---");
144
+ });
145
+
146
+ it("does not break on 4-backtick fences containing 3-backtick lines", () => {
147
+ const input = `\`\`\`\`md
148
+ \`\`\` still code
149
+ --- slide ---
150
+ \`\`\`\`
151
+ --- slide ---
152
+ # Real slide`;
153
+ const slides = splitSlides(input);
154
+ expect(slides).toHaveLength(2);
155
+ expect(slides[0]).toContain("--- slide ---");
156
+ expect(slides[1].trim()).toBe("# Real slide");
157
+ });
158
+ });
159
+
160
+ describe("slides-visuals diagnostics filtering", () => {
161
+ it("drops unknown-type diagnostics while preserving schema violations", () => {
162
+ const markdown = `:::future-thing
163
+ foo: bar
164
+ :::
165
+
166
+ :::kpi
167
+ label: Missing value
168
+ :::`;
169
+ const diagnostics = validateSlidesVisualsFences(markdown);
170
+ const filtered = filterUnknownSlidesVisualsTypeDiagnostics(diagnostics);
171
+ expect(
172
+ diagnostics.some((d) => d.message.startsWith("Unknown slides-visuals block type:")),
173
+ ).toBe(true);
174
+ expect(filtered.some((d) => d.message.startsWith("Unknown slides-visuals block type:"))).toBe(
175
+ false,
176
+ );
177
+ expect(filtered.some((d) => d.message.includes("Missing required key: value"))).toBe(true);
178
+ });
179
+ });
180
+
181
+ describe("segmentSlides", () => {
182
+ it("uses frontmatter title and class when present", () => {
183
+ const input = `---
184
+ title: Intro Slide
185
+ class: two-column
186
+ ---
187
+
188
+ # Welcome`;
189
+ const segments = segmentSlides(input, "Deck");
190
+ expect(segments).toHaveLength(1);
191
+ expect(segments[0].title).toBe("Intro Slide");
192
+ expect(segments[0].className).toBe("two-column");
193
+ });
194
+
195
+ it("falls back to first heading when frontmatter title is missing", () => {
196
+ const input = `# Architecture Overview
197
+
198
+ Body`;
199
+ const segments = segmentSlides(input, "Deck");
200
+ expect(segments[0].title).toBe("Architecture Overview");
201
+ });
202
+
203
+ it("ignores headings inside fenced code blocks when deriving title", () => {
204
+ const input = `\`\`\`md
205
+ # Not a real heading
206
+ \`\`\`
207
+
208
+ # Real Heading`;
209
+ const segments = segmentSlides(input, "Deck");
210
+ expect(segments[0].title).toBe("Real Heading");
211
+ });
212
+
213
+ it("falls back to indexed title when no frontmatter title or heading exists", () => {
214
+ const input = `Just text
215
+ --- slide ---
216
+ More text`;
217
+ const segments = segmentSlides(input, "Runbook Deck");
218
+ expect(segments).toHaveLength(2);
219
+ expect(segments[0].title).toBe("Runbook Deck (1)");
220
+ expect(segments[1].title).toBe("Runbook Deck (2)");
221
+ });
222
+
223
+ it("parses frontmatter per slide segment", () => {
224
+ const input = `---
225
+ title: First
226
+ ---
227
+
228
+ # One
229
+ --- slide ---
230
+ ---
231
+ title: Second
232
+ class: centered
233
+ ---
234
+
235
+ # Two`;
236
+ const segments = segmentSlides(input, "Deck");
237
+ expect(segments).toHaveLength(2);
238
+ expect(segments[0].title).toBe("First");
239
+ expect(segments[1].title).toBe("Second");
240
+ expect(segments[1].className).toBe("centered");
241
+ });
242
+
243
+ it("sanitizes frontmatter class to safe class tokens", () => {
244
+ const input = `---
245
+ class: centered two-column "><img src=x onerror=alert(1)>
246
+ ---
247
+
248
+ # Safe classes only`;
249
+ const segments = segmentSlides(input, "Deck");
250
+ expect(segments[0].className).toBe("centered two-column");
251
+ });
252
+ });
253
+
254
+ describe("collectSlides and buildSlideNav", () => {
255
+ const tempDirs: string[] = [];
256
+
257
+ afterEach(async () => {
258
+ for (const dir of tempDirs) {
259
+ await rm(dir, { recursive: true, force: true });
260
+ }
261
+ tempDirs.length = 0;
262
+ });
263
+
264
+ it("collects segmented markdown slides and assigns sequential ids", async () => {
265
+ const dir = await mkdtemp(join(tmpdir(), "kitfly-slides-collect-"));
266
+ tempDirs.push(dir);
267
+ const mdPath = join(dir, "deck.md");
268
+ await writeFile(
269
+ mdPath,
270
+ `---
271
+ title: Intro
272
+ ---
273
+
274
+ # Intro
275
+ --- slide ---
276
+ # Next`,
277
+ "utf-8",
278
+ );
279
+
280
+ const files: ContentFile[] = [
281
+ { path: mdPath, urlPath: "slides/deck", section: "Slides", sectionBase: "slides" },
282
+ ];
283
+
284
+ const slides = await collectSlides(files);
285
+ expect(slides).toHaveLength(2);
286
+ expect(slides[0].id).toBe("slide-1");
287
+ expect(slides[1].id).toBe("slide-2");
288
+ expect(slides[0].title).toBe("Intro");
289
+ expect(slides[1].title).toBe("Next");
290
+ expect(slides[0].kind).toBe("markdown");
291
+ });
292
+
293
+ it("collects yaml/json files as single non-markdown slides", async () => {
294
+ const dir = await mkdtemp(join(tmpdir(), "kitfly-slides-kinds-"));
295
+ tempDirs.push(dir);
296
+ const yamlPath = join(dir, "config.yaml");
297
+ const jsonPath = join(dir, "data.json");
298
+ await writeFile(yamlPath, "name: test\n", "utf-8");
299
+ await writeFile(jsonPath, '{"ok":true}\n', "utf-8");
300
+
301
+ const files: ContentFile[] = [
302
+ { path: yamlPath, urlPath: "ref/config", section: "Reference", sectionBase: "ref" },
303
+ { path: jsonPath, urlPath: "ref/data", section: "Reference", sectionBase: "ref" },
304
+ ];
305
+ const slides = await collectSlides(files);
306
+ expect(slides).toHaveLength(2);
307
+ expect(slides[0].kind).toBe("yaml");
308
+ expect(slides[1].kind).toBe("json");
309
+ expect(slides[0].title).toBe("config");
310
+ expect(slides[1].title).toBe("data");
311
+ });
312
+
313
+ it("builds slide nav grouped by section using slide ids", () => {
314
+ const nav = buildSlideNav(
315
+ [
316
+ {
317
+ index: 0,
318
+ frontmatter: {},
319
+ body: "# A",
320
+ title: "Slide A",
321
+ id: "slide-1",
322
+ section: "Slides",
323
+ sourcePath: "/tmp/a.md",
324
+ sourceUrlPath: "slides/a",
325
+ kind: "markdown",
326
+ },
327
+ {
328
+ index: 1,
329
+ frontmatter: {},
330
+ body: "# B",
331
+ title: "Slide B",
332
+ id: "slide-2",
333
+ section: "Slides",
334
+ sourcePath: "/tmp/b.md",
335
+ sourceUrlPath: "slides/b",
336
+ kind: "markdown",
337
+ },
338
+ ],
339
+ {
340
+ docroot: ".",
341
+ title: "Deck",
342
+ brand: { name: "Test", url: "/" },
343
+ sections: [{ name: "Slides", path: "slides" }],
344
+ },
345
+ "slide-2",
346
+ );
347
+ expect(nav).toContain('<a href="#slide-1">Slide A</a>');
348
+ expect(nav).toContain('<a href="#slide-2" class="active">Slide B</a>');
349
+ expect(nav).toContain('<span class="nav-section">Slides</span>');
350
+ });
351
+ });
352
+
353
+ describe("rewriteRelativeAssetUrls", () => {
354
+ it("rewrites image sources relative to the source markdown path", () => {
355
+ const html = '<p><img src="./img/diagram.png" alt="diagram"></p>';
356
+ const rewritten = rewriteRelativeAssetUrls(html, "slides/deck", "/");
357
+ expect(rewritten).toContain('src="/slides/img/diagram.png"');
358
+ });
359
+
360
+ it("preserves external and anchor refs", () => {
361
+ const html = '<a href="https://example.com">ext</a> <a href="#slide-2">hash</a>';
362
+ const rewritten = rewriteRelativeAssetUrls(html, "slides/deck", "./");
363
+ expect(rewritten).toContain('href="https://example.com"');
364
+ expect(rewritten).toContain('href="#slide-2"');
365
+ });
366
+
367
+ it("keeps query/hash suffix when rewriting", () => {
368
+ const html = '<a href="../files/report.pdf?dl=1#v2">report</a>';
369
+ const rewritten = rewriteRelativeAssetUrls(html, "slides/deck", "./");
370
+ expect(rewritten).toContain('href="./files/report.pdf?dl=1#v2"');
371
+ });
372
+
373
+ it("does not rewrite non-asset href links", () => {
374
+ const html = '<a href="other.md">doc</a> <a href="./other.md">doc2</a>';
375
+ const rewritten = rewriteRelativeAssetUrls(html, "slides/deck", "./");
376
+ expect(rewritten).toContain('href="other.md"');
377
+ expect(rewritten).toContain('href="./other.md"');
378
+ });
91
379
  });
92
380
 
93
381
  // ---------------------------------------------------------------------------
@@ -818,6 +1106,8 @@ describe("loadSiteConfig", () => {
818
1106
  const result = await loadSiteConfig("/nonexistent/path", "Default Title");
819
1107
  expect(result.docroot).toBe(".");
820
1108
  expect(result.title).toBe("Default Title");
1109
+ expect(result.mode).toBe("docs");
1110
+ expect(result.aspect).toBe("16/9");
821
1111
  expect(result.brand.name).toBe("Handbook");
822
1112
  expect(result.sections).toEqual([]);
823
1113
  });
@@ -887,6 +1177,58 @@ ${links}
887
1177
  await rm(dir, { recursive: true, force: true });
888
1178
  }
889
1179
  });
1180
+
1181
+ it("parses slides mode and aspect from site.yaml", async () => {
1182
+ const dir = await mkdtemp(join(tmpdir(), "kitfly-slides-config-"));
1183
+ try {
1184
+ await writeFile(
1185
+ join(dir, "site.yaml"),
1186
+ `title: Slides
1187
+ mode: slides
1188
+ aspect: "4/3"
1189
+ brand:
1190
+ name: Test
1191
+ url: /
1192
+ sections:
1193
+ - name: Deck
1194
+ path: slides
1195
+ `,
1196
+ "utf-8",
1197
+ );
1198
+
1199
+ const config = await loadSiteConfig(dir);
1200
+ expect(config.mode).toBe("slides");
1201
+ expect(config.aspect).toBe("4/3");
1202
+ } finally {
1203
+ await rm(dir, { recursive: true, force: true });
1204
+ }
1205
+ });
1206
+
1207
+ it("falls back to default docs mode and aspect when invalid", async () => {
1208
+ const dir = await mkdtemp(join(tmpdir(), "kitfly-slides-defaults-"));
1209
+ try {
1210
+ await writeFile(
1211
+ join(dir, "site.yaml"),
1212
+ `title: Defaults
1213
+ mode: invalid
1214
+ aspect: "21/9"
1215
+ brand:
1216
+ name: Test
1217
+ url: /
1218
+ sections:
1219
+ - name: Deck
1220
+ path: slides
1221
+ `,
1222
+ "utf-8",
1223
+ );
1224
+
1225
+ const config = await loadSiteConfig(dir);
1226
+ expect(config.mode).toBe("docs");
1227
+ expect(config.aspect).toBe("16/9");
1228
+ } finally {
1229
+ await rm(dir, { recursive: true, force: true });
1230
+ }
1231
+ });
890
1232
  });
891
1233
 
892
1234
  // ---------------------------------------------------------------------------
@@ -1017,6 +1359,115 @@ describe("resolveSiteVersion", () => {
1017
1359
  const version = await resolveSiteVersion("/nonexistent/path", "9.9.9");
1018
1360
  expect(version).toBe("9.9.9");
1019
1361
  });
1362
+
1363
+ it("resolves auto from VERSION first non-empty line", async () => {
1364
+ const dir = await mkdtemp(join(tmpdir(), "kitfly-version-auto-"));
1365
+ try {
1366
+ await writeFile(join(dir, "VERSION"), "\n \n 1.2.3 \n2.0.0\n", "utf-8");
1367
+ const version = await resolveSiteVersion(dir, "auto");
1368
+ expect(version).toBe("1.2.3");
1369
+ } finally {
1370
+ await rm(dir, { recursive: true, force: true });
1371
+ }
1372
+ });
1373
+
1374
+ it("resolves version from file path with spaces", async () => {
1375
+ const dir = await mkdtemp(join(tmpdir(), "kitfly-version-file-"));
1376
+ try {
1377
+ await mkdir(join(dir, "meta"), { recursive: true });
1378
+ await writeFile(join(dir, "meta", "site version.txt"), "2026.02.12\n", "utf-8");
1379
+ const version = await resolveSiteVersion(dir, "file:./meta/site version.txt");
1380
+ expect(version).toBe("2026.02.12");
1381
+ } finally {
1382
+ await rm(dir, { recursive: true, force: true });
1383
+ }
1384
+ });
1385
+
1386
+ it("rejects absolute file path and falls back", async () => {
1387
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
1388
+ try {
1389
+ const version = await resolveSiteVersion("/nonexistent/path", "file:/etc/hostname");
1390
+ expect(version).toBeUndefined();
1391
+ expect(warn).toHaveBeenCalledWith(
1392
+ expect.stringContaining("version file: absolute paths are not allowed"),
1393
+ );
1394
+ } finally {
1395
+ warn.mockRestore();
1396
+ }
1397
+ });
1398
+
1399
+ it("rejects windows absolute file path and falls back", async () => {
1400
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
1401
+ try {
1402
+ const version = await resolveSiteVersion("/nonexistent/path", "file:C:\\temp\\VERSION");
1403
+ expect(version).toBeUndefined();
1404
+ expect(warn).toHaveBeenCalledWith(
1405
+ expect.stringContaining("version file: absolute paths are not allowed"),
1406
+ );
1407
+ } finally {
1408
+ warn.mockRestore();
1409
+ }
1410
+ });
1411
+
1412
+ it("rejects windows drive-relative file path and falls back", async () => {
1413
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
1414
+ try {
1415
+ const version = await resolveSiteVersion("/nonexistent/path", "file:C:VERSION");
1416
+ expect(version).toBeUndefined();
1417
+ expect(warn).toHaveBeenCalledWith(
1418
+ expect.stringContaining("version file: absolute paths are not allowed"),
1419
+ );
1420
+ } finally {
1421
+ warn.mockRestore();
1422
+ }
1423
+ });
1424
+
1425
+ it("rejects path that escapes site root and falls back", async () => {
1426
+ const dir = await mkdtemp(join(tmpdir(), "kitfly-version-escape-"));
1427
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
1428
+ try {
1429
+ const version = await resolveSiteVersion(dir, "file:../../etc/passwd");
1430
+ expect(version).toBeUndefined();
1431
+ expect(warn).toHaveBeenCalledWith(
1432
+ expect.stringContaining("version file: path escapes site root"),
1433
+ );
1434
+ } finally {
1435
+ warn.mockRestore();
1436
+ await rm(dir, { recursive: true, force: true });
1437
+ }
1438
+ });
1439
+
1440
+ it("warns for empty file path and falls back", async () => {
1441
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
1442
+ try {
1443
+ const version = await resolveSiteVersion("/nonexistent/path", "file:");
1444
+ expect(version).toBeUndefined();
1445
+ expect(warn).toHaveBeenCalledWith("version file: path is empty");
1446
+ } finally {
1447
+ warn.mockRestore();
1448
+ }
1449
+ });
1450
+
1451
+ it("falls back when auto VERSION file is missing", async () => {
1452
+ const dir = await mkdtemp(join(tmpdir(), "kitfly-version-auto-missing-"));
1453
+ try {
1454
+ const version = await resolveSiteVersion(dir, "auto");
1455
+ expect(version).toBeUndefined();
1456
+ } finally {
1457
+ await rm(dir, { recursive: true, force: true });
1458
+ }
1459
+ });
1460
+
1461
+ it("falls back when auto VERSION file is empty", async () => {
1462
+ const dir = await mkdtemp(join(tmpdir(), "kitfly-version-auto-empty-"));
1463
+ try {
1464
+ await writeFile(join(dir, "VERSION"), "\n \n\t\n", "utf-8");
1465
+ const version = await resolveSiteVersion(dir, "auto");
1466
+ expect(version).toBeUndefined();
1467
+ } finally {
1468
+ await rm(dir, { recursive: true, force: true });
1469
+ }
1470
+ });
1020
1471
  });
1021
1472
 
1022
1473
  // ---------------------------------------------------------------------------
@@ -0,0 +1,28 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { describe, expect, it } from "vitest";
4
+ import { validateSlidesVisualsFences } from "../shared.ts";
5
+
6
+ const FIXTURES = join(__dirname, "fixtures", "fences", "slides-visuals");
7
+
8
+ describe("slides-visuals fence contract", () => {
9
+ it("accepts valid fixtures", async () => {
10
+ const dir = join(FIXTURES, "valid");
11
+ const files = await readdir(dir);
12
+ for (const f of files) {
13
+ const md = await readFile(join(dir, f), "utf-8");
14
+ const diags = validateSlidesVisualsFences(md);
15
+ expect(diags, `${f} should be valid`).toHaveLength(0);
16
+ }
17
+ });
18
+
19
+ it("rejects invalid fixtures", async () => {
20
+ const dir = join(FIXTURES, "invalid");
21
+ const files = await readdir(dir);
22
+ for (const f of files) {
23
+ const md = await readFile(join(dir, f), "utf-8");
24
+ const diags = validateSlidesVisualsFences(md);
25
+ expect(diags.length, `${f} should be invalid`).toBeGreaterThan(0);
26
+ }
27
+ });
28
+ });
@@ -0,0 +1,147 @@
1
+ import { expect, test } from "bun:test";
2
+
3
+ type SlidesVisualsTestHooks = {
4
+ parseBodyNodesWithFirstLines: (
5
+ firstLines: string[],
6
+ between: any[],
7
+ end: any,
8
+ type: string,
9
+ ) => Record<string, unknown>;
10
+ rowCells: (row: unknown) => string[];
11
+ };
12
+
13
+ class FakeElement {
14
+ tagName: string;
15
+ textContent: string;
16
+ #children: FakeElement[];
17
+
18
+ constructor(tagName: string, textContent = "", children: FakeElement[] = []) {
19
+ this.tagName = tagName;
20
+ this.textContent = textContent;
21
+ this.#children = children;
22
+ }
23
+
24
+ querySelectorAll(selector: string): FakeElement[] {
25
+ if (selector === ":scope > li") return this.#children;
26
+ if (selector === ":scope > p") return [];
27
+ return [];
28
+ }
29
+ }
30
+
31
+ async function loadHooks(): Promise<SlidesVisualsTestHooks> {
32
+ // Executes the plugin file and registers hooks on globalThis (in non-DOM test env).
33
+ // @ts-expect-error — JS plugin file, no declaration needed
34
+ await import("../../plugins-dist/slides-visuals.js");
35
+ const hooks = (globalThis as any).__kitflySlidesVisualsTest as SlidesVisualsTestHooks | undefined;
36
+ if (!hooks) throw new Error("slides-visuals test hooks not found on globalThis");
37
+ return hooks;
38
+ }
39
+
40
+ test("slides-visuals: absorbed scalar marker preserves preceding item (compare)", async () => {
41
+ const { parseBodyNodesWithFirstLines } = await loadHooks();
42
+
43
+ const out = parseBodyNodesWithFirstLines(
44
+ ['left-title: "Build In-House"', "left:"],
45
+ [],
46
+ new FakeElement("UL", "", [
47
+ new FakeElement("LI", "Ongoing maintenance burden\nright-title: Auth0 / Clerk"),
48
+ new FakeElement("LI", "Production-ready in 2 weeks"),
49
+ ]),
50
+ "compare",
51
+ );
52
+
53
+ expect(out["left-title"]).toBe("Build In-House");
54
+ expect(out["right-title"]).toBe("Auth0 / Clerk");
55
+ expect(out.left).toEqual(["Ongoing maintenance burden"]);
56
+ expect(out.right).toEqual(["Production-ready in 2 weeks"]);
57
+ });
58
+
59
+ test("slides-visuals: absorbed list marker preserves preceding item (comparison-table)", async () => {
60
+ const { parseBodyNodesWithFirstLines } = await loadHooks();
61
+
62
+ const out = parseBodyNodesWithFirstLines(
63
+ ["headers:"],
64
+ [],
65
+ new FakeElement("UL", "", [
66
+ new FakeElement("LI", "Feature"),
67
+ new FakeElement("LI", "Us"),
68
+ new FakeElement("LI", "Competitor A"),
69
+ new FakeElement("LI", "Competitor B\nrows:"),
70
+ new FakeElement("LI", '["Real-time sync", "Yes", "Yes", "No"]'),
71
+ ]),
72
+ "comparison-table",
73
+ );
74
+
75
+ expect(out.headers).toEqual(["Feature", "Us", "Competitor A", "Competitor B"]);
76
+ expect(out.rows).toEqual(['["Real-time sync", "Yes", "Yes", "No"]']);
77
+ });
78
+
79
+ test("slides-visuals: strips trailing ::: from string list items (layer-cake)", async () => {
80
+ const { parseBodyNodesWithFirstLines } = await loadHooks();
81
+
82
+ const out = parseBodyNodesWithFirstLines(
83
+ ["layers:"],
84
+ [],
85
+ new FakeElement("UL", "", [new FakeElement("LI", "Infrastructure :::")]),
86
+ "layer-cake",
87
+ );
88
+
89
+ expect(out.layers).toEqual(["Infrastructure"]);
90
+ });
91
+
92
+ test("slides-visuals: parses object list items for stat-grid metrics", async () => {
93
+ const { parseBodyNodesWithFirstLines } = await loadHooks();
94
+
95
+ const out = parseBodyNodesWithFirstLines(
96
+ ["metrics:"],
97
+ [],
98
+ new FakeElement("UL", "", [
99
+ new FakeElement("LI", "label: Users\nvalue: 1.2M\ntrend: +6%"),
100
+ new FakeElement("LI", "label: MRR\nvalue: $240k"),
101
+ ]),
102
+ "stat-grid",
103
+ );
104
+
105
+ expect(out.metrics).toEqual([
106
+ { label: "Users", value: "1.2M", trend: "+6%" },
107
+ { label: "MRR", value: "$240k" },
108
+ ]);
109
+ });
110
+
111
+ test("slides-visuals: rowCells parses JSON array strings", async () => {
112
+ const hooks = await loadHooks();
113
+ expect(hooks.rowCells('["A", "B", "C"]')).toEqual(["A", "B", "C"]);
114
+ });
115
+
116
+ test("slides-visuals: absorbed scalar marker preserves preceding item (flow-converging)", async () => {
117
+ const { parseBodyNodesWithFirstLines } = await loadHooks();
118
+
119
+ const out = parseBodyNodesWithFirstLines(
120
+ ["sources:"],
121
+ [],
122
+ new FakeElement("UL", "", [
123
+ new FakeElement("LI", "Frontend Logs\ntarget: Dashboard"),
124
+ new FakeElement("LI", "API Logs"),
125
+ ]),
126
+ "flow-converging",
127
+ );
128
+
129
+ expect(out.target).toBe("Dashboard");
130
+ expect(out.sources).toEqual(["Frontend Logs", "API Logs"]);
131
+ });
132
+
133
+ test("slides-visuals: parses object list items for timeline events", async () => {
134
+ const { parseBodyNodesWithFirstLines } = await loadHooks();
135
+
136
+ const out = parseBodyNodesWithFirstLines(
137
+ ["events:"],
138
+ [],
139
+ new FakeElement("UL", "", [
140
+ new FakeElement("LI", "label: Kickoff\ndate: Jan 2026"),
141
+ new FakeElement("LI", "label: Alpha"),
142
+ ]),
143
+ "timeline-horizontal",
144
+ );
145
+
146
+ expect(out.events).toEqual([{ label: "Kickoff", date: "Jan 2026" }, { label: "Alpha" }]);
147
+ });