kitfly 0.2.0 → 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 +68 -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/content/reference/slides-authoring-guidelines.md +129 -0
- package/dist/_raw/content/reference.md +1 -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 +422 -0
- package/dist/content/reference/structure.html +10 -6
- package/dist/content/reference.html +11 -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 +38 -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/plugins-dist/slides-visuals.css +166 -0
- package/plugins-dist/slides-visuals.js +124 -33
- package/registry/plugins.yaml +30 -5
- package/schemas/v0/site.schema.json +56 -0
- package/scripts/build.ts +195 -70
- package/scripts/bundle.ts +122 -11
- package/scripts/dev.ts +345 -178
- package/src/__tests__/brief.test.ts +151 -0
- package/src/__tests__/build.test.ts +234 -4
- package/src/__tests__/bundle.test.ts +134 -0
- package/src/__tests__/dev-plugin-errors.test.ts +20 -0
- package/src/__tests__/fixtures/fences/slides-visuals/invalid/flow-branching-no-source.md +5 -0
- package/src/__tests__/fixtures/fences/slides-visuals/invalid/flow-converging-no-target.md +6 -0
- package/src/__tests__/fixtures/fences/slides-visuals/invalid/staircase-empty-steps.md +3 -0
- package/src/__tests__/fixtures/fences/slides-visuals/invalid/timeline-horizontal-no-events.md +2 -0
- package/src/__tests__/fixtures/fences/slides-visuals/valid/flow-branching-no-split.md +7 -0
- package/src/__tests__/fixtures/fences/slides-visuals/valid/flow-branching.md +8 -0
- package/src/__tests__/fixtures/fences/slides-visuals/valid/flow-converging-no-merge.md +7 -0
- package/src/__tests__/fixtures/fences/slides-visuals/valid/flow-converging.md +8 -0
- package/src/__tests__/fixtures/fences/slides-visuals/valid/staircase-down.md +7 -0
- package/src/__tests__/fixtures/fences/slides-visuals/valid/staircase.md +8 -0
- package/src/__tests__/fixtures/fences/slides-visuals/valid/timeline-horizontal.md +9 -0
- package/src/__tests__/fixtures/fences/slides-visuals/valid/timeline-vertical.md +10 -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 +621 -1
- package/src/__tests__/slides-charts-lite-runtime.bun.test.ts +45 -0
- package/src/__tests__/slides-visuals-runtime-regressions.bun.test.ts +33 -0
- package/src/cli.ts +11 -4
- package/src/commands/init.ts +1 -1
- package/src/shared.ts +761 -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,15 +25,22 @@ import {
|
|
|
24
25
|
envString,
|
|
25
26
|
escapeHtml,
|
|
26
27
|
exists,
|
|
28
|
+
filterByProfile,
|
|
29
|
+
filterUnknownSlidesVisualsTypeDiagnostics,
|
|
27
30
|
formatDate,
|
|
28
31
|
generateProvenance,
|
|
29
32
|
getGitInfo,
|
|
30
33
|
KITFLY_BRAND,
|
|
34
|
+
loadDataBindings,
|
|
31
35
|
loadSiteConfig,
|
|
36
|
+
mergeFrontmatterWithBody,
|
|
37
|
+
normalizeProfileTags,
|
|
32
38
|
type Provenance,
|
|
39
|
+
pagePathForData,
|
|
33
40
|
parseFrontmatter,
|
|
34
41
|
parseValue,
|
|
35
42
|
parseYaml,
|
|
43
|
+
resolveBindings,
|
|
36
44
|
resolveSiteVersion,
|
|
37
45
|
rewriteRelativeAssetUrls,
|
|
38
46
|
type SiteConfig,
|
|
@@ -42,6 +50,7 @@ import {
|
|
|
42
50
|
stripQuotes,
|
|
43
51
|
toUrlPath,
|
|
44
52
|
validatePath,
|
|
53
|
+
validateSlidesVisualsFences,
|
|
45
54
|
} from "../shared.ts";
|
|
46
55
|
|
|
47
56
|
describe("slugify", () => {
|
|
@@ -102,6 +111,271 @@ description: A test page
|
|
|
102
111
|
});
|
|
103
112
|
});
|
|
104
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
|
+
|
|
105
379
|
describe("splitSlides", () => {
|
|
106
380
|
it("splits markdown on explicit slide delimiter", () => {
|
|
107
381
|
const input = `# One
|
|
@@ -155,6 +429,27 @@ Still one slide`;
|
|
|
155
429
|
});
|
|
156
430
|
});
|
|
157
431
|
|
|
432
|
+
describe("slides-visuals diagnostics filtering", () => {
|
|
433
|
+
it("drops unknown-type diagnostics while preserving schema violations", () => {
|
|
434
|
+
const markdown = `:::future-thing
|
|
435
|
+
foo: bar
|
|
436
|
+
:::
|
|
437
|
+
|
|
438
|
+
:::kpi
|
|
439
|
+
label: Missing value
|
|
440
|
+
:::`;
|
|
441
|
+
const diagnostics = validateSlidesVisualsFences(markdown);
|
|
442
|
+
const filtered = filterUnknownSlidesVisualsTypeDiagnostics(diagnostics);
|
|
443
|
+
expect(
|
|
444
|
+
diagnostics.some((d) => d.message.startsWith("Unknown slides-visuals block type:")),
|
|
445
|
+
).toBe(true);
|
|
446
|
+
expect(filtered.some((d) => d.message.startsWith("Unknown slides-visuals block type:"))).toBe(
|
|
447
|
+
false,
|
|
448
|
+
);
|
|
449
|
+
expect(filtered.some((d) => d.message.includes("Missing required key: value"))).toBe(true);
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
|
|
158
453
|
describe("segmentSlides", () => {
|
|
159
454
|
it("uses frontmatter title and class when present", () => {
|
|
160
455
|
const input = `---
|
|
@@ -325,6 +620,140 @@ title: Intro
|
|
|
325
620
|
expect(nav).toContain('<a href="#slide-2" class="active">Slide B</a>');
|
|
326
621
|
expect(nav).toContain('<span class="nav-section">Slides</span>');
|
|
327
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
|
+
});
|
|
328
757
|
});
|
|
329
758
|
|
|
330
759
|
describe("rewriteRelativeAssetUrls", () => {
|
|
@@ -509,6 +938,78 @@ description: 'A test site'`;
|
|
|
509
938
|
expect(result.tags).toEqual(["javascript", "typescript"]);
|
|
510
939
|
});
|
|
511
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
|
+
|
|
512
1013
|
it("returns empty object for empty input", () => {
|
|
513
1014
|
const result = parseYaml("");
|
|
514
1015
|
expect(result).toEqual({});
|
|
@@ -1099,12 +1600,18 @@ version: "1.2.0"
|
|
|
1099
1600
|
brand:
|
|
1100
1601
|
name: Test
|
|
1101
1602
|
url: /
|
|
1603
|
+
logoDark: assets/brand/logo-dark.png
|
|
1102
1604
|
sections:
|
|
1103
1605
|
- name: Guide
|
|
1104
1606
|
path: guide
|
|
1105
1607
|
footer:
|
|
1106
1608
|
copyright: "© 2026 Test"
|
|
1107
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
|
|
1108
1615
|
links:
|
|
1109
1616
|
- text: Privacy
|
|
1110
1617
|
url: /privacy
|
|
@@ -1114,14 +1621,45 @@ footer:
|
|
|
1114
1621
|
|
|
1115
1622
|
const config = await loadSiteConfig(dir);
|
|
1116
1623
|
expect(config.version).toBe("1.2.0");
|
|
1624
|
+
expect(config.brand.logoDark).toBe("assets/brand/logo-dark.png");
|
|
1117
1625
|
expect(config.footer?.copyright).toBe("© 2026 Test");
|
|
1118
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);
|
|
1119
1632
|
expect(config.footer?.links).toEqual([{ text: "Privacy", url: "/privacy" }]);
|
|
1120
1633
|
} finally {
|
|
1121
1634
|
await rm(dir, { recursive: true, force: true });
|
|
1122
1635
|
}
|
|
1123
1636
|
});
|
|
1124
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
|
+
|
|
1125
1663
|
it("truncates footer links to max 10", async () => {
|
|
1126
1664
|
const dir = await mkdtemp(join(tmpdir(), "kitfly-footer-links-"));
|
|
1127
1665
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
@@ -1662,6 +2200,70 @@ describe("buildFooter", () => {
|
|
|
1662
2200
|
expect(result).toContain('class="footer-link"');
|
|
1663
2201
|
});
|
|
1664
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
|
+
|
|
1665
2267
|
it("uses publish date year in default copyright", () => {
|
|
1666
2268
|
const result = buildFooter(baseProvenance, baseConfig);
|
|
1667
2269
|
expect(result).toContain("© 2024 Acme Corp");
|
|
@@ -1728,6 +2330,24 @@ describe("buildBundleFooter", () => {
|
|
|
1728
2330
|
expect(result).toContain("<em>");
|
|
1729
2331
|
expect(result).not.toContain("<em>");
|
|
1730
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
|
+
});
|
|
1731
2351
|
});
|
|
1732
2352
|
|
|
1733
2353
|
// ---------------------------------------------------------------------------
|