kitfly 0.2.1 → 0.2.3
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 +56 -0
- package/README.md +25 -10
- package/VERSION +1 -1
- package/dist/_raw/content/guide/branding.md +146 -0
- package/dist/_raw/content/guide/data-driven-content.md +204 -0
- package/dist/_raw/content/reference/configuration.md +145 -7
- package/dist/_raw/content/reference/environment-variables.md +26 -1
- package/dist/_raw/content/reference/glossary.md +25 -1
- package/dist/_raw/content/reference/key-concepts.md +30 -2
- package/dist/_raw/content/reference/plugins.md +14 -0
- package/dist/_raw/docs/decisions/ADR-0006-data-driven-content.md +350 -0
- package/dist/content/deployment/preflight.html +10 -6
- package/dist/content/deployment/recipes/aws-s3.html +10 -6
- package/dist/content/deployment/recipes/cloudflare-pages.html +10 -6
- package/dist/content/deployment/recipes/cloudflare-r2.html +10 -6
- package/dist/content/deployment/recipes/fly-io.html +10 -6
- package/dist/content/deployment/recipes/github-pages.html +10 -6
- package/dist/content/deployment/recipes/netlify.html +10 -6
- package/dist/content/deployment/recipes/vercel.html +10 -6
- package/dist/content/deployment/secrets-and-env-vars.html +10 -6
- package/dist/content/deployment.html +10 -6
- package/dist/content/guide/approaches.html +10 -6
- package/dist/content/guide/branding.html +510 -0
- package/dist/content/guide/data-driven-content.html +543 -0
- package/dist/content/guide/features.html +10 -6
- package/dist/content/guide/getting-started.html +10 -6
- package/dist/content/guide/kitfly-overview.html +10 -6
- package/dist/content/reference/configuration.html +135 -9
- package/dist/content/reference/design-catalog.html +10 -6
- package/dist/content/reference/environment-variables.html +50 -8
- package/dist/content/reference/glossary.html +24 -8
- package/dist/content/reference/key-concepts.html +33 -9
- package/dist/content/reference/plugins.html +22 -7
- package/dist/content/reference/slides-authoring-guidelines.html +10 -6
- package/dist/content/reference/structure.html +10 -6
- package/dist/content/reference.html +10 -6
- package/dist/content/templates/crucible.html +10 -6
- package/dist/content/templates/handbook.html +10 -6
- package/dist/content/templates/minimal.html +10 -6
- package/dist/content/templates/overview.html +10 -6
- package/dist/content/templates/pipeline.html +10 -6
- package/dist/content/templates/productbook.html +10 -6
- package/dist/content/templates/runbook.html +10 -6
- package/dist/content/templates/servicebook.html +10 -6
- package/dist/content-index.json +29 -2
- package/dist/docs/decisions/ADR-0001-minimalist-site-code.html +10 -6
- package/dist/docs/decisions/ADR-0002-ai-accessibility.html +10 -6
- package/dist/docs/decisions/ADR-0003-single-file-bundle.html +10 -6
- package/dist/docs/decisions/ADR-0004-bun-runtime.html +10 -6
- package/dist/docs/decisions/ADR-0005-plugin-contract-and-distribution.html +10 -6
- package/dist/docs/decisions/ADR-0006-data-driven-content.html +752 -0
- package/dist/docs/decisions/DDR-0001-viewport-locked-layout.html +10 -6
- package/dist/docs/decisions/DDR-0002-theme-system.html +10 -6
- package/dist/docs/decisions/DDR-0003-bounded-logo-slot.html +10 -6
- package/dist/docs/decisions/DDR-0004-slides-rendering-model.html +10 -6
- package/dist/docs/decisions/DDR-0005-deterministic-layout-boundary.html +10 -6
- package/dist/docs/userguide/cli/build.html +10 -6
- package/dist/docs/userguide/cli/bundle.html +10 -6
- package/dist/docs/userguide/cli/dev.html +10 -6
- package/dist/docs/userguide/cli/init.html +10 -6
- package/dist/docs/userguide/cli/servers.html +10 -6
- package/dist/docs/userguide/cli/stop.html +10 -6
- package/dist/docs/userguide/cli/update.html +10 -6
- package/dist/docs/userguide/cli/version.html +10 -6
- package/dist/docs/userguide/cli.html +10 -6
- package/dist/docs/userguide/sharing.html +10 -6
- package/dist/index.html +10 -6
- package/dist/llms.txt +3 -3
- package/dist/provenance.json +4 -4
- package/dist/schemas/plugin-registry.schema.html +10 -6
- package/dist/schemas/plugin-schemas-notes.html +10 -6
- package/dist/schemas/plugin.schema.html +10 -6
- package/dist/schemas/plugins.schema.html +10 -6
- package/dist/schemas/v0/common.schema.html +14 -10
- package/dist/schemas/v0/plugin-registry.schema.html +13 -9
- package/dist/schemas/v0/plugin.schema.html +13 -9
- package/dist/schemas/v0/plugins.schema.html +13 -9
- package/dist/schemas/v0/site.schema.html +67 -7
- package/dist/schemas/v0/theme.schema.html +21 -17
- package/dist/schemas.html +10 -6
- package/dist/styles.css +39 -4
- package/package.json +1 -1
- package/plugins-dist/latex-runtime.js +140 -0
- package/plugins-dist/latex.js +178 -0
- package/plugins-dist/slides-charts-lite-runtime.js +179 -0
- package/plugins-dist/slides-charts-lite.js +198 -0
- package/registry/plugins.yaml +25 -0
- package/schemas/v0/site.schema.json +56 -0
- package/scripts/build.ts +191 -69
- package/scripts/bundle.ts +118 -10
- package/scripts/dev.ts +245 -166
- package/src/__tests__/brief.test.ts +151 -0
- package/src/__tests__/build.test.ts +169 -1
- package/src/__tests__/bundle.test.ts +134 -0
- package/src/__tests__/init.test.ts +51 -2
- package/src/__tests__/latex-runtime.bun.test.ts +35 -0
- package/src/__tests__/shared.test.ts +598 -1
- package/src/__tests__/slides-charts-lite-runtime.bun.test.ts +45 -0
- package/src/cli.ts +11 -4
- package/src/commands/init.ts +1 -1
- package/src/shared.ts +725 -18
- package/src/site/styles.css +39 -4
- package/src/site/template.html +5 -2
- package/src/templates/brief.ts +486 -0
- package/src/templates/deck.ts +59 -0
- package/src/templates/driver.ts +46 -13
- package/src/templates/handbook.ts +32 -0
- package/src/templates/runbook.ts +32 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Basic tests for shared utilities
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
7
|
import { join, resolve } from "node:path";
|
|
8
8
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
buildNavStatic,
|
|
16
16
|
buildPageMeta,
|
|
17
17
|
buildSlideNav,
|
|
18
|
+
buildSlideNavHierarchical,
|
|
18
19
|
buildToc,
|
|
19
20
|
type ContentFile,
|
|
20
21
|
collectFiles,
|
|
@@ -24,16 +25,22 @@ import {
|
|
|
24
25
|
envString,
|
|
25
26
|
escapeHtml,
|
|
26
27
|
exists,
|
|
28
|
+
filterByProfile,
|
|
27
29
|
filterUnknownSlidesVisualsTypeDiagnostics,
|
|
28
30
|
formatDate,
|
|
29
31
|
generateProvenance,
|
|
30
32
|
getGitInfo,
|
|
31
33
|
KITFLY_BRAND,
|
|
34
|
+
loadDataBindings,
|
|
32
35
|
loadSiteConfig,
|
|
36
|
+
mergeFrontmatterWithBody,
|
|
37
|
+
normalizeProfileTags,
|
|
33
38
|
type Provenance,
|
|
39
|
+
pagePathForData,
|
|
34
40
|
parseFrontmatter,
|
|
35
41
|
parseValue,
|
|
36
42
|
parseYaml,
|
|
43
|
+
resolveBindings,
|
|
37
44
|
resolveSiteVersion,
|
|
38
45
|
rewriteRelativeAssetUrls,
|
|
39
46
|
type SiteConfig,
|
|
@@ -104,6 +111,271 @@ description: A test page
|
|
|
104
111
|
});
|
|
105
112
|
});
|
|
106
113
|
|
|
114
|
+
describe("normalizeProfileTags", () => {
|
|
115
|
+
it("parses scalar and array-like profile tags", () => {
|
|
116
|
+
expect(normalizeProfileTags("alpha")).toEqual(["alpha"]);
|
|
117
|
+
expect(normalizeProfileTags("[alpha, beta]")).toEqual(["alpha", "beta"]);
|
|
118
|
+
expect(normalizeProfileTags(["alpha", "beta"])).toEqual(["alpha", "beta"]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("normalizes whitespace, quotes, and case", () => {
|
|
122
|
+
expect(normalizeProfileTags("[\"Alpha\", 'beta']")).toEqual(["alpha", "beta"]);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("filterByProfile", () => {
|
|
127
|
+
it("keeps all files when profiles are not configured", async () => {
|
|
128
|
+
const root = await mkdtemp(join(tmpdir(), "kitfly-profile-"));
|
|
129
|
+
try {
|
|
130
|
+
const alwaysPath = join(root, "always.md");
|
|
131
|
+
const alphaPath = join(root, "alpha.md");
|
|
132
|
+
const bothPath = join(root, "both.md");
|
|
133
|
+
const betaPath = join(root, "beta.md");
|
|
134
|
+
await writeFile(alwaysPath, "# Always");
|
|
135
|
+
await writeFile(alphaPath, "---\nprofile: alpha\n---\n# Alpha");
|
|
136
|
+
await writeFile(bothPath, "---\nprofile: [alpha, beta]\n---\n# Both");
|
|
137
|
+
await writeFile(betaPath, "---\nprofile: beta\n---\n# Beta");
|
|
138
|
+
|
|
139
|
+
const files: ContentFile[] = [
|
|
140
|
+
{ path: alwaysPath, urlPath: "always", section: "Docs" },
|
|
141
|
+
{ path: alphaPath, urlPath: "alpha", section: "Docs" },
|
|
142
|
+
{ path: bothPath, urlPath: "both", section: "Docs" },
|
|
143
|
+
{ path: betaPath, urlPath: "beta", section: "Docs" },
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
const noProfile = await filterByProfile(files);
|
|
147
|
+
expect(noProfile.map((f) => f.urlPath)).toEqual(["always", "alpha", "both", "beta"]);
|
|
148
|
+
} finally {
|
|
149
|
+
await rm(root, { recursive: true, force: true });
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("filters tagged files when profiles are configured", async () => {
|
|
154
|
+
const root = await mkdtemp(join(tmpdir(), "kitfly-profile-"));
|
|
155
|
+
try {
|
|
156
|
+
const alwaysPath = join(root, "always.md");
|
|
157
|
+
const alphaPath = join(root, "alpha.md");
|
|
158
|
+
const bothPath = join(root, "both.md");
|
|
159
|
+
const betaPath = join(root, "beta.md");
|
|
160
|
+
await writeFile(alwaysPath, "# Always");
|
|
161
|
+
await writeFile(alphaPath, "---\nprofile: alpha\n---\n# Alpha");
|
|
162
|
+
await writeFile(bothPath, "---\nprofile: [alpha, beta]\n---\n# Both");
|
|
163
|
+
await writeFile(betaPath, "---\nprofile: beta\n---\n# Beta");
|
|
164
|
+
|
|
165
|
+
const files: ContentFile[] = [
|
|
166
|
+
{ path: alwaysPath, urlPath: "always", section: "Docs" },
|
|
167
|
+
{ path: alphaPath, urlPath: "alpha", section: "Docs" },
|
|
168
|
+
{ path: bothPath, urlPath: "both", section: "Docs" },
|
|
169
|
+
{ path: betaPath, urlPath: "beta", section: "Docs" },
|
|
170
|
+
];
|
|
171
|
+
const profiles = {
|
|
172
|
+
alpha: { include: { tags: ["alpha"] } },
|
|
173
|
+
beta: { include: { tags: ["beta"] } },
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const noProfile = await filterByProfile(files, undefined, profiles);
|
|
177
|
+
expect(noProfile.map((f) => f.urlPath)).toEqual(["always"]);
|
|
178
|
+
|
|
179
|
+
const alphaProfile = await filterByProfile(files, "alpha", profiles);
|
|
180
|
+
expect(alphaProfile.map((f) => f.urlPath)).toEqual(["always", "alpha", "both"]);
|
|
181
|
+
|
|
182
|
+
const betaProfile = await filterByProfile(files, "beta", profiles);
|
|
183
|
+
expect(betaProfile.map((f) => f.urlPath)).toEqual(["always", "both", "beta"]);
|
|
184
|
+
} finally {
|
|
185
|
+
await rm(root, { recursive: true, force: true });
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe("data bindings", () => {
|
|
191
|
+
it("resolves values and snippets with formatter chains", () => {
|
|
192
|
+
const bound = resolveBindings(
|
|
193
|
+
[
|
|
194
|
+
"Rate: {{ baseline | dollar }}",
|
|
195
|
+
"Pct: {{ ratio | percent }}",
|
|
196
|
+
"Rounded: {{ pi | round(2) }}",
|
|
197
|
+
"Upper: {{ label | upper }}",
|
|
198
|
+
"Chain: {{ baseline | round(0) | dollar }}",
|
|
199
|
+
"{{ snippet:table }}",
|
|
200
|
+
].join("\n"),
|
|
201
|
+
{
|
|
202
|
+
globals: { baseline: "1500", ratio: "0.125", pi: "3.14159" },
|
|
203
|
+
inject: { label: "pricing" },
|
|
204
|
+
snippets: [{ slot: "table", content: "| A | B |\n|---|---|" }],
|
|
205
|
+
},
|
|
206
|
+
"product/pricing.md",
|
|
207
|
+
);
|
|
208
|
+
expect(bound).toContain("Rate: $1,500");
|
|
209
|
+
expect(bound).toContain("Pct: 12.5%");
|
|
210
|
+
expect(bound).toContain("Rounded: 3.14");
|
|
211
|
+
expect(bound).toContain("Upper: PRICING");
|
|
212
|
+
expect(bound).toContain("Chain: $1,500");
|
|
213
|
+
expect(bound).toContain("| A | B |");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("throws on unresolved keys, unknown snippets, and unknown formatters", () => {
|
|
217
|
+
expect(() =>
|
|
218
|
+
resolveBindings("{{ missing }}", { globals: {}, inject: {}, snippets: [] }, "x.md"),
|
|
219
|
+
).toThrow(/unresolved binding/);
|
|
220
|
+
expect(() =>
|
|
221
|
+
resolveBindings("{{ snippet:nope }}", { globals: {}, inject: {}, snippets: [] }, "x.md"),
|
|
222
|
+
).toThrow(/unknown snippet/);
|
|
223
|
+
expect(() =>
|
|
224
|
+
resolveBindings(
|
|
225
|
+
"{{ n | custom }}",
|
|
226
|
+
{ globals: { n: "1" }, inject: {}, snippets: [] },
|
|
227
|
+
"x.md",
|
|
228
|
+
),
|
|
229
|
+
).toThrow(/unknown formatter/);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe("loadDataBindings", () => {
|
|
234
|
+
it("loads page-level inject/snippets and validates optional schema", async () => {
|
|
235
|
+
const root = await mkdtemp(join(tmpdir(), "kitfly-data-"));
|
|
236
|
+
try {
|
|
237
|
+
await mkdir(join(root, "content", "product"), { recursive: true });
|
|
238
|
+
await mkdir(join(root, "data"), { recursive: true });
|
|
239
|
+
await writeFile(
|
|
240
|
+
join(root, "data", "pricing.yaml"),
|
|
241
|
+
[
|
|
242
|
+
"globals:",
|
|
243
|
+
' baseline: "200"',
|
|
244
|
+
"pages:",
|
|
245
|
+
" - path: product/pricing.md",
|
|
246
|
+
" inject:",
|
|
247
|
+
' hero: "Implementation costs"',
|
|
248
|
+
" snippets:",
|
|
249
|
+
" - slot: pricing-table",
|
|
250
|
+
" content: |",
|
|
251
|
+
" | Tier | Price |",
|
|
252
|
+
].join("\n"),
|
|
253
|
+
);
|
|
254
|
+
await writeFile(
|
|
255
|
+
join(root, "data", "pricing.schema.json"),
|
|
256
|
+
JSON.stringify(
|
|
257
|
+
{
|
|
258
|
+
type: "object",
|
|
259
|
+
required: ["globals", "pages"],
|
|
260
|
+
properties: {
|
|
261
|
+
globals: {
|
|
262
|
+
type: "object",
|
|
263
|
+
required: ["baseline"],
|
|
264
|
+
properties: {
|
|
265
|
+
baseline: { type: "string", pattern: "^[0-9]+$" },
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
pages: { type: "array" },
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
null,
|
|
272
|
+
2,
|
|
273
|
+
),
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
const pagePath = pagePathForData(
|
|
277
|
+
root,
|
|
278
|
+
"content",
|
|
279
|
+
join(root, "content", "product", "pricing.md"),
|
|
280
|
+
);
|
|
281
|
+
const bindings = await loadDataBindings(
|
|
282
|
+
"data/pricing.yaml",
|
|
283
|
+
pagePath,
|
|
284
|
+
root,
|
|
285
|
+
"content",
|
|
286
|
+
"data",
|
|
287
|
+
);
|
|
288
|
+
expect(bindings.globals.baseline).toBe("200");
|
|
289
|
+
expect(bindings.inject.hero).toBe("Implementation costs");
|
|
290
|
+
expect(bindings.snippets[0].slot).toBe("pricing-table");
|
|
291
|
+
expect(bindings.snippets[0].content).toContain("| Tier | Price |");
|
|
292
|
+
} finally {
|
|
293
|
+
await rm(root, { recursive: true, force: true });
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("rejects data paths that escape dataroot", async () => {
|
|
298
|
+
const root = await mkdtemp(join(tmpdir(), "kitfly-data-"));
|
|
299
|
+
try {
|
|
300
|
+
await mkdir(join(root, "data"), { recursive: true });
|
|
301
|
+
await writeFile(join(root, "outside.yaml"), 'globals:\n x: "1"\n');
|
|
302
|
+
await expect(loadDataBindings("../outside.yaml", "a.md", root, ".", "data")).rejects.toThrow(
|
|
303
|
+
/data path escapes/,
|
|
304
|
+
);
|
|
305
|
+
} finally {
|
|
306
|
+
await rm(root, { recursive: true, force: true });
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("rejects symlinked dataroot that escapes site root", async () => {
|
|
311
|
+
const root = await mkdtemp(join(tmpdir(), "kitfly-data-"));
|
|
312
|
+
const outside = await mkdtemp(join(tmpdir(), "kitfly-outside-"));
|
|
313
|
+
try {
|
|
314
|
+
await mkdir(join(root, "content"), { recursive: true });
|
|
315
|
+
await writeFile(join(outside, "pricing.yaml"), 'globals:\n x: "1"\n');
|
|
316
|
+
await symlink(outside, join(root, "data"));
|
|
317
|
+
await expect(
|
|
318
|
+
loadDataBindings("data/pricing.yaml", "pricing.md", root, "content", "data"),
|
|
319
|
+
).rejects.toThrow(/data path escapes kitsite/);
|
|
320
|
+
} finally {
|
|
321
|
+
await rm(root, { recursive: true, force: true });
|
|
322
|
+
await rm(outside, { recursive: true, force: true });
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("loads multiline YAML snippet blocks for binding resolution", async () => {
|
|
327
|
+
const root = await mkdtemp(join(tmpdir(), "kitfly-data-"));
|
|
328
|
+
try {
|
|
329
|
+
await mkdir(join(root, "content", "product"), { recursive: true });
|
|
330
|
+
await mkdir(join(root, "data"), { recursive: true });
|
|
331
|
+
await writeFile(
|
|
332
|
+
join(root, "data", "pricing.yaml"),
|
|
333
|
+
[
|
|
334
|
+
"pages:",
|
|
335
|
+
" - path: product/pricing.md",
|
|
336
|
+
" snippets:",
|
|
337
|
+
" - slot: pricing-table",
|
|
338
|
+
" content: |",
|
|
339
|
+
" | Tier | Price |",
|
|
340
|
+
" |------|-------|",
|
|
341
|
+
" | Starter | $200 |",
|
|
342
|
+
].join("\n"),
|
|
343
|
+
);
|
|
344
|
+
const bindings = await loadDataBindings(
|
|
345
|
+
"data/pricing.yaml",
|
|
346
|
+
"product/pricing.md",
|
|
347
|
+
root,
|
|
348
|
+
"content",
|
|
349
|
+
"data",
|
|
350
|
+
);
|
|
351
|
+
const rendered = resolveBindings(
|
|
352
|
+
"Table:\n{{ snippet:pricing-table }}",
|
|
353
|
+
bindings,
|
|
354
|
+
"product/pricing.md",
|
|
355
|
+
);
|
|
356
|
+
expect(rendered).toContain("| Tier | Price |");
|
|
357
|
+
expect(rendered).toContain("| Starter | $200 |");
|
|
358
|
+
} finally {
|
|
359
|
+
await rm(root, { recursive: true, force: true });
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
describe("mergeFrontmatterWithBody", () => {
|
|
365
|
+
it("preserves original frontmatter text while replacing body", () => {
|
|
366
|
+
const original = `---
|
|
367
|
+
title: "A # B"
|
|
368
|
+
tags: [a, b]
|
|
369
|
+
---
|
|
370
|
+
|
|
371
|
+
# Old`;
|
|
372
|
+
const merged = mergeFrontmatterWithBody(original, "# New");
|
|
373
|
+
expect(merged).toContain('title: "A # B"');
|
|
374
|
+
expect(merged).toContain("tags: [a, b]");
|
|
375
|
+
expect(merged.endsWith("# New")).toBe(true);
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
|
|
107
379
|
describe("splitSlides", () => {
|
|
108
380
|
it("splits markdown on explicit slide delimiter", () => {
|
|
109
381
|
const input = `# One
|
|
@@ -348,6 +620,140 @@ title: Intro
|
|
|
348
620
|
expect(nav).toContain('<a href="#slide-2" class="active">Slide B</a>');
|
|
349
621
|
expect(nav).toContain('<span class="nav-section">Slides</span>');
|
|
350
622
|
});
|
|
623
|
+
|
|
624
|
+
it("builds hierarchical slide nav for nested source paths", () => {
|
|
625
|
+
const nav = buildSlideNavHierarchical(
|
|
626
|
+
[
|
|
627
|
+
{
|
|
628
|
+
index: 0,
|
|
629
|
+
frontmatter: {},
|
|
630
|
+
body: "# Overview",
|
|
631
|
+
title: "Overview",
|
|
632
|
+
id: "slide-1",
|
|
633
|
+
section: "Data Integration",
|
|
634
|
+
sourcePath: "/tmp/di/slides.md",
|
|
635
|
+
sourceUrlPath: "data-integration/slides",
|
|
636
|
+
kind: "markdown",
|
|
637
|
+
},
|
|
638
|
+
{
|
|
639
|
+
index: 1,
|
|
640
|
+
frontmatter: {},
|
|
641
|
+
body: "# POS Data",
|
|
642
|
+
title: "POS Data",
|
|
643
|
+
id: "slide-2",
|
|
644
|
+
section: "Data Integration",
|
|
645
|
+
sourcePath: "/tmp/di/sources/slides.md",
|
|
646
|
+
sourceUrlPath: "data-integration/sources/slides",
|
|
647
|
+
kind: "markdown",
|
|
648
|
+
},
|
|
649
|
+
{
|
|
650
|
+
index: 2,
|
|
651
|
+
frontmatter: {},
|
|
652
|
+
body: "# ETL Pipeline",
|
|
653
|
+
title: "ETL Pipeline",
|
|
654
|
+
id: "slide-3",
|
|
655
|
+
section: "Data Integration",
|
|
656
|
+
sourcePath: "/tmp/di/transforms/slides.md",
|
|
657
|
+
sourceUrlPath: "data-integration/transforms/slides",
|
|
658
|
+
kind: "markdown",
|
|
659
|
+
},
|
|
660
|
+
],
|
|
661
|
+
{
|
|
662
|
+
docroot: ".",
|
|
663
|
+
title: "Deck",
|
|
664
|
+
brand: { name: "Test", url: "/" },
|
|
665
|
+
sections: [{ name: "Data Integration", path: "data-integration" }],
|
|
666
|
+
},
|
|
667
|
+
"slide-2",
|
|
668
|
+
);
|
|
669
|
+
|
|
670
|
+
expect(nav).toContain('<summary class="nav-group">Sources</summary>');
|
|
671
|
+
expect(nav).toContain('<summary class="nav-group">Transforms</summary>');
|
|
672
|
+
expect(nav).toContain('<a href="#slide-2" class="active">POS Data</a>');
|
|
673
|
+
expect(nav).toContain('<a href="#slide-3">ETL Pipeline</a>');
|
|
674
|
+
expect(nav).toContain("<details open>");
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
it("keeps flat nav output when slides are not nested", () => {
|
|
678
|
+
const nav = buildSlideNavHierarchical(
|
|
679
|
+
[
|
|
680
|
+
{
|
|
681
|
+
index: 0,
|
|
682
|
+
frontmatter: {},
|
|
683
|
+
body: "# Intro",
|
|
684
|
+
title: "Intro",
|
|
685
|
+
id: "slide-1",
|
|
686
|
+
section: "Slides",
|
|
687
|
+
sourcePath: "/tmp/slides/a.md",
|
|
688
|
+
sourceUrlPath: "slides/a",
|
|
689
|
+
kind: "markdown",
|
|
690
|
+
},
|
|
691
|
+
{
|
|
692
|
+
index: 1,
|
|
693
|
+
frontmatter: {},
|
|
694
|
+
body: "# Next",
|
|
695
|
+
title: "Next",
|
|
696
|
+
id: "slide-2",
|
|
697
|
+
section: "Slides",
|
|
698
|
+
sourcePath: "/tmp/slides/b.md",
|
|
699
|
+
sourceUrlPath: "slides/b",
|
|
700
|
+
kind: "markdown",
|
|
701
|
+
},
|
|
702
|
+
],
|
|
703
|
+
{
|
|
704
|
+
docroot: ".",
|
|
705
|
+
title: "Deck",
|
|
706
|
+
brand: { name: "Test", url: "/" },
|
|
707
|
+
sections: [{ name: "Slides", path: "slides" }],
|
|
708
|
+
},
|
|
709
|
+
"slide-1",
|
|
710
|
+
);
|
|
711
|
+
|
|
712
|
+
expect(nav).toContain('<a href="#slide-1" class="active">Intro</a>');
|
|
713
|
+
expect(nav).toContain('<a href="#slide-2">Next</a>');
|
|
714
|
+
expect(nav).not.toContain('class="nav-group"');
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
it("keeps flat nav output when section path has trailing slash", () => {
|
|
718
|
+
const nav = buildSlideNavHierarchical(
|
|
719
|
+
[
|
|
720
|
+
{
|
|
721
|
+
index: 0,
|
|
722
|
+
frontmatter: {},
|
|
723
|
+
body: "# Intro",
|
|
724
|
+
title: "Intro",
|
|
725
|
+
id: "slide-1",
|
|
726
|
+
section: "Slides",
|
|
727
|
+
sourcePath: "/tmp/slides/a.md",
|
|
728
|
+
sourceUrlPath: "slides/a",
|
|
729
|
+
kind: "markdown",
|
|
730
|
+
},
|
|
731
|
+
{
|
|
732
|
+
index: 1,
|
|
733
|
+
frontmatter: {},
|
|
734
|
+
body: "# Next",
|
|
735
|
+
title: "Next",
|
|
736
|
+
id: "slide-2",
|
|
737
|
+
section: "Slides",
|
|
738
|
+
sourcePath: "/tmp/slides/b.md",
|
|
739
|
+
sourceUrlPath: "slides/b",
|
|
740
|
+
kind: "markdown",
|
|
741
|
+
},
|
|
742
|
+
],
|
|
743
|
+
{
|
|
744
|
+
docroot: ".",
|
|
745
|
+
title: "Deck",
|
|
746
|
+
brand: { name: "Test", url: "/" },
|
|
747
|
+
sections: [{ name: "Slides", path: "slides/" }],
|
|
748
|
+
},
|
|
749
|
+
"slide-1",
|
|
750
|
+
);
|
|
751
|
+
|
|
752
|
+
expect(nav).toContain('<a href="#slide-1" class="active">Intro</a>');
|
|
753
|
+
expect(nav).toContain('<a href="#slide-2">Next</a>');
|
|
754
|
+
expect(nav).not.toContain('class="nav-group"');
|
|
755
|
+
expect(nav).not.toContain("<summary");
|
|
756
|
+
});
|
|
351
757
|
});
|
|
352
758
|
|
|
353
759
|
describe("rewriteRelativeAssetUrls", () => {
|
|
@@ -532,6 +938,78 @@ description: 'A test site'`;
|
|
|
532
938
|
expect(result.tags).toEqual(["javascript", "typescript"]);
|
|
533
939
|
});
|
|
534
940
|
|
|
941
|
+
it("parses literal block scalars", () => {
|
|
942
|
+
const yaml = `snippets:
|
|
943
|
+
- slot: pricing-table
|
|
944
|
+
content: |
|
|
945
|
+
| Tier | Price |
|
|
946
|
+
|------|-------|
|
|
947
|
+
| Starter | $200 |`;
|
|
948
|
+
const result = parseYaml(yaml);
|
|
949
|
+
const snippets = result.snippets as Array<Record<string, unknown>>;
|
|
950
|
+
expect(snippets[0].content).toBe("| Tier | Price |\n|------|-------|\n| Starter | $200 |");
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
it("parses folded block scalars", () => {
|
|
954
|
+
const yaml = `globals:
|
|
955
|
+
summary: >
|
|
956
|
+
Line one
|
|
957
|
+
line two
|
|
958
|
+
|
|
959
|
+
Paragraph two`;
|
|
960
|
+
const result = parseYaml(yaml);
|
|
961
|
+
const globals = result.globals as Record<string, unknown>;
|
|
962
|
+
expect(globals.summary).toBe("Line one line two\nParagraph two");
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
it("supports block scalar chomping indicators", () => {
|
|
966
|
+
const yaml = `globals:
|
|
967
|
+
keep: |+
|
|
968
|
+
One
|
|
969
|
+
Two
|
|
970
|
+
strip: >-
|
|
971
|
+
A
|
|
972
|
+
B`;
|
|
973
|
+
const result = parseYaml(yaml);
|
|
974
|
+
const globals = result.globals as Record<string, unknown>;
|
|
975
|
+
expect(globals.keep).toBe("One\nTwo\n");
|
|
976
|
+
expect(globals.strip).toBe("A B");
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
it("parses direct array block scalar items", () => {
|
|
980
|
+
const yaml = `snippets:
|
|
981
|
+
- |
|
|
982
|
+
line 1
|
|
983
|
+
line 2
|
|
984
|
+
- >-
|
|
985
|
+
folded
|
|
986
|
+
line`;
|
|
987
|
+
const result = parseYaml(yaml);
|
|
988
|
+
expect(result.snippets).toEqual(["line 1\nline 2", "folded line"]);
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
it("accepts block scalar indentation indicators", () => {
|
|
992
|
+
const yaml = `globals:
|
|
993
|
+
note: |2-
|
|
994
|
+
indented
|
|
995
|
+
block`;
|
|
996
|
+
const result = parseYaml(yaml);
|
|
997
|
+
const globals = result.globals as Record<string, unknown>;
|
|
998
|
+
expect(globals.note).toBe("indented\nblock");
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
it("treats invalid block scalar headers as plain strings", () => {
|
|
1002
|
+
const yaml = `globals:
|
|
1003
|
+
bad: |abc
|
|
1004
|
+
snippets:
|
|
1005
|
+
- >foo`;
|
|
1006
|
+
const result = parseYaml(yaml);
|
|
1007
|
+
const globals = result.globals as Record<string, unknown>;
|
|
1008
|
+
const snippets = result.snippets as unknown[];
|
|
1009
|
+
expect(globals.bad).toBe("|abc");
|
|
1010
|
+
expect(snippets[0]).toBe(">foo");
|
|
1011
|
+
});
|
|
1012
|
+
|
|
535
1013
|
it("returns empty object for empty input", () => {
|
|
536
1014
|
const result = parseYaml("");
|
|
537
1015
|
expect(result).toEqual({});
|
|
@@ -1122,12 +1600,18 @@ version: "1.2.0"
|
|
|
1122
1600
|
brand:
|
|
1123
1601
|
name: Test
|
|
1124
1602
|
url: /
|
|
1603
|
+
logoDark: assets/brand/logo-dark.png
|
|
1125
1604
|
sections:
|
|
1126
1605
|
- name: Guide
|
|
1127
1606
|
path: guide
|
|
1128
1607
|
footer:
|
|
1129
1608
|
copyright: "© 2026 Test"
|
|
1130
1609
|
attribution: false
|
|
1610
|
+
logo: "assets/brand/footer-logo.png"
|
|
1611
|
+
logoDark: "assets/brand/footer-logo-dark.png"
|
|
1612
|
+
logoUrl: "https://example.com/footer"
|
|
1613
|
+
logoAlt: "Footer Brand"
|
|
1614
|
+
logoHeight: 24
|
|
1131
1615
|
links:
|
|
1132
1616
|
- text: Privacy
|
|
1133
1617
|
url: /privacy
|
|
@@ -1137,14 +1621,45 @@ footer:
|
|
|
1137
1621
|
|
|
1138
1622
|
const config = await loadSiteConfig(dir);
|
|
1139
1623
|
expect(config.version).toBe("1.2.0");
|
|
1624
|
+
expect(config.brand.logoDark).toBe("assets/brand/logo-dark.png");
|
|
1140
1625
|
expect(config.footer?.copyright).toBe("© 2026 Test");
|
|
1141
1626
|
expect(config.footer?.attribution).toBe(false);
|
|
1627
|
+
expect(config.footer?.logo).toBe("assets/brand/footer-logo.png");
|
|
1628
|
+
expect(config.footer?.logoDark).toBe("assets/brand/footer-logo-dark.png");
|
|
1629
|
+
expect(config.footer?.logoUrl).toBe("https://example.com/footer");
|
|
1630
|
+
expect(config.footer?.logoAlt).toBe("Footer Brand");
|
|
1631
|
+
expect(config.footer?.logoHeight).toBe(24);
|
|
1142
1632
|
expect(config.footer?.links).toEqual([{ text: "Privacy", url: "/privacy" }]);
|
|
1143
1633
|
} finally {
|
|
1144
1634
|
await rm(dir, { recursive: true, force: true });
|
|
1145
1635
|
}
|
|
1146
1636
|
});
|
|
1147
1637
|
|
|
1638
|
+
it("clamps footer.logoHeight to supported range", async () => {
|
|
1639
|
+
const dir = await mkdtemp(join(tmpdir(), "kitfly-footer-logo-height-"));
|
|
1640
|
+
try {
|
|
1641
|
+
await writeFile(
|
|
1642
|
+
join(dir, "site.yaml"),
|
|
1643
|
+
`title: Test
|
|
1644
|
+
brand:
|
|
1645
|
+
name: Test
|
|
1646
|
+
url: /
|
|
1647
|
+
sections:
|
|
1648
|
+
- name: Guide
|
|
1649
|
+
path: guide
|
|
1650
|
+
footer:
|
|
1651
|
+
logoHeight: 100
|
|
1652
|
+
`,
|
|
1653
|
+
"utf-8",
|
|
1654
|
+
);
|
|
1655
|
+
|
|
1656
|
+
const config = await loadSiteConfig(dir);
|
|
1657
|
+
expect(config.footer?.logoHeight).toBe(40);
|
|
1658
|
+
} finally {
|
|
1659
|
+
await rm(dir, { recursive: true, force: true });
|
|
1660
|
+
}
|
|
1661
|
+
});
|
|
1662
|
+
|
|
1148
1663
|
it("truncates footer links to max 10", async () => {
|
|
1149
1664
|
const dir = await mkdtemp(join(tmpdir(), "kitfly-footer-links-"));
|
|
1150
1665
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
@@ -1685,6 +2200,70 @@ describe("buildFooter", () => {
|
|
|
1685
2200
|
expect(result).toContain('class="footer-link"');
|
|
1686
2201
|
});
|
|
1687
2202
|
|
|
2203
|
+
it("renders footer logo before version with configurable metadata", () => {
|
|
2204
|
+
const result = buildFooter(
|
|
2205
|
+
baseProvenance,
|
|
2206
|
+
{
|
|
2207
|
+
...baseConfig,
|
|
2208
|
+
footer: {
|
|
2209
|
+
logo: "assets/brand/footer-logo.png",
|
|
2210
|
+
logoUrl: "https://footer.example.com",
|
|
2211
|
+
logoAlt: "Footer Brand",
|
|
2212
|
+
logoHeight: 22,
|
|
2213
|
+
},
|
|
2214
|
+
},
|
|
2215
|
+
"../",
|
|
2216
|
+
);
|
|
2217
|
+
expect(result).toContain('class="footer-logo-link"');
|
|
2218
|
+
expect(result).toContain('class="footer-logo-img"');
|
|
2219
|
+
expect(result).toContain('href="https://footer.example.com"');
|
|
2220
|
+
expect(result).toContain('src="../assets/brand/footer-logo.png"');
|
|
2221
|
+
expect(result).toContain('alt="Footer Brand"');
|
|
2222
|
+
expect(result).toContain("max-height: 22px");
|
|
2223
|
+
expect(result).toContain("onerror=\"this.onerror=null;this.style.display='none'\"");
|
|
2224
|
+
expect(result.indexOf('class="footer-logo-img"')).toBeLessThan(
|
|
2225
|
+
result.indexOf('class="footer-version"'),
|
|
2226
|
+
);
|
|
2227
|
+
});
|
|
2228
|
+
|
|
2229
|
+
it("renders footer light/dark logo variants when logoDark is set", () => {
|
|
2230
|
+
const result = buildFooter(baseProvenance, {
|
|
2231
|
+
...baseConfig,
|
|
2232
|
+
footer: {
|
|
2233
|
+
logo: "assets/brand/footer-logo.png",
|
|
2234
|
+
logoDark: "assets/brand/footer-logo-dark.png",
|
|
2235
|
+
},
|
|
2236
|
+
});
|
|
2237
|
+
expect(result).toContain("footer-logo-img logo-light");
|
|
2238
|
+
expect(result).toContain("footer-logo-img logo-dark");
|
|
2239
|
+
expect(result).toContain('src="assets/brand/footer-logo-dark.png"');
|
|
2240
|
+
});
|
|
2241
|
+
|
|
2242
|
+
it("falls back footer logo alt text to copyright then brand name", () => {
|
|
2243
|
+
const withCopyrightAlt = buildFooter(
|
|
2244
|
+
baseProvenance,
|
|
2245
|
+
{
|
|
2246
|
+
...baseConfig,
|
|
2247
|
+
footer: {
|
|
2248
|
+
logo: "assets/brand/footer-logo.png",
|
|
2249
|
+
copyright: "Copyright 2026 Acme",
|
|
2250
|
+
},
|
|
2251
|
+
},
|
|
2252
|
+
"./",
|
|
2253
|
+
);
|
|
2254
|
+
expect(withCopyrightAlt).toContain('alt="Copyright 2026 Acme"');
|
|
2255
|
+
|
|
2256
|
+
const withBrandAlt = buildFooter(
|
|
2257
|
+
baseProvenance,
|
|
2258
|
+
{
|
|
2259
|
+
...baseConfig,
|
|
2260
|
+
footer: { logo: "assets/brand/footer-logo.png" },
|
|
2261
|
+
},
|
|
2262
|
+
"./",
|
|
2263
|
+
);
|
|
2264
|
+
expect(withBrandAlt).toContain('alt="Acme Corp"');
|
|
2265
|
+
});
|
|
2266
|
+
|
|
1688
2267
|
it("uses publish date year in default copyright", () => {
|
|
1689
2268
|
const result = buildFooter(baseProvenance, baseConfig);
|
|
1690
2269
|
expect(result).toContain("© 2024 Acme Corp");
|
|
@@ -1751,6 +2330,24 @@ describe("buildBundleFooter", () => {
|
|
|
1751
2330
|
expect(result).toContain("<em>");
|
|
1752
2331
|
expect(result).not.toContain("<em>");
|
|
1753
2332
|
});
|
|
2333
|
+
|
|
2334
|
+
it("renders footer logo in bundle mode with override source", () => {
|
|
2335
|
+
const result = buildBundleFooter(
|
|
2336
|
+
"0.1.1",
|
|
2337
|
+
{
|
|
2338
|
+
...baseConfig,
|
|
2339
|
+
footer: {
|
|
2340
|
+
logo: "assets/brand/footer-logo.png",
|
|
2341
|
+
logoUrl: "https://footer.example.com",
|
|
2342
|
+
logoHeight: 18,
|
|
2343
|
+
},
|
|
2344
|
+
},
|
|
2345
|
+
"data:image/png;base64,AAAA",
|
|
2346
|
+
);
|
|
2347
|
+
expect(result).toContain('src="data:image/png;base64,AAAA"');
|
|
2348
|
+
expect(result).toContain('href="https://footer.example.com"');
|
|
2349
|
+
expect(result).toContain("max-height: 18px");
|
|
2350
|
+
});
|
|
1754
2351
|
});
|
|
1755
2352
|
|
|
1756
2353
|
// ---------------------------------------------------------------------------
|