export type Prop = string | number | boolean; export type Attributes = Record; export type Html = string | NonText; export type NonText = { tag: string; attributes: Attributes; children: Html[]; }; export const Fragment = "Fragment"; function is_string(elem: Html): elem is string { return typeof elem == "string"; } export function fr(...children: Html[]): Html { return { tag: Fragment, attributes: {}, children }; } function div(...children: Html[]): Html { return { tag: "div", attributes: {}, children }; } function expand_fragments_in_list(children: Html[]): Html[] { let out = []; for (const child of children) { if (is_string(child)) { out.push(child); } else if (child.tag == Fragment) { out.push(...child.children.map(expand_fragments)); } else { out.push(child); } } return out; } // NOTE: if you pass a fragment to this, it won't be expanded (which makes sense if you think about it) function expand_fragments(elem: Html): Html { if (is_string(elem)) { return elem; } else { const children = expand_fragments_in_list(elem.children); return { ...elem, children }; } } const attr = (attrs: Attributes) => (elem: Html): Html => { if (is_string(elem)) { return elem; } else { const { tag, attributes, children } = elem; return { tag, attributes: { ...attributes, ...attrs }, children }; } }; const escape = (unsafe: string): string => { return unsafe .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); }; function render_attributes(attrs: Attributes): string { return ( (Object.keys(attrs).length > 0 ? " " : "") + Object.entries(attrs) .filter(([_attr, val]) => typeof val != "undefined") .map(([attr, val]) => `${attr}="${escape((val ?? "").toString())}"`) .join(" ") ); } function indent(str: string, amount = 4, char = " "): string { const ind = char.repeat(4); return str .split("\n") .map((l) => ind + l) .join("\n"); } function render_elem( { tag, attributes, children }: NonText, mini = false, ): string { if (children.length == 0) { return ( `<${tag}${render_attributes(attributes)}` + (mini ? "/>" : " />") ); } else { let inner = ""; let string_last = false; for (let i = 0; i < children.length; i++) { let child = children[i]; const rendered = render(child, mini ? "mini" : undefined); if (is_string(child)) { mini && string_last && (inner += " "); inner += rendered; string_last = true; } else { inner += rendered; string_last = false; } if (!mini && i + 1 < children.length) inner += "\n"; } return [ `<${tag}${render_attributes(attributes)}>`, mini ? inner : indent(inner), ``, ].join(mini ? "" : "\n"); } } // old: // joining with " " is inefficient, but necessary for correct string behavior // i've decided that joining with "" is worth the size savings // THIS MEANS MINIFICATION IS SEMANTICALLY DIFFERENT // JK I DID IT THE HARD WAY :] export function render(elem: Html, mini?: "mini"): string { if (is_string(elem)) { return escape(elem); } else if (elem.tag == Fragment) { // mimics react's behavior with fragments, ehhh nvm different aims return elem.children.map((elem) => render(elem, mini)).join("\n"); } else { const { tag, attributes } = elem; const expanded = expand_fragments(elem); if (is_string(expanded)) { throw "impossible"; } return render_elem( { tag, attributes, children: expanded.children }, mini == "mini", ); } }