@campo-design/lint 0.0.0-stage → 0.1.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 +294 -2
- package/bin/campo-lint.js +74 -0
- package/package.json +46 -3
- package/src/core.js +253 -0
- package/src/eslint/index.js +354 -0
- package/src/setup.js +100 -0
- package/src/stylelint/index.js +49 -0
- package/src/stylelint/plugin.js +153 -0
package/src/core.js
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// What Campo's lint rules know about Campo, in one place.
|
|
2
|
+
//
|
|
3
|
+
// Both linters read this file: the Stylelint plugin (./stylelint) for CSS and
|
|
4
|
+
// the ESLint plugin (./eslint) for JavaScript. Campo's own scoring
|
|
5
|
+
// (scripts/token-score.mjs, behind `pnpm metrics`, the bench grader and the
|
|
6
|
+
// docs and site check) reads it too. So "a raw value", "a primitive" and "a
|
|
7
|
+
// Phosphor name" mean the same thing wherever Campo checks code. Change a rule
|
|
8
|
+
// here, once.
|
|
9
|
+
//
|
|
10
|
+
// Nothing here is a hand-written list of tokens, icons or components. It reads
|
|
11
|
+
// them from the Campo packages a product has installed, so a product's lint
|
|
12
|
+
// always matches its Campo version:
|
|
13
|
+
// tokens @campo-design/tokens/describe (reads the DTCG sources)
|
|
14
|
+
// icons @campo-design/icons/names.json (Phosphor names)
|
|
15
|
+
// components @campo-design/webawesome/contracts, and Web Awesome's own
|
|
16
|
+
// custom-elements.json for each component's attributes
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
import { primitiveCssVars, tokenManifest } from '@campo-design/tokens/describe';
|
|
19
|
+
import { checkUsage } from '@campo-design/webawesome/contracts';
|
|
20
|
+
|
|
21
|
+
const require = createRequire(import.meta.url);
|
|
22
|
+
|
|
23
|
+
// Loaded on first use, so a linter that never meets a token, icon or
|
|
24
|
+
// component doesn't read the files.
|
|
25
|
+
const once = (load) => {
|
|
26
|
+
let value;
|
|
27
|
+
let loaded = false;
|
|
28
|
+
return () => {
|
|
29
|
+
if (!loaded) {
|
|
30
|
+
value = load();
|
|
31
|
+
loaded = true;
|
|
32
|
+
}
|
|
33
|
+
return value;
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// ---- Tokens ----------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
const tokens = once(() => {
|
|
40
|
+
const manifest = tokenManifest().tokens;
|
|
41
|
+
const cssVarOf = Object.fromEntries(manifest.map((t) => [t.path, t.cssVar]));
|
|
42
|
+
return {
|
|
43
|
+
primitive: primitiveCssVars(),
|
|
44
|
+
semantic: new Set(manifest.filter((t) => t.tier === 'semantic').map((t) => t.cssVar)),
|
|
45
|
+
// A primitive's CSS variable → the semantic tokens that point at it.
|
|
46
|
+
useInstead: Object.fromEntries(
|
|
47
|
+
manifest.filter((t) => t.tier === 'primitive').map((t) => [t.cssVar, (t.useInstead ?? []).map((p) => cssVarOf[p])]),
|
|
48
|
+
),
|
|
49
|
+
};
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/** `semantic`, `primitive`, or `unknown` (a --campo-* name Campo doesn't define). */
|
|
53
|
+
export function tokenTier(name) {
|
|
54
|
+
const { semantic, primitive } = tokens();
|
|
55
|
+
if (semantic.has(name)) return 'semantic';
|
|
56
|
+
if (primitive.has(name)) return 'primitive';
|
|
57
|
+
return 'unknown';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Every primitive's CSS variable. */
|
|
61
|
+
export const primitives = () => [...tokens().primitive];
|
|
62
|
+
|
|
63
|
+
/** The semantic tokens that point at a primitive: what to use instead. */
|
|
64
|
+
export const semanticFor = (primitive) => tokens().useInstead[primitive] ?? [];
|
|
65
|
+
|
|
66
|
+
/** Every --campo-* variable a value reads through var(). */
|
|
67
|
+
export const campoVars = (value) => [...value.matchAll(/var\(\s*(--campo-[a-z0-9-]+)/g)].map((m) => m[1]);
|
|
68
|
+
|
|
69
|
+
/** The message for a --campo-* variable that isn't a semantic token, or null if it is one. */
|
|
70
|
+
export function tierProblem(name) {
|
|
71
|
+
const tier = tokenTier(name);
|
|
72
|
+
if (tier === 'semantic') return null;
|
|
73
|
+
if (tier === 'unknown') return `${name} isn't a Campo token. Check the name in @campo-design/tokens/manifest.json.`;
|
|
74
|
+
const instead = semanticFor(name);
|
|
75
|
+
const hint = instead.length ? ` Use ${instead.slice(0, 3).join(', ')}${instead.length > 3 ? ', …' : ''} instead.` : ' Use the semantic token for its role.';
|
|
76
|
+
return `${name} is a primitive. Styles use semantic tokens, so themes and redesigns reach them.${hint}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---- Raw values ------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
const length = ['px', 'rem'];
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Which raw units each property may not take, because a token exists for it.
|
|
85
|
+
* Keys are a property name or a "/regex/", the format Stylelint's
|
|
86
|
+
* declaration-property-unit-disallowed-list takes. What stays allowed on
|
|
87
|
+
* purpose: layout values (max-width: 65ch, grid templates, breakpoints), em
|
|
88
|
+
* values (relative to the text around them), 0, % and keywords.
|
|
89
|
+
*/
|
|
90
|
+
export const TOKEN_UNITS = {
|
|
91
|
+
'/^(margin|padding)(-.+)?$/': length,
|
|
92
|
+
'/^(gap|row-gap|column-gap)$/': length,
|
|
93
|
+
'/^border(-.+)?-radius$/': [...length, 'em'],
|
|
94
|
+
'/^(border|outline)(-(top|right|bottom|left|block|inline)(-(start|end))?)?(-width)?$/': length,
|
|
95
|
+
'outline-offset': length,
|
|
96
|
+
'/^font(-size)?$/': length,
|
|
97
|
+
'line-height': length,
|
|
98
|
+
'letter-spacing': length,
|
|
99
|
+
'/^(box|text)-shadow$/': length,
|
|
100
|
+
'/^(transition|animation)(-duration|-delay)?$/': ['ms', 's'],
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/** Color functions a style may not call: colors come from tokens. */
|
|
104
|
+
export const COLOR_FUNCTIONS = ['rgb', 'rgba', 'hsl', 'hsla', 'hwb', 'lab', 'lch', 'oklab', 'oklch'];
|
|
105
|
+
|
|
106
|
+
/** Properties that have a Campo token family: color, spacing, radius, border, type, motion, shadow. */
|
|
107
|
+
export const DESIGN_PROPERTY =
|
|
108
|
+
/^(color|background(-color)?|border(-(top|right|bottom|left|block|inline)(-(start|end))?)?(-(color|width))?|border(-.+)?-radius|outline(-color|-width|-offset)?|fill|stroke|text-decoration-color|caret-color|accent-color|box-shadow|margin(-.+)?|padding(-.+)?|gap|row-gap|column-gap|font-size|font-weight|font-family|line-height|transition(-duration|-delay)?|animation(-duration|-delay)?)$/;
|
|
109
|
+
|
|
110
|
+
/** A hardcoded color, px/rem length or ms/s duration. */
|
|
111
|
+
export const RAW_VALUE = new RegExp(
|
|
112
|
+
`#[0-9a-f]{3,8}\\b|\\b(${COLOR_FUNCTIONS.join('|')})\\(|(?<![\\w.-])-?\\d*\\.?\\d+(px|rem|ms|s)\\b`,
|
|
113
|
+
'i',
|
|
114
|
+
);
|
|
115
|
+
const NAMED_COLOR = /\b(white|black|red|blue|green|gray|grey|orange|purple|yellow|pink)\b/i;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* What a design declaration's value is:
|
|
119
|
+
* token a semantic --campo-* variable
|
|
120
|
+
* unknown a --campo-* variable Campo doesn't define
|
|
121
|
+
* primitive a primitive --campo-* variable
|
|
122
|
+
* raw a hardcoded color, px/rem length, ms/s duration or numeric weight
|
|
123
|
+
* neutral 0, %, em, keywords, other variables: nothing a token should replace
|
|
124
|
+
*/
|
|
125
|
+
export function classify(property, value) {
|
|
126
|
+
const vars = campoVars(value);
|
|
127
|
+
const tiers = vars.map(tokenTier);
|
|
128
|
+
if (tiers.includes('unknown')) return 'unknown';
|
|
129
|
+
if (tiers.includes('primitive')) return 'primitive';
|
|
130
|
+
if (tiers.includes('semantic')) return 'token';
|
|
131
|
+
// Strip var() fallbacks and the like before looking for raw values.
|
|
132
|
+
const bare = value.replace(/var\([^)]*\)/g, '');
|
|
133
|
+
if (RAW_VALUE.test(bare)) return 'raw';
|
|
134
|
+
if (/color|background|fill|stroke|border|outline|shadow/.test(property) && NAMED_COLOR.test(bare)) return 'raw';
|
|
135
|
+
if (property === 'font-weight' && /^\d+$/.test(bare.trim())) return 'raw';
|
|
136
|
+
return 'neutral';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// React turns a bare number into px for these. `lineHeight: 1.5` and
|
|
140
|
+
// `fontWeight: 600` aren't lengths; the weight is caught by classify.
|
|
141
|
+
const UNITLESS_PX = /^(margin|padding|gap|row-gap|column-gap|border(-.+)?-(radius|width)|border-width|outline(-width|-offset)?|font-size)/;
|
|
142
|
+
|
|
143
|
+
/** A React style key (`paddingBlock`) as its CSS property (`padding-block`). */
|
|
144
|
+
export const cssProperty = (key) => (key.startsWith('--') ? key : key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`));
|
|
145
|
+
|
|
146
|
+
/** A number in a React style object as the CSS value React renders. */
|
|
147
|
+
export const styleNumber = (property, number) => (UNITLESS_PX.test(property) && Number(number) !== 0 ? `${number}px` : String(number));
|
|
148
|
+
|
|
149
|
+
// ---- Icons -----------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
const icons = once(() => {
|
|
152
|
+
try {
|
|
153
|
+
return new Set(require('@campo-design/icons/names.json'));
|
|
154
|
+
} catch {
|
|
155
|
+
// @campo-design/icons isn't built (only inside the Campo repo, before
|
|
156
|
+
// `pnpm build`). Skip the icon rule rather than flag every icon.
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
/** Whether the Phosphor name list could be read. */
|
|
162
|
+
export const iconsLoaded = () => icons() !== null;
|
|
163
|
+
|
|
164
|
+
/** true or false, or null when the name list isn't available. */
|
|
165
|
+
export const isPhosphorIcon = (name) => (icons() ? icons().has(name) : null);
|
|
166
|
+
|
|
167
|
+
/** `<wa-icon name="…">` in HTML or a template: each name and where it starts. */
|
|
168
|
+
export const iconNames = (text) => [...text.matchAll(/<wa-icon\b[^>]*?\bname=["']([a-z0-9-]+)["']/g)].map((m) => ({ name: m[1], index: m.index }));
|
|
169
|
+
|
|
170
|
+
// ---- Component contracts ---------------------------------------------------
|
|
171
|
+
|
|
172
|
+
const customElements = once(() => require('@awesome.me/webawesome/dist/custom-elements.json'));
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Uses of a Web Awesome component, in HTML or JSX text, with a combination its
|
|
176
|
+
* contract lists as a gap (it renders in stock Web Awesome colours, not
|
|
177
|
+
* Campo's) or a value the component doesn't have. Each is { tag, line, detail, message }.
|
|
178
|
+
*/
|
|
179
|
+
export function contractFindings(text) {
|
|
180
|
+
return checkUsage(withoutTemplateExpressions(text), customElements()).findings.map((f) => {
|
|
181
|
+
if (f.kind === 'gap') {
|
|
182
|
+
const combo = Object.entries(f.combo).map(([a, v]) => `${a}=${v ?? '(not set)'}`).join(' ');
|
|
183
|
+
const instead = f.gap.instead ? ` ${f.gap.instead}` : '';
|
|
184
|
+
return { tag: f.tag, line: f.line, detail: `<${f.tag}> ${combo}`, message: `<${f.tag}> ${combo} is a gap in Campo's contract: it renders in stock Web Awesome colours.${instead}` };
|
|
185
|
+
}
|
|
186
|
+
const allowed = f.allowed ? ` It takes ${f.allowed.join(', ')}.` : '';
|
|
187
|
+
return { tag: f.tag, line: f.line, detail: `<${f.tag}> ${f.attr}="${f.value}" doesn't exist`, message: `<${f.tag}> has no ${f.attr}="${f.value}".${allowed}` };
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Rewrites each JavaScript template expression (lit's `variant=${v}`) as a
|
|
193
|
+
* Nunjucks-style `{{ }}`, keeping its line breaks so line numbers hold.
|
|
194
|
+
* checkUsage skips a value only known at run time, but it learned to read
|
|
195
|
+
* `${` as one only after the bridge's 0.4.0; before that it reported the
|
|
196
|
+
* expression as a value the component doesn't have. Every bridge release from
|
|
197
|
+
* lint's floor on already skips `{{ }}`, so lint gives the same answer on all
|
|
198
|
+
* of them (scripts/test-peer-floors.mjs runs lint's tests against the floor).
|
|
199
|
+
*/
|
|
200
|
+
export const withoutTemplateExpressions = (text) =>
|
|
201
|
+
text.replace(/\$\{[^}]*\}/g, (expression) => `{{${expression.slice(2, -1).replace(/[^\n]/g, '')}}}`);
|
|
202
|
+
|
|
203
|
+
// ---- React -----------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
// Where a React component for a Web Awesome element can be imported from:
|
|
206
|
+
// Campo's package (`@campo-design/react/button`), or Web Awesome's own
|
|
207
|
+
// wrappers, whose types don't know Campo's contracts but whose use is still
|
|
208
|
+
// checked.
|
|
209
|
+
const REACT_MODULE = /import\s+(?:(\w+)\s*,?\s*)?(?:\{([^}]*)\})?\s*from\s*['"](@campo-design\/react|@awesome\.me\/webawesome\/dist\/react)\/([a-z0-9-]+)(?:\/index\.js)?['"]/g;
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The React components a file imports for Web Awesome elements, as a Map of
|
|
213
|
+
* the local name to its element: `import { WaButton } from
|
|
214
|
+
* '@campo-design/react/button'` gives WaButton → wa-button. Each module has
|
|
215
|
+
* one component, so any name imported from it (a default import, a renamed
|
|
216
|
+
* one) stands for its element. A namespace import (`* as C`) isn't followed.
|
|
217
|
+
*/
|
|
218
|
+
export function reactTags(text) {
|
|
219
|
+
const tags = new Map();
|
|
220
|
+
for (const m of text.matchAll(REACT_MODULE)) {
|
|
221
|
+
const tag = `wa-${m[4]}`;
|
|
222
|
+
if (m[1]) tags.set(m[1], tag);
|
|
223
|
+
for (const spec of (m[2] ?? '').split(',')) {
|
|
224
|
+
const local = spec.replace(/^\s*type\s+/, '').split(/\s+as\s+/).pop().trim();
|
|
225
|
+
if (/^[A-Z]\w*$/.test(local)) tags.set(local, tag);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return tags;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* JSX that uses React components for Web Awesome elements, rewritten as the
|
|
233
|
+
* elements: `<WaButton variant="brand">` becomes `<wa-button
|
|
234
|
+
* variant="brand">`, so every check that reads elements (contract gaps,
|
|
235
|
+
* icon names) reads React too. Only opening tags change, and never a line
|
|
236
|
+
* break, so line numbers stay right.
|
|
237
|
+
*/
|
|
238
|
+
export function reactAsElements(text, tags = reactTags(text)) {
|
|
239
|
+
if (!tags.size) return text;
|
|
240
|
+
const names = [...tags.keys()].sort((a, b) => b.length - a.length).join('|');
|
|
241
|
+
return text.replace(new RegExp(`<(${names})(?=[\\s/>])`, 'g'), (_, name) => `<${tags.get(name)}`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---- Setup -----------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
/** Web Awesome's own stylesheet: Campo's CSS styles native elements instead. */
|
|
247
|
+
export const WEBAWESOME_STYLESHEET = /webawesome\/dist\/styles\/webawesome\.css/;
|
|
248
|
+
|
|
249
|
+
/** Web Awesome's autoloader, or its root entry, which starts it. Campo imports each component. */
|
|
250
|
+
export const AUTOLOADER = /webawesome\.loader(?:\.js)?|@awesome\.me\/webawesome\/dist\/webawesome\.js|^@awesome\.me\/webawesome$/;
|
|
251
|
+
|
|
252
|
+
/** Web Awesome's palette and brand classes: Campo decides color. */
|
|
253
|
+
export const PALETTE_CLASS = /(?<![\w-])wa-(?:palette|brand)-[a-z0-9-]+/g;
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
// Campo's ESLint plugin, for the JavaScript, JSX and templates of a product
|
|
2
|
+
// built with Campo. In a flat config:
|
|
3
|
+
//
|
|
4
|
+
// import campo from '@campo-design/lint/eslint';
|
|
5
|
+
// export default [campo.configs.recommended];
|
|
6
|
+
//
|
|
7
|
+
// JavaScript carries Campo's rules in its strings: CSS in template literals
|
|
8
|
+
// and style objects, markup in lit's html`` or innerHTML, import paths. So
|
|
9
|
+
// most rules read string literals and template text, and JSX where it's
|
|
10
|
+
// parsed. They're the same rules Campo's Stylelint config applies to CSS,
|
|
11
|
+
// plus the ones only script can break (imports, icon names, component
|
|
12
|
+
// attributes). Each is described in ../../README.md.
|
|
13
|
+
import {
|
|
14
|
+
AUTOLOADER,
|
|
15
|
+
DESIGN_PROPERTY,
|
|
16
|
+
PALETTE_CLASS,
|
|
17
|
+
WEBAWESOME_STYLESHEET,
|
|
18
|
+
classify,
|
|
19
|
+
contractFindings,
|
|
20
|
+
cssProperty,
|
|
21
|
+
iconNames,
|
|
22
|
+
isPhosphorIcon,
|
|
23
|
+
reactAsElements,
|
|
24
|
+
reactTags,
|
|
25
|
+
styleNumber,
|
|
26
|
+
tierProblem,
|
|
27
|
+
} from '../core.js';
|
|
28
|
+
|
|
29
|
+
const docs = (rule, description) => ({
|
|
30
|
+
description,
|
|
31
|
+
url: `https://github.com/jlitchfield/campo/blob/main/packages/lint/README.md#campo${rule}`,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// ---- Reading strings ---------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
/** A string literal's or template element's text, or null for anything else. */
|
|
37
|
+
function textOf(node) {
|
|
38
|
+
if (node.type === 'Literal' && typeof node.value === 'string') return node.value;
|
|
39
|
+
if (node.type === 'TemplateElement') return node.value.cooked ?? node.value.raw;
|
|
40
|
+
if (node.type === 'JSXText') return node.value;
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Calls `check(text, node, at)` for every string, template part and JSX text
|
|
46
|
+
* in a file. `at(index)` turns an offset in the text into a location to report.
|
|
47
|
+
*/
|
|
48
|
+
function eachText(context, check) {
|
|
49
|
+
const source = context.sourceCode;
|
|
50
|
+
const visit = (node) => {
|
|
51
|
+
const text = textOf(node);
|
|
52
|
+
if (text === null) return;
|
|
53
|
+
// Skip the opening quote or backtick so offsets land on the text.
|
|
54
|
+
const start = node.range[0] + (node.type === 'JSXText' ? 0 : 1);
|
|
55
|
+
const at = (index) => {
|
|
56
|
+
const loc = source.getLocFromIndex(Math.min(start + index, node.range[1]));
|
|
57
|
+
return { start: loc, end: loc };
|
|
58
|
+
};
|
|
59
|
+
check(text, node, at);
|
|
60
|
+
};
|
|
61
|
+
return { Literal: visit, TemplateElement: visit, JSXText: visit };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A rule that reports every match of `pattern` in a file's strings. */
|
|
65
|
+
const textRule = ({ name, description, pattern, message }) => ({
|
|
66
|
+
meta: { type: 'problem', docs: docs(name, description), schema: [], messages: { rejected: message } },
|
|
67
|
+
create: (context) =>
|
|
68
|
+
eachText(context, (text, node, at) => {
|
|
69
|
+
const re = new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`);
|
|
70
|
+
for (const m of text.matchAll(re)) context.report({ node, loc: at(m.index), messageId: 'rejected', data: { match: m[0] } });
|
|
71
|
+
}),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// ---- Rules -------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
const semanticTokens = {
|
|
77
|
+
meta: {
|
|
78
|
+
type: 'problem',
|
|
79
|
+
docs: docs('semantic-tokens', 'A var(--campo-*) must be a semantic token: not a primitive, not a name Campo does not define.'),
|
|
80
|
+
schema: [],
|
|
81
|
+
messages: { rejected: '{{problem}}' },
|
|
82
|
+
},
|
|
83
|
+
create: (context) =>
|
|
84
|
+
eachText(context, (text, node, at) => {
|
|
85
|
+
for (const m of text.matchAll(/var\(\s*(--campo-[a-z0-9-]+)/g)) {
|
|
86
|
+
const problem = tierProblem(m[1]);
|
|
87
|
+
if (problem) context.report({ node, loc: at(m.index), messageId: 'rejected', data: { problem } });
|
|
88
|
+
}
|
|
89
|
+
}),
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const LOOKS_LIKE_CSS = /(?<![\w-])[a-z][a-z-]*\s*:\s*[^;{}]+;|style=["']/;
|
|
93
|
+
|
|
94
|
+
/** Raw design values in CSS written in a template literal or string: `gap: 16px`. */
|
|
95
|
+
function rawInCss(text) {
|
|
96
|
+
const found = [];
|
|
97
|
+
const css = text.replace(/\/\*[\s\S]*?\*\//g, (c) => ' '.repeat(c.length));
|
|
98
|
+
for (const m of css.matchAll(/(?<![\w-])([a-z][a-z-]*)\s*:\s*([^;{}`]+)/g)) {
|
|
99
|
+
const [, property, value] = m;
|
|
100
|
+
if (DESIGN_PROPERTY.test(property) && classify(property, value.trim()) === 'raw') found.push({ index: m.index, property, value: value.trim() });
|
|
101
|
+
}
|
|
102
|
+
return found;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const noRawValues = {
|
|
106
|
+
meta: {
|
|
107
|
+
type: 'problem',
|
|
108
|
+
docs: docs('no-raw-values', 'Design values in style objects and CSS-in-JS come from Campo tokens, not raw colors, lengths or durations.'),
|
|
109
|
+
schema: [],
|
|
110
|
+
messages: {
|
|
111
|
+
rejected: 'Use a Campo token for {{property}}, not "{{value}}". If none fits, that\'s a token for Campo to add.',
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
create(context) {
|
|
115
|
+
const report = (node, property, value, loc) => context.report({ node, loc, messageId: 'rejected', data: { property, value } });
|
|
116
|
+
|
|
117
|
+
/** A style object's properties: { paddingBlock: 8, color: '#333' }. */
|
|
118
|
+
const checkStyleObject = (object) => {
|
|
119
|
+
for (const prop of object.properties) {
|
|
120
|
+
if (prop.type !== 'Property' || prop.computed) continue;
|
|
121
|
+
const key = prop.key.type === 'Identifier' ? prop.key.name : String(prop.key.value);
|
|
122
|
+
const property = cssProperty(key);
|
|
123
|
+
if (property.startsWith('--') || !DESIGN_PROPERTY.test(property)) continue;
|
|
124
|
+
const { value } = prop;
|
|
125
|
+
let css = null;
|
|
126
|
+
if (value.type === 'Literal' && typeof value.value === 'number') css = styleNumber(property, value.value);
|
|
127
|
+
else if (value.type === 'Literal' && typeof value.value === 'string') css = value.value;
|
|
128
|
+
else if (value.type === 'TemplateLiteral' && value.expressions.length === 0) css = value.quasis[0].value.cooked;
|
|
129
|
+
if (css !== null && classify(property, css) === 'raw') report(value, property, css);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
// JSX: style={{ ... }}
|
|
135
|
+
JSXAttribute(node) {
|
|
136
|
+
if (node.name.name !== 'style' || node.value?.type !== 'JSXExpressionContainer') return;
|
|
137
|
+
if (node.value.expression.type === 'ObjectExpression') checkStyleObject(node.value.expression);
|
|
138
|
+
},
|
|
139
|
+
// Object.assign(el.style, { ... }) and el.style.gap = '16px'
|
|
140
|
+
CallExpression(node) {
|
|
141
|
+
const { callee, arguments: args } = node;
|
|
142
|
+
const isAssign = callee.type === 'MemberExpression' && callee.object.name === 'Object' && callee.property.name === 'assign';
|
|
143
|
+
const target = args[0];
|
|
144
|
+
const isStyle = target?.type === 'MemberExpression' && target.property.name === 'style';
|
|
145
|
+
if (isAssign && isStyle && args[1]?.type === 'ObjectExpression') checkStyleObject(args[1]);
|
|
146
|
+
},
|
|
147
|
+
AssignmentExpression(node) {
|
|
148
|
+
const { left, right } = node;
|
|
149
|
+
if (left.type !== 'MemberExpression' || left.object.type !== 'MemberExpression' || left.object.property.name !== 'style') return;
|
|
150
|
+
if (left.computed || right.type !== 'Literal') return;
|
|
151
|
+
const property = cssProperty(left.property.name);
|
|
152
|
+
const css = typeof right.value === 'number' ? styleNumber(property, right.value) : right.value;
|
|
153
|
+
if (DESIGN_PROPERTY.test(property) && typeof css === 'string' && classify(property, css) === 'raw') report(right, property, css);
|
|
154
|
+
},
|
|
155
|
+
// CSS in a template literal or string: css`...`, <style>${...}</style>, innerHTML.
|
|
156
|
+
...eachText(context, (text, node, at) => {
|
|
157
|
+
// Only text that holds a whole declaration (`gap: 16px;`) or an inline
|
|
158
|
+
// style, so an ordinary string such as 'Time: 5s' isn't read as CSS.
|
|
159
|
+
if (!LOOKS_LIKE_CSS.test(text)) return;
|
|
160
|
+
for (const { index, property, value } of rawInCss(text)) report(node, property, value, at(index));
|
|
161
|
+
}),
|
|
162
|
+
};
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const noImportant = textRule({
|
|
167
|
+
name: 'no-important',
|
|
168
|
+
description: 'No !important in CSS written in script: layer order is what lets product CSS win.',
|
|
169
|
+
pattern: /!important\b/,
|
|
170
|
+
message: "Don't use !important: Campo's CSS is in layers, so product CSS already wins without it.",
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const noWaOverride = {
|
|
174
|
+
meta: {
|
|
175
|
+
type: 'problem',
|
|
176
|
+
docs: docs('no-wa-override', 'Never set a --wa-* token: Campo\'s bridge maps every one to a Campo token.'),
|
|
177
|
+
schema: [],
|
|
178
|
+
messages: {
|
|
179
|
+
rejected:
|
|
180
|
+
"Don't set {{name}}: Campo's bridge maps every --wa-* token to a Campo token. Use the component's variants and appearances, or name the gap as a Campo token decision.",
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
create(context) {
|
|
184
|
+
const report = (node, name, loc) => context.report({ node, loc, messageId: 'rejected', data: { name } });
|
|
185
|
+
return {
|
|
186
|
+
// CSS in strings and templates: `--wa-color-brand: red;`
|
|
187
|
+
...eachText(context, (text, node, at) => {
|
|
188
|
+
for (const m of text.matchAll(/(--wa-[a-z0-9-]+)\s*:/g)) report(node, m[1], at(m.index));
|
|
189
|
+
}),
|
|
190
|
+
// Style objects: { '--wa-color-brand': 'red' }
|
|
191
|
+
Property(node) {
|
|
192
|
+
const key = node.key.type === 'Literal' ? node.key.value : null;
|
|
193
|
+
if (typeof key === 'string' && key.startsWith('--wa-')) report(node.key, key);
|
|
194
|
+
},
|
|
195
|
+
// el.style.setProperty('--wa-color-brand', 'red')
|
|
196
|
+
CallExpression(node) {
|
|
197
|
+
const [first] = node.arguments;
|
|
198
|
+
if (node.callee.type === 'MemberExpression' && node.callee.property.name === 'setProperty' && first?.type === 'Literal' && /^--wa-/.test(first.value)) {
|
|
199
|
+
report(first, first.value);
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const noColorSchemeQuery = textRule({
|
|
207
|
+
name: 'no-color-scheme-query',
|
|
208
|
+
description: "Don't handle dark mode in product code: Campo's themes repoint the semantic tokens.",
|
|
209
|
+
pattern: /prefers-color-scheme/,
|
|
210
|
+
message:
|
|
211
|
+
"Don't handle dark mode yourself: Campo's themes repoint the semantic tokens, so a screen built from them is right in both. No media queries or theme checks in script.",
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const phosphorIcons = {
|
|
215
|
+
meta: {
|
|
216
|
+
type: 'problem',
|
|
217
|
+
docs: docs('phosphor-icons', '<wa-icon name> takes a Phosphor name. A Font Awesome name draws nothing, with no error.'),
|
|
218
|
+
schema: [],
|
|
219
|
+
messages: {
|
|
220
|
+
rejected:
|
|
221
|
+
'"{{name}}" isn\'t a Phosphor icon, so <wa-icon> draws nothing. Campo uses Phosphor names (check-circle, x-circle, warning, info), not Font Awesome\'s.',
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
create(context) {
|
|
225
|
+
const report = (node, name, loc) => {
|
|
226
|
+
if (isPhosphorIcon(name) === false) context.report({ node, loc, messageId: 'rejected', data: { name } });
|
|
227
|
+
};
|
|
228
|
+
// React components for <wa-icon>: WaIcon from @campo-design/react/icon, under any name.
|
|
229
|
+
let tags = new Map();
|
|
230
|
+
return {
|
|
231
|
+
Program() {
|
|
232
|
+
tags = reactTags(context.sourceCode.text);
|
|
233
|
+
},
|
|
234
|
+
// Markup in strings and templates.
|
|
235
|
+
...eachText(context, (text, node, at) => {
|
|
236
|
+
for (const { name, index } of iconNames(text)) report(node, name, at(index));
|
|
237
|
+
}),
|
|
238
|
+
// JSX: <wa-icon name="…" />, or <WaIcon name="…" /> from @campo-design/react
|
|
239
|
+
JSXOpeningElement(node) {
|
|
240
|
+
if (node.name.type !== 'JSXIdentifier') return;
|
|
241
|
+
if (node.name.name !== 'wa-icon' && tags.get(node.name.name) !== 'wa-icon') return;
|
|
242
|
+
const attr = node.attributes.find((a) => a.type === 'JSXAttribute' && a.name.name === 'name');
|
|
243
|
+
if (attr?.value?.type === 'Literal' && typeof attr.value.value === 'string') report(attr.value, attr.value.value);
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const contractGaps = {
|
|
250
|
+
meta: {
|
|
251
|
+
type: 'problem',
|
|
252
|
+
docs: docs('contract-gaps', "A Web Awesome component used with a combination its Campo contract lists as a gap, or a value it doesn't have."),
|
|
253
|
+
schema: [],
|
|
254
|
+
messages: { rejected: '{{message}}' },
|
|
255
|
+
},
|
|
256
|
+
create(context) {
|
|
257
|
+
return {
|
|
258
|
+
// The contract check reads markup as text, in HTML strings, templates and
|
|
259
|
+
// JSX alike. React components from @campo-design/react (or Web Awesome's
|
|
260
|
+
// own wrappers) are read as the elements they render, so
|
|
261
|
+
// <WaButton variant="danger" appearance="accent"> is checked like
|
|
262
|
+
// <wa-button …>: the same check as the package's types, for code
|
|
263
|
+
// TypeScript doesn't check (plain JavaScript, or a cast).
|
|
264
|
+
'Program:exit'(node) {
|
|
265
|
+
const source = context.sourceCode.text;
|
|
266
|
+
const tags = reactTags(source);
|
|
267
|
+
const text = reactAsElements(source, tags);
|
|
268
|
+
if (!/<wa-/.test(text)) return;
|
|
269
|
+
for (const f of contractFindings(text)) {
|
|
270
|
+
// Report at the tag as written: <wa-button>, or <WaButton> in React.
|
|
271
|
+
const line = context.sourceCode.lines[f.line - 1] ?? '';
|
|
272
|
+
const names = [f.tag, ...[...tags].filter(([, tag]) => tag === f.tag).map(([name]) => name)];
|
|
273
|
+
let column = -1;
|
|
274
|
+
let written = f.tag;
|
|
275
|
+
for (const name of names) {
|
|
276
|
+
const index = line.search(new RegExp(`<${name}(?=[\\s/>]|$)`));
|
|
277
|
+
if (index >= 0 && (column < 0 || index < column)) [column, written] = [index, name];
|
|
278
|
+
}
|
|
279
|
+
column = Math.max(0, column);
|
|
280
|
+
const message = written === f.tag ? f.message : f.message.replace(`<${f.tag}>`, `<${written}> (<${f.tag}>)`);
|
|
281
|
+
context.report({ node, loc: { start: { line: f.line, column }, end: { line: f.line, column: column + written.length + 1 } }, messageId: 'rejected', data: { message } });
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const noWebawesomeStylesheet = textRule({
|
|
289
|
+
name: 'no-webawesome-stylesheet',
|
|
290
|
+
description: "Don't import Web Awesome's webawesome.css: Campo's CSS styles native elements.",
|
|
291
|
+
pattern: WEBAWESOME_STYLESHEET,
|
|
292
|
+
message: "Don't import Web Awesome's webawesome.css: import @campo-design/webawesome/theme.css, and @campo-design/css for native elements.",
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
const noAutoloader = {
|
|
296
|
+
meta: {
|
|
297
|
+
type: 'problem',
|
|
298
|
+
docs: docs('no-autoloader', "Import each Web Awesome component; don't use the autoloader or the package's root entry, which starts it."),
|
|
299
|
+
schema: [],
|
|
300
|
+
messages: {
|
|
301
|
+
rejected:
|
|
302
|
+
'Import each component you use (@awesome.me/webawesome/dist/components/<name>/<name>.js), not the autoloader: Campo checks what an app imports.',
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
create(context) {
|
|
306
|
+
const check = (source) => {
|
|
307
|
+
if (source?.type === 'Literal' && typeof source.value === 'string' && AUTOLOADER.test(source.value)) {
|
|
308
|
+
context.report({ node: source, messageId: 'rejected' });
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
return {
|
|
312
|
+
ImportDeclaration: (node) => check(node.source),
|
|
313
|
+
ImportExpression: (node) => check(node.source),
|
|
314
|
+
ExportAllDeclaration: (node) => check(node.source),
|
|
315
|
+
ExportNamedDeclaration: (node) => check(node.source),
|
|
316
|
+
};
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const noPaletteClass = textRule({
|
|
321
|
+
name: 'no-palette-class',
|
|
322
|
+
description: "No wa-palette-* or wa-brand-* classes: Campo decides color.",
|
|
323
|
+
pattern: PALETTE_CLASS,
|
|
324
|
+
message: "Don't use {{match}}: Campo decides color through its tokens. Keep <html class=\"wa-theme-default campo-wa\"> only.",
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// ---- The plugin ----------------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
const rules = {
|
|
330
|
+
'semantic-tokens': semanticTokens,
|
|
331
|
+
'no-raw-values': noRawValues,
|
|
332
|
+
'no-important': noImportant,
|
|
333
|
+
'no-wa-override': noWaOverride,
|
|
334
|
+
'no-color-scheme-query': noColorSchemeQuery,
|
|
335
|
+
'phosphor-icons': phosphorIcons,
|
|
336
|
+
'contract-gaps': contractGaps,
|
|
337
|
+
'no-webawesome-stylesheet': noWebawesomeStylesheet,
|
|
338
|
+
'no-autoloader': noAutoloader,
|
|
339
|
+
'no-palette-class': noPaletteClass,
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
const plugin = {
|
|
343
|
+
meta: { name: '@campo-design/lint', namespace: 'campo' },
|
|
344
|
+
rules,
|
|
345
|
+
configs: {},
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
plugin.configs.recommended = {
|
|
349
|
+
name: '@campo-design/lint/recommended',
|
|
350
|
+
plugins: { campo: plugin },
|
|
351
|
+
rules: Object.fromEntries(Object.keys(rules).map((name) => [`campo/${name}`, 'error'])),
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
export default plugin;
|