@wix/origami-transforms 0.1.9 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,2 +1,3 @@
1
+ export function removeCustomEnums(root: any): void;
1
2
  /** @type {import('../transform/types.js').CssTransformPlugin} */
2
3
  export const customEnumsPlugin: import("../transform/types.js").CssTransformPlugin;
@@ -0,0 +1,7 @@
1
+ /** component-wrapper-server's `PACKAGE_SCOPE` (`src/wrapper/packageTarball.ts`). */
2
+ export const WRAPPERS_SCOPE: "@origami-wrappers";
3
+ export function wrapperPackageName(module: string): string;
4
+ export function wrapperImport({ module, symbol }: {
5
+ readonly module: string;
6
+ readonly symbol: string;
7
+ }): string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wix/origami-transforms",
3
- "version": "0.1.9",
3
+ "version": "0.1.12",
4
4
  "description": "Babel and PostCSS checks and transforms for Wix origami page files.",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -50,9 +50,13 @@
50
50
  ".": {
51
51
  "types": "./dist/index.d.ts",
52
52
  "default": "./src/index.js"
53
+ },
54
+ "./wrapper-import": {
55
+ "types": "./dist/wrapperImport.d.ts",
56
+ "default": "./src/wrapperImport.js"
53
57
  }
54
58
  },
55
59
  "type": "module",
56
60
  "types": "./dist/index.d.ts",
57
- "falconPackageHash": "3225c4d857f7c4821fc78ea579b9a416bd05abd58f4bf0b0ac7906c0"
61
+ "falconPackageHash": "5339c279d9f83f77637d6772dcd519d52c576f898a610ca35dcdd0df"
58
62
  }
@@ -1,25 +1,63 @@
1
1
  // Resolves a component's custom enums at build time. A site sets only the enum's value on an instance,
2
2
  // `#comp-1 { --labelAlign: center }`, and this writes the vars that option applies in its place, from the
3
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.
4
+ // `customEnums.ts`), read from the wrapper's folder: the item's JSX imports the wrapper's CSS, so the blocks aren't in
5
+ // the stylesheet. A stylesheet that `@import`s the wrapper's CSS has them inlined; those are used and removed. A ref
6
+ // element's enums are its type's: the wrapper names the type with `@custom-enum-ref`, and they come from that type's
7
+ // wrapper.
5
8
  // Native `if()` can't do it: Firefox doesn't have it, and a var named like its key refers to itself.
6
9
  //
7
10
  // The instance's component comes from the item's structure file (structureFile.js): the element with
8
11
  // `id="comp-1"`, and the `@origami-wrappers/…` import it is rendered from. See customEnums.md.
9
12
 
10
- import { realpathSync } from 'node:fs'
13
+ import { existsSync, readFileSync, realpathSync } from 'node:fs'
11
14
  import { createRequire } from 'node:module'
12
- import { basename, dirname } from 'node:path'
15
+ import { basename, dirname, join } from 'node:path'
13
16
  import postcss from 'postcss'
14
17
  import { LayoutError } from '../transform/layoutError.js'
18
+ import { WRAPPERS_SCOPE, wrapperImport } from '../wrapperImport.js'
15
19
  import { literalIdOf, parseStructure, structureFileOf, walk } from './structureFile.js'
16
20
 
21
+ const CUSTOM_ENUMS_CSS = 'custom-enums.css'
17
22
  // `@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
