@caelo-cms/shared 0.10.22 → 0.10.24
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/dist/ai-tools.d.ts +289 -273
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai-tools.js +342 -323
- package/dist/ai-tools.js.map +1 -1
- package/dist/auth-forms.d.ts.map +1 -1
- package/dist/auth-forms.js +4 -1
- package/dist/auth-forms.js.map +1 -1
- package/dist/base-css.d.ts +35 -0
- package/dist/base-css.d.ts.map +1 -0
- package/dist/base-css.js +40 -0
- package/dist/base-css.js.map +1 -0
- package/dist/build-page.d.ts +330 -0
- package/dist/build-page.d.ts.map +1 -0
- package/dist/build-page.js +282 -0
- package/dist/build-page.js.map +1 -0
- package/dist/content.d.ts +322 -9
- package/dist/content.d.ts.map +1 -1
- package/dist/content.js +354 -11
- package/dist/content.js.map +1 -1
- package/dist/css-gradient-scan.d.ts +14 -0
- package/dist/css-gradient-scan.d.ts.map +1 -0
- package/dist/css-gradient-scan.js +81 -0
- package/dist/css-gradient-scan.js.map +1 -0
- package/dist/css-var-scan.d.ts +56 -0
- package/dist/css-var-scan.d.ts.map +1 -0
- package/dist/css-var-scan.js +97 -0
- package/dist/css-var-scan.js.map +1 -0
- package/dist/design-draft-shell.d.ts +21 -0
- package/dist/design-draft-shell.d.ts.map +1 -0
- package/dist/design-draft-shell.js +81 -0
- package/dist/design-draft-shell.js.map +1 -0
- package/dist/design-manifest.d.ts +36 -0
- package/dist/design-manifest.d.ts.map +1 -0
- package/dist/design-manifest.js +90 -0
- package/dist/design-manifest.js.map +1 -0
- package/dist/fonts.d.ts +89 -0
- package/dist/fonts.d.ts.map +1 -0
- package/dist/fonts.js +241 -0
- package/dist/fonts.js.map +1 -0
- package/dist/genesis-inventory.d.ts +32 -0
- package/dist/genesis-inventory.d.ts.map +1 -0
- package/dist/genesis-inventory.js +186 -0
- package/dist/genesis-inventory.js.map +1 -0
- package/dist/genesis.d.ts +102 -0
- package/dist/genesis.d.ts.map +1 -0
- package/dist/genesis.js +145 -0
- package/dist/genesis.js.map +1 -0
- package/dist/i18n.d.ts +29 -36
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +53 -128
- package/dist/i18n.js.map +1 -1
- package/dist/index.d.ts +28 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +28 -1
- package/dist/index.js.map +1 -1
- package/dist/interactions.d.ts +23 -0
- package/dist/interactions.d.ts.map +1 -0
- package/dist/interactions.js +44 -0
- package/dist/interactions.js.map +1 -0
- package/dist/media.d.ts +101 -16
- package/dist/media.d.ts.map +1 -1
- package/dist/media.js +126 -15
- package/dist/media.js.map +1 -1
- package/dist/page-log.d.ts +94 -0
- package/dist/page-log.d.ts.map +1 -0
- package/dist/page-log.js +111 -0
- package/dist/page-log.js.map +1 -0
- package/dist/preview-compose.d.ts +85 -12
- package/dist/preview-compose.d.ts.map +1 -1
- package/dist/preview-compose.js +157 -67
- package/dist/preview-compose.js.map +1 -1
- package/dist/proposal-status.d.ts +40 -0
- package/dist/proposal-status.d.ts.map +1 -0
- package/dist/proposal-status.js +34 -0
- package/dist/proposal-status.js.map +1 -0
- package/dist/responsive-images.d.ts +64 -0
- package/dist/responsive-images.d.ts.map +1 -0
- package/dist/responsive-images.js +98 -0
- package/dist/responsive-images.js.map +1 -0
- package/dist/safe-keys.d.ts +9 -0
- package/dist/safe-keys.d.ts.map +1 -0
- package/dist/safe-keys.js +20 -0
- package/dist/safe-keys.js.map +1 -0
- package/dist/seo.d.ts +11 -16
- package/dist/seo.d.ts.map +1 -1
- package/dist/seo.js +7 -23
- package/dist/seo.js.map +1 -1
- package/dist/skills.d.ts +14 -68
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +19 -113
- package/dist/skills.js.map +1 -1
- package/dist/strip-cdata.d.ts +7 -0
- package/dist/strip-cdata.d.ts.map +1 -0
- package/dist/strip-cdata.js +48 -0
- package/dist/strip-cdata.js.map +1 -0
- package/dist/structured-sets.d.ts +6 -52
- package/dist/structured-sets.d.ts.map +1 -1
- package/dist/structured-sets.js +6 -68
- package/dist/structured-sets.js.map +1 -1
- package/dist/subagents.d.ts +105 -3
- package/dist/subagents.d.ts.map +1 -1
- package/dist/subagents.js +224 -41
- package/dist/subagents.js.map +1 -1
- package/dist/template-engine.d.ts +85 -0
- package/dist/template-engine.d.ts.map +1 -0
- package/dist/template-engine.js +403 -0
- package/dist/template-engine.js.map +1 -0
- package/dist/theme-importers/auto-detect.d.ts +26 -0
- package/dist/theme-importers/auto-detect.d.ts.map +1 -0
- package/dist/theme-importers/auto-detect.js +42 -0
- package/dist/theme-importers/auto-detect.js.map +1 -0
- package/dist/theme-importers/css-comments.d.ts +12 -0
- package/dist/theme-importers/css-comments.d.ts.map +1 -0
- package/dist/theme-importers/css-comments.js +15 -0
- package/dist/theme-importers/css-comments.js.map +1 -0
- package/dist/theme-importers/dtcg.d.ts +46 -0
- package/dist/theme-importers/dtcg.d.ts.map +1 -0
- package/dist/theme-importers/dtcg.js +111 -0
- package/dist/theme-importers/dtcg.js.map +1 -0
- package/dist/theme-importers/loose.d.ts +3 -0
- package/dist/theme-importers/loose.d.ts.map +1 -0
- package/dist/theme-importers/loose.js +76 -0
- package/dist/theme-importers/loose.js.map +1 -0
- package/dist/theme-importers/shadcn.d.ts +24 -0
- package/dist/theme-importers/shadcn.d.ts.map +1 -0
- package/dist/theme-importers/shadcn.js +135 -0
- package/dist/theme-importers/shadcn.js.map +1 -0
- package/dist/theme-importers/style-dictionary.d.ts +17 -0
- package/dist/theme-importers/style-dictionary.d.ts.map +1 -0
- package/dist/theme-importers/style-dictionary.js +125 -0
- package/dist/theme-importers/style-dictionary.js.map +1 -0
- package/dist/theme-importers/tailwind.d.ts +3 -0
- package/dist/theme-importers/tailwind.d.ts.map +1 -0
- package/dist/theme-importers/tailwind.js +218 -0
- package/dist/theme-importers/tailwind.js.map +1 -0
- package/dist/theme-literal-binding.d.ts +37 -0
- package/dist/theme-literal-binding.d.ts.map +1 -0
- package/dist/theme-literal-binding.js +138 -0
- package/dist/theme-literal-binding.js.map +1 -0
- package/dist/theme-normalize.d.ts +31 -0
- package/dist/theme-normalize.d.ts.map +1 -0
- package/dist/theme-normalize.js +587 -0
- package/dist/theme-normalize.js.map +1 -0
- package/dist/theme-ramp.d.ts +55 -0
- package/dist/theme-ramp.d.ts.map +1 -0
- package/dist/theme-ramp.js +149 -0
- package/dist/theme-ramp.js.map +1 -0
- package/dist/theme-render.d.ts +105 -0
- package/dist/theme-render.d.ts.map +1 -0
- package/dist/theme-render.js +441 -0
- package/dist/theme-render.js.map +1 -0
- package/dist/themes-errors.d.ts +109 -0
- package/dist/themes-errors.d.ts.map +1 -0
- package/dist/themes-errors.js +170 -0
- package/dist/themes-errors.js.map +1 -0
- package/dist/themes.d.ts +343 -0
- package/dist/themes.d.ts.map +1 -0
- package/dist/themes.js +697 -0
- package/dist/themes.js.map +1 -0
- package/dist/version.d.ts +7 -4
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +6 -3
- package/dist/version.js.map +1 -1
- package/package.json +10 -2
- package/src/__tests__/redos-hardening.test.ts +160 -0
- package/src/ai-tools-add-module-modes.test.ts +106 -0
- package/src/ai-tools-position.test.ts +134 -0
- package/src/ai-tools.test.ts +81 -0
- package/src/ai-tools.ts +1105 -0
- package/src/auth-forms.ts +36 -0
- package/src/base-css.ts +42 -0
- package/src/build-page.test.ts +228 -0
- package/src/build-page.ts +319 -0
- package/src/cap-failures.ts +67 -0
- package/src/content.test.ts +170 -0
- package/src/content.ts +620 -0
- package/src/context.ts +43 -0
- package/src/css-gradient-scan.ts +88 -0
- package/src/css-var-scan.test.ts +96 -0
- package/src/css-var-scan.ts +144 -0
- package/src/derive-module-type.test.ts +80 -0
- package/src/design-draft-shell.test.ts +85 -0
- package/src/design-draft-shell.ts +109 -0
- package/src/design-manifest.ts +93 -0
- package/src/fonts.test.ts +157 -0
- package/src/fonts.ts +296 -0
- package/src/genesis-inventory.test.ts +86 -0
- package/src/genesis-inventory.ts +215 -0
- package/src/genesis-sanitize.test.ts +35 -0
- package/src/genesis.ts +158 -0
- package/src/i18n.test.ts +58 -0
- package/src/i18n.ts +91 -0
- package/src/index.test.ts +10 -0
- package/src/index.ts +59 -0
- package/src/interactions.ts +48 -0
- package/src/logger.ts +147 -0
- package/src/media.test.ts +160 -0
- package/src/media.ts +355 -0
- package/src/page-log.test.ts +163 -0
- package/src/page-log.ts +124 -0
- package/src/preview-compose.test.ts +637 -0
- package/src/preview-compose.ts +656 -0
- package/src/preview-scanner.test.ts +96 -0
- package/src/preview-scanner.ts +214 -0
- package/src/proposal-status.test.ts +69 -0
- package/src/proposal-status.ts +40 -0
- package/src/responsive-images.test.ts +104 -0
- package/src/responsive-images.ts +151 -0
- package/src/result.ts +29 -0
- package/src/safe-keys.ts +21 -0
- package/src/seo.test.ts +194 -0
- package/src/seo.ts +233 -0
- package/src/skills.ts +48 -0
- package/src/snapshots.test.ts +80 -0
- package/src/snapshots.ts +81 -0
- package/src/strip-cdata.test.ts +41 -0
- package/src/strip-cdata.ts +50 -0
- package/src/structured-sets.ts +114 -0
- package/src/subagents.test.ts +262 -0
- package/src/subagents.ts +432 -0
- package/src/template-engine.test.ts +379 -0
- package/src/template-engine.ts +520 -0
- package/src/theme-gradient.test.ts +92 -0
- package/src/theme-importers/__tests__/proto-pollution.test.ts +54 -0
- package/src/theme-importers/auto-detect.ts +84 -0
- package/src/theme-importers/css-comments.ts +15 -0
- package/src/theme-importers/dtcg.ts +106 -0
- package/src/theme-importers/loose.ts +76 -0
- package/src/theme-importers/shadcn.ts +133 -0
- package/src/theme-importers/style-dictionary.ts +125 -0
- package/src/theme-importers/tailwind.ts +217 -0
- package/src/theme-literal-binding.test.ts +71 -0
- package/src/theme-literal-binding.ts +159 -0
- package/src/theme-motion.test.ts +115 -0
- package/src/theme-normalize-envelope.test.ts +43 -0
- package/src/theme-normalize-gradient.test.ts +135 -0
- package/src/theme-normalize.ts +661 -0
- package/src/theme-ramp.ts +187 -0
- package/src/theme-render-sanitize.test.ts +45 -0
- package/src/theme-render.test.ts +119 -0
- package/src/theme-render.ts +487 -0
- package/src/theme-shadow.test.ts +56 -0
- package/src/themes-errors.ts +199 -0
- package/src/themes.ts +842 -0
- package/src/version.ts +66 -0
- package/dist/translation.d.ts +0 -127
- package/dist/translation.d.ts.map +0 -1
- package/dist/translation.js +0 -208
- package/dist/translation.js.map +0 -1
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* issue #165 — the Design Manifest: a generated, versioned description
|
|
5
|
+
* of THIS site's design system (epic #149, growth-time convergence).
|
|
6
|
+
*
|
|
7
|
+
* The operator requirement is asymmetric: pages of the SAME site share
|
|
8
|
+
* one visual line, while different sites are free to look nothing
|
|
9
|
+
* alike. A global stylesheet solves the first at the cost of the
|
|
10
|
+
* second; the per-site manifest solves both — it captures what this
|
|
11
|
+
* site's tokens/patterns MEAN so the AI converges toward its own
|
|
12
|
+
* design, not a Caelo default.
|
|
13
|
+
*
|
|
14
|
+
* Written at Genesis materialisation (or curated later via chat);
|
|
15
|
+
* rendered as the `## Design system` system-prompt block.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
|
|
20
|
+
export const designManifestPattern = z
|
|
21
|
+
.object({
|
|
22
|
+
/** Pattern name the AI reasons in ("hero", "card grid", "cta band"). */
|
|
23
|
+
name: z.string().min(1).max(80),
|
|
24
|
+
/** Stable module `type` implementing it — reuse target (#159 place mode). */
|
|
25
|
+
moduleType: z.string().max(64).optional(),
|
|
26
|
+
/** One-line spec: what makes this pattern THIS site's version of it. */
|
|
27
|
+
spec: z.string().min(1).max(500),
|
|
28
|
+
})
|
|
29
|
+
.strict();
|
|
30
|
+
|
|
31
|
+
export const designManifestSchema = z
|
|
32
|
+
.object({
|
|
33
|
+
/** `--color-primary` → "CTAs and links only" — usage semantics per var. */
|
|
34
|
+
tokenRoles: z.record(z.string().min(3).max(64), z.string().min(1).max(300)).optional(),
|
|
35
|
+
/** Scale ratio, casing, measure rules. */
|
|
36
|
+
typography: z.string().min(1).max(1000).optional(),
|
|
37
|
+
/** Section padding, container width, grid gaps. */
|
|
38
|
+
rhythm: z.string().min(1).max(1000).optional(),
|
|
39
|
+
patterns: z.array(designManifestPattern).max(24).optional(),
|
|
40
|
+
imagery: z.string().min(1).max(500).optional(),
|
|
41
|
+
avoid: z.string().min(1).max(500).optional(),
|
|
42
|
+
})
|
|
43
|
+
.strict();
|
|
44
|
+
export type DesignManifest = z.infer<typeof designManifestSchema>;
|
|
45
|
+
|
|
46
|
+
/** Render the `## Design system` prompt block. Null when empty. */
|
|
47
|
+
export function formatDesignSystemBlock(manifest: DesignManifest | null): string | null {
|
|
48
|
+
if (manifest === null) return null;
|
|
49
|
+
const lines: string[] = [
|
|
50
|
+
"## Design system",
|
|
51
|
+
"",
|
|
52
|
+
"THIS site's own design language — derived from the chosen design. Every module you author or restyle conforms to it unless the operator explicitly asks to break the line. Reuse the pattern's module type (place mode) before minting lookalikes.",
|
|
53
|
+
"",
|
|
54
|
+
];
|
|
55
|
+
let hasContent = false;
|
|
56
|
+
const roles = manifest.tokenRoles ?? {};
|
|
57
|
+
const roleEntries = Object.entries(roles);
|
|
58
|
+
if (roleEntries.length > 0) {
|
|
59
|
+
hasContent = true;
|
|
60
|
+
lines.push("Token roles:");
|
|
61
|
+
for (const [token, role] of roleEntries.slice(0, 24)) lines.push(`- \`${token}\` — ${role}`);
|
|
62
|
+
lines.push("");
|
|
63
|
+
}
|
|
64
|
+
if (manifest.typography) {
|
|
65
|
+
hasContent = true;
|
|
66
|
+
lines.push(`Typography: ${manifest.typography}`, "");
|
|
67
|
+
}
|
|
68
|
+
if (manifest.rhythm) {
|
|
69
|
+
hasContent = true;
|
|
70
|
+
lines.push(`Rhythm: ${manifest.rhythm}`, "");
|
|
71
|
+
}
|
|
72
|
+
if (manifest.patterns && manifest.patterns.length > 0) {
|
|
73
|
+
hasContent = true;
|
|
74
|
+
lines.push("Patterns:");
|
|
75
|
+
for (const p of manifest.patterns) {
|
|
76
|
+
lines.push(
|
|
77
|
+
`- **${p.name}**${p.moduleType ? ` (module type \`${p.moduleType}\`)` : ""} — ${p.spec}`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
lines.push("");
|
|
81
|
+
}
|
|
82
|
+
if (manifest.imagery) {
|
|
83
|
+
hasContent = true;
|
|
84
|
+
lines.push(`Imagery: ${manifest.imagery}`, "");
|
|
85
|
+
}
|
|
86
|
+
if (manifest.avoid) {
|
|
87
|
+
hasContent = true;
|
|
88
|
+
lines.push(`Never: ${manifest.avoid}`, "");
|
|
89
|
+
}
|
|
90
|
+
if (!hasContent) return null;
|
|
91
|
+
while (lines[lines.length - 1] === "") lines.pop();
|
|
92
|
+
return lines.join("\n");
|
|
93
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* issue #150 — unit coverage for the pure web-font helpers: request
|
|
5
|
+
* extraction from typography tokens, system-stack classification, css2
|
|
6
|
+
* payload parsing, self-hosted @font-face emission, preload selection.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from "bun:test";
|
|
10
|
+
import {
|
|
11
|
+
buildFontFaceCss,
|
|
12
|
+
extractThemeFontRequests,
|
|
13
|
+
fontUnresolvableMarker,
|
|
14
|
+
googleFontsCssUrl,
|
|
15
|
+
isSystemFontFamily,
|
|
16
|
+
parseFontsCss,
|
|
17
|
+
type ResolvedFontFace,
|
|
18
|
+
selectPreloadFaces,
|
|
19
|
+
} from "./fonts.js";
|
|
20
|
+
import type { ThemeDocument } from "./themes.js";
|
|
21
|
+
|
|
22
|
+
const TOKENS = {
|
|
23
|
+
typography: {
|
|
24
|
+
body: { $type: "typography", $value: { fontFamily: '"Inter", sans-serif', fontWeight: 400 } },
|
|
25
|
+
heading: {
|
|
26
|
+
$type: "typography",
|
|
27
|
+
$value: { fontFamily: "Playfair Display, serif", fontWeight: 700 },
|
|
28
|
+
},
|
|
29
|
+
emphasis: { $type: "typography", $value: { fontFamily: "Inter", fontWeight: "bold" } },
|
|
30
|
+
mono: { $type: "typography", $value: { fontFamily: "ui-monospace, Menlo, monospace" } },
|
|
31
|
+
aliasing: { $type: "typography", $value: "{typography.body}" },
|
|
32
|
+
},
|
|
33
|
+
} as unknown as ThemeDocument;
|
|
34
|
+
|
|
35
|
+
describe("extractThemeFontRequests (issue #150)", () => {
|
|
36
|
+
it("collects web families with merged weights + roles, skips system stacks and aliases", () => {
|
|
37
|
+
const reqs = extractThemeFontRequests(TOKENS);
|
|
38
|
+
const byFamily = new Map(reqs.map((r) => [r.family, r]));
|
|
39
|
+
expect([...byFamily.keys()].sort()).toEqual(["Inter", "Playfair Display"]);
|
|
40
|
+
// 400 from body + bold→700 from emphasis, merged and sorted.
|
|
41
|
+
expect(byFamily.get("Inter")?.weights).toEqual([400, 700]);
|
|
42
|
+
expect(byFamily.get("Inter")?.roles.sort()).toEqual(["body", "emphasis"]);
|
|
43
|
+
expect(byFamily.get("Playfair Display")?.weights).toEqual([700]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("defaults weights when the theme declares none", () => {
|
|
47
|
+
const reqs = extractThemeFontRequests({
|
|
48
|
+
typography: { body: { $value: { fontFamily: "Poppins" } } },
|
|
49
|
+
} as unknown as ThemeDocument);
|
|
50
|
+
expect(reqs[0]?.weights).toEqual([400, 700]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("returns [] for token documents without typography", () => {
|
|
54
|
+
expect(extractThemeFontRequests({} as ThemeDocument)).toEqual([]);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("isSystemFontFamily", () => {
|
|
59
|
+
it("classifies generic keywords and OS staples as system", () => {
|
|
60
|
+
for (const f of ["system-ui", "sans-serif", "Georgia", " Menlo ", "SEGOE UI"]) {
|
|
61
|
+
expect(isSystemFontFamily(f)).toBe(true);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
it("classifies hosted-web staples as web fonts", () => {
|
|
65
|
+
for (const f of ["Inter", "Poppins", "Playfair Display", "Roboto"]) {
|
|
66
|
+
expect(isSystemFontFamily(f)).toBe(false);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("googleFontsCssUrl", () => {
|
|
72
|
+
it("encodes the family and joins weights", () => {
|
|
73
|
+
expect(
|
|
74
|
+
googleFontsCssUrl({ family: "Playfair Display", weights: [400, 700], roles: ["heading"] }),
|
|
75
|
+
).toBe("https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700&display=swap");
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const CSS2_FIXTURE = `
|
|
80
|
+
/* latin-ext */
|
|
81
|
+
@font-face {
|
|
82
|
+
font-family: 'Poppins';
|
|
83
|
+
font-style: normal;
|
|
84
|
+
font-weight: 400;
|
|
85
|
+
font-display: swap;
|
|
86
|
+
src: url(https://fonts.gstatic.com/s/poppins/v23/pxiEyp8kv8JHgFVrJJnecmNE.woff2) format('woff2');
|
|
87
|
+
unicode-range: U+0100-02BA;
|
|
88
|
+
}
|
|
89
|
+
/* latin */
|
|
90
|
+
@font-face {
|
|
91
|
+
font-family: 'Poppins';
|
|
92
|
+
font-style: normal;
|
|
93
|
+
font-weight: 700;
|
|
94
|
+
font-display: swap;
|
|
95
|
+
src: url(https://fonts.gstatic.com/s/poppins/v23/pxiByp8kv8JHgFVrLCz7Z1xlEA.woff2) format('woff2');
|
|
96
|
+
unicode-range: U+0000-00FF, U+0131;
|
|
97
|
+
}
|
|
98
|
+
@font-face {
|
|
99
|
+
font-family: 'Poppins';
|
|
100
|
+
font-style: normal;
|
|
101
|
+
font-weight: 400;
|
|
102
|
+
font-display: swap;
|
|
103
|
+
src: url(https://fonts.gstatic.com/s/poppins/v23/pxiEyp8kv8JHgFVrJJfecg.woff2) format('woff2');
|
|
104
|
+
unicode-range: U+0000-00FF, U+0131, U+0152-0153;
|
|
105
|
+
}
|
|
106
|
+
`;
|
|
107
|
+
|
|
108
|
+
describe("parseFontsCss", () => {
|
|
109
|
+
it("parses css2 @font-face blocks into face records", () => {
|
|
110
|
+
const faces = parseFontsCss(CSS2_FIXTURE);
|
|
111
|
+
expect(faces).toHaveLength(3);
|
|
112
|
+
expect(faces[0]).toEqual({
|
|
113
|
+
family: "Poppins",
|
|
114
|
+
style: "normal",
|
|
115
|
+
weight: "400",
|
|
116
|
+
unicodeRange: "U+0100-02BA",
|
|
117
|
+
srcUrl: "https://fonts.gstatic.com/s/poppins/v23/pxiEyp8kv8JHgFVrJJnecmNE.woff2",
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("returns [] on payloads without woff URLs — the caller's loud-failure signal", () => {
|
|
122
|
+
expect(parseFontsCss("body{color:red}")).toEqual([]);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("buildFontFaceCss + selectPreloadFaces", () => {
|
|
127
|
+
const faces: ResolvedFontFace[] = parseFontsCss(CSS2_FIXTURE).map((f, i) => ({
|
|
128
|
+
family: f.family,
|
|
129
|
+
style: f.style,
|
|
130
|
+
weight: f.weight,
|
|
131
|
+
unicodeRange: f.unicodeRange,
|
|
132
|
+
publicUrl: `/_assets/fonts/poppins/${i}.woff2`,
|
|
133
|
+
}));
|
|
134
|
+
|
|
135
|
+
it("emits self-hosted @font-face with font-display: swap", () => {
|
|
136
|
+
const css = buildFontFaceCss(faces);
|
|
137
|
+
expect(css).toContain('font-family:"Poppins"');
|
|
138
|
+
expect(css).toContain("font-display:swap");
|
|
139
|
+
expect(css).toContain("url(/_assets/fonts/poppins/0.woff2) format('woff2')");
|
|
140
|
+
expect(css).toContain("unicode-range:U+0100-02BA;");
|
|
141
|
+
expect(css).not.toContain("fonts.gstatic.com");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("preloads one latin normal-style face per family at the lowest weight", () => {
|
|
145
|
+
const picks = selectPreloadFaces(faces);
|
|
146
|
+
// Latin faces are weight 700 (index 1) and 400 (index 2) → lowest is 400.
|
|
147
|
+
expect(picks).toHaveLength(1);
|
|
148
|
+
expect(picks[0]?.weight).toBe("400");
|
|
149
|
+
expect(picks[0]?.publicUrl).toBe("/_assets/fonts/poppins/2.woff2");
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("fontUnresolvableMarker", () => {
|
|
154
|
+
it("matches the theme-asset-unbound marker convention", () => {
|
|
155
|
+
expect(fontUnresolvableMarker("Poppins")).toBe("theme-font-unresolvable:Poppins");
|
|
156
|
+
});
|
|
157
|
+
});
|
package/src/fonts.ts
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* issue #150 — web-font resolution primitives.
|
|
5
|
+
*
|
|
6
|
+
* Theme typography tokens name font families (`--font-heading:
|
|
7
|
+
* "Poppins"`), but until #150 nothing delivered the font files to
|
|
8
|
+
* rendered pages — every site silently fell back to system fonts, the
|
|
9
|
+
* exact silent-degradation CLAUDE.md §2 forbids. These pure helpers are
|
|
10
|
+
* the shared half of the fix: extract the families a theme actually
|
|
11
|
+
* uses, classify system stacks (nothing to deliver) vs web fonts
|
|
12
|
+
* (self-hosted at deploy — hotlinking fonts.googleapis.com is both a
|
|
13
|
+
* GDPR liability, LG München I 3 O 17493/20, and a third-party request
|
|
14
|
+
* on the critical path), and build/parse the CSS involved.
|
|
15
|
+
*
|
|
16
|
+
* Network + disk live in `@caelo-cms/admin-core`'s font resolver; the
|
|
17
|
+
* static generator and the preview op share that resolver so preview
|
|
18
|
+
* and production render the same `@font-face` set (parity contract,
|
|
19
|
+
* apps/static-generator/src/generate.ts).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { ThemeDocument } from "./themes.js";
|
|
23
|
+
|
|
24
|
+
/** One family the theme needs delivered, with the weights it uses. */
|
|
25
|
+
export interface FontRequest {
|
|
26
|
+
/** Primary family name, unquoted (`Poppins`), first entry of the stack. */
|
|
27
|
+
readonly family: string;
|
|
28
|
+
/** Distinct numeric weights, ascending. Always non-empty. */
|
|
29
|
+
readonly weights: readonly number[];
|
|
30
|
+
/** Typography token names that reference the family (`body`, `heading`). */
|
|
31
|
+
readonly roles: readonly string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A single @font-face declaration parsed from a fonts-CSS payload. */
|
|
35
|
+
export interface ParsedFontFace {
|
|
36
|
+
readonly family: string;
|
|
37
|
+
readonly style: string;
|
|
38
|
+
readonly weight: string;
|
|
39
|
+
readonly unicodeRange: string | null;
|
|
40
|
+
/** Remote woff2 URL as served by the fonts source. */
|
|
41
|
+
readonly srcUrl: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A parsed face whose bytes now live at a self-hosted public URL. */
|
|
45
|
+
export interface ResolvedFontFace extends Omit<ParsedFontFace, "srcUrl"> {
|
|
46
|
+
readonly publicUrl: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Families that ship with operating systems — nothing to download, no
|
|
51
|
+
* @font-face to emit. Generic CSS keywords included. Lowercased for
|
|
52
|
+
* case-insensitive membership tests.
|
|
53
|
+
*/
|
|
54
|
+
const SYSTEM_FONT_FAMILIES: ReadonlySet<string> = new Set(
|
|
55
|
+
[
|
|
56
|
+
// Generic keywords + UI stacks
|
|
57
|
+
"serif",
|
|
58
|
+
"sans-serif",
|
|
59
|
+
"monospace",
|
|
60
|
+
"cursive",
|
|
61
|
+
"fantasy",
|
|
62
|
+
"system-ui",
|
|
63
|
+
"ui-sans-serif",
|
|
64
|
+
"ui-serif",
|
|
65
|
+
"ui-monospace",
|
|
66
|
+
"ui-rounded",
|
|
67
|
+
"-apple-system",
|
|
68
|
+
"blinkmacsystemfont",
|
|
69
|
+
// Cross-platform classics
|
|
70
|
+
"arial",
|
|
71
|
+
"arial black",
|
|
72
|
+
"helvetica",
|
|
73
|
+
"helvetica neue",
|
|
74
|
+
"georgia",
|
|
75
|
+
"times",
|
|
76
|
+
"times new roman",
|
|
77
|
+
"courier",
|
|
78
|
+
"courier new",
|
|
79
|
+
"verdana",
|
|
80
|
+
"tahoma",
|
|
81
|
+
"trebuchet ms",
|
|
82
|
+
"impact",
|
|
83
|
+
"palatino",
|
|
84
|
+
"garamond",
|
|
85
|
+
// Platform UI/mono staples browsers resolve locally
|
|
86
|
+
"segoe ui",
|
|
87
|
+
"sf pro",
|
|
88
|
+
"sf pro text",
|
|
89
|
+
"sf pro display",
|
|
90
|
+
"sf mono",
|
|
91
|
+
"sfmono-regular",
|
|
92
|
+
"menlo",
|
|
93
|
+
"monaco",
|
|
94
|
+
"consolas",
|
|
95
|
+
"lucida console",
|
|
96
|
+
"lucida sans",
|
|
97
|
+
].map((f) => f.toLowerCase()),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
/** True when the family resolves locally on visitors' machines. */
|
|
101
|
+
export function isSystemFontFamily(family: string): boolean {
|
|
102
|
+
return SYSTEM_FONT_FAMILIES.has(family.trim().toLowerCase());
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Strip quotes + whitespace from one family in a font stack. */
|
|
106
|
+
function cleanFamilyName(raw: string): string {
|
|
107
|
+
return raw
|
|
108
|
+
.trim()
|
|
109
|
+
.replace(/^["']|["']$/g, "")
|
|
110
|
+
.trim();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const KEYWORD_WEIGHTS: Record<string, number> = {
|
|
114
|
+
normal: 400,
|
|
115
|
+
bold: 700,
|
|
116
|
+
// Relative keywords have no absolute value; anchor to the common pair
|
|
117
|
+
// so the request still covers a usable range.
|
|
118
|
+
lighter: 300,
|
|
119
|
+
bolder: 700,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const DEFAULT_WEIGHTS = [400, 700] as const;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Walk `typography.*` composites and collect every non-system family
|
|
126
|
+
* with the weights the theme declares for it. Alias entries (`$value`
|
|
127
|
+
* as `{other.token}` string) are skipped — they point at a concrete
|
|
128
|
+
* composite that is collected on its own.
|
|
129
|
+
*
|
|
130
|
+
* Only the FIRST family of a declared stack is requested: the rest of
|
|
131
|
+
* the stack is the author's fallback chain, which by definition is not
|
|
132
|
+
* delivered.
|
|
133
|
+
*/
|
|
134
|
+
export function extractThemeFontRequests(tokens: ThemeDocument): FontRequest[] {
|
|
135
|
+
const typography = (tokens as Record<string, unknown>).typography;
|
|
136
|
+
if (typography === undefined || typography === null || typeof typography !== "object") {
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
const byFamily = new Map<string, { weights: Set<number>; roles: string[] }>();
|
|
140
|
+
for (const [role, entry] of Object.entries(typography as Record<string, unknown>)) {
|
|
141
|
+
if (entry === null || typeof entry !== "object") continue;
|
|
142
|
+
const value = (entry as { $value?: unknown }).$value;
|
|
143
|
+
if (value === null || typeof value !== "object") continue; // alias or malformed
|
|
144
|
+
const composite = value as { fontFamily?: unknown; fontWeight?: unknown };
|
|
145
|
+
if (typeof composite.fontFamily !== "string") continue;
|
|
146
|
+
const primary = cleanFamilyName(composite.fontFamily.split(",")[0] ?? "");
|
|
147
|
+
if (primary === "" || isSystemFontFamily(primary)) continue;
|
|
148
|
+
|
|
149
|
+
const bucket = byFamily.get(primary) ?? { weights: new Set<number>(), roles: [] };
|
|
150
|
+
if (typeof composite.fontWeight === "number") {
|
|
151
|
+
bucket.weights.add(composite.fontWeight);
|
|
152
|
+
} else if (typeof composite.fontWeight === "string") {
|
|
153
|
+
const mapped = KEYWORD_WEIGHTS[composite.fontWeight];
|
|
154
|
+
if (mapped !== undefined) bucket.weights.add(mapped);
|
|
155
|
+
}
|
|
156
|
+
bucket.roles.push(role);
|
|
157
|
+
byFamily.set(primary, bucket);
|
|
158
|
+
}
|
|
159
|
+
return [...byFamily.entries()].map(([family, b]) => ({
|
|
160
|
+
family,
|
|
161
|
+
weights:
|
|
162
|
+
b.weights.size > 0
|
|
163
|
+
? [...b.weights].sort((a, z) => a - z)
|
|
164
|
+
: ([...DEFAULT_WEIGHTS] as number[]),
|
|
165
|
+
roles: b.roles,
|
|
166
|
+
}));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** css2 request URL for one family (woff2 negotiated via the UA header). */
|
|
170
|
+
export function googleFontsCssUrl(request: FontRequest): string {
|
|
171
|
+
const family = encodeURIComponent(request.family).replace(/%20/g, "+");
|
|
172
|
+
const weights = request.weights.join(";");
|
|
173
|
+
return `https://fonts.googleapis.com/css2?family=${family}:wght@${weights}&display=swap`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* `[^{}]*` (not `[^}]*`): the char class excluding BOTH braces makes the
|
|
178
|
+
* pattern unambiguous — on adversarial payloads full of unclosed
|
|
179
|
+
* `@font-face{` starts, a failed match aborts at the next `{` instead of
|
|
180
|
+
* rescanning to end-of-input per start position (the polynomial-ReDoS
|
|
181
|
+
* shape CodeQL flags; this file parses remotely-served CSS).
|
|
182
|
+
*/
|
|
183
|
+
const FONT_FACE_BLOCK_RE = /@font-face\s*\{([^{}]*)\}/g;
|
|
184
|
+
|
|
185
|
+
/** css2 responses are ~10–50 KB; anything past this is not a fonts CSS. */
|
|
186
|
+
const MAX_FONTS_CSS_BYTES = 1_000_000;
|
|
187
|
+
|
|
188
|
+
function declValue(block: string, prop: string): string | null {
|
|
189
|
+
const m = new RegExp(`${prop}\\s*:\\s*([^;]+);`, "i").exec(block);
|
|
190
|
+
return m ? (m[1] ?? "").trim() : null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Parse a fonts-CSS payload (css2 response shape) into face records.
|
|
195
|
+
* Faces without a woff2/woff URL are dropped; a payload yielding zero
|
|
196
|
+
* faces is the caller's loud-failure signal, not a silent empty set.
|
|
197
|
+
* Oversized payloads short-circuit to [] for the same loud path —
|
|
198
|
+
* a legitimate css2 response is orders of magnitude smaller.
|
|
199
|
+
*/
|
|
200
|
+
export function parseFontsCss(cssText: string): ParsedFontFace[] {
|
|
201
|
+
if (cssText.length > MAX_FONTS_CSS_BYTES) return [];
|
|
202
|
+
const faces: ParsedFontFace[] = [];
|
|
203
|
+
for (const m of cssText.matchAll(FONT_FACE_BLOCK_RE)) {
|
|
204
|
+
const block = m[1] ?? "";
|
|
205
|
+
const familyRaw = declValue(block, "font-family");
|
|
206
|
+
const src = declValue(block, "src");
|
|
207
|
+
if (familyRaw === null || src === null) continue;
|
|
208
|
+
// Capture the whole url(...) argument with an unambiguous class,
|
|
209
|
+
// then check the extension in code — `[^)]+\.woff2?` is the classic
|
|
210
|
+
// ambiguous-suffix polynomial pattern.
|
|
211
|
+
const urlMatch = /url\(([^()]*)\)/i.exec(src);
|
|
212
|
+
if (!urlMatch) continue;
|
|
213
|
+
const srcUrl = (urlMatch[1] ?? "").trim().replace(/^["']|["']$/g, "");
|
|
214
|
+
const lower = srcUrl.toLowerCase();
|
|
215
|
+
if (!(lower.endsWith(".woff2") || lower.endsWith(".woff"))) continue;
|
|
216
|
+
faces.push({
|
|
217
|
+
family: cleanFamilyName(familyRaw),
|
|
218
|
+
style: declValue(block, "font-style") ?? "normal",
|
|
219
|
+
weight: declValue(block, "font-weight") ?? "400",
|
|
220
|
+
unicodeRange: declValue(block, "unicode-range"),
|
|
221
|
+
srcUrl,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
return faces;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Emit self-hosted @font-face CSS. `font-display: swap` always — text
|
|
229
|
+
* must render in the fallback while the woff2 loads.
|
|
230
|
+
*/
|
|
231
|
+
export function buildFontFaceCss(faces: readonly ResolvedFontFace[]): string {
|
|
232
|
+
return faces
|
|
233
|
+
.map((f) => {
|
|
234
|
+
const range = f.unicodeRange !== null ? `unicode-range:${f.unicodeRange};` : "";
|
|
235
|
+
return (
|
|
236
|
+
`@font-face{font-family:${JSON.stringify(f.family)};` +
|
|
237
|
+
`font-style:${f.style};font-weight:${f.weight};font-display:swap;` +
|
|
238
|
+
`src:url(${f.publicUrl}) format('woff2');${range}}`
|
|
239
|
+
);
|
|
240
|
+
})
|
|
241
|
+
.join("\n");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Latin-subset heuristic: css2 marks it with a range starting U+0000. */
|
|
245
|
+
function isLatinFace(f: ResolvedFontFace): boolean {
|
|
246
|
+
return f.unicodeRange === null || f.unicodeRange.toLowerCase().includes("u+0000");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Pick the faces worth a `<link rel="preload">`: one per family — the
|
|
251
|
+
* latin, normal-style face at the lowest declared weight (the body-copy
|
|
252
|
+
* face; heavier display cuts arrive via normal loading). Capped at two
|
|
253
|
+
* families so the preload budget stays sane on multi-family themes.
|
|
254
|
+
*/
|
|
255
|
+
export function selectPreloadFaces(faces: readonly ResolvedFontFace[]): ResolvedFontFace[] {
|
|
256
|
+
const byFamily = new Map<string, ResolvedFontFace[]>();
|
|
257
|
+
for (const f of faces) {
|
|
258
|
+
if (f.style !== "normal" || !isLatinFace(f)) continue;
|
|
259
|
+
const list = byFamily.get(f.family) ?? [];
|
|
260
|
+
list.push(f);
|
|
261
|
+
byFamily.set(f.family, list);
|
|
262
|
+
}
|
|
263
|
+
const picks: ResolvedFontFace[] = [];
|
|
264
|
+
for (const list of byFamily.values()) {
|
|
265
|
+
list.sort((a, z) => Number.parseInt(a.weight, 10) - Number.parseInt(z.weight, 10));
|
|
266
|
+
const first = list[0];
|
|
267
|
+
if (first !== undefined) picks.push(first);
|
|
268
|
+
}
|
|
269
|
+
return picks.slice(0, 2);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Structured marker for a family that could not be resolved. Lands in
|
|
274
|
+
* `missingSlots` (preview, soft) or a thrown deploy error (hard) — the
|
|
275
|
+
* same loud-signal convention as `theme-asset-unbound:<slot>`.
|
|
276
|
+
*/
|
|
277
|
+
export function fontUnresolvableMarker(family: string): string {
|
|
278
|
+
return `theme-font-unresolvable:${family}`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Filesystem/URL-safe slug for a family name. Shared between the font
|
|
283
|
+
* resolver (writes `<cacheDir>/<slug>/…`) and the admin serve route
|
|
284
|
+
* (reads the same path from the URL) so the two can't drift.
|
|
285
|
+
*/
|
|
286
|
+
export function fontFamilySlug(family: string): string {
|
|
287
|
+
// After the collapse pass, dash RUNS no longer exist — at most one
|
|
288
|
+
// leading + one trailing dash remain, so the trim pattern needs no
|
|
289
|
+
// quantifier (a `-+$` here is the polynomial-ReDoS shape on
|
|
290
|
+
// adversarial all-dash input; this runs on library-supplied strings).
|
|
291
|
+
return family
|
|
292
|
+
.trim()
|
|
293
|
+
.toLowerCase()
|
|
294
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
295
|
+
.replace(/^-|-$/g, "");
|
|
296
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* issue #164 — inventory stage unit coverage: style extraction, color
|
|
5
|
+
* usage attribution (including gradient stops), typography + spacing
|
|
6
|
+
* histograms, outline scan, formatting, and the linear-parse budget on
|
|
7
|
+
* adversarial input (ReDoS discipline, issue #113).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it } from "bun:test";
|
|
11
|
+
import {
|
|
12
|
+
extractDraftCss,
|
|
13
|
+
formatGenesisInventory,
|
|
14
|
+
inventoryGenesisDraft,
|
|
15
|
+
} from "./genesis-inventory.js";
|
|
16
|
+
|
|
17
|
+
const DRAFT = `<!doctype html><html><head><style>
|
|
18
|
+
body { font-family: "Inter", sans-serif; color: #0f172a; background: #ffffff; margin: 0; }
|
|
19
|
+
h1, h2 { font-family: "Playfair Display", serif; }
|
|
20
|
+
h1 { font-size: clamp(2.5rem, 6vw, 4rem); }
|
|
21
|
+
.hero { background: linear-gradient(135deg, #4f46e5, #7c3aed); padding: 6rem 2rem; color: #ffffff; }
|
|
22
|
+
.card { background: #ffffff; border-radius: 12px; box-shadow: 0 4px 24px rgba(15,23,42,0.08); padding: 2rem; }
|
|
23
|
+
.cta { background: #4f46e5; color: #ffffff; padding: 0.75rem 1.5rem; border-radius: 9999px; }
|
|
24
|
+
.section-alt { background: #f5f3ff; padding: 6rem 2rem; }
|
|
25
|
+
</style></head>
|
|
26
|
+
<body>
|
|
27
|
+
<header><nav>Brand</nav></header>
|
|
28
|
+
<section class="hero"><h1>Bold headline for the brand</h1></section>
|
|
29
|
+
<section class="section-alt"><h2>What we do</h2></section>
|
|
30
|
+
<footer>© Brand</footer>
|
|
31
|
+
</body></html>`;
|
|
32
|
+
|
|
33
|
+
describe("inventoryGenesisDraft (issue #164)", () => {
|
|
34
|
+
const inv = inventoryGenesisDraft(DRAFT);
|
|
35
|
+
|
|
36
|
+
it("extracts style blocks linearly", () => {
|
|
37
|
+
expect(extractDraftCss(DRAFT)).toContain("linear-gradient");
|
|
38
|
+
expect(extractDraftCss("<p>no styles</p>")).toBe("");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("attributes colors to properties with counts, including gradient stops", () => {
|
|
42
|
+
const primary = inv.colors.find((c) => c.value === "#4f46e5");
|
|
43
|
+
expect(primary).toBeDefined();
|
|
44
|
+
expect(primary?.count).toBe(2); // gradient stop + .cta background
|
|
45
|
+
expect(primary?.properties).toContain("background");
|
|
46
|
+
const white = inv.colors.find((c) => c.value === "#ffffff");
|
|
47
|
+
expect(white?.properties).toEqual(["background", "color"]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("collects gradients, fonts, sizes, spacing, radii, shadows", () => {
|
|
51
|
+
expect(inv.gradients[0]).toBe("linear-gradient(135deg, #4f46e5, #7c3aed)");
|
|
52
|
+
expect(inv.fontFamilies).toContain("Inter");
|
|
53
|
+
expect(inv.fontFamilies).toContain("Playfair Display");
|
|
54
|
+
expect(inv.fontSizes).toContain("clamp(2.5rem, 6vw, 4rem)");
|
|
55
|
+
expect(inv.spacingValues).toContain("6rem");
|
|
56
|
+
expect(inv.radiusValues).toContain("12px");
|
|
57
|
+
expect(inv.shadows[0]).toContain("0 4px 24px");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("builds the section outline in document order", () => {
|
|
61
|
+
expect(inv.outline.map((o) => o.tag)).toEqual([
|
|
62
|
+
"header",
|
|
63
|
+
"nav",
|
|
64
|
+
"section",
|
|
65
|
+
"h1",
|
|
66
|
+
"section",
|
|
67
|
+
"h2",
|
|
68
|
+
"footer",
|
|
69
|
+
]);
|
|
70
|
+
expect(inv.outline[3]?.text).toContain("Bold headline");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("formats a compact prompt-friendly report", () => {
|
|
74
|
+
const report = formatGenesisInventory(inv);
|
|
75
|
+
expect(report).toContain("#4f46e5×2[background]");
|
|
76
|
+
expect(report).toContain("Font families: Inter, Playfair Display");
|
|
77
|
+
expect(report).toContain("Outline: <header>");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("stays within budget on adversarial input (linear parse)", () => {
|
|
81
|
+
const evil = `<style>${"a{b:c;".repeat(30_000)}</style>${"<section>".repeat(5_000)}`;
|
|
82
|
+
const t0 = performance.now();
|
|
83
|
+
inventoryGenesisDraft(evil);
|
|
84
|
+
expect(performance.now() - t0).toBeLessThan(250);
|
|
85
|
+
});
|
|
86
|
+
});
|