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
|
@@ -0,0 +1,781 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bundle a site into a single self-contained HTML file
|
|
3
|
+
*
|
|
4
|
+
* Usage: bun run bundle [folder] [options]
|
|
5
|
+
*
|
|
6
|
+
* Options:
|
|
7
|
+
* -o, --out <dir> Output directory [env: KITFLY_BUILD_OUT] [default: dist]
|
|
8
|
+
* -n, --name <file> Bundle filename [env: KITFLY_BUNDLE_NAME] [default: bundle.html]
|
|
9
|
+
* --raw Include raw markdown in bundle [env: KITFLY_BUILD_RAW] [default: true]
|
|
10
|
+
* --no-raw Don't include raw markdown
|
|
11
|
+
* --help Show help message
|
|
12
|
+
*
|
|
13
|
+
* Creates dist/bundle.html - a single file containing all content,
|
|
14
|
+
* styles, and scripts for offline viewing.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
18
|
+
import { basename, extname, join, resolve } from "node:path";
|
|
19
|
+
import { marked, Renderer } from "marked";
|
|
20
|
+
import { ENGINE_ASSETS_DIR } from "../src/engine.ts";
|
|
21
|
+
import {
|
|
22
|
+
buildBundleFooter,
|
|
23
|
+
// Navigation/template building
|
|
24
|
+
buildSectionNav,
|
|
25
|
+
// Types
|
|
26
|
+
type ContentFile,
|
|
27
|
+
collectFiles,
|
|
28
|
+
envBool,
|
|
29
|
+
// Config helpers
|
|
30
|
+
envString,
|
|
31
|
+
// Formatting
|
|
32
|
+
escapeHtml,
|
|
33
|
+
// YAML/Config parsing
|
|
34
|
+
loadSiteConfig,
|
|
35
|
+
// Markdown utilities
|
|
36
|
+
parseFrontmatter,
|
|
37
|
+
resolveSiteVersion,
|
|
38
|
+
resolveStylesPath,
|
|
39
|
+
type SiteConfig,
|
|
40
|
+
slugify,
|
|
41
|
+
validatePath,
|
|
42
|
+
} from "../src/shared.ts";
|
|
43
|
+
import { generateThemeCSS, getPrismUrls, loadTheme } from "../src/theme.ts";
|
|
44
|
+
|
|
45
|
+
// Defaults
|
|
46
|
+
const DEFAULT_OUT = "dist";
|
|
47
|
+
const DEFAULT_NAME = "bundle.html";
|
|
48
|
+
|
|
49
|
+
let ROOT = process.cwd();
|
|
50
|
+
let OUT_DIR = DEFAULT_OUT;
|
|
51
|
+
let BUNDLE_NAME = DEFAULT_NAME;
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// CLI argument parsing
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
interface ParsedArgs {
|
|
58
|
+
folder?: string;
|
|
59
|
+
out?: string;
|
|
60
|
+
name?: string;
|
|
61
|
+
raw?: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseArgs(argv: string[]): ParsedArgs {
|
|
65
|
+
const result: ParsedArgs = {};
|
|
66
|
+
for (let i = 0; i < argv.length; i++) {
|
|
67
|
+
const arg = argv[i];
|
|
68
|
+
const next = argv[i + 1];
|
|
69
|
+
|
|
70
|
+
if ((arg === "--out" || arg === "-o") && next && !next.startsWith("-")) {
|
|
71
|
+
result.out = next;
|
|
72
|
+
i++;
|
|
73
|
+
} else if ((arg === "--name" || arg === "-n") && next && !next.startsWith("-")) {
|
|
74
|
+
result.name = next;
|
|
75
|
+
i++;
|
|
76
|
+
} else if (arg === "--raw") {
|
|
77
|
+
result.raw = true;
|
|
78
|
+
} else if (arg === "--no-raw") {
|
|
79
|
+
result.raw = false;
|
|
80
|
+
} else if (!arg.startsWith("-") && !result.folder) {
|
|
81
|
+
result.folder = arg;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function getConfig(): {
|
|
88
|
+
folder?: string;
|
|
89
|
+
out: string;
|
|
90
|
+
name: string;
|
|
91
|
+
raw: boolean;
|
|
92
|
+
} {
|
|
93
|
+
const args = parseArgs(process.argv.slice(2));
|
|
94
|
+
return {
|
|
95
|
+
folder: args.folder,
|
|
96
|
+
out: args.out ?? envString("KITFLY_BUILD_OUT", DEFAULT_OUT),
|
|
97
|
+
name: args.name ?? envString("KITFLY_BUNDLE_NAME", DEFAULT_NAME),
|
|
98
|
+
raw: args.raw ?? envBool("KITFLY_BUILD_RAW", true),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Configure marked with custom renderer
|
|
103
|
+
const renderer = new Renderer();
|
|
104
|
+
const originalCode = renderer.code.bind(renderer);
|
|
105
|
+
renderer.code = (code: { type: "code"; raw: string; text: string; lang?: string }) => {
|
|
106
|
+
if (code.lang === "mermaid") {
|
|
107
|
+
const escaped = code.text.replace(/"/g, """);
|
|
108
|
+
return `<pre class="mermaid" data-mermaid-source="${escaped}">${code.text}</pre>`;
|
|
109
|
+
}
|
|
110
|
+
return originalCode(code);
|
|
111
|
+
};
|
|
112
|
+
renderer.heading = ({ text, depth }: { text: string; depth: number }) => {
|
|
113
|
+
const plain = text.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
|
|
114
|
+
const id = slugify(plain);
|
|
115
|
+
const inner = marked.parseInline(text) as string;
|
|
116
|
+
return `<h${depth} id="${id}">${inner}</h${depth}>\n`;
|
|
117
|
+
};
|
|
118
|
+
marked.use({ renderer });
|
|
119
|
+
|
|
120
|
+
// MIME type from file extension
|
|
121
|
+
function imageMime(filePath: string): string | null {
|
|
122
|
+
const ext = extname(filePath).toLowerCase();
|
|
123
|
+
const map: Record<string, string> = {
|
|
124
|
+
".png": "image/png",
|
|
125
|
+
".jpg": "image/jpeg",
|
|
126
|
+
".jpeg": "image/jpeg",
|
|
127
|
+
".gif": "image/gif",
|
|
128
|
+
".webp": "image/webp",
|
|
129
|
+
".svg": "image/svg+xml",
|
|
130
|
+
".ico": "image/x-icon",
|
|
131
|
+
};
|
|
132
|
+
return map[ext] ?? null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Resolve a local image path to an absolute filesystem path
|
|
136
|
+
async function resolveLocalImage(src: string, config: SiteConfig): Promise<string | null> {
|
|
137
|
+
const clean = decodeURIComponent(src).replace(/^\//, "");
|
|
138
|
+
|
|
139
|
+
// 1. /assets/... — try site assets then engine assets
|
|
140
|
+
if (clean.startsWith("assets/")) {
|
|
141
|
+
const rel = clean.slice("assets/".length);
|
|
142
|
+
for (const base of [join(ROOT, "assets"), ENGINE_ASSETS_DIR]) {
|
|
143
|
+
const p = join(base, rel);
|
|
144
|
+
try {
|
|
145
|
+
await stat(p);
|
|
146
|
+
return p;
|
|
147
|
+
} catch {
|
|
148
|
+
/* continue */
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 2. Resolve via docroot (handles absolute content paths)
|
|
154
|
+
const docPath = validatePath(ROOT, config.docroot, clean, false);
|
|
155
|
+
if (docPath) {
|
|
156
|
+
try {
|
|
157
|
+
await stat(docPath);
|
|
158
|
+
return docPath;
|
|
159
|
+
} catch {
|
|
160
|
+
/* continue */
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// 3. Search section directories
|
|
165
|
+
for (const section of config.sections) {
|
|
166
|
+
const sectionPath = validatePath(ROOT, config.docroot, section.path, false);
|
|
167
|
+
if (!sectionPath) continue;
|
|
168
|
+
const p = join(sectionPath, clean);
|
|
169
|
+
try {
|
|
170
|
+
await stat(p);
|
|
171
|
+
return p;
|
|
172
|
+
} catch {
|
|
173
|
+
/* continue */
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Convert a local file to a base64 data URI
|
|
181
|
+
async function fileToDataUri(filePath: string): Promise<string | null> {
|
|
182
|
+
const mime = imageMime(filePath);
|
|
183
|
+
if (!mime) return null;
|
|
184
|
+
const bytes = await readFile(filePath);
|
|
185
|
+
const base64 = Buffer.from(bytes).toString("base64");
|
|
186
|
+
return `data:${mime};base64,${base64}`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Inline all local <img src="..."> references as base64 data URIs
|
|
190
|
+
async function inlineLocalImages(html: string, config: SiteConfig): Promise<string> {
|
|
191
|
+
const imgRegex = /<img\s[^>]*src="([^"]+)"[^>]*>/g;
|
|
192
|
+
const matches = [...html.matchAll(imgRegex)];
|
|
193
|
+
let result = html;
|
|
194
|
+
|
|
195
|
+
for (const match of matches) {
|
|
196
|
+
const src = match[1];
|
|
197
|
+
// Skip external URLs and already-inlined data URIs
|
|
198
|
+
if (/^(https?:|data:)/i.test(src)) continue;
|
|
199
|
+
|
|
200
|
+
const resolved = await resolveLocalImage(src, config);
|
|
201
|
+
if (!resolved) {
|
|
202
|
+
console.warn(` ⚠ Image not found for inlining: ${src}`);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
const dataUri = await fileToDataUri(resolved);
|
|
206
|
+
if (!dataUri) continue;
|
|
207
|
+
result = result.replace(match[0], match[0].replace(`src="${src}"`, `src="${dataUri}"`));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return result;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Rewrite internal content links to hash navigation for single-file bundle
|
|
214
|
+
function rewriteContentLinks(
|
|
215
|
+
html: string,
|
|
216
|
+
files: ContentFile[],
|
|
217
|
+
currentUrlPath?: string,
|
|
218
|
+
docroot?: string,
|
|
219
|
+
): string {
|
|
220
|
+
// Build lookup maps: urlPath -> sectionId, plus docroot-stripped variants
|
|
221
|
+
const lookup = new Map<string, string>();
|
|
222
|
+
for (const file of files) {
|
|
223
|
+
const sid = slugify(file.urlPath);
|
|
224
|
+
lookup.set(file.urlPath, sid);
|
|
225
|
+
// Also register without the docroot prefix so content-relative links match
|
|
226
|
+
if (docroot && docroot !== "." && file.urlPath.startsWith(`${docroot}/`)) {
|
|
227
|
+
lookup.set(file.urlPath.slice(docroot.length + 1), sid);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function resolve(href: string): string | null {
|
|
232
|
+
let cleaned = href;
|
|
233
|
+
|
|
234
|
+
// Resolve relative links against current page's urlPath
|
|
235
|
+
if (currentUrlPath && !cleaned.startsWith("/")) {
|
|
236
|
+
const base = currentUrlPath.includes("/")
|
|
237
|
+
? currentUrlPath.slice(0, currentUrlPath.lastIndexOf("/"))
|
|
238
|
+
: "";
|
|
239
|
+
cleaned = base ? `${base}/${cleaned}` : cleaned;
|
|
240
|
+
|
|
241
|
+
// Resolve ../ segments
|
|
242
|
+
const parts = cleaned.split("/");
|
|
243
|
+
const resolved: string[] = [];
|
|
244
|
+
for (const part of parts) {
|
|
245
|
+
if (part === "..") {
|
|
246
|
+
resolved.pop();
|
|
247
|
+
} else if (part !== ".") {
|
|
248
|
+
resolved.push(part);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
cleaned = resolved.join("/");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Normalize
|
|
255
|
+
cleaned = cleaned
|
|
256
|
+
.replace(/^\//, "")
|
|
257
|
+
.replace(/\.(html|md)$/, "")
|
|
258
|
+
.replace(/\/$/, "");
|
|
259
|
+
|
|
260
|
+
return lookup.get(cleaned) ?? null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return html.replace(/<a\s([^>]*?)href="([^"]*)"([^>]*?)>/g, (_match, before, href, after) => {
|
|
264
|
+
// Skip external, anchor-only, and data links
|
|
265
|
+
if (/^(https?:|mailto:|data:|#)/i.test(href)) {
|
|
266
|
+
return `<a ${before}href="${href}"${after}>`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const sectionId = resolve(href);
|
|
270
|
+
if (sectionId) {
|
|
271
|
+
return `<a ${before}href="#${sectionId}"${after}>`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Leave unmatched links unchanged
|
|
275
|
+
return `<a ${before}href="${href}"${after}>`;
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function buildBundleNav(files: ContentFile[], config: SiteConfig): string {
|
|
280
|
+
const sectionFiles = new Map<string, ContentFile[]>();
|
|
281
|
+
for (const file of files) {
|
|
282
|
+
if (!sectionFiles.has(file.section)) {
|
|
283
|
+
sectionFiles.set(file.section, []);
|
|
284
|
+
}
|
|
285
|
+
sectionFiles.get(file.section)?.push(file);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const makeHref = (urlPath: string) => `#${slugify(urlPath)}`;
|
|
289
|
+
let html = '<ul class="bundle-nav">';
|
|
290
|
+
if (config.home) {
|
|
291
|
+
html += '<li><a href="#home" class="nav-home">Home</a></li>';
|
|
292
|
+
}
|
|
293
|
+
html += buildSectionNav(sectionFiles, config, null, makeHref);
|
|
294
|
+
html += "</ul>";
|
|
295
|
+
return html;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function buildBundleSidebarHeader(
|
|
299
|
+
config: SiteConfig,
|
|
300
|
+
version: string | undefined,
|
|
301
|
+
brandLogo: string,
|
|
302
|
+
): string {
|
|
303
|
+
const brandTarget = config.brand.external ? ' target="_blank" rel="noopener"' : "";
|
|
304
|
+
const logoClass = config.brand.logoType === "wordmark" ? "logo-wordmark" : "logo-icon";
|
|
305
|
+
const productHref = config.home ? "#home" : "#";
|
|
306
|
+
const versionLabel = version ? `v${version}` : "unversioned";
|
|
307
|
+
|
|
308
|
+
return `
|
|
309
|
+
<div class="sidebar-header">
|
|
310
|
+
<div class="logo ${logoClass}">
|
|
311
|
+
<a href="${config.brand.url}" class="logo-icon"${brandTarget}>
|
|
312
|
+
<img src="${brandLogo}" alt="${config.brand.name}" class="logo-img">
|
|
313
|
+
</a>
|
|
314
|
+
<span class="logo-text">
|
|
315
|
+
<a href="${config.brand.url}" class="brand"${brandTarget}>${config.brand.name}</a>
|
|
316
|
+
<a href="${productHref}" class="product">Bundle</a>
|
|
317
|
+
</span>
|
|
318
|
+
</div>
|
|
319
|
+
<div class="header-tools">
|
|
320
|
+
<button class="theme-toggle" onclick="toggleTheme()" title="Toggle theme" aria-label="Toggle theme">
|
|
321
|
+
<svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
322
|
+
<circle cx="12" cy="12" r="5"/>
|
|
323
|
+
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
|
|
324
|
+
</svg>
|
|
325
|
+
<svg class="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
326
|
+
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
|
327
|
+
</svg>
|
|
328
|
+
</button>
|
|
329
|
+
<div class="sidebar-meta">
|
|
330
|
+
<span class="meta-version">${versionLabel}</span>
|
|
331
|
+
<span class="meta-branch">bundle</span>
|
|
332
|
+
</div>
|
|
333
|
+
</div>
|
|
334
|
+
</div>`;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Resolve and inline a brand asset path, returning data URI or original path
|
|
338
|
+
async function inlineBrandAsset(assetPath: string): Promise<string> {
|
|
339
|
+
const clean = assetPath.replace(/^\//, "");
|
|
340
|
+
for (const base of [join(ROOT, "assets"), ENGINE_ASSETS_DIR]) {
|
|
341
|
+
// assetPath is typically "assets/brand/logo.png" — strip leading "assets/"
|
|
342
|
+
const rel = clean.startsWith("assets/") ? clean.slice("assets/".length) : clean;
|
|
343
|
+
const p = join(base, rel);
|
|
344
|
+
try {
|
|
345
|
+
await stat(p);
|
|
346
|
+
const uri = await fileToDataUri(p);
|
|
347
|
+
if (uri) return uri;
|
|
348
|
+
} catch {
|
|
349
|
+
/* continue */
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return assetPath;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Fetch and cache external scripts for offline bundle
|
|
356
|
+
async function fetchScript(url: string): Promise<string> {
|
|
357
|
+
console.log(` ↓ Fetching ${url.split("/").pop()}...`);
|
|
358
|
+
const response = await fetch(url);
|
|
359
|
+
if (!response.ok) {
|
|
360
|
+
throw new Error(`Failed to fetch ${url}: ${response.status}`);
|
|
361
|
+
}
|
|
362
|
+
return response.text();
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function fetchExternalAssets(prismUrls: { light: string; dark: string }): Promise<{
|
|
366
|
+
prismCss: string;
|
|
367
|
+
prismCssDark: string;
|
|
368
|
+
prismCore: string;
|
|
369
|
+
prismAutoloader: string;
|
|
370
|
+
mermaid: string;
|
|
371
|
+
}> {
|
|
372
|
+
const [prismCss, prismCssDark, prismCore, prismAutoloader, mermaid] = await Promise.all([
|
|
373
|
+
fetchScript(prismUrls.light),
|
|
374
|
+
fetchScript(prismUrls.dark),
|
|
375
|
+
fetchScript("https://cdn.jsdelivr.net/npm/prismjs@1/components/prism-core.min.js"),
|
|
376
|
+
fetchScript(
|
|
377
|
+
"https://cdn.jsdelivr.net/npm/prismjs@1/plugins/autoloader/prism-autoloader.min.js",
|
|
378
|
+
),
|
|
379
|
+
fetchScript("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"),
|
|
380
|
+
]);
|
|
381
|
+
|
|
382
|
+
return { prismCss, prismCssDark, prismCore, prismAutoloader, mermaid };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Build the bundle
|
|
386
|
+
async function bundle() {
|
|
387
|
+
console.log("Bundling site...\n");
|
|
388
|
+
|
|
389
|
+
const config = await loadSiteConfig(ROOT, "Documentation");
|
|
390
|
+
console.log(` ✓ Loaded config: "${config.title}" (${config.sections.length} sections)`);
|
|
391
|
+
|
|
392
|
+
const theme = await loadTheme(ROOT);
|
|
393
|
+
console.log(` ✓ Loaded theme: "${theme.name || "default"}"`);
|
|
394
|
+
const prismUrls = getPrismUrls(theme);
|
|
395
|
+
|
|
396
|
+
const files = await collectFiles(ROOT, config);
|
|
397
|
+
if (files.length === 0) {
|
|
398
|
+
console.error("No content files found. Cannot create bundle.");
|
|
399
|
+
process.exit(1);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Read CSS
|
|
403
|
+
const css = await readFile(await resolveStylesPath(ROOT), "utf-8");
|
|
404
|
+
console.log(" ✓ Loaded styles.css");
|
|
405
|
+
|
|
406
|
+
// Fetch external assets for offline support
|
|
407
|
+
const assets = await fetchExternalAssets(prismUrls);
|
|
408
|
+
console.log(" ✓ Fetched external assets (Prism, Mermaid)");
|
|
409
|
+
|
|
410
|
+
// Resolve site version (site.yaml version, then git tag)
|
|
411
|
+
const version = await resolveSiteVersion(ROOT, config.version);
|
|
412
|
+
|
|
413
|
+
// Build navigation and content sections
|
|
414
|
+
const sections: Map<string, { id: string; title: string; html: string }[]> = new Map();
|
|
415
|
+
|
|
416
|
+
// Add home page as first item if specified
|
|
417
|
+
if (config.home) {
|
|
418
|
+
const homePath = validatePath(ROOT, config.docroot, config.home);
|
|
419
|
+
if (homePath) {
|
|
420
|
+
try {
|
|
421
|
+
await stat(homePath);
|
|
422
|
+
const content = await readFile(homePath, "utf-8");
|
|
423
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
424
|
+
const title = (frontmatter.title as string) || "Home";
|
|
425
|
+
let htmlContent = marked.parse(body) as string;
|
|
426
|
+
htmlContent = await inlineLocalImages(htmlContent, config);
|
|
427
|
+
htmlContent = rewriteContentLinks(htmlContent, files, undefined, config.docroot);
|
|
428
|
+
sections.set("Home", [{ id: "home", title, html: htmlContent }]);
|
|
429
|
+
console.log(` ✓ Added home page: ${config.home}`);
|
|
430
|
+
} catch {
|
|
431
|
+
console.warn(` ⚠ Home page ${config.home} not found`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Collect page metadata and raw content for AI accessibility
|
|
437
|
+
const pageIndex: {
|
|
438
|
+
path: string;
|
|
439
|
+
title: string;
|
|
440
|
+
section: string;
|
|
441
|
+
description?: string;
|
|
442
|
+
}[] = [];
|
|
443
|
+
const rawMarkdown: { path: string; content: string }[] = [];
|
|
444
|
+
|
|
445
|
+
for (const file of files) {
|
|
446
|
+
const content = await readFile(file.path, "utf-8");
|
|
447
|
+
let title = basename(file.path).replace(/\.(md|yaml|json)$/, "");
|
|
448
|
+
let description: string | undefined;
|
|
449
|
+
let htmlContent: string;
|
|
450
|
+
|
|
451
|
+
if (file.path.endsWith(".yaml")) {
|
|
452
|
+
htmlContent = `<pre><code class="language-yaml">${escapeHtml(content)}</code></pre>`;
|
|
453
|
+
} else if (file.path.endsWith(".json")) {
|
|
454
|
+
// Render JSON as code block (pretty-printed)
|
|
455
|
+
let prettyJson = content;
|
|
456
|
+
try {
|
|
457
|
+
prettyJson = JSON.stringify(JSON.parse(content), null, 2);
|
|
458
|
+
} catch {
|
|
459
|
+
// Use original if not valid JSON
|
|
460
|
+
}
|
|
461
|
+
htmlContent = `<pre><code class="language-json">${escapeHtml(prettyJson)}</code></pre>`;
|
|
462
|
+
} else {
|
|
463
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
464
|
+
if (frontmatter.title) {
|
|
465
|
+
title = frontmatter.title as string;
|
|
466
|
+
}
|
|
467
|
+
if (frontmatter.description) {
|
|
468
|
+
description = frontmatter.description as string;
|
|
469
|
+
}
|
|
470
|
+
htmlContent = marked.parse(body) as string;
|
|
471
|
+
|
|
472
|
+
// Collect raw markdown for AI accessibility
|
|
473
|
+
if (INCLUDE_RAW) {
|
|
474
|
+
rawMarkdown.push({ path: file.urlPath, content });
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Collect page metadata for content index
|
|
479
|
+
pageIndex.push({
|
|
480
|
+
path: file.urlPath,
|
|
481
|
+
title,
|
|
482
|
+
section: file.section,
|
|
483
|
+
description,
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
// Inline any SVG references
|
|
487
|
+
htmlContent = await inlineLocalImages(htmlContent, config);
|
|
488
|
+
htmlContent = rewriteContentLinks(htmlContent, files, file.urlPath, config.docroot);
|
|
489
|
+
|
|
490
|
+
const sectionId = slugify(file.urlPath);
|
|
491
|
+
|
|
492
|
+
if (!sections.has(file.section)) {
|
|
493
|
+
sections.set(file.section, []);
|
|
494
|
+
}
|
|
495
|
+
sections.get(file.section)?.push({ id: sectionId, title, html: htmlContent });
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Build navigation HTML from shared hierarchical nav tree
|
|
499
|
+
const navHtml = buildBundleNav(files, config);
|
|
500
|
+
|
|
501
|
+
// Build content HTML
|
|
502
|
+
let contentHtml = "";
|
|
503
|
+
for (const [, items] of sections) {
|
|
504
|
+
for (const item of items) {
|
|
505
|
+
contentHtml += `
|
|
506
|
+
<section id="${item.id}" class="bundle-section">
|
|
507
|
+
<h1 class="section-title">${item.title}</h1>
|
|
508
|
+
${item.html}
|
|
509
|
+
</section>
|
|
510
|
+
`;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const themeCSS = generateThemeCSS(theme);
|
|
515
|
+
|
|
516
|
+
// Inline brand assets for self-contained bundle
|
|
517
|
+
const brandLogo = await inlineBrandAsset(config.brand.logo || "assets/brand/logo.png");
|
|
518
|
+
const brandFavicon = await inlineBrandAsset(config.brand.favicon || "assets/brand/favicon.png");
|
|
519
|
+
|
|
520
|
+
// Build the complete HTML document
|
|
521
|
+
const html = `<!DOCTYPE html>
|
|
522
|
+
<html lang="en">
|
|
523
|
+
<head>
|
|
524
|
+
<meta charset="UTF-8">
|
|
525
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
526
|
+
<title>${config.title}</title>
|
|
527
|
+
<link rel="icon" href="${brandFavicon}">
|
|
528
|
+
<style>
|
|
529
|
+
${css}
|
|
530
|
+
|
|
531
|
+
/* Bundle-specific styles */
|
|
532
|
+
.bundle-nav { position: sticky; top: 1rem; }
|
|
533
|
+
.bundle-section {
|
|
534
|
+
padding: 2rem 0;
|
|
535
|
+
border-bottom: 1px solid var(--color-border);
|
|
536
|
+
scroll-margin-top: 1rem;
|
|
537
|
+
}
|
|
538
|
+
.bundle-section:last-child { border-bottom: none; }
|
|
539
|
+
.section-title { margin-top: 0; }
|
|
540
|
+
|
|
541
|
+
/* Print styles for bundle */
|
|
542
|
+
@media print {
|
|
543
|
+
.sidebar { display: none !important; }
|
|
544
|
+
.bundle-section { page-break-inside: avoid; }
|
|
545
|
+
}
|
|
546
|
+
</style>
|
|
547
|
+
${themeCSS}
|
|
548
|
+
<style id="prism-light">
|
|
549
|
+
${assets.prismCss}
|
|
550
|
+
</style>
|
|
551
|
+
<style id="prism-dark" disabled>
|
|
552
|
+
${assets.prismCssDark}
|
|
553
|
+
</style>
|
|
554
|
+
<script>
|
|
555
|
+
(function() {
|
|
556
|
+
const saved = localStorage.getItem('theme');
|
|
557
|
+
if (saved) {
|
|
558
|
+
document.documentElement.setAttribute('data-theme', saved);
|
|
559
|
+
}
|
|
560
|
+
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
561
|
+
const isDark = saved === 'dark' || (!saved && prefersDark);
|
|
562
|
+
if (isDark) {
|
|
563
|
+
document.getElementById('prism-light')?.setAttribute('disabled', '');
|
|
564
|
+
document.getElementById('prism-dark')?.removeAttribute('disabled');
|
|
565
|
+
}
|
|
566
|
+
})();
|
|
567
|
+
</script>
|
|
568
|
+
</head>
|
|
569
|
+
<body>
|
|
570
|
+
<div class="layout">
|
|
571
|
+
<nav class="sidebar">
|
|
572
|
+
${buildBundleSidebarHeader(config, version, brandLogo)}
|
|
573
|
+
<div class="sidebar-nav">
|
|
574
|
+
${navHtml}
|
|
575
|
+
</div>
|
|
576
|
+
</nav>
|
|
577
|
+
<main class="content">
|
|
578
|
+
<article class="prose">
|
|
579
|
+
${contentHtml}
|
|
580
|
+
</article>
|
|
581
|
+
</main>
|
|
582
|
+
</div>
|
|
583
|
+
${buildBundleFooter(version, config)}
|
|
584
|
+
<script>
|
|
585
|
+
${assets.prismCore}
|
|
586
|
+
</script>
|
|
587
|
+
<script>
|
|
588
|
+
${assets.prismAutoloader}
|
|
589
|
+
</script>
|
|
590
|
+
<script>
|
|
591
|
+
${assets.mermaid}
|
|
592
|
+
</script>
|
|
593
|
+
<script>
|
|
594
|
+
// Initialize Mermaid
|
|
595
|
+
function getMermaidTheme() {
|
|
596
|
+
const theme = document.documentElement.getAttribute('data-theme');
|
|
597
|
+
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
598
|
+
const isDark = theme === 'dark' || (!theme && prefersDark);
|
|
599
|
+
return isDark ? 'dark' : 'neutral';
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
mermaid.initialize({ startOnLoad: true, theme: getMermaidTheme() });
|
|
603
|
+
|
|
604
|
+
window.reinitMermaid = async function() {
|
|
605
|
+
mermaid.initialize({ startOnLoad: false, theme: getMermaidTheme() });
|
|
606
|
+
const diagrams = document.querySelectorAll('.mermaid');
|
|
607
|
+
for (const el of diagrams) {
|
|
608
|
+
const code = el.getAttribute('data-mermaid-source');
|
|
609
|
+
if (code) {
|
|
610
|
+
el.innerHTML = code;
|
|
611
|
+
el.removeAttribute('data-processed');
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
await mermaid.run({ nodes: diagrams });
|
|
615
|
+
};
|
|
616
|
+
</script>
|
|
617
|
+
<script>
|
|
618
|
+
function toggleTheme() {
|
|
619
|
+
const html = document.documentElement;
|
|
620
|
+
const current = html.getAttribute('data-theme');
|
|
621
|
+
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
622
|
+
|
|
623
|
+
let next;
|
|
624
|
+
if (current === 'dark') {
|
|
625
|
+
next = 'light';
|
|
626
|
+
} else if (current === 'light') {
|
|
627
|
+
next = 'dark';
|
|
628
|
+
} else {
|
|
629
|
+
next = prefersDark ? 'light' : 'dark';
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
html.setAttribute('data-theme', next);
|
|
633
|
+
localStorage.setItem('theme', next);
|
|
634
|
+
|
|
635
|
+
const prismLight = document.getElementById('prism-light');
|
|
636
|
+
const prismDark = document.getElementById('prism-dark');
|
|
637
|
+
if (next === 'dark') {
|
|
638
|
+
prismLight?.setAttribute('disabled', '');
|
|
639
|
+
prismDark?.removeAttribute('disabled');
|
|
640
|
+
} else {
|
|
641
|
+
prismLight?.removeAttribute('disabled');
|
|
642
|
+
prismDark?.setAttribute('disabled', '');
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
if (window.reinitMermaid) {
|
|
646
|
+
window.reinitMermaid();
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Smooth scroll for anchor links
|
|
651
|
+
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
|
652
|
+
anchor.addEventListener('click', function (e) {
|
|
653
|
+
e.preventDefault();
|
|
654
|
+
const target = document.querySelector(this.getAttribute('href'));
|
|
655
|
+
if (target) {
|
|
656
|
+
target.scrollIntoView({ behavior: 'smooth' });
|
|
657
|
+
history.pushState(null, '', this.getAttribute('href'));
|
|
658
|
+
}
|
|
659
|
+
});
|
|
660
|
+
});
|
|
661
|
+
</script>
|
|
662
|
+
<!-- AI Accessibility: Content Index -->
|
|
663
|
+
<script type="application/json" id="kitfly-content-index">
|
|
664
|
+
${JSON.stringify(
|
|
665
|
+
{
|
|
666
|
+
version,
|
|
667
|
+
title: config.title,
|
|
668
|
+
generated: new Date().toISOString(),
|
|
669
|
+
format: "bundle",
|
|
670
|
+
pages: pageIndex.map((p) => ({
|
|
671
|
+
id: slugify(p.path),
|
|
672
|
+
path: p.path,
|
|
673
|
+
title: p.title,
|
|
674
|
+
section: p.section,
|
|
675
|
+
description: p.description,
|
|
676
|
+
})),
|
|
677
|
+
},
|
|
678
|
+
null,
|
|
679
|
+
2,
|
|
680
|
+
)}
|
|
681
|
+
</script>
|
|
682
|
+
${
|
|
683
|
+
INCLUDE_RAW
|
|
684
|
+
? ` <!-- AI Accessibility: Raw Markdown -->
|
|
685
|
+
<script type="application/json" id="kitfly-raw-markdown">
|
|
686
|
+
${JSON.stringify(
|
|
687
|
+
rawMarkdown.reduce(
|
|
688
|
+
(acc, { path, content }) => {
|
|
689
|
+
acc[path] = content;
|
|
690
|
+
return acc;
|
|
691
|
+
},
|
|
692
|
+
{} as Record<string, string>,
|
|
693
|
+
),
|
|
694
|
+
)}
|
|
695
|
+
</script>`
|
|
696
|
+
: "<!-- Raw markdown disabled (--no-raw) -->"
|
|
697
|
+
}
|
|
698
|
+
</body>
|
|
699
|
+
</html>`;
|
|
700
|
+
|
|
701
|
+
// Write the bundle
|
|
702
|
+
const outDir = join(ROOT, OUT_DIR);
|
|
703
|
+
await mkdir(outDir, { recursive: true });
|
|
704
|
+
const bundlePath = join(outDir, BUNDLE_NAME);
|
|
705
|
+
await writeFile(bundlePath, html);
|
|
706
|
+
|
|
707
|
+
const sizeKB = (Buffer.byteLength(html, "utf-8") / 1024).toFixed(1);
|
|
708
|
+
console.log(` ✓ ${BUNDLE_NAME} (${sizeKB} KB, ${files.length} pages)`);
|
|
709
|
+
|
|
710
|
+
console.log(`\n\x1b[32mBundle complete! Output: ${OUT_DIR}/${BUNDLE_NAME}\x1b[0m`);
|
|
711
|
+
console.log(`\nTo view: open ${OUT_DIR}/${BUNDLE_NAME}`);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
export interface BundleOptions {
|
|
715
|
+
folder?: string;
|
|
716
|
+
out?: string;
|
|
717
|
+
name?: string;
|
|
718
|
+
raw?: boolean; // Include raw markdown in bundle (default: true)
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
let INCLUDE_RAW = true;
|
|
722
|
+
|
|
723
|
+
export {
|
|
724
|
+
buildBundleNav,
|
|
725
|
+
buildBundleSidebarHeader,
|
|
726
|
+
fileToDataUri,
|
|
727
|
+
imageMime,
|
|
728
|
+
inlineBrandAsset,
|
|
729
|
+
inlineLocalImages,
|
|
730
|
+
parseArgs,
|
|
731
|
+
resolveLocalImage,
|
|
732
|
+
rewriteContentLinks,
|
|
733
|
+
};
|
|
734
|
+
|
|
735
|
+
export async function bundleSite(options: BundleOptions = {}) {
|
|
736
|
+
if (options.folder) {
|
|
737
|
+
ROOT = resolve(process.cwd(), options.folder);
|
|
738
|
+
}
|
|
739
|
+
if (options.out) {
|
|
740
|
+
OUT_DIR = options.out;
|
|
741
|
+
}
|
|
742
|
+
if (options.name) {
|
|
743
|
+
BUNDLE_NAME = options.name;
|
|
744
|
+
}
|
|
745
|
+
if (options.raw === false) {
|
|
746
|
+
INCLUDE_RAW = false;
|
|
747
|
+
}
|
|
748
|
+
await bundle();
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
if (import.meta.main) {
|
|
752
|
+
// Check for help flag
|
|
753
|
+
if (process.argv.includes("--help")) {
|
|
754
|
+
console.log(`
|
|
755
|
+
Usage: bun run bundle [folder] [options]
|
|
756
|
+
|
|
757
|
+
Options:
|
|
758
|
+
-o, --out <dir> Output directory [env: KITFLY_BUILD_OUT] [default: ${DEFAULT_OUT}]
|
|
759
|
+
-n, --name <file> Bundle filename [env: KITFLY_BUNDLE_NAME] [default: ${DEFAULT_NAME}]
|
|
760
|
+
--raw Include raw markdown in bundle [env: KITFLY_BUILD_RAW] [default: true]
|
|
761
|
+
--no-raw Don't include raw markdown
|
|
762
|
+
--help Show this help message
|
|
763
|
+
|
|
764
|
+
Examples:
|
|
765
|
+
bun run bundle
|
|
766
|
+
bun run bundle ./docs
|
|
767
|
+
bun run bundle --name docs.html
|
|
768
|
+
bun run bundle ./docs --out ./public --name handbook.html
|
|
769
|
+
KITFLY_BUNDLE_NAME=docs.html bun run bundle
|
|
770
|
+
`);
|
|
771
|
+
process.exit(0);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const cfg = getConfig();
|
|
775
|
+
bundleSite({
|
|
776
|
+
folder: cfg.folder,
|
|
777
|
+
out: cfg.out,
|
|
778
|
+
name: cfg.name,
|
|
779
|
+
raw: cfg.raw,
|
|
780
|
+
}).catch(console.error);
|
|
781
|
+
}
|