kitfly 0.1.2 → 0.2.0

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 (194) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +63 -16
  3. package/VERSION +1 -1
  4. package/dist/_raw/content/deployment/preflight.md +134 -0
  5. package/dist/_raw/content/deployment/recipes/aws-s3.md +128 -0
  6. package/dist/_raw/content/deployment/recipes/cloudflare-pages.md +73 -0
  7. package/dist/_raw/content/deployment/recipes/cloudflare-r2.md +156 -0
  8. package/dist/_raw/content/deployment/recipes/fly-io.md +57 -0
  9. package/dist/_raw/content/deployment/recipes/github-pages.md +112 -0
  10. package/dist/_raw/content/deployment/recipes/netlify.md +99 -0
  11. package/dist/_raw/content/deployment/recipes/vercel.md +88 -0
  12. package/dist/_raw/content/deployment/secrets-and-env-vars.md +75 -0
  13. package/dist/_raw/content/deployment.md +128 -0
  14. package/dist/_raw/content/guide/approaches.md +182 -0
  15. package/dist/_raw/content/guide/features.md +121 -0
  16. package/dist/_raw/content/guide/getting-started.md +112 -0
  17. package/dist/_raw/content/guide/kitfly-overview.md +209 -0
  18. package/dist/_raw/content/reference/configuration.md +259 -0
  19. package/dist/_raw/content/reference/design-catalog.md +167 -0
  20. package/dist/_raw/content/reference/environment-variables.md +66 -0
  21. package/dist/_raw/content/reference/glossary.md +92 -0
  22. package/dist/_raw/content/reference/key-concepts.md +118 -0
  23. package/dist/_raw/content/reference/plugins.md +220 -0
  24. package/dist/_raw/content/reference/structure.md +166 -0
  25. package/dist/_raw/content/reference.md +19 -0
  26. package/dist/_raw/content/templates/crucible.md +192 -0
  27. package/dist/_raw/content/templates/handbook.md +83 -0
  28. package/dist/_raw/content/templates/minimal.md +138 -0
  29. package/dist/_raw/content/templates/overview.md +187 -0
  30. package/dist/_raw/content/templates/pipeline.md +151 -0
  31. package/dist/_raw/content/templates/productbook.md +187 -0
  32. package/dist/_raw/content/templates/runbook.md +193 -0
  33. package/dist/_raw/content/templates/servicebook.md +163 -0
  34. package/dist/_raw/docs/decisions/ADR-0001-minimalist-site-code.md +118 -0
  35. package/dist/_raw/docs/decisions/ADR-0002-ai-accessibility.md +153 -0
  36. package/dist/_raw/docs/decisions/ADR-0003-single-file-bundle.md +93 -0
  37. package/dist/_raw/docs/decisions/ADR-0004-bun-runtime.md +98 -0
  38. package/dist/_raw/docs/decisions/ADR-0005-plugin-contract-and-distribution.md +110 -0
  39. package/dist/_raw/docs/decisions/DDR-0001-viewport-locked-layout.md +111 -0
  40. package/dist/_raw/docs/decisions/DDR-0002-theme-system.md +131 -0
  41. package/dist/_raw/docs/decisions/DDR-0003-bounded-logo-slot.md +106 -0
  42. package/dist/_raw/docs/decisions/DDR-0004-slides-rendering-model.md +113 -0
  43. package/dist/_raw/docs/decisions/DDR-0005-deterministic-layout-boundary.md +107 -0
  44. package/dist/_raw/docs/userguide/cli/build.md +85 -0
  45. package/dist/_raw/docs/userguide/cli/bundle.md +81 -0
  46. package/dist/_raw/docs/userguide/cli/dev.md +92 -0
  47. package/dist/_raw/docs/userguide/cli/init.md +116 -0
  48. package/dist/_raw/docs/userguide/cli/servers.md +69 -0
  49. package/dist/_raw/docs/userguide/cli/stop.md +76 -0
  50. package/dist/_raw/docs/userguide/cli/update.md +78 -0
  51. package/dist/_raw/docs/userguide/cli/version.md +65 -0
  52. package/dist/_raw/docs/userguide/cli.md +34 -0
  53. package/dist/_raw/docs/userguide/sharing.md +94 -0
  54. package/dist/_raw/schemas/plugin-schemas-notes.md +71 -0
  55. package/dist/_raw/schemas.md +42 -0
  56. package/dist/assets/brand/kitfly-favicon-32.png +0 -0
  57. package/dist/assets/brand/kitfly-icon-64.png +0 -0
  58. package/dist/assets/brand/kitfly-logo-128.png +0 -0
  59. package/dist/assets/brand/kitfly-logo-512.png +0 -0
  60. package/dist/assets/brand/kitfly-logo.svg +12132 -0
  61. package/dist/assets/brand/kitfly-neon-128.png +0 -0
  62. package/dist/assets/brand/kitfly-neon-192.png +0 -0
  63. package/dist/assets/brand/kitfly-neon-256.png +0 -0
  64. package/dist/assets/brand/kitfly-neon.png +0 -0
  65. package/dist/assets/brand/palette.md +75 -0
  66. package/dist/content/deployment/index.html +11 -0
  67. package/dist/content/deployment/preflight.html +418 -0
  68. package/dist/content/deployment/recipes/aws-s3.html +421 -0
  69. package/dist/content/deployment/recipes/cloudflare-pages.html +372 -0
  70. package/dist/content/deployment/recipes/cloudflare-r2.html +443 -0
  71. package/dist/content/deployment/recipes/fly-io.html +356 -0
  72. package/dist/content/deployment/recipes/github-pages.html +414 -0
  73. package/dist/content/deployment/recipes/index.html +11 -0
  74. package/dist/content/deployment/recipes/netlify.html +394 -0
  75. package/dist/content/deployment/recipes/vercel.html +382 -0
  76. package/dist/content/deployment/secrets-and-env-vars.html +380 -0
  77. package/dist/content/deployment.html +426 -0
  78. package/dist/content/guide/approaches.html +501 -0
  79. package/dist/content/guide/features.html +436 -0
  80. package/dist/content/guide/getting-started.html +403 -0
  81. package/dist/content/guide/index.html +11 -0
  82. package/dist/content/guide/kitfly-overview.html +544 -0
  83. package/dist/content/index.html +11 -0
  84. package/dist/content/reference/configuration.html +580 -0
  85. package/dist/content/reference/design-catalog.html +449 -0
  86. package/dist/content/reference/environment-variables.html +367 -0
  87. package/dist/content/reference/glossary.html +368 -0
  88. package/dist/content/reference/index.html +11 -0
  89. package/dist/content/reference/key-concepts.html +399 -0
  90. package/dist/content/reference/plugins.html +491 -0
  91. package/dist/content/reference/structure.html +463 -0
  92. package/dist/content/reference.html +334 -0
  93. package/dist/content/templates/crucible.html +546 -0
  94. package/dist/content/templates/handbook.html +405 -0
  95. package/dist/content/templates/index.html +11 -0
  96. package/dist/content/templates/minimal.html +447 -0
  97. package/dist/content/templates/overview.html +558 -0
  98. package/dist/content/templates/pipeline.html +494 -0
  99. package/dist/content/templates/productbook.html +540 -0
  100. package/dist/content/templates/runbook.html +543 -0
  101. package/dist/content/templates/servicebook.html +523 -0
  102. package/dist/content-index.json +540 -0
  103. package/dist/docs/decisions/ADR-0001-minimalist-site-code.html +491 -0
  104. package/dist/docs/decisions/ADR-0002-ai-accessibility.html +434 -0
  105. package/dist/docs/decisions/ADR-0003-single-file-bundle.html +412 -0
  106. package/dist/docs/decisions/ADR-0004-bun-runtime.html +409 -0
  107. package/dist/docs/decisions/ADR-0005-plugin-contract-and-distribution.html +402 -0
  108. package/dist/docs/decisions/DDR-0001-viewport-locked-layout.html +459 -0
  109. package/dist/docs/decisions/DDR-0002-theme-system.html +452 -0
  110. package/dist/docs/decisions/DDR-0003-bounded-logo-slot.html +423 -0
  111. package/dist/docs/decisions/DDR-0004-slides-rendering-model.html +399 -0
  112. package/dist/docs/decisions/DDR-0005-deterministic-layout-boundary.html +422 -0
  113. package/dist/docs/decisions/index.html +11 -0
  114. package/dist/docs/userguide/cli/build.html +408 -0
  115. package/dist/docs/userguide/cli/bundle.html +419 -0
  116. package/dist/docs/userguide/cli/dev.html +428 -0
  117. package/dist/docs/userguide/cli/index.html +11 -0
  118. package/dist/docs/userguide/cli/init.html +436 -0
  119. package/dist/docs/userguide/cli/servers.html +393 -0
  120. package/dist/docs/userguide/cli/stop.html +408 -0
  121. package/dist/docs/userguide/cli/update.html +406 -0
  122. package/dist/docs/userguide/cli/version.html +406 -0
  123. package/dist/docs/userguide/cli.html +386 -0
  124. package/dist/docs/userguide/index.html +11 -0
  125. package/dist/docs/userguide/sharing.html +465 -0
  126. package/dist/index.html +387 -0
  127. package/dist/llms.txt +18 -0
  128. package/dist/provenance.json +7 -0
  129. package/dist/schemas/index.html +11 -0
  130. package/dist/schemas/plugin-registry.schema.html +327 -0
  131. package/dist/schemas/plugin-schemas-notes.html +364 -0
  132. package/dist/schemas/plugin.schema.html +327 -0
  133. package/dist/schemas/plugins.schema.html +327 -0
  134. package/dist/schemas/v0/common.schema.html +386 -0
  135. package/dist/schemas/v0/index.html +11 -0
  136. package/dist/schemas/v0/plugin-registry.schema.html +547 -0
  137. package/dist/schemas/v0/plugin.schema.html +497 -0
  138. package/dist/schemas/v0/plugins.schema.html +406 -0
  139. package/dist/schemas/v0/site.schema.html +541 -0
  140. package/dist/schemas/v0/theme.schema.html +615 -0
  141. package/dist/schemas.html +351 -0
  142. package/dist/styles.css +1262 -0
  143. package/package.json +4 -2
  144. package/plugins-dist/callouts.css +32 -0
  145. package/plugins-dist/callouts.js +46 -0
  146. package/plugins-dist/slides-visuals.css +224 -0
  147. package/plugins-dist/slides-visuals.js +598 -0
  148. package/registry/plugins.yaml +35 -0
  149. package/schemas/README.md +10 -0
  150. package/schemas/plugin-registry.schema.json +5 -0
  151. package/schemas/plugin-schemas-notes.md +71 -0
  152. package/schemas/plugin.schema.json +5 -0
  153. package/schemas/plugins.schema.json +5 -0
  154. package/schemas/v0/common.schema.json +64 -0
  155. package/schemas/v0/plugin-registry.schema.json +225 -0
  156. package/schemas/v0/plugin.schema.json +175 -0
  157. package/schemas/v0/plugins.schema.json +84 -0
  158. package/schemas/v0/site.schema.json +56 -9
  159. package/schemas/v0/theme.schema.json +105 -22
  160. package/scripts/build.ts +155 -3
  161. package/scripts/bundle.ts +258 -95
  162. package/scripts/dev.ts +203 -1
  163. package/src/__tests__/build.test.ts +158 -1
  164. package/src/__tests__/bundle.test.ts +31 -0
  165. package/src/__tests__/cli.test.ts +14 -3
  166. package/src/__tests__/fixtures/fences/slides-visuals/invalid/bad-list-indent.md +5 -0
  167. package/src/__tests__/fixtures/fences/slides-visuals/invalid/blank-line.md +5 -0
  168. package/src/__tests__/fixtures/fences/slides-visuals/invalid/compare-object-items.md +9 -0
  169. package/src/__tests__/fixtures/fences/slides-visuals/invalid/indented-fence.md +4 -0
  170. package/src/__tests__/fixtures/fences/slides-visuals/invalid/stat-grid-missing-fields.md +5 -0
  171. package/src/__tests__/fixtures/fences/slides-visuals/invalid/unknown-type.md +3 -0
  172. package/src/__tests__/fixtures/fences/slides-visuals/valid/compare.md +10 -0
  173. package/src/__tests__/fixtures/fences/slides-visuals/valid/comparison-table.md +14 -0
  174. package/src/__tests__/fixtures/fences/slides-visuals/valid/funnel.md +7 -0
  175. package/src/__tests__/fixtures/fences/slides-visuals/valid/kpi.md +5 -0
  176. package/src/__tests__/fixtures/fences/slides-visuals/valid/layer-cake.md +6 -0
  177. package/src/__tests__/fixtures/fences/slides-visuals/valid/pyramid.md +6 -0
  178. package/src/__tests__/fixtures/fences/slides-visuals/valid/quadrant-grid.md +8 -0
  179. package/src/__tests__/fixtures/fences/slides-visuals/valid/scorecard.md +13 -0
  180. package/src/__tests__/fixtures/fences/slides-visuals/valid/stat-grid.md +8 -0
  181. package/src/__tests__/init.test.ts +35 -0
  182. package/src/__tests__/plugin-loader.test.ts +221 -0
  183. package/src/__tests__/shared.test.ts +428 -0
  184. package/src/__tests__/slides-visuals-fence-contract.test.ts +28 -0
  185. package/src/__tests__/slides-visuals-runtime-regressions.bun.test.ts +114 -0
  186. package/src/__tests__/styles.test.ts +35 -0
  187. package/src/cli.ts +9 -4
  188. package/src/plugin-loader.ts +245 -0
  189. package/src/shared.ts +614 -7
  190. package/src/site/styles.css +331 -0
  191. package/src/site/template.html +66 -5
  192. package/src/templates/deck.ts +186 -0
  193. package/src/templates/driver.ts +11 -1
  194. package/src/templates/minimal.ts +1 -0
