@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.
@@ -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
+ &lt;p&gt;&lt;strong&gt;Bold&lt;/strong&gt;&lt;/p&gt;
69
+ &lt;p&gt;&lt;em&gt;Italic&lt;/em&gt;&lt;/p&gt;
70
+ &lt;p&gt;&lt;u&gt;Underline&lt;/u&gt;&lt;/p&gt;
71
+ &lt;p&gt;&lt;s&gt;Strikethrough&lt;/s&gt;&lt;/p&gt;
72
+ &lt;blockquote&gt;Blockquote&lt;/blockquote&gt;
73
+ &lt;p&gt;&lt;code&gt;Inline code&lt;/code&gt;&lt;/p&gt;
74
+ &lt;pre data-language=plain&gt;Code block&lt;/pre&gt;
75
+ &lt;p&gt;&lt;span style=color:red&gt;Coloured text&lt;/span&gt;&lt;/p&gt;
76
+ &lt;ul&gt;&lt;li&gt;Bullet&lt;/li&gt;&lt;li&gt;Bullet&lt;ul&gt;&lt;li&gt;Nested bullet&lt;/li&gt;&lt;/ul&gt;&lt;/li&gt;&lt;/ul&gt;
77
+ &lt;ol&gt;&lt;li&gt;Numbered&lt;ol&gt;&lt;li&gt;Nested numbered&lt;/li&gt;&lt;li&gt;Nested numbered&lt;/li&gt;&lt;/ol&gt;&lt;/li&gt;&lt;/ol&gt;
78
+ &lt;ul&gt;&lt;li data-list=checked&gt;Checked&lt;/li&gt;&lt;li data-list=unchecked&gt;Unchecked&lt;/li&gt;&lt;/ul&gt;
79
+ &lt;hr class=ql-hr&gt;
80
+ &lt;p&gt;Divider above&lt;/p&gt;
81
+ &lt;p&gt;&lt;a href=https://example.com&gt;Link&lt;/a&gt;&lt;/p&gt;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubex/zinc",
3
- "version": "1.1.181",
3
+ "version": "1.1.182",
4
4
  "description": "A collection of web components for building web applications based off of @shoelace-style/Shoelace",
5
5
  "keywords": [
6
6
  "web components",
@@ -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
- let convertedHtml = div?.innerHTML ?? '';
74
- convertedHtml = convertedHtml.replace(/&nbsp;/g, ' ');
75
- convertedHtml = convertedHtml.replace(/&lt;/g, '<');
76
- convertedHtml = convertedHtml.replace(/&gt;/g, '>');
88
+ // Escaped source has no element children; textContent decodes it once, including &amp;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 baseStyles = "<style>body,html{background-color: #ffffff}img{max-width:100%; max-height: 500px;}</style>";
80
- iframe.srcdoc = baseStyles + convertedHtml;
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">&lt;p&gt;Test&amp;nbsp;Format&lt;/p&gt;</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&nbsp;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 &lt; 2 &amp;&amp; 3 &gt; 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
+ '&lt;p&gt;&lt;s&gt;struck&lt;/s&gt; &lt;code&gt;inline&lt;/code&gt;&lt;/p&gt;&lt;blockquote&gt;quote&lt;/blockquote&gt;'
61
+ + '&lt;pre&gt;block&lt;/pre&gt;&lt;hr&gt;'
62
+ + '&lt;p&gt;&lt;span style="color: red; background-image: url(https://x.test/p.gif)"&gt;red&lt;/span&gt;&lt;/p&gt;'
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
  '&lt;p&gt;&lt;a href="JavaScript:alert(1)"&gt;click&lt;/a&gt;&lt;/p&gt;'
@@ -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);