UNPKG

@limetech/lime-elements

Version:
391 lines (383 loc) 21.4 kB
import { r as registerInstance, h, H as Host } from './index-DBTJNfo7.js'; import { m as markdownToHTML } from './markdown-parser-CeVA0wsI.js'; import { g as globalConfig } from './config-Dnt5w_Bp.js'; import { d as defaultSchema } from './index-CJ0GYrWG.js'; import './_commonjsHelpers-BFTU3MAI.js'; class ImageIntersectionObserver { /** * @param containerElement - The element containing images to observe. */ constructor(containerElement) { this.handleIntersection = (entries) => { for (const entry of entries) { if (entry.isIntersecting) { const img = entry.target; const dataSrc = img.dataset.src; if (dataSrc) { img.setAttribute('src', dataSrc); delete img.dataset.src; } this.observer.unobserve(img); } } }; this.observer = new IntersectionObserver(this.handleIntersection); const images = containerElement.querySelectorAll('img'); for (const img of images) { this.observer.observe(img); } } disconnect() { this.observer.disconnect(); } } var _a; /** * Map of URL-bearing property names to their allowed protocols, sourced * from rehype-sanitize's defaultSchema. This means protocol updates from * Dependabot bumps to rehype-sanitize automatically apply here too — * no custom blocklist to maintain. * * We can't use rehype-sanitize directly for URL sanitization because it * operates on HTML attributes, not on values inside JSON strings. By the * time rehype-sanitize runs, `link` is just a raw JSON string attribute — * the `href` only becomes visible after JSON parsing in hydration. * So we replicate rehype-sanitize's protocol validation logic here, * using its own protocol list as the source of truth. * * The map covers all URL-bearing property names that rehype-sanitize * defines protocols for: `href`, `src`, `cite`, and `longDesc`. * * @internal */ const SAFE_PROTOCOLS_BY_PROPERTY = new Map(Object.entries((_a = defaultSchema.protocols) !== null && _a !== void 0 ? _a : {}).map(([prop, protocols]) => [ prop, new Set(protocols), ])); /** * After innerHTML is set on a container, custom elements receive all * attribute values as strings. This function walks whitelisted custom * elements and parses any attribute values that look like JSON objects * or arrays, setting them as JS properties instead. * * This enables markdown content to include custom elements with complex * props, e.g.: * ```html * <limel-chip text="GitHub" link='{"href":"https://github.com","target":"_blank"}'></limel-chip> * ``` * * @param container - The root element to search within. * @param whitelist - The list of whitelisted custom element definitions. */ function hydrateCustomElements(container, whitelist) { if (!container || !(whitelist === null || whitelist === void 0 ? void 0 : whitelist.length)) { return; } for (const definition of whitelist) { const elements = container.querySelectorAll(definition.tagName); for (const element of elements) { hydrateElement(element, definition.attributes); } } } function hydrateElement(element, attributes) { for (const attrName of attributes) { const value = element.getAttribute(attrName); if (!value) { continue; } const parsed = tryParseJson(value); if (parsed !== undefined) { const sanitized = sanitizeUrls(parsed); // Set the JS property (camelCase) instead of the attribute const propName = attributeToPropName(attrName); element[propName] = sanitized; } } } function tryParseJson(value) { const trimmed = value.trim(); if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) { try { return JSON.parse(trimmed); } catch (_a) { // The sanitizer may HTML-encode quotes inside attribute values. // Try decoding common HTML entities before giving up. try { const decoded = trimmed .replaceAll('&#x22;', '"') .replaceAll('&#34;', '"') .replaceAll('&quot;', '"') .replaceAll('&#x27;', "'") .replaceAll('&#39;', "'") .replaceAll('&apos;', "'") .replaceAll('&amp;', '&'); return JSON.parse(decoded); } catch (_b) { return; } } } } /** * Check whether a URL string uses a safe protocol for the given property. * Relative URLs, hash links, and protocol-relative URLs are always allowed. * @param value - The URL string to check. * @param allowedProtocols - The set of allowed protocols for this property. */ function isSafeUrl(value, allowedProtocols) { const trimmed = value.trim(); const colonIndex = trimmed.indexOf(':'); // No colon, or colon appears after ?, #, or / → relative URL, always safe if (colonIndex === -1 || /[?#/]/.test(trimmed.slice(0, colonIndex))) { return true; } const protocol = trimmed.slice(0, colonIndex).toLowerCase(); return allowedProtocols.has(protocol); } /** * Recursively sanitize URL-bearing properties in a parsed JSON value. * Uses the same protocol allowlists as rehype-sanitize to block dangerous * schemes (e.g. `javascript:`, `data:`) while allowing safe ones. * Covers all URL properties that rehype-sanitize defines protocols for: * `href`, `src`, `cite`, and `longDesc`. * Unsafe URLs are removed to prevent script injection. * @param value */ function sanitizeUrls(value) { if (value === null || typeof value !== 'object') { return value; } if (Array.isArray(value)) { return value.map(sanitizeUrls); } const result = Object.assign({}, value); for (const key of Object.keys(result)) { const allowedProtocols = SAFE_PROTOCOLS_BY_PROPERTY.get(key); if (allowedProtocols && typeof result[key] === 'string' && !isSafeUrl(result[key], allowedProtocols)) { console.warn(`limel-markdown: Removed unsafe URL from "${key}" during sanitization.`); delete result[key]; } else if (typeof result[key] === 'object' && result[key] !== null) { result[key] = sanitizeUrls(result[key]); } } return result; } /** * Convert a kebab-case attribute name to a camelCase property name. * e.g. "menu-items" → "menuItems" * @param attrName */ function attributeToPropName(attrName) { return attrName.replaceAll(/-([a-z])/g, (_, letter) => letter.toUpperCase()); } /** * Default whitelist of lime-elements components that are safe to render * inside `limel-markdown`. * * These components are self-contained and require no complex setup. * URL-bearing properties (e.g. `link.href` on `limel-chip`) are * automatically sanitized during hydration to prevent injection attacks. * * Consumers can extend this list via the `whitelist` prop or * `limel-config` global config. * * @internal */ const DEFAULT_MARKDOWN_WHITELIST = [ { tagName: 'limel-chip', attributes: [ 'text', 'icon', 'link', 'badge', 'disabled', 'readonly', 'selected', 'type', 'size', ], }, { tagName: 'limel-icon', attributes: ['name', 'size', 'badge'], }, { tagName: 'limel-badge', attributes: ['label'], }, { tagName: 'limel-callout', attributes: ['heading', 'icon', 'type'], }, { tagName: 'limel-linear-progress', attributes: ['value', 'indeterminate'], }, { tagName: 'limel-circular-progress', attributes: [ 'value', 'max-value', 'prefix', 'suffix', 'size', 'display-percentage-colors', ], }, { tagName: 'limel-spinner', attributes: ['size'], }, { tagName: 'limel-info-tile', attributes: ['value', 'icon', 'label', 'prefix', 'suffix', 'badge'], }, ]; const markdownCss = () => `@charset "UTF-8";pre,pre p,code{font-family:ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, "DejaVu Sans Mono", monospace;font-size:0.75rem}pre{max-width:100%;white-space:pre-wrap;word-wrap:break-word}code{letter-spacing:-0.0125rem;color:rgb(var(--contrast-1300));tab-size:4;hyphens:none;display:inline;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}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{display:inline-block;font-family:ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, "DejaVu Sans Mono", monospace;font-weight:600;color:rgb(var(--contrast-1100));white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:normal;border-radius:0.125rem;border-style:solid;border-color:rgb(var(--contrast-500));border-width:0 1px 0.1875rem 1px;padding:0.0625rem 0.375rem;margin:0 0.25rem;background-color:rgb(var(--contrast-200));box-shadow:var(--button-shadow-normal), 0 0.625rem 0.375rem -0.5rem rgb(var(--color-black), 0.02), 0 0.025rem 0.5rem 0 rgb(var(--contrast-100)) inset}: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}`; const Markdown = class { constructor(hostRef) { registerInstance(this, hostRef); /** * 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 watchers() { return { "value": [{ "textChanged": 0 }], "whitelist": [{ "handleWhitelistChange": 0 }], "removeEmptyParagraphs": [{ "handleRemoveEmptyParagraphsChange": 0 }] }; } }; /** * 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], })); } Markdown.style = markdownCss(); export { Markdown as limel_markdown };