UNPKG

myst-to-html

Version:
157 lines (148 loc) 4.57 kB
import { escapeHtml } from 'markdown-it/lib/common/utils.js'; const HTML_EMPTY_ELEMENTS = new Set([ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr', ]); type AttrTypes = string | string[] | number | boolean | undefined | null; type HTMLAttributes = { // String Arrays are joined by ' ' for attributes (like class names) [attr: string]: AttrTypes; }; // Same typing as prosemirror-model export interface HTMLOutputSpecArray { 0: string; 1?: HTMLOutputSpec | 0 | HTMLAttributes; 2?: HTMLOutputSpec | 0; 3?: HTMLOutputSpec | 0; 4?: HTMLOutputSpec | 0; 5?: HTMLOutputSpec | 0; 6?: HTMLOutputSpec | 0; 7?: HTMLOutputSpec | 0; 8?: HTMLOutputSpec | 0; 9?: HTMLOutputSpec | 0; } type HTMLOutputSpecArrayInternal = [ string, HTMLOutputSpec | 0 | HTMLAttributes, HTMLOutputSpec | 0, ]; export type HTMLOutputSpec = HTMLOutputSpecArray; const formatAttr = (key: string, value: AttrTypes): string | null => { let v: string; if (value == null) return null; if (Array.isArray(value)) { v = value.join(' '); } else if (typeof value === 'number') { v = String(value); } else if (typeof value === 'boolean') { if (!value) return null; v = ''; } else { v = value; } return `${key}="${escapeHtml(v)}"`; }; export function formatTag(tag: string, attributes: HTMLAttributes, inline: boolean): string { const { children, ...rest } = attributes; const join = inline ? '' : '\n'; const attrs = Object.entries(rest) .filter(([, value]) => value != null && value !== false) .map(([key, value]) => formatAttr(key, value)) .filter((value) => value != null) .join(' '); const html = `<${escapeHtml(tag)}${attrs ? ` ${attrs}` : ''}>`; if (children) return `${html}${join}${escapeHtml(String(children))}`; return html; } function toHTMLRecurse(template: HTMLOutputSpec, inline: boolean): [string, string | null] { // Convert to an internal type which is actually an array const T = template as HTMLOutputSpecArrayInternal; // Cannot have more than one hole in the template const atMostOneHole = T.flat(Infinity).filter((v) => v === 0).length <= 1; if (!atMostOneHole) throw new Error('There cannot be more than one hole in the template.'); // Grab the tag and attributes if they exist! const tag = T[0]; const hasAttrs = !Array.isArray(T?.[1]) && typeof T?.[1] === 'object'; const attrs = hasAttrs ? (T[1] as HTMLAttributes) : {}; // These are the tag arrays before and after the hole. const before: string[] = []; const after: string[] = []; before.push(formatTag(tag, attrs, inline)); let foundHole = false; T.slice(hasAttrs ? 2 : 1).forEach((value) => { const v = value as HTMLOutputSpec | 0; if (v === 0) { foundHole = true; return; } // Recurse, if a hole is found then split the return const [b, a] = toHTMLRecurse(v, inline); before.push(b); if (a) { foundHole = true; after.push(a); } }); const join = inline ? '' : '\n'; const closingTag = HTML_EMPTY_ELEMENTS.has(tag) ? '' : `</${tag}>`; if (!foundHole) { if (closingTag) before.push(closingTag); return [before.join(join), null]; } if (closingTag) after.push(closingTag); return [before.join(join), after.join(join)]; } /** * A helper function to create valid HTML with a "hole" (represented by zero) for content. * * The content is escaped and null/undefined attributes are not included. * * **A simple wrapper tag:** * ``` * const attr = 'hello'; * const html = toHTML(['tag', {attr}, 0]); * console.log(html); * > ['<tag attr="hello">', '</tag>'] * ``` * * **A nested wrapper tag:** * ``` * const html = toHTML([ * 'tag', {attr}, * ['img', {src}], * ['caption', 0], * ]); * console.log(html); * > ['<tag attr="x"><img src="src"><caption>', '</caption></tag>'] * ``` * * You can include `children` in the `attrs` and that adds inline content for a tag. * * You can also send in a list of strings for `attrs`, which are joined with a space (`' '`). * * Types are based on prosemirror-model. * * @param spec The spec for the dom model. * @param opts Options dict, `inline` creates HTML that is on a single line. */ export function toHTML( template: HTMLOutputSpec, opts = { inline: false }, ): [string, string | null] { const [before, after] = toHTMLRecurse(template, opts.inline); const join = opts.inline ? '' : '\n'; return [`${before}${join}`, after ? `${after}${join}` : null]; }