@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/translation.mjs CHANGED
@@ -1,122 +1,262 @@
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"]))
1
+ /**
2
+ * The rules that hold `translation.md` (HFS R58, `FE_I18N_LITERAL`).
3
+ *
4
+ * TWO RULES. The first forbids a translation call below a block - easy and mostly redundant, since
5
+ * the split rule already stops the drawing half reaching for the runtime. The second is the law:
6
+ * NO LITERAL COPY AT ANY TIER, IN ANY LANGUAGE, AND NO PRAGMA.
7
+ *
8
+ * WHAT CHANGED, AND WHY. The previous rule scanned only the vocabulary tiers (leaves,
9
+ * composites, branches), so a block or a page holding every word of a screen was exempt, and the
10
+ * companion language rule carried a `vn-ok: <reason>` pragma that was used 490 times in one
11
+ * repository. One escape is enough to turn a rule into a comment convention. There is now no tier
12
+ * exemption and no pragma: a word a reader can see or hear comes from `t()` over a `next-intl`
13
+ * catalogue, and the catalogue (`messages/<locale>.json`) is the only place a second language is
14
+ * content.
15
+ *
16
+ * THE ATTRIBUTES MATTER MORE THAN THE MARKUP. Copy hides in `aria-label`, `placeholder`, `title`
17
+ * and `alt` precisely because none of them reads as a sentence when you scan the file - and an
18
+ * `aria-label` is not a small case: a screen reader treats it as the primary text, so an English
19
+ * one on a translated surface is the loudest defect on the page for the reader least able to work
20
+ * around it. It also hides in object copy (`{ title: "..." }`), which is how a page's metadata
21
+ * ends up in one language regardless of the URL.
22
+ *
23
+ * TWO STRENGTHS OF TEST, ON PURPOSE. JSX text and the attributes a reader hears (`label`, `title`,
24
+ * `alt`, `placeholder`, the `aria-*` set) are copy whatever they say, so ANY word reports: `Save` is
25
+ * copy, `logo` is copy. An object property or a generic prop (`description`, `hint`) may carry a
26
+ * token (`{ title: "sm" }`), so those need to look like prose - a space, a non-ASCII letter or a
27
+ * leading capital. A crude test that fires on real copy
28
+ * and spares tokens is worth more than a clever one nobody trusts.
29
+ */
30
+
31
+ import { SECOND_LANGUAGE_LETTER, isContentFile } from "./comments.mjs"
32
+ import { normalizePath } from "./lib/path.mjs"
33
+
34
+ /** Tiers that receive every word they render: they know no domain, so they can know no sentence. */
35
+ const VOCABULARY_DIRS = ["leaves", "shells", "composites", "branches"]
36
+
37
+ /** True when a file sits in a tier that must not resolve copy. */
38
+ const isVocabularyFile = (filename) => {
39
+ const file = normalizePath(filename)
40
+ return VOCABULARY_DIRS.some((dir) => file.includes(`/src/components/${dir}/`))
41
+ }
42
+
43
+ /** Attributes a reader sees or hears: any word in them is copy. */
44
+ const STRICT_ATTRS = new Set([
45
+ "label",
46
+ "aria-label",
47
+ "aria-description",
48
+ "aria-roledescription",
49
+ "aria-valuetext",
50
+ "aria-placeholder",
51
+ "placeholder",
52
+ "title",
53
+ "alt",
54
+ ])
55
+
56
+ /** Props that usually carry a sentence but may carry a token (a variant, an id). */
57
+ const PROSE_ATTRS = new Set([
58
+ "description",
59
+ "helperText",
60
+ "hint",
61
+ "tooltip",
62
+ "caption",
63
+ "heading",
64
+ "subtitle",
65
+ "errorMessage",
66
+ "emptyText",
67
+ "loadingText",
68
+ "confirmLabel",
69
+ "cancelLabel",
70
+ ])
71
+
72
+ /** Object keys whose value is shown to a reader. */
73
+ const COPY_KEYS = new Set([
74
+ "title",
75
+ "label",
76
+ "description",
77
+ "placeholder",
78
+ "message",
79
+ "heading",
80
+ "subtitle",
81
+ "caption",
82
+ "tooltip",
83
+ "hint",
84
+ "cta",
85
+ "text",
86
+ "alt",
87
+ "ariaLabel",
88
+ "helperText",
89
+ "emptyText",
90
+ "errorMessage",
91
+ "successMessage",
92
+ ])
93
+
94
+ /** Calls that resolve a word at render time. */
95
+ const RESOLVES_COPY = /^(?:useTranslations|useLocale|useFormatter|getTranslations)$/
96
+
97
+ /**
98
+ * True when the text contains a word: two or more letters in any script.
99
+ *
100
+ * TWO LETTERS, NOT ONE, AND THE SAME AS THE REPOSITORY GATE. A lone letter is a unit, a separator or a
101
+ * key cap (`x`, `k`, `A`); a word is where copy starts. nivo-fe's `scripts/check-i18n-catalog.mjs`
102
+ * draws the line at two letters for JSX text and copy attributes, and this rule draws it in the same
103
+ * place so the lint and the gate never disagree about what a literal is. Neither has a suppression
104
+ * marker.
105
+ */
106
+ const hasLetter = (text) => typeof text === "string" && /\p{L}{2,}/u.test(text)
107
+
108
+ /** Prose rather than a token: it has a space, a non-ASCII letter, or it starts like a sentence. */
109
+ const looksLikeProse = (text) =>
110
+ hasLetter(text) && (/\s/.test(text) || /[^\x00-\x7F]/.test(text) || /^[A-Z]/.test(text))
111
+
112
+ /** Static string carried by a literal or an expression-free template, else null. */
113
+ const staticString = (node) => {
114
+ if (!node) return null
115
+ if (node.type === "Literal" && typeof node.value === "string") return node.value
116
+ if (node.type === "TemplateLiteral" && node.expressions.length === 0) {
117
+ return node.quasis.map((quasi) => quasi.value.cooked ?? "").join("")
118
+ }
119
+ return null
120
+ }
121
+
122
+ /** Static string carried by a JSX attribute value, else null. */
123
+ const attributeText = (node) => {
124
+ const value = node && node.value
125
+ if (!value) return null
126
+ if (value.type === "JSXExpressionContainer") return staticString(value.expression)
127
+ return staticString(value)
128
+ }
129
+
130
+ /** The value node behind a JSX attribute, for de-duplication against the second-language walk. */
131
+ const attributeValueNode = (node) =>
132
+ node.value && node.value.type === "JSXExpressionContainer" ? node.value.expression : node.value
133
+
134
+ /** The name of an object property key, else null. */
135
+ const keyName = (property) => {
136
+ if (property.computed) return null
137
+ if (property.key.type === "Identifier") return property.key.name
138
+ if (property.key.type === "Literal" && typeof property.key.value === "string") return property.key.value
139
+ return null
140
+ }
141
+
142
+ // -- COPY-1 ----------------------------------------------------------------------------------------
143
+
144
+ /** A component below a block never resolves a word. */
145
+ export const noCopyResolutionBelowBlock = {
146
+ meta: {
147
+ type: "problem",
148
+ docs: { description: "The vocabulary tiers receive resolved strings; they never resolve one." },
149
+ schema: [],
150
+ messages: {
151
+ resolves:
152
+ "`{{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.",
153
+ },
154
+ },
155
+ create(context) {
156
+ if (!isVocabularyFile(context.filename || context.getFilename())) return {}
157
+ return {
158
+ CallExpression(node) {
159
+ const callee = node.callee
160
+ if (!callee || callee.type !== "Identifier" || !RESOLVES_COPY.test(callee.name)) return
161
+ context.report({ node, messageId: "resolves", data: { name: callee.name } })
162
+ },
163
+ }
164
+ },
165
+ }
166
+
167
+ // -- COPY-2 ----------------------------------------------------------------------------------------
168
+
169
+ /** No literal copy at any tier, in any language, and no pragma to allow one. */
170
+ export const noHardcodedCopy = {
171
+ meta: {
172
+ type: "problem",
173
+ docs: { description: "User-facing text comes from a `next-intl` catalogue through `t()`, at every tier." },
174
+ schema: [],
175
+ messages: {
176
+ text:
177
+ "`{{text}}` is copy written into source. A reader in another language sees it exactly as written, and there is no tier or comment that makes that acceptable: take the sentence from the catalogue with `t(\"key\")`.",
178
+ attribute:
179
+ "`{{attr}}=\"{{text}}\"` is copy, hardcoded. One reader in another language sees it verbatim - and `{{attr}}` is where copy hides, because it does not read as a sentence when scanning the file. Use `t(\"key\")`.",
180
+ property:
181
+ "`{{key}}: \"{{text}}\"` is copy in an object, so whatever displays this object (a page title, an empty state, a toast) shows it in one language. Use `t(\"key\")`, or build the object where `t` is in scope.",
182
+ second:
183
+ "A second-language string in source. Text in another language is content, and content lives in `messages/<locale>.json` behind `t()`; there is no pragma that lets it stay here.",
184
+ comment:
185
+ "A second-language comment. Source is English so that every reader can read all of it; the product's other language belongs in the catalogue.",
186
+ },
187
+ },
188
+ create(context) {
189
+ if (isContentFile(context.filename || context.getFilename())) return {}
190
+ const source = context.sourceCode || context.getSourceCode()
191
+ /** Value nodes already reported through their owner (attribute, property), so a string reports once. */
192
+ const claimed = new WeakSet()
193
+
194
+ return {
195
+ Program() {
196
+ for (const comment of source.getAllComments()) {
197
+ if (SECOND_LANGUAGE_LETTER.test(comment.value)) context.report({ node: comment, messageId: "comment" })
198
+ }
199
+ },
200
+ JSXText(node) {
201
+ const text = String(node.value || "").trim()
202
+ if (hasLetter(text)) context.report({ node, messageId: "text", data: { text } })
203
+ },
204
+ JSXExpressionContainer(node) {
205
+ // `<p>{"Hello"}</p>`: a literal in child position is JSX text with braces around it.
206
+ const parent = node.parent
207
+ if (!parent || (parent.type !== "JSXElement" && parent.type !== "JSXFragment")) return
208
+ const text = staticString(node.expression)
209
+ if (text === null || !hasLetter(text.trim())) return
210
+ claimed.add(node.expression)
211
+ context.report({ node, messageId: "text", data: { text: text.trim() } })
212
+ },
213
+ JSXAttribute(node) {
214
+ const attr = node.name && node.name.type === "JSXIdentifier" ? node.name.name : null
215
+ if (!attr) return
216
+ const strict = STRICT_ATTRS.has(attr)
217
+ if (!strict && !PROSE_ATTRS.has(attr)) return
218
+ const text = attributeText(node)
219
+ if (text === null) return
220
+ if (strict ? !hasLetter(text) : !looksLikeProse(text)) return
221
+ claimed.add(attributeValueNode(node))
222
+ context.report({ node, messageId: "attribute", data: { attr, text } })
223
+ },
224
+ Property(node) {
225
+ const key = keyName(node)
226
+ if (!key || !COPY_KEYS.has(key)) return
227
+ const text = staticString(node.value)
228
+ if (text === null || !looksLikeProse(text)) return
229
+ claimed.add(node.value)
230
+ context.report({ node, messageId: "property", data: { key, text } })
231
+ },
232
+ Literal(node) {
233
+ if (claimed.has(node) || typeof node.value !== "string") return
234
+ if (SECOND_LANGUAGE_LETTER.test(node.value)) context.report({ node, messageId: "second" })
235
+ },
236
+ TemplateElement(node) {
237
+ if (SECOND_LANGUAGE_LETTER.test((node.value && node.value.cooked) || "")) {
238
+ context.report({ node, messageId: "second" })
239
+ }
240
+ },
241
+ Identifier(node) {
242
+ if (SECOND_LANGUAGE_LETTER.test(node.name)) context.report({ node, messageId: "second" })
243
+ },
244
+ }
245
+ },
246
+ }
247
+
248
+ /** The rules this law contributes to the plugin. */
249
+ export const rules = {
250
+ "no-copy-resolution-below-block": noCopyResolutionBelowBlock,
251
+ "no-hardcoded-copy": noHardcodedCopy,
252
+ }
253
+
254
+ /**
255
+ * The level this law asks for, as the plugin's own opinion.
256
+ *
257
+ * `no-hardcoded-copy` is the one a repository with history should expect a large count from, and
258
+ * every report is a real move: the string has to be lifted into a catalogue key and read through
259
+ * `t()`, which is work rather than a deletion. That is the reason there is no `warn` rollout and no
260
+ * pragma - a repository burns the count down before it adopts, it does not live beside it.
261
+ */
262
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))