import { escapeHtml, safeUrl } from "../inline/render.js";
/**
* Attribute rendering for handlers that emit tags with caller-supplied
* attributes.
*
* Escaping the *value* is not sufficient: an attribute **name** is written
* outside quotes, so a name like `a" onload="alert(1)` breaks out of the tag
* regardless of how its value is escaped. Names are therefore validated
* against a strict pattern rather than escaped, and event handlers and
* URL-bearing attributes are filtered on top of that.
*/
/** HTML attribute names we are willing to emit: letters, digits, `-`, `_`, `:`. */
const VALID_ATTR_NAME = /^[a-zA-Z][a-zA-Z0-9\-_:]*$/;
/** Attributes whose value is a URL and therefore needs scheme filtering. */
const URL_ATTRS = new Set([
"href",
"src",
"action",
"formaction",
"poster",
"cite",
"data",
"background",
"xlink:href",
]);
/**
* Custom element / tag names we are willing to emit. Mirrors the HTML custom
* element grammar closely enough to make tag-name breakout impossible.
*/
const VALID_TAG_NAME = /^[a-zA-Z][a-zA-Z0-9]*(?:-[a-zA-Z0-9]+)*$/;
const FALSY_ATTR_VALUES = new Set(["", "false", "null", "undefined", "0"]);
export function isFalsyAttrValue(value: string | undefined): boolean {
return value === undefined || FALSY_ATTR_VALUES.has(value.trim().toLowerCase());
}
/** True when `name` is safe to emit as an attribute name. */
export function isSafeAttrName(name: string): boolean {
if (!VALID_ATTR_NAME.test(name)) return false;
// `onclick`, `onfocus`, … execute script; never emit them from markdown.
if (/^on/i.test(name)) return false;
return true;
}
/** True when `name` is safe to emit as a tag name. */
export function isSafeTagName(name: string): boolean {
return VALID_TAG_NAME.test(name);
}
/**
* Render an attribute record to a ` k="v"` string, dropping unsafe names and
* neutralising script-bearing URLs. Returns "" when nothing survives.
*/
export function renderAttrs(
attrs?: Record,
booleans?: ReadonlySet,
): string {
const parts: string[] = [];
for (const [k, v] of Object.entries(attrs ?? {})) {
if (!isSafeAttrName(k)) continue;
if (booleans?.has(k.toLowerCase())) {
if (!isFalsyAttrValue(v)) parts.push(k);
continue;
}
const value = URL_ATTRS.has(k.toLowerCase()) ? safeUrl(v) : v;
parts.push(`${k}="${escapeHtml(value)}"`);
}
return parts.length > 0 ? ` ${parts.join(" ")}` : "";
}
/** Error box matching the style other handlers use for malformed input. */
export function renderBlockError(message: string): string {
return `${escapeHtml(message)}`;
}