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
@@ -14,9 +14,11 @@ 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,
@@ -32,8 +34,11 @@ import {
32
34
  parseValue,
33
35
  parseYaml,
34
36
  resolveSiteVersion,
37
+ rewriteRelativeAssetUrls,
35
38
  type SiteConfig,
39
+ segmentSlides,
36
40
  slugify,
41
+ splitSlides,
37
42
  stripQuotes,
38
43
  toUrlPath,
39
44
  validatePath,
@@ -88,6 +93,266 @@ description: A test page
88
93
  expect(Object.keys(frontmatter)).toHaveLength(0);
89
94
  expect(body).toBe(content);
90
95
  });
96
+
97
+ it("extracts frontmatter with leading whitespace (delimiter-split slides)", () => {
98
+ const content = `\n\n ---\n title: Slide Two\n ---\n\n# Two`;
99
+ const { frontmatter, body } = parseFrontmatter(content);
100
+ expect(frontmatter.title).toBe("Slide Two");
101
+ expect(body.trim()).toBe("# Two");
102
+ });
103
+ });
104
+
105
+ describe("splitSlides", () => {
106
+ it("splits markdown on explicit slide delimiter", () => {
107
+ const input = `# One
108
+
109
+ --- slide ---
110
+
111
+ # Two`;
112
+ const slides = splitSlides(input);
113
+ expect(slides).toHaveLength(2);
114
+ expect(slides[0]).toContain("# One");
115
+ expect(slides[1]).toContain("# Two");
116
+ });
117
+
118
+ it("does not split on plain horizontal rules", () => {
119
+ const input = `# One
120
+
121
+ ---
122
+
123
+ Still one slide`;
124
+ const slides = splitSlides(input);
125
+ expect(slides).toHaveLength(1);
126
+ });
127
+
128
+ it("ignores delimiter text inside fenced code blocks", () => {
129
+ const input = `# One
130
+
131
+ \`\`\`md
132
+ --- slide ---
133
+ \`\`\`
134
+
135
+ --- slide ---
136
+
137
+ # Two`;
138
+ const slides = splitSlides(input);
139
+ expect(slides).toHaveLength(2);
140
+ expect(slides[0]).toContain("```md");
141
+ expect(slides[0]).toContain("--- slide ---");
142
+ });
143
+
144
+ it("does not break on 4-backtick fences containing 3-backtick lines", () => {
145
+ const input = `\`\`\`\`md
146
+ \`\`\` still code
147
+ --- slide ---
148
+ \`\`\`\`
149
+ --- slide ---
150
+ # Real slide`;
151
+ const slides = splitSlides(input);
152
+ expect(slides).toHaveLength(2);
153
+ expect(slides[0]).toContain("--- slide ---");
154
+ expect(slides[1].trim()).toBe("# Real slide");
155
+ });
156
+ });
157
+
158
+ describe("segmentSlides", () => {
159
+ it("uses frontmatter title and class when present", () => {
160
+ const input = `---
161
+ title: Intro Slide
162
+ class: two-column
163
+ ---
164
+
165
+ # Welcome`;
166
+ const segments = segmentSlides(input, "Deck");
167
+ expect(segments).toHaveLength(1);
168
+ expect(segments[0].title).toBe("Intro Slide");
169
+ expect(segments[0].className).toBe("two-column");
170
+ });
171
+
172
+ it("falls back to first heading when frontmatter title is missing", () => {
173
+ const input = `# Architecture Overview
174
+
175
+ Body`;
176
+ const segments = segmentSlides(input, "Deck");
177
+ expect(segments[0].title).toBe("Architecture Overview");
178
+ });
179
+
180
+ it("ignores headings inside fenced code blocks when deriving title", () => {
181
+ const input = `\`\`\`md
182
+ # Not a real heading
183
+ \`\`\`
184
+
185
+ # Real Heading`;
186
+ const segments = segmentSlides(input, "Deck");
187
+ expect(segments[0].title).toBe("Real Heading");
188
+ });
189
+
190
+ it("falls back to indexed title when no frontmatter title or heading exists", () => {
191
+ const input = `Just text
192
+ --- slide ---
193
+ More text`;
194
+ const segments = segmentSlides(input, "Runbook Deck");
195
+ expect(segments).toHaveLength(2);
196
+ expect(segments[0].title).toBe("Runbook Deck (1)");
197
+ expect(segments[1].title).toBe("Runbook Deck (2)");
198
+ });
199
+
200
+ it("parses frontmatter per slide segment", () => {
201
+ const input = `---
202
+ title: First
203
+ ---
204
+
205
+ # One
206
+ --- slide ---
207
+ ---
208
+ title: Second
209
+ class: centered
210
+ ---
211
+
212
+ # Two`;
213
+ const segments = segmentSlides(input, "Deck");
214
+ expect(segments).toHaveLength(2);
215
+ expect(segments[0].title).toBe("First");
216
+ expect(segments[1].title).toBe("Second");
217
+ expect(segments[1].className).toBe("centered");
218
+ });
219
+
220
+ it("sanitizes frontmatter class to safe class tokens", () => {
221
+ const input = `---
222
+ class: centered two-column "><img src=x onerror=alert(1)>
223
+ ---
224
+
225
+ # Safe classes only`;
226
+ const segments = segmentSlides(input, "Deck");
227
+ expect(segments[0].className).toBe("centered two-column");
228
+ });
229
+ });
230
+
231
+ describe("collectSlides and buildSlideNav", () => {
232
+ const tempDirs: string[] = [];
233
+
234
+ afterEach(async () => {
235
+ for (const dir of tempDirs) {
236
+ await rm(dir, { recursive: true, force: true });
237
+ }
238
+ tempDirs.length = 0;
239
+ });
240
+
241
+ it("collects segmented markdown slides and assigns sequential ids", async () => {
242
+ const dir = await mkdtemp(join(tmpdir(), "kitfly-slides-collect-"));
243
+ tempDirs.push(dir);
244
+ const mdPath = join(dir, "deck.md");
245
+ await writeFile(
246
+ mdPath,
247
+ `---
248
+ title: Intro
249
+ ---
250
+
251
+ # Intro
252
+ --- slide ---
253
+ # Next`,
254
+ "utf-8",
255
+ );
256
+
257
+ const files: ContentFile[] = [
258
+ { path: mdPath, urlPath: "slides/deck", section: "Slides", sectionBase: "slides" },
259
+ ];
260
+
261
+ const slides = await collectSlides(files);
262
+ expect(slides).toHaveLength(2);
263
+ expect(slides[0].id).toBe("slide-1");
264
+ expect(slides[1].id).toBe("slide-2");
265
+ expect(slides[0].title).toBe("Intro");
266
+ expect(slides[1].title).toBe("Next");
267
+ expect(slides[0].kind).toBe("markdown");
268
+ });
269
+
270
+ it("collects yaml/json files as single non-markdown slides", async () => {
271
+ const dir = await mkdtemp(join(tmpdir(), "kitfly-slides-kinds-"));
272
+ tempDirs.push(dir);
273
+ const yamlPath = join(dir, "config.yaml");
274
+ const jsonPath = join(dir, "data.json");
275
+ await writeFile(yamlPath, "name: test\n", "utf-8");
276
+ await writeFile(jsonPath, '{"ok":true}\n', "utf-8");
277
+
278
+ const files: ContentFile[] = [
279
+ { path: yamlPath, urlPath: "ref/config", section: "Reference", sectionBase: "ref" },
280
+ { path: jsonPath, urlPath: "ref/data", section: "Reference", sectionBase: "ref" },
281
+ ];
282
+ const slides = await collectSlides(files);
283
+ expect(slides).toHaveLength(2);
284
+ expect(slides[0].kind).toBe("yaml");
285
+ expect(slides[1].kind).toBe("json");
286
+ expect(slides[0].title).toBe("config");
287
+ expect(slides[1].title).toBe("data");
288
+ });
289
+
290
+ it("builds slide nav grouped by section using slide ids", () => {
291
+ const nav = buildSlideNav(
292
+ [
293
+ {
294
+ index: 0,
295
+ frontmatter: {},
296
+ body: "# A",
297
+ title: "Slide A",
298
+ id: "slide-1",
299
+ section: "Slides",
300
+ sourcePath: "/tmp/a.md",
301
+ sourceUrlPath: "slides/a",
302
+ kind: "markdown",
303
+ },
304
+ {
305
+ index: 1,
306
+ frontmatter: {},
307
+ body: "# B",
308
+ title: "Slide B",
309
+ id: "slide-2",
310
+ section: "Slides",
311
+ sourcePath: "/tmp/b.md",
312
+ sourceUrlPath: "slides/b",
313
+ kind: "markdown",
314
+ },
315
+ ],
316
+ {
317
+ docroot: ".",
318
+ title: "Deck",
319
+ brand: { name: "Test", url: "/" },
320
+ sections: [{ name: "Slides", path: "slides" }],
321
+ },
322
+ "slide-2",
323
+ );
324
+ expect(nav).toContain('<a href="#slide-1">Slide A</a>');
325
+ expect(nav).toContain('<a href="#slide-2" class="active">Slide B</a>');
326
+ expect(nav).toContain('<span class="nav-section">Slides</span>');
327
+ });
328
+ });
329
+
330
+ describe("rewriteRelativeAssetUrls", () => {
331
+ it("rewrites image sources relative to the source markdown path", () => {
332
+ const html = '<p><img src="./img/diagram.png" alt="diagram"></p>';
333
+ const rewritten = rewriteRelativeAssetUrls(html, "slides/deck", "/");
334
+ expect(rewritten).toContain('src="/slides/img/diagram.png"');
335
+ });
336
+
337
+ it("preserves external and anchor refs", () => {
338
+ const html = '<a href="https://example.com">ext</a> <a href="#slide-2">hash</a>';
339
+ const rewritten = rewriteRelativeAssetUrls(html, "slides/deck", "./");
340
+ expect(rewritten).toContain('href="https://example.com"');
341
+ expect(rewritten).toContain('href="#slide-2"');
342
+ });
343
+
344
+ it("keeps query/hash suffix when rewriting", () => {
345
+ const html = '<a href="../files/report.pdf?dl=1#v2">report</a>';
346
+ const rewritten = rewriteRelativeAssetUrls(html, "slides/deck", "./");
347
+ expect(rewritten).toContain('href="./files/report.pdf?dl=1#v2"');
348
+ });
349
+
350
+ it("does not rewrite non-asset href links", () => {
351
+ const html = '<a href="other.md">doc</a> <a href="./other.md">doc2</a>';
352
+ const rewritten = rewriteRelativeAssetUrls(html, "slides/deck", "./");
353
+ expect(rewritten).toContain('href="other.md"');
354
+ expect(rewritten).toContain('href="./other.md"');
355
+ });
91
356
  });
