kitfly 0.2.1 → 0.2.4

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 (132) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +38 -21
  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/gantt-widget.md +468 -0
  9. package/dist/_raw/content/reference/glossary.md +25 -1
  10. package/dist/_raw/content/reference/key-concepts.md +30 -2
  11. package/dist/_raw/content/reference/plugins.md +170 -1
  12. package/dist/_raw/docs/decisions/ADR-0006-data-driven-content.md +350 -0
  13. package/dist/content/deployment/preflight.html +11 -8
  14. package/dist/content/deployment/recipes/aws-s3.html +11 -8
  15. package/dist/content/deployment/recipes/cloudflare-pages.html +11 -8
  16. package/dist/content/deployment/recipes/cloudflare-r2.html +11 -8
  17. package/dist/content/deployment/recipes/fly-io.html +11 -8
  18. package/dist/content/deployment/recipes/github-pages.html +11 -8
  19. package/dist/content/deployment/recipes/netlify.html +11 -8
  20. package/dist/content/deployment/recipes/vercel.html +11 -8
  21. package/dist/content/deployment/secrets-and-env-vars.html +11 -8
  22. package/dist/content/deployment.html +11 -8
  23. package/dist/content/guide/approaches.html +11 -8
  24. package/dist/content/guide/branding.html +509 -0
  25. package/dist/content/guide/data-driven-content.html +542 -0
  26. package/dist/content/guide/features.html +11 -8
  27. package/dist/content/guide/getting-started.html +11 -8
  28. package/dist/content/guide/kitfly-overview.html +11 -8
  29. package/dist/content/reference/configuration.html +136 -11
  30. package/dist/content/reference/design-catalog.html +11 -8
  31. package/dist/content/reference/environment-variables.html +51 -10
  32. package/dist/content/reference/gantt-widget.html +899 -0
  33. package/dist/content/reference/glossary.html +25 -10
  34. package/dist/content/reference/key-concepts.html +34 -11
  35. package/dist/content/reference/plugins.html +261 -10
  36. package/dist/content/reference/slides-authoring-guidelines.html +11 -8
  37. package/dist/content/reference/structure.html +11 -8
  38. package/dist/content/reference.html +11 -8
  39. package/dist/content/templates/crucible.html +11 -8
  40. package/dist/content/templates/handbook.html +11 -8
  41. package/dist/content/templates/minimal.html +11 -8
  42. package/dist/content/templates/overview.html +11 -8
  43. package/dist/content/templates/pipeline.html +11 -8
  44. package/dist/content/templates/productbook.html +11 -8
  45. package/dist/content/templates/runbook.html +11 -8
  46. package/dist/content/templates/servicebook.html +11 -8
  47. package/dist/content-index.json +37 -2
  48. package/dist/docs/decisions/ADR-0001-minimalist-site-code.html +11 -8
  49. package/dist/docs/decisions/ADR-0002-ai-accessibility.html +11 -8
  50. package/dist/docs/decisions/ADR-0003-single-file-bundle.html +11 -8
  51. package/dist/docs/decisions/ADR-0004-bun-runtime.html +11 -8
  52. package/dist/docs/decisions/ADR-0005-plugin-contract-and-distribution.html +11 -8
  53. package/dist/docs/decisions/ADR-0006-data-driven-content.html +751 -0
  54. package/dist/docs/decisions/DDR-0001-viewport-locked-layout.html +11 -8
  55. package/dist/docs/decisions/DDR-0002-theme-system.html +11 -8
  56. package/dist/docs/decisions/DDR-0003-bounded-logo-slot.html +11 -8
  57. package/dist/docs/decisions/DDR-0004-slides-rendering-model.html +11 -8
  58. package/dist/docs/decisions/DDR-0005-deterministic-layout-boundary.html +11 -8
  59. package/dist/docs/userguide/cli/build.html +11 -8
  60. package/dist/docs/userguide/cli/bundle.html +11 -8
  61. package/dist/docs/userguide/cli/dev.html +11 -8
  62. package/dist/docs/userguide/cli/init.html +11 -8
  63. package/dist/docs/userguide/cli/servers.html +11 -8
  64. package/dist/docs/userguide/cli/stop.html +11 -8
  65. package/dist/docs/userguide/cli/update.html +11 -8
  66. package/dist/docs/userguide/cli/version.html +11 -8
  67. package/dist/docs/userguide/cli.html +11 -8
  68. package/dist/docs/userguide/sharing.html +11 -8
  69. package/dist/index.html +11 -8
  70. package/dist/llms.txt +3 -3
  71. package/dist/provenance.json +4 -5
  72. package/dist/reports/license-inventory.csv +199 -0
  73. package/dist/schemas/plugin-registry.schema.html +11 -8
  74. package/dist/schemas/plugin-schemas-notes.html +11 -8
  75. package/dist/schemas/plugin.schema.html +11 -8
  76. package/dist/schemas/plugins.schema.html +11 -8
  77. package/dist/schemas/v0/common.schema.html +15 -12
  78. package/dist/schemas/v0/plugin-registry.schema.html +14 -11
  79. package/dist/schemas/v0/plugin.schema.html +14 -11
  80. package/dist/schemas/v0/plugins.schema.html +14 -11
  81. package/dist/schemas/v0/site.schema.html +68 -9
  82. package/dist/schemas/v0/theme.schema.html +22 -19
  83. package/dist/schemas.html +11 -8
  84. package/dist/styles.css +39 -4
  85. package/package.json +1 -1
  86. package/plugins-dist/latex-runtime.js +140 -0
  87. package/plugins-dist/latex.js +178 -0
  88. package/plugins-dist/planning-visuals.css +261 -0
  89. package/plugins-dist/planning-visuals.js +669 -0
  90. package/plugins-dist/slides-charts-lite-runtime.js +179 -0
  91. package/plugins-dist/slides-charts-lite.js +198 -0
  92. package/registry/plugins.yaml +40 -1
  93. package/schemas/v0/site.schema.json +56 -0
  94. package/scripts/build-all.ts +5 -0
  95. package/scripts/build.ts +264 -80
  96. package/scripts/bundle.ts +188 -17
  97. package/scripts/dev.ts +294 -171
  98. package/scripts/embed-docs.ts +119 -0
  99. package/src/__tests__/brief.test.ts +151 -0
  100. package/src/__tests__/build.test.ts +293 -1
  101. package/src/__tests__/bundle.test.ts +195 -0
  102. package/src/__tests__/docs.test.ts +117 -0
  103. package/src/__tests__/fixtures/fences/planning-visuals/invalid/bad-month-format.md +10 -0
  104. package/src/__tests__/fixtures/fences/planning-visuals/invalid/marker-format-mismatch.md +13 -0
  105. package/src/__tests__/fixtures/fences/planning-visuals/invalid/milestone-format-mismatch.md +13 -0
  106. package/src/__tests__/fixtures/fences/planning-visuals/invalid/missing-tracks.md +5 -0
  107. package/src/__tests__/fixtures/fences/planning-visuals/invalid/track-reversed.md +10 -0
  108. package/src/__tests__/fixtures/fences/planning-visuals/valid/markers-basic.md +15 -0
  109. package/src/__tests__/fixtures/fences/planning-visuals/valid/markers-no-milestones.md +13 -0
  110. package/src/__tests__/fixtures/fences/planning-visuals/valid/month-basic.md +16 -0
  111. package/src/__tests__/fixtures/fences/planning-visuals/valid/no-milestones.md +10 -0
  112. package/src/__tests__/fixtures/fences/planning-visuals/valid/week-basic.md +20 -0
  113. package/src/__tests__/init.test.ts +51 -2
  114. package/src/__tests__/latex-runtime.bun.test.ts +35 -0
  115. package/src/__tests__/planning-visuals-fence-contract.test.ts +28 -0
  116. package/src/__tests__/planning-visuals-runtime-regressions.bun.test.ts +68 -0
  117. package/src/__tests__/planning-visuals-runtime.bun.test.ts +192 -0
  118. package/src/__tests__/shared.test.ts +719 -1
  119. package/src/__tests__/slides-charts-lite-runtime.bun.test.ts +45 -0
  120. package/src/cli.ts +124 -22
  121. package/src/commands/docs.ts +71 -0
  122. package/src/commands/init.ts +1 -1
  123. package/src/generated/embedded-docs.ts +2384 -0
  124. package/src/server-registry.ts +50 -10
  125. package/src/shared.ts +1174 -43
  126. package/src/site/styles.css +39 -4
  127. package/src/site/template.html +5 -2
  128. package/src/templates/brief.ts +486 -0
  129. package/src/templates/deck.ts +59 -0
  130. package/src/templates/driver.ts +46 -13
  131. package/src/templates/handbook.ts +32 -0
  132. 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,25 +15,34 @@ import {
15
15
  buildNavStatic,
16
16
  buildPageMeta,
17
17
  buildSlideNav,
18
+ buildSlideNavHierarchical,
18
19
  buildToc,
19
20
  type ContentFile,
20
21
  collectFiles,
22
+ collectPlanningVisualsContainmentWarnings,
21
23
  collectSlides,
22
24
  envBool,
23
25
  envInt,
24
26
  envString,
25
27
  escapeHtml,
26
28
  exists,
29
+ filterByProfile,
30
+ filterUnknownPlanningVisualsTypeDiagnostics,
27
31
  filterUnknownSlidesVisualsTypeDiagnostics,
28
32
  formatDate,
29
33
  generateProvenance,
30
34
  getGitInfo,
31
35
  KITFLY_BRAND,
36
+ loadDataBindings,
32
37
  loadSiteConfig,
38
+ mergeFrontmatterWithBody,
39
+ normalizeProfileTags,
33
40
  type Provenance,
41
+ pagePathForData,
34
42
  parseFrontmatter,
35
43
  parseValue,
36
44
  parseYaml,
45
+ resolveBindings,
37
46
  resolveSiteVersion,
38
47
  rewriteRelativeAssetUrls,
39
48
  type SiteConfig,
@@ -43,6 +52,7 @@ import {
43
52
  stripQuotes,
44
53
  toUrlPath,
45
54
  validatePath,
55
+ validatePlanningVisualsFences,
46
56
  validateSlidesVisualsFences,
47
57
  } from "../shared.ts";
48
58
 
@@ -104,6 +114,271 @@ description: A test page
104
114
  });