+ const WRAPPER_IMPORT = new RegExp(`^${WRAPPERS_SCOPE}/[\\w-]+/[\\w-]+$`)
24
+ // A component type, `wixEditorElements.Button`: its module and symbol.
25
+ const COMPONENT_TYPE = /^([\w-]+)\.([\w-]+)$/
26
+ /** The selector's compounds, split at its combinators, outside parentheses and brackets. */
27
+ const compoundsOf = selector => {
28
+ const compounds = ['']
29
+ let depth = 0
30
+ for (const char of selector.trim()) {
31
+ if (char === '(' || char === '[') depth++
32
+ if (char === ')' || char === ']') depth--
33
+ if (depth === 0 && /[\s>+~]/.test(char)) {
34
+ if (compounds.at(-1) !== '') compounds.push('')
35
+ } else compounds[compounds.length - 1] += char
36
+ }
37
+ return compounds.filter(Boolean)
38
+ }
39
+
40
+ /** A compound's own ids and classes, `#comp-1` and `.button`, not those in its `:not(…)` or attributes. */
41
+ const namesOf = compound => {
42
+ let own = compound.replace(/\[[^\]]*\]/g, '')
43
+ while (/\([^()]*\)/.test(own)) own = own.replace(/\([^()]*\)/g, '')
44
+ return own.match(/[.#][\w-]+/g) ?? []
45
+ }
46
+
47
+ /**
48
+ * The last of `compounds` that `element`'s compounds match, each a descendant of the one before, or -1: an element
49
+ * matches a compound that has all its names, `.button.hamburger-open-button` matching `.button.hamburger-open-button.x`.
50
+ */
51
+ const lastMatch = (element, compounds) => {
52
+ const covers = (names, wanted) => wanted.every(name => names.includes(name))
53
+ for (let end = compounds.length - 1; end >= 0; end--) {
54
+ if (!covers(compounds[end], element.at(-1))) continue
55
+ let matched = 0
56
+ for (let at = 0; at < end && matched < element.length - 1; at++) if (covers(compounds[at], element[matched])) matched++
57
+ if (matched === element.length - 1) return end
58
+ }
59
+ return -1
60
+ }
23
61
 
24
62
  /** Id → the wrapper import it is rendered from, for elements with a literal `id` and an imported wrapper tag. */
25
63
  const importsById = structurePath => {
@@ -48,21 +86,27 @@ const real = path => {
48
86
  }
49
87
  }
50
88
 
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) => {
89
+ /** The folder an `@origami-wrappers/…` import resolves to from the structure file, or undefined when it doesn't. */
90
+ const wrapperFolders = structurePath => {
56
91
  const require = createRequire(structurePath)
57
- const byId = new Map()
58
- for (const [id, source] of importsById(structurePath)) {
59
- let folder
92
+ return source => {
60
93
  try {
61
- folder = real(dirname(require.resolve(source)))
94
+ return real(dirname(require.resolve(source)))
62
95
  } catch {
63
- folder = undefined
96
+ return undefined
64
97
  }
65
- byId.set(id, (folder !== undefined && byFolder.get(folder)) || [])
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Id → the enums of every wrapper instance; none for one whose component has no custom enums. `folderOf` gives an
103
+ * import's wrapper folder, and `enumsOf` a wrapper folder's enums.
104
+ */
105
+ const enumsById = (structurePath, folderOf, enumsOf) => {
106
+ const byId = new Map()
107
+ for (const [id, source] of importsById(structurePath)) {
108
+ const folder = folderOf(source)
109
+ byId.set(id, folder === undefined ? [] : enumsOf(folder))
66
110
  }
67
111
  return byId
68
112
  }
@@ -88,17 +132,39 @@ const resolve = (rule, selector) => {
88
132
  const fromDependency = declaration => declaration.source?.input.file?.split(/[\\/]/).includes('node_modules') ?? false
89
133
 
90
134
  /**
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.
135
+ * The `@custom-enum` blocks and `@custom-enum-ref` declarations in the stylesheet: wrapper folder → its enums, with
136
+ * each option as the enum's full var set, and its ref elements; the at-rules themselves; and the ones that can't be
137
+ * read.
93
138
  *
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.
139
+ * A block sits in its element's rule, `.menu`, or `.menu .dropdown` for an inline element at any depth. A var an
140
+ * option doesn't set has the default option's value, or `inherit` when the default doesn't set it either, which is
141
+ * what an instance that picks no option gets. An option that doesn't set its own key still says which option it is.
142
+ * A ref declaration sits in its element's rule, `.menu .button.hamburger-open-button`, and names the element's type.
97
143
  */
98
144
  const definitionsIn = root => {
99
145
  const byFolder = new Map()
100
146
  const blocks = []
101
147
  const malformed = []
148
+ const definitionOf = file => {
149
+ const folder = real(dirname(file))
150
+ if (!byFolder.has(folder)) byFolder.set(folder, { enums: [], refs: [] })
151
+ return byFolder.get(folder)
152
+ }
153
+ root.walkAtRules('custom-enum-ref', declaration => {
154
+ blocks.push(declaration)
155
+ const type = COMPONENT_TYPE.exec(declaration.params.trim())
156
+ const file = declaration.source?.input.file
157
+ const rule = declaration.parent?.type === 'rule' ? /** @type {import('postcss').Rule} */ (declaration.parent) : undefined
158
+ const [, ...element] = rule ? resolve(rule, rule.selector)[0].split(/\s+/) : []
159
+ if (type === null || file === undefined || element.length === 0) {
160
+ malformed.push(declaration)
161
+ return
162
+ }
163
+ definitionOf(file).refs.push({
164
+ selector: element.join(' '),
165
+ source: wrapperImport({ module: type[1], symbol: type[2] }),
166
+ })
167
+ })
102
168
  root.walkAtRules('custom-enum', block => {
103
169
  blocks.push(block)
104
170
  const keyVar = block.params.trim()
@@ -123,7 +189,7 @@ const definitionsIn = root => {
123
189
  }
124
190
 
125
191
  const [selector] = resolve(rule, rule.selector)
126
- const [editorSelector, ...inline] = selector.split(/\s+/)
192
+ const [, ...inline] = selector.split(/\s+/)
127
193
  const defaults = applied.get(defaultValue)
128
194
  const vars = [...new Set([...applied.values()].flatMap(values => Object.keys(values)))]
129
195
  const options = Object.fromEntries(
@@ -132,13 +198,9 @@ const definitionsIn = root => {
132
198
  Object.fromEntries(vars.map(name => [name, values[name] ?? defaults[name] ?? 'inherit'])),
133
199
  ])
134
200
  )
135
- const folder = real(dirname(file))
136
- const enums = byFolder.get(folder) ?? []
137
- byFolder.set(folder, enums)
138
- enums.push({
201
+ definitionOf(file).enums.push({
139
202
  key: keyVar,
140
203
  options,
141
- editorSelector,
142
204
  // An inline element's selector inside the editor element's, undefined for the editor element itself.
143
205
  inline: inline.length ? inline.join(' ') : undefined,
144
206
  })
@@ -167,25 +229,17 @@ const expansion = (declaration, values) =>
167
229
  )
168
230
 
169
231
  /**
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.
232
+ * Removes the stylesheet's `@custom-enum` blocks and `@custom-enum-ref` declarations, which only this plugin reads,
233
+ * and a rule that held nothing else.
173
234
  */
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)
235
+ export const removeCustomEnums = root => {
236
+ const emptied = new Set()
237
+ root.walkAtRules(/^custom-enum(?:-ref)?$/, node => {
238
+ if (node.parent) node.parent.raws.semicolon = true
239
+ emptied.add(node.parent)
240
+ node.remove()
241
+ })
242
+ for (const rule of emptied) if (rule?.type === 'rule' && rule.nodes.length === 0) rule.remove()
189
243
  }
190
244
 
191
245
  /** @type {import('../transform/types.js').CssTransformPlugin} */
@@ -194,11 +248,47 @@ export const customEnumsPlugin = {
194
248
  // After mixins copy their bodies in, before presets and layout move declarations out of the instance's rule.
195
249
  order: 11,
196
250
  transform(root, context) {
197
- const { byFolder, blocks, malformed } = definitionsIn(root)
198
- if (blocks.length === 0) return
251
+ const inlined = definitionsIn(root)
199
252
  const structurePath = structureFileOf(context.filePath)
253
+ if (structurePath === undefined && inlined.blocks.length === 0) return
200
254
  if (structurePath !== undefined) context.addDependency?.(structurePath)
201
- const instances = structurePath === undefined ? new Map() : enumsById(structurePath, byFolder)
255
+ const malformed = [...inlined.malformed]
256
+ // A wrapper's definitions are the ones inlined from its folder, or else those in the folder's custom-enums.css.
257
+ const readFolders = new Map()
258
+ const definitionOf = folder => {
259
+ if (inlined.byFolder.has(folder)) return inlined.byFolder.get(folder)
260
+ if (!readFolders.has(folder)) {
261
+ const file = join(folder, CUSTOM_ENUMS_CSS)
262
+ let definition = { enums: [], refs: [] }
263
+ if (existsSync(file)) {
264
+ context.addDependency?.(file)
265
+ const definitions = definitionsIn(postcss.parse(readFileSync(file, 'utf8'), { from: file }))
266
+ malformed.push(...definitions.malformed)
267
+ definition = definitions.byFolder.get(folder) ?? definition
268
+ }
269
+ readFolders.set(folder, definition)
270
+ }
271
+ return readFolders.get(folder)
272
+ }
273
+ const folderOf = structurePath === undefined ? () => undefined : wrapperFolders(structurePath)
274
+ // A wrapper's enums, and those of each ref element's type as if the ref were their inline element:
275
+ // `.button.hamburger-open-button` gets the Button's `.button` enums, and `.button.hamburger-open-button .label`
276
+ // its `.button .label` ones. `seen` stops a type that refers back to itself.
277
+ const enumsOf = (folder, seen = new Set()) => {
278
+ if (seen.has(folder)) return []
279
+ const { enums, refs } = definitionOf(folder)
280
+ const refEnums = refs.flatMap(({ selector, source }) => {
281
+ const refFolder = folderOf(source)
282
+ return refFolder === undefined
283
+ ? []
284
+ : enumsOf(refFolder, new Set([...seen, folder])).map(item => ({
285
+ ...item,
286
+ inline: item.inline === undefined ? selector : `${selector} ${item.inline}`,
287
+ }))
288
+ })
289
+ return [...enums, ...refEnums]
290
+ }
291
+ const instances = structurePath === undefined ? new Map() : enumsById(structurePath, folderOf, enumsOf)
202
292
  const keys = new Set([...instances.values()].flatMap(enums => enums.map(({ key }) => key)))
203
293
 
204
294
  /** @type {import('postcss').Declaration[]} */
@@ -210,39 +300,45 @@ export const customEnumsPlugin = {
210
300
  collect()
211
301
 
212
302
  /**
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.
303
+ * The vars `declaration` resolves to on the element `selector` is nearest to, the reason it can't, or null when
304
+ * that element has no such enum, or `selector` is in no instance, so the var is an ordinary one.
216
305
  */
217
306
  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
- )
307
+ const compounds = compoundsOf(selector).map(namesOf)
308
+ const isInstance = name => instances.has(name.slice(1))
309
+ // The last instance in the selector, `#comp-1` or `.comp-1`; what's before it, such as a variant's class,
310
+ // doesn't matter.
311
+ let at = compounds.length - 1
312
+ while (at >= 0 && !compounds[at].some(isInstance)) at--
313
+ if (at === -1) return null
314
+ const enums = instances.get(compounds[at].filter(isInstance).at(-1).slice(1))
315
+ const after = compounds.slice(at + 1)
316
+ // The element nearest the selector's end: the inline or ref element matched last, its longer selector on a
317
+ // tie, or the editor element when none is. Its enums alone resolve, so a key on a variant of the element
318
+ // (`#comp-1 .dropdown .variant-1`) resolves by it, and two elements can share a key.
319
+ let nearest
320
+ let end = -1
321
+ let length = 0
322
+ for (const inline of new Set(enums.flatMap(({ inline }) => (inline === undefined ? [] : [inline])))) {
323
+ const element = compoundsOf(inline).map(namesOf)
324
+ const found = lastMatch(element, after)
325
+ if (found > end || (found !== -1 && found === end && element.length > length)) {
326
+ nearest = inline
327
+ end = found
328
+ length = element.length
329
+ }
226
330
  }
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
- )
331
+ const matching = enums.filter(item => item.key === declaration.prop && item.inline === nearest)
236
332
  if (matching.length === 0) return null
237
333
  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.`
334
+ if (matching.length > 1) return `the element has custom enum ${key} more than once, so it can't say which.`
239
335
  const [found] = matching
240
336
  const value = declaration.value.trim()
241
337
  const allowed = Object.keys(found.options).join(', ')
242
338
  if (/\bvar\(/.test(value)) return `a custom enum's value must be literal, one of ${allowed}.`
243
339
  const options = found.options[value]
244
340
  if (options === undefined) return `"${value}" is not an option of ${key}; use one of ${allowed}.`
245
- return { found, options, onElement: descendants !== '' || found.inline === undefined }
341
+ return options
246
342
  }
247
343
 
248
344
  // A block comes from the wrapper package, which the site can't fix: its enum is skipped, so an instance that
@@ -250,8 +346,12 @@ export const customEnumsPlugin = {
250
346
  for (const block of malformed) {
251
347
  context.warn?.(
252
348
  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.`
349
+ block.name === 'custom-enum-ref'
350
+ ? `custom-enums: @custom-enum-ref ${block.params}${where(block)}: can't be read; it needs a component ` +
351
+ `type, such as wixEditorElements.Button, in an element rule under the editor element's. That ` +
352
+ `element's enums are left as authored.`
353
+ : `custom-enums: @custom-enum ${block.params}${where(block)}: can't be read; it needs a --key, a ` +
354
+ `default: that is one of its @option blocks, and an element rule around it. Its key is left as authored.`
255
355
  )
256
356
  }
257
357
 
@@ -259,16 +359,18 @@ export const customEnumsPlugin = {
259
359
  const violations = new Set()
260
360
  /** @type {any} The first violation's source, whose position the bundler prints. */
261
361
  let first
362
+ // The rules with an enum that resolves, the only ones a selector list is split in.
363
+ const resolving = new Set()
262
364
  for (const declaration of declarations) {
263
365
  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.']
366
+ const problems = (rule?.selectors ?? []).flatMap(own => {
367
+ const results = resolve(rule, own).map(selector => lookup(declaration, selector))
368
+ if (results.some(result => result !== null)) resolving.add(rule)
369
+ if (results.length > 1 && results.some(result => result !== null)) {
370
+ return ['set a custom enum on one instance rule, not under a parent selector list.']
371
+ }
372
+ return results.filter(result => typeof result === 'string')
373
+ })
272
374
  for (const problem of problems) {
273
375
  first ??= declaration.source
274
376
  violations.add(`custom-enums: ${declaration.prop}: ${declaration.value}${where(declaration)}: ${problem}`)
@@ -282,45 +384,23 @@ export const customEnumsPlugin = {
282
384
  })
283
385
  }
284
386
 
285
- for (const block of blocks) {
286
- if (block.parent) block.parent.raws.semicolon = true
287
- block.remove()
288
- }
387
+ removeCustomEnums(root)
289
388
 
290
389
  // 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
390
+ for (const rule of resolving) {
391
+ if (rule.selectors.length < 2) continue
293
392
  for (const selector of [...rule.selectors].reverse()) rule.after(rule.clone({ selector }))
294
393
  rule.remove()
295
394
  }
296
395
  declarations.length = 0
297
396
  collect()
298
397
 
299
- const emptied = new Set()
300
- const inserted = new Map()
301
398
  for (const declaration of declarations) {
302
- const rule = /** @type {import('postcss').Rule} */ (ruleOf(declaration))
399
+ const rule = ruleOf(declaration)
400
+ if (rule === undefined) continue
303
401
  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
- })
402
+ const options = /** @type {Exclude<ReturnType<typeof lookup>, string>} */ (lookup(declaration, selector))
403
+ if (options !== null) declaration.replaceWith(expansion(declaration, options))
324
404
  }
325
405
  },
326
406
  }
@@ -57,15 +57,41 @@ the defaults:
57
57
  }
58
58
  ```
59
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`).
60
+ The item's JSX imports each used wrapper's `defaults.css`, which imports this file, so the browser loads
61
+ it ahead of the site's stylesheets, and the blocks are not in the document the plugin gets. The plugin reads
62
+ them from the wrapper's folder instead. The CSS stage removes them, and the ref declarations, from the wrapper's own CSS, which runs no
63
+ other plugin; browsers would ignore the unknown at-rule anyway, and the defaults still apply. A stylesheet
64
+ that `@import`s `defaults.css` has the blocks inlined: the plugin reads those and removes them from the output.
65
+ An inline element's block sits in `{editor selector} {inline selector}` (`.menu .menu-input`), flat at any depth: the
66
+ Menu's dropdown, which sits in its navbar, is `.menu .dropdown`, as exports address it (`.comp-1 .dropdown`).
67
+
68
+ ## Ref elements
69
+
70
+ A ref element is another component inside this one, such as the Menu's hamburger buttons, which are Buttons. Its
71
+ enums are its type's, so the wrapper doesn't copy them; it names the type in the ref's rule:
72
+
73
+ ```css
74
+ .menu .button.hamburger-open-button {
75
+ @custom-enum-ref wixEditorElements.Button;
76
+ }
77
+ ```
78
+
79
+ The plugin resolves the type's wrapper from the structure file, through `wrapperImport` (`src/wrapperImport.js`), the
80
+ same name origami-site-generator imports wrappers by (`@origami-wrappers/wixEditorElements/button`), and reads that wrapper's `custom-enums.css`. The type's own enums
81
+ (`.button`) apply on the ref's selector, and its inline elements' (`.button .label`) on the ref's selector followed by
82
+ theirs:
83
+
84
+ ```css
85
+ .comp-1 .button.hamburger-open-button { --icon-position: row-reverse; }
86
+ .comp-1 .button.hamburger-open-button .label { --label-wrap: nowrap; }
87
+ ```
88
+
89
+ A ref inside the type's wrapper is followed the same way. A type with no wrapper in the site gives the ref no enums.
64
90
 
65
91
  The item's structure file (next to the stylesheet, or one folder up for `_generated/`) says which
66
92
  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.
93
+ import resolves to the wrapper's folder, and that folder's `custom-enums.css` holds its enums. The plugin
94
+ declares the structure file and each `custom-enums.css` it reads, so editing either re-runs the stylesheet.
69
95
 
70
96
  Each option is written as the full set of the enum's vars: the default's value wherever the option itself is
71
97
  silent, or `inherit` when the default is silent too (what an instance that picks no option gets). So a mobile
@@ -73,26 +99,31 @@ option replaces all of a desktop option's vars, not just the ones it names.
73
99
 
74
100
  ## The rules
75
101
 
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.
102
+ - A key resolves by the component element nearest the selector's end, inside the selector's last instance
103
+ (`#comp-1` or `.comp-1`): the inline or ref element matched last, or the editor element when none is. What isn't
104
+ an element, such as a variant's class, a state or a combinator, doesn't change which element that is:
105
+
106
+ | selector | resolves by |
107
+ |---|---|
108
+ | `#comp-1`, `#comp-1:hover`, `#comp-1 .variant-1`, `.variant-1 #comp-1` | the Menu |
109
+ | `#comp-1 .dropdown`, `#comp-1 .navbar .dropdown .variant-1` | the dropdown |
110
+ | `#comp-1 .button.hamburger-open-button .variant-1` | the Button's enums |
111
+ | `.button.hamburger-open-button .variant-1`, `.card` | nothing: no instance |
112
+
113
+ Only that element's enums resolve, so two elements can share a key (the navbar's and the dropdown's
114
+ `items-width`). A key that element has no enum of, such as the dropdown's on the instance (`.comp-1 {
115
+ --items-width: fillMenu }`), or a selector in no instance, is an ordinary var, left as authored: not every var
116
+ with an enum's name is that enum. A selector list is split into one rule per instance.
83
117
  - The vars get the specificity of the rule the enum was set in, like any var the site sets there. An
84
118
  active preset (`.w-{id} .menu`) outranks `.comp-1`, so its values win over the option; `#comp-1`
85
119
  outranks the preset.
86
120
  - A var on an instance whose component has no enum of that name is an ordinary var, left as authored.
87
121
  - 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
122
  - The value must be one of the options, written literally. Anything else fails the build with every
92
123
  violation in one error, and the stylesheet is left as it was.
93
124
  - A stylesheet with no structure file, or whose components have no custom enums, is left as authored.
94
125
  - Declarations inlined from `node_modules` are the wrapper's own and are skipped: its enum defaults on
95
126
  `.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.
127
+ - A `@custom-enum` the plugin can't read (no `default:` among its options), or a `@custom-enum-ref` (no
128
+ `module.Component` type, or no element rule around it), is a warning, not an error: it comes from the wrapper
129
+ package, which the site can't fix. Its keys are left as authored, so the instance gets the wrapper's default.
@@ -1,5 +1,5 @@
1
1
  // Fails the build on an Image whose `uri` is not a Wix media ID, such as a web URL or a local file.
2
- // The Wix import accepts such a `uri` but the image is broken on the site; `origami upload-media`
2
+ // The Wix import accepts such a `uri` but the image is broken on the site; `wix origami upload-media`
3
3
  // uploads it and points the Image at the uploaded file. `yarn dev` only warns, so the page still
4
4
  // previews. Like the layout rules, Image is identified by its JSX tag name, and a computed `uri` is
5
5
  // not checked.
@@ -34,7 +34,7 @@ export const mediaValidationPlugin = {
34
34
  const id = attributes.find(attribute => attribute.name.name === 'id')?.value?.value
35
35
  const line = uri.loc?.start.line
36
36
  violations.push(
37
- `${file}${line ? `:${line}` : ''} <Image${id ? ` id="${id}"` : ''}> image.uri "${uri.value.value}" is not a Wix media ID. Run \`origami upload-media\` to upload it.`
37
+ `${file}${line ? `:${line}` : ''} <Image${id ? ` id="${id}"` : ''}> image.uri "${uri.value.value}" is not a Wix media ID. Run \`wix origami upload-media\` to upload it.`
38
38
  )
39
39
  first ??= { file: context.filePath, line, column: uri.loc?.start.column }
40
40
  },
