myst-to-typst
Version:
Export from MyST mdast to Typst
565 lines (564 loc) • 20.8 kB
JavaScript
import { fileError, fileWarn, toText, getMetadataTags } from 'myst-common';
import { captionHandler, containerHandler } from './container.js';
import { getLatexImageWidth, hrefToLatexText, nodeOnlyHasTextChildren, stringToTypstMath, stringToTypstText, } from './utils.js';
import MATH_HANDLERS, { resolveRecursiveCommands } from './math.js';
import { select, selectAll } from 'unist-util-select';
import { tableCellHandler, tableHandler, tableRowHandler } from './table.js';
import { proofHandlers } from './proofs.js';
const admonition = `#let admonition(body, heading: none, color: blue) = {
let stroke = (left: 2pt + color.darken(20%))
let fill = color.lighten(80%)
let title
if heading != none {
title = block(width: 100%, inset: (x: 8pt, y: 4pt), fill: fill, below: 0pt, radius: (top-right: 2pt))[#text(11pt, weight: "bold")[#heading]]
}
block(width: 100%, stroke: stroke, [
#title
#block(fill: luma(240), width: 100%, inset: 8pt, radius: (bottom-right: 2pt))[#body]
])
}`;
const admonitionMacros = {
attention: '#let attentionBlock(body, heading: [Attention]) = admonition(body, heading: heading, color: yellow)',
caution: '#let cautionBlock(body, heading: [Caution]) = admonition(body, heading: heading, color: yellow)',
danger: '#let dangerBlock(body, heading: [Danger]) = admonition(body, heading: heading, color: red)',
error: '#let errorBlock(body, heading: [Error]) = admonition(body, heading: heading, color: red)',
hint: '#let hintBlock(body, heading: [Hint]) = admonition(body, heading: heading, color: green)',
important: '#let importantBlock(body, heading: [Important]) = admonition(body, heading: heading, color: blue)',
note: '#let noteBlock(body, heading: [Note]) = admonition(body, heading: heading, color: blue)',
seealso: '#let seealsoBlock(body, heading: [See Also]) = admonition(body, heading: heading, color: green)',
tip: '#let tipBlock(body, heading: [Tip]) = admonition(body, heading: heading, color: green)',
warning: '#let warningBlock(body, heading: [Warning]) = admonition(body, heading: heading, color: yellow)',
};
const tabSet = `
#let tabSet(body) = {
block(width: 100%, stroke: luma(240), [#body])
}`;
const tabItem = `
#let tabItem(body, heading: none) = {
let title
if heading != none {
title = block(width: 100%, inset: (x: 8pt, y: 4pt), fill: luma(250))[#text(9pt, weight: "bold")[#heading]]
}
block(width: 100%, [
#title
#block(width: 100%, inset: (x: 8pt, bottom: 8pt))[#body]
])
}`;
const INDENT = ' ';
const linkHandler = (node, state) => {
const href = node.url;
state.write('#link("');
state.write(hrefToLatexText(href));
state.write('")');
if (node.children.length && node.children[0].value !== href) {
state.write('[');
state.renderChildren(node);
state.write(']');
}
};
function prevCharacterIsText(parent, node) {
var _a, _b;
const ind = (_a = parent === null || parent === void 0 ? void 0 : parent.children) === null || _a === void 0 ? void 0 : _a.findIndex((n) => n === node);
if (!ind)
return false;
const prev = (_b = parent === null || parent === void 0 ? void 0 : parent.children) === null || _b === void 0 ? void 0 : _b[ind - 1];
if (!(prev === null || prev === void 0 ? void 0 : prev.value))
return false;
return ((prev === null || prev === void 0 ? void 0 : prev.type) === 'text' && !!prev.value.match(/[a-zA-Z0-9\-_]$/)) || false;
}
function nextCharacterIsText(parent, node) {
var _a, _b;
const ind = (_a = parent === null || parent === void 0 ? void 0 : parent.children) === null || _a === void 0 ? void 0 : _a.findIndex((n) => n === node);
if (!ind)
return false;
const next = (_b = parent === null || parent === void 0 ? void 0 : parent.children) === null || _b === void 0 ? void 0 : _b[ind + 1];
if (!(next === null || next === void 0 ? void 0 : next.value))
return false;
return ((next === null || next === void 0 ? void 0 : next.type) === 'text' && !!next.value.match(/^[a-zA-Z0-9\-_]/)) || false;
}
const handlers = {
text(node, state) {
// We do not want markdown formatting to be carried over to typst
// As the meaning in lists, etc. is different
state.text(node.value.replaceAll('\n', ' '));
},
paragraph(node, state) {
const { identifier } = node;
const after = identifier ? ` <${identifier}>` : undefined;
state.renderChildren(node, 2, { after });
},
heading(node, state) {
const { depth, identifier, enumerated, implicit } = node;
state.write(`${Array(depth).fill('=').join('')} `);
state.renderChildren(node);
if (enumerated !== false && identifier && !implicit) {
// Implicit labels can have duplicates and stop typst from compiling
state.write(` <${identifier}>`);
}
state.write('\n\n');
},
block(node, state) {
const metadataTags = getMetadataTags(node);
if (metadataTags.includes('no-typst'))
return;
if (metadataTags.includes('no-pdf'))
return;
if (node.visibility === 'remove' || node.visibility === 'hide')
return;
if (metadataTags.includes('page-break') || metadataTags.includes('new-page')) {
state.write('#pagebreak(weak: true)\n');
}
state.renderChildren(node, 2);
},
blockquote(node, state) {
if (state.data.isInBlockquote) {
state.renderChildren(node);
return;
}
state.write('#quote(block: true)[');
state.renderChildren(node);
state.write(']');
},
definitionList(node, state) {
let dedent = false;
if (!state.data.definitionIndent) {
state.data.definitionIndent = 2;
}
else {
state.write(`#set terms(indent: ${state.data.definitionIndent}em)`);
state.data.definitionIndent += 2;
dedent = true;
}
state.renderChildren(node, 1);
state.data.definitionIndent -= 2;
if (dedent)
state.write(`#set terms(indent: ${state.data.definitionIndent - 2}em)\n`);
},
definitionTerm(node, state) {
state.ensureNewLine();
state.write('/ ');
state.renderChildren(node);
state.write(': ');
},
definitionDescription(node, state) {
state.renderChildren(node);
},
code(node, state) {
var _a;
if (node.visibility === 'remove' || node.visibility === 'hide')
return;
let ticks = '```';
while (node.value.includes(ticks)) {
ticks += '`';
}
const start = `${ticks}${(_a = node.lang) !== null && _a !== void 0 ? _a : ''}\n`;
const end = `\n${ticks}`;
state.write(start);
state.write(node.value);
state.write(end);
state.ensureNewLine(true);
state.addNewLine();
},
list(node, state) {
var _a;
var _b;
const setStart = node.ordered && node.start && node.start !== 1;
if (setStart) {
state.write(`#set enum(start: ${node.start})`);
}
(_a = (_b = state.data).list) !== null && _a !== void 0 ? _a : (_b.list = { env: [] });
state.data.list.env.push(node.ordered ? '+' : '-');
state.renderChildren(node, setStart ? 1 : 2);
state.data.list.env.pop();
if (setStart) {
state.write('#set enum(start: 1)\n\n');
}
},
listItem(node, state) {
var _a, _b, _c;
const listEnv = (_b = (_a = state.data.list) === null || _a === void 0 ? void 0 : _a.env) !== null && _b !== void 0 ? _b : [];
const tabs = Array(Math.max(listEnv.length - 1, 0))
.fill(INDENT)
.join('');
const env = (_c = listEnv.slice(-1)[0]) !== null && _c !== void 0 ? _c : '-';
state.ensureNewLine();
state.write(`${tabs}${env} `);
state.renderChildren(node, 1);
},
thematicBreak(node, state) {
state.write('#line(length: 100%, stroke: gray)\n\n');
},
...MATH_HANDLERS,
mystRole(node, state) {
state.renderChildren(node);
},
mystDirective(node, state) {
state.renderChildren(node, 2);
},
comment(node, state) {
var _a, _b;
state.ensureNewLine();
if ((_a = node.value) === null || _a === void 0 ? void 0 : _a.includes('\n')) {
state.write(`/*\n${node.value}\n*/\n\n`);
}
else {
state.write(`// ${(_b = node.value) !== null && _b !== void 0 ? _b : ''}\n\n`);
}
},
strong(node, state, parent) {
const prev = prevCharacterIsText(parent, node);
const next = nextCharacterIsText(parent, node);
if (nodeOnlyHasTextChildren(node) && !(prev || next)) {
state.write('*');
state.renderChildren(node);
state.write('*');
}
else {
state.renderInlineEnvironment(node, 'strong');
}
},
emphasis(node, state, parent) {
const prev = prevCharacterIsText(parent, node);
const next = nextCharacterIsText(parent, node);
if (nodeOnlyHasTextChildren(node) && !prev && !next) {
state.write('_');
state.renderChildren(node);
state.write('_');
}
else {
state.renderInlineEnvironment(node, 'emph');
}
},
underline(node, state) {
state.renderInlineEnvironment(node, 'underline');
},
smallcaps(node, state) {
state.renderInlineEnvironment(node, 'smallcaps');
},
inlineCode(node, state) {
let ticks = '`';
// inlineCode can sometimes have children (e.g. from latex)
const value = toText(node);
// Double ticks create empty inline code; we never want that for start/end
while (ticks === '``' || value.includes(ticks)) {
ticks += '`';
}
state.write(ticks);
if (value.startsWith('`'))
state.write(' ');
state.write(value);
if (value.endsWith('`'))
state.write(' ');
state.write(ticks);
},
subscript(node, state) {
state.renderInlineEnvironment(node, 'sub');
},
superscript(node, state) {
state.renderInlineEnvironment(node, 'super');
},
delete(node, state) {
state.renderInlineEnvironment(node, 'strike');
},
break(node, state) {
state.write(' \\');
state.ensureNewLine();
},
abbreviation(node, state) {
state.renderChildren(node);
},
inlineExpression(node, state) {
// TODO: This is **very** simple at the moment
// It will work for inline nodes likely only, we can make it better soon
fileWarn(state.file, 'inlineExpression rendering in typst is in beta', {
node,
note: 'Rendering will work only for text nodes',
});
state.renderChildren(node);
},
link: linkHandler,
admonition(node, state) {
state.useMacro(admonition);
state.ensureNewLine();
const title = select('admonitionTitle', node);
if (!node.kind) {
fileError(state.file, `Unknown admonition kind`, {
node,
source: 'myst-to-typst',
});
return;
}
state.useMacro(admonitionMacros[node.kind]);
state.write(`#${node.kind}Block`);
if (title && toText(title).toLowerCase().replaceAll(' ', '') !== node.kind) {
state.write('(heading: [');
state.renderChildren(title);
state.write('])');
}
state.write('[\n');
state.renderChildren(node);
state.write('\n]\n\n');
},
admonitionTitle() {
return;
},
table: tableHandler,
tableRow: tableRowHandler,
tableCell: tableCellHandler,
image(node, state) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { width: nodeWidth, url: nodeSrc, align } = node;
const src = nodeSrc;
const width = getLatexImageWidth(nodeWidth);
const command = state.data.isInTable || !state.data.isInFigure ? '#image' : 'image';
state.write(`${command}("${src}"`);
if (!state.data.isInTable) {
state.write(`, width: ${width}`);
}
state.write(')\n\n');
},
iframe(node, state) {
var _a;
const image = (_a = node.children) === null || _a === void 0 ? void 0 : _a[0];
if (!image || image.placeholder !== true)
return;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { width: nodeWidth, url: nodeSrc, align } = image;
const src = nodeSrc;
const width = getLatexImageWidth(nodeWidth);
state.write(`#image("${src}"`);
if (!state.data.isInTable) {
state.write(`, width: ${width}`);
}
state.write(')\n\n');
},
container: containerHandler,
caption: captionHandler,
legend: captionHandler,
captionNumber: () => undefined,
crossReference(node, state, parent) {
var _a;
if (node.remoteBaseUrl) {
// We don't want to handle remote references, treat them as links
const url = node.remoteBaseUrl +
(node.url === '/' ? '' : (_a = node.url) !== null && _a !== void 0 ? _a : '') +
(node.html_id ? `#${node.html_id}` : '');
linkHandler({ ...node, url: url }, state);
return;
}
const id = node.identifier;
if (node.children && node.children.length > 0) {
state.write(`#link(<${id}>)[`);
state.renderChildren(node);
state.write(']');
}
else {
// Note that we don't need to protect against the previous character as text
const next = nextCharacterIsText(parent, node);
state.write(next ? `#[@${id}]` : `@${id}`);
}
},
citeGroup(node, state) {
state.renderChildren(node, 0, { delim: ' ' });
},
cite(node, state) {
const needsLabel = !/^[a-zA-Z0-9_\-:.]+$/.test(node.label);
const label = needsLabel ? `label("${node.label}")` : `<${node.label}>`;
state.write(`#cite(${label}`);
if (node.kind === 'narrative')
state.write(`, form: "prose"`);
// node.prefix not supported by typst: see https://github.com/typst/typst/issues/1139
if (node.suffix)
state.write(`, supplement: [${node.suffix}]`);
state.write(`)`);
},
embed(node, state) {
state.renderChildren(node, 2);
},
include(node, state) {
state.renderChildren(node, 2);
},
footnoteReference(node, state) {
if (!node.identifier)
return;
const footnote = state.footnotes[node.identifier];
if (!footnote) {
fileError(state.file, `Unknown footnote identifier "${node.identifier}"`, {
node,
source: 'myst-to-typst',
});
return;
}
state.write('#footnote[');
state.renderChildren(footnote);
state.write(']');
},
footnoteDefinition() {
// Nothing!
},
// si(node, state) {
// // state.useMacro('siunitx');
// if (node.number == null) {
// state.write(`\\unit{${node.units?.map((u: string) => `\\${u}`).join('') ?? ''}}`);
// } else {
// state.write(
// `\\qty{${node.number}}{${node.units?.map((u: string) => `\\${u}`).join('') ?? ''}}`,
// );
// }
// },
div(node, state) {
state.renderChildren(node, 1);
},
span(node, state) {
state.renderChildren(node, 0, { trimEnd: false });
},
raw(node, state) {
var _a;
if (node.typst) {
state.write(node.typst);
}
else if ((_a = node.children) === null || _a === void 0 ? void 0 : _a.length) {
state.renderChildren(node, undefined, { trimEnd: false });
}
},
tabSet(node, state) {
state.useMacro(tabSet);
state.write('#tabSet[\n');
state.renderChildren(node);
state.write('\n]\n\n');
},
tabItem(node, state) {
state.useMacro(tabItem);
state.ensureNewLine();
const title = node.title;
state.write(`#tabItem(heading: [${title}])[\n`);
state.renderChildren(node);
state.write('\n]\n\n');
},
card(node, state) {
var _a;
if (node.url) {
(_a = node.children) === null || _a === void 0 ? void 0 : _a.push({ type: 'paragraph', children: [{ type: 'text', value: node.url }] });
}
state.renderChildren(node);
state.ensureNewLine();
state.write('\n');
},
cardTitle(node, state) {
state.write('*');
state.renderChildren(node);
state.write('*');
state.ensureNewLine();
state.write('\n');
},
root(node, state) {
state.renderChildren(node);
},
footer() {
return;
},
...proofHandlers,
};
class TypstSerializer {
constructor(file, tree, opts) {
var _a;
file.result = '';
this.file = file;
const { math, ...otherOpts } = opts !== null && opts !== void 0 ? opts : {};
this.options = { ...otherOpts };
if (math)
this.options.math = resolveRecursiveCommands(math);
this.data = { mathPlugins: {}, macros: new Set() };
this.handlers = (_a = opts === null || opts === void 0 ? void 0 : opts.handlers) !== null && _a !== void 0 ? _a : handlers;
this.footnotes = Object.fromEntries(selectAll('footnoteDefinition', tree).map((node) => {
const fn = node;
return [fn.identifier, fn];
}));
this.renderChildren(tree);
}
get out() {
return this.file.result;
}
useMacro(macro) {
this.data.macros.add(macro);
}
write(value) {
this.file.result += value;
}
text(value, mathMode = false) {
const escaped = mathMode ? stringToTypstMath(value) : stringToTypstText(value);
this.write(escaped);
}
trimEnd() {
this.file.result = this.out.trimEnd();
}
addNewLine() {
this.write('\n');
}
ensureNewLine(trim = false) {
if (trim)
this.trimEnd();
if (this.out.endsWith('\n'))
return;
this.addNewLine();
}
renderChildren(node, trailingNewLines = 0, opts = {}) {
var _a, _b, _c;
if (Array.isArray(node)) {
this.renderChildren({ children: node }, trailingNewLines, opts);
return;
}
const { delim = '', trimEnd = true, after } = opts;
const numChildren = (_b = (_a = node.children) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0;
(_c = node.children) === null || _c === void 0 ? void 0 : _c.forEach((child, index) => {
if (!child)
return;
const handler = this.handlers[child === null || child === void 0 ? void 0 : child.type];
if (handler) {
handler(child, this, node);
}
else {
fileError(this.file, `Unhandled Typst conversion for node of "${child === null || child === void 0 ? void 0 : child.type}"`, {
node: child,
source: 'myst-to-typst',
});
}
if (delim && index + 1 < numChildren)
this.write(delim);
});
if (trimEnd)
this.trimEnd();
if (after)
this.write(after);
for (let i = trailingNewLines; i--;)
this.addNewLine();
}
renderEnvironment(node, env) {
this.file.result += `#${env}[\n`;
this.renderChildren(node, 1);
this.file.result += `]\n\n`;
}
renderInlineEnvironment(node, env) {
this.file.result += `#${env}[`;
this.renderChildren(node);
this.file.result += ']';
}
}
const plugin = function (opts) {
this.Compiler = (node, file) => {
const state = new TypstSerializer(file, node, opts !== null && opts !== void 0 ? opts : { handlers });
const tex = file.result.trim();
const result = {
macros: [...state.data.macros],
commands: state.data.mathPlugins,
value: tex,
};
file.result = result;
return file;
};
return (node) => {
// Preprocess
return node;
};
};
export default plugin;