import { existsSync } from "node:fs"; import { readdir, readFile } from "node:fs/promises"; import { join, relative, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { transform } from "@astrojs/compiler"; import { experimental_AstroContainer as AstroContainer } from "astro/container"; import ts from "typescript"; import type { Integration, IntegrationContext } from "muddle/contracts"; export interface AstroIntegrationOptions { dir?: string; partials?: boolean; } function routeFor(root: string, file: string): string { const path = relative(root, file) .replace(/\\/g, "/") .replace(/\.astro$/, ""); return `/${path.replace(/index$/, "").replace(/\/+/g, "/") || ""}` || "/"; } export function astroIntegration( options: AstroIntegrationOptions = {}, ): Integration { return { name: "astro", async setup(context: IntegrationContext) { context.loader("astro", { name: "astro", extensions: [".astro"], async load(file) { return { data: {}, content: await renderAstro(file.source, undefined, file.path), }; }, }); const dir = resolve(context.config.root, options.dir ?? "src/pages"); if (!existsSync(dir)) return; for (const file of await astroFiles(dir)) { const route = routeFor(dir, file); context.route( "GET", route, async (request) => new Response( await renderAstro(await readFile(file, "utf8"), request, file), { headers: { "content-type": "text/html; charset=utf-8", }, }, ), ); } }, }; } async function astroFiles(dir: string): Promise { const files: string[] = []; for (const entry of await readdir(dir, { withFileTypes: true })) { const path = join(dir, entry.name); if (entry.isDirectory()) files.push(...(await astroFiles(path))); else if (entry.name.endsWith(".astro")) files.push(path); } return files; } export async function renderAstro( source: string, request = new Request("http://localhost/"), filename = resolve("inline.astro"), ): Promise { const component = await compileAstro(source, filename); const container = await AstroContainer.create(); return container.renderToString(component, { request }); } async function compileAstro( source: string, filename: string, ancestors: string[] = [], ): Promise { if (ancestors.includes(filename)) { throw new Error(`Circular Astro import ${filename}`); } const result = await transform(source, { filename, internalURL: "astro/runtime/server/index.js", }); let code = result.code .replace(/,\s*createMetadata as \$\$createMetadata/, "") .replace(/export const \$\$metadata = [^\n]+\n/g, ""); for (const match of [...code.matchAll(/from\s+["']([^"']+)["']/g)]) { const specifier = match[1]!; let url: string; if (specifier.endsWith(".astro")) { const file = resolve(filename, "..", specifier); const child = await compileAstro(await readFile(file, "utf8"), file, [ ...ancestors, filename, ]); const key = `__muddle_astro_${crypto.randomUUID().replaceAll("-", "")}`; (globalThis as Record)[key] = child; url = `data:text/javascript;base64,${Buffer.from( `export default globalThis[${JSON.stringify(key)}]`, ).toString("base64")}`; } else { url = specifier.startsWith(".") ? pathToFileURL(resolve(filename, "..", specifier)).href : import.meta.resolve(specifier); } code = code .replaceAll(`"${specifier}"`, JSON.stringify(url)) .replaceAll(`'${specifier}'`, JSON.stringify(url)); } code = ts.transpileModule(code, { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022, }, }).outputText; return ( await import( `data:text/javascript;base64,${Buffer.from(code).toString("base64")}` ) ).default; } /** Import into TemplateContext with `.import("Card", astroComponent("./Card.astro"))`. */ export function astroComponent(file: string) { return { async render( props: Record, context: { params: Record; locals: Record; }, ) { const path = resolve(file); const component = await compileAstro(await readFile(path, "utf8"), path); const container = await AstroContainer.create(); const slots: Record = {}; for (const [key, value] of Object.entries(props)) { if (typeof value === "function") { slots[key === "content" ? "default" : key] = await value(); } } return container.renderToString(component, { props, params: context.params, locals: context.locals, slots, }); }, }; }