@@ -8,6 +8,7 @@
8
8
  // stylesheet the plugins see is the whole page: a rule declared in one carrier and a rule that
9
9
  // depends on it in another arrive in the same document.
10
10
 
11
+ import { removeCustomEnums } from '../../cssPlugins/customEnums.js'
11
12
  import { runCssPlugins } from './runPlugins.js'
12
13
 
13
14
  const DEPENDENCY_DIRECTORY = 'node_modules/'
@@ -34,7 +35,12 @@ export const createCssPostcssPlugin = ({ selectPlugins, contextFor, failures })
34
35
  if (from === undefined) return
35
36
 
36
37
  const fileContext = contextFor(from)
37
- if (!isSiteOwned(fileContext.relativePath)) return
38
+ // A dependency's stylesheet runs no plugins. It only loses a wrapper's `@custom-enum` blocks and `@custom-enum-ref`
39
+ // declarations, which the custom-enums plugin reads from the wrapper's folder and browsers would skip.
40
+ if (!isSiteOwned(fileContext.relativePath)) {
41
+ removeCustomEnums(root)
42
+ return
43
+ }
38
44
 
39
45
  const plugins = selectPlugins(fileContext)
40
46
  if (!plugins.length) return
@@ -0,0 +1,21 @@
1
+ // How a site imports a component's wrapper. origami-site-generator writes these imports, and the custom-enums CSS
2
+ // plugin finds a ref element's type's wrapper through the same names. Kept free of dependencies, so the generator's
3
+ // bundle doesn't take the transforms with it.
4
+
5
+ /** component-wrapper-server's `PACKAGE_SCOPE` (`src/wrapper/packageTarball.ts`). */
6
+ export const WRAPPERS_SCOPE = '@origami-wrappers'
7
+
8
+ /**
9
+ * The name the site depends on a module's wrapper package under. It keeps the module as is, so an
10
+ * import of it maps back to the module; the package's own name is lowercased, which yarn does not check.
11
+ * @param {string} module
12
+ */
13
+ export const wrapperPackageName = module => `${WRAPPERS_SCOPE}/${module}`
14
+
15
+ /**
16
+ * Where a component's wrapper is imported from: its folder in the module's package, which
17
+ * component-wrapper-server names by the symbol lowercased, so `wixEditorElements.WRichText` is
18
+ * `@origami-wrappers/wixEditorElements/wrichtext`.
19
+ * @param {{ readonly module: string, readonly symbol: string }} component
20
+ */
21
+ export const wrapperImport = ({ module, symbol }) => `${wrapperPackageName(module)}/${symbol.toLowerCase()}`