@akinon/pz-theme 2.0.167 → 2.0.168-rc.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.
@@ -33,19 +33,20 @@ export type PropertyType = (typeof CUSTOM_PROPERTY_TYPES)[number];
33
33
  * The subset whose value provably survives the full round trip — panel write,
34
34
  * persistence, and resolution back into a prop.
35
35
  *
36
- * The three excluded members are excluded for reasons that were verified, not
37
- * guessed:
36
+ * The two excluded members are excluded for reasons that were verified, not
37
+ * guessed: `multiselect` and `localetext` are stored UNBUCKETED by the editor
38
+ * (`theme-basic-properties.tsx:1547-1548` passes arrays and locale objects
39
+ * through without the per-breakpoint wrapper). `getResponsiveValue` then
40
+ * rejects an array with a devWarn and returns the fallback, and a locale
41
+ * object has no `desktop` key so it does too. They can never deliver a
42
+ * value until a per-type resolver exists.
38
43
  *
39
- * - `multiselect` and `localetext` are stored UNBUCKETED by the editor
40
- * (`theme-basic-properties.tsx:1547-1548` passes arrays and locale objects
41
- * through without the per-breakpoint wrapper). `getResponsiveValue` then
42
- * rejects an array with a devWarn and returns the fallback, and a locale
43
- * object has no `desktop` key so it does too. They can never deliver a
44
- * value until a per-type resolver exists.
45
- * - `richtext` has no safe renderer here. The allowlist sanitizer this repo
46
- * already owns parses by assigning to `innerHTML` on a live element, so the
47
- * sink is its first act — and pz-theme renders server-side, where there is
48
- * no `document` at all.
44
+ * `richtext` is the one type with such a resolver. The editor's rich text
45
+ * control writes per language (`{ tr: '<p>…</p>', en: … }`, unbucketed, like
46
+ * `localetext`), so `useThemeSetting` reads the page's language out of the
47
+ * map before the breakpoint read, then passes the html through
48
+ * `sanitizeRichText`, a DOM-free allowlist, so a block can hand it to
49
+ * `dangerouslySetInnerHTML` on the server as well as the client.
49
50
  *
50
51
  * Declaring one of these throws at define time rather than shipping a control
51
52
  * that silently shows the default forever.
@@ -59,12 +60,11 @@ export const SUPPORTED_PROPERTY_TYPES: readonly PropertyType[] = [
59
60
  'color',
60
61
  'image',
61
62
  'file',
63
+ 'richtext',
62
64
  'typography',
63
65
  'icon'
64
66
  ];
65
67
 
66
- export const isSupportedPropertyType = (
67
- type: unknown
68
- ): type is PropertyType =>
68
+ export const isSupportedPropertyType = (type: unknown): type is PropertyType =>
69
69
  typeof type === 'string' &&
70
70
  (SUPPORTED_PROPERTY_TYPES as readonly string[]).includes(type);
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { getResponsiveValue } from '../utils';
4
+ import { sanitizeRichText } from '../utils/sanitize-rich-text';
4
5
  import { devWarn } from '../utils/dev-warn';
5
6
  import {
6
7
  isLocalizedValue,
@@ -238,6 +239,11 @@ export function useThemeSetting<
238
239
 
239
240
  const defaults = splitDefault(declaration, valueLocale);
240
241
 
242
+ const finish = (value: unknown): TValue =>
243
+ (declaration.type === 'richtext'
244
+ ? sanitizeRichText(typeof value === 'string' ? value : '')
245
+ : value) as TValue;
246
+
241
247
  if (!context) {
242
248
  // A missing block context is never an error on its own: a developer
243
249
  // previewing the component in a storybook or a unit test should see the
@@ -254,7 +260,7 @@ export function useThemeSetting<
254
260
  `unreachable and the declared default is used instead. Render this ` +
255
261
  `component through a block registered with registerCustomBlocks().`
256
262
  );
257
- return defaults.page as TValue;
263
+ return finish(defaults.page);
258
264
  }
