myst-to-html
Version:
Export from MyST mdast to HTML
144 lines (143 loc) • 5.14 kB
JavaScript
import { unified } from 'unified';
import rehypeParse from 'rehype-parse';
import rehypeRemark from 'rehype-remark';
import { all } from 'hast-util-to-mdast';
import { visit } from 'unist-util-visit';
import { select, selectAll } from 'unist-util-select';
import { findAfter } from 'unist-util-find-after';
import { remove } from 'unist-util-remove';
import { liftChildren, normalizeLabel, AdmonitionKind, admonitionKindToTitle } from 'myst-common';
import { enumerateTargets, resolveReferences } from './state.js';
const defaultOptions = {
addAdmonitionHeaders: true,
addContainerCaptionNumbers: true,
disableHeadingEnumeration: false,
disableContainerEnumeration: false,
disableEquationEnumeration: false,
};
const defaultHtmlToMdastOptions = {
keepBreaks: true,
htmlHandlers: {
table(h, node) {
return h(node, 'table', all(h, node));
},
th(h, node) {
const result = h(node, 'tableCell', all(h, node));
result.header = true;
return result;
},
_brKeep(h, node) {
return h(node, '_break');
},
},
};
// Visit all admonitions and add headers if necessary
export function addAdmonitionHeaders(tree) {
visit(tree, 'admonition', (node) => {
if (!node.kind || node.kind === AdmonitionKind.admonition)
return;
node.children = [
{
type: 'admonitionTitle',
children: [{ type: 'text', value: admonitionKindToTitle(node.kind) }],
},
...(node.children ?? []),
];
});
}
// Visit all containers and add captions
export function addContainerCaptionNumbers(tree, state) {
selectAll('container', tree)
.filter((container) => container.enumerator !== false)
.forEach((container) => {
const enumerator = state.getTarget(container.identifier)?.node.enumerator;
const para = select('caption > paragraph', container);
if (enumerator && para) {
para.children = [
{ type: 'captionNumber', kind: container.kind, value: enumerator },
...(para?.children ?? []),
];
}
});
}
/**
* Propagate target identifier/value to subsequent node
*
* Note: While this propagation happens regardless of the
* subsequent node type, references are only resolved to
* the TargetKind nodes enumerated in state.ts. For example:
*
* (paragraph-target)=
* Just a normal paragraph
*
* will add identifier/label to paragraph node, but the node
* will still not be targetable.
*/
export function propagateTargets(tree) {
visit(tree, 'mystTarget', (node, index) => {
const nextNode = findAfter(tree, index);
const normalized = normalizeLabel(node.label);
if (nextNode && normalized) {
nextNode.identifier = normalized.identifier;
nextNode.label = normalized.label;
}
});
remove(tree, 'mystTarget');
}
/**
* Ensure caption content is nested in a paragraph.
*
* This function is idempotent!
*/
export function ensureCaptionIsParagraph(tree) {
visit(tree, 'caption', (node) => {
if (node.children && node.children[0].type !== 'paragraph') {
node.children = [{ type: 'paragraph', children: node.children }];
}
});
}
export function convertHtmlToMdast(tree, opts) {
const handlers = { ...defaultHtmlToMdastOptions.htmlHandlers, ...opts?.htmlHandlers };
const otherOptions = { ...defaultHtmlToMdastOptions, ...opts };
const htmlNodes = selectAll('html', tree);
htmlNodes.forEach((node) => {
const hast = unified()
.use(rehypeParse, { fragment: true })
.parse(node.value);
// hast-util-to-mdast removes breaks if they are the first/last children
// and nests standalone breaks in paragraphs.
// However, since HTML nodes may just be fragments in the middle of markdown text,
// there is an option to `keepBreaks` which will simply convert `<br />`
// tags to `break` nodes, without the special hast-util-to-mdast behavior.
if (otherOptions.keepBreaks) {
selectAll('[tagName=br]', hast).forEach((n) => {
n.tagName = '_brKeep';
});
}
const mdast = unified().use(rehypeRemark, { handlers }).runSync(hast);
node.type = 'htmlParsed';
node.children = mdast.children;
visit(node, (n) => delete n.position);
});
liftChildren(tree, 'htmlParsed');
selectAll('_break', tree).forEach((n) => {
n.type = 'break';
});
return tree;
}
export const transform = (state, o) => (tree) => {
const opts = {
...defaultOptions,
...o,
};
ensureCaptionIsParagraph(tree);
propagateTargets(tree);
enumerateTargets(state, tree, opts);
resolveReferences(state, tree);
liftChildren(tree, 'mystDirective');
liftChildren(tree, 'mystRole');
if (opts.addAdmonitionHeaders)
addAdmonitionHeaders(tree);
if (opts.addContainerCaptionNumbers)
addContainerCaptionNumbers(tree, state);
};