@wix/origami-lint 1.34.3 → 1.34.5

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
+ }
@@ -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";
@@ -6,6 +6,8 @@
6
6
  // recognized by import source (rulesApi.getType), so renaming an import doesn't matter.
7
7
  // Checked in every file, pages and global components alike:
8
8
  // - Structure returns one element, with a string id (tracker CONV-runValidation.ts:9);
9
+ // - every section, wherever it sits (a page's, a global component's top one, one nested lower down), holds exactly one
10
+ // <InternalContainer> and nothing else: the site's own build refuses anything else (rule S2-SECTION-001, tracker OUR-001);
9
11
  // - a <Page> is only ever the top of its own page: one held by another element is refused at save (rule card P1-R16);
10
12
  // - the header and footer sit outside every <InternalContainer>: inside one, that container can no longer be deleted in the
11
13
  // editor (rule card P1-R03). Both the HeaderSection / FooterSection tags and a page's reference to the global ones;
@@ -38,6 +40,8 @@ export default defineRule({
38
40
  if (isLandmark(input.node, input.rulesApi))
39
41
  hasSectionInLandmark(input);
40
42
  hasOnlyElements(input);
43
+ if (isSection(input.node, input.rulesApi))
44
+ hasOneInternalContainer(input.node, input);
41
45
  hasLayerArrays(input);
42
46
  isNotNestedPage(input);
43
47
  isOutsideInternalContainers(input);
@@ -50,8 +54,7 @@ export default defineRule({
50
54
  return;
51
55
  if (!startsWithPage(input))
52
56
  return;
53
- for (const section of getSections(input))
54
- hasOneInternalContainer(section, input);
57
+ checkPageChildren(input);
55
58
  },
56
59
  },
57
60
  });
@@ -94,29 +97,25 @@ function startsWithPage({ node, rulesApi, report }) {
94
97
  report({ loc: getLoc(node, rulesApi), message: `A page must start with <Page>, found ${describe(node, rulesApi)}.` });
95
98
  return false;
96
99
  }
