@estiva-app/ui 0.31.0 → 0.32.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.
@@ -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
+ }
@@ -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 }])
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
  })
@@ -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'])
157
157
 
158
158
  expect((await writeGateCount({ root: dir, repo: 'probe' })).changed).toBe(false)
159
159