stoop 0.2.1 → 0.4.0

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 (56) hide show
  1. package/README.md +48 -103
  2. package/dist/api/core-api.d.ts +34 -0
  3. package/dist/api/core-api.js +135 -0
  4. package/dist/api/global-css.d.ts +0 -11
  5. package/dist/api/global-css.js +42 -0
  6. package/dist/api/styled.d.ts +0 -1
  7. package/dist/api/styled.js +419 -0
  8. package/dist/api/theme-provider.d.ts +41 -0
  9. package/dist/api/theme-provider.js +223 -0
  10. package/dist/constants.d.ts +13 -4
  11. package/dist/constants.js +154 -0
  12. package/dist/core/cache.d.ts +5 -9
  13. package/dist/core/cache.js +68 -0
  14. package/dist/core/compiler.d.ts +11 -0
  15. package/dist/core/compiler.js +206 -0
  16. package/dist/core/theme-manager.d.ts +27 -4
  17. package/dist/core/theme-manager.js +107 -0
  18. package/dist/core/variants.js +38 -0
  19. package/dist/create-stoop-internal.d.ts +30 -0
  20. package/dist/create-stoop-internal.js +123 -0
  21. package/dist/create-stoop-ssr.d.ts +10 -0
  22. package/dist/create-stoop-ssr.js +26 -0
  23. package/dist/create-stoop.d.ts +32 -4
  24. package/dist/create-stoop.js +156 -0
  25. package/dist/inject.d.ts +113 -0
  26. package/dist/inject.js +308 -0
  27. package/dist/types/index.d.ts +157 -17
  28. package/dist/types/index.js +5 -0
  29. package/dist/types/react-polymorphic-types.d.ts +15 -8
  30. package/dist/utils/auto-preload.d.ts +45 -0
  31. package/dist/utils/auto-preload.js +167 -0
  32. package/dist/utils/helpers.d.ts +64 -0
  33. package/dist/utils/helpers.js +150 -0
  34. package/dist/utils/storage.d.ts +148 -0
  35. package/dist/utils/storage.js +396 -0
  36. package/dist/utils/{string.d.ts → theme-utils.d.ts} +36 -12
  37. package/dist/utils/theme-utils.js +353 -0
  38. package/dist/utils/theme.d.ts +17 -5
  39. package/dist/utils/theme.js +304 -0
  40. package/package.json +48 -24
  41. package/LICENSE.md +0 -21
  42. package/dist/api/create-theme.d.ts +0 -13
  43. package/dist/api/css.d.ts +0 -16
  44. package/dist/api/keyframes.d.ts +0 -16
  45. package/dist/api/provider.d.ts +0 -19
  46. package/dist/api/use-theme.d.ts +0 -13
  47. package/dist/index.d.ts +0 -6
  48. package/dist/index.js +0 -13
  49. package/dist/inject/browser.d.ts +0 -59
  50. package/dist/inject/dedup.d.ts +0 -29
  51. package/dist/inject/index.d.ts +0 -41
  52. package/dist/inject/ssr.d.ts +0 -28
  53. package/dist/utils/theme-map.d.ts +0 -25
  54. package/dist/utils/theme-validation.d.ts +0 -13
  55. package/dist/utils/type-guards.d.ts +0 -26
  56. package/dist/utils/utilities.d.ts +0 -14
