@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.
- package/dist/cssPlugins/customEnums.d.ts +2 -0
- package/dist/cssPlugins/structureFile.d.ts +4 -0
- package/package.json +2 -2
- package/src/cssPlugins/customEnums.js +326 -0
- package/src/cssPlugins/customEnums.md +98 -0
- package/src/cssPlugins/index.js +10 -7
- package/src/cssPlugins/stackChildren.js +12 -43
- package/src/cssPlugins/structureFile.js +43 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wix/origami-transforms",
|
|
3
|
-
"version": "0.1.
|
|
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": "
|
|
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.
|
package/src/cssPlugins/index.js
CHANGED
|
@@ -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
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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
|
|
7
|
-
//
|
|
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 {
|
|
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
|
-
|
|
85
|
-
if (
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
|
|
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 =
|
|
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
|