259
265
 
260
266
  // Reported on every render rather than once from an effect: the collection
@@ -271,7 +277,17 @@ export function useThemeSetting<
271
277
  // attribute keys that were never declared. `node` rather than `block` so a
272
278
  // setting declared inside a custom SECTION reads the section's own
273
279
  // properties, which is where the editor writes it.
274
- const stored = context.node.properties?.[key];
280
+ const authored = context.node.properties?.[key];
281
+
282
+ // The rich text control writes per language — `{ tr: '<p>…</p>', en: … }` —
283
+ // and the editor stores that map as it is, with no breakpoint key: the same
284
+ // shape that keeps `localetext` out (see `property-types.ts`). Read the
285
+ // page's language out of it first; what is left is a string or a breakpoint
286
+ // map, which the responsive read below already understands.
287
+ const stored =
288
+ declaration.type === 'richtext'
289
+ ? resolveLocalizedValue(authored, valueLocale)
290
+ : authored;
275
291
 
276
292
  // The sentinel again, for the same reason: handing `getResponsiveValue` an
277
293
  // undefined fallback would resolve an undeclared default to `''`, so a
@@ -280,15 +296,15 @@ export function useThemeSetting<
280
296
  const resolved = getResponsiveValue(stored, breakpoint, NOT_AUTHORED);
281
297
 
282
298
  if (resolved === NOT_AUTHORED) {
283
- return resolveAuthoredDefault(defaults.page, breakpoint) as TValue;
299
+ return finish(resolveAuthoredDefault(defaults.page, breakpoint));
284
300
  }
285
301
 
286
302
  // The default-language text as the editor seeded it when the block was
287
303
  // placed, never changed since: still the author's copy, so it follows the
288
304
  // page's language. Anything the merchant typed is theirs and shows as typed.
289
305
  if (defaults.perLocale && resolved === defaults.base) {
290
- return defaults.page as TValue;
306
+ return finish(defaults.page);
291
307
  }
292
308
 
293
- return resolved as TValue;
309
+ return finish(resolved);
294
310
  }
@@ -4,14 +4,28 @@ import { useEffect } from 'react';
4
4
  import { useLocalization } from '@akinon/next/hooks';
5
5
 
6
6
  import { isTrustedEditorEvent, postToEditor } from './editor-bridge';
7
+ import { useValueLocale } from './utils/localized-value';
7
8
 
