@starci/eslint-canon-fe 1.0.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/tokens.mjs ADDED
@@ -0,0 +1,268 @@
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"]))
@@ -0,0 +1,122 @@
1
+ /**
2
+ * The rules that hold `translation.md`.
3
+ *
4
+ * TWO RULES, AND THE SECOND ONE IS THE INTERESTING ONE. Forbidding a translation call below a block
5
+ * is easy and mostly redundant - the split rule already stops the drawing half reaching for the
6
+ * runtime. What nothing else catches is a literal that a reader can SEE, sitting in a tier that is
7
+ * supposed to receive every word it renders.
8
+ *
9
+ * THE ATTRIBUTES MATTER MORE THAN THE MARKUP. Copy hides in `aria-label`, `placeholder`, `title`
10
+ * and `alt` precisely because none of them reads as a sentence when you scan the file - and an
11
+ * `aria-label` is not a small case: a screen reader treats it as the primary text, so an English
12
+ * one on a translated surface is the loudest defect on the page for the reader least able to work
13
+ * around it.
14
+ *
15
+ * The prose test is deliberately crude - a space and a leading capital - because the alternative is
16
+ * a rule that argues about whether a given string is a sentence. A crude test that fires on real
17
+ * copy and spares tokens is worth more than a clever one nobody trusts.
18
+ */
19
+
20
+ /** Forward-slash form of a filename, so Windows paths compare like every other path. */
21
+ const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
22
+
23
+ /** Tiers that receive every word they render: they know no domain, so they can know no sentence. */
24
+ const VOCABULARY_DIRS = ["leaves", "shells", "composites", "branches"]
25
+
26
+ /** True when a file sits in a tier that must not hold copy. */
27
+ const isVocabularyFile = (filename) => {
28
+ const file = normalizePath(filename)
29
+ return VOCABULARY_DIRS.some((dir) => file.includes(`/src/components/${dir}/`))
30
+ }
31
+
32
+ /** Attributes a reader sees or hears, none of which looks like a sentence in the markup. */
33
+ const VISIBLE_ATTRS = new Set(["aria-label", "placeholder", "title", "alt", "aria-description"])
34
+
35
+ /** Calls that resolve a word at render time. */
36
+ const RESOLVES_COPY = /^(?:useTranslations|useLocale|useFormatter|getTranslations)$/
37
+
38
+ /** Prose rather than a token: it has a space and it starts like a sentence. */
39
+ const looksLikeProse = (text) => typeof text === "string" && /\s/.test(text) && /^[A-Z]/.test(text)
40
+
41
+ /** Static string carried by a JSX attribute. */
42
+ const attributeText = (node) => {
43
+ const value = node && node.value
44
+ if (!value) return null
45
+ if (value.type === "Literal" && typeof value.value === "string") return value.value
46
+ if (value.type === "JSXExpressionContainer" && value.expression.type === "Literal") {
47
+ return typeof value.expression.value === "string" ? value.expression.value : null
48
+ }
49
+ return null
50
+ }
51
+
52
+ // -- COPY-1 ----------------------------------------------------------------------------------------
53
+
54
+ /** A component below a block never resolves a word. */
55
+ export const noCopyResolutionBelowBlock = {
56
+ meta: {
57
+ type: "problem",
58
+ docs: { description: "The vocabulary tiers receive resolved strings; they never resolve one." },
59
+ schema: [],
60
+ messages: {
61
+ resolves:
62
+ "`{{name}}(...)` resolves a word in a tier that receives every word it renders. This component would then need the translation runtime to be rendered from a fixture, and it would have to know which situation the reader is in to pick the right sentence - which is the connected half's job, one file away.",
63
+ },
64
+ },
65
+ create(context) {
66
+ if (!isVocabularyFile(context.filename || context.getFilename())) return {}
67
+ return {
68
+ CallExpression(node) {
69
+ const callee = node.callee
70
+ if (!callee || callee.type !== "Identifier" || !RESOLVES_COPY.test(callee.name)) return
71
+ context.report({ node, messageId: "resolves", data: { name: callee.name } })
72
+ },
73
+ }
74
+ },
75
+ }
76
+
77
+ // -- COPY-2 ----------------------------------------------------------------------------------------
78
+
79
+ /** A component below a block holds no literal a reader can see or hear. */
80
+ export const noHardcodedCopyInVocabulary = {
81
+ meta: {
82
+ type: "problem",
83
+ docs: { description: "No visible or spoken literal in a tier that receives its words." },
84
+ schema: [],
85
+ messages: {
86
+ hardcoded:
87
+ "`{{attr}}=\"{{text}}\"` is copy, hardcoded in a tier that receives every word it renders - so one reader in another language sees English here. It also does not read as a sentence when scanning this file, which is why this attribute is where copy hides. Take a resolved string through `props`.",
88
+ text:
89
+ "`{{text}}` is copy written into a tier that receives its words. A reader in another language sees this exactly as written. Take a resolved string through `props`.",
90
+ },
91
+ },
92
+ create(context) {
93
+ if (!isVocabularyFile(context.filename || context.getFilename())) return {}
94
+ return {
95
+ JSXAttribute(node) {
96
+ const attr = node.name && node.name.type === "JSXIdentifier" ? node.name.name : null
97
+ if (!attr || !VISIBLE_ATTRS.has(attr)) return
98
+ const text = attributeText(node)
99
+ if (looksLikeProse(text)) context.report({ node, messageId: "hardcoded", data: { attr, text } })
100
+ },
101
+ JSXText(node) {
102
+ const text = String(node.value || "").trim()
103
+ if (looksLikeProse(text)) context.report({ node, messageId: "text", data: { text } })
104
+ },
105
+ }
106
+ },
107
+ }
108
+
109
+ /** The rules this law contributes to the plugin. */
110
+ export const rules = {
111
+ "no-copy-resolution-below-block": noCopyResolutionBelowBlock,
112
+ "no-hardcoded-copy-in-vocabulary": noHardcodedCopyInVocabulary,
113
+ }
114
+
115
+ /**
116
+ * The level this law asks for, as the plugin's own opinion.
117
+ *
118
+ * The second rule is the one a repository with history should expect a count from, and every report
119
+ * is a real move: the string has to be lifted to a connected half and given a key, which is work
120
+ * rather than a deletion.
121
+ */
122
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The rules that hold `type-safety.md`.
3
+ *
4
+ * ONE RULE HERE, AND TWO ELSEWHERE. `any` is refused by the TypeScript plugin's own rule and the
5
+ * array spelling by its formatter rule, so neither is reimplemented - a second copy of somebody
6
+ * else's rule is a second thing to keep in step, and the one nobody edits is the one that stops
7
+ * matching. What is left is the double cast, which no off-the-shelf rule refuses because most
8
+ * codebases consider it a legitimate escape.
9
+ *
10
+ * THE EXEMPTION IS A PATH, and it has to be. Proving a closed API refuses bad input means building
11
+ * bad input, and there is no way to construct a value the types forbid without telling the compiler
12
+ * to forget them. A judgement-based exemption would be argued at every call site; a path is argued
13
+ * once, here.
14
+ */
15
+
16
+ /** Forward-slash form of a filename, so Windows paths compare like every other path. */
17
+ const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
18
+
19
+ /** Product source lives under `src/`; tooling and config are out of scope. */
20
+ const isSourceFile = (filename) => normalizePath(filename).includes("/src/")
21
+
22
+ /** A test may build a value the types forbid, because that is what it is proving. */
23
+ const isTestFile = (filename) => /\.(?:test|spec)\.(?:ts|tsx)$/.test(normalizePath(filename))
24
+
25
+ /** Product source this rule governs. */
26
+ const isGoverned = (filename) => isSourceFile(filename) && !isTestFile(filename)
27
+
28
+ /** True when a type node is the `unknown` keyword. */
29
+ const isUnknown = (node) => Boolean(node) && node.type === "TSUnknownKeyword"
30
+
31
+ // -- TYPE-SAFETY-1 ---------------------------------------------------------------------------------
32
+
33
+ /** A cast through `unknown` erases what the compiler knew, at the seam where it mattered. */
34
+ export const noDoubleCast = {
35
+ meta: {
36
+ type: "problem",
37
+ docs: { description: "No cast through `unknown`; it turns checking off at the boundary." },
38
+ schema: [],
39
+ messages: {
40
+ double:
41
+ "Casting through `unknown` tells the compiler to forget everything it knew about this value, because the two types have nothing in common. That is not a narrowing - a narrowing is a claim the compiler can still partly check - it is an erasure, and the seam it erases is the one worth checking: where a value crosses from outside the program to inside it. Narrow from `unknown` with a check the compiler can follow, or fix the shape.",
42
+ },
43
+ },
44
+ create(context) {
45
+ if (!isGoverned(context.filename || context.getFilename())) return {}
46
+ return {
47
+ TSAsExpression(node) {
48
+ // The outer cast of a `x as unknown as T` pair: its operand is itself a cast to `unknown`.
49
+ const inner = node.expression
50
+ if (!inner || inner.type !== "TSAsExpression") return
51
+ if (!isUnknown(inner.typeAnnotation)) return
52
+ context.report({ node, messageId: "double" })
53
+ },
54
+ }
55
+ },
56
+ }
57
+
58
+ /** The rules this law contributes to the plugin. */
59
+ export const rules = {
60
+ "no-double-cast": noDoubleCast,
61
+ }
62
+
63
+ /**
64
+ * The level this law asks for, as the plugin's own opinion.
65
+ *
66
+ * Exact - it matches one syntactic shape - but a repository adopting it with history should expect
67
+ * each report to be real work: a double cast is usually load-bearing by the time anybody notices,
68
+ * and removing one means giving the value a shape it did not have.
69
+ *
70
+ * `any` and the array spelling are NOT published here. They belong to the TypeScript plugin's own
71
+ * rules, which a consuming repository already has: `@typescript-eslint/no-explicit-any` and
72
+ * `@typescript-eslint/array-type` with the generic default.
73
+ */
74
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
package/typography.mjs ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The rule that holds `typography.md`.
3
+ *
4
+ * MOST OF THIS LAW IS A UNION, and what is left is one shape a type cannot see: a heading TAG
5
+ * written by hand. The level union stops a fifth step being asked for; nothing in the type system
6
+ * notices an `<h2>` in a file that never mentions the heading component at all.
7
+ *
8
+ * The rule's own twin lives in `tokens.mjs`: that one catches a heading assembled out of a large
9
+ * size and a heavy weight on some other element. Between them they cover both ways a heading gets
10
+ * hand-rolled - with the right tag and the wrong look, or the right look and no tag.
11
+ */
12
+
13
+ /** Forward-slash form of a filename, so Windows paths compare like every other path. */
14
+ const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
15
+
16
+ /** Product source lives under `src/`; tooling and config are out of scope. */
17
+ const isSourceFile = (filename) => normalizePath(filename).includes("/src/")
18
+
19
+ /** A twin test may build heading markup by hand to assert against it. */
20
+ const isTestFile = (filename) => /\.(?:test|spec)\.(?:ts|tsx)$/.test(normalizePath(filename))
21
+
22
+ /** The one component that owns the tag and the size together, and therefore may write the tag. */
23
+ const isHeadingComponent = (filename) => normalizePath(filename).includes("/src/components/leaves/Heading/")
24
+
25
+ /** The tags a document outline is built from. */
26
+ const HEADING_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"])
27
+
28
+ /** The deepest level the component draws; below this a page has nested too far. */
29
+ const DEEPEST_LEVEL = 4
30
+
31
+ /** Intrinsic (lowercase) tag name, or null for a component. */
32
+ const tagName = (opening) => {
33
+ const name = opening && opening.name
34
+ if (!name || name.type !== "JSXIdentifier") return null
35
+ return name.name === name.name.toLowerCase() ? name.name : null
36
+ }
37
+
38
+ // -- TYPESET-1 · TYPESET-2 -------------------------------------------------------------------------------
39
+
40
+ /** A heading comes from the component that owns its tag and its size as one decision. */
41
+ export const noHeadingTagOutsideHeadingComponent = {
42
+ meta: {
43
+ type: "problem",
44
+ docs: { description: "Heading tags are written by the heading component, from a level." },
45
+ schema: [],
46
+ messages: {
47
+ tag:
48
+ "`<{{tag}}>` written here throws away the two facts the heading component exists to keep together: the tag a screen reader builds the document outline from, and the size a reader sees. Set separately they drift, and a screen's third-largest text becomes its first heading. Render the heading component with a level of {{level}}; one prop decides both, so they cannot disagree.",
49
+ tooDeep:
50
+ "`<{{tag}}>` is deeper than the scale goes: levels stop at {{deepest}}, because a fifth means the page has nested further than a reader can hold. That is a structure problem wearing a styling problem's clothes - flatten the section, then render the title with a level the scale has.",
51
+ },
52
+ },
53
+ create(context) {
54
+ const file = normalizePath(context.filename || context.getFilename())
55
+ if (!isSourceFile(file) || isTestFile(file) || isHeadingComponent(file)) return {}
56
+ return {
57
+ JSXOpeningElement(node) {
58
+ const tag = tagName(node)
59
+ if (!tag || !HEADING_TAGS.has(tag)) return
60
+ const level = Number(tag.slice(1))
61
+ if (level > DEEPEST_LEVEL) {
62
+ context.report({ node, messageId: "tooDeep", data: { tag, deepest: DEEPEST_LEVEL } })
63
+ return
64
+ }
65
+ context.report({ node, messageId: "tag", data: { tag, level } })
66
+ },
67
+ }
68
+ },
69
+ }
70
+
71
+ /** The rules this law contributes to the plugin. */
72
+ export const rules = {
73
+ "no-heading-tag-outside-heading-component": noHeadingTagOutsideHeadingComponent,
74
+ }
75
+
76
+ /**
77
+ * The level this law asks for, as the plugin's own opinion.
78
+ *
79
+ * Exact: a tag name in a file that is not the component that owns it. A repository adopting this
80
+ * with history should expect the deeper levels to report a structure problem rather than a styling
81
+ * one, and those take longer to fix than the message suggests.
82
+ */
83
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))