kitfly 0.2.0 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +25 -10
  3. package/VERSION +1 -1
  4. package/dist/_raw/content/guide/branding.md +146 -0
  5. package/dist/_raw/content/guide/data-driven-content.md +204 -0
  6. package/dist/_raw/content/reference/configuration.md +145 -7
  7. package/dist/_raw/content/reference/environment-variables.md +26 -1
  8. package/dist/_raw/content/reference/glossary.md +25 -1
  9. package/dist/_raw/content/reference/key-concepts.md +30 -2
  10. package/dist/_raw/content/reference/plugins.md +14 -0
  11. package/dist/_raw/content/reference/slides-authoring-guidelines.md +129 -0
  12. package/dist/_raw/content/reference.md +1 -0
  13. package/dist/_raw/docs/decisions/ADR-0006-data-driven-content.md +350 -0
  14. package/dist/content/deployment/preflight.html +10 -6
  15. package/dist/content/deployment/recipes/aws-s3.html +10 -6
  16. package/dist/content/deployment/recipes/cloudflare-pages.html +10 -6
  17. package/dist/content/deployment/recipes/cloudflare-r2.html +10 -6
  18. package/dist/content/deployment/recipes/fly-io.html +10 -6
  19. package/dist/content/deployment/recipes/github-pages.html +10 -6
  20. package/dist/content/deployment/recipes/netlify.html +10 -6
  21. package/dist/content/deployment/recipes/vercel.html +10 -6
  22. package/dist/content/deployment/secrets-and-env-vars.html +10 -6
  23. package/dist/content/deployment.html +10 -6
  24. package/dist/content/guide/approaches.html +10 -6
  25. package/dist/content/guide/branding.html +510 -0
  26. package/dist/content/guide/data-driven-content.html +543 -0
  27. package/dist/content/guide/features.html +10 -6
  28. package/dist/content/guide/getting-started.html +10 -6
  29. package/dist/content/guide/kitfly-overview.html +10 -6
  30. package/dist/content/reference/configuration.html +135 -9
  31. package/dist/content/reference/design-catalog.html +10 -6
  32. package/dist/content/reference/environment-variables.html +50 -8
  33. package/dist/content/reference/glossary.html +24 -8
  34. package/dist/content/reference/key-concepts.html +33 -9
  35. package/dist/content/reference/plugins.html +22 -7
  36. package/dist/content/reference/slides-authoring-guidelines.html +422 -0
  37. package/dist/content/reference/structure.html +10 -6
  38. package/dist/content/reference.html +11 -6
  39. package/dist/content/templates/crucible.html +10 -6
  40. package/dist/content/templates/handbook.html +10 -6
  41. package/dist/content/templates/minimal.html +10 -6
  42. package/dist/content/templates/overview.html +10 -6
  43. package/dist/content/templates/pipeline.html +10 -6
  44. package/dist/content/templates/productbook.html +10 -6
  45. package/dist/content/templates/runbook.html +10 -6
  46. package/dist/content/templates/servicebook.html +10 -6
  47. package/dist/content-index.json +38 -2
  48. package/dist/docs/decisions/ADR-0001-minimalist-site-code.html +10 -6
  49. package/dist/docs/decisions/ADR-0002-ai-accessibility.html +10 -6
  50. package/dist/docs/decisions/ADR-0003-single-file-bundle.html +10 -6
  51. package/dist/docs/decisions/ADR-0004-bun-runtime.html +10 -6
  52. package/dist/docs/decisions/ADR-0005-plugin-contract-and-distribution.html +10 -6
  53. package/dist/docs/decisions/ADR-0006-data-driven-content.html +752 -0
  54. package/dist/docs/decisions/DDR-0001-viewport-locked-layout.html +10 -6
  55. package/dist/docs/decisions/DDR-0002-theme-system.html +10 -6
  56. package/dist/docs/decisions/DDR-0003-bounded-logo-slot.html +10 -6
  57. package/dist/docs/decisions/DDR-0004-slides-rendering-model.html +10 -6
  58. package/dist/docs/decisions/DDR-0005-deterministic-layout-boundary.html +10 -6
  59. package/dist/docs/userguide/cli/build.html +10 -6
  60. package/dist/docs/userguide/cli/bundle.html +10 -6
  61. package/dist/docs/userguide/cli/dev.html +10 -6
  62. package/dist/docs/userguide/cli/init.html +10 -6
  63. package/dist/docs/userguide/cli/servers.html +10 -6
  64. package/dist/docs/userguide/cli/stop.html +10 -6
  65. package/dist/docs/userguide/cli/update.html +10 -6
  66. package/dist/docs/userguide/cli/version.html +10 -6
  67. package/dist/docs/userguide/cli.html +10 -6
  68. package/dist/docs/userguide/sharing.html +10 -6
  69. package/dist/index.html +10 -6
  70. package/dist/llms.txt +3 -3
  71. package/dist/provenance.json +4 -4
  72. package/dist/schemas/plugin-registry.schema.html +10 -6
  73. package/dist/schemas/plugin-schemas-notes.html +10 -6
  74. package/dist/schemas/plugin.schema.html +10 -6
  75. package/dist/schemas/plugins.schema.html +10 -6
  76. package/dist/schemas/v0/common.schema.html +14 -10
  77. package/dist/schemas/v0/plugin-registry.schema.html +13 -9
  78. package/dist/schemas/v0/plugin.schema.html +13 -9
  79. package/dist/schemas/v0/plugins.schema.html +13 -9
  80. package/dist/schemas/v0/site.schema.html +67 -7
  81. package/dist/schemas/v0/theme.schema.html +21 -17
  82. package/dist/schemas.html +10 -6
  83. package/dist/styles.css +39 -4
  84. package/package.json +1 -1
  85. package/plugins-dist/latex-runtime.js +140 -0
  86. package/plugins-dist/latex.js +178 -0
  87. package/plugins-dist/slides-charts-lite-runtime.js +179 -0
  88. package/plugins-dist/slides-charts-lite.js +198 -0
  89. package/plugins-dist/slides-visuals.css +166 -0
  90. package/plugins-dist/slides-visuals.js +124 -33
  91. package/registry/plugins.yaml +30 -5
  92. package/schemas/v0/site.schema.json +56 -0
  93. package/scripts/build.ts +195 -70
  94. package/scripts/bundle.ts +122 -11
  95. package/scripts/dev.ts +345 -178
  96. package/src/__tests__/brief.test.ts +151 -0
  97. package/src/__tests__/build.test.ts +234 -4
  98. package/src/__tests__/bundle.test.ts +134 -0
  99. package/src/__tests__/dev-plugin-errors.test.ts +20 -0
  100. package/src/__tests__/fixtures/fences/slides-visuals/invalid/flow-branching-no-source.md +5 -0
  101. package/src/__tests__/fixtures/fences/slides-visuals/invalid/flow-converging-no-target.md +6 -0
  102. package/src/__tests__/fixtures/fences/slides-visuals/invalid/staircase-empty-steps.md +3 -0
  103. package/src/__tests__/fixtures/fences/slides-visuals/invalid/timeline-horizontal-no-events.md +2 -0
  104. package/src/__tests__/fixtures/fences/slides-visuals/valid/flow-branching-no-split.md +7 -0
  105. package/src/__tests__/fixtures/fences/slides-visuals/valid/flow-branching.md +8 -0
  106. package/src/__tests__/fixtures/fences/slides-visuals/valid/flow-converging-no-merge.md +7 -0
  107. package/src/__tests__/fixtures/fences/slides-visuals/valid/flow-converging.md +8 -0
  108. package/src/__tests__/fixtures/fences/slides-visuals/valid/staircase-down.md +7 -0
  109. package/src/__tests__/fixtures/fences/slides-visuals/valid/staircase.md +8 -0
  110. package/src/__tests__/fixtures/fences/slides-visuals/valid/timeline-horizontal.md +9 -0
  111. package/src/__tests__/fixtures/fences/slides-visuals/valid/timeline-vertical.md +10 -0
  112. package/src/__tests__/init.test.ts +51 -2
  113. package/src/__tests__/latex-runtime.bun.test.ts +35 -0
  114. package/src/__tests__/shared.test.ts +621 -1
  115. package/src/__tests__/slides-charts-lite-runtime.bun.test.ts +45 -0
  116. package/src/__tests__/slides-visuals-runtime-regressions.bun.test.ts +33 -0
  117. package/src/cli.ts +11 -4
  118. package/src/commands/init.ts +1 -1
  119. package/src/shared.ts +761 -18
  120. package/src/site/styles.css +39 -4
  121. package/src/site/template.html +5 -2
  122. package/src/templates/brief.ts +486 -0
  123. package/src/templates/deck.ts +59 -0
  124. package/src/templates/driver.ts +46 -13
  125. package/src/templates/handbook.ts +32 -0
  126. 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()
