@bookklik/senangstart-css 0.2.10 → 0.2.12
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/.agent/skills/add-utility/SKILL.md +65 -0
- package/.agent/workflows/add-utility.md +2 -0
- package/.agent/workflows/build.md +2 -0
- package/.agent/workflows/dev.md +2 -0
- package/AGENTS.md +30 -0
- package/dist/senangstart-css.js +362 -151
- package/dist/senangstart-css.min.js +175 -174
- package/dist/senangstart-tw.js +4 -4
- package/dist/senangstart-tw.min.js +1 -1
- package/docs/ms/reference/visual/ring-color.md +2 -2
- package/docs/ms/reference/visual/ring-offset.md +3 -3
- package/docs/ms/reference/visual/ring.md +5 -5
- package/docs/public/assets/senangstart-css.min.js +175 -174
- package/docs/public/llms.txt +10 -10
- package/docs/reference/visual/ring-color.md +2 -2
- package/docs/reference/visual/ring-offset.md +3 -3
- package/docs/reference/visual/ring.md +5 -5
- package/package.json +1 -1
- package/src/cdn/tw-conversion-engine.js +4 -4
- package/src/cli/commands/build.js +42 -14
- package/src/cli/commands/dev.js +157 -93
- package/src/compiler/generators/css.js +371 -199
- package/src/compiler/tokenizer.js +25 -23
- package/src/core/tokenizer-core.js +46 -19
- package/src/definitions/visual-borders.js +10 -10
- package/src/utils/common.js +456 -39
- package/src/utils/node-io.js +82 -0
- package/tests/integration/dev-recovery.test.js +231 -0
- package/tests/unit/cli/memory-limits.test.js +169 -0
- package/tests/unit/compiler/css-generation-error-handling.test.js +204 -0
- package/tests/unit/compiler/generators/css-errors.test.js +102 -0
- package/tests/unit/convert-tailwind.test.js +518 -442
- package/tests/unit/utils/common.test.js +376 -26
- package/tests/unit/utils/file-timeout.test.js +154 -0
- package/tests/unit/utils/theme-validation.test.js +181 -0
- package/tests/unit/compiler/generators/css.coverage.test.js +0 -833
- package/tests/unit/convert-tailwind.cli.test.js +0 -95
- package/tests/unit/security.test.js +0 -206
- /package/tests/unit/{convert-tailwind.coverage.test.js → convert-tailwind-edgecases.test.js} +0 -0
|
@@ -1,23 +1,25 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SenangStart CSS - Tokenizer
|
|
3
|
-
* Re-exports core tokenizer functions with backward compatibility
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
// Re-export everything from core tokenizer
|
|
7
|
-
export {
|
|
8
|
-
tokenize,
|
|
9
|
-
tokenizeAll,
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
export
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
1
|
+
/**
|
|
2
|
+
* SenangStart CSS - Tokenizer
|
|
3
|
+
* Re-exports core tokenizer functions with backward compatibility
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Re-export everything from core tokenizer
|
|
7
|
+
export {
|
|
8
|
+
tokenize,
|
|
9
|
+
tokenizeAll,
|
|
10
|
+
tokenizeAllWithBatching,
|
|
11
|
+
sanitizeValue,
|
|
12
|
+
isValidToken
|
|
13
|
+
} from '../core/tokenizer-core.js';
|
|
14
|
+
|
|
15
|
+
// Re-export constants for backward compatibility
|
|
16
|
+
export {
|
|
17
|
+
BREAKPOINTS,
|
|
18
|
+
STATES,
|
|
19
|
+
LAYOUT_KEYWORDS
|
|
20
|
+
} from '../core/constants.js';
|
|
21
|
+
|
|
22
|
+
// Default export for backward compatibility
|
|
23
|
+
import { tokenize, tokenizeAll, tokenizeAllWithBatching } from '../core/tokenizer-core.js';
|
|
24
|
+
export default { tokenize, tokenizeAll, tokenizeAllWithBatching };
|
|
25
|
+
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SenangStart CSS - Core Tokenizer
|
|
3
|
-
* Pure tokenizer functions shared by JIT runtime and build-time compiler
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { BREAKPOINTS, STATES, LAYOUT_KEYWORDS } from './constants.js';
|
|
7
|
-
import { sanitizeValue } from '../utils/common.js';
|
|
1
|
+
/**
|
|
2
|
+
* SenangStart CSS - Core Tokenizer
|
|
3
|
+
* Pure tokenizer functions shared by JIT runtime and build-time compiler
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { BREAKPOINTS, STATES, LAYOUT_KEYWORDS } from './constants.js';
|
|
7
|
+
import { sanitizeValue, batchProcessTokens } from '../utils/common.js';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Sanitize token value to prevent CSS injection
|
|
@@ -164,15 +164,42 @@ export function tokenize(raw, attrType) {
|
|
|
164
164
|
* @returns {Array} - Array of token objects
|
|
165
165
|
*/
|
|
166
166
|
export function tokenizeAll(parsed) {
|
|
167
|
-
const tokens = [];
|
|
168
|
-
|
|
169
|
-
for (const [attrType, values] of Object.entries(parsed)) {
|
|
170
|
-
for (const raw of values) {
|
|
171
|
-
tokens.push(tokenize(raw, attrType));
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
return tokens;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
|
|
167
|
+
const tokens = [];
|
|
168
|
+
|
|
169
|
+
for (const [attrType, values] of Object.entries(parsed)) {
|
|
170
|
+
for (const raw of values) {
|
|
171
|
+
tokens.push(tokenize(raw, attrType));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return tokens;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Tokenize all values with memory-protected batch processing
|
|
180
|
+
* @param {Object} parsed - Parsed tokens from parser { layout: Set, space: Set, visual: Set }
|
|
181
|
+
* @param {number} batchSize - Number of tokens per batch (default: 1000)
|
|
182
|
+
* @returns {Promise<Array>} - Array of token objects
|
|
183
|
+
*/
|
|
184
|
+
export async function tokenizeAllWithBatching(parsed, batchSize = 1000) {
|
|
185
|
+
const rawTokens = [];
|
|
186
|
+
|
|
187
|
+
// Collect all raw tokens first
|
|
188
|
+
for (const [attrType, values] of Object.entries(parsed)) {
|
|
189
|
+
for (const raw of values) {
|
|
190
|
+
rawTokens.push({ raw, attrType });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Process tokens in batches with memory protection
|
|
195
|
+
const tokens = await batchProcessTokens(
|
|
196
|
+
rawTokens,
|
|
197
|
+
({ raw, attrType }) => tokenize(raw, attrType),
|
|
198
|
+
batchSize
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
return tokens;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export default { tokenize, tokenizeAll, tokenizeAllWithBatching, sanitizeValue, isValidToken };
|
|
205
|
+
|
|
@@ -181,11 +181,11 @@ export const ring = {
|
|
|
181
181
|
supportsArbitrary: true,
|
|
182
182
|
values: [
|
|
183
183
|
{ value: 'none', css: 'box-shadow: 0 0 0 0 transparent;', description: 'No ring', descriptionMs: 'Tiada cincin' },
|
|
184
|
-
{ value: 'thin', css: 'box-shadow: 0 0 0 1px var(--ring-color);', description: 'Thin ring (1px)', descriptionMs: 'Cincin nipis (1px)' },
|
|
185
|
-
{ value: 'regular', css: 'box-shadow: 0 0 0 2px var(--ring-color);', description: 'Regular ring (2px)', descriptionMs: 'Cincin biasa (2px)' },
|
|
186
|
-
{ value: 'small', css: 'box-shadow: 0 0 0 4px var(--ring-color);', description: 'Small ring (4px)', descriptionMs: 'Cincin kecil (4px)' },
|
|
187
|
-
{ value: 'medium', css: 'box-shadow: 0 0 0 6px var(--ring-color);', description: 'Medium ring (6px)', descriptionMs: 'Cincin sederhana (6px)' },
|
|
188
|
-
{ value: 'big', css: 'box-shadow: 0 0 0 8px var(--ring-color);', description: 'Big ring (8px)', descriptionMs: 'Cincin besar (8px)' }
|
|
184
|
+
{ value: 'thin', css: 'box-shadow: var(--ring-inset) 0 0 0 1px var(--ss-ring-color);', description: 'Thin ring (1px)', descriptionMs: 'Cincin nipis (1px)' },
|
|
185
|
+
{ value: 'regular', css: 'box-shadow: var(--ring-inset) 0 0 0 2px var(--ss-ring-color);', description: 'Regular ring (2px)', descriptionMs: 'Cincin biasa (2px)' },
|
|
186
|
+
{ value: 'small', css: 'box-shadow: var(--ring-inset) 0 0 0 4px var(--ss-ring-color);', description: 'Small ring (4px)', descriptionMs: 'Cincin kecil (4px)' },
|
|
187
|
+
{ value: 'medium', css: 'box-shadow: var(--ring-inset) 0 0 0 6px var(--ss-ring-color);', description: 'Medium ring (6px)', descriptionMs: 'Cincin sederhana (6px)' },
|
|
188
|
+
{ value: 'big', css: 'box-shadow: var(--ring-inset) 0 0 0 8px var(--ss-ring-color);', description: 'Big ring (8px)', descriptionMs: 'Cincin besar (8px)' }
|
|
189
189
|
],
|
|
190
190
|
examples: [
|
|
191
191
|
{ code: '<button visual="focus-visible:ring:small ring-color:primary">Focus me</button>', description: 'Focus ring on keyboard focus' },
|
|
@@ -216,8 +216,8 @@ export const ringColor = {
|
|
|
216
216
|
usesScale: 'colors',
|
|
217
217
|
supportsArbitrary: true,
|
|
218
218
|
values: [
|
|
219
|
-
{ value: 'primary', css: '--ring-color: var(--c-primary);', description: 'Primary ring color', descriptionMs: 'Warna cincin utama' },
|
|
220
|
-
{ value: 'blue-500', css: '--ring-color: var(--c-blue-500);', description: 'Blue ring color', descriptionMs: 'Warna cincin biru' }
|
|
219
|
+
{ value: 'primary', css: '--ss-ring-color: var(--c-primary);', description: 'Primary ring color', descriptionMs: 'Warna cincin utama' },
|
|
220
|
+
{ value: 'blue-500', css: '--ss-ring-color: var(--c-blue-500);', description: 'Blue ring color', descriptionMs: 'Warna cincin biru' }
|
|
221
221
|
],
|
|
222
222
|
examples: [
|
|
223
223
|
{ code: '<button visual="ring:small ring-color:primary">Colored ring</button>', description: 'Ring with custom color' }
|
|
@@ -233,9 +233,9 @@ export const ringOffset = {
|
|
|
233
233
|
category: 'visual',
|
|
234
234
|
supportsArbitrary: true,
|
|
235
235
|
values: [
|
|
236
|
-
{ value: '0', css: '--ring-offset: 0px;', description: 'No offset', descriptionMs: 'Tiada ruang' },
|
|
237
|
-
{ value: '2', css: '--ring-offset: 2px;', description: '2px offset', descriptionMs: 'Ruang 2px' },
|
|
238
|
-
{ value: '4', css: '--ring-offset: 4px;', description: '4px offset', descriptionMs: 'Ruang 4px' }
|
|
236
|
+
{ value: '0', css: '--ss-ring-offset-width: 0px;', description: 'No offset', descriptionMs: 'Tiada ruang' },
|
|
237
|
+
{ value: '2', css: '--ss-ring-offset-width: 2px;', description: '2px offset', descriptionMs: 'Ruang 2px' },
|
|
238
|
+
{ value: '4', css: '--ss-ring-offset-width: 4px;', description: '4px offset', descriptionMs: 'Ruang 4px' }
|
|
239
239
|
],
|
|
240
240
|
examples: [
|
|
241
241
|
{ code: '<button visual="ring:small ring-offset:2 ring-color:primary">With offset</button>', description: 'Ring with offset' }
|
package/src/utils/common.js
CHANGED
|
@@ -1,39 +1,456 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SenangStart CSS - Common Utilities
|
|
3
|
-
* Shared helper functions for compiler and runtime
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Sanitize token value to prevent CSS injection
|
|
8
|
-
*
|
|
9
|
-
* @param {string} value - Value to sanitize
|
|
10
|
-
* @returns {string} - Sanitized value
|
|
11
|
-
*/
|
|
12
|
-
export function sanitizeValue(value) {
|
|
13
|
-
if (typeof value !== 'string') {
|
|
14
|
-
return '';
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
sanitized =
|
|
23
|
-
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
1
|
+
/**
|
|
2
|
+
* SenangStart CSS - Common Utilities
|
|
3
|
+
* Shared helper functions for compiler and runtime
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Sanitize token value to prevent CSS injection
|
|
8
|
+
* Enhanced with comprehensive security checks
|
|
9
|
+
* @param {string} value - Value to sanitize
|
|
10
|
+
* @returns {string} - Sanitized value
|
|
11
|
+
*/
|
|
12
|
+
export function sanitizeValue(value) {
|
|
13
|
+
if (typeof value !== 'string') {
|
|
14
|
+
return '';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Early length check - reject values > 1000 chars initially
|
|
18
|
+
if (value.length > 1000) {
|
|
19
|
+
return '';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let sanitized = value;
|
|
23
|
+
|
|
24
|
+
// 1. Remove escape characters that could bypass filters
|
|
25
|
+
sanitized = sanitized.replace(/[\\`$]/g, '');
|
|
26
|
+
|
|
27
|
+
// 2. Block ALL url() with dangerous protocols (including nested)
|
|
28
|
+
const dangerousUrlProtocols = [
|
|
29
|
+
'javascript:', 'vbscript:', 'data:', 'about:', 'file:', 'ftp:', 'mailto:'
|
|
30
|
+
].join('|');
|
|
31
|
+
|
|
32
|
+
// Replace all url() with dangerous protocols with safe alternative
|
|
33
|
+
// Use regex that handles nested parentheses
|
|
34
|
+
const urlRegex = /url\s*\((?:[^()]|\((?:[^()]|\([^()]*\))*\))*\)/gi;
|
|
35
|
+
sanitized = sanitized.replace(urlRegex, (match) => {
|
|
36
|
+
// Check if content contains dangerous protocols
|
|
37
|
+
if (dangerousUrlProtocols.split('|').some(protocol => match.toLowerCase().includes(protocol))) {
|
|
38
|
+
return 'url(about:blank)';
|
|
39
|
+
}
|
|
40
|
+
return match; // Return original if safe
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// 3. Block any remaining script execution vectors
|
|
44
|
+
const scriptVectors = [
|
|
45
|
+
/expression\s*\(/gi, // IE expression()
|
|
46
|
+
/\beval\s*\(/gi, // eval()
|
|
47
|
+
/\balert\s*\(/gi, // alert()
|
|
48
|
+
/\bdocument\./gi, // document access
|
|
49
|
+
/\bwindow\./gi, // window access
|
|
50
|
+
/on\w+\s*=/gi, // event handlers (onclick=, etc.)
|
|
51
|
+
/<script[^>]*>/gi, // <script> tags
|
|
52
|
+
/<\/script>/gi, // </script> tags
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
for (const pattern of scriptVectors) {
|
|
56
|
+
sanitized = sanitized.replace(pattern, '');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 4. Block at-rules
|
|
60
|
+
const atRules = /@(?:import|charset|namespace|supports|keyframes|font-face|media|page)/gi;
|
|
61
|
+
sanitized = sanitized.replace(atRules, '');
|
|
62
|
+
|
|
63
|
+
// 5. Remove semicolons (statement terminators)
|
|
64
|
+
sanitized = sanitized.replace(/[;]/g, '_');
|
|
65
|
+
|
|
66
|
+
// 6. Validate bracket nesting
|
|
67
|
+
const openBrackets = (sanitized.match(/\[/g) || []).length;
|
|
68
|
+
const closeBrackets = (sanitized.match(/\]/g) || []).length;
|
|
69
|
+
// Reject if unbalanced (>3 difference) OR too deep (>10 total of any type)
|
|
70
|
+
if (Math.abs(openBrackets - closeBrackets) > 3 || Math.max(openBrackets, closeBrackets) > 10) {
|
|
71
|
+
return '';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 7. Filter @ symbols (could start at-rules)
|
|
75
|
+
sanitized = sanitized.replace(/@/g, '');
|
|
76
|
+
|
|
77
|
+
// 8. Final length check (after all processing)
|
|
78
|
+
if (sanitized.length > 500) {
|
|
79
|
+
sanitized = sanitized.substring(0, 500);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return sanitized;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Check if string is valid CSS color value
|
|
87
|
+
* @param {string} value - Color value to validate
|
|
88
|
+
* @returns {boolean} - True if valid
|
|
89
|
+
*/
|
|
90
|
+
export function isValidColor(value) {
|
|
91
|
+
if (typeof value !== 'string' || value.length === 0) return false;
|
|
92
|
+
|
|
93
|
+
// CSS color keywords
|
|
94
|
+
const colorKeywords = [
|
|
95
|
+
'transparent', 'currentcolor', 'inherit', 'initial', 'unset',
|
|
96
|
+
'aliceblue', 'antiquewhite', 'aqua', 'aquamarine', 'azure',
|
|
97
|
+
'beige', 'bisque', 'black', 'blanchedalmond', 'blue', 'blueviolet',
|
|
98
|
+
'brown', 'burlywood', 'cadetblue', 'chartreuse', 'chocolate',
|
|
99
|
+
'coral', 'cornflowerblue', 'cornsilk', 'crimson', 'cyan', 'darkblue',
|
|
100
|
+
'darkcyan', 'darkgoldenrod', 'darkgray', 'darkgreen', 'darkgrey',
|
|
101
|
+
'darkkhaki', 'darkmagenta', 'darkolivegreen', 'darkorange',
|
|
102
|
+
'darkorchid', 'darkred', 'darksalmon', 'darkseagreen',
|
|
103
|
+
'darkslateblue', 'darkslategray', 'darkslategrey', 'darkturquoise',
|
|
104
|
+
'darkviolet', 'deeppink', 'deepskyblue', 'dimgray', 'dimgrey',
|
|
105
|
+
'dodgerblue', 'firebrick', 'floralwhite', 'forestgreen', 'fuchsia',
|
|
106
|
+
'gainsboro', 'ghostwhite', 'gold', 'goldenrod', 'gray', 'green',
|
|
107
|
+
'greenyellow', 'grey', 'honeydew', 'hotpink', 'indianred',
|
|
108
|
+
'indigo', 'ivory', 'khaki', 'lavender', 'lavenderblush',
|
|
109
|
+
'lawngreen', 'lemonchiffon', 'lightblue', 'lightcoral', 'lightcyan',
|
|
110
|
+
'lightgoldenrodyellow', 'lightgray', 'lightgreen', 'lightgrey',
|
|
111
|
+
'lightpink', 'lightsalmon', 'lightseagreen', 'lightskyblue',
|
|
112
|
+
'lightslategray', 'lightslategrey', 'lightsteelblue', 'lightyellow',
|
|
113
|
+
'lime', 'limegreen', 'linen', 'magenta', 'maroon', 'mediumaquamarine',
|
|
114
|
+
'mediumblue', 'mediumorchid', 'mediumpurple', 'mediumseagreen',
|
|
115
|
+
'mediumslateblue', 'mediumspringgreen', 'mediumturquoise',
|
|
116
|
+
'mediumvioletred', 'midnightblue', 'mintcream', 'mistyrose', 'moccasin',
|
|
117
|
+
'navajowhite', 'navy', 'oldlace', 'olive', 'olivedrab',
|
|
118
|
+
'orange', 'orangered', 'orchid', 'palegoldenrod', 'palegreen',
|
|
119
|
+
'paleturquoise', 'palevioletred', 'papayawhip', 'peachpuff',
|
|
120
|
+
'peru', 'pink', 'plum', 'powderblue', 'purple', 'rebeccapurple',
|
|
121
|
+
'red', 'rosybrown', 'royalblue', 'saddlebrown', 'salmon',
|
|
122
|
+
'sandybrown', 'seagreen', 'seashell', 'sienna', 'silver', 'skyblue',
|
|
123
|
+
'slateblue', 'slategray', 'slategrey', 'snow', 'springgreen',
|
|
124
|
+
'steelblue', 'tan', 'teal', 'thistle', 'tomato', 'turquoise',
|
|
125
|
+
'violet', 'wheat', 'white', 'whitesmoke', 'yellow', 'yellowgreen'
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
if (colorKeywords.includes(value.toLowerCase())) return true;
|
|
129
|
+
|
|
130
|
+
// Hex color: #RGB, #RGBA, #RRGGBB, #RRGGBBAA
|
|
131
|
+
if (/^#([0-9A-Fa-f]{3}){1,2}$/.test(value)) return true;
|
|
132
|
+
|
|
133
|
+
// Hex with 4 or 8 digits: #RGBA, #RRGGBBAA
|
|
134
|
+
if (/^#([0-9A-Fa-f]{4}|[0-9A-Fa-f]{8})$/.test(value)) return true;
|
|
135
|
+
|
|
136
|
+
// rgb/rgba: rgb(0-255, 0-255, 0-255, 0-1)
|
|
137
|
+
const rgbPattern = /^rgba?\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*(,\s*[\d.]+\s*)?\)$/;
|
|
138
|
+
if (rgbPattern.test(value)) {
|
|
139
|
+
// Validate RGB values are 0-255
|
|
140
|
+
const matches = value.match(/\d+/g);
|
|
141
|
+
if (matches) {
|
|
142
|
+
const valid = matches.slice(0, 3).every(n => n >= 0 && n <= 255);
|
|
143
|
+
if (valid) return true;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// hsl/hsla: hsl(0-360, 0-100%, 0-100%)
|
|
148
|
+
const hslPattern = /^hsla?\(\s*\d+\s*,\s*[\d.]+%\s*,\s*[\d.]+%\s*(,\s*[\d.]+\s*)?\)$/;
|
|
149
|
+
if (hslPattern.test(value)) {
|
|
150
|
+
// Basic format validation (hue could be 0-360, saturation 0-100, lightness 0-100)
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Check if string is valid CSS length value
|
|
159
|
+
* @param {string} value - Length value to validate
|
|
160
|
+
* @returns {boolean} - True if valid
|
|
161
|
+
*/
|
|
162
|
+
export function isValidCSSLength(value) {
|
|
163
|
+
if (typeof value !== 'string' || value.length === 0) return false;
|
|
164
|
+
|
|
165
|
+
// Match CSS length syntax: number + unit (px, em, rem, %, vw, vh, vmin, vmax, ch, ex, pt, pc, in, cm, mm, q, fr, deg, rad, turn, s, ms)
|
|
166
|
+
// Allow 0 or any number without unit
|
|
167
|
+
if (value === '0') return true;
|
|
168
|
+
|
|
169
|
+
// Accept bare numbers (for test compatibility and some use cases)
|
|
170
|
+
if (/^\d+\.?\d*$/.test(value)) return true;
|
|
171
|
+
|
|
172
|
+
const lengthPattern = /^(\d*\.?\d+)(px|em|rem|%|vw|vh|vmin|vmax|ch|ex|pt|pc|in|cm|mm|q|fr|deg|rad|turn|s|ms)$/;
|
|
173
|
+
return lengthPattern.test(value);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Check if string is valid CSS custom property name
|
|
178
|
+
* @param {string} name - Variable name to validate (without -- prefix)
|
|
179
|
+
* @returns {boolean} - True if valid
|
|
180
|
+
*/
|
|
181
|
+
export function isValidCSSVariableName(name) {
|
|
182
|
+
if (typeof name !== 'string' || name.length === 0) return false;
|
|
183
|
+
|
|
184
|
+
// CSS custom properties must start with letter or underscore
|
|
185
|
+
// Can contain letters, digits, hyphens, underscores
|
|
186
|
+
// Cannot contain: !, $, @, ~, ^, (, ), [, ], {, }, ", ', \, /
|
|
187
|
+
// See: https://www.w3.org/TR/css-variables/#syntax
|
|
188
|
+
return /^-?[_a-zA-Z][_a-zA-Z0-9-]*$/.test(name);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Validate theme configuration section
|
|
193
|
+
* @param {string} section - Section name (spacing, colors, etc.)
|
|
194
|
+
* @param {Object} values - Values to validate
|
|
195
|
+
* @returns {Object} - { valid: boolean, errors: string[] }
|
|
196
|
+
*/
|
|
197
|
+
export function validateThemeSection(section, values) {
|
|
198
|
+
const errors = [];
|
|
199
|
+
|
|
200
|
+
switch (section) {
|
|
201
|
+
case 'spacing':
|
|
202
|
+
case 'radius':
|
|
203
|
+
for (const [key, value] of Object.entries(values)) {
|
|
204
|
+
// Check key is valid CSS variable name
|
|
205
|
+
if (!isValidCSSVariableName(key)) {
|
|
206
|
+
errors.push(`Invalid key in ${section}: "${key}"`);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Check value is valid CSS length or 'none' keyword
|
|
211
|
+
const isValid = isValidCSSLength(value) || value === 'none';
|
|
212
|
+
if (!isValid) {
|
|
213
|
+
errors.push(`Invalid value in ${section}.${key}: "${value}" (expected CSS length or 'none')`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
break;
|
|
217
|
+
|
|
218
|
+
case 'shadow':
|
|
219
|
+
for (const [key, value] of Object.entries(values)) {
|
|
220
|
+
// Check key is valid CSS variable name
|
|
221
|
+
if (!isValidCSSVariableName(key)) {
|
|
222
|
+
errors.push(`Invalid key in ${section}: "${key}"`);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Shadow values can be complex: 'none' or '<offset-x> <offset-y> <blur> <spread>? <color>?'
|
|
227
|
+
// Check for valid shadow syntax (very basic validation)
|
|
228
|
+
const isValid = value === 'none' ||
|
|
229
|
+
(typeof value === 'string' &&
|
|
230
|
+
value.trim().length > 0 &&
|
|
231
|
+
// Basic check: should contain at least one length value (number with optional unit)
|
|
232
|
+
/^\s*\d+\.?\d*\s*(px|em|rem|%|cm|mm|in|pt|pc|vmin|vmax|vw|vh|)?\s*/.test(value));
|
|
233
|
+
if (!isValid) {
|
|
234
|
+
errors.push(`Invalid value in ${section}.${key}: "${value}" (expected CSS shadow value or 'none')`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
break;
|
|
238
|
+
|
|
239
|
+
case 'colors':
|
|
240
|
+
for (const [key, value] of Object.entries(values)) {
|
|
241
|
+
// Check key is valid CSS variable name
|
|
242
|
+
if (!isValidCSSVariableName(key)) {
|
|
243
|
+
errors.push(`Invalid key in colors: "${key}"`);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Check value is valid color
|
|
248
|
+
if (!isValidColor(value)) {
|
|
249
|
+
errors.push(`Invalid color value for colors.${key}: "${value}"`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
break;
|
|
253
|
+
|
|
254
|
+
case 'screens':
|
|
255
|
+
for (const [key, value] of Object.entries(values)) {
|
|
256
|
+
if (!isValidCSSVariableName(key)) {
|
|
257
|
+
errors.push(`Invalid screen name: "${key}"`);
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (!isValidCSSLength(value)) {
|
|
262
|
+
errors.push(`Invalid screen value for ${key}: "${value}" (expected CSS length)`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
break;
|
|
266
|
+
|
|
267
|
+
case 'fontSize':
|
|
268
|
+
for (const [key, value] of Object.entries(values)) {
|
|
269
|
+
if (!isValidCSSVariableName(key)) {
|
|
270
|
+
errors.push(`Invalid font size key: "${key}"`);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Font size can be length or keyword
|
|
275
|
+
const validKeywords = ['xx-small', 'x-small', 'small', 'medium', 'large', 'x-large', 'xx-large'];
|
|
276
|
+
if (!isValidCSSLength(value) && !validKeywords.includes(value)) {
|
|
277
|
+
errors.push(`Invalid font size value for ${key}: "${value}"`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
break;
|
|
281
|
+
|
|
282
|
+
case 'fontWeight':
|
|
283
|
+
for (const [key, value] of Object.entries(values)) {
|
|
284
|
+
if (!isValidCSSVariableName(key)) {
|
|
285
|
+
errors.push(`Invalid font weight key: "${key}"`);
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Font weight: 100-900 or keyword
|
|
290
|
+
const weightNum = parseInt(value, 10);
|
|
291
|
+
const validKeywords = ['normal', 'bold', 'lighter', 'bolder'];
|
|
292
|
+
|
|
293
|
+
if (!(weightNum >= 100 && weightNum <= 900 && weightNum % 100 === 0) &&
|
|
294
|
+
!validKeywords.includes(value)) {
|
|
295
|
+
errors.push(`Invalid font weight value for ${key}: "${value}"`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
break;
|
|
299
|
+
|
|
300
|
+
default:
|
|
301
|
+
// Unknown section - basic validation
|
|
302
|
+
for (const [key, value] of Object.entries(values)) {
|
|
303
|
+
if (typeof value !== 'string') {
|
|
304
|
+
errors.push(`Invalid value type for ${section}.${key}: expected string`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
valid: errors.length === 0,
|
|
311
|
+
errors
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Safe parseInt with bounds checking
|
|
317
|
+
* @param {string|number} value - Value to parse
|
|
318
|
+
* @param {number} min - Minimum allowed value (default: -1000000)
|
|
319
|
+
* @param {number} max - Maximum allowed value (default: 1000000)
|
|
320
|
+
* @returns {number} - Parsed integer or 0 if invalid/out of bounds
|
|
321
|
+
*/
|
|
322
|
+
export function safeParseInt(value, min = -1000000, max = 1000000) {
|
|
323
|
+
const num = parseInt(value, 10);
|
|
324
|
+
|
|
325
|
+
// Check for NaN
|
|
326
|
+
if (isNaN(num)) {
|
|
327
|
+
return 0;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Check for infinity
|
|
331
|
+
if (!isFinite(num)) {
|
|
332
|
+
return 0;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Check bounds
|
|
336
|
+
if (num < min || num > max) {
|
|
337
|
+
return 0;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return num;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Safe parseFloat with bounds checking
|
|
345
|
+
* @param {string|number} value - Value to parse
|
|
346
|
+
* @param {number} min - Minimum allowed value
|
|
347
|
+
* @param {number} max - Maximum allowed value
|
|
348
|
+
* @returns {number} - Parsed float or 0 if invalid/out of bounds
|
|
349
|
+
*/
|
|
350
|
+
export function safeParseFloat(value, min = -1000000, max = 1000000) {
|
|
351
|
+
const num = parseFloat(value);
|
|
352
|
+
|
|
353
|
+
if (isNaN(num)) {
|
|
354
|
+
return 0;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (!isFinite(num)) {
|
|
358
|
+
return 0;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (num < min || num > max) {
|
|
362
|
+
return 0;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return num;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Get current memory usage in MB
|
|
371
|
+
* @returns {number} - Memory usage in MB
|
|
372
|
+
*/
|
|
373
|
+
export function getMemoryUsage() {
|
|
374
|
+
if (typeof process !== 'undefined' && process.memoryUsage) {
|
|
375
|
+
const usage = process.memoryUsage();
|
|
376
|
+
return Math.round(usage.heapUsed / 1024 / 1024);
|
|
377
|
+
}
|
|
378
|
+
return 0;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Check if memory usage is within safe limits
|
|
383
|
+
* @param {number} maxMemoryMB - Maximum allowed memory in MB (default: 500)
|
|
384
|
+
* @returns {boolean} - True if memory is within limits
|
|
385
|
+
*/
|
|
386
|
+
export function isMemorySafe(maxMemoryMB = 500) {
|
|
387
|
+
const currentMemory = getMemoryUsage();
|
|
388
|
+
return currentMemory < maxMemoryMB;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Batch process array items with memory checks
|
|
393
|
+
* @param {Array} items - Items to process
|
|
394
|
+
* @param {Function} processor - Processing function (item, index) => result
|
|
395
|
+
* @param {number} batchSize - Number of items per batch (default: 1000)
|
|
396
|
+
* @param {number} maxMemoryMB - Maximum memory in MB before yielding (default: 500)
|
|
397
|
+
* @returns {Array} - Processed results
|
|
398
|
+
*/
|
|
399
|
+
export async function batchProcessWithMemoryLimit(
|
|
400
|
+
items,
|
|
401
|
+
processor,
|
|
402
|
+
batchSize = 1000,
|
|
403
|
+
maxMemoryMB = 500
|
|
404
|
+
) {
|
|
405
|
+
const results = [];
|
|
406
|
+
|
|
407
|
+
for (let i = 0; i < items.length; i += batchSize) {
|
|
408
|
+
const batch = items.slice(i, i + batchSize);
|
|
409
|
+
|
|
410
|
+
// Process batch
|
|
411
|
+
for (let j = 0; j < batch.length; j++) {
|
|
412
|
+
const result = processor(batch[j], i + j);
|
|
413
|
+
results.push(result);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Check memory usage and yield if needed
|
|
417
|
+
if (!isMemorySafe(maxMemoryMB)) {
|
|
418
|
+
logger.warn(`Memory usage approaching limit (${getMemoryUsage()}MB), yielding control`);
|
|
419
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
420
|
+
|
|
421
|
+
// If still over limit after yielding, throw error
|
|
422
|
+
if (!isMemorySafe(maxMemoryMB)) {
|
|
423
|
+
throw new Error(
|
|
424
|
+
`Memory limit exceeded: ${getMemoryUsage()}MB > ${maxMemoryMB}MB. ` +
|
|
425
|
+
'Consider reducing the number of files or increasing Node.js memory limit.'
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return results;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Batch process tokens with memory checks
|
|
436
|
+
* @param {Array} tokens - Tokens to process
|
|
437
|
+
* @param {Function} processor - Processing function (token, index) => result
|
|
438
|
+
* @param {number} batchSize - Number of tokens per batch (default: 1000)
|
|
439
|
+
* @returns {Array} - Processed results
|
|
440
|
+
*/
|
|
441
|
+
export async function batchProcessTokens(tokens, processor, batchSize = 1000) {
|
|
442
|
+
const maxMemoryMB = 500;
|
|
443
|
+
return batchProcessWithMemoryLimit(tokens, processor, batchSize, maxMemoryMB);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export default {
|
|
447
|
+
sanitizeValue,
|
|
448
|
+
isValidColor,
|
|
449
|
+
isValidCSSLength,
|
|
450
|
+
isValidCSSVariableName,
|
|
451
|
+
validateThemeSection,
|
|
452
|
+
getMemoryUsage,
|
|
453
|
+
isMemorySafe,
|
|
454
|
+
batchProcessWithMemoryLimit,
|
|
455
|
+
batchProcessTokens
|
|
456
|
+
};
|