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.
- package/CHANGELOG.md +68 -0
- package/README.md +25 -10
- package/VERSION +1 -1
- package/dist/_raw/content/guide/branding.md +146 -0
- package/dist/_raw/content/guide/data-driven-content.md +204 -0
- package/dist/_raw/content/reference/configuration.md +145 -7
- package/dist/_raw/content/reference/environment-variables.md +26 -1
- package/dist/_raw/content/reference/glossary.md +25 -1
- package/dist/_raw/content/reference/key-concepts.md +30 -2
- package/dist/_raw/content/reference/plugins.md +14 -0
- package/dist/_raw/content/reference/slides-authoring-guidelines.md +129 -0
- package/dist/_raw/content/reference.md +1 -0
- package/dist/_raw/docs/decisions/ADR-0006-data-driven-content.md +350 -0
- package/dist/content/deployment/preflight.html +10 -6
- package/dist/content/deployment/recipes/aws-s3.html +10 -6
- package/dist/content/deployment/recipes/cloudflare-pages.html +10 -6
- package/dist/content/deployment/recipes/cloudflare-r2.html +10 -6
- package/dist/content/deployment/recipes/fly-io.html +10 -6
- package/dist/content/deployment/recipes/github-pages.html +10 -6
- package/dist/content/deployment/recipes/netlify.html +10 -6
- package/dist/content/deployment/recipes/vercel.html +10 -6
- package/dist/content/deployment/secrets-and-env-vars.html +10 -6
- package/dist/content/deployment.html +10 -6
- package/dist/content/guide/approaches.html +10 -6
- package/dist/content/guide/branding.html +510 -0
- package/dist/content/guide/data-driven-content.html +543 -0
- package/dist/content/guide/features.html +10 -6
- package/dist/content/guide/getting-started.html +10 -6
- package/dist/content/guide/kitfly-overview.html +10 -6
- package/dist/content/reference/configuration.html +135 -9
- package/dist/content/reference/design-catalog.html +10 -6
- package/dist/content/reference/environment-variables.html +50 -8
- package/dist/content/reference/glossary.html +24 -8
- package/dist/content/reference/key-concepts.html +33 -9
- package/dist/content/reference/plugins.html +22 -7
- package/dist/content/reference/slides-authoring-guidelines.html +422 -0
- package/dist/content/reference/structure.html +10 -6
- package/dist/content/reference.html +11 -6
- package/dist/content/templates/crucible.html +10 -6
- package/dist/content/templates/handbook.html +10 -6
- package/dist/content/templates/minimal.html +10 -6
- package/dist/content/templates/overview.html +10 -6
- package/dist/content/templates/pipeline.html +10 -6
- package/dist/content/templates/productbook.html +10 -6
- package/dist/content/templates/runbook.html +10 -6
- package/dist/content/templates/servicebook.html +10 -6
- package/dist/content-index.json +38 -2
- package/dist/docs/decisions/ADR-0001-minimalist-site-code.html +10 -6
- package/dist/docs/decisions/ADR-0002-ai-accessibility.html +10 -6
- package/dist/docs/decisions/ADR-0003-single-file-bundle.html +10 -6
- package/dist/docs/decisions/ADR-0004-bun-runtime.html +10 -6
- package/dist/docs/decisions/ADR-0005-plugin-contract-and-distribution.html +10 -6
- package/dist/docs/decisions/ADR-0006-data-driven-content.html +752 -0
- package/dist/docs/decisions/DDR-0001-viewport-locked-layout.html +10 -6
- package/dist/docs/decisions/DDR-0002-theme-system.html +10 -6
- package/dist/docs/decisions/DDR-0003-bounded-logo-slot.html +10 -6
- package/dist/docs/decisions/DDR-0004-slides-rendering-model.html +10 -6
- package/dist/docs/decisions/DDR-0005-deterministic-layout-boundary.html +10 -6
- package/dist/docs/userguide/cli/build.html +10 -6
- package/dist/docs/userguide/cli/bundle.html +10 -6
- package/dist/docs/userguide/cli/dev.html +10 -6
- package/dist/docs/userguide/cli/init.html +10 -6
- package/dist/docs/userguide/cli/servers.html +10 -6
- package/dist/docs/userguide/cli/stop.html +10 -6
- package/dist/docs/userguide/cli/update.html +10 -6
- package/dist/docs/userguide/cli/version.html +10 -6
- package/dist/docs/userguide/cli.html +10 -6
- package/dist/docs/userguide/sharing.html +10 -6
- package/dist/index.html +10 -6
- package/dist/llms.txt +3 -3
- package/dist/provenance.json +4 -4
- package/dist/schemas/plugin-registry.schema.html +10 -6
- package/dist/schemas/plugin-schemas-notes.html +10 -6
- package/dist/schemas/plugin.schema.html +10 -6
- package/dist/schemas/plugins.schema.html +10 -6
- package/dist/schemas/v0/common.schema.html +14 -10
- package/dist/schemas/v0/plugin-registry.schema.html +13 -9
- package/dist/schemas/v0/plugin.schema.html +13 -9
- package/dist/schemas/v0/plugins.schema.html +13 -9
- package/dist/schemas/v0/site.schema.html +67 -7
- package/dist/schemas/v0/theme.schema.html +21 -17
- package/dist/schemas.html +10 -6
- package/dist/styles.css +39 -4
- package/package.json +1 -1
- package/plugins-dist/latex-runtime.js +140 -0
- package/plugins-dist/latex.js +178 -0
- package/plugins-dist/slides-charts-lite-runtime.js +179 -0
- package/plugins-dist/slides-charts-lite.js +198 -0
- package/plugins-dist/slides-visuals.css +166 -0
- package/plugins-dist/slides-visuals.js +124 -33
- package/registry/plugins.yaml +30 -5
- package/schemas/v0/site.schema.json +56 -0
- package/scripts/build.ts +195 -70
- package/scripts/bundle.ts +122 -11
- package/scripts/dev.ts +345 -178
- package/src/__tests__/brief.test.ts +151 -0
- package/src/__tests__/build.test.ts +234 -4
- package/src/__tests__/bundle.test.ts +134 -0
- package/src/__tests__/dev-plugin-errors.test.ts +20 -0
- package/src/__tests__/fixtures/fences/slides-visuals/invalid/flow-branching-no-source.md +5 -0
- package/src/__tests__/fixtures/fences/slides-visuals/invalid/flow-converging-no-target.md +6 -0
- package/src/__tests__/fixtures/fences/slides-visuals/invalid/staircase-empty-steps.md +3 -0
- package/src/__tests__/fixtures/fences/slides-visuals/invalid/timeline-horizontal-no-events.md +2 -0
- package/src/__tests__/fixtures/fences/slides-visuals/valid/flow-branching-no-split.md +7 -0
- package/src/__tests__/fixtures/fences/slides-visuals/valid/flow-branching.md +8 -0
- package/src/__tests__/fixtures/fences/slides-visuals/valid/flow-converging-no-merge.md +7 -0
- package/src/__tests__/fixtures/fences/slides-visuals/valid/flow-converging.md +8 -0
- package/src/__tests__/fixtures/fences/slides-visuals/valid/staircase-down.md +7 -0
- package/src/__tests__/fixtures/fences/slides-visuals/valid/staircase.md +8 -0
- package/src/__tests__/fixtures/fences/slides-visuals/valid/timeline-horizontal.md +9 -0
- package/src/__tests__/fixtures/fences/slides-visuals/valid/timeline-vertical.md +10 -0
- package/src/__tests__/init.test.ts +51 -2
- package/src/__tests__/latex-runtime.bun.test.ts +35 -0
- package/src/__tests__/shared.test.ts +621 -1
- package/src/__tests__/slides-charts-lite-runtime.bun.test.ts +45 -0
- package/src/__tests__/slides-visuals-runtime-regressions.bun.test.ts +33 -0
- package/src/cli.ts +11 -4
- package/src/commands/init.ts +1 -1
- package/src/shared.ts +761 -18
- package/src/site/styles.css +39 -4
- package/src/site/template.html +5 -2
- package/src/templates/brief.ts +486 -0
- package/src/templates/deck.ts +59 -0
- package/src/templates/driver.ts +46 -13
- package/src/templates/handbook.ts +32 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
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
|
-
|
|
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(
|
|
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",
|