105
115
  });
106
116
 
117
+ describe("normalizeProfileTags", () => {
118
+ it("parses scalar and array-like profile tags", () => {
119
+ expect(normalizeProfileTags("alpha")).toEqual(["alpha"]);
120
+ expect(normalizeProfileTags("[alpha, beta]")).toEqual(["alpha", "beta"]);
121
+ expect(normalizeProfileTags(["alpha", "beta"])).toEqual(["alpha", "beta"]);
122
+ });
123
+
124
+ it("normalizes whitespace, quotes, and case", () => {
125
+ expect(normalizeProfileTags("[\"Alpha\", 'beta']")).toEqual(["alpha", "beta"]);
126
+ });
127
+ });
128
+
129
+ describe("filterByProfile", () => {
130
+ it("keeps all files when profiles are not configured", async () => {
131
+ const root = await mkdtemp(join(tmpdir(), "kitfly-profile-"));
132
+ try {
133
+ const alwaysPath = join(root, "always.md");
134
+ const alphaPath = join(root, "alpha.md");
135
+ const bothPath = join(root, "both.md");
136
+ const betaPath = join(root, "beta.md");
137
+ await writeFile(alwaysPath, "# Always");
138
+ await writeFile(alphaPath, "---\nprofile: alpha\n---\n# Alpha");
139
+ await writeFile(bothPath, "---\nprofile: [alpha, beta]\n---\n# Both");
140
+ await writeFile(betaPath, "---\nprofile: beta\n---\n# Beta");
141
+
142
+ const files: ContentFile[] = [
143
+ { path: alwaysPath, urlPath: "always", section: "Docs" },
144
+ { path: alphaPath, urlPath: "alpha", section: "Docs" },
145
+ { path: bothPath, urlPath: "both", section: "Docs" },
146
+ { path: betaPath, urlPath: "beta", section: "Docs" },
147
+ ];
148
+
149
+ const noProfile = await filterByProfile(files);
150
+ expect(noProfile.map((f) => f.urlPath)).toEqual(["always", "alpha", "both", "beta"]);
151
+ } finally {
152
+ await rm(root, { recursive: true, force: true });
153
+ }
154
+ });
155
+
156
+ it("filters tagged files when profiles are configured", async () => {
157
+ const root = await mkdtemp(join(tmpdir(), "kitfly-profile-"));
158
+ try {
159
+ const alwaysPath = join(root, "always.md");
160
+ const alphaPath = join(root, "alpha.md");
161
+ const bothPath = join(root, "both.md");
162
+ const betaPath = join(root, "beta.md");
163
+ await writeFile(alwaysPath, "# Always");
164
+ await writeFile(alphaPath, "---\nprofile: alpha\n---\n# Alpha");
165
+ await writeFile(bothPath, "---\nprofile: [alpha, beta]\n---\n# Both");
166
+ await writeFile(betaPath, "---\nprofile: beta\n---\n# Beta");
167
+
168
+ const files: ContentFile[] = [
169
+ { path: alwaysPath, urlPath: "always", section: "Docs" },
170
+ { path: alphaPath, urlPath: "alpha", section: "Docs" },
171
+ { path: bothPath, urlPath: "both", section: "Docs" },
172
+ { path: betaPath, urlPath: "beta", section: "Docs" },
173
+ ];
174
+ const profiles = {
175
+ alpha: { include: { tags: ["alpha"] } },
176
+ beta: { include: { tags: ["beta"] } },
177
+ };
178
+
179
+ const noProfile = await filterByProfile(files, undefined, profiles);
180
+ expect(noProfile.map((f) => f.urlPath)).toEqual(["always"]);
181
+
182
+ const alphaProfile = await filterByProfile(files, "alpha", profiles);
183
+ expect(alphaProfile.map((f) => f.urlPath)).toEqual(["always", "alpha", "both"]);
184
+
185
+ const betaProfile = await filterByProfile(files, "beta", profiles);
186
+ expect(betaProfile.map((f) => f.urlPath)).toEqual(["always", "both", "beta"]);
187
+ } finally {
188
+ await rm(root, { recursive: true, force: true });
189
+ }
190
+ });
191
+ });
192
+
193
+ describe("data bindings", () => {
194
+ it("resolves values and snippets with formatter chains", () => {
195
+ const bound = resolveBindings(
196
+ [
197
+ "Rate: {{ baseline | dollar }}",
198
+ "Pct: {{ ratio | percent }}",
199
+ "Rounded: {{ pi | round(2) }}",
200
+ "Upper: {{ label | upper }}",
201
+ "Chain: {{ baseline | round(0) | dollar }}",
202
+ "{{ snippet:table }}",
203
+ ].join("\n"),
204
+ {
205
+ globals: { baseline: "1500", ratio: "0.125", pi: "3.14159" },
206
+ inject: { label: "pricing" },
207
+ snippets: [{ slot: "table", content: "| A | B |\n|---|---|" }],
208
+ },
209
+ "product/pricing.md",
210
+ );
211
+ expect(bound).toContain("Rate: $1,500");
212
+ expect(bound).toContain("Pct: 12.5%");
213
+ expect(bound).toContain("Rounded: 3.14");
214
+ expect(bound).toContain("Upper: PRICING");
215
+ expect(bound).toContain("Chain: $1,500");
216
+ expect(bound).toContain("| A | B |");
217
+ });
218
+
219
+ it("throws on unresolved keys, unknown snippets, and unknown formatters", () => {
220
+ expect(() =>
221
+ resolveBindings("{{ missing }}", { globals: {}, inject: {}, snippets: [] }, "x.md"),
222
+ ).toThrow(/unresolved binding/);
223
+ expect(() =>
224
+ resolveBindings("{{ snippet:nope }}", { globals: {}, inject: {}, snippets: [] }, "x.md"),
225
+ ).toThrow(/unknown snippet/);
226
+ expect(() =>
227
+ resolveBindings(
228
+ "{{ n | custom }}",
229
+ { globals: { n: "1" }, inject: {}, snippets: [] },
230
+ "x.md",
231
+ ),
232
+ ).toThrow(/unknown formatter/);
233
+ });
234
+ });
235
+
236
+ describe("loadDataBindings", () => {
237
+ it("loads page-level inject/snippets and validates optional schema", async () => {
238
+ const root = await mkdtemp(join(tmpdir(), "kitfly-data-"));
239
+ try {
240
+ await mkdir(join(root, "content", "product"), { recursive: true });
241
+ await mkdir(join(root, "data"), { recursive: true });
242
+ await writeFile(
243
+ join(root, "data", "pricing.yaml"),
244
+ [
245
+ "globals:",
246
+ ' baseline: "200"',
247
+ "pages:",
248
+ " - path: product/pricing.md",
249
+ " inject:",
250
+ ' hero: "Implementation costs"',
251
+ " snippets:",
252
+ " - slot: pricing-table",
253
+ " content: |",
254
+ " | Tier | Price |",
255
+ ].join("\n"),
256
+ );
257
+ await writeFile(
258
+ join(root, "data", "pricing.schema.json"),
259
+ JSON.stringify(
260
+ {
261
+ type: "object",
262
+ required: ["globals", "pages"],
263
+ properties: {
264
+ globals: {
265
+ type: "object",
266
+ required: ["baseline"],
267
+ properties: {
268
+ baseline: { type: "string", pattern: "^[0-9]+$" },
269
+ },
270
+ },
271
+ pages: { type: "array" },
272
+ },
273
+ },
274
+ null,
275
+ 2,
276
+ ),
277
+ );
278
+
279
+ const pagePath = pagePathForData(
280
+ root,
281
+ "content",
282
+ join(root, "content", "product", "pricing.md"),
283
+ );
284
+ const bindings = await loadDataBindings(
285
+ "data/pricing.yaml",
286
+ pagePath,
287
+ root,
288
+ "content",
289
+ "data",
290
+ );
291
+ expect(bindings.globals.baseline).toBe("200");
292
+ expect(bindings.inject.hero).toBe("Implementation costs");
293
+ expect(bindings.snippets[0].slot).toBe("pricing-table");
294
+ expect(bindings.snippets[0].content).toContain("| Tier | Price |");
295
+ } finally {
296
+ await rm(root, { recursive: true, force: true });
297
+ }
298
+ });
299
+
300
+ it("rejects data paths that escape dataroot", async () => {
301
+ const root = await mkdtemp(join(tmpdir(), "kitfly-data-"));
302
+ try {
303
+ await mkdir(join(root, "data"), { recursive: true });
304
+ await writeFile(join(root, "outside.yaml"), 'globals:\n x: "1"\n');
305
+ await expect(loadDataBindings("../outside.yaml", "a.md", root, ".", "data")).rejects.toThrow(
306
+ /data path escapes/,
307
+ );
308
+ } finally {
309
+ await rm(root, { recursive: true, force: true });
310
+ }
311
+ });
312
+
313
+ it("rejects symlinked dataroot that escapes site root", async () => {
314
+ const root = await mkdtemp(join(tmpdir(), "kitfly-data-"));
315
+ const outside = await mkdtemp(join(tmpdir(), "kitfly-outside-"));
316
+ try {
317
+ await mkdir(join(root, "content"), { recursive: true });
318
+ await writeFile(join(outside, "pricing.yaml"), 'globals:\n x: "1"\n');
319
+ await symlink(outside, join(root, "data"));
320
+ await expect(
321
+ loadDataBindings("data/pricing.yaml", "pricing.md", root, "content", "data"),
322
+ ).rejects.toThrow(/data path escapes kitsite/);
323
+ } finally {
324
+ await rm(root, { recursive: true, force: true });
325
+ await rm(outside, { recursive: true, force: true });
326
+ }
327
+ });
328
+
329
+ it("loads multiline YAML snippet blocks for binding resolution", async () => {
330
+ const root = await mkdtemp(join(tmpdir(), "kitfly-data-"));
331
+ try {
332
+ await mkdir(join(root, "content", "product"), { recursive: true });
333
+ await mkdir(join(root, "data"), { recursive: true });
334
+ await writeFile(
335
+ join(root, "data", "pricing.yaml"),
336
+ [
337
+ "pages:",
338
+ " - path: product/pricing.md",
339
+ " snippets:",
340
+ " - slot: pricing-table",
341
+ " content: |",
342
+ " | Tier | Price |",
343
+ " |------|-------|",
344
+ " | Starter | $200 |",
345
+ ].join("\n"),
346
+ );
347
+ const bindings = await loadDataBindings(
348
+ "data/pricing.yaml",
349
+ "product/pricing.md",
350
+ root,
351
+ "content",
352
+ "data",
353
+ );
354
+ const rendered = resolveBindings(
355
+ "Table:\n{{ snippet:pricing-table }}",
356
+ bindings,
357
+ "product/pricing.md",
358
+ );
359
+ expect(rendered).toContain("| Tier | Price |");
360
+ expect(rendered).toContain("| Starter | $200 |");
361
+ } finally {
362
+ await rm(root, { recursive: true, force: true });
363
+ }
364
+ });
365
+ });
366
+
367
+ describe("mergeFrontmatterWithBody", () => {
368
+ it("preserves original frontmatter text while replacing body", () => {
369
+ const original = `---
370
+ title: "A # B"
371
+ tags: [a, b]
372
+ ---
373
+
374
+ # Old`;
375
+ const merged = mergeFrontmatterWithBody(original, "# New");
376
+ expect(merged).toContain('title: "A # B"');
377
+ expect(merged).toContain("tags: [a, b]");
378
+ expect(merged.endsWith("# New")).toBe(true);
379
+ });
380
+ });
381
+
107
382
  describe("splitSlides", () => {
108
383
  it("splits markdown on explicit slide delimiter", () => {
109
384
  const input = `# One
@@ -178,6 +453,124 @@ label: Missing value
178
453
  });
179
454
  });
180
455
 
456
+ describe("planning-visuals diagnostics filtering", () => {
457
+ it("drops unknown-type diagnostics while preserving schema violations", () => {
458
+ const markdown = `:::future-planning
459
+ foo: bar
460
+ :::
461
+
462
+ :::gantt
463
+ time-unit: week
464
+ time-start: 2026-W10
465
+ time-end: 2026-W12
466
+ :::`;
467
+ const diagnostics = validatePlanningVisualsFences(markdown);
468
+ const filtered = filterUnknownPlanningVisualsTypeDiagnostics(diagnostics);
469
+ expect(
470
+ diagnostics.some((d) => d.message.startsWith("Unknown planning-visuals block type:")),
471
+ ).toBe(true);
472
+ expect(filtered.some((d) => d.message.startsWith("Unknown planning-visuals block type:"))).toBe(
473
+ false,
474
+ );
475
+ expect(filtered.some((d) => d.message.includes("Missing required key: tracks"))).toBe(true);
476
+ });
477
+ });
478
+
479
+ describe("planning-visuals containment warnings", () => {
480
+ it("returns non-fatal containment warnings when rows exceed axis", () => {
481
+ const markdown = `:::gantt
482
+ time-unit: month
483
+ time-start: 2026-04
484
+ time-end: 2026-06
485
+ tracks:
486
+ - label: Wave 1
487
+ depth: 1
488
+ start: 2026-03
489
+ end: 2026-07
490
+ milestones:
491
+ - label: Late marker
492
+ date: 2026-08
493
+ :::`;
494
+ const warnings = collectPlanningVisualsContainmentWarnings(markdown);
495
+ expect(warnings.some((w) => w.message.includes("Track range is outside axis"))).toBe(true);
496
+ expect(warnings.some((w) => w.message.includes("Milestone date is outside axis"))).toBe(true);
497
+ });
498
+
499
+ it("warns when marker date is outside axis range", () => {
500
+ const markdown = `:::gantt
501
+ time-unit: week
502
+ time-start: 2026-W14
503
+ time-end: 2026-W20
504
+ markers:
505
+ - label: Late gate
506
+ date: 2026-W22
507
+ tracks:
508
+ - label: Phase 1
509
+ depth: 1
510
+ start: 2026-W14
511
+ end: 2026-W18
512
+ :::`;
513
+ const warnings = collectPlanningVisualsContainmentWarnings(markdown);
514
+ expect(warnings.some((w) => w.message.includes("Marker date is outside axis"))).toBe(true);
515
+ });
516
+ });
517
+
518
+ describe("planning-visuals marker validation", () => {
519
+ it("accepts valid markers", () => {
520
+ const markdown = `:::gantt
521
+ time-unit: week
522
+ time-start: 2026-W14
523
+ time-end: 2026-W30
524
+ markers:
525
+ - label: Go/No-Go
526
+ date: 2026-W20
527
+ tracks:
528
+ - label: Phase 1
529
+ depth: 1
530
+ start: 2026-W14
531
+ end: 2026-W24
532
+ :::`;
533
+ const diags = validatePlanningVisualsFences(markdown);
534
+ expect(diags).toHaveLength(0);
535
+ });
536
+
537
+ it("rejects marker with mismatched date format", () => {
538
+ const markdown = `:::gantt
539
+ time-unit: month
540
+ time-start: 2026-04
541
+ time-end: 2026-10
542
+ markers:
543
+ - label: Gate
544
+ date: 2026-W20
545
+ tracks:
546
+ - label: Build
547
+ depth: 1
548
+ start: 2026-04
549
+ end: 2026-07
550
+ :::`;
551
+ const diags = validatePlanningVisualsFences(markdown);
552
+ expect(diags.some((d) => d.message.includes("Marker date must match month format"))).toBe(true);
553
+ });
554
+
555
+ it("accepts month markers with day precision", () => {
556
+ const markdown = `:::gantt
557
+ time-unit: month
558
+ time-start: 2026-04
559
+ time-end: 2026-10
560
+ markers:
561
+ - label: Conference
562
+ date: 2026-05-26
563
+ tracks:
564
+ - label: Build
565
+ depth: 1
566
+ start: 2026-04
567
+ end: 2026-07
568
+ :::`;
569
+ const diags = validatePlanningVisualsFences(markdown);
570
+ expect(diags).toHaveLength(0);
571
+ });
572
+ });
573
+
181
574
  describe("segmentSlides", () => {
182
575
  it("uses frontmatter title and class when present", () => {
183
576
  const input = `---
@@ -348,6 +741,140 @@ title: Intro
348
741
  expect(nav).toContain('<a href="#slide-2" class="active">Slide B</a>');
349
742
  expect(nav).toContain('<span class="nav-section">Slides</span>');
350
743
  });
744
+
745
+ it("builds hierarchical slide nav for nested source paths", () => {
746
+ const nav = buildSlideNavHierarchical(
747
+ [
748
+ {
749
+ index: 0,
750
+ frontmatter: {},
751
+ body: "# Overview",
752
+ title: "Overview",
753
+ id: "slide-1",
754
+ section: "Data Integration",
755
+ sourcePath: "/tmp/di/slides.md",
756
+ sourceUrlPath: "data-integration/slides",
757
+ kind: "markdown",
758
+ },
759
+ {
760
+ index: 1,
761
+ frontmatter: {},
762
+ body: "# POS Data",
763
+ title: "POS Data",
764
+ id: "slide-2",
765
+ section: "Data Integration",
766
+ sourcePath: "/tmp/di/sources/slides.md",
767
+ sourceUrlPath: "data-integration/sources/slides",
768
+ kind: "markdown",
769
+ },
770
+ {
771
+ index: 2,
772
+ frontmatter: {},
773
+ body: "# ETL Pipeline",
774
+ title: "ETL Pipeline",
775
+ id: "slide-3",
776
+ section: "Data Integration",
777
+ sourcePath: "/tmp/di/transforms/slides.md",
778
+ sourceUrlPath: "data-integration/transforms/slides",
779
+ kind: "markdown",
780
+ },
781
+ ],
782
+ {
783
+ docroot: ".",
784
+ title: "Deck",
785
+ brand: { name: "Test", url: "/" },
786
+ sections: [{ name: "Data Integration", path: "data-integration" }],
787
+ },
788
+ "slide-2",
789
+ );
790
+
791
+ expect(nav).toContain('<summary class="nav-group">Sources</summary>');
792
+ expect(nav).toContain('<summary class="nav-group">Transforms</summary>');
793
+ expect(nav).toContain('<a href="#slide-2" class="active">POS Data</a>');
794
+ expect(nav).toContain('<a href="#slide-3">ETL Pipeline</a>');
795
+ expect(nav).toContain("<details open>");
796
+ });
797
+
798
+ it("keeps flat nav output when slides are not nested", () => {
799
+ const nav = buildSlideNavHierarchical(
800
+ [
801
+ {
802
+ index: 0,
803
+ frontmatter: {},
804
+ body: "# Intro",
805
+ title: "Intro",
806
+ id: "slide-1",
807
+ section: "Slides",
808
+ sourcePath: "/tmp/slides/a.md",
809
+ sourceUrlPath: "slides/a",
810
+ kind: "markdown",
811
+ },
812
+ {
813
+ index: 1,
814
+ frontmatter: {},
815
+ body: "# Next",
816
+ title: "Next",
817
+ id: "slide-2",
818
+ section: "Slides",
819
+ sourcePath: "/tmp/slides/b.md",
820
+ sourceUrlPath: "slides/b",
821
+ kind: "markdown",
822
+ },
823
+ ],
824
+ {
825
+ docroot: ".",
826
+ title: "Deck",
827
+ brand: { name: "Test", url: "/" },
828
+ sections: [{ name: "Slides", path: "slides" }],
829
+ },
830
+ "slide-1",
831
+ );
832
+
833
+ expect(nav).toContain('<a href="#slide-1" class="active">Intro</a>');
834
+ expect(nav).toContain('<a href="#slide-2">Next</a>');
835
+ expect(nav).not.toContain('class="nav-group"');
836
+ });
837
+
838
+ it("keeps flat nav output when section path has trailing slash", () => {
839
+ const nav = buildSlideNavHierarchical(
840
+ [
841
+ {
842
+ index: 0,
843
+ frontmatter: {},
844
+ body: "# Intro",
845
+ title: "Intro",
846
+ id: "slide-1",
847
+ section: "Slides",
848
+ sourcePath: "/tmp/slides/a.md",
849
+ sourceUrlPath: "slides/a",
850
+ kind: "markdown",
851
+ },
852
+ {
853
+ index: 1,
854
+ frontmatter: {},
855
+ body: "# Next",
856
+ title: "Next",
857
+ id: "slide-2",
858
+ section: "Slides",
859
+ sourcePath: "/tmp/slides/b.md",
860
+ sourceUrlPath: "slides/b",
861
+ kind: "markdown",
862
+ },
863
+ ],
864
+ {
865
+ docroot: ".",
866
+ title: "Deck",
867
+ brand: { name: "Test", url: "/" },
868
+ sections: [{ name: "Slides", path: "slides/" }],
869
+ },
870
+ "slide-1",
871
+ );
872
+
873
+ expect(nav).toContain('<a href="#slide-1" class="active">Intro</a>');
874
+ expect(nav).toContain('<a href="#slide-2">Next</a>');
875
+ expect(nav).not.toContain('class="nav-group"');
876
+ expect(nav).not.toContain("<summary");
877
+ });
351
878
  });