@@ -506,6 +1001,11 @@ const SLIDES_VISUALS_TYPES = new Set([
506
1001
  "layer-cake",
507
1002
  "pyramid",
508
1003
  "funnel",
1004
+ "timeline-horizontal",
1005
+ "timeline-vertical",
1006
+ "flow-branching",
1007
+ "flow-converging",
1008
+ "staircase",
509
1009
  ]);
510
1010
 
511
1011
  const SLIDES_VISUALS_RULES: Record<
@@ -564,6 +1064,31 @@ const SLIDES_VISUALS_RULES: Record<
564
1064
  scalars: [],
565
1065
  lists: { stages: { kind: "strings" } },
566
1066
  },
1067
+ "timeline-horizontal": {
1068
+ required: ["events"],
1069
+ scalars: [],
1070
+ lists: { events: { kind: "objects", fields: ["label"], optional: ["date"] } },
1071
+ },
1072
+ "timeline-vertical": {
1073
+ required: ["events"],
1074
+ scalars: [],
1075
+ lists: { events: { kind: "objects", fields: ["label"], optional: ["date"] } },
1076
+ },
1077
+ "flow-branching": {
1078
+ required: ["source", "branches"],
1079
+ scalars: ["source", "split"],
1080
+ lists: { branches: { kind: "strings" } },
1081
+ },
1082
+ "flow-converging": {
1083
+ required: ["sources", "target"],
1084
+ scalars: ["target", "merge"],
1085
+ lists: { sources: { kind: "strings" } },
1086
+ },
1087
+ staircase: {
1088
+ required: ["steps"],
1089
+ scalars: ["direction"],
1090
+ lists: { steps: { kind: "strings" } },
1091
+ },
567
1092
  };
