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
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env bun
2
+
3
+ /**
4
+ * Build-time codegen: reads docs/embed-manifest.yaml, resolves globs,
5
+ * and generates src/generated/embedded-docs.ts with all matched content.
6
+ *
7
+ * Usage:
8
+ * bun scripts/embed-docs.ts
9
+ */
10
+
11
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { basename, dirname } from "node:path";
13
+ import { Glob } from "bun";
14
+ import { parseFrontmatter, parseYaml } from "../src/shared.ts";
15
+
16
+ const MANIFEST_PATH = "docs/embed-manifest.yaml";
17
+ const OUTPUT_PATH = "src/generated/embedded-docs.ts";
18
+
19
+ function toSlug(filePath: string): string {
20
+ // Normalize path separators first (Windows compat: Bun.Glob returns backslashes)
21
+ const normalized = filePath.replace(/\\/g, "/");
22
+ // Strip first segment (docs/ or content/) and .md extension
23
+ const withoutPrefix = normalized.replace(/^[^/]+\//, "");
24
+ const withoutExt = withoutPrefix.replace(/\.md$/, "");
25
+ const base = basename(withoutExt).toLowerCase();
26
+ if (base === "readme" || base === "index") {
27
+ return dirname(withoutPrefix).replace(/\\/g, "/");
28
+ }
29
+ return withoutExt;
30
+ }
31
+
32
+ function extractTitle(body: string): string {
33
+ for (const line of body.split("\n")) {
34
+ const m = line.match(/^#\s+(.+)/);
35
+ if (m) return m[1].trim();
36
+ }
37
+ return "(untitled)";
38
+ }
39
+
40
+ async function resolveGlobs(includes: string[], excludes: string[]): Promise<string[]> {
41
+ const matched = new Set<string>();
42
+
43
+ for (const pattern of includes) {
44
+ const glob = new Glob(pattern);
45
+ for await (const path of glob.scan({ cwd: ".", dot: false })) {
46
+ matched.add(path);
47
+ }
48
+ }
49
+
50
+ for (const pattern of excludes) {
51
+ const glob = new Glob(pattern);
52
+ for await (const path of glob.scan({ cwd: ".", dot: false })) {
53
+ matched.delete(path);
54
+ }
55
+ }
56
+
57
+ return [...matched].sort();
58
+ }
59
+
60
+ function escapeForTemplate(s: string): string {
61
+ return s.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
62
+ }
63
+
64
+ async function main(): Promise<void> {
65
+ const raw = readFileSync(MANIFEST_PATH, "utf-8");
66
+ const manifest = parseYaml(raw);
67
+ const includes = manifest.include as string[];
68
+ const excludes = (manifest.exclude as string[]) ?? [];
69
+
70
+ if (!includes?.length) {
71
+ console.error(`Error: no include patterns in ${MANIFEST_PATH}`);
72
+ process.exit(1);
73
+ }
74
+
75
+ const files = await resolveGlobs(includes, excludes);
76
+
77
+ if (files.length === 0) {
78
+ console.error("Error: no files matched the manifest patterns");
79
+ process.exit(1);
80
+ }
81
+
82
+ const entries: Array<[string, string, string]> = [];
83
+
84
+ for (const file of files) {
85
+ const content = readFileSync(file, "utf-8");
86
+ const { body } = parseFrontmatter(content);
87
+ const trimmed = body.trim();
88
+ const slug = toSlug(file);
89
+ const title = extractTitle(trimmed);
90
+ entries.push([slug, title, trimmed]);
91
+ }
92
+
93
+ // Sort by slug for deterministic output
94
+ entries.sort((a, b) => a[0].localeCompare(b[0]));
95
+
96
+ const lines = [
97
+ "// AUTO-GENERATED by scripts/embed-docs.ts — do not edit",
98
+ "export const EMBEDDED_DOCS: ReadonlyArray<readonly [string, string, string]> = [",
99
+ ];
100
+
101
+ for (const [slug, title, content] of entries) {
102
+ lines.push(
103
+ `\t[${JSON.stringify(slug)}, ${JSON.stringify(title)}, \`${escapeForTemplate(content)}\`],`,
104
+ );
105
+ }
106
+
107
+ lines.push("];");
108
+ lines.push("");
109
+
110
+ mkdirSync(dirname(OUTPUT_PATH), { recursive: true });
111
+ writeFileSync(OUTPUT_PATH, lines.join("\n"));
112
+
113
+ console.log(`Embedded ${entries.length} docs into ${OUTPUT_PATH}`);
114
+ for (const [slug, title] of entries) {
115
+ console.log(` ${slug} — ${title}`);
116
+ }
117
+ }
118
+
119
+ main();
@@ -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";
@@ -456,4 +624,128 @@ plugins:
456
624
  expect(html).toContain('data-kitfly-plugin="slides-visuals@0.2.1"');
457
625
  expect(html).toContain("future-thing");
458
626
  });
