@limetech/lime-elements 39.2.1 → 39.3.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 CHANGED
@@ -1,3 +1,17 @@
1
+ ## [39.3.0](https://github.com/Lundalogik/lime-elements/compare/v39.2.2...v39.3.0) (2026-02-23)
2
+
3
+ ### Features
4
+
5
+
6
+ * **markdown:** hydrate JSON attributes on whitelisted custom elements ([ef3a7a9](https://github.com/Lundalogik/lime-elements/commit/ef3a7a95d7340362dc7916fe16542e39278e74b0))
7
+
8
+ ## [39.2.2](https://github.com/Lundalogik/lime-elements/compare/v39.2.1...v39.2.2) (2026-02-22)
9
+
10
+ ### Bug Fixes
11
+
12
+
13
+ * **email-viewer:** add explicit font and meta attributes to sanitization schema ([c3ea360](https://github.com/Lundalogik/lime-elements/commit/c3ea360fcc50fe7131caf366b695614d41c14e9d))
14
+
1
15
  ## [39.2.1](https://github.com/Lundalogik/lime-elements/compare/v39.2.0...v39.2.1) (2026-02-22)
2
16
 
3
17
  ### Bug Fixes
@@ -4569,6 +4569,7 @@ class PostalMime {
4569
4569
  }
4570
4570
  }
4571
4571
 
4572
+ var _a, _b, _c, _d, _e, _f, _g;
4572
4573
  const allowedMimeTypes = new Set([
4573
4574
  'image/png',
4574
4575
  'image/jpeg',
@@ -4594,7 +4595,7 @@ const allowedMimeTypes = new Set([
4594
4595
  async function sanitizeEmailHTML(html) {
4595
4596
  const file = await index.unified()
4596
4597
  .use(index.rehypeParse)
4597
- .use(index.rehypeSanitize, getEmailSanitizationSchema())
4598
+ .use(index.rehypeSanitize, emailSanitizationSchema)
4598
4599
  .use(() => {
4599
4600
  return (tree) => {
4600
4601
  index.visit(tree, 'element', (node) => {
@@ -4607,65 +4608,62 @@ async function sanitizeEmailHTML(html) {
4607
4608
  .process(html);
4608
4609
  return file.toString();
4609
4610
  }
4611
+ // Base src protocols from defaultSchema, extended with 'data' below.
4612
+ const defaultSrcProtocols = (_b = (_a = index.defaultSchema.protocols) === null || _a === void 0 ? void 0 : _a.src) !== null && _b !== void 0 ? _b : [];
4610
4613
  /**
4611
- * Returns a rehype-sanitize schema that allows all standard HTML elements
4612
- * and attributes needed for rich email rendering, including `style`.
4614
+ * Rehype-sanitize schema that allows all standard HTML elements and attributes
4615
+ * needed for rich email rendering, including `style`.
4616
+ *
4617
+ * Hoisted to module scope since the schema has no runtime dependencies and
4618
+ * doesn't need to be reconstructed on every sanitization call.
4613
4619
  */
4614
- function getEmailSanitizationSchema() {
4615
- var _a, _b, _c, _d, _e, _f, _g;
4616
- const defaultSrcProtocols = (_b = (_a = index.defaultSchema.protocols) === null || _a === void 0 ? void 0 : _a.src) !== null && _b !== void 0 ? _b : [];
4617
- const schema = Object.assign(Object.assign({}, index.defaultSchema), {
4618
- // Disable the 'user-content-' prefix that rehype-sanitize adds to
4619
- // id and name attributes. Email HTML uses ids for internal anchor
4620
- // links (href="#section") that must resolve without a prefix.
4621
- clobberPrefix: '', protocols: Object.assign(Object.assign({}, index.defaultSchema.protocols), {
4622
- // Email bodies often embed images as data URLs. We allow `data:` here,
4623
- // but still validate the MIME type in `sanitizeDangerousUrls`.
4624
- src: [...defaultSrcProtocols, 'http', 'https', 'data']
4625
- }), attributes: Object.assign(Object.assign({}, index.defaultSchema.attributes), { table: [
4626
- ...((_c = index.defaultSchema.attributes.table) !== null && _c !== void 0 ? _c : []),
4627
- // Email HTML often relies on these legacy attributes.
4628
- 'cellpadding',
4629
- 'cellPadding',
4630
- 'cellspacing',
4631
- 'cellSpacing',
4632
- 'border',
4633
- 'dir',
4634
- 'width',
4635
- 'height',
4636
- ], font: ['face'], colgroup: [...((_d = index.defaultSchema.attributes.colgroup) !== null && _d !== void 0 ? _d : []), 'span'], col: [...((_e = index.defaultSchema.attributes.col) !== null && _e !== void 0 ? _e : []), 'width', 'span'], '*': [
4637
- ...((_f = index.defaultSchema.attributes['*']) !== null && _f !== void 0 ? _f : []),
4638
- 'style', // Allow inline styles on all elements
4639
- // NOTE: rehype/parse maps `class` to the HAST property name
4640
- // `className`, which is what rehype-sanitize checks.
4641
- 'className',
4642
- 'class', // Keep for completeness
4643
- 'id', // Allow id for anchors/internal navigation
4644
- // Used to store remote image URLs without loading them immediately.
4645
- 'data-remote-src',
4646
- 'dataRemoteSrc',
4647
- ] }),
4648
- // Allow common email-specific tags
4649
- tagNames: [
4650
- ...((_g = index.defaultSchema.tagNames) !== null && _g !== void 0 ? _g : []),
4651
- // Allow full-document HTML emails. These tags won't render as text,
4652
- // but keeping them avoids their contents being surfaced as plain text.
4653
- 'html',
4654
- 'head',
4655
- 'body',
4656
- 'title',
4657
- 'meta',
4658
- // Preserve embedded email CSS.
4659
- 'style',
4660
- // Preserve table column sizing when using <colgroup>/<col>.
4661
- 'colgroup',
4662
- 'col',
4663
- 'center', // Deprecated but widely used in email
4664
- 'font', // Deprecated but widely used in email
4665
- ]
4666
- });
4667
- return schema;
4668
- }
4620
+ const emailSanitizationSchema = Object.assign(Object.assign({}, index.defaultSchema), {
4621
+ // Disable the 'user-content-' prefix that rehype-sanitize adds to
4622
+ // id and name attributes. Email HTML uses ids for internal anchor
4623
+ // links (href="#section") that must resolve without a prefix.
4624
+ clobberPrefix: '', protocols: Object.assign(Object.assign({}, index.defaultSchema.protocols), {
4625
+ // Email bodies often embed images as data URLs. We allow `data:` here,
4626
+ // but still validate the MIME type in `sanitizeDangerousUrls`.
4627
+ src: [...defaultSrcProtocols, 'data']
4628
+ }), attributes: Object.assign(Object.assign({}, index.defaultSchema.attributes), { table: [
4629
+ ...((_c = index.defaultSchema.attributes.table) !== null && _c !== void 0 ? _c : []),
4630
+ // Email HTML often relies on these legacy attributes.
4631
+ // rehype-parse converts to camelCase HAST properties.
4632
+ 'cellPadding',
4633
+ 'cellSpacing',
4634
+ 'border',
4635
+ 'dir',
4636
+ 'width',
4637
+ 'height',
4638
+ ], font: ['color', 'size', 'face'], meta: ['charset', 'content', 'name'], colgroup: [...((_d = index.defaultSchema.attributes.colgroup) !== null && _d !== void 0 ? _d : []), 'span'], col: [...((_e = index.defaultSchema.attributes.col) !== null && _e !== void 0 ? _e : []), 'width', 'span'], '*': [
4639
+ ...((_f = index.defaultSchema.attributes['*']) !== null && _f !== void 0 ? _f : []),
4640
+ 'style', // Allow inline styles on all elements
4641
+ // NOTE: rehype/parse maps `class` to the HAST property name
4642
+ // `className`, which is what rehype-sanitize checks.
4643
+ 'className',
4644
+ 'id', // Allow id for anchors/internal navigation
4645
+ // Used to store remote image URLs without loading them immediately.
4646
+ 'dataRemoteSrc',
4647
+ ] }),
4648
+ // Allow common email-specific tags
4649
+ tagNames: [
4650
+ ...((_g = index.defaultSchema.tagNames) !== null && _g !== void 0 ? _g : []),
4651
+ // Allow full-document HTML emails. These tags won't render as text,
4652
+ // but keeping them avoids their contents being surfaced as plain text.
4653
+ 'html',
4654
+ 'head',
4655
+ 'body',
4656
+ 'title',
4657
+ 'meta',
4658
+ // Preserve embedded email CSS.
4659
+ 'style',
4660
+ // Preserve table column sizing when using <colgroup>/<col>.
4661
+ 'colgroup',
4662
+ 'col',
4663
+ 'center', // Deprecated but widely used in email
4664
+ 'font', // Deprecated but widely used in email
4665
+ ]
4666
+ });
4669
4667
  /**
4670
4668
  * Validates and normalizes potentially dangerous URL attributes.
4671
4669
  *
@@ -35,6 +35,82 @@ class ImageIntersectionObserver {
35
35
  }
36
36
  }
37
37
 
38
+ /**
39
+ * After innerHTML is set on a container, custom elements receive all
40
+ * attribute values as strings. This function walks whitelisted custom
41
+ * elements and parses any attribute values that look like JSON objects
42
+ * or arrays, setting them as JS properties instead.
43
+ *
44
+ * This enables markdown content to include custom elements with complex
45
+ * props, e.g.:
46
+ * ```html
47
+ * <limel-chip text="GitHub" link='{"href":"https://github.com","target":"_blank"}'></limel-chip>
48
+ * ```
49
+ *
50
+ * @param container - The root element to search within.
51
+ * @param whitelist - The list of whitelisted custom element definitions.
52
+ */
53
+ function hydrateCustomElements(container, whitelist) {
54
+ if (!container || !(whitelist === null || whitelist === void 0 ? void 0 : whitelist.length)) {
55
+ return;
56
+ }
57
+ for (const definition of whitelist) {
58
+ const elements = container.querySelectorAll(definition.tagName);
59
+ for (const element of elements) {
60
+ hydrateElement(element, definition.attributes);
61
+ }
62
+ }
63
+ }
64
+ function hydrateElement(element, attributes) {
65
+ for (const attrName of attributes) {
66
+ const value = element.getAttribute(attrName);
67
+ if (!value) {
68
+ continue;
69
+ }
70
+ const parsed = tryParseJson(value);
71
+ if (parsed !== undefined) {
72
+ // Set the JS property (camelCase) instead of the attribute
73
+ const propName = attributeToPropName(attrName);
74
+ element[propName] = parsed;
75
+ }
76
+ }
77
+ }
78
+ function tryParseJson(value) {
79
+ const trimmed = value.trim();
80
+ if ((trimmed.startsWith('{') && trimmed.endsWith('}')) ||
81
+ (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
82
+ try {
83
+ return JSON.parse(trimmed);
84
+ }
85
+ catch (_a) {
86
+ // The sanitizer may HTML-encode quotes inside attribute values.
87
+ // Try decoding common HTML entities before giving up.
88
+ try {
89
+ const decoded = trimmed
90
+ .replaceAll('&#x22;', '"')
91
+ .replaceAll('&#34;', '"')
92
+ .replaceAll('&quot;', '"')
93
+ .replaceAll('&#x27;', "'")
94
+ .replaceAll('&#39;', "'")
95
+ .replaceAll('&apos;', "'")
96
+ .replaceAll('&amp;', '&');
97
+ return JSON.parse(decoded);
98
+ }
99
+ catch (_b) {
100
+ return;
101
+ }
102
+ }
103
+ }
104
+ }
105
+ /**
106
+ * Convert a kebab-case attribute name to a camelCase property name.
107
+ * e.g. "menu-items" → "menuItems"
108
+ * @param attrName
109
+ */
110
+ function attributeToPropName(attrName) {
111
+ return attrName.replaceAll(/-([a-z])/g, (_, letter) => letter.toUpperCase());
112
+ }
113
+
38
114
  const markdownCss = () => `@charset "UTF-8";code{font-family:ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, "DejaVu Sans Mono", monospace;font-size:var(--limel-theme-default-small-font-size);letter-spacing:-0.0125rem;color:rgb(var(--contrast-1300));-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none;display:inline-block;border-radius:0.25rem;padding:0.03125rem 0.25rem;background-color:rgb(var(--contrast-600))}pre>code{display:block;margin:0.5rem 0;padding:0.5rem 0.75rem;overflow:auto;white-space:pre-wrap}h1{font-size:1.5rem}h2{font-size:1.25rem}h3{font-size:1.125rem}h4{font-size:1rem}h5{font-size:var(--limel-theme-default-font-size)}h6{font-size:0.75rem}h1,h2{margin-top:0.5rem;margin-bottom:0.5rem;letter-spacing:-0.03125rem;font-weight:500}h3,h4{margin-top:0.75rem;margin-bottom:0.25rem;font-weight:600}h5,h6{margin-top:0.5rem;margin-bottom:0.125rem;font-weight:600}h1,h2,h3,h4,h5,h6{word-break:break-word;hyphens:auto;-webkit-hyphens:auto}:not([contenteditable=true]) h1,:not([contenteditable=true]) h2,:not([contenteditable=true]) h3,:not([contenteditable=true]) h4,:not([contenteditable=true]) h5,:not([contenteditable=true]) h6{text-wrap:balance}[contenteditable=true] h1,[contenteditable=true] h2,[contenteditable=true] h3,[contenteditable=true] h4,[contenteditable=true] h5,[contenteditable=true] h6{text-wrap:initial}:host(limel-markdown.truncate-paragraphs) p{overflow:hidden;white-space:nowrap;text-overflow:ellipsis}p,li{font-size:var(--limel-theme-default-font-size);word-break:break-word}a{word-break:break-all}p{margin-top:0;margin-bottom:0.5rem}p:only-child{margin-bottom:0}a{transition:color 0.2s ease;color:var(--markdown-hyperlink-color, rgb(var(--color-blue-dark)));text-decoration:none}a:hover{color:var(--markdown-hyperlink-color--hovered, rgb(var(--color-blue-default)))}hr{margin:1.75rem 0 2rem 0;border-width:0;border-top:1px solid rgb(var(--contrast-500))}ul{list-style:none}ul li{position:relative;margin-left:0.75rem}ul li:before{content:"";position:absolute;left:-0.5rem;top:0.5rem;width:0.25rem;height:0.25rem;border-radius:50%;background-color:rgb(var(--contrast-700));display:block}ol{margin-top:0.25rem;padding-left:1rem}ul{margin-top:0.25rem;padding-left:0}ul ul,ul ol,ol ol,ol ul{margin-left:0}li{margin-bottom:0.25rem}:host(limel-markdown:not(.no-table-styles)) table{table-layout:auto;min-width:100%;border-collapse:collapse;border-spacing:0;background:transparent;margin:0.75rem 0}:host(limel-markdown:not(.no-table-styles)) tbody{border:1px solid rgb(var(--contrast-400));border-radius:0.25rem}:host(limel-markdown:not(.no-table-styles)) th,:host(limel-markdown:not(.no-table-styles)) td{text-align:left;vertical-align:top;transition:background-color 0.2s ease;font-size:var(--limel-theme-default-font-size)}:host(limel-markdown:not(.no-table-styles)) td{padding:0.5rem 0.375rem 0.75rem 0.375rem}:host(limel-markdown:not(.no-table-styles)) tr th{background-color:rgb(var(--contrast-400));padding:0.25rem 0.375rem;font-weight:normal}:host(limel-markdown:not(.no-table-styles)) tr th:only-child{text-align:center}:host(limel-markdown:not(.no-table-styles)) tbody tr:nth-child(odd) td{background-color:rgb(var(--contrast-200))}:host(limel-markdown:not(.no-table-styles)) tbody tr:hover td{background-color:rgb(var(--contrast-300))}table{display:block;box-sizing:border-box;overflow-x:auto;-webkit-overflow-scrolling:touch;max-width:100%}blockquote{position:relative;max-width:100%;margin:0.75rem 0;padding:0.5rem;border-left:0.25rem solid rgb(var(--contrast-500));background-color:rgb(var(--contrast-200))}blockquote:before,blockquote:after{position:absolute;line-height:0;font-size:2rem;opacity:0.4}blockquote:before{content:"“";left:-0.5rem;top:0.5rem}blockquote:after{content:"”";right:-0.25rem;bottom:-0.25rem}blockquote blockquote{padding-top:0;padding-right:0;padding-bottom:0;padding-left:0.25rem;border-color:rgb(var(--contrast-700));border-left-width:1px}blockquote blockquote:before,blockquote blockquote:after{display:none}blockquote:has(>blockquote){padding-left:0.25rem;padding-bottom:0}dl{display:grid;grid-template-columns:1fr 2fr;grid-template-rows:1fr;margin-bottom:2rem;border:1px solid rgb(var(--contrast-400));border-radius:0.375rem;background-color:rgb(var(--contrast-200))}dl dt,dl dd{padding:0.375rem 0.5rem;font-size:var(--limel-theme-default-font-size);margin:0}dl dt:nth-of-type(even),dl dd:nth-of-type(even){background-color:rgb(var(--contrast-300))}dl dt:first-child{border-top-left-radius:0.375rem}dl dt:last-child{border-bottom-left-radius:0.375rem}dl dd:first-child{border-top-right-radius:0.375rem}dl dd:last-child{border-bottom-right-radius:0.375rem}img{max-width:100%;border-radius:0.25rem}kbd{font-family:ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, "DejaVu Sans Mono", monospace;font-weight:600;color:rgb(var(--contrast-1100));background-color:rgb(var(--contrast-200));white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:normal;padding:0.125rem 0.5rem;margin:0 0.25rem;box-shadow:var(--button-shadow-normal), 0 0.03125rem 0.21875rem 0 rgba(var(--contrast-100), 0.5) inset;border-radius:0.125rem;border-style:solid;border-color:rgba(var(--contrast-600), 0.8);border-width:0 1px 0.125rem 1px}:host(limel-markdown.adjust-for-table-cell) img{max-height:1.25rem;vertical-align:middle}:host(limel-markdown.adjust-for-table-cell) p{display:inline}:host(limel-markdown.adjust-for-table-cell) h1,:host(limel-markdown.adjust-for-table-cell) h2,:host(limel-markdown.adjust-for-table-cell) h3,:host(limel-markdown.adjust-for-table-cell) h4,:host(limel-markdown.adjust-for-table-cell) h5,:host(limel-markdown.adjust-for-table-cell) h6{display:inline-block;vertical-align:bottom;font-size:var(--limel-theme-default-font-size);margin:0 0.25rem 0 0;letter-spacing:normal;font-weight:500}:host(limel-markdown.adjust-for-table-cell) h1:before,:host(limel-markdown.adjust-for-table-cell) h2:before,:host(limel-markdown.adjust-for-table-cell) h3:before,:host(limel-markdown.adjust-for-table-cell) h4:before,:host(limel-markdown.adjust-for-table-cell) h5:before,:host(limel-markdown.adjust-for-table-cell) h6:before{opacity:0.6;vertical-align:middle;font-size:0.5rem;border-radius:0.25rem 0 0 0.25rem;padding:0.25rem;padding-right:2rem;margin-right:-1.75rem;background:linear-gradient(to right, rgb(var(--contrast-800), 0.6), rgb(var(--contrast-800), 0))}:host(limel-markdown.adjust-for-table-cell) h1:before{content:"H1"}:host(limel-markdown.adjust-for-table-cell) h2:before{content:"H2"}:host(limel-markdown.adjust-for-table-cell) h3:before{content:"H3"}:host(limel-markdown.adjust-for-table-cell) h4:before{content:"H4"}:host(limel-markdown.adjust-for-table-cell) h5:before{content:"H5"}:host(limel-markdown.adjust-for-table-cell) h6:before{content:"H6"}:host(limel-markdown.adjust-for-table-cell) pre{margin:0}:host(limel-markdown.adjust-for-table-cell) pre>code{padding:0.125rem;margin:0}:host(limel-markdown.adjust-for-table-cell) dl{margin:0}:host(limel-markdown.adjust-for-table-cell) dl dt,:host(limel-markdown.adjust-for-table-cell) dl dd{padding:0.00625rem 0.125rem}*,*::before,*::after{box-sizing:border-box}* :where(:not(img,video,svg,canvas,iframe)),*::before :where(:not(img,video,svg,canvas,iframe)),*::after :where(:not(img,video,svg,canvas,iframe)){min-width:0;min-height:0}hr{border-top:1px solid rgb(var(--contrast-700))}.MsoNormal{margin:0}:host(limel-markdown.reset-img-height) #markdown img{height:auto}`;
39
115
 
40
116
  const Markdown = class {
@@ -69,7 +145,7 @@ const Markdown = class {
69
145
  this.imageIntersectionObserver = null;
70
146
  }
71
147
  async textChanged() {
72
- var _a;
148
+ var _a, _b;
73
149
  try {
74
150
  this.cleanupImageIntersectionObserver();
75
151
  const html = await markdownParser.markdownToHTML(this.value, {
@@ -79,6 +155,7 @@ const Markdown = class {
79
155
  removeEmptyParagraphs: this.removeEmptyParagraphs,
80
156
  });
81
157
  this.rootElement.innerHTML = html;
158
+ hydrateCustomElements(this.rootElement, (_b = this.whitelist) !== null && _b !== void 0 ? _b : []);
82
159
  this.setupImageIntersectionObserver();
83
160
  }
84
161
  catch (error) {
@@ -95,7 +172,7 @@ const Markdown = class {
95
172
  this.cleanupImageIntersectionObserver();
96
173
  }
97
174
  render() {
98
- return (index.h(index.Host, { key: 'b1939b572c0a9ac59178497f56ebc469bc707d98' }, index.h("div", { key: 'f9dd27e5263281fad9e6d1da9d25d3ccd3a9990e', id: "markdown", ref: (el) => (this.rootElement = el) })));
175
+ return (index.h(index.Host, { key: 'd7e3122596fa19c63e05d69855fbc693cb8c4fe7' }, index.h("div", { key: '9a65798b0888a3d2d7a35c0d320408faa3d937b6', id: "markdown", ref: (el) => (this.rootElement = el) })));
99
176
  }
100
177
  setupImageIntersectionObserver() {
101
178
  if (this.lazyLoadImages) {
@@ -1,3 +1,4 @@
1
+ var _a, _b, _c, _d, _e, _f, _g;
1
2
  import { unified } from "unified";
2
3
  import rehypeParse from "rehype-parse";
3
4
  import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
@@ -28,7 +29,7 @@ const allowedMimeTypes = new Set([
28
29
  export async function sanitizeEmailHTML(html) {
29
30
  const file = await unified()
30
31
  .use(rehypeParse)
31
- .use(rehypeSanitize, getEmailSanitizationSchema())
32
+ .use(rehypeSanitize, emailSanitizationSchema)
32
33
  .use(() => {
33
34
  return (tree) => {
34
35
  visit(tree, 'element', (node) => {
@@ -41,65 +42,62 @@ export async function sanitizeEmailHTML(html) {
41
42
  .process(html);
42
43
  return file.toString();
43
44
  }
45
+ // Base src protocols from defaultSchema, extended with 'data' below.
46
+ const defaultSrcProtocols = (_b = (_a = defaultSchema.protocols) === null || _a === void 0 ? void 0 : _a.src) !== null && _b !== void 0 ? _b : [];
44
47
  /**
45
- * Returns a rehype-sanitize schema that allows all standard HTML elements
46
- * and attributes needed for rich email rendering, including `style`.
48
+ * Rehype-sanitize schema that allows all standard HTML elements and attributes
49
+ * needed for rich email rendering, including `style`.
50
+ *
51
+ * Hoisted to module scope since the schema has no runtime dependencies and
52
+ * doesn't need to be reconstructed on every sanitization call.
47
53
  */
48
- function getEmailSanitizationSchema() {
49
- var _a, _b, _c, _d, _e, _f, _g;
50
- const defaultSrcProtocols = (_b = (_a = defaultSchema.protocols) === null || _a === void 0 ? void 0 : _a.src) !== null && _b !== void 0 ? _b : [];
51
- const schema = Object.assign(Object.assign({}, defaultSchema), {
52
- // Disable the 'user-content-' prefix that rehype-sanitize adds to
53
- // id and name attributes. Email HTML uses ids for internal anchor
54
- // links (href="#section") that must resolve without a prefix.
55
- clobberPrefix: '', protocols: Object.assign(Object.assign({}, defaultSchema.protocols), {
56
- // Email bodies often embed images as data URLs. We allow `data:` here,
57
- // but still validate the MIME type in `sanitizeDangerousUrls`.
58
- src: [...defaultSrcProtocols, 'http', 'https', 'data']
59
- }), attributes: Object.assign(Object.assign({}, defaultSchema.attributes), { table: [
60
- ...((_c = defaultSchema.attributes.table) !== null && _c !== void 0 ? _c : []),
61
- // Email HTML often relies on these legacy attributes.
62
- 'cellpadding',
63
- 'cellPadding',
64
- 'cellspacing',
65
- 'cellSpacing',
66
- 'border',
67
- 'dir',
68
- 'width',
69
- 'height',
70
- ], font: ['face'], colgroup: [...((_d = defaultSchema.attributes.colgroup) !== null && _d !== void 0 ? _d : []), 'span'], col: [...((_e = defaultSchema.attributes.col) !== null && _e !== void 0 ? _e : []), 'width', 'span'], '*': [
71
- ...((_f = defaultSchema.attributes['*']) !== null && _f !== void 0 ? _f : []),
72
- 'style', // Allow inline styles on all elements
73
- // NOTE: rehype/parse maps `class` to the HAST property name
74
- // `className`, which is what rehype-sanitize checks.
75
- 'className',
76
- 'class', // Keep for completeness
77
- 'id', // Allow id for anchors/internal navigation
78
- // Used to store remote image URLs without loading them immediately.
79
- 'data-remote-src',
80
- 'dataRemoteSrc',
81
- ] }),
82
- // Allow common email-specific tags
83
- tagNames: [
84
- ...((_g = defaultSchema.tagNames) !== null && _g !== void 0 ? _g : []),
85
- // Allow full-document HTML emails. These tags won't render as text,
86
- // but keeping them avoids their contents being surfaced as plain text.
87
- 'html',
88
- 'head',
89
- 'body',
90
- 'title',
91
- 'meta',
92
- // Preserve embedded email CSS.
93
- 'style',
94
- // Preserve table column sizing when using <colgroup>/<col>.
95
- 'colgroup',
96
- 'col',
97
- 'center', // Deprecated but widely used in email
98
- 'font', // Deprecated but widely used in email
99
- ]
100
- });
101
- return schema;
102
- }
54
+ const emailSanitizationSchema = Object.assign(Object.assign({}, defaultSchema), {
55
+ // Disable the 'user-content-' prefix that rehype-sanitize adds to
56
+ // id and name attributes. Email HTML uses ids for internal anchor
57
+ // links (href="#section") that must resolve without a prefix.
58
+ clobberPrefix: '', protocols: Object.assign(Object.assign({}, defaultSchema.protocols), {
59
+ // Email bodies often embed images as data URLs. We allow `data:` here,
60
+ // but still validate the MIME type in `sanitizeDangerousUrls`.
61
+ src: [...defaultSrcProtocols, 'data']
62
+ }), attributes: Object.assign(Object.assign({}, defaultSchema.attributes), { table: [
63
+ ...((_c = defaultSchema.attributes.table) !== null && _c !== void 0 ? _c : []),
64
+ // Email HTML often relies on these legacy attributes.
65
+ // rehype-parse converts to camelCase HAST properties.
66
+ 'cellPadding',
67
+ 'cellSpacing',
68
+ 'border',
69
+ 'dir',
70
+ 'width',
71
+ 'height',
72
+ ], font: ['color', 'size', 'face'], meta: ['charset', 'content', 'name'], colgroup: [...((_d = defaultSchema.attributes.colgroup) !== null && _d !== void 0 ? _d : []), 'span'], col: [...((_e = defaultSchema.attributes.col) !== null && _e !== void 0 ? _e : []), 'width', 'span'], '*': [
73
+ ...((_f = defaultSchema.attributes['*']) !== null && _f !== void 0 ? _f : []),
74
+ 'style', // Allow inline styles on all elements
75
+ // NOTE: rehype/parse maps `class` to the HAST property name
76
+ // `className`, which is what rehype-sanitize checks.
77
+ 'className',
78
+ 'id', // Allow id for anchors/internal navigation
79
+ // Used to store remote image URLs without loading them immediately.
80
+ 'dataRemoteSrc',
81
+ ] }),
82
+ // Allow common email-specific tags
83
+ tagNames: [
84
+ ...((_g = defaultSchema.tagNames) !== null && _g !== void 0 ? _g : []),
85
+ // Allow full-document HTML emails. These tags won't render as text,
86
+ // but keeping them avoids their contents being surfaced as plain text.
87
+ 'html',
88
+ 'head',
89
+ 'body',
90
+ 'title',
91
+ 'meta',
92
+ // Preserve embedded email CSS.
93
+ 'style',
94
+ // Preserve table column sizing when using <colgroup>/<col>.
95
+ 'colgroup',
96
+ 'col',
97
+ 'center', // Deprecated but widely used in email
98
+ 'font', // Deprecated but widely used in email
99
+ ]
100
+ });
103
101
  /**
104
102
  * Validates and normalizes potentially dangerous URL attributes.
105
103
  *
@@ -0,0 +1,75 @@
1
+ /**
2
+ * After innerHTML is set on a container, custom elements receive all
3
+ * attribute values as strings. This function walks whitelisted custom
4
+ * elements and parses any attribute values that look like JSON objects
5
+ * or arrays, setting them as JS properties instead.
6
+ *
7
+ * This enables markdown content to include custom elements with complex
8
+ * props, e.g.:
9
+ * ```html
10
+ * <limel-chip text="GitHub" link='{"href":"https://github.com","target":"_blank"}'></limel-chip>
11
+ * ```
12
+ *
13
+ * @param container - The root element to search within.
14
+ * @param whitelist - The list of whitelisted custom element definitions.
15
+ */
16
+ export function hydrateCustomElements(container, whitelist) {
17
+ if (!container || !(whitelist === null || whitelist === void 0 ? void 0 : whitelist.length)) {
18
+ return;
19
+ }
20
+ for (const definition of whitelist) {
21
+ const elements = container.querySelectorAll(definition.tagName);
22
+ for (const element of elements) {
23
+ hydrateElement(element, definition.attributes);
24
+ }
25
+ }
26
+ }
27
+ function hydrateElement(element, attributes) {
28
+ for (const attrName of attributes) {
29
+ const value = element.getAttribute(attrName);
30
+ if (!value) {
31
+ continue;
32
+ }
33
+ const parsed = tryParseJson(value);
34
+ if (parsed !== undefined) {
35
+ // Set the JS property (camelCase) instead of the attribute
36
+ const propName = attributeToPropName(attrName);
37
+ element[propName] = parsed;
38
+ }
39
+ }
40
+ }
41
+ function tryParseJson(value) {
42
+ const trimmed = value.trim();
43
+ if ((trimmed.startsWith('{') && trimmed.endsWith('}')) ||
44
+ (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
45
+ try {
46
+ return JSON.parse(trimmed);
47
+ }
48
+ catch (_a) {
49
+ // The sanitizer may HTML-encode quotes inside attribute values.
50
+ // Try decoding common HTML entities before giving up.
51
+ try {
52
+ const decoded = trimmed
53
+ .replaceAll('&#x22;', '"')
54
+ .replaceAll('&#34;', '"')
55
+ .replaceAll('&quot;', '"')
56
+ .replaceAll('&#x27;', "'")
57
+ .replaceAll('&#39;', "'")
58
+ .replaceAll('&apos;', "'")
59
+ .replaceAll('&amp;', '&');
60
+ return JSON.parse(decoded);
61
+ }
62
+ catch (_b) {
63
+ return;
64
+ }
65
+ }
66
+ }
67
+ }
68
+ /**
69
+ * Convert a kebab-case attribute name to a camelCase property name.
70
+ * e.g. "menu-items" → "menuItems"
71
+ * @param attrName
72
+ */
73
+ function attributeToPropName(attrName) {
74
+ return attrName.replaceAll(/-([a-z])/g, (_, letter) => letter.toUpperCase());
75
+ }
@@ -2,6 +2,7 @@ import { h, Host } from "@stencil/core";
2
2
  import { markdownToHTML } from "./markdown-parser";
3
3
  import { globalConfig } from "../../global/config";
4
4
  import { ImageIntersectionObserver } from "./image-intersection-observer";
5
+ import { hydrateCustomElements } from "./hydrate-custom-elements";
5
6
  /**
6
7
  * The Markdown component receives markdown syntax
7
8
  * and renders it as HTML.
@@ -19,6 +20,7 @@ import { ImageIntersectionObserver } from "./image-intersection-observer";
19
20
  * @exampleComponent limel-example-markdown-blockquotes
20
21
  * @exampleComponent limel-example-markdown-horizontal-rule
21
22
  * @exampleComponent limel-example-markdown-custom-component
23
+ * @exampleComponent limel-example-markdown-custom-component-with-json-props
22
24
  * @exampleComponent limel-example-markdown-remove-empty-paragraphs
23
25
  * @exampleComponent limel-example-markdown-composite
24
26
  */
@@ -53,7 +55,7 @@ export class Markdown {
53
55
  this.imageIntersectionObserver = null;
54
56
  }
55
57
  async textChanged() {
56
- var _a;
58
+ var _a, _b;
57
59
  try {
58
60
  this.cleanupImageIntersectionObserver();
59
61
  const html = await markdownToHTML(this.value, {
@@ -63,6 +65,7 @@ export class Markdown {
63
65
  removeEmptyParagraphs: this.removeEmptyParagraphs,
64
66
  });
65
67
  this.rootElement.innerHTML = html;
68
+ hydrateCustomElements(this.rootElement, (_b = this.whitelist) !== null && _b !== void 0 ? _b : []);
66
69
  this.setupImageIntersectionObserver();
67
70
  }
68
71
  catch (error) {
@@ -79,7 +82,7 @@ export class Markdown {
79
82
  this.cleanupImageIntersectionObserver();
80
83
  }
81
84
  render() {
82
- return (h(Host, { key: 'b1939b572c0a9ac59178497f56ebc469bc707d98' }, h("div", { key: 'f9dd27e5263281fad9e6d1da9d25d3ccd3a9990e', id: "markdown", ref: (el) => (this.rootElement = el) })));
85
+ return (h(Host, { key: 'd7e3122596fa19c63e05d69855fbc693cb8c4fe7' }, h("div", { key: '9a65798b0888a3d2d7a35c0d320408faa3d937b6', id: "markdown", ref: (el) => (this.rootElement = el) })));
83
86
  }
84
87
  setupImageIntersectionObserver() {
85
88
  if (this.lazyLoadImages) {