@akinon/pz-theme 2.0.82-beta.0 → 2.0.82

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.
@@ -156,6 +156,74 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
156
156
  const slideGap = parseNumber(getConfigEntry('slide-gap'), 16);
157
157
  const slidePadding = slideGap / 2;
158
158
 
159
+ /**
160
+ * Edge treatment, the same vocabulary the slider SECTIONS grew — this block
161
+ * is a second, independent renderer of the same carousel (a Featured Brand
162
+ * Collection is built from it), and an author who learned the setting on one
163
+ * should find it on the other.
164
+ *
165
+ * `flush-edges` moves the whole gap to one side so the outer edges carry
166
+ * none, which is what lets the last card's content reach the band; a
167
+ * FRACTIONAL item count is what slices the next one and says "there is
168
+ * more". `lead-in` is not padding — no padding is position-dependent — so it
169
+ * rides the library's own `additionalTransfrom` and is handed back the
170
+ * moment the track leaves the start.
171
+ */
172
+ const flushEdges =
173
+ getResponsiveValue(
174
+ getConfigEntry('flush-edges'),
175
+ currentBreakpoint,
176
+ false
177
+ ) === true;
178
+ const leadIn = clampNumber(
179
+ parseNumber(
180
+ getResponsiveValue(getConfigEntry('lead-in'), currentBreakpoint, 0),
181
+ 0
182
+ ),
183
+ 0,
184
+ 400
185
+ );
186
+ /**
187
+ * Which edge the gap belongs to, and when it exists.
188
+ *
189
+ * `left` is a LEAD-IN: it sits before the first card while the track is at
190
+ * the start and slides away the moment it moves. `right` is its mirror, a
191
+ * TRAIL-OUT at the far end — because a right-hand gap at the START is not
192
+ * something the geometry can offer: what sits against the right edge there
193
+ * is the sliced next card, and how much of it shows is the item count's
194
+ * job, not a gap's.
195
+ *
196
+ * Both ride the library's own `additionalTransfrom`: +N pushes the track
197
+ * right and opens the left gap, -N pulls it left and opens the right one.
198
+ */
199
+ const leadInSide =
200
+ String(
201
+ getResponsiveValue(
202
+ getConfigEntry('lead-in-side'),
203
+ currentBreakpoint,
204
+ 'left'
205
+ ) ?? 'left'
206
+ ) === 'right'
207
+ ? 'right'
208
+ : 'left';
209
+
210
+ // This renderer only distinguishes narrow from wide, so the item count it
211
+ // is actually showing is the one its own `slidesToSlide` picks from.
212
+ const itemsShown = isNarrowBreakpoint(currentBreakpoint)
213
+ ? mobileItems
214
+ : desktopItems;
215
+ const useLeadIn = leadIn > 0 && visibleCount > itemsShown && !useInfinite;
216
+ const [slidePosition, setSlidePosition] = React.useState(0);
217
+ const lastPosition = Math.max(0, visibleCount - itemsShown);
218
+ const edgeOffset =
219
+ leadInSide === 'left'
220
+ ? slidePosition <= 0
221
+ ? leadIn
222
+ : 0
223
+ : slidePosition >= lastPosition - 0.001
224
+ ? -leadIn
225
+ : 0;
226
+
159
227
  const blockStyles = block.styles || {};
