@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webmate-studio/builder",
3
- "version": "0.2.50",
3
+ "version": "0.2.51",
4
4
  "type": "module",
5
5
  "description": "Webmate Studio Component Builder",
6
6
  "keywords": [
@@ -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 };