@wix/origami-lint 1.34.24 → 1.34.26
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.
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { BREAKPOINT_AT_RULES, createCssFileReporter, describeAtRule, getCssChildren, getDashedId, getMixinId, getSegmentClass, getSelectorSegments, isCssIdentifier, isSafeCssIdentifier } from "../../rules-api/index.js";
|
|
2
2
|
import { defineRule } from "./define-rule.js";
|
|
3
|
-
const CHECKS = ['invalid_mixin_id', 'invalid_mixin_result', 'missing_parent_selector', 'bare_parent_selector', 'nested_style_rule', 'apply_at_result_root', 'nested_mixin', 'invalid_result_at_rule', 'invalid_parent_selector', 'conditional_global_style_link', 'invalid_linked_style_id', 'missing_linked_style', 'duplicate_global_style_link', 'link_with_values', 'missing_global_style_link'];
|
|
3
|
+
const CHECKS = ['invalid_mixin_id', 'invalid_mixin_result', 'missing_parent_selector', 'bare_parent_selector', 'nested_style_rule', 'apply_at_result_root', 'nested_mixin', 'invalid_result_at_rule', 'invalid_parent_selector', 'conditional_global_style_link', 'invalid_linked_style_id', 'missing_linked_style', 'duplicate_global_style_link', 'link_with_values', 'missing_global_style_link', 'mixin_link_cycle'];
|
|
4
4
|
export default defineRule({
|
|
5
5
|
name: 'global-styles',
|
|
6
6
|
checks: CHECKS,
|
|
@@ -22,9 +22,12 @@ function checkMixinsAndLinks(root, file, report) {
|
|
|
22
22
|
const mixins = getCssChildren(root).filter((node) => node.type === 'atrule' && node.name === 'mixin');
|
|
23
23
|
const rules = mixins.flatMap((mixin) => checkMixin(mixin, reportInFile));
|
|
24
24
|
const declaredIds = new Set(mixins.flatMap((mixin) => getMixinId(mixin) ?? []));
|
|
25
|
-
const linkedParts = checkLinks(rules.filter((rule) => rule.apply), declaredIds, reportInFile);
|
|
25
|
+
const { linkedParts, validLinks } = checkLinks(rules.filter((rule) => rule.apply), declaredIds, reportInFile);
|
|
26
26
|
for (const group of groupBySelector(rules))
|
|
27
27
|
checkLinkOrValues(group, linkedParts, reportInFile);
|
|
28
|
+
// the build keeps the last `@mixin` of an id (mixins.js bodies.set): its links are the ones it follows
|
|
29
|
+
const lastMixinById = new Map(mixins.flatMap((mixin) => { const id = getMixinId(mixin); return id === null ? [] : [[id, mixin]]; }));
|
|
30
|
+
checkLinkCycles(validLinks.filter((link) => getEnclosingMixin(link.node) === lastMixinById.get(link.mixin)), reportInFile);
|
|
28
31
|
}
|
|
29
32
|
/** `@mixin --id { @result { … } }` (CONV-astParser.ts:287, :293), then its `@result`; returns its `&` rules that pass. */
|
|
30
33
|
function checkMixin(mixin, reportInFile) {
|
|
@@ -107,6 +110,7 @@ function checkSelector(rule, reportInFile) {
|
|
|
107
110
|
function checkLinks(links, declaredIds, reportInFile) {
|
|
108
111
|
const linkedParts = new Set();
|
|
109
112
|
const validLinkParts = new Set();
|
|
113
|
+
const validLinks = [];
|
|
110
114
|
for (const link of links) {
|
|
111
115
|
const child = link.apply.params.trim();
|
|
112
116
|
const linkDescription = `\`@mixin --${link.mixin}\`'s link \`@apply ${child}\``;
|
|
@@ -135,8 +139,43 @@ function checkLinks(links, declaredIds, reportInFile) {
|
|
|
135
139
|
continue;
|
|
136
140
|
}
|
|
137
141
|
validLinkParts.add(part);
|
|
142
|
+
validLinks.push(link);
|
|
138
143
|
}
|
|
139
|
-
return linkedParts;
|
|
144
|
+
return { linkedParts, validLinks };
|
|
145
|
+
}
|
|
146
|
+
/** Reports each link that closes a cycle (OUR-007): a walk from each mixin in the file's order, depth first, visiting each mixin once; a link
|
|
147
|
+
* to a mixin still on the walk's path leads back to it. Every cycle holds such a link, so removing the reported ones breaks them all. */
|
|
148
|
+
function checkLinkCycles(validLinks, reportInFile) {
|
|
149
|
+
const linksByMixin = new Map();
|
|
150
|
+
for (const link of validLinks)
|
|
151
|
+
linksByMixin.set(link.mixin, [...(linksByMixin.get(link.mixin) ?? []), link]);
|
|
152
|
+
const visited = new Set();
|
|
153
|
+
for (const start of linksByMixin.keys()) {
|
|
154
|
+
if (visited.has(start))
|
|
155
|
+
continue;
|
|
156
|
+
visited.add(start);
|
|
157
|
+
// the walk's path: each mixin on it, and how many of its links were followed
|
|
158
|
+
const path = [{ mixin: start, next: 0 }];
|
|
159
|
+
while (path.length > 0) {
|
|
160
|
+
const step = path.at(-1);
|
|
161
|
+
const link = linksByMixin.get(step.mixin)?.[step.next++];
|
|
162
|
+
if (!link) {
|
|
163
|
+
path.pop();
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const child = getDashedId(link.apply.params);
|
|
167
|
+
const onPath = path.findIndex(({ mixin }) => mixin === child);
|
|
168
|
+
if (onPath !== -1)
|
|
169
|
+
reportCycle(link, child, [...path.slice(onPath).map(({ mixin }) => mixin), child], reportInFile);
|
|
170
|
+
else if (!visited.has(child)) {
|
|
171
|
+
visited.add(child);
|
|
172
|
+
path.push({ mixin: child, next: 0 });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function reportCycle(link, child, chain, reportInFile) {
|
|
178
|
+
reportInFile(link.apply, 'mixin_link_cycle', `\`@mixin --${link.mixin}\` links \`--${child}\`, which leads back to it: ${chain.map((id) => `--${id}`).join(' → ')}. A global style can't contain itself: remove this link, or link a style that doesn't lead back to \`--${child}\` (tracker OUR-007).`);
|
|
140
179
|
}
|
|
141
180
|
/** One selector of a mixin (across its breakpoints): a link alone, or values; values on an inner part where a link names it (CONV-styleParser.ts:803, :698). */
|
|
142
181
|
function checkLinkOrValues(group, linkedParts, reportInFile) {
|
|
@@ -152,6 +191,13 @@ function checkLinkOrValues(group, linkedParts, reportInFile) {
|
|
|
152
191
|
reportInFile(first.node, 'missing_global_style_link', `\`@mixin --${first.mixin}\` styles \`${part}\` but links no global style there: add \`& ${part} { @apply --<child> }\` and move the values to that child's @mixin (tracker CONV-styleParser.ts:698).`);
|
|
153
192
|
}
|
|
154
193
|
// ── reading a mixin ──
|
|
194
|
+
/** The `@mixin` a rule of its `@result` sits in. */
|
|
195
|
+
function getEnclosingMixin(rule) {
|
|
196
|
+
for (let node = rule.parent; node; node = node.parent)
|
|
197
|
+
if (node.type === 'atrule' && node.name === 'mixin')
|
|
198
|
+
return node;
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
155
201
|
/** The mixin's one `@result` with no params and a block, beside nothing; undefined otherwise. */
|
|
156
202
|
function getResult(mixin) {
|
|
157
203
|
if (mixin.nodes === undefined)
|
|
@@ -14,15 +14,21 @@
|
|
|
14
14
|
// - a <Remove> holds exactly one component element (CONV-parseJsx.ts:1028), and no <Remove> sits inside another: <Remove> wraps
|
|
15
15
|
// the component itself (CONV-parseJsx.ts:1237; converter 0.86 parseJsx.ts:1500, :1681);
|
|
16
16
|
// - only elements go between a component's tags: no text, no {…}, no fragment (CONV-parseJsx.ts:1668);
|
|
17
|
-
// - an <InternalContainer>'s backgroundLayer / pinnedLayer is an array of elements
|
|
18
|
-
//
|
|
17
|
+
// - an <InternalContainer>'s backgroundLayer / pinnedLayer, and a <Page>'s backgroundLayer / viewportLayer, is an array of elements
|
|
18
|
+
// (CONV-parseJsx.ts:1630);
|
|
19
|
+
// - a <PinnedToScreen> takes no props and holds one section, header or footer, in its <Page> slot: a child of the <Page>, or its
|
|
20
|
+
// header / footer. A pinned section is never in the viewportLayer, which holds the page's other pinned components.
|
|
21
|
+
// Wrappers are transparent for the tree, as they are for the import: a section wrapped in <Remove> or <PinnedToScreen> is still the
|
|
22
|
+
// page's child.
|
|
19
23
|
// <Interaction> is no wrapper since converter 0.80 (interactions live in servicesConfigs/interactions.ts): allowed-imports
|
|
20
24
|
// reports the tag, and it stays transparent here so the elements inside it aren't reported too. Context-provider wrappers join
|
|
21
25
|
// once the page model lists the providers.
|
|
22
26
|
import { FILE_START } from "../../page-model/index.js";
|
|
23
|
-
import { LAYER_ATTRIBUTES, SECTIONS, WRAPPERS } from "../../rules-api/index.js";
|
|
27
|
+
import { getName, GLOBAL_SECTIONS, LAYER_ATTRIBUTES, SECTIONS, WRAPPERS } from "../../rules-api/index.js";
|
|
24
28
|
import { defineRule } from "./define-rule.js";
|
|
25
|
-
const CHECKS = ['missing_root', 'invalid_global_root', 'missing_root_id', 'invalid_page_root', 'non_element_child', 'invalid_section_container', 'nested_page', 'landmark_inside_container', 'invalid_wrapper_children', 'nested_remove', 'invalid_element_array', 'invalid_landmark_section'];
|
|
29
|
+
const CHECKS = ['missing_root', 'invalid_global_root', 'missing_root_id', 'invalid_page_root', 'non_element_child', 'invalid_section_container', 'nested_page', 'landmark_inside_container', 'invalid_wrapper_children', 'nested_remove', 'invalid_element_array', 'invalid_landmark_section', 'invalid_pinned_child', 'invalid_pinned_placement', 'pinned_wrapper_props', 'section_in_viewport_layer'];
|
|
30
|
+
/** What <PinnedToScreen> may hold: a section, a header or footer, or a page's reference to a global one. */
|
|
31
|
+
const PINNABLE = [...SECTIONS, ...GLOBAL_SECTIONS, 'headerLandmark', 'footerLandmark'];
|
|
26
32
|
export default defineRule({
|
|
27
33
|
name: 'page-structure',
|
|
28
34
|
checks: CHECKS,
|
|
@@ -33,7 +39,10 @@ export default defineRule({
|
|
|
33
39
|
if (!input.rulesApi.getComponent(input.node))
|
|
34
40
|
return;
|
|
35
41
|
if (isWrapper(input.node, input.rulesApi)) {
|
|
36
|
-
|
|
42
|
+
const type = input.rulesApi.getType(input.node);
|
|
43
|
+
if (type === 'pinnedToScreen')
|
|
44
|
+
checkPinnedToScreen(input);
|
|
45
|
+
if (type !== 'remove')
|
|
37
46
|
return;
|
|
38
47
|
holdsOneElement(input);
|
|
39
48
|
checkNoRemoveInRemove(input);
|
|
@@ -45,6 +54,7 @@ export default defineRule({
|
|
|
45
54
|
if (isSection(input.node, input.rulesApi))
|
|
46
55
|
hasOneInternalContainer(input.node, input);
|
|
47
56
|
hasLayerArrays(input);
|
|
57
|
+
checkViewportLayerItems(input);
|
|
48
58
|
isNotNestedPage(input);
|
|
49
59
|
isOutsideInternalContainers(input);
|
|
50
60
|
if (!isTop(input.node, input.rulesApi))
|
|
@@ -162,9 +172,10 @@ function hasOnlyElements({ node, rulesApi, report }) {
|
|
|
162
172
|
report({ check: 'non_element_child', loc: child.type === 'JSXText' ? getTextLoc(child) : child.loc, message: `${describeElement(node, rulesApi)} has text or {…} as a child. Put the value in an attribute: only elements go between the tags (tracker CONV-parseJsx.ts:1668).` });
|
|
163
173
|
}
|
|
164
174
|
}
|
|
165
|
-
/** Reports an <InternalContainer>'s
|
|
175
|
+
/** Reports an <InternalContainer>'s or a <Page>'s layer that isn't an array literal of elements (CONV-parseJsx.ts:1630). */
|
|
166
176
|
function hasLayerArrays({ node, rulesApi, report }) {
|
|
167
|
-
|
|
177
|
+
const type = rulesApi.getType(node);
|
|
178
|
+
if (type !== 'innerElement' && type !== 'page')
|
|
168
179
|
return;
|
|
169
180
|
for (const attribute of node.openingElement.attributes) {
|
|
170
181
|
if (attribute.type !== 'JSXAttribute' || attribute.name.type !== 'JSXIdentifier' || !LAYER_ATTRIBUTES.includes(attribute.name.name))
|
|
@@ -174,7 +185,62 @@ function hasLayerArrays({ node, rulesApi, report }) {
|
|
|
174
185
|
report({ check: 'invalid_element_array', loc: attribute.loc, message: `${describeElement(node, rulesApi)}'s ${attribute.name.name} must be an array of elements: ${attribute.name.name}={[<… />]} (tracker CONV-parseJsx.ts:1630).` });
|
|
175
186
|
}
|
|
176
187
|
}
|
|
188
|
+
/** A <PinnedToScreen>: no props, one element held, a pinnable one, in its <Page> slot. */
|
|
189
|
+
function checkPinnedToScreen(input) {
|
|
190
|
+
const { node, rulesApi, report } = input;
|
|
191
|
+
if (node.openingElement.attributes.length > 0) {
|
|
192
|
+
report({ check: 'pinned_wrapper_props', loc: node.openingElement.name.loc, message: `<${rulesApi.getTagName(node)}> takes no props: the element it pins keeps its own id, className and layout.` });
|
|
193
|
+
}
|
|
194
|
+
holdsOneElement(input);
|
|
195
|
+
checkPinnedChild(input);
|
|
196
|
+
checkPinnedPlacement(input);
|
|
197
|
+
}
|
|
198
|
+
/** Reports a <PinnedToScreen> whose one element isn't a section, header or footer; the wrong number of elements is holdsOneElement's. */
|
|
199
|
+
function checkPinnedChild({ node, rulesApi, report }) {
|
|
200
|
+
if (!holdsOne(node))
|
|
201
|
+
return;
|
|
202
|
+
const inside = rulesApi.getChildElements(node);
|
|
203
|
+
if (inside.length === 1 && isPinnable(inside[0], rulesApi))
|
|
204
|
+
return;
|
|
205
|
+
report({ check: 'invalid_pinned_child', loc: node.openingElement.name.loc, message: `<${rulesApi.getTagName(node)}> holds ${inside.map((child) => describeElement(child, rulesApi)).join(', ') || 'nothing'}. It pins a section, header or footer; put any other pinned component in the <Page>'s viewportLayer.` });
|
|
206
|
+
}
|
|
207
|
+
/** Reports a <PinnedToScreen> anywhere but in a <Page>'s children or its header / footer, or inside another <PinnedToScreen>. */
|
|
208
|
+
function checkPinnedPlacement({ node, rulesApi, report }) {
|
|
209
|
+
const holder = getHolder(node, rulesApi);
|
|
210
|
+
const between = getAncestors(node).slice(0, holder ? getAncestors(node).indexOf(holder) : undefined);
|
|
211
|
+
const attribute = holder && getHolderAttribute(node, holder);
|
|
212
|
+
if (holder && rulesApi.getType(holder) === 'page' && (attribute === null || attribute === 'header' || attribute === 'footer')
|
|
213
|
+
&& !between.some((wrapper) => rulesApi.getType(wrapper) === 'pinnedToScreen'))
|
|
214
|
+
return;
|
|
215
|
+
const where = holder ? `inside ${describeElement(holder, rulesApi)}${attribute ? `'s ${attribute}` : ''}` : 'at the top';
|
|
216
|
+
report({ check: 'invalid_pinned_placement', loc: node.openingElement.name.loc, message: `<${rulesApi.getTagName(node)}> is ${where}. It goes in its <Page> slot only, once: around a child of the <Page>, or in its header or footer.` });
|
|
217
|
+
}
|
|
218
|
+
/** Reports a section, header or footer in a <Page>'s viewportLayer: a pinned one stays in its slot, wrapped in <PinnedToScreen>. One already wrapped is checkPinnedPlacement's. */
|
|
219
|
+
function checkViewportLayerItems({ node, rulesApi, report }) {
|
|
220
|
+
if (rulesApi.getType(node) !== 'page')
|
|
221
|
+
return;
|
|
222
|
+
for (const { element, attribute } of rulesApi.getAttributeElements(node)) {
|
|
223
|
+
if (getName(attribute) !== 'viewportLayer' || !isPinnable(element, rulesApi))
|
|
224
|
+
continue;
|
|
225
|
+
if (getAncestors(element).some((wrapper) => rulesApi.getType(wrapper) === 'pinnedToScreen'))
|
|
226
|
+
continue;
|
|
227
|
+
report({ check: 'section_in_viewport_layer', loc: getLoc(element, rulesApi), message: `${describeElement(element, rulesApi)} is in the viewportLayer of ${describeElement(node, rulesApi)}. A pinned section, header or footer stays in its <Page> slot, wrapped in <PinnedToScreen>.` });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
177
230
|
// ── reading the tree ──
|
|
231
|
+
/** A section, header or footer, which <PinnedToScreen> may hold. */
|
|
232
|
+
const isPinnable = (element, rulesApi) => {
|
|
233
|
+
const type = rulesApi.getType(element);
|
|
234
|
+
return type !== null && PINNABLE.includes(type);
|
|
235
|
+
};
|
|
236
|
+
/** The attribute of the holder the element is passed in (`header`); null for one of its children. */
|
|
237
|
+
function getHolderAttribute(element, holder) {
|
|
238
|
+
for (let node = element.parent; node && node !== holder; node = node.parent) {
|
|
239
|
+
if (node.type === 'JSXAttribute')
|
|
240
|
+
return getName(node);
|
|
241
|
+
}
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
178
244
|
/** 'header' / 'footer' for the section tags and for a page's reference to the global ones; null for anything else. */
|
|
179
245
|
function headerOrFooter(type) {
|
|
180
246
|
if (type === 'header' || type === 'globalHeader' || type === 'headerLandmark')
|
package/dist/shared/constants.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
export const SECTIONS = ['section', 'header', 'footer'];
|
|
3
3
|
/** A page's reference to a global component (`<HeaderSection_x sharedCompId="…" />`). */
|
|
4
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'];
|
|
5
|
+
/** A tag that wraps one component and isn't one itself: `<Remove when={…}>`, `<Interaction interactKey="…">`, `<PinnedToScreen>` (the import drops it). */
|
|
6
|
+
export const WRAPPERS = ['remove', 'interaction', 'pinnedToScreen'];
|
|
7
7
|
/** The name a structure file exports its tree under: `export const Structure = () => (…)`. */
|
|
8
8
|
export const STRUCTURE = 'Structure';
|
|
9
9
|
/** The props a global component's Structure may take: its instance identity, `({ id, className })` (#300). */
|
|
@@ -38,6 +38,7 @@ export const EVENT_ATTRIBUTE = /^on/i;
|
|
|
38
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
39
|
export const CALLBACK_PROP = /^on[A-Z]/;
|
|
40
40
|
/** By import source; the wrappers also by the imported name, since their packages export other things (`Show`, `Interact`). */
|
|
41
|
-
|
|
41
|
+
/** The attributes that hold an element's layers: drawn behind it, and pinned to it (a container's pinnedLayer, the Page's viewportLayer). */
|
|
42
|
+
export const LAYER_ATTRIBUTES = ['backgroundLayer', 'pinnedLayer', 'viewportLayer'];
|
|
42
43
|
/** The attribute that holds the elements of a component's inner parts: `elementProps={{ panel: { content: <X /> } }}`. */
|
|
43
44
|
export const ELEMENT_PROPS = 'elementProps';
|
package/dist/shared/utils.js
CHANGED
|
@@ -4,6 +4,7 @@ const TYPE_BY_IMPORT_SOURCE = [
|
|
|
4
4
|
[new RegExp(`^${SYSTEM_PACKAGE}/(?:layout/)?Page$`), 'page'],
|
|
5
5
|
[new RegExp(`^${SYSTEM_PACKAGE}/layout/Header$`), 'headerLandmark'],
|
|
6
6
|
[new RegExp(`^${SYSTEM_PACKAGE}/layout/Footer$`), 'footerLandmark'],
|
|
7
|
+
[new RegExp(`^${SYSTEM_PACKAGE}/layout/PinnedToScreen$`), 'pinnedToScreen'],
|
|
7
8
|
[new RegExp(`^${SYSTEM_PACKAGE}/(?:layout/)?Section$`), 'section'],
|
|
8
9
|
[new RegExp(`^${SYSTEM_PACKAGE}/(?:layout/)?HeaderSection$`), 'header'],
|
|
9
10
|
[new RegExp(`^${SYSTEM_PACKAGE}/(?:layout/)?FooterSection$`), 'footer'],
|
|
@@ -37,7 +38,7 @@ export function getType(element, program) {
|
|
|
37
38
|
return imported === undefined || found.imported === imported ? type : null;
|
|
38
39
|
}
|
|
39
40
|
/** The tags the import reads as the DM's own components ('document') or a global component's reference (identity.ts identityOfTag): no data attributes. */
|
|
40
|
-
const NO_DATA_TYPES = ['page', 'section', 'header', 'footer', 'headerLandmark', 'footerLandmark', ...GLOBAL_SECTIONS];
|
|
41
|
+
const NO_DATA_TYPES = ['page', 'section', 'header', 'footer', 'headerLandmark', 'footerLandmark', 'pinnedToScreen', ...GLOBAL_SECTIONS];
|
|
41
42
|
/** Converter 0.81: are the tag's attributes saved as its component's data? Yes on a builder component (its import gives a componentType):
|
|
42
43
|
* imported, not from `document` and not from a local file. No on the DM's own tags (`Page`, the sections) and a global component's reference. */
|
|
43
44
|
export function hasDataAttributes(element, program) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wix/origami-lint",
|
|
3
|
-
"version": "1.34.
|
|
3
|
+
"version": "1.34.26",
|
|
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": {
|
|
@@ -52,5 +52,5 @@
|
|
|
52
52
|
]
|
|
53
53
|
}
|
|
54
54
|
},
|
|
55
|
-
"falconPackageHash": "
|
|
55
|
+
"falconPackageHash": "789db12225da1bce9eb55f3b1d60b56d77f04f07778f4eb0a234e3ff"
|
|
56
56
|
}
|