tempest-react-sdk 0.27.0 → 0.28.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 (107) hide show
  1. package/README.md +24 -0
  2. package/bin/lib/css/analyze.mjs +149 -0
  3. package/bin/lib/css/analyze.test.mjs +136 -0
  4. package/bin/lib/css/collect.mjs +152 -0
  5. package/bin/lib/css/findings.mjs +79 -0
  6. package/bin/lib/css/fix.e2e.test.mjs +99 -0
  7. package/bin/lib/css/fix.mjs +138 -0
  8. package/bin/lib/css/fix.test.mjs +108 -0
  9. package/bin/lib/css/index.mjs +16 -0
  10. package/bin/lib/css/parse.mjs +398 -0
  11. package/bin/lib/css/parse.test.mjs +146 -0
  12. package/bin/lib/css/properties.mjs +399 -0
  13. package/bin/lib/css/repetition.mjs +213 -0
  14. package/bin/lib/css/repetition.test.mjs +166 -0
  15. package/bin/lib/css/semantic.mjs +327 -0
  16. package/bin/lib/css/semantic.test.mjs +201 -0
  17. package/bin/lib/css/tokens.mjs +119 -0
  18. package/bin/lib/doctor/doctor.e2e.test.mjs +45 -0
  19. package/bin/tempest.mjs +169 -7
  20. package/dist/charts/scales.cjs +2 -0
  21. package/dist/charts/scales.cjs.map +1 -0
  22. package/dist/charts/scales.js +29 -0
  23. package/dist/charts/scales.js.map +1 -0
  24. package/dist/charts.cjs +1 -1
  25. package/dist/charts.d.ts +91 -0
  26. package/dist/charts.js +9 -8
  27. package/dist/components/BottomNavigation/BottomNavigation.module.cjs.map +1 -1
  28. package/dist/components/BottomNavigation/BottomNavigation.module.js.map +1 -1
  29. package/dist/components/Carousel/Carousel.module.cjs.map +1 -1
  30. package/dist/components/Carousel/Carousel.module.js.map +1 -1
  31. package/dist/components/CodeBlock/CodeBlock.cjs +3 -0
  32. package/dist/components/CodeBlock/CodeBlock.cjs.map +1 -0
  33. package/dist/components/CodeBlock/CodeBlock.js +53 -0
  34. package/dist/components/CodeBlock/CodeBlock.js.map +1 -0
  35. package/dist/components/CodeBlock/CodeBlock.module.cjs +2 -0
  36. package/dist/components/CodeBlock/CodeBlock.module.cjs.map +1 -0
  37. package/dist/components/CodeBlock/CodeBlock.module.js +29 -0
  38. package/dist/components/CodeBlock/CodeBlock.module.js.map +1 -0
  39. package/dist/components/CodeBlock/tokenize.cjs +3 -0
  40. package/dist/components/CodeBlock/tokenize.cjs.map +1 -0
  41. package/dist/components/CodeBlock/tokenize.js +267 -0
  42. package/dist/components/CodeBlock/tokenize.js.map +1 -0
  43. package/dist/components/Navbar/Navbar.module.cjs.map +1 -1
  44. package/dist/components/Navbar/Navbar.module.js.map +1 -1
  45. package/dist/components/QRCode/QRCode.cjs +2 -0
  46. package/dist/components/QRCode/QRCode.cjs.map +1 -0
  47. package/dist/components/QRCode/QRCode.js +49 -0
  48. package/dist/components/QRCode/QRCode.js.map +1 -0
  49. package/dist/components/QRCode/QRCode.module.cjs +2 -0
  50. package/dist/components/QRCode/QRCode.module.cjs.map +1 -0
  51. package/dist/components/QRCode/QRCode.module.js +9 -0
  52. package/dist/components/QRCode/QRCode.module.js.map +1 -0
  53. package/dist/components/QRCode/qr-encode.cjs +2 -0
  54. package/dist/components/QRCode/qr-encode.cjs.map +1 -0
  55. package/dist/components/QRCode/qr-encode.js +295 -0
  56. package/dist/components/QRCode/qr-encode.js.map +1 -0
  57. package/dist/components/QRCode/qr-tables.cjs +2 -0
  58. package/dist/components/QRCode/qr-tables.cjs.map +1 -0
  59. package/dist/components/QRCode/qr-tables.js +366 -0
  60. package/dist/components/QRCode/qr-tables.js.map +1 -0
  61. package/dist/components/ScrollArea/ScrollArea.cjs +1 -1
  62. package/dist/components/ScrollArea/ScrollArea.cjs.map +1 -1
  63. package/dist/components/ScrollArea/ScrollArea.js +22 -16
  64. package/dist/components/ScrollArea/ScrollArea.js.map +1 -1
  65. package/dist/components/ScrollArea/ScrollArea.module.cjs.map +1 -1
  66. package/dist/components/ScrollArea/ScrollArea.module.js.map +1 -1
  67. package/dist/components/Sparkline/Sparkline.cjs +2 -0
  68. package/dist/components/Sparkline/Sparkline.cjs.map +1 -0
  69. package/dist/components/Sparkline/Sparkline.js +64 -0
  70. package/dist/components/Sparkline/Sparkline.js.map +1 -0
  71. package/dist/components/Sparkline/Sparkline.module.cjs +2 -0
  72. package/dist/components/Sparkline/Sparkline.module.cjs.map +1 -0
  73. package/dist/components/Sparkline/Sparkline.module.js +10 -0
  74. package/dist/components/Sparkline/Sparkline.module.js.map +1 -0
  75. package/dist/components/Sparkline/sparkline-geometry.cjs +2 -0
  76. package/dist/components/Sparkline/sparkline-geometry.cjs.map +1 -0
  77. package/dist/components/Sparkline/sparkline-geometry.js +56 -0
  78. package/dist/components/Sparkline/sparkline-geometry.js.map +1 -0
  79. package/dist/components/Table/Table.cjs +1 -1
  80. package/dist/components/Table/Table.cjs.map +1 -1
  81. package/dist/components/Table/Table.js +37 -30
  82. package/dist/components/Table/Table.js.map +1 -1
  83. package/dist/components/Table/Table.module.cjs.map +1 -1
  84. package/dist/components/Table/Table.module.js.map +1 -1
  85. package/dist/components/VirtualList/VirtualList.cjs +1 -1
  86. package/dist/components/VirtualList/VirtualList.cjs.map +1 -1
  87. package/dist/components/VirtualList/VirtualList.js +13 -11
  88. package/dist/components/VirtualList/VirtualList.js.map +1 -1
  89. package/dist/components/VirtualList/VirtualList.module.cjs.map +1 -1
  90. package/dist/components/VirtualList/VirtualList.module.js.map +1 -1
  91. package/dist/hooks/use-scroll-overflow.cjs +2 -0
  92. package/dist/hooks/use-scroll-overflow.cjs.map +1 -0
  93. package/dist/hooks/use-scroll-overflow.js +22 -0
  94. package/dist/hooks/use-scroll-overflow.js.map +1 -0
  95. package/dist/styles.css +1 -1
  96. package/dist/tempest-react-sdk.cjs +1 -1
  97. package/dist/tempest-react-sdk.d.ts +445 -2
  98. package/dist/tempest-react-sdk.js +197 -190
  99. package/dist/theme/create-theme.cjs +3 -3
  100. package/dist/theme/create-theme.cjs.map +1 -1
  101. package/dist/theme/create-theme.js +52 -28
  102. package/dist/theme/create-theme.js.map +1 -1
  103. package/dist/theme/data-viz-ramps.cjs +2 -0
  104. package/dist/theme/data-viz-ramps.cjs.map +1 -0
  105. package/dist/theme/data-viz-ramps.js +58 -0
  106. package/dist/theme/data-viz-ramps.js.map +1 -0
  107. package/package.json +2 -1
