kitfly 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +25 -10
  3. package/VERSION +1 -1
  4. package/dist/_raw/content/guide/branding.md +146 -0
  5. package/dist/_raw/content/guide/data-driven-content.md +204 -0
  6. package/dist/_raw/content/reference/configuration.md +145 -7
  7. package/dist/_raw/content/reference/environment-variables.md +26 -1
  8. package/dist/_raw/content/reference/glossary.md +25 -1
  9. package/dist/_raw/content/reference/key-concepts.md +30 -2
  10. package/dist/_raw/content/reference/plugins.md +14 -0
  11. package/dist/_raw/docs/decisions/ADR-0006-data-driven-content.md +350 -0
  12. package/dist/content/deployment/preflight.html +10 -6
  13. package/dist/content/deployment/recipes/aws-s3.html +10 -6
  14. package/dist/content/deployment/recipes/cloudflare-pages.html +10 -6
  15. package/dist/content/deployment/recipes/cloudflare-r2.html +10 -6
  16. package/dist/content/deployment/recipes/fly-io.html +10 -6
  17. package/dist/content/deployment/recipes/github-pages.html +10 -6
  18. package/dist/content/deployment/recipes/netlify.html +10 -6
  19. package/dist/content/deployment/recipes/vercel.html +10 -6
  20. package/dist/content/deployment/secrets-and-env-vars.html +10 -6
  21. package/dist/content/deployment.html +10 -6
  22. package/dist/content/guide/approaches.html +10 -6
  23. package/dist/content/guide/branding.html +510 -0
  24. package/dist/content/guide/data-driven-content.html +543 -0
  25. package/dist/content/guide/features.html +10 -6
  26. package/dist/content/guide/getting-started.html +10 -6
  27. package/dist/content/guide/kitfly-overview.html +10 -6
  28. package/dist/content/reference/configuration.html +135 -9
  29. package/dist/content/reference/design-catalog.html +10 -6
  30. package/dist/content/reference/environment-variables.html +50 -8
  31. package/dist/content/reference/glossary.html +24 -8
  32. package/dist/content/reference/key-concepts.html +33 -9
  33. package/dist/content/reference/plugins.html +22 -7
  34. package/dist/content/reference/slides-authoring-guidelines.html +10 -6
  35. package/dist/content/reference/structure.html +10 -6
  36. package/dist/content/reference.html +10 -6
  37. package/dist/content/templates/crucible.html +10 -6
  38. package/dist/content/templates/handbook.html +10 -6
  39. package/dist/content/templates/minimal.html +10 -6
  40. package/dist/content/templates/overview.html +10 -6
  41. package/dist/content/templates/pipeline.html +10 -6
  42. package/dist/content/templates/productbook.html +10 -6
  43. package/dist/content/templates/runbook.html +10 -6
  44. package/dist/content/templates/servicebook.html +10 -6
  45. package/dist/content-index.json +29 -2
  46. package/dist/docs/decisions/ADR-0001-minimalist-site-code.html +10 -6
  47. package/dist/docs/decisions/ADR-0002-ai-accessibility.html +10 -6
  48. package/dist/docs/decisions/ADR-0003-single-file-bundle.html +10 -6
  49. package/dist/docs/decisions/ADR-0004-bun-runtime.html +10 -6
  50. package/dist/docs/decisions/ADR-0005-plugin-contract-and-distribution.html +10 -6
  51. package/dist/docs/decisions/ADR-0006-data-driven-content.html +752 -0
  52. package/dist/docs/decisions/DDR-0001-viewport-locked-layout.html +10 -6
  53. package/dist/docs/decisions/DDR-0002-theme-system.html +10 -6
  54. package/dist/docs/decisions/DDR-0003-bounded-logo-slot.html +10 -6
  55. package/dist/docs/decisions/DDR-0004-slides-rendering-model.html +10 -6
  56. package/dist/docs/decisions/DDR-0005-deterministic-layout-boundary.html +10 -6
  57. package/dist/docs/userguide/cli/build.html +10 -6
  58. package/dist/docs/userguide/cli/bundle.html +10 -6
  59. package/dist/docs/userguide/cli/dev.html +10 -6
  60. package/dist/docs/userguide/cli/init.html +10 -6
  61. package/dist/docs/userguide/cli/servers.html +10 -6
  62. package/dist/docs/userguide/cli/stop.html +10 -6
  63. package/dist/docs/userguide/cli/update.html +10 -6
  64. package/dist/docs/userguide/cli/version.html +10 -6
  65. package/dist/docs/userguide/cli.html +10 -6
  66. package/dist/docs/userguide/sharing.html +10 -6
  67. package/dist/index.html +10 -6
  68. package/dist/llms.txt +3 -3
  69. package/dist/provenance.json +4 -4
  70. package/dist/schemas/plugin-registry.schema.html +10 -6
  71. package/dist/schemas/plugin-schemas-notes.html +10 -6
  72. package/dist/schemas/plugin.schema.html +10 -6
  73. package/dist/schemas/plugins.schema.html +10 -6
  74. package/dist/schemas/v0/common.schema.html +14 -10
  75. package/dist/schemas/v0/plugin-registry.schema.html +13 -9
  76. package/dist/schemas/v0/plugin.schema.html +13 -9
  77. package/dist/schemas/v0/plugins.schema.html +13 -9
  78. package/dist/schemas/v0/site.schema.html +67 -7
  79. package/dist/schemas/v0/theme.schema.html +21 -17
  80. package/dist/schemas.html +10 -6
  81. package/dist/styles.css +39 -4
  82. package/package.json +1 -1
  83. package/plugins-dist/latex-runtime.js +140 -0
  84. package/plugins-dist/latex.js +178 -0
  85. package/plugins-dist/slides-charts-lite-runtime.js +179 -0
  86. package/plugins-dist/slides-charts-lite.js +198 -0
  87. package/registry/plugins.yaml +25 -0
  88. package/schemas/v0/site.schema.json +56 -0
  89. package/scripts/build.ts +191 -69
  90. package/scripts/bundle.ts +118 -10
  91. package/scripts/dev.ts +245 -166
  92. package/src/__tests__/brief.test.ts +151 -0
  93. package/src/__tests__/build.test.ts +169 -1
  94. package/src/__tests__/bundle.test.ts +134 -0
  95. package/src/__tests__/init.test.ts +51 -2
  96. package/src/__tests__/latex-runtime.bun.test.ts +35 -0
  97. package/src/__tests__/shared.test.ts +598 -1
  98. package/src/__tests__/slides-charts-lite-runtime.bun.test.ts +45 -0
  99. package/src/cli.ts +11 -4
  100. package/src/commands/init.ts +1 -1
  101. package/src/shared.ts +725 -18
  102. package/src/site/styles.css +39 -4
  103. package/src/site/template.html +5 -2
  104. package/src/templates/brief.ts +486 -0
  105. package/src/templates/deck.ts +59 -0
  106. package/src/templates/driver.ts +46 -13
  107. package/src/templates/handbook.ts +32 -0
  108. package/src/templates/runbook.ts +32 -0
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()
@@ -884,7 +1379,10 @@ export function segmentSlides(content: string, fallbackTitle: string): SlideSegm
884
1379
  * Collect slide content objects from discovered content files.
