@estiva-app/ui 0.18.0 → 0.20.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.
Files changed (84) hide show
  1. package/README.md +13 -5
  2. package/dist/AttachmentCard.d.ts.map +1 -1
  3. package/dist/Breadcrumb.d.ts.map +1 -1
  4. package/dist/Button.d.ts +6 -1
  5. package/dist/Button.d.ts.map +1 -1
  6. package/dist/Card.d.ts +6 -1
  7. package/dist/Card.d.ts.map +1 -1
  8. package/dist/Checkbox.d.ts +16 -1
  9. package/dist/Checkbox.d.ts.map +1 -1
  10. package/dist/DialogShell.d.ts.map +1 -1
  11. package/dist/Divider.d.ts +7 -0
  12. package/dist/Divider.d.ts.map +1 -1
  13. package/dist/IconButton.d.ts +12 -2
  14. package/dist/IconButton.d.ts.map +1 -1
  15. package/dist/IdentityMenu.d.ts.map +1 -1
  16. package/dist/Link.d.ts +6 -1
  17. package/dist/Link.d.ts.map +1 -1
  18. package/dist/Menu.d.ts.map +1 -1
  19. package/dist/Popover.d.ts.map +1 -1
  20. package/dist/ReactionPicker.d.ts.map +1 -1
  21. package/dist/ScrollArea.d.ts +6 -0
  22. package/dist/ScrollArea.d.ts.map +1 -1
  23. package/dist/SectionLabel.d.ts +6 -1
  24. package/dist/SectionLabel.d.ts.map +1 -1
  25. package/dist/Tooltip.d.ts.map +1 -1
  26. package/dist/eslint/index.d.ts +4 -0
  27. package/dist/eslint/index.d.ts.map +1 -1
  28. package/dist/eslint/index.js +968 -3
  29. package/dist/eslint/index.js.map +4 -4
  30. package/dist/eslint/no-rebuilt-behaviour.d.ts +88 -0
  31. package/dist/eslint/no-rebuilt-behaviour.d.ts.map +1 -0
  32. package/dist/eslint/no-restyled-part.d.ts +81 -0
  33. package/dist/eslint/no-restyled-part.d.ts.map +1 -0
  34. package/dist/index.js +300 -268
  35. package/dist/index.js.map +4 -4
  36. package/package.json +1 -1
  37. package/src/AttachmentCard.tsx +7 -2
  38. package/src/Banner.tsx +2 -2
  39. package/src/Breadcrumb.tsx +9 -4
  40. package/src/Button.mdx +7 -0
  41. package/src/Button.stories.tsx +4 -1
  42. package/src/Button.tsx +10 -2
  43. package/src/Card.mdx +7 -0
  44. package/src/Card.stories.tsx +11 -0
  45. package/src/Card.tsx +8 -0
  46. package/src/Checkbox.mdx +20 -1
  47. package/src/Checkbox.stories.tsx +44 -0
  48. package/src/Checkbox.test.tsx +63 -0
  49. package/src/Checkbox.tsx +30 -1
  50. package/src/CommandPalette.tsx +1 -1
  51. package/src/ConfirmDialog.tsx +2 -2
  52. package/src/DialogShell.tsx +6 -7
  53. package/src/Divider.mdx +3 -0
  54. package/src/Divider.tsx +12 -2
  55. package/src/EditableText.mdx +5 -0
  56. package/src/IconButton.mdx +9 -0
  57. package/src/IconButton.stories.tsx +10 -1
  58. package/src/IconButton.tsx +22 -3
  59. package/src/IdentityMenu.tsx +4 -1
  60. package/src/Link.mdx +6 -0
  61. package/src/Link.stories.tsx +12 -0
  62. package/src/Link.tsx +7 -2
  63. package/src/Menu.stories.tsx +1 -1
  64. package/src/Menu.test.tsx +2 -2
  65. package/src/Menu.tsx +6 -3
  66. package/src/Person.stories.tsx +5 -5
  67. package/src/Popover.test.tsx +2 -2
  68. package/src/Popover.tsx +3 -2
  69. package/src/PreviewCard.tsx +1 -1
  70. package/src/Property.stories.tsx +2 -2
  71. package/src/ReactionPicker.tsx +6 -3
  72. package/src/ScrollArea.stories.tsx +38 -6
  73. package/src/ScrollArea.tsx +7 -1
  74. package/src/SectionLabel.mdx +3 -2
  75. package/src/SectionLabel.stories.tsx +3 -0
  76. package/src/SectionLabel.tsx +7 -2
  77. package/src/Tooltip.tsx +12 -14
  78. package/src/eslint/index.test.ts +58 -9
  79. package/src/eslint/index.ts +19 -3
  80. package/src/eslint/no-rebuilt-behaviour.test.ts +276 -0
  81. package/src/eslint/no-rebuilt-behaviour.ts +593 -0
  82. package/src/eslint/no-restyled-part.test.ts +301 -0
  83. package/src/eslint/no-restyled-part.ts +647 -0
  84. package/stories/Choosing.mdx +1 -1
