myst-to-typst
Version:
Export from MyST mdast to Typst
583 lines (559 loc) • 18.3 kB
text/typescript
import type { Root, Parent } from 'myst-spec';
import type { Plugin } from 'unified';
import type { VFile } from 'vfile';
import type { GenericNode } from 'myst-common';
import { fileError, fileWarn, toText, getMetadataTags } from 'myst-common';
import { captionHandler, containerHandler, getDefaultCaptionSupplement } from './container.js';
import type {
Handler,
ITypstSerializer,
TypstResult,
Options,
StateData,
RenderChildrenOptions,
} from './types.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 type { Admonition, Code, CrossReference, FootnoteDefinition, TabItem } from 'myst-spec-ext';
import { tableCellHandler, tableHandler, tableRowHandler } from './table.js';
import { proofHandlers } from './proofs.js';
export type { TypstResult } from './types.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: any, state: ITypstSerializer) => {
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: GenericNode, node: GenericNode): boolean {
const ind = parent?.children?.findIndex((n: GenericNode) => n === node);
if (!ind) return false;
const prev = parent?.children?.[ind - 1];
if (!prev?.value) return false;
return (prev?.type === 'text' && !!prev.value.match(/[a-zA-Z0-9\-_]$/)) || false;
}
function nextCharacterIsText(parent: GenericNode, node: GenericNode): boolean {
const ind = parent?.children?.findIndex((n: GenericNode) => n === node);
if (!ind) return false;
const next = parent?.children?.[ind + 1];
if (!next?.value) return false;
return (next?.type === 'text' && !!next.value.match(/^[a-zA-Z0-9\-_]/)) || false;
}
const handlers: Record<string, Handler> = {
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: Code, state) {
if (node.visibility === 'remove' || node.visibility === 'hide') return;
let ticks = '```';
while (node.value.includes(ticks)) {
ticks += '`';
}
const start = `${ticks}${node.lang ?? ''}\n`;
const end = `\n${ticks}`;
state.write(start);
state.write(node.value);
state.write(end);
state.ensureNewLine(true);
state.addNewLine();
},
list(node, state) {
const setStart = node.ordered && node.start && node.start !== 1;
if (setStart) {
state.write(`#set enum(start: ${node.start})`);
}
state.data.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) {
const listEnv = state.data.list?.env ?? [];
const tabs = Array(Math.max(listEnv.length - 1, 0))
.fill(INDENT)
.join('');
const env = listEnv.slice(-1)[0] ?? '-';
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) {
state.ensureNewLine();
if (node.value?.includes('\n')) {
state.write(`/*\n${node.value}\n*/\n\n`);
} else {
state.write(`// ${node.value ?? ''}\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: Admonition, 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) {
const image = node.children?.[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: CrossReference, state, parent) {
if (node.remoteBaseUrl) {
// We don't want to handle remote references, treat them as links
const url =
node.remoteBaseUrl +
(node.url === '/' ? '' : node.url ?? '') +
(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) {
if (node.typst) {
state.write(node.typst);
} else if (node.children?.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: TabItem, 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) {
if (node.url) {
node.children?.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 implements ITypstSerializer {
file: VFile;
data: StateData;
options: Options;
handlers: Record<string, Handler>;
footnotes: Record<string, FootnoteDefinition>;
constructor(file: VFile, tree: Root, opts?: Options) {
file.result = '';
this.file = file;
const { math, ...otherOpts } = opts ?? {};
this.options = { ...otherOpts };
if (math) this.options.math = resolveRecursiveCommands(math);
this.data = { mathPlugins: {}, macros: new Set() };
this.handlers = opts?.handlers ?? handlers;
this.footnotes = Object.fromEntries(
selectAll('footnoteDefinition', tree).map((node) => {
const fn = node as FootnoteDefinition;
return [fn.identifier, fn];
}),
);
this.renderChildren(tree);
}
get out(): string {
return this.file.result as string;
}
useMacro(macro: string) {
this.data.macros.add(macro);
}
write(value: string) {
this.file.result += value;
}
text(value: string, 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: Partial<Parent> | Parent[],
trailingNewLines = 0,
opts: RenderChildrenOptions = {},
) {
if (Array.isArray(node)) {
this.renderChildren({ children: node }, trailingNewLines, opts);
return;
}
const { delim = '', trimEnd = true, after } = opts;
const numChildren = node.children?.length ?? 0;
node.children?.forEach((child, index) => {
if (!child) return;
const handler = this.handlers[child?.type];
if (handler) {
handler(child, this, node);
} else {
fileError(this.file, `Unhandled Typst conversion for node of "${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: any, env: string) {
this.file.result += `#${env}[\n`;
this.renderChildren(node, 1);
this.file.result += `]\n\n`;
}
renderInlineEnvironment(node: any, env: string) {
this.file.result += `#${env}[`;
this.renderChildren(node);
this.file.result += ']';
}
}
const plugin: Plugin<[Options?], Root, VFile> = function (opts) {
this.Compiler = (node, file) => {
const state = new TypstSerializer(file, node, opts ?? { handlers });
const tex = (file.result as string).trim();
const result: TypstResult = {
macros: [...state.data.macros],
commands: state.data.mathPlugins,
value: tex,
};
file.result = result;
return file;
};
return (node: Root) => {
// Preprocess
return node;
};
};
export default plugin;