@wix/origami-site-runtime 1.0.0

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.
Files changed (39) hide show
  1. package/README.md +79 -0
  2. package/package.json +68 -0
  3. package/src/cssPlugins/customMedia.js +52 -0
  4. package/src/cssPlugins/index.js +18 -0
  5. package/src/cssPlugins/layout/README.md +77 -0
  6. package/src/cssPlugins/layout/compiler.js +116 -0
  7. package/src/cssPlugins/layout/contract.js +35 -0
  8. package/src/cssPlugins/layout/index.js +12 -0
  9. package/src/cssPlugins/layout/parser.js +81 -0
  10. package/src/cssPlugins/mixins.js +23 -0
  11. package/src/cssPlugins/spx/README.md +73 -0
  12. package/src/cssPlugins/spx/calls.js +12 -0
  13. package/src/cssPlugins/spx/cascade.js +41 -0
  14. package/src/cssPlugins/spx/index.js +13 -0
  15. package/src/default-error-404.jsx +9 -0
  16. package/src/index.js +4 -0
  17. package/src/jsxPlugins/index.js +8 -0
  18. package/src/jsxPlugins/origamiCompatibility/ast.js +30 -0
  19. package/src/jsxPlugins/origamiCompatibility/componentBindings.js +43 -0
  20. package/src/jsxPlugins/origamiCompatibility/index.js +48 -0
  21. package/src/jsxPlugins/origamiCompatibility/rules/containerProps.js +15 -0
  22. package/src/jsxPlugins/origamiCompatibility/rules/richTextHtml.js +22 -0
  23. package/src/routing/documentHtml.js +19 -0
  24. package/src/routing/pagesToRoutes.js +110 -0
  25. package/src/routing/siteLayout.js +31 -0
  26. package/src/routing/urlPath.js +62 -0
  27. package/src/siteViteConfig.js +91 -0
  28. package/src/transform/context.js +13 -0
  29. package/src/transform/css/postcssPlugin.js +35 -0
  30. package/src/transform/css/runPlugins.js +22 -0
  31. package/src/transform/jsx/babelAdapter.js +37 -0
  32. package/src/transform/jsx/runPlugins.js +20 -0
  33. package/src/transform/pipeline.js +55 -0
  34. package/src/transform/registry.js +40 -0
  35. package/src/transform/types.js +39 -0
  36. package/src/vitePlugins/documentMiddleware.js +42 -0
  37. package/src/vitePlugins/itemFolderImports.js +16 -0
  38. package/src/vitePlugins/reactBabel.js +6 -0
  39. package/src/vitePlugins/siteDocumentsPlugin.js +35 -0
