@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.
@@ -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, GLOBAL_SECTIONS, RESERVED_ATTRIBUTES, RESERVED_ENTRY_KEYS, SHARED_COMP_ID, bare, getAttribute, getExpression, getKey, getName, getProperties, getProperty, isOneEditAway, isStructure, ownerOf, } from "../rules-api/index.js";
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) || (isReserved(name) && !READ_AS_DATA.includes(name)))
69
+ if (!value || skip.includes(name) || (isReservedAttribute(name) && !READ_AS_DATA.includes(name)))
70
70
  continue;
71
- if (!isReserved(name) && (isExcluded(name) || nearMissOf(name) || value.type === 'CallExpression'))
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) || builderOf(bare(property.value), input.rulesApi))
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).type === 'CallExpression')
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 (!isReserved(key) && !isExcluded(key) && !typo)
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 || (takesData(input) && getAttribute(input.node, '_id') !== undefined);
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) => (builderOf(bare(property.value), input.rulesApi) ? [] : calls(property.value)));
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
- /** Not the component's data: the converter's reserved attributes, and `sharedCompId`. */
311
- const isReserved = (name) => RESERVED_ATTRIBUTES.includes(name) || name === SHARED_COMP_ID;
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) => !takesData(input) ? [] : getAttributes(input.node).filter((attribute) => isDataName(getName(attribute)) && getExpression(attribute)?.type !== 'CallExpression');
316
- const entryDataKeys = (entry) => getProperties(entry).filter((property) => getKey(property) !== null && !RESERVED_ENTRY_KEYS.includes(getKey(property)) && bare(property.value).type !== 'CallExpression');
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
- function builderOf(node, rulesApi) {
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.type === 'CallExpression'))
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
  }
@@ -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";
@@ -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 { BUILDERS, DATA_KEY, GLOBAL_SECTIONS, INSTANCE_PROPS, LAYER_ATTRIBUTES, RESERVED_ATTRIBUTES, RESERVED_ENTRY_KEYS, RUNTIME_ONLY_BUILDERS, RUNTIME_PACKAGE, SECTIONS, SHARED_COMP_ID, STRUCTURE, WRAPPERS, bare, getAttribute, getExpression, getKey, getName, getProperties, getProperty, isOneEditAway, isStructure, ownerOf, } from "../shared/utils.js";
5
- export { BREAKPOINT_AT_RULES, CONTENT_TARGET, getCssChildren, getDashedId, getSegmentClass, getSelectorSegments, isCssIdentifier, isPresetProperty, isSafeCssIdentifier, ORIGAMI_TARGETS, OVERFLOW_TARGET, PREAMBLE_LAYER } from "../shared/css.js";
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, isTypeScript, WRAPPERS } from "../shared/utils.js";
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'];
@@ -93,32 +93,32 @@ export function getSelectorSegments(selector) {
93
93
  return merged;
94
94
  }, []);
95
95
  }
96
- /**
97
- * A segment's class, its states taken out, read as cssSelectors.ts parseSegment reads it (0.83.0): `&:hover` → `&` (the mixin's own
98
- * element); `.label:hover` → `.label`; `.label.open` → `.label`. A functional pseudo-class (`:not(:hover)`) or an attribute
99
- * (`[data-state~="open"]`) right after the class is part of the class, its brackets whole. Null for a segment the import can't parse:
100
- * a part that isn't a class (`[data-ie-name="x"]`, `:hover` alone, `:root`), or `&` followed by anything but a state (`&[x]`, `&:not(…)`).
101
- */
102
- export function getSegmentClass(segment) {
103
- const base = segment.startsWith('&') ? '&' : BASE_CLASS.exec(segment)?.[0];
104
- if (base === undefined)
105
- return null;
106
- const isClass = base !== '&';
107
- let kept = base;
108
- let rest = segment.slice(base.length);
109
- let seenState = false;
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 functional = isClass && !seenState ? FUNCTIONAL_PSEUDO.exec(rest) : null;
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 || seenState || !isClass ? null : ATTRIBUTE.exec(rest);
114
- const token = functional ?? state ?? attribute;
115
- if (!token)
116
- return null;
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
- seenState = true;
112
+ states.push(token);
119
113
  else
120
- kept += token[0];
121
- rest = rest.slice(token[0].length);
114
+ base += token;
115
+ rest = rest.slice(token.length);
122
116
  }
123
- return kept;
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
  }