import { mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { RuleTester } from 'eslint' import ts from 'typescript' import { parser } from 'typescript-eslint' import { describe, expect, it } from 'vitest' import { isPlacement, noRestyledPart, PART_LOOK_PROPS, PLACEMENT } from './no-restyled-part' RuleTester.describe = describe RuleTester.it = it RuleTester.itOnly = it.only const tester = new RuleTester({ languageOptions: { parser, parserOptions: { ecmaFeatures: { jsx: true } } }, linterOptions: { reportUnusedDisableDirectives: 'off' }, }) /** * The rule follows imports on disk, so its cases live in two small folders: an * app, whose files re-export and wrap the package's parts the ways Peek and Ship * do, and a copy of the package's own layout. */ const root = mkdtempSync(join(tmpdir(), 'no-restyled-part-')) const write = (path: string, text: string) => { mkdirSync(dirname(join(root, path)), { recursive: true }) writeFileSync(join(root, path), text) } write('app/package.json', '{ "name": "app" }') write('app/src/components/ui/Button.tsx', "export { Button, type ButtonProps } from '@estiva-app/ui'\n") write('app/src/components/ui/index.ts', "export * from './Button'\n") write('app/src/components/ui/Skeleton.tsx', "import { SkeletonList } from '@estiva-app/ui'\nexport const SkeletonSidebarList = SkeletonList\n") write( 'app/src/components/ui/EmptyState.tsx', "import { EmptyState as UiEmptyState, type EmptyStateProps } from '@estiva-app/ui'\nexport function EmptyState({ message = 'Nothing', ...rest }: EmptyStateProps) {\n return \n}\n", ) write( 'app/src/components/ConversationCard.tsx', "import { Card, cn } from '@estiva-app/ui'\nexport function ConversationCard({ className, title }: { className?: string; title: string }) {\n return {title}\n}\n", ) write('app/src/components/Button.tsx', 'export function Button() {\n return null\n}\n') write('pkg/package.json', '{ "name": "@estiva-app/ui" }') write('pkg/src/index.ts', "export { Link, type LinkProps } from './Link'\nexport { Card } from './Card'\nexport { cn } from './cn'\n") write('pkg/src/Link.tsx', 'export function Link() {\n return null\n}\n') write('pkg/src/cn.ts', 'export const cn = (...a: string[]) => a.join(" ")\n') const app = join(root, 'app/src/Probe.tsx') const pkg = join(root, 'pkg/src/Card.tsx') const ui = (imports: string, body: string) => `import { ${imports} } from '@estiva-app/ui'\nexport function Probe() {\n return (\n${body}\n )\n}\n` const local = (imports: string, from: string, body: string) => `import { ${imports} } from '${from}'\nexport function Probe() {\n return (\n${body}\n )\n}\n` const USE_VARIANT_OR_SIZE = ' Use its `variant` or `size`.' const restyled = (classes: string, where: string, use: string) => `${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.` const restyledStyle = (keys: string, where: string, use: string) => `${keys} in the \`style\` of ${where} changes how it looks. A part of @estiva-app/ui is placed from outside, never restyled, by class or by style: 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.` const noProp = (part: string) => ` \`${part}\` has no prop for this yet: ask Katerina, and it gets made in @estiva-app/ui.` tester.run('no-restyled-part', noRestyledPart, { valid: [ // placement, in every form it is written { name: 'space, size, flex and grid', filename: app, code: ui('Button', ' ') }, { name: 'shown or hidden, and position', filename: app, code: ui('Card', ' ') }, { name: 'placement behind a breakpoint, a theme or a state', filename: app, code: ui('Card', ' ') }, { name: 'a name for hover draws nothing', filename: app, code: ui('Card', ' ') }, { name: 'cn, a condition and a template, all placement', filename: app, code: ui('Button, cn', ' ') }, { name: 'a class the code works out while it runs passes', filename: app, code: ui('Button', ' ') }, { name: 'an inner box’s own class prop, placement', filename: app, code: ui('ScrollArea', ' ') }, { name: 'a raw element is not a part', filename: app, code: ui('Button', '
') }, { name: 'an app component of the same name is not a part', filename: app, code: local('Button', './components/Button', ' ') }, // style on a part: placement keys pass (B6) { name: 'style: space, size, position and a move', filename: app, code: ui('Card', " ") }, { name: 'style: a const of placement, and a custom property', filename: app, code: "import { Card } from '@estiva-app/ui'\nconst at = { top: 0, left: 0 }\nexport function Probe() {\n return \n}\n" }, { name: 'style the code works out while it runs passes', filename: app, code: ui('Card', ' ') }, { name: 'style on a component that hands on only its className is not read', filename: app, code: local('ConversationCard', './components/ConversationCard', ' ') }, { name: 'EmptyState placed', filename: app, code: ui('EmptyState', ' ') }, { name: 'Form placed, its fields its own', filename: app, code: ui('Form', '
x
') }, { name: 'a free Form lays itself out', filename: app, code: ui('Form', '
x
') }, { name: 'a Form whose layout the rule cannot read passes', filename: app, code: ui('Form', '
x
') }, { name: "Peek's MoveDialog: the sentences one block inside the Form", filename: app, code: ui('Form', '

Notes

Everyone who can read it now still can.

x
') }, { name: 'a spaced box with only the Form in it spaces nothing', filename: app, code: ui('Form', '
x
') }, { name: 'a box beside the Form with no spacing of its own', filename: app, code: ui('Form', '

Hides it.

x
') }, { name: 'a free Form in a spaced row is laid out by its row', filename: app, code: ui('Form', '

Name

x
') }, // escapes { name: 'escaped, with its reason', filename: app, code: ui('Link', ' // @estiva-escape(no-restyled-part): the whole row is the link, and no part does that yet\n x') }, // the package itself { name: 'inside the package: a name index.ts does not export', filename: pkg, code: local('Base', './Base', ' ') }, { name: 'inside the package: a raw element in a part', filename: pkg, code: 'export function Card() {\n return
\n}\n' }, ], invalid: [ { name: 'style: a corner, an opacity, a weight and a line height (B6)', filename: app, code: ui('Button', ' '), errors: [{ message: restyledStyle('`borderRadius` `opacity` `fontWeight` `lineHeight`', '`Button`', USE_VARIANT_OR_SIZE) }], }, { name: 'style: quoted keys, a const, and a re-export', filename: app, code: "import { Button } from './components/ui'\nconst look = { 'background': 'red' } as const\nexport function Probe() {\n return \n}\n", errors: [{ message: restyledStyle('`background`', '`Button`', USE_VARIANT_OR_SIZE) }], }, { name: 'a colour and a text size, naming the props that carry a look', filename: app, code: ui('Button', ' '), errors: [{ message: restyled('`text-text-secondary` `text-caption`', '`Button`', USE_VARIANT_OR_SIZE) }], }, { name: 'a part with no look prop says to ask', filename: app, code: ui('EditableText', ' true} />'), errors: [{ message: restyled('`text-h2`', '`EditableText`', noProp('EditableText')) }], }, { name: 'border, corner, shadow, clipping, wrapping and effects', filename: app, code: ui('ScrollArea', ' x'), errors: [{ message: restyled('`overflow-hidden` `rounded-lg` `border` `border-border-default` `shadow-md` `truncate` `transition-colors`', '`ScrollArea`', noProp('ScrollArea')) }], }, { name: 'a look behind a variant, and an extra layer', filename: app, code: ui('Link', ' x'), errors: [{ message: restyled('`signal:hover:bg-success-muted` `after:absolute` `[&>*]:shrink-0`', '`Link`', ' Use its `variant`, `truncate` or `cover`.') }], }, { name: 'an inner box’s class prop', filename: app, code: ui('DialogShell', ' x'), errors: [{ message: restyled('`text-body-2`', "`DialogShell`'s `bodyClassName`", noProp('DialogShell')) }], }, { name: 'cn, both sides of a condition, a template and a clsx object', filename: app, code: ui('Card, cn', " "), errors: [{ message: restyled('`bg-bg-active` `text-h5` `rounded-lg` `text-text-muted`', '`Card`', ` Use its ${'`fill`, `hover`, `attention`, `selected`, `active`, `hovered`, `quietUntilHover`, `unreadable` or `clip`'}.`) }], }, { name: 'a const in the same file, and a class map read from one', filename: app, code: "import { Person } from '@estiva-app/ui'\nconst NAME = 'text-caption'\nconst toneStyles = { quiet: 'text-text-muted', loud: { strong: 'font-semibold' } } as const\nexport function Probe({ tone }: { tone: 'quiet' | 'loud' }) {\n return \n}\n", errors: [{ message: restyled('`text-caption` `text-text-muted` `font-semibold`', '`Person`', ' Use its `size`.') }], }, { name: 'a const object spread onto the part', filename: app, code: "import { InlineChip } from '@estiva-app/ui'\nexport function Probe() {\n const chip = { tone: 'neutral', className: 'text-h2' } as const\n return x\n}\n", errors: [{ message: restyled('`text-h2`', '`InlineChip`', noProp('InlineChip')) }], }, { name: 'a namespace import', filename: app, code: "import * as UI from '@estiva-app/ui'\nexport function Probe() {\n return \n}\n", errors: [{ message: restyled('`text-h2`', '`Chip`', ' Use its `type`.') }], }, { name: 'a re-export through an app file, by the @/ alias', filename: app, code: local('Button', '@/components/ui/Button', ' '), errors: [{ message: restyled('`text-h2`', '`Button`', USE_VARIANT_OR_SIZE) }], }, { name: 'a re-export through a barrel, by a relative path', filename: app, code: local('Button', './components/ui', ' '), errors: [{ message: restyled('`text-h2`', '`Button`', USE_VARIANT_OR_SIZE) }], }, { name: 'a part given another name in an app file', filename: app, code: local('SkeletonSidebarList', '@/components/ui/Skeleton', ' '), errors: [{ message: restyled('`rounded-lg`', '`SkeletonList`', noProp('SkeletonList')) }], }, { name: 'a wrapper that hands its props on, and EmptyState’s padding', filename: app, code: local('EmptyState', '@/components/ui/EmptyState', ' '), errors: [ { message: restyled('`text-text-muted`', '`EmptyState`', ' Use its `scope`.') }, { message: '`px-3` `py-2` pads `EmptyState`. EmptyState takes no padding (Katerina, 14 September): its room comes from the box its rows live in, so put the padding on that box.' }, ], }, { name: 'an app component that hands its className on to a part', filename: app, code: local('ConversationCard', '@/components/ConversationCard', ' '), errors: [{ message: restyled('`bg-bg-hover`', '`ConversationCard` (it hands its `className` to `Card`)', ` Use its ${'`fill`, `hover`, `attention`, `selected`, `active`, `hovered`, `quietUntilHover`, `unreadable` or `clip`'}.`) }], }, { name: 'a wrapper declared in the same file', filename: app, code: "import { Link, type LinkProps } from '@estiva-app/ui'\nfunction RouterLink({ href, ...props }: LinkProps) {\n return \n}\nexport function Probe() {\n return x\n}\n", errors: [{ message: restyled('`text-h5`', '`RouterLink` (it hands its props to `Link`)', ' Use its `variant`, `truncate` or `cover`.') }], }, { name: "Peek's and Ship's ArchiveDialog at 0.49.0: sentences and the Form in a gap-3 box, 12px above the field", filename: app, code: ui('Form', '

This hides the topic.

Nothing is deleted.

x
'), errors: [{ message: '`gap-3` on the box around `Form` spaces the form from what is beside it, so the gap above its first field is not the form’s 24px. Put what is beside it inside the `Form`, as one block before the fields, and drop the box: the Form puts 24px between them (Katerina, 1 October; MoveDialog in Peek does this).' }], }, { name: "a DialogShell's body spaced around the Form and a sentence", filename: app, code: ui('DialogShell, Form', '

Say why.

x
'), errors: [{ message: '`space-y-4` on the box around `Form` spaces the form from what is beside it, so the gap above its first field is not the form’s 24px. Put what is beside it inside the `Form`, as one block before the fields, and drop the box: the Form puts 24px between them (Katerina, 1 October; MoveDialog in Peek does this).' }], }, { name: 'a Form laid out by hand', filename: app, code: ui('Form', '
x
'), errors: [{ message: '`flex` `flex-col` `gap-2` `sm:gap-4` lays out the fields of `Form`. A Form places its own fields, 24px apart (Katerina, 28 September), so every form reads the same: remove it. A form that is not a list of fields (a field and its button, a composer) says `layout="free"` and lays itself out.' }], }, { name: 'a Form that says fields is laid out by the Form', filename: app, code: ui('Form', '
x
'), errors: [{ message: '`space-y-3` lays out the fields of `Form`. A Form places its own fields, 24px apart (Katerina, 28 September), so every form reads the same: remove it. A form that is not a list of fields (a field and its button, a composer) says `layout="free"` and lays itself out.' }], }, { name: 'EmptyState padded directly', filename: app, code: ui('EmptyState', ' '), errors: [{ message: '`py-6` pads `EmptyState`. EmptyState takes no padding (Katerina, 14 September): its room comes from the box its rows live in, so put the padding on that box.' }], }, { name: 'an escape with no reason is reported, and so is the class', filename: app, code: ui('Link', ' // @estiva-escape(no-restyled-part):\n x'), errors: [{ messageId: 'escapeWithoutReason' }, { message: restyled('`text-h2`', '`Link`', ' Use its `variant`, `truncate` or `cover`.') }], }, { name: 'inside the package: a part imported from a sibling', filename: pkg, code: local('Link', './Link', ' x'), errors: [{ message: restyled('`rounded-lg` `border`', '`Link`', ' Use its `variant`, `truncate` or `cover`.') }], }, { name: 'inside the package: a part used in the file that declares it', filename: pkg, code: 'export function Link() {\n return null\n}\nexport function Probe() {\n return \n}\n', errors: [{ message: restyled('`text-caption`', '`Link`', ' Use its `variant`, `truncate` or `cover`.') }], }, ], }) /** Every part the package exports, from its own index.ts. */ const SRC = new URL('..', import.meta.url) const indexSource = readFileSync(new URL('index.ts', SRC), 'utf8') const PARTS = [...indexSource.matchAll(/export \{([^}]*)\} from '\.\/[^']+'/g)] .flatMap((m) => m[1].split(',')) .map((name) => name.trim()) .filter((name) => /^[A-Z][a-z]/.test(name)) describe('every part of the package is covered, by where it comes from', () => { it('finds the parts', () => { expect(PARTS.length).toBeGreaterThan(70) expect(PARTS).toContain('EnterHint') }) tester.run('no-restyled-part, one case per exported part', noRestyledPart, { valid: [], invalid: PARTS.map((part) => ({ name: `${part}, restyled`, filename: app, code: ui(part, ` <${part} className="bg-bg-surface" />`), errors: [{ message: restyled('`bg-bg-surface`', `\`${part}\``, PART_LOOK_PROPS[part] ? ` Use its ${PART_LOOK_PROPS[part].length === 1 ? `\`${PART_LOOK_PROPS[part][0]}\`` : `${PART_LOOK_PROPS[part].slice(0, -1).map((p) => `\`${p}\``).join(', ')} or \`${PART_LOOK_PROPS[part].at(-1)}\``}.` : noProp(part)) }], })), }) }) describe('the placement list', () => { it('lets placement through and stops a look', () => { for (const token of ['m-2', '-mt-px', 'px-3', 'w-[244px]', 'max-h-[70vh]', 'flex', 'flex-col', 'grow', 'basis-1/2', 'gap-x-2', 'space-y-1', 'col-span-2', 'row-start-1', 'order-last', 'self-end', 'justify-between', 'items-center', 'content-start', 'hidden', 'inline-flex', 'relative', '-top-1', 'z-10', 'group', '!mt-2', 'md:hidden']) expect(isPlacement(token), token).toBe(true) for (const token of ['text-h2', 'text-text-primary', 'bg-bg-surface', 'border', 'border-t', 'rounded-lg', 'shadow-md', 'font-mono', 'truncate', 'whitespace-nowrap', 'break-all', 'overflow-hidden', 'opacity-70', 'transition-colors', 'cursor-pointer', 'pointer-events-none', 'resize-none', 'content-[\'x\']', 'placeholder-text-muted', 'after:absolute', '[&>*]:shrink-0', '*:mt-2', 'ring-1']) expect(isPlacement(token), token).toBe(false) }) it('names what each entry lets through', () => { expect(PLACEMENT.map((p) => p.what)).toEqual(['space around', 'space inside', 'width and height', 'shown, and how it lays out', 'its place in a row or a grid', 'alignment', 'position', 'a name for hover']) }) }) /** * The message names the props a part has for how it looks. They are read here * from the package's own source with the TypeScript parser: every prop named * exists on its part, and every prop of one of these names that a part has is * named. */ describe('the look props the message names are the package’s own', () => { const LOOK_NAMES = new Set(['variant', 'size', 'tone', 'fill', 'type', 'scope', 'layout', 'surface', 'pressed', 'attention', 'truncate', 'mono']) const files = readdirSync(SRC).filter((f) => f.endsWith('.tsx') && !/\.(stories|test)\.tsx$/.test(f)) const interfaces = new Map() const functions = new Map() const aliases = new Map() for (const file of files) { const source = ts.createSourceFile(file, readFileSync(new URL(file, SRC), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX) source.forEachChild(function visit(node) { if (ts.isInterfaceDeclaration(node)) interfaces.set(node.name.text, node) if (ts.isFunctionDeclaration(node) && node.name) functions.set(node.name.text, node) if (ts.isTypeAliasDeclaration(node)) aliases.set(node.name.text, node.type) node.forEachChild(visit) }) } /** A prop that chooses between fixed looks: a set of words, a yes/no, or a number (a face's size). */ const choosesALook = (type: ts.TypeNode | undefined): boolean => { if (!type) return false if (type.kind === ts.SyntaxKind.BooleanKeyword || type.kind === ts.SyntaxKind.NumberKeyword) return true if (ts.isTypeReferenceNode(type)) return choosesALook(aliases.get(type.typeName.getText())) if (ts.isUnionTypeNode(type)) return type.types.every((t) => ts.isLiteralTypeNode(t) && ts.isStringLiteral(t.literal)) return ts.isLiteralTypeNode(type) && ts.isStringLiteral(type.literal) } /** A part's own props (not the HTML element's it extends), and whether each chooses a look. */ const propsOf = (part: string): Map => { const props = new Map() const add = (member: ts.TypeElement) => { if (member.name) props.set(member.name.getText(), ts.isPropertySignature(member) && choosesALook(member.type)) } const fromInterface = (name: string) => { const declaration = interfaces.get(name) if (!declaration) return false declaration.members.forEach(add) for (const clause of declaration.heritageClauses ?? []) for (const type of clause.types) fromInterface(type.expression.getText()) return true } if (!fromInterface(`${part}Props`)) { const type = functions.get(part)?.parameters[0]?.type if (type && ts.isTypeLiteralNode(type)) type.members.forEach(add) } return props } it('every prop the message names exists on its part', () => { for (const [part, props] of Object.entries(PART_LOOK_PROPS)) { expect(PARTS, part).toContain(part) const own = propsOf(part) for (const prop of props) expect(own.get(prop), `${part}.${prop}`).toBe(true) } }) /** Named like a look, and not one: AttachmentCard's `size` is the file's, in bytes. */ // Form's `layout` says whether it places its fields at all, not how anything looks. const NOT_A_LOOK: Record = { AttachmentCard: ['size'], Form: ['layout'] } it('every look prop a part has is named', () => { for (const part of PARTS) { const missing = [...propsOf(part)] .filter(([prop, chooses]) => chooses && LOOK_NAMES.has(prop) && !(PART_LOOK_PROPS[part] ?? []).includes(prop) && !(NOT_A_LOOK[part] ?? []).includes(prop)) .map(([prop]) => prop) expect(missing, part).toEqual([]) } }) })