@wix/origami-lint 1.34.4 → 1.34.6
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/dist/rules/bindings.js +210 -103
- package/dist/rules/css-selectors.js +270 -0
- package/dist/rules/data-values.js +17 -26
- package/dist/rules/index.js +2 -1
- package/dist/rules-api/index.js +3 -2
- package/dist/rules-api/rules-api.js +3 -1
- package/dist/shared/constants.js +41 -0
- package/dist/shared/css.js +23 -23
- package/dist/shared/utils.js +74 -39
- package/package.json +2 -2
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { BREAKPOINT_AT_RULES, createCssFileReporter, getSelectorSegments, isCssIdentifier, isSafeCssIdentifier, PREAMBLE_LAYER, readSegment } from "../rules-api/index.js";
|
|
2
|
+
import { defineRule } from "./define-rule.js";
|
|
3
|
+
/** The at-rules whose rules the import reads: breakpoint blocks, and a `@mixin`'s `@result`. */
|
|
4
|
+
const READ_AT_RULES = [...BREAKPOINT_AT_RULES, 'result'];
|
|
5
|
+
const SCOPE_OWNER = /^\(\.([^:[\]"\s]+)\)$/;
|
|
6
|
+
/** A class made of classes only (no attribute or `:not(…)` glued to it), whose later classes are state classes. */
|
|
7
|
+
const CLASSES_ONLY = /^[^:[\]"]+$/;
|
|
8
|
+
export default defineRule({
|
|
9
|
+
name: 'css-selectors',
|
|
10
|
+
editors: ['studio2'],
|
|
11
|
+
callbacks: {
|
|
12
|
+
Program: checkCssFilesSelectors,
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
// ── the steps ──
|
|
16
|
+
/** Every rule of layout.css, style.css and theme.css, with the class names of the page's elements. */
|
|
17
|
+
function checkCssFilesSelectors({ rulesApi, report }) {
|
|
18
|
+
const file = {
|
|
19
|
+
elementClassNames: new Set(Object.values(rulesApi.page.components).flatMap((component) => component.classNames)),
|
|
20
|
+
inMixin: false,
|
|
21
|
+
};
|
|
22
|
+
for (const kind of ['layout', 'style', 'theme']) {
|
|
23
|
+
const root = rulesApi.page.css.files[kind];
|
|
24
|
+
if (root)
|
|
25
|
+
checkNodes(root.nodes, { ...file, name: `${kind}.css`, reportAt: createCssFileReporter(`${kind}.css`, report) });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** The rules the import reads, file it reads them; a rule's own nested rules are dropped or refused by other rows, not read. */
|
|
29
|
+
function checkNodes(nodes, file) {
|
|
30
|
+
for (const node of nodes) {
|
|
31
|
+
if (node.type === 'rule')
|
|
32
|
+
checkSelector(node, file);
|
|
33
|
+
if (node.type !== 'atrule' || node.nodes === undefined)
|
|
34
|
+
continue;
|
|
35
|
+
if (node.name === 'layer' && node.params.trim() === PREAMBLE_LAYER)
|
|
36
|
+
continue;
|
|
37
|
+
if (node.name === 'scope')
|
|
38
|
+
checkScopeOwner(node, file);
|
|
39
|
+
if (node.name === 'mixin')
|
|
40
|
+
checkNodes(node.nodes, { ...file, inMixin: true });
|
|
41
|
+
else if (READ_AT_RULES.includes(node.name))
|
|
42
|
+
checkNodes(node.nodes, file);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** A `@scope` names its owner as `(.class)` (CONV-utils.ts:70), and the class is a plain identifier: `(.comp-a.comp-a--open)` and
|
|
46
|
+
* `(.comp-a#x)` pass the pattern, then the import refuses the owner (live: "Cannot resolve breakpoint {…ownerComponentId:
|
|
47
|
+
* "comp-a.x"}"; styleParser.ts:282 `assertSafeBreakpointId` behind it). */
|
|
48
|
+
function checkScopeOwner(node, file) {
|
|
49
|
+
const params = node.params.trim();
|
|
50
|
+
const owner = SCOPE_OWNER.exec(params)?.[1];
|
|
51
|
+
if (owner !== undefined && isSafeCssIdentifier(owner))
|
|
52
|
+
return;
|
|
53
|
+
file.reportAt(node, `\`@scope ${params}\` must name its owner as \`(.<class>)\`, one element's class. Write \`@scope (.${ownClassOf(owner ?? params.replace(/^[(.#\s]+/, ''))})\` (tracker CONV-utils.ts:70).`);
|
|
54
|
+
}
|
|
55
|
+
/** One rule's selector, in the import's order: the flat global style, the split, the parts, the first part, then the chain. */
|
|
56
|
+
function checkSelector(rule, file) {
|
|
57
|
+
const selector = rule.selector.replace(/\s+/g, ' ').trim();
|
|
58
|
+
if (selector.startsWith(':root'))
|
|
59
|
+
return;
|
|
60
|
+
if (!checkFlatGlobalStyle(selector, rule, file))
|
|
61
|
+
return;
|
|
62
|
+
const parts = splitParts(selector, rule, file);
|
|
63
|
+
if (!parts)
|
|
64
|
+
return;
|
|
65
|
+
const [first, ...rest] = parts;
|
|
66
|
+
if (first === undefined)
|
|
67
|
+
return file.reportAt(rule, 'the rule has an empty selector. Start it with the element\'s class: `.comp-a { … }` (tracker CONV-cssSelectors.ts:370).');
|
|
68
|
+
if (first.isAmpersand)
|
|
69
|
+
return checkGlobalStyleChain(first, rest, selector, rule, file);
|
|
70
|
+
if (file.inMixin)
|
|
71
|
+
return;
|
|
72
|
+
if (first.className === undefined)
|
|
73
|
+
return file.reportAt(rule, `selector \`${selector}\` doesn't start with an element. Start it with the element's class: \`.comp-a${first.text}\` (tracker CONV-cssSelectors.ts:370).`);
|
|
74
|
+
const element = splitStateClasses(first);
|
|
75
|
+
if (!checkElementClass(element.className, first.className, selector, rule, file))
|
|
76
|
+
return;
|
|
77
|
+
if (file.name === 'layout.css')
|
|
78
|
+
return checkLayoutChain(element, rest, selector, rule, file);
|
|
79
|
+
checkElementChain(element, rest, selector, rule, file);
|
|
80
|
+
}
|
|
81
|
+
/** Reports a style or theme rule written as `--id { … }` (CONV-astParser.ts:88). */
|
|
82
|
+
function checkFlatGlobalStyle(selector, rule, file) {
|
|
83
|
+
if (file.name === 'layout.css' || file.inMixin || !selector.startsWith('--'))
|
|
84
|
+
return true;
|
|
85
|
+
file.reportAt(rule, `\`${selector} { … }\` is not a valid rule. Declare the global style as \`@mixin ${selector} { @result { … } }\` (tracker CONV-astParser.ts:88).`);
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
/** Reports an element class that isn't a plain identifier or is a reserved name (CONV-cssSelectors.ts:54), naming the class as written. */
|
|
89
|
+
function checkElementClass(className, written, selector, rule, file) {
|
|
90
|
+
if (isSafeCssIdentifier(className))
|
|
91
|
+
return true;
|
|
92
|
+
const problem = isCssIdentifier(className) ? `\`${className}\` is a reserved name, not an element class. Give the element another className` : `\`${written}\` is not an element class. ${howToWrite(written, ownClassOf(className))}`;
|
|
93
|
+
file.reportAt(rule, `in \`${selector}\`, ${problem} (tracker CONV-cssSelectors.ts:54).`);
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
/** A layout.css rule: one element's class (CONV-layoutParser.ts:105), no state (:108); a part with no class is refused first (CONV-cssSelectors.ts:213). */
|
|
97
|
+
function checkLayoutChain(first, rest, selector, rule, file) {
|
|
98
|
+
if (!checkPartsHaveClass(rest, selector, rule, file))
|
|
99
|
+
return;
|
|
100
|
+
const state = [first, ...rest].flatMap((part) => part.states)[0];
|
|
101
|
+
if (rest.length) {
|
|
102
|
+
const element = rest.findLast((part) => file.elementClassNames.has(baseClassOf(part))) ?? first;
|
|
103
|
+
return file.reportAt(rule, `\`${selector}\` names more than one element; a layout rule takes one element's class: write \`.${baseClassOf(element)} { … }\` (layout can't be set on an inner part or through an ancestor) (tracker CONV-layoutParser.ts:105).`);
|
|
104
|
+
}
|
|
105
|
+
if (state !== undefined)
|
|
106
|
+
return file.reportAt(rule, `\`${selector}\` sets layout by state, which isn't supported: remove \`${state}\` from the selector (state changes go in style.css) (tracker CONV-layoutParser.ts:108).`);
|
|
107
|
+
checkElementExists(first, rule, file);
|
|
108
|
+
}
|
|
109
|
+
/** A style or theme rule on an element (cssSelectors.ts parseComponentChain): a second element needs a state on the first (:321), carries
|
|
110
|
+
* none of its own (:216), and is followed by no third (:318); then the inner parts, with no id (:222), then the element exists
|
|
111
|
+
* (CONV-fromBundle.ts:855). */
|
|
112
|
+
function checkElementChain(first, rest, selector, rule, file) {
|
|
113
|
+
let target = first;
|
|
114
|
+
let inner = rest;
|
|
115
|
+
if (rest.length && isAnotherElement(rest[0], file)) {
|
|
116
|
+
const second = splitStateClasses(rest[0]);
|
|
117
|
+
const third = rest.slice(1).find((part) => isAnotherElement(part, file));
|
|
118
|
+
if (third)
|
|
119
|
+
return file.reportAt(rule, `\`${selector}\` chains three elements. Keep one ancestor: \`.${first.className}${first.states.join('')} .${baseClassOf(third)}\` (tracker CONV-cssSelectors.ts:318).`);
|
|
120
|
+
if (!first.states.length)
|
|
121
|
+
return file.reportAt(rule, `\`${selector}\` puts one element under another without a state. Address the element by its own class (\`.${ownClassOf(second.className)}\`), or condition it on the ancestor's state (\`.${first.className}:hover .${ownClassOf(second.className)}\`) (tracker CONV-cssSelectors.ts:321).`);
|
|
122
|
+
if (second.states.length)
|
|
123
|
+
return reportTwoOwners(first, second, selector, rule, file);
|
|
124
|
+
if (!checkElementClass(second.className, rest[0].className, selector, rule, file))
|
|
125
|
+
return;
|
|
126
|
+
target = second;
|
|
127
|
+
inner = rest.slice(1);
|
|
128
|
+
}
|
|
129
|
+
if (!checkInnerParts(inner, first, selector, rule, file))
|
|
130
|
+
return;
|
|
131
|
+
if (!checkInnerPartsHaveNoId(inner, selector, rule, file))
|
|
132
|
+
return;
|
|
133
|
+
if (file.name === 'style.css')
|
|
134
|
+
checkElementExists(target, rule, file);
|
|
135
|
+
}
|
|
136
|
+
/** A class after an element is another element when the page has an element with it, or when it holds a `#` outside an attribute
|
|
137
|
+
* (`[data-x="#"]` is no id), which no inner part's selector has (`.comp-b#x`: the import reads it as an element and refuses it). Anything
|
|
138
|
+
* else is taken as an inner part (cssSelectors.ts namesInnerElementOf reads the contract; the lint doesn't). */
|
|
139
|
+
function isAnotherElement(part, file) {
|
|
140
|
+
return part.className !== undefined && (file.elementClassNames.has(baseClassOf(part)) || hasId(part));
|
|
141
|
+
}
|
|
142
|
+
/** Whether a part's class holds a `#` outside an attribute (`[data-x="#"]` is no id). */
|
|
143
|
+
const hasId = (part) => part.className?.replace(/\[[^\]]*\]/g, '').includes('#') ?? false;
|
|
144
|
+
/** Reports the first inner part with an id (CONV-cssSelectors.ts:222): no inner part's selector has one, so the import refuses
|
|
145
|
+
* `.comp-a:hover .label .foo#x` whatever `.label` is. */
|
|
146
|
+
function checkInnerPartsHaveNoId(inner, selector, rule, file) {
|
|
147
|
+
const part = inner.find(hasId);
|
|
148
|
+
if (part === undefined)
|
|
149
|
+
return true;
|
|
150
|
+
file.reportAt(rule, `in \`${selector}\`, \`.${part.className}\` is not an inner part: an inner part's selector has no \`#\` id. Use an inner part's selector from the element's contract, without the id (\`.${ownClassOf(part.className)}\`) (tracker CONV-cssSelectors.ts:222).`);
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
/** A `@mixin`'s rule, `&…` (cssSelectors.ts parseGlobalStyleChain): outside a `@mixin` it's refused (:377); a later `&` or `:root` is the
|
|
154
|
+
* global-styles rows'; then the inner parts, read as an element's (parseIeChain), with no id (:222). */
|
|
155
|
+
function checkGlobalStyleChain(first, rest, selector, rule, file) {
|
|
156
|
+
if (!file.inMixin)
|
|
157
|
+
return file.reportAt(rule, `\`${selector}\` is outside a \`@mixin\`. Replace \`&\` with the element's class (\`.comp-a${selector.slice(1)}\`), or move the rule into the global style's \`@mixin\` (tracker CONV-cssSelectors.ts:377).`);
|
|
158
|
+
if (rest.some((part) => part.isAmpersand || part.text === ':root'))
|
|
159
|
+
return;
|
|
160
|
+
if (!checkInnerParts(rest, first, selector, rule, file))
|
|
161
|
+
return;
|
|
162
|
+
checkInnerPartsHaveNoId(rest, selector, rule, file);
|
|
163
|
+
}
|
|
164
|
+
/** The inner parts after an element or `&` (cssSelectors.ts parseManifestSelector, parseOwnerStateSelector): every part has a class and
|
|
165
|
+
* states sit on one part (:213), not on it and the element both (:216). Only `:pseudo` states are read here: file a part's own selector
|
|
166
|
+
* ends and its state classes start is the contract's, which the lint doesn't read. */
|
|
167
|
+
function checkInnerParts(inner, owner, selector, rule, file) {
|
|
168
|
+
if (!checkPartsHaveClass(inner, selector, rule, file))
|
|
169
|
+
return false;
|
|
170
|
+
const stated = inner.filter((part) => part.states.length);
|
|
171
|
+
if (stated.length > 1) {
|
|
172
|
+
file.reportAt(rule, `in \`${selector}\`, states sit on \`${stated[0].text}\` and \`${stated[1].text}\`. Attach the states to the one part that owns them (tracker CONV-cssSelectors.ts:213).`);
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
if (stated.length && owner.states.length) {
|
|
176
|
+
reportTwoOwners(owner, stated[0], selector, rule, file);
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
/** Reports a part after the first with no class (CONV-cssSelectors.ts:213). */
|
|
182
|
+
function checkPartsHaveClass(parts, selector, rule, file) {
|
|
183
|
+
const bare = parts.find((part) => part.className === undefined);
|
|
184
|
+
if (!bare)
|
|
185
|
+
return true;
|
|
186
|
+
file.reportAt(rule, `in \`${selector}\`, \`${bare.text}\` has no class. Attach the state to the part that owns it (\`.comp-a:hover\`, \`.comp-a .label:hover\`) and write every later part as a class (tracker CONV-cssSelectors.ts:213).`);
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
/** Reports states on two owners (CONV-cssSelectors.ts:216). */
|
|
190
|
+
function reportTwoOwners(owner, other, selector, rule, file) {
|
|
191
|
+
const name = (part) => (part.isAmpersand ? '&' : `.${baseClassOf(part)}`);
|
|
192
|
+
file.reportAt(rule, `\`${selector}\` puts states on two owners (\`${name(owner)}\` and \`${name(other)}\`). Condition the rule on one owner's states only (tracker CONV-cssSelectors.ts:216).`);
|
|
193
|
+
}
|
|
194
|
+
/** Reports a rule for an element that structure.jsx doesn't have (CONV-fromBundle.ts:855). */
|
|
195
|
+
function checkElementExists(element, rule, file) {
|
|
196
|
+
if (file.inMixin || file.elementClassNames.has(element.className))
|
|
197
|
+
return;
|
|
198
|
+
file.reportAt(rule, `\`.${element.className}\` styles no element: no element in structure.jsx has className="${element.className}". Remove the rule, or write the className of an element (tracker CONV-fromBundle.ts:855).`);
|
|
199
|
+
}
|
|
200
|
+
// ── reading a selector ──
|
|
201
|
+
/** The selector's parts, split as the import splits them (getSelectorSegments, cssSelectors.ts splitSegments), each read as the import
|
|
202
|
+
* reads it. Null after reporting a list (:92), an unbalanced selector (:87) or a part it can't read (:169). */
|
|
203
|
+
function splitParts(selector, rule, file) {
|
|
204
|
+
const segments = getSelectorSegments(selector);
|
|
205
|
+
if (!segments)
|
|
206
|
+
return reportUnsplittable(selector, rule, file);
|
|
207
|
+
const parts = [];
|
|
208
|
+
for (const segment of segments) {
|
|
209
|
+
const part = readPart(segment, selector, rule, file);
|
|
210
|
+
if (!part)
|
|
211
|
+
return null;
|
|
212
|
+
parts.push(part);
|
|
213
|
+
}
|
|
214
|
+
return parts;
|
|
215
|
+
}
|
|
216
|
+
/** Reports why the import can't split a selector: a list (:92), or a bracket or quote not closed (:87), whichever comes first. */
|
|
217
|
+
function reportUnsplittable(selector, rule, file) {
|
|
218
|
+
const openers = [];
|
|
219
|
+
let inQuotes = false;
|
|
220
|
+
for (const char of selector) {
|
|
221
|
+
if (inQuotes)
|
|
222
|
+
inQuotes = char !== '"';
|
|
223
|
+
else if (char === '"')
|
|
224
|
+
inQuotes = true;
|
|
225
|
+
else if (char === '[' || char === '(')
|
|
226
|
+
openers.push(char);
|
|
227
|
+
else if ((char === ']' || char === ')') && !openers.pop())
|
|
228
|
+
return reportUnbalanced(`has a \`${char}\` with no \`${char === ']' ? '[' : '('}\` before it`, selector, rule, file);
|
|
229
|
+
else if (char === ',' && !openers.length) {
|
|
230
|
+
file.reportAt(rule, `\`${selector}\` is a selector list. Split it into one rule per selector (tracker CONV-cssSelectors.ts:92).`);
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return reportUnbalanced(inQuotes ? 'has an unclosed `"`' : `has an unclosed \`${openers.at(-1)}\``, selector, rule, file);
|
|
235
|
+
}
|
|
236
|
+
function reportUnbalanced(what, selector, rule, file) {
|
|
237
|
+
file.reportAt(rule, `selector \`${selector}\` ${what}. Close the bracket or quote (tracker CONV-cssSelectors.ts:87).`);
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
/** One part as the import reads it: `:root`, or `&` or a class, then states (`:hover`, `.state-class`); an attribute or functional
|
|
241
|
+
* pseudo-class glued to the class is part of the class. Null after reporting what it can't read (CONV-cssSelectors.ts:169). */
|
|
242
|
+
function readPart(text, selector, rule, file) {
|
|
243
|
+
if (text === ':root')
|
|
244
|
+
return { text, isAmpersand: false, states: [] };
|
|
245
|
+
const { base, states, unread } = readSegment(text);
|
|
246
|
+
if (unread) {
|
|
247
|
+
file.reportAt(rule, `in \`${selector}\`, the format can't read \`${unread}\`. ${howToWrite(unread, 'comp-a')} (tracker CONV-cssSelectors.ts:169).`);
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
return { text, isAmpersand: base === '&', className: base === '&' ? undefined : base?.slice(1), states };
|
|
251
|
+
}
|
|
252
|
+
/** The import reads an element's class as one class: the classes after it are its state classes (cssSelectors.ts withStateClasses). */
|
|
253
|
+
function splitStateClasses(part) {
|
|
254
|
+
if (part.className === undefined || !CLASSES_ONLY.test(part.className))
|
|
255
|
+
return part;
|
|
256
|
+
const [className, ...stateClasses] = part.className.split('.');
|
|
257
|
+
return { ...part, className, states: [...stateClasses.map((name) => `.${name}`), ...part.states] };
|
|
258
|
+
}
|
|
259
|
+
/** The identifier a class starts with, for a message's suggestion: 'comp-b' for `comp-b#x`; 'comp-a' when it starts with none. */
|
|
260
|
+
const ownClassOf = (className) => /^-?[_a-zA-Z][_a-zA-Z0-9-]*/.exec(className)?.[0] ?? 'comp-a';
|
|
261
|
+
/** A part's first class, before any state class glued to it: 'comp-b' for `.comp-b.comp-b--open`. */
|
|
262
|
+
const baseClassOf = (part) => part.className?.split('.')[0] ?? '';
|
|
263
|
+
/** What to write instead of a form the import can't read; the forms earlier converters wrote get their own line. */
|
|
264
|
+
function howToWrite(written, element) {
|
|
265
|
+
if (written.includes('[data-state~='))
|
|
266
|
+
return `Since converter 0.74 a state is written as \`:hover\` or as a state class (\`.${element}.${element}--open\`), not \`[data-state~=…]\``;
|
|
267
|
+
if (written.includes('[data-ie-name='))
|
|
268
|
+
return `Since converter 0.71 an inner part is addressed by its class from the component's contract (\`.label\`), not \`[data-ie-name]\``;
|
|
269
|
+
return `Address the element by its own class only (\`.${element}\`); write a state as \`:hover\` or as a state class (\`.${element}.${element}--open\`)`;
|
|
270
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// 1227, 1231, 1235, 1255, 1268, 1289, 1697; projectData.ts 214, 225, 281, 435, 443; enrichImage.ts 94, 105.
|
|
3
3
|
// Converter 0.81 (checked at 0.84, 2c0e21c): the tag's import, not a `type` attr, decides if a component takes data attributes; `type` is data.
|
|
4
4
|
import { isDeepStrictEqual } from 'node:util';
|
|
5
|
-
import { DATA_KEY,
|
|
5
|
+
import { DATA_KEY, EVENT_ATTRIBUTE, RESERVED_ATTRIBUTES, RESERVED_ENTRY_KEYS, bare, getAttribute, getExpression, getKey, getName, getObject, getProperties, getCallbackKind, getProperty, isBound, isOneEditAway, isReservedAttribute, isStructure, ownerOf, } from "../rules-api/index.js";
|
|
6
6
|
import { defineRule } from "./define-rule.js";
|
|
7
7
|
/** The names the import never reads as data (dataKeys.ts). The React name is joined as the converter does: Wix Standards fails the build on it in any file. */
|
|
8
8
|
const EXCLUDED_DATA_KEYS = [
|
|
@@ -66,9 +66,9 @@ function checkValuesAreLiterals(input, skip) {
|
|
|
66
66
|
continue;
|
|
67
67
|
}
|
|
68
68
|
const value = getExpression(attribute);
|
|
69
|
-
if (!value || skip.includes(name) || (
|
|
69
|
+
if (!value || skip.includes(name) || (isReservedAttribute(name) && !READ_AS_DATA.includes(name)))
|
|
70
70
|
continue;
|
|
71
|
-
if (!
|
|
71
|
+
if (!isReservedAttribute(name) && (isExcluded(name) || nearMissOf(name) || isBound(value) || getCallbackKind(value) === 'template'))
|
|
72
72
|
continue;
|
|
73
73
|
const mode = name === 'props' ? PROPS : FULL;
|
|
74
74
|
const found = name === 'elementProps' ? nonLiteralInElementProps(value, mode, input.rulesApi, textOf(input)) : nonLiteralIn(value, mode, input.rulesApi, textOf(input));
|
|
@@ -107,7 +107,7 @@ function checkElementPropsEntries(input, map, path) {
|
|
|
107
107
|
for (const property of getProperties(map)) {
|
|
108
108
|
const name = getKey(property);
|
|
109
109
|
const at = [...path, name].join('.');
|
|
110
|
-
if (!checkEntryName(input, property, name) ||
|
|
110
|
+
if (!checkEntryName(input, property, name) || isRuntimeCall(bare(property.value), input.rulesApi))
|
|
111
111
|
continue;
|
|
112
112
|
// an object written out: a media call decodes to an object, but not to an inner part's settings
|
|
113
113
|
const entry = getObject(property.value);
|
|
@@ -138,10 +138,10 @@ function checkEntryName(input, property, name) {
|
|
|
138
138
|
function checkEntryKeysAreKnown(input, entry, at) {
|
|
139
139
|
for (const property of getProperties(entry)) {
|
|
140
140
|
const key = getKey(property);
|
|
141
|
-
if (key === null || key === '_id' || RESERVED_ENTRY_KEYS.includes(key) || bare(property.value)
|
|
141
|
+
if (key === null || key === '_id' || RESERVED_ENTRY_KEYS.includes(key) || isBound(bare(property.value)))
|
|
142
142
|
continue;
|
|
143
143
|
const typo = nearMissOf(key);
|
|
144
|
-
if (!
|
|
144
|
+
if (!isReservedAttribute(key) && !isExcluded(key) && !typo)
|
|
145
145
|
continue;
|
|
146
146
|
say(input, property.key, `${owner(input)}'s elementProps entry \`${at}\` has the key \`${key}\`, which an inner part doesn't take. Use its data keys, or \`data: {…}\`${typo ? ` (did you mean \`${typo}\`?)` : ''}.`);
|
|
147
147
|
}
|
|
@@ -151,7 +151,7 @@ function checkDataCarrier(input) {
|
|
|
151
151
|
const value = getExpression(data);
|
|
152
152
|
if (!value)
|
|
153
153
|
return null;
|
|
154
|
-
const merges = dataAttributes(input).length > 0 || (
|
|
154
|
+
const merges = dataAttributes(input).length > 0 || (input.rulesApi.hasDataAttributes(input.node) && getAttribute(input.node, '_id') !== undefined);
|
|
155
155
|
if (!merges)
|
|
156
156
|
return null;
|
|
157
157
|
if (!isObjectValue(value, input.rulesApi)) {
|
|
@@ -286,34 +286,23 @@ function imageCallsIn(input, dataValuesRead, read) {
|
|
|
286
286
|
if (value.type === 'ArrayExpression')
|
|
287
287
|
return value.elements.flatMap((element) => (element && element.type !== 'SpreadElement' ? calls(element) : []));
|
|
288
288
|
if (value.type === 'ObjectExpression')
|
|
289
|
-
return getProperties(value).flatMap((property) => (
|
|
289
|
+
return getProperties(value).flatMap((property) => (isRuntimeCall(bare(property.value), input.rulesApi) ? [] : calls(property.value)));
|
|
290
290
|
return [];
|
|
291
291
|
};
|
|
292
292
|
return values.flatMap(calls);
|
|
293
293
|
}
|
|
294
294
|
// ── reading the code ──
|
|
295
295
|
const getAttributes = (element) => element.openingElement.attributes.filter((attribute) => attribute.type === 'JSXAttribute');
|
|
296
|
-
const getObject = (node) => (node && bare(node).type === 'ObjectExpression' ? bare(node) : null);
|
|
297
|
-
/** The tags the import reads as the DM's own components ('document') or a global component's reference (identity.ts identityOfTag): no data attributes. */
|
|
298
|
-
const NO_DATA_TYPES = ['page', 'section', 'header', 'footer', 'headerLandmark', 'footerLandmark', ...GLOBAL_SECTIONS];
|
|
299
|
-
/** Converter 0.81: a builder component (its import gives a componentType) takes data attributes; the `type` attr is gone. */
|
|
300
|
-
function takesData({ node, rulesApi }) {
|
|
301
|
-
const found = rulesApi.getImport(node);
|
|
302
|
-
const type = rulesApi.getType(node);
|
|
303
|
-
return found !== null && found.kind !== 'core' && !found.source.startsWith('.') && !(type && NO_DATA_TYPES.includes(type));
|
|
304
|
-
}
|
|
305
296
|
/** The name the import gives the tag's component (parseJsx.ts namedImportOf): the imported name, else the tag's own (`<Box>`, `<ui.Box>`). */
|
|
306
297
|
function componentNameOf({ node, rulesApi }) {
|
|
307
298
|
const imported = rulesApi.getImport(node)?.imported;
|
|
308
299
|
return imported && imported !== 'default' && imported !== '*' ? imported : rulesApi.getTagName(node).split('.').at(-1);
|
|
309
300
|
}
|
|
310
|
-
|
|
311
|
-
const
|
|
312
|
-
const isDataName = (name) => !isReserved(name) && !isExcluded(name) && nearMissOf(name) === undefined;
|
|
313
|
-
const isExcluded = (name) => EXCLUDED_DATA_KEYS.includes(name) || name.startsWith('data-') || name.startsWith('aria-') || /^on/i.test(name) || !DATA_KEY.test(name);
|
|
301
|
+
const isDataName = (name) => !isReservedAttribute(name) && !isExcluded(name) && nearMissOf(name) === undefined;
|
|
302
|
+
const isExcluded = (name) => EXCLUDED_DATA_KEYS.includes(name) || name.startsWith('data-') || name.startsWith('aria-') || EVENT_ATTRIBUTE.test(name) || !DATA_KEY.test(name);
|
|
314
303
|
const nearMissOf = (name) => RESERVED_ATTRIBUTES.find((target) => isOneEditAway(name, target));
|
|
315
|
-
const dataAttributes = (input) => !
|
|
316
|
-
const entryDataKeys = (entry) => getProperties(entry).filter((property) => getKey(property) !== null && !RESERVED_ENTRY_KEYS.includes(getKey(property)) && bare(property.value)
|
|
304
|
+
const dataAttributes = (input) => !input.rulesApi.hasDataAttributes(input.node) ? [] : getAttributes(input.node).filter((attribute) => isDataName(getName(attribute)) && !isBound(getExpression(attribute)) && getCallbackKind(getExpression(attribute) ?? attribute) !== 'template');
|
|
305
|
+
const entryDataKeys = (entry) => getProperties(entry).filter((property) => getKey(property) !== null && !RESERVED_ENTRY_KEYS.includes(getKey(property)) && !isBound(bare(property.value)));
|
|
317
306
|
/** The attribute a data value's path starts at: `data.x` → `data`, `elementProps.nav.label` → `elementProps`. */
|
|
318
307
|
const attributeOf = (path) => path.split('.')[0];
|
|
319
308
|
function getEntries(map, path = []) {
|
|
@@ -357,7 +346,8 @@ function documentIdentifierOf(node, rulesApi) {
|
|
|
357
346
|
}
|
|
358
347
|
/** What `x` decodes to is an object: an object written out, or a media call (the import keeps it as an object). */
|
|
359
348
|
const isObjectValue = (node, rulesApi) => bare(node).type === 'ObjectExpression' || isImageCall(bare(node), rulesApi) || documentIdentifierOf(bare(node), rulesApi) !== null;
|
|
360
|
-
|
|
349
|
+
/** A call of any name from the runtime, a builder or not: the bindings rule reports one that isn't a builder. */
|
|
350
|
+
function isRuntimeCall(node, rulesApi) {
|
|
361
351
|
return node.type === 'CallExpression' && node.callee.type === 'Identifier' && rulesApi.getImport(node.callee)?.kind === 'runtime';
|
|
362
352
|
}
|
|
363
353
|
function nonLiteralIn(node, mode, rulesApi, getText, path = '') {
|
|
@@ -433,7 +423,7 @@ function nonLiteralInElementProps(node, mode, rulesApi, getText, path = '') {
|
|
|
433
423
|
const readEntry = (property, at) => {
|
|
434
424
|
const key = getKey(property);
|
|
435
425
|
const value = bare(property.value);
|
|
436
|
-
if (key === 'bindings' || key === 'slots' || key === 'className' || (!RESERVED_ENTRY_KEYS.includes(key) && value
|
|
426
|
+
if (key === 'bindings' || key === 'slots' || key === 'className' || (!RESERVED_ENTRY_KEYS.includes(key) && isBound(value)))
|
|
437
427
|
return null;
|
|
438
428
|
return key === 'elementProps' ? nonLiteralInElementProps(value, mode, rulesApi, getText, at) : nonLiteralIn(value, mode, rulesApi, getText, at);
|
|
439
429
|
};
|
|
@@ -499,10 +489,11 @@ function dataValues(input) {
|
|
|
499
489
|
}
|
|
500
490
|
return values;
|
|
501
491
|
}
|
|
492
|
+
/** In Structure's own JSX, or in a renderItem template, which the import reads in the row's scope (parseJsx.ts claimSlotChild). */
|
|
502
493
|
function isWrittenOut(element) {
|
|
503
494
|
for (let up = element.parent; up.type !== 'JSXElement' && up.type !== 'Program'; up = up.parent) {
|
|
504
495
|
if (up.type === 'ArrowFunctionExpression')
|
|
505
|
-
return isStructure(up);
|
|
496
|
+
return isStructure(up) || getCallbackKind(up) === 'template';
|
|
506
497
|
if (NOT_READ_IN.includes(up.type) || (up.type === 'JSXExpressionContainer' && up.parent.type !== 'JSXAttribute'))
|
|
507
498
|
return false;
|
|
508
499
|
}
|
package/dist/rules/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import bindings from "./bindings.js";
|
|
|
4
4
|
import breakpoints from "./breakpoints.js";
|
|
5
5
|
import cssFileNesting from "./css-file-nesting.js";
|
|
6
6
|
import cssLayoutProperties from "./css-layout-properties.js";
|
|
7
|
+
import cssSelectors from "./css-selectors.js";
|
|
7
8
|
import dataValues from "./data-values.js";
|
|
8
9
|
import globalStyles from "./global-styles.js";
|
|
9
10
|
import noAbsoluteSection from "./no-absolute-section.js";
|
|
@@ -12,5 +13,5 @@ import slotAttributes from "./slot-attributes.js";
|
|
|
12
13
|
import themeFontIds from "./theme-font-ids.js";
|
|
13
14
|
import themeRoot from "./theme-root.js";
|
|
14
15
|
import noFreeCode from "./no-free-code.js";
|
|
15
|
-
export const ruleRegistry = [allowedImports, noFreeCode, pageStructure, noAbsoluteSection, themeFontIds, slotAttributes, themeRoot, breakpoints, cssFileNesting, globalStyles, applyAndPresets, bindings, dataValues, cssLayoutProperties];
|
|
16
|
+
export const ruleRegistry = [allowedImports, noFreeCode, pageStructure, noAbsoluteSection, themeFontIds, slotAttributes, themeRoot, breakpoints, cssFileNesting, globalStyles, applyAndPresets, bindings, dataValues, cssLayoutProperties, cssSelectors];
|
|
16
17
|
export { defineRule } from "./define-rule.js";
|
package/dist/rules-api/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Part 3 — rules API: one linted file's page and the functions over it, and how a rule reports.
|
|
2
2
|
export { createRulesApi, getOrCreateRulesApi } from "./rules-api.js";
|
|
3
3
|
export { createCssFileReporter, describeAtRule, reportCssProblem } from "./report.js";
|
|
4
|
-
export {
|
|
5
|
-
export {
|
|
4
|
+
export { bare, getAttribute, getExpression, getKey, getName, getObject, getProperties, getCallbackKind, getParamReadKind, getProperty, isBound, isOneEditAway, isReservedAttribute, isStructure, ownerOf, } from "../shared/utils.js";
|
|
5
|
+
export { BUILDERS, CALLBACK_PROP, DATA_KEY, EVENT_ATTRIBUTE, GLOBAL_SECTIONS, INSTANCE_PROPS, LAYER_ATTRIBUTES, RESERVED_ATTRIBUTES, RESERVED_ENTRY_KEYS, RUNTIME_ONLY_BUILDERS, RUNTIME_PACKAGE, SECTIONS, SHARED_COMP_ID, STRUCTURE, SYSTEM_PACKAGE, TRANSFORM_BUILDERS, TYPED_BUILDERS, WRAPPERS, } from "../shared/constants.js";
|
|
6
|
+
export { BREAKPOINT_AT_RULES, CONTENT_TARGET, getCssChildren, getDashedId, getSegmentClass, getSelectorSegments, isCssIdentifier, isPresetProperty, isSafeCssIdentifier, ORIGAMI_TARGETS, OVERFLOW_TARGET, PREAMBLE_LAYER, readSegment } from "../shared/css.js";
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
// One per file, shared by every rule on it. Only building its page reads from disk, once per file.
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { readPage } from "../page-model/index.js";
|
|
6
|
-
import { getAttributeElements as getHeldElements, getChildren, getId, getImport, getImportKind, getImportedName, getPropNames, getSlot, getTagName, getType,
|
|
6
|
+
import { getAttributeElements as getHeldElements, getChildren, getId, getImport, getImportKind, getImportedName, getPropNames, getSlot, getTagName, getType, hasDataAttributes, isTypeScript } from "../shared/utils.js";
|
|
7
7
|
import { readBreakpointName, readCustomMedia, readThemeTokens } from "../shared/css.js";
|
|
8
|
+
import { WRAPPERS } from "../shared/constants.js";
|
|
8
9
|
import { getEnclosingAtRules, isBaseDeclaration } from "./utils.js";
|
|
9
10
|
/** Theme lines come from @apply inside style.css, so style.css's own lines usually come after them. */
|
|
10
11
|
const CASCADE_ORDER = ['layout', 'theme', 'style'];
|
|
@@ -43,6 +44,7 @@ export function createRulesApi(page) {
|
|
|
43
44
|
getAttributeElements,
|
|
44
45
|
getSlot,
|
|
45
46
|
getImport: (node) => getImport(node.type === 'JSXElement' ? getTagName(node).split('.')[0] : node.name, getProgram(node)),
|
|
47
|
+
hasDataAttributes: (node) => hasDataAttributes(node, getProgram(node)),
|
|
46
48
|
getImportKind,
|
|
47
49
|
getImportedName,
|
|
48
50
|
isTypeScript,
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Any section: rules that don't care which one. */
|
|
2
|
+
export const SECTIONS = ['section', 'header', 'footer'];
|
|
3
|
+
/** A page's reference to a global component (`<HeaderSection_x sharedCompId="…" />`). */
|
|
4
|
+
export const GLOBAL_SECTIONS = ['globalHeader', 'globalFooter', 'globalSection'];
|
|
5
|
+
/** A tag that wraps one component and isn't one itself: `<Remove when={…}>`, `<Interaction interactKey="…">`. */
|
|
6
|
+
export const WRAPPERS = ['remove', 'interaction'];
|
|
7
|
+
/** The name a structure file exports its tree under: `export const Structure = () => (…)`. */
|
|
8
|
+
export const STRUCTURE = 'Structure';
|
|
9
|
+
/** The props a global component's Structure may take: its instance identity, `({ id, className })` (#300). */
|
|
10
|
+
export const INSTANCE_PROPS = ['id', 'className'];
|
|
11
|
+
/** The system components package: `Page`, the sections, `InternalContainer`. */
|
|
12
|
+
export const SYSTEM_PACKAGE = '@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 typed builders (converter 0.76, PRIMITIVE_DATA_TYPES): `email('a@b.c')` is a fixed value of that type, like a literal. */
|
|
16
|
+
export const TYPED_BUILDERS = ['textEnum', 'phone', 'email', 'webUrl', 'hostname', 'regex', 'guid', 'direction', 'localDate', 'localTime', 'localDateTime', 'dateTime'];
|
|
17
|
+
/** The builders whose second argument is a callback, `map(source, (item) => ({…}))` (astValue.ts TRANSFORM_CALLBACKS). */
|
|
18
|
+
export const TRANSFORM_BUILDERS = ['map', 'shape'];
|
|
19
|
+
/** The runtime's builders: the converter's BINDINGS_BUILDER_ORDER without `Remove`, which is a tag (reservedAttributes.ts); `map`, `shape` and `callback` since converter 0.82–0.86. */
|
|
20
|
+
export const BUILDERS = [
|
|
21
|
+
'all', 'ctx', 'fn', 'is', 'raw', 'state', 'map', 'shape', 'callback',
|
|
22
|
+
...TYPED_BUILDERS,
|
|
23
|
+
];
|
|
24
|
+
/** The six builders no other package exports under these names; `email`, `regex`… may well come from a library for fn(). */
|
|
25
|
+
export const RUNTIME_ONLY_BUILDERS = ['all', 'ctx', 'fn', 'is', 'raw', 'state'];
|
|
26
|
+
/** 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). */
|
|
27
|
+
export const RESERVED_ATTRIBUTES = [
|
|
28
|
+
'id', '_id', 'data', 'props', 'skin', 'styleId', 'className', 'documentType', 'style', 'slot', 'bindings', 'elementProps',
|
|
29
|
+
];
|
|
30
|
+
/** The attribute of a page's reference to a global component (SHARED_COMP_ID_ATTR); on any other tag it's data. */
|
|
31
|
+
export const SHARED_COMP_ID = 'sharedCompId';
|
|
32
|
+
/** 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). */
|
|
33
|
+
export const RESERVED_ENTRY_KEYS = ['data', 'slots', 'elementProps', 'bindings', 'className'];
|
|
34
|
+
/** A data key is a JavaScript name (dataKeys.ts). */
|
|
35
|
+
export const DATA_KEY = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
36
|
+
/** An attribute the import reads as a React event, never as data: `on` in any case (dataKeys.ts REACT_EVENT_ATTR_PATTERN, converter 0.87). */
|
|
37
|
+
export const EVENT_ATTRIBUTE = /^on/i;
|
|
38
|
+
/** A callback prop, which the runtime hands a function: `on` and a capital letter, `onClick` but not `onclick` (the runtime's withOrigami.tsx). */
|
|
39
|
+
export const CALLBACK_PROP = /^on[A-Z]/;
|
|
40
|
+
/** By import source; the wrappers also by the imported name, since their packages export other things (`Show`, `Interact`). */
|
|
41
|
+
export const LAYER_ATTRIBUTES = ['backgroundLayer', 'pinnedLayer'];
|
package/dist/shared/css.js
CHANGED
|
@@ -93,32 +93,32 @@ export function getSelectorSegments(selector) {
|
|
|
93
93
|
return merged;
|
|
94
94
|
}, []);
|
|
95
95
|
}
|
|
96
|
-
/**
|
|
97
|
-
*
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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;
|
|
96
|
+
/** A segment's base, states and what the import can't read. A functional pseudo-class (`:not(:hover)`) or an attribute (`[data-state~="open"]`)
|
|
97
|
+
* right after the class is part of the class; after a state, or after `&`, it isn't read. */
|
|
98
|
+
export function readSegment(segment) {
|
|
99
|
+
let base = segment.startsWith('&') ? '&' : BASE_CLASS.exec(segment)?.[0];
|
|
100
|
+
const isClass = base !== undefined && base !== '&';
|
|
101
|
+
const states = [];
|
|
102
|
+
let rest = segment.slice(base?.length ?? 0);
|
|
110
103
|
while (rest) {
|
|
111
|
-
const
|
|
104
|
+
const gluedToClass = isClass && states.length === 0;
|
|
105
|
+
const functional = gluedToClass ? FUNCTIONAL_PSEUDO.exec(rest) : null;
|
|
112
106
|
const state = functional ? null : (STATE_CLASS.exec(rest) ?? PSEUDO_CLASS.exec(rest));
|
|
113
|
-
const attribute = functional || state ||
|
|
114
|
-
const token = functional ?? state ?? attribute;
|
|
115
|
-
if (
|
|
116
|
-
|
|
107
|
+
const attribute = functional || state || !gluedToClass ? null : ATTRIBUTE.exec(rest);
|
|
108
|
+
const token = (functional ?? state ?? attribute)?.[0];
|
|
109
|
+
if (token === undefined)
|
|
110
|
+
break;
|
|
117
111
|
if (state)
|
|
118
|
-
|
|
112
|
+
states.push(token);
|
|
119
113
|
else
|
|
120
|
-
|
|
121
|
-
rest = rest.slice(token
|
|
114
|
+
base += token;
|
|
115
|
+
rest = rest.slice(token.length);
|
|
122
116
|
}
|
|
123
|
-
return
|
|
117
|
+
return { base, states, unread: rest };
|
|
118
|
+
}
|
|
119
|
+
/** A segment's class, its states taken out: `&:hover` → `&`; `.label:hover` → `.label`; `.label.open` → `.label`. Null for a segment the import
|
|
120
|
+
* can't parse: a part that isn't a class (`[data-ie-name="x"]`, `:hover` alone, `:root`), or `&` followed by anything but a state (`&[x]`, `&:not(…)`). */
|
|
121
|
+
export function getSegmentClass(segment) {
|
|
122
|
+
const { base, unread } = readSegment(segment);
|
|
123
|
+
return base === undefined || unread ? null : base;
|
|
124
124
|
}
|