568
1093
 
569
1094
  /**
@@ -754,6 +1279,12 @@ export function validateSlidesVisualsFences(markdown: string): SlidesVisualsFenc
754
1279
  return diagnostics;
755
1280
  }
756
1281
 
1282
+ export function filterUnknownSlidesVisualsTypeDiagnostics(
1283
+ diagnostics: SlidesVisualsFenceDiagnostic[],
1284
+ ): SlidesVisualsFenceDiagnostic[] {
1285
+ return diagnostics.filter((d) => !d.message.startsWith("Unknown slides-visuals block type:"));
1286
+ }
1287
+
757
1288
  /**
758
1289
  * Split markdown content into slide chunks using explicit delimiter.
759
1290
  * Delimiter lines inside fenced code blocks are ignored.
@@ -848,7 +1379,10 @@ export function segmentSlides(content: string, fallbackTitle: string): SlideSegm
848
1379
  * Collect slide content objects from discovered content files.
849
1380
  * Markdown files can produce multiple slides via explicit delimiters.
850
1381
  */
851
- export async function collectSlides(files: ContentFile[]): Promise<SlideContent[]> {
1382
+ export async function collectSlides(
1383
+ files: ContentFile[],
1384
+ options: CollectSlidesOptions = {},
1385
+ ): Promise<SlideContent[]> {
852
1386
  const slides: SlideContent[] = [];
853
1387
  let index = 0;
854
1388
 
@@ -857,7 +1391,8 @@ export async function collectSlides(files: ContentFile[]): Promise<SlideContent[
857
1391
  const stem = basename(file.path, extname(file.path));
858
1392
 
859
1393
  if (file.path.endsWith(".md")) {
860
- const segments = segmentSlides(raw, stem);
1394
+ const markdown = options.markdownTransform ? await options.markdownTransform(raw, file) : raw;
1395
+ const segments = segmentSlides(markdown, stem);
861
1396
  for (const segment of segments) {
862
1397
  index += 1;
863
1398
  slides.push({
@@ -890,7 +1425,82 @@ export async function collectSlides(files: ContentFile[]): Promise<SlideContent[
890
1425
  return slides;
891
1426
  }
892
1427
 
893
- export function buildSlideNav(
1428
+ interface SlideNavGroup {
1429
+ name: string;
1430
+ groups: Map<string, SlideNavGroup>;
1431
+ slides: SlideContent[];
1432
+ }
1433
+
1434
+ function sectionRelativePath(sourceUrlPath: string, sectionBase: string): string {
1435
+ if (!sectionBase) return sourceUrlPath;
1436
+ if (sourceUrlPath === sectionBase) return "";
1437
+ if (sourceUrlPath.startsWith(`${sectionBase}/`))
1438
+ return sourceUrlPath.slice(sectionBase.length + 1);
1439
+ return sourceUrlPath;
1440
+ }
1441
+
1442
+ function toTitleCaseSlug(segment: string): string {
1443
+ return segment
1444
+ .split(/[-_]/)
1445
+ .filter(Boolean)
1446
+ .map((token) => token.charAt(0).toUpperCase() + token.slice(1))
1447
+ .join(" ");
1448
+ }
1449
+
1450
+ function createSlideNavGroup(name: string): SlideNavGroup {
1451
+ return { name, groups: new Map(), slides: [] };
1452
+ }
1453
+
1454
+ function buildSlideSectionTree(items: SlideContent[], sectionBase: string): SlideNavGroup {
1455
+ const root = createSlideNavGroup("");
1456
+ for (const slide of items) {
1457
+ const rel = sectionRelativePath(slide.sourceUrlPath, sectionBase);
1458
+ const segments = rel.split("/").filter(Boolean);
1459
+ segments.pop(); // Drop file stem so nav groups only reflect subfolders.
1460
+
1461
+ let node = root;
1462
+ for (const segment of segments) {
1463
+ let next = node.groups.get(segment);
1464
+ if (!next) {
1465
+ next = createSlideNavGroup(toTitleCaseSlug(segment));
1466
+ node.groups.set(segment, next);
1467
+ }
1468
+ node = next;
1469
+ }
1470
+ node.slides.push(slide);
1471
+ }
1472
+ return root;
1473
+ }
1474
+
1475
+ function slideGroupContains(group: SlideNavGroup, currentSlideId: string | undefined): boolean {
1476
+ if (!currentSlideId) return false;
1477
+ if (group.slides.some((slide) => slide.id === currentSlideId)) return true;
1478
+ for (const child of group.groups.values()) {
1479
+ if (slideGroupContains(child, currentSlideId)) return true;
1480
+ }
1481
+ return false;
1482
+ }
1483
+
1484
+ function renderSlideGroup(group: SlideNavGroup, currentSlideId?: string): string {
1485
+ let html = "<ul>";
1486
+
1487
+ for (const slide of group.slides) {
1488
+ const active = currentSlideId === slide.id ? ' class="active"' : "";
1489
+ html += `<li><a href="#${slide.id}"${active}>${escapeHtml(slide.title)}</a></li>`;
1490
+ }
1491
+
1492
+ for (const child of group.groups.values()) {
1493
+ const open = slideGroupContains(child, currentSlideId) ? " open" : "";
1494
+ html += `<li><details${open}><summary class="nav-group">${escapeHtml(child.name)}</summary>`;
1495
+ html += renderSlideGroup(child, currentSlideId);
1496
+ html += "</details></li>";
1497
+ }
1498
+
1499
+ html += "</ul>";
1500
+ return html;
1501
+ }
1502
+
1503
+ export function buildSlideNavHierarchical(
894
1504
  slides: SlideContent[],
895
1505
  config: SiteConfig,
896
1506
  currentSlideId?: string,
@@ -905,17 +1515,25 @@ export function buildSlideNav(
905
1515
  for (const section of config.sections) {
906
1516
  const items = grouped.get(section.name);
907
1517
  if (!items || items.length === 0) continue;
908
- html += `<li><span class="nav-section">${escapeHtml(section.name)}</span><ul>`;
909
- for (const slide of items) {
910
- const active = currentSlideId === slide.id ? ' class="active"' : "";
911
- html += `<li><a href="#${slide.id}"${active}>${escapeHtml(slide.title)}</a></li>`;
912
- }
913
- html += "</ul></li>";
1518
+ const sectionBase = section.path.replace(/^\/+|\/+$/g, "");
1519
+ const tree = buildSlideSectionTree(items, sectionBase);
1520
+ html += `<li><span class="nav-section">${escapeHtml(section.name)}</span>`;
1521
+ html += renderSlideGroup(tree, currentSlideId);
1522
+ html += "</li>";
914
1523
  }
915
1524
  html += "</ul>";
916
1525
  return html;
917
1526
  }
918
1527
 
1528
+ // Backwards-compatible alias.
1529
+ export function buildSlideNav(
1530
+ slides: SlideContent[],
1531
+ config: SiteConfig,
1532
+ currentSlideId?: string,
1533
+ ): string {
1534
+ return buildSlideNavHierarchical(slides, config, currentSlideId);
1535
+ }
1536
+
919
1537
  function resolveRelativeContentPath(pathOrRef: string, currentUrlPath?: string): string {
920
1538
  let cleaned = pathOrRef;
921
1539
  if (currentUrlPath && !cleaned.startsWith("/")) {
@@ -1442,10 +2060,68 @@ export function buildPageMeta(frontmatter: Record<string, unknown>): string {
1442
2060
  return `<div class="page-meta">Last updated: ${formatted}</div>`;
1443
2061
  }
1444
2062
 
2063
+ interface LogoImgHtmlOptions {
2064
+ logo: string;
2065
+ logoDark?: string;
2066
+ alt: string;
2067
+ className?: string;
2068
+ pathPrefix?: string;
2069
+ onerrorFallback?: boolean;
2070
+ style?: string;
2071
+ }
2072
+
2073
+ export function buildLogoImgHtml(options: LogoImgHtmlOptions): string {
2074
+ const className = options.className || "logo-img";
2075
+ const pathPrefix = options.pathPrefix || "";
2076
+ const onerror = options.onerrorFallback
2077
+ ? `onerror="this.onerror=null;this.style.display='none';this.parentElement.classList.add('logo-fallback')"`
2078
+ : `onerror="this.onerror=null;this.style.display='none'"`;
2079
+ const style = options.style ? `style="${escapeHtml(options.style)}"` : "";
2080
+ const lightSrc = `${pathPrefix}${options.logo}`;
2081
+ const alt = escapeHtml(options.alt);
2082
+
2083
+ if (!options.logoDark) {
2084
+ return `<img src="${escapeHtml(lightSrc)}" alt="${alt}" class="${className}" ${style} ${onerror}>`;
2085
+ }
2086
+
2087
+ const darkSrc = `${pathPrefix}${options.logoDark}`;
2088
+ return `<img src="${escapeHtml(lightSrc)}" alt="${alt}" class="${className} logo-light" ${style} ${onerror}>
2089
+ <img src="${escapeHtml(darkSrc)}" alt="${alt}" class="${className} logo-dark" ${style} onerror="this.onerror=null;this.style.display='none'">`;
2090
+ }
2091
+
1445
2092
  /**
1446
2093
  * Build footer HTML from provenance
1447
2094
  */
1448
- export function buildFooter(provenance: Provenance, config: SiteConfig): string {
2095
+ function renderFooterLogo(
2096
+ footer: SiteFooter,
2097
+ config: SiteConfig,
2098
+ pathPrefix: string,
2099
+ logoOverride?: string,
2100
+ logoDarkOverride?: string,
2101
+ ): string {
2102
+ const footerLogo = logoOverride || footer.logo;
2103
+ if (!footerLogo) return "";
2104
+
2105
+ const altText = footer.logoAlt || footer.copyright || config.brand.name;
2106
+ const logoHeight = footer.logoHeight ?? 20;
2107
+ const logoDark = logoDarkOverride || footer.logoDark;
2108
+ const image = buildLogoImgHtml({
2109
+ logo: footerLogo,
2110
+ logoDark,
2111
+ alt: altText,
2112
+ className: "footer-logo-img",
2113
+ pathPrefix: logoOverride ? "" : pathPrefix,
2114
+ onerrorFallback: false,
2115
+ style: `max-height: ${logoHeight}px`,
2116
+ });
2117
+ const wrapped = footer.logoUrl
2118
+ ? `<a href="${escapeHtml(footer.logoUrl)}" class="footer-logo-link">${image}</a>`
2119
+ : `<span class="footer-logo-link">${image}</span>`;
2120
+
2121
+ return `${wrapped}<span class="footer-separator">·</span>`;
2122
+ }
2123
+
2124
+ export function buildFooter(provenance: Provenance, config: SiteConfig, pathPrefix = ""): string {
1449
2125
  const commitDate = formatDate(provenance.gitCommitDate);
1450
2126
  const publishYear = Number.isNaN(new Date(provenance.gitCommitDate).getTime())
1451
2127
  ? new Date().getFullYear().toString()
@@ -1474,11 +2150,13 @@ export function buildFooter(provenance: Provenance, config: SiteConfig): string
1474
2150
  ? `<span class="footer-version">v${escapeHtml(provenance.version)}</span>
1475
2151
  <span class="footer-separator">·</span>`
1476
2152
  : "";
2153
+ const footerLogoHtml = renderFooterLogo(footer, config, pathPrefix);
1477
2154
 
1478
2155
  return `
1479
2156
  <footer class="site-footer">
1480
2157
  <div class="footer-content">
1481
2158
  <div class="footer-left">
2159
+ ${footerLogoHtml}
1482
2160
  ${versionHtml}
1483
2161
  <span class="footer-commit" title="Commit: ${escapeHtml(provenance.gitCommit)}">Published ${commitDate}</span>
1484
2162
  </div>
@@ -1500,7 +2178,12 @@ export function buildFooter(provenance: Provenance, config: SiteConfig): string
1500
2178
  /**
1501
2179
  * Build bundle footer HTML.
1502
2180
  */
1503
- export function buildBundleFooter(version: string | undefined, config: SiteConfig): string {
2181
+ export function buildBundleFooter(
2182
+ version: string | undefined,
2183
+ config: SiteConfig,
2184
+ logoOverride?: string,
2185
+ logoDarkOverride?: string,
2186
+ ): string {
1504
2187
  const footer = config.footer || {};
1505
2188
  const copyrightText = footer.copyright
1506
2189
  ? escapeHtml(footer.copyright)
@@ -1525,11 +2208,13 @@ export function buildBundleFooter(version: string | undefined, config: SiteConfi
1525
2208
  ? `<span class="footer-version">v${escapeHtml(version)}</span>
1526
2209
  <span class="footer-separator">·</span>`
1527
2210
  : "";
2211
+ const footerLogoHtml = renderFooterLogo(footer, config, "", logoOverride, logoDarkOverride);
1528
2212
 
1529
2213
  return `
1530
2214
  <footer class="site-footer">
1531
2215
  <div class="footer-content">
1532
2216
  <div class="footer-left">
2217
+ ${footerLogoHtml}
1533
2218
  ${versionHtml}
1534
2219
  <span class="footer-commit">Published (offline bundle)</span>
1535
2220
  </div>
@@ -1727,6 +2412,7 @@ function normalizeFooter(footer: unknown): SiteFooter | undefined {
1727
2412
  if (!footer || typeof footer !== "object") return undefined;
1728
2413
  const raw = footer as Record<string, unknown>;
1729
2414
  let links: FooterLink[] | undefined;
2415
+ let logoHeight: number | undefined;
1730
2416
 
1731
2417
  if (Array.isArray(raw.links)) {
1732
2418
  links = raw.links
@@ -1743,15 +2429,66 @@ function normalizeFooter(footer: unknown): SiteFooter | undefined {
1743
2429
  console.warn("⚠ site.yaml footer.links supports at most 10 links; truncating extras.");
1744
2430
  }
1745
2431
  }
2432
+ const parsedLogoHeight =
2433
+ typeof raw.logoHeight === "number"
2434
+ ? raw.logoHeight
2435
+ : typeof raw.logoHeight === "string"
2436
+ ? Number.parseInt(raw.logoHeight, 10)
2437
+ : NaN;
2438
+ if (Number.isInteger(parsedLogoHeight)) {
2439
+ logoHeight = Math.max(10, Math.min(40, parsedLogoHeight));
2440
+ }
1746
2441
 
1747
2442
  return {
1748
2443
  copyright: typeof raw.copyright === "string" ? raw.copyright : undefined,
1749
2444
  copyrightUrl: typeof raw.copyrightUrl === "string" ? raw.copyrightUrl : undefined,
1750
2445
  links,
1751
2446
  attribution: typeof raw.attribution === "boolean" ? raw.attribution : undefined,
2447
+ logo: typeof raw.logo === "string" ? raw.logo : undefined,
2448
+ logoDark: typeof raw.logoDark === "string" ? raw.logoDark : undefined,
2449
+ logoUrl: typeof raw.logoUrl === "string" ? raw.logoUrl : undefined,
2450
+ logoAlt: typeof raw.logoAlt === "string" ? raw.logoAlt : undefined,
2451
+ logoHeight,
1752
2452
  };
1753
2453
  }
1754
2454
 
2455
+ function normalizeProfiles(raw: unknown): Record<string, ProfileConfig> | undefined {
2456
+ if (!raw || typeof raw !== "object") return undefined;
2457
+ const entries = Object.entries(raw as Record<string, unknown>);
2458
+ if (entries.length === 0) return undefined;
2459
+
2460
+ const profiles: Record<string, ProfileConfig> = {};
2461
+ for (const [name, value] of entries) {
2462
+ if (!value || typeof value !== "object") continue;
2463
+ const profileRaw = value as Record<string, unknown>;
2464
+ const tags = normalizeProfileTags(
2465
+ (profileRaw.include as Record<string, unknown> | undefined)?.tags,
2466
+ );
2467
+ profiles[name.trim().toLowerCase()] = {
2468
+ description: typeof profileRaw.description === "string" ? profileRaw.description : undefined,
2469
+ include: tags.length > 0 ? { tags } : undefined,
2470
+ };
2471
+ }
2472
+
2473
+ return Object.keys(profiles).length > 0 ? profiles : undefined;
2474
+ }
2475
+
2476
+ function normalizePrebuild(raw: unknown): PrebuildHook[] | undefined {
2477
+ if (!Array.isArray(raw)) return undefined;
2478
+ const hooks = raw
2479
+ .map((entry) => {
2480
+ if (!entry || typeof entry !== "object") return null;
2481
+ const record = entry as Record<string, unknown>;
2482
+ if (typeof record.command !== "string" || !record.command.trim()) return null;
2483
+ const watch = Array.isArray(record.watch)
2484
+ ? record.watch.filter((item): item is string => typeof item === "string" && !!item.trim())
2485
+ : undefined;
2486
+ return { command: record.command.trim(), watch } as PrebuildHook;
2487
+ })
2488
+ .filter((hook): hook is PrebuildHook => !!hook);
2489
+ return hooks.length > 0 ? hooks : undefined;
2490
+ }
2491
+
1755
2492
  /**
1756
2493
  * Load site configuration with fallback chain
1757
2494
  * @param root - The root directory
@@ -1775,6 +2512,7 @@ export async function loadSiteConfig(
1775
2512
 
1776
2513
  return {
1777
2514
  docroot: parsed.docroot || ".",
2515
+ dataroot: typeof parsedRecord.dataroot === "string" ? parsedRecord.dataroot : "data",
1778
2516
  title: parsed.title,
1779
2517
  version: typeof parsedRecord.version === "string" ? parsedRecord.version : undefined,
1780
2518
  mode: parsedRecord.mode === "slides" ? "slides" : "docs",
@@ -1789,12 +2527,15 @@ export async function loadSiteConfig(
1789
2527
  brand: {
1790
2528
  ...parsed.brand,
1791
2529
  logo: parsed.brand.logo || "assets/brand/logo.png",
2530
+ logoDark: typeof parsed.brand.logoDark === "string" ? parsed.brand.logoDark : undefined,
1792
2531
  favicon: parsed.brand.favicon || "assets/brand/favicon.png",
1793
2532
  logoType: parsed.brand.logoType || "icon",
1794
2533
  },
1795
2534
  sections: parsed.sections,
1796
2535
  footer: normalizeFooter(parsedRecord.footer),
1797
2536
  server: parsed.server,
2537
+ profiles: normalizeProfiles(parsedRecord.profiles),
2538
+ prebuild: normalizePrebuild(parsedRecord.prebuild),
1798
2539
  };
1799
2540
  } catch (e) {
1800
2541
  if ((e as NodeJS.ErrnoException).code !== "ENOENT") {
@@ -1823,6 +2564,7 @@ export async function loadSiteConfig(
1823
2564
  if (sections.length > 0) {
1824
2565
  return {
1825
2566
  docroot: "content",
2567
+ dataroot: "data",
1826
2568
  title: "Documentation",
1827
2569
  mode: "docs",
1828
2570
  aspect: "16/9",
@@ -1837,6 +2579,7 @@ export async function loadSiteConfig(
1837
2579
  // Final fallback
1838
2580
  return {
1839
2581
  docroot: ".",
2582
+ dataroot: "data",
1840
2583
  title: defaultTitle,
1841
2584
  mode: "docs",
1842
2585
  aspect: "16/9",