kitfly 0.1.2
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 +60 -0
- package/LICENSE +21 -0
- package/README.md +136 -0
- package/VERSION +1 -0
- package/package.json +63 -0
- package/schemas/README.md +32 -0
- package/schemas/site.schema.json +5 -0
- package/schemas/theme.schema.json +5 -0
- package/schemas/v0/site.schema.json +172 -0
- package/schemas/v0/theme.schema.json +210 -0
- package/scripts/build-all.ts +121 -0
- package/scripts/build.ts +601 -0
- package/scripts/bundle.ts +781 -0
- package/scripts/dev.ts +777 -0
- package/scripts/generate-checksums.sh +78 -0
- package/scripts/release/export-release-key.sh +28 -0
- package/scripts/release/release-guard-tag-version.sh +79 -0
- package/scripts/release/sign-release-assets.sh +123 -0
- package/scripts/release/upload-release-assets.sh +76 -0
- package/scripts/release/upload-release-provenance.sh +52 -0
- package/scripts/release/verify-public-key.sh +48 -0
- package/scripts/release/verify-signatures.sh +117 -0
- package/scripts/version-sync.ts +82 -0
- package/src/__tests__/build.test.ts +240 -0
- package/src/__tests__/bundle.test.ts +786 -0
- package/src/__tests__/cli.test.ts +706 -0
- package/src/__tests__/crucible.test.ts +1043 -0
- package/src/__tests__/engine.test.ts +157 -0
- package/src/__tests__/init.test.ts +450 -0
- package/src/__tests__/pipeline.test.ts +1087 -0
- package/src/__tests__/productbook.test.ts +1206 -0
- package/src/__tests__/runbook.test.ts +974 -0
- package/src/__tests__/server-registry.test.ts +1251 -0
- package/src/__tests__/servicebook.test.ts +1248 -0
- package/src/__tests__/shared.test.ts +2005 -0
- package/src/__tests__/styles.test.ts +14 -0
- package/src/__tests__/theme-schema.test.ts +47 -0
- package/src/__tests__/theme.test.ts +554 -0
- package/src/cli.ts +582 -0
- package/src/commands/init.ts +92 -0
- package/src/commands/update.ts +444 -0
- package/src/engine.ts +20 -0
- package/src/logger.ts +15 -0
- package/src/migrations/0000_schema_versioning.ts +67 -0
- package/src/migrations/0001_server_port.ts +52 -0
- package/src/migrations/0002_brand_logo.ts +49 -0
- package/src/migrations/index.ts +26 -0
- package/src/migrations/schema.ts +24 -0
- package/src/server-registry.ts +405 -0
- package/src/shared.ts +1239 -0
- package/src/site/styles.css +931 -0
- package/src/site/template.html +193 -0
- package/src/templates/crucible.ts +1163 -0
- package/src/templates/driver.ts +876 -0
- package/src/templates/handbook.ts +339 -0
- package/src/templates/minimal.ts +139 -0
- package/src/templates/pipeline.ts +966 -0
- package/src/templates/productbook.ts +1032 -0
- package/src/templates/runbook.ts +829 -0
- package/src/templates/schema.ts +119 -0
- package/src/templates/servicebook.ts +1242 -0
- package/src/theme.ts +245 -0
package/src/shared.ts
ADDED
|
@@ -0,0 +1,1239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utilities for Kitfly scripts (dev, build, bundle)
|
|
3
|
+
*
|
|
4
|
+
* This module contains common functions used across multiple scripts
|
|
5
|
+
* to reduce duplication and ensure consistency.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
9
|
+
import { join, resolve, sep } from "node:path";
|
|
10
|
+
import { ENGINE_SITE_DIR, siteOverridePath } from "./engine.ts";
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Type definitions
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
export interface SiteSection {
|
|
17
|
+
name: string;
|
|
18
|
+
path: string;
|
|
19
|
+
files?: string[];
|
|
20
|
+
maxDepth?: number; // Max directory depth for auto-discovery (default: 4, max: 10)
|
|
21
|
+
exclude?: string[]; // Glob patterns to exclude from auto-discovery
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SiteBrand {
|
|
25
|
+
name: string;
|
|
26
|
+
url: string;
|
|
27
|
+
external?: boolean;
|
|
28
|
+
logo?: string; // Path to logo image (default: assets/brand/logo.png)
|
|
29
|
+
favicon?: string; // Path to favicon (default: assets/brand/favicon.png)
|
|
30
|
+
logoType?: "icon" | "wordmark"; // icon = square, wordmark = wide
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface KitflyBrand {
|
|
34
|
+
readonly name: string;
|
|
35
|
+
readonly url: string;
|
|
36
|
+
readonly logo: string;
|
|
37
|
+
readonly favicon: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const KITFLY_BRAND: Readonly<KitflyBrand> = {
|
|
41
|
+
name: "Kitfly",
|
|
42
|
+
url: "https://kitfly.dev",
|
|
43
|
+
logo: "assets/brand/kitfly-neon-128.png",
|
|
44
|
+
favicon: "assets/brand/kitfly-favicon-32.png",
|
|
45
|
+
} as const;
|
|
46
|
+
|
|
47
|
+
export interface FooterLink {
|
|
48
|
+
text: string;
|
|
49
|
+
url: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface SiteFooter {
|
|
53
|
+
copyright?: string;
|
|
54
|
+
copyrightUrl?: string;
|
|
55
|
+
links?: FooterLink[];
|
|
56
|
+
attribution?: boolean;
|
|
57
|
+
// social?: SocialLinks; // Reserved for future
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface SiteServer {
|
|
61
|
+
port?: number; // Default dev server port
|
|
62
|
+
host?: string; // Default dev server host
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface SiteConfig {
|
|
66
|
+
docroot: string;
|
|
67
|
+
title: string;
|
|
68
|
+
version?: string;
|
|
69
|
+
home?: string;
|
|
70
|
+
brand: SiteBrand;
|
|
71
|
+
sections: SiteSection[];
|
|
72
|
+
footer?: SiteFooter;
|
|
73
|
+
server?: SiteServer;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface Provenance {
|
|
77
|
+
version?: string;
|
|
78
|
+
buildDate: string;
|
|
79
|
+
gitCommit: string;
|
|
80
|
+
gitCommitDate: string;
|
|
81
|
+
gitBranch: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface ContentFile {
|
|
85
|
+
path: string;
|
|
86
|
+
urlPath: string;
|
|
87
|
+
section: string;
|
|
88
|
+
sectionBase?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Environment and CLI helpers
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
export function envString(name: string, fallback: string): string {
|
|
96
|
+
return process.env[name] ?? fallback;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function envInt(name: string, fallback: number): number {
|
|
100
|
+
const val = process.env[name];
|
|
101
|
+
if (!val) return fallback;
|
|
102
|
+
const parsed = parseInt(val, 10);
|
|
103
|
+
return Number.isNaN(parsed) ? fallback : parsed;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function envBool(name: string, fallback: boolean): boolean {
|
|
107
|
+
const val = process.env[name]?.toLowerCase();
|
|
108
|
+
if (!val) return fallback;
|
|
109
|
+
if (["true", "1", "yes"].includes(val)) return true;
|
|
110
|
+
if (["false", "0", "no"].includes(val)) return false;
|
|
111
|
+
return fallback;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Network utilities
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Check if a port is available by attempting to connect to it.
|
|
120
|
+
* Returns true if port is free, false if in use.
|
|
121
|
+
*/
|
|
122
|
+
export async function isPortAvailable(port: number, host = "localhost"): Promise<boolean> {
|
|
123
|
+
return new Promise((resolve) => {
|
|
124
|
+
const net = require("node:net");
|
|
125
|
+
const socket = new net.Socket();
|
|
126
|
+
|
|
127
|
+
socket.setTimeout(1000);
|
|
128
|
+
|
|
129
|
+
socket.on("connect", () => {
|
|
130
|
+
socket.destroy();
|
|
131
|
+
resolve(false); // Port is in use (connection succeeded)
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
socket.on("timeout", () => {
|
|
135
|
+
socket.destroy();
|
|
136
|
+
resolve(true); // Timeout = likely no one listening
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
socket.on("error", (err: NodeJS.ErrnoException) => {
|
|
140
|
+
socket.destroy();
|
|
141
|
+
if (err.code === "ECONNREFUSED") {
|
|
142
|
+
resolve(true); // Connection refused = port is free
|
|
143
|
+
} else {
|
|
144
|
+
resolve(true); // Other errors = assume free
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
socket.connect(port, host);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Check port and exit with error if in use.
|
|
154
|
+
* Call this before starting a server.
|
|
155
|
+
*/
|
|
156
|
+
export async function checkPortOrExit(port: number, host = "localhost"): Promise<void> {
|
|
157
|
+
const available = await isPortAvailable(port, host);
|
|
158
|
+
if (!available) {
|
|
159
|
+
console.error(`\x1b[31mError: Port ${port} is already in use\x1b[0m\n`);
|
|
160
|
+
console.error(`Another process is listening on ${host}:${port}.`);
|
|
161
|
+
console.error(`\nOptions:`);
|
|
162
|
+
console.error(` • Use a different port: --port ${port + 1}`);
|
|
163
|
+
console.error(` • Stop the other process first`);
|
|
164
|
+
process.exit(1);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// YAML/Config parsing
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
export function parseYaml(content: string): Record<string, unknown> {
|
|
173
|
+
const result: Record<string, unknown> = {};
|
|
174
|
+
const lines = content.split("\n");
|
|
175
|
+
|
|
176
|
+
function stripInlineComment(raw: string): string {
|
|
177
|
+
let inSingle = false;
|
|
178
|
+
let inDouble = false;
|
|
179
|
+
let escaped = false;
|
|
180
|
+
for (let i = 0; i < raw.length; i++) {
|
|
181
|
+
const ch = raw[i];
|
|
182
|
+
if (escaped) {
|
|
183
|
+
escaped = false;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (ch === "\\") {
|
|
187
|
+
escaped = true;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (!inDouble && ch === "'") {
|
|
191
|
+
inSingle = !inSingle;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (!inSingle && ch === '"') {
|
|
195
|
+
inDouble = !inDouble;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (!inSingle && !inDouble && ch === "#") {
|
|
199
|
+
// YAML inline comment (outside quotes)
|
|
200
|
+
return raw.slice(0, i).trimEnd();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return raw.trimEnd();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Stack tracks current object context with its base indentation
|
|
207
|
+
const stack: { obj: Record<string, unknown>; indent: number }[] = [{ obj: result, indent: -2 }];
|
|
208
|
+
|
|
209
|
+
for (let i = 0; i < lines.length; i++) {
|
|
210
|
+
const line = lines[i];
|
|
211
|
+
// Skip comments and empty lines
|
|
212
|
+
if (line.trim().startsWith("#") || line.trim() === "") continue;
|
|
213
|
+
|
|
214
|
+
const indent = line.search(/\S/);
|
|
215
|
+
const trimmed = line.trim();
|
|
216
|
+
|
|
217
|
+
// Pop stack when we dedent
|
|
218
|
+
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
|
|
219
|
+
stack.pop();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Array item (starts with "- ")
|
|
223
|
+
if (trimmed.startsWith("- ")) {
|
|
224
|
+
const afterDash = trimmed.slice(2);
|
|
225
|
+
const colonIndex = afterDash.indexOf(":");
|
|
226
|
+
|
|
227
|
+
if (colonIndex > 0) {
|
|
228
|
+
// Object in array: "- name: value"
|
|
229
|
+
const key = afterDash.slice(0, colonIndex).trim();
|
|
230
|
+
const val = stripInlineComment(afterDash.slice(colonIndex + 1).trim());
|
|
231
|
+
|
|
232
|
+
// Create new object for this array item
|
|
233
|
+
const obj: Record<string, unknown> = {};
|
|
234
|
+
|
|
235
|
+
// Handle inline array value like files: ["a", "b"]
|
|
236
|
+
if (val.startsWith("[") && val.endsWith("]")) {
|
|
237
|
+
const arrContent = val.slice(1, -1);
|
|
238
|
+
obj[key] = arrContent.split(",").map((s) => stripQuotes(s.trim()));
|
|
239
|
+
} else if (val === "") {
|
|
240
|
+
// Nested structure will follow
|
|
241
|
+
obj[key] = null; // Placeholder
|
|
242
|
+
} else {
|
|
243
|
+
obj[key] = parseValue(val);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Find the array in parent
|
|
247
|
+
const parent = stack[stack.length - 1].obj;
|
|
248
|
+
const arrays = Object.entries(parent).filter(([, v]) => Array.isArray(v));
|
|
249
|
+
if (arrays.length > 0) {
|
|
250
|
+
const [, arr] = arrays[arrays.length - 1];
|
|
251
|
+
(arr as unknown[]).push(obj);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Push this object onto stack for subsequent properties
|
|
255
|
+
stack.push({ obj, indent });
|
|
256
|
+
} else {
|
|
257
|
+
// Simple array item: "- value"
|
|
258
|
+
const parent = stack[stack.length - 1].obj;
|
|
259
|
+
const arrays = Object.entries(parent).filter(([, v]) => Array.isArray(v));
|
|
260
|
+
if (arrays.length > 0) {
|
|
261
|
+
const [, arr] = arrays[arrays.length - 1];
|
|
262
|
+
(arr as unknown[]).push(stripQuotes(stripInlineComment(afterDash.trim())));
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Key: value pair
|
|
269
|
+
const colonIndex = trimmed.indexOf(":");
|
|
270
|
+
if (colonIndex > 0) {
|
|
271
|
+
const key = trimmed.slice(0, colonIndex).trim();
|
|
272
|
+
const value = stripInlineComment(trimmed.slice(colonIndex + 1).trim());
|
|
273
|
+
const parent = stack[stack.length - 1].obj;
|
|
274
|
+
|
|
275
|
+
if (value === "") {
|
|
276
|
+
// Check if next non-empty line is an array or object
|
|
277
|
+
let nextIdx = i + 1;
|
|
278
|
+
while (nextIdx < lines.length && lines[nextIdx].trim() === "") nextIdx++;
|
|
279
|
+
|
|
280
|
+
if (nextIdx < lines.length && lines[nextIdx].trim().startsWith("- ")) {
|
|
281
|
+
// It's an array
|
|
282
|
+
parent[key] = [];
|
|
283
|
+
} else {
|
|
284
|
+
// It's a nested object
|
|
285
|
+
const nested: Record<string, unknown> = {};
|
|
286
|
+
parent[key] = nested;
|
|
287
|
+
stack.push({ obj: nested, indent });
|
|
288
|
+
}
|
|
289
|
+
} else if (value.startsWith("[") && value.endsWith("]")) {
|
|
290
|
+
// Inline array
|
|
291
|
+
const arrContent = value.slice(1, -1);
|
|
292
|
+
parent[key] = arrContent.split(",").map((s) => stripQuotes(s.trim()));
|
|
293
|
+
} else {
|
|
294
|
+
parent[key] = parseValue(value);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return result;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function parseValue(value: string): unknown {
|
|
303
|
+
const stripped = stripQuotes(value);
|
|
304
|
+
if (stripped === "true") return true;
|
|
305
|
+
if (stripped === "false") return false;
|
|
306
|
+
return stripped;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function stripQuotes(value: string): string {
|
|
310
|
+
if (
|
|
311
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
312
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
313
|
+
) {
|
|
314
|
+
return value.slice(1, -1);
|
|
315
|
+
}
|
|
316
|
+
return value;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
// File utilities
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
export async function exists(path: string): Promise<boolean> {
|
|
324
|
+
try {
|
|
325
|
+
await stat(path);
|
|
326
|
+
return true;
|
|
327
|
+
} catch {
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Validate path doesn't escape repo root
|
|
334
|
+
* @param root - The root directory
|
|
335
|
+
* @param docroot - The document root relative to ROOT
|
|
336
|
+
* @param requestedPath - The requested path
|
|
337
|
+
* @param logErrors - Whether to log errors (default: false)
|
|
338
|
+
* @returns The resolved path or null if invalid
|
|
339
|
+
*/
|
|
340
|
+
export function validatePath(
|
|
341
|
+
root: string,
|
|
342
|
+
docroot: string,
|
|
343
|
+
requestedPath: string,
|
|
344
|
+
logErrors = false,
|
|
345
|
+
): string | null {
|
|
346
|
+
const resolved = resolve(root, docroot, requestedPath);
|
|
347
|
+
const normalizedRoot = resolve(root);
|
|
348
|
+
if (!resolved.startsWith(`${normalizedRoot}${sep}`) && resolved !== normalizedRoot) {
|
|
349
|
+
if (logErrors) {
|
|
350
|
+
console.error(`Path escapes repo root: ${requestedPath}`);
|
|
351
|
+
}
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
return resolved;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Normalize a resolved path to a URL-safe path relative to ROOT
|
|
359
|
+
* Handles ../docs/decisions -> docs/decisions
|
|
360
|
+
*/
|
|
361
|
+
export function toUrlPath(root: string, resolvedPath: string): string {
|
|
362
|
+
const normalizedRoot = resolve(root);
|
|
363
|
+
if (resolvedPath.startsWith(`${normalizedRoot}${sep}`)) {
|
|
364
|
+
return resolvedPath.slice(normalizedRoot.length + 1).replaceAll("\\", "/");
|
|
365
|
+
}
|
|
366
|
+
return resolvedPath;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export async function resolveTemplatePath(siteRoot: string): Promise<string> {
|
|
370
|
+
const override = siteOverridePath(siteRoot, "template.html");
|
|
371
|
+
if (await exists(override)) return override;
|
|
372
|
+
return join(ENGINE_SITE_DIR, "template.html");
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export async function resolveStylesPath(siteRoot: string): Promise<string> {
|
|
376
|
+
const override = siteOverridePath(siteRoot, "styles.css");
|
|
377
|
+
if (await exists(override)) return override;
|
|
378
|
+
return join(ENGINE_SITE_DIR, "styles.css");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ---------------------------------------------------------------------------
|
|
382
|
+
// Markdown utilities
|
|
383
|
+
// ---------------------------------------------------------------------------
|
|
384
|
+
|
|
385
|
+
export function parseFrontmatter(content: string): {
|
|
386
|
+
frontmatter: Record<string, unknown>;
|
|
387
|
+
body: string;
|
|
388
|
+
} {
|
|
389
|
+
const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
390
|
+
if (!match) {
|
|
391
|
+
return { frontmatter: {}, body: content };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const frontmatter: Record<string, unknown> = {};
|
|
395
|
+
const lines = match[1].split("\n");
|
|
396
|
+
for (const line of lines) {
|
|
397
|
+
const colonIndex = line.indexOf(":");
|
|
398
|
+
if (colonIndex > 0) {
|
|
399
|
+
const key = line.slice(0, colonIndex).trim();
|
|
400
|
+
let value = line.slice(colonIndex + 1).trim();
|
|
401
|
+
// Remove quotes if present
|
|
402
|
+
if (
|
|
403
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
404
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
405
|
+
) {
|
|
406
|
+
value = value.slice(1, -1);
|
|
407
|
+
}
|
|
408
|
+
frontmatter[key] = value;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return { frontmatter, body: match[2] };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export function slugify(text: string): string {
|
|
416
|
+
return text
|
|
417
|
+
.toLowerCase()
|
|
418
|
+
.replace(/[^\w\s-]/g, "")
|
|
419
|
+
.replace(/\s+/g, "-")
|
|
420
|
+
.replace(/-+/g, "-")
|
|
421
|
+
.trim();
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ---------------------------------------------------------------------------
|
|
425
|
+
// Navigation/template building
|
|
426
|
+
// ---------------------------------------------------------------------------
|
|
427
|
+
|
|
428
|
+
/** Hard ceiling — guards against symlink loops or pathological trees */
|
|
429
|
+
const MAX_DISCOVERY_DEPTH = 10;
|
|
430
|
+
|
|
431
|
+
/** Default depth for auto-discovered sections (keeps nav manageable) */
|
|
432
|
+
const DEFAULT_DISCOVERY_DEPTH = 4;
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Recursively collect content files from a directory.
|
|
436
|
+
*/
|
|
437
|
+
async function walkContentDir(
|
|
438
|
+
dir: string,
|
|
439
|
+
urlBase: string,
|
|
440
|
+
section: string,
|
|
441
|
+
sectionBase: string,
|
|
442
|
+
relBase: string,
|
|
443
|
+
depth: number,
|
|
444
|
+
maxDepth: number,
|
|
445
|
+
exclude: string[],
|
|
446
|
+
files: ContentFile[],
|
|
447
|
+
): Promise<void> {
|
|
448
|
+
if (depth > maxDepth) return;
|
|
449
|
+
|
|
450
|
+
let entries: import("node:fs").Dirent[];
|
|
451
|
+
try {
|
|
452
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
453
|
+
} catch {
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Sort for consistent cross-platform ordering
|
|
458
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
459
|
+
|
|
460
|
+
for (const entry of entries) {
|
|
461
|
+
if (entry.name.startsWith(".")) continue;
|
|
462
|
+
const relPath = relBase ? `${relBase}/${entry.name}` : entry.name;
|
|
463
|
+
if (matchesExclude(relPath, exclude)) continue;
|
|
464
|
+
|
|
465
|
+
if (entry.isDirectory()) {
|
|
466
|
+
await walkContentDir(
|
|
467
|
+
join(dir, entry.name),
|
|
468
|
+
`${urlBase}/${entry.name}`,
|
|
469
|
+
section,
|
|
470
|
+
sectionBase,
|
|
471
|
+
relPath,
|
|
472
|
+
depth + 1,
|
|
473
|
+
maxDepth,
|
|
474
|
+
exclude,
|
|
475
|
+
files,
|
|
476
|
+
);
|
|
477
|
+
} else if (entry.isFile()) {
|
|
478
|
+
const name = entry.name;
|
|
479
|
+
if (!name.endsWith(".md") && !name.endsWith(".yaml") && !name.endsWith(".json")) continue;
|
|
480
|
+
|
|
481
|
+
const stem = name.replace(/\.(md|yaml|json)$/, "");
|
|
482
|
+
let urlPath: string;
|
|
483
|
+
if (stem.toLowerCase() === "index" || stem.toLowerCase() === "readme") {
|
|
484
|
+
// Use directory path as URL — parent dir name becomes display name
|
|
485
|
+
urlPath = urlBase;
|
|
486
|
+
} else {
|
|
487
|
+
urlPath = `${urlBase}/${stem}`;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
files.push({
|
|
491
|
+
path: join(dir, name),
|
|
492
|
+
urlPath,
|
|
493
|
+
section,
|
|
494
|
+
sectionBase,
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function matchesExclude(name: string, patterns: string[]): boolean {
|
|
501
|
+
if (patterns.length === 0) return false;
|
|
502
|
+
return patterns.some((pattern) => globMatch(pattern, name));
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function globMatch(pattern: string, str: string): boolean {
|
|
506
|
+
const escapeRegex = (s: string) => s.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
|
507
|
+
let regexPattern = "";
|
|
508
|
+
for (const ch of pattern) {
|
|
509
|
+
if (ch === "*") regexPattern += "[^/]*";
|
|
510
|
+
else if (ch === "?") regexPattern += "[^/]";
|
|
511
|
+
else regexPattern += escapeRegex(ch);
|
|
512
|
+
}
|
|
513
|
+
const regex = `^${regexPattern}$`;
|
|
514
|
+
return new RegExp(regex).test(str);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Collect all content files based on config
|
|
519
|
+
*/
|
|
520
|
+
export async function collectFiles(root: string, config: SiteConfig): Promise<ContentFile[]> {
|
|
521
|
+
const files: ContentFile[] = [];
|
|
522
|
+
|
|
523
|
+
for (const section of config.sections) {
|
|
524
|
+
const sectionPath = validatePath(root, config.docroot, section.path);
|
|
525
|
+
if (!sectionPath) continue;
|
|
526
|
+
|
|
527
|
+
// Compute URL base from resolved path (handles ../docs/decisions -> docs/decisions)
|
|
528
|
+
const urlBase = toUrlPath(root, sectionPath);
|
|
529
|
+
|
|
530
|
+
if (section.files) {
|
|
531
|
+
// Explicit file list (for root-level sections like Overview)
|
|
532
|
+
for (const file of section.files) {
|
|
533
|
+
const filePath = join(sectionPath, file);
|
|
534
|
+
try {
|
|
535
|
+
await stat(filePath);
|
|
536
|
+
const name = file.replace(/\.(md|yaml|json)$/, "");
|
|
537
|
+
files.push({
|
|
538
|
+
path: filePath,
|
|
539
|
+
urlPath: name.toLowerCase(),
|
|
540
|
+
section: section.name,
|
|
541
|
+
sectionBase: urlBase,
|
|
542
|
+
});
|
|
543
|
+
} catch {
|
|
544
|
+
// Skip if doesn't exist
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
} else {
|
|
548
|
+
// Auto-discover from directory (recursive)
|
|
549
|
+
const maxDepth = Math.min(
|
|
550
|
+
typeof section.maxDepth === "number" ? section.maxDepth : DEFAULT_DISCOVERY_DEPTH,
|
|
551
|
+
MAX_DISCOVERY_DEPTH,
|
|
552
|
+
);
|
|
553
|
+
await walkContentDir(
|
|
554
|
+
sectionPath,
|
|
555
|
+
urlBase,
|
|
556
|
+
section.name,
|
|
557
|
+
urlBase,
|
|
558
|
+
"",
|
|
559
|
+
0,
|
|
560
|
+
maxDepth,
|
|
561
|
+
section.exclude ?? [],
|
|
562
|
+
files,
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
return files;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// ---------------------------------------------------------------------------
|
|
571
|
+
// Hierarchical navigation tree
|
|
572
|
+
// ---------------------------------------------------------------------------
|
|
573
|
+
|
|
574
|
+
interface NavNode {
|
|
575
|
+
name: string;
|
|
576
|
+
urlPath: string | null; // null for dirs without index.md
|
|
577
|
+
children: NavNode[];
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Derive section URL base from a group of files (fallback when sectionBase not set)
|
|
582
|
+
*/
|
|
583
|
+
function findSectionBase(urlPaths: string[]): string {
|
|
584
|
+
if (urlPaths.length === 0) return "";
|
|
585
|
+
if (urlPaths.length === 1) {
|
|
586
|
+
const parts = urlPaths[0].split("/");
|
|
587
|
+
return parts.length > 1 ? parts.slice(0, -1).join("/") : "";
|
|
588
|
+
}
|
|
589
|
+
const split = urlPaths.map((p) => p.split("/"));
|
|
590
|
+
let commonLen = 0;
|
|
591
|
+
for (let i = 0; i < Math.min(...split.map((s) => s.length)); i++) {
|
|
592
|
+
if (split.every((s) => s[i] === split[0][i])) commonLen = i + 1;
|
|
593
|
+
else break;
|
|
594
|
+
}
|
|
595
|
+
return split[0].slice(0, commonLen).join("/");
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Build a NavNode tree from a flat file list for one section.
|
|
600
|
+
* Returns the tree and the section root index urlPath (if any).
|
|
601
|
+
*/
|
|
602
|
+
function buildNavTree(
|
|
603
|
+
files: ContentFile[],
|
|
604
|
+
sectionBase: string,
|
|
605
|
+
): { tree: NavNode[]; indexUrlPath: string | null } {
|
|
606
|
+
const root: NavNode = { name: "", urlPath: null, children: [] };
|
|
607
|
+
let indexUrlPath: string | null = null;
|
|
608
|
+
|
|
609
|
+
for (const file of files) {
|
|
610
|
+
const rel =
|
|
611
|
+
file.urlPath.length > sectionBase.length ? file.urlPath.slice(sectionBase.length + 1) : "";
|
|
612
|
+
|
|
613
|
+
if (rel === "") {
|
|
614
|
+
// Section root index — becomes section header link
|
|
615
|
+
indexUrlPath = file.urlPath;
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const segments = rel.split("/");
|
|
620
|
+
let node = root;
|
|
621
|
+
|
|
622
|
+
for (let i = 0; i < segments.length; i++) {
|
|
623
|
+
const seg = segments[i];
|
|
624
|
+
const isLeaf = i === segments.length - 1;
|
|
625
|
+
|
|
626
|
+
if (isLeaf) {
|
|
627
|
+
const existing = node.children.find((c) => c.name === seg);
|
|
628
|
+
if (existing && existing.children.length > 0 && !existing.urlPath) {
|
|
629
|
+
// Directory node exists without a link — this is its index file
|
|
630
|
+
existing.urlPath = file.urlPath;
|
|
631
|
+
} else {
|
|
632
|
+
node.children.push({ name: seg, urlPath: file.urlPath, children: [] });
|
|
633
|
+
}
|
|
634
|
+
} else {
|
|
635
|
+
let child = node.children.find((c) => c.name === seg);
|
|
636
|
+
if (!child) {
|
|
637
|
+
child = { name: seg, urlPath: null, children: [] };
|
|
638
|
+
node.children.push(child);
|
|
639
|
+
}
|
|
640
|
+
node = child;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
return { tree: root.children, indexUrlPath };
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function nodeContainsPath(node: NavNode, urlPath: string): boolean {
|
|
649
|
+
if (node.urlPath === urlPath) return true;
|
|
650
|
+
return node.children.some((c) => nodeContainsPath(c, urlPath));
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Render a NavNode tree to HTML with collapsible groups.
|
|
655
|
+
*/
|
|
656
|
+
function renderNavTree(
|
|
657
|
+
nodes: NavNode[],
|
|
658
|
+
currentUrlPath: string | null,
|
|
659
|
+
makeHref: (urlPath: string) => string,
|
|
660
|
+
): string {
|
|
661
|
+
if (nodes.length === 0) return "";
|
|
662
|
+
|
|
663
|
+
let html = "<ul>";
|
|
664
|
+
for (const node of nodes) {
|
|
665
|
+
if (node.children.length > 0) {
|
|
666
|
+
// Directory with children — collapsible group
|
|
667
|
+
const isOpen = currentUrlPath ? nodeContainsPath(node, currentUrlPath) : false;
|
|
668
|
+
const open = isOpen ? " open" : "";
|
|
669
|
+
html += `<li><details${open}><summary class="nav-group">`;
|
|
670
|
+
if (node.urlPath) {
|
|
671
|
+
const active = currentUrlPath === node.urlPath ? ' class="active"' : "";
|
|
672
|
+
html += `<a href="${makeHref(node.urlPath)}"${active}>${node.name}</a>`;
|
|
673
|
+
} else {
|
|
674
|
+
html += node.name;
|
|
675
|
+
}
|
|
676
|
+
html += `</summary>`;
|
|
677
|
+
html += renderNavTree(node.children, currentUrlPath, makeHref);
|
|
678
|
+
html += `</details></li>`;
|
|
679
|
+
} else {
|
|
680
|
+
// Leaf file
|
|
681
|
+
const active = currentUrlPath === node.urlPath ? ' class="active"' : "";
|
|
682
|
+
html += `<li><a href="${makeHref(node.urlPath ?? "")}"${active}>${node.name}</a></li>`;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
html += "</ul>";
|
|
686
|
+
return html;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Build section nav HTML using tree renderer.
|
|
691
|
+
* Shared logic for buildNavSimple and buildNavStatic.
|
|
692
|
+
*/
|
|
693
|
+
export function buildSectionNav(
|
|
694
|
+
sectionFiles: Map<string, ContentFile[]>,
|
|
695
|
+
config: SiteConfig,
|
|
696
|
+
currentUrlPath: string | null,
|
|
697
|
+
makeHref: (urlPath: string) => string,
|
|
698
|
+
): string {
|
|
699
|
+
let html = "";
|
|
700
|
+
|
|
701
|
+
// Render sections in config order
|
|
702
|
+
for (const section of config.sections) {
|
|
703
|
+
const items = sectionFiles.get(section.name);
|
|
704
|
+
if (!items || items.length === 0) continue;
|
|
705
|
+
|
|
706
|
+
const sBase = items[0]?.sectionBase ?? findSectionBase(items.map((f) => f.urlPath));
|
|
707
|
+
const { tree, indexUrlPath } = buildNavTree(items, sBase);
|
|
708
|
+
|
|
709
|
+
// Section header — clickable if section has root index
|
|
710
|
+
if (indexUrlPath) {
|
|
711
|
+
const active = currentUrlPath === indexUrlPath ? ' class="active"' : "";
|
|
712
|
+
html += `<li><a href="${makeHref(indexUrlPath)}"${active} class="nav-section">${section.name}</a>`;
|
|
713
|
+
} else {
|
|
714
|
+
html += `<li><span class="nav-section">${section.name}</span>`;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
html += renderNavTree(tree, currentUrlPath, makeHref);
|
|
718
|
+
html += `</li>`;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
return html;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Build navigation HTML for dev server (simple, no path prefix)
|
|
726
|
+
*/
|
|
727
|
+
export function buildNavSimple(
|
|
728
|
+
files: ContentFile[],
|
|
729
|
+
config: SiteConfig,
|
|
730
|
+
currentUrlPath?: string,
|
|
731
|
+
): string {
|
|
732
|
+
// Group files by section
|
|
733
|
+
const sectionFiles = new Map<string, ContentFile[]>();
|
|
734
|
+
for (const file of files) {
|
|
735
|
+
if (!sectionFiles.has(file.section)) {
|
|
736
|
+
sectionFiles.set(file.section, []);
|
|
737
|
+
}
|
|
738
|
+
sectionFiles.get(file.section)?.push(file);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const makeHref = (urlPath: string) => `/${urlPath}`;
|
|
742
|
+
let html = "<ul>";
|
|
743
|
+
|
|
744
|
+
if (config.home) {
|
|
745
|
+
html += `<li><a href="/" class="nav-home">Home</a></li>`;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
html += buildSectionNav(sectionFiles, config, currentUrlPath ?? null, makeHref);
|
|
749
|
+
html += "</ul>";
|
|
750
|
+
|
|
751
|
+
return html;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Build navigation HTML for static build (with path prefix and active state)
|
|
756
|
+
*/
|
|
757
|
+
export function buildNavStatic(
|
|
758
|
+
files: ContentFile[],
|
|
759
|
+
currentKey: string,
|
|
760
|
+
config: SiteConfig,
|
|
761
|
+
pathPrefix: string,
|
|
762
|
+
): string {
|
|
763
|
+
// Group files by section
|
|
764
|
+
const sectionFiles = new Map<string, ContentFile[]>();
|
|
765
|
+
for (const file of files) {
|
|
766
|
+
if (!sectionFiles.has(file.section)) {
|
|
767
|
+
sectionFiles.set(file.section, []);
|
|
768
|
+
}
|
|
769
|
+
sectionFiles.get(file.section)?.push(file);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const makeHref = (urlPath: string) => `${pathPrefix}${urlPath}.html`;
|
|
773
|
+
let html = "<ul>";
|
|
774
|
+
|
|
775
|
+
if (config.home) {
|
|
776
|
+
const homeActive = currentKey === "" ? ' class="active"' : "";
|
|
777
|
+
html += `<li><a href="${pathPrefix}index.html"${homeActive} class="nav-home">Home</a></li>`;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
html += buildSectionNav(sectionFiles, config, currentKey || null, makeHref);
|
|
781
|
+
html += "</ul>";
|
|
782
|
+
|
|
783
|
+
return html;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Extract TOC from rendered HTML
|
|
788
|
+
*/
|
|
789
|
+
export function buildToc(html: string): string {
|
|
790
|
+
const headings: { level: number; id: string; text: string }[] = [];
|
|
791
|
+
const regex = /<h([23])\s+id="([^"]+)"[^>]*>([^<]+)<\/h[23]>/gi;
|
|
792
|
+
let match: RegExpExecArray | null = null;
|
|
793
|
+
while (true) {
|
|
794
|
+
match = regex.exec(html);
|
|
795
|
+
if (match === null) break;
|
|
796
|
+
headings.push({
|
|
797
|
+
level: parseInt(match[1], 10),
|
|
798
|
+
id: match[2],
|
|
799
|
+
text: match[3].trim(),
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
if (headings.length < 2) return "";
|
|
804
|
+
|
|
805
|
+
let tocHtml = '<aside class="toc"><span class="toc-title">On this page</span><ul>';
|
|
806
|
+
for (const h of headings) {
|
|
807
|
+
const levelClass = h.level === 3 ? ' class="toc-h3"' : "";
|
|
808
|
+
tocHtml += `<li${levelClass}><a href="#${h.id}">${h.text}</a></li>`;
|
|
809
|
+
}
|
|
810
|
+
tocHtml += "</ul></aside>";
|
|
811
|
+
return tocHtml;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Find the first file in a section that matches the given path prefix
|
|
816
|
+
*/
|
|
817
|
+
function findFirstFileInSection(files: ContentFile[], pathPrefix: string): ContentFile | undefined {
|
|
818
|
+
return files.find((file) => file.urlPath.startsWith(`${pathPrefix}/`));
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Build breadcrumbs for dev server (simple, no path prefix)
|
|
823
|
+
* Links to first file in each section instead of non-existent index pages
|
|
824
|
+
*/
|
|
825
|
+
export function buildBreadcrumbsSimple(
|
|
826
|
+
urlPath: string,
|
|
827
|
+
files: ContentFile[],
|
|
828
|
+
_config: SiteConfig,
|
|
829
|
+
): string {
|
|
830
|
+
const parts = urlPath.split("/").filter(Boolean);
|
|
831
|
+
if (parts.length <= 1) return "";
|
|
832
|
+
|
|
833
|
+
let html = '<nav class="breadcrumbs">';
|
|
834
|
+
let path = "";
|
|
835
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
836
|
+
path += (path ? "/" : "") + parts[i];
|
|
837
|
+
const name = parts[i].charAt(0).toUpperCase() + parts[i].slice(1);
|
|
838
|
+
|
|
839
|
+
// Find first file in this section to link to
|
|
840
|
+
const firstFile = findFirstFileInSection(files, path);
|
|
841
|
+
const href = firstFile ? `/${firstFile.urlPath}` : `/${path}/`;
|
|
842
|
+
|
|
843
|
+
html += `<a href="${href}">${name}</a><span class="separator">›</span>`;
|
|
844
|
+
}
|
|
845
|
+
html += `<span>${parts[parts.length - 1]}</span>`;
|
|
846
|
+
html += "</nav>";
|
|
847
|
+
return html;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Build breadcrumbs for static build (with path prefix)
|
|
852
|
+
* Links to first file in each section instead of non-existent index pages
|
|
853
|
+
*/
|
|
854
|
+
export function buildBreadcrumbsStatic(
|
|
855
|
+
urlKey: string,
|
|
856
|
+
pathPrefix: string,
|
|
857
|
+
files: ContentFile[],
|
|
858
|
+
_config: SiteConfig,
|
|
859
|
+
): string {
|
|
860
|
+
const parts = urlKey.split("/").filter(Boolean);
|
|
861
|
+
if (parts.length <= 1) return "";
|
|
862
|
+
|
|
863
|
+
let html = '<nav class="breadcrumbs">';
|
|
864
|
+
let path = "";
|
|
865
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
866
|
+
path += (path ? "/" : "") + parts[i];
|
|
867
|
+
const name = parts[i].charAt(0).toUpperCase() + parts[i].slice(1);
|
|
868
|
+
|
|
869
|
+
// Find first file in this section to link to
|
|
870
|
+
const firstFile = findFirstFileInSection(files, path);
|
|
871
|
+
const href = firstFile
|
|
872
|
+
? `${pathPrefix}${firstFile.urlPath}.html`
|
|
873
|
+
: `${pathPrefix}${path}/index.html`;
|
|
874
|
+
|
|
875
|
+
html += `<a href="${href}">${name}</a><span class="separator">›</span>`;
|
|
876
|
+
}
|
|
877
|
+
html += `<span>${parts[parts.length - 1]}</span>`;
|
|
878
|
+
html += "</nav>";
|
|
879
|
+
return html;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* Build page meta (last updated date)
|
|
884
|
+
*/
|
|
885
|
+
export function buildPageMeta(frontmatter: Record<string, unknown>): string {
|
|
886
|
+
const lastUpdated = frontmatter.last_updated as string | undefined;
|
|
887
|
+
if (!lastUpdated) return "";
|
|
888
|
+
const formatted = formatDate(lastUpdated);
|
|
889
|
+
return `<div class="page-meta">Last updated: ${formatted}</div>`;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* Build footer HTML from provenance
|
|
894
|
+
*/
|
|
895
|
+
export function buildFooter(provenance: Provenance, config: SiteConfig): string {
|
|
896
|
+
const commitDate = formatDate(provenance.gitCommitDate);
|
|
897
|
+
const publishYear = Number.isNaN(new Date(provenance.gitCommitDate).getTime())
|
|
898
|
+
? new Date().getFullYear().toString()
|
|
899
|
+
: new Date(provenance.gitCommitDate).getUTCFullYear().toString();
|
|
900
|
+
const footer = config.footer || {};
|
|
901
|
+
const copyrightText = footer.copyright
|
|
902
|
+
? escapeHtml(footer.copyright)
|
|
903
|
+
: `© ${publishYear} ${escapeHtml(config.brand.name)}`;
|
|
904
|
+
const copyrightHtml = footer.copyrightUrl
|
|
905
|
+
? `<a href="${escapeHtml(footer.copyrightUrl)}" class="footer-link">${copyrightText}</a>`
|
|
906
|
+
: copyrightText;
|
|
907
|
+
const hasCustomLinks = Array.isArray(footer.links);
|
|
908
|
+
const brandLinkText = /^https?:\/\//.test(config.brand.url)
|
|
909
|
+
? config.brand.url.replace(/^https?:\/\//, "")
|
|
910
|
+
: config.brand.name;
|
|
911
|
+
const linksHtml = hasCustomLinks
|
|
912
|
+
? (footer.links ?? [])
|
|
913
|
+
.map(
|
|
914
|
+
(link) =>
|
|
915
|
+
`<a href="${escapeHtml(link.url)}" class="footer-link">${escapeHtml(link.text)}</a>`,
|
|
916
|
+
)
|
|
917
|
+
.join('<span class="footer-separator">·</span>')
|
|
918
|
+
: `<a href="${escapeHtml(config.brand.url)}" class="footer-link"${config.brand.external ? ' target="_blank" rel="noopener"' : ""}>${escapeHtml(brandLinkText)}</a>`;
|
|
919
|
+
const attributionEnabled = footer.attribution !== false;
|
|
920
|
+
const versionHtml = provenance.version
|
|
921
|
+
? `<span class="footer-version">v${escapeHtml(provenance.version)}</span>
|
|
922
|
+
<span class="footer-separator">·</span>`
|
|
923
|
+
: "";
|
|
924
|
+
|
|
925
|
+
return `
|
|
926
|
+
<footer class="site-footer">
|
|
927
|
+
<div class="footer-content">
|
|
928
|
+
<div class="footer-left">
|
|
929
|
+
${versionHtml}
|
|
930
|
+
<span class="footer-commit" title="Commit: ${escapeHtml(provenance.gitCommit)}">Published ${commitDate}</span>
|
|
931
|
+
</div>
|
|
932
|
+
<div class="footer-center">
|
|
933
|
+
<span class="footer-copyright">${copyrightHtml}</span>
|
|
934
|
+
${linksHtml ? `<span class="footer-separator">·</span>${linksHtml}` : ""}
|
|
935
|
+
</div>
|
|
936
|
+
${
|
|
937
|
+
attributionEnabled
|
|
938
|
+
? `<div class="footer-right">
|
|
939
|
+
<a href="${KITFLY_BRAND.url}" class="footer-link">Built with ${KITFLY_BRAND.name}</a>
|
|
940
|
+
</div>`
|
|
941
|
+
: ""
|
|
942
|
+
}
|
|
943
|
+
</div>
|
|
944
|
+
</footer>`;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Build bundle footer HTML.
|
|
949
|
+
*/
|
|
950
|
+
export function buildBundleFooter(version: string | undefined, config: SiteConfig): string {
|
|
951
|
+
const footer = config.footer || {};
|
|
952
|
+
const copyrightText = footer.copyright
|
|
953
|
+
? escapeHtml(footer.copyright)
|
|
954
|
+
: `© ${new Date().getFullYear()} ${escapeHtml(config.brand.name)}`;
|
|
955
|
+
const copyrightHtml = footer.copyrightUrl
|
|
956
|
+
? `<a href="${escapeHtml(footer.copyrightUrl)}" class="footer-link">${copyrightText}</a>`
|
|
957
|
+
: copyrightText;
|
|
958
|
+
const hasCustomLinks = Array.isArray(footer.links);
|
|
959
|
+
const brandLinkText = /^https?:\/\//.test(config.brand.url)
|
|
960
|
+
? config.brand.url.replace(/^https?:\/\//, "")
|
|
961
|
+
: config.brand.name;
|
|
962
|
+
const linksHtml = hasCustomLinks
|
|
963
|
+
? (footer.links ?? [])
|
|
964
|
+
.map(
|
|
965
|
+
(link) =>
|
|
966
|
+
`<a href="${escapeHtml(link.url)}" class="footer-link">${escapeHtml(link.text)}</a>`,
|
|
967
|
+
)
|
|
968
|
+
.join('<span class="footer-separator">·</span>')
|
|
969
|
+
: `<a href="${escapeHtml(config.brand.url)}" class="footer-link"${config.brand.external ? ' target="_blank" rel="noopener"' : ""}>${escapeHtml(brandLinkText)}</a>`;
|
|
970
|
+
const attributionEnabled = footer.attribution !== false;
|
|
971
|
+
const versionHtml = version
|
|
972
|
+
? `<span class="footer-version">v${escapeHtml(version)}</span>
|
|
973
|
+
<span class="footer-separator">·</span>`
|
|
974
|
+
: "";
|
|
975
|
+
|
|
976
|
+
return `
|
|
977
|
+
<footer class="site-footer">
|
|
978
|
+
<div class="footer-content">
|
|
979
|
+
<div class="footer-left">
|
|
980
|
+
${versionHtml}
|
|
981
|
+
<span class="footer-commit">Published (offline bundle)</span>
|
|
982
|
+
</div>
|
|
983
|
+
<div class="footer-center">
|
|
984
|
+
<span class="footer-copyright">${copyrightHtml}</span>
|
|
985
|
+
${linksHtml ? `<span class="footer-separator">·</span>${linksHtml}` : ""}
|
|
986
|
+
</div>
|
|
987
|
+
${
|
|
988
|
+
attributionEnabled
|
|
989
|
+
? `<div class="footer-right">
|
|
990
|
+
<a href="${KITFLY_BRAND.url}" class="footer-link">Built with ${KITFLY_BRAND.name}</a>
|
|
991
|
+
</div>`
|
|
992
|
+
: ""
|
|
993
|
+
}
|
|
994
|
+
</div>
|
|
995
|
+
</footer>`;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// ---------------------------------------------------------------------------
|
|
999
|
+
// Formatting
|
|
1000
|
+
// ---------------------------------------------------------------------------
|
|
1001
|
+
|
|
1002
|
+
export function escapeHtml(text: string): string {
|
|
1003
|
+
return text
|
|
1004
|
+
.replace(/&/g, "&")
|
|
1005
|
+
.replace(/</g, "<")
|
|
1006
|
+
.replace(/>/g, ">")
|
|
1007
|
+
.replace(/"/g, """)
|
|
1008
|
+
.replace(/'/g, "'");
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* Format date for display (YYYY-MM-DD for consistency)
|
|
1013
|
+
*/
|
|
1014
|
+
export function formatDate(isoDate: string): string {
|
|
1015
|
+
if (isoDate === "unknown" || isoDate === "dev") return isoDate;
|
|
1016
|
+
try {
|
|
1017
|
+
const date = new Date(isoDate);
|
|
1018
|
+
return date.toISOString().split("T")[0];
|
|
1019
|
+
} catch {
|
|
1020
|
+
return isoDate;
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// ---------------------------------------------------------------------------
|
|
1025
|
+
// Provenance
|
|
1026
|
+
// ---------------------------------------------------------------------------
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* Get git information
|
|
1030
|
+
* @param root - The root directory for git commands
|
|
1031
|
+
* @param devMode - If true, use "dev"/"local" defaults; if false, use "unknown" defaults
|
|
1032
|
+
*/
|
|
1033
|
+
export async function getGitInfo(
|
|
1034
|
+
root: string,
|
|
1035
|
+
devMode = false,
|
|
1036
|
+
): Promise<{
|
|
1037
|
+
commit: string;
|
|
1038
|
+
commitDate: string;
|
|
1039
|
+
branch: string;
|
|
1040
|
+
}> {
|
|
1041
|
+
const defaultInfo = devMode
|
|
1042
|
+
? {
|
|
1043
|
+
commit: "dev",
|
|
1044
|
+
commitDate: new Date().toISOString(),
|
|
1045
|
+
branch: "local",
|
|
1046
|
+
}
|
|
1047
|
+
: {
|
|
1048
|
+
commit: "unknown",
|
|
1049
|
+
commitDate: "unknown",
|
|
1050
|
+
branch: "unknown",
|
|
1051
|
+
};
|
|
1052
|
+
|
|
1053
|
+
try {
|
|
1054
|
+
async function runGit(args: string[]): Promise<string> {
|
|
1055
|
+
const proc = Bun.spawn(["git", ...args], {
|
|
1056
|
+
cwd: root,
|
|
1057
|
+
stdout: "pipe",
|
|
1058
|
+
stderr: "ignore",
|
|
1059
|
+
});
|
|
1060
|
+
const out = (await new Response(proc.stdout).text()).trim();
|
|
1061
|
+
const code = await proc.exited;
|
|
1062
|
+
return code === 0 ? out : "";
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
const commit = await runGit(["rev-parse", "--short", "HEAD"]);
|
|
1066
|
+
const commitDate = await runGit(["log", "-1", "--format=%cI"]);
|
|
1067
|
+
const branch = await runGit(["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
1068
|
+
|
|
1069
|
+
return {
|
|
1070
|
+
commit: commit || defaultInfo.commit,
|
|
1071
|
+
commitDate: commitDate || defaultInfo.commitDate,
|
|
1072
|
+
branch: branch || defaultInfo.branch,
|
|
1073
|
+
};
|
|
1074
|
+
} catch {
|
|
1075
|
+
return defaultInfo;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
export async function resolveSiteVersion(
|
|
1080
|
+
root: string,
|
|
1081
|
+
configuredVersion?: string,
|
|
1082
|
+
): Promise<string | undefined> {
|
|
1083
|
+
if (typeof configuredVersion === "string" && configuredVersion.trim() !== "") {
|
|
1084
|
+
return configuredVersion.trim();
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
try {
|
|
1088
|
+
const proc = Bun.spawn(["git", "describe", "--tags", "--exact-match", "HEAD"], {
|
|
1089
|
+
cwd: root,
|
|
1090
|
+
stdout: "pipe",
|
|
1091
|
+
stderr: "ignore",
|
|
1092
|
+
});
|
|
1093
|
+
const out = (await new Response(proc.stdout).text()).trim();
|
|
1094
|
+
const code = await proc.exited;
|
|
1095
|
+
if (code === 0 && out) {
|
|
1096
|
+
return out.replace(/^v/, "");
|
|
1097
|
+
}
|
|
1098
|
+
} catch {
|
|
1099
|
+
// No git tag fallback available
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
return undefined;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
/**
|
|
1106
|
+
* Generate provenance information
|
|
1107
|
+
* @param root - The root directory
|
|
1108
|
+
* @param devMode - If true, use dev-friendly defaults
|
|
1109
|
+
*/
|
|
1110
|
+
export async function generateProvenance(
|
|
1111
|
+
root: string,
|
|
1112
|
+
devMode = false,
|
|
1113
|
+
siteVersion?: string,
|
|
1114
|
+
): Promise<Provenance> {
|
|
1115
|
+
const version = await resolveSiteVersion(root, siteVersion);
|
|
1116
|
+
const gitInfo = await getGitInfo(root, devMode);
|
|
1117
|
+
|
|
1118
|
+
return {
|
|
1119
|
+
version,
|
|
1120
|
+
buildDate: new Date().toISOString(),
|
|
1121
|
+
gitCommit: gitInfo.commit,
|
|
1122
|
+
gitCommitDate: gitInfo.commitDate,
|
|
1123
|
+
gitBranch: gitInfo.branch,
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// ---------------------------------------------------------------------------
|
|
1128
|
+
// Site configuration
|
|
1129
|
+
// ---------------------------------------------------------------------------
|
|
1130
|
+
|
|
1131
|
+
function normalizeFooter(footer: unknown): SiteFooter | undefined {
|
|
1132
|
+
if (!footer || typeof footer !== "object") return undefined;
|
|
1133
|
+
const raw = footer as Record<string, unknown>;
|
|
1134
|
+
let links: FooterLink[] | undefined;
|
|
1135
|
+
|
|
1136
|
+
if (Array.isArray(raw.links)) {
|
|
1137
|
+
links = raw.links
|
|
1138
|
+
.filter(
|
|
1139
|
+
(link): link is FooterLink =>
|
|
1140
|
+
typeof link === "object" &&
|
|
1141
|
+
link !== null &&
|
|
1142
|
+
typeof (link as Record<string, unknown>).text === "string" &&
|
|
1143
|
+
typeof (link as Record<string, unknown>).url === "string",
|
|
1144
|
+
)
|
|
1145
|
+
.slice(0, 10);
|
|
1146
|
+
|
|
1147
|
+
if (raw.links.length > 10) {
|
|
1148
|
+
console.warn("⚠ site.yaml footer.links supports at most 10 links; truncating extras.");
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
return {
|
|
1153
|
+
copyright: typeof raw.copyright === "string" ? raw.copyright : undefined,
|
|
1154
|
+
copyrightUrl: typeof raw.copyrightUrl === "string" ? raw.copyrightUrl : undefined,
|
|
1155
|
+
links,
|
|
1156
|
+
attribution: typeof raw.attribution === "boolean" ? raw.attribution : undefined,
|
|
1157
|
+
};
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/**
|
|
1161
|
+
* Load site configuration with fallback chain
|
|
1162
|
+
* @param root - The root directory
|
|
1163
|
+
* @param defaultTitle - Default title if no config found (default: "Getting Started")
|
|
1164
|
+
*/
|
|
1165
|
+
export async function loadSiteConfig(
|
|
1166
|
+
root: string,
|
|
1167
|
+
defaultTitle = "Getting Started",
|
|
1168
|
+
): Promise<SiteConfig> {
|
|
1169
|
+
// Try site.yaml first
|
|
1170
|
+
try {
|
|
1171
|
+
const configPath = join(root, "site.yaml");
|
|
1172
|
+
const content = await readFile(configPath, "utf-8");
|
|
1173
|
+
const parsed = parseYaml(content) as unknown as SiteConfig;
|
|
1174
|
+
const parsedRecord = parsed as unknown as Record<string, unknown>;
|
|
1175
|
+
|
|
1176
|
+
// Validate required fields
|
|
1177
|
+
if (!parsed.title || !parsed.brand || !parsed.sections) {
|
|
1178
|
+
throw new Error("site.yaml missing required fields: title, brand, sections");
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
return {
|
|
1182
|
+
docroot: parsed.docroot || ".",
|
|
1183
|
+
title: parsed.title,
|
|
1184
|
+
version: typeof parsedRecord.version === "string" ? parsedRecord.version : undefined,
|
|
1185
|
+
home: parsed.home as string | undefined,
|
|
1186
|
+
brand: {
|
|
1187
|
+
...parsed.brand,
|
|
1188
|
+
logo: parsed.brand.logo || "assets/brand/logo.png",
|
|
1189
|
+
favicon: parsed.brand.favicon || "assets/brand/favicon.png",
|
|
1190
|
+
logoType: parsed.brand.logoType || "icon",
|
|
1191
|
+
},
|
|
1192
|
+
sections: parsed.sections,
|
|
1193
|
+
footer: normalizeFooter(parsedRecord.footer),
|
|
1194
|
+
server: parsed.server,
|
|
1195
|
+
};
|
|
1196
|
+
} catch (e) {
|
|
1197
|
+
if ((e as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
1198
|
+
throw e;
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// Fallback: check for content/ directory
|
|
1203
|
+
try {
|
|
1204
|
+
const contentDir = join(root, "content");
|
|
1205
|
+
await stat(contentDir);
|
|
1206
|
+
|
|
1207
|
+
// Auto-discover sections from subdirectories
|
|
1208
|
+
const entries = await readdir(contentDir, { withFileTypes: true });
|
|
1209
|
+
const sections: SiteSection[] = [];
|
|
1210
|
+
|
|
1211
|
+
for (const entry of entries) {
|
|
1212
|
+
if (entry.isDirectory()) {
|
|
1213
|
+
sections.push({
|
|
1214
|
+
name: entry.name.charAt(0).toUpperCase() + entry.name.slice(1),
|
|
1215
|
+
path: entry.name,
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
if (sections.length > 0) {
|
|
1221
|
+
return {
|
|
1222
|
+
docroot: "content",
|
|
1223
|
+
title: "Documentation",
|
|
1224
|
+
brand: { name: "Docs", url: "/" },
|
|
1225
|
+
sections,
|
|
1226
|
+
};
|
|
1227
|
+
}
|
|
1228
|
+
} catch {
|
|
1229
|
+
// content/ doesn't exist
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
// Final fallback
|
|
1233
|
+
return {
|
|
1234
|
+
docroot: ".",
|
|
1235
|
+
title: defaultTitle,
|
|
1236
|
+
brand: { name: "Handbook", url: "/" },
|
|
1237
|
+
sections: [],
|
|
1238
|
+
};
|
|
1239
|
+
}
|