@wix/origami-transforms 0.1.14 → 0.1.16

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.
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Every registration in `files`, by its `companionApp.<Name>` type.
3
+ * @param {Iterable<readonly [string, string]>} files Each extension file's path and source.
4
+ * @returns {Map<string, CompanionRegistration>}
5
+ */
6
+ export function readCompanionRegistrations(files: Iterable<readonly [string, string]>): Map<string, CompanionRegistration>;
7
+ /**
8
+ * @typedef {object} CompanionModule
9
+ * @property {CompanionRegistration} registration
10
+ * @property {CompanionImport} import How a page imports it: a component as its entry exports it, a context provider
11
+ * as the entry's default export.
12
+ */
13
+ /**
14
+ * Every `@site/site-code/<name>` a page can import, by its `companionApp` name. A provider's hook is imported from
15
+ * the provider's module.
16
+ * @param {ReadonlyMap<string, CompanionRegistration>} registrations
17
+ * @returns {Map<string, CompanionModule>}
18
+ */
19
+ export function companionModules(registrations: ReadonlyMap<string, CompanionRegistration>): Map<string, CompanionModule>;
20
+ export function isExtensionFile(path: string): boolean;
21
+ export function importTarget(from: string, specifier: string): string;
22
+ export function entryTarget(registration: CompanionRegistration, srcDir: string): string;
23
+ export function findFile(target: string, exists: (path: string) => boolean): string | undefined;
24
+ export function companionExports(modules: ReadonlyMap<string, CompanionModule>, entry: (registration: CompanionRegistration) => string): Record<string, string>;
25
+ export function readCompanionRegistrationsIn(srcDir: string): Map<string, CompanionRegistration>;
26
+ export type CompanionModule = {
27
+ registration: CompanionRegistration;
28
+ /**
29
+ * How a page imports it: a component as its entry exports it, a context provider
30
+ * as the entry's default export.
31
+ */
32
+ import: CompanionImport;
33
+ };
34
+ export type CompanionImport = {
35
+ kind: "default";
36
+ } | {
37
+ kind: "named";
38
+ exportName: string;
39
+ };
40
+ export type CompanionComponentRegistration = {
41
+ kind: "component";
42
+ /**
43
+ * The registration's file.
44
+ */
45
+ path: string;
46
+ /**
47
+ * The client entry's import as written (`./component.tsx?url`), relative to `path`.
48
+ */
49
+ source: string;
50
+ /**
51
+ * How the entry's component is exported.
52
+ */
53
+ componentImport: CompanionImport;
54
+ };
55
+ export type CompanionContextRegistration = {
56
+ kind: "contextProvider";
57
+ /**
58
+ * The registration's file.
59
+ */
60
+ path: string;
61
+ /**
62
+ * `resources.client.url`, relative to the companion app's `src`, as the Wix CLI reads it.
63
+ * The module's default export is the provider.
64
+ */
65
+ source: string;
66
+ /**
67
+ * `resources.contextSpecifier.hook`, the module's named export that reads the context.
68
+ */
69
+ hook: string;
70
+ };
71
+ export type CompanionRegistration = CompanionComponentRegistration | CompanionContextRegistration;
@@ -1,5 +1,6 @@
1
1
  export const CONTENT: "internal-container-content-wrapper";
2
2
  export const OVERFLOW: "internal-container-overflow-wrapper";
3
+ export const DEFAULTS_LAYER: "origami-container-defaults";
3
4
  export const targets: Readonly<{
4
5
  "internal-container-content-wrapper": Set<string>;
5
6
  "internal-container-overflow-wrapper": Set<string>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wix/origami-transforms",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "Babel and PostCSS checks and transforms for Wix origami page files.",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -15,8 +15,10 @@
15
15
  },
16
16
  "dependencies": {
17
17
  "@babel/core": "^7.26.0",
18
+ "@babel/parser": "^7.25.0",
19
+ "@babel/types": "^7.25.2",
18
20
  "@wix/origami-fonts": "1.0.1",
19
- "@wix/origami-system-components": "1.9.0",
21
+ "@wix/origami-system-components": "1.10.0",
20
22
  "postcss": "^8.5.3",
21
23
  "postcss-custom-media": "^12.0.1"
22
24
  },
