myst-to-html
Version:
Export from MyST mdast to HTML
157 lines (148 loc) • 4.57 kB
text/typescript
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];
}