Files
WRNexusJS/packages/core/src/jsx-runtime.ts
T
2026-07-12 15:55:18 +05:30

176 lines
4.6 KiB
TypeScript

import { escapeHtml } from "./security.ts";
export type Renderable = Html | string | number | boolean | null | undefined | Renderable[];
export type Props = Record<string, unknown> & {
children?: Renderable;
dangerouslySetInnerHTML?: { __html?: unknown };
};
export type Component<P extends Props = Props> = (props: P) => Renderable;
export type ElementType = string | Component | typeof Fragment;
export class Html {
constructor(public readonly html: string) {}
toString(): string {
return this.html;
}
}
export const Fragment = Symbol.for("wrnexus.fragment");
const VOID_ELEMENTS = new Set([
"area",
"base",
"br",
"col",
"embed",
"hr",
"img",
"input",
"link",
"meta",
"param",
"source",
"track",
"wbr",
]);
const SAFE_TAG_NAME = /^[A-Za-z][A-Za-z0-9._:-]*$/;
const SAFE_ATTR_NAME = /^[A-Za-z_:][A-Za-z0-9:._-]*$/;
function isHtml(value: unknown): value is Html {
return value instanceof Html;
}
function raw(value: string): Html {
return new Html(value);
}
export function mustache(expr: string): Html;
export function mustache(strings: TemplateStringsArray, ...values: unknown[]): Html;
export function mustache(input: string | TemplateStringsArray, ...values: unknown[]): Html {
const expr =
typeof input === "string"
? input
: input.reduce((out, part, index) => {
const value = index < values.length ? String(values[index]) : "";
return out + part + value;
}, "");
return raw(`{{${expr.trim()}}}`);
}
function renderChild(value: Renderable): string {
if (value === null || value === undefined || typeof value === "boolean") return "";
if (Array.isArray(value)) return value.map(renderChild).join("");
if (isHtml(value)) return value.html;
return escapeHtml(String(value));
}
function renderComponentResult(value: Renderable): string {
if (value === null || value === undefined || typeof value === "boolean") return "";
if (Array.isArray(value)) return value.map(renderComponentResult).join("");
if (isHtml(value)) return value.html;
// WrNexus page/component strings are HTML by convention.
if (typeof value === "string") return value;
return escapeHtml(String(value));
}
function attrName(name: string): string {
if (name === "className") return "class";
if (name === "htmlFor") return "for";
return name;
}
function styleToString(value: Record<string, unknown>): string {
return Object.entries(value)
.filter(([, v]) => v !== null && v !== undefined && v !== false)
.map(([k, v]) => `${k.replace(/[A-Z]/g, (ch) => `-${ch.toLowerCase()}`)}: ${String(v)}`)
.join("; ");
}
function renderAttrs(props: Props): string {
const attrs: string[] = [];
for (const [key, value] of Object.entries(props)) {
if (
key === "children" ||
key === "key" ||
key === "ref" ||
key === "dangerouslySetInnerHTML" ||
value === null ||
value === undefined ||
value === false
) {
continue;
}
if (typeof value === "function") continue;
const name = attrName(key);
if (!SAFE_ATTR_NAME.test(name)) continue;
if (value === true) {
attrs.push(name);
continue;
}
const rendered =
key === "style" && typeof value === "object" && !Array.isArray(value)
? styleToString(value as Record<string, unknown>)
: String(value);
attrs.push(`${name}="${escapeHtml(rendered)}"`);
}
return attrs.length ? ` ${attrs.join(" ")}` : "";
}
export function jsx(type: ElementType, props: Props | null): Html {
const safeProps = props ?? {};
if (type === Fragment) {
return raw(renderChild(safeProps.children));
}
if (typeof type === "function") {
return raw(renderComponentResult(type(safeProps)));
}
if (!SAFE_TAG_NAME.test(type)) throw new TypeError(`Invalid JSX tag name: ${type}`);
const attrs = renderAttrs(safeProps);
if (VOID_ELEMENTS.has(type)) {
return raw(`<${type}${attrs}>`);
}
const children =
safeProps.dangerouslySetInnerHTML && "__html" in safeProps.dangerouslySetInnerHTML
? String(safeProps.dangerouslySetInnerHTML.__html ?? "")
: renderChild(safeProps.children);
return raw(`<${type}${attrs}>${children}</${type}>`);
}
export const jsxs = jsx;
// TypeScript's automatic JSX runtime looks for this exported namespace.
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace JSX {
export type Element = Html;
export type ElementType = string | Component;
export interface ElementChildrenAttribute {
children: unknown;
}
export interface IntrinsicAttributes {
key?: string | number;
}
export interface IntrinsicElements {
[tagName: string]: Props;
}
}