@wix/origami-transforms 0.1.28 → 0.1.30

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 (75) hide show
  1. package/dist/cssPlugins/componentPosition.d.ts +6 -2
  2. package/dist/cssPlugins/customEnums.d.ts +5 -2
  3. package/dist/cssPlugins/customMedia.d.ts +7 -2
  4. package/dist/cssPlugins/fonts.d.ts +6 -2
  5. package/dist/cssPlugins/index.d.ts +2 -2
  6. package/dist/cssPlugins/layout/index.d.ts +7 -2
  7. package/dist/cssPlugins/mixins.d.ts +7 -2
  8. package/dist/cssPlugins/presets.d.ts +6 -2
  9. package/dist/cssPlugins/shapeDividers.d.ts +6 -2
  10. package/dist/cssPlugins/spx/calls.d.ts +3 -0
  11. package/dist/cssPlugins/spx/index.d.ts +7 -2
  12. package/dist/cssPlugins/spxThemeVariables/index.d.ts +6 -0
  13. package/dist/cssPlugins/spxThemeVariables/utils/breakpointUtils.d.ts +23 -0
  14. package/dist/cssPlugins/spxThemeVariables/utils/copyPlanUtils.d.ts +14 -0
  15. package/dist/cssPlugins/spxThemeVariables/utils/cssTreeUtils.d.ts +10 -0
  16. package/dist/cssPlugins/spxThemeVariables/utils/restateUtils.d.ts +9 -0
  17. package/dist/cssPlugins/spxThemeVariables/utils/sectionUtils.d.ts +6 -0
  18. package/dist/cssPlugins/spxThemeVariables/utils/stylesheetUtils.d.ts +10 -0
  19. package/dist/cssPlugins/spxThemeVariables/utils/themeUtils.d.ts +6 -0
  20. package/dist/cssPlugins/spxThemeVariables/utils/wrappingAtRuleUtils.d.ts +10 -0
  21. package/dist/cssPlugins/spxThemeVariables/utils/writeUtils.d.ts +17 -0
  22. package/dist/cssPlugins/stackChildren.d.ts +5 -2
  23. package/dist/cssPlugins/wixVectorUrls.d.ts +6 -2
  24. package/dist/jsxPlugins/index.d.ts +2 -2
  25. package/dist/jsxPlugins/mediaValidation/index.d.ts +5 -2
  26. package/dist/jsxPlugins/origamiCompatibility/index.d.ts +6 -2
  27. package/dist/jsxPlugins/pageSlots.d.ts +7 -6
  28. package/dist/jsxPlugins/validation/checks.d.ts +7 -7
  29. package/dist/jsxPlugins/validation/index.d.ts +5 -2
  30. package/dist/jsxPlugins/validation/rules.d.ts +25 -0
  31. package/dist/transform/bi.d.ts +9 -0
  32. package/dist/transform/layoutError.d.ts +7 -4
  33. package/dist/transform/pipeline.d.ts +2 -2
  34. package/dist/transform/types.d.ts +11 -11
  35. package/package.json +3 -3
  36. package/src/cssPlugins/componentPosition.js +5 -1
  37. package/src/cssPlugins/customEnums.js +17 -11
  38. package/src/cssPlugins/customMedia.js +10 -3
  39. package/src/cssPlugins/fonts.js +5 -1
  40. package/src/cssPlugins/index.js +3 -1
  41. package/src/cssPlugins/layout/index.js +8 -1
  42. package/src/cssPlugins/layout/parser.js +15 -14
  43. package/src/cssPlugins/mixins.js +13 -6
  44. package/src/cssPlugins/presets.js +5 -1
  45. package/src/cssPlugins/shapeDividers.js +5 -1
  46. package/src/cssPlugins/spx/README.md +12 -3
  47. package/src/cssPlugins/spx/calls.js +28 -1
  48. package/src/cssPlugins/spx/cascade.js +26 -6
  49. package/src/cssPlugins/spx/index.js +8 -1
  50. package/src/cssPlugins/spxThemeVariables/README.md +14 -0
  51. package/src/cssPlugins/spxThemeVariables/index.js +36 -0
  52. package/src/cssPlugins/spxThemeVariables/utils/breakpointUtils.js +126 -0
  53. package/src/cssPlugins/spxThemeVariables/utils/copyPlanUtils.js +162 -0
  54. package/src/cssPlugins/spxThemeVariables/utils/cssTreeUtils.js +58 -0
  55. package/src/cssPlugins/spxThemeVariables/utils/restateUtils.js +134 -0
  56. package/src/cssPlugins/spxThemeVariables/utils/sectionUtils.js +32 -0
  57. package/src/cssPlugins/spxThemeVariables/utils/stylesheetUtils.js +98 -0
  58. package/src/cssPlugins/spxThemeVariables/utils/themeUtils.js +90 -0
  59. package/src/cssPlugins/spxThemeVariables/utils/wrappingAtRuleUtils.js +68 -0
  60. package/src/cssPlugins/spxThemeVariables/utils/writeUtils.js +98 -0
  61. package/src/cssPlugins/stackChildren.js +6 -2
  62. package/src/cssPlugins/wixVectorUrls.js +5 -1
  63. package/src/jsxPlugins/index.js +1 -1
  64. package/src/jsxPlugins/mediaValidation/index.js +10 -5
  65. package/src/jsxPlugins/origamiCompatibility/index.js +5 -1
  66. package/src/jsxPlugins/pageSlots.js +6 -1
  67. package/src/jsxPlugins/validation/checks.js +15 -8
  68. package/src/jsxPlugins/validation/index.js +9 -3
  69. package/src/jsxPlugins/validation/rules.js +14 -0
  70. package/src/transform/bi.js +30 -0
  71. package/src/transform/css/runPlugins.js +5 -7
  72. package/src/transform/jsx/runPlugins.js +3 -6
  73. package/src/transform/layoutError.js +7 -3
  74. package/src/transform/pipeline.js +2 -2
  75. package/src/transform/types.js +28 -6
