tempest-react-sdk 0.26.1 → 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 (132) hide show
  1. package/README.md +25 -1
  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 +246 -0
  19. package/bin/tempest.mjs +272 -36
  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/ImageCropper/ImageCropper.cjs +2 -0
  44. package/dist/components/ImageCropper/ImageCropper.cjs.map +1 -0
  45. package/dist/components/ImageCropper/ImageCropper.js +240 -0
  46. package/dist/components/ImageCropper/ImageCropper.js.map +1 -0
  47. package/dist/components/ImageCropper/ImageCropper.module.cjs +2 -0
  48. package/dist/components/ImageCropper/ImageCropper.module.cjs.map +1 -0
  49. package/dist/components/ImageCropper/ImageCropper.module.js +15 -0
  50. package/dist/components/ImageCropper/ImageCropper.module.js.map +1 -0
  51. package/dist/components/ImageCropper/crop-geometry.cjs +2 -0
  52. package/dist/components/ImageCropper/crop-geometry.cjs.map +1 -0
  53. package/dist/components/ImageCropper/crop-geometry.js +52 -0
  54. package/dist/components/ImageCropper/crop-geometry.js.map +1 -0
  55. package/dist/components/Navbar/Navbar.module.cjs.map +1 -1
  56. package/dist/components/Navbar/Navbar.module.js.map +1 -1
  57. package/dist/components/QRCode/QRCode.cjs +2 -0
  58. package/dist/components/QRCode/QRCode.cjs.map +1 -0
  59. package/dist/components/QRCode/QRCode.js +49 -0
  60. package/dist/components/QRCode/QRCode.js.map +1 -0
  61. package/dist/components/QRCode/QRCode.module.cjs +2 -0
  62. package/dist/components/QRCode/QRCode.module.cjs.map +1 -0
  63. package/dist/components/QRCode/QRCode.module.js +9 -0
  64. package/dist/components/QRCode/QRCode.module.js.map +1 -0
  65. package/dist/components/QRCode/qr-encode.cjs +2 -0
  66. package/dist/components/QRCode/qr-encode.cjs.map +1 -0
  67. package/dist/components/QRCode/qr-encode.js +295 -0
  68. package/dist/components/QRCode/qr-encode.js.map +1 -0
  69. package/dist/components/QRCode/qr-tables.cjs +2 -0
  70. package/dist/components/QRCode/qr-tables.cjs.map +1 -0
  71. package/dist/components/QRCode/qr-tables.js +366 -0
  72. package/dist/components/QRCode/qr-tables.js.map +1 -0
  73. package/dist/components/Scheduler/Scheduler.cjs +2 -0
  74. package/dist/components/Scheduler/Scheduler.cjs.map +1 -0
  75. package/dist/components/Scheduler/Scheduler.js +134 -0
  76. package/dist/components/Scheduler/Scheduler.js.map +1 -0
  77. package/dist/components/Scheduler/Scheduler.module.cjs +2 -0
  78. package/dist/components/Scheduler/Scheduler.module.cjs.map +1 -0
  79. package/dist/components/Scheduler/Scheduler.module.js +26 -0
  80. package/dist/components/Scheduler/Scheduler.module.js.map +1 -0
  81. package/dist/components/Scheduler/scheduler-layout.cjs +2 -0
  82. package/dist/components/Scheduler/scheduler-layout.cjs.map +1 -0
  83. package/dist/components/Scheduler/scheduler-layout.js +97 -0
  84. package/dist/components/Scheduler/scheduler-layout.js.map +1 -0
  85. package/dist/components/ScrollArea/ScrollArea.cjs +1 -1
  86. package/dist/components/ScrollArea/ScrollArea.cjs.map +1 -1
  87. package/dist/components/ScrollArea/ScrollArea.js +22 -16
  88. package/dist/components/ScrollArea/ScrollArea.js.map +1 -1
  89. package/dist/components/ScrollArea/ScrollArea.module.cjs.map +1 -1
  90. package/dist/components/ScrollArea/ScrollArea.module.js.map +1 -1
  91. package/dist/components/Sparkline/Sparkline.cjs +2 -0
  92. package/dist/components/Sparkline/Sparkline.cjs.map +1 -0
  93. package/dist/components/Sparkline/Sparkline.js +64 -0
  94. package/dist/components/Sparkline/Sparkline.js.map +1 -0
  95. package/dist/components/Sparkline/Sparkline.module.cjs +2 -0
  96. package/dist/components/Sparkline/Sparkline.module.cjs.map +1 -0
  97. package/dist/components/Sparkline/Sparkline.module.js +10 -0
  98. package/dist/components/Sparkline/Sparkline.module.js.map +1 -0
  99. package/dist/components/Sparkline/sparkline-geometry.cjs +2 -0
  100. package/dist/components/Sparkline/sparkline-geometry.cjs.map +1 -0
  101. package/dist/components/Sparkline/sparkline-geometry.js +56 -0
  102. package/dist/components/Sparkline/sparkline-geometry.js.map +1 -0
  103. package/dist/components/Table/Table.cjs +1 -1
  104. package/dist/components/Table/Table.cjs.map +1 -1
  105. package/dist/components/Table/Table.js +37 -30
  106. package/dist/components/Table/Table.js.map +1 -1
  107. package/dist/components/Table/Table.module.cjs.map +1 -1
  108. package/dist/components/Table/Table.module.js.map +1 -1
  109. package/dist/components/VirtualList/VirtualList.cjs +1 -1
  110. package/dist/components/VirtualList/VirtualList.cjs.map +1 -1
  111. package/dist/components/VirtualList/VirtualList.js +13 -11
  112. package/dist/components/VirtualList/VirtualList.js.map +1 -1
  113. package/dist/components/VirtualList/VirtualList.module.cjs.map +1 -1
  114. package/dist/components/VirtualList/VirtualList.module.js.map +1 -1
  115. package/dist/hooks/use-scroll-overflow.cjs +2 -0
  116. package/dist/hooks/use-scroll-overflow.cjs.map +1 -0
  117. package/dist/hooks/use-scroll-overflow.js +22 -0
  118. package/dist/hooks/use-scroll-overflow.js.map +1 -0
  119. package/dist/styles.css +1 -1
  120. package/dist/tempest-react-sdk.cjs +1 -1
  121. package/dist/tempest-react-sdk.d.ts +607 -2
  122. package/dist/tempest-react-sdk.js +197 -188
  123. package/dist/theme/create-theme.cjs +3 -3
  124. package/dist/theme/create-theme.cjs.map +1 -1
  125. package/dist/theme/create-theme.js +52 -28
  126. package/dist/theme/create-theme.js.map +1 -1
  127. package/dist/theme/data-viz-ramps.cjs +2 -0
  128. package/dist/theme/data-viz-ramps.cjs.map +1 -0
  129. package/dist/theme/data-viz-ramps.js +58 -0
  130. package/dist/theme/data-viz-ramps.js.map +1 -0
  131. package/package.json +2 -1
  132. package/template/package.json +1 -1
