c3b-lint 0.0.11 → 0.0.13

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
@@ -20,7 +20,7 @@ markdown that a tool can check, with one command and one config to install. It r
20
20
  stylesheets (webfonts are self-hosted), stylesheet order (`reset`, `tokens`, `main`, …, page file last),
21
21
  the skip-link rule (`<a id="skip-link" href="#main">` first in `<body>` when there is a header or nav, absent
22
22
  otherwise, `<main id="main">`), one `<h1>`, `alt` on every image, no inline handlers, no `<style>` or `style=""`
23
- (except a JS-driven custom property), relative paths only, kebab-case ids and classes, no duplicate ids,
23
+ (except a JS-driven custom property), relative paths only (a Barba site's links outside its container — header, menu — stay root-relative), kebab-case ids and classes, no duplicate ids,
24
24
  `integrity` on CDN scripts.
25
25
  5. **Project structure**: singular directory names, lowercase kebab-case asset names (and no camera exports), and,
26
26
  when `src/` holds pages, CSS only in `src/style/` + `style/page/` + `style/animation/`, JS only in `src/script/`
@@ -66,6 +66,26 @@ Then in `package.json`:
66
66
  `postinstall` keeps the four files current and wires the hook on every clone with no extra step (`--init` only
67
67
  writes, it never fails an install); `pretest` makes a lint error fail `npm test` and anything that runs it.
68
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
+
69
89
  ## Usage
70
90
 
71
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
@@ -25,6 +26,19 @@ const toPosix = (p) => p.split(sep).join('/');
25
26
  const attr = (el, name) => el.attrs?.find((a) => a.name === name)?.value;
26
27
  const line = (el) => el.sourceCodeLocation?.startLine ?? 0;
27
28
  const isElement = (node) => Boolean(node.tagName);
29
+ const isBarbaContainer = (node) => isElement(node) && attr(node, 'data-barba') === 'container';
30
+
31
+ // Inside Barba's page container: replaced on every navigation, so its links resolve against the URL
32
+ // of the page they came with
33
+ function inBarbaContainer(el) {
34
+ for (let node = el.parentNode; node; node = node.parentNode) {
35
+ if (isBarbaContainer(node)) {
36
+ return true;
37
+ }
38
+ }
39
+
40
+ return false;
41
+ }
28
42
 
29
43
  // Every element in document order
30
44
  function* elements(node) {
@@ -43,8 +57,9 @@ const find = (root, name) => [...elements(root)].find((el) => el.tagName === nam
43
57
  const children = (el) => (el.childNodes ?? []).filter(isElement);
44
58
 
45
59
  export function checkHtml(report) {
60
+ const ignored = ignoredPaths();
46
61
  const files = globSync('src/**/*.html').map(toPosix)
47
- .filter((f) => !f.startsWith('src/partial/') && !f.includes('/vendor/')).sort();
62
+ .filter((f) => !f.startsWith('src/partial/') && !f.includes('/vendor/') && !ignored.has(f)).sort();
48
63
  for (const file of files) {
49
64
  checkPage(report, file, readFileSync(file, 'utf8'));
50
65
  }
@@ -152,6 +167,10 @@ function checkPage(report, file, source) {
152
167
  report.error(h1s[1] ? at(h1s[1]) : file, `one <h1> per page (found ${ h1s.length })`);
153
168
  }
154
169
  const ids = new Map();
170
+ // A Barba site keeps everything outside its container (header, menu) for the whole visit while the URL
171
+ // changes under it, so a relative link there would resolve against whichever page is showing: those
172
+ // links stay root-relative
173
+ const barba = all.some((el) => attr(el, 'data-barba') === 'wrapper');
155
174
  for (const el of all) {
156
175
  for (const { name, value } of el.attrs ?? []) {
157
176
  if (/^on[a-z]+$/.test(name)) {
@@ -160,7 +179,8 @@ function checkPage(report, file, source) {
160
179
  if (name === 'style' && !/^\s*(--[a-z0-9-]+\s*:[^;]+;?\s*)+$/.test(value)) {
161
180
  report.error(at(el), 'no style="" attributes, except to set a JS-driven custom property');
162
181
  }
163
- if ((name === 'href' || name === 'src') && /^\/(?!\/)/.test(value)) {
182
+ const persistentLink = barba && el.name === 'a' && name === 'href' && !inBarbaContainer(el);
183
+ if ((name === 'href' || name === 'src') && /^\/(?!\/)/.test(value) && !persistentLink) {
164
184
  report.error(at(el), `root-relative path ${ value } — use a relative path`);
165
185
  }
166
186
  if (name === 'id') {
@@ -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
@@ -61,7 +65,7 @@ export const browser = {
61
65
  languageOptions: {
62
66
  ecmaVersion: 2024,
63
67
  sourceType: 'script',
64
- globals: { ...globals.browser, HTMLCanvasElement: 'readonly', App: 'writable', ColabClient: 'readonly' }
68
+ globals: { ...globals.browser, HTMLCanvasElement: 'readonly', App: 'writable', ColabClient: 'readonly', ...cdnGlobals }
65
69
  },
66
70
  rules: {
67
71
  ...js.configs.recommended.rules,
@@ -127,7 +131,7 @@ export const librarySrc = {
127
131
  };
128
132
 
129
133
  export default [
130
- { ignores: ['node_modules/**', 'dist/**', 'c10g/**', 'src/**/vendor/**', '**/*.min.js'] },
134
+ { ignores: ['node_modules/**', 'dist/**', 'c10g/**', 'src/**/vendor/**', '**/*.min.js', ...settings.ignore] },
131
135
  browser,
132
136
  node
133
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.11",
3
+ "version": "0.0.13",
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",