@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.
Files changed (249) hide show
  1. package/dist/ai-tools.d.ts +289 -273
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +342 -323
  4. package/dist/ai-tools.js.map +1 -1
  5. package/dist/auth-forms.d.ts.map +1 -1
  6. package/dist/auth-forms.js +4 -1
  7. package/dist/auth-forms.js.map +1 -1
  8. package/dist/base-css.d.ts +35 -0
  9. package/dist/base-css.d.ts.map +1 -0
  10. package/dist/base-css.js +40 -0
  11. package/dist/base-css.js.map +1 -0
  12. package/dist/build-page.d.ts +330 -0
  13. package/dist/build-page.d.ts.map +1 -0
  14. package/dist/build-page.js +282 -0
  15. package/dist/build-page.js.map +1 -0
  16. package/dist/content.d.ts +322 -9
  17. package/dist/content.d.ts.map +1 -1
  18. package/dist/content.js +354 -11
  19. package/dist/content.js.map +1 -1
  20. package/dist/css-gradient-scan.d.ts +14 -0
  21. package/dist/css-gradient-scan.d.ts.map +1 -0
  22. package/dist/css-gradient-scan.js +81 -0
  23. package/dist/css-gradient-scan.js.map +1 -0
  24. package/dist/css-var-scan.d.ts +56 -0
  25. package/dist/css-var-scan.d.ts.map +1 -0
  26. package/dist/css-var-scan.js +97 -0
  27. package/dist/css-var-scan.js.map +1 -0
  28. package/dist/design-draft-shell.d.ts +21 -0
  29. package/dist/design-draft-shell.d.ts.map +1 -0
  30. package/dist/design-draft-shell.js +81 -0
  31. package/dist/design-draft-shell.js.map +1 -0
  32. package/dist/design-manifest.d.ts +36 -0
  33. package/dist/design-manifest.d.ts.map +1 -0
  34. package/dist/design-manifest.js +90 -0
  35. package/dist/design-manifest.js.map +1 -0
  36. package/dist/fonts.d.ts +89 -0
  37. package/dist/fonts.d.ts.map +1 -0
  38. package/dist/fonts.js +241 -0
  39. package/dist/fonts.js.map +1 -0
  40. package/dist/genesis-inventory.d.ts +32 -0
  41. package/dist/genesis-inventory.d.ts.map +1 -0
  42. package/dist/genesis-inventory.js +186 -0
  43. package/dist/genesis-inventory.js.map +1 -0
  44. package/dist/genesis.d.ts +102 -0
  45. package/dist/genesis.d.ts.map +1 -0
  46. package/dist/genesis.js +145 -0
  47. package/dist/genesis.js.map +1 -0
  48. package/dist/i18n.d.ts +29 -36
  49. package/dist/i18n.d.ts.map +1 -1
  50. package/dist/i18n.js +53 -128
  51. package/dist/i18n.js.map +1 -1
  52. package/dist/index.d.ts +28 -1
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +28 -1
  55. package/dist/index.js.map +1 -1
  56. package/dist/interactions.d.ts +23 -0
  57. package/dist/interactions.d.ts.map +1 -0
  58. package/dist/interactions.js +44 -0
  59. package/dist/interactions.js.map +1 -0
  60. package/dist/media.d.ts +101 -16
  61. package/dist/media.d.ts.map +1 -1
  62. package/dist/media.js +126 -15
  63. package/dist/media.js.map +1 -1
  64. package/dist/page-log.d.ts +94 -0
  65. package/dist/page-log.d.ts.map +1 -0
  66. package/dist/page-log.js +111 -0
  67. package/dist/page-log.js.map +1 -0
  68. package/dist/preview-compose.d.ts +85 -12
  69. package/dist/preview-compose.d.ts.map +1 -1
  70. package/dist/preview-compose.js +157 -67
  71. package/dist/preview-compose.js.map +1 -1
  72. package/dist/proposal-status.d.ts +40 -0
  73. package/dist/proposal-status.d.ts.map +1 -0
  74. package/dist/proposal-status.js +34 -0
  75. package/dist/proposal-status.js.map +1 -0
  76. package/dist/responsive-images.d.ts +64 -0
  77. package/dist/responsive-images.d.ts.map +1 -0
  78. package/dist/responsive-images.js +98 -0
  79. package/dist/responsive-images.js.map +1 -0
  80. package/dist/safe-keys.d.ts +9 -0
  81. package/dist/safe-keys.d.ts.map +1 -0
  82. package/dist/safe-keys.js +20 -0
  83. package/dist/safe-keys.js.map +1 -0
  84. package/dist/seo.d.ts +11 -16
  85. package/dist/seo.d.ts.map +1 -1
  86. package/dist/seo.js +7 -23
  87. package/dist/seo.js.map +1 -1
  88. package/dist/skills.d.ts +14 -68
  89. package/dist/skills.d.ts.map +1 -1
  90. package/dist/skills.js +19 -113
  91. package/dist/skills.js.map +1 -1
  92. package/dist/strip-cdata.d.ts +7 -0
  93. package/dist/strip-cdata.d.ts.map +1 -0
  94. package/dist/strip-cdata.js +48 -0
  95. package/dist/strip-cdata.js.map +1 -0
  96. package/dist/structured-sets.d.ts +6 -52
  97. package/dist/structured-sets.d.ts.map +1 -1
  98. package/dist/structured-sets.js +6 -68
  99. package/dist/structured-sets.js.map +1 -1
  100. package/dist/subagents.d.ts +105 -3
  101. package/dist/subagents.d.ts.map +1 -1
  102. package/dist/subagents.js +224 -41
  103. package/dist/subagents.js.map +1 -1
  104. package/dist/template-engine.d.ts +85 -0
  105. package/dist/template-engine.d.ts.map +1 -0
  106. package/dist/template-engine.js +403 -0
  107. package/dist/template-engine.js.map +1 -0
  108. package/dist/theme-importers/auto-detect.d.ts +26 -0
  109. package/dist/theme-importers/auto-detect.d.ts.map +1 -0
  110. package/dist/theme-importers/auto-detect.js +42 -0
  111. package/dist/theme-importers/auto-detect.js.map +1 -0
  112. package/dist/theme-importers/css-comments.d.ts +12 -0
  113. package/dist/theme-importers/css-comments.d.ts.map +1 -0
  114. package/dist/theme-importers/css-comments.js +15 -0
  115. package/dist/theme-importers/css-comments.js.map +1 -0
  116. package/dist/theme-importers/dtcg.d.ts +46 -0
  117. package/dist/theme-importers/dtcg.d.ts.map +1 -0
  118. package/dist/theme-importers/dtcg.js +111 -0
  119. package/dist/theme-importers/dtcg.js.map +1 -0
  120. package/dist/theme-importers/loose.d.ts +3 -0
  121. package/dist/theme-importers/loose.d.ts.map +1 -0
  122. package/dist/theme-importers/loose.js +76 -0
  123. package/dist/theme-importers/loose.js.map +1 -0
  124. package/dist/theme-importers/shadcn.d.ts +24 -0
  125. package/dist/theme-importers/shadcn.d.ts.map +1 -0
  126. package/dist/theme-importers/shadcn.js +135 -0
  127. package/dist/theme-importers/shadcn.js.map +1 -0
  128. package/dist/theme-importers/style-dictionary.d.ts +17 -0
  129. package/dist/theme-importers/style-dictionary.d.ts.map +1 -0
  130. package/dist/theme-importers/style-dictionary.js +125 -0
  131. package/dist/theme-importers/style-dictionary.js.map +1 -0
  132. package/dist/theme-importers/tailwind.d.ts +3 -0
  133. package/dist/theme-importers/tailwind.d.ts.map +1 -0
  134. package/dist/theme-importers/tailwind.js +218 -0
  135. package/dist/theme-importers/tailwind.js.map +1 -0
  136. package/dist/theme-literal-binding.d.ts +37 -0
  137. package/dist/theme-literal-binding.d.ts.map +1 -0
  138. package/dist/theme-literal-binding.js +138 -0
  139. package/dist/theme-literal-binding.js.map +1 -0
  140. package/dist/theme-normalize.d.ts +31 -0
  141. package/dist/theme-normalize.d.ts.map +1 -0
  142. package/dist/theme-normalize.js +587 -0
  143. package/dist/theme-normalize.js.map +1 -0
  144. package/dist/theme-ramp.d.ts +55 -0
  145. package/dist/theme-ramp.d.ts.map +1 -0
  146. package/dist/theme-ramp.js +149 -0
  147. package/dist/theme-ramp.js.map +1 -0
  148. package/dist/theme-render.d.ts +105 -0
  149. package/dist/theme-render.d.ts.map +1 -0
  150. package/dist/theme-render.js +441 -0
  151. package/dist/theme-render.js.map +1 -0
  152. package/dist/themes-errors.d.ts +109 -0
  153. package/dist/themes-errors.d.ts.map +1 -0
  154. package/dist/themes-errors.js +170 -0
  155. package/dist/themes-errors.js.map +1 -0
  156. package/dist/themes.d.ts +343 -0
  157. package/dist/themes.d.ts.map +1 -0
  158. package/dist/themes.js +697 -0
  159. package/dist/themes.js.map +1 -0
  160. package/dist/version.d.ts +7 -4
  161. package/dist/version.d.ts.map +1 -1
  162. package/dist/version.js +6 -3
  163. package/dist/version.js.map +1 -1
  164. package/package.json +10 -2
  165. package/src/__tests__/redos-hardening.test.ts +160 -0
  166. package/src/ai-tools-add-module-modes.test.ts +106 -0
  167. package/src/ai-tools-position.test.ts +134 -0
  168. package/src/ai-tools.test.ts +81 -0
  169. package/src/ai-tools.ts +1105 -0
  170. package/src/auth-forms.ts +36 -0
  171. package/src/base-css.ts +42 -0
  172. package/src/build-page.test.ts +228 -0
  173. package/src/build-page.ts +319 -0
  174. package/src/cap-failures.ts +67 -0
  175. package/src/content.test.ts +170 -0
  176. package/src/content.ts +620 -0
  177. package/src/context.ts +43 -0
  178. package/src/css-gradient-scan.ts +88 -0
  179. package/src/css-var-scan.test.ts +96 -0
  180. package/src/css-var-scan.ts +144 -0
  181. package/src/derive-module-type.test.ts +80 -0
  182. package/src/design-draft-shell.test.ts +85 -0
  183. package/src/design-draft-shell.ts +109 -0
  184. package/src/design-manifest.ts +93 -0
  185. package/src/fonts.test.ts +157 -0
  186. package/src/fonts.ts +296 -0
  187. package/src/genesis-inventory.test.ts +86 -0
  188. package/src/genesis-inventory.ts +215 -0
  189. package/src/genesis-sanitize.test.ts +35 -0
  190. package/src/genesis.ts +158 -0
  191. package/src/i18n.test.ts +58 -0
  192. package/src/i18n.ts +91 -0
  193. package/src/index.test.ts +10 -0
  194. package/src/index.ts +59 -0
  195. package/src/interactions.ts +48 -0
  196. package/src/logger.ts +147 -0
  197. package/src/media.test.ts +160 -0
  198. package/src/media.ts +355 -0
  199. package/src/page-log.test.ts +163 -0
  200. package/src/page-log.ts +124 -0
  201. package/src/preview-compose.test.ts +637 -0
  202. package/src/preview-compose.ts +656 -0
  203. package/src/preview-scanner.test.ts +96 -0
  204. package/src/preview-scanner.ts +214 -0
  205. package/src/proposal-status.test.ts +69 -0
  206. package/src/proposal-status.ts +40 -0
  207. package/src/responsive-images.test.ts +104 -0
  208. package/src/responsive-images.ts +151 -0
  209. package/src/result.ts +29 -0
  210. package/src/safe-keys.ts +21 -0
  211. package/src/seo.test.ts +194 -0
  212. package/src/seo.ts +233 -0
  213. package/src/skills.ts +48 -0
  214. package/src/snapshots.test.ts +80 -0
  215. package/src/snapshots.ts +81 -0
  216. package/src/strip-cdata.test.ts +41 -0
  217. package/src/strip-cdata.ts +50 -0
  218. package/src/structured-sets.ts +114 -0
  219. package/src/subagents.test.ts +262 -0
  220. package/src/subagents.ts +432 -0
  221. package/src/template-engine.test.ts +379 -0
  222. package/src/template-engine.ts +520 -0
  223. package/src/theme-gradient.test.ts +92 -0
  224. package/src/theme-importers/__tests__/proto-pollution.test.ts +54 -0
  225. package/src/theme-importers/auto-detect.ts +84 -0
  226. package/src/theme-importers/css-comments.ts +15 -0
  227. package/src/theme-importers/dtcg.ts +106 -0
  228. package/src/theme-importers/loose.ts +76 -0
  229. package/src/theme-importers/shadcn.ts +133 -0
  230. package/src/theme-importers/style-dictionary.ts +125 -0
  231. package/src/theme-importers/tailwind.ts +217 -0
  232. package/src/theme-literal-binding.test.ts +71 -0
  233. package/src/theme-literal-binding.ts +159 -0
  234. package/src/theme-motion.test.ts +115 -0
  235. package/src/theme-normalize-envelope.test.ts +43 -0
  236. package/src/theme-normalize-gradient.test.ts +135 -0
  237. package/src/theme-normalize.ts +661 -0
  238. package/src/theme-ramp.ts +187 -0
  239. package/src/theme-render-sanitize.test.ts +45 -0
  240. package/src/theme-render.test.ts +119 -0
  241. package/src/theme-render.ts +487 -0
  242. package/src/theme-shadow.test.ts +56 -0
  243. package/src/themes-errors.ts +199 -0
  244. package/src/themes.ts +842 -0
  245. package/src/version.ts +66 -0
  246. package/dist/translation.d.ts +0 -127
  247. package/dist/translation.d.ts.map +0 -1
  248. package/dist/translation.js +0 -208
  249. 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
+ });