stoop 0.6.1 → 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.
- package/dist/api/core-api.d.ts +2 -0
- package/dist/api/core-api.js +171 -0
- package/dist/api/styled.d.ts +3 -0
- package/dist/api/styled.js +467 -0
- package/dist/api/theme-provider.d.ts +3 -0
- package/dist/api/theme-provider.js +199 -0
- package/dist/constants.js +154 -0
- package/dist/core/cache.js +66 -0
- package/dist/core/compiler.js +408 -0
- package/dist/core/stringify.js +150 -0
- package/dist/core/theme-manager.js +97 -0
- package/dist/create-stoop-internal.d.ts +2 -2
- package/dist/create-stoop-internal.js +119 -0
- package/dist/create-stoop-ssr.d.ts +2 -0
- package/dist/create-stoop-ssr.js +22 -16
- package/dist/create-stoop.d.ts +6 -25
- package/dist/create-stoop.js +48 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +5 -17
- package/dist/inject.js +293 -0
- package/dist/types/index.d.ts +4 -4
- package/dist/types/index.js +5 -2
- package/dist/utils/helpers.js +157 -0
- package/dist/utils/storage.js +130 -0
- package/dist/utils/theme-utils.js +328 -0
- package/dist/utils/theme.js +430 -0
- package/package.json +10 -14
|
@@ -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
|
-
*
|
|
4
|
-
*
|
|
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";
|