92
357
 
93
358
  // ---------------------------------------------------------------------------
@@ -818,6 +1083,8 @@ describe("loadSiteConfig", () => {
818
1083
  const result = await loadSiteConfig("/nonexistent/path", "Default Title");
819
1084
  expect(result.docroot).toBe(".");
820
1085
  expect(result.title).toBe("Default Title");
1086
+ expect(result.mode).toBe("docs");
1087
+ expect(result.aspect).toBe("16/9");
821
1088
  expect(result.brand.name).toBe("Handbook");
822
1089
  expect(result.sections).toEqual([]);
823
1090
  });
@@ -887,6 +1154,58 @@ ${links}
887
1154
  await rm(dir, { recursive: true, force: true });
888
1155
  }
889
1156
  });
1157
+
1158
+ it("parses slides mode and aspect from site.yaml", async () => {
1159
+ const dir = await mkdtemp(join(tmpdir(), "kitfly-slides-config-"));
1160
+ try {
1161
+ await writeFile(
1162
+ join(dir, "site.yaml"),
1163
+ `title: Slides
1164
+ mode: slides
1165
+ aspect: "4/3"
1166
+ brand:
1167
+ name: Test
1168
+ url: /
1169
+ sections:
1170
+ - name: Deck
1171
+ path: slides
1172
+ `,
1173
+ "utf-8",
1174
+ );
1175
+
1176
+ const config = await loadSiteConfig(dir);
1177
+ expect(config.mode).toBe("slides");
1178
+ expect(config.aspect).toBe("4/3");
1179
+ } finally {
1180
+ await rm(dir, { recursive: true, force: true });
1181
+ }
1182
+ });
1183
+
1184
+ it("falls back to default docs mode and aspect when invalid", async () => {
1185
+ const dir = await mkdtemp(join(tmpdir(), "kitfly-slides-defaults-"));
1186
+ try {
1187
+ await writeFile(
1188
+ join(dir, "site.yaml"),
1189
+ `title: Defaults
1190
+ mode: invalid
1191
+ aspect: "21/9"
1192
+ brand:
1193
+ name: Test
1194
+ url: /
1195
+ sections:
1196
+ - name: Deck
1197
+ path: slides
1198
+ `,
1199
+ "utf-8",
1200
+ );
1201
+
1202
+ const config = await loadSiteConfig(dir);
1203
+ expect(config.mode).toBe("docs");
1204
+ expect(config.aspect).toBe("16/9");
1205
+ } finally {
1206
+ await rm(dir, { recursive: true, force: true });
1207
+ }
1208
+ });
890
1209
  });
