/// /** * The token contract as lint settings, written once (docs/GATES.md §23). * * Until UIG-10 this block was pasted into every repo: `eslint.config.js` here, * `eslint.tokens.js` in Peek, `eslint.config.js` in Ship. Katerina ruled on 17 September that a * gate piece ships in the package and an app imports it, so this is that one * copy. Peek and Ship move onto it in UIG-32. * * Two configs, as the copies had: * * - `tokenLint` — a class Tailwind does not generate (the doubled token name, * `bg-bg-surface`), Tailwind's own type ramp or palette, a raw colour, an * opacity modifier on a token colour. * - `tokenValues` — UIG-28: type, corners and shadows written by hand, and an * inline style that sets a colour (errors); heights, spacing and border * widths written by hand (warnings, never blocking). * * And `tokenConfig`, the two on their own as an app's `lint:tokens` runs them * (UIG-37), with TypeScript's rule names known so its directives pass. * * The patterns are the same for everyone. Only the words differ: an app is told * to use "the package", the package is told to add the token to `tokens.css`. * Measured on 17 September: the UIG-28 block was byte for byte the same in Peek * and Ship, and this repository's had the same patterns with seven messages * worded for the package. * * Where the plugin looks: every `className`, every `cn()` / `clsx()` argument, * and the values of a class map whose name ends in `Styles` / `_STYLES` or * `Classes` / `_CLASSES`. A class map named any other way is invisible to it. * A class list joined by hand, `[…].join(' ')`, is read by `token-joined` * (`./token-joined.ts`, UIG-42) with the same patterns. * * Tests are out of `tokenValues` (ruling of 13 September): a test mounts markup * to test it. A kept hand-written value says why: * `// eslint-disable-next-line -- @estiva-escape: ` (A2). */ import { createRequire } from 'node:module' import { fileURLToPath } from 'node:url' import type { ESLint, Linter } from 'eslint' import betterTailwindcss from 'eslint-plugin-better-tailwindcss' import { getDefaultSelectors } from 'eslint-plugin-better-tailwindcss/defaults' import { parser as typescriptParser, plugin as typescriptPlugin } from 'typescript-eslint' import { type Restriction, tokenJoinedPlugin } from './token-joined' import { INLINE_STYLE_MESSAGE, INLINE_STYLE_TOKEN_KEYS, tokenStylePlugin } from './token-style' /** Who reads the messages. */ export type TokenAudience = 'app' | 'package' export interface TokenLintOptions { /** The Tailwind config the unknown-class rule reads, relative to where ESLint runs. */ tailwindConfig?: string /** `app` (the default) names the package; `package` names `tokens.css` and the preset. */ audience?: TokenAudience } export interface TokenConfigOptions extends TokenLintOptions { /** * Plugins whose `eslint-disable` directives appear in the source, registered * with every rule off. ESLint refuses a directive for a rule it does not * know. TypeScript's are registered already. */ quiet?: Record /** Folders the token lint never reads, beside build output. */ ignores?: string[] } /** What the token lint never reads: build output. */ export const TOKEN_LINT_IGNORES = ['dist/', 'storybook-static/', 'node_modules/'] /** * Plugins a check that runs on its own registers, every rule off, so the * directives another lint writes do not fail it (UIG-37). TypeScript's lint is * in every app and writes `eslint-disable-next-line @typescript-eslint/...`; * Peek PR #324 failed the token lint on one. Only the checks that run alone * take this — a full lint that loads TypeScript's rules from its own copy would * refuse a second plugin under the same name. */ export const KNOWN_PLUGINS: Record = { '@typescript-eslint': typescriptPlugin as ESLint.Plugin } const CLASS_MAP_SELECTOR = { kind: 'variable', name: '^(?:[A-Z][A-Z0-9_]*_(?:STYLES|CLASSES)|[a-z][a-zA-Z0-9]*(?:Styles|Classes))$', match: [{ type: 'strings' }, { type: 'objectValues' }], } const WORDS = { app: { ramp: "Tailwind's type ramp is not a token. Use the ramp from the package: text-body-2, text-caption, text-h3, text-btn-default...", palette: "Tailwind's palette is not a token. Use a colour from the package (bg-bg-surface, text-text-primary, border-border-subtle...); a colour with no token is a missing token.", raw: 'A raw colour is a missing token: add it to the package (tokens.css in every theme block, and the preset), or say why not in an eslint-disable comment.', opacity: 'An opacity modifier on a token colour compiles to nothing. A transparent colour is a token of its own (D16): add it to the package.', type: '$0 is type written by hand. A token from the package sets the size, line height, letter spacing and weight together (text-body-2, text-caption, text-h5...); if none fits, add one to the package, or say why not in an eslint-disable comment.', corner: '$0 is a corner written by hand, and no token has it. Use a corner from the package (rounded-sm 4px, rounded-md 6px, rounded-lg 8px...), or say why not in an eslint-disable comment.', shadow: '$0 is a shadow written by hand. Use a shadow from the package (shadow-sm, shadow-md, shadow-focus-ring, drop-shadow-glow-success...); a shadow with no token is a missing token.', merge: "Merge classes with the package's cn(), which the app re-exports. A plain tailwind-merge does not know the type ramp, and drops text-body-2 beside a text colour.", }, package: { ramp: "Tailwind's type ramp is not a token. Use the ramp in tailwind-preset.js: text-body-2, text-caption, text-h3, text-btn-default...", palette: "Tailwind's palette is not a token. Use a colour from tailwind-preset.js (bg-bg-surface, text-text-primary, border-border-subtle...); a colour with no token is a missing token.", raw: 'A raw colour is a missing token: add it to tokens.css in every theme block and to the preset, or say why not in an eslint-disable comment.', opacity: 'An opacity modifier on a token colour compiles to nothing. A transparent colour is a token of its own (D16): add it to tokens.css in every theme block and to the preset.', type: '$0 is type written by hand. A token from tailwind-preset.js sets the size, line height, letter spacing and weight together (text-body-2, text-caption, text-h5...); if none fits, add one to the preset and to cn.ts, or say why not in an eslint-disable comment.', corner: '$0 is a corner written by hand, and no token has it. Use a corner from tailwind-preset.js (rounded-sm 4px, rounded-md 6px, rounded-lg 8px...), or say why not in an eslint-disable comment.', shadow: '$0 is a shadow written by hand. Use a shadow from tailwind-preset.js (shadow-sm, shadow-md, shadow-focus-ring, drop-shadow-glow-success...); a shadow with no token is a missing token.', merge: "Merge classes with this package's cn() (src/cn.ts), the one place tailwind-merge is taught the type ramp. A plain tailwind-merge drops text-body-2 beside a text colour.", }, } satisfies Record> /** * The variants in front of a class, arbitrary ones too (`[&_pre]:`, * `data-[state=open]:`, `group-hover/row:`), and the `!` that makes a class * important. Until UIG-42 the four class rules read only plain variants, so * `[&_pre]:text-sm` and `group-hover/row:bg-gray-100` passed. */ const V = '^(?:(?:[^:\\[\\]\\s]|\\[[^\\]]*\\])+:)*!?' /** * The class rules. `no-unknown-classes` reads the app's own Tailwind config, so * it knows exactly what the preset and the app generate. */ export function tokenLint({ tailwindConfig = 'tailwind.config.js', audience = 'app' }: TokenLintOptions = {}): Linter.Config { const words = WORDS[audience] const restrict: Restriction[] = [ { pattern: `${V}text-(?:xs|sm|base|lg|xl|[2-9]xl)$`, message: words.ramp }, { pattern: `${V}(?:text|bg|border|ring|outline|fill|stroke|decoration|divide|placeholder|from|via|to|accent|caret|shadow)-(?:black|white|slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)(?:-[0-9]{2,3})?(?:/[0-9]{1,3})?$`, message: words.palette, }, { pattern: `${V}[a-z-]+-\\[[^\\]]*(?:#[0-9a-fA-F]{3}|rgba?\\(|hsla?\\(|oklch\\()`, message: words.raw }, { pattern: `${V}(?:text|bg|border|ring|outline|fill|stroke|decoration|divide|placeholder|from|via|to|accent|caret|shadow)-(?:bg|text|border|accent|info|warning|success|error)-[a-z-]+/[0-9]{1,3}$`, message: words.opacity, }, ] return { files: ['**/*.{ts,tsx}'], languageOptions: { parser: typescriptParser, parserOptions: { ecmaFeatures: { jsx: true } }, }, plugins: { 'better-tailwindcss': betterTailwindcss, 'token-joined': tokenJoinedPlugin }, settings: { 'better-tailwindcss': { tailwindConfig, selectors: [...getDefaultSelectors(), CLASS_MAP_SELECTOR], }, }, rules: { 'better-tailwindcss/no-unknown-classes': 'error', 'better-tailwindcss/no-restricted-classes': ['error', { restrict }], // A class list joined by hand, which the plugin above cannot read (UIG-42). 'token-joined/classes': ['error', { restrict }], // UIG-21: "use the package's cn(), never a plain twMerge" was a sentence // in all three repos' CLAUDE.md, and nothing checked it. 'no-restricted-imports': ['error', { paths: [{ name: 'tailwind-merge', message: words.merge }] }], }, } } // UIG-28's patterns. A colour inside `text-[…]` is left to the raw-colour rule // of `tokenLint`. const NOT_COLOUR = '(?!color:|#|rgba?\\(|hsla?\\(|oklch\\(|var\\()' const HAND_WRITTEN = { type: `${V}(?:text-\\[${NOT_COLOUR}|text-\\[length:|leading-\\[|tracking-\\[)[^\\]]+\\](?:/\\S*)?$`, corner: `${V}rounded(?:-(?:t|r|b|l|s|e|tl|tr|br|bl|ss|se|es|ee))?-\\[[^\\]]+\\]$`, shadow: `${V}(?:shadow|drop-shadow)-\\[(?!color:)[^\\]]+\\]$`, spacing: `${V}-?(?:w|h|size|min-w|min-h|max-w|max-h|p[xytrblse]?|m[xytrblse]?|gap(?:-[xy])?|space-[xy]|inset(?:-[xy])?|top|right|bottom|left|translate-[xy]|basis|indent|scroll-[mp][xytrblse]?|border-spacing(?:-[xy])?)-\\[[^\\]]+\\]$`, width: `${V}(?:border(?:-[xytrblse])?|divide-[xy]|outline|ring|ring-offset)-\\[(?:length:)?-?[0-9.]+(?:px|rem|em)?\\]$`, } const INLINE_STYLE_TOKEN_PROPERTIES = String(INLINE_STYLE_TOKEN_KEYS) interface Preset { theme: { extend: { fontSize: Record; borderRadius: Record } } } /** * The preset, loaded the way Tailwind loads it: it imports `tailwindcss/plugin`, * which a plain `import` cannot resolve. Read from beside this module, so an app * and this repository read the one preset the package ships. */ function loadPreset(): Preset { const require = createRequire(import.meta.url) const loadConfig = require('tailwindcss/loadConfig') as (path: string) => Preset return loadConfig(fileURLToPath(new URL('../../tailwind-preset.js', import.meta.url))) } /** * UIG-28: the values the tokens already name, written by hand (Katerina's * rulings B11, B12, C3 of 13 September and R2 of 15 September). Register it * after `tokenLint`, whose parser and plugin settings it borrows. Under its own * rule names, `token-values` and `token-spacing` (the same plugin twice, so one * runs as errors and one as warnings): an escape for a hand-written size then * cannot also silence a raw colour on the same line. */ export function tokenValues({ audience = 'app' }: Pick = {}): Linter.Config { const words = WORDS[audience] const preset = loadPreset() // `text-[14px]` names the token of that size, read from the preset, so the // message stays true when the ramp changes. const tokensBySize: Record = {} for (const [name, value] of Object.entries(preset.theme.extend.fontSize)) { const size = Array.isArray(value) ? value[0] : value ;(tokensBySize[size] ??= []).push(`text-${name}`) } const cornersBySize: Record = {} for (const [name, size] of Object.entries(preset.theme.extend.borderRadius)) (cornersBySize[size] ??= []).push(name === 'DEFAULT' ? 'rounded' : `rounded-${name}`) const values: Restriction[] = [ ...Object.entries(tokensBySize).map(([size, names]) => ({ pattern: `${V}text-\\[${size.replace('.', '\\.')}\\](?:/\\S*)?$`, message: `$0 is the type ramp written by hand. Use ${names.join(' or ')}.`, })), { pattern: HAND_WRITTEN.type, message: words.type, }, ...Object.entries(cornersBySize).map(([size, names]) => ({ pattern: `${V}rounded(?:-(?:t|r|b|l|s|e|tl|tr|br|bl|ss|se|es|ee))?-\\[${size.replace('.', '\\.')}\\]$`, message: `$0 is a corner written by hand. Use ${names.join(' or ')} (with the same side, if it has one).`, })), { pattern: HAND_WRITTEN.corner, message: words.corner, }, { pattern: HAND_WRITTEN.shadow, message: words.shadow, }, ] const spacing: Restriction[] = [ { pattern: HAND_WRITTEN.spacing, message: '$0 is a size or a space written by hand. Use a step of the spacing scale (p-3, h-9, gap-2...) if one fits. Reported, not blocked.', }, { pattern: HAND_WRITTEN.width, message: '$0 is a border or ring width written by hand. Use border, border-2, ring-1... if one fits. Reported, not blocked.', }, ] return { files: ['**/*.{ts,tsx}'], ignores: ['**/*.test.ts', '**/*.test.tsx'], plugins: { 'token-values': betterTailwindcss, 'token-spacing': betterTailwindcss, 'token-style': tokenStylePlugin, 'token-joined': tokenJoinedPlugin }, rules: { // A style kept in a const and passed by name (R9): the selector below cannot follow it. 'token-style/no-token-style': 'error', 'token-values/no-restricted-classes': ['error', { restrict: values }], 'token-spacing/no-restricted-classes': ['warn', { restrict: spacing }], // The same two inside a class list joined by hand (UIG-42). 'token-joined/values': ['error', { restrict: values }], 'token-joined/spacing': ['warn', { restrict: spacing }], 'no-restricted-syntax': [ 'error', { selector: `JSXAttribute[name.name="style"] > JSXExpressionContainer > ObjectExpression > Property[key.name=${INLINE_STYLE_TOKEN_PROPERTIES}]`, message: INLINE_STYLE_MESSAGE, }, // The same key in quotes, `style={{ 'background': 'red' }}` (R9). { selector: `JSXAttribute[name.name="style"] > JSXExpressionContainer > ObjectExpression > Property[key.value=${INLINE_STYLE_TOKEN_PROPERTIES}]`, message: INLINE_STYLE_MESSAGE, }, ], }, } } /** * The token lint on its own — an app's `eslint.tokens.config.js`, `npm run * lint:tokens`, the CI step. It fails on the token contract and on nothing * else, so a backlog in the full lint never blocks it. Peek built this file by * hand until UIG-37. */ export function tokenConfig({ quiet = {}, ignores = [], ...options }: TokenConfigOptions = {}): Linter.Config[] { return [ { ignores: [...TOKEN_LINT_IGNORES, ...ignores] }, { plugins: { ...KNOWN_PLUGINS, ...quiet }, // Every other lint's directives are unused here, because their rules are // off here. Reporting them would be this config complaining about the // others' business. linterOptions: { reportUnusedDisableDirectives: 'off' }, }, tokenLint(options), tokenValues({ audience: options.audience }), ] }