8
9
  /**
9
10
  * The storefront half of the editor's Language dropdown.
10
11
  *
11
12
  * The editor starts that dropdown with English only and fills it from the
12
- * `LOCALE_UPDATE` frame the preview posts (`{ locale, locales }`); when the
13
- * merchant picks another language it posts `LOCALE_UPDATE` back and expects the
14
- * preview to follow. For a long time the only code answering either direction
13
+ * `LOCALE_UPDATE` frame the preview posts (`{ locale, locales, defaultLocale }`);
14
+ * when the merchant picks another language it posts `LOCALE_UPDATE` back and
15
+ * expects the preview to follow.
16
+ *
17
+ * The frame carries the storefront's default language as well. The editor's
18
+ * per-language text controls store a plain string in that language and a map
19
+ * in every other, so an editor that never heard it assumed English and gave
20
+ * every Turkish-default store a stray `en` key (ZERO-5296).
21
+ *
22
+ * It is posted BEFORE the document reports ready (`IFRAME_READY`): the bridge
23
+ * is a child of the placeholder wrapper, whose own effect posts that frame,
24
+ * and React runs a child's effects first. The editor relies on the order — it
25
+ * holds every per-language write until the preview is ready, and takes
26
+ * "ready without languages" to mean a storefront that will never send them.
27
+ *
28
+ * For a long time the only code answering either direction
15
29
  * was the app-template's `LanguageSelect` — brand code. A brand whose header no
16
30
  * longer renders that component (a header composed through ChromeSlot, a
17
31
  * custom header section, a project older than the template) left the editor
@@ -54,6 +68,7 @@ export interface EditorLocaleOption {
54
68
  interface LocaleState {
55
69
  locale: string;
56
70
  locales: EditorLocaleOption[];
71
+ defaultLocale?: string;
57
72
  setLocale: (locale: string) => void;
58
73
  }
59
74
 
@@ -86,15 +101,22 @@ const toEditorLocales = (locales: unknown): EditorLocaleOption[] =>
86
101
  : entry.value
87
102
  }));
88
103
 
89
- const announcementKey = (locale: string, locales: EditorLocaleOption[]) =>
90
- JSON.stringify([locale, locales]);
104
+ const announcementKey = ({
105
+ locale,
106
+ locales,
107
+ defaultLocale
108
+ }: Omit<LocaleState, 'setLocale'>) =>
109
+ JSON.stringify([locale, locales, defaultLocale ?? null]);
91
110
 
92
- const announce = ({ locale, locales }: LocaleState) => {
93
- const key = announcementKey(locale, locales);
111
+ const announce = ({ locale, locales, defaultLocale }: LocaleState) => {
112
+ const key = announcementKey({ locale, locales, defaultLocale });
94
113
  if (key === lastAnnounced) return;
95
114
  lastAnnounced = key;
96
115
 
97
- postToEditor({ type: 'LOCALE_UPDATE', data: { locale, locales } });
116
+ postToEditor({
117
+ type: 'LOCALE_UPDATE',
118
+ data: { locale, locales, defaultLocale }
119
+ });
98
120
  };
99
121
 
100
122
  const handleEditorMessage = (event: MessageEvent) => {
@@ -115,7 +137,7 @@ const handleEditorMessage = (event: MessageEvent) => {
115
137
 
116
138
  // The editor is already showing `requested`. Recording it as announced keeps
117
139
  // the switch from being echoed back when the new locale reaches the page.
118
- lastAnnounced = announcementKey(requested, current.locales);
140
+ lastAnnounced = announcementKey({ ...current, locale: requested });
119
141
  current.setLocale(requested);
120
142
  };
121
143
 
@@ -126,6 +148,7 @@ const handleEditorMessage = (event: MessageEvent) => {
126
148
  */
