@caelo-cms/shared 0.10.21 → 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 +291 -231
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +349 -281
  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,84 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * v0.11.1 (issue #76) — theme import format auto-detection chain.
5
+ *
6
+ * Tries each importer in priority order:
7
+ *
8
+ * 1. DTCG (most specific: `$value` leaves)
9
+ * 2. Style Dictionary (`value` leaves)
10
+ * 3. Tailwind 4 (`@theme { … }` block)
11
+ * 4. shadcn (`:root { … }` CSS variables)
12
+ * 5. Loose key-value (`{looseName: value}` JSON object)
13
+ *
14
+ * The first importer that doesn't throw a "shape" rejection wins.
15
+ * "Hard" parse errors (e.g. TailwindImportError for calc()) surface
16
+ * verbatim — they signal the input WAS that format but unparseable.
17
+ *
18
+ * Returns `{format, tokens}` so the caller's tool result can echo the
19
+ * detected format back to the AI for next-turn awareness.
20
+ */
21
+
22
+ import type { ThemeDocument } from "../themes.js";
23
+ import {
24
+ NoImporterMatched,
25
+ NotDtcgShape,
26
+ NotLooseShape,
27
+ NotShadcnShape,
28
+ NotStyleDictionaryShape,
29
+ NotTailwindShape,
30
+ } from "../themes-errors.js";
31
+ import { importDtcg } from "./dtcg.js";
32
+ import { importLoose } from "./loose.js";
33
+ import { importShadcn } from "./shadcn.js";
34
+ import { importStyleDictionary } from "./style-dictionary.js";
35
+ import { importTailwind } from "./tailwind.js";
36
+
37
+ export type DetectedFormat = "dtcg" | "style-dictionary" | "tailwind" | "shadcn" | "loose";
38
+
39
+ export interface AutoDetectResult {
40
+ readonly format: DetectedFormat;
41
+ readonly tokens: ThemeDocument;
42
+ }
43
+
44
+ interface ImporterDef {
45
+ readonly format: DetectedFormat;
46
+ readonly importer: (body: string) => ThemeDocument;
47
+ /** When true, the auto-detect chain falls through on this error class. */
48
+ readonly fallThroughOn: ReadonlyArray<new (...args: never[]) => Error>;
49
+ }
50
+
51
+ const CHAIN: readonly ImporterDef[] = [
52
+ { format: "dtcg", importer: importDtcg, fallThroughOn: [NotDtcgShape] },
53
+ {
54
+ format: "style-dictionary",
55
+ importer: importStyleDictionary,
56
+ fallThroughOn: [NotStyleDictionaryShape],
57
+ },
58
+ { format: "tailwind", importer: importTailwind, fallThroughOn: [NotTailwindShape] },
59
+ { format: "shadcn", importer: importShadcn, fallThroughOn: [NotShadcnShape] },
60
+ { format: "loose", importer: importLoose, fallThroughOn: [NotLooseShape] },
61
+ ];
62
+
63
+ export function autoDetectAndImport(body: string): AutoDetectResult {
64
+ const attempts: Array<{ format: string; reason: string }> = [];
65
+ for (const def of CHAIN) {
66
+ try {
67
+ const tokens = def.importer(body);
68
+ return { format: def.format, tokens };
69
+ } catch (e) {
70
+ const isFallThrough = def.fallThroughOn.some((cls) => e instanceof cls);
71
+ if (!isFallThrough) {
72
+ // Hard error: the input WAS in this format but the importer
73
+ // can't handle it. Surface verbatim so the operator/AI gets a
74
+ // concrete fix-it.
75
+ throw e;
76
+ }
77
+ attempts.push({
78
+ format: def.format,
79
+ reason: e instanceof Error ? (e.message.split("\n")[0] ?? "rejected") : "rejected",
80
+ });
81
+ }
82
+ }
83
+ throw new NoImporterMatched(attempts);
84
+ }
@@ -0,0 +1,15 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Strip `/* … *\/` block comments from a CSS string.
5
+ *
6
+ * The body uses a "tempered dot" (`(?:(?!\*\/)[\s\S])*`) rather than a lazy
7
+ * `[\s\S]*?`: the lazy form backtracks O(n²) on an unterminated comment in
8
+ * untrusted theme CSS (CodeQL js/polynomial-redos). The tempered form has a
9
+ * single unambiguous match path and stops at the first `*\/`, so the output
10
+ * is identical to the lazy form on valid CSS. Single-sourced here so the two
11
+ * theme importers (tailwind, shadcn) cannot drift to a vulnerable variant.
12
+ */
13
+ export function stripCssComments(css: string): string {
14
+ return css.replace(/\/\*(?:(?!\*\/)[\s\S])*\*\//g, "");
15
+ }
@@ -0,0 +1,106 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * v0.11.0 — DTCG (W3C Design Tokens Format) import / export for the
5
+ * `themes` primitive (#45 Phase 7 slice 1).
6
+ *
7
+ * The full format-auto-detect (Style Dictionary / Tailwind 4 @theme /
8
+ * shadcn `:root` / loose key-value) is v0.11.2 — see the follow-up
9
+ * comment §4 on #45. This slice ships the DTCG side only so design
10
+ * tooling (Figma, Tokens Studio, Style Dictionary) round-trips cleanly
11
+ * from day one.
12
+ *
13
+ * The shape we accept IS the same shape `themes.tokens` jsonb carries,
14
+ * because `themes.ts`'s Zod schema mirrors DTCG. So `importDtcg` is
15
+ * essentially `JSON.parse + validateThemeTokens`; `exportDtcg` returns
16
+ * the stored tokens jsonb after stable-stringification. The wrappers
17
+ * exist so we have explicit entry points to grow Style-Dictionary /
18
+ * Tailwind / shadcn / loose parsers next to in v0.11.2 without
19
+ * widening `themes.ts`.
20
+ */
21
+
22
+ import { type Theme, type ThemeDocument, validateThemeTokens } from "../themes.js";
23
+ import { NotDtcgShape } from "../themes-errors.js";
24
+
25
+ /**
26
+ * Parse a DTCG JSON document into a validated tokens tree. Three
27
+ * failure surfaces:
28
+ *
29
+ * 1. Malformed JSON → `DTCG parse failed: <SyntaxError msg>`
30
+ * 2. Valid JSON, no `$value` anywhere → `NotDtcgShape` (round-2
31
+ * opt §2: pre-Zod sniff for the common case where the operator
32
+ * pastes a Tailwind / Style Dictionary / Figma export and the
33
+ * AI sees a deeply-nested Zod path mismatch instead of a clear
34
+ * "this isn't DTCG" hint).
35
+ * 3. Valid JSON, has $value leaves, but Zod rejects something → the
36
+ * ZodError surfaces verbatim (per-token path mismatch, missing
37
+ * composite sub-field, etc.).
38
+ */
39
+ export function importDtcg(body: string): ThemeDocument {
40
+ let parsed: unknown;
41
+ try {
42
+ parsed = JSON.parse(body);
43
+ } catch (e) {
44
+ const msg = e instanceof Error ? e.message : String(e);
45
+ throw new Error(`DTCG parse failed: ${msg}`);
46
+ }
47
+ if (!hasAnyDollarValue(parsed)) {
48
+ throw new NotDtcgShape();
49
+ }
50
+ return validateThemeTokens(parsed);
51
+ }
52
+
53
+ /**
54
+ * Walks the parsed tree once, returns true as soon as ANY leaf object
55
+ * carries a `$value` key. Cheap O(n) over the parsed structure — same
56
+ * shape Zod will walk anyway during full validation, just early-exits.
57
+ * Treats arrays as nested containers so a shadow-layer array of
58
+ * composite values still sniffs as DTCG.
59
+ */
60
+ function hasAnyDollarValue(node: unknown): boolean {
61
+ if (node === null || typeof node !== "object") return false;
62
+ if (Array.isArray(node)) return node.some(hasAnyDollarValue);
63
+ const obj = node as Record<string, unknown>;
64
+ if ("$value" in obj) return true;
65
+ for (const v of Object.values(obj)) {
66
+ if (hasAnyDollarValue(v)) return true;
67
+ }
68
+ return false;
69
+ }
70
+
71
+ /**
72
+ * Emit a stable DTCG JSON document for the given theme. Keys are
73
+ * sorted alphabetically at every nesting level so the output is
74
+ * byte-for-byte deterministic — Style Dictionary / Tokens Studio diff
75
+ * cleanly under git.
76
+ *
77
+ * Only `tokens` is exported; asset URLs and the `is_active` flag are
78
+ * Caelo-specific and have no DTCG representation. Operators bind
79
+ * assets through `set_theme_asset`, not through an exported JSON file.
80
+ */
81
+ export function exportDtcg(theme: Pick<Theme, "tokens">): string {
82
+ return `${JSON.stringify(sortDeep(theme.tokens), null, 2)}\n`;
83
+ }
84
+
85
+ function sortDeep(value: unknown): unknown {
86
+ if (value === null) return null;
87
+ if (Array.isArray(value)) return value.map(sortDeep);
88
+ if (typeof value !== "object") return value;
89
+ const obj = value as Record<string, unknown>;
90
+ const sortedKeys = Object.keys(obj).sort((a, b) => {
91
+ // DTCG convention: $-prefixed metadata keys ($value, $type,
92
+ // $description, $extensions) sort BEFORE plain group keys so a
93
+ // leaf renders with its metadata up top, then its sub-references
94
+ // (if any) below. Within each tier keys sort alphabetically.
95
+ const aDollar = a.startsWith("$");
96
+ const bDollar = b.startsWith("$");
97
+ if (aDollar && !bDollar) return -1;
98
+ if (!aDollar && bDollar) return 1;
99
+ return a.localeCompare(b);
100
+ });
101
+ const out: Record<string, unknown> = {};
102
+ for (const k of sortedKeys) {
103
+ out[k] = sortDeep(obj[k]);
104
+ }
105
+ return out;
106
+ }
@@ -0,0 +1,76 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * v0.11.1 (issue #76) — loose key-value JSON object importer.
5
+ *
6
+ * Accepts a flat JSON object of `{looseName: value}` pairs and runs
7
+ * each entry through `normalizeTokens` + `applyDtcgWrites` so the
8
+ * result lands at canonical DTCG paths. This is the same path
9
+ * `themes.update_tokens` uses for AI-supplied input — extracted here
10
+ * so `import_theme` can offer a "paste a flat object, server figures
11
+ * out where each one goes" surface.
12
+ *
13
+ * The importer starts from an empty document so it produces a complete
14
+ * tokens tree (no existing tokens merged in). Callers wanting a patch
15
+ * use `themes.update_tokens` directly.
16
+ */
17
+
18
+ import { normalizeTokens } from "../theme-normalize.js";
19
+ import { applyDtcgWrites, type ThemeDocument, validateThemeTokens } from "../themes.js";
20
+ import { NotLooseShape } from "../themes-errors.js";
21
+
22
+ export function importLoose(body: string): ThemeDocument {
23
+ let parsed: unknown;
24
+ try {
25
+ parsed = JSON.parse(body);
26
+ } catch (e) {
27
+ const msg = e instanceof Error ? e.message : String(e);
28
+ throw new NotLooseShape(`loose-format parse failed: ${msg}`);
29
+ }
30
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
31
+ throw new NotLooseShape("loose-format input must be a JSON object");
32
+ }
33
+ // Reject inputs that are clearly other formats so auto-detect doesn't
34
+ // accidentally swallow them at the last hop.
35
+ if (hasNestedDollarValue(parsed) || hasNestedPlainValue(parsed)) {
36
+ throw new NotLooseShape(
37
+ "input has nested `$value` / `value` leaves — looks like DTCG or Style Dictionary, not loose",
38
+ );
39
+ }
40
+
41
+ const obj = parsed as Record<string, unknown>;
42
+ if (Object.keys(obj).length === 0) {
43
+ throw new NotLooseShape("loose-format input is an empty object");
44
+ }
45
+ // Normalize loose names → canonical paths (throws UnknownTokenName on
46
+ // ambiguity, per the v0.11.0 normalizer's failure surface).
47
+ const normalized = normalizeTokens(obj);
48
+ // Apply to an empty document so the result is a complete tokens tree.
49
+ const written = applyDtcgWrites({}, normalized.set, normalized.types);
50
+ return validateThemeTokens(written);
51
+ }
52
+
53
+ function hasNestedDollarValue(node: unknown): boolean {
54
+ if (node === null || typeof node !== "object" || Array.isArray(node)) return false;
55
+ const obj = node as Record<string, unknown>;
56
+ for (const [k, v] of Object.entries(obj)) {
57
+ if (k === "$value") return true;
58
+ if (typeof v === "object" && v !== null && hasNestedDollarValue(v)) return true;
59
+ }
60
+ return false;
61
+ }
62
+
63
+ function hasNestedPlainValue(node: unknown): boolean {
64
+ if (node === null || typeof node !== "object" || Array.isArray(node)) return false;
65
+ const obj = node as Record<string, unknown>;
66
+ // A "Style Dictionary leaf" looks like `{value: <primitive>, ...}` —
67
+ // not a top-level flat key whose value is a primitive.
68
+ for (const v of Object.values(obj)) {
69
+ if (v && typeof v === "object" && !Array.isArray(v)) {
70
+ const inner = v as Record<string, unknown>;
71
+ if ("value" in inner && typeof inner.value !== "object") return true;
72
+ if (hasNestedPlainValue(v)) return true;
73
+ }
74
+ }
75
+ return false;
76
+ }
@@ -0,0 +1,133 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * v0.11.1 (issue #76) — shadcn CSS-variable importer.
5
+ *
6
+ * shadcn-style themes ship as a `:root { … }` (+ optional `.dark { … }`)
7
+ * block of unprefixed CSS variables:
8
+ *
9
+ * :root {
10
+ * --background: 0 0% 100%;
11
+ * --primary: 222.2 47.4% 11.2%;
12
+ * --primary-foreground: 210 40% 98%;
13
+ * }
14
+ * .dark {
15
+ * --background: 222.2 84% 4.9%;
16
+ * --primary: 210 40% 98%;
17
+ * }
18
+ *
19
+ * Each unprefixed name maps to `color.<name>` (shadcn variables are
20
+ * exclusively colors today — typography/spacing live in Tailwind config).
21
+ * When both blocks exist, paired names land as `{light, dark}` composite
22
+ * color values; lone names emit flat values.
23
+ */
24
+
25
+ import { type ThemeDocument, validateThemeTokens } from "../themes.js";
26
+ import { NotShadcnShape } from "../themes-errors.js";
27
+ import { stripCssComments } from "./css-comments.js";
28
+
29
+ interface CssVarMap {
30
+ readonly [name: string]: string;
31
+ }
32
+
33
+ export function importShadcn(body: string): ThemeDocument {
34
+ // Quick rejections so the auto-detect chain can fall through fast.
35
+ if (/"value"\s*:/i.test(body)) {
36
+ throw new NotShadcnShape('input contains `"value":` — looks like Style Dictionary JSON');
37
+ }
38
+ const lightBlock = extractBlock(body, ":root");
39
+ if (lightBlock === null) {
40
+ throw new NotShadcnShape();
41
+ }
42
+ const lightVars = parseCssVars(lightBlock);
43
+ if (Object.keys(lightVars).length === 0) {
44
+ throw new NotShadcnShape(":root block found but no `--name: value;` pairs inside");
45
+ }
46
+ const darkBlock = extractBlock(body, ".dark");
47
+ const darkVars = darkBlock === null ? null : parseCssVars(darkBlock);
48
+
49
+ const color: Record<string, unknown> = {};
50
+ for (const [name, lightVal] of Object.entries(lightVars)) {
51
+ const wrapped = wrapColorValue(lightVal);
52
+ if (darkVars && darkVars[name] !== undefined) {
53
+ color[name] = {
54
+ $value: { light: wrapped, dark: wrapColorValue(darkVars[name]) },
55
+ $type: "color",
56
+ };
57
+ } else {
58
+ color[name] = { $value: wrapped, $type: "color" };
59
+ }
60
+ }
61
+ // Surface dark-only names too (no light counterpart). Rare but real
62
+ // when an operator splits brand variants across blocks.
63
+ if (darkVars) {
64
+ for (const [name, darkVal] of Object.entries(darkVars)) {
65
+ if (name in color) continue;
66
+ color[name] = { $value: wrapColorValue(darkVal), $type: "color" };
67
+ }
68
+ }
69
+
70
+ const doc: ThemeDocument = { color };
71
+ return validateThemeTokens(doc);
72
+ }
73
+
74
+ /**
75
+ * Find a selector block (e.g. `:root` or `.dark`) and return the body
76
+ * inside the matching braces. Strips block comments before matching.
77
+ */
78
+ function extractBlock(body: string, selector: string): string | null {
79
+ const stripped = stripCssComments(body);
80
+ // Escape regex specials in the selector (`.` for `.dark`).
81
+ const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
82
+ const match = new RegExp(`${escaped}\\s*\\{`).exec(stripped);
83
+ if (!match) return null;
84
+ const start = match.index + match[0].length;
85
+ let depth = 1;
86
+ let i = start;
87
+ while (i < stripped.length && depth > 0) {
88
+ const ch = stripped[i];
89
+ if (ch === "{") depth += 1;
90
+ else if (ch === "}") depth -= 1;
91
+ if (depth === 0) break;
92
+ i += 1;
93
+ }
94
+ if (depth !== 0) return null;
95
+ return stripped.slice(start, i);
96
+ }
97
+
98
+ function parseCssVars(block: string): CssVarMap {
99
+ const out: Record<string, string> = {};
100
+ for (const raw of block.split(";")) {
101
+ const line = raw.trim();
102
+ if (line.length === 0 || !line.startsWith("--")) continue;
103
+ const colonIdx = line.indexOf(":");
104
+ if (colonIdx <= 0) continue;
105
+ const name = line.slice(2, colonIdx).trim();
106
+ const value = line.slice(colonIdx + 1).trim();
107
+ if (name.length === 0 || value.length === 0) continue;
108
+ out[name] = value;
109
+ }
110
+ return out;
111
+ }
112
+
113
+ /**
114
+ * shadcn often stores HSL channels without the `hsl(...)` wrapper
115
+ * (`--primary: 222.2 47.4% 11.2%`). Detect that shape and wrap so the
116
+ * resulting value is a valid CSS color string the renderer / browser
117
+ * can use directly. Hex / oklch / rgb / hsl(...) values pass through
118
+ * unchanged.
119
+ */
120
+ function wrapColorValue(raw: string): string {
121
+ const trimmed = raw.trim();
122
+ // Already a recognised CSS color → pass through.
123
+ if (/^#[0-9a-fA-F]{3,8}$/.test(trimmed)) return trimmed;
124
+ if (/^(oklch|rgb|rgba|hsl|hsla|lab|lch|color|hwb)\s*\(/i.test(trimmed)) return trimmed;
125
+ if (/^(transparent|currentColor)$/i.test(trimmed)) return trimmed;
126
+ // Three space-separated tokens with a `%` somewhere → shadcn HSL triple.
127
+ if (/^\d+(\.\d+)?\s+\d+(\.\d+)?%\s+\d+(\.\d+)?%$/.test(trimmed)) {
128
+ return `hsl(${trimmed})`;
129
+ }
130
+ // Bare named color or anything else — pass through; the Zod schema
131
+ // will catch genuinely-broken inputs at validateThemeTokens.
132
+ return trimmed;
133
+ }
@@ -0,0 +1,125 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * v0.11.1 (issue #76) — Style Dictionary v3/v4 importer.
5
+ *
6
+ * Style Dictionary stores tokens as `{ value: "…", attributes?: {…} }`
7
+ * leaves instead of DTCG's `$value` / `$type`. This importer walks the
8
+ * tree, rewrites `value` → `$value` (and `type` → `$type` where set),
9
+ * preserves `attributes` blocks under `$extensions.styleDictionary`,
10
+ * and feeds the result through `validateThemeTokens` for round-trip
11
+ * compatibility with the DTCG storage shape.
12
+ *
13
+ * Recognition heuristic: input must be valid JSON with at least one
14
+ * nested `{ value: … }` leaf and NO `$value` keys (the latter would
15
+ * indicate DTCG — the auto-detect chain handles priority).
16
+ */
17
+
18
+ import { type ThemeDocument, validateThemeTokens } from "../themes.js";
19
+ import { NotStyleDictionaryShape } from "../themes-errors.js";
20
+
21
+ export function importStyleDictionary(body: string): ThemeDocument {
22
+ let parsed: unknown;
23
+ try {
24
+ parsed = JSON.parse(body);
25
+ } catch (e) {
26
+ const msg = e instanceof Error ? e.message : String(e);
27
+ throw new NotStyleDictionaryShape(`Style Dictionary parse failed: ${msg}`);
28
+ }
29
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
30
+ throw new NotStyleDictionaryShape("Style Dictionary input must be a JSON object");
31
+ }
32
+ if (hasDollarValue(parsed)) {
33
+ throw new NotStyleDictionaryShape(
34
+ "input has `$value` leaves — looks like DTCG, not Style Dictionary",
35
+ );
36
+ }
37
+ if (!hasPlainValue(parsed)) {
38
+ throw new NotStyleDictionaryShape(
39
+ "input has no `{ value: … }` leaves typical of Style Dictionary",
40
+ );
41
+ }
42
+ const dtcg = rewriteSdToDtcg(parsed);
43
+ // Top-level Style Dictionary categories may not exactly match DTCG
44
+ // (e.g. `size` instead of `spacing`); rename common cases so the
45
+ // resulting document validates and renders correctly.
46
+ const renamed = renameTopLevelCategories(dtcg);
47
+ return validateThemeTokens(renamed);
48
+ }
49
+
50
+ function hasDollarValue(node: unknown): boolean {
51
+ if (node === null || typeof node !== "object") return false;
52
+ if (Array.isArray(node)) return node.some(hasDollarValue);
53
+ const obj = node as Record<string, unknown>;
54
+ if ("$value" in obj) return true;
55
+ for (const v of Object.values(obj)) {
56
+ if (hasDollarValue(v)) return true;
57
+ }
58
+ return false;
59
+ }
60
+
61
+ function hasPlainValue(node: unknown): boolean {
62
+ if (node === null || typeof node !== "object") return false;
63
+ if (Array.isArray(node)) return node.some(hasPlainValue);
64
+ const obj = node as Record<string, unknown>;
65
+ // A leaf in Style Dictionary has `value` AND no nested object siblings
66
+ // carrying their own value. Detecting "has value somewhere" is enough
67
+ // for the auto-detect sniff; full validation happens in rewriteSdToDtcg.
68
+ if ("value" in obj && typeof obj.value !== "object") return true;
69
+ for (const v of Object.values(obj)) {
70
+ if (hasPlainValue(v)) return true;
71
+ }
72
+ return false;
73
+ }
74
+
75
+ function rewriteSdToDtcg(node: unknown): unknown {
76
+ if (node === null || typeof node !== "object" || Array.isArray(node)) return node;
77
+ const obj = node as Record<string, unknown>;
78
+ // Leaf detection: has `value` but no nested children that themselves
79
+ // have `value` (deepest-first rewrite).
80
+ if ("value" in obj && typeof obj.value !== "object") {
81
+ const leaf: Record<string, unknown> = { $value: obj.value };
82
+ if ("type" in obj && typeof obj.type === "string") {
83
+ leaf.$type = obj.type;
84
+ }
85
+ if ("description" in obj && typeof obj.description === "string") {
86
+ leaf.$description = obj.description;
87
+ }
88
+ if ("attributes" in obj && obj.attributes && typeof obj.attributes === "object") {
89
+ leaf.$extensions = { styleDictionary: { attributes: obj.attributes } };
90
+ }
91
+ return leaf;
92
+ }
93
+ const out: Record<string, unknown> = {};
94
+ for (const [k, v] of Object.entries(obj)) {
95
+ out[k] = rewriteSdToDtcg(v);
96
+ }
97
+ return out;
98
+ }
99
+
100
+ /**
101
+ * Common Style Dictionary category name → DTCG category map. Only
102
+ * applied at the root; nested groups keep their original names.
103
+ */
104
+ const SD_TO_DTCG_CATEGORY: Record<string, string> = {
105
+ size: "spacing",
106
+ sizes: "spacing",
107
+ spaces: "spacing",
108
+ colors: "color",
109
+ fonts: "typography",
110
+ font: "typography",
111
+ shadows: "shadow",
112
+ radii: "radius",
113
+ borderRadius: "radius",
114
+ };
115
+
116
+ function renameTopLevelCategories(doc: unknown): unknown {
117
+ if (!doc || typeof doc !== "object" || Array.isArray(doc)) return doc;
118
+ const obj = doc as Record<string, unknown>;
119
+ const out: Record<string, unknown> = {};
120
+ for (const [k, v] of Object.entries(obj)) {
121
+ const renamed = SD_TO_DTCG_CATEGORY[k];
122
+ out[renamed ?? k] = v;
123
+ }
124
+ return out;
125
+ }