Built and signed on GitHub ActionsBuilt and signed on GitHub Actions
Built and signed on GitHub Actions
latest
ngasull/classicThis package works with DenoIt is unknown whether this package works with Cloudflare Workers, Node.js, Bun
JSR Score
41%
Published
2 months ago (0.1.1)
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553import { client, type Fn, indexedUris, inline, isJSable, type JS, js, type JSable, jsResources, mkRef, type RefTree, type ServedJSContext, toJS, unsafe, } from "jsr:@classic/js@0"; import { contextSymbol, type DOMLiteral, type DOMNode, DOMNodeKind, ElementKind, type JSX, type JSXComponent, type JSXContext, type JSXContextAPI, type JSXContextInit, type JSXElement, type JSXRef, } from "./types.ts"; import { voidElements } from "./void.ts"; const camelRegExp = /[A-Z]/g; const hyphenize = (camel: string) => camel.replace( camelRegExp, (l: string) => "-" + l.toLowerCase(), ); const eventPropRegExp = /^on([A-Z]\w+)$/; // Only escape when necessary ; avoids inline JS like "a && b" to become "a && b" const escapesRegex = /&(#\d{2,4}|[A-z][A-z\d]+);/g; const escapeEscapes = (value: string) => value.replaceAll(escapesRegex, (_, code) => `&${code};`); const escapeTag = (tag: string) => tag.replaceAll(/[<>"'&]/g, ""); const zeroWidthSpaceHTML = "​"; const escapeTextNode = (text: string) => escapeEscapes(text).replaceAll("<", "<") || zeroWidthSpaceHTML; // Empty would not be parsed as a text node const commentEscapeRegExp = /--(#|>)/g; const escapeComment = (comment: string) => comment.replaceAll(commentEscapeRegExp, "--#$1"); export const escapeScriptContent = (node: DOMLiteral) => String(node).replaceAll("</script", "</scr\\ipt"); const encoder = new TextEncoder(); export const render = ( root: JSXElement | PromiseLike<JSXElement>, opts: { context?: JSXContextInit<unknown>[] | JSXContextAPI; doctype?: boolean; } = {}, ): ReadableStream<Uint8Array> => new ReadableStream<Uint8Array>({ start(controller) { const { context } = opts; const ctx = initContext(context); if (!ctx.get($effects)) ctx.provide($effects, []); const effects = ctx($effects); const served = ctx.get($served); const write = (chunk: Uint8Array) => controller.enqueue(chunk); const tree = domNodes(root, ctx); writeDOMTree(tree, { served, effects, write, ...opts }, true) .finally(() => { controller.close(); }); }, }); export const createContext = <T>(name?: string): JSXContext<T> => { const $ = Symbol(name); const init = (value: T) => [$, value] as const; return { init, [contextSymbol]: $ }; }; type JSXContextInternal = JSXContextAPI & { [$contextData]: Map<symbol, unknown>; }; export const initContext = ( init?: JSXContextInit<unknown>[] | JSXContextAPI, ): JSXContextAPI => { if (init && !Array.isArray(init)) { const entries: JSXContextInit<unknown>[] = [ ...(init as JSXContextInternal)[$contextData].entries(), ]; return initContext(entries); } const use = <T, Args extends any[]>( context: JSXContext<T> | ((use: JSXContextAPI, ...args: Args) => T), ...args: Args ) => { if (typeof context === "function") { return context(ctx, ...args); } else { if (!use[$contextData].has(context[contextSymbol])) { throw new Error( `Looking up unset context ${ context[contextSymbol].description ?? "" }`, ); } return use[$contextData].get(context[contextSymbol]) as T; } }; use[$contextData] = new Map<symbol, unknown>(init); const ctx = Object.assign(use, contextProto); return ctx; }; const $contextData = Symbol("data"); const contextProto = { get<T>(context: JSXContext<T>) { return this[$contextData].get(context[contextSymbol]) as T; }, provide<T>(context: JSXContext<T>, value: T) { this[$contextData].set(context[contextSymbol], value); return this; }, } satisfies ThisType<JSXContextInternal>; export const $effects = createContext<JSable<void>[]>("effect"); export const $served = createContext<ServedJSContext>("served"); export const $client = <T>(name: string) => (use: JSXContextAPI): JS<T> => { const module = use($served).getModule(name); if (!module) { throw Error( `${name} needs to be added to your client modules configuration`, ); } return module as JS<T>; }; const activate = async ( refs: RefTree, opts: { served?: ServedJSContext; effects: JSable<void>[]; write: (chunk: Uint8Array) => void; }, ): Promise<DOMNode | void> => { const { effects, served } = opts; if (effects.length) { if (!served) { effects.splice(0, effects.length); return console.error( `Can't attach JS to refs: no served JS context is provided`, ); } const [activationScript] = await toJS( () => effects, { served, refs: refs.length ? ["$", refs] : true }, ); effects.splice(0, effects.length); await writeDOMTree([{ kind: DOMNodeKind.Tag, tag: "script", attributes: new Map(), children: [{ kind: DOMNodeKind.Text, text: refs.length ? `{const $=document.currentScript;setTimeout(async()=>{${activationScript}})}` : `(async()=>{${activationScript}})()`, ref: mkRef(), }], ref: mkRef(), }], opts); } }; const writeDOMTree = async ( tree: Iterable<DOMNode> | AsyncIterable<DOMNode>, opts: { doctype?: boolean; served?: ServedJSContext; effects: JSable<void>[]; write: (chunk: Uint8Array) => void; }, root?: boolean, ): Promise<RefTree> => { const { doctype, write } = opts; const writeStr = (chunk: string) => write(encoder.encode(chunk)); const refs: RefTree = []; for await (const node of tree) { let childRefs: RefTree | null = null; switch (node.kind) { case DOMNodeKind.Comment: { if (node.text) { writeStr(`<!--`); writeStr(escapeComment(node.text)); writeStr(`-->`); } else { writeStr(`<!>`); } break; } case DOMNodeKind.Tag: { if ( root && ( doctype === true || (doctype == null && node.tag === "html") ) ) { writeStr("<!DOCTYPE html>"); } writeStr("<"); writeStr(escapeTag(node.tag)); for (const [name, value] of node.attributes) { if (value === false) continue; const valueStr = value === true ? "" : String(value); const escapedValue = escapeEscapes(valueStr).replaceAll("'", "'"); writeStr(" "); writeStr(escapeTag(name)); if (escapedValue) { writeStr("="); if (/[\s>"]/.test(escapedValue)) { writeStr("'"); writeStr(escapedValue); writeStr("'"); } else { writeStr(escapedValue); } } } writeStr(">"); if (!voidElements.has(node.tag)) { if (node.tag === "script") { for await (const c of node.children) { if (c.kind === DOMNodeKind.Text) { writeStr(escapeScriptContent(c.text)); } else { console.warn(`<script> received non-text child: ${c}`); } } } else { // Write any global initializing effect that may use document.body if (node.tag === "body") await activate([], opts); childRefs = await writeDOMTree(node.children, opts); if (node.tag === "body") await activate(childRefs, opts); } writeStr("</"); writeStr(node.tag); writeStr(">"); } break; } case DOMNodeKind.Text: { writeStr(escapeTextNode(node.text)); break; } case DOMNodeKind.HTMLNode: { const reader = node.html.getReader(); while (true) { const res = await reader.read(); if (res.done) break; write(res.value); } break; } } refs.push(childRefs?.length ? [node.ref, childRefs] : [node.ref]); } if (root) await activate(refs, opts); return refs; }; const domNodes = async function* ( nodeLike: JSX.Element, ctx: JSXContextAPI, ): AsyncIterable<DOMNode> { const node = nodeLike && "then" in nodeLike ? await nodeLike : nodeLike; if (!node) return; const effects = ctx($effects); switch (node.kind) { case ElementKind.Fragment: { for (const e of node.children) { yield* domNodes(e, ctx); } return; } case ElementKind.Component: { const { Component, props } = node; const subCtx = initContext(ctx); yield* domNodes(Component(props, subCtx), subCtx); return; } case ElementKind.Comment: { return yield { kind: DOMNodeKind.Comment, text: node.text, ref: node.ref, }; } case ElementKind.Intrinsic: { const { tag, props: { ref, ...props } } = node; const attributes = new Map<string, string | number | boolean>(); const reactiveAttributes: [ string, JSable<string | number | boolean | null>, ][] = []; if (ref) { const refHook = (ref as unknown as JSXRef<Element>)(node.ref); if (refHook !== node.ref && isJSable<void>(refHook)) { effects.unshift(refHook); } } const propEntries = Object.entries(props); let entry; while ((entry = propEntries.shift())) { const [prop, value] = entry; const name = hyphenize(prop); await (async function recordAttr( name: string, value: | string | number | boolean | null | undefined | JSable<string | number | boolean | null>, ) { if (value != null) { const eventMatch = prop.match(eventPropRegExp); if (eventMatch) { effects.push( onEvent(node.ref, eventMatch[1].toLowerCase(), value), ); } else if (isJSable<string | number | boolean | null>(value)) { await recordAttr(name, await js.eval(value)); reactiveAttributes.push([name, value]); } else { attributes.set(name, value); } } })(name, value); } for (const [name, expr] of reactiveAttributes) { const uris = jsResources(expr); if (uris.length) { effects.push(subAttribute(uris, node.ref, name, () => inline(expr))); } } return yield { kind: DOMNodeKind.Tag, tag, attributes, children: disambiguateText(node.children, ctx), ref: node.ref, }; } case ElementKind.JS: { const uris = jsResources(node.js); if (uris.length) { effects.push( js.fn(() => subText( node.ref, () => inline(node.js), // js.comma(js.reassign(node.element, node.element), node.element), indexedUris(uris), ) )(), ); } return yield { kind: DOMNodeKind.Text, text: String(await js.eval(node.js) ?? ""), ref: node.ref, }; } case ElementKind.Text: { return yield { kind: DOMNodeKind.Text, text: String(node.text), ref: node.ref, }; } case ElementKind.HTMLNode: { return yield { kind: DOMNodeKind.HTMLNode, html: node.html, ref: node.ref, }; } } throw Error(`Can't handle JSX node ${JSON.stringify(node)}`); }; async function* disambiguateText( children: readonly JSXElement[], ctx: JSXContextAPI, ): AsyncIterable<DOMNode> { let prev: DOMNode | null = null; for (const child of children) { for await (const c of domNodes(child, ctx)) { if ( prev && c.kind === DOMNodeKind.Text && prev.kind === DOMNodeKind.Text ) { yield { kind: DOMNodeKind.Comment, text: "", ref: mkRef(), }; } yield c; prev = c; } } } const onEvent = js.fn(( target: JS<EventTarget>, type: JS<string>, cb: JS<(e: Event) => void>, ) => [ js`let c=${cb}`, target.addEventListener(type, unsafe("c")), js`return ()=>${target.removeEventListener(type, unsafe("c"))}`, ]); const subAttribute = js.fn(( uris: JS<string[]>, target: JS<Element>, k: JS<string>, expr: JS<() => unknown>, ): JS<void> => client.sub( target, () => { const v = expr(); return js`!${v}&&${v}!==""?${target.removeAttribute(k)}:${ target.setAttribute(k, js`${v}===true?"":String(${v})`) }`; }, indexedUris(uris), ) ); const subText = js.fn(( node: JS<Text>, value: JS<() => DOMLiteral>, uris: JS<readonly string[]>, ) => js< () => void >`${client.sub}(${node},_=>${node}.textContent=${value}(),${uris})` ); export const Effect: JSXComponent<{ js: Fn<[], void | (() => void)>; uris?: | readonly string[] | JSable<readonly string[]> | readonly JSable<string>[]; }> = ({ js: cb, uris }, use) => { const ref = mkRef<Comment>(); use($effects).push( js.fn(() => { const effectJs = js.fn(cb); return client.sub( ref, effectJs, uris ? indexedUris(uris) : [], ); })(), ); return { kind: ElementKind.Comment, text: "", ref, }; }; export const Html: JSXComponent<{ contents: string | Uint8Array | ReadableStream<Uint8Array>; }> = ({ contents }) => ({ kind: ElementKind.HTMLNode, html: contents instanceof ReadableStream ? contents : new ReadableStream<Uint8Array>({ start(controller) { controller.enqueue( typeof contents === "string" ? encoder.encode(contents) : contents, ); controller.close(); }, }), ref: mkRef(), });