352
879
 
353
880
  describe("rewriteRelativeAssetUrls", () => {
@@ -532,6 +1059,78 @@ description: 'A test site'`;
532
1059
  expect(result.tags).toEqual(["javascript", "typescript"]);
533
1060
  });
534
1061
 
1062
+ it("parses literal block scalars", () => {
1063
+ const yaml = `snippets:
1064
+ - slot: pricing-table
1065
+ content: |
1066
+ | Tier | Price |
1067
+ |------|-------|
1068
+ | Starter | $200 |`;
1069
+ const result = parseYaml(yaml);
1070
+ const snippets = result.snippets as Array<Record<string, unknown>>;
1071
+ expect(snippets[0].content).toBe("| Tier | Price |\n|------|-------|\n| Starter | $200 |");
1072
+ });
1073
+
1074
+ it("parses folded block scalars", () => {
1075
+ const yaml = `globals:
1076
+ summary: >
1077
+ Line one
1078
+ line two
1079
+
1080
+ Paragraph two`;
1081
+ const result = parseYaml(yaml);
1082
+ const globals = result.globals as Record<string, unknown>;
1083
+ expect(globals.summary).toBe("Line one line two\nParagraph two");
1084
+ });
1085
+
1086
+ it("supports block scalar chomping indicators", () => {
1087
+ const yaml = `globals:
1088
+ keep: |+
1089
+ One
1090
+ Two
1091
+ strip: >-
1092
+ A
1093
+ B`;
1094
+ const result = parseYaml(yaml);
1095
+ const globals = result.globals as Record<string, unknown>;
1096
+ expect(globals.keep).toBe("One\nTwo\n");
1097
+ expect(globals.strip).toBe("A B");
1098
+ });
1099
+
1100
+ it("parses direct array block scalar items", () => {
1101
+ const yaml = `snippets:
1102
+ - |
1103
+ line 1
1104
+ line 2
1105
+ - >-
1106
+ folded
1107
+ line`;
1108
+ const result = parseYaml(yaml);
1109
+ expect(result.snippets).toEqual(["line 1\nline 2", "folded line"]);
1110
+ });
1111
+
1112
+ it("accepts block scalar indentation indicators", () => {
1113
+ const yaml = `globals:
1114
+ note: |2-
1115
+ indented
1116
+ block`;
1117
+ const result = parseYaml(yaml);
1118
+ const globals = result.globals as Record<string, unknown>;
1119
+ expect(globals.note).toBe("indented\nblock");
1120
+ });
1121
+
1122
+ it("treats invalid block scalar headers as plain strings", () => {
1123
+ const yaml = `globals:
1124
+ bad: |abc
1125
+ snippets:
1126
+ - >foo`;
1127
+ const result = parseYaml(yaml);
1128
+ const globals = result.globals as Record<string, unknown>;
1129
+ const snippets = result.snippets as unknown[];
1130
+ expect(globals.bad).toBe("|abc");
1131
+ expect(snippets[0]).toBe(">foo");
1132
+ });
1133
+
535
1134
  it("returns empty object for empty input", () => {
536
1135
  const result = parseYaml("");
537
1136
  expect(result).toEqual({});
@@ -1122,12 +1721,18 @@ version: "1.2.0"
1122
1721
  brand:
1123
1722
  name: Test
1124
1723
  url: /
1724
+ logoDark: assets/brand/logo-dark.png
1125
1725
  sections:
1126
1726
  - name: Guide
1127
1727
  path: guide
1128
1728
  footer:
1129
1729
  copyright: "© 2026 Test"
1130
1730
  attribution: false
1731
+ logo: "assets/brand/footer-logo.png"
1732
+ logoDark: "assets/brand/footer-logo-dark.png"
1733
+ logoUrl: "https://example.com/footer"
1734
+ logoAlt: "Footer Brand"
1735
+ logoHeight: 24
1131
1736
  links:
1132
1737
  - text: Privacy
1133
1738
  url: /privacy
@@ -1137,14 +1742,45 @@ footer:
1137
1742
 
1138
1743
  const config = await loadSiteConfig(dir);
1139
1744
  expect(config.version).toBe("1.2.0");
1745
+ expect(config.brand.logoDark).toBe("assets/brand/logo-dark.png");
1140
1746
  expect(config.footer?.copyright).toBe("© 2026 Test");
1141
1747
  expect(config.footer?.attribution).toBe(false);
1748
+ expect(config.footer?.logo).toBe("assets/brand/footer-logo.png");
1749
+ expect(config.footer?.logoDark).toBe("assets/brand/footer-logo-dark.png");
1750
+ expect(config.footer?.logoUrl).toBe("https://example.com/footer");
1751
+ expect(config.footer?.logoAlt).toBe("Footer Brand");
1752
+ expect(config.footer?.logoHeight).toBe(24);
1142
1753
  expect(config.footer?.links).toEqual([{ text: "Privacy", url: "/privacy" }]);
1143
1754
  } finally {
1144
1755
  await rm(dir, { recursive: true, force: true });
1145
1756
  }
1146
1757
  });
1147
1758
 
1759
+ it("clamps footer.logoHeight to supported range", async () => {
1760
+ const dir = await mkdtemp(join(tmpdir(), "kitfly-footer-logo-height-"));
1761
+ try {
1762
+ await writeFile(
1763
+ join(dir, "site.yaml"),
1764
+ `title: Test
1765
+ brand:
1766
+ name: Test
1767
+ url: /
1768
+ sections:
1769
+ - name: Guide
1770
+ path: guide
1771
+ footer:
1772
+ logoHeight: 100
1773
+ `,
1774
+ "utf-8",
1775
+ );
1776
+
1777
+ const config = await loadSiteConfig(dir);
1778
+ expect(config.footer?.logoHeight).toBe(40);
1779
+ } finally {
1780
+ await rm(dir, { recursive: true, force: true });
1781
+ }
1782
+ });
1783
+
1148
1784
  it("truncates footer links to max 10", async () => {
1149
1785
  const dir = await mkdtemp(join(tmpdir(), "kitfly-footer-links-"));
1150
1786
  const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
@@ -1685,6 +2321,70 @@ describe("buildFooter", () => {
1685
2321
  expect(result).toContain('class="footer-link"');
1686
2322
  });
1687
2323
 
2324
+ it("renders footer logo before version with configurable metadata", () => {
2325
+ const result = buildFooter(
2326
+ baseProvenance,
2327
+ {
2328
+ ...baseConfig,
2329
+ footer: {
2330
+ logo: "assets/brand/footer-logo.png",
2331
+ logoUrl: "https://footer.example.com",
2332
+ logoAlt: "Footer Brand",
2333
+ logoHeight: 22,
2334
+ },
2335
+ },
2336
+ "../",
2337
+ );
2338
+ expect(result).toContain('class="footer-logo-link"');
2339
+ expect(result).toContain('class="footer-logo-img"');
2340
+ expect(result).toContain('href="https://footer.example.com"');
2341
+ expect(result).toContain('src="../assets/brand/footer-logo.png"');
2342
+ expect(result).toContain('alt="Footer Brand"');
2343
+ expect(result).toContain("max-height: 22px");
2344
+ expect(result).toContain("onerror=\"this.onerror=null;this.style.display='none'\"");
2345
+ expect(result.indexOf('class="footer-logo-img"')).toBeLessThan(
2346
+ result.indexOf('class="footer-version"'),
2347
+ );
2348
+ });
2349
+
2350
+ it("renders footer light/dark logo variants when logoDark is set", () => {
2351
+ const result = buildFooter(baseProvenance, {
2352
+ ...baseConfig,
2353
+ footer: {
2354
+ logo: "assets/brand/footer-logo.png",
2355
+ logoDark: "assets/brand/footer-logo-dark.png",
2356
+ },
2357
+ });
2358
+ expect(result).toContain("footer-logo-img logo-light");
2359
+ expect(result).toContain("footer-logo-img logo-dark");
2360
+ expect(result).toContain('src="assets/brand/footer-logo-dark.png"');
2361
+ });
2362
+
2363
+ it("falls back footer logo alt text to copyright then brand name", () => {
2364
+ const withCopyrightAlt = buildFooter(
2365
+ baseProvenance,
2366
+ {
2367
+ ...baseConfig,
2368
+ footer: {
2369
+ logo: "assets/brand/footer-logo.png",
2370
+ copyright: "Copyright 2026 Acme",
2371
+ },
2372
+ },
2373
+ "./",
2374
+ );
2375
+ expect(withCopyrightAlt).toContain('alt="Copyright 2026 Acme"');
2376
+
2377
+ const withBrandAlt = buildFooter(
2378
+ baseProvenance,
2379
+ {
2380
+ ...baseConfig,
2381
+ footer: { logo: "assets/brand/footer-logo.png" },
2382
+ },
2383
+ "./",
2384
+ );
2385
+ expect(withBrandAlt).toContain('alt="Acme Corp"');
2386
+ });
2387
+
1688
2388
  it("uses publish date year in default copyright", () => {
1689
2389
  const result = buildFooter(baseProvenance, baseConfig);
1690
2390
  expect(result).toContain("© 2024 Acme Corp");
@@ -1751,6 +2451,24 @@ describe("buildBundleFooter", () => {
1751
2451
  expect(result).toContain("&lt;em&gt;");
1752
2452
  expect(result).not.toContain("<em>");
1753
2453
  });
2454
+
2455
+ it("renders footer logo in bundle mode with override source", () => {
2456
+ const result = buildBundleFooter(
2457
+ "0.1.1",
2458
+ {
2459
+ ...baseConfig,
2460
+ footer: {
2461
+ logo: "assets/brand/footer-logo.png",
2462
+ logoUrl: "https://footer.example.com",
2463
+ logoHeight: 18,
2464
+ },
2465
+ },
2466
+ "data:image/png;base64,AAAA",
2467
+ );
2468
+ expect(result).toContain('src="data:image/png;base64,AAAA"');
2469
+ expect(result).toContain('href="https://footer.example.com"');
2470
+ expect(result).toContain("max-height: 18px");
2471
+ });
1754
2472
  });
1755
2473
 
1756
2474
  // ---------------------------------------------------------------------------