UNPKG

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
"use strict"; 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, "&amp;") .replace(/</g, "&lt;") .replace(/>/g, "&gt;") .replace(/"/g, "&quot;") .replace(/'/g, "&#039;"); } 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;