@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/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;