891
1210
 
892
1211
  // ---------------------------------------------------------------------------
@@ -1017,6 +1336,115 @@ describe("resolveSiteVersion", () => {
1017
1336
  const version = await resolveSiteVersion("/nonexistent/path", "9.9.9");
1018
1337
  expect(version).toBe("9.9.9");
1019
1338
  });
1339
+
1340
+ it("resolves auto from VERSION first non-empty line", async () => {
1341
+ const dir = await mkdtemp(join(tmpdir(), "kitfly-version-auto-"));
1342
+ try {
1343
+ await writeFile(join(dir, "VERSION"), "\n \n 1.2.3 \n2.0.0\n", "utf-8");
1344
+ const version = await resolveSiteVersion(dir, "auto");
1345
+ expect(version).toBe("1.2.3");
1346
+ } finally {
1347
+ await rm(dir, { recursive: true, force: true });
1348
+ }
1349
+ });
1350
+
1351
+ it("resolves version from file path with spaces", async () => {
1352
+ const dir = await mkdtemp(join(tmpdir(), "kitfly-version-file-"));
1353
+ try {
1354
+ await mkdir(join(dir, "meta"), { recursive: true });
1355
+ await writeFile(join(dir, "meta", "site version.txt"), "2026.02.12\n", "utf-8");
1356
+ const version = await resolveSiteVersion(dir, "file:./meta/site version.txt");
1357
+ expect(version).toBe("2026.02.12");
1358
+ } finally {
1359
+ await rm(dir, { recursive: true, force: true });
1360
+ }
1361
+ });
1362
+
1363
+ it("rejects absolute file path and falls back", async () => {
1364
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
1365
+ try {
1366
+ const version = await resolveSiteVersion("/nonexistent/path", "file:/etc/hostname");
1367
+ expect(version).toBeUndefined();
1368
+ expect(warn).toHaveBeenCalledWith(
1369
+ expect.stringContaining("version file: absolute paths are not allowed"),
1370
+ );
1371
+ } finally {
1372
+ warn.mockRestore();
1373
+ }
1374
+ });
1375
+
1376
+ it("rejects windows absolute file path and falls back", async () => {
1377
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
1378
+ try {
1379
+ const version = await resolveSiteVersion("/nonexistent/path", "file:C:\\temp\\VERSION");
1380
+ expect(version).toBeUndefined();
1381
+ expect(warn).toHaveBeenCalledWith(
1382
+ expect.stringContaining("version file: absolute paths are not allowed"),
1383
+ );
1384
+ } finally {
1385
+ warn.mockRestore();
1386
+ }
1387
+ });
1388
+
1389
+ it("rejects windows drive-relative file path and falls back", async () => {
1390
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
1391
+ try {
1392
+ const version = await resolveSiteVersion("/nonexistent/path", "file:C:VERSION");
1393
+ expect(version).toBeUndefined();
1394
+ expect(warn).toHaveBeenCalledWith(
1395
+ expect.stringContaining("version file: absolute paths are not allowed"),
1396
+ );
1397
+ } finally {
1398
+ warn.mockRestore();
1399
+ }
1400
+ });
1401
+
1402
+ it("rejects path that escapes site root and falls back", async () => {
1403
+ const dir = await mkdtemp(join(tmpdir(), "kitfly-version-escape-"));
1404
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
1405
+ try {
1406
+ const version = await resolveSiteVersion(dir, "file:../../etc/passwd");
1407
+ expect(version).toBeUndefined();
1408
+ expect(warn).toHaveBeenCalledWith(
1409
+ expect.stringContaining("version file: path escapes site root"),
1410
+ );
1411
+ } finally {
1412
+ warn.mockRestore();
1413
+ await rm(dir, { recursive: true, force: true });
1414
+ }
1415
+ });
1416
+
1417
+ it("warns for empty file path and falls back", async () => {
1418
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
1419
+ try {
1420
+ const version = await resolveSiteVersion("/nonexistent/path", "file:");
1421
+ expect(version).toBeUndefined();
1422
+ expect(warn).toHaveBeenCalledWith("version file: path is empty");
1423
+ } finally {
1424
+ warn.mockRestore();
1425
+ }
1426
+ });
1427
+
1428
+ it("falls back when auto VERSION file is missing", async () => {
1429
+ const dir = await mkdtemp(join(tmpdir(), "kitfly-version-auto-missing-"));
1430
+ try {
1431
+ const version = await resolveSiteVersion(dir, "auto");
1432
+ expect(version).toBeUndefined();
1433
+ } finally {
1434
+ await rm(dir, { recursive: true, force: true });
1435
+ }
1436
+ });
1437
+
1438
+ it("falls back when auto VERSION file is empty", async () => {
1439
+ const dir = await mkdtemp(join(tmpdir(), "kitfly-version-auto-empty-"));
1440
+ try {
1441
+ await writeFile(join(dir, "VERSION"), "\n \n\t\n", "utf-8");
1442
+ const version = await resolveSiteVersion(dir, "auto");
1443
+ expect(version).toBeUndefined();
1444
+ } finally {
1445
+ await rm(dir, { recursive: true, force: true });
1446
+ }
1447
+ });
1020
1448
  });