97
- /** <Page>'s sections. Any other component (the page's <Background>, a pinned element) is accepted, as the import accepts it; reports text and {…} (CONV-parseJsx.ts:1668). */
98
- function getSections({ node: page, rulesApi, report }) {
99
- const sections = [];
100
+ /** Reports text and {…} directly in <Page> (CONV-parseJsx.ts:1668). Any component is accepted there: a section, the page's <Background>, a pinned element, as the import accepts it. */
101
+ function checkPageChildren({ node: page, rulesApi, report }) {
100
102
  for (const child of getStructuralChildren(page, rulesApi)) {
101
- if (child.type === 'JSXElement') {
102
- const type = rulesApi.getType(child);
103
- if (type && SECTIONS.includes(type))
104
- sections.push(child);
103
+ if (child.type === 'JSXElement')
105
104
  continue;
106
- }
107
105
  report({ loc: getLoc(page, rulesApi), message: `<Page> has ${describe(child, rulesApi)} as a child. Only elements go directly inside <Page>: a section, or a component pinned to the page (tracker CONV-parseJsx.ts:1668).` });
108
106
  }
109
- return sections;
110
107
  }
111
- /** Reports a section that doesn't hold exactly one <InternalContainer>, and nothing else. A wrapper in it that holds the wrong number of elements is reported by its own step first. */
108
+ /** Reports a section, anywhere in the file, that doesn't hold exactly one <InternalContainer>, and nothing else (S2-SECTION-001, OUR-001). A wrapper in it
109
+ * that holds the wrong number of elements is reported by its own step first; text and {…} count only where hasOnlyElements leaves them to this step. */
112
110
  function hasOneInternalContainer(section, { rulesApi, report }) {
113
111
  if (rulesApi.getChildren(section).some((child) => child.type === 'JSXElement' && isWrapper(child, rulesApi) && !holdsOne(child)))
114
112
  return;
115
- const inside = getStructuralChildren(section, rulesApi);
113
+ const reportsText = isRootTreeHolder(section, rulesApi);
114
+ const inside = getStructuralChildren(section, rulesApi).filter((child) => reportsText || child.type === 'JSXElement');
116
115
  if (inside.length === 1 && inside[0].type === 'JSXElement' && rulesApi.getType(inside[0]) === 'innerElement')
117
116
  return;
118
117
  const name = rulesApi.getId(section) ?? `<${rulesApi.getTagName(section)}>`;
119
- report({ loc: getLoc(section, rulesApi), message: `Section ${name} must contain exactly one <InternalContainer>, found: ${inside.map((child) => describe(child, rulesApi)).join(', ') || 'nothing'}.` });
118
+ report({ loc: getLoc(section, rulesApi), message: `Section ${name} must contain exactly one <InternalContainer>, found: ${inside.map((child) => describe(child, rulesApi)).join(', ') || 'nothing'}. Put the section's content inside its one <InternalContainer> (rule S2-SECTION-001).` });
120
119
  }
121
120
  /** Reports a <Page> held by another element (P1-R16), unless the root steps above report that placement already (a section's children). */
122
121
  function isNotNestedPage({ node, rulesApi, report }) {
@@ -250,6 +249,11 @@ function describe(child, rulesApi) {
250
249
  return `<${rulesApi.getTagName(child)}>`;
251
250
  return child.type === 'JSXText' ? 'text' : 'a {…} expression';
252
251
  }
252
+ /** Section, HeaderSection, FooterSection (by import source); not a page's reference to a global one. */
253
+ function isSection(node, rulesApi) {
254
+ const type = rulesApi.getType(node);
255
+ return type !== null && SECTIONS.includes(type);
256
+ }
253
257
  function isLandmark(node, rulesApi) {
254
258
  const type = rulesApi.getType(node);
255
259
  return type === 'headerLandmark' || type === 'footerLandmark';
@@ -259,10 +263,8 @@ function hasSectionInLandmark(input) {
259
263
  const { node, rulesApi, report } = input;
260
264
  const expected = rulesApi.getType(node) === 'headerLandmark' ? 'header' : 'footer';
261
265
  const children = getStructuralChildren(node, rulesApi);
262
- if (children.length === 1 && children[0].type === 'JSXElement' && rulesApi.getType(children[0]) === expected) {
263
- hasOneInternalContainer(children[0], input);
266
+ if (children.length === 1 && children[0].type === 'JSXElement' && rulesApi.getType(children[0]) === expected)
264
267
  return;
265
- }
266
268
  report({ loc: getLoc(node, rulesApi), message: `<${rulesApi.getTagName(node)}> must contain exactly one ${expected === 'header' ? 'HeaderSection' : 'FooterSection'}.` });
267
269
  }
268
270
  function receivesInstanceId(node) {
@@ -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, getProperties, getProperty, isOneEditAway, isStructure, ownerOf, } from "../shared/utils.js";
5
+ 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, SYSTEM_PACKAGE, 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, 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'];
@@ -0,0 +1,33 @@
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 runtime's builders: the converter's BINDINGS_BUILDER_ORDER without `Remove`, which is a tag (reservedAttributes.ts). */
16
+ export const BUILDERS = [
17
+ 'all', 'ctx', 'fn', 'is', 'raw', 'state',
18
+ 'textEnum', 'phone', 'email', 'webUrl', 'hostname', 'regex', 'guid', 'direction', 'localDate', 'localTime', 'localDateTime', 'dateTime',
19
+ ];
20
+ /** The six builders no other package exports under these names; `email`, `regex`… may well come from a library for fn(). */
21
+ export const RUNTIME_ONLY_BUILDERS = ['all', 'ctx', 'fn', 'is', 'raw', 'state'];
22
+ /** The attributes the converter reads on any component as something other than its data (reservedAttributes.ts RESERVED_ATTRIBUTES, converter 0.81: `type` is data since). */
23
+ export const RESERVED_ATTRIBUTES = [
24
+ 'id', '_id', 'data', 'props', 'skin', 'styleId', 'className', 'documentType', 'style', 'slot', 'bindings', 'elementProps',
25
+ ];
26
+ /** The attribute of a page's reference to a global component (SHARED_COMP_ID_ATTR); on any other tag it's data. */
27
+ export const SHARED_COMP_ID = 'sharedCompId';
28
+ /** The keys of an elementProps entry the converter reads as something other than the part's data (RESERVED_INNER_ELEMENT_ENTRY_KEYS, converter 0.80). */
29
+ export const RESERVED_ENTRY_KEYS = ['data', 'slots', 'elementProps', 'bindings', 'className'];
30
+ /** A data key is a JavaScript name (dataKeys.ts). */
31
+ export const DATA_KEY = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
32
+ /** By import source; the wrappers also by the imported name, since their packages export other things (`Show`, `Interact`). */
33
+ 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
  }
@@ -1,43 +1,13 @@
1
+ import { RUNTIME_PACKAGE, STRUCTURE, SYSTEM_PACKAGE } from "./constants.js";
1
2
  import { GLOBAL_COMPONENTS, GLOBAL_MENUS } from "./site-files-tree-mapping.js";
2
- /** Any section: rules that don't care which one. */
3
- export const SECTIONS = ['section', 'header', 'footer'];
4
- /** A page's reference to a global component (`<HeaderSection_x sharedCompId="…" />`). */
5
- export const GLOBAL_SECTIONS = ['globalHeader', 'globalFooter', 'globalSection'];
6
- /** A tag that wraps one component and isn't one itself: `<Remove when={…}>`, `<Interaction interactKey="…">`. */
7
- export const WRAPPERS = ['remove', 'interaction'];
8
- /** The name a structure file exports its tree under: `export const Structure = () => (…)`. */
9
- export const STRUCTURE = 'Structure';
10
- /** The props a global component's Structure may take: its instance identity, `({ id, className })` (#300). */
11
- export const INSTANCE_PROPS = ['id', 'className'];
12
- const SYSTEM = '@wix/origami-system-components';
13
- /** The runtime package: the builders (`ctx`, `fn`…) and the `<Remove>` wrapper. */
14
- export const RUNTIME_PACKAGE = '@wix/origami-runtime';
15
- /** The runtime's builders: the converter's BINDINGS_BUILDER_ORDER without `Remove`, which is a tag (reservedAttributes.ts). */
16
- export const BUILDERS = [
17
- 'all', 'ctx', 'fn', 'is', 'raw', 'state',
18
- 'textEnum', 'phone', 'email', 'webUrl', 'hostname', 'regex', 'guid', 'direction', 'localDate', 'localTime', 'localDateTime', 'dateTime',
19
- ];
20
- /** The six builders no other package exports under these names; `email`, `regex`… may well come from a library for fn(). */
21
- export const RUNTIME_ONLY_BUILDERS = ['all', 'ctx', 'fn', 'is', 'raw', 'state'];
22
- /** The attributes the converter reads on any component as something other than its data (reservedAttributes.ts RESERVED_ATTRIBUTES, converter 0.81: `type` is data since). */
23
- export const RESERVED_ATTRIBUTES = [
24
- 'id', '_id', 'data', 'props', 'skin', 'styleId', 'className', 'documentType', 'style', 'slot', 'bindings', 'elementProps',
25
- ];
26
- /** The attribute of a page's reference to a global component (SHARED_COMP_ID_ATTR); on any other tag it's data. */
27
- export const SHARED_COMP_ID = 'sharedCompId';
28
- /** The keys of an elementProps entry the converter reads as something other than the part's data (RESERVED_INNER_ELEMENT_ENTRY_KEYS, converter 0.80). */
29
- export const RESERVED_ENTRY_KEYS = ['data', 'slots', 'elementProps', 'bindings', 'className'];
30
- /** A data key is a JavaScript name (dataKeys.ts). */
31
- export const DATA_KEY = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
32
- /** By import source; the wrappers also by the imported name, since their packages export other things (`Show`, `Interact`). */
33
3
  const TYPE_BY_IMPORT_SOURCE = [
34
- [new RegExp(`^${SYSTEM}/(?:layout/)?Page$`), 'page'],
35
- [new RegExp(`^${SYSTEM}/layout/Header$`), 'headerLandmark'],
36
- [new RegExp(`^${SYSTEM}/layout/Footer$`), 'footerLandmark'],
37
- [new RegExp(`^${SYSTEM}/(?:layout/)?Section$`), 'section'],
38
- [new RegExp(`^${SYSTEM}/(?:layout/)?HeaderSection$`), 'header'],
39
- [new RegExp(`^${SYSTEM}/(?:layout/)?FooterSection$`), 'footer'],
40
- [new RegExp(`^${SYSTEM}/InternalContainer$`), 'innerElement'],
4
+ [new RegExp(`^${SYSTEM_PACKAGE}/(?:layout/)?Page$`), 'page'],
5
+ [new RegExp(`^${SYSTEM_PACKAGE}/layout/Header$`), 'headerLandmark'],
6
+ [new RegExp(`^${SYSTEM_PACKAGE}/layout/Footer$`), 'footerLandmark'],
7
+ [new RegExp(`^${SYSTEM_PACKAGE}/(?:layout/)?Section$`), 'section'],
8
+ [new RegExp(`^${SYSTEM_PACKAGE}/(?:layout/)?HeaderSection$`), 'header'],
9
+ [new RegExp(`^${SYSTEM_PACKAGE}/(?:layout/)?FooterSection$`), 'footer'],
10
+ [new RegExp(`^${SYSTEM_PACKAGE}/InternalContainer$`), 'innerElement'],
41
11
  // A global component's folder is `<Type>_<id>`: HeaderSection_mb7ogqqn.
42
12
  [new RegExp(`/${GLOBAL_COMPONENTS}/(?:HeaderSection|Header)_[^/]+$`), 'globalHeader'],
43
13
  [new RegExp(`/${GLOBAL_COMPONENTS}/(?:FooterSection|Footer)_[^/]+$`), 'globalFooter'],
@@ -109,7 +79,6 @@ export function getSlot(element) {
109
79
  return getLiteralString(element.openingElement.attributes.find(isSlotAttribute)?.value);
110
80
  }
111
81
  /** The attributes that hold an element's layers: drawn behind it, and pinned to it. */
112
- export const LAYER_ATTRIBUTES = ['backgroundLayer', 'pinnedLayer'];
113
82
  /** The elements this one holds in its attributes (`content={<X />}`, or inside the attribute's object or array, `elementProps` too), each with the attribute it is in. Not its children, and not the elements inside those elements. */
114
83
  export function getAttributeElements(owner) {
115
84
  const elementsIn = (value) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wix/origami-lint",
3
- "version": "1.34.3",
3
+ "version": "1.34.5",
4
4
  "description": "Offline lint for an exported Origami site: lints the pages that changed since the last passing run",
5
5
  "type": "module",
6
6
  "bin": {
@@ -51,5 +51,5 @@
51
51
  ]
52
52
  }
53
53
  },
54
- "falconPackageHash": "162134940d5c0140fe1b81ff9111d25a7f98f0201dfbbc32130245ca"
54
+ "falconPackageHash": "7e83272a42478bff6871d3e1a9841d447d7f2446e79f08856a87192d"
55
55
  }