@starci/eslint-canon-fe 3.0.2 → 5.1.2

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 (53) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +41 -16
  3. package/brand-values.mjs +174 -0
  4. package/class-names.mjs +1 -1
  5. package/client-boundary.mjs +199 -0
  6. package/comments.mjs +144 -190
  7. package/docs/brand-values.md +30 -0
  8. package/docs/client-boundary.md +99 -0
  9. package/docs/e2e-shape.md +161 -0
  10. package/docs/env-owner.md +52 -0
  11. package/docs/formatting.md +30 -0
  12. package/docs/hooks-folder.md +29 -0
  13. package/docs/hygiene.md +111 -0
  14. package/docs/lint-escape-hatch.md +32 -0
  15. package/docs/lists.md +74 -0
  16. package/docs/native-controls.md +73 -0
  17. package/docs/next-conventions.md +206 -0
  18. package/docs/size-and-state-budget.md +73 -0
  19. package/docs/spec-quality.md +139 -0
  20. package/docs/translation.md +50 -0
  21. package/docs/transport.md +168 -0
  22. package/docs/type-safety.md +95 -0
  23. package/e2e-shape.mjs +348 -0
  24. package/env-owner.mjs +134 -0
  25. package/file-layout.mjs +100 -7
  26. package/formatting.mjs +106 -0
  27. package/hooks-folder.mjs +119 -0
  28. package/hygiene.mjs +235 -0
  29. package/icon.mjs +210 -211
  30. package/index.mjs +292 -131
  31. package/lib/ast.mjs +94 -0
  32. package/lib/path.mjs +2 -0
  33. package/lib/scope.mjs +42 -0
  34. package/lib/why.mjs +304 -0
  35. package/lint-adoption.mjs +15 -52
  36. package/lint-escape-hatch.mjs +91 -56
  37. package/lists.mjs +197 -0
  38. package/loading.mjs +163 -164
  39. package/naming.mjs +143 -143
  40. package/native-controls.mjs +112 -0
  41. package/next-conventions.mjs +346 -0
  42. package/package.json +7 -3
  43. package/props-and-slots.mjs +4 -2
  44. package/served-locale.mjs +160 -160
  45. package/shape-slot.mjs +339 -0
  46. package/size-and-state-budget.mjs +196 -0
  47. package/spec-quality.mjs +239 -0
  48. package/the-split.mjs +133 -134
  49. package/tokens.mjs +267 -268
  50. package/translation.mjs +262 -122
  51. package/transport.mjs +404 -0
  52. package/type-safety.mjs +156 -74
  53. package/typography.mjs +82 -83
