@usefragments/core 1.5.2 → 1.7.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.
Files changed (64) hide show
  1. package/dist/chunk-RANPUC6C.js +72 -0
  2. package/dist/chunk-RANPUC6C.js.map +1 -0
  3. package/dist/{chunk-BAHCOAVG.js → chunk-WNMWKUYG.js} +447 -201
  4. package/dist/chunk-WNMWKUYG.js.map +1 -0
  5. package/dist/codes/index.d.ts +2 -2
  6. package/dist/codes/index.js +1 -1
  7. package/dist/compiled-types/index.d.ts +1 -1
  8. package/dist/compiled-types/index.js +8 -0
  9. package/dist/generate/index.d.ts +1 -1
  10. package/dist/{governance-pKrfh517.d.ts → governance-D9KtH-vg.d.ts} +617 -22
  11. package/dist/index.d.ts +800 -179
  12. package/dist/index.js +1003 -237
  13. package/dist/index.js.map +1 -1
  14. package/dist/react-types.d.ts +1 -1
  15. package/dist/registry.d.ts +36 -36
  16. package/dist/schemas/index.d.ts +1 -1
  17. package/dist/test-utils.d.ts +1 -1
  18. package/package.json +2 -1
  19. package/src/__tests__/policy-exclude.test.ts +180 -0
  20. package/src/agent-format.test.ts +13 -0
  21. package/src/agent-format.ts +9 -3
  22. package/src/canonical-bridge.ts +69 -1
  23. package/src/canonical-direction.test.ts +118 -0
  24. package/src/canonical-direction.ts +43 -2
  25. package/src/codes/__tests__/codes.test.ts +14 -2
  26. package/src/codes/codes.ts +41 -2
  27. package/src/compiled-types/index.ts +81 -0
  28. package/src/compiled-types/parse.test.ts +47 -0
  29. package/src/facts/builders.ts +48 -0
  30. package/src/facts/compile.ts +148 -34
  31. package/src/facts/fact-index.ts +15 -2
  32. package/src/facts/facts.test.ts +44 -3
  33. package/src/facts/index.ts +11 -6
  34. package/src/facts/types.ts +60 -9
  35. package/src/governance-integrity.test.ts +272 -4
  36. package/src/governance-integrity.ts +345 -25
  37. package/src/governance.ts +87 -1
  38. package/src/index.ts +46 -1
  39. package/src/policy-exclude.ts +113 -0
  40. package/src/rules/a11y-required-accessible-name.ts +175 -28
  41. package/src/rules/a11y-standard.ts +102 -0
  42. package/src/rules/a11y-utils.ts +7 -0
  43. package/src/rules/components-prefer-library.test.ts +75 -28
  44. package/src/rules/components-prefer-library.ts +35 -15
  45. package/src/rules/components-shadow-component.test.ts +21 -9
  46. package/src/rules/emit-gate.test.ts +74 -4
  47. package/src/rules/emit-gate.ts +24 -9
  48. package/src/rules/families.test.ts +69 -0
  49. package/src/rules/families.ts +52 -0
  50. package/src/rules/fix-availability.ts +1 -0
  51. package/src/rules/index.ts +18 -2
  52. package/src/rules/jsx-preferred-import-path.ts +29 -11
  53. package/src/rules/rules.test.ts +188 -8
  54. package/src/rules/styles-no-raw-color.ts +13 -4
  55. package/src/rules/styles-no-raw-dimensions.ts +13 -4
  56. package/src/rules/styles-no-raw-spacing.ts +12 -4
  57. package/src/rules/styles-no-raw-typography.ts +13 -4
  58. package/src/rules/tiers.ts +1 -0
  59. package/src/rules/utils.ts +39 -0
  60. package/src/tokens/design-token-parser.test.ts +131 -0
  61. package/src/tokens/design-token-parser.ts +362 -49
  62. package/src/types.ts +2 -2
  63. package/dist/chunk-BAHCOAVG.js.map +0 -1
  64. package/dist/{index-DbkPE46t.d.ts → index-hZAlYCli.d.ts} +8 -8
