@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
@@ -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,93 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * issue #165 — the Design Manifest: a generated, versioned description
5
+ * of THIS site's design system (epic #149, growth-time convergence).
6
+ *
7
+ * The operator requirement is asymmetric: pages of the SAME site share
8
+ * one visual line, while different sites are free to look nothing
9
+ * alike. A global stylesheet solves the first at the cost of the
10
+ * second; the per-site manifest solves both — it captures what this
11
+ * site's tokens/patterns MEAN so the AI converges toward its own
12
+ * design, not a Caelo default.
13
+ *
14
+ * Written at Genesis materialisation (or curated later via chat);
15
+ * rendered as the `## Design system` system-prompt block.
16
+ */
17
+
18
+ import { z } from "zod";
19
+
20
+ export const designManifestPattern = z
21
+ .object({
22
+ /** Pattern name the AI reasons in ("hero", "card grid", "cta band"). */
23
+ name: z.string().min(1).max(80),
24
+ /** Stable module `type` implementing it — reuse target (#159 place mode). */
25
+ moduleType: z.string().max(64).optional(),
26
+ /** One-line spec: what makes this pattern THIS site's version of it. */
27
+ spec: z.string().min(1).max(500),
28
+ })
29
+ .strict();
30
+
31
+ export const designManifestSchema = z
32
+ .object({
33
+ /** `--color-primary` → "CTAs and links only" — usage semantics per var. */
34
+ tokenRoles: z.record(z.string().min(3).max(64), z.string().min(1).max(300)).optional(),
35
+ /** Scale ratio, casing, measure rules. */
36
+ typography: z.string().min(1).max(1000).optional(),
37
+ /** Section padding, container width, grid gaps. */
38
+ rhythm: z.string().min(1).max(1000).optional(),
39
+ patterns: z.array(designManifestPattern).max(24).optional(),
40
+ imagery: z.string().min(1).max(500).optional(),
41
+ avoid: z.string().min(1).max(500).optional(),
42
+ })
43
+ .strict();
44
+ export type DesignManifest = z.infer<typeof designManifestSchema>;
45
+
46
+ /** Render the `## Design system` prompt block. Null when empty. */
47
+ export function formatDesignSystemBlock(manifest: DesignManifest | null): string | null {
48
+ if (manifest === null) return null;
49
+ const lines: string[] = [
50
+ "## Design system",
51
+ "",
52
+ "THIS site's own design language — derived from the chosen design. Every module you author or restyle conforms to it unless the operator explicitly asks to break the line. Reuse the pattern's module type (place mode) before minting lookalikes.",
53
+ "",
54
+ ];
55
+ let hasContent = false;
56
+ const roles = manifest.tokenRoles ?? {};
57
+ const roleEntries = Object.entries(roles);
58
+ if (roleEntries.length > 0) {
59
+ hasContent = true;
60
+ lines.push("Token roles:");
61
+ for (const [token, role] of roleEntries.slice(0, 24)) lines.push(`- \`${token}\` — ${role}`);
62
+ lines.push("");
63
+ }
64
+ if (manifest.typography) {
65
+ hasContent = true;
66
+ lines.push(`Typography: ${manifest.typography}`, "");
67
+ }
68
+ if (manifest.rhythm) {
69
+ hasContent = true;
70
+ lines.push(`Rhythm: ${manifest.rhythm}`, "");
71
+ }
72
+ if (manifest.patterns && manifest.patterns.length > 0) {
73
+ hasContent = true;
74
+ lines.push("Patterns:");
75
+ for (const p of manifest.patterns) {
76
+ lines.push(
77
+ `- **${p.name}**${p.moduleType ? ` (module type \`${p.moduleType}\`)` : ""} — ${p.spec}`,
78
+ );
79
+ }
80
+ lines.push("");
81
+ }
82
+ if (manifest.imagery) {
83
+ hasContent = true;
84
+ lines.push(`Imagery: ${manifest.imagery}`, "");
85
+ }
86
+ if (manifest.avoid) {
87
+ hasContent = true;
88
+ lines.push(`Never: ${manifest.avoid}`, "");
89
+ }
90
+ if (!hasContent) return null;
91
+ while (lines[lines.length - 1] === "") lines.pop();
92
+ return lines.join("\n");
93
+ }
@@ -0,0 +1,157 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * issue #150 — unit coverage for the pure web-font helpers: request
5
+ * extraction from typography tokens, system-stack classification, css2
6
+ * payload parsing, self-hosted @font-face emission, preload selection.
7
+ */
8
+
9
+ import { describe, expect, it } from "bun:test";
10
+ import {
11
+ buildFontFaceCss,
12
+ extractThemeFontRequests,
13
+ fontUnresolvableMarker,
14
+ googleFontsCssUrl,
15
+ isSystemFontFamily,
16
+ parseFontsCss,
17
+ type ResolvedFontFace,
18
+ selectPreloadFaces,
19
+ } from "./fonts.js";
20
+ import type { ThemeDocument } from "./themes.js";
21
+
22
+ const TOKENS = {
23
+ typography: {
24
+ body: { $type: "typography", $value: { fontFamily: '"Inter", sans-serif', fontWeight: 400 } },
25
+ heading: {
26
+ $type: "typography",
27
+ $value: { fontFamily: "Playfair Display, serif", fontWeight: 700 },
28
+ },
29
+ emphasis: { $type: "typography", $value: { fontFamily: "Inter", fontWeight: "bold" } },
30
+ mono: { $type: "typography", $value: { fontFamily: "ui-monospace, Menlo, monospace" } },
31
+ aliasing: { $type: "typography", $value: "{typography.body}" },
32
+ },
33
+ } as unknown as ThemeDocument;
34
+
35
+ describe("extractThemeFontRequests (issue #150)", () => {
36
+ it("collects web families with merged weights + roles, skips system stacks and aliases", () => {
37
+ const reqs = extractThemeFontRequests(TOKENS);
38
+ const byFamily = new Map(reqs.map((r) => [r.family, r]));
39
+ expect([...byFamily.keys()].sort()).toEqual(["Inter", "Playfair Display"]);
40
+ // 400 from body + bold→700 from emphasis, merged and sorted.
41
+ expect(byFamily.get("Inter")?.weights).toEqual([400, 700]);
42
+ expect(byFamily.get("Inter")?.roles.sort()).toEqual(["body", "emphasis"]);
43
+ expect(byFamily.get("Playfair Display")?.weights).toEqual([700]);
44
+ });
45
+
46
+ it("defaults weights when the theme declares none", () => {
47
+ const reqs = extractThemeFontRequests({
48
+ typography: { body: { $value: { fontFamily: "Poppins" } } },
49
+ } as unknown as ThemeDocument);
50
+ expect(reqs[0]?.weights).toEqual([400, 700]);
51
+ });
52
+
53
+ it("returns [] for token documents without typography", () => {
54
+ expect(extractThemeFontRequests({} as ThemeDocument)).toEqual([]);
55
+ });
56
+ });
57
+
58
+ describe("isSystemFontFamily", () => {
59
+ it("classifies generic keywords and OS staples as system", () => {
60
+ for (const f of ["system-ui", "sans-serif", "Georgia", " Menlo ", "SEGOE UI"]) {
61
+ expect(isSystemFontFamily(f)).toBe(true);
62
+ }
63
+ });
64
+ it("classifies hosted-web staples as web fonts", () => {
65
+ for (const f of ["Inter", "Poppins", "Playfair Display", "Roboto"]) {
66
+ expect(isSystemFontFamily(f)).toBe(false);
67
+ }
68
+ });
69
+ });
70
+
71
+ describe("googleFontsCssUrl", () => {
72
+ it("encodes the family and joins weights", () => {
73
+ expect(
74
+ googleFontsCssUrl({ family: "Playfair Display", weights: [400, 700], roles: ["heading"] }),
75
+ ).toBe("https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700&display=swap");
76
+ });
77
+ });
78
+
79
+ const CSS2_FIXTURE = `
80
+ /* latin-ext */
81
+ @font-face {
82
+ font-family: 'Poppins';
83
+ font-style: normal;
84
+ font-weight: 400;
85
+ font-display: swap;
86
+ src: url(https://fonts.gstatic.com/s/poppins/v23/pxiEyp8kv8JHgFVrJJnecmNE.woff2) format('woff2');
87
+ unicode-range: U+0100-02BA;
88
+ }
89
+ /* latin */
90
+ @font-face {
91
+ font-family: 'Poppins';
92
+ font-style: normal;
93
+ font-weight: 700;
94
+ font-display: swap;
95
+ src: url(https://fonts.gstatic.com/s/poppins/v23/pxiByp8kv8JHgFVrLCz7Z1xlEA.woff2) format('woff2');
96
+ unicode-range: U+0000-00FF, U+0131;
97
+ }
98
+ @font-face {
99
+ font-family: 'Poppins';
100
+ font-style: normal;
101
+ font-weight: 400;
102
+ font-display: swap;
103
+ src: url(https://fonts.gstatic.com/s/poppins/v23/pxiEyp8kv8JHgFVrJJfecg.woff2) format('woff2');
104
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153;
105
+ }
106
+ `;
107
+
108
+ describe("parseFontsCss", () => {
109
+ it("parses css2 @font-face blocks into face records", () => {
110
+ const faces = parseFontsCss(CSS2_FIXTURE);
111
+ expect(faces).toHaveLength(3);
112
+ expect(faces[0]).toEqual({
113
+ family: "Poppins",
114
+ style: "normal",
115
+ weight: "400",
116
+ unicodeRange: "U+0100-02BA",
117
+ srcUrl: "https://fonts.gstatic.com/s/poppins/v23/pxiEyp8kv8JHgFVrJJnecmNE.woff2",
118
+ });
119
+ });
120
+
121
+ it("returns [] on payloads without woff URLs — the caller's loud-failure signal", () => {
122
+ expect(parseFontsCss("body{color:red}")).toEqual([]);
123
+ });
124
+ });
125
+
126
+ describe("buildFontFaceCss + selectPreloadFaces", () => {
127
+ const faces: ResolvedFontFace[] = parseFontsCss(CSS2_FIXTURE).map((f, i) => ({
128
+ family: f.family,
129
+ style: f.style,
130
+ weight: f.weight,
131
+ unicodeRange: f.unicodeRange,
132
+ publicUrl: `/_assets/fonts/poppins/${i}.woff2`,
133
+ }));
134
+
135
+ it("emits self-hosted @font-face with font-display: swap", () => {
136
+ const css = buildFontFaceCss(faces);
137
+ expect(css).toContain('font-family:"Poppins"');
138
+ expect(css).toContain("font-display:swap");
139
+ expect(css).toContain("url(/_assets/fonts/poppins/0.woff2) format('woff2')");
140
+ expect(css).toContain("unicode-range:U+0100-02BA;");
141
+ expect(css).not.toContain("fonts.gstatic.com");
142
+ });
143
+
144
+ it("preloads one latin normal-style face per family at the lowest weight", () => {
145
+ const picks = selectPreloadFaces(faces);
146
+ // Latin faces are weight 700 (index 1) and 400 (index 2) → lowest is 400.
147
+ expect(picks).toHaveLength(1);
148
+ expect(picks[0]?.weight).toBe("400");
149
+ expect(picks[0]?.publicUrl).toBe("/_assets/fonts/poppins/2.woff2");
150
+ });
151
+ });
152
+
153
+ describe("fontUnresolvableMarker", () => {
154
+ it("matches the theme-asset-unbound marker convention", () => {
155
+ expect(fontUnresolvableMarker("Poppins")).toBe("theme-font-unresolvable:Poppins");
156
+ });
157
+ });