@wix/origami-lint 0.0.0-stage → 1.34.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +41 -2
  2. package/dist/history/changed-files.js +42 -0
  3. package/dist/history/index.js +3 -0
  4. package/dist/history/last-passing.js +44 -0
  5. package/dist/history/utils.js +13 -0
  6. package/dist/lint.js +13 -0
  7. package/dist/page-model/build-page-model.js +11 -0
  8. package/dist/page-model/connect-css.js +43 -0
  9. package/dist/page-model/css-selectors.js +49 -0
  10. package/dist/page-model/index.js +5 -0
  11. package/dist/page-model/model.js +5 -0
  12. package/dist/page-model/pages.js +27 -0
  13. package/dist/page-model/read-css.js +84 -0
  14. package/dist/page-model/read-page.js +17 -0
  15. package/dist/page-model/read-structure.js +58 -0
  16. package/dist/page-model/read-theme.js +14 -0
  17. package/dist/page-model/utils.js +78 -0
  18. package/dist/rules/allowed-imports.js +109 -0
  19. package/dist/rules/apply-and-presets.js +310 -0
  20. package/dist/rules/bindings.js +673 -0
  21. package/dist/rules/breakpoints.js +124 -0
  22. package/dist/rules/css-file-nesting.js +196 -0
  23. package/dist/rules/css-layout-properties.js +398 -0
  24. package/dist/rules/data-values.js +511 -0
  25. package/dist/rules/define-rule.js +4 -0
  26. package/dist/rules/global-styles.js +215 -0
  27. package/dist/rules/index.js +16 -0
  28. package/dist/rules/no-absolute-section.js +31 -0
  29. package/dist/rules/no-free-code.js +112 -0
  30. package/dist/rules/page-structure.js +277 -0
  31. package/dist/rules/slot-attributes.js +96 -0
  32. package/dist/rules/theme-font-ids.js +69 -0
  33. package/dist/rules/theme-root.js +122 -0
  34. package/dist/rules-adapter/index.js +3 -0
  35. package/dist/rules-adapter/oxlint/adapt-rule.js +17 -0
  36. package/dist/rules-adapter/oxlint/callbacks.js +1 -0
  37. package/dist/rules-adapter/oxlint/origami-rules.config.js +12 -0
  38. package/dist/rules-adapter/oxlint/oxlint-plugin.js +7 -0
  39. package/dist/rules-adapter/oxlint/run-rules.js +22 -0
  40. package/dist/rules-adapter/tool-types.js +1 -0
  41. package/dist/rules-adapter/utils.js +20 -0
  42. package/dist/rules-api/index.js +5 -0
  43. package/dist/rules-api/report.js +13 -0
  44. package/dist/rules-api/rules-api.js +70 -0
  45. package/dist/rules-api/utils.js +11 -0
  46. package/dist/shared/css.js +124 -0
  47. package/dist/shared/log.js +4 -0
  48. package/dist/shared/paths.js +28 -0
  49. package/dist/shared/site-files-tree-mapping.js +12 -0
  50. package/dist/shared/utils.js +208 -0
  51. package/package.json +52 -3
