UNPKG

@limetech/lime-elements

Version:
125 lines (124 loc) 4.72 kB
import { SAFE_PROTOCOLS_BY_PROPERTY } from "./safe-url-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. */ export 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()); }