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 CHANGED
@@ -1,3 +1,78 @@
1
- # Temporary Holding Version
1
+ # c3b-lint
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ }
@@ -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.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }