@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
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describeAtRule, reportCssProblem } from "../rules-api/index.js";
|
|
2
|
+
import { defineRule } from "./define-rule.js";
|
|
3
|
+
/** The converter's shape for a declaration's name: a dashed identifier. */
|
|
4
|
+
const DASHED_NAME = /^--[A-Za-z0-9_-]+$/;
|
|
5
|
+
export default defineRule({
|
|
6
|
+
name: 'breakpoints',
|
|
7
|
+
editors: ['studio2'],
|
|
8
|
+
callbacks: {
|
|
9
|
+
Program: ({ rulesApi, report }) => {
|
|
10
|
+
const site = getSiteBreakpoints(rulesApi);
|
|
11
|
+
for (const file of getFileBreakpoints(rulesApi)) {
|
|
12
|
+
checkDeclarations(file, site, report);
|
|
13
|
+
checkNamedUses(file, site, report);
|
|
14
|
+
checkRanges(file, site, report);
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
// ── the steps ──
|
|
20
|
+
/** Every `@custom-media` of a file: well formed (CONV-astParser.ts:328), its name declared once in the file (CONV-breakpoints.ts:197); in a page file, with a theme to compare with, also one of the site's names (:335) with its range (:339). theme.css's own are the site's. */
|
|
21
|
+
function checkDeclarations(file, site, report) {
|
|
22
|
+
const first = new Map();
|
|
23
|
+
for (const declaration of file.declared) {
|
|
24
|
+
if (!isWellFormed(file, declaration, report))
|
|
25
|
+
continue;
|
|
26
|
+
if (!isFirstOfName(file, declaration, first, report))
|
|
27
|
+
continue;
|
|
28
|
+
if (file.isPage && site.hasTheme && isSiteName(file, declaration, site, report))
|
|
29
|
+
keepsSiteRange(file, declaration, site, report);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Reports a `@custom-media` without a dashed name or without a query (CONV-astParser.ts:328). */
|
|
33
|
+
function isWellFormed(file, declaration, report) {
|
|
34
|
+
if (hasShape(declaration))
|
|
35
|
+
return true;
|
|
36
|
+
const params = `${declaration.name} ${declaration.query}`.trim();
|
|
37
|
+
reportCssProblem(report, file.name, declaration.line, `\`@custom-media ${params}\` isn't \`--<name> <media query>\`; write it like \`@custom-media --mobile (max-width: 750px);\` (tracker CONV-astParser.ts:328).`);
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
/** Reports a second `@custom-media` of a name the file already declares: the import can't tell two ranges with one name apart (CONV-breakpoints.ts:197). */
|
|
41
|
+
function isFirstOfName(file, declaration, first, report) {
|
|
42
|
+
const earlier = first.get(declaration.name);
|
|
43
|
+
if (!earlier)
|
|
44
|
+
return first.set(declaration.name, declaration), true;
|
|
45
|
+
reportCssProblem(report, file.name, declaration.line, `\`@custom-media ${declaration.name}\` is declared again (first at ${file.name}:${earlier.line}). The import can't tell two ranges with one name apart: keep one (tracker CONV-breakpoints.ts:197).`);
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
/** Reports a page file's declaration whose name isn't one of the site's (CONV-astParser.ts:335). */
|
|
49
|
+
function isSiteName(file, declaration, site, report) {
|
|
50
|
+
const names = site.breakpoints.map(({ name }) => name);
|
|
51
|
+
if (names.includes(declaration.name))
|
|
52
|
+
return true;
|
|
53
|
+
reportCssProblem(report, file.name, declaration.line, `\`@custom-media ${declaration.name}\` isn't one of the site's named breakpoints (the theme has ${names.join(', ') || 'none'}). Use a range the site declares; breakpoints are added in the editor, not in CSS (tracker CONV-astParser.ts:335).`);
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
/** Reports a page file's declaration whose range isn't the site's for that name, to the letter: the import compares the text (CONV-astParser.ts:339). */
|
|
57
|
+
function keepsSiteRange(file, declaration, site, report) {
|
|
58
|
+
const exported = getSiteRange(declaration.name, site);
|
|
59
|
+
if (exported.trim() === declaration.query.trim())
|
|
60
|
+
return true;
|
|
61
|
+
reportCssProblem(report, file.name, declaration.line, `\`@custom-media ${declaration.name} ${declaration.query}\` changes the site's range for ${declaration.name}, which is \`${exported}\`. Restore it; a breakpoint's width is changed in the editor, not in CSS (tracker CONV-astParser.ts:339).`);
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
/** Reports a `@media (--name)` with no declaration of that name in the same file (CONV-breakpoints.ts:57). A malformed declaration of the name (its own line) counts as the declaration, so the use gets no second line. */
|
|
65
|
+
function checkNamedUses(file, site, report) {
|
|
66
|
+
const declared = new Set(file.declared.flatMap(({ name }) => [name, `--${name}`]));
|
|
67
|
+
for (const { name, line } of file.used) {
|
|
68
|
+
if (!name || declared.has(name))
|
|
69
|
+
continue;
|
|
70
|
+
const range = getSiteRange(name, site);
|
|
71
|
+
const fix = range
|
|
72
|
+
? `add \`@custom-media ${name} ${range};\` at the top of ${file.name}, or write the range itself, \`@media ${range}\``
|
|
73
|
+
: `add \`@custom-media ${name} (<its range>);\` at the top of ${file.name} (the range the site gives it), or write the range itself`;
|
|
74
|
+
reportCssProblem(report, file.name, line, `\`@media (${name})\` uses a name the file doesn't declare: ${fix} (tracker CONV-breakpoints.ts:57).`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Reports each `@media` of a file whose range is none of the site's (P4-R01, CONV-breakpoints.ts:75); nothing without a theme to compare with. A name is read through the file's declaration when that declaration is the site's; otherwise the steps above report it. A component's own breakpoint (inside `@scope`) is compared once unmapped.json is read. */
|
|
78
|
+
function checkRanges(file, site, report) {
|
|
79
|
+
if (!site.hasTheme)
|
|
80
|
+
return;
|
|
81
|
+
for (const media of file.used) {
|
|
82
|
+
if (media.scope !== null)
|
|
83
|
+
continue;
|
|
84
|
+
const query = resolve(media, file.declared, site);
|
|
85
|
+
if (query === null || site.breakpoints.some((breakpoint) => canonical(breakpoint.query) === canonical(query)))
|
|
86
|
+
continue;
|
|
87
|
+
reportCssProblem(report, file.name, media.line, `\`${describeAtRule({ name: 'media', params: media.query })}\` is a breakpoint the site doesn't have. Write one of the theme's breakpoints: ${describe(site.breakpoints)} (rule P4-R01; tracker CONV-breakpoints.ts:75). The import refuses a value for a breakpoint the site doesn't have.`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// ── reading the files ──
|
|
91
|
+
/** The site's breakpoints from theme.css: its well-formed `@custom-media` (a malformed one is the shape step's). */
|
|
92
|
+
const getSiteBreakpoints = (rulesApi) => ({ hasTheme: rulesApi.page.css.files.theme !== undefined, breakpoints: rulesApi.getCustomMedia('theme').filter(hasShape) });
|
|
93
|
+
/** For layout.css, style.css and theme.css: the breakpoints each declares (`@custom-media`) and uses (`@media`). */
|
|
94
|
+
const getFileBreakpoints = (rulesApi) => ['layout', 'style', 'theme'].map((kind) => {
|
|
95
|
+
const isPage = kind !== 'theme';
|
|
96
|
+
return { name: `${kind}.css`, isPage, declared: rulesApi.getCustomMedia(kind), used: getMedia(rulesApi.page.css.files[kind], isPage, rulesApi) };
|
|
97
|
+
});
|
|
98
|
+
/** Every `@media` a file writes, in order. In a page file: not inside the file's own `@mixin` (no-page-mixin's), and only one with a block. In theme.css: every one, inside its mixins too. */
|
|
99
|
+
function getMedia(file, isPage, rulesApi) {
|
|
100
|
+
const media = [];
|
|
101
|
+
file?.walkAtRules('media', (node) => {
|
|
102
|
+
const around = rulesApi.getEnclosingAtRules(node);
|
|
103
|
+
if (isPage && (!node.nodes || around.some(({ name }) => name === 'mixin')))
|
|
104
|
+
return;
|
|
105
|
+
const query = node.params.trim();
|
|
106
|
+
media.push({ query, name: rulesApi.getBreakpointName(query), line: node.source?.start?.line ?? 1, scope: around.findLast(({ name }) => name === 'scope')?.params.trim() ?? null });
|
|
107
|
+
});
|
|
108
|
+
return media;
|
|
109
|
+
}
|
|
110
|
+
const hasShape = ({ name, query }) => DASHED_NAME.test(name) && query !== '';
|
|
111
|
+
/** The site's range for a name; undefined when the theme has no well-formed declaration of it. */
|
|
112
|
+
const getSiteRange = (name, site) => site.breakpoints.find((breakpoint) => breakpoint.name === name)?.query;
|
|
113
|
+
/** The range to compare: the @media's own; for `(--name)`, the range the file declares for it, when that declaration is the site's name with the site's range (otherwise the steps above report it); null when there is nothing to compare. */
|
|
114
|
+
function resolve(media, declarations, site) {
|
|
115
|
+
if (!media.name)
|
|
116
|
+
return media.query;
|
|
117
|
+
const declaration = declarations.find((entry) => entry.name === media.name && hasShape(entry));
|
|
118
|
+
const exported = getSiteRange(media.name, site);
|
|
119
|
+
return declaration && exported !== undefined && exported.trim() === declaration.query.trim() ? declaration.query : null;
|
|
120
|
+
}
|
|
121
|
+
/** How the import reads a range: `(max-width:1023px)` and `(max-width: 1023px)` are the same; anything else, a different case or other spaces included, is another query. */
|
|
122
|
+
const canonical = (query) => query.trim().replace(/:\s+/g, ':');
|
|
123
|
+
// ── reporting ──
|
|
124
|
+
const describe = (site) => site.length ? site.map(({ query, line }) => `\`@media ${query}\` (theme.css:${line})`).join(', ') : 'none: theme.css declares no @custom-media';
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { BREAKPOINT_AT_RULES, createCssFileReporter, describeAtRule, getCssChildren, ORIGAMI_TARGETS, PREAMBLE_LAYER } from "../rules-api/index.js";
|
|
2
|
+
import { defineRule } from "./define-rule.js";
|
|
3
|
+
/** The import's leading-import line (cssFileParser.ts `IMPORT_LINE_PATTERN`). */
|
|
4
|
+
const IMPORT_LINE = /^@import '([^']+)';$/;
|
|
5
|
+
export default defineRule({
|
|
6
|
+
name: 'css-file-nesting',
|
|
7
|
+
editors: ['studio2'],
|
|
8
|
+
callbacks: {
|
|
9
|
+
Program: ({ rulesApi, report }) => {
|
|
10
|
+
for (const kind of ['layout', 'style', 'theme']) {
|
|
11
|
+
const root = rulesApi.page.css.files[kind];
|
|
12
|
+
if (root)
|
|
13
|
+
checkTopLevel(root, `${kind}.css`, report);
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
// ── the steps ──
|
|
19
|
+
/** The file's top level: imports, at-rules, declarations, rules and breakpoint blocks (CONV-fromBundle.ts:987, CONV-utils.ts:92, :76, CONV-astParser.ts:412). */
|
|
20
|
+
function checkTopLevel(root, file, report) {
|
|
21
|
+
const reportInFile = createCssFileReporter(file, report);
|
|
22
|
+
const leadingImportLines = file === 'theme.css' ? 0 : countLeadingImportLines(root.source?.input.css ?? '');
|
|
23
|
+
for (const node of root.nodes) {
|
|
24
|
+
if (node.type === 'comment')
|
|
25
|
+
continue;
|
|
26
|
+
if (node.type === 'atrule' && node.name === 'layer' && node.params.trim() === PREAMBLE_LAYER)
|
|
27
|
+
continue;
|
|
28
|
+
if (node.type === 'rule')
|
|
29
|
+
checkRule(node, file, reportInFile);
|
|
30
|
+
else if (node.type === 'decl')
|
|
31
|
+
checkTopLevelDeclaration(node, file, reportInFile);
|
|
32
|
+
else if (node.type === 'atrule')
|
|
33
|
+
checkTopAtRule(node, file, (node.source?.start?.line ?? 0) <= leadingImportLines, reportInFile);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function checkTopAtRule(node, file, isLeadingImport, reportInFile) {
|
|
37
|
+
if (node.name === 'import' && isLeadingImport)
|
|
38
|
+
return reportInFile(node, `\`@import ${node.params.trim()}\` can't be in ${file}: an item's CSS carries no imports (the preview loads the theme from _generated/). Remove the line (tracker CONV-fromBundle.ts:987).`);
|
|
39
|
+
if (node.name === 'custom-media')
|
|
40
|
+
return;
|
|
41
|
+
if (node.name === 'mixin' && file !== 'layout.css')
|
|
42
|
+
return checkMixin(node, reportInFile);
|
|
43
|
+
if (node.name === 'scope' && file === 'layout.css')
|
|
44
|
+
return checkLayoutScope(node, reportInFile);
|
|
45
|
+
if (!hasBlock(node, reportInFile))
|
|
46
|
+
return;
|
|
47
|
+
if (BREAKPOINT_AT_RULES.includes(node.name))
|
|
48
|
+
return checkBreakpoint(node, file, reportInFile);
|
|
49
|
+
reportInFile(node, `\`@${node.name}\` isn't part of the export format at the top of ${file}. At the top level only @media, @scope, @custom-media${file === 'layout.css' ? '' : ' and @mixin'} are allowed${pageMixinNote(file)} (tracker CONV-utils.ts:92).`);
|
|
50
|
+
}
|
|
51
|
+
/** Reports a bare declaration at the top of layout.css (CONV-astParser.ts:412); style.css and theme.css drop one silently. */
|
|
52
|
+
function checkTopLevelDeclaration(node, file, reportInFile) {
|
|
53
|
+
if (file !== 'layout.css')
|
|
54
|
+
return;
|
|
55
|
+
reportInFile(node, `\`${node.prop}: ${node.value}\` is at the top level. Put the declaration inside the element's rule (\`.comp-a { … }\`) (tracker CONV-astParser.ts:412).`);
|
|
56
|
+
}
|
|
57
|
+
/** A layout `@scope` holds exactly one `@media`, checked before its block (CONV-astParser.ts:399, as parseCssAst does). */
|
|
58
|
+
function checkLayoutScope(node, reportInFile) {
|
|
59
|
+
const scopeChildren = node.nodes === undefined ? [] : getCssChildren(node);
|
|
60
|
+
if (scopeChildren.length !== 1 || !isMedia(scopeChildren[0])) {
|
|
61
|
+
return reportInFile(node, `\`@scope ${node.params.trim()}\` must hold exactly one \`@media (…) { … }\` block. Put the rules inside one @media in the @scope (tracker CONV-astParser.ts:399).`);
|
|
62
|
+
}
|
|
63
|
+
if (hasBlock(scopeChildren[0], reportInFile))
|
|
64
|
+
checkBreakpointContent(scopeChildren[0], 'layout.css', reportInFile);
|
|
65
|
+
}
|
|
66
|
+
/** A top-level style or theme `@media` / `@scope` (its block checked already). */
|
|
67
|
+
function checkBreakpoint(node, file, reportInFile) {
|
|
68
|
+
const media = getScopedMedia(node, reportInFile);
|
|
69
|
+
if (media)
|
|
70
|
+
checkBreakpointContent(media, file, reportInFile);
|
|
71
|
+
}
|
|
72
|
+
/** The `@media` a breakpoint at-rule stands for, following `@scope` → its first child as the import does (CONV-utils.ts:92, :76); null after reporting. */
|
|
73
|
+
function getScopedMedia(node, reportInFile) {
|
|
74
|
+
let current = node;
|
|
75
|
+
while (current.name === 'scope') {
|
|
76
|
+
const first = getCssChildren(current)[0];
|
|
77
|
+
if (first?.type === 'atrule' && first.name === 'scope') {
|
|
78
|
+
if (!hasBlock(first, reportInFile))
|
|
79
|
+
return null;
|
|
80
|
+
current = first;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (!isMedia(first)) {
|
|
84
|
+
reportInFile(current, `\`@scope ${current.params.trim()}\` must start with a \`@media (…) { … }\` block: put the rules inside it (tracker CONV-utils.ts:92).`);
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
if (!hasBlock(first, reportInFile))
|
|
88
|
+
return null;
|
|
89
|
+
current = first;
|
|
90
|
+
}
|
|
91
|
+
return current;
|
|
92
|
+
}
|
|
93
|
+
/** A breakpoint block holds element rules only (CONV-astParser.ts:147, :151). */
|
|
94
|
+
function checkBreakpointContent(media, file, reportInFile) {
|
|
95
|
+
for (const child of getCssChildren(media)) {
|
|
96
|
+
if (child.type === 'rule')
|
|
97
|
+
checkRule(child, file, reportInFile);
|
|
98
|
+
else if (child.type === 'atrule')
|
|
99
|
+
reportInFile(child, `\`@${child.name}\` inside \`${describeAtRule(media)}\` is not allowed. A breakpoint block holds only element rules: declare \`@mixin\` at the top level${pageMixinNote(file)} and put its breakpoint variant in a \`@media\` inside its \`@result\`; don't nest one \`@media\` in another (tracker CONV-astParser.ts:147).`);
|
|
100
|
+
else if (child.type === 'decl')
|
|
101
|
+
reportInFile(child, `\`${child.prop}: ${child.value}\` sits directly in \`${describeAtRule(media)}\`. Wrap it in the element's rule: \`@media (…) { .comp-a { … } }\` (tracker CONV-astParser.ts:151).`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** Reports an at-rule written without a block (CONV-utils.ts:76): the import reads every top-level at-rule but `@custom-media` and `@mixin` as a breakpoint. */
|
|
105
|
+
function hasBlock(node, reportInFile) {
|
|
106
|
+
if (node.nodes !== undefined)
|
|
107
|
+
return true;
|
|
108
|
+
reportInFile(node, `\`${describeAtRule(node)}\` has no block. Give it a \`{ … }\` block with the element rules, or remove it (tracker CONV-utils.ts:76).`);
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
/** An element rule: layout's (CONV-astParser.ts:74, :51, :54, :57) or style's and theme's (CONV-astParser.ts:95). */
|
|
112
|
+
function checkRule(rule, file, reportInFile) {
|
|
113
|
+
if (file === 'layout.css')
|
|
114
|
+
checkLayoutRule(rule, reportInFile);
|
|
115
|
+
else
|
|
116
|
+
checkStyleRule(rule, reportInFile);
|
|
117
|
+
}
|
|
118
|
+
function checkLayoutRule(rule, reportInFile) {
|
|
119
|
+
const selector = rule.selector.trim();
|
|
120
|
+
const seenTargets = new Set();
|
|
121
|
+
for (const child of getCssChildren(rule)) {
|
|
122
|
+
if (child.type === 'decl')
|
|
123
|
+
continue;
|
|
124
|
+
if (child.type === 'atrule' && child.name === 'origami-target') {
|
|
125
|
+
checkOrigamiTarget(child, selector, seenTargets, reportInFile);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const what = child.type === 'rule' ? `the rule \`${child.selector.trim()}\`` : `\`${describeAtRule(child)}\``;
|
|
129
|
+
const fix = child.type === 'rule' ? 'Write it as its own rule' : child.name === 'media' ? `Move it out: \`@media (…) { ${selector} { … } }\` at the top level` : 'Move it out of the rule';
|
|
130
|
+
reportInFile(child, `rule \`${selector}\`: ${what} can't be inside a layout rule, which holds only declarations and @origami-target blocks. ${fix} (tracker CONV-astParser.ts:74).`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/** An `@origami-target`: a block (CONV-astParser.ts:51), a known name (:54), once per rule (:57), only declarations (:51), in the converter's order. */
|
|
134
|
+
function checkOrigamiTarget(node, selector, seenTargets, reportInFile) {
|
|
135
|
+
const target = node.params.trim();
|
|
136
|
+
const reportTargetShape = (fix) => reportInFile(node, `rule \`${selector}\`: \`@origami-target ${target}\` holds only declarations, in a \`{ … }\` block. ${fix} (tracker CONV-astParser.ts:51).`);
|
|
137
|
+
if (node.nodes === undefined)
|
|
138
|
+
return reportTargetShape("Give it a block with the container's declarations");
|
|
139
|
+
if (!ORIGAMI_TARGETS.includes(target))
|
|
140
|
+
return reportInFile(node, `rule \`${selector}\`: \`@origami-target ${target}\` is not a known target. Use \`@origami-target ${ORIGAMI_TARGETS[0]}\` or \`@origami-target ${ORIGAMI_TARGETS[1]}\` (tracker CONV-astParser.ts:54).`);
|
|
141
|
+
if (seenTargets.has(target))
|
|
142
|
+
return reportInFile(node, `rule \`${selector}\`: \`@origami-target ${target}\` appears twice. Merge the two blocks into one (tracker CONV-astParser.ts:57).`);
|
|
143
|
+
seenTargets.add(target);
|
|
144
|
+
const nested = getCssChildren(node).find((child) => child.type !== 'decl');
|
|
145
|
+
if (!nested)
|
|
146
|
+
return;
|
|
147
|
+
reportTargetShape(nested.type === 'atrule' && nested.name === 'media' ? `Move the \`@media\` out and around the whole rule: \`@media (…) { ${selector} { @origami-target … { … } } }\`` : 'Move it out of the block');
|
|
148
|
+
}
|
|
149
|
+
/** Reports an at-rule other than `@apply` inside a style rule, or a `@mixin`'s `&` rule (CONV-astParser.ts:95). A nested rule is dropped by the import, not refused. */
|
|
150
|
+
function checkStyleRule(rule, reportInFile) {
|
|
151
|
+
const selector = rule.selector.trim();
|
|
152
|
+
for (const child of getCssChildren(rule)) {
|
|
153
|
+
if (child.type === 'atrule' && child.name !== 'apply') {
|
|
154
|
+
reportInFile(child, `rule \`${selector}\`: \`@${child.name}\` can't be nested in a style rule. Put the rule inside it instead: \`@media (…) { ${selector} { … } }\` (in a @mixin: \`@result { @media (…) { &… { … } } }\`) (tracker CONV-astParser.ts:95).`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/** A `@mixin`'s `@result`: its `&` rules hold no at-rule but `@apply` (CONV-astParser.ts:95); its breakpoint blocks have a block (CONV-utils.ts:76) and a `@scope` starts with a `@media` (CONV-utils.ts:92). The rest of a mixin's shape is the global-styles rows'. */
|
|
159
|
+
function checkMixin(node, reportInFile) {
|
|
160
|
+
for (const child of getCssChildren(node)) {
|
|
161
|
+
if (child.type !== 'atrule' || child.name !== 'result')
|
|
162
|
+
continue;
|
|
163
|
+
checkMixinLevel(getCssChildren(child), reportInFile, true);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/** One level of a `@mixin`'s `@result`: its `&` rules, and (at the first level) its breakpoint blocks, whose content is the next level. */
|
|
167
|
+
function checkMixinLevel(nodes, reportInFile, allowBreakpoints) {
|
|
168
|
+
for (const child of nodes) {
|
|
169
|
+
if (child.type === 'rule') {
|
|
170
|
+
const selector = child.selector.trim();
|
|
171
|
+
if (selector.startsWith('&') && selector !== '&')
|
|
172
|
+
checkStyleRule(child, reportInFile);
|
|
173
|
+
}
|
|
174
|
+
else if (child.type === 'atrule' && allowBreakpoints && BREAKPOINT_AT_RULES.includes(child.name)) {
|
|
175
|
+
if (!hasBlock(child, reportInFile))
|
|
176
|
+
continue;
|
|
177
|
+
const media = getScopedMedia(child, reportInFile);
|
|
178
|
+
if (media)
|
|
179
|
+
checkMixinLevel(getCssChildren(media), reportInFile, false);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// ── reading the tree ──
|
|
184
|
+
const isMedia = (node) => node?.type === 'atrule' && node.name === 'media';
|
|
185
|
+
/** How many lines the import strips as leading imports: from the first line, each matching its pattern, no blank line between. A line
|
|
186
|
+
* ending in `\r\n` keeps its `\r` and doesn't match, as in the import (`extractLeadingCssImports` splits on `\n` too). */
|
|
187
|
+
function countLeadingImportLines(css) {
|
|
188
|
+
const lines = css.split('\n');
|
|
189
|
+
let count = 0;
|
|
190
|
+
while (count < lines.length && IMPORT_LINE.test(lines[count]))
|
|
191
|
+
count += 1;
|
|
192
|
+
return count;
|
|
193
|
+
}
|
|
194
|
+
// ── reporting ──
|
|
195
|
+
/** A page's style.css reads a `@mixin` only as one of the site's global styles (fromBundle.ts:1395, styleParser.ts:798); no note elsewhere. */
|
|
196
|
+
const pageMixinNote = (file) => (file === 'style.css' ? " (in a page's style.css, only for a global style the site already has)" : '');
|