158 lines
4.5 KiB
TypeScript
158 lines
4.5 KiB
TypeScript
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<string[]> {
|
|
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<string> {
|
|
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<any> {
|
|
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<string, unknown>)[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<string, unknown>,
|
|
context: {
|
|
params: Record<string, string>;
|
|
locals: Record<string, unknown>;
|
|
},
|
|
) {
|
|
const path = resolve(file);
|
|
const component = await compileAstro(await readFile(path, "utf8"), path);
|
|
const container = await AstroContainer.create();
|
|
const slots: Record<string, string> = {};
|
|
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,
|
|
});
|
|
},
|
|
};
|
|
}
|