| |
| export type DomChild = Node | string | number | null | undefined | false; |
|
|
| declare const trustedHtmlBrand: unique symbol; |
|
|
| |
| |
| |
| |
| export type TrustedHtml = string & { readonly [trustedHtmlBrand]: true }; |
|
|
| |
| export interface DomProps { |
| className?: string; |
| style?: Partial<CSSStyleDeclaration> | string; |
| dataset?: Record<string, string>; |
| [key: string]: unknown; |
| } |
|
|
| export function h( |
| tag: string, |
| propsOrChild?: DomProps | DomChild | null, |
| ...children: DomChild[] |
| ): HTMLElement { |
| const el = document.createElement(tag); |
|
|
| let allChildren: DomChild[]; |
|
|
| if ( |
| propsOrChild != null && |
| typeof propsOrChild === 'object' && |
| !(propsOrChild instanceof Node) |
| ) { |
| applyProps(el, propsOrChild as DomProps); |
| allChildren = children; |
| } else { |
| allChildren = [propsOrChild as DomChild, ...children]; |
| } |
|
|
| appendChildren(el, allChildren); |
| return el; |
| } |
|
|
| export function text(value: string): Text { |
| return document.createTextNode(value); |
| } |
|
|
| export function fragment(...children: DomChild[]): DocumentFragment { |
| const frag = document.createDocumentFragment(); |
| appendChildren(frag, children); |
| return frag; |
| } |
|
|
| export function clearChildren(el: Element): void { |
| while (el.lastChild) el.removeChild(el.lastChild); |
| } |
|
|
| export function replaceChildren(el: Element, ...children: DomChild[]): void { |
| const frag = document.createDocumentFragment(); |
| appendChildren(frag, children); |
| clearChildren(el); |
| el.appendChild(frag); |
| } |
|
|
| export function trustedHtml(html: string, reason: string): TrustedHtml { |
| if (!reason.trim()) { |
| throw new Error('trustedHtml() requires an audit reason'); |
| } |
| return html as TrustedHtml; |
| } |
|
|
| export function setTrustedHtml(el: Element, html: TrustedHtml): void { |
| el.innerHTML = html; |
| } |
|
|
| export function rawHtml(html: TrustedHtml): DocumentFragment { |
| const tpl = document.createElement('template'); |
| tpl.innerHTML = html; |
| return tpl.content; |
| } |
|
|
| const SAFE_TAGS = new Set([ |
| 'strong', 'em', 'b', 'i', 'br', 'p', 'ul', 'ol', 'li', 'span', 'div', 'a', |
| ]); |
| const SAFE_ATTRS = new Set(['class', 'href', 'target', 'rel', 'style']); |
|
|
| |
| |
| const SAFE_STYLE_RE = /^color:\s*(#[0-9a-fA-F]{3,8}|rgb\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)|[a-zA-Z]+|var\(--[\w-]+\))\s*;?\s*$/; |
|
|
| export function ensureNoopenerRel(rel: string | null): string { |
| const tokens = new Set( |
| (rel ?? '') |
| .split(/\s+/) |
| .map((token) => token.trim().toLowerCase()) |
| .filter((token) => token && token !== 'opener'), |
| ); |
| tokens.add('noopener'); |
| tokens.add('noreferrer'); |
| return Array.from(tokens).join(' '); |
| } |
|
|
| |
| export function safeHtml(html: string): DocumentFragment { |
| const tpl = document.createElement('template'); |
| tpl.innerHTML = html; |
| const walk = (parent: Element | DocumentFragment) => { |
| const children = Array.from(parent.childNodes); |
| for (const node of children) { |
| if (node.nodeType === Node.ELEMENT_NODE) { |
| const el = node as Element; |
| if (!SAFE_TAGS.has(el.tagName.toLowerCase())) { |
| |
| while (el.firstChild) parent.insertBefore(el.firstChild, el); |
| parent.removeChild(el); |
| continue; |
| } |
| |
| for (const attr of Array.from(el.attributes)) { |
| if (!SAFE_ATTRS.has(attr.name.toLowerCase())) { |
| el.removeAttribute(attr.name); |
| } |
| } |
| |
| if (el.hasAttribute('href')) { |
| const href = el.getAttribute('href') || ''; |
| if (!/^https?:\/\//i.test(href) && !href.startsWith('/') && !href.startsWith('#')) { |
| el.removeAttribute('href'); |
| } |
| } |
| |
| if (el.hasAttribute('style')) { |
| const style = el.getAttribute('style') || ''; |
| if (!SAFE_STYLE_RE.test(style.trim())) { |
| el.removeAttribute('style'); |
| } |
| } |
| if ( |
| el.tagName.toLowerCase() === 'a' && |
| el.getAttribute('target')?.toLowerCase() === '_blank' |
| ) { |
| el.setAttribute('rel', ensureNoopenerRel(el.getAttribute('rel'))); |
| } |
| walk(el); |
| } |
| } |
| }; |
| walk(tpl.content); |
| return tpl.content; |
| } |
|
|
| function applyProps(el: HTMLElement, props: DomProps): void { |
| for (const key in props) { |
| const value = props[key]; |
| if (value == null || value === false) continue; |
|
|
| if (key === 'className') { |
| el.className = value as string; |
| } else if (key === 'style') { |
| if (typeof value === 'string') { |
| el.style.cssText = value; |
| } else if (typeof value === 'object') { |
| Object.assign(el.style, value); |
| } |
| } else if (key === 'dataset') { |
| const ds = value as Record<string, string>; |
| for (const k in ds) { |
| el.dataset[k] = ds[k]!; |
| } |
| } else if (key.startsWith('on') && typeof value === 'function') { |
| el.addEventListener( |
| key.slice(2).toLowerCase(), |
| value as EventListener, |
| ); |
| } else if (value === true) { |
| el.setAttribute(key, ''); |
| } else { |
| el.setAttribute(key, String(value)); |
| } |
| } |
| } |
|
|
| function appendChildren( |
| parent: Element | DocumentFragment, |
| children: DomChild[], |
| ): void { |
| for (const child of children) { |
| if (child == null || child === false) continue; |
| if (child instanceof Node) { |
| parent.appendChild(child); |
| } else { |
| parent.appendChild(document.createTextNode(String(child))); |
| } |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| export function getFocusableElements(root: ParentNode): HTMLElement[] { |
| return Array.from( |
| root.querySelectorAll<HTMLElement>( |
| 'button:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])', |
| ), |
| ).filter( |
| (el) => |
| !el.hasAttribute('disabled') && |
| el.getAttribute('aria-hidden') !== 'true' && |
| el.offsetParent !== null, |
| ); |
| } |
|
|