a minimal web framework for deno
at main 333 lines 8.3 kB view raw
1/** 2 * @module jsx-runtime 3 * Minimal async-aware, JSX `precompile` renderer. 4 */ 5 6/** 7 * Copyright (c) 2025 adoravel 8 * SPDX-License-Identifier: LGPL-3.0-or-later 9 */ 10 11// deno-fmt-ignore 12export const voidTags: ReadonlySet<string> = new Set([ 13 "area", "base", "br", "col", "embed", "hr", "img", "input", 14 "link", "meta", "param", "source", "track", "wbr", 15]); 16 17// deno-fmt-ignore 18const ESC_RE = /[&<>"']/; 19 20const enum EscapeLut { 21 AMP = 38, 22 LESS_THAN = 60, 23 GREATER_THAN = 62, 24 DOUBLE_QUOTE = 34, 25 SINGLE_QUOTE = 39, 26} 27 28interface Html { 29 __html?: string; 30} 31 32export const Fragment = Symbol("jsx.fragment"); 33const jsxBrand = Symbol.for("jsx.element"); 34 35export interface JsxElement { 36 readonly [jsxBrand]: true; 37 readonly tag: string | Component | typeof Fragment; 38 readonly props: Props; 39} 40 41export type Component<P extends Props = Props> = (props: P) => JsxNode; 42 43export type JsxNode = string | number | boolean | null | undefined | JsxElement | JsxNode[] | Promise<JsxNode>; 44 45type Props = { 46 children?: JsxElement | JsxElement[]; 47 dangerouslySetInnerHTML?: { __html: string }; 48 [key: string]: unknown; 49}; 50 51const prototype: Pick<JsxElement, typeof jsxBrand> = Object.create(null, { 52 [jsxBrand]: { value: true, enumerable: false, writable: false }, 53 toString: { 54 value: function () { 55 return renderTrusted(this); 56 }, 57 }, 58}); 59 60function isJsxElement(value: unknown): value is JsxElement { 61 return typeof value === "object" && value != null && jsxBrand in value; 62} 63 64function ignore(value: unknown): value is undefined | null | false { 65 return value == null || value === undefined || value === false; 66} 67 68function encode(str: string): string { 69 if (!str.length || !ESC_RE.test(str)) return str; 70 71 let out = "", last = 0; 72 73 for (let i = 0; i < str.length; i++) { 74 let esc: string; 75 76 // deno-fmt-ignore 77 switch (str.charCodeAt(i)) { 78 case EscapeLut.AMP: esc = "&amp;"; break; 79 case EscapeLut.LESS_THAN: esc = "&lt;"; break; 80 case EscapeLut.GREATER_THAN: esc = "&gt;"; break; 81 case EscapeLut.DOUBLE_QUOTE: esc = "&quot;"; break; 82 case EscapeLut.SINGLE_QUOTE: esc = "&#39;"; break; 83 default: continue; 84 } 85 86 if (i !== last) out += str.slice(last, i); 87 out += esc, last = i + 1; 88 } 89 90 return last === 0 ? str : out + str.slice(last); 91} 92 93export function jsxEscape(value: unknown): string | Promise<string> { 94 if (ignore(value)) return ""; 95 if (Array.isArray(value)) return renderTrustedArray(value); 96 97 switch (typeof value) { 98 case "string": 99 return encode(value); 100 case "object": 101 if ("__html" in value) { 102 return (value as Html).__html ?? ""; 103 } 104 if (isJsxElement(value)) { 105 return renderJsx(value); 106 } 107 break; 108 case "number": 109 case "boolean": 110 return value.toString(); 111 } 112 113 return value as string; 114} 115 116export function jsxAttr(k: string, v: unknown): string { 117 if (v == null || v === false) return ""; 118 if (v === true) return ` ${k}`; 119 120 if (k === "style" && typeof v === "object" && !Array.isArray(v)) { 121 const css = renderStyle(v as Record<string, string | number>); 122 return css ? ` style="${css}"` : ""; 123 } 124 125 if (k.startsWith("on")) return ""; 126 return ` ${k}="${encode(String(v))}"`; 127} 128 129function renderStyle(style: Record<string, string | number>): string { 130 let css = ""; 131 132 for (const key in style) { 133 const val = style[key]; 134 if (val == null) continue; 135 const prop = key.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`); 136 css += `${prop}:${encode(String(val))};`; 137 } 138 return css; 139} 140 141function renderTrusted(node: unknown): string | Promise<string> { 142 if (node == null || node === false || node === true) return ""; 143 if (typeof node === "string") return node; 144 if (typeof node === "number") return String(node); 145 146 if (node instanceof Promise) { 147 return node.then((v) => renderTrusted(v)); 148 } 149 if (Array.isArray(node)) { 150 return renderTrustedArray(node); 151 } 152 if (isJsxElement(node)) { 153 return renderJsx(node); 154 } 155 if (typeof node === "object" && "__html" in node) { 156 return (node as Html).__html!; 157 } 158 159 return String(node); 160} 161 162function renderTrustedArray(nodes: unknown[]): string | Promise<string> { 163 let html = ""; 164 165 for (let i = 0; i < nodes.length; i++) { 166 const r = renderTrusted(nodes[i]); 167 168 if (r instanceof Promise) { 169 return continueArrayAsync(html, nodes, i, r); 170 } 171 html += r; 172 } 173 174 return html; 175} 176 177async function continueArrayAsync( 178 html: string, 179 node: unknown[], 180 index: number, 181 firstPromise: Promise<string>, 182): Promise<string> { 183 html += await firstPromise; 184 for (let i = index + 1; i < node.length; i++) { 185 html += await renderTrusted(node[i]); 186 } 187 return html; 188} 189 190export function jsxTemplate( 191 template: string[], 192 ...values: unknown[] 193): string | Promise<string> { 194 let html = template[0]; 195 196 for (let i = 0; i < values.length; i++) { 197 const r = renderTrusted(values[i]); 198 199 if (r instanceof Promise) { 200 return continueAsync(html, template, values, i, r); 201 } 202 203 html += r + template[i + 1]; 204 } 205 206 return html; 207} 208 209async function continueAsync( 210 html: string, 211 template: string[], 212 values: unknown[], 213 index: number, 214 pending: Promise<string>, 215): Promise<string> { 216 html += await pending; 217 html += template[index + 1]; 218 219 for (let i = index + 1; i < values.length; i++) { 220 html += await renderTrusted(values[i] as JsxNode); 221 html += template[i + 1]; 222 } 223 224 return html; 225} 226 227/** 228 * jsx factory function 229 * @template P compoonent properties parameter 230 * @param tag the element tag type (e.g., `div`, `span`, or a Component function) 231 * @param props the attributes and children of the element 232 * @returns either the rendered html string or a `Promise` resolving to one 233 */ 234export function jsx<P extends Props = Props>(tag: JsxElement["tag"], props: P | null = {} as P): JsxElement { 235 const el = Object.create(prototype); 236 el.tag = tag; 237 el.props = props ?? {}; 238 return el; 239} 240 241function renderJsx(element: JsxElement): string | Promise<string> { 242 const { tag, props } = element; 243 const { children, dangerouslySetInnerHTML, ...attrs } = props; 244 245 if (tag === Fragment) return renderTrusted(children); 246 if (typeof tag === "function") { 247 const result = tag(props as any); 248 249 if (result instanceof Promise) { 250 return result.then((r) => ignore(r) ? "" : typeof r === "string" ? r : renderJsx(r as JsxElement)); 251 } 252 return ignore(result) ? "" : typeof result === "string" ? result : renderJsx(result as JsxElement); 253 } 254 255 if (typeof tag !== "string") { 256 throw new TypeError("invalid jsx tag"); 257 } 258 259 let html = `<${tag}`; 260 for (const name in attrs) { 261 html += jsxAttr(name, attrs[name]); 262 } 263 html += ">"; 264 265 if (voidTags.has(tag)) return html; 266 267 if (dangerouslySetInnerHTML != null && children != null) { 268 throw new Error("cannot use both children and dangerouslySetInnerHTML"); 269 } 270 if (dangerouslySetInnerHTML != null) { 271 return html + dangerouslySetInnerHTML.__html + `</${tag}>`; 272 } 273 274 const inner = renderTrusted(children); 275 if (inner instanceof Promise) { 276 return inner.then((c) => html + c + `</${tag}>`); 277 } 278 return html + inner + `</${tag}>`; 279} 280 281type CSSProperties = 282 & { 283 [K in keyof CSSStyleDeclaration]?: CSSStyleDeclaration[K] extends string ? string | number 284 : never; 285 } 286 & { 287 [key: `--${string}`]: string | number; 288 [key: `-webkit-${string}`]: string | number; 289 [key: `-moz-${string}`]: string | number; 290 [key: `-ms-${string}`]: string | number; 291 }; 292 293type HTMLAttributeMap<T = HTMLElement> = Partial< 294 Omit<T, keyof Element | "children" | "style" | "href"> & { 295 style?: string | CSSProperties; 296 class?: string; 297 dangerouslySetInnerHTML?: Html; 298 children?: any; 299 key?: string; 300 charset?: string; 301 href: string | SVGAnimatedString; 302 [key: `data-${string}`]: string | number | boolean | null | undefined; 303 [key: `aria-${string}`]: string | number | boolean | null | undefined; 304 [key: `on${string}`]: string | ((e: Event) => void); 305 } 306>; 307 308export declare namespace JSX { 309 /** defines valid JSX elements */ 310 export type ElementType = 311 | keyof IntrinsicElements 312 | Component<any>; 313 314 export interface ElementChildrenAttribute { 315 // deno-lint-ignore ban-types 316 children: {}; 317 } 318 319 /** type definitions for intrinsic HTML and SVG elements */ 320 export type IntrinsicElements = 321 & { 322 [K in keyof HTMLElementTagNameMap]: HTMLAttributeMap< 323 HTMLElementTagNameMap[K] 324 >; 325 } 326 & { 327 [K in keyof SVGElementTagNameMap]: HTMLAttributeMap< 328 SVGElementTagNameMap[K] 329 >; 330 }; 331} 332 333export { jsx as jsxDEV, jsx as jsxs };