@@ -175,4 +175,135 @@ describe("parseDesignTokenContent — CSS terminator robustness (#34/#35)", () =
175
175
  expect(res.tokens.find((token) => token.name === "--sp-1")?.category).toBe("spacing");
176
176
  expect(res.tokens.find((token) => token.name === "--fui-sp-2")?.category).toBe("spacing");
177
177
  });
178
+
179
+ it("preserves the same custom property across configured theme selectors", () => {
180
+ const content = [
181
+ ":root { --surface: #ffffff; --text: #111111; }",
182
+ '[data-theme="dark"] { --surface: #111111; --text: var(--surface); }',
183
+ ".high-contrast { --surface: #000000; }",
184
+ ].join("\n");
185
+ const res = parseDesignTokenContent(content, {
186
+ filePath: "tokens.css",
187
+ themeSelectors: {
188
+ ":root": "default",
189
+ '[data-theme="dark"]': "dark",
190
+ ".high-contrast": "high-contrast",
191
+ },
192
+ });
193
+
194
+ expect(
195
+ res.tokens
196
+ .filter((token) => token.name === "--surface")
197
+ .map((token) => [token.theme, token.resolvedValue, token.selector])
198
+ ).toEqual([
199
+ ["default", "#ffffff", ":root"],
200
+ ["dark", "#111111", '[data-theme="dark"]'],
201
+ ["high-contrast", "#000000", ".high-contrast"],
202
+ ]);
203
+ expect(
204
+ res.tokens.find((token) => token.name === "--text" && token.theme === "dark")?.resolvedValue
205
+ ).toBe("#111111");
206
+ });
207
+
208
+ it("preserves selector-specific values in compact single-line theme CSS", () => {
209
+ const content =
210
+ ':root{--surface:#ffffff;--text:#111111}[data-theme="dark"]{--surface:#111111;--text:var(--surface)}.high-contrast{--surface:#000000}';
211
+ const res = parseDesignTokenContent(content, {
212
+ filePath: "tokens.min.css",
213
+ themeSelectors: {
214
+ ":root": "default",
215
+ '[data-theme="dark"]': "dark",
216
+ ".high-contrast": "high-contrast",
217
+ },
218
+ });
219
+
220
+ expect(
221
+ res.tokens
222
+ .filter((token) => token.name === "--surface")
223
+ .map((token) => [token.theme, token.resolvedValue, token.selector])
224
+ ).toEqual([
225
+ ["default", "#ffffff", ":root"],
226
+ ["dark", "#111111", '[data-theme="dark"]'],
227
+ ["high-contrast", "#000000", ".high-contrast"],
228
+ ]);
229
+ expect(
230
+ res.tokens.find((token) => token.name === "--text" && token.theme === "dark")?.resolvedValue
231
+ ).toBe("#111111");
232
+ });
233
+
234
+ it("uses the nearest selector through compact at-rules and ignores structural text in comments", () => {
235
+ const content =
236
+ ':root{/* fake } [data-theme="dark"] { */--surface:#fff}@media(prefers-color-scheme:dark){[data-theme="dark"]{--surface:#111}}';
237
+ const res = parseDesignTokenContent(content, {
238
+ filePath: "tokens.min.css",
239
+ themeSelectors: {
240
+ ":root": "default",
241
+ '[data-theme="dark"]': "dark",
242
+ },
243
+ });
244
+
245
+ expect(
246
+ res.tokens
247
+ .filter((token) => token.name === "--surface")
248
+ .map((token) => [token.theme, token.resolvedValue, token.selector])
249
+ ).toEqual([
250
+ ["default", "#fff", ":root"],
251
+ ["dark", "#111", '[data-theme="dark"]'],
252
+ ]);
253
+ });
254
+
255
+ it("ignores declaration-shaped text in comments and strings while preserving quoted delimiters", () => {
256
+ const content =
257
+ ':root{/* --comment-token:red; } */--label:"literal; { value }";--image:url("data:image/svg+xml;utf8,<svg>{}</svg>");--raw-image:url(data:image/svg+xml;utf8,<svg>{}</svg>);--real:#fff;content:"--string-token:blue;"}';
258
+ const res = parseDesignTokenContent(content, { filePath: "tokens.min.css" });
259
+
260
+ expect(res.tokens.map((token) => [token.name, token.rawValue])).toEqual([
261
+ ["--label", '"literal; { value }"'],
262
+ ["--image", 'url("data:image/svg+xml;utf8,<svg>{}</svg>")'],
263
+ ["--raw-image", "url(data:image/svg+xml;utf8,<svg>{}</svg>)"],
264
+ ["--real", "#fff"],
265
+ ]);
266
+ });
267
+
268
+ it("keeps the nearest theme selector through multiple nested compact at-rules", () => {
269
+ const content =
270
+ '@layer theme{@supports(--feature-probe:#fff){:root{--surface:#fff}@media(prefers-color-scheme:dark){[data-theme="dark"]{--surface:#111;--text:var(--surface)}}}}';
271
+ const res = parseDesignTokenContent(content, {
272
+ filePath: "tokens.min.css",
273
+ themeSelectors: {
274
+ ":root": "default",
275
+ '[data-theme="dark"]': "dark",
276
+ },
277
+ });
278
+
279
+ expect(
280
+ res.tokens.map((token) => [token.name, token.theme, token.selector, token.resolvedValue])
281
+ ).toEqual([
282
+ ["--surface", "default", ":root", "#fff"],
283
+ ["--surface", "dark", '[data-theme="dark"]', "#111"],
284
+ ["--text", "dark", '[data-theme="dark"]', "#111"],
285
+ ]);
286
+ });
287
+
288
+ it("handles thousands of compact declarations without losing selector context", () => {
289
+ const declarationCount = 4_000;
290
+ const declarations = Array.from(
291
+ { length: declarationCount },
292
+ (_, index) => `--token-${index}:${index}px`
293
+ ).join(";");
294
+ const res = parseDesignTokenContent(
295
+ `@layer theme{@media(min-width:0){[data-theme="dense"]{${declarations}}}}`,
296
+ {
297
+ filePath: "tokens.min.css",
298
+ themeSelectors: { '[data-theme="dense"]': "dense" },
299
+ }
300
+ );
301
+
302
+ expect(res.tokens).toHaveLength(declarationCount);
303
+ expect(res.tokens.at(-1)).toMatchObject({
304
+ name: `--token-${declarationCount - 1}`,
305
+ theme: "dense",
306
+ selector: '[data-theme="dense"]',
307
+ });
308
+ });
178
309
  });
