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
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Tests for the Brief template definition
3
+ *
4
+ * Covers: src/templates/brief.ts
5
+ * Strategy: import the template directly, verify structure and generated content
6
+ */
7
+
8
+ import { describe, expect, it } from "vitest";
9
+ import { brief } from "../templates/brief.ts";
10
+ import type { BrandingConfig, TemplateContext, TemplateFile } from "../templates/schema.ts";
11
+
12
+ function makeCtx(overrides?: Partial<TemplateContext>): TemplateContext {
13
+ const branding: BrandingConfig = {
14
+ siteName: "Test Brief",
15
+ brandName: "Acme Corp",
16
+ brandUrl: "https://acme.example.com",
17
+ primaryColor: "#2563eb",
18
+ footerText: "Footer text",
19
+ };
20
+ return {
21
+ name: "test-brief",
22
+ branding,
23
+ template: brief,
24
+ year: 2026,
25
+ ...overrides,
26
+ };
27
+ }
28
+
29
+ function resolveContent(file: TemplateFile, ctx: TemplateContext): string {
30
+ return typeof file.content === "function" ? file.content(ctx) : file.content;
31
+ }
32
+
33
+ function findFile(path: string): TemplateFile {
34
+ const f = brief.files.find((entry) => entry.path === path);
35
+ if (!f) throw new Error(`Template file not found: ${path}`);
36
+ return f;
37
+ }
38
+
39
+ describe("brief template definition", () => {
40
+ it("has correct identity metadata", () => {
41
+ expect(brief.id).toBe("brief");
42
+ expect(brief.name).toBe("Brief");
43
+ expect(brief.version).toBe(1);
44
+ expect(brief.extends).toBe("minimal");
45
+ });
46
+
47
+ it("defines exactly four sections", () => {
48
+ expect(brief.sections).toHaveLength(4);
49
+ expect(brief.sections.map((s) => s.name)).toEqual([
50
+ "Product",
51
+ "Use Cases",
52
+ "Getting Started",
53
+ "Reference",
54
+ ]);
55
+ expect(brief.sections.map((s) => s.path)).toEqual([
56
+ "content/product",
57
+ "content/use-cases",
58
+ "content/getting-started",
59
+ "content/reference",
60
+ ]);
61
+ });
62
+
63
+ it("defines unique file paths", () => {
64
+ const paths = brief.files.map((f) => f.path);
65
+ expect(new Set(paths).size).toBe(paths.length);
66
+ });
67
+ });
68
+
69
+ describe("brief site.yaml", () => {
70
+ const ctx = makeCtx();
71
+ const file = findFile("site.yaml");
72
+
73
+ it("includes branding and all four section paths", () => {
74
+ const content = resolveContent(file, ctx);
75
+ expect(content).toContain('title: "Test Brief"');
76
+ expect(content).toContain('name: "Acme Corp"');
77
+ expect(content).toContain('url: "https://acme.example.com"');
78
+ expect(content).toContain('"content/product"');
79
+ expect(content).toContain('"content/use-cases"');
80
+ expect(content).toContain('"content/getting-started"');
81
+ expect(content).toContain('"content/reference"');
82
+ expect(content).toContain('home: "index.md"');
83
+ });
84
+ });
85
+
86
+ describe("brief starter files", () => {
87
+ const ctx = makeCtx();
88
+ const starterFiles = [
89
+ "index.md",
90
+ "content/product/overview.md",
91
+ "content/product/capabilities.md",
92
+ "content/use-cases/overview.md",
93
+ "content/use-cases/example-use-case.md",
94
+ "content/getting-started/overview.md",
95
+ "content/getting-started/requirements.md",
96
+ "content/reference/architecture.md",
97
+ "content/reference/faq.md",
98
+ "content/reference/contacts.md",
99
+ ];
100
+
101
+ it("ships all 10 starter files", () => {
102
+ expect(starterFiles).toHaveLength(10);
103
+ for (const path of starterFiles) {
104
+ expect(brief.files.some((f) => f.path === path)).toBe(true);
105
+ }
106
+ });
107
+
108
+ it("includes frontmatter in markdown starter files", () => {
109
+ for (const path of starterFiles) {
110
+ const content = resolveContent(findFile(path), ctx);
111
+ expect(content).toMatch(/^---\n/);
112
+ }
113
+ });
114
+
115
+ it("includes customize markers in starter files", () => {
116
+ for (const path of starterFiles) {
117
+ const content = resolveContent(findFile(path), ctx);
118
+ expect(content).toContain("<!-- ← CUSTOMIZE");
119
+ }
120
+ });
121
+
122
+ it("uses professional framing in index and use cases", () => {
123
+ const index = resolveContent(findFile("index.md"), ctx);
124
+ expect(index).toContain("At a Glance");
125
+ expect(index).toContain("[Use Cases](/content/use-cases/overview)");
126
+
127
+ const useCases = resolveContent(findFile("content/use-cases/example-use-case.md"), ctx);
128
+ expect(useCases).toContain("## Scenario");
129
+ expect(useCases).toContain("## Outcome");
130
+ expect(useCases).toContain("| Metric | Before | After |");
131
+ });
132
+ });
133
+
134
+ describe("brief CUSTOMIZING.md", () => {
135
+ const ctx = makeCtx();
136
+ const file = findFile("CUSTOMIZING.md");
137
+
138
+ it("documents template identity and external-audience tone", () => {
139
+ const content = resolveContent(file, ctx);
140
+ expect(content).toContain("template: brief");
141
+ expect(content).toContain("clients, prospects, and partners");
142
+ expect(content).toContain("Informational and professional");
143
+ expect(content).toContain("Not sales copy");
144
+ });
145
+
146
+ it("guides use-case duplication workflow", () => {
147
+ const content = resolveContent(file, ctx);
148
+ expect(content).toContain("Duplicate `content/use-cases/example-use-case.md`");
149
+ expect(content).toContain("problem -> solution -> outcome");
150
+ });
151
+ });
@@ -35,7 +35,8 @@ async function writeSiteYaml(dir: string, extra: Record<string, unknown> = {}):
35
35
  const mode = extra.mode ? `mode: ${extra.mode}\n` : "";
