stoop 0.6.0 → 0.6.2

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.
@@ -0,0 +1,408 @@
1
+ /**
2
+ * CSS compilation engine.
3
+ * Converts CSS objects to CSS strings and generates unique class names.
4
+ * Handles nested selectors, media queries, styled component targeting, and theme tokens.
5
+ */
6
+ import { MAX_CSS_NESTING_DEPTH, STOOP_COMPONENT_SYMBOL } from "../constants";
7
+ import { injectCSS } from "../inject";
8
+ import { isValidCSSObject, applyUtilities, isStyledComponentRef } from "../utils/helpers";
9
+ import { replaceThemeTokensWithVars } from "../utils/theme";
10
+ import { escapeCSSValue, hash, sanitizeCSSSelector, sanitizeMediaQuery, sanitizePrefix, } from "../utils/theme-utils";
11
+ import { classNameCache, cssStringCache } from "./cache";
12
+ import { sanitizeCSSPropertyName } from "./stringify";
13
+ /**
14
+ * Set of CSS properties that require length units for numeric values.
15
+ * These properties should have 'px' automatically appended to unitless numbers.
16
+ */
17
+ const DIMENSIONAL_PROPERTIES = new Set([
18
+ "width",
19
+ "height",
20
+ "min-width",
21
+ "min-height",
22
+ "max-width",
23
+ "max-height",
24
+ "top",
25
+ "right",
26
+ "bottom",
27
+ "left",
28
+ "margin",
29
+ "margin-top",
30
+ "margin-right",
31
+ "margin-bottom",
32
+ "margin-left",
33
+ "padding",
34
+ "padding-top",
35
+ "padding-right",
36
+ "padding-bottom",
37
+ "padding-left",
38
+ "border-width",
39
+ "border-top-width",
40
+ "border-right-width",
41
+ "border-bottom-width",
42
+ "border-left-width",
43
+ "border-radius",
44
+ "border-top-left-radius",
45
+ "border-top-right-radius",
46
+ "border-bottom-left-radius",
47
+ "border-bottom-right-radius",
48
+ "font-size",
49
+ "letter-spacing",
50
+ "word-spacing",
51
+ "text-indent",
52
+ "flex-basis",
53
+ "gap",
54
+ "row-gap",
55
+ "column-gap",
56
+ "grid-template-rows",
57
+ "grid-template-columns",
58
+ "grid-auto-rows",
59
+ "grid-auto-columns",
60
+ "perspective",
61
+ "outline-width",
62
+ "outline-offset",
63
+ "clip",
64
+ "vertical-align",
65
+ "object-position",
66
+ "background-position",
67
+ "background-size",
68
+ "mask-position",
69
+ "mask-size",
70
+ "scroll-margin",
71
+ "scroll-padding",
72
+ "shape-margin",
73
+ ]);
74
+ /**
75
+ * Checks if a CSS property requires length units for numeric values.
76
+ *
77
+ * @param property - CSS property name (in kebab-case)
78
+ * @returns True if the property requires units
79
+ */
80
+ function requiresUnit(property) {
81
+ return DIMENSIONAL_PROPERTIES.has(property);
82
+ }
83
+ /**
84
+ * Normalizes a CSS value by adding 'px' unit to unitless numeric values
85
+ * for properties that require units.
86
+ *
87
+ * @param property - CSS property name (in kebab-case)
88
+ * @param value - CSS value (string or number)
89
+ * @returns Normalized value string
90
+ */
91
+ function normalizeCSSValue(property, value) {
92
+ if (typeof value === "string") {
93
+ // If it's already a string, check if it's a unitless number
94
+ // Only add px if it's a pure number and the property requires units
95
+ if (requiresUnit(property) && /^-?\d+\.?\d*$/.test(value.trim())) {
96
+ return `${value}px`;
97
+ }
98
+ return value;
99
+ }
100
+ // For numeric values, add px if the property requires units
101
+ if (typeof value === "number" && requiresUnit(property)) {
102
+ return `${value}px`;
103
+ }
104
+ return String(value);
105
+ }
106
+ /**
107
+ * Checks if a key/value pair represents a styled component reference.
108
+ *
109
+ * @param key - Property key to check
110
+ * @param value - Property value to check
111
+ * @returns True if key/value represents a styled component reference
112
+ */
113
+ function isStyledComponentKey(key, value) {
114
+ if (typeof key === "symbol" && key === STOOP_COMPONENT_SYMBOL) {
115
+ return true;
116
+ }
117
+ if (isStyledComponentRef(value)) {
118
+ return true;
119
+ }
120
+ if (typeof key === "string" && key.startsWith("__STOOP_COMPONENT_")) {
121
+ return true;
122
+ }
123
+ return false;
124
+ }
125
+ /**
126
+ * Extracts the class name from a styled component reference.
127
+ *
128
+ * @param key - Property key
129
+ * @param value - Property value
130
+ * @returns Extracted class name or empty string
131
+ */
132
+ function getClassNameFromKeyOrValue(key, value) {
133
+ if (typeof value === "object" &&
134
+ value !== null &&
135
+ "__stoopClassName" in value &&
136
+ typeof value.__stoopClassName === "string") {
137
+ return value.__stoopClassName;
138
+ }
139
+ if (typeof key === "string" && key.startsWith("__STOOP_COMPONENT_")) {
140
+ return key.replace("__STOOP_COMPONENT_", "");
141
+ }
142
+ return "";
143
+ }
144
+ /**
145
+ * Converts a CSS object to a CSS string with proper nesting and selectors.
146
+ * Handles pseudo-selectors, media queries, combinators, and styled component targeting.
147
+ *
148
+ * @param obj - CSS object to convert
149
+ * @param selector - Current selector context
150
+ * @param depth - Current nesting depth
151
+ * @param media - Media query breakpoints
152
+ * @returns CSS string
153
+ */
154
+ export function cssObjectToString(obj, selector = "", depth = 0, media) {
155
+ if (!obj || typeof obj !== "object") {
156
+ return "";
157
+ }
158
+ if (depth > MAX_CSS_NESTING_DEPTH) {
159
+ return "";
160
+ }
161
+ const cssProperties = [];
162
+ const nestedRulesList = [];
163
+ // Only sort keys when selector is empty (for hashing/deterministic output)
164
+ // When selector is provided, we don't need deterministic ordering for performance
165
+ const keys = selector === "" ? Object.keys(obj).sort() : Object.keys(obj);
166
+ for (const key of keys) {
167
+ const value = obj[key];
168
+ if (isStyledComponentKey(key, value)) {
169
+ const componentClassName = getClassNameFromKeyOrValue(key, value);
170
+ if (!componentClassName) {
171
+ continue;
172
+ }
173
+ const sanitizedClassName = sanitizeCSSSelector(componentClassName);
174
+ if (!sanitizedClassName) {
175
+ continue;
176
+ }
177
+ const componentSelector = selector
178
+ ? `${selector} .${sanitizedClassName}`
179
+ : `.${sanitizedClassName}`;
180
+ const nestedCss = isValidCSSObject(value)
181
+ ? cssObjectToString(value, componentSelector, depth + 1, media)
182
+ : "";
183
+ if (nestedCss) {
184
+ nestedRulesList.push(nestedCss);
185
+ }
186
+ continue;
187
+ }
188
+ if (isValidCSSObject(value)) {
189
+ if (media && key in media) {
190
+ const mediaQuery = sanitizeMediaQuery(media[key]);
191
+ if (mediaQuery) {
192
+ const nestedCss = cssObjectToString(value, selector, depth + 1, media);
193
+ if (nestedCss) {
194
+ nestedRulesList.push(`${mediaQuery} { ${nestedCss} }`);
195
+ }
196
+ }
197
+ }
198
+ else if (key.startsWith("@")) {
199
+ const sanitizedKey = sanitizeCSSSelector(key);
200
+ if (sanitizedKey) {
201
+ const nestedCss = cssObjectToString(value, selector, depth + 1, media);
202
+ if (nestedCss) {
203
+ nestedRulesList.push(`${sanitizedKey} { ${nestedCss} }`);
204
+ }
205
+ }
206
+ }
207
+ else if (key.includes("&")) {
208
+ const sanitizedKey = sanitizeCSSSelector(key);
209
+ if (sanitizedKey) {
210
+ const parts = sanitizedKey.split("&");
211
+ // Handle comma-separated selectors: split by comma, apply & replacement to each part, then rejoin
212
+ if (selector.includes(",")) {
213
+ const selectorParts = selector.split(",").map((s) => s.trim());
214
+ const nestedSelectors = selectorParts.map((sel) => parts.join(sel));
215
+ const nestedSelector = nestedSelectors.join(", ");
216
+ const nestedCss = cssObjectToString(value, nestedSelector, depth + 1, media);
217
+ if (nestedCss) {
218
+ nestedRulesList.push(nestedCss);
219
+ }
220
+ }
221
+ else {
222
+ // Single selector: simple & replacement
223
+ const nestedSelector = parts.join(selector);
224
+ const nestedCss = cssObjectToString(value, nestedSelector, depth + 1, media);
225
+ if (nestedCss) {
226
+ nestedRulesList.push(nestedCss);
227
+ }
228
+ }
229
+ }
230
+ }
231
+ else if (key.startsWith(":")) {
232
+ const sanitizedKey = sanitizeCSSSelector(key);
233
+ if (sanitizedKey) {
234
+ const nestedSelector = `${selector}${sanitizedKey}`;
235
+ const nestedCss = cssObjectToString(value, nestedSelector, depth + 1, media);
236
+ if (nestedCss) {
237
+ nestedRulesList.push(nestedCss);
238
+ }
239
+ }
240
+ }
241
+ else if (key.includes(",")) {
242
+ // Handle comma-separated selectors (e.g., "a, a:visited")
243
+ // Split by comma, sanitize each part, then rejoin
244
+ // This must come before the space/combinator check since comma-separated selectors can contain spaces
245
+ const parts = key.split(",").map((part) => part.trim());
246
+ const sanitizedParts = parts
247
+ .map((part) => sanitizeCSSSelector(part))
248
+ .filter((part) => part);
249
+ if (sanitizedParts.length > 0) {
250
+ const nestedSelector = sanitizedParts.join(", ");
251
+ const nestedCss = cssObjectToString(value, nestedSelector, depth + 1, media);
252
+ if (nestedCss) {
253
+ nestedRulesList.push(nestedCss);
254
+ }
255
+ }
256
+ }
257
+ else if (key.includes(" ") || key.includes(">") || key.includes("+") || key.includes("~")) {
258
+ const sanitizedKey = sanitizeCSSSelector(key);
259
+ if (sanitizedKey) {
260
+ const startsWithCombinator = /^[\s>+~]/.test(sanitizedKey.trim());
261
+ const nestedSelector = startsWithCombinator
262
+ ? `${selector}${sanitizedKey}`
263
+ : `${selector} ${sanitizedKey}`;
264
+ const nestedCss = cssObjectToString(value, nestedSelector, depth + 1, media);
265
+ if (nestedCss) {
266
+ nestedRulesList.push(nestedCss);
267
+ }
268
+ }
269
+ }
270
+ else {
271
+ const sanitizedKey = sanitizeCSSSelector(key);
272
+ if (sanitizedKey) {
273
+ const nestedSelector = selector ? `${selector} ${sanitizedKey}` : sanitizedKey;
274
+ const nestedCss = cssObjectToString(value, nestedSelector, depth + 1, media);
275
+ if (nestedCss) {
276
+ nestedRulesList.push(nestedCss);
277
+ }
278
+ }
279
+ }
280
+ }
281
+ else if (value !== undefined) {
282
+ const property = sanitizeCSSPropertyName(key);
283
+ if (property && (typeof value === "string" || typeof value === "number")) {
284
+ const normalizedValue = normalizeCSSValue(property, value);
285
+ // Special handling for content property with empty string
286
+ // CSS requires content: ""; not content: ;
287
+ if (property === "content" && normalizedValue === "") {
288
+ cssProperties.push(`${property}: "";`);
289
+ }
290
+ else {
291
+ const escapedValue = escapeCSSValue(normalizedValue);
292
+ cssProperties.push(`${property}: ${escapedValue};`);
293
+ }
294
+ }
295
+ }
296
+ }
297
+ const parts = [];
298
+ if (cssProperties.length > 0) {
299
+ parts.push(`${selector} { ${cssProperties.join(" ")} }`);
300
+ }
301
+ parts.push(...nestedRulesList);
302
+ return parts.join("");
303
+ }
304
+ /**
305
+ * Extracts CSS properties string from a CSS object for hashing.
306
+ * Only processes top-level properties (no nested rules, media queries, etc.).
307
+ * This is optimized for simple CSS objects without nesting.
308
+ *
309
+ * @param obj - CSS object to process
310
+ * @param media - Optional media query breakpoints
311
+ * @returns CSS properties string without selector wrapper, or empty string if complex
312
+ */
313
+ function extractCSSPropertiesForHash(obj, media) {
314
+ if (!obj || typeof obj !== "object") {
315
+ return "";
316
+ }
317
+ const cssProperties = [];
318
+ const keys = Object.keys(obj).sort(); // Sort for deterministic hashing
319
+ for (const key of keys) {
320
+ const value = obj[key];
321
+ // Check for nested rules - if found, we need full stringification
322
+ if (isValidCSSObject(value)) {
323
+ return ""; // Complex CSS, use full stringification
324
+ }
325
+ // Check for media queries
326
+ if (media && key in media) {
327
+ return ""; // Has media queries, use full stringification
328
+ }
329
+ // Check for pseudo-selectors, combinators, etc.
330
+ if (key.startsWith("@") ||
331
+ key.includes("&") ||
332
+ key.startsWith(":") ||
333
+ key.includes(" ") ||
334
+ key.includes(">") ||
335
+ key.includes("+") ||
336
+ key.includes("~")) {
337
+ return ""; // Complex selector, use full stringification
338
+ }
339
+ if (value !== undefined && !isStyledComponentKey(key, value)) {
340
+ const property = sanitizeCSSPropertyName(key);
341
+ if (property && (typeof value === "string" || typeof value === "number")) {
342
+ const normalizedValue = normalizeCSSValue(property, value);
343
+ // Special handling for content property with empty string
344
+ // CSS requires content: ""; not content: ;
345
+ if (property === "content" && normalizedValue === "") {
346
+ cssProperties.push(`${property}: "";`);
347
+ }
348
+ else {
349
+ const escapedValue = escapeCSSValue(normalizedValue);
350
+ cssProperties.push(`${property}: ${escapedValue};`);
351
+ }
352
+ }
353
+ }
354
+ }
355
+ return cssProperties.join(" ");
356
+ }
357
+ /**
358
+ * Compiles CSS objects into CSS strings and generates unique class names.
359
+ * Handles nested selectors, media queries, styled component targeting, and theme tokens.
360
+ *
361
+ * @param styles - CSS object to compile
362
+ * @param currentTheme - Theme for token resolution
363
+ * @param prefix - Optional prefix for generated class names
364
+ * @param media - Optional media query breakpoints
365
+ * @param utils - Optional utility functions
366
+ * @param themeMap - Optional theme scale mappings
367
+ * @returns Generated class name
368
+ */
369
+ export function compileCSS(styles, currentTheme, prefix = "stoop", media, utils, themeMap) {
370
+ const sanitizedPrefix = sanitizePrefix(prefix);
371
+ const stylesWithUtils = applyUtilities(styles, utils);
372
+ const themedStyles = replaceThemeTokensWithVars(stylesWithUtils, currentTheme, themeMap);
373
+ // Optimize: Try to extract properties for hash first (fast path for simple CSS)
374
+ const propertiesString = extractCSSPropertiesForHash(themedStyles, media);
375
+ let cssString;
376
+ let stylesHash;
377
+ if (propertiesString) {
378
+ // Fast path: simple CSS without nested rules - use properties string for hash
379
+ cssString = propertiesString;
380
+ stylesHash = hash(cssString);
381
+ }
382
+ else {
383
+ // Fallback: complex CSS with nested rules - need full stringification
384
+ cssString = cssObjectToString(themedStyles, "", 0, media);
385
+ stylesHash = hash(cssString);
386
+ }
387
+ const className = sanitizedPrefix ? `${sanitizedPrefix}-${stylesHash}` : `css-${stylesHash}`;
388
+ const cacheKey = `${sanitizedPrefix}:${className}`;
389
+ const cachedCSS = cssStringCache.get(cacheKey);
390
+ if (cachedCSS) {
391
+ injectCSS(cachedCSS, sanitizedPrefix, cacheKey);
392
+ return className;
393
+ }
394
+ // Generate final CSS with className selector
395
+ let fullCSS;
396
+ if (propertiesString) {
397
+ // Fast path: wrap properties with selector
398
+ fullCSS = `.${className} { ${propertiesString} }`;
399
+ }
400
+ else {
401
+ // Fallback: full stringification with selector
402
+ fullCSS = cssObjectToString(themedStyles, `.${className}`, 0, media);
403
+ }
404
+ cssStringCache.set(cacheKey, fullCSS);
405
+ classNameCache.set(cacheKey, className);
406
+ injectCSS(fullCSS, sanitizedPrefix, cacheKey);
407
+ return className;
408
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * CSS property name stringification utilities.
3
+ * Handles conversion of camelCase CSS property names to kebab-case,
4
+ * including proper vendor prefix detection and normalization.
5
+ */
6
+ import { SANITIZE_CACHE_SIZE_LIMIT } from "../constants";
7
+ import { LRUCache } from "./cache";
8
+ const propertyNameCache = new LRUCache(SANITIZE_CACHE_SIZE_LIMIT);
9
+ /**
10
+ * Converts a camelCase string to kebab-case.
11
+ *
12
+ * @param str - String to convert
13
+ * @returns Kebab-case string
14
+ */
15
+ function toKebabCase(str) {
16
+ return str.replace(/([A-Z])/g, "-$1").toLowerCase();
17
+ }
18
+ /**
19
+ * Normalizes a property name for vendor prefix detection.
20
+ * Handles all-caps and mixed-case scenarios.
21
+ *
22
+ * @param property - Property name to normalize
23
+ * @returns Normalized property name
24
+ */
25
+ function normalizePropertyName(property) {
26
+ if (property === property.toUpperCase() && property.length > 1) {
27
+ return property.charAt(0) + property.slice(1).toLowerCase();
28
+ }
29
+ return property;
30
+ }
31
+ /**
32
+ * Converts the rest of a property name (after vendor prefix) to kebab-case.
33
+ * Ensures the first character is lowercase before conversion to avoid double dashes.
34
+ *
35
+ * @param rest - The property name part after the vendor prefix
36
+ * @returns Kebab-case string
37
+ */
38
+ function restToKebabCase(rest) {
39
+ if (!rest) {
40
+ return "";
41
+ }
42
+ return (rest.charAt(0).toLowerCase() +
43
+ rest
44
+ .slice(1)
45
+ .replace(/([A-Z])/g, "-$1")
46
+ .toLowerCase());
47
+ }
48
+ /**
49
+ * Sanitizes CSS property names to prevent injection attacks.
50
+ * Handles vendor prefixes, camelCase conversion, and edge cases.
51
+ *
52
+ * Vendor prefix patterns handled:
53
+ * - Moz* → -moz-*
54
+ * - Webkit* → -webkit-*
55
+ * - ms* → -ms-*
56
+ * - O* → -o-*
57
+ *
58
+ * @param propertyName - Property name to sanitize
59
+ * @returns Sanitized property name
60
+ */
61
+ export function sanitizeCSSPropertyName(propertyName) {
62
+ if (!propertyName || typeof propertyName !== "string") {
63
+ return "";
64
+ }
65
+ const cached = propertyNameCache.get(propertyName);
66
+ if (cached !== undefined) {
67
+ return cached;
68
+ }
69
+ if (/^-[a-z]+-/.test(propertyName)) {
70
+ propertyNameCache.set(propertyName, propertyName);
71
+ return propertyName;
72
+ }
73
+ const normalized = normalizePropertyName(propertyName);
74
+ // Mozilla prefix (case-insensitive): Moz*, moz*, MOZ* → -moz-*
75
+ if (/^[Mm]oz/i.test(normalized)) {
76
+ if (normalized.length === 3 || normalized.toLowerCase() === "moz") {
77
+ const result = "-moz";
78
+ propertyNameCache.set(propertyName, result);
79
+ return result;
80
+ }
81
+ const match = normalized.match(/^[Mm]oz(.+)$/i);
82
+ if (match && match[1]) {
83
+ const [, rest] = match;
84
+ const kebab = restToKebabCase(rest);
85
+ if (kebab) {
86
+ const result = `-moz-${kebab}`;
87
+ propertyNameCache.set(propertyName, result);
88
+ return result;
89
+ }
90
+ }
91
+ }
92
+ // WebKit prefix (case-insensitive): Webkit*, webkit*, WEBKIT* → -webkit-*
93
+ if (/^[Ww]ebkit/i.test(normalized)) {
94
+ if (normalized.length === 6 || normalized.toLowerCase() === "webkit") {
95
+ const result = "-webkit";
96
+ propertyNameCache.set(propertyName, result);
97
+ return result;
98
+ }
99
+ const match = normalized.match(/^[Ww]ebkit(.+)$/i);
100
+ if (match && match[1]) {
101
+ const [, rest] = match;
102
+ const kebab = restToKebabCase(rest);
103
+ if (kebab) {
104
+ const result = `-webkit-${kebab}`;
105
+ propertyNameCache.set(propertyName, result);
106
+ return result;
107
+ }
108
+ }
109
+ }
110
+ // Microsoft prefix (case-insensitive): ms*, Ms*, MS* → -ms-*
111
+ if (/^[Mm]s/i.test(normalized)) {
112
+ if (normalized.length === 2 || normalized.toLowerCase() === "ms") {
113
+ const result = "-ms";
114
+ propertyNameCache.set(propertyName, result);
115
+ return result;
116
+ }
117
+ const match = normalized.match(/^[Mm]s(.+)$/i);
118
+ if (match && match[1]) {
119
+ const [, rest] = match;
120
+ const kebab = restToKebabCase(rest);
121
+ if (kebab) {
122
+ const result = `-ms-${kebab}`;
123
+ propertyNameCache.set(propertyName, result);
124
+ return result;
125
+ }
126
+ }
127
+ }
128
+ // Opera prefix (single uppercase O): O* → -o-* or just O → -o
129
+ if (/^O/i.test(normalized)) {
130
+ if (normalized.length === 1 || normalized.toLowerCase() === "o") {
131
+ const result = "-o";
132
+ propertyNameCache.set(propertyName, result);
133
+ return result;
134
+ }
135
+ if (/^O[A-Z]/.test(normalized)) {
136
+ const rest = normalized.substring(1);
137
+ const kebab = restToKebabCase(rest);
138
+ if (kebab) {
139
+ const result = `-o-${kebab}`;
140
+ propertyNameCache.set(propertyName, result);
141
+ return result;
142
+ }
143
+ }
144
+ }
145
+ const kebab = toKebabCase(normalized);
146
+ const sanitized = kebab.replace(/[^a-zA-Z0-9-]/g, "").replace(/^-+|-+$/g, "");
147
+ const result = sanitized.replace(/^\d+/, "") || "";
148
+ propertyNameCache.set(propertyName, result);
149
+ return result;
150
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Theme variable management.
3
+ * Updates CSS custom properties when theme changes and merges themes with the default theme.
4
+ */
5
+ import { injectAllThemeVariables } from "../inject";
6
+ import { isBrowser } from "../utils/helpers";
7
+ import { generateAllThemeVariables, themesAreEqual } from "../utils/theme";
8
+ const defaultThemes = new Map();
9
+ /**
10
+ * Registers the default theme for a given prefix.
11
+ * Called automatically by createStoop.
12
+ *
13
+ * @param theme - Default theme from createStoop
14
+ * @param prefix - Optional prefix for theme scoping
15
+ */
16
+ export function registerDefaultTheme(theme, prefix = "stoop") {
17
+ const sanitizedPrefix = prefix || "";
18
+ defaultThemes.set(sanitizedPrefix, theme);
19
+ }
20
+ /**
21
+ * Gets the default theme for a given prefix.
22
+ *
23
+ * @param prefix - Optional prefix for theme scoping
24
+ * @returns Default theme or null if not registered
25
+ */
26
+ export function getDefaultTheme(prefix = "stoop") {
27
+ const sanitizedPrefix = prefix || "";
28
+ return defaultThemes.get(sanitizedPrefix) || null;
29
+ }
30
+ /**
31
+ * Merges source theme into target theme, handling nested objects.
32
+ *
33
+ * @param target - Target theme to merge into
34
+ * @param source - Source theme to merge from
35
+ * @returns Merged theme
36
+ */
37
+ export function mergeThemes(target, source) {
38
+ const merged = { ...target };
39
+ const sourceKeys = Object.keys(source);
40
+ for (const key of sourceKeys) {
41
+ if (key === "media") {
42
+ continue;
43
+ }
44
+ const sourceValue = source[key];
45
+ const targetValue = target[key];
46
+ if (sourceValue &&
47
+ typeof sourceValue === "object" &&
48
+ !Array.isArray(sourceValue) &&
49
+ targetValue &&
50
+ typeof targetValue === "object" &&
51
+ !Array.isArray(targetValue)) {
52
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
53
+ merged[key] = { ...targetValue, ...sourceValue };
54
+ }
55
+ else if (sourceValue !== undefined) {
56
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
57
+ merged[key] = sourceValue;
58
+ }
59
+ }
60
+ return merged;
61
+ }
62
+ /**
63
+ * Merges a theme with the default theme, ensuring all themes extend the default theme.
64
+ *
65
+ * @param theme - Theme to merge
66
+ * @param prefix - Optional prefix for theme scoping
67
+ * @returns Merged theme (or original if it's the default theme)
68
+ */
69
+ export function mergeWithDefaultTheme(theme, prefix = "stoop") {
70
+ const defaultTheme = getDefaultTheme(prefix);
71
+ if (!defaultTheme) {
72
+ return theme;
73
+ }
74
+ if (themesAreEqual(theme, defaultTheme)) {
75
+ return theme;
76
+ }
77
+ return mergeThemes(defaultTheme, theme);
78
+ }
79
+ /**
80
+ * Injects CSS variables for all themes using attribute selectors.
81
+ * All themes are available simultaneously, with theme switching handled by changing the data-theme attribute.
82
+ *
83
+ * @param themes - Map of theme names to theme objects
84
+ * @param prefix - Optional prefix for CSS variable names
85
+ * @param attribute - Attribute name for theme selection (defaults to 'data-theme')
86
+ */
87
+ export function injectAllThemes(themes, prefix = "stoop", attribute = "data-theme") {
88
+ if (!isBrowser()) {
89
+ return;
90
+ }
91
+ const mergedThemes = {};
92
+ for (const [themeName, theme] of Object.entries(themes)) {
93
+ mergedThemes[themeName] = mergeWithDefaultTheme(theme, prefix);
94
+ }
95
+ const allThemeVars = generateAllThemeVariables(mergedThemes, prefix, attribute);
96
+ injectAllThemeVariables(allThemeVars, prefix);
97
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Internal implementation for creating Stoop instances.
3
- * This file is used by the SSR entry point and does NOT import React types at module level.
4
- * React types are only imported conditionally when creating client instances.
3
+ * SERVER-SAFE: No "use client" dependencies, no React imports.
4
+ * This file is used by both client (create-stoop.ts) and server (create-stoop-ssr.ts) entry points.
5
5
  */
6
6
  import type { CSS, StoopConfig, Theme, ThemeScale } from "./types";
7
7
  import { createCSSFunction, createKeyframesFunction, createGlobalCSSFunction } from "./api/core-api";