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.
@@ -0,0 +1,328 @@
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 sanitizeClassNameCache = new LRUCache(SANITIZE_CACHE_SIZE_LIMIT);
13
+ const variableNameCache = new LRUCache(SANITIZE_CACHE_SIZE_LIMIT);
14
+ /**
15
+ * Generates a hash string from an input string using FNV-1a algorithm.
16
+ * Includes string length to reduce collision probability.
17
+ *
18
+ * @param str - String to hash
19
+ * @returns Hashed string
20
+ */
21
+ export function hash(str) {
22
+ // Fast path for empty strings
23
+ if (str.length === 0) {
24
+ return "0";
25
+ }
26
+ const FNV_OFFSET_BASIS = 2166136261;
27
+ const FNV_PRIME = 16777619;
28
+ let hash = FNV_OFFSET_BASIS;
29
+ // Optimize: use charCodeAt in a tight loop
30
+ for (let i = 0; i < str.length; i++) {
31
+ hash ^= str.charCodeAt(i);
32
+ hash = Math.imul(hash, FNV_PRIME);
33
+ }
34
+ hash ^= str.length;
35
+ return (hash >>> 0).toString(36);
36
+ }
37
+ /**
38
+ * Generates a hash string from an object by stringifying it.
39
+ *
40
+ * @param obj - Object to hash
41
+ * @returns Hashed string
42
+ */
43
+ export function hashObject(obj) {
44
+ try {
45
+ return hash(JSON.stringify(obj));
46
+ }
47
+ catch {
48
+ return hash(String(obj));
49
+ }
50
+ }
51
+ /**
52
+ * Converts a camelCase string to kebab-case.
53
+ *
54
+ * @param str - String to convert
55
+ * @returns Kebab-case string
56
+ */
57
+ export function toKebabCase(str) {
58
+ return str.replace(/([A-Z])/g, "-$1").toLowerCase();
59
+ }
60
+ /**
61
+ * Internal function to escape CSS values with optional brace escaping.
62
+ *
63
+ * @param value - Value to escape
64
+ * @param escapeBraces - Whether to escape curly braces
65
+ * @returns Escaped value string
66
+ */
67
+ function escapeCSSValueInternal(value, escapeBraces = false) {
68
+ const str = String(value);
69
+ let result = str
70
+ .replace(/\\/g, "\\\\")
71
+ .replace(/"/g, '\\"')
72
+ .replace(/'/g, "\\'")
73
+ .replace(/;/g, "\\;")
74
+ .replace(/\n/g, "\\A ")
75
+ .replace(/\r/g, "")
76
+ .replace(/\f/g, "\\C ");
77
+ if (escapeBraces) {
78
+ result = result.replace(/\{/g, "\\7B ").replace(/\}/g, "\\7D ");
79
+ }
80
+ return result;
81
+ }
82
+ /**
83
+ * Escapes CSS property values to prevent injection attacks.
84
+ * Escapes quotes, semicolons, and other special characters.
85
+ *
86
+ * @param value - Value to escape
87
+ * @returns Escaped value string
88
+ */
89
+ export function escapeCSSValue(value) {
90
+ return escapeCSSValueInternal(value, false);
91
+ }
92
+ /**
93
+ * Validates and sanitizes CSS selectors to prevent injection attacks.
94
+ * Only allows safe selector characters. Returns empty string for invalid selectors.
95
+ *
96
+ * @param selector - Selector to sanitize
97
+ * @returns Sanitized selector string or empty string if invalid
98
+ */
99
+ export function sanitizeCSSSelector(selector) {
100
+ const cached = selectorCache.get(selector);
101
+ if (cached !== undefined) {
102
+ return cached;
103
+ }
104
+ // Preserve commas for comma-separated selectors (e.g., "a, a:visited")
105
+ const sanitized = selector.replace(/[^a-zA-Z0-9\s\-_>+~:.#[\]&@(),]/g, "");
106
+ const result = !sanitized.trim() || /^[>+~:.#[\]&@()\s]+$/.test(sanitized) ? "" : sanitized;
107
+ selectorCache.set(selector, result);
108
+ return result;
109
+ }
110
+ /**
111
+ * Validates and sanitizes CSS variable names to prevent injection attacks.
112
+ * CSS custom properties must start with -- and contain only valid characters.
113
+ *
114
+ * @param name - Variable name to sanitize
115
+ * @returns Sanitized variable name
116
+ */
117
+ export function sanitizeCSSVariableName(name) {
118
+ const cached = variableNameCache.get(name);
119
+ if (cached !== undefined) {
120
+ return cached;
121
+ }
122
+ const sanitized = name.replace(/[^a-zA-Z0-9-_]/g, "-");
123
+ const cleaned = sanitized.replace(/^[\d-]+/, "").replace(/^-+/, "");
124
+ const result = cleaned || "invalid";
125
+ variableNameCache.set(name, result);
126
+ return result;
127
+ }
128
+ /**
129
+ * Escapes CSS variable values to prevent injection attacks.
130
+ *
131
+ * @param value - Value to escape
132
+ * @returns Escaped value string
133
+ */
134
+ export function escapeCSSVariableValue(value) {
135
+ return escapeCSSValueInternal(value, true);
136
+ }
137
+ /**
138
+ * Sanitizes prefix for use in CSS selectors and class names.
139
+ * Only allows alphanumeric characters, hyphens, and underscores.
140
+ * Defaults to "stoop" if prefix is empty or becomes empty after sanitization.
141
+ *
142
+ * @param prefix - Prefix to sanitize
143
+ * @returns Sanitized prefix string (never empty, defaults to "stoop")
144
+ */
145
+ export function sanitizePrefix(prefix) {
146
+ if (!prefix) {
147
+ return "stoop";
148
+ }
149
+ const sanitized = prefix.replace(/[^a-zA-Z0-9-_]/g, "");
150
+ const cleaned = sanitized.replace(/^[\d-]+/, "").replace(/^-+/, "");
151
+ return cleaned || "stoop";
152
+ }
153
+ /**
154
+ * Sanitizes media query strings to prevent injection attacks.
155
+ * Only allows safe characters for media queries.
156
+ *
157
+ * @param mediaQuery - Media query string to sanitize
158
+ * @returns Sanitized media query string or empty string if invalid
159
+ */
160
+ export function sanitizeMediaQuery(mediaQuery) {
161
+ if (!mediaQuery || typeof mediaQuery !== "string") {
162
+ return "";
163
+ }
164
+ const sanitized = mediaQuery.replace(/[^a-zA-Z0-9\s():,<>=\-@]/g, "");
165
+ if (!sanitized.trim() || !/[a-zA-Z]/.test(sanitized)) {
166
+ return "";
167
+ }
168
+ return sanitized;
169
+ }
170
+ /**
171
+ * Sanitizes CSS class names to prevent injection attacks.
172
+ * Only allows valid CSS class name characters.
173
+ *
174
+ * @param className - Class name(s) to sanitize
175
+ * @returns Sanitized class name string
176
+ */
177
+ export function sanitizeClassName(className) {
178
+ if (!className || typeof className !== "string") {
179
+ return "";
180
+ }
181
+ const cached = sanitizeClassNameCache.get(className);
182
+ if (cached !== undefined) {
183
+ return cached;
184
+ }
185
+ const classes = className.trim().split(/\s+/);
186
+ const sanitizedClasses = [];
187
+ for (const cls of classes) {
188
+ if (!cls) {
189
+ continue;
190
+ }
191
+ const sanitized = cls.replace(/[^a-zA-Z0-9-_]/g, "");
192
+ const cleaned = sanitized.replace(/^\d+/, "");
193
+ if (cleaned && /^[a-zA-Z-_]/.test(cleaned)) {
194
+ sanitizedClasses.push(cleaned);
195
+ }
196
+ }
197
+ const result = sanitizedClasses.join(" ");
198
+ sanitizeClassNameCache.set(className, result);
199
+ return result;
200
+ }
201
+ /**
202
+ * Validates keyframe percentage keys (e.g., "0%", "50%", "from", "to").
203
+ *
204
+ * @param key - Keyframe key to validate
205
+ * @returns True if key is valid
206
+ */
207
+ export function validateKeyframeKey(key) {
208
+ if (!key || typeof key !== "string") {
209
+ return false;
210
+ }
211
+ if (key === "from" || key === "to") {
212
+ return true;
213
+ }
214
+ const percentageMatch = /^\d+(\.\d+)?%$/.test(key);
215
+ if (percentageMatch) {
216
+ const num = parseFloat(key);
217
+ return num >= 0 && num <= 100;
218
+ }
219
+ return false;
220
+ }
221
+ /**
222
+ * Gets a pre-compiled regex for matching :root CSS selector blocks.
223
+ *
224
+ * @param prefix - Optional prefix (unused, kept for API compatibility)
225
+ * @returns RegExp for matching :root selector blocks
226
+ */
227
+ export function getRootRegex(prefix = "") {
228
+ if (!cachedRootRegex) {
229
+ const rootSelector = ":root";
230
+ const escapedSelector = rootSelector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
231
+ cachedRootRegex = new RegExp(`${escapedSelector}\\s*\\{[\\s\\S]*\\}`);
232
+ }
233
+ return cachedRootRegex;
234
+ }
235
+ // ============================================================================
236
+ // Theme Map Utilities
237
+ // ============================================================================
238
+ /**
239
+ * Auto-detects theme scale from CSS property name using pattern matching.
240
+ *
241
+ * @param property - CSS property name
242
+ * @returns Theme scale name or undefined if no pattern matches
243
+ */
244
+ export function autoDetectScale(property) {
245
+ // Color properties
246
+ if (property.includes("Color") ||
247
+ property === "fill" ||
248
+ property === "stroke" ||
249
+ property === "accentColor" ||
250
+ property === "caretColor" ||
251
+ property === "border" ||
252
+ property === "outline" ||
253
+ (property.includes("background") && !property.includes("Size") && !property.includes("Image"))) {
254
+ return "colors";
255
+ }
256
+ // Spacing properties
257
+ if (/^(margin|padding|gap|inset|top|right|bottom|left|rowGap|columnGap|gridGap|gridRowGap|gridColumnGap)/.test(property) ||
258
+ property.includes("Block") ||
259
+ property.includes("Inline")) {
260
+ return "space";
261
+ }
262
+ // Size properties
263
+ if (/(width|height|size|basis)$/i.test(property) ||
264
+ property.includes("BlockSize") ||
265
+ property.includes("InlineSize")) {
266
+ return "sizes";
267
+ }
268
+ // Typography: Font Size
269
+ if (property === "fontSize" || (property === "font" && !property.includes("Family"))) {
270
+ return "fontSizes";
271
+ }
272
+ // Typography: Font Family
273
+ if (property === "fontFamily" || property.includes("FontFamily")) {
274
+ return "fonts";
275
+ }
276
+ // Typography: Font Weight
277
+ if (property === "fontWeight" || property.includes("FontWeight")) {
278
+ return "fontWeights";
279
+ }
280
+ // Typography: Letter Spacing
281
+ if (property === "letterSpacing" || property.includes("LetterSpacing")) {
282
+ return "letterSpacings";
283
+ }
284
+ // Border Radius
285
+ if (property.includes("Radius") || property.includes("radius")) {
286
+ return "radii";
287
+ }
288
+ // Shadows
289
+ if (property.includes("Shadow") ||
290
+ property.includes("shadow") ||
291
+ property === "filter" ||
292
+ property === "backdropFilter") {
293
+ return "shadows";
294
+ }
295
+ // Z-Index
296
+ if (property === "zIndex" || property.includes("ZIndex") || property.includes("z-index")) {
297
+ return "zIndices";
298
+ }
299
+ // Opacity
300
+ if (property === "opacity" || property.includes("Opacity")) {
301
+ return "opacities";
302
+ }
303
+ // Transitions and animations
304
+ if (property.startsWith("transition") ||
305
+ property.startsWith("animation") ||
306
+ property.includes("Transition") ||
307
+ property.includes("Animation")) {
308
+ return "transitions";
309
+ }
310
+ return undefined;
311
+ }
312
+ /**
313
+ * Gets the theme scale for a CSS property.
314
+ * Checks user themeMap first, then default themeMap, then pattern matching.
315
+ *
316
+ * @param property - CSS property name
317
+ * @param userThemeMap - Optional user-provided themeMap override
318
+ * @returns Theme scale name or undefined if no mapping found
319
+ */
320
+ export function getScaleForProperty(property, userThemeMap) {
321
+ if (userThemeMap && property in userThemeMap) {
322
+ return userThemeMap[property];
323
+ }
324
+ if (property in DEFAULT_THEME_MAP) {
325
+ return DEFAULT_THEME_MAP[property];
326
+ }
327
+ return autoDetectScale(property);
328
+ }