@wix/origami-site-runtime 1.0.1 → 1.0.3
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/README.md +3 -3
- package/package.json +3 -3
- package/src/cssPlugins/index.js +5 -2
- package/src/cssPlugins/layout/compiler.js +1 -1
- package/src/cssPlugins/layout/parser.js +1 -1
- package/src/cssPlugins/stackChildren.js +205 -0
- package/src/jsxPlugins/index.js +2 -1
- package/src/jsxPlugins/validation/checks.js +105 -0
- package/src/jsxPlugins/validation/index.js +65 -0
- package/src/jsxPlugins/validation/rules.js +24 -0
- package/src/jsxPlugins/validation/tree.js +15 -0
- package/src/transform/css/postcssPlugin.js +27 -5
- package/src/transform/css/runPlugins.js +4 -1
- package/src/transform/jsx/runPlugins.js +4 -1
- package/src/transform/layoutError.js +17 -0
- package/src/transform/pipeline.js +6 -1
- package/src/transform/types.js +8 -2
- package/src/vitePlugins/siteDocumentsPlugin.js +10 -1
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ cannot hide an illegally placed target. Diagnostics retain the original node sou
|
|
|
26
26
|
`@origami-target internal-container-content-wrapper` routes to
|
|
27
27
|
`#id > .id-overflow-wrapper > .id-container`; `internal-container-overflow-wrapper`
|
|
28
28
|
routes to `#id > .id-overflow-wrapper`. Names come from the pure
|
|
29
|
-
`@wix/origami-
|
|
29
|
+
`@wix/origami-runtime/layout-contract` entry point. Root sizing, item placement, visibility
|
|
30
30
|
and ordinary positioning remain on the ID selector. Background, border and pinned-layer
|
|
31
31
|
CSS stays in the snapshot; the maintained component preserves focus and wrapper flags. Ref support is deferred
|
|
32
32
|
(see the [runtime decision](../origami-runtime/README.md#decision-defer-ref-support-2026-09-24)).
|
|
@@ -70,8 +70,8 @@ limitations, not claims of Thunderbolt parity; they need a separate producer/run
|
|
|
70
70
|
|
|
71
71
|
## Verification
|
|
72
72
|
|
|
73
|
-
- `
|
|
74
|
-
- `
|
|
73
|
+
- `pnpm --filter @wix/origami-site-runtime run test`: parser/compiler, Vite and packed tests.
|
|
74
|
+
- `pnpm --filter @wix/origami-site-runtime run test:browser`: actual component/snapshot in Chromium,
|
|
75
75
|
responsive SPX and grid/flex, nested fractional fixed values, max widths/alignment, overflow,
|
|
76
76
|
visibility, focus/pointer mechanics, HMR and production build. Install Playwright Chromium or
|
|
77
77
|
set `CHROME_CHANNEL=chrome` to use installed Chrome.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wix/origami-site-runtime",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "Vite development and build helpers for wix-import static sites",
|
|
5
5
|
"main": "./src/index.js",
|
|
6
6
|
"files": [
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"@babel/types": "^7.25.2",
|
|
20
20
|
"@wix/builder-error-pages": "^1.1.6",
|
|
21
21
|
"@wix/builder-piler-components": "^1.6.7",
|
|
22
|
-
"@wix/origami-
|
|
22
|
+
"@wix/origami-runtime": "0.9.0",
|
|
23
23
|
"postcss": "^8.5.3",
|
|
24
24
|
"postcss-custom-media": "^12.0.1",
|
|
25
25
|
"react": "^18.3.1",
|
|
@@ -64,5 +64,5 @@
|
|
|
64
64
|
"./default-error-404": "./src/default-error-404.jsx"
|
|
65
65
|
},
|
|
66
66
|
"type": "module",
|
|
67
|
-
"falconPackageHash": "
|
|
67
|
+
"falconPackageHash": "85b492ce3a05b09e7ef072ed40132bd25eb8316f3c6e7549596f550d"
|
|
68
68
|
}
|
package/src/cssPlugins/index.js
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
// A descriptor needs a `name`, an `order`, and `transform(root, context)`, where `root` is the PostCSS Root for the
|
|
6
6
|
// stylesheet; see `../transform/types.js` for the contract and `../transform/css/postcssPlugin.js` for what the
|
|
7
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.
|
|
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.
|
|
9
11
|
//
|
|
10
12
|
// `@layer` needs no plugin: cascade layers have been in every browser since March 2022.
|
|
11
13
|
|
|
@@ -13,6 +15,7 @@ import { customMediaPlugin } from './customMedia.js'
|
|
|
13
15
|
import { mixinsPlugin } from './mixins.js'
|
|
14
16
|
import { layoutPlugin } from './layout/index.js'
|
|
15
17
|
import { spxPlugin } from './spx/index.js'
|
|
18
|
+
import { stackChildrenPlugin } from './stackChildren.js'
|
|
16
19
|
|
|
17
20
|
/** @type {readonly import('../transform/types.js').CssTransformPlugin[]} */
|
|
18
|
-
export const cssPlugins = Object.freeze([customMediaPlugin, mixinsPlugin, layoutPlugin, spxPlugin])
|
|
21
|
+
export const cssPlugins = Object.freeze([customMediaPlugin, mixinsPlugin, stackChildrenPlugin, layoutPlugin, spxPlugin])
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import postcss from 'postcss'
|
|
2
|
-
import { buildContainerClasses } from '@wix/origami-
|
|
2
|
+
import { buildContainerClasses } from '@wix/origami-runtime/layout-contract'
|
|
3
3
|
import { CONTENT } from './contract.js'
|
|
4
4
|
import { isLayoutSource, parseTargets } from './parser.js'
|
|
5
5
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { basename } from 'node:path'
|
|
2
|
-
import { isValidContainerId } from '@wix/origami-
|
|
2
|
+
import { isValidContainerId } from '@wix/origami-runtime/layout-contract'
|
|
3
3
|
import { relativeToSite } from '../../routing/siteLayout.js'
|
|
4
4
|
import { CONTENT, OVERFLOW, targets, displays } from './contract.js'
|
|
5
5
|
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// Checks the CSS of a stack's direct children (rule S2-STACK-001). It changes nothing: every violation in the
|
|
2
|
+
// stylesheet is collected, and the build fails once with all of them.
|
|
3
|
+
//
|
|
4
|
+
// A stack is a parent whose rule declares `--container-layout-type: StackContainerLayout`; its
|
|
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
|
|
9
|
+
// carriers already inlined (see ../transform/css/postcssPlugin.js), so every rule is in view.
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
12
|
+
import { basename, dirname, join } from 'node:path'
|
|
13
|
+
import { parseSync } from '@babel/core'
|
|
14
|
+
import { LayoutError } from '../transform/layoutError.js'
|
|
15
|
+
|
|
16
|
+
const STRUCTURE_FILES = ['structure.tsx', 'structure.jsx']
|
|
17
|
+
const GENERATED = '_generated'
|
|
18
|
+
const STACK_CONTAINER = 'StackContainerLayout'
|
|
19
|
+
const BASE = ''
|
|
20
|
+
|
|
21
|
+
// What a stack child may write, per direction. `0` is allowed wherever a length is.
|
|
22
|
+
const ALLOWED = {
|
|
23
|
+
vertical: { width: ['spx', 'px', 'vw', 'vh', '%', 'calc'], margin: ['spx', 'px', 'vw', 'vh', '%'] },
|
|
24
|
+
horizontal: { width: ['spx', 'px', 'vw', 'vh', 'calc'], margin: ['spx', 'px', 'vw', 'vh'] },
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const UNIT = /^-?(?:\d+\.?\d*|\.\d+)(px|vw|vh|%)$/i
|
|
28
|
+
const LABELS = { spx: '--spx()', px: 'px', vw: 'vw', vh: 'vh', '%': '%', calc: 'calc()' }
|
|
29
|
+
|
|
30
|
+
/** `width` → 'width', `margin`/`margin-*` → 'margin', anything else → undefined. */
|
|
31
|
+
const kindOf = prop =>
|
|
32
|
+
prop === 'width' ? 'width' : prop === 'margin' || prop.startsWith('margin-') ? 'margin' : undefined
|
|
33
|
+
|
|
34
|
+
/** A value split on top-level whitespace, so `calc(1px + 2%)` stays one token. */
|
|
35
|
+
const tokens = value => {
|
|
36
|
+
const result = []
|
|
37
|
+
let depth = 0
|
|
38
|
+
let current = ''
|
|
39
|
+
for (const char of value.replace(/!important\s*$/i, '').trim()) {
|
|
40
|
+
if (char === '(') depth++
|
|
41
|
+
else if (char === ')') depth--
|
|
42
|
+
if (depth === 0 && /\s/.test(char)) {
|
|
43
|
+
if (current) result.push(current)
|
|
44
|
+
current = ''
|
|
45
|
+
} else current += char
|
|
46
|
+
}
|
|
47
|
+
if (current) result.push(current)
|
|
48
|
+
return result
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The kind of length a token is: 'spx', 'px', 'vw', 'vh', '%', 'calc', 'zero', or undefined. */
|
|
52
|
+
const lengthOf = token => {
|
|
53
|
+
if (/^--spx\(.*\)$/.test(token)) return 'spx'
|
|
54
|
+
if (/^calc\(.*\)$/i.test(token)) return 'calc'
|
|
55
|
+
if (/^-?0*\.?0+$/.test(token)) return 'zero'
|
|
56
|
+
return UNIT.exec(token)?.[1].toLowerCase()
|
|
57
|
+
}
|
|
58
|
+
|
|
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
|
+
/** Child id → its direct parent's id, for elements with a literal `id`. Slot props are not children. */
|
|
66
|
+
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
|
+
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
|
+
const directChildren = node =>
|
|
81
|
+
node.children.flatMap(child =>
|
|
82
|
+
child.type === 'JSXElement' ? [child] : child.type === 'JSXFragment' ? directChildren(child) : []
|
|
83
|
+
)
|
|
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)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
visit(ast?.program)
|
|
99
|
+
return parents
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The at-rule conditions a rule sits under, e.g. `media (max-width: 750px)`, or BASE. */
|
|
103
|
+
const conditionOf = rule => {
|
|
104
|
+
const conditions = []
|
|
105
|
+
for (let node = rule.parent; node && node.type !== 'root'; node = node.parent) {
|
|
106
|
+
if (node.type === 'atrule') conditions.unshift(`${node.name} ${node.params}`)
|
|
107
|
+
}
|
|
108
|
+
return conditions.join(' / ')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Id → condition → the declarations that rules selecting exactly `#id` make there, in source order. */
|
|
112
|
+
const declarationsById = root => {
|
|
113
|
+
const byId = new Map()
|
|
114
|
+
root.walkRules(rule => {
|
|
115
|
+
const ids = rule.selectors.map(selector => /^#([\w-]+)$/.exec(selector.trim())?.[1]).filter(Boolean)
|
|
116
|
+
if (!ids.length) return
|
|
117
|
+
const condition = conditionOf(rule)
|
|
118
|
+
for (const id of ids) {
|
|
119
|
+
const byCondition = byId.get(id) ?? new Map()
|
|
120
|
+
byId.set(id, byCondition)
|
|
121
|
+
const declarations = byCondition.get(condition) ?? []
|
|
122
|
+
byCondition.set(condition, declarations)
|
|
123
|
+
rule.each(node => {
|
|
124
|
+
if (node.type === 'decl') declarations.push(node)
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
return byId
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The declarations in effect under `condition`: the base ones, then the condition's own. */
|
|
132
|
+
const effective = (byCondition, condition) => {
|
|
133
|
+
const result = new Map()
|
|
134
|
+
for (const key of condition === BASE ? [BASE] : [BASE, condition]) {
|
|
135
|
+
for (const declaration of byCondition?.get(key) ?? []) result.set(declaration.prop, declaration)
|
|
136
|
+
}
|
|
137
|
+
return result
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 'vertical' or 'horizontal' when these declarations make a stack, else undefined. */
|
|
141
|
+
const directionOf = declarations => {
|
|
142
|
+
if (declarations.get('--container-layout-type')?.value.trim() !== STACK_CONTAINER) return undefined
|
|
143
|
+
return /^column/.test(declarations.get('flex-direction')?.value.trim() ?? '') ? 'vertical' : 'horizontal'
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** ` (layout.css:12)`: the carrier the declaration was written in, which the bundler doesn't name. */
|
|
147
|
+
const where = declaration => {
|
|
148
|
+
const file = declaration.source?.input.file
|
|
149
|
+
const line = declaration.source?.start?.line
|
|
150
|
+
return file ? ` (${basename(file)}${line ? `:${line}` : ''})` : ''
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const or = items => (items.length === 1 ? items[0] : `${items.slice(0, -1).join(', ')} or ${items.at(-1)}`)
|
|
154
|
+
|
|
155
|
+
/** @type {import('../transform/types.js').CssTransformPlugin} */
|
|
156
|
+
export const stackChildrenPlugin = {
|
|
157
|
+
name: 'stack-children',
|
|
158
|
+
// After mixins copy their bodies in, before spx rewrites --spx() calls away.
|
|
159
|
+
order: 12,
|
|
160
|
+
transform(root, context) {
|
|
161
|
+
const structurePath = STRUCTURE_FILES.map(file => join(structureFolderOf(context.filePath), file)).find(
|
|
162
|
+
existsSync
|
|
163
|
+
)
|
|
164
|
+
if (structurePath === undefined) return
|
|
165
|
+
context.addDependency?.(structurePath)
|
|
166
|
+
const parents = parentsIn(structurePath)
|
|
167
|
+
if (parents.size === 0) return
|
|
168
|
+
const byId = declarationsById(root)
|
|
169
|
+
|
|
170
|
+
const violations = new Set()
|
|
171
|
+
let first
|
|
172
|
+
for (const [child, parent] of parents) {
|
|
173
|
+
const childRules = byId.get(child)
|
|
174
|
+
const parentRules = byId.get(parent)
|
|
175
|
+
if (childRules === undefined || parentRules === undefined) continue
|
|
176
|
+
for (const condition of new Set([BASE, ...parentRules.keys(), ...childRules.keys()])) {
|
|
177
|
+
const direction = directionOf(effective(parentRules, condition))
|
|
178
|
+
if (direction === undefined) continue
|
|
179
|
+
for (const declaration of effective(childRules, condition).values()) {
|
|
180
|
+
const kind = kindOf(declaration.prop)
|
|
181
|
+
if (kind === undefined) continue
|
|
182
|
+
const allowed = ALLOWED[direction][kind]
|
|
183
|
+
const bad = tokens(declaration.value).filter(token => {
|
|
184
|
+
const length = lengthOf(token)
|
|
185
|
+
return length === undefined || (length !== 'zero' && !allowed.includes(length))
|
|
186
|
+
})
|
|
187
|
+
if (!bad.length) continue
|
|
188
|
+
first ??= declaration.source
|
|
189
|
+
violations.add(
|
|
190
|
+
`#${child}${where(declaration)}, a ${direction} stack child: ${declaration.prop} must be ` +
|
|
191
|
+
`${or(allowed.map(unit => LABELS[unit]))}; found ${bad.join(' ')}.`
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (violations.size) {
|
|
198
|
+
throw new LayoutError([...violations], {
|
|
199
|
+
file: first?.input.file,
|
|
200
|
+
line: first?.start?.line,
|
|
201
|
+
column: first?.start?.column,
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
}
|
package/src/jsxPlugins/index.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// `program` is the Babel Program path for the module; see `../transform/types.js`.
|
|
4
4
|
|
|
5
5
|
import { origamiCompatibilityPlugin } from './origamiCompatibility/index.js'
|
|
6
|
+
import { layoutValidationPlugin } from './validation/index.js'
|
|
6
7
|
|
|
7
8
|
/** @type {readonly import('../transform/types.js').JsxTransformPlugin[]} */
|
|
8
|
-
export const jsxPlugins = Object.freeze([origamiCompatibilityPlugin])
|
|
9
|
+
export const jsxPlugins = Object.freeze([layoutValidationPlugin, origamiCompatibilityPlugin])
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// The checks behind the constraints in ./rules.js. Each gets the element a rule applies to and
|
|
2
|
+
// reports what it found; the message states the rule's whole constraint and how to fix it, so one
|
|
3
|
+
// error is enough to write the correct structure.
|
|
4
|
+
//
|
|
5
|
+
// To add a kind of constraint: add a function to CHECKS, use its key in rules.js, and extend
|
|
6
|
+
// expectation() so messages state it.
|
|
7
|
+
|
|
8
|
+
import { describe } from './tree.js'
|
|
9
|
+
|
|
10
|
+
const CHILD_CONSTRAINTS = ['allowedChildren', 'minChildren', 'maxChildren']
|
|
11
|
+
|
|
12
|
+
const list = names => {
|
|
13
|
+
const items = names.map(name => `<${name}>`)
|
|
14
|
+
return items.length === 1 ? items[0] : `${items.slice(0, -1).join(', ')} or ${items.at(-1)}`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// "must have exactly one <InternalContainer> child"
|
|
18
|
+
const childrenExpectation = ({ allowedChildren, minChildren: min, maxChildren: max }) => {
|
|
19
|
+
const count =
|
|
20
|
+
min !== undefined && min === max
|
|
21
|
+
? `exactly ${min === 1 ? 'one' : min}`
|
|
22
|
+
: min !== undefined && max !== undefined
|
|
23
|
+
? `between ${min} and ${max}`
|
|
24
|
+
: min !== undefined
|
|
25
|
+
? `at least ${min}`
|
|
26
|
+
: max !== undefined
|
|
27
|
+
? `at most ${max}`
|
|
28
|
+
: 'only'
|
|
29
|
+
const kind = allowedChildren ? list(allowedChildren) : 'JSX element'
|
|
30
|
+
const noun = min === 1 && max === 1 ? 'child' : 'children'
|
|
31
|
+
return `must have ${count} ${kind} ${noun}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const expectation = rule => {
|
|
35
|
+
const parts = []
|
|
36
|
+
if (rule.allowedChildren || rule.minChildren !== undefined || rule.maxChildren !== undefined) {
|
|
37
|
+
parts.push(childrenExpectation(rule))
|
|
38
|
+
}
|
|
39
|
+
return parts.join(', and ')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const at = node => (node.loc ? ` (line ${node.loc.start.line})` : '')
|
|
43
|
+
|
|
44
|
+
/** A constrained parent's children must be literal JSX tags. */
|
|
45
|
+
const checkLiteral = ({ node, fail }) => {
|
|
46
|
+
const unseen = what => fail(`${what}; write each child as a JSX tag`)
|
|
47
|
+
for (const attribute of node.openingElement.attributes) {
|
|
48
|
+
if (attribute.type === 'JSXSpreadAttribute') unseen(`spread props${at(attribute)}`)
|
|
49
|
+
else if (attribute.name.name === 'children') unseen(`a children= prop${at(attribute)}`)
|
|
50
|
+
}
|
|
51
|
+
for (const child of node.children) {
|
|
52
|
+
if (child.type === 'JSXExpressionContainer' && child.expression.type !== 'JSXEmptyExpression') {
|
|
53
|
+
unseen(`a {…} expression${at(child)}`)
|
|
54
|
+
} else if (child.type === 'JSXSpreadChild') unseen(`a {...spread} child${at(child)}`)
|
|
55
|
+
else if (child.type === 'JSXFragment') unseen(`a fragment${at(child)}`)
|
|
56
|
+
else if (child.type === 'JSXText' && child.value.trim()) unseen(`text${at(child)}`)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const CHECKS = {
|
|
61
|
+
allowedChildren: (allowed, { elements, fail }) => {
|
|
62
|
+
for (const { element, component } of elements) {
|
|
63
|
+
if (!allowed.includes(component)) fail(`${describe(element)}`)
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
minChildren: (min, { elements, fail }) => {
|
|
67
|
+
if (elements.length < min) fail(elements.length === 0 ? 'none' : `${elements.length}`)
|
|
68
|
+
},
|
|
69
|
+
maxChildren: (max, { elements, fail }) => {
|
|
70
|
+
if (elements.length > max) fail(`${elements.length}`)
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Fail fast on a typo in rules.js.
|
|
75
|
+
export const assertKnownConstraints = rules => {
|
|
76
|
+
for (const rule of rules) {
|
|
77
|
+
for (const key of Object.keys(rule)) {
|
|
78
|
+
if (key !== 'id' && key !== 'components' && !CHECKS[key]) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`layout rules: unknown constraint "${key}" on ${rule.id}. Known: ${Object.keys(CHECKS).join(', ')}`
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const constrainsChildren = rule => CHILD_CONSTRAINTS.some(key => key in rule)
|
|
88
|
+
|
|
89
|
+
/** Runs every constraint of `rule` on one element; `report` receives each full message. */
|
|
90
|
+
export const checkRule = (rule, { node, componentOf, report }) => {
|
|
91
|
+
const prefix = `${describe(node)} ${expectation(rule)}`
|
|
92
|
+
const fail = got => report(`${prefix}; found ${got}.`)
|
|
93
|
+
|
|
94
|
+
const elements = node.children
|
|
95
|
+
.filter(child => child.type === 'JSXElement')
|
|
96
|
+
.map(element => ({ element, component: componentOf(element) }))
|
|
97
|
+
const context = { node, elements, fail }
|
|
98
|
+
|
|
99
|
+
if (constrainsChildren(rule)) checkLiteral(context)
|
|
100
|
+
for (const [key, value] of Object.entries(rule)) CHECKS[key]?.(value, context)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** `createElement(Section, …)` would hide a constrained component's children from the checks. */
|
|
104
|
+
export const checkCreateElement = (rule, { node, component, report }) =>
|
|
105
|
+
report(`<${component}>${at(node)} ${expectation(rule)}; found createElement(${component}), write it as a JSX tag.`)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Checks authored structures against the Studio 2 site rulebook layout rules. It changes
|
|
2
|
+
// nothing: every violation in the file is collected, and the build fails once with all of them.
|
|
3
|
+
//
|
|
4
|
+
// One walk serves every rule. Rules are data (./rules.js); each names the components it looks at,
|
|
5
|
+
// identified for now by JSX tag name as written (<Section>, <InternalContainer>), whatever they are
|
|
6
|
+
// imported from, and the checks (./checks.js) run on each matching element.
|
|
7
|
+
|
|
8
|
+
import { basename } from 'node:path'
|
|
9
|
+
import { LayoutError } from '../../transform/layoutError.js'
|
|
10
|
+
import { assertKnownConstraints, checkCreateElement, checkRule, constrainsChildren } from './checks.js'
|
|
11
|
+
import { RULES } from './rules.js'
|
|
12
|
+
|
|
13
|
+
const STRUCTURE_FILES = new Set(['structure.tsx', 'structure.jsx'])
|
|
14
|
+
|
|
15
|
+
assertKnownConstraints(RULES)
|
|
16
|
+
|
|
17
|
+
/** Component (tag) name -> the rules that check it. */
|
|
18
|
+
const RULES_BY_COMPONENT = new Map(
|
|
19
|
+
RULES.flatMap(rule => rule.components).map(component => [
|
|
20
|
+
component,
|
|
21
|
+
RULES.filter(rule => rule.components.includes(component)),
|
|
22
|
+
])
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
const isCreateElement = callee =>
|
|
26
|
+
(callee.type === 'Identifier' && callee.name === 'createElement') ||
|
|
27
|
+
(callee.type === 'MemberExpression' && !callee.computed && callee.property.name === 'createElement')
|
|
28
|
+
|
|
29
|
+
/** @type {import('../../transform/types.js').JsxTransformPlugin} */
|
|
30
|
+
export const layoutValidationPlugin = {
|
|
31
|
+
name: 'wix-import-layout-validation',
|
|
32
|
+
// Before any plugin that rewrites the tree, so the rules see it as authored.
|
|
33
|
+
order: -100,
|
|
34
|
+
appliesTo: context => STRUCTURE_FILES.has(basename(context.filePath)),
|
|
35
|
+
|
|
36
|
+
transform: program => {
|
|
37
|
+
// The component an element is: its tag name, as written.
|
|
38
|
+
const componentOf = node => {
|
|
39
|
+
const name = node.openingElement.name
|
|
40
|
+
return name.type === 'JSXIdentifier' ? name.name : undefined
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const violations = []
|
|
44
|
+
const report = message => violations.push(message)
|
|
45
|
+
program.traverse({
|
|
46
|
+
JSXElement(path) {
|
|
47
|
+
const component = componentOf(path.node)
|
|
48
|
+
const rules = component === undefined ? undefined : RULES_BY_COMPONENT.get(component)
|
|
49
|
+
if (rules === undefined) return
|
|
50
|
+
for (const rule of rules) checkRule(rule, { node: path.node, componentOf, report })
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
CallExpression({ node }) {
|
|
54
|
+
const [type] = node.arguments
|
|
55
|
+
if (!isCreateElement(node.callee) || type?.type !== 'Identifier') return
|
|
56
|
+
const component = type.name
|
|
57
|
+
for (const rule of RULES_BY_COMPONENT.get(component) ?? []) {
|
|
58
|
+
if (constrainsChildren(rule)) checkCreateElement(rule, { node, component, report })
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
if (violations.length) throw new LayoutError(violations)
|
|
64
|
+
},
|
|
65
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// The Studio 2 site rulebook rules (rev 1.40) this build checks, as data.
|
|
2
|
+
// A rule lives here unless TypeScript can enforce all of it; a rule is never split between the two.
|
|
3
|
+
//
|
|
4
|
+
// A rule names the components it applies to, by JSX tag name, and the constraints they must meet. Each
|
|
5
|
+
// constraint is a check in ./checks.js, keyed by its name:
|
|
6
|
+
// allowedChildren: string[] — the only components allowed as direct children
|
|
7
|
+
// minChildren: number — minimum number of direct child elements
|
|
8
|
+
// maxChildren: number — maximum number of direct child elements
|
|
9
|
+
//
|
|
10
|
+
// A component whose children a rule constrains must be written as literal JSX with literal child
|
|
11
|
+
// tags: {expressions}, fragments, text, spread props, a children= prop and createElement() are
|
|
12
|
+
// errors, so the check always sees the real structure.
|
|
13
|
+
|
|
14
|
+
const STANDARD_SECTIONS = ['Section', 'HeaderSection', 'FooterSection']
|
|
15
|
+
|
|
16
|
+
export const RULES = [
|
|
17
|
+
{
|
|
18
|
+
id: 'S2-SECTION-001',
|
|
19
|
+
components: STANDARD_SECTIONS,
|
|
20
|
+
allowedChildren: ['InternalContainer'],
|
|
21
|
+
minChildren: 1,
|
|
22
|
+
maxChildren: 1,
|
|
23
|
+
},
|
|
24
|
+
]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Reads over the authored JSX tree, shared by the layout rules.
|
|
2
|
+
|
|
3
|
+
/** `<Section id="x">` at line 12 → `<Section id="x"> (line 12)`, for error messages. */
|
|
4
|
+
export const describe = node => {
|
|
5
|
+
const opening = node.openingElement
|
|
6
|
+
const tag = opening.name.type === 'JSXIdentifier' ? opening.name.name : 'element'
|
|
7
|
+
const id = opening.attributes.find(
|
|
8
|
+
attribute =>
|
|
9
|
+
attribute.type === 'JSXAttribute' &&
|
|
10
|
+
attribute.name.name === 'id' &&
|
|
11
|
+
attribute.value?.type === 'StringLiteral'
|
|
12
|
+
)
|
|
13
|
+
const line = node.loc ? ` (line ${node.loc.start.line})` : ''
|
|
14
|
+
return `<${tag}${id ? ` id="${id.value.value}"` : ''}>${line}`
|
|
15
|
+
}
|
|
@@ -19,17 +19,39 @@ const isSiteOwned = relativePath =>
|
|
|
19
19
|
!relativePath.startsWith(DEPENDENCY_DIRECTORY) &&
|
|
20
20
|
!relativePath.includes(`/${DEPENDENCY_DIRECTORY}`)
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
/**
|
|
23
|
+
* @param {{
|
|
24
|
+
* selectPlugins: (context: import('../types.js').TransformContext) => readonly object[],
|
|
25
|
+
* contextFor: (id: string) => import('../types.js').TransformContext,
|
|
26
|
+
* failures?: Map<string, Set<string>>,
|
|
27
|
+
* }} options `failures` maps each stylesheet whose last run failed to the files it declared before failing.
|
|
28
|
+
*/
|
|
29
|
+
export const createCssPostcssPlugin = ({ selectPlugins, contextFor, failures }) => ({
|
|
23
30
|
postcssPlugin: 'wix-import-css-transform',
|
|
24
31
|
|
|
25
32
|
Once(root, { result }) {
|
|
26
33
|
const from = result.opts.from
|
|
27
34
|
if (from === undefined) return
|
|
28
35
|
|
|
29
|
-
const
|
|
30
|
-
if (!isSiteOwned(
|
|
36
|
+
const fileContext = contextFor(from)
|
|
37
|
+
if (!isSiteOwned(fileContext.relativePath)) return
|
|
31
38
|
|
|
32
|
-
const plugins = selectPlugins(
|
|
33
|
-
if (plugins.length)
|
|
39
|
+
const plugins = selectPlugins(fileContext)
|
|
40
|
+
if (!plugins.length) return
|
|
41
|
+
// A plugin that reads another file declares it, so the bundler re-runs this stylesheet when that file
|
|
42
|
+
// changes. Vite reads these messages only from a run that succeeds, so a failed run's declarations are
|
|
43
|
+
// kept in `failures` instead; see vitePlugins/siteDocumentsPlugin.js.
|
|
44
|
+
const declared = new Set()
|
|
45
|
+
const addDependency = file => {
|
|
46
|
+
declared.add(file)
|
|
47
|
+
result.messages.push({ type: 'dependency', plugin: 'wix-import-css-transform', file, parent: from })
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
runCssPlugins({ plugins, root, context: { ...fileContext, addDependency } })
|
|
51
|
+
failures?.delete(from)
|
|
52
|
+
} catch (error) {
|
|
53
|
+
failures?.set(from, declared)
|
|
54
|
+
throw error
|
|
55
|
+
}
|
|
34
56
|
},
|
|
35
57
|
})
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Runs the selected CSS plugins over one shared Root, in order. Parsing, stringifying and source
|
|
2
2
|
// maps belong to PostCSS, which drives this stage — the only thing added here is attributing a
|
|
3
|
-
// failure to the plugin and the file that caused it.
|
|
3
|
+
// failure to the plugin and the file that caused it. A LayoutError is the author's, so it passes through.
|
|
4
|
+
|
|
5
|
+
import { LayoutError } from '../layoutError.js'
|
|
4
6
|
|
|
5
7
|
const reason = cause => (cause instanceof Error ? cause.message : String(cause))
|
|
6
8
|
|
|
@@ -10,6 +12,7 @@ export const runCssPlugins = ({ plugins, root, context }) => {
|
|
|
10
12
|
try {
|
|
11
13
|
plugin[phase]?.(root, context)
|
|
12
14
|
} catch (cause) {
|
|
15
|
+
if (cause instanceof LayoutError) throw cause
|
|
13
16
|
// Tooling such as Vite's error overlay prints only the message, so the reason goes in it as well. A
|
|
14
17
|
// plugin may throw any value, so it is read without assuming an Error.
|
|
15
18
|
throw new Error(
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Runs the selected JSX plugins over one shared Program path, in order. Parsing and printing belong
|
|
2
2
|
// to Babel, which drives this stage — the only thing added here is attributing a failure to the
|
|
3
|
-
// plugin and the file that caused it.
|
|
3
|
+
// plugin and the file that caused it. A LayoutError is the author's, so it passes through.
|
|
4
|
+
|
|
5
|
+
import { LayoutError } from '../layoutError.js'
|
|
4
6
|
|
|
5
7
|
const reason = cause => (cause instanceof Error ? cause.message : String(cause))
|
|
6
8
|
|
|
@@ -9,6 +11,7 @@ export const runJsxPlugins = ({ plugins, program, context }) => {
|
|
|
9
11
|
try {
|
|
10
12
|
plugin.transform(program, context)
|
|
11
13
|
} catch (cause) {
|
|
14
|
+
if (cause instanceof LayoutError) throw cause
|
|
12
15
|
// Tooling such as Vite's error overlay prints only the message, so the reason goes in it as well. A
|
|
13
16
|
// plugin may throw any value, so it is read without assuming an Error.
|
|
14
17
|
throw new Error(
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// A broken layout rule is the author's to fix, not a plugin crash. The stages pass it through as is,
|
|
2
|
+
// without naming the plugin, and it carries no stack: the bundler already names the file, and the
|
|
3
|
+
// messages say where in it.
|
|
4
|
+
|
|
5
|
+
export class LayoutError extends Error {
|
|
6
|
+
/**
|
|
7
|
+
* @param {readonly string[]} violations
|
|
8
|
+
* @param {{ file?: string, line?: number, column?: number }} [location] The first violation's, which the
|
|
9
|
+
* bundler prints as the error's position (Vite's CSS stage reads `file`, `line` and `column`).
|
|
10
|
+
*/
|
|
11
|
+
constructor(violations, location = {}) {
|
|
12
|
+
super(violations.length === 1 ? violations[0] : `\n ${violations.join('\n ')}`)
|
|
13
|
+
this.name = 'LayoutError'
|
|
14
|
+
this.stack = ''
|
|
15
|
+
Object.assign(this, location)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -42,11 +42,16 @@ export const createTransformPipeline = ({ layout, jsxPlugins = defaultJsxPlugins
|
|
|
42
42
|
const currentState = () => (state ??= createState({ layout, registries, command }))
|
|
43
43
|
const contextFor = (id, options) => currentState().contextFactory(id, options)
|
|
44
44
|
const selectFor = stage => context => currentState().selectPlugins(stage, context)
|
|
45
|
+
/** @type {Map<string, Set<string>>} stylesheet whose last CSS run failed -> the files it declared */
|
|
46
|
+
const cssFailures = new Map()
|
|
45
47
|
|
|
46
48
|
return {
|
|
47
|
-
postcssPlugin: createCssPostcssPlugin({ contextFor, selectPlugins: selectFor('css') }),
|
|
49
|
+
postcssPlugin: createCssPostcssPlugin({ contextFor, selectPlugins: selectFor('css'), failures: cssFailures }),
|
|
48
50
|
babelOptionsFor: createBabelOptionsSelector({ contextFor, selectPlugins: selectFor('jsx') }),
|
|
49
51
|
|
|
52
|
+
/** Whether a stylesheet whose last CSS run failed declared `file`, so an edit to it may fix the stylesheet. */
|
|
53
|
+
failedStylesheetDependsOn: file => [...cssFailures.values()].some(files => files.has(file)),
|
|
54
|
+
|
|
50
55
|
setCommand: value => {
|
|
51
56
|
command = value
|
|
52
57
|
state = undefined
|
package/src/transform/types.js
CHANGED
|
@@ -31,9 +31,15 @@
|
|
|
31
31
|
* and applied before any of them run.
|
|
32
32
|
*
|
|
33
33
|
* @typedef {TransformPluginBase & {
|
|
34
|
-
* validate?: (root: import('postcss').Root, context:
|
|
35
|
-
* transform: (root: import('postcss').Root, context:
|
|
34
|
+
* validate?: (root: import('postcss').Root, context: CssTransformContext) => void,
|
|
35
|
+
* transform: (root: import('postcss').Root, context: CssTransformContext) => void
|
|
36
36
|
* }} CssTransformPlugin
|
|
37
|
+
*
|
|
38
|
+
* @typedef {TransformContext & {
|
|
39
|
+
* addDependency?: (file: string) => void
|
|
40
|
+
* }} CssTransformContext
|
|
41
|
+
* `addDependency` declares a file the plugin read besides the stylesheet, so the bundler re-runs the
|
|
42
|
+
* stylesheet when it changes. Absent when no bundler drives the run.
|
|
37
43
|
*/
|
|
38
44
|
|
|
39
45
|
export {}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// The `wix-import-site` Vite plugin: serves route-matched documents in development, synthesizes one
|
|
2
|
-
// virtual HTML entry per render target for the build
|
|
2
|
+
// virtual HTML entry per render target for the build, and reloads the page when a file a failing stylesheet read
|
|
3
|
+
// changes.
|
|
3
4
|
|
|
4
5
|
import { readFileSync } from 'node:fs'
|
|
6
|
+
import { resolve } from 'node:path'
|
|
5
7
|
import { renderPageDocument } from '../routing/documentHtml.js'
|
|
6
8
|
import { createDocumentMiddleware } from './documentMiddleware.js'
|
|
7
9
|
|
|
@@ -23,6 +25,13 @@ export const siteDocumentsPlugin = ({ layout, documents, pipeline }) => ({
|
|
|
23
25
|
)
|
|
24
26
|
},
|
|
25
27
|
|
|
28
|
+
// A file a CSS plugin read, like the structure file the stack check reads, is declared as a dependency of the
|
|
29
|
+
// stylesheet, so Vite hot-updates the stylesheet when it changes. Vite drops the declarations of a run that
|
|
30
|
+
// fails, though, so a stylesheet would keep its error after the file was fixed: the page is reloaded instead.
|
|
31
|
+
handleHotUpdate({ file, server }) {
|
|
32
|
+
if (pipeline.failedStylesheetDependsOn(resolve(file))) server.ws.send({ type: 'full-reload', path: '*' })
|
|
33
|
+
},
|
|
34
|
+
|
|
26
35
|
resolveId(id) {
|
|
27
36
|
return documents.has(id) ? id : undefined
|
|
28
37
|
},
|