package/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # Wix import site runtime
2
+
3
+ Static routes come from item folders under `src/pages`. The home page is the page folder in parentheses, e.g. `src/pages/(home)`; a folder in parentheses is left out of the URL. Each document loads its item's `_generated/index.jsx`; `pages/error404` supplies an optional custom 404 page.
4
+
5
+ ## Routing roadmap
6
+
7
+ **Popups:** They arrive through the pages API, live at `src/popups/<slug>`, and will open via `?popup=` in their own root, like piler's `site-express-export/mountPopup.js`.
8
+
9
+ **Static-dynamic pages:** A folder with `route.json` will be an app's URL prefix. Literal child folders are fixed pages under it and remain static.
10
+
11
+ **Dynamic pages:** A `[param]` child will be resolved per request. A production Node server and the dev middleware will call the router service, reusing `routeToDynamicPage` from `@wix/builder-utils`, and inject `__WIX_RUNTIME_SERVICES_CONFIGS__` before the page entry, like piler's `site-express-export/server.js.ejs`. `RouteTable` already declares these types.
12
+
13
+ ## Layout CSS
14
+
15
+ Vite serve/build compiles DM JSX era 0.42 InternalContainer semantic layout in memory.
16
+ Caller-supplied plugin lists replace the built-in ones.
17
+ The original layout.css, format version and unmapped.json remain available for import-back.
18
+ Legacy flat layout and other components are not converted into semantic layout.
19
+
20
+ The default order is custom media → mixins → semantic container → SPX. Selected plugins
21
+ may expose `validate(root, context)`; all validators run before any transform so a mixin
22
+ cannot hide an illegally placed target. Diagnostics retain the original node source.
23
+
24
+ ## Container contract
25
+
26
+ `@origami-target internal-container-content-wrapper` routes to
27
+ `#id > .id-overflow-wrapper > .id-container`; `internal-container-overflow-wrapper`
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
30
+ and ordinary positioning remain on the ID selector. Background, border and pinned-layer
31
+ CSS stays in the snapshot; the maintained component preserves focus and wrapper flags. Ref support is deferred
32
+ (see the [runtime decision](../origami-runtime/README.md#decision-defer-ref-support-2026-09-24)).
33
+
34
+ Only explicit targets in site-owned layout.css carriers opt in. Sources of inlined nodes
35
+ are checked too, including dependency CSS. Targets in style/global-style CSS, mixins,
36
+ unknown layers or malformed bodies fail with source diagnostics. CSS discovery does not
37
+ prove the component's JSX type; DM owns that eligibility check.
38
+
39
+ Content accepts DM grid/flex vocabulary (including stack and organizer), structural relative
40
+ positioning, padding, gaps and content max-width/alignment. Root max-width remains separate.
41
+ Sparse alignment and width overrides use independent variables. Overflow routes to the
42
+ outer wrapper, with explicit scroll-y setting sticky offset to zero and other explicit
43
+ values resetting it. Visibility retains layout space and ordinary descendant behavior.
44
+
45
+ Scopes, media, supports, container queries, layers and separate rules retain their order.
46
+ When an ID is also the scoping root, its selector gains `:where(:scope)` so it can match
47
+ that root without increasing specificity. Structural rules stay in their original envelope;
48
+ low-specificity defaults use a reserved first `origami-container-defaults` cascade layer,
49
+ with copies of the original conditional/scope envelopes, so defaults cannot override authored
50
+ values in later layers. Nested containers receive their own defaults.
51
+
52
+ All SPX expressions read the browser's active settings, including ordinary component styles
53
+ and semantic container wrappers. Settings-only breakpoint overrides therefore affect sizes
54
+ without repeating the size declarations. The layout compiler does not reset SPX settings;
55
+ wrappers and nested components follow normal CSS inheritance. Scale retains the signed
56
+ half-pixel floor; fixed values retain fractional pixels; zero is zero. See the
57
+ [SPX plugin README](src/cssPlugins/spx/README.md) for a simple example and implementation details.
58
+
59
+ ## Release and limitations
60
+
61
+ Use the existing snapshot 0.4.0 baseline. Publish origami-runtime 0.2.0 (including layout-contract), then this runtime
62
+ 1.1.0, before the generator/CLI that selects these minimum versions. Explicit local/file
63
+ runtime overrides remain supported. Packed tests verify the compiler and pure contract
64
+ outside the workspace without loading React or the snapshot. Coordinate DM 0.42 publication
65
+ and first-published reader loadability separately; that producer check is not established here.
66
+
67
+ `--spx-stopper-max` is preserved but does not clamp scaling. The SPX plugin does not generate its runtime clamp formula. `position: stickyToHeader` is preserved
68
+ as authored but is not a browser position value and receives no lowering. These are release
69
+ limitations, not claims of Thunderbolt parity; they need a separate producer/runtime contract.
70
+
71
+ ## Verification
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,
75
+ responsive SPX and grid/flex, nested fractional fixed values, max widths/alignment, overflow,
76
+ visibility, focus/pointer mechanics, HMR and production build. Install Playwright Chromium or
77
+ set `CHROME_CHANNEL=chrome` to use installed Chrome.
78
+ - Build, lint and test the origami runtime and site generator when changing their contract or
79
+ minimum versions. Static producer fixture provenance lives in test/fixtures/css/README.md.
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "@wix/origami-site-runtime",
3
+ "version": "1.0.0",
4
+ "description": "Vite development and build helpers for wix-import static sites",
5
+ "main": "./src/index.js",
6
+ "files": [
7
+ "src"
8
+ ],
9
+ "scripts": {
10
+ "build": "rm -rf dist tsconfig.tsbuildinfo && tsc -b .",
11
+ "lint": "oxlint .",
12
+ "test": "pnpm run build && node --test test/*.spec.js",
13
+ "clean": "rm -rf dist tsconfig.tsbuildinfo",
14
+ "test:browser": "node test/spx.browser.mjs && node test/semanticContainer.browser.mjs"
15
+ },
16
+ "dependencies": {
17
+ "@babel/core": "^7.26.0",
18
+ "@babel/parser": "^7.25.0",
19
+ "@babel/types": "^7.25.2",
20
+ "@wix/builder-error-pages": "^1.1.6",
21
+ "@wix/builder-piler-components": "^1.6.7",
22
+ "@wix/origami-react-runtime": "1.0.0",
23
+ "postcss": "^8.5.3",
24
+ "postcss-custom-media": "^12.0.1",
25
+ "react": "^18.3.1",
26
+ "react-dom": "^18.3.1"
27
+ },
28
+ "devDependencies": {
29
+ "@wix/origami-types": "1.0.0",
30
+ "@types/node": "^22.0.0",
31
+ "@vitejs/plugin-react": "^4.3.4",
32
+ "playwright": "^1.61.1",
33
+ "typescript": "^6.0.3",
34
+ "vite": "^6.0.0"
35
+ },
36
+ "peerDependencies": {
37
+ "@vitejs/plugin-react": "^4.3.4",
38
+ "vite": "^6.0.0"
39
+ },
40
+ "publishUnscoped": false,
41
+ "publishConfig": {
42
+ "registry": "https://registry.npmjs.org/",
43
+ "access": "public"
44
+ },
45
+ "wix": {
46
+ "artifact": {
47
+ "groupId": "com.wixpress.npm",
48
+ "artifactId": "origami-site-runtime-clone",
49
+ "targets": {
50
+ "docker": false
51
+ }
52
+ },
53
+ "validations": {
54
+ "source": [
55
+ "lint"
56
+ ],
57
+ "postBuild": [
58
+ "test"
59
+ ]
60
+ }
61
+ },
62
+ "exports": {
63
+ ".": "./src/index.js",
64
+ "./default-error-404": "./src/default-error-404.jsx"
65
+ },
66
+ "type": "module",
67
+ "falconPackageHash": "e4d43ee225d9fd6b2a984e43f7d601eb4882c251249a3d557ea756e0"
68
+ }
@@ -0,0 +1,52 @@
1
+ // `@custom-media --name (query);` with `@media (--name) {}` (Media Queries Level 5). No browser ships it, so the
2
+ // alias is always expanded. The expansion is postcss-custom-media's, which matches the DM output exactly; this
3
+ // descriptor only runs that plugin over the stage's shared Root, so the stage keeps its gating (site-owned files)
4
+ // and its ordering.
5
+ //
6
+ // A definition is only visible within the document that declares it. DM emits the definitions in the theme
7
+ // carrier, the generator `@import`s that into every page stylesheet, and Vite's postcss-import inlines it before
8
+ // this stage runs — so definition and usage share one Root.
9
+
10
+ import postcss from 'postcss'
11
+ import postcssCustomMedia from 'postcss-custom-media'
12
+
13
+ const processor = postcss([postcssCustomMedia()])
14
+
15
+ const CUSTOM_MEDIA_QUERY = /\(\s*--/
16
+
17
+ // Whether the document uses the feature at all: a definition to remove, or an alias to expand. Every carrier but
18
+ // the theme has neither, and this walk visits only at-rules, where the expansion walks and re-marks every node.
19
+ const usesCustomMedia = root => {
20
+ let used = false
21
+ root.walkAtRules(atRule => {
22
+ if (atRule.name === 'custom-media' || (atRule.name === 'media' && CUSTOM_MEDIA_QUERY.test(atRule.params))) {
23
+ used = true
24
+ return false
25
+ }
26
+ })
27
+ return used
28
+ }
29
+
30
+ /** @type {import('../transform/types.js').CssTransformPlugin} */
31
+ export const customMediaPlugin = {
32
+ name: 'custom-media',
33
+ order: 0,
34
+ transform(root, context) {
35
+ if (!usesCustomMedia(root)) return
36
+ // A Root handed to `process` is transformed in place. The plugin has no async hooks, so `sync` is safe.
37
+ // Its warnings go to the nested Result and are dropped: the only one it raises is a cyclic definition,
38
+ // which leaves the alias unexpanded and so fails below anyway. A definition it cannot parse at all is a
39
+ // different matter — it emits the unparsed bytes as the query and warns about nothing, which no check
40
+ // here can see. DM does not emit one.
41
+ processor.process(root, { from: context.filePath }).sync()
42
+ // An unexpanded alias is left as authored, and a browser evaluates it to "unknown", which silently
43
+ // disables the whole block.
44
+ root.walkAtRules('media', atRule => {
45
+ if (CUSTOM_MEDIA_QUERY.test(atRule.params)) {
46
+ throw new Error(
47
+ `@media ${atRule.params} has no usable @custom-media definition: it is missing or cyclic`
48
+ )
49
+ }
50
+ })
51
+ },
52
+ }
@@ -0,0 +1,18 @@
1
+ // The CSS stage's plugins, in the order they run. DM authors its CSS in features no browser implements yet, or only
2
+ // Chromium does; these compile them away so a generated site renders everywhere. A caller can still pass its own
3
+ // list through `createSiteViteConfig({ cssPlugins })`.
4
+ //
5
+ // A descriptor needs a `name`, an `order`, and `transform(root, context)`, where `root` is the PostCSS Root for the
6
+ // stylesheet; see `../transform/types.js` for the contract and `../transform/css/postcssPlugin.js` for what the
7
+ // stage guarantees a plugin sees. The order matters once: mixin bodies are copied into their calling rules before
8
+ // layout routes declarations to wrappers and SPX converts calls using the browser's active settings.
9
+ //
10
+ // `@layer` needs no plugin: cascade layers have been in every browser since March 2022.
11
+
12
+ import { customMediaPlugin } from './customMedia.js'
13
+ import { mixinsPlugin } from './mixins.js'
14
+ import { layoutPlugin } from './layout/index.js'
15
+ import { spxPlugin } from './spx/index.js'
16
+
17
+ /** @type {readonly import('../transform/types.js').CssTransformPlugin[]} */
18
+ export const cssPlugins = Object.freeze([customMediaPlugin, mixinsPlugin, layoutPlugin, spxPlugin])
@@ -0,0 +1,77 @@
1
+ # Layout puts container styles on the right elements
2
+
3
+ A container has an outer component, an overflow wrapper and a content wrapper.
4
+ The outer component controls its place on the page. The overflow wrapper controls
5
+ scrolling and clipping. The content wrapper arranges the children.
6
+
7
+ In site-owned `layout.css`, `@origami-target` says which wrapper a style belongs to:
8
+
9
+ ```css
10
+ #container {
11
+ --container-layout-type: FlexContainerLayout;
12
+
13
+ @origami-target internal-container-content-wrapper {
14
+ display: flex;
15
+ flex-direction: column;
16
+ row-gap: --spx(16);
17
+ --content-max-width: 1200px;
18
+ --content-max-width-align: center;
19
+ }
20
+
21
+ @origami-target internal-container-overflow-wrapper {
22
+ overflow-y: auto;
23
+ }
24
+ }
25
+ ```
26
+
27
+ The plugin replaces these target blocks with ordinary CSS rules that select the
28
+ matching wrappers. Grid, flex, gaps, padding and content width go on the content
29
+ wrapper; overflow goes on the overflow wrapper. Other component styles stay on
30
+ the outer element.
31
+
32
+ ## What the plugin adds
33
+
34
+ The outer element and overflow wrapper get a one-cell grid so the wrappers fill
35
+ the container. The content wrapper gets sizing and positioning styles, including
36
+ a maximum width and left, center or right alignment. It lets pointer events pass
37
+ through empty space while its children remain clickable.
38
+
39
+ Each container starts with no content width limit and centered alignment. These
40
+ defaults live in a separate CSS layer so authored settings can override them.
41
+ Media queries, scopes and other surrounding conditions still apply to generated
42
+ rules. Vertical overflow also sets `--sticky-offset` to `0px` for `scroll` and
43
+ resets it for other values.
44
+
45
+ ## Why validation runs before transformations
46
+
47
+ During development and builds, Vite first inlines CSS imports. Our CSS pipeline
48
+ then runs every plugin's optional `validate()` before running any plugin's
49
+ `transform()`. Currently, layout is the only plugin with this validation hook.
50
+ If validation fails, processing stops before transformations begin.
51
+
52
+ Layout needs this early check because targets are not allowed inside mixins.
53
+ The mixin plugin runs before layout compilation: it removes mixin definitions
54
+ and copies their contents into the rules that use them. After that, an invalid
55
+ target could look like a normal component rule, or disappear with an unused
56
+ mixin. Early validation checks the original placement before that evidence is lost.
57
+
58
+ Previously, plugins only had a transformation step and checked input as they
59
+ processed it. The target placement rules introduced the need for a separate
60
+ validation phase. Layout calls `parseTargets()` again during transformation to
61
+ check and collect the target nodes from the updated stylesheet.
62
+
63
+ ## Files and boundaries
64
+
65
+ - `index.js` registers validation and transformation.
66
+ - `contract.js` lists the supported targets, their properties and layout displays.
67
+ - `parser.js` checks target placement, properties and values, and rejects duplicates.
68
+ - `compiler.js` builds wrapper selectors, adds base styles and moves declarations.
69
+
70
+ Targets must come from site-owned `layout.css` and belong to a single component ID
71
+ rule. Unsupported targets or properties fail with an error rather than being ignored.
72
+
73
+ This plugin generates CSS; the runtime supplies the wrapper elements. SPX values
74
+ are converted afterward by the SPX plugin.
75
+
76
+ `test/semanticContainer.browser.mjs` checks container behavior in the browser.
77
+ Run it together with the SPX checks using the package's `test:browser` script.
@@ -0,0 +1,116 @@
1
+ import postcss from 'postcss'
2
+ import { buildContainerClasses } from '@wix/origami-react-runtime/layout-contract'
3
+ import { CONTENT } from './contract.js'
4
+ import { isLayoutSource, parseTargets } from './parser.js'
5
+
6
+ const grid = { display: 'grid', 'grid-template-rows': '1fr', 'grid-template-columns': 'minmax(0, 1fr)' }
7
+
8
+ /** Preserve source locations on every emitted declaration and selector. */
9
+ const ruleFor = (owner, selector, declarations) => {
10
+ const rule = postcss.rule({ selector, source: owner.source })
11
+ for (const [prop, value] of Object.entries(declarations)) {
12
+ const declaration = postcss.decl({ prop, value: String(value) })
13
+ declaration.source = owner.source
14
+ rule.append(declaration)
15
+ }
16
+ return rule
17
+ }
18
+
19
+ /** @param {import('postcss').Root} root
20
+ * @param {import('../../transform/types.js').TransformContext} context */
21
+ export const compileContainers = (root, context) => {
22
+ const owners = parseTargets(root, context)
23
+ if (!owners.size) return
24
+ // Thunderbolt writes max-width and margin-left directly; neither property inherits.
25
+ // We use custom properties so width and alignment can change independently at breakpoints.
26
+ // Those do inherit, so each container needs defaults to avoid taking its parent's settings.
27
+ // This first layer keeps defaults weaker than authored rules, including rules in later layers.
28
+ // Clone only conditional/scope envelopes into it so the defaults apply under the same conditions.
29
+ const defaultsLayer = postcss.atRule({ name: 'layer', params: 'origami-container-defaults' })
30
+ if (root.source) defaultsLayer.source = root.source
31
+ root.prepend(defaultsLayer)
32
+ // Source identity prevents a matching selector in another carrier from becoming an owner.
33
+ const identities = new Set([...owners.keys()].map(rule => `${rule.source?.input.file}\0${rule.selector}`))
34
+ const rules = []
35
+ root.walkRules(rule => {
36
+ if (isLayoutSource(rule, context) && identities.has(`${rule.source?.input.file}\0${rule.selector}`))
37
+ rules.push(rule)
38
+ })
39
+ // Mechanics are emitted before the first rule in each envelope; authored declarations win.
40
+ const contexts = new WeakMap()
41
+ for (const owner of rules) {
42
+ const { contentClass, overflowClass } = buildContainerClasses(owner.selector.slice(1))
43
+ // In @scope, an unqualified selector cannot select the scoping root. DM emits
44
+ // #id there too; :where(:scope) includes that root without adding specificity.
45
+ let selector = owner.selector
46
+ for (let ancestor = owner.parent; ancestor && ancestor.type !== 'root'; ancestor = ancestor.parent) {
47
+ if (ancestor.type === 'atrule' && ancestor.name === 'scope') {
48
+ if (ancestor.params.trim() === `(${selector})`) selector = `:where(:scope)${selector}`
49
+ break
50
+ }
51
+ }
52
+ const overflow = `${selector} > .${overflowClass}`
53
+ const content = `${overflow} > .${contentClass}`
54
+ const emitted = contexts.get(owner.parent) ?? new Set()
55
+ if (!emitted.has(owner.selector)) {
56
+ let defaultsParent = defaultsLayer
57
+ const envelopes = []
58
+ for (let ancestor = owner.parent; ancestor && ancestor.type !== 'root'; ancestor = ancestor.parent) {
59
+ if (ancestor.type === 'atrule' && ancestor.name !== 'layer') envelopes.unshift(ancestor)
60
+ }
61
+ for (const envelope of envelopes) {
62
+ const clone = envelope.clone({ nodes: [] })
63
+ defaultsParent.append(clone)
64
+ defaultsParent = clone
65
+ }
66
+ defaultsParent.append(
67
+ ruleFor(owner, `:where(${content})`, {
68
+ '--content-max-width': 'none',
69
+ '--origami-content-align': '0.5',
70
+ })
71
+ )
72
+ owner.before(ruleFor(owner, selector, grid))
73
+ owner.before(ruleFor(owner, overflow, { ...grid, position: 'relative', 'align-self': 'stretch' }))
74
+ owner.before(
75
+ ruleFor(owner, content, {
76
+ 'box-sizing': 'border-box',
77
+ position: 'relative',
78
+ 'pointer-events': 'none',
79
+ 'max-width': 'var(--content-max-width)',
80
+ '--section-max-width': 'var(--content-max-width)',
81
+ 'margin-left':
82
+ 'clamp(0px, (100% - var(--content-max-width, 100%)) * var(--origami-content-align), 100 * var(--one-unit, 1%))',
83
+ })
84
+ )
85
+ owner.before(ruleFor(owner, `${content} > *`, { 'pointer-events': 'auto' }))
86
+ emitted.add(owner.selector)
87
+ contexts.set(owner.parent, emitted)
88
+ }
89
+ owner.selector = selector
90
+ let previous = owner
91
+ for (const target of owners.get(owner) ?? []) {
92
+ const generated = ruleFor(owner, target.params.trim() === CONTENT ? content : overflow, {})
93
+ for (const node of target.nodes ?? []) {
94
+ const clone = node.clone()
95
+ if (clone.type === 'decl') {
96
+ if (clone.prop === '--content-max-width-align') {
97
+ clone.prop = '--origami-content-align'
98
+ // Left margin uses none, half or all of the extra space to align the content.
99
+ clone.value = { left: '0', center: '0.5', right: '1' }[clone.value]
100
+ }
101
+ if (clone.prop === 'overflow-y')
102
+ generated.append(
103
+ clone.clone({
104
+ prop: '--sticky-offset',
105
+ value: clone.value === 'scroll' ? '0px' : 'initial',
106
+ })
107
+ )
108
+ }
109
+ generated.append(clone)
110
+ }
111
+ previous.after(generated)
112
+ previous = generated
113
+ target.remove()
114
+ }
115
+ }
116
+ }
@@ -0,0 +1,35 @@
1
+ // DM JSX era 0.42, document-management ef124715766d47d0b420bb3ec5a6c54a674a7b9b.
2
+ // Consumer routing only: component eligibility is validated by DM against JSX.
3
+ export const CONTENT = 'internal-container-content-wrapper'
4
+ export const OVERFLOW = 'internal-container-overflow-wrapper'
5
+ export const targets = Object.freeze({
6
+ [CONTENT]: new Set([
7
+ 'position',
8
+ 'display',
9
+ 'grid-template-columns',
10
+ 'grid-template-rows',
11
+ 'grid-auto-flow',
12
+ 'grid-auto-rows',
13
+ 'grid-auto-columns',
14
+ 'row-gap',
15
+ 'column-gap',
16
+ 'flex-direction',
17
+ 'flex-wrap',
18
+ 'justify-content',
19
+ 'align-items',
20
+ 'align-content',
21
+ 'padding-top',
22
+ 'padding-bottom',
23
+ 'padding-left',
24
+ 'padding-right',
25
+ '--content-max-width',
26
+ '--content-max-width-align',
27
+ ]),
28
+ [OVERFLOW]: new Set(['overflow-x', 'overflow-y']),
29
+ })
30
+ export const displays = Object.freeze({
31
+ GridContainerLayout: 'grid',
32
+ FlexContainerLayout: 'flex',
33
+ StackContainerLayout: 'flex',
34
+ OrganizerContainerLayout: 'flex',
35
+ })
@@ -0,0 +1,12 @@
1
+ import { compileContainers } from './compiler.js'
2
+ import { parseTargets } from './parser.js'
3
+
4
+ /** @type {import('../../transform/types.js').CssTransformPlugin} */
5
+ export const layoutPlugin = {
6
+ name: 'layout',
7
+ order: 15,
8
+ validate: (root, context) => {
9
+ parseTargets(root, context)
10
+ },
11
+ transform: compileContainers,
12
+ }
@@ -0,0 +1,81 @@
1
+ import { basename } from 'node:path'
2
+ import { isValidContainerId } from '@wix/origami-react-runtime/layout-contract'
3
+ import { relativeToSite } from '../../routing/siteLayout.js'
4
+ import { CONTENT, OVERFLOW, targets, displays } from './contract.js'
5
+
6
+ /** @param {import('postcss').Node} node
7
+ * @param {import('../../transform/types.js').TransformContext} context */
8
+ export const isLayoutSource = (node, context) => {
9
+ const file = node.source?.input.file
10
+ if (!file || basename(file) !== 'layout.css') return false
11
+ const relative = context.layout ? relativeToSite(context.layout.root, file) : file
12
+ return relative !== undefined && !relative.split(/[\\/]/).includes('node_modules')
13
+ }
14
+
15
+ /** Validate before mixins can erase the original placement/source of a target.
16
+ * @param {import('postcss').Root} root
17
+ * @param {import('../../transform/types.js').TransformContext} context */
18
+ export const parseTargets = (root, context) => {
19
+ /** @type {Map<import('postcss').Rule, import('postcss').AtRule[]>} */
20
+ const owners = new Map()
21
+ root.walkAtRules('origami-target', target => {
22
+ const name = target.params.trim()
23
+ if (!Object.hasOwn(targets, name)) throw target.error(`Unknown @origami-target ${name}`)
24
+ const owner = target.parent
25
+ if (owner?.type !== 'rule' || !owner.selector.startsWith('#') || !isValidContainerId(owner.selector.slice(1))) {
26
+ throw target.error('@origami-target requires a single component ID rule')
27
+ }
28
+ for (let ancestor = owner.parent; ancestor && ancestor.type !== 'root'; ancestor = ancestor.parent) {
29
+ if (
30
+ ancestor.type !== 'atrule' ||
31
+ !['scope', 'media', 'layer', 'supports', 'container'].includes(ancestor.name)
32
+ ) {
33
+ throw target.error('@origami-target is only allowed inside layout rule envelopes')
34
+ }
35
+ }
36
+ if (!isLayoutSource(target, context))
37
+ throw target.error('@origami-target must originate in site-owned layout.css')
38
+ const siblings = owners.get(owner) ?? []
39
+ if (siblings.some(sibling => sibling.params.trim() === name)) throw target.error(`Duplicate target ${name}`)
40
+ const seen = new Set()
41
+ for (const node of target.nodes ?? []) {
42
+ if (node.type === 'comment') continue
43
+ if (node.type !== 'decl') throw node.error('Target bodies must contain declarations only')
44
+ if (seen.has(node.prop)) throw node.error(`Duplicate target property ${node.prop}`)
45
+ seen.add(node.prop)
46
+ if (!targets[name].has(node.prop)) throw node.error(`${node.prop} does not belong to ${name}`)
47
+ if (node.prop === 'position' && node.value !== 'relative')
48
+ throw node.error('Content position must be relative')
49
+ if (name === OVERFLOW && !['auto', 'scroll', 'hidden', 'visible', 'clip'].includes(node.value)) {
50
+ throw node.error(`Unsupported overflow value ${node.value}`)
51
+ }
52
+ if (node.prop === '--content-max-width-align' && !['left', 'center', 'right'].includes(node.value)) {
53
+ throw node.error(`Unsupported content alignment ${node.value}`)
54
+ }
55
+ if (node.prop === 'display') {
56
+ const type = owner.nodes.find(item => item.type === 'decl' && item.prop === '--container-layout-type')
57
+ if (
58
+ !['grid', 'flex'].includes(node.value) ||
59
+ (type?.type === 'decl' &&
60
+ (!Object.hasOwn(displays, type.value) || displays[type.value] !== node.value))
61
+ ) {
62
+ throw node.error('Container type and content display are inconsistent')
63
+ }
64
+ }
65
+ }
66
+ siblings.push(target)
67
+ owners.set(owner, siblings)
68
+ })
69
+ for (const owner of owners.keys()) {
70
+ const seen = new Set()
71
+ for (const node of owner.nodes) {
72
+ if (node.type !== 'decl') continue
73
+ if (seen.has(node.prop)) throw node.error(`Duplicate root property ${node.prop}`)
74
+ seen.add(node.prop)
75
+ if ((targets[CONTENT].has(node.prop) && node.prop !== 'position') || targets[OVERFLOW].has(node.prop)) {
76
+ throw node.error(`${node.prop} belongs inside @origami-target`)
77
+ }
78
+ }
79
+ }
80
+ return owners
81
+ }
@@ -0,0 +1,23 @@
1
+ /** @param {import('postcss').AtRule} atRule */
2
+ const mixinName = atRule => atRule.params.trim().replace(/\(\)$/, '')
3
+
4
+ /** @type {import('../transform/types.js').CssTransformPlugin} */
5
+ export const mixinsPlugin = {
6
+ name: 'mixins',
7
+ order: 10,
8
+ transform(root) {
9
+ /** @type {Map<string, import('postcss').ChildNode[]>} */
10
+ const bodies = new Map()
11
+ root.walkAtRules('mixin', atRule => {
12
+ const first = atRule.first
13
+ const body = first?.type === 'atrule' && first.name === 'result' ? first : atRule
14
+ bodies.set(mixinName(atRule), body.nodes ?? [])
15
+ atRule.remove()
16
+ })
17
+ root.walkAtRules('apply', atRule => {
18
+ const body = bodies.get(mixinName(atRule))
19
+ if (!body) throw new Error(`@apply ${atRule.params} has no @mixin definition`)
20
+ atRule.replaceWith(body.map(node => node.clone()))
21
+ })
22
+ },
23
+ }
@@ -0,0 +1,73 @@
1
+ # SPX follows the active CSS settings
2
+
3
+ A component can declare a size once and change its scaling settings at a breakpoint:
4
+
5
+ ```css
6
+ #button {
7
+ --spx-ref-width: 1280;
8
+ --spx-resolver-type: scale;
9
+ border-width: --spx(4);
10
+ }
11
+
12
+ @media (max-width: 600px) {
13
+ #button {
14
+ --spx-ref-width: 390;
15
+ }
16
+ }
17
+ ```
18
+
19
+ The desktop border declaration still applies on mobile. At a 390px viewport, it should
20
+ be 4px again because its reference width is now 390. If the build replaces `4 / 1280`
21
+ with a constant, the mobile reference-width override can no longer affect that border.
22
+
23
+ This applies to regular component styles as well as container layouts. There is one
24
+ SPX conversion path for all declarations. Layout first moves content and overflow
25
+ styles onto their runtime wrappers; this plugin then converts every `--spx(number)`.
26
+
27
+ ## What the browser calculates
28
+
29
+ In scale mode, the size is approximately:
30
+
31
+ ```text
32
+ number × (scaling-factor − scrollbar-width) / spx-ref-width
33
+ ```
34
+
35
+ Nonzero scaled values have a minimum magnitude of 0.5px, preserving their sign.
36
+ In fixed mode, the number is a pixel value, including fractional pixels. Zero stays zero.
37
+
38
+ The plugin keeps reference width, scaling factor and scrollbar width as `var()` reads
39
+ inside each expression. It translates `--spx-resolver-type: scale` to a private numeric
40
+ switch of 1, and `fixed` to 0. That switch selects scaled lengths or fixed pixels using
41
+ ordinary CSS arithmetic, without requiring CSS custom functions or style queries.
42
+
43
+ Settings stay in their authored rules, including their media queries, scopes, layers,
44
+ source order and `!important`. Even a stylesheet that contains only settings must be
45
+ processed, because it can affect calls declared in another stylesheet. The original
46
+ resolver declaration is retained alongside its numeric switch.
47
+
48
+ We inline the calculation instead of inheriting a precomputed `--origami-spx-unit`.
49
+ An inherited derived custom property has already been resolved on its parent; changing
50
+ reference width or scaling factor on a child would not recompute it there. Reading the
51
+ settings in each expression lets ordinary components and internal wrappers use their
52
+ own effective settings. No per-container SPX reset is needed.
53
+
54
+ Existing preamble declarations remain in CSS and participate in the normal cascade.
55
+ When settings are absent, expressions fall back to reference width 1280, scale mode,
56
+ viewport width 100vw and scrollbar width 0px. Nested components inherit settings unless
57
+ they override them. As with any CSS custom property, an authored token containing an
58
+ SPX expression resolves where the token is declared; inheriting the token does not
59
+ re-evaluate it using a child's settings.
60
+
61
+ ## Files and boundaries
62
+
63
+ - `index.js` removes the SPX function definition and visits declarations.
64
+ - `cascade.js` translates resolver settings and generates browser-evaluated expressions.
65
+ - `calls.js` parses literal numeric SPX calls and rejects unsupported arguments.
66
+
67
+ Reference widths must be positive numbers and resolver modes must be `scale` or `fixed`.
68
+ SPX stopper clamping is still outside this plugin: it uses the effective
69
+ `--scaling-factor` supplied by CSS and does not generate Thunderbolt's clamp formula.
70
+
71
+ `test/spx.browser.mjs` checks computed styles for the button example, nested overrides,
72
+ settings in separate stylesheets, cascade priority, fixed fractions, signs and zero.
73
+ Run it together with the container browser checks using the package's `test:browser` script.
@@ -0,0 +1,12 @@
1
+ const CALL = /--spx\(\s*(-?(?:\d+\.?\d*|\.\d+))\s*\)/g
2
+
3
+ /** Replace literal SPX calls, reporting unsupported arguments at their declaration.
4
+ * @param {import('postcss').Declaration} declaration
5
+ * @param {(value: number) => string} resolve
6
+ * @param {() => Error} invalidCall
7
+ */
8
+ export const lowerCalls = (declaration, resolve, invalidCall) => {
9
+ if (!declaration.value.includes('--spx(')) return
10
+ declaration.value = declaration.value.replace(CALL, (_, literal) => resolve(Number(literal)))
11
+ if (declaration.value.includes('--spx(')) throw invalidCall()
12
+ }