@ozsarman/clarityjs 0.6.0

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,641 @@
1
+ /**
2
+ * Clarity.js — Scoped CSS Processor
3
+ *
4
+ * Extracts <style scoped> blocks from .clarity source files and transforms
5
+ * them into component-scoped CSS using a unique data attribute.
6
+ *
7
+ * How it works:
8
+ * 1. Extract the <style scoped> block from source
9
+ * 2. Generate a stable hash (e.g. "data-c-3f8a1b") from the file path
10
+ * 3. Rewrite every CSS selector to include the scoped attribute:
11
+ * .card { ... } → .card[data-c-3f8a1b] { ... }
12
+ * 4. Inject the data attribute into the root element of the compiled component
13
+ *
14
+ * The Vite plugin (vite-plugin.js) calls this during transform.
15
+ * The CodeGenerator receives the scopeId and injects data-c-{id} automatically.
16
+ *
17
+ * Non-scoped <style> blocks are returned as-is (global CSS).
18
+ *
19
+ * Usage (from Vite plugin / build tool):
20
+ *
21
+ * import { extractStyles, scopeCSS, hashId } from '@ozsarman/clarityjs/scoped-css';
22
+ *
23
+ * const { scoped, global, scopeId } = extractStyles(source, filename);
24
+ * const transformedCSS = scopeCSS(scoped, scopeId);
25
+ * // Inject scopeId into codegen options so root element gets data-c-{scopeId}
26
+ *
27
+ * Author: Claude (Anthropic) + Özdemir Sarman
28
+ */
29
+
30
+ // ─── hashId ──────────────────────────────────────────────────────────────────
31
+
32
+ /**
33
+ * Generate a short, stable 6-char hex hash from a string (e.g. file path).
34
+ * Uses a simple djb2-style hash — collision probability is negligible for
35
+ * the number of components in a typical application.
36
+ *
37
+ * @param {string} str
38
+ * @returns {string} 6-character hex string e.g. "3f8a1b"
39
+ */
40
+ export function hashId(str) {
41
+ let h = 5381;
42
+ for (let i = 0; i < str.length; i++) {
43
+ h = ((h << 5) + h) ^ str.charCodeAt(i);
44
+ h = h >>> 0; // keep unsigned 32-bit
45
+ }
46
+ return (h >>> 0).toString(16).padStart(8, '0').slice(0, 6);
47
+ }
48
+
49
+ // ─── extractStyles ────────────────────────────────────────────────────────────
50
+
51
+ /**
52
+ * Extract <style> and <style scoped> blocks from Clarity source.
53
+ *
54
+ * @param {string} source - Full .clarity file source
55
+ * @param {string} filename - File path (used to generate the scope id)
56
+ * @returns {{
57
+ * scoped: string, // CSS from <style scoped> (or '')
58
+ * global: string, // CSS from <style> (or '')
59
+ * scopeId: string, // e.g. "3f8a1b"
60
+ * source: string, // source with style blocks stripped
61
+ * }}
62
+ */
63
+ export function extractStyles(source, filename = '<anonymous>') {
64
+ const scopeId = hashId(filename);
65
+
66
+ let scoped = '';
67
+ let global = '';
68
+ let module_ = ''; // <style module> content
69
+
70
+ // Match <style scoped> ... </style>
71
+ const scopedRE = /<style\s+scoped\s*>([\s\S]*?)<\/style>/gi;
72
+ // Match <style module> ... </style>
73
+ const moduleRE = /<style\s+module\s*>([\s\S]*?)<\/style>/gi;
74
+ // Match <style> ... </style> (without scoped/module)
75
+ const globalRE = /<style(?!\s+(?:scoped|module))\s*>([\s\S]*?)<\/style>/gi;
76
+
77
+ let stripped = source;
78
+ let m;
79
+
80
+ while ((m = scopedRE.exec(source)) !== null) scoped += m[1];
81
+ while ((m = moduleRE.exec(source)) !== null) module_ += m[1];
82
+ while ((m = globalRE.exec(source)) !== null) global += m[1];
83
+
84
+ // Strip all kinds of <style> from source before compilation
85
+ stripped = stripped
86
+ .replace(/<style\s+scoped\s*>[\s\S]*?<\/style>/gi, '')
87
+ .replace(/<style\s+module\s*>[\s\S]*?<\/style>/gi, '')
88
+ .replace(/<style\s*>[\s\S]*?<\/style>/gi, '');
89
+
90
+ return { scoped, global, module: module_, scopeId, source: stripped.trim() };
91
+ }
92
+
93
+ // ─── scopeCSS ────────────────────────────────────────────────────────────────
94
+
95
+ /**
96
+ * Transform CSS by adding a scoped data-attribute to every selector.
97
+ *
98
+ * Examples:
99
+ * .card { ... } → .card[data-c-3f8a1b] { ... }
100
+ * h1, p { ... } → h1[data-c-3f8a1b], p[data-c-3f8a1b] { ... }
101
+ * .parent .child { ... } → .parent .child[data-c-3f8a1b] { ... }
102
+ * :root { ... } → :root { ... } (left untouched)
103
+ * @keyframes spin { ... } → @keyframes spin { ... } (keyframes untouched)
104
+ * @media (…) { .x { } } → @media (…) { .x[data-c-…] { } }
105
+ *
106
+ * @param {string} css - Raw CSS string
107
+ * @param {string} scopeId - e.g. "3f8a1b"
108
+ * @returns {string} - Scoped CSS
109
+ */
110
+ export function scopeCSS(css, scopeId) {
111
+ if (!css.trim()) return '';
112
+ const attr = `[data-c-${scopeId}]`;
113
+ return _transformCSS(css, attr);
114
+ }
115
+
116
+ // ─── Internal CSS transformer ─────────────────────────────────────────────────
117
+
118
+ function _transformCSS(css, attr) {
119
+ const result = [];
120
+ let i = 0;
121
+
122
+ while (i < css.length) {
123
+ // Skip comments
124
+ if (css[i] === '/' && css[i + 1] === '*') {
125
+ const end = css.indexOf('*/', i + 2);
126
+ if (end === -1) {
127
+ result.push(css.slice(i));
128
+ break;
129
+ }
130
+ result.push(css.slice(i, end + 2));
131
+ i = end + 2;
132
+ continue;
133
+ }
134
+
135
+ // At-rules with blocks: @media, @supports, @layer, @container
136
+ if (css[i] === '@') {
137
+ const { rule, rest, isBlock } = _readAtRule(css, i);
138
+ if (isBlock) {
139
+ // Recurse into the block content to scope nested selectors
140
+ const blockMatch = rule.match(/^(@[\w-]+[^{]*)\{([\s\S]*)\}$/s);
141
+ if (blockMatch) {
142
+ const inner = _transformCSS(blockMatch[2], attr);
143
+ result.push(`${blockMatch[1]}{${inner}}`);
144
+ } else {
145
+ result.push(rule);
146
+ }
147
+ } else {
148
+ // @import, @charset, @keyframes (no inner selectors to scope)
149
+ result.push(rule);
150
+ }
151
+ i += rule.length;
152
+ continue;
153
+ }
154
+
155
+ // Skip whitespace
156
+ if (/\s/.test(css[i])) {
157
+ result.push(css[i]);
158
+ i++;
159
+ continue;
160
+ }
161
+
162
+ // Read selector + block
163
+ const { selector, block, total } = _readSelectorBlock(css, i);
164
+ if (total === 0) {
165
+ // Malformed — pass through
166
+ result.push(css[i]);
167
+ i++;
168
+ continue;
169
+ }
170
+
171
+ const scopedSelector = _scopeSelector(selector, attr);
172
+ result.push(`${scopedSelector}${block}`);
173
+ i += total;
174
+ }
175
+
176
+ return result.join('');
177
+ }
178
+
179
+ function _readAtRule(css, start) {
180
+ // Find the end of the at-rule (either ; or a {…} block)
181
+ let i = start + 1;
182
+ let depth = 0;
183
+ let inStr = false;
184
+ let strChar = '';
185
+
186
+ while (i < css.length) {
187
+ const c = css[i];
188
+
189
+ if (inStr) {
190
+ if (c === strChar && css[i - 1] !== '\\') inStr = false;
191
+ i++;
192
+ continue;
193
+ }
194
+
195
+ if (c === '"' || c === "'") { inStr = true; strChar = c; i++; continue; }
196
+
197
+ if (c === '{') {
198
+ depth++;
199
+ if (depth === 1 && _isAtRuleWithBlock(css, start, i)) {
200
+ // Read until matching }
201
+ i++;
202
+ while (i < css.length) {
203
+ if (css[i] === '{') depth++;
204
+ else if (css[i] === '}') {
205
+ depth--;
206
+ if (depth === 0) { i++; break; }
207
+ }
208
+ i++;
209
+ }
210
+ return { rule: css.slice(start, i), rest: css.slice(i), isBlock: true };
211
+ }
212
+ }
213
+
214
+ if (c === ';' && depth === 0) {
215
+ i++;
216
+ return { rule: css.slice(start, i), rest: css.slice(i), isBlock: false };
217
+ }
218
+
219
+ i++;
220
+ }
221
+
222
+ return { rule: css.slice(start), rest: '', isBlock: false };
223
+ }
224
+
225
+ function _isAtRuleWithBlock(css, atStart, bracePos) {
226
+ const name = css.slice(atStart, bracePos).match(/@([\w-]+)/)?.[1] ?? '';
227
+ // Keyframes, charset, import — do not recurse
228
+ const noRecurse = new Set(['keyframes', 'charset', 'import', 'font-face', 'namespace']);
229
+ return !noRecurse.has(name);
230
+ }
231
+
232
+ function _readSelectorBlock(css, start) {
233
+ // Read up to the first {
234
+ let i = start;
235
+ let inStr = false;
236
+ let strChar = '';
237
+
238
+ while (i < css.length) {
239
+ const c = css[i];
240
+ if (inStr) {
241
+ if (c === strChar && css[i - 1] !== '\\') inStr = false;
242
+ i++;
243
+ continue;
244
+ }
245
+ if (c === '"' || c === "'") { inStr = true; strChar = c; i++; continue; }
246
+ if (c === '{') break;
247
+ i++;
248
+ }
249
+
250
+ if (i >= css.length) return { selector: '', block: '', total: 0 };
251
+
252
+ const selector = css.slice(start, i).trim();
253
+ // Now read the block {…}
254
+ let depth = 0;
255
+ let blockStart = i;
256
+ i++;
257
+ depth = 1;
258
+ while (i < css.length && depth > 0) {
259
+ if (css[i] === '{') depth++;
260
+ else if (css[i] === '}') depth--;
261
+ i++;
262
+ }
263
+
264
+ const block = css.slice(blockStart, i);
265
+ return { selector, block, total: i - start };
266
+ }
267
+
268
+ /**
269
+ * Add the scope attribute to the last simple selector in each compound selector.
270
+ *
271
+ * Selector list (comma-separated) → each selector gets scoped independently.
272
+ *
273
+ * We append to the LAST simple selector in a compound so that:
274
+ * .parent .child → .parent .child[attr] (only the leaf is scoped)
275
+ *
276
+ * Special cases left untouched:
277
+ * :root, html, body, @keyframes stop selectors, :global(...)
278
+ */
279
+ function _scopeSelector(selector, attr) {
280
+ if (!selector.trim()) return selector;
281
+
282
+ // Split on top-level commas (not inside parens)
283
+ const parts = _splitSelectorList(selector);
284
+
285
+ return parts.map(part => {
286
+ const t = part.trim();
287
+
288
+ // :root, html, body — never scoped
289
+ if (/^(:root|html|body)$/i.test(t)) return t;
290
+
291
+ // :global(…) — strip the wrapper, output the inner selector as-is (not scoped)
292
+ if (t.startsWith(':global(') && t.endsWith(')')) {
293
+ return t.slice(':global('.length, -1).trim();
294
+ }
295
+
296
+ // :local(…) — explicitly scope the inner selector
297
+ if (t.startsWith(':local(') && t.endsWith(')')) {
298
+ const inner = t.slice(':local('.length, -1).trim();
299
+ return _appendToLastSimple(inner, attr);
300
+ }
301
+
302
+ // Mixed selectors may contain :global() or :local() inline fragments:
303
+ // .parent :global(.child) → .parent[attr] .child
304
+ // :global(.outer) .inner → .outer .inner[attr]
305
+ // Handle these by splitting on :global()/`:local()` pseudo-functions.
306
+ if (t.includes(':global(') || t.includes(':local(')) {
307
+ return _processLocalGlobalFragments(t, attr);
308
+ }
309
+
310
+ // Default: scope the whole selector
311
+ return _appendToLastSimple(t, attr);
312
+ }).join(', ');
313
+ }
314
+
315
+ /**
316
+ * Handle selectors that mix :local() and :global() fragments inline.
317
+ * E.g.: ".parent :global(.child)" → ".parent[attr] .child"
318
+ * ":global(.header) .body" → ".header .body[attr]"
319
+ */
320
+ function _processLocalGlobalFragments(selector, attr) {
321
+ // Tokenise the selector into scoped and unscoped segments
322
+ const result = [];
323
+ let i = 0;
324
+ let lastWasGlobal = false;
325
+ let buf = '';
326
+
327
+ while (i < selector.length) {
328
+ // Detect :global(...)
329
+ if (selector.startsWith(':global(', i)) {
330
+ // Flush buffered scoped text
331
+ if (buf.trim()) {
332
+ result.push(_appendToLastSimple(buf.trim(), attr));
333
+ buf = '';
334
+ lastWasGlobal = false;
335
+ }
336
+ const end = _findClosingParen(selector, i + ':global('.length - 1);
337
+ const inner = selector.slice(i + ':global('.length, end);
338
+ result.push(inner);
339
+ i = end + 1;
340
+ lastWasGlobal = true;
341
+ continue;
342
+ }
343
+ // Detect :local(...)
344
+ if (selector.startsWith(':local(', i)) {
345
+ if (buf.trim()) {
346
+ result.push(_appendToLastSimple(buf.trim(), attr));
347
+ buf = '';
348
+ }
349
+ const end = _findClosingParen(selector, i + ':local('.length - 1);
350
+ const inner = selector.slice(i + ':local('.length, end);
351
+ result.push(_appendToLastSimple(inner.trim(), attr));
352
+ i = end + 1;
353
+ lastWasGlobal = false;
354
+ continue;
355
+ }
356
+ // Accumulate combinator/whitespace between segments
357
+ if (lastWasGlobal && /[\s>~+]/.test(selector[i])) {
358
+ result.push(selector[i]);
359
+ i++;
360
+ continue;
361
+ }
362
+ buf += selector[i];
363
+ i++;
364
+ }
365
+ if (buf.trim()) result.push(_appendToLastSimple(buf.trim(), attr));
366
+ return result.join('');
367
+ }
368
+
369
+ function _findClosingParen(str, openPos) {
370
+ let depth = 0;
371
+ for (let i = openPos; i < str.length; i++) {
372
+ if (str[i] === '(') depth++;
373
+ else if (str[i] === ')') {
374
+ depth--;
375
+ if (depth === 0) return i;
376
+ }
377
+ }
378
+ return str.length - 1;
379
+ }
380
+
381
+ function _appendToLastSimple(selector, attr) {
382
+ // Find the last segment (not a combinator: space, >, ~, +)
383
+ // Split on combinators while keeping them
384
+ const parts = selector.split(/([\s>~+]+)/);
385
+ // last non-combinator part
386
+ let lastIdx = parts.length - 1;
387
+ while (lastIdx > 0 && /^[\s>~+]+$/.test(parts[lastIdx])) lastIdx--;
388
+
389
+ const lastPart = parts[lastIdx];
390
+
391
+ // Insert attr before pseudo-elements (::before, ::after)
392
+ const pseudoElRE = /:{1,2}[a-z-]+(\([^)]*\))?$/i;
393
+ const peMatch = lastPart.match(pseudoElRE);
394
+
395
+ if (peMatch) {
396
+ const idx = lastPart.lastIndexOf(peMatch[0]);
397
+ parts[lastIdx] = lastPart.slice(0, idx) + attr + lastPart.slice(idx);
398
+ } else {
399
+ parts[lastIdx] = lastPart + attr;
400
+ }
401
+
402
+ return parts.join('');
403
+ }
404
+
405
+ function _splitSelectorList(selector) {
406
+ const parts = [];
407
+ let depth = 0;
408
+ let start = 0;
409
+ let inStr = false;
410
+ let strCh = '';
411
+
412
+ for (let i = 0; i < selector.length; i++) {
413
+ const c = selector[i];
414
+ if (inStr) {
415
+ if (c === strCh && selector[i - 1] !== '\\') inStr = false;
416
+ continue;
417
+ }
418
+ if (c === '"' || c === "'") { inStr = true; strCh = c; continue; }
419
+ if (c === '(') { depth++; continue; }
420
+ if (c === ')') { depth--; continue; }
421
+ if (c === ',' && depth === 0) {
422
+ parts.push(selector.slice(start, i));
423
+ start = i + 1;
424
+ }
425
+ }
426
+
427
+ parts.push(selector.slice(start));
428
+ return parts;
429
+ }
430
+
431
+ // ─── injectScopeAttr ─────────────────────────────────────────────────────────
432
+
433
+ /**
434
+ * Given generated JavaScript code for a component, inject the scope data
435
+ * attribute onto the root element.
436
+ *
437
+ * The codegen emits something like:
438
+ * const _root = h('div', { class: 'card' }, [...]);
439
+ *
440
+ * We patch it to:
441
+ * const _root = h('div', { class: 'card', 'data-c-3f8a1b': '' }, [...]);
442
+ *
443
+ * This is called by the Vite plugin / compiler after codegen, before writing
444
+ * the final JS output.
445
+ *
446
+ * @param {string} code - Generated JS
447
+ * @param {string} scopeId - e.g. "3f8a1b"
448
+ * @returns {string} - Patched JS
449
+ */
450
+ export function injectScopeAttr(code, scopeId) {
451
+ if (!scopeId) return code;
452
+ const attr = `'data-c-${scopeId}': ''`;
453
+ // The codegen always names the root element variable _root in the component factory.
454
+ // We insert the attr into the first h(...) call props object.
455
+ // Pattern: h('tag', { → h('tag', { 'data-c-xxx': '',
456
+ return code.replace(
457
+ /\bh\((['"`][^'"` ]+['"`]),\s*\{/,
458
+ `h($1, { ${attr}, `
459
+ );
460
+ }
461
+
462
+ // ─── CSS Modules support ──────────────────────────────────────────────────────
463
+
464
+ /**
465
+ * Process a `<style module>` block.
466
+ *
467
+ * In CSS Modules mode:
468
+ * - All class selectors are renamed: .card → .card_3f8a1b
469
+ * - :global(.foo) opts out of renaming
470
+ * - :local(.foo) opts in (always renamed, even if global mode is used)
471
+ * - Returns { css: transformedCSS, classMap: { card: 'card_3f8a1b' } }
472
+ *
473
+ * Usage:
474
+ * const { css, classMap } = cssModules(raw, scopeId);
475
+ * // classMap.card === 'card_3f8a1b'
476
+ * // Inject classMap as `styles` into the component props
477
+ *
478
+ * @param {string} css - Raw CSS from <style module>
479
+ * @param {string} scopeId - Scope hash e.g. "3f8a1b"
480
+ * @param {object} [opts]
481
+ * @param {boolean} [opts.globalDefault=false] - If true, classes are global by default (only :local() scopes)
482
+ * @returns {{ css: string, classMap: object }}
483
+ */
484
+ export function cssModules(css, scopeId, { globalDefault = false } = {}) {
485
+ if (!css.trim()) return { css: '', classMap: {} };
486
+
487
+ const classMap = {};
488
+
489
+ /**
490
+ * Rename a class name: .card → .card_3f8a1b
491
+ * Side effect: records the mapping in classMap.
492
+ */
493
+ function rename(cls) {
494
+ const key = cls.slice(1); // strip leading '.'
495
+ const renamed = `${key}_${scopeId}`;
496
+ classMap[key] = renamed;
497
+ return `.${renamed}`;
498
+ }
499
+
500
+ const transformed = _transformCSSModules(css, { rename, globalDefault });
501
+ return { css: transformed, classMap };
502
+ }
503
+
504
+ /**
505
+ * Transform CSS for modules mode: rename .class selectors unless :global() wrapped.
506
+ */
507
+ function _transformCSSModules(css, { rename, globalDefault }) {
508
+ const result = [];
509
+ let i = 0;
510
+
511
+ while (i < css.length) {
512
+ // Skip comments
513
+ if (css[i] === '/' && css[i + 1] === '*') {
514
+ const end = css.indexOf('*/', i + 2);
515
+ if (end === -1) { result.push(css.slice(i)); break; }
516
+ result.push(css.slice(i, end + 2));
517
+ i = end + 2;
518
+ continue;
519
+ }
520
+
521
+ // At-rules with blocks
522
+ if (css[i] === '@') {
523
+ const { rule, isBlock } = _readAtRule(css, i);
524
+ if (isBlock) {
525
+ const blockMatch = rule.match(/^(@[\w-]+[^{]*)\{([\s\S]*)\}$/s);
526
+ if (blockMatch) {
527
+ const inner = _transformCSSModules(blockMatch[2], { rename, globalDefault });
528
+ result.push(`${blockMatch[1]}{${inner}}`);
529
+ } else {
530
+ result.push(rule);
531
+ }
532
+ } else {
533
+ result.push(rule);
534
+ }
535
+ i += rule.length;
536
+ continue;
537
+ }
538
+
539
+ // Whitespace pass-through
540
+ if (/\s/.test(css[i])) { result.push(css[i]); i++; continue; }
541
+
542
+ // Selector block
543
+ const { selector, block, total } = _readSelectorBlock(css, i);
544
+ if (total === 0) { result.push(css[i]); i++; continue; }
545
+
546
+ const transformedSelector = _transformModuleSelector(selector, { rename, globalDefault });
547
+ result.push(`${transformedSelector}${block}`);
548
+ i += total;
549
+ }
550
+
551
+ return result.join('');
552
+ }
553
+
554
+ function _transformModuleSelector(selector, { rename, globalDefault }) {
555
+ if (!selector.trim()) return selector;
556
+ const parts = _splitSelectorList(selector);
557
+
558
+ return parts.map(part => {
559
+ const t = part.trim();
560
+ return _renameClassesInSelector(t, { rename, globalDefault });
561
+ }).join(', ');
562
+ }
563
+
564
+ /**
565
+ * Walk through a selector string, renaming class tokens.
566
+ *
567
+ * Handles:
568
+ * .card → .card_xxx (scoped)
569
+ * :global(.card) → .card (not renamed)
570
+ * :local(.card) → .card_xxx (always renamed)
571
+ * .parent :global(.sub) → .parent_xxx .sub (mixed)
572
+ */
573
+ function _renameClassesInSelector(selector, { rename, globalDefault }) {
574
+ let result = '';
575
+ let i = 0;
576
+
577
+ while (i < selector.length) {
578
+ // :global(...) — pass through inner content unchanged
579
+ if (selector.startsWith(':global(', i)) {
580
+ const end = _findClosingParen(selector, i + ':global('.length - 1);
581
+ const inner = selector.slice(i + ':global('.length, end);
582
+ // Keep as-is — don't rename inside :global()
583
+ result += inner;
584
+ i = end + 1;
585
+ continue;
586
+ }
587
+
588
+ // :local(...) — always rename inner classes
589
+ if (selector.startsWith(':local(', i)) {
590
+ const end = _findClosingParen(selector, i + ':local('.length - 1);
591
+ const inner = selector.slice(i + ':local('.length, end);
592
+ result += _renameRawClasses(inner, rename);
593
+ i = end + 1;
594
+ continue;
595
+ }
596
+
597
+ // Class selector token: .foo
598
+ if (selector[i] === '.' && (i === 0 || /[\s>~+:(]/.test(selector[i - 1]))) {
599
+ // Read class name
600
+ let j = i + 1;
601
+ while (j < selector.length && /[\w-]/.test(selector[j])) j++;
602
+ const cls = selector.slice(i, j);
603
+ if (!globalDefault) {
604
+ result += rename(cls);
605
+ } else {
606
+ result += cls; // global-default: leave class as-is (only :local() renames)
607
+ }
608
+ i = j;
609
+ continue;
610
+ }
611
+
612
+ result += selector[i];
613
+ i++;
614
+ }
615
+
616
+ return result;
617
+ }
618
+
619
+ function _renameRawClasses(selector, rename) {
620
+ return selector.replace(/\.([\w-]+)/g, (_, name) => rename(`.${name}`));
621
+ }
622
+
623
+ // ─── collectStyles (for SSR / build pipeline) ─────────────────────────────────
624
+
625
+ /**
626
+ * Aggregate all scoped + global CSS from multiple processed files.
627
+ * Returns a single CSS string suitable for injection into <head>.
628
+ *
629
+ * @param {Array<{ scoped: string, global: string, scopeId: string }>} entries
630
+ * @returns {string}
631
+ */
632
+ export function collectStyles(entries) {
633
+ const parts = [];
634
+ for (const { scoped, global, scopeId } of entries) {
635
+ if (global.trim()) parts.push(global.trim());
636
+ if (scoped.trim() && scopeId) {
637
+ parts.push(scopeCSS(scoped, scopeId));
638
+ }
639
+ }
640
+ return parts.join('\n');
641
+ }