kitfly 0.2.1 → 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 (108) hide show
  1. package/CHANGELOG.md +56 -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/docs/decisions/ADR-0006-data-driven-content.md +350 -0
  12. package/dist/content/deployment/preflight.html +10 -6
  13. package/dist/content/deployment/recipes/aws-s3.html +10 -6
  14. package/dist/content/deployment/recipes/cloudflare-pages.html +10 -6
  15. package/dist/content/deployment/recipes/cloudflare-r2.html +10 -6
  16. package/dist/content/deployment/recipes/fly-io.html +10 -6
  17. package/dist/content/deployment/recipes/github-pages.html +10 -6
  18. package/dist/content/deployment/recipes/netlify.html +10 -6
  19. package/dist/content/deployment/recipes/vercel.html +10 -6
  20. package/dist/content/deployment/secrets-and-env-vars.html +10 -6
  21. package/dist/content/deployment.html +10 -6
  22. package/dist/content/guide/approaches.html +10 -6
  23. package/dist/content/guide/branding.html +510 -0
  24. package/dist/content/guide/data-driven-content.html +543 -0
  25. package/dist/content/guide/features.html +10 -6
  26. package/dist/content/guide/getting-started.html +10 -6
  27. package/dist/content/guide/kitfly-overview.html +10 -6
  28. package/dist/content/reference/configuration.html +135 -9
  29. package/dist/content/reference/design-catalog.html +10 -6
  30. package/dist/content/reference/environment-variables.html +50 -8
  31. package/dist/content/reference/glossary.html +24 -8
  32. package/dist/content/reference/key-concepts.html +33 -9
  33. package/dist/content/reference/plugins.html +22 -7
  34. package/dist/content/reference/slides-authoring-guidelines.html +10 -6
  35. package/dist/content/reference/structure.html +10 -6
  36. package/dist/content/reference.html +10 -6
  37. package/dist/content/templates/crucible.html +10 -6
  38. package/dist/content/templates/handbook.html +10 -6
  39. package/dist/content/templates/minimal.html +10 -6
  40. package/dist/content/templates/overview.html +10 -6
  41. package/dist/content/templates/pipeline.html +10 -6
  42. package/dist/content/templates/productbook.html +10 -6
  43. package/dist/content/templates/runbook.html +10 -6
  44. package/dist/content/templates/servicebook.html +10 -6
  45. package/dist/content-index.json +29 -2
  46. package/dist/docs/decisions/ADR-0001-minimalist-site-code.html +10 -6
  47. package/dist/docs/decisions/ADR-0002-ai-accessibility.html +10 -6
  48. package/dist/docs/decisions/ADR-0003-single-file-bundle.html +10 -6
  49. package/dist/docs/decisions/ADR-0004-bun-runtime.html +10 -6
  50. package/dist/docs/decisions/ADR-0005-plugin-contract-and-distribution.html +10 -6
  51. package/dist/docs/decisions/ADR-0006-data-driven-content.html +752 -0
  52. package/dist/docs/decisions/DDR-0001-viewport-locked-layout.html +10 -6
  53. package/dist/docs/decisions/DDR-0002-theme-system.html +10 -6
  54. package/dist/docs/decisions/DDR-0003-bounded-logo-slot.html +10 -6
  55. package/dist/docs/decisions/DDR-0004-slides-rendering-model.html +10 -6
  56. package/dist/docs/decisions/DDR-0005-deterministic-layout-boundary.html +10 -6
  57. package/dist/docs/userguide/cli/build.html +10 -6
  58. package/dist/docs/userguide/cli/bundle.html +10 -6
  59. package/dist/docs/userguide/cli/dev.html +10 -6
  60. package/dist/docs/userguide/cli/init.html +10 -6
  61. package/dist/docs/userguide/cli/servers.html +10 -6
  62. package/dist/docs/userguide/cli/stop.html +10 -6
  63. package/dist/docs/userguide/cli/update.html +10 -6
  64. package/dist/docs/userguide/cli/version.html +10 -6
  65. package/dist/docs/userguide/cli.html +10 -6
  66. package/dist/docs/userguide/sharing.html +10 -6
  67. package/dist/index.html +10 -6
  68. package/dist/llms.txt +3 -3
  69. package/dist/provenance.json +4 -4
  70. package/dist/schemas/plugin-registry.schema.html +10 -6
  71. package/dist/schemas/plugin-schemas-notes.html +10 -6
  72. package/dist/schemas/plugin.schema.html +10 -6
  73. package/dist/schemas/plugins.schema.html +10 -6
  74. package/dist/schemas/v0/common.schema.html +14 -10
  75. package/dist/schemas/v0/plugin-registry.schema.html +13 -9
  76. package/dist/schemas/v0/plugin.schema.html +13 -9
  77. package/dist/schemas/v0/plugins.schema.html +13 -9
  78. package/dist/schemas/v0/site.schema.html +67 -7
  79. package/dist/schemas/v0/theme.schema.html +21 -17
  80. package/dist/schemas.html +10 -6
  81. package/dist/styles.css +39 -4
  82. package/package.json +1 -1
  83. package/plugins-dist/latex-runtime.js +140 -0
  84. package/plugins-dist/latex.js +178 -0
  85. package/plugins-dist/slides-charts-lite-runtime.js +179 -0
  86. package/plugins-dist/slides-charts-lite.js +198 -0
  87. package/registry/plugins.yaml +25 -0
  88. package/schemas/v0/site.schema.json +56 -0
  89. package/scripts/build.ts +191 -69
  90. package/scripts/bundle.ts +118 -10
  91. package/scripts/dev.ts +245 -166
  92. package/src/__tests__/brief.test.ts +151 -0
  93. package/src/__tests__/build.test.ts +169 -1
  94. package/src/__tests__/bundle.test.ts +134 -0
  95. package/src/__tests__/init.test.ts +51 -2
  96. package/src/__tests__/latex-runtime.bun.test.ts +35 -0
  97. package/src/__tests__/shared.test.ts +598 -1
  98. package/src/__tests__/slides-charts-lite-runtime.bun.test.ts +45 -0
  99. package/src/cli.ts +11 -4
  100. package/src/commands/init.ts +1 -1
  101. package/src/shared.ts +725 -18
  102. package/src/site/styles.css +39 -4
  103. package/src/site/template.html +5 -2
  104. package/src/templates/brief.ts +486 -0
  105. package/src/templates/deck.ts +59 -0
  106. package/src/templates/driver.ts +46 -13
  107. package/src/templates/handbook.ts +32 -0
  108. 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,16 +25,22 @@ import {
24
25
  envString,
25
26
  escapeHtml,
26
27
  exists,
28
+ filterByProfile,
27
29
  filterUnknownSlidesVisualsTypeDiagnostics,
28
30
  formatDate,
29
31
  generateProvenance,
30
32
  getGitInfo,
31
33
  KITFLY_BRAND,
34
+ loadDataBindings,
32
35
  loadSiteConfig,
36
+ mergeFrontmatterWithBody,
37
+ normalizeProfileTags,
33
38
  type Provenance,
39
+ pagePathForData,
34
40
  parseFrontmatter,
35
41
  parseValue,
36
42
  parseYaml,
43
+ resolveBindings,
37
44
  resolveSiteVersion,
38
45
  rewriteRelativeAssetUrls,
39
46
  type SiteConfig,
@@ -104,6 +111,271 @@ description: A test page
104
111
  });