885
1380
  * Markdown files can produce multiple slides via explicit delimiters.
886
1381
  */
887
- export async function collectSlides(files: ContentFile[]): Promise<SlideContent[]> {
1382
+ export async function collectSlides(
1383
+ files: ContentFile[],
1384
+ options: CollectSlidesOptions = {},
1385
+ ): Promise<SlideContent[]> {
888
1386
  const slides: SlideContent[] = [];
889
1387
  let index = 0;
890
1388
 
@@ -893,7 +1391,8 @@ export async function collectSlides(files: ContentFile[]): Promise<SlideContent[
893
1391
  const stem = basename(file.path, extname(file.path));
894
1392
 
895
1393
  if (file.path.endsWith(".md")) {
896
- const segments = segmentSlides(raw, stem);
1394
+ const markdown = options.markdownTransform ? await options.markdownTransform(raw, file) : raw;
1395
+ const segments = segmentSlides(markdown, stem);
897
1396
  for (const segment of segments) {
898
1397
  index += 1;
899
1398
  slides.push({
@@ -926,7 +1425,82 @@ export async function collectSlides(files: ContentFile[]): Promise<SlideContent[
926
1425
  return slides;
927
1426
  }
928
1427
 
929
- 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(
930
1504
  slides: SlideContent[],
931
1505
  config: SiteConfig,
932
1506
  currentSlideId?: string,
@@ -941,17 +1515,25 @@ export function buildSlideNav(
941
1515
  for (const section of config.sections) {
942
1516
  const items = grouped.get(section.name);
943
1517
  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>";
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>";
950
1523
  }
951
1524
  html += "</ul>";
952
1525
  return html;
953
1526
  }
954
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
+
955
1537
  function resolveRelativeContentPath(pathOrRef: string, currentUrlPath?: string): string {
956
1538
  let cleaned = pathOrRef;
957
1539
  if (currentUrlPath && !cleaned.startsWith("/")) {
@@ -1478,10 +2060,68 @@ export function buildPageMeta(frontmatter: Record<string, unknown>): string {
1478
2060
  return `<div class="page-meta">Last updated: ${formatted}</div>`;
1479
2061
  }
1480
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
+
1481
2092
  /**
1482
2093
  * Build footer HTML from provenance
1483
2094
  */
1484
- 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 {
1485
2125
  const commitDate = formatDate(provenance.gitCommitDate);
1486
2126
  const publishYear = Number.isNaN(new Date(provenance.gitCommitDate).getTime())
1487
2127
  ? new Date().getFullYear().toString()
@@ -1510,11 +2150,13 @@ export function buildFooter(provenance: Provenance, config: SiteConfig): string
1510
2150
  ? `<span class="footer-version">v${escapeHtml(provenance.version)}</span>
1511
2151
  <span class="footer-separator">·</span>`
1512
2152
  : "";
2153
+ const footerLogoHtml = renderFooterLogo(footer, config, pathPrefix);
1513
2154
 
1514
2155
  return `
1515
2156
  <footer class="site-footer">
1516
2157
  <div class="footer-content">
1517
2158
  <div class="footer-left">
2159
+ ${footerLogoHtml}
1518
2160
  ${versionHtml}
1519
2161
  <span class="footer-commit" title="Commit: ${escapeHtml(provenance.gitCommit)}">Published ${commitDate}</span>
1520
2162
  </div>
@@ -1536,7 +2178,12 @@ export function buildFooter(provenance: Provenance, config: SiteConfig): string
1536
2178
  /**
1537
2179
  * Build bundle footer HTML.
1538
2180
  */
1539
- 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 {
1540
2187
  const footer = config.footer || {};
1541
2188
  const copyrightText = footer.copyright
1542
2189
  ? escapeHtml(footer.copyright)
@@ -1561,11 +2208,13 @@ export function buildBundleFooter(version: string | undefined, config: SiteConfi
1561
2208
  ? `<span class="footer-version">v${escapeHtml(version)}</span>
1562
2209
  <span class="footer-separator">·</span>`
1563
2210
  : "";
2211
+ const footerLogoHtml = renderFooterLogo(footer, config, "", logoOverride, logoDarkOverride);
1564
2212
 
1565
2213
  return `
1566
2214
  <footer class="site-footer">
1567
2215
  <div class="footer-content">
1568
2216
  <div class="footer-left">
2217
+ ${footerLogoHtml}
1569
2218
  ${versionHtml}
1570
2219
  <span class="footer-commit">Published (offline bundle)</span>
1571
2220
  </div>
@@ -1763,6 +2412,7 @@ function normalizeFooter(footer: unknown): SiteFooter | undefined {
1763
2412
  if (!footer || typeof footer !== "object") return undefined;
1764
2413
  const raw = footer as Record<string, unknown>;
1765
2414
  let links: FooterLink[] | undefined;
2415
+ let logoHeight: number | undefined;
1766
2416
 
1767
2417
  if (Array.isArray(raw.links)) {
1768
2418
  links = raw.links
@@ -1779,15 +2429,66 @@ function normalizeFooter(footer: unknown): SiteFooter | undefined {
1779
2429
  console.warn("⚠ site.yaml footer.links supports at most 10 links; truncating extras.");
1780
2430
  }
1781
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
+ }
1782
2441
 
1783
2442
  return {
1784
2443
  copyright: typeof raw.copyright === "string" ? raw.copyright : undefined,
1785
2444
  copyrightUrl: typeof raw.copyrightUrl === "string" ? raw.copyrightUrl : undefined,
1786
2445
  links,
1787
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,
1788
2452
  };
1789
2453
  }
1790
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
+
1791
2492
  /**
1792
2493
  * Load site configuration with fallback chain
1793
2494
  * @param root - The root directory
@@ -1811,6 +2512,7 @@ export async function loadSiteConfig(
1811
2512
 
1812
2513
  return {
1813
2514
  docroot: parsed.docroot || ".",
2515
+ dataroot: typeof parsedRecord.dataroot === "string" ? parsedRecord.dataroot : "data",
1814
2516
  title: parsed.title,
1815
2517
  version: typeof parsedRecord.version === "string" ? parsedRecord.version : undefined,
1816
2518
  mode: parsedRecord.mode === "slides" ? "slides" : "docs",
@@ -1825,12 +2527,15 @@ export async function loadSiteConfig(
1825
2527
  brand: {
1826
2528
  ...parsed.brand,
1827
2529
  logo: parsed.brand.logo || "assets/brand/logo.png",
2530
+ logoDark: typeof parsed.brand.logoDark === "string" ? parsed.brand.logoDark : undefined,
1828
2531
  favicon: parsed.brand.favicon || "assets/brand/favicon.png",
1829
2532
  logoType: parsed.brand.logoType || "icon",
1830
2533
  },
1831
2534
  sections: parsed.sections,
1832
2535
  footer: normalizeFooter(parsedRecord.footer),
1833
2536
  server: parsed.server,
2537
+ profiles: normalizeProfiles(parsedRecord.profiles),
2538
+ prebuild: normalizePrebuild(parsedRecord.prebuild),
1834
2539
  };
1835
2540
  } catch (e) {
1836
2541
  if ((e as NodeJS.ErrnoException).code !== "ENOENT") {
@@ -1859,6 +2564,7 @@ export async function loadSiteConfig(
1859
2564
  if (sections.length > 0) {
1860
2565
  return {
1861
2566
  docroot: "content",
2567
+ dataroot: "data",
1862
2568
  title: "Documentation",
1863
2569
  mode: "docs",
1864
2570
  aspect: "16/9",
@@ -1873,6 +2579,7 @@ export async function loadSiteConfig(
1873
2579
  // Final fallback
1874
2580
  return {
1875
2581
  docroot: ".",
2582
+ dataroot: "data",
1876
2583
  title: defaultTitle,
1877
2584
  mode: "docs",
1878
2585
  aspect: "16/9",