@akinon/pz-theme 2.0.162-rc.0 → 2.0.163-beta.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.
- package/CHANGELOG.md +15 -50
- package/package.json +4 -4
- package/readme.md +22 -0
- package/src/__tests__/custom-catalog-announce.test.ts +110 -0
- package/src/__tests__/editor-locale-bridge.test.tsx +1 -32
- package/src/blocks/__tests__/text-block.test.tsx +6 -34
- package/src/blocks/order-detail-block.tsx +95 -113
- package/src/blocks/text-block.tsx +2 -6
- package/src/chrome/composition-source.ts +7 -2
- package/src/chrome/preview-snapshot-source.ts +5 -1
- package/src/content-capabilities.ts +41 -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/load-placeholder-sections.ts +13 -3
- package/src/placeholder-registry.ts +42 -1
- package/src/region/__tests__/editor-handshake.test.ts +144 -0
- package/src/region/__tests__/region-contract.test.ts +500 -0
- package/src/region/__tests__/region-placeholder-readers.test.ts +431 -0
- package/src/region/__tests__/region-source.test.ts +621 -0
- package/src/region/editor-handshake.ts +60 -0
- package/src/region/region-contract.ts +184 -0
- package/src/region/region-source.ts +360 -0
- package/src/theme-placeholder-wrapper.tsx +0 -3
- 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
|
@@ -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
|
-
};
|