/**
* The forge JSX runtime - compiles JSX straight to escaped HTML strings, with no
* framework underneath.
*
* TypeScript's automatic JSX transform (`"jsx": "react-jsx"` with `"jsxImportSource"`
* pointing at this package) rewrites `
{y}
` into calls to the
* `jsx`/`jsxs` functions in this module, so this file's name and export names are a
* compiler contract, not a style choice.
*
* @experimental Part of the experimental JSX component API - may change or be removed
* in a minor release.
*/
import type { IntrinsicElementAttributes } from './types/intrinsicElements.type'
/**
* Elements with no closing tag, per the HTML spec - rendered as `` with
* children ignored.
*/
const VOID_ELEMENTS = new Set([
'area',
'base',
'br',
'col',
'embed',
'hr',
'img',
'input',
'link',
'meta',
'source',
'track',
'wbr',
])
const HTML_ENTITY_MAP: Record = {
'<': '<',
'>': '>',
'&': '&',
'"': '"',
"'": ''',
}
const escapeHtmlEntities = (value: string): string => value.replace(/[<>&"']/g, char => HTML_ENTITY_MAP[char])
/**
* Identifies `RawHtml` across bundle copies of this module - `Symbol.for` keys into the
* global symbol registry, so the check holds even when two entrypoints each bundle
* their own copy of the class.
*/
const RAW_HTML_BRAND = Symbol.for('forge.jsx.rawHtml')
/**
* A string of HTML that is already safe to embed - the serializer includes it verbatim
* instead of escaping it. Every JSX expression evaluates to one of these.
*
* @experimental Part of the experimental JSX component API - may change or be removed
* in a minor release.
*/
export class RawHtml {
readonly [RAW_HTML_BRAND] = true
constructor(readonly html: string) {}
toString(): string {
return this.html
}
}
const isRawHtml = (value: unknown): value is RawHtml =>
typeof value === 'object' && value !== null && RAW_HTML_BRAND in value
/**
* Marks trusted markup as safe to embed without escaping - the HTML of an
* already-rendered child block, for instance. Everything not wrapped in `raw()`
* is escaped.
*
* @experimental Part of the experimental JSX component API - may change or be removed
* in a minor release.
*
* @example
* ```tsx
* {raw(renderedChildBlock.html)}
* ```
*/
export const raw = (html: string): RawHtml => new RawHtml(html)
/**
* Anything a JSX expression can nest inside an element. Strings and numbers are
* escaped, `RawHtml` is embedded verbatim, and `null`/`undefined`/booleans render
* as nothing (so `{condition && ...
}` works).
*/
export type JsxChild = string | number | boolean | null | undefined | RawHtml | JsxChild[]
/**
* The props a JSX element receives: attributes plus the nested children. The
* automatic transform passes children inside props rather than as extra arguments.
*/
export interface JsxProps {
children?: JsxChild
[attribute: string]: unknown
}
type FunctionComponent = (props: JsxProps) => RawHtml
const serializeChildren = (child: JsxChild): string => {
if (child === null || child === undefined || typeof child === 'boolean') {
return ''
}
if (Array.isArray(child)) {
return child.map(serializeChildren).join('')
}
if (isRawHtml(child)) {
return child.html
}
return escapeHtmlEntities(String(child))
}
const serializeAttributes = (props: JsxProps): string =>
Object.entries(props)
.filter(([name, value]) => name !== 'children' && value !== undefined && value !== null && value !== false)
.map(([name, value]) =>
value === true
? ` ${escapeHtmlEntities(name)}`
: ` ${escapeHtmlEntities(name)}="${escapeHtmlEntities(String(value))}"`,
)
.join('')
/**
* The automatic JSX transform's element factory - `{y}
` compiles
* to `jsx('div', { class: 'x', children: y })`. Not called directly from user code.
*
* The transform passes JSX `key` attributes as a third argument; keys are meaningless
* when rendering to a string, so the extra argument is ignored.
*
* @experimental Part of the experimental JSX component API - may change or be removed
* in a minor release.
*/
export const jsx = (tag: string | FunctionComponent, props: JsxProps): RawHtml => {
if (typeof tag === 'function') {
return tag(props)
}
const attributes = serializeAttributes(props)
if (VOID_ELEMENTS.has(tag)) {
return new RawHtml(`<${tag}${attributes}>`)
}
return new RawHtml(`<${tag}${attributes}>${serializeChildren(props.children)}${tag}>`)
}
/**
* The transform calls `jsxs` instead of `jsx` when an element has multiple static
* children - the distinction only matters to frameworks that key children, so both
* share one implementation here.
*
* @experimental Part of the experimental JSX component API - may change or be removed
* in a minor release.
*/
export const jsxs = jsx
/**
* Renders fragment children (`<>...>`) with no wrapping element.
*
* @experimental Part of the experimental JSX component API - may change or be removed
* in a minor release.
*/
export const Fragment = (props: JsxProps): RawHtml => new RawHtml(serializeChildren(props.children))
/**
* Development-mode entry point - dev transforms (Vite, esbuild with jsxDev,
* TypeScript's "react-jsxdev") import `/jsx-dev-runtime` and call
* `jsxDEV` instead of `jsx`/`jsxs`. Its extra arguments (key, static-children flag,
* source location) only matter to frameworks that diff and re-render, so it is the
* production implementation under the dev name - the package's `jsx-dev-runtime`
* subpath resolves to this same module.
*
* @experimental Part of the experimental JSX component API - may change or be removed
* in a minor release.
*/
export const jsxDEV = jsx
/**
* The types TypeScript reads from the `jsxImportSource` module to type-check JSX
* expressions: what an expression evaluates to, which tags exist with which
* attributes, and which prop carries nested children.
*
* The namespace, its name and its member names are all part of the compiler's JSX
* contract, hence the lint exemptions.
*/
// eslint-disable-next-line @typescript-eslint/no-namespace
export declare namespace JSX {
type Element = RawHtml
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface IntrinsicElements extends IntrinsicElementAttributes {}
interface ElementChildrenAttribute {
children: unknown
}
}