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,2005 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Basic tests for shared utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join, resolve } from "node:path";
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
9
|
+
import {
|
|
10
|
+
buildBreadcrumbsSimple,
|
|
11
|
+
buildBreadcrumbsStatic,
|
|
12
|
+
buildBundleFooter,
|
|
13
|
+
buildFooter,
|
|
14
|
+
buildNavSimple,
|
|
15
|
+
buildNavStatic,
|
|
16
|
+
buildPageMeta,
|
|
17
|
+
buildToc,
|
|
18
|
+
type ContentFile,
|
|
19
|
+
collectFiles,
|
|
20
|
+
envBool,
|
|
21
|
+
envInt,
|
|
22
|
+
envString,
|
|
23
|
+
escapeHtml,
|
|
24
|
+
exists,
|
|
25
|
+
formatDate,
|
|
26
|
+
generateProvenance,
|
|
27
|
+
getGitInfo,
|
|
28
|
+
KITFLY_BRAND,
|
|
29
|
+
loadSiteConfig,
|
|
30
|
+
type Provenance,
|
|
31
|
+
parseFrontmatter,
|
|
32
|
+
parseValue,
|
|
33
|
+
parseYaml,
|
|
34
|
+
resolveSiteVersion,
|
|
35
|
+
type SiteConfig,
|
|
36
|
+
slugify,
|
|
37
|
+
stripQuotes,
|
|
38
|
+
toUrlPath,
|
|
39
|
+
validatePath,
|
|
40
|
+
} from "../shared.ts";
|
|
41
|
+
|
|
42
|
+
describe("slugify", () => {
|
|
43
|
+
it("converts text to URL-friendly slug", () => {
|
|
44
|
+
expect(slugify("Hello World")).toBe("hello-world");
|
|
45
|
+
expect(slugify("Getting Started")).toBe("getting-started");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("handles special characters", () => {
|
|
49
|
+
expect(slugify("What's New?")).toBe("whats-new");
|
|
50
|
+
expect(slugify("C++ Programming")).toBe("c-programming");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("handles consecutive spaces", () => {
|
|
54
|
+
expect(slugify("Multiple Spaces")).toBe("multiple-spaces");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("escapeHtml", () => {
|
|
59
|
+
it("escapes HTML special characters", () => {
|
|
60
|
+
expect(escapeHtml("<script>")).toBe("<script>");
|
|
61
|
+
expect(escapeHtml('Hello "World"')).toBe("Hello "World"");
|
|
62
|
+
expect(escapeHtml("a & b")).toBe("a & b");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("handles empty string", () => {
|
|
66
|
+
expect(escapeHtml("")).toBe("");
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("parseFrontmatter", () => {
|
|
71
|
+
it("extracts YAML frontmatter from markdown", () => {
|
|
72
|
+
const content = `---
|
|
73
|
+
title: My Page
|
|
74
|
+
description: A test page
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
# Content here`;
|
|
78
|
+
|
|
79
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
80
|
+
expect(frontmatter.title).toBe("My Page");
|
|
81
|
+
expect(frontmatter.description).toBe("A test page");
|
|
82
|
+
expect(body.trim()).toBe("# Content here");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("handles content without frontmatter", () => {
|
|
86
|
+
const content = "# Just a heading\n\nSome text";
|
|
87
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
88
|
+
expect(Object.keys(frontmatter)).toHaveLength(0);
|
|
89
|
+
expect(body).toBe(content);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// parseYaml, parseValue, stripQuotes tests
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
describe("stripQuotes", () => {
|
|
98
|
+
it("removes double quotes from string", () => {
|
|
99
|
+
expect(stripQuotes('"hello world"')).toBe("hello world");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("removes single quotes from string", () => {
|
|
103
|
+
expect(stripQuotes("'hello world'")).toBe("hello world");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("returns string unchanged if no quotes", () => {
|
|
107
|
+
expect(stripQuotes("hello world")).toBe("hello world");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("returns string unchanged if only starting quote", () => {
|
|
111
|
+
expect(stripQuotes('"hello world')).toBe('"hello world');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("returns string unchanged if only ending quote", () => {
|
|
115
|
+
expect(stripQuotes('hello world"')).toBe('hello world"');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("returns string unchanged if mismatched quotes", () => {
|
|
119
|
+
expect(stripQuotes("\"hello world'")).toBe("\"hello world'");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("handles empty string", () => {
|
|
123
|
+
expect(stripQuotes("")).toBe("");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("handles empty quoted string", () => {
|
|
127
|
+
expect(stripQuotes('""')).toBe("");
|
|
128
|
+
expect(stripQuotes("''")).toBe("");
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("parseValue", () => {
|
|
133
|
+
it("parses boolean true", () => {
|
|
134
|
+
expect(parseValue("true")).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("parses boolean false", () => {
|
|
138
|
+
expect(parseValue("false")).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("parses quoted boolean as string", () => {
|
|
142
|
+
// parseValue strips quotes before interpreting booleans
|
|
143
|
+
expect(parseValue('"true"')).toBe(true);
|
|
144
|
+
expect(parseValue('"false"')).toBe(false);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("returns string values unchanged", () => {
|
|
148
|
+
expect(parseValue("hello")).toBe("hello");
|
|
149
|
+
expect(parseValue("123")).toBe("123");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("strips quotes from string values", () => {
|
|
153
|
+
expect(parseValue('"hello"')).toBe("hello");
|
|
154
|
+
expect(parseValue("'world'")).toBe("world");
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("parseYaml", () => {
|
|
159
|
+
it("parses simple key-value pairs", () => {
|
|
160
|
+
const yaml = `title: My Site
|
|
161
|
+
description: A test site`;
|
|
162
|
+
const result = parseYaml(yaml);
|
|
163
|
+
expect(result.title).toBe("My Site");
|
|
164
|
+
expect(result.description).toBe("A test site");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("parses nested objects", () => {
|
|
168
|
+
const yaml = `brand:
|
|
169
|
+
name: My Brand
|
|
170
|
+
url: https://example.com`;
|
|
171
|
+
const result = parseYaml(yaml);
|
|
172
|
+
expect(result.brand).toEqual({
|
|
173
|
+
name: "My Brand",
|
|
174
|
+
url: "https://example.com",
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("parses arrays with objects", () => {
|
|
179
|
+
const yaml = `sections:
|
|
180
|
+
- name: Overview
|
|
181
|
+
path: ./
|
|
182
|
+
- name: Guides
|
|
183
|
+
path: guides`;
|
|
184
|
+
const result = parseYaml(yaml);
|
|
185
|
+
expect(result.sections).toEqual([
|
|
186
|
+
{ name: "Overview", path: "./" },
|
|
187
|
+
{ name: "Guides", path: "guides" },
|
|
188
|
+
]);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("parses inline arrays", () => {
|
|
192
|
+
const yaml = `sections:
|
|
193
|
+
- name: Overview
|
|
194
|
+
files: ["README.md", "index.md"]`;
|
|
195
|
+
const result = parseYaml(yaml);
|
|
196
|
+
expect((result.sections as unknown[])[0]).toEqual({
|
|
197
|
+
name: "Overview",
|
|
198
|
+
files: ["README.md", "index.md"],
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("parses boolean values", () => {
|
|
203
|
+
const yaml = `brand:
|
|
204
|
+
external: true
|
|
205
|
+
internal: false`;
|
|
206
|
+
const result = parseYaml(yaml);
|
|
207
|
+
expect((result.brand as Record<string, unknown>).external).toBe(true);
|
|
208
|
+
expect((result.brand as Record<string, unknown>).internal).toBe(false);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("skips comment lines", () => {
|
|
212
|
+
const yaml = `# This is a comment
|
|
213
|
+
title: My Site
|
|
214
|
+
# Another comment
|
|
215
|
+
description: Test`;
|
|
216
|
+
const result = parseYaml(yaml);
|
|
217
|
+
expect(result.title).toBe("My Site");
|
|
218
|
+
expect(result.description).toBe("Test");
|
|
219
|
+
expect(Object.keys(result)).toHaveLength(2);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("skips empty lines", () => {
|
|
223
|
+
const yaml = `title: My Site
|
|
224
|
+
|
|
225
|
+
description: Test`;
|
|
226
|
+
const result = parseYaml(yaml);
|
|
227
|
+
expect(result.title).toBe("My Site");
|
|
228
|
+
expect(result.description).toBe("Test");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("handles quoted values", () => {
|
|
232
|
+
const yaml = `title: "My Site"
|
|
233
|
+
description: 'A test site'`;
|
|
234
|
+
const result = parseYaml(yaml);
|
|
235
|
+
expect(result.title).toBe("My Site");
|
|
236
|
+
expect(result.description).toBe("A test site");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("handles simple array items", () => {
|
|
240
|
+
const yaml = `tags:
|
|
241
|
+
- javascript
|
|
242
|
+
- typescript`;
|
|
243
|
+
const result = parseYaml(yaml);
|
|
244
|
+
expect(result.tags).toEqual(["javascript", "typescript"]);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("returns empty object for empty input", () => {
|
|
248
|
+
const result = parseYaml("");
|
|
249
|
+
expect(result).toEqual({});
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
// validatePath, toUrlPath, exists tests
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
describe("validatePath", () => {
|
|
258
|
+
const root = "/home/user/project";
|
|
259
|
+
|
|
260
|
+
it("returns resolved path for valid path within root", () => {
|
|
261
|
+
const result = validatePath(root, "docs", "guide.md");
|
|
262
|
+
expect(result).toBe(resolve(root, "docs", "guide.md"));
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("returns null for path escaping root with ../", () => {
|
|
266
|
+
const result = validatePath(root, "docs", "../../../etc/passwd");
|
|
267
|
+
expect(result).toBeNull();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("allows paths that stay within root", () => {
|
|
271
|
+
const result = validatePath(root, "docs", "../content/file.md");
|
|
272
|
+
expect(result).toBe(resolve(root, "content", "file.md"));
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("returns root path itself when path resolves to root", () => {
|
|
276
|
+
const result = validatePath(root, ".", ".");
|
|
277
|
+
expect(result).toBe(resolve(root));
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("logs error when logErrors is true and path escapes", () => {
|
|
281
|
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
282
|
+
const result = validatePath(root, "docs", "../../../../etc/passwd", true);
|
|
283
|
+
expect(result).toBeNull();
|
|
284
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Path escapes repo root"));
|
|
285
|
+
consoleSpy.mockRestore();
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("does not log error when logErrors is false", () => {
|
|
289
|
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
290
|
+
const result = validatePath(root, "docs", "../../../../etc/passwd", false);
|
|
291
|
+
expect(result).toBeNull();
|
|
292
|
+
expect(consoleSpy).not.toHaveBeenCalled();
|
|
293
|
+
consoleSpy.mockRestore();
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
describe("toUrlPath", () => {
|
|
298
|
+
it("strips root prefix from path", () => {
|
|
299
|
+
const root = "/home/user/project";
|
|
300
|
+
const result = toUrlPath(root, resolve(root, "docs", "guide.md"));
|
|
301
|
+
expect(result).toBe("docs/guide.md");
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("returns path unchanged if it does not start with root", () => {
|
|
305
|
+
const result = toUrlPath("/home/user/project", "/other/path/file.md");
|
|
306
|
+
expect(result).toBe("/other/path/file.md");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("handles nested paths correctly", () => {
|
|
310
|
+
const root = "/root";
|
|
311
|
+
const result = toUrlPath(root, resolve(root, "a", "b", "c", "file.md"));
|
|
312
|
+
expect(result).toBe("a/b/c/file.md");
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
describe("exists", () => {
|
|
317
|
+
it("returns true for existing file", async () => {
|
|
318
|
+
// Test with current test file
|
|
319
|
+
const result = await exists(__filename);
|
|
320
|
+
expect(result).toBe(true);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("returns false for non-existing file", async () => {
|
|
324
|
+
const result = await exists("/nonexistent/path/to/file.txt");
|
|
325
|
+
expect(result).toBe(false);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("returns true for existing directory", async () => {
|
|
329
|
+
const result = await exists(__dirname);
|
|
330
|
+
expect(result).toBe(true);
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
// buildToc tests
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
describe("buildToc", () => {
|
|
339
|
+
it("builds TOC from h2 and h3 headings", () => {
|
|
340
|
+
const html = `
|
|
341
|
+
<h2 id="intro">Introduction</h2>
|
|
342
|
+
<p>Some content</p>
|
|
343
|
+
<h3 id="setup">Setup</h3>
|
|
344
|
+
<p>More content</p>
|
|
345
|
+
<h2 id="usage">Usage</h2>
|
|
346
|
+
`;
|
|
347
|
+
const result = buildToc(html);
|
|
348
|
+
expect(result).toContain('<aside class="toc">');
|
|
349
|
+
expect(result).toContain('<a href="#intro">Introduction</a>');
|
|
350
|
+
expect(result).toContain('<a href="#setup">Setup</a>');
|
|
351
|
+
expect(result).toContain('<a href="#usage">Usage</a>');
|
|
352
|
+
expect(result).toContain('class="toc-h3"');
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("returns empty string for fewer than 2 headings", () => {
|
|
356
|
+
const html = `<h2 id="only">Only One</h2>`;
|
|
357
|
+
const result = buildToc(html);
|
|
358
|
+
expect(result).toBe("");
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("returns empty string for no headings", () => {
|
|
362
|
+
const html = `<p>No headings here</p>`;
|
|
363
|
+
const result = buildToc(html);
|
|
364
|
+
expect(result).toBe("");
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("ignores h1 and h4+ headings", () => {
|
|
368
|
+
const html = `
|
|
369
|
+
<h1 id="title">Title</h1>
|
|
370
|
+
<h2 id="intro">Introduction</h2>
|
|
371
|
+
<h2 id="usage">Usage</h2>
|
|
372
|
+
<h4 id="sub">Subheading</h4>
|
|
373
|
+
`;
|
|
374
|
+
const result = buildToc(html);
|
|
375
|
+
expect(result).not.toContain("Title");
|
|
376
|
+
expect(result).not.toContain("Subheading");
|
|
377
|
+
expect(result).toContain("Introduction");
|
|
378
|
+
expect(result).toContain("Usage");
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("applies toc-h3 class only to h3 headings", () => {
|
|
382
|
+
const html = `
|
|
383
|
+
<h2 id="section">Section</h2>
|
|
384
|
+
<h3 id="subsection">Subsection</h3>
|
|
385
|
+
<h2 id="another">Another</h2>
|
|
386
|
+
`;
|
|
387
|
+
const result = buildToc(html);
|
|
388
|
+
expect(result).toContain('<li class="toc-h3"><a href="#subsection">');
|
|
389
|
+
expect(result).not.toContain('<li class="toc-h3"><a href="#section">');
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
// ---------------------------------------------------------------------------
|
|
394
|
+
// buildNavSimple, buildNavStatic tests
|
|
395
|
+
// ---------------------------------------------------------------------------
|
|
396
|
+
|
|
397
|
+
describe("buildNavSimple", () => {
|
|
398
|
+
const files: ContentFile[] = [
|
|
399
|
+
{ path: "/docs/intro.md", urlPath: "docs/intro", section: "Guides" },
|
|
400
|
+
{ path: "/docs/setup.md", urlPath: "docs/setup", section: "Guides" },
|
|
401
|
+
{ path: "/api/ref.md", urlPath: "api/ref", section: "API" },
|
|
402
|
+
];
|
|
403
|
+
|
|
404
|
+
const config: SiteConfig = {
|
|
405
|
+
docroot: ".",
|
|
406
|
+
title: "Test Site",
|
|
407
|
+
brand: { name: "Test", url: "/" },
|
|
408
|
+
sections: [
|
|
409
|
+
{ name: "Guides", path: "docs" },
|
|
410
|
+
{ name: "API", path: "api" },
|
|
411
|
+
],
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
it("builds navigation with sections", () => {
|
|
415
|
+
const result = buildNavSimple(files, config);
|
|
416
|
+
expect(result).toContain('<span class="nav-section">Guides</span>');
|
|
417
|
+
expect(result).toContain('<span class="nav-section">API</span>');
|
|
418
|
+
expect(result).toContain('<a href="/docs/intro">intro</a>');
|
|
419
|
+
expect(result).toContain('<a href="/docs/setup">setup</a>');
|
|
420
|
+
expect(result).toContain('<a href="/api/ref">ref</a>');
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("adds home link when config.home is set", () => {
|
|
424
|
+
const configWithHome = { ...config, home: "index.md" };
|
|
425
|
+
const result = buildNavSimple(files, configWithHome);
|
|
426
|
+
expect(result).toContain('<a href="/" class="nav-home">Home</a>');
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it("does not add home link when config.home is not set", () => {
|
|
430
|
+
const result = buildNavSimple(files, config);
|
|
431
|
+
expect(result).not.toContain("nav-home");
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it("handles empty files array", () => {
|
|
435
|
+
const result = buildNavSimple([], config);
|
|
436
|
+
expect(result).toBe("<ul></ul>");
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("renders hierarchical nav with collapsible groups", () => {
|
|
440
|
+
const nestedFiles: ContentFile[] = [
|
|
441
|
+
{
|
|
442
|
+
path: "/ref/api/users.md",
|
|
443
|
+
urlPath: "ref/api/users",
|
|
444
|
+
section: "Reference",
|
|
445
|
+
sectionBase: "ref",
|
|
446
|
+
},
|
|
447
|
+
{
|
|
448
|
+
path: "/ref/api/auth.md",
|
|
449
|
+
urlPath: "ref/api/auth",
|
|
450
|
+
section: "Reference",
|
|
451
|
+
sectionBase: "ref",
|
|
452
|
+
},
|
|
453
|
+
{
|
|
454
|
+
path: "/ref/overview.md",
|
|
455
|
+
urlPath: "ref/overview",
|
|
456
|
+
section: "Reference",
|
|
457
|
+
sectionBase: "ref",
|
|
458
|
+
},
|
|
459
|
+
];
|
|
460
|
+
const nestedConfig: SiteConfig = {
|
|
461
|
+
...config,
|
|
462
|
+
sections: [{ name: "Reference", path: "ref" }],
|
|
463
|
+
};
|
|
464
|
+
const result = buildNavSimple(nestedFiles, nestedConfig);
|
|
465
|
+
// api/ should be a collapsible group
|
|
466
|
+
expect(result).toContain("<details>");
|
|
467
|
+
expect(result).toContain('<summary class="nav-group">api</summary>');
|
|
468
|
+
expect(result).toContain('<a href="/ref/api/users">users</a>');
|
|
469
|
+
expect(result).toContain('<a href="/ref/api/auth">auth</a>');
|
|
470
|
+
// overview is a flat leaf at section root
|
|
471
|
+
expect(result).toContain('<a href="/ref/overview">overview</a>');
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it("makes section header clickable when index.md exists", () => {
|
|
475
|
+
const indexFiles: ContentFile[] = [
|
|
476
|
+
{ path: "/docs/index.md", urlPath: "docs", section: "Guides", sectionBase: "docs" },
|
|
477
|
+
{ path: "/docs/intro.md", urlPath: "docs/intro", section: "Guides", sectionBase: "docs" },
|
|
478
|
+
];
|
|
479
|
+
const result = buildNavSimple(indexFiles, config);
|
|
480
|
+
// Section header should be a link (from index.md)
|
|
481
|
+
expect(result).toContain('class="nav-section">Guides</a>');
|
|
482
|
+
expect(result).toContain('<a href="/docs/intro">intro</a>');
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it("auto-expands section containing active page", () => {
|
|
486
|
+
const nestedFiles: ContentFile[] = [
|
|
487
|
+
{
|
|
488
|
+
path: "/ref/api/users.md",
|
|
489
|
+
urlPath: "ref/api/users",
|
|
490
|
+
section: "Reference",
|
|
491
|
+
sectionBase: "ref",
|
|
492
|
+
},
|
|
493
|
+
{
|
|
494
|
+
path: "/ref/api/auth.md",
|
|
495
|
+
urlPath: "ref/api/auth",
|
|
496
|
+
section: "Reference",
|
|
497
|
+
sectionBase: "ref",
|
|
498
|
+
},
|
|
499
|
+
];
|
|
500
|
+
const nestedConfig: SiteConfig = {
|
|
501
|
+
...config,
|
|
502
|
+
sections: [{ name: "Reference", path: "ref" }],
|
|
503
|
+
};
|
|
504
|
+
const result = buildNavSimple(nestedFiles, nestedConfig, "ref/api/users");
|
|
505
|
+
expect(result).toContain("<details open>");
|
|
506
|
+
expect(result).toContain('class="active"');
|
|
507
|
+
});
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
describe("buildNavStatic", () => {
|
|
511
|
+
const files: ContentFile[] = [
|
|
512
|
+
{ path: "/docs/intro.md", urlPath: "docs/intro", section: "Guides" },
|
|
513
|
+
{ path: "/docs/setup.md", urlPath: "docs/setup", section: "Guides" },
|
|
514
|
+
];
|
|
515
|
+
|
|
516
|
+
const config: SiteConfig = {
|
|
517
|
+
docroot: ".",
|
|
518
|
+
title: "Test Site",
|
|
519
|
+
brand: { name: "Test", url: "/" },
|
|
520
|
+
sections: [{ name: "Guides", path: "docs" }],
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
it("builds navigation with path prefix", () => {
|
|
524
|
+
const result = buildNavStatic(files, "docs/intro", config, "/site/");
|
|
525
|
+
expect(result).toContain('href="/site/docs/intro.html"');
|
|
526
|
+
expect(result).toContain('href="/site/docs/setup.html"');
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it("marks current page as active", () => {
|
|
530
|
+
const result = buildNavStatic(files, "docs/intro", config, "/site/");
|
|
531
|
+
expect(result).toContain('<a href="/site/docs/intro.html" class="active">');
|
|
532
|
+
expect(result).not.toContain('<a href="/site/docs/setup.html" class="active">');
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it("adds home link with active state when on home page", () => {
|
|
536
|
+
const configWithHome = { ...config, home: "index.md" };
|
|
537
|
+
const result = buildNavStatic(files, "", configWithHome, "/site/");
|
|
538
|
+
expect(result).toContain('class="active" class="nav-home"');
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it("adds home link without active when not on home page", () => {
|
|
542
|
+
const configWithHome = { ...config, home: "index.md" };
|
|
543
|
+
const result = buildNavStatic(files, "docs/intro", configWithHome, "/site/");
|
|
544
|
+
expect(result).toContain('<a href="/site/index.html" class="nav-home">');
|
|
545
|
+
});
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
// ---------------------------------------------------------------------------
|
|
549
|
+
// buildBreadcrumbsSimple, buildBreadcrumbsStatic tests
|
|
550
|
+
// ---------------------------------------------------------------------------
|
|
551
|
+
|
|
552
|
+
describe("buildBreadcrumbsSimple", () => {
|
|
553
|
+
const files: ContentFile[] = [
|
|
554
|
+
{ path: "/docs/getting-started.md", urlPath: "docs/getting-started", section: "Docs" },
|
|
555
|
+
{ path: "/docs/guides/intro.md", urlPath: "docs/guides/intro", section: "Guides" },
|
|
556
|
+
{ path: "/docs/guides/advanced.md", urlPath: "docs/guides/advanced", section: "Guides" },
|
|
557
|
+
{ path: "/api/reference/methods.md", urlPath: "api/reference/methods", section: "API" },
|
|
558
|
+
];
|
|
559
|
+
|
|
560
|
+
const config: SiteConfig = {
|
|
561
|
+
docroot: ".",
|
|
562
|
+
title: "Test Site",
|
|
563
|
+
brand: { name: "Test", url: "/" },
|
|
564
|
+
sections: [],
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
it("builds breadcrumbs for nested path linking to first file in section", () => {
|
|
568
|
+
const result = buildBreadcrumbsSimple("docs/guides/intro", files, config);
|
|
569
|
+
expect(result).toContain('<nav class="breadcrumbs">');
|
|
570
|
+
// Should link to first file in docs section, not /docs/
|
|
571
|
+
expect(result).toContain('<a href="/docs/getting-started">Docs</a>');
|
|
572
|
+
// Should link to first file in docs/guides section, not /docs/guides/
|
|
573
|
+
expect(result).toContain('<a href="/docs/guides/intro">Guides</a>');
|
|
574
|
+
expect(result).toContain("<span>intro</span>");
|
|
575
|
+
expect(result).toContain('<span class="separator">');
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it("returns empty string for single-level path", () => {
|
|
579
|
+
const result = buildBreadcrumbsSimple("intro", files, config);
|
|
580
|
+
expect(result).toBe("");
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
it("returns empty string for empty path", () => {
|
|
584
|
+
const result = buildBreadcrumbsSimple("", files, config);
|
|
585
|
+
expect(result).toBe("");
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it("capitalizes first letter of each segment", () => {
|
|
589
|
+
const result = buildBreadcrumbsSimple("api/reference/methods", files, config);
|
|
590
|
+
expect(result).toContain(">Api</a>");
|
|
591
|
+
expect(result).toContain(">Reference</a>");
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
it("falls back to directory path when no matching file found", () => {
|
|
595
|
+
const emptyFiles: ContentFile[] = [];
|
|
596
|
+
const result = buildBreadcrumbsSimple("unknown/section/page", emptyFiles, config);
|
|
597
|
+
expect(result).toContain('<a href="/unknown/">Unknown</a>');
|
|
598
|
+
expect(result).toContain('<a href="/unknown/section/">Section</a>');
|
|
599
|
+
});
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
describe("buildBreadcrumbsStatic", () => {
|
|
603
|
+
const files: ContentFile[] = [
|
|
604
|
+
{ path: "/docs/getting-started.md", urlPath: "docs/getting-started", section: "Docs" },
|
|
605
|
+
{ path: "/docs/guides/intro.md", urlPath: "docs/guides/intro", section: "Guides" },
|
|
606
|
+
{ path: "/docs/guides/advanced.md", urlPath: "docs/guides/advanced", section: "Guides" },
|
|
607
|
+
];
|
|
608
|
+
|
|
609
|
+
const config: SiteConfig = {
|
|
610
|
+
docroot: ".",
|
|
611
|
+
title: "Test Site",
|
|
612
|
+
brand: { name: "Test", url: "/" },
|
|
613
|
+
sections: [],
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
it("builds breadcrumbs with path prefix linking to first file in section", () => {
|
|
617
|
+
const result = buildBreadcrumbsStatic("docs/guides/intro", "/site/", files, config);
|
|
618
|
+
// Should link to first file in docs section, not /site/docs/index.html
|
|
619
|
+
expect(result).toContain('<a href="/site/docs/getting-started.html">Docs</a>');
|
|
620
|
+
// Should link to first file in docs/guides section, not /site/docs/guides/index.html
|
|
621
|
+
expect(result).toContain('<a href="/site/docs/guides/intro.html">Guides</a>');
|
|
622
|
+
expect(result).toContain("<span>intro</span>");
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it("returns empty string for single-level path", () => {
|
|
626
|
+
const result = buildBreadcrumbsStatic("intro", "/site/", files, config);
|
|
627
|
+
expect(result).toBe("");
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
it("handles empty path prefix", () => {
|
|
631
|
+
const result = buildBreadcrumbsStatic("docs/intro", "", files, config);
|
|
632
|
+
expect(result).toContain('<a href="docs/getting-started.html">Docs</a>');
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
it("falls back to index.html when no matching file found", () => {
|
|
636
|
+
const emptyFiles: ContentFile[] = [];
|
|
637
|
+
const result = buildBreadcrumbsStatic("unknown/section/page", "/site/", emptyFiles, config);
|
|
638
|
+
expect(result).toContain('<a href="/site/unknown/index.html">Unknown</a>');
|
|
639
|
+
expect(result).toContain('<a href="/site/unknown/section/index.html">Section</a>');
|
|
640
|
+
});
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
// ---------------------------------------------------------------------------
|
|
644
|
+
// formatDate tests
|
|
645
|
+
// ---------------------------------------------------------------------------
|
|
646
|
+
|
|
647
|
+
describe("formatDate", () => {
|
|
648
|
+
it("formats ISO date to YYYY-MM-DD", () => {
|
|
649
|
+
const result = formatDate("2024-03-15T10:30:00Z");
|
|
650
|
+
expect(result).toBe("2024-03-15");
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
it("returns 'unknown' unchanged", () => {
|
|
654
|
+
const result = formatDate("unknown");
|
|
655
|
+
expect(result).toBe("unknown");
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
it("returns 'dev' unchanged", () => {
|
|
659
|
+
const result = formatDate("dev");
|
|
660
|
+
expect(result).toBe("dev");
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
it("handles date with timezone offset", () => {
|
|
664
|
+
const result = formatDate("2024-03-15T10:30:00+05:00");
|
|
665
|
+
expect(result).toBe("2024-03-15");
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
it("returns invalid date string unchanged", () => {
|
|
669
|
+
const result = formatDate("not-a-date");
|
|
670
|
+
// Invalid Date will still produce a string, but it might be "Invalid Date"
|
|
671
|
+
// The function catches errors and returns the original
|
|
672
|
+
expect(typeof result).toBe("string");
|
|
673
|
+
});
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
// ---------------------------------------------------------------------------
|
|
677
|
+
// envString, envInt, envBool tests
|
|
678
|
+
// ---------------------------------------------------------------------------
|
|
679
|
+
|
|
680
|
+
describe("envString", () => {
|
|
681
|
+
const originalEnv = process.env;
|
|
682
|
+
|
|
683
|
+
beforeEach(() => {
|
|
684
|
+
process.env = { ...originalEnv };
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
afterEach(() => {
|
|
688
|
+
process.env = originalEnv;
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
it("returns environment variable value when set", () => {
|
|
692
|
+
process.env.TEST_STRING = "hello";
|
|
693
|
+
expect(envString("TEST_STRING", "default")).toBe("hello");
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
it("returns fallback when environment variable is not set", () => {
|
|
697
|
+
delete process.env.TEST_STRING;
|
|
698
|
+
expect(envString("TEST_STRING", "default")).toBe("default");
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
it("returns empty string if env var is empty string", () => {
|
|
702
|
+
process.env.TEST_STRING = "";
|
|
703
|
+
expect(envString("TEST_STRING", "default")).toBe("");
|
|
704
|
+
});
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
describe("envInt", () => {
|
|
708
|
+
const originalEnv = process.env;
|
|
709
|
+
|
|
710
|
+
beforeEach(() => {
|
|
711
|
+
process.env = { ...originalEnv };
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
afterEach(() => {
|
|
715
|
+
process.env = originalEnv;
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
it("returns parsed integer when env var is valid number", () => {
|
|
719
|
+
process.env.TEST_INT = "42";
|
|
720
|
+
expect(envInt("TEST_INT", 10)).toBe(42);
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
it("returns fallback when env var is not set", () => {
|
|
724
|
+
delete process.env.TEST_INT;
|
|
725
|
+
expect(envInt("TEST_INT", 10)).toBe(10);
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
it("returns fallback when env var is not a valid number", () => {
|
|
729
|
+
process.env.TEST_INT = "not-a-number";
|
|
730
|
+
expect(envInt("TEST_INT", 10)).toBe(10);
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
it("returns fallback when env var is empty", () => {
|
|
734
|
+
process.env.TEST_INT = "";
|
|
735
|
+
expect(envInt("TEST_INT", 10)).toBe(10);
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
it("handles negative numbers", () => {
|
|
739
|
+
process.env.TEST_INT = "-5";
|
|
740
|
+
expect(envInt("TEST_INT", 10)).toBe(-5);
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
it("truncates floating point numbers", () => {
|
|
744
|
+
process.env.TEST_INT = "3.14";
|
|
745
|
+
expect(envInt("TEST_INT", 10)).toBe(3);
|
|
746
|
+
});
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
describe("envBool", () => {
|
|
750
|
+
const originalEnv = process.env;
|
|
751
|
+
|
|
752
|
+
beforeEach(() => {
|
|
753
|
+
process.env = { ...originalEnv };
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
afterEach(() => {
|
|
757
|
+
process.env = originalEnv;
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
it("returns true for 'true'", () => {
|
|
761
|
+
process.env.TEST_BOOL = "true";
|
|
762
|
+
expect(envBool("TEST_BOOL", false)).toBe(true);
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
it("returns true for '1'", () => {
|
|
766
|
+
process.env.TEST_BOOL = "1";
|
|
767
|
+
expect(envBool("TEST_BOOL", false)).toBe(true);
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
it("returns true for 'yes'", () => {
|
|
771
|
+
process.env.TEST_BOOL = "yes";
|
|
772
|
+
expect(envBool("TEST_BOOL", false)).toBe(true);
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
it("returns false for 'false'", () => {
|
|
776
|
+
process.env.TEST_BOOL = "false";
|
|
777
|
+
expect(envBool("TEST_BOOL", true)).toBe(false);
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
it("returns false for '0'", () => {
|
|
781
|
+
process.env.TEST_BOOL = "0";
|
|
782
|
+
expect(envBool("TEST_BOOL", true)).toBe(false);
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
it("returns false for 'no'", () => {
|
|
786
|
+
process.env.TEST_BOOL = "no";
|
|
787
|
+
expect(envBool("TEST_BOOL", true)).toBe(false);
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
it("returns fallback when env var is not set", () => {
|
|
791
|
+
delete process.env.TEST_BOOL;
|
|
792
|
+
expect(envBool("TEST_BOOL", true)).toBe(true);
|
|
793
|
+
expect(envBool("TEST_BOOL", false)).toBe(false);
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
it("returns fallback for unrecognized value", () => {
|
|
797
|
+
process.env.TEST_BOOL = "maybe";
|
|
798
|
+
expect(envBool("TEST_BOOL", true)).toBe(true);
|
|
799
|
+
expect(envBool("TEST_BOOL", false)).toBe(false);
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
it("is case insensitive", () => {
|
|
803
|
+
process.env.TEST_BOOL = "TRUE";
|
|
804
|
+
expect(envBool("TEST_BOOL", false)).toBe(true);
|
|
805
|
+
process.env.TEST_BOOL = "FALSE";
|
|
806
|
+
expect(envBool("TEST_BOOL", true)).toBe(false);
|
|
807
|
+
process.env.TEST_BOOL = "Yes";
|
|
808
|
+
expect(envBool("TEST_BOOL", false)).toBe(true);
|
|
809
|
+
});
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
// ---------------------------------------------------------------------------
|
|
813
|
+
// loadSiteConfig tests (with mocking)
|
|
814
|
+
// ---------------------------------------------------------------------------
|
|
815
|
+
|
|
816
|
+
describe("loadSiteConfig", () => {
|
|
817
|
+
it("returns fallback config when site.yaml does not exist and no content dir", async () => {
|
|
818
|
+
const result = await loadSiteConfig("/nonexistent/path", "Default Title");
|
|
819
|
+
expect(result.docroot).toBe(".");
|
|
820
|
+
expect(result.title).toBe("Default Title");
|
|
821
|
+
expect(result.brand.name).toBe("Handbook");
|
|
822
|
+
expect(result.sections).toEqual([]);
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
it("parses footer config fields from site.yaml", async () => {
|
|
826
|
+
const dir = await mkdtemp(join(tmpdir(), "kitfly-footer-config-"));
|
|
827
|
+
try {
|
|
828
|
+
await writeFile(
|
|
829
|
+
join(dir, "site.yaml"),
|
|
830
|
+
`title: Test
|
|
831
|
+
version: "1.2.0"
|
|
832
|
+
brand:
|
|
833
|
+
name: Test
|
|
834
|
+
url: /
|
|
835
|
+
sections:
|
|
836
|
+
- name: Guide
|
|
837
|
+
path: guide
|
|
838
|
+
footer:
|
|
839
|
+
copyright: "© 2026 Test"
|
|
840
|
+
attribution: false
|
|
841
|
+
links:
|
|
842
|
+
- text: Privacy
|
|
843
|
+
url: /privacy
|
|
844
|
+
`,
|
|
845
|
+
"utf-8",
|
|
846
|
+
);
|
|
847
|
+
|
|
848
|
+
const config = await loadSiteConfig(dir);
|
|
849
|
+
expect(config.version).toBe("1.2.0");
|
|
850
|
+
expect(config.footer?.copyright).toBe("© 2026 Test");
|
|
851
|
+
expect(config.footer?.attribution).toBe(false);
|
|
852
|
+
expect(config.footer?.links).toEqual([{ text: "Privacy", url: "/privacy" }]);
|
|
853
|
+
} finally {
|
|
854
|
+
await rm(dir, { recursive: true, force: true });
|
|
855
|
+
}
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
it("truncates footer links to max 10", async () => {
|
|
859
|
+
const dir = await mkdtemp(join(tmpdir(), "kitfly-footer-links-"));
|
|
860
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
861
|
+
try {
|
|
862
|
+
const links = Array.from(
|
|
863
|
+
{ length: 12 },
|
|
864
|
+
(_, i) => ` - text: Link${i + 1}\n url: /l${i + 1}`,
|
|
865
|
+
).join("\n");
|
|
866
|
+
await writeFile(
|
|
867
|
+
join(dir, "site.yaml"),
|
|
868
|
+
`title: Test
|
|
869
|
+
brand:
|
|
870
|
+
name: Test
|
|
871
|
+
url: /
|
|
872
|
+
sections:
|
|
873
|
+
- name: Guide
|
|
874
|
+
path: guide
|
|
875
|
+
footer:
|
|
876
|
+
links:
|
|
877
|
+
${links}
|
|
878
|
+
`,
|
|
879
|
+
"utf-8",
|
|
880
|
+
);
|
|
881
|
+
|
|
882
|
+
const config = await loadSiteConfig(dir);
|
|
883
|
+
expect(config.footer?.links).toHaveLength(10);
|
|
884
|
+
expect(warn).toHaveBeenCalled();
|
|
885
|
+
} finally {
|
|
886
|
+
warn.mockRestore();
|
|
887
|
+
await rm(dir, { recursive: true, force: true });
|
|
888
|
+
}
|
|
889
|
+
});
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
// ---------------------------------------------------------------------------
|
|
893
|
+
// collectFiles tests
|
|
894
|
+
// ---------------------------------------------------------------------------
|
|
895
|
+
|
|
896
|
+
describe("collectFiles", () => {
|
|
897
|
+
it("returns empty array when no sections", async () => {
|
|
898
|
+
const config: SiteConfig = {
|
|
899
|
+
docroot: ".",
|
|
900
|
+
title: "Test",
|
|
901
|
+
brand: { name: "Test", url: "/" },
|
|
902
|
+
sections: [],
|
|
903
|
+
};
|
|
904
|
+
const result = await collectFiles("/nonexistent", config);
|
|
905
|
+
expect(result).toEqual([]);
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
it("skips sections with invalid paths", async () => {
|
|
909
|
+
const config: SiteConfig = {
|
|
910
|
+
docroot: ".",
|
|
911
|
+
title: "Test",
|
|
912
|
+
brand: { name: "Test", url: "/" },
|
|
913
|
+
sections: [{ name: "Invalid", path: "../../../../../etc" }],
|
|
914
|
+
};
|
|
915
|
+
const result = await collectFiles("/tmp", config);
|
|
916
|
+
expect(result).toEqual([]);
|
|
917
|
+
});
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
// ---------------------------------------------------------------------------
|
|
921
|
+
// getGitInfo and generateProvenance tests
|
|
922
|
+
// ---------------------------------------------------------------------------
|
|
923
|
+
|
|
924
|
+
describe("getGitInfo", () => {
|
|
925
|
+
it("returns dev defaults when devMode is true and git fails", async () => {
|
|
926
|
+
const result = await getGitInfo("/nonexistent/path", true);
|
|
927
|
+
expect(result.commit).toBe("dev");
|
|
928
|
+
expect(result.branch).toBe("local");
|
|
929
|
+
expect(result.commitDate).toBeDefined();
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
it("returns unknown defaults when devMode is false and git fails", async () => {
|
|
933
|
+
const result = await getGitInfo("/nonexistent/path", false);
|
|
934
|
+
expect(result.commit).toBe("unknown");
|
|
935
|
+
expect(result.branch).toBe("unknown");
|
|
936
|
+
expect(result.commitDate).toBe("unknown");
|
|
937
|
+
});
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
describe("generateProvenance", () => {
|
|
941
|
+
it("uses site.yaml version when present", async () => {
|
|
942
|
+
const dir = await mkdtemp(join(tmpdir(), "kitfly-version-config-"));
|
|
943
|
+
try {
|
|
944
|
+
await writeFile(
|
|
945
|
+
join(dir, "site.yaml"),
|
|
946
|
+
`title: Test
|
|
947
|
+
version: "2.4.1"
|
|
948
|
+
brand:
|
|
949
|
+
name: Test
|
|
950
|
+
url: /
|
|
951
|
+
sections:
|
|
952
|
+
- name: Guide
|
|
953
|
+
path: guide
|
|
954
|
+
`,
|
|
955
|
+
"utf-8",
|
|
956
|
+
);
|
|
957
|
+
|
|
958
|
+
const result = await generateProvenance(dir, true, "2.4.1");
|
|
959
|
+
expect(result.version).toBe("2.4.1");
|
|
960
|
+
} finally {
|
|
961
|
+
await rm(dir, { recursive: true, force: true });
|
|
962
|
+
}
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
// resolveSiteVersion() uses Bun.spawn for git tag detection, so this test
|
|
966
|
+
// can only pass under the Bun runtime. v8 coverage requires Node, where
|
|
967
|
+
// Bun globals don't exist. Once Bun supports node:inspector coverage APIs
|
|
968
|
+
// we can remove the skipIf and run this unconditionally.
|
|
969
|
+
it.skipIf(typeof globalThis.Bun === "undefined")(
|
|
970
|
+
"falls back to git tag when site.yaml version is not set",
|
|
971
|
+
async () => {
|
|
972
|
+
const dir = await mkdtemp(join(tmpdir(), "kitfly-version-tag-"));
|
|
973
|
+
try {
|
|
974
|
+
const run = async (args: string[]) => {
|
|
975
|
+
const proc = Bun.spawn(["git", ...args], { cwd: dir, stdout: "pipe", stderr: "ignore" });
|
|
976
|
+
await proc.exited;
|
|
977
|
+
};
|
|
978
|
+
await run(["init"]);
|
|
979
|
+
await run(["config", "user.email", "test@example.com"]);
|
|
980
|
+
await run(["config", "user.name", "Kitfly Test"]);
|
|
981
|
+
await writeFile(join(dir, "README.md"), "# test\n", "utf-8");
|
|
982
|
+
await run(["add", "README.md"]);
|
|
983
|
+
await run(["commit", "-m", "init"]);
|
|
984
|
+
await run(["tag", "v3.5.7"]);
|
|
985
|
+
|
|
986
|
+
const result = await generateProvenance(dir, false);
|
|
987
|
+
expect(result.version).toBe("3.5.7");
|
|
988
|
+
} finally {
|
|
989
|
+
await rm(dir, { recursive: true, force: true });
|
|
990
|
+
}
|
|
991
|
+
},
|
|
992
|
+
);
|
|
993
|
+
|
|
994
|
+
it("returns empty version when neither site.yaml version nor git tag exist", async () => {
|
|
995
|
+
const result = await generateProvenance("/nonexistent/path", true);
|
|
996
|
+
expect(result.version).toBeUndefined();
|
|
997
|
+
expect(result.buildDate).toBeDefined();
|
|
998
|
+
expect(result.gitCommit).toBe("dev");
|
|
999
|
+
expect(result.gitBranch).toBe("local");
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
it("generates provenance with unknown values when not in dev mode", async () => {
|
|
1003
|
+
const result = await generateProvenance("/nonexistent/path", false);
|
|
1004
|
+
expect(result.gitCommit).toBe("unknown");
|
|
1005
|
+
expect(result.gitBranch).toBe("unknown");
|
|
1006
|
+
expect(result.gitCommitDate).toBe("unknown");
|
|
1007
|
+
});
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
describe("resolveSiteVersion", () => {
|
|
1011
|
+
it("returns empty string when no config and no tag", async () => {
|
|
1012
|
+
const version = await resolveSiteVersion("/nonexistent/path");
|
|
1013
|
+
expect(version).toBeUndefined();
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
it("returns provided site version without git lookup", async () => {
|
|
1017
|
+
const version = await resolveSiteVersion("/nonexistent/path", "9.9.9");
|
|
1018
|
+
expect(version).toBe("9.9.9");
|
|
1019
|
+
});
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
// ---------------------------------------------------------------------------
|
|
1023
|
+
// buildPageMeta tests
|
|
1024
|
+
// ---------------------------------------------------------------------------
|
|
1025
|
+
|
|
1026
|
+
describe("buildPageMeta", () => {
|
|
1027
|
+
it("returns empty string when no last_updated in frontmatter", () => {
|
|
1028
|
+
expect(buildPageMeta({})).toBe("");
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
it("returns empty string when last_updated is undefined", () => {
|
|
1032
|
+
expect(buildPageMeta({ title: "Hello" })).toBe("");
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
it("returns formatted date div when last_updated is set", () => {
|
|
1036
|
+
const result = buildPageMeta({ last_updated: "2024-06-15T12:00:00Z" });
|
|
1037
|
+
expect(result).toContain('<div class="page-meta">');
|
|
1038
|
+
expect(result).toContain("Last updated: 2024-06-15");
|
|
1039
|
+
expect(result).toContain("</div>");
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
it("passes through special date strings via formatDate", () => {
|
|
1043
|
+
const result = buildPageMeta({ last_updated: "unknown" });
|
|
1044
|
+
expect(result).toContain("Last updated: unknown");
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
it("handles dev mode date string", () => {
|
|
1048
|
+
const result = buildPageMeta({ last_updated: "dev" });
|
|
1049
|
+
expect(result).toContain("Last updated: dev");
|
|
1050
|
+
});
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
// ---------------------------------------------------------------------------
|
|
1054
|
+
// buildFooter tests
|
|
1055
|
+
// ---------------------------------------------------------------------------
|
|
1056
|
+
|
|
1057
|
+
describe("buildFooter", () => {
|
|
1058
|
+
const baseProvenance: Provenance = {
|
|
1059
|
+
version: "1.2.3",
|
|
1060
|
+
buildDate: "2024-06-15T12:00:00Z",
|
|
1061
|
+
gitCommit: "abc1234",
|
|
1062
|
+
gitCommitDate: "2024-06-14T10:00:00Z",
|
|
1063
|
+
gitBranch: "main",
|
|
1064
|
+
};
|
|
1065
|
+
|
|
1066
|
+
const baseConfig: SiteConfig = {
|
|
1067
|
+
docroot: ".",
|
|
1068
|
+
title: "Test Site",
|
|
1069
|
+
brand: { name: "Acme Corp", url: "https://acme.com" },
|
|
1070
|
+
sections: [],
|
|
1071
|
+
};
|
|
1072
|
+
|
|
1073
|
+
it("includes version number", () => {
|
|
1074
|
+
const result = buildFooter(baseProvenance, baseConfig);
|
|
1075
|
+
expect(result).toContain("v1.2.3");
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
it("omits version span when provenance version is empty", () => {
|
|
1079
|
+
const result = buildFooter({ ...baseProvenance, version: "" }, baseConfig);
|
|
1080
|
+
expect(result).not.toContain('class="footer-version"');
|
|
1081
|
+
expect(result).toContain("Published 2024-06-14");
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
it("includes formatted commit date", () => {
|
|
1085
|
+
const result = buildFooter(baseProvenance, baseConfig);
|
|
1086
|
+
expect(result).toContain("Published 2024-06-14");
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
it("includes commit hash in title attribute", () => {
|
|
1090
|
+
const result = buildFooter(baseProvenance, baseConfig);
|
|
1091
|
+
expect(result).toContain('title="Commit: abc1234"');
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
it("includes brand name in copyright", () => {
|
|
1095
|
+
const result = buildFooter(baseProvenance, baseConfig);
|
|
1096
|
+
expect(result).toContain("Acme Corp");
|
|
1097
|
+
});
|
|
1098
|
+
|
|
1099
|
+
it("includes default brand URL link text without protocol", () => {
|
|
1100
|
+
const result = buildFooter(baseProvenance, baseConfig);
|
|
1101
|
+
expect(result).toContain(">acme.com</a>");
|
|
1102
|
+
expect(result).toContain('href="https://acme.com"');
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
it("strips http:// from brand URL display", () => {
|
|
1106
|
+
const config = { ...baseConfig, brand: { name: "Test", url: "http://example.org" } };
|
|
1107
|
+
const result = buildFooter(baseProvenance, config);
|
|
1108
|
+
expect(result).toContain(">example.org</a>");
|
|
1109
|
+
});
|
|
1110
|
+
|
|
1111
|
+
it("uses brand name as link text when brand URL is relative", () => {
|
|
1112
|
+
const config = { ...baseConfig, brand: { name: "My Product", url: "/" } };
|
|
1113
|
+
const result = buildFooter(baseProvenance, config);
|
|
1114
|
+
expect(result).toContain(">My Product</a>");
|
|
1115
|
+
expect(result).not.toContain(">/</a>");
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
it("adds target=_blank for external brands on default brand link", () => {
|
|
1119
|
+
const config = {
|
|
1120
|
+
...baseConfig,
|
|
1121
|
+
brand: { name: "Ext", url: "https://ext.com", external: true },
|
|
1122
|
+
};
|
|
1123
|
+
const result = buildFooter(baseProvenance, config);
|
|
1124
|
+
expect(result).toContain('target="_blank"');
|
|
1125
|
+
expect(result).toContain('rel="noopener"');
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
it("renders attribution by default", () => {
|
|
1129
|
+
const result = buildFooter(baseProvenance, baseConfig);
|
|
1130
|
+
expect(result).toContain(`Built with ${KITFLY_BRAND.name}`);
|
|
1131
|
+
expect(result).toContain(`href="${KITFLY_BRAND.url}"`);
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
it("removes attribution when footer.attribution is false", () => {
|
|
1135
|
+
const result = buildFooter(baseProvenance, {
|
|
1136
|
+
...baseConfig,
|
|
1137
|
+
footer: { attribution: false },
|
|
1138
|
+
});
|
|
1139
|
+
expect(result).not.toContain("Built with Kitfly");
|
|
1140
|
+
expect(result).not.toContain('class="footer-right"');
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
it("uses custom copyright text verbatim", () => {
|
|
1144
|
+
const result = buildFooter(baseProvenance, {
|
|
1145
|
+
...baseConfig,
|
|
1146
|
+
footer: { copyright: "Copyright 2024-2026 Acme Corp" },
|
|
1147
|
+
});
|
|
1148
|
+
expect(result).toContain("Copyright 2024-2026 Acme Corp");
|
|
1149
|
+
});
|
|
1150
|
+
|
|
1151
|
+
it("wraps copyright in link when copyrightUrl is set", () => {
|
|
1152
|
+
const result = buildFooter(baseProvenance, {
|
|
1153
|
+
...baseConfig,
|
|
1154
|
+
footer: { copyright: "© 2026 3 Leaps, LLC", copyrightUrl: "https://3leaps.net" },
|
|
1155
|
+
});
|
|
1156
|
+
expect(result).toContain('href="https://3leaps.net"');
|
|
1157
|
+
expect(result).toContain(">© 2026 3 Leaps, LLC</a>");
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
it("renders copyright as plain text when copyrightUrl is absent", () => {
|
|
1161
|
+
const result = buildFooter(baseProvenance, {
|
|
1162
|
+
...baseConfig,
|
|
1163
|
+
footer: { copyright: "© 2026 Acme" },
|
|
1164
|
+
});
|
|
1165
|
+
expect(result).toContain(">© 2026 Acme</span>");
|
|
1166
|
+
expect(result).not.toContain('">© 2026 Acme</a>');
|
|
1167
|
+
});
|
|
1168
|
+
|
|
1169
|
+
it("uses footer.links when provided", () => {
|
|
1170
|
+
const result = buildFooter(baseProvenance, {
|
|
1171
|
+
...baseConfig,
|
|
1172
|
+
footer: {
|
|
1173
|
+
links: [
|
|
1174
|
+
{ text: "Privacy", url: "/privacy" },
|
|
1175
|
+
{ text: "Terms", url: "/terms" },
|
|
1176
|
+
],
|
|
1177
|
+
},
|
|
1178
|
+
});
|
|
1179
|
+
expect(result).toContain('href="/privacy"');
|
|
1180
|
+
expect(result).toContain(">Privacy</a>");
|
|
1181
|
+
expect(result).toContain('href="/terms"');
|
|
1182
|
+
expect(result).toContain(">Terms</a>");
|
|
1183
|
+
expect(result).not.toContain('href="https://acme.com"');
|
|
1184
|
+
});
|
|
1185
|
+
|
|
1186
|
+
it("suppresses all center links when footer.links is empty array", () => {
|
|
1187
|
+
const result = buildFooter(baseProvenance, {
|
|
1188
|
+
...baseConfig,
|
|
1189
|
+
footer: { links: [] },
|
|
1190
|
+
});
|
|
1191
|
+
expect(result).not.toContain('href="https://acme.com"');
|
|
1192
|
+
expect(result).not.toContain(">acme.com</a>");
|
|
1193
|
+
});
|
|
1194
|
+
|
|
1195
|
+
it("escapes HTML in custom copyright text", () => {
|
|
1196
|
+
const result = buildFooter(baseProvenance, {
|
|
1197
|
+
...baseConfig,
|
|
1198
|
+
footer: { copyright: '© 2026 <script>alert("xss")</script>' },
|
|
1199
|
+
});
|
|
1200
|
+
expect(result).toContain("<script>");
|
|
1201
|
+
expect(result).not.toContain("<script>");
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
it("escapes HTML in copyrightUrl attribute", () => {
|
|
1205
|
+
const result = buildFooter(baseProvenance, {
|
|
1206
|
+
...baseConfig,
|
|
1207
|
+
footer: { copyright: "Test", copyrightUrl: 'https://example.com" onclick="alert(1)' },
|
|
1208
|
+
});
|
|
1209
|
+
expect(result).toContain(""");
|
|
1210
|
+
expect(result).not.toContain('onclick="alert(1)"');
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
it("escapes HTML in footer link text and url", () => {
|
|
1214
|
+
const result = buildFooter(baseProvenance, {
|
|
1215
|
+
...baseConfig,
|
|
1216
|
+
footer: {
|
|
1217
|
+
links: [{ text: "<b>Bold</b>", url: '/test">' }],
|
|
1218
|
+
},
|
|
1219
|
+
});
|
|
1220
|
+
expect(result).toContain("<b>Bold</b>");
|
|
1221
|
+
expect(result).toContain("/test">");
|
|
1222
|
+
});
|
|
1223
|
+
|
|
1224
|
+
it("includes footer structure classes", () => {
|
|
1225
|
+
const result = buildFooter(baseProvenance, baseConfig);
|
|
1226
|
+
expect(result).toContain('class="site-footer"');
|
|
1227
|
+
expect(result).toContain('class="footer-content"');
|
|
1228
|
+
expect(result).toContain('class="footer-left"');
|
|
1229
|
+
expect(result).toContain('class="footer-center"');
|
|
1230
|
+
expect(result).toContain('class="footer-right"');
|
|
1231
|
+
expect(result).toContain('class="footer-version"');
|
|
1232
|
+
expect(result).toContain('class="footer-commit"');
|
|
1233
|
+
expect(result).toContain('class="footer-copyright"');
|
|
1234
|
+
expect(result).toContain('class="footer-link"');
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1237
|
+
it("uses publish date year in default copyright", () => {
|
|
1238
|
+
const result = buildFooter(baseProvenance, baseConfig);
|
|
1239
|
+
expect(result).toContain("© 2024 Acme Corp");
|
|
1240
|
+
});
|
|
1241
|
+
});
|
|
1242
|
+
|
|
1243
|
+
describe("buildBundleFooter", () => {
|
|
1244
|
+
const baseConfig: SiteConfig = {
|
|
1245
|
+
docroot: ".",
|
|
1246
|
+
title: "Bundle Test",
|
|
1247
|
+
brand: { name: "Acme Corp", url: "https://acme.com" },
|
|
1248
|
+
sections: [],
|
|
1249
|
+
};
|
|
1250
|
+
|
|
1251
|
+
it("includes attribution by default", () => {
|
|
1252
|
+
const result = buildBundleFooter("0.1.1", baseConfig);
|
|
1253
|
+
expect(result).toContain("Published (offline bundle)");
|
|
1254
|
+
expect(result).toContain(`Built with ${KITFLY_BRAND.name}`);
|
|
1255
|
+
});
|
|
1256
|
+
|
|
1257
|
+
it("omits version span when bundle version is empty", () => {
|
|
1258
|
+
const result = buildBundleFooter("", baseConfig);
|
|
1259
|
+
expect(result).not.toContain('class="footer-version"');
|
|
1260
|
+
expect(result).toContain("Published (offline bundle)");
|
|
1261
|
+
});
|
|
1262
|
+
|
|
1263
|
+
it("respects attribution opt-out", () => {
|
|
1264
|
+
const result = buildBundleFooter("0.1.1", {
|
|
1265
|
+
...baseConfig,
|
|
1266
|
+
footer: { attribution: false },
|
|
1267
|
+
});
|
|
1268
|
+
expect(result).not.toContain("Built with Kitfly");
|
|
1269
|
+
expect(result).not.toContain('class="footer-right"');
|
|
1270
|
+
});
|
|
1271
|
+
|
|
1272
|
+
it("wraps copyright in link when copyrightUrl is set", () => {
|
|
1273
|
+
const result = buildBundleFooter("0.1.1", {
|
|
1274
|
+
...baseConfig,
|
|
1275
|
+
footer: { copyright: "© 2026 3 Leaps, LLC", copyrightUrl: "https://3leaps.net" },
|
|
1276
|
+
});
|
|
1277
|
+
expect(result).toContain('href="https://3leaps.net"');
|
|
1278
|
+
expect(result).toContain(">© 2026 3 Leaps, LLC</a>");
|
|
1279
|
+
});
|
|
1280
|
+
|
|
1281
|
+
it("suppresses all center links when footer.links is empty array", () => {
|
|
1282
|
+
const result = buildBundleFooter("0.1.1", {
|
|
1283
|
+
...baseConfig,
|
|
1284
|
+
footer: { links: [] },
|
|
1285
|
+
});
|
|
1286
|
+
expect(result).not.toContain('href="https://acme.com"');
|
|
1287
|
+
expect(result).not.toContain(">acme.com</a>");
|
|
1288
|
+
});
|
|
1289
|
+
|
|
1290
|
+
it("escapes HTML in custom copyright and link fields", () => {
|
|
1291
|
+
const result = buildBundleFooter("0.1.1", {
|
|
1292
|
+
...baseConfig,
|
|
1293
|
+
footer: {
|
|
1294
|
+
copyright: '<img src=x onerror="alert(1)">',
|
|
1295
|
+
links: [{ text: "<em>XSS</em>", url: "/ok" }],
|
|
1296
|
+
},
|
|
1297
|
+
});
|
|
1298
|
+
expect(result).toContain("<img");
|
|
1299
|
+
expect(result).not.toContain("<img");
|
|
1300
|
+
expect(result).toContain("<em>");
|
|
1301
|
+
expect(result).not.toContain("<em>");
|
|
1302
|
+
});
|
|
1303
|
+
});
|
|
1304
|
+
|
|
1305
|
+
// ---------------------------------------------------------------------------
|
|
1306
|
+
// buildNavTree / renderNavTree / nodeContainsPath via buildNavSimple
|
|
1307
|
+
// (These are private functions tested through the public API)
|
|
1308
|
+
// ---------------------------------------------------------------------------
|
|
1309
|
+
|
|
1310
|
+
describe("buildNavTree and renderNavTree (via buildNavSimple)", () => {
|
|
1311
|
+
const baseConfig: SiteConfig = {
|
|
1312
|
+
docroot: ".",
|
|
1313
|
+
title: "Test",
|
|
1314
|
+
brand: { name: "Test", url: "/" },
|
|
1315
|
+
sections: [{ name: "Docs", path: "docs" }],
|
|
1316
|
+
};
|
|
1317
|
+
|
|
1318
|
+
it("renders flat file list as simple links", () => {
|
|
1319
|
+
const files: ContentFile[] = [
|
|
1320
|
+
{ path: "/docs/alpha.md", urlPath: "docs/alpha", section: "Docs", sectionBase: "docs" },
|
|
1321
|
+
{ path: "/docs/beta.md", urlPath: "docs/beta", section: "Docs", sectionBase: "docs" },
|
|
1322
|
+
];
|
|
1323
|
+
const result = buildNavSimple(files, baseConfig);
|
|
1324
|
+
expect(result).toContain('<a href="/docs/alpha">alpha</a>');
|
|
1325
|
+
expect(result).toContain('<a href="/docs/beta">beta</a>');
|
|
1326
|
+
// No <details> for flat list
|
|
1327
|
+
expect(result).not.toContain("<details>");
|
|
1328
|
+
});
|
|
1329
|
+
|
|
1330
|
+
it("renders nested files with collapsible groups", () => {
|
|
1331
|
+
const files: ContentFile[] = [
|
|
1332
|
+
{
|
|
1333
|
+
path: "/docs/guides/start.md",
|
|
1334
|
+
urlPath: "docs/guides/start",
|
|
1335
|
+
section: "Docs",
|
|
1336
|
+
sectionBase: "docs",
|
|
1337
|
+
},
|
|
1338
|
+
{
|
|
1339
|
+
path: "/docs/guides/advanced.md",
|
|
1340
|
+
urlPath: "docs/guides/advanced",
|
|
1341
|
+
section: "Docs",
|
|
1342
|
+
sectionBase: "docs",
|
|
1343
|
+
},
|
|
1344
|
+
{
|
|
1345
|
+
path: "/docs/overview.md",
|
|
1346
|
+
urlPath: "docs/overview",
|
|
1347
|
+
section: "Docs",
|
|
1348
|
+
sectionBase: "docs",
|
|
1349
|
+
},
|
|
1350
|
+
];
|
|
1351
|
+
const result = buildNavSimple(files, baseConfig);
|
|
1352
|
+
expect(result).toContain("<details>");
|
|
1353
|
+
expect(result).toContain('<summary class="nav-group">guides</summary>');
|
|
1354
|
+
expect(result).toContain('<a href="/docs/guides/start">start</a>');
|
|
1355
|
+
expect(result).toContain('<a href="/docs/guides/advanced">advanced</a>');
|
|
1356
|
+
expect(result).toContain('<a href="/docs/overview">overview</a>');
|
|
1357
|
+
});
|
|
1358
|
+
|
|
1359
|
+
it("makes section header a link when section has index file", () => {
|
|
1360
|
+
const files: ContentFile[] = [
|
|
1361
|
+
{ path: "/docs/index.md", urlPath: "docs", section: "Docs", sectionBase: "docs" },
|
|
1362
|
+
{ path: "/docs/page.md", urlPath: "docs/page", section: "Docs", sectionBase: "docs" },
|
|
1363
|
+
];
|
|
1364
|
+
const result = buildNavSimple(files, baseConfig);
|
|
1365
|
+
expect(result).toContain('class="nav-section">Docs</a>');
|
|
1366
|
+
expect(result).toContain('href="/docs"');
|
|
1367
|
+
});
|
|
1368
|
+
|
|
1369
|
+
it("uses span for section header when no index file", () => {
|
|
1370
|
+
const files: ContentFile[] = [
|
|
1371
|
+
{ path: "/docs/page.md", urlPath: "docs/page", section: "Docs", sectionBase: "docs" },
|
|
1372
|
+
];
|
|
1373
|
+
const result = buildNavSimple(files, baseConfig);
|
|
1374
|
+
expect(result).toContain('<span class="nav-section">Docs</span>');
|
|
1375
|
+
});
|
|
1376
|
+
|
|
1377
|
+
it("marks the active page with class", () => {
|
|
1378
|
+
const files: ContentFile[] = [
|
|
1379
|
+
{ path: "/docs/alpha.md", urlPath: "docs/alpha", section: "Docs", sectionBase: "docs" },
|
|
1380
|
+
{ path: "/docs/beta.md", urlPath: "docs/beta", section: "Docs", sectionBase: "docs" },
|
|
1381
|
+
];
|
|
1382
|
+
const result = buildNavSimple(files, baseConfig, "docs/alpha");
|
|
1383
|
+
expect(result).toContain('<a href="/docs/alpha" class="active">alpha</a>');
|
|
1384
|
+
expect(result).not.toContain('<a href="/docs/beta" class="active">');
|
|
1385
|
+
});
|
|
1386
|
+
|
|
1387
|
+
it("auto-opens details group containing active page", () => {
|
|
1388
|
+
const files: ContentFile[] = [
|
|
1389
|
+
{
|
|
1390
|
+
path: "/docs/api/users.md",
|
|
1391
|
+
urlPath: "docs/api/users",
|
|
1392
|
+
section: "Docs",
|
|
1393
|
+
sectionBase: "docs",
|
|
1394
|
+
},
|
|
1395
|
+
{
|
|
1396
|
+
path: "/docs/api/auth.md",
|
|
1397
|
+
urlPath: "docs/api/auth",
|
|
1398
|
+
section: "Docs",
|
|
1399
|
+
sectionBase: "docs",
|
|
1400
|
+
},
|
|
1401
|
+
{
|
|
1402
|
+
path: "/docs/overview.md",
|
|
1403
|
+
urlPath: "docs/overview",
|
|
1404
|
+
section: "Docs",
|
|
1405
|
+
sectionBase: "docs",
|
|
1406
|
+
},
|
|
1407
|
+
];
|
|
1408
|
+
const result = buildNavSimple(files, baseConfig, "docs/api/users");
|
|
1409
|
+
expect(result).toContain("<details open>");
|
|
1410
|
+
});
|
|
1411
|
+
|
|
1412
|
+
it("does not open details group when active page is elsewhere", () => {
|
|
1413
|
+
const files: ContentFile[] = [
|
|
1414
|
+
{
|
|
1415
|
+
path: "/docs/api/users.md",
|
|
1416
|
+
urlPath: "docs/api/users",
|
|
1417
|
+
section: "Docs",
|
|
1418
|
+
sectionBase: "docs",
|
|
1419
|
+
},
|
|
1420
|
+
{
|
|
1421
|
+
path: "/docs/overview.md",
|
|
1422
|
+
urlPath: "docs/overview",
|
|
1423
|
+
section: "Docs",
|
|
1424
|
+
sectionBase: "docs",
|
|
1425
|
+
},
|
|
1426
|
+
];
|
|
1427
|
+
const result = buildNavSimple(files, baseConfig, "docs/overview");
|
|
1428
|
+
// The api group should NOT be open
|
|
1429
|
+
expect(result).not.toContain("<details open>");
|
|
1430
|
+
expect(result).toContain("<details>");
|
|
1431
|
+
});
|
|
1432
|
+
|
|
1433
|
+
it("handles deeply nested paths", () => {
|
|
1434
|
+
const files: ContentFile[] = [
|
|
1435
|
+
{
|
|
1436
|
+
path: "/docs/a/b/c.md",
|
|
1437
|
+
urlPath: "docs/a/b/c",
|
|
1438
|
+
section: "Docs",
|
|
1439
|
+
sectionBase: "docs",
|
|
1440
|
+
},
|
|
1441
|
+
];
|
|
1442
|
+
const result = buildNavSimple(files, baseConfig);
|
|
1443
|
+
expect(result).toContain('<summary class="nav-group">a</summary>');
|
|
1444
|
+
expect(result).toContain('<summary class="nav-group">b</summary>');
|
|
1445
|
+
expect(result).toContain('<a href="/docs/a/b/c">c</a>');
|
|
1446
|
+
});
|
|
1447
|
+
|
|
1448
|
+
it("handles directory index file that becomes group link", () => {
|
|
1449
|
+
const files: ContentFile[] = [
|
|
1450
|
+
{
|
|
1451
|
+
path: "/docs/guides/index.md",
|
|
1452
|
+
urlPath: "docs/guides",
|
|
1453
|
+
section: "Docs",
|
|
1454
|
+
sectionBase: "docs",
|
|
1455
|
+
},
|
|
1456
|
+
{
|
|
1457
|
+
path: "/docs/guides/setup.md",
|
|
1458
|
+
urlPath: "docs/guides/setup",
|
|
1459
|
+
section: "Docs",
|
|
1460
|
+
sectionBase: "docs",
|
|
1461
|
+
},
|
|
1462
|
+
];
|
|
1463
|
+
const result = buildNavSimple(files, baseConfig);
|
|
1464
|
+
// The guides directory group should have a clickable link
|
|
1465
|
+
expect(result).toContain('<summary class="nav-group">');
|
|
1466
|
+
expect(result).toContain('href="/docs/guides"');
|
|
1467
|
+
expect(result).toContain("guides</a>");
|
|
1468
|
+
expect(result).toContain('<a href="/docs/guides/setup">setup</a>');
|
|
1469
|
+
});
|
|
1470
|
+
|
|
1471
|
+
it("renders multiple sections in config order", () => {
|
|
1472
|
+
const multiConfig: SiteConfig = {
|
|
1473
|
+
...baseConfig,
|
|
1474
|
+
sections: [
|
|
1475
|
+
{ name: "Alpha", path: "alpha" },
|
|
1476
|
+
{ name: "Beta", path: "beta" },
|
|
1477
|
+
],
|
|
1478
|
+
};
|
|
1479
|
+
const files: ContentFile[] = [
|
|
1480
|
+
{
|
|
1481
|
+
path: "/beta/b.md",
|
|
1482
|
+
urlPath: "beta/b",
|
|
1483
|
+
section: "Beta",
|
|
1484
|
+
sectionBase: "beta",
|
|
1485
|
+
},
|
|
1486
|
+
{
|
|
1487
|
+
path: "/alpha/a.md",
|
|
1488
|
+
urlPath: "alpha/a",
|
|
1489
|
+
section: "Alpha",
|
|
1490
|
+
sectionBase: "alpha",
|
|
1491
|
+
},
|
|
1492
|
+
];
|
|
1493
|
+
const result = buildNavSimple(files, multiConfig);
|
|
1494
|
+
const alphaPos = result.indexOf("Alpha");
|
|
1495
|
+
const betaPos = result.indexOf("Beta");
|
|
1496
|
+
expect(alphaPos).toBeLessThan(betaPos);
|
|
1497
|
+
});
|
|
1498
|
+
|
|
1499
|
+
it("returns empty ul when no files match any section", () => {
|
|
1500
|
+
const result = buildNavSimple([], baseConfig);
|
|
1501
|
+
expect(result).toBe("<ul></ul>");
|
|
1502
|
+
});
|
|
1503
|
+
});
|
|
1504
|
+
|
|
1505
|
+
// ---------------------------------------------------------------------------
|
|
1506
|
+
// nodeContainsPath (via buildNavSimple auto-open behavior)
|
|
1507
|
+
// ---------------------------------------------------------------------------
|
|
1508
|
+
|
|
1509
|
+
describe("nodeContainsPath (via buildNavSimple details open)", () => {
|
|
1510
|
+
const config: SiteConfig = {
|
|
1511
|
+
docroot: ".",
|
|
1512
|
+
title: "Test",
|
|
1513
|
+
brand: { name: "Test", url: "/" },
|
|
1514
|
+
sections: [{ name: "Docs", path: "docs" }],
|
|
1515
|
+
};
|
|
1516
|
+
|
|
1517
|
+
it("opens parent group when active page is a nested child", () => {
|
|
1518
|
+
const files: ContentFile[] = [
|
|
1519
|
+
{
|
|
1520
|
+
path: "/docs/ref/api/get.md",
|
|
1521
|
+
urlPath: "docs/ref/api/get",
|
|
1522
|
+
section: "Docs",
|
|
1523
|
+
sectionBase: "docs",
|
|
1524
|
+
},
|
|
1525
|
+
{
|
|
1526
|
+
path: "/docs/ref/api/post.md",
|
|
1527
|
+
urlPath: "docs/ref/api/post",
|
|
1528
|
+
section: "Docs",
|
|
1529
|
+
sectionBase: "docs",
|
|
1530
|
+
},
|
|
1531
|
+
{
|
|
1532
|
+
path: "/docs/other.md",
|
|
1533
|
+
urlPath: "docs/other",
|
|
1534
|
+
section: "Docs",
|
|
1535
|
+
sectionBase: "docs",
|
|
1536
|
+
},
|
|
1537
|
+
];
|
|
1538
|
+
const result = buildNavSimple(files, config, "docs/ref/api/get");
|
|
1539
|
+
// Both the ref group and the api group should be open
|
|
1540
|
+
const openCount = (result.match(/<details open>/g) || []).length;
|
|
1541
|
+
expect(openCount).toBe(2);
|
|
1542
|
+
});
|
|
1543
|
+
|
|
1544
|
+
it("does not open any group when current page is a top-level leaf", () => {
|
|
1545
|
+
const files: ContentFile[] = [
|
|
1546
|
+
{
|
|
1547
|
+
path: "/docs/api/users.md",
|
|
1548
|
+
urlPath: "docs/api/users",
|
|
1549
|
+
section: "Docs",
|
|
1550
|
+
sectionBase: "docs",
|
|
1551
|
+
},
|
|
1552
|
+
{
|
|
1553
|
+
path: "/docs/top.md",
|
|
1554
|
+
urlPath: "docs/top",
|
|
1555
|
+
section: "Docs",
|
|
1556
|
+
sectionBase: "docs",
|
|
1557
|
+
},
|
|
1558
|
+
];
|
|
1559
|
+
const result = buildNavSimple(files, config, "docs/top");
|
|
1560
|
+
expect(result).not.toContain("<details open>");
|
|
1561
|
+
});
|
|
1562
|
+
});
|
|
1563
|
+
|
|
1564
|
+
// ---------------------------------------------------------------------------
|
|
1565
|
+
// findSectionBase (via buildNavSimple with missing sectionBase)
|
|
1566
|
+
// ---------------------------------------------------------------------------
|
|
1567
|
+
|
|
1568
|
+
describe("findSectionBase (via buildNavSimple without sectionBase)", () => {
|
|
1569
|
+
const config: SiteConfig = {
|
|
1570
|
+
docroot: ".",
|
|
1571
|
+
title: "Test",
|
|
1572
|
+
brand: { name: "Test", url: "/" },
|
|
1573
|
+
sections: [{ name: "Docs", path: "docs" }],
|
|
1574
|
+
};
|
|
1575
|
+
|
|
1576
|
+
it("derives section base from common prefix when sectionBase is not set", () => {
|
|
1577
|
+
const files: ContentFile[] = [
|
|
1578
|
+
{ path: "/docs/alpha.md", urlPath: "docs/alpha", section: "Docs" },
|
|
1579
|
+
{ path: "/docs/beta.md", urlPath: "docs/beta", section: "Docs" },
|
|
1580
|
+
];
|
|
1581
|
+
const result = buildNavSimple(files, config);
|
|
1582
|
+
// If findSectionBase works correctly, these should appear as leaf nodes
|
|
1583
|
+
expect(result).toContain('<a href="/docs/alpha">alpha</a>');
|
|
1584
|
+
expect(result).toContain('<a href="/docs/beta">beta</a>');
|
|
1585
|
+
});
|
|
1586
|
+
|
|
1587
|
+
it("handles single file section without sectionBase", () => {
|
|
1588
|
+
const files: ContentFile[] = [{ path: "/docs/only.md", urlPath: "docs/only", section: "Docs" }];
|
|
1589
|
+
const result = buildNavSimple(files, config);
|
|
1590
|
+
expect(result).toContain('<a href="/docs/only">only</a>');
|
|
1591
|
+
});
|
|
1592
|
+
});
|
|
1593
|
+
|
|
1594
|
+
// ---------------------------------------------------------------------------
|
|
1595
|
+
// renderNavTree (via buildNavStatic with path prefix)
|
|
1596
|
+
// ---------------------------------------------------------------------------
|
|
1597
|
+
|
|
1598
|
+
describe("renderNavTree with static paths (via buildNavStatic)", () => {
|
|
1599
|
+
const config: SiteConfig = {
|
|
1600
|
+
docroot: ".",
|
|
1601
|
+
title: "Test",
|
|
1602
|
+
brand: { name: "Test", url: "/" },
|
|
1603
|
+
sections: [{ name: "Docs", path: "docs" }],
|
|
1604
|
+
};
|
|
1605
|
+
|
|
1606
|
+
it("appends .html to all hrefs", () => {
|
|
1607
|
+
const files: ContentFile[] = [
|
|
1608
|
+
{ path: "/docs/intro.md", urlPath: "docs/intro", section: "Docs", sectionBase: "docs" },
|
|
1609
|
+
];
|
|
1610
|
+
const result = buildNavStatic(files, "docs/intro", config, "/out/");
|
|
1611
|
+
expect(result).toContain('href="/out/docs/intro.html"');
|
|
1612
|
+
});
|
|
1613
|
+
|
|
1614
|
+
it("marks active page in static nav", () => {
|
|
1615
|
+
const files: ContentFile[] = [
|
|
1616
|
+
{ path: "/docs/a.md", urlPath: "docs/a", section: "Docs", sectionBase: "docs" },
|
|
1617
|
+
{ path: "/docs/b.md", urlPath: "docs/b", section: "Docs", sectionBase: "docs" },
|
|
1618
|
+
];
|
|
1619
|
+
const result = buildNavStatic(files, "docs/a", config, "/out/");
|
|
1620
|
+
expect(result).toContain('<a href="/out/docs/a.html" class="active">a</a>');
|
|
1621
|
+
expect(result).not.toContain('<a href="/out/docs/b.html" class="active">');
|
|
1622
|
+
});
|
|
1623
|
+
|
|
1624
|
+
it("opens group containing active page in static nav", () => {
|
|
1625
|
+
const files: ContentFile[] = [
|
|
1626
|
+
{
|
|
1627
|
+
path: "/docs/api/get.md",
|
|
1628
|
+
urlPath: "docs/api/get",
|
|
1629
|
+
section: "Docs",
|
|
1630
|
+
sectionBase: "docs",
|
|
1631
|
+
},
|
|
1632
|
+
{
|
|
1633
|
+
path: "/docs/api/post.md",
|
|
1634
|
+
urlPath: "docs/api/post",
|
|
1635
|
+
section: "Docs",
|
|
1636
|
+
sectionBase: "docs",
|
|
1637
|
+
},
|
|
1638
|
+
];
|
|
1639
|
+
const result = buildNavStatic(files, "docs/api/get", config, "/out/");
|
|
1640
|
+
expect(result).toContain("<details open>");
|
|
1641
|
+
});
|
|
1642
|
+
|
|
1643
|
+
it("renders empty nav when no files", () => {
|
|
1644
|
+
const result = buildNavStatic([], "", config, "/out/");
|
|
1645
|
+
expect(result).toBe("<ul></ul>");
|
|
1646
|
+
});
|
|
1647
|
+
});
|
|
1648
|
+
|
|
1649
|
+
// ---------------------------------------------------------------------------
|
|
1650
|
+
// walkContentDir (via collectFiles with real temp directories)
|
|
1651
|
+
// ---------------------------------------------------------------------------
|
|
1652
|
+
|
|
1653
|
+
describe("walkContentDir (via collectFiles)", () => {
|
|
1654
|
+
let tmpDir: string;
|
|
1655
|
+
|
|
1656
|
+
beforeEach(async () => {
|
|
1657
|
+
tmpDir = await mkdtemp(join(tmpdir(), "kitfly-test-"));
|
|
1658
|
+
});
|
|
1659
|
+
|
|
1660
|
+
afterEach(async () => {
|
|
1661
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
1662
|
+
});
|
|
1663
|
+
|
|
1664
|
+
it("discovers markdown files in a section directory", async () => {
|
|
1665
|
+
await mkdir(join(tmpDir, "docs"), { recursive: true });
|
|
1666
|
+
await writeFile(join(tmpDir, "docs", "intro.md"), "# Intro");
|
|
1667
|
+
await writeFile(join(tmpDir, "docs", "setup.md"), "# Setup");
|
|
1668
|
+
|
|
1669
|
+
const config: SiteConfig = {
|
|
1670
|
+
docroot: ".",
|
|
1671
|
+
title: "Test",
|
|
1672
|
+
brand: { name: "T", url: "/" },
|
|
1673
|
+
sections: [{ name: "Docs", path: "docs" }],
|
|
1674
|
+
};
|
|
1675
|
+
const files = await collectFiles(tmpDir, config);
|
|
1676
|
+
expect(files).toHaveLength(2);
|
|
1677
|
+
const urls = files.map((f) => f.urlPath).sort();
|
|
1678
|
+
expect(urls).toEqual(["docs/intro", "docs/setup"]);
|
|
1679
|
+
});
|
|
1680
|
+
|
|
1681
|
+
it("discovers files recursively in subdirectories", async () => {
|
|
1682
|
+
await mkdir(join(tmpDir, "docs", "guides"), { recursive: true });
|
|
1683
|
+
await writeFile(join(tmpDir, "docs", "top.md"), "# Top");
|
|
1684
|
+
await writeFile(join(tmpDir, "docs", "guides", "deep.md"), "# Deep");
|
|
1685
|
+
|
|
1686
|
+
const config: SiteConfig = {
|
|
1687
|
+
docroot: ".",
|
|
1688
|
+
title: "Test",
|
|
1689
|
+
brand: { name: "T", url: "/" },
|
|
1690
|
+
sections: [{ name: "Docs", path: "docs" }],
|
|
1691
|
+
};
|
|
1692
|
+
const files = await collectFiles(tmpDir, config);
|
|
1693
|
+
expect(files).toHaveLength(2);
|
|
1694
|
+
const urls = files.map((f) => f.urlPath).sort();
|
|
1695
|
+
expect(urls).toEqual(["docs/guides/deep", "docs/top"]);
|
|
1696
|
+
});
|
|
1697
|
+
|
|
1698
|
+
it("maps index.md to parent directory URL path", async () => {
|
|
1699
|
+
await mkdir(join(tmpDir, "docs"), { recursive: true });
|
|
1700
|
+
await writeFile(join(tmpDir, "docs", "index.md"), "# Index");
|
|
1701
|
+
|
|
1702
|
+
const config: SiteConfig = {
|
|
1703
|
+
docroot: ".",
|
|
1704
|
+
title: "Test",
|
|
1705
|
+
brand: { name: "T", url: "/" },
|
|
1706
|
+
sections: [{ name: "Docs", path: "docs" }],
|
|
1707
|
+
};
|
|
1708
|
+
const files = await collectFiles(tmpDir, config);
|
|
1709
|
+
expect(files).toHaveLength(1);
|
|
1710
|
+
expect(files[0].urlPath).toBe("docs");
|
|
1711
|
+
});
|
|
1712
|
+
|
|
1713
|
+
it("maps readme.md to parent directory URL path", async () => {
|
|
1714
|
+
await mkdir(join(tmpDir, "docs"), { recursive: true });
|
|
1715
|
+
await writeFile(join(tmpDir, "docs", "README.md"), "# Readme");
|
|
1716
|
+
|
|
1717
|
+
const config: SiteConfig = {
|
|
1718
|
+
docroot: ".",
|
|
1719
|
+
title: "Test",
|
|
1720
|
+
brand: { name: "T", url: "/" },
|
|
1721
|
+
sections: [{ name: "Docs", path: "docs" }],
|
|
1722
|
+
};
|
|
1723
|
+
const files = await collectFiles(tmpDir, config);
|
|
1724
|
+
expect(files).toHaveLength(1);
|
|
1725
|
+
expect(files[0].urlPath).toBe("docs");
|
|
1726
|
+
});
|
|
1727
|
+
|
|
1728
|
+
it("skips hidden files and directories", async () => {
|
|
1729
|
+
await mkdir(join(tmpDir, "docs", ".hidden"), { recursive: true });
|
|
1730
|
+
await writeFile(join(tmpDir, "docs", ".hidden", "secret.md"), "# Secret");
|
|
1731
|
+
await writeFile(join(tmpDir, "docs", ".dotfile.md"), "# Dot");
|
|
1732
|
+
await writeFile(join(tmpDir, "docs", "visible.md"), "# Visible");
|
|
1733
|
+
|
|
1734
|
+
const config: SiteConfig = {
|
|
1735
|
+
docroot: ".",
|
|
1736
|
+
title: "Test",
|
|
1737
|
+
brand: { name: "T", url: "/" },
|
|
1738
|
+
sections: [{ name: "Docs", path: "docs" }],
|
|
1739
|
+
};
|
|
1740
|
+
const files = await collectFiles(tmpDir, config);
|
|
1741
|
+
expect(files).toHaveLength(1);
|
|
1742
|
+
expect(files[0].urlPath).toBe("docs/visible");
|
|
1743
|
+
});
|
|
1744
|
+
|
|
1745
|
+
it("skips non-content files (txt, html, etc.)", async () => {
|
|
1746
|
+
await mkdir(join(tmpDir, "docs"), { recursive: true });
|
|
1747
|
+
await writeFile(join(tmpDir, "docs", "readme.txt"), "text file");
|
|
1748
|
+
await writeFile(join(tmpDir, "docs", "page.html"), "<html>");
|
|
1749
|
+
await writeFile(join(tmpDir, "docs", "real.md"), "# Real");
|
|
1750
|
+
await writeFile(join(tmpDir, "docs", "data.json"), "{}");
|
|
1751
|
+
await writeFile(join(tmpDir, "docs", "config.yaml"), "key: val");
|
|
1752
|
+
|
|
1753
|
+
const config: SiteConfig = {
|
|
1754
|
+
docroot: ".",
|
|
1755
|
+
title: "Test",
|
|
1756
|
+
brand: { name: "T", url: "/" },
|
|
1757
|
+
sections: [{ name: "Docs", path: "docs" }],
|
|
1758
|
+
};
|
|
1759
|
+
const files = await collectFiles(tmpDir, config);
|
|
1760
|
+
const extensions = files.map((f) => f.path.split(".").pop());
|
|
1761
|
+
// md, json, yaml are allowed; txt and html are not
|
|
1762
|
+
expect(extensions).not.toContain("txt");
|
|
1763
|
+
expect(extensions).not.toContain("html");
|
|
1764
|
+
expect(files.length).toBe(3);
|
|
1765
|
+
});
|
|
1766
|
+
|
|
1767
|
+
it("respects maxDepth limit on sections", async () => {
|
|
1768
|
+
// Create a path 3 levels deep: docs/a/b/c/deep.md
|
|
1769
|
+
await mkdir(join(tmpDir, "docs", "a", "b", "c"), { recursive: true });
|
|
1770
|
+
await writeFile(join(tmpDir, "docs", "a", "b", "c", "deep.md"), "# Deep");
|
|
1771
|
+
await writeFile(join(tmpDir, "docs", "a", "shallow.md"), "# Shallow");
|
|
1772
|
+
|
|
1773
|
+
const config: SiteConfig = {
|
|
1774
|
+
docroot: ".",
|
|
1775
|
+
title: "Test",
|
|
1776
|
+
brand: { name: "T", url: "/" },
|
|
1777
|
+
sections: [{ name: "Docs", path: "docs", maxDepth: 2 }],
|
|
1778
|
+
};
|
|
1779
|
+
const files = await collectFiles(tmpDir, config);
|
|
1780
|
+
// depth 0 = docs/, depth 1 = docs/a/, depth 2 = docs/a/b/
|
|
1781
|
+
// docs/a/shallow.md is at depth 1 (OK)
|
|
1782
|
+
// docs/a/b/c/deep.md is at depth 3 (exceeds maxDepth 2)
|
|
1783
|
+
const urls = files.map((f) => f.urlPath);
|
|
1784
|
+
expect(urls).toContain("docs/a/shallow");
|
|
1785
|
+
expect(urls).not.toContain("docs/a/b/c/deep");
|
|
1786
|
+
});
|
|
1787
|
+
|
|
1788
|
+
it("uses explicit file list when section has files property", async () => {
|
|
1789
|
+
await mkdir(join(tmpDir, "docs"), { recursive: true });
|
|
1790
|
+
await writeFile(join(tmpDir, "docs", "included.md"), "# Included");
|
|
1791
|
+
await writeFile(join(tmpDir, "docs", "excluded.md"), "# Excluded");
|
|
1792
|
+
|
|
1793
|
+
const config: SiteConfig = {
|
|
1794
|
+
docroot: ".",
|
|
1795
|
+
title: "Test",
|
|
1796
|
+
brand: { name: "T", url: "/" },
|
|
1797
|
+
sections: [{ name: "Docs", path: "docs", files: ["included.md"] }],
|
|
1798
|
+
};
|
|
1799
|
+
const files = await collectFiles(tmpDir, config);
|
|
1800
|
+
expect(files).toHaveLength(1);
|
|
1801
|
+
expect(files[0].urlPath).toBe("included");
|
|
1802
|
+
});
|
|
1803
|
+
|
|
1804
|
+
it("skips files in explicit list that do not exist", async () => {
|
|
1805
|
+
await mkdir(join(tmpDir, "docs"), { recursive: true });
|
|
1806
|
+
await writeFile(join(tmpDir, "docs", "exists.md"), "# Exists");
|
|
1807
|
+
|
|
1808
|
+
const config: SiteConfig = {
|
|
1809
|
+
docroot: ".",
|
|
1810
|
+
title: "Test",
|
|
1811
|
+
brand: { name: "T", url: "/" },
|
|
1812
|
+
sections: [{ name: "Docs", path: "docs", files: ["exists.md", "missing.md"] }],
|
|
1813
|
+
};
|
|
1814
|
+
const files = await collectFiles(tmpDir, config);
|
|
1815
|
+
expect(files).toHaveLength(1);
|
|
1816
|
+
expect(files[0].urlPath).toBe("exists");
|
|
1817
|
+
});
|
|
1818
|
+
|
|
1819
|
+
it("returns sorted entries for deterministic order", async () => {
|
|
1820
|
+
await mkdir(join(tmpDir, "docs"), { recursive: true });
|
|
1821
|
+
await writeFile(join(tmpDir, "docs", "zebra.md"), "# Zebra");
|
|
1822
|
+
await writeFile(join(tmpDir, "docs", "alpha.md"), "# Alpha");
|
|
1823
|
+
await writeFile(join(tmpDir, "docs", "middle.md"), "# Middle");
|
|
1824
|
+
|
|
1825
|
+
const config: SiteConfig = {
|
|
1826
|
+
docroot: ".",
|
|
1827
|
+
title: "Test",
|
|
1828
|
+
brand: { name: "T", url: "/" },
|
|
1829
|
+
sections: [{ name: "Docs", path: "docs" }],
|
|
1830
|
+
};
|
|
1831
|
+
const files = await collectFiles(tmpDir, config);
|
|
1832
|
+
const urls = files.map((f) => f.urlPath);
|
|
1833
|
+
expect(urls).toEqual(["docs/alpha", "docs/middle", "docs/zebra"]);
|
|
1834
|
+
});
|
|
1835
|
+
|
|
1836
|
+
it("handles empty section directory", async () => {
|
|
1837
|
+
await mkdir(join(tmpDir, "docs"), { recursive: true });
|
|
1838
|
+
|
|
1839
|
+
const config: SiteConfig = {
|
|
1840
|
+
docroot: ".",
|
|
1841
|
+
title: "Test",
|
|
1842
|
+
brand: { name: "T", url: "/" },
|
|
1843
|
+
sections: [{ name: "Docs", path: "docs" }],
|
|
1844
|
+
};
|
|
1845
|
+
const files = await collectFiles(tmpDir, config);
|
|
1846
|
+
expect(files).toEqual([]);
|
|
1847
|
+
});
|
|
1848
|
+
|
|
1849
|
+
it("sets sectionBase on discovered files", async () => {
|
|
1850
|
+
await mkdir(join(tmpDir, "docs"), { recursive: true });
|
|
1851
|
+
await writeFile(join(tmpDir, "docs", "page.md"), "# Page");
|
|
1852
|
+
|
|
1853
|
+
const config: SiteConfig = {
|
|
1854
|
+
docroot: ".",
|
|
1855
|
+
title: "Test",
|
|
1856
|
+
brand: { name: "T", url: "/" },
|
|
1857
|
+
sections: [{ name: "Docs", path: "docs" }],
|
|
1858
|
+
};
|
|
1859
|
+
const files = await collectFiles(tmpDir, config);
|
|
1860
|
+
expect(files[0].sectionBase).toBe("docs");
|
|
1861
|
+
});
|
|
1862
|
+
|
|
1863
|
+
it("handles non-existent section directory gracefully", async () => {
|
|
1864
|
+
const config: SiteConfig = {
|
|
1865
|
+
docroot: ".",
|
|
1866
|
+
title: "Test",
|
|
1867
|
+
brand: { name: "T", url: "/" },
|
|
1868
|
+
sections: [{ name: "Docs", path: "nope" }],
|
|
1869
|
+
};
|
|
1870
|
+
const files = await collectFiles(tmpDir, config);
|
|
1871
|
+
expect(files).toEqual([]);
|
|
1872
|
+
});
|
|
1873
|
+
|
|
1874
|
+
it("excludes matching file names with exact pattern", async () => {
|
|
1875
|
+
await mkdir(join(tmpDir, "docs"), { recursive: true });
|
|
1876
|
+
await writeFile(join(tmpDir, "docs", "keep.md"), "# Keep");
|
|
1877
|
+
await writeFile(join(tmpDir, "docs", "site.schema.json"), "{}");
|
|
1878
|
+
|
|
1879
|
+
const config: SiteConfig = {
|
|
1880
|
+
docroot: ".",
|
|
1881
|
+
title: "Test",
|
|
1882
|
+
brand: { name: "T", url: "/" },
|
|
1883
|
+
sections: [{ name: "Docs", path: "docs", exclude: ["site.schema.json"] }],
|
|
1884
|
+
};
|
|
1885
|
+
const files = await collectFiles(tmpDir, config);
|
|
1886
|
+
const urls = files.map((f) => f.urlPath);
|
|
1887
|
+
expect(urls).toContain("docs/keep");
|
|
1888
|
+
expect(urls).not.toContain("docs/site.schema");
|
|
1889
|
+
});
|
|
1890
|
+
|
|
1891
|
+
it("excludes multiple files with wildcard pattern", async () => {
|
|
1892
|
+
await mkdir(join(tmpDir, "docs"), { recursive: true });
|
|
1893
|
+
await writeFile(join(tmpDir, "docs", "draft-api.md"), "# Draft API");
|
|
1894
|
+
await writeFile(join(tmpDir, "docs", "draft-notes.md"), "# Draft Notes");
|
|
1895
|
+
await writeFile(join(tmpDir, "docs", "final.md"), "# Final");
|
|
1896
|
+
|
|
1897
|
+
const config: SiteConfig = {
|
|
1898
|
+
docroot: ".",
|
|
1899
|
+
title: "Test",
|
|
1900
|
+
brand: { name: "T", url: "/" },
|
|
1901
|
+
sections: [{ name: "Docs", path: "docs", exclude: ["draft*"] }],
|
|
1902
|
+
};
|
|
1903
|
+
const files = await collectFiles(tmpDir, config);
|
|
1904
|
+
const urls = files.map((f) => f.urlPath);
|
|
1905
|
+
expect(urls).toContain("docs/final");
|
|
1906
|
+
expect(urls).not.toContain("docs/draft-api");
|
|
1907
|
+
expect(urls).not.toContain("docs/draft-notes");
|
|
1908
|
+
});
|
|
1909
|
+
|
|
1910
|
+
it("keeps files when exclude pattern does not match", async () => {
|
|
1911
|
+
await mkdir(join(tmpDir, "docs"), { recursive: true });
|
|
1912
|
+
await writeFile(join(tmpDir, "docs", "alpha.md"), "# Alpha");
|
|
1913
|
+
await writeFile(join(tmpDir, "docs", "beta.md"), "# Beta");
|
|
1914
|
+
|
|
1915
|
+
const config: SiteConfig = {
|
|
1916
|
+
docroot: ".",
|
|
1917
|
+
title: "Test",
|
|
1918
|
+
brand: { name: "T", url: "/" },
|
|
1919
|
+
sections: [{ name: "Docs", path: "docs", exclude: ["draft*"] }],
|
|
1920
|
+
};
|
|
1921
|
+
const files = await collectFiles(tmpDir, config);
|
|
1922
|
+
const urls = files.map((f) => f.urlPath).sort();
|
|
1923
|
+
expect(urls).toEqual(["docs/alpha", "docs/beta"]);
|
|
1924
|
+
});
|
|
1925
|
+
|
|
1926
|
+
it("excludes matching directory names and skips entire subtree", async () => {
|
|
1927
|
+
await mkdir(join(tmpDir, "docs", "internal"), { recursive: true });
|
|
1928
|
+
await writeFile(join(tmpDir, "docs", "public.md"), "# Public");
|
|
1929
|
+
await writeFile(join(tmpDir, "docs", "internal", "secret.md"), "# Secret");
|
|
1930
|
+
|
|
1931
|
+
const config: SiteConfig = {
|
|
1932
|
+
docroot: ".",
|
|
1933
|
+
title: "Test",
|
|
1934
|
+
brand: { name: "T", url: "/" },
|
|
1935
|
+
sections: [{ name: "Docs", path: "docs", exclude: ["internal"] }],
|
|
1936
|
+
};
|
|
1937
|
+
const files = await collectFiles(tmpDir, config);
|
|
1938
|
+
const urls = files.map((f) => f.urlPath);
|
|
1939
|
+
expect(urls).toContain("docs/public");
|
|
1940
|
+
expect(urls).not.toContain("docs/internal/secret");
|
|
1941
|
+
});
|
|
1942
|
+
|
|
1943
|
+
it("treats empty exclude as no filtering", async () => {
|
|
1944
|
+
await mkdir(join(tmpDir, "docs"), { recursive: true });
|
|
1945
|
+
await writeFile(join(tmpDir, "docs", "one.md"), "# One");
|
|
1946
|
+
await writeFile(join(tmpDir, "docs", "two.md"), "# Two");
|
|
1947
|
+
|
|
1948
|
+
const config: SiteConfig = {
|
|
1949
|
+
docroot: ".",
|
|
1950
|
+
title: "Test",
|
|
1951
|
+
brand: { name: "T", url: "/" },
|
|
1952
|
+
sections: [{ name: "Docs", path: "docs", exclude: [] }],
|
|
1953
|
+
};
|
|
1954
|
+
const files = await collectFiles(tmpDir, config);
|
|
1955
|
+
const urls = files.map((f) => f.urlPath).sort();
|
|
1956
|
+
expect(urls).toEqual(["docs/one", "docs/two"]);
|
|
1957
|
+
});
|
|
1958
|
+
|
|
1959
|
+
it("ignores exclude when section uses explicit files list", async () => {
|
|
1960
|
+
await mkdir(join(tmpDir, "docs"), { recursive: true });
|
|
1961
|
+
await writeFile(join(tmpDir, "docs", "included.md"), "# Included");
|
|
1962
|
+
await writeFile(join(tmpDir, "docs", "other.md"), "# Other");
|
|
1963
|
+
|
|
1964
|
+
const config: SiteConfig = {
|
|
1965
|
+
docroot: ".",
|
|
1966
|
+
title: "Test",
|
|
1967
|
+
brand: { name: "T", url: "/" },
|
|
1968
|
+
sections: [
|
|
1969
|
+
{
|
|
1970
|
+
name: "Docs",
|
|
1971
|
+
path: "docs",
|
|
1972
|
+
files: ["included.md"],
|
|
1973
|
+
exclude: ["included.md"],
|
|
1974
|
+
},
|
|
1975
|
+
],
|
|
1976
|
+
};
|
|
1977
|
+
const files = await collectFiles(tmpDir, config);
|
|
1978
|
+
expect(files).toHaveLength(1);
|
|
1979
|
+
expect(files[0].urlPath).toBe("included");
|
|
1980
|
+
});
|
|
1981
|
+
|
|
1982
|
+
it("matches exclude against section-relative path so nested duplicates are preserved", async () => {
|
|
1983
|
+
await mkdir(join(tmpDir, "schemas", "v0"), { recursive: true });
|
|
1984
|
+
await writeFile(join(tmpDir, "schemas", "site.schema.json"), "{}");
|
|
1985
|
+
await writeFile(join(tmpDir, "schemas", "theme.schema.json"), "{}");
|
|
1986
|
+
await writeFile(join(tmpDir, "schemas", "v0", "site.schema.json"), "{}");
|
|
1987
|
+
await writeFile(join(tmpDir, "schemas", "v0", "theme.schema.json"), "{}");
|
|
1988
|
+
|
|
1989
|
+
const config: SiteConfig = {
|
|
1990
|
+
docroot: ".",
|
|
1991
|
+
title: "Test",
|
|
1992
|
+
brand: { name: "T", url: "/" },
|
|
1993
|
+
sections: [
|
|
1994
|
+
{
|
|
1995
|
+
name: "Schemas",
|
|
1996
|
+
path: "schemas",
|
|
1997
|
+
exclude: ["site.schema.json", "theme.schema.json"],
|
|
1998
|
+
},
|
|
1999
|
+
],
|
|
2000
|
+
};
|
|
2001
|
+
const files = await collectFiles(tmpDir, config);
|
|
2002
|
+
const urls = files.map((f) => f.urlPath).sort();
|
|
2003
|
+
expect(urls).toEqual(["schemas/v0/site.schema", "schemas/v0/theme.schema"]);
|
|
2004
|
+
});
|
|
2005
|
+
});
|