@starci/eslint-canon-fe 4.0.0 → 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 +34 -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 +1 -2
- package/formatting.mjs +106 -0
- package/hooks-folder.mjs +119 -0
- package/hygiene.mjs +235 -0
- package/icon.mjs +210 -211
- package/index.mjs +290 -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 +8 -4
- package/served-locale.mjs +160 -160
- package/shape-slot.mjs +1 -2
- 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/lists.mjs
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules that hold `lists.md` (HFS R65 `FE_SIZE_AND_STATE_BUDGET`, sub-check `FE_LIST_KEY`).
|
|
3
|
+
*
|
|
4
|
+
* A LIST IS ITEMS WITH IDENTITY. React reconciles a rendered list by `key`: the key is how it knows
|
|
5
|
+
* that the third row is still the third row after the first was deleted. With no key React falls
|
|
6
|
+
* back to position, with an index key it says position is identity, and either way a deleted row
|
|
7
|
+
* hands its state (an open menu, a half-typed input, a focus ring) to its neighbour. Nothing fails a
|
|
8
|
+
* type check and the bug appears only on the second interaction, on a real list.
|
|
9
|
+
*
|
|
10
|
+
* WHAT COUNTS AS A LIST. A `.map` callback that returns JSX. A static array written in place
|
|
11
|
+
* (`[0, 1, 2].map(...)`) or a callback that ignores its item (`(_, index) => ...`, a skeleton) has no identity to lose, so an index key there is fine.
|
|
12
|
+
*
|
|
13
|
+
* THE MEMO TAX. A component prop written as `{{ ... }}` or `[...]` inside a `.map` callback is a new
|
|
14
|
+
* object per row per render, so `memo` on the row never hits and the whole list re-renders on every
|
|
15
|
+
* keystroke of its parent. Hoist the constant, or build it once above the map.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { attribute, attributeValue, calleeName, isFunction, mentions } from "./lib/ast.mjs"
|
|
19
|
+
import { isSpecFile } from "./lib/scope.mjs"
|
|
20
|
+
|
|
21
|
+
/** `.map(callback)` where the callback is written inline; the call node, else null. */
|
|
22
|
+
const mapCallback = (call) => {
|
|
23
|
+
if (call.type !== "CallExpression" || call.callee.type !== "MemberExpression" || call.callee.computed) return null
|
|
24
|
+
if (call.callee.property.type !== "Identifier" || call.callee.property.name !== "map") return null
|
|
25
|
+
const receiver = calleeName(call.callee.object)
|
|
26
|
+
if (receiver !== null && /(?:^|\.)Children$/.test(receiver)) return null
|
|
27
|
+
const callback = call.arguments[0]
|
|
28
|
+
return callback && isFunction(callback) ? callback : null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The JSX a callback returns: its expression body, or the value of each of its own `return` statements. */
|
|
32
|
+
const returnedJsx = (callback) => {
|
|
33
|
+
const found = []
|
|
34
|
+
const take = (node) => {
|
|
35
|
+
if (!node) return
|
|
36
|
+
if (node.type === "JSXElement" || node.type === "JSXFragment") found.push(node)
|
|
37
|
+
else if (node.type === "ConditionalExpression") {
|
|
38
|
+
take(node.consequent)
|
|
39
|
+
take(node.alternate)
|
|
40
|
+
} else if (node.type === "LogicalExpression") take(node.right)
|
|
41
|
+
}
|
|
42
|
+
if (callback.body.type !== "BlockStatement") {
|
|
43
|
+
take(callback.body)
|
|
44
|
+
return found
|
|
45
|
+
}
|
|
46
|
+
const scan = (node) => {
|
|
47
|
+
if (!node || typeof node.type !== "string") return
|
|
48
|
+
if (node.type === "ReturnStatement") return take(node.argument)
|
|
49
|
+
if (node.type === "BlockStatement") node.body.forEach(scan)
|
|
50
|
+
else if (node.type === "IfStatement") {
|
|
51
|
+
scan(node.consequent)
|
|
52
|
+
scan(node.alternate)
|
|
53
|
+
} else if (node.type === "SwitchStatement") node.cases.forEach((entry) => entry.consequent.forEach(scan))
|
|
54
|
+
else if (node.type === "TryStatement") {
|
|
55
|
+
scan(node.block)
|
|
56
|
+
scan(node.handler?.body)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
scan(callback.body)
|
|
60
|
+
return found
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* True when the rows have no identity to lose: the receiver is an array written in place, or the callback
|
|
65
|
+
* ignores the item (`(_, index) => ...`), which is how a skeleton or a placeholder count is rendered.
|
|
66
|
+
*/
|
|
67
|
+
const isPositional = (call, callback) => {
|
|
68
|
+
if (call.callee.object.type === "ArrayExpression") return true
|
|
69
|
+
const item = callback.params[0]
|
|
70
|
+
return Boolean(item) && item.type === "Identifier" && item.name.startsWith("_")
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// -- LISTS-1 ---------------------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
/** Every element a `.map` callback returns carries a `key`. */
|
|
76
|
+
export const listItemHasKey = {
|
|
77
|
+
meta: {
|
|
78
|
+
type: "problem",
|
|
79
|
+
docs: { description: "An element returned from `.map` carries a `key`." },
|
|
80
|
+
schema: [],
|
|
81
|
+
messages: {
|
|
82
|
+
missing:
|
|
83
|
+
"This element is returned from `.map` with no `key`. React then identifies rows by position, so deleting or reordering one hands its state (an open menu, typed text, focus) to its neighbour. Give it a stable key from the data: `key={row.id}`.",
|
|
84
|
+
fragment:
|
|
85
|
+
"A `<>...</>` returned from `.map` cannot carry a key. Use `<Fragment key={...}>` (import `Fragment` from `react`) so each row has an identity.",
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
create(context) {
|
|
89
|
+
if (isSpecFile(context.filename || context.getFilename())) return {}
|
|
90
|
+
return {
|
|
91
|
+
CallExpression(call) {
|
|
92
|
+
const callback = mapCallback(call)
|
|
93
|
+
if (!callback) return
|
|
94
|
+
for (const element of returnedJsx(callback)) {
|
|
95
|
+
if (element.type === "JSXFragment") context.report({ node: element, messageId: "fragment" })
|
|
96
|
+
else if (!attribute(element.openingElement, "key")) context.report({ node: element.openingElement, messageId: "missing" })
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// -- LISTS-2 ---------------------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
/** The key is never the position, and never made up on every render. */
|
|
106
|
+
export const noIndexKey = {
|
|
107
|
+
meta: {
|
|
108
|
+
type: "problem",
|
|
109
|
+
docs: { description: "A list key is stable data identity, never the map index or a random value." },
|
|
110
|
+
schema: [],
|
|
111
|
+
messages: {
|
|
112
|
+
index:
|
|
113
|
+
"`key` is built from the `.map` index. The index is the row's position, not its identity: after a delete or a reorder the wrong row keeps the state. Use an id the data carries (`key={row.id}`).",
|
|
114
|
+
random:
|
|
115
|
+
"`key` is generated during render (`Math.random`, `Date.now`, `crypto.randomUUID`). A new key on every render remounts every row, throwing away its state and its DOM. Use an id the data carries.",
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
create(context) {
|
|
119
|
+
if (isSpecFile(context.filename || context.getFilename())) return {}
|
|
120
|
+
const source = context.sourceCode ?? context.getSourceCode()
|
|
121
|
+
return {
|
|
122
|
+
CallExpression(call) {
|
|
123
|
+
const callback = mapCallback(call)
|
|
124
|
+
if (!callback) return
|
|
125
|
+
const indexParam = callback.params[1]
|
|
126
|
+
const indexName = indexParam && indexParam.type === "Identifier" ? indexParam.name : null
|
|
127
|
+
for (const element of returnedJsx(callback)) {
|
|
128
|
+
if (element.type !== "JSXElement") continue
|
|
129
|
+
const key = attribute(element.openingElement, "key")
|
|
130
|
+
const value = attributeValue(key)
|
|
131
|
+
if (!value) continue
|
|
132
|
+
const text = source.getText(value)
|
|
133
|
+
if (/\b(?:Math\.random|Date\.now|randomUUID|nanoid|uuid)\b/.test(text)) {
|
|
134
|
+
context.report({ node: key, messageId: "random" })
|
|
135
|
+
} else if (indexName && !isPositional(call, callback) && mentions(source, value, indexName)) {
|
|
136
|
+
context.report({ node: key, messageId: "index" })
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// -- LISTS-3 ---------------------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
/** True for `<Component>` and `<a.B>`, false for a DOM tag: only a component can be memoised. */
|
|
147
|
+
const isComponentTag = (opening) => {
|
|
148
|
+
if (opening.name.type === "JSXMemberExpression") return true
|
|
149
|
+
return opening.name.type === "JSXIdentifier" && /^[A-Z]/.test(opening.name.name)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** True when the nearest function around `node` is a `.map` callback. */
|
|
153
|
+
const insideMapCallback = (node) => {
|
|
154
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
155
|
+
if (!isFunction(current)) continue
|
|
156
|
+
return current.parent?.type === "CallExpression" && current.parent.arguments[0] === current && mapCallback(current.parent) === current
|
|
157
|
+
}
|
|
158
|
+
return false
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** A component in a mapped list gets no object or array literal for a prop. */
|
|
162
|
+
export const noInlineLiteralPropInList = {
|
|
163
|
+
meta: {
|
|
164
|
+
type: "problem",
|
|
165
|
+
docs: { description: "No object or array literal as a prop of a component rendered inside `.map`." },
|
|
166
|
+
schema: [],
|
|
167
|
+
messages: {
|
|
168
|
+
literal:
|
|
169
|
+
"`{{prop}}` is an object or array literal written inside `.map`: a new value per row per render, so a memoised row never hits and the whole list re-renders on every parent update. Hoist it to a constant above the component, or build it once before the map.",
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
create(context) {
|
|
173
|
+
if (isSpecFile(context.filename || context.getFilename())) return {}
|
|
174
|
+
return {
|
|
175
|
+
JSXAttribute(node) {
|
|
176
|
+
const opening = node.parent
|
|
177
|
+
if (!opening || opening.type !== "JSXOpeningElement" || !isComponentTag(opening)) return
|
|
178
|
+
if (node.name.type !== "JSXIdentifier" || node.name.name === "key") return
|
|
179
|
+
const value = attributeValue(node)
|
|
180
|
+
if (!value || (value.type !== "ObjectExpression" && value.type !== "ArrayExpression")) return
|
|
181
|
+
if (value.type === "ArrayExpression" && value.elements.length === 0) return
|
|
182
|
+
if (!insideMapCallback(node)) return
|
|
183
|
+
context.report({ node, messageId: "literal", data: { prop: node.name.name } })
|
|
184
|
+
},
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The rules this law contributes to the plugin. */
|
|
190
|
+
export const rules = {
|
|
191
|
+
"list-item-has-key": listItemHasKey,
|
|
192
|
+
"no-index-key": noIndexKey,
|
|
193
|
+
"no-inline-literal-prop-in-list": noInlineLiteralPropInList,
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Every rule is an error. */
|
|
197
|
+
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|
package/loading.mjs
CHANGED
|
@@ -1,164 +1,163 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The rules that hold `loading.md`.
|
|
3
|
-
*
|
|
4
|
-
* Three shapes of the same mistake: a second tree describing the first. A twin component, a
|
|
5
|
-
* ready-made placeholder handed in as a prop, and a ternary picking between two different
|
|
6
|
-
* components at a call site. Each is correct on the day it is written and wrong the first time the
|
|
7
|
-
* real shape changes.
|
|
8
|
-
*
|
|
9
|
-
* WHY THIS NEEDS RULES AT ALL, when so much of this canon is held by types. A resting shape has no
|
|
10
|
-
* assertion to fail: it is not wrong in a way a compiler or a test can see, it is wrong on screen,
|
|
11
|
-
* and only for the second somebody happens to be watching. That is exactly the class of defect a
|
|
12
|
-
* lint rule is for - the one nothing else will ever report.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
if (
|
|
37
|
-
if (name.type === "
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (
|
|
131
|
-
if (expression.type === "
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
"no-
|
|
153
|
-
"no-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|
|
1
|
+
/**
|
|
2
|
+
* The rules that hold `loading.md`.
|
|
3
|
+
*
|
|
4
|
+
* Three shapes of the same mistake: a second tree describing the first. A twin component, a
|
|
5
|
+
* ready-made placeholder handed in as a prop, and a ternary picking between two different
|
|
6
|
+
* components at a call site. Each is correct on the day it is written and wrong the first time the
|
|
7
|
+
* real shape changes.
|
|
8
|
+
*
|
|
9
|
+
* WHY THIS NEEDS RULES AT ALL, when so much of this canon is held by types. A resting shape has no
|
|
10
|
+
* assertion to fail: it is not wrong in a way a compiler or a test can see, it is wrong on screen,
|
|
11
|
+
* and only for the second somebody happens to be watching. That is exactly the class of defect a
|
|
12
|
+
* lint rule is for - the one nothing else will ever report.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { normalizePath } from "./lib/path.mjs"
|
|
16
|
+
|
|
17
|
+
/** The component tree; a placeholder in a test fixture is a fixture, not a second tree. */
|
|
18
|
+
const isComponentFile = (filename) => normalizePath(filename).includes("/src/components/")
|
|
19
|
+
|
|
20
|
+
/** A twin test may build a resting shape by hand to assert against it. */
|
|
21
|
+
const isTestFile = (filename) => /\.(?:test|spec)\.(?:ts|tsx)$/.test(normalizePath(filename))
|
|
22
|
+
|
|
23
|
+
/** Product component source these rules govern. */
|
|
24
|
+
const isGoverned = (filename) => isComponentFile(filename) && !isTestFile(filename)
|
|
25
|
+
|
|
26
|
+
/** A flag that means "waiting", in the spellings this codebase uses. */
|
|
27
|
+
const WAITING_FLAG = /\bis(?:Loading|Skeleton|Pending)\b/
|
|
28
|
+
|
|
29
|
+
/** The name of a component whose whole job is to mirror another one's shape. */
|
|
30
|
+
const TWIN_NAME = /^[A-Za-z0-9]*Skeleton$/
|
|
31
|
+
|
|
32
|
+
/** JSX element name, including `Foo.Bar`. */
|
|
33
|
+
const jsxElementName = (opening) => {
|
|
34
|
+
const name = opening && opening.name
|
|
35
|
+
if (!name) return null
|
|
36
|
+
if (name.type === "JSXIdentifier") return name.name
|
|
37
|
+
if (name.type === "JSXMemberExpression" && name.object && name.property) {
|
|
38
|
+
return `${name.object.name}.${name.property.name}`
|
|
39
|
+
}
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// -- LOADING-1 -------------------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
/** No component exists whose job is to mirror another component's shape. */
|
|
46
|
+
export const noRestingTwinComponent = {
|
|
47
|
+
meta: {
|
|
48
|
+
type: "problem",
|
|
49
|
+
docs: { description: "No component whose whole job is to mirror another one's shape." },
|
|
50
|
+
schema: [],
|
|
51
|
+
messages: {
|
|
52
|
+
twin:
|
|
53
|
+
"`{{name}}` is a hand-mirrored twin: a second description of a shape that already has one. It cannot be kept in step - it can only be noticed after it has already drifted, and a resting shape has no assertion to fail, so nothing will report the drift. Give the component it mirrors a resting state and let it rest as ITSELF.",
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
create(context) {
|
|
57
|
+
const file = normalizePath(context.filename || context.getFilename())
|
|
58
|
+
if (!isGoverned(file)) return {}
|
|
59
|
+
const hit = file.match(/\/([A-Za-z0-9]*Skeleton)\/index\.tsx?$/) || file.match(/\/([A-Za-z0-9]*Skeleton)\.tsx?$/)
|
|
60
|
+
if (!hit || !TWIN_NAME.test(hit[1])) return {}
|
|
61
|
+
return {
|
|
62
|
+
Program(node) {
|
|
63
|
+
context.report({ node, messageId: "twin", data: { name: hit[1] } })
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// -- LOADING-1, handed in from outside --------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
/** A ready-made placeholder is the same second tree, passed as a prop. */
|
|
72
|
+
export const noPlaceholderProp = {
|
|
73
|
+
meta: {
|
|
74
|
+
type: "problem",
|
|
75
|
+
docs: { description: "No prop hands a component a ready-made resting shape to render." },
|
|
76
|
+
schema: [],
|
|
77
|
+
messages: {
|
|
78
|
+
prop:
|
|
79
|
+
"A resting shape handed in as markup is a second description of a tree that already has one, and it sits further from the shape it copies than a twin does - the component it describes cannot even see it. Pass the waiting flag down instead, and let each part rest as itself.",
|
|
80
|
+
import:
|
|
81
|
+
"`{{name}}` is a hand-kept resting tree imported from beside its subject. It drifts the first time the real shape changes, and nothing reports it. Thread the waiting flag down instead.",
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
create(context) {
|
|
85
|
+
if (!isGoverned(context.filename || context.getFilename())) return {}
|
|
86
|
+
return {
|
|
87
|
+
JSXAttribute(node) {
|
|
88
|
+
const name = node.name && node.name.type === "JSXIdentifier" ? node.name.name : null
|
|
89
|
+
if (name !== "skeleton" && name !== "placeholder" && name !== "fallback") return
|
|
90
|
+
const value = node.value
|
|
91
|
+
if (!value || value.type !== "JSXExpressionContainer") return
|
|
92
|
+
const expression = value.expression
|
|
93
|
+
if (expression.type === "JSXElement" || expression.type === "JSXFragment") {
|
|
94
|
+
context.report({ node, messageId: "prop" })
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
ImportDeclaration(node) {
|
|
98
|
+
const source = node.source && node.source.value
|
|
99
|
+
if (typeof source !== "string" || !/^\.\.?\//.test(source)) return
|
|
100
|
+
for (const specifier of node.specifiers || []) {
|
|
101
|
+
const local = specifier.local && specifier.local.name
|
|
102
|
+
// A bare `Skeleton` is the primitive a component rests WITH, not a twin of one.
|
|
103
|
+
if (!local || local === "Skeleton" || !TWIN_NAME.test(local)) continue
|
|
104
|
+
context.report({ node: specifier, messageId: "import", data: { name: local } })
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// -- LOADING-2 -------------------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
/** A caller never picks between a resting shape and a real one. */
|
|
114
|
+
export const noRestingBranchAtCallSite = {
|
|
115
|
+
meta: {
|
|
116
|
+
type: "problem",
|
|
117
|
+
docs: { description: "A waiting flag must not choose between two different components." },
|
|
118
|
+
schema: [],
|
|
119
|
+
messages: {
|
|
120
|
+
branch:
|
|
121
|
+
"`{{flag}}` picks between two DIFFERENT elements, which is a resting shape written at the call site - and it drifts from the real one the first time the real one changes. Give the component below a waiting flag and pass it down; let it rest as ITSELF. The same component on both sides is the honest form: one description, two states.",
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
create(context) {
|
|
125
|
+
if (!isGoverned(context.filename || context.getFilename())) return {}
|
|
126
|
+
const source = context.sourceCode || context.getSourceCode()
|
|
127
|
+
/** Root element name of one arm, or null when the arm is not an element. */
|
|
128
|
+
const armName = (expression) => {
|
|
129
|
+
if (!expression) return null
|
|
130
|
+
if (expression.type === "JSXElement") return jsxElementName(expression.openingElement)
|
|
131
|
+
if (expression.type === "JSXFragment") return "<>"
|
|
132
|
+
return null
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
ConditionalExpression(node) {
|
|
136
|
+
const test = source.getText(node.test)
|
|
137
|
+
if (!WAITING_FLAG.test(test)) return
|
|
138
|
+
const left = armName(node.consequent)
|
|
139
|
+
const right = armName(node.alternate)
|
|
140
|
+
// Both arms must be real elements AND differ. `null` on one side is LOADING-5 - a control
|
|
141
|
+
// that has nowhere to go yet - and that is correct rather than a second tree.
|
|
142
|
+
if (!left || !right || left === right) return
|
|
143
|
+
context.report({ node, messageId: "branch", data: { flag: test.trim().slice(0, 40) } })
|
|
144
|
+
},
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The rules this law contributes to the plugin. */
|
|
150
|
+
export const rules = {
|
|
151
|
+
"no-resting-twin-component": noRestingTwinComponent,
|
|
152
|
+
"no-placeholder-prop": noPlaceholderProp,
|
|
153
|
+
"no-resting-branch-at-call-site": noRestingBranchAtCallSite,
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The level this law asks for, as the plugin's own opinion.
|
|
158
|
+
*
|
|
159
|
+
* A repository adopting these with history should expect the twin rule to report a folder at a
|
|
160
|
+
* time: hand-kept placeholder trees arrive in families, one per screen, and each is a real shape
|
|
161
|
+
* that has to be folded back into the component it copies rather than deleted.
|
|
162
|
+
*/
|
|
163
|
+
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|