@thejaredwilcurt/csslop 0.0.1

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,556 @@
1
+ /**
2
+ * @file Converts parsed CSS AST rule nodes into minified CSS strings, handling all CSS at-rule types, selectors, declarations, and nesting.
3
+ */
4
+
5
+ import { processDeclarations } from '../declarations/process.js';
6
+ import { minifyValue } from '../value/minify.js';
7
+
8
+ import {
9
+ canUnwrapSupports,
10
+ normalizeMedia,
11
+ normalizeSupports,
12
+ unescapeIdent,
13
+ unescapeSelector
14
+ } from './normalize.js';
15
+
16
+ /**
17
+ * Renders an array of CSS declaration objects as a minified semicolon-separated string, filtering out whitespace entries.
18
+ *
19
+ * @param {Array} declarations The declaration objects to render.
20
+ * @return {string} A semicolon-joined "property:value" string.
21
+ */
22
+ function stringifyDeclarations (declarations) {
23
+ return declarations
24
+ .filter((declaration) => {
25
+ return declaration.type !== 'whitespace';
26
+ })
27
+ .map((declaration) => {
28
+ return [declaration.property, ':', minifyValue(declaration)].join('');
29
+ })
30
+ .join(';');
31
+ }
32
+
33
+ /**
34
+ * Recursively stringifies child rules into a concatenated minified CSS string.
35
+ *
36
+ * @param {Array} rules The child AST rule nodes to stringify.
37
+ * @param {object} context The minification context.
38
+ * @return {string} The concatenated minified CSS for all child rules.
39
+ */
40
+ function stringifyChildRules (rules, context) {
41
+ return (rules || []).map((childRule) => {
42
+ return stringifyRule(childRule, context);
43
+ }).join('');
44
+ }
45
+
46
+ /**
47
+ * Processes a bare `:is()` selector by merging `:link`+`:visited` into `:any-link`,
48
+ * de-duplicating, sorting alphabetically, and conditionally expanding into individual
49
+ * selectors when all parts are simple type/universal selectors with no modifications.
50
+ *
51
+ * @param {string} selector A minified CSS selector string.
52
+ * @return {Array} An array of one or more processed selector strings.
53
+ */
54
+ function processIsSelector (selector) {
55
+ // Replace :is(:link,:visited) and :is(:visited,:link) with :any-link
56
+ selector = selector.replace(/:is\(:link,:visited\)/g, ':any-link');
57
+ selector = selector.replace(/:is\(:visited,:link\)/g, ':any-link');
58
+ // Only process bare :is() selectors (where :is() is the entire selector)
59
+ if (!selector.startsWith(':is(')) {
60
+ return [selector];
61
+ }
62
+ let depth = 0;
63
+ let closingIndex = -1;
64
+ for (let index = 4; index < selector.length; index++) {
65
+ if (selector[index] === '(') {
66
+ depth++;
67
+ } else if (selector[index] === ')') {
68
+ if (depth === 0) {
69
+ closingIndex = index;
70
+ break;
71
+ }
72
+ depth--;
73
+ }
74
+ }
75
+ if (closingIndex !== selector.length - 1) {
76
+ return [selector];
77
+ }
78
+ const content = selector.slice(4, -1);
79
+ let parts = [];
80
+ let currentPart = '';
81
+ let parenDepth = 0;
82
+ for (const character of content) {
83
+ if (character === '(') {
84
+ parenDepth++;
85
+ } else if (character === ')') {
86
+ parenDepth--;
87
+ }
88
+ if (character === ',' && parenDepth === 0) {
89
+ parts.push(currentPart);
90
+ currentPart = '';
91
+ } else {
92
+ currentPart += character;
93
+ }
94
+ }
95
+ parts.push(currentPart);
96
+ const originalCount = parts.length;
97
+ // Replace :link + :visited with :any-link
98
+ const hasLink = parts.includes(':link');
99
+ const hasVisited = parts.includes(':visited');
100
+ if (hasLink && hasVisited) {
101
+ parts = parts.filter((part) => {
102
+ return part !== ':link' && part !== ':visited';
103
+ });
104
+ if (!parts.includes(':any-link')) {
105
+ parts.push(':any-link');
106
+ }
107
+ }
108
+ // De-duplicate
109
+ parts = [...new Set(parts)];
110
+ // Sort alphabetically
111
+ parts.sort();
112
+ // Unwrap :is() with a single selector
113
+ if (parts.length === 1) {
114
+ return parts;
115
+ }
116
+ // Expand if all parts are simple type/universal selectors and no dedup/replacement occurred
117
+ const allSimple = parts.every((part) => {
118
+ return /^[a-z*][a-z0-9-]*$/i.test(part);
119
+ });
120
+ if (allSimple && parts.length === originalCount) {
121
+ return parts;
122
+ }
123
+ return [':is(' + parts.join(',') + ')'];
124
+ }
125
+
126
+ /**
127
+ * Converts a parsed CSS AST rule node into a minified CSS string, dispatching to specialized handlers for each rule type including selectors, `@media`, `@keyframes`, `@layer`, and other at-rules.
128
+ *
129
+ * @param {object} rule The AST rule node to stringify.
130
+ * @param {object} context The minification context with registered custom property data.
131
+ * @param {boolean} nested Whether this rule is nested inside another rule, affecting spacing.
132
+ * @return {string} The minified CSS string for this rule, or an empty string if the rule is empty.
133
+ */
134
+ function stringifyRule (rule, context, nested = false) {
135
+ if (rule.type === 'rule') {
136
+ let declarations = rule.declarations
137
+ ?.filter((declaration) => {
138
+ return declaration.type !== 'whitespace';
139
+ }) || [];
140
+
141
+ // Ignore empty rules (comments-only rules are also effectively empty)
142
+ const isEffectivelyEmpty = (
143
+ declarations.length === 0 ||
144
+ declarations.every((declaration) => {
145
+ return declaration.type === 'comment' && !declaration.comment?.startsWith('!');
146
+ })
147
+ );
148
+ if (isEffectivelyEmpty) {
149
+ return '';
150
+ }
151
+
152
+ let output = [];
153
+ if (rule.selectors?.length) {
154
+ let uniqueSelectors = [...new Set(rule.selectors)];
155
+ // Minify spacing within selectors (e.g. inside :is(), :where(), etc)
156
+ uniqueSelectors = uniqueSelectors.map((selector) => {
157
+ let minified = unescapeSelector(selector);
158
+ // Collapse whitespace to single space
159
+ minified = minified.replace(/\s+/g, ' ');
160
+ // Strip whitespace around selector combinators and commas
161
+ minified = minified.replace(/\s*([,>+~])\s*/g, '$1');
162
+ // Strip whitespace inside parentheses for pseudo-class arguments
163
+ minified = minified.replace(/\(\s+/g, '(').replace(/\s+\)/g, ')');
164
+ // Simplify :nth-child(2n+1) to :nth-child(odd)
165
+ minified = minified.replace(/:nth-child\(2n\s*\+\s*1\)/g, ':nth-child(odd)');
166
+ // Simplify :nth-child(2n+0) to :nth-child(2n)
167
+ minified = minified.replace(/:nth-child\(2n\s*\+\s*0\)/g, ':nth-child(2n)');
168
+ // Simplify :nth-child(1n) to :nth-child(n)
169
+ minified = minified.replace(/:nth-child\(1n\)/g, ':nth-child(n)');
170
+ // Remove unnecessary leading + sign from :nth-child()
171
+ minified = minified.replace(/:nth-child\(\+\s*(\d+)\)/g, ':nth-child($1)');
172
+ // Simplify :nth-child(0n+N) to :nth-child(N)
173
+ minified = minified.replace(/:nth-child\(0n\s*\+\s*(\d+)\)/g, ':nth-child($1)');
174
+ // Replace :nth-child(1) with :first-child
175
+ minified = minified.replace(/:nth-child\(1\)/g, ':first-child');
176
+ // Replace :nth-last-child(1) with :last-child
177
+ minified = minified.replace(/:nth-last-child\(1\)/g, ':last-child');
178
+ // Replace :nth-of-type(1) with :first-of-type
179
+ minified = minified.replace(/:nth-of-type\(1\)/g, ':first-of-type');
180
+ // Replace :nth-last-of-type(1) with :last-of-type
181
+ minified = minified.replace(/:nth-last-of-type\(1\)/g, ':last-of-type');
182
+ // Convert double-colon pseudo-elements to single-colon legacy form
183
+ minified = minified.replace(/::before/g, ':before');
184
+ minified = minified.replace(/::after/g, ':after');
185
+
186
+ // Strip redundant universal selector `*` when it precedes an ID, class, or attribute selector
187
+ minified = minified.replace(/\*([#.[])/g, '$1');
188
+
189
+ // Minify double-quoted attribute selectors: remove inner whitespace and escape when shorter
190
+ minified = minified.replace(/\[\s*([^=]+)\s*=\s*"(.*?)"\s*\]/g, (match, attribute, value) => {
191
+ // Escape special characters that require quoting, and compare lengths
192
+ let escaped = value.replace(/([#.:/])/g, '\\$1');
193
+ if (escaped.length < value.length + 2) {
194
+ return '[' + attribute + '=' + escaped + ']';
195
+ }
196
+ return '[' + attribute + '="' + value + '"]';
197
+ });
198
+ // Minify single-quoted attribute selectors: remove inner whitespace and escape when shorter
199
+ minified = minified.replace(/\[\s*([^=]+)\s*=\s*'(.*?)'\s*\]/g, (match, attribute, value) => {
200
+ // Escape special characters that require quoting, and compare lengths
201
+ let escaped = value.replace(/([#.:/])/g, '\\$1');
202
+ if (escaped.length < value.length + 2) {
203
+ return '[' + attribute + '=' + escaped + ']';
204
+ }
205
+ return '[' + attribute + '="' + value + '"]';
206
+ });
207
+ // Minify unquoted attribute selectors: quote when unescaping produces a shorter result
208
+ minified = minified.replace(/\[\s*([^=]+)\s*=\s*([^"'].*?)\s*\]/g, (match, attribute, value) => {
209
+ // Unescape special characters and compare with quoted form
210
+ let unescaped = value.replace(/\\([#.:/])/g, '$1');
211
+ if (unescaped.length + 2 < value.length) {
212
+ return '[' + attribute + '="' + unescaped + '"]';
213
+ }
214
+ return '[' + attribute + '=' + value + ']';
215
+ });
216
+
217
+ // Minify logical combinations
218
+ minified = minified.replace(/(?<=\b(?:button|fieldset|form|input|select|textarea)):not\(:invalid\)/g, ':valid');
219
+ minified = minified.replace(/:not\(:dir\(ltr\)\)/g, ':dir(rtl)');
220
+ minified = minified.replace(/:not\(:not\((.*?)\)\)/g, '$1');
221
+ minified = minified.replace(/:not\(:enabled\)/g, ':disabled');
222
+ minified = minified.replace(/:not\(:required\)/g, ':optional');
223
+ minified = minified.replace(/(^|[\s,>+~])(a|area|link)(?:\[.*?\])*(?::where\()?:not\(:link\)\)?/g, (match) => {
224
+ return match.replace(':not(:link)', ':visited');
225
+ });
226
+ // Remove redundant leading "& " nesting selector
227
+ minified = minified.replace(/^& /, '');
228
+ return minified;
229
+ });
230
+ uniqueSelectors = uniqueSelectors.flatMap(processIsSelector);
231
+ uniqueSelectors = [...new Set(uniqueSelectors)];
232
+ const headingSet = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
233
+ const isAllHeadings = (
234
+ rule.selectors.length === 6 &&
235
+ uniqueSelectors.length === 6 &&
236
+ uniqueSelectors.every((selector) => {
237
+ return headingSet.has(selector);
238
+ })
239
+ );
240
+ if (isAllHeadings) {
241
+ uniqueSelectors = [':heading'];
242
+ }
243
+ output.push(uniqueSelectors.join(','));
244
+ }
245
+ output.push('{');
246
+
247
+ declarations = processDeclarations(declarations, context);
248
+
249
+ // We need to properly output nested rules vs normal declarations.
250
+ // Wait, the processDeclarations will now correctly keep 'rule' types because we push them in processDeclarations.
251
+ // If we have `.foo { .bar { color: red; } }`, the inner rule is a declaration of type 'rule' with selectors: ['.bar'].
252
+ // The previous stringifyRule recursively calls stringifyRule.
253
+
254
+ let innerDeclarations = declarations.filter((declaration) => {
255
+ return declaration.type !== 'rule' && declaration.type !== 'media';
256
+ });
257
+ let nestedRules = declarations.filter((declaration) => {
258
+ return declaration.type === 'rule' || declaration.type === 'media';
259
+ });
260
+
261
+ let renderedDeclarations = innerDeclarations
262
+ .map((declaration) => {
263
+ if (!declaration.property) {
264
+ return '';
265
+ }
266
+ const property = unescapeIdent(declaration.property);
267
+ let value;
268
+ if (property.startsWith('--')) {
269
+ const syntax = context.registeredCustomPropertySyntax.get(property);
270
+ if (syntax === '"<color>"') {
271
+ value = minifyValue(declaration);
272
+ } else {
273
+ const rawValue = declaration.rawValue || declaration.value || '';
274
+ const trimmedRawValue = rawValue.trim();
275
+ if (trimmedRawValue === '') {
276
+ value = ' ';
277
+ // Preserve leading space for rgb() space-syntax values in custom properties
278
+ } else if (/^rgb\(\s*\d+\s+\d+\s+\d+\s*\)$/i.test(trimmedRawValue)) {
279
+ value = ' ' + trimmedRawValue;
280
+ } else {
281
+ value = trimmedRawValue;
282
+ }
283
+ }
284
+ } else {
285
+ value = minifyValue(declaration);
286
+ }
287
+ return [property, ':', value].join('');
288
+ })
289
+ .join(';');
290
+
291
+ let renderedNested = nestedRules.map((nestedRule) => {
292
+ return stringifyRule(nestedRule, context, true);
293
+ }).join('');
294
+
295
+ output.push(renderedDeclarations);
296
+ if (renderedDeclarations && renderedNested && !renderedDeclarations.endsWith(';')) {
297
+ output.push(';');
298
+ }
299
+ output.push(renderedNested);
300
+
301
+ output.push('}');
302
+ return output.join('');
303
+ }
304
+
305
+ if (rule.type === 'media') {
306
+ const normalizedMedia = normalizeMedia(rule.media);
307
+ let separator;
308
+ if (nested && normalizedMedia.startsWith('(')) {
309
+ separator = '';
310
+ } else {
311
+ separator = ' ';
312
+ }
313
+ const items = rule.rules || [];
314
+ const mediaDeclarations = items.filter((item) => {
315
+ return item.type === 'declaration' && item.property;
316
+ });
317
+ const subRules = items.filter((item) => {
318
+ return item.type !== 'declaration';
319
+ });
320
+ const renderedDeclarations = mediaDeclarations.map((declaration) => {
321
+ return [unescapeIdent(declaration.property), ':', minifyValue(declaration)].join('');
322
+ }).join(';');
323
+ const renderedRules = subRules.map((childRule) => {
324
+ return stringifyRule(childRule, context, false);
325
+ }).join('');
326
+ const children = [renderedDeclarations, renderedRules].filter(Boolean).join('');
327
+ if (!children) {
328
+ return '';
329
+ }
330
+ return '@media' + separator + normalizedMedia + '{' + children + '}';
331
+ }
332
+
333
+ if (rule.type === 'starting-style') {
334
+ const children = stringifyChildRules(rule.rules, context);
335
+ if (!children) {
336
+ return '';
337
+ }
338
+ return '@starting-style{' + children + '}';
339
+ }
340
+
341
+ if (rule.type === 'scope') {
342
+ // Collapse whitespace around "to" keyword in @scope condition
343
+ const scope = (rule.scope || '').trim().replace(/\s+to\s+/g, 'to ');
344
+ const children = stringifyChildRules(rule.rules, context);
345
+ if (!children) {
346
+ return '';
347
+ }
348
+ return '@scope' + scope + '{' + children + '}';
349
+ }
350
+
351
+ if (rule.type === 'supports') {
352
+ let supports = normalizeSupports(rule.supports);
353
+ let children = stringifyChildRules(rule.rules, context);
354
+ if (!children) {
355
+ return '';
356
+ }
357
+ if (canUnwrapSupports(supports)) {
358
+ return children;
359
+ }
360
+ // Check if @supports condition has adjacent logical operators that allow tight spacing
361
+ const needsTightSpacing = supports.startsWith('(') && /\)(?:and|or)\s*\(/.test(supports);
362
+ let supportsSeparator;
363
+ if (needsTightSpacing) {
364
+ supportsSeparator = '';
365
+ } else {
366
+ supportsSeparator = ' ';
367
+ }
368
+ return '@supports' + supportsSeparator + supports + '{' + children + '}';
369
+ }
370
+
371
+ if (rule.type === 'keyframes') {
372
+ let children = (rule.keyframes || [])
373
+ .filter((keyframe) => {
374
+ return keyframe.type === 'keyframe';
375
+ })
376
+ .map((keyframe) => {
377
+ let output = [];
378
+ let stopValues = keyframe.values.map((stopValue) => {
379
+ if (stopValue === 'from') {
380
+ return '0%';
381
+ }
382
+ if (stopValue === '100%') {
383
+ return 'to';
384
+ }
385
+ return stopValue;
386
+ });
387
+ output.push(stopValues.join(','));
388
+ output.push('{');
389
+ const renderedKeyframeDeclarations = keyframe.declarations
390
+ ?.filter((declaration) => {
391
+ return declaration.type !== 'whitespace';
392
+ })
393
+ ?.map((declaration) => {
394
+ return [declaration.property, ':', minifyValue(declaration)].join('');
395
+ })
396
+ .join(';') || '';
397
+ output.push(renderedKeyframeDeclarations);
398
+ output.push('}');
399
+ return output.join('');
400
+ }).join('');
401
+ if (!children) {
402
+ return '';
403
+ }
404
+ return '@keyframes ' + rule.name + '{' + children + '}';
405
+ }
406
+
407
+ if (rule.type === 'font-face') {
408
+ let renderedDeclarations = stringifyDeclarations(rule.declarations || []);
409
+ if (!renderedDeclarations) {
410
+ return '';
411
+ }
412
+ return '@font-face{' + renderedDeclarations + '}';
413
+ }
414
+
415
+ if (rule.type === 'charset') {
416
+ return '@charset ' + rule.charset + ';';
417
+ }
418
+
419
+ if (rule.type === 'import') {
420
+ let importStatement = rule.import;
421
+ // Unwrap url() with a quoted string to just the quoted string
422
+ importStatement = importStatement.replace(/url\(\s*(".*?"|'.*?')\s*\)/g, '$1');
423
+ // Unwrap url() with an unquoted path and add quotes
424
+ importStatement = importStatement.replace(/url\(\s*(.*?)\s*\)/g, '"$1"');
425
+ // Collapse whitespace in the import statement
426
+ importStatement = importStatement.replace(/\s+/g, ' ').trim();
427
+ // Remove space immediately after the quoted URL path
428
+ importStatement = importStatement.replace(/^(".*?"|'.*?') /, '$1');
429
+ // Remove space between closing paren and next at-rule condition keyword
430
+ importStatement = importStatement.replace(/\) ([a-zA-Z])/g, ')$1');
431
+ // Minify property:value pairs inside supports() by removing whitespace around colons
432
+ importStatement = importStatement.replace(
433
+ /supports\(([^()]*)\)/g,
434
+ (fullMatch, content) => {
435
+ return 'supports(' + content.replace(/\s*:\s*/g, ':') + ')';
436
+ }
437
+ );
438
+ const startsWithQuote = importStatement.startsWith('"') || importStatement.startsWith('\'');
439
+ let importSeparator;
440
+ if (startsWithQuote) {
441
+ importSeparator = '';
442
+ } else {
443
+ importSeparator = ' ';
444
+ }
445
+ return '@import' + importSeparator + importStatement + ';';
446
+ }
447
+
448
+ if (rule.type === 'layer') {
449
+ if (rule.rules && rule.rules.length) {
450
+ return '@layer ' + (rule.layer || '') + '{' + stringifyChildRules(rule.rules, context) + '}';
451
+ } else {
452
+ return '@layer ' + rule.layer + ';';
453
+ }
454
+ }
455
+
456
+ if (rule.type === 'property') {
457
+ let renderedDeclarations = stringifyDeclarations(rule.declarations || []);
458
+ if (!renderedDeclarations) {
459
+ return '';
460
+ }
461
+ return '@property ' + rule.name + '{' + renderedDeclarations + '}';
462
+ }
463
+
464
+ if (rule.type === 'container') {
465
+ // Minify @container condition: collapse whitespace and strip spaces around punctuation
466
+ let container = rule.container
467
+ .replace(/\s+/g, ' ')
468
+ .replace(/\s*([:,])\s*/g, '$1')
469
+ .replace(/\s*([=<>])\s*/g, '$1')
470
+ .replace(/\(\s+/g, '(')
471
+ .replace(/\s+\)/g, ')');
472
+ // Convert min-width/max-width to range syntax (e.g. min-width:768px → width>=768px)
473
+ container = container.replace(/min-width:(\d+px)/gi, 'width>=$1').replace(/max-width:(\d+px)/gi, 'width<=$1');
474
+ let children = stringifyChildRules(rule.rules, context);
475
+ if (!children) {
476
+ return '';
477
+ }
478
+ let containerSeparator;
479
+ if (container.startsWith('(')) {
480
+ containerSeparator = '';
481
+ } else {
482
+ containerSeparator = ' ';
483
+ }
484
+ return '@container' + containerSeparator + container + '{' + children + '}';
485
+ }
486
+
487
+ if (rule.type === 'page') {
488
+ const trimmedSelectors = (rule.selectors || []).map((selector) => {
489
+ return selector.trim();
490
+ }).filter(Boolean);
491
+ const selectorString = trimmedSelectors.join(',');
492
+ let pageSeparator = '';
493
+ if (selectorString) {
494
+ if (selectorString.startsWith(':')) {
495
+ pageSeparator = '';
496
+ } else {
497
+ pageSeparator = ' ';
498
+ }
499
+ }
500
+ const parts = (rule.declarations || [])
501
+ .filter((declaration) => {
502
+ return declaration.type !== 'whitespace';
503
+ })
504
+ .flatMap((declaration) => {
505
+ if (declaration.type === 'page-margin-box' && declaration.name) {
506
+ const innerDeclarations = (declaration.declarations || [])
507
+ .filter((innerDeclaration) => {
508
+ return innerDeclaration.type !== 'whitespace' && innerDeclaration.property;
509
+ })
510
+ .map((innerDeclaration) => {
511
+ return [unescapeIdent(innerDeclaration.property), ':', minifyValue(innerDeclaration)].join('');
512
+ })
513
+ .join(';');
514
+ if (innerDeclarations) {
515
+ return ['@' + declaration.name + '{' + innerDeclarations + '}'];
516
+ }
517
+ return [];
518
+ }
519
+ if (declaration.property) {
520
+ return [[unescapeIdent(declaration.property), ':', minifyValue(declaration)].join('')];
521
+ }
522
+ return [];
523
+ });
524
+ if (!parts.length) {
525
+ return '';
526
+ }
527
+ return '@page' + pageSeparator + selectorString + '{' + parts.join(';') + '}';
528
+ }
529
+
530
+ if (rule.type === 'counter-style') {
531
+ let renderedDeclarations = stringifyDeclarations(rule.declarations || []);
532
+ if (!renderedDeclarations) {
533
+ return '';
534
+ }
535
+ return '@counter-style ' + rule.name + '{' + renderedDeclarations + '}';
536
+ }
537
+
538
+ if (rule.type === 'position-try') {
539
+ let renderedDeclarations = stringifyDeclarations(rule.declarations || []);
540
+ if (!renderedDeclarations) {
541
+ return '';
542
+ }
543
+ return '@position-try ' + rule.name + '{' + renderedDeclarations + '}';
544
+ }
545
+
546
+ if (rule.type === 'comment') {
547
+ if (rule.comment.startsWith('!')) {
548
+ return '/*' + rule.comment + '*/';
549
+ }
550
+ return '';
551
+ }
552
+
553
+ return ''; // Ignore unknown for now
554
+ }
555
+
556
+ export { stringifyRule };
@@ -0,0 +1,37 @@
1
+ /**
2
+ * @file General CSS utilities shared across the minification pipeline.
3
+ */
4
+
5
+ /**
6
+ * Resolves a CSS Unicode escape hex string to its literal character.
7
+ * Returns null for control characters (code points below 0x20 or equal to 0x7F),
8
+ * which must remain escaped in CSS.
9
+ *
10
+ * @param {string} hex The hex digit string (1–6 characters) from a CSS escape sequence.
11
+ * @return {string|null} The resolved character, or null if the code point is a control character.
12
+ */
13
+ function resolveUnicodeEscape (hex) {
14
+ const codePoint = parseInt(hex, 16);
15
+ const isControlCharacter = codePoint < 0x20 || codePoint === 0x7f;
16
+ if (isControlCharacter) {
17
+ return null;
18
+ }
19
+ return String.fromCodePoint(codePoint);
20
+ }
21
+
22
+ /**
23
+ * Escapes special regex metacharacters in a string so it can be safely used
24
+ * as a literal pattern in a RegExp constructor.
25
+ *
26
+ * @param {string} input The string to escape.
27
+ * @return {string} The escaped string safe for use in a RegExp.
28
+ */
29
+ function escapeRegexString (input) {
30
+ // Escape all regex metacharacters: . * + ? ^ $ { } ( ) | [ ] backslash
31
+ return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
32
+ }
33
+
34
+ export {
35
+ escapeRegexString,
36
+ resolveUnicodeEscape
37
+ };