PluginProbe
Extendify / 3.2.1
Extendify v3.2.1
3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 0.6.0 All 127 releases
extendify / src / Shared / lib / vibe-preview.js

vibe-preview.js in Extendify 3.2.1, at src/Shared/lib/vibe-preview.js

228 lines 7.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { deepMerge, isObject } from '@shared/lib/utils';
2 import { ownedSettingPaths, ownedStylePaths } from '@shared/lib/vibe-globals';
3
4 const headings = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
5
6 const kebab = (property) =>
7 property.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
8
9 // Core's selectors for theme.json elements; heading resolves per level.
10 const elementSelectors = {
11 body: 'body',
12 button: '.wp-element-button, .wp-block-button__link',
13 caption: '.wp-element-caption',
14 cite: 'cite',
15 link: 'a:where(:not(.wp-element-button))',
16 };
17
18 const blockSelector = (block) => {
19 const [namespace, name] = block.split('/');
20 return namespace === 'core'
21 ? `.wp-block-${name}`
22 : `.wp-block-${namespace}-${name}`;
23 };
24
25 // Property paths that don't kebab straight into their css property.
26 const propertyAliases = {
27 'color.background': 'background-color',
28 'color.text': 'color',
29 'color.gradient': 'background',
30 'filter.duotone': 'filter',
31 shadow: 'box-shadow',
32 'border.radius.topLeft': 'border-top-left-radius',
33 'border.radius.topRight': 'border-top-right-radius',
34 'border.radius.bottomLeft': 'border-bottom-left-radius',
35 'border.radius.bottomRight': 'border-bottom-right-radius',
36 };
37
38 // typography.fontSize is font-size, but border.radius keeps its group.
39 const grouplessRoots = new Set(['typography', 'spacing', 'dimensions']);
40
41 const cssProperty = (path) =>
42 propertyAliases[path.join('.')] ??
43 (grouplessRoots.has(path[0]) ? path.slice(1) : path).map(kebab).join('-');
44
45 const valueAt = (source, path) =>
46 path.reduce(
47 (value, key) => (isObject(value) ? value[key] : undefined),
48 source,
49 );
50
51 // theme.json stores preset references as var:preset|color|primary.
52 const cssValue = (value) =>
53 typeof value === 'string' && value.startsWith('var:')
54 ? `var(--wp--${value.slice(4).split('|').map(kebab).join('--')})`
55 : value;
56
57 // revert alone would drop the theme's own value with the outgoing vibe's.
58 const resetValue = (value) =>
59 typeof value === 'string' || typeof value === 'number'
60 ? cssValue(value)
61 : 'revert';
62
63 const setDeclarations = (rules, key, text) => {
64 for (const declaration of text.split(';')) {
65 const [name, ...value] = declaration.split(':');
66 const property = name.trim();
67 if (!/^-?[a-zA-Z][-a-zA-Z]*$/.test(property) || !value.length) continue;
68 const declarations = rules.get(key) ?? new Map();
69 declarations.set(property, value.join(':').trim());
70 rules.set(key, declarations);
71 }
72 };
73
74 const cssRuleMap = (css) => {
75 const rules = new Map();
76 if (typeof css !== 'string') return rules;
77
78 let topLevel = '';
79 let lastIndex = 0;
80 for (const match of css.matchAll(/([^{};]*)\{([^{}]*)\}/g)) {
81 topLevel += css.slice(lastIndex, match.index);
82 lastIndex = match.index + match[0].length;
83 setDeclarations(rules, match[1].trim().replace(/\s+/g, ' '), match[2]);
84 }
85 setDeclarations(rules, '', topLevel + css.slice(lastIndex));
86
87 return rules;
88 };
89
90 // Core emits block custom css at raw specificity; a fully :where-wrapped reset loses.
91 const nestedSelector = (blockSel, nested) =>
92 nested
93 .split(',')
94 .map((part) => {
95 const trimmed = part.trim().replace(/\s+/g, ' ');
96 if (!trimmed) return `:root :where(${blockSel})`;
97 return trimmed.includes('&')
98 ? `:root ${trimmed.replace(/&/g, `:where(${blockSel})`)}`
99 : `:root :where(${blockSel}) ${trimmed}`;
100 })
101 .join(', ');
102
103 const addSlot = (slots, selector, property, resolve) =>
104 slots.set(`${selector}|${property}`, { selector, property, resolve });
105
106 const addCssSlots = (slots, blockSel, css, path) => {
107 for (const [nested, declarations] of cssRuleMap(css)) {
108 const selector = nested
109 ? nestedSelector(blockSel, nested)
110 : `:root :where(${blockSel})`;
111 for (const property of declarations.keys()) {
112 addSlot(slots, selector, property, (theme) =>
113 cssRuleMap(valueAt(theme, path)).get(nested)?.get(property),
114 );
115 }
116 }
117 };
118
119 const collectSlots = (slots, vibe) => {
120 for (const path of ownedStylePaths({ vibe })) {
121 if (path[0] === 'elements') {
122 const [, element, ...rest] = path;
123 // Resolved per level so a theme's own h5 value survives the reset.
124 for (const level of element === 'heading' ? headings : [element]) {
125 if (headings.includes(level)) {
126 addSlot(
127 slots,
128 `:root :where(${level})`,
129 cssProperty(rest),
130 (theme) =>
131 valueAt(theme, ['elements', level, ...rest]) ??
132 valueAt(theme, ['elements', 'heading', ...rest]),
133 );
134 continue;
135 }
136 addSlot(
137 slots,
138 `:root :where(${elementSelectors[element] ?? element})`,
139 cssProperty(rest),
140 (theme) => valueAt(theme, path),
141 );
142 }
143 continue;
144 }
145
146 if (path[0] === 'blocks') {
147 const [, block, ...rest] = path;
148 const selector = blockSelector(block);
149 if (rest[rest.length - 1] === 'css') {
150 addCssSlots(slots, selector, valueAt(vibe?.styles, path), path);
151 continue;
152 }
153 addSlot(slots, `:root :where(${selector})`, cssProperty(rest), (theme) =>
154 valueAt(theme, path),
155 );
156 continue;
157 }
158
159 addSlot(slots, ':root :where(body)', cssProperty(path), (theme) =>
160 valueAt(theme, path),
161 );
162 }
163 };
164
165 const customVarPaths = (vibes) =>
166 ownedSettingPaths(vibes).filter((path) => path[0] === 'custom');
167
168 // Core names the vars via _wp_to_kebab_case, which drops the ':' from :hover.
169 const wpKebab = (key) =>
170 kebab(key)
171 .replace(/[^a-z0-9-]+/gi, '-')
172 .replace(/-+/g, '-')
173 .replace(/^-|-$/g, '');
174
175 // Layout sizes are server-compiled fluid clamps, so settings resets stop at custom vars.
176 const customVarResetCss = ({ payloads, slug, themeSettings }) => {
177 const incoming = new Set(
178 customVarPaths({ [slug]: payloads?.[slug] }).map((path) => path.join('.')),
179 );
180
181 const declarations = customVarPaths(payloads ?? {})
182 .filter((path) => !incoming.has(path.join('.')))
183 .map(
184 (path) =>
185 `--wp--${path.map(wpKebab).join('--')}:${resetValue(
186 valueAt(themeSettings, path),
187 )}`,
188 );
189
190 return declarations.length ? `:root{${declarations.join(';')};}` : '';
191 };
192
193 // The applied fonts variation is the no-vibe baseline where it declares a value.
194 export const withAppliedVariation = (theme, variation) => {
195 const base = isObject(theme) ? theme : {};
196 return isObject(variation) ? deepMerge(base, variation) : base;
197 };
198
199 // Css only adds, so an incoming vibe's silence cannot undo the applied vibe's
200 // styles. Which vibe is applied is unknown here, so every vibe's slots get cleared.
201 export const buildVibeResetCss = ({
202 payloads,
203 slug,
204 themeStyles,
205 themeSettings,
206 }) => {
207 const owned = new Map();
208 for (const vibe of Object.values(payloads ?? {})) collectSlots(owned, vibe);
209
210 const incoming = new Map();
211 if (payloads?.[slug]) collectSlots(incoming, payloads[slug]);
212
213 const rules = new Map();
214 for (const [key, { selector, property, resolve }] of owned) {
215 if (incoming.has(key)) continue;
216 const declarations = rules.get(selector) ?? [];
217 declarations.push(`${property}:${resetValue(resolve(themeStyles))}`);
218 rules.set(selector, declarations);
219 }
220
221 let css = '';
222 for (const [selector, declarations] of rules) {
223 css += `${selector}{${declarations.join(';')};}`;
224 }
225
226 return css + customVarResetCss({ payloads, slug, themeSettings });
227 };
228