Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/components/Typst.astro
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ let input: TypstDocInput =

const html =
options.target === "html"
? (await renderToHTML(input, options)).html
? ((await renderToHTML(input, options))?.html?.html?.()) || ""
: (await renderToSVGString(input, options)).svg;
---

Expand Down
31 changes: 17 additions & 14 deletions src/lib/typst.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ let compilerIns: NodeCompiler | undefined;

/** The cached dynamic layout compiler instance */
let dynCompilerIns: DynLayoutCompiler | undefined;
let cheerioHandler: AstroTypstRenderOption["cheerio"] | undefined;

export function setCheerio(cheerio: any) {
cheerioHandler = cheerio;
}

function prepareSource(source: TypstDocInput, _options: any) {
if (typeof source === "string") {
Expand All @@ -28,7 +33,7 @@ function getInitOptions(): CompileArgs {
return initOptions;
}

function initCompiler(): NodeCompiler {
function initCompiler(): NodeCompiler {
return NodeCompiler.create(getInitOptions());
}

Expand Down Expand Up @@ -58,20 +63,22 @@ export function getFrontmatter($typst: NodeCompiler, source: NodeTypstDocument |
return frontmatter;
}

type RenderToSVGStringOption = Omit<AstroTypstRenderOption, "cheerio">;

/**
* @param source The source code of the .typ file.
* @param options Options for rendering the SVG.
* @returns The SVG string.
*/
export async function renderToSVGString(source: TypstDocInput, options: AstroTypstRenderOption | undefined) {
export async function renderToSVGString(source: TypstDocInput, options: RenderToSVGStringOption | undefined) {
source = prepareSource(source, options);
const $typst = source.mainFileContent ? getOrInitCompiler() : initCompiler();
const svg = await renderToSVGString_($typst, source);
$typst.evictCache(60);
let $ = load(svg, {
xml: true,
});
(options?.cheerio?.preprocess) && ($ = options?.cheerio?.preprocess($, source));
(cheerioHandler?.preprocess) && ($ = cheerioHandler?.preprocess($, source));
const remPx = options?.remPx || 16;
const width = $("svg").attr("width");
if (options?.width === undefined && width !== undefined) {
Expand All @@ -96,8 +103,8 @@ export async function renderToSVGString(source: TypstDocInput, options: AstroTyp
$("svg").attr(key, value as any);
}
}
(options?.cheerio?.postprocess) && ($ = options?.cheerio?.postprocess($, source));
const svgString = options?.cheerio?.stringify ? options?.cheerio?.stringify($, source) : $.html();
(cheerioHandler?.postprocess) && ($ = cheerioHandler?.postprocess($, source));
const svgString = cheerioHandler?.stringify ? cheerioHandler?.stringify($, source) : $.html();
// @ts-ignore
return { svg: svgString, frontmatter: () => getFrontmatter($typst, source) };
}
Expand Down Expand Up @@ -140,29 +147,25 @@ export async function renderToDynamicLayout(
}

export async function renderToHTML(
source: TypstDocInput & { body?: boolean },
source: TypstDocInput,
options: any,
) {
const onlyBody = source?.body !== false;
source = prepareSource(source, options);
const $typst = getOrInitCompiler();
const docRes = $typst.compileHtml(source);
if (!docRes.result) {
logger.error("Error compiling typst to HTML");
docRes.printDiagnostics();
return { html: "" };
return { html: undefined };
}
const doc = docRes.result;
const html = $typst.tryHtml(doc);
if (!html.result) {
html.printDiagnostics();
return { html: "" };
return { html: undefined };
}
return {
html:
onlyBody ?
html.result.body() :
html.result.html(),
html: html.result,
frontmatter: () => getFrontmatter($typst, doc),
};
}
Expand All @@ -181,7 +184,7 @@ export async function renderToHTMLish(
source,
options
);
html = htmlRes;
html = (source.body ? htmlRes?.body() : htmlRes?.html()) || "";
getFrontmatter = frontmatter || (() => ({}));
} else /* svg */ {
let { svg, frontmatter } = await renderToSVGString(
Expand Down
149 changes: 92 additions & 57 deletions src/lib/vite.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import fs from "fs/promises";
import { type Plugin, type ViteDevServer } from "vite";
import { renderToHTMLish } from "./typst.js";
import { pathToFileURL } from "node:url";
import { setCheerio as compilerSetCheerio } from "./typst.js";
import { detectTarget, type AstroTypstConfig } from "./prelude.js";
import logger from "./logger.js";
import path from "node:path/posix";
import type { AstroConfig } from "astro";
import { resolve, dirname } from "path";
import { fileURLToPath } from 'url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

function isTypstFile(id: string) {
return /\.typ(\?(html|svg|html&body|body&html))?$/.test(id);
Expand All @@ -28,10 +29,40 @@ function debug(...args: any[]) {

export default function (config: AstroTypstConfig, options: AstroConfig): Plugin {
let server: ViteDevServer;

// Extracts `cheerio` which is not serializable.
const compileConfig = { ...config.options };
if (compileConfig?.cheerio) {
compilerSetCheerio(compileConfig.cheerio);
delete compileConfig.cheerio;
}
// Serialize the compileConfig to JSON.
const compileConfigString = JSON.stringify(compileConfig);

const plugin: Plugin = {
name: 'vite-plugin-astro-typ',
enforce: 'pre',

// Resolves `astro-typst/runtime` statically, so that we
// can use it in typst components reliably.
config(config, env) {

config.resolve ||= {};
config.resolve.alias ||= {};

if (config.resolve.alias instanceof Array) {
config.resolve.alias = [
...config.resolve.alias,
{
find: 'astro-typst/runtime',
replacement: resolve(__dirname, 'typst.js'),
},
];
} else {
config.resolve.alias['astro-typst/runtime'] = resolve(__dirname, 'typst.js');
}
},

// resolveId(source, importer, options) {
// if (!isTypstFile(source)) return null;
// const { path, opts } = extractOpts(source);
Expand All @@ -50,10 +81,10 @@ export default function (config: AstroTypstConfig, options: AstroConfig): Plugin
if (isTypstFile(filePath)) {
const modules = server.moduleGraph.getModulesByFile(filePath);
if (modules) {
for (const mod of modules) {
modules.forEach(mod => {
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({
Expand Down Expand Up @@ -82,68 +113,72 @@ export default function (config: AstroTypstConfig, options: AstroConfig): Plugin
}
let emitSvg = opts.includes('img') || config?.emitSvg === true;

let { html, getFrontmatter } = await renderToHTMLish(
{
mainFilePath,
body: emitSvg ? true : isBody,
},
config.options,
isHtml
);

if (emitSvg && !isHtml) {
let imgSvg = "";
const contentHash = crypto.randomUUID().slice(0, 8);
const fileName = `typst-${contentHash}.svg`;

const emitSvgDir = config.emitSvgDir ?? "typst";
const base = options.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 = `<img src='${publicUrl}' />`;
} else { // 'serve' mode inlines svg as base64
imgSvg = `<img src="data:image/svg+xml;base64,${Buffer.from(html, 'utf-8').toString('base64')}" />`;
}
html = imgSvg;
}

const docArgs = JSON.stringify({ mainFilePath, body: emitSvg ? true : isBody });
const code = `
import { createComponent, render, renderComponent, unescapeHTML } from "astro/runtime/server/index.js";
import { pathToFileURL } from "node:url";
import * as path from "node:path";
import crypto from "node:crypto";
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { renderToHTML, renderToSVGString } from "astro-typst/runtime";
const docArgs = ${docArgs};
// todo: will getFrontmatter be always used in most cases?
const { html: htmlDoc, svg, frontmatter: getFrontmatter } = await ${isHtml ? "renderToHTML" : "renderToSVGString"}(
docArgs,
${compileConfigString},
);

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 ${JSON.stringify(await fs.readFile(mainFilePath, 'utf-8'))};
let bodyCache = undefined;
let htmlCache = undefined;
const getHtml = (body) => {
if (!htmlDoc) { return ""; }
return body ?
(bodyCache === undefined ? (bodyCache = htmlDoc.body()) : bodyCache) :
(htmlCache === undefined ? (htmlCache = htmlDoc.html()) : htmlCache);
}
export function compiledContent() {
return ${JSON.stringify(html)};

export const frontmatter = getFrontmatter();
export const file = docArgs.mainFilePath;
export const url = pathToFileURL(file);

export function rawContent() {
return readFileSync(file, 'utf-8');
}
export function getHeadings() {
return undefined;
}

export const Content = createComponent((result, _props, slots) => {
export const Content = createComponent((result, props, slots) => {
const { layout, ...content } = frontmatter;
content.file = file;
content.url = url;
// return render\`\${compiledContent()}\`;
return render\`\${unescapeHTML(compiledContent())}\`;
let toRender = ${isHtml ? "getHtml(props?.body || docArgs.body)" : "svg"};

if (${emitSvg && !isHtml}) {
let imgSvg = "";
const contentHash = crypto.randomUUID().slice(0, 8);
const fileName = \`typst-\${contentHash}.svg\`;

const emitSvgDir = ${JSON.stringify(config.emitSvgDir ?? "typst")};
const base = ${JSON.stringify(options.base ?? "/")};
let publicUrl = path.join(base, emitSvgDir, fileName);

if (false) { // 'build' mode: import.meta.env.PROD
const emitName = path.join(emitSvgDir, fileName);
const respId = this.emitFile({
type: 'asset',
fileName: emitName,
source: Buffer.from(toRender, 'utf-8'),
});
imgSvg = \`<img src='\${publicUrl}' />\`;
} else { // 'serve' mode inlines svg as base64
imgSvg = \`<img src="data:image/svg+xml;base64,\${Buffer.from(toRender, 'utf-8').toString('base64')}" />\`;
}
toRender = imgSvg;
}

return render(toRender);
});

export default Content;
Expand Down