c3b-lint 0.0.8 → 0.0.10
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/README.md +4 -2
- package/c3b-lint.js +32 -8
- package/check/css-tokens.js +3 -2
- package/check/hook.js +4 -1
- package/check/html.js +3 -3
- package/check/report.js +1 -1
- package/config/editorconfig +3 -0
- package/config/eslint.config.js +36 -15
- package/config/stylelint.config.js +11 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,7 +15,8 @@ markdown that a tool can check, with one command and one config to install. It r
|
|
|
15
15
|
rules (that is a token by definition), and a token nothing uses (CSS `var()`, or JS/HTML by name).
|
|
16
16
|
4. **HTML** on every page under `src/` (partials are skipped): doctype, `lang`, charset, viewport without
|
|
17
17
|
`user-scalable=no`, the required head set (title, description, absolute canonical, `og:*` with an absolute
|
|
18
|
-
`og:image`, `twitter:card`, one SVG favicon; a `noindex` page is excused the canonical and social tags), no external
|
|
18
|
+
`og:image`, `twitter:card`, one SVG favicon; a `noindex` page is excused the canonical and social tags), no external
|
|
19
|
+
stylesheets (webfonts are self-hosted), stylesheet order (`reset`, `tokens`, `main`, …, page file last),
|
|
19
20
|
the skip-link rule (`<a id="skip-link" href="#main">` first in `<body>` when there is a header or nav, absent
|
|
20
21
|
otherwise, `<main id="main">`), one `<h1>`, `alt` on every image, no inline handlers, no `<style>` or `style=""`
|
|
21
22
|
(except a JS-driven custom property), relative paths only, kebab-case ids and classes, no duplicate ids,
|
|
@@ -46,7 +47,8 @@ npx c3b-lint --init
|
|
|
46
47
|
|
|
47
48
|
- `.editorconfig` — the shared one (a copy; editors need a real file).
|
|
48
49
|
- `eslint.config.js` and `stylelint.config.js` — one line each, re-exporting this package's configs, so WebStorm
|
|
49
|
-
shows the same errors inline as the CLI reports. Bumping `c3b-lint` changes the rules everywhere.
|
|
50
|
+
shows the same errors inline as the CLI reports. Bumping `c3b-lint` changes the rules everywhere. Written only
|
|
51
|
+
when missing: a project that has extended its config keeps it.
|
|
50
52
|
- `.githooks/pre-commit`, and `git config core.hooksPath .githooks` — the lint runs before every commit, so a
|
|
51
53
|
commit that fails it never lands, whoever makes it. The hook lives in the repo, so after a clone one `--init`
|
|
52
54
|
(or `npx c3b-lint --init` in `postinstall`) wires it up. If `node_modules` is missing it says so and lets the
|
package/c3b-lint.js
CHANGED
|
@@ -37,6 +37,7 @@ function configFor(file) {
|
|
|
37
37
|
|
|
38
38
|
return join(CONFIG_DIR, file);
|
|
39
39
|
}
|
|
40
|
+
|
|
40
41
|
const MAX_LINES = 500;
|
|
41
42
|
|
|
42
43
|
// Files --init writes at the project root. The .editorconfig is a copy (it has to be a real
|
|
@@ -44,14 +45,14 @@ const MAX_LINES = 500;
|
|
|
44
45
|
// c3b-lint changes the rules everywhere without touching projects.
|
|
45
46
|
const BASELINE = {
|
|
46
47
|
'.editorconfig': readFileSync(join(CONFIG_DIR, 'editorconfig'), 'utf8'),
|
|
47
|
-
'eslint.config.js':
|
|
48
|
-
'stylelint.config.js':
|
|
48
|
+
'eslint.config.js': 'export { default } from \'c3b-lint/config/eslint.config.js\';\n',
|
|
49
|
+
'stylelint.config.js': 'export { default } from \'c3b-lint/config/stylelint.config.js\';\n'
|
|
49
50
|
};
|
|
50
51
|
|
|
51
52
|
const FLAGS = {
|
|
52
53
|
'--fix': 'apply the ESLint and Stylelint fixes that are safe to apply automatically',
|
|
53
54
|
'--init': 'write .editorconfig, eslint.config.js, stylelint.config.js and the pre-commit hook',
|
|
54
|
-
'--help': 'print this help'
|
|
55
|
+
'--help': 'print this help'
|
|
55
56
|
};
|
|
56
57
|
|
|
57
58
|
function usage() {
|
|
@@ -96,6 +97,11 @@ function writeBaseline() {
|
|
|
96
97
|
if (existsSync(path) && readFileSync(path, 'utf8') === content) {
|
|
97
98
|
continue;
|
|
98
99
|
}
|
|
100
|
+
// a lint config the project has extended (c10g's librarySrc, colab-client's exceptions) is its own:
|
|
101
|
+
// only the .editorconfig is kept in step with the shared one
|
|
102
|
+
if (file.endsWith('.js') && existsSync(path)) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
99
105
|
// a project keeping a .mjs config (not "type": "module") has made its choice
|
|
100
106
|
if (file.endsWith('.js') && existsSync(path.replace(/\.js$/, '.mjs'))) {
|
|
101
107
|
continue;
|
|
@@ -177,7 +183,7 @@ async function runStylelint() {
|
|
|
177
183
|
config,
|
|
178
184
|
configBasedir: process.cwd(),
|
|
179
185
|
fix,
|
|
180
|
-
formatter: 'string'
|
|
186
|
+
formatter: 'string'
|
|
181
187
|
});
|
|
182
188
|
const output = result.report ?? result.output;
|
|
183
189
|
if (output.trim()) {
|
|
@@ -199,11 +205,29 @@ async function main() {
|
|
|
199
205
|
return;
|
|
200
206
|
}
|
|
201
207
|
|
|
208
|
+
const started = performance.now();
|
|
209
|
+
const timings = [];
|
|
210
|
+
// Runs one check and records how long it took, so a slow run says where the time went
|
|
211
|
+
const timed = async (name, check) => {
|
|
212
|
+
const before = performance.now();
|
|
213
|
+
const result = await check();
|
|
214
|
+
timings.push(` ${ name.padEnd(13) }${ Math.round(performance.now() - before) }ms`);
|
|
215
|
+
|
|
216
|
+
return result;
|
|
217
|
+
};
|
|
218
|
+
|
|
202
219
|
const report = createReport();
|
|
203
|
-
checkStructure(report);
|
|
204
|
-
checkHtml(report);
|
|
205
|
-
checkCssTokens(report);
|
|
206
|
-
|
|
220
|
+
await timed('structure', () => checkStructure(report));
|
|
221
|
+
await timed('html', () => checkHtml(report));
|
|
222
|
+
await timed('tokens', () => checkCssTokens(report));
|
|
223
|
+
let errors = report.errors;
|
|
224
|
+
errors += await timed('editorconfig', () => checkEditorconfig());
|
|
225
|
+
errors += await timed('lengths', () => checkFileLengths());
|
|
226
|
+
errors += await timed('eslint', () => runEslint());
|
|
227
|
+
errors += await timed('stylelint', () => runStylelint());
|
|
228
|
+
|
|
229
|
+
console.log(timings.join('\n'));
|
|
230
|
+
console.log(` ${ 'total'.padEnd(13) }${ Math.round(performance.now() - started) }ms`);
|
|
207
231
|
if (errors > 0) {
|
|
208
232
|
fail(`${ errors } error(s)`);
|
|
209
233
|
}
|
package/check/css-tokens.js
CHANGED
|
@@ -89,8 +89,9 @@ export function checkCssTokens(report) {
|
|
|
89
89
|
// spacing property (a 24px icon is not --spacing-lg, a 16px font size is not --spacing-md)
|
|
90
90
|
const exact = normalise(value);
|
|
91
91
|
const lengthLike = /^-?\d/.test(exact);
|
|
92
|
-
// zero and
|
|
93
|
-
|
|
92
|
+
// zero, keywords and a var() reference are not "the token's value" even when a token (an alias
|
|
93
|
+
// like --color-icon-border: var(--color-border)) happens to hold them
|
|
94
|
+
const trivial = /^(0|0px|none|auto|inherit|initial|transparent|currentcolor)$/.test(exact) || /var\(/.test(exact);
|
|
94
95
|
if (byValue.has(exact) && !trivial && !decl.prop.startsWith('--') && (!lengthLike || SPACING_PROPERTIES.test(decl.prop))) {
|
|
95
96
|
report.error(`${ file }:${ where.line }`, `${ decl.value } duplicates ${ byValue.get(exact) } — use var(${ byValue.get(exact) })`);
|
|
96
97
|
}
|
package/check/hook.js
CHANGED
|
@@ -26,7 +26,10 @@ export function installHook(log) {
|
|
|
26
26
|
|
|
27
27
|
if (existsSync('.git')) {
|
|
28
28
|
try {
|
|
29
|
-
const current = execFileSync('git', ['config', '--get', 'core.hooksPath'], {
|
|
29
|
+
const current = execFileSync('git', ['config', '--get', 'core.hooksPath'], {
|
|
30
|
+
encoding: 'utf8',
|
|
31
|
+
stdio: 'pipe'
|
|
32
|
+
}).trim();
|
|
30
33
|
if (current === '.githooks') {
|
|
31
34
|
return;
|
|
32
35
|
}
|
package/check/html.js
CHANGED
|
@@ -18,7 +18,7 @@ const REQUIRED_META = [
|
|
|
18
18
|
['og:url', (el) => el.name === 'meta' && attr(el, 'property') === 'og:url'],
|
|
19
19
|
['og:image', (el) => el.name === 'meta' && attr(el, 'property') === 'og:image'],
|
|
20
20
|
['twitter:card', (el) => el.name === 'meta' && attr(el, 'name') === 'twitter:card'],
|
|
21
|
-
['favicon link[rel=icon]', (el) => el.name === 'link' && attr(el, 'rel') === 'icon']
|
|
21
|
+
['favicon link[rel=icon]', (el) => el.name === 'link' && attr(el, 'rel') === 'icon']
|
|
22
22
|
];
|
|
23
23
|
|
|
24
24
|
const toPosix = (p) => p.split(sep).join('/');
|
|
@@ -44,7 +44,7 @@ const children = (el) => (el.childNodes ?? []).filter(isElement);
|
|
|
44
44
|
|
|
45
45
|
export function checkHtml(report) {
|
|
46
46
|
const files = globSync('src/**/*.html').map(toPosix)
|
|
47
|
-
|
|
47
|
+
.filter((f) => !f.startsWith('src/partial/') && !f.includes('/vendor/')).sort();
|
|
48
48
|
for (const file of files) {
|
|
49
49
|
checkPage(report, file, readFileSync(file, 'utf8'));
|
|
50
50
|
}
|
|
@@ -112,7 +112,7 @@ function checkPage(report, file, source) {
|
|
|
112
112
|
|
|
113
113
|
// --- stylesheet link order: reset, tokens, main, components, page file last
|
|
114
114
|
const allSheets = headEls.filter((el) => el.name === 'link' && attr(el, 'rel') === 'stylesheet')
|
|
115
|
-
|
|
115
|
+
.map((el) => ({ el, href: attr(el, 'href') ?? '' }));
|
|
116
116
|
// frontend.md: webfonts are self-hosted woff2 in src/asset/font/; no stylesheet comes from another origin
|
|
117
117
|
for (const sheet of allSheets.filter((s) => /^(https?:)?\/\//.test(s.href))) {
|
|
118
118
|
report.error(at(sheet.el), `external stylesheet ${ sheet.href } — self-host it (fonts as woff2 in src/asset/font/)`);
|
package/check/report.js
CHANGED
package/config/editorconfig
CHANGED
|
@@ -31,6 +31,9 @@ ij_javascript_align_multiline_parameters = false
|
|
|
31
31
|
ij_javascript_align_multiline_chained_methods = false
|
|
32
32
|
ij_javascript_blank_lines_after_imports = 1
|
|
33
33
|
ij_javascript_keep_blank_lines_in_code = 1
|
|
34
|
+
ij_javascript_enforce_trailing_comma = remove
|
|
35
|
+
ij_javascript_spaces_within_array_initializer_brackets = false
|
|
36
|
+
ij_javascript_space_before_function_left_parenth = false
|
|
34
37
|
|
|
35
38
|
[*.java]
|
|
36
39
|
ij_java_if_brace_force = always
|
package/config/eslint.config.js
CHANGED
|
@@ -23,6 +23,27 @@ const shared = {
|
|
|
23
23
|
'padding-line-between-statements': ['error', { blankLine: 'always', prev: '*', next: 'return' }],
|
|
24
24
|
// an unused catch binding is fine: `catch (error) { // reason }` is how we say the failure is expected
|
|
25
25
|
'no-unused-vars': ['error', { caughtErrors: 'none' }],
|
|
26
|
+
|
|
27
|
+
// Formatting, matching the shared .editorconfig so the CLI enforces what WebStorm formats, and
|
|
28
|
+
// --fix can do it without an IDE. (ESLint 9 still ships these rules deprecated; when it drops
|
|
29
|
+
// them in 10, they move to @stylistic/eslint-plugin under the same names.)
|
|
30
|
+
// MemberExpression: 2 is the editorconfig's continuation indent of 8 for a chained call on its own line
|
|
31
|
+
'indent': ['error', 4, { SwitchCase: 1, MemberExpression: 2 }],
|
|
32
|
+
'quotes': ['error', 'single', { avoidEscape: true, allowTemplateLiterals: false }],
|
|
33
|
+
'semi': ['error', 'always'],
|
|
34
|
+
'template-curly-spacing': ['error', 'always'],
|
|
35
|
+
'object-curly-spacing': ['error', 'always'],
|
|
36
|
+
'array-bracket-spacing': ['error', 'never'],
|
|
37
|
+
'comma-dangle': ['error', 'never'],
|
|
38
|
+
'arrow-parens': ['error', 'always'],
|
|
39
|
+
'space-before-function-paren': ['error', { anonymous: 'always', named: 'never', asyncArrow: 'always' }],
|
|
40
|
+
'keyword-spacing': 'error',
|
|
41
|
+
'space-infix-ops': 'error',
|
|
42
|
+
'comma-spacing': 'error',
|
|
43
|
+
'key-spacing': 'error',
|
|
44
|
+
'no-trailing-spaces': 'error',
|
|
45
|
+
'no-multiple-empty-lines': ['error', { max: 1, maxEOF: 0 }],
|
|
46
|
+
'eol-last': 'error'
|
|
26
47
|
};
|
|
27
48
|
|
|
28
49
|
export const browser = {
|
|
@@ -30,7 +51,7 @@ export const browser = {
|
|
|
30
51
|
languageOptions: {
|
|
31
52
|
ecmaVersion: 2024,
|
|
32
53
|
sourceType: 'script',
|
|
33
|
-
globals: { ...globals.browser, HTMLCanvasElement: 'readonly', App: 'writable', ColabClient: 'readonly' }
|
|
54
|
+
globals: { ...globals.browser, HTMLCanvasElement: 'readonly', App: 'writable', ColabClient: 'readonly' }
|
|
34
55
|
},
|
|
35
56
|
rules: {
|
|
36
57
|
...js.configs.recommended.rules,
|
|
@@ -41,26 +62,26 @@ export const browser = {
|
|
|
41
62
|
'error',
|
|
42
63
|
{
|
|
43
64
|
selector: 'Program > VariableDeclaration',
|
|
44
|
-
message: 'Classic scripts share one global scope: wrap the file in an IIFE and attach its API to window.App.'
|
|
65
|
+
message: 'Classic scripts share one global scope: wrap the file in an IIFE and attach its API to window.App.'
|
|
45
66
|
},
|
|
46
67
|
{
|
|
47
68
|
selector: 'Program > FunctionDeclaration',
|
|
48
|
-
message: 'Classic scripts share one global scope: wrap the file in an IIFE and attach its API to window.App.'
|
|
49
|
-
}
|
|
69
|
+
message: 'Classic scripts share one global scope: wrap the file in an IIFE and attach its API to window.App.'
|
|
70
|
+
}
|
|
50
71
|
],
|
|
51
72
|
'no-restricted-properties': [
|
|
52
73
|
'warn',
|
|
53
74
|
{
|
|
54
75
|
object: 'document',
|
|
55
76
|
property: 'getElementsByClassName',
|
|
56
|
-
message: 'Use getElementById for the landmark, then querySelectorAll scoped off it.'
|
|
57
|
-
}
|
|
77
|
+
message: 'Use getElementById for the landmark, then querySelectorAll scoped off it.'
|
|
78
|
+
}
|
|
58
79
|
],
|
|
59
80
|
'no-restricted-globals': [
|
|
60
81
|
'warn',
|
|
61
|
-
{ name: 'setInterval', message: 'Use IntersectionObserver or requestAnimationFrame, not polling.' }
|
|
62
|
-
]
|
|
63
|
-
}
|
|
82
|
+
{ name: 'setInterval', message: 'Use IntersectionObserver or requestAnimationFrame, not polling.' }
|
|
83
|
+
]
|
|
84
|
+
}
|
|
64
85
|
};
|
|
65
86
|
|
|
66
87
|
export const node = {
|
|
@@ -69,12 +90,12 @@ export const node = {
|
|
|
69
90
|
languageOptions: {
|
|
70
91
|
ecmaVersion: 2024,
|
|
71
92
|
sourceType: 'module',
|
|
72
|
-
globals: globals.node
|
|
93
|
+
globals: globals.node
|
|
73
94
|
},
|
|
74
95
|
rules: {
|
|
75
96
|
...js.configs.recommended.rules,
|
|
76
|
-
...shared
|
|
77
|
-
}
|
|
97
|
+
...shared
|
|
98
|
+
}
|
|
78
99
|
};
|
|
79
100
|
|
|
80
101
|
// For a Node library that keeps its modules in src/ (c10g): the Node rules there instead of the
|
|
@@ -89,12 +110,12 @@ export const librarySrc = {
|
|
|
89
110
|
'no-console': 'off',
|
|
90
111
|
'no-restricted-syntax': 'off',
|
|
91
112
|
'no-restricted-properties': 'off',
|
|
92
|
-
'no-restricted-globals': 'off'
|
|
93
|
-
}
|
|
113
|
+
'no-restricted-globals': 'off'
|
|
114
|
+
}
|
|
94
115
|
};
|
|
95
116
|
|
|
96
117
|
export default [
|
|
97
118
|
{ ignores: ['node_modules/**', 'dist/**', 'c10g/**', 'src/**/vendor/**', '**/*.min.js'] },
|
|
98
119
|
browser,
|
|
99
|
-
node
|
|
120
|
+
node
|
|
100
121
|
];
|
|
@@ -23,7 +23,7 @@ export default {
|
|
|
23
23
|
'selector-id-pattern': [KEBAB, { message: 'ids are kebab-case' }],
|
|
24
24
|
'selector-class-pattern': [CLASS, {
|
|
25
25
|
resolveNestedSelectors: true,
|
|
26
|
-
message: 'classes are kebab-case; state classes are is-*; a variant of a shared class uses --'
|
|
26
|
+
message: 'classes are kebab-case; state classes are is-*; a variant of a shared class uses --'
|
|
27
27
|
}],
|
|
28
28
|
// custom properties are kebab-case everywhere; the stricter token shape applies in tokens.css below
|
|
29
29
|
'custom-property-pattern': ['^[a-z0-9]+(-[a-z0-9]+)*$', { message: 'custom properties are kebab-case' }],
|
|
@@ -33,33 +33,33 @@ export default {
|
|
|
33
33
|
'unit-disallowed-list': [['dvh'], { message: 'dvh follows the phone toolbar; use vh, or svh for bottom-anchored content' }],
|
|
34
34
|
'declaration-property-unit-allowed-list': {
|
|
35
35
|
'font-size': ['rem', 'vw', '%'],
|
|
36
|
-
'letter-spacing': ['em']
|
|
36
|
+
'letter-spacing': ['em']
|
|
37
37
|
},
|
|
38
38
|
// frontend.md: don't style off data attributes; they exist for JS
|
|
39
39
|
'selector-attribute-name-disallowed-list': [['/^data-/'], {
|
|
40
40
|
severity: 'warning',
|
|
41
|
-
message: 'data-* attributes are for JS; style with an id, class or structural selector'
|
|
42
|
-
}]
|
|
41
|
+
message: 'data-* attributes are for JS; style with an id, class or structural selector'
|
|
42
|
+
}]
|
|
43
43
|
},
|
|
44
44
|
// Override globs are relative to the project root: c3b-lint sets configBasedir to it.
|
|
45
45
|
overrides: [
|
|
46
46
|
{
|
|
47
47
|
files: ['src/style/tokens.css', 'src/style/reset.css'],
|
|
48
|
-
rules: { 'selector-disallowed-list': null }
|
|
48
|
+
rules: { 'selector-disallowed-list': null }
|
|
49
49
|
},
|
|
50
50
|
{
|
|
51
51
|
// a theme switch (:root[data-theme="light"] { --color-bg: … }) is the one place styling
|
|
52
52
|
// hangs off a data attribute: JS sets it, and only tokens change
|
|
53
53
|
files: ['src/style/tokens.css'],
|
|
54
|
-
rules: { 'selector-attribute-name-disallowed-list': null }
|
|
54
|
+
rules: { 'selector-attribute-name-disallowed-list': null }
|
|
55
55
|
},
|
|
56
56
|
{
|
|
57
57
|
files: ['src/style/tokens.css'],
|
|
58
58
|
rules: {
|
|
59
59
|
'custom-property-pattern': ['^[a-z][a-z0-9]*-[a-z0-9]+(-[a-z0-9]+)*$', {
|
|
60
|
-
message: 'tokens are --{category}-{role}[-{variant}]: --color-text-muted, not --fg'
|
|
61
|
-
}]
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
]
|
|
60
|
+
message: 'tokens are --{category}-{role}[-{variant}]: --color-text-muted, not --fg'
|
|
61
|
+
}]
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
]
|
|
65
65
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "c3b-lint",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.10",
|
|
4
4
|
"description": "Lints a co:lab project against the rules a tool can check: ESLint, Stylelint, the 500-line limit and the shared .editorconfig; --init writes the config files.",
|
|
5
5
|
"license": "GPL-3.0",
|
|
6
6
|
"type": "module",
|