@@ -0,0 +1,647 @@
1
+ import { existsSync, readFileSync, statSync } from 'node:fs'
2
+ import { dirname, join, resolve } from 'node:path'
3
+ import type { Rule, Scope } from 'eslint'
4
+ import { ESCAPE_MESSAGES, isEscaped } from './escape'
5
+
6
+ /**
7
+ * A part of the package is placed from outside, never restyled (UIG-9; Katerina's
8
+ * ruling B8 of 13 September, "only position and spacing pass into our
9
+ * components").
10
+ *
11
+ * Every part takes `className`, and some take a second class prop for an inner
12
+ * box (`contentClassName`, `bodyClassName`…). Through either, an app could push
13
+ * a colour, a text size or a border into a part and quietly undo it: the token
14
+ * lint only asks whether a class is a real token, never whether the part should
15
+ * take it. This rule reads every class passed into a part and lets through only
16
+ * placement — where the part sits and how much room it takes.
17
+ *
18
+ * **Which elements are parts: by where they come from, never by name.** No list
19
+ * of names is kept, so a part added to the package later is covered the day it
20
+ * is added.
21
+ *
22
+ * - In an app: anything imported from `@estiva-app/ui`, and anything imported
23
+ * from the app's own files that is one of those — a re-export (Peek's
24
+ * `components/ui/Button.tsx`), a wrapper that hands its props on to a part
25
+ * (`{...rest}`), or a component that hands its own `className` on to a part
26
+ * (Peek's `ConversationCard` into `Card`). Relative imports and the apps' `@/`
27
+ * alias are followed; `@/` is the `src` folder beside the nearest
28
+ * `package.json`.
29
+ * - Inside the package (Katerina, 17 September): a name `src/index.ts` exports,
30
+ * imported from a sibling file or declared in the same file.
31
+ *
32
+ * **What it reads:** a string, a template, `cn()`/`clsx()` and their arguments,
33
+ * both sides of a condition, a `const` in the same file and the values of a
34
+ * class map read from one. A class the code works out while it runs passes: the
35
+ * rule cannot read it.
36
+ *
37
+ * `EmptyState` takes no padding either (Katerina's ruling 6 of 14 September):
38
+ * its room comes from the box its rows live in.
39
+ */
40
+
41
+ // ---------------------------------------------------------------- the classes
42
+
43
+ /** Where a part sits and how much room it takes. Read on the utility, after its variants. */
44
+ export const PLACEMENT: { what: string; pattern: RegExp }[] = [
45
+ { what: 'space around', pattern: /^m[xytrblse]?-/ },
46
+ { what: 'space inside', pattern: /^p[xytrblse]?-/ },
47
+ { what: 'width and height', pattern: /^(?:w|h|size|min-w|min-h|max-w|max-h)-/ },
48
+ { what: 'shown, and how it lays out', pattern: /^(?:hidden|block|inline|inline-block|inline-flex|inline-grid|flex|grid|contents|flow-root)$/ },
49
+ { what: 'its place in a row or a grid', pattern: /^(?:grow|shrink)$|^(?:flex|basis|grow|shrink|order|gap|gap-x|gap-y|space-x|space-y|col|row|grid-cols|grid-rows|grid-flow|auto-cols|auto-rows)-/ },
50
+ { what: 'alignment', pattern: /^(?:self|justify|justify-items|justify-self|items|place-content|place-items|place-self)-|^content-(?!\[)/ },
51
+ { what: 'position', pattern: /^(?:static|relative|absolute|fixed|sticky)$|^(?:inset|inset-x|inset-y|top|right|bottom|left|start|end|z)-/ },
52
+ // Draws nothing: it names the part, so what is inside can answer its hover (`group-hover:`).
53
+ { what: 'a name for hover', pattern: /^(?:group|peer)(?:\/[\w-]+)?$/ },
54
+ ]
55
+
56
+ /**
57
+ * A variant that draws another box or reaches inside the part: `before:`,
58
+ * `after:`, `[&>*]:`, `*:`… A placement class behind one of these is not the
59
+ * part's placement.
60
+ */
61
+ const REACHING_VARIANT = /^\[|^(?:before|after|placeholder|file|marker|selection|first-letter|first-line|backdrop|\*|\*\*)$/
62
+
63
+ /** A class list's variants and its utility: `sm:[&>*]:shrink-0` → `['sm', '[&>*]']`, `shrink-0`. */
64
+ export function splitClass(token: string): { variants: string[]; utility: string } {
65
+ const variants: string[] = []
66
+ let current = ''
67
+ let depth = 0
68
+ for (const character of token) {
69
+ if (character === '[') depth += 1
70
+ if (character === ']') depth -= 1
71
+ if (character === ':' && depth === 0) {
72
+ variants.push(current)
73
+ current = ''
74
+ } else current += character
75
+ }
76
+ return { variants, utility: current.replace(/^!/, '').replace(/!$/, '').replace(/^-/, '') }
77
+ }
78
+
79
+ /** Whether a class only places the part. */
80
+ export function isPlacement(token: string): boolean {
81
+ const { variants, utility } = splitClass(token)
82
+ if (variants.some((variant) => REACHING_VARIANT.test(variant))) return false
83
+ return PLACEMENT.some(({ pattern }) => pattern.test(utility))
84
+ }
85
+
86
+ const isPadding = (token: string) => {
87
+ const { variants, utility } = splitClass(token)
88
+ return !variants.some((variant) => REACHING_VARIANT.test(variant)) && /^p[xytrblse]?-/.test(utility)
89
+ }
90
+
91
+ /**
92
+ * The props a part has for how it looks, named in the message. Held to the
93
+ * package's source by `no-restyled-part.test.ts`: every prop here exists, and
94
+ * every look prop an exported part has is here. A part missing from this table
95
+ * has none, and the message says to ask for one.
96
+ */
97
+ export const PART_LOOK_PROPS: Record<string, string[]> = {
98
+ AppShell: ['variant'],
99
+ Avatar: ['size'],
100
+ AvatarGroup: ['size'],
101
+ Banner: ['tone'],
102
+ Button: ['variant', 'size'],
103
+ Card: ['fill', 'hover', 'attention', 'selected', 'active', 'hovered', 'quietUntilHover', 'unreadable', 'clip'],
104
+ Checkbox: ['row'],
105
+ Chip: ['type'],
106
+ Divider: ['tone', 'orientation'],
107
+ EmptyState: ['scope'],
108
+ FieldLine: ['tone'],
109
+ IconButton: ['variant', 'pressed', 'glow'],
110
+ InputChip: ['truncate'],
111
+ Link: ['variant', 'truncate'],
112
+ MenuItem: ['size', 'selected', 'destructive'],
113
+ MenuSub: ['selected'],
114
+ NavItem: ['active'],
115
+ Person: ['size'],
116
+ ProgressBar: ['variant'],
117
+ Property: ['layout'],
118
+ RailItem: ['active'],
119
+ Reaction: ['pressed'],
120
+ ReactionPicker: ['surface'],
121
+ SectionLabel: ['tone'],
122
+ Select: ['size'],
123
+ Tabs: ['size'],
124
+ TextInput: ['size'],
125
+ Toast: ['type'],
126
+ Toolbar: ['surface'],
127
+ ToolbarButton: ['variant', 'pressed', 'glow'],
128
+ TopBar: ['variant'],
129
+ }
130
+
131
+ /** The class props of a part: `className`, and the ones for an inner box. */
132
+ const CLASS_PROP = /^(?:className|[a-z][A-Za-z]*ClassName)$/
133
+
134
+ // ---------------------------------------------------------------- the nodes
135
+
136
+ interface Node {
137
+ type: string
138
+ range: [number, number]
139
+ loc?: NonNullable<Rule.Node['loc']>
140
+ parent?: Node
141
+ [key: string]: unknown
142
+ }
143
+
144
+ const child = (node: Node, key: string) => node[key] as Node | null | undefined
145
+ const children = (node: Node, key: string) => (node[key] as (Node | null)[] | undefined) ?? []
146
+ const nameOf = (node: Node | null | undefined): string | undefined =>
147
+ node?.type === 'Identifier' || node?.type === 'JSXIdentifier' ? (node.name as string) : node?.type === 'Literal' && typeof node.value === 'string' ? node.value : undefined
148
+
149
+ /** Walk every node under `root`, skipping the parser's bookkeeping keys. */
150
+ function walk(root: Node, visit: (node: Node) => void): void {
151
+ const stack: Node[] = [root]
152
+ while (stack.length) {
153
+ const node = stack.pop() as Node
154
+ visit(node)
155
+ for (const key of Object.keys(node)) {
156
+ if (key === 'parent' || key === 'loc' || key === 'range' || key === 'tokens' || key === 'comments') continue
157
+ const value = node[key]
158
+ if (Array.isArray(value)) for (const item of value) { if (item && typeof (item as Node).type === 'string') stack.push(item as Node) }
159
+ else if (value && typeof (value as Node).type === 'string') stack.push(value as Node)
160
+ }
161
+ }
162
+ }
163
+
164
+ // ---------------------------------------------------------------- the parts
165
+
166
+ /**
167
+ * What a name is to this rule: the part it draws, and which of the caller's
168
+ * class props reach which of the part's (`all` for a part, a re-export or a
169
+ * wrapper; a map for a component that hands on its own `className`). `via` is
170
+ * the app's own component in between, for the message.
171
+ */
172
+ export interface PartBinding {
173
+ part: string
174
+ props: 'all' | Record<string, string>
175
+ via?: string
176
+ }
177
+
178
+ interface Parser {
179
+ parseForESLint?: (code: string, options: unknown) => { ast: Node }
180
+ parse?: (code: string, options: unknown) => Node
181
+ }
182
+
183
+ interface ModuleFacts {
184
+ mtime: number
185
+ exports: Map<string, PartBinding>
186
+ }
187
+
188
+ const PACKAGE = '@estiva-app/ui'
189
+ const moduleCache = new Map<string, ModuleFacts>()
190
+ const packageDirCache = new Map<string, string | null>()
191
+ const packagePartsCache = new Map<string, { mtime: number; parts: Set<string> }>()
192
+
193
+ function parseFile(parser: Parser, filePath: string): Node | null {
194
+ const code = readFileSync(filePath, 'utf8')
195
+ const options = { filePath, ecmaVersion: 'latest', sourceType: 'module', ecmaFeatures: { jsx: filePath.endsWith('x') }, range: true, loc: true }
196
+ try {
197
+ if (parser.parseForESLint) return parser.parseForESLint(code, options).ast
198
+ if (parser.parse) return parser.parse(code, options)
199
+ } catch {
200
+ // A file that does not parse is its own lint's business; it hands on no parts.
201
+ }
202
+ return null
203
+ }
204
+
205
+ /** The nearest folder holding a `package.json`, from a file. */
206
+ function packageDirOf(file: string): string | null {
207
+ const start = dirname(resolve(file))
208
+ if (packageDirCache.has(start)) return packageDirCache.get(start) ?? null
209
+ let dir = start
210
+ for (;;) {
211
+ if (existsSync(join(dir, 'package.json'))) break
212
+ const up = dirname(dir)
213
+ if (up === dir) {
214
+ dir = ''
215
+ break
216
+ }
217
+ dir = up
218
+ }
219
+ packageDirCache.set(start, dir || null)
220
+ return dir || null
221
+ }
222
+
223
+ function resolveModule(fromFile: string, specifier: string): string | null {
224
+ let base: string
225
+ if (specifier.startsWith('.')) base = resolve(dirname(fromFile), specifier)
226
+ else if (specifier.startsWith('@/')) {
227
+ const dir = packageDirOf(fromFile)
228
+ if (!dir) return null
229
+ base = join(dir, 'src', specifier.slice(2))
230
+ } else return null
231
+ for (const candidate of [base, `${base}.tsx`, `${base}.ts`, join(base, 'index.tsx'), join(base, 'index.ts')]) {
232
+ if (existsSync(candidate) && statSync(candidate).isFile()) return candidate
233
+ }
234
+ return null
235
+ }
236
+
237
+ /** The names this package's `src/index.ts` exports, when the file lives in `@estiva-app/ui` itself. */
238
+ function packagePartsFor(file: string, parser: Parser): Set<string> | null {
239
+ const dir = packageDirOf(file)
240
+ if (!dir) return null
241
+ let name: string | undefined
242
+ try {
243
+ name = (JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { name?: string }).name
244
+ } catch {
245
+ return null
246
+ }
247
+ if (name !== PACKAGE) return null
248
+ const index = join(dir, 'src', 'index.ts')
249
+ if (!existsSync(index)) return null
250
+ const mtime = statSync(index).mtimeMs
251
+ const cached = packagePartsCache.get(index)
252
+ if (cached && cached.mtime === mtime) return cached.parts
253
+ const parts = new Set<string>()
254
+ const ast = parseFile(parser, index)
255
+ for (const statement of ast ? children(ast, 'body') : []) {
256
+ if (statement?.type !== 'ExportNamedDeclaration' || statement.exportKind === 'type') continue
257
+ for (const specifier of children(statement, 'specifiers')) {
258
+ const exported = nameOf(child(specifier as Node, 'exported'))
259
+ if (specifier?.exportKind !== 'type' && exported && /^[A-Z][a-z]/.test(exported)) parts.add(exported)
260
+ }
261
+ }
262
+ packagePartsCache.set(index, { mtime, parts })
263
+ return parts
264
+ }
265
+
266
+ /** A function component declared in a module: its name, and the function. */
267
+ function functionComponents(program: Node): Map<string, Node> {
268
+ const found = new Map<string, Node>()
269
+ const unwrap = (node: Node | null | undefined): Node | null => {
270
+ let current = node
271
+ while (current?.type === 'CallExpression' && children(current, 'arguments').length) {
272
+ const callee = child(current, 'callee')
273
+ const text = callee?.type === 'Identifier' ? nameOf(callee) : callee?.type === 'MemberExpression' ? nameOf(child(callee, 'property')) : undefined
274
+ if (text !== 'forwardRef' && text !== 'memo') return null
275
+ current = children(current, 'arguments')[0]
276
+ }
277
+ return current?.type === 'ArrowFunctionExpression' || current?.type === 'FunctionExpression' ? current : null
278
+ }
279
+ walk(program, (node) => {
280
+ if (node.type === 'FunctionDeclaration') {
281
+ const name = nameOf(child(node, 'id'))
282
+ if (name && /^[A-Z]/.test(name)) found.set(name, node)
283
+ } else if (node.type === 'VariableDeclarator') {
284
+ const name = nameOf(child(node, 'id'))
285
+ const fn = unwrap(child(node, 'init'))
286
+ if (name && /^[A-Z]/.test(name) && fn) found.set(name, fn)
287
+ }
288
+ })
289
+ return found
290
+ }
291
+
292
+ /**
293
+ * The parts a module's names stand for. `ownFile` is the file being linted: its
294
+ * imports are read the same way, so a wrapper declared beside its use counts.
295
+ */
296
+ function moduleParts(file: string, program: Node, parser: Parser, stack: Set<string>): { locals: Map<string, PartBinding | 'namespace'>; exports: Map<string, PartBinding> } {
297
+ const locals = new Map<string, PartBinding | 'namespace'>()
298
+ const packageParts = packagePartsFor(file, parser)
299
+ const body = children(program, 'body')
300
+
301
+ const exportsOf = (specifier: string): Map<string, PartBinding> | 'package' | null => {
302
+ if (specifier === PACKAGE) return 'package'
303
+ const target = resolveModule(file, specifier)
304
+ if (!target) return null
305
+ if (packageParts) return 'package'
306
+ return moduleExports(target, parser, stack)
307
+ }
308
+
309
+ for (const statement of body) {
310
+ if (statement?.type !== 'ImportDeclaration' || statement.importKind === 'type') continue
311
+ const source = exportsOf(String((child(statement, 'source') as Node).value))
312
+ if (!source) continue
313
+ for (const specifier of children(statement, 'specifiers')) {
314
+ if (!specifier || specifier.importKind === 'type') continue
315
+ const local = nameOf(child(specifier, 'local'))
316
+ if (!local) continue
317
+ if (specifier.type === 'ImportNamespaceSpecifier') {
318
+ if (source === 'package' && !packageParts) locals.set(local, 'namespace')
319
+ continue
320
+ }
321
+ const imported = specifier.type === 'ImportDefaultSpecifier' ? 'default' : nameOf(child(specifier, 'imported'))
322
+ if (!imported) continue
323
+ if (source === 'package') {
324
+ const isPart = packageParts ? packageParts.has(imported) : /^[A-Z]/.test(imported)
325
+ if (isPart) locals.set(local, { part: imported, props: 'all' })
326
+ } else if (source.has(imported)) locals.set(local, source.get(imported) as PartBinding)
327
+ }
328
+ }
329
+
330
+ // Inside the package, a part used in the file that declares it.
331
+ const components = functionComponents(program)
332
+ if (packageParts) for (const name of components.keys()) if (packageParts.has(name) && !locals.has(name)) locals.set(name, { part: name, props: 'all' })
333
+
334
+ // In an app, a component that hands its props, or its own className, on to a part.
335
+ if (!packageParts) {
336
+ let changed = true
337
+ for (let round = 0; changed && round < 10; round += 1) {
338
+ changed = false
339
+ for (const [name, fn] of components) {
340
+ const binding = handsOnTo(fn, (tag) => bindingOfTag(tag, locals))
341
+ if (!binding) continue
342
+ const next = { ...binding, via: name }
343
+ const previous = locals.get(name)
344
+ if (!previous || JSON.stringify(previous) !== JSON.stringify(next)) {
345
+ if (previous && previous !== 'namespace' && !previous.via) continue
346
+ locals.set(name, next)
347
+ changed = true
348
+ }
349
+ }
350
+ }
351
+ }
352
+
353
+ const exports = new Map<string, PartBinding>()
354
+ const localBinding = (name: string | undefined) => {
355
+ const binding = name ? locals.get(name) : undefined
356
+ return binding && binding !== 'namespace' ? binding : undefined
357
+ }
358
+ for (const statement of body) {
359
+ if (!statement) continue
360
+ if (statement.type === 'ExportNamedDeclaration') {
361
+ const declaration = child(statement, 'declaration')
362
+ if (declaration?.type === 'FunctionDeclaration') {
363
+ const name = nameOf(child(declaration, 'id'))
364
+ const binding = localBinding(name)
365
+ if (name && binding) exports.set(name, binding)
366
+ } else if (declaration?.type === 'VariableDeclaration') {
367
+ for (const declarator of children(declaration, 'declarations')) {
368
+ const name = nameOf(child(declarator as Node, 'id'))
369
+ const init = child(declarator as Node, 'init')
370
+ const binding = localBinding(name) ?? (init?.type === 'Identifier' ? localBinding(nameOf(init)) : undefined)
371
+ if (name && binding) exports.set(name, binding)
372
+ }
373
+ }
374
+ const source = child(statement, 'source')
375
+ const from = source ? exportsOf(String(source.value)) : null
376
+ for (const specifier of children(statement, 'specifiers')) {
377
+ if (!specifier || specifier.exportKind === 'type') continue
378
+ const local = nameOf(child(specifier, 'local'))
379
+ const exported = nameOf(child(specifier, 'exported'))
380
+ if (!local || !exported) continue
381
+ if (!source) {
382
+ const binding = localBinding(local)
383
+ if (binding) exports.set(exported, binding)
384
+ } else if (from === 'package') {
385
+ if (/^[A-Z]/.test(local)) exports.set(exported, { part: local, props: 'all' })
386
+ } else if (from?.has(local)) exports.set(exported, from.get(local) as PartBinding)
387
+ }
388
+ } else if (statement.type === 'ExportAllDeclaration' && !child(statement, 'exported')) {
389
+ const from = exportsOf(String((child(statement, 'source') as Node).value))
390
+ if (from && from !== 'package') for (const [name, binding] of from) exports.set(name, binding)
391
+ } else if (statement.type === 'ExportDefaultDeclaration') {
392
+ const declaration = child(statement, 'declaration')
393
+ const name = declaration?.type === 'Identifier' ? nameOf(declaration) : nameOf(child(declaration as Node, 'id'))
394
+ const binding = localBinding(name)
395
+ if (binding) exports.set('default', binding)
396
+ }
397
+ }
398
+ return { locals, exports }
399
+ }
400
+
401
+ function moduleExports(file: string, parser: Parser, stack: Set<string>): Map<string, PartBinding> {
402
+ const mtime = statSync(file).mtimeMs
403
+ const cached = moduleCache.get(file)
404
+ if (cached && cached.mtime === mtime) return cached.exports
405
+ if (stack.has(file)) return new Map()
406
+ stack.add(file)
407
+ const program = parseFile(parser, file)
408
+ const exports = program ? moduleParts(file, program, parser, stack).exports : new Map<string, PartBinding>()
409
+ stack.delete(file)
410
+ moduleCache.set(file, { mtime, exports })
411
+ return exports
412
+ }
413
+
414
+ function bindingOfTag(tag: Node | null | undefined, locals: Map<string, PartBinding | 'namespace'>): PartBinding | undefined {
415
+ if (!tag) return undefined
416
+ if (tag.type === 'JSXIdentifier') {
417
+ const binding = locals.get(tag.name as string)
418
+ return binding && binding !== 'namespace' ? binding : undefined
419
+ }
420
+ if (tag.type === 'JSXMemberExpression') {
421
+ const object = child(tag, 'object')
422
+ const property = nameOf(child(tag, 'property'))
423
+ if (object?.type === 'JSXIdentifier' && locals.get(object.name as string) === 'namespace' && property) return { part: property, props: 'all' }
424
+ }
425
+ return undefined
426
+ }
427
+
428
+ /**
429
+ * Whether a function component hands its props on to a part: its rest or its
430
+ * whole props spread onto one (a wrapper), or its own class props passed into
431
+ * one's class props (a component that places a part by its caller's word).
432
+ */
433
+ function handsOnTo(fn: Node, bindingOf: (tag: Node | null | undefined) => PartBinding | undefined): PartBinding | undefined {
434
+ const param = children(fn, 'params')[0]
435
+ if (!param) return undefined
436
+ let restName: string | undefined
437
+ let propsName: string | undefined
438
+ const classParams = new Map<string, string>()
439
+ if (param.type === 'ObjectPattern') {
440
+ for (const property of children(param, 'properties')) {
441
+ if (!property) continue
442
+ if (property.type === 'RestElement') restName = nameOf(child(property, 'argument'))
443
+ else if (property.type === 'Property') {
444
+ const key = nameOf(child(property, 'key'))
445
+ let value = child(property, 'value')
446
+ if (value?.type === 'AssignmentPattern') value = child(value, 'left')
447
+ const local = nameOf(value)
448
+ if (key && local && CLASS_PROP.test(key)) classParams.set(local, key)
449
+ }
450
+ }
451
+ } else if (param.type === 'Identifier') propsName = nameOf(param)
452
+
453
+ let wrapper: PartBinding | undefined
454
+ const forwarded: { part?: string; props: Record<string, string> } = { props: {} }
455
+ const body = child(fn, 'body')
456
+ if (!body) return undefined
457
+ walk(body, (node) => {
458
+ if (wrapper || node.type !== 'JSXOpeningElement') return
459
+ const binding = bindingOf(child(node, 'name'))
460
+ if (!binding) return
461
+ for (const attribute of children(node, 'attributes')) {
462
+ if (!attribute) continue
463
+ if (attribute.type === 'JSXSpreadAttribute') {
464
+ const argument = nameOf(child(attribute, 'argument'))
465
+ if (argument && (argument === restName || argument === propsName)) wrapper = { part: binding.part, props: binding.props }
466
+ continue
467
+ }
468
+ const prop = nameOf(child(attribute, 'name'))
469
+ if (!prop || !CLASS_PROP.test(prop)) continue
470
+ const partProp = binding.props === 'all' ? prop : binding.props[prop]
471
+ if (!partProp) continue
472
+ walk(attribute, (inner) => {
473
+ let callerProp: string | undefined
474
+ if (inner.type === 'Identifier') callerProp = classParams.get(inner.name as string)
475
+ if (inner.type === 'MemberExpression' && nameOf(child(inner, 'object')) === propsName) {
476
+ const key = nameOf(child(inner, 'property'))
477
+ if (key && CLASS_PROP.test(key)) callerProp = key
478
+ }
479
+ if (!callerProp) return
480
+ if (forwarded.part && forwarded.part !== binding.part) return
481
+ forwarded.part = binding.part
482
+ forwarded.props[callerProp] = partProp
483
+ })
484
+ }
485
+ })
486
+ if (wrapper) return wrapper
487
+ return forwarded.part ? { part: forwarded.part, props: forwarded.props } : undefined
488
+ }
489
+
490
+ // ---------------------------------------------------------------- reading a class list
491
+
492
+ const CLASS_FUNCTIONS = new Set(['cn', 'clsx', 'cx', 'twMerge', 'classNames'])
493
+
494
+ /** The `const` a name stands for in this file, if it is one. */
495
+ function constantOf(identifier: Node, scope: Scope.Scope | null): Node | undefined {
496
+ let current = scope
497
+ while (current) {
498
+ const variable = current.set.get(identifier.name as string)
499
+ if (variable) {
500
+ const definition = variable.defs[0] as unknown as { type: string; node: Node; parent?: Node } | undefined
501
+ if (variable.defs.length !== 1 || definition?.type !== 'Variable') return undefined
502
+ if ((definition.parent as Node | undefined)?.kind !== 'const') return undefined
503
+ return child(definition.node, 'init') ?? undefined
504
+ }
505
+ current = current.upper
506
+ }
507
+ return undefined
508
+ }
509
+
510
+ /**
511
+ * Every class the code spells out for a value, as far as it can be read in this
512
+ * file. `asMap` reads an object as a class map (its values) rather than as a
513
+ * `clsx` object (its keys).
514
+ */
515
+ export function classesOf(value: Node | null | undefined, scopeOf: (node: Node) => Scope.Scope | null, asMap = false, seen = new Set<Node>()): string[] {
516
+ if (!value || seen.has(value) || seen.size > 200) return []
517
+ seen.add(value)
518
+ const read = (node: Node | null | undefined, map = false) => classesOf(node, scopeOf, map, seen)
519
+ const words = (text: unknown) => (typeof text === 'string' ? text.split(/\s+/).filter(Boolean) : [])
520
+ switch (value.type) {
521
+ case 'Literal':
522
+ return words(value.value)
523
+ case 'TemplateLiteral':
524
+ return [...children(value, 'quasis').flatMap((quasi) => words((quasi?.value as { cooked?: string }).cooked)), ...children(value, 'expressions').flatMap((e) => read(e))]
525
+ case 'JSXExpressionContainer':
526
+ case 'TSAsExpression':
527
+ case 'TSSatisfiesExpression':
528
+ case 'TSNonNullExpression':
529
+ case 'ChainExpression':
530
+ return read(child(value, 'expression'), asMap)
531
+ case 'ConditionalExpression':
532
+ return [...read(child(value, 'consequent')), ...read(child(value, 'alternate'))]
533
+ case 'LogicalExpression':
534
+ return value.operator === '&&' ? read(child(value, 'right')) : [...read(child(value, 'left')), ...read(child(value, 'right'))]
535
+ case 'BinaryExpression':
536
+ return value.operator === '+' ? [...read(child(value, 'left')), ...read(child(value, 'right'))] : []
537
+ case 'ArrayExpression':
538
+ return children(value, 'elements').flatMap((e) => read(e))
539
+ case 'ObjectExpression':
540
+ return children(value, 'properties').flatMap((property) => {
541
+ if (property?.type !== 'Property') return []
542
+ return asMap ? read(child(property, 'value'), true) : property.computed ? [] : words(nameOf(child(property, 'key')))
543
+ })
544
+ case 'CallExpression': {
545
+ const callee = child(value, 'callee')
546
+ return callee?.type === 'Identifier' && CLASS_FUNCTIONS.has(callee.name as string) ? children(value, 'arguments').flatMap((a) => read(a)) : []
547
+ }
548
+ case 'Identifier': {
549
+ const constant = constantOf(value, scopeOf(value))
550
+ return constant ? read(constant, asMap) : []
551
+ }
552
+ case 'MemberExpression': {
553
+ const object = child(value, 'object')
554
+ const target = object?.type === 'Identifier' ? constantOf(object, scopeOf(object)) : object
555
+ const map = target?.type === 'TSAsExpression' || target?.type === 'TSSatisfiesExpression' ? child(target, 'expression') : target
556
+ if (map?.type !== 'ObjectExpression') return []
557
+ const property = child(value, 'property')
558
+ const key = value.computed ? (property?.type === 'Literal' ? String(property.value) : undefined) : nameOf(property)
559
+ return children(map, 'properties').flatMap((entry) => {
560
+ if (entry?.type !== 'Property') return []
561
+ if (key !== undefined && nameOf(child(entry, 'key')) !== key) return []
562
+ return read(child(entry, 'value'), true)
563
+ })
564
+ }
565
+ default:
566
+ return []
567
+ }
568
+ }
569
+
570
+ // ---------------------------------------------------------------- the rule
571
+
572
+ const code = (items: string[]) => items.map((item) => `\`${item}\``).join(' ')
573
+ const or = (items: string[]) => (items.length === 1 ? code(items) : `${items.slice(0, -1).map((i) => `\`${i}\``).join(', ')} or \`${items[items.length - 1]}\``)
574
+
575
+ export const noRestyledPart: Rule.RuleModule = {
576
+ meta: {
577
+ type: 'problem',
578
+ docs: { description: 'Only placement passes into a part of @estiva-app/ui through a class prop' },
579
+ schema: [],
580
+ messages: {
581
+ restyled:
582
+ '{{classes}} on {{where}} changes how it looks. A part of @estiva-app/ui is placed from outside, never restyled: only space, size, flex and grid, and position pass in.{{use}} A look for what is around it goes on your own element around it.',
583
+ emptyStatePadding:
584
+ '{{classes}} pads {{where}}. EmptyState takes no padding (Katerina, 14 September): its room comes from the box its rows live in, so put the padding on that box.',
585
+ ...ESCAPE_MESSAGES,
586
+ },
587
+ },
588
+ create(context) {
589
+ const parser = (context.languageOptions?.parser ?? {}) as Parser
590
+ const file = context.filename
591
+ const sourceCode = context.sourceCode
592
+ const scopeOf = (node: Node) => sourceCode.getScope(node as unknown as Parameters<typeof sourceCode.getScope>[0])
593
+ let locals: Map<string, PartBinding | 'namespace'> | undefined
594
+ const escapes = new Map<Node, boolean>()
595
+ const escaped = (anchor: Node) => {
596
+ if (!escapes.has(anchor)) escapes.set(anchor, isEscaped(context, anchor))
597
+ return escapes.get(anchor) === true
598
+ }
599
+
600
+ const check = (element: Node, binding: PartBinding, prop: string, value: Node | null | undefined, at: Node) => {
601
+ const partProp = binding.props === 'all' ? prop : binding.props[prop]
602
+ if (!partProp || !value) return
603
+ const classes = classesOf(value, scopeOf)
604
+ const handedOn = binding.props === 'all' ? 'its props' : `its \`${prop}\``
605
+ const via = binding.via && binding.via !== binding.part ? `\`${binding.via}\` (it hands ${handedOn} to \`${binding.part}\`)` : `\`${binding.part}\``
606
+ const where = `${via}${partProp === 'className' ? '' : `'s \`${partProp}\``}`
607
+ const restyled = [...new Set(classes.filter((token) => !isPlacement(token)))]
608
+ const padding = binding.part === 'EmptyState' && binding.props === 'all' ? [...new Set(classes.filter(isPadding))] : []
609
+ if (!restyled.length && !padding.length) return
610
+ if (escaped(element)) return
611
+ if (restyled.length) {
612
+ const props = PART_LOOK_PROPS[binding.part]
613
+ const use = props ? ` Use its ${or(props)}.` : ` \`${binding.part}\` has no prop for this yet: ask Katerina, and it gets made in @estiva-app/ui.`
614
+ context.report({ loc: at.loc as NonNullable<Rule.Node['loc']>, messageId: 'restyled', data: { classes: code(restyled), where, use } })
615
+ }
616
+ if (padding.length) context.report({ loc: at.loc as NonNullable<Rule.Node['loc']>, messageId: 'emptyStatePadding', data: { classes: code(padding), where } })
617
+ }
618
+
619
+ return {
620
+ Program(program) {
621
+ locals = moduleParts(file, program as unknown as Node, parser, new Set([file])).locals
622
+ },
623
+ JSXOpeningElement(ruleNode: Rule.Node) {
624
+ const element = ruleNode as unknown as Node
625
+ const binding = locals && bindingOfTag(child(element, 'name'), locals)
626
+ if (!binding) return
627
+ for (const attribute of children(element, 'attributes')) {
628
+ if (!attribute) continue
629
+ if (attribute.type === 'JSXAttribute') {
630
+ const prop = nameOf(child(attribute, 'name'))
631
+ if (prop && CLASS_PROP.test(prop)) check(element, binding, prop, child(attribute, 'value'), attribute)
632
+ continue
633
+ }
634
+ // `{...{ className }}`, or a `const` object spread onto the part.
635
+ let spread = child(attribute, 'argument')
636
+ if (spread?.type === 'Identifier') spread = constantOf(spread, scopeOf(spread))
637
+ while (spread?.type === 'TSAsExpression' || spread?.type === 'TSSatisfiesExpression') spread = child(spread, 'expression')
638
+ if (spread?.type !== 'ObjectExpression') continue
639
+ for (const property of children(spread, 'properties')) {
640
+ const key = property?.type === 'Property' && !property.computed ? nameOf(child(property, 'key')) : undefined
641
+ if (key && CLASS_PROP.test(key)) check(element, binding, key, child(property as Node, 'value'), property as Node)
642
+ }
643
+ }
644
+ },
645
+ }
646
+ },
647
+ }
@@ -28,7 +28,7 @@ fork freely, owe nothing back, mention it on the package's ticket.
28
28
  | Yes or no, with words beside it that toggle it too | **Checkbox** `label` | words written beside it by hand — they name nothing and toggle nothing |
29
29
  | Switching between views of one thing | **Tabs** | a row of Buttons |
30
30
  | View options behind a `…` | **Menu**, `selected` on the current row | Select |
31
- | Rows of checkboxes that stay open | a list in a **DialogShell** | a Menu — a Checkbox inside a `menuitem` is invalid HTML |
31
+ | Rows of checkboxes that stay open | **Checkbox** `row`, in a list in a **DialogShell** | a Menu — a Checkbox inside a `menuitem` is invalid HTML; a row with `role="option"` drawn by hand |
32
32
 
33
33
  ## Typing
34
34