@kubex/zinc 1.1.181 → 1.1.182
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/dist/custom-elements.json +711 -711
- package/dist/vscode.html-custom-data.json +74 -74
- package/dist/web-types.json +189 -189
- package/dist/zn.d.ts +2 -1
- package/dist/zn.min.js +72 -72
- package/docs/pages/components/content-block.md +47 -0
- package/package.json +1 -1
- package/src/components/content-block/content-block.component.ts +21 -7
- package/src/components/content-block/content-block.scss +36 -0
- package/src/components/content-block/content-block.test.ts +25 -0
- package/src/utilities/sanitize-html.ts +11 -2
|
@@ -52,6 +52,53 @@ layout: component
|
|
|
52
52
|
</zn-content-block>
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
+
### Editor Formatting
|
|
56
|
+
|
|
57
|
+
Text that holds escaped HTML, such as a note saved from `zn-editor`, is sanitized and rendered with the
|
|
58
|
+
editor's formatting. Only the text and background colour survive from a `style` attribute.
|
|
59
|
+
|
|
60
|
+
```html:preview
|
|
61
|
+
|
|
62
|
+
<zn-content-block
|
|
63
|
+
time="16 Jun 2025, 13:00"
|
|
64
|
+
sender="John Smith"
|
|
65
|
+
avatar="JS"
|
|
66
|
+
>
|
|
67
|
+
<div slot="text">
|
|
68
|
+
<p><strong>Bold</strong></p>
|
|
69
|
+
<p><em>Italic</em></p>
|
|
70
|
+
<p><u>Underline</u></p>
|
|
71
|
+
<p><s>Strikethrough</s></p>
|
|
72
|
+
<blockquote>Blockquote</blockquote>
|
|
73
|
+
<p><code>Inline code</code></p>
|
|
74
|
+
<pre data-language=plain>Code block</pre>
|
|
75
|
+
<p><span style=color:red>Coloured text</span></p>
|
|
76
|
+
<ul><li>Bullet</li><li>Bullet<ul><li>Nested bullet</li></ul></li></ul>
|
|
77
|
+
<ol><li>Numbered<ol><li>Nested numbered</li><li>Nested numbered</li></ol></li></ol>
|
|
78
|
+
<ul><li data-list=checked>Checked</li><li data-list=unchecked>Unchecked</li></ul>
|
|
79
|
+
<hr class=ql-hr>
|
|
80
|
+
<p>Divider above</p>
|
|
81
|
+
<p><a href=https://example.com>Link</a></p>
|
|
82
|
+
</div>
|
|
83
|
+
<div slot="html">
|
|
84
|
+
<p><strong>Bold</strong></p>
|
|
85
|
+
<p><em>Italic</em></p>
|
|
86
|
+
<p><u>Underline</u></p>
|
|
87
|
+
<p><s>Strikethrough</s></p>
|
|
88
|
+
<blockquote>Blockquote</blockquote>
|
|
89
|
+
<p><code>Inline code</code></p>
|
|
90
|
+
<pre data-language="plain">Code block</pre>
|
|
91
|
+
<p><span style="color:red">Coloured text</span></p>
|
|
92
|
+
<ul><li>Bullet</li><li>Bullet<ul><li>Nested bullet</li></ul></li></ul>
|
|
93
|
+
<ol><li>Numbered<ol><li>Nested numbered</li><li>Nested numbered</li></ol></li></ol>
|
|
94
|
+
<ul><li data-list="checked">Checked</li><li data-list="unchecked">Unchecked</li></ul>
|
|
95
|
+
<hr class="ql-hr">
|
|
96
|
+
<p>Divider above</p>
|
|
97
|
+
<p><a href="https://example.com">Link</a></p>
|
|
98
|
+
</div>
|
|
99
|
+
</zn-content-block>
|
|
100
|
+
```
|
|
101
|
+
|
|
55
102
|
### Attachments
|
|
56
103
|
|
|
57
104
|
Attachments render in a row at the bottom of the content and are only visible while the block
|
package/package.json
CHANGED
|
@@ -13,6 +13,21 @@ import type ZnTile from "../tile";
|
|
|
13
13
|
|
|
14
14
|
import styles from './content-block.scss';
|
|
15
15
|
|
|
16
|
+
// What the editor (Quill) can produce beyond the sanitizer's defaults
|
|
17
|
+
const EDITOR_TAGS = ['s', 'code', 'pre', 'blockquote', 'hr', 'span'];
|
|
18
|
+
|
|
19
|
+
// The iframe can't see the shadow styles, so editor formatting is restyled to match the text view
|
|
20
|
+
const IFRAME_STYLES = 'html,body{background-color:#fff;margin:0}img{max-width:100%;max-height:500px}'
|
|
21
|
+
+ 'p{margin:0}ul,ol{margin:0;padding-left:1.5em}'
|
|
22
|
+
+ 'blockquote{border-left:4px solid #ddd;margin:5px 0;padding-left:16px}'
|
|
23
|
+
+ 'code{background-color:#f0f0f0;border-radius:3px;font-size:85%;padding:2px 4px}'
|
|
24
|
+
+ 'pre{background-color:#23241f;border-radius:3px;margin:5px 0;padding:5px 10px;color:#f8f8f2;overflow:auto}'
|
|
25
|
+
+ 'pre code{background:none;padding:0}'
|
|
26
|
+
+ 'hr{border:none;border-top:1px solid #ddd;margin:8px 0}'
|
|
27
|
+
+ 'li[data-list=checked],li[data-list=unchecked]{list-style:none}'
|
|
28
|
+
+ 'li[data-list=checked]::before{content:"☑";color:#777;margin:0 .3em 0 -1.5em}'
|
|
29
|
+
+ 'li[data-list=unchecked]::before{content:"☐";color:#777;margin:0 .3em 0 -1.5em}';
|
|
30
|
+
|
|
16
31
|
interface TextRow {
|
|
17
32
|
lines: string[];
|
|
18
33
|
type: 'reply' | 'text';
|
|
@@ -70,14 +85,13 @@ export default class ContentBlock extends ZincElement {
|
|
|
70
85
|
if (iframe.contentDocument) {
|
|
71
86
|
const div = this.htmlNodes[0] as HTMLDivElement;
|
|
72
87
|
if (div) {
|
|
73
|
-
|
|
74
|
-
convertedHtml =
|
|
75
|
-
convertedHtml = convertedHtml.replace(/</g, '<');
|
|
76
|
-
convertedHtml = convertedHtml.replace(/>/g, '>');
|
|
88
|
+
// Escaped source has no element children; textContent decodes it once, including &nbsp;
|
|
89
|
+
let convertedHtml = div.children.length ? div.innerHTML : div.textContent ?? '';
|
|
77
90
|
convertedHtml = convertedHtml.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
|
|
78
91
|
|
|
79
|
-
const
|
|
80
|
-
iframe.srcdoc =
|
|
92
|
+
const {fontFamily, fontSize, lineHeight, color} = getComputedStyle(this.shadowRoot?.querySelector('.text-section') ?? this);
|
|
93
|
+
iframe.srcdoc = `<style>body{font-family:${fontFamily};font-size:${fontSize};line-height:${lineHeight};color:${color}}${IFRAME_STYLES}</style>`
|
|
94
|
+
+ convertedHtml;
|
|
81
95
|
}
|
|
82
96
|
}
|
|
83
97
|
|
|
@@ -366,7 +380,7 @@ export default class ContentBlock extends ZincElement {
|
|
|
366
380
|
const textRows: TextRow[] = [];
|
|
367
381
|
|
|
368
382
|
if (textContent && looksLikeHtml(textContent.innerText)) {
|
|
369
|
-
this._textRows = [{lines: [sanitizeHtml(textContent.innerText)], type: 'text', html: true}];
|
|
383
|
+
this._textRows = [{lines: [sanitizeHtml(textContent.innerText, EDITOR_TAGS, true)], type: 'text', html: true}];
|
|
370
384
|
return this._textRows;
|
|
371
385
|
}
|
|
372
386
|
|
|
@@ -118,6 +118,38 @@
|
|
|
118
118
|
font-size: var(--zn-font-size-small);
|
|
119
119
|
opacity: 0.8;
|
|
120
120
|
}
|
|
121
|
+
|
|
122
|
+
p {
|
|
123
|
+
margin: 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
blockquote {
|
|
127
|
+
border-left: 4px solid rgb(var(--zn-border-color));
|
|
128
|
+
margin: 5px 0;
|
|
129
|
+
padding-left: 16px;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
code {
|
|
133
|
+
background-color: #f0f0f0;
|
|
134
|
+
border-radius: 3px;
|
|
135
|
+
font-size: 85%;
|
|
136
|
+
padding: 2px 4px;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
pre {
|
|
140
|
+
background-color: #23241f;
|
|
141
|
+
border-radius: 3px;
|
|
142
|
+
margin-block: 5px;
|
|
143
|
+
padding: 5px 10px;
|
|
144
|
+
color: #f8f8f2;
|
|
145
|
+
overflow: auto;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
hr {
|
|
149
|
+
border: none;
|
|
150
|
+
border-top: 1px solid rgb(var(--zn-border-color));
|
|
151
|
+
margin: var(--zn-spacing-small) 0;
|
|
152
|
+
}
|
|
121
153
|
}
|
|
122
154
|
|
|
123
155
|
.hidden {
|
|
@@ -139,6 +171,10 @@ ol, ul {
|
|
|
139
171
|
margin: 0;
|
|
140
172
|
}
|
|
141
173
|
|
|
174
|
+
li > ol, li > ul {
|
|
175
|
+
padding-left: 1.5em;
|
|
176
|
+
}
|
|
177
|
+
|
|
142
178
|
li[data-list=checked] > .ql-ui, li[data-list=unchecked] > .ql-ui {
|
|
143
179
|
color: #777;
|
|
144
180
|
}
|
|
@@ -17,6 +17,16 @@ describe('<zn-content-block>', () => {
|
|
|
17
17
|
expect(el).to.exist;
|
|
18
18
|
});
|
|
19
19
|
|
|
20
|
+
it('should decode escaped source in the html body once', async () => {
|
|
21
|
+
const el = await fixture<LitElement>(
|
|
22
|
+
'<zn-content-block><div slot="html"><p>Test&nbsp;Format</p></div></zn-content-block>'
|
|
23
|
+
);
|
|
24
|
+
await el.updateComplete;
|
|
25
|
+
const iframe = el.shadowRoot!.querySelector('iframe')!;
|
|
26
|
+
|
|
27
|
+
expect(iframe.srcdoc).to.contain('<p>Test Format</p>');
|
|
28
|
+
});
|
|
29
|
+
|
|
20
30
|
it('should render plain text in the text body as text', async () => {
|
|
21
31
|
const content = await textSection('1 < 2 && 3 > 2');
|
|
22
32
|
|
|
@@ -45,6 +55,21 @@ describe('<zn-content-block>', () => {
|
|
|
45
55
|
expect(content.textContent).to.not.contain('<p>');
|
|
46
56
|
});
|
|
47
57
|
|
|
58
|
+
it('should keep editor formatting and only colours from styles in the text body', async () => {
|
|
59
|
+
const content = await textSection(
|
|
60
|
+
'<p><s>struck</s> <code>inline</code></p><blockquote>quote</blockquote>'
|
|
61
|
+
+ '<pre>block</pre><hr>'
|
|
62
|
+
+ '<p><span style="color: red; background-image: url(https://x.test/p.gif)">red</span></p>'
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
expect(content.querySelector('s')?.textContent).to.equal('struck');
|
|
66
|
+
expect(content.querySelector('code')?.textContent).to.equal('inline');
|
|
67
|
+
expect(content.querySelector('blockquote')?.textContent).to.equal('quote');
|
|
68
|
+
expect(content.querySelector('pre')?.textContent).to.equal('block');
|
|
69
|
+
expect(content.querySelector('hr')).to.exist;
|
|
70
|
+
expect(content.querySelector('span')!.getAttribute('style')).to.equal('color: red;');
|
|
71
|
+
});
|
|
72
|
+
|
|
48
73
|
it('should strip unsafe links and dropped elements from markup in the text body', async () => {
|
|
49
74
|
const content = await textSection(
|
|
50
75
|
'<p><a href="JavaScript:alert(1)">click</a></p>'
|
|
@@ -16,11 +16,12 @@ const DROPPED = [
|
|
|
16
16
|
/**
|
|
17
17
|
* Sanitizes untrusted HTML down to formatting tags, keeping only a link's href when it is http(s) or mailto.
|
|
18
18
|
* Tags in `keep` are allowed as they are, so a block such as `pre` isn't turned into a paragraph.
|
|
19
|
+
* With `colors`, a `style` attribute survives holding only its text and background colour.
|
|
19
20
|
*/
|
|
20
|
-
export function sanitizeHtml(source: string, keep: string[] = []): string {
|
|
21
|
+
export function sanitizeHtml(source: string, keep: string[] = [], colors = false): string {
|
|
21
22
|
const fragment = DOMPurify.sanitize(source, {
|
|
22
23
|
ALLOWED_TAGS: [...FORMATTING, ...BLOCKS, ...keep],
|
|
23
|
-
ALLOWED_ATTR: ['href'],
|
|
24
|
+
ALLOWED_ATTR: colors ? ['href', 'style'] : ['href'],
|
|
24
25
|
ALLOWED_URI_REGEXP: /^(?:https?|mailto):/i,
|
|
25
26
|
FORBID_CONTENTS: DROPPED,
|
|
26
27
|
RETURN_DOM_FRAGMENT: true,
|
|
@@ -36,6 +37,14 @@ export function sanitizeHtml(source: string, keep: string[] = []): string {
|
|
|
36
37
|
fragment.querySelectorAll('p').forEach(p => {
|
|
37
38
|
if (!p.textContent?.trim()) p.remove();
|
|
38
39
|
});
|
|
40
|
+
// Any other declaration could load a remote url(), which in an email is a tracking pixel
|
|
41
|
+
fragment.querySelectorAll<HTMLElement>('[style]').forEach(el => {
|
|
42
|
+
const {color, backgroundColor} = el.style;
|
|
43
|
+
el.removeAttribute('style');
|
|
44
|
+
el.style.color = color;
|
|
45
|
+
el.style.backgroundColor = backgroundColor;
|
|
46
|
+
if (!el.getAttribute('style')) el.removeAttribute('style');
|
|
47
|
+
});
|
|
39
48
|
fragment.querySelectorAll('a[href]').forEach(link => {
|
|
40
49
|
try {
|
|
41
50
|
link.setAttribute('href', new URL(link.getAttribute('href')!.trim()).href);
|