36
36
  const aspect = extra.aspect ? `aspect: ${extra.aspect}\n` : "";
37
37
  const home = extra.home ? `home: ${extra.home}\n` : "";
38
- const yaml = `title: ${title}\n${version}${mode}${aspect}brand:\n${brand}\n${home}sections:\n${sections}\n`;
38
+ const footer = extra.footer ? `footer:\n${extra.footer}\n` : "";
39
+ const yaml = `title: ${title}\n${version}${mode}${aspect}brand:\n${brand}\n${home}${footer}sections:\n${sections}\n`;
39
40
  await writeFile(join(dir, "site.yaml"), yaml);
40
41
  }
41
42
 
@@ -216,6 +217,45 @@ describe("build", () => {
216
217
  expect(html).toContain("unversioned");
217
218
  });
218
219
 
220
+ it("renders footer logo with static path prefix in built output", async () => {
221
+ const siteDir = await makeTempDir();
222
+ const outDir = "out";
223
+ await writeSiteYaml(siteDir, {
224
+ footer: ' logo: "assets/brand/footer-logo.png"\n logoAlt: "Footer Brand"\n logoHeight: 24',
225
+ });
226
+ await writeMd(siteDir, "docs/page.md", "# Page");
227
+
228
+ await build({ folder: siteDir, out: outDir });
229
+
230
+ const html = await readFile(join(siteDir, outDir, "index.html"), "utf-8");
231
+ expect(html).toContain('class="footer-logo-img"');
232
+ expect(html).toContain('src="./assets/brand/footer-logo.png"');
233
+ expect(html).toContain('alt="Footer Brand"');
234
+ expect(html).toContain("max-height: 24px");
235
+ });
236
+
237
+ it("renders light/dark variants for header and footer logos when configured", async () => {
238
+ const siteDir = await makeTempDir();
239
+ const outDir = "out";
240
+ await writeSiteYaml(siteDir, {
241
+ brand:
242
+ ' name: Test\n url: /\n logo: "assets/brand/logo.png"\n logoDark: "assets/brand/logo-dark.png"',
243
+ footer:
244
+ ' logo: "assets/brand/footer-logo.png"\n logoDark: "assets/brand/footer-logo-dark.png"',
245
+ });
246
+ await writeMd(siteDir, "docs/page.md", "# Page");
247
+
248
+ await build({ folder: siteDir, out: outDir });
249
+
250
+ const html = await readFile(join(siteDir, outDir, "index.html"), "utf-8");
251
+ expect(html).toContain("logo-img logo-light");
252
+ expect(html).toContain("logo-img logo-dark");
253
+ expect(html).toContain("./assets/brand/logo-dark.png");
254
+ expect(html).toContain("footer-logo-img logo-light");
255
+ expect(html).toContain("footer-logo-img logo-dark");
256
+ expect(html).toContain("./assets/brand/footer-logo-dark.png");
257
+ });
258
+
219
259
  it("builds a single-page hash-routed deck when mode is slides", async () => {
220
260
  const siteDir = await makeTempDir();
221
261
  const outDir = "out";
@@ -332,6 +372,87 @@ plugins:
332
372
  expect(html).toContain(js);
333
373
  });
