@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.
- package/CHANGELOG.md +35 -0
- package/README.md +41 -16
- package/brand-values.mjs +174 -0
- package/class-names.mjs +1 -1
- package/client-boundary.mjs +199 -0
- package/comments.mjs +144 -190
- package/docs/brand-values.md +30 -0
- package/docs/client-boundary.md +99 -0
- package/docs/e2e-shape.md +161 -0
- package/docs/env-owner.md +52 -0
- package/docs/formatting.md +30 -0
- package/docs/hooks-folder.md +29 -0
- package/docs/hygiene.md +111 -0
- package/docs/lint-escape-hatch.md +32 -0
- package/docs/lists.md +74 -0
- package/docs/native-controls.md +73 -0
- package/docs/next-conventions.md +206 -0
- package/docs/size-and-state-budget.md +73 -0
- package/docs/spec-quality.md +139 -0
- package/docs/translation.md +50 -0
- package/docs/transport.md +168 -0
- package/docs/type-safety.md +95 -0
- package/e2e-shape.mjs +348 -0
- package/env-owner.mjs +134 -0
- package/file-layout.mjs +100 -7
- package/formatting.mjs +106 -0
- package/hooks-folder.mjs +119 -0
- package/hygiene.mjs +235 -0
- package/icon.mjs +210 -211
- package/index.mjs +292 -131
- package/lib/ast.mjs +94 -0
- package/lib/path.mjs +2 -0
- package/lib/scope.mjs +42 -0
- package/lib/why.mjs +304 -0
- package/lint-adoption.mjs +15 -52
- package/lint-escape-hatch.mjs +91 -56
- package/lists.mjs +197 -0
- package/loading.mjs +163 -164
- package/naming.mjs +143 -143
- package/native-controls.mjs +112 -0
- package/next-conventions.mjs +346 -0
- package/package.json +7 -3
- package/props-and-slots.mjs +4 -2
- package/served-locale.mjs +160 -160
- package/shape-slot.mjs +339 -0
- package/size-and-state-budget.mjs +196 -0
- package/spec-quality.mjs +239 -0
- package/the-split.mjs +133 -134
- package/tokens.mjs +267 -268
- package/translation.mjs +262 -122
- package/transport.mjs +404 -0
- package/type-safety.mjs +156 -74
- 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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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"]))
|