UNPKG

myst-cli

Version:
342 lines (341 loc) 15.6 kB
import AdmZip from 'adm-zip'; import fs from 'node:fs'; import path from 'node:path'; import which from 'which'; import { makeExecutable, tic, writeFileToFolder } from 'myst-cli-utils'; import { extractPart, RuleId, TemplateKind } from 'myst-common'; import { FRONTMATTER_ALIASES, PAGE_FRONTMATTER_KEYS, PROJECT_FRONTMATTER_KEYS, articlesWithFile, validateProjectFrontmatter, } from 'myst-frontmatter'; import MystTemplate from 'myst-templates'; import mystToTypst from 'myst-to-typst'; import { filterKeys } from 'simple-validators'; import { unified } from 'unified'; import { selectAll } from 'unist-util-select'; import { VFile } from 'vfile'; import { mergeTypstTemplateImports, renderTemplate, renderTypstImports } from 'jtex'; import { frontmatterValidationOpts } from '../frontmatter.js'; import { finalizeMdast } from '../process/mdast.js'; import { selectors } from '../store/index.js'; import { ImageExtensions } from '../utils/resolveExtension.js'; import { logMessagesFromVFile } from '../utils/logging.js'; import { getFileContent } from './utils/getFileContent.js'; import { addWarningForFile } from '../utils/addWarningForFile.js'; import { createTempFolder } from '../utils/createTempFolder.js'; import { resolveFrontmatterParts } from '../utils/resolveFrontmatterParts.js'; import version from '../version.js'; import { cleanOutput } from './utils/cleanOutput.js'; import { writeBibtexFromCitationRenderers } from './utils/bibtex.js'; export const DEFAULT_BIB_FILENAME = 'main.bib'; const TYPST_IMAGE_EXTENSIONS = [ ImageExtensions.svg, ImageExtensions.png, ImageExtensions.jpg, ImageExtensions.jpeg, ]; export function isTypstAvailable() { return which.sync('typst', { nothrow: true }); } export async function runTypstExecutable(session, typstFile) { if (!isTypstAvailable()) { throw new Error('The typst CLI must be installed to build PDFs with typst'); } if (path.extname(typstFile) !== '.typ') { throw new Error(`invalid input file for typst executable: ${typstFile}`); } session.log.debug('Running typst compile'); await makeExecutable(`typst compile "${typstFile}"`, session.log)(); } export function mdastToTypst(session, mdast, references, frontmatter, templateYml, printGlossaries) { const pipe = unified().use(mystToTypst, { math: frontmatter?.math, // citestyle: templateYml?.style?.citation, // bibliography: templateYml?.style?.bibliography, // printGlossaries, // references, // ...frontmatter.settings?.myst_to_tex, }); const result = pipe.runSync(mdast); const typ = pipe.stringify(result); logMessagesFromVFile(session, typ); return typ.result; } export function extractTypstPart(session, mdast, references, partDefinition, frontmatter, templateYml) { const part = extractPart(mdast, partDefinition.id, { frontmatterParts: resolveFrontmatterParts(session, frontmatter), }); if (!part) return undefined; if (!partDefinition.as_list) { // Do not build glossaries when extracting parts: references cannot be mapped to definitions return mdastToTypst(session, part, references, frontmatter, templateYml, false); } if (part.children.length === 1 && part.children[0]?.children?.length === 1 && part.children[0].children[0].type === 'list') { const items = selectAll('listItem', part); return items.map((item) => { return mdastToTypst(session, { type: 'root', children: item.children }, references, frontmatter, templateYml, false); }); } return part.children.map((block) => { return mdastToTypst(session, { type: 'root', children: [block] }, references, frontmatter, templateYml, false); }); } function titleToTypstHeading(session, title, depth = 1) { const headingMdast = { type: 'root', children: [ { type: 'heading', depth, children: [{ type: 'text', value: title }], }, ], }; const content = mdastToTypst(session, headingMdast, {}, {}, null, false); return content.value; } export async function localArticleToTypstRaw(session, templateOptions, opts) { const { articles, output } = templateOptions; const { projectPath, extraLinkTransformers, execute } = opts ?? {}; const fileArticles = articlesWithFile(articles); const content = await getFileContent(session, fileArticles.map((article) => article.file), { projectPath, imageExtensions: TYPST_IMAGE_EXTENSIONS, extraLinkTransformers, titleDepths: fileArticles.map((article) => article.level), preFrontmatters: fileArticles.map((article) => filterKeys(article, [...PAGE_FRONTMATTER_KEYS, ...Object.keys(FRONTMATTER_ALIASES)])), execute, }); const toc = tic(); const results = await Promise.all(content.map(async ({ mdast, frontmatter, references }, ind) => { await finalizeMdast(session, mdast, frontmatter, fileArticles[ind].file, { imageWriteFolder: path.join(path.dirname(output), 'files'), imageAltOutputFolder: 'files/', imageExtensions: TYPST_IMAGE_EXTENSIONS, simplifyFigures: true, }); return mdastToTypst(session, mdast, references, frontmatter, null, false); })); session.log.info(toc(`📑 Exported typst in %s, copying to ${output}`)); if (results.length === 1) { writeFileToFolder(output, results[0].value); } else { const { dir, name, ext } = path.parse(output); let includeContent = ''; let fileInd = 0; let addPageBreak = false; articles.forEach((article) => { if (addPageBreak) includeContent += '#pagebreak()\n\n'; addPageBreak = false; if (article.file) { const base = `${name}-${content[fileInd]?.slug ?? fileInd}${ext}`; const includeFile = path.format({ dir, ext, base }); let part = ''; const { title, content_includes_title } = content[fileInd]?.frontmatter ?? {}; if (title && !content_includes_title) { part = `${titleToTypstHeading(session, title, article.level)}\n\n`; } writeFileToFolder(includeFile, `${part}${results[fileInd].value}`); includeContent += `#include "${base}"\n\n`; fileInd++; addPageBreak = true; } else if (article.title) { includeContent += `${titleToTypstHeading(session, article.title, article.level)}\n\n`; } }); writeFileToFolder(output, includeContent); } await runTypstExecutable(session, output); // TODO: add imports and macros? return { tempFolders: [] }; } export async function localArticleToTypstTemplated(session, file, templateOptions, opts) { const { output, articles, template } = templateOptions; const { projectPath, extraLinkTransformers, clean, ci, execute } = opts ?? {}; const filesPath = path.join(path.dirname(output), 'files'); const fileArticles = articlesWithFile(articles); const content = await getFileContent(session, fileArticles.map((article) => article.file), { projectPath, imageExtensions: TYPST_IMAGE_EXTENSIONS, extraLinkTransformers, titleDepths: fileArticles.map((article) => article.level), preFrontmatters: fileArticles.map((article) => filterKeys(article, [...PAGE_FRONTMATTER_KEYS, ...Object.keys(FRONTMATTER_ALIASES)])), execute, }); const bibtexWritten = writeBibtexFromCitationRenderers(session, path.join(path.dirname(output), DEFAULT_BIB_FILENAME), content); const warningLogFn = (message) => { addWarningForFile(session, file, message, 'warn', { ruleId: RuleId.texRenders, }); }; const mystTemplate = new MystTemplate(session, { kind: TemplateKind.typst, template: template || undefined, buildDir: session.buildPath(), errorLogFn: (message) => { addWarningForFile(session, file, message, 'error', { ruleId: RuleId.texRenders, }); }, warningLogFn, }); await mystTemplate.ensureTemplateExistsOnPath(); const toc = tic(); const templateYml = mystTemplate.getValidatedTemplateYml(); const partDefinitions = templateYml?.parts || []; const parts = {}; let collected = { macros: [], commands: {}, }; const state = session.store.getState(); const projectFrontmatter = selectors.selectLocalProjectConfig(state, projectPath ?? '.') ?? {}; if (file === selectors.selectCurrentProjectFile(state)) { // If export is defined at the project level, prioritize project parts over page parts partDefinitions.forEach((def) => { const part = extractTypstPart(session, { type: 'root', children: [] }, {}, def, projectFrontmatter, templateYml); if (Array.isArray(part)) { // This is the case if def.as_list is true part.forEach((item) => { collected = mergeTypstTemplateImports(collected, item); }); parts[def.id] = part.map(({ value }) => value); } else if (part != null) { collected = mergeTypstTemplateImports(collected, part); parts[def.id] = part?.value ?? ''; } }); } const hasGlossaries = false; const results = await Promise.all(content.map(async ({ mdast, frontmatter, references }, ind) => { await finalizeMdast(session, mdast, frontmatter, fileArticles[ind].file, { imageWriteFolder: filesPath, imageAltOutputFolder: 'files/', imageExtensions: TYPST_IMAGE_EXTENSIONS, simplifyFigures: true, }); partDefinitions.forEach((def) => { const part = extractTypstPart(session, mdast, references, def, frontmatter, templateYml); if (part && parts[def.id]) { addWarningForFile(session, file, `multiple values for part '${def.id}' found; ignoring value from ${fileArticles[ind].file}`, 'error', { ruleId: RuleId.texRenders }); } else if (Array.isArray(part)) { // This is the case if def.as_list is true part.forEach((item) => { collected = mergeTypstTemplateImports(collected, item); }); parts[def.id] = part.map(({ value }) => value); } else if (part != null) { collected = mergeTypstTemplateImports(collected, part); parts[def.id] = part?.value ?? ''; } }); const result = mdastToTypst(session, mdast, references, frontmatter, templateYml, true); collected = mergeTypstTemplateImports(collected, result); return result; })); let frontmatter; let typstContent; const versionComment = ci ? '' : `/* Written by MyST v${version} */\n\n`; if (results.length === 1) { frontmatter = content[0].frontmatter; typstContent = results[0].value; } else { frontmatter = projectFrontmatter; const { dir, name, ext } = path.parse(output); typstContent = ''; let fileInd = 0; let addPageBreak = false; articles.forEach((article) => { if (addPageBreak) typstContent += '#pagebreak()\n\n'; addPageBreak = false; if (article.file) { const base = `${name}-${content[fileInd]?.slug ?? fileInd}${ext}`; const includeFile = path.format({ dir, ext, base }); const exports = renderTypstImports(false, collected); let part = ''; const { title, content_includes_title } = content[fileInd]?.frontmatter ?? {}; if (title && !content_includes_title) { part = `${titleToTypstHeading(session, title, article.level)}\n\n`; } writeFileToFolder(includeFile, `${versionComment}${exports}\n\n${part}${results[fileInd].value}`); typstContent += `#include "${base}"\n\n`; fileInd++; addPageBreak = true; } else if (article.title) { typstContent += `${titleToTypstHeading(session, article.title, article.level)}\n\n`; } }); } const vfile = new VFile(); vfile.path = file; const exportFrontmatter = validateProjectFrontmatter(filterKeys(templateOptions, [...PROJECT_FRONTMATTER_KEYS, ...Object.keys(FRONTMATTER_ALIASES)]), frontmatterValidationOpts(vfile)); logMessagesFromVFile(session, vfile); frontmatter = { ...frontmatter, ...exportFrontmatter }; typstContent = `${versionComment}${typstContent}`; session.log.info(toc(`📑 Exported typst in %s, copying to ${output}`)); renderTemplate(mystTemplate, { contentOrPath: typstContent, outputPath: output, frontmatter, parts, options: { ...frontmatter.options, ...templateOptions }, bibliography: bibtexWritten ? DEFAULT_BIB_FILENAME : undefined, sourceFile: file, imports: collected, force: clean, packages: templateYml.packages, filesPath, removeVersionComment: ci, }); await runTypstExecutable(session, output); return { tempFolders: [], hasGlossaries }; } export async function runTypstExport(// DBG: Must return an info on whether glossaries are present session, file, exportOptions, opts) { if (opts?.clean) cleanOutput(session, exportOptions.output); let result; if (exportOptions.template === null) { result = await localArticleToTypstRaw(session, exportOptions, opts); } else { result = await localArticleToTypstTemplated(session, file, exportOptions, opts); } return result; } export async function runTypstZipExport(session, file, exportOptions, opts) { if (opts?.clean) cleanOutput(session, exportOptions.output); const zipOutput = exportOptions.output; const typFolder = createTempFolder(session); exportOptions.output = path.join(typFolder, `${path.basename(zipOutput, path.extname(zipOutput))}.typ`); await runTypstExport(session, file, exportOptions, { ...(opts ?? {}), clean: false }); session.log.info(`🤐 Zipping typst outputs to ${zipOutput}`); const zip = new AdmZip(); zip.addLocalFolder(typFolder); zip.writeZip(zipOutput); return { tempFolders: [typFolder] }; } export async function runTypstPdfExport(session, file, exportOptions, opts) { if (opts?.clean) cleanOutput(session, exportOptions.output); const pdfOutput = exportOptions.output; const typFolder = createTempFolder(session); exportOptions.output = path.join(typFolder, `${path.basename(pdfOutput, path.extname(pdfOutput))}.typ`); await runTypstExport(session, file, exportOptions, { ...(opts ?? {}), clean: false }); const writeFolder = path.dirname(pdfOutput); session.log.info(`🖨 Rendering typst pdf to ${pdfOutput}`); if (!fs.existsSync(writeFolder)) fs.mkdirSync(writeFolder, { recursive: true }); fs.copyFileSync(exportOptions.output.replace('.typ', '.pdf'), pdfOutput); return { tempFolders: [typFolder] }; }