@wix/origami-transforms 0.1.29 → 0.1.31

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 (27) hide show
  1. package/dist/cssPlugins/spx/calls.d.ts +3 -0
  2. package/dist/cssPlugins/spxThemeVariables/index.d.ts +6 -0
  3. package/dist/cssPlugins/spxThemeVariables/utils/breakpointUtils.d.ts +23 -0
  4. package/dist/cssPlugins/spxThemeVariables/utils/copyPlanUtils.d.ts +14 -0
  5. package/dist/cssPlugins/spxThemeVariables/utils/cssTreeUtils.d.ts +10 -0
  6. package/dist/cssPlugins/spxThemeVariables/utils/restateUtils.d.ts +9 -0
  7. package/dist/cssPlugins/spxThemeVariables/utils/sectionUtils.d.ts +6 -0
  8. package/dist/cssPlugins/spxThemeVariables/utils/stylesheetUtils.d.ts +10 -0
  9. package/dist/cssPlugins/spxThemeVariables/utils/themeUtils.d.ts +6 -0
  10. package/dist/cssPlugins/spxThemeVariables/utils/wrappingAtRuleUtils.d.ts +10 -0
  11. package/dist/cssPlugins/spxThemeVariables/utils/writeUtils.d.ts +17 -0
  12. package/package.json +3 -3
  13. package/src/cssPlugins/index.js +2 -0
  14. package/src/cssPlugins/spx/README.md +12 -3
  15. package/src/cssPlugins/spx/calls.js +26 -0
  16. package/src/cssPlugins/spx/cascade.js +23 -4
  17. package/src/cssPlugins/spxThemeVariables/README.md +14 -0
  18. package/src/cssPlugins/spxThemeVariables/index.js +36 -0
  19. package/src/cssPlugins/spxThemeVariables/utils/breakpointUtils.js +126 -0
  20. package/src/cssPlugins/spxThemeVariables/utils/copyPlanUtils.js +162 -0
  21. package/src/cssPlugins/spxThemeVariables/utils/cssTreeUtils.js +58 -0
  22. package/src/cssPlugins/spxThemeVariables/utils/restateUtils.js +134 -0
  23. package/src/cssPlugins/spxThemeVariables/utils/sectionUtils.js +32 -0
  24. package/src/cssPlugins/spxThemeVariables/utils/stylesheetUtils.js +98 -0
  25. package/src/cssPlugins/spxThemeVariables/utils/themeUtils.js +90 -0
  26. package/src/cssPlugins/spxThemeVariables/utils/wrappingAtRuleUtils.js +68 -0
  27. package/src/cssPlugins/spxThemeVariables/utils/writeUtils.js +98 -0
@@ -1 +1,4 @@
1
+ export const THEME_SPX: "--theme-spx(";
1
2
  export function lowerCalls(declaration: import("postcss").Declaration, resolve: (value: number) => string, invalidCall: () => Error): void;
