@estiva-app/ui 0.43.0 → 0.44.1

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,94 @@
1
+ /**
2
+ * The token contract, read inside a class list joined by hand (UIG-42):
3
+ *
4
+ * <div className={['text-sm', open && 'bg-gray-100'].join(' ')} />
5
+ *
6
+ * The Tailwind plugin stops at a function call, so it never reads the list in
7
+ * front of `.join(' ')`, and no setting can make it: a call it is told about is
8
+ * read at its arguments, which here is the `' '`. This rule reads the list and
9
+ * tests each class against the same patterns, with the same messages, as the
10
+ * rule it stands in for. It registers once per rule it mirrors, so an error
11
+ * stays an error and a warning a warning (`token-joined/classes`, `/values`,
12
+ * `/spacing`).
13
+ *
14
+ * A list joined with anything but one space is not a class list, and a
15
+ * sentence built this way passes unless one of its words is itself such a
16
+ * class, as in `'set text-sm here'`. Only strings written in the list are
17
+ * read, directly or behind `cond && …`, `a ? … : …` and a template's fixed
18
+ * parts; a list worked out while it runs cannot be read.
19
+ */
20
+ import type { ESLint, Rule } from 'eslint'
21
+
22
+ /** One pattern and its message, the shape `no-restricted-classes` takes. `$0` is the class. */
23
+ export interface Restriction {
24
+ pattern: string
25
+ message: string
26
+ }
27
+
28
+ interface Node {
29
+ type: string
30
+ [key: string]: unknown
31
+ }
32
+
33
+ const isOneSpace = (node: Node | undefined) => node?.type === 'Literal' && node.value === ' '
34
+
35
+ /** `.join` on a list, through any `.filter(…)` between them. */
36
+ function joinedList(call: Node): Node | undefined {
37
+ const callee = call.callee as Node
38
+ if (callee.type !== 'MemberExpression' || callee.computed || (callee.property as Node).name !== 'join') return undefined
39
+ if ((call.arguments as Node[]).length !== 1 || !isOneSpace((call.arguments as Node[])[0])) return undefined
40
+ let list = callee.object as Node
41
+ while (list.type === 'CallExpression' && (list.callee as Node).type === 'MemberExpression' && ((list.callee as Node).property as Node).name === 'filter') {
42
+ list = (list.callee as Node).object as Node
43
+ }
44
+ return list.type === 'ArrayExpression' ? list : undefined
45
+ }
46
+
47
+ /** The strings written in one item of the list, each with the node an escape sits above. */
48
+ function stringsIn(node: Node | null, out: { node: Node; text: string }[] = []) {
49
+ if (!node) return out
50
+ if (node.type === 'Literal' && typeof node.value === 'string') out.push({ node, text: node.value })
51
+ else if (node.type === 'TemplateLiteral') for (const quasi of node.quasis as Node[]) out.push({ node: quasi, text: String((quasi.value as { cooked: string }).cooked ?? '') })
52
+ else if (node.type === 'ConditionalExpression') stringsIn(node.consequent as Node, out), stringsIn(node.alternate as Node, out)
53
+ else if (node.type === 'LogicalExpression') {
54
+ if (node.operator !== '&&') stringsIn(node.left as Node, out)
55
+ stringsIn(node.right as Node, out)
56
+ } else if (node.type === 'TSAsExpression' || node.type === 'TSSatisfiesExpression') stringsIn(node.expression as Node, out)
57
+ return out
58
+ }
59
+
60
+ const joinedListRule: Rule.RuleModule = {
61
+ meta: {
62
+ type: 'problem',
63
+ docs: { description: 'The token contract, inside a class list joined with .join(" ")' },
64
+ schema: [
65
+ {
66
+ type: 'object',
67
+ properties: { restrict: { type: 'array', items: { type: 'object', properties: { pattern: { type: 'string' }, message: { type: 'string' } }, required: ['pattern', 'message'] } } },
68
+ required: ['restrict'],
69
+ additionalProperties: false,
70
+ },
71
+ ],
72
+ },
73
+ create(context) {
74
+ const { restrict } = context.options[0] as { restrict: Restriction[] }
75
+ const rules = restrict.map(({ pattern, message }) => ({ pattern: new RegExp(pattern), message }))
76
+ return {
77
+ CallExpression(ruleNode: Rule.Node) {
78
+ const list = joinedList(ruleNode as unknown as Node)
79
+ if (!list) return
80
+ for (const item of list.elements as (Node | null)[]) {
81
+ for (const { node, text } of stringsIn(item)) {
82
+ for (const name of text.split(/\s+/)) {
83
+ const rule = name && rules.find(({ pattern }) => pattern.test(name))
84
+ if (rule) context.report({ node: node as unknown as Rule.Node, message: rule.message.replaceAll('$0', name) })
85
+ }
86
+ }
87
+ }
88
+ },
89
+ }
90
+ },
91
+ }
92
+
93
+ /** The plugin, as the token lint registers it: one rule, under three names. */
94
+ export const tokenJoinedPlugin: ESLint.Plugin = { rules: { classes: joinedListRule, values: joinedListRule, spacing: joinedListRule } }
@@ -28,6 +28,8 @@
28
28
  * Where the plugin looks: every `className`, every `cn()` / `clsx()` argument,
