@estiva-app/ui 0.31.0 → 0.33.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/README.md +13 -1
- package/dist/eslint/index.d.ts +3 -0
- package/dist/eslint/index.d.ts.map +1 -1
- package/dist/eslint/index.js +1336 -1062
- package/dist/eslint/index.js.map +4 -4
- package/dist/eslint/no-handmade-empty-state.d.ts +25 -0
- package/dist/eslint/no-handmade-empty-state.d.ts.map +1 -0
- package/dist/eslint/no-native-title.d.ts +31 -0
- package/dist/eslint/no-native-title.d.ts.map +1 -0
- package/dist/eslint/no-restyled-part.d.ts +20 -1
- package/dist/eslint/no-restyled-part.d.ts.map +1 -1
- package/package.json +1 -1
- package/registry.json +1 -1
- package/src/EmptyState.mdx +5 -2
- package/src/FilePicker.stories.tsx +5 -1
- package/src/ReactionPicker.stories.tsx +2 -1
- package/src/Tooltip.mdx +1 -0
- package/src/eslint/index.test.ts +16 -9
- package/src/eslint/index.ts +13 -2
- package/src/eslint/no-handmade-empty-state.test.ts +151 -0
- package/src/eslint/no-handmade-empty-state.ts +344 -0
- package/src/eslint/no-native-title.test.ts +67 -0
- package/src/eslint/no-native-title.ts +72 -0
- package/src/eslint/no-restyled-part.ts +11 -0
- package/src/gates/create-app.test.ts +1 -1
- package/src/gates/gates.test.ts +1 -1
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { statSync } from 'node:fs'
|
|
2
|
+
import type { Rule } from 'eslint'
|
|
3
|
+
import { ESCAPE_MESSAGES, isEscaped } from './escape'
|
|
4
|
+
import {
|
|
5
|
+
bindingOfTag,
|
|
6
|
+
classesOf,
|
|
7
|
+
functionComponents,
|
|
8
|
+
parseFile,
|
|
9
|
+
partsOfFile,
|
|
10
|
+
resolveModule,
|
|
11
|
+
walk,
|
|
12
|
+
type Node,
|
|
13
|
+
type Parser,
|
|
14
|
+
type PartBinding,
|
|
15
|
+
} from './no-restyled-part'
|
|
16
|
+
|
|
17
|
+
type Locals = Map<string, PartBinding | 'namespace'>
|
|
18
|
+
|
|
19
|
+
const child = (node: Node, key: string) => node[key] as Node | null | undefined
|
|
20
|
+
const children = (node: Node, key: string) => ((node[key] as (Node | null)[] | undefined) ?? []).filter((n): n is Node => !!n)
|
|
21
|
+
const nameOf = (node: Node | null | undefined): string | undefined =>
|
|
22
|
+
node?.type === 'Identifier' || node?.type === 'JSXIdentifier' ? (node.name as string) : undefined
|
|
23
|
+
const tagName = (element: Node) => nameOf(child(child(element, 'openingElement') as Node, 'name'))
|
|
24
|
+
/**
|
|
25
|
+
* Whether a name is the part itself: imported, re-exported, or an app wrapper
|
|
26
|
+
* that hands everything on (Peek's `EmptyState`). A component that only hands
|
|
27
|
+
* its `className` to one (Ship's `Activity`) is the app's own, and is followed.
|
|
28
|
+
*/
|
|
29
|
+
const isPart = (binding: PartBinding | undefined, part: string) => binding?.part === part && binding.props === 'all'
|
|
30
|
+
const attributeOf = (opening: Node, name: string) => children(opening, 'attributes').find((a) => a.type === 'JSXAttribute' && nameOf(child(a, 'name')) === name)
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------- shape 1: a line drawn by hand
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* An empty state is a line, not a paragraph of prose. The words are not read:
|
|
36
|
+
* UIG-1's pass on words alone fired on a hint and a status note, and words
|
|
37
|
+
* missed four of Peek's dialogs ("There is nowhere else to move it…", "Every
|
|
38
|
+
* topic is already in your Open work."). Where the line sits decides.
|
|
39
|
+
*/
|
|
40
|
+
const SHORT = 120
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The element's words, when words are all it holds: text, a string, a name
|
|
44
|
+
* dropped into a sentence ("There is nowhere else in {teamName} to put it.").
|
|
45
|
+
* A line with no words of its own — a count, a message's body — is not one.
|
|
46
|
+
*/
|
|
47
|
+
function onlyWords(element: Node): string | undefined {
|
|
48
|
+
let line = ''
|
|
49
|
+
let own = ''
|
|
50
|
+
for (const kid of children(element, 'children')) {
|
|
51
|
+
let piece: string
|
|
52
|
+
if (kid.type === 'JSXText') piece = String(kid.value)
|
|
53
|
+
else if (kid.type === 'JSXExpressionContainer') {
|
|
54
|
+
const e = child(kid, 'expression') as Node
|
|
55
|
+
if (e.type === 'JSXEmptyExpression') continue
|
|
56
|
+
if (e.type === 'Literal' && typeof e.value === 'string') piece = e.value
|
|
57
|
+
else if (e.type === 'TemplateLiteral' && children(e, 'expressions').length === 0) piece = children(e, 'quasis').map((q) => (q.value as { cooked: string }).cooked).join('')
|
|
58
|
+
else if (e.type === 'Identifier' || e.type === 'MemberExpression') {
|
|
59
|
+
line += 'x'
|
|
60
|
+
continue
|
|
61
|
+
} else return undefined
|
|
62
|
+
} else return undefined
|
|
63
|
+
own += piece
|
|
64
|
+
line += piece
|
|
65
|
+
}
|
|
66
|
+
if (!/[a-z]{2,}/i.test(own)) return undefined
|
|
67
|
+
return line.replace(/\s+/g, ' ').trim()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const isCount = (node: Node | null | undefined): boolean => {
|
|
71
|
+
if (node?.type === 'ChainExpression') return isCount(child(node, 'expression'))
|
|
72
|
+
return node?.type === 'MemberExpression' && !node.computed && /^(?:length|size)$/.test(nameOf(child(node, 'property')) ?? '')
|
|
73
|
+
}
|
|
74
|
+
const isNumber = (node: Node | null | undefined, n: number) => node?.type === 'Literal' && node.value === n
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* What a condition says about a list: `empty` when it is true for an empty one
|
|
78
|
+
* (`rows.length === 0`, `!rows.length`, `isEmpty(rows)`), `full` when it is
|
|
79
|
+
* true for a list with rows (`rows.length > 0`, `rows.length`), nothing when it
|
|
80
|
+
* does not ask. `a && b` says what either side says.
|
|
81
|
+
*/
|
|
82
|
+
function whenTrue(test: Node | null | undefined): 'empty' | 'full' | undefined {
|
|
83
|
+
if (!test) return undefined
|
|
84
|
+
if (isCount(test)) return 'full'
|
|
85
|
+
switch (test.type) {
|
|
86
|
+
case 'UnaryExpression': {
|
|
87
|
+
if (test.operator !== '!') return undefined
|
|
88
|
+
const inner = whenTrue(child(test, 'argument'))
|
|
89
|
+
return inner === 'empty' ? 'full' : inner === 'full' ? 'empty' : undefined
|
|
90
|
+
}
|
|
91
|
+
case 'LogicalExpression':
|
|
92
|
+
if (test.operator !== '&&') return undefined
|
|
93
|
+
return whenTrue(child(test, 'left')) ?? whenTrue(child(test, 'right'))
|
|
94
|
+
case 'BinaryExpression': {
|
|
95
|
+
let [count, other, op] = [child(test, 'left'), child(test, 'right'), String(test.operator)]
|
|
96
|
+
if (!isCount(count)) [count, other, op] = [other, count, ({ '<': '>', '>': '<', '<=': '>=', '>=': '<=' } as Record<string, string>)[op] ?? op]
|
|
97
|
+
if (!isCount(count)) return undefined
|
|
98
|
+
if (isNumber(other, 0)) return op === '===' || op === '==' || op === '<=' ? 'empty' : op === '>' || op === '!==' || op === '!=' ? 'full' : undefined
|
|
99
|
+
if (isNumber(other, 1)) return op === '<' ? 'empty' : op === '>=' ? 'full' : undefined
|
|
100
|
+
return undefined
|
|
101
|
+
}
|
|
102
|
+
case 'CallExpression': {
|
|
103
|
+
const callee = child(test, 'callee')
|
|
104
|
+
const name = callee?.type === 'MemberExpression' ? nameOf(child(callee, 'property')) : nameOf(callee)
|
|
105
|
+
return name && /^is(?:Empty|Blank)$/.test(name) ? 'empty' : undefined
|
|
106
|
+
}
|
|
107
|
+
default:
|
|
108
|
+
return undefined
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const isMapCall = (node: Node | null | undefined) => {
|
|
113
|
+
const callee = node?.type === 'CallExpression' ? child(node, 'callee') : undefined
|
|
114
|
+
return callee?.type === 'MemberExpression' && nameOf(child(callee, 'property')) === 'map'
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Whether a branch **is** a list: a `.map(…)`, or a box that holds only that —
|
|
119
|
+
* `<ul>{rows.map(…)}</ul>`. A branch that shows something else and lists a few
|
|
120
|
+
* things inside it (an answer with its sources) is not the list the line stands
|
|
121
|
+
* in for.
|
|
122
|
+
*/
|
|
123
|
+
function isAList(branch: Node | null | undefined, depth = 0): boolean {
|
|
124
|
+
if (!branch || depth > 2) return false
|
|
125
|
+
if (isMapCall(branch)) return true
|
|
126
|
+
if (branch.type !== 'JSXElement' && branch.type !== 'JSXFragment') return false
|
|
127
|
+
if (branch.type === 'JSXElement' && !/^[a-z]/.test(tagName(branch) ?? '')) return false
|
|
128
|
+
const kids = children(branch, 'children').filter((k) => !(k.type === 'JSXText' && !String(k.value).trim()) && !(k.type === 'JSXExpressionContainer' && (child(k, 'expression') as Node).type === 'JSXEmptyExpression'))
|
|
129
|
+
return kids.length === 1 && (kids[0].type === 'JSXExpressionContainer' ? isMapCall(child(kids[0], 'expression')) : isAList(kids[0], depth + 1))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** A box around one thing and nothing else: a wrapper that pads or places the line. */
|
|
133
|
+
function soleChildOf(parent: Node | undefined, element: Node): boolean {
|
|
134
|
+
if (parent?.type !== 'JSXElement' || !/^[a-z]/.test(tagName(parent) ?? '')) return false
|
|
135
|
+
const kids = children(parent, 'children').filter((k) => !(k.type === 'JSXText' && !String(k.value).trim()) && !(k.type === 'JSXExpressionContainer' && (child(k, 'expression') as Node).type === 'JSXEmptyExpression'))
|
|
136
|
+
return kids.length === 1 && kids[0] === element
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Whether the element stands where a list's rows would be: one side of a
|
|
141
|
+
* condition whose other side maps a list, or under a condition that asks if
|
|
142
|
+
* something is empty — `cond && …`, `cond ? … : …`, `if (cond) return …`.
|
|
143
|
+
*/
|
|
144
|
+
function standsInForAList(element: Node): boolean {
|
|
145
|
+
let at = element
|
|
146
|
+
for (let depth = 0; depth < 3 && soleChildOf(at.parent, at); depth += 1) at = at.parent as Node
|
|
147
|
+
const parent = at.parent
|
|
148
|
+
if (!parent) return false
|
|
149
|
+
if (parent.type === 'ConditionalExpression' && parent.test !== at) {
|
|
150
|
+
const consequent = parent.consequent === at
|
|
151
|
+
const other = child(parent, consequent ? 'alternate' : 'consequent')
|
|
152
|
+
return whenTrue(child(parent, 'test')) === (consequent ? 'empty' : 'full') || isAList(other)
|
|
153
|
+
}
|
|
154
|
+
if (parent.type === 'LogicalExpression' && parent.operator === '&&' && parent.right === at) return whenTrue(child(parent, 'left')) === 'empty'
|
|
155
|
+
if (parent.type === 'ReturnStatement') {
|
|
156
|
+
let holder = parent.parent
|
|
157
|
+
if (holder?.type === 'BlockStatement' && children(holder, 'body').length === 1) holder = holder.parent
|
|
158
|
+
return holder?.type === 'IfStatement' && child(holder, 'consequent') !== undefined && whenTrue(child(holder, 'test')) === 'empty'
|
|
159
|
+
}
|
|
160
|
+
return false
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---------------------------------------------------------------- shape 2: the right part at the wrong scope
|
|
164
|
+
|
|
165
|
+
/** A content box that fills its viewport: `min-h-full` or `h-full` on `contentClassName`. */
|
|
166
|
+
const FILLS = /^(?:min-h-full|h-full)$/
|
|
167
|
+
|
|
168
|
+
function isPageScope(opening: Node): boolean {
|
|
169
|
+
const scope = attributeOf(opening, 'scope')
|
|
170
|
+
if (!scope) return true
|
|
171
|
+
const value = child(scope, 'value')
|
|
172
|
+
return value?.type === 'Literal' && value.value === 'page'
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
interface FileFacts {
|
|
176
|
+
file: string
|
|
177
|
+
locals: Locals
|
|
178
|
+
components: Map<string, Node>
|
|
179
|
+
/** A local name imported from the app's own files: where from, and under what name. */
|
|
180
|
+
imports: Map<string, { file: string; name: string }>
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const factsCache = new Map<string, { mtime: number; facts: FileFacts | null }>()
|
|
184
|
+
|
|
185
|
+
function factsOf(file: string, program: Node, parser: Parser): FileFacts {
|
|
186
|
+
const imports = new Map<string, { file: string; name: string }>()
|
|
187
|
+
for (const statement of children(program, 'body')) {
|
|
188
|
+
if (statement.type !== 'ImportDeclaration' || statement.importKind === 'type') continue
|
|
189
|
+
const target = resolveModule(file, String((child(statement, 'source') as Node).value))
|
|
190
|
+
if (!target) continue
|
|
191
|
+
for (const specifier of children(statement, 'specifiers')) {
|
|
192
|
+
if (specifier.type !== 'ImportSpecifier' || specifier.importKind === 'type') continue
|
|
193
|
+
const local = nameOf(child(specifier, 'local'))
|
|
194
|
+
const imported = nameOf(child(specifier, 'imported'))
|
|
195
|
+
if (local && imported) imports.set(local, { file: target, name: imported })
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return { file, locals: partsOfFile(file, program, parser), components: functionComponents(program), imports }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function factsOfFile(file: string, parser: Parser): FileFacts | null {
|
|
202
|
+
const mtime = statSync(file).mtimeMs
|
|
203
|
+
const cached = factsCache.get(file)
|
|
204
|
+
if (cached && cached.mtime === mtime) return cached.facts
|
|
205
|
+
factsCache.set(file, { mtime, facts: null })
|
|
206
|
+
const program = parseFile(parser, file)
|
|
207
|
+
const facts = program ? factsOf(file, program, parser) : null
|
|
208
|
+
factsCache.set(file, { mtime, facts })
|
|
209
|
+
return facts
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
interface PageState {
|
|
213
|
+
file: string
|
|
214
|
+
line: number
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Every page-scope `EmptyState` a component can draw, following the app's own
|
|
219
|
+
* components it draws in turn, in its file and in the files it imports from,
|
|
220
|
+
* four deep.
|
|
221
|
+
*/
|
|
222
|
+
function pageStatesOf(facts: FileFacts, name: string, parser: Parser, depth: number, seen: Set<string>): PageState[] {
|
|
223
|
+
const key = `${facts.file}#${name}`
|
|
224
|
+
if (depth > 4 || seen.has(key)) return []
|
|
225
|
+
seen.add(key)
|
|
226
|
+
const local = facts.components.get(name)
|
|
227
|
+
if (!local) {
|
|
228
|
+
const from = facts.imports.get(name)
|
|
229
|
+
const there = from ? factsOfFile(from.file, parser) : null
|
|
230
|
+
return there && from ? pageStatesOf(there, from.name, parser, depth + 1, seen) : []
|
|
231
|
+
}
|
|
232
|
+
const found: PageState[] = []
|
|
233
|
+
walk(local, (node) => {
|
|
234
|
+
if (node.type !== 'JSXOpeningElement') return
|
|
235
|
+
const tag = child(node, 'name')
|
|
236
|
+
const binding = bindingOfTag(tag, facts.locals)
|
|
237
|
+
if (isPart(binding, 'EmptyState')) {
|
|
238
|
+
if (isPageScope(node)) found.push({ file: facts.file, line: node.loc?.start.line ?? 0 })
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
const component = binding?.props === 'all' ? undefined : nameOf(tag)
|
|
242
|
+
if (component && /^[A-Z]/.test(component)) found.push(...pageStatesOf(facts, component, parser, depth + 1, seen))
|
|
243
|
+
})
|
|
244
|
+
return found
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const fileName = (file: string) => file.replace(/\\/g, '/').split('/').pop()
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* UIG-23: an empty state drawn by hand, or the right part at the wrong scope.
|
|
251
|
+
*
|
|
252
|
+
* **A line drawn by hand.** A plain box whose only content is a short line
|
|
253
|
+
* saying there is nothing ("No tickets yet.") where a list's rows would be: one
|
|
254
|
+
* side of a condition whose other side maps the list, or under a condition that
|
|
255
|
+
* asks if something is empty. Both halves are needed. The words alone fire on a
|
|
256
|
+
* hint and a status note (UIG-1 measured it); the place alone fires on any
|
|
257
|
+
* line a list shows beside itself.
|
|
258
|
+
*
|
|
259
|
+
* **The wrong scope.** A `page`-scope `EmptyState` centres in the room a flex
|
|
260
|
+
* column gives it. Inside a `ScrollArea` the content box is only as tall as
|
|
261
|
+
* what is in it, so the state sits at the top of its pane. The rule looks at
|
|
262
|
+
* the `ScrollArea`'s content and at every component of the app's own placed in
|
|
263
|
+
* it, following them into their files, because that is how four of them
|
|
264
|
+
* arrived on Folders in one merge (peek `7f22e5e`): the state was drawn in one
|
|
265
|
+
* file and scrolled in another. A `contentClassName` with `min-h-full` or
|
|
266
|
+
* `h-full` gives the state its room, and passes.
|
|
267
|
+
*
|
|
268
|
+
* Not judged: the package's `EmptyState` and a part's own lines (a
|
|
269
|
+
* `FieldLine`, a palette's empty row) — a part is already a part.
|
|
270
|
+
*/
|
|
271
|
+
export const noHandmadeEmptyState: Rule.RuleModule = {
|
|
272
|
+
meta: {
|
|
273
|
+
type: 'problem',
|
|
274
|
+
docs: { description: 'An empty state drawn by hand, or a page-scope EmptyState that cannot centre' },
|
|
275
|
+
schema: [],
|
|
276
|
+
messages: {
|
|
277
|
+
handmade:
|
|
278
|
+
'A line saying there is nothing is an EmptyState. Use `EmptyState` from @estiva-app/ui: `scope="section"` inside a section, `scope="page"` for a whole page.',
|
|
279
|
+
wrongScope:
|
|
280
|
+
'A page-scope EmptyState inside a ScrollArea sits at the top of its pane, not the middle: the ScrollArea\'s content is only as tall as what is in it. Use `scope="section"` when the pane has anything else on it; when the whole pane is empty, draw the page EmptyState in place of the ScrollArea, not inside it.',
|
|
281
|
+
wrongScopeVia:
|
|
282
|
+
'`{{component}}` draws a page-scope EmptyState ({{where}}) inside this ScrollArea, where it sits at the top of its pane, not the middle: the ScrollArea\'s content is only as tall as what is in it. Use `scope="section"` there when the pane has anything else on it; when the whole pane is empty, draw the page EmptyState in place of the ScrollArea, not inside it.',
|
|
283
|
+
...ESCAPE_MESSAGES,
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
create(context) {
|
|
287
|
+
const parser = (context.languageOptions?.parser ?? {}) as Parser
|
|
288
|
+
const file = context.filename
|
|
289
|
+
let facts: FileFacts | undefined
|
|
290
|
+
// A ScrollArea inside another is walked by both; each state is reported once.
|
|
291
|
+
const reported = new Set<Node>()
|
|
292
|
+
const scopeOf = (node: Node) => context.sourceCode.getScope(node as unknown as Parameters<typeof context.sourceCode.getScope>[0])
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
Program(program) {
|
|
296
|
+
facts = factsOf(file, program as unknown as Node, parser)
|
|
297
|
+
},
|
|
298
|
+
JSXElement(ruleNode: Rule.Node) {
|
|
299
|
+
const element = ruleNode as unknown as Node
|
|
300
|
+
const opening = child(element, 'openingElement') as Node
|
|
301
|
+
const tag = tagName(element)
|
|
302
|
+
|
|
303
|
+
// Shape 1: a plain box holding only a "nothing" line, where a list would be.
|
|
304
|
+
if (tag && /^[a-z]/.test(tag)) {
|
|
305
|
+
const words = onlyWords(element)
|
|
306
|
+
if (!words || words.length > SHORT) return
|
|
307
|
+
if (!standsInForAList(element)) return
|
|
308
|
+
if (isEscaped(context, opening)) return
|
|
309
|
+
context.report({ loc: opening.loc as NonNullable<Rule.Node['loc']>, messageId: 'handmade' })
|
|
310
|
+
return
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Shape 2: a page-scope EmptyState in a ScrollArea that does not fill.
|
|
314
|
+
if (!facts || !isPart(bindingOfTag(child(opening, 'name'), facts.locals), 'ScrollArea')) return
|
|
315
|
+
const content = attributeOf(opening, 'contentClassName')
|
|
316
|
+
if (content && classesOf(child(content, 'value'), scopeOf).some((c) => FILLS.test(c.replace(/^(?:[^:\s[\]]+:)+/, '')))) return
|
|
317
|
+
const here = facts
|
|
318
|
+
for (const kid of children(element, 'children')) {
|
|
319
|
+
walk(kid, (node) => {
|
|
320
|
+
if (node.type !== 'JSXOpeningElement') return
|
|
321
|
+
const name = child(node, 'name')
|
|
322
|
+
const binding = bindingOfTag(name, here.locals)
|
|
323
|
+
if (isPart(binding, 'EmptyState')) {
|
|
324
|
+
if (!isPageScope(node) || reported.has(node) || isEscaped(context, node)) return
|
|
325
|
+
reported.add(node)
|
|
326
|
+
context.report({ loc: node.loc as NonNullable<Rule.Node['loc']>, messageId: 'wrongScope' })
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
const component = binding?.props === 'all' ? undefined : nameOf(name)
|
|
330
|
+
if (!component || !/^[A-Z]/.test(component)) return
|
|
331
|
+
const states = pageStatesOf(here, component, parser, 0, new Set())
|
|
332
|
+
if (!states.length || reported.has(node) || isEscaped(context, node)) return
|
|
333
|
+
reported.add(node)
|
|
334
|
+
// In reading order: file, then line.
|
|
335
|
+
for (const state of [...states].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line)) {
|
|
336
|
+
const where = state.file === file ? `line ${state.line}` : `${fileName(state.file)}:${state.line}`
|
|
337
|
+
context.report({ loc: node.loc as NonNullable<Rule.Node['loc']>, messageId: 'wrongScopeVia', data: { component, where } })
|
|
338
|
+
}
|
|
339
|
+
})
|
|
340
|
+
}
|
|
341
|
+
},
|
|
342
|
+
}
|
|
343
|
+
},
|
|
344
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
import { RuleTester } from 'eslint'
|
|
5
|
+
import { parser } from 'typescript-eslint'
|
|
6
|
+
import { describe, expect, it } from 'vitest'
|
|
7
|
+
import { noNativeTitle, PARTS_WITH_A_TITLE } from './no-native-title'
|
|
8
|
+
|
|
9
|
+
RuleTester.describe = describe
|
|
10
|
+
RuleTester.it = it
|
|
11
|
+
RuleTester.itOnly = it.only
|
|
12
|
+
|
|
13
|
+
const tester = new RuleTester({
|
|
14
|
+
languageOptions: { parser, parserOptions: { ecmaFeatures: { jsx: true } } },
|
|
15
|
+
linterOptions: { reportUnusedDisableDirectives: 'off' },
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
/** An app on disk, so a wrapper of the app's own is followed to its part, as Peek's are. */
|
|
19
|
+
const root = mkdtempSync(join(tmpdir(), 'no-native-title-'))
|
|
20
|
+
const write = (path: string, text: string) => {
|
|
21
|
+
mkdirSync(dirname(join(root, path)), { recursive: true })
|
|
22
|
+
writeFileSync(join(root, path), text)
|
|
23
|
+
}
|
|
24
|
+
write('app/package.json', '{ "name": "app" }')
|
|
25
|
+
write('app/src/components/ui/IconButton.tsx', "export { IconButton } from '@estiva-app/ui'\n")
|
|
26
|
+
write('app/src/components/ui/Button.tsx', "import { Button as UiButton, type ButtonProps } from '@estiva-app/ui'\nexport function Button(props: ButtonProps) {\n return <UiButton {...props} />\n}\n")
|
|
27
|
+
write('app/src/components/Banner.tsx', 'export function NewTopicBanner({ title }: { title: string }) {\n return <p>{title}</p>\n}\n')
|
|
28
|
+
const app = join(root, 'app/src/Probe.tsx')
|
|
29
|
+
|
|
30
|
+
const component = (body: string, imports = '') => `${imports}export function Probe({ at, name }: { at: string; name: string }) {\n return (\n${body}\n )\n}\n`
|
|
31
|
+
const native = [{ messageId: 'native' }]
|
|
32
|
+
|
|
33
|
+
tester.run('no-native-title', noNativeTitle, {
|
|
34
|
+
valid: [
|
|
35
|
+
{ name: 'the part: WithTooltip', code: component('<WithTooltip label={at}><time>{at}</time></WithTooltip>', "import { WithTooltip } from '@estiva-app/ui'\n") },
|
|
36
|
+
{ name: "IconButton's own tooltip", code: component('<IconButton tooltip="Close" aria-label="Close">x</IconButton>', "import { IconButton } from '@estiva-app/ui'\n") },
|
|
37
|
+
{ name: 'a <title> inside an <svg> is a label for a screen reader, not a tooltip', code: component('<svg viewBox="0 0 16 16"><title>Unread</title><circle r="4" /></svg>') },
|
|
38
|
+
{ name: "a <title> in a document's head", code: component('<html><head><title>Estiva</title></head><body /></html>') },
|
|
39
|
+
...PARTS_WITH_A_TITLE.map((part) => ({ name: `${part}'s title is its own heading`, code: component(`<${part} title="Files" />`, `import { ${part} } from '@estiva-app/ui'\n`) })),
|
|
40
|
+
{ name: "the app's own component with a title of its own (Peek's NewTopicBanner)", filename: app, code: component('<NewTopicBanner title={name} />', "import { NewTopicBanner } from '@/components/Banner'\n") },
|
|
41
|
+
{ name: "a story's title is the Storybook's, an object key", code: "const meta = { title: 'Primitives/Tooltip', component: Tooltip }\nexport default meta\n" },
|
|
42
|
+
{ name: 'a prop of another name', code: component('<span aria-label={name} data-title={name}>{name}</span>') },
|
|
43
|
+
{ name: 'an escape with its reason', code: component('<>\n{/* @estiva-escape: the print view has no pointer, and the address must survive a copy */}\n<span title={at}>{at}</span>\n</>') },
|
|
44
|
+
],
|
|
45
|
+
invalid: [
|
|
46
|
+
{
|
|
47
|
+
name: 'the file conversation timestamp at peek 3dc663b~1 (ForeignConversationView.tsx:106)',
|
|
48
|
+
code: component('<time\n dateTime={at}\n title={new Date(at).toLocaleString()}\n className="text-caption text-text-muted"\n>\n {at}\n</time>'),
|
|
49
|
+
errors: native,
|
|
50
|
+
},
|
|
51
|
+
{ name: "a reference chip's cut-short title (Reference.tsx at UIG-1's commits)", code: component('<span className="truncate" title={name}>{name}</span>'), errors: native },
|
|
52
|
+
{ name: 'a title that is only there sometimes (MentionChip.tsx)', code: component('<span title={name ? undefined : at}>{name}</span>'), errors: native },
|
|
53
|
+
{ name: 'a string', code: component('<div title="online presence" />'), errors: native },
|
|
54
|
+
{ name: "a part that hands it on to its element: the apps' status probe", code: component('<Button title="Delete">x</Button>', "import { Button } from '@estiva-app/ui'\n"), errors: native },
|
|
55
|
+
{ name: "through the app's re-export", filename: app, code: component('<IconButton title="Close" aria-label="Close">x</IconButton>', "import { IconButton } from '@/components/ui/IconButton'\n"), errors: native },
|
|
56
|
+
{ name: "through the app's wrapper that hands its props on", filename: app, code: component('<Button title="Delete">x</Button>', "import { Button } from '@/components/ui/Button'\n"), errors: native },
|
|
57
|
+
{ name: 'an escape with no reason is not an escape', code: component('<>\n{/* @estiva-escape */}\n<span title={at}>{at}</span>\n</>'), errors: [{ messageId: 'escapeWithoutReason' }, ...native] },
|
|
58
|
+
],
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('PARTS_WITH_A_TITLE', () => {
|
|
62
|
+
it('is every part the catalogue says takes a title of its own, and no other', () => {
|
|
63
|
+
const registry = JSON.parse(readFileSync(new URL('../../registry.json', import.meta.url), 'utf8')) as { entries: { name: string; props?: { name: string }[] | null }[] }
|
|
64
|
+
const declared = registry.entries.filter((entry) => entry.props?.some((prop) => prop.name === 'title')).map((entry) => entry.name).sort()
|
|
65
|
+
expect([...PARTS_WITH_A_TITLE].sort()).toEqual(declared)
|
|
66
|
+
})
|
|
67
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { Rule } from 'eslint'
|
|
2
|
+
import { ESCAPE_MESSAGES, isEscaped } from './escape'
|
|
3
|
+
import { bindingOfTag, partsOfFile, type Node, type Parser, type PartBinding } from './no-restyled-part'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The parts whose `title` is their own: the words of a heading or a dialog,
|
|
7
|
+
* drawn on the page. Every other part hands a `title` on to its element, and
|
|
8
|
+
* the browser shows it as its tooltip. `no-native-title.test.ts` holds this
|
|
9
|
+
* list to the catalogue: a part that gains a `title` of its own joins it.
|
|
10
|
+
*/
|
|
11
|
+
export const PARTS_WITH_A_TITLE = ['CollapsibleSection', 'ConfirmDialog', 'ContainerHeader', 'DialogShell', 'ListColumn', 'SectionHeader'] as const
|
|
12
|
+
|
|
13
|
+
const child = (node: Node, key: string) => node[key] as Node | null | undefined
|
|
14
|
+
const nameOf = (node: Node | null | undefined): string | undefined =>
|
|
15
|
+
node?.type === 'JSXIdentifier' || node?.type === 'Identifier' ? (node.name as string) : undefined
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* UIG-24: the browser's tooltip where ours belongs.
|
|
19
|
+
*
|
|
20
|
+
* A `title` on an element is the browser's hover box: grey, in the system's
|
|
21
|
+
* font, after a delay nobody chose, and never on a touch screen. The package's
|
|
22
|
+
* `WithTooltip` is the tooltip here (and `IconButton`'s `tooltip`, which draws
|
|
23
|
+
* it). The rule reads the attribute, never the page: a tooltip's words are not
|
|
24
|
+
* on the page until it opens.
|
|
25
|
+
*
|
|
26
|
+
* Judged: a `title` on a plain element, and on a part of the package (or an
|
|
27
|
+
* app's wrapper that hands all its props on to one) whose `title` is not its
|
|
28
|
+
* own — `<Button title>` reaches the `<button>`. Not judged, on purpose:
|
|
29
|
+
*
|
|
30
|
+
* - the `<title>` **element**, in an `<svg>` (a label for a screen reader) or
|
|
31
|
+
* a document's head: an element, not an attribute, so the rule never sees it;
|
|
32
|
+
* - a part whose `title` is its own heading (`PARTS_WITH_A_TITLE`);
|
|
33
|
+
* - the app's own components: what one does with a `title` is its own business,
|
|
34
|
+
* and its elements are judged where it draws them;
|
|
35
|
+
* - a story's `title` (`meta = { title: 'Group/Name' }`): an object key, not an
|
|
36
|
+
* attribute.
|
|
37
|
+
*/
|
|
38
|
+
export const noNativeTitle: Rule.RuleModule = {
|
|
39
|
+
meta: {
|
|
40
|
+
type: 'problem',
|
|
41
|
+
docs: { description: "A title attribute: the browser's tooltip where WithTooltip is the part" },
|
|
42
|
+
schema: [],
|
|
43
|
+
messages: {
|
|
44
|
+
native:
|
|
45
|
+
"`title` is the browser's tooltip, not ours. Wrap it in `WithTooltip` from @estiva-app/ui, or use `IconButton`'s `tooltip`: its page (Primitives/Tooltip) says when it shows and how.",
|
|
46
|
+
...ESCAPE_MESSAGES,
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
create(context) {
|
|
50
|
+
const parser = (context.languageOptions?.parser ?? {}) as Parser
|
|
51
|
+
let locals: Map<string, PartBinding | 'namespace'> | undefined
|
|
52
|
+
return {
|
|
53
|
+
Program(program) {
|
|
54
|
+
locals = partsOfFile(context.filename, program as unknown as Node, parser)
|
|
55
|
+
},
|
|
56
|
+
JSXAttribute(ruleNode: Rule.Node) {
|
|
57
|
+
const attribute = ruleNode as unknown as Node
|
|
58
|
+
if (nameOf(child(attribute, 'name')) !== 'title') return
|
|
59
|
+
const opening = attribute.parent as Node
|
|
60
|
+
const tag = child(opening, 'name')
|
|
61
|
+
const name = nameOf(tag)
|
|
62
|
+
if (!(name && /^[a-z]/.test(name))) {
|
|
63
|
+
const binding = locals && bindingOfTag(tag, locals)
|
|
64
|
+
if (!binding || binding.props !== 'all') return
|
|
65
|
+
if ((PARTS_WITH_A_TITLE as readonly string[]).includes(binding.part)) return
|
|
66
|
+
}
|
|
67
|
+
if (isEscaped(context, opening)) return
|
|
68
|
+
context.report({ loc: attribute.loc as NonNullable<Rule.Node['loc']>, messageId: 'native' })
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
}
|
|
@@ -399,6 +399,17 @@ function moduleParts(file: string, program: Node, parser: Parser, stack: Set<str
|
|
|
399
399
|
return { locals, exports }
|
|
400
400
|
}
|
|
401
401
|
|
|
402
|
+
/**
|
|
403
|
+
* What a file's names stand for, found the way this rule finds them: for a rule
|
|
404
|
+
* that asks whether a tag is one particular part, through an app's wrapper too
|
|
405
|
+
* (UIG-23 asks for `EmptyState` and `ScrollArea`).
|
|
406
|
+
*/
|
|
407
|
+
export function partsOfFile(file: string, program: Node, parser: Parser): Map<string, PartBinding | 'namespace'> {
|
|
408
|
+
return moduleParts(file, program, parser, new Set([file])).locals
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export { bindingOfTag, functionComponents, parseFile, resolveModule, walk, type Node, type Parser }
|
|
412
|
+
|
|
402
413
|
function moduleExports(file: string, parser: Parser, stack: Set<string>): Map<string, PartBinding> {
|
|
403
414
|
const mtime = statSync(file).mtimeMs
|
|
404
415
|
const cached = moduleCache.get(file)
|
|
@@ -59,7 +59,7 @@ describe('create-estiva-app', () => {
|
|
|
59
59
|
|
|
60
60
|
it('starts the count at zero and keeps the CI job named gate', () => {
|
|
61
61
|
const count = JSON.parse(files['.gates-count.json'])
|
|
62
|
-
expect(Object.values(count.rules)).toEqual([{ errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }])
|
|
62
|
+
expect(Object.values(count.rules)).toEqual([{ errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }])
|
|
63
63
|
expect(files['.github/workflows/deploy.yml']).toMatch(/\n {2}gate:\n[\s\S]*npm run lint:rules/)
|
|
64
64
|
expect(files['docs/GATES-DEBT.md']).toContain('Nothing.')
|
|
65
65
|
})
|
package/src/gates/gates.test.ts
CHANGED
|
@@ -153,7 +153,7 @@ describe('writeGateCount', () => {
|
|
|
153
153
|
const written = JSON.parse(readFileSync(join(dir, '.gates-count.json'), 'utf8'))
|
|
154
154
|
expect(written).toMatchObject({ schemaVersion: 1, repo: 'probe' })
|
|
155
155
|
expect(written.rules['estiva/no-raw-element']).toEqual({ errors: 0, warnings: 0, escapes: 1 })
|
|
156
|
-
expect(Object.keys(written.rules)).toEqual(['estiva/no-raw-element', 'estiva/no-rebuilt-behaviour', 'estiva/no-restyled-part', 'estiva/no-handmade-header'])
|
|
156
|
+
expect(Object.keys(written.rules)).toEqual(['estiva/no-raw-element', 'estiva/no-rebuilt-behaviour', 'estiva/no-restyled-part', 'estiva/no-handmade-header', 'estiva/no-handmade-empty-state', 'estiva/no-native-title'])
|
|
157
157
|
|
|
158
158
|
expect((await writeGateCount({ root: dir, repo: 'probe' })).changed).toBe(false)
|
|
159
159
|
|