@@ -10,12 +10,7 @@ import { parseScssTokens } from "./scss.js";
10
10
  import { containsTailwindV4Theme, parseTailwindV4Theme } from "./tailwind-v4.js";
11
11
  import type { DesignTokenParseOptions, ParsedToken, TokenParseOutput } from "./types.js";
12
12
 
13
- // Token declaration value class excludes `;` (the normal terminator), and `{`/`}`
14
- // so the final declaration in a block (no trailing `;` — valid CSS, #34) and a
15
- // value that wraps across newlines (#35) are both captured without swallowing a
16
- // following block. Matched over the FULL content string (not per-line) so the
17
- // `[^;{}]+?` class can span `\n`. Terminates on `;`, the next `}`, or EOF.
18
- const TOKEN_DECLARATION_PATTERN = /--([a-zA-Z0-9_-]+)\s*:\s*([^;{}]+?)\s*(?:;|(?=\})|$)/g;
13
+ const CSS_CUSTOM_PROPERTY_NAME_CHARACTER = /[a-zA-Z0-9_-]/u;
19
14
  const VAR_REFERENCE_PATTERN = /var\(\s*--([a-zA-Z0-9_-]+)(?:\s*,\s*([^)]+))?\s*\)/;
20
15
 
21
16
  export function parseDesignTokenContent(
@@ -98,25 +93,64 @@ export function parseDesignTokenContent(
98
93
  }
99
94
  }
100
95
 