29
29
  * and the values of a class map whose name ends in `Styles` / `_STYLES` or
30
30
  * `Classes` / `_CLASSES`. A class map named any other way is invisible to it.
31
+ * A class list joined by hand, `[…].join(' ')`, is read by `token-joined`
32
+ * (`./token-joined.ts`, UIG-42) with the same patterns.
31
33
  *
32
34
  * Tests are out of `tokenValues` (ruling of 13 September): a test mounts markup
33
35
  * to test it. A kept hand-written value says why:
@@ -39,6 +41,7 @@ import type { ESLint, Linter } from 'eslint'
39
41
  import betterTailwindcss from 'eslint-plugin-better-tailwindcss'
40
42
  import { getDefaultSelectors } from 'eslint-plugin-better-tailwindcss/defaults'
41
43
  import { parser as typescriptParser, plugin as typescriptPlugin } from 'typescript-eslint'
44
+ import { type Restriction, tokenJoinedPlugin } from './token-joined'
42
45
  import { INLINE_STYLE_MESSAGE, INLINE_STYLE_TOKEN_KEYS, tokenStylePlugin } from './token-style'
43
46
 
44
47
  /** Who reads the messages. */
@@ -104,19 +107,39 @@ const WORDS = {
104
107
  },
105
108
  } satisfies Record<TokenAudience, Record<string, string>>
106
109
 
110
+ /**
111
+ * The variants in front of a class, arbitrary ones too (`[&_pre]:`,
112
+ * `data-[state=open]:`, `group-hover/row:`), and the `!` that makes a class
113
+ * important. Until UIG-42 the four class rules read only plain variants, so
114
+ * `[&_pre]:text-sm` and `group-hover/row:bg-gray-100` passed.
115
+ */
116
+ const V = '^(?:(?:[^:\\[\\]\\s]|\\[[^\\]]*\\])+:)*!?'
117
+
107
118
  /**
108
119
  * The class rules. `no-unknown-classes` reads the app's own Tailwind config, so
109
120
  * it knows exactly what the preset and the app generate.
110
121
  */
