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.
Files changed (62) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/LICENSE +21 -0
  3. package/README.md +136 -0
  4. package/VERSION +1 -0
  5. package/package.json +63 -0
  6. package/schemas/README.md +32 -0
  7. package/schemas/site.schema.json +5 -0
  8. package/schemas/theme.schema.json +5 -0
  9. package/schemas/v0/site.schema.json +172 -0
  10. package/schemas/v0/theme.schema.json +210 -0
  11. package/scripts/build-all.ts +121 -0
  12. package/scripts/build.ts +601 -0
  13. package/scripts/bundle.ts +781 -0
  14. package/scripts/dev.ts +777 -0
  15. package/scripts/generate-checksums.sh +78 -0
  16. package/scripts/release/export-release-key.sh +28 -0
  17. package/scripts/release/release-guard-tag-version.sh +79 -0
  18. package/scripts/release/sign-release-assets.sh +123 -0
  19. package/scripts/release/upload-release-assets.sh +76 -0
  20. package/scripts/release/upload-release-provenance.sh +52 -0
  21. package/scripts/release/verify-public-key.sh +48 -0
  22. package/scripts/release/verify-signatures.sh +117 -0
  23. package/scripts/version-sync.ts +82 -0
  24. package/src/__tests__/build.test.ts +240 -0
  25. package/src/__tests__/bundle.test.ts +786 -0
  26. package/src/__tests__/cli.test.ts +706 -0
  27. package/src/__tests__/crucible.test.ts +1043 -0
  28. package/src/__tests__/engine.test.ts +157 -0
  29. package/src/__tests__/init.test.ts +450 -0
  30. package/src/__tests__/pipeline.test.ts +1087 -0
  31. package/src/__tests__/productbook.test.ts +1206 -0
  32. package/src/__tests__/runbook.test.ts +974 -0
  33. package/src/__tests__/server-registry.test.ts +1251 -0
  34. package/src/__tests__/servicebook.test.ts +1248 -0
  35. package/src/__tests__/shared.test.ts +2005 -0
  36. package/src/__tests__/styles.test.ts +14 -0
  37. package/src/__tests__/theme-schema.test.ts +47 -0
  38. package/src/__tests__/theme.test.ts +554 -0
  39. package/src/cli.ts +582 -0
  40. package/src/commands/init.ts +92 -0
  41. package/src/commands/update.ts +444 -0
  42. package/src/engine.ts +20 -0
  43. package/src/logger.ts +15 -0
  44. package/src/migrations/0000_schema_versioning.ts +67 -0
  45. package/src/migrations/0001_server_port.ts +52 -0
  46. package/src/migrations/0002_brand_logo.ts +49 -0
  47. package/src/migrations/index.ts +26 -0
  48. package/src/migrations/schema.ts +24 -0
  49. package/src/server-registry.ts +405 -0
  50. package/src/shared.ts +1239 -0
  51. package/src/site/styles.css +931 -0
  52. package/src/site/template.html +193 -0
  53. package/src/templates/crucible.ts +1163 -0
  54. package/src/templates/driver.ts +876 -0
  55. package/src/templates/handbook.ts +339 -0
  56. package/src/templates/minimal.ts +139 -0
  57. package/src/templates/pipeline.ts +966 -0
  58. package/src/templates/productbook.ts +1032 -0
  59. package/src/templates/runbook.ts +829 -0
  60. package/src/templates/schema.ts +119 -0
  61. package/src/templates/servicebook.ts +1242 -0
  62. 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, "&quot;");
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
+ }