@limetech/lime-elements
Version:
125 lines (124 loc) • 4.72 kB
JavaScript
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('"', '"')
.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());
}