c3b-lint 0.0.10 → 0.0.12

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