@wix/origami-site-runtime 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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-react-runtime/layout-contract` entry point. Root sizing, item placement, visibility
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
- - `yarn workspace @wix/origami-site-runtime test`: parser/compiler, Vite and packed tests.
74
- - `yarn workspace @wix/origami-site-runtime test:browser`: actual component/snapshot in Chromium,
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.1",
3
+ "version": "1.0.2",
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-react-runtime": "1.1.0",
22
+ "@wix/origami-runtime": "0.8.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": "e47c7dc0872fc8f72c857c2eb238c0fd694ab3713e3eb68247abf768"
67
+ "falconPackageHash": "3fcbcf21abe9563da3da6312bc4bfe3f95fc4f7e93d351370dca2193"
68
68
  }
@@ -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-react-runtime/layout-contract'
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-react-runtime/layout-contract'
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
+ }
@@ -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
- export const createCssPostcssPlugin = ({ selectPlugins, contextFor }) => ({
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 context = contextFor(from)
30
- if (!isSiteOwned(context.relativePath)) return
36
+ const fileContext = contextFor(from)
37
+ if (!isSiteOwned(fileContext.relativePath)) return
31
38
 
32
- const plugins = selectPlugins(context)
33
- if (plugins.length) runCssPlugins({ plugins, root, context })
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
@@ -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: TransformContext) => void,
35
- * transform: (root: import('postcss').Root, context: TransformContext) => void
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
  },