@@ -49,8 +49,12 @@ const fontIdsOf = declaration => {
49
49
  return family.split(',').map(name => name.trim().replace(/^(['"])(.*)\1$/, '$2').toLowerCase())
50
50
  }
51
51
 
52
- /** @type {import('../transform/types.js').CssTransformPlugin} */
52
+ const checks = /** @type {const} */ ([])
53
+ /** @typedef {typeof checks[number]} Check */
54
+
55
+ /** @type {import('../transform/types.js').CssTransformPlugin<Check>} */
53
56
  export const fontsPlugin = {
57
+ checks,
54
58
  name: 'fonts',
55
59
  // Reads the declarations as authored, before spx rewrites the sizes the family follows.
56
60
  order: 5,
@@ -26,10 +26,11 @@ import { presetsPlugin } from './presets.js'
26
26
  import { shapeDividersPlugin } from './shapeDividers.js'
27
27
  import { layoutPlugin } from './layout/index.js'
28
28
  import { spxPlugin } from './spx/index.js'
29
+ import { spxThemeVariablesPlugin } from './spxThemeVariables/index.js'
29
30
  import { stackChildrenPlugin } from './stackChildren.js'
30
31
  import { wixVectorUrlsPlugin } from './wixVectorUrls.js'
31
32
 
32
- /** @type {readonly import('../transform/types.js').CssTransformPlugin[]} */
33
+ /** @type {readonly import('../transform/types.js').CssTransformPlugin<string>[]} */
33
34
  export const cssPlugins = Object.freeze([
34
35
  customMediaPlugin,
35
36
  fontsPlugin,
@@ -39,6 +40,7 @@ export const cssPlugins = Object.freeze([
39
40
  presetsPlugin,
40
41
  componentPositionPlugin,
41
42
  layoutPlugin,
43
+ spxThemeVariablesPlugin,
42
44
  spxPlugin,
43
45
  shapeDividersPlugin,
44
46
  wixVectorUrlsPlugin,
@@ -1,8 +1,15 @@
1
1
  import { compileContainers } from './compiler.js'
2
2
  import { parseTargets } from './parser.js'
3
3
 
4
- /** @type {import('../../transform/types.js').CssTransformPlugin} */
4
+ export const checks = /** @type {const} */ (['unknown_target', 'target_requires_component_id', 'invalid_target_envelope', 'target_not_site_layout', 'duplicate_target', 'target_requires_declarations', 'duplicate_target_property', 'property_wrong_target', 'content_position_not_relative', 'unsupported_overflow', 'unsupported_content_alignment', 'inconsistent_container_display', 'duplicate_root_property', 'property_requires_target'])
5
+ /** @typedef {typeof checks[number]} Check */
6
+
7
+ /** @template {Error} E @param {Check} check @param {E} error */
8
+ export const fail = (check, error) => Object.assign(error, { check })
9
+
10
+ /** @type {import('../../transform/types.js').CssTransformPlugin<Check>} */
5
11
  export const layoutPlugin = {
12
+ checks,
6
13
  name: 'layout',
7
14
  order: 15,
8
15
  validate: (root, context) => {
@@ -1,3 +1,4 @@
1
+ import { fail } from './index.js'
1
2
  import { basename } from 'node:path'
2
3
  import { isValidContainerId } from '@wix/origami-system-components/layout-contract'
3
4
  import { relativeToSite } from '../../transform/relativeToSite.js'
@@ -20,7 +21,7 @@ export const parseTargets = (root, context) => {
20
21
  const owners = new Map()
21
22
  root.walkAtRules('origami-target', target => {
22
23
  const name = target.params.trim()
23
- if (!Object.hasOwn(targets, name)) throw target.error(`Unknown @origami-target ${name}`)
24
+ if (!Object.hasOwn(targets, name)) throw fail('unknown_target', target.error(`Unknown @origami-target ${name}`))
24
25
  const owner = target.parent
25
26
  // DM writes component rules as .compId; #compId is still accepted.
26
27
  if (
@@ -28,34 +29,34 @@ export const parseTargets = (root, context) => {
28
29
  !['.', '#'].includes(owner.selector[0]) ||
29
30
  !isValidContainerId(owner.selector.slice(1))
30
31
  ) {
31
- throw target.error('@origami-target requires a single component ID rule')
32
+ throw fail('target_requires_component_id', target.error('@origami-target requires a single component ID rule'))
32
33
  }
33
34
  for (let ancestor = owner.parent; ancestor && ancestor.type !== 'root'; ancestor = ancestor.parent) {
34
35
  if (
35
36
  ancestor.type !== 'atrule' ||
36
37
  !['scope', 'media', 'layer', 'supports', 'container'].includes(ancestor.name)
37
38
  ) {
38
- throw target.error('@origami-target is only allowed inside layout rule envelopes')
39
+ throw fail('invalid_target_envelope', target.error('@origami-target is only allowed inside layout rule envelopes'))
39
40
  }
40
41
  }
41
42
  if (!isLayoutSource(target, context))
42
- throw target.error('@origami-target must originate in site-owned layout.css')
43
+ throw fail('target_not_site_layout', target.error('@origami-target must originate in site-owned layout.css'))
43
44
  const siblings = owners.get(owner) ?? []
44
- if (siblings.some(sibling => sibling.params.trim() === name)) throw target.error(`Duplicate target ${name}`)
45
+ if (siblings.some(sibling => sibling.params.trim() === name)) throw fail('duplicate_target', target.error(`Duplicate target ${name}`))
45
46
  const seen = new Set()
46
47
  for (const node of target.nodes ?? []) {
47
48
  if (node.type === 'comment') continue
48
- if (node.type !== 'decl') throw node.error('Target bodies must contain declarations only')
49
- if (seen.has(node.prop)) throw node.error(`Duplicate target property ${node.prop}`)
49
+ if (node.type !== 'decl') throw fail('target_requires_declarations', node.error('Target bodies must contain declarations only'))
50
+ if (seen.has(node.prop)) throw fail('duplicate_target_property', node.error(`Duplicate target property ${node.prop}`))
50
51
  seen.add(node.prop)
51
- if (!targets[name].has(node.prop)) throw node.error(`${node.prop} does not belong to ${name}`)
52
+ if (!targets[name].has(node.prop)) throw fail('property_wrong_target', node.error(`${node.prop} does not belong to ${name}`))
52
53
  if (node.prop === 'position' && node.value !== 'relative')
53
- throw node.error('Content position must be relative')
54
+ throw fail('content_position_not_relative', node.error('Content position must be relative'))
54
55
  if (name === OVERFLOW && !['auto', 'scroll', 'hidden', 'visible', 'clip'].includes(node.value)) {
55
- throw node.error(`Unsupported overflow value ${node.value}`)
56
+ throw fail('unsupported_overflow', node.error(`Unsupported overflow value ${node.value}`))
56
57
  }
57
58
  if (node.prop === '--content-max-width-align' && !['left', 'center', 'right'].includes(node.value)) {
58
- throw node.error(`Unsupported content alignment ${node.value}`)
59
+ throw fail('unsupported_content_alignment', node.error(`Unsupported content alignment ${node.value}`))
59
60
  }
60
61
  if (node.prop === 'display') {
61
62
  const type = owner.nodes.find(item => item.type === 'decl' && item.prop === '--container-layout-type')
@@ -64,7 +65,7 @@ export const parseTargets = (root, context) => {
64
65
  (type?.type === 'decl' &&
65
66
  (!Object.hasOwn(displays, type.value) || displays[type.value] !== node.value))
66
67
  ) {
67
- throw node.error('Container type and content display are inconsistent')
68
+ throw fail('inconsistent_container_display', node.error('Container type and content display are inconsistent'))
68
69
  }
69
70
  }
70
71
  }
@@ -75,10 +76,10 @@ export const parseTargets = (root, context) => {
75
76
  const seen = new Set()
76
77
  for (const node of owner.nodes) {
77
78
  if (node.type !== 'decl') continue
78
- if (seen.has(node.prop)) throw node.error(`Duplicate root property ${node.prop}`)
79
+ if (seen.has(node.prop)) throw fail('duplicate_root_property', node.error(`Duplicate root property ${node.prop}`))
79
80
  seen.add(node.prop)
80
81
  if ((targets[CONTENT].has(node.prop) && node.prop !== 'position') || targets[OVERFLOW].has(node.prop)) {
81
- throw node.error(`${node.prop} belongs inside @origami-target`)
82
+ throw fail('property_requires_target', node.error(`${node.prop} belongs inside @origami-target`))
82
83
  }
83
84
  }
84
85
  }
@@ -7,8 +7,15 @@ const MAX_CLONED_BYTES = 10 * 1024 * 1024
7
7
  /** @param {import('postcss').AtRule} atRule */
8
8
  const mixinName = atRule => atRule.params.trim().replace(/\(\)$/, '')
9
9
 
10
- /** @type {import('../transform/types.js').CssTransformPlugin} */
10
+ export const checks = /** @type {const} */ (['missing_mixin', 'cyclic_apply', 'expansion_depth_exceeded', 'cloned_node_budget_exceeded', 'cloned_css_budget_exceeded'])
11
+ /** @typedef {typeof checks[number]} Check */
12
+
13
+ /** @template {Error} E @param {Check} check @param {E} error */
14
+ export const fail = (check, error) => Object.assign(error, { check })
15
+
16
+ /** @type {import('../transform/types.js').CssTransformPlugin<Check>} */
11
17
  export const mixinsPlugin = {
18
+ checks,
12
19
  name: 'mixins',
13
20
  order: 10,
14
21
  transform(root) {
@@ -30,16 +37,16 @@ export const mixinsPlugin = {
30
37
  container.walkAtRules('apply', atRule => {
31
38
  const name = mixinName(atRule)
32
39
  const body = bodies.get(name)
33
- if (!body) throw new Error(`@apply ${atRule.params} has no @mixin definition`)
34
- if (stack.includes(name)) throw new Error(`Cyclic @apply: ${[...stack, name].join(' -> ')}`)
40
+ if (!body) throw fail('missing_mixin', new Error(`@apply ${atRule.params} has no @mixin definition`))
41
+ if (stack.includes(name)) throw fail('cyclic_apply', new Error(`Cyclic @apply: ${[...stack, name].join(' -> ')}`))
35
42
  if (stack.length >= MAX_EXPANSION_DEPTH) {
36
- throw new Error(`@apply ${name} exceeds maximum expansion depth (${MAX_EXPANSION_DEPTH})`)
43
+ throw fail('expansion_depth_exceeded', new Error(`@apply ${name} exceeds maximum expansion depth (${MAX_EXPANSION_DEPTH})`))
37
44
  }
38
45
  const pending = [...body]
39
46
  while (pending.length) {
40
47
  const node = pending.pop()
41
48
  if (++clonedNodes > MAX_CLONED_NODES) {
42
- throw new Error(`@apply ${name} exceeds stylesheet cloned-node budget (${MAX_CLONED_NODES})`)
49
+ throw fail('cloned_node_budget_exceeded', new Error(`@apply ${name} exceeds stylesheet cloned-node budget (${MAX_CLONED_NODES})`))
43
50
  }
44
51
  if (node && 'nodes' in node && node.nodes) {
45
52
  for (const child of node.nodes) pending.push(child)
@@ -48,7 +55,7 @@ export const mixinsPlugin = {
48
55
  for (const node of body) {
49
56
  clonedBytes += Buffer.byteLength((node.raws.before ?? '') + node.toString(), 'utf8')
50
57
  if (clonedBytes > MAX_CLONED_BYTES) {
51
- throw new Error(`@apply ${name} exceeds stylesheet cloned-CSS byte budget (${MAX_CLONED_BYTES})`)
58
+ throw fail('cloned_css_budget_exceeded', new Error(`@apply ${name} exceeds stylesheet cloned-CSS byte budget (${MAX_CLONED_BYTES})`))
52
59
  }
53
60
  }
54
61
  // PostCSS does not revisit nodes inserted in place of the current at-rule.
@@ -23,8 +23,12 @@ const target = (selector, key) => {
23
23
  return selector === first ? `.${id}${WRAPPER}` : null
24
24
  }
25
25
 
26
- /** @type {import('../transform/types.js').CssTransformPlugin} */
26
+ const checks = /** @type {const} */ ([])
27
+ /** @typedef {typeof checks[number]} Check */
28
+
29
+ /** @type {import('../transform/types.js').CssTransformPlugin<Check>} */
27
30
  export const presetsPlugin = {
31
+ checks,
28
32
  name: 'presets',
29
33
  order: 13,
30
34
  transform(root, context) {
@@ -101,8 +101,12 @@ const layerDeclarations = (compId, vars) => {
101
101
  }
102
102
  }
103
103
 
104
- /** @type {import('../transform/types.js').CssTransformPlugin} */
104
+ const checks = /** @type {const} */ ([])
105
+ /** @typedef {typeof checks[number]} Check */
106
+
107
+ /** @type {import('../transform/types.js').CssTransformPlugin<Check>} */
105
108
  export const shapeDividersPlugin = {
109
+ checks,
106
110
  name: 'shape-dividers',
107
111
  // Last, with wix-vector-urls: it only adds rules, which no other plugin needs to see, and it reads only `--height` as
108
112
  // `var(--height)`, so spx converts that var's `--spx()` where it is declared.
@@ -64,17 +64,26 @@ When settings are absent, expressions fall back to reference width 1280, scale m
64
64
  viewport width 100vw, scrollbar width 0px, stoppers 0px / 99999px and no cap. Nested components inherit settings unless
65
65
  they override them. As with any CSS custom property, an authored variable containing an
66
66
  SPX expression resolves where the variable is declared; inheriting the variable does not
67
- re-evaluate it using a child's settings.
67
+ re-evaluate it using a child's settings. So `../spxThemeVariables/` copies the SPX-dependent theme
68
+ variables onto capped containers.
69
+
70
+ ## Theme calls
71
+
72
+ `../spxThemeVariables/` rewrites each theme `--spx(N)` as `--theme-spx(N, W)`, W being the theme's
73
+ reference width; authors never write it. It compiles like `--spx(N)` but divides by the literal W
74
+ instead of `var(--spx-ref-width, 1280)`, so a copied theme value keeps its theme size while still
75
+ following the section's cap and scaling factor. N must be a literal number and W a positive one.
68
76
 
69
77
  ## Files and boundaries
70
78
 
71
79
  - `index.js` removes the SPX function definition and visits declarations.
72
80
  - `cascade.js` translates resolver settings and generates browser-evaluated expressions.
73
- - `calls.js` parses literal numeric SPX calls and rejects unsupported arguments.
81
+ - `calls.js` parses literal numeric `--spx()` and `--theme-spx()` calls and rejects unsupported arguments.
74
82
 
75
83
  Reference widths must be positive numbers and resolver modes must be `scale` or `fixed`.
76
84
  Stoppers and `--content-max-width` are not validated.
77
85
 
78
86
  `test/spx.browser.mjs` checks computed styles for the button example, nested overrides,
79
- settings in separate stylesheets, cascade priority, fixed fractions, signs, zero, stoppers and cap.
87
+ settings in separate stylesheets, cascade priority, fixed fractions, signs, zero, stoppers, cap
88
+ and theme copies on capped sections.
80
89
  Run it together with the container browser checks using the package's `test:browser` script.
@@ -1,4 +1,8 @@
1
+ import { fail } from './index.js'
1
2
  const CALL = /--spx\(\s*(-?(?:\d+\.?\d*|\.\d+))\s*\)/g
3
+ const THEME_CALL = /--theme-spx\(\s*(-?(?:\d+\.?\d*|\.\d+))\s*,\s*(\d+\.?\d*|\.\d+)\s*\)/g
4
+
5
+ export const THEME_SPX = '--theme-spx('
2
6
 
3
7
  /** Replace literal SPX calls, reporting unsupported arguments at their declaration.
4
8
  * @param {import('postcss').Declaration} declaration
@@ -8,5 +12,28 @@ const CALL = /--spx\(\s*(-?(?:\d+\.?\d*|\.\d+))\s*\)/g
8
12
  export const lowerCalls = (declaration, resolve, invalidCall) => {
9
13
  if (!declaration.value.includes('--spx(')) return
10
14
  declaration.value = declaration.value.replace(CALL, (_, literal) => resolve(Number(literal)))
11
- if (declaration.value.includes('--spx(')) throw invalidCall()
15
+ if (declaration.value.includes('--spx(')) throw fail('non_literal_spx_argument', invalidCall())
16
+ }
17
+
18
+ /**
19
+ * @param {import('postcss').Declaration} declaration
20
+ * @param {(value: number, themeRefWidth: number) => string} resolve
21
+ * @param {() => Error} invalidCall
22
+ */
23
+ export const lowerThemeCalls = (declaration, resolve, invalidCall) => {
24
+ if (!declaration.value.includes(THEME_SPX)) return
25
+ declaration.value = declaration.value.replace(THEME_CALL, (call, literal, themeRefWidthLiteral) => {
26
+ const themeRefWidth = Number(themeRefWidthLiteral)
27
+ if (themeRefWidth <= 0) return call
28
+ const number = Number(literal)
29
+ return resolve(number, themeRefWidth)
30
+ })
31
+ if (declaration.value.includes(THEME_SPX)) throw invalidCall()
12
32
  }
33
+
34
+ /**
35
+ * @param {string} value
36
+ * @param {number} themeRefWidth
37
+ */
38
+ export const toThemeCalls = (value, themeRefWidth) =>
39
+ value.replace(CALL, (_, literal) => `--theme-spx(${literal}, ${themeRefWidth})`)
@@ -1,22 +1,41 @@
1
- import { lowerCalls } from './calls.js'
1
+ import { fail } from './index.js'
2
+ import { lowerCalls, lowerThemeCalls } from './calls.js'
2
3
 
3
4
  // Keep arithmetic at the call site: an inherited derived custom property would already
4
5
  // be computed against its parent's settings. Only the numeric resolver switch inherits.
5
6
  const SCALE = 'var(--origami-spx-scale, 1)'
6
7
  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)`
8
+ /**
9
+ * @param {string} cap
10
+ * @param {string} [divisor]
11
+ */
12
+ const unitCappedAt = (cap, divisor = 'var(--spx-ref-width, 1280)') =>
13
+ `(${SCALE} * (clamp(var(--spx-stopper-min, 0px), var(--scaling-factor, 100vw), min(var(--spx-stopper-max, 99999px), ${cap})) - var(--scrollbar-width, 0px)) / ${divisor} + (1 - ${SCALE}) * 1px)`
10
14
  const UNIT = unitCappedAt('var(--content-max-width, 99999px)')
11
15
  const UNCAPPED_UNIT = unitCappedAt('99999px')
12
16
 
17
+ /** @param {import('postcss').Declaration} declaration */
18
+ const compileThemeCalls = declaration => {
19
+ const cap = declaration.prop === '--content-max-width' ? '99999px' : 'var(--content-max-width, 99999px)'
20
+ lowerThemeCalls(
21
+ declaration,
22
+ (number, themeRefWidth) => {
23
+ if (number === 0) return '0px'
24
+ const divisor = String(themeRefWidth)
25
+ const unit = unitCappedAt(cap, divisor)
26
+ return number < 0 ? `min(calc(-1 * ${FLOOR}), ${number} * ${unit})` : `max(${FLOOR}, ${number} * ${unit})`
27
+ },
28
+ () => declaration.error('SPX calls --theme-spx() without a literal number and a positive literal theme reference width')
29
+ )
30
+ }
31
+
13
32
  /** Compile settings and calls in place, retaining their cascade and source locations.
14
33
  * @param {import('postcss').Declaration} declaration
15
34
  */
16
35
  export const compileCascadeDeclaration = declaration => {
17
36
  if (declaration.prop === '--spx-resolver-type') {
18
37
  const resolver = declaration.value.trim()
19
- if (!['scale', 'fixed'].includes(resolver)) throw declaration.error('Unsupported SPX resolver')
38
+ if (!['scale', 'fixed'].includes(resolver)) throw fail('unsupported_spx_resolver', declaration.error('Unsupported SPX resolver'))
20
39
  const value = resolver === 'scale' ? '1' : '0'
21
40
  const previous = declaration.prev()
22
41
  if (
@@ -32,8 +51,9 @@ export const compileCascadeDeclaration = declaration => {
32
51
  declaration.prop === '--spx-ref-width' &&
33
52
  (!Number.isFinite(Number(declaration.value)) || Number(declaration.value) <= 0)
34
53
  ) {
35
- throw declaration.error('SPX reference width must be positive')
54
+ throw fail('invalid_spx_reference_width', declaration.error('SPX reference width must be positive'))
36
55
  }
56
+ compileThemeCalls(declaration)
37
57
  const unit = declaration.prop === '--content-max-width' ? UNCAPPED_UNIT : UNIT
38
58
  lowerCalls(
39
59
  declaration,
@@ -1,7 +1,14 @@
1
1
  import { compileCascadeDeclaration } from './cascade.js'
2
2
 
3
- /** @type {import('../../transform/types.js').CssTransformPlugin} */
3
+ export const checks = /** @type {const} */ (['unsupported_spx_resolver', 'invalid_spx_reference_width', 'non_literal_spx_argument'])
4
+ /** @typedef {typeof checks[number]} Check */
5
+
6
+ /** @template {Error} E @param {Check} check @param {E} error */
7
+ export const fail = (check, error) => Object.assign(error, { check })
8
+
9
+ /** @type {import('../../transform/types.js').CssTransformPlugin<Check>} */
4
10
  export const spxPlugin = {
11
+ checks,
5
12
  name: 'spx',
6
13
  order: 20,
7
14
  transform(root) {
@@ -0,0 +1,14 @@
1
+ # Theme SPX variables follow each section's settings
2
+
3
+ A custom property resolves `--spx()` where it is declared, so theme variables on `:root` ignore a section's `--content-max-width`. Like Thunderbolt, this plugin re-declares them on each capped section.
4
+
5
+ - **Theme:** the `:root` rules from `src/global/themes/theme.css`, at the top level or under one `(max-width: Npx)` `@media`. Theme variables are their custom properties except the SPX settings. Without a site folder in the context, the plugin does nothing.
6
+ - **Cascade:** at a breakpoint, the theme value of a variable is the one CSS gives `:root` there: among desktop, that breakpoint and every wider one, the declaration that comes last in the stylesheet wins, whatever the widths. So a wider block written after a narrower one overrides it.
7
+ - **Restate:** a theme variable holding `--spx(` that a breakpoint inherits, through the cascade, from a breakpoint with another `--spx-ref-width` is re-declared on that breakpoint's last theme `:root`, widest breakpoint first, so it is tagged with the breakpoint's own reference width, like the `:root` value it had before. When a narrower breakpoint that sets the variable comes before that `:root`, the restatement goes in a new `:root` block of that breakpoint placed just before the narrower one, so the narrower value still wins. `var()` readers are not restated, and neither is a value written after that last `:root`, since the restatement would lose to it.
8
+ - **Tag:** each theme `--spx(N)` becomes `--theme-spx(N, W)`, W being that breakpoint's `--spx-ref-width`, so a section's own reference width never changes it.
9
+ - **Capped:** a section whose desktop rule sets `--content-max-width`.
10
+ - **Copied:** desktop copies every SPX-dependent variable (it holds `--theme-spx(` or reads one through `var()`, at any depth), except `--content-max-width`. Each breakpoint copies the variables whose cascade winner it sets that are SPX-dependent or override a wider copy, plus inherited ones that first become SPX-dependent there. Copies are written widest first, so each breakpoint copy holds the value `:root` has there.
11
+ - **Written:** one `@layer origami-theme-copies` at the top, after any `@charset` and `@import`, so it is the lowest layer: any section rule wins, and a copy still beats the value inherited from `:root`. Inside it, one `:where(#a, #b, …)` rule for desktop, then one `@media` per breakpoint, widest first. Sections inside `@scope`, `@supports` or `@container` get their own list, wrapped in a copy of those wrapping at-rules, so the copies keep the section's condition. Copies drop `!important`. Nothing is written without copies or capped sections.
12
+ - **Order:** 16, after layout (15) and before SPX (20), which compiles `--theme-spx()`.
13
+
14
+ Code: `index.js` runs `stylesheetUtils.js` (scan), `themeUtils.js` (read), `copyPlanUtils.js` (breakpoint order, shared by restate and plan), `restateUtils.js`, `themeUtils.js` (tag), `sectionUtils.js` (with `wrappingAtRuleUtils.js`), `copyPlanUtils.js`, then `writeUtils.js`. Tests: `test/spxThemeVariables.spec.js`.
@@ -0,0 +1,36 @@
1
+ import { planBreakpoints, planThemeCopies } from './utils/copyPlanUtils.js'
2
+ import { readSectionRules } from './utils/sectionUtils.js'
3
+ import { restateThemeVariables } from './utils/restateUtils.js'
4
+ import { scanStylesheet } from './utils/stylesheetUtils.js'
5
+ import { readThemeVariables, tagThemeVariables } from './utils/themeUtils.js'
6
+ import { writeCopies } from './utils/writeUtils.js'
7
+
8
+ /**
9
+ * @param {import('postcss').Root} root
10
+ * @param {string} siteFolder
11
+ */
12
+ const copyThemeVariables = (root, siteFolder) => {
13
+ const { themeRules, layoutRules } = scanStylesheet(root, siteFolder)
14
+ const theme = readThemeVariables(themeRules)
15
+ const breakpointPlan = planBreakpoints(theme)
16
+ restateThemeVariables(theme, breakpointPlan)
17
+ tagThemeVariables(theme.rules.map(({ scanned }) => scanned), theme)
18
+ const sections = readSectionRules(layoutRules)
19
+ const themeCopies = planThemeCopies(theme, breakpointPlan)
20
+ writeCopies(root, themeCopies, sections)
21
+ }
22
+
23
+ const checks = /** @type {const} */ ([])
24
+ /** @typedef {typeof checks[number]} Check */
25
+
26
+ /** @type {import('../../transform/types.js').CssTransformPlugin<Check>} */
27
+ export const spxThemeVariablesPlugin = {
28
+ checks,
29
+ name: 'spx-theme-variables',
30
+ order: 16,
31
+ transform(root, context) {
32
+ const siteFolder = context?.layout?.root
33
+ if (siteFolder === undefined) return
34
+ copyThemeVariables(root, siteFolder)
35
+ },
36
+ }
@@ -0,0 +1,126 @@
1
+ import { DESKTOP, collapseWhitespace, isMedia } from './cssTreeUtils.js'
2
+
3
+ /** @typedef {Map<string, import('postcss').Declaration>} VariableMap */
4
+
5
+ /**
6
+ * @typedef {object} ThemeRule
7
+ * @property {import('./stylesheetUtils.js').ScannedRule} scanned
8
+ * @property {VariableMap} variables
9
+ */
10
+
11
+ /**
12
+ * @typedef {object} ThemeVariables
13
+ * @property {ThemeRule[]} rules
14
+ * @property {string[]} breakpoints
15
+ * @property {Map<string, number>} refWidthByBreakpoint
16
+ */
17
+
18
+ /**
19
+ * @typedef {object} CascadedVariable
20
+ * @property {import('postcss').Declaration} declaration
21
+ * @property {string} breakpoint
22
+ * @property {number} index
23
+ */
24
+
25
+ const DEFAULT_REF_WIDTH = 1280
26
+
27
+ const MAX_WIDTH = /^\(\s*max-width\s*:\s*(\d+(?:\.\d+)?)px\s*\)$/i
28
+
29
+ /** @param {string} query */
30
+ export const maxWidthOf = query => {
31
+ const trimmed = query.trim()
32
+ const match = MAX_WIDTH.exec(trimmed)
33
+ return match === null ? undefined : Number(match[1])
34
+ }
35
+
36
+ /** @param {string} query */
37
+ const breakpointKeyOf = query => {
38
+ const maxWidth = maxWidthOf(query)
39
+ return maxWidth === undefined ? collapseWhitespace(query) : `(max-width: ${maxWidth}px)`
40
+ }
41
+
42
+ /** @param {readonly import('postcss').Node[]} ancestors */
43
+ export const breakpointOf = ancestors => {
44
+ const media = ancestors.find(isMedia)
45
+ return media === undefined ? DESKTOP : breakpointKeyOf(media.params)
46
+ }
47
+
48
+ /** @param {readonly string[]} breakpoints */
49
+ export const widestFirst = breakpoints =>
50
+ [...breakpoints].sort((left, right) => (maxWidthOf(right) ?? 0) - (maxWidthOf(left) ?? 0))
51
+
52
+ /** @param {ThemeVariables} theme */
53
+ export const themeBreakpointsOf = ({ breakpoints }) => breakpoints
54
+
55
+ /**
56
+ * @param {readonly string[]} breakpoints
57
+ * @param {string} breakpoint
58
+ */
59
+ const enclosingBreakpointsOf = (breakpoints, breakpoint) => {
60
+ const maxWidth = maxWidthOf(breakpoint)
61
+ if (maxWidth === undefined) return [breakpoint]
62
+ return breakpoints.filter(candidate => (maxWidthOf(candidate) ?? -1) >= maxWidth)
63
+ }
64
+
65
+ /**
66
+ * @param {ThemeVariables} theme
67
+ * @param {string} breakpoint
68
+ */
69
+ export const inheritedBreakpointsOf = ({ breakpoints }, breakpoint) => {
70
+ if (breakpoint === DESKTOP) return []
71
+ const enclosing = enclosingBreakpointsOf(breakpoints, breakpoint)
72
+ const outer = enclosing.filter(candidate => candidate !== breakpoint)
73
+ return [DESKTOP, ...outer]
74
+ }
75
+
76
+ /**
77
+ * @param {ThemeRule} rule
78
+ * @param {number} index
79
+ * @returns {[string, CascadedVariable][]}
80
+ */
81
+ const cascadedVariablesOf = ({ scanned, variables }, index) => {
82
+ const { breakpoint } = scanned
83
+ return [...variables].map(([prop, declaration]) => [prop, { declaration, breakpoint, index }])
84
+ }
85
+
86
+ /**
87
+ * @param {ThemeVariables} theme
88
+ * @param {readonly string[]} cascadeBreakpoints
89
+ * @returns {Map<string, CascadedVariable>}
90
+ */
91
+ export const cascadeAt = ({ rules }, cascadeBreakpoints) => {
92
+ const matching = new Set(cascadeBreakpoints)
93
+ const entries = rules.flatMap((rule, index) => (matching.has(rule.scanned.breakpoint) ? cascadedVariablesOf(rule, index) : []))
94
+ return new Map(entries)
95
+ }
96
+
97
+ /**
98
+ * @param {readonly string[]} breakpoints
99
+ * @param {Map<string, number>} ownRefWidths
100
+ * @param {string} breakpoint
101
+ */
102
+ const resolveRefWidth = (breakpoints, ownRefWidths, breakpoint) => {
103
+ const own = ownRefWidths.get(breakpoint)
104
+ if (own !== undefined) return own
105
+ const enclosing = enclosingBreakpointsOf(breakpoints, breakpoint)
106
+ const defined = enclosing.flatMap(candidate => ownRefWidths.get(candidate) ?? [])
107
+ return defined.at(-1) ?? ownRefWidths.get(DESKTOP) ?? DEFAULT_REF_WIDTH
108
+ }
109
+
110
+ /**
111
+ * @param {readonly string[]} breakpoints
112
+ * @param {Map<string, number>} ownRefWidths
113
+ */
114
+ export const resolveRefWidths = (breakpoints, ownRefWidths) => {
115
+ const entries = [DESKTOP, ...breakpoints].map(breakpoint => {
116
+ const refWidth = resolveRefWidth(breakpoints, ownRefWidths, breakpoint)
117
+ return /** @type {const} */ ([breakpoint, refWidth])
118
+ })
119
+ return new Map(entries)
120
+ }
121
+
122
+ /**
123
+ * @param {ThemeVariables} theme
124
+ * @param {string} breakpoint
125
+ */
126
+ export const refWidthAt = ({ refWidthByBreakpoint }, breakpoint) => refWidthByBreakpoint.get(breakpoint) ?? DEFAULT_REF_WIDTH