@wix/origami-lint 0.0.0-stage → 1.34.2

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,215 @@
1
+ import { BREAKPOINT_AT_RULES, createCssFileReporter, describeAtRule, getCssChildren, getDashedId, getSegmentClass, getSelectorSegments, isCssIdentifier, isSafeCssIdentifier } from "../rules-api/index.js";
2
+ import { defineRule } from "./define-rule.js";
3
+ export default defineRule({
4
+ name: 'global-styles',
5
+ editors: ['studio2'],
6
+ callbacks: {
7
+ Program: ({ rulesApi, report }) => {
8
+ const { files } = rulesApi.page.css;
9
+ if (files.style)
10
+ checkMixinsAndLinks(files.style, 'style.css', report);
11
+ if (files.theme)
12
+ checkMixinsAndLinks(files.theme, 'theme.css', report);
13
+ },
14
+ },
15
+ });
16
+ // ── the steps ──
17
+ /** The file's top-level `@mixin`s, each checked on its own; then the links between them (CONV-styleParser.ts). */
18
+ function checkMixinsAndLinks(root, file, report) {
19
+ const reportInFile = createCssFileReporter(file, report);
20
+ const mixins = getCssChildren(root).filter((node) => node.type === 'atrule' && node.name === 'mixin');
21
+ const rules = mixins.flatMap((mixin) => checkMixin(mixin, reportInFile));
22
+ const declaredIds = new Set(mixins.flatMap((mixin) => getMixinId(mixin) ?? []));
23
+ const linkedParts = checkLinks(rules.filter((rule) => rule.apply), declaredIds, reportInFile);
24
+ for (const group of groupBySelector(rules))
25
+ checkLinkOrValues(group, linkedParts, reportInFile);
26
+ }
27
+ /** `@mixin --id { @result { … } }` (CONV-astParser.ts:287, :293), then its `@result`; returns its `&` rules that pass. */
28
+ function checkMixin(mixin, reportInFile) {
29
+ const id = getMixinId(mixin);
30
+ if (id === null) {
31
+ reportInFile(mixin, getMixinIdMessage(mixin.params.trim()));
32
+ return [];
33
+ }
34
+ const result = getResult(mixin);
35
+ if (!result) {
36
+ reportInFile(mixin, `\`@mixin --${id}\` must be \`@mixin --${id} { @result { … } }\`. Put everything inside one \`@result { }\` (tracker CONV-astParser.ts:293).`);
37
+ return [];
38
+ }
39
+ const rules = [];
40
+ checkResultLevel(getCssChildren(result), id, undefined, rules, reportInFile);
41
+ return rules.filter((rule) => checkSelector(rule, reportInFile));
42
+ }
43
+ /** One level of `@result`: its top, or a breakpoint block in it (CONV-astParser.ts:195–254, walkMixinResultNodes). */
44
+ function checkResultLevel(nodes, id, breakpoint, rules, reportInFile) {
45
+ for (const node of nodes) {
46
+ if (node.type === 'rule')
47
+ checkMixinRule(node, id, breakpoint, rules, reportInFile);
48
+ else if (node.type === 'atrule')
49
+ checkResultAtRule(node, id, breakpoint, rules, reportInFile);
50
+ }
51
+ }
52
+ /** An `&` rule: starts with `&` (:195), not a bare `&` (:198), no nested rule (:204); kept when it's a value rule or a link. */
53
+ function checkMixinRule(node, id, breakpoint, rules, reportInFile) {
54
+ const selector = node.selector.trim();
55
+ const inner = getCssChildren(node);
56
+ if (!selector.startsWith('&'))
57
+ return reportInFile(node, `\`@mixin --${id}\`: selector \`${selector}\` must start with \`&\`. Write \`& ${selector}\` (or \`&:hover\`) (tracker CONV-astParser.ts:195).`);
58
+ if (selector === '&')
59
+ return reportInFile(node, getBareAmpersandMessage(id, inner));
60
+ const nested = inner.find((child) => child.type === 'rule');
61
+ if (nested)
62
+ return reportInFile(nested, `\`@mixin --${id}\`: a rule is nested in \`${selector}\`. Write the whole selector on one rule: \`${joinSelectors(selector, nested.selector.trim())} { … }\` (tracker CONV-astParser.ts:204).`);
63
+ const atRules = inner.filter((child) => child.type === 'atrule');
64
+ const applies = atRules.filter((child) => child.name === 'apply');
65
+ // another at-rule (CONV-astParser.ts:211) is css-file-nesting's; two `@apply`s, one beside declarations, or one that isn't
66
+ // `@apply --id;` (CONV-astParser.ts:41), apply-and-presets': none is a link
67
+ if (applies.length < atRules.length || applies.length > 1 || (applies.length === 1 && (inner.some((child) => child.type === 'decl') || !isApplyWritten(applies[0]))))
68
+ return;
69
+ rules.push({ mixin: id, selector, node, breakpoint, apply: applies[0] });
70
+ }
71
+ /** An at-rule in `@result`: no `@apply` (:246), no `@mixin` (:251); only `@media` / `@scope` at its top, none inside them (:254). */
72
+ function checkResultAtRule(node, id, breakpoint, rules, reportInFile) {
73
+ if (node.name === 'apply')
74
+ return reportInFile(node, `\`@mixin --${id}\`: \`@apply\` can't sit at the root of \`@result\`. Remove it, or move it into an inner part's rule: \`& .label { @apply ${node.params.trim()}; }\` (tracker CONV-astParser.ts:246).`);
75
+ if (node.name === 'mixin')
76
+ return reportInFile(node, `\`@mixin --${id}\`: \`@mixin ${node.params.trim()}\` is declared inside it. Declare each \`@mixin\` at the top level of the file (tracker CONV-astParser.ts:251).`);
77
+ if (breakpoint || !BREAKPOINT_AT_RULES.includes(node.name)) {
78
+ const where = breakpoint ? `\`${describeAtRule(breakpoint)}\` in \`@result\`` : '`@result`';
79
+ return reportInFile(node, `\`@mixin --${id}\`: \`@${node.name}\` is not allowed in ${where}. Use only \`@media\` / \`@scope\` directly inside \`@result\`, one level deep (tracker CONV-astParser.ts:254).`);
80
+ }
81
+ const media = getBreakpointBody(node);
82
+ if (media)
83
+ checkResultLevel(getCssChildren(media), id, node, rules, reportInFile);
84
+ }
85
+ /**
86
+ * Whether the rule's selector is one the links are read on: reports one that writes `&` again, or `:root`, after its start
87
+ * (CONV-cssSelectors.ts:351). A selector the import can't parse (invalidSelector) is the selectors rows', not read here: a list, an
88
+ * unbalanced bracket, `&` followed by anything but a state (`& > .label`, `&-x`, `&[data-state~="open"]`), an `&` inside a part (`.foo&`,
89
+ * `&:hover&`), a part that isn't a class (`& [data-ie-name="label"]:hover`). `:root` parses (parseSegment reads it as the root): :351's.
90
+ */
91
+ function checkSelector(rule, reportInFile) {
92
+ const segments = getSelectorSegments(rule.selector);
93
+ if (segments === null || !/^&([:.[]|$)/.test(segments[0]) || segments.some((segment, index) => segment.indexOf('&', index === 0 ? 1 : 0) > 0))
94
+ return false;
95
+ if (segments.some((segment) => segment !== ':root' && getSegmentClass(segment) === null))
96
+ return false;
97
+ const repeated = segments.slice(1).find((segment) => segment.startsWith('&') || segment === ':root');
98
+ if (repeated === undefined)
99
+ return true;
100
+ const problem = repeated === ':root' ? 'has `:root` after `&`' : 'repeats `&`';
101
+ reportInFile(rule.node, `\`@mixin --${rule.mixin}\`: selector \`${rule.selector}\` ${problem}. Write \`&\` once, at the start of the selector (tracker CONV-cssSelectors.ts:351).`);
102
+ return false;
103
+ }
104
+ /** Each link, in the import's order (collectGlobalStyleLinks): no state (:655), no breakpoint (:664), a dashed id (:670) the file declares (:673), once per part (:677). Returns the parts a link names, by mixin. */
105
+ function checkLinks(links, declaredIds, reportInFile) {
106
+ const linkedParts = new Set();
107
+ const validLinkParts = new Set();
108
+ for (const link of links) {
109
+ const child = link.apply.params.trim();
110
+ const linkDescription = `\`@mixin --${link.mixin}\`'s link \`@apply ${child}\``;
111
+ const fix = `write it as \`${getSelectorWithoutStates(link.selector)} { @apply ${child} }\``;
112
+ if (hasState(link.selector)) {
113
+ reportInFile(link.apply, `${linkDescription} can't be under a state or a breakpoint: ${fix} and put the state / breakpoint values in @mixin ${child} (tracker CONV-styleParser.ts:655).`);
114
+ continue;
115
+ }
116
+ if (link.breakpoint) {
117
+ reportInFile(link.apply, `${linkDescription} can't be under a state or a breakpoint: ${fix} outside \`${describeAtRule(link.breakpoint)}\`, and put the breakpoint values in @mixin ${child} (tracker CONV-styleParser.ts:655).`);
118
+ continue;
119
+ }
120
+ const part = getPartKey(link);
121
+ linkedParts.add(part);
122
+ const id = getDashedId(child);
123
+ if (!isSafeCssIdentifier(id)) {
124
+ reportInFile(link.apply, `\`@mixin --${link.mixin}\` links \`${child}\`, which is not a valid global style name: use letters, digits, - and _ (tracker CONV-styleParser.ts:670).`);
125
+ continue;
126
+ }
127
+ if (!declaredIds.has(id)) {
128
+ reportInFile(link.apply, `\`@mixin --${link.mixin}\` links \`${child}\`, but the file has no \`@mixin ${child}\`: add it (tracker CONV-styleParser.ts:673).`);
129
+ continue;
130
+ }
131
+ if (validLinkParts.has(part)) {
132
+ reportInFile(link.apply, `\`@mixin --${link.mixin}\` links two global styles at \`${getPart(link.selector)}\`: keep one (tracker CONV-styleParser.ts:677).`);
133
+ continue;
134
+ }
135
+ validLinkParts.add(part);
136
+ }
137
+ return linkedParts;
138
+ }
139
+ /** 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). */
140
+ function checkLinkOrValues(group, linkedParts, reportInFile) {
141
+ const link = group.find((rule) => rule.apply);
142
+ const values = group.filter((rule) => !rule.apply);
143
+ const [first] = group;
144
+ if (link && values.length > 0) {
145
+ return reportInFile(values[0].node, `\`${first.selector}\` of \`@mixin --${first.mixin}\` has both \`@apply ${link.apply.params.trim()}\` and values: move the values into @mixin ${link.apply.params.trim()} (tracker CONV-styleParser.ts:803).`);
146
+ }
147
+ const part = getPart(first.selector);
148
+ if (link || part === '' || linkedParts.has(getPartKey(first)))
149
+ return;
150
+ reportInFile(first.node, `\`@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).`);
151
+ }
152
+ // ── reading a mixin ──
153
+ /** The id a `@mixin` declares (`--btn` → 'btn'), or null when it isn't one dashed identifier. */
154
+ function getMixinId(mixin) {
155
+ const id = getDashedId(mixin.params);
156
+ return id !== undefined && isSafeCssIdentifier(id) ? id : null;
157
+ }
158
+ /** The mixin's one `@result` with no params and a block, beside nothing; undefined otherwise. */
159
+ function getResult(mixin) {
160
+ if (mixin.nodes === undefined)
161
+ return undefined;
162
+ const [result, ...rest] = getCssChildren(mixin);
163
+ if (rest.length > 0 || result?.type !== 'atrule' || result.name !== 'result' || result.params.trim() || result.nodes === undefined)
164
+ return undefined;
165
+ return result;
166
+ }
167
+ /** The block a breakpoint in `@result` stands for: a `@media`'s own, a `@scope`'s first `@media` (utils.ts parseBreakpointAst). Undefined when it has none: css-file-nesting's rows. */
168
+ function getBreakpointBody(node) {
169
+ let current = node;
170
+ while (current?.type === 'atrule' && current.name === 'scope' && current.nodes !== undefined)
171
+ current = getCssChildren(current)[0];
172
+ return current?.type === 'atrule' && current.name === 'media' && current.nodes !== undefined ? current : undefined;
173
+ }
174
+ /** A mixin's rules by selector, as the import groups its records (parseRecords): one group per mixin and selector, its breakpoints together. */
175
+ function groupBySelector(rules) {
176
+ const groups = new Map();
177
+ for (const rule of rules) {
178
+ const key = `${rule.mixin}\0${rule.selector}`;
179
+ const group = groups.get(key);
180
+ if (group)
181
+ group.push(rule);
182
+ else
183
+ groups.set(key, [rule]);
184
+ }
185
+ return [...groups.values()];
186
+ }
187
+ // ── reading a selector ──
188
+ const getSegments = (selector) => getSelectorSegments(selector) ?? [];
189
+ /** The selector with no state: `&:hover .label` → `& .label`. */
190
+ const getSelectorWithoutStates = (selector) => getSegments(selector).map((segment) => getSegmentClass(segment) ?? segment).join(' ');
191
+ const hasState = (selector) => getSelectorWithoutStates(selector) !== getSegments(selector).join(' ');
192
+ /** The inner part a selector styles, without states: `&:hover .label` → `.label`; '' for the mixin's own element. */
193
+ const getPart = (selector) => getSegments(selector).slice(1).map((segment) => getSegmentClass(segment) ?? segment).join(' ');
194
+ /** `@apply --id;`: one dashed id and no block, as the import reads it (applyTargetOf). */
195
+ const isApplyWritten = (apply) => getDashedId(apply.params) !== undefined && getCssChildren(apply).length === 0;
196
+ const getPartKey = (rule) => `${rule.mixin}\0${getPart(rule.selector)}`;
197
+ /** A nested rule's selector written on its parent: `&:hover` + `& .label` (or `.label`) → `&:hover .label`. */
198
+ const joinSelectors = (outer, inner) => (inner.includes('&') ? inner.replaceAll('&', outer) : `${outer} ${inner}`);
199
+ // ── the messages ──
200
+ /** `@mixin --btn:hover` → the dashed id to write, and where its state goes. */
201
+ function getMixinIdMessage(params) {
202
+ const written = getDashedId(params);
203
+ if (written !== undefined && isCssIdentifier(written) && !isSafeCssIdentifier(written))
204
+ return `\`@mixin ${params}\`: \`${written}\` is reserved. Name the global style with another dashed identifier (\`@mixin --${written.replace(/^_+|_+$/g, '')}-style\`) (tracker CONV-astParser.ts:287).`;
205
+ const id = /^-*([_a-zA-Z][_a-zA-Z0-9-]*)/.exec(params)?.[1] ?? 'id';
206
+ const state = /[:[]/.test(params) ? `. Write the state as an \`&${params.slice(params.search(/[:[]/))} { … }\` rule inside \`@result\`` : '';
207
+ return `\`@mixin ${params}\` must name one dashed identifier (\`@mixin --${id}\`)${state} (tracker CONV-astParser.ts:287).`;
208
+ }
209
+ /** A bare `& { … }` (CONV-astParser.ts:198): its values go straight in `@result`; an `@apply` in it is the import's :658 case, refused here first. */
210
+ function getBareAmpersandMessage(id, inner) {
211
+ const apply = inner.find((child) => child.type === 'atrule' && child.name === 'apply');
212
+ if (apply)
213
+ return `\`@mixin --${id}\` applies ${apply.params.trim()} to itself, which isn't supported: write the values in @mixin --${id}'s @result, or link a child on an inner part (\`& .label { @apply … }\`) (tracker CONV-astParser.ts:198, CONV-styleParser.ts:658).`;
214
+ return `\`@mixin --${id}\`: drop the \`& { }\` wrapper and write its declarations directly in \`@result\` (tracker CONV-astParser.ts:198).`;
215
+ }
@@ -0,0 +1,16 @@
1
+ import allowedImports from "./allowed-imports.js";
2
+ import applyAndPresets from "./apply-and-presets.js";
3
+ import bindings from "./bindings.js";
4
+ import breakpoints from "./breakpoints.js";
5
+ import cssFileNesting from "./css-file-nesting.js";
6
+ import cssLayoutProperties from "./css-layout-properties.js";
7
+ import dataValues from "./data-values.js";
8
+ import globalStyles from "./global-styles.js";
9
+ import noAbsoluteSection from "./no-absolute-section.js";
10
+ import pageStructure from "./page-structure.js";
11
+ import slotAttributes from "./slot-attributes.js";
12
+ import themeFontIds from "./theme-font-ids.js";
13
+ import themeRoot from "./theme-root.js";
14
+ 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 { defineRule } from "./define-rule.js";
@@ -0,0 +1,31 @@
1
+ import { SECTIONS } from "../rules-api/index.js";
2
+ import { defineRule } from "./define-rule.js";
3
+ export default defineRule({
4
+ name: 'no-absolute-section',
5
+ editors: 'all',
6
+ callbacks: {
7
+ JSXElement: (input) => {
8
+ const section = getSection(input);
9
+ if (section)
10
+ hasNoAbsolutePosition(section, input);
11
+ },
12
+ },
13
+ });
14
+ // ── the steps ──
15
+ /** The section this element is; null for any other element, or JSX outside Structure. */
16
+ function getSection({ node, rulesApi }) {
17
+ const type = rulesApi.getType(node);
18
+ return type && SECTIONS.includes(type) ? rulesApi.getComponent(node) : null;
19
+ }
20
+ /** Reports each `position: absolute` the section has, in any CSS, on any part of it, at any @media or @origami-target. */
21
+ function hasNoAbsolutePosition(section, { rulesApi, report }) {
22
+ for (const decl of rulesApi.getCss(section, 'position').filter(isAbsolute)) {
23
+ report({ loc: section.loc, message: `Section ${section.key} has \`position: absolute\`${describeWhere(decl)}. Sections can't be absolutely positioned.` });
24
+ }
25
+ }
26
+ // ── reporting ──
27
+ const isAbsolute = (decl) => decl.value.trim().toLowerCase() === 'absolute';
28
+ function describeWhere({ media, selector }) {
29
+ const parts = [selector !== '&' && `\`${selector}\``, media && `@media ${media}`].filter(Boolean);
30
+ return parts.length ? ` (${parts.join(', ')})` : '';
31
+ }
@@ -0,0 +1,112 @@
1
+ import { STRUCTURE } from "../rules-api/index.js";
2
+ import { defineRule } from "./define-rule.js";
3
+ const FREE_CODE = 'The import refuses this statement: a structure file holds only imports, `const` / `let` / `var` declarations and `export const Structure = () => …` (tracker CONV-parseJsx.ts:287).';
4
+ const STRUCTURE_SHAPE = 'Declare Structure once, as `export const Structure = () => ( <Page …/> )`: an exported const arrow that returns the root element directly, with no `{ return … }`, fragment or condition around it (tracker CONV-parseJsx.ts:335).';
5
+ const typeScript = (code) => `\`${code}\` is TypeScript. The import reads a structure file as plain JavaScript and fails on it: remove it (tracker CONV-parseJsx.ts:1845).`;
6
+ /** Every statement type but ImportDeclaration (always allowed) and the TypeScript ones (TYPESCRIPT). */
7
+ const STATEMENTS = [
8
+ 'ExpressionStatement', 'ExportNamedDeclaration', 'ExportDefaultDeclaration', 'ExportAllDeclaration', 'VariableDeclaration',
9
+ 'FunctionDeclaration', 'ClassDeclaration', 'BlockStatement', 'EmptyStatement', 'IfStatement', 'SwitchStatement', 'ForStatement',
10
+ 'ForInStatement', 'ForOfStatement', 'WhileStatement', 'DoWhileStatement', 'BreakStatement', 'ContinueStatement', 'LabeledStatement',
11
+ 'ThrowStatement', 'TryStatement', 'WithStatement', 'DebuggerStatement',
12
+ ];
13
+ /** The TypeScript nodes that can sit where JavaScript is written; the other TS nodes are only inside these. */
14
+ const TYPESCRIPT = [
15
+ 'TSTypeAnnotation', 'TSTypeParameterDeclaration', 'TSTypeParameterInstantiation', 'TSAsExpression', 'TSSatisfiesExpression',
16
+ 'TSNonNullExpression', 'TSTypeAssertion', 'TSInstantiationExpression', 'TSParameterProperty', 'TSIndexSignature', 'TSClassImplements',
17
+ 'TSAbstractMethodDefinition', 'TSAbstractPropertyDefinition', 'TSAbstractAccessorProperty', 'TSEmptyBodyFunctionExpression',
18
+ 'TSDeclareFunction', 'TSTypeAliasDeclaration', 'TSInterfaceDeclaration', 'TSEnumDeclaration', 'TSModuleDeclaration',
19
+ 'TSImportEqualsDeclaration', 'TSExportAssignment', 'TSNamespaceExportDeclaration',
20
+ 'ImportDeclaration', 'ImportSpecifier', 'ExportSpecifier',
21
+ ];
22
+ export default defineRule({
23
+ name: 'no-free-code',
24
+ editors: 'all',
25
+ callbacks: {
26
+ ...on(STATEMENTS, isAllowedStatement),
27
+ ...on(TYPESCRIPT, hasNoTypeScript),
28
+ ArrowFunctionExpression: (input) => {
29
+ if (isStructureArrow(input.node))
30
+ returnsElement(input);
31
+ },
32
+ },
33
+ });
34
+ // ── the steps ──
35
+ function isAllowedStatement({ node, rulesApi, report, context }) {
36
+ if (!isAtTop(node))
37
+ return;
38
+ if (rulesApi.isTypeScript(node))
39
+ return reportTypeScript(node, report, context);
40
+ const declaration = declarationOf(node);
41
+ if (rulesApi.isTypeScript(declaration)) {
42
+ // `export declare class` / `export abstract class`: no TYPESCRIPT callback sees a class or a variable, so it is reported here.
43
+ if (declaration && !TYPESCRIPT.includes(declaration.type))
44
+ reportTypeScript(declaration, report, context);
45
+ return;
46
+ }
47
+ if (isTypeOnlyExport(node, rulesApi))
48
+ return;
49
+ if (declaresStructure(node)) {
50
+ if (!structureArrowOf(node))
51
+ report({ loc: node.loc, message: STRUCTURE_SHAPE });
52
+ return;
53
+ }
54
+ // The import drops every variable declaration before it checks the statements (parseJsx.ts rejectTopLevelStatements).
55
+ if (declaration?.type !== 'VariableDeclaration')
56
+ report({ loc: node.loc, message: FREE_CODE });
57
+ }
58
+ /** Skips a file with no root element (page-structure reports it) and a body that is TypeScript (hasNoTypeScript reports it). */
59
+ function returnsElement({ node, rulesApi, report }) {
60
+ const body = unwrap(node.body);
61
+ if (body.type === 'JSXElement' || rulesApi.isTypeScript(body) || !rulesApi.getRoot())
62
+ return;
63
+ report({ loc: node.body.loc, message: STRUCTURE_SHAPE });
64
+ }
65
+ /** In a structure.jsx it doesn't parse; this is for a structure.tsx, which the import parses as JavaScript too. */
66
+ function hasNoTypeScript({ node, rulesApi, report, context }) {
67
+ if (rulesApi.isTypeScript(node) && !isInsideTypeScript(node, rulesApi))
68
+ reportTypeScript(node, report, context);
69
+ }
70
+ function reportTypeScript(node, report, context) {
71
+ report({ loc: node.loc, message: typeScript(shortCode(context.sourceCode.getText(node))) });
72
+ }
73
+ // ── reading the code ──
74
+ function on(names, check) {
75
+ return Object.fromEntries(names.map((name) => [name, check]));
76
+ }
77
+ const isAtTop = (node) => node.parent?.type === 'Program';
78
+ function isInsideTypeScript(node, rulesApi) {
79
+ for (let up = node.parent; up; up = up.parent)
80
+ if (rulesApi.isTypeScript(up))
81
+ return true;
82
+ return false;
83
+ }
84
+ const declarationOf = (node) => node.type === 'ExportNamedDeclaration' || node.type === 'ExportDefaultDeclaration' ? node.declaration : node;
85
+ function declaresStructure(node) {
86
+ const declaration = declarationOf(node);
87
+ if (declaration?.type === 'VariableDeclaration')
88
+ return declaration.declarations.some(({ id }) => id.type === 'Identifier' && id.name === STRUCTURE);
89
+ return (declaration?.type === 'FunctionDeclaration' || declaration?.type === 'ClassDeclaration') && declaration.id?.name === STRUCTURE;
90
+ }
91
+ function structureArrowOf(node) {
92
+ if (node.type !== 'ExportNamedDeclaration' || node.declaration?.type !== 'VariableDeclaration' || node.declaration.kind !== 'const')
93
+ return null;
94
+ // The import sees no declaration at all in a const with a `{ a } = …` or a `b` with no value next to Structure (parseModule.ts isNamed).
95
+ const { declarations } = node.declaration;
96
+ if (!declarations.every(({ id, init }) => id.type === 'Identifier' && init))
97
+ return null;
98
+ const init = declarations.find(({ id }) => id.type === 'Identifier' && id.name === STRUCTURE)?.init;
99
+ // Parameters and `async` pass: the import reads only the arrow's body (parseJsx.ts structureElementOrIssue).
100
+ return init?.type === 'ArrowFunctionExpression' ? init : null;
101
+ }
102
+ /** `export { type Y } from 'x'` is TypeScript, reported once by its specifier (hasNoTypeScript), not as free code too. */
103
+ const isTypeOnlyExport = (node, rulesApi) => node.type === 'ExportNamedDeclaration' && !node.declaration && node.specifiers.length > 0 && node.specifiers.every(rulesApi.isTypeScript);
104
+ function isStructureArrow(node) {
105
+ const statement = node.parent.parent?.parent;
106
+ return statement?.type === 'ExportNamedDeclaration' && isAtTop(statement) && structureArrowOf(statement) === node;
107
+ }
108
+ const unwrap = (node) => (node.type === 'ParenthesizedExpression' ? node.expression : node);
109
+ function shortCode(code) {
110
+ const [line] = code.trim().split('\n');
111
+ return line.length > 40 ? `${line.slice(0, 40)}…` : line;
112
+ }