@akinon/pz-theme 2.0.164-rc.0 → 2.0.164

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.
Files changed (46) hide show
  1. package/CHANGELOG.md +8 -50
  2. package/package.json +4 -4
  3. package/src/__tests__/editor-locale-bridge.test.tsx +1 -32
  4. package/src/__tests__/theme-settings-defaults.test.tsx +395 -0
  5. package/src/__tests__/theme-text-defaults-code-owned.test.tsx +340 -0
  6. package/src/blocks/__tests__/breadcrumb-theme-separator.test.tsx +148 -0
  7. package/src/blocks/__tests__/social-icons-theme-fallback.test.tsx +268 -0
  8. package/src/blocks/__tests__/text-block.test.tsx +6 -34
  9. package/src/blocks/accordion-block.tsx +10 -0
  10. package/src/blocks/hotspot-block.tsx +6 -1
  11. package/src/blocks/listing-breadcrumb-block.tsx +10 -1
  12. package/src/blocks/order-detail-block.tsx +95 -113
  13. package/src/blocks/product-breadcrumb-block.tsx +9 -1
  14. package/src/blocks/social-glyphs.tsx +31 -0
  15. package/src/blocks/social-icons-list-block.tsx +72 -8
  16. package/src/blocks/text-block.tsx +15 -6
  17. package/src/chrome/__tests__/theme-page-up.test.tsx +522 -0
  18. package/src/chrome/theme-page-up.tsx +303 -0
  19. package/src/chrome/use-editor-theme-settings.ts +104 -0
  20. package/src/components/with-designer-features.tsx +27 -0
  21. package/src/custom/README.md +219 -100
  22. package/src/custom/__tests__/use-theme-setting.test.tsx +0 -97
  23. package/src/custom/collect.ts +9 -4
  24. package/src/custom/property-types.ts +15 -15
  25. package/src/custom/use-theme-setting.ts +5 -21
  26. package/src/editor-locale-bridge.tsx +11 -39
  27. package/src/index.ts +15 -0
  28. package/src/overlays/theme-modals.tsx +10 -0
  29. package/src/sections/__tests__/section-wrapper.test.tsx +76 -0
  30. package/src/sections/coupon-banner-section.tsx +15 -1
  31. package/src/sections/section-wrapper.tsx +7 -0
  32. package/src/sections/tabs-section.tsx +13 -0
  33. package/src/theme-placeholder-wrapper.tsx +50 -3
  34. package/src/theme-section.tsx +15 -0
  35. package/src/utils/__tests__/svg-image-source.test.ts +123 -0
  36. package/src/utils/__tests__/theme-settings-css.test.ts +801 -0
  37. package/src/utils/__tests__/theme-settings-readers.test.ts +213 -0
  38. package/src/utils/__tests__/theme-settings-resolver.test.ts +232 -0
  39. package/src/utils/index.ts +36 -1
  40. package/src/utils/svg-image-source.ts +84 -0
  41. package/src/utils/theme-settings-css.ts +717 -0
  42. package/src/utils/theme-settings-readers.ts +205 -0
  43. package/src/blocks/__tests__/order-line-actions.test.tsx +0 -54
  44. package/src/blocks/order-line-actions.ts +0 -45
  45. package/src/utils/__tests__/sanitize-rich-text.test.ts +0 -80
  46. package/src/utils/sanitize-rich-text.ts +0 -215
