@akinon/pz-theme 2.0.36-rc.0 → 2.0.37-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 -31
- package/jest/__mocks__/hookform-resolvers.js +5 -0
- package/jest/__mocks__/next-auth-react.js +5 -0
- package/jest.config.js +3 -0
- package/package.json +4 -4
- package/src/blocks/accordion-block.tsx +35 -3
- package/src/blocks/commerce-format.ts +220 -0
- package/src/blocks/icon-block.tsx +6 -1
- package/src/blocks/image-gallery-block.tsx +1 -1
- package/src/blocks/link-block.tsx +9 -1
- package/src/blocks/slider-block.tsx +2 -1
- package/src/blocks/tab-block.tsx +15 -2
- package/src/blocks/video-block.tsx +6 -0
- package/src/components/__tests__/part-state-preview-scope.test.tsx +148 -0
- package/src/components/action-toolbar.tsx +182 -3
- package/src/components/part-state-preview.tsx +92 -0
- package/src/hooks/__tests__/use-overlay.test.tsx +503 -0
- package/src/hooks/use-designer-features.tsx +16 -1
- package/src/hooks/use-overlay.ts +538 -0
- package/src/hooks/use-visibility-context.ts +14 -2
- package/src/sections/__tests__/divider-section.test.tsx +28 -1
- package/src/sections/contact-form-section.tsx +4 -1
- package/src/sections/coupon-banner-section.tsx +2 -1
- package/src/sections/featured-product-spotlight-section.tsx +4 -4
- package/src/sections/find-in-store-section.tsx +2 -1
- package/src/sections/hover-showcase-section.tsx +2 -1
- package/src/sections/installment-options-section.tsx +2 -1
- package/src/sections/order-tracking-lookup-section.tsx +6 -1
- package/src/sections/posts-slider-section.tsx +8 -3
- package/src/sections/pre-order-launch-banner-section.tsx +2 -1
- package/src/sections/tabs-section.tsx +7 -13
- package/src/theme-block.tsx +81 -16
- package/src/theme-placeholder-client.tsx +8 -1
- package/src/theme-placeholder-wrapper.tsx +17 -3
- package/src/theme-placeholder.tsx +51 -5
- package/src/theme-section.tsx +20 -4
- package/src/utils/__tests__/breakpoint-cascade.test.ts +351 -0
- package/src/utils/__tests__/group-box-chrome.test.ts +83 -0
- package/src/utils/__tests__/iterator-bindings.test.tsx +1178 -0
- package/src/utils/__tests__/part-ownership-guard.test.tsx +298 -0
- package/src/utils/breakpoints.ts +116 -0
- package/src/utils/custom-css.ts +233 -0
- package/src/utils/index.ts +353 -42
- package/src/utils/iterator-utils.ts +675 -62
- package/src/utils/page-context-discovery.test.ts +188 -0
- package/src/utils/page-context-discovery.ts +48 -4
- package/src/utils/part-styles.ts +229 -0
- package/src/utils/row-visibility.ts +273 -0
- package/src/utils/visibility-rules.test.ts +579 -0
- package/src/utils/visibility-rules.ts +346 -3
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render } from '@testing-library/react';
|
|
3
|
+
import ThemeBlock from '../../theme-block';
|
|
4
|
+
import { generateThemeCSS } from '../index';
|
|
5
|
+
import { buildPartSelectors } from '../part-styles';
|
|
6
|
+
import { buildIteratorBlock } from '../iterator-utils';
|
|
7
|
+
import type { Section } from '../../theme-section';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The part-ownership guard, pushed past the two-block case.
|
|
11
|
+
*
|
|
12
|
+
* A part rule is confined to its declaring block by excluding anything sitting
|
|
13
|
+
* inside a nested block. That is only safe while two things hold, and neither
|
|
14
|
+
* is obvious by reading the selector:
|
|
15
|
+
*
|
|
16
|
+
* 1. no renderer draws its own part inside a child block's wrapper — if one
|
|
17
|
+
* did, the guard would delete a style the block legitimately owns;
|
|
18
|
+
* 2. the exclusion reaches all the way down, not just one level.
|
|
19
|
+
*
|
|
20
|
+
* Both are asserted here against shipped renderers and against repeated rows,
|
|
21
|
+
* where clones deliberately share one style identity.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const group = (id: string, label: string, children: unknown[] = []) => ({
|
|
25
|
+
id,
|
|
26
|
+
type: 'group',
|
|
27
|
+
label,
|
|
28
|
+
properties: {},
|
|
29
|
+
styles: {},
|
|
30
|
+
order: 0,
|
|
31
|
+
hidden: false,
|
|
32
|
+
blocks: children
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const text = (id: string) => ({
|
|
36
|
+
id,
|
|
37
|
+
type: 'text',
|
|
38
|
+
label: 'Text',
|
|
39
|
+
value: '<p>copy</p>',
|
|
40
|
+
properties: {},
|
|
41
|
+
styles: {},
|
|
42
|
+
order: 0,
|
|
43
|
+
hidden: false
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const accordion = (
|
|
47
|
+
id: string,
|
|
48
|
+
styles: Record<string, unknown> = {},
|
|
49
|
+
inner?: unknown[]
|
|
50
|
+
) => ({
|
|
51
|
+
id,
|
|
52
|
+
type: 'accordion',
|
|
53
|
+
label: 'Accordion',
|
|
54
|
+
value: '',
|
|
55
|
+
properties: { defaultOpen: true },
|
|
56
|
+
styles,
|
|
57
|
+
order: 0,
|
|
58
|
+
hidden: false,
|
|
59
|
+
blocks: [
|
|
60
|
+
group(`${id}-heading`, 'Accordion Heading', [text(`${id}-title`)]),
|
|
61
|
+
group(`${id}-content`, 'Accordion Content', inner || [text(`${id}-copy`)])
|
|
62
|
+
]
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const sectionOf = (root: unknown): Section[] =>
|
|
66
|
+
[
|
|
67
|
+
{
|
|
68
|
+
id: 'sec-1',
|
|
69
|
+
type: 'custom',
|
|
70
|
+
name: 'Section',
|
|
71
|
+
label: 'Section',
|
|
72
|
+
order: 0,
|
|
73
|
+
hidden: false,
|
|
74
|
+
properties: {},
|
|
75
|
+
styles: {},
|
|
76
|
+
blocks: [root]
|
|
77
|
+
}
|
|
78
|
+
] as unknown as Section[];
|
|
79
|
+
|
|
80
|
+
const renderRoot = (root: unknown) =>
|
|
81
|
+
render(
|
|
82
|
+
<ThemeBlock
|
|
83
|
+
block={root as never}
|
|
84
|
+
placeholderId="p"
|
|
85
|
+
sectionId="sec-1"
|
|
86
|
+
isDesigner={false}
|
|
87
|
+
/>
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
/** Every selector the generated stylesheet aims at the given part. */
|
|
91
|
+
const selectorsFor = (css: string, part: string): string[] =>
|
|
92
|
+
css
|
|
93
|
+
.split('}')
|
|
94
|
+
.map((chunk) => chunk.slice(0, chunk.indexOf('{')))
|
|
95
|
+
.filter((selector) => selector.includes(`[data-part="${part}"]`))
|
|
96
|
+
.flatMap((selector) => selector.split(',').map((one) => one.trim()))
|
|
97
|
+
.filter(Boolean);
|
|
98
|
+
|
|
99
|
+
const ownersOf = (root: HTMLElement, selectors: string[]): string[] => {
|
|
100
|
+
const owners = new Set<string>();
|
|
101
|
+
|
|
102
|
+
selectors.forEach((selector) => {
|
|
103
|
+
root.querySelectorAll<HTMLElement>(selector).forEach((element) => {
|
|
104
|
+
owners.add(
|
|
105
|
+
element.closest('[data-block-id]')?.getAttribute('data-block-id') || '?'
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
return Array.from(owners).sort();
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const partSelector = (blockId: string, part: string): string =>
|
|
114
|
+
buildPartSelectors(`[data-block-id="${blockId}"]`, {
|
|
115
|
+
part,
|
|
116
|
+
state: null,
|
|
117
|
+
styles: {}
|
|
118
|
+
})[0];
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Every `data-part` element in the tree, checked against the rule its OWN
|
|
122
|
+
* block would generate. A non-empty result means the guard deleted a style the
|
|
123
|
+
* block owns — the one way this fix could be worse than the leak it replaces.
|
|
124
|
+
*/
|
|
125
|
+
const partsTheirOwnBlockCannotReach = (root: HTMLElement): string[] => {
|
|
126
|
+
const broken: string[] = [];
|
|
127
|
+
|
|
128
|
+
root.querySelectorAll<HTMLElement>('[data-part]').forEach((element) => {
|
|
129
|
+
const owner = element.closest('[data-block-id]');
|
|
130
|
+
if (!owner) return;
|
|
131
|
+
|
|
132
|
+
const ownerId = owner.getAttribute('data-block-id') as string;
|
|
133
|
+
const part = element.getAttribute('data-part') as string;
|
|
134
|
+
const selector = partSelector(ownerId, part);
|
|
135
|
+
|
|
136
|
+
if (selector && !element.matches(selector)) {
|
|
137
|
+
broken.push(`${ownerId}/${part}`);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
return Array.from(new Set(broken)).sort();
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
describe('the guard reaches every level, not just the first', () => {
|
|
145
|
+
const nest = (styled: string) =>
|
|
146
|
+
accordion(
|
|
147
|
+
'lvl1',
|
|
148
|
+
styled === 'lvl1' ? { 'header/color': { desktop: '#f00' } } : {},
|
|
149
|
+
[
|
|
150
|
+
accordion(
|
|
151
|
+
'lvl2',
|
|
152
|
+
styled === 'lvl2' ? { 'header/color': { desktop: '#f00' } } : {},
|
|
153
|
+
[
|
|
154
|
+
accordion(
|
|
155
|
+
'lvl3',
|
|
156
|
+
styled === 'lvl3' ? { 'header/color': { desktop: '#f00' } } : {}
|
|
157
|
+
)
|
|
158
|
+
]
|
|
159
|
+
)
|
|
160
|
+
]
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
it.each(['lvl1', 'lvl2', 'lvl3'])(
|
|
164
|
+
'a header style on %s reaches that block and no other',
|
|
165
|
+
(level) => {
|
|
166
|
+
const css = generateThemeCSS(sectionOf(nest(level)));
|
|
167
|
+
const { container } = renderRoot(nest('none'));
|
|
168
|
+
|
|
169
|
+
// Premise: three blocks really do name a header the same way.
|
|
170
|
+
expect(container.querySelectorAll('[data-part="header"]')).toHaveLength(3);
|
|
171
|
+
expect(ownersOf(container, selectorsFor(css, 'header'))).toEqual([level]);
|
|
172
|
+
}
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
it('holds on every breakpoint, not only the one that was tested', () => {
|
|
176
|
+
const styled = accordion(
|
|
177
|
+
'outer',
|
|
178
|
+
{ 'header/color': { desktop: '#f00', mobile: '#0f0' } },
|
|
179
|
+
[accordion('inner')]
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
['desktop', 'mobile', undefined].forEach((breakpoint) => {
|
|
183
|
+
const css = generateThemeCSS(sectionOf(styled), breakpoint);
|
|
184
|
+
const { container, unmount } = renderRoot(
|
|
185
|
+
accordion('outer', {}, [accordion('inner')])
|
|
186
|
+
);
|
|
187
|
+
const selectors = selectorsFor(css, 'header');
|
|
188
|
+
|
|
189
|
+
expect(selectors.length).toBeGreaterThan(0);
|
|
190
|
+
expect(ownersOf(container, selectors)).toEqual(['outer']);
|
|
191
|
+
unmount();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe('the guard never costs a block a part it owns', () => {
|
|
197
|
+
// The breadth case — one tree built from many part-stamped renderers — lands
|
|
198
|
+
// with the renderer vocabulary itself. Only a handful of renderers name their
|
|
199
|
+
// internals at this point, so asserting a wide sweep here would either be
|
|
200
|
+
// vacuous or force the whole block catalogue into this change.
|
|
201
|
+
|
|
202
|
+
it('across repeated rows, which share one style identity', () => {
|
|
203
|
+
const expanded = buildIteratorBlock({
|
|
204
|
+
iteratorBlock: {
|
|
205
|
+
id: 'it-1',
|
|
206
|
+
type: 'group',
|
|
207
|
+
label: 'Repeat',
|
|
208
|
+
isIterator: true,
|
|
209
|
+
blocks: [accordion('tpl')]
|
|
210
|
+
} as never,
|
|
211
|
+
items: [{ a: 1 }, { a: 2 }, { a: 3 }]
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const { container } = renderRoot(
|
|
215
|
+
accordion('shell', {}, (expanded.blocks || []) as unknown[])
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
// One authored rule, three rows: the clones keep the template's identity,
|
|
219
|
+
// so the rule has to keep matching all of them.
|
|
220
|
+
expect(container.querySelectorAll(partSelector('tpl', 'header'))).toHaveLength(3);
|
|
221
|
+
expect(container.querySelectorAll(partSelector('shell', 'header'))).toHaveLength(1);
|
|
222
|
+
expect(partsTheirOwnBlockCannotReach(container)).toEqual([]);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('across a repeat inside a repeat', () => {
|
|
226
|
+
const inner = buildIteratorBlock({
|
|
227
|
+
iteratorBlock: {
|
|
228
|
+
id: 'it-inner',
|
|
229
|
+
type: 'group',
|
|
230
|
+
label: 'Repeat',
|
|
231
|
+
isIterator: true,
|
|
232
|
+
blocks: [accordion('tpl-inner')]
|
|
233
|
+
} as never,
|
|
234
|
+
items: [{ a: 1 }, { a: 2 }]
|
|
235
|
+
});
|
|
236
|
+
const outer = buildIteratorBlock({
|
|
237
|
+
iteratorBlock: {
|
|
238
|
+
id: 'it-outer',
|
|
239
|
+
type: 'group',
|
|
240
|
+
label: 'Repeat',
|
|
241
|
+
isIterator: true,
|
|
242
|
+
blocks: [accordion('tpl-outer', {}, (inner.blocks || []) as unknown[])]
|
|
243
|
+
} as never,
|
|
244
|
+
items: [{ a: 1 }, { a: 2 }]
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const { container } = renderRoot(
|
|
248
|
+
accordion('shell', {}, (outer.blocks || []) as unknown[])
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
expect(container.querySelectorAll(partSelector('tpl-outer', 'header'))).toHaveLength(2);
|
|
252
|
+
expect(container.querySelectorAll(partSelector('tpl-inner', 'header'))).toHaveLength(4);
|
|
253
|
+
expect(partsTheirOwnBlockCannotReach(container)).toEqual([]);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe('a composition that names no part', () => {
|
|
258
|
+
it('is generated without a guard anywhere in it', () => {
|
|
259
|
+
// The feature is off until a designer types a part key; the stylesheet a
|
|
260
|
+
// published composition already ships must come out exactly as before.
|
|
261
|
+
const css = generateThemeCSS(
|
|
262
|
+
sectionOf(
|
|
263
|
+
accordion(
|
|
264
|
+
'outer',
|
|
265
|
+
{ backgroundColor: { desktop: '#fff' }, padding: { desktop: '8px' } },
|
|
266
|
+
[accordion('inner')]
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
expect(css).toContain('[data-block-id="outer"]');
|
|
272
|
+
expect(css).not.toContain(':where(');
|
|
273
|
+
expect(css).not.toContain(':not(');
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
describe('a block id that did not come from us', () => {
|
|
278
|
+
it('stays inside the quoted value on both copies of the selector', () => {
|
|
279
|
+
// The guard repeats the block selector, so a crafted id gets two chances
|
|
280
|
+
// to break out of the quoted value instead of one.
|
|
281
|
+
const css = generateThemeCSS(
|
|
282
|
+
sectionOf({
|
|
283
|
+
...accordion('safe', { 'header/color': { desktop: '#f00' } }),
|
|
284
|
+
styleSourceId: 'a"] , * { color: red } [x="'
|
|
285
|
+
})
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
// Whatever escaping the block selector received, the guard's copy of it is
|
|
289
|
+
// the identical string — so the guard cannot be the weaker of the two and
|
|
290
|
+
// the id gets no second chance at breaking out.
|
|
291
|
+
const blockSelector = css.slice(0, css.indexOf(' [data-part='));
|
|
292
|
+
|
|
293
|
+
expect(blockSelector).toContain('\\"');
|
|
294
|
+
expect(css).toContain(
|
|
295
|
+
`:not(:where(${blockSelector} [data-block-id] *))`
|
|
296
|
+
);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The breakpoints, and the two lines between them.
|
|
3
|
+
*
|
|
4
|
+
* There used to be one line. `1023px` was written out as a literal in the CSS
|
|
5
|
+
* generator, in the custom-CSS hatch and in six block renderers that emit their
|
|
6
|
+
* own scoped `<style>`; the number agreeing everywhere was a matter of nobody
|
|
7
|
+
* mistyping it. It lives here now, and the media-query strings are built from
|
|
8
|
+
* it rather than repeated.
|
|
9
|
+
*
|
|
10
|
+
* Where the second line sits was not invented. Every brand that ships a
|
|
11
|
+
* Tailwind config uses the same two stops — superstep_zero, whites-pz,
|
|
12
|
+
* occasion_next, lacostetr_next, minycenter-next-pz and next-barcin all
|
|
13
|
+
* declare `md: 768px` and `lg: 1024px`, and every hand-written tablet rule in
|
|
14
|
+
* those repos is written against one of them. The editor's own
|
|
15
|
+
* `DEFAULT_BREAKPOINTS` already listed a 768px tablet it had no way to author,
|
|
16
|
+
* and `posts-slider-section` already carried a `{ min: 768, max: 1023 }` band.
|
|
17
|
+
* So: desktop at 1024 and up, tablet 768-1023, mobile 767 and down.
|
|
18
|
+
*
|
|
19
|
+
* ## What a value at one breakpoint means for the others
|
|
20
|
+
*
|
|
21
|
+
* Desktop-first, because that is what the existing output already is: the
|
|
22
|
+
* desktop value is the unconditional rule and narrower breakpoints override it
|
|
23
|
+
* inside a `max-width` query. A third breakpoint does not get to introduce a
|
|
24
|
+
* second convention.
|
|
25
|
+
*
|
|
26
|
+
* `mobile` keeps the meaning it has always had — *narrower than desktop*,
|
|
27
|
+
* everything below 1024 — and `tablet` is a narrower override layered on top of
|
|
28
|
+
* it inside its own band. Read as a fall-through chain:
|
|
29
|
+
*
|
|
30
|
+
* desktop -> (authored desktop)
|
|
31
|
+
* mobile -> mobile ?? desktop
|
|
32
|
+
* tablet -> tablet ?? mobile ?? desktop
|
|
33
|
+
*
|
|
34
|
+
* which is exactly what the emitted cascade produces at each width, given the
|
|
35
|
+
* query order in `generateElementCSS`. That equivalence is the whole point: the
|
|
36
|
+
* renderer, the editor canvas and the stylesheet cannot disagree because they
|
|
37
|
+
* are three readings of the same chain.
|
|
38
|
+
*
|
|
39
|
+
* It is also the only choice that leaves today's compositions alone. Every
|
|
40
|
+
* published composition authors desktop and mobile and nothing else, and on
|
|
41
|
+
* those, tablet resolves to the mobile value and no tablet rule is emitted —
|
|
42
|
+
* byte for byte the stylesheet it produced before.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/** Narrowest width that is still "desktop". The existing line, unmoved. */
|
|
46
|
+
export const DESKTOP_MIN_WIDTH = 1024;
|
|
47
|
+
|
|
48
|
+
/** Narrowest width that is still "tablet". Below this is a phone. */
|
|
49
|
+
export const TABLET_MIN_WIDTH = 768;
|
|
50
|
+
|
|
51
|
+
export type BreakpointName = 'desktop' | 'tablet' | 'mobile';
|
|
52
|
+
|
|
53
|
+
/** Widest first, which is also authoring order in the editor. */
|
|
54
|
+
export const BREAKPOINTS: BreakpointName[] = ['desktop', 'tablet', 'mobile'];
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Where a breakpoint looks when it has nothing authored of its own. Absence
|
|
58
|
+
* falls through; it never resolves to an empty value.
|
|
59
|
+
*
|
|
60
|
+
* `desktop` and `mobile` are absent from this map on purpose — they behave
|
|
61
|
+
* exactly as they did before this file existed (`mobile` falls straight back to
|
|
62
|
+
* `desktop`, which the readers do unconditionally).
|
|
63
|
+
*/
|
|
64
|
+
export const BREAKPOINT_FALLBACKS: Record<string, BreakpointName[]> = {
|
|
65
|
+
tablet: ['mobile']
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The mobile query, unchanged and deliberately still `max-width: 1023px`
|
|
70
|
+
* rather than being narrowed to 767. Narrowing it would hand the 768-1023 band
|
|
71
|
+
* back to the desktop rule, which is a different-looking tablet on every
|
|
72
|
+
* composition already published.
|
|
73
|
+
*/
|
|
74
|
+
export const MOBILE_MEDIA_QUERY = `@media (max-width: ${
|
|
75
|
+
DESKTOP_MIN_WIDTH - 1
|
|
76
|
+
}px)`;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The tablet query. Emitted *after* the mobile query and at equal specificity,
|
|
80
|
+
* so within 768-1023 it is source order that makes tablet win — see
|
|
81
|
+
* `generateElementCSS`, and `breakpoint-cascade.test.ts` which asserts the
|
|
82
|
+
* order rather than trusting it.
|
|
83
|
+
*/
|
|
84
|
+
export const TABLET_MEDIA_QUERY = `@media (min-width: ${TABLET_MIN_WIDTH}px) and (max-width: ${
|
|
85
|
+
DESKTOP_MIN_WIDTH - 1
|
|
86
|
+
}px)`;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The same two queries without the spaces, for the scoped `<style>` a handful
|
|
90
|
+
* of block renderers inline next to their markup. They were written compact and
|
|
91
|
+
* are asserted compact by tests that pin those blocks' HTML.
|
|
92
|
+
*/
|
|
93
|
+
export const MOBILE_MEDIA_QUERY_COMPACT = `@media (max-width:${
|
|
94
|
+
DESKTOP_MIN_WIDTH - 1
|
|
95
|
+
}px)`;
|
|
96
|
+
|
|
97
|
+
export const TABLET_MEDIA_QUERY_COMPACT = `@media (min-width:${TABLET_MIN_WIDTH}px) and (max-width:${
|
|
98
|
+
DESKTOP_MIN_WIDTH - 1
|
|
99
|
+
}px)`;
|
|
100
|
+
|
|
101
|
+
/** The breakpoint a viewport of this width is being read at. */
|
|
102
|
+
export const breakpointForWidth = (width: number): BreakpointName => {
|
|
103
|
+
if (width >= DESKTOP_MIN_WIDTH) return 'desktop';
|
|
104
|
+
if (width >= TABLET_MIN_WIDTH) return 'tablet';
|
|
105
|
+
return 'mobile';
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* "Narrower than desktop" — the question the code that predates the tablet was
|
|
110
|
+
* actually asking whenever it compared against `'mobile'`. Answering it with a
|
|
111
|
+
* predicate rather than an equality check is what keeps those call sites
|
|
112
|
+
* behaving at 768-1023 exactly as they did when `'mobile'` was the only other
|
|
113
|
+
* value there was.
|
|
114
|
+
*/
|
|
115
|
+
export const isNarrowBreakpoint = (breakpoint?: string): boolean =>
|
|
116
|
+
breakpoint === 'mobile' || breakpoint === 'tablet';
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The escape hatch.
|
|
3
|
+
*
|
|
4
|
+
* Named parts and state controls cover the cases we anticipated. They will
|
|
5
|
+
* never cover all of them — a brand will always turn up wanting the third
|
|
6
|
+
* facet chip on mobile to wrap differently. Rather than grow the descriptor
|
|
7
|
+
* list forever, a block can carry raw CSS scoped to itself, where `&` is the
|
|
8
|
+
* block and `data-part` names its internals:
|
|
9
|
+
*
|
|
10
|
+
* & [data-part="dropdown"] { border-radius: 0; }
|
|
11
|
+
* & [data-part="option"][data-state~="selected"] { outline: 2px solid; }
|
|
12
|
+
*
|
|
13
|
+
* The block selector is prepended to anything that does not already mention
|
|
14
|
+
* `&`, so a designer cannot accidentally restyle the rest of the page. That
|
|
15
|
+
* containment is the point: this is a scoped hatch, not a stylesheet.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { MOBILE_MEDIA_QUERY, TABLET_MEDIA_QUERY } from './breakpoints';
|
|
19
|
+
|
|
20
|
+
/** Nothing here is a legitimate need in a block's scoped CSS, and each is a way out of it. */
|
|
21
|
+
const FORBIDDEN = [
|
|
22
|
+
/@import\b/i,
|
|
23
|
+
/@charset\b/i,
|
|
24
|
+
/javascript\s*:/i,
|
|
25
|
+
/expression\s*\(/i,
|
|
26
|
+
/behavior\s*:/i,
|
|
27
|
+
/-moz-binding/i,
|
|
28
|
+
/<\s*\/?\s*style/i,
|
|
29
|
+
/<\s*script/i
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const stripComments = (css: string): string =>
|
|
33
|
+
css.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
34
|
+
|
|
35
|
+
const bracesBalance = (css: string): boolean => {
|
|
36
|
+
let depth = 0;
|
|
37
|
+
|
|
38
|
+
for (const char of css) {
|
|
39
|
+
if (char === '{') depth += 1;
|
|
40
|
+
if (char === '}') {
|
|
41
|
+
depth -= 1;
|
|
42
|
+
// A closing brace with nothing open means the author has already left
|
|
43
|
+
// their own block and is styling the page.
|
|
44
|
+
if (depth < 0) return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return depth === 0;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Splits top-level rules so each selector list can be scoped individually.
|
|
53
|
+
* At-rules that carry a body (`@media`, `@supports`, `@container`) keep their
|
|
54
|
+
* body and are scoped from the inside.
|
|
55
|
+
*/
|
|
56
|
+
const splitTopLevelRules = (css: string): string[] => {
|
|
57
|
+
const rules: string[] = [];
|
|
58
|
+
let depth = 0;
|
|
59
|
+
let start = 0;
|
|
60
|
+
|
|
61
|
+
for (let i = 0; i < css.length; i++) {
|
|
62
|
+
const char = css[i];
|
|
63
|
+
if (char === '{') depth += 1;
|
|
64
|
+
if (char === '}') {
|
|
65
|
+
depth -= 1;
|
|
66
|
+
if (depth === 0) {
|
|
67
|
+
rules.push(css.slice(start, i + 1));
|
|
68
|
+
start = i + 1;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const tail = css.slice(start).trim();
|
|
74
|
+
if (tail) rules.push(tail);
|
|
75
|
+
|
|
76
|
+
return rules.map(rule => rule.trim()).filter(Boolean);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const scopeSelectorList = (selectors: string, scope: string): string =>
|
|
80
|
+
selectors
|
|
81
|
+
.split(',')
|
|
82
|
+
.map(selector => selector.trim())
|
|
83
|
+
.filter(Boolean)
|
|
84
|
+
.map(selector =>
|
|
85
|
+
selector.includes('&')
|
|
86
|
+
? selector.split('&').join(scope)
|
|
87
|
+
: `${scope} ${selector}`
|
|
88
|
+
)
|
|
89
|
+
.join(', ');
|
|
90
|
+
|
|
91
|
+
const scopeRule = (rule: string, scope: string): string => {
|
|
92
|
+
const open = rule.indexOf('{');
|
|
93
|
+
if (open === -1) return '';
|
|
94
|
+
|
|
95
|
+
const prelude = rule.slice(0, open).trim();
|
|
96
|
+
const body = rule.slice(open + 1, rule.lastIndexOf('}'));
|
|
97
|
+
|
|
98
|
+
if (prelude.startsWith('@')) {
|
|
99
|
+
// `@keyframes` and `@font-face` have no selectors to scope — their bodies
|
|
100
|
+
// are keyframe stops and descriptors. Scoping them turned a working
|
|
101
|
+
// animation into a dead at-rule, which is why a spinner could not be
|
|
102
|
+
// authored at all.
|
|
103
|
+
if (/^@(keyframes|-\w+-keyframes|font-face|property)\b/i.test(prelude)) {
|
|
104
|
+
return rule;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Conditional group rules: scope what is inside, keep the condition.
|
|
108
|
+
const inner = splitTopLevelRules(body)
|
|
109
|
+
.map(innerRule => scopeRule(innerRule, scope))
|
|
110
|
+
.filter(Boolean)
|
|
111
|
+
.join('\n');
|
|
112
|
+
|
|
113
|
+
return inner ? `${prelude} {\n${inner}\n}` : '';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return `${scopeSelectorList(prelude, scope)} {${body}}`;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Returns CSS scoped to `scope`, or an empty string when the input is unsafe.
|
|
121
|
+
* Silently dropping beats emitting something the author did not intend.
|
|
122
|
+
*/
|
|
123
|
+
export const scopeCustomCss = (raw: unknown, scope: string): string => {
|
|
124
|
+
if (typeof raw !== 'string') return '';
|
|
125
|
+
|
|
126
|
+
const css = stripComments(raw).trim();
|
|
127
|
+
if (!css) return '';
|
|
128
|
+
|
|
129
|
+
if (FORBIDDEN.some(pattern => pattern.test(css))) return '';
|
|
130
|
+
if (!bracesBalance(css)) return '';
|
|
131
|
+
|
|
132
|
+
// Bare declarations ("color: red") are the common case for "just this
|
|
133
|
+
// block", so they get wrapped rather than rejected.
|
|
134
|
+
if (!css.includes('{')) {
|
|
135
|
+
return `${scope} {\n${css}\n}\n`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
splitTopLevelRules(css)
|
|
140
|
+
.map(rule => scopeRule(rule, scope))
|
|
141
|
+
.filter(Boolean)
|
|
142
|
+
.join('\n') + '\n'
|
|
143
|
+
);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/** Reads one breakpoint's custom CSS off a block. */
|
|
147
|
+
export const readCustomCss = (
|
|
148
|
+
properties: Record<string, unknown> | undefined | null,
|
|
149
|
+
targetBreakpoint?: string
|
|
150
|
+
): string => {
|
|
151
|
+
const entry = properties?.customCss ?? properties?.['custom-css'];
|
|
152
|
+
if (entry === undefined || entry === null) return '';
|
|
153
|
+
|
|
154
|
+
if (typeof entry === 'string') return entry;
|
|
155
|
+
|
|
156
|
+
if (typeof entry === 'object') {
|
|
157
|
+
const responsive = entry as {
|
|
158
|
+
desktop?: unknown;
|
|
159
|
+
tablet?: unknown;
|
|
160
|
+
mobile?: unknown;
|
|
161
|
+
};
|
|
162
|
+
// The same fall-through the styles use: a tablet with nothing of its own
|
|
163
|
+
// shows what the mobile bucket puts on screen at that width, not desktop.
|
|
164
|
+
const value =
|
|
165
|
+
targetBreakpoint === 'mobile'
|
|
166
|
+
? responsive.mobile ?? responsive.desktop
|
|
167
|
+
: targetBreakpoint === 'tablet'
|
|
168
|
+
? responsive.tablet ?? responsive.mobile ?? responsive.desktop
|
|
169
|
+
: responsive.desktop;
|
|
170
|
+
return typeof value === 'string' ? value : '';
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return '';
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* All of a block's custom CSS, scoped.
|
|
178
|
+
*
|
|
179
|
+
* On the storefront there is no target breakpoint — the same stylesheet has
|
|
180
|
+
* to serve all of them — so reading a single bucket silently dropped
|
|
181
|
+
* everything a designer wrote for a narrower viewport. Each is emitted inside
|
|
182
|
+
* the same media query the rest of the generator uses, tablet after mobile so
|
|
183
|
+
* source order settles the 768-1023 band. In the designer a breakpoint IS
|
|
184
|
+
* given, and only that bucket is emitted, because the canvas is already
|
|
185
|
+
* showing one viewport.
|
|
186
|
+
*/
|
|
187
|
+
export const buildCustomCss = (
|
|
188
|
+
properties: Record<string, unknown> | undefined | null,
|
|
189
|
+
scope: string,
|
|
190
|
+
targetBreakpoint?: string
|
|
191
|
+
): string => {
|
|
192
|
+
if (targetBreakpoint) {
|
|
193
|
+
return scopeCustomCss(readCustomCss(properties, targetBreakpoint), scope);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const desktop = scopeCustomCss(readCustomCss(properties), scope);
|
|
197
|
+
|
|
198
|
+
const entry = properties?.customCss ?? properties?.['custom-css'];
|
|
199
|
+
const responsive =
|
|
200
|
+
entry && typeof entry === 'object'
|
|
201
|
+
? (entry as { desktop?: unknown; tablet?: unknown; mobile?: unknown })
|
|
202
|
+
: null;
|
|
203
|
+
const mobileRaw = responsive ? responsive.mobile : null;
|
|
204
|
+
const tabletRaw = responsive ? responsive.tablet : null;
|
|
205
|
+
|
|
206
|
+
// Only a value that differs from desktop is worth a second rule.
|
|
207
|
+
const desktopRaw = responsive ? responsive.desktop : entry;
|
|
208
|
+
|
|
209
|
+
let css = desktop;
|
|
210
|
+
|
|
211
|
+
if (typeof mobileRaw === 'string' && mobileRaw.trim()) {
|
|
212
|
+
if (mobileRaw !== desktopRaw) {
|
|
213
|
+
const mobile = scopeCustomCss(mobileRaw, scope);
|
|
214
|
+
if (mobile) css += `${MOBILE_MEDIA_QUERY} {\n${mobile}}\n`;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// A tablet rule earns its bytes only against what the band would already
|
|
219
|
+
// show: the mobile block when there is one, otherwise desktop.
|
|
220
|
+
const inheritedRaw =
|
|
221
|
+
typeof mobileRaw === 'string' && mobileRaw.trim() ? mobileRaw : desktopRaw;
|
|
222
|
+
|
|
223
|
+
if (
|
|
224
|
+
typeof tabletRaw === 'string' &&
|
|
225
|
+
tabletRaw.trim() &&
|
|
226
|
+
tabletRaw !== inheritedRaw
|
|
227
|
+
) {
|
|
228
|
+
const tablet = scopeCustomCss(tabletRaw, scope);
|
|
229
|
+
if (tablet) css += `${TABLET_MEDIA_QUERY} {\n${tablet}}\n`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return css;
|
|
233
|
+
};
|