160
228
  const sectionStyles = getCSSStyles(
161
229
  typeof blockStyles === 'object' && blockStyles !== null
@@ -620,6 +688,16 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
620
688
  autoPlaySpeed={autoPlaySpeed}
621
689
  containerClass="slider-carousel"
622
690
  dotListClass="slider-dots"
691
+ /*
692
+ * Spread conditionally so a slider that has not opted in is handed
693
+ * the identical prop object it was handed before.
694
+ */
695
+ {...(useLeadIn
696
+ ? {
697
+ additionalTransfrom: edgeOffset,
698
+ beforeChange: (nextSlide: number) => setSlidePosition(nextSlide)
699
+ }
700
+ : {})}
623
701
  customLeftArrow={
624
702
  !isDesigner && showArrows && !arrowsOutside ? (
625
703
  <CustomLeftArrow />
@@ -640,10 +718,20 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
640
718
  return (
641
719
  <div
642
720
  key={childBlock.id}
643
- style={{
644
- paddingLeft: slidePadding,
645
- paddingRight: slidePadding
646
- }}
721
+ style={
722
+ flushEdges
723
+ ? {
724
+ // The gap BETWEEN cards is unchanged — it just all sits
725
+ // on one side, so the outer edges carry none.
726
+ paddingLeft: 0,
727
+ paddingRight:
728
+ index === sortedBlocks.length - 1 ? 0 : slideGap
729
+ }
730
+ : {
731
+ paddingLeft: slidePadding,
732
+ paddingRight: slidePadding
733
+ }
734
+ }
647
735
  >
648
736
  <ThemeBlock
649
737
  block={renderedChildBlock}
@@ -1,11 +1,39 @@
1
1
  'use client';
2
2
 
3
3
  import { useLocalization } from '@akinon/next/hooks';
4
- import React, { useMemo } from 'react';
4
+ import React from 'react';
5
5
  import { BlockRendererProps } from './block-renderer-registry';
6
6
  import { getCSSStyles } from '../utils';
7
7
  import { useThemeSettingsContext } from '../theme-settings-context';
8
8
 
9
+ /**
10
+ * The block's box geometry, which this div must not wear.
11
+ *
12
+ * Everything else in a text block's style map has to be applied here, and not
13
+ * because the stylesheet forgot it: a bound value (`{{item.badgeColor}}`) is
14
+ * deliberately stripped from the shared `[data-block-id]` rule, because every
15
+ * row of an iterator shares that id and a rule cannot hold a different colour
16
+ * per row. Inline is the only place a per-row value can land.
17
+ *
18
+ * Margin and padding are the exception. They describe the block's box, the
19
+ * stylesheet already puts them on the `[data-block-id]` wrapper this div renders
20
+ * inside, and a box inside a box means they compound — a designer setting 29px
21
+ * under a heading got 29 on the wrapper and 29 again here, with nothing in the
22
+ * inspector to explain the second one. Measured on a live page: nine blocks
23
+ * carried the same margin at both levels.
24
+ *
25
+ * A BOUND margin is the one thing this gives up, and it is not a thing: row data
26
+ * carries prices and colours, not the spacing of the box it is drawn in.
27
+ */
28
+ const BOX_GEOMETRY_PREFIXES = ['margin', 'padding'];
29
+
30
+ const isBoxGeometryKey = (key: string): boolean => {
31
+ const normalized = key.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
32
+ return BOX_GEOMETRY_PREFIXES.some(
33
+ (prefix) => normalized === prefix || normalized.startsWith(`${prefix}-`)
34
+ );
35
+ };
36
+
9
37
  const TextBlock = ({
10
38
  block,
11
39
  currentBreakpoint = 'desktop'
@@ -40,12 +68,22 @@ const TextBlock = ({
40
68
 
41
69
  const content = getLocalizedContent();
42
70
 
43
- const wrapperStyles = useMemo(() => {
44
- return getCSSStyles(block.styles || {}, themeSettings, currentBreakpoint);
71
+ const wrapperStyles = React.useMemo(() => {
72
+ const styles = block.styles || {};
73
+ const withoutBoxGeometry = Object.fromEntries(
74
+ Object.entries(styles).filter(([key]) => !isBoxGeometryKey(key))
75
+ );
76
+
77
+ return getCSSStyles(withoutBoxGeometry, themeSettings, currentBreakpoint);
45
78
  }, [block.styles, themeSettings, currentBreakpoint]);
46
79
 
80
+ const hasStyles = Object.keys(wrapperStyles).length > 0;
81
+
47
82
  return (
48
- <div style={wrapperStyles} dangerouslySetInnerHTML={{ __html: content }} />
83
+ <div
84
+ {...(hasStyles ? { style: wrapperStyles } : null)}
85
+ dangerouslySetInnerHTML={{ __html: content }}
86
+ />
49
87
  );
50
88
  };
51
89
 
@@ -1,78 +1,197 @@
1
1
  import React from 'react';
2
+ import { getResponsiveValue } from '../utils';
2
3
  import { BlockRendererProps } from './block-renderer-registry';
3
4
 
4
- const getVideoEmbedUrl = (
5
- url: string,
6
- properties?: {
7
- autoplay?: boolean;
8
- loop?: boolean;
9
- muted?: boolean;
10
- controls?: boolean;
5
+ /**
6
+ * What an author's URL turned out to be.
7
+ *
8
+ * Tagged rather than inferred: the branch that picks `<iframe>` over `<video>`
9
+ * used to re-read the parser's own OUTPUT for the string 'youtube.com', so a
10
+ * file whose path happened to contain that text rendered in the wrong element.
11
+ * The parser already knows which it produced — it should say so.
12
+ */
13
+ type ParsedVideo =
14
+ | { kind: 'embed'; src: string; defaultRatio?: string }
15
+ | { kind: 'file'; src: string }
16
+ | null;
17
+
18
+ interface VideoProperties {
19
+ autoplay?: boolean;
20
+ loop?: boolean;
21
+ muted?: boolean;
22
+ controls?: boolean;
23
+ poster?: unknown;
24
+ playsInline?: boolean;
25
+ /**
26
+ * `auto` reads the address; `file` says "trust me, this plays". The escape
27
+ * hatch exists because a signed CDN link often carries no extension at all,
28
+ * and guessing from a url that merely mentions `.mp4` somewhere would let
29
+ * `…/watch?ref=clip.mp4` through as a playable file.
30
+ */
31
+ sourceType?: string;
32
+ }
33
+
34
+ /** Hosts, not substrings: `evil.com/?u=youtube.com/watch?v=x` is not YouTube. */
35
+ const YOUTUBE_HOSTS = /(^|\.)(youtube\.com|youtube-nocookie\.com|youtu\.be)$/i;
36
+
37
+ /**
38
+ * The full YouTube id alphabet. Also the guard that stops an author-supplied
39
+ * fragment being interpolated into an embed URL unencoded.
40
+ */
41
+ const YOUTUBE_ID = /^[A-Za-z0-9_-]{6,20}$/;
42
+
43
+ const VIMEO_HOSTS = /(^|\.)vimeo\.com$/i;
44
+ const VIMEO_ID = /^\d+$/;
45
+
46
+ /** Extensions worth recognising on a path. Matched against the PATHNAME. */
47
+ const FILE_EXTENSION = /\.(mp4|webm|ogg|ogv|m4v|mov)$/i;
48
+
49
+ /**
50
+ * Only ever http(s). Verified before this guard existed:
51
+ * `javascript:alert(1)//x.mp4` and `data:text/html,…//x.mp4` both satisfied
52
+ * the old extension regex and were handed straight to `<video src>`.
53
+ */
54
+ const SAFE_PROTOCOLS = new Set(['http:', 'https:']);
55
+
56
+ const parseUrl = (url: string): URL | null => {
57
+ try {
58
+ // A base is needed for relative paths (`/media/clip.mp4`), which are a
59
+ // legitimate way to serve a clip from the storefront's own public folder.
60
+ return new URL(url, 'https://theme.invalid');
61
+ } catch {
62
+ return null;
11
63
  }
64
+ };
65
+
66
+ const buildEmbedParams = (
67
+ properties: VideoProperties | undefined,
68
+ videoId: string,
69
+ loopNeedsPlaylist: boolean
12
70
  ) => {
71
+ const params = new URLSearchParams();
72
+ if (properties?.autoplay) params.append('autoplay', '1');
73
+ if (properties?.loop) {
74
+ params.append('loop', '1');
75
+ if (loopNeedsPlaylist) params.append('playlist', videoId);
76
+ }
77
+ if (properties?.muted) {
78
+ params.append(loopNeedsPlaylist ? 'mute' : 'muted', '1');
79
+ }
80
+ if (properties?.controls === false) params.append('controls', '0');
81
+
82
+ const queryString = params.toString();
83
+ return queryString ? `?${queryString}` : '';
84
+ };
85
+
86
+ export const parseVideoSource = (
87
+ url: string,
88
+ properties?: VideoProperties
89
+ ): ParsedVideo => {
13
90
  if (!url || typeof url !== 'string') return null;
14
91
 
15
- if (url.includes('youtube.com') || url.includes('youtu.be')) {
16
- let videoId = '';
17
- if (url.includes('youtu.be/')) {
18
- videoId = url.split('youtu.be/')[1].split('?')[0];
19
- } else if (url.includes('watch?v=')) {
20
- videoId = url.split('watch?v=')[1].split('&')[0];
21
- } else if (url.includes('embed/')) {
22
- videoId = url.split('embed/')[1].split('?')[0];
23
- }
92
+ const parsed = parseUrl(url.trim());
93
+ if (!parsed) return null;
24
94
 
25
- if (!videoId) return null;
95
+ const host = parsed.hostname;
96
+ const path = parsed.pathname;
26
97
 
27
- const params = new URLSearchParams();
28
- if (properties?.autoplay) params.append('autoplay', '1');
29
- if (properties?.loop) {
30
- params.append('loop', '1');
31
- params.append('playlist', videoId);
32
- }
33
- if (properties?.muted) params.append('mute', '1');
34
- if (properties?.controls === false) params.append('controls', '0');
98
+ if (YOUTUBE_HOSTS.test(host)) {
99
+ let videoId = '';
100
+ let defaultRatio: string | undefined;
35
101
 
36
- const queryString = params.toString();
37
- return `https://www.youtube.com/embed/${videoId}${
38
- queryString ? '?' + queryString : ''
39
- }`;
40
- }
102
+ if (/(^|\.)youtu\.be$/i.test(host)) {
103
+ videoId = path.slice(1);
104
+ } else if (path === '/watch') {
105
+ videoId = parsed.searchParams.get('v') ?? '';
106
+ } else if (path.startsWith('/embed/')) {
107
+ videoId = path.slice('/embed/'.length);
108
+ } else if (path.startsWith('/shorts/')) {
109
+ videoId = path.slice('/shorts/'.length);
110
+ // A Short is filmed vertical. Supplying the ratio here is what makes
111
+ // pasting the link enough — the author sets nothing.
112
+ defaultRatio = '9/16';
113
+ } else if (path.startsWith('/live/')) {
114
+ videoId = path.slice('/live/'.length);
115
+ }
41
116
 
42
- if (url.includes('vimeo.com')) {
43
- const videoId = url.split('vimeo.com/')[1]?.split('?')[0];
117
+ videoId = videoId.split('/')[0];
118
+ if (!YOUTUBE_ID.test(videoId)) return null;
44
119
 
45
- if (!videoId) return null;
120
+ return {
121
+ kind: 'embed',
122
+ // The emitted host stays `www.youtube.com`. Accepting a nocookie link as
123
+ // INPUT is a parsing fix; switching what we emit is a privacy decision
124
+ // for the brand to make, and there is no consent gate here to hang it on.
125
+ src: `https://www.youtube.com/embed/${videoId}${buildEmbedParams(
126
+ properties,
127
+ videoId,
128
+ true
129
+ )}`,
130
+ defaultRatio
131
+ };
132
+ }
46
133
 
47
- const params = new URLSearchParams();
48
- if (properties?.autoplay) params.append('autoplay', '1');
49
- if (properties?.loop) params.append('loop', '1');
50
- if (properties?.muted) params.append('muted', '1');
51
- if (properties?.controls === false) params.append('controls', '0');
134
+ if (VIMEO_HOSTS.test(host)) {
135
+ const videoId = path.slice(1).split('/')[0];
136
+ if (!VIMEO_ID.test(videoId)) return null;
52
137
 
53
- const queryString = params.toString();
54
- return `https://player.vimeo.com/video/${videoId}${
55
- queryString ? '?' + queryString : ''
56
- }`;
138
+ return {
139
+ kind: 'embed',
140
+ src: `https://player.vimeo.com/video/${videoId}${buildEmbedParams(
141
+ properties,
142
+ videoId,
143
+ false
144
+ )}`
145
+ };
57
146
  }
58
147
 
59
- if (url.match(/\.(mp4|webm|ogg)$/i)) {
60
- return url;
148
+ if (!SAFE_PROTOCOLS.has(parsed.protocol)) return null;
149
+
150
+ // The extension is read off the PATH, so a signed or cache-busted address
151
+ // (`…/clip.mp4?sig=…`) plays. The old test matched the whole string and
152
+ // anchored at its end, which rejected every url carrying a query — the
153
+ // ordinary shape of an uploaded asset.
154
+ if (FILE_EXTENSION.test(path) || properties?.sourceType === 'file') {
155
+ return { kind: 'file', src: url };
61
156
  }
62
157
 
63
158
  return null;
64
159
  };
65
160
 
66
- const VideoBlock = ({ block }: BlockRendererProps) => {
67
- const videoUrl = block.value;
68
- const properties = block.properties || {};
161
+ /**
162
+ * A video: a YouTube or Vimeo embed, or a file the browser plays itself.
163
+ *
164
+ * Properties: url (on `block.value`), controls, autoplay, loop, muted,
165
+ * playsInline, poster, sourceType. Style: `aspect-ratio`.
166
+ */
167
+ const VideoBlock = ({
168
+ block,
169
+ currentBreakpoint = 'desktop'
170
+ }: BlockRendererProps) => {
171
+ const videoUrl = block.value as string;
172
+ const properties = (block.properties || {}) as VideoProperties;
69
173
 
70
- const embedUrl = getVideoEmbedUrl(videoUrl, properties);
174
+ const parsed = parseVideoSource(videoUrl, properties);
71
175
 
72
- let content;
176
+ /**
177
+ * The author's choice wins; a Short supplies its own when they made none.
178
+ * With neither, the historic fixed height is kept, so every composition
179
+ * already rendering a video keeps its exact box.
180
+ */
181
+ const authored = getResponsiveValue(
182
+ block.styles?.['aspect-ratio'],
183
+ currentBreakpoint,
184
+ undefined
185
+ ) as string | undefined;
186
+ const ratio =
187
+ authored && authored !== 'auto'
188
+ ? authored
189
+ : parsed?.kind === 'embed'
190
+ ? parsed.defaultRatio
191
+ : undefined;
73
192
 
74
- if (!videoUrl || !embedUrl) {
75
- content = (
193
+ if (!videoUrl || !parsed) {
194
+ return (
76
195
  <div
77
196
  style={{
78
197
  minHeight: '300px',
@@ -87,42 +206,51 @@ const VideoBlock = ({ block }: BlockRendererProps) => {
87
206
  {!videoUrl ? 'No video URL provided' : 'Invalid video URL'}
88
207
  </div>
89
208
  );
90
- } else if (embedUrl.includes('youtube.com') || embedUrl.includes('vimeo.com')) {
91
- content = (
209
+ }
210
+
211
+ if (parsed.kind === 'embed') {
212
+ return (
92
213
  <iframe
93
- src={embedUrl}
94
- style={{
95
- width: '100%',
96
- height: '400px',
97
- border: 'none'
98
- }}
214
+ src={parsed.src}
215
+ style={
216
+ ratio
217
+ ? // The ratio decides the height. Asking for `height: 100%` instead
218
+ // collapses the player to nothing whenever the box around it is
219
+ // auto-height — which is what a block styled by its ratio is.
220
+ {
221
+ width: '100%',
222
+ height: 'auto',
223
+ aspectRatio: ratio,
224
+ border: 'none'
225
+ }
226
+ : { width: '100%', height: '400px', border: 'none' }
227
+ }
99
228
  allow="autoplay; fullscreen; picture-in-picture"
100
229
  allowFullScreen
101
230
  />
102
231
  );
103
- } else {
104
- content = (
105
- <video
106
- src={embedUrl}
107
- controls={properties.controls !== false}
108
- autoPlay={properties.autoplay}
109
- loop={properties.loop}
110
- muted={properties.muted}
111
- poster={
112
- typeof properties.poster === 'string' ? properties.poster : undefined
113
- }
114
- // Without it iOS opens the video full screen, which is never what an
115
- // inline background clip is for.
116
- playsInline={properties.playsInline !== false}
117
- style={{
118
- width: '100%',
119
- height: 'auto'
120
- }}
121
- />
122
- );
123
232
  }
124
233
 
125
- return content;
234
+ return (
235
+ <video
236
+ src={parsed.src}
237
+ controls={properties.controls !== false}
238
+ autoPlay={properties.autoplay}
239
+ loop={properties.loop}
240
+ muted={properties.muted}
241
+ poster={
242
+ typeof properties.poster === 'string' ? properties.poster : undefined
243
+ }
244
+ // Without it iOS opens the video full screen, which is never what an
245
+ // inline background clip is for.
246
+ playsInline={properties.playsInline !== false}
247
+ style={
248
+ ratio
249
+ ? { width: '100%', aspectRatio: ratio, objectFit: 'cover' }
250
+ : { width: '100%', height: 'auto' }
251
+ }
252
+ />
253
+ );
126
254
  };
127
255
 
128
256
  export default VideoBlock;