@limetech/lime-elements
Version:
279 lines (278 loc) • 11.9 kB
JavaScript
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 ` `).
*/
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 ` `)."
},
"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],
}));
}