@akinon/pz-theme 2.0.164-rc.1 → 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.
- package/CHANGELOG.md +8 -58
- package/package.json +4 -4
- package/src/__tests__/editor-locale-bridge.test.tsx +1 -32
- package/src/__tests__/theme-settings-defaults.test.tsx +395 -0
- package/src/__tests__/theme-text-defaults-code-owned.test.tsx +340 -0
- package/src/blocks/__tests__/breadcrumb-theme-separator.test.tsx +148 -0
- package/src/blocks/__tests__/social-icons-theme-fallback.test.tsx +268 -0
- package/src/blocks/__tests__/text-block.test.tsx +6 -34
- package/src/blocks/accordion-block.tsx +10 -0
- package/src/blocks/hotspot-block.tsx +6 -1
- package/src/blocks/listing-breadcrumb-block.tsx +10 -1
- package/src/blocks/order-detail-block.tsx +95 -113
- package/src/blocks/product-breadcrumb-block.tsx +9 -1
- package/src/blocks/social-glyphs.tsx +31 -0
- package/src/blocks/social-icons-list-block.tsx +72 -8
- package/src/blocks/text-block.tsx +15 -6
- package/src/chrome/__tests__/theme-page-up.test.tsx +522 -0
- package/src/chrome/theme-page-up.tsx +303 -0
- package/src/chrome/use-editor-theme-settings.ts +104 -0
- package/src/components/with-designer-features.tsx +27 -0
- package/src/custom/README.md +219 -100
- package/src/custom/__tests__/use-theme-setting.test.tsx +0 -97
- package/src/custom/collect.ts +9 -4
- package/src/custom/property-types.ts +15 -15
- package/src/custom/use-theme-setting.ts +5 -21
- package/src/editor-locale-bridge.tsx +11 -39
- package/src/index.ts +15 -0
- package/src/overlays/theme-modals.tsx +10 -0
- package/src/sections/__tests__/section-wrapper.test.tsx +76 -0
- package/src/sections/coupon-banner-section.tsx +15 -1
- package/src/sections/section-wrapper.tsx +7 -0
- package/src/sections/tabs-section.tsx +13 -0
- package/src/theme-placeholder-wrapper.tsx +50 -3
- package/src/theme-section.tsx +15 -0
- package/src/utils/__tests__/svg-image-source.test.ts +123 -0
- package/src/utils/__tests__/theme-settings-css.test.ts +801 -0
- package/src/utils/__tests__/theme-settings-readers.test.ts +213 -0
- package/src/utils/__tests__/theme-settings-resolver.test.ts +232 -0
- package/src/utils/index.ts +36 -1
- package/src/utils/svg-image-source.ts +84 -0
- package/src/utils/theme-settings-css.ts +717 -0
- package/src/utils/theme-settings-readers.ts +205 -0
- package/src/blocks/__tests__/order-line-actions.test.tsx +0 -54
- package/src/blocks/order-line-actions.ts +0 -45
- package/src/utils/__tests__/sanitize-rich-text.test.ts +0 -80
- package/src/utils/sanitize-rich-text.ts +0 -215
|
@@ -0,0 +1,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}} & {{add_day|4}}')).toBe(
|
|
40
|
-
'{{countdown}} & {{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
|
-
'<img src=x onerror=alert(1)'
|
|
57
|
-
);
|
|
58
|
-
expect(sanitizeRichText('<p>a</p><b')).toBe('<p>a</p><b');
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it('escapes every < that does not become a kept tag', () => {
|
|
62
|
-
expect(sanitizeRichText('a < b <3 c')).toBe('a < b <3 c');
|
|
63
|
-
expect(sanitizeRichText('<!DOCTYPE html><?xml version="1.0"?>x')).toBe(
|
|
64
|
-
'<!DOCTYPE html><?xml version="1.0"?>x'
|
|
65
|
-
);
|
|
66
|
-
expect(sanitizeRichText('</>')).toBe('</>');
|
|
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, '&')
|
|
54
|
-
.replace(/"/g, '"')
|
|
55
|
-
.replace(/</g, '<')
|
|
56
|
-
.replace(/>/g, '>');
|
|
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 `<`. 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 ` ` 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('<');
|
|
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
|
-
};
|