@thejaredwilcurt/csslop 0.0.5 → 0.0.7
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/README.md +1 -1
- package/package.json +3 -3
- package/src/index.js +7 -2
- package/src/rules/optimize.js +143 -1
- package/src/value/minify.js +3 -20
- package/src/value/unicode-range.js +176 -0
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
* Use the tests from the open source, 3rd-party, CSS minification auditing library [css-minify-tests](https://github.com/keithamus/css-minify-tests).
|
|
16
16
|
* Have AI completely generate the library logic to pass all tests.
|
|
17
|
-
* Have me (an experienced library author) validate the outcomes and [make upstream corrections](https://github.com/keithamus/css-minify-tests/
|
|
17
|
+
* Have me (an experienced library author) validate the outcomes and [make upstream corrections](https://github.com/keithamus/css-minify-tests/issues?q=author%3ATheJaredWilcurt) to the tests library.
|
|
18
18
|
|
|
19
19
|
**The Results:**
|
|
20
20
|
|
package/package.json
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
"name": "@thejaredwilcurt/csslop",
|
|
3
3
|
"main": "index.js",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.0.
|
|
5
|
+
"version": "0.0.7",
|
|
6
6
|
"description": "Experimental CSS minification",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"copy": "node ./scripts/copyTests.js",
|
|
9
|
-
"test": "node ./tests/
|
|
10
|
-
"real": "node ./tests/
|
|
9
|
+
"test": "node ./tests/index.test.js",
|
|
10
|
+
"real": "node ./tests/realworld.test.js",
|
|
11
11
|
"lint": "eslint *.js scripts src tests --fix",
|
|
12
12
|
"bump": "npx --yes -- @jsdevtools/version-bump-prompt && npm i",
|
|
13
13
|
"publish": "npm publish --access=public"
|
package/src/index.js
CHANGED
|
@@ -20,11 +20,13 @@ import {
|
|
|
20
20
|
import {
|
|
21
21
|
deduplicateKeyframes,
|
|
22
22
|
expandPureNestedRules,
|
|
23
|
+
factorCommonParents,
|
|
23
24
|
mergeByDeclarations,
|
|
24
25
|
mergeLayerRules,
|
|
25
26
|
mergeMediaRules,
|
|
26
27
|
mergeSelectorRules,
|
|
27
|
-
nestFlatRules
|
|
28
|
+
nestFlatRules,
|
|
29
|
+
removeEmptyRules
|
|
28
30
|
} from './rules/optimize.js';
|
|
29
31
|
import { stringifyRule } from './rules/stringify.js';
|
|
30
32
|
import { minifyValue } from './value/minify.js';
|
|
@@ -84,7 +86,10 @@ export const minifyCSS = function (input) {
|
|
|
84
86
|
|
|
85
87
|
const mergedRules = mergeSelectorRules(ast.stylesheet.rules);
|
|
86
88
|
const declarationMergedRules = mergeByDeclarations(mergedRules);
|
|
87
|
-
const
|
|
89
|
+
const nestedRules = nestFlatRules(declarationMergedRules);
|
|
90
|
+
const nonEmptyRules = removeEmptyRules(nestedRules);
|
|
91
|
+
const factoredRules = factorCommonParents(nonEmptyRules);
|
|
92
|
+
const finalRules = nestFlatRules(factoredRules);
|
|
88
93
|
|
|
89
94
|
for (const rule of finalRules) {
|
|
90
95
|
output.push(stringifyRule(rule, context));
|
package/src/rules/optimize.js
CHANGED
|
@@ -123,6 +123,50 @@ function tryNestSelector (parentSel, childSel) {
|
|
|
123
123
|
return null;
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Determines whether a rule is effectively empty, containing no meaningful
|
|
128
|
+
* CSS output after minification. A rule is effectively empty when it has
|
|
129
|
+
* no declarations, or all of its entries are whitespace, non-important
|
|
130
|
+
* comments, or recursively empty nested rules.
|
|
131
|
+
*
|
|
132
|
+
* @param {object} rule The AST rule node to evaluate.
|
|
133
|
+
* @return {boolean} True if the rule produces no CSS output.
|
|
134
|
+
*/
|
|
135
|
+
function isRuleEffectivelyEmpty (rule) {
|
|
136
|
+
if (rule.type !== 'rule') {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
const nonWhitespaceEntries = (rule.declarations || []).filter((declaration) => {
|
|
140
|
+
return declaration.type !== 'whitespace';
|
|
141
|
+
});
|
|
142
|
+
if (nonWhitespaceEntries.length === 0) {
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
return nonWhitespaceEntries.every((entry) => {
|
|
146
|
+
if (entry.type === 'comment') {
|
|
147
|
+
return !entry.comment?.startsWith('!');
|
|
148
|
+
}
|
|
149
|
+
if (entry.type === 'rule') {
|
|
150
|
+
return isRuleEffectivelyEmpty(entry);
|
|
151
|
+
}
|
|
152
|
+
return false;
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Filters out effectively empty rules from the rules array, preventing
|
|
158
|
+
* empty rules from being nested into parent rules during later
|
|
159
|
+
* optimization passes and producing incorrect non-empty output.
|
|
160
|
+
*
|
|
161
|
+
* @param {Array} rules The AST rule nodes to filter.
|
|
162
|
+
* @return {Array} A new array with effectively empty rules removed.
|
|
163
|
+
*/
|
|
164
|
+
function removeEmptyRules (rules) {
|
|
165
|
+
return rules.filter((rule) => {
|
|
166
|
+
return !isRuleEffectivelyEmpty(rule);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
126
170
|
/**
|
|
127
171
|
* Groups flat CSS rules into nested structures where a child selector can be expressed relative to a preceding parent, reducing output size through CSS nesting.
|
|
128
172
|
*
|
|
@@ -171,6 +215,102 @@ function nestFlatRules (rules) {
|
|
|
171
215
|
return result;
|
|
172
216
|
}
|
|
173
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Extracts the leading compound selector of a complex selector: everything up to
|
|
220
|
+
* the first top-level combinator (descendant whitespace, `>`, `+`, or `~`).
|
|
221
|
+
* Combinator characters nested inside `()` or `[]` (such as `:nth-child(2n+1)` or
|
|
222
|
+
* `[a~=b]`) are ignored so only structural combinators split the selector.
|
|
223
|
+
*
|
|
224
|
+
* @param {string} selector The selector string to inspect.
|
|
225
|
+
* @return {string|null} The leading compound selector, or null when the selector has no descendant part to factor out.
|
|
226
|
+
*/
|
|
227
|
+
function extractLeadingCompound (selector) {
|
|
228
|
+
const trimmed = selector.trim();
|
|
229
|
+
let bracketDepth = 0;
|
|
230
|
+
for (let index = 0; index < trimmed.length; index++) {
|
|
231
|
+
const character = trimmed[index];
|
|
232
|
+
if (character === '(' || character === '[') {
|
|
233
|
+
bracketDepth++;
|
|
234
|
+
} else if (character === ')' || character === ']') {
|
|
235
|
+
bracketDepth--;
|
|
236
|
+
} else if (
|
|
237
|
+
bracketDepth === 0 &&
|
|
238
|
+
(character === ' ' || character === '>' || character === '+' || character === '~')
|
|
239
|
+
) {
|
|
240
|
+
const compound = trimmed.slice(0, index).trim();
|
|
241
|
+
return compound.length ? compound : null;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Groups consecutive sibling rules that share a common leading compound selector
|
|
249
|
+
* (such as `.foo` in `.foo .a`, `.foo .b`) into a synthesized parent rule with the
|
|
250
|
+
* shared portion factored out, but only when nesting trims more characters from the
|
|
251
|
+
* child selectors than the wrapper itself costs.
|
|
252
|
+
*
|
|
253
|
+
* @param {Array} rules The flat AST rule nodes to factor.
|
|
254
|
+
* @return {Array} A new array of rules with shared parent selectors factored into nesting wrappers.
|
|
255
|
+
*/
|
|
256
|
+
function factorCommonParents (rules) {
|
|
257
|
+
const result = [];
|
|
258
|
+
let index = 0;
|
|
259
|
+
while (index < rules.length) {
|
|
260
|
+
const rule = rules[index];
|
|
261
|
+
// Only single-selector style rules can act as a factoring candidate.
|
|
262
|
+
if (rule.type !== 'rule' || rule.selectors?.length !== 1) {
|
|
263
|
+
result.push(rule);
|
|
264
|
+
index++;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
const candidateParent = extractLeadingCompound(rule.selectors[0]);
|
|
268
|
+
if (candidateParent === null) {
|
|
269
|
+
result.push(rule);
|
|
270
|
+
index++;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
// Collect the run of consecutive rules that can all nest under the candidate.
|
|
274
|
+
const run = [];
|
|
275
|
+
const nestedForms = [];
|
|
276
|
+
let lookahead = index;
|
|
277
|
+
while (lookahead < rules.length) {
|
|
278
|
+
const sibling = rules[lookahead];
|
|
279
|
+
if (sibling.type !== 'rule' || sibling.selectors?.length !== 1) {
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
const nestedSelector = tryNestSelector(candidateParent, sibling.selectors[0]);
|
|
283
|
+
if (nestedSelector === null) {
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
run.push(sibling);
|
|
287
|
+
nestedForms.push(nestedSelector);
|
|
288
|
+
lookahead++;
|
|
289
|
+
}
|
|
290
|
+
// The wrapper writes the shared selector once plus its surrounding braces.
|
|
291
|
+
const wrapperCost = candidateParent.length + 2;
|
|
292
|
+
const charactersSaved = run.reduce((total, sibling, position) => {
|
|
293
|
+
return total + sibling.selectors[0].trim().length - nestedForms[position].length;
|
|
294
|
+
}, 0);
|
|
295
|
+
if (run.length >= 2 && charactersSaved > wrapperCost) {
|
|
296
|
+
const children = run.map((sibling, position) => {
|
|
297
|
+
return { ...sibling, selectors: [nestedForms[position]] };
|
|
298
|
+
});
|
|
299
|
+
result.push({
|
|
300
|
+
type: 'rule',
|
|
301
|
+
selectors: [candidateParent],
|
|
302
|
+
// Recurse so deeper shared prefixes among the children also factor out.
|
|
303
|
+
declarations: factorCommonParents(children)
|
|
304
|
+
});
|
|
305
|
+
index = lookahead;
|
|
306
|
+
} else {
|
|
307
|
+
result.push(rule);
|
|
308
|
+
index++;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return result;
|
|
312
|
+
}
|
|
313
|
+
|
|
174
314
|
/**
|
|
175
315
|
* Merges adjacent `@media` rules that share an identical normalized query string and deduplicates their child selector rules.
|
|
176
316
|
*
|
|
@@ -367,9 +507,11 @@ function mergeLayerRules (rules, mergeSelectorRules) {
|
|
|
367
507
|
export {
|
|
368
508
|
deduplicateKeyframes,
|
|
369
509
|
expandPureNestedRules,
|
|
510
|
+
factorCommonParents,
|
|
370
511
|
mergeByDeclarations,
|
|
371
512
|
mergeLayerRules,
|
|
372
513
|
mergeMediaRules,
|
|
373
514
|
mergeSelectorRules,
|
|
374
|
-
nestFlatRules
|
|
515
|
+
nestFlatRules,
|
|
516
|
+
removeEmptyRules
|
|
375
517
|
};
|
package/src/value/minify.js
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
roundCompactNumber
|
|
28
28
|
} from './shared.js';
|
|
29
29
|
import { minifyTransformValue } from './transforms.js';
|
|
30
|
+
import { optimizeUnicodeRange } from './unicode-range.js';
|
|
30
31
|
|
|
31
32
|
/**
|
|
32
33
|
* Map of position-area two-keyword values to their single-keyword equivalents.
|
|
@@ -692,27 +693,9 @@ function minifyValue (declaration) {
|
|
|
692
693
|
val = minifyGradients(val);
|
|
693
694
|
}
|
|
694
695
|
|
|
695
|
-
// Unicode range
|
|
696
|
+
// Unicode range optimization: dedup, merge overlapping/adjacent, wildcard compression
|
|
696
697
|
if (declaration.property === 'unicode-range') {
|
|
697
|
-
val = val
|
|
698
|
-
const len = Math.max(startHex.length, endHex.length);
|
|
699
|
-
const s = startHex.padStart(len, '0').toUpperCase();
|
|
700
|
-
const e = endHex.padStart(len, '0').toUpperCase();
|
|
701
|
-
let prefixLen = 0;
|
|
702
|
-
while (prefixLen < len && s[prefixLen] === e[prefixLen]) {
|
|
703
|
-
prefixLen++;
|
|
704
|
-
}
|
|
705
|
-
const suffixS = s.slice(prefixLen);
|
|
706
|
-
const suffixE = e.slice(prefixLen);
|
|
707
|
-
// Check if the suffix range spans all values (all-zeros start, all-F end) for wildcard replacement
|
|
708
|
-
if (/^0*$/.test(suffixS) && /^F*$/i.test(suffixE)) {
|
|
709
|
-
const wildcardCount = len - prefixLen;
|
|
710
|
-
// Strip leading zeros from the common prefix
|
|
711
|
-
const prefix = s.slice(0, prefixLen).replace(/^0+/, '');
|
|
712
|
-
return 'U+' + prefix + '?'.repeat(wildcardCount);
|
|
713
|
-
}
|
|
714
|
-
return match;
|
|
715
|
-
});
|
|
698
|
+
val = optimizeUnicodeRange(val);
|
|
716
699
|
}
|
|
717
700
|
|
|
718
701
|
return val;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Optimizes CSS unicode-range values by deduplicating, merging overlapping
|
|
3
|
+
* and adjacent ranges, collapsing consecutive single values into ranges, and
|
|
4
|
+
* applying wildcard compression.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Parses a single unicode-range token into a numeric start/end pair.
|
|
9
|
+
* Handles single values (U+1F170), explicit ranges (U+2000-2002),
|
|
10
|
+
* and wildcard notation (U+4??).
|
|
11
|
+
*
|
|
12
|
+
* @param {string} token A trimmed unicode-range token.
|
|
13
|
+
* @return {object|null} Object with numeric start and end, or null if unparseable.
|
|
14
|
+
*/
|
|
15
|
+
function parseUnicodeRangeToken (token) {
|
|
16
|
+
const trimmed = token.trim();
|
|
17
|
+
|
|
18
|
+
// Match wildcard notation: U+ followed by optional hex prefix then one or more ?
|
|
19
|
+
const wildcardMatch = trimmed.match(/^U\+([0-9a-fA-F]*)(\?+)$/i);
|
|
20
|
+
if (wildcardMatch) {
|
|
21
|
+
const prefix = wildcardMatch[1] || '';
|
|
22
|
+
const wildcardCount = wildcardMatch[2].length;
|
|
23
|
+
const start = parseInt(prefix + '0'.repeat(wildcardCount), 16);
|
|
24
|
+
const end = parseInt(prefix + 'F'.repeat(wildcardCount), 16);
|
|
25
|
+
return { start, end };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Match explicit range: U+XXXX-YYYY
|
|
29
|
+
const rangeMatch = trimmed.match(/^U\+([0-9a-fA-F]+)-([0-9a-fA-F]+)$/i);
|
|
30
|
+
if (rangeMatch) {
|
|
31
|
+
return {
|
|
32
|
+
start: parseInt(rangeMatch[1], 16),
|
|
33
|
+
end: parseInt(rangeMatch[2], 16)
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Match single code point: U+XXXX
|
|
38
|
+
const singleMatch = trimmed.match(/^U\+([0-9a-fA-F]+)$/i);
|
|
39
|
+
if (singleMatch) {
|
|
40
|
+
const codePoint = parseInt(singleMatch[1], 16);
|
|
41
|
+
return { start: codePoint, end: codePoint };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Sorts ranges by start value and merges any that overlap or are adjacent
|
|
49
|
+
* (where the gap between them is zero or negative after +1 adjustment).
|
|
50
|
+
*
|
|
51
|
+
* @param {Array} ranges Array of {start, end} objects.
|
|
52
|
+
* @return {Array} New array of merged {start, end} objects.
|
|
53
|
+
*/
|
|
54
|
+
function mergeOverlappingRanges (ranges) {
|
|
55
|
+
if (ranges.length <= 1) {
|
|
56
|
+
return ranges.map((range) => {
|
|
57
|
+
return { start: range.start, end: range.end };
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const sorted = [...ranges].sort((rangeA, rangeB) => {
|
|
62
|
+
return rangeA.start - rangeB.start || rangeB.end - rangeA.end;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const merged = [{ start: sorted[0].start, end: sorted[0].end }];
|
|
66
|
+
for (let index = 1; index < sorted.length; index++) {
|
|
67
|
+
const current = sorted[index];
|
|
68
|
+
const previous = merged[merged.length - 1];
|
|
69
|
+
|
|
70
|
+
if (current.start <= previous.end + 1) {
|
|
71
|
+
previous.end = Math.max(previous.end, current.end);
|
|
72
|
+
} else {
|
|
73
|
+
merged.push({ start: current.start, end: current.end });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return merged;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Attempts to express a numeric range as wildcard notation (e.g. U+5??).
|
|
82
|
+
* Wildcards are only possible when the suffix of the start is all zeros
|
|
83
|
+
* and the suffix of the end is all F's, meaning the range covers an
|
|
84
|
+
* entire block aligned to a power-of-16 boundary.
|
|
85
|
+
*
|
|
86
|
+
* @param {number} start The range start code point.
|
|
87
|
+
* @param {number} end The range end code point.
|
|
88
|
+
* @return {string|null} Wildcard string like "U+5??", or null if not representable.
|
|
89
|
+
*/
|
|
90
|
+
function tryWildcardNotation (start, end) {
|
|
91
|
+
if (start > end) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const startHex = start.toString(16).toUpperCase();
|
|
96
|
+
const endHex = end.toString(16).toUpperCase();
|
|
97
|
+
const digitCount = Math.max(startHex.length, endHex.length);
|
|
98
|
+
const paddedStart = startHex.padStart(digitCount, '0');
|
|
99
|
+
const paddedEnd = endHex.padStart(digitCount, '0');
|
|
100
|
+
|
|
101
|
+
let commonPrefixLength = 0;
|
|
102
|
+
while (commonPrefixLength < digitCount && paddedStart[commonPrefixLength] === paddedEnd[commonPrefixLength]) {
|
|
103
|
+
commonPrefixLength++;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const startSuffix = paddedStart.slice(commonPrefixLength);
|
|
107
|
+
const endSuffix = paddedEnd.slice(commonPrefixLength);
|
|
108
|
+
|
|
109
|
+
// Wildcard requires suffix to span the full 0-F range (all zeros to all F's)
|
|
110
|
+
const suffixIsAllZeros = /^0+$/.test(startSuffix);
|
|
111
|
+
const suffixIsAllFs = /^F+$/i.test(endSuffix);
|
|
112
|
+
if (startSuffix.length > 0 && suffixIsAllZeros && suffixIsAllFs) {
|
|
113
|
+
const wildcardCount = digitCount - commonPrefixLength;
|
|
114
|
+
// Strip leading zeros from the fixed-width prefix
|
|
115
|
+
const prefix = paddedStart.slice(0, commonPrefixLength).replace(/^0+/, '');
|
|
116
|
+
return 'U+' + prefix + '?'.repeat(wildcardCount);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Formats a single merged range back into the shortest valid unicode-range token.
|
|
124
|
+
* Tries wildcard notation first, then falls back to explicit range or single value.
|
|
125
|
+
*
|
|
126
|
+
* @param {number} start The range start code point.
|
|
127
|
+
* @param {number} end The range end code point.
|
|
128
|
+
* @return {string} The formatted unicode-range token.
|
|
129
|
+
*/
|
|
130
|
+
function formatUnicodeRange (start, end) {
|
|
131
|
+
const wildcardForm = tryWildcardNotation(start, end);
|
|
132
|
+
if (wildcardForm) {
|
|
133
|
+
return wildcardForm;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const startHex = start.toString(16).toUpperCase();
|
|
137
|
+
if (start === end) {
|
|
138
|
+
return 'U+' + startHex;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const endHex = end.toString(16).toUpperCase();
|
|
142
|
+
return 'U+' + startHex + '-' + endHex;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Optimizes a CSS unicode-range value by parsing all tokens, merging
|
|
147
|
+
* overlapping/adjacent/duplicate ranges, and re-encoding with the
|
|
148
|
+
* shortest possible notation (wildcards where applicable).
|
|
149
|
+
*
|
|
150
|
+
* @param {string} value The raw unicode-range CSS value (comma-separated tokens).
|
|
151
|
+
* @return {string} The optimized unicode-range value.
|
|
152
|
+
*/
|
|
153
|
+
function optimizeUnicodeRange (value) {
|
|
154
|
+
const tokens = value.split(',');
|
|
155
|
+
|
|
156
|
+
const ranges = [];
|
|
157
|
+
for (const token of tokens) {
|
|
158
|
+
const parsed = parseUnicodeRangeToken(token);
|
|
159
|
+
if (parsed) {
|
|
160
|
+
ranges.push(parsed);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (ranges.length === 0) {
|
|
165
|
+
return value;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const merged = mergeOverlappingRanges(ranges);
|
|
169
|
+
|
|
170
|
+
const formatted = merged.map((range) => {
|
|
171
|
+
return formatUnicodeRange(range.start, range.end);
|
|
172
|
+
});
|
|
173
|
+
return formatted.join(',');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export { optimizeUnicodeRange };
|