@wix/origami-lint 0.0.0-stage → 1.34.1
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 +41 -2
- package/dist/history/changed-files.js +42 -0
- package/dist/history/index.js +3 -0
- package/dist/history/last-passing.js +44 -0
- package/dist/history/utils.js +13 -0
- package/dist/lint.js +13 -0
- package/dist/page-model/build-page-model.js +11 -0
- package/dist/page-model/connect-css.js +43 -0
- package/dist/page-model/css-selectors.js +49 -0
- package/dist/page-model/index.js +5 -0
- package/dist/page-model/model.js +5 -0
- package/dist/page-model/pages.js +27 -0
- package/dist/page-model/read-css.js +84 -0
- package/dist/page-model/read-page.js +17 -0
- package/dist/page-model/read-structure.js +58 -0
- package/dist/page-model/read-theme.js +14 -0
- package/dist/page-model/utils.js +78 -0
- package/dist/rules/allowed-imports.js +109 -0
- package/dist/rules/apply-and-presets.js +310 -0
- package/dist/rules/bindings.js +673 -0
- package/dist/rules/breakpoints.js +124 -0
- package/dist/rules/css-file-nesting.js +196 -0
- package/dist/rules/css-layout-properties.js +398 -0
- package/dist/rules/data-values.js +511 -0
- package/dist/rules/define-rule.js +4 -0
- package/dist/rules/global-styles.js +215 -0
- package/dist/rules/index.js +16 -0
- package/dist/rules/no-absolute-section.js +31 -0
- package/dist/rules/no-free-code.js +112 -0
- package/dist/rules/page-structure.js +277 -0
- package/dist/rules/slot-attributes.js +96 -0
- package/dist/rules/theme-font-ids.js +69 -0
- package/dist/rules/theme-root.js +122 -0
- package/dist/rules-adapter/index.js +3 -0
- package/dist/rules-adapter/oxlint/adapt-rule.js +17 -0
- package/dist/rules-adapter/oxlint/callbacks.js +1 -0
- package/dist/rules-adapter/oxlint/origami-rules.config.js +12 -0
- package/dist/rules-adapter/oxlint/oxlint-plugin.js +7 -0
- package/dist/rules-adapter/oxlint/run-rules.js +22 -0
- package/dist/rules-adapter/tool-types.js +1 -0
- package/dist/rules-adapter/utils.js +20 -0
- package/dist/rules-api/index.js +5 -0
- package/dist/rules-api/report.js +13 -0
- package/dist/rules-api/rules-api.js +70 -0
- package/dist/rules-api/utils.js +11 -0
- package/dist/shared/css.js +124 -0
- package/dist/shared/log.js +4 -0
- package/dist/shared/paths.js +28 -0
- package/dist/shared/site-files-tree-mapping.js +12 -0
- package/dist/shared/utils.js +208 -0
- package/package.json +52 -3
package/README.md
CHANGED
|
@@ -1,3 +1,42 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @wix/origami-lint (POC)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Offline lint for an exported Origami site. Each run lints only the pages whose files changed since the last passing
|
|
4
|
+
run (every page when `theme.css` changed), and runs every rule on every component of those pages.
|
|
5
|
+
The rules run as plain oxlint rules.
|
|
6
|
+
|
|
7
|
+
Published to the Wix registry (public npm later). How the lint works, its parts and how to write a rule:
|
|
8
|
+
[lint/README.md](lint/README.md).
|
|
9
|
+
|
|
10
|
+
## In a site
|
|
11
|
+
|
|
12
|
+
`export-site` adds the lint to every exported site, in the site's root `package.json`: `@wix/origami-lint` in the dev
|
|
13
|
+
dependencies and a `lint` script (`origami-lint`). It also writes the root `.gitignore` (the lint snapshots every file
|
|
14
|
+
git doesn't ignore) and runs `git init` at the end (the lint keeps the last passing version in the site's repository).
|
|
15
|
+
|
|
16
|
+
The lint runs only from the site's root: the folder it starts in is the site, and it stops unless that folder's
|
|
17
|
+
`package.json` has `@wix/origami-lint` and the folder holds the git repository.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
yarn install # at the site's root
|
|
21
|
+
yarn lint # what changed since the last passing run
|
|
22
|
+
yarn lint --full # everything
|
|
23
|
+
ORIGAMI_LINT_BASE=main yarn lint # compare with a git ref; never saved as passing
|
|
24
|
+
yarn up @wix/origami-lint # a new lint version: the next run checks everything
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Develop
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pnpm --filter @wix/origami-lint run build # typecheck (tsc -p .), then lint/ → dist/ (tsconfig.build.json)
|
|
31
|
+
pnpm --filter @wix/origami-lint run test
|
|
32
|
+
pnpm --filter @wix/origami-lint run lint
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
lint/ the source (lint.ts is the entry point and the origami-lint bin)
|
|
37
|
+
dist/ lint/ built to JavaScript: what the package publishes (Node won't run .ts inside node_modules)
|
|
38
|
+
test/ rules on in-memory pages, CSS, the pages to lint, the oxlint adapter; history, the site root and a whole run on temporary repositories
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The oxlint config is `lint/rules-adapter/oxlint/origami-rules.config.ts`, passed to oxlint with `-c`. It is not named
|
|
42
|
+
`oxlint.config.ts`, so `oxlint .` in this repo doesn't load it as a nested config.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Which files changed since the last passing run. Now = the tree hash of the whole folder, committed or not,
|
|
2
|
+
// taken with a private index file, so your staging area, branches and history are never touched.
|
|
3
|
+
import { mkdirSync } from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { log } from "../shared/log.js";
|
|
6
|
+
import { LINT_DIR } from "../shared/paths.js";
|
|
7
|
+
import { lastPassingVersion } from "./last-passing.js";
|
|
8
|
+
import { git, gitList } from "./utils.js";
|
|
9
|
+
const PRIVATE_INDEX = { GIT_INDEX_FILE: path.join(LINT_DIR, 'index') };
|
|
10
|
+
/** `--full`: compare with nothing. `ORIGAMI_LINT_BASE=main`: compare with main instead of the last passing run. */
|
|
11
|
+
export function getChangedFiles() {
|
|
12
|
+
const base = process.env.ORIGAMI_LINT_BASE;
|
|
13
|
+
const now = takeSnapshot();
|
|
14
|
+
const before = findVersionToCompareWith(base);
|
|
15
|
+
const changedFiles = {
|
|
16
|
+
now,
|
|
17
|
+
since: before && (base ?? before.slice(0, 7)),
|
|
18
|
+
comparedWithBase: Boolean(base),
|
|
19
|
+
changed: before ? gitList(['diff', '--name-only', '-z', '--no-renames', before, now]) : null,
|
|
20
|
+
all: gitList(['ls-tree', '-r', '-z', '--name-only', now]),
|
|
21
|
+
};
|
|
22
|
+
logChangedFiles(changedFiles);
|
|
23
|
+
return changedFiles;
|
|
24
|
+
}
|
|
25
|
+
function takeSnapshot() {
|
|
26
|
+
mkdirSync(LINT_DIR, { recursive: true });
|
|
27
|
+
git(['add', '-A'], PRIVATE_INDEX);
|
|
28
|
+
return git(['write-tree'], PRIVATE_INDEX);
|
|
29
|
+
}
|
|
30
|
+
function findVersionToCompareWith(base) {
|
|
31
|
+
if (process.argv.includes('--full'))
|
|
32
|
+
return null;
|
|
33
|
+
if (base)
|
|
34
|
+
return git(['rev-parse', base]);
|
|
35
|
+
return lastPassingVersion();
|
|
36
|
+
}
|
|
37
|
+
function logChangedFiles({ since, changed }) {
|
|
38
|
+
if (!changed)
|
|
39
|
+
log('all files (nothing to compare with)');
|
|
40
|
+
else
|
|
41
|
+
log(`${changed.length} files changed since ${since}`);
|
|
42
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// The last passing run: its snapshot, stored as a git ref, and the lint version that passed it, stored in .lint/.
|
|
2
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { log } from "../shared/log.js";
|
|
5
|
+
import { LINT_DIR, LINT_PACKAGE_FILE } from "../shared/paths.js";
|
|
6
|
+
import { git } from "./utils.js";
|
|
7
|
+
const LAST_PASSING_REF = 'refs/origami/last-valid';
|
|
8
|
+
const LAST_PASSING_FILE = path.join(LINT_DIR, 'last-passing.json');
|
|
9
|
+
const LINT_VERSION = JSON.parse(readFileSync(LINT_PACKAGE_FILE, 'utf8')).version;
|
|
10
|
+
/** null = nothing to compare with: nothing passed yet, or another lint version passed it (its rules may differ). */
|
|
11
|
+
export function lastPassingVersion() {
|
|
12
|
+
const snapshot = lastPassingSnapshot();
|
|
13
|
+
if (!snapshot)
|
|
14
|
+
return null;
|
|
15
|
+
const passedWith = lastPassingLintVersion();
|
|
16
|
+
if (passedWith === LINT_VERSION)
|
|
17
|
+
return snapshot;
|
|
18
|
+
log(`the lint changed since the last passing run (${passedWith ?? 'unknown'} → ${LINT_VERSION})`);
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
/** The next run compares with the version that just passed. */
|
|
22
|
+
export function markPassing({ now, comparedWithBase }) {
|
|
23
|
+
if (comparedWithBase)
|
|
24
|
+
return log('not saved as passing: compared with ORIGAMI_LINT_BASE');
|
|
25
|
+
git(['update-ref', LAST_PASSING_REF, now]);
|
|
26
|
+
writeFileSync(LAST_PASSING_FILE, `${JSON.stringify({ lintVersion: LINT_VERSION }, null, 2)}\n`);
|
|
27
|
+
log('saved as passing: the next run compares with now');
|
|
28
|
+
}
|
|
29
|
+
function lastPassingSnapshot() {
|
|
30
|
+
try {
|
|
31
|
+
return git(['rev-parse', '--verify', '--quiet', LAST_PASSING_REF]);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function lastPassingLintVersion() {
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(readFileSync(LAST_PASSING_FILE, 'utf8')).lintVersion;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Git commands used by history.
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { ROOT } from "../shared/paths.js";
|
|
4
|
+
function runGit(args, env = {}) {
|
|
5
|
+
return execFileSync('git', args, { cwd: ROOT, maxBuffer: 256 * 2 ** 20, env: { ...process.env, ...env } });
|
|
6
|
+
}
|
|
7
|
+
export function git(args, env) {
|
|
8
|
+
return runGit(args, env).toString('utf8').trim();
|
|
9
|
+
}
|
|
10
|
+
/** Output of a `-z` command, split on NUL. */
|
|
11
|
+
export function gitList(args) {
|
|
12
|
+
return runGit(args).toString('utf8').split('\0').filter(Boolean);
|
|
13
|
+
}
|
package/dist/lint.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Origami lint: lints the pages whose files changed since the last passing run. See lint/README.md.
|
|
3
|
+
import { getChangedFiles, markPassing } from "./history/index.js";
|
|
4
|
+
import { runRules } from "./rules-adapter/index.js";
|
|
5
|
+
import { findPagesToLint } from "./page-model/index.js";
|
|
6
|
+
import { checkStartedAtSiteRoot } from "./shared/paths.js";
|
|
7
|
+
checkStartedAtSiteRoot();
|
|
8
|
+
const changedFiles = getChangedFiles();
|
|
9
|
+
const pagesToLint = findPagesToLint(changedFiles);
|
|
10
|
+
const passed = runRules(pagesToLint);
|
|
11
|
+
if (passed)
|
|
12
|
+
markPassing(changedFiles);
|
|
13
|
+
process.exit(passed ? 0 : 1);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { connectCssToComponents } from "./connect-css.js";
|
|
2
|
+
import { readCss } from "./read-css.js";
|
|
3
|
+
import { readStructure } from "./read-structure.js";
|
|
4
|
+
import { GLOBAL_COMPONENTS_FOLDER } from "../shared/site-files-tree-mapping.js";
|
|
5
|
+
export function buildPageModel({ jsxFile, text, program }, readFile) {
|
|
6
|
+
const page = jsxFile.slice(0, jsxFile.lastIndexOf('/'));
|
|
7
|
+
const structure = readStructure(jsxFile, text, program);
|
|
8
|
+
const css = readCss(page, readFile);
|
|
9
|
+
connectCssToComponents(structure.components, css);
|
|
10
|
+
return { path: page, kind: page.startsWith(`${GLOBAL_COMPONENTS_FOLDER}/`) ? 'global' : 'page', jsxFile, ...structure, css };
|
|
11
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// THE place that ties CSS to components: each component takes the CSS of each of its class names (`.x`, as the import
|
|
2
|
+
// matches it since converter 0.63). Class names no component took become unconnectedCss.
|
|
3
|
+
import { CSS_FILES, CSS_KINDS } from "./model.js";
|
|
4
|
+
/** Two components with the same class name both take its CSS, as in a browser. */
|
|
5
|
+
export function connectCssToComponents(components, css) {
|
|
6
|
+
const connected = new Set();
|
|
7
|
+
for (const component of Object.values(components)) {
|
|
8
|
+
const taken = component.classNames.filter((name) => css.byClassName[name]);
|
|
9
|
+
if (!taken.length)
|
|
10
|
+
continue;
|
|
11
|
+
component.css = joinCss(taken.map((name) => css.byClassName[name]));
|
|
12
|
+
for (const name of taken)
|
|
13
|
+
connected.add(name);
|
|
14
|
+
}
|
|
15
|
+
moveUnconnected(css, connected);
|
|
16
|
+
}
|
|
17
|
+
/** One component's CSS from several class names: per kind and property, the lines of all of them in the order they're written,
|
|
18
|
+
* as the cascade reads them (`.card` then `.wide` in the file: `.wide` wins, whatever the className's order). A line that two of
|
|
19
|
+
* its classes both reach (`.a, .b { … }` on className="a b") is one line, as the browser applies it once. */
|
|
20
|
+
function joinCss(all) {
|
|
21
|
+
if (all.length === 1)
|
|
22
|
+
return all[0];
|
|
23
|
+
return Object.fromEntries(CSS_KINDS.map((kind) => {
|
|
24
|
+
const joined = Object.create(null);
|
|
25
|
+
for (const componentCss of all)
|
|
26
|
+
for (const [property, lines] of Object.entries(componentCss[kind]))
|
|
27
|
+
(joined[property] ??= []).push(...lines);
|
|
28
|
+
for (const [property, lines] of Object.entries(joined))
|
|
29
|
+
joined[property] = lines.sort(byPlace).filter((line, index) => index === 0 || !isSameLine(lines[index - 1], line));
|
|
30
|
+
return [kind, joined];
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
const byPlace = (a, b) => CSS_FILES.indexOf(a.file) - CSS_FILES.indexOf(b.file) || a.line - b.line || a.column - b.column;
|
|
34
|
+
/** The same line of the file, reached through the same selector and media. */
|
|
35
|
+
const isSameLine = (a, b) => byPlace(a, b) === 0 && a.selector === b.selector && a.media === b.media;
|
|
36
|
+
function moveUnconnected(css, connected) {
|
|
37
|
+
for (const [name, componentCss] of Object.entries(css.byClassName)) {
|
|
38
|
+
if (connected.has(name))
|
|
39
|
+
continue;
|
|
40
|
+
css.unconnectedCss[`.${name}`] = componentCss;
|
|
41
|
+
delete css.byClassName[name];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// CSS selectors: joining a nested rule to its parent, and which component a selector belongs to (the one of its first `.x`).
|
|
2
|
+
/** `&` is the parent selector; a child without `&` is a descendant of it. */
|
|
3
|
+
export function nestSelectors(parents, children) {
|
|
4
|
+
if (!parents.length)
|
|
5
|
+
return children;
|
|
6
|
+
return parents.flatMap((parent) => children.map((child) => (child.includes('&') ? child.replaceAll('&', parent) : `${parent} ${child}`)));
|
|
7
|
+
}
|
|
8
|
+
/** A selector's owner is its first `.x` or `#x` outside [attribute] brackets (only the first is read): '.comp-x' → '.comp-x', '&';
|
|
9
|
+
* '.comp-x:hover' → '.comp-x', '&:hover'; '.comp-x .label' → '.comp-x', '& .label'; '#comp-x' → '#comp-x', '&'; ':root' → null, ':root'. */
|
|
10
|
+
export function parseComponentSelector(selector) {
|
|
11
|
+
const normalized = selector.replace(/\s+/g, ' ').trim();
|
|
12
|
+
const owner = findOwner(normalized);
|
|
13
|
+
if (!owner)
|
|
14
|
+
return { owner: null, selector: normalized };
|
|
15
|
+
return { owner: owner.text, selector: `${normalized.slice(0, owner.start)}&${normalized.slice(owner.start + owner.text.length)}` };
|
|
16
|
+
}
|
|
17
|
+
/** A CSS name: letters, digits, `-`, `_`, any non-ASCII letter, and escapes (`\:`, `\31 `). */
|
|
18
|
+
const NAME = /^(?:[\w-]|\P{ASCII}|\\[0-9a-fA-F]{1,6}\s?|\\[^\n0-9a-fA-F])+/u;
|
|
19
|
+
/** `a\:b` → 'a:b', `\31 23` → '123': the class name an escaped selector names. */
|
|
20
|
+
export const unescapeName = (name) => name.replace(/\\([0-9a-fA-F]{1,6})\s?|\\(.)/g, (_, hex, char) => (hex ? String.fromCodePoint(parseInt(hex, 16)) : char));
|
|
21
|
+
/** The first `.name` or `#name` outside [ ] (an attribute's value can hold a dot), a quoted value (it can hold a `]`) and ( ) (`:not(.x)`,
|
|
22
|
+
* `:is(.a, .b)` name other elements or a choice, not the one the rule is on). */
|
|
23
|
+
function findOwner(selector) {
|
|
24
|
+
let depth = 0;
|
|
25
|
+
let quote = null;
|
|
26
|
+
for (let index = 0; index < selector.length; index++) {
|
|
27
|
+
const char = selector[index];
|
|
28
|
+
if (quote) {
|
|
29
|
+
if (char === '\\')
|
|
30
|
+
index++;
|
|
31
|
+
else if (char === quote)
|
|
32
|
+
quote = null;
|
|
33
|
+
}
|
|
34
|
+
else if (char === '"' || char === "'")
|
|
35
|
+
quote = char;
|
|
36
|
+
else if (char === '\\')
|
|
37
|
+
index++;
|
|
38
|
+
else if (char === '[' || char === '(')
|
|
39
|
+
depth++;
|
|
40
|
+
else if (char === ']' || char === ')')
|
|
41
|
+
depth--;
|
|
42
|
+
else if (depth === 0 && (char === '.' || char === '#')) {
|
|
43
|
+
const name = NAME.exec(selector.slice(index + 1))?.[0];
|
|
44
|
+
if (name)
|
|
45
|
+
return { text: `${char}${name}`, start: index };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Part 2 — page model: which pages to lint, and one page's model (its components, each with its CSS).
|
|
2
|
+
export * from "./model.js";
|
|
3
|
+
export { buildPageModel } from "./build-page-model.js";
|
|
4
|
+
export { findPagesToLint, JSX_FILES } from "./pages.js";
|
|
5
|
+
export { readPage } from "./read-page.js";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** layout.css, style.css, and what a component gets from the theme through @apply. */
|
|
2
|
+
export const CSS_KINDS = ['layout', 'style', 'theme'];
|
|
3
|
+
/** A page's own CSS files, in the order they're read; with the structure file, the files that make a page (pages.ts). */
|
|
4
|
+
export const CSS_FILES = ['layout.css', 'style.css'];
|
|
5
|
+
export const FILE_START = { start: { line: 1, column: 0 }, end: { line: 1, column: 0 } };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { log } from "../shared/log.js";
|
|
2
|
+
import { CSS_FILES } from "./model.js";
|
|
3
|
+
import { GLOBAL_COMPONENTS_FOLDER, PAGES_FOLDER, THEME_FILE } from "../shared/site-files-tree-mapping.js";
|
|
4
|
+
export const PAGE_ROOTS = [PAGES_FOLDER, GLOBAL_COMPONENTS_FOLDER];
|
|
5
|
+
/** A page's structure file: structure.tsx, or structure.jsx (exports). structure.tsx wins if both exist. */
|
|
6
|
+
export const JSX_FILES = ['structure.tsx', 'structure.jsx'];
|
|
7
|
+
/** Written by the site generator inside a page folder; never a page itself. */
|
|
8
|
+
const GENERATED_FOLDER = '_generated';
|
|
9
|
+
/** The structure files to lint: the pages of the changed files; every page on a full run. */
|
|
10
|
+
export function findPagesToLint({ changed, all }) {
|
|
11
|
+
const pagesToLint = findPages(changed ?? all, all);
|
|
12
|
+
log(`${pagesToLint.length} pages to lint`);
|
|
13
|
+
return pagesToLint;
|
|
14
|
+
}
|
|
15
|
+
/** The structure files of the pages these files belong to (every page when the theme is one of them), among currentFiles. */
|
|
16
|
+
export function findPages(files, currentFiles) {
|
|
17
|
+
const existing = new Set(currentFiles);
|
|
18
|
+
const pageFilesToCheck = files.includes(THEME_FILE) ? currentFiles : files;
|
|
19
|
+
const pages = new Set(pageFilesToCheck.map(pageOfFile).filter((page) => page !== null));
|
|
20
|
+
return [...pages].map((page) => JSX_FILES.map((name) => `${page}/${name}`).find((file) => existing.has(file))).filter((file) => file !== undefined);
|
|
21
|
+
}
|
|
22
|
+
/** 'packages/site-app/src/pages/about-us/team/style.css' → 'packages/site-app/src/pages/about-us/team'; other files → null. Pages can be nested. */
|
|
23
|
+
function pageOfFile(file) {
|
|
24
|
+
const page = file.slice(0, file.lastIndexOf('/'));
|
|
25
|
+
const isInPageFolder = PAGE_ROOTS.some((root) => page.startsWith(`${root}/`)) && !page.split('/').includes(GENERATED_FOLDER);
|
|
26
|
+
return isInPageFolder && [...JSX_FILES, ...CSS_FILES].map((name) => `${page}/${name}`).includes(file) ? page : null;
|
|
27
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// A page's layout.css + style.css, and theme.css → PageCss: every line under its selector's class name and its kind, or in globalCss;
|
|
2
|
+
// each file as parsed.
|
|
3
|
+
// Nested rules are joined to their parent, and `@apply --mixin` is replaced by the theme's mixin, as kind 'theme'.
|
|
4
|
+
import postcss, {} from 'postcss';
|
|
5
|
+
import { CSS_FILES } from "./model.js";
|
|
6
|
+
import { nestSelectors, parseComponentSelector, unescapeName } from "./css-selectors.js";
|
|
7
|
+
import { THEME_FILE } from "../shared/site-files-tree-mapping.js";
|
|
8
|
+
import { mixinName, readMixins } from "./read-theme.js";
|
|
9
|
+
import { emptyComponentCss } from "./utils.js";
|
|
10
|
+
const KIND_OF_FILE = { 'layout.css': 'layout', 'style.css': 'style' };
|
|
11
|
+
/** A property's name as the browser reads it: `Position` is `position`; a custom property (`--Gap`) keeps its case, which counts. */
|
|
12
|
+
const propertyName = (prop) => (prop.startsWith('--') ? prop : prop.toLowerCase());
|
|
13
|
+
const placeOf = (node) => ({ line: node.source?.start?.line ?? 1, column: node.source?.start?.column ?? 1 });
|
|
14
|
+
/** theme.css is read and parsed once: its tree feeds the mixins `@apply` puts in, and rulesApi reads its tokens and breakpoints. */
|
|
15
|
+
export function readCss(page, readFile) {
|
|
16
|
+
const themeCss = readFile(THEME_FILE);
|
|
17
|
+
const theme = themeCss === undefined ? undefined : postcss.parse(themeCss);
|
|
18
|
+
const css = {
|
|
19
|
+
// No prototype: a class named `constructor` or `__proto__` is a key like any other, not Object's.
|
|
20
|
+
byClassName: Object.create(null),
|
|
21
|
+
unconnectedCss: Object.create(null),
|
|
22
|
+
globalCss: emptyComponentCss(),
|
|
23
|
+
files: { layout: undefined, style: undefined, theme },
|
|
24
|
+
};
|
|
25
|
+
const mixins = readMixins(theme);
|
|
26
|
+
const addLine = lineAdderTo(css);
|
|
27
|
+
for (const file of CSS_FILES) {
|
|
28
|
+
const text = readFile(`${page}/${file}`);
|
|
29
|
+
if (text === undefined)
|
|
30
|
+
continue;
|
|
31
|
+
const root = postcss.parse(text);
|
|
32
|
+
const kind = KIND_OF_FILE[file];
|
|
33
|
+
css.files[kind] = root;
|
|
34
|
+
readNodes(root.nodes, { selectors: [], media: null, target: null, kind, file, applying: [], appliedAt: null }, mixins, addLine);
|
|
35
|
+
}
|
|
36
|
+
return css;
|
|
37
|
+
}
|
|
38
|
+
/** A line goes under its selector's first class name, or in globalCss when the selector has no `.x` or `#x`. A `#x` owner is
|
|
39
|
+
* unconnected from the start: the import doesn't read #id since converter 0.63. */
|
|
40
|
+
function lineAdderTo(css) {
|
|
41
|
+
return (selector, property, value, { media, target, kind, file }, { line, column }) => {
|
|
42
|
+
const { owner, selector: componentSelector } = parseComponentSelector(selector);
|
|
43
|
+
const componentCss = !owner ? css.globalCss
|
|
44
|
+
: owner.startsWith('.') ? (css.byClassName[unescapeName(owner.slice(1))] ??= emptyComponentCss())
|
|
45
|
+
: (css.unconnectedCss[owner] ??= emptyComponentCss());
|
|
46
|
+
(componentCss[kind][property] ??= []).push({ value, media, selector: target ? `${componentSelector} @origami-target ${target}` : componentSelector, file, line, column });
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function readNodes(nodes, context, mixins, addLine) {
|
|
50
|
+
for (const node of nodes) {
|
|
51
|
+
if (node.type === 'decl' && context.selectors.length) {
|
|
52
|
+
for (const selector of context.selectors)
|
|
53
|
+
addLine(selector, propertyName(node.prop), node.value.trim(), context, context.appliedAt ?? placeOf(node));
|
|
54
|
+
}
|
|
55
|
+
else if (node.type === 'rule') {
|
|
56
|
+
readNodes(node.nodes, { ...context, selectors: nestSelectors(context.selectors, node.selectors) }, mixins, addLine);
|
|
57
|
+
}
|
|
58
|
+
else if (node.type === 'atrule') {
|
|
59
|
+
readAtRule(node, context, mixins, addLine);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function readAtRule(node, context, mixins, addLine) {
|
|
64
|
+
const params = node.params.trim();
|
|
65
|
+
if (node.name === 'mixin')
|
|
66
|
+
return;
|
|
67
|
+
if (node.name === 'apply')
|
|
68
|
+
return applyMixin(mixinName(params), { ...context, appliedAt: context.appliedAt ?? placeOf(node) }, mixins, addLine);
|
|
69
|
+
if (!node.nodes)
|
|
70
|
+
return;
|
|
71
|
+
if (node.name === 'media')
|
|
72
|
+
readNodes(node.nodes, { ...context, media: context.media ? `${context.media} and ${params}` : params }, mixins, addLine);
|
|
73
|
+
else if (node.name === 'origami-target')
|
|
74
|
+
readNodes(node.nodes, { ...context, target: params }, mixins, addLine);
|
|
75
|
+
else
|
|
76
|
+
readNodes(node.nodes, context, mixins, addLine);
|
|
77
|
+
}
|
|
78
|
+
/** A missing mixin, or one that applies itself, adds nothing. */
|
|
79
|
+
function applyMixin(name, context, mixins, addLine) {
|
|
80
|
+
const body = mixins.get(name);
|
|
81
|
+
if (!body || context.applying.includes(name))
|
|
82
|
+
return;
|
|
83
|
+
readNodes(body, { ...context, kind: 'theme', applying: [...context.applying, name] }, mixins, addLine);
|
|
84
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// A page from disk: the structure file the lint tool is linting, and its CSS and the theme read from the site's folder.
|
|
2
|
+
// A page reads only its own files and the theme, so linting only the pages whose files changed misses nothing.
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { buildPageModel } from "./build-page-model.js";
|
|
6
|
+
/** root: the site's folder. Nothing is cached here: the rules API keeps one page per file. */
|
|
7
|
+
export function readPage(root, structure) {
|
|
8
|
+
return buildPageModel(structure, (file) => readIfExists(path.join(root, file)));
|
|
9
|
+
}
|
|
10
|
+
function readIfExists(file) {
|
|
11
|
+
try {
|
|
12
|
+
return readFileSync(file, 'utf8');
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// structure.jsx (or .tsx) → components. Every JSX element inside Structure is a component.
|
|
2
|
+
// Only what the lint tool can't give a rule: each component's key and parentKey, to connect its CSS. The rest is in the tool's syntax tree.
|
|
3
|
+
import { parseSync } from 'oxc-parser';
|
|
4
|
+
import { findDeclaration, isIdAttribute, getLiteralClassNames, getLiteralId, getNestingContext, createSourceReader, walk, emptyComponentCss } from "./utils.js";
|
|
5
|
+
/** program: the same text's oxc syntax tree, when the lint tool already has it (oxlint's is the same shape); otherwise parsed here. */
|
|
6
|
+
export function readStructure(jsxFile, text, program = parseSync(jsxFile, text).program) {
|
|
7
|
+
const source = createSourceReader(text);
|
|
8
|
+
const elements = findElements(program);
|
|
9
|
+
return { components: toComponents(elements, assignKeys(elements, source), source) };
|
|
10
|
+
}
|
|
11
|
+
/** Every JSX element inside the `Structure` declaration, outermost first. */
|
|
12
|
+
function findElements(program) {
|
|
13
|
+
const structure = findDeclaration(program, 'Structure');
|
|
14
|
+
if (!structure)
|
|
15
|
+
return [];
|
|
16
|
+
const elements = [];
|
|
17
|
+
const siblingCounts = new Map();
|
|
18
|
+
walk(structure, [], (node, ancestors) => {
|
|
19
|
+
if (node.type !== 'JSXElement')
|
|
20
|
+
return;
|
|
21
|
+
const { parent, slot } = getNestingContext(ancestors);
|
|
22
|
+
const siblings = `${parent?.start}|${slot}`;
|
|
23
|
+
const index = siblingCounts.get(siblings) ?? 0;
|
|
24
|
+
siblingCounts.set(siblings, index + 1);
|
|
25
|
+
elements.push({ node: node, parent, slot, index });
|
|
26
|
+
});
|
|
27
|
+
return elements;
|
|
28
|
+
}
|
|
29
|
+
/** Its literal id when no other element uses it, else its path: 'comp-x/div[1]', 'comp-x/backgroundLayer:Background[0]'. */
|
|
30
|
+
function assignKeys(elements, source) {
|
|
31
|
+
const ids = elements.map(({ node }) => getLiteralId(node)).filter((id) => id !== null);
|
|
32
|
+
const isUnique = (id) => ids.indexOf(id) === ids.lastIndexOf(id);
|
|
33
|
+
const keys = new Map();
|
|
34
|
+
for (const { node, parent, slot, index } of elements) {
|
|
35
|
+
const id = getLiteralId(node);
|
|
36
|
+
const parentKey = parent ? keys.get(parent) : null;
|
|
37
|
+
const path = `${parentKey ? `${parentKey}/` : ''}${slot ? `${slot}:` : ''}${source.code(node.openingElement.name)}[${index}]`;
|
|
38
|
+
keys.set(node, id && isUnique(id) ? id : path);
|
|
39
|
+
}
|
|
40
|
+
return keys;
|
|
41
|
+
}
|
|
42
|
+
/** slot, tag and index make the path keys; they aren't kept (a rule reads an element's slot with rulesApi.getSlot). */
|
|
43
|
+
function toComponents(elements, keys, source) {
|
|
44
|
+
const components = {};
|
|
45
|
+
for (const { node, parent } of elements) {
|
|
46
|
+
const key = keys.get(node);
|
|
47
|
+
components[key] = {
|
|
48
|
+
key,
|
|
49
|
+
sourceStart: node.start,
|
|
50
|
+
id: getLiteralId(node),
|
|
51
|
+
classNames: getLiteralClassNames(node),
|
|
52
|
+
parentKey: parent ? keys.get(parent) : null,
|
|
53
|
+
css: emptyComponentCss(),
|
|
54
|
+
loc: source.loc(node.openingElement.attributes.find(isIdAttribute) ?? node.openingElement.name),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return components;
|
|
58
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** `@mixin --name { @result { … } }` (or without @result) → name → its body. No theme → no mixins. */
|
|
2
|
+
export function readMixins(theme) {
|
|
3
|
+
const mixins = new Map();
|
|
4
|
+
if (theme === undefined)
|
|
5
|
+
return mixins;
|
|
6
|
+
theme.walkAtRules('mixin', (mixin) => {
|
|
7
|
+
const first = mixin.first;
|
|
8
|
+
const body = first?.type === 'atrule' && first.name === 'result' ? first : mixin;
|
|
9
|
+
mixins.set(mixinName(mixin.params), body.nodes ?? []);
|
|
10
|
+
});
|
|
11
|
+
return mixins;
|
|
12
|
+
}
|
|
13
|
+
/** '--button-primary' and '--button-primary()' are the same mixin. */
|
|
14
|
+
export const mixinName = (params) => params.trim().replace(/\(\)$/, '');
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { CSS_KINDS } from "./model.js";
|
|
2
|
+
// ── syntax tree ──
|
|
3
|
+
/** Calls visit(node, ancestors) for every node. Skips `parent`, which oxlint's syntax tree has (oxc-parser's doesn't). */
|
|
4
|
+
export function walk(node, ancestors, visit) {
|
|
5
|
+
visit(node, ancestors);
|
|
6
|
+
ancestors.push(node);
|
|
7
|
+
for (const [key, value] of Object.entries(node)) {
|
|
8
|
+
if (key === 'parent')
|
|
9
|
+
continue;
|
|
10
|
+
for (const child of Array.isArray(value) ? value : [value])
|
|
11
|
+
if (typeof child?.type === 'string')
|
|
12
|
+
walk(child, ancestors, visit);
|
|
13
|
+
}
|
|
14
|
+
ancestors.pop();
|
|
15
|
+
}
|
|
16
|
+
/** Top-level statements, with `export` unwrapped. */
|
|
17
|
+
function topLevelDeclarations(program) {
|
|
18
|
+
return program.body.map((statement) => (statement.type === 'ExportNamedDeclaration' ? statement.declaration : statement));
|
|
19
|
+
}
|
|
20
|
+
export function findDeclaration(program, name) {
|
|
21
|
+
for (const node of topLevelDeclarations(program)) {
|
|
22
|
+
if (node?.type === 'FunctionDeclaration' && node.id?.name === name)
|
|
23
|
+
return node;
|
|
24
|
+
if (node?.type === 'VariableDeclaration') {
|
|
25
|
+
const declarator = node.declarations.find((d) => d.id.type === 'Identifier' && d.id.name === name);
|
|
26
|
+
if (declarator)
|
|
27
|
+
return declarator;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
// ── JSX ──
|
|
33
|
+
/** The nearest enclosing element, and the prop we came through if the element is passed in one. */
|
|
34
|
+
export function getNestingContext(ancestors) {
|
|
35
|
+
let slot = null;
|
|
36
|
+
for (const node of [...ancestors].reverse()) {
|
|
37
|
+
if (node.type === 'JSXAttribute' && slot === null)
|
|
38
|
+
slot = attributeName(node);
|
|
39
|
+
if (node.type === 'JSXElement')
|
|
40
|
+
return { parent: node, slot };
|
|
41
|
+
}
|
|
42
|
+
return { parent: null, slot: null };
|
|
43
|
+
}
|
|
44
|
+
export function attributeName(attr) {
|
|
45
|
+
if (attr.type !== 'JSXAttribute')
|
|
46
|
+
return null;
|
|
47
|
+
return attr.name.type === 'JSXIdentifier' ? attr.name.name : `${attr.name.namespace.name}:${attr.name.name.name}`;
|
|
48
|
+
}
|
|
49
|
+
export const isIdAttribute = (attr) => attributeName(attr) === 'id';
|
|
50
|
+
/** id="comp-x" → 'comp-x'; no id, or id={variable} → null. */
|
|
51
|
+
export const getLiteralId = (node) => getLiteralAttribute(node, 'id');
|
|
52
|
+
/** className="comp-x card" → ['comp-x', 'card']; no className, or className={variable} → []. */
|
|
53
|
+
export const getLiteralClassNames = (node) => getLiteralAttribute(node, 'className')?.split(/\s+/).filter(Boolean) ?? [];
|
|
54
|
+
function getLiteralAttribute(node, name) {
|
|
55
|
+
const attr = node.openingElement.attributes.find((attribute) => attributeName(attribute) === name);
|
|
56
|
+
const value = attr?.type === 'JSXAttribute' ? attr.value : null;
|
|
57
|
+
return value?.type === 'Literal' && typeof value.value === 'string' ? value.value : null;
|
|
58
|
+
}
|
|
59
|
+
export function createSourceReader(text) {
|
|
60
|
+
const lineStarts = [0];
|
|
61
|
+
for (let i = 0; i < text.length; i++)
|
|
62
|
+
if (text[i] === '\n')
|
|
63
|
+
lineStarts.push(i + 1);
|
|
64
|
+
const position = (offset) => {
|
|
65
|
+
let line = 0;
|
|
66
|
+
while (line + 1 < lineStarts.length && lineStarts[line + 1] <= offset)
|
|
67
|
+
line++;
|
|
68
|
+
return { line: line + 1, column: offset - lineStarts[line] };
|
|
69
|
+
};
|
|
70
|
+
return {
|
|
71
|
+
code: (node) => text.slice(node.start, node.end).replace(/\s+/g, ' ').trim(),
|
|
72
|
+
loc: (node) => ({ start: position(node.start), end: position(node.end) }),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
// ── CSS ──
|
|
76
|
+
/** No CSS of any kind. */
|
|
77
|
+
/** No prototype: a property written `constructor` or `__proto__` is a key like any other, not Object's. */
|
|
78
|
+
export const emptyComponentCss = () => Object.fromEntries(CSS_KINDS.map((kind) => [kind, Object.create(null)]));
|