@@ -54,9 +56,13 @@
54
56
  "./wrapper-import": {
55
57
  "types": "./dist/wrapperImport.d.ts",
56
58
  "default": "./src/wrapperImport.js"
59
+ },
60
+ "./companion-registrations": {
61
+ "types": "./dist/companionRegistrations.d.ts",
62
+ "default": "./src/companionRegistrations.js"
57
63
  }
58
64
  },
59
65
  "type": "module",
60
66
  "types": "./dist/index.d.ts",
61
- "falconPackageHash": "dcaeecf42c720e3e9a25f95eadad7516666d783eef907ba5f39467f1"
67
+ "falconPackageHash": "74a88e698f17027ba6854f8a4cdcd82fb6e31bdaec6caea30a13b8ac"
62
68
  }
@@ -0,0 +1,200 @@
1
+ // A companion app's `editorReactComponent` and `contextProvider` registrations, read the same way by the export
2
+ // (origami-site-generator, from the exported files) and by the site's Vite plugin (origami-site-runtime, from disk).
3
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
4
+ import { posix } from 'node:path'
5
+ import { parse } from '@babel/parser'
6
+ import * as t from '@babel/types'
7
+
8
+ /** @typedef {{ kind: 'default' } | { kind: 'named', exportName: string }} CompanionImport */
9
+ /**
10
+ * @typedef {object} CompanionComponentRegistration
11
+ * @property {'component'} kind
12
+ * @property {string} path The registration's file.
13
+ * @property {string} source The client entry's import as written (`./component.tsx?url`), relative to `path`.
14
+ * @property {CompanionImport} componentImport How the entry's component is exported.
15
+ */
16
+ /**
17
+ * @typedef {object} CompanionContextRegistration
18
+ * @property {'contextProvider'} kind
19
+ * @property {string} path The registration's file.
20
+ * @property {string} source `resources.client.url`, relative to the companion app's `src`, as the Wix CLI reads it.
21
+ * The module's default export is the provider.
22
+ * @property {string} hook `resources.contextSpecifier.hook`, the module's named export that reads the context.
23
+ */
24
+ /** @typedef {CompanionComponentRegistration | CompanionContextRegistration} CompanionRegistration */
25
+
26
+ /** @param {string} path */
27
+ export const isExtensionFile = path => /\.extension\.[cm]?[jt]sx?$/.test(path)
28
+
29
+ /**
30
+ * Every registration in `files`, by its `companionApp.<Name>` type.
31
+ * @param {Iterable<readonly [string, string]>} files Each extension file's path and source.
32
+ * @returns {Map<string, CompanionRegistration>}
33
+ */
34
+ export function readCompanionRegistrations(files) {
35
+ /** @type {Map<string, CompanionRegistration>} */
36
+ const registrations = new Map()
37
+ for (const [path, source] of files) {
38
+ const ast = parse(source, { sourceType: 'module', plugins: ['jsx', 'typescript'] })
39
+ t.traverseFast(ast, node => {
40
+ if (!t.isCallExpression(node) || !t.isMemberExpression(node.callee)) return
41
+ const isComponent = t.isIdentifier(node.callee.property, { name: 'editorReactComponent' })
42
+ if (!isComponent && !t.isIdentifier(node.callee.property, { name: 'contextProvider' })) return
43
+ const config = node.arguments[0]
44
+ const type = property(config, 'type')
45
+ const resources = property(config, 'resources')
46
+ const registration = isComponent ? component(ast, path, resources) : contextProvider(path, resources)
47
+ if (!t.isStringLiteral(type)) throw new Error(`Unsupported component registration: ${path}`)
48
+ if (!/^companionApp\.[^.]+$/.test(type.value))
49
+ throw new Error(`Unsupported companion component namespace: ${type.value}`)
50
+ if (registrations.has(type.value)) throw new Error(`Duplicate user code component: ${type.value}`)
51
+ registrations.set(type.value, { ...registration, path })
52
+ })
53
+ }
54
+ return registrations
55
+ }
56
+
57
+ /**
58
+ * @param {t.File} ast @param {string} path @param {t.Expression | undefined} resources
59
+ * @returns {Omit<CompanionComponentRegistration, 'path'>}
60
+ */
61
+ const component = (ast, path, resources) => {
62
+ const url = property(property(resources, 'client'), 'componentUrl')
63
+ if (!t.isIdentifier(url)) throw new Error(`Unsupported component registration: ${path}`)
64
+ const declaration = ast.program.body.find(
65
+ statement =>
66
+ t.isImportDeclaration(statement) && statement.specifiers.some(specifier => specifier.local.name === url.name)
67
+ )
68
+ if (!t.isImportDeclaration(declaration)) throw new Error(`Missing component entry: ${path}`)
69
+ const specifier = declaration.specifiers.find(specifier => specifier.local.name === url.name)
70
+ if (!specifier || t.isImportNamespaceSpecifier(specifier))
71
+ throw new Error(`Unsupported component registration: ${path}`)
72
+ return {
73
+ kind: 'component',
74
+ source: declaration.source.value,
75
+ componentImport: t.isImportDefaultSpecifier(specifier)
76
+ ? { kind: 'default' }
77
+ : {
78
+ kind: 'named',
79
+ exportName: t.isIdentifier(specifier.imported) ? specifier.imported.name : specifier.imported.value,
80
+ },
81
+ }
82
+ }
83
+
84
+ /**
85
+ * @param {string} path @param {t.Expression | undefined} resources
86
+ * @returns {Omit<CompanionContextRegistration, 'path'>}
87
+ */
88
+ const contextProvider = (path, resources) => {
89
+ const url = property(property(resources, 'client'), 'url')
90
+ const hook = property(property(resources, 'contextSpecifier'), 'hook')
91
+ if (!t.isStringLiteral(url) || !t.isStringLiteral(hook) || !/^[A-Za-z_$][\w$]*$/.test(hook.value))
92
+ throw new Error(`Unsupported context provider registration: ${path}`)
93
+ return { kind: 'contextProvider', source: url.value, hook: hook.value }
94
+ }
95
+
96
+ /**
97
+ * The file a relative import in `from` points at, without its query, before `findFile` fills in its extension.
98
+ * @param {string} from @param {string} specifier
99
+ */
100
+ export const importTarget = (from, specifier) =>
101
+ posix.normalize(posix.join(posix.dirname(from), specifier.split(/[?#]/)[0]))
102
+
103
+ /**
104
+ * Where a registration's client entry points, before `findFile` fills in its extension.
105
+ * @param {CompanionRegistration} registration @param {string} srcDir The companion app's `src`, in the same form as `path`.
106
+ */
107
+ export const entryTarget = (registration, srcDir) =>
108
+ registration.kind === 'component'
109
+ ? importTarget(registration.path, registration.source)
110
+ : importTarget(posix.join(srcDir, 'index'), registration.source)
111
+
112
+ // The files an import can mean, most likely first: as written, a `.js` import of a TypeScript source, then an added
113
+ // extension or index.
114
+ /** @param {string} target */
115
+ const candidates = target => [
116
+ target,
117
+ ...(/\.[cm]?js$/.test(target)
118
+ ? [
119
+ target.replace(/\.js$/, '.ts'),
120
+ target.replace(/\.js$/, '.tsx'),
121
+ target.replace(/\.mjs$/, '.mts'),
122
+ target.replace(/\.cjs$/, '.cts'),
123
+ ]
124
+ : []),
125
+ ...['.ts', '.tsx', '.js', '.jsx', '.mjs', '.json', '/index.ts', '/index.tsx', '/index.js'].map(ext => target + ext),
126
+ ]
127
+
128
+ /**
129
+ * The file an import target (from `importTarget` or `entryTarget`) means, or undefined when none exists.
130
+ * @param {string} target @param {(path: string) => boolean} exists
131
+ */
132
+ export const findFile = (target, exists) => candidates(target).find(exists)
133
+
134
+ /**
135
+ * @typedef {object} CompanionModule
136
+ * @property {CompanionRegistration} registration
137
+ * @property {CompanionImport} import How a page imports it: a component as its entry exports it, a context provider
138
+ * as the entry's default export.
139
+ */
140
+
141
+ /**
142
+ * Every `@site/site-code/<name>` a page can import, by its `companionApp` name. A provider's hook is imported from
143
+ * the provider's module.
144
+ * @param {ReadonlyMap<string, CompanionRegistration>} registrations
145
+ * @returns {Map<string, CompanionModule>}
146
+ */
147
+ export function companionModules(registrations) {
148
+ /** @type {Map<string, CompanionModule>} */
149
+ const modules = new Map()
150
+ for (const [type, registration] of registrations) {
151
+ const name = type.slice('companionApp.'.length)
152
+ if (!/^[A-Za-z_$][\w$]*$/.test(name)) throw new Error(`Unsupported extension component name: ${name}`)
153
+ modules.set(name, {
154
+ registration,
155
+ import: registration.kind === 'component' ? registration.componentImport : { kind: 'default' },
156
+ })
157
+ }
158
+ return modules
159
+ }
160
+
161
+ /**
162
+ * The site-code `package.json` `exports` that TypeScript resolves `@site/site-code/<name>` through, one per module.
163
+ * @param {ReadonlyMap<string, CompanionModule>} modules
164
+ * @param {(registration: CompanionRegistration) => string} entry The registration's client entry, relative to the
165
+ * companion app.
166
+ * @returns {Record<string, string>}
167
+ */
168
+ export const companionExports = (modules, entry) =>
169
+ Object.fromEntries([...modules].map(([name, { registration }]) => [`./${name}`, `./${entry(registration)}`]))
170
+
171
+ /**
172
+ * Every registration under a companion app's `src` on disk, with absolute paths; none when it has no `src`.
173
+ * @param {string} srcDir
174
+ */
175
+ export const readCompanionRegistrationsIn = srcDir =>
176
+ readCompanionRegistrations(
177
+ extensionFiles(srcDir).map(file => /** @type {const} */ ([file, readFileSync(file, 'utf8')]))
178
+ )
179
+
180
+ /** @param {string} directory @returns {string[]} */
181
+ const extensionFiles = directory =>
182
+ existsSync(directory)
183
+ ? readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
184
+ const path = posix.join(directory, entry.name)
185
+ if (entry.isDirectory()) return entry.name === 'node_modules' ? [] : extensionFiles(path)
186
+ return isExtensionFile(entry.name) ? [path] : []
187
+ })
188
+ : []
189
+
190
+ /** @param {unknown} node @param {string} name @returns {t.Expression | undefined} */
191
+ const property = (node, name) => {
192
+ if (!node || !t.isObjectExpression(/** @type {t.Node} */ (node))) return undefined
193
+ const match = /** @type {t.ObjectExpression} */ (node).properties.find(
194
+ candidate =>
195
+ t.isObjectProperty(candidate) &&
196
+ !candidate.computed &&
197
+ (t.isIdentifier(candidate.key, { name }) || t.isStringLiteral(candidate.key, { value: name }))
198
+ )
199
+ return t.isObjectProperty(match) && t.isExpression(match.value) ? match.value : undefined
200
+ }
@@ -32,15 +32,21 @@ the outer element.
32
32
  ## What the plugin adds
33
33
 
34
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.
35
+ the container. The content wrapper lets pointer events pass through empty space
36
+ while its children stay clickable. Vertical overflow also sets `--sticky-offset`
37
+ to `0px` for `scroll` and resets it for other values.
38
+
39
+ Each container's alignment defaults to center in a separate first CSS layer, so
40
+ authored values always win. Media queries and scopes around a rule still apply.
41
+
42
+ ### Content max width
43
+
44
+ Where DM writes `--content-max-width` (usually a section's content wrapper), the
45
+ plugin adds `max-width` and an alignment `margin-left` next to it, and nowhere else.
46
+
47
+ - Those two properties do not inherit, so nested containers are not boxed in.
48
+ - `--content-max-width` does inherit, so nested containers use it as their SPX cap
49
+ (see the SPX plugin README). With none above, SPX is uncapped.
44
50
 
45
51
  ## Why validation runs before transformations
46
52
 
@@ -1,9 +1,11 @@
1
1
  import postcss from 'postcss'
2
2
  import { buildContainerClasses } from '@wix/origami-system-components/layout-contract'
3
- import { CONTENT } from './contract.js'
3
+ import { CONTENT, DEFAULTS_LAYER } from './contract.js'
4
4
  import { isLayoutSource, parseTargets } from './parser.js'
5
5
 
6
6
  const grid = { display: 'grid', 'grid-template-rows': '1fr', 'grid-template-columns': 'minmax(0, 1fr)' }
7
+ const contentMargin =
8
+ 'clamp(0px, (100% - var(--content-max-width)) * var(--origami-content-align), 100 * var(--one-unit, 1%))'
7
9
 
8
10
  /** Preserve source locations on every emitted declaration and selector. */
9
11
  const ruleFor = (owner, selector, declarations) => {
@@ -21,12 +23,12 @@ const ruleFor = (owner, selector, declarations) => {
21
23
  export const compileContainers = (root, context) => {
22
24
  const owners = parseTargets(root, context)
23
25
  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.
26
+ // max-width and margin-left are written only where a content max width is set.
27
+ // Alignment is a custom property so it can change independently at breakpoints.
28
+ // That inherits, so each container needs a default to avoid taking its parent's alignment.
27
29
  // This first layer keeps defaults weaker than authored rules, including rules in later layers.
28
30
  // 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' })
31
+ const defaultsLayer = postcss.atRule({ name: 'layer', params: DEFAULTS_LAYER })
30
32
  if (root.source) defaultsLayer.source = root.source
31
33
  root.prepend(defaultsLayer)
32
34
  // Source identity prevents a matching selector in another carrier from becoming an owner.
@@ -64,10 +66,7 @@ export const compileContainers = (root, context) => {
64
66
  defaultsParent = clone
65
67
  }
66
68
  defaultsParent.append(
67
- ruleFor(owner, `:where(${content})`, {
68
- '--content-max-width': 'none',
69
- '--origami-content-align': '0.5',
70
- })
69
+ ruleFor(owner, `:where(${content})`, { '--origami-content-align': '0.5' })
71
70
  )
72
71
  owner.before(ruleFor(owner, selector, grid))
73
72
  owner.before(ruleFor(owner, overflow, { ...grid, position: 'relative', 'align-self': 'stretch' }))
@@ -76,10 +75,6 @@ export const compileContainers = (root, context) => {
76
75
  'box-sizing': 'border-box',
77
76
  position: 'relative',
78
77
  '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
78
  })
84
79
  )
85
80
  owner.before(ruleFor(owner, `${content} > *`, { 'pointer-events': 'auto' }))
@@ -98,6 +93,11 @@ export const compileContainers = (root, context) => {
98
93
  // Left margin uses none, half or all of the extra space to align the content.
99
94
  clone.value = { left: '0', center: '0.5', right: '1' }[clone.value]
100
95
  }
96
+ if (clone.prop === '--content-max-width')
97
+ generated.append(
98
+ clone.clone({ prop: 'max-width', value: 'var(--content-max-width)' }),
99
+ clone.clone({ prop: 'margin-left', value: contentMargin })
100
+ )
101
101
  if (clone.prop === 'overflow-y')
102
102
  generated.append(
103
103
  clone.clone({
@@ -2,6 +2,7 @@
2
2
  // Consumer routing only: component eligibility is validated by DM against JSX.
3
3
  export const CONTENT = 'internal-container-content-wrapper'
4
4
  export const OVERFLOW = 'internal-container-overflow-wrapper'
5
+ export const DEFAULTS_LAYER = 'origami-container-defaults'
5
6
  export const targets = Object.freeze({
6
7
  [CONTENT]: new Set([
7
8
  'position',
@@ -29,13 +29,21 @@ styles onto their runtime wrappers; this plugin then converts every `--spx(numbe
29
29
  In scale mode, the size is approximately:
30
30
 
31
31
  ```text
32
- number × (scaling-factor − scrollbar-width) / spx-ref-width
32
+ number × (clamp(spx-stopper-min, scaling-factor, min(spx-stopper-max, content-max-width)) − scrollbar-width) / spx-ref-width
33
33
  ```
34
34
 
35
+ This matches DM's `--spx()` in the `theme.css` preamble.
36
+
37
+ - **Stoppers:** DM writes them inside `@layer spx-preamble`, so the viewer's unlayered global CSS
38
+ values (`0px` / `9999px`) win.
39
+ - **Cap:** `--content-max-width`, set by DM on each section's content wrapper and inherited by its
40
+ descendants. With none above, the cap is 99999px. A `--content-max-width` that uses `--spx()` is
41
+ compiled without the cap, since a custom property can't read itself.
42
+
35
43
  Nonzero scaled values have a minimum magnitude of 0.5px, preserving their sign.
36
44
  In fixed mode, the number is a pixel value, including fractional pixels. Zero stays zero.
37
45
 
38
- The plugin keeps reference width, scaling factor and scrollbar width as `var()` reads
46
+ The plugin keeps reference width, scaling factor, scrollbar width and stoppers as `var()` reads
39
47
  inside each expression. It translates `--spx-resolver-type: scale` to a private numeric
40
48
  switch of 1, and `fixed` to 0. That switch selects scaled lengths or fixed pixels using
41
49
  ordinary CSS arithmetic, without requiring CSS custom functions or style queries.
@@ -53,9 +61,9 @@ own effective settings. No per-container SPX reset is needed.
53
61
 
54
62
  Existing preamble declarations remain in CSS and participate in the normal cascade.
55
63
  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
64
+ viewport width 100vw, scrollbar width 0px, stoppers 0px / 99999px and no cap. Nested components inherit settings unless
65
+ they override them. As with any CSS custom property, an authored variable containing an
66
+ SPX expression resolves where the variable is declared; inheriting the variable does not
59
67
  re-evaluate it using a child's settings.
60
68
 
61
69
  ## Files and boundaries
@@ -65,9 +73,8 @@ re-evaluate it using a child's settings.
65
73
  - `calls.js` parses literal numeric SPX calls and rejects unsupported arguments.
66
74
 
67
75
  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.
76
+ Stoppers and `--content-max-width` are not validated.
70
77
 
71
78
  `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.
79
+ settings in separate stylesheets, cascade priority, fixed fractions, signs, zero, stoppers and cap.
73
80
  Run it together with the container browser checks using the package's `test:browser` script.
@@ -3,8 +3,12 @@ import { lowerCalls } from './calls.js'
3
3
  // Keep arithmetic at the call site: an inherited derived custom property would already
4
4
  // be computed against its parent's settings. Only the numeric resolver switch inherits.
5
5
  const SCALE = 'var(--origami-spx-scale, 1)'
6
- const UNIT = `(${SCALE} * (var(--scaling-factor, 100vw) - var(--scrollbar-width, 0px)) / var(--spx-ref-width, 1280) + (1 - ${SCALE}) * 1px)`
7
6
  const FLOOR = `calc(0.5px * ${SCALE})`
7
+ /** @param {string} cap */
8
+ const unitCappedAt = cap =>
9
+ `(${SCALE} * (clamp(var(--spx-stopper-min, 0px), var(--scaling-factor, 100vw), min(var(--spx-stopper-max, 99999px), ${cap})) - var(--scrollbar-width, 0px)) / var(--spx-ref-width, 1280) + (1 - ${SCALE}) * 1px)`
10
+ const UNIT = unitCappedAt('var(--content-max-width, 99999px)')
11
+ const UNCAPPED_UNIT = unitCappedAt('99999px')
8
12
 
9
13
  /** Compile settings and calls in place, retaining their cascade and source locations.
10
14
  * @param {import('postcss').Declaration} declaration
@@ -30,11 +34,12 @@ export const compileCascadeDeclaration = declaration => {
30
34
  ) {
31
35
  throw declaration.error('SPX reference width must be positive')
32
36
  }
37
+ const unit = declaration.prop === '--content-max-width' ? UNCAPPED_UNIT : UNIT
33
38
  lowerCalls(
34
39
  declaration,
35
40
  number => {
36
41
  if (number === 0) return '0px'
37
- return number < 0 ? `min(calc(-1 * ${FLOOR}), ${number} * ${UNIT})` : `max(${FLOOR}, ${number} * ${UNIT})`
42
+ return number < 0 ? `min(calc(-1 * ${FLOOR}), ${number} * ${unit})` : `max(${FLOOR}, ${number} * ${unit})`
38
43
  },
39
44
  () => declaration.error('SPX calls --spx() with a non-literal argument')
40
45
  )