127
149
  export function EditorLocaleBridge(): null {
128
150
  const { locale, locales, setLocale } = useLocalization();
151
+ const { defaultLocale } = useValueLocale();
129
152
 
130
153
  useEffect(() => {
131
154
  if (!isFramed()) return;
@@ -147,9 +170,14 @@ export function EditorLocaleBridge(): null {
147
170
  useEffect(() => {
148
171
  if (!isFramed() || !locale) return;
149
172
 
150
- current = { locale, locales: toEditorLocales(locales), setLocale };
173
+ current = {
174
+ locale,
175
+ locales: toEditorLocales(locales),
176
+ defaultLocale,
177
+ setLocale
178
+ };
151
179
  announce(current);
152
- }, [locale, locales, setLocale]);
180
+ }, [locale, locales, defaultLocale, setLocale]);
153
181
 
154
182
  return null;
155
183
  }
@@ -1216,6 +1216,9 @@ export default function ThemePlaceholderWrapper({
1216
1216
  Tells the editor which languages this storefront has and follows its
1217
1217
  language switch. Every placeholder renders one; they share a single
1218
1218
  listener and a single announcement (see editor-locale-bridge.tsx).
1219
+ A child of this wrapper on purpose: its effect, and so its frame,
1220
+ runs before the effect below that posts IFRAME_READY, and the editor
1221
+ counts on hearing the languages before the ready report.
1219
1222
  */}
1220
1223
  <EditorLocaleBridge />
1221
1224
  {themeBackgroundCSS && (
@@ -0,0 +1,80 @@
1
+ import { sanitizeRichText } from '../sanitize-rich-text';
2
+
3
+ describe('sanitizeRichText', () => {
4
+ it('keeps the markup the editor writes', () => {
5
+ const html =
6
+ '<p>Sipariş <b>verirseniz</b> <i>yarın</i> <u>kapınızda</u></p><ul><li>bir</li></ul><h2>Başlık</h2><br>';
7
+
8
+ expect(sanitizeRichText(html)).toBe(
9
+ '<p>Sipariş <b>verirseniz</b> <i>yarın</i> <u>kapınızda</u></p><ul><li>bir</li></ul><h2>Başlık</h2><br />'
10
+ );
11
+ });
12
+
13
+ it('drops scripts, event handlers and unknown tags but keeps their text', () => {
14
+ const html =
15
+ '<p onclick="x()">a<script>alert(1)</script><custom>b</custom><img src=x onerror=alert(1)></p>';
16
+
17
+ expect(sanitizeRichText(html)).toBe('<p>ab</p>');
18
+ });
19
+
20
+ it('keeps only safe links and forces rel on new-tab links', () => {
21
+ expect(
22
+ sanitizeRichText(
23
+ '<a href="javascript:alert(1)">x</a><a href="https://gant.com" target="_blank">y</a><a href="/sss">z</a>'
24
+ )
25
+ ).toBe(
26
+ '<a>x</a><a href="https://gant.com" target="_blank" rel="noopener noreferrer">y</a><a href="/sss">z</a>'
27
+ );
28
+ });
29
+
30
+ it('keeps only the colour and emphasis styles', () => {
31
+ expect(
32
+ sanitizeRichText(
33
+ '<span style="color: #9a2320; position: fixed; background: url(x)">a</span>'
34
+ )
35
+ ).toBe('<span style="color: #9a2320">a</span>');
36
+ });
37
+
38
+ it('leaves text, entities and placeholders untouched', () => {
39
+ expect(sanitizeRichText('{{countdown}} &amp; {{add_day|4}}')).toBe(
40
+ '{{countdown}} &amp; {{add_day|4}}'
41
+ );
42
+ expect(sanitizeRichText('')).toBe('');
43
+ });
44
+
45
+ it('reads tag names the way the browser does, so no element slips through as text', () => {
46
+ expect(
47
+ sanitizeRichText(
48
+ '<x_y onmouseover=alert(1) style=position:fixed;inset:0>a</x_y>'
49
+ )
50
+ ).toBe('a');
51
+ expect(sanitizeRichText('<svg/onload=alert(1)>b')).toBe('b');
52
+ });
53
+
54
+ it('never lets an unfinished tag run into the markup that follows', () => {
55
+ expect(sanitizeRichText('<img src=x onerror=alert(1)')).toBe(
56
+ '&lt;img src=x onerror=alert(1)'
57
+ );
58
+ expect(sanitizeRichText('<p>a</p><b')).toBe('<p>a</p>&lt;b');
59
+ });
60
+
61
+ it('escapes every < that does not become a kept tag', () => {
62
+ expect(sanitizeRichText('a < b <3 c')).toBe('a &lt; b &lt;3 c');
63
+ expect(sanitizeRichText('<!DOCTYPE html><?xml version="1.0"?>x')).toBe(
64
+ '&lt;!DOCTYPE html>&lt;?xml version="1.0"?>x'
65
+ );
66
+ expect(sanitizeRichText('</>')).toBe('&lt;/>');
67
+ });
68
+
69
+ it('reads a quoted attribute value holding > as part of the tag', () => {
70
+ expect(sanitizeRichText('<a href="/x" title="a>b">t</a>')).toBe(
71
+ '<a href="/x">t</a>'
72
+ );
73
+ });
74
+
75
+ it('drops an unterminated comment or script to the end', () => {
76
+ expect(sanitizeRichText('a<!-- b')).toBe('a');
77
+ expect(sanitizeRichText('<!-->x')).toBe('x');
78
+ expect(sanitizeRichText('a<script>alert(1)')).toBe('a');
79
+ });
80
+ });
@@ -0,0 +1,215 @@
1
+ const ALLOWED_TAGS = new Set([
2
+ 'p',
3
+ 'div',
4
+ 'br',
5
+ 'h1',
6
+ 'h2',
7
+ 'h3',
8
+ 'h4',
9
+ 'h5',
10
+ 'h6',
11
+ 'ul',
12
+ 'ol',
13
+ 'li',
14
+ 'b',
15
+ 'strong',
16
+ 'i',
17
+ 'em',
18
+ 'u',
19
+ 's',
20
+ 'span',
21
+ 'a'
22
+ ]);
23
+
24
+ const VOID_TAGS = new Set(['br']);
25
+
26
+ const DROPPED_WITH_CONTENT = new Set([
27
+ 'script',
28
+ 'style',
29
+ 'iframe',
30
+ 'object',
31
+ 'embed',
32
+ 'template',
33
+ 'noscript'
34
+ ]);
35
+
36
+ const ALLOWED_STYLE_PROPERTIES = new Set([
37
+ 'color',
38
+ 'background-color',
39
+ 'font-weight',
40
+ 'font-style',
41
+ 'text-decoration',
42
+ 'text-decoration-line',
43
+ 'text-align'
44
+ ]);
45
+
46
+ const SAFE_HREF = /^(?:https?:\/\/|mailto:|tel:|\/(?!\/)|#|\?)/i;
47
+
48
+ const ATTRIBUTE_PATTERN =
49
+ /([a-zA-Z-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
50
+
51
+ const escapeAttribute = (value: string): string =>
52
+ value
53
+ .replace(/&/g, '&amp;')
54
+ .replace(/"/g, '&quot;')
55
+ .replace(/</g, '&lt;')
56
+ .replace(/>/g, '&gt;');
57
+
58
+ const readAttributes = (raw: string): Record<string, string> => {
59
+ const attributes: Record<string, string> = {};
60
+ let match = ATTRIBUTE_PATTERN.exec(raw);
61
+ while (match) {
62
+ attributes[match[1].toLowerCase()] = match[2] ?? match[3] ?? match[4] ?? '';
63
+ match = ATTRIBUTE_PATTERN.exec(raw);
64
+ }
65
+ ATTRIBUTE_PATTERN.lastIndex = 0;
66
+ return attributes;
67
+ };
68
+
69
+ const safeStyle = (value: string): string =>
70
+ value
71
+ .split(';')
72
+ .map((declaration) => declaration.trim())
73
+ .filter(Boolean)
74
+ .map((declaration) => {
75
+ const colon = declaration.indexOf(':');
76
+ if (colon === -1) return null;
77
+ const property = declaration.slice(0, colon).trim().toLowerCase();
78
+ const propertyValue = declaration.slice(colon + 1).trim();
79
+ if (!ALLOWED_STYLE_PROPERTIES.has(property)) return null;
80
+ if (/url\(|expression\(|javascript:|\\/i.test(propertyValue)) return null;
81
+ return `${property}: ${propertyValue}`;
82
+ })
83
+ .filter((declaration): declaration is string => declaration !== null)
84
+ .join('; ');
85
+
86
+ const openingTag = (name: string, raw: string): string => {
87
+ const attributes = readAttributes(raw);
88
+ const kept: string[] = [];
89
+
90
+ if (name === 'a') {
91
+ const href = attributes.href?.trim() ?? '';
92
+ if (href && SAFE_HREF.test(href)) {
93
+ kept.push(`href="${escapeAttribute(href)}"`);
94
+ if (attributes.target === '_blank') {
95
+ kept.push('target="_blank"', 'rel="noopener noreferrer"');
96
+ }
97
+ }
98
+ }
99
+
100
+ const style = attributes.style ? safeStyle(attributes.style) : '';
101
+ if (style) kept.push(`style="${escapeAttribute(style)}"`);
102
+
103
+ const suffix = kept.length ? ` ${kept.join(' ')}` : '';
104
+ return VOID_TAGS.has(name) ? `<${name}${suffix} />` : `<${name}${suffix}>`;
105
+ };
106
+
107
+ /**
108
+ * One tag, read the way the HTML tokenizer reads it: the name runs up to
109
+ * whitespace, `/` or `>` (so `x_y`, `svg/onload` and friends are names, not
110
+ * text), attribute values may be quoted and may hold `>`, and the tag closes
111
+ * at the first unquoted `>`.
112
+ *
113
+ * `null` when the input ends inside the tag. The browser would keep reading
114
+ * into whatever markup follows — on the server that is React's own closing
115
+ * tag — so an unfinished tag is never passed on; the caller escapes its `<`.
116
+ */
117
+ interface TagToken {
118
+ name: string;
119
+ attributes: string;
120
+ closing: boolean;
121
+ end: number;
122
+ }
123
+
124
+ const isAsciiLetter = (char: string): boolean => /^[A-Za-z]$/.test(char);
125
+ const endsTagName = (char: string): boolean => /^[\s/>]$/.test(char);
126
+
127
+ const readTag = (html: string, start: number): TagToken | null => {
128
+ let index = start + 1;
129
+ const closing = html.charAt(index) === '/';
130
+ if (closing) index += 1;
131
+
132
+ if (!isAsciiLetter(html.charAt(index))) return null;
133
+ const nameStart = index;
134
+ while (index < html.length && !endsTagName(html.charAt(index))) index += 1;
135
+ const name = html.slice(nameStart, index).toLowerCase();
136
+
137
+ const attributesStart = index;
138
+ let quote: string | null = null;
139
+ while (index < html.length) {
140
+ const char = html.charAt(index);
141
+ if (quote) {
142
+ if (char === quote) quote = null;
143
+ } else if (char === '"' || char === "'") {
144
+ quote = char;
145
+ } else if (char === '>') {
146
+ return {
147
+ name,
148
+ attributes: html.slice(attributesStart, index),
149
+ closing,
150
+ end: index + 1
151
+ };
152
+ }
153
+ index += 1;
154
+ }
155
+
156
+ return null;
157
+ };
158
+
159
+ /**
160
+ * The output holds no `<` this function did not write itself: every `<` in
161
+ * the input either becomes a rebuilt, allowlisted tag with escaped attribute
162
+ * values, or `&lt;`. Comments are dropped, the content of script-like
163
+ * elements with them. Text and entities are otherwise left as written, so the
164
+ * editor's `&nbsp;` and the message placeholders survive.
165
+ */
166
+ export const sanitizeRichText = (html: string): string => {
167
+ if (!html) return '';
168
+
169
+ const output: string[] = [];
170
+ let dropUntil: string | null = null;
171
+ let position = 0;
172
+
173
+ const emit = (text: string) => {
174
+ if (!dropUntil && text) output.push(text);
175
+ };
176
+
177
+ while (position < html.length) {
178
+ const lt = html.indexOf('<', position);
179
+ if (lt === -1) {
180
+ emit(html.slice(position));
181
+ break;
182
+ }
183
+ emit(html.slice(position, lt));
184
+
185
+ if (html.startsWith('<!--', lt)) {
186
+ const end = html.indexOf('-->', lt + 2);
187
+ position = end === -1 ? html.length : end + 3;
188
+ continue;
189
+ }
190
+
191
+ const tag = readTag(html, lt);
192
+ if (!tag) {
193
+ emit('&lt;');
194
+ position = lt + 1;
195
+ continue;
196
+ }
197
+ position = tag.end;
198
+
199
+ if (dropUntil) {
200
+ if (tag.closing && tag.name === dropUntil) dropUntil = null;
201
+ } else if (DROPPED_WITH_CONTENT.has(tag.name)) {
202
+ if (!tag.closing) dropUntil = tag.name;
203
+ } else if (ALLOWED_TAGS.has(tag.name)) {
204
+ output.push(
205
+ tag.closing
206
+ ? VOID_TAGS.has(tag.name)
207
+ ? ''
208
+ : `</${tag.name}>`
209
+ : openingTag(tag.name, tag.attributes)
210
+ );
211
+ }
212
+ }
213
+
214
+ return output.join('');
215
+ };