@@ -0,0 +1,327 @@
1
+ // Per-file CSS analysis: what the parser flagged as broken syntax, plus what is
2
+ // syntactically fine and still wrong — a declaration the next one kills, a
3
+ // property name nobody implements, a `var()` pointing at a token that does not
4
+ // exist.
5
+ import { finding } from "./findings.mjs";
6
+ import { maskValue, normalizeSelectors } from "./parse.mjs";
7
+ import { isCheckableProperty, KNOWN_AT_RULES, KNOWN_PROPERTIES, nearest } from "./properties.mjs";
8
+ import { tokenForValue } from "./tokens.mjs";
9
+
10
+ /** Human text for each parser-level code. */
11
+ const SYNTAX_MESSAGES = {
12
+ "unterminated-comment": "unterminated `/*` comment — everything after it is swallowed",
13
+ "unterminated-string": "unterminated string — the rest of the line is parsed as part of it",
14
+ "unterminated-paren": "unbalanced `(` — the rest of the sheet is parsed as part of the value",
15
+ "unclosed-block": "block opened here is never closed with `}`",
16
+ "unexpected-brace": "stray `}` with no block open",
17
+ "missing-brace": "selector with no `{ … }` block",
18
+ "missing-colon": "declaration without `:`",
19
+ "missing-property": "declaration with no property name before `:`",
20
+ "empty-value": "declaration with an empty value — the browser drops it",
21
+ "empty-selector": "`{` with no selector before it",
22
+ "declaration-outside-rule": "declaration outside any rule — the browser drops it",
23
+ };
24
+
25
+ /** Properties whose literal values are worth suggesting a token for. */
26
+ const TOKENIZABLE = new Set([
27
+ "color",
28
+ "background",
29
+ "background-color",
30
+ "border-color",
31
+ "border-top-color",
32
+ "border-right-color",
33
+ "border-bottom-color",
34
+ "border-left-color",
35
+ "outline-color",
36
+ "fill",
37
+ "stroke",
38
+ "box-shadow",
39
+ "border-radius",
40
+ "font-size",
41
+ "font-family",
42
+ "font-weight",
43
+ "line-height",
44
+ "gap",
45
+ "row-gap",
46
+ "column-gap",
47
+ "padding",
48
+ "margin",
49
+ "z-index",
50
+ "transition-duration",
51
+ ]);
52
+
53
+ /**
54
+ * True when a second declaration of the same property is the progressive-
55
+ * enhancement idiom rather than a mistake.
56
+ *
57
+ * The pattern is deliberate and everywhere: write a value old browsers
58
+ * understand, then the modern one they will ignore. Flagging it would train
59
+ * people to ignore the whole check, so anything that smells like the modern half
60
+ * — a vendor prefix, a function call, a viewport unit, an `!important` escalation
61
+ * — exempts the pair.
62
+ *
63
+ * @param {{ value: string }} earlier
64
+ * @param {{ value: string }} later
65
+ * @returns {boolean}
66
+ */
67
+ function isProgressiveFallback(earlier, later) {
68
+ const modern = /\b(var|env|clamp|min|max|calc|color-mix|light-dark|oklch|lab)\(/i;
69
+ const units = /\b\d+(\.\d+)?(dvh|svh|lvh|dvw|svw|lvw|cqw|cqh|cqi|cqb)\b/i;
70
+ const prefixed = /(^|\s)-(webkit|moz|ms|o)-/;
71
+ if (modern.test(later.value) || units.test(later.value) || prefixed.test(later.value)) {
72
+ return true;
73
+ }
74
+ if (prefixed.test(earlier.value)) return true;
75
+ return /!\s*important/i.test(later.value) && !/!\s*important/i.test(earlier.value);
76
+ }
77
+
78
+ /** `var(--name[, fallback])` references in a value. */
79
+ function varReferences(value) {
80
+ const out = [];
81
+ const re = /var\(\s*(--[a-zA-Z0-9_-]+)\s*(,)?/g;
82
+ let match;
83
+ while ((match = re.exec(value))) out.push({ name: match[1], hasFallback: Boolean(match[2]) });
84
+ return out;
85
+ }
86
+
87
+ /** Key that makes two rules comparable: same selector list, same at-rule context. */
88
+ export function ruleKey(block) {
89
+ return `${block.context.join(" › ")}|${normalizeSelectors(block.prelude).join(", ")}`;
90
+ }
91
+
92
+ /** Normalized `prop:value` signature of a block's declarations. */
93
+ export function declSignature(block) {
94
+ return block.decls
95
+ .map((d) => `${d.prop.toLowerCase()}:${d.value.replace(/\s+/g, " ").toLowerCase()}`)
96
+ .sort()
97
+ .join(";");
98
+ }
99
+
100
+ /**
101
+ * Analyze one stylesheet.
102
+ *
103
+ * @param {object} params
104
+ * @param {string} params.file - Path to report findings against.
105
+ * @param {ReturnType<import("./parse.mjs").parseCss>} params.parsed
106
+ * @param {{ names: Set<string>, byValue: Map<string, string[]> }} params.tokens
107
+ * @param {Set<string>} params.definedVars - Custom properties defined anywhere in the project.
108
+ * @param {boolean} [params.isModule] - Whether the file is a CSS Module.
109
+ * @returns {Array<ReturnType<typeof finding>>}
110
+ */
111
+ export function analyzeParsed({ file, parsed, tokens, definedVars, isModule = false }) {
112
+ const findings = [];
113
+ const at = (code, line, message, fixable = false, extra = {}) =>
114
+ findings.push(finding(code, { file, line, message, fixable, extra }));
115
+
116
+ for (const error of parsed.errors) {
117
+ const detail = error.text ? ` — \`${truncate(error.text)}\`` : "";
118
+ at(error.code, error.line, `${SYNTAX_MESSAGES[error.code] ?? error.code}${detail}`);
119
+ }
120
+
121
+ for (const { line, prop } of missingSemicolons(parsed)) {
122
+ at(
123
+ "missing-semicolon",
124
+ line,
125
+ `the value of \`${prop}\` swallows the declaration(s) below it — a \`;\` is missing, ` +
126
+ "so the browser drops all of them",
127
+ );
128
+ }
129
+
130
+ for (const statement of parsed.statements) {
131
+ checkAtRule(statement.name, statement.line, at);
132
+ }
133
+
134
+ const byKey = new Map();
135
+
136
+ for (const block of parsed.blocks) {
137
+ if (block.kind === "at") {
138
+ checkAtRule(/^@([\w-]+)/.exec(block.prelude)?.[1]?.toLowerCase() ?? "", block.line, at);
139
+ }
140
+
141
+ if (block.decls.length === 0 && block.children === 0 && !block.invalid) {
142
+ at(
143
+ "empty-rule",
144
+ block.line,
145
+ isModule
146
+ ? `\`${truncate(block.prelude)}\` is empty — dead unless your JS uses it as a marker class, ` +
147
+ "so it is reported but never removed automatically"
148
+ : `\`${truncate(block.prelude)}\` is empty — dead code`,
149
+ !isModule,
150
+ );
151
+ }
152
+
153
+ const seen = new Map();
154
+ for (const decl of block.decls) {
155
+ const prop = decl.prop.toLowerCase();
156
+ const previous = seen.get(prop);
157
+ if (previous) {
158
+ const sameValue =
159
+ previous.value.replace(/\s+/g, " ").toLowerCase() ===
160
+ decl.value.replace(/\s+/g, " ").toLowerCase();
161
+ if (sameValue) {
162
+ at(
163
+ "duplicate-declaration",
164
+ decl.line,
165
+ `\`${prop}\` is declared twice with the same value (also on line ${previous.line}) — ` +
166
+ "the first one is dead",
167
+ true,
168
+ );
169
+ } else if (!isProgressiveFallback(previous, decl)) {
170
+ at(
171
+ "overridden-declaration",
172
+ decl.line,
173
+ `\`${prop}: ${truncate(decl.value)}\` overrides \`${truncate(previous.value)}\` from ` +
174
+ `line ${previous.line} in the same rule — one of them is a mistake`,
175
+ );
176
+ }
177
+ }
178
+ seen.set(prop, decl);
179
+
180
+ checkDecl({ decl, prop, tokens, definedVars, at });
181
+ }
182
+
183
+ if (block.kind !== "rule") continue;
184
+ const key = ruleKey(block);
185
+ const previous = byKey.get(key);
186
+ if (previous) {
187
+ reportDuplicateRule({ block, previous, at });
188
+ } else {
189
+ byKey.set(key, block);
190
+ }
191
+ }
192
+
193
+ return findings;
194
+ }
195
+
196
+ /** Report a selector declared twice in the same file. */
197
+ function reportDuplicateRule({ block, previous, at }) {
198
+ const identical = declSignature(block) === declSignature(previous) && block.decls.length > 0;
199
+ if (identical) {
200
+ at(
201
+ "duplicate-rule",
202
+ block.line,
203
+ `\`${truncate(block.prelude)}\` repeats line ${previous.line} declaration for declaration — ` +
204
+ "the first copy has no effect",
205
+ true,
206
+ );
207
+ return;
208
+ }
209
+ const props = new Set(previous.decls.map((d) => d.prop.toLowerCase()));
210
+ const shadowed = block.decls.map((d) => d.prop.toLowerCase()).filter((p) => props.has(p));
211
+ at(
212
+ "duplicate-selector",
213
+ block.line,
214
+ shadowed.length > 0
215
+ ? `\`${truncate(block.prelude)}\` is declared again (first on line ${previous.line}) and re-declares ` +
216
+ `${shadowed.slice(0, 4).join(", ")} — the earlier value is dead; merge the rules`
217
+ : `\`${truncate(block.prelude)}\` is declared in two places (line ${previous.line} and here) — ` +
218
+ "merge them so the rule reads in one place",
219
+ );
220
+ }
221
+
222
+ /**
223
+ * Property-level and value-level checks for a single declaration.
224
+ *
225
+ * A `var()` **with a fallback** is never reported. That is the SDK's own knob
226
+ * idiom — `var(--tempest-card-padding, var(--tempest-space-5))` is a component
227
+ * offering an override nobody is required to set — so "this name is not a token"
228
+ * would fire on the pattern the docs teach. Without a fallback the same
229
+ * declaration resolves to nothing, which is a real defect and is reported.
230
+ */
231
+ function checkDecl({ decl, prop, tokens, definedVars, at }) {
232
+ if (isCheckableProperty(prop) && !KNOWN_PROPERTIES.has(prop)) {
233
+ const suggestion = nearest(prop, KNOWN_PROPERTIES);
234
+ if (suggestion) {
235
+ at(
236
+ "unknown-property",
237
+ decl.line,
238
+ `\`${prop}\` is not a CSS property — did you mean \`${suggestion}\`? The browser drops the declaration`,
239
+ );
240
+ }
241
+ }
242
+
243
+ for (const ref of varReferences(decl.value)) {
244
+ if (ref.hasFallback || definedVars.has(ref.name)) continue;
245
+ if (ref.name.startsWith("--tempest-")) {
246
+ if (tokens.names.size === 0) continue;
247
+ const suggestion = nearest(ref.name, tokens.names, 3);
248
+ at(
249
+ "unknown-token",
250
+ decl.line,
251
+ `\`${ref.name}\` is not an SDK token, nothing in this project defines it, and it has no ` +
252
+ `fallback${suggestion ? ` — did you mean \`${suggestion}\`?` : ""}`,
253
+ );
254
+ continue;
255
+ }
256
+ at(
257
+ "undefined-var",
258
+ decl.line,
259
+ `\`${ref.name}\` is never defined in this project and has no fallback — ` +
260
+ "the declaration resolves to nothing",
261
+ );
262
+ }
263
+
264
+ if (!TOKENIZABLE.has(prop) || decl.value.includes("var(")) return;
265
+ const token = tokenForValue(decl.value, tokens.byValue);
266
+ if (token) {
267
+ at(
268
+ "hardcoded-token-value",
269
+ decl.line,
270
+ `\`${prop}: ${truncate(decl.value)}\` is exactly \`${token}\` — use \`var(${token})\` so ` +
271
+ "the theme and dark mode follow it",
272
+ );
273
+ }
274
+ }
275
+
276
+ /** Flag an at-rule name the browser will not recognize. */
277
+ function checkAtRule(name, line, at) {
278
+ if (!name || KNOWN_AT_RULES.has(name)) return;
279
+ if (!/^[a-z][a-z-]*$/.test(name)) return;
280
+ const suggestion = nearest(name, KNOWN_AT_RULES);
281
+ at(
282
+ "unknown-at-rule",
283
+ line,
284
+ `\`@${name}\` is not a CSS at-rule${suggestion ? ` — did you mean \`@${suggestion}\`?` : ""}; ` +
285
+ "the browser skips the whole block",
286
+ );
287
+ }
288
+
289
+ /** Shorten a snippet for a one-line report. */
290
+ function truncate(text, max = 60) {
291
+ const flat = String(text).replace(/\s+/g, " ").trim();
292
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
293
+ }
294
+
295
+ /** Custom properties **defined** by a parsed sheet (`--x: …`, at any depth). */
296
+ export function definedCustomProperties(parsed) {
297
+ const out = new Set();
298
+ for (const block of parsed.blocks) {
299
+ for (const decl of block.decls) if (decl.prop.startsWith("--")) out.add(decl.prop);
300
+ }
301
+ return out;
302
+ }
303
+
304
+ /**
305
+ * Declarations whose value spans a `;` the author forgot.
306
+ *
307
+ * The parser cannot know: `color: red` followed by a newline and
308
+ * `background: blue` with no semicolon between them is one syntactically valid
309
+ * declaration with a nonsense value, and the browser drops both. It reads as a
310
+ * formatting slip and costs an afternoon.
311
+ *
312
+ * @param {ReturnType<import("./parse.mjs").parseCss>} parsed
313
+ * @returns {Array<{ line: number, prop: string }>}
314
+ */
315
+ export function missingSemicolons(parsed) {
316
+ const out = [];
317
+ for (const block of parsed.blocks) {
318
+ for (const decl of block.decls) {
319
+ if (!decl.value.includes("\n")) continue;
320
+ const mask = maskValue(decl.value);
321
+ if (/\n[^\S\n]*[-a-zA-Z][\w-]*[^\S\n]*:/.test(mask)) {
322
+ out.push({ line: decl.line, prop: decl.prop });
323
+ }
324
+ }
325
+ }
326
+ return out;
327
+ }
@@ -0,0 +1,201 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { parseCss } from "./parse.mjs";
4
+ import { analyzeParsed, declSignature, definedCustomProperties } from "./semantic.mjs";
5
+
6
+ const TOKENS = {
7
+ names: new Set(["--tempest-space-2", "--tempest-primary", "--tempest-text"]),
8
+ byValue: new Map([
9
+ ["8px", ["--tempest-space-2"]],
10
+ ["#0d6efd", ["--tempest-primary"]],
11
+ ["4px", ["--tempest-space-1", "--tempest-radius-sm"]],
12
+ ]),
13
+ utilities: new Set(),
14
+ };
15
+
16
+ const analyze = (text, options = {}) =>
17
+ analyzeParsed({
18
+ file: "src/a.css",
19
+ parsed: parseCss(text),
20
+ tokens: TOKENS,
21
+ definedVars: new Set([...TOKENS.names, ...(options.defined ?? [])]),
22
+ isModule: options.isModule ?? false,
23
+ });
24
+
25
+ const codes = (text, options) => analyze(text, options).map((f) => f.code);
26
+ const first = (text, code, options) => analyze(text, options).find((f) => f.code === code);
27
+
28
+ describe("analyzeParsed — syntax passthrough", () => {
29
+ it("turns parser codes into findings with a message and a line", () => {
30
+ const [found] = analyze(".a { color: ; }");
31
+ expect(found).toMatchObject({ code: "empty-value", severity: "error", line: 1 });
32
+ expect(found.message).toContain("empty value");
33
+ });
34
+
35
+ it("flags a missing semicolon that swallows the next declarations", () => {
36
+ const found = first(".a {\n padding: 8px\n margin: 0;\n}\n", "missing-semicolon");
37
+ expect(found).toMatchObject({ severity: "error", line: 2 });
38
+ });
39
+
40
+ it("does not mistake a multi-line value for a missing semicolon", () => {
41
+ expect(
42
+ codes(".a {\n grid-template-columns: repeat(\n 2,\n 1fr\n );\n}\n"),
43
+ ).toEqual([]);
44
+ });
45
+ });
46
+
47
+ describe("analyzeParsed — duplicate declarations", () => {
48
+ it("flags the same property with the same value twice", () => {
49
+ const found = first(".a { color: red; color: red; }", "duplicate-declaration");
50
+ expect(found).toMatchObject({ severity: "warn", fixable: true });
51
+ });
52
+
53
+ it("flags an override with a different value in the same rule", () => {
54
+ const found = first(".a { color: red; color: blue; }", "overridden-declaration");
55
+ expect(found.fixable).toBe(false);
56
+ expect(found.message).toContain("overrides");
57
+ });
58
+
59
+ it("accepts a var() fallback after a literal", () => {
60
+ expect(codes(".a { color: #0d6efd; color: var(--tempest-primary); }")).toEqual([
61
+ "hardcoded-token-value",
62
+ ]);
63
+ });
64
+
65
+ it("accepts a vendor-prefixed value before the standard one", () => {
66
+ expect(codes(".a { display: -webkit-box; display: flex; }")).toEqual([]);
67
+ });
68
+
69
+ it("accepts an !important escalation", () => {
70
+ expect(codes(".a { color: red; color: blue !important; }")).toEqual([]);
71
+ });
72
+ });
73
+
74
+ describe("analyzeParsed — duplicate selectors", () => {
75
+ it("flags a selector declared twice with overlapping properties", () => {
76
+ const found = first(".a { color: red; }\n.a { color: blue; }\n", "duplicate-selector");
77
+ expect(found).toMatchObject({ line: 2 });
78
+ expect(found.message).toContain("color");
79
+ });
80
+
81
+ it("flags a selector split across two rules with no overlap", () => {
82
+ const found = first(".a { color: red; }\n.a { padding: 0; }\n", "duplicate-selector");
83
+ expect(found.message).toContain("merge them");
84
+ });
85
+
86
+ it("flags an exact repeat as a duplicate rule, and marks it fixable", () => {
87
+ const found = first(".a { color: red; }\n.a { color: red; }\n", "duplicate-rule");
88
+ expect(found).toMatchObject({ fixable: true, line: 2 });
89
+ });
90
+
91
+ it("treats a reordered selector list as the same rule", () => {
92
+ expect(codes(".a, .b { color: red; }\n.b, .a { color: blue; }\n")).toContain(
93
+ "duplicate-selector",
94
+ );
95
+ });
96
+
97
+ it("does not compare rules across different at-rule contexts", () => {
98
+ expect(codes(".a { color: red; }\n@media print {\n .a { color: black; }\n}\n")).toEqual(
99
+ [],
100
+ );
101
+ });
102
+ });
103
+
104
+ describe("analyzeParsed — names", () => {
105
+ it("flags a misspelled property when a real one is close", () => {
106
+ const found = first(".a { bacground-color: red; }", "unknown-property");
107
+ expect(found.message).toContain("background-color");
108
+ });
109
+
110
+ it("stays silent on a property it simply does not know", () => {
111
+ expect(codes(".a { anchor-scope: all; }")).toEqual([]);
112
+ });
113
+
114
+ it("ignores vendor prefixes and custom properties", () => {
115
+ expect(codes(".a { -webkit-line-clamp: 2; --my-own: 1; }")).toEqual([]);
116
+ });
117
+
118
+ it("flags a misspelled at-rule", () => {
119
+ const found = first("@medai (min-width: 1px) { .a { color: red; } }", "unknown-at-rule");
120
+ expect(found.message).toContain("@media");
121
+ });
122
+
123
+ it("accepts modern at-rules", () => {
124
+ expect(codes("@layer base {\n .a { color: red; }\n}\n")).toEqual([]);
125
+ });
126
+ });
127
+
128
+ describe("analyzeParsed — custom properties", () => {
129
+ it("flags an SDK token that does not exist, and suggests the nearest", () => {
130
+ const found = first(".a { gap: var(--tempest-space-3); }", "unknown-token");
131
+ expect(found.message).toContain("--tempest-space-2");
132
+ });
133
+
134
+ it("accepts a `--tempest-*` knob that carries a fallback", () => {
135
+ // `var(--tempest-card-padding, …)` is the SDK's own override idiom: the name
136
+ // is not a token, it is a hook an app may set. A fallback means it renders.
137
+ expect(codes(".a { gap: var(--tempest-card-padding, 8px); }")).toEqual([]);
138
+ });
139
+
140
+ it("flags a var() nobody defines when there is no fallback", () => {
141
+ const found = first(".a { width: var(--nope); }", "undefined-var");
142
+ expect(found.severity).toBe("warn");
143
+ });
144
+
145
+ it("accepts a var() defined elsewhere in the project", () => {
146
+ expect(codes(".a { width: var(--row-height); }", { defined: ["--row-height"] })).toEqual(
147
+ [],
148
+ );
149
+ });
150
+
151
+ it("accepts a var() with a fallback", () => {
152
+ expect(codes(".a { width: var(--row-height, 40px); }")).toEqual([]);
153
+ });
154
+
155
+ it("accepts a var() the same sheet defines", () => {
156
+ const text = ":root { --mine: 4px; }\n.a { gap: var(--mine); }\n";
157
+ const parsed = parseCss(text);
158
+ expect(definedCustomProperties(parsed)).toEqual(new Set(["--mine"]));
159
+ });
160
+ });
161
+
162
+ describe("analyzeParsed — token suggestions", () => {
163
+ it("suggests the token whose value matches exactly", () => {
164
+ const found = first(".a { gap: 8px; }", "hardcoded-token-value");
165
+ expect(found).toMatchObject({ severity: "info" });
166
+ expect(found.message).toContain("var(--tempest-space-2)");
167
+ });
168
+
169
+ it("stays silent when the value belongs to more than one token", () => {
170
+ expect(codes(".a { gap: 4px; }")).toEqual([]);
171
+ });
172
+
173
+ it("stays silent for properties nobody themes", () => {
174
+ expect(codes(".a { width: 8px; }")).toEqual([]);
175
+ });
176
+ });
177
+
178
+ describe("analyzeParsed — empty rules", () => {
179
+ it("flags an empty rule in a plain sheet as fixable", () => {
180
+ const found = first(".a {}\n", "empty-rule");
181
+ expect(found.fixable).toBe(true);
182
+ });
183
+
184
+ it("never removes an empty rule in a CSS Module", () => {
185
+ const found = first(".a {}\n", "empty-rule", { isModule: true });
186
+ expect(found.fixable).toBe(false);
187
+ expect(found.message).toContain("marker class");
188
+ });
189
+
190
+ it("does not call a rule with nested children empty", () => {
191
+ expect(codes(".a {\n &:hover { color: red; }\n}\n")).toEqual([]);
192
+ });
193
+ });
194
+
195
+ describe("declSignature", () => {
196
+ it("ignores declaration order, case and whitespace", () => {
197
+ const a = parseCss(".x { color: RED; padding: 0 ; }").blocks[0];
198
+ const b = parseCss(".y { padding: 0; color: red; }").blocks[0];
199
+ expect(declSignature(a)).toBe(declSignature(b));
200
+ });
201
+ });
@@ -0,0 +1,119 @@
1
+ // The `--tempest-*` token table, read from whichever stylesheet this project
2
+ // actually has: the installed SDK's `dist/styles.css`, or — when the CLI runs
3
+ // from a checkout of the SDK itself — the sources under `src/styles/`.
4
+ //
5
+ // Read, never hard-coded. A copy of the token list inside the CLI would drift
6
+ // the first time a token is added, and then it would report the app's correct
7
+ // code as a typo, which is worse than not checking at all. No stylesheet found
8
+ // means the token checks stay silent.
9
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
10
+ import { join } from "node:path";
11
+
12
+ /** Candidate stylesheet locations, in the order they are trusted. */
13
+ function candidates(root, selfDir) {
14
+ return [
15
+ join(root, "node_modules", "tempest-react-sdk", "dist", "styles.css"),
16
+ join(root, "node_modules", "tempest-react-sdk", "dist", "utilities.css"),
17
+ join(selfDir, "..", "dist", "styles.css"),
18
+ join(selfDir, "..", "dist", "utilities.css"),
19
+ ];
20
+ }
21
+
22
+ /** Every `.css` file under a directory (non-recursive), sorted. */
23
+ function cssFilesIn(dir) {
24
+ try {
25
+ return readdirSync(dir)
26
+ .filter((name) => name.endsWith(".css"))
27
+ .sort()
28
+ .map((name) => join(dir, name));
29
+ } catch {
30
+ return [];
31
+ }
32
+ }
33
+
34
+ /** Normalize a token value for value→token lookups. */
35
+ function normalizeValue(value) {
36
+ return value.trim().replace(/\s+/g, " ").toLowerCase();
37
+ }
38
+
39
+ /**
40
+ * Collect `--custom-property: value` pairs from stylesheet text.
41
+ *
42
+ * Deliberately regex-based rather than a full parse: this runs over the SDK's
43
+ * own already-valid stylesheet, where the only thing wanted is the names, and a
44
+ * value containing `;` inside `url()` at worst costs one entry in the value map.
45
+ *
46
+ * @param {string} text
47
+ * @returns {Array<[name: string, value: string]>}
48
+ */
49
+ function customProperties(text) {
50
+ const out = [];
51
+ const re = /(--[a-zA-Z0-9_-]+)\s*:\s*([^;}]+)/g;
52
+ let match;
53
+ while ((match = re.exec(text))) out.push([match[1], match[2].trim()]);
54
+ return out;
55
+ }
56
+
57
+ /**
58
+ * Load the SDK design tokens.
59
+ *
60
+ * @param {object} params
61
+ * @param {string} params.root - Project root.
62
+ * @param {string} params.selfDir - Directory of the running CLI (`bin/`).
63
+ * @returns {{
64
+ * source: string | null,
65
+ * names: Set<string>,
66
+ * byValue: Map<string, string[]>,
67
+ * utilities: Set<string>,
68
+ * }}
69
+ */
70
+ export function loadTokens({ root, selfDir }) {
71
+ const files = candidates(root, selfDir).filter(existsSync);
72
+ if (files.length === 0) {
73
+ const localStyles = join(selfDir, "..", "src", "styles");
74
+ files.push(...cssFilesIn(localStyles));
75
+ }
76
+ const names = new Set();
77
+ const byValue = new Map();
78
+ const utilities = new Set();
79
+ let source = null;
80
+
81
+ for (const file of files) {
82
+ let text;
83
+ try {
84
+ text = readFileSync(file, "utf8");
85
+ } catch {
86
+ continue;
87
+ }
88
+ source ??= file;
89
+ for (const [name, value] of customProperties(text)) {
90
+ if (!name.startsWith("--tempest-")) continue;
91
+ names.add(name);
92
+ if (value.includes("var(")) continue;
93
+ const key = normalizeValue(value);
94
+ const list = byValue.get(key) ?? [];
95
+ if (!list.includes(name)) list.push(name);
96
+ byValue.set(key, list);
97
+ }
98
+ for (const match of text.matchAll(/\.(tempest-[a-z0-9-]+)/g)) utilities.add(match[1]);
99
+ }
100
+
101
+ return { source, names, byValue, utilities };
102
+ }
103
+
104
+ /**
105
+ * Token whose value is exactly this literal, when exactly one token has it.
106
+ *
107
+ * Ambiguity is the whole reason for the single-match rule: `4px` is the value of
108
+ * several tokens, and telling somebody to use `--tempest-space-1` when they meant
109
+ * a hairline border is a bad suggestion delivered confidently. One match means
110
+ * the intent is unambiguous.
111
+ *
112
+ * @param {string} value - Declaration value as written.
113
+ * @param {Map<string, string[]>} byValue - Value→tokens index.
114
+ * @returns {string | null}
115
+ */
116
+ export function tokenForValue(value, byValue) {
117
+ const matches = byValue.get(normalizeValue(value));
118
+ return matches && matches.length === 1 ? matches[0] : null;
119
+ }