@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,109 @@
|
|
|
1
|
+
// The "allowed-imports" subject of the converter rules. Also: nothing is imported from @wix/interact or @wix/motion-presets: since
|
|
2
|
+
// converter 0.80 a page's interactions live in servicesConfigs/interactions.ts, and structure.jsx can't use these packages (0.86
|
|
3
|
+
// refuses a tag from them, parseJsx.ts:471, identity.ts:80; a call is free code, and fn() / ctx() refuse them as a source).
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { CSS_FILES, JSX_FILES } from "../page-model/index.js";
|
|
6
|
+
import { BUILDERS, RUNTIME_ONLY_BUILDERS, RUNTIME_PACKAGE } from "../rules-api/index.js";
|
|
7
|
+
import { GLOBAL_COMPONENTS, GLOBAL_COMPONENTS_FOLDER, GLOBAL_FOLDER, SITE_SRC } from "../shared/site-files-tree-mapping.js";
|
|
8
|
+
import { defineRule } from "./define-rule.js";
|
|
9
|
+
/** The interactions packages, which the import reserves: structure.jsx imports nothing from them. Matched by source, not by name: `Interact` from @site/site-code is the site's own. */
|
|
10
|
+
const INTERACTIONS_PACKAGES = ['interactReact', 'interact', 'motionPresets'];
|
|
11
|
+
const SOURCE_HINT = `Only packages, files in ${path.posix.basename(GLOBAL_FOLDER)}/, and ${CSS_FILES.map((file) => `'./${file}'`).join(' or ')} can be.`;
|
|
12
|
+
export default defineRule({
|
|
13
|
+
name: 'allowed-imports',
|
|
14
|
+
editors: 'all',
|
|
15
|
+
callbacks: {
|
|
16
|
+
ImportDeclaration: (input) => {
|
|
17
|
+
if (!hasAllowedSource(input))
|
|
18
|
+
return;
|
|
19
|
+
if (!isNotInteractionsPackage(input))
|
|
20
|
+
return;
|
|
21
|
+
checkGlobalComponentImport(input);
|
|
22
|
+
checkBuilderImports(input);
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
// ── the steps ──
|
|
27
|
+
function hasAllowedSource({ node, rulesApi, report }) {
|
|
28
|
+
const source = node.source.value;
|
|
29
|
+
if (isAllowedSource(source, rulesApi.page.jsxFile))
|
|
30
|
+
return true;
|
|
31
|
+
report({ loc: node.loc, message: `\`${source}\` can't be imported. ${SOURCE_HINT}` });
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
/** Reports an import from @wix/interact or @wix/motion-presets; an `import type` is erased, so it passes. */
|
|
35
|
+
function isNotInteractionsPackage({ node, rulesApi, report }) {
|
|
36
|
+
const source = node.source.value;
|
|
37
|
+
const kind = rulesApi.getImportKind(source);
|
|
38
|
+
if (!kind || !INTERACTIONS_PACKAGES.includes(kind) || (node.specifiers.length > 0 && getValueSpecifiers(node).length === 0))
|
|
39
|
+
return true;
|
|
40
|
+
report({ loc: node.loc, message: `\`${source}\` can't be imported: a page's interactions go in its servicesConfigs/interactions.ts, with the element's id as \`key\`. Remove the import and any <Interaction> tag around an element (keep the element).` });
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
/** An import that reaches global/components must be `import <Folder> from '…/global/components/<Folder>'`, as the converter reads it. */
|
|
44
|
+
function checkGlobalComponentImport({ node, rulesApi, report }) {
|
|
45
|
+
const source = node.source.value;
|
|
46
|
+
const { jsxFile } = rulesApi.page;
|
|
47
|
+
const folder = getGlobalComponentFolder(source, jsxFile);
|
|
48
|
+
if (!folder)
|
|
49
|
+
return;
|
|
50
|
+
if (node.specifiers.length === 0) {
|
|
51
|
+
report({ loc: node.loc, message: `${source} is imported without a name. Import a global component under its folder's name: \`import ${folder} from '${getGlobalComponentImportPath(folder, jsxFile)}'\`, and write \`<${folder}>\`.` });
|
|
52
|
+
}
|
|
53
|
+
for (const specifier of getValueSpecifiers(node)) {
|
|
54
|
+
const name = specifier.type === 'ImportNamespaceSpecifier' ? '*' : specifier.local.name;
|
|
55
|
+
if (withoutLeadingDots(source) === `${GLOBAL_COMPONENTS}/${name}`)
|
|
56
|
+
continue;
|
|
57
|
+
report({
|
|
58
|
+
loc: specifier.loc,
|
|
59
|
+
message: `\`${specifier.local.name}\` is imported from ${source}. Import a global component under its folder's name: \`import ${folder} from '${getGlobalComponentImportPath(folder, jsxFile)}'\`, and write \`<${folder}>\`.`,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function checkBuilderImports({ node, rulesApi, report }) {
|
|
64
|
+
const source = node.source.value;
|
|
65
|
+
for (const specifier of getValueSpecifiers(node)) {
|
|
66
|
+
const localName = specifier.local.name;
|
|
67
|
+
const importedName = rulesApi.getImportedName(specifier);
|
|
68
|
+
if (source === RUNTIME_PACKAGE) {
|
|
69
|
+
if (importedName === 'default') {
|
|
70
|
+
report({ loc: specifier.loc, message: `\`${localName}\` is a default import of ${RUNTIME_PACKAGE}. Import each builder by name: \`import { ctx } from '${RUNTIME_PACKAGE}'\`.` });
|
|
71
|
+
}
|
|
72
|
+
else if (BUILDERS.includes(importedName) && localName !== importedName) {
|
|
73
|
+
report({ loc: specifier.loc, message: `The builder \`${importedName}\` is imported as \`${localName}\`. Import it under its own name: \`import { ${importedName} } from '${RUNTIME_PACKAGE}'\`, and call it as \`${importedName}(…)\`.` });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
else if (RUNTIME_ONLY_BUILDERS.includes(localName)) {
|
|
77
|
+
report({ loc: specifier.loc, message: `\`${localName}\` is imported from ${source}, but \`${localName}\` is a builder of ${RUNTIME_PACKAGE}. Import it from there: \`import { ${localName} } from '${RUNTIME_PACKAGE}'\`.` });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// ── reading the code ──
|
|
82
|
+
function isAllowedSource(source, jsxFile) {
|
|
83
|
+
if (!/^[./]/.test(source) && !source.includes(':'))
|
|
84
|
+
return true;
|
|
85
|
+
if (CSS_FILES.some((file) => source === `./${file}`))
|
|
86
|
+
return true;
|
|
87
|
+
if (!source.startsWith('.'))
|
|
88
|
+
return false;
|
|
89
|
+
const ownFolder = path.posix.dirname(jsxFile);
|
|
90
|
+
const importedPath = path.posix.join(ownFolder, source);
|
|
91
|
+
return importedPath.startsWith(`${GLOBAL_FOLDER}/`) && !importedPath.startsWith(`${ownFolder}/`);
|
|
92
|
+
}
|
|
93
|
+
/** The converter's canonicalSpecifier (identity.ts). */
|
|
94
|
+
const withoutLeadingDots = (source) => source.replace(/^(\.\.?\/)+/, '');
|
|
95
|
+
/** The global component an import reaches from jsxFile's folder (`../FooterSection`, `…/Header/`, `…/Header/structure.jsx`), or null:
|
|
96
|
+
* another file in its folder (`…/Header/helper.js`) isn't the component. */
|
|
97
|
+
function getGlobalComponentFolder(source, jsxFile) {
|
|
98
|
+
if (!source.startsWith('.'))
|
|
99
|
+
return null;
|
|
100
|
+
const target = path.posix.join(path.posix.dirname(jsxFile), source);
|
|
101
|
+
if (!target.startsWith(`${GLOBAL_COMPONENTS_FOLDER}/`))
|
|
102
|
+
return null;
|
|
103
|
+
const [folder, ...rest] = target.slice(GLOBAL_COMPONENTS_FOLDER.length + 1).split('/').filter(Boolean);
|
|
104
|
+
return folder && (rest.length === 0 || (rest.length === 1 && JSX_FILES.includes(rest[0]))) ? folder : null;
|
|
105
|
+
}
|
|
106
|
+
/** The import of a global component as the export writes it: up to src/, then down to its folder. */
|
|
107
|
+
const getGlobalComponentImportPath = (folder, jsxFile) => `${path.posix.relative(path.posix.dirname(jsxFile), SITE_SRC)}/${GLOBAL_COMPONENTS}/${folder}`;
|
|
108
|
+
/** The specifiers that exist at runtime: `import type { … }` and `import { type … }` are erased, so they import no component or builder. */
|
|
109
|
+
const getValueSpecifiers = (node) => node.importKind === 'type' ? [] : node.specifiers.filter((specifier) => specifier.type !== 'ImportSpecifier' || specifier.importKind !== 'type');
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { createCssFileReporter, describeAtRule, getCssChildren, getDashedId, getSegmentClass, getSelectorSegments, isPresetProperty, isSafeCssIdentifier } from "../rules-api/index.js";
|
|
2
|
+
import { defineRule } from "./define-rule.js";
|
|
3
|
+
export default defineRule({
|
|
4
|
+
name: 'apply-and-presets',
|
|
5
|
+
editors: ['studio2'],
|
|
6
|
+
callbacks: {
|
|
7
|
+
Program: ({ rulesApi, report }) => {
|
|
8
|
+
for (const kind of ['style', 'theme']) {
|
|
9
|
+
const root = rulesApi.page.css.files[kind];
|
|
10
|
+
if (root)
|
|
11
|
+
checkAppliesAndPresets(root, kind, rulesApi, report);
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
});
|
|
16
|
+
// ── the steps ──
|
|
17
|
+
/** Each rule on its own, in the file's order; then the rules that passed, by selector (CONV-styleParser.ts). */
|
|
18
|
+
function checkAppliesAndPresets(root, kind, rulesApi, report) {
|
|
19
|
+
const reportInFile = createCssFileReporter(`${kind}.css`, report);
|
|
20
|
+
const elementRules = [];
|
|
21
|
+
root.walkRules((rule) => {
|
|
22
|
+
if (isInsideRule(rule))
|
|
23
|
+
return;
|
|
24
|
+
const enclosing = rulesApi.getEnclosingAtRules(rule);
|
|
25
|
+
if (isInMixinResult(enclosing))
|
|
26
|
+
checkMixinRule(rule, enclosing[0], reportInFile);
|
|
27
|
+
else if (isElementRulePlace(enclosing)) {
|
|
28
|
+
const elementRule = checkElementRule(rule, getBreakpointKey(enclosing, kind, rulesApi), enclosing.at(-1), reportInFile);
|
|
29
|
+
if (elementRule)
|
|
30
|
+
elementRules.push(elementRule);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
const groups = groupBySelector(elementRules.filter((rule) => !rule.selector.startsWith(':root') && isSelectorParsed(rule.selector)));
|
|
34
|
+
const componentClasses = new Set(Object.values(rulesApi.page.components).flatMap((component) => component.classNames));
|
|
35
|
+
for (const group of groups)
|
|
36
|
+
checkSelectorRules(group, componentClasses, reportInFile);
|
|
37
|
+
checkOneSelectorPerElement(groups, reportInFile);
|
|
38
|
+
}
|
|
39
|
+
/** An element rule: one `@apply` (CONV-astParser.ts:99), written `--id` with no block (:41), and no style declaration beside it but `--preset…` (:117). */
|
|
40
|
+
function checkElementRule(node, breakpoint, media, reportInFile) {
|
|
41
|
+
const selector = node.selector.trim();
|
|
42
|
+
// a flat `--id { }` rule is global-styles' (astParser.ts:87)
|
|
43
|
+
if (selector.startsWith('--'))
|
|
44
|
+
return null;
|
|
45
|
+
const children = getCssChildren(node);
|
|
46
|
+
const atRules = children.filter((child) => child.type === 'atrule');
|
|
47
|
+
// another at-rule in a style rule is css-file-nesting's (CONV-astParser.ts:95)
|
|
48
|
+
if (atRules.some((atRule) => atRule.name !== 'apply'))
|
|
49
|
+
return null;
|
|
50
|
+
const where = `rule \`${selector}\``;
|
|
51
|
+
if (atRules.length > 1)
|
|
52
|
+
return reportTwoApplies(atRules, where, 'an element takes one global style', reportInFile);
|
|
53
|
+
const [apply] = atRules;
|
|
54
|
+
if (apply && !isApplyWritten(apply))
|
|
55
|
+
return reportApplyForm(apply, where, reportInFile);
|
|
56
|
+
const declarations = children.filter((child) => child.type === 'decl');
|
|
57
|
+
const values = declarations.filter((declaration) => !isPresetProperty(declaration.prop));
|
|
58
|
+
if (apply && values.length > 0) {
|
|
59
|
+
reportInFile(values[0], `${where}: \`${describeAtRule(apply)}\` can't share a rule with style declarations (${describeProperties(values)}). Put them in the global style (\`@mixin ${apply.params.trim()}\` in theme.css), or under another breakpoint or state; only \`--preset…\` may stay beside the \`@apply\` (tracker CONV-astParser.ts:117).`);
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
return { node, selector: node.selector, breakpoint, media, apply, presets: declarations.filter((declaration) => isPresetProperty(declaration.prop)), values };
|
|
63
|
+
}
|
|
64
|
+
/** A `@mixin`'s `&` rule, in the import's order: one `@apply` (CONV-astParser.ts:99, at :228), nothing beside it (:117, at :231), written `--id` with no block (:41). */
|
|
65
|
+
function checkMixinRule(node, mixin, reportInFile) {
|
|
66
|
+
const selector = node.selector.trim();
|
|
67
|
+
const children = getCssChildren(node);
|
|
68
|
+
// a rule that doesn't start with `&`, a bare `&`, or a nested rule is global-styles' (CONV-astParser.ts:195, :198, :204)
|
|
69
|
+
if (!selector.startsWith('&') || selector === '&' || children.some((child) => child.type === 'rule'))
|
|
70
|
+
return;
|
|
71
|
+
const atRules = children.filter((child) => child.type === 'atrule');
|
|
72
|
+
// another at-rule in an `&` rule is css-file-nesting's (CONV-astParser.ts:95, at :211)
|
|
73
|
+
if (atRules.some((atRule) => atRule.name !== 'apply'))
|
|
74
|
+
return;
|
|
75
|
+
const where = `\`${selector}\` of \`@mixin ${mixin.params.trim()}\``;
|
|
76
|
+
if (atRules.length > 1)
|
|
77
|
+
return void reportTwoApplies(atRules, where, 'an inner part takes one global style', reportInFile);
|
|
78
|
+
const [apply] = atRules;
|
|
79
|
+
if (!apply)
|
|
80
|
+
return;
|
|
81
|
+
const declarations = children.filter((child) => child.type === 'decl');
|
|
82
|
+
if (declarations.length > 0)
|
|
83
|
+
return reportInFile(declarations[0], `${where}: \`${describeAtRule(apply)}\` can't share a rule with declarations (${describeProperties(declarations)}). Move them into \`@mixin ${apply.params.trim()}\` (tracker CONV-astParser.ts:117).`);
|
|
84
|
+
if (!isApplyWritten(apply))
|
|
85
|
+
reportApplyForm(apply, where, reportInFile);
|
|
86
|
+
}
|
|
87
|
+
/** The rules of one selector, by breakpoint: the preset picks (CONV-styleParser.ts:62, :88, :583), one `@apply` (:179), an `@apply` or declarations (:204). */
|
|
88
|
+
function checkSelectorRules(group, componentClasses, reportInFile) {
|
|
89
|
+
const where = `rule \`${group[0].selector.trim()}\``;
|
|
90
|
+
const picks = group.flatMap((rule) => rule.presets.flatMap((declaration) => checkPresetName(declaration, where, reportInFile)));
|
|
91
|
+
if (isInnerPartRule(group[0].selector, componentClasses)) {
|
|
92
|
+
for (const { declaration } of picks.filter(({ path }) => path.length > 0)) {
|
|
93
|
+
reportInFile(declaration, `${where}: \`${declaration.prop}\` can't be set on an inner part's rule, which picks only the part's own preset (\`--preset\`). Write it on the component's own rule, with the part's whole path: \`--preset-<part>-${declaration.prop.slice('--preset-'.length)}\` (tracker CONV-styleParser.ts:583).`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
for (const rules of groupByBreakpoint(group)) {
|
|
97
|
+
const breakpointPresets = new Set(rules.flatMap((rule) => rule.presets));
|
|
98
|
+
checkPresetValues(picks.filter(({ declaration }) => breakpointPresets.has(declaration)), where, reportInFile);
|
|
99
|
+
const breakpointDescription = rules[0].media ? `in \`${describeAtRule(rules[0].media)}\`` : 'outside any @media';
|
|
100
|
+
const applies = rules.filter((rule) => rule.apply);
|
|
101
|
+
for (const rule of applies.slice(1)) {
|
|
102
|
+
reportInFile(rule.apply, `${where}: two \`@apply\` for this selector ${breakpointDescription} (\`${describeAtRule(applies[0].apply)}\` on line ${getLine(applies[0].apply)}, \`${describeAtRule(rule.apply)}\`): keep one (tracker CONV-styleParser.ts:179).`);
|
|
103
|
+
}
|
|
104
|
+
const ruleWithValues = rules.find((rule) => rule.values.length > 0);
|
|
105
|
+
if (applies.length > 0 && ruleWithValues) {
|
|
106
|
+
const { apply } = applies[0];
|
|
107
|
+
reportInFile(ruleWithValues.values[0], `${where} has both \`${describeAtRule(apply)}\` (line ${getLine(apply)}) and style declarations ${breakpointDescription}: keep one, or move the declarations into the global style (\`@mixin ${apply.params.trim()}\` in theme.css) (tracker CONV-styleParser.ts:204).`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** A `--preset-<path>` names each part as a CSS identifier (CONV-styleParser.ts:62); returns the pick, or none after reporting. */
|
|
112
|
+
function checkPresetName(declaration, where, reportInFile) {
|
|
113
|
+
if (declaration.prop === '--preset')
|
|
114
|
+
return [{ declaration, path: [] }];
|
|
115
|
+
const path = decodePresetPath(declaration.prop.slice('--preset-'.length));
|
|
116
|
+
if (path.every(isSafeCssIdentifier))
|
|
117
|
+
return [{ declaration, path }];
|
|
118
|
+
reportInFile(declaration, `${where}: \`${declaration.prop}\` doesn't name an inner part: write \`--preset-<part>\`, each part a CSS identifier, parts joined by \`-\` and a \`-\` inside a part's name written \`--\` (\`close-button\` → \`--preset-close--button\`) (tracker CONV-styleParser.ts:62).`);
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
/** The last pick of each path at one breakpoint is the one the import reads: it has a value (CONV-styleParser.ts:88). */
|
|
122
|
+
function checkPresetValues(picks, where, reportInFile) {
|
|
123
|
+
const seen = new Set();
|
|
124
|
+
for (const { declaration, path } of picks.toReversed()) {
|
|
125
|
+
const key = path.join('|');
|
|
126
|
+
if (seen.has(key))
|
|
127
|
+
continue;
|
|
128
|
+
seen.add(key);
|
|
129
|
+
if (!declaration.value.trim())
|
|
130
|
+
reportInFile(declaration, `${where}: \`${declaration.prop}\` has no value. Write a preset name, or remove the declaration to use no preset (tracker CONV-styleParser.ts:88).`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* One element (or inner part) takes its default style, and each default preset, from one selector (CONV-styleParser.ts:109): not
|
|
135
|
+
* `.comp-x .label` and `.comp-x .label`, not `--preset-label` on `.comp-x` and `--preset` on `.comp-x .label`. Default: outside any
|
|
136
|
+
* `@media`, with no state. A part is matched by its class name, `.label` with `--preset-label`.
|
|
137
|
+
*/
|
|
138
|
+
function checkOneSelectorPerElement(groups, reportInFile) {
|
|
139
|
+
const firstStylingRules = new Map();
|
|
140
|
+
const firstPicks = new Map();
|
|
141
|
+
for (const group of groups) {
|
|
142
|
+
const target = getDefaultTarget(group[0].selector);
|
|
143
|
+
if (target === null)
|
|
144
|
+
continue;
|
|
145
|
+
const rules = group.filter((rule) => rule.breakpoint === '');
|
|
146
|
+
const stylingRule = rules.find((rule) => rule.apply !== undefined || rule.values.length > 0);
|
|
147
|
+
if (stylingRule) {
|
|
148
|
+
const first = firstStylingRules.get(target.join(' '));
|
|
149
|
+
if (first && first.selector !== stylingRule.selector) {
|
|
150
|
+
reportInFile(stylingRule.node, `\`${stylingRule.selector.trim()}\` and \`${first.selector.trim()}\` (line ${getLine(first.node)}) are one element, and both set its default style: write it under one selector (tracker CONV-styleParser.ts:109).`);
|
|
151
|
+
}
|
|
152
|
+
else if (!first)
|
|
153
|
+
firstStylingRules.set(target.join(' '), stylingRule);
|
|
154
|
+
}
|
|
155
|
+
const lastPicks = new Map();
|
|
156
|
+
for (const declaration of rules.flatMap((rule) => rule.presets)) {
|
|
157
|
+
const path = getPresetPath(declaration.prop);
|
|
158
|
+
if (path === null || (target.length > 1 && path.length > 0))
|
|
159
|
+
continue;
|
|
160
|
+
lastPicks.set([target[0], ...target.slice(1).map((part) => part.slice(1)), ...path].join('|'), declaration);
|
|
161
|
+
}
|
|
162
|
+
for (const [key, declaration] of lastPicks) {
|
|
163
|
+
const first = firstPicks.get(key);
|
|
164
|
+
if (!first)
|
|
165
|
+
firstPicks.set(key, { selector: group[0].selector, declaration });
|
|
166
|
+
else
|
|
167
|
+
reportInFile(declaration, `${describePresetOwner(key)}'s preset is picked twice: \`${first.declaration.prop}\` on \`${first.selector.trim()}\` (line ${getLine(first.declaration)}) and \`${declaration.prop}\` on \`${group[0].selector.trim()}\`. Keep one (tracker CONV-styleParser.ts:109).`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// ── where a rule is ──
|
|
172
|
+
/** Inside another style rule, at any depth (`.a { .b { } }`, `.a { @media (…) { .b { } } }`): css-file-nesting's and global-styles' rows. */
|
|
173
|
+
function isInsideRule(rule) {
|
|
174
|
+
for (let parent = rule.parent; parent && parent.type !== 'root'; parent = parent.parent) {
|
|
175
|
+
if (parent.type === 'rule')
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
/** In a top-level `@mixin`'s `@result`, or in one `@media` (or `@scope` → `@media`) in it; deeper is global-styles' (CONV-astParser.ts:254). */
|
|
181
|
+
const isInMixinResult = (enclosing) => enclosing[0]?.name === 'mixin' && enclosing[1]?.name === 'result' && isElementRulePlace(enclosing.slice(2));
|
|
182
|
+
/** At the top of the file, or in one top-level breakpoint: `@media`, or `@scope` → … → `@media`. Anywhere else is css-file-nesting's. */
|
|
183
|
+
const isElementRulePlace = (enclosing) => enclosing.length === 0 || (enclosing.at(-1).name === 'media' && enclosing.slice(0, -1).every((atRule) => atRule.name === 'scope'));
|
|
184
|
+
/** The breakpoint key, as the import resolves it: the query with a named breakpoint replaced by its range from the file's `@custom-media`, spaces collapsed, after its `@scope`s. */
|
|
185
|
+
function getBreakpointKey(enclosing, kind, rulesApi) {
|
|
186
|
+
return enclosing
|
|
187
|
+
.map((atRule) => {
|
|
188
|
+
if (atRule.name === 'scope')
|
|
189
|
+
return `@scope ${collapseSpaces(atRule.params)}`;
|
|
190
|
+
const name = rulesApi.getBreakpointName(atRule.params);
|
|
191
|
+
const declared = name === null ? undefined : rulesApi.getCustomMedia(kind).find((customMedia) => customMedia.name === name);
|
|
192
|
+
return collapseSpaces(declared?.query ?? atRule.params);
|
|
193
|
+
})
|
|
194
|
+
.join(' ');
|
|
195
|
+
}
|
|
196
|
+
// ── reading a selector ──
|
|
197
|
+
/**
|
|
198
|
+
* Whether a selector's rule is an inner part's (`.comp-x .label`), as the import's parseComponentChain reads it: anything after the
|
|
199
|
+
* component's class is its inner part, except a component's own class after an ancestor's state (`.comp-y:hover .comp-x` is comp-x's).
|
|
200
|
+
*/
|
|
201
|
+
function isInnerPartRule(selector, componentClasses) {
|
|
202
|
+
const segments = getSelectorSegments(selector);
|
|
203
|
+
if (segments === null || segments.length < 2 || !hasComponentClass(segments[0]))
|
|
204
|
+
return false;
|
|
205
|
+
const hasAncestorState = hasComponentState(segments[0]);
|
|
206
|
+
const second = getSegmentClass(segments[1]);
|
|
207
|
+
const isComponentClass = second !== null && /^\.[^.]+$/.test(second) && componentClasses.has(second.slice(1));
|
|
208
|
+
return hasAncestorState && isComponentClass ? segments.length > 2 : true;
|
|
209
|
+
}
|
|
210
|
+
/** The element a selector with no state names: its classes ['.comp-x', '.label']; null with a state, or for a selector the import can't parse (the selectors rows'). */
|
|
211
|
+
function getDefaultTarget(selector) {
|
|
212
|
+
const segments = getSelectorSegments(selector);
|
|
213
|
+
if (segments === null || segments.length === 0 || !hasComponentClass(segments[0]))
|
|
214
|
+
return null;
|
|
215
|
+
if (hasComponentState(segments[0]))
|
|
216
|
+
return null;
|
|
217
|
+
return segments.every((segment) => getSegmentClass(segment) === segment) ? segments : null;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Whether a selector's first segment names a component as parseComponentChain reads it: its class, a class after it aside
|
|
221
|
+
* (withStateClasses), is a safe identifier. `.comp-x[data-x]` isn't (an attribute is part of the class at 0.83.0): the selectors rows'.
|
|
222
|
+
*/
|
|
223
|
+
function hasComponentClass(segment) {
|
|
224
|
+
const className = getSegmentClass(segment)?.slice(1);
|
|
225
|
+
if (className === undefined)
|
|
226
|
+
return false;
|
|
227
|
+
return isSafeCssIdentifier(/^[^:[\]"]+$/.test(className) ? className.split('.')[0] : className);
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Whether the import parses the selector (parseSelector, which it runs on a selector's rules before it reads their presets and
|
|
231
|
+
* `@apply`): one selector, its brackets closed, its first segment a component class. Any other is the selectors rows' (invalidSelector).
|
|
232
|
+
*/
|
|
233
|
+
function isSelectorParsed(selector) {
|
|
234
|
+
const segments = getSelectorSegments(selector);
|
|
235
|
+
return segments !== null && segments.length > 0 && hasComponentClass(segments[0]);
|
|
236
|
+
}
|
|
237
|
+
/** Whether a component's segment has a state: `:hover`, or a class after its own (`.comp-x.onSale`, withStateClasses since 0.80.0). */
|
|
238
|
+
const hasComponentState = (segment) => getSegmentClass(segment) !== segment || /^\.[^.:[\]"]+\./.test(segment);
|
|
239
|
+
/** `--preset` → []; `--preset-close--button-icon` → ['close-button', 'icon']; null for a path a part of which isn't an identifier. */
|
|
240
|
+
function getPresetPath(prop) {
|
|
241
|
+
if (prop === '--preset')
|
|
242
|
+
return [];
|
|
243
|
+
const path = decodePresetPath(prop.slice('--preset-'.length));
|
|
244
|
+
return path.every(isSafeCssIdentifier) ? path : null;
|
|
245
|
+
}
|
|
246
|
+
/** A `--preset-` property's path (css/utils.ts decodePresetPath): a lone `-` ends a part, a doubled `-` is one `-` in a part's name. */
|
|
247
|
+
function decodePresetPath(suffix) {
|
|
248
|
+
const path = [];
|
|
249
|
+
let current = '';
|
|
250
|
+
for (let index = 0; index < suffix.length; index += 1) {
|
|
251
|
+
if (suffix[index] !== '-')
|
|
252
|
+
current += suffix[index];
|
|
253
|
+
else if (suffix[index + 1] === '-') {
|
|
254
|
+
current += '-';
|
|
255
|
+
index += 1;
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
path.push(current);
|
|
259
|
+
current = '';
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
path.push(current);
|
|
263
|
+
return path;
|
|
264
|
+
}
|
|
265
|
+
/** Whether an `@apply` is one dashed id with no block after it (CONV-astParser.ts:41; an empty block, or one with only comments, passes). */
|
|
266
|
+
const isApplyWritten = (apply) => getDashedId(apply.params) !== undefined && getCssChildren(apply).length === 0;
|
|
267
|
+
// ── grouping ──
|
|
268
|
+
/** The rules by selector as written, in the file's order (the import's parseRecords groups by it). */
|
|
269
|
+
function groupBySelector(rules) {
|
|
270
|
+
const groups = new Map();
|
|
271
|
+
for (const rule of rules) {
|
|
272
|
+
const group = groups.get(rule.selector);
|
|
273
|
+
if (group)
|
|
274
|
+
group.push(rule);
|
|
275
|
+
else
|
|
276
|
+
groups.set(rule.selector, [rule]);
|
|
277
|
+
}
|
|
278
|
+
return [...groups.values()];
|
|
279
|
+
}
|
|
280
|
+
/** One selector's rules by breakpoint (groupDeclarationsByBreakpoint). */
|
|
281
|
+
function groupByBreakpoint(rules) {
|
|
282
|
+
const groups = new Map();
|
|
283
|
+
for (const rule of rules) {
|
|
284
|
+
const group = groups.get(rule.breakpoint);
|
|
285
|
+
if (group)
|
|
286
|
+
group.push(rule);
|
|
287
|
+
else
|
|
288
|
+
groups.set(rule.breakpoint, [rule]);
|
|
289
|
+
}
|
|
290
|
+
return [...groups.values()];
|
|
291
|
+
}
|
|
292
|
+
// ── reporting ──
|
|
293
|
+
/** Reports the second `@apply` of one rule (CONV-astParser.ts:99); returns null, as the rule isn't read further. */
|
|
294
|
+
function reportTwoApplies(applies, where, why, reportInFile) {
|
|
295
|
+
reportInFile(applies[1], `${where}: ${applies.length} \`@apply\` in one rule (${applies.map((apply) => `\`${describeAtRule(apply)}\``).join(', ')}). Keep one: ${why} (tracker CONV-astParser.ts:99).`);
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
/** Reports an `@apply` that isn't one dashed id with no block (CONV-astParser.ts:41); returns null, as the rule isn't read further. */
|
|
299
|
+
function reportApplyForm(apply, where, reportInFile) {
|
|
300
|
+
reportInFile(apply, `${where}: \`${describeAtRule(apply)}\` must name one global style by its dashed id. Write \`@apply --<global-style-id>;\`, with no block after it (tracker CONV-astParser.ts:41).`);
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
/** A pick's key → the element it picks the preset of: '.comp-x|label' → '`label` of `.comp-x`'; '.comp-x' → '`.comp-x`'. */
|
|
304
|
+
function describePresetOwner(key) {
|
|
305
|
+
const [component, ...path] = key.split('|');
|
|
306
|
+
return path.length > 0 ? `\`${path.join(' ')}\` of \`${component}\`` : `\`${component}\``;
|
|
307
|
+
}
|
|
308
|
+
const describeProperties = (declarations) => declarations.map((declaration) => `\`${declaration.prop}\``).join(', ');
|
|
309
|
+
const getLine = (node) => node.source?.start?.line ?? 1;
|
|
310
|
+
const collapseSpaces = (text) => text.trim().replace(/\s+/g, ' ');
|