a minimal web framework for deno
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 = "&"; break;
79 case EscapeLut.LESS_THAN: esc = "<"; break;
80 case EscapeLut.GREATER_THAN: esc = ">"; break;
81 case EscapeLut.DOUBLE_QUOTE: esc = """; break;
82 case EscapeLut.SINGLE_QUOTE: esc = "'"; 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 };