@caelo-cms/shared 0.10.22 → 0.10.23

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 (241) hide show
  1. package/dist/ai-tools.d.ts +290 -214
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +341 -264
  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 +24 -0
  9. package/dist/base-css.d.ts.map +1 -0
  10. package/dist/base-css.js +28 -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-manifest.d.ts +36 -0
  29. package/dist/design-manifest.d.ts.map +1 -0
  30. package/dist/design-manifest.js +90 -0
  31. package/dist/design-manifest.js.map +1 -0
  32. package/dist/fonts.d.ts +89 -0
  33. package/dist/fonts.d.ts.map +1 -0
  34. package/dist/fonts.js +241 -0
  35. package/dist/fonts.js.map +1 -0
  36. package/dist/genesis-inventory.d.ts +32 -0
  37. package/dist/genesis-inventory.d.ts.map +1 -0
  38. package/dist/genesis-inventory.js +186 -0
  39. package/dist/genesis-inventory.js.map +1 -0
  40. package/dist/genesis.d.ts +62 -0
  41. package/dist/genesis.d.ts.map +1 -0
  42. package/dist/genesis.js +78 -0
  43. package/dist/genesis.js.map +1 -0
  44. package/dist/i18n.d.ts +44 -1
  45. package/dist/i18n.d.ts.map +1 -1
  46. package/dist/i18n.js +72 -6
  47. package/dist/i18n.js.map +1 -1
  48. package/dist/index.d.ts +27 -0
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +27 -0
  51. package/dist/index.js.map +1 -1
  52. package/dist/interactions.d.ts +23 -0
  53. package/dist/interactions.d.ts.map +1 -0
  54. package/dist/interactions.js +44 -0
  55. package/dist/interactions.js.map +1 -0
  56. package/dist/media.d.ts +101 -16
  57. package/dist/media.d.ts.map +1 -1
  58. package/dist/media.js +126 -15
  59. package/dist/media.js.map +1 -1
  60. package/dist/page-log.d.ts +94 -0
  61. package/dist/page-log.d.ts.map +1 -0
  62. package/dist/page-log.js +111 -0
  63. package/dist/page-log.js.map +1 -0
  64. package/dist/preview-compose.d.ts +79 -0
  65. package/dist/preview-compose.d.ts.map +1 -1
  66. package/dist/preview-compose.js +155 -25
  67. package/dist/preview-compose.js.map +1 -1
  68. package/dist/proposal-status.d.ts +40 -0
  69. package/dist/proposal-status.d.ts.map +1 -0
  70. package/dist/proposal-status.js +34 -0
  71. package/dist/proposal-status.js.map +1 -0
  72. package/dist/responsive-images.d.ts +64 -0
  73. package/dist/responsive-images.d.ts.map +1 -0
  74. package/dist/responsive-images.js +98 -0
  75. package/dist/responsive-images.js.map +1 -0
  76. package/dist/safe-keys.d.ts +9 -0
  77. package/dist/safe-keys.d.ts.map +1 -0
  78. package/dist/safe-keys.js +20 -0
  79. package/dist/safe-keys.js.map +1 -0
  80. package/dist/seo.d.ts +8 -0
  81. package/dist/seo.d.ts.map +1 -1
  82. package/dist/seo.js +3 -1
  83. package/dist/seo.js.map +1 -1
  84. package/dist/skills.d.ts +14 -68
  85. package/dist/skills.d.ts.map +1 -1
  86. package/dist/skills.js +19 -113
  87. package/dist/skills.js.map +1 -1
  88. package/dist/strip-cdata.d.ts +7 -0
  89. package/dist/strip-cdata.d.ts.map +1 -0
  90. package/dist/strip-cdata.js +48 -0
  91. package/dist/strip-cdata.js.map +1 -0
  92. package/dist/structured-sets.d.ts +6 -16
  93. package/dist/structured-sets.d.ts.map +1 -1
  94. package/dist/structured-sets.js +5 -16
  95. package/dist/structured-sets.js.map +1 -1
  96. package/dist/subagents.d.ts +105 -3
  97. package/dist/subagents.d.ts.map +1 -1
  98. package/dist/subagents.js +224 -41
  99. package/dist/subagents.js.map +1 -1
  100. package/dist/template-engine.d.ts +85 -0
  101. package/dist/template-engine.d.ts.map +1 -0
  102. package/dist/template-engine.js +403 -0
  103. package/dist/template-engine.js.map +1 -0
  104. package/dist/theme-importers/auto-detect.d.ts +26 -0
  105. package/dist/theme-importers/auto-detect.d.ts.map +1 -0
  106. package/dist/theme-importers/auto-detect.js +42 -0
  107. package/dist/theme-importers/auto-detect.js.map +1 -0
  108. package/dist/theme-importers/css-comments.d.ts +12 -0
  109. package/dist/theme-importers/css-comments.d.ts.map +1 -0
  110. package/dist/theme-importers/css-comments.js +15 -0
  111. package/dist/theme-importers/css-comments.js.map +1 -0
  112. package/dist/theme-importers/dtcg.d.ts +46 -0
  113. package/dist/theme-importers/dtcg.d.ts.map +1 -0
  114. package/dist/theme-importers/dtcg.js +111 -0
  115. package/dist/theme-importers/dtcg.js.map +1 -0
  116. package/dist/theme-importers/loose.d.ts +3 -0
  117. package/dist/theme-importers/loose.d.ts.map +1 -0
  118. package/dist/theme-importers/loose.js +76 -0
  119. package/dist/theme-importers/loose.js.map +1 -0
  120. package/dist/theme-importers/shadcn.d.ts +24 -0
  121. package/dist/theme-importers/shadcn.d.ts.map +1 -0
  122. package/dist/theme-importers/shadcn.js +135 -0
  123. package/dist/theme-importers/shadcn.js.map +1 -0
  124. package/dist/theme-importers/style-dictionary.d.ts +17 -0
  125. package/dist/theme-importers/style-dictionary.d.ts.map +1 -0
  126. package/dist/theme-importers/style-dictionary.js +125 -0
  127. package/dist/theme-importers/style-dictionary.js.map +1 -0
  128. package/dist/theme-importers/tailwind.d.ts +3 -0
  129. package/dist/theme-importers/tailwind.d.ts.map +1 -0
  130. package/dist/theme-importers/tailwind.js +218 -0
  131. package/dist/theme-importers/tailwind.js.map +1 -0
  132. package/dist/theme-literal-binding.d.ts +37 -0
  133. package/dist/theme-literal-binding.d.ts.map +1 -0
  134. package/dist/theme-literal-binding.js +138 -0
  135. package/dist/theme-literal-binding.js.map +1 -0
  136. package/dist/theme-normalize.d.ts +31 -0
  137. package/dist/theme-normalize.d.ts.map +1 -0
  138. package/dist/theme-normalize.js +587 -0
  139. package/dist/theme-normalize.js.map +1 -0
  140. package/dist/theme-ramp.d.ts +55 -0
  141. package/dist/theme-ramp.d.ts.map +1 -0
  142. package/dist/theme-ramp.js +149 -0
  143. package/dist/theme-ramp.js.map +1 -0
  144. package/dist/theme-render.d.ts +105 -0
  145. package/dist/theme-render.d.ts.map +1 -0
  146. package/dist/theme-render.js +441 -0
  147. package/dist/theme-render.js.map +1 -0
  148. package/dist/themes-errors.d.ts +109 -0
  149. package/dist/themes-errors.d.ts.map +1 -0
  150. package/dist/themes-errors.js +170 -0
  151. package/dist/themes-errors.js.map +1 -0
  152. package/dist/themes.d.ts +343 -0
  153. package/dist/themes.d.ts.map +1 -0
  154. package/dist/themes.js +697 -0
  155. package/dist/themes.js.map +1 -0
  156. package/dist/version.d.ts +7 -4
  157. package/dist/version.d.ts.map +1 -1
  158. package/dist/version.js +6 -3
  159. package/dist/version.js.map +1 -1
  160. package/package.json +10 -2
  161. package/src/__tests__/redos-hardening.test.ts +160 -0
  162. package/src/ai-tools-add-module-modes.test.ts +106 -0
  163. package/src/ai-tools-position.test.ts +134 -0
  164. package/src/ai-tools.test.ts +81 -0
  165. package/src/ai-tools.ts +1179 -0
  166. package/src/auth-forms.ts +36 -0
  167. package/src/base-css.ts +30 -0
  168. package/src/build-page.test.ts +228 -0
  169. package/src/build-page.ts +319 -0
  170. package/src/cap-failures.ts +67 -0
  171. package/src/content.test.ts +170 -0
  172. package/src/content.ts +620 -0
  173. package/src/context.ts +43 -0
  174. package/src/css-gradient-scan.ts +88 -0
  175. package/src/css-var-scan.test.ts +96 -0
  176. package/src/css-var-scan.ts +144 -0
  177. package/src/derive-module-type.test.ts +80 -0
  178. package/src/design-manifest.ts +93 -0
  179. package/src/fonts.test.ts +157 -0
  180. package/src/fonts.ts +296 -0
  181. package/src/genesis-inventory.test.ts +86 -0
  182. package/src/genesis-inventory.ts +215 -0
  183. package/src/genesis-sanitize.test.ts +35 -0
  184. package/src/genesis.ts +87 -0
  185. package/src/i18n.test.ts +274 -0
  186. package/src/i18n.ts +269 -0
  187. package/src/index.test.ts +10 -0
  188. package/src/index.ts +59 -0
  189. package/src/interactions.ts +48 -0
  190. package/src/logger.ts +147 -0
  191. package/src/media.test.ts +160 -0
  192. package/src/media.ts +355 -0
  193. package/src/page-log.test.ts +163 -0
  194. package/src/page-log.ts +124 -0
  195. package/src/preview-compose.test.ts +602 -0
  196. package/src/preview-compose.ts +709 -0
  197. package/src/preview-scanner.test.ts +96 -0
  198. package/src/preview-scanner.ts +214 -0
  199. package/src/proposal-status.test.ts +69 -0
  200. package/src/proposal-status.ts +40 -0
  201. package/src/responsive-images.test.ts +104 -0
  202. package/src/responsive-images.ts +151 -0
  203. package/src/result.ts +29 -0
  204. package/src/safe-keys.ts +21 -0
  205. package/src/seo.test.ts +234 -0
  206. package/src/seo.ts +261 -0
  207. package/src/skills.ts +48 -0
  208. package/src/snapshots.test.ts +80 -0
  209. package/src/snapshots.ts +81 -0
  210. package/src/strip-cdata.test.ts +41 -0
  211. package/src/strip-cdata.ts +50 -0
  212. package/src/structured-sets.ts +180 -0
  213. package/src/subagents.test.ts +262 -0
  214. package/src/subagents.ts +432 -0
  215. package/src/template-engine.test.ts +379 -0
  216. package/src/template-engine.ts +520 -0
  217. package/src/theme-gradient.test.ts +92 -0
  218. package/src/theme-importers/__tests__/proto-pollution.test.ts +54 -0
  219. package/src/theme-importers/auto-detect.ts +84 -0
  220. package/src/theme-importers/css-comments.ts +15 -0
  221. package/src/theme-importers/dtcg.ts +106 -0
  222. package/src/theme-importers/loose.ts +76 -0
  223. package/src/theme-importers/shadcn.ts +133 -0
  224. package/src/theme-importers/style-dictionary.ts +125 -0
  225. package/src/theme-importers/tailwind.ts +217 -0
  226. package/src/theme-literal-binding.test.ts +71 -0
  227. package/src/theme-literal-binding.ts +159 -0
  228. package/src/theme-motion.test.ts +115 -0
  229. package/src/theme-normalize-envelope.test.ts +43 -0
  230. package/src/theme-normalize-gradient.test.ts +135 -0
  231. package/src/theme-normalize.ts +661 -0
  232. package/src/theme-ramp.ts +187 -0
  233. package/src/theme-render-sanitize.test.ts +45 -0
  234. package/src/theme-render.test.ts +119 -0
  235. package/src/theme-render.ts +487 -0
  236. package/src/theme-shadow.test.ts +56 -0
  237. package/src/themes-errors.ts +199 -0
  238. package/src/themes.ts +842 -0
  239. package/src/translation.test.ts +160 -0
  240. package/src/translation.ts +295 -0
  241. package/src/version.ts +66 -0
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
+ });
@@ -0,0 +1,215 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * issue #164 — design-compiler stage 1: the deterministic INVENTORY.
5
+ *
6
+ * Compiler contract (epic #149): the AI decides, code executes and
7
+ * verifies. This module is the "code" half of the first stage — it
8
+ * parses a Genesis draft (complete single-file HTML) into a fact base
9
+ * of design values (colors with usage contexts, font families, size /
10
+ * spacing / radius histograms, gradients, shadows) plus a section
11
+ * outline. The AI reads the inventory and makes the DECISIONS (which
12
+ * literal becomes which token, what things are called); it never has
13
+ * to re-derive facts by eyeballing raw CSS, and the facts can't be
14
+ * hallucinated.
15
+ *
16
+ * Pure string analysis, linear scans, unambiguous regex classes
17
+ * (ReDoS-aware per issue #113 — drafts are AI-authored but the parser
18
+ * budget must hold on adversarial input too).
19
+ */
20
+
21
+ import { scanCssGradients } from "./css-gradient-scan.js";
22
+
23
+ /** One distinct color literal with where/how often the draft uses it. */
24
+ export interface ColorUsage {
25
+ readonly value: string;
26
+ readonly count: number;
27
+ /** Distinct declaration properties using it (background, color, …). */
28
+ readonly properties: readonly string[];
29
+ }
30
+
31
+ export interface GenesisDraftInventory {
32
+ readonly colors: readonly ColorUsage[];
33
+ readonly gradients: readonly string[];
34
+ readonly fontFamilies: readonly string[];
35
+ readonly fontSizes: readonly string[];
36
+ readonly spacingValues: readonly string[];
37
+ readonly radiusValues: readonly string[];
38
+ readonly shadows: readonly string[];
39
+ /** Ordered structural outline of the body (tag + text snippet). */
40
+ readonly outline: readonly { tag: string; text: string }[];
41
+ }
42
+
43
+ /** Linear extraction of every <style> block's contents. */
44
+ export function extractDraftCss(html: string): string {
45
+ const parts: string[] = [];
46
+ const lower = html.toLowerCase();
47
+ let from = 0;
48
+ while (true) {
49
+ const open = lower.indexOf("<style", from);
50
+ if (open === -1) break;
51
+ const openEnd = lower.indexOf(">", open);
52
+ if (openEnd === -1) break;
53
+ const close = lower.indexOf("</style", openEnd);
54
+ if (close === -1) break;
55
+ parts.push(html.slice(openEnd + 1, close));
56
+ from = close + 8;
57
+ }
58
+ return parts.join("\n");
59
+ }
60
+
61
+ const DECLARATION_RE = /([a-zA-Z-]+)\s*:\s*([^;{}]+)/g;
62
+ const COLOR_LITERAL_RE = /#[0-9a-fA-F]{3,8}\b|(?:oklch|rgba?|hsla?|lab|lch|hwb)\([^()]*\)/g;
63
+ const LENGTH_RE = /-?\d+(?:\.\d+)?(?:rem|em|px|vh|vw|ch|%)\b/g;
64
+
65
+ const SPACING_PROPS = new Set([
66
+ "margin",
67
+ "margin-top",
68
+ "margin-bottom",
69
+ "margin-left",
70
+ "margin-right",
71
+ "margin-block",
72
+ "margin-inline",
73
+ "padding",
74
+ "padding-top",
75
+ "padding-bottom",
76
+ "padding-left",
77
+ "padding-right",
78
+ "padding-block",
79
+ "padding-inline",
80
+ "gap",
81
+ "row-gap",
82
+ "column-gap",
83
+ ]);
84
+
85
+ const GENERIC_FONT_KEYWORDS: ReadonlySet<string> = new Set([
86
+ "serif",
87
+ "sans-serif",
88
+ "monospace",
89
+ "cursive",
90
+ "fantasy",
91
+ "system-ui",
92
+ "ui-sans-serif",
93
+ "ui-serif",
94
+ "ui-monospace",
95
+ ]);
96
+
97
+ function pushCount(map: Map<string, number>, key: string): void {
98
+ map.set(key, (map.get(key) ?? 0) + 1);
99
+ }
100
+
101
+ function sortedByCount(map: Map<string, number>): string[] {
102
+ return [...map.entries()].sort((a, z) => z[1] - a[1]).map(([k]) => k);
103
+ }
104
+
105
+ const OUTLINE_TAG_RE = /<(section|header|footer|main|nav|h1|h2|h3)\b[^>]*>/gi;
106
+ const TAG_STRIP_RE = /<[^>]*>/g;
107
+
108
+ /**
109
+ * Build the inventory. Caps guard pathological drafts: the boundary
110
+ * already limits draft size (GENESIS_DRAFT_MAX_HTML_BYTES), and each
111
+ * result list is truncated to what a decision-maker can actually use.
112
+ */
113
+ export function inventoryGenesisDraft(html: string): GenesisDraftInventory {
114
+ const css = extractDraftCss(html);
115
+
116
+ const colorMeta = new Map<string, { count: number; properties: Set<string> }>();
117
+ const gradients = new Map<string, number>();
118
+ const fontFamilies = new Map<string, number>();
119
+ const fontSizes = new Map<string, number>();
120
+ const spacing = new Map<string, number>();
121
+ const radii = new Map<string, number>();
122
+ const shadows = new Map<string, number>();
123
+
124
+ for (const decl of css.matchAll(DECLARATION_RE)) {
125
+ const prop = (decl[1] ?? "").toLowerCase();
126
+ const value = (decl[2] ?? "").trim();
127
+
128
+ for (const g of scanCssGradients(value)) {
129
+ pushCount(gradients, g.literal);
130
+ }
131
+ // Colors inside gradients still count — a gradient stop color is a
132
+ // palette member the token map must name.
133
+ for (const c of value.matchAll(COLOR_LITERAL_RE)) {
134
+ const key = c[0].toLowerCase();
135
+ const meta = colorMeta.get(key) ?? { count: 0, properties: new Set<string>() };
136
+ meta.count += 1;
137
+ meta.properties.add(prop);
138
+ colorMeta.set(key, meta);
139
+ }
140
+ if (prop === "font-family") {
141
+ for (const fam of value.split(",")) {
142
+ const clean = fam.trim().replace(/^["']|["']$/g, "");
143
+ // Generic keywords are fallback-chain noise, not palette
144
+ // members — the token map names real typefaces.
145
+ if (
146
+ clean.length > 0 &&
147
+ clean.length < 80 &&
148
+ !GENERIC_FONT_KEYWORDS.has(clean.toLowerCase())
149
+ ) {
150
+ pushCount(fontFamilies, clean);
151
+ }
152
+ }
153
+ } else if (prop === "font-size") {
154
+ pushCount(fontSizes, value);
155
+ } else if (SPACING_PROPS.has(prop)) {
156
+ for (const len of value.matchAll(LENGTH_RE)) pushCount(spacing, len[0]);
157
+ } else if (prop === "border-radius") {
158
+ pushCount(radii, value);
159
+ } else if (prop === "box-shadow" || prop === "text-shadow") {
160
+ pushCount(shadows, value);
161
+ }
162
+ }
163
+
164
+ const outline: { tag: string; text: string }[] = [];
165
+ for (const m of html.matchAll(OUTLINE_TAG_RE)) {
166
+ if (outline.length >= 60) break;
167
+ const tag = (m[1] ?? "").toLowerCase();
168
+ const start = (m.index ?? 0) + m[0].length;
169
+ const text = html
170
+ .slice(start, start + 400)
171
+ .replace(TAG_STRIP_RE, " ")
172
+ .replace(/\s+/g, " ")
173
+ .trim()
174
+ .slice(0, 80);
175
+ outline.push({ tag, text });
176
+ }
177
+
178
+ return {
179
+ colors: [...colorMeta.entries()]
180
+ .sort((a, z) => z[1].count - a[1].count)
181
+ .slice(0, 40)
182
+ .map(([value, meta]) => ({
183
+ value,
184
+ count: meta.count,
185
+ properties: [...meta.properties].sort(),
186
+ })),
187
+ gradients: sortedByCount(gradients).slice(0, 12),
188
+ fontFamilies: sortedByCount(fontFamilies).slice(0, 12),
189
+ fontSizes: sortedByCount(fontSizes).slice(0, 24),
190
+ spacingValues: sortedByCount(spacing).slice(0, 24),
191
+ radiusValues: sortedByCount(radii).slice(0, 12),
192
+ shadows: sortedByCount(shadows).slice(0, 12),
193
+ outline,
194
+ };
195
+ }
196
+
197
+ /** Compact, prompt-friendly rendering of the inventory. */
198
+ export function formatGenesisInventory(inv: GenesisDraftInventory): string {
199
+ const lines: string[] = [];
200
+ lines.push(
201
+ `Colors (${inv.colors.length}): ${inv.colors
202
+ .map((c) => `${c.value}×${c.count}[${c.properties.join("/")}]`)
203
+ .join(", ")}`,
204
+ );
205
+ if (inv.gradients.length > 0) lines.push(`Gradients: ${inv.gradients.join(" | ")}`);
206
+ lines.push(`Font families: ${inv.fontFamilies.join(", ") || "(none declared)"}`);
207
+ lines.push(`Font sizes: ${inv.fontSizes.join(", ")}`);
208
+ lines.push(`Spacing values: ${inv.spacingValues.join(", ")}`);
209
+ if (inv.radiusValues.length > 0) lines.push(`Radii: ${inv.radiusValues.join(", ")}`);
210
+ if (inv.shadows.length > 0) lines.push(`Shadows: ${inv.shadows.join(" | ")}`);
211
+ lines.push(
212
+ `Outline: ${inv.outline.map((o) => `<${o.tag}>${o.text ? ` ${o.text}` : ""}`).join(" → ")}`,
213
+ );
214
+ return lines.join("\n");
215
+ }
@@ -0,0 +1,35 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * issue #199 — sanitizeDraftHtml. Adversarial per CLAUDE.md §6:
5
+ * operator-provided HTML becomes a stored draft that flows into
6
+ * materialisation, so script removal must survive casing tricks,
7
+ * multiple blocks, and unterminated tags.
8
+ */
9
+
10
+ import { describe, expect, it } from "bun:test";
11
+ import { sanitizeDraftHtml } from "./genesis.js";
12
+
13
+ describe("sanitizeDraftHtml (#199)", () => {
14
+ it("strips script blocks, keeps everything else byte-identical", () => {
15
+ const html = `<html><head><style>.a{color:red}</style><script>alert(1)</script></head><body><h1>Hi</h1></body></html>`;
16
+ expect(sanitizeDraftHtml(html)).toBe(
17
+ `<html><head><style>.a{color:red}</style></head><body><h1>Hi</h1></body></html>`,
18
+ );
19
+ });
20
+
21
+ it("survives casing + attributes + multiple blocks", () => {
22
+ const html = `<SCRIPT src="evil.js"></SCRIPT><p>a</p><ScRiPt type="module">fetch('/x')</ScRiPt><p>b</p>`;
23
+ expect(sanitizeDraftHtml(html)).toBe(`<p>a</p><p>b</p>`);
24
+ });
25
+
26
+ it("an unterminated script drops to EOF rather than leaking half a payload", () => {
27
+ const html = `<p>keep</p><script>document.cookie`;
28
+ expect(sanitizeDraftHtml(html)).toBe(`<p>keep</p>`);
29
+ });
30
+
31
+ it("leaves script-free documents untouched", () => {
32
+ const html = `<body><section class="description">javascript is a word, &lt;script&gt; is an entity</section></body>`;
33
+ expect(sanitizeDraftHtml(html)).toBe(html);
34
+ });
35
+ });