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,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
|
+
});
|