c3b-lint 0.0.0-stage → 0.0.2
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 +77 -2
- package/c3b-lint.js +199 -0
- package/check/css-tokens.js +139 -0
- package/check/hook.js +39 -0
- package/check/html.js +183 -0
- package/check/report.js +23 -0
- package/check/structure.js +86 -0
- package/config/editorconfig +65 -0
- package/config/eslint.config.js +84 -0
- package/config/stylelint.config.js +65 -0
- package/package.json +36 -4
package/README.md
CHANGED
|
@@ -1,3 +1,78 @@
|
|
|
1
|
-
#
|
|
1
|
+
# c3b-lint
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Lints a co:lab project against the rules in the [llm-instructions](https://codeberg.org/colab47/llm-instructions)
|
|
4
|
+
markdown that a tool can check, with one command and one config to install. It runs:
|
|
5
|
+
|
|
6
|
+
1. **ESLint** on every `.js` file — classic browser scripts under `src/` (IIFE required, no top-level declarations,
|
|
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.
|
|
9
|
+
2. **Stylelint** on `src/**/*.css` with `stylelint-config-recommended` plus the rules from `frontend.md`: no
|
|
10
|
+
`!important`, max 3 compound selectors, kebab-case ids/classes with `is-*` states and `--` variants, `:root` only
|
|
11
|
+
in `tokens.css` (and `reset.css`), token names `--{category}-{role}`, `rem` font sizes, `em` letter-spacing,
|
|
12
|
+
never `dvh`, no styling off `data-*` (warning).
|
|
13
|
+
3. **Token hygiene** across all the CSS, which no per-file linter can do: a literal that duplicates a token's value
|
|
14
|
+
(`#2b4b6f` where `--color-primary` is that), a literal colour, font-family or spacing value used in two or more
|
|
15
|
+
rules (that is a token by definition), and a token nothing uses (CSS `var()`, or JS/HTML by name).
|
|
16
|
+
4. **HTML** on every page under `src/` (partials are skipped): doctype, `lang`, charset, viewport without
|
|
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), stylesheet order (`reset`, `tokens`, `main`, …, page file last),
|
|
19
|
+
the skip-link rule (`<a id="skip-link" href="#main">` first in `<body>` when there is a header or nav, absent
|
|
20
|
+
otherwise, `<main id="main">`), one `<h1>`, `alt` on every image, no inline handlers, no `<style>` or `style=""`
|
|
21
|
+
(except a JS-driven custom property), relative paths only, kebab-case ids and classes, no duplicate ids,
|
|
22
|
+
`integrity` on CDN scripts.
|
|
23
|
+
5. **Project structure**: singular directory names, lowercase kebab-case asset names (and no camera exports), CSS
|
|
24
|
+
only in `src/style/` + `style/page/` + `style/animation/`, JS only in `src/script/`, fonts only in
|
|
25
|
+
`src/asset/font/` as woff2, file names starting with a letter.
|
|
26
|
+
6. The **500-line limit** on `src/**/*.{css,html}` (ESLint reports the JS).
|
|
27
|
+
7. That the project's **`.editorconfig`** is the shared one — it carries the JetBrains `ij_*` code-style keys, so
|
|
28
|
+
WebStorm and IntelliJ format the same way without committing `.idea/`.
|
|
29
|
+
|
|
30
|
+
Formatting itself (indent, quotes, spacing) is not linted: the `.editorconfig` and the IDE own it.
|
|
31
|
+
|
|
32
|
+
## Setup
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
npm install --save-dev c3b-lint
|
|
36
|
+
npx c3b-lint --init
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`--init` writes four files at the project root, all to commit:
|
|
40
|
+
|
|
41
|
+
- `.editorconfig` — the shared one (a copy; editors need a real file).
|
|
42
|
+
- `eslint.config.js` and `stylelint.config.js` — one line each, re-exporting this package's configs, so WebStorm
|
|
43
|
+
shows the same errors inline as the CLI reports. Bumping `c3b-lint` changes the rules everywhere.
|
|
44
|
+
- `.githooks/pre-commit`, and `git config core.hooksPath .githooks` — the lint runs before every commit, so a
|
|
45
|
+
commit that fails it never lands, whoever makes it. The hook lives in the repo, so after a clone one `--init`
|
|
46
|
+
(or `npx c3b-lint --init` in `postinstall`) wires it up. If `node_modules` is missing it says so and lets the
|
|
47
|
+
commit through; the test gate catches it later.
|
|
48
|
+
|
|
49
|
+
Then in `package.json`:
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
"postinstall": "c3b-lint --init",
|
|
53
|
+
"lint": "c3b-lint",
|
|
54
|
+
"pretest": "colab-partial --check && c3b-lint"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`postinstall` keeps the four files current and wires the hook on every clone with no extra step (`--init` only
|
|
58
|
+
writes, it never fails an install); `pretest` makes a lint error fail `npm test` and anything that runs it.
|
|
59
|
+
|
|
60
|
+
## Usage
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
c3b-lint # lint; exits 1 on any error
|
|
64
|
+
c3b-lint --fix # apply the fixes ESLint and Stylelint consider safe (var -> const, braces, …); reformat in the IDE after
|
|
65
|
+
c3b-lint --init # (re)write .editorconfig, eslint.config.js, stylelint.config.js, the pre-commit hook
|
|
66
|
+
c3b-lint --help
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The version is printed first, so you can tell which install is running.
|
|
70
|
+
|
|
71
|
+
## Not covered
|
|
72
|
+
|
|
73
|
+
Rules that need judgement stay with review: whether two selectors are structurally disjoint, flex vs grid, whether
|
|
74
|
+
a one-off literal should be a token anyway, where a component style belongs. `no-descending-specificity` is off for
|
|
75
|
+
the same reason. The checks that need a browser (console errors, broken links and images, accessibility, overflow)
|
|
76
|
+
stay in [c10g](https://codeberg.org/colab47/c10g); its SEO and duplicate-id suites now overlap with the HTML check
|
|
77
|
+
here. Java projects use [colab-checkstyle](https://codeberg.org/colab47/colab-checkstyle) and the same
|
|
78
|
+
`.editorconfig`.
|
package/c3b-lint.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, globSync, readFileSync, writeFileSync } from 'fs';
|
|
3
|
+
import { dirname, join, resolve, sep } from 'path';
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from 'url';
|
|
5
|
+
import { ESLint } from 'eslint';
|
|
6
|
+
import stylelint from 'stylelint';
|
|
7
|
+
import { createReport } from './check/report.js';
|
|
8
|
+
import { checkCssTokens } from './check/css-tokens.js';
|
|
9
|
+
import { checkHtml } from './check/html.js';
|
|
10
|
+
import { checkStructure } from './check/structure.js';
|
|
11
|
+
import { installHook } from './check/hook.js';
|
|
12
|
+
|
|
13
|
+
// Printed first so it's obvious which installed version is running.
|
|
14
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const { name, version } = JSON.parse(readFileSync(join(HERE, 'package.json'), 'utf8'));
|
|
16
|
+
console.log(`${ name } version: ${ version }`);
|
|
17
|
+
|
|
18
|
+
// Lints a co:lab project against the rules in the llm-instructions markdown that a tool can check:
|
|
19
|
+
// ESLint on every .js file, Stylelint on src/**/*.css, token hygiene across the CSS, the HTML rules,
|
|
20
|
+
// the project structure, the 500-line limit on CSS and HTML (ESLint covers JS), and that the
|
|
21
|
+
// project's .editorconfig is the shared one. The ESLint/Stylelint configs ship in config/; --init
|
|
22
|
+
// writes one-line re-exports of them at the project root (what WebStorm reads to show the same
|
|
23
|
+
// results inline) and installs the pre-commit hook.
|
|
24
|
+
|
|
25
|
+
const CONFIG_DIR = join(HERE, 'config');
|
|
26
|
+
const EDITORCONFIG = resolve('.editorconfig');
|
|
27
|
+
|
|
28
|
+
// The project's own config file when it has one (the one-liner --init writes, or an extended copy for
|
|
29
|
+
// a project that needs an exception), so the CLI and WebStorm always agree; this package's otherwise.
|
|
30
|
+
const configFor = (file) => (existsSync(resolve(file)) ? resolve(file) : join(CONFIG_DIR, file));
|
|
31
|
+
const MAX_LINES = 500;
|
|
32
|
+
|
|
33
|
+
// Files --init writes at the project root. The .editorconfig is a copy (it has to be a real
|
|
34
|
+
// file for editors to read it); the two lint configs re-export this package's, so a bump of
|
|
35
|
+
// c3b-lint changes the rules everywhere without touching projects.
|
|
36
|
+
const BASELINE = {
|
|
37
|
+
'.editorconfig': readFileSync(join(CONFIG_DIR, 'editorconfig'), 'utf8'),
|
|
38
|
+
'eslint.config.js': "export { default } from 'c3b-lint/config/eslint.config.js';\n",
|
|
39
|
+
'stylelint.config.js': "export { default } from 'c3b-lint/config/stylelint.config.js';\n",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const FLAGS = {
|
|
43
|
+
'--fix': 'apply the ESLint and Stylelint fixes that are safe to apply automatically',
|
|
44
|
+
'--init': 'write .editorconfig, eslint.config.js, stylelint.config.js and the pre-commit hook',
|
|
45
|
+
'--help': 'print this help',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function usage() {
|
|
49
|
+
console.log(`Usage: ${ name } [flags]\n\nLints .js, src/**/*.css and src/**/*.html, token use, project structure, file lengths and .editorconfig; exits 1 on any error.\n\nFlags:`);
|
|
50
|
+
for (const [flag, description] of Object.entries(FLAGS)) {
|
|
51
|
+
console.log(` ${ flag.padEnd(16) }${ description }`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function fail(message) {
|
|
56
|
+
console.error(`${ name }: ${ message }`);
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const args = process.argv.slice(2);
|
|
61
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
62
|
+
usage();
|
|
63
|
+
process.exit(0);
|
|
64
|
+
}
|
|
65
|
+
for (const arg of args) {
|
|
66
|
+
if (!(arg in FLAGS)) {
|
|
67
|
+
console.error(`${ name }: unknown option ${ arg }\n`);
|
|
68
|
+
usage();
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const fix = args.includes('--fix');
|
|
74
|
+
const init = args.includes('--init');
|
|
75
|
+
|
|
76
|
+
const toPosix = (p) => p.split(sep).join('/');
|
|
77
|
+
|
|
78
|
+
if (!existsSync(resolve('package.json'))) {
|
|
79
|
+
fail('package.json not found — run from the project root');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// --- --init ---------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
function writeBaseline() {
|
|
85
|
+
for (const [file, content] of Object.entries(BASELINE)) {
|
|
86
|
+
const path = resolve(file);
|
|
87
|
+
if (existsSync(path) && readFileSync(path, 'utf8') === content) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
console.log(` write ${ file }`);
|
|
91
|
+
writeFileSync(path, content);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// --- the checks -----------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
// Each check returns the number of errors it found, after printing them.
|
|
98
|
+
|
|
99
|
+
function checkEditorconfig() {
|
|
100
|
+
if (!existsSync(EDITORCONFIG)) {
|
|
101
|
+
console.log(` error .editorconfig is missing — run "${ name } --init"`);
|
|
102
|
+
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
|
105
|
+
if (readFileSync(EDITORCONFIG, 'utf8') !== BASELINE['.editorconfig']) {
|
|
106
|
+
console.log(` error .editorconfig differs from the shared one — run "${ name } --init"`);
|
|
107
|
+
|
|
108
|
+
return 1;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// general.md: no hand-written file past 500 lines. ESLint reports JS; this covers CSS and HTML.
|
|
115
|
+
function checkFileLengths() {
|
|
116
|
+
let errors = 0;
|
|
117
|
+
const files = globSync('src/**/*.{css,html}').filter((file) => !toPosix(file).includes('/vendor/'));
|
|
118
|
+
for (const file of files.sort()) {
|
|
119
|
+
const content = readFileSync(file, 'utf8');
|
|
120
|
+
const lines = content.split('\n').length - (content.endsWith('\n') ? 1 : 0);
|
|
121
|
+
if (lines > MAX_LINES) {
|
|
122
|
+
console.log(` error ${ toPosix(file) } is ${ lines } lines (limit ${ MAX_LINES }) — split it by responsibility`);
|
|
123
|
+
errors++;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return errors;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function runEslint() {
|
|
131
|
+
const eslint = new ESLint({ cwd: process.cwd(), overrideConfigFile: configFor('eslint.config.js'), fix });
|
|
132
|
+
let results;
|
|
133
|
+
try {
|
|
134
|
+
results = await eslint.lintFiles(['.']);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
// a project with no .js at all (a pure HTML/CSS site) is not an error
|
|
137
|
+
if (/No files matching|All files matched by '\.' are ignored/.test(error.message)) {
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
if (fix) {
|
|
143
|
+
await ESLint.outputFixes(results);
|
|
144
|
+
}
|
|
145
|
+
const formatter = await eslint.loadFormatter('stylish');
|
|
146
|
+
const output = await formatter.format(results);
|
|
147
|
+
if (output.trim()) {
|
|
148
|
+
console.log(output);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return results.reduce((sum, result) => sum + result.errorCount, 0);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function runStylelint() {
|
|
155
|
+
if (globSync('src/**/*.css').length === 0) {
|
|
156
|
+
return 0;
|
|
157
|
+
}
|
|
158
|
+
// Passed as an object with the project as base dir, so the overrides' globs match project files
|
|
159
|
+
// (a configFile's globs only ever match under that file's own directory)
|
|
160
|
+
const { default: config } = await import(pathToFileURL(configFor('stylelint.config.js')).href);
|
|
161
|
+
const result = await stylelint.lint({
|
|
162
|
+
files: 'src/**/*.css',
|
|
163
|
+
config,
|
|
164
|
+
configBasedir: process.cwd(),
|
|
165
|
+
fix,
|
|
166
|
+
formatter: 'string',
|
|
167
|
+
});
|
|
168
|
+
const output = result.report ?? result.output;
|
|
169
|
+
if (output.trim()) {
|
|
170
|
+
console.log(output);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return result.results.reduce((sum, file) => sum + file.warnings.filter((w) => w.severity === 'error').length, 0);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// --- run ------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
async function main() {
|
|
179
|
+
// --init only writes: it runs from postinstall, where a lint failure must not fail npm install
|
|
180
|
+
if (init) {
|
|
181
|
+
writeBaseline();
|
|
182
|
+
installHook((what) => console.log(` write ${ what }`));
|
|
183
|
+
console.log('Baseline written. Run c3b-lint to lint.');
|
|
184
|
+
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const report = createReport();
|
|
189
|
+
checkStructure(report);
|
|
190
|
+
checkHtml(report);
|
|
191
|
+
checkCssTokens(report);
|
|
192
|
+
const errors = checkEditorconfig() + checkFileLengths() + report.errors + await runEslint() + await runStylelint();
|
|
193
|
+
if (errors > 0) {
|
|
194
|
+
fail(`${ errors } error(s)`);
|
|
195
|
+
}
|
|
196
|
+
console.log('No lint errors.');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
main().catch((error) => fail(error.message));
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { globSync, readFileSync } from 'fs';
|
|
2
|
+
import { sep } from 'path';
|
|
3
|
+
import postcss from 'postcss';
|
|
4
|
+
|
|
5
|
+
// Token hygiene, from frontend.md's CSS Tokens section:
|
|
6
|
+
// - a literal that duplicates a token's value is a bug: use the token
|
|
7
|
+
// - a literal colour, font or spacing value that appears in two or more rules is a token by definition
|
|
8
|
+
// - a token with no uses is deleted
|
|
9
|
+
// Stylelint has no rule for any of these, because they need the whole project, not one file.
|
|
10
|
+
|
|
11
|
+
const TOKENS = 'src/style/tokens.css';
|
|
12
|
+
const SPACING_PROPERTIES = /^(margin|padding|gap|row-gap|column-gap)(-[a-z]+)?$/;
|
|
13
|
+
const COLOUR = /#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?)\([^)]*\)/gi;
|
|
14
|
+
|
|
15
|
+
const toPosix = (p) => p.split(sep).join('/');
|
|
16
|
+
|
|
17
|
+
// Hex colours compare as lowercase six/eight digit; everything else as lowercase with single spaces.
|
|
18
|
+
function normalise(value) {
|
|
19
|
+
const v = value.trim().toLowerCase().replace(/\s+/g, ' ').replace(/\s*,\s*/g, ', ');
|
|
20
|
+
const short = v.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])([0-9a-f])?$/);
|
|
21
|
+
if (short) {
|
|
22
|
+
return '#' + short.slice(1, 5).filter(Boolean).map((c) => c + c).join('');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return v;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function parse(file) {
|
|
29
|
+
try {
|
|
30
|
+
return postcss.parse(readFileSync(file, 'utf8'), { from: file });
|
|
31
|
+
} catch (error) {
|
|
32
|
+
return null; // Stylelint reports the syntax error
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readTokens() {
|
|
37
|
+
const tokens = new Map();
|
|
38
|
+
const root = parse(TOKENS);
|
|
39
|
+
if (!root) {
|
|
40
|
+
return tokens;
|
|
41
|
+
}
|
|
42
|
+
root.walkDecls(/^--/, (decl) => {
|
|
43
|
+
tokens.set(decl.prop, normalise(decl.value));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return tokens;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function checkCssTokens(report) {
|
|
50
|
+
const files = globSync('src/**/*.css').map(toPosix).filter((f) => !f.includes('/vendor/')).sort();
|
|
51
|
+
if (!files.includes(TOKENS)) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const tokens = readTokens();
|
|
55
|
+
const byValue = new Map([...tokens].map(([name, value]) => [value, name]));
|
|
56
|
+
|
|
57
|
+
// literal -> [{ file, line }] across every rule it appears in (one entry per rule)
|
|
58
|
+
const colours = new Map();
|
|
59
|
+
const fonts = new Map();
|
|
60
|
+
const spacing = new Map();
|
|
61
|
+
const used = new Set();
|
|
62
|
+
const noteUse = (text) => {
|
|
63
|
+
for (const match of text.matchAll(/--[a-z0-9-]+/g)) {
|
|
64
|
+
used.add(match[0]);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
for (const file of files) {
|
|
69
|
+
const root = parse(file);
|
|
70
|
+
if (!root) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
root.walkDecls((decl) => {
|
|
74
|
+
noteUse(decl.value);
|
|
75
|
+
if (file === TOKENS || decl.parent.type === 'atrule' && decl.parent.name === 'font-face') {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
// the steps of one @keyframes are one rule: the same colour in 20%, 40% and 60% is one animation
|
|
79
|
+
const keyframes = decl.parent.parent?.type === 'atrule' && decl.parent.parent.name === 'keyframes';
|
|
80
|
+
const where = { file, line: decl.source.start.line, rule: keyframes ? decl.parent.parent : decl.parent };
|
|
81
|
+
const value = decl.value.replace(/url\([^)]*\)/g, '');
|
|
82
|
+
|
|
83
|
+
// A colour or font literal duplicates a token wherever it appears; a length only does on a
|
|
84
|
+
// spacing property (a 24px icon is not --spacing-lg, a 16px font size is not --spacing-md)
|
|
85
|
+
const exact = normalise(value);
|
|
86
|
+
const lengthLike = /^-?\d/.test(exact);
|
|
87
|
+
if (byValue.has(exact) && !decl.prop.startsWith('--') && (!lengthLike || SPACING_PROPERTIES.test(decl.prop))) {
|
|
88
|
+
report.error(`${ file }:${ where.line }`, `${ decl.value } duplicates ${ byValue.get(exact) } — use var(${ byValue.get(exact) })`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (const match of value.matchAll(COLOUR)) {
|
|
92
|
+
const colour = normalise(match[0]);
|
|
93
|
+
if (byValue.has(colour) && exact !== colour) {
|
|
94
|
+
report.error(`${ file }:${ where.line }`, `${ match[0] } duplicates ${ byValue.get(colour) } — use var(${ byValue.get(colour) })`);
|
|
95
|
+
} else if (!byValue.has(colour)) {
|
|
96
|
+
add(colours, colour, where);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (decl.prop === 'font-family' && !/var\(/.test(value)) {
|
|
100
|
+
add(fonts, exact, where);
|
|
101
|
+
}
|
|
102
|
+
if (SPACING_PROPERTIES.test(decl.prop) && !/var\(|calc\(|%|auto|^0$/.test(value)) {
|
|
103
|
+
add(spacing, exact, where);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
for (const file of globSync('src/**/*.{js,html}').map(toPosix)) {
|
|
109
|
+
noteUse(readFileSync(file, 'utf8'));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (const [name] of tokens) {
|
|
113
|
+
if (!used.has(name)) {
|
|
114
|
+
report.error(TOKENS, `${ name } is never used — delete it`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
reportRepeats(report, colours, 'colour');
|
|
118
|
+
reportRepeats(report, fonts, 'font-family');
|
|
119
|
+
reportRepeats(report, spacing, 'spacing value');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// One entry per rule: the same literal twice in one rule (a border shorthand and a shadow) is one use.
|
|
123
|
+
function add(map, key, where) {
|
|
124
|
+
const list = map.get(key) ?? [];
|
|
125
|
+
if (!list.some((entry) => entry.rule === where.rule)) {
|
|
126
|
+
list.push(where);
|
|
127
|
+
map.set(key, list);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function reportRepeats(report, map, kind) {
|
|
132
|
+
for (const [literal, uses] of map) {
|
|
133
|
+
if (uses.length < 2) {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const places = uses.map((use) => `${ use.file }:${ use.line }`).join(', ');
|
|
137
|
+
report.error(uses[0].file, `${ kind } ${ literal } is used in ${ uses.length } rules (${ places }) — make it a token in ${ TOKENS }`);
|
|
138
|
+
}
|
|
139
|
+
}
|
package/check/hook.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from 'fs';
|
|
2
|
+
import { execFileSync } from 'child_process';
|
|
3
|
+
|
|
4
|
+
// A pre-commit hook, so a commit that fails the lint never lands — designers commit straight to the
|
|
5
|
+
// repos, and nobody runs npm test before every commit. The hook lives in the repo (.githooks/) and is
|
|
6
|
+
// wired with core.hooksPath, so it works with no extra tool and survives a fresh clone after one
|
|
7
|
+
// --init. If the package isn't installed (no npm install yet), it says so and lets the commit through
|
|
8
|
+
// rather than blocking someone who hasn't set up: the test gate still catches it later.
|
|
9
|
+
const HOOK = `#!/bin/sh
|
|
10
|
+
# Written by c3b-lint --init. Runs the lint before every commit; see https://codeberg.org/colab47/c3b-lint
|
|
11
|
+
if [ -x node_modules/.bin/c3b-lint ]; then
|
|
12
|
+
node_modules/.bin/c3b-lint || exit 1
|
|
13
|
+
else
|
|
14
|
+
echo "c3b-lint is not installed (run npm install) — commit not linted"
|
|
15
|
+
fi
|
|
16
|
+
`;
|
|
17
|
+
|
|
18
|
+
export function installHook(log) {
|
|
19
|
+
const path = '.githooks/pre-commit';
|
|
20
|
+
if (!existsSync(path) || readFileSync(path, 'utf8') !== HOOK) {
|
|
21
|
+
mkdirSync('.githooks', { recursive: true });
|
|
22
|
+
writeFileSync(path, HOOK);
|
|
23
|
+
log(path);
|
|
24
|
+
}
|
|
25
|
+
chmodSync(path, 0o755);
|
|
26
|
+
|
|
27
|
+
if (existsSync('.git')) {
|
|
28
|
+
try {
|
|
29
|
+
const current = execFileSync('git', ['config', '--get', 'core.hooksPath'], { encoding: 'utf8', stdio: 'pipe' }).trim();
|
|
30
|
+
if (current === '.githooks') {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
} catch (error) {
|
|
34
|
+
// not set yet
|
|
35
|
+
}
|
|
36
|
+
execFileSync('git', ['config', 'core.hooksPath', '.githooks'], { stdio: 'pipe' });
|
|
37
|
+
log('git config core.hooksPath .githooks');
|
|
38
|
+
}
|
|
39
|
+
}
|
package/check/html.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { globSync, readFileSync } from 'fs';
|
|
2
|
+
import { basename, sep } from 'path';
|
|
3
|
+
import { parse } from 'parse5';
|
|
4
|
+
|
|
5
|
+
// The HTML rules from frontend.md that a parser can check, on every page under src/ (partials are
|
|
6
|
+
// fragments and are skipped). c10g checks the served page in a browser; this runs in milliseconds
|
|
7
|
+
// before any browser starts, and covers what a browser can't see, like link order and skip links.
|
|
8
|
+
|
|
9
|
+
const KEBAB = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
10
|
+
const CLASS = /^(is-[a-z0-9]+(-[a-z0-9]+)*|[a-z][a-z0-9]*(-[a-z0-9]+)*(--[a-z0-9]+(-[a-z0-9]+)*)?)$/;
|
|
11
|
+
const STYLE_ORDER = ['reset.css', 'tokens.css', 'main.css'];
|
|
12
|
+
const REQUIRED_META = [
|
|
13
|
+
['meta[name=description]', (el) => el.name === 'meta' && attr(el, 'name') === 'description'],
|
|
14
|
+
['link[rel=canonical]', (el) => el.name === 'link' && attr(el, 'rel') === 'canonical'],
|
|
15
|
+
['og:title', (el) => el.name === 'meta' && attr(el, 'property') === 'og:title'],
|
|
16
|
+
['og:description', (el) => el.name === 'meta' && attr(el, 'property') === 'og:description'],
|
|
17
|
+
['og:type', (el) => el.name === 'meta' && attr(el, 'property') === 'og:type'],
|
|
18
|
+
['og:url', (el) => el.name === 'meta' && attr(el, 'property') === 'og:url'],
|
|
19
|
+
['og:image', (el) => el.name === 'meta' && attr(el, 'property') === 'og:image'],
|
|
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'],
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const toPosix = (p) => p.split(sep).join('/');
|
|
25
|
+
const attr = (el, name) => el.attrs?.find((a) => a.name === name)?.value;
|
|
26
|
+
const line = (el) => el.sourceCodeLocation?.startLine ?? 0;
|
|
27
|
+
const isElement = (node) => Boolean(node.tagName);
|
|
28
|
+
|
|
29
|
+
// Every element in document order
|
|
30
|
+
function* elements(node) {
|
|
31
|
+
for (const child of node.childNodes ?? []) {
|
|
32
|
+
if (isElement(child)) {
|
|
33
|
+
yield child;
|
|
34
|
+
yield* elements(child);
|
|
35
|
+
}
|
|
36
|
+
if (child.content) {
|
|
37
|
+
yield* elements(child.content);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const find = (root, name) => [...elements(root)].find((el) => el.tagName === name);
|
|
43
|
+
const children = (el) => (el.childNodes ?? []).filter(isElement);
|
|
44
|
+
|
|
45
|
+
export function checkHtml(report) {
|
|
46
|
+
const files = globSync('src/**/*.html').map(toPosix)
|
|
47
|
+
.filter((f) => !f.startsWith('src/partial/') && !f.includes('/vendor/')).sort();
|
|
48
|
+
for (const file of files) {
|
|
49
|
+
checkPage(report, file, readFileSync(file, 'utf8'));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function checkPage(report, file, source) {
|
|
54
|
+
const document = parse(source, { sourceCodeLocationInfo: true });
|
|
55
|
+
const all = [...elements(document)].map((el) => ({ ...el, name: el.tagName }));
|
|
56
|
+
const at = (el) => `${ file }:${ line(el) }`;
|
|
57
|
+
|
|
58
|
+
// --- document basics
|
|
59
|
+
if (!/^\s*<!doctype html>/i.test(source)) {
|
|
60
|
+
report.error(file, 'missing <!DOCTYPE html>');
|
|
61
|
+
}
|
|
62
|
+
const html = find(document, 'html');
|
|
63
|
+
if (html && !attr(html, 'lang')) {
|
|
64
|
+
report.error(at(html), '<html> needs a lang attribute');
|
|
65
|
+
}
|
|
66
|
+
const head = find(document, 'head');
|
|
67
|
+
const body = find(document, 'body');
|
|
68
|
+
if (!head || !body) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const headEls = children(head).map((el) => ({ ...el, name: el.tagName }));
|
|
72
|
+
if (!headEls.some((el) => el.name === 'meta' && attr(el, 'charset'))) {
|
|
73
|
+
report.error(file, 'missing <meta charset>');
|
|
74
|
+
}
|
|
75
|
+
const viewport = headEls.find((el) => el.name === 'meta' && attr(el, 'name') === 'viewport');
|
|
76
|
+
if (!viewport) {
|
|
77
|
+
report.error(file, 'missing viewport meta');
|
|
78
|
+
} else if (/user-scalable\s*=\s*no|maximum-scale/i.test(attr(viewport, 'content') ?? '')) {
|
|
79
|
+
report.error(at(viewport), 'viewport must not set user-scalable=no or maximum-scale');
|
|
80
|
+
}
|
|
81
|
+
if (!headEls.some((el) => el.name === 'title')) {
|
|
82
|
+
report.error(file, 'missing <title>');
|
|
83
|
+
}
|
|
84
|
+
// an internal tool (noindex) is never shared or indexed, so the social and canonical tags are
|
|
85
|
+
// not required there; the description and favicon still are
|
|
86
|
+
const noindex = headEls.some((el) => el.name === 'meta' && attr(el, 'name') === 'robots' && /noindex/.test(attr(el, 'content') ?? ''));
|
|
87
|
+
for (const [label, matches] of REQUIRED_META) {
|
|
88
|
+
if (noindex && /canonical|og:|twitter:/.test(label)) {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (!headEls.some(matches)) {
|
|
92
|
+
report.error(file, `missing ${ label }`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const ogImage = headEls.find((el) => el.name === 'meta' && attr(el, 'property') === 'og:image');
|
|
96
|
+
if (ogImage && !/^https?:\/\//.test(attr(ogImage, 'content') ?? '')) {
|
|
97
|
+
report.error(at(ogImage), 'og:image must be an absolute URL (social platforms ignore relative paths)');
|
|
98
|
+
}
|
|
99
|
+
const canonical = headEls.find((el) => el.name === 'link' && attr(el, 'rel') === 'canonical');
|
|
100
|
+
if (canonical && !/^https?:\/\//.test(attr(canonical, 'href') ?? '')) {
|
|
101
|
+
report.error(at(canonical), 'canonical must be an absolute URL');
|
|
102
|
+
}
|
|
103
|
+
const favicons = headEls.filter((el) => el.name === 'link' && attr(el, 'rel') === 'icon');
|
|
104
|
+
if (favicons.length > 1) {
|
|
105
|
+
report.error(at(favicons[1]), 'one favicon <link>, an SVG');
|
|
106
|
+
} else if (favicons.length === 1 && !/\.svg$/.test(attr(favicons[0], 'href') ?? '')) {
|
|
107
|
+
report.error(at(favicons[0]), 'the favicon is an SVG');
|
|
108
|
+
}
|
|
109
|
+
if (headEls.some((el) => el.name === 'style')) {
|
|
110
|
+
report.error(file, 'no <style> blocks — styles live in src/style/');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// --- stylesheet link order: reset, tokens, main, components, page file last
|
|
114
|
+
const sheets = headEls.filter((el) => el.name === 'link' && attr(el, 'rel') === 'stylesheet')
|
|
115
|
+
.map((el) => ({ el, href: attr(el, 'href') ?? '' }));
|
|
116
|
+
const names = sheets.map((s) => basename(s.href.split('?')[0]));
|
|
117
|
+
const expectedStart = STYLE_ORDER.filter((n) => names.includes(n));
|
|
118
|
+
if (names.slice(0, expectedStart.length).join() !== expectedStart.join()) {
|
|
119
|
+
report.error(at(sheets[0].el), `stylesheets start with ${ STYLE_ORDER.join(', ') } in that order`);
|
|
120
|
+
}
|
|
121
|
+
const pageIndex = sheets.findIndex((s) => /\/page\//.test(s.href));
|
|
122
|
+
if (pageIndex !== -1 && pageIndex !== sheets.length - 1) {
|
|
123
|
+
report.error(at(sheets[pageIndex].el), 'the page stylesheet is linked last');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// --- skip link and main
|
|
127
|
+
const hasChrome = all.some((el) => el.name === 'header' || el.name === 'nav');
|
|
128
|
+
const first = children(body)[0];
|
|
129
|
+
const skipLink = first && first.tagName === 'a' && attr(first, 'id') === 'skip-link';
|
|
130
|
+
const main = all.find((el) => el.name === 'main');
|
|
131
|
+
if (hasChrome) {
|
|
132
|
+
if (!skipLink || attr(first, 'href') !== '#main') {
|
|
133
|
+
report.error(file, 'a page with a header or nav starts <body> with <a id="skip-link" href="#main">');
|
|
134
|
+
}
|
|
135
|
+
if (!main || attr(main, 'id') !== 'main') {
|
|
136
|
+
report.error(file, '<main> carries id="main" for the skip link');
|
|
137
|
+
}
|
|
138
|
+
} else if (skipLink) {
|
|
139
|
+
report.error(at(first), 'a page with no header or nav has no skip link');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// --- headings, images, scripts, paths, inline handlers and styles, names
|
|
143
|
+
const h1s = all.filter((el) => el.name === 'h1');
|
|
144
|
+
if (h1s.length !== 1) {
|
|
145
|
+
report.error(h1s[1] ? at(h1s[1]) : file, `one <h1> per page (found ${ h1s.length })`);
|
|
146
|
+
}
|
|
147
|
+
const ids = new Map();
|
|
148
|
+
for (const el of all) {
|
|
149
|
+
for (const { name, value } of el.attrs ?? []) {
|
|
150
|
+
if (/^on[a-z]+$/.test(name)) {
|
|
151
|
+
report.error(at(el), `no inline handlers (${ name }) — JS lives in src/script/`);
|
|
152
|
+
}
|
|
153
|
+
if (name === 'style' && !/^\s*(--[a-z0-9-]+\s*:[^;]+;?\s*)+$/.test(value)) {
|
|
154
|
+
report.error(at(el), 'no style="" attributes, except to set a JS-driven custom property');
|
|
155
|
+
}
|
|
156
|
+
if ((name === 'href' || name === 'src') && /^\/(?!\/)/.test(value)) {
|
|
157
|
+
report.error(at(el), `root-relative path ${ value } — use a relative path`);
|
|
158
|
+
}
|
|
159
|
+
if (name === 'id') {
|
|
160
|
+
if (!KEBAB.test(value)) {
|
|
161
|
+
report.error(at(el), `id "${ value }" is not kebab-case`);
|
|
162
|
+
}
|
|
163
|
+
if (ids.has(value)) {
|
|
164
|
+
report.error(at(el), `duplicate id "${ value }" (first at line ${ ids.get(value) })`);
|
|
165
|
+
}
|
|
166
|
+
ids.set(value, line(el));
|
|
167
|
+
}
|
|
168
|
+
if (name === 'class') {
|
|
169
|
+
for (const cls of value.split(/\s+/).filter(Boolean)) {
|
|
170
|
+
if (!CLASS.test(cls)) {
|
|
171
|
+
report.error(at(el), `class "${ cls }" is not kebab-case (state classes are is-*, variants use --)`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (el.name === 'img' && attr(el, 'alt') === undefined) {
|
|
177
|
+
report.error(at(el), '<img> needs alt text (alt="" if decorative)');
|
|
178
|
+
}
|
|
179
|
+
if (el.name === 'script' && /^https?:\/\//.test(attr(el, 'src') ?? '') && !attr(el, 'integrity')) {
|
|
180
|
+
report.error(at(el), 'CDN scripts carry integrity and crossorigin="anonymous"');
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
package/check/report.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// One line per finding, the way every co:lab tool prints: a padded level, then the file and
|
|
2
|
+
// message. Returns a counter so each check can hand back how many errors it printed.
|
|
3
|
+
export function createReport() {
|
|
4
|
+
let errors = 0;
|
|
5
|
+
let warnings = 0;
|
|
6
|
+
|
|
7
|
+
return {
|
|
8
|
+
error(file, message) {
|
|
9
|
+
console.log(` error ${ file }: ${ message }`);
|
|
10
|
+
errors++;
|
|
11
|
+
},
|
|
12
|
+
warn(file, message) {
|
|
13
|
+
console.log(` warn ${ file }: ${ message }`);
|
|
14
|
+
warnings++;
|
|
15
|
+
},
|
|
16
|
+
get errors() {
|
|
17
|
+
return errors;
|
|
18
|
+
},
|
|
19
|
+
get warnings() {
|
|
20
|
+
return warnings;
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { existsSync, globSync, readdirSync, statSync } from 'fs';
|
|
2
|
+
import { basename, dirname, sep } from 'path';
|
|
3
|
+
|
|
4
|
+
// The project-structure rules from general.md and frontend.md: singular directory names, kebab-case
|
|
5
|
+
// asset names, and where CSS, JS and fonts live under src/. Pure filesystem walks.
|
|
6
|
+
|
|
7
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'c10g', '.llm-rules', '.idea', 'vendor', 'target', 'build']);
|
|
8
|
+
// Words that end in s but are not plurals, or that are plural by convention outside our control
|
|
9
|
+
const SINGULAR_ENDING_IN_S = new Set(['css', 'js', 'canvas', 'status', 'news', 'gl', 'lens', 'bus', 'analysis', 'ios', 'macos', 'assets']);
|
|
10
|
+
const ASSET_NAME = /^[a-z0-9]+(-[a-z0-9]+)*(\.[a-z0-9]+)+$/;
|
|
11
|
+
const CSS_HOMES = [/^src\/style\/[a-z0-9-]+\.css$/, /^src\/style\/page\/[a-z][a-z0-9-]*\.css$/, /^src\/style\/animation\/[a-z][a-z0-9-]*\.css$/];
|
|
12
|
+
const JS_HOMES = [/^src\/script\/([a-z0-9-]+\/)*[a-z][a-z0-9-]*\.js$/, /^src\/gl\//];
|
|
13
|
+
|
|
14
|
+
const toPosix = (p) => p.split(sep).join('/');
|
|
15
|
+
|
|
16
|
+
export function checkStructure(report) {
|
|
17
|
+
checkDirectoryNames(report, '.');
|
|
18
|
+
checkAssetNames(report);
|
|
19
|
+
checkFileHomes(report);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Every directory, root included, is singular: component/ not components/. "assets" is in the
|
|
23
|
+
// exception list only because it is a common name for a dir that should be asset/ — it is reported
|
|
24
|
+
// separately below, with the rename.
|
|
25
|
+
function checkDirectoryNames(report, dir) {
|
|
26
|
+
for (const entry of readdirSync(dir)) {
|
|
27
|
+
if (SKIP_DIRS.has(entry) || entry.startsWith('.')) {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const path = dir === '.' ? entry : `${ dir }/${ entry }`;
|
|
31
|
+
if (!statSync(path).isDirectory()) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (entry === 'assets' || entry === 'images' || entry === 'scripts' || entry === 'styles') {
|
|
35
|
+
report.error(path + '/', `directories are singular — rename to ${ entry.slice(0, -1) }/`);
|
|
36
|
+
} else if (/[a-z]s$/.test(entry) && !/ss$/.test(entry) && !SINGULAR_ENDING_IN_S.has(entry)) {
|
|
37
|
+
report.error(path + '/', `directories are singular — rename to ${ entry.slice(0, -1) }/ (or add it to the exceptions in c3b-lint if it is not a plural)`);
|
|
38
|
+
}
|
|
39
|
+
checkDirectoryNames(report, path);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Asset files are lowercase kebab-case: booking-empty-state.svg, never LUNABLACK_2026-0138.webp
|
|
44
|
+
function checkAssetNames(report) {
|
|
45
|
+
if (!existsSync('src/asset')) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
for (const file of globSync('src/asset/**/*').map(toPosix).sort()) {
|
|
49
|
+
if (statSync(file).isDirectory()) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const name = basename(file);
|
|
53
|
+
if (!ASSET_NAME.test(name)) {
|
|
54
|
+
report.error(file, 'asset names are lowercase kebab-case (letters, digits, hyphens)');
|
|
55
|
+
} else if (/^(img|dsc|dcim|screenshot|untitled)[-0-9]/.test(name)) {
|
|
56
|
+
report.error(file, 'name the asset after what it shows, not the camera export');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// CSS lives in src/style/ (reset, tokens, main, components) with pages in style/page/ and animations
|
|
62
|
+
// in style/animation/; JS in src/script/ (WebGL helpers in src/gl/); fonts in src/asset/font/.
|
|
63
|
+
function checkFileHomes(report) {
|
|
64
|
+
if (!existsSync('src')) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
for (const file of globSync('src/**/*.{css,js,woff,woff2,ttf,otf}').map(toPosix).sort()) {
|
|
68
|
+
if (file.includes('/vendor/') || /\.min\.js$/.test(file)) {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (/^\d/.test(basename(file))) {
|
|
72
|
+
report.error(file, 'file names start with a letter, never a digit');
|
|
73
|
+
}
|
|
74
|
+
if (file.endsWith('.css') && !CSS_HOMES.some((home) => home.test(file))) {
|
|
75
|
+
report.error(file, 'CSS lives in src/style/ (shared), src/style/page/ (one per page) or src/style/animation/');
|
|
76
|
+
} else if (file.endsWith('.js') && !JS_HOMES.some((home) => home.test(file))) {
|
|
77
|
+
report.error(file, 'JS lives in src/script/');
|
|
78
|
+
} else if (/\.(woff2?|ttf|otf)$/.test(file)) {
|
|
79
|
+
if (dirname(file) !== 'src/asset/font') {
|
|
80
|
+
report.error(file, 'fonts live in src/asset/font/');
|
|
81
|
+
} else if (!file.endsWith('.woff2')) {
|
|
82
|
+
report.error(file, 'fonts are self-hosted as woff2 only');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# co:lab editor settings, written by c3b-lint --init and checked by every c3b-lint run.
|
|
2
|
+
# The ij_* keys are JetBrains code-style settings (WebStorm / IntelliJ read them from here),
|
|
3
|
+
# so the formatter is shared without committing .idea/.
|
|
4
|
+
root = true
|
|
5
|
+
|
|
6
|
+
[*]
|
|
7
|
+
indent_style = space
|
|
8
|
+
indent_size = 4
|
|
9
|
+
end_of_line = lf
|
|
10
|
+
charset = utf-8
|
|
11
|
+
insert_final_newline = true
|
|
12
|
+
trim_trailing_whitespace = true
|
|
13
|
+
max_line_length = 120
|
|
14
|
+
ij_continuation_indent_size = 8
|
|
15
|
+
ij_visual_guides = 120
|
|
16
|
+
ij_wrap_on_typing = false
|
|
17
|
+
|
|
18
|
+
[*.{js,mjs,cjs}]
|
|
19
|
+
ij_javascript_use_double_quotes = false
|
|
20
|
+
ij_javascript_force_quote_style = true
|
|
21
|
+
ij_javascript_force_semicolon_style = true
|
|
22
|
+
ij_javascript_use_semicolon_after_statement = true
|
|
23
|
+
ij_javascript_if_brace_force = always
|
|
24
|
+
ij_javascript_for_brace_force = always
|
|
25
|
+
ij_javascript_while_brace_force = always
|
|
26
|
+
ij_javascript_do_while_brace_force = always
|
|
27
|
+
ij_javascript_spaces_within_object_literal_braces = true
|
|
28
|
+
ij_javascript_spaces_within_imports = true
|
|
29
|
+
ij_javascript_spaces_within_interpolation_expressions = true
|
|
30
|
+
ij_javascript_align_multiline_parameters = false
|
|
31
|
+
ij_javascript_align_multiline_chained_methods = false
|
|
32
|
+
ij_javascript_blank_lines_after_imports = 1
|
|
33
|
+
ij_javascript_keep_blank_lines_in_code = 1
|
|
34
|
+
|
|
35
|
+
[*.java]
|
|
36
|
+
ij_java_if_brace_force = always
|
|
37
|
+
ij_java_for_brace_force = always
|
|
38
|
+
ij_java_while_brace_force = always
|
|
39
|
+
ij_java_dowhile_brace_force = always
|
|
40
|
+
ij_java_align_multiline_parameters = false
|
|
41
|
+
ij_java_align_group_field_declarations = false
|
|
42
|
+
ij_java_align_consecutive_variable_declarations = false
|
|
43
|
+
ij_java_align_consecutive_assignments = false
|
|
44
|
+
ij_java_class_count_to_use_import_on_demand = 999
|
|
45
|
+
ij_java_names_count_to_use_import_on_demand = 999
|
|
46
|
+
ij_java_method_call_chain_wrap = normal
|
|
47
|
+
ij_java_blank_lines_after_imports = 1
|
|
48
|
+
ij_java_keep_blank_lines_in_code = 1
|
|
49
|
+
ij_java_keep_blank_lines_in_declarations = 1
|
|
50
|
+
ij_java_blank_lines_around_method = 1
|
|
51
|
+
ij_java_blank_lines_around_field = 0
|
|
52
|
+
ij_java_generate_final_locals = true
|
|
53
|
+
ij_java_generate_final_parameters = true
|
|
54
|
+
|
|
55
|
+
[*.{css,html}]
|
|
56
|
+
ij_html_quote_style = double
|
|
57
|
+
ij_css_enforce_quotes_on_format = true
|
|
58
|
+
ij_css_use_double_quotes = true
|
|
59
|
+
|
|
60
|
+
[*.md]
|
|
61
|
+
max_line_length = 120
|
|
62
|
+
trim_trailing_whitespace = false
|
|
63
|
+
|
|
64
|
+
[*.{json,yml,yaml}]
|
|
65
|
+
indent_size = 2
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import js from '@eslint/js';
|
|
2
|
+
import globals from 'globals';
|
|
3
|
+
|
|
4
|
+
// The JavaScript rules from frontend.md and node-cli.md that a linter can check. Formatting
|
|
5
|
+
// (indent, quotes, spacing) is not here: .editorconfig and the IDE own that.
|
|
6
|
+
//
|
|
7
|
+
// Two shapes of file in every co:lab project:
|
|
8
|
+
// - src/**: classic browser scripts, one global scope, wrapped in an IIFE, no console.log
|
|
9
|
+
// - everything else (bin files, script/, test/, config): ES modules on Node
|
|
10
|
+
|
|
11
|
+
const MAX_LINES = 500;
|
|
12
|
+
|
|
13
|
+
const shared = {
|
|
14
|
+
// general.md: no source file past 500 lines
|
|
15
|
+
'max-lines': ['error', { max: MAX_LINES, skipBlankLines: false, skipComments: false }],
|
|
16
|
+
// frontend.md: brackets on every if; const by default, let when reassigned, never var; blank line before return
|
|
17
|
+
'curly': ['error', 'all'],
|
|
18
|
+
// and the block's body on its own line: `if (x) {y();}` is what curly's fix produces on its own
|
|
19
|
+
'brace-style': ['error', '1tbs', { allowSingleLine: false }],
|
|
20
|
+
'no-var': 'error',
|
|
21
|
+
'prefer-const': 'error',
|
|
22
|
+
'eqeqeq': ['error', 'always'],
|
|
23
|
+
'padding-line-between-statements': ['error', { blankLine: 'always', prev: '*', next: 'return' }],
|
|
24
|
+
// an unused catch binding is fine: `catch (error) { // reason }` is how we say the failure is expected
|
|
25
|
+
'no-unused-vars': ['error', { caughtErrors: 'none' }],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const browser = {
|
|
29
|
+
files: ['src/**/*.js'],
|
|
30
|
+
languageOptions: {
|
|
31
|
+
ecmaVersion: 2024,
|
|
32
|
+
sourceType: 'script',
|
|
33
|
+
globals: { ...globals.browser, App: 'writable', ColabClient: 'readonly' },
|
|
34
|
+
},
|
|
35
|
+
rules: {
|
|
36
|
+
...js.configs.recommended.rules,
|
|
37
|
+
...shared,
|
|
38
|
+
// frontend.md: no console.log/debug in src/; warn only for feature fallbacks
|
|
39
|
+
'no-console': ['error', { allow: ['warn', 'error'] }],
|
|
40
|
+
'no-restricted-syntax': [
|
|
41
|
+
'error',
|
|
42
|
+
{
|
|
43
|
+
selector: 'Program > VariableDeclaration',
|
|
44
|
+
message: 'Classic scripts share one global scope: wrap the file in an IIFE and attach its API to window.App.',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
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
|
+
},
|
|
50
|
+
],
|
|
51
|
+
'no-restricted-properties': [
|
|
52
|
+
'warn',
|
|
53
|
+
{
|
|
54
|
+
object: 'document',
|
|
55
|
+
property: 'getElementsByClassName',
|
|
56
|
+
message: 'Use getElementById for the landmark, then querySelectorAll scoped off it.',
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
'no-restricted-globals': [
|
|
60
|
+
'warn',
|
|
61
|
+
{ name: 'setInterval', message: 'Use IntersectionObserver or requestAnimationFrame, not polling.' },
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const node = {
|
|
67
|
+
files: ['**/*.js'],
|
|
68
|
+
ignores: ['src/**'],
|
|
69
|
+
languageOptions: {
|
|
70
|
+
ecmaVersion: 2024,
|
|
71
|
+
sourceType: 'module',
|
|
72
|
+
globals: globals.node,
|
|
73
|
+
},
|
|
74
|
+
rules: {
|
|
75
|
+
...js.configs.recommended.rules,
|
|
76
|
+
...shared,
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export default [
|
|
81
|
+
{ ignores: ['node_modules/**', 'dist/**', 'c10g/**', 'src/**/vendor/**', '**/*.min.js'] },
|
|
82
|
+
browser,
|
|
83
|
+
node,
|
|
84
|
+
];
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import recommended from 'stylelint-config-recommended';
|
|
2
|
+
|
|
3
|
+
// The CSS rules from frontend.md that a linter can check. Formatting is left to .editorconfig
|
|
4
|
+
// and the IDE, and judgement calls (when a literal should become a token, whether a selector
|
|
5
|
+
// is structurally disjoint) are left to review.
|
|
6
|
+
//
|
|
7
|
+
// stylelint-config-recommended's rules are spread in rather than `extends`-ed: c3b-lint passes this
|
|
8
|
+
// config to Stylelint as an object, so there is nothing to resolve from the project's node_modules.
|
|
9
|
+
|
|
10
|
+
const KEBAB = '^[a-z][a-z0-9]*(-[a-z0-9]+)*$';
|
|
11
|
+
// a kebab-case class, an is-* state class, or a kebab-case class with one -- variant
|
|
12
|
+
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
|
+
export default {
|
|
15
|
+
rules: {
|
|
16
|
+
...recommended.rules,
|
|
17
|
+
// a judgement call in frontend.md (structurally disjoint selectors), too noisy as a hard rule
|
|
18
|
+
'no-descending-specificity': null,
|
|
19
|
+
// frontend.md: no !important; max 3 compound selectors per rule
|
|
20
|
+
'declaration-no-important': true,
|
|
21
|
+
'selector-max-compound-selectors': 3,
|
|
22
|
+
// kebab-case ids and classes, is-* for state, -- for a variant of a shared class
|
|
23
|
+
'selector-id-pattern': [KEBAB, { message: 'ids are kebab-case' }],
|
|
24
|
+
'selector-class-pattern': [CLASS, {
|
|
25
|
+
resolveNestedSelectors: true,
|
|
26
|
+
message: 'classes are kebab-case; state classes are is-*; a variant of a shared class uses --',
|
|
27
|
+
}],
|
|
28
|
+
// custom properties are kebab-case everywhere; the stricter token shape applies in tokens.css below
|
|
29
|
+
'custom-property-pattern': ['^[a-z0-9]+(-[a-z0-9]+)*$', { message: 'custom properties are kebab-case' }],
|
|
30
|
+
// tokens are defined once, in tokens.css; reset.css may touch :root for box-sizing and text-size-adjust
|
|
31
|
+
'selector-disallowed-list': [['/^:root/'], { message: ':root declarations belong in tokens.css' }],
|
|
32
|
+
// units: font sizes in rem (clamp() may mix in vw), letter-spacing in em, never dvh
|
|
33
|
+
'unit-disallowed-list': [['dvh'], { message: 'dvh follows the phone toolbar; use vh, or svh for bottom-anchored content' }],
|
|
34
|
+
'declaration-property-unit-allowed-list': {
|
|
35
|
+
'font-size': ['rem', 'vw', '%'],
|
|
36
|
+
'letter-spacing': ['em'],
|
|
37
|
+
},
|
|
38
|
+
// frontend.md: don't style off data attributes; they exist for JS
|
|
39
|
+
'selector-attribute-name-disallowed-list': [['/^data-/'], {
|
|
40
|
+
severity: 'warning',
|
|
41
|
+
message: 'data-* attributes are for JS; style with an id, class or structural selector',
|
|
42
|
+
}],
|
|
43
|
+
},
|
|
44
|
+
// Override globs are relative to the project root: c3b-lint sets configBasedir to it.
|
|
45
|
+
overrides: [
|
|
46
|
+
{
|
|
47
|
+
files: ['src/style/tokens.css', 'src/style/reset.css'],
|
|
48
|
+
rules: { 'selector-disallowed-list': null },
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
// a theme switch (:root[data-theme="light"] { --color-bg: … }) is the one place styling
|
|
52
|
+
// hangs off a data attribute: JS sets it, and only tokens change
|
|
53
|
+
files: ['src/style/tokens.css'],
|
|
54
|
+
rules: { 'selector-attribute-name-disallowed-list': null },
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
files: ['src/style/tokens.css'],
|
|
58
|
+
rules: {
|
|
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
|
+
],
|
|
65
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "c3b-lint",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.0.2",
|
|
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
|
+
"license": "GPL-3.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"c3b-lint": "c3b-lint.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"c3b-lint.js",
|
|
12
|
+
"check",
|
|
13
|
+
"config"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=22"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "https://codeberg.org/colab47/c3b-lint.git"
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"registry": "https://registry.npmjs.org"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"test": "node c3b-lint.js && node --test test/*.test.js",
|
|
27
|
+
"deploy": "npm run test && npm publish"
|
|
28
|
+
},
|
|
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"
|
|
37
|
+
}
|
|
38
|
+
}
|