@akinon/pz-theme 2.0.164-rc.1 → 2.0.165-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -58
- package/package.json +4 -4
- package/src/__tests__/editor-locale-bridge.test.tsx +1 -32
- package/src/__tests__/theme-settings-defaults.test.tsx +395 -0
- package/src/__tests__/theme-text-defaults-code-owned.test.tsx +340 -0
- package/src/blocks/__tests__/breadcrumb-theme-separator.test.tsx +148 -0
- package/src/blocks/__tests__/social-icons-theme-fallback.test.tsx +268 -0
- package/src/blocks/__tests__/text-block.test.tsx +6 -34
- package/src/blocks/accordion-block.tsx +10 -0
- package/src/blocks/hotspot-block.tsx +6 -1
- package/src/blocks/listing-breadcrumb-block.tsx +10 -1
- package/src/blocks/order-detail-block.tsx +95 -113
- package/src/blocks/product-breadcrumb-block.tsx +9 -1
- package/src/blocks/social-glyphs.tsx +31 -0
- package/src/blocks/social-icons-list-block.tsx +72 -8
- package/src/blocks/text-block.tsx +15 -6
- package/src/chrome/__tests__/theme-page-up.test.tsx +522 -0
- package/src/chrome/theme-page-up.tsx +303 -0
- package/src/chrome/use-editor-theme-settings.ts +104 -0
- package/src/components/with-designer-features.tsx +27 -0
- package/src/custom/README.md +219 -100
- package/src/custom/__tests__/use-theme-setting.test.tsx +0 -97
- package/src/custom/collect.ts +9 -4
- package/src/custom/property-types.ts +15 -15
- package/src/custom/use-theme-setting.ts +5 -21
- package/src/editor-locale-bridge.tsx +11 -39
- package/src/index.ts +15 -0
- package/src/overlays/theme-modals.tsx +10 -0
- package/src/sections/__tests__/section-wrapper.test.tsx +76 -0
- package/src/sections/coupon-banner-section.tsx +15 -1
- package/src/sections/section-wrapper.tsx +7 -0
- package/src/sections/tabs-section.tsx +13 -0
- package/src/theme-placeholder-wrapper.tsx +50 -3
- package/src/theme-section.tsx +15 -0
- package/src/utils/__tests__/svg-image-source.test.ts +123 -0
- package/src/utils/__tests__/theme-settings-css.test.ts +801 -0
- package/src/utils/__tests__/theme-settings-readers.test.ts +213 -0
- package/src/utils/__tests__/theme-settings-resolver.test.ts +232 -0
- package/src/utils/index.ts +36 -1
- package/src/utils/svg-image-source.ts +84 -0
- package/src/utils/theme-settings-css.ts +717 -0
- package/src/utils/theme-settings-readers.ts +205 -0
- package/src/blocks/__tests__/order-line-actions.test.tsx +0 -54
- package/src/blocks/order-line-actions.ts +0 -45
- package/src/utils/__tests__/sanitize-rich-text.test.ts +0 -80
- package/src/utils/sanitize-rich-text.ts +0 -215
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The storefront side of the redesigned Web Theme Settings.
|
|
3
|
+
*
|
|
4
|
+
* The settings page writes a handful of new top-level `theme_settings` keys:
|
|
5
|
+
* five colour roles (`linkColor`, `headingColor`, `textColor`,
|
|
6
|
+
* `footerBackgroundColor`, `borderColor`), merchant-added `customColors`, and
|
|
7
|
+
* typography variants that carry `lineHeight` / `letterSpacing` /
|
|
8
|
+
* `textTransform` next to the family, weight and size they always had. This
|
|
9
|
+
* module turns them into:
|
|
10
|
+
*
|
|
11
|
+
* - CSS variables on `:root` (`--theme-link`, `--theme-custom-<id>`,
|
|
12
|
+
* `--theme-font-size-<variant>` …), for the server `<style>` and for the
|
|
13
|
+
* client, from ONE collector so the two can never disagree;
|
|
14
|
+
* - a resolver for `var(--theme-…)` inside block style values, answering
|
|
15
|
+
* from the same validated values (emission and resolution stay in parity);
|
|
16
|
+
* - element defaults for rich text (`a`, `h1`–`h6`, `p`, `li`, `hr`) and the
|
|
17
|
+
* composed footer, emitted ONLY for keys that are explicitly set.
|
|
18
|
+
*
|
|
19
|
+
* Two rules every function below honours:
|
|
20
|
+
*
|
|
21
|
+
* 1. Nothing is emitted for a theme that never touched the new keys, so an
|
|
22
|
+
* existing site renders byte-identically (the builders return '').
|
|
23
|
+
* 2. Every value is validated before it reaches a `<style>` tag or an inline
|
|
24
|
+
* style. `neutralizeStyleTagBreakout` only stops `</style`; a value such
|
|
25
|
+
* as `#fff}input{background:url(//x)}` would still close the rule, and a
|
|
26
|
+
* theme JSON import or a direct API write bypasses the editor's own
|
|
27
|
+
* checks. Invalid values are dropped, never escaped.
|
|
28
|
+
*
|
|
29
|
+
* No React and no import of `./index` (which imports this module), so it is
|
|
30
|
+
* safe to use from server code, client code and the utils barrel alike.
|
|
31
|
+
*/
|
|
32
|
+
import { neutralizeStyleTagBreakout } from './css-safety';
|
|
33
|
+
import { splitPartStyles } from './part-styles';
|
|
34
|
+
|
|
35
|
+
type ThemeSettingsInput = Record<string, unknown> | null | undefined;
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Contract tables
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
/** Colour role storage key → the CSS variable it is published as. */
|
|
42
|
+
export const THEME_COLOR_ROLE_VARIABLES = {
|
|
43
|
+
linkColor: '--theme-link',
|
|
44
|
+
headingColor: '--theme-heading',
|
|
45
|
+
textColor: '--theme-text',
|
|
46
|
+
footerBackgroundColor: '--theme-footer-background',
|
|
47
|
+
borderColor: '--theme-border'
|
|
48
|
+
} as const;
|
|
49
|
+
|
|
50
|
+
export type ThemeColorRoleKey = keyof typeof THEME_COLOR_ROLE_VARIABLES;
|
|
51
|
+
|
|
52
|
+
const THEME_COLOR_ROLE_KEYS = Object.keys(
|
|
53
|
+
THEME_COLOR_ROLE_VARIABLES
|
|
54
|
+
) as ThemeColorRoleKey[];
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The five colour variables that predate this module. They are written by
|
|
58
|
+
* `injectThemeColors` and resolved through `THEME_CSS_VARIABLE_MAP`, and they
|
|
59
|
+
* are never removed: `--theme-primary` has no server `<style>` baseline, so
|
|
60
|
+
* removing it inline would leave the client without it.
|
|
61
|
+
*/
|
|
62
|
+
const LEGACY_COLOR_VARIABLES: ReadonlySet<string> = new Set([
|
|
63
|
+
'--theme-primary',
|
|
64
|
+
'--theme-secondary',
|
|
65
|
+
'--theme-color3',
|
|
66
|
+
'--theme-color4',
|
|
67
|
+
'--theme-background'
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Custom colour ids, editor and storefront alike. `custom-` plus a
|
|
72
|
+
* non-reusable base-36 stamp, so a deleted colour's id is never handed to a
|
|
73
|
+
* new one (content still pointing at it would silently re-bind).
|
|
74
|
+
*/
|
|
75
|
+
export const THEME_CUSTOM_COLOR_ID = /^custom-[a-z0-9]+$/;
|
|
76
|
+
|
|
77
|
+
/** The typography variable prefixes, per variant key K: `${prefix}K`. */
|
|
78
|
+
export const THEME_TYPOGRAPHY_VARIABLE_PREFIXES = {
|
|
79
|
+
size: '--theme-font-size-',
|
|
80
|
+
weight: '--theme-font-weight-',
|
|
81
|
+
lineHeight: '--theme-line-height-',
|
|
82
|
+
letterSpacing: '--theme-letter-spacing-',
|
|
83
|
+
transform: '--theme-text-transform-'
|
|
84
|
+
} as const;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Which element each managed variant styles inside rich text. Order is the
|
|
88
|
+
* emission order. `button`, `menu`, `caption` and custom styles publish their
|
|
89
|
+
* variables only.
|
|
90
|
+
*/
|
|
91
|
+
const TYPOGRAPHY_ELEMENT_DEFAULTS: ReadonlyArray<readonly [string, string]> = [
|
|
92
|
+
['heading01', 'h1'],
|
|
93
|
+
['heading02', 'h2'],
|
|
94
|
+
['subheading', 'h3'],
|
|
95
|
+
['heading04', 'h4'],
|
|
96
|
+
['body', 'p'],
|
|
97
|
+
['link', 'a']
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
const TYPOGRAPHY_DEFAULT_KEYS: ReadonlySet<string> = new Set(
|
|
101
|
+
TYPOGRAPHY_ELEMENT_DEFAULTS.map(([key]) => key)
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
/** Stamped on a text block's root while element defaults are active. */
|
|
105
|
+
export const THEME_RICH_TEXT_ATTRIBUTE = 'data-pz-rich-text';
|
|
106
|
+
/** Stamped on a block wrapper / section whose root styles set a colour. */
|
|
107
|
+
export const THEME_OWN_COLOR_ATTRIBUTE = 'data-pz-own-color';
|
|
108
|
+
/** Stamped on a block wrapper / section whose root styles set typography. */
|
|
109
|
+
export const THEME_OWN_TYPE_ATTRIBUTE = 'data-pz-own-type';
|
|
110
|
+
|
|
111
|
+
export const THEME_OWN_COLOR_STYLE_KEYS: readonly string[] = ['color'];
|
|
112
|
+
export const THEME_OWN_TYPE_STYLE_KEYS: readonly string[] = [
|
|
113
|
+
'font-family',
|
|
114
|
+
'font-size',
|
|
115
|
+
'font-weight',
|
|
116
|
+
'line-height',
|
|
117
|
+
'letter-spacing',
|
|
118
|
+
'text-transform'
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Validators
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
const HEX_COLOR = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
|
126
|
+
const VARIANT_KEY = /^[a-z0-9-]+$/i;
|
|
127
|
+
const FONT_WEIGHT = /^[1-9]00$/;
|
|
128
|
+
const NUMERIC = /^-?(?:\d+(?:\.\d*)?|\.\d+)(?:px)?$/i;
|
|
129
|
+
const UNSAFE_FAMILY = /[;{}<>\\]|\/\*/;
|
|
130
|
+
|
|
131
|
+
export type ThemeTextTransform = 'none' | 'uppercase' | 'lowercase';
|
|
132
|
+
const TEXT_TRANSFORMS: ReadonlySet<string> = new Set([
|
|
133
|
+
'none',
|
|
134
|
+
'uppercase',
|
|
135
|
+
'lowercase'
|
|
136
|
+
]);
|
|
137
|
+
|
|
138
|
+
const SIZE_RANGE = [1, 400] as const;
|
|
139
|
+
const LETTER_SPACING_RANGE = [-50, 50] as const;
|
|
140
|
+
|
|
141
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
142
|
+
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
143
|
+
|
|
144
|
+
/** `#rgb`, `#rgba`, `#rrggbb` or `#rrggbbaa`, nothing else. */
|
|
145
|
+
export const isThemeHexColor = (value: unknown): value is string =>
|
|
146
|
+
typeof value === 'string' && HEX_COLOR.test(value.trim());
|
|
147
|
+
|
|
148
|
+
/** A number, or a numeric string with an optional `px` (`'32px'` → 32). */
|
|
149
|
+
const toFiniteNumber = (value: unknown): number | undefined => {
|
|
150
|
+
if (typeof value === 'number') {
|
|
151
|
+
return Number.isFinite(value) ? value : undefined;
|
|
152
|
+
}
|
|
153
|
+
if (typeof value !== 'string') return undefined;
|
|
154
|
+
const trimmed = value.trim();
|
|
155
|
+
if (!NUMERIC.test(trimmed)) return undefined;
|
|
156
|
+
const parsed = parseFloat(trimmed);
|
|
157
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const clamp = (value: number, [min, max]: readonly [number, number]) =>
|
|
161
|
+
Math.min(max, Math.max(min, value));
|
|
162
|
+
|
|
163
|
+
/** Three decimals at most, so a stepper's 0.30000000000000004 prints 0.3. */
|
|
164
|
+
const formatNumber = (value: number): string =>
|
|
165
|
+
String(Number(value.toFixed(3)));
|
|
166
|
+
|
|
167
|
+
const toPx = (value: number) => `${formatNumber(value)}px`;
|
|
168
|
+
|
|
169
|
+
const readFontWeight = (value: unknown): string | undefined => {
|
|
170
|
+
const raw = typeof value === 'number' ? String(value) : value;
|
|
171
|
+
return typeof raw === 'string' && FONT_WEIGHT.test(raw.trim())
|
|
172
|
+
? raw.trim()
|
|
173
|
+
: undefined;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const readTextTransform = (value: unknown): ThemeTextTransform | undefined =>
|
|
177
|
+
typeof value === 'string' && TEXT_TRANSFORMS.has(value)
|
|
178
|
+
? (value as ThemeTextTransform)
|
|
179
|
+
: undefined;
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The variant's family when it is one a `font-family` declaration can carry
|
|
183
|
+
* safely. Mirrors `readThemeTypographyFont` (empty and `inherit` mean "no
|
|
184
|
+
* face") and additionally refuses anything that could end the declaration.
|
|
185
|
+
*/
|
|
186
|
+
const readSafeFamily = (value: unknown): string | undefined => {
|
|
187
|
+
if (typeof value !== 'string') return undefined;
|
|
188
|
+
const family = value.trim();
|
|
189
|
+
if (!family || family === 'inherit' || UNSAFE_FAMILY.test(family)) {
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
return family;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// Readers
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
/** One colour role's value, or undefined when unset or not a hex colour. */
|
|
200
|
+
export const readThemeRoleColor = (
|
|
201
|
+
settings: ThemeSettingsInput,
|
|
202
|
+
key: ThemeColorRoleKey
|
|
203
|
+
): string | undefined => {
|
|
204
|
+
const value = settings?.[key];
|
|
205
|
+
return isThemeHexColor(value) ? value.trim() : undefined;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
export interface ThemeCustomColorValue {
|
|
209
|
+
id: string;
|
|
210
|
+
value: string;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Valid custom colours, deduplicated by id (the first entry wins). */
|
|
214
|
+
export const readThemeCustomColors = (
|
|
215
|
+
settings: ThemeSettingsInput
|
|
216
|
+
): ThemeCustomColorValue[] => {
|
|
217
|
+
const entries = settings?.customColors;
|
|
218
|
+
if (!Array.isArray(entries)) return [];
|
|
219
|
+
|
|
220
|
+
const seen = new Set<string>();
|
|
221
|
+
const colors: ThemeCustomColorValue[] = [];
|
|
222
|
+
|
|
223
|
+
entries.forEach((entry) => {
|
|
224
|
+
if (!isRecord(entry)) return;
|
|
225
|
+
const { id, value } = entry;
|
|
226
|
+
if (typeof id !== 'string' || !THEME_CUSTOM_COLOR_ID.test(id)) return;
|
|
227
|
+
if (seen.has(id) || !isThemeHexColor(value)) return;
|
|
228
|
+
seen.add(id);
|
|
229
|
+
colors.push({ id, value: value.trim() });
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
return colors;
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
export interface ManagedTypographyVariant {
|
|
236
|
+
key: string;
|
|
237
|
+
/** Only when a safe family resolves; its variable is `--theme-font-<key>`. */
|
|
238
|
+
family?: string;
|
|
239
|
+
/** px, clamped to 1..400 */
|
|
240
|
+
size?: number;
|
|
241
|
+
weight?: string;
|
|
242
|
+
/** px, clamped to 1..400 — its presence is what makes the variant managed */
|
|
243
|
+
lineHeight: number;
|
|
244
|
+
/** px, clamped to -50..50 */
|
|
245
|
+
letterSpacing?: number;
|
|
246
|
+
transform?: ThemeTextTransform;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The typography variants managed by the redesigned settings: those with a
|
|
251
|
+
* finite `lineHeight`. Variants saved by the old UI (family/weight/size only,
|
|
252
|
+
* often a never-applied seed such as heading01 34px/500) are left alone, so
|
|
253
|
+
* no existing site is retypeset. Deduplicated by key, the first entry wins,
|
|
254
|
+
* like `readThemeTypographyFont`.
|
|
255
|
+
*/
|
|
256
|
+
export const readManagedTypography = (
|
|
257
|
+
settings: ThemeSettingsInput
|
|
258
|
+
): ManagedTypographyVariant[] => {
|
|
259
|
+
const variants = settings?.typography;
|
|
260
|
+
if (!Array.isArray(variants)) return [];
|
|
261
|
+
|
|
262
|
+
const seen = new Set<string>();
|
|
263
|
+
const managed: ManagedTypographyVariant[] = [];
|
|
264
|
+
|
|
265
|
+
variants.forEach((variant) => {
|
|
266
|
+
if (!isRecord(variant)) return;
|
|
267
|
+
const { key } = variant;
|
|
268
|
+
if (typeof key !== 'string' || !VARIANT_KEY.test(key)) return;
|
|
269
|
+
if (seen.has(key)) return;
|
|
270
|
+
seen.add(key);
|
|
271
|
+
|
|
272
|
+
const lineHeight = toFiniteNumber(variant.lineHeight);
|
|
273
|
+
if (lineHeight === undefined) return;
|
|
274
|
+
|
|
275
|
+
const size = toFiniteNumber(variant.fontSize);
|
|
276
|
+
const letterSpacing = toFiniteNumber(variant.letterSpacing);
|
|
277
|
+
const family = readSafeFamily(variant.fontFamily);
|
|
278
|
+
const weight = readFontWeight(variant.fontWeight);
|
|
279
|
+
const transform = readTextTransform(variant.textTransform);
|
|
280
|
+
|
|
281
|
+
managed.push({
|
|
282
|
+
key,
|
|
283
|
+
lineHeight: clamp(lineHeight, SIZE_RANGE),
|
|
284
|
+
...(family ? { family } : null),
|
|
285
|
+
...(size !== undefined ? { size: clamp(size, SIZE_RANGE) } : null),
|
|
286
|
+
...(weight ? { weight } : null),
|
|
287
|
+
...(letterSpacing !== undefined
|
|
288
|
+
? { letterSpacing: clamp(letterSpacing, LETTER_SPACING_RANGE) }
|
|
289
|
+
: null),
|
|
290
|
+
...(transform ? { transform } : null)
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
return managed;
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
// Root variables (server <style>, client inline, resolver)
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
export type ThemeRootVariable = readonly [name: string, value: string];
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Every `:root` variable the new keys publish, in a stable order: colour
|
|
305
|
+
* roles, custom colours, then per managed variant its size, weight, line
|
|
306
|
+
* height, letter spacing and transform. Family variables are NOT included;
|
|
307
|
+
* they stay with `buildThemeTypographyCss`, whose output is unchanged.
|
|
308
|
+
*/
|
|
309
|
+
export const collectThemeRootVariables = (
|
|
310
|
+
settings: ThemeSettingsInput
|
|
311
|
+
): ThemeRootVariable[] => {
|
|
312
|
+
if (!isRecord(settings)) return [];
|
|
313
|
+
|
|
314
|
+
const variables: ThemeRootVariable[] = [];
|
|
315
|
+
|
|
316
|
+
THEME_COLOR_ROLE_KEYS.forEach((key) => {
|
|
317
|
+
const value = readThemeRoleColor(settings, key);
|
|
318
|
+
if (value) variables.push([THEME_COLOR_ROLE_VARIABLES[key], value]);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
readThemeCustomColors(settings).forEach(({ id, value }) => {
|
|
322
|
+
variables.push([`--theme-${id}`, value]);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
const prefixes = THEME_TYPOGRAPHY_VARIABLE_PREFIXES;
|
|
326
|
+
readManagedTypography(settings).forEach((variant) => {
|
|
327
|
+
const { key } = variant;
|
|
328
|
+
if (variant.size !== undefined) {
|
|
329
|
+
variables.push([`${prefixes.size}${key}`, toPx(variant.size)]);
|
|
330
|
+
}
|
|
331
|
+
if (variant.weight) {
|
|
332
|
+
variables.push([`${prefixes.weight}${key}`, variant.weight]);
|
|
333
|
+
}
|
|
334
|
+
variables.push([`${prefixes.lineHeight}${key}`, toPx(variant.lineHeight)]);
|
|
335
|
+
if (variant.letterSpacing !== undefined) {
|
|
336
|
+
variables.push([
|
|
337
|
+
`${prefixes.letterSpacing}${key}`,
|
|
338
|
+
toPx(variant.letterSpacing)
|
|
339
|
+
]);
|
|
340
|
+
}
|
|
341
|
+
if (variant.transform) {
|
|
342
|
+
variables.push([`${prefixes.transform}${key}`, variant.transform]);
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
return variables;
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Settings objects are replaced, never mutated (the server memoizes one per
|
|
351
|
+
* request; the editor sends a fresh one with every LOAD_THEME/UPDATE_THEME),
|
|
352
|
+
* so the derived lookups are cached per object.
|
|
353
|
+
*/
|
|
354
|
+
const rootVariableCache = new WeakMap<object, ReadonlyMap<string, string>>();
|
|
355
|
+
|
|
356
|
+
const rootVariableMap = (
|
|
357
|
+
settings: Record<string, unknown>
|
|
358
|
+
): ReadonlyMap<string, string> => {
|
|
359
|
+
const cached = rootVariableCache.get(settings);
|
|
360
|
+
if (cached) return cached;
|
|
361
|
+
const map = new Map(collectThemeRootVariables(settings));
|
|
362
|
+
rootVariableCache.set(settings, map);
|
|
363
|
+
return map;
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* The value `var(<name>)` stands for, when `name` is one of the variables
|
|
368
|
+
* above and its key holds a valid value. Used by `resolveThemeCssVariables`,
|
|
369
|
+
* so a block style such as `color: var(--theme-link)` is inlined on the
|
|
370
|
+
* server exactly as the `:root` rule would paint it.
|
|
371
|
+
*/
|
|
372
|
+
export const resolveThemeSettingsVariable = (
|
|
373
|
+
name: string,
|
|
374
|
+
settings: ThemeSettingsInput
|
|
375
|
+
): string | undefined => {
|
|
376
|
+
if (!isRecord(settings) || !name.startsWith('--theme-')) return undefined;
|
|
377
|
+
return rootVariableMap(settings).get(name);
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Whether `name` is one of the typography metric variables. They share the
|
|
382
|
+
* `--theme-font-` prefix with the family variables, so the family lookup has
|
|
383
|
+
* to skip them: `--theme-font-size-body` is the body's size, never the family
|
|
384
|
+
* of a variant keyed `size-body`.
|
|
385
|
+
*/
|
|
386
|
+
export const isThemeTypographyMetricVariable = (name: string): boolean =>
|
|
387
|
+
Object.values(THEME_TYPOGRAPHY_VARIABLE_PREFIXES).some((prefix) =>
|
|
388
|
+
name.startsWith(prefix)
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
/** `:root{…}` with every variable above, or '' when there is none. */
|
|
392
|
+
export const buildThemeRoleVariablesCss = (
|
|
393
|
+
settings: ThemeSettingsInput
|
|
394
|
+
): string => {
|
|
395
|
+
const variables = collectThemeRootVariables(settings);
|
|
396
|
+
if (!variables.length) return '';
|
|
397
|
+
return neutralizeStyleTagBreakout(
|
|
398
|
+
`:root{${variables.map(([name, value]) => `${name}:${value}`).join(';')}}`
|
|
399
|
+
);
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Brings `style` (the document element's inline style) in line with `next`:
|
|
404
|
+
* sets every variable in it and removes the ones written last time that are
|
|
405
|
+
* gone now (a deleted custom colour, a role the merchant hid). Returns the
|
|
406
|
+
* new written set for the caller to keep. The five legacy colour variables
|
|
407
|
+
* are never written or removed here.
|
|
408
|
+
*
|
|
409
|
+
* Removal is safe on a page whose placeholders carry different settings
|
|
410
|
+
* (draft mode): each placeholder's server `<style>` stays in the cascade as
|
|
411
|
+
* the baseline, and inline values only ever override it.
|
|
412
|
+
*/
|
|
413
|
+
export const syncThemeRootVariables = (
|
|
414
|
+
style: Pick<CSSStyleDeclaration, 'setProperty' | 'removeProperty'>,
|
|
415
|
+
next: ReadonlyArray<ThemeRootVariable>,
|
|
416
|
+
previous: ReadonlySet<string>
|
|
417
|
+
): Set<string> => {
|
|
418
|
+
const variables = new Map<string, string>();
|
|
419
|
+
next.forEach(([name, value]) => {
|
|
420
|
+
if (!LEGACY_COLOR_VARIABLES.has(name)) variables.set(name, value);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
previous.forEach((name) => {
|
|
424
|
+
if (!variables.has(name) && !LEGACY_COLOR_VARIABLES.has(name)) {
|
|
425
|
+
style.removeProperty(name);
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
variables.forEach((value, name) => style.setProperty(name, value));
|
|
429
|
+
|
|
430
|
+
return new Set(variables.keys());
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
// ---------------------------------------------------------------------------
|
|
434
|
+
// Element defaults
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
|
|
437
|
+
const textDefaultsCache = new WeakMap<object, boolean>();
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Whether any rich-text element default is active: a valid text, heading,
|
|
441
|
+
* link or border colour, or a managed heading01/heading02/subheading/
|
|
442
|
+
* heading04/body/link variant. Gates the `data-pz-*` markers, so a site that
|
|
443
|
+
* never set these keys renders without a single new attribute.
|
|
444
|
+
*/
|
|
445
|
+
export const hasThemeTextDefaults = (settings: ThemeSettingsInput): boolean => {
|
|
446
|
+
if (!isRecord(settings)) return false;
|
|
447
|
+
const cached = textDefaultsCache.get(settings);
|
|
448
|
+
if (cached !== undefined) return cached;
|
|
449
|
+
|
|
450
|
+
const active =
|
|
451
|
+
Boolean(
|
|
452
|
+
readThemeRoleColor(settings, 'textColor') ||
|
|
453
|
+
readThemeRoleColor(settings, 'headingColor') ||
|
|
454
|
+
readThemeRoleColor(settings, 'linkColor') ||
|
|
455
|
+
readThemeRoleColor(settings, 'borderColor')
|
|
456
|
+
) ||
|
|
457
|
+
readManagedTypography(settings).some(({ key }) =>
|
|
458
|
+
TYPOGRAPHY_DEFAULT_KEYS.has(key)
|
|
459
|
+
);
|
|
460
|
+
|
|
461
|
+
textDefaultsCache.set(settings, active);
|
|
462
|
+
return active;
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
/** Rich text rendered by a theme placeholder (a text block's root). */
|
|
466
|
+
const RICH_TEXT = `:where(.theme-placeholder [${THEME_RICH_TEXT_ATTRIBUTE}])`;
|
|
467
|
+
/**
|
|
468
|
+
* An inline colour inside the rich text. The editor's text-colour tool wraps
|
|
469
|
+
* its selection in `<span style="color: …">`, around whole paragraphs or a
|
|
470
|
+
* heading, or around a link; older and pasted markup may use `<font color>`.
|
|
471
|
+
* A default set on the p, h2 or a inside would replace the colour it
|
|
472
|
+
* inherits from there.
|
|
473
|
+
*/
|
|
474
|
+
const INLINE_COLOR = `[${THEME_RICH_TEXT_ATTRIBUTE}] :is([style^="color"],[style*=";color"],[style*="; color"],font[color])`;
|
|
475
|
+
/** The same for inline typography, which only pasted or older markup carries. */
|
|
476
|
+
const INLINE_TYPE = `[${THEME_RICH_TEXT_ATTRIBUTE}] :is([style*="font-size"],[style*="font-family"],[style*="font-weight"],[style*="font:"],[style*="line-height"],[style*="letter-spacing"],[style*="text-transform"],font[size],font[face])`;
|
|
477
|
+
/** Not inside an element, or an inline style, that set its own colour. */
|
|
478
|
+
const NOT_OWN_COLOR = `:not(:where([${THEME_OWN_COLOR_ATTRIBUTE}] *,${INLINE_COLOR} *))`;
|
|
479
|
+
/** Not inside an element, or an inline style, that set its own typography. */
|
|
480
|
+
const NOT_OWN_TYPE = `:not(:where([${THEME_OWN_TYPE_ATTRIBUTE}] *,${INLINE_TYPE} *))`;
|
|
481
|
+
/** A link inside a heading is set in the heading's size, not the Link style's. */
|
|
482
|
+
const NOT_IN_HEADING = ':not(:where(:is(h1,h2,h3,h4,h5,h6) *))';
|
|
483
|
+
const FOOTER = ':where(.theme-placeholder[data-placeholder="footer"])';
|
|
484
|
+
|
|
485
|
+
/** Keywords, not names: quoted, `sans-serif` would name a font. */
|
|
486
|
+
const GENERIC_FONT_FAMILIES: ReadonlySet<string> = new Set([
|
|
487
|
+
'serif',
|
|
488
|
+
'sans-serif',
|
|
489
|
+
'monospace',
|
|
490
|
+
'cursive',
|
|
491
|
+
'fantasy',
|
|
492
|
+
'system-ui',
|
|
493
|
+
'ui-serif',
|
|
494
|
+
'ui-sans-serif',
|
|
495
|
+
'ui-monospace',
|
|
496
|
+
'ui-rounded',
|
|
497
|
+
'emoji',
|
|
498
|
+
'math',
|
|
499
|
+
'fangsong'
|
|
500
|
+
]);
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* A validated family as a `font-family` value with every name quoted. An
|
|
504
|
+
* unquoted name has to be a run of identifiers, so `Source Sans 3`, `Exo 2`
|
|
505
|
+
* or `Press Start 2P` would leave the declaration invalid and the text in
|
|
506
|
+
* the inherited face. A stack (`Roboto, sans-serif`, written by the old font
|
|
507
|
+
* select) keeps its generic family a keyword. The `--theme-font-<key>`
|
|
508
|
+
* variable is left as it always was, so existing output does not change.
|
|
509
|
+
*/
|
|
510
|
+
const toFontFamilyValue = (family: string): string | undefined => {
|
|
511
|
+
const names = family
|
|
512
|
+
.split(',')
|
|
513
|
+
.map((name) => name.replace(/["']/g, '').trim())
|
|
514
|
+
.filter(Boolean)
|
|
515
|
+
.map((name) =>
|
|
516
|
+
GENERIC_FONT_FAMILIES.has(name.toLowerCase())
|
|
517
|
+
? name.toLowerCase()
|
|
518
|
+
: `"${name}"`
|
|
519
|
+
);
|
|
520
|
+
return names.length ? names.join(',') : undefined;
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
const typographyDeclarations = (variant: ManagedTypographyVariant): string => {
|
|
524
|
+
const { key } = variant;
|
|
525
|
+
const prefixes = THEME_TYPOGRAPHY_VARIABLE_PREFIXES;
|
|
526
|
+
const family = variant.family ? toFontFamilyValue(variant.family) : '';
|
|
527
|
+
return [
|
|
528
|
+
family ? `font-family:${family}` : '',
|
|
529
|
+
variant.size !== undefined ? `font-size:var(${prefixes.size}${key})` : '',
|
|
530
|
+
variant.weight ? `font-weight:var(${prefixes.weight}${key})` : '',
|
|
531
|
+
`line-height:var(${prefixes.lineHeight}${key})`,
|
|
532
|
+
variant.letterSpacing !== undefined
|
|
533
|
+
? `letter-spacing:var(${prefixes.letterSpacing}${key})`
|
|
534
|
+
: '',
|
|
535
|
+
variant.transform ? `text-transform:var(${prefixes.transform}${key})` : ''
|
|
536
|
+
]
|
|
537
|
+
.filter(Boolean)
|
|
538
|
+
.join(';');
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* The global defaults, for the keys that are set and nothing else.
|
|
543
|
+
*
|
|
544
|
+
* Every element rule has specificity exactly (0,0,1): it beats Tailwind v3's
|
|
545
|
+
* unlayered preflight on source order (this `<style>` is in the body, after
|
|
546
|
+
* the head CSS), beats Tailwind v4's layered preflight, and loses to any
|
|
547
|
+
* class and to the block CSS. It is scoped to rich text because rich text
|
|
548
|
+
* carries no utility classes; aimed at bare `.theme-placeholder` descendants
|
|
549
|
+
* it would override pz-theme's own sections, brand sections, the portaled
|
|
550
|
+
* checkout and the designer chrome. The root-level rules (text colour and
|
|
551
|
+
* body typography on the rich-text root, the footer background) are (0,0,0).
|
|
552
|
+
*
|
|
553
|
+
* The `data-pz-own-*` exclusions keep authored values: a heading inside a
|
|
554
|
+
* section or group that set its own colour keeps inheriting it, and a text
|
|
555
|
+
* block that set its own font size is not retypeset. Inline colour and
|
|
556
|
+
* typography inside the rich text are kept the same way.
|
|
557
|
+
*/
|
|
558
|
+
export const buildThemeElementDefaultsCss = (
|
|
559
|
+
settings: ThemeSettingsInput
|
|
560
|
+
): string => {
|
|
561
|
+
if (!isRecord(settings)) return '';
|
|
562
|
+
|
|
563
|
+
const rules: string[] = [];
|
|
564
|
+
const vars = THEME_COLOR_ROLE_VARIABLES;
|
|
565
|
+
|
|
566
|
+
if (readThemeRoleColor(settings, 'textColor')) {
|
|
567
|
+
rules.push(
|
|
568
|
+
`${RICH_TEXT}${NOT_OWN_COLOR},${RICH_TEXT} :is(p,li)${NOT_OWN_COLOR}{color:var(${vars.textColor})}`
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
if (readThemeRoleColor(settings, 'headingColor')) {
|
|
572
|
+
rules.push(
|
|
573
|
+
`${RICH_TEXT} :is(h1,h2,h3,h4,h5,h6)${NOT_OWN_COLOR}{color:var(${vars.headingColor})}`
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
if (readThemeRoleColor(settings, 'linkColor')) {
|
|
577
|
+
rules.push(`${RICH_TEXT} a${NOT_OWN_COLOR}{color:var(${vars.linkColor})}`);
|
|
578
|
+
}
|
|
579
|
+
if (readThemeRoleColor(settings, 'borderColor')) {
|
|
580
|
+
rules.push(`${RICH_TEXT} hr{border-color:var(${vars.borderColor})}`);
|
|
581
|
+
}
|
|
582
|
+
if (readThemeRoleColor(settings, 'footerBackgroundColor')) {
|
|
583
|
+
rules.push(
|
|
584
|
+
`${FOOTER}{background-color:var(${vars.footerBackgroundColor})}`
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const managed = new Map(
|
|
589
|
+
readManagedTypography(settings).map((variant) => [variant.key, variant])
|
|
590
|
+
);
|
|
591
|
+
TYPOGRAPHY_ELEMENT_DEFAULTS.forEach(([key, element]) => {
|
|
592
|
+
const variant = managed.get(key);
|
|
593
|
+
if (!variant) return;
|
|
594
|
+
const selector =
|
|
595
|
+
key === 'body'
|
|
596
|
+
? `${RICH_TEXT}${NOT_OWN_TYPE},${RICH_TEXT} ${element}${NOT_OWN_TYPE}`
|
|
597
|
+
: key === 'link'
|
|
598
|
+
? `${RICH_TEXT} ${element}${NOT_IN_HEADING}${NOT_OWN_TYPE}`
|
|
599
|
+
: `${RICH_TEXT} ${element}${NOT_OWN_TYPE}`;
|
|
600
|
+
rules.push(`${selector}{${typographyDeclarations(variant)}}`);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
return rules.length ? neutralizeStyleTagBreakout(rules.join('')) : '';
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
// ---------------------------------------------------------------------------
|
|
607
|
+
// Authored-style markers
|
|
608
|
+
// ---------------------------------------------------------------------------
|
|
609
|
+
|
|
610
|
+
const toKebabCase = (key: string): string =>
|
|
611
|
+
key.includes('-')
|
|
612
|
+
? key
|
|
613
|
+
: key.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
|
|
614
|
+
|
|
615
|
+
const isAuthoredScalar = (value: unknown): boolean =>
|
|
616
|
+
(typeof value === 'string' && value.trim() !== '') ||
|
|
617
|
+
(typeof value === 'number' && Number.isFinite(value));
|
|
618
|
+
|
|
619
|
+
/** A scalar, or a per-breakpoint map with at least one authored value. */
|
|
620
|
+
const isAuthoredStyleValue = (value: unknown): boolean =>
|
|
621
|
+
isAuthoredScalar(value) ||
|
|
622
|
+
(isRecord(value) && Object.values(value).some(isAuthoredScalar));
|
|
623
|
+
|
|
624
|
+
const hasAuthoredKey = (
|
|
625
|
+
bucket: Record<string, unknown>,
|
|
626
|
+
keys: readonly string[]
|
|
627
|
+
): boolean =>
|
|
628
|
+
Object.entries(bucket).some(
|
|
629
|
+
([key, value]) =>
|
|
630
|
+
keys.includes(toKebabCase(key)) && isAuthoredStyleValue(value)
|
|
631
|
+
);
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Whether a block's or section's ROOT styles set any of `keys` (kebab-case),
|
|
635
|
+
* at any breakpoint, at rest or in a root state (`:hover/color`,
|
|
636
|
+
* `:scrolled/color`): the state rule paints the element too, and a default
|
|
637
|
+
* on the rich text inside would stop it reaching the text. Part keys
|
|
638
|
+
* (`label/color`) do not count: they style another element (see
|
|
639
|
+
* `themeOwnPartStyleAttributes` for a part that holds child blocks).
|
|
640
|
+
*/
|
|
641
|
+
export const hasAuthoredRootStyle = (
|
|
642
|
+
styles: unknown,
|
|
643
|
+
keys: readonly string[]
|
|
644
|
+
): boolean => {
|
|
645
|
+
if (!isRecord(styles)) return false;
|
|
646
|
+
const { root, rootStates } = splitPartStyles(styles);
|
|
647
|
+
return (
|
|
648
|
+
hasAuthoredKey(root, keys) ||
|
|
649
|
+
rootStates.some((bucket) => hasAuthoredKey(bucket.styles, keys))
|
|
650
|
+
);
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
const ownStyleMarkers = (
|
|
654
|
+
ownColor: boolean,
|
|
655
|
+
ownType: boolean
|
|
656
|
+
): Record<string, string> | null =>
|
|
657
|
+
ownColor || ownType
|
|
658
|
+
? {
|
|
659
|
+
...(ownColor ? { [THEME_OWN_COLOR_ATTRIBUTE]: '' } : null),
|
|
660
|
+
...(ownType ? { [THEME_OWN_TYPE_ATTRIBUTE]: '' } : null)
|
|
661
|
+
}
|
|
662
|
+
: null;
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Text colour or typography a renderer paints on an element itself, where no
|
|
666
|
+
* style object shows it (a class list such as `uppercase tracking-[0.12em]`).
|
|
667
|
+
*/
|
|
668
|
+
export interface ThemeOwnTextStyle {
|
|
669
|
+
color?: boolean;
|
|
670
|
+
type?: boolean;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* The `data-pz-own-color` / `data-pz-own-type` attributes for an element with
|
|
675
|
+
* these root styles (or painted as `owned` says), or null. Null whenever the
|
|
676
|
+
* theme has no rich-text defaults, so untouched sites gain no attribute.
|
|
677
|
+
*/
|
|
678
|
+
export const themeOwnStyleAttributes = (
|
|
679
|
+
styles: unknown,
|
|
680
|
+
settings: ThemeSettingsInput,
|
|
681
|
+
owned?: ThemeOwnTextStyle
|
|
682
|
+
): Record<string, string> | null => {
|
|
683
|
+
if (!hasThemeTextDefaults(settings)) return null;
|
|
684
|
+
|
|
685
|
+
return ownStyleMarkers(
|
|
686
|
+
owned?.color === true ||
|
|
687
|
+
hasAuthoredRootStyle(styles, THEME_OWN_COLOR_STYLE_KEYS),
|
|
688
|
+
owned?.type === true ||
|
|
689
|
+
hasAuthoredRootStyle(styles, THEME_OWN_TYPE_STYLE_KEYS)
|
|
690
|
+
);
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* The same attributes for one PART of a block, for a renderer whose part
|
|
695
|
+
* element holds child blocks (an accordion's header row and panel). A part
|
|
696
|
+
* colour or typography, at rest or in a state (`header/color`,
|
|
697
|
+
* `header/open/color`), paints that element, so the child text blocks have
|
|
698
|
+
* to keep inheriting it.
|
|
699
|
+
*/
|
|
700
|
+
export const themeOwnPartStyleAttributes = (
|
|
701
|
+
styles: unknown,
|
|
702
|
+
part: string,
|
|
703
|
+
settings: ThemeSettingsInput
|
|
704
|
+
): Record<string, string> | null => {
|
|
705
|
+
if (!hasThemeTextDefaults(settings) || !isRecord(styles)) return null;
|
|
706
|
+
|
|
707
|
+
const buckets = splitPartStyles(styles)
|
|
708
|
+
.parts.filter((bucket) => bucket.part === part)
|
|
709
|
+
.map((bucket) => bucket.styles);
|
|
710
|
+
|
|
711
|
+
return ownStyleMarkers(
|
|
712
|
+
buckets.some((bucket) =>
|
|
713
|
+
hasAuthoredKey(bucket, THEME_OWN_COLOR_STYLE_KEYS)
|
|
714
|
+
),
|
|
715
|
+
buckets.some((bucket) => hasAuthoredKey(bucket, THEME_OWN_TYPE_STYLE_KEYS))
|
|
716
|
+
);
|
|
717
|
+
};
|