kitfly 0.2.0 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +25 -10
  3. package/VERSION +1 -1
  4. package/dist/_raw/content/guide/branding.md +146 -0
  5. package/dist/_raw/content/guide/data-driven-content.md +204 -0
  6. package/dist/_raw/content/reference/configuration.md +145 -7
  7. package/dist/_raw/content/reference/environment-variables.md +26 -1
  8. package/dist/_raw/content/reference/glossary.md +25 -1
  9. package/dist/_raw/content/reference/key-concepts.md +30 -2
  10. package/dist/_raw/content/reference/plugins.md +14 -0
  11. package/dist/_raw/content/reference/slides-authoring-guidelines.md +129 -0
  12. package/dist/_raw/content/reference.md +1 -0
  13. package/dist/_raw/docs/decisions/ADR-0006-data-driven-content.md +350 -0
  14. package/dist/content/deployment/preflight.html +10 -6
  15. package/dist/content/deployment/recipes/aws-s3.html +10 -6
  16. package/dist/content/deployment/recipes/cloudflare-pages.html +10 -6
  17. package/dist/content/deployment/recipes/cloudflare-r2.html +10 -6
  18. package/dist/content/deployment/recipes/fly-io.html +10 -6
  19. package/dist/content/deployment/recipes/github-pages.html +10 -6
  20. package/dist/content/deployment/recipes/netlify.html +10 -6
  21. package/dist/content/deployment/recipes/vercel.html +10 -6
  22. package/dist/content/deployment/secrets-and-env-vars.html +10 -6
  23. package/dist/content/deployment.html +10 -6
  24. package/dist/content/guide/approaches.html +10 -6
  25. package/dist/content/guide/branding.html +510 -0
  26. package/dist/content/guide/data-driven-content.html +543 -0
  27. package/dist/content/guide/features.html +10 -6
  28. package/dist/content/guide/getting-started.html +10 -6
  29. package/dist/content/guide/kitfly-overview.html +10 -6
  30. package/dist/content/reference/configuration.html +135 -9
  31. package/dist/content/reference/design-catalog.html +10 -6
  32. package/dist/content/reference/environment-variables.html +50 -8
  33. package/dist/content/reference/glossary.html +24 -8
  34. package/dist/content/reference/key-concepts.html +33 -9
  35. package/dist/content/reference/plugins.html +22 -7
  36. package/dist/content/reference/slides-authoring-guidelines.html +422 -0
  37. package/dist/content/reference/structure.html +10 -6
  38. package/dist/content/reference.html +11 -6
  39. package/dist/content/templates/crucible.html +10 -6
  40. package/dist/content/templates/handbook.html +10 -6
  41. package/dist/content/templates/minimal.html +10 -6
  42. package/dist/content/templates/overview.html +10 -6
  43. package/dist/content/templates/pipeline.html +10 -6
  44. package/dist/content/templates/productbook.html +10 -6
  45. package/dist/content/templates/runbook.html +10 -6
  46. package/dist/content/templates/servicebook.html +10 -6
  47. package/dist/content-index.json +38 -2
  48. package/dist/docs/decisions/ADR-0001-minimalist-site-code.html +10 -6
  49. package/dist/docs/decisions/ADR-0002-ai-accessibility.html +10 -6
  50. package/dist/docs/decisions/ADR-0003-single-file-bundle.html +10 -6
  51. package/dist/docs/decisions/ADR-0004-bun-runtime.html +10 -6
  52. package/dist/docs/decisions/ADR-0005-plugin-contract-and-distribution.html +10 -6
  53. package/dist/docs/decisions/ADR-0006-data-driven-content.html +752 -0
  54. package/dist/docs/decisions/DDR-0001-viewport-locked-layout.html +10 -6
  55. package/dist/docs/decisions/DDR-0002-theme-system.html +10 -6
  56. package/dist/docs/decisions/DDR-0003-bounded-logo-slot.html +10 -6
  57. package/dist/docs/decisions/DDR-0004-slides-rendering-model.html +10 -6
  58. package/dist/docs/decisions/DDR-0005-deterministic-layout-boundary.html +10 -6
  59. package/dist/docs/userguide/cli/build.html +10 -6
  60. package/dist/docs/userguide/cli/bundle.html +10 -6
  61. package/dist/docs/userguide/cli/dev.html +10 -6
  62. package/dist/docs/userguide/cli/init.html +10 -6
  63. package/dist/docs/userguide/cli/servers.html +10 -6
  64. package/dist/docs/userguide/cli/stop.html +10 -6
  65. package/dist/docs/userguide/cli/update.html +10 -6
  66. package/dist/docs/userguide/cli/version.html +10 -6
  67. package/dist/docs/userguide/cli.html +10 -6
  68. package/dist/docs/userguide/sharing.html +10 -6
  69. package/dist/index.html +10 -6
  70. package/dist/llms.txt +3 -3
  71. package/dist/provenance.json +4 -4
  72. package/dist/schemas/plugin-registry.schema.html +10 -6
  73. package/dist/schemas/plugin-schemas-notes.html +10 -6
  74. package/dist/schemas/plugin.schema.html +10 -6
  75. package/dist/schemas/plugins.schema.html +10 -6
  76. package/dist/schemas/v0/common.schema.html +14 -10
  77. package/dist/schemas/v0/plugin-registry.schema.html +13 -9
  78. package/dist/schemas/v0/plugin.schema.html +13 -9
  79. package/dist/schemas/v0/plugins.schema.html +13 -9
  80. package/dist/schemas/v0/site.schema.html +67 -7
  81. package/dist/schemas/v0/theme.schema.html +21 -17
  82. package/dist/schemas.html +10 -6
  83. package/dist/styles.css +39 -4
  84. package/package.json +1 -1
  85. package/plugins-dist/latex-runtime.js +140 -0
  86. package/plugins-dist/latex.js +178 -0
  87. package/plugins-dist/slides-charts-lite-runtime.js +179 -0
  88. package/plugins-dist/slides-charts-lite.js +198 -0
  89. package/plugins-dist/slides-visuals.css +166 -0
  90. package/plugins-dist/slides-visuals.js +124 -33
  91. package/registry/plugins.yaml +30 -5
  92. package/schemas/v0/site.schema.json +56 -0
  93. package/scripts/build.ts +195 -70
  94. package/scripts/bundle.ts +122 -11
  95. package/scripts/dev.ts +345 -178
  96. package/src/__tests__/brief.test.ts +151 -0
  97. package/src/__tests__/build.test.ts +234 -4
  98. package/src/__tests__/bundle.test.ts +134 -0
  99. package/src/__tests__/dev-plugin-errors.test.ts +20 -0
  100. package/src/__tests__/fixtures/fences/slides-visuals/invalid/flow-branching-no-source.md +5 -0
  101. package/src/__tests__/fixtures/fences/slides-visuals/invalid/flow-converging-no-target.md +6 -0
  102. package/src/__tests__/fixtures/fences/slides-visuals/invalid/staircase-empty-steps.md +3 -0
  103. package/src/__tests__/fixtures/fences/slides-visuals/invalid/timeline-horizontal-no-events.md +2 -0
  104. package/src/__tests__/fixtures/fences/slides-visuals/valid/flow-branching-no-split.md +7 -0
  105. package/src/__tests__/fixtures/fences/slides-visuals/valid/flow-branching.md +8 -0
  106. package/src/__tests__/fixtures/fences/slides-visuals/valid/flow-converging-no-merge.md +7 -0
  107. package/src/__tests__/fixtures/fences/slides-visuals/valid/flow-converging.md +8 -0
  108. package/src/__tests__/fixtures/fences/slides-visuals/valid/staircase-down.md +7 -0
  109. package/src/__tests__/fixtures/fences/slides-visuals/valid/staircase.md +8 -0
  110. package/src/__tests__/fixtures/fences/slides-visuals/valid/timeline-horizontal.md +9 -0
  111. package/src/__tests__/fixtures/fences/slides-visuals/valid/timeline-vertical.md +10 -0
  112. package/src/__tests__/init.test.ts +51 -2
  113. package/src/__tests__/latex-runtime.bun.test.ts +35 -0
  114. package/src/__tests__/shared.test.ts +621 -1
  115. package/src/__tests__/slides-charts-lite-runtime.bun.test.ts +45 -0
  116. package/src/__tests__/slides-visuals-runtime-regressions.bun.test.ts +33 -0
  117. package/src/cli.ts +11 -4
  118. package/src/commands/init.ts +1 -1
  119. package/src/shared.ts +761 -18
  120. package/src/site/styles.css +39 -4
  121. package/src/site/template.html +5 -2
  122. package/src/templates/brief.ts +486 -0
  123. package/src/templates/deck.ts +59 -0
  124. package/src/templates/driver.ts +46 -13
  125. package/src/templates/handbook.ts +32 -0
  126. package/src/templates/runbook.ts +32 -0