3
+ export function lowerThemeCalls(declaration: import("postcss").Declaration, resolve: (value: number, themeRefWidth: number) => string, invalidCall: () => Error): void;
4
+ export function toThemeCalls(value: string, themeRefWidth: number): string;
@@ -0,0 +1,6 @@
1
+ /** @typedef {typeof checks[number]} Check */
2
+ /** @type {import('../../transform/types.js').CssTransformPlugin<Check>} */
3
+ export const spxThemeVariablesPlugin: import("../../transform/types.js").CssTransformPlugin<Check>;
4
+ export type Check = (typeof checks)[number];
5
+ declare const checks: readonly [];
6
+ export {};
@@ -0,0 +1,23 @@
1
+ export function maxWidthOf(query: string): number | undefined;
2
+ export function breakpointOf(ancestors: readonly import("postcss").Node[]): string;
3
+ export function widestFirst(breakpoints: readonly string[]): string[];
4
+ export function themeBreakpointsOf({ breakpoints }: ThemeVariables): string[];
5
+ export function inheritedBreakpointsOf({ breakpoints }: ThemeVariables, breakpoint: string): string[];
6
+ export function cascadeAt({ rules }: ThemeVariables, cascadeBreakpoints: readonly string[]): Map<string, CascadedVariable>;
7
+ export function resolveRefWidths(breakpoints: readonly string[], ownRefWidths: Map<string, number>): Map<string, number>;
8
+ export function refWidthAt({ refWidthByBreakpoint }: ThemeVariables, breakpoint: string): number;
9
+ export type VariableMap = Map<string, import("postcss").Declaration>;
10
+ export type ThemeRule = {
11
+ scanned: import("./stylesheetUtils.js").ScannedRule;
12
+ variables: VariableMap;
13
+ };
14
+ export type ThemeVariables = {
15
+ rules: ThemeRule[];
16
+ breakpoints: string[];
17
+ refWidthByBreakpoint: Map<string, number>;
18
+ };
19
+ export type CascadedVariable = {
20
+ declaration: import("postcss").Declaration;
21
+ breakpoint: string;
22
+ index: number;
23
+ };
@@ -0,0 +1,14 @@
1
+ export function planBreakpoints(theme: ThemeVariables): BreakpointPlan;
2
+ export function planThemeCopies(theme: ThemeVariables, breakpointPlan: BreakpointPlan): ThemeCopies;
3
+ export type CascadedVariable = import("./breakpointUtils.js").CascadedVariable;
4
+ export type ThemeVariables = import("./breakpointUtils.js").ThemeVariables;
5
+ export type VariableMap = import("./breakpointUtils.js").VariableMap;
6
+ export type Declaration = import("postcss").Declaration;
7
+ export type ThemeCopies = Map<string, VariableMap>;
8
+ export type VariableReferences = Map<string, string[]>;
9
+ export type BreakpointPlan = Map<string, string[]>;
10
+ export type CopyCriteria = {
11
+ ownVariables: VariableMap;
12
+ scaleDependent: Set<string>;
13
+ widerCopied: Set<string>;
14
+ };
@@ -0,0 +1,10 @@
1
+ export const DESKTOP: "";
2
+ export function ancestorsOf(node: import("postcss").Node): import("postcss").Node[];
3
+ export function isAtRule(node: import("postcss").Node): node is import("postcss").AtRule;
4
+ export function collapseWhitespace(text: string): string;
5
+ export function isInContainerDefaults(ancestors: readonly import("postcss").Node[]): boolean;
6
+ export function isTopLevel(ancestors: readonly import("postcss").Node[]): ancestors is readonly import("postcss").AtRule[];
7
+ export function hasRootSelector(rule: import("postcss").Rule): boolean;
8
+ export function isCustomProperty(prop: string): boolean;
9
+ export function isMedia(node: import("postcss").Node): node is import("postcss").AtRule;
10
+ export function declarationsOf(rule: import("postcss").Rule): import("postcss").Declaration[];
@@ -0,0 +1,9 @@
1
+ export function restateThemeVariables(theme: ThemeVariables, breakpointPlan: BreakpointPlan): void;
2
+ export type CascadedVariable = import("./breakpointUtils.js").CascadedVariable;
3
+ export type ThemeVariables = import("./breakpointUtils.js").ThemeVariables;
4
+ export type BreakpointPlan = import("./copyPlanUtils.js").BreakpointPlan;
5
+ export type RestateTarget = {
6
+ breakpoint: string;
7
+ index: number;
8
+ refWidth: number;
9
+ };
@@ -0,0 +1,6 @@
1
+ /** @typedef {import('./stylesheetUtils.js').ScannedRule} ScannedRule */
2
+ /** @typedef {import('./wrappingAtRuleUtils.js').Section} Section */
3
+ export const CONTENT_MAX_WIDTH: "--content-max-width";
4
+ export function readSectionRules(layoutRules: readonly ScannedRule[]): Section[];
5
+ export type ScannedRule = import("./stylesheetUtils.js").ScannedRule;
6
+ export type Section = import("./wrappingAtRuleUtils.js").Section;
@@ -0,0 +1,10 @@
1
+ export function scanStylesheet(root: import("postcss").Root, siteFolder: string): StylesheetScan;
2
+ export type ScannedRule = {
3
+ rule: import("postcss").Rule;
4
+ breakpoint: string;
5
+ declarations: import("postcss").Declaration[];
6
+ };
7
+ export type StylesheetScan = {
8
+ themeRules: ScannedRule[];
9
+ layoutRules: ScannedRule[];
10
+ };
@@ -0,0 +1,6 @@
1
+ export function readThemeVariables(themeRules: readonly ScannedRule[]): ThemeVariables;
2
+ export function tagThemeVariables(themeRules: readonly ScannedRule[], theme: ThemeVariables): void;
3
+ export type ThemeRule = import("./breakpointUtils.js").ThemeRule;
4
+ export type ThemeVariables = import("./breakpointUtils.js").ThemeVariables;
5
+ export type VariableMap = import("./breakpointUtils.js").VariableMap;
6
+ export type ScannedRule = import("./stylesheetUtils.js").ScannedRule;
@@ -0,0 +1,10 @@
1
+ export function wrappingAtRulesOf(rule: import("postcss").Rule): import("postcss").AtRule[];
2
+ export function groupSectionsByWrappingAtRules(sections: readonly Section[]): SectionGroup[];
3
+ export type Section = {
4
+ selector: string;
5
+ wrappingAtRules: import("postcss").AtRule[];
6
+ };
7
+ export type SectionGroup = {
8
+ wrappingAtRules: import("postcss").AtRule[];
9
+ selectors: string[];
10
+ };
@@ -0,0 +1,17 @@
1
+ /** @typedef {import('./copyPlanUtils.js').ThemeCopies} ThemeCopies */
2
+ /** @typedef {import('./wrappingAtRuleUtils.js').Section} Section */
3
+ /** @typedef {import('./wrappingAtRuleUtils.js').SectionGroup} SectionGroup */
4
+ /**
5
+ * @typedef {object} BreakpointCopies
6
+ * @property {string} breakpoint
7
+ * @property {import('postcss').Declaration[]} declarations
8
+ */
9
+ export const THEME_COPIES_LAYER: "origami-theme-copies";
10
+ export function writeCopies(root: import("postcss").Root, themeCopies: ThemeCopies, sections: readonly Section[]): void;
11
+ export type ThemeCopies = import("./copyPlanUtils.js").ThemeCopies;
12
+ export type Section = import("./wrappingAtRuleUtils.js").Section;
13
+ export type SectionGroup = import("./wrappingAtRuleUtils.js").SectionGroup;
14
+ export type BreakpointCopies = {
15
+ breakpoint: string;
16
+ declarations: import("postcss").Declaration[];
17
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wix/origami-transforms",
3
- "version": "0.1.29",
3
+ "version": "0.1.31",
4
4
  "description": "Babel and PostCSS checks and transforms for Wix origami page files.",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -23,7 +23,7 @@
23
23
  "@wix/origami-system-components": "1.10.6",
24
24
  "postcss": "^8.5.3",
25
25
  "postcss-custom-media": "^12.0.1",
26
- "@wix/origami-bi": "0.10.0"
26
+ "@wix/origami-bi": "0.11.0"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/node": "^22.0.0",
@@ -71,5 +71,5 @@
71
71
  },
72
72
  "type": "module",
73
73
  "types": "./dist/index.d.ts",
74
- "falconPackageHash": "26f18af79b8f5dd98a7aba58e68be7f9dd09c52bdd522e0eb33180c4"
74
+ "falconPackageHash": "eea52f454e6f8d604431d5d69e6ce0bae25496f2528518f4dbdb43dd"
75
75
  }
@@ -26,6 +26,7 @@ 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
 
@@ -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,
@@ -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,5 +1,8 @@
1
1
  import { fail } from './index.js'
