@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,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
+ });
package/src/genesis.ts ADDED
@@ -0,0 +1,158 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * issue #163 — Site Genesis shared shapes; issue #375 generalises the
5
+ * same draft loop to growth-time.
6
+ *
7
+ * Design-time is divergent: the AI drafts complete freeform HTML pages
8
+ * (one per design direction) and the operator picks one; the CMS
9
+ * structure is derived FROM that choice (#164's compiler). Growth-time
10
+ * reuses the identical draft → present → select → materialise loop at
11
+ * smaller scope: `page` and `module` drafts are FRAGMENTS bound to the
12
+ * site's `var(--…)` theme tokens, composed into the real theme shell
13
+ * only at view time (never an AI approximation of the theme — #375).
14
+ * These are the boundary schemas for the brief + the draft rows.
15
+ */
16
+
17
+ import { z } from "zod";
18
+
19
+ /**
20
+ * The structured Design Brief the discovery dialog produces. Every
21
+ * field optional — the AI fills what the conversation answered; the
22
+ * draft prompts degrade gracefully on gaps. Stored on
23
+ * `site_defaults.design_brief`.
24
+ */
25
+ export const designBriefSchema = z
26
+ .object({
27
+ audience: z.string().min(1).max(500).optional(),
28
+ /** 3–5 adjectives the operator wants the design to feel like. */
29
+ moodWords: z.array(z.string().min(1).max(40)).max(12).optional(),
30
+ tone: z.string().min(1).max(300).optional(),
31
+ industry: z.string().min(1).max(200).optional(),
32
+ differentiators: z.string().min(1).max(1000).optional(),
33
+ imageryDirection: z.string().min(1).max(500).optional(),
34
+ avoid: z.string().min(1).max(500).optional(),
35
+ })
36
+ .strict();
37
+ export type DesignBrief = z.infer<typeof designBriefSchema>;
38
+
39
+ /** Complete single-file drafts stay well under this (inline CSS only). */
40
+ export const GENESIS_DRAFT_MAX_HTML_BYTES = 300_000;
41
+
42
+ export const genesisDraftStatus = z.enum(["candidate", "selected", "discarded"]);
43
+ export type GenesisDraftStatus = z.infer<typeof genesisDraftStatus>;
44
+
45
+ /** issue #199 — where a draft came from. */
46
+ export const genesisDraftSourceKind = z.enum(["genesis", "byod_image", "byod_html"]);
47
+ export type GenesisDraftSourceKind = z.infer<typeof genesisDraftSourceKind>;
48
+
49
+ /**
50
+ * issue #375 — what a draft covers. `site` is the Genesis case (a whole
51
+ * new design); `page` / `module` are growth-time variants of something
52
+ * that already exists.
53
+ */
54
+ export const designDraftScope = z.enum(["site", "page", "module"]);
55
+ export type DesignDraftScope = z.infer<typeof designDraftScope>;
56
+
57
+ /**
58
+ * issue #375 — how a draft's HTML is stored. `site` drafts are complete
59
+ * standalone documents (Genesis, unchanged); `page`/`module` drafts are
60
+ * fragments that only become a renderable document when composed with
61
+ * the site's real theme shell at view time — so a stored variant never
62
+ * freezes a stale copy of the theme.
63
+ */
64
+ export const designDraftFormat = z.enum(["document", "fragment"]);
65
+ export type DesignDraftFormat = z.infer<typeof designDraftFormat>;
66
+
67
+ /** The stored format is fully determined by the scope (see the paired
68
+ * DB CHECK constraint): `site` → standalone document, else fragment. */
69
+ export function draftFormatForScope(scope: DesignDraftScope): DesignDraftFormat {
70
+ return scope === "site" ? "document" : "fragment";
71
+ }
72
+
73
+ const addDraftBase = z
74
+ .object({
75
+ /** Human-readable design direction ("bold editorial"). */
76
+ direction: z.string().min(3).max(120),
77
+ /** Why this direction fits the brief — shown beside the preview. */
78
+ rationale: z.string().max(1000).default(""),
79
+ /** Site scope: complete self-contained single-file HTML. Page/module
80
+ * scope: an HTML fragment bound to the site's `var(--…)` tokens. */
81
+ html: z.string().min(200).max(GENESIS_DRAFT_MAX_HTML_BYTES),
82
+ /** issue #375 — what this draft covers; `site` is the Genesis case. */
83
+ scope: designDraftScope.default("site"),
84
+ /** issue #375 — page scope: the page this variant redesigns. Module
85
+ * scope: optional page context for "this module as used on …". */
86
+ targetPageId: z.string().uuid().optional(),
87
+ /** issue #375 — module scope: the module this variant restyles. */
88
+ targetModuleId: z.string().uuid().optional(),
89
+ /** issue #375 — groups one request's variants into one comparison.
90
+ * Omit on the first save (the op mints and returns it); pass it on
91
+ * every sibling save of the same round. */
92
+ variantSetId: z.string().uuid().optional(),
93
+ /** issue #199 — 'byod_image' (AI reproduction of an operator
94
+ * mockup) / 'byod_html' (operator HTML, sanitised at the op). */
95
+ sourceKind: genesisDraftSourceKind.default("genesis"),
96
+ /** issue #199 — byod_image only: the uploaded mockup's media
97
+ * asset. The parity gate uses THIS image as the reference. */
98
+ referenceAssetId: z.string().uuid().optional(),
99
+ })
100
+ .strict();
101
+
102
+ /** Scope/target pairing rules shared by the op and the AI tool. */
103
+ export const genesisAddDraftInput = addDraftBase.superRefine((v, ctx) => {
104
+ if (v.scope === "site") {
105
+ if (v.targetPageId !== undefined || v.targetModuleId !== undefined) {
106
+ ctx.addIssue({
107
+ code: z.ZodIssueCode.custom,
108
+ message: "site-scope drafts take no target — drop targetPageId/targetModuleId",
109
+ });
110
+ }
111
+ } else if (v.sourceKind !== "genesis") {
112
+ ctx.addIssue({
113
+ code: z.ZodIssueCode.custom,
114
+ message:
115
+ "byod drafts are site-scope only (issue #199); growth-time variants use sourceKind 'genesis'",
116
+ });
117
+ } else if (v.scope === "page") {
118
+ if (v.targetPageId === undefined || v.targetModuleId !== undefined) {
119
+ ctx.addIssue({
120
+ code: z.ZodIssueCode.custom,
121
+ message: "page-scope drafts need targetPageId (and no targetModuleId)",
122
+ });
123
+ }
124
+ } else if (v.targetModuleId === undefined) {
125
+ ctx.addIssue({
126
+ code: z.ZodIssueCode.custom,
127
+ message: "module-scope drafts need targetModuleId (targetPageId is optional page context)",
128
+ });
129
+ }
130
+ });
131
+ export type GenesisAddDraftInput = z.infer<typeof genesisAddDraftInput>;
132
+
133
+ /**
134
+ * issue #199 — strip <script> blocks from operator-provided draft
135
+ * HTML. Defense in depth: drafts render inside sandbox="" iframes
136
+ * (scripts never execute there), but stored HTML flows onward into
137
+ * materialisation, so the boundary removes them outright. Linear
138
+ * scan per the #113 discipline; case-insensitive; unterminated
139
+ * script blocks drop everything to EOF (never leak half a script).
140
+ */
141
+ export function sanitizeDraftHtml(html: string): string {
142
+ const lower = html.toLowerCase();
143
+ let out = "";
144
+ let from = 0;
145
+ while (true) {
146
+ const open = lower.indexOf("<script", from);
147
+ if (open === -1) {
148
+ out += html.slice(from);
149
+ return out;
150
+ }
151
+ out += html.slice(from, open);
152
+ const close = lower.indexOf("</script", open);
153
+ if (close === -1) return out;
154
+ const closeEnd = lower.indexOf(">", close);
155
+ if (closeEnd === -1) return out;
156
+ from = closeEnd + 1;
157
+ }
158
+ }
@@ -0,0 +1,58 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import { computeContentHash, isHomeSlug, pageIsLocaleHome } from "./i18n.js";
5
+
6
+ describe("isHomeSlug", () => {
7
+ it("recognises the magic-slug sentinels regardless of surrounding slashes", () => {
8
+ for (const s of ["", "home", "index", "/", "/home/", "/index"]) {
9
+ expect(isHomeSlug(s)).toBe(true);
10
+ }
11
+ });
12
+
13
+ it("treats any other slug as non-home", () => {
14
+ for (const s of ["about", "en", "blog/post", "homepage"]) {
15
+ expect(isHomeSlug(s)).toBe(false);
16
+ }
17
+ });
18
+ });
19
+
20
+ describe("pageIsLocaleHome (0184 shared predicate)", () => {
21
+ const PAGE = "11111111-1111-4111-8111-111111111111";
22
+ const OTHER = "22222222-2222-4222-8222-222222222222";
23
+
24
+ it("is true when the page IS the locale's designated home_page_id", () => {
25
+ expect(pageIsLocaleHome(PAGE, "en", PAGE)).toBe(true);
26
+ });
27
+
28
+ it("is true for a magic slug even with no designation", () => {
29
+ expect(pageIsLocaleHome(PAGE, "home", null)).toBe(true);
30
+ expect(pageIsLocaleHome(PAGE, "index", undefined)).toBe(true);
31
+ });
32
+
33
+ it("is false for a non-magic slug that isn't the designated page", () => {
34
+ expect(pageIsLocaleHome(PAGE, "en", OTHER)).toBe(false);
35
+ expect(pageIsLocaleHome(PAGE, "about", null)).toBe(false);
36
+ });
37
+ });
38
+
39
+ describe("computeContentHash", () => {
40
+ it("is stable across runs for the same input", async () => {
41
+ const a = await computeContentHash({ x: 1, y: [1, 2] });
42
+ const b = await computeContentHash({ x: 1, y: [1, 2] });
43
+ expect(a).toBe(b);
44
+ expect(a).toMatch(/^[0-9a-f]{64}$/);
45
+ });
46
+
47
+ it("is order-independent for object keys", async () => {
48
+ const a = await computeContentHash({ x: 1, y: 2 });
49
+ const b = await computeContentHash({ y: 2, x: 1 });
50
+ expect(a).toBe(b);
51
+ });
52
+
53
+ it("is order-dependent for arrays", async () => {
54
+ const a = await computeContentHash({ x: [1, 2] });
55
+ const b = await computeContentHash({ x: [2, 1] });
56
+ expect(a).not.toBe(b);
57
+ });
58
+ });
package/src/i18n.ts ADDED
@@ -0,0 +1,91 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Slug/URL primitives shared between the static generator, the preview
5
+ * op, and the admin UI. Epic #380 Phase A (#383): locale URL shaping
6
+ * (resolveLocaleUrl, buildHreflangLinks) is deleted — URL shape beyond
7
+ * base + slug becomes a plugin contribution on the URL composition
8
+ * point (#390). What survives until the page-identity cut (#384):
9
+ *
10
+ * isHomeSlug / pageIsLocaleHome — the home-decision predicate.
11
+ * computeContentHash — drives `pages.content_hash` (removed in #384).
12
+ * trimSlashes / trimTrailingSlashes — linear-scan slug hygiene.
13
+ */
14
+
15
+ const TEXT_ENCODER = new TextEncoder();
16
+
17
+ /**
18
+ * Strip leading and trailing `/` characters with a single linear scan.
19
+ *
20
+ * Replaces the regex `/^\/+|\/+$/g`, whose trailing-slash branch
21
+ * backtracks O(n²) on a long run of slashes that is not at end-of-string
22
+ * (CodeQL js/polynomial-redos). This char-index walk is unconditionally
23
+ * linear and produces byte-identical output for every input.
24
+ */
25
+ export function trimSlashes(s: string): string {
26
+ let start = 0;
27
+ let end = s.length;
28
+ while (start < end && s.charCodeAt(start) === 47 /* '/' */) start += 1;
29
+ while (end > start && s.charCodeAt(end - 1) === 47 /* '/' */) end -= 1;
30
+ return s.slice(start, end);
31
+ }
32
+
33
+ /**
34
+ * Strip only trailing `/` characters with a single linear scan — the
35
+ * non-backtracking replacement for `/\/+$/` (same ReDoS class as above).
36
+ * Used for base URLs, which carry a scheme and must keep their leading
37
+ * characters.
38
+ */
39
+ export function trimTrailingSlashes(s: string): string {
40
+ let end = s.length;
41
+ while (end > 0 && s.charCodeAt(end - 1) === 47 /* '/' */) end -= 1;
42
+ return s.slice(0, end);
43
+ }
44
+
45
+ /**
46
+ * The "magic slug" sentinel test: a page whose slug is empty, `home`,
47
+ * or `index` is the locale root by convention even without an explicit
48
+ * `locales.home_page_id` designation. Leading/trailing slashes are
49
+ * ignored so `/home/` reads the same as `home`.
50
+ */
51
+ export function isHomeSlug(slug: string): boolean {
52
+ const s = trimSlashes(slug);
53
+ return s === "" || s === "home" || s === "index";
54
+ }
55
+
56
+ /**
57
+ * The single home-decision predicate every URL/output-path site must
58
+ * use so canonical, hreflang, redirects, and the emitted file agree
59
+ * (0184 explicit-homepage feature). A page is the locale root when it
60
+ * IS the locale's designated `home_page_id` OR carries a magic slug.
61
+ *
62
+ * @param pageId The page being resolved.
63
+ * @param slug That page's slug.
64
+ * @param localeHomePageId `locales.home_page_id` for the page's locale
65
+ * (null/undefined when no page is designated).
66
+ */
67
+ export function pageIsLocaleHome(
68
+ pageId: string,
69
+ slug: string,
70
+ localeHomePageId: string | null | undefined,
71
+ ): boolean {
72
+ return (localeHomePageId != null && pageId === localeHomePageId) || isHomeSlug(slug);
73
+ }
74
+
75
+ /**
76
+ * Canonical-JSON serializer + sha256 → hex. Stable across runs because
77
+ * keys are sorted. The output is used for `pages.content_hash` so that
78
+ * a Mode-2 translation can detect whether its source has changed.
79
+ */
80
+ export async function computeContentHash(value: unknown): Promise<string> {
81
+ const canonical = JSON.stringify(value, (_key, val) => {
82
+ if (val && typeof val === "object" && !Array.isArray(val)) {
83
+ const sorted: Record<string, unknown> = {};
84
+ for (const k of Object.keys(val).sort()) sorted[k] = (val as Record<string, unknown>)[k];
85
+ return sorted;
86
+ }
87
+ return val;
88
+ });
89
+ const digest = await crypto.subtle.digest("SHA-256", TEXT_ENCODER.encode(canonical));
90
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
91
+ }
@@ -0,0 +1,10 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import { PROJECT_NAME } from "./index.js";
5
+
6
+ describe("shared scaffold", () => {
7
+ it("exports the project name", () => {
8
+ expect(PROJECT_NAME).toBe("caelo-cms");
9
+ });
10
+ });
package/src/index.ts ADDED
@@ -0,0 +1,59 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ export const PROJECT_NAME = "caelo-cms";
4
+
5
+ export * from "./ai-tools.js";
6
+ export * from "./auth-forms.js";
7
+ export * from "./base-css.js";
8
+ export * from "./build-page.js";
9
+ export * from "./cap-failures.js";
10
+ export * from "./content.js";
11
+ export * from "./context.js";
12
+ export * from "./css-gradient-scan.js";
13
+ export * from "./css-var-scan.js";
14
+ export * from "./design-draft-shell.js";
15
+ export * from "./design-manifest.js";
16
+ export * from "./fonts.js";
17
+ export * from "./genesis.js";
18
+ export * from "./genesis-inventory.js";
19
+ export * from "./i18n.js";
20
+ export * from "./interactions.js";
21
+ export {
22
+ type LogContext,
23
+ type Logger,
24
+ type LoggerOptions,
25
+ type LogLevel,
26
+ makeLogger,
27
+ mintRequestId,
28
+ redact,
29
+ type ServiceName,
30
+ type StructuredLogEntry,
31
+ } from "./logger.js";
32
+ export * from "./media.js";
33
+ export * from "./page-log.js";
34
+ export * from "./preview-compose.js";
35
+ export * from "./preview-scanner.js";
36
+ export * from "./proposal-status.js";
37
+ export * from "./responsive-images.js";
38
+ export * from "./result.js";
39
+ export * from "./safe-keys.js";
40
+ export * from "./seo.js";
41
+ export * from "./skills.js";
42
+ export * from "./snapshots.js";
43
+ export * from "./strip-cdata.js";
44
+ export * from "./structured-sets.js";
45
+ export * from "./subagents.js";
46
+ export * from "./template-engine.js";
47
+ export * from "./theme-importers/auto-detect.js";
48
+ export * from "./theme-importers/dtcg.js";
49
+ export * from "./theme-importers/loose.js";
50
+ export * from "./theme-importers/shadcn.js";
51
+ export * from "./theme-importers/style-dictionary.js";
52
+ export * from "./theme-importers/tailwind.js";
53
+ export * from "./theme-literal-binding.js";
54
+ export * from "./theme-normalize.js";
55
+ export * from "./theme-ramp.js";
56
+ export * from "./theme-render.js";
57
+ export * from "./themes.js";
58
+ export * from "./themes-errors.js";
59
+ export { CAELO_VERSION, CALEO_VERSION, type CaeloVersion, parseVersion } from "./version.js";
@@ -0,0 +1,48 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * issue #160 — the vetted interaction baseline (epic #149).
5
+ *
6
+ * A header without a working mobile menu reads as broken, not minimal
7
+ * — and hand-rolled per-module JS gets focus/aria handling wrong more
8
+ * often than right. This module carries the FUNCTIONAL (non-aesthetic)
9
+ * CSS + JS for the built-in nav renderer: hamburger toggle under the
10
+ * mobile breakpoint, `aria-expanded` bookkeeping, escape-to-close.
11
+ * All colors/spacing stay the module/theme's business — these rules
12
+ * only govern show/hide behaviour, so they don't violate the
13
+ * no-global-look re-scope (#151).
14
+ *
15
+ * The composer includes each asset ONCE per page when at least one
16
+ * nav-menu module rendered (natural dedup, same rule as #158).
17
+ */
18
+
19
+ /** Functional-only rules: collapse/expand mechanics, zero aesthetics. */
20
+ export const NAV_FUNCTIONAL_CSS =
21
+ ".caelo-nav-menu .caelo-nav-toggle{display:none;background:none;border:0;cursor:pointer;padding:0.5rem;color:inherit;font:inherit}" +
22
+ ".caelo-nav-menu .caelo-nav-toggle .caelo-nav-bar{display:block;width:1.25em;height:2px;background:currentColor;margin:0.25em 0}" +
23
+ "@media (max-width: 720px){" +
24
+ ".caelo-nav-menu .caelo-nav-toggle{display:inline-block}" +
25
+ ".caelo-nav-menu > ul{display:none}" +
26
+ '.caelo-nav-menu[data-nav-open="true"] > ul{display:block}' +
27
+ "}";
28
+
29
+ /**
30
+ * Toggle behaviour. Runs deferred; idempotent per element via the
31
+ * data attribute; closes on Escape and returns focus to the toggle.
32
+ */
33
+ export const NAV_TOGGLE_JS =
34
+ "(function(){" +
35
+ "document.querySelectorAll('.caelo-nav-menu').forEach(function(nav){" +
36
+ "var btn=nav.querySelector('.caelo-nav-toggle');if(!btn)return;" +
37
+ "btn.addEventListener('click',function(){" +
38
+ "var open=nav.getAttribute('data-nav-open')==='true';" +
39
+ "nav.setAttribute('data-nav-open',String(!open));" +
40
+ "btn.setAttribute('aria-expanded',String(!open));" +
41
+ "});" +
42
+ "nav.addEventListener('keydown',function(e){" +
43
+ "if(e.key==='Escape'&&nav.getAttribute('data-nav-open')==='true'){" +
44
+ "nav.setAttribute('data-nav-open','false');" +
45
+ "btn.setAttribute('aria-expanded','false');btn.focus();" +
46
+ "}});" +
47
+ "});" +
48
+ "})();";