@@ -0,0 +1,205 @@
1
+ import { MAX_INLINE_SVG_LENGTH, toSafeImageSource } from './svg-image-source';
2
+
3
+ /**
4
+ * Validated readers for the theme_settings keys the editor's settings page
5
+ * writes and blocks or chrome consume directly.
6
+ *
7
+ * No `'use client'` and no client imports: `overlays/theme-modals.tsx` is a
8
+ * server component and calls these while rendering, and a server module that
9
+ * imports a value from a client module receives a client reference instead
10
+ * of the function (see overlays/__tests__/server-read-path-client-free.test.ts).
11
+ *
12
+ * Every reader treats settings as untrusted. They come from the editor, but
13
+ * also from theme packages imported as JSON and from direct API writes.
14
+ */
15
+
16
+ type SettingsLike = Record<string, unknown> | null | undefined;
17
+
18
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
19
+ !!value && typeof value === 'object' && !Array.isArray(value);
20
+
21
+ const CONTROL_CHARACTER = /[\u0000-\u001F\u007F]/;
22
+
23
+ /* -------------------------------------------------------------------------- */
24
+ /* Breadcrumb */
25
+ /* -------------------------------------------------------------------------- */
26
+
27
+ /** Long enough for '›', '→' or ' / ', short enough to stay a separator. */
28
+ export const MAX_BREADCRUMB_SEPARATOR_LENGTH = 8;
29
+
30
+ /**
31
+ * The theme-wide breadcrumb separator (`breadcrumbSeparator`), or `undefined`
32
+ * when the theme sets none — callers chain their own default with `??`.
33
+ *
34
+ * Rendered as text by the breadcrumb blocks, so no escaping is needed; the
35
+ * validation only keeps a separator a separator. Length is counted in code
36
+ * points so a single emoji or arrow is one character.
37
+ */
38
+ export const readThemeBreadcrumbSeparator = (
39
+ settings: SettingsLike
40
+ ): string | undefined => {
41
+ const raw = settings?.breadcrumbSeparator;
42
+ if (typeof raw !== 'string') return undefined;
43
+
44
+ const value = raw.trim();
45
+ if (!value || CONTROL_CHARACTER.test(value)) return undefined;
46
+ if (Array.from(value).length > MAX_BREADCRUMB_SEPARATOR_LENGTH) {
47
+ return undefined;
48
+ }
49
+
50
+ return value;
51
+ };
52
+
53
+ /* -------------------------------------------------------------------------- */
54
+ /* Social networks */
55
+ /* -------------------------------------------------------------------------- */
56
+
57
+ const SAFE_SOCIAL_HREF = /^(https?:\/\/|mailto:|tel:|\/(?![/\\]))/i;
58
+
59
+ /**
60
+ * `instagram.com/brand` or `www.facebook.com/brand`: what a merchant types
61
+ * into a "link" field. Only a dotted host qualifies, so a bare word never
62
+ * turns into a link to some other site.
63
+ */
64
+ const BARE_DOMAIN =
65
+ /^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}(?::\d{1,5})?(?:[/?#]\S*)?$/i;
66
+
67
+ /**
68
+ * A social link as an href the storefront can render, or `null`.
69
+ *
70
+ * Accepted: http(s) URLs, `mailto:`, `tel:` and root-relative paths (never
71
+ * `//host`). A bare domain gets `https://`. Everything else — `javascript:`,
72
+ * `data:`, a protocol-relative URL, a value with control characters (which
73
+ * browsers strip, so `/\t/evil.example` would become `//evil.example`) — is
74
+ * dropped.
75
+ */
76
+ export const toSafeSocialHref = (value: unknown): string | null => {
77
+ if (typeof value !== 'string') return null;
78
+
79
+ const href = value.trim();
80
+ if (!href || CONTROL_CHARACTER.test(href)) return null;
81
+
82
+ if (SAFE_SOCIAL_HREF.test(href)) {
83
+ // Whitespace inside a web URL is a typo, not a link.
84
+ if (/^(https?:|\/)/i.test(href) && /\s/.test(href)) return null;
85
+ return href;
86
+ }
87
+
88
+ if (BARE_DOMAIN.test(href)) return `https://${href}`;
89
+
90
+ return null;
91
+ };
92
+
93
+ /** One entry of `socialNetworks`, ready to render. */
94
+ export interface ThemeSocialNetworkEntry {
95
+ /** The stored network key, lower-cased (`facebook`, `x`, legacy `twitter`). */
96
+ network: string;
97
+ /** A validated href (see `toSafeSocialHref`). */
98
+ href: string;
99
+ /**
100
+ * The brand's own icon as an image source (`data:image/svg+xml…` for
101
+ * uploaded markup), or `null` to draw the built-in platform glyph.
102
+ */
103
+ iconSrc: string | null;
104
+ }
105
+
106
+ const NETWORK_KEY = /^[a-z0-9_-]{1,32}$/;
107
+
108
+ /**
109
+ * `socialNetworks` (`Record<network, { url, icon }>`) as a list in insertion
110
+ * order — the order the settings page shows and lets the merchant arrange.
111
+ *
112
+ * An entry without a usable link is skipped: these entries render as links,
113
+ * and a platform mark that goes nowhere is an unfinished row, not content.
114
+ */
115
+ export const readThemeSocialNetworks = (
116
+ settings: SettingsLike
117
+ ): ThemeSocialNetworkEntry[] => {
118
+ const networks = settings?.socialNetworks;
119
+ if (!isRecord(networks)) return [];
120
+
121
+ const entries: ThemeSocialNetworkEntry[] = [];
122
+ const seen = new Set<string>();
123
+
124
+ Object.entries(networks).forEach(([key, raw]) => {
125
+ const network = key.trim().toLowerCase();
126
+ // The settings page stores X as `x`, the old one as `twitter`; they are
127
+ // one network, so a blob carrying both still draws one mark.
128
+ const identity = network === 'twitter' ? 'x' : network;
129
+ if (!NETWORK_KEY.test(network) || seen.has(identity)) return;
130
+ if (!isRecord(raw)) return;
131
+
132
+ const href = toSafeSocialHref(raw.url);
133
+ if (!href) return;
134
+
135
+ seen.add(identity);
136
+ entries.push({ network, href, iconSrc: toSafeImageSource(raw.icon) });
137
+ });
138
+
139
+ return entries;
140
+ };
141
+
142
+ /* -------------------------------------------------------------------------- */
143
+ /* Page up */
144
+ /* -------------------------------------------------------------------------- */
145
+
146
+ /**
147
+ * Brands with a fixed bottom bar (an add-to-cart strip, a cookie banner) set
148
+ * this CSS variable on any ancestor to lift the back-to-top button above it.
149
+ * When it is unset the button measures the corner itself
150
+ * (chrome/theme-page-up.tsx `measureBottomOverlay`). Declared here, outside
151
+ * the client module, so server code importing it gets the string.
152
+ */
153
+ export const PAGE_UP_OFFSET_VARIABLE = '--pz-bottom-overlay-offset';
154
+
155
+ /**
156
+ * The only settings the back-to-top button reads. Picked on the server so the
157
+ * client component does not receive (and the page does not serialize) the
158
+ * whole settings blob a second time.
159
+ */
160
+ // A type alias, not an interface, so it stays assignable to the
161
+ // `Record<string, unknown>` settings shape `resolveIconSource` and
162
+ // `ThemePageUp` take.
163
+ export type ThemePageUpSettings = {
164
+ pageUpEnabled?: true;
165
+ /** Uploaded SVG markup or an image URL; unset draws the built-in arrow. */
166
+ pageUpIcon?: string;
167
+ /**
168
+ * The brand's override of the catalogue `up-arrow` icon, which is the
169
+ * built-in arrow's own override (see blocks/icon-source.tsx).
170
+ */
171
+ icons?: { 'up-arrow': unknown };
172
+ };
173
+
174
+ /**
175
+ * Empty unless the button is on: `ThemeModals` passes this to a client
176
+ * component on every page, and a client component's props are serialized
177
+ * into the page whatever it renders, so an icon of a theme that never turned
178
+ * the button on would ride along everywhere. The designer's live toggle
179
+ * still works: the editor's frames carry the full settings, read here again.
180
+ */
181
+ export const readThemePageUpSettings = (
182
+ settings: SettingsLike
183
+ ): ThemePageUpSettings => {
184
+ if (!isRecord(settings) || settings.pageUpEnabled !== true) return {};
185
+
186
+ const result: ThemePageUpSettings = { pageUpEnabled: true };
187
+
188
+ const icon = settings.pageUpIcon;
189
+ if (
190
+ typeof icon === 'string' &&
191
+ icon.trim() &&
192
+ icon.length <= MAX_INLINE_SVG_LENGTH
193
+ ) {
194
+ result.pageUpIcon = icon;
195
+ }
196
+
197
+ const upArrow = isRecord(settings.icons)
198
+ ? settings.icons['up-arrow']
199
+ : undefined;
200
+ if ((typeof upArrow === 'string' && upArrow.trim()) || isRecord(upArrow)) {
201
+ result.icons = { 'up-arrow': upArrow };
202
+ }
203
+
204
+ return result;
205
+ };
@@ -1,54 +0,0 @@
1
- import React from 'react';
2
- import {
3
- registerOrderLineActions,
4
- renderOrderLineActions
5
- } from '../order-line-actions';
6
-
7
- const context = {
8
- order: { id: 7, number: '20920397829', created_date: '2026-10-01' },
9
- line: {
10
- key: '1',
11
- name: 'Product',
12
- image: '',
13
- href: '',
14
- price: '100',
15
- total: 100,
16
- quantity: 1,
17
- item: { id: 1, status: { value: '550', label: 'Delivered' } }
18
- },
19
- isDesigner: false
20
- };
21
-
22
- describe('order line actions registry', () => {
23
- it('renders nothing until a brand registers', () => {
24
- expect(renderOrderLineActions(context)).toBeNull();
25
- });
26
-
27
- it('hands the line and order to the registered renderer', () => {
28
- const renderer = jest.fn(() => <span>action</span>);
29
- const unregister = registerOrderLineActions(renderer);
30
-
31
- expect(renderOrderLineActions(context)).toEqual(<span>action</span>);
32
- expect(renderer).toHaveBeenCalledWith(context);
33
-
34
- unregister();
35
- expect(renderOrderLineActions(context)).toBeNull();
36
- });
37
-
38
- it('swallows a throwing renderer so a brand bug cannot break the line', () => {
39
- const unregister = registerOrderLineActions(() => {
40
- throw new Error('boom');
41
- });
42
-
43
- expect(renderOrderLineActions(context)).toBeNull();
44
- unregister();
45
- });
46
-
47
- it('only forgets the renderer it was asked to forget', () => {
48
- const first = registerOrderLineActions(() => 'first');
49
- registerOrderLineActions(() => 'second');
50
-
51
- first();
52
- expect(renderOrderLineActions(context)).toBe('second');
53
- });
54
- });
@@ -1,45 +0,0 @@
1
- import type { ReactNode } from 'react';
2
- import type { GroupedOrderLine, OrderLineLike } from './product-block-utils';
3
-
4
- export interface OrderLineActionsOrder {
5
- id?: number;
6
- number?: string;
7
- created_date?: string;
8
- }
9
-
10
- export type OrderLineActionsItem = OrderLineLike & {
11
- status?: { value?: string; label?: string };
12
- [key: string]: unknown;
13
- };
14
-
15
- export interface OrderLineActionsContext {
16
- order: OrderLineActionsOrder;
17
- line: GroupedOrderLine<OrderLineActionsItem>;
18
- isDesigner: boolean;
19
- }
20
-
21
- export type OrderLineActionsRenderer = (
22
- context: OrderLineActionsContext
23
- ) => ReactNode;
24
-
25
- let renderer: OrderLineActionsRenderer | null = null;
26
-
27
- export const registerOrderLineActions = (
28
- fn: OrderLineActionsRenderer
29
- ): (() => void) => {
30
- renderer = fn;
31
- return () => {
32
- if (renderer === fn) renderer = null;
33
- };
34
- };
35
-
36
- export const renderOrderLineActions = (
37
- context: OrderLineActionsContext
38
- ): ReactNode => {
39
- if (!renderer) return null;
40
- try {
41
- return renderer(context) ?? null;
42
- } catch {
43
- return null;
44
- }
45
- };
@@ -1,80 +0,0 @@
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
- });
@@ -1,215 +0,0 @@
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
- };