element-crafter
Version:
A zero-dependency TypeScript library for creating HTML elements in both SSR and client-side environments
291 lines (290 loc) • 9.5 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.PageBuilder = exports.HtmlBuilder = void 0;
exports.createHtmlBuilder = createHtmlBuilder;
exports.createSSRBuilder = createSSRBuilder;
exports.createClientBuilder = createClientBuilder;
class HtmlBuilder {
constructor(config) {
this.config = {
escapeContent: true,
validateAttributes: true,
customVoidTags: new Set(),
...config,
};
}
get isSsr() {
return this.config.isSsr;
}
get voidTags() {
return new Set([
...HtmlBuilder.STANDARD_VOID_TAGS,
...this.config.customVoidTags,
]);
}
static escapeHtml(content) {
if (content == null)
return "";
return String(content)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
shouldEscapeContent(tagName, explicitEscape) {
if (explicitEscape !== undefined) {
return explicitEscape;
}
if (HtmlBuilder.NO_ESCAPE_TAGS.has(tagName.toLowerCase())) {
return false;
}
return this.config.escapeContent;
}
validateAttribute(name, value) {
if (!this.config.validateAttributes)
return;
if (typeof name !== "string" || name.length === 0) {
throw new TypeError(`Invalid attribute name: ${name}`);
}
const dangerousAttributes = ["srcdoc", "formaction"];
if (dangerousAttributes.includes(name.toLowerCase()) &&
typeof value === "string") {
if (value.includes("javascript:") || value.includes("data:text/html")) {
throw new DOMException(`Potentially unsafe value for attribute ${name}: ${value}`);
}
}
}
buildAttributeString(attributes) {
const parts = [];
for (const [name, value] of Object.entries(attributes)) {
if (typeof value === "function")
continue;
this.validateAttribute(name, value);
if (value === null || value === undefined || value === false) {
continue;
}
if (value === true || HtmlBuilder.BOOLEAN_ATTRIBUTES.has(name)) {
parts.push(name);
}
else {
const escapedValue = HtmlBuilder.escapeHtml(value);
parts.push(`${name}="${escapedValue}"`);
}
}
return parts.length > 0 ? ` ${parts.join(" ")}` : "";
}
flattenContent(content) {
const result = [];
for (const item of content) {
if (Array.isArray(item)) {
result.push(...this.flattenContent(item));
}
else {
result.push(item);
}
}
return result;
}
processContent(content, escapeContent) {
if (content == null)
return "";
if (typeof content === "object" &&
content !== null &&
content.__escaped === true) {
return content.value;
}
if (typeof content === "string") {
if (this.config.isSsr && escapeContent) {
return HtmlBuilder.escapeHtml(content);
}
return content;
}
if (typeof content === "number") {
return String(content);
}
if (this.config.isSsr) {
return String(content);
}
else {
if (content instanceof Node) {
return content.textContent || "";
}
return String(content);
}
}
createElement(tagName, attributes, options, ...children) {
if (this.config.isSsr) {
return this.createElementSSR(tagName, attributes, options, children);
}
else {
return this.createElementClient(tagName, attributes, options, children);
}
}
createElementSSR(tagName, attributes, options, children = []) {
const attrs = attributes || {};
const shouldEscape = this.shouldEscapeContent(tagName, options === null || options === void 0 ? void 0 : options.escapeContent);
const attributeString = this.buildAttributeString(attrs);
const isVoid = this.voidTags.has(tagName.toLowerCase());
if (isVoid) {
return `<${tagName}${attributeString}>`;
}
const flatChildren = this.flattenContent(children);
const childContent = flatChildren
.map((child) => this.processContent(child, shouldEscape))
.join("");
return `<${tagName}${attributeString}>${childContent}</${tagName}>`;
}
createElementClient(tagName, attributes, options, children = []) {
const element = document.createElement(tagName);
const attrs = attributes || {};
const shouldEscape = this.shouldEscapeContent(tagName, options === null || options === void 0 ? void 0 : options.escapeContent);
for (const [name, value] of Object.entries(attrs)) {
if (typeof value === "function") {
const eventName = name.toLowerCase().startsWith("on")
? name.slice(2)
: name;
element.addEventListener(eventName, value);
}
else if (value !== null && value !== undefined && value !== false) {
this.validateAttribute(name, value);
if (value === true || HtmlBuilder.BOOLEAN_ATTRIBUTES.has(name)) {
element.setAttribute(name, "");
}
else {
element.setAttribute(name, String(value));
}
}
}
const flatChildren = this.flattenContent(children);
for (const child of flatChildren) {
if (child instanceof Node) {
element.appendChild(child);
}
else if (child != null) {
const textContent = this.processContent(child, shouldEscape);
if (textContent) {
element.appendChild(document.createTextNode(textContent));
}
}
}
return element;
}
createText(text, escape = true) {
if (this.config.isSsr) {
if (escape) {
return { __escaped: true, value: HtmlBuilder.escapeHtml(text) };
}
else {
return text;
}
}
else {
return document.createTextNode(text);
}
}
createFragment(...children) {
if (this.config.isSsr) {
const flatChildren = this.flattenContent(children);
return flatChildren
.map((child) => this.processContent(child, this.config.escapeContent))
.join("");
}
else {
const fragment = document.createDocumentFragment();
const flatChildren = this.flattenContent(children);
for (const child of flatChildren) {
if (child instanceof Node) {
fragment.appendChild(child);
}
else if (child != null) {
const textContent = this.processContent(child, this.config.escapeContent);
if (textContent) {
fragment.appendChild(document.createTextNode(textContent));
}
}
}
return fragment;
}
}
}
exports.HtmlBuilder = HtmlBuilder;
HtmlBuilder.STANDARD_VOID_TAGS = new Set([
"area",
"base",
"br",
"col",
"embed",
"hr",
"img",
"input",
"link",
"meta",
"param",
"source",
"track",
"wbr",
]);
HtmlBuilder.BOOLEAN_ATTRIBUTES = new Set([
"autofocus",
"autoplay",
"checked",
"controls",
"defer",
"disabled",
"hidden",
"loop",
"multiple",
"muted",
"open",
"readonly",
"required",
"reversed",
"selected",
]);
HtmlBuilder.NO_ESCAPE_TAGS = new Set([
"script",
"style",
"pre",
"code",
"textarea",
"noscript",
"xmp",
]);
class PageBuilder {
static buildPage(options) {
const { title, head = "", body, scripts = "", lang = "en", charset = "utf-8", } = options;
return `<!DOCTYPE html>
<html lang="${HtmlBuilder.escapeHtml(lang)}">
<head>
<meta charset="${HtmlBuilder.escapeHtml(charset)}">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${HtmlBuilder.escapeHtml(title)}</title>
${head}
</head>
<body>
${body}
${scripts}
</body>
</html>`;
}
static buildFromTemplate(templateContent, replacements) {
let result = templateContent;
for (const [placeholder, replacement] of Object.entries(replacements)) {
const regex = new RegExp(placeholder.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g");
result = result.replace(regex, replacement);
}
return result;
}
}
exports.PageBuilder = PageBuilder;
function createHtmlBuilder(config) {
return new HtmlBuilder(config);
}
function createSSRBuilder(options) {
return new HtmlBuilder({ ...options, isSsr: true });
}
function createClientBuilder(options) {
return new HtmlBuilder({ ...options, isSsr: false });
}
exports.default = HtmlBuilder;