105
112
  });
106
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
+
107
379
  describe("splitSlides", () => {
108
380
  it("splits markdown on explicit slide delimiter", () => {
109
381
  const input = `# One
@@ -348,6 +620,140 @@ title: Intro
348
620
  expect(nav).toContain('<a href="#slide-2" class="active">Slide B</a>');
349
621
  expect(nav).toContain('<span class="nav-section">Slides</span>');
350
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
+ });
351
757
  });
352
758
 
353
759
  describe("rewriteRelativeAssetUrls", () => {
@@ -532,6 +938,78 @@ description: 'A test site'`;
532
938
  expect(result.tags).toEqual(["javascript", "typescript"]);
533
939
  });
534
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
+
535
1013
  it("returns empty object for empty input", () => {
536
1014
  const result = parseYaml("");
537
1015
  expect(result).toEqual({});
@@ -1122,12 +1600,18 @@ version: "1.2.0"
1122
1600
  brand:
1123
1601
  name: Test
1124
1602
  url: /
1603
+ logoDark: assets/brand/logo-dark.png
1125
1604
  sections:
1126
1605
  - name: Guide
1127
1606
  path: guide
1128
1607
  footer:
1129
1608
  copyright: "© 2026 Test"
1130
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
1131
1615
  links:
1132
1616
  - text: Privacy
1133
1617
  url: /privacy
@@ -1137,14 +1621,45 @@ footer:
1137
1621
 
1138
1622
  const config = await loadSiteConfig(dir);
1139
1623
  expect(config.version).toBe("1.2.0");
1624
+ expect(config.brand.logoDark).toBe("assets/brand/logo-dark.png");
1140
1625
  expect(config.footer?.copyright).toBe("© 2026 Test");
1141
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);
1142
1632
  expect(config.footer?.links).toEqual([{ text: "Privacy", url: "/privacy" }]);
1143
1633
  } finally {
1144
1634
  await rm(dir, { recursive: true, force: true });
1145
1635
  }
1146
1636
  });
1147
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
+
1148
1663
  it("truncates footer links to max 10", async () => {
1149
1664
  const dir = await mkdtemp(join(tmpdir(), "kitfly-footer-links-"));
1150
1665
  const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
@@ -1685,6 +2200,70 @@ describe("buildFooter", () => {
1685
2200
  expect(result).toContain('class="footer-link"');
1686
2201
  });
1687
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
+
1688
2267
  it("uses publish date year in default copyright", () => {
1689
2268
  const result = buildFooter(baseProvenance, baseConfig);
1690
2269
  expect(result).toContain("© 2024 Acme Corp");
@@ -1751,6 +2330,24 @@ describe("buildBundleFooter", () => {
1751
2330
  expect(result).toContain("&lt;em&gt;");
1752
2331
  expect(result).not.toContain("<em>");
1753
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
+ });
1754
2351
  });
1755
2352
 
1756
2353
  // ---------------------------------------------------------------------------