@limetech/lime-elements
Version:
391 lines (383 loc) • 21.4 kB
JavaScript
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('"', '"')
.replaceAll('"', '"')
.replaceAll('"', '"')
.replaceAll(''', "'")
.replaceAll(''', "'")
.replaceAll(''', "'")
.replaceAll('&', '&');
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 = () => ` "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 ` `).
*/
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 };