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.
- package/README.md +24 -0
- package/bin/lib/css/analyze.mjs +149 -0
- package/bin/lib/css/analyze.test.mjs +136 -0
- package/bin/lib/css/collect.mjs +152 -0
- package/bin/lib/css/findings.mjs +79 -0
- package/bin/lib/css/fix.e2e.test.mjs +99 -0
- package/bin/lib/css/fix.mjs +138 -0
- package/bin/lib/css/fix.test.mjs +108 -0
- package/bin/lib/css/index.mjs +16 -0
- package/bin/lib/css/parse.mjs +398 -0
- package/bin/lib/css/parse.test.mjs +146 -0
- package/bin/lib/css/properties.mjs +399 -0
- package/bin/lib/css/repetition.mjs +213 -0
- package/bin/lib/css/repetition.test.mjs +166 -0
- package/bin/lib/css/semantic.mjs +327 -0
- package/bin/lib/css/semantic.test.mjs +201 -0
- package/bin/lib/css/tokens.mjs +119 -0
- package/bin/lib/doctor/doctor.e2e.test.mjs +45 -0
- package/bin/tempest.mjs +169 -7
- package/dist/charts/scales.cjs +2 -0
- package/dist/charts/scales.cjs.map +1 -0
- package/dist/charts/scales.js +29 -0
- package/dist/charts/scales.js.map +1 -0
- package/dist/charts.cjs +1 -1
- package/dist/charts.d.ts +91 -0
- package/dist/charts.js +9 -8
- package/dist/components/BottomNavigation/BottomNavigation.module.cjs.map +1 -1
- package/dist/components/BottomNavigation/BottomNavigation.module.js.map +1 -1
- package/dist/components/Carousel/Carousel.module.cjs.map +1 -1
- package/dist/components/Carousel/Carousel.module.js.map +1 -1
- package/dist/components/CodeBlock/CodeBlock.cjs +3 -0
- package/dist/components/CodeBlock/CodeBlock.cjs.map +1 -0
- package/dist/components/CodeBlock/CodeBlock.js +53 -0
- package/dist/components/CodeBlock/CodeBlock.js.map +1 -0
- package/dist/components/CodeBlock/CodeBlock.module.cjs +2 -0
- package/dist/components/CodeBlock/CodeBlock.module.cjs.map +1 -0
- package/dist/components/CodeBlock/CodeBlock.module.js +29 -0
- package/dist/components/CodeBlock/CodeBlock.module.js.map +1 -0
- package/dist/components/CodeBlock/tokenize.cjs +3 -0
- package/dist/components/CodeBlock/tokenize.cjs.map +1 -0
- package/dist/components/CodeBlock/tokenize.js +267 -0
- package/dist/components/CodeBlock/tokenize.js.map +1 -0
- package/dist/components/Navbar/Navbar.module.cjs.map +1 -1
- package/dist/components/Navbar/Navbar.module.js.map +1 -1
- package/dist/components/QRCode/QRCode.cjs +2 -0
- package/dist/components/QRCode/QRCode.cjs.map +1 -0
- package/dist/components/QRCode/QRCode.js +49 -0
- package/dist/components/QRCode/QRCode.js.map +1 -0
- package/dist/components/QRCode/QRCode.module.cjs +2 -0
- package/dist/components/QRCode/QRCode.module.cjs.map +1 -0
- package/dist/components/QRCode/QRCode.module.js +9 -0
- package/dist/components/QRCode/QRCode.module.js.map +1 -0
- package/dist/components/QRCode/qr-encode.cjs +2 -0
- package/dist/components/QRCode/qr-encode.cjs.map +1 -0
- package/dist/components/QRCode/qr-encode.js +295 -0
- package/dist/components/QRCode/qr-encode.js.map +1 -0
- package/dist/components/QRCode/qr-tables.cjs +2 -0
- package/dist/components/QRCode/qr-tables.cjs.map +1 -0
- package/dist/components/QRCode/qr-tables.js +366 -0
- package/dist/components/QRCode/qr-tables.js.map +1 -0
- package/dist/components/ScrollArea/ScrollArea.cjs +1 -1
- package/dist/components/ScrollArea/ScrollArea.cjs.map +1 -1
- package/dist/components/ScrollArea/ScrollArea.js +22 -16
- package/dist/components/ScrollArea/ScrollArea.js.map +1 -1
- package/dist/components/ScrollArea/ScrollArea.module.cjs.map +1 -1
- package/dist/components/ScrollArea/ScrollArea.module.js.map +1 -1
- package/dist/components/Sparkline/Sparkline.cjs +2 -0
- package/dist/components/Sparkline/Sparkline.cjs.map +1 -0
- package/dist/components/Sparkline/Sparkline.js +64 -0
- package/dist/components/Sparkline/Sparkline.js.map +1 -0
- package/dist/components/Sparkline/Sparkline.module.cjs +2 -0
- package/dist/components/Sparkline/Sparkline.module.cjs.map +1 -0
- package/dist/components/Sparkline/Sparkline.module.js +10 -0
- package/dist/components/Sparkline/Sparkline.module.js.map +1 -0
- package/dist/components/Sparkline/sparkline-geometry.cjs +2 -0
- package/dist/components/Sparkline/sparkline-geometry.cjs.map +1 -0
- package/dist/components/Sparkline/sparkline-geometry.js +56 -0
- package/dist/components/Sparkline/sparkline-geometry.js.map +1 -0
- package/dist/components/Table/Table.cjs +1 -1
- package/dist/components/Table/Table.cjs.map +1 -1
- package/dist/components/Table/Table.js +37 -30
- package/dist/components/Table/Table.js.map +1 -1
- package/dist/components/Table/Table.module.cjs.map +1 -1
- package/dist/components/Table/Table.module.js.map +1 -1
- package/dist/components/VirtualList/VirtualList.cjs +1 -1
- package/dist/components/VirtualList/VirtualList.cjs.map +1 -1
- package/dist/components/VirtualList/VirtualList.js +13 -11
- package/dist/components/VirtualList/VirtualList.js.map +1 -1
- package/dist/components/VirtualList/VirtualList.module.cjs.map +1 -1
- package/dist/components/VirtualList/VirtualList.module.js.map +1 -1
- package/dist/hooks/use-scroll-overflow.cjs +2 -0
- package/dist/hooks/use-scroll-overflow.cjs.map +1 -0
- package/dist/hooks/use-scroll-overflow.js +22 -0
- package/dist/hooks/use-scroll-overflow.js.map +1 -0
- package/dist/styles.css +1 -1
- package/dist/tempest-react-sdk.cjs +1 -1
- package/dist/tempest-react-sdk.d.ts +445 -2
- package/dist/tempest-react-sdk.js +197 -190
- package/dist/theme/create-theme.cjs +3 -3
- package/dist/theme/create-theme.cjs.map +1 -1
- package/dist/theme/create-theme.js +52 -28
- package/dist/theme/create-theme.js.map +1 -1
- package/dist/theme/data-viz-ramps.cjs +2 -0
- package/dist/theme/data-viz-ramps.cjs.map +1 -0
- package/dist/theme/data-viz-ramps.js +58 -0
- package/dist/theme/data-viz-ramps.js.map +1 -0
- 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
|
+
}
|