websnacks/src/render.ts
M. George Hansen 2bf1125b83
feat: add dangerouslySetInnerHTML attr
This adds are new attribute to Elements, "dangerouslySetInnerHTML", which like the same attribute from React allows one to take a stirng of unescaped HTML and render it unconditionally. This is of course a potentially dangerous operation that can open your app up to XSS attacks, but for interoperating with existing content management systems and libraries that output HTML (e.g. markdown renderers).

Using "dangerouslySetInnerHTML" on an element with children will generate an error within createElement, since it doesn't make sense to have both children and inner HTML.
2021-01-02 21:24:17 -08:00

119 lines
3.4 KiB
TypeScript

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { Element, HTMLElement } from "./component";
const HTML_ESCAPES: { [char: string]: string } = {
"&": "&",
"<": "&lt;",
">": "&gt;",
};
const escapeHtml = (text: string): string =>
text.replace(/[&<>]/g, (t) => HTML_ESCAPES[t]);
const escapeAttr = (text: string): string => text.replace(/"/g, "&quot;");
const renderElement = (elem: Element): string => {
// Ignore null and true/false to support nicer JSX conditional syntax with
// &&, ||, !! operators.
if (elem == null || typeof elem === "boolean") {
return "";
}
if (typeof elem === "number") {
return elem.toString();
}
if (typeof elem === "string") {
return escapeHtml(elem);
}
if (Array.isArray(elem)) {
return elem.map((e) => renderElement(e)).join("");
}
let output = "";
output += startTag(elem);
if (elem.attributes.dangerouslySetInnerHTML != null) {
output += elem.attributes.dangerouslySetInnerHTML;
} else {
for (const child of elem.children) {
output += renderElement(child);
}
}
output += endTag(elem);
return output;
};
const startTag = (elem: HTMLElement): string => {
if (elem.tag === "#fragment") {
return "";
}
let output = `<${escapeHtml(elem.tag)}`;
for (const [attrName, attrValue] of Object.entries(elem.attributes)) {
// Handle boolean attributes with a false value by not outputting the
// attribute at all.
if (attrValue === false) {
continue;
}
// Ignore the special attr for setting raw inner HTML.
if (attrName === "dangerouslySetInnerHTML") {
continue;
}
let normalizedAttrName = escapeHtml(attrName.toLowerCase());
if (normalizedAttrName === "classname") {
normalizedAttrName = "class";
}
if (attrValue === true) {
output += ` ${normalizedAttrName}=""`;
} else {
output += ` ${normalizedAttrName}="${escapeAttr(
attrValue.toString(),
)}"`;
}
}
output += ">";
return output;
};
const endTag = (elem: HTMLElement): string => {
if (elem.tag === "#fragment") {
return "";
}
return `</${escapeHtml(elem.tag)}>`;
};
/**
* Render a complete HTML page from an HTMLElement. Note that the root element
* must represent a valid HTML tag.
*
* @param rootElem HTML element representing the root of the document.
*
* @return Fully rendered HTML document as a string.
*/
export const renderPage = (rootElem: Element): string => {
if (rootElem == undefined) {
throw new Error(`root page element cannot be null`);
}
if (typeof rootElem !== "object" || !("tag" in rootElem)) {
throw new Error(
`root page element must be a valid HTMLElement, got ${JSON.stringify(
rootElem,
)}`,
);
}
if (rootElem.tag.toLowerCase() !== "html") {
throw new Error(
`attempted to render page with non-HTML root element ${rootElem.tag}`,
);
}
let output = "<!DOCTYPE html>";
output += renderElement(rootElem);
return output;
};