@@ -0,0 +1,353 @@
1
+ /**
2
+ * Theme-related string utilities and property mapping.
3
+ * Provides hashing, CSS sanitization, and theme scale mapping for property-aware token resolution.
4
+ */
5
+ import { DEFAULT_THEME_MAP, SANITIZE_CACHE_SIZE_LIMIT } from "../constants";
6
+ // ============================================================================
7
+ // String Utilities
8
+ // ============================================================================
9
+ import { LRUCache } from "../core/cache";
10
+ let cachedRootRegex = null;
11
+ const selectorCache = new LRUCache(SANITIZE_CACHE_SIZE_LIMIT);
12
+ const propertyNameCache = new LRUCache(SANITIZE_CACHE_SIZE_LIMIT);
13
+ const sanitizeClassNameCache = new LRUCache(SANITIZE_CACHE_SIZE_LIMIT);
14
+ const variableNameCache = new LRUCache(SANITIZE_CACHE_SIZE_LIMIT);
15
+ /**
16
+ * Generates a hash string from an input string using FNV-1a algorithm.
17
+ * Includes string length to reduce collision probability.
18
+ *
19
+ * @param str - String to hash
20
+ * @returns Hashed string
21
+ */
22
+ export function hash(str) {
23
+ const FNV_OFFSET_BASIS = 2166136261;
24
+ const FNV_PRIME = 16777619;
25
+ let hash = FNV_OFFSET_BASIS;
26
+ for (let i = 0; i < str.length; i++) {
27
+ hash ^= str.charCodeAt(i);
28
+ hash = Math.imul(hash, FNV_PRIME);
29
+ }
30
+ hash ^= str.length;
31
+ return (hash >>> 0).toString(36);
32
+ }
33
+ /**
34
+ * Generates a hash string from an object by stringifying it.
35
+ *
36
+ * @param obj - Object to hash
37
+ * @returns Hashed string
38
+ */
39
+ export function hashObject(obj) {
40
+ try {
41
+ return hash(JSON.stringify(obj));
42
+ }
43
+ catch {
44
+ return hash(String(obj));
45
+ }
46
+ }
47
+ /**
48
+ * Converts a camelCase string to kebab-case.
49
+ *
50
+ * @param str - String to convert
51
+ * @returns Kebab-case string
52
+ */
53
+ export function toKebabCase(str) {
54
+ return str.replace(/([A-Z])/g, "-$1").toLowerCase();
55
+ }
56
+ /**
57
+ * Internal function to escape CSS values with optional brace escaping.
58
+ *
59
+ * @param value - Value to escape
60
+ * @param escapeBraces - Whether to escape curly braces
61
+ * @returns Escaped value string
62
+ */
63
+ function escapeCSSValueInternal(value, escapeBraces = false) {
64
+ const str = String(value);
65
+ let result = str
66
+ .replace(/\\/g, "\\\\")
67
+ .replace(/"/g, '\\"')
68
+ .replace(/'/g, "\\'")
69
+ .replace(/;/g, "\\;")
70
+ .replace(/\n/g, "\\A ")
71
+ .replace(/\r/g, "")
72
+ .replace(/\f/g, "\\C ");
73
+ if (escapeBraces) {
74
+ result = result.replace(/\{/g, "\\7B ").replace(/\}/g, "\\7D ");
75
+ }
76
+ return result;
77
+ }
78
+ /**
79
+ * Escapes CSS property values to prevent injection attacks.
80
+ * Escapes quotes, semicolons, and other special characters.
81
+ *
82
+ * @param value - Value to escape
83
+ * @returns Escaped value string
84
+ */
85
+ export function escapeCSSValue(value) {
86
+ return escapeCSSValueInternal(value, false);
87
+ }
88
+ /**
89
+ * Validates and sanitizes CSS selectors to prevent injection attacks.
90
+ * Only allows safe selector characters. Returns empty string for invalid selectors.
91
+ * Uses memoization for performance.
92
+ *
93
+ * @param selector - Selector to sanitize
94
+ * @returns Sanitized selector string or empty string if invalid
95
+ */
96
+ export function sanitizeCSSSelector(selector) {
97
+ const cached = selectorCache.get(selector);
98
+ if (cached !== undefined) {
99
+ return cached;
100
+ }
101
+ const sanitized = selector.replace(/[^a-zA-Z0-9\s\-_>+~:.#[\]&@()]/g, "");
102
+ const result = !sanitized.trim() || /^[>+~:.#[\]&@()\s]+$/.test(sanitized) ? "" : sanitized;
103
+ selectorCache.set(selector, result);
104
+ return result;
105
+ }
106
+ /**
107
+ * Validates and sanitizes CSS variable names to prevent injection attacks.
108
+ * CSS custom properties must start with -- and contain only valid characters.
109
+ * Uses memoization for performance.
110
+ *
111
+ * @param name - Variable name to sanitize
112
+ * @returns Sanitized variable name
113
+ */
114
+ export function sanitizeCSSVariableName(name) {
115
+ const cached = variableNameCache.get(name);
116
+ if (cached !== undefined) {
117
+ return cached;
118
+ }
119
+ const sanitized = name.replace(/[^a-zA-Z0-9-_]/g, "-");
120
+ const cleaned = sanitized.replace(/^[\d-]+/, "").replace(/^-+/, "");
121
+ const result = cleaned || "invalid";
122
+ variableNameCache.set(name, result);
123
+ return result;
124
+ }
125
+ /**
126
+ * Escapes CSS variable values to prevent injection attacks.
127
+ *
128
+ * @param value - Value to escape
129
+ * @returns Escaped value string
130
+ */
131
+ export function escapeCSSVariableValue(value) {
132
+ return escapeCSSValueInternal(value, true);
133
+ }
134
+ /**
135
+ * Sanitizes prefix for use in CSS selectors and class names.
136
+ * Only allows alphanumeric characters, hyphens, and underscores.
137
+ * Defaults to "stoop" if prefix is empty or becomes empty after sanitization.
138
+ *
139
+ * @param prefix - Prefix to sanitize
140
+ * @returns Sanitized prefix string (never empty, defaults to "stoop")
141
+ */
142
+ export function sanitizePrefix(prefix) {
143
+ if (!prefix) {
144
+ return "stoop";
145
+ }
146
+ const sanitized = prefix.replace(/[^a-zA-Z0-9-_]/g, "");
147
+ const cleaned = sanitized.replace(/^[\d-]+/, "").replace(/^-+/, "");
148
+ // Return "stoop" as default if sanitization results in empty string
149
+ return cleaned || "stoop";
150
+ }
151
+ /**
152
+ * Sanitizes media query strings to prevent injection attacks.
153
+ * Only allows safe characters for media queries.
154
+ *
155
+ * @param mediaQuery - Media query string to sanitize
156
+ * @returns Sanitized media query string or empty string if invalid
157
+ */
158
+ export function sanitizeMediaQuery(mediaQuery) {
159
+ if (!mediaQuery || typeof mediaQuery !== "string") {
160
+ return "";
161
+ }
162
+ const sanitized = mediaQuery.replace(/[^a-zA-Z0-9\s():,<>=\-@]/g, "");
163
+ if (!sanitized.trim() || !/[a-zA-Z]/.test(sanitized)) {
164
+ return "";
165
+ }
166
+ return sanitized;
167
+ }
168
+ /**
169
+ * Sanitizes CSS class names to prevent injection attacks.
170
+ * Only allows valid CSS class name characters.
171
+ * Uses memoization for performance.
172
+ *
173
+ * @param className - Class name(s) to sanitize
174
+ * @returns Sanitized class name string
175
+ */
176
+ export function sanitizeClassName(className) {
177
+ if (!className || typeof className !== "string") {
178
+ return "";
179
+ }
180
+ const cached = sanitizeClassNameCache.get(className);
181
+ if (cached !== undefined) {
182
+ return cached;
183
+ }
184
+ const classes = className.trim().split(/\s+/);
185
+ const sanitizedClasses = [];
186
+ for (const cls of classes) {
187
+ if (!cls) {
188
+ continue;
189
+ }
190
+ const sanitized = cls.replace(/[^a-zA-Z0-9-_]/g, "");
191
+ const cleaned = sanitized.replace(/^\d+/, "");
192
+ if (cleaned && /^[a-zA-Z-_]/.test(cleaned)) {
193
+ sanitizedClasses.push(cleaned);
194
+ }
195
+ }
196
+ const result = sanitizedClasses.join(" ");
197
+ sanitizeClassNameCache.set(className, result);
198
+ return result;
199
+ }
200
+ /**
201
+ * Sanitizes CSS property names to prevent injection attacks.
202
+ * Uses memoization for performance.
203
+ *
204
+ * @param propertyName - Property name to sanitize
205
+ * @returns Sanitized property name
206
+ */
207
+ export function sanitizeCSSPropertyName(propertyName) {
208
+ if (!propertyName || typeof propertyName !== "string") {
209
+ return "";
210
+ }
211
+ const cached = propertyNameCache.get(propertyName);
212
+ if (cached !== undefined) {
213
+ return cached;
214
+ }
215
+ const kebab = toKebabCase(propertyName);
216
+ const sanitized = kebab.replace(/[^a-zA-Z0-9-]/g, "").replace(/^-+|-+$/g, "");
217
+ const result = sanitized.replace(/^\d+/, "") || "";
218
+ propertyNameCache.set(propertyName, result);
219
+ return result;
220
+ }
221
+ /**
222
+ * Validates keyframe percentage keys (e.g., "0%", "50%", "from", "to").
223
+ *
224
+ * @param key - Keyframe key to validate
225
+ * @returns True if key is valid
226
+ */
227
+ export function validateKeyframeKey(key) {
228
+ if (!key || typeof key !== "string") {
229
+ return false;
230
+ }
231
+ if (key === "from" || key === "to") {
232
+ return true;
233
+ }
234
+ const percentageMatch = /^\d+(\.\d+)?%$/.test(key);
235
+ if (percentageMatch) {
236
+ const num = parseFloat(key);
237
+ return num >= 0 && num <= 100;
238
+ }
239
+ return false;
240
+ }
241
+ /**
242
+ * Gets a pre-compiled regex for matching :root CSS selector blocks.
243
+ * Uses caching for performance.
244
+ *
245
+ * @param prefix - Optional prefix (unused, kept for API compatibility)
246
+ * @returns RegExp for matching :root selector blocks
247
+ */
248
+ export function getRootRegex(prefix = "") {
249
+ if (!cachedRootRegex) {
250
+ const rootSelector = ":root";
251
+ const escapedSelector = rootSelector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
252
+ // Match :root block - use greedy match to handle nested braces (e.g., calc())
253
+ // Since theme vars should only have one :root block, greedy matching is safe
254
+ // Greedy matching ensures we capture the entire :root block, including nested functions
255
+ cachedRootRegex = new RegExp(`${escapedSelector}\\s*\\{[\\s\\S]*\\}`);
256
+ }
257
+ return cachedRootRegex;
258
+ }
259
+ // ============================================================================
260
+ // Theme Map Utilities
261
+ // ============================================================================
262
+ /**
263
+ * Auto-detects theme scale from CSS property name using pattern matching.
264
+ * Used as fallback when property is not in DEFAULT_THEME_MAP.
265
+ *
266
+ * @param property - CSS property name
267
+ * @returns Theme scale name or undefined if no pattern matches
268
+ */
269
+ export function autoDetectScale(property) {
270
+ // Color properties
271
+ if (property.includes("Color") ||
272
+ property === "fill" ||
273
+ property === "stroke" ||
274
+ property === "accentColor" ||
275
+ property === "caretColor" ||
276
+ property === "border" ||
277
+ property === "outline" ||
278
+ (property.includes("background") && !property.includes("Size") && !property.includes("Image"))) {
279
+ return "colors";
280
+ }
281
+ // Spacing properties
282
+ if (/^(margin|padding|gap|inset|top|right|bottom|left|rowGap|columnGap|gridGap|gridRowGap|gridColumnGap)/.test(property) ||
283
+ property.includes("Block") ||
284
+ property.includes("Inline")) {
285
+ return "space";
286
+ }
287
+ // Size properties
288
+ if (/(width|height|size|basis)$/i.test(property) ||
289
+ property.includes("BlockSize") ||
290
+ property.includes("InlineSize")) {
291
+ return "sizes";
292
+ }
293
+ // Typography: Font Size
294
+ if (property === "fontSize" || (property === "font" && !property.includes("Family"))) {
295
+ return "fontSizes";
296
+ }
297
+ // Typography: Font Family
298
+ if (property === "fontFamily" || property.includes("FontFamily")) {
299
+ return "fonts";
300
+ }
301
+ // Typography: Font Weight
302
+ if (property === "fontWeight" || property.includes("FontWeight")) {
303
+ return "fontWeights";
304
+ }
305
+ // Typography: Letter Spacing
306
+ if (property === "letterSpacing" || property.includes("LetterSpacing")) {
307
+ return "letterSpacings";
308
+ }
309
+ // Border Radius
310
+ if (property.includes("Radius") || property.includes("radius")) {
311
+ return "radii";
312
+ }
313
+ // Shadows
314
+ if (property.includes("Shadow") ||
315
+ property.includes("shadow") ||
316
+ property === "filter" ||
317
+ property === "backdropFilter") {
318
+ return "shadows";
319
+ }
320
+ // Z-Index
321
+ if (property === "zIndex" || property.includes("ZIndex") || property.includes("z-index")) {
322
+ return "zIndices";
323
+ }
324
+ // Opacity
325
+ if (property === "opacity" || property.includes("Opacity")) {
326
+ return "opacities";
327
+ }
328
+ // Transitions and animations
329
+ if (property.startsWith("transition") ||
330
+ property.startsWith("animation") ||
331
+ property.includes("Transition") ||
332
+ property.includes("Animation")) {
333
+ return "transitions";
334
+ }
335
+ return undefined;
336
+ }
337
+ /**
338
+ * Gets the theme scale for a CSS property.
339
+ * Checks user themeMap first, then default themeMap, then pattern matching.
340
+ *
341
+ * @param property - CSS property name
342
+ * @param userThemeMap - Optional user-provided themeMap override
343
+ * @returns Theme scale name or undefined if no mapping found
344
+ */
345
+ export function getScaleForProperty(property, userThemeMap) {
346
+ if (userThemeMap && property in userThemeMap) {
347
+ return userThemeMap[property];
348
+ }
349
+ if (property in DEFAULT_THEME_MAP) {
350
+ return DEFAULT_THEME_MAP[property];
351
+ }
352
+ return autoDetectScale(property);
353
+ }
@@ -5,12 +5,12 @@
5
5
  */
6
6
  import type { CSS, Theme, ThemeScale } from "../types";
7
7
  /**
8
- * Compares two themes for equality by structure and values.
9
- * Excludes 'media' property from comparison since it's not a CSS variable scale.
8
+ * Compares two themes for structural and value equality.
9
+ * Excludes 'media' property from comparison.
10
10
  *
11
11
  * @param theme1 - First theme to compare
12
12
  * @param theme2 - Second theme to compare
13
- * @returns True if themes are equal, false otherwise
13
+ * @returns True if themes are equal
14
14
  */
15
15
  export declare function themesAreEqual(theme1: Theme | null, theme2: Theme | null): boolean;
16
16
  /**
@@ -28,9 +28,21 @@ export declare function tokenToCSSVar(token: string, theme?: Theme, property?: s
28
28
  *
29
29
  * @param theme - Theme object to convert to CSS variables
30
30
  * @param prefix - Optional prefix for CSS variable names
31
- * @returns CSS string with :root selector and CSS variables
31
+ * @param attributeSelector - Optional attribute selector (e.g., '[data-theme="light"]'). Defaults to ':root'
32
+ * @returns CSS string with selector and CSS variables
32
33
  */
33
- export declare function generateCSSVariables(theme: Theme, prefix?: string): string;
34
+ export declare function generateCSSVariables(theme: Theme, prefix?: string, attributeSelector?: string): string;
35
+ /**
36
+ * Generates CSS custom properties for all themes using attribute selectors.
37
+ * This allows all themes to be available simultaneously, with theme switching
38
+ * handled by changing the data-theme attribute.
39
+ *
40
+ * @param themes - Map of theme names to theme objects
41
+ * @param prefix - Optional prefix for CSS variable names
42
+ * @param attribute - Attribute name for theme selection (defaults to 'data-theme')
43
+ * @returns CSS string with all theme CSS variables
44
+ */
45
+ export declare function generateAllThemeVariables(themes: Record<string, Theme>, prefix?: string, attribute?: string): string;
34
46
  /**
35
47
  * Recursively replaces theme tokens with CSS variable references in a CSS object.
36
48
  *
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Theme token resolution utilities.
3
+ * Converts theme tokens to CSS variables for runtime theme switching.
4
+ * Uses cached token index for efficient lookups and theme comparison.
5
+ */
6
+ import { isCSSObject, isThemeObject, isProduction } from "./helpers";
7
+ import { escapeCSSVariableValue, getScaleForProperty, sanitizeCSSVariableName, } from "./theme-utils";
8
+ // Pre-compiled regex for token replacement (matches $primary, -$medium, $colors.primary, etc.)
9
+ const TOKEN_REGEX = /(-?\$[a-zA-Z][a-zA-Z0-9]*(?:\$[a-zA-Z][a-zA-Z0-9]*)?(?:\.[a-zA-Z][a-zA-Z0-9]*)?)/g;
10
+ /**
11
+ * Builds an index of all tokens in a theme for fast lookups.
12
+ *
13
+ * @param theme - Theme to index
14
+ * @returns Map of token names to their paths in the theme
15
+ */
16
+ function buildTokenIndex(theme) {
17
+ const index = new Map();
18
+ function processThemeObject(obj, path = []) {
19
+ const keys = Object.keys(obj);
20
+ for (const key of keys) {
21
+ const value = obj[key];
22
+ const currentPath = [...path, key];
23
+ if (isThemeObject(value)) {
24
+ processThemeObject(value, currentPath);
25
+ }
26
+ else {
27
+ const existing = index.get(key);
28
+ if (existing) {
29
+ existing.push(currentPath);
30
+ }
31
+ else {
32
+ index.set(key, [currentPath]);
33
+ }
34
+ }
35
+ }
36
+ }
37
+ processThemeObject(theme);
38
+ for (const [, paths] of index.entries()) {
39
+ if (paths.length > 1) {
40
+ paths.sort((a, b) => {
41
+ const depthDiff = a.length - b.length;
42
+ if (depthDiff !== 0) {
43
+ return depthDiff;
44
+ }
45
+ const pathA = a.join(".");
46
+ const pathB = b.join(".");
47
+ return pathA.localeCompare(pathB);
48
+ });
49
+ }
50
+ }
51
+ return index;
52
+ }
53
+ /**
54
+ * Checks if two themes have the same top-level keys (excluding 'media').
55
+ *
56
+ * @param theme1 - First theme
57
+ * @param theme2 - Second theme
58
+ * @returns True if themes have same keys
59
+ */
60
+ function themesHaveSameKeys(theme1, theme2) {
61
+ const keys1 = Object.keys(theme1).filter((key) => key !== "media");
62
+ const keys2 = Object.keys(theme2).filter((key) => key !== "media");
63
+ if (keys1.length !== keys2.length) {
64
+ return false;
65
+ }
66
+ for (const key of keys1) {
67
+ if (!(key in theme2)) {
68
+ return false;
69
+ }
70
+ }
71
+ return true;
72
+ }
73
+ /**
74
+ * Compares two themes for structural and value equality.
75
+ * Excludes 'media' property from comparison.
76
+ *
77
+ * @param theme1 - First theme to compare
78
+ * @param theme2 - Second theme to compare
79
+ * @returns True if themes are equal
80
+ */
81
+ export function themesAreEqual(theme1, theme2) {
82
+ if (theme1 === theme2) {
83
+ return true;
84
+ }
85
+ if (!theme1 || !theme2) {
86
+ return false;
87
+ }
88
+ if (!themesHaveSameKeys(theme1, theme2)) {
89
+ return false;
90
+ }
91
+ const theme1WithoutMedia = { ...theme1 };
92
+ const theme2WithoutMedia = { ...theme2 };
93
+ delete theme1WithoutMedia.media;
94
+ delete theme2WithoutMedia.media;
95
+ return JSON.stringify(theme1WithoutMedia) === JSON.stringify(theme2WithoutMedia);
96
+ }
97
+ /**
98
+ * Finds a token in the theme, optionally scoped to a specific scale.
99
+ *
100
+ * @param theme - Theme to search
101
+ * @param tokenName - Token name to find
102
+ * @param scale - Optional scale to search within first
103
+ * @returns Path to token or null if not found
104
+ */
105
+ function findTokenInTheme(theme, tokenName, scale) {
106
+ if (scale && scale in theme) {
107
+ const scaleValue = theme[scale];
108
+ if (scaleValue &&
109
+ typeof scaleValue === "object" &&
110
+ !Array.isArray(scaleValue) &&
111
+ tokenName in scaleValue) {
112
+ return [scale, tokenName];
113
+ }
114
+ }
115
+ const index = buildTokenIndex(theme);
116
+ const paths = index.get(tokenName);
117
+ if (!paths || paths.length === 0) {
118
+ return null;
119
+ }
120
+ return paths[0];
121
+ }
122
+ /**
123
+ * Converts a theme token string to a CSS variable reference.
124
+ *
125
+ * @param token - Token string (e.g., "$primary" or "$colors$primary")
126
+ * @param theme - Optional theme for token resolution
127
+ * @param property - Optional CSS property name for scale detection
128
+ * @param themeMap - Optional theme scale mappings
129
+ * @returns CSS variable reference string
130
+ */
131
+ export function tokenToCSSVar(token, theme, property, themeMap) {
132
+ if (!token.startsWith("$")) {
133
+ return token;
134
+ }
135
+ const tokenName = token.slice(1);
136
+ // Handle explicit scale: $colors$primary or $colors.primary
137
+ if (tokenName.includes("$") || tokenName.includes(".")) {
138
+ const parts = tokenName.includes("$") ? tokenName.split("$") : tokenName.split(".");
139
+ const sanitizedParts = parts.map((part) => sanitizeCSSVariableName(part));
140
+ const cssVarName = `--${sanitizedParts.join("-")}`;
141
+ return `var(${cssVarName})`;
142
+ }
143
+ // Handle shorthand token: $primary
144
+ if (theme && property) {
145
+ const scale = getScaleForProperty(property, themeMap);
146
+ if (scale) {
147
+ const foundPath = findTokenInTheme(theme, tokenName, scale);
148
+ if (foundPath) {
149
+ const sanitizedParts = foundPath.map((part) => sanitizeCSSVariableName(part));
150
+ const cssVarName = `--${sanitizedParts.join("-")}`;
151
+ return `var(${cssVarName})`;
152
+ }
153
+ }
154
+ const index = buildTokenIndex(theme);
155
+ const paths = index.get(tokenName);
156
+ if (paths && paths.length > 1) {
157
+ if (!isProduction()) {
158
+ const scaleInfo = scale
159
+ ? `Property "${property}" maps to "${scale}" scale, but token not found there. `
160
+ : `No scale mapping found for property "${property}". `;
161
+ // eslint-disable-next-line no-console
162
+ console.warn(`[Stoop] Ambiguous token "$${tokenName}" found in multiple categories: ${paths.map((p) => p.join(".")).join(", ")}. ` +
163
+ `${scaleInfo}` +
164
+ `Using "${paths[0].join(".")}" (deterministic: shorter paths first, then alphabetical). ` +
165
+ `Use full path "$${paths[0].join(".")}" to be explicit.`);
166
+ }
167
+ }
168
+ const foundPath = findTokenInTheme(theme, tokenName);
169
+ if (foundPath) {
170
+ const sanitizedParts = foundPath.map((part) => sanitizeCSSVariableName(part));
171
+ const cssVarName = `--${sanitizedParts.join("-")}`;
172
+ return `var(${cssVarName})`;
173
+ }
174
+ }
175
+ else if (theme) {
176
+ const index = buildTokenIndex(theme);
177
+ const paths = index.get(tokenName);
178
+ if (paths && paths.length > 1) {
179
+ if (!isProduction()) {
180
+ // eslint-disable-next-line no-console
181
+ console.warn(`[Stoop] Ambiguous token "$${tokenName}" found in multiple categories: ${paths.map((p) => p.join(".")).join(", ")}. ` +
182
+ `Using "${paths[0].join(".")}" (deterministic: shorter paths first, then alphabetical). ` +
183
+ `Use full path "$${paths[0].join(".")}" to be explicit, or use with a CSS property for automatic resolution.`);
184
+ }
185
+ }
186
+ const foundPath = findTokenInTheme(theme, tokenName);
187
+ if (foundPath) {
188
+ const sanitizedParts = foundPath.map((part) => sanitizeCSSVariableName(part));
189
+ const cssVarName = `--${sanitizedParts.join("-")}`;
190
+ return `var(${cssVarName})`;
191
+ }
192
+ }
193
+ const sanitizedTokenName = sanitizeCSSVariableName(tokenName);
194
+ const cssVarName = `--${sanitizedTokenName}`;
195
+ return `var(${cssVarName})`;
196
+ }
197
+ /**
198
+ * Generates CSS custom properties from a theme object.
199
+ *
200
+ * @param theme - Theme object to convert to CSS variables
201
+ * @param prefix - Optional prefix for CSS variable names
202
+ * @param attributeSelector - Optional attribute selector (e.g., '[data-theme="light"]'). Defaults to ':root'
203
+ * @returns CSS string with selector and CSS variables
204
+ */
205
+ export function generateCSSVariables(theme, prefix = "stoop", attributeSelector) {
206
+ const rootSelector = attributeSelector || ":root";
207
+ const variables = [];
208
+ function processThemeObject(obj, path = []) {
209
+ const keys = Object.keys(obj).sort();
210
+ for (const key of keys) {
211
+ // Media queries cannot be CSS variables
212
+ if (key === "media") {
213
+ continue;
214
+ }
215
+ const value = obj[key];
216
+ const currentPath = [...path, key];
217
+ if (isThemeObject(value)) {
218
+ processThemeObject(value, currentPath);
219
+ }
220
+ else {
221
+ const sanitizedParts = currentPath.map((part) => sanitizeCSSVariableName(part));
222
+ const varName = `--${sanitizedParts.join("-")}`;
223
+ const escapedValue = typeof value === "string" || typeof value === "number"
224
+ ? escapeCSSVariableValue(value)
225
+ : String(value);
226
+ variables.push(` ${varName}: ${escapedValue};`);
227
+ }
228
+ }
229
+ }
230
+ processThemeObject(theme);
231
+ if (variables.length === 0) {
232
+ return "";
233
+ }
234
+ return `${rootSelector} {\n${variables.join("\n")}\n}`;
235
+ }
236
+ /**
237
+ * Generates CSS custom properties for all themes using attribute selectors.
238
+ * This allows all themes to be available simultaneously, with theme switching
239
+ * handled by changing the data-theme attribute.
240
+ *
241
+ * @param themes - Map of theme names to theme objects
242
+ * @param prefix - Optional prefix for CSS variable names
243
+ * @param attribute - Attribute name for theme selection (defaults to 'data-theme')
244
+ * @returns CSS string with all theme CSS variables
245
+ */
246
+ export function generateAllThemeVariables(themes, prefix = "stoop", attribute = "data-theme") {
247
+ const themeBlocks = [];
248
+ for (const [themeName, theme] of Object.entries(themes)) {
249
+ const attributeSelector = `[${attribute}="${themeName}"]`;
250
+ const cssVars = generateCSSVariables(theme, prefix, attributeSelector);
251
+ if (cssVars) {
252
+ themeBlocks.push(cssVars);
253
+ }
254
+ }
255
+ return themeBlocks.join("\n\n");
256
+ }
257
+ /**
258
+ * Recursively replaces theme tokens with CSS variable references in a CSS object.
259
+ *
260
+ * @param obj - CSS object to process
261
+ * @param theme - Optional theme for token resolution
262
+ * @param themeMap - Optional theme scale mappings
263
+ * @param property - Optional CSS property name for scale detection
264
+ * @returns CSS object with tokens replaced by CSS variables
265
+ */
266
+ export function replaceThemeTokensWithVars(obj, theme, themeMap, property) {
267
+ if (!obj || typeof obj !== "object") {
268
+ return obj;
269
+ }
270
+ const result = {};
271
+ let hasTokens = false;
272
+ const keys = Object.keys(obj).sort();
273
+ for (const key of keys) {
274
+ const value = obj[key];
275
+ if (isCSSObject(value)) {
276
+ const processed = replaceThemeTokensWithVars(value, theme, themeMap, undefined);
277
+ result[key] = processed;
278
+ // Check if processing changed anything (indicates tokens were found)
279
+ if (processed !== value) {
280
+ hasTokens = true;
281
+ }
282
+ }
283
+ else if (typeof value === "string" && value.includes("$")) {
284
+ hasTokens = true;
285
+ const cssProperty = property || key;
286
+ result[key] = value.replace(TOKEN_REGEX, (token) => {
287
+ if (token.startsWith("-$")) {
288
+ const positiveToken = token.slice(1);
289
+ const cssVar = tokenToCSSVar(positiveToken, theme, cssProperty, themeMap);
290
+ return `calc(-1 * ${cssVar})`;
291
+ }
292
+ return tokenToCSSVar(token, theme, cssProperty, themeMap);
293
+ });
294
+ }
295
+ else {
296
+ result[key] = value;
297
+ }
298
+ }
299
+ // Early exit: if no tokens were found, return original object to avoid unnecessary copying
300
+ if (!hasTokens) {
301
+ return obj;
302
+ }
303
+ return result;
304
+ }