@@ -2,7 +2,7 @@
2
2
  * Basic tests for shared utilities
3
3
  */
4
4
 
5
- import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
5
+ import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
6
6
  import { tmpdir } from "node:os";
7
7
  import { join, resolve } from "node:path";
8
8
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -15,6 +15,7 @@ import {
15
15
  buildNavStatic,
16
16
  buildPageMeta,
17
17
  buildSlideNav,
18
+ buildSlideNavHierarchical,
18
19
  buildToc,
19
20
  type ContentFile,
20
21
  collectFiles,
@@ -24,15 +25,22 @@ import {
24
25
  envString,
25
26
  escapeHtml,
26
27
  exists,
28
+ filterByProfile,
29
+ filterUnknownSlidesVisualsTypeDiagnostics,
27
30
  formatDate,
28
31
  generateProvenance,
29
32
  getGitInfo,
30
33
  KITFLY_BRAND,
34
+ loadDataBindings,
31
35
  loadSiteConfig,
36
+ mergeFrontmatterWithBody,
37
+ normalizeProfileTags,
32
38
  type Provenance,
39
+ pagePathForData,
33
40
  parseFrontmatter,
34
41
  parseValue,
35
42
  parseYaml,
43
+ resolveBindings,
36
44
  resolveSiteVersion,
37
45
  rewriteRelativeAssetUrls,
38
46
  type SiteConfig,
@@ -42,6 +50,7 @@ import {
42
50
  stripQuotes,
43
51
  toUrlPath,
44
52
  validatePath,
53
+ validateSlidesVisualsFences,
45
54
  } from "../shared.ts";
46
55
 
47
56
  describe("slugify", () => {
@@ -102,6 +111,271 @@ description: A test page
102
111
  });
103
112
  });
