kitfly 0.2.1 → 0.2.4
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 +79 -0
- package/README.md +38 -21
- 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/gantt-widget.md +468 -0
- 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 +170 -1
- package/dist/_raw/docs/decisions/ADR-0006-data-driven-content.md +350 -0
- package/dist/content/deployment/preflight.html +11 -8
- package/dist/content/deployment/recipes/aws-s3.html +11 -8
- package/dist/content/deployment/recipes/cloudflare-pages.html +11 -8
- package/dist/content/deployment/recipes/cloudflare-r2.html +11 -8
- package/dist/content/deployment/recipes/fly-io.html +11 -8
- package/dist/content/deployment/recipes/github-pages.html +11 -8
- package/dist/content/deployment/recipes/netlify.html +11 -8
- package/dist/content/deployment/recipes/vercel.html +11 -8
- package/dist/content/deployment/secrets-and-env-vars.html +11 -8
- package/dist/content/deployment.html +11 -8
- package/dist/content/guide/approaches.html +11 -8
- package/dist/content/guide/branding.html +509 -0
- package/dist/content/guide/data-driven-content.html +542 -0
- package/dist/content/guide/features.html +11 -8
- package/dist/content/guide/getting-started.html +11 -8
- package/dist/content/guide/kitfly-overview.html +11 -8
- package/dist/content/reference/configuration.html +136 -11
- package/dist/content/reference/design-catalog.html +11 -8
- package/dist/content/reference/environment-variables.html +51 -10
- package/dist/content/reference/gantt-widget.html +899 -0
- package/dist/content/reference/glossary.html +25 -10
- package/dist/content/reference/key-concepts.html +34 -11
- package/dist/content/reference/plugins.html +261 -10
- package/dist/content/reference/slides-authoring-guidelines.html +11 -8
- package/dist/content/reference/structure.html +11 -8
- package/dist/content/reference.html +11 -8
- package/dist/content/templates/crucible.html +11 -8
- package/dist/content/templates/handbook.html +11 -8
- package/dist/content/templates/minimal.html +11 -8
- package/dist/content/templates/overview.html +11 -8
- package/dist/content/templates/pipeline.html +11 -8
- package/dist/content/templates/productbook.html +11 -8
- package/dist/content/templates/runbook.html +11 -8
- package/dist/content/templates/servicebook.html +11 -8
- package/dist/content-index.json +37 -2
- package/dist/docs/decisions/ADR-0001-minimalist-site-code.html +11 -8
- package/dist/docs/decisions/ADR-0002-ai-accessibility.html +11 -8
- package/dist/docs/decisions/ADR-0003-single-file-bundle.html +11 -8
- package/dist/docs/decisions/ADR-0004-bun-runtime.html +11 -8
- package/dist/docs/decisions/ADR-0005-plugin-contract-and-distribution.html +11 -8
- package/dist/docs/decisions/ADR-0006-data-driven-content.html +751 -0
- package/dist/docs/decisions/DDR-0001-viewport-locked-layout.html +11 -8
- package/dist/docs/decisions/DDR-0002-theme-system.html +11 -8
- package/dist/docs/decisions/DDR-0003-bounded-logo-slot.html +11 -8
- package/dist/docs/decisions/DDR-0004-slides-rendering-model.html +11 -8
- package/dist/docs/decisions/DDR-0005-deterministic-layout-boundary.html +11 -8
- package/dist/docs/userguide/cli/build.html +11 -8
- package/dist/docs/userguide/cli/bundle.html +11 -8
- package/dist/docs/userguide/cli/dev.html +11 -8
- package/dist/docs/userguide/cli/init.html +11 -8
- package/dist/docs/userguide/cli/servers.html +11 -8
- package/dist/docs/userguide/cli/stop.html +11 -8
- package/dist/docs/userguide/cli/update.html +11 -8
- package/dist/docs/userguide/cli/version.html +11 -8
- package/dist/docs/userguide/cli.html +11 -8
- package/dist/docs/userguide/sharing.html +11 -8
- package/dist/index.html +11 -8
- package/dist/llms.txt +3 -3
- package/dist/provenance.json +4 -5
- package/dist/reports/license-inventory.csv +199 -0
- package/dist/schemas/plugin-registry.schema.html +11 -8
- package/dist/schemas/plugin-schemas-notes.html +11 -8
- package/dist/schemas/plugin.schema.html +11 -8
- package/dist/schemas/plugins.schema.html +11 -8
- package/dist/schemas/v0/common.schema.html +15 -12
- package/dist/schemas/v0/plugin-registry.schema.html +14 -11
- package/dist/schemas/v0/plugin.schema.html +14 -11
- package/dist/schemas/v0/plugins.schema.html +14 -11
- package/dist/schemas/v0/site.schema.html +68 -9
- package/dist/schemas/v0/theme.schema.html +22 -19
- package/dist/schemas.html +11 -8
- 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/planning-visuals.css +261 -0
- package/plugins-dist/planning-visuals.js +669 -0
- package/plugins-dist/slides-charts-lite-runtime.js +179 -0
- package/plugins-dist/slides-charts-lite.js +198 -0
- package/registry/plugins.yaml +40 -1
- package/schemas/v0/site.schema.json +56 -0
- package/scripts/build-all.ts +5 -0
- package/scripts/build.ts +264 -80
- package/scripts/bundle.ts +188 -17
- package/scripts/dev.ts +294 -171
- package/scripts/embed-docs.ts +119 -0
- package/src/__tests__/brief.test.ts +151 -0
- package/src/__tests__/build.test.ts +293 -1
- package/src/__tests__/bundle.test.ts +195 -0
- package/src/__tests__/docs.test.ts +117 -0
- package/src/__tests__/fixtures/fences/planning-visuals/invalid/bad-month-format.md +10 -0
- package/src/__tests__/fixtures/fences/planning-visuals/invalid/marker-format-mismatch.md +13 -0
- package/src/__tests__/fixtures/fences/planning-visuals/invalid/milestone-format-mismatch.md +13 -0
- package/src/__tests__/fixtures/fences/planning-visuals/invalid/missing-tracks.md +5 -0
- package/src/__tests__/fixtures/fences/planning-visuals/invalid/track-reversed.md +10 -0
- package/src/__tests__/fixtures/fences/planning-visuals/valid/markers-basic.md +15 -0
- package/src/__tests__/fixtures/fences/planning-visuals/valid/markers-no-milestones.md +13 -0
- package/src/__tests__/fixtures/fences/planning-visuals/valid/month-basic.md +16 -0
- package/src/__tests__/fixtures/fences/planning-visuals/valid/no-milestones.md +10 -0
- package/src/__tests__/fixtures/fences/planning-visuals/valid/week-basic.md +20 -0
- package/src/__tests__/init.test.ts +51 -2
- package/src/__tests__/latex-runtime.bun.test.ts +35 -0
- package/src/__tests__/planning-visuals-fence-contract.test.ts +28 -0
- package/src/__tests__/planning-visuals-runtime-regressions.bun.test.ts +68 -0
- package/src/__tests__/planning-visuals-runtime.bun.test.ts +192 -0
- package/src/__tests__/shared.test.ts +719 -1
- package/src/__tests__/slides-charts-lite-runtime.bun.test.ts +45 -0
- package/src/cli.ts +124 -22
- package/src/commands/docs.ts +71 -0
- package/src/commands/init.ts +1 -1
- package/src/generated/embedded-docs.ts +2384 -0
- package/src/server-registry.ts +50 -10
- package/src/shared.ts +1174 -43
- 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()
|
|
@@ -513,17 +1008,16 @@ const SLIDES_VISUALS_TYPES = new Set([
|
|
|
513
1008
|
"staircase",
|
|
514
1009
|
]);
|
|
515
1010
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
{
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
> = {
|
|
1011
|
+
type VisualListRule =
|
|
1012
|
+
| { kind: "strings" }
|
|
1013
|
+
| { kind: "objects"; fields: string[]; optional?: string[] };
|
|
1014
|
+
type VisualRules = {
|
|
1015
|
+
required: string[];
|
|
1016
|
+
scalars: string[];
|
|
1017
|
+
lists: Record<string, VisualListRule>;
|
|
1018
|
+
};
|
|
1019
|
+
|
|
1020
|
+
const SLIDES_VISUALS_RULES: Record<string, VisualRules> = {
|
|
527
1021
|
kpi: {
|
|
528
1022
|
required: ["label", "value"],
|
|
529
1023
|
scalars: ["label", "value", "trend"],
|
|
@@ -596,11 +1090,44 @@ const SLIDES_VISUALS_RULES: Record<
|
|
|
596
1090
|
},
|
|
597
1091
|
};
|
|
598
1092
|
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
1093
|
+
const PLANNING_VISUALS_TYPES = new Set(["gantt"]);
|
|
1094
|
+
|
|
1095
|
+
const PLANNING_VISUALS_RULES: Record<string, VisualRules> = {
|
|
1096
|
+
gantt: {
|
|
1097
|
+
required: ["time-unit", "time-start", "time-end", "tracks"],
|
|
1098
|
+
scalars: ["label", "time-unit", "time-start", "time-end", "max-depth", "max-tracks", "today"],
|
|
1099
|
+
lists: {
|
|
1100
|
+
tracks: {
|
|
1101
|
+
kind: "objects",
|
|
1102
|
+
fields: ["label", "depth", "start", "end"],
|
|
1103
|
+
optional: ["status"],
|
|
1104
|
+
},
|
|
1105
|
+
milestones: {
|
|
1106
|
+
kind: "objects",
|
|
1107
|
+
fields: ["label", "date"],
|
|
1108
|
+
optional: ["depth"],
|
|
1109
|
+
},
|
|
1110
|
+
markers: {
|
|
1111
|
+
kind: "objects",
|
|
1112
|
+
fields: ["label", "date"],
|
|
1113
|
+
optional: ["color"],
|
|
1114
|
+
},
|
|
1115
|
+
},
|
|
1116
|
+
},
|
|
1117
|
+
};
|
|
1118
|
+
|
|
1119
|
+
function parseQuotedScalar(raw: string): string {
|
|
1120
|
+
const trimmed = raw.trim();
|
|
1121
|
+
const match = trimmed.match(/^"(.*)"$/) || trimmed.match(/^'(.*)'$/);
|
|
1122
|
+
return match ? match[1] : trimmed;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
function validateVisualFences(
|
|
1126
|
+
markdown: string,
|
|
1127
|
+
visualTypes: Set<string>,
|
|
1128
|
+
visualRules: Record<string, VisualRules>,
|
|
1129
|
+
unknownTypePrefix: string,
|
|
1130
|
+
): SlidesVisualsFenceDiagnostic[] {
|
|
604
1131
|
const diagnostics: SlidesVisualsFenceDiagnostic[] = [];
|
|
605
1132
|
const lines = markdown.replaceAll("\r\n", "\n").split("\n");
|
|
606
1133
|
|
|
@@ -618,7 +1145,7 @@ export function validateSlidesVisualsFences(markdown: string): SlidesVisualsFenc
|
|
|
618
1145
|
}
|
|
619
1146
|
|
|
620
1147
|
function finishFence(closeLine: number) {
|
|
621
|
-
const rules =
|
|
1148
|
+
const rules = visualRules[visualType];
|
|
622
1149
|
if (!rules) return;
|
|
623
1150
|
|
|
624
1151
|
for (const key of rules.required) {
|
|
@@ -664,10 +1191,10 @@ export function validateSlidesVisualsFences(markdown: string): SlidesVisualsFenc
|
|
|
664
1191
|
const m = raw.match(/^:::\s*([a-z0-9-]+)\s*$/i);
|
|
665
1192
|
if (!m) continue;
|
|
666
1193
|
const type = m[1].toLowerCase();
|
|
667
|
-
if (!
|
|
1194
|
+
if (!visualTypes.has(type)) {
|
|
668
1195
|
diagnostics.push({
|
|
669
1196
|
line: i + 1,
|
|
670
|
-
message:
|
|
1197
|
+
message: `${unknownTypePrefix}${type}`,
|
|
671
1198
|
type,
|
|
672
1199
|
});
|
|
673
1200
|
continue;
|
|
@@ -682,7 +1209,6 @@ export function validateSlidesVisualsFences(markdown: string): SlidesVisualsFenc
|
|
|
682
1209
|
continue;
|
|
683
1210
|
}
|
|
684
1211
|
|
|
685
|
-
// inside visual fence
|
|
686
1212
|
if (trimmed === ":::" && !raw.startsWith(":::")) {
|
|
687
1213
|
err(i + 1, "Closing ::: fence must start at column 0");
|
|
688
1214
|
continue;
|
|
@@ -702,13 +1228,12 @@ export function validateSlidesVisualsFences(markdown: string): SlidesVisualsFenc
|
|
|
702
1228
|
}
|
|
703
1229
|
|
|
704
1230
|
if (/^\s/.test(raw)) {
|
|
705
|
-
// list item or list continuation
|
|
706
1231
|
if (!currentListKey) {
|
|
707
1232
|
err(i + 1, "Indented content is only allowed inside a list");
|
|
708
1233
|
continue;
|
|
709
1234
|
}
|
|
710
1235
|
|
|
711
|
-
const listRule =
|
|
1236
|
+
const listRule = visualRules[visualType]?.lists[currentListKey];
|
|
712
1237
|
const item = raw.match(/^ {2}-\s+(.+)$/);
|
|
713
1238
|
if (item) {
|
|
714
1239
|
listItems += 1;
|
|
@@ -739,7 +1264,7 @@ export function validateSlidesVisualsFences(markdown: string): SlidesVisualsFenc
|
|
|
739
1264
|
continue;
|
|
740
1265
|
}
|
|
741
1266
|
|
|
742
|
-
const rules =
|
|
1267
|
+
const rules = visualRules[visualType];
|
|
743
1268
|
if (!rules) continue;
|
|
744
1269
|
|
|
745
1270
|
const kv = raw.match(/^([a-z][a-z0-9-]*)\s*:\s*(.*)$/i);
|
|
@@ -752,7 +1277,6 @@ export function validateSlidesVisualsFences(markdown: string): SlidesVisualsFenc
|
|
|
752
1277
|
const value = kv[2];
|
|
753
1278
|
|
|
754
1279
|
if (value === "") {
|
|
755
|
-
// list key
|
|
756
1280
|
const listRule = rules.lists[key];
|
|
757
1281
|
if (!listRule) {
|
|
758
1282
|
err(i + 1, `Key '${key}' is not a supported list for ${visualType}`);
|
|
@@ -764,7 +1288,6 @@ export function validateSlidesVisualsFences(markdown: string): SlidesVisualsFenc
|
|
|
764
1288
|
continue;
|
|
765
1289
|
}
|
|
766
1290
|
|
|
767
|
-
// scalar key
|
|
768
1291
|
if (!rules.scalars.includes(key)) {
|
|
769
1292
|
err(i + 1, `Key '${key}' is not a supported scalar for ${visualType}`);
|
|
770
1293
|
continue;
|
|
@@ -784,12 +1307,408 @@ export function validateSlidesVisualsFences(markdown: string): SlidesVisualsFenc
|
|
|
784
1307
|
return diagnostics;
|
|
785
1308
|
}
|
|
786
1309
|
|
|
1310
|
+
/**
|
|
1311
|
+
* Validate slides-visuals `:::` blocks in a single markdown slide body.
|
|
1312
|
+
* This contract is intentionally strict so writers/devs don’t guess at edge cases.
|
|
1313
|
+
*/
|
|
1314
|
+
export function validateSlidesVisualsFences(markdown: string): SlidesVisualsFenceDiagnostic[] {
|
|
1315
|
+
return validateVisualFences(
|
|
1316
|
+
markdown,
|
|
1317
|
+
SLIDES_VISUALS_TYPES,
|
|
1318
|
+
SLIDES_VISUALS_RULES,
|
|
1319
|
+
"Unknown slides-visuals block type: ",
|
|
1320
|
+
);
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
type ParsedPlanningBlock = {
|
|
1324
|
+
startLine: number;
|
|
1325
|
+
data: Record<string, unknown>;
|
|
1326
|
+
scalarLines: Record<string, number>;
|
|
1327
|
+
listLines: Record<string, number>;
|
|
1328
|
+
};
|
|
1329
|
+
|
|
1330
|
+
function parsePlanningGanttBlocks(markdown: string): ParsedPlanningBlock[] {
|
|
1331
|
+
const blocks: ParsedPlanningBlock[] = [];
|
|
1332
|
+
const lines = markdown.replaceAll("\r\n", "\n").split("\n");
|
|
1333
|
+
let mdFence: FenceState | null = null;
|
|
1334
|
+
let current: ParsedPlanningBlock | null = null;
|
|
1335
|
+
let currentList: string | null = null;
|
|
1336
|
+
let currentObject: Record<string, unknown> | null = null;
|
|
1337
|
+
|
|
1338
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1339
|
+
const raw = lines[i];
|
|
1340
|
+
const trimmed = raw.trim();
|
|
1341
|
+
const lineNo = i + 1;
|
|
1342
|
+
|
|
1343
|
+
mdFence = updateFenceState(trimmed, mdFence);
|
|
1344
|
+
if (mdFence) continue;
|
|
1345
|
+
|
|
1346
|
+
if (!current) {
|
|
1347
|
+
const open = raw.match(/^:::\s*([a-z0-9-]+)\s*$/i);
|
|
1348
|
+
if (!open || open[1].toLowerCase() !== "gantt") continue;
|
|
1349
|
+
current = {
|
|
1350
|
+
startLine: lineNo,
|
|
1351
|
+
data: {},
|
|
1352
|
+
scalarLines: {},
|
|
1353
|
+
listLines: {},
|
|
1354
|
+
};
|
|
1355
|
+
currentList = null;
|
|
1356
|
+
currentObject = null;
|
|
1357
|
+
continue;
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
if (raw.match(/^:::\s*$/)) {
|
|
1361
|
+
blocks.push(current);
|
|
1362
|
+
current = null;
|
|
1363
|
+
currentList = null;
|
|
1364
|
+
currentObject = null;
|
|
1365
|
+
continue;
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
const kv = raw.match(/^([a-z][a-z0-9-]*)\s*:\s*(.*)$/i);
|
|
1369
|
+
if (kv) {
|
|
1370
|
+
const key = kv[1].toLowerCase();
|
|
1371
|
+
const value = kv[2];
|
|
1372
|
+
if (value === "") {
|
|
1373
|
+
currentList = key;
|
|
1374
|
+
currentObject = null;
|
|
1375
|
+
current.listLines[key] = lineNo;
|
|
1376
|
+
if (!Array.isArray(current.data[key])) current.data[key] = [];
|
|
1377
|
+
} else {
|
|
1378
|
+
current.data[key] = parseQuotedScalar(value);
|
|
1379
|
+
current.scalarLines[key] = lineNo;
|
|
1380
|
+
currentList = null;
|
|
1381
|
+
currentObject = null;
|
|
1382
|
+
}
|
|
1383
|
+
continue;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
const item = raw.match(/^ {2}-\s+(.+)$/);
|
|
1387
|
+
if (item && currentList) {
|
|
1388
|
+
const list: unknown[] = Array.isArray(current.data[currentList])
|
|
1389
|
+
? (current.data[currentList] as unknown[])
|
|
1390
|
+
: [];
|
|
1391
|
+
current.data[currentList] = list;
|
|
1392
|
+
const objKV = item[1].match(/^([a-z][a-z0-9-]*)\s*:\s*(.+)$/i);
|
|
1393
|
+
if (objKV) {
|
|
1394
|
+
currentObject = { [objKV[1].toLowerCase()]: parseQuotedScalar(objKV[2]) };
|
|
1395
|
+
list.push(currentObject);
|
|
1396
|
+
} else {
|
|
1397
|
+
currentObject = null;
|
|
1398
|
+
list.push(parseQuotedScalar(item[1]));
|
|
1399
|
+
}
|
|
1400
|
+
continue;
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
const cont = raw.match(/^ {4}([a-z][a-z0-9-]*)\s*:\s*(.+)$/i);
|
|
1404
|
+
if (cont && currentObject) {
|
|
1405
|
+
currentObject[cont[1].toLowerCase()] = parseQuotedScalar(cont[2]);
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
return blocks;
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
function isoWeeksInYear(year: number): number {
|
|
1413
|
+
const dec28 = new Date(Date.UTC(year, 11, 28));
|
|
1414
|
+
return getIsoWeekInfo(Math.floor(dec28.getTime() / (24 * 60 * 60 * 1000))).week;
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
function isoWeekMondayUtcMs(year: number, week: number): number {
|
|
1418
|
+
const jan4 = new Date(Date.UTC(year, 0, 4));
|
|
1419
|
+
const jan4Weekday = (jan4.getUTCDay() + 6) % 7; // Monday=0
|
|
1420
|
+
const weekOneMondayMs = jan4.getTime() - jan4Weekday * 24 * 60 * 60 * 1000;
|
|
1421
|
+
return weekOneMondayMs + (week - 1) * 7 * 24 * 60 * 60 * 1000;
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
function parseWeekOrdinal(value: string): number | null {
|
|
1425
|
+
const isoWeekAnchorDay = -3; // 1969-12-29 (Monday of 1970-W01)
|
|
1426
|
+
const match = value.match(/^(\d{4})-W(\d{2})$/i);
|
|
1427
|
+
if (!match) return null;
|
|
1428
|
+
const year = Number.parseInt(match[1], 10);
|
|
1429
|
+
const week = Number.parseInt(match[2], 10);
|
|
1430
|
+
if (week < 1 || week > isoWeeksInYear(year)) return null;
|
|
1431
|
+
const dayMs = 24 * 60 * 60 * 1000;
|
|
1432
|
+
const mondayDayOrdinal = Math.floor(isoWeekMondayUtcMs(year, week) / dayMs);
|
|
1433
|
+
return Math.floor((mondayDayOrdinal - isoWeekAnchorDay) / 7);
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
function parseMonthOrdinal(value: string): number | null {
|
|
1437
|
+
const match = value.match(/^(\d{4})-(\d{2})$/);
|
|
1438
|
+
if (!match) return null;
|
|
1439
|
+
const year = Number.parseInt(match[1], 10);
|
|
1440
|
+
const month = Number.parseInt(match[2], 10);
|
|
1441
|
+
if (month < 1 || month > 12) return null;
|
|
1442
|
+
return year * 12 + (month - 1);
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
function daysInMonthUtc(year: number, month: number): number {
|
|
1446
|
+
return new Date(Date.UTC(year, month, 0)).getUTCDate();
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
function parsePlanningMonthMarkerPosition(value: string): number | null {
|
|
1450
|
+
const monthOrdinal = parseMonthOrdinal(value);
|
|
1451
|
+
if (monthOrdinal != null) return monthOrdinal + 0.5;
|
|
1452
|
+
|
|
1453
|
+
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
1454
|
+
if (!match) return null;
|
|
1455
|
+
const year = Number.parseInt(match[1], 10);
|
|
1456
|
+
const month = Number.parseInt(match[2], 10);
|
|
1457
|
+
const day = Number.parseInt(match[3], 10);
|
|
1458
|
+
if (month < 1 || month > 12) return null;
|
|
1459
|
+
const dim = daysInMonthUtc(year, month);
|
|
1460
|
+
if (day < 1 || day > dim) return null;
|
|
1461
|
+
const ordinal = year * 12 + (month - 1);
|
|
1462
|
+
return ordinal + (day - 0.5) / dim;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
function parsePlanningUnitOrdinal(value: unknown, unit: string): number | null {
|
|
1466
|
+
if (typeof value !== "string" || !value.trim()) return null;
|
|
1467
|
+
if (unit === "week") return parseWeekOrdinal(value.trim());
|
|
1468
|
+
if (unit === "month") return parseMonthOrdinal(value.trim());
|
|
1469
|
+
return null;
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
function parsePlanningMarkerPosition(value: unknown, unit: string): number | null {
|
|
1473
|
+
if (typeof value !== "string" || !value.trim()) return null;
|
|
1474
|
+
const raw = value.trim();
|
|
1475
|
+
if (unit === "week") {
|
|
1476
|
+
const ordinal = parseWeekOrdinal(raw);
|
|
1477
|
+
return ordinal == null ? null : ordinal + 0.5;
|
|
1478
|
+
}
|
|
1479
|
+
if (unit === "month") {
|
|
1480
|
+
return parsePlanningMonthMarkerPosition(raw);
|
|
1481
|
+
}
|
|
1482
|
+
return null;
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
function getIsoWeekInfo(dayOrdinal: number): { year: number; week: number } {
|
|
1486
|
+
const dayMs = 24 * 60 * 60 * 1000;
|
|
1487
|
+
const date = new Date(dayOrdinal * dayMs);
|
|
1488
|
+
const day = (date.getUTCDay() + 6) % 7; // Monday=0
|
|
1489
|
+
const thursday = new Date(date.getTime() + (3 - day) * dayMs);
|
|
1490
|
+
const year = thursday.getUTCFullYear();
|
|
1491
|
+
const firstThursday = new Date(Date.UTC(year, 0, 4));
|
|
1492
|
+
const firstThursdayDay = (firstThursday.getUTCDay() + 6) % 7;
|
|
1493
|
+
const firstThursdayOrdinal = Math.floor(firstThursday.getTime() / dayMs) + (3 - firstThursdayDay);
|
|
1494
|
+
const week = Math.floor((Math.floor(thursday.getTime() / dayMs) - firstThursdayOrdinal) / 7) + 1;
|
|
1495
|
+
return { year, week };
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
export function validatePlanningVisualsFences(markdown: string): SlidesVisualsFenceDiagnostic[] {
|
|
1499
|
+
const diagnostics = validateVisualFences(
|
|
1500
|
+
markdown,
|
|
1501
|
+
PLANNING_VISUALS_TYPES,
|
|
1502
|
+
PLANNING_VISUALS_RULES,
|
|
1503
|
+
"Unknown planning-visuals block type: ",
|
|
1504
|
+
);
|
|
1505
|
+
const ganttBlocks = parsePlanningGanttBlocks(markdown);
|
|
1506
|
+
|
|
1507
|
+
for (const block of ganttBlocks) {
|
|
1508
|
+
const unitRaw = block.data["time-unit"];
|
|
1509
|
+
const unit = typeof unitRaw === "string" ? unitRaw.trim().toLowerCase() : "";
|
|
1510
|
+
const unitLine = block.scalarLines["time-unit"] ?? block.startLine;
|
|
1511
|
+
const startLine = block.scalarLines["time-start"] ?? block.startLine;
|
|
1512
|
+
const endLine = block.scalarLines["time-end"] ?? block.startLine;
|
|
1513
|
+
const tracksLine = block.listLines.tracks ?? block.startLine;
|
|
1514
|
+
const milestonesLine = block.listLines.milestones ?? block.startLine;
|
|
1515
|
+
|
|
1516
|
+
if (unit !== "week" && unit !== "month") {
|
|
1517
|
+
diagnostics.push({
|
|
1518
|
+
line: unitLine,
|
|
1519
|
+
message: "Invalid time-unit (expected 'week' or 'month')",
|
|
1520
|
+
type: "gantt",
|
|
1521
|
+
});
|
|
1522
|
+
continue;
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
const axisStart = parsePlanningUnitOrdinal(block.data["time-start"], unit);
|
|
1526
|
+
const axisEnd = parsePlanningUnitOrdinal(block.data["time-end"], unit);
|
|
1527
|
+
if (axisStart == null) {
|
|
1528
|
+
diagnostics.push({
|
|
1529
|
+
line: startLine,
|
|
1530
|
+
message: `Invalid time-start format for ${unit}`,
|
|
1531
|
+
type: "gantt",
|
|
1532
|
+
});
|
|
1533
|
+
}
|
|
1534
|
+
if (axisEnd == null) {
|
|
1535
|
+
diagnostics.push({
|
|
1536
|
+
line: endLine,
|
|
1537
|
+
message: `Invalid time-end format for ${unit}`,
|
|
1538
|
+
type: "gantt",
|
|
1539
|
+
});
|
|
1540
|
+
}
|
|
1541
|
+
if (axisStart != null && axisEnd != null && axisStart >= axisEnd) {
|
|
1542
|
+
diagnostics.push({
|
|
1543
|
+
line: endLine,
|
|
1544
|
+
message: "time-start must be before time-end",
|
|
1545
|
+
type: "gantt",
|
|
1546
|
+
});
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
const todayRaw = block.data.today;
|
|
1550
|
+
if (todayRaw != null && parsePlanningUnitOrdinal(todayRaw, unit) == null) {
|
|
1551
|
+
diagnostics.push({
|
|
1552
|
+
line: block.scalarLines.today ?? block.startLine,
|
|
1553
|
+
message: `Invalid today format for ${unit}`,
|
|
1554
|
+
type: "gantt",
|
|
1555
|
+
});
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
const tracks = Array.isArray(block.data.tracks) ? block.data.tracks : [];
|
|
1559
|
+
for (const track of tracks) {
|
|
1560
|
+
if (!track || typeof track !== "object") {
|
|
1561
|
+
diagnostics.push({
|
|
1562
|
+
line: tracksLine,
|
|
1563
|
+
message: "Track items must be objects",
|
|
1564
|
+
type: "gantt",
|
|
1565
|
+
});
|
|
1566
|
+
continue;
|
|
1567
|
+
}
|
|
1568
|
+
const start = parsePlanningUnitOrdinal((track as Record<string, unknown>).start, unit);
|
|
1569
|
+
const end = parsePlanningUnitOrdinal((track as Record<string, unknown>).end, unit);
|
|
1570
|
+
if (start == null || end == null) {
|
|
1571
|
+
diagnostics.push({
|
|
1572
|
+
line: tracksLine,
|
|
1573
|
+
message: `Track start/end must match ${unit} format`,
|
|
1574
|
+
type: "gantt",
|
|
1575
|
+
});
|
|
1576
|
+
continue;
|
|
1577
|
+
}
|
|
1578
|
+
if (start > end) {
|
|
1579
|
+
diagnostics.push({
|
|
1580
|
+
line: tracksLine,
|
|
1581
|
+
message: "Track start must be before or equal to end",
|
|
1582
|
+
type: "gantt",
|
|
1583
|
+
});
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
const milestones = Array.isArray(block.data.milestones) ? block.data.milestones : [];
|
|
1588
|
+
for (const milestone of milestones) {
|
|
1589
|
+
if (!milestone || typeof milestone !== "object") {
|
|
1590
|
+
diagnostics.push({
|
|
1591
|
+
line: milestonesLine,
|
|
1592
|
+
message: "Milestone items must be objects",
|
|
1593
|
+
type: "gantt",
|
|
1594
|
+
});
|
|
1595
|
+
continue;
|
|
1596
|
+
}
|
|
1597
|
+
const date = parsePlanningUnitOrdinal((milestone as Record<string, unknown>).date, unit);
|
|
1598
|
+
if (date == null) {
|
|
1599
|
+
diagnostics.push({
|
|
1600
|
+
line: milestonesLine,
|
|
1601
|
+
message: `Milestone date must match ${unit} format`,
|
|
1602
|
+
type: "gantt",
|
|
1603
|
+
});
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
const markersLine = block.listLines.markers ?? block.startLine;
|
|
1608
|
+
const markers = Array.isArray(block.data.markers) ? block.data.markers : [];
|
|
1609
|
+
for (const marker of markers) {
|
|
1610
|
+
if (!marker || typeof marker !== "object") {
|
|
1611
|
+
diagnostics.push({
|
|
1612
|
+
line: markersLine,
|
|
1613
|
+
message: "Marker items must be objects",
|
|
1614
|
+
type: "gantt",
|
|
1615
|
+
});
|
|
1616
|
+
continue;
|
|
1617
|
+
}
|
|
1618
|
+
const date = parsePlanningMarkerPosition((marker as Record<string, unknown>).date, unit);
|
|
1619
|
+
if (date == null) {
|
|
1620
|
+
const expected =
|
|
1621
|
+
unit === "month" ? "month format (YYYY-MM or YYYY-MM-DD)" : "week format (YYYY-Www)";
|
|
1622
|
+
diagnostics.push({
|
|
1623
|
+
line: markersLine,
|
|
1624
|
+
message: `Marker date must match ${expected}`,
|
|
1625
|
+
type: "gantt",
|
|
1626
|
+
});
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
return diagnostics;
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
export function collectPlanningVisualsContainmentWarnings(
|
|
1635
|
+
markdown: string,
|
|
1636
|
+
): SlidesVisualsFenceDiagnostic[] {
|
|
1637
|
+
const warnings: SlidesVisualsFenceDiagnostic[] = [];
|
|
1638
|
+
const ganttBlocks = parsePlanningGanttBlocks(markdown);
|
|
1639
|
+
|
|
1640
|
+
for (const block of ganttBlocks) {
|
|
1641
|
+
const unitRaw = block.data["time-unit"];
|
|
1642
|
+
const unit = typeof unitRaw === "string" ? unitRaw.trim().toLowerCase() : "";
|
|
1643
|
+
if (unit !== "week" && unit !== "month") continue;
|
|
1644
|
+
|
|
1645
|
+
const axisStart = parsePlanningUnitOrdinal(block.data["time-start"], unit);
|
|
1646
|
+
const axisEnd = parsePlanningUnitOrdinal(block.data["time-end"], unit);
|
|
1647
|
+
if (axisStart == null || axisEnd == null || axisStart >= axisEnd) continue;
|
|
1648
|
+
|
|
1649
|
+
const tracksLine = block.listLines.tracks ?? block.startLine;
|
|
1650
|
+
const milestonesLine = block.listLines.milestones ?? block.startLine;
|
|
1651
|
+
|
|
1652
|
+
const tracks = Array.isArray(block.data.tracks) ? block.data.tracks : [];
|
|
1653
|
+
for (const track of tracks) {
|
|
1654
|
+
if (!track || typeof track !== "object") continue;
|
|
1655
|
+
const start = parsePlanningUnitOrdinal((track as Record<string, unknown>).start, unit);
|
|
1656
|
+
const end = parsePlanningUnitOrdinal((track as Record<string, unknown>).end, unit);
|
|
1657
|
+
if (start == null || end == null) continue;
|
|
1658
|
+
if (start < axisStart || end > axisEnd) {
|
|
1659
|
+
warnings.push({
|
|
1660
|
+
line: tracksLine,
|
|
1661
|
+
message: "Track range is outside axis and will be clipped",
|
|
1662
|
+
type: "gantt",
|
|
1663
|
+
});
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
const milestones = Array.isArray(block.data.milestones) ? block.data.milestones : [];
|
|
1668
|
+
for (const milestone of milestones) {
|
|
1669
|
+
if (!milestone || typeof milestone !== "object") continue;
|
|
1670
|
+
const date = parsePlanningUnitOrdinal((milestone as Record<string, unknown>).date, unit);
|
|
1671
|
+
if (date == null) continue;
|
|
1672
|
+
if (date < axisStart || date > axisEnd) {
|
|
1673
|
+
warnings.push({
|
|
1674
|
+
line: milestonesLine,
|
|
1675
|
+
message: "Milestone date is outside axis and will not be rendered",
|
|
1676
|
+
type: "gantt",
|
|
1677
|
+
});
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
const markersLine = block.listLines.markers ?? block.startLine;
|
|
1682
|
+
const markers = Array.isArray(block.data.markers) ? block.data.markers : [];
|
|
1683
|
+
for (const marker of markers) {
|
|
1684
|
+
if (!marker || typeof marker !== "object") continue;
|
|
1685
|
+
const position = parsePlanningMarkerPosition((marker as Record<string, unknown>).date, unit);
|
|
1686
|
+
if (position == null) continue;
|
|
1687
|
+
if (position < axisStart || position > axisEnd + 1) {
|
|
1688
|
+
warnings.push({
|
|
1689
|
+
line: markersLine,
|
|
1690
|
+
message: "Marker date is outside axis and will not be rendered",
|
|
1691
|
+
type: "gantt",
|
|
1692
|
+
});
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
return warnings;
|
|
1698
|
+
}
|
|
1699
|
+
|
|
787
1700
|
export function filterUnknownSlidesVisualsTypeDiagnostics(
|
|
788
1701
|
diagnostics: SlidesVisualsFenceDiagnostic[],
|
|
789
1702
|
): SlidesVisualsFenceDiagnostic[] {
|
|
790
1703
|
return diagnostics.filter((d) => !d.message.startsWith("Unknown slides-visuals block type:"));
|
|
791
1704
|
}
|
|
792
1705
|
|
|
1706
|
+
export function filterUnknownPlanningVisualsTypeDiagnostics(
|
|
1707
|
+
diagnostics: SlidesVisualsFenceDiagnostic[],
|
|
1708
|
+
): SlidesVisualsFenceDiagnostic[] {
|
|
1709
|
+
return diagnostics.filter((d) => !d.message.startsWith("Unknown planning-visuals block type:"));
|
|
1710
|
+
}
|
|
1711
|
+
|
|
793
1712
|
/**
|
|
794
1713
|
* Split markdown content into slide chunks using explicit delimiter.
|
|
795
1714
|
* Delimiter lines inside fenced code blocks are ignored.
|
|
@@ -884,7 +1803,10 @@ export function segmentSlides(content: string, fallbackTitle: string): SlideSegm
|
|
|
884
1803
|
* Collect slide content objects from discovered content files.
|
|
885
1804
|
* Markdown files can produce multiple slides via explicit delimiters.
|
|
886
1805
|
*/
|
|
887
|
-
export async function collectSlides(
|
|
1806
|
+
export async function collectSlides(
|
|
1807
|
+
files: ContentFile[],
|
|
1808
|
+
options: CollectSlidesOptions = {},
|
|
1809
|
+
): Promise<SlideContent[]> {
|
|
888
1810
|
const slides: SlideContent[] = [];
|
|
889
1811
|
let index = 0;
|
|
890
1812
|
|
|
@@ -893,7 +1815,8 @@ export async function collectSlides(files: ContentFile[]): Promise<SlideContent[
|
|
|
893
1815
|
const stem = basename(file.path, extname(file.path));
|
|
894
1816
|
|
|
895
1817
|
if (file.path.endsWith(".md")) {
|
|
896
|
-
const
|
|
1818
|
+
const markdown = options.markdownTransform ? await options.markdownTransform(raw, file) : raw;
|
|
1819
|
+
const segments = segmentSlides(markdown, stem);
|
|
897
1820
|
for (const segment of segments) {
|
|
898
1821
|
index += 1;
|
|
899
1822
|
slides.push({
|
|
@@ -926,7 +1849,82 @@ export async function collectSlides(files: ContentFile[]): Promise<SlideContent[
|
|
|
926
1849
|
return slides;
|
|
927
1850
|
}
|
|
928
1851
|
|
|
929
|
-
|
|
1852
|
+
interface SlideNavGroup {
|
|
1853
|
+
name: string;
|
|
1854
|
+
groups: Map<string, SlideNavGroup>;
|
|
1855
|
+
slides: SlideContent[];
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
function sectionRelativePath(sourceUrlPath: string, sectionBase: string): string {
|
|
1859
|
+
if (!sectionBase) return sourceUrlPath;
|
|
1860
|
+
if (sourceUrlPath === sectionBase) return "";
|
|
1861
|
+
if (sourceUrlPath.startsWith(`${sectionBase}/`))
|
|
1862
|
+
return sourceUrlPath.slice(sectionBase.length + 1);
|
|
1863
|
+
return sourceUrlPath;
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
function toTitleCaseSlug(segment: string): string {
|
|
1867
|
+
return segment
|
|
1868
|
+
.split(/[-_]/)
|
|
1869
|
+
.filter(Boolean)
|
|
1870
|
+
.map((token) => token.charAt(0).toUpperCase() + token.slice(1))
|
|
1871
|
+
.join(" ");
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
function createSlideNavGroup(name: string): SlideNavGroup {
|
|
1875
|
+
return { name, groups: new Map(), slides: [] };
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
function buildSlideSectionTree(items: SlideContent[], sectionBase: string): SlideNavGroup {
|
|
1879
|
+
const root = createSlideNavGroup("");
|
|
1880
|
+
for (const slide of items) {
|
|
1881
|
+
const rel = sectionRelativePath(slide.sourceUrlPath, sectionBase);
|
|
1882
|
+
const segments = rel.split("/").filter(Boolean);
|
|
1883
|
+
segments.pop(); // Drop file stem so nav groups only reflect subfolders.
|
|
1884
|
+
|
|
1885
|
+
let node = root;
|
|
1886
|
+
for (const segment of segments) {
|
|
1887
|
+
let next = node.groups.get(segment);
|
|
1888
|
+
if (!next) {
|
|
1889
|
+
next = createSlideNavGroup(toTitleCaseSlug(segment));
|
|
1890
|
+
node.groups.set(segment, next);
|
|
1891
|
+
}
|
|
1892
|
+
node = next;
|
|
1893
|
+
}
|
|
1894
|
+
node.slides.push(slide);
|
|
1895
|
+
}
|
|
1896
|
+
return root;
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
function slideGroupContains(group: SlideNavGroup, currentSlideId: string | undefined): boolean {
|
|
1900
|
+
if (!currentSlideId) return false;
|
|
1901
|
+
if (group.slides.some((slide) => slide.id === currentSlideId)) return true;
|
|
1902
|
+
for (const child of group.groups.values()) {
|
|
1903
|
+
if (slideGroupContains(child, currentSlideId)) return true;
|
|
1904
|
+
}
|
|
1905
|
+
return false;
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
function renderSlideGroup(group: SlideNavGroup, currentSlideId?: string): string {
|
|
1909
|
+
let html = "<ul>";
|
|
1910
|
+
|
|
1911
|
+
for (const slide of group.slides) {
|
|
1912
|
+
const active = currentSlideId === slide.id ? ' class="active"' : "";
|
|
1913
|
+
html += `<li><a href="#${slide.id}"${active}>${escapeHtml(slide.title)}</a></li>`;
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
for (const child of group.groups.values()) {
|
|
1917
|
+
const open = slideGroupContains(child, currentSlideId) ? " open" : "";
|
|
1918
|
+
html += `<li><details${open}><summary class="nav-group">${escapeHtml(child.name)}</summary>`;
|
|
1919
|
+
html += renderSlideGroup(child, currentSlideId);
|
|
1920
|
+
html += "</details></li>";
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
html += "</ul>";
|
|
1924
|
+
return html;
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
export function buildSlideNavHierarchical(
|
|
930
1928
|
slides: SlideContent[],
|
|
931
1929
|
config: SiteConfig,
|
|
932
1930
|
currentSlideId?: string,
|
|
@@ -941,17 +1939,25 @@ export function buildSlideNav(
|
|
|
941
1939
|
for (const section of config.sections) {
|
|
942
1940
|
const items = grouped.get(section.name);
|
|
943
1941
|
if (!items || items.length === 0) continue;
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
html += "</ul></li>";
|
|
1942
|
+
const sectionBase = section.path.replace(/^\/+|\/+$/g, "");
|
|
1943
|
+
const tree = buildSlideSectionTree(items, sectionBase);
|
|
1944
|
+
html += `<li><span class="nav-section">${escapeHtml(section.name)}</span>`;
|
|
1945
|
+
html += renderSlideGroup(tree, currentSlideId);
|
|
1946
|
+
html += "</li>";
|
|
950
1947
|
}
|
|
951
1948
|
html += "</ul>";
|
|
952
1949
|
return html;
|
|
953
1950
|
}
|
|
954
1951
|
|
|
1952
|
+
// Backwards-compatible alias.
|
|
1953
|
+
export function buildSlideNav(
|
|
1954
|
+
slides: SlideContent[],
|
|
1955
|
+
config: SiteConfig,
|
|
1956
|
+
currentSlideId?: string,
|
|
1957
|
+
): string {
|
|
1958
|
+
return buildSlideNavHierarchical(slides, config, currentSlideId);
|
|
1959
|
+
}
|
|
1960
|
+
|
|
955
1961
|
function resolveRelativeContentPath(pathOrRef: string, currentUrlPath?: string): string {
|
|
956
1962
|
let cleaned = pathOrRef;
|
|
957
1963
|
if (currentUrlPath && !cleaned.startsWith("/")) {
|
|
@@ -1478,10 +2484,68 @@ export function buildPageMeta(frontmatter: Record<string, unknown>): string {
|
|
|
1478
2484
|
return `<div class="page-meta">Last updated: ${formatted}</div>`;
|
|
1479
2485
|
}
|
|
1480
2486
|
|
|
2487
|
+
interface LogoImgHtmlOptions {
|
|
2488
|
+
logo: string;
|
|
2489
|
+
logoDark?: string;
|
|
2490
|
+
alt: string;
|
|
2491
|
+
className?: string;
|
|
2492
|
+
pathPrefix?: string;
|
|
2493
|
+
onerrorFallback?: boolean;
|
|
2494
|
+
style?: string;
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
export function buildLogoImgHtml(options: LogoImgHtmlOptions): string {
|
|
2498
|
+
const className = options.className || "logo-img";
|
|
2499
|
+
const pathPrefix = options.pathPrefix || "";
|
|
2500
|
+
const onerror = options.onerrorFallback
|
|
2501
|
+
? `onerror="this.onerror=null;this.style.display='none';this.parentElement.classList.add('logo-fallback')"`
|
|
2502
|
+
: `onerror="this.onerror=null;this.style.display='none'"`;
|
|
2503
|
+
const style = options.style ? `style="${escapeHtml(options.style)}"` : "";
|
|
2504
|
+
const lightSrc = `${pathPrefix}${options.logo}`;
|
|
2505
|
+
const alt = escapeHtml(options.alt);
|
|
2506
|
+
|
|
2507
|
+
if (!options.logoDark) {
|
|
2508
|
+
return `<img src="${escapeHtml(lightSrc)}" alt="${alt}" class="${className}" ${style} ${onerror}>`;
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
const darkSrc = `${pathPrefix}${options.logoDark}`;
|
|
2512
|
+
return `<img src="${escapeHtml(lightSrc)}" alt="${alt}" class="${className} logo-light" ${style} ${onerror}>
|
|
2513
|
+
<img src="${escapeHtml(darkSrc)}" alt="${alt}" class="${className} logo-dark" ${style} onerror="this.onerror=null;this.style.display='none'">`;
|
|
2514
|
+
}
|
|
2515
|
+
|
|
1481
2516
|
/**
|
|
1482
2517
|
* Build footer HTML from provenance
|
|
1483
2518
|
*/
|
|
1484
|
-
|
|
2519
|
+
function renderFooterLogo(
|
|
2520
|
+
footer: SiteFooter,
|
|
2521
|
+
config: SiteConfig,
|
|
2522
|
+
pathPrefix: string,
|
|
2523
|
+
logoOverride?: string,
|
|
2524
|
+
logoDarkOverride?: string,
|
|
2525
|
+
): string {
|
|
2526
|
+
const footerLogo = logoOverride || footer.logo;
|
|
2527
|
+
if (!footerLogo) return "";
|
|
2528
|
+
|
|
2529
|
+
const altText = footer.logoAlt || footer.copyright || config.brand.name;
|
|
2530
|
+
const logoHeight = footer.logoHeight ?? 20;
|
|
2531
|
+
const logoDark = logoDarkOverride || footer.logoDark;
|
|
2532
|
+
const image = buildLogoImgHtml({
|
|
2533
|
+
logo: footerLogo,
|
|
2534
|
+
logoDark,
|
|
2535
|
+
alt: altText,
|
|
2536
|
+
className: "footer-logo-img",
|
|
2537
|
+
pathPrefix: logoOverride ? "" : pathPrefix,
|
|
2538
|
+
onerrorFallback: false,
|
|
2539
|
+
style: `max-height: ${logoHeight}px`,
|
|
2540
|
+
});
|
|
2541
|
+
const wrapped = footer.logoUrl
|
|
2542
|
+
? `<a href="${escapeHtml(footer.logoUrl)}" class="footer-logo-link">${image}</a>`
|
|
2543
|
+
: `<span class="footer-logo-link">${image}</span>`;
|
|
2544
|
+
|
|
2545
|
+
return `${wrapped}<span class="footer-separator">·</span>`;
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
export function buildFooter(provenance: Provenance, config: SiteConfig, pathPrefix = ""): string {
|
|
1485
2549
|
const commitDate = formatDate(provenance.gitCommitDate);
|
|
1486
2550
|
const publishYear = Number.isNaN(new Date(provenance.gitCommitDate).getTime())
|
|
1487
2551
|
? new Date().getFullYear().toString()
|
|
@@ -1510,11 +2574,13 @@ export function buildFooter(provenance: Provenance, config: SiteConfig): string
|
|
|
1510
2574
|
? `<span class="footer-version">v${escapeHtml(provenance.version)}</span>
|
|
1511
2575
|
<span class="footer-separator">·</span>`
|
|
1512
2576
|
: "";
|
|
2577
|
+
const footerLogoHtml = renderFooterLogo(footer, config, pathPrefix);
|
|
1513
2578
|
|
|
1514
2579
|
return `
|
|
1515
2580
|
<footer class="site-footer">
|
|
1516
2581
|
<div class="footer-content">
|
|
1517
2582
|
<div class="footer-left">
|
|
2583
|
+
${footerLogoHtml}
|
|
1518
2584
|
${versionHtml}
|
|
1519
2585
|
<span class="footer-commit" title="Commit: ${escapeHtml(provenance.gitCommit)}">Published ${commitDate}</span>
|
|
1520
2586
|
</div>
|
|
@@ -1536,7 +2602,12 @@ export function buildFooter(provenance: Provenance, config: SiteConfig): string
|
|
|
1536
2602
|
/**
|
|
1537
2603
|
* Build bundle footer HTML.
|
|
1538
2604
|
*/
|
|
1539
|
-
export function buildBundleFooter(
|
|
2605
|
+
export function buildBundleFooter(
|
|
2606
|
+
version: string | undefined,
|
|
2607
|
+
config: SiteConfig,
|
|
2608
|
+
logoOverride?: string,
|
|
2609
|
+
logoDarkOverride?: string,
|
|
2610
|
+
): string {
|
|
1540
2611
|
const footer = config.footer || {};
|
|
1541
2612
|
const copyrightText = footer.copyright
|
|
1542
2613
|
? escapeHtml(footer.copyright)
|
|
@@ -1561,11 +2632,13 @@ export function buildBundleFooter(version: string | undefined, config: SiteConfi
|
|
|
1561
2632
|
? `<span class="footer-version">v${escapeHtml(version)}</span>
|
|
1562
2633
|
<span class="footer-separator">·</span>`
|
|
1563
2634
|
: "";
|
|
2635
|
+
const footerLogoHtml = renderFooterLogo(footer, config, "", logoOverride, logoDarkOverride);
|
|
1564
2636
|
|
|
1565
2637
|
return `
|
|
1566
2638
|
<footer class="site-footer">
|
|
1567
2639
|
<div class="footer-content">
|
|
1568
2640
|
<div class="footer-left">
|
|
2641
|
+
${footerLogoHtml}
|
|
1569
2642
|
${versionHtml}
|
|
1570
2643
|
<span class="footer-commit">Published (offline bundle)</span>
|
|
1571
2644
|
</div>
|
|
@@ -1763,6 +2836,7 @@ function normalizeFooter(footer: unknown): SiteFooter | undefined {
|
|
|
1763
2836
|
if (!footer || typeof footer !== "object") return undefined;
|
|
1764
2837
|
const raw = footer as Record<string, unknown>;
|
|
1765
2838
|
let links: FooterLink[] | undefined;
|
|
2839
|
+
let logoHeight: number | undefined;
|
|
1766
2840
|
|
|
1767
2841
|
if (Array.isArray(raw.links)) {
|
|
1768
2842
|
links = raw.links
|
|
@@ -1779,15 +2853,66 @@ function normalizeFooter(footer: unknown): SiteFooter | undefined {
|
|
|
1779
2853
|
console.warn("⚠ site.yaml footer.links supports at most 10 links; truncating extras.");
|
|
1780
2854
|
}
|
|
1781
2855
|
}
|
|
2856
|
+
const parsedLogoHeight =
|
|
2857
|
+
typeof raw.logoHeight === "number"
|
|
2858
|
+
? raw.logoHeight
|
|
2859
|
+
: typeof raw.logoHeight === "string"
|
|
2860
|
+
? Number.parseInt(raw.logoHeight, 10)
|
|
2861
|
+
: NaN;
|
|
2862
|
+
if (Number.isInteger(parsedLogoHeight)) {
|
|
2863
|
+
logoHeight = Math.max(10, Math.min(40, parsedLogoHeight));
|
|
2864
|
+
}
|
|
1782
2865
|
|
|
1783
2866
|
return {
|
|
1784
2867
|
copyright: typeof raw.copyright === "string" ? raw.copyright : undefined,
|
|
1785
2868
|
copyrightUrl: typeof raw.copyrightUrl === "string" ? raw.copyrightUrl : undefined,
|
|
1786
2869
|
links,
|
|
1787
2870
|
attribution: typeof raw.attribution === "boolean" ? raw.attribution : undefined,
|
|
2871
|
+
logo: typeof raw.logo === "string" ? raw.logo : undefined,
|
|
2872
|
+
logoDark: typeof raw.logoDark === "string" ? raw.logoDark : undefined,
|
|
2873
|
+
logoUrl: typeof raw.logoUrl === "string" ? raw.logoUrl : undefined,
|
|
2874
|
+
logoAlt: typeof raw.logoAlt === "string" ? raw.logoAlt : undefined,
|
|
2875
|
+
logoHeight,
|
|
1788
2876
|
};
|
|
1789
2877
|
}
|
|
1790
2878
|
|
|
2879
|
+
function normalizeProfiles(raw: unknown): Record<string, ProfileConfig> | undefined {
|
|
2880
|
+
if (!raw || typeof raw !== "object") return undefined;
|
|
2881
|
+
const entries = Object.entries(raw as Record<string, unknown>);
|
|
2882
|
+
if (entries.length === 0) return undefined;
|
|
2883
|
+
|
|
2884
|
+
const profiles: Record<string, ProfileConfig> = {};
|
|
2885
|
+
for (const [name, value] of entries) {
|
|
2886
|
+
if (!value || typeof value !== "object") continue;
|
|
2887
|
+
const profileRaw = value as Record<string, unknown>;
|
|
2888
|
+
const tags = normalizeProfileTags(
|
|
2889
|
+
(profileRaw.include as Record<string, unknown> | undefined)?.tags,
|
|
2890
|
+
);
|
|
2891
|
+
profiles[name.trim().toLowerCase()] = {
|
|
2892
|
+
description: typeof profileRaw.description === "string" ? profileRaw.description : undefined,
|
|
2893
|
+
include: tags.length > 0 ? { tags } : undefined,
|
|
2894
|
+
};
|
|
2895
|
+
}
|
|
2896
|
+
|
|
2897
|
+
return Object.keys(profiles).length > 0 ? profiles : undefined;
|
|
2898
|
+
}
|
|
2899
|
+
|
|
2900
|
+
function normalizePrebuild(raw: unknown): PrebuildHook[] | undefined {
|
|
2901
|
+
if (!Array.isArray(raw)) return undefined;
|
|
2902
|
+
const hooks = raw
|
|
2903
|
+
.map((entry) => {
|
|
2904
|
+
if (!entry || typeof entry !== "object") return null;
|
|
2905
|
+
const record = entry as Record<string, unknown>;
|
|
2906
|
+
if (typeof record.command !== "string" || !record.command.trim()) return null;
|
|
2907
|
+
const watch = Array.isArray(record.watch)
|
|
2908
|
+
? record.watch.filter((item): item is string => typeof item === "string" && !!item.trim())
|
|
2909
|
+
: undefined;
|
|
2910
|
+
return { command: record.command.trim(), watch } as PrebuildHook;
|
|
2911
|
+
})
|
|
2912
|
+
.filter((hook): hook is PrebuildHook => !!hook);
|
|
2913
|
+
return hooks.length > 0 ? hooks : undefined;
|
|
2914
|
+
}
|
|
2915
|
+
|
|
1791
2916
|
/**
|
|
1792
2917
|
* Load site configuration with fallback chain
|
|
1793
2918
|
* @param root - The root directory
|
|
@@ -1811,6 +2936,7 @@ export async function loadSiteConfig(
|
|
|
1811
2936
|
|
|
1812
2937
|
return {
|
|
1813
2938
|
docroot: parsed.docroot || ".",
|
|
2939
|
+
dataroot: typeof parsedRecord.dataroot === "string" ? parsedRecord.dataroot : "data",
|
|
1814
2940
|
title: parsed.title,
|
|
1815
2941
|
version: typeof parsedRecord.version === "string" ? parsedRecord.version : undefined,
|
|
1816
2942
|
mode: parsedRecord.mode === "slides" ? "slides" : "docs",
|
|
@@ -1825,12 +2951,15 @@ export async function loadSiteConfig(
|
|
|
1825
2951
|
brand: {
|
|
1826
2952
|
...parsed.brand,
|
|
1827
2953
|
logo: parsed.brand.logo || "assets/brand/logo.png",
|
|
2954
|
+
logoDark: typeof parsed.brand.logoDark === "string" ? parsed.brand.logoDark : undefined,
|
|
1828
2955
|
favicon: parsed.brand.favicon || "assets/brand/favicon.png",
|
|
1829
2956
|
logoType: parsed.brand.logoType || "icon",
|
|
1830
2957
|
},
|
|
1831
2958
|
sections: parsed.sections,
|
|
1832
2959
|
footer: normalizeFooter(parsedRecord.footer),
|
|
1833
2960
|
server: parsed.server,
|
|
2961
|
+
profiles: normalizeProfiles(parsedRecord.profiles),
|
|
2962
|
+
prebuild: normalizePrebuild(parsedRecord.prebuild),
|
|
1834
2963
|
};
|
|
1835
2964
|
} catch (e) {
|
|
1836
2965
|
if ((e as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
@@ -1859,6 +2988,7 @@ export async function loadSiteConfig(
|
|
|
1859
2988
|
if (sections.length > 0) {
|
|
1860
2989
|
return {
|
|
1861
2990
|
docroot: "content",
|
|
2991
|
+
dataroot: "data",
|
|
1862
2992
|
title: "Documentation",
|
|
1863
2993
|
mode: "docs",
|
|
1864
2994
|
aspect: "16/9",
|
|
@@ -1873,6 +3003,7 @@ export async function loadSiteConfig(
|
|
|
1873
3003
|
// Final fallback
|
|
1874
3004
|
return {
|
|
1875
3005
|
docroot: ".",
|
|
3006
|
+
dataroot: "data",
|
|
1876
3007
|
title: defaultTitle,
|
|
1877
3008
|
mode: "docs",
|
|
1878
3009
|
aspect: "16/9",
|