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.
- package/CHANGELOG.md +56 -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/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 +10 -6
- package/dist/content/reference/structure.html +10 -6
- package/dist/content/reference.html +10 -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 +29 -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/registry/plugins.yaml +25 -0
- package/schemas/v0/site.schema.json +56 -0
- package/scripts/build.ts +191 -69
- package/scripts/bundle.ts +118 -10
- package/scripts/dev.ts +245 -166
- package/src/__tests__/brief.test.ts +151 -0
- package/src/__tests__/build.test.ts +169 -1
- package/src/__tests__/bundle.test.ts +134 -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 +598 -1
- package/src/__tests__/slides-charts-lite-runtime.bun.test.ts +45 -0
- package/src/cli.ts +11 -4
- package/src/commands/init.ts +1 -1
- package/src/shared.ts +725 -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()
|
|
@@ -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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
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
|
-
|
|
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(
|
|
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",
|