104
113
 
114
+ describe("normalizeProfileTags", () => {
115
+ it("parses scalar and array-like profile tags", () => {
116
+ expect(normalizeProfileTags("alpha")).toEqual(["alpha"]);
117
+ expect(normalizeProfileTags("[alpha, beta]")).toEqual(["alpha", "beta"]);
118
+ expect(normalizeProfileTags(["alpha", "beta"])).toEqual(["alpha", "beta"]);
119
+ });
120
+
121
+ it("normalizes whitespace, quotes, and case", () => {
122
+ expect(normalizeProfileTags("[\"Alpha\", 'beta']")).toEqual(["alpha", "beta"]);
123
+ });
124
+ });
125
+
126
+ describe("filterByProfile", () => {
127
+ it("keeps all files when profiles are not configured", async () => {
128
+ const root = await mkdtemp(join(tmpdir(), "kitfly-profile-"));
129
+ try {
130
+ const alwaysPath = join(root, "always.md");
131
+ const alphaPath = join(root, "alpha.md");
132
+ const bothPath = join(root, "both.md");
133
+ const betaPath = join(root, "beta.md");
134
+ await writeFile(alwaysPath, "# Always");
135
+ await writeFile(alphaPath, "---\nprofile: alpha\n---\n# Alpha");
136
+ await writeFile(bothPath, "---\nprofile: [alpha, beta]\n---\n# Both");
137
+ await writeFile(betaPath, "---\nprofile: beta\n---\n# Beta");
138
+
139
+ const files: ContentFile[] = [
140
+ { path: alwaysPath, urlPath: "always", section: "Docs" },
141
+ { path: alphaPath, urlPath: "alpha", section: "Docs" },
142
+ { path: bothPath, urlPath: "both", section: "Docs" },
143
+ { path: betaPath, urlPath: "beta", section: "Docs" },
144
+ ];
145
+
146
+ const noProfile = await filterByProfile(files);
147
+ expect(noProfile.map((f) => f.urlPath)).toEqual(["always", "alpha", "both", "beta"]);
148
+ } finally {
149
+ await rm(root, { recursive: true, force: true });
150
+ }
151
+ });
152
+
153
+ it("filters tagged files when profiles are configured", async () => {
154
+ const root = await mkdtemp(join(tmpdir(), "kitfly-profile-"));
155
+ try {
156
+ const alwaysPath = join(root, "always.md");
157
+ const alphaPath = join(root, "alpha.md");
158
+ const bothPath = join(root, "both.md");
159
+ const betaPath = join(root, "beta.md");
160
+ await writeFile(alwaysPath, "# Always");
161
+ await writeFile(alphaPath, "---\nprofile: alpha\n---\n# Alpha");
162
+ await writeFile(bothPath, "---\nprofile: [alpha, beta]\n---\n# Both");
163
+ await writeFile(betaPath, "---\nprofile: beta\n---\n# Beta");
164
+
165
+ const files: ContentFile[] = [
166
+ { path: alwaysPath, urlPath: "always", section: "Docs" },
167
+ { path: alphaPath, urlPath: "alpha", section: "Docs" },
168
+ { path: bothPath, urlPath: "both", section: "Docs" },
169
+ { path: betaPath, urlPath: "beta", section: "Docs" },
170
+ ];
171
+ const profiles = {
172
+ alpha: { include: { tags: ["alpha"] } },
173
+ beta: { include: { tags: ["beta"] } },
174
+ };
175
+
176
+ const noProfile = await filterByProfile(files, undefined, profiles);
177
+ expect(noProfile.map((f) => f.urlPath)).toEqual(["always"]);
178
+
179
+ const alphaProfile = await filterByProfile(files, "alpha", profiles);
180
+ expect(alphaProfile.map((f) => f.urlPath)).toEqual(["always", "alpha", "both"]);
181
+
182
+ const betaProfile = await filterByProfile(files, "beta", profiles);
183
+ expect(betaProfile.map((f) => f.urlPath)).toEqual(["always", "both", "beta"]);
184
+ } finally {
185
+ await rm(root, { recursive: true, force: true });
186
+ }
187
+ });
188
+ });
189
+
190
+ describe("data bindings", () => {
191
+ it("resolves values and snippets with formatter chains", () => {
192
+ const bound = resolveBindings(
193
+ [
194
+ "Rate: {{ baseline | dollar }}",
195
+ "Pct: {{ ratio | percent }}",
196
+ "Rounded: {{ pi | round(2) }}",
197
+ "Upper: {{ label | upper }}",
198
+ "Chain: {{ baseline | round(0) | dollar }}",
199
+ "{{ snippet:table }}",
200
+ ].join("\n"),
201
+ {
202
+ globals: { baseline: "1500", ratio: "0.125", pi: "3.14159" },
203
+ inject: { label: "pricing" },
204
+ snippets: [{ slot: "table", content: "| A | B |\n|---|---|" }],
205
+ },
206
+ "product/pricing.md",
207
+ );
208
+ expect(bound).toContain("Rate: $1,500");
209
+ expect(bound).toContain("Pct: 12.5%");
210
+ expect(bound).toContain("Rounded: 3.14");
211
+ expect(bound).toContain("Upper: PRICING");
212
+ expect(bound).toContain("Chain: $1,500");
213
+ expect(bound).toContain("| A | B |");
214
+ });
215
+
216
+ it("throws on unresolved keys, unknown snippets, and unknown formatters", () => {
217
+ expect(() =>
218
+ resolveBindings("{{ missing }}", { globals: {}, inject: {}, snippets: [] }, "x.md"),
219
+ ).toThrow(/unresolved binding/);
220
+ expect(() =>
221
+ resolveBindings("{{ snippet:nope }}", { globals: {}, inject: {}, snippets: [] }, "x.md"),
222
+ ).toThrow(/unknown snippet/);
223
+ expect(() =>
224
+ resolveBindings(
225
+ "{{ n | custom }}",
226
+ { globals: { n: "1" }, inject: {}, snippets: [] },
227
+ "x.md",
228
+ ),
229
+ ).toThrow(/unknown formatter/);
230
+ });
231
+ });
232
+
233
+ describe("loadDataBindings", () => {
234
+ it("loads page-level inject/snippets and validates optional schema", async () => {
235
+ const root = await mkdtemp(join(tmpdir(), "kitfly-data-"));
236
+ try {
237
+ await mkdir(join(root, "content", "product"), { recursive: true });
238
+ await mkdir(join(root, "data"), { recursive: true });
239
+ await writeFile(
240
+ join(root, "data", "pricing.yaml"),
241
+ [
242
+ "globals:",
243
+ ' baseline: "200"',
244
+ "pages:",
245
+ " - path: product/pricing.md",
246
+ " inject:",
247
+ ' hero: "Implementation costs"',
248
+ " snippets:",
249
+ " - slot: pricing-table",
250
+ " content: |",
251
+ " | Tier | Price |",
252
+ ].join("\n"),
253
+ );
254
+ await writeFile(
255
+ join(root, "data", "pricing.schema.json"),
256
+ JSON.stringify(
257
+ {
258
+ type: "object",
259
+ required: ["globals", "pages"],
260
+ properties: {
261
+ globals: {
262
+ type: "object",
263
+ required: ["baseline"],
264
+ properties: {
265
+ baseline: { type: "string", pattern: "^[0-9]+$" },
266
+ },
267
+ },
268
+ pages: { type: "array" },
269
+ },
270
+ },
271
+ null,
272
+ 2,
273
+ ),
274
+ );
275
+
276
+ const pagePath = pagePathForData(
277
+ root,
278
+ "content",
279
+ join(root, "content", "product", "pricing.md"),
280
+ );
281
+ const bindings = await loadDataBindings(
282
+ "data/pricing.yaml",
283
+ pagePath,
284
+ root,
285
+ "content",
286
+ "data",
287
+ );
288
+ expect(bindings.globals.baseline).toBe("200");
289
+ expect(bindings.inject.hero).toBe("Implementation costs");
290
+ expect(bindings.snippets[0].slot).toBe("pricing-table");
291
+ expect(bindings.snippets[0].content).toContain("| Tier | Price |");
292
+ } finally {
293
+ await rm(root, { recursive: true, force: true });
294
+ }
295
+ });
296
+
297
+ it("rejects data paths that escape dataroot", async () => {
298
+ const root = await mkdtemp(join(tmpdir(), "kitfly-data-"));
299
+ try {
300
+ await mkdir(join(root, "data"), { recursive: true });
301
+ await writeFile(join(root, "outside.yaml"), 'globals:\n x: "1"\n');
302
+ await expect(loadDataBindings("../outside.yaml", "a.md", root, ".", "data")).rejects.toThrow(
303
+ /data path escapes/,
304
+ );
305
+ } finally {
306
+ await rm(root, { recursive: true, force: true });
307
+ }
308
+ });
309
+
310
+ it("rejects symlinked dataroot that escapes site root", async () => {
311
+ const root = await mkdtemp(join(tmpdir(), "kitfly-data-"));
312
+ const outside = await mkdtemp(join(tmpdir(), "kitfly-outside-"));
313
+ try {
314
+ await mkdir(join(root, "content"), { recursive: true });
315
+ await writeFile(join(outside, "pricing.yaml"), 'globals:\n x: "1"\n');
316
+ await symlink(outside, join(root, "data"));
317
+ await expect(
318
+ loadDataBindings("data/pricing.yaml", "pricing.md", root, "content", "data"),
319
+ ).rejects.toThrow(/data path escapes kitsite/);
320
+ } finally {
321
+ await rm(root, { recursive: true, force: true });
322
+ await rm(outside, { recursive: true, force: true });
323
+ }
324
+ });
325
+
326
+ it("loads multiline YAML snippet blocks for binding resolution", async () => {
327
+ const root = await mkdtemp(join(tmpdir(), "kitfly-data-"));
328
+ try {
329
+ await mkdir(join(root, "content", "product"), { recursive: true });
330
+ await mkdir(join(root, "data"), { recursive: true });
331
+ await writeFile(
332
+ join(root, "data", "pricing.yaml"),
333
+ [
334
+ "pages:",
335
+ " - path: product/pricing.md",
336
+ " snippets:",
337
+ " - slot: pricing-table",
338
+ " content: |",
339
+ " | Tier | Price |",
340
+ " |------|-------|",
341
+ " | Starter | $200 |",
342
+ ].join("\n"),
343
+ );
344
+ const bindings = await loadDataBindings(
345
+ "data/pricing.yaml",
346
+ "product/pricing.md",
347
+ root,
348
+ "content",
349
+ "data",
350
+ );
351
+ const rendered = resolveBindings(
352
+ "Table:\n{{ snippet:pricing-table }}",
353
+ bindings,
354
+ "product/pricing.md",
355
+ );
356
+ expect(rendered).toContain("| Tier | Price |");
357
+ expect(rendered).toContain("| Starter | $200 |");
358
+ } finally {
359
+ await rm(root, { recursive: true, force: true });
360
+ }
361
+ });
362
+ });
363
+
364
+ describe("mergeFrontmatterWithBody", () => {
365
+ it("preserves original frontmatter text while replacing body", () => {
366
+ const original = `---
367
+ title: "A # B"
368
+ tags: [a, b]
369
+ ---
370
+
371
+ # Old`;
372
+ const merged = mergeFrontmatterWithBody(original, "# New");
373
+ expect(merged).toContain('title: "A # B"');
374
+ expect(merged).toContain("tags: [a, b]");
375
+ expect(merged.endsWith("# New")).toBe(true);
376
+ });
377
+ });
378
+
105
379
  describe("splitSlides", () => {
106
380
  it("splits markdown on explicit slide delimiter", () => {
107
381
  const input = `# One
@@ -155,6 +429,27 @@ Still one slide`;
155
429
  });
156
430
  });
