@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,398 @@
1
+ import { CONTENT_TARGET, createCssFileReporter, getName, LAYER_ATTRIBUTES, OVERFLOW_TARGET } from "../rules-api/index.js";
2
+ import { defineRule } from "./define-rule.js";
3
+ // ── the tables (propSpecs.ts) ──
4
+ const keywords = (...values) => ({ values, allowed: values.join(', ') });
5
+ const ANY = { allowed: 'any value' };
6
+ const SIZE = { reads: isSize, allowed: 'a size: a number with px, %, fr, spx, vh, vw, em or rem, calc(…), auto, fit-content, max-content, min-content or unset' };
7
+ const NUMBER = { reads: (value) => Number.isFinite(Number(value)), allowed: 'a number' };
8
+ const TRACKS = { reads: isTracks, allowed: 'grid tracks: sizes, minmax(…) and repeat(…)' };
9
+ const TEMPLATE_TRACKS = { reads: (value) => value === 'none' || isTracks(value), allowed: 'grid tracks: sizes, minmax(…) and repeat(…), or none' };
10
+ const GRID_LINE = { reads: (value) => /^-?\d+\s*\/\s*-?\d+$/.test(value), allowed: '`<start> / <end>`, two whole numbers' };
11
+ const ALIGNMENT = keywords('space-between', 'space-around', 'space-evenly', 'start', 'center', 'end');
12
+ /** align-self and justify-self: the converter copies the value as written, and the page schema's ItemsAlignment takes only these (live import). */
13
+ const SELF_ALIGNMENT = keywords('start', 'end', 'center', 'stretch', 'content');
14
+ /** The element's own properties (COMPONENT_LAYOUT_PROPS). */
15
+ const ELEMENT_PROPS = {
16
+ width: SIZE,
17
+ height: SIZE,
18
+ 'aspect-ratio': { reads: isAspectRatio, allowed: 'a ratio: `<width> / <height>`, or one number' },
19
+ 'min-width': SIZE,
20
+ 'min-height': SIZE,
21
+ 'max-width': SIZE,
22
+ 'max-height': SIZE,
23
+ rotate: { reads: (value) => /^-?\d+(?:\.\d+)?deg$/.test(value), allowed: '`<n>deg`' },
24
+ visibility: keywords('hidden', 'visible'),
25
+ direction: keywords('ltr', 'rtl'),
26
+ 'container-type': keywords('inline-size'),
27
+ };
28
+ /** How the element sits in its parent (GRID_ITEM_PROPS, STACK_ITEM_PROPS, grid-row / grid-column). */
29
+ const ITEM_PROPS = {
30
+ 'grid-row': GRID_LINE,
31
+ 'grid-column': GRID_LINE,
32
+ position: ANY,
33
+ top: SIZE,
34
+ bottom: SIZE,
35
+ left: SIZE,
36
+ right: SIZE,
37
+ 'z-index': { reads: (value) => value === 'auto' || /^calc\(\s*infinity\s*\)$/.test(value), allowed: 'calc(infinity), auto' },
38
+ 'align-self': SELF_ALIGNMENT,
39
+ 'justify-self': SELF_ALIGNMENT,
40
+ 'scroll-snap-align': keywords('start', 'center', 'end', 'none'),
41
+ order: NUMBER,
42
+ 'flex-basis': SIZE,
43
+ 'flex-grow': NUMBER,
44
+ 'flex-shrink': NUMBER,
45
+ 'margin-top': SIZE,
46
+ 'margin-bottom': SIZE,
47
+ 'margin-left': SIZE,
48
+ 'margin-right': SIZE,
49
+ };
50
+ const grid = (spec, target = CONTENT_TARGET) => ({ spec, grid: true, stack: false, target });
51
+ const stack = (spec, target = CONTENT_TARGET) => ({ spec, grid: false, stack: true, target });
52
+ const both = (spec, target = CONTENT_TARGET) => ({ spec, grid: true, stack: true, target });
53
+ /** The container's properties (GRID_CONTAINER_PROPS, STACK_CONTAINER_PROPS), in a rule or in their `@origami-target` block. */
54
+ const CONTAINER_PROPS = {
55
+ display: both(keywords('grid', 'flex')),
56
+ 'grid-template-columns': grid(TEMPLATE_TRACKS),
57
+ 'grid-template-rows': grid(TEMPLATE_TRACKS),
58
+ 'grid-auto-flow': grid(keywords('row', 'column', 'dense', 'row dense', 'column dense')),
59
+ 'grid-auto-rows': grid(TRACKS),
60
+ 'grid-auto-columns': grid(TRACKS),
61
+ 'flex-direction': stack(keywords('row', 'row-reverse', 'column', 'column-reverse')),
62
+ 'flex-wrap': stack(keywords('nowrap')),
63
+ 'justify-content': stack(ALIGNMENT),
64
+ 'align-items': stack(ANY),
65
+ 'align-content': stack(ALIGNMENT),
66
+ 'row-gap': both(SIZE),
67
+ 'column-gap': both(SIZE),
68
+ 'padding-top': both(SIZE),
69
+ 'padding-bottom': both(SIZE),
70
+ 'padding-left': both(SIZE),
71
+ 'padding-right': both(SIZE),
72
+ 'scroll-behavior': both(keywords('auto', 'smooth'), OVERFLOW_TARGET),
73
+ 'scrollbar-width': both(keywords('none', 'auto'), OVERFLOW_TARGET),
74
+ 'overflow-x': both(keywords('auto', 'scroll', 'hidden', 'visible', 'clip'), OVERFLOW_TARGET),
75
+ 'overflow-y': both(keywords('auto', 'scroll', 'hidden', 'visible', 'clip'), OVERFLOW_TARGET),
76
+ 'scroll-snap-type': both({ reads: (value) => value === 'none' || /^(?:(?:x|y)\s+)?(?:mandatory|proximity)$|^(?:x|y)$/.test(value), allowed: 'x, y, mandatory, proximity, `<x|y> <mandatory|proximity>`, none' }, OVERFLOW_TARGET),
77
+ '--content-max-width': both(SIZE),
78
+ '--content-max-width-align': both(keywords('center', 'left', 'right')),
79
+ '--spx-stopper-min': both(SIZE, 'root'),
80
+ '--spx-stopper-max': both(SIZE, 'root'),
81
+ };
82
+ /** The reference its `--spx()` sizes scale by (spxTransformer.ts SPX_PROPS); checked together, :417. */
83
+ const SPX_PROPS = ['--spx-ref-width', '--spx-resolver-type'];
84
+ const SPX_RESOLVER_TYPES = ['scale', 'fixed'];
85
+ /** A property allowed at the top of a rule with `@origami-target` blocks (isRootLayoutProperty): the element's own, item and spx properties, and the root-target container property. */
86
+ const isRootProperty = (prop) => prop in ELEMENT_PROPS || prop in ITEM_PROPS || SPX_PROPS.includes(prop) || CONTAINER_PROPS[prop]?.target === 'root';
87
+ const isLayoutProperty = (prop) => isRootProperty(prop) || prop in CONTAINER_PROPS;
88
+ const ROW = (line) => `(tracker CONV-layoutParser.ts:${line})`;
89
+ export default defineRule({
90
+ name: 'css-layout-properties',
91
+ editors: ['studio2'],
92
+ callbacks: {
93
+ Program: checkLayoutFile,
94
+ JSXElement: checkItemParents,
95
+ },
96
+ });
97
+ /** The page's layout.css: each rule block, then each element at each breakpoint. */
98
+ function checkLayoutFile({ rulesApi, report }) {
99
+ const root = rulesApi.page.css.files.layout;
100
+ if (!root)
101
+ return;
102
+ const reportAt = createCssFileReporter('layout.css', report);
103
+ const blocks = readRuleBlocks(root);
104
+ for (const block of blocks)
105
+ checkBlock(block, reportAt);
106
+ const scopes = mergeScopes(blocks);
107
+ const defaultTypes = new Map();
108
+ for (const scope of scopes) {
109
+ const inherited = scope.media === null ? undefined : defaultTypes.get(scope.element);
110
+ const type = checkScope(scope, inherited, reportAt);
111
+ if (scope.media === null && type)
112
+ defaultTypes.set(scope.element, type);
113
+ }
114
+ }
115
+ /** Each property once per block (:498); a rule without targets holds layout properties (:505); with targets, root properties at the top (:512) and each container property in its target (:524). */
116
+ function checkBlock(block, reportAt) {
117
+ checkPropertiesOnce(block.declarations, `${block.element}'s rule`, reportAt);
118
+ for (const [target, declarations] of block.targets)
119
+ checkPropertiesOnce(declarations, `${block.element}'s \`@origami-target ${target}\` block`, reportAt);
120
+ if (block.targets.size === 0)
121
+ return checkLayoutPropertiesOnly(block, reportAt);
122
+ checkRootPropertiesAtTop(block, reportAt);
123
+ for (const [target, declarations] of block.targets)
124
+ checkTargetProperties(block.element, target, declarations, reportAt);
125
+ }
126
+ function checkPropertiesOnce(declarations, where, reportAt) {
127
+ const seen = new Set();
128
+ for (const decl of declarations) {
129
+ if (seen.has(decl.prop))
130
+ reportAt(decl, `\`${decl.prop}\` is declared twice in ${where}: keep one ${ROW(498)}.`);
131
+ seen.add(decl.prop);
132
+ }
133
+ }
134
+ function checkLayoutPropertiesOnly({ element, declarations }, reportAt) {
135
+ for (const decl of declarations) {
136
+ if (!isLayoutProperty(decl.prop))
137
+ reportAt(decl, `${describeNonLayoutProperty(decl.prop, element)} ${ROW(505)}.`);
138
+ }
139
+ }
140
+ /** A property the layout tables don't have: in capitals, the import doesn't know it (it reads names as written), else it is paint for style.css. */
141
+ function describeNonLayoutProperty(prop, element) {
142
+ const lowercase = prop.toLowerCase();
143
+ if (!prop.startsWith('--') && lowercase !== prop && isLayoutProperty(lowercase))
144
+ return `\`${prop}\` on ${element}: the import reads a property name as written, so write it in lowercase, \`${lowercase}\``;
145
+ return `\`${prop}\` on ${element} is not a layout property: move it to ${element}'s rule in style.css`;
146
+ }
147
+ function checkRootPropertiesAtTop({ element, declarations }, reportAt) {
148
+ for (const decl of declarations) {
149
+ if (isRootProperty(decl.prop))
150
+ continue;
151
+ const container = CONTAINER_PROPS[decl.prop];
152
+ if (container)
153
+ reportAt(decl, `${element}'s \`${decl.prop}\` goes inside \`@origami-target ${container.target} { … }\`, not at the top of its rule ${ROW(512)}.`);
154
+ else
155
+ reportAt(decl, `${describeNonLayoutProperty(decl.prop, element)} ${ROW(512)}.`);
156
+ }
157
+ }
158
+ /** Each declaration of a target block is a container property of that target; the content block also takes `position: relative` (targetContract.ts). */
159
+ function checkTargetProperties(element, target, declarations, reportAt) {
160
+ for (const decl of declarations) {
161
+ if (target === CONTENT_TARGET && decl.prop === 'position' && decl.value.trim() === 'relative')
162
+ continue;
163
+ const container = CONTAINER_PROPS[decl.prop];
164
+ if (container?.target === target)
165
+ continue;
166
+ if (container) {
167
+ const place = container.target === 'root' ? `at the top of ${element}'s rule` : `in \`@origami-target ${container.target}\``;
168
+ reportAt(decl, `${element}'s \`${decl.prop}\` goes ${place}, not in ${target} ${ROW(524)}.`);
169
+ }
170
+ else {
171
+ const fix = isLayoutProperty(decl.prop) ? `move it to the top of ${element}'s rule` : `move it to ${element}'s rule in style.css`;
172
+ reportAt(decl, `\`${decl.prop}\` in ${element}'s \`@origami-target ${target}\` block is not a container property: ${fix} ${ROW(524)}.`);
173
+ }
174
+ }
175
+ }
176
+ /** An element at one breakpoint: its spx reference (:417), its container type (:218), each value (:430, :437, :440, :447), a display for its targets (:451), grid-row with grid-column (:264). Returns the container type, for the breakpoints that inherit it. */
177
+ function checkScope(scope, inherited, reportAt) {
178
+ const { element, values } = scope;
179
+ checkSpxReference(scope, reportAt);
180
+ const ownType = checkContainerType(scope, reportAt);
181
+ const type = ownType ?? inherited;
182
+ for (const [prop, decl] of values) {
183
+ const value = decl.value.trim();
184
+ if (value === '') {
185
+ reportAt(decl, `${element}'s \`${prop}\` has no value: write a value or remove the declaration ${ROW(430)}.`);
186
+ continue;
187
+ }
188
+ if (SPX_PROPS.includes(prop))
189
+ continue;
190
+ const container = CONTAINER_PROPS[prop];
191
+ if (container && type && !container[type]) {
192
+ const [own, other] = type === 'grid' ? ['grid', 'flex'] : ['flex', 'grid'];
193
+ reportAt(decl, `${element} is a ${own} container (\`display: ${own}\`), and \`${prop}\` is a ${other} property: remove it, or use \`display: ${other}\` ${ROW(447)}.`);
194
+ continue;
195
+ }
196
+ if (prop === 'flex-wrap' && ownType === 'stack')
197
+ continue; // :218 reported it; an inherited flex reads it as a value (:437)
198
+ const spec = ELEMENT_PROPS[prop] ?? ITEM_PROPS[prop] ?? container?.spec;
199
+ if (spec && !isReadable(spec, value))
200
+ reportAt(decl, `${element}'s \`${prop}: ${value}\` is not a layout value. Allowed: ${spec.allowed} ${ROW(437)}.`);
201
+ }
202
+ if (scope.rulesWithTargets.length && !type) {
203
+ reportAt(scope.rulesWithTargets[0], `${element} has \`@origami-target\` blocks but no display: add \`display: grid\` or \`display: flex\` in its \`@origami-target ${CONTENT_TARGET}\` block ${ROW(451)}.`);
204
+ }
205
+ const gridRow = values.get('grid-row');
206
+ if (gridRow && !values.has('grid-column')) {
207
+ reportAt(gridRow, `${element} has \`grid-row\` but no \`grid-column\`: add \`grid-column: <start> / <end>\` (in the same @media, if the grid-row is in one) ${ROW(264)}.`);
208
+ }
209
+ return type;
210
+ }
211
+ /** `--spx-ref-width` is a plain number and `--spx-resolver-type` is scale or fixed; a missing one takes its default (spxTransformer.ts). */
212
+ function checkSpxReference({ element, values }, reportAt) {
213
+ const [refWidth, resolverType] = SPX_PROPS.map((prop) => values.get(prop));
214
+ const first = refWidth ?? resolverType;
215
+ if (!first)
216
+ return;
217
+ const refWidthValue = refWidth?.value.trim();
218
+ const resolverTypeValue = resolverType?.value.trim();
219
+ if (refWidthValue === '' || resolverTypeValue === '')
220
+ return; // :430 reports the empty one
221
+ if ((refWidthValue === undefined || Number.isFinite(Number(refWidthValue))) && (resolverTypeValue === undefined || SPX_RESOLVER_TYPES.includes(resolverTypeValue)))
222
+ return;
223
+ reportAt(first, `${element}'s spx context is invalid: \`--spx-ref-width\` takes a plain number (1280, no px) and \`--spx-resolver-type\` takes scale or fixed ${ROW(417)}.`);
224
+ }
225
+ /** The container type the scope's own display gives (resolveContainerType): grid, or flex with no flex-wrap other than nowrap (:218). */
226
+ function checkContainerType({ element, values }, reportAt) {
227
+ const display = values.get('display')?.value.trim();
228
+ if (display === 'grid')
229
+ return 'grid';
230
+ if (display !== 'flex')
231
+ return undefined;
232
+ const wrap = values.get('flex-wrap');
233
+ const wrapValue = wrap?.value.trim();
234
+ if (wrap && wrapValue !== '' && wrapValue !== 'nowrap') {
235
+ reportAt(wrap, `${element} has \`flex-wrap: ${wrapValue}\`, which isn't supported: remove it (or write \`nowrap\`), or use \`display: grid\` for items that wrap ${ROW(218)}.`);
236
+ }
237
+ return 'stack';
238
+ }
239
+ // ── the elements: an item needs a container parent (:359) ──
240
+ /** Each item of this element (its children, the elements in its attributes) against this element's container type. */
241
+ function checkItemParents(input) {
242
+ const parent = input.rulesApi.getComponent(input.node);
243
+ if (!parent)
244
+ return;
245
+ if (!parent.classNames.length)
246
+ return; // fromBundle.ts gives a class-less parent no children's layout
247
+ for (const child of getItems(input))
248
+ checkContainerParent(child, parent, input);
249
+ }
250
+ /** The elements this one is the parent of, as the import reads structure.jsx: its child elements and the elements in its attributes, through wrappers; not its layers (they get their item layout from the layer). */
251
+ function getItems({ node, rulesApi }) {
252
+ const held = rulesApi.getAttributeElements(node).filter(({ attribute }) => !LAYER_ATTRIBUTES.includes(getName(attribute))).map(({ element }) => element);
253
+ return [...rulesApi.getChildElements(node), ...held].flatMap((element) => rulesApi.getComponent(element) ?? []);
254
+ }
255
+ /** Reports each breakpoint at which the child sets item properties in layout.css while its parent has no `display: grid` or `flex` there or by default; not for a `position: fixed` child (its last position at that breakpoint). */
256
+ function checkContainerParent(child, parent, { report }) {
257
+ const byMedia = new Map();
258
+ for (const prop of Object.keys(ITEM_PROPS)) {
259
+ for (const decl of (child.css.layout[prop] ?? []).filter(isOnElement))
260
+ byMedia.set(decl.media, [...(byMedia.get(decl.media) ?? []), { prop, decl }]);
261
+ }
262
+ for (const [media, declarations] of byMedia) {
263
+ if (declarations.filter(({ prop }) => prop === 'position').at(-1)?.decl.value.trim() === 'fixed')
264
+ continue; // the last position wins, as the import merges the scope
265
+ if (isContainerAt(parent, media))
266
+ continue;
267
+ const childName = nameOf(child);
268
+ const parentName = nameOf(parent);
269
+ const props = [...new Set(declarations.map(({ prop }) => prop))].join(', ');
270
+ const line = Math.min(...declarations.map(({ decl }) => decl.line));
271
+ report({ loc: child.loc, message: `layout.css:${line}: ${childName} sets item properties (${props}), but its parent ${parentName} has no container layout${media ? ` at @media ${media}` : ''}: give ${parentName} \`display: grid\` or \`display: flex\`, or remove ${childName}'s item properties ${ROW(359)}.` });
272
+ }
273
+ }
274
+ /** The parent's container type at this breakpoint, as the import derives it: its last `display` there (on itself or in its content-wrapper block) when it is grid or flex, else the default's. */
275
+ function isContainerAt(parent, media) {
276
+ const displays = (parent.css.layout.display ?? []).filter((decl) => decl.selector === '&' || decl.selector === `& @origami-target ${CONTENT_TARGET}`);
277
+ const isContainer = (at) => ['grid', 'flex'].includes(displays.filter((decl) => decl.media === at).at(-1)?.value.trim() ?? '');
278
+ return isContainer(media) || (media !== null && isContainer(null));
279
+ }
280
+ /** On the element itself, not in a target block or on an inner part; at any breakpoint. */
281
+ const isOnElement = (decl) => decl.selector === '&';
282
+ /** How the import names the element: its class (the selector's), or its key when it has no class (and so no layout CSS). */
283
+ const nameOf = (component) => component.classNames[0] ?? component.key;
284
+ // ── reading the file ──
285
+ /** A layout rule's selector: one element's class. Any other selector is the selectors subject's. */
286
+ const CLASS_SELECTOR = /^\.(\S+)$/;
287
+ /** The rule blocks of the file in order: at the top level, in each `@media`, and in each `@scope`'s `@media`. The preamble layer is skipped. */
288
+ function readRuleBlocks(root) {
289
+ const blocks = [];
290
+ const readRules = (container, media) => {
291
+ for (const node of container.nodes ?? []) {
292
+ if (node.type === 'rule') {
293
+ const element = CLASS_SELECTOR.exec(node.selector.trim())?.[1];
294
+ if (element)
295
+ blocks.push(readRuleBlock(element, media, node));
296
+ }
297
+ else if (node.type === 'atrule' && media === null) {
298
+ if (node.name === 'media')
299
+ readRules(node, node.params.trim());
300
+ else if (node.name === 'scope')
301
+ readRules(node, null);
302
+ }
303
+ }
304
+ };
305
+ readRules(root, null);
306
+ return blocks;
307
+ }
308
+ function readRuleBlock(element, media, rule) {
309
+ const block = { element, media, rule, declarations: [], targets: new Map() };
310
+ for (const node of rule.nodes) {
311
+ if (node.type === 'decl')
312
+ block.declarations.push(node);
313
+ else if (node.type === 'atrule' && node.name === 'origami-target' && node.nodes) {
314
+ const target = node.params.trim();
315
+ block.targets.set(target, [...(block.targets.get(target) ?? []), ...node.nodes.filter((child) => child.type === 'decl')]);
316
+ }
317
+ }
318
+ return block;
319
+ }
320
+ /** The blocks merged per element and breakpoint, the default scopes first (a breakpoint inherits the default's container type); a target's declarations join the rule's. */
321
+ function mergeScopes(blocks) {
322
+ const scopes = new Map();
323
+ for (const block of blocks) {
324
+ const key = `${block.media ?? ''}\n${block.element}`;
325
+ const scope = scopes.get(key) ?? { element: block.element, media: block.media, values: new Map(), rulesWithTargets: [] };
326
+ scopes.set(key, scope);
327
+ for (const decl of [...block.declarations, ...[...block.targets.values()].flat()])
328
+ scope.values.set(decl.prop, decl);
329
+ if (block.targets.size)
330
+ scope.rulesWithTargets.push(block.rule);
331
+ }
332
+ return [...scopes.values()].sort((a, b) => Number(a.media !== null) - Number(b.media !== null));
333
+ }
334
+ // ── reading values (valueDeserializers.ts) ──
335
+ /** A value reads as the import reads it: a site layout variable's token, `var(--name, <fallback>)`, by its fallback (layoutVariables.ts literalOf); `--spx(12)` as `12spx`. */
336
+ function isReadable(spec, value) {
337
+ const literal = LAYOUT_VARIABLE_TOKEN.exec(value)?.[1] ?? value;
338
+ if (spec.values)
339
+ return spec.values.includes(literal);
340
+ return spec.reads ? spec.reads(restoreSpx(literal)) : true;
341
+ }
342
+ /** `var(--name, <fallback>)`: a field connected to one of the site's layout variables, written with the field's own value as the fallback. */
343
+ const LAYOUT_VARIABLE_TOKEN = /^var\(--[A-Za-z_-][\w-]*,\s*(.+)\)$/;
344
+ /** The import reads `--spx(12)` as `12spx` before it reads a value (spxTransformer.ts restoreSpxValue). */
345
+ const restoreSpx = (value) => value.replace(/--spx\((-?\d+(?:\.\d+)?)\)/g, '$1spx');
346
+ const SIZE_KEYWORDS = ['auto', 'fit-content', 'max-content', 'min-content', 'unset'];
347
+ const UNIT_SIZE = /^-?\d+(?:\.\d+)?(?:px|%|fr|spx|vh|vw|em|rem)$/;
348
+ function isSize(value) {
349
+ return SIZE_KEYWORDS.includes(value) || UNIT_SIZE.test(value) || /^calc\(.+\)$/.test(value);
350
+ }
351
+ /** `<width> / <height>` → height / width; one number → 1 / n; a ratio that is finite and above zero. */
352
+ function isAspectRatio(value) {
353
+ const match = /^(\d*\.?\d+)\s*\/\s*(\d*\.?\d+)$/.exec(value);
354
+ const ratio = match ? Number(match[2]) / Number(match[1]) : 1 / Number(value);
355
+ return Number.isFinite(ratio) && ratio > 0;
356
+ }
357
+ /** A track list: tracks separated by spaces outside parentheses, each a size, `minmax(<size>, <size>)` or `repeat(<n | auto-fill | auto-fit>, <tracks>)`. */
358
+ function isTracks(value) {
359
+ const tracks = splitOutsideParens(value, ' ');
360
+ return tracks.length > 0 && tracks.every(isTrack);
361
+ }
362
+ function isTrack(track) {
363
+ if (track.startsWith('repeat(') && track.endsWith(')')) {
364
+ const [count, tracks] = splitOutsideParens(track.slice('repeat('.length, -1), ',', 2);
365
+ if (tracks === undefined)
366
+ return false;
367
+ const n = Number(count);
368
+ const countOk = Number.isFinite(n) ? Number.isInteger(n) && n > 0 : ['auto-fill', 'auto-fit'].includes(count);
369
+ return countOk && isTracks(tracks);
370
+ }
371
+ if (track.startsWith('minmax(') && track.endsWith(')')) {
372
+ const [min, max] = splitOutsideParens(track.slice('minmax('.length, -1), ',', 2);
373
+ return max !== undefined && isSize(min) && isSize(max);
374
+ }
375
+ return isSize(track);
376
+ }
377
+ /** Splits on a separator at parenthesis depth 0, trimming each part and dropping empty ones; `limit` 2 splits once (the import's splitFunctionArgs). */
378
+ function splitOutsideParens(value, separator, limit = Infinity) {
379
+ const parts = [];
380
+ let current = '';
381
+ let depth = 0;
382
+ for (const char of value) {
383
+ if (char === '(')
384
+ depth++;
385
+ else if (char === ')')
386
+ depth--;
387
+ if (char === separator && depth === 0 && parts.length < limit - 1) {
388
+ if (current.trim() || separator === ',')
389
+ parts.push(current.trim());
390
+ current = '';
391
+ }
392
+ else
393
+ current += char;
394
+ }
395
+ if (current.trim() || separator === ',')
396
+ parts.push(current.trim());
397
+ return parts;
398
+ }