@@ -0,0 +1,138 @@
1
+ // The `tempest fix` CSS pass. Removes only what is provably dead:
2
+ //
3
+ // 1. a declaration repeated with the identical value in the same rule,
4
+ // 2. a rule that repeats an earlier one declaration for declaration,
5
+ // 3. an empty rule in a plain stylesheet.
6
+ //
7
+ // Nothing here changes what the browser computes. The **earlier** copy is always
8
+ // the one removed, because CSS is last-wins: dropping the later one would change
9
+ // the result whenever something in between touches the same property.
10
+ //
11
+ // Everything else the analysis finds — an override with a different value, a
12
+ // misspelled property, a repeated block that wants to be a global class — is
13
+ // reported and left alone. Guessing which of two conflicting values the author
14
+ // meant is not a fix, it is a coin flip inside somebody's design.
15
+ import { declSignature, ruleKey } from "./semantic.mjs";
16
+
17
+ /**
18
+ * Grow a removal range to swallow the leading indentation and the trailing
19
+ * whitespace, so deleting a declaration leaves neither a blank indented line nor
20
+ * a double space where it used to sit on a one-line rule.
21
+ *
22
+ * @param {string} text
23
+ * @param {number} start
24
+ * @param {number} end
25
+ * @returns {{ start: number, end: number }}
26
+ */
27
+ function expandRange(text, start, end) {
28
+ let from = start;
29
+ while (from > 0 && (text[from - 1] === " " || text[from - 1] === "\t")) from -= 1;
30
+ if (from > 0 && text[from - 1] !== "\n") from = start;
31
+
32
+ let to = end;
33
+ while (to < text.length && (text[to] === " " || text[to] === "\t")) to += 1;
34
+ if (text[to] === "\r") to += 1;
35
+ if (text[to] === "\n") to += 1;
36
+
37
+ return { start: from, end: to };
38
+ }
39
+
40
+ /**
41
+ * Compute the edits for one stylesheet.
42
+ *
43
+ * @param {object} params
44
+ * @param {string} params.text - Original file contents.
45
+ * @param {ReturnType<import("./parse.mjs").parseCss>} params.parsed
46
+ * @param {boolean} [params.isModule] - CSS Module: empty rules are kept.
47
+ * @returns {{ text: string, changes: Array<{ code: string, line: number, message: string }> }}
48
+ */
49
+ export function fixCss({ text, parsed, isModule = false }) {
50
+ if (parsed.errors.length > 0) return { text, changes: [] };
51
+
52
+ const edits = [];
53
+ const changes = [];
54
+ const byKey = new Map();
55
+
56
+ for (const block of parsed.blocks) {
57
+ const removable = block.end !== null && block.start < block.end;
58
+
59
+ if (block.kind === "rule" && removable) {
60
+ const key = ruleKey(block);
61
+ const previous = byKey.get(key);
62
+ if (
63
+ previous &&
64
+ block.decls.length > 0 &&
65
+ declSignature(block) === declSignature(previous.block) &&
66
+ previous.removable
67
+ ) {
68
+ edits.push({ ...expandRange(text, previous.block.start, previous.block.end) });
69
+ changes.push({
70
+ code: "duplicate-rule",
71
+ line: previous.block.line,
72
+ message: `removed \`${previous.block.prelude}\` — line ${block.line} repeats it exactly`,
73
+ });
74
+ }
75
+ byKey.set(key, { block, removable });
76
+ }
77
+
78
+ if (block.decls.length === 0 && block.children === 0 && !isModule && removable) {
79
+ edits.push({ ...expandRange(text, block.start, block.end) });
80
+ changes.push({
81
+ code: "empty-rule",
82
+ line: block.line,
83
+ message: `removed empty rule \`${block.prelude}\``,
84
+ });
85
+ continue;
86
+ }
87
+
88
+ const lastByProp = new Map();
89
+ for (const decl of block.decls) {
90
+ const prop = decl.prop.toLowerCase();
91
+ const value = decl.value.replace(/\s+/g, " ").toLowerCase();
92
+ const previous = lastByProp.get(prop);
93
+ if (previous && previous.value === value) {
94
+ edits.push({ ...expandRange(text, previous.decl.start, previous.decl.end) });
95
+ changes.push({
96
+ code: "duplicate-declaration",
97
+ line: previous.decl.line,
98
+ message: `removed duplicate \`${prop}\` — line ${decl.line} declares the same value`,
99
+ });
100
+ }
101
+ lastByProp.set(prop, { decl, value });
102
+ }
103
+ }
104
+
105
+ if (edits.length === 0) return { text, changes: [] };
106
+
107
+ const applied = dropOverlaps(edits);
108
+ let out = text;
109
+ for (const edit of applied) out = out.slice(0, edit.start) + out.slice(edit.end);
110
+
111
+ return {
112
+ text: out,
113
+ changes: changes.sort((a, b) => a.line - b.line),
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Keep the widest non-overlapping edits, ordered back-to-front for splicing.
119
+ *
120
+ * A duplicate declaration inside a rule that is itself a duplicate produces two
121
+ * overlapping removals. The enclosing one wins — dropping the whole rule already
122
+ * drops the declaration — and applying both would splice at an offset that no
123
+ * longer exists.
124
+ *
125
+ * @param {Array<{ start: number, end: number }>} edits
126
+ * @returns {Array<{ start: number, end: number }>} Descending by `start`.
127
+ */
128
+ function dropOverlaps(edits) {
129
+ const sorted = [...edits].sort((a, b) => a.start - b.start || b.end - a.end);
130
+ const kept = [];
131
+ let reach = -1;
132
+ for (const edit of sorted) {
133
+ if (edit.start < reach) continue;
134
+ kept.push(edit);
135
+ reach = edit.end;
136
+ }
137
+ return kept.reverse();
138
+ }
@@ -0,0 +1,108 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { fixCss } from "./fix.mjs";
4
+ import { parseCss } from "./parse.mjs";
5
+
6
+ const fix = (text, isModule = false) => fixCss({ text, parsed: parseCss(text), isModule });
7
+
8
+ describe("fixCss — duplicate declarations", () => {
9
+ it("removes the earlier copy and keeps the cascade position of the later one", () => {
10
+ const { text, changes } = fix(
11
+ ".a {\n color: red;\n padding: 0;\n color: red;\n}\n",
12
+ );
13
+ expect(text).toBe(".a {\n padding: 0;\n color: red;\n}\n");
14
+ expect(changes).toEqual([
15
+ {
16
+ code: "duplicate-declaration",
17
+ line: 2,
18
+ message: "removed duplicate `color` — line 4 declares the same value",
19
+ },
20
+ ]);
21
+ });
22
+
23
+ it("treats values that differ only in whitespace or case as the same", () => {
24
+ const { text } = fix(".a {\n color: RED;\n color: red;\n}\n");
25
+ expect(text).toBe(".a {\n color: red;\n}\n");
26
+ });
27
+
28
+ it("leaves an override with a different value alone", () => {
29
+ const text = ".a {\n color: red;\n color: blue;\n}\n";
30
+ expect(fix(text).text).toBe(text);
31
+ });
32
+
33
+ it("keeps a declaration that is duplicated in a different rule", () => {
34
+ const text = ".a { color: red; }\n.b { color: red; }\n";
35
+ expect(fix(text).text).toBe(text);
36
+ });
37
+ });
38
+
39
+ describe("fixCss — duplicate rules", () => {
40
+ it("removes an exact earlier repeat", () => {
41
+ const { text, changes } = fix(".a {\n color: red;\n}\n.a {\n color: red;\n}\n");
42
+ expect(text).toBe(".a {\n color: red;\n}\n");
43
+ expect(changes[0].code).toBe("duplicate-rule");
44
+ });
45
+
46
+ it("keeps two rules that declare different values", () => {
47
+ const text = ".a {\n color: red;\n}\n.a {\n color: blue;\n}\n";
48
+ expect(fix(text).text).toBe(text);
49
+ });
50
+
51
+ it("does not merge rules across at-rule contexts", () => {
52
+ const text =
53
+ ".a {\n color: red;\n}\n@media print {\n .a {\n color: red;\n }\n}\n";
54
+ expect(fix(text).text).toBe(text);
55
+ });
56
+
57
+ it("does not apply an inner and an outer removal that overlap", () => {
58
+ const { text, changes } = fix(
59
+ ".a {\n color: red;\n color: red;\n}\n.a {\n color: red;\n color: red;\n}\n",
60
+ );
61
+ expect(text).toBe(".a {\n color: red;\n}\n");
62
+ expect(changes.map((c) => c.code)).toContain("duplicate-rule");
63
+ });
64
+ });
65
+
66
+ describe("fixCss — empty rules", () => {
67
+ it("removes an empty rule from a plain sheet", () => {
68
+ const { text, changes } = fix(".a {\n color: red;\n}\n.gone {\n}\n");
69
+ expect(text).toBe(".a {\n color: red;\n}\n");
70
+ expect(changes[0].code).toBe("empty-rule");
71
+ });
72
+
73
+ it("keeps an empty rule in a CSS Module", () => {
74
+ const text = ".marker {\n}\n";
75
+ expect(fix(text, true).text).toBe(text);
76
+ });
77
+
78
+ it("keeps a rule that only holds nested rules", () => {
79
+ const text = ".a {\n &:hover {\n color: red;\n }\n}\n";
80
+ expect(fix(text).text).toBe(text);
81
+ });
82
+ });
83
+
84
+ describe("fixCss — refusals", () => {
85
+ it("refuses to touch a sheet with a syntax error", () => {
86
+ const text = ".a {\n color: red;\n color: red;\n";
87
+ const { text: out, changes } = fix(text);
88
+ expect(out).toBe(text);
89
+ expect(changes).toEqual([]);
90
+ });
91
+
92
+ it("returns the input unchanged when there is nothing to remove", () => {
93
+ const text = ".a {\n color: red;\n}\n";
94
+ const result = fix(text);
95
+ expect(result.text).toBe(text);
96
+ expect(result.changes).toEqual([]);
97
+ });
98
+
99
+ it("keeps the comment above a surviving declaration", () => {
100
+ const { text } = fix(".a {\n color: red;\n /* why */\n color: red;\n}\n");
101
+ expect(text).toBe(".a {\n /* why */\n color: red;\n}\n");
102
+ });
103
+
104
+ it("survives a one-line rule", () => {
105
+ const { text } = fix(".a { color: red; color: red; }\n");
106
+ expect(text).toBe(".a { color: red; }\n");
107
+ });
108
+ });
@@ -0,0 +1,16 @@
1
+ // Public surface of the CSS analysis used by `tempest doctor` and `tempest fix`.
2
+ export { analyzeCss, applyCssFixes } from "./analyze.mjs";
3
+ export { collectStylesheets, customPropertiesInSources, looksMinified } from "./collect.mjs";
4
+ export { countBySeverity, finding, SEVERITY, sortFindings } from "./findings.mjs";
5
+ export { fixCss } from "./fix.mjs";
6
+ export { maskValue, normalizeSelectors, parseCss, stripComments } from "./parse.mjs";
7
+ export { distance, KNOWN_AT_RULES, KNOWN_PROPERTIES, nearest } from "./properties.mjs";
8
+ export { matchUtility, repetitionFindings } from "./repetition.mjs";
9
+ export {
10
+ analyzeParsed,
11
+ declSignature,
12
+ definedCustomProperties,
13
+ missingSemicolons,
14
+ ruleKey,
15
+ } from "./semantic.mjs";
16
+ export { loadTokens, tokenForValue } from "./tokens.mjs";
@@ -0,0 +1,398 @@
1
+ // A tolerant CSS scanner: turns a stylesheet into blocks + declarations and
2
+ // records every syntax defect it walks past instead of throwing.
3
+ //
4
+ // Tolerant on purpose. A linter that dies on the first unbalanced brace reports
5
+ // one problem per run, and the file it refuses to read is exactly the file that
6
+ // needs reading. This one keeps scanning: a stray `}` is recorded and the walk
7
+ // continues, so a single pass finds every defect in the sheet.
8
+ //
9
+ // Offsets are kept truthful (every block and declaration carries `start`/`end`)
10
+ // because `tempest fix` splices the original text — it never re-prints the AST,
11
+ // so formatting, comments and author intent survive a fix pass untouched.
12
+
13
+ /** Number of newlines in a string. */
14
+ function countNewlines(text) {
15
+ let n = 0;
16
+ for (let i = 0; i < text.length; i += 1) if (text[i] === "\n") n += 1;
17
+ return n;
18
+ }
19
+
20
+ /**
21
+ * Index of the first character that is neither whitespace nor part of a
22
+ * comment, so a declaration's reported line points at the declaration and not
23
+ * at the blank line or the comment above it.
24
+ *
25
+ * @param {string} raw
26
+ * @returns {number} Index into `raw`; `raw.length` when it holds only trivia.
27
+ */
28
+ function contentStart(raw) {
29
+ let i = 0;
30
+ while (i < raw.length) {
31
+ const ch = raw[i];
32
+ if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r" || ch === "\f") {
33
+ i += 1;
34
+ continue;
35
+ }
36
+ if (ch === "/" && raw[i + 1] === "*") {
37
+ const close = raw.indexOf("*/", i + 2);
38
+ if (close < 0) return raw.length;
39
+ i = close + 2;
40
+ continue;
41
+ }
42
+ return i;
43
+ }
44
+ return i;
45
+ }
46
+
47
+ /**
48
+ * Drop `/* … *\/` comments, keeping everything else byte-for-byte.
49
+ *
50
+ * @param {string} raw
51
+ * @returns {string}
52
+ */
53
+ export function stripComments(raw) {
54
+ let out = "";
55
+ let i = 0;
56
+ while (i < raw.length) {
57
+ if (raw[i] === "/" && raw[i + 1] === "*") {
58
+ const close = raw.indexOf("*/", i + 2);
59
+ if (close < 0) break;
60
+ i = close + 2;
61
+ out += " ";
62
+ continue;
63
+ }
64
+ if (raw[i] === '"' || raw[i] === "'") {
65
+ const quote = raw[i];
66
+ let j = i + 1;
67
+ while (j < raw.length && raw[j] !== quote) j += raw[j] === "\\" ? 2 : 1;
68
+ out += raw.slice(i, Math.min(j + 1, raw.length));
69
+ i = j + 1;
70
+ continue;
71
+ }
72
+ out += raw[i];
73
+ i += 1;
74
+ }
75
+ return out;
76
+ }
77
+
78
+ /**
79
+ * Blank out quoted strings and parenthesized groups, so a scan for a delimiter
80
+ * cannot trip over one that lives inside `url(…)`, `calc(…)` or a string.
81
+ *
82
+ * @param {string} value
83
+ * @returns {string} Same length as `value`, with those spans replaced by spaces.
84
+ */
85
+ export function maskValue(value) {
86
+ let out = "";
87
+ let depth = 0;
88
+ let quote = null;
89
+ for (let i = 0; i < value.length; i += 1) {
90
+ const ch = value[i];
91
+ if (quote) {
92
+ out += ch === "\n" ? "\n" : " ";
93
+ if (ch === quote) quote = null;
94
+ else if (ch === "\\") {
95
+ out += " ";
96
+ i += 1;
97
+ }
98
+ continue;
99
+ }
100
+ if (ch === '"' || ch === "'") {
101
+ quote = ch;
102
+ out += " ";
103
+ continue;
104
+ }
105
+ if (ch === "(") {
106
+ depth += 1;
107
+ out += " ";
108
+ continue;
109
+ }
110
+ if (ch === ")") {
111
+ depth = Math.max(0, depth - 1);
112
+ out += " ";
113
+ continue;
114
+ }
115
+ out += depth > 0 && ch !== "\n" ? " " : ch;
116
+ }
117
+ return out;
118
+ }
119
+
120
+ /** Index of the first `:` outside strings and parens, or -1. */
121
+ function colonIndex(raw) {
122
+ return maskValue(raw).indexOf(":");
123
+ }
124
+
125
+ /**
126
+ * Parse a stylesheet.
127
+ *
128
+ * @param {string} text - Raw file contents.
129
+ * @returns {{
130
+ * blocks: Array<{
131
+ * kind: "rule" | "at",
132
+ * prelude: string,
133
+ * context: string[],
134
+ * line: number,
135
+ * endLine: number | null,
136
+ * start: number,
137
+ * bodyStart: number,
138
+ * end: number | null,
139
+ * decls: Array<{ prop: string, value: string, line: number, start: number, end: number }>,
140
+ * children: number,
141
+ * invalid: boolean,
142
+ * }>,
143
+ * statements: Array<{ name: string, params: string, line: number }>,
144
+ * errors: Array<{ code: string, line: number, text?: string }>,
145
+ * }}
146
+ */
147
+ export function parseCss(text) {
148
+ const blocks = [];
149
+ const statements = [];
150
+ const errors = [];
151
+ const stack = [];
152
+
153
+ let i = 0;
154
+ let line = 1;
155
+ let bufStart = 0;
156
+ let bufLine = 1;
157
+
158
+ const reset = () => {
159
+ bufStart = i;
160
+ bufLine = line;
161
+ };
162
+
163
+ /**
164
+ * Record a defect, and mark the block it happened inside.
165
+ *
166
+ * The mark is what keeps one mistake from being reported as two: a rule whose
167
+ * only declaration the parser had to reject looks empty afterwards, and
168
+ * "this rule is dead code" is a lie about a rule that is merely broken.
169
+ */
170
+ const pushError = (code, errorLine, text) => {
171
+ errors.push(
172
+ text === undefined ? { code, line: errorLine } : { code, line: errorLine, text },
173
+ );
174
+ const block = stack[stack.length - 1];
175
+ if (block) block.invalid = true;
176
+ };
177
+
178
+ const pending = () => {
179
+ const raw = text.slice(bufStart, i);
180
+ const offset = contentStart(raw);
181
+ return {
182
+ raw,
183
+ body: stripComments(raw.slice(offset)).trim(),
184
+ start: bufStart + offset,
185
+ line: bufLine + countNewlines(raw.slice(0, offset)),
186
+ };
187
+ };
188
+
189
+ /**
190
+ * Turn the pending buffer into a declaration (or an at-statement), pushing a
191
+ * diagnostic when it is neither.
192
+ */
193
+ const flushDecl = (end) => {
194
+ const { body, start, line: declLine } = pending();
195
+ if (!body) return;
196
+ if (body.startsWith("@")) {
197
+ const match = /^@([\w-]+)\s*([\s\S]*)$/.exec(body);
198
+ statements.push({
199
+ name: (match?.[1] ?? "").toLowerCase(),
200
+ params: (match?.[2] ?? "").trim(),
201
+ line: declLine,
202
+ });
203
+ return;
204
+ }
205
+ const block = stack[stack.length - 1];
206
+ if (!block) {
207
+ pushError("declaration-outside-rule", declLine, body);
208
+ return;
209
+ }
210
+ const colon = colonIndex(body);
211
+ if (colon < 0) {
212
+ pushError("missing-colon", declLine, body);
213
+ return;
214
+ }
215
+ const prop = body.slice(0, colon).trim();
216
+ const value = body.slice(colon + 1).trim();
217
+ if (!prop) {
218
+ pushError("missing-property", declLine, body);
219
+ return;
220
+ }
221
+ if (!value) {
222
+ pushError("empty-value", declLine, prop);
223
+ return;
224
+ }
225
+ block.decls.push({ prop, value, line: declLine, start, end });
226
+ };
227
+
228
+ while (i < text.length) {
229
+ const ch = text[i];
230
+
231
+ if (ch === "\n") {
232
+ line += 1;
233
+ i += 1;
234
+ continue;
235
+ }
236
+
237
+ if (ch === "/" && text[i + 1] === "*") {
238
+ const close = text.indexOf("*/", i + 2);
239
+ if (close < 0) {
240
+ pushError("unterminated-comment", line);
241
+ line += countNewlines(text.slice(i));
242
+ i = text.length;
243
+ continue;
244
+ }
245
+ line += countNewlines(text.slice(i, close));
246
+ i = close + 2;
247
+ continue;
248
+ }
249
+
250
+ if (ch === '"' || ch === "'") {
251
+ let j = i + 1;
252
+ let closed = false;
253
+ while (j < text.length) {
254
+ if (text[j] === "\\") {
255
+ j += 2;
256
+ continue;
257
+ }
258
+ if (text[j] === ch) {
259
+ closed = true;
260
+ break;
261
+ }
262
+ if (text[j] === "\n") break;
263
+ j += 1;
264
+ }
265
+ if (!closed) pushError("unterminated-string", line);
266
+ line += countNewlines(text.slice(i, Math.min(j + 1, text.length)));
267
+ i = Math.min(j + 1, text.length);
268
+ continue;
269
+ }
270
+
271
+ if (ch === "(") {
272
+ let depth = 0;
273
+ let j = i;
274
+ let quote = null;
275
+ while (j < text.length) {
276
+ const cj = text[j];
277
+ if (quote) {
278
+ if (cj === "\\") j += 1;
279
+ else if (cj === quote) quote = null;
280
+ } else if (cj === '"' || cj === "'") quote = cj;
281
+ else if (cj === "(") depth += 1;
282
+ else if (cj === ")") {
283
+ depth -= 1;
284
+ if (depth === 0) break;
285
+ }
286
+ j += 1;
287
+ }
288
+ if (depth !== 0) pushError("unterminated-paren", line);
289
+ line += countNewlines(text.slice(i, Math.min(j + 1, text.length)));
290
+ i = Math.min(j + 1, text.length);
291
+ continue;
292
+ }
293
+
294
+ if (ch === "{") {
295
+ const { body, start, line: preludeLine } = pending();
296
+ if (!body) pushError("empty-selector", line);
297
+ const parent = stack[stack.length - 1];
298
+ if (parent) parent.children += 1;
299
+ const block = {
300
+ kind: body.startsWith("@") ? "at" : "rule",
301
+ prelude: body,
302
+ context: stack.map((b) => b.prelude),
303
+ line: preludeLine,
304
+ endLine: null,
305
+ start,
306
+ bodyStart: i + 1,
307
+ end: null,
308
+ decls: [],
309
+ children: 0,
310
+ invalid: false,
311
+ };
312
+ blocks.push(block);
313
+ stack.push(block);
314
+ i += 1;
315
+ reset();
316
+ continue;
317
+ }
318
+
319
+ if (ch === "}") {
320
+ flushDecl(i);
321
+ const block = stack.pop();
322
+ if (!block) pushError("unexpected-brace", line);
323
+ else {
324
+ block.endLine = line;
325
+ block.end = i + 1;
326
+ }
327
+ i += 1;
328
+ reset();
329
+ continue;
330
+ }
331
+
332
+ if (ch === ";") {
333
+ flushDecl(i + 1);
334
+ i += 1;
335
+ reset();
336
+ continue;
337
+ }
338
+
339
+ i += 1;
340
+ }
341
+
342
+ const { body: trailing, line: trailingLine } = pending();
343
+ if (stack.length > 0) {
344
+ flushDecl(i);
345
+ for (const block of stack) {
346
+ block.invalid = true;
347
+ errors.push({ code: "unclosed-block", line: block.line });
348
+ }
349
+ } else if (trailing.startsWith("@")) {
350
+ flushDecl(i);
351
+ } else if (trailing && colonIndex(trailing) >= 0) {
352
+ errors.push({ code: "declaration-outside-rule", line: trailingLine, text: trailing });
353
+ } else if (trailing) {
354
+ errors.push({ code: "missing-brace", line: trailingLine, text: trailing });
355
+ }
356
+
357
+ return { blocks, statements, errors };
358
+ }
359
+
360
+ /**
361
+ * Selector list of a rule, normalized so two spellings of the same target
362
+ * compare equal: whitespace collapsed, combinators spaced, list order sorted
363
+ * (`.a, .b` and `.b, .a` select the same set).
364
+ *
365
+ * @param {string} prelude
366
+ * @returns {string[]}
367
+ */
368
+ export function normalizeSelectors(prelude) {
369
+ return maskCommas(prelude)
370
+ .map((part) =>
371
+ part
372
+ .trim()
373
+ .replace(/\s*([>+~])\s*/g, " $1 ")
374
+ .replace(/\s+/g, " "),
375
+ )
376
+ .filter(Boolean)
377
+ .sort();
378
+ }
379
+
380
+ /**
381
+ * Split a selector list on top-level commas — a comma inside `:is(a, b)` or
382
+ * `[title="a,b"]` does not start a new selector.
383
+ *
384
+ * @param {string} prelude
385
+ * @returns {string[]}
386
+ */
387
+ function maskCommas(prelude) {
388
+ const mask = maskValue(prelude);
389
+ const out = [];
390
+ let last = 0;
391
+ for (let i = 0; i < mask.length; i += 1) {
392
+ if (mask[i] !== ",") continue;
393
+ out.push(prelude.slice(last, i));
394
+ last = i + 1;
395
+ }
396
+ out.push(prelude.slice(last));
397
+ return out;
398
+ }