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,786 @@
1
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import {
6
+ buildBundleNav,
7
+ buildBundleSidebarHeader,
8
+ fileToDataUri,
9
+ imageMime,
10
+ inlineLocalImages,
11
+ parseArgs,
12
+ resolveLocalImage,
13
+ rewriteContentLinks,
14
+ } from "../../scripts/bundle.ts";
15
+ import type { ContentFile, SiteConfig } from "../shared.ts";
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Temp dir helpers
19
+ // ---------------------------------------------------------------------------
20
+
21
+ const tempDirs: string[] = [];
22
+
23
+ async function makeTempDir(): Promise<string> {
24
+ const dir = await mkdtemp(join(tmpdir(), "kitfly-bundle-test-"));
25
+ tempDirs.push(dir);
26
+ return dir;
27
+ }
28
+
29
+ afterEach(async () => {
30
+ for (const d of tempDirs) {
31
+ await rm(d, { recursive: true, force: true }).catch(() => {});
32
+ }
33
+ tempDirs.length = 0;
34
+ });
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // parseArgs
38
+ // ---------------------------------------------------------------------------
39
+
40
+ describe("parseArgs", () => {
41
+ it("returns empty object for empty argv", () => {
42
+ expect(parseArgs([])).toEqual({});
43
+ });
44
+
45
+ it("parses --out with long flag", () => {
46
+ const result = parseArgs(["--out", "public"]);
47
+ expect(result.out).toBe("public");
48
+ });
49
+
50
+ it("parses -o short flag", () => {
51
+ const result = parseArgs(["-o", "build"]);
52
+ expect(result.out).toBe("build");
53
+ });
54
+
55
+ it("parses --name with long flag", () => {
56
+ const result = parseArgs(["--name", "docs.html"]);
57
+ expect(result.name).toBe("docs.html");
58
+ });
59
+
60
+ it("parses -n short flag", () => {
61
+ const result = parseArgs(["-n", "site.html"]);
62
+ expect(result.name).toBe("site.html");
63
+ });
64
+
65
+ it("parses --raw as true", () => {
66
+ const result = parseArgs(["--raw"]);
67
+ expect(result.raw).toBe(true);
68
+ });
69
+
70
+ it("parses --no-raw as false", () => {
71
+ const result = parseArgs(["--no-raw"]);
72
+ expect(result.raw).toBe(false);
73
+ });
74
+
75
+ it("parses positional folder argument", () => {
76
+ const result = parseArgs(["./docs"]);
77
+ expect(result.folder).toBe("./docs");
78
+ });
79
+
80
+ it("parses all options together", () => {
81
+ const result = parseArgs(["./mysite", "--out", "public", "-n", "app.html", "--no-raw"]);
82
+ expect(result.folder).toBe("./mysite");
83
+ expect(result.out).toBe("public");
84
+ expect(result.name).toBe("app.html");
85
+ expect(result.raw).toBe(false);
86
+ });
87
+
88
+ it("ignores --out when next arg starts with dash", () => {
89
+ const result = parseArgs(["--out", "--name", "foo.html"]);
90
+ expect(result.out).toBeUndefined();
91
+ expect(result.name).toBe("foo.html");
92
+ });
93
+
94
+ it("ignores --name when next arg starts with dash", () => {
95
+ const result = parseArgs(["--name", "--raw"]);
96
+ expect(result.name).toBeUndefined();
97
+ expect(result.raw).toBe(true);
98
+ });
99
+
100
+ it("uses first positional arg as folder, ignores subsequent positionals", () => {
101
+ const result = parseArgs(["./first", "./second"]);
102
+ expect(result.folder).toBe("./first");
103
+ });
104
+
105
+ it("ignores unknown flags", () => {
106
+ const result = parseArgs(["--unknown", "--out", "dist"]);
107
+ expect(result.out).toBe("dist");
108
+ });
109
+ });
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // imageMime
113
+ // ---------------------------------------------------------------------------
114
+
115
+ describe("imageMime", () => {
116
+ it("returns image/png for .png", () => {
117
+ expect(imageMime("logo.png")).toBe("image/png");
118
+ });
119
+
120
+ it("returns image/jpeg for .jpg", () => {
121
+ expect(imageMime("photo.jpg")).toBe("image/jpeg");
122
+ });
123
+
124
+ it("returns image/jpeg for .jpeg", () => {
125
+ expect(imageMime("photo.jpeg")).toBe("image/jpeg");
126
+ });
127
+
128
+ it("returns image/gif for .gif", () => {
129
+ expect(imageMime("anim.gif")).toBe("image/gif");
130
+ });
131
+
132
+ it("returns image/webp for .webp", () => {
133
+ expect(imageMime("hero.webp")).toBe("image/webp");
134
+ });
135
+
136
+ it("returns image/svg+xml for .svg", () => {
137
+ expect(imageMime("icon.svg")).toBe("image/svg+xml");
138
+ });
139
+
140
+ it("returns image/x-icon for .ico", () => {
141
+ expect(imageMime("favicon.ico")).toBe("image/x-icon");
142
+ });
143
+
144
+ it("returns null for unsupported extensions", () => {
145
+ expect(imageMime("doc.pdf")).toBeNull();
146
+ expect(imageMime("data.json")).toBeNull();
147
+ expect(imageMime("style.css")).toBeNull();
148
+ });
149
+
150
+ it("is case-insensitive for extension", () => {
151
+ expect(imageMime("logo.PNG")).toBe("image/png");
152
+ expect(imageMime("photo.JPG")).toBe("image/jpeg");
153
+ expect(imageMime("icon.SVG")).toBe("image/svg+xml");
154
+ });
155
+
156
+ it("handles paths with directories", () => {
157
+ expect(imageMime("/assets/brand/logo.png")).toBe("image/png");
158
+ expect(imageMime("images/hero.webp")).toBe("image/webp");
159
+ });
160
+ });
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // rewriteContentLinks
164
+ // ---------------------------------------------------------------------------
165
+
166
+ describe("rewriteContentLinks", () => {
167
+ const files: ContentFile[] = [
168
+ {
169
+ path: "/docs/guide/start.md",
170
+ urlPath: "docs/guide/start",
171
+ section: "Guide",
172
+ sectionBase: "docs/guide",
173
+ },
174
+ {
175
+ path: "/docs/reference/api.md",
176
+ urlPath: "docs/reference/api",
177
+ section: "Reference",
178
+ sectionBase: "docs/reference",
179
+ },
180
+ {
181
+ path: "/docs/guide/advanced.md",
182
+ urlPath: "docs/guide/advanced",
183
+ section: "Guide",
184
+ sectionBase: "docs/guide",
185
+ },
186
+ ];
187
+
188
+ it("rewrites absolute content links to hash links", () => {
189
+ const html = '<a href="/docs/guide/start">Getting Started</a>';
190
+ const result = rewriteContentLinks(html, files);
191
+ expect(result).toContain('href="#docsguidestart"');
192
+ });
193
+
194
+ it("rewrites links with .md extension", () => {
195
+ const html = '<a href="docs/guide/start.md">Getting Started</a>';
196
+ const result = rewriteContentLinks(html, files);
197
+ expect(result).toContain('href="#docsguidestart"');
198
+ });
199
+
200
+ it("rewrites links with .html extension", () => {
201
+ const html = '<a href="docs/guide/start.html">Getting Started</a>';
202
+ const result = rewriteContentLinks(html, files);
203
+ expect(result).toContain('href="#docsguidestart"');
204
+ });
205
+
206
+ it("leaves external http links unchanged", () => {
207
+ const html = '<a href="https://example.com">External</a>';
208
+ const result = rewriteContentLinks(html, files);
209
+ expect(result).toContain('href="https://example.com"');
210
+ });
211
+
212
+ it("leaves external https links unchanged", () => {
213
+ const html = '<a href="http://example.com">External</a>';
214
+ const result = rewriteContentLinks(html, files);
215
+ expect(result).toContain('href="http://example.com"');
216
+ });
217
+
218
+ it("leaves mailto links unchanged", () => {
219
+ const html = '<a href="mailto:test@example.com">Email</a>';
220
+ const result = rewriteContentLinks(html, files);
221
+ expect(result).toContain('href="mailto:test@example.com"');
222
+ });
223
+
224
+ it("leaves anchor-only links unchanged", () => {
225
+ const html = '<a href="#section-id">Section</a>';
226
+ const result = rewriteContentLinks(html, files);
227
+ expect(result).toContain('href="#section-id"');
228
+ });
229
+
230
+ it("leaves data: links unchanged", () => {
231
+ const html = '<a href="data:text/html,hello">Data</a>';
232
+ const result = rewriteContentLinks(html, files);
233
+ expect(result).toContain('href="data:text/html,hello"');
234
+ });
235
+
236
+ it("leaves unmatched internal links unchanged", () => {
237
+ const html = '<a href="nonexistent/page">Missing</a>';
238
+ const result = rewriteContentLinks(html, files);
239
+ expect(result).toContain('href="nonexistent/page"');
240
+ });
241
+
242
+ it("resolves relative links from current page", () => {
243
+ // From docs/guide/start, a relative link to "advanced" should resolve to docs/guide/advanced
244
+ const html = '<a href="advanced">Advanced</a>';
245
+ const result = rewriteContentLinks(html, files, "docs/guide/start");
246
+ expect(result).toContain('href="#docsguideadvanced"');
247
+ });
248
+
249
+ it("resolves ../ relative links", () => {
250
+ // From docs/guide/start, ../reference/api should resolve to docs/reference/api
251
+ const html = '<a href="../reference/api">API</a>';
252
+ const result = rewriteContentLinks(html, files, "docs/guide/start");
253
+ expect(result).toContain('href="#docsreferenceapi"');
254
+ });
255
+
256
+ it("preserves attributes on anchor tags", () => {
257
+ const html = '<a class="link" href="docs/guide/start" target="_blank">Start</a>';
258
+ const result = rewriteContentLinks(html, files);
259
+ expect(result).toContain('class="link"');
260
+ });
261
+
262
+ it("handles multiple links in the same HTML", () => {
263
+ const html = `
264
+ <a href="docs/guide/start">Start</a>
265
+ <a href="https://external.com">External</a>
266
+ <a href="docs/reference/api">API</a>
267
+ `;
268
+ const result = rewriteContentLinks(html, files);
269
+ expect(result).toContain('href="#docsguidestart"');
270
+ expect(result).toContain('href="https://external.com"');
271
+ expect(result).toContain('href="#docsreferenceapi"');
272
+ });
273
+
274
+ it("strips trailing slashes before matching", () => {
275
+ const html = '<a href="docs/guide/start/">Start</a>';
276
+ const result = rewriteContentLinks(html, files);
277
+ expect(result).toContain('href="#docsguidestart"');
278
+ });
279
+
280
+ it("strips docroot prefix for matching when docroot is provided", () => {
281
+ const html = '<a href="guide/start">Start</a>';
282
+ const result = rewriteContentLinks(html, files, undefined, "docs");
283
+ expect(result).toContain('href="#docsguidestart"');
284
+ });
285
+
286
+ it("handles HTML with no links", () => {
287
+ const html = "<p>No links here</p>";
288
+ const result = rewriteContentLinks(html, files);
289
+ expect(result).toBe("<p>No links here</p>");
290
+ });
291
+
292
+ it("handles empty files array", () => {
293
+ const html = '<a href="docs/guide/start">Start</a>';
294
+ const result = rewriteContentLinks(html, []);
295
+ expect(result).toContain('href="docs/guide/start"');
296
+ });
297
+ });
298
+
299
+ // ---------------------------------------------------------------------------
300
+ // fileToDataUri
301
+ // ---------------------------------------------------------------------------
302
+
303
+ describe("fileToDataUri", () => {
304
+ it("converts a PNG file to base64 data URI", async () => {
305
+ const dir = await makeTempDir();
306
+ // 1x1 red PNG pixel
307
+ const pngBytes = Buffer.from(
308
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==",
309
+ "base64",
310
+ );
311
+ const pngPath = join(dir, "test.png");
312
+ await writeFile(pngPath, pngBytes);
313
+
314
+ const result = await fileToDataUri(pngPath);
315
+ expect(result).not.toBeNull();
316
+ expect(typeof result).toBe("string");
317
+ expect((result as string).startsWith("data:image/png;base64,")).toBe(true);
318
+ // Round-trip: the base64 should decode back to the original bytes
319
+ const base64Part = (result as string).split(",")[1];
320
+ expect(Buffer.from(base64Part, "base64")).toEqual(pngBytes);
321
+ });
322
+
323
+ it("converts a SVG file to base64 data URI", async () => {
324
+ const dir = await makeTempDir();
325
+ const svgContent = '<svg xmlns="http://www.w3.org/2000/svg"><circle r="5"/></svg>';
326
+ const svgPath = join(dir, "icon.svg");
327
+ await writeFile(svgPath, svgContent);
328
+
329
+ const result = await fileToDataUri(svgPath);
330
+ expect(result).not.toBeNull();
331
+ expect((result as string).startsWith("data:image/svg+xml;base64,")).toBe(true);
332
+ });
333
+
334
+ it("converts a JPEG file to base64 data URI", async () => {
335
+ const dir = await makeTempDir();
336
+ const jpgPath = join(dir, "photo.jpg");
337
+ await writeFile(jpgPath, Buffer.from([0xff, 0xd8, 0xff, 0xe0])); // minimal JPEG header
338
+
339
+ const result = await fileToDataUri(jpgPath);
340
+ expect(result).not.toBeNull();
341
+ expect((result as string).startsWith("data:image/jpeg;base64,")).toBe(true);
342
+ });
343
+
344
+ it("returns null for unsupported file types", async () => {
345
+ const dir = await makeTempDir();
346
+ const txtPath = join(dir, "readme.txt");
347
+ await writeFile(txtPath, "hello");
348
+
349
+ const result = await fileToDataUri(txtPath);
350
+ expect(result).toBeNull();
351
+ });
352
+
353
+ it("returns null for a .md file", async () => {
354
+ const dir = await makeTempDir();
355
+ const mdPath = join(dir, "doc.md");
356
+ await writeFile(mdPath, "# Hello");
357
+
358
+ const result = await fileToDataUri(mdPath);
359
+ expect(result).toBeNull();
360
+ });
361
+ });
362
+
363
+ // ---------------------------------------------------------------------------
364
+ // resolveLocalImage
365
+ // ---------------------------------------------------------------------------
366
+
367
+ describe("resolveLocalImage", () => {
368
+ it("resolves an image from the docroot", async () => {
369
+ const dir = await makeTempDir();
370
+ const imgDir = join(dir, "images");
371
+ await mkdir(imgDir, { recursive: true });
372
+ const imgPath = join(imgDir, "photo.png");
373
+ await writeFile(imgPath, "fake-png");
374
+
375
+ const config: SiteConfig = {
376
+ docroot: ".",
377
+ title: "Test",
378
+ brand: { name: "Test", url: "/" },
379
+ sections: [],
380
+ };
381
+
382
+ // resolveLocalImage uses the module-level ROOT variable which we can't easily override
383
+ // But we can test the case where it finds a file via section paths
384
+ const configWithSection: SiteConfig = {
385
+ ...config,
386
+ sections: [{ name: "Docs", path: "." }],
387
+ };
388
+
389
+ // The function uses a module-level ROOT; we test behavior through the
390
+ // src "images/photo.png" being resolved via section directories
391
+ // This is a limited test since ROOT is module-scoped
392
+ const result = await resolveLocalImage("images/photo.png", configWithSection);
393
+ // Result depends on module-level ROOT which points to cwd - may not find our temp file
394
+ // So we just verify it returns string | null without throwing
395
+ expect(result === null || typeof result === "string").toBe(true);
396
+ });
397
+
398
+ it("returns null for a nonexistent image", async () => {
399
+ const config: SiteConfig = {
400
+ docroot: ".",
401
+ title: "Test",
402
+ brand: { name: "Test", url: "/" },
403
+ sections: [],
404
+ };
405
+
406
+ const result = await resolveLocalImage("nonexistent/image.png", config);
407
+ expect(result).toBeNull();
408
+ });
409
+
410
+ it("skips external URLs gracefully (function expects local paths)", async () => {
411
+ const config: SiteConfig = {
412
+ docroot: ".",
413
+ title: "Test",
414
+ brand: { name: "Test", url: "/" },
415
+ sections: [],
416
+ };
417
+
418
+ // resolveLocalImage expects local paths, not URLs
419
+ // external filtering happens in inlineLocalImages
420
+ const result = await resolveLocalImage("https://example.com/img.png", config);
421
+ // Should return null since this won't resolve to any local file
422
+ expect(result).toBeNull();
423
+ });
424
+ });
425
+
426
+ // ---------------------------------------------------------------------------
427
+ // inlineLocalImages
428
+ // ---------------------------------------------------------------------------
429
+
430
+ describe("inlineLocalImages", () => {
431
+ it("skips external URLs", async () => {
432
+ const config: SiteConfig = {
433
+ docroot: ".",
434
+ title: "Test",
435
+ brand: { name: "Test", url: "/" },
436
+ sections: [],
437
+ };
438
+
439
+ const html = '<img src="https://example.com/photo.png" alt="External">';
440
+ const result = await inlineLocalImages(html, config);
441
+ expect(result).toBe(html); // Unchanged
442
+ });
443
+
444
+ it("skips already-inlined data URIs", async () => {
445
+ const config: SiteConfig = {
446
+ docroot: ".",
447
+ title: "Test",
448
+ brand: { name: "Test", url: "/" },
449
+ sections: [],
450
+ };
451
+
452
+ const html = '<img src="data:image/png;base64,AAAA" alt="Inlined">';
453
+ const result = await inlineLocalImages(html, config);
454
+ expect(result).toBe(html); // Unchanged
455
+ });
456
+
457
+ it("returns HTML unchanged when there are no img tags", async () => {
458
+ const config: SiteConfig = {
459
+ docroot: ".",
460
+ title: "Test",
461
+ brand: { name: "Test", url: "/" },
462
+ sections: [],
463
+ };
464
+
465
+ const html = "<p>No images here</p>";
466
+ const result = await inlineLocalImages(html, config);
467
+ expect(result).toBe(html);
468
+ });
469
+
470
+ it("preserves non-local images that cannot be resolved", async () => {
471
+ const config: SiteConfig = {
472
+ docroot: ".",
473
+ title: "Test",
474
+ brand: { name: "Test", url: "/" },
475
+ sections: [],
476
+ };
477
+
478
+ const html = '<img src="nonexistent/photo.png" alt="Missing">';
479
+ const result = await inlineLocalImages(html, config);
480
+ // The image can't be found so it stays unchanged
481
+ expect(result).toContain('src="nonexistent/photo.png"');
482
+ });
483
+ });
484
+
485
+ // ---------------------------------------------------------------------------
486
+ // buildBundleNav (existing tests preserved + new)
487
+ // ---------------------------------------------------------------------------
488
+
489
+ describe("buildBundleNav", () => {
490
+ const config: SiteConfig = {
491
+ docroot: ".",
492
+ title: "Bundle Test",
493
+ brand: { name: "Test", url: "/" },
494
+ sections: [
495
+ { name: "Guide", path: "guide" },
496
+ { name: "Reference", path: "reference" },
497
+ ],
498
+ };
499
+
500
+ it("renders nested pages with details/summary and #hash links", () => {
501
+ const files: ContentFile[] = [
502
+ {
503
+ path: "/guide/start.md",
504
+ urlPath: "guide/start",
505
+ section: "Guide",
506
+ sectionBase: "guide",
507
+ },
508
+ {
509
+ path: "/guide/api/users.md",
510
+ urlPath: "guide/api/users",
511
+ section: "Guide",
512
+ sectionBase: "guide",
513
+ },
514
+ ];
515
+
516
+ const html = buildBundleNav(files, config);
517
+ expect(html).toContain('<summary class="nav-group">api</summary>');
518
+ expect(html).toContain('<a href="#guideapiusers">users</a>');
519
+ expect(html).toContain("<details>");
520
+ expect(html).not.toContain("<details open>");
521
+ });
522
+
523
+ it("renders flat sections without details groups", () => {
524
+ const files: ContentFile[] = [
525
+ {
526
+ path: "/reference/cli.md",
527
+ urlPath: "reference/cli",
528
+ section: "Reference",
529
+ sectionBase: "reference",
530
+ },
531
+ ];
532
+
533
+ const html = buildBundleNav(files, config);
534
+ expect(html).toContain('<span class="nav-section">Reference</span>');
535
+ expect(html).toContain('<a href="#referencecli">cli</a>');
536
+ expect(html).not.toContain("<details>");
537
+ });
538
+
539
+ it("includes home link when home is configured", () => {
540
+ const files: ContentFile[] = [
541
+ {
542
+ path: "/guide/start.md",
543
+ urlPath: "guide/start",
544
+ section: "Guide",
545
+ sectionBase: "guide",
546
+ },
547
+ ];
548
+ const withHome = { ...config, home: "index.md" };
549
+
550
+ const html = buildBundleNav(files, withHome);
551
+ expect(html).toContain('<a href="#home" class="nav-home">Home</a>');
552
+ });
553
+
554
+ it("omits home link when home is not configured", () => {
555
+ const files: ContentFile[] = [
556
+ {
557
+ path: "/guide/start.md",
558
+ urlPath: "guide/start",
559
+ section: "Guide",
560
+ sectionBase: "guide",
561
+ },
562
+ ];
563
+
564
+ const html = buildBundleNav(files, config);
565
+ expect(html).not.toContain("nav-home");
566
+ });
567
+
568
+ it("renders empty nav for empty files", () => {
569
+ const html = buildBundleNav([], config);
570
+ expect(html).toContain('<ul class="bundle-nav">');
571
+ expect(html).toContain("</ul>");
572
+ expect(html).not.toContain("nav-section");
573
+ });
574
+
575
+ it("renders multiple sections in config order", () => {
576
+ const files: ContentFile[] = [
577
+ {
578
+ path: "/guide/start.md",
579
+ urlPath: "guide/start",
580
+ section: "Guide",
581
+ sectionBase: "guide",
582
+ },
583
+ {
584
+ path: "/reference/cli.md",
585
+ urlPath: "reference/cli",
586
+ section: "Reference",
587
+ sectionBase: "reference",
588
+ },
589
+ ];
590
+
591
+ const html = buildBundleNav(files, config);
592
+ const guidePos = html.indexOf("Guide");
593
+ const refPos = html.indexOf("Reference");
594
+ expect(guidePos).toBeLessThan(refPos);
595
+ });
596
+
597
+ it("generates slugified hash hrefs", () => {
598
+ const files: ContentFile[] = [
599
+ {
600
+ path: "/guide/getting-started.md",
601
+ urlPath: "guide/getting-started",
602
+ section: "Guide",
603
+ sectionBase: "guide",
604
+ },
605
+ ];
606
+
607
+ const html = buildBundleNav(files, config);
608
+ expect(html).toContain('href="#guidegetting-started"');
609
+ });
610
+ });
611
+
612
+ // ---------------------------------------------------------------------------
613
+ // buildBundleSidebarHeader (existing tests preserved + new)
614
+ // ---------------------------------------------------------------------------
615
+
616
+ describe("buildBundleSidebarHeader", () => {
617
+ it("applies wordmark logo class and template-aligned tools/meta structure", () => {
618
+ const config: SiteConfig = {
619
+ docroot: ".",
620
+ title: "Bundle Test",
621
+ home: "index.md",
622
+ brand: { name: "Acme Corp", url: "/", logoType: "wordmark" },
623
+ sections: [],
624
+ };
625
+
626
+ const html = buildBundleSidebarHeader(config, "0.1.0", "data:image/png;base64,AA==");
627
+ expect(html).toContain('class="logo logo-wordmark"');
628
+ expect(html).toContain('<div class="header-tools">');
629
+ expect(html).toContain('<div class="sidebar-meta">');
630
+ expect(html).toContain('<a href="#home" class="product">Bundle</a>');
631
+ });
632
+
633
+ it("defaults to icon logo class when logoType is not set", () => {
634
+ const config: SiteConfig = {
635
+ docroot: ".",
636
+ title: "Bundle Test",
637
+ brand: { name: "Kitfly", url: "/" },
638
+ sections: [],
639
+ };
640
+
641
+ const html = buildBundleSidebarHeader(config, "0.1.0", "data:image/png;base64,AA==");
642
+ expect(html).toContain('class="logo logo-icon"');
643
+ });
644
+
645
+ it("bundle output includes custom sidebar width from theme layout", async () => {
646
+ const source = await readFile(`${process.cwd()}/scripts/bundle.ts`, "utf-8");
647
+ expect(source).toContain("const themeCSS = generateThemeCSS(theme);");
648
+ expect(source).toContain(`\${themeCSS}`);
649
+ });
650
+
651
+ it("shows version label with v prefix when version is provided", () => {
652
+ const config: SiteConfig = {
653
+ docroot: ".",
654
+ title: "Test",
655
+ brand: { name: "Test", url: "/" },
656
+ sections: [],
657
+ };
658
+
659
+ const html = buildBundleSidebarHeader(config, "2.3.4", "logo.png");
660
+ expect(html).toContain("v2.3.4");
661
+ });
662
+
663
+ it("shows 'unversioned' when version is undefined", () => {
664
+ const config: SiteConfig = {
665
+ docroot: ".",
666
+ title: "Test",
667
+ brand: { name: "Test", url: "/" },
668
+ sections: [],
669
+ };
670
+
671
+ const html = buildBundleSidebarHeader(config, undefined, "logo.png");
672
+ expect(html).toContain("unversioned");
673
+ });
674
+
675
+ it("uses brand name in logo alt text", () => {
676
+ const config: SiteConfig = {
677
+ docroot: ".",
678
+ title: "Test",
679
+ brand: { name: "My Company", url: "/" },
680
+ sections: [],
681
+ };
682
+
683
+ const html = buildBundleSidebarHeader(config, "1.0", "logo.png");
684
+ expect(html).toContain('alt="My Company"');
685
+ });
686
+
687
+ it("links brand to brand URL", () => {
688
+ const config: SiteConfig = {
689
+ docroot: ".",
690
+ title: "Test",
691
+ brand: { name: "Acme", url: "https://acme.com" },
692
+ sections: [],
693
+ };
694
+
695
+ const html = buildBundleSidebarHeader(config, "1.0", "logo.png");
696
+ expect(html).toContain('href="https://acme.com"');
697
+ });
698
+
699
+ it("adds target _blank for external brands", () => {
700
+ const config: SiteConfig = {
701
+ docroot: ".",
702
+ title: "Test",
703
+ brand: { name: "Acme", url: "https://acme.com", external: true },
704
+ sections: [],
705
+ };
706
+
707
+ const html = buildBundleSidebarHeader(config, "1.0", "logo.png");
708
+ expect(html).toContain('target="_blank"');
709
+ expect(html).toContain('rel="noopener"');
710
+ });
711
+
712
+ it("does not add target _blank when brand is not external", () => {
713
+ const config: SiteConfig = {
714
+ docroot: ".",
715
+ title: "Test",
716
+ brand: { name: "Acme", url: "/" },
717
+ sections: [],
718
+ };
719
+
720
+ const html = buildBundleSidebarHeader(config, "1.0", "logo.png");
721
+ expect(html).not.toContain('target="_blank"');
722
+ });
723
+
724
+ it("product link uses # when home is not set", () => {
725
+ const config: SiteConfig = {
726
+ docroot: ".",
727
+ title: "Test",
728
+ brand: { name: "Test", url: "/" },
729
+ sections: [],
730
+ };
731
+
732
+ const html = buildBundleSidebarHeader(config, "1.0", "logo.png");
733
+ expect(html).toContain('<a href="#" class="product">Bundle</a>');
734
+ });
735
+
736
+ it("product link uses #home when home is set", () => {
737
+ const config: SiteConfig = {
738
+ docroot: ".",
739
+ title: "Test",
740
+ home: "index.md",
741
+ brand: { name: "Test", url: "/" },
742
+ sections: [],
743
+ };
744
+
745
+ const html = buildBundleSidebarHeader(config, "1.0", "logo.png");
746
+ expect(html).toContain('<a href="#home" class="product">Bundle</a>');
747
+ });
748
+
749
+ it("includes theme toggle button", () => {
750
+ const config: SiteConfig = {
751
+ docroot: ".",
752
+ title: "Test",
753
+ brand: { name: "Test", url: "/" },
754
+ sections: [],
755
+ };
756
+
757
+ const html = buildBundleSidebarHeader(config, "1.0", "logo.png");
758
+ expect(html).toContain('class="theme-toggle"');
759
+ expect(html).toContain("toggleTheme()");
760
+ });
761
+
762
+ it("includes meta-branch as 'bundle'", () => {
763
+ const config: SiteConfig = {
764
+ docroot: ".",
765
+ title: "Test",
766
+ brand: { name: "Test", url: "/" },
767
+ sections: [],
768
+ };
769
+
770
+ const html = buildBundleSidebarHeader(config, "1.0", "logo.png");
771
+ expect(html).toContain('<span class="meta-branch">bundle</span>');
772
+ });
773
+
774
+ it("uses provided brandLogo in img src", () => {
775
+ const config: SiteConfig = {
776
+ docroot: ".",
777
+ title: "Test",
778
+ brand: { name: "Test", url: "/" },
779
+ sections: [],
780
+ };
781
+
782
+ const logo = "data:image/svg+xml;base64,PHN2Zy8+";
783
+ const html = buildBundleSidebarHeader(config, "1.0", logo);
784
+ expect(html).toContain(`src="${logo}"`);
785
+ });
786
+ });