package/src/shared.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { readdir, readFile, stat } from "node:fs/promises";
9
- import { join, resolve, sep } from "node:path";
9
+ import { basename, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
10
10
  import { ENGINE_SITE_DIR, siteOverridePath } from "./engine.ts";
11
11
 
12
12
  // ---------------------------------------------------------------------------
@@ -62,10 +62,15 @@ export interface SiteServer {
62
62
  host?: string; // Default dev server host
63
63
  }
64
64
 
65
+ export type SiteMode = "docs" | "slides";
66
+ export type SlideAspect = "16/9" | "4/3" | "3/2" | "16/10";
67
+
65
68
  export interface SiteConfig {
66
69
  docroot: string;
67
70
  title: string;
68
71
  version?: string;
72
+ mode?: SiteMode;
73
+ aspect?: SlideAspect;
69
74
  home?: string;
70
75
  brand: SiteBrand;
71
76
  sections: SiteSection[];
@@ -88,6 +93,22 @@ export interface ContentFile {
88
93
  sectionBase?: string;
89
94
  }
90
95
 
96
+ export interface SlideSegment {
97
+ index: number;
98
+ frontmatter: Record<string, unknown>;
99
+ body: string;
100
+ title: string;
101
+ className?: string;
102
+ }
103
+
104
+ export interface SlideContent extends SlideSegment {
105
+ id: string;
106
+ section: string;
107
+ sourcePath: string;
108
+ sourceUrlPath: string;
109
+ kind: "markdown" | "yaml" | "json";
110
+ }
111
+
91
112
  // ---------------------------------------------------------------------------
92
113
  // Environment and CLI helpers
93
114
  // ---------------------------------------------------------------------------
@@ -386,14 +407,28 @@ export function parseFrontmatter(content: string): {
386
407
  frontmatter: Record<string, unknown>;
387
408
  body: string;
388
409
  } {
389
- const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
390
- if (!match) {
410
+ const normalized = content.replace(/^\uFEFF/, "").replaceAll("\r\n", "\n");
411
+ const lines = normalized.split("\n");
412
+
413
+ let i = 0;
414
+ while (i < lines.length && lines[i].trim() === "") i += 1;
415
+ if (i >= lines.length || lines[i].trim() !== "---") {
391
416
  return { frontmatter: {}, body: content };
392
417
  }
393
418
 
419
+ i += 1;
420
+ const fmLines: string[] = [];
421
+ while (i < lines.length && lines[i].trim() !== "---") {
422
+ fmLines.push(lines[i]);
423
+ i += 1;
424
+ }
425
+ if (i >= lines.length) return { frontmatter: {}, body: content };
426
+ i += 1; // consume closing ---
427
+
428
+ const body = lines.slice(i).join("\n");
429
+
394
430
  const frontmatter: Record<string, unknown> = {};
395
- const lines = match[1].split("\n");
396
- for (const line of lines) {
431
+ for (const line of fmLines) {
397
432
  const colonIndex = line.indexOf(":");
398
433
  if (colonIndex > 0) {
399
434
  const key = line.slice(0, colonIndex).trim();
@@ -409,7 +444,7 @@ export function parseFrontmatter(content: string): {
409
444
  }
410
445
  }
411
446
 
412
- return { frontmatter, body: match[2] };
447
+ return { frontmatter, body };
413
448
  }
414
449
 
415
450
  export function slugify(text: string): string {
@@ -421,6 +456,524 @@ export function slugify(text: string): string {
421
456
  .trim();
422
457
  }
423
458
 
459
+ export type SlidesVisualsFenceDiagnostic = {
460
+ line: number;
461
+ message: string;
462
+ type?: string;
463
+ };
464
+
465
+ interface FenceState {
466
+ char: "`" | "~";
467
+ length: number;
468
+ }
469
+
470
+ interface FenceMarker extends FenceState {
471
+ trailer: string;
472
+ }
473
+
474
+ function parseFenceMarker(trimmed: string): FenceMarker | null {
475
+ const match = trimmed.match(/^([`~]{3,})(.*)$/);
476
+ if (!match) return null;
477
+ const marker = match[1];
478
+ if (!marker.split("").every((ch) => ch === marker[0])) return null;
479
+ const char = marker[0] as "`" | "~";
480
+ return { char, length: marker.length, trailer: match[2] };
481
+ }
482
+
483
+ function updateFenceState(trimmed: string, fence: FenceState | null): FenceState | null {
484
+ const marker = parseFenceMarker(trimmed);
485
+ if (!marker) return fence;
486
+
487
+ if (!fence) {
488
+ return { char: marker.char, length: marker.length };
489
+ }
490
+
491
+ // Markdown closing fences must use the same fence character and at least the same length.
492
+ if (marker.char === fence.char && marker.length >= fence.length && marker.trailer.trim() === "") {
493
+ return null;
494
+ }
495
+
496
+ return fence;
497
+ }
498
+
499
+ const SLIDES_VISUALS_TYPES = new Set([
500
+ "kpi",
501
+ "stat-grid",
502
+ "compare",
503
+ "quadrant-grid",
504
+ "scorecard",
505
+ "comparison-table",
506
+ "layer-cake",
507
+ "pyramid",
508
+ "funnel",
509
+ ]);
510
+
511
+ const SLIDES_VISUALS_RULES: Record<
512
+ string,
513
+ {
514
+ required: string[];
515
+ scalars: string[];
516
+ lists: Record<
517
+ string,
518
+ { kind: "strings" } | { kind: "objects"; fields: string[]; optional?: string[] }
519
+ >;
520
+ }
521
+ > = {
522
+ kpi: {
523
+ required: ["label", "value"],
524
+ scalars: ["label", "value", "trend"],
525
+ lists: {},
526
+ },
527
+ "stat-grid": {
528
+ required: ["metrics"],
529
+ scalars: [],
530
+ lists: { metrics: { kind: "objects", fields: ["label", "value"], optional: ["trend"] } },
531
+ },
532
+ compare: {
533
+ required: ["left", "right"],
534
+ scalars: ["left-title", "right-title"],
535
+ lists: { left: { kind: "strings" }, right: { kind: "strings" } },
536
+ },
537
+ "quadrant-grid": {
538
+ required: ["tl", "tr", "bl", "br"],
539
+ scalars: ["axis-x", "axis-y", "tl", "tr", "bl", "br"],
540
+ lists: {},
541
+ },
542
+ scorecard: {
543
+ required: ["metrics"],
544
+ scalars: [],
545
+ lists: { metrics: { kind: "objects", fields: ["label", "value"], optional: ["trend"] } },
546
+ },
547
+ "comparison-table": {
548
+ required: ["headers", "rows"],
549
+ scalars: [],
550
+ lists: { headers: { kind: "strings" }, rows: { kind: "strings" } },
551
+ },
552
+ "layer-cake": {
553
+ required: ["layers"],
554
+ scalars: [],
555
+ lists: { layers: { kind: "strings" } },
556
+ },
557
+ pyramid: {
558
+ required: ["levels"],
559
+ scalars: [],
560
+ lists: { levels: { kind: "strings" } },
561
+ },
562
+ funnel: {
563
+ required: ["stages"],
564
+ scalars: [],
565
+ lists: { stages: { kind: "strings" } },
566
+ },
567
+ };
568
+
569
+ /**
570
+ * Validate slides-visuals `:::` blocks in a single markdown slide body.
571
+ * This contract is intentionally strict so writers/devs don’t guess at edge cases.
572
+ */
573
+ export function validateSlidesVisualsFences(markdown: string): SlidesVisualsFenceDiagnostic[] {
574
+ const diagnostics: SlidesVisualsFenceDiagnostic[] = [];
575
+ const lines = markdown.replaceAll("\r\n", "\n").split("\n");
576
+
577
+ let mdFence: FenceState | null = null;
578
+ let inVisual = false;
579
+ let visualType = "";
580
+ let visualStart = 0;
581
+ let seenKeys = new Set<string>();
582
+ let currentListKey: string | null = null;
583
+ let listItems = 0;
584
+ let listItemFields: Record<string, Set<string>> | null = null;
585
+
586
+ function err(line: number, message: string) {
587
+ diagnostics.push({ line, message, type: inVisual ? visualType : undefined });
588
+ }
589
+
590
+ function finishFence(closeLine: number) {
591
+ const rules = SLIDES_VISUALS_RULES[visualType];
592
+ if (!rules) return;
593
+
594
+ for (const key of rules.required) {
595
+ if (!seenKeys.has(key)) {
596
+ err(visualStart, `Missing required key: ${key}`);
597
+ }
598
+ }
599
+
600
+ if (currentListKey && listItems === 0) {
601
+ err(closeLine, `List '${currentListKey}' must have at least one item`);
602
+ }
603
+
604
+ if (listItemFields) {
605
+ for (const [requiredKey, fields] of Object.entries(listItemFields)) {
606
+ const listRule = rules.lists[requiredKey];
607
+ if (!listRule || listRule.kind !== "objects") continue;
608
+ for (const req of listRule.fields) {
609
+ if (!fields.has(req))
610
+ err(visualStart, `List '${requiredKey}' items must include '${req}'`);
611
+ }
612
+ }
613
+ }
614
+ }
615
+
616
+ for (let i = 0; i < lines.length; i++) {
617
+ const raw = lines[i];
618
+ const trimmed = raw.trim();
619
+
620
+ mdFence = updateFenceState(trimmed, mdFence);
621
+ if (mdFence) continue;
622
+
623
+ if (!inVisual) {
624
+ if (trimmed.startsWith(":::") && !raw.startsWith(":::")) {
625
+ const mBad = trimmed.match(/^:::\s*([a-z0-9-]+)\s*$/i);
626
+ const type = mBad?.[1]?.toLowerCase();
627
+ diagnostics.push({
628
+ line: i + 1,
629
+ message: "Opening ::: fence must start at column 0",
630
+ type,
631
+ });
632
+ continue;
633
+ }
634
+ const m = raw.match(/^:::\s*([a-z0-9-]+)\s*$/i);
635
+ if (!m) continue;
636
+ const type = m[1].toLowerCase();
637
+ if (!SLIDES_VISUALS_TYPES.has(type)) {
638
+ diagnostics.push({
639
+ line: i + 1,
640
+ message: `Unknown slides-visuals block type: ${type}`,
641
+ type,
642
+ });
643
+ continue;
644
+ }
645
+ inVisual = true;
646
+ visualType = type;
647
+ visualStart = i + 1;
648
+ seenKeys = new Set();
649
+ currentListKey = null;
650
+ listItems = 0;
651
+ listItemFields = null;
652
+ continue;
653
+ }
654
+
655
+ // inside visual fence
656
+ if (trimmed === ":::" && !raw.startsWith(":::")) {
657
+ err(i + 1, "Closing ::: fence must start at column 0");
658
+ continue;
659
+ }
660
+
661
+ if (raw.match(/^:::\s*$/)) {
662
+ finishFence(i + 1);
663
+ inVisual = false;
664
+ visualType = "";
665
+ currentListKey = null;
666
+ continue;
667
+ }
668
+
669
+ if (trimmed === "") {
670
+ err(i + 1, "Blank lines are not allowed inside ::: blocks");
671
+ continue;
672
+ }
673
+
674
+ if (/^\s/.test(raw)) {
675
+ // list item or list continuation
676
+ if (!currentListKey) {
677
+ err(i + 1, "Indented content is only allowed inside a list");
678
+ continue;
679
+ }
680
+
681
+ const listRule = SLIDES_VISUALS_RULES[visualType]?.lists[currentListKey];
682
+ const item = raw.match(/^ {2}-\s+(.+)$/);
683
+ if (item) {
684
+ listItems += 1;
685
+ if (listRule?.kind === "objects") {
686
+ const kv = item[1].match(/^([a-z][a-z0-9-]*)\s*:\s*(.+)$/i);
687
+ if (!listItemFields) listItemFields = {};
688
+ const fields = listItemFields;
689
+ fields[currentListKey] ??= new Set<string>();
690
+ if (kv) fields[currentListKey].add(kv[1].toLowerCase());
691
+ }
692
+ continue;
693
+ }
694
+
695
+ const cont = raw.match(/^ {4}([a-z][a-z0-9-]*)\s*:\s*(.+)$/i);
696
+ if (cont) {
697
+ if (listRule?.kind !== "objects") {
698
+ err(i + 1, `List '${currentListKey}' items must be strings (no object fields)`);
699
+ continue;
700
+ }
701
+ if (!listItemFields) listItemFields = {};
702
+ const fields = listItemFields;
703
+ fields[currentListKey] ??= new Set<string>();
704
+ fields[currentListKey].add(cont[1].toLowerCase());
705
+ continue;
706
+ }
707
+
708
+ err(i + 1, "Invalid list syntax (expected ' - ...' or ' field: value')");
709
+ continue;
710
+ }
711
+
712
+ const rules = SLIDES_VISUALS_RULES[visualType];
713
+ if (!rules) continue;
714
+
715
+ const kv = raw.match(/^([a-z][a-z0-9-]*)\s*:\s*(.*)$/i);
716
+ if (!kv) {
717
+ err(i + 1, "Invalid line inside ::: block (expected 'key: value' or 'key:')");
718
+ continue;
719
+ }
720
+
721
+ const key = kv[1].toLowerCase();
722
+ const value = kv[2];
723
+
724
+ if (value === "") {
725
+ // list key
726
+ const listRule = rules.lists[key];
727
+ if (!listRule) {
728
+ err(i + 1, `Key '${key}' is not a supported list for ${visualType}`);
729
+ continue;
730
+ }
731
+ seenKeys.add(key);
732
+ currentListKey = key;
733
+ listItems = 0;
734
+ continue;
735
+ }
736
+
737
+ // scalar key
738
+ if (!rules.scalars.includes(key)) {
739
+ err(i + 1, `Key '${key}' is not a supported scalar for ${visualType}`);
740
+ continue;
741
+ }
742
+ seenKeys.add(key);
743
+ currentListKey = null;
744
+ }
745
+
746
+ if (inVisual) {
747
+ diagnostics.push({
748
+ line: visualStart,
749
+ message: `Unclosed ::: block (missing closing ':::')`,
750
+ type: visualType,
751
+ });
752
+ }
753
+
754
+ return diagnostics;
755
+ }
756
+
757
+ /**
758
+ * Split markdown content into slide chunks using explicit delimiter.
759
+ * Delimiter lines inside fenced code blocks are ignored.
760
+ */
761
+ export function splitSlides(content: string): string[] {
762
+ const lines = content.split(/\r?\n/);
763
+ const slides: string[] = [];
764
+ let current: string[] = [];
765
+ let fence: FenceState | null = null;
766
+
767
+ for (const line of lines) {
768
+ const trimmed = line.trim();
769
+ fence = updateFenceState(trimmed, fence);
770
+
771
+ if (!fence && trimmed === "--- slide ---") {
772
+ slides.push(current.join("\n"));
773
+ current = [];
774
+ continue;
775
+ }
776
+
777
+ current.push(line);
778
+ }
779
+ slides.push(current.join("\n"));
780
+
781
+ return slides.filter((s) => s.trim() !== "");
782
+ }
783
+
784
+ function extractHeadingTitle(markdown: string): string | undefined {
785
+ const lines = markdown.split(/\r?\n/);
786
+ let fence: FenceState | null = null;
787
+
788
+ for (const line of lines) {
789
+ const trimmed = line.trim();
790
+ fence = updateFenceState(trimmed, fence);
791
+
792
+ if (fence) continue;
793
+ const match = trimmed.match(/^#{1,6}\s+(.+)$/);
794
+ if (match) {
795
+ return match[1].replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").trim();
796
+ }
797
+ }
798
+
799
+ return undefined;
800
+ }
801
+
802
+ function asNonEmptyString(value: unknown): string | undefined {
803
+ if (typeof value !== "string") return undefined;
804
+ const trimmed = value.trim();
805
+ return trimmed === "" ? undefined : trimmed;
806
+ }
807
+
808
+ function sanitizeClassNameList(value: unknown): string | undefined {
809
+ const classList = asNonEmptyString(value);
810
+ if (!classList) return undefined;
811
+
812
+ const safeTokens = classList
813
+ .split(/\s+/)
814
+ .map((token) => token.trim())
815
+ .filter(Boolean)
816
+ .filter((token) => /^[A-Za-z0-9_-]+$/.test(token));
817
+
818
+ return safeTokens.length > 0 ? safeTokens.join(" ") : undefined;
819
+ }
820
+
821
+ /**
822
+ * Parse markdown into slide segments with resolved titles and optional classes.
823
+ * Title precedence: frontmatter.title -> first heading -> fallback title.
824
+ */
825
+ export function segmentSlides(content: string, fallbackTitle: string): SlideSegment[] {
826
+ const parts = splitSlides(content);
827
+ const total = parts.length;
828
+
829
+ return parts.map((part, index) => {
830
+ const { frontmatter, body } = parseFrontmatter(part);
831
+ const fmTitle = asNonEmptyString(frontmatter.title);
832
+ const headingTitle = extractHeadingTitle(body);
833
+ const autoFallback = total > 1 ? `${fallbackTitle} (${index + 1})` : fallbackTitle;
834
+ const title = fmTitle || headingTitle || autoFallback;
835
+ const className = sanitizeClassNameList(frontmatter.class);
836
+
837
+ return {
838
+ index,
839
+ frontmatter,
840
+ body,
841
+ title,
842
+ className,
843
+ };
844
+ });
845
+ }
846
+
847
+ /**
848
+ * Collect slide content objects from discovered content files.
849
+ * Markdown files can produce multiple slides via explicit delimiters.
850
+ */
851
+ export async function collectSlides(files: ContentFile[]): Promise<SlideContent[]> {
852
+ const slides: SlideContent[] = [];
853
+ let index = 0;
854
+
855
+ for (const file of files) {
856
+ const raw = await readFile(file.path, "utf-8");
857
+ const stem = basename(file.path, extname(file.path));
858
+
859
+ if (file.path.endsWith(".md")) {
860
+ const segments = segmentSlides(raw, stem);
861
+ for (const segment of segments) {
862
+ index += 1;
863
+ slides.push({
864
+ ...segment,
865
+ id: `slide-${index}`,
866
+ section: file.section,
867
+ sourcePath: file.path,
868
+ sourceUrlPath: file.urlPath,
869
+ kind: "markdown",
870
+ });
871
+ }
872
+ continue;
873
+ }
874
+
875
+ index += 1;
876
+ slides.push({
877
+ index: 0,
878
+ frontmatter: {},
879
+ body: raw,
880
+ title: stem,
881
+ className: undefined,
882
+ id: `slide-${index}`,
883
+ section: file.section,
884
+ sourcePath: file.path,
885
+ sourceUrlPath: file.urlPath,
886
+ kind: file.path.endsWith(".yaml") ? "yaml" : "json",
887
+ });
888
+ }
889
+
890
+ return slides;
891
+ }
892
+
893
+ export function buildSlideNav(
894
+ slides: SlideContent[],
895
+ config: SiteConfig,
896
+ currentSlideId?: string,
897
+ ): string {
898
+ const grouped = new Map<string, SlideContent[]>();
899
+ for (const slide of slides) {
900
+ if (!grouped.has(slide.section)) grouped.set(slide.section, []);
901
+ grouped.get(slide.section)?.push(slide);
902
+ }
903
+
904
+ let html = "<ul>";
905
+ for (const section of config.sections) {
906
+ const items = grouped.get(section.name);
907
+ 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>";
914
+ }
915
+ html += "</ul>";
916
+ return html;
917
+ }
918
+
919
+ function resolveRelativeContentPath(pathOrRef: string, currentUrlPath?: string): string {
920
+ let cleaned = pathOrRef;
921
+ if (currentUrlPath && !cleaned.startsWith("/")) {
922
+ const base = currentUrlPath.includes("/")
923
+ ? currentUrlPath.slice(0, currentUrlPath.lastIndexOf("/"))
924
+ : "";
925
+ cleaned = base ? `${base}/${cleaned}` : cleaned;
926
+ }
927
+
928
+ const segments = cleaned.split("/");
929
+ const resolved: string[] = [];
930
+ for (const segment of segments) {
931
+ if (!segment || segment === ".") continue;
932
+ if (segment === "..") resolved.pop();
933
+ else resolved.push(segment);
934
+ }
935
+ return resolved.join("/");
936
+ }
937
+
938
+ function splitUrlSuffix(url: string): { path: string; suffix: string } {
939
+ const idx = url.search(/[?#]/);
940
+ if (idx < 0) return { path: url, suffix: "" };
941
+ return { path: url.slice(0, idx), suffix: url.slice(idx) };
942
+ }
943
+
944
+ function isExternalOrAnchorRef(ref: string): boolean {
945
+ return /^(https?:|mailto:|tel:|data:|javascript:|#|\/\/)/i.test(ref);
946
+ }
947
+
948
+ // Rewrite relative href/src URLs so slide assets resolve from their source folder.
949
+ export function rewriteRelativeAssetUrls(
950
+ html: string,
951
+ currentUrlPath?: string,
952
+ pathPrefix = "/",
953
+ ): string {
954
+ const assetHrefPattern =
955
+ /\.(pdf|png|jpe?g|gif|webp|svg|ico|bmp|avif|json|ya?ml|csv|txt|zip|mp4|webm|mov|mp3|wav|ogg)$/i;
956
+
957
+ return html.replace(/\b(href|src)="([^"]+)"/g, (_m, attr, value: string) => {
958
+ if (isExternalOrAnchorRef(value) || value.startsWith("/")) {
959
+ return `${attr}="${value}"`;
960
+ }
961
+ if (attr === "href") {
962
+ const isExplicitRelative = value.startsWith("./") || value.startsWith("../");
963
+ const { path } = splitUrlSuffix(value);
964
+ if (!isExplicitRelative || !assetHrefPattern.test(path)) {
965
+ return `${attr}="${value}"`;
966
+ }
967
+ }
968
+
969
+ const { path, suffix } = splitUrlSuffix(value);
970
+ const resolved = resolveRelativeContentPath(path, currentUrlPath);
971
+ const prefix = pathPrefix.endsWith("/") ? pathPrefix : `${pathPrefix}/`;
972
+ const rewritten = `${prefix}${resolved}${suffix}`;
973
+ return `${attr}="${rewritten}"`;
974
+ });
975
+ }
976
+
424
977
  // ---------------------------------------------------------------------------
425
978
  // Navigation/template building
426
979
  // ---------------------------------------------------------------------------
@@ -1081,7 +1634,32 @@ export async function resolveSiteVersion(
1081
1634
  configuredVersion?: string,
1082
1635
  ): Promise<string | undefined> {
1083
1636
  if (typeof configuredVersion === "string" && configuredVersion.trim() !== "") {
1084
- return configuredVersion.trim();
1637
+ const value = configuredVersion.trim();
1638
+ const lower = value.toLowerCase();
1639
+
1640
+ if (lower === "auto") {
1641
+ const autoVersion = await readVersionLine(join(root, "VERSION"));
1642
+ if (autoVersion) return autoVersion;
1643
+ } else if (lower.startsWith("file:")) {
1644
+ const rawPath = value.slice(5).trim();
1645
+ if (!rawPath) {
1646
+ console.warn("version file: path is empty");
1647
+ } else if (isAbsoluteVersionPath(rawPath)) {
1648
+ console.warn(`version file: absolute paths are not allowed: ${rawPath}`);
1649
+ } else {
1650
+ const normalizedRoot = resolve(root);
1651
+ const resolvedPath = resolve(root, rawPath);
1652
+ const rel = relative(normalizedRoot, resolvedPath);
1653
+ if (rel.startsWith("..") || rel === ".." || rel.includes(`${sep}..${sep}`)) {
1654
+ console.warn(`version file: path escapes site root: ${rawPath}`);
1655
+ } else {
1656
+ const fileVersion = await readVersionLine(resolvedPath);
1657
+ if (fileVersion) return fileVersion;
1658
+ }
1659
+ }
1660
+ } else {
1661
+ return value;
1662
+ }
1085
1663
  }
1086
1664
 
1087
1665
  try {
@@ -1102,6 +1680,23 @@ export async function resolveSiteVersion(
1102
1680
  return undefined;
1103
1681
  }
1104
1682
 
1683
+ function isAbsoluteVersionPath(pathValue: string): boolean {
1684
+ return isAbsolute(pathValue) || /^[A-Za-z]:/.test(pathValue) || pathValue.startsWith("\\\\");
1685
+ }
1686
+
1687
+ async function readVersionLine(path: string): Promise<string | undefined> {
1688
+ try {
1689
+ const content = await readFile(path, "utf-8");
1690
+ for (const line of content.split(/\r?\n/)) {
1691
+ const trimmed = line.trim();
1692
+ if (trimmed) return trimmed;
1693
+ }
1694
+ } catch {
1695
+ // Fall through to git tag resolution
1696
+ }
1697
+ return undefined;
1698
+ }
1699
+
1105
1700
  /**
1106
1701
  * Generate provenance information
1107
1702
  * @param root - The root directory
@@ -1182,6 +1777,14 @@ export async function loadSiteConfig(
1182
1777
  docroot: parsed.docroot || ".",
1183
1778
  title: parsed.title,
1184
1779
  version: typeof parsedRecord.version === "string" ? parsedRecord.version : undefined,
1780
+ mode: parsedRecord.mode === "slides" ? "slides" : "docs",
1781
+ aspect:
1782
+ parsedRecord.aspect === "4/3" ||
1783
+ parsedRecord.aspect === "3/2" ||
1784
+ parsedRecord.aspect === "16/10" ||
1785
+ parsedRecord.aspect === "16/9"
1786
+ ? parsedRecord.aspect
1787
+ : "16/9",
1185
1788
  home: parsed.home as string | undefined,
1186
1789
  brand: {
1187
1790
  ...parsed.brand,
@@ -1221,6 +1824,8 @@ export async function loadSiteConfig(
1221
1824
  return {
1222
1825
  docroot: "content",
1223
1826
  title: "Documentation",
1827
+ mode: "docs",
1828
+ aspect: "16/9",
1224
1829
  brand: { name: "Docs", url: "/" },
1225
1830
  sections,
1226
1831
  };
@@ -1233,6 +1838,8 @@ export async function loadSiteConfig(
1233
1838
  return {
1234
1839
  docroot: ".",
1235
1840
  title: defaultTitle,
1841
+ mode: "docs",
1842
+ aspect: "16/9",
1236
1843
  brand: { name: "Handbook", url: "/" },
1237
1844
  sections: [],
1238
1845
  };