first commit
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user