@thejaredwilcurt/csslop 0.0.16 → 0.0.18

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.
@@ -5,6 +5,10 @@
5
5
  import { processDeclarations } from '../declarations/process.js';
6
6
  import { minifyValue } from '../value/minify.js';
7
7
 
8
+ import {
9
+ collapseCustomPropertyWhitespace,
10
+ processCustomPropertyComments
11
+ } from './custom-properties.js';
8
12
  import {
9
13
  canUnwrapSupports,
10
14
  normalizeMedia,
@@ -12,6 +16,12 @@ import {
12
16
  unescapeIdent,
13
17
  unescapeSelector
14
18
  } from './normalize.js';
19
+ import {
20
+ flattenNestingParentIsSelector,
21
+ mergeAdjacentWherePseudoClasses,
22
+ processIsSelector,
23
+ splitParametersByComma
24
+ } from './selectors.js';
15
25
 
16
26
  /**
17
27
  * Renders an array of CSS declaration objects as a minified semicolon-separated string, filtering out whitespace entries.
@@ -22,7 +32,7 @@ import {
22
32
  function stringifyDeclarations (declarations) {
23
33
  return declarations
24
34
  .filter((declaration) => {
25
- return declaration.type !== 'whitespace';
35
+ return declaration.type !== 'whitespace' && declaration.type !== 'comment' && declaration.property;
26
36
  })
27
37
  .map((declaration) => {
28
38
  return [declaration.property, ':', minifyValue(declaration)].join('');
@@ -42,35 +52,6 @@ function stringifyChildRules (rules, context) {
42
52
  return stringifyRule(childRule, context);
43
53
  }).join('');
44
54
  }
45
-
46
- /**
47
- * Splits a parameter string by commas while respecting nested parentheses,
48
- * so commas inside function calls within default values are not treated as separators.
49
- *
50
- * @param {string} parameterString The comma-separated parameter string to split.
51
- * @return {Array} An array of individual parameter strings.
52
- */
53
- function splitParametersByComma (parameterString) {
54
- const parameters = [];
55
- let currentParameter = '';
56
- let parenthesisDepth = 0;
57
- for (const character of parameterString) {
58
- if (character === '(') {
59
- parenthesisDepth++;
60
- } else if (character === ')') {
61
- parenthesisDepth--;
62
- }
63
- if (character === ',' && parenthesisDepth === 0) {
64
- parameters.push(currentParameter);
65
- currentParameter = '';
66
- } else {
67
- currentParameter += character;
68
- }
69
- }
70
- parameters.push(currentParameter);
71
- return parameters;
72
- }
73
-
74
55
  /**
75
56
  * Minifies a `@function` prelude (signature) by collapsing whitespace around
76
57
  * parameter separators (commas) and default value delimiters (colons).
@@ -137,312 +118,6 @@ function stringifyAtRule (rule, context) {
137
118
  const separator = minifiedPrelude ? ' ' : '';
138
119
  return '@' + rule.name + separator + minifiedPrelude + '{' + body + '}';
139
120
  }
140
-
141
- /**
142
- * Finds the index of the closing parenthesis that matches the opening
143
- * parenthesis at the given position in the string.
144
- *
145
- * @param {string} text The string to search within.
146
- * @param {number} openIndex The index of the opening parenthesis.
147
- * @return {number} The index of the matching closing parenthesis, or -1 if not found.
148
- */
149
- function findMatchingCloseParenthesis (text, openIndex) {
150
- let depth = 0;
151
- for (let index = openIndex; index < text.length; index++) {
152
- if (text[index] === '(') {
153
- depth++;
154
- } else if (text[index] === ')') {
155
- depth--;
156
- if (depth === 0) {
157
- return index;
158
- }
159
- }
160
- }
161
- return -1;
162
- }
163
-
164
- /**
165
- * Extracts the type selector or universal selector from the beginning of a
166
- * compound selector string, if one is present. A type selector is a bare
167
- * element name (e.g. `div`, `a`); the universal selector is `*`.
168
- *
169
- * @param {string} compoundSelector A single compound CSS selector string.
170
- * @return {string|null} The type or universal selector, or null if none is present.
171
- */
172
- function extractTypeSelector (compoundSelector) {
173
- // Match universal selector (*) or type selector (letter followed by alphanumeric chars or hyphens)
174
- const match = compoundSelector.match(/^(\*|[a-zA-Z][a-zA-Z0-9-]*)/);
175
- if (match) {
176
- return match[0];
177
- }
178
- return null;
179
- }
180
-
181
- /**
182
- * Merges two simple/compound selectors into a single compound selector,
183
- * ensuring any type or universal selector appears first. Returns null when
184
- * merging is invalid because both sides contain a type or universal selector.
185
- *
186
- * @param {string} left The first selector to merge.
187
- * @param {string} right The second selector to merge.
188
- * @return {string|null} The merged compound selector, or null if the merge is invalid.
189
- */
190
- function mergeCompoundSelectors (left, right) {
191
- const leftTypeSelector = extractTypeSelector(left);
192
- const rightTypeSelector = extractTypeSelector(right);
193
- if (leftTypeSelector && rightTypeSelector) {
194
- return null;
195
- }
196
- // When the right side has a type selector, it must come first in the compound
197
- if (rightTypeSelector) {
198
- return right + left;
199
- }
200
- return left + right;
201
- }
202
-
203
- /**
204
- * Builds the cartesian product of two selector lists by merging every
205
- * combination of left and right selectors into compound selectors.
206
- * Returns null if any combination produces an invalid merge.
207
- *
208
- * @param {Array} leftParts Selectors from the first `:where()`.
209
- * @param {Array} rightParts Selectors from the second `:where()`.
210
- * @return {Array|null} The array of merged compound selectors, or null if any merge is invalid.
211
- */
212
- function buildWhereCartesianProduct (leftParts, rightParts) {
213
- const products = [];
214
- for (const leftSelector of leftParts) {
215
- for (const rightSelector of rightParts) {
216
- const merged = mergeCompoundSelectors(leftSelector.trim(), rightSelector.trim());
217
- if (merged === null) {
218
- return null;
219
- }
220
- products.push(merged);
221
- }
222
- }
223
- return products;
224
- }
225
-
226
- /**
227
- * Scans a selector string for adjacent `:where(A):where(B)` patterns and
228
- * merges them into a single `:where(AB)` (or `:where()` with the cartesian
229
- * product of their selector lists) when the merged form is strictly shorter.
230
- * Type selectors are correctly repositioned to the front of each merged
231
- * compound, and merges that would produce invalid compound selectors (two
232
- * type selectors) are skipped.
233
- *
234
- * @param {string} selector A minified CSS selector string.
235
- * @return {string} The selector with beneficial adjacent `:where()` merges applied.
236
- */
237
- function mergeAdjacentWherePseudoClasses (selector) {
238
- let result = selector;
239
- let position = 0;
240
- while (position < result.length) {
241
- const whereIndex = result.indexOf(':where(', position);
242
- if (whereIndex === -1) {
243
- break;
244
- }
245
- // Index of the '(' in the first ':where('
246
- const firstOpenParenthesis = whereIndex + 6;
247
- const firstCloseParenthesis = findMatchingCloseParenthesis(result, firstOpenParenthesis);
248
- if (firstCloseParenthesis === -1) {
249
- break;
250
- }
251
- const adjacentStart = firstCloseParenthesis + 1;
252
- const adjacentWhereTag = ':where(';
253
- if (result.slice(adjacentStart, adjacentStart + adjacentWhereTag.length) !== adjacentWhereTag) {
254
- position = firstCloseParenthesis + 1;
255
- continue;
256
- }
257
- // Index of the '(' in the second ':where('
258
- const secondOpenParenthesis = adjacentStart + 6;
259
- const secondCloseParenthesis = findMatchingCloseParenthesis(result, secondOpenParenthesis);
260
- if (secondCloseParenthesis === -1) {
261
- break;
262
- }
263
- const firstInnerContent = result.slice(firstOpenParenthesis + 1, firstCloseParenthesis);
264
- const secondInnerContent = result.slice(secondOpenParenthesis + 1, secondCloseParenthesis);
265
- const leftParts = splitParametersByComma(firstInnerContent);
266
- const rightParts = splitParametersByComma(secondInnerContent);
267
- const mergedParts = buildWhereCartesianProduct(leftParts, rightParts);
268
- if (mergedParts === null) {
269
- position = firstCloseParenthesis + 1;
270
- continue;
271
- }
272
- const originalFragment = result.slice(whereIndex, secondCloseParenthesis + 1);
273
- const mergedFragment = ':where(' + mergedParts.join(',') + ')';
274
- if (mergedFragment.length < originalFragment.length) {
275
- result = result.slice(0, whereIndex) + mergedFragment + result.slice(secondCloseParenthesis + 1);
276
- // Don't advance position; the merged result may be adjacent to another :where()
277
- } else {
278
- position = firstCloseParenthesis + 1;
279
- }
280
- }
281
- return result;
282
- }
283
-
284
- /**
285
- * Processes a bare `:is()` selector by merging `:link`+`:visited` into `:any-link`,
286
- * de-duplicating, sorting alphabetically, and conditionally expanding into individual
287
- * selectors when all parts are simple type/universal selectors with no modifications.
288
- *
289
- * @param {string} selector A minified CSS selector string.
290
- * @return {Array} An array of one or more processed selector strings.
291
- */
292
- function processIsSelector (selector) {
293
- // Replace :is(:link,:visited) and :is(:visited,:link) with :any-link
294
- selector = selector.replace(/:is\(:link,:visited\)/g, ':any-link');
295
- selector = selector.replace(/:is\(:visited,:link\)/g, ':any-link');
296
- // Only process bare :is() selectors (where :is() is the entire selector)
297
- if (!selector.startsWith(':is(')) {
298
- return [selector];
299
- }
300
- let depth = 0;
301
- let closingIndex = -1;
302
- for (let index = 4; index < selector.length; index++) {
303
- if (selector[index] === '(') {
304
- depth++;
305
- } else if (selector[index] === ')') {
306
- if (depth === 0) {
307
- closingIndex = index;
308
- break;
309
- }
310
- depth--;
311
- }
312
- }
313
- if (closingIndex !== selector.length - 1) {
314
- return [selector];
315
- }
316
- const content = selector.slice(4, -1);
317
- let parts = [];
318
- let currentPart = '';
319
- let parenDepth = 0;
320
- for (const character of content) {
321
- if (character === '(') {
322
- parenDepth++;
323
- } else if (character === ')') {
324
- parenDepth--;
325
- }
326
- if (character === ',' && parenDepth === 0) {
327
- parts.push(currentPart);
328
- currentPart = '';
329
- } else {
330
- currentPart += character;
331
- }
332
- }
333
- parts.push(currentPart);
334
- const originalCount = parts.length;
335
- // Replace :link + :visited with :any-link
336
- const hasLink = parts.includes(':link');
337
- const hasVisited = parts.includes(':visited');
338
- if (hasLink && hasVisited) {
339
- parts = parts.filter((part) => {
340
- return part !== ':link' && part !== ':visited';
341
- });
342
- if (!parts.includes(':any-link')) {
343
- parts.push(':any-link');
344
- }
345
- }
346
- // De-duplicate
347
- parts = [...new Set(parts)];
348
- // Sort alphabetically
349
- parts.sort();
350
- // Unwrap :is() with a single selector
351
- if (parts.length === 1) {
352
- return parts;
353
- }
354
- // Expand if all parts are simple type/universal selectors and no dedup/replacement occurred
355
- const allSimple = parts.every((part) => {
356
- return /^[a-z*][a-z0-9-]*$/i.test(part);
357
- });
358
- if (allSimple && parts.length === originalCount) {
359
- return parts;
360
- }
361
- return [':is(' + parts.join(',') + ')'];
362
- }
363
-
364
- /**
365
- * Removes spaces after commas only inside parenthesized groups (function
366
- * calls like `var()`, `calc()`), leaving top-level comma spacing intact.
367
- *
368
- * @param {string} value The whitespace-collapsed custom property value.
369
- * @return {string} The value with post-comma spaces removed inside function calls only.
370
- */
371
- function removeSpacesAfterCommasInsideFunctions (value) {
372
- let result = '';
373
- let parenthesisDepth = 0;
374
- for (let index = 0; index < value.length; index++) {
375
- const character = value[index];
376
- if (character === '(') {
377
- parenthesisDepth++;
378
- }
379
- if (character === ')') {
380
- parenthesisDepth--;
381
- }
382
- if (character === ',' && parenthesisDepth > 0) {
383
- result += ',';
384
- // Skip whitespace after the comma inside function calls
385
- while (index + 1 < value.length && value[index + 1] === ' ') {
386
- index++;
387
- }
388
- } else {
389
- result += character;
390
- }
391
- }
392
- return result;
393
- }
394
-
395
- /**
396
- * Strips leading zeros from decimal numbers in a custom property value
397
- * (e.g. `0.5` becomes `.5`, `-0.02em` becomes `-.02em`).
398
- *
399
- * @param {string} value The custom property value string.
400
- * @return {string} The value with leading zeros removed from decimals.
401
- */
402
- function stripLeadingZerosFromDecimals (value) {
403
- // Match a boundary (start, whitespace, comma, open-paren), optional sign, then leading zeros before a decimal
404
- return value.replace(/(^|\s|,|\()(-?)0+(\.\d+)/g, '$1$2$3');
405
- }
406
-
407
- /**
408
- * Processes CSS comments within a custom property value. If the value
409
- * consists entirely of a comment, the comment is removed (producing an
410
- * empty value). If comments appear between other tokens, their content
411
- * is stripped but empty comment delimiters are kept as zero-width
412
- * token separators to preserve the token sequence.
413
- *
414
- * @param {string} value The raw custom property value string.
415
- * @return {string} The value with comments processed.
416
- */
417
- function processCustomPropertyComments (value) {
418
- // Match values that are entirely a comment (with optional surrounding whitespace)
419
- const commentOnlyPattern = /^\s*\/\*.*?\*\/\s*$/s;
420
- if (commentOnlyPattern.test(value)) {
421
- return '';
422
- }
423
- // Strip comment content but keep empty markers as token separators
424
- return value.replace(/\/\*.*?\*\//g, '/**/');
425
- }
426
-
427
- /**
428
- * Collapses whitespace in a custom property value while preserving
429
- * token boundaries. Each whitespace sequence is reduced to a single
430
- * space, spaces after commas inside function calls are removed, and
431
- * leading zeros on decimal numbers are stripped.
432
- *
433
- * @param {string} value The raw custom property value string.
434
- * @return {string} The minified custom property value.
435
- */
436
- function collapseCustomPropertyWhitespace (value) {
437
- // Collapse all whitespace sequences (newlines, tabs, multiple spaces) to a single space
438
- let collapsed = value.replace(/\s+/g, ' ');
439
- // Remove spaces after commas only inside function calls (e.g. var(--bar, 1.5) → var(--bar,1.5))
440
- collapsed = removeSpacesAfterCommasInsideFunctions(collapsed);
441
- // Strip leading zeros from decimals (e.g. 0.5 → .5, -0.02em → -.02em)
442
- collapsed = stripLeadingZerosFromDecimals(collapsed);
443
- return collapsed;
444
- }
445
-
446
121
  /**
447
122
  * 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.
448
123
  *
@@ -548,6 +223,15 @@ function stringifyRule (rule, context, nested = false) {
548
223
  minified = mergeAdjacentWherePseudoClasses(minified);
549
224
  return minified;
550
225
  });
226
+ // When this rule is a nesting parent, its whole selector list is treated
227
+ // as :is() for the children's specificity, so a top-level :is() can be
228
+ // safely lifted into the list without altering specificity.
229
+ const isNestingParent = (rule.declarations || []).some((declaration) => {
230
+ return declaration.type === 'rule';
231
+ });
232
+ if (isNestingParent) {
233
+ uniqueSelectors = uniqueSelectors.flatMap(flattenNestingParentIsSelector);
234
+ }
551
235
  uniqueSelectors = uniqueSelectors.flatMap(processIsSelector);
552
236
  uniqueSelectors = [...new Set(uniqueSelectors)];
553
237
  const headingSet = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
@@ -639,8 +323,11 @@ function stringifyRule (rule, context, nested = false) {
639
323
 
640
324
  if (rule.type === 'media') {
641
325
  const normalizedMedia = normalizeMedia(rule.media);
326
+ // A custom-media reference is a parenthesized dashed-ident like (--modern);
327
+ // no space is needed after @media when the query begins with such a token.
328
+ const isCustomMediaReference = normalizedMedia.startsWith('(--');
642
329
  let separator;
643
- if (nested && normalizedMedia.startsWith('(')) {
330
+ if ((nested && normalizedMedia.startsWith('(')) || isCustomMediaReference) {
644
331
  separator = '';
645
332
  } else {
646
333
  separator = ' ';
@@ -796,6 +483,31 @@ function stringifyRule (rule, context, nested = false) {
796
483
  }
797
484
 
798
485
  if (rule.type === 'property') {
486
+ const propertyDeclarations = (rule.declarations || []).filter((declaration) => {
487
+ return declaration.type === 'declaration' && declaration.property;
488
+ });
489
+ const hasSyntaxDescriptor = propertyDeclarations.some((declaration) => {
490
+ return declaration.property === 'syntax';
491
+ });
492
+ const hasInheritsDescriptor = propertyDeclarations.some((declaration) => {
493
+ return declaration.property === 'inherits';
494
+ });
495
+ if (!hasSyntaxDescriptor || !hasInheritsDescriptor) {
496
+ return '';
497
+ }
498
+
499
+ const syntaxDeclaration = propertyDeclarations.find((declaration) => {
500
+ return declaration.property === 'syntax';
501
+ });
502
+ const syntaxValue = (syntaxDeclaration.value || '').replace(/["']/g, '').trim();
503
+ const isUniversalSyntax = syntaxValue === '*';
504
+ const hasInitialValue = propertyDeclarations.some((declaration) => {
505
+ return declaration.property === 'initial-value';
506
+ });
507
+ if (!isUniversalSyntax && !hasInitialValue) {
508
+ return '';
509
+ }
510
+
799
511
  let renderedDeclarations = stringifyDeclarations(rule.declarations || []);
800
512
  if (!renderedDeclarations) {
801
513
  return '';
@@ -905,6 +617,18 @@ function stringifyRule (rule, context, nested = false) {
905
617
  return '';
906
618
  }
907
619
 
620
+ if (rule.type === 'custom-media') {
621
+ // Collapse whitespace, strip spaces around commas, and tighten parentheses
622
+ const condition = (rule.media || '')
623
+ .replace(/\s+/g, ' ')
624
+ .replace(/\s*,\s*/g, ',')
625
+ .replace(/\(\s+/g, '(')
626
+ .replace(/\s+\)/g, ')')
627
+ .trim();
628
+ const conditionSeparator = condition ? ' ' : '';
629
+ return '@custom-media ' + rule.name + conditionSeparator + condition + ';';
630
+ }
631
+
908
632
  if (rule.type === 'at-rule') {
909
633
  return stringifyAtRule(rule, context);
910
634
  }