@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/setup.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Campo's fixed setup, checked across a whole product (AGENTS.md, Web
|
|
2
|
+
// Awesome: "Setup is fixed").
|
|
3
|
+
//
|
|
4
|
+
// Unlike the Stylelint and ESLint rules, most of these are about a product,
|
|
5
|
+
// not one file: the theme classes can sit in a layout and connectColorScheme()
|
|
6
|
+
// in a script, and a linter sees one file at a time. So setupFindings takes
|
|
7
|
+
// every file of a product at once. `campo-lint setup` (../bin/campo-lint.js)
|
|
8
|
+
// runs it on a product's folders; Campo's own docs and site check, and the
|
|
9
|
+
// bench grader, run it through scripts/token-score.mjs.
|
|
10
|
+
//
|
|
11
|
+
// setupThemeClass <html class="wa-theme-default campo-wa">: both classes, somewhere
|
|
12
|
+
// setupThemeCss @campo-design/webawesome/theme.css imported
|
|
13
|
+
// setupColorScheme connectColorScheme() called, so Web Awesome follows data-theme
|
|
14
|
+
// setupIcons registerCampoIcons() called, so <wa-icon> draws Phosphor
|
|
15
|
+
// setupImport each <wa-*> element used is imported from dist/components/
|
|
16
|
+
// (in React, from @campo-design/react/<name>)
|
|
17
|
+
// setupStylesheet Web Awesome's webawesome.css imported (Campo's CSS styles native elements)
|
|
18
|
+
// setupAutoloader Web Awesome's autoloader (webawesome.loader.js, or its root entry, which starts it)
|
|
19
|
+
// setupPalette a wa-palette-* or wa-brand-* class (Campo decides color)
|
|
20
|
+
// setupReactImport a component imported from Web Awesome's own React wrappers
|
|
21
|
+
// (dist/react/), whose types don't know Campo's contracts
|
|
22
|
+
//
|
|
23
|
+
// In React, setupCampo() from @campo-design/react counts for both
|
|
24
|
+
// connectColorScheme() and registerCampoIcons(): it calls them.
|
|
25
|
+
//
|
|
26
|
+
// The first five only apply to a product that uses Web Awesome at all.
|
|
27
|
+
import { reactAsElements } from './core.js';
|
|
28
|
+
|
|
29
|
+
export const SETUP_RULES = ['setupThemeClass', 'setupThemeCss', 'setupColorScheme', 'setupIcons', 'setupImport', 'setupStylesheet', 'setupAutoloader', 'setupPalette', 'setupReactImport'];
|
|
30
|
+
|
|
31
|
+
/** What to do about each finding, for a person or an agent reading the report. */
|
|
32
|
+
export const SETUP_FIXES = {
|
|
33
|
+
setupThemeClass: 'Put class="wa-theme-default campo-wa" on <html>.',
|
|
34
|
+
setupThemeCss: "Import @campo-design/webawesome/theme.css once, in the product's entry stylesheet or script.",
|
|
35
|
+
setupColorScheme: 'Call connectColorScheme() from @campo-design/webawesome once (in React, setupCampo()), so Web Awesome follows data-theme.',
|
|
36
|
+
setupIcons: 'Call registerCampoIcons() from @campo-design/webawesome/icons once (in React, setupCampo()), so <wa-icon> draws Phosphor.',
|
|
37
|
+
setupImport: 'Import it from @awesome.me/webawesome/dist/components/<name>/<name>.js (in React, @campo-design/react/<name>).',
|
|
38
|
+
setupStylesheet: "Remove it: Campo's CSS (@campo-design/css) styles native elements.",
|
|
39
|
+
setupAutoloader: 'Import each component you use instead: Campo checks what a product imports.',
|
|
40
|
+
setupPalette: 'Remove it: Campo decides color through its tokens.',
|
|
41
|
+
setupReactImport: "Import it from @campo-design/react/<name>: its types come from Campo's contracts.",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// Blanks out code shown as text, keeping line breaks so line numbers stay
|
|
45
|
+
// right: <code> and <pre> elements, and in Markdown, fenced blocks and inline
|
|
46
|
+
// code. Docs that teach a rule ("no `!important`") quote what it forbids.
|
|
47
|
+
const blank = (match) => match.replace(/[^\n]/g, ' ');
|
|
48
|
+
export function withoutCode(text, file) {
|
|
49
|
+
let out = text.replace(/<(code|pre)\b[\s\S]*?<\/\1>/g, blank);
|
|
50
|
+
if (file.endsWith('.md')) out = out.replace(/^```[\s\S]*?^```/gm, blank).replace(/`[^`\n]*`/g, blank);
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Setup findings for a whole product, from its files as [{ file, text }].
|
|
56
|
+
* Each finding is { rule, detail, file?, line? }: a missing step has no file.
|
|
57
|
+
* Code shown as text is skipped.
|
|
58
|
+
*/
|
|
59
|
+
export function setupFindings(entries) {
|
|
60
|
+
const findings = [];
|
|
61
|
+
const code = entries.map(({ file, text }) => ({ file, text: reactAsElements(withoutCode(text, file)) }));
|
|
62
|
+
const lineOf = (text, index) => text.slice(0, index).split('\n').length;
|
|
63
|
+
for (const { file, text } of code) {
|
|
64
|
+
for (const m of text.matchAll(/webawesome\/dist\/styles\/webawesome\.css/g)) {
|
|
65
|
+
findings.push({ rule: 'setupStylesheet', detail: 'webawesome.css', file, line: lineOf(text, m.index) });
|
|
66
|
+
}
|
|
67
|
+
for (const m of text.matchAll(/webawesome\.loader(?:\.js)?|@awesome\.me\/webawesome\/dist\/webawesome\.js|(?:from\s*|import\s*\(?\s*)['"]@awesome\.me\/webawesome['"]/g)) {
|
|
68
|
+
findings.push({ rule: 'setupAutoloader', detail: m[0], file, line: lineOf(text, m.index) });
|
|
69
|
+
}
|
|
70
|
+
for (const m of text.matchAll(/(?<![\w-])wa-(?:palette|brand)-[a-z0-9-]+/g)) {
|
|
71
|
+
findings.push({ rule: 'setupPalette', detail: m[0], file, line: lineOf(text, m.index) });
|
|
72
|
+
}
|
|
73
|
+
for (const m of text.matchAll(/@awesome\.me\/webawesome\/dist\/react\/([a-z0-9-]+)/g)) {
|
|
74
|
+
findings.push({ rule: 'setupReactImport', detail: `@awesome.me/webawesome/dist/react/${m[1]}`, file, line: lineOf(text, m.index) });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const all = code.map((c) => c.text).join('\n');
|
|
79
|
+
const tags = new Map();
|
|
80
|
+
for (const { file, text } of code) {
|
|
81
|
+
for (const m of text.matchAll(/<(wa-[a-z][a-z0-9-]*)\b/g)) if (!tags.has(m[1])) tags.set(m[1], { file, line: lineOf(text, m.index) });
|
|
82
|
+
}
|
|
83
|
+
const usesWebAwesome = tags.size > 0 || /@awesome\.me\/webawesome|@campo-design\/(?:webawesome|react)/.test(all);
|
|
84
|
+
const setupCampo = /\bsetupCampo\s*\(/.test(all);
|
|
85
|
+
if (!usesWebAwesome) return findings;
|
|
86
|
+
|
|
87
|
+
if (!(/(?<![\w-])wa-theme-default(?![\w-])/.test(all) && /(?<![\w-])campo-wa(?![\w-])/.test(all))) {
|
|
88
|
+
findings.push({ rule: 'setupThemeClass', detail: 'wa-theme-default and campo-wa on <html>' });
|
|
89
|
+
}
|
|
90
|
+
if (!/@campo-design\/webawesome\/theme\.css/.test(all)) findings.push({ rule: 'setupThemeCss', detail: '@campo-design/webawesome/theme.css' });
|
|
91
|
+
if (!setupCampo && !/\bconnectColorScheme\s*\(/.test(all)) findings.push({ rule: 'setupColorScheme', detail: 'connectColorScheme()' });
|
|
92
|
+
if (!setupCampo && !/\bregisterCampoIcons\s*\(/.test(all)) findings.push({ rule: 'setupIcons', detail: 'registerCampoIcons()' });
|
|
93
|
+
const imported = new Set(
|
|
94
|
+
[...all.matchAll(/webawesome\/dist\/(?:components|react)\/([a-z0-9-]+)\/|@campo-design\/react\/([a-z0-9-]+)/g)].map((m) => `wa-${m[1] ?? m[2]}`),
|
|
95
|
+
);
|
|
96
|
+
for (const [tag, where] of tags) {
|
|
97
|
+
if (!imported.has(tag)) findings.push({ rule: 'setupImport', detail: `<${tag}>`, ...where });
|
|
98
|
+
}
|
|
99
|
+
return findings;
|
|
100
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Campo's shared Stylelint config: `extends: ['@campo-design/lint/stylelint']`.
|
|
2
|
+
//
|
|
3
|
+
// It holds Campo's rules only: design values come from semantic tokens, and
|
|
4
|
+
// a product doesn't patch Campo. Bug-catching rules (invalid properties,
|
|
5
|
+
// unknown units) are the product's own choice; add
|
|
6
|
+
// stylelint-config-recommended next to it for those.
|
|
7
|
+
//
|
|
8
|
+
// What it allows on purpose: layout values (max-width: 65ch, grid templates,
|
|
9
|
+
// breakpoints), em values, 0, %, keywords (transparent, currentColor,
|
|
10
|
+
// inherit), and a local custom property for a size two rules share.
|
|
11
|
+
import plugins from './plugin.js';
|
|
12
|
+
import { COLOR_FUNCTIONS, TOKEN_UNITS } from '../core.js';
|
|
13
|
+
|
|
14
|
+
export default {
|
|
15
|
+
plugins,
|
|
16
|
+
// A stylelint-disable must say why, after `--`: `/* stylelint-disable rule
|
|
17
|
+
// -- reason */`. A disable with no reason is how an exception becomes a habit.
|
|
18
|
+
reportDescriptionlessDisables: true,
|
|
19
|
+
rules: {
|
|
20
|
+
// Tokens only, and only the semantic tier.
|
|
21
|
+
'campo/semantic-tokens': true,
|
|
22
|
+
|
|
23
|
+
// Colors: tokens only.
|
|
24
|
+
'color-no-hex': true,
|
|
25
|
+
'color-named': 'never',
|
|
26
|
+
'function-disallowed-list': [COLOR_FUNCTIONS, { message: (name) => `Use a Campo color token, not ${name}().` }],
|
|
27
|
+
|
|
28
|
+
// Lengths and durations: tokens only, for the properties tokens exist for.
|
|
29
|
+
'declaration-property-unit-disallowed-list': [
|
|
30
|
+
TOKEN_UNITS,
|
|
31
|
+
{
|
|
32
|
+
message: (property, unit) =>
|
|
33
|
+
`Use a Campo token for ${property}, not a raw "${unit}" value. If none fits, that's a token for Campo to add.`,
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
// The same, for a raw value routed through a local custom property.
|
|
37
|
+
'campo/no-raw-through-custom-property': true,
|
|
38
|
+
'declaration-property-value-disallowed-list': [
|
|
39
|
+
{ 'font-weight': ['/^\\d+$/'] },
|
|
40
|
+
{ message: (property) => `Use a Campo token for ${property}, not a raw number.` },
|
|
41
|
+
],
|
|
42
|
+
|
|
43
|
+
// Don't patch Campo.
|
|
44
|
+
// Layer order is what lets product CSS win over Campo's; !important bypasses it.
|
|
45
|
+
'declaration-no-important': true,
|
|
46
|
+
'campo/no-wa-override': true,
|
|
47
|
+
'campo/no-color-scheme-query': true,
|
|
48
|
+
},
|
|
49
|
+
};
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Campo's Stylelint rules: the ones Stylelint's built-in rules can't express.
|
|
2
|
+
// ./index.js turns them on, with the built-in rules, as Campo's shared config.
|
|
3
|
+
//
|
|
4
|
+
// campo/semantic-tokens
|
|
5
|
+
// A var(--campo-*) that isn't a semantic token: a primitive (reaches past
|
|
6
|
+
// the tier, so a theme or redesign doesn't reach it) or a name Campo doesn't
|
|
7
|
+
// define (looks right, resolves to nothing). The message names the semantic
|
|
8
|
+
// tokens that point at a primitive.
|
|
9
|
+
//
|
|
10
|
+
// campo/no-raw-through-custom-property
|
|
11
|
+
// declaration-property-unit-disallowed-list stops `gap: 16px`, but not
|
|
12
|
+
// `--gap: 16px; gap: var(--gap)`: a custom property has no property type, so
|
|
13
|
+
// the unit rule can't see where its value ends up. This rule follows a local
|
|
14
|
+
// custom property (not --campo-* or --wa-*) declared in the same file to the
|
|
15
|
+
// properties that use it, and reports a raw unit there that the unit rule
|
|
16
|
+
// would have reported if it were written out. A local custom property for a
|
|
17
|
+
// size two rules share is still fine: it's reported only where it lands in a
|
|
18
|
+
// property tokens own, such as padding or gap. Same file only.
|
|
19
|
+
//
|
|
20
|
+
// campo/no-wa-override
|
|
21
|
+
// Declaring a --wa-* token. Campo's bridge maps every one to a Campo token;
|
|
22
|
+
// an override anywhere else bypasses Campo and breaks at the next theme
|
|
23
|
+
// change. To change how Web Awesome looks, change a Campo token.
|
|
24
|
+
//
|
|
25
|
+
// campo/no-color-scheme-query
|
|
26
|
+
// An @media (prefers-color-scheme). Campo's themes repoint the semantic
|
|
27
|
+
// tokens, so a screen built from them is already right in both; a query adds
|
|
28
|
+
// a second, unchecked version of the theme.
|
|
29
|
+
import stylelint from 'stylelint';
|
|
30
|
+
import { TOKEN_UNITS, tierProblem } from '../core.js';
|
|
31
|
+
|
|
32
|
+
const {
|
|
33
|
+
createPlugin,
|
|
34
|
+
utils: { report, ruleMessages, validateOptions },
|
|
35
|
+
} = stylelint;
|
|
36
|
+
|
|
37
|
+
const NAMESPACE = 'campo';
|
|
38
|
+
const url = (rule) => `https://github.com/jlitchfield/campo/blob/main/packages/lint/README.md#${NAMESPACE}${rule}`;
|
|
39
|
+
const on = (actual) => (result, ruleName) => validateOptions(result, ruleName, { actual, possible: [true] });
|
|
40
|
+
|
|
41
|
+
// ---- campo/semantic-tokens ---------------------------------------------------
|
|
42
|
+
|
|
43
|
+
const semanticTokens = (() => {
|
|
44
|
+
const ruleName = `${NAMESPACE}/semantic-tokens`;
|
|
45
|
+
const messages = ruleMessages(ruleName, { rejected: (problem) => problem });
|
|
46
|
+
const rule = (primary) => (root, result) => {
|
|
47
|
+
if (!on(primary)(result, ruleName)) return;
|
|
48
|
+
const check = (node, value) => {
|
|
49
|
+
for (const m of value.matchAll(/var\(\s*(--campo-[a-z0-9-]+)/g)) {
|
|
50
|
+
const problem = tierProblem(m[1]);
|
|
51
|
+
if (problem) report({ ruleName, result, node, message: messages.rejected(problem), word: m[1] });
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
root.walkDecls((decl) => check(decl, decl.value));
|
|
55
|
+
root.walkAtRules((atRule) => check(atRule, atRule.params));
|
|
56
|
+
};
|
|
57
|
+
rule.ruleName = ruleName;
|
|
58
|
+
rule.messages = messages;
|
|
59
|
+
rule.meta = { url: url('semantic-tokens') };
|
|
60
|
+
return createPlugin(ruleName, rule);
|
|
61
|
+
})();
|
|
62
|
+
|
|
63
|
+
// ---- campo/no-raw-through-custom-property ------------------------------------
|
|
64
|
+
|
|
65
|
+
const rawThroughCustomProperty = (() => {
|
|
66
|
+
const ruleName = `${NAMESPACE}/no-raw-through-custom-property`;
|
|
67
|
+
const messages = ruleMessages(ruleName, {
|
|
68
|
+
rejected: (property, name, unit) =>
|
|
69
|
+
`Use a Campo token for ${property}: ${name} holds a raw "${unit}" value. If none fits, that's a token for Campo to add.`,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// A number with a unit, not inside a var() name: 16px, .5rem, 200ms.
|
|
73
|
+
const RAW = /(?<![\w-])-?(?:\d*\.)?\d+(px|rem|em|ms|s)\b/g;
|
|
74
|
+
const VAR = /var\(\s*(--[\w-]+)/g;
|
|
75
|
+
|
|
76
|
+
/** Turns the unit rule's { "/regex/" | "name": units } into matchers. */
|
|
77
|
+
const matchers = (byProperty) =>
|
|
78
|
+
Object.entries(byProperty).map(([key, units]) => {
|
|
79
|
+
const test = key.startsWith('/') && key.endsWith('/') ? new RegExp(key.slice(1, -1)) : null;
|
|
80
|
+
return { matches: (prop) => (test ? test.test(prop) : prop === key), units };
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// `true` uses Campo's TOKEN_UNITS; an object replaces them.
|
|
84
|
+
const rule = (primary) => (root, result) => {
|
|
85
|
+
const valid = validateOptions(result, ruleName, { actual: primary, possible: (v) => v === true || (v && typeof v === 'object') });
|
|
86
|
+
if (!valid) return;
|
|
87
|
+
const governed = matchers(primary === true ? TOKEN_UNITS : primary);
|
|
88
|
+
|
|
89
|
+
// Local custom properties with raw units in their value, and those units.
|
|
90
|
+
const raw = new Map();
|
|
91
|
+
root.walkDecls(/^--/, (decl) => {
|
|
92
|
+
if (/^--(campo|wa)-/.test(decl.prop)) return;
|
|
93
|
+
const units = [...decl.value.matchAll(RAW)].map((m) => m[1]);
|
|
94
|
+
if (units.length) raw.set(decl.prop, new Set([...(raw.get(decl.prop) ?? []), ...units]));
|
|
95
|
+
});
|
|
96
|
+
if (!raw.size) return;
|
|
97
|
+
|
|
98
|
+
root.walkDecls((decl) => {
|
|
99
|
+
if (decl.prop.startsWith('--')) return;
|
|
100
|
+
const prop = decl.prop.toLowerCase();
|
|
101
|
+
const disallowed = governed.filter((g) => g.matches(prop)).flatMap((g) => g.units);
|
|
102
|
+
if (!disallowed.length) return;
|
|
103
|
+
for (const [, name] of decl.value.matchAll(VAR)) {
|
|
104
|
+
const unit = [...(raw.get(name) ?? [])].find((u) => disallowed.includes(u));
|
|
105
|
+
if (unit) report({ ruleName, result, node: decl, message: messages.rejected(prop, name, unit), word: name });
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
};
|
|
109
|
+
rule.ruleName = ruleName;
|
|
110
|
+
rule.messages = messages;
|
|
111
|
+
rule.meta = { url: url('no-raw-through-custom-property') };
|
|
112
|
+
return createPlugin(ruleName, rule);
|
|
113
|
+
})();
|
|
114
|
+
|
|
115
|
+
// ---- campo/no-wa-override ----------------------------------------------------
|
|
116
|
+
|
|
117
|
+
const noWaOverride = (() => {
|
|
118
|
+
const ruleName = `${NAMESPACE}/no-wa-override`;
|
|
119
|
+
const messages = ruleMessages(ruleName, {
|
|
120
|
+
rejected: (prop) =>
|
|
121
|
+
`Don't declare ${prop}: 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.`,
|
|
122
|
+
});
|
|
123
|
+
const rule = (primary) => (root, result) => {
|
|
124
|
+
if (!on(primary)(result, ruleName)) return;
|
|
125
|
+
root.walkDecls(/^--wa-/, (decl) => report({ ruleName, result, node: decl, message: messages.rejected(decl.prop), word: decl.prop }));
|
|
126
|
+
};
|
|
127
|
+
rule.ruleName = ruleName;
|
|
128
|
+
rule.messages = messages;
|
|
129
|
+
rule.meta = { url: url('no-wa-override') };
|
|
130
|
+
return createPlugin(ruleName, rule);
|
|
131
|
+
})();
|
|
132
|
+
|
|
133
|
+
// ---- campo/no-color-scheme-query ---------------------------------------------
|
|
134
|
+
|
|
135
|
+
const noColorSchemeQuery = (() => {
|
|
136
|
+
const ruleName = `${NAMESPACE}/no-color-scheme-query`;
|
|
137
|
+
const messages = ruleMessages(ruleName, {
|
|
138
|
+
rejected: () =>
|
|
139
|
+
"Don't handle dark mode yourself: Campo's themes repoint the semantic tokens, so styles built from them are right in both.",
|
|
140
|
+
});
|
|
141
|
+
const rule = (primary) => (root, result) => {
|
|
142
|
+
if (!on(primary)(result, ruleName)) return;
|
|
143
|
+
root.walkAtRules((atRule) => {
|
|
144
|
+
if (/prefers-color-scheme/i.test(atRule.params)) report({ ruleName, result, node: atRule, message: messages.rejected(), word: 'prefers-color-scheme' });
|
|
145
|
+
});
|
|
146
|
+
};
|
|
147
|
+
rule.ruleName = ruleName;
|
|
148
|
+
rule.messages = messages;
|
|
149
|
+
rule.meta = { url: url('no-color-scheme-query') };
|
|
150
|
+
return createPlugin(ruleName, rule);
|
|
151
|
+
})();
|
|
152
|
+
|
|
153
|
+
export default [semanticTokens, rawThroughCustomProperty, noWaOverride, noColorSchemeQuery];
|