@@ -0,0 +1,277 @@
1
+ // The page-structure subject (tracker tab site-app-rules-mapping): what may sit inside what in a structure file.
2
+ // A site page is <Page> → sections → exactly one <InternalContainer> each; sections are Section, HeaderSection, FooterSection,
3
+ // or the site's global header / footer. <Page> may also hold any other component: the page's own <Background> (the export writes it
4
+ // as <Page>'s last child) and pinned elements (wix-site-edit components.md, layout.md "Pinned"); the import accepts any component
5
+ // there. A global component is a section of the master page, so its top element is a section (rule card P1-R13). Types are
6
+ // recognized by import source (rulesApi.getType), so renaming an import doesn't matter.
7
+ // Checked in every file, pages and global components alike:
8
+ // - Structure returns one element, with a string id (tracker CONV-runValidation.ts:9);
9
+ // - 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
+ // - the header and footer sit outside every <InternalContainer>: inside one, that container can no longer be deleted in the
11
+ // editor (rule card P1-R03). Both the HeaderSection / FooterSection tags and a page's reference to the global ones;
12
+ // - a <Remove> holds exactly one component element (CONV-parseJsx.ts:1028), and no <Remove> sits inside another: <Remove> wraps
13
+ // the component itself (CONV-parseJsx.ts:1237; converter 0.86 parseJsx.ts:1500, :1681);
14
+ // - only elements go between a component's tags: no text, no {…}, no fragment (CONV-parseJsx.ts:1668);
15
+ // - an <InternalContainer>'s backgroundLayer / pinnedLayer is an array of elements (CONV-parseJsx.ts:1630).
16
+ // Wrappers are transparent for the tree, as they are for the import: a section wrapped in <Remove> is still the page's child.
17
+ // <Interaction> is no wrapper since converter 0.80 (interactions live in servicesConfigs/interactions.ts): allowed-imports
18
+ // reports the tag, and it stays transparent here so the elements inside it aren't reported too. Context-provider wrappers join
19
+ // once the page model lists the providers.
20
+ import { FILE_START } from "../page-model/index.js";
21
+ import { LAYER_ATTRIBUTES, SECTIONS, WRAPPERS } from "../rules-api/index.js";
22
+ import { defineRule } from "./define-rule.js";
23
+ export default defineRule({
24
+ name: 'page-structure',
25
+ editors: 'all',
26
+ callbacks: {
27
+ Program: (input) => hasRoot(input),
28
+ JSXElement: (input) => {
29
+ if (!input.rulesApi.getComponent(input.node))
30
+ return;
31
+ if (isWrapper(input.node, input.rulesApi)) {
32
+ if (input.rulesApi.getType(input.node) !== 'remove')
33
+ return;
34
+ holdsOneElement(input);
35
+ checkNoRemoveInRemove(input);
36
+ return;
37
+ }
38
+ if (isLandmark(input.node, input.rulesApi))
39
+ hasSectionInLandmark(input);
40
+ hasOnlyElements(input);
41
+ hasLayerArrays(input);
42
+ isNotNestedPage(input);
43
+ isOutsideInternalContainers(input);
44
+ if (!isTop(input.node, input.rulesApi))
45
+ return;
46
+ if (!isSectionAtTop(input))
47
+ return;
48
+ rootHasId(input);
49
+ if (input.rulesApi.page.kind !== 'page')
50
+ return;
51
+ if (!startsWithPage(input))
52
+ return;
53
+ for (const section of getSections(input))
54
+ hasOneInternalContainer(section, input);
55
+ },
56
+ },
57
+ });
58
+ // ── the steps ──
59
+ /** Reports a file whose Structure returns no element (CONV-runValidation.ts:9). */
60
+ function hasRoot({ rulesApi, report }) {
61
+ if (rulesApi.getRoot())
62
+ return;
63
+ if (rulesApi.page.kind === 'page')
64
+ report({ loc: FILE_START, message: 'A page must start with <Page>, found nothing.' });
65
+ else
66
+ report({ loc: FILE_START, message: 'A global component must start with a section (HeaderSection, FooterSection or Section), found nothing (tracker CONV-runValidation.ts:9).' });
67
+ }
68
+ /** On a global component, reports a top element that isn't a section (P1-R13); the steps after it are for a section. */
69
+ function isSectionAtTop({ node, rulesApi, report }) {
70
+ if (rulesApi.page.kind !== 'global')
71
+ return true;
72
+ const type = rulesApi.getType(node);
73
+ if (isLandmark(node, rulesApi) || (type && SECTIONS.includes(type)))
74
+ return true;
75
+ report({ loc: getLoc(node, rulesApi), message: `${describeElement(node, rulesApi)} sits directly in the master page. A global component's top element is a section (HeaderSection, FooterSection or Section); put it inside one (rule P1-R13).` });
76
+ return false;
77
+ }
78
+ /** Reports a root element with no string id (CONV-runValidation.ts:9). The tree steps go on: the import reports them all in one run. */
79
+ function rootHasId({ node, rulesApi, report }) {
80
+ if (rulesApi.getId(node) !== null)
81
+ return;
82
+ // Shared landmarks receive the instance identity from Structure's props.
83
+ if (rulesApi.page.kind === 'global' && isLandmark(node, rulesApi) && node.openingElement.attributes.some(attribute => attribute.type === 'JSXAttribute' && attribute.name.type === 'JSXIdentifier' && attribute.name.name === 'id'
84
+ && attribute.value?.type === 'JSXExpressionContainer' && attribute.value.expression.type === 'Identifier'
85
+ && attribute.value.expression.name === 'id') && receivesInstanceId(node))
86
+ return;
87
+ const tag = rulesApi.getTagName(node);
88
+ report({ loc: getLoc(node, rulesApi), message: `<${tag}> at the top has no id. Give the root element its id, like \`<${tag} id="c1dmp">\` (tracker CONV-runValidation.ts:9).` });
89
+ }
90
+ /** Reports a page's root that isn't <Page>. */
91
+ function startsWithPage({ node, rulesApi, report }) {
92
+ if (rulesApi.getType(node) === 'page')
93
+ return true;
94
+ report({ loc: getLoc(node, rulesApi), message: `A page must start with <Page>, found ${describe(node, rulesApi)}.` });
95
+ return false;
96
+ }
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
+ 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);
105
+ continue;
106
+ }
107
+ 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
+ }
109
+ return sections;
110
+ }
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. */
112
+ function hasOneInternalContainer(section, { rulesApi, report }) {
113
+ if (rulesApi.getChildren(section).some((child) => child.type === 'JSXElement' && isWrapper(child, rulesApi) && !holdsOne(child)))
114
+ return;
115
+ const inside = getStructuralChildren(section, rulesApi);
116
+ if (inside.length === 1 && inside[0].type === 'JSXElement' && rulesApi.getType(inside[0]) === 'innerElement')
117
+ return;
118
+ 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'}.` });
120
+ }
121
+ /** Reports a <Page> held by another element (P1-R16), unless the root steps above report that placement already (a section's children). */
122
+ function isNotNestedPage({ node, rulesApi, report }) {
123
+ if (rulesApi.getType(node) !== 'page')
124
+ return;
125
+ const holder = getHolder(node, rulesApi);
126
+ if (!holder || (rulesApi.getType(holder) !== 'page' && isRootTreeHolder(holder, rulesApi) && isStructuralChild(holder, node, rulesApi)))
127
+ return;
128
+ report({ loc: getLoc(node, rulesApi), message: `${describeElement(node, rulesApi)} is inside ${describeElement(holder, rulesApi)}. A page is only the top element of its own page (rule P1-R16).` });
129
+ }
130
+ /** Reports a header or footer, or a page's reference to the global one, with an <InternalContainer> among its ancestors (P1-R03). */
131
+ function isOutsideInternalContainers({ node, rulesApi, report }) {
132
+ const kind = headerOrFooter(rulesApi.getType(node));
133
+ if (!kind)
134
+ return;
135
+ const container = getAncestors(node).find((ancestor) => rulesApi.getType(ancestor) === 'innerElement');
136
+ if (!container)
137
+ return;
138
+ report({ loc: getLoc(node, rulesApi), message: `The ${kind} ${describeElement(node, rulesApi)} is inside the internal container ${describeElement(container, rulesApi)}. Move it out of every internal container: the header and footer sit directly in the page or the master page (rule P1-R03).` });
139
+ }
140
+ /** Reports a wrapper that doesn't hold exactly one element: none, two, text, or a {…} (a comment too) beside it (CONV-parseJsx.ts:1028). */
141
+ function holdsOneElement({ node, rulesApi, report }) {
142
+ if (holdsOne(node))
143
+ return;
144
+ report({ loc: node.openingElement.name.loc, message: `<${rulesApi.getTagName(node)}> wraps exactly one component element: give each component its own wrapper (tracker CONV-parseJsx.ts:1028).` });
145
+ }
146
+ /** Reports a <Remove> right inside another <Remove> (CONV-parseJsx.ts:1237): <Remove> wraps the component itself, so nothing nests in it. Compared with the element right around it, as the import does. */
147
+ function checkNoRemoveInRemove({ node, rulesApi, report }) {
148
+ const outer = getAncestors(node)[0];
149
+ if (!outer || rulesApi.getType(outer) !== 'remove')
150
+ return;
151
+ report({ loc: node.openingElement.name.loc, message: `<${rulesApi.getTagName(node)}> is inside another <Remove>. <Remove> wraps the component itself: merge the two conditions into one <Remove> (tracker CONV-parseJsx.ts:1237).` });
152
+ }
153
+ /** Reports text, a {…} (a comment too), a fragment or a spread between a component's tags (CONV-parseJsx.ts:1668). The root steps report those in <Page> and its sections, except comments, which they don't see. */
154
+ function hasOnlyElements({ node, rulesApi, report }) {
155
+ const commentsOnly = isRootTreeHolder(node, rulesApi);
156
+ for (const child of node.children) {
157
+ if (child.type === 'JSXElement' || (child.type === 'JSXText' && child.value.trim() === ''))
158
+ continue;
159
+ if (commentsOnly && !isComment(child))
160
+ continue;
161
+ report({ 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).` });
162
+ }
163
+ }
164
+ /** Reports an <InternalContainer>'s backgroundLayer / pinnedLayer that isn't an array literal of elements (CONV-parseJsx.ts:1630). */
165
+ function hasLayerArrays({ node, rulesApi, report }) {
166
+ if (rulesApi.getType(node) !== 'innerElement')
167
+ return;
168
+ for (const attribute of node.openingElement.attributes) {
169
+ if (attribute.type !== 'JSXAttribute' || attribute.name.type !== 'JSXIdentifier' || !LAYER_ATTRIBUTES.includes(attribute.name.name))
170
+ continue;
171
+ if (isArrayOfElements(attribute))
172
+ continue;
173
+ report({ 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).` });
174
+ }
175
+ }
176
+ // ── reading the tree ──
177
+ /** 'header' / 'footer' for the section tags and for a page's reference to the global ones; null for anything else. */
178
+ function headerOrFooter(type) {
179
+ if (type === 'header' || type === 'globalHeader' || type === 'headerLandmark')
180
+ return 'header';
181
+ if (type === 'footer' || type === 'globalFooter' || type === 'footerLandmark')
182
+ return 'footer';
183
+ return null;
184
+ }
185
+ /** <Remove>, <Interaction>: a tag that wraps one component and isn't one. */
186
+ function isWrapper(element, rulesApi) {
187
+ const type = rulesApi.getType(element);
188
+ return type !== null && WRAPPERS.includes(type);
189
+ }
190
+ /** The JSX elements around this one, closest first: its parents, and the element whose prop it's passed in. Wrappers included. */
191
+ function getAncestors(element) {
192
+ const ancestors = [];
193
+ for (let node = element.parent; node; node = node.parent) {
194
+ if (node.type === 'JSXElement')
195
+ ancestors.push(node);
196
+ }
197
+ return ancestors;
198
+ }
199
+ /** The elements around this one that are components, closest first: wrappers are transparent. */
200
+ const getHolders = (element, rulesApi) => getAncestors(element).filter((ancestor) => !isWrapper(ancestor, rulesApi));
201
+ /** The closest component around this one; null at the top. */
202
+ const getHolder = (element, rulesApi) => getHolders(element, rulesApi)[0] ?? null;
203
+ /** The file's top element: no component around it (wrappers don't count). */
204
+ const isTop = (element, rulesApi) => getHolders(element, rulesApi).length === 0;
205
+ /** The element's children as the import sees them: a wrapper stands for the elements it holds. Text and {…} in a wrapper are its own problem. */
206
+ const getStructuralChildren = (element, rulesApi) => rulesApi.getChildren(element).flatMap((child) => (child.type === 'JSXElement' && isWrapper(child, rulesApi) ? rulesApi.getChildElements(child) : [child]));
207
+ /** On a page whose top is <Page>, the root steps report every child of that <Page> and of its sections: a child of those holders isn't reported twice. */
208
+ function isRootTreeHolder(holder, rulesApi) {
209
+ if (rulesApi.page.kind !== 'page')
210
+ return false;
211
+ const around = getHolders(holder, rulesApi);
212
+ if (around.length === 0)
213
+ return rulesApi.getType(holder) === 'page';
214
+ const type = rulesApi.getType(holder);
215
+ return around.length === 1 && isRootTreeHolder(around[0], rulesApi) && type !== null && SECTIONS.includes(type) && isStructuralChild(around[0], holder, rulesApi);
216
+ }
217
+ /** Whether the element is one of the holder's children (through wrappers), not something passed in one of its attributes. */
218
+ const isStructuralChild = (holder, element, rulesApi) => getStructuralChildren(holder, rulesApi).includes(element);
219
+ /** A wrapper holds exactly one element: non-blank text, a {…} (a comment too), a fragment count as what it holds. */
220
+ const holdsOne = (wrapper) => {
221
+ const inside = wrapper.children.filter((child) => child.type !== 'JSXText' || child.value.trim() !== '');
222
+ return inside.length === 1 && inside[0].type === 'JSXElement';
223
+ };
224
+ /** A JSX comment: an expression container with nothing in it. */
225
+ const isComment = (child) => child.type === 'JSXExpressionContainer' && child.expression.type === 'JSXEmptyExpression';
226
+ /** `backgroundLayer={[<Background … />]}`: an array literal whose every entry is an element. */
227
+ function isArrayOfElements(attribute) {
228
+ const { value } = attribute;
229
+ if (value?.type !== 'JSXExpressionContainer' || value.expression.type !== 'ArrayExpression')
230
+ return false;
231
+ return value.expression.elements.every((entry) => entry !== null && entry.type === 'JSXElement');
232
+ }
233
+ // ── reporting ──
234
+ /** Where a text child starts, its leading whitespace skipped: a text's node begins right after the opening tag. */
235
+ function getTextLoc(text) {
236
+ const leading = /^\s*/.exec(text.value)[0];
237
+ const lines = leading.split('\n');
238
+ const start = lines.length > 1 ? { line: text.loc.start.line + lines.length - 1, column: lines[lines.length - 1].length } : { line: text.loc.start.line, column: text.loc.start.column + leading.length };
239
+ return { start, end: text.loc.end };
240
+ }
241
+ /** Where to mark an element: its id attribute, or its tag name. */
242
+ const getLoc = (element, rulesApi) => rulesApi.getComponent(element)?.loc ?? element.openingElement.name.loc;
243
+ /** '<Page> inner', or '<Top>' without an id. */
244
+ function describeElement(element, rulesApi) {
245
+ const id = rulesApi.getId(element);
246
+ return `<${rulesApi.getTagName(element)}>${id ? ` ${id}` : ''}`;
247
+ }
248
+ function describe(child, rulesApi) {
249
+ if (child.type === 'JSXElement')
250
+ return `<${rulesApi.getTagName(child)}>`;
251
+ return child.type === 'JSXText' ? 'text' : 'a {…} expression';
252
+ }
253
+ function isLandmark(node, rulesApi) {
254
+ const type = rulesApi.getType(node);
255
+ return type === 'headerLandmark' || type === 'footerLandmark';
256
+ }
257
+ /** A landmark owns instance placement; the matching section owns the definition's content. */
258
+ function hasSectionInLandmark(input) {
259
+ const { node, rulesApi, report } = input;
260
+ const expected = rulesApi.getType(node) === 'headerLandmark' ? 'header' : 'footer';
261
+ const children = getStructuralChildren(node, rulesApi);
262
+ if (children.length === 1 && children[0].type === 'JSXElement' && rulesApi.getType(children[0]) === expected) {
263
+ hasOneInternalContainer(children[0], input);
264
+ return;
265
+ }
266
+ report({ loc: getLoc(node, rulesApi), message: `<${rulesApi.getTagName(node)}> must contain exactly one ${expected === 'header' ? 'HeaderSection' : 'FooterSection'}.` });
267
+ }
268
+ function receivesInstanceId(node) {
269
+ for (let parent = node.parent; parent; parent = parent.parent) {
270
+ if (parent.type !== 'ArrowFunctionExpression')
271
+ continue;
272
+ const parameter = parent.params[0];
273
+ return parameter?.type === 'ObjectPattern' && parameter.properties.some(property => property.type === 'Property' && !property.computed && property.key.type === 'Identifier' && property.key.name === 'id'
274
+ && property.value.type === 'Identifier' && property.value.name === 'id');
275
+ }
276
+ return false;
277
+ }
@@ -0,0 +1,96 @@
1
+ // Slot placement: how `slot=` is written on a component that fills a named spot.
2
+ // An element goes into a spot in one of two ways: in an attribute (`content={<InternalContainer />}`), or as a child with `slot="…"`.
3
+ // - an element in an attribute has no `slot=` of its own: its place is the attribute; layers are not slots (CONV-parseJsx.ts:1507);
4
+ // - one component uses one way, not both: attribute elements, or children with `slot=` / an elementProps entry's `slots: {…}` (CONV-parseJsx.ts:1693);
5
+ // - the root element has no `slot=`: it has no owner to be a slot of (CONV-fromBundle.ts:1312);
6
+ // - two children of one owner don't share a slot key (CONV-slots.ts:555).
7
+ // Children are the element children and the layer elements (backgroundLayer, pinnedLayer); <Remove> and <Interaction> are transparent.
8
+ import { LAYER_ATTRIBUTES, WRAPPERS } from "../rules-api/index.js";
9
+ import { defineRule } from "./define-rule.js";
10
+ export default defineRule({
11
+ name: 'slot-attributes',
12
+ editors: ['studio2'],
13
+ callbacks: {
14
+ JSXElement: (input) => {
15
+ if (!input.rulesApi.getComponent(input.node))
16
+ return;
17
+ rootHasNoSlot(input);
18
+ for (const { element, attribute } of input.rulesApi.getAttributeElements(input.node))
19
+ attributeElementHasNoSlot(element, attribute, input);
20
+ usesSlotsOnlyOneWay(input);
21
+ usesEachSlotKeyOnce(input);
22
+ },
23
+ },
24
+ });
25
+ // ── the steps ──
26
+ /** Reports a slot attribute on the root element (CONV-fromBundle.ts:1312). */
27
+ function rootHasNoSlot({ node, rulesApi, report }) {
28
+ const slot = rulesApi.getSlot(node);
29
+ if (slot === null || !isRoot(node, rulesApi))
30
+ return;
31
+ report({ loc: getLoc(node, rulesApi), message: `The root ${describe(node, rulesApi)} can't have a slot attribute. Remove slot="${slot}" (tracker CONV-fromBundle.ts:1312).` });
32
+ }
33
+ /** The root element: the top of Structure, or the element a <Remove> / <Interaction> at the top holds (the page model makes that wrapper the root). */
34
+ function isRoot(node, rulesApi) {
35
+ let top = node;
36
+ while (top.parent?.type === 'JSXElement' && isWrapper(top.parent, rulesApi))
37
+ top = top.parent;
38
+ return rulesApi.getRoot() === rulesApi.getComponent(top);
39
+ }
40
+ const isWrapper = (element, rulesApi) => {
41
+ const type = rulesApi.getType(element);
42
+ return type !== null && WRAPPERS.includes(type);
43
+ };
44
+ /** Reports a slot attribute on an element that sits in an attribute of its owner, layers excluded: its place is the attribute (CONV-parseJsx.ts:1507). */
45
+ function attributeElementHasNoSlot(element, attribute, { rulesApi, report }) {
46
+ const name = getAttributeName(attribute);
47
+ if (LAYER_ATTRIBUTES.includes(name) || rulesApi.getSlot(element) === null)
48
+ return;
49
+ report({ loc: getLoc(element, rulesApi), message: `${describe(element, rulesApi)} is placed in the attribute '${name}' and also has slot="${rulesApi.getSlot(element)}". Remove slot=: its place is the attribute (tracker CONV-parseJsx.ts:1507).` });
50
+ }
51
+ /** Reports an owner that holds elements in its attributes and also has a child with slot=, or an elementProps entry with slots: {…} (CONV-parseJsx.ts:1693). */
52
+ function usesSlotsOnlyOneWay({ node, rulesApi, report }) {
53
+ if (!getSlotElements(node, rulesApi).length)
54
+ return;
55
+ if (!getSlottedChildren(node, rulesApi).length && !hasEntrySlots(node))
56
+ return;
57
+ report({ loc: getLoc(node, rulesApi), message: `${describe(node, rulesApi)} places slotted children both in attributes and with slot= (or slots: {…}). Use one way: elements in attributes, or children with slot= (tracker CONV-parseJsx.ts:1693).` });
58
+ }
59
+ /** Reports a slot key that two children of the owner share (CONV-slots.ts:555), at the later one in the owner's list: children first, then layer elements. */
60
+ function usesEachSlotKeyOnce({ node, rulesApi, report }) {
61
+ const first = new Map();
62
+ for (const child of getSlottedChildren(node, rulesApi)) {
63
+ const slot = rulesApi.getSlot(child);
64
+ const other = first.get(slot);
65
+ if (!other)
66
+ first.set(slot, child);
67
+ else
68
+ report({ loc: getLoc(child, rulesApi), message: `Slot "${slot}" of ${describe(node, rulesApi)} is used by both ${describe(other, rulesApi)} and ${describe(child, rulesApi)}. Give each slotted child its own slot key (tracker CONV-slots.ts:555).` });
69
+ }
70
+ }
71
+ // ── reading the owner ──
72
+ /** The elements the owner holds in its attributes, not counting its layers. */
73
+ const getSlotElements = (owner, rulesApi) => rulesApi.getAttributeElements(owner).filter(({ attribute }) => !LAYER_ATTRIBUTES.includes(getAttributeName(attribute)));
74
+ /** The owner's children (and layer elements) that have a slot key. */
75
+ const getSlottedChildren = (owner, rulesApi) => [...rulesApi.getChildElements(owner), ...rulesApi.getAttributeElements(owner).filter(({ attribute }) => LAYER_ATTRIBUTES.includes(getAttributeName(attribute))).map(({ element }) => element)]
76
+ .filter((child) => rulesApi.getSlot(child) !== null);
77
+ /** `elementProps={{ panel: { slots: {…} } }}`, or the same in a nested `elementProps` of an entry. */
78
+ function hasEntrySlots(owner) {
79
+ const attribute = owner.openingElement.attributes.find((candidate) => candidate.type === 'JSXAttribute' && getAttributeName(candidate) === 'elementProps');
80
+ return attribute?.type === 'JSXAttribute' && attribute.value?.type === 'JSXExpressionContainer' && entriesHaveSlots(attribute.value.expression);
81
+ }
82
+ function entriesHaveSlots(entries) {
83
+ if (entries.type !== 'ObjectExpression')
84
+ return false;
85
+ return entries.properties.some((entry) => entry.type === 'Property' && entry.value.type === 'ObjectExpression' && entry.value.properties.some((property) => property.type === 'Property' && (getKeyName(property) === 'slots' || (getKeyName(property) === 'elementProps' && entriesHaveSlots(property.value)))));
86
+ }
87
+ const getKeyName = (property) => property.computed ? null : property.key.type === 'Identifier' ? property.key.name : property.key.type === 'Literal' ? String(property.key.value) : null;
88
+ const getAttributeName = (attribute) => (attribute.name.type === 'JSXIdentifier' ? String(attribute.name.name) : '');
89
+ // ── reporting ──
90
+ /** Where to mark an element: its id attribute, or its tag name. */
91
+ const getLoc = (element, rulesApi) => rulesApi.getComponent(element)?.loc ?? element.openingElement.name.loc;
92
+ /** '<Page> c1dmp', or '<Top>' without an id. */
93
+ function describe(element, rulesApi) {
94
+ const id = rulesApi.getId(element);
95
+ return `<${rulesApi.getTagName(element)}>${id ? ` ${id}` : ''}`;
96
+ }
@@ -0,0 +1,69 @@
1
+ // Every theme font token (`--wst-heading-1-font: normal normal 700 --spx(88)/1em benzin`) must end in exactly one font id
2
+ // from the Wix font catalog in @wix/origami-fonts: no quotes, commas or fallbacks, the same case. The editor reads that
3
+ // family as one Wix font id and fails the import otherwise. A legacy font, which the editor no longer offers, counts as
4
+ // not in the catalog. A `var(…)` token is left alone.
5
+ // theme.css isn't a file oxlint lints, so the rule runs on each linted page (a theme change lints every page) and
6
+ // marks the page's start; the message names the token's line in theme.css.
7
+ import { fonts } from '@wix/origami-fonts';
8
+ import { reportCssProblem } from "../rules-api/index.js";
9
+ import { defineRule } from "./define-rule.js";
10
+ const FONT_TOKEN = /^--wst-[\w-]+-font$/;
11
+ // The size in a `font` shorthand, with its optional line height: the family follows it. The number's quantifiers
12
+ // never overlap, so a long run of digits that is not a size fails in linear time.
13
+ const SIZE = /^(?:--spx\(.*\)|calc\(.*\)|(?:\d+(?:\.\d*)?|\.\d+)(?:px|em|rem|%|vw|vh|pt)?)(?:\/\S+)?$/i;
14
+ const FONT_IDS = new Set(fonts.filter((font) => font.permissions !== 'legacy').map((font) => font.id));
15
+ export default defineRule({
16
+ name: 'theme-font-ids',
17
+ editors: 'all',
18
+ callbacks: {
19
+ Program: (input) => {
20
+ for (const token of getFontTokens(input))
21
+ hasCatalogFontId(token, input);
22
+ },
23
+ },
24
+ });
25
+ // ── the steps ──
26
+ /** The theme's font tokens: `--wst-…-font`. */
27
+ function getFontTokens({ rulesApi }) {
28
+ return rulesApi.getThemeTokens().filter(({ name }) => FONT_TOKEN.test(name));
29
+ }
30
+ /** Reports a font token whose family isn't exactly one id from the catalog. A `var(…)` or a value without a family isn't checked. */
31
+ function hasCatalogFontId({ name, value, line }, { report }) {
32
+ const family = getFontFamily(value);
33
+ if (family === undefined || FONT_IDS.has(family))
34
+ return;
35
+ reportCssProblem(report, 'theme.css', line, `${name} must name exactly one Wix font id from the catalog; found ${family}. Run \`wix origami search-font --font <name>\` for the id.`);
36
+ }
37
+ // ── reading a font value ──
38
+ /** A value split on top-level whitespace, so `--spx(16)/1.3em` stays one part. */
39
+ function splitParts(value) {
40
+ const parts = [];
41
+ let depth = 0;
42
+ let current = '';
43
+ for (const char of value) {
44
+ if (char === '(')
45
+ depth++;
46
+ else if (char === ')')
47
+ depth--;
48
+ if (depth === 0 && /\s/.test(char)) {
49
+ if (current)
50
+ parts.push(current);
51
+ current = '';
52
+ }
53
+ else
54
+ current += char;
55
+ }
56
+ if (current)
57
+ parts.push(current);
58
+ return parts;
59
+ }
60
+ /** A font shorthand's family as written, after its last size; undefined for `var(…)` or no family. */
61
+ function getFontFamily(value) {
62
+ if (value.includes('var('))
63
+ return undefined;
64
+ const parts = splitParts(value);
65
+ const size = parts.findLastIndex((part) => SIZE.test(part));
66
+ if (size === -1 || size === parts.length - 1)
67
+ return undefined;
68
+ return parts.slice(size + 1).join(' ');
69
+ }
@@ -0,0 +1,122 @@
1
+ import { describeAtRule, isPresetProperty, PREAMBLE_LAYER, reportCssProblem } from "../rules-api/index.js";
2
+ import { defineRule } from "./define-rule.js";
3
+ const SPX_PROPS = ['--spx-ref-width', '--spx-resolver-type'];
4
+ const SPX_RESOLVERS = ['scale', 'fixed'];
5
+ /** A named breakpoint: `(--tablet)`. */
6
+ export default defineRule({
7
+ name: 'theme-root',
8
+ editors: ['studio2'],
9
+ callbacks: {
10
+ Program: (input) => {
11
+ const { css } = input.rulesApi.page;
12
+ for (const root of findRootRules(css.files.theme, input)) {
13
+ if (root.layer === PREAMBLE_LAYER)
14
+ continue;
15
+ if (!isAlone('theme.css', root, input))
16
+ continue;
17
+ checkThemeRoot(root, input);
18
+ }
19
+ for (const file of ['layout', 'style']) {
20
+ for (const rule of findRootRules(css.files[file], input)) {
21
+ if (rule.layer === PREAMBLE_LAYER)
22
+ continue;
23
+ if (isAlone(`${file}.css`, rule, input))
24
+ isNotInPage(file, rule, input);
25
+ }
26
+ }
27
+ },
28
+ },
29
+ });
30
+ // ── the steps ──
31
+ /** Reports a selector with a part after `:root` (CONV-cssSelectors.ts:388). */
32
+ function isAlone(file, rule, { report }) {
33
+ if (rule.selector === ':root')
34
+ return true;
35
+ reportCssProblem(report, file, rule.line, `\`${rule.selector}\` puts a part after :root. Write \`:root { … }\` alone; style elements by their own class (\`.comp-a { … }\`) (tracker CONV-cssSelectors.ts:388).`);
36
+ return false;
37
+ }
38
+ /** Reports a `:root` rule in a page's own CSS: in style.css (CONV-styleParser.ts:443); in layout.css too, which the import refuses as well (seen live, no tracker row yet). */
39
+ function isNotInPage(file, rule, { report }) {
40
+ const source = file === 'style' ? 'tracker CONV-styleParser.ts:443' : 'the import refuses it: seen live';
41
+ reportCssProblem(report, `${file}.css`, rule.line, `\`:root\` can't be used in a page's ${file}.css: set site variables in global/themes/theme.css (${source}).`);
42
+ }
43
+ /** A theme `:root` rule: a preset (step 4), then only tokens or only the spx context (step 1); an spx context's values (step 2) and place (step 3). A `:root` inside a `@mixin` is the global-styles rows' (its selector must start with `&` there). */
44
+ function checkThemeRoot(root, input) {
45
+ if (root.enclosing.some((atRule) => atRule.startsWith('@mixin')))
46
+ return;
47
+ if (!hasNoPreset(root, input))
48
+ return;
49
+ const kind = holdsTokensOrSpx(root, input);
50
+ if (kind !== 'spx')
51
+ return;
52
+ if (!isValidSpxContext(root, input))
53
+ return;
54
+ isSpxContextInPlace(root, input);
55
+ }
56
+ /** Reports `--preset` or `--preset-<part>` in `:root` (CONV-parseTheme.ts:224). */
57
+ function hasNoPreset(root, { report }) {
58
+ const preset = root.declarations.find(({ prop }) => isPresetProperty(prop));
59
+ if (!preset)
60
+ return true;
61
+ reportCssProblem(report, 'theme.css', root.line, `\`:root\` can't set ${preset.prop}; theme.css holds only :root tokens, the spx context and @mixin global styles. Set a preset on the element's rule in its page's style.css (tracker CONV-parseTheme.ts:224).`);
62
+ return false;
63
+ }
64
+ /** Reports a plain property, or tokens mixed with the spx context (CONV-parseTheme.ts:170). 'tokens' / 'spx' when the rule is one of the two. */
65
+ function holdsTokensOrSpx(root, { report }) {
66
+ const plain = root.declarations.find(({ prop }) => !prop.startsWith('--'));
67
+ if (plain) {
68
+ reportCssProblem(report, 'theme.css', root.line, `this :root rule has \`${plain.prop}\`; a :root rule holds only tokens (--…), or only --spx-ref-width and --spx-resolver-type. Move other properties to an element's rule in style.css (tracker CONV-parseTheme.ts:170).`);
69
+ return null;
70
+ }
71
+ const spx = root.declarations.filter(isSpx);
72
+ if (spx.length === 0)
73
+ return 'tokens';
74
+ if (spx.length === root.declarations.length)
75
+ return 'spx';
76
+ reportCssProblem(report, 'theme.css', root.line, `this :root rule mixes the spx context (--spx-ref-width, --spx-resolver-type) with other lines; write the spx context as its own :root rule (tracker CONV-parseTheme.ts:170).`);
77
+ return null;
78
+ }
79
+ /** Reports an spx context missing one of its two properties, with a width that isn't a positive plain number, or a resolver that isn't scale / fixed (CONV-parseTheme.ts:177). */
80
+ function isValidSpxContext(root, { report }) {
81
+ const width = lastValue(root, '--spx-ref-width');
82
+ const resolver = lastValue(root, '--spx-resolver-type');
83
+ if (width !== undefined && Number.isFinite(Number(width)) && Number(width) > 0 && resolver !== undefined && SPX_RESOLVERS.includes(resolver))
84
+ return true;
85
+ reportCssProblem(report, 'theme.css', root.line, `this :root spx rule must set both --spx-ref-width (a positive number, like 1280) and --spx-resolver-type (scale or fixed) (tracker CONV-parseTheme.ts:177).`);
86
+ return false;
87
+ }
88
+ /** Reports an spx context under anything but one `@media (--name)` the theme declares: two conditions, a plain range, another at-rule (CONV-parseTheme.ts:82). At the top level it is the default range's. */
89
+ function isSpxContextInPlace(root, { rulesApi, report }) {
90
+ if (root.enclosing.length === 0)
91
+ return;
92
+ const name = root.enclosing.length === 1 && root.enclosing[0].startsWith('@media ') ? rulesApi.getBreakpointName(root.enclosing[0].slice('@media '.length)) : null;
93
+ if (name && rulesApi.getCustomMedia('theme').some((breakpoint) => breakpoint.name === name))
94
+ return;
95
+ reportCssProblem(report, 'theme.css', root.line, `set the spx context at the top level or under \`@media (--<named range>)\`, one of the theme's @custom-media names; not under ${root.enclosing.map((atRule) => `\`${atRule}\``).join(', ')} (tracker CONV-parseTheme.ts:82).`);
96
+ }
97
+ // ── reading the CSS ──
98
+ /** Every rule whose selector starts with `:root` in a parsed file, in order, with what's around it and its declarations. No file → none. */
99
+ function findRootRules(file, { rulesApi }) {
100
+ const rules = [];
101
+ file?.walkRules((rule) => {
102
+ const selector = rule.selector.trim();
103
+ if (!selector.startsWith(':root'))
104
+ return;
105
+ const declarations = rule.nodes.flatMap((node) => (node.type === 'decl' ? [{ prop: node.prop, value: node.value.trim(), line: node.source?.start?.line ?? 1 }] : []));
106
+ rules.push({ selector, line: rule.source?.start?.line ?? 1, ...getPlace(rulesApi.getEnclosingAtRules(rule)), declarations });
107
+ });
108
+ return rules;
109
+ }
110
+ /** The at-rules around a rule, outer to inner, as written (`@layer` aside), and the nearest `@layer` name; [] and null at the top level. */
111
+ function getPlace(atRules) {
112
+ const enclosing = atRules.filter(({ name }) => name !== 'layer').map(describeAtRule);
113
+ return { enclosing, layer: atRules.findLast(({ name }) => name === 'layer')?.params.trim() ?? null };
114
+ }
115
+ const isSpx = ({ prop }) => SPX_PROPS.includes(prop);
116
+ /** A property's value as the import reads it: the last declaration wins. */
117
+ function lastValue(root, prop) {
118
+ for (let i = root.declarations.length - 1; i >= 0; i--)
119
+ if (root.declarations[i].prop === prop)
120
+ return root.declarations[i].value;
121
+ return undefined;
122
+ }
@@ -0,0 +1,3 @@
1
+ // Part 5 — rules adapter: runs the rules inside a lint tool (oxlint today). Everything that knows oxlint is in oxlint/.
2
+ export { runRules } from "./oxlint/run-rules.js";
3
+ export { createReport, createToolCallbacks } from "./utils.js";
@@ -0,0 +1,17 @@
1
+ import { getOrCreateRulesApi } from "../../rules-api/index.js";
2
+ import { createReport, createToolCallbacks } from "../utils.js";
3
+ export function toOxlintRule(rule) {
4
+ return {
5
+ meta: { type: 'problem' },
6
+ // oxlint calls this once per rule per file. The file's rules API is shared by every rule (getOrCreateRulesApi caches it).
7
+ create: (context) => {
8
+ const report = createReport((problem) => context.report(problem));
9
+ return createToolCallbacks(rule, { rulesApi: () => getOrCreateRulesApi(toLintedFile(context)), report, context });
10
+ },
11
+ };
12
+ }
13
+ /** oxlint already parsed the file: its syntax tree is reused instead of parsing it again. */
14
+ function toLintedFile({ cwd, filename, sourceCode }) {
15
+ // oxlint's tree is oxc-parser's (oxlint runs on oxc), plus `parent`, `range` and `loc`. Their types are declared apart.
16
+ return { cwd, filename, text: sourceCode.text, program: sourceCode.ast };
17
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,12 @@
1
+ // Passed to oxlint with -c by run-rules.ts. Not named oxlint.config.ts, so `oxlint .` in the origami repo does not load it as a nested config.
2
+ import path from 'node:path';
3
+ import { defineConfig } from 'oxlint';
4
+ import { ruleRegistry } from "../../rules/index.js";
5
+ /** Each rule on at its own severity. oxlint exits 0 when it finds only warnings, so warnings don't fail the lint. */
6
+ export const ruleSeverities = (rules) => Object.fromEntries(rules.map(({ name, severity = 'error' }) => [`origami/${name}`, severity]));
7
+ export default defineConfig({
8
+ // .ts in the origami repo, .js once built: the build rewrites imports, not this path.
9
+ jsPlugins: [path.join(import.meta.dirname, `oxlint-plugin${path.extname(import.meta.filename)}`)],
10
+ categories: { correctness: 'off' },
11
+ rules: ruleSeverities(ruleRegistry),
12
+ });
@@ -0,0 +1,7 @@
1
+ // The plugin oxlint loads when it starts: every rule as an oxlint rule.
2
+ import { ruleRegistry } from "../../rules/index.js";
3
+ import { toOxlintRule } from "./adapt-rule.js";
4
+ export default {
5
+ meta: { name: 'origami' },
6
+ rules: Object.fromEntries(ruleRegistry.map((rule) => [rule.name, toOxlintRule(rule)])),
7
+ };
@@ -0,0 +1,22 @@
1
+ // Runs the rules with oxlint on the pages' structure files. Its config loads the rules (through oxlint-plugin.ts).
2
+ import { spawnSync } from 'node:child_process';
3
+ import { createRequire } from 'node:module';
4
+ import path from 'node:path';
5
+ import { log } from "../../shared/log.js";
6
+ import { ROOT } from "../../shared/paths.js";
7
+ // oxlint is the lint's own dependency: found from here, wherever the package manager installed it.
8
+ const OXLINT_BIN = path.join(path.dirname(createRequire(import.meta.url).resolve('oxlint/package.json')), 'bin/oxlint');
9
+ // .ts in the origami repo, .js once built.
10
+ const OXLINT_CONFIG = path.join(import.meta.dirname, `origami-rules.config${path.extname(import.meta.filename)}`);
11
+ /** jsxFiles: the pages' structure files, from the site root. true when there was nothing to lint, or oxlint found no errors. */
12
+ export function runRules(jsxFiles) {
13
+ const passed = runOxlint(jsxFiles);
14
+ log(passed ? 'rules passed' : 'rules failed');
15
+ return passed;
16
+ }
17
+ function runOxlint(files) {
18
+ if (files.length === 0)
19
+ return true;
20
+ const result = spawnSync(process.execPath, [OXLINT_BIN, '-c', OXLINT_CONFIG, ...files], { cwd: ROOT, stdio: 'inherit' });
21
+ return result.status === 0;
22
+ }
@@ -0,0 +1 @@
1
+ export {};