627
+
628
+ it("enforces planning-visuals fence contract in docs mode", async () => {
629
+ const siteDir = await makeTempDir();
630
+ const outDir = "out";
631
+ await writeSiteYaml(siteDir, { mode: "docs" });
632
+ await writeMd(
633
+ siteDir,
634
+ "docs/plan.md",
635
+ `# Plan
636
+
637
+ :::gantt
638
+ time-unit: week
639
+ time-start: 2026-W10
640
+ time-end: 2026-W12
641
+ :::
642
+ `,
643
+ );
644
+
645
+ const js = "console.log('planning visuals');";
646
+ const css = ".kitfly-planning-gantt{border:1px solid red;}";
647
+ await mkdir(join(siteDir, "plugins-dist"), { recursive: true });
648
+ await writeFile(join(siteDir, "plugins-dist", "planning-visuals.js"), js, "utf-8");
649
+ await writeFile(join(siteDir, "plugins-dist", "planning-visuals.css"), css, "utf-8");
650
+ await mkdir(join(siteDir, "registry"), { recursive: true });
651
+ await writeFile(
652
+ join(siteDir, "registry", "plugins.yaml"),
653
+ `version: 1
654
+ updated: "2026-03-03"
655
+ baseUrl: ""
656
+ plugins:
657
+ planning-visuals:
658
+ name: "Planning Visuals"
659
+ description: "Test planning visuals"
660
+ version: "0.2.4"
661
+ contract: "1"
662
+ kitfly: ">=0.2.4 <1.0.0"
663
+ license: MIT
664
+ verified: true
665
+ assets:
666
+ js: "plugins-dist/planning-visuals.js"
667
+ css: "plugins-dist/planning-visuals.css"
668
+ assetSha256:
669
+ js: "sha256:${sha256Hex(js)}"
670
+ css: "sha256:${sha256Hex(css)}"
671
+ `,
672
+ "utf-8",
673
+ );
674
+ await writeFile(
675
+ join(siteDir, "kitfly.plugins.yaml"),
676
+ "plugins:\n - planning-visuals@0.2.4\n",
677
+ "utf-8",
678
+ );
679
+
680
+ await expect(build({ folder: siteDir, out: outDir })).rejects.toThrow(
681
+ /planning-visuals fence contract violations/i,
682
+ );
683
+ });
684
+
685
+ it("ignores unknown planning-visuals block types while enforcing known contracts", async () => {
686
+ const siteDir = await makeTempDir();
687
+ const outDir = "out";
688
+ await writeSiteYaml(siteDir, { mode: "docs" });
689
+ await writeMd(
690
+ siteDir,
691
+ "docs/plan.md",
692
+ `# Plan
693
+
694
+ :::future-plan
695
+ note: pass-through
696
+ :::
697
+
698
+ :::gantt
699
+ time-unit: month
700
+ time-start: 2026-04
701
+ time-end: 2026-08
702
+ tracks:
703
+ - label: Wave 1
704
+ depth: 1
705
+ start: 2026-04
706
+ end: 2026-06
707
+ :::
708
+ `,
709
+ );
710
+
711
+ const js = "console.log('planning visuals');";
712
+ const css = ".kitfly-planning-gantt{border:1px solid red;}";
713
+ await mkdir(join(siteDir, "plugins-dist"), { recursive: true });
714
+ await writeFile(join(siteDir, "plugins-dist", "planning-visuals.js"), js, "utf-8");
715
+ await writeFile(join(siteDir, "plugins-dist", "planning-visuals.css"), css, "utf-8");
716
+ await mkdir(join(siteDir, "registry"), { recursive: true });
717
+ await writeFile(
718
+ join(siteDir, "registry", "plugins.yaml"),
719
+ `version: 1
720
+ updated: "2026-03-03"
721
+ baseUrl: ""
722
+ plugins:
723
+ planning-visuals:
724
+ name: "Planning Visuals"
725
+ description: "Test planning visuals"
726
+ version: "0.2.4"
727
+ contract: "1"
728
+ kitfly: ">=0.2.4 <1.0.0"
729
+ license: MIT
730
+ verified: true
731
+ assets:
732
+ js: "plugins-dist/planning-visuals.js"
733
+ css: "plugins-dist/planning-visuals.css"
734
+ assetSha256:
735
+ js: "sha256:${sha256Hex(js)}"
736
+ css: "sha256:${sha256Hex(css)}"
737
+ `,
738
+ "utf-8",
739
+ );
740
+ await writeFile(
741
+ join(siteDir, "kitfly.plugins.yaml"),
742
+ "plugins:\n - planning-visuals@0.2.4\n",
743
+ "utf-8",
744
+ );
745
+
746
+ await expect(build({ folder: siteDir, out: outDir })).resolves.toBeUndefined();
747
+ const html = await readFile(join(siteDir, outDir, "index.html"), "utf-8");
748
+ expect(html).toContain('data-kitfly-plugin="planning-visuals@0.2.4"');
749
+ expect(html).toContain("future-plan");
750
+ });
459
751
  });