@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.
- package/dist/eslint/index.js +21 -0
- package/dist/eslint/index.js.map +2 -2
- package/dist/eslint/no-handmade-empty-state.d.ts +7 -0
- package/dist/eslint/no-handmade-empty-state.d.ts.map +1 -1
- package/dist/gates/app-checks.d.ts.map +1 -1
- package/dist/gates/{chunk-SYVKGOWM.js → chunk-P5W6O5TF.js} +114 -63
- package/dist/gates/chunk-P5W6O5TF.js.map +7 -0
- package/dist/gates/cli.js +1 -1
- package/dist/gates/gate-config.d.ts.map +1 -1
- package/dist/gates/index.js +16 -4
- package/dist/gates/index.js.map +2 -2
- package/dist/gates/token-joined.d.ts +28 -0
- package/dist/gates/token-joined.d.ts.map +1 -0
- package/dist/gates/token-lint.d.ts.map +1 -1
- package/package.json +1 -1
- package/registry.json +1 -1
- package/src/EmptyState.mdx +1 -1
- package/src/eslint/no-handmade-empty-state.test.ts +23 -1
- package/src/eslint/no-handmade-empty-state.ts +37 -1
- package/src/gates/app-checks.ts +13 -1
- package/src/gates/create-app.test.ts +1 -2
- package/src/gates/gate-config.ts +2 -0
- package/src/gates/gates.test.ts +26 -0
- package/src/gates/token-joined.ts +94 -0
- package/src/gates/token-lint.ts +68 -66
- package/dist/gates/chunk-SYVKGOWM.js.map +0 -7
|
@@ -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 } }
|
package/src/gates/token-lint.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
154
|
-
//
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
{
|