kitfly 0.2.1 → 0.2.4
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 +79 -0
- package/README.md +38 -21
- 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/gantt-widget.md +468 -0
- 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 +170 -1
- package/dist/_raw/docs/decisions/ADR-0006-data-driven-content.md +350 -0
- package/dist/content/deployment/preflight.html +11 -8
- package/dist/content/deployment/recipes/aws-s3.html +11 -8
- package/dist/content/deployment/recipes/cloudflare-pages.html +11 -8
- package/dist/content/deployment/recipes/cloudflare-r2.html +11 -8
- package/dist/content/deployment/recipes/fly-io.html +11 -8
- package/dist/content/deployment/recipes/github-pages.html +11 -8
- package/dist/content/deployment/recipes/netlify.html +11 -8
- package/dist/content/deployment/recipes/vercel.html +11 -8
- package/dist/content/deployment/secrets-and-env-vars.html +11 -8
- package/dist/content/deployment.html +11 -8
- package/dist/content/guide/approaches.html +11 -8
- package/dist/content/guide/branding.html +509 -0
- package/dist/content/guide/data-driven-content.html +542 -0
- package/dist/content/guide/features.html +11 -8
- package/dist/content/guide/getting-started.html +11 -8
- package/dist/content/guide/kitfly-overview.html +11 -8
- package/dist/content/reference/configuration.html +136 -11
- package/dist/content/reference/design-catalog.html +11 -8
- package/dist/content/reference/environment-variables.html +51 -10
- package/dist/content/reference/gantt-widget.html +899 -0
- package/dist/content/reference/glossary.html +25 -10
- package/dist/content/reference/key-concepts.html +34 -11
- package/dist/content/reference/plugins.html +261 -10
- package/dist/content/reference/slides-authoring-guidelines.html +11 -8
- package/dist/content/reference/structure.html +11 -8
- package/dist/content/reference.html +11 -8
- package/dist/content/templates/crucible.html +11 -8
- package/dist/content/templates/handbook.html +11 -8
- package/dist/content/templates/minimal.html +11 -8
- package/dist/content/templates/overview.html +11 -8
- package/dist/content/templates/pipeline.html +11 -8
- package/dist/content/templates/productbook.html +11 -8
- package/dist/content/templates/runbook.html +11 -8
- package/dist/content/templates/servicebook.html +11 -8
- package/dist/content-index.json +37 -2
- package/dist/docs/decisions/ADR-0001-minimalist-site-code.html +11 -8
- package/dist/docs/decisions/ADR-0002-ai-accessibility.html +11 -8
- package/dist/docs/decisions/ADR-0003-single-file-bundle.html +11 -8
- package/dist/docs/decisions/ADR-0004-bun-runtime.html +11 -8
- package/dist/docs/decisions/ADR-0005-plugin-contract-and-distribution.html +11 -8
- package/dist/docs/decisions/ADR-0006-data-driven-content.html +751 -0
- package/dist/docs/decisions/DDR-0001-viewport-locked-layout.html +11 -8
- package/dist/docs/decisions/DDR-0002-theme-system.html +11 -8
- package/dist/docs/decisions/DDR-0003-bounded-logo-slot.html +11 -8
- package/dist/docs/decisions/DDR-0004-slides-rendering-model.html +11 -8
- package/dist/docs/decisions/DDR-0005-deterministic-layout-boundary.html +11 -8
- package/dist/docs/userguide/cli/build.html +11 -8
- package/dist/docs/userguide/cli/bundle.html +11 -8
- package/dist/docs/userguide/cli/dev.html +11 -8
- package/dist/docs/userguide/cli/init.html +11 -8
- package/dist/docs/userguide/cli/servers.html +11 -8
- package/dist/docs/userguide/cli/stop.html +11 -8
- package/dist/docs/userguide/cli/update.html +11 -8
- package/dist/docs/userguide/cli/version.html +11 -8
- package/dist/docs/userguide/cli.html +11 -8
- package/dist/docs/userguide/sharing.html +11 -8
- package/dist/index.html +11 -8
- package/dist/llms.txt +3 -3
- package/dist/provenance.json +4 -5
- package/dist/reports/license-inventory.csv +199 -0
- package/dist/schemas/plugin-registry.schema.html +11 -8
- package/dist/schemas/plugin-schemas-notes.html +11 -8
- package/dist/schemas/plugin.schema.html +11 -8
- package/dist/schemas/plugins.schema.html +11 -8
- package/dist/schemas/v0/common.schema.html +15 -12
- package/dist/schemas/v0/plugin-registry.schema.html +14 -11
- package/dist/schemas/v0/plugin.schema.html +14 -11
- package/dist/schemas/v0/plugins.schema.html +14 -11
- package/dist/schemas/v0/site.schema.html +68 -9
- package/dist/schemas/v0/theme.schema.html +22 -19
- package/dist/schemas.html +11 -8
- 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/planning-visuals.css +261 -0
- package/plugins-dist/planning-visuals.js +669 -0
- package/plugins-dist/slides-charts-lite-runtime.js +179 -0
- package/plugins-dist/slides-charts-lite.js +198 -0
- package/registry/plugins.yaml +40 -1
- package/schemas/v0/site.schema.json +56 -0
- package/scripts/build-all.ts +5 -0
- package/scripts/build.ts +264 -80
- package/scripts/bundle.ts +188 -17
- package/scripts/dev.ts +294 -171
- package/scripts/embed-docs.ts +119 -0
- package/src/__tests__/brief.test.ts +151 -0
- package/src/__tests__/build.test.ts +293 -1
- package/src/__tests__/bundle.test.ts +195 -0
- package/src/__tests__/docs.test.ts +117 -0
- package/src/__tests__/fixtures/fences/planning-visuals/invalid/bad-month-format.md +10 -0
- package/src/__tests__/fixtures/fences/planning-visuals/invalid/marker-format-mismatch.md +13 -0
- package/src/__tests__/fixtures/fences/planning-visuals/invalid/milestone-format-mismatch.md +13 -0
- package/src/__tests__/fixtures/fences/planning-visuals/invalid/missing-tracks.md +5 -0
- package/src/__tests__/fixtures/fences/planning-visuals/invalid/track-reversed.md +10 -0
- package/src/__tests__/fixtures/fences/planning-visuals/valid/markers-basic.md +15 -0
- package/src/__tests__/fixtures/fences/planning-visuals/valid/markers-no-milestones.md +13 -0
- package/src/__tests__/fixtures/fences/planning-visuals/valid/month-basic.md +16 -0
- package/src/__tests__/fixtures/fences/planning-visuals/valid/no-milestones.md +10 -0
- package/src/__tests__/fixtures/fences/planning-visuals/valid/week-basic.md +20 -0
- package/src/__tests__/init.test.ts +51 -2
- package/src/__tests__/latex-runtime.bun.test.ts +35 -0
- package/src/__tests__/planning-visuals-fence-contract.test.ts +28 -0
- package/src/__tests__/planning-visuals-runtime-regressions.bun.test.ts +68 -0
- package/src/__tests__/planning-visuals-runtime.bun.test.ts +192 -0
- package/src/__tests__/shared.test.ts +719 -1
- package/src/__tests__/slides-charts-lite-runtime.bun.test.ts +45 -0
- package/src/cli.ts +124 -22
- package/src/commands/docs.ts +71 -0
- package/src/commands/init.ts +1 -1
- package/src/generated/embedded-docs.ts +2384 -0
- package/src/server-registry.ts +50 -10
- package/src/shared.ts +1174 -43
- 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,25 +15,34 @@ import {
|
|
|
15
15
|
buildNavStatic,
|
|
16
16
|
buildPageMeta,
|
|
17
17
|
buildSlideNav,
|
|
18
|
+
buildSlideNavHierarchical,
|
|
18
19
|
buildToc,
|
|
19
20
|
type ContentFile,
|
|
20
21
|
collectFiles,
|
|
22
|
+
collectPlanningVisualsContainmentWarnings,
|
|
21
23
|
collectSlides,
|
|
22
24
|
envBool,
|
|
23
25
|
envInt,
|
|
24
26
|
envString,
|
|
25
27
|
escapeHtml,
|
|
26
28
|
exists,
|
|
29
|
+
filterByProfile,
|
|
30
|
+
filterUnknownPlanningVisualsTypeDiagnostics,
|
|
27
31
|
filterUnknownSlidesVisualsTypeDiagnostics,
|
|
28
32
|
formatDate,
|
|
29
33
|
generateProvenance,
|
|
30
34
|
getGitInfo,
|
|
31
35
|
KITFLY_BRAND,
|
|
36
|
+
loadDataBindings,
|
|
32
37
|
loadSiteConfig,
|
|
38
|
+
mergeFrontmatterWithBody,
|
|
39
|
+
normalizeProfileTags,
|
|
33
40
|
type Provenance,
|
|
41
|
+
pagePathForData,
|
|
34
42
|
parseFrontmatter,
|
|
35
43
|
parseValue,
|
|
36
44
|
parseYaml,
|
|
45
|
+
resolveBindings,
|
|
37
46
|
resolveSiteVersion,
|
|
38
47
|
rewriteRelativeAssetUrls,
|
|
39
48
|
type SiteConfig,
|
|
@@ -43,6 +52,7 @@ import {
|
|
|
43
52
|
stripQuotes,
|
|
44
53
|
toUrlPath,
|
|
45
54
|
validatePath,
|
|
55
|
+
validatePlanningVisualsFences,
|
|
46
56
|
validateSlidesVisualsFences,
|
|
47
57
|
} from "../shared.ts";
|
|
48
58
|
|
|
@@ -104,6 +114,271 @@ description: A test page
|
|
|
104
114
|
});
|
|
105
115
|
});
|
|
106
116
|
|
|
117
|
+
describe("normalizeProfileTags", () => {
|
|
118
|
+
it("parses scalar and array-like profile tags", () => {
|
|
119
|
+
expect(normalizeProfileTags("alpha")).toEqual(["alpha"]);
|
|
120
|
+
expect(normalizeProfileTags("[alpha, beta]")).toEqual(["alpha", "beta"]);
|
|
121
|
+
expect(normalizeProfileTags(["alpha", "beta"])).toEqual(["alpha", "beta"]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("normalizes whitespace, quotes, and case", () => {
|
|
125
|
+
expect(normalizeProfileTags("[\"Alpha\", 'beta']")).toEqual(["alpha", "beta"]);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("filterByProfile", () => {
|
|
130
|
+
it("keeps all files when profiles are not configured", async () => {
|
|
131
|
+
const root = await mkdtemp(join(tmpdir(), "kitfly-profile-"));
|
|
132
|
+
try {
|
|
133
|
+
const alwaysPath = join(root, "always.md");
|
|
134
|
+
const alphaPath = join(root, "alpha.md");
|
|
135
|
+
const bothPath = join(root, "both.md");
|
|
136
|
+
const betaPath = join(root, "beta.md");
|
|
137
|
+
await writeFile(alwaysPath, "# Always");
|
|
138
|
+
await writeFile(alphaPath, "---\nprofile: alpha\n---\n# Alpha");
|
|
139
|
+
await writeFile(bothPath, "---\nprofile: [alpha, beta]\n---\n# Both");
|
|
140
|
+
await writeFile(betaPath, "---\nprofile: beta\n---\n# Beta");
|
|
141
|
+
|
|
142
|
+
const files: ContentFile[] = [
|
|
143
|
+
{ path: alwaysPath, urlPath: "always", section: "Docs" },
|
|
144
|
+
{ path: alphaPath, urlPath: "alpha", section: "Docs" },
|
|
145
|
+
{ path: bothPath, urlPath: "both", section: "Docs" },
|
|
146
|
+
{ path: betaPath, urlPath: "beta", section: "Docs" },
|
|
147
|
+
];
|
|
148
|
+
|
|
149
|
+
const noProfile = await filterByProfile(files);
|
|
150
|
+
expect(noProfile.map((f) => f.urlPath)).toEqual(["always", "alpha", "both", "beta"]);
|
|
151
|
+
} finally {
|
|
152
|
+
await rm(root, { recursive: true, force: true });
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("filters tagged files when profiles are configured", async () => {
|
|
157
|
+
const root = await mkdtemp(join(tmpdir(), "kitfly-profile-"));
|
|
158
|
+
try {
|
|
159
|
+
const alwaysPath = join(root, "always.md");
|
|
160
|
+
const alphaPath = join(root, "alpha.md");
|
|
161
|
+
const bothPath = join(root, "both.md");
|
|
162
|
+
const betaPath = join(root, "beta.md");
|
|
163
|
+
await writeFile(alwaysPath, "# Always");
|
|
164
|
+
await writeFile(alphaPath, "---\nprofile: alpha\n---\n# Alpha");
|
|
165
|
+
await writeFile(bothPath, "---\nprofile: [alpha, beta]\n---\n# Both");
|
|
166
|
+
await writeFile(betaPath, "---\nprofile: beta\n---\n# Beta");
|
|
167
|
+
|
|
168
|
+
const files: ContentFile[] = [
|
|
169
|
+
{ path: alwaysPath, urlPath: "always", section: "Docs" },
|
|
170
|
+
{ path: alphaPath, urlPath: "alpha", section: "Docs" },
|
|
171
|
+
{ path: bothPath, urlPath: "both", section: "Docs" },
|
|
172
|
+
{ path: betaPath, urlPath: "beta", section: "Docs" },
|
|
173
|
+
];
|
|
174
|
+
const profiles = {
|
|
175
|
+
alpha: { include: { tags: ["alpha"] } },
|
|
176
|
+
beta: { include: { tags: ["beta"] } },
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const noProfile = await filterByProfile(files, undefined, profiles);
|
|
180
|
+
expect(noProfile.map((f) => f.urlPath)).toEqual(["always"]);
|
|
181
|
+
|
|
182
|
+
const alphaProfile = await filterByProfile(files, "alpha", profiles);
|
|
183
|
+
expect(alphaProfile.map((f) => f.urlPath)).toEqual(["always", "alpha", "both"]);
|
|
184
|
+
|
|
185
|
+
const betaProfile = await filterByProfile(files, "beta", profiles);
|
|
186
|
+
expect(betaProfile.map((f) => f.urlPath)).toEqual(["always", "both", "beta"]);
|
|
187
|
+
} finally {
|
|
188
|
+
await rm(root, { recursive: true, force: true });
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("data bindings", () => {
|
|
194
|
+
it("resolves values and snippets with formatter chains", () => {
|
|
195
|
+
const bound = resolveBindings(
|
|
196
|
+
[
|
|
197
|
+
"Rate: {{ baseline | dollar }}",
|
|
198
|
+
"Pct: {{ ratio | percent }}",
|
|
199
|
+
"Rounded: {{ pi | round(2) }}",
|
|
200
|
+
"Upper: {{ label | upper }}",
|
|
201
|
+
"Chain: {{ baseline | round(0) | dollar }}",
|
|
202
|
+
"{{ snippet:table }}",
|
|
203
|
+
].join("\n"),
|
|
204
|
+
{
|
|
205
|
+
globals: { baseline: "1500", ratio: "0.125", pi: "3.14159" },
|
|
206
|
+
inject: { label: "pricing" },
|
|
207
|
+
snippets: [{ slot: "table", content: "| A | B |\n|---|---|" }],
|
|
208
|
+
},
|
|
209
|
+
"product/pricing.md",
|
|
210
|
+
);
|
|
211
|
+
expect(bound).toContain("Rate: $1,500");
|
|
212
|
+
expect(bound).toContain("Pct: 12.5%");
|
|
213
|
+
expect(bound).toContain("Rounded: 3.14");
|
|
214
|
+
expect(bound).toContain("Upper: PRICING");
|
|
215
|
+
expect(bound).toContain("Chain: $1,500");
|
|
216
|
+
expect(bound).toContain("| A | B |");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("throws on unresolved keys, unknown snippets, and unknown formatters", () => {
|
|
220
|
+
expect(() =>
|
|
221
|
+
resolveBindings("{{ missing }}", { globals: {}, inject: {}, snippets: [] }, "x.md"),
|
|
222
|
+
).toThrow(/unresolved binding/);
|
|
223
|
+
expect(() =>
|
|
224
|
+
resolveBindings("{{ snippet:nope }}", { globals: {}, inject: {}, snippets: [] }, "x.md"),
|
|
225
|
+
).toThrow(/unknown snippet/);
|
|
226
|
+
expect(() =>
|
|
227
|
+
resolveBindings(
|
|
228
|
+
"{{ n | custom }}",
|
|
229
|
+
{ globals: { n: "1" }, inject: {}, snippets: [] },
|
|
230
|
+
"x.md",
|
|
231
|
+
),
|
|
232
|
+
).toThrow(/unknown formatter/);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe("loadDataBindings", () => {
|
|
237
|
+
it("loads page-level inject/snippets and validates optional schema", async () => {
|
|
238
|
+
const root = await mkdtemp(join(tmpdir(), "kitfly-data-"));
|
|
239
|
+
try {
|
|
240
|
+
await mkdir(join(root, "content", "product"), { recursive: true });
|
|
241
|
+
await mkdir(join(root, "data"), { recursive: true });
|
|
242
|
+
await writeFile(
|
|
243
|
+
join(root, "data", "pricing.yaml"),
|
|
244
|
+
[
|
|
245
|
+
"globals:",
|
|
246
|
+
' baseline: "200"',
|
|
247
|
+
"pages:",
|
|
248
|
+
" - path: product/pricing.md",
|
|
249
|
+
" inject:",
|
|
250
|
+
' hero: "Implementation costs"',
|
|
251
|
+
" snippets:",
|
|
252
|
+
" - slot: pricing-table",
|
|
253
|
+
" content: |",
|
|
254
|
+
" | Tier | Price |",
|
|
255
|
+
].join("\n"),
|
|
256
|
+
);
|
|
257
|
+
await writeFile(
|
|
258
|
+
join(root, "data", "pricing.schema.json"),
|
|
259
|
+
JSON.stringify(
|
|
260
|
+
{
|
|
261
|
+
type: "object",
|
|
262
|
+
required: ["globals", "pages"],
|
|
263
|
+
properties: {
|
|
264
|
+
globals: {
|
|
265
|
+
type: "object",
|
|
266
|
+
required: ["baseline"],
|
|
267
|
+
properties: {
|
|
268
|
+
baseline: { type: "string", pattern: "^[0-9]+$" },
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
pages: { type: "array" },
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
null,
|
|
275
|
+
2,
|
|
276
|
+
),
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
const pagePath = pagePathForData(
|
|
280
|
+
root,
|
|
281
|
+
"content",
|
|
282
|
+
join(root, "content", "product", "pricing.md"),
|
|
283
|
+
);
|
|
284
|
+
const bindings = await loadDataBindings(
|
|
285
|
+
"data/pricing.yaml",
|
|
286
|
+
pagePath,
|
|
287
|
+
root,
|
|
288
|
+
"content",
|
|
289
|
+
"data",
|
|
290
|
+
);
|
|
291
|
+
expect(bindings.globals.baseline).toBe("200");
|
|
292
|
+
expect(bindings.inject.hero).toBe("Implementation costs");
|
|
293
|
+
expect(bindings.snippets[0].slot).toBe("pricing-table");
|
|
294
|
+
expect(bindings.snippets[0].content).toContain("| Tier | Price |");
|
|
295
|
+
} finally {
|
|
296
|
+
await rm(root, { recursive: true, force: true });
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("rejects data paths that escape dataroot", async () => {
|
|
301
|
+
const root = await mkdtemp(join(tmpdir(), "kitfly-data-"));
|
|
302
|
+
try {
|
|
303
|
+
await mkdir(join(root, "data"), { recursive: true });
|
|
304
|
+
await writeFile(join(root, "outside.yaml"), 'globals:\n x: "1"\n');
|
|
305
|
+
await expect(loadDataBindings("../outside.yaml", "a.md", root, ".", "data")).rejects.toThrow(
|
|
306
|
+
/data path escapes/,
|
|
307
|
+
);
|
|
308
|
+
} finally {
|
|
309
|
+
await rm(root, { recursive: true, force: true });
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("rejects symlinked dataroot that escapes site root", async () => {
|
|
314
|
+
const root = await mkdtemp(join(tmpdir(), "kitfly-data-"));
|
|
315
|
+
const outside = await mkdtemp(join(tmpdir(), "kitfly-outside-"));
|
|
316
|
+
try {
|
|
317
|
+
await mkdir(join(root, "content"), { recursive: true });
|
|
318
|
+
await writeFile(join(outside, "pricing.yaml"), 'globals:\n x: "1"\n');
|
|
319
|
+
await symlink(outside, join(root, "data"));
|
|
320
|
+
await expect(
|
|
321
|
+
loadDataBindings("data/pricing.yaml", "pricing.md", root, "content", "data"),
|
|
322
|
+
).rejects.toThrow(/data path escapes kitsite/);
|
|
323
|
+
} finally {
|
|
324
|
+
await rm(root, { recursive: true, force: true });
|
|
325
|
+
await rm(outside, { recursive: true, force: true });
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("loads multiline YAML snippet blocks for binding resolution", async () => {
|
|
330
|
+
const root = await mkdtemp(join(tmpdir(), "kitfly-data-"));
|
|
331
|
+
try {
|
|
332
|
+
await mkdir(join(root, "content", "product"), { recursive: true });
|
|
333
|
+
await mkdir(join(root, "data"), { recursive: true });
|
|
334
|
+
await writeFile(
|
|
335
|
+
join(root, "data", "pricing.yaml"),
|
|
336
|
+
[
|
|
337
|
+
"pages:",
|
|
338
|
+
" - path: product/pricing.md",
|
|
339
|
+
" snippets:",
|
|
340
|
+
" - slot: pricing-table",
|
|
341
|
+
" content: |",
|
|
342
|
+
" | Tier | Price |",
|
|
343
|
+
" |------|-------|",
|
|
344
|
+
" | Starter | $200 |",
|
|
345
|
+
].join("\n"),
|
|
346
|
+
);
|
|
347
|
+
const bindings = await loadDataBindings(
|
|
348
|
+
"data/pricing.yaml",
|
|
349
|
+
"product/pricing.md",
|
|
350
|
+
root,
|
|
351
|
+
"content",
|
|
352
|
+
"data",
|
|
353
|
+
);
|
|
354
|
+
const rendered = resolveBindings(
|
|
355
|
+
"Table:\n{{ snippet:pricing-table }}",
|
|
356
|
+
bindings,
|
|
357
|
+
"product/pricing.md",
|
|
358
|
+
);
|
|
359
|
+
expect(rendered).toContain("| Tier | Price |");
|
|
360
|
+
expect(rendered).toContain("| Starter | $200 |");
|
|
361
|
+
} finally {
|
|
362
|
+
await rm(root, { recursive: true, force: true });
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
describe("mergeFrontmatterWithBody", () => {
|
|
368
|
+
it("preserves original frontmatter text while replacing body", () => {
|
|
369
|
+
const original = `---
|
|
370
|
+
title: "A # B"
|
|
371
|
+
tags: [a, b]
|
|
372
|
+
---
|
|
373
|
+
|
|
374
|
+
# Old`;
|
|
375
|
+
const merged = mergeFrontmatterWithBody(original, "# New");
|
|
376
|
+
expect(merged).toContain('title: "A # B"');
|
|
377
|
+
expect(merged).toContain("tags: [a, b]");
|
|
378
|
+
expect(merged.endsWith("# New")).toBe(true);
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
|
|
107
382
|
describe("splitSlides", () => {
|
|
108
383
|
it("splits markdown on explicit slide delimiter", () => {
|
|
109
384
|
const input = `# One
|
|
@@ -178,6 +453,124 @@ label: Missing value
|
|
|
178
453
|
});
|
|
179
454
|
});
|
|
180
455
|
|
|
456
|
+
describe("planning-visuals diagnostics filtering", () => {
|
|
457
|
+
it("drops unknown-type diagnostics while preserving schema violations", () => {
|
|
458
|
+
const markdown = `:::future-planning
|
|
459
|
+
foo: bar
|
|
460
|
+
:::
|
|
461
|
+
|
|
462
|
+
:::gantt
|
|
463
|
+
time-unit: week
|
|
464
|
+
time-start: 2026-W10
|
|
465
|
+
time-end: 2026-W12
|
|
466
|
+
:::`;
|
|
467
|
+
const diagnostics = validatePlanningVisualsFences(markdown);
|
|
468
|
+
const filtered = filterUnknownPlanningVisualsTypeDiagnostics(diagnostics);
|
|
469
|
+
expect(
|
|
470
|
+
diagnostics.some((d) => d.message.startsWith("Unknown planning-visuals block type:")),
|
|
471
|
+
).toBe(true);
|
|
472
|
+
expect(filtered.some((d) => d.message.startsWith("Unknown planning-visuals block type:"))).toBe(
|
|
473
|
+
false,
|
|
474
|
+
);
|
|
475
|
+
expect(filtered.some((d) => d.message.includes("Missing required key: tracks"))).toBe(true);
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
describe("planning-visuals containment warnings", () => {
|
|
480
|
+
it("returns non-fatal containment warnings when rows exceed axis", () => {
|
|
481
|
+
const markdown = `:::gantt
|
|
482
|
+
time-unit: month
|
|
483
|
+
time-start: 2026-04
|
|
484
|
+
time-end: 2026-06
|
|
485
|
+
tracks:
|
|
486
|
+
- label: Wave 1
|
|
487
|
+
depth: 1
|
|
488
|
+
start: 2026-03
|
|
489
|
+
end: 2026-07
|
|
490
|
+
milestones:
|
|
491
|
+
- label: Late marker
|
|
492
|
+
date: 2026-08
|
|
493
|
+
:::`;
|
|
494
|
+
const warnings = collectPlanningVisualsContainmentWarnings(markdown);
|
|
495
|
+
expect(warnings.some((w) => w.message.includes("Track range is outside axis"))).toBe(true);
|
|
496
|
+
expect(warnings.some((w) => w.message.includes("Milestone date is outside axis"))).toBe(true);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it("warns when marker date is outside axis range", () => {
|
|
500
|
+
const markdown = `:::gantt
|
|
501
|
+
time-unit: week
|
|
502
|
+
time-start: 2026-W14
|
|
503
|
+
time-end: 2026-W20
|
|
504
|
+
markers:
|
|
505
|
+
- label: Late gate
|
|
506
|
+
date: 2026-W22
|
|
507
|
+
tracks:
|
|
508
|
+
- label: Phase 1
|
|
509
|
+
depth: 1
|
|
510
|
+
start: 2026-W14
|
|
511
|
+
end: 2026-W18
|
|
512
|
+
:::`;
|
|
513
|
+
const warnings = collectPlanningVisualsContainmentWarnings(markdown);
|
|
514
|
+
expect(warnings.some((w) => w.message.includes("Marker date is outside axis"))).toBe(true);
|
|
515
|
+
});
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
describe("planning-visuals marker validation", () => {
|
|
519
|
+
it("accepts valid markers", () => {
|
|
520
|
+
const markdown = `:::gantt
|
|
521
|
+
time-unit: week
|
|
522
|
+
time-start: 2026-W14
|
|
523
|
+
time-end: 2026-W30
|
|
524
|
+
markers:
|
|
525
|
+
- label: Go/No-Go
|
|
526
|
+
date: 2026-W20
|
|
527
|
+
tracks:
|
|
528
|
+
- label: Phase 1
|
|
529
|
+
depth: 1
|
|
530
|
+
start: 2026-W14
|
|
531
|
+
end: 2026-W24
|
|
532
|
+
:::`;
|
|
533
|
+
const diags = validatePlanningVisualsFences(markdown);
|
|
534
|
+
expect(diags).toHaveLength(0);
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
it("rejects marker with mismatched date format", () => {
|
|
538
|
+
const markdown = `:::gantt
|
|
539
|
+
time-unit: month
|
|
540
|
+
time-start: 2026-04
|
|
541
|
+
time-end: 2026-10
|
|
542
|
+
markers:
|
|
543
|
+
- label: Gate
|
|
544
|
+
date: 2026-W20
|
|
545
|
+
tracks:
|
|
546
|
+
- label: Build
|
|
547
|
+
depth: 1
|
|
548
|
+
start: 2026-04
|
|
549
|
+
end: 2026-07
|
|
550
|
+
:::`;
|
|
551
|
+
const diags = validatePlanningVisualsFences(markdown);
|
|
552
|
+
expect(diags.some((d) => d.message.includes("Marker date must match month format"))).toBe(true);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it("accepts month markers with day precision", () => {
|
|
556
|
+
const markdown = `:::gantt
|
|
557
|
+
time-unit: month
|
|
558
|
+
time-start: 2026-04
|
|
559
|
+
time-end: 2026-10
|
|
560
|
+
markers:
|
|
561
|
+
- label: Conference
|
|
562
|
+
date: 2026-05-26
|
|
563
|
+
tracks:
|
|
564
|
+
- label: Build
|
|
565
|
+
depth: 1
|
|
566
|
+
start: 2026-04
|
|
567
|
+
end: 2026-07
|
|
568
|
+
:::`;
|
|
569
|
+
const diags = validatePlanningVisualsFences(markdown);
|
|
570
|
+
expect(diags).toHaveLength(0);
|
|
571
|
+
});
|
|
572
|
+
});
|
|
573
|
+
|
|
181
574
|
describe("segmentSlides", () => {
|
|
182
575
|
it("uses frontmatter title and class when present", () => {
|
|
183
576
|
const input = `---
|
|
@@ -348,6 +741,140 @@ title: Intro
|
|
|
348
741
|
expect(nav).toContain('<a href="#slide-2" class="active">Slide B</a>');
|
|
349
742
|
expect(nav).toContain('<span class="nav-section">Slides</span>');
|
|
350
743
|
});
|
|
744
|
+
|
|
745
|
+
it("builds hierarchical slide nav for nested source paths", () => {
|
|
746
|
+
const nav = buildSlideNavHierarchical(
|
|
747
|
+
[
|
|
748
|
+
{
|
|
749
|
+
index: 0,
|
|
750
|
+
frontmatter: {},
|
|
751
|
+
body: "# Overview",
|
|
752
|
+
title: "Overview",
|
|
753
|
+
id: "slide-1",
|
|
754
|
+
section: "Data Integration",
|
|
755
|
+
sourcePath: "/tmp/di/slides.md",
|
|
756
|
+
sourceUrlPath: "data-integration/slides",
|
|
757
|
+
kind: "markdown",
|
|
758
|
+
},
|
|
759
|
+
{
|
|
760
|
+
index: 1,
|
|
761
|
+
frontmatter: {},
|
|
762
|
+
body: "# POS Data",
|
|
763
|
+
title: "POS Data",
|
|
764
|
+
id: "slide-2",
|
|
765
|
+
section: "Data Integration",
|
|
766
|
+
sourcePath: "/tmp/di/sources/slides.md",
|
|
767
|
+
sourceUrlPath: "data-integration/sources/slides",
|
|
768
|
+
kind: "markdown",
|
|
769
|
+
},
|
|
770
|
+
{
|
|
771
|
+
index: 2,
|
|
772
|
+
frontmatter: {},
|
|
773
|
+
body: "# ETL Pipeline",
|
|
774
|
+
title: "ETL Pipeline",
|
|
775
|
+
id: "slide-3",
|
|
776
|
+
section: "Data Integration",
|
|
777
|
+
sourcePath: "/tmp/di/transforms/slides.md",
|
|
778
|
+
sourceUrlPath: "data-integration/transforms/slides",
|
|
779
|
+
kind: "markdown",
|
|
780
|
+
},
|
|
781
|
+
],
|
|
782
|
+
{
|
|
783
|
+
docroot: ".",
|
|
784
|
+
title: "Deck",
|
|
785
|
+
brand: { name: "Test", url: "/" },
|
|
786
|
+
sections: [{ name: "Data Integration", path: "data-integration" }],
|
|
787
|
+
},
|
|
788
|
+
"slide-2",
|
|
789
|
+
);
|
|
790
|
+
|
|
791
|
+
expect(nav).toContain('<summary class="nav-group">Sources</summary>');
|
|
792
|
+
expect(nav).toContain('<summary class="nav-group">Transforms</summary>');
|
|
793
|
+
expect(nav).toContain('<a href="#slide-2" class="active">POS Data</a>');
|
|
794
|
+
expect(nav).toContain('<a href="#slide-3">ETL Pipeline</a>');
|
|
795
|
+
expect(nav).toContain("<details open>");
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
it("keeps flat nav output when slides are not nested", () => {
|
|
799
|
+
const nav = buildSlideNavHierarchical(
|
|
800
|
+
[
|
|
801
|
+
{
|
|
802
|
+
index: 0,
|
|
803
|
+
frontmatter: {},
|
|
804
|
+
body: "# Intro",
|
|
805
|
+
title: "Intro",
|
|
806
|
+
id: "slide-1",
|
|
807
|
+
section: "Slides",
|
|
808
|
+
sourcePath: "/tmp/slides/a.md",
|
|
809
|
+
sourceUrlPath: "slides/a",
|
|
810
|
+
kind: "markdown",
|
|
811
|
+
},
|
|
812
|
+
{
|
|
813
|
+
index: 1,
|
|
814
|
+
frontmatter: {},
|
|
815
|
+
body: "# Next",
|
|
816
|
+
title: "Next",
|
|
817
|
+
id: "slide-2",
|
|
818
|
+
section: "Slides",
|
|
819
|
+
sourcePath: "/tmp/slides/b.md",
|
|
820
|
+
sourceUrlPath: "slides/b",
|
|
821
|
+
kind: "markdown",
|
|
822
|
+
},
|
|
823
|
+
],
|
|
824
|
+
{
|
|
825
|
+
docroot: ".",
|
|
826
|
+
title: "Deck",
|
|
827
|
+
brand: { name: "Test", url: "/" },
|
|
828
|
+
sections: [{ name: "Slides", path: "slides" }],
|
|
829
|
+
},
|
|
830
|
+
"slide-1",
|
|
831
|
+
);
|
|
832
|
+
|
|
833
|
+
expect(nav).toContain('<a href="#slide-1" class="active">Intro</a>');
|
|
834
|
+
expect(nav).toContain('<a href="#slide-2">Next</a>');
|
|
835
|
+
expect(nav).not.toContain('class="nav-group"');
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
it("keeps flat nav output when section path has trailing slash", () => {
|
|
839
|
+
const nav = buildSlideNavHierarchical(
|
|
840
|
+
[
|
|
841
|
+
{
|
|
842
|
+
index: 0,
|
|
843
|
+
frontmatter: {},
|
|
844
|
+
body: "# Intro",
|
|
845
|
+
title: "Intro",
|
|
846
|
+
id: "slide-1",
|
|
847
|
+
section: "Slides",
|
|
848
|
+
sourcePath: "/tmp/slides/a.md",
|
|
849
|
+
sourceUrlPath: "slides/a",
|
|
850
|
+
kind: "markdown",
|
|
851
|
+
},
|
|
852
|
+
{
|
|
853
|
+
index: 1,
|
|
854
|
+
frontmatter: {},
|
|
855
|
+
body: "# Next",
|
|
856
|
+
title: "Next",
|
|
857
|
+
id: "slide-2",
|
|
858
|
+
section: "Slides",
|
|
859
|
+
sourcePath: "/tmp/slides/b.md",
|
|
860
|
+
sourceUrlPath: "slides/b",
|
|
861
|
+
kind: "markdown",
|
|
862
|
+
},
|
|
863
|
+
],
|
|
864
|
+
{
|
|
865
|
+
docroot: ".",
|
|
866
|
+
title: "Deck",
|
|
867
|
+
brand: { name: "Test", url: "/" },
|
|
868
|
+
sections: [{ name: "Slides", path: "slides/" }],
|
|
869
|
+
},
|
|
870
|
+
"slide-1",
|
|
871
|
+
);
|
|
872
|
+
|
|
873
|
+
expect(nav).toContain('<a href="#slide-1" class="active">Intro</a>');
|
|
874
|
+
expect(nav).toContain('<a href="#slide-2">Next</a>');
|
|
875
|
+
expect(nav).not.toContain('class="nav-group"');
|
|
876
|
+
expect(nav).not.toContain("<summary");
|
|
877
|
+
});
|
|
351
878
|
});
|
|
352
879
|
|
|
353
880
|
describe("rewriteRelativeAssetUrls", () => {
|
|
@@ -532,6 +1059,78 @@ description: 'A test site'`;
|
|
|
532
1059
|
expect(result.tags).toEqual(["javascript", "typescript"]);
|
|
533
1060
|
});
|
|
534
1061
|
|
|
1062
|
+
it("parses literal block scalars", () => {
|
|
1063
|
+
const yaml = `snippets:
|
|
1064
|
+
- slot: pricing-table
|
|
1065
|
+
content: |
|
|
1066
|
+
| Tier | Price |
|
|
1067
|
+
|------|-------|
|
|
1068
|
+
| Starter | $200 |`;
|
|
1069
|
+
const result = parseYaml(yaml);
|
|
1070
|
+
const snippets = result.snippets as Array<Record<string, unknown>>;
|
|
1071
|
+
expect(snippets[0].content).toBe("| Tier | Price |\n|------|-------|\n| Starter | $200 |");
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
it("parses folded block scalars", () => {
|
|
1075
|
+
const yaml = `globals:
|
|
1076
|
+
summary: >
|
|
1077
|
+
Line one
|
|
1078
|
+
line two
|
|
1079
|
+
|
|
1080
|
+
Paragraph two`;
|
|
1081
|
+
const result = parseYaml(yaml);
|
|
1082
|
+
const globals = result.globals as Record<string, unknown>;
|
|
1083
|
+
expect(globals.summary).toBe("Line one line two\nParagraph two");
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
it("supports block scalar chomping indicators", () => {
|
|
1087
|
+
const yaml = `globals:
|
|
1088
|
+
keep: |+
|
|
1089
|
+
One
|
|
1090
|
+
Two
|
|
1091
|
+
strip: >-
|
|
1092
|
+
A
|
|
1093
|
+
B`;
|
|
1094
|
+
const result = parseYaml(yaml);
|
|
1095
|
+
const globals = result.globals as Record<string, unknown>;
|
|
1096
|
+
expect(globals.keep).toBe("One\nTwo\n");
|
|
1097
|
+
expect(globals.strip).toBe("A B");
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
it("parses direct array block scalar items", () => {
|
|
1101
|
+
const yaml = `snippets:
|
|
1102
|
+
- |
|
|
1103
|
+
line 1
|
|
1104
|
+
line 2
|
|
1105
|
+
- >-
|
|
1106
|
+
folded
|
|
1107
|
+
line`;
|
|
1108
|
+
const result = parseYaml(yaml);
|
|
1109
|
+
expect(result.snippets).toEqual(["line 1\nline 2", "folded line"]);
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
it("accepts block scalar indentation indicators", () => {
|
|
1113
|
+
const yaml = `globals:
|
|
1114
|
+
note: |2-
|
|
1115
|
+
indented
|
|
1116
|
+
block`;
|
|
1117
|
+
const result = parseYaml(yaml);
|
|
1118
|
+
const globals = result.globals as Record<string, unknown>;
|
|
1119
|
+
expect(globals.note).toBe("indented\nblock");
|
|
1120
|
+
});
|
|
1121
|
+
|
|
1122
|
+
it("treats invalid block scalar headers as plain strings", () => {
|
|
1123
|
+
const yaml = `globals:
|
|
1124
|
+
bad: |abc
|
|
1125
|
+
snippets:
|
|
1126
|
+
- >foo`;
|
|
1127
|
+
const result = parseYaml(yaml);
|
|
1128
|
+
const globals = result.globals as Record<string, unknown>;
|
|
1129
|
+
const snippets = result.snippets as unknown[];
|
|
1130
|
+
expect(globals.bad).toBe("|abc");
|
|
1131
|
+
expect(snippets[0]).toBe(">foo");
|
|
1132
|
+
});
|
|
1133
|
+
|
|
535
1134
|
it("returns empty object for empty input", () => {
|
|
536
1135
|
const result = parseYaml("");
|
|
537
1136
|
expect(result).toEqual({});
|
|
@@ -1122,12 +1721,18 @@ version: "1.2.0"
|
|
|
1122
1721
|
brand:
|
|
1123
1722
|
name: Test
|
|
1124
1723
|
url: /
|
|
1724
|
+
logoDark: assets/brand/logo-dark.png
|
|
1125
1725
|
sections:
|
|
1126
1726
|
- name: Guide
|
|
1127
1727
|
path: guide
|
|
1128
1728
|
footer:
|
|
1129
1729
|
copyright: "© 2026 Test"
|
|
1130
1730
|
attribution: false
|
|
1731
|
+
logo: "assets/brand/footer-logo.png"
|
|
1732
|
+
logoDark: "assets/brand/footer-logo-dark.png"
|
|
1733
|
+
logoUrl: "https://example.com/footer"
|
|
1734
|
+
logoAlt: "Footer Brand"
|
|
1735
|
+
logoHeight: 24
|
|
1131
1736
|
links:
|
|
1132
1737
|
- text: Privacy
|
|
1133
1738
|
url: /privacy
|
|
@@ -1137,14 +1742,45 @@ footer:
|
|
|
1137
1742
|
|
|
1138
1743
|
const config = await loadSiteConfig(dir);
|
|
1139
1744
|
expect(config.version).toBe("1.2.0");
|
|
1745
|
+
expect(config.brand.logoDark).toBe("assets/brand/logo-dark.png");
|
|
1140
1746
|
expect(config.footer?.copyright).toBe("© 2026 Test");
|
|
1141
1747
|
expect(config.footer?.attribution).toBe(false);
|
|
1748
|
+
expect(config.footer?.logo).toBe("assets/brand/footer-logo.png");
|
|
1749
|
+
expect(config.footer?.logoDark).toBe("assets/brand/footer-logo-dark.png");
|
|
1750
|
+
expect(config.footer?.logoUrl).toBe("https://example.com/footer");
|
|
1751
|
+
expect(config.footer?.logoAlt).toBe("Footer Brand");
|
|
1752
|
+
expect(config.footer?.logoHeight).toBe(24);
|
|
1142
1753
|
expect(config.footer?.links).toEqual([{ text: "Privacy", url: "/privacy" }]);
|
|
1143
1754
|
} finally {
|
|
1144
1755
|
await rm(dir, { recursive: true, force: true });
|
|
1145
1756
|
}
|
|
1146
1757
|
});
|
|
1147
1758
|
|
|
1759
|
+
it("clamps footer.logoHeight to supported range", async () => {
|
|
1760
|
+
const dir = await mkdtemp(join(tmpdir(), "kitfly-footer-logo-height-"));
|
|
1761
|
+
try {
|
|
1762
|
+
await writeFile(
|
|
1763
|
+
join(dir, "site.yaml"),
|
|
1764
|
+
`title: Test
|
|
1765
|
+
brand:
|
|
1766
|
+
name: Test
|
|
1767
|
+
url: /
|
|
1768
|
+
sections:
|
|
1769
|
+
- name: Guide
|
|
1770
|
+
path: guide
|
|
1771
|
+
footer:
|
|
1772
|
+
logoHeight: 100
|
|
1773
|
+
`,
|
|
1774
|
+
"utf-8",
|
|
1775
|
+
);
|
|
1776
|
+
|
|
1777
|
+
const config = await loadSiteConfig(dir);
|
|
1778
|
+
expect(config.footer?.logoHeight).toBe(40);
|
|
1779
|
+
} finally {
|
|
1780
|
+
await rm(dir, { recursive: true, force: true });
|
|
1781
|
+
}
|
|
1782
|
+
});
|
|
1783
|
+
|
|
1148
1784
|
it("truncates footer links to max 10", async () => {
|
|
1149
1785
|
const dir = await mkdtemp(join(tmpdir(), "kitfly-footer-links-"));
|
|
1150
1786
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
@@ -1685,6 +2321,70 @@ describe("buildFooter", () => {
|
|
|
1685
2321
|
expect(result).toContain('class="footer-link"');
|
|
1686
2322
|
});
|
|
1687
2323
|
|
|
2324
|
+
it("renders footer logo before version with configurable metadata", () => {
|
|
2325
|
+
const result = buildFooter(
|
|
2326
|
+
baseProvenance,
|
|
2327
|
+
{
|
|
2328
|
+
...baseConfig,
|
|
2329
|
+
footer: {
|
|
2330
|
+
logo: "assets/brand/footer-logo.png",
|
|
2331
|
+
logoUrl: "https://footer.example.com",
|
|
2332
|
+
logoAlt: "Footer Brand",
|
|
2333
|
+
logoHeight: 22,
|
|
2334
|
+
},
|
|
2335
|
+
},
|
|
2336
|
+
"../",
|
|
2337
|
+
);
|
|
2338
|
+
expect(result).toContain('class="footer-logo-link"');
|
|
2339
|
+
expect(result).toContain('class="footer-logo-img"');
|
|
2340
|
+
expect(result).toContain('href="https://footer.example.com"');
|
|
2341
|
+
expect(result).toContain('src="../assets/brand/footer-logo.png"');
|
|
2342
|
+
expect(result).toContain('alt="Footer Brand"');
|
|
2343
|
+
expect(result).toContain("max-height: 22px");
|
|
2344
|
+
expect(result).toContain("onerror=\"this.onerror=null;this.style.display='none'\"");
|
|
2345
|
+
expect(result.indexOf('class="footer-logo-img"')).toBeLessThan(
|
|
2346
|
+
result.indexOf('class="footer-version"'),
|
|
2347
|
+
);
|
|
2348
|
+
});
|
|
2349
|
+
|
|
2350
|
+
it("renders footer light/dark logo variants when logoDark is set", () => {
|
|
2351
|
+
const result = buildFooter(baseProvenance, {
|
|
2352
|
+
...baseConfig,
|
|
2353
|
+
footer: {
|
|
2354
|
+
logo: "assets/brand/footer-logo.png",
|
|
2355
|
+
logoDark: "assets/brand/footer-logo-dark.png",
|
|
2356
|
+
},
|
|
2357
|
+
});
|
|
2358
|
+
expect(result).toContain("footer-logo-img logo-light");
|
|
2359
|
+
expect(result).toContain("footer-logo-img logo-dark");
|
|
2360
|
+
expect(result).toContain('src="assets/brand/footer-logo-dark.png"');
|
|
2361
|
+
});
|
|
2362
|
+
|
|
2363
|
+
it("falls back footer logo alt text to copyright then brand name", () => {
|
|
2364
|
+
const withCopyrightAlt = buildFooter(
|
|
2365
|
+
baseProvenance,
|
|
2366
|
+
{
|
|
2367
|
+
...baseConfig,
|
|
2368
|
+
footer: {
|
|
2369
|
+
logo: "assets/brand/footer-logo.png",
|
|
2370
|
+
copyright: "Copyright 2026 Acme",
|
|
2371
|
+
},
|
|
2372
|
+
},
|
|
2373
|
+
"./",
|
|
2374
|
+
);
|
|
2375
|
+
expect(withCopyrightAlt).toContain('alt="Copyright 2026 Acme"');
|
|
2376
|
+
|
|
2377
|
+
const withBrandAlt = buildFooter(
|
|
2378
|
+
baseProvenance,
|
|
2379
|
+
{
|
|
2380
|
+
...baseConfig,
|
|
2381
|
+
footer: { logo: "assets/brand/footer-logo.png" },
|
|
2382
|
+
},
|
|
2383
|
+
"./",
|
|
2384
|
+
);
|
|
2385
|
+
expect(withBrandAlt).toContain('alt="Acme Corp"');
|
|
2386
|
+
});
|
|
2387
|
+
|
|
1688
2388
|
it("uses publish date year in default copyright", () => {
|
|
1689
2389
|
const result = buildFooter(baseProvenance, baseConfig);
|
|
1690
2390
|
expect(result).toContain("© 2024 Acme Corp");
|
|
@@ -1751,6 +2451,24 @@ describe("buildBundleFooter", () => {
|
|
|
1751
2451
|
expect(result).toContain("<em>");
|
|
1752
2452
|
expect(result).not.toContain("<em>");
|
|
1753
2453
|
});
|
|
2454
|
+
|
|
2455
|
+
it("renders footer logo in bundle mode with override source", () => {
|
|
2456
|
+
const result = buildBundleFooter(
|
|
2457
|
+
"0.1.1",
|
|
2458
|
+
{
|
|
2459
|
+
...baseConfig,
|
|
2460
|
+
footer: {
|
|
2461
|
+
logo: "assets/brand/footer-logo.png",
|
|
2462
|
+
logoUrl: "https://footer.example.com",
|
|
2463
|
+
logoHeight: 18,
|
|
2464
|
+
},
|
|
2465
|
+
},
|
|
2466
|
+
"data:image/png;base64,AAAA",
|
|
2467
|
+
);
|
|
2468
|
+
expect(result).toContain('src="data:image/png;base64,AAAA"');
|
|
2469
|
+
expect(result).toContain('href="https://footer.example.com"');
|
|
2470
|
+
expect(result).toContain("max-height: 18px");
|
|
2471
|
+
});
|
|
1754
2472
|
});
|
|
1755
2473
|
|
|
1756
2474
|
// ---------------------------------------------------------------------------
|