@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,88 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * issue #164 (CodeQL follow-up) — linear CSS gradient scanner.
5
+ *
6
+ * A `*-gradient\([^;{}]*\)` regex over whole stylesheets is the
7
+ * polynomial-ReDoS shape (#113): on adversarial input full of unclosed
8
+ * `conic-gradient(` starts, every attempt rescans to end-of-input.
9
+ * Gradient arguments legitimately contain nested parens (rgba() stops),
10
+ * so the class can't simply exclude `(`. This scanner walks the string
11
+ * once: find the next `-gradient(` head, then match parens with a
12
+ * depth counter, bounded by a hard argument-length cap.
13
+ */
14
+
15
+ /** No real gradient needs more; unclosed heads stop scanning here. */
16
+ const MAX_GRADIENT_LENGTH = 2_000;
17
+
18
+ const NEEDLE = "-gradient(";
19
+ const BASES = ["linear", "radial", "conic"] as const;
20
+ const REPEATING = "repeating-";
21
+
22
+ export interface CssGradientMatch {
23
+ readonly start: number;
24
+ readonly end: number;
25
+ readonly literal: string;
26
+ }
27
+
28
+ /**
29
+ * All complete gradient literals, in document order. Linear: ONE
30
+ * indexOf needle per hit (each advancing the cursor), a bounded
31
+ * backwards check for the base keyword, and a capped paren walk.
32
+ */
33
+ export function scanCssGradients(text: string): CssGradientMatch[] {
34
+ const lower = text.toLowerCase();
35
+ const out: CssGradientMatch[] = [];
36
+ let from = 0;
37
+ while (from < lower.length) {
38
+ const g = lower.indexOf(NEEDLE, from);
39
+ if (g === -1) break;
40
+ let headStart = -1;
41
+ for (const base of BASES) {
42
+ if (g >= base.length && lower.startsWith(base, g - base.length)) {
43
+ headStart = g - base.length;
44
+ break;
45
+ }
46
+ }
47
+ if (headStart === -1) {
48
+ from = g + NEEDLE.length;
49
+ continue;
50
+ }
51
+ if (
52
+ headStart >= REPEATING.length &&
53
+ lower.startsWith(REPEATING, headStart - REPEATING.length)
54
+ ) {
55
+ headStart -= REPEATING.length;
56
+ }
57
+ let depth = 1;
58
+ let i = g + NEEDLE.length;
59
+ const limit = Math.min(lower.length, i + MAX_GRADIENT_LENGTH);
60
+ while (i < limit && depth > 0) {
61
+ const ch = lower.charCodeAt(i);
62
+ if (ch === 40 /* ( */) depth += 1;
63
+ else if (ch === 41 /* ) */) depth -= 1;
64
+ i += 1;
65
+ }
66
+ if (depth === 0) {
67
+ out.push({ start: headStart, end: i, literal: text.slice(headStart, i) });
68
+ from = i;
69
+ } else {
70
+ // Unclosed (or over-long) head — advance past it, never rescan.
71
+ from = g + NEEDLE.length;
72
+ }
73
+ }
74
+ return out;
75
+ }
76
+
77
+ /** Replace each complete gradient via `cb` (return the literal to keep). */
78
+ export function replaceCssGradients(text: string, cb: (literal: string) => string): string {
79
+ const matches = scanCssGradients(text);
80
+ if (matches.length === 0) return text;
81
+ let out = "";
82
+ let cursor = 0;
83
+ for (const m of matches) {
84
+ out += text.slice(cursor, m.start) + cb(m.literal);
85
+ cursor = m.end;
86
+ }
87
+ return out + text.slice(cursor);
88
+ }
@@ -0,0 +1,96 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * issue #156 — scanner unit coverage: reference/definition extraction
5
+ * (nested fallbacks, comments-in-values, strings), locally-defined
6
+ * custom properties, did-you-mean suggestions, warning formatting.
7
+ */
8
+
9
+ import { describe, expect, it } from "bun:test";
10
+ import {
11
+ extractCssVarDefinitions,
12
+ extractCssVarReferences,
13
+ formatUnknownCssVarWarning,
14
+ scanCssVars,
15
+ unknownCssVarMarker,
16
+ } from "./css-var-scan.js";
17
+
18
+ const KNOWN = ["--color-primary", "--color-foreground", "--color-background", "--spacing-md"];
19
+
20
+ describe("extractCssVarReferences", () => {
21
+ it("finds plain and fallback-carrying references, deduped", () => {
22
+ const refs = extractCssVarReferences(
23
+ ".a{color:var(--color-primary)}.b{color:var(--color-primary);background:var( --color-bg , #fff )}",
24
+ );
25
+ expect(refs).toEqual([
26
+ { name: "--color-primary", hasFallback: false },
27
+ { name: "--color-bg", hasFallback: true },
28
+ ]);
29
+ });
30
+
31
+ it("handles nested var() fallbacks", () => {
32
+ const refs = extractCssVarReferences("p{color:var(--a, var(--b, red))}");
33
+ expect(refs.map((r) => r.name).sort()).toEqual(["--a", "--b"]);
34
+ });
35
+ });
36
+
37
+ describe("extractCssVarDefinitions", () => {
38
+ it("collects custom-property declarations, not references", () => {
39
+ const defs = extractCssVarDefinitions(
40
+ ":root{--site-gutter:2rem}.x{--local: 1px; color:var(--other)}",
41
+ );
42
+ expect([...defs].sort()).toEqual(["--local", "--site-gutter"]);
43
+ });
44
+ });
45
+
46
+ describe("scanCssVars", () => {
47
+ it("reports unknown refs with a did-you-mean suggestion", () => {
48
+ const unknown = scanCssVars({ css: "h1{color:var(--color-foregruond)}", knownVars: KNOWN });
49
+ expect(unknown).toHaveLength(1);
50
+ expect(unknown[0]?.name).toBe("--color-foregruond");
51
+ expect(unknown[0]?.suggestion).toBe("--color-foreground");
52
+ });
53
+
54
+ it("treats locally-defined custom properties as known", () => {
55
+ const unknown = scanCssVars({
56
+ css: ".hero{--hero-angle:12deg; transform:rotate(var(--hero-angle))}",
57
+ knownVars: KNOWN,
58
+ });
59
+ expect(unknown).toEqual([]);
60
+ });
61
+
62
+ it("suppresses far-fetched suggestions", () => {
63
+ const unknown = scanCssVars({ css: "p{width:var(--zzz-totally-unrelated)}", knownVars: KNOWN });
64
+ expect(unknown[0]?.suggestion).toBeNull();
65
+ });
66
+
67
+ it("is silent on clean css", () => {
68
+ expect(
69
+ scanCssVars({
70
+ css: "p{color:var(--color-primary);padding:var(--spacing-md)}",
71
+ knownVars: KNOWN,
72
+ }),
73
+ ).toEqual([]);
74
+ });
75
+ });
76
+
77
+ describe("formatUnknownCssVarWarning / marker", () => {
78
+ it("formats an AI-actionable one-liner and distinguishes fallback vs invalid", () => {
79
+ const msg = formatUnknownCssVarWarning([
80
+ { name: "--color-bg", hasFallback: true, suggestion: "--color-background" },
81
+ { name: "--nope", hasFallback: false, suggestion: null },
82
+ ]);
83
+ expect(msg).toContain("--color-bg");
84
+ expect(msg).toContain("did you mean `--color-background`?");
85
+ expect(msg).toContain("hardcoded fallback");
86
+ expect(msg).toContain("won't apply");
87
+ });
88
+
89
+ it("returns null when nothing is unknown (no noise)", () => {
90
+ expect(formatUnknownCssVarWarning([])).toBeNull();
91
+ });
92
+
93
+ it("marker follows the missing-content convention", () => {
94
+ expect(unknownCssVarMarker("--color-bg")).toBe("unknown-css-var:--color-bg");
95
+ });
96
+ });
@@ -0,0 +1,144 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * issue #156 — CSS custom-property reference scanner.
5
+ *
6
+ * The silent-fallback trap: module/template/layout CSS referencing a
7
+ * `var(--…)` the active theme never emits renders the hardcoded
8
+ * fallback (or nothing), so the page quietly diverges from the theme —
9
+ * the monochrome regression class documented in theme-render.ts. The
10
+ * prompt-level var inventory guards the happy path; this scanner is the
11
+ * mechanical write-time guard (CLAUDE.md §2: misses must be loud).
12
+ *
13
+ * Pure string analysis — callers (authoring tools, preview op) supply
14
+ * the known-var set from `listThemeCssVarNames(activeTheme.tokens)`.
15
+ * Custom properties DEFINED anywhere in the scanned bundle are treated
16
+ * as known: a layout may define `--site-gutter` for its modules, and
17
+ * that is legitimate authoring, not drift.
18
+ */
19
+
20
+ /** One `var(--x)` reference found in CSS. */
21
+ export interface CssVarReference {
22
+ readonly name: string;
23
+ /** True when the reference carries a literal fallback (`var(--x, #fff)`). */
24
+ readonly hasFallback: boolean;
25
+ }
26
+
27
+ const VAR_REFERENCE_RE = /var\(\s*(--[a-zA-Z0-9_-]+)\s*(,)?/g;
28
+ const VAR_DEFINITION_RE = /(?:^|[{;\s])(--[a-zA-Z0-9_-]+)\s*:/g;
29
+
30
+ /** Extract every `var(--x[, fallback])` reference (deduped by name+fallback). */
31
+ export function extractCssVarReferences(css: string): CssVarReference[] {
32
+ const seen = new Map<string, CssVarReference>();
33
+ for (const m of css.matchAll(VAR_REFERENCE_RE)) {
34
+ const name = m[1] ?? "";
35
+ const hasFallback = m[2] === ",";
36
+ const key = `${name}|${hasFallback}`;
37
+ if (!seen.has(key)) seen.set(key, { name, hasFallback });
38
+ }
39
+ return [...seen.values()];
40
+ }
41
+
42
+ /** Custom properties DEFINED in the CSS (`--x: value;`). */
43
+ export function extractCssVarDefinitions(css: string): Set<string> {
44
+ const out = new Set<string>();
45
+ for (const m of css.matchAll(VAR_DEFINITION_RE)) {
46
+ out.add(m[1] ?? "");
47
+ }
48
+ return out;
49
+ }
50
+
51
+ /** Levenshtein distance, small-string use only (var names). */
52
+ function editDistance(a: string, b: string): number {
53
+ const prev = new Array<number>(b.length + 1);
54
+ for (let j = 0; j <= b.length; j++) prev[j] = j;
55
+ for (let i = 1; i <= a.length; i++) {
56
+ let diag = prev[0] as number;
57
+ prev[0] = i;
58
+ for (let j = 1; j <= b.length; j++) {
59
+ const tmp = prev[j] as number;
60
+ prev[j] = Math.min(
61
+ (prev[j] as number) + 1,
62
+ (prev[j - 1] as number) + 1,
63
+ diag + (a[i - 1] === b[j - 1] ? 0 : 1),
64
+ );
65
+ diag = tmp;
66
+ }
67
+ }
68
+ return prev[b.length] as number;
69
+ }
70
+
71
+ /** An unknown reference + the closest known name worth suggesting. */
72
+ export interface UnknownCssVar {
73
+ readonly name: string;
74
+ readonly hasFallback: boolean;
75
+ /** Closest known var within edit-distance 40% of length, or null. */
76
+ readonly suggestion: string | null;
77
+ }
78
+
79
+ export interface CssVarScanInput {
80
+ /** The CSS bundle to scan (concatenate layout+template+module parts). */
81
+ readonly css: string;
82
+ /** Vars the active theme emits (`listThemeCssVarNames`). */
83
+ readonly knownVars: readonly string[];
84
+ }
85
+
86
+ /**
87
+ * Report `var()` references that neither the theme emits nor the
88
+ * bundle itself defines, with a nearest-match suggestion so the fix is
89
+ * one edit, not a research task (CLAUDE.md §11: AI-actionable).
90
+ */
91
+ export function scanCssVars(input: CssVarScanInput): UnknownCssVar[] {
92
+ const known = new Set(input.knownVars);
93
+ for (const def of extractCssVarDefinitions(input.css)) known.add(def);
94
+
95
+ const unknown: UnknownCssVar[] = [];
96
+ const reported = new Set<string>();
97
+ for (const ref of extractCssVarReferences(input.css)) {
98
+ if (known.has(ref.name) || reported.has(ref.name)) continue;
99
+ reported.add(ref.name);
100
+ let best: string | null = null;
101
+ let bestDist = Number.POSITIVE_INFINITY;
102
+ for (const candidate of known) {
103
+ const d = editDistance(ref.name, candidate);
104
+ if (d < bestDist) {
105
+ bestDist = d;
106
+ best = candidate;
107
+ }
108
+ }
109
+ const threshold = Math.max(2, Math.floor(ref.name.length * 0.4));
110
+ unknown.push({
111
+ name: ref.name,
112
+ hasFallback: ref.hasFallback,
113
+ suggestion: best !== null && bestDist <= threshold ? best : null,
114
+ });
115
+ }
116
+ return unknown;
117
+ }
118
+
119
+ /**
120
+ * Marker for the preview op's missing-content surface — same
121
+ * convention as `theme-asset-unbound:<slot>` / `theme-font-unresolvable`.
122
+ */
123
+ export function unknownCssVarMarker(name: string): string {
124
+ return `unknown-css-var:${name}`;
125
+ }
126
+
127
+ /**
128
+ * One-line, AI-actionable warning for authoring-tool results. Returns
129
+ * null when nothing is unknown (no noise on clean writes).
130
+ */
131
+ export function formatUnknownCssVarWarning(unknown: readonly UnknownCssVar[]): string | null {
132
+ if (unknown.length === 0) return null;
133
+ const parts = unknown.map((u) => {
134
+ const hint = u.suggestion !== null ? ` (did you mean \`${u.suggestion}\`?)` : "";
135
+ const fallback = u.hasFallback
136
+ ? " — its hardcoded fallback is what visitors will see, detached from the theme"
137
+ : " — the declaration is invalid and the property won't apply";
138
+ return `\`${u.name}\`${hint}${fallback}`;
139
+ });
140
+ return (
141
+ `⚠️ Unknown CSS vars (the active theme does not emit them): ${parts.join("; ")}. ` +
142
+ "Use the exact names from the `## Theme` inventory, or define the custom property in this CSS if it's intentional."
143
+ );
144
+ }
@@ -0,0 +1,80 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * v0.12.3 (issue #106) — unit guards for the module type/slug helpers.
5
+ * The load-bearing invariant: a module's stable `type` is the slug base
6
+ * WITHOUT the uniqueness suffix, so `type` is always a prefix of `slug`
7
+ * and the two can never drift (which is what the allowedModuleTypes match
8
+ * relies on).
9
+ */
10
+
11
+ import { describe, expect, it } from "bun:test";
12
+ import { deriveModuleType, slugifyModuleName, slugifyModuleSection } from "./content.js";
13
+
14
+ describe("deriveModuleType", () => {
15
+ it("lowercases + hyphenates and drops no suffix", () => {
16
+ expect(deriveModuleType("Primary Button")).toBe("primary-button");
17
+ });
18
+
19
+ it("collapses runs of non-alphanumerics to a single hyphen, trimmed", () => {
20
+ expect(deriveModuleType("CTA!! banner $$$")).toBe("cta-banner");
21
+ });
22
+
23
+ it("falls back to 'module' for empty / all-punctuation names", () => {
24
+ expect(deriveModuleType("!!!")).toBe("module");
25
+ expect(deriveModuleType("")).toBe("module");
26
+ });
27
+
28
+ it("caps the base at 40 chars", () => {
29
+ expect(deriveModuleType("a".repeat(80)).length).toBe(40);
30
+ });
31
+
32
+ it("produces NO -<timestamp> suffix (contrast with slugifyModuleName)", () => {
33
+ const t = deriveModuleType("Button");
34
+ expect(t).toBe("button");
35
+ expect(t).not.toMatch(/-[a-z0-9]+$/);
36
+ });
37
+
38
+ // Copilot review (#106 PR): the 40-char cap used to run AFTER the edge-hyphen
39
+ // trim, so a name whose collapsed hyphen sits at the boundary truncated into a
40
+ // trailing hyphen — which would double-hyphenate `slug = type + "-" + suffix`.
41
+ it("never returns a trailing hyphen when the cap lands on one", () => {
42
+ const name = `${"a".repeat(39)} tail`; // collapses to 39 a's + "-tail"
43
+ const t = deriveModuleType(name);
44
+ expect(t).not.toMatch(/-$/);
45
+ expect(t).toBe("a".repeat(39));
46
+ // the composed slug stays single-hyphenated
47
+ expect(slugifyModuleName(name, "s")).toBe(`${"a".repeat(39)}-s`);
48
+ });
49
+
50
+ it("never returns a leading hyphen", () => {
51
+ expect(deriveModuleType(" !!leading")).toBe("leading");
52
+ expect(deriveModuleType("!!leading")).not.toMatch(/^-/);
53
+ });
54
+ });
55
+
56
+ describe("slugifyModuleName", () => {
57
+ it("composes as deriveModuleType + '-' + suffix (type is a slug prefix)", () => {
58
+ expect(slugifyModuleName("Primary Button", "abc123")).toBe("primary-button-abc123");
59
+ const name = "Hero Banner";
60
+ expect(slugifyModuleName(name, "x").startsWith(`${deriveModuleType(name)}-`)).toBe(true);
61
+ });
62
+
63
+ it("uses a base36 timestamp suffix when none is given", () => {
64
+ expect(slugifyModuleName("Button")).toMatch(/^button-[a-z0-9]+$/);
65
+ });
66
+ });
67
+
68
+ describe("slugifyModuleSection", () => {
69
+ it("inserts the section index between the type base and the suffix", () => {
70
+ expect(slugifyModuleSection("Hero", 2, "zzz")).toBe("hero-2-zzz");
71
+ });
72
+
73
+ it("keeps the type base as a prefix so same-named sections don't collide", () => {
74
+ const a = slugifyModuleSection("Card", 0, "s");
75
+ const b = slugifyModuleSection("Card", 1, "s");
76
+ expect(a).not.toBe(b);
77
+ expect(a.startsWith("card-")).toBe(true);
78
+ expect(b.startsWith("card-")).toBe(true);
79
+ });
80
+ });
@@ -0,0 +1,85 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * issue #375 — theme-shell composition for growth-time design drafts:
5
+ * head order (fonts → theme vars → base), theme-asset placeholder
6
+ * resolution incl. the loud unbound path, and the no-theme degradation.
7
+ */
8
+
9
+ import { describe, expect, it } from "bun:test";
10
+ import { composeDesignDraftShell } from "./design-draft-shell.js";
11
+ import type { ComposeTheme } from "./preview-compose.js";
12
+ import type { ThemeDocument } from "./themes.js";
13
+
14
+ const TOKENS = {
15
+ color: {
16
+ background: { $type: "color", $value: "#ffffff" },
17
+ primary: { $type: "color", $value: "#4f46e5" },
18
+ },
19
+ } as ThemeDocument;
20
+
21
+ const THEME: ComposeTheme = {
22
+ tokens: TOKENS,
23
+ assets: {
24
+ logo: { mediaId: "11111111-1111-4111-8111-111111111111", url: "/_caelo/media/logo/orig" },
25
+ logoDark: null,
26
+ favicon: null,
27
+ socialShare: null,
28
+ },
29
+ };
30
+
31
+ const FRAGMENT = `<section class="hero"><h1>Real headline</h1><style>.hero{color:var(--color-primary)}</style></section>`;
32
+
33
+ describe("composeDesignDraftShell (issue #375)", () => {
34
+ it("wraps the fragment in fonts → theme vars → base head order", () => {
35
+ const out = composeDesignDraftShell({
36
+ fragmentHtml: FRAGMENT,
37
+ theme: THEME,
38
+ fonts: { css: "@font-face{font-family:Inter}", preloads: ["/_caelo/fonts/inter/aa.woff2"] },
39
+ title: "Draft — bold",
40
+ });
41
+ expect(out.html).toStartWith("<!doctype html>");
42
+ expect(out.html).toContain('<style data-source="fonts">@font-face{font-family:Inter}');
43
+ expect(out.html).toContain('rel="preload" as="font"');
44
+ expect(out.html).toContain('<style data-source="theme">');
45
+ expect(out.html).toContain("--color-primary:#4f46e5");
46
+ expect(out.html).toContain('<style data-source="base">');
47
+ expect(out.html).toContain(FRAGMENT);
48
+ expect(out.missingSlots).toEqual([]);
49
+ const fontsAt = out.html.indexOf('data-source="fonts"');
50
+ const themeAt = out.html.indexOf('data-source="theme"');
51
+ const baseAt = out.html.indexOf('data-source="base"');
52
+ expect(fontsAt).toBeLessThan(themeAt);
53
+ expect(themeAt).toBeLessThan(baseAt);
54
+ });
55
+
56
+ it("substitutes bound theme-asset placeholders and flags unbound ones loudly", () => {
57
+ const out = composeDesignDraftShell({
58
+ fragmentHtml: `<img src="{{theme_logo_url}}"><img src="{{theme_favicon_url}}">`,
59
+ theme: THEME,
60
+ title: "assets",
61
+ });
62
+ expect(out.html).toContain('src="/_caelo/media/logo/orig"');
63
+ // Unbound slot: placeholder survives raw IN PLACE (§2 no-fallbacks;
64
+ // the attribute value must not be corrupted by an inline marker) …
65
+ expect(out.html).toContain('src="{{theme_favicon_url}}"');
66
+ // … and the marker rides as a real comment node at the body's end.
67
+ expect(out.html).toContain("<!-- caelo:missing reason=theme-asset-unbound:favicon --></body>");
68
+ expect(out.missingSlots).toEqual(["theme-asset-unbound:favicon"]);
69
+ });
70
+
71
+ it("renders without theme vars when no theme exists (visibly broken on purpose)", () => {
72
+ const out = composeDesignDraftShell({ fragmentHtml: FRAGMENT, title: "no theme" });
73
+ expect(out.html).not.toContain('data-source="theme"');
74
+ expect(out.html).not.toContain('data-source="fonts"');
75
+ expect(out.html).toContain('data-source="base"');
76
+ });
77
+
78
+ it("escapes the document title", () => {
79
+ const out = composeDesignDraftShell({
80
+ fragmentHtml: FRAGMENT,
81
+ title: `<script>"x"</script>`,
82
+ });
83
+ expect(out.html).toContain("<title>&lt;script&gt;&quot;x&quot;&lt;/script&gt;</title>");
84
+ });
85
+ });
@@ -0,0 +1,109 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * issue #375 — theme shell for growth-time design-draft previews.
5
+ *
6
+ * Page/module-scope drafts are stored as FRAGMENTS bound to the site's
7
+ * `var(--…)` tokens; this composer wraps one in the site's real
8
+ * compiled theme at view time: web fonts (#150), theme CSS variables,
9
+ * and the invisible technical baseline (#151) — in the exact head
10
+ * order `composePagePreview` uses, so a draft previews in the same
11
+ * cascade it will materialise into. Pure / sync / no IO: the caller
12
+ * (the `genesis.render_draft` op) loads the theme row and resolves
13
+ * fonts, mirroring `pages.render_preview`.
14
+ */
15
+
16
+ import { BASE_TECHNICAL_CSS } from "./base-css.js";
17
+ import { type ComposeFonts, type ComposeTheme, fontsHeadFragment } from "./preview-compose.js";
18
+ import { caeloMissingComment } from "./template-engine.js";
19
+ import { renderThemeCss } from "./theme-render.js";
20
+
21
+ /** Same four placeholders the template engine resolves (v0.11.1, #76). */
22
+ const THEME_ASSET_KEY_TO_SLOT = {
23
+ theme_logo_url: "logo",
24
+ theme_logo_dark_url: "logoDark",
25
+ theme_favicon_url: "favicon",
26
+ theme_social_share_url: "socialShare",
27
+ } as const;
28
+
29
+ export interface DesignDraftShellInput {
30
+ /** The stored fragment (already script-stripped at the boundary). */
31
+ readonly fragmentHtml: string;
32
+ /** Active theme; undefined renders the shell without theme vars
33
+ * (loud: the draft's `var(--…)` references fall to browser initial
34
+ * values — visibly broken, per CLAUDE.md §2 no-fallbacks). */
35
+ readonly theme?: ComposeTheme;
36
+ /** Resolved web fonts; undefined = system stacks only. */
37
+ readonly fonts?: ComposeFonts;
38
+ /** Document title shown in the iframe (accessibility). */
39
+ readonly title: string;
40
+ }
41
+
42
+ export interface DesignDraftShellOutput {
43
+ readonly html: string;
44
+ /** `theme-asset-unbound:<slot>` markers, mirroring the template
45
+ * engine's failure-marker contract. */
46
+ readonly missingSlots: readonly string[];
47
+ }
48
+
49
+ /**
50
+ * Resolve `{{theme_*_url}}` placeholders in a draft fragment. Bound
51
+ * slots substitute their URL; a placeholder on an unbound slot stays
52
+ * loud-raw IN PLACE (placeholders usually sit inside attribute values,
53
+ * where an appended comment would corrupt the URL string — PR-378
54
+ * review) and lands in `missingSlots`; the caller appends the real
55
+ * `caelo:missing` comment nodes at the end of the body so the marker
56
+ * tooling still sees them. Other `{{…}}` sequences are left untouched:
57
+ * a draft is not a fielded module.
58
+ */
59
+ function substituteThemeAssetUrls(
60
+ html: string,
61
+ theme: ComposeTheme | undefined,
62
+ missing: string[],
63
+ ): string {
64
+ return html.replace(/\{\{\s*(theme_[a-z_]+)\s*\}\}/g, (match, key: string) => {
65
+ const slot = THEME_ASSET_KEY_TO_SLOT[key as keyof typeof THEME_ASSET_KEY_TO_SLOT];
66
+ if (slot === undefined) return match;
67
+ const url = theme?.assets[slot]?.url;
68
+ if (url === undefined) {
69
+ missing.push(`theme-asset-unbound:${slot}`);
70
+ return match;
71
+ }
72
+ return url;
73
+ });
74
+ }
75
+
76
+ export function composeDesignDraftShell(input: DesignDraftShellInput): DesignDraftShellOutput {
77
+ const missing: string[] = [];
78
+ const substituted = substituteThemeAssetUrls(input.fragmentHtml, input.theme, missing);
79
+ // Real comment NODES, after the fragment — never inside an attribute.
80
+ const markers = [...new Set(missing)].map((m) => caeloMissingComment(m)).join("");
81
+ const body = substituted + markers;
82
+
83
+ // Head order mirrors composePagePreview: fonts first (URL discovery),
84
+ // then theme vars (so fragment CSS can `var(--color-primary)`), then
85
+ // the technical baseline.
86
+ const head: string[] = [
87
+ `<meta charset="utf-8">`,
88
+ `<meta name="viewport" content="width=device-width, initial-scale=1">`,
89
+ `<title>${escapeHtml(input.title)}</title>`,
90
+ ];
91
+ const fonts = fontsHeadFragment(input.fonts);
92
+ if (fonts !== null) head.push(fonts);
93
+ const themeCss = input.theme !== undefined ? renderThemeCss(input.theme.tokens) : null;
94
+ if (themeCss !== null) head.push(`<style data-source="theme">${themeCss}</style>`);
95
+ head.push(`<style data-source="base">${BASE_TECHNICAL_CSS}</style>`);
96
+
97
+ return {
98
+ html: `<!doctype html>\n<html><head>${head.join("")}</head><body>${body}</body></html>`,
99
+ missingSlots: missing,
100
+ };
101
+ }
102
+
103
+ function escapeHtml(s: string): string {
104
+ return s
105
+ .replaceAll("&", "&amp;")
106
+ .replaceAll("<", "&lt;")
107
+ .replaceAll(">", "&gt;")
108
+ .replaceAll('"', "&quot;");
109
+ }