@akinon/pz-theme 2.0.163-rc.0 → 2.0.163
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 +7 -50
- package/package.json +4 -4
- package/src/__tests__/commerce-channel-frozen.test.ts +12 -1
- package/src/__tests__/editor-locale-bridge.test.tsx +1 -32
- package/src/__tests__/section-list-identity.test.tsx +113 -0
- package/src/blocks/__tests__/favourites-grid-impression.test.tsx +217 -0
- package/src/blocks/__tests__/group-block-list-click.test.tsx +90 -0
- package/src/blocks/__tests__/text-block.test.tsx +6 -34
- package/src/blocks/favourites-grid-block.tsx +16 -18
- package/src/blocks/group-block.tsx +14 -2
- package/src/blocks/item-template.tsx +1 -0
- package/src/blocks/order-detail-block.tsx +95 -113
- package/src/blocks/product-block-utils.tsx +5 -1
- package/src/blocks/product-recommendations-block.tsx +45 -13
- package/src/blocks/text-block.tsx +2 -6
- 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/hooks/__tests__/use-list-impression.test.tsx +228 -0
- package/src/hooks/__tests__/use-section-seen.test.tsx +55 -2
- package/src/hooks/use-list-impression.ts +116 -0
- package/src/hooks/use-section-seen.ts +22 -2
- package/src/sections/__tests__/posts-slider-select-item.test.tsx +205 -0
- package/src/sections/__tests__/section-wrapper-list.test.tsx +148 -0
- package/src/sections/posts-slider-section.tsx +62 -2
- package/src/sections/section-wrapper.tsx +32 -2
- package/src/theme-block.tsx +6 -0
- package/src/theme-placeholder-wrapper.tsx +0 -3
- package/src/theme-section-data-context.tsx +33 -0
- package/src/theme-section.tsx +60 -2
- package/src/utils/__tests__/section-impressions.test.ts +68 -0
- package/src/utils/iterator-price-shapes.test.ts +13 -0
- package/src/utils/iterator-utils.ts +2 -1
- package/src/utils/section-impressions.ts +37 -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
package/src/theme-section.tsx
CHANGED
|
@@ -27,7 +27,12 @@ import { DesignerPlaceholder } from './blocks/product-block-utils';
|
|
|
27
27
|
import { CustomSectionHost } from './custom/section-host';
|
|
28
28
|
import { getCustomSectionDefinition } from './custom/section-definitions';
|
|
29
29
|
import { isCustomThemeType } from './custom/types';
|
|
30
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
ThemeSectionDataProvider,
|
|
32
|
+
ThemeSectionListProvider
|
|
33
|
+
} from './theme-section-data-context';
|
|
34
|
+
import { useListImpression } from './hooks/use-list-impression';
|
|
35
|
+
import { reportsOwnImpressions } from './utils/section-impressions';
|
|
31
36
|
import { PreviewToEditorMessageType } from '@akinon/theme-contract';
|
|
32
37
|
import { useSectionSeen } from './hooks/use-section-seen';
|
|
33
38
|
export interface Section {
|
|
@@ -52,6 +57,15 @@ export interface Section {
|
|
|
52
57
|
dataSourceSaved?: boolean;
|
|
53
58
|
}
|
|
54
59
|
|
|
60
|
+
/** How many products the section's collection holds; 0 without one. */
|
|
61
|
+
const sectionProductCount = (section: Section): number => {
|
|
62
|
+
const collection = section.dataSource?.details?.collection;
|
|
63
|
+
const rows = collection?.data ?? collection?.products;
|
|
64
|
+
if (Array.isArray(rows)) return rows.length;
|
|
65
|
+
if (Array.isArray(rows?.products)) return rows.products.length;
|
|
66
|
+
return 0;
|
|
67
|
+
};
|
|
68
|
+
|
|
55
69
|
interface ThemeSectionProps {
|
|
56
70
|
section: Section;
|
|
57
71
|
placeholderId: string;
|
|
@@ -141,7 +155,21 @@ export default function ThemeSection({
|
|
|
141
155
|
// Report this section becoming visible (ZERO-5164). The generic branch
|
|
142
156
|
// below renders its own <section>; sections that go through SectionWrapper
|
|
143
157
|
// are reported there, so exactly one of the two runs per section.
|
|
144
|
-
|
|
158
|
+
// The list this section's product rows are (ZERO-5305): the collection it
|
|
159
|
+
// is bound to, else the section; named after the section's label.
|
|
160
|
+
const sectionListId = String(
|
|
161
|
+
effectiveSection.dataSourceId || effectiveSection.id || ''
|
|
162
|
+
);
|
|
163
|
+
const sectionListName = String(
|
|
164
|
+
effectiveSection.label || effectiveSection.name || ''
|
|
165
|
+
).trim();
|
|
166
|
+
|
|
167
|
+
useSectionSeen({
|
|
168
|
+
ref: sectionRef,
|
|
169
|
+
section: effectiveSection,
|
|
170
|
+
placeholderId,
|
|
171
|
+
productCount: sectionProductCount(effectiveSection)
|
|
172
|
+
});
|
|
145
173
|
|
|
146
174
|
const constrainsContent = hasSectionContentWidth(effectiveSection.styles);
|
|
147
175
|
|
|
@@ -249,6 +277,32 @@ export default function ThemeSection({
|
|
|
249
277
|
[effectiveSection.dataSource, isDesigner]
|
|
250
278
|
);
|
|
251
279
|
|
|
280
|
+
// A section bound to a collection lists its products (a carousel, a strip):
|
|
281
|
+
// reported as one view_item_list when it comes into view. A section with a
|
|
282
|
+
// block that reports its products itself (the listing or favourites grid, a
|
|
283
|
+
// recommendations strip drawing the section's rows) is left to it.
|
|
284
|
+
// A section drawn through `SectionWrapper` reports there instead: this ref
|
|
285
|
+
// is attached only by the generic branch's own <section>.
|
|
286
|
+
const listProducts = reportsOwnImpressions(effectiveSection.blocks)
|
|
287
|
+
? null
|
|
288
|
+
: sectionDataItems;
|
|
289
|
+
useListImpression({
|
|
290
|
+
ref: sectionRef,
|
|
291
|
+
products: listProducts,
|
|
292
|
+
listId: sectionListId,
|
|
293
|
+
listName: sectionListName,
|
|
294
|
+
enabled: !isDesigner
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// What `SectionWrapper` needs to report this section as the generic branch
|
|
298
|
+
// does: seen with its product count, listed under its list identity.
|
|
299
|
+
const wrapperListProps = {
|
|
300
|
+
productCount: sectionProductCount(effectiveSection),
|
|
301
|
+
listProducts,
|
|
302
|
+
listId: sectionListId,
|
|
303
|
+
listName: sectionListName
|
|
304
|
+
};
|
|
305
|
+
|
|
252
306
|
const getIteratorRenderMeta = (iteratorBlock: Block) => {
|
|
253
307
|
const dataPath =
|
|
254
308
|
(iteratorBlock.iteratorDataPath as string | undefined) ||
|
|
@@ -598,6 +652,7 @@ export default function ThemeSection({
|
|
|
598
652
|
onToggleVisibility={onToggleVisibility}
|
|
599
653
|
onDelete={onDelete}
|
|
600
654
|
onRename={onRename}
|
|
655
|
+
{...wrapperListProps}
|
|
601
656
|
>
|
|
602
657
|
<CustomSectionHost
|
|
603
658
|
type={effectiveSection.type}
|
|
@@ -660,6 +715,7 @@ export default function ThemeSection({
|
|
|
660
715
|
onToggleVisibility={onToggleVisibility}
|
|
661
716
|
onDelete={onDelete}
|
|
662
717
|
onRename={onRename}
|
|
718
|
+
{...wrapperListProps}
|
|
663
719
|
>
|
|
664
720
|
<CustomSectionRenderer
|
|
665
721
|
section={effectiveSection}
|
|
@@ -1141,6 +1197,7 @@ export default function ThemeSection({
|
|
|
1141
1197
|
|
|
1142
1198
|
return (
|
|
1143
1199
|
<ThemeSectionDataProvider items={sectionDataItems}>
|
|
1200
|
+
<ThemeSectionListProvider listId={sectionListId} listName={sectionListName}>
|
|
1144
1201
|
<section
|
|
1145
1202
|
ref={sectionRef}
|
|
1146
1203
|
data-section-id={effectiveSection.id}
|
|
@@ -1473,6 +1530,7 @@ export default function ThemeSection({
|
|
|
1473
1530
|
})}
|
|
1474
1531
|
</div>
|
|
1475
1532
|
</section>
|
|
1533
|
+
</ThemeSectionListProvider>
|
|
1476
1534
|
</ThemeSectionDataProvider>
|
|
1477
1535
|
);
|
|
1478
1536
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { reportsOwnImpressions } from '../section-impressions';
|
|
2
|
+
import type { Block } from '../../theme-block';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A section reports its products as one list impression unless a block in
|
|
6
|
+
* it already does (ZERO-5305): one view_item_list per list, never two.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const block = (
|
|
10
|
+
type: string,
|
|
11
|
+
properties: Record<string, unknown> = {},
|
|
12
|
+
blocks: Block[] = []
|
|
13
|
+
) =>
|
|
14
|
+
({
|
|
15
|
+
id: `${type}-1`,
|
|
16
|
+
type,
|
|
17
|
+
label: type,
|
|
18
|
+
value: '',
|
|
19
|
+
properties,
|
|
20
|
+
styles: {},
|
|
21
|
+
order: 0,
|
|
22
|
+
hidden: false,
|
|
23
|
+
blocks
|
|
24
|
+
} as unknown as Block);
|
|
25
|
+
|
|
26
|
+
describe('reportsOwnImpressions (ZERO-5305)', () => {
|
|
27
|
+
it('leaves the listing and favourites grids their own reports, however deep', () => {
|
|
28
|
+
expect(reportsOwnImpressions([block('product-grid')])).toBe(true);
|
|
29
|
+
expect(
|
|
30
|
+
reportsOwnImpressions([
|
|
31
|
+
block('group', {}, [block('group', {}, [block('favourites-grid')])])
|
|
32
|
+
])
|
|
33
|
+
).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('leaves a recommendations strip drawing the section’s rows its own report', () => {
|
|
37
|
+
expect(
|
|
38
|
+
reportsOwnImpressions([
|
|
39
|
+
block('product-recommendations', { source: 'section' })
|
|
40
|
+
])
|
|
41
|
+
).toBe(true);
|
|
42
|
+
expect(
|
|
43
|
+
reportsOwnImpressions([
|
|
44
|
+
block('product-recommendations', {
|
|
45
|
+
source: { desktop: 'section', mobile: 'collection' }
|
|
46
|
+
})
|
|
47
|
+
])
|
|
48
|
+
).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('still reports the section’s rows next to a strip with a source of its own', () => {
|
|
52
|
+
expect(
|
|
53
|
+
reportsOwnImpressions([
|
|
54
|
+
block('product-recommendations', { source: 'recently-viewed' })
|
|
55
|
+
])
|
|
56
|
+
).toBe(false);
|
|
57
|
+
expect(reportsOwnImpressions([block('product-recommendations')])).toBe(
|
|
58
|
+
false
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('reports a plain carousel’s rows itself', () => {
|
|
63
|
+
expect(reportsOwnImpressions([block('group', {}, [block('image')])])).toBe(
|
|
64
|
+
false
|
|
65
|
+
);
|
|
66
|
+
expect(reportsOwnImpressions(undefined)).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -359,6 +359,19 @@ describe('a collection row published in the listing row shape', () => {
|
|
|
359
359
|
false
|
|
360
360
|
);
|
|
361
361
|
});
|
|
362
|
+
|
|
363
|
+
it('numbers each scoped row, for the position a card click reports (ZERO-5305)', () => {
|
|
364
|
+
const expanded = buildIteratorBlock({
|
|
365
|
+
iteratorBlock: cardIterator({}, [text('name', { dataBinding: 'item.name' })]),
|
|
366
|
+
sectionDataSource: source([
|
|
367
|
+
omnitronRow(7, '2999.00', ''),
|
|
368
|
+
omnitronRow(8, '1999.00', '')
|
|
369
|
+
]),
|
|
370
|
+
pageContext: null
|
|
371
|
+
} as never);
|
|
372
|
+
|
|
373
|
+
expect(expanded.blocks?.map((clone) => clone.itemScopeIndex)).toEqual([0, 1]);
|
|
374
|
+
});
|
|
362
375
|
});
|
|
363
376
|
|
|
364
377
|
describe('a repeat inside a collection row', () => {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Block } from '../theme-block';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Whether a block inside a section already reports the section's products
|
|
5
|
+
* as a list impression of its own (ZERO-5305), so the section must not report
|
|
6
|
+
* them a second time:
|
|
7
|
+
*
|
|
8
|
+
* - the listing grid and the favourites grid report their own pages;
|
|
9
|
+
* - a recommendations strip drawing the SECTION's rows (`source: section`)
|
|
10
|
+
* reports them under its title.
|
|
11
|
+
*
|
|
12
|
+
* A strip with a source of its own (a collection by id, recently viewed,
|
|
13
|
+
* favourites) shows other products, so the section's rows still need their
|
|
14
|
+
* own report. The source is read at every breakpoint: a strip that draws the
|
|
15
|
+
* section's rows anywhere draws them.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const OWN_IMPRESSION_GRIDS = new Set(['product-grid', 'favourites-grid']);
|
|
19
|
+
|
|
20
|
+
const drawsSectionRows = (block: Block): boolean => {
|
|
21
|
+
const source = (block.properties as Record<string, unknown> | undefined)
|
|
22
|
+
?.source;
|
|
23
|
+
const values =
|
|
24
|
+
source && typeof source === 'object'
|
|
25
|
+
? Object.values(source as Record<string, unknown>)
|
|
26
|
+
: [source];
|
|
27
|
+
|
|
28
|
+
return values.some((value) => value === 'section');
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const reportsOwnImpressions = (blocks: Block[] | undefined): boolean =>
|
|
32
|
+
(blocks ?? []).some(
|
|
33
|
+
(block) =>
|
|
34
|
+
OWN_IMPRESSION_GRIDS.has(block.type) ||
|
|
35
|
+
(block.type === 'product-recommendations' && drawsSectionRows(block)) ||
|
|
36
|
+
reportsOwnImpressions(block.blocks)
|
|
37
|
+
);
|
|
@@ -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
|
-
};
|