@wix/origami-lint 0.0.0-stage → 1.34.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 +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
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** The report a rule gets on a file: a violation found from both sides (parent and child) is reported once. Neither oxlint nor ESLint does that. */
|
|
2
|
+
export function createReport(send) {
|
|
3
|
+
const reported = new Set();
|
|
4
|
+
return (problem) => {
|
|
5
|
+
const { start, end } = problem.loc;
|
|
6
|
+
const key = `${start.line}:${start.column}-${end.line}:${end.column} ${problem.message}`;
|
|
7
|
+
if (reported.has(key))
|
|
8
|
+
return;
|
|
9
|
+
reported.add(key);
|
|
10
|
+
send(problem);
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
/** A rule's callbacks as the tool calls them, with the node only: every entry of rule.callbacks, so there's no list of names to keep. */
|
|
14
|
+
export function createToolCallbacks(rule, { rulesApi, report, context }) {
|
|
15
|
+
return Object.fromEntries(Object.entries(rule.callbacks).map(([name, callback]) => {
|
|
16
|
+
// The tool calls each callback with the node its name is for, as Callbacks types it.
|
|
17
|
+
const check = callback;
|
|
18
|
+
return [name, (node) => check({ node, get rulesApi() { return rulesApi(); }, report, context })];
|
|
19
|
+
}));
|
|
20
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Part 3 — rules API: one linted file's page and the functions over it, and how a rule reports.
|
|
2
|
+
export { createRulesApi, getOrCreateRulesApi } from "./rules-api.js";
|
|
3
|
+
export { createCssFileReporter, describeAtRule, reportCssProblem } from "./report.js";
|
|
4
|
+
export { BUILDERS, DATA_KEY, GLOBAL_SECTIONS, INSTANCE_PROPS, LAYER_ATTRIBUTES, RESERVED_ATTRIBUTES, RESERVED_ENTRY_KEYS, RUNTIME_ONLY_BUILDERS, RUNTIME_PACKAGE, SECTIONS, SHARED_COMP_ID, STRUCTURE, WRAPPERS, bare, getAttribute, getExpression, getKey, getName, getProperties, getProperty, isOneEditAway, isStructure, ownerOf, } from "../shared/utils.js";
|
|
5
|
+
export { BREAKPOINT_AT_RULES, CONTENT_TARGET, getCssChildren, getDashedId, getSegmentClass, getSelectorSegments, isCssIdentifier, isPresetProperty, isSafeCssIdentifier, ORIGAMI_TARGETS, OVERFLOW_TARGET, PREAMBLE_LAYER } from "../shared/css.js";
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { FILE_START } from "../page-model/index.js";
|
|
2
|
+
/** Reports a problem on a line of a CSS file: at the page's start (CSS isn't a file the lint tool lints), the message led by `file:line: `. */
|
|
3
|
+
export function reportCssProblem(report, file, line, message) {
|
|
4
|
+
report({ loc: FILE_START, message: `${file}:${line}: ${message}` });
|
|
5
|
+
}
|
|
6
|
+
/** A reporter for one CSS file: each problem at the page's start, led by `file:line: ` with the node's line (reportCssProblem). */
|
|
7
|
+
export function createCssFileReporter(file, report) {
|
|
8
|
+
return (node, message) => reportCssProblem(report, file, node.source?.start?.line ?? 1, message);
|
|
9
|
+
}
|
|
10
|
+
/** An at-rule as a message writes it: '@media (max-width: 1px)', or '@media' with no params. */
|
|
11
|
+
export function describeAtRule({ name, params }) {
|
|
12
|
+
return params.trim() ? `@${name} ${params.trim()}` : `@${name}`;
|
|
13
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// The rules API of one linted file: its page, and the functions over it that the lint tool can't provide (mostly CSS),
|
|
2
|
+
// and reading the file's own code that more than one rule needs (what type a tag is, its name, its id, its slot, its children, the elements in its attributes, where a name is imported from, the name an import is exported under, what is TypeScript, the props an arrow destructures).
|
|
3
|
+
// One per file, shared by every rule on it. Only building its page reads from disk, once per file.
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { readPage } from "../page-model/index.js";
|
|
6
|
+
import { getAttributeElements as getHeldElements, getChildren, getId, getImport, getImportKind, getImportedName, getPropNames, getSlot, getTagName, getType, isTypeScript, WRAPPERS } from "../shared/utils.js";
|
|
7
|
+
import { readBreakpointName, readCustomMedia, readThemeTokens } from "../shared/css.js";
|
|
8
|
+
import { getEnclosingAtRules, isBaseDeclaration } from "./utils.js";
|
|
9
|
+
/** Theme lines come from @apply inside style.css, so style.css's own lines usually come after them. */
|
|
10
|
+
const CASCADE_ORDER = ['layout', 'theme', 'style'];
|
|
11
|
+
/** The rules API over a page already built. */
|
|
12
|
+
export function createRulesApi(page) {
|
|
13
|
+
const componentsBySourceStart = new Map(Object.values(page.components).map((component) => [component.sourceStart, component]));
|
|
14
|
+
const getParent = (component) => (component.parentKey ? (page.components[component.parentKey] ?? null) : null);
|
|
15
|
+
const getCss = (component, property) => CASCADE_ORDER.flatMap((kind) => component.css[kind][property] ?? []);
|
|
16
|
+
const isWrapper = (element) => {
|
|
17
|
+
const type = getType(element, getProgram(element));
|
|
18
|
+
return type !== null && WRAPPERS.includes(type);
|
|
19
|
+
};
|
|
20
|
+
const getChildElements = (node) => getChildren(node).flatMap((child) => {
|
|
21
|
+
if (child.type !== 'JSXElement')
|
|
22
|
+
return [];
|
|
23
|
+
return isWrapper(child) ? getChildElements(child) : [child];
|
|
24
|
+
});
|
|
25
|
+
const getAttributeElements = (node) => getHeldElements(node).flatMap(({ element, attribute }) => (isWrapper(element) ? getChildElements(element).map((inner) => ({ element: inner, attribute })) : [{ element, attribute }]));
|
|
26
|
+
return {
|
|
27
|
+
page,
|
|
28
|
+
// A JSX element starts where its component starts.
|
|
29
|
+
getComponent: (node) => componentsBySourceStart.get(node.range[0]) ?? null,
|
|
30
|
+
getParent,
|
|
31
|
+
getRoot: () => Object.values(page.components).find((component) => !getParent(component)) ?? null,
|
|
32
|
+
getCss,
|
|
33
|
+
getBaseCss: (component, property) => getCss(component, property).filter(isBaseDeclaration).at(-1),
|
|
34
|
+
getThemeTokens: () => readThemeTokens(page.css.files.theme),
|
|
35
|
+
getCustomMedia: (kind) => readCustomMedia(page.css.files[kind]),
|
|
36
|
+
getBreakpointName: readBreakpointName,
|
|
37
|
+
getEnclosingAtRules,
|
|
38
|
+
getType: (node) => getType(node, getProgram(node)),
|
|
39
|
+
getTagName,
|
|
40
|
+
getId,
|
|
41
|
+
getChildren,
|
|
42
|
+
getChildElements,
|
|
43
|
+
getAttributeElements,
|
|
44
|
+
getSlot,
|
|
45
|
+
getImport: (node) => getImport(node.type === 'JSXElement' ? getTagName(node).split('.')[0] : node.name, getProgram(node)),
|
|
46
|
+
getImportKind,
|
|
47
|
+
getImportedName,
|
|
48
|
+
isTypeScript,
|
|
49
|
+
getPropNames,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const rulesApis = new Map();
|
|
53
|
+
/** This file's rules API: its page is built on the first call for the file (CSS and theme from disk), then shared by every rule. */
|
|
54
|
+
export function getOrCreateRulesApi(file) {
|
|
55
|
+
let rulesApi = rulesApis.get(file.filename);
|
|
56
|
+
if (!rulesApi)
|
|
57
|
+
rulesApis.set(file.filename, (rulesApi = createRulesApi(readPage(file.cwd, { jsxFile: getRelativeJsxPath(file), text: file.text, program: file.program }))));
|
|
58
|
+
return rulesApi;
|
|
59
|
+
}
|
|
60
|
+
/** The tool gives an absolute path; pages are keyed by the path from the site root. */
|
|
61
|
+
function getRelativeJsxPath({ cwd, filename }) {
|
|
62
|
+
return path.relative(cwd, filename).split(path.sep).join('/');
|
|
63
|
+
}
|
|
64
|
+
/** The file a node is in: the top of its parents. */
|
|
65
|
+
function getProgram(node) {
|
|
66
|
+
let top = node.parent;
|
|
67
|
+
while (top.type !== 'Program')
|
|
68
|
+
top = top.parent;
|
|
69
|
+
return top;
|
|
70
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** On the component itself, outside any @media and @origami-target: applies by default. */
|
|
2
|
+
export const isBaseDeclaration = (decl) => !decl.media && decl.selector === '&';
|
|
3
|
+
/** The at-rules around a CSS node, outer to inner (`@media`, `@scope`, `@layer`, `@mixin`…); [] at the file's top level. */
|
|
4
|
+
export function getEnclosingAtRules(node) {
|
|
5
|
+
const atRules = [];
|
|
6
|
+
for (let parent = node.parent; parent && parent.type !== 'root'; parent = parent.parent) {
|
|
7
|
+
if (parent.type === 'atrule')
|
|
8
|
+
atRules.unshift(parent);
|
|
9
|
+
}
|
|
10
|
+
return atRules;
|
|
11
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/** The `@layer` the export writes at the top of every CSS file (the spx preamble); the import skips it. */
|
|
2
|
+
export const PREAMBLE_LAYER = 'spx-preamble';
|
|
3
|
+
/** The at-rules the import reads as a breakpoint block: `@media`, and `@scope (.owner) { @media … }`. */
|
|
4
|
+
export const BREAKPOINT_AT_RULES = ['media', 'scope'];
|
|
5
|
+
/** The `@origami-target` blocks a layout rule can hold, an InternalContainer's inner wrappers (the converter's LAYOUT_TARGETS): its
|
|
6
|
+
* content wrapper (the container's layout) and its overflow wrapper (its scrolling). */
|
|
7
|
+
export const CONTENT_TARGET = 'internal-container-content-wrapper';
|
|
8
|
+
export const OVERFLOW_TARGET = 'internal-container-overflow-wrapper';
|
|
9
|
+
export const ORIGAMI_TARGETS = [CONTENT_TARGET, OVERFLOW_TARGET];
|
|
10
|
+
/** The import's mixin id and `@apply` target: `--` and no space (astParser.ts `MIXIN_ID_PATTERN`). */
|
|
11
|
+
const DASHED_ID = /^--(\S+)$/;
|
|
12
|
+
/** The import's identifier (cssSelectors.ts `SAFE_CSS_IDENT`: `^-?[_a-zA-Z]+[_a-zA-Z0-9-]*$`), written so it can't backtrack: the same strings. */
|
|
13
|
+
const CSS_IDENT = /^-?[_a-zA-Z][_a-zA-Z0-9-]*$/;
|
|
14
|
+
/** Names the import refuses as an identifier: they'd be an object's own keys (cssSelectors.ts `RESERVED_OBJECT_KEYS`). */
|
|
15
|
+
const RESERVED_IDENTIFIERS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
16
|
+
// cssSelectors.ts parseSegment's patterns
|
|
17
|
+
const BASE_CLASS = /^\.[^:[\]"]+/;
|
|
18
|
+
const FUNCTIONAL_PSEUDO = /^:[A-Za-z-]+\([^)]*\)/;
|
|
19
|
+
/** A class after the first: a state (`&.onSale`). */
|
|
20
|
+
const STATE_CLASS = /^\.[^.:[\]"]+/;
|
|
21
|
+
const PSEUDO_CLASS = /^:[A-Za-z-]+/;
|
|
22
|
+
const ATTRIBUTE = /^\[[^\]]*\]/;
|
|
23
|
+
/** Every `--name: value` in a file, wherever it's declared, in order. No file → none. */
|
|
24
|
+
export function readThemeTokens(file) {
|
|
25
|
+
const tokens = [];
|
|
26
|
+
file?.walkDecls(/^--/, ({ prop, value, source }) => {
|
|
27
|
+
tokens.push({ name: prop, value: value.trim(), line: source?.start?.line ?? 1 });
|
|
28
|
+
});
|
|
29
|
+
return tokens;
|
|
30
|
+
}
|
|
31
|
+
/** Every `@custom-media --name (query)` at a file's top level, in order, as the import reads them; theme.css's are the site's breakpoints. No file → none. */
|
|
32
|
+
export function readCustomMedia(file) {
|
|
33
|
+
if (file === undefined)
|
|
34
|
+
return [];
|
|
35
|
+
return file.nodes.flatMap((node) => (node.type === 'atrule' && node.name === 'custom-media' ? [{ ...parseCustomMedia(node.params), line: node.source?.start?.line ?? 1 }] : []));
|
|
36
|
+
}
|
|
37
|
+
/** A named breakpoint, `(--tablet)`: one dashed name in parentheses, spaces aside. */
|
|
38
|
+
const NAMED_QUERY = /^\(\s*(--[\w-]+)\s*\)$/;
|
|
39
|
+
/** A `@media` query's params → the breakpoint name it uses: '(--tablet)' → '--tablet'; null for a range or anything that isn't one name. */
|
|
40
|
+
export const readBreakpointName = (query) => NAMED_QUERY.exec(query.trim())?.[1] ?? null;
|
|
41
|
+
/** `--tablet (max-width: 1023px)` → its name and its query, as written (the import compares the text). A malformed declaration keeps what it has: `mobile (…)` → name 'mobile'; `--phone` → query ''. */
|
|
42
|
+
function parseCustomMedia(params) {
|
|
43
|
+
const [, name = '', query = ''] = /^(\S*)\s*([\s\S]*)$/.exec(params.trim()) ?? [];
|
|
44
|
+
return { name, query };
|
|
45
|
+
}
|
|
46
|
+
/** A container's children, comments aside; none for a node with no block. */
|
|
47
|
+
export const getCssChildren = (node) => (node.nodes ?? []).filter((child) => child.type !== 'comment');
|
|
48
|
+
/** Whether a property picks a preset (css/utils.ts `isPresetProperty`): `--preset` (the rule's own element) or `--preset-<part path>`. */
|
|
49
|
+
export const isPresetProperty = (prop) => prop === '--preset' || prop.startsWith('--preset-');
|
|
50
|
+
/** The id a `@mixin` or `@apply` names: '--btn' → 'btn' (spaces around aside); undefined when its params aren't `--` and one word. */
|
|
51
|
+
export const getDashedId = (params) => DASHED_ID.exec(params.trim())?.[1];
|
|
52
|
+
/** Whether the import reads this as an identifier (cssSelectors.ts `isCssIdentifier`): letters, digits, - and _, not starting with a digit. */
|
|
53
|
+
export const isCssIdentifier = (id) => CSS_IDENT.test(id);
|
|
54
|
+
/** Whether the import takes this as a name (a mixin id, a preset path's part; cssSelectors.ts `isSafeCssIdentifier`): an identifier, not a reserved name. */
|
|
55
|
+
export const isSafeCssIdentifier = (id) => !RESERVED_IDENTIFIERS.has(id) && isCssIdentifier(id);
|
|
56
|
+
/** The selector's parts, split on spaces outside brackets and quotes, a combinator kept with its sides (cssSelectors.ts splitSegments); null for a list or an unbalanced bracket: the selectors rows'. */
|
|
57
|
+
export function getSelectorSegments(selector) {
|
|
58
|
+
const segments = [];
|
|
59
|
+
let current = '';
|
|
60
|
+
let depth = 0;
|
|
61
|
+
let inQuotes = false;
|
|
62
|
+
for (const char of selector) {
|
|
63
|
+
if (inQuotes)
|
|
64
|
+
inQuotes = char !== '"';
|
|
65
|
+
else if (char === '"')
|
|
66
|
+
inQuotes = true;
|
|
67
|
+
else if (char === '[' || char === '(')
|
|
68
|
+
depth += 1;
|
|
69
|
+
else if (char === ']' || char === ')')
|
|
70
|
+
depth -= 1;
|
|
71
|
+
else if (char === ',' && depth === 0)
|
|
72
|
+
return null;
|
|
73
|
+
if (depth < 0)
|
|
74
|
+
return null;
|
|
75
|
+
if (/\s/.test(char) && depth === 0 && !inQuotes) {
|
|
76
|
+
if (current)
|
|
77
|
+
segments.push(current);
|
|
78
|
+
current = '';
|
|
79
|
+
}
|
|
80
|
+
else
|
|
81
|
+
current += char;
|
|
82
|
+
}
|
|
83
|
+
if (inQuotes || depth > 0)
|
|
84
|
+
return null;
|
|
85
|
+
if (current)
|
|
86
|
+
segments.push(current);
|
|
87
|
+
return segments.reduce((merged, segment) => {
|
|
88
|
+
const previous = merged.at(-1);
|
|
89
|
+
if (previous !== undefined && (/[>+~]$/.test(previous) || /^[>+~]/.test(segment)))
|
|
90
|
+
merged[merged.length - 1] = `${previous} ${segment}`;
|
|
91
|
+
else
|
|
92
|
+
merged.push(segment);
|
|
93
|
+
return merged;
|
|
94
|
+
}, []);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* A segment's class, its states taken out, read as cssSelectors.ts parseSegment reads it (0.83.0): `&:hover` → `&` (the mixin's own
|
|
98
|
+
* element); `.label:hover` → `.label`; `.label.open` → `.label`. A functional pseudo-class (`:not(:hover)`) or an attribute
|
|
99
|
+
* (`[data-state~="open"]`) right after the class is part of the class, its brackets whole. Null for a segment the import can't parse:
|
|
100
|
+
* a part that isn't a class (`[data-ie-name="x"]`, `:hover` alone, `:root`), or `&` followed by anything but a state (`&[x]`, `&:not(…)`).
|
|
101
|
+
*/
|
|
102
|
+
export function getSegmentClass(segment) {
|
|
103
|
+
const base = segment.startsWith('&') ? '&' : BASE_CLASS.exec(segment)?.[0];
|
|
104
|
+
if (base === undefined)
|
|
105
|
+
return null;
|
|
106
|
+
const isClass = base !== '&';
|
|
107
|
+
let kept = base;
|
|
108
|
+
let rest = segment.slice(base.length);
|
|
109
|
+
let seenState = false;
|
|
110
|
+
while (rest) {
|
|
111
|
+
const functional = isClass && !seenState ? FUNCTIONAL_PSEUDO.exec(rest) : null;
|
|
112
|
+
const state = functional ? null : (STATE_CLASS.exec(rest) ?? PSEUDO_CLASS.exec(rest));
|
|
113
|
+
const attribute = functional || state || seenState || !isClass ? null : ATTRIBUTE.exec(rest);
|
|
114
|
+
const token = functional ?? state ?? attribute;
|
|
115
|
+
if (!token)
|
|
116
|
+
return null;
|
|
117
|
+
if (state)
|
|
118
|
+
seenState = true;
|
|
119
|
+
else
|
|
120
|
+
kept += token[0];
|
|
121
|
+
rest = rest.slice(token[0].length);
|
|
122
|
+
}
|
|
123
|
+
return kept;
|
|
124
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
/** The site: the folder the lint was started from (`yarn lint` at the site's root). */
|
|
4
|
+
export const ROOT = process.cwd();
|
|
5
|
+
export const LINT_DIR = path.join(ROOT, '.lint');
|
|
6
|
+
/** The lint's own package.json: the same two levels up from lint/ (source) and dist/ (published). */
|
|
7
|
+
export const LINT_PACKAGE_FILE = path.resolve(import.meta.dirname, '../../package.json');
|
|
8
|
+
const LINT_PACKAGE = 'origami-lint';
|
|
9
|
+
const LINT_PACKAGE_NAME = `@wix/${LINT_PACKAGE}`;
|
|
10
|
+
/** The lint runs only from the folder whose package.json adds it, which holds the site's git repository. */
|
|
11
|
+
export function checkStartedAtSiteRoot() {
|
|
12
|
+
const problem = siteRootProblem();
|
|
13
|
+
if (!problem)
|
|
14
|
+
return;
|
|
15
|
+
console.error(`${LINT_PACKAGE}: ${problem}`);
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
function siteRootProblem() {
|
|
19
|
+
const packageFile = path.join(ROOT, 'package.json');
|
|
20
|
+
if (!existsSync(packageFile) || !addsTheLint(JSON.parse(readFileSync(packageFile, 'utf8')))) {
|
|
21
|
+
return `run it from the site's root, the folder whose package.json has ${LINT_PACKAGE_NAME} (started in ${ROOT})`;
|
|
22
|
+
}
|
|
23
|
+
if (!existsSync(path.join(ROOT, '.git'))) {
|
|
24
|
+
return `no git repository in ${ROOT}. The lint keeps the last passing version in the site's repository: run git init here.`;
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const addsTheLint = ({ dependencies, devDependencies }) => LINT_PACKAGE_NAME in { ...dependencies, ...devDependencies };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Where things sit in the exported site, from the site's root. The one place that knows its folders (docs/export-site-tree.md).
|
|
2
|
+
export const SITE_SRC = 'packages/site-app/src';
|
|
3
|
+
/** One page per folder; routes can be nested (about-us/team). */
|
|
4
|
+
export const PAGES_FOLDER = `${SITE_SRC}/pages`;
|
|
5
|
+
/** What every page shares: the global components, the menus, the theme. */
|
|
6
|
+
export const GLOBAL_FOLDER = `${SITE_SRC}/global`;
|
|
7
|
+
/** Inside src/: one global component per folder (the header, the footer, any other shared section). A page imports one from here. */
|
|
8
|
+
export const GLOBAL_COMPONENTS = 'global/components';
|
|
9
|
+
export const GLOBAL_COMPONENTS_FOLDER = `${SITE_SRC}/${GLOBAL_COMPONENTS}`;
|
|
10
|
+
/** Inside src/: one file per menu. A page imports a menu from here (`global/menus/<menu id>`). */
|
|
11
|
+
export const GLOBAL_MENUS = 'global/menus';
|
|
12
|
+
export const THEME_FILE = `${GLOBAL_FOLDER}/themes/theme.css`;
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { GLOBAL_COMPONENTS, GLOBAL_MENUS } from "./site-files-tree-mapping.js";
|
|
2
|
+
/** Any section: rules that don't care which one. */
|
|
3
|
+
export const SECTIONS = ['section', 'header', 'footer'];
|
|
4
|
+
/** A page's reference to a global component (`<HeaderSection_x sharedCompId="…" />`). */
|
|
5
|
+
export const GLOBAL_SECTIONS = ['globalHeader', 'globalFooter', 'globalSection'];
|
|
6
|
+
/** A tag that wraps one component and isn't one itself: `<Remove when={…}>`, `<Interaction interactKey="…">`. */
|
|
7
|
+
export const WRAPPERS = ['remove', 'interaction'];
|
|
8
|
+
/** The name a structure file exports its tree under: `export const Structure = () => (…)`. */
|
|
9
|
+
export const STRUCTURE = 'Structure';
|
|
10
|
+
/** The props a global component's Structure may take: its instance identity, `({ id, className })` (#300). */
|
|
11
|
+
export const INSTANCE_PROPS = ['id', 'className'];
|
|
12
|
+
const SYSTEM = '@wix/origami-system-components';
|
|
13
|
+
/** The runtime package: the builders (`ctx`, `fn`…) and the `<Remove>` wrapper. */
|
|
14
|
+
export const RUNTIME_PACKAGE = '@wix/origami-runtime';
|
|
15
|
+
/** The runtime's builders: the converter's BINDINGS_BUILDER_ORDER without `Remove`, which is a tag (reservedAttributes.ts). */
|
|
16
|
+
export const BUILDERS = [
|
|
17
|
+
'all', 'ctx', 'fn', 'is', 'raw', 'state',
|
|
18
|
+
'textEnum', 'phone', 'email', 'webUrl', 'hostname', 'regex', 'guid', 'direction', 'localDate', 'localTime', 'localDateTime', 'dateTime',
|
|
19
|
+
];
|
|
20
|
+
/** The six builders no other package exports under these names; `email`, `regex`… may well come from a library for fn(). */
|
|
21
|
+
export const RUNTIME_ONLY_BUILDERS = ['all', 'ctx', 'fn', 'is', 'raw', 'state'];
|
|
22
|
+
/** The attributes the converter reads on any component as something other than its data (reservedAttributes.ts RESERVED_ATTRIBUTES, converter 0.81: `type` is data since). */
|
|
23
|
+
export const RESERVED_ATTRIBUTES = [
|
|
24
|
+
'id', '_id', 'data', 'props', 'skin', 'styleId', 'className', 'documentType', 'style', 'slot', 'bindings', 'elementProps',
|
|
25
|
+
];
|
|
26
|
+
/** The attribute of a page's reference to a global component (SHARED_COMP_ID_ATTR); on any other tag it's data. */
|
|
27
|
+
export const SHARED_COMP_ID = 'sharedCompId';
|
|
28
|
+
/** The keys of an elementProps entry the converter reads as something other than the part's data (RESERVED_INNER_ELEMENT_ENTRY_KEYS, converter 0.80). */
|
|
29
|
+
export const RESERVED_ENTRY_KEYS = ['data', 'slots', 'elementProps', 'bindings', 'className'];
|
|
30
|
+
/** A data key is a JavaScript name (dataKeys.ts). */
|
|
31
|
+
export const DATA_KEY = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
32
|
+
/** By import source; the wrappers also by the imported name, since their packages export other things (`Show`, `Interact`). */
|
|
33
|
+
const TYPE_BY_IMPORT_SOURCE = [
|
|
34
|
+
[new RegExp(`^${SYSTEM}/(?:layout/)?Page$`), 'page'],
|
|
35
|
+
[new RegExp(`^${SYSTEM}/layout/Header$`), 'headerLandmark'],
|
|
36
|
+
[new RegExp(`^${SYSTEM}/layout/Footer$`), 'footerLandmark'],
|
|
37
|
+
[new RegExp(`^${SYSTEM}/(?:layout/)?Section$`), 'section'],
|
|
38
|
+
[new RegExp(`^${SYSTEM}/(?:layout/)?HeaderSection$`), 'header'],
|
|
39
|
+
[new RegExp(`^${SYSTEM}/(?:layout/)?FooterSection$`), 'footer'],
|
|
40
|
+
[new RegExp(`^${SYSTEM}/InternalContainer$`), 'innerElement'],
|
|
41
|
+
// A global component's folder is `<Type>_<id>`: HeaderSection_mb7ogqqn.
|
|
42
|
+
[new RegExp(`/${GLOBAL_COMPONENTS}/(?:HeaderSection|Header)_[^/]+$`), 'globalHeader'],
|
|
43
|
+
[new RegExp(`/${GLOBAL_COMPONENTS}/(?:FooterSection|Footer)_[^/]+$`), 'globalFooter'],
|
|
44
|
+
[new RegExp(`/${GLOBAL_COMPONENTS}/[^/]+$`), 'globalSection'],
|
|
45
|
+
[new RegExp(`^${RUNTIME_PACKAGE}$`), 'remove', 'Remove'],
|
|
46
|
+
[/^@wix\/interact\/react$/, 'interaction', 'Interaction'],
|
|
47
|
+
];
|
|
48
|
+
const KIND_BY_IMPORT_SOURCE = [
|
|
49
|
+
[new RegExp(`^${RUNTIME_PACKAGE}$`), 'runtime'],
|
|
50
|
+
[/^@wix\/sdk$/, 'sdk'],
|
|
51
|
+
[/^@wix\/interact\/react$/, 'interactReact'],
|
|
52
|
+
[/^@wix\/interact(\/|$)/, 'interact'],
|
|
53
|
+
[/^@wix\/motion-presets$/, 'motionPresets'],
|
|
54
|
+
[new RegExp(`^(\\.\\.?/)*${GLOBAL_MENUS}(/|$)`), 'menu'],
|
|
55
|
+
[/^document$/, 'core'],
|
|
56
|
+
[/^document\//, 'document'],
|
|
57
|
+
];
|
|
58
|
+
/** <Band> with `import { Section as Band } from '@wix/origami-system-components/Section'` → 'section'. `<Band.Item>` is Band's. A wrapper is the named export: `<Show>` from the runtime is null. */
|
|
59
|
+
export function getType(element, program) {
|
|
60
|
+
const found = getImport(getTagName(element).split('.')[0], program);
|
|
61
|
+
const entry = found && TYPE_BY_IMPORT_SOURCE.find(([pattern]) => pattern.test(found.source));
|
|
62
|
+
if (!entry)
|
|
63
|
+
return null;
|
|
64
|
+
const [, type, imported] = entry;
|
|
65
|
+
return imported === undefined || found.imported === imported ? type : null;
|
|
66
|
+
}
|
|
67
|
+
/** What an import's source is: 'runtime' for @wix/origami-runtime, 'interact' for @wix/interact/…; null for anything else. */
|
|
68
|
+
export const getImportKind = (source) => KIND_BY_IMPORT_SOURCE.find(([pattern]) => pattern.test(source))?.[1] ?? null;
|
|
69
|
+
/** Where a name of the file comes from; null when no import brings it in. */
|
|
70
|
+
export function getImport(name, program) {
|
|
71
|
+
for (const { source, specifiers } of program.body.filter(isImport)) {
|
|
72
|
+
const specifier = specifiers.find(({ local }) => local.name === name);
|
|
73
|
+
if (!specifier)
|
|
74
|
+
continue;
|
|
75
|
+
return { imported: getImportedName(specifier), source: source.value, kind: getImportKind(source.value) };
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
/** The name a specifier is exported under: `import X` → 'default', `import * as X` → '*', `import { Remove as Hide }` → 'Remove', `import { 'a-b' as ab }` → 'a-b'. */
|
|
80
|
+
export function getImportedName({ type, imported }) {
|
|
81
|
+
if (type === 'ImportDefaultSpecifier')
|
|
82
|
+
return 'default';
|
|
83
|
+
if (type === 'ImportNamespaceSpecifier')
|
|
84
|
+
return '*';
|
|
85
|
+
return typeof imported?.name === 'string' ? imported.name : String(imported?.value);
|
|
86
|
+
}
|
|
87
|
+
/** 'Section', 'Band.Item', 'svg:rect'. */
|
|
88
|
+
export function getTagName(element) {
|
|
89
|
+
const text = (name) => {
|
|
90
|
+
if (name.type === 'JSXIdentifier')
|
|
91
|
+
return name.name;
|
|
92
|
+
if (name.type === 'JSXNamespacedName')
|
|
93
|
+
return `${name.namespace.name}:${name.name.name}`;
|
|
94
|
+
return `${text(name.object)}.${name.property.name}`;
|
|
95
|
+
};
|
|
96
|
+
return text(element.openingElement.name);
|
|
97
|
+
}
|
|
98
|
+
/** An attribute's value as a string: x="a" and x={'a'} → 'a'; no value, or any other expression → null. */
|
|
99
|
+
export function getLiteralString(value) {
|
|
100
|
+
const literal = value?.type === 'JSXExpressionContainer' ? value.expression : value;
|
|
101
|
+
return literal?.type === 'Literal' && typeof literal.value === 'string' ? literal.value : null;
|
|
102
|
+
}
|
|
103
|
+
/** id="sec" and id={'sec'} → 'sec'; no id, or id={variable} → null. */
|
|
104
|
+
export function getId(element) {
|
|
105
|
+
return getLiteralString(element.openingElement.attributes.find(isIdAttribute)?.value);
|
|
106
|
+
}
|
|
107
|
+
/** slot="footer" or slot={'footer'} → 'footer'; no slot, or slot={variable} → null. */
|
|
108
|
+
export function getSlot(element) {
|
|
109
|
+
return getLiteralString(element.openingElement.attributes.find(isSlotAttribute)?.value);
|
|
110
|
+
}
|
|
111
|
+
/** The attributes that hold an element's layers: drawn behind it, and pinned to it. */
|
|
112
|
+
export const LAYER_ATTRIBUTES = ['backgroundLayer', 'pinnedLayer'];
|
|
113
|
+
/** The elements this one holds in its attributes (`content={<X />}`, or inside the attribute's object or array, `elementProps` too), each with the attribute it is in. Not its children, and not the elements inside those elements. */
|
|
114
|
+
export function getAttributeElements(owner) {
|
|
115
|
+
const elementsIn = (value) => {
|
|
116
|
+
switch (value?.type) {
|
|
117
|
+
case 'JSXElement': return [value];
|
|
118
|
+
case 'JSXExpressionContainer': return elementsIn(value.expression);
|
|
119
|
+
case 'ObjectExpression': return value.properties.flatMap((property) => (property.type === 'Property' ? elementsIn(property.value) : []));
|
|
120
|
+
case 'ArrayExpression': return value.elements.flatMap(elementsIn);
|
|
121
|
+
default: return [];
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
return owner.openingElement.attributes.filter(isAttribute).flatMap((attribute) => elementsIn(attribute.value).map((element) => ({ element, attribute: attribute })));
|
|
125
|
+
}
|
|
126
|
+
/** Elements, text and {expressions}, in order. Whitespace and JSX comments are skipped. */
|
|
127
|
+
export function getChildren(element) {
|
|
128
|
+
return element.children.filter((child) => {
|
|
129
|
+
if (child.type === 'JSXText')
|
|
130
|
+
return String(child.value).trim() !== '';
|
|
131
|
+
return !(child.type === 'JSXExpressionContainer' && child.expression?.type === 'JSXEmptyExpression');
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
/** The element's attribute with this name (`a:b` for a namespaced one); undefined without one. */
|
|
135
|
+
export const getAttribute = (element, name) => element.openingElement.attributes.find((attribute) => attribute.type === 'JSXAttribute' && getName(attribute) === name);
|
|
136
|
+
/** The attribute's name: `type`, or `a:b` for a namespaced one. */
|
|
137
|
+
export const getName = (attribute) => (attribute.name.type === 'JSXIdentifier' ? attribute.name.name : `${attribute.name.namespace.name}:${attribute.name.name.name}`);
|
|
138
|
+
/** What the attribute's {…} holds, without parentheses; null for a string value, no value, or `{}`. */
|
|
139
|
+
export function getExpression(attribute) {
|
|
140
|
+
const value = attribute?.value;
|
|
141
|
+
if (value?.type !== 'JSXExpressionContainer' || value.expression.type === 'JSXEmptyExpression')
|
|
142
|
+
return null;
|
|
143
|
+
return bare(value.expression);
|
|
144
|
+
}
|
|
145
|
+
/** The node inside its parentheses: `((x))` → `x`. */
|
|
146
|
+
export function bare(node) {
|
|
147
|
+
return node.type === 'ParenthesizedExpression' ? bare(node.expression) : node;
|
|
148
|
+
}
|
|
149
|
+
/** An object's `key: value` entries: no spread, no computed key. */
|
|
150
|
+
export const getProperties = (object) => object.properties.filter((property) => property.type === 'Property' && !property.computed);
|
|
151
|
+
/** The object's entry with this key; undefined without one. */
|
|
152
|
+
export const getProperty = (object, key) => getProperties(object).find((property) => getKey(property) === key);
|
|
153
|
+
/** null for a number key: the import reads only name and string keys, and fails on any other. */
|
|
154
|
+
export function getKey(property) {
|
|
155
|
+
if (property.key.type === 'Identifier')
|
|
156
|
+
return property.key.name;
|
|
157
|
+
return property.key.type === 'Literal' && typeof property.key.value === 'string' ? property.key.value : null;
|
|
158
|
+
}
|
|
159
|
+
/** The function `export const Structure = () => …` at the top of the file. Any `Structure` declarator counts, as the page model reads it; no-free-code reports a wrong form. */
|
|
160
|
+
export function isStructure(node) {
|
|
161
|
+
const declarator = node.parent;
|
|
162
|
+
return declarator?.type === 'VariableDeclarator' && declarator.id?.type === 'Identifier' && declarator.id.name === STRUCTURE
|
|
163
|
+
&& declarator.parent?.parent?.type === 'ExportNamedDeclaration' && declarator.parent.parent.parent?.type === 'Program';
|
|
164
|
+
}
|
|
165
|
+
/** A component in a message: `<Image>` img, or `<Image>` when it has no id. */
|
|
166
|
+
export function ownerOf(element) {
|
|
167
|
+
const id = getId(element);
|
|
168
|
+
return `\`<${getTagName(element)}>\`${id ? ` ${id}` : ''}`;
|
|
169
|
+
}
|
|
170
|
+
/** One letter added, removed or changed, as the converter counts it (dataKeys.ts, reservedAttrNearMiss). */
|
|
171
|
+
export function isOneEditAway(name, target) {
|
|
172
|
+
const delta = name.length - target.length;
|
|
173
|
+
if (name === target || Math.abs(delta) > 1)
|
|
174
|
+
return false;
|
|
175
|
+
let edits = 0;
|
|
176
|
+
for (let i = 0, j = 0; i < name.length || j < target.length;) {
|
|
177
|
+
if (name[i] === target[j]) {
|
|
178
|
+
i++;
|
|
179
|
+
j++;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (edits === 1)
|
|
183
|
+
return false;
|
|
184
|
+
edits = 1;
|
|
185
|
+
if (delta >= 0)
|
|
186
|
+
i++;
|
|
187
|
+
if (delta <= 0)
|
|
188
|
+
j++;
|
|
189
|
+
}
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
/** A TypeScript node (`TSTypeAnnotation`, `TSAsExpression`…), an `import type` / `export type` / `{ type Y }`, or a `declare` / `abstract` declaration. JavaScript, null and undefined: false. */
|
|
193
|
+
export function isTypeScript(node) {
|
|
194
|
+
return !!node && (node.type.startsWith('TS') || node.importKind === 'type' || node.exportKind === 'type' || node.declare === true || node.abstract === true);
|
|
195
|
+
}
|
|
196
|
+
/** The names a function destructures from its one parameter: `({ id, className })` → ['id', 'className']. A property with a default, a new name, a computed key or `...rest` is null. `()` and `({})` → []; any other parameters → null. */
|
|
197
|
+
export function getPropNames(fn) {
|
|
198
|
+
if (fn.params.length === 0)
|
|
199
|
+
return [];
|
|
200
|
+
const [param] = fn.params;
|
|
201
|
+
if (fn.params.length > 1 || param.type !== 'ObjectPattern')
|
|
202
|
+
return null;
|
|
203
|
+
return (param.properties ?? []).map(({ type, computed, key, value }) => type === 'Property' && !computed && key?.type === 'Identifier' && value?.type === 'Identifier' && value.name === key.name ? String(key.name) : null);
|
|
204
|
+
}
|
|
205
|
+
const isImport = (statement) => statement.type === 'ImportDeclaration';
|
|
206
|
+
const isAttribute = (attribute) => attribute.type === 'JSXAttribute';
|
|
207
|
+
const isIdAttribute = (attribute) => isAttribute(attribute) && attribute.name.type === 'JSXIdentifier' && attribute.name.name === 'id';
|
|
208
|
+
const isSlotAttribute = (attribute) => isAttribute(attribute) && attribute.name.type === 'JSXIdentifier' && attribute.name.name === 'slot';
|
package/package.json
CHANGED
|
@@ -1,6 +1,55 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wix/origami-lint",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "1.34.2",
|
|
4
|
+
"description": "Offline lint for an exported Origami site: lints the pages that changed since the last passing run",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"origami-lint": "dist/lint.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md",
|
|
12
|
+
"!**/*.tsbuildinfo"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": "^20.19.0 || >=22.12.0"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "rm -rf dist && tsc -p . && tsc -p tsconfig.build.json",
|
|
19
|
+
"lint": "oxlint .",
|
|
20
|
+
"typecheck": "tsc -p .",
|
|
21
|
+
"test": "node --test 'test/*.test.ts'"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@wix/origami-fonts": "1.0.1",
|
|
25
|
+
"oxc-parser": "^0.151.0",
|
|
26
|
+
"oxlint": "^1.39.0",
|
|
27
|
+
"postcss": "^8.5.3"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "^22.0.0",
|
|
31
|
+
"typescript": "^6.0.3"
|
|
32
|
+
},
|
|
33
|
+
"publishUnscoped": false,
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"registry": "https://registry.npmjs.org/",
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"wix": {
|
|
39
|
+
"artifact": {
|
|
40
|
+
"groupId": "com.wixpress.npm",
|
|
41
|
+
"artifactId": "origami-lint",
|
|
42
|
+
"targets": {
|
|
43
|
+
"docker": false
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"validations": {
|
|
47
|
+
"postBuild": [
|
|
48
|
+
"typecheck",
|
|
49
|
+
"test",
|
|
50
|
+
"lint"
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"falconPackageHash": "b4530d28f7fd3cf6e883a2f9ab79852ec1f94c67e1fa6b2ae68ace9a"
|
|
6
55
|
}
|