@wix/origami-transforms 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ /** @type {import('../transform/types.js').CssTransformPlugin} */
2
+ export const customEnumsPlugin: import("../transform/types.js").CssTransformPlugin;
@@ -0,0 +1,4 @@
1
+ export function structureFileOf(stylesheetPath: any): string | undefined;
2
+ export function parseStructure(structurePath: any): any;
3
+ export function walk(node: any, visit: any): void;
4
+ export function literalIdOf(openingElement: any): any;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wix/origami-transforms",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Babel and PostCSS checks and transforms for Wix origami page files.",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -54,5 +54,5 @@
54
54
  },
55
55
  "type": "module",
56
56
  "types": "./dist/index.d.ts",
57
- "falconPackageHash": "ca2361f449d0870ec5720dae8f71767a7dadabb976f2ed36c1481607"
57
+ "falconPackageHash": "9674cee94f59f7db338de844100c7527c7eeddff5e2d58f750c8f488"
58
58
  }
@@ -0,0 +1,326 @@
1
+ // Resolves a component's custom enums at build time. A site sets only the enum's value on an instance,
2
+ // `#comp-1 { --labelAlign: center }`, and this writes the vars that option applies in its place, from the
3
+ // `@custom-enum` blocks in the component wrapper's `custom-enums.css` (see component-wrapper-server's
4
+ // `customEnums.ts`), which the page imports and the bundler inlines. The blocks are removed from the output.
5
+ // Native `if()` can't do it: Firefox doesn't have it, and a var named like its key refers to itself.
6
+ //
7
+ // The instance's component comes from the item's structure file (structureFile.js): the element with
8
+ // `id="comp-1"`, and the `@origami-wrappers/…` import it is rendered from. See customEnums.md.
9
+
10
+ import { realpathSync } from 'node:fs'
11
+ import { createRequire } from 'node:module'
12
+ import { basename, dirname } from 'node:path'
13
+ import postcss from 'postcss'
14
+ import { LayoutError } from '../transform/layoutError.js'
15
+ import { literalIdOf, parseStructure, structureFileOf, walk } from './structureFile.js'
16
+
17
+ // `@origami-wrappers/<module>/<symbol>`, and nothing that could resolve outside its package.
18
+ const WRAPPER_IMPORT = /^@origami-wrappers\/[\w-]+\/[\w-]+$/
19
+ // `#id` or `.id` (exports give an instance both), then only what narrows that same element: classes,
20
+ // pseudo-classes, attributes. Then any descendants, such as an inline element's own selector. No other combinators,
21
+ // and no pseudo-elements, which the component doesn't read vars from.
22
+ const INSTANCE = /^[#.]([\w-]+)(?!.*::)(?:[.:[][^\s>+~]*)?((?:\s+[^\s>+~]+)*)$/
23
+
24
+ /** Id → the wrapper import it is rendered from, for elements with a literal `id` and an imported wrapper tag. */
25
+ const importsById = structurePath => {
26
+ const program = parseStructure(structurePath)
27
+ const sources = new Map()
28
+ for (const node of program?.body ?? []) {
29
+ if (node.type !== 'ImportDeclaration' || !WRAPPER_IMPORT.test(node.source.value)) continue
30
+ for (const specifier of node.specifiers) sources.set(specifier.local.name, node.source.value)
31
+ }
32
+ const byId = new Map()
33
+ walk(program, node => {
34
+ if (node.type !== 'JSXOpeningElement' || node.name.type !== 'JSXIdentifier') return
35
+ const id = literalIdOf(node)
36
+ const source = sources.get(node.name.name)
37
+ if (id !== undefined && source !== undefined) byId.set(id, source)
38
+ })
39
+ return byId
40
+ }
41
+
42
+ /** A path with its symlinks resolved, so a wrapper reached through a package manager's links matches its folder. */
43
+ const real = path => {
44
+ try {
45
+ return realpathSync(path)
46
+ } catch {
47
+ return path
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Id → the enums of every wrapper instance; none for one whose component has no custom enums. An instance's enums
53
+ * are the `@custom-enum` blocks from its wrapper's folder.
54
+ */
55
+ const enumsById = (structurePath, byFolder) => {
56
+ const require = createRequire(structurePath)
57
+ const byId = new Map()
58
+ for (const [id, source] of importsById(structurePath)) {
59
+ let folder
60
+ try {
61
+ folder = real(dirname(require.resolve(source)))
62
+ } catch {
63
+ folder = undefined
64
+ }
65
+ byId.set(id, (folder !== undefined && byFolder.get(folder)) || [])
66
+ }
67
+ return byId
68
+ }
69
+
70
+ /** `selector`, one of the rule's own, resolved through its parent rules, past any at-rules, as CSS Nesting does. */
71
+ const resolve = (rule, selector) => {
72
+ let selectors = [selector]
73
+ for (let outer = rule.parent; outer && outer.type !== 'root'; outer = outer.parent) {
74
+ if (outer.type !== 'rule') continue
75
+ const inner = selectors
76
+ selectors = /** @type {import('postcss').Rule} */ (outer).selectors.flatMap(parent =>
77
+ inner.map(item => (item.includes('&') ? item.replaceAll('&', parent) : `${parent} ${item}`))
78
+ )
79
+ }
80
+ return selectors.map(item => item.trim())
81
+ }
82
+
83
+ /**
84
+ * Whether a declaration came from a dependency's stylesheet that the page `@import`s, which the bundler inlines: a
85
+ * wrapper's `defaults.css` sets its enums' defaults on `.menu`, and its presets set enum keys. Those are the
86
+ * wrapper's own, never an instance's.
87
+ */
88
+ const fromDependency = declaration => declaration.source?.input.file?.split(/[\\/]/).includes('node_modules') ?? false
89
+
90
+ /**
91
+ * The `@custom-enum` blocks in the stylesheet: wrapper folder → its enums, with each option as the enum's full var
92
+ * set; the blocks themselves, to remove; and the ones that can't be read.
93
+ *
94
+ * A block sits in its element's rule, `.menu`, or `.menu .menu-input` for an inline element. A var an option doesn't
95
+ * set has the default option's value, or `inherit` when the default doesn't set it either, which is what an instance
96
+ * that picks no option gets. An option that doesn't set its own key still says which option it is.
97
+ */
98
+ const definitionsIn = root => {
99
+ const byFolder = new Map()
100
+ const blocks = []
101
+ const malformed = []
102
+ root.walkAtRules('custom-enum', block => {
103
+ blocks.push(block)
104
+ const keyVar = block.params.trim()
105
+ const file = block.source?.input.file
106
+ const rule = block.parent?.type === 'rule' ? /** @type {import('postcss').Rule} */ (block.parent) : undefined
107
+ const applied = new Map()
108
+ let defaultValue
109
+ for (const node of block.nodes ?? []) {
110
+ if (node.type === 'decl' && node.prop === 'default') defaultValue = node.value.trim()
111
+ if (node.type === 'atrule' && node.name === 'option') {
112
+ const values = {}
113
+ node.each(child => {
114
+ if (child.type === 'decl') values[child.prop] = child.value
115
+ })
116
+ values[keyVar] ??= node.params.trim()
117
+ applied.set(node.params.trim(), values)
118
+ }
119
+ }
120
+ if (!keyVar.startsWith('--') || file === undefined || rule === undefined || !applied.has(defaultValue)) {
121
+ malformed.push(block)
122
+ return
123
+ }
124
+
125
+ const [selector] = resolve(rule, rule.selector)
126
+ const [editorSelector, ...inline] = selector.split(/\s+/)
127
+ const defaults = applied.get(defaultValue)
128
+ const vars = [...new Set([...applied.values()].flatMap(values => Object.keys(values)))]
129
+ const options = Object.fromEntries(
130
+ [...applied].map(([value, values]) => [
131
+ value,
132
+ Object.fromEntries(vars.map(name => [name, values[name] ?? defaults[name] ?? 'inherit'])),
133
+ ])
134
+ )
135
+ const folder = real(dirname(file))
136
+ const enums = byFolder.get(folder) ?? []
137
+ byFolder.set(folder, enums)
138
+ enums.push({
139
+ key: keyVar,
140
+ options,
141
+ editorSelector,
142
+ // An inline element's selector inside the editor element's, undefined for the editor element itself.
143
+ inline: inline.length ? inline.join(' ') : undefined,
144
+ })
145
+ })
146
+ return { byFolder, blocks, malformed }
147
+ }
148
+
149
+ /** The rule a declaration is in, through any at-rules nested in it. */
150
+ const ruleOf = declaration => {
151
+ let node = declaration.parent
152
+ while (node && node.type === 'atrule') node = node.parent
153
+ return node?.type === 'rule' ? /** @type {import('postcss').Rule} */ (node) : undefined
154
+ }
155
+
156
+ /** ` (style.css:12)`: the carrier the declaration was written in, which the bundler doesn't name. */
157
+ const where = declaration => {
158
+ const file = declaration.source?.input.file
159
+ const line = declaration.source?.start?.line
160
+ return file ? ` (${basename(file)}${line ? `:${line}` : ''})` : ''
161
+ }
162
+
163
+ /** The option's vars as declarations, carrying the authored one's `!important` and source. */
164
+ const expansion = (declaration, values) =>
165
+ Object.entries(values).map(([prop, value]) =>
166
+ declaration.clone({ prop, value })
167
+ )
168
+
169
+ /**
170
+ * An inline element's vars, as a rule of their own after the outermost node, under the same at-rules.
171
+ * `:not()` keeps them off the same inline element of an instance nested inside this one. `inserted` maps each
172
+ * outermost node to the last rule put after it, so the rules keep their declarations' source order.
173
+ */
174
+ const inlineRule = (declaration, instance, { inline, editorSelector }, options, inserted) => {
175
+ let node = postcss
176
+ .rule({
177
+ selector: `${instance} ${inline}:not(${instance} ${editorSelector} ${inline})`,
178
+ source: declaration.source,
179
+ raws: { semicolon: true },
180
+ })
181
+ .append(expansion(declaration, options))
182
+ let outer = declaration.parent
183
+ for (let parent = declaration.parent; parent && parent.type !== 'root'; parent = parent.parent) {
184
+ if (parent.type === 'atrule') node = parent.clone({ nodes: [] }).append(node)
185
+ outer = parent
186
+ }
187
+ ;(inserted.get(outer) ?? outer).after(node)
188
+ inserted.set(outer, node)
189
+ }
190
+
191
+ /** @type {import('../transform/types.js').CssTransformPlugin} */
192
+ export const customEnumsPlugin = {
193
+ name: 'custom-enums',
194
+ // After mixins copy their bodies in, before presets and layout move declarations out of the instance's rule.
195
+ order: 11,
196
+ transform(root, context) {
197
+ const { byFolder, blocks, malformed } = definitionsIn(root)
198
+ if (blocks.length === 0) return
199
+ const structurePath = structureFileOf(context.filePath)
200
+ if (structurePath !== undefined) context.addDependency?.(structurePath)
201
+ const instances = structurePath === undefined ? new Map() : enumsById(structurePath, byFolder)
202
+ const keys = new Set([...instances.values()].flatMap(enums => enums.map(({ key }) => key)))
203
+
204
+ /** @type {import('postcss').Declaration[]} */
205
+ const declarations = []
206
+ const collect = () =>
207
+ root.walkDecls(declaration => {
208
+ if (keys.has(declaration.prop) && !fromDependency(declaration)) declarations.push(declaration)
209
+ })
210
+ collect()
211
+
212
+ /**
213
+ * What `declaration` resolves to on the instance or inline element `selector` is, the reason it can't, or null
214
+ * when that element has no such enum, so the var is an ordinary one. `onElement` says the selector is the
215
+ * element the enum is on, so its vars go in place.
216
+ */
217
+ const lookup = (declaration, selector) => {
218
+ const match = INSTANCE.exec(selector)
219
+ const enums = match ? instances.get(match[1]) : undefined
220
+ if (match === null || enums === undefined) {
221
+ return (
222
+ `"${selector}" is not a component instance. Set a custom enum on the instance's own #id or .id (with ` +
223
+ `a literal id="…" in the structure file), or on its inline element (#id .menu-input), with ` +
224
+ `nothing after either but classes or pseudo-classes.`
225
+ )
226
+ }
227
+ // An inline element's selector, then only what narrows it, as on the instance.
228
+ const descendants = match[2].trim()
229
+ const onInline = ({ inline }) =>
230
+ inline !== undefined &&
231
+ descendants.startsWith(inline) &&
232
+ /^(?:$|[.:[])/.test(descendants.slice(inline.length))
233
+ const matching = enums.filter(
234
+ item => item.key === declaration.prop && (descendants === '' || onInline(item))
235
+ )
236
+ if (matching.length === 0) return null
237
+ const key = declaration.prop.slice(2)
238
+ if (matching.length > 1) return `two of the component's elements have a custom enum ${key}, so it can't say which.`
239
+ const [found] = matching
240
+ const value = declaration.value.trim()
241
+ const allowed = Object.keys(found.options).join(', ')
242
+ if (/\bvar\(/.test(value)) return `a custom enum's value must be literal, one of ${allowed}.`
243
+ const options = found.options[value]
244
+ if (options === undefined) return `"${value}" is not an option of ${key}; use one of ${allowed}.`
245
+ return { found, options, onElement: descendants !== '' || found.inline === undefined }
246
+ }
247
+
248
+ // A block comes from the wrapper package, which the site can't fix: its enum is skipped, so an instance that
249
+ // sets its key keeps it as authored and gets the wrapper's default.
250
+ for (const block of malformed) {
251
+ context.warn?.(
252
+ block,
253
+ `custom-enums: @custom-enum ${block.params}${where(block)}: can't be read; it needs a --key, a ` +
254
+ `default: that is one of its @option blocks, and an element rule around it. Its key is left as authored.`
255
+ )
256
+ }
257
+
258
+ // Every declaration is checked before any is rewritten: a failed run leaves the stylesheet as it was.
259
+ const violations = new Set()
260
+ /** @type {any} The first violation's source, whose position the bundler prints. */
261
+ let first
262
+ for (const declaration of declarations) {
263
+ const rule = ruleOf(declaration)
264
+ const problems = rule
265
+ ? rule.selectors.flatMap(own => {
266
+ const resolved = resolve(rule, own)
267
+ if (resolved.length !== 1) return ['set a custom enum on one instance rule, not under a parent selector list.']
268
+ const result = lookup(declaration, resolved[0])
269
+ return typeof result === 'string' ? [result] : []
270
+ })
271
+ : ['set a custom enum on one instance rule, not outside a rule.']
272
+ for (const problem of problems) {
273
+ first ??= declaration.source
274
+ violations.add(`custom-enums: ${declaration.prop}: ${declaration.value}${where(declaration)}: ${problem}`)
275
+ }
276
+ }
277
+ if (violations.size) {
278
+ throw new LayoutError([...violations], {
279
+ file: first?.input.file,
280
+ line: first?.start?.line,
281
+ column: first?.start?.column,
282
+ })
283
+ }
284
+
285
+ for (const block of blocks) {
286
+ if (block.parent) block.parent.raws.semicolon = true
287
+ block.remove()
288
+ }
289
+
290
+ // A rule with a selector list is split, one rule per selector, so each instance gets its own option.
291
+ for (const rule of new Set(declarations.map(ruleOf))) {
292
+ if (rule === undefined || rule.selectors.length < 2) continue
293
+ for (const selector of [...rule.selectors].reverse()) rule.after(rule.clone({ selector }))
294
+ rule.remove()
295
+ }
296
+ declarations.length = 0
297
+ collect()
298
+
299
+ const emptied = new Set()
300
+ const inserted = new Map()
301
+ for (const declaration of declarations) {
302
+ const rule = /** @type {import('postcss').Rule} */ (ruleOf(declaration))
303
+ const [selector] = resolve(rule, rule.selector)
304
+ const result = /** @type {Exclude<ReturnType<typeof lookup>, string>} */ (lookup(declaration, selector))
305
+ if (result === null) continue
306
+ const { found, options, onElement } = result
307
+ if (onElement) {
308
+ declaration.replaceWith(expansion(declaration, options))
309
+ } else {
310
+ emptied.add(declaration.parent)
311
+ inlineRule(declaration, selector, found, options, inserted)
312
+ declaration.remove()
313
+ }
314
+ }
315
+
316
+ // Drop the rules and at-rules the inline moves left empty, up to the outermost.
317
+ for (let nodes = [...emptied]; nodes.length; ) {
318
+ nodes = nodes.flatMap(node => {
319
+ if (node.nodes.length || node.type === 'root') return []
320
+ const parent = node.parent
321
+ node.remove()
322
+ return parent ? [parent] : []
323
+ })
324
+ }
325
+ },
326
+ }
@@ -0,0 +1,98 @@
1
+ # Custom enums resolve at build time
2
+
3
+ A component's manifest can declare a custom enum: a var whose value is one of a few options, each of
4
+ which applies its own set of vars. Menu's `labelAlign`, for example:
5
+
6
+ | option | `--labelPadding_start` | `--labelPadding_end` |
7
+ |---|---|---|
8
+ | `start` (default) | `var(--labelPadding)` | `20` |
9
+ | `center` | `0` | `0` |
10
+ | `end` | `20` | `var(--labelPadding)` |
11
+
12
+ A site sets only the option, on the instance:
13
+
14
+ ```css
15
+ #comp-1 { --labelAlign: center; }
16
+ @media (max-width: 750px) {
17
+ #comp-1 { --labelAlign: end; --labelPadding: 12px; }
18
+ }
19
+ ```
20
+
21
+ and this plugin writes the option's vars in its place:
22
+
23
+ ```css
24
+ #comp-1 { --labelPadding_start: 0; --labelPadding_end: 0; --labelAlign: center; }
25
+ @media (max-width: 750px) {
26
+ #comp-1 { --labelPadding_start: 20; --labelPadding_end: var(--labelPadding); --labelAlign: end; --labelPadding: 12px; }
27
+ }
28
+ ```
29
+
30
+ An instance that sets nothing gets the default option from the wrapper's `custom-enums.css`, which
31
+ `defaults.css` imports.
32
+
33
+ ## Why not in the browser
34
+
35
+ Native `if()` could pick the option from the key (`--labelPadding_end: if(style(--labelAlign: end): …)`),
36
+ but Firefox doesn't support it, and an option may apply a var named like its own key (`inputAlign: end`
37
+ applies `--inputAlign: right`). That var's `if()` would read itself, which is a cycle, so the var becomes
38
+ invalid in every browser.
39
+
40
+ ## Where the options come from
41
+
42
+ The component's wrapper ships them in its `custom-enums.css` (generated by component-wrapper-server), next to
43
+ the defaults:
44
+
45
+ ```css
46
+ .menu {
47
+ --labelPadding_start: var(--labelPadding);
48
+ --labelPadding_end: 20;
49
+ --labelAlign: start;
50
+
51
+ @custom-enum --labelAlign {
52
+ default: start;
53
+ @option start { --labelPadding_start: var(--labelPadding); --labelPadding_end: 20; }
54
+ @option center { --labelPadding_start: 0; --labelPadding_end: 0; }
55
+ @option end { --labelPadding_start: 20; --labelPadding_end: var(--labelPadding); }
56
+ }
57
+ }
58
+ ```
59
+
60
+ The page's generated stylesheet `@import`s each used wrapper's `defaults.css`, which imports this file, and
61
+ the bundler inlines it, so the blocks are in the document the plugin gets. The plugin reads them and removes
62
+ them from the output. Without the plugin, browsers ignore the unknown at-rule and the defaults still apply.
63
+ An inline element's block sits in `{editor selector} {inline selector}` (`.menu .menu-input`).
64
+
65
+ The item's structure file (next to the stylesheet, or one folder up for `_generated/`) says which
66
+ component each `id` is: the element `<Menu id="comp-1" />` and its `@origami-wrappers/…` import. The
67
+ import resolves to the wrapper's folder, and the blocks inlined from that folder are its enums. The plugin
68
+ declares the structure file, so editing it re-runs the stylesheet.
69
+
70
+ Each option is written as the full set of the enum's vars: the default's value wherever the option itself is
71
+ silent, or `inherit` when the default is silent too (what an instance that picks no option gets). So a mobile
72
+ option replaces all of a desktop option's vars, not just the ones it names.
73
+
74
+ ## The rules
75
+
76
+ - Set the enum on the instance's own `#id` or `.id`, or on its inline element's selector under it
77
+ (`#comp-1 .menu-input`), which is how exports write it. Classes and pseudo-classes after either are kept
78
+ (`#comp-1:hover`); a combinator other than a space (`#comp-1 > .menu-input`) or a pseudo-element
79
+ (`#comp-1::before`) is an error. A selector list is split into one rule per instance.
80
+ - On an inline element, the key is that element's enum, so two elements can share a key. A key on a descendant
81
+ with no such enum, such as an element nested in an inline element (`#comp-1 .navbar .dropdown`), is an
82
+ ordinary var, left as authored.
83
+ - The vars get the specificity of the rule the enum was set in, like any var the site sets there. An
84
+ active preset (`.w-{id} .menu`) outranks `.comp-1`, so its values win over the option; `#comp-1`
85
+ outranks the preset.
86
+ - A var on an instance whose component has no enum of that name is an ordinary var, left as authored.
87
+ - The vars are written where the enum was, so a var the site sets later in the rule wins.
88
+ - An inline element's enum set on its selector is written in place. Set on the instance itself, it gets a rule of its own,
89
+ `#comp-1 .menu-input:not(#comp-1 .menu .menu-input)`, under the same `@media`, in source order. The `:not()` keeps
90
+ it off the inline element of another instance nested inside this one.
91
+ - The value must be one of the options, written literally. Anything else fails the build with every
92
+ violation in one error, and the stylesheet is left as it was.
93
+ - A stylesheet with no structure file, or whose components have no custom enums, is left as authored.
94
+ - Declarations inlined from `node_modules` are the wrapper's own and are skipped: its enum defaults on
95
+ `.menu`, and the presets that set enum keys.
96
+ - A `@custom-enum` the plugin can't read (no `default:` among its options) is a warning, not an error: it
97
+ comes from the wrapper package, which the site can't fix. Its key is left as authored, so the instance
98
+ gets the wrapper's default.
@@ -3,16 +3,18 @@
3
3
  // list through `createSiteViteConfig({ cssPlugins })`.
4
4
  //
5
5
  // A descriptor needs a `name`, an `order`, and `transform(root, context)`, where `root` is the PostCSS Root for the
6
- // stylesheet; see `../transform/types.js` for the contract and `../transform/css/postcssPlugin.js` for what the
7
- // stage guarantees a plugin sees. The order matters once: mixin bodies are copied into their calling rules before
8
- // layout routes declarations to wrappers and SPX converts calls using the browser's active settings. stack-children
9
- // only checks, and runs between mixins and layout: it needs the mixins' declarations and the stack rules as authored,
10
- // before layout compiles them away. presets runs after mixins too, so a `--preset` a mixin body carries is moved
11
- // like an authored one. fonts appends the `@font-face` rules that load the Wix fonts the stylesheet names from Wix's
12
- // CDN.
6
+ // stylesheet; see `../transform/types.js` for the contract and `../transform/css/postcssPlugin.js` for what the stage
7
+ // guarantees a plugin sees. The order matters once: mixin bodies are copied into their calling rules before layout
8
+ // routes declarations to wrappers and SPX converts calls using the browser's active settings. stack-children only
9
+ // checks, and runs between mixins and layout: it needs the mixins' declarations and the stack rules as authored, before
10
+ // layout compiles them away. presets runs after mixins too, so a `--preset` a mixin body carries is moved like an
11
+ // authored one. custom-enums runs after mixins as well, so an enum a mixin body sets is resolved, and before presets
12
+ // and layout move declarations out of the instance's rule. fonts appends the `@font-face` rules that load the Wix fonts
13
+ // the stylesheet names from Wix's CDN.
13
14
  //
14
15
  // `@layer` needs no plugin: cascade layers have been in every browser since March 2022.
15
16
 
17
+ import { customEnumsPlugin } from './customEnums.js'
16
18
  import { customMediaPlugin } from './customMedia.js'
17
19
  import { fontsPlugin } from './fonts.js'
18
20
  import { mixinsPlugin } from './mixins.js'
@@ -26,6 +28,7 @@ export const cssPlugins = Object.freeze([
26
28
  customMediaPlugin,
27
29
  fontsPlugin,
28
30
  mixinsPlugin,
31
+ customEnumsPlugin,
29
32
  stackChildrenPlugin,
30
33
  presetsPlugin,
31
34
  layoutPlugin,
@@ -3,18 +3,14 @@
3
3
  //
4
4
  // A stack is a parent whose rule declares `--container-layout-type: StackContainerLayout`; its
5
5
  // `flex-direction` says which way it stacks (column → vertical, anything else → horizontal, as in
6
- // CSS). Parent and child are the JSX tree's, so the tree comes from the item's `structure.tsx` (or `.jsx`): next
7
- // to the stylesheet, or one folder up when the stylesheet is the generated one in `_generated/`. Elements and
8
- // rules meet on `id` and `#id`. The stylesheet is the page's, with its
6
+ // CSS). Parent and child are the JSX tree's, so the tree comes from the item's structure file (see
7
+ // structureFile.js). The stylesheet is the page's, with its
9
8
  // carriers already inlined (see ../transform/css/postcssPlugin.js), so every rule is in view.
10
9
 
11
- import { existsSync, readFileSync } from 'node:fs'
12
- import { basename, dirname, join } from 'node:path'
13
- import { parseSync } from '@babel/core'
10
+ import { basename } from 'node:path'
14
11
  import { LayoutError } from '../transform/layoutError.js'
12
+ import { literalIdOf, parseStructure, structureFileOf, walk } from './structureFile.js'
15
13
 
16
- const STRUCTURE_FILES = ['structure.tsx', 'structure.jsx']
17
- const GENERATED = '_generated'
18
14
  const STACK_CONTAINER = 'StackContainerLayout'
19
15
  const BASE = ''
20
16
 
@@ -56,46 +52,21 @@ const lengthOf = token => {
56
52
  return UNIT.exec(token)?.[1].toLowerCase()
57
53
  }
58
54
 
59
- /** The folder holding the structure file a stylesheet is checked against: its own, or its item's for `_generated/`. */
60
- const structureFolderOf = stylesheetPath => {
61
- const folder = dirname(stylesheetPath)
62
- return basename(folder) === GENERATED ? dirname(folder) : folder
63
- }
64
-
65
55
  /** Child id → its direct parent's id, for elements with a literal `id`. Slot props are not children. */
66
56
  const parentsIn = structurePath => {
67
- const ast = parseSync(readFileSync(structurePath, 'utf8'), {
68
- babelrc: false,
69
- configFile: false,
70
- filename: structurePath,
71
- parserOpts: { plugins: structurePath.endsWith('.tsx') ? ['jsx', 'typescript'] : ['jsx'] },
72
- })
73
57
  const parents = new Map()
74
- const idOf = element => {
75
- const attribute = element.openingElement.attributes.find(
76
- node => node.type === 'JSXAttribute' && node.name.name === 'id' && node.value?.type === 'StringLiteral'
77
- )
78
- return attribute?.value.value
79
- }
80
58
  const directChildren = node =>
81
59
  node.children.flatMap(child =>
82
60
  child.type === 'JSXElement' ? [child] : child.type === 'JSXFragment' ? directChildren(child) : []
83
61
  )
84
- const visit = node => {
85
- if (Array.isArray(node)) return node.forEach(visit)
86
- if (!node || typeof node.type !== 'string') return
87
- if (node.type === 'JSXElement') {
88
- const parent = idOf(node)
89
- for (const child of directChildren(node)) {
90
- const id = idOf(child)
91
- if (parent !== undefined && id !== undefined) parents.set(id, parent)
92
- }
93
- }
94
- for (const [key, value] of Object.entries(node)) {
95
- if (key !== 'loc' && value && typeof value === 'object') visit(value)
62
+ walk(parseStructure(structurePath), node => {
63
+ if (node.type !== 'JSXElement') return
64
+ const parent = literalIdOf(node.openingElement)
65
+ for (const child of directChildren(node)) {
66
+ const id = literalIdOf(child.openingElement)
67
+ if (parent !== undefined && id !== undefined) parents.set(id, parent)
96
68
  }
97
- }
98
- visit(ast?.program)
69
+ })
99
70
  return parents
100
71
  }
101
72
 
@@ -158,9 +129,7 @@ export const stackChildrenPlugin = {
158
129
  // After mixins copy their bodies in, before spx rewrites --spx() calls away.
159
130
  order: 12,
160
131
  transform(root, context) {
161
- const structurePath = STRUCTURE_FILES.map(file => join(structureFolderOf(context.filePath), file)).find(
162
- existsSync
163
- )
132
+ const structurePath = structureFileOf(context.filePath)
164
133
  if (structurePath === undefined) return
165
134
  context.addDependency?.(structurePath)
166
135
  const parents = parentsIn(structurePath)
@@ -0,0 +1,43 @@
1
+ // The item's structure file, for CSS plugins that check a stylesheet against its JSX tree: `structure.tsx` (or
2
+ // `.jsx`) next to the stylesheet, or one folder up when the stylesheet is the generated one in `_generated/`.
3
+ // Elements and rules meet on a literal `id` and `#id`.
4
+
5
+ import { existsSync, readFileSync } from 'node:fs'
6
+ import { basename, dirname, join } from 'node:path'
7
+ import { parseSync } from '@babel/core'
8
+
9
+ const STRUCTURE_FILES = ['structure.tsx', 'structure.jsx']
10
+ const GENERATED = '_generated'
11
+
12
+ /** The structure file `stylesheetPath` is checked against, or undefined when its item has none. */
13
+ export const structureFileOf = stylesheetPath => {
14
+ const folder = dirname(stylesheetPath)
15
+ const item = basename(folder) === GENERATED ? dirname(folder) : folder
16
+ return STRUCTURE_FILES.map(file => join(item, file)).find(existsSync)
17
+ }
18
+
19
+ /** The structure file's program. */
20
+ export const parseStructure = structurePath =>
21
+ parseSync(readFileSync(structurePath, 'utf8'), {
22
+ babelrc: false,
23
+ configFile: false,
24
+ filename: structurePath,
25
+ parserOpts: { plugins: structurePath.endsWith('.tsx') ? ['jsx', 'typescript'] : ['jsx'] },
26
+ })?.program
27
+
28
+ /** Calls `visit` on every node under `node`, parents first. */
29
+ export const walk = (node, visit) => {
30
+ if (Array.isArray(node)) return node.forEach(child => walk(child, visit))
31
+ if (!node || typeof node.type !== 'string') return
32
+ visit(node)
33
+ for (const [key, value] of Object.entries(node)) {
34
+ if (key !== 'loc' && value && typeof value === 'object') walk(value, visit)
35
+ }
36
+ }
37
+
38
+ /** A JSX opening element's `id`, when it is a string literal. */
39
+ export const literalIdOf = openingElement =>
40
+ openingElement.attributes.find(
41
+ attribute =>
42
+ attribute.type === 'JSXAttribute' && attribute.name.name === 'id' && attribute.value?.type === 'StringLiteral'
43
+ )?.value.value