UNPKG

@limetech/lime-elements

Version:
279 lines (278 loc) 11.9 kB
import { h, Host } from "@stencil/core"; import { markdownToHTML } from "./markdown-parser"; import { globalConfig } from "../../global/config"; import { ImageIntersectionObserver } from "./image-intersection-observer"; import { hydrateCustomElements } from "./hydrate-custom-elements"; import { DEFAULT_MARKDOWN_WHITELIST } from "./default-whitelist"; /** * The Markdown component receives markdown syntax * and renders it as HTML. * * A built-in set of lime-elements components is whitelisted by default * and can be used directly in markdown content without any configuration. * Consumers can extend this list via the `whitelist` prop or `limel-config`. * * When custom elements use JSON attribute values, any URL-bearing * properties (`href`, `src`, `cite`, `longDesc`) are automatically * sanitized using the same protocol allowlists as rehype-sanitize. * URLs with dangerous schemes (e.g. `javascript:`, `data:`) are * removed (with a console warning) to prevent script injection. * * @exampleComponent limel-example-markdown-headings * @exampleComponent limel-example-markdown-emphasis * @exampleComponent limel-example-markdown-lists * @exampleComponent limel-example-markdown-links * @exampleComponent limel-example-markdown-images * @exampleComponent limel-example-markdown-code * @exampleComponent limel-example-markdown-footnotes * @exampleComponent limel-example-markdown-tables * @exampleComponent limel-example-markdown-html * @exampleComponent limel-example-markdown-keys * @exampleComponent limel-example-markdown-blockquotes * @exampleComponent limel-example-markdown-horizontal-rule * @exampleComponent limel-example-markdown-custom-component * @exampleComponent limel-example-markdown-custom-component-with-json-props * @exampleComponent limel-example-markdown-remove-empty-paragraphs * @exampleComponent limel-example-markdown-composite */ export class Markdown { constructor() { /** * The input text. Treated as GitHub Flavored Markdown, with the addition * that any included HTML will be parsed and rendered as HTML, rather than * as text. */ this.value = ''; /** * Additional whitelisted custom elements to render inside markdown. * * A built-in set of lime-elements components (such as `limel-chip`, * `limel-icon`, `limel-badge`, `limel-callout`, etc.) is always * allowed by default. Any entries provided here are **merged** with * those defaults — if both define the same `tagName`, their * attributes are combined. * * Can also be set via `limel-config`. Setting this property will * override the global config. * * JSON attribute values that contain URL-bearing properties * (`href`, `src`, `cite`, `longDesc`) are automatically sanitized * using the same protocol allowlists as rehype-sanitize. URLs with * dangerous schemes (e.g. `javascript:`, `data:`) are removed * (with a console warning). * * @alpha */ this.whitelist = globalConfig.markdownWhitelist; /** * Enable lazy loading for images */ this.lazyLoadImages = false; /** * Set to `false` to preserve empty paragraphs before rendering. * Empty paragraphs are paragraphs that do not contain * any meaningful content (text, images, etc.), or only contain * whitespace (`<br />` or `&nbsp;`). */ this.removeEmptyParagraphs = true; this.imageIntersectionObserver = null; } async textChanged() { try { this.cleanupImageIntersectionObserver(); // The whitelist merge and default import live here (not in // markdown-parser.ts) because this component orchestrates both // the parser and hydration, which both need the combined list. if (!this.cachedCombinedWhitelist || this.whitelist !== this.cachedConsumerWhitelist) { this.cachedConsumerWhitelist = this.whitelist; this.cachedCombinedWhitelist = mergeWhitelists(DEFAULT_MARKDOWN_WHITELIST, this.whitelist); } const combinedWhitelist = this.cachedCombinedWhitelist; const html = await markdownToHTML(this.value, { forceHardLineBreaks: true, whitelist: combinedWhitelist, lazyLoadImages: this.lazyLoadImages, removeEmptyParagraphs: this.removeEmptyParagraphs, }); this.rootElement.innerHTML = html; // Hydration parses JSON attribute values (e.g. link='{"href":"..."}') // into JS properties. URL sanitization happens here because // rehype-sanitize can't inspect values inside JSON strings. hydrateCustomElements(this.rootElement, combinedWhitelist); this.setupImageIntersectionObserver(); } catch (error) { console.error(error); } } handleWhitelistChange() { return this.textChanged(); } handleRemoveEmptyParagraphsChange() { return this.textChanged(); } async componentDidLoad() { this.textChanged(); } disconnectedCallback() { this.cleanupImageIntersectionObserver(); } render() { return (h(Host, { key: 'd3c5e71466ad7fa2723a0a44bc6ba6742e597ca1' }, h("div", { key: 'ff45056e1a3ad465bdea9026b0c9674d911607a2', id: "markdown", ref: (el) => (this.rootElement = el) }))); } setupImageIntersectionObserver() { if (this.lazyLoadImages) { this.imageIntersectionObserver = new ImageIntersectionObserver(this.rootElement); } } cleanupImageIntersectionObserver() { if (this.imageIntersectionObserver) { this.imageIntersectionObserver.disconnect(); this.imageIntersectionObserver = null; } } static get is() { return "limel-markdown"; } static get encapsulation() { return "shadow"; } static get originalStyleUrls() { return { "$": ["markdown.scss"] }; } static get styleUrls() { return { "$": ["markdown.css"] }; } static get properties() { return { "value": { "type": "string", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "The input text. Treated as GitHub Flavored Markdown, with the addition\nthat any included HTML will be parsed and rendered as HTML, rather than\nas text." }, "getter": false, "setter": false, "reflect": false, "attribute": "value", "defaultValue": "''" }, "whitelist": { "type": "unknown", "mutable": false, "complexType": { "original": "CustomElementDefinition[]", "resolved": "CustomElementDefinition[]", "references": { "CustomElementDefinition": { "location": "import", "path": "../../global/shared-types/custom-element.types", "id": "src/global/shared-types/custom-element.types.ts::CustomElementDefinition", "referenceLocation": "CustomElementDefinition" } } }, "required": false, "optional": true, "docs": { "tags": [{ "name": "alpha", "text": undefined }], "text": "Additional whitelisted custom elements to render inside markdown.\n\nA built-in set of lime-elements components (such as `limel-chip`,\n`limel-icon`, `limel-badge`, `limel-callout`, etc.) is always\nallowed by default. Any entries provided here are **merged** with\nthose defaults \u2014 if both define the same `tagName`, their\nattributes are combined.\n\nCan also be set via `limel-config`. Setting this property will\noverride the global config.\n\nJSON attribute values that contain URL-bearing properties\n(`href`, `src`, `cite`, `longDesc`) are automatically sanitized\nusing the same protocol allowlists as rehype-sanitize. URLs with\ndangerous schemes (e.g. `javascript:`, `data:`) are removed\n(with a console warning)." }, "getter": false, "setter": false, "defaultValue": "globalConfig.markdownWhitelist" }, "lazyLoadImages": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "Enable lazy loading for images" }, "getter": false, "setter": false, "reflect": true, "attribute": "lazy-load-images", "defaultValue": "false" }, "removeEmptyParagraphs": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "Set to `false` to preserve empty paragraphs before rendering.\nEmpty paragraphs are paragraphs that do not contain\nany meaningful content (text, images, etc.), or only contain\nwhitespace (`<br />` or `&nbsp;`)." }, "getter": false, "setter": false, "reflect": true, "attribute": "remove-empty-paragraphs", "defaultValue": "true" } }; } static get watchers() { return [{ "propName": "value", "methodName": "textChanged" }, { "propName": "whitelist", "methodName": "handleWhitelistChange" }, { "propName": "removeEmptyParagraphs", "methodName": "handleRemoveEmptyParagraphsChange" }]; } } /** * Merge the default whitelist with a consumer-provided one. * If both define the same tagName, their attributes are combined. * @param defaults * @param consumer */ function mergeWhitelists(defaults, consumer) { if (!(consumer === null || consumer === void 0 ? void 0 : consumer.length)) { return defaults.map((def) => (Object.assign(Object.assign({}, def), { attributes: [...def.attributes] }))); } const merged = new Map(); for (const def of [...defaults, ...consumer]) { const existing = merged.get(def.tagName); if (existing) { for (const attr of def.attributes) { existing.add(attr); } } else { merged.set(def.tagName, new Set(def.attributes)); } } return [...merged.entries()].map(([tagName, attrs]) => ({ tagName, attributes: [...attrs], })); }