157
431
 
432
+ describe("slides-visuals diagnostics filtering", () => {
433
+ it("drops unknown-type diagnostics while preserving schema violations", () => {
434
+ const markdown = `:::future-thing
435
+ foo: bar
436
+ :::
437
+
438
+ :::kpi
439
+ label: Missing value
440
+ :::`;
441
+ const diagnostics = validateSlidesVisualsFences(markdown);
442
+ const filtered = filterUnknownSlidesVisualsTypeDiagnostics(diagnostics);
443
+ expect(
444
+ diagnostics.some((d) => d.message.startsWith("Unknown slides-visuals block type:")),
445
+ ).toBe(true);
446
+ expect(filtered.some((d) => d.message.startsWith("Unknown slides-visuals block type:"))).toBe(
447
+ false,
448
+ );
449
+ expect(filtered.some((d) => d.message.includes("Missing required key: value"))).toBe(true);
450
+ });
451
+ });
452
+
158
453
  describe("segmentSlides", () => {
159
454
  it("uses frontmatter title and class when present", () => {
160
455
  const input = `---
@@ -325,6 +620,140 @@ title: Intro
325
620
  expect(nav).toContain('<a href="#slide-2" class="active">Slide B</a>');
326
621
  expect(nav).toContain('<span class="nav-section">Slides</span>');
327
622
  });
623
+
624
+ it("builds hierarchical slide nav for nested source paths", () => {
625
+ const nav = buildSlideNavHierarchical(
626
+ [
627
+ {
628
+ index: 0,
629
+ frontmatter: {},
630
+ body: "# Overview",
631
+ title: "Overview",
632
+ id: "slide-1",
633
+ section: "Data Integration",
634
+ sourcePath: "/tmp/di/slides.md",
635
+ sourceUrlPath: "data-integration/slides",
636
+ kind: "markdown",
637
+ },
638
+ {
639
+ index: 1,
640
+ frontmatter: {},
641
+ body: "# POS Data",
642
+ title: "POS Data",
643
+ id: "slide-2",
644
+ section: "Data Integration",
645
+ sourcePath: "/tmp/di/sources/slides.md",
646
+ sourceUrlPath: "data-integration/sources/slides",
647
+ kind: "markdown",
648
+ },
649
+ {
650
+ index: 2,
651
+ frontmatter: {},
652
+ body: "# ETL Pipeline",
653
+ title: "ETL Pipeline",
654
+ id: "slide-3",
655
+ section: "Data Integration",
656
+ sourcePath: "/tmp/di/transforms/slides.md",
657
+ sourceUrlPath: "data-integration/transforms/slides",
658
+ kind: "markdown",
659
+ },
660
+ ],
661
+ {
662
+ docroot: ".",
663
+ title: "Deck",
664
+ brand: { name: "Test", url: "/" },
665
+ sections: [{ name: "Data Integration", path: "data-integration" }],
666
+ },
667
+ "slide-2",
668
+ );
669
+
670
+ expect(nav).toContain('<summary class="nav-group">Sources</summary>');
671
+ expect(nav).toContain('<summary class="nav-group">Transforms</summary>');
672
+ expect(nav).toContain('<a href="#slide-2" class="active">POS Data</a>');
673
+ expect(nav).toContain('<a href="#slide-3">ETL Pipeline</a>');
674
+ expect(nav).toContain("<details open>");
675
+ });
676
+
677
+ it("keeps flat nav output when slides are not nested", () => {
678
+ const nav = buildSlideNavHierarchical(
679
+ [
680
+ {
681
+ index: 0,
682
+ frontmatter: {},
683
+ body: "# Intro",
684
+ title: "Intro",
685
+ id: "slide-1",
686
+ section: "Slides",
687
+ sourcePath: "/tmp/slides/a.md",
688
+ sourceUrlPath: "slides/a",
689
+ kind: "markdown",
690
+ },
691
+ {
692
+ index: 1,
693
+ frontmatter: {},
694
+ body: "# Next",
695
+ title: "Next",
696
+ id: "slide-2",
697
+ section: "Slides",
698
+ sourcePath: "/tmp/slides/b.md",
699
+ sourceUrlPath: "slides/b",
700
+ kind: "markdown",
701
+ },
702
+ ],
703
+ {
704
+ docroot: ".",
705
+ title: "Deck",
706
+ brand: { name: "Test", url: "/" },
707
+ sections: [{ name: "Slides", path: "slides" }],
708
+ },
709
+ "slide-1",
710
+ );
711
+
712
+ expect(nav).toContain('<a href="#slide-1" class="active">Intro</a>');
713
+ expect(nav).toContain('<a href="#slide-2">Next</a>');
714
+ expect(nav).not.toContain('class="nav-group"');
715
+ });
716
+
717
+ it("keeps flat nav output when section path has trailing slash", () => {
718
+ const nav = buildSlideNavHierarchical(
719
+ [
720
+ {
721
+ index: 0,
722
+ frontmatter: {},
723
+ body: "# Intro",
724
+ title: "Intro",
725
+ id: "slide-1",
726
+ section: "Slides",
727
+ sourcePath: "/tmp/slides/a.md",
728
+ sourceUrlPath: "slides/a",
729
+ kind: "markdown",
730
+ },
731
+ {
732
+ index: 1,
733
+ frontmatter: {},
734
+ body: "# Next",
735
+ title: "Next",
736
+ id: "slide-2",
737
+ section: "Slides",
738
+ sourcePath: "/tmp/slides/b.md",
739
+ sourceUrlPath: "slides/b",
740
+ kind: "markdown",
741
+ },
742
+ ],
743
+ {
744
+ docroot: ".",
745
+ title: "Deck",
746
+ brand: { name: "Test", url: "/" },
747
+ sections: [{ name: "Slides", path: "slides/" }],
748
+ },
749
+ "slide-1",
750
+ );
751
+
752
+ expect(nav).toContain('<a href="#slide-1" class="active">Intro</a>');
753
+ expect(nav).toContain('<a href="#slide-2">Next</a>');
754
+ expect(nav).not.toContain('class="nav-group"');
755
+ expect(nav).not.toContain("<summary");
756
+ });
328
757
  });
329
758
 
330
759
  describe("rewriteRelativeAssetUrls", () => {
@@ -509,6 +938,78 @@ description: 'A test site'`;
509
938
  expect(result.tags).toEqual(["javascript", "typescript"]);
510
939
  });
511
940
 
941
+ it("parses literal block scalars", () => {
942
+ const yaml = `snippets:
943
+ - slot: pricing-table
944
+ content: |
945
+ | Tier | Price |
946
+ |------|-------|
947
+ | Starter | $200 |`;
948
+ const result = parseYaml(yaml);
949
+ const snippets = result.snippets as Array<Record<string, unknown>>;
950
+ expect(snippets[0].content).toBe("| Tier | Price |\n|------|-------|\n| Starter | $200 |");
951
+ });
952
+
953
+ it("parses folded block scalars", () => {
954
+ const yaml = `globals:
955
+ summary: >
956
+ Line one
957
+ line two
958
+
959
+ Paragraph two`;
960
+ const result = parseYaml(yaml);
961
+ const globals = result.globals as Record<string, unknown>;
962
+ expect(globals.summary).toBe("Line one line two\nParagraph two");
963
+ });
964
+
965
+ it("supports block scalar chomping indicators", () => {
966
+ const yaml = `globals:
967
+ keep: |+
968
+ One
969
+ Two
970
+ strip: >-
971
+ A
972
+ B`;
973
+ const result = parseYaml(yaml);
974
+ const globals = result.globals as Record<string, unknown>;
975
+ expect(globals.keep).toBe("One\nTwo\n");
976
+ expect(globals.strip).toBe("A B");
977
+ });
978
+
979
+ it("parses direct array block scalar items", () => {
980
+ const yaml = `snippets:
981
+ - |
982
+ line 1
983
+ line 2
984
+ - >-
985
+ folded
986
+ line`;
987
+ const result = parseYaml(yaml);
988
+ expect(result.snippets).toEqual(["line 1\nline 2", "folded line"]);
989
+ });
990
+
991
+ it("accepts block scalar indentation indicators", () => {
992
+ const yaml = `globals:
993
+ note: |2-
994
+ indented
995
+ block`;
996
+ const result = parseYaml(yaml);
997
+ const globals = result.globals as Record<string, unknown>;
998
+ expect(globals.note).toBe("indented\nblock");
999
+ });
1000
+
1001
+ it("treats invalid block scalar headers as plain strings", () => {
1002
+ const yaml = `globals:
1003
+ bad: |abc
1004
+ snippets:
1005
+ - >foo`;
1006
+ const result = parseYaml(yaml);
1007
+ const globals = result.globals as Record<string, unknown>;
1008
+ const snippets = result.snippets as unknown[];
1009
+ expect(globals.bad).toBe("|abc");
1010
+ expect(snippets[0]).toBe(">foo");
1011
+ });
1012
+
512
1013
  it("returns empty object for empty input", () => {
513
1014
  const result = parseYaml("");
514
1015
  expect(result).toEqual({});
@@ -1099,12 +1600,18 @@ version: "1.2.0"
1099
1600
  brand:
1100
1601
  name: Test
1101
1602
  url: /
1603
+ logoDark: assets/brand/logo-dark.png
1102
1604
  sections:
1103
1605
  - name: Guide
1104
1606
  path: guide
1105
1607
  footer:
1106
1608
  copyright: "© 2026 Test"
1107
1609
  attribution: false
1610
+ logo: "assets/brand/footer-logo.png"
1611
+ logoDark: "assets/brand/footer-logo-dark.png"
1612
+ logoUrl: "https://example.com/footer"
1613
+ logoAlt: "Footer Brand"
1614
+ logoHeight: 24
1108
1615
  links:
1109
1616
  - text: Privacy
1110
1617
  url: /privacy
@@ -1114,14 +1621,45 @@ footer:
1114
1621
 
1115
1622
  const config = await loadSiteConfig(dir);
1116
1623
  expect(config.version).toBe("1.2.0");
1624
+ expect(config.brand.logoDark).toBe("assets/brand/logo-dark.png");
1117
1625
  expect(config.footer?.copyright).toBe("© 2026 Test");
1118
1626
  expect(config.footer?.attribution).toBe(false);
1627
+ expect(config.footer?.logo).toBe("assets/brand/footer-logo.png");
1628
+ expect(config.footer?.logoDark).toBe("assets/brand/footer-logo-dark.png");
1629
+ expect(config.footer?.logoUrl).toBe("https://example.com/footer");
1630
+ expect(config.footer?.logoAlt).toBe("Footer Brand");
1631
+ expect(config.footer?.logoHeight).toBe(24);
1119
1632
  expect(config.footer?.links).toEqual([{ text: "Privacy", url: "/privacy" }]);
1120
1633
  } finally {
1121
1634
  await rm(dir, { recursive: true, force: true });
1122
1635
  }
1123
1636
  });
1124
1637
 
1638
+ it("clamps footer.logoHeight to supported range", async () => {
1639
+ const dir = await mkdtemp(join(tmpdir(), "kitfly-footer-logo-height-"));
1640
+ try {
1641
+ await writeFile(
1642
+ join(dir, "site.yaml"),
1643
+ `title: Test
1644
+ brand:
1645
+ name: Test
1646
+ url: /
1647
+ sections:
1648
+ - name: Guide
1649
+ path: guide
1650
+ footer:
1651
+ logoHeight: 100
1652
+ `,
1653
+ "utf-8",
1654
+ );
1655
+
1656
+ const config = await loadSiteConfig(dir);
1657
+ expect(config.footer?.logoHeight).toBe(40);
1658
+ } finally {
1659
+ await rm(dir, { recursive: true, force: true });
1660
+ }
1661
+ });
1662
+
1125
1663
  it("truncates footer links to max 10", async () => {
1126
1664
  const dir = await mkdtemp(join(tmpdir(), "kitfly-footer-links-"));
1127
1665
  const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
@@ -1662,6 +2200,70 @@ describe("buildFooter", () => {
1662
2200
  expect(result).toContain('class="footer-link"');
1663
2201
  });
1664
2202
 
2203
+ it("renders footer logo before version with configurable metadata", () => {
2204
+ const result = buildFooter(
2205
+ baseProvenance,
2206
+ {
2207
+ ...baseConfig,
2208
+ footer: {
2209
+ logo: "assets/brand/footer-logo.png",
2210
+ logoUrl: "https://footer.example.com",
2211
+ logoAlt: "Footer Brand",
2212
+ logoHeight: 22,
2213
+ },
2214
+ },
2215
+ "../",
2216
+ );
2217
+ expect(result).toContain('class="footer-logo-link"');
2218
+ expect(result).toContain('class="footer-logo-img"');
2219
+ expect(result).toContain('href="https://footer.example.com"');
2220
+ expect(result).toContain('src="../assets/brand/footer-logo.png"');
2221
+ expect(result).toContain('alt="Footer Brand"');
2222
+ expect(result).toContain("max-height: 22px");
2223
+ expect(result).toContain("onerror=\"this.onerror=null;this.style.display='none'\"");
2224
+ expect(result.indexOf('class="footer-logo-img"')).toBeLessThan(
2225
+ result.indexOf('class="footer-version"'),
2226
+ );
2227
+ });
2228
+
2229
+ it("renders footer light/dark logo variants when logoDark is set", () => {
2230
+ const result = buildFooter(baseProvenance, {
2231
+ ...baseConfig,
2232
+ footer: {
2233
+ logo: "assets/brand/footer-logo.png",
2234
+ logoDark: "assets/brand/footer-logo-dark.png",
2235
+ },
2236
+ });
2237
+ expect(result).toContain("footer-logo-img logo-light");
2238
+ expect(result).toContain("footer-logo-img logo-dark");
2239
+ expect(result).toContain('src="assets/brand/footer-logo-dark.png"');
2240
+ });
2241
+
2242
+ it("falls back footer logo alt text to copyright then brand name", () => {
2243
+ const withCopyrightAlt = buildFooter(
2244
+ baseProvenance,
2245
+ {
2246
+ ...baseConfig,
2247
+ footer: {
2248
+ logo: "assets/brand/footer-logo.png",
2249
+ copyright: "Copyright 2026 Acme",
2250
+ },
2251
+ },
2252
+ "./",
2253
+ );
2254
+ expect(withCopyrightAlt).toContain('alt="Copyright 2026 Acme"');
2255
+
2256
+ const withBrandAlt = buildFooter(
2257
+ baseProvenance,
2258
+ {
2259
+ ...baseConfig,
2260
+ footer: { logo: "assets/brand/footer-logo.png" },
2261
+ },
2262
+ "./",
2263
+ );
2264
+ expect(withBrandAlt).toContain('alt="Acme Corp"');
2265
+ });
2266
+
1665
2267
  it("uses publish date year in default copyright", () => {
1666
2268
  const result = buildFooter(baseProvenance, baseConfig);
1667
2269
  expect(result).toContain("© 2024 Acme Corp");
@@ -1728,6 +2330,24 @@ describe("buildBundleFooter", () => {
1728
2330
  expect(result).toContain("&lt;em&gt;");
1729
2331
  expect(result).not.toContain("<em>");
1730
2332
  });
2333
+
2334
+ it("renders footer logo in bundle mode with override source", () => {
2335
+ const result = buildBundleFooter(
2336
+ "0.1.1",
2337
+ {
2338
+ ...baseConfig,
2339
+ footer: {
2340
+ logo: "assets/brand/footer-logo.png",
2341
+ logoUrl: "https://footer.example.com",
2342
+ logoHeight: 18,
2343
+ },
2344
+ },
2345
+ "data:image/png;base64,AAAA",
2346
+ );
2347
+ expect(result).toContain('src="data:image/png;base64,AAAA"');
2348
+ expect(result).toContain('href="https://footer.example.com"');
2349
+ expect(result).toContain("max-height: 18px");
2350
+ });
1731
2351
  });
1732
2352
 
1733
2353
  // ---------------------------------------------------------------------------