101
- const tokensByName = new Map<string, { rawValue: string; line?: number }>();
102
-
103
- // Match over the FULL content so a value spanning newlines (#35) is captured.
104
- // The line number is recomputed from the match index because findSelectorForLine
105
- // and extractDescription still attribute by line. Internal whitespace (incl.
106
- // newlines) is collapsed so a multi-line value stores cleanly.
107
- for (const match of content.matchAll(TOKEN_DECLARATION_PATTERN)) {
108
- const [, name, rawValue] = match;
109
- const fullName = `--${name}`;
110
- const lineNumber = countNewlines(content, match.index ?? 0) + 1;
111
- tokensByName.set(fullName, {
112
- rawValue: rawValue.trim().replace(/\s+/g, " "),
96
+ const declarationsByThemeAndName = new Map<
97
+ string,
98
+ {
99
+ name: string;
100
+ rawValue: string;
101
+ line?: number;
102
+ selector: string;
103
+ theme: string;
104
+ }
105
+ >();
106
+
107
+ // Scan once over the full source. Besides resolving compact single-line
108
+ // selectors, the scanner distinguishes declarations from declaration-shaped
109
+ // text in comments/strings and respects delimiters inside CSS values.
110
+ const declarationMatches = scanCssCustomPropertyDeclarations(content);
111
+ for (const match of declarationMatches) {
112
+ const fullName = match.name;
113
+ const lineNumber = match.line;
114
+ const selector = match.selector;
115
+ const theme = themeSelectors[selector] || "default";
116
+ declarationsByThemeAndName.set(`${theme}\0${fullName}`, {
117
+ name: fullName,
118
+ rawValue: normalizeCssCustomPropertyValue(match.rawValue),
113
119
  line: lineNumber,
120
+ selector,
121
+ theme,
114
122
  });
115
123
  }
116
124
 
117
- for (const [name, { rawValue, line }] of tokensByName) {
118
- const selector = findSelectorForLine(content, line ?? 1);
119
- const theme = themeSelectors[selector] || "default";
125
+ const declarations = [...declarationsByThemeAndName.values()];
126
+ const defaults = new Map<string, { rawValue: string; line?: number }>();
127
+ const declarationsByTheme = new Map<string, Map<string, { rawValue: string; line?: number }>>();
128
+ for (const declaration of declarations) {
129
+ if (declaration.theme === "default") defaults.set(declaration.name, declaration);
130
+ let themeDeclarations = declarationsByTheme.get(declaration.theme);
131
+ if (!themeDeclarations) {
132
+ themeDeclarations = new Map();
133
+ declarationsByTheme.set(declaration.theme, themeDeclarations);
134
+ }
135
+ themeDeclarations.set(declaration.name, declaration);
136
+ }
137
+
138
+ const tokensByTheme = new Map<string, Map<string, { rawValue: string; line?: number }>>();
139
+ for (const [theme, themeDeclarations] of declarationsByTheme) {
140
+ if (theme === "default") {
141
+ tokensByTheme.set(theme, defaults);
142
+ continue;
143
+ }
144
+ const tokensByName = new Map(defaults);
145
+ for (const [name, declaration] of themeDeclarations) {
146
+ tokensByName.set(name, declaration);
147
+ }
148
+ tokensByTheme.set(theme, tokensByName);
149
+ }
150
+
151
+ const contentLines = content.split("\n");
152
+ for (const { name, rawValue, line, selector, theme } of declarations) {
153
+ const tokensByName = tokensByTheme.get(theme) ?? defaults;
120
154
  const { resolvedValue, chain, hasCircular, unresolvedRef } = resolveDesignTokenValue(
121
155
  rawValue,
122
156
  tokensByName
@@ -141,7 +175,7 @@ export function parseDesignTokenContent(
141
175
  lineNumber: line,
142
176
  theme,
143
177
  selector,
144
- description: extractDescription(content, line ?? 1),
178
+ description: extractDescription(contentLines, line ?? 1),
145
179
  });
146
180
  }
147
181
 
@@ -162,15 +196,6 @@ export function parseDesignTokenContent(
162
196
  };
163
197
  }
164
198
 
165
- /** Count the `\n` characters in `content` up to (not including) `index`. */
166
- function countNewlines(content: string, index: number): number {
167
- let count = 0;
168
- for (let i = 0; i < index && i < content.length; i++) {
169
- if (content.charCodeAt(i) === 10) count++;
170
- }
171
- return count;
172
- }
173
-
174
199
  /**
175
200
  * Flatten a DTCG/JSON `TokenParseOutput` into `DesignToken[]`. Names pass through
176
201
  * verbatim (parseDtcgTokens already emits `--kebab` CSS names via
@@ -384,34 +409,322 @@ function inferTokenLevel(name: string, rawValue: string, referenceChain: string[
384
409
  return 2;
385
410
  }
386
411
 
387
- function findSelectorForLine(content: string, targetLine: number): string {
388
- const lines = content.split("\n");
412
+ interface ScannedCssCustomProperty {
413
+ name: string;
414
+ rawValue: string;
415
+ line: number;
416
+ selector: string;
417
+ }
418
+
419
+ /**
420
+ * Extract custom-property declarations and their nearest non-at-rule selector
421
+ * in one pass. Comments and strings are lexical, not structural; declaration
422
+ * values may contain quoted or nested `;`, `{`, and `}` delimiters.
423
+ */
424
+ function scanCssCustomPropertyDeclarations(content: string): ScannedCssCustomProperty[] {
425
+ const declarations: ScannedCssCustomProperty[] = [];
426
+ const parentSelectors: string[] = [];
389
427
  let currentSelector = ":root";
390
- let braceDepth = 0;
391
-
392
- for (let i = 0; i < Math.min(targetLine, lines.length); i++) {
393
- const line = lines[i];
394
- const selectorMatch = line.match(/^\s*([^{]+)\s*\{/);
395
- if (selectorMatch) {
396
- const selector = selectorMatch[1].trim();
397
- if ((line.match(/\{/g) || []).length > (line.match(/\}/g) || []).length) {
398
- currentSelector = selector;
428
+ let boundary = 0;
429
+ let statementHasContent = false;
430
+ let line = 1;
431
+ let quote: "'" | '"' | null = null;
432
+ let escaped = false;
433
+ let inComment = false;
434
+ let parentheses = 0;
435
+ let brackets = 0;
436
+
437
+ for (let index = 0; index < content.length; index++) {
438
+ const character = content[index];
439
+ const next = content[index + 1];
440
+ if (character === "\n") line++;
441
+
442
+ if (inComment) {
443
+ if (character === "*" && next === "/") {
444
+ inComment = false;
445
+ index++;
399
446
  }
447
+ continue;
448
+ }
449
+ if (quote) {
450
+ if (escaped) {
451
+ escaped = false;
452
+ } else if (character === "\\") {
453
+ escaped = true;
454
+ } else if (character === quote) {
455
+ quote = null;
456
+ }
457
+ continue;
458
+ }
459
+ if (character === "/" && next === "*") {
460
+ inComment = true;
461
+ index++;
462
+ continue;
463
+ }
464
+ if (character === "'" || character === '"') {
465
+ quote = character;
466
+ statementHasContent = true;
467
+ continue;
468
+ }
469
+ if (character === "\\") {
470
+ statementHasContent = true;
471
+ if (next === "\n") line++;
472
+ index++;
473
+ continue;
474
+ }
475
+ if (character === "(") {
476
+ parentheses++;
477
+ statementHasContent = true;
478
+ continue;
479
+ }
480
+ if (character === ")") {
481
+ parentheses = Math.max(0, parentheses - 1);
482
+ statementHasContent = true;
483
+ continue;
484
+ }
485
+ if (character === "[") {
486
+ brackets++;
487
+ statementHasContent = true;
488
+ continue;
400
489
  }
490
+ if (character === "]") {
491
+ brackets = Math.max(0, brackets - 1);
492
+ statementHasContent = true;
493
+ continue;
494
+ }
495
+ if (parentheses > 0 || brackets > 0) {
496
+ if (!/\s/u.test(character)) statementHasContent = true;
497
+ continue;
498
+ }
499
+
500
+ if (character === "{") {
501
+ const header = stripCssComments(content.slice(boundary, index)).trim();
502
+ parentSelectors.push(currentSelector);
503
+ if (header && !header.startsWith("@")) currentSelector = header;
504
+ boundary = index + 1;
505
+ statementHasContent = false;
506
+ } else if (character === "}") {
507
+ currentSelector = parentSelectors.pop() ?? ":root";
508
+ boundary = index + 1;
509
+ statementHasContent = false;
510
+ } else if (character === ";") {
511
+ boundary = index + 1;
512
+ statementHasContent = false;
513
+ } else if (character === "-" && next === "-" && !statementHasContent) {
514
+ const declaration = scanCssCustomPropertyAt(content, index);
515
+ if (!declaration) {
516
+ statementHasContent = true;
517
+ continue;
518
+ }
519
+
520
+ const rawValue = content.slice(declaration.valueStart, declaration.end).trim();
521
+ if (rawValue) {
522
+ declarations.push({
523
+ name: declaration.name,
524
+ rawValue,
525
+ line,
526
+ selector: currentSelector,
527
+ });
528
+ }
529
+ line += countNewlinesInRange(content, index + 1, declaration.end);
530
+ statementHasContent = true;
531
+ index = declaration.end - 1;
532
+ } else if (!/\s/u.test(character)) {
533
+ statementHasContent = true;
534
+ }
535
+ }
401
536
 
402
- braceDepth += (line.match(/\{/g) || []).length;
403
- braceDepth -= (line.match(/\}/g) || []).length;
537
+ return declarations;
538
+ }
404
539
 
405
- if (braceDepth === 0) {
406
- currentSelector = ":root";
540
+ function scanCssCustomPropertyAt(
541
+ content: string,
542
+ start: number
543
+ ): { name: string; valueStart: number; end: number } | null {
544
+ let cursor = start + 2;
545
+ while (
546
+ cursor < content.length &&
547
+ CSS_CUSTOM_PROPERTY_NAME_CHARACTER.test(content[cursor] ?? "")
548
+ ) {
549
+ cursor++;
550
+ }
551
+ if (cursor === start + 2) return null;
552
+
553
+ const nameEnd = cursor;
554
+ cursor = skipCssWhitespaceAndComments(content, cursor);
555
+ if (content[cursor] !== ":") return null;
556
+
557
+ const valueStart = cursor + 1;
558
+ return {
559
+ name: content.slice(start, nameEnd),
560
+ valueStart,
561
+ end: findCssCustomPropertyValueEnd(content, valueStart),
562
+ };
563
+ }
564
+
565
+ function findCssCustomPropertyValueEnd(content: string, start: number): number {
566
+ let quote: "'" | '"' | null = null;
567
+ let escaped = false;
568
+ let inComment = false;
569
+ let parentheses = 0;
570
+ let brackets = 0;
571
+ let braces = 0;
572
+
573
+ for (let index = start; index < content.length; index++) {
574
+ const character = content[index];
575
+ const next = content[index + 1];
576
+ if (inComment) {
577
+ if (character === "*" && next === "/") {
578
+ inComment = false;
579
+ index++;
580
+ }
581
+ continue;
582
+ }
583
+ if (quote) {
584
+ if (escaped) {
585
+ escaped = false;
586
+ } else if (character === "\\") {
587
+ escaped = true;
588
+ } else if (character === quote) {
589
+ quote = null;
590
+ }
591
+ continue;
592
+ }
593
+ if (character === "/" && next === "*") {
594
+ inComment = true;
595
+ index++;
596
+ continue;
597
+ }
598
+ if (character === "'" || character === '"') {
599
+ quote = character;
600
+ continue;
601
+ }
602
+ if (character === "\\") {
603
+ index++;
604
+ continue;
605
+ }
606
+ if (character === "(") {
607
+ parentheses++;
608
+ } else if (character === ")") {
609
+ parentheses = Math.max(0, parentheses - 1);
610
+ } else if (character === "[") {
611
+ brackets++;
612
+ } else if (character === "]") {
613
+ brackets = Math.max(0, brackets - 1);
614
+ } else if (character === "{") {
615
+ braces++;
616
+ } else if (character === "}") {
617
+ if (braces > 0) {
618
+ braces--;
619
+ } else if (parentheses === 0 && brackets === 0) {
620
+ return index;
621
+ }
622
+ } else if (character === ";" && parentheses === 0 && brackets === 0 && braces === 0) {
623
+ return index;
624
+ }
625
+ }
626
+
627
+ return content.length;
628
+ }
629
+
630
+ function skipCssWhitespaceAndComments(content: string, start: number): number {
631
+ let cursor = start;
632
+ while (cursor < content.length) {
633
+ if (/\s/u.test(content[cursor] ?? "")) {
634
+ cursor++;
635
+ continue;
636
+ }
637
+ if (content[cursor] !== "/" || content[cursor + 1] !== "*") break;
638
+ const commentEnd = content.indexOf("*/", cursor + 2);
639
+ if (commentEnd < 0) return content.length;
640
+ cursor = commentEnd + 2;
641
+ }
642
+ return cursor;
643
+ }
644
+
645
+ function stripCssComments(content: string): string {
646
+ let output = "";
647
+ let quote: "'" | '"' | null = null;
648
+ let escaped = false;
649
+ let inComment = false;
650
+
651
+ for (let index = 0; index < content.length; index++) {
652
+ const character = content[index];
653
+ const next = content[index + 1];
654
+ if (inComment) {
655
+ if (character === "*" && next === "/") {
656
+ inComment = false;
657
+ index++;
658
+ }
659
+ continue;
660
+ }
661
+ if (quote) {
662
+ output += character;
663
+ if (escaped) {
664
+ escaped = false;
665
+ } else if (character === "\\") {
666
+ escaped = true;
667
+ } else if (character === quote) {
668
+ quote = null;
669
+ }
670
+ continue;
671
+ }
672
+ if (character === "/" && next === "*") {
673
+ inComment = true;
674
+ index++;
675
+ continue;
676
+ }
677
+ output += character;
678
+ if (character === "'" || character === '"') quote = character;
679
+ }
680
+
681
+ return output;
682
+ }
683
+
684
+ function countNewlinesInRange(content: string, start: number, end: number): number {
685
+ let count = 0;
686
+ for (let index = start; index < end; index++) {
687
+ if (content.charCodeAt(index) === 10) count++;
688
+ }
689
+ return count;
690
+ }
691
+
692
+ function normalizeCssCustomPropertyValue(value: string): string {
693
+ let output = "";
694
+ let quote: "'" | '"' | null = null;
695
+ let escaped = false;
696
+ let pendingWhitespace = false;
697
+
698
+ for (const character of value.trim()) {
699
+ if (quote) {
700
+ output += character;
701
+ if (escaped) {
702
+ escaped = false;
703
+ } else if (character === "\\") {
704
+ escaped = true;
705
+ } else if (character === quote) {
706
+ quote = null;
707
+ }
708
+ continue;
709
+ }
710
+ if (character === "'" || character === '"') {
711
+ if (pendingWhitespace && output) output += " ";
712
+ pendingWhitespace = false;
713
+ quote = character;
714
+ output += character;
715
+ } else if (/\s/u.test(character)) {
716
+ pendingWhitespace = true;
717
+ } else {
718
+ if (pendingWhitespace && output) output += " ";
719
+ pendingWhitespace = false;
720
+ output += character;
407
721
  }
408
722
  }
409
723
 
410
- return currentSelector;
724
+ return output;
411
725
  }
412
726
 
413
- function extractDescription(content: string, line: number): string | undefined {
414
- const lines = content.split("\n");
727
+ function extractDescription(lines: readonly string[], line: number): string | undefined {
415
728
  if (line <= 1) return undefined;
416
729
 
417
730
  const prevLine = lines[line - 2]?.trim();
package/src/types.ts CHANGED
@@ -892,7 +892,7 @@ export type Theme = "light" | "dark";
892
892
  */
893
893
  export interface Screenshot {
894
894
  /** PNG image data */
895
- data: Buffer;
895
+ data: Uint8Array;
896
896
 
897
897
  /** SHA-256 hash of image data (for change detection) */
898
898
  hash: string;
@@ -944,7 +944,7 @@ export interface DiffResult {
944
944
  totalPixels: number;
945
945
 
946
946
  /** PNG image highlighting differences */
947
- diffImage?: Buffer;
947
+ diffImage?: Uint8Array;
948
948
 
949
949
  /** Bounding boxes of changed regions */
950
950
  changedRegions: BoundingBox[];