334
374
 
375
+ it("preserves dollar-sign replacement patterns in injected plugin JS", async () => {
376
+ const siteDir = await makeTempDir();
377
+ const outDir = "out";
378
+ await writeSiteYaml(siteDir);
379
+ await writeMd(siteDir, "docs/page.md", "# Test");
380
+
381
+ const js = "const expr='x'; const sample = `$${expr}$`; const marker = '$`';";
382
+ await mkdir(join(siteDir, "plugins-dist"), { recursive: true });
383
+ await writeFile(join(siteDir, "plugins-dist", "literal.js"), js, "utf-8");
384
+
385
+ await mkdir(join(siteDir, "registry"), { recursive: true });
386
+ await writeFile(
387
+ join(siteDir, "registry", "plugins.yaml"),
388
+ `version: 1
389
+ updated: "2026-02-16"
390
+ baseUrl: ""
391
+ plugins:
392
+ literal:
393
+ name: "Literal"
394
+ description: "Replacement pattern regression"
395
+ version: "1.0.0"
396
+ contract: "1"
397
+ kitfly: ">=0.2.0 <1.0.0"
398
+ license: MIT
399
+ verified: true
400
+ assets:
401
+ js: "plugins-dist/literal.js"
402
+ assetSha256:
403
+ js: "sha256:${sha256Hex(js)}"
404
+ `,
405
+ "utf-8",
406
+ );
407
+
408
+ await writeFile(join(siteDir, "kitfly.plugins.yaml"), "plugins:\n - literal@1.0.0\n", "utf-8");
409
+
410
+ await build({ folder: siteDir, out: outDir });
411
+
412
+ const html = await readFile(join(siteDir, outDir, "index.html"), "utf-8");
413
+ expect(html).toContain("const sample = `$${expr}$`;");
414
+ expect(html).toContain("const marker = '$`';");
415
+ expect(html).not.toContain("<!DOCTYPE html><html");
416
+ });
417
+
418
+ it("preserves dollar-sign replacement patterns in markdown content", async () => {
419
+ const siteDir = await makeTempDir();
420
+ const outDir = "out";
421
+ await writeSiteYaml(siteDir);
422
+ await writeMd(
423
+ siteDir,
424
+ "docs/dollars.md",
425
+ "# Dollars\n\nLiteral dollars: $$ and $` and $' and $&\n\nMath style: $$x^2$$",
426
+ );
427
+
428
+ await build({ folder: siteDir, out: outDir });
429
+
430
+ const html = await readFile(join(siteDir, outDir, "index.html"), "utf-8");
431
+ expect(html).toContain("Literal dollars: $$ and $` and $&#39; and $&amp;");
432
+ expect(html).toContain("Math style: $$x^2$$");
433
+ expect(html.match(/<!DOCTYPE html>/g)?.length ?? 0).toBe(1);
434
+ });
435
+
436
+ it("injects latex plugin in docs mode", async () => {
437
+ const siteDir = await makeTempDir();
438
+ const outDir = "out";
439
+ await writeSiteYaml(siteDir);
440
+ await writeMd(
441
+ siteDir,
442
+ "docs/math.md",
443
+ "# Math\n\nInline: $x^2$.\n\n$$\n\\\\int_0^1 x^2 dx\n$$\n\n```math\n\\\\sum_{i=1}^{n} i\n```",
444
+ );
445
+ await writeFile(join(siteDir, "kitfly.plugins.yaml"), "plugins:\n - latex@0.2.2\n", "utf-8");
446
+
447
+ await build({ folder: siteDir, out: outDir });
448
+
449
+ const html = await readFile(join(siteDir, outDir, "index.html"), "utf-8");
450
+ expect(html).toContain('data-kitfly-plugin="latex@0.2.2"');
451
+ expect(html).toContain("const KATEX_JS_URL =");
452
+ expect(html).toContain("cdn.jsdelivr.net/npm/katex@0.16.21/dist/katex.min.js");
453
+ expect(html).toContain("cdn.jsdelivr.net/npm/katex@0.16.21/dist/katex.min.css");
454
+ });
455
+
335
456
  it("injects slides-only plugins when mode=slides", async () => {
336
457
  const siteDir = await makeTempDir();
337
458
  const outDir = "out";
@@ -395,6 +516,53 @@ plugins:
395
516
  expect(html).toContain(js);
396
517
  });
