@webmate-studio/builder 0.2.50 → 0.2.51
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/package.json +1 -1
- package/src/css-deduplicator.js +393 -0
- package/src/index.js +2 -1
package/package.json
CHANGED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSS Deduplication Service for Tailwind v4
|
|
3
|
+
* Combines multiple CSS files and removes duplicate rules
|
|
4
|
+
* Special handling for @layer blocks - extracts and deduplicates layer content
|
|
5
|
+
* Maintains correct order: base -> properties -> theme -> utilities -> components
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Extract @layer blocks from CSS
|
|
10
|
+
* Returns { layerName: content[] } map
|
|
11
|
+
*/
|
|
12
|
+
function extractLayers(css) {
|
|
13
|
+
const layers = {};
|
|
14
|
+
const layerRegex = /@layer\s+(\w+)\s*\{([\s\S]*?)\n\}/g;
|
|
15
|
+
let match;
|
|
16
|
+
|
|
17
|
+
while ((match = layerRegex.exec(css)) !== null) {
|
|
18
|
+
const layerName = match[1]; // base, theme, utilities, etc
|
|
19
|
+
const content = match[2].trim();
|
|
20
|
+
|
|
21
|
+
if (!layers[layerName]) {
|
|
22
|
+
layers[layerName] = [];
|
|
23
|
+
}
|
|
24
|
+
layers[layerName].push(content);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return layers;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Remove @layer blocks from CSS, returning only non-layer content
|
|
32
|
+
*/
|
|
33
|
+
function removeLayerBlocks(css) {
|
|
34
|
+
return css.replace(/@layer\s+\w+\s*\{[\s\S]*?\n\}/g, '').trim();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Normalize CSS rule for comparison
|
|
39
|
+
* Removes extra whitespace and normalizes formatting
|
|
40
|
+
*/
|
|
41
|
+
function normalizeRule(rule) {
|
|
42
|
+
return rule
|
|
43
|
+
.trim()
|
|
44
|
+
// Normalize whitespace around braces
|
|
45
|
+
.replace(/\s*{\s*/g, '{')
|
|
46
|
+
.replace(/\s*}\s*/g, '}')
|
|
47
|
+
// Normalize whitespace around colons and semicolons
|
|
48
|
+
.replace(/\s*:\s*/g, ':')
|
|
49
|
+
.replace(/\s*;\s*/g, ';')
|
|
50
|
+
// Normalize whitespace in selectors
|
|
51
|
+
.replace(/\s*,\s*/g, ',')
|
|
52
|
+
// Remove trailing semicolon (optional in CSS)
|
|
53
|
+
.replace(/;}/g, '}')
|
|
54
|
+
// Collapse multiple spaces
|
|
55
|
+
.replace(/\s+/g, ' ');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Deduplicate multiple CSS strings (Tailwind v4 aware)
|
|
60
|
+
* @param {string[]} cssArray - Array of CSS strings
|
|
61
|
+
* @returns {string} Deduplicated CSS
|
|
62
|
+
*/
|
|
63
|
+
export function deduplicateCSS(cssArray) {
|
|
64
|
+
const seenRules = new Map(); // normalized -> original
|
|
65
|
+
const tailwindLayers = {
|
|
66
|
+
base: new Set(),
|
|
67
|
+
properties: new Set(),
|
|
68
|
+
theme: new Set(),
|
|
69
|
+
utilities: new Set()
|
|
70
|
+
};
|
|
71
|
+
const regularRules = {
|
|
72
|
+
charset: [], // @charset declarations
|
|
73
|
+
imports: [], // @import rules
|
|
74
|
+
other: [] // Non-layer rules
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
let totalRules = 0;
|
|
78
|
+
let duplicatesSkipped = 0;
|
|
79
|
+
|
|
80
|
+
for (const css of cssArray) {
|
|
81
|
+
if (!css || typeof css !== 'string') continue;
|
|
82
|
+
|
|
83
|
+
// Extract Tailwind @layer blocks
|
|
84
|
+
const extractedLayers = extractLayers(css);
|
|
85
|
+
|
|
86
|
+
// Add layer content to Sets (automatic deduplication)
|
|
87
|
+
for (const [layerName, contents] of Object.entries(extractedLayers)) {
|
|
88
|
+
if (tailwindLayers[layerName]) {
|
|
89
|
+
for (const content of contents) {
|
|
90
|
+
// Split layer content into individual rules and deduplicate
|
|
91
|
+
const rules = splitCSSRules(content);
|
|
92
|
+
for (const rule of rules) {
|
|
93
|
+
const trimmed = rule.trim();
|
|
94
|
+
if (trimmed) {
|
|
95
|
+
const normalized = normalizeRule(trimmed);
|
|
96
|
+
if (!seenRules.has(normalized)) {
|
|
97
|
+
seenRules.set(normalized, trimmed);
|
|
98
|
+
tailwindLayers[layerName].add(trimmed);
|
|
99
|
+
totalRules++;
|
|
100
|
+
} else {
|
|
101
|
+
duplicatesSkipped++;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Process non-layer CSS (utilities, custom rules)
|
|
110
|
+
const nonLayerCSS = removeLayerBlocks(css);
|
|
111
|
+
if (nonLayerCSS) {
|
|
112
|
+
const rules = splitCSSRules(nonLayerCSS);
|
|
113
|
+
|
|
114
|
+
for (const rule of rules) {
|
|
115
|
+
const trimmed = rule.trim();
|
|
116
|
+
if (!trimmed) continue;
|
|
117
|
+
|
|
118
|
+
// Skip empty rules
|
|
119
|
+
if (isEmptyRule(trimmed)) {
|
|
120
|
+
duplicatesSkipped++;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Normalize for duplicate detection
|
|
125
|
+
const normalized = normalizeRule(trimmed);
|
|
126
|
+
|
|
127
|
+
// Skip exact duplicates (based on normalized form)
|
|
128
|
+
if (seenRules.has(normalized)) {
|
|
129
|
+
duplicatesSkipped++;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
seenRules.set(normalized, trimmed);
|
|
133
|
+
totalRules++;
|
|
134
|
+
|
|
135
|
+
// Categorize rule
|
|
136
|
+
if (trimmed.startsWith('@charset')) {
|
|
137
|
+
regularRules.charset.push(trimmed);
|
|
138
|
+
} else if (trimmed.startsWith('@import')) {
|
|
139
|
+
regularRules.imports.push(trimmed);
|
|
140
|
+
} else {
|
|
141
|
+
regularRules.other.push(trimmed);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
console.log(`[CSS Deduplicator] Total: ${totalRules} rules, Removed: ${duplicatesSkipped} duplicates`);
|
|
148
|
+
|
|
149
|
+
// Combine in correct order (Tailwind v4 order)
|
|
150
|
+
const parts = [];
|
|
151
|
+
|
|
152
|
+
// @charset and @import first
|
|
153
|
+
if (regularRules.charset.length > 0) {
|
|
154
|
+
parts.push(regularRules.charset[0]); // Only keep first @charset
|
|
155
|
+
}
|
|
156
|
+
if (regularRules.imports.length > 0) {
|
|
157
|
+
parts.push(...regularRules.imports);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Tailwind layers in correct order
|
|
161
|
+
if (tailwindLayers.base.size > 0) {
|
|
162
|
+
parts.push(`@layer base {\n${Array.from(tailwindLayers.base).join('\n\n')}\n}`);
|
|
163
|
+
}
|
|
164
|
+
if (tailwindLayers.properties.size > 0) {
|
|
165
|
+
parts.push(`@layer properties {\n${Array.from(tailwindLayers.properties).join('\n\n')}\n}`);
|
|
166
|
+
}
|
|
167
|
+
if (tailwindLayers.theme.size > 0) {
|
|
168
|
+
parts.push(`@layer theme {\n${Array.from(tailwindLayers.theme).join('\n\n')}\n}`);
|
|
169
|
+
}
|
|
170
|
+
if (tailwindLayers.utilities.size > 0) {
|
|
171
|
+
// Sort utilities by breakpoint before joining
|
|
172
|
+
const sortedUtilities = sortUtilityRules(Array.from(tailwindLayers.utilities));
|
|
173
|
+
parts.push(`@layer utilities {\n${sortedUtilities.join('\n\n')}\n}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Other rules (component-specific, non-layer)
|
|
177
|
+
if (regularRules.other.length > 0) {
|
|
178
|
+
// Post-process: Remove empty rules that have non-empty counterparts
|
|
179
|
+
let filteredRules = filterEmptyDuplicates(regularRules.other);
|
|
180
|
+
|
|
181
|
+
// Sort rules: non-responsive first, then responsive (sm, md, lg, xl, 2xl)
|
|
182
|
+
filteredRules = sortUtilityRules(filteredRules);
|
|
183
|
+
|
|
184
|
+
parts.push(...filteredRules);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return parts.join('\n\n');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Sort utility rules by responsive breakpoint
|
|
192
|
+
* Non-responsive rules first, then sm, md, lg, xl, 2xl
|
|
193
|
+
* This ensures correct CSS cascade (mobile-first approach)
|
|
194
|
+
*/
|
|
195
|
+
function sortUtilityRules(rules) {
|
|
196
|
+
const breakpointOrder = {
|
|
197
|
+
none: 0, // No breakpoint
|
|
198
|
+
sm: 1, // >= 640px
|
|
199
|
+
md: 2, // >= 768px
|
|
200
|
+
lg: 3, // >= 1024px
|
|
201
|
+
xl: 4, // >= 1280px
|
|
202
|
+
'2xl': 5 // >= 1536px
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
return rules.sort((a, b) => {
|
|
206
|
+
const breakpointA = getBreakpoint(a);
|
|
207
|
+
const breakpointB = getBreakpoint(b);
|
|
208
|
+
|
|
209
|
+
return (breakpointOrder[breakpointA] || 0) - (breakpointOrder[breakpointB] || 0);
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Extract breakpoint from a CSS rule selector
|
|
215
|
+
* Example: ".sm\:grid-cols-3 { ... }" -> "sm"
|
|
216
|
+
*/
|
|
217
|
+
function getBreakpoint(rule) {
|
|
218
|
+
const selector = extractSelector(rule);
|
|
219
|
+
if (!selector) return 'none';
|
|
220
|
+
|
|
221
|
+
// Check for responsive prefix (e.g., .sm\:, .md\:, .lg\:)
|
|
222
|
+
const match = selector.match(/\.(sm|md|lg|xl|2xl)\\:/);
|
|
223
|
+
return match ? match[1] : 'none';
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Filter out empty rules that have non-empty versions with the same selector
|
|
228
|
+
* Example: Remove `.sm\:grid-cols-3 {}` if `.sm\:grid-cols-3 { @media ... }` exists
|
|
229
|
+
*/
|
|
230
|
+
function filterEmptyDuplicates(rules) {
|
|
231
|
+
const selectorMap = new Map(); // selector -> rule
|
|
232
|
+
|
|
233
|
+
for (const rule of rules) {
|
|
234
|
+
const selector = extractSelector(rule);
|
|
235
|
+
if (!selector) continue;
|
|
236
|
+
|
|
237
|
+
const existing = selectorMap.get(selector);
|
|
238
|
+
|
|
239
|
+
if (!existing) {
|
|
240
|
+
// First time seeing this selector
|
|
241
|
+
selectorMap.set(selector, rule);
|
|
242
|
+
} else {
|
|
243
|
+
// Selector already exists - keep the non-empty one
|
|
244
|
+
const isCurrentEmpty = isEmptyRule(rule);
|
|
245
|
+
const isExistingEmpty = isEmptyRule(existing);
|
|
246
|
+
|
|
247
|
+
if (isCurrentEmpty && !isExistingEmpty) {
|
|
248
|
+
// Keep existing (non-empty), skip current (empty)
|
|
249
|
+
continue;
|
|
250
|
+
} else if (!isCurrentEmpty && isExistingEmpty) {
|
|
251
|
+
// Replace with current (non-empty)
|
|
252
|
+
selectorMap.set(selector, rule);
|
|
253
|
+
} else if (!isCurrentEmpty && !isExistingEmpty) {
|
|
254
|
+
// Both non-empty - keep the more complex one (longer)
|
|
255
|
+
if (rule.length > existing.length) {
|
|
256
|
+
selectorMap.set(selector, rule);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// Both empty - keep first (doesn't matter)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return Array.from(selectorMap.values());
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Extract the selector from a CSS rule
|
|
268
|
+
* Example: ".flex { display: flex; }" -> ".flex"
|
|
269
|
+
*/
|
|
270
|
+
function extractSelector(rule) {
|
|
271
|
+
const match = rule.match(/^([^{]+)\{/);
|
|
272
|
+
return match ? match[1].trim() : null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Split CSS into rules
|
|
277
|
+
* Handles nested structures (media queries, nested selectors)
|
|
278
|
+
*/
|
|
279
|
+
function splitCSSRules(css) {
|
|
280
|
+
const rules = [];
|
|
281
|
+
let current = '';
|
|
282
|
+
let depth = 0;
|
|
283
|
+
let inString = false;
|
|
284
|
+
let stringChar = null;
|
|
285
|
+
let startDepth = 0; // Track starting depth for this rule
|
|
286
|
+
|
|
287
|
+
for (let i = 0; i < css.length; i++) {
|
|
288
|
+
const char = css[i];
|
|
289
|
+
const prevChar = i > 0 ? css[i - 1] : '';
|
|
290
|
+
|
|
291
|
+
// Track string literals
|
|
292
|
+
if ((char === '"' || char === "'") && prevChar !== '\\') {
|
|
293
|
+
if (!inString) {
|
|
294
|
+
inString = true;
|
|
295
|
+
stringChar = char;
|
|
296
|
+
} else if (char === stringChar) {
|
|
297
|
+
inString = false;
|
|
298
|
+
stringChar = null;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Track brace depth (outside strings)
|
|
303
|
+
if (!inString) {
|
|
304
|
+
if (char === '{') {
|
|
305
|
+
// Starting a new rule - record initial depth
|
|
306
|
+
if (depth === 0) {
|
|
307
|
+
startDepth = 0;
|
|
308
|
+
}
|
|
309
|
+
depth++;
|
|
310
|
+
} else if (char === '}') {
|
|
311
|
+
depth--;
|
|
312
|
+
|
|
313
|
+
// Rule complete when we close back to starting depth (0)
|
|
314
|
+
if (depth === 0) {
|
|
315
|
+
current += char;
|
|
316
|
+
|
|
317
|
+
// Look ahead to see if there's more content
|
|
318
|
+
let j = i + 1;
|
|
319
|
+
while (j < css.length && /\s/.test(css[j])) {
|
|
320
|
+
j++;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// If we hit a selector or @ rule, end current rule
|
|
324
|
+
if (j >= css.length || css[j] === '.' || css[j] === '#' || css[j] === '@' || /[a-zA-Z]/.test(css[j])) {
|
|
325
|
+
const trimmed = current.trim();
|
|
326
|
+
// Only add non-empty rules
|
|
327
|
+
if (trimmed && !isEmptyRule(trimmed)) {
|
|
328
|
+
rules.push(trimmed);
|
|
329
|
+
}
|
|
330
|
+
current = '';
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
current += char;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Add remaining content
|
|
341
|
+
const trimmed = current.trim();
|
|
342
|
+
if (trimmed && !isEmptyRule(trimmed)) {
|
|
343
|
+
rules.push(trimmed);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return rules;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Check if a CSS rule is empty (no declarations)
|
|
351
|
+
*/
|
|
352
|
+
function isEmptyRule(rule) {
|
|
353
|
+
// Extract content between outermost braces
|
|
354
|
+
const match = rule.match(/^[^{]+\{([\s\S]*)\}$/);
|
|
355
|
+
if (!match) return false;
|
|
356
|
+
|
|
357
|
+
const content = match[1].trim();
|
|
358
|
+
|
|
359
|
+
// Empty if no content at all
|
|
360
|
+
if (!content) return true;
|
|
361
|
+
|
|
362
|
+
// NOT empty if it contains @media, @supports, or other nested blocks
|
|
363
|
+
// (these are valid, even if the outer rule has no direct declarations)
|
|
364
|
+
if (content.includes('@media') || content.includes('@supports') || content.includes('@container')) {
|
|
365
|
+
return false;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Check if there are any CSS declarations (property: value)
|
|
369
|
+
// Look for colon followed by semicolon or closing brace
|
|
370
|
+
const hasDeclarations = /:\s*[^:;{}]+[;}]/.test(content);
|
|
371
|
+
|
|
372
|
+
return !hasDeclarations;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Generate cache key from component versions
|
|
377
|
+
* @param {Array} components - Array of {name, versionId}
|
|
378
|
+
* @returns {string} Hash
|
|
379
|
+
*/
|
|
380
|
+
export function generateCSSCacheKey(components) {
|
|
381
|
+
const sorted = components.sort((a, b) => a.name.localeCompare(b.name));
|
|
382
|
+
const str = sorted.map((c) => `${c.name}:${c.versionId}`).join('|');
|
|
383
|
+
|
|
384
|
+
// Simple hash
|
|
385
|
+
let hash = 0;
|
|
386
|
+
for (let i = 0; i < str.length; i++) {
|
|
387
|
+
const char = str.charCodeAt(i);
|
|
388
|
+
hash = (hash << 5) - hash + char;
|
|
389
|
+
hash = hash & hash; // Convert to 32bit integer
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return Math.abs(hash).toString(36);
|
|
393
|
+
}
|
package/src/index.js
CHANGED
|
@@ -2,5 +2,6 @@ import { build } from './build.js';
|
|
|
2
2
|
import { generateComponentCSS, generateTailwindCSS, extractTailwindClasses } from './tailwind-generator.js';
|
|
3
3
|
import { cleanComponentHTML } from './html-cleaner.js';
|
|
4
4
|
import { bundleIsland, bundleComponentIslands } from './bundler.js';
|
|
5
|
+
import { deduplicateCSS } from './css-deduplicator.js';
|
|
5
6
|
|
|
6
|
-
export { build, generateComponentCSS, generateTailwindCSS, extractTailwindClasses, cleanComponentHTML, bundleIsland, bundleComponentIslands };
|
|
7
|
+
export { build, generateComponentCSS, generateTailwindCSS, extractTailwindClasses, cleanComponentHTML, bundleIsland, bundleComponentIslands, deduplicateCSS };
|