import { type Plugin, type ViteDevServer } from "vite"; import { renderToHTMLish } from "./typst.js"; import { pathToFileURL } from "node:url"; import { detectTarget, type AstroTypstConfig } from "./prelude.js"; import { rehypeTypstx } from "./rehype.js"; import logger from "./logger.js"; import path from "node:path/posix"; import { getAstroConfig } from "./store.js"; function isTypstFile(id: string) { return /\.typ(\?(html|svg|html&body|body&html|html-text|html-hast))?$/.test(id); } function extractOpts(id: string) { const q = id.lastIndexOf('?'); if (q === -1) return { path: id, opts: '' }; const path = id.slice(0, q); const opts = id.slice(q + 1); return { path, opts, } } function debug(...args: any[]) { if (process.env.DEBUG) { console.debug(...args); } } export default function (config: AstroTypstConfig): Plugin { const astroConfig = getAstroConfig(); let server: ViteDevServer; const VITE_PLUGIN_NAME = 'vite-plugin-astro-typ'; const plugin: Plugin = { name: VITE_PLUGIN_NAME, enforce: 'pre', load(id) { if (!isTypstFile(id)) return; debug(`[vite-plugin-astro-typ] Loading id: ${id}`); }, configureServer(_server) { server = _server; // listen for .typ file changes server.watcher.on('change', async (filePath) => { if (isTypstFile(filePath)) { const modules = server.moduleGraph.getModulesByFile(filePath); if (modules) { for (const mod of modules) { debug(`[vite-plugin-astro-typ] Invalidating module: ${mod.id}`); server.moduleGraph.invalidateModule(mod); } } else { debug(`[vite-plugin-astro-typ] No modules found for file: ${filePath}`); server.ws.send({ type: 'full-reload', path: '*', }); } } }); }, async transform(_: string, id: string) { if (!isTypstFile(id)) return; const { path: mainFilePath, opts } = extractOpts(id); await new Promise((resolve) => setTimeout(resolve, 1000)); let isHtml = false; if (opts.includes('svg') || opts.includes("img")) { isHtml = false; } else if (opts.includes('html')) { isHtml = true; } else { isHtml = await detectTarget(mainFilePath, config.target) === "html"; } let emitSvg = opts.includes('img') || config?.emitSvg === true; let htmlMode = config.htmlMode ?? 'hast'; if ( opts.includes('html-text') || opts.includes('htxt') ) htmlMode = 'text'; let { html, getFrontmatter } = await renderToHTMLish( { mainFilePath, // TODO: remove body; autodetect after the render process is delayed body: htmlMode === 'hast' ? "hast" : true, }, config.options, isHtml ); // emitSvg: put svg to public dir and inline it as a base64 string (dev) / img (prod) if (emitSvg && !isHtml) { let imgSvg = ""; const contentHash = crypto.randomUUID().slice(0, 8); const fileName = `typst-${contentHash}.svg`; const emitSvgDir = config.emitSvgDir ?? "typst"; const base = astroConfig.base ?? "/"; let publicUrl = path.join(base, emitSvgDir, fileName); logger.debug({ base, emitSvgDir, fileName, publicUrl, }) if (import.meta.env.PROD) { // 'build' mode const emitName = path.join(emitSvgDir, fileName); const respId = this.emitFile({ type: 'asset', fileName: emitName, source: Buffer.from(html, 'utf-8'), }); logger.debug("emitFile", respId) imgSvg = ``; } else { // 'serve' mode inlines svg as base64 imgSvg = ``; } html = imgSvg; } else if (isHtml) { // output is actually hast if (htmlMode === 'hast') { if (typeof html === 'string') { console.warn(html); throw new Error("html is a string, but it should be a hast object"); } html = await rehypeTypstx(html, astroConfig.markdown?.rehypePlugins ?? []); } } const code = (isHtml && htmlMode === 'hast') ? html + "\nexport const Content = MDXContent" : ` import { createComponent, render, renderJSX, renderComponent, unescapeHTML } from "astro/runtime/server/index.js"; import { AstroJSX, jsx } from 'astro/jsx-runtime'; import { readFileSync } from "node:fs"; export const name = "TypstComponent"; export const html = ${JSON.stringify(html)}; export const frontmatter = ${JSON.stringify(getFrontmatter())}; export const file = ${JSON.stringify(mainFilePath)}; export const url = ${JSON.stringify(pathToFileURL(mainFilePath))}; export function rawContent() { return readFileSync(file, 'utf-8'); } export function compiledContent() { return ${ // since Astro v5, function `compiledContent` is an async function JSON.stringify(html) }; } export function getHeadings() { return undefined; } export const Content = createComponent(async (result, _props, slots) => { const { layout, ...content } = frontmatter; const slot = await slots?.default?.(); content.file = file; content.url = url; // return render\`\${compiledContent()}\`; return render\`\${unescapeHTML(compiledContent())}\`; }); export default Content; ` return { code, map: null, } } } return plugin; }