c3b-lint 0.0.10 → 0.0.12
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 +22 -1
- package/c3b-lint.js +3 -1
- package/check/css-tokens.js +3 -1
- package/check/html.js +3 -1
- package/check/structure.js +16 -23
- package/config/eslint.config.js +20 -4
- package/config/project-settings.js +38 -0
- package/config/stylelint.config.js +3 -0
- package/package.json +8 -8
package/README.md
CHANGED
|
@@ -5,7 +5,8 @@ markdown that a tool can check, with one command and one config to install. It r
|
|
|
5
5
|
|
|
6
6
|
1. **ESLint** on every `.js` file — classic browser scripts under `src/` (IIFE required, no top-level declarations,
|
|
7
7
|
no `console.log`), ES modules everywhere else — with `eslint:recommended` plus the rules from `frontend.md`:
|
|
8
|
-
braces on every `if`, `const`/`let` never `var`, `===`, a blank line before `return`, 500 lines max
|
|
8
|
+
braces on every `if`, `const`/`let` never `var`, `===`, a blank line before `return`, 500 lines max, and a
|
|
9
|
+
`replace()`/`replaceAll()` whose replacement comes from data must pass a function (a string one expands `$&`).
|
|
9
10
|
2. **Stylelint** on `src/**/*.css` with `stylelint-config-recommended` plus the rules from `frontend.md`: no
|
|
10
11
|
`!important`, max 3 compound selectors, kebab-case ids/classes with `is-*` states and `--` variants, `:root` only
|
|
11
12
|
in `tokens.css` (and `reset.css`), token names `--{category}-{role}`, `rem` font sizes, `em` letter-spacing,
|
|
@@ -65,6 +66,26 @@ Then in `package.json`:
|
|
|
65
66
|
`postinstall` keeps the four files current and wires the hook on every clone with no extra step (`--init` only
|
|
66
67
|
writes, it never fails an install); `pretest` makes a lint error fail `npm test` and anything that runs it.
|
|
67
68
|
|
|
69
|
+
## Project settings
|
|
70
|
+
|
|
71
|
+
A project's own settings go in its `package.json`, under `"c3b-lint"`:
|
|
72
|
+
|
|
73
|
+
```json
|
|
74
|
+
"c3b-lint": {
|
|
75
|
+
"ignore": ["src/experiment/**"],
|
|
76
|
+
"globals": ["THREE", "gsap", "ScrollTrigger"],
|
|
77
|
+
"singularDirectories": ["mannequin-films"]
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
- `ignore` — globs, from the project root, that no check looks at: ESLint, Stylelint and every check c3b-lint runs
|
|
82
|
+
itself (HTML, structure, tokens, file lengths). For code that is not the site's own, like standalone experiments.
|
|
83
|
+
- `globals` — the names a page's CDN `<script>` tags define (`THREE`, `gsap`, `Lenis`, …). ESLint lints each file on
|
|
84
|
+
its own and never reads the HTML, so without them every use is "not defined". Read-only, `src/` scripts only.
|
|
85
|
+
- `singularDirectories` — plural-looking directory names that are proper names (a client's brand).
|
|
86
|
+
|
|
87
|
+
The ESLint and Stylelint configs read these too, so WebStorm reports what the CLI does.
|
|
88
|
+
|
|
68
89
|
## Usage
|
|
69
90
|
|
|
70
91
|
```
|
package/c3b-lint.js
CHANGED
|
@@ -9,6 +9,7 @@ import { checkCssTokens } from './check/css-tokens.js';
|
|
|
9
9
|
import { checkHtml } from './check/html.js';
|
|
10
10
|
import { checkStructure } from './check/structure.js';
|
|
11
11
|
import { installHook } from './check/hook.js';
|
|
12
|
+
import { ignoredPaths } from './config/project-settings.js';
|
|
12
13
|
|
|
13
14
|
// Printed first so it's obvious which installed version is running.
|
|
14
15
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
@@ -133,7 +134,8 @@ function checkEditorconfig() {
|
|
|
133
134
|
// general.md: no hand-written file past 500 lines. ESLint reports JS; this covers CSS and HTML.
|
|
134
135
|
function checkFileLengths() {
|
|
135
136
|
let errors = 0;
|
|
136
|
-
const
|
|
137
|
+
const ignored = ignoredPaths();
|
|
138
|
+
const files = globSync('src/**/*.{css,html}').map(toPosix).filter((file) => !file.includes('/vendor/') && !ignored.has(file));
|
|
137
139
|
for (const file of files.sort()) {
|
|
138
140
|
// inline <svg> is vector art, not hand-written markup: its lines are not counted
|
|
139
141
|
const content = readFileSync(file, 'utf8').replace(/<svg[\s\S]*?<\/svg>/g, '<svg/>');
|
package/check/css-tokens.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { globSync, readFileSync } from 'fs';
|
|
2
2
|
import { sep } from 'path';
|
|
3
3
|
import postcss from 'postcss';
|
|
4
|
+
import { ignoredPaths } from '../config/project-settings.js';
|
|
4
5
|
|
|
5
6
|
// Token hygiene, from frontend.md's CSS Tokens section:
|
|
6
7
|
// - a literal that duplicates a token's value is a bug: use the token
|
|
@@ -52,7 +53,8 @@ function readTokens() {
|
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
export function checkCssTokens(report) {
|
|
55
|
-
const
|
|
56
|
+
const ignored = ignoredPaths();
|
|
57
|
+
const files = globSync('src/**/*.css').map(toPosix).filter((f) => !f.includes('/vendor/') && !ignored.has(f)).sort();
|
|
56
58
|
if (!files.includes(TOKENS)) {
|
|
57
59
|
return;
|
|
58
60
|
}
|
package/check/html.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { globSync, readFileSync } from 'fs';
|
|
2
2
|
import { basename, sep } from 'path';
|
|
3
3
|
import { parse } from 'parse5';
|
|
4
|
+
import { ignoredPaths } from '../config/project-settings.js';
|
|
4
5
|
|
|
5
6
|
// The HTML rules from frontend.md that a parser can check, on every page under src/ (partials are
|
|
6
7
|
// fragments and are skipped). c10g checks the served page in a browser; this runs in milliseconds
|
|
@@ -43,8 +44,9 @@ const find = (root, name) => [...elements(root)].find((el) => el.tagName === nam
|
|
|
43
44
|
const children = (el) => (el.childNodes ?? []).filter(isElement);
|
|
44
45
|
|
|
45
46
|
export function checkHtml(report) {
|
|
47
|
+
const ignored = ignoredPaths();
|
|
46
48
|
const files = globSync('src/**/*.html').map(toPosix)
|
|
47
|
-
.filter((f) => !f.startsWith('src/partial/') && !f.includes('/vendor/')).sort();
|
|
49
|
+
.filter((f) => !f.startsWith('src/partial/') && !f.includes('/vendor/') && !ignored.has(f)).sort();
|
|
48
50
|
for (const file of files) {
|
|
49
51
|
checkPage(report, file, readFileSync(file, 'utf8'));
|
|
50
52
|
}
|
package/check/structure.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { existsSync, globSync, readdirSync,
|
|
1
|
+
import { existsSync, globSync, readdirSync, statSync } from 'fs';
|
|
2
2
|
import { basename, dirname, sep } from 'path';
|
|
3
|
+
import { ignoredPaths, readSettings } from '../config/project-settings.js';
|
|
3
4
|
|
|
4
5
|
// The project-structure rules from general.md and frontend.md: singular directory names, kebab-case
|
|
5
6
|
// asset names, and where CSS, JS and fonts live under src/. Pure filesystem walks.
|
|
@@ -15,34 +16,26 @@ const CSS_HOMES_EXTRA = [/^src\/experiment\//];
|
|
|
15
16
|
|
|
16
17
|
const toPosix = (p) => p.split(sep).join('/');
|
|
17
18
|
|
|
18
|
-
// A project names the plural-looking directories that are proper names (a client's brand, an
|
|
19
|
-
// experiment's title) in package.json: "c3b-lint": { "singularDirectories": ["mannequin-films"] }
|
|
20
|
-
function readAllowedDirectories() {
|
|
21
|
-
try {
|
|
22
|
-
const pkg = JSON.parse(readFileSync('package.json', 'utf8'));
|
|
23
|
-
|
|
24
|
-
return new Set(pkg['c3b-lint']?.singularDirectories ?? []);
|
|
25
|
-
} catch (error) {
|
|
26
|
-
return new Set();
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
19
|
export function checkStructure(report) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
20
|
+
// A project names the plural-looking directories that are proper names (a client's brand, an
|
|
21
|
+
// experiment's title) in package.json: "c3b-lint": { "singularDirectories": ["mannequin-films"] }
|
|
22
|
+
const allowed = new Set(readSettings().singularDirectories);
|
|
23
|
+
const ignored = ignoredPaths();
|
|
24
|
+
checkDirectoryNames(report, '.', allowed, ignored);
|
|
25
|
+
checkAssetNames(report, ignored);
|
|
26
|
+
checkFileHomes(report, ignored);
|
|
34
27
|
}
|
|
35
28
|
|
|
36
29
|
// Every directory, root included, is singular: component/ not components/. "assets" is in the
|
|
37
30
|
// exception list only because it is a common name for a dir that should be asset/ — it is reported
|
|
38
31
|
// separately below, with the rename.
|
|
39
|
-
function checkDirectoryNames(report, dir, allowed) {
|
|
32
|
+
function checkDirectoryNames(report, dir, allowed, ignored) {
|
|
40
33
|
for (const entry of readdirSync(dir)) {
|
|
41
34
|
if (SKIP_DIRS.has(entry) || entry.startsWith('.') || allowed.has(entry)) {
|
|
42
35
|
continue;
|
|
43
36
|
}
|
|
44
37
|
const path = dir === '.' ? entry : `${ dir }/${ entry }`;
|
|
45
|
-
if (!statSync(path).isDirectory()) {
|
|
38
|
+
if (ignored.has(path) || !statSync(path).isDirectory()) {
|
|
46
39
|
continue;
|
|
47
40
|
}
|
|
48
41
|
if (entry === 'assets' || entry === 'images' || entry === 'scripts' || entry === 'styles') {
|
|
@@ -50,17 +43,17 @@ function checkDirectoryNames(report, dir, allowed) {
|
|
|
50
43
|
} else if (/[a-z]s$/.test(entry) && !/ss$/.test(entry) && !SINGULAR_ENDING_IN_S.has(entry)) {
|
|
51
44
|
report.error(path + '/', `directories are singular — rename to ${ entry.slice(0, -1) }/ (a proper name goes in package.json "c3b-lint".singularDirectories)`);
|
|
52
45
|
}
|
|
53
|
-
checkDirectoryNames(report, path, allowed);
|
|
46
|
+
checkDirectoryNames(report, path, allowed, ignored);
|
|
54
47
|
}
|
|
55
48
|
}
|
|
56
49
|
|
|
57
50
|
// Asset files are lowercase kebab-case: booking-empty-state.svg, never LUNABLACK_2026-0138.webp
|
|
58
|
-
function checkAssetNames(report) {
|
|
51
|
+
function checkAssetNames(report, ignored) {
|
|
59
52
|
if (!existsSync('src/asset')) {
|
|
60
53
|
return;
|
|
61
54
|
}
|
|
62
55
|
for (const file of globSync('src/asset/**/*').map(toPosix).sort()) {
|
|
63
|
-
if (statSync(file).isDirectory()) {
|
|
56
|
+
if (ignored.has(file) || statSync(file).isDirectory()) {
|
|
64
57
|
continue;
|
|
65
58
|
}
|
|
66
59
|
const name = basename(file);
|
|
@@ -74,14 +67,14 @@ function checkAssetNames(report) {
|
|
|
74
67
|
|
|
75
68
|
// CSS lives in src/style/ (reset, tokens, main, components) with pages in style/page/ and animations
|
|
76
69
|
// in style/animation/; JS in src/script/ (WebGL helpers in src/gl/); fonts in src/asset/font/.
|
|
77
|
-
function checkFileHomes(report) {
|
|
70
|
+
function checkFileHomes(report, ignored) {
|
|
78
71
|
// src/ is a web root when it holds pages; a library that keeps its modules in src/ (c10g) is not
|
|
79
72
|
// bound to the site layout
|
|
80
73
|
if (!existsSync('src') || globSync('src/**/*.html').length === 0) {
|
|
81
74
|
return;
|
|
82
75
|
}
|
|
83
76
|
for (const file of globSync('src/**/*.{css,js,woff,woff2,ttf,otf}').map(toPosix).sort()) {
|
|
84
|
-
if (file.includes('/vendor/') || /\.min\.js$/.test(file)) {
|
|
77
|
+
if (ignored.has(file) || file.includes('/vendor/') || /\.min\.js$/.test(file)) {
|
|
85
78
|
continue;
|
|
86
79
|
}
|
|
87
80
|
if (/^\d/.test(basename(file))) {
|
package/config/eslint.config.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import js from '@eslint/js';
|
|
2
2
|
import globals from 'globals';
|
|
3
|
+
import { readSettings } from './project-settings.js';
|
|
3
4
|
|
|
4
5
|
// The JavaScript rules from frontend.md and node-cli.md that a linter can check. Formatting
|
|
5
6
|
// (indent, quotes, spacing) is not here: .editorconfig and the IDE own that.
|
|
@@ -9,6 +10,9 @@ import globals from 'globals';
|
|
|
9
10
|
// - everything else (bin files, script/, test/, config): ES modules on Node
|
|
10
11
|
|
|
11
12
|
const MAX_LINES = 500;
|
|
13
|
+
const settings = readSettings();
|
|
14
|
+
// the project's CDN globals (package.json "c3b-lint".globals), read-only like the browser's own
|
|
15
|
+
const cdnGlobals = Object.fromEntries(settings.globals.map((name) => [name, 'readonly']));
|
|
12
16
|
|
|
13
17
|
const shared = {
|
|
14
18
|
// general.md: no source file past 500 lines
|
|
@@ -46,12 +50,22 @@ const shared = {
|
|
|
46
50
|
'eol-last': 'error'
|
|
47
51
|
};
|
|
48
52
|
|
|
53
|
+
// node-cli.md: a replacement that comes from data (a name, a URL, a hash) goes in as a function — a
|
|
54
|
+
// string replacement expands `$&`, `$'` and `$1`, so a value with a `$` in it rewrites the output.
|
|
55
|
+
// A literal second argument is fine; anything else (an identifier, a template with expressions, a
|
|
56
|
+
// call) must be wrapped: `text.replace(re, () => value)`.
|
|
57
|
+
const dataReplacement = {
|
|
58
|
+
selector: 'CallExpression[callee.property.name=/^replace(All)?$/][arguments.length=2]'
|
|
59
|
+
+ ':not([arguments.1.type=/^(ArrowFunctionExpression|FunctionExpression|Literal)$/])',
|
|
60
|
+
message: 'A replacement from data expands $& and $1: pass a function, text.replace(re, () => value).'
|
|
61
|
+
};
|
|
62
|
+
|
|
49
63
|
export const browser = {
|
|
50
64
|
files: ['src/**/*.js'],
|
|
51
65
|
languageOptions: {
|
|
52
66
|
ecmaVersion: 2024,
|
|
53
67
|
sourceType: 'script',
|
|
54
|
-
globals: { ...globals.browser, HTMLCanvasElement: 'readonly', App: 'writable', ColabClient: 'readonly' }
|
|
68
|
+
globals: { ...globals.browser, HTMLCanvasElement: 'readonly', App: 'writable', ColabClient: 'readonly', ...cdnGlobals }
|
|
55
69
|
},
|
|
56
70
|
rules: {
|
|
57
71
|
...js.configs.recommended.rules,
|
|
@@ -60,6 +74,7 @@ export const browser = {
|
|
|
60
74
|
'no-console': ['error', { allow: ['warn', 'error'] }],
|
|
61
75
|
'no-restricted-syntax': [
|
|
62
76
|
'error',
|
|
77
|
+
dataReplacement,
|
|
63
78
|
{
|
|
64
79
|
selector: 'Program > VariableDeclaration',
|
|
65
80
|
message: 'Classic scripts share one global scope: wrap the file in an IIFE and attach its API to window.App.'
|
|
@@ -94,7 +109,8 @@ export const node = {
|
|
|
94
109
|
},
|
|
95
110
|
rules: {
|
|
96
111
|
...js.configs.recommended.rules,
|
|
97
|
-
...shared
|
|
112
|
+
...shared,
|
|
113
|
+
'no-restricted-syntax': ['error', dataReplacement]
|
|
98
114
|
}
|
|
99
115
|
};
|
|
100
116
|
|
|
@@ -108,14 +124,14 @@ export const librarySrc = {
|
|
|
108
124
|
rules: {
|
|
109
125
|
...node.rules,
|
|
110
126
|
'no-console': 'off',
|
|
111
|
-
'no-restricted-syntax': '
|
|
127
|
+
'no-restricted-syntax': ['error', dataReplacement],
|
|
112
128
|
'no-restricted-properties': 'off',
|
|
113
129
|
'no-restricted-globals': 'off'
|
|
114
130
|
}
|
|
115
131
|
};
|
|
116
132
|
|
|
117
133
|
export default [
|
|
118
|
-
{ ignores: ['node_modules/**', 'dist/**', 'c10g/**', 'src/**/vendor/**', '**/*.min.js'] },
|
|
134
|
+
{ ignores: ['node_modules/**', 'dist/**', 'c10g/**', 'src/**/vendor/**', '**/*.min.js', ...settings.ignore] },
|
|
119
135
|
browser,
|
|
120
136
|
node
|
|
121
137
|
];
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { globSync, readFileSync } from 'fs';
|
|
2
|
+
import { resolve, sep } from 'path';
|
|
3
|
+
|
|
4
|
+
// A project's own c3b-lint settings, read from the "c3b-lint" key of its package.json (the project being
|
|
5
|
+
// linted, so the current directory — the CLI and WebStorm both run from the project root):
|
|
6
|
+
//
|
|
7
|
+
// "c3b-lint": {
|
|
8
|
+
// "ignore": ["src/experiment/**"], globs no check looks at: ESLint, Stylelint and c3b-lint's own
|
|
9
|
+
// "globals": ["THREE", "gsap"], names the pages' CDN <script> tags define, read-only in src/ scripts
|
|
10
|
+
// "singularDirectories": ["mannequin-films"] plural-looking directory names that are proper names
|
|
11
|
+
// }
|
|
12
|
+
//
|
|
13
|
+
// The ESLint and Stylelint configs read it too, so the editor reports what the CLI does.
|
|
14
|
+
export function readSettings() {
|
|
15
|
+
let settings = {};
|
|
16
|
+
try {
|
|
17
|
+
settings = JSON.parse(readFileSync(resolve('package.json'), 'utf8'))['c3b-lint'] ?? {};
|
|
18
|
+
} catch (error) {
|
|
19
|
+
// no package.json, or not JSON: the CLI reports the first, and the defaults apply
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
ignore: settings.ignore ?? [],
|
|
24
|
+
globals: settings.globals ?? [],
|
|
25
|
+
singularDirectories: settings.singularDirectories ?? []
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Every file and directory the "ignore" globs match, as posix paths from the project root, for the checks
|
|
30
|
+
// c3b-lint runs itself (ESLint and Stylelint are handed the globs directly)
|
|
31
|
+
export function ignoredPaths() {
|
|
32
|
+
const { ignore } = readSettings();
|
|
33
|
+
if (ignore.length === 0) {
|
|
34
|
+
return new Set();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return new Set(globSync(ignore).map((path) => path.split(sep).join('/')));
|
|
38
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import recommended from 'stylelint-config-recommended';
|
|
2
|
+
import { readSettings } from './project-settings.js';
|
|
2
3
|
|
|
3
4
|
// The CSS rules from frontend.md that a linter can check. Formatting is left to .editorconfig
|
|
4
5
|
// and the IDE, and judgement calls (when a literal should become a token, whether a selector
|
|
@@ -12,6 +13,8 @@ const KEBAB = '^[a-z][a-z0-9]*(-[a-z0-9]+)*$';
|
|
|
12
13
|
const CLASS = '^(is-[a-z0-9]+(-[a-z0-9]+)*|[a-z][a-z0-9]*(-[a-z0-9]+)*(--[a-z0-9]+(-[a-z0-9]+)*)?)$';
|
|
13
14
|
|
|
14
15
|
export default {
|
|
16
|
+
// the project's package.json "c3b-lint".ignore
|
|
17
|
+
ignoreFiles: readSettings().ignore,
|
|
15
18
|
rules: {
|
|
16
19
|
...recommended.rules,
|
|
17
20
|
// a judgement call in frontend.md (structurally disjoint selectors), too noisy as a hard rule
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "c3b-lint",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.12",
|
|
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",
|
|
@@ -27,12 +27,12 @@
|
|
|
27
27
|
"deploy": "npm run test && npm publish"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@eslint/js": "9",
|
|
31
|
-
"eslint": "9",
|
|
32
|
-
"globals": "16",
|
|
33
|
-
"parse5": "7",
|
|
34
|
-
"postcss": "8",
|
|
35
|
-
"stylelint": "16",
|
|
36
|
-
"stylelint-config-recommended": "17"
|
|
30
|
+
"@eslint/js": "9.39.5",
|
|
31
|
+
"eslint": "9.39.5",
|
|
32
|
+
"globals": "16.5.0",
|
|
33
|
+
"parse5": "7.3.0",
|
|
34
|
+
"postcss": "8.5.29",
|
|
35
|
+
"stylelint": "16.26.1",
|
|
36
|
+
"stylelint-config-recommended": "17.0.0"
|
|
37
37
|
}
|
|
38
38
|
}
|