2
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('
3
6
 
4
7
  /** Replace literal SPX calls, reporting unsupported arguments at their declaration.
5
8
  * @param {import('postcss').Declaration} declaration
@@ -11,3 +14,26 @@ export const lowerCalls = (declaration, resolve, invalidCall) => {
11
14
  declaration.value = declaration.value.replace(CALL, (_, literal) => resolve(Number(literal)))
12
15
  if (declaration.value.includes('--spx(')) throw fail('non_literal_spx_argument', invalidCall())
13
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()
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,16 +1,34 @@
1
1
  import { fail } from './index.js'
2
- import { lowerCalls } from './calls.js'
2
+ import { lowerCalls, lowerThemeCalls } from './calls.js'
3
3
 
4
4
  // Keep arithmetic at the call site: an inherited derived custom property would already
5
5
  // be computed against its parent's settings. Only the numeric resolver switch inherits.
6
6
  const SCALE = 'var(--origami-spx-scale, 1)'
7
7
  const FLOOR = `calc(0.5px * ${SCALE})`
8
- /** @param {string} cap */
9
- const unitCappedAt = cap =>
10
- `(${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)`
11
14
  const UNIT = unitCappedAt('var(--content-max-width, 99999px)')
12
15
  const UNCAPPED_UNIT = unitCappedAt('99999px')
13
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
+
14
32
  /** Compile settings and calls in place, retaining their cascade and source locations.
15
33
  * @param {import('postcss').Declaration} declaration
16
34
  */
@@ -35,6 +53,7 @@ export const compileCascadeDeclaration = declaration => {
35
53
  ) {
36
54
  throw fail('invalid_spx_reference_width', declaration.error('SPX reference width must be positive'))
37
55
  }
56
+ compileThemeCalls(declaration)
38
57
  const unit = declaration.prop === '--content-max-width' ? UNCAPPED_UNIT : UNIT
39
58
  lowerCalls(
40
59
  declaration,
@@ -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
@@ -0,0 +1,162 @@
1
+ import { THEME_SPX } from '../../spx/calls.js'
2
+ import { cascadeAt, inheritedBreakpointsOf, themeBreakpointsOf, widestFirst } from './breakpointUtils.js'
3
+ import { DESKTOP } from './cssTreeUtils.js'
4
+ import { CONTENT_MAX_WIDTH } from './sectionUtils.js'
5
+
6
+ /** @typedef {import('./breakpointUtils.js').CascadedVariable} CascadedVariable */
7
+ /** @typedef {import('./breakpointUtils.js').ThemeVariables} ThemeVariables */
8
+ /** @typedef {import('./breakpointUtils.js').VariableMap} VariableMap */
9
+ /** @typedef {import('postcss').Declaration} Declaration */
10
+ /** @typedef {Map<string, VariableMap>} ThemeCopies */
11
+ /** @typedef {Map<string, string[]>} VariableReferences */
12
+ /** @typedef {Map<string, string[]>} BreakpointPlan */
13
+
14
+ const VAR_REFERENCE = /var\(\s*(--[\w-]+)/g
15
+
16
+ /** @param {ThemeVariables} theme */
17
+ const getPlanOrder = theme => {
18
+ const breakpoints = themeBreakpointsOf(theme)
19
+ const widest = widestFirst(breakpoints)
20
+ return [DESKTOP, ...widest]
21
+ }
22
+
23
+ /**
24
+ * @param {ThemeVariables} theme
25
+ * @returns {BreakpointPlan}
26
+ */
27
+ export const planBreakpoints = theme => {
28
+ const breakpoints = getPlanOrder(theme)
29
+ const entries = breakpoints.map(breakpoint => {
30
+ const inheritedBreakpoints = inheritedBreakpointsOf(theme, breakpoint)
31
+ return /** @type {const} */ ([breakpoint, inheritedBreakpoints])
32
+ })
33
+ return new Map(entries)
34
+ }
35
+
36
+ /** @param {Declaration} declaration */
37
+ const getReferencedNames = declaration => {
38
+ const matches = [...declaration.value.matchAll(VAR_REFERENCE)]
39
+ return matches.map(([, name]) => name)
40
+ }
41
+
42
+ /**
43
+ * @param {VariableMap} variables
44
+ * @returns {VariableReferences}
45
+ */
46
+ const buildReferenceMap = variables => {
47
+ /** @type {VariableReferences} */
48
+ const references = new Map()
49
+ for (const [prop, declaration] of variables) {
50
+ const names = getReferencedNames(declaration)
51
+ references.set(prop, names)
52
+ }
53
+ return references
54
+ }
55
+
56
+ /**
57
+ * @param {Set<string>} targets
58
+ * @param {readonly string[]} names
59
+ */
60
+ const readsAny = (targets, names) => names.some(name => targets.has(name))
61
+
62
+ /**
63
+ * @param {VariableReferences} references
64
+ * @param {Set<string>} seeds
65
+ */
66
+ const collectReaders = (references, seeds) => {
67
+ const reached = new Set(seeds)
68
+ let previousSize = -1
69
+ while (reached.size !== previousSize) {
70
+ previousSize = reached.size
71
+ for (const [prop, names] of references) {
72
+ if (readsAny(reached, names)) reached.add(prop)
73
+ }
74
+ }
75
+ return reached
76
+ }
77
+
78
+ /** @param {VariableMap} variables */
79
+ const findScaleDependent = variables => {
80
+ const scaledVariables = [...variables].filter(([, declaration]) => declaration.value.includes(THEME_SPX))
81
+ const scaledProps = scaledVariables.map(([prop]) => prop)
82
+ const scaled = new Set(scaledProps)
83
+ const references = buildReferenceMap(variables)
84
+ return collectReaders(references, scaled)
85
+ }
86
+
87
+ /**
88
+ * @param {Map<string, CascadedVariable>} cascaded
89
+ * @returns {VariableMap}
90
+ */
91
+ const mergeCascade = cascaded => {
92
+ const variables = [...cascaded].map(([prop, { declaration }]) => /** @type {const} */ ([prop, declaration]))
93
+ return new Map(variables)
94
+ }
95
+
96
+ /**
97
+ * @param {Map<string, CascadedVariable>} cascaded
98
+ * @param {string} breakpoint
99
+ * @returns {VariableMap}
100
+ */
101
+ const ownVariablesOf = (cascaded, breakpoint) => {
102
+ const own = [...cascaded].filter(([, cascadedVariable]) => cascadedVariable.breakpoint === breakpoint)
103
+ const variables = own.map(([prop, { declaration }]) => /** @type {const} */ ([prop, declaration]))
104
+ return new Map(variables)
105
+ }
106
+
107
+ /**
108
+ * @param {ThemeCopies} themeCopies
109
+ * @param {readonly string[]} inheritedBreakpoints
110
+ */
111
+ const getWiderCopiedNames = (themeCopies, inheritedBreakpoints) => {
112
+ const props = inheritedBreakpoints.flatMap(breakpoint => {
113
+ const copies = themeCopies.get(breakpoint) ?? new Map()
114
+ return [...copies.keys()]
115
+ })
116
+ return new Set(props)
117
+ }
118
+
119
+ /**
120
+ * @typedef {object} CopyCriteria
121
+ * @property {VariableMap} ownVariables
122
+ * @property {Set<string>} scaleDependent
123
+ * @property {Set<string>} widerCopied
124
+ */
125
+
126
+ /**
127
+ * @param {string} prop
128
+ * @param {CopyCriteria} criteria
129
+ */
130
+ const shouldCopy = (prop, { ownVariables, scaleDependent, widerCopied }) =>
131
+ ownVariables.has(prop)
132
+ ? scaleDependent.has(prop) || widerCopied.has(prop)
133
+ : scaleDependent.has(prop) && !widerCopied.has(prop)
134
+
135
+ /**
136
+ * @param {string} prop
137
+ * @param {CopyCriteria} criteria
138
+ */
139
+ const shouldCopyVariable = (prop, criteria) => prop !== CONTENT_MAX_WIDTH && shouldCopy(prop, criteria)
140
+
141
+ /**
142
+ * @param {ThemeVariables} theme
143
+ * @param {BreakpointPlan} breakpointPlan
144
+ * @returns {ThemeCopies}
145
+ */
146
+ export const planThemeCopies = (theme, breakpointPlan) => {
147
+ /** @type {ThemeCopies} */
148
+ const themeCopies = new Map()
149
+ for (const [breakpoint, inheritedBreakpoints] of breakpointPlan) {
150
+ const cascadeBreakpoints = [...inheritedBreakpoints, breakpoint]
151
+ const cascaded = cascadeAt(theme, cascadeBreakpoints)
152
+ const ownVariables = ownVariablesOf(cascaded, breakpoint)
153
+ const effectiveVariables = mergeCascade(cascaded)
154
+ const scaleDependent = findScaleDependent(effectiveVariables)
155
+ const widerCopied = getWiderCopiedNames(themeCopies, inheritedBreakpoints)
156
+ const criteria = { ownVariables, scaleDependent, widerCopied }
157
+ const copiedEntries = [...effectiveVariables].filter(([prop]) => shouldCopyVariable(prop, criteria))
158
+ const copies = new Map(copiedEntries)
159
+ themeCopies.set(breakpoint, copies)
160
+ }
161
+ return themeCopies
162
+ }
@@ -0,0 +1,58 @@
1
+ import { DEFAULTS_LAYER } from '../../layout/contract.js'
2
+
3
+ export const DESKTOP = ''
4
+
5
+ /**
6
+ * @param {import('postcss').Node} node
7
+ * @returns {import('postcss').Node[]}
8
+ */
9
+ export const ancestorsOf = node => {
10
+ /** @type {import('postcss').Node[]} */
11
+ const ancestors = []
12
+ let current = node.parent
13
+ while (current !== undefined && current.type !== 'root' && current.type !== 'document') {
14
+ ancestors.push(current)
15
+ current = current.parent
16
+ }
17
+ return ancestors
18
+ }
19
+
20
+ /**
21
+ * @param {import('postcss').Node} node
22
+ * @returns {node is import('postcss').AtRule}
23
+ */
24
+ export const isAtRule = node => node.type === 'atrule'
25
+
26
+ /** @param {string} text */
27
+ export const collapseWhitespace = text => text.replace(/\s+/g, ' ').trim()
28
+
29
+ /**
30
+ * @param {import('postcss').Node} node
31
+ * @param {string} name
32
+ */
33
+ const isLayerNamed = (node, name) => isAtRule(node) && node.name === 'layer' && node.params.trim() === name
34
+
35
+ /** @param {readonly import('postcss').Node[]} ancestors */
36
+ export const isInContainerDefaults = ancestors => ancestors.some(node => isLayerNamed(node, DEFAULTS_LAYER))
37
+
38
+ /** @param {readonly import('postcss').Node[]} ancestors */
39
+ export const isTopLevel = ancestors => ancestors.every(isAtRule)
40
+
41
+ /** @param {import('postcss').Rule} rule */
42
+ export const hasRootSelector = rule => rule.selector.trim() === ':root'
43
+
44
+ /** @param {string} prop */
45
+ export const isCustomProperty = prop => prop.startsWith('--')
46
+
47
+ /**
48
+ * @param {import('postcss').Node} node
49
+ * @returns {node is import('postcss').AtRule}
50
+ */
51
+ export const isMedia = node => isAtRule(node) && node.name === 'media'
52
+
53
+ /**
54
+ * @param {import('postcss').Rule} rule
55
+ * @returns {import('postcss').Declaration[]}
56
+ */
57
+ export const declarationsOf = rule =>
58
+ rule.nodes.filter(/** @returns {node is import('postcss').Declaration} */ node => node.type === 'decl')
@@ -0,0 +1,134 @@
1
+ import { cascadeAt, maxWidthOf, refWidthAt } from './breakpointUtils.js'
2
+ import { isAtRule } from './cssTreeUtils.js'
3
+
4
+ /** @typedef {import('./breakpointUtils.js').CascadedVariable} CascadedVariable */
5
+ /** @typedef {import('./breakpointUtils.js').ThemeVariables} ThemeVariables */
6
+ /** @typedef {import('./copyPlanUtils.js').BreakpointPlan} BreakpointPlan */
7
+
8
+ /**
9
+ * @typedef {object} RestateTarget
10
+ * @property {string} breakpoint
11
+ * @property {number} index
12
+ * @property {number} refWidth
13
+ */
14
+
15
+ const SPX_CALL = '--spx('
16
+
17
+ /**
18
+ * @param {ThemeVariables} theme
19
+ * @param {string} breakpoint
20
+ */
21
+ const lastIndexAt = ({ rules }, breakpoint) => {
22
+ const indexes = rules.flatMap(({ scanned }, index) => (scanned.breakpoint === breakpoint ? [index] : []))
23
+ return indexes.at(-1)
24
+ }
25
+
26
+ /**
27
+ * @param {ThemeVariables} theme
28
+ * @param {RestateTarget} target
29
+ * @param {[string, CascadedVariable]} entry
30
+ */
31
+ const needsRestate = (theme, target, [, source]) => {
32
+ if (source.breakpoint === target.breakpoint) return false
33
+ if (source.index > target.index) return false
34
+ if (!source.declaration.value.includes(SPX_CALL)) return false
35
+ const sourceRefWidth = refWidthAt(theme, source.breakpoint)
36
+ return sourceRefWidth !== target.refWidth
37
+ }
38
+
39
+ /**
40
+ * @param {ThemeVariables} theme
41
+ * @param {RestateTarget} target
42
+ * @param {[string, CascadedVariable]} entry
43
+ */
44
+ const narrowerRuleIndexOf = ({ rules }, target, [prop, source]) => {
45
+ const targetWidth = maxWidthOf(target.breakpoint)
46
+ if (targetWidth === undefined) return -1
47
+ const sourceIndex = rules.findIndex(({ scanned }) => scanned.rule === source.declaration.parent)
48
+ const declaresNarrower = ({ scanned, variables }, index) => {
49
+ const width = maxWidthOf(scanned.breakpoint)
50
+ return index > sourceIndex && width !== undefined && width < targetWidth && variables.has(prop)
51
+ }
52
+ return rules.findIndex(declaresNarrower)
53
+ }
54
+
55
+ /**
56
+ * @param {import('./breakpointUtils.js').ThemeRule} target
57
+ * @param {[string, CascadedVariable]} entry
58
+ */
59
+ const restateInto = ({ scanned, variables }, [prop, { declaration }]) => {
60
+ const clone = declaration.clone({ raws: {} })
61
+ scanned.rule.append(clone)
62
+ scanned.declarations.push(clone)
63
+ variables.set(prop, clone)
64
+ }
65
+
66
+ /**
67
+ * @param {import('postcss').Declaration} clone
68
+ * @param {import('postcss').Rule} targetRule
69
+ * @param {import('postcss').AtRule} targetMedia
70
+ */
71
+ const wrapperOf = (clone, targetRule, targetMedia) => {
72
+ const rule = targetRule.clone({ raws: {} }).removeAll()
73
+ rule.append(clone)
74
+ const wrapper = targetMedia.clone({ raws: {} }).removeAll()
75
+ wrapper.append(rule)
76
+ return { rule, wrapper }
77
+ }
78
+
79
+ /**
80
+ * @param {ThemeVariables} theme
81
+ * @param {import('./breakpointUtils.js').ThemeRule} targetRule
82
+ * @param {number} index
83
+ * @param {[string, CascadedVariable]} entry
84
+ */
85
+ const restateBefore = (theme, targetRule, index, entry) => {
86
+ const [prop, { declaration }] = entry
87
+ const targetMedia = targetRule.scanned.rule.parent
88
+ const narrowerMedia = theme.rules[index].scanned.rule.parent
89
+ if (targetMedia === undefined || narrowerMedia === undefined) return restateInto(targetRule, entry)
90
+ if (!isAtRule(targetMedia) || !isAtRule(narrowerMedia)) return restateInto(targetRule, entry)
91
+ const clone = declaration.clone({ raws: {} })
92
+ const { rule, wrapper } = wrapperOf(clone, targetRule.scanned.rule, targetMedia)
93
+ narrowerMedia.before(wrapper)
94
+ const { breakpoint } = targetRule.scanned
95
+ const scanned = { rule, breakpoint, declarations: [clone] }
96
+ theme.rules.splice(index, 0, { scanned, variables: new Map([[prop, clone]]) })
97
+ }
98
+
99
+ /**
100
+ * @param {ThemeVariables} theme
101
+ * @param {import('./breakpointUtils.js').ThemeRule} targetRule
102
+ * @param {RestateTarget} target
103
+ * @param {[string, CascadedVariable]} entry
104
+ */
105
+ const restate = (theme, targetRule, target, entry) => {
106
+ const index = narrowerRuleIndexOf(theme, target, entry)
107
+ const targetIndex = theme.rules.indexOf(targetRule)
108
+ if (index === -1 || index >= targetIndex) return restateInto(targetRule, entry)
109
+ restateBefore(theme, targetRule, index, entry)
110
+ }
111
+
112
+ /**
113
+ * @param {ThemeVariables} theme
114
+ * @param {string} breakpoint
115
+ * @param {readonly string[]} inheritedBreakpoints
116
+ */
117
+ const restateAt = (theme, breakpoint, inheritedBreakpoints) => {
118
+ const index = lastIndexAt(theme, breakpoint)
119
+ if (index === undefined) return
120
+ const refWidth = refWidthAt(theme, breakpoint)
121
+ const target = { breakpoint, index, refWidth }
122
+ const cascaded = cascadeAt(theme, [...inheritedBreakpoints, breakpoint])
123
+ const stale = [...cascaded].filter(entry => needsRestate(theme, target, entry))
124
+ const targetRule = theme.rules[index]
125
+ for (const entry of stale) restate(theme, targetRule, target, entry)
126
+ }
127
+
128
+ /**
129
+ * @param {ThemeVariables} theme
130
+ * @param {BreakpointPlan} breakpointPlan
131
+ */
132
+ export const restateThemeVariables = (theme, breakpointPlan) => {
133
+ for (const [breakpoint, inheritedBreakpoints] of breakpointPlan) restateAt(theme, breakpoint, inheritedBreakpoints)
134
+ }
@@ -0,0 +1,32 @@
1
+ import { DESKTOP } from './cssTreeUtils.js'
2
+ import { wrappingAtRulesOf } from './wrappingAtRuleUtils.js'
3
+
4
+ /** @typedef {import('./stylesheetUtils.js').ScannedRule} ScannedRule */
5
+ /** @typedef {import('./wrappingAtRuleUtils.js').Section} Section */
6
+
7
+ export const CONTENT_MAX_WIDTH = '--content-max-width'
8
+
9
+ /** @param {ScannedRule} layoutRule */
10
+ const capsAtDesktop = ({ breakpoint, declarations }) => {
11
+ if (breakpoint !== DESKTOP) return false
12
+ return declarations.some(({ prop }) => prop === CONTENT_MAX_WIDTH)
13
+ }
14
+
15
+ /**
16
+ * @param {ScannedRule} layoutRule
17
+ * @returns {Section}
18
+ */
19
+ const sectionOf = ({ rule }) => {
20
+ const selector = rule.selector.trim()
21
+ const wrappingAtRules = wrappingAtRulesOf(rule)
22
+ return { selector, wrappingAtRules }
23
+ }
24
+
25
+ /**
26
+ * @param {readonly ScannedRule[]} layoutRules
27
+ * @returns {Section[]}
28
+ */
29
+ export const readSectionRules = layoutRules => {
30
+ const sectionsWithMaxWidthRules = layoutRules.filter(capsAtDesktop)
31
+ return sectionsWithMaxWidthRules.map(sectionOf)
32
+ }
@@ -0,0 +1,98 @@
1
+ import { breakpointOf, maxWidthOf } from './breakpointUtils.js'
2
+ import {
3
+ ancestorsOf,
4
+ declarationsOf,
5
+ hasRootSelector,
6
+ isInContainerDefaults,
7
+ isMedia,
8
+ isTopLevel,
9
+ } from './cssTreeUtils.js'
10
+ import { relativeToSite } from '../../../transform/relativeToSite.js'
11
+
12
+ /**
13
+ * @typedef {object} ScannedRule
14
+ * @property {import('postcss').Rule} rule
15
+ * @property {string} breakpoint
16
+ * @property {import('postcss').Declaration[]} declarations
17
+ */
18
+
19
+ /**
20
+ * @typedef {object} StylesheetScan
21
+ * @property {ScannedRule[]} themeRules
22
+ * @property {ScannedRule[]} layoutRules
23
+ */
24
+
25
+ const THEME_FILE = 'src/global/themes/theme.css'
26
+
27
+ /** @param {readonly import('postcss').Node[]} ancestors */
28
+ const isThemeContext = ancestors => {
29
+ const [parent] = ancestors
30
+ if (parent === undefined) return true
31
+ return ancestors.length === 1 && isMedia(parent) && maxWidthOf(parent.params) !== undefined
32
+ }
33
+
34
+ /**
35
+ * @param {import('postcss').Rule} rule
36
+ * @param {string} siteFolder
37
+ */
38
+ const isFromThemeFile = (rule, siteFolder) => {
39
+ const file = rule.source?.input.file
40
+ if (file === undefined) return false
41
+ const relative = relativeToSite(siteFolder, file)
42
+ return relative === THEME_FILE
43
+ }
44
+
45
+ /** @param {readonly import('postcss').Node[]} ancestors */
46
+ const isLayoutRule = ancestors => isTopLevel(ancestors) && !isInContainerDefaults(ancestors)
47
+
48
+ /**
49
+ * @param {import('postcss').Rule} rule
50
+ * @param {import('postcss').Node[]} ancestors
51
+ * @returns {ScannedRule}
52
+ */
53
+ const scannedRuleOf = (rule, ancestors) => {
54
+ const breakpoint = breakpointOf(ancestors)
55
+ const declarations = declarationsOf(rule)
56
+ return { rule, breakpoint, declarations }
57
+ }
58
+
59
+ /**
60
+ * @param {import('postcss').Rule} rule
61
+ * @param {string} siteFolder
62
+ * @returns {ScannedRule[]}
63
+ */
64
+ const themeRuleOf = (rule, siteFolder) => {
65
+ if (!isFromThemeFile(rule, siteFolder)) return []
66
+ const ancestors = ancestorsOf(rule)
67
+ if (!isThemeContext(ancestors)) return []
68
+ return [scannedRuleOf(rule, ancestors)]
69
+ }
70
+
71
+ /**
72
+ * @param {import('postcss').Rule} rule
73
+ * @returns {ScannedRule[]}
74
+ */
75
+ const layoutRuleOf = rule => {
76
+ const ancestors = ancestorsOf(rule)
77
+ return isLayoutRule(ancestors) ? [scannedRuleOf(rule, ancestors)] : []
78
+ }
79
+
80
+ /**
81
+ * @param {import('postcss').Root} root
82
+ * @param {string} siteFolder
83
+ * @returns {StylesheetScan}
84
+ */
85
+ export const scanStylesheet = (root, siteFolder) => {
86
+ /** @type {StylesheetScan} */
87
+ const scan = { themeRules: [], layoutRules: [] }
88
+ root.walkRules(rule => {
89
+ if (hasRootSelector(rule)) {
90
+ const themeRules = themeRuleOf(rule, siteFolder)
91
+ scan.themeRules.push(...themeRules)
92
+ return
93
+ }
94
+ const layoutRules = layoutRuleOf(rule)
95
+ scan.layoutRules.push(...layoutRules)
96
+ })
97
+ return scan
98
+ }
@@ -0,0 +1,90 @@
1
+ import { toThemeCalls } from '../../spx/calls.js'
2
+ import { refWidthAt, resolveRefWidths } from './breakpointUtils.js'
3
+ import { DESKTOP, isCustomProperty } from './cssTreeUtils.js'
4
+
5
+ /** @typedef {import('./breakpointUtils.js').ThemeRule} ThemeRule */
6
+ /** @typedef {import('./breakpointUtils.js').ThemeVariables} ThemeVariables */
7
+ /** @typedef {import('./breakpointUtils.js').VariableMap} VariableMap */
8
+ /** @typedef {import('./stylesheetUtils.js').ScannedRule} ScannedRule */
9
+
10
+ const REF_WIDTH = '--spx-ref-width'
11
+
12
+ const SPX_SETTINGS = new Set([
13
+ REF_WIDTH,
14
+ '--spx-resolver-type',
15
+ '--spx-stopper-min',
16
+ '--spx-stopper-max',
17
+ '--scaling-factor',
18
+ '--scrollbar-width',
19
+ ])
20
+
21
+ /** @param {string} prop */
22
+ const isThemeVariable = prop => isCustomProperty(prop) && !SPX_SETTINGS.has(prop)
23
+
24
+ /** @param {number} refWidth */
25
+ const isUsableRefWidth = refWidth => Number.isFinite(refWidth) && refWidth > 0
26
+
27
+ /** @param {readonly ScannedRule[]} themeRules */
28
+ const rootDeclarationsByBreakpointOf = themeRules => {
29
+ /** @type {Map<string, VariableMap>} */
30
+ const declarationsByBreakpoint = new Map()
31
+ for (const { breakpoint, declarations } of themeRules) {
32
+ const declarationMap = declarationsByBreakpoint.get(breakpoint) ?? new Map()
33
+ declarationsByBreakpoint.set(breakpoint, declarationMap)
34
+ for (const declaration of declarations) {
35
+ declarationMap.set(declaration.prop, declaration)
36
+ }
37
+ }
38
+ return declarationsByBreakpoint
39
+ }
40
+
41
+ /**
42
+ * @param {ScannedRule} scanned
43
+ * @returns {ThemeRule}
44
+ */
45
+ const themeRuleOf = scanned => {
46
+ const themeVariables = scanned.declarations.filter(({ prop }) => isThemeVariable(prop))
47
+ const entries = themeVariables.map(declaration => /** @type {const} */ ([declaration.prop, declaration]))
48
+ const variables = new Map(entries)
49
+ return { scanned, variables }
50
+ }
51
+
52
+ /** @param {Map<string, VariableMap>} declarationsByBreakpoint */
53
+ const breakpointsOf = declarationsByBreakpoint => {
54
+ const breakpoints = [...declarationsByBreakpoint.keys()]
55
+ return breakpoints.filter(breakpoint => breakpoint !== DESKTOP)
56
+ }
57
+
58
+ /** @param {Map<string, VariableMap>} declarationsByBreakpoint */
59
+ const refWidthByBreakpointOf = declarationsByBreakpoint => {
60
+ const refWidths = [...declarationsByBreakpoint].flatMap(([breakpoint, declarationMap]) => {
61
+ const refWidth = Number(declarationMap.get(REF_WIDTH)?.value)
62
+ return isUsableRefWidth(refWidth) ? [/** @type {const} */ ([breakpoint, refWidth])] : []
63
+ })
64
+ return new Map(refWidths)
65
+ }
66
+
67
+ /**
68
+ * @param {readonly ScannedRule[]} themeRules
69
+ * @returns {ThemeVariables}
70
+ */
71
+ export const readThemeVariables = themeRules => {
72
+ const declarationsByBreakpoint = rootDeclarationsByBreakpointOf(themeRules)
73
+ const rules = themeRules.map(themeRuleOf)
74
+ const breakpoints = breakpointsOf(declarationsByBreakpoint)
75
+ const ownRefWidths = refWidthByBreakpointOf(declarationsByBreakpoint)
76
+ const refWidthByBreakpoint = resolveRefWidths(breakpoints, ownRefWidths)
77
+ return { rules, breakpoints, refWidthByBreakpoint }
78
+ }
79
+
80
+ /**
81
+ * @param {readonly ScannedRule[]} themeRules
82
+ * @param {ThemeVariables} theme
83
+ */
84
+ export const tagThemeVariables = (themeRules, theme) => {
85
+ for (const { breakpoint, declarations } of themeRules) {
86
+ const refWidth = refWidthAt(theme, breakpoint)
87
+ const variables = declarations.filter(({ prop }) => isThemeVariable(prop))
88
+ for (const declaration of variables) declaration.value = toThemeCalls(declaration.value, refWidth)
89
+ }
90
+ }
@@ -0,0 +1,68 @@
1
+ import { ancestorsOf, collapseWhitespace, isAtRule } from './cssTreeUtils.js'
2
+
3
+ /**
4
+ * @typedef {object} Section
5
+ * @property {string} selector
6
+ * @property {import('postcss').AtRule[]} wrappingAtRules
7
+ */
8
+
9
+ /**
10
+ * @typedef {object} SectionGroup
11
+ * @property {import('postcss').AtRule[]} wrappingAtRules
12
+ * @property {string[]} selectors
13
+ */
14
+
15
+ const UNWRAPPED_KEY = ''
16
+
17
+ /**
18
+ * @param {import('postcss').Node} node
19
+ * @returns {node is import('postcss').AtRule}
20
+ */
21
+ const isWrappingAtRule = node => isAtRule(node) && node.name !== 'layer'
22
+
23
+ /**
24
+ * @param {import('postcss').Rule} rule
25
+ * @returns {import('postcss').AtRule[]}
26
+ */
27
+ export const wrappingAtRulesOf = rule => {
28
+ const ancestors = ancestorsOf(rule)
29
+ const wrappingAtRules = ancestors.filter(isWrappingAtRule)
30
+ return wrappingAtRules.reverse()
31
+ }
32
+
33
+ /** @param {import('postcss').AtRule} atRule */
34
+ const atRuleLabelOf = ({ name, params }) => {
35
+ const collapsed = collapseWhitespace(params)
36
+ return `@${name} ${collapsed}`
37
+ }
38
+
39
+ /** @param {readonly import('postcss').AtRule[]} wrappingAtRules */
40
+ const wrappingKeyOf = wrappingAtRules => {
41
+ const labels = wrappingAtRules.map(atRuleLabelOf)
42
+ return labels.join(' ')
43
+ }
44
+
45
+ /**
46
+ * @param {Map<string, SectionGroup>} groups
47
+ * @param {Section} section
48
+ */
49
+ const addSectionToGroup = (groups, { selector, wrappingAtRules }) => {
50
+ const key = wrappingKeyOf(wrappingAtRules)
51
+ const group = groups.get(key) ?? { wrappingAtRules, selectors: [] }
52
+ if (!group.selectors.includes(selector)) group.selectors.push(selector)
53
+ groups.set(key, group)
54
+ }
55
+
56
+ /**
57
+ * @param {readonly Section[]} sections
58
+ * @returns {SectionGroup[]}
59
+ */
60
+ export const groupSectionsByWrappingAtRules = sections => {
61
+ /** @type {Map<string, SectionGroup>} */
62
+ const groups = new Map()
63
+ for (const section of sections) addSectionToGroup(groups, section)
64
+ const unwrapped = groups.get(UNWRAPPED_KEY)
65
+ groups.delete(UNWRAPPED_KEY)
66
+ const wrapped = [...groups.values()]
67
+ return unwrapped === undefined ? wrapped : [unwrapped, ...wrapped]
68
+ }
@@ -0,0 +1,98 @@
1
+ import postcss from 'postcss'
2
+ import { DESKTOP } from './cssTreeUtils.js'
3
+ import { groupSectionsByWrappingAtRules } from './wrappingAtRuleUtils.js'
4
+
5
+ /** @typedef {import('./copyPlanUtils.js').ThemeCopies} ThemeCopies */
6
+ /** @typedef {import('./wrappingAtRuleUtils.js').Section} Section */
7
+ /** @typedef {import('./wrappingAtRuleUtils.js').SectionGroup} SectionGroup */
8
+
9
+ /**
10
+ * @typedef {object} BreakpointCopies
11
+ * @property {string} breakpoint
12
+ * @property {import('postcss').Declaration[]} declarations
13
+ */
14
+
15
+ export const THEME_COPIES_LAYER = 'origami-theme-copies'
16
+
17
+ const LEADING_AT_RULES = new Set(['charset', 'import'])
18
+
19
+ /**
20
+ * @param {ThemeCopies} themeCopies
21
+ * @returns {BreakpointCopies[]}
22
+ */
23
+ const breakpointCopiesOf = themeCopies => {
24
+ const planned = [...themeCopies].map(([breakpoint, copies]) => ({ breakpoint, declarations: [...copies.values()] }))
25
+ return planned.filter(({ declarations }) => declarations.length > 0)
26
+ }
27
+
28
+ /**
29
+ * @param {string} selector
30
+ * @param {BreakpointCopies} breakpointCopies
31
+ * @returns {import('postcss').ChildNode}
32
+ */
33
+ const breakpointBlockOf = (selector, { breakpoint, declarations }) => {
34
+ const nodes = declarations.map(declaration => declaration.clone({ important: false, raws: {} }))
35
+ const rule = postcss.rule({ selector, nodes })
36
+ if (breakpoint === DESKTOP) return rule
37
+ return postcss.atRule({ name: 'media', params: breakpoint, nodes: [rule] })
38
+ }
39
+
40
+ /**
41
+ * @param {import('postcss').ChildNode[]} nodes
42
+ * @param {import('postcss').AtRule} wrappingAtRule
43
+ * @returns {import('postcss').ChildNode[]}
44
+ */
45
+ const wrapInAtRuleClone = (nodes, wrappingAtRule) => {
46
+ const clone = wrappingAtRule.clone({ nodes: [] })
47
+ clone.append(nodes)
48
+ return [clone]
49
+ }
50
+
51
+ /**
52
+ * @param {readonly BreakpointCopies[]} allBreakpointCopies
53
+ * @param {SectionGroup} group
54
+ * @returns {import('postcss').ChildNode[]}
55
+ */
56
+ const groupBlocksOf = (allBreakpointCopies, { wrappingAtRules, selectors }) => {
57
+ const selector = `:where(${selectors.join(', ')})`
58
+ const blocks = allBreakpointCopies.map(breakpointCopies => breakpointBlockOf(selector, breakpointCopies))
59
+ return wrappingAtRules.reduceRight(wrapInAtRuleClone, blocks)
60
+ }
61
+
62
+ /**
63
+ * @param {readonly BreakpointCopies[]} allBreakpointCopies
64
+ * @param {readonly SectionGroup[]} groups
65
+ */
66
+ const copyLayerOf = (allBreakpointCopies, groups) => {
67
+ const nodes = groups.flatMap(group => groupBlocksOf(allBreakpointCopies, group))
68
+ return postcss.atRule({ name: 'layer', params: THEME_COPIES_LAYER, nodes })
69
+ }
70
+
71
+ /** @param {import('postcss').ChildNode} node */
72
+ const isLeadingAtRule = node => node.type === 'atrule' && LEADING_AT_RULES.has(node.name)
73
+
74
+ /**
75
+ * @param {import('postcss').Root} root
76
+ * @param {import('postcss').AtRule} layer
77
+ */
78
+ const prependAfterLeadingAtRules = (root, layer) => {
79
+ const firstOther = root.nodes.find(node => node.type !== 'comment' && !isLeadingAtRule(node))
80
+ if (firstOther === undefined) {
81
+ root.append(layer)
82
+ return
83
+ }
84
+ firstOther.before(layer)
85
+ }
86
+
87
+ /**
88
+ * @param {import('postcss').Root} root
89
+ * @param {ThemeCopies} themeCopies
90
+ * @param {readonly Section[]} sections
91
+ */
92
+ export const writeCopies = (root, themeCopies, sections) => {
93
+ const allBreakpointCopies = breakpointCopiesOf(themeCopies)
94
+ if (allBreakpointCopies.length === 0 || sections.length === 0) return
95
+ const groups = groupSectionsByWrappingAtRules(sections)
96
+ const layer = copyLayerOf(allBreakpointCopies, groups)
97
+ prependAfterLeadingAtRules(root, layer)
98
+ }