package/tokens.mjs CHANGED
@@ -1,268 +1,267 @@
1
- import { existsSync, readFileSync } from "node:fs"
2
- import { dirname, join } from "node:path"
3
-
4
- /**
5
- * The rules that hold `tokens.md`.
6
- *
7
- * THE UNION DOES MOST OF THIS, AND THESE RULES COVER WHAT IT CANNOT SEE. Every tier above the
8
- * leaves takes its classes from a typed entry, so an off-scale value there does not fail review -
9
- * it fails to compile, and there is nothing left to patrol. The leaf folder writes its own classes
10
- * and is exempt from the entry rules by policy, which makes it the one place a fractional step or a
11
- * bracketed length can still be typed. That folder is what these rules are for.
12
- *
13
- * THEY READ CONSTANTS AS WELL AS MARKUP. The last off-scale value in the source these were written
14
- * for lived in a module constant - `size-3.5` in one leaf, and a `py-1.5` in another - where every
15
- * rule that only walked JSX attributes looked straight past it. Hoisting hides a value; it does not
16
- * license one.
17
- */
18
-
19
- /** Forward-slash form of a filename, so Windows paths compare like every other path. */
20
- const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
21
-
22
- /** Product source lives under `src/`; tooling and config are out of scope. */
23
- const isSourceFile = (filename) => normalizePath(filename).includes("/src/")
24
-
25
- /**
26
- * A fractional step, in any family that measures.
27
- *
28
- * The rungs are whole and unevenly spaced, so half of one is not "between two rungs" - it is off
29
- * the ladder, and it will match nothing else on any screen.
30
- */
31
- const FRACTIONAL =
32
- /\b(?:gap|gap-x|gap-y|p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|space-x|space-y|inset|top|bottom|left|right|size|w|h)-\d+\.\d+\b/
33
-
34
- /** A bracketed length: a value chosen once, for one screen, findable by nobody. */
35
- const ARBITRARY_LENGTH =
36
- /\b(?:gap|gap-x|gap-y|p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|space-x|space-y|size|w|h|min-w|min-h|max-w|max-h)-\[[^\]]+\]/
37
-
38
- /** A raw colour, which escapes the semantic palette even when it matches it today. */
39
- const RAW_COLOUR = /\b(?:text|bg|border|ring|from|to|via|fill|stroke|shadow|decoration)-\[#[0-9a-fA-F]/
40
-
41
- /** Large text. Paired with a heavy weight it is a heading, whatever element carries it. */
42
- const LARGE_TEXT = /\btext-(?:xl|2xl|3xl|4xl|5xl)\b/
43
-
44
- /** A heavy weight. */
45
- const HEAVY_WEIGHT = /\bfont-(?:bold|extrabold|black)\b/
46
-
47
- /** True for a `className` / `class` JSX attribute. */
48
- const isClassAttribute = (node) =>
49
- node.type === "JSXAttribute" && node.name && (node.name.name === "className" || node.name.name === "class")
50
-
51
- /** Static string carried by a JSX attribute, a module constant, or an array member. */
52
- const staticText = (value) => {
53
- if (!value) return null
54
- if (value.type === "Literal" && typeof value.value === "string") return value.value
55
- if (value.type === "TemplateLiteral" && value.expressions.length === 0) {
56
- return value.quasis.map((quasi) => quasi.value.cooked).join(" ")
57
- }
58
- if (value.type === "JSXExpressionContainer") return staticText(value.expression)
59
- if (value.type === "ArrayExpression") {
60
- const members = value.elements.map((element) => staticText(element)).filter(Boolean)
61
- return members.length > 0 ? members.join(" ") : null
62
- }
63
- return null
64
- }
65
-
66
- /** Walk every place a class string can be written: markup, a constant, or an entry's array. */
67
- const classTextVisitors = (context, report) => {
68
- if (!isSourceFile(context.filename || context.getFilename())) return {}
69
- return {
70
- JSXAttribute(node) {
71
- if (!isClassAttribute(node)) return
72
- report(node, staticText(node.value))
73
- },
74
- VariableDeclarator(node) {
75
- report(node, staticText(node.init))
76
- },
77
- Property(node) {
78
- if (node.computed) return
79
- const key = node.key.type === "Identifier" ? node.key.name : null
80
- if (key !== "classes") return
81
- report(node, staticText(node.value))
82
- },
83
- }
84
- }
85
-
86
- // -- TOKEN-3 ---------------------------------------------------------------------------------------
87
-
88
- /** A fractional step is off the ladder, not between two rungs. */
89
- export const noFractionalStep = {
90
- meta: {
91
- type: "problem",
92
- docs: { description: "A measurement takes a whole rung; a fractional step is off the scale." },
93
- schema: [],
94
- messages: {
95
- fractional:
96
- "`{{cls}}` is half a rung. The scale is whole steps, unevenly spaced, so this is not between two rungs - it is off the ladder, and it will match nothing else on any screen. Use the nearest rung; there is no case where the right answer is half of one.",
97
- },
98
- },
99
- create(context) {
100
- return classTextVisitors(context, (node, text) => {
101
- if (!text) return
102
- const hit = text.match(FRACTIONAL)
103
- if (hit) context.report({ node, messageId: "fractional", data: { cls: hit[0] } })
104
- })
105
- },
106
- }
107
-
108
- // -- TOKEN-4 ---------------------------------------------------------------------------------------
109
-
110
- /** An arbitrary value escapes the system, whatever it happens to evaluate to. */
111
- export const noArbitraryValue = {
112
- meta: {
113
- type: "problem",
114
- docs: { description: "Bracketed lengths and raw colours escape the closed vocabulary." },
115
- schema: [],
116
- messages: {
117
- length:
118
- "`{{cls}}` is a value chosen once, for one screen. Even where it happens to equal a rung, nobody searching the scale can find it and it does not move when the scale moves. Use a member of the vocabulary, or add one deliberately.",
119
- colour:
120
- "`{{cls}}` is a raw colour, outside the semantic palette. A colour named for what it MEANS follows the theme; one named for what it is stays the same in a theme where it is wrong.",
121
- },
122
- },
123
- create(context) {
124
- return classTextVisitors(context, (node, text) => {
125
- if (!text) return
126
- const length = text.match(ARBITRARY_LENGTH)
127
- if (length) context.report({ node, messageId: "length", data: { cls: length[0] } })
128
- const colour = text.match(RAW_COLOUR)
129
- if (colour) context.report({ node, messageId: "colour", data: { cls: colour[0] } })
130
- })
131
- },
132
- }
133
-
134
- // -- TOKEN-5 ---------------------------------------------------------------------------------------
135
-
136
- /** Large text plus a heavy weight is a heading, and headings come from the component that owns both. */
137
- export const noHandRolledHeading = {
138
- meta: {
139
- type: "problem",
140
- docs: { description: "A heading comes from the heading component, not from raw type classes." },
141
- schema: [],
142
- messages: {
143
- heading:
144
- "Large text and a heavy weight together are a heading, whatever element carries them - and assembled here it is a heading nothing else knows about: the outline a screen reader builds does not contain it, and it stays behind the day the type scale changes. Render the heading component, which owns the tag and the size as one decision.",
145
- },
146
- },
147
- create(context) {
148
- return classTextVisitors(context, (node, text) => {
149
- if (!text) return
150
- if (LARGE_TEXT.test(text) && HEAVY_WEIGHT.test(text)) {
151
- context.report({ node, messageId: "heading" })
152
- }
153
- })
154
- },
155
- }
156
-
157
- /**
158
- * The class families whose names are PROMISES about a theme token, and the variable each needs.
159
- *
160
- * Tailwind resolves `max-w-app-lg` by looking for `--container-app-lg`. Nothing warns when that
161
- * variable does not exist: the class is emitted, the element gets no width, and the type system
162
- * is satisfied because the name IS in the union. That is the failure this rule exists for - a
163
- * class can be typesafe and mean nothing at the same time, and the union is exactly what makes
164
- * it look settled.
165
- *
166
- * Only families whose token name is DERIVABLE from the class name are listed. A colour or a
167
- * spacing step resolves through Tailwind's own scale as well as the theme, so a rule guessing at
168
- * those would report classes that work.
169
- */
170
- /**
171
- * Names Tailwind resolves ITSELF, which therefore promise nothing about this theme.
172
- *
173
- * `min-h-screen` is the viewport, not a variable; so are `full`, `fit`, `auto` and the viewport
174
- * units. Reported as unresolved tokens they would send an author to define `--min-height-screen`,
175
- * which is a variable nothing reads - the rule would have invented work and broken a working class.
176
- * Measured on both repositories the first time this ran: two findings, both wrong, both this list.
177
- */
178
- const TAILWIND_OWN_NAMES = new Set([
179
- "screen", "full", "fit", "auto", "none", "min", "max", "prose",
180
- "dvh", "svh", "lvh", "dvw", "svw", "lvw", "px",
181
- ])
182
- const TOKEN_CLASS_FAMILIES = [
183
- { pattern: /^max-w-app-([a-z0-9]+)$/, variable: (name) => `--container-app-${name}` },
184
- { pattern: /^max-h-([a-z][a-z0-9-]*)$/, variable: (name) => `--max-height-${name}` },
185
- { pattern: /^min-h-([a-z][a-z0-9-]*)$/, variable: (name) => `--min-height-${name}` },
186
- ]
187
-
188
- /** Stylesheets a repository may declare its theme in, relative to the repository root. */
189
- const STYLESHEET_CANDIDATES = [
190
- "src/app/globals.css",
191
- "apps/app/src/app/globals.css",
192
- "apps/expert/src/app/globals.css",
193
- "apps/landing/src/app/globals.css",
194
- "packages/ui/src/styles/globals.css",
195
- ]
196
-
197
- /** Every stylesheet found above the linted file, read once and cached for the run. */
198
- const themeCache = new Map()
199
- const themeTextFor = (filename) => {
200
- let directory = dirname(normalizePath(filename))
201
- for (let depth = 0; depth < 12 && directory !== "/" && directory !== ""; depth += 1) {
202
- if (themeCache.has(directory)) return themeCache.get(directory)
203
- const found = STYLESHEET_CANDIDATES
204
- .map((relative) => join(directory, relative))
205
- .filter((candidate) => existsSync(candidate))
206
- .map((candidate) => readFileSync(candidate, "utf8"))
207
- if (found.length > 0) {
208
- const text = found.join("\n")
209
- themeCache.set(directory, text)
210
- return text
211
- }
212
- directory = dirname(directory)
213
- }
214
- return null
215
- }
216
-
217
- /** A class naming a token the theme never defines is a class that draws nothing. */
218
- export const noUnresolvedTokenClass = {
219
- meta: {
220
- type: "problem",
221
- docs: { description: "A class naming a theme token resolves only when the theme defines it." },
222
- schema: [],
223
- messages: {
224
- unresolved:
225
- "`{{value}}` reads as a token this theme defines, and `{{variable}}` is defined nowhere in it. Tailwind emits the class, the element gets nothing, and both the union and the compiler stay happy - which is why this is the one dead class no type can catch. Define `{{variable}}` in the stylesheet, or name a token that exists.",
226
- },
227
- },
228
- create(context) {
229
- const file = normalizePath(context.filename || context.getFilename())
230
- if (!isSourceFile(file)) return {}
231
- const theme = themeTextFor(file)
232
- // A reader that cannot find the stylesheet stays quiet rather than calling every token dead.
233
- if (theme === null) return {}
234
- // The visitor hands (node, text) in that order; reversing them here is how a rule reports the
235
- // node as its message and the message as its node, and neither complains.
236
- const check = (node, value) => {
237
- for (const raw of String(value).split(/\s+/)) {
238
- const bare = raw.replace(/^[a-z-]+:/, "").replace(/^!/, "")
239
- for (const family of TOKEN_CLASS_FAMILIES) {
240
- const match = family.pattern.exec(bare)
241
- if (match === null) continue
242
- if (TAILWIND_OWN_NAMES.has(match[1])) continue
243
- const variable = family.variable(match[1])
244
- if (theme.includes(variable)) continue
245
- context.report({ node, messageId: "unresolved", data: { value: bare, variable } })
246
- }
247
- }
248
- }
249
- return classTextVisitors(context, check)
250
- },
251
- }
252
-
253
- /** The rules this law contributes to the plugin. */
254
- export const rules = {
255
- "no-fractional-step": noFractionalStep,
256
- "no-arbitrary-value": noArbitraryValue,
257
- "no-hand-rolled-heading": noHandRolledHeading,
258
- "no-unresolved-token-class": noUnresolvedTokenClass,
259
- }
260
-
261
- /**
262
- * The level this law asks for, as the plugin's own opinion.
263
- *
264
- * All three match on shape rather than judgement, so none carries the false-positive risk that
265
- * would justify adopting it at `warn`. A repository with history should expect the arbitrary-value
266
- * rule to report most: a bracketed length is the usual shape of a one-off fix.
267
- */
268
- export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
1
+ import { existsSync, readFileSync } from "node:fs"
2
+ import { dirname, join } from "node:path"
3
+
4
+ /**
5
+ * The rules that hold `tokens.md`.
6
+ *
7
+ * THE UNION DOES MOST OF THIS, AND THESE RULES COVER WHAT IT CANNOT SEE. Every tier above the
8
+ * leaves takes its classes from a typed entry, so an off-scale value there does not fail review -
9
+ * it fails to compile, and there is nothing left to patrol. The leaf folder writes its own classes
10
+ * and is exempt from the entry rules by policy, which makes it the one place a fractional step or a
11
+ * bracketed length can still be typed. That folder is what these rules are for.
12
+ *
13
+ * THEY READ CONSTANTS AS WELL AS MARKUP. The last off-scale value in the source these were written
14
+ * for lived in a module constant - `size-3.5` in one leaf, and a `py-1.5` in another - where every
15
+ * rule that only walked JSX attributes looked straight past it. Hoisting hides a value; it does not
16
+ * license one.
17
+ */
18
+
19
+ import { normalizePath } from "./lib/path.mjs"
20
+
21
+ /** Product source lives under `src/`; tooling and config are out of scope. */
22
+ const isSourceFile = (filename) => normalizePath(filename).includes("/src/")
23
+
24
+ /**
25
+ * A fractional step, in any family that measures.
26
+ *
27
+ * The rungs are whole and unevenly spaced, so half of one is not "between two rungs" - it is off
28
+ * the ladder, and it will match nothing else on any screen.
29
+ */
30
+ const FRACTIONAL =
31
+ /\b(?:gap|gap-x|gap-y|p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|space-x|space-y|inset|top|bottom|left|right|size|w|h)-\d+\.\d+\b/
32
+
33
+ /** A bracketed length: a value chosen once, for one screen, findable by nobody. */
34
+ const ARBITRARY_LENGTH =
35
+ /\b(?:gap|gap-x|gap-y|p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|space-x|space-y|size|w|h|min-w|min-h|max-w|max-h)-\[[^\]]+\]/
36
+
37
+ /** A raw colour, which escapes the semantic palette even when it matches it today. */
38
+ const RAW_COLOUR = /\b(?:text|bg|border|ring|from|to|via|fill|stroke|shadow|decoration)-\[#[0-9a-fA-F]/
39
+
40
+ /** Large text. Paired with a heavy weight it is a heading, whatever element carries it. */
41
+ const LARGE_TEXT = /\btext-(?:xl|2xl|3xl|4xl|5xl)\b/
42
+
43
+ /** A heavy weight. */
44
+ const HEAVY_WEIGHT = /\bfont-(?:bold|extrabold|black)\b/
45
+
46
+ /** True for a `className` / `class` JSX attribute. */
47
+ const isClassAttribute = (node) =>
48
+ node.type === "JSXAttribute" && node.name && (node.name.name === "className" || node.name.name === "class")
49
+
50
+ /** Static string carried by a JSX attribute, a module constant, or an array member. */
51
+ const staticText = (value) => {
52
+ if (!value) return null
53
+ if (value.type === "Literal" && typeof value.value === "string") return value.value
54
+ if (value.type === "TemplateLiteral" && value.expressions.length === 0) {
55
+ return value.quasis.map((quasi) => quasi.value.cooked).join(" ")
56
+ }
57
+ if (value.type === "JSXExpressionContainer") return staticText(value.expression)
58
+ if (value.type === "ArrayExpression") {
59
+ const members = value.elements.map((element) => staticText(element)).filter(Boolean)
60
+ return members.length > 0 ? members.join(" ") : null
61
+ }
62
+ return null
63
+ }
64
+
65
+ /** Walk every place a class string can be written: markup, a constant, or an entry's array. */
66
+ const classTextVisitors = (context, report) => {
67
+ if (!isSourceFile(context.filename || context.getFilename())) return {}
68
+ return {
69
+ JSXAttribute(node) {
70
+ if (!isClassAttribute(node)) return
71
+ report(node, staticText(node.value))
72
+ },
73
+ VariableDeclarator(node) {
74
+ report(node, staticText(node.init))
75
+ },
76
+ Property(node) {
77
+ if (node.computed) return
78
+ const key = node.key.type === "Identifier" ? node.key.name : null
79
+ if (key !== "classes") return
80
+ report(node, staticText(node.value))
81
+ },
82
+ }
83
+ }
84
+
85
+ // -- TOKEN-3 ---------------------------------------------------------------------------------------
86
+
87
+ /** A fractional step is off the ladder, not between two rungs. */
88
+ export const noFractionalStep = {
89
+ meta: {
90
+ type: "problem",
91
+ docs: { description: "A measurement takes a whole rung; a fractional step is off the scale." },
92
+ schema: [],
93
+ messages: {
94
+ fractional:
95
+ "`{{cls}}` is half a rung. The scale is whole steps, unevenly spaced, so this is not between two rungs - it is off the ladder, and it will match nothing else on any screen. Use the nearest rung; there is no case where the right answer is half of one.",
96
+ },
97
+ },
98
+ create(context) {
99
+ return classTextVisitors(context, (node, text) => {
100
+ if (!text) return
101
+ const hit = text.match(FRACTIONAL)
102
+ if (hit) context.report({ node, messageId: "fractional", data: { cls: hit[0] } })
103
+ })
104
+ },
105
+ }
106
+
107
+ // -- TOKEN-4 ---------------------------------------------------------------------------------------
108
+
109
+ /** An arbitrary value escapes the system, whatever it happens to evaluate to. */
110
+ export const noArbitraryValue = {
111
+ meta: {
112
+ type: "problem",
113
+ docs: { description: "Bracketed lengths and raw colours escape the closed vocabulary." },
114
+ schema: [],
115
+ messages: {
116
+ length:
117
+ "`{{cls}}` is a value chosen once, for one screen. Even where it happens to equal a rung, nobody searching the scale can find it and it does not move when the scale moves. Use a member of the vocabulary, or add one deliberately.",
118
+ colour:
119
+ "`{{cls}}` is a raw colour, outside the semantic palette. A colour named for what it MEANS follows the theme; one named for what it is stays the same in a theme where it is wrong.",
120
+ },
121
+ },
122
+ create(context) {
123
+ return classTextVisitors(context, (node, text) => {
124
+ if (!text) return
125
+ const length = text.match(ARBITRARY_LENGTH)
126
+ if (length) context.report({ node, messageId: "length", data: { cls: length[0] } })
127
+ const colour = text.match(RAW_COLOUR)
128
+ if (colour) context.report({ node, messageId: "colour", data: { cls: colour[0] } })
129
+ })
130
+ },
131
+ }
132
+
133
+ // -- TOKEN-5 ---------------------------------------------------------------------------------------
134
+
135
+ /** Large text plus a heavy weight is a heading, and headings come from the component that owns both. */
136
+ export const noHandRolledHeading = {
137
+ meta: {
138
+ type: "problem",
139
+ docs: { description: "A heading comes from the heading component, not from raw type classes." },
140
+ schema: [],
141
+ messages: {
142
+ heading:
143
+ "Large text and a heavy weight together are a heading, whatever element carries them - and assembled here it is a heading nothing else knows about: the outline a screen reader builds does not contain it, and it stays behind the day the type scale changes. Render the heading component, which owns the tag and the size as one decision.",
144
+ },
145
+ },
146
+ create(context) {
147
+ return classTextVisitors(context, (node, text) => {
148
+ if (!text) return
149
+ if (LARGE_TEXT.test(text) && HEAVY_WEIGHT.test(text)) {
150
+ context.report({ node, messageId: "heading" })
151
+ }
152
+ })
153
+ },
154
+ }
155
+
156
+ /**
157
+ * The class families whose names are PROMISES about a theme token, and the variable each needs.
158
+ *
159
+ * Tailwind resolves `max-w-app-lg` by looking for `--container-app-lg`. Nothing warns when that
160
+ * variable does not exist: the class is emitted, the element gets no width, and the type system
161
+ * is satisfied because the name IS in the union. That is the failure this rule exists for - a
162
+ * class can be typesafe and mean nothing at the same time, and the union is exactly what makes
163
+ * it look settled.
164
+ *
165
+ * Only families whose token name is DERIVABLE from the class name are listed. A colour or a
166
+ * spacing step resolves through Tailwind's own scale as well as the theme, so a rule guessing at
167
+ * those would report classes that work.
168
+ */
169
+ /**
170
+ * Names Tailwind resolves ITSELF, which therefore promise nothing about this theme.
171
+ *
172
+ * `min-h-screen` is the viewport, not a variable; so are `full`, `fit`, `auto` and the viewport
173
+ * units. Reported as unresolved tokens they would send an author to define `--min-height-screen`,
174
+ * which is a variable nothing reads - the rule would have invented work and broken a working class.
175
+ * Measured on both repositories the first time this ran: two findings, both wrong, both this list.
176
+ */
177
+ const TAILWIND_OWN_NAMES = new Set([
178
+ "screen", "full", "fit", "auto", "none", "min", "max", "prose",
179
+ "dvh", "svh", "lvh", "dvw", "svw", "lvw", "px",
180
+ ])
181
+ const TOKEN_CLASS_FAMILIES = [
182
+ { pattern: /^max-w-app-([a-z0-9]+)$/, variable: (name) => `--container-app-${name}` },
183
+ { pattern: /^max-h-([a-z][a-z0-9-]*)$/, variable: (name) => `--max-height-${name}` },
184
+ { pattern: /^min-h-([a-z][a-z0-9-]*)$/, variable: (name) => `--min-height-${name}` },
185
+ ]
186
+
187
+ /** Stylesheets a repository may declare its theme in, relative to the repository root. */
188
+ const STYLESHEET_CANDIDATES = [
189
+ "src/app/globals.css",
190
+ "apps/app/src/app/globals.css",
191
+ "apps/expert/src/app/globals.css",
192
+ "apps/landing/src/app/globals.css",
193
+ "packages/ui/src/styles/globals.css",
194
+ ]
195
+
196
+ /** Every stylesheet found above the linted file, read once and cached for the run. */
197
+ const themeCache = new Map()
198
+ const themeTextFor = (filename) => {
199
+ let directory = dirname(normalizePath(filename))
200
+ for (let depth = 0; depth < 12 && directory !== "/" && directory !== ""; depth += 1) {
201
+ if (themeCache.has(directory)) return themeCache.get(directory)
202
+ const found = STYLESHEET_CANDIDATES
203
+ .map((relative) => join(directory, relative))
204
+ .filter((candidate) => existsSync(candidate))
205
+ .map((candidate) => readFileSync(candidate, "utf8"))
206
+ if (found.length > 0) {
207
+ const text = found.join("\n")
208
+ themeCache.set(directory, text)
209
+ return text
210
+ }
211
+ directory = dirname(directory)
212
+ }
213
+ return null
214
+ }
215
+
216
+ /** A class naming a token the theme never defines is a class that draws nothing. */
217
+ export const noUnresolvedTokenClass = {
218
+ meta: {
219
+ type: "problem",
220
+ docs: { description: "A class naming a theme token resolves only when the theme defines it." },
221
+ schema: [],
222
+ messages: {
223
+ unresolved:
224
+ "`{{value}}` reads as a token this theme defines, and `{{variable}}` is defined nowhere in it. Tailwind emits the class, the element gets nothing, and both the union and the compiler stay happy - which is why this is the one dead class no type can catch. Define `{{variable}}` in the stylesheet, or name a token that exists.",
225
+ },
226
+ },
227
+ create(context) {
228
+ const file = normalizePath(context.filename || context.getFilename())
229
+ if (!isSourceFile(file)) return {}
230
+ const theme = themeTextFor(file)
231
+ // A reader that cannot find the stylesheet stays quiet rather than calling every token dead.
232
+ if (theme === null) return {}
233
+ // The visitor hands (node, text) in that order; reversing them here is how a rule reports the
234
+ // node as its message and the message as its node, and neither complains.
235
+ const check = (node, value) => {
236
+ for (const raw of String(value).split(/\s+/)) {
237
+ const bare = raw.replace(/^[a-z-]+:/, "").replace(/^!/, "")
238
+ for (const family of TOKEN_CLASS_FAMILIES) {
239
+ const match = family.pattern.exec(bare)
240
+ if (match === null) continue
241
+ if (TAILWIND_OWN_NAMES.has(match[1])) continue
242
+ const variable = family.variable(match[1])
243
+ if (theme.includes(variable)) continue
244
+ context.report({ node, messageId: "unresolved", data: { value: bare, variable } })
245
+ }
246
+ }
247
+ }
248
+ return classTextVisitors(context, check)
249
+ },
250
+ }
251
+
252
+ /** The rules this law contributes to the plugin. */
253
+ export const rules = {
254
+ "no-fractional-step": noFractionalStep,
255
+ "no-arbitrary-value": noArbitraryValue,
256
+ "no-hand-rolled-heading": noHandRolledHeading,
257
+ "no-unresolved-token-class": noUnresolvedTokenClass,
258
+ }
259
+
260
+ /**
261
+ * The level this law asks for, as the plugin's own opinion.
262
+ *
263
+ * All three match on shape rather than judgement, so none carries the false-positive risk that
264
+ * would justify adopting it at `warn`. A repository with history should expect the arbitrary-value
265
+ * rule to report most: a bracketed length is the usual shape of a one-off fix.
266
+ */
267
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))