397
518
 
519
+ it("injects slides-charts-lite plugin in slides mode", async () => {
520
+ const siteDir = await makeTempDir();
521
+ const outDir = "out";
522
+ await writeSiteYaml(siteDir, { mode: "slides" });
523
+ await writeMd(
524
+ siteDir,
525
+ "docs/deck.md",
526
+ `# Chart
527
+
528
+ \`\`\`chart
529
+ kind: bar
530
+ title: Revenue
531
+ labels: ["Q1", "Q2"]
532
+ data: [10, 12]
533
+ \`\`\`
534
+ `,
535
+ );
536
+ await writeFile(
537
+ join(siteDir, "kitfly.plugins.yaml"),
538
+ "plugins:\n - slides-charts-lite@0.2.2\n",
539
+ "utf-8",
540
+ );
541
+
542
+ await build({ folder: siteDir, out: outDir });
543
+
544
+ const html = await readFile(join(siteDir, outDir, "index.html"), "utf-8");
545
+ expect(html).toContain('data-kitfly-plugin="slides-charts-lite@0.2.2"');
546
+ expect(html).toContain("kitfly-chart-wrapper");
547
+ });
548
+
549
+ it("does not inject slides-charts-lite in docs mode", async () => {
550
+ const siteDir = await makeTempDir();
551
+ const outDir = "out";
552
+ await writeSiteYaml(siteDir, { mode: "docs" });
553
+ await writeMd(siteDir, "docs/page.md", "# Page");
554
+ await writeFile(
555
+ join(siteDir, "kitfly.plugins.yaml"),
556
+ "plugins:\n - slides-charts-lite@0.2.2\n",
557
+ "utf-8",
558
+ );
559
+
560
+ await build({ folder: siteDir, out: outDir });
561
+
562
+ const html = await readFile(join(siteDir, outDir, "index.html"), "utf-8");
563
+ expect(html).not.toContain('data-kitfly-plugin="slides-charts-lite@0.2.2"');
564
+ });
565
+
398
566
  it("ignores unknown slides-visuals block types while enforcing known contracts", async () => {
399
567
  const siteDir = await makeTempDir();
400
568
  const outDir = "out";
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from "vitest";
5
5
  import {
6
6
  buildBundleNav,
7
7
  buildBundleSidebarHeader,
8
+ bundleSite,
8
9
  fileToDataUri,
9
10
  imageMime,
10
11
  inlineLocalImages,
@@ -815,3 +816,136 @@ describe("buildBundleSidebarHeader", () => {
815
816
  expect(html).toContain(`src="${logo}"`);
816
817
  });
817
818
  });
819
+
820
+ describe("bundleSite plugin integration", () => {
821
+ it("inlines latex plugin script when enabled", async () => {
822
+ const siteDir = await makeTempDir();
823
+ await mkdir(join(siteDir, "docs"), { recursive: true });
824
+ await writeFile(
825
+ join(siteDir, "site.yaml"),
826
+ 'title: "Bundle Test"\nbrand:\n name: "Test"\n url: "/"\nsections:\n - name: Docs\n path: docs\n',
827
+ "utf-8",
828
+ );
829
+ await writeFile(join(siteDir, "docs", "index.md"), "# Bundle Math\n\n$E=mc^2$", "utf-8");
830
+ await writeFile(join(siteDir, "kitfly.plugins.yaml"), "plugins:\n - latex@0.2.2\n", "utf-8");
831
+
832
+ await bundleSite({ folder: siteDir, out: "bundles", name: "bundle.html" });
833
+
834
+ const html = await readFile(join(siteDir, "bundles", "bundle.html"), "utf-8");
835
+ expect(html).toContain('data-kitfly-plugin="latex@0.2.2"');
836
+ expect(html).toContain("const KATEX_JS_URL =");
837
+ expect(html).toContain("cdn.jsdelivr.net/npm/katex@0.16.21/dist/katex.min.js");
838
+ expect(html).toContain("cdn.jsdelivr.net/npm/katex@0.16.21/dist/katex.min.css");
839
+ });
840
+
841
+ it("inlines slides-charts-lite plugin in slides mode", async () => {
842
+ const siteDir = await makeTempDir();
843
+ await mkdir(join(siteDir, "slides"), { recursive: true });
844
+ await writeFile(
845
+ join(siteDir, "site.yaml"),
846
+ 'title: "Bundle Charts Test"\nmode: "slides"\nbrand:\n name: "Test"\n url: "/"\nsections:\n - name: Slides\n path: slides\n',
847
+ "utf-8",
848
+ );
849
+ await writeFile(
850
+ join(siteDir, "slides", "deck.md"),
851
+ '# Deck\n\n```chart\nkind: line\nlabels: ["W1", "W2"]\ndata: [2, 3]\n```',
852
+ "utf-8",
853
+ );
854
+ await writeFile(
855
+ join(siteDir, "kitfly.plugins.yaml"),
856
+ "plugins:\n - slides-charts-lite@0.2.2\n",
857
+ "utf-8",
858
+ );
859
+
860
+ await bundleSite({ folder: siteDir, out: "bundles", name: "bundle.html" });
861
+
862
+ const html = await readFile(join(siteDir, "bundles", "bundle.html"), "utf-8");
863
+ expect(html).toContain('data-kitfly-plugin="slides-charts-lite@0.2.2"');
864
+ expect(html).toContain("kitfly-chart-wrapper");
865
+ });
866
+
867
+ it("inlines footer logo image when configured", async () => {
868
+ const siteDir = await makeTempDir();
869
+ await mkdir(join(siteDir, "docs"), { recursive: true });
870
+ await mkdir(join(siteDir, "assets", "brand"), { recursive: true });
871
+ await writeFile(
872
+ join(siteDir, "site.yaml"),
873
+ 'title: "Bundle Footer Test"\nbrand:\n name: "Test"\n url: "/"\nfooter:\n logo: "assets/brand/footer-logo.png"\n logoAlt: "Footer Brand"\n logoHeight: 24\nsections:\n - name: Docs\n path: docs\n',
874
+ "utf-8",
875
+ );
876
+ await writeFile(join(siteDir, "docs", "index.md"), "# Footer Logo");
877
+ await writeFile(
878
+ join(siteDir, "assets", "brand", "footer-logo.png"),
879
+ Buffer.from(
880
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl9x9kAAAAASUVORK5CYII=",
881
+ "base64",
882
+ ),
883
+ );
884
+
885
+ await bundleSite({ folder: siteDir, out: "bundles", name: "bundle.html" });
886
+
887
+ const html = await readFile(join(siteDir, "bundles", "bundle.html"), "utf-8");
888
+ expect(html).toContain('class="footer-logo-img"');
889
+ expect(html).toContain('src="data:image/png;base64,');
890
+ expect(html).not.toContain('src="assets/brand/footer-logo.png"');
891
+ expect(html).toContain('alt="Footer Brand"');
892
+ expect(html).toContain("max-height: 24px");
893
+ });
894
+
895
+ it("inlines footer logo from site-root-relative path outside assets", async () => {
896
+ const siteDir = await makeTempDir();
897
+ await mkdir(join(siteDir, "docs"), { recursive: true });
898
+ await mkdir(join(siteDir, "logos"), { recursive: true });
899
+ await writeFile(
900
+ join(siteDir, "site.yaml"),
901
+ 'title: "Bundle Footer Root Path Test"\nbrand:\n name: "Test"\n url: "/"\nfooter:\n logo: "logos/footer.png"\n logoAlt: "Footer Root Logo"\nsections:\n - name: Docs\n path: docs\n',
902
+ "utf-8",
903
+ );
904
+ await writeFile(join(siteDir, "docs", "index.md"), "# Footer Root Path");
905
+ await writeFile(
906
+ join(siteDir, "logos", "footer.png"),
907
+ Buffer.from(
908
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl9x9kAAAAASUVORK5CYII=",
909
+ "base64",
910
+ ),
911
+ );
912
+
913
+ await bundleSite({ folder: siteDir, out: "bundles", name: "bundle.html" });
914
+
915
+ const html = await readFile(join(siteDir, "bundles", "bundle.html"), "utf-8");
916
+ expect(html).toContain('class="footer-logo-img"');
917
+ expect(html).toContain('src="data:image/png;base64,');
918
+ expect(html).not.toContain('src="logos/footer.png"');
919
+ expect(html).toContain('alt="Footer Root Logo"');
920
+ });
921
+
922
+ it("inlines light/dark variants for header and footer logos", async () => {
923
+ const siteDir = await makeTempDir();
924
+ await mkdir(join(siteDir, "docs"), { recursive: true });
925
+ await mkdir(join(siteDir, "assets", "brand"), { recursive: true });
926
+ await writeFile(
927
+ join(siteDir, "site.yaml"),
928
+ 'title: "Bundle Dark Logos Test"\nbrand:\n name: "Test"\n url: "/"\n logo: "assets/brand/logo.png"\n logoDark: "assets/brand/logo-dark.png"\nfooter:\n logo: "assets/brand/footer-logo.png"\n logoDark: "assets/brand/footer-logo-dark.png"\nsections:\n - name: Docs\n path: docs\n',
929
+ "utf-8",
930
+ );
931
+ await writeFile(join(siteDir, "docs", "index.md"), "# Dark Logos");
932
+ const pixelPng = Buffer.from(
933
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl9x9kAAAAASUVORK5CYII=",
934
+ "base64",
935
+ );
936
+ await writeFile(join(siteDir, "assets", "brand", "logo.png"), pixelPng);
937
+ await writeFile(join(siteDir, "assets", "brand", "logo-dark.png"), pixelPng);
938
+ await writeFile(join(siteDir, "assets", "brand", "footer-logo.png"), pixelPng);
939
+ await writeFile(join(siteDir, "assets", "brand", "footer-logo-dark.png"), pixelPng);
940
+
941
+ await bundleSite({ folder: siteDir, out: "bundles", name: "bundle.html" });
942
+
943
+ const html = await readFile(join(siteDir, "bundles", "bundle.html"), "utf-8");
944
+ expect(html).toContain('class="logo-img logo-light"');
945
+ expect(html).toContain('class="logo-img logo-dark"');
946
+ expect(html).toContain('class="footer-logo-img logo-light"');
947
+ expect(html).toContain('class="footer-logo-img logo-dark"');
948
+ expect(html).not.toContain('src="assets/brand/logo-dark.png"');
949
+ expect(html).not.toContain('src="assets/brand/footer-logo-dark.png"');
950
+ });
951
+ });
@@ -9,8 +9,8 @@ import { existsSync } from "node:fs";
9
9
  import { mkdtemp, readFile, rm } from "node:fs/promises";
10
10
  import { tmpdir } from "node:os";
11
11
  import { join } from "node:path";
12
- import { afterEach, beforeEach, describe, expect, it } from "vitest";
13
- import { init } from "../commands/init.ts";
12
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
13
+ import { init, listAvailableTemplates } from "../commands/init.ts";
14
14
  import { defaultBranding, getTemplate, listTemplates, runTemplate } from "../templates/driver.ts";
15
15
 
16
16
  // ---------------------------------------------------------------------------
@@ -53,6 +53,7 @@ describe("template registry", () => {
53
53
  expect(ids).toContain("minimal");
54
54
  expect(ids).toContain("deck");
55
55
  expect(ids).toContain("handbook");
56
+ expect(ids).toContain("brief");
56
57
  expect(templates.length).toBeGreaterThanOrEqual(2);
57
58
  });
58
59
 
@@ -66,6 +67,17 @@ describe("template registry", () => {
66
67
  it("returns undefined for unknown template", () => {
67
68
  expect(getTemplate("nonexistent")).toBeUndefined();
68
69
  });
70
+
71
+ it("listAvailableTemplates output includes brief", () => {
72
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
73
+ try {
74
+ listAvailableTemplates();
75
+ const output = spy.mock.calls.map((call) => String(call[0] ?? "")).join("\n");
76
+ expect(output).toContain("brief");
77
+ } finally {
78
+ spy.mockRestore();
79
+ }
80
+ });
69
81
  });
70
82
 
71
83
  // ---------------------------------------------------------------------------
@@ -358,6 +370,16 @@ describe("init() entry point", () => {
358
370
  expect(generatedExists(projectName, "CUSTOMIZING.md")).toBe(true);
359
371
  });
360
372
 
373
+ it("creates a brief site when template flag is set", async () => {
374
+ await init(projectName, { template: "brief", git: false });
375
+
376
+ expect(generatedExists(projectName, "content/product/overview.md")).toBe(true);
377
+ expect(generatedExists(projectName, "content/use-cases/example-use-case.md")).toBe(true);
378
+ expect(generatedExists(projectName, "content/getting-started/requirements.md")).toBe(true);
379
+ expect(generatedExists(projectName, "content/reference/contacts.md")).toBe(true);
380
+ expect(generatedExists(projectName, "CUSTOMIZING.md")).toBe(true);
381
+ });
382
+
361
383
  it("passes brand overrides through to template context", async () => {
362
384
  await init(projectName, {
363
385
  git: false,
@@ -439,6 +461,33 @@ describe("standalone mode", () => {
439
461
  });
440
462
  });
441
463
 
464
+ describe("ai-assist mode", () => {
465
+ const projectName = "test-brief-ai";
466
+
467
+ it("brief template writes AGENTS.md and brief-specific role files", async () => {
468
+ await runTemplate({
469
+ name: projectName,
470
+ template: "brief",
471
+ git: false,
472
+ aiAssist: true,
473
+ });
474
+
475
+ expect(generatedExists(projectName, "AGENTS.md")).toBe(true);
476
+ expect(generatedExists(projectName, "config/agentic/roles/devlead.yaml")).toBe(true);
477
+ expect(generatedExists(projectName, "config/agentic/roles/infoarch.yaml")).toBe(true);
478
+ expect(generatedExists(projectName, "config/agentic/roles/qa.yaml")).toBe(true);
479
+ expect(generatedExists(projectName, "config/agentic/roles/prodstrat.yaml")).toBe(true);
480
+ expect(generatedExists(projectName, "config/agentic/roles/advisor.yaml")).toBe(true);
481
+ expect(generatedExists(projectName, "config/agentic/roles/analyst.yaml")).toBe(false);
482
+ expect(generatedExists(projectName, "config/agentic/roles/prodmktg.yaml")).toBe(false);
483
+
484
+ const agents = await readGenerated(projectName, "AGENTS.md");
485
+ expect(agents).toContain("external audience brief");
486
+ expect(agents).toContain("informational and professional");
487
+ expect(agents).toContain("problem -> solution -> outcome");
488
+ });
489
+ });
490
+
442
491
  // ---------------------------------------------------------------------------
443
492
  // Default Branding Derivation
444
493
  // ---------------------------------------------------------------------------
@@ -0,0 +1,35 @@
1
+ import { expect, test } from "bun:test";
2
+
3
+ type LatexHooks = {
4
+ splitMath: (text: string) => Array<{ text?: string; math?: string; display?: boolean }>;
5
+ isCurrency: (expr: string) => boolean;
6
+ shouldTreatAsLiteralInline: (expr: string, text: string, closingIndex: number) => boolean;
7
+ };
8
+
9
+ async function loadHooks(): Promise<LatexHooks> {
10
+ // @ts-expect-error JS plugin helper file
11
+ await import("../../plugins-dist/latex-runtime.js");
12
+ const hooks = (globalThis as any).__kitflyLatexTest as LatexHooks | undefined;
13
+ if (!hooks) throw new Error("latex test hooks not found on globalThis");
14
+ return hooks;
15
+ }
16
+
17
+ test("latex: parses inline math", async () => {
18
+ const hooks = await loadHooks();
19
+ expect(hooks.splitMath("A $x^2$ value")).toEqual([
20
+ { text: "A " },
21
+ { math: "x^2", display: false },
22
+ { text: " value" },
23
+ ]);
24
+ });
25
+
26
+ test("latex: treats $5-$10 as literal currency range", async () => {
27
+ const hooks = await loadHooks();
28
+ expect(hooks.splitMath("$5-$10")).toEqual([{ text: "$5-$10" }]);
29
+ });
30
+
31
+ test("latex: currency helper matches dashed ranges", async () => {
32
+ const hooks = await loadHooks();
33
+ expect(hooks.isCurrency("5-10")).toBe(true);
34
+ expect(hooks.isCurrency("5 to 10")).toBe(true);
35
+ });