@prongsjs/ui 0.0.0-stage → 0.3.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.
- package/LICENSE +21 -0
- package/README.md +4 -2
- package/dist/classes.d.ts +23 -0
- package/dist/classes.js +98 -0
- package/dist/dev.d.ts +14 -0
- package/dist/dev.js +36 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +4 -0
- package/dist/profile.d.ts +26 -0
- package/dist/profile.js +101 -0
- package/dist/tooling/analyze.d.ts +42 -0
- package/dist/tooling/analyze.js +175 -0
- package/dist/tooling/components.d.ts +24 -0
- package/dist/tooling/components.js +161 -0
- package/dist/tooling/index.d.ts +17 -0
- package/dist/tooling/index.js +540 -0
- package/dist/tooling/spec.d.ts +24 -0
- package/dist/tooling/spec.js +453 -0
- package/dist/tooling/theme.d.ts +83 -0
- package/dist/tooling/theme.js +288 -0
- package/dist/vocabulary.d.ts +32 -0
- package/dist/vocabulary.js +57 -0
- package/package.json +72 -4
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
import { twMerge } from 'tailwind-merge';
|
|
2
|
+
import { classSimilarity, isTextStyleClass, isVisualClass, splitClasses, stripVariants } from '../classes.js';
|
|
3
|
+
import { domainOf } from '@prongsjs/core';
|
|
4
|
+
import { componentFor } from '../vocabulary.js';
|
|
5
|
+
import { analyzeFile, hasMarker } from './analyze.js';
|
|
6
|
+
import { describeBinding, groupByComponent, uiReferenceSection, uiSpecSection, uiUsage } from './spec.js';
|
|
7
|
+
const LEAVES = ['Text', 'Action', 'Field', 'Media', 'Dialog', 'Popover'];
|
|
8
|
+
const BOX_TAGS = new Set(['div', 'section', 'aside', 'article', 'header', 'footer', 'nav', 'main', 'ul', 'ol', 'li', 'form']);
|
|
9
|
+
/** Classes that give a box its own surface: fill, border, corners, shadow. */
|
|
10
|
+
const SURFACE = /^(bg-(?!transparent$|none$)|border(?!-0$|-none$|-transparent$|-collapse$|-separate$|-spacing)|rounded|shadow(?!-none$)|ring-(?!0$)|backdrop-|card-)/;
|
|
11
|
+
/** Inline style properties that change how something looks (layout properties are fine). */
|
|
12
|
+
const VISUAL_STYLE = /^(color|background|backgroundColor|backgroundImage|border|border(Top|Right|Bottom|Left)?(Color|Width|Style)?|borderRadius|boxShadow|outline|font|fontSize|fontWeight|fontFamily|fontStyle|lineHeight|letterSpacing|textDecoration|textTransform|opacity|filter)$/;
|
|
13
|
+
const TEXT_TAGS = new Set(['p', 'span', 'label', 'small', 'strong', 'em', 'li', 'blockquote']);
|
|
14
|
+
const TEXT_INPUT_TYPES = new Set(['text', 'email', 'password', 'search', 'number', 'url', 'tel']);
|
|
15
|
+
/** A colour written as a value instead of a token: `bg-[#e5383b]`, `[color:rgb(0_0_0)]`. */
|
|
16
|
+
const LITERAL_COLOR = /\[[^\]]*(#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\()/i;
|
|
17
|
+
/** A colour from Tailwind's palette, which a theme cannot change: `text-sky-700`. White and black are allowed. */
|
|
18
|
+
const PALETTE_COLOR = /^(?:bg|text|border(?:-[trblxyse])?|ring|ring-offset|outline|fill|stroke|divide|placeholder|accent|caret|decoration|from|via|to|shadow|inset-shadow)-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d{2,3}(?:\/\d+)?$/;
|
|
19
|
+
/** Variables Tailwind itself provides (its theme namespaces and internals). */
|
|
20
|
+
const TAILWIND_VARIABLE = /^--(?:tw-|color-|spacing|font-|text-|leading-|tracking-|radius-|shadow-|inset-shadow-|drop-shadow-|blur-|breakpoint-|container-|ease-|animate-|perspective-|aspect-|default-)/;
|
|
21
|
+
const STYLESHEET = /\.(css|scss|sass|less)$/;
|
|
22
|
+
/** The document root and inline SVG may carry classes; icons are not looks an intent owns. */
|
|
23
|
+
const FREE_TAGS = new Set(['html', 'body', 'svg', 'path', 'g', 'circle', 'ellipse', 'rect', 'line', 'polyline', 'polygon', 'defs', 'use', 'mask', 'clipPath', 'br', 'option']);
|
|
24
|
+
/** Classes that change behaviour or motion but no look of their own. */
|
|
25
|
+
const INERT = /^(group|peer)(\/[\w-]+)?$|^(cursor-|select-|pointer-events-|transition|duration-|ease-|delay-|appearance-|list-|will-change-|touch-|outline-none$|resize|snap-|scroll-smooth$|isolate$|surface-page$|\[scrollbar-)/;
|
|
26
|
+
/** A selector or Tailwind variant that matches elements by their intent: [data-intent=...], data-[intent=...]. */
|
|
27
|
+
const INTENT_SELECTOR = /\[\s*data-intent\b|data-\[intent\b/;
|
|
28
|
+
/** How many times a Layout tweak repeats before it counts as a missing intent. */
|
|
29
|
+
const REPEATED_TWEAK = 3;
|
|
30
|
+
/** Elements that change what a region does, so an intent rendering one is no stand-in for a plain box. */
|
|
31
|
+
const BEHAVIOUR_TAGS = new Set(['form', 'table', 'ul', 'ol', 'li', 'hr', 'details', 'main', 'nav', 'header', 'footer', 'figure', 'aside']);
|
|
32
|
+
/** The class strings in a registry module, with where each starts. */
|
|
33
|
+
function registryClassStrings(text) {
|
|
34
|
+
const found = [];
|
|
35
|
+
for (const m of text.matchAll(/classes:\s*(['"`])((?:\\.|(?!\1)[^\\])*)\1/g)) {
|
|
36
|
+
found.push({ value: m[2], offset: m.index + m[0].indexOf(m[1]) });
|
|
37
|
+
}
|
|
38
|
+
return found;
|
|
39
|
+
}
|
|
40
|
+
function strings(site, attr) {
|
|
41
|
+
const value = site.attrs.get(attr);
|
|
42
|
+
return value?.kind === 'strings' ? value.values : null;
|
|
43
|
+
}
|
|
44
|
+
function classesOf(site) {
|
|
45
|
+
return (strings(site, 'className') ?? []).flatMap(splitClasses);
|
|
46
|
+
}
|
|
47
|
+
/** Which component should replace a raw element, or undefined when the element is fine as is. */
|
|
48
|
+
function replacementFor(site) {
|
|
49
|
+
const classes = classesOf(site);
|
|
50
|
+
if (site.nextLink || site.tag === 'a')
|
|
51
|
+
return classes.some(isVisualClass) ? 'Action' : undefined;
|
|
52
|
+
if (site.tag === 'button')
|
|
53
|
+
return 'Action';
|
|
54
|
+
if (/^h[1-6]$/.test(site.tag))
|
|
55
|
+
return 'Text';
|
|
56
|
+
if (site.tag === 'textarea' || site.tag === 'select')
|
|
57
|
+
return 'Field';
|
|
58
|
+
if (site.tag === 'img')
|
|
59
|
+
return site.attrs.has('className') ? 'Media' : undefined;
|
|
60
|
+
if (site.tag === 'dialog')
|
|
61
|
+
return 'Dialog';
|
|
62
|
+
if (site.attrs.has('popover'))
|
|
63
|
+
return 'Popover';
|
|
64
|
+
if (site.tag === 'input') {
|
|
65
|
+
const type = strings(site, 'type')?.[0] ?? 'text';
|
|
66
|
+
return TEXT_INPUT_TYPES.has(type) ? 'Field' : undefined;
|
|
67
|
+
}
|
|
68
|
+
if (TEXT_TAGS.has(site.tag))
|
|
69
|
+
return classes.some(isTextStyleClass) ? 'Text' : undefined;
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
/** Lowercase words from code: `toggleSave(item)` -> toggle, save, item. */
|
|
73
|
+
function words(text) {
|
|
74
|
+
return new Set((text ?? '')
|
|
75
|
+
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
76
|
+
.toLowerCase()
|
|
77
|
+
.split(/[^a-z]+/)
|
|
78
|
+
.filter((w) => w.length > 2));
|
|
79
|
+
}
|
|
80
|
+
/** True when the code mentions `word` or a close inflection of it (share/shared/sharing). */
|
|
81
|
+
function mentions(found, word) {
|
|
82
|
+
if (found.has(word))
|
|
83
|
+
return true;
|
|
84
|
+
// Prefix matching only for real stems; "see" must not match "set".
|
|
85
|
+
const stem = word.length > 4 ? word.replace(/e$/, '') : word;
|
|
86
|
+
if (stem.length < 4)
|
|
87
|
+
return false;
|
|
88
|
+
return [...found].some((w) => w.startsWith(stem) && w.length - stem.length <= 3);
|
|
89
|
+
}
|
|
90
|
+
/** Call-site intents for a component, ranked by how closely they match an element. */
|
|
91
|
+
function rankIntents(registry, component, site, limit = 2) {
|
|
92
|
+
const classes = classesOf(site);
|
|
93
|
+
const type = strings(site, 'type')?.[0];
|
|
94
|
+
return registry.ids
|
|
95
|
+
.filter((id) => !registry.isInternal(id) && componentFor(id) === component)
|
|
96
|
+
.map((id) => {
|
|
97
|
+
const binding = registry.resolve(id).binding;
|
|
98
|
+
const similarity = classSimilarity(classes, splitClasses(binding.classes));
|
|
99
|
+
let score = similarity;
|
|
100
|
+
if (binding.element === site.tag)
|
|
101
|
+
score += 0.5;
|
|
102
|
+
if (type && binding.behavior?.type === type)
|
|
103
|
+
score += 0.5;
|
|
104
|
+
if (site.nextLink && id === 'action.navigate')
|
|
105
|
+
score += 0.3;
|
|
106
|
+
return { id, score, similarity };
|
|
107
|
+
})
|
|
108
|
+
.filter(({ score }) => score > 0)
|
|
109
|
+
// On a tie, the intent whose classes match more closely wins over one that only shares the element.
|
|
110
|
+
.sort((a, b) => b.score - a.score || b.similarity - a.similarity || a.id.localeCompare(b.id))
|
|
111
|
+
.slice(0, limit)
|
|
112
|
+
.map(({ id }) => id);
|
|
113
|
+
}
|
|
114
|
+
export function createUiTooling(options) {
|
|
115
|
+
const componentsDir = options.componentsDir.replace(/\/+$/, '');
|
|
116
|
+
const cli = options.cli ?? 'npx prongs';
|
|
117
|
+
const suggestIntent = (registry, id) => ({
|
|
118
|
+
intent: id,
|
|
119
|
+
component: componentFor(id),
|
|
120
|
+
text: registry.defs[id].description,
|
|
121
|
+
});
|
|
122
|
+
const violation = (ctx, site, rule, severity, message, suggestions = [], extra = {}) => ({
|
|
123
|
+
rule,
|
|
124
|
+
severity,
|
|
125
|
+
path: ctx.file.path,
|
|
126
|
+
line: site.line,
|
|
127
|
+
column: site.offset - ctx.file.text.lastIndexOf('\n', site.offset - 1),
|
|
128
|
+
snippet: (ctx.file.text.split('\n')[site.line - 1] ?? '').trim(),
|
|
129
|
+
message,
|
|
130
|
+
suggestions,
|
|
131
|
+
...extra,
|
|
132
|
+
});
|
|
133
|
+
const registryPath = options.registryPath ? options.registryPath.replace(/^\.\//, '') : undefined;
|
|
134
|
+
/** One violation per class string in the registry that `offending` finds something in. */
|
|
135
|
+
const registryViolations = (ctx, rule, severity, offending, message) => {
|
|
136
|
+
if (ctx.file.path !== registryPath)
|
|
137
|
+
return [];
|
|
138
|
+
const lines = ctx.file.text.split('\n');
|
|
139
|
+
return registryClassStrings(ctx.file.text).flatMap(({ value, offset }) => {
|
|
140
|
+
const found = offending(splitClasses(value), value);
|
|
141
|
+
if (found.length === 0)
|
|
142
|
+
return [];
|
|
143
|
+
const line = ctx.file.text.slice(0, offset).split('\n').length;
|
|
144
|
+
return [{
|
|
145
|
+
rule,
|
|
146
|
+
severity,
|
|
147
|
+
path: ctx.file.path,
|
|
148
|
+
line,
|
|
149
|
+
column: offset - ctx.file.text.lastIndexOf('\n', offset - 1),
|
|
150
|
+
snippet: (lines[line - 1] ?? '').trim(),
|
|
151
|
+
message: message(found),
|
|
152
|
+
suggestions: [],
|
|
153
|
+
data: { found },
|
|
154
|
+
}];
|
|
155
|
+
});
|
|
156
|
+
};
|
|
157
|
+
const sitesOf = (ctx) => {
|
|
158
|
+
if (!/\.[jt]sx$/.test(ctx.file.path))
|
|
159
|
+
return [];
|
|
160
|
+
const analysis = analyzeFile(ctx.file, componentsDir);
|
|
161
|
+
return analysis.sites
|
|
162
|
+
.filter((site) => !hasMarker(analysis, site.line, 'ignore'))
|
|
163
|
+
.map((site) => ({ site, analysis }));
|
|
164
|
+
};
|
|
165
|
+
const rules = [
|
|
166
|
+
{
|
|
167
|
+
id: 'domain-mismatch',
|
|
168
|
+
run(ctx) {
|
|
169
|
+
return sitesOf(ctx).flatMap(({ site }) => {
|
|
170
|
+
if (!site.component)
|
|
171
|
+
return [];
|
|
172
|
+
return (strings(site, 'intent') ?? [])
|
|
173
|
+
.filter((id) => ctx.registry.has(id) && !ctx.registry.isInternal(id))
|
|
174
|
+
.filter((id) => componentFor(id) !== site.component)
|
|
175
|
+
.map((id) => violation(ctx, site, 'domain-mismatch', 'error', `<${site.component}> cannot render "${id}"; use <${componentFor(id)}>.`, [
|
|
176
|
+
{ component: componentFor(id), intent: id, text: `<${componentFor(id)} intent="${id}">` },
|
|
177
|
+
], { intent: id }));
|
|
178
|
+
});
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
id: 'dynamic-intent',
|
|
183
|
+
run(ctx) {
|
|
184
|
+
return sitesOf(ctx)
|
|
185
|
+
.filter(({ site }) => site.component && site.attrs.get('intent')?.kind === 'dynamic')
|
|
186
|
+
.map(({ site }) => violation(ctx, site, 'dynamic-intent', 'info', 'Intent is computed at runtime, so it was not checked. Prefer literal intents or a ternary of literals.'));
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
id: 'leaf-classname',
|
|
191
|
+
run(ctx) {
|
|
192
|
+
return sitesOf(ctx)
|
|
193
|
+
.filter(({ site }) => site.component && LEAVES.includes(site.component) && site.attrs.has('className'))
|
|
194
|
+
.map(({ site }) => violation(ctx, site, 'leaf-classname', 'error', `<${site.component}> takes no className. Use its layout props (align, width, hideBelow, showBelow, truncate, nowrap), wrap it in a <Layout>, or change the intent.`, [], { intent: strings(site, 'intent')?.[0], data: { classes: classesOf(site) } }));
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
id: 'leaf-style',
|
|
199
|
+
run(ctx) {
|
|
200
|
+
return sitesOf(ctx)
|
|
201
|
+
.filter(({ site }) => site.component && LEAVES.includes(site.component) && site.attrs.has('style'))
|
|
202
|
+
.map(({ site }) => violation(ctx, site, 'leaf-style', 'error', `<${site.component}> takes no style: an inline style overrides the intent just like a className. Pick or add an intent that looks right.`, [], { intent: strings(site, 'intent')?.[0] }));
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
id: 'layout-visual-class',
|
|
207
|
+
run(ctx) {
|
|
208
|
+
return sitesOf(ctx).flatMap(({ site }) => {
|
|
209
|
+
if (site.component !== 'Layout')
|
|
210
|
+
return [];
|
|
211
|
+
const visual = classesOf(site).filter(isVisualClass);
|
|
212
|
+
if (visual.length === 0)
|
|
213
|
+
return [];
|
|
214
|
+
const intent = strings(site, 'intent')?.[0];
|
|
215
|
+
return [
|
|
216
|
+
violation(ctx, site, 'layout-visual-class', 'error', `Layout className may only hold layout and spacing classes; "${visual.join(' ')}" changes the look.`, intent
|
|
217
|
+
? [{
|
|
218
|
+
text: 'Give this region its own intent',
|
|
219
|
+
command: `${cli} add-intent layout.<name> --refs ${intent} --classes "${visual.join(' ')}" --description "..."`,
|
|
220
|
+
}]
|
|
221
|
+
: [], { intent, data: { classes: visual } }),
|
|
222
|
+
];
|
|
223
|
+
});
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
id: 'unsafe-classname',
|
|
228
|
+
run(ctx) {
|
|
229
|
+
return sitesOf(ctx).flatMap(({ site, analysis }) => {
|
|
230
|
+
if (!site.component || !site.attrs.has('unsafeClassName'))
|
|
231
|
+
return [];
|
|
232
|
+
const classes = (strings(site, 'unsafeClassName') ?? []).flatMap(splitClasses);
|
|
233
|
+
const intent = strings(site, 'intent')?.[0];
|
|
234
|
+
const reasoned = hasMarker(analysis, site.line, 'unsafe');
|
|
235
|
+
return [
|
|
236
|
+
violation(ctx, site, 'unsafe-classname', reasoned ? 'warning' : 'error', reasoned
|
|
237
|
+
? `unsafeClassName "${classes.join(' ')}" bypasses the intent.`
|
|
238
|
+
: 'unsafeClassName needs a `prongs-unsafe: <reason>` comment on the line above.', intent
|
|
239
|
+
? [{
|
|
240
|
+
text: 'If this repeats, make it an intent',
|
|
241
|
+
command: `${cli} add-intent ${intent.split('.')[0]}.<name> --refs ${intent} --classes "${classes.join(' ')}" --description "..."`,
|
|
242
|
+
}]
|
|
243
|
+
: [], { intent, data: { classes } }),
|
|
244
|
+
];
|
|
245
|
+
});
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
id: 'raw-style',
|
|
250
|
+
run(ctx) {
|
|
251
|
+
return sitesOf(ctx).flatMap(({ site }) => {
|
|
252
|
+
if (site.component && site.component !== 'Layout')
|
|
253
|
+
return []; // leaf components reject style outright
|
|
254
|
+
const visual = (site.styleKeys ?? []).filter((k) => !k.startsWith('--') && VISUAL_STYLE.test(k));
|
|
255
|
+
if (visual.length === 0)
|
|
256
|
+
return [];
|
|
257
|
+
return [
|
|
258
|
+
violation(ctx, site, 'raw-style', 'error', `Inline style sets how this looks (${visual.join(', ')}). Looks belong to intents; pass values that come from data as CSS variables (vars={{ '--x': value }} on Prongs components).`, [], { data: { tag: site.component ?? site.tag, styleKeys: visual } }),
|
|
259
|
+
];
|
|
260
|
+
});
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
id: 'raw-surface',
|
|
265
|
+
run(ctx) {
|
|
266
|
+
return sitesOf(ctx).flatMap(({ site }) => {
|
|
267
|
+
if (site.component || !BOX_TAGS.has(site.tag))
|
|
268
|
+
return [];
|
|
269
|
+
const surface = classesOf(site).filter((c) => SURFACE.test(stripVariants(c)));
|
|
270
|
+
if (surface.length === 0)
|
|
271
|
+
return [];
|
|
272
|
+
const ranked = rankIntents(ctx.registry, 'Layout', site);
|
|
273
|
+
return [
|
|
274
|
+
violation(ctx, site, 'raw-surface', 'error', `<${site.tag}> draws its own surface (${surface.join(' ')}); use <Layout> with an intent that says what this region is.`, ranked.length ? ranked.map((id) => suggestIntent(ctx.registry, id)) : [{ component: 'Layout', text: '<Layout intent="...">' }], { data: { tag: site.tag, component: 'Layout', classes: classesOf(site) } }),
|
|
275
|
+
];
|
|
276
|
+
});
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
id: 'name-mismatch',
|
|
281
|
+
run(ctx) {
|
|
282
|
+
const { registry } = ctx;
|
|
283
|
+
return sitesOf(ctx).flatMap(({ site }) => {
|
|
284
|
+
if (site.component !== 'Action')
|
|
285
|
+
return [];
|
|
286
|
+
const used = strings(site, 'intent')?.[0];
|
|
287
|
+
if (!used || !registry.has(used) || registry.isInternal(used))
|
|
288
|
+
return [];
|
|
289
|
+
// Only actions are named by verbs; nav and choice labels name destinations and options.
|
|
290
|
+
if (domainOf(used) !== 'action')
|
|
291
|
+
return [];
|
|
292
|
+
// A link's text names where it goes ("Create one" -> /create), not an action.
|
|
293
|
+
if (used === 'action.navigate' && site.attrs.has('href'))
|
|
294
|
+
return [];
|
|
295
|
+
const said = [site.handlerText, site.childText, ...(strings(site, 'aria-label') ?? []), ...(strings(site, 'label') ?? [])];
|
|
296
|
+
const found = words(said.filter(Boolean).join(' '));
|
|
297
|
+
if (found.size === 0)
|
|
298
|
+
return [];
|
|
299
|
+
// The meaning word plus keywords, inherited through refs, so action.bookmark.reader
|
|
300
|
+
// answers to everything action.bookmark does. Variant words ("item") are not signals.
|
|
301
|
+
const signals = (id, seen = new Set()) => {
|
|
302
|
+
if (seen.has(id) || !registry.has(id))
|
|
303
|
+
return [];
|
|
304
|
+
seen.add(id);
|
|
305
|
+
const def = registry.defs[id];
|
|
306
|
+
return [
|
|
307
|
+
id.split('.')[1],
|
|
308
|
+
...(def.keywords ?? []),
|
|
309
|
+
...(def.refs ?? []).filter((r) => domainOf(r) === domainOf(id)).flatMap((r) => signals(r, seen)),
|
|
310
|
+
];
|
|
311
|
+
};
|
|
312
|
+
if (signals(used).some((w) => mentions(found, w)))
|
|
313
|
+
return [];
|
|
314
|
+
const better = registry.ids.filter((id) => id !== used &&
|
|
315
|
+
!registry.isInternal(id) &&
|
|
316
|
+
domainOf(id) === domainOf(used) &&
|
|
317
|
+
signals(id).some((w) => mentions(found, w)));
|
|
318
|
+
if (better.length === 0)
|
|
319
|
+
return [];
|
|
320
|
+
return [
|
|
321
|
+
violation(ctx, site, 'name-mismatch', 'warning', `The code says "${[...found].join(' ')}", which points to ${better.join(' or ')}, not ${used}.`, better.map((id) => suggestIntent(registry, id)), { intent: used }),
|
|
322
|
+
];
|
|
323
|
+
});
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
id: 'raw-value',
|
|
328
|
+
run(ctx) {
|
|
329
|
+
const inRegistry = registryViolations(ctx, 'raw-value', 'warning', (classes) => classes.filter((c) => LITERAL_COLOR.test(c) || PALETTE_COLOR.test(stripVariants(c))), (found) => `${found.join(' ')} ${found.length === 1 ? 'is a colour' : 'are colours'} the theme cannot change. Use a token (text-destructive, bg-surface-hover, var(--success)), or declare one in your CSS.`);
|
|
330
|
+
// A CSS variable may carry a value from data, but not a colour fixed in code.
|
|
331
|
+
const inVars = sitesOf(ctx).flatMap(({ site }) => (site.colorVars ?? []).map((found) => violation(ctx, site, 'raw-value', 'warning', `${found.name} in ${found.attr} is set to the colour ${found.value}, which the theme cannot change. Fixed colours belong in your theme as tokens; vars and style are for values that come from data.`, [], { data: found })));
|
|
332
|
+
return [...inRegistry, ...inVars];
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
id: 'unknown-token',
|
|
337
|
+
run(ctx) {
|
|
338
|
+
const tokens = options.tokens;
|
|
339
|
+
if (!tokens || tokens.size === 0)
|
|
340
|
+
return [];
|
|
341
|
+
// Variables a registry class sets itself, like [--primary:var(--destructive)].
|
|
342
|
+
const local = new Set([...ctx.file.text.matchAll(/\[(--[\w-]+):/g)].map((m) => m[1]));
|
|
343
|
+
return registryViolations(ctx, 'unknown-token', 'error', (_, value) =>
|
|
344
|
+
// A var() with a fallback is optional on purpose (a value passed through `vars` only sometimes).
|
|
345
|
+
[...new Set([...value.matchAll(/(?:var\(|-\()(--[\w-]+)(?![\w-]|\s*,)/g)].map((m) => m[1]))].filter((name) => !tokens.has(name) && !local.has(name) && !TAILWIND_VARIABLE.test(name)), (found) => `${found.join(', ')} ${found.length === 1 ? 'is' : 'are'} not declared in any CSS file or passed through \`vars\`, so ${found.length === 1 ? 'it resolves' : 'they resolve'} to nothing. Fix the name or declare it in your theme.`);
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
id: 'intent-selector',
|
|
350
|
+
run(ctx) {
|
|
351
|
+
const message = 'Styles an element by its data-intent. data-intent is for finding elements (analytics, tests, A/B tools), not styling them: change the look in the registry, or add an intent.';
|
|
352
|
+
if (STYLESHEET.test(ctx.file.path)) {
|
|
353
|
+
const lines = ctx.file.text.split('\n');
|
|
354
|
+
let inComment = false;
|
|
355
|
+
return lines.flatMap((text, index) => {
|
|
356
|
+
// Comments may mention data-intent; strip them before looking for selectors.
|
|
357
|
+
let code = '';
|
|
358
|
+
for (let i = 0; i < text.length; i++) {
|
|
359
|
+
if (inComment) {
|
|
360
|
+
if (text.startsWith('*/', i))
|
|
361
|
+
(inComment = false), i++;
|
|
362
|
+
}
|
|
363
|
+
else if (text.startsWith('/*', i))
|
|
364
|
+
(inComment = true), i++;
|
|
365
|
+
else
|
|
366
|
+
code += text[i];
|
|
367
|
+
}
|
|
368
|
+
const column = code.search(INTENT_SELECTOR);
|
|
369
|
+
if (column < 0)
|
|
370
|
+
return [];
|
|
371
|
+
return [{ rule: 'intent-selector', severity: 'error', path: ctx.file.path, line: index + 1, column: column + 1, snippet: text.trim(), message, suggestions: [] }];
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
const inRegistry = registryViolations(ctx, 'intent-selector', 'error', (classes) => classes.filter((c) => INTENT_SELECTOR.test(c)), () => message);
|
|
375
|
+
const inJsx = sitesOf(ctx).flatMap(({ site }) => {
|
|
376
|
+
const classes = [...classesOf(site), ...(strings(site, 'unsafeClassName') ?? []).flatMap(splitClasses)].filter((c) => INTENT_SELECTOR.test(c));
|
|
377
|
+
return classes.length ? [violation(ctx, site, 'intent-selector', 'error', message, [], { data: { classes } })] : [];
|
|
378
|
+
});
|
|
379
|
+
return [...inRegistry, ...inJsx];
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
id: 'raw-look',
|
|
384
|
+
run(ctx) {
|
|
385
|
+
return sitesOf(ctx).flatMap(({ site }) => {
|
|
386
|
+
if (site.component || site.nextLink || !/^[a-z]/.test(site.tag) || FREE_TAGS.has(site.tag))
|
|
387
|
+
return [];
|
|
388
|
+
// raw-element and raw-surface already report these.
|
|
389
|
+
if (replacementFor(site))
|
|
390
|
+
return [];
|
|
391
|
+
const classes = classesOf(site);
|
|
392
|
+
if (BOX_TAGS.has(site.tag) && classes.some((c) => SURFACE.test(stripVariants(c))))
|
|
393
|
+
return [];
|
|
394
|
+
const looks = classes.filter((c) => isVisualClass(c) && !INERT.test(stripVariants(c)));
|
|
395
|
+
if (looks.length === 0)
|
|
396
|
+
return [];
|
|
397
|
+
const component = looks.some(isTextStyleClass) && !looks.some((c) => SURFACE.test(stripVariants(c))) ? 'Text' : 'Layout';
|
|
398
|
+
return [
|
|
399
|
+
violation(ctx, site, 'raw-look', 'error', `<${site.tag}> sets its own look (${looks.join(' ')}). Give it an intent: <${component} intent="...">, or Media for frames and images. A justified exception takes // prongs-ignore: <reason>.`, [{ component, text: `<${component} intent="...">` }], { data: { tag: site.tag, component, classes: looks } }),
|
|
400
|
+
];
|
|
401
|
+
});
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
{
|
|
405
|
+
id: 'raw-element',
|
|
406
|
+
run(ctx) {
|
|
407
|
+
return sitesOf(ctx).flatMap(({ site }) => {
|
|
408
|
+
if (site.component)
|
|
409
|
+
return [];
|
|
410
|
+
const component = replacementFor(site);
|
|
411
|
+
if (!component)
|
|
412
|
+
return [];
|
|
413
|
+
const name = site.nextLink ? 'Link' : `<${site.tag}>`;
|
|
414
|
+
const ranked = rankIntents(ctx.registry, component, site);
|
|
415
|
+
return [
|
|
416
|
+
violation(ctx, site, 'raw-element', 'error', `${site.nextLink ? '<Link>' : name} with its own styling; use <${component}> with an intent.`, ranked.length ? ranked.map((id) => suggestIntent(ctx.registry, id)) : [{ component, text: `<${component} intent="...">` }], { data: { tag: site.nextLink ? 'Link' : site.tag, component, classes: classesOf(site) } }),
|
|
417
|
+
];
|
|
418
|
+
});
|
|
419
|
+
},
|
|
420
|
+
},
|
|
421
|
+
];
|
|
422
|
+
/** How a box lays out its children, at the base breakpoint. */
|
|
423
|
+
const directionOf = (classes) => {
|
|
424
|
+
const base = new Set(classes);
|
|
425
|
+
if (base.has('grid'))
|
|
426
|
+
return 'grid';
|
|
427
|
+
if (base.has('flex-col'))
|
|
428
|
+
return 'column';
|
|
429
|
+
return base.has('flex') || base.has('inline-flex') ? 'row' : 'block';
|
|
430
|
+
};
|
|
431
|
+
/**
|
|
432
|
+
* Layout intents that already carry the tweak: same direction once the tweak applies, the same
|
|
433
|
+
* element or a plain one, and no look of their own beyond the tweaked intent's.
|
|
434
|
+
*/
|
|
435
|
+
const intentsWithTweak = (registry, intent, classes) => {
|
|
436
|
+
const own = registry.resolve(intent).binding;
|
|
437
|
+
const wanted = splitClasses(twMerge(own.classes, classes.join(' ')));
|
|
438
|
+
const ownLooks = new Set(splitClasses(own.classes).filter(isVisualClass));
|
|
439
|
+
return registry.ids
|
|
440
|
+
.filter((id) => id !== intent && !registry.isInternal(id) && componentFor(id) === 'Layout')
|
|
441
|
+
.map((id) => ({ id, binding: registry.resolve(id).binding }))
|
|
442
|
+
.filter(({ binding }) => !binding.element || binding.element === own.element || !BEHAVIOUR_TAGS.has(binding.element))
|
|
443
|
+
.filter(({ binding }) => {
|
|
444
|
+
const has = splitClasses(binding.classes);
|
|
445
|
+
const set = new Set(has);
|
|
446
|
+
return (classes.every((c) => set.has(c)) &&
|
|
447
|
+
directionOf(has) === directionOf(wanted) &&
|
|
448
|
+
has.filter(isVisualClass).every((c) => ownLooks.has(c)));
|
|
449
|
+
})
|
|
450
|
+
.map(({ id, binding }) => ({ id, score: classSimilarity(wanted, splitClasses(binding.classes)) }))
|
|
451
|
+
// Below this, the candidate merely shares a class; swapping to it would change more than the tweak.
|
|
452
|
+
.filter(({ score }) => score >= 0.5)
|
|
453
|
+
.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id))
|
|
454
|
+
.slice(0, 2)
|
|
455
|
+
.map(({ id }) => id);
|
|
456
|
+
};
|
|
457
|
+
const projectRules = [
|
|
458
|
+
{
|
|
459
|
+
id: 'repeated-tweak',
|
|
460
|
+
run({ registry, files }) {
|
|
461
|
+
const byClass = new Map();
|
|
462
|
+
for (const file of files) {
|
|
463
|
+
for (const { site } of sitesOf({ registry, file })) {
|
|
464
|
+
if (site.component !== 'Layout')
|
|
465
|
+
continue;
|
|
466
|
+
const intent = strings(site, 'intent')?.[0];
|
|
467
|
+
if (!intent || !registry.has(intent) || registry.isInternal(intent))
|
|
468
|
+
continue;
|
|
469
|
+
// Visual classes are layout-visual-class errors already; a ternary may list a class twice.
|
|
470
|
+
for (const cls of new Set(classesOf(site).filter((c) => !isVisualClass(c)))) {
|
|
471
|
+
const key = `${intent} ${cls}`;
|
|
472
|
+
const group = byClass.get(key) ?? { intent, cls, places: [] };
|
|
473
|
+
group.places.push({ file, site });
|
|
474
|
+
byClass.set(key, group);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
// Classes that always travel together ("min-w-0 flex-1") are one tweak, not two.
|
|
479
|
+
const where = (p) => `${p.file.path}:${p.site.line}`;
|
|
480
|
+
const tweaks = new Map();
|
|
481
|
+
for (const group of byClass.values()) {
|
|
482
|
+
if (group.places.length < REPEATED_TWEAK)
|
|
483
|
+
continue;
|
|
484
|
+
const key = `${group.intent} ${group.places.map(where).join(',')}`;
|
|
485
|
+
const tweak = tweaks.get(key) ?? { intent: group.intent, classes: [], places: group.places };
|
|
486
|
+
tweak.classes.push(group.cls);
|
|
487
|
+
tweaks.set(key, tweak);
|
|
488
|
+
}
|
|
489
|
+
return [...tweaks.values()].map(({ intent, classes, places }) => {
|
|
490
|
+
const [first, ...rest] = places;
|
|
491
|
+
const shown = rest.slice(0, 4).map(where);
|
|
492
|
+
const more = rest.length - shown.length;
|
|
493
|
+
const also = shown.length ? ` Also at ${shown.join(', ')}${more > 0 ? ` and ${more} more` : ''}.` : '';
|
|
494
|
+
const own = new Set(splitClasses(registry.resolve(intent).binding.classes));
|
|
495
|
+
if (classes.every((c) => own.has(c))) {
|
|
496
|
+
return violation({ registry, file: first.file }, first.site, 'repeated-tweak', 'warning', `${intent} already has "${classes.join(' ')}", so it does nothing in these ${places.length} places; remove it.${also}`, [], { intent, data: { classes, count: places.length, places: places.map(where), redundant: true } });
|
|
497
|
+
}
|
|
498
|
+
const existing = intentsWithTweak(registry, intent, classes);
|
|
499
|
+
const fix = existing.length
|
|
500
|
+
? `If these are the same kind of region, ${existing.join(' or ')} already ${existing.length > 1 ? 'have' : 'has'} it.`
|
|
501
|
+
: 'If these are the same kind of region, give it an intent.';
|
|
502
|
+
return violation({ registry, file: first.file }, first.site, 'repeated-tweak', 'warning', `${intent} gets "${classes.join(' ')}" in ${places.length} places. A tweak that repeats is a missing intent. ${fix}${also}`, [
|
|
503
|
+
...existing.map((id) => suggestIntent(registry, id)),
|
|
504
|
+
{
|
|
505
|
+
text: 'Or add an intent for it',
|
|
506
|
+
command: `${cli} add-intent layout.<name> --refs ${intent} --classes "${classes.join(' ')}" --description "..."`,
|
|
507
|
+
},
|
|
508
|
+
], { intent, data: { classes, count: places.length, places: places.map(where) } });
|
|
509
|
+
});
|
|
510
|
+
},
|
|
511
|
+
},
|
|
512
|
+
];
|
|
513
|
+
return {
|
|
514
|
+
name: 'ui',
|
|
515
|
+
appliesTo: (path) => path === registryPath || STYLESHEET.test(path) || (/\.[jt]sx$/.test(path) && !path.startsWith(componentsDir + '/')),
|
|
516
|
+
findUsages(ctx) {
|
|
517
|
+
return sitesOf(ctx).flatMap(({ site }) => {
|
|
518
|
+
if (!site.component)
|
|
519
|
+
return [];
|
|
520
|
+
return (strings(site, 'intent') ?? []).map((intent) => ({
|
|
521
|
+
intent,
|
|
522
|
+
path: ctx.file.path,
|
|
523
|
+
line: site.line,
|
|
524
|
+
column: 1,
|
|
525
|
+
snippet: (ctx.file.text.split('\n')[site.line - 1] ?? '').trim(),
|
|
526
|
+
via: site.component,
|
|
527
|
+
}));
|
|
528
|
+
});
|
|
529
|
+
},
|
|
530
|
+
rules,
|
|
531
|
+
projectRules,
|
|
532
|
+
describe: describeBinding,
|
|
533
|
+
groupOf: groupByComponent,
|
|
534
|
+
usage: uiUsage,
|
|
535
|
+
specSection: uiSpecSection,
|
|
536
|
+
referenceSection: uiReferenceSection,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
export { UI_RULE_DOCS, specExamples, uiSpecTooling } from './spec.js';
|
|
540
|
+
export { themeCss, THEME_TOKENS, THEME_VARIABLES, SURFACE_TOKENS, SURFACE_CLASSES } from './theme.js';
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { AnyRegistry } from '@prongsjs/core';
|
|
2
|
+
import type { RuleDoc, SpecContext, SpecTooling } from '@prongsjs/core/engine';
|
|
3
|
+
/** What an intent renders, without its classes: `<form>`, `role="status"`, `type="email"`. */
|
|
4
|
+
export declare function describeBinding(binding: unknown): string;
|
|
5
|
+
/** The examples whose intents all exist, for tests that check them against a registry. */
|
|
6
|
+
export declare function specExamples(registry: AnyRegistry): {
|
|
7
|
+
title: string;
|
|
8
|
+
code: string;
|
|
9
|
+
}[];
|
|
10
|
+
export declare function uiSpecSection(registry: AnyRegistry, context: SpecContext): string;
|
|
11
|
+
/** What only editing the registry or the theme needs: PRONGS.reference.md. */
|
|
12
|
+
export declare function uiReferenceSection(): string;
|
|
13
|
+
/** How a call site writes an intent, for `prongs find`. */
|
|
14
|
+
export declare function uiUsage(id: string, binding: unknown): string;
|
|
15
|
+
/** Groups the spec's index by the component that renders each intent. */
|
|
16
|
+
export declare function groupByComponent(id: string): string;
|
|
17
|
+
/**
|
|
18
|
+
* What the UI profile adds to PRONGS.md, without the code analysis (and the TypeScript
|
|
19
|
+
* compiler it needs). For writing the spec somewhere other than the CLI, like a docs site.
|
|
20
|
+
*/
|
|
21
|
+
export { COMPONENT_DOCS, LAYOUT_PROPS, type ComponentDoc, type PropDoc } from './components.js';
|
|
22
|
+
export declare const uiSpecTooling: SpecTooling;
|
|
23
|
+
/** What each UI rule finds, for the docs and for anyone reading a check report. */
|
|
24
|
+
export declare const UI_RULE_DOCS: readonly RuleDoc[];
|