1021
1449
 
1022
1450
  // ---------------------------------------------------------------------------
@@ -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,114 @@
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
+ });
@@ -12,3 +12,38 @@ describe("sidebar folder indicator CSS", () => {
12
12
  expect(css).toContain("transform: rotate(90deg)");
13
13
  });
14
14
  });
15
+
16
+ describe("slide layout primitives CSS", () => {
17
+ it("includes core block-flow/grid/stack primitives", async () => {
18
+ const css = await readFile(join(process.cwd(), "src/site/styles.css"), "utf-8");
19
+ expect(css).toContain(".block-flow");
20
+ expect(css).toContain(".block-grid");
21
+ expect(css).toContain(".block-stack");
22
+ expect(css).toContain(".block-label");
23
+ expect(css).toContain(".block-flow:not(.vertical) .block:not(:last-child)::after");
24
+ expect(css).toContain(".block-flow.vertical .block:not(:last-child)::after");
25
+ expect(css).toContain(".block-grid.cols-3");
26
+ expect(css).toContain(".block-grid.cols-4");
27
+ });
28
+
29
+ it("includes shape modifiers for block visuals", async () => {
30
+ const css = await readFile(join(process.cwd(), "src/site/styles.css"), "utf-8");
31
+ expect(css).toContain(".block.circle");
32
+ expect(css).toContain(".block.pill");
33
+ expect(css).toContain(".block.diamond");
34
+ expect(css).toContain(".block.chevron");
35
+ expect(css).toContain(".block.hexagon");
36
+ expect(css).toContain(".block.triangle");
37
+ expect(css).toContain(".block.block-arrow");
38
+ });
39
+
40
+ it("keeps non-active layout-class slides hidden", async () => {
41
+ const css = await readFile(join(process.cwd(), "src/site/styles.css"), "utf-8");
42
+ expect(css).toContain(".slide {");
43
+ expect(css).toContain("display: none;");
44
+ expect(css).toContain(".slide.active.centered");
45
+ expect(css).toContain(".slide.active.two-column");
46
+ expect(css).not.toContain(".slide.centered {\n min-height: 100%;\n display: flex;");
47
+ expect(css).not.toContain(".slide.two-column {\n display: grid;");
48
+ });
49
+ });
package/src/cli.ts CHANGED
@@ -70,7 +70,7 @@ kitfly v${VERSION} - Turn your writing into a website
70
70
  Usage:
71
71
  kitfly dev [folder] Start dev server with hot reload
72
72
  kitfly build [folder] Build static site to dist/
73
- kitfly bundle [folder] Build single-file HTML bundle
73
+ kitfly bundle [folder] Build single-file HTML bundle to bundles/
74
74
  kitfly init [name] Create new project from template
75
75
  kitfly update [version] Update standalone site code
76
76
  kitfly servers List running dev servers
@@ -86,11 +86,15 @@ Dev options:
86
86
  --json Output JSON (implies --daemon)
87
87
  --no-open Don't open browser
88
88
 
89
- Build/bundle options:
89
+ Build options:
90
90
  --out <dir> Output directory [env: KITFLY_BUILD_OUT] (default: dist)
91
- --name <file> Bundle filename (default: bundle.html)
92
91
  --no-raw Don't include raw markdown
93
92
 
93
+ Bundle options:
94
+ --out <dir> Output directory [env: KITFLY_BUNDLE_OUT] (default: bundles)
95
+ --name <file> Bundle filename (default: bundle.html)
96
+ --no-raw Don't include raw markdown [env: KITFLY_BUNDLE_RAW]
97
+
94
98
  Stop options:
95
99
  --force Skip graceful shutdown, kill immediately
96
100
 
@@ -117,6 +121,7 @@ Examples:
117
121
  kitfly logs 3340 --follow
118
122
  kitfly logs --clean
119
123
  kitfly build ./docs --out ./public
124
+ kitfly bundle ./docs --out ./bundles --name docs.html
120
125
  kitfly init my-handbook
121
126
  kitfly update --check
122
127
 
@@ -359,7 +364,7 @@ async function main() {
359
364
 
360
365
  case "bundle": {
361
366
  const folder = positional[0] || ".";
362
- const out = (flags.out as string) || "dist";
367
+ const out = (flags.out as string) || "bundles";
363
368
  const name = (flags.name as string) || "bundle.html";
364
369
  const raw = flags.raw !== false; // --no-raw disables raw markdown
365
370
  const { bundleSite } = await import("../scripts/bundle.ts");