@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.
- package/README.md +79 -0
- package/package.json +68 -0
- package/src/cssPlugins/customMedia.js +52 -0
- package/src/cssPlugins/index.js +18 -0
- package/src/cssPlugins/layout/README.md +77 -0
- package/src/cssPlugins/layout/compiler.js +116 -0
- package/src/cssPlugins/layout/contract.js +35 -0
- package/src/cssPlugins/layout/index.js +12 -0
- package/src/cssPlugins/layout/parser.js +81 -0
- package/src/cssPlugins/mixins.js +23 -0
- package/src/cssPlugins/spx/README.md +73 -0
- package/src/cssPlugins/spx/calls.js +12 -0
- package/src/cssPlugins/spx/cascade.js +41 -0
- package/src/cssPlugins/spx/index.js +13 -0
- package/src/default-error-404.jsx +9 -0
- package/src/index.js +4 -0
- package/src/jsxPlugins/index.js +8 -0
- package/src/jsxPlugins/origamiCompatibility/ast.js +30 -0
- package/src/jsxPlugins/origamiCompatibility/componentBindings.js +43 -0
- package/src/jsxPlugins/origamiCompatibility/index.js +48 -0
- package/src/jsxPlugins/origamiCompatibility/rules/containerProps.js +15 -0
- package/src/jsxPlugins/origamiCompatibility/rules/richTextHtml.js +22 -0
- package/src/routing/documentHtml.js +19 -0
- package/src/routing/pagesToRoutes.js +110 -0
- package/src/routing/siteLayout.js +31 -0
- package/src/routing/urlPath.js +62 -0
- package/src/siteViteConfig.js +91 -0
- package/src/transform/context.js +13 -0
- package/src/transform/css/postcssPlugin.js +35 -0
- package/src/transform/css/runPlugins.js +22 -0
- package/src/transform/jsx/babelAdapter.js +37 -0
- package/src/transform/jsx/runPlugins.js +20 -0
- package/src/transform/pipeline.js +55 -0
- package/src/transform/registry.js +40 -0
- package/src/transform/types.js +39 -0
- package/src/vitePlugins/documentMiddleware.js +42 -0
- package/src/vitePlugins/itemFolderImports.js +16 -0
- package/src/vitePlugins/reactBabel.js +6 -0
- 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
|
+
}
|