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
package/src/shared.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * to reduce duplication and ensure consistency.
6
6
  */
7
7
 
8
- import { readdir, readFile, stat } from "node:fs/promises";
8
+ import { readdir, readFile, realpath, stat } from "node:fs/promises";
9
9
  import { basename, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
10
10
  import { ENGINE_SITE_DIR, siteOverridePath } from "./engine.ts";
11
11
 
@@ -26,6 +26,7 @@ export interface SiteBrand {
26
26
  url: string;
27
27
  external?: boolean;
28
28
  logo?: string; // Path to logo image (default: assets/brand/logo.png)
29
+ logoDark?: string; // Optional dark-mode logo image
29
30
  favicon?: string; // Path to favicon (default: assets/brand/favicon.png)
30
31
  logoType?: "icon" | "wordmark"; // icon = square, wordmark = wide
31
32
  }
@@ -54,6 +55,11 @@ export interface SiteFooter {
54
55
  copyrightUrl?: string;
55
56
  links?: FooterLink[];
56
57
  attribution?: boolean;
58
+ logo?: string;
59
+ logoDark?: string;
60
+ logoUrl?: string;
61
+ logoAlt?: string;
62
+ logoHeight?: number;
57
63
  // social?: SocialLinks; // Reserved for future
58
64
  }
59
65
 
@@ -62,11 +68,24 @@ export interface SiteServer {
62
68
  host?: string; // Default dev server host
63
69
  }
64
70
 
71
+ export interface ProfileConfig {
72
+ description?: string;
73
+ include?: {
74
+ tags?: string[];
75
+ };
76
+ }
77
+
78
+ export interface PrebuildHook {
79
+ command: string;
80
+ watch?: string[];
81
+ }
82
+
65
83
  export type SiteMode = "docs" | "slides";
66
84
  export type SlideAspect = "16/9" | "4/3" | "3/2" | "16/10";
67
85
 
68
86
  export interface SiteConfig {
69
87
  docroot: string;
88
+ dataroot?: string;
70
89
  title: string;
71
90
  version?: string;
72
91
  mode?: SiteMode;
@@ -76,6 +95,8 @@ export interface SiteConfig {
76
95
  sections: SiteSection[];
77
96
  footer?: SiteFooter;
78
97
  server?: SiteServer;
98
+ profiles?: Record<string, ProfileConfig>;
99
+ prebuild?: PrebuildHook[];
79
100
  }
80
101
 
81
102
  export interface Provenance {
@@ -109,6 +130,21 @@ export interface SlideContent extends SlideSegment {
109
130
  kind: "markdown" | "yaml" | "json";
110
131
  }
111
132
 
133
+ export interface CollectSlidesOptions {
134
+ markdownTransform?: (raw: string, file: ContentFile) => Promise<string> | string;
135
+ }
136
+
137
+ export interface DataSnippet {
138
+ slot: string;
139
+ content: string;
140
+ }
141
+
142
+ export interface DataBindings {
143
+ globals: Record<string, string>;
144
+ inject: Record<string, string>;
145
+ snippets: DataSnippet[];
146
+ }
147
+
112
148
  // ---------------------------------------------------------------------------
113
149
  // Environment and CLI helpers
114
150
  // ---------------------------------------------------------------------------
@@ -227,6 +263,75 @@ export function parseYaml(content: string): Record<string, unknown> {
227
263
  // Stack tracks current object context with its base indentation
228
264
  const stack: { obj: Record<string, unknown>; indent: number }[] = [{ obj: result, indent: -2 }];
229
265
 
266
+ function foldBlockScalarLines(blockLines: string[]): string {
267
+ let output = "";
268
+ for (let idx = 0; idx < blockLines.length; idx++) {
269
+ const line = blockLines[idx];
270
+ if (idx === 0) {
271
+ output = line;
272
+ continue;
273
+ }
274
+ const prev = blockLines[idx - 1];
275
+ if (line === "") {
276
+ output += "\n";
277
+ continue;
278
+ }
279
+ output += prev === "" ? line : ` ${line}`;
280
+ }
281
+ return output;
282
+ }
283
+
284
+ function parseBlockHeader(
285
+ token: string,
286
+ ): { style: "|" | ">"; chomp: "clip" | "strip" | "keep" } | null {
287
+ if (!token) return null;
288
+ const style = token[0];
289
+ if (style !== "|" && style !== ">") return null;
290
+ const tail = token.slice(1);
291
+ if (tail && !/^([1-9][+-]?|[+-][1-9]?|[+-])$/.test(tail)) {
292
+ return null;
293
+ }
294
+ const chomp = tail.includes("+") ? "keep" : tail.includes("-") ? "strip" : "clip";
295
+ return { style, chomp };
296
+ }
297
+
298
+ function parseBlockScalar(
299
+ startLine: number,
300
+ baseIndent: number,
301
+ style: "|" | ">",
302
+ chomp: "clip" | "strip" | "keep",
303
+ ): { value: string; endLine: number } {
304
+ const rawBlock: string[] = [];
305
+ let cursor = startLine;
306
+
307
+ while (cursor < lines.length) {
308
+ const candidate = lines[cursor];
309
+ if (candidate.trim() === "") {
310
+ rawBlock.push("");
311
+ cursor += 1;
312
+ continue;
313
+ }
314
+ const candidateIndent = candidate.search(/\S/);
315
+ if (candidateIndent <= baseIndent) break;
316
+ rawBlock.push(candidate);
317
+ cursor += 1;
318
+ }
319
+
320
+ const indentLevels = rawBlock
321
+ .filter((line) => line.trim() !== "")
322
+ .map((line) => line.search(/\S/));
323
+ const blockIndent = indentLevels.length > 0 ? Math.min(...indentLevels) : 0;
324
+ const blockLines = rawBlock.map((line) => {
325
+ if (line === "") return "";
326
+ return line.slice(blockIndent);
327
+ });
328
+
329
+ let value = style === "|" ? blockLines.join("\n") : foldBlockScalarLines(blockLines);
330
+ if (chomp === "strip") value = value.replace(/\n+$/g, "");
331
+ if (chomp === "keep" && value !== "" && !value.endsWith("\n")) value += "\n";
332
+ return { value, endLine: cursor - 1 };
333
+ }
334
+
230
335
  for (let i = 0; i < lines.length; i++) {
231
336
  const line = lines[i];
232
337
  // Skip comments and empty lines
@@ -257,11 +362,18 @@ export function parseYaml(content: string): Record<string, unknown> {
257
362
  if (val.startsWith("[") && val.endsWith("]")) {
258
363
  const arrContent = val.slice(1, -1);
259
364
  obj[key] = arrContent.split(",").map((s) => stripQuotes(s.trim()));
260
- } else if (val === "") {
261
- // Nested structure will follow
262
- obj[key] = null; // Placeholder
263
365
  } else {
264
- obj[key] = parseValue(val);
366
+ const header = parseBlockHeader(val);
367
+ if (header) {
368
+ const block = parseBlockScalar(i + 1, indent, header.style, header.chomp);
369
+ obj[key] = block.value;
370
+ i = block.endLine;
371
+ } else if (val === "") {
372
+ // Nested structure will follow
373
+ obj[key] = null; // Placeholder
374
+ } else {
375
+ obj[key] = parseValue(val);
376
+ }
265
377
  }
266
378
 
267
379
  // Find the array in parent
@@ -280,7 +392,15 @@ export function parseYaml(content: string): Record<string, unknown> {
280
392
  const arrays = Object.entries(parent).filter(([, v]) => Array.isArray(v));
281
393
  if (arrays.length > 0) {
282
394
  const [, arr] = arrays[arrays.length - 1];
283
- (arr as unknown[]).push(stripQuotes(stripInlineComment(afterDash.trim())));
395
+ const itemValue = stripInlineComment(afterDash.trim());
396
+ const header = parseBlockHeader(itemValue);
397
+ if (header) {
398
+ const block = parseBlockScalar(i + 1, indent, header.style, header.chomp);
399
+ (arr as unknown[]).push(block.value);
400
+ i = block.endLine;
401
+ } else {
402
+ (arr as unknown[]).push(stripQuotes(itemValue));
403
+ }
284
404
  }
285
405
  }
286
406
  continue;
@@ -312,7 +432,14 @@ export function parseYaml(content: string): Record<string, unknown> {
312
432
  const arrContent = value.slice(1, -1);
313
433
  parent[key] = arrContent.split(",").map((s) => stripQuotes(s.trim()));
314
434
  } else {
315
- parent[key] = parseValue(value);
435
+ const header = parseBlockHeader(value);
436
+ if (header) {
437
+ const block = parseBlockScalar(i + 1, indent, header.style, header.chomp);
438
+ parent[key] = block.value;
439
+ i = block.endLine;
440
+ } else {
441
+ parent[key] = parseValue(value);
442
+ }
316
443
  }
317
444
  }
318
445
  }
@@ -447,6 +574,374 @@ export function parseFrontmatter(content: string): {
447
574
  return { frontmatter, body };
448
575
  }
449
576
 
577
+ export function mergeFrontmatterWithBody(originalContent: string, body: string): string {
578
+ const normalized = originalContent.replace(/^\uFEFF/, "").replaceAll("\r\n", "\n");
579
+ const lines = normalized.split("\n");
580
+
581
+ let i = 0;
582
+ while (i < lines.length && lines[i].trim() === "") i += 1;
583
+ if (i >= lines.length || lines[i].trim() !== "---") {
584
+ return body;
585
+ }
586
+
587
+ i += 1;
588
+ while (i < lines.length && lines[i].trim() !== "---") {
589
+ i += 1;
590
+ }
591
+ if (i >= lines.length) return body;
592
+ i += 1; // consume closing ---
593
+
594
+ const prefix = lines.slice(0, i).join("\n");
595
+ return `${prefix}\n${body}`;
596
+ }
597
+
598
+ export function normalizeProfileTags(value: unknown): string[] {
599
+ if (Array.isArray(value)) {
600
+ return value
601
+ .filter((entry): entry is string => typeof entry === "string")
602
+ .map((entry) => entry.trim().toLowerCase())
603
+ .filter((entry) => entry.length > 0);
604
+ }
605
+
606
+ if (typeof value !== "string") return [];
607
+ const raw = value.trim();
608
+ if (!raw) return [];
609
+
610
+ if (raw.startsWith("[") && raw.endsWith("]")) {
611
+ const inner = raw.slice(1, -1).trim();
612
+ if (!inner) return [];
613
+ return inner
614
+ .split(",")
615
+ .map((entry) => stripQuotes(entry.trim()).toLowerCase())
616
+ .filter((entry) => entry.length > 0);
617
+ }
618
+
619
+ return [stripQuotes(raw).toLowerCase()].filter((entry) => entry.length > 0);
620
+ }
621
+
622
+ function normalizePathForMatch(pathValue: string): string {
623
+ return pathValue
624
+ .replaceAll("\\", "/")
625
+ .replace(/^\.\/+/, "")
626
+ .replace(/^\/+/, "");
627
+ }
628
+
629
+ export function pagePathForData(siteRoot: string, docroot: string, filePath: string): string {
630
+ const relFromDocroot = normalizePathForMatch(relative(resolve(siteRoot, docroot), filePath));
631
+ if (relFromDocroot && !relFromDocroot.startsWith("../")) return relFromDocroot;
632
+ return normalizePathForMatch(relative(siteRoot, filePath));
633
+ }
634
+
635
+ function toStringRecord(raw: unknown): Record<string, string> {
636
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
637
+ const result: Record<string, string> = {};
638
+ for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
639
+ if (typeof value === "string") result[key] = value;
640
+ else if (typeof value === "number" || typeof value === "boolean") result[key] = String(value);
641
+ }
642
+ return result;
643
+ }
644
+
645
+ function toSnippetArray(raw: unknown): DataSnippet[] {
646
+ if (!Array.isArray(raw)) return [];
647
+ return raw
648
+ .map((item) => {
649
+ if (!item || typeof item !== "object") return null;
650
+ const entry = item as Record<string, unknown>;
651
+ if (typeof entry.slot !== "string" || typeof entry.content !== "string") return null;
652
+ return { slot: entry.slot, content: entry.content };
653
+ })
654
+ .filter((item): item is DataSnippet => !!item);
655
+ }
656
+
657
+ function validateSchemaNode(
658
+ value: unknown,
659
+ schema: unknown,
660
+ pathLabel: string,
661
+ dataPath: string,
662
+ ): void {
663
+ if (!schema || typeof schema !== "object") return;
664
+ const schemaObj = schema as Record<string, unknown>;
665
+ const type = typeof schemaObj.type === "string" ? schemaObj.type : undefined;
666
+
667
+ if (type) {
668
+ const valid =
669
+ (type === "object" && value !== null && typeof value === "object" && !Array.isArray(value)) ||
670
+ (type === "array" && Array.isArray(value)) ||
671
+ (type === "string" && typeof value === "string") ||
672
+ (type === "number" && typeof value === "number") ||
673
+ (type === "boolean" && typeof value === "boolean");
674
+ if (!valid) {
675
+ throw new Error(`schema validation failed at ${pathLabel} in ${dataPath}: expected ${type}`);
676
+ }
677
+ }
678
+
679
+ if (type === "string" && typeof value === "string" && typeof schemaObj.pattern === "string") {
680
+ const re = new RegExp(schemaObj.pattern);
681
+ if (!re.test(value)) {
682
+ throw new Error(
683
+ `schema validation failed at ${pathLabel} in ${dataPath}: value does not match pattern`,
684
+ );
685
+ }
686
+ }
687
+
688
+ if (type === "object" && value && typeof value === "object" && !Array.isArray(value)) {
689
+ const obj = value as Record<string, unknown>;
690
+ const required = Array.isArray(schemaObj.required)
691
+ ? schemaObj.required.filter((key): key is string => typeof key === "string")
692
+ : [];
693
+ for (const key of required) {
694
+ if (!(key in obj)) {
695
+ throw new Error(`schema validation failed at ${pathLabel}.${key} in ${dataPath}: required`);
696
+ }
697
+ }
698
+ if (schemaObj.properties && typeof schemaObj.properties === "object") {
699
+ for (const [key, subSchema] of Object.entries(
700
+ schemaObj.properties as Record<string, unknown>,
701
+ )) {
702
+ if (key in obj) {
703
+ validateSchemaNode(obj[key], subSchema, `${pathLabel}.${key}`, dataPath);
704
+ }
705
+ }
706
+ }
707
+ }
708
+
709
+ if (type === "array" && Array.isArray(value) && schemaObj.items) {
710
+ for (const [idx, item] of value.entries()) {
711
+ validateSchemaNode(item, schemaObj.items, `${pathLabel}[${idx}]`, dataPath);
712
+ }
713
+ }
714
+ }
715
+
716
+ async function maybeValidateDataSchema(resolvedDataPath: string, parsed: unknown): Promise<void> {
717
+ const schemaPath = resolvedDataPath.replace(/\.(ya?ml|json)$/i, ".schema.json");
718
+ if (!(await exists(schemaPath))) return;
719
+
720
+ let schema: unknown;
721
+ try {
722
+ schema = JSON.parse(await readFile(schemaPath, "utf-8"));
723
+ } catch (error) {
724
+ const message = error instanceof Error ? error.message : String(error);
725
+ throw new Error(`schema validation failed: invalid schema JSON ${schemaPath} (${message})`);
726
+ }
727
+
728
+ validateSchemaNode(parsed, schema, "$", normalizePathForMatch(schemaPath));
729
+ }
730
+
731
+ function parseNumeric(value: string, formatter: string, key: string, filePath: string): number {
732
+ const n = Number(value);
733
+ if (Number.isNaN(n)) {
734
+ throw new Error(
735
+ `${formatter} formatter: "${value}" is not a number (key "${key}" in ${filePath})`,
736
+ );
737
+ }
738
+ return n;
739
+ }
740
+
741
+ function applyFormatter(formatter: string, value: string, key: string, filePath: string): string {
742
+ const round = formatter.match(/^round\((\d+)\)$/);
743
+ if (round) {
744
+ const n = parseNumeric(value, formatter, key, filePath);
745
+ return n.toFixed(parseInt(round[1], 10));
746
+ }
747
+
748
+ switch (formatter) {
749
+ case "dollar": {
750
+ const n = parseNumeric(value, formatter, key, filePath);
751
+ return Number.isInteger(n)
752
+ ? `$${n.toLocaleString("en-US")}`
753
+ : `$${n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
754
+ }
755
+ case "number":
756
+ return parseNumeric(value, formatter, key, filePath).toLocaleString("en-US");
757
+ case "percent":
758
+ return `${parseNumeric(value, formatter, key, filePath) * 100}%`;
759
+ case "upper":
760
+ return value.toUpperCase();
761
+ case "lower":
762
+ return value.toLowerCase();
763
+ default:
764
+ throw new Error(`unknown formatter "${formatter}" in ${filePath}`);
765
+ }
766
+ }
767
+
768
+ export async function loadDataBindings(
769
+ dataPath: string,
770
+ pagePath: string,
771
+ siteRoot: string,
772
+ docroot = ".",
773
+ dataroot = "data",
774
+ ): Promise<DataBindings> {
775
+ const siteRootReal = await realpath(siteRoot);
776
+ const normalizedDataPath = normalizePathForMatch(dataPath);
777
+ const dataDir = validatePath(siteRoot, ".", dataroot);
778
+ const resolved = validatePath(siteRoot, ".", normalizedDataPath);
779
+ if (!dataDir || !resolved) throw new Error(`data path escapes kitsite: ${dataPath}`);
780
+ if (!resolved.startsWith(`${dataDir}${sep}`) && resolved !== dataDir) {
781
+ throw new Error(`data path escapes dataroot: ${dataPath}`);
782
+ }
783
+ if (!(await exists(resolved))) {
784
+ throw new Error(`data file not found: ${dataPath}`);
785
+ }
786
+ const dataDirReal = await realpath(dataDir);
787
+ const resolvedReal = await realpath(resolved);
788
+ if (!dataDirReal.startsWith(`${siteRootReal}${sep}`) && dataDirReal !== siteRootReal) {
789
+ throw new Error(`data path escapes kitsite: ${dataPath}`);
790
+ }
791
+ if (!resolvedReal.startsWith(`${siteRootReal}${sep}`) && resolvedReal !== siteRootReal) {
792
+ throw new Error(`data path escapes kitsite: ${dataPath}`);
793
+ }
794
+ if (!resolvedReal.startsWith(`${dataDirReal}${sep}`) && resolvedReal !== dataDirReal) {
795
+ throw new Error(`data path escapes dataroot: ${dataPath}`);
796
+ }
797
+
798
+ const raw = await readFile(resolved, "utf-8");
799
+ const parsed = normalizedDataPath.endsWith(".json")
800
+ ? JSON.parse(raw)
801
+ : (parseYaml(raw) as Record<string, unknown>);
802
+ await maybeValidateDataSchema(resolved, parsed);
803
+
804
+ const doc = parsed as Record<string, unknown>;
805
+ const globals = toStringRecord(doc.globals);
806
+ const normalizedPagePath = normalizePathForMatch(pagePath);
807
+ const rootRelativePagePath = normalizePathForMatch(
808
+ relative(siteRoot, resolve(siteRoot, docroot, normalizedPagePath)),
809
+ );
810
+ const pages = Array.isArray(doc.pages) ? doc.pages : [];
811
+ const pageEntry = pages.find((entry) => {
812
+ if (!entry || typeof entry !== "object") return false;
813
+ const pathValue = normalizePathForMatch((entry as Record<string, unknown>).path as string);
814
+ return pathValue === normalizedPagePath || pathValue === rootRelativePagePath;
815
+ }) as Record<string, unknown> | undefined;
816
+
817
+ return {
818
+ globals,
819
+ inject: pageEntry ? toStringRecord(pageEntry.inject) : {},
820
+ snippets: pageEntry ? toSnippetArray(pageEntry.snippets) : [],
821
+ };
822
+ }
823
+
824
+ export function resolveBindings(content: string, bindings: DataBindings, filePath: string): string {
825
+ const values = { ...bindings.globals, ...bindings.inject };
826
+
827
+ const resolvedSnippets = content.replace(
828
+ /\{\{\s*snippet:([A-Za-z0-9][\w-]*)\s*\}\}/g,
829
+ (_match, slot: string) => {
830
+ const snippet = bindings.snippets.find((entry) => entry.slot === slot);
831
+ if (!snippet) throw new Error(`unknown snippet "${slot}" in ${filePath}`);
832
+ return snippet.content;
833
+ },
834
+ );
835
+
836
+ return resolvedSnippets.replace(
837
+ /\{\{\s*([A-Za-z0-9][\w-]*)(\s*(?:\|[^}]+)?)\s*\}\}/g,
838
+ (_match, key: string, rawPipeline: string) => {
839
+ const value = values[key];
840
+ if (value === undefined) throw new Error(`unresolved binding "${key}" in ${filePath}`);
841
+ const steps = rawPipeline
842
+ .split("|")
843
+ .map((part) => part.trim())
844
+ .filter(Boolean);
845
+ return steps.reduce((acc, step) => applyFormatter(step, acc, key, filePath), value);
846
+ },
847
+ );
848
+ }
849
+
850
+ function commandForPlatform(command: string): string[] {
851
+ if (process.platform === "win32") return ["cmd", "/d", "/s", "/c", command];
852
+ return ["sh", "-lc", command];
853
+ }
854
+
855
+ export async function runPrebuildHooks(
856
+ hooks: PrebuildHook[],
857
+ siteRoot: string,
858
+ buildMode: "dev" | "build" | "bundle",
859
+ profile?: string,
860
+ dataroot = "data",
861
+ changedPath?: string,
862
+ ): Promise<number> {
863
+ if (!hooks.length) return 0;
864
+ const normalizedChangedPath = changedPath ? normalizePathForMatch(changedPath) : undefined;
865
+ const filteredHooks = normalizedChangedPath
866
+ ? hooks.filter((hook) =>
867
+ Array.isArray(hook.watch)
868
+ ? hook.watch.some((pattern) => globMatch(pattern, normalizedChangedPath))
869
+ : false,
870
+ )
871
+ : hooks;
872
+ if (!filteredHooks.length) return 0;
873
+
874
+ const normalizedDataDir = `${normalizePathForMatch(dataroot).replace(/\/+$/, "") || "data"}/`;
875
+ const env = {
876
+ ...process.env,
877
+ KITFLY_SITE_ROOT: siteRoot,
878
+ KITFLY_DATA_DIR: normalizedDataDir,
879
+ KITFLY_BUILD_MODE: buildMode,
880
+ ...(profile ? { KITFLY_PROFILE: profile } : {}),
881
+ };
882
+
883
+ for (const hook of filteredHooks) {
884
+ if (!hook.command) continue;
885
+ const result = Bun.spawnSync(commandForPlatform(hook.command), {
886
+ cwd: siteRoot,
887
+ env,
888
+ stdout: "inherit",
889
+ stderr: "pipe",
890
+ });
891
+ if (result.exitCode !== 0) {
892
+ const stderr = new TextDecoder().decode(result.stderr).trim();
893
+ const detail = stderr ? `\n${stderr}` : "";
894
+ throw new Error(`prebuild hook failed (exit ${result.exitCode}): ${hook.command}${detail}`);
895
+ }
896
+ }
897
+
898
+ return filteredHooks.length;
899
+ }
900
+
901
+ export async function filterByProfile(
902
+ files: ContentFile[],
903
+ activeProfile?: string,
904
+ profileConfig?: Record<string, ProfileConfig>,
905
+ ): Promise<ContentFile[]> {
906
+ const normalizedProfile = activeProfile?.trim().toLowerCase();
907
+ const hasProfilesConfig = !!profileConfig && Object.keys(profileConfig).length > 0;
908
+ if (!normalizedProfile && !hasProfilesConfig) {
909
+ // Backward compatibility: no profiles configured means no filtering at all.
910
+ return files;
911
+ }
912
+
913
+ let allowedTags = normalizedProfile ? [normalizedProfile] : [];
914
+ if (normalizedProfile && profileConfig?.[normalizedProfile]?.include?.tags) {
915
+ allowedTags = normalizeProfileTags(profileConfig[normalizedProfile].include?.tags ?? []);
916
+ }
917
+
918
+ const filtered: ContentFile[] = [];
919
+ for (const file of files) {
920
+ let content = "";
921
+ try {
922
+ content = await readFile(file.path, "utf-8");
923
+ } catch {
924
+ // If a file disappears during watch/build, skip it.
925
+ continue;
926
+ }
927
+ const { frontmatter } = parseFrontmatter(content);
928
+ const tags = normalizeProfileTags(frontmatter.profile);
929
+
930
+ // Untagged content is always included.
931
+ if (tags.length === 0) {
932
+ filtered.push(file);
933
+ continue;
934
+ }
935
+
936
+ // Tagged content is opt-in via active profile match.
937
+ if (normalizedProfile && tags.some((tag) => allowedTags.includes(tag))) {
938
+ filtered.push(file);
939
+ }
940
+ }
941
+
942
+ return filtered;
943
+ }
944
+
450
945
  export function slugify(text: string): string {
451
946
  return text
452
947
  .toLowerCase()
@@ -513,17 +1008,16 @@ const SLIDES_VISUALS_TYPES = new Set([
513
1008
  "staircase",
514
1009
  ]);
515
1010
 
516
- const SLIDES_VISUALS_RULES: Record<
517
- string,
518
- {
519
- required: string[];
520
- scalars: string[];
521
- lists: Record<
522
- string,
523
- { kind: "strings" } | { kind: "objects"; fields: string[]; optional?: string[] }
524
- >;
525
- }
526
- > = {
1011
+ type VisualListRule =
1012
+ | { kind: "strings" }
1013
+ | { kind: "objects"; fields: string[]; optional?: string[] };
1014
+ type VisualRules = {
1015
+ required: string[];
1016
+ scalars: string[];
1017
+ lists: Record<string, VisualListRule>;
1018
+ };
1019
+
1020
+ const SLIDES_VISUALS_RULES: Record<string, VisualRules> = {
527
1021
  kpi: {
528
1022
  required: ["label", "value"],
529
1023
  scalars: ["label", "value", "trend"],
@@ -596,11 +1090,44 @@ const SLIDES_VISUALS_RULES: Record<
596
1090
  },
597
1091
  };
598
1092
 
599
- /**
600
- * Validate slides-visuals `:::` blocks in a single markdown slide body.
601
- * This contract is intentionally strict so writers/devs don’t guess at edge cases.
602
- */
603
- export function validateSlidesVisualsFences(markdown: string): SlidesVisualsFenceDiagnostic[] {
1093
+ const PLANNING_VISUALS_TYPES = new Set(["gantt"]);
1094
+
1095
+ const PLANNING_VISUALS_RULES: Record<string, VisualRules> = {
1096
+ gantt: {
1097
+ required: ["time-unit", "time-start", "time-end", "tracks"],
1098
+ scalars: ["label", "time-unit", "time-start", "time-end", "max-depth", "max-tracks", "today"],
1099
+ lists: {
1100
+ tracks: {
1101
+ kind: "objects",
1102
+ fields: ["label", "depth", "start", "end"],
1103
+ optional: ["status"],
1104
+ },
1105
+ milestones: {
1106
+ kind: "objects",
1107
+ fields: ["label", "date"],
1108
+ optional: ["depth"],
1109
+ },
1110
+ markers: {
1111
+ kind: "objects",
1112
+ fields: ["label", "date"],
1113
+ optional: ["color"],
1114
+ },
1115
+ },
1116
+ },
1117
+ };
1118
+
1119
+ function parseQuotedScalar(raw: string): string {
1120
+ const trimmed = raw.trim();
1121
+ const match = trimmed.match(/^"(.*)"$/) || trimmed.match(/^'(.*)'$/);
1122
+ return match ? match[1] : trimmed;
1123
+ }
1124
+
1125
+ function validateVisualFences(
1126
+ markdown: string,
1127
+ visualTypes: Set<string>,
1128
+ visualRules: Record<string, VisualRules>,
1129
+ unknownTypePrefix: string,
1130
+ ): SlidesVisualsFenceDiagnostic[] {
604
1131
  const diagnostics: SlidesVisualsFenceDiagnostic[] = [];
605
1132
  const lines = markdown.replaceAll("\r\n", "\n").split("\n");
606
1133
 
@@ -618,7 +1145,7 @@ export function validateSlidesVisualsFences(markdown: string): SlidesVisualsFenc
618
1145
  }
619
1146
 
620
1147
  function finishFence(closeLine: number) {
621
- const rules = SLIDES_VISUALS_RULES[visualType];
1148
+ const rules = visualRules[visualType];
622
1149
  if (!rules) return;
623
1150
 
624
1151
  for (const key of rules.required) {
@@ -664,10 +1191,10 @@ export function validateSlidesVisualsFences(markdown: string): SlidesVisualsFenc
664
1191
  const m = raw.match(/^:::\s*([a-z0-9-]+)\s*$/i);
665
1192
  if (!m) continue;
666
1193
  const type = m[1].toLowerCase();
667
- if (!SLIDES_VISUALS_TYPES.has(type)) {
1194
+ if (!visualTypes.has(type)) {
668
1195
  diagnostics.push({
669
1196
  line: i + 1,
670
- message: `Unknown slides-visuals block type: ${type}`,
1197
+ message: `${unknownTypePrefix}${type}`,
671
1198
  type,
672
1199
  });
673
1200
  continue;
@@ -682,7 +1209,6 @@ export function validateSlidesVisualsFences(markdown: string): SlidesVisualsFenc
682
1209
  continue;
683
1210
  }
684
1211
 
685
- // inside visual fence
686
1212
  if (trimmed === ":::" && !raw.startsWith(":::")) {
687
1213
  err(i + 1, "Closing ::: fence must start at column 0");
688
1214
  continue;
@@ -702,13 +1228,12 @@ export function validateSlidesVisualsFences(markdown: string): SlidesVisualsFenc
702
1228
  }
703
1229
 
704
1230
  if (/^\s/.test(raw)) {
705
- // list item or list continuation
706
1231
  if (!currentListKey) {
707
1232
  err(i + 1, "Indented content is only allowed inside a list");
708
1233
  continue;
709
1234
  }
710
1235
 
711
- const listRule = SLIDES_VISUALS_RULES[visualType]?.lists[currentListKey];
1236
+ const listRule = visualRules[visualType]?.lists[currentListKey];
712
1237
  const item = raw.match(/^ {2}-\s+(.+)$/);
713
1238
  if (item) {
714
1239
  listItems += 1;
@@ -739,7 +1264,7 @@ export function validateSlidesVisualsFences(markdown: string): SlidesVisualsFenc
739
1264
  continue;
740
1265
  }
741
1266
 
742
- const rules = SLIDES_VISUALS_RULES[visualType];
1267
+ const rules = visualRules[visualType];
743
1268
  if (!rules) continue;
744
1269
 
745
1270
  const kv = raw.match(/^([a-z][a-z0-9-]*)\s*:\s*(.*)$/i);
@@ -752,7 +1277,6 @@ export function validateSlidesVisualsFences(markdown: string): SlidesVisualsFenc
752
1277
  const value = kv[2];
753
1278
 
754
1279
  if (value === "") {
755
- // list key
756
1280
  const listRule = rules.lists[key];
757
1281
  if (!listRule) {
758
1282
  err(i + 1, `Key '${key}' is not a supported list for ${visualType}`);
@@ -764,7 +1288,6 @@ export function validateSlidesVisualsFences(markdown: string): SlidesVisualsFenc
764
1288
  continue;
765
1289
  }
766
1290
 
767
- // scalar key
768
1291
  if (!rules.scalars.includes(key)) {
769
1292
  err(i + 1, `Key '${key}' is not a supported scalar for ${visualType}`);
770
1293
  continue;
@@ -784,12 +1307,408 @@ export function validateSlidesVisualsFences(markdown: string): SlidesVisualsFenc
784
1307
  return diagnostics;
785
1308
  }
786
1309
 
1310
+ /**
1311
+ * Validate slides-visuals `:::` blocks in a single markdown slide body.
1312
+ * This contract is intentionally strict so writers/devs don’t guess at edge cases.
1313
+ */
1314
+ export function validateSlidesVisualsFences(markdown: string): SlidesVisualsFenceDiagnostic[] {
1315
+ return validateVisualFences(
1316
+ markdown,
1317
+ SLIDES_VISUALS_TYPES,
1318
+ SLIDES_VISUALS_RULES,
1319
+ "Unknown slides-visuals block type: ",
1320
+ );
1321
+ }
1322
+
1323
+ type ParsedPlanningBlock = {
1324
+ startLine: number;
1325
+ data: Record<string, unknown>;
1326
+ scalarLines: Record<string, number>;
1327
+ listLines: Record<string, number>;
1328
+ };
1329
+
1330
+ function parsePlanningGanttBlocks(markdown: string): ParsedPlanningBlock[] {
1331
+ const blocks: ParsedPlanningBlock[] = [];
1332
+ const lines = markdown.replaceAll("\r\n", "\n").split("\n");
1333
+ let mdFence: FenceState | null = null;
1334
+ let current: ParsedPlanningBlock | null = null;
1335
+ let currentList: string | null = null;
1336
+ let currentObject: Record<string, unknown> | null = null;
1337
+
1338
+ for (let i = 0; i < lines.length; i++) {
1339
+ const raw = lines[i];
1340
+ const trimmed = raw.trim();
1341
+ const lineNo = i + 1;
1342
+
1343
+ mdFence = updateFenceState(trimmed, mdFence);
1344
+ if (mdFence) continue;
1345
+
1346
+ if (!current) {
1347
+ const open = raw.match(/^:::\s*([a-z0-9-]+)\s*$/i);
1348
+ if (!open || open[1].toLowerCase() !== "gantt") continue;
1349
+ current = {
1350
+ startLine: lineNo,
1351
+ data: {},
1352
+ scalarLines: {},
1353
+ listLines: {},
1354
+ };
1355
+ currentList = null;
1356
+ currentObject = null;
1357
+ continue;
1358
+ }
1359
+
1360
+ if (raw.match(/^:::\s*$/)) {
1361
+ blocks.push(current);
1362
+ current = null;
1363
+ currentList = null;
1364
+ currentObject = null;
1365
+ continue;
1366
+ }
1367
+
1368
+ const kv = raw.match(/^([a-z][a-z0-9-]*)\s*:\s*(.*)$/i);
1369
+ if (kv) {
1370
+ const key = kv[1].toLowerCase();
1371
+ const value = kv[2];
1372
+ if (value === "") {
1373
+ currentList = key;
1374
+ currentObject = null;
1375
+ current.listLines[key] = lineNo;
1376
+ if (!Array.isArray(current.data[key])) current.data[key] = [];
1377
+ } else {
1378
+ current.data[key] = parseQuotedScalar(value);
1379
+ current.scalarLines[key] = lineNo;
1380
+ currentList = null;
1381
+ currentObject = null;
1382
+ }
1383
+ continue;
1384
+ }
1385
+
1386
+ const item = raw.match(/^ {2}-\s+(.+)$/);
1387
+ if (item && currentList) {
1388
+ const list: unknown[] = Array.isArray(current.data[currentList])
1389
+ ? (current.data[currentList] as unknown[])
1390
+ : [];
1391
+ current.data[currentList] = list;
1392
+ const objKV = item[1].match(/^([a-z][a-z0-9-]*)\s*:\s*(.+)$/i);
1393
+ if (objKV) {
1394
+ currentObject = { [objKV[1].toLowerCase()]: parseQuotedScalar(objKV[2]) };
1395
+ list.push(currentObject);
1396
+ } else {
1397
+ currentObject = null;
1398
+ list.push(parseQuotedScalar(item[1]));
1399
+ }
1400
+ continue;
1401
+ }
1402
+
1403
+ const cont = raw.match(/^ {4}([a-z][a-z0-9-]*)\s*:\s*(.+)$/i);
1404
+ if (cont && currentObject) {
1405
+ currentObject[cont[1].toLowerCase()] = parseQuotedScalar(cont[2]);
1406
+ }
1407
+ }
1408
+
1409
+ return blocks;
1410
+ }
1411
+
1412
+ function isoWeeksInYear(year: number): number {
1413
+ const dec28 = new Date(Date.UTC(year, 11, 28));
1414
+ return getIsoWeekInfo(Math.floor(dec28.getTime() / (24 * 60 * 60 * 1000))).week;
1415
+ }
1416
+
1417
+ function isoWeekMondayUtcMs(year: number, week: number): number {
1418
+ const jan4 = new Date(Date.UTC(year, 0, 4));
1419
+ const jan4Weekday = (jan4.getUTCDay() + 6) % 7; // Monday=0
1420
+ const weekOneMondayMs = jan4.getTime() - jan4Weekday * 24 * 60 * 60 * 1000;
1421
+ return weekOneMondayMs + (week - 1) * 7 * 24 * 60 * 60 * 1000;
1422
+ }
1423
+
1424
+ function parseWeekOrdinal(value: string): number | null {
1425
+ const isoWeekAnchorDay = -3; // 1969-12-29 (Monday of 1970-W01)
1426
+ const match = value.match(/^(\d{4})-W(\d{2})$/i);
1427
+ if (!match) return null;
1428
+ const year = Number.parseInt(match[1], 10);
1429
+ const week = Number.parseInt(match[2], 10);
1430
+ if (week < 1 || week > isoWeeksInYear(year)) return null;
1431
+ const dayMs = 24 * 60 * 60 * 1000;
1432
+ const mondayDayOrdinal = Math.floor(isoWeekMondayUtcMs(year, week) / dayMs);
1433
+ return Math.floor((mondayDayOrdinal - isoWeekAnchorDay) / 7);
1434
+ }
1435
+
1436
+ function parseMonthOrdinal(value: string): number | null {
1437
+ const match = value.match(/^(\d{4})-(\d{2})$/);
1438
+ if (!match) return null;
1439
+ const year = Number.parseInt(match[1], 10);
1440
+ const month = Number.parseInt(match[2], 10);
1441
+ if (month < 1 || month > 12) return null;
1442
+ return year * 12 + (month - 1);
1443
+ }
1444
+
1445
+ function daysInMonthUtc(year: number, month: number): number {
1446
+ return new Date(Date.UTC(year, month, 0)).getUTCDate();
1447
+ }
1448
+
1449
+ function parsePlanningMonthMarkerPosition(value: string): number | null {
1450
+ const monthOrdinal = parseMonthOrdinal(value);
1451
+ if (monthOrdinal != null) return monthOrdinal + 0.5;
1452
+
1453
+ const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
1454
+ if (!match) return null;
1455
+ const year = Number.parseInt(match[1], 10);
1456
+ const month = Number.parseInt(match[2], 10);
1457
+ const day = Number.parseInt(match[3], 10);
1458
+ if (month < 1 || month > 12) return null;
1459
+ const dim = daysInMonthUtc(year, month);
1460
+ if (day < 1 || day > dim) return null;
1461
+ const ordinal = year * 12 + (month - 1);
1462
+ return ordinal + (day - 0.5) / dim;
1463
+ }
1464
+
1465
+ function parsePlanningUnitOrdinal(value: unknown, unit: string): number | null {
1466
+ if (typeof value !== "string" || !value.trim()) return null;
1467
+ if (unit === "week") return parseWeekOrdinal(value.trim());
1468
+ if (unit === "month") return parseMonthOrdinal(value.trim());
1469
+ return null;
1470
+ }
1471
+
1472
+ function parsePlanningMarkerPosition(value: unknown, unit: string): number | null {
1473
+ if (typeof value !== "string" || !value.trim()) return null;
1474
+ const raw = value.trim();
1475
+ if (unit === "week") {
1476
+ const ordinal = parseWeekOrdinal(raw);
1477
+ return ordinal == null ? null : ordinal + 0.5;
1478
+ }
1479
+ if (unit === "month") {
1480
+ return parsePlanningMonthMarkerPosition(raw);
1481
+ }
1482
+ return null;
1483
+ }
1484
+
1485
+ function getIsoWeekInfo(dayOrdinal: number): { year: number; week: number } {
1486
+ const dayMs = 24 * 60 * 60 * 1000;
1487
+ const date = new Date(dayOrdinal * dayMs);
1488
+ const day = (date.getUTCDay() + 6) % 7; // Monday=0
1489
+ const thursday = new Date(date.getTime() + (3 - day) * dayMs);
1490
+ const year = thursday.getUTCFullYear();
1491
+ const firstThursday = new Date(Date.UTC(year, 0, 4));
1492
+ const firstThursdayDay = (firstThursday.getUTCDay() + 6) % 7;
1493
+ const firstThursdayOrdinal = Math.floor(firstThursday.getTime() / dayMs) + (3 - firstThursdayDay);
1494
+ const week = Math.floor((Math.floor(thursday.getTime() / dayMs) - firstThursdayOrdinal) / 7) + 1;
1495
+ return { year, week };
1496
+ }
1497
+
1498
+ export function validatePlanningVisualsFences(markdown: string): SlidesVisualsFenceDiagnostic[] {
1499
+ const diagnostics = validateVisualFences(
1500
+ markdown,
1501
+ PLANNING_VISUALS_TYPES,
1502
+ PLANNING_VISUALS_RULES,
1503
+ "Unknown planning-visuals block type: ",
1504
+ );
1505
+ const ganttBlocks = parsePlanningGanttBlocks(markdown);
1506
+
1507
+ for (const block of ganttBlocks) {
1508
+ const unitRaw = block.data["time-unit"];
1509
+ const unit = typeof unitRaw === "string" ? unitRaw.trim().toLowerCase() : "";
1510
+ const unitLine = block.scalarLines["time-unit"] ?? block.startLine;
1511
+ const startLine = block.scalarLines["time-start"] ?? block.startLine;
1512
+ const endLine = block.scalarLines["time-end"] ?? block.startLine;
1513
+ const tracksLine = block.listLines.tracks ?? block.startLine;
1514
+ const milestonesLine = block.listLines.milestones ?? block.startLine;
1515
+
1516
+ if (unit !== "week" && unit !== "month") {
1517
+ diagnostics.push({
1518
+ line: unitLine,
1519
+ message: "Invalid time-unit (expected 'week' or 'month')",
1520
+ type: "gantt",
1521
+ });
1522
+ continue;
1523
+ }
1524
+
1525
+ const axisStart = parsePlanningUnitOrdinal(block.data["time-start"], unit);
1526
+ const axisEnd = parsePlanningUnitOrdinal(block.data["time-end"], unit);
1527
+ if (axisStart == null) {
1528
+ diagnostics.push({
1529
+ line: startLine,
1530
+ message: `Invalid time-start format for ${unit}`,
1531
+ type: "gantt",
1532
+ });
1533
+ }
1534
+ if (axisEnd == null) {
1535
+ diagnostics.push({
1536
+ line: endLine,
1537
+ message: `Invalid time-end format for ${unit}`,
1538
+ type: "gantt",
1539
+ });
1540
+ }
1541
+ if (axisStart != null && axisEnd != null && axisStart >= axisEnd) {
1542
+ diagnostics.push({
1543
+ line: endLine,
1544
+ message: "time-start must be before time-end",
1545
+ type: "gantt",
1546
+ });
1547
+ }
1548
+
1549
+ const todayRaw = block.data.today;
1550
+ if (todayRaw != null && parsePlanningUnitOrdinal(todayRaw, unit) == null) {
1551
+ diagnostics.push({
1552
+ line: block.scalarLines.today ?? block.startLine,
1553
+ message: `Invalid today format for ${unit}`,
1554
+ type: "gantt",
1555
+ });
1556
+ }
1557
+
1558
+ const tracks = Array.isArray(block.data.tracks) ? block.data.tracks : [];
1559
+ for (const track of tracks) {
1560
+ if (!track || typeof track !== "object") {
1561
+ diagnostics.push({
1562
+ line: tracksLine,
1563
+ message: "Track items must be objects",
1564
+ type: "gantt",
1565
+ });
1566
+ continue;
1567
+ }
1568
+ const start = parsePlanningUnitOrdinal((track as Record<string, unknown>).start, unit);
1569
+ const end = parsePlanningUnitOrdinal((track as Record<string, unknown>).end, unit);
1570
+ if (start == null || end == null) {
1571
+ diagnostics.push({
1572
+ line: tracksLine,
1573
+ message: `Track start/end must match ${unit} format`,
1574
+ type: "gantt",
1575
+ });
1576
+ continue;
1577
+ }
1578
+ if (start > end) {
1579
+ diagnostics.push({
1580
+ line: tracksLine,
1581
+ message: "Track start must be before or equal to end",
1582
+ type: "gantt",
1583
+ });
1584
+ }
1585
+ }
1586
+
1587
+ const milestones = Array.isArray(block.data.milestones) ? block.data.milestones : [];
1588
+ for (const milestone of milestones) {
1589
+ if (!milestone || typeof milestone !== "object") {
1590
+ diagnostics.push({
1591
+ line: milestonesLine,
1592
+ message: "Milestone items must be objects",
1593
+ type: "gantt",
1594
+ });
1595
+ continue;
1596
+ }
1597
+ const date = parsePlanningUnitOrdinal((milestone as Record<string, unknown>).date, unit);
1598
+ if (date == null) {
1599
+ diagnostics.push({
1600
+ line: milestonesLine,
1601
+ message: `Milestone date must match ${unit} format`,
1602
+ type: "gantt",
1603
+ });
1604
+ }
1605
+ }
1606
+
1607
+ const markersLine = block.listLines.markers ?? block.startLine;
1608
+ const markers = Array.isArray(block.data.markers) ? block.data.markers : [];
1609
+ for (const marker of markers) {
1610
+ if (!marker || typeof marker !== "object") {
1611
+ diagnostics.push({
1612
+ line: markersLine,
1613
+ message: "Marker items must be objects",
1614
+ type: "gantt",
1615
+ });
1616
+ continue;
1617
+ }
1618
+ const date = parsePlanningMarkerPosition((marker as Record<string, unknown>).date, unit);
1619
+ if (date == null) {
1620
+ const expected =
1621
+ unit === "month" ? "month format (YYYY-MM or YYYY-MM-DD)" : "week format (YYYY-Www)";
1622
+ diagnostics.push({
1623
+ line: markersLine,
1624
+ message: `Marker date must match ${expected}`,
1625
+ type: "gantt",
1626
+ });
1627
+ }
1628
+ }
1629
+ }
1630
+
1631
+ return diagnostics;
1632
+ }
1633
+
1634
+ export function collectPlanningVisualsContainmentWarnings(
1635
+ markdown: string,
1636
+ ): SlidesVisualsFenceDiagnostic[] {
1637
+ const warnings: SlidesVisualsFenceDiagnostic[] = [];
1638
+ const ganttBlocks = parsePlanningGanttBlocks(markdown);
1639
+
1640
+ for (const block of ganttBlocks) {
1641
+ const unitRaw = block.data["time-unit"];
1642
+ const unit = typeof unitRaw === "string" ? unitRaw.trim().toLowerCase() : "";
1643
+ if (unit !== "week" && unit !== "month") continue;
1644
+
1645
+ const axisStart = parsePlanningUnitOrdinal(block.data["time-start"], unit);
1646
+ const axisEnd = parsePlanningUnitOrdinal(block.data["time-end"], unit);
1647
+ if (axisStart == null || axisEnd == null || axisStart >= axisEnd) continue;
1648
+
1649
+ const tracksLine = block.listLines.tracks ?? block.startLine;
1650
+ const milestonesLine = block.listLines.milestones ?? block.startLine;
1651
+
1652
+ const tracks = Array.isArray(block.data.tracks) ? block.data.tracks : [];
1653
+ for (const track of tracks) {
1654
+ if (!track || typeof track !== "object") continue;
1655
+ const start = parsePlanningUnitOrdinal((track as Record<string, unknown>).start, unit);
1656
+ const end = parsePlanningUnitOrdinal((track as Record<string, unknown>).end, unit);
1657
+ if (start == null || end == null) continue;
1658
+ if (start < axisStart || end > axisEnd) {
1659
+ warnings.push({
1660
+ line: tracksLine,
1661
+ message: "Track range is outside axis and will be clipped",
1662
+ type: "gantt",
1663
+ });
1664
+ }
1665
+ }
1666
+
1667
+ const milestones = Array.isArray(block.data.milestones) ? block.data.milestones : [];
1668
+ for (const milestone of milestones) {
1669
+ if (!milestone || typeof milestone !== "object") continue;
1670
+ const date = parsePlanningUnitOrdinal((milestone as Record<string, unknown>).date, unit);
1671
+ if (date == null) continue;
1672
+ if (date < axisStart || date > axisEnd) {
1673
+ warnings.push({
1674
+ line: milestonesLine,
1675
+ message: "Milestone date is outside axis and will not be rendered",
1676
+ type: "gantt",
1677
+ });
1678
+ }
1679
+ }
1680
+
1681
+ const markersLine = block.listLines.markers ?? block.startLine;
1682
+ const markers = Array.isArray(block.data.markers) ? block.data.markers : [];
1683
+ for (const marker of markers) {
1684
+ if (!marker || typeof marker !== "object") continue;
1685
+ const position = parsePlanningMarkerPosition((marker as Record<string, unknown>).date, unit);
1686
+ if (position == null) continue;
1687
+ if (position < axisStart || position > axisEnd + 1) {
1688
+ warnings.push({
1689
+ line: markersLine,
1690
+ message: "Marker date is outside axis and will not be rendered",
1691
+ type: "gantt",
1692
+ });
1693
+ }
1694
+ }
1695
+ }
1696
+
1697
+ return warnings;
1698
+ }
1699
+
787
1700
  export function filterUnknownSlidesVisualsTypeDiagnostics(
788
1701
  diagnostics: SlidesVisualsFenceDiagnostic[],
789
1702
  ): SlidesVisualsFenceDiagnostic[] {
790
1703
  return diagnostics.filter((d) => !d.message.startsWith("Unknown slides-visuals block type:"));
791
1704
  }
792
1705
 
1706
+ export function filterUnknownPlanningVisualsTypeDiagnostics(
1707
+ diagnostics: SlidesVisualsFenceDiagnostic[],
1708
+ ): SlidesVisualsFenceDiagnostic[] {
1709
+ return diagnostics.filter((d) => !d.message.startsWith("Unknown planning-visuals block type:"));
1710
+ }
1711
+
793
1712
  /**
794
1713
  * Split markdown content into slide chunks using explicit delimiter.
795
1714
  * Delimiter lines inside fenced code blocks are ignored.
@@ -884,7 +1803,10 @@ export function segmentSlides(content: string, fallbackTitle: string): SlideSegm
884
1803
  * Collect slide content objects from discovered content files.
885
1804
  * Markdown files can produce multiple slides via explicit delimiters.
886
1805
  */
887
- export async function collectSlides(files: ContentFile[]): Promise<SlideContent[]> {
1806
+ export async function collectSlides(
1807
+ files: ContentFile[],
1808
+ options: CollectSlidesOptions = {},
1809
+ ): Promise<SlideContent[]> {
888
1810
  const slides: SlideContent[] = [];
889
1811
  let index = 0;
890
1812
 
@@ -893,7 +1815,8 @@ export async function collectSlides(files: ContentFile[]): Promise<SlideContent[
893
1815
  const stem = basename(file.path, extname(file.path));
894
1816
 
895
1817
  if (file.path.endsWith(".md")) {
896
- const segments = segmentSlides(raw, stem);
1818
+ const markdown = options.markdownTransform ? await options.markdownTransform(raw, file) : raw;
1819
+ const segments = segmentSlides(markdown, stem);
897
1820
  for (const segment of segments) {
898
1821
  index += 1;
899
1822
  slides.push({
@@ -926,7 +1849,82 @@ export async function collectSlides(files: ContentFile[]): Promise<SlideContent[
926
1849
  return slides;
927
1850
  }
928
1851
 
929
- export function buildSlideNav(
1852
+ interface SlideNavGroup {
1853
+ name: string;
1854
+ groups: Map<string, SlideNavGroup>;
1855
+ slides: SlideContent[];
1856
+ }
1857
+
1858
+ function sectionRelativePath(sourceUrlPath: string, sectionBase: string): string {
1859
+ if (!sectionBase) return sourceUrlPath;
1860
+ if (sourceUrlPath === sectionBase) return "";
1861
+ if (sourceUrlPath.startsWith(`${sectionBase}/`))
1862
+ return sourceUrlPath.slice(sectionBase.length + 1);
1863
+ return sourceUrlPath;
1864
+ }
1865
+
1866
+ function toTitleCaseSlug(segment: string): string {
1867
+ return segment
1868
+ .split(/[-_]/)
1869
+ .filter(Boolean)
1870
+ .map((token) => token.charAt(0).toUpperCase() + token.slice(1))
1871
+ .join(" ");
1872
+ }
1873
+
1874
+ function createSlideNavGroup(name: string): SlideNavGroup {
1875
+ return { name, groups: new Map(), slides: [] };
1876
+ }
1877
+
1878
+ function buildSlideSectionTree(items: SlideContent[], sectionBase: string): SlideNavGroup {
1879
+ const root = createSlideNavGroup("");
1880
+ for (const slide of items) {
1881
+ const rel = sectionRelativePath(slide.sourceUrlPath, sectionBase);
1882
+ const segments = rel.split("/").filter(Boolean);
1883
+ segments.pop(); // Drop file stem so nav groups only reflect subfolders.
1884
+
1885
+ let node = root;
1886
+ for (const segment of segments) {
1887
+ let next = node.groups.get(segment);
1888
+ if (!next) {
1889
+ next = createSlideNavGroup(toTitleCaseSlug(segment));
1890
+ node.groups.set(segment, next);
1891
+ }
1892
+ node = next;
1893
+ }
1894
+ node.slides.push(slide);
1895
+ }
1896
+ return root;
1897
+ }
1898
+
1899
+ function slideGroupContains(group: SlideNavGroup, currentSlideId: string | undefined): boolean {
1900
+ if (!currentSlideId) return false;
1901
+ if (group.slides.some((slide) => slide.id === currentSlideId)) return true;
1902
+ for (const child of group.groups.values()) {
1903
+ if (slideGroupContains(child, currentSlideId)) return true;
1904
+ }
1905
+ return false;
1906
+ }
1907
+
1908
+ function renderSlideGroup(group: SlideNavGroup, currentSlideId?: string): string {
1909
+ let html = "<ul>";
1910
+
1911
+ for (const slide of group.slides) {
1912
+ const active = currentSlideId === slide.id ? ' class="active"' : "";
1913
+ html += `<li><a href="#${slide.id}"${active}>${escapeHtml(slide.title)}</a></li>`;
1914
+ }
1915
+
1916
+ for (const child of group.groups.values()) {
1917
+ const open = slideGroupContains(child, currentSlideId) ? " open" : "";
1918
+ html += `<li><details${open}><summary class="nav-group">${escapeHtml(child.name)}</summary>`;
1919
+ html += renderSlideGroup(child, currentSlideId);
1920
+ html += "</details></li>";
1921
+ }
1922
+
1923
+ html += "</ul>";
1924
+ return html;
1925
+ }
1926
+
1927
+ export function buildSlideNavHierarchical(
930
1928
  slides: SlideContent[],
931
1929
  config: SiteConfig,
932
1930
  currentSlideId?: string,
@@ -941,17 +1939,25 @@ export function buildSlideNav(
941
1939
  for (const section of config.sections) {
942
1940
  const items = grouped.get(section.name);
943
1941
  if (!items || items.length === 0) continue;
944
- html += `<li><span class="nav-section">${escapeHtml(section.name)}</span><ul>`;
945
- for (const slide of items) {
946
- const active = currentSlideId === slide.id ? ' class="active"' : "";
947
- html += `<li><a href="#${slide.id}"${active}>${escapeHtml(slide.title)}</a></li>`;
948
- }
949
- html += "</ul></li>";
1942
+ const sectionBase = section.path.replace(/^\/+|\/+$/g, "");
1943
+ const tree = buildSlideSectionTree(items, sectionBase);
1944
+ html += `<li><span class="nav-section">${escapeHtml(section.name)}</span>`;
1945
+ html += renderSlideGroup(tree, currentSlideId);
1946
+ html += "</li>";
950
1947
  }
951
1948
  html += "</ul>";
952
1949
  return html;
953
1950
  }
954
1951
 
1952
+ // Backwards-compatible alias.
1953
+ export function buildSlideNav(
1954
+ slides: SlideContent[],
1955
+ config: SiteConfig,
1956
+ currentSlideId?: string,
1957
+ ): string {
1958
+ return buildSlideNavHierarchical(slides, config, currentSlideId);
1959
+ }
1960
+
955
1961
  function resolveRelativeContentPath(pathOrRef: string, currentUrlPath?: string): string {
956
1962
  let cleaned = pathOrRef;
957
1963
  if (currentUrlPath && !cleaned.startsWith("/")) {
@@ -1478,10 +2484,68 @@ export function buildPageMeta(frontmatter: Record<string, unknown>): string {
1478
2484
  return `<div class="page-meta">Last updated: ${formatted}</div>`;
1479
2485
  }
1480
2486
 
2487
+ interface LogoImgHtmlOptions {
2488
+ logo: string;
2489
+ logoDark?: string;
2490
+ alt: string;
2491
+ className?: string;
2492
+ pathPrefix?: string;
2493
+ onerrorFallback?: boolean;
2494
+ style?: string;
2495
+ }
2496
+
2497
+ export function buildLogoImgHtml(options: LogoImgHtmlOptions): string {
2498
+ const className = options.className || "logo-img";
2499
+ const pathPrefix = options.pathPrefix || "";
2500
+ const onerror = options.onerrorFallback
2501
+ ? `onerror="this.onerror=null;this.style.display='none';this.parentElement.classList.add('logo-fallback')"`
2502
+ : `onerror="this.onerror=null;this.style.display='none'"`;
2503
+ const style = options.style ? `style="${escapeHtml(options.style)}"` : "";
2504
+ const lightSrc = `${pathPrefix}${options.logo}`;
2505
+ const alt = escapeHtml(options.alt);
2506
+
2507
+ if (!options.logoDark) {
2508
+ return `<img src="${escapeHtml(lightSrc)}" alt="${alt}" class="${className}" ${style} ${onerror}>`;
2509
+ }
2510
+
2511
+ const darkSrc = `${pathPrefix}${options.logoDark}`;
2512
+ return `<img src="${escapeHtml(lightSrc)}" alt="${alt}" class="${className} logo-light" ${style} ${onerror}>
2513
+ <img src="${escapeHtml(darkSrc)}" alt="${alt}" class="${className} logo-dark" ${style} onerror="this.onerror=null;this.style.display='none'">`;
2514
+ }
2515
+
1481
2516
  /**
1482
2517
  * Build footer HTML from provenance
1483
2518
  */
1484
- export function buildFooter(provenance: Provenance, config: SiteConfig): string {
2519
+ function renderFooterLogo(
2520
+ footer: SiteFooter,
2521
+ config: SiteConfig,
2522
+ pathPrefix: string,
2523
+ logoOverride?: string,
2524
+ logoDarkOverride?: string,
2525
+ ): string {
2526
+ const footerLogo = logoOverride || footer.logo;
2527
+ if (!footerLogo) return "";
2528
+
2529
+ const altText = footer.logoAlt || footer.copyright || config.brand.name;
2530
+ const logoHeight = footer.logoHeight ?? 20;
2531
+ const logoDark = logoDarkOverride || footer.logoDark;
2532
+ const image = buildLogoImgHtml({
2533
+ logo: footerLogo,
2534
+ logoDark,
2535
+ alt: altText,
2536
+ className: "footer-logo-img",
2537
+ pathPrefix: logoOverride ? "" : pathPrefix,
2538
+ onerrorFallback: false,
2539
+ style: `max-height: ${logoHeight}px`,
2540
+ });
2541
+ const wrapped = footer.logoUrl
2542
+ ? `<a href="${escapeHtml(footer.logoUrl)}" class="footer-logo-link">${image}</a>`
2543
+ : `<span class="footer-logo-link">${image}</span>`;
2544
+
2545
+ return `${wrapped}<span class="footer-separator">·</span>`;
2546
+ }
2547
+
2548
+ export function buildFooter(provenance: Provenance, config: SiteConfig, pathPrefix = ""): string {
1485
2549
  const commitDate = formatDate(provenance.gitCommitDate);
1486
2550
  const publishYear = Number.isNaN(new Date(provenance.gitCommitDate).getTime())
1487
2551
  ? new Date().getFullYear().toString()
@@ -1510,11 +2574,13 @@ export function buildFooter(provenance: Provenance, config: SiteConfig): string
1510
2574
  ? `<span class="footer-version">v${escapeHtml(provenance.version)}</span>
1511
2575
  <span class="footer-separator">·</span>`
1512
2576
  : "";
2577
+ const footerLogoHtml = renderFooterLogo(footer, config, pathPrefix);
1513
2578
 
1514
2579
  return `
1515
2580
  <footer class="site-footer">
1516
2581
  <div class="footer-content">
1517
2582
  <div class="footer-left">
2583
+ ${footerLogoHtml}
1518
2584
  ${versionHtml}
1519
2585
  <span class="footer-commit" title="Commit: ${escapeHtml(provenance.gitCommit)}">Published ${commitDate}</span>
1520
2586
  </div>
@@ -1536,7 +2602,12 @@ export function buildFooter(provenance: Provenance, config: SiteConfig): string
1536
2602
  /**
1537
2603
  * Build bundle footer HTML.
1538
2604
  */
1539
- export function buildBundleFooter(version: string | undefined, config: SiteConfig): string {
2605
+ export function buildBundleFooter(
2606
+ version: string | undefined,
2607
+ config: SiteConfig,
2608
+ logoOverride?: string,
2609
+ logoDarkOverride?: string,
2610
+ ): string {
1540
2611
  const footer = config.footer || {};
1541
2612
  const copyrightText = footer.copyright
1542
2613
  ? escapeHtml(footer.copyright)
@@ -1561,11 +2632,13 @@ export function buildBundleFooter(version: string | undefined, config: SiteConfi
1561
2632
  ? `<span class="footer-version">v${escapeHtml(version)}</span>
1562
2633
  <span class="footer-separator">·</span>`
1563
2634
  : "";
2635
+ const footerLogoHtml = renderFooterLogo(footer, config, "", logoOverride, logoDarkOverride);
1564
2636
 
1565
2637
  return `
1566
2638
  <footer class="site-footer">
1567
2639
  <div class="footer-content">
1568
2640
  <div class="footer-left">
2641
+ ${footerLogoHtml}
1569
2642
  ${versionHtml}
1570
2643
  <span class="footer-commit">Published (offline bundle)</span>
1571
2644
  </div>
@@ -1763,6 +2836,7 @@ function normalizeFooter(footer: unknown): SiteFooter | undefined {
1763
2836
  if (!footer || typeof footer !== "object") return undefined;
1764
2837
  const raw = footer as Record<string, unknown>;
1765
2838
  let links: FooterLink[] | undefined;
2839
+ let logoHeight: number | undefined;
1766
2840
 
1767
2841
  if (Array.isArray(raw.links)) {
1768
2842
  links = raw.links
@@ -1779,15 +2853,66 @@ function normalizeFooter(footer: unknown): SiteFooter | undefined {
1779
2853
  console.warn("⚠ site.yaml footer.links supports at most 10 links; truncating extras.");
1780
2854
  }
1781
2855
  }
2856
+ const parsedLogoHeight =
2857
+ typeof raw.logoHeight === "number"
2858
+ ? raw.logoHeight
2859
+ : typeof raw.logoHeight === "string"
2860
+ ? Number.parseInt(raw.logoHeight, 10)
2861
+ : NaN;
2862
+ if (Number.isInteger(parsedLogoHeight)) {
2863
+ logoHeight = Math.max(10, Math.min(40, parsedLogoHeight));
2864
+ }
1782
2865
 
1783
2866
  return {
1784
2867
  copyright: typeof raw.copyright === "string" ? raw.copyright : undefined,
1785
2868
  copyrightUrl: typeof raw.copyrightUrl === "string" ? raw.copyrightUrl : undefined,
1786
2869
  links,
1787
2870
  attribution: typeof raw.attribution === "boolean" ? raw.attribution : undefined,
2871
+ logo: typeof raw.logo === "string" ? raw.logo : undefined,
2872
+ logoDark: typeof raw.logoDark === "string" ? raw.logoDark : undefined,
2873
+ logoUrl: typeof raw.logoUrl === "string" ? raw.logoUrl : undefined,
2874
+ logoAlt: typeof raw.logoAlt === "string" ? raw.logoAlt : undefined,
2875
+ logoHeight,
1788
2876
  };
1789
2877
  }
1790
2878
 
2879
+ function normalizeProfiles(raw: unknown): Record<string, ProfileConfig> | undefined {
2880
+ if (!raw || typeof raw !== "object") return undefined;
2881
+ const entries = Object.entries(raw as Record<string, unknown>);
2882
+ if (entries.length === 0) return undefined;
2883
+
2884
+ const profiles: Record<string, ProfileConfig> = {};
2885
+ for (const [name, value] of entries) {
2886
+ if (!value || typeof value !== "object") continue;
2887
+ const profileRaw = value as Record<string, unknown>;
2888
+ const tags = normalizeProfileTags(
2889
+ (profileRaw.include as Record<string, unknown> | undefined)?.tags,
2890
+ );
2891
+ profiles[name.trim().toLowerCase()] = {
2892
+ description: typeof profileRaw.description === "string" ? profileRaw.description : undefined,
2893
+ include: tags.length > 0 ? { tags } : undefined,
2894
+ };
2895
+ }
2896
+
2897
+ return Object.keys(profiles).length > 0 ? profiles : undefined;
2898
+ }
2899
+
2900
+ function normalizePrebuild(raw: unknown): PrebuildHook[] | undefined {
2901
+ if (!Array.isArray(raw)) return undefined;
2902
+ const hooks = raw
2903
+ .map((entry) => {
2904
+ if (!entry || typeof entry !== "object") return null;
2905
+ const record = entry as Record<string, unknown>;
2906
+ if (typeof record.command !== "string" || !record.command.trim()) return null;
2907
+ const watch = Array.isArray(record.watch)
2908
+ ? record.watch.filter((item): item is string => typeof item === "string" && !!item.trim())
2909
+ : undefined;
2910
+ return { command: record.command.trim(), watch } as PrebuildHook;
2911
+ })
2912
+ .filter((hook): hook is PrebuildHook => !!hook);
2913
+ return hooks.length > 0 ? hooks : undefined;
2914
+ }
2915
+
1791
2916
  /**
1792
2917
  * Load site configuration with fallback chain
1793
2918
  * @param root - The root directory
@@ -1811,6 +2936,7 @@ export async function loadSiteConfig(
1811
2936
 
1812
2937
  return {
1813
2938
  docroot: parsed.docroot || ".",
2939
+ dataroot: typeof parsedRecord.dataroot === "string" ? parsedRecord.dataroot : "data",
1814
2940
  title: parsed.title,
1815
2941
  version: typeof parsedRecord.version === "string" ? parsedRecord.version : undefined,
1816
2942
  mode: parsedRecord.mode === "slides" ? "slides" : "docs",
@@ -1825,12 +2951,15 @@ export async function loadSiteConfig(
1825
2951
  brand: {
1826
2952
  ...parsed.brand,
1827
2953
  logo: parsed.brand.logo || "assets/brand/logo.png",
2954
+ logoDark: typeof parsed.brand.logoDark === "string" ? parsed.brand.logoDark : undefined,
1828
2955
  favicon: parsed.brand.favicon || "assets/brand/favicon.png",
1829
2956
  logoType: parsed.brand.logoType || "icon",
1830
2957
  },
1831
2958
  sections: parsed.sections,
1832
2959
  footer: normalizeFooter(parsedRecord.footer),
1833
2960
  server: parsed.server,
2961
+ profiles: normalizeProfiles(parsedRecord.profiles),
2962
+ prebuild: normalizePrebuild(parsedRecord.prebuild),
1834
2963
  };
1835
2964
  } catch (e) {
1836
2965
  if ((e as NodeJS.ErrnoException).code !== "ENOENT") {
@@ -1859,6 +2988,7 @@ export async function loadSiteConfig(
1859
2988
  if (sections.length > 0) {
1860
2989
  return {
1861
2990
  docroot: "content",
2991
+ dataroot: "data",
1862
2992
  title: "Documentation",
1863
2993
  mode: "docs",
1864
2994
  aspect: "16/9",
@@ -1873,6 +3003,7 @@ export async function loadSiteConfig(
1873
3003
  // Final fallback
1874
3004
  return {
1875
3005
  docroot: ".",
3006
+ dataroot: "data",
1876
3007
  title: defaultTitle,
1877
3008
  mode: "docs",
1878
3009
  aspect: "16/9",