111
122
  export function tokenLint({ tailwindConfig = 'tailwind.config.js', audience = 'app' }: TokenLintOptions = {}): Linter.Config {
112
123
  const words = WORDS[audience]
124
+ const restrict: Restriction[] = [
125
+ { pattern: `${V}text-(?:xs|sm|base|lg|xl|[2-9]xl)$`, message: words.ramp },
126
+ {
127
+ 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})?$`,
128
+ message: words.palette,
129
+ },
130
+ { pattern: `${V}[a-z-]+-\\[[^\\]]*(?:#[0-9a-fA-F]{3}|rgba?\\(|hsla?\\(|oklch\\()`, message: words.raw },
131
+ {
132
+ 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}$`,
133
+ message: words.opacity,
134
+ },
135
+ ]
113
136
  return {
114
137
  files: ['**/*.{ts,tsx}'],
115
138
  languageOptions: {
116
139
  parser: typescriptParser,
117
140
  parserOptions: { ecmaFeatures: { jsx: true } },
118
141
  },
119
- plugins: { 'better-tailwindcss': betterTailwindcss },
142
+ plugins: { 'better-tailwindcss': betterTailwindcss, 'token-joined': tokenJoinedPlugin },
120
143
  settings: {
121
144
  'better-tailwindcss': {
122
145
  tailwindConfig,
@@ -125,24 +148,9 @@ export function tokenLint({ tailwindConfig = 'tailwind.config.js', audience = 'a
125
148
  },
126
149
  rules: {
127
150
  'better-tailwindcss/no-unknown-classes': 'error',
128
- 'better-tailwindcss/no-restricted-classes': [
129
- 'error',
130
- {
131
- restrict: [
132
- { pattern: '^(?:[a-z0-9-]+:)*text-(?:xs|sm|base|lg|xl|[2-9]xl)$', message: words.ramp },
133
- {
134
- pattern:
135
- '^(?:[a-z0-9-]+:)*(?: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})?$',
136
- message: words.palette,
137
- },
138
- { pattern: '^(?:[a-z0-9-]+:)*[a-z-]+-\\[[^\\]]*(?:#[0-9a-fA-F]{3}|rgba?\\(|hsla?\\(|oklch\\()', message: words.raw },
139
- {
140
- pattern: '^(?:[a-z0-9-]+:)*(?: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}$',
141
- message: words.opacity,
142
- },
143
- ],
144
- },
145
- ],
151
+ 'better-tailwindcss/no-restricted-classes': ['error', { restrict }],
152
+ // A class list joined by hand, which the plugin above cannot read (UIG-42).
153
+ 'token-joined/classes': ['error', { restrict }],
146
154
  // UIG-21: "use the package's cn(), never a plain twMerge" was a sentence
147
155
  // in all three repos' CLAUDE.md, and nothing checked it.
148
156
  'no-restricted-imports': ['error', { paths: [{ name: 'tailwind-merge', message: words.merge }] }],
@@ -150,10 +158,8 @@ export function tokenLint({ tailwindConfig = 'tailwind.config.js', audience = 'a
150
158
  }
151
159
  }
152
160
 
153
- // UIG-28's patterns. The variant part reads arbitrary variants too
154
- // (`[&_pre]:`, `data-[state]:`, `group-hover/row:`). A colour inside `text-[…]`
155
- // is left to the raw-colour rule of `tokenLint`.
156
- const V = '^(?:(?:[^:\\[\\]\\s]|\\[[^\\]]*\\])+:)*!?'
161
+ // UIG-28's patterns. A colour inside `text-[…]` is left to the raw-colour rule
162
+ // of `tokenLint`.
157
163
  const NOT_COLOUR = '(?!color:|#|rgba?\\(|hsla?\\(|oklch\\(|var\\()'
158
164
  const HAND_WRITTEN = {
159
165
  type: `${V}(?:text-\\[${NOT_COLOUR}|text-\\[length:|leading-\\[|tracking-\\[)[^\\]]+\\](?:/\\S*)?$`,
@@ -201,55 +207,51 @@ export function tokenValues({ audience = 'app' }: Pick<TokenLintOptions, 'audien
201
207
  const cornersBySize: Record<string, string[]> = {}
202
208
  for (const [name, size] of Object.entries(preset.theme.extend.borderRadius)) (cornersBySize[size] ??= []).push(name === 'DEFAULT' ? 'rounded' : `rounded-${name}`)
203
209
 
210
+ const values: Restriction[] = [
211
+ ...Object.entries(tokensBySize).map(([size, names]) => ({
212
+ pattern: `${V}text-\\[${size.replace('.', '\\.')}\\](?:/\\S*)?$`,
213
+ message: `$0 is the type ramp written by hand. Use ${names.join(' or ')}.`,
214
+ })),
215
+ {
216
+ pattern: HAND_WRITTEN.type,
217
+ message: words.type,
218
+ },
219
+ ...Object.entries(cornersBySize).map(([size, names]) => ({
220
+ pattern: `${V}rounded(?:-(?:t|r|b|l|s|e|tl|tr|br|bl|ss|se|es|ee))?-\\[${size.replace('.', '\\.')}\\]$`,
221
+ message: `$0 is a corner written by hand. Use ${names.join(' or ')} (with the same side, if it has one).`,
222
+ })),
223
+ {
224
+ pattern: HAND_WRITTEN.corner,
225
+ message: words.corner,
226
+ },
227
+ {
228
+ pattern: HAND_WRITTEN.shadow,
229
+ message: words.shadow,
230
+ },
231
+ ]
232
+ const spacing: Restriction[] = [
233
+ {
234
+ pattern: HAND_WRITTEN.spacing,
235
+ 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.',
236
+ },
237
+ {
238
+ pattern: HAND_WRITTEN.width,
239
+ message: '$0 is a border or ring width written by hand. Use border, border-2, ring-1... if one fits. Reported, not blocked.',
240
+ },
241
+ ]
242
+
204
243
  return {
205
244
  files: ['**/*.{ts,tsx}'],
206
245
  ignores: ['**/*.test.ts', '**/*.test.tsx'],
207
- plugins: { 'token-values': betterTailwindcss, 'token-spacing': betterTailwindcss, 'token-style': tokenStylePlugin },
246
+ plugins: { 'token-values': betterTailwindcss, 'token-spacing': betterTailwindcss, 'token-style': tokenStylePlugin, 'token-joined': tokenJoinedPlugin },
208
247
  rules: {
209
248
  // A style kept in a const and passed by name (R9): the selector below cannot follow it.
210
249
  'token-style/no-token-style': 'error',
211
- 'token-values/no-restricted-classes': [
212
- 'error',
213
- {
214
- restrict: [
215
- ...Object.entries(tokensBySize).map(([size, names]) => ({
216
- pattern: `${V}text-\\[${size.replace('.', '\\.')}\\](?:/\\S*)?$`,
217
- message: `$0 is the type ramp written by hand. Use ${names.join(' or ')}.`,
218
- })),
219
- {
220
- pattern: HAND_WRITTEN.type,
221
- message: words.type,
222
- },
223
- ...Object.entries(cornersBySize).map(([size, names]) => ({
224
- pattern: `${V}rounded(?:-(?:t|r|b|l|s|e|tl|tr|br|bl|ss|se|es|ee))?-\\[${size.replace('.', '\\.')}\\]$`,
225
- message: `$0 is a corner written by hand. Use ${names.join(' or ')} (with the same side, if it has one).`,
226
- })),
227
- {
228
- pattern: HAND_WRITTEN.corner,
229
- message: words.corner,
230
- },
231
- {
232
- pattern: HAND_WRITTEN.shadow,
233
- message: words.shadow,
234
- },
235
- ],
236
- },
237
- ],
238
- 'token-spacing/no-restricted-classes': [
239
- 'warn',
240
- {
241
- restrict: [
242
- {
243
- pattern: HAND_WRITTEN.spacing,
244
- 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.',
245
- },
246
- {
247
- pattern: HAND_WRITTEN.width,
248
- message: '$0 is a border or ring width written by hand. Use border, border-2, ring-1... if one fits. Reported, not blocked.',
249
- },
250
- ],
251
- },
252
- ],
250
+ 'token-values/no-restricted-classes': ['error', { restrict: values }],
251
+ 'token-spacing/no-restricted-classes': ['warn', { restrict: spacing }],
252
+ // The same two inside a class list joined by hand (UIG-42).
253
+ 'token-joined/values': ['error', { restrict: values }],
254
+ 'token-joined/spacing': ['warn', { restrict: spacing }],
253
255
  'no-restricted-syntax': [
254
256
  'error',
255
257
  {