sitegen/framework/css.ts
chloe caruso c5ac450f21 feat: dynamic page regeneration (#24)
the asset system is reworked to support "dynamic" entries, where each
entry is a separate file on disk containing the latest generation's
headers+raw+gzip+zstd. when calling view.regenerate, it will look for
pages that had "export const regenerate" during generation, and render
those pages using the view system, but then store the results as assets
instead of sending as a response.

pages configured as regenerable are also bundled as views, using the
non-aliasing key "page:${page.id}". this cannot alias because file
paths may not contain a colon.
2025-08-11 22:43:27 -07:00

117 lines
2.9 KiB
TypeScript

export interface Theme {
bg: string;
fg: string;
primary?: string;
h1?: string;
}
export const defaultTheme: Theme = {
bg: "#ffffff",
fg: "#050505",
primary: "#2e7dab",
};
export function stringifyTheme(theme: Theme) {
return [
":root {",
"--bg: " + theme.bg + ";",
"--fg: " + theme.fg + ";",
theme.primary ? "--primary: " + theme.primary + ";" : null,
"}",
theme.h1 ? "h1 { color: " + theme.h1 + "}" : null,
].filter(Boolean).join("\n");
}
export function preprocess(css: string, theme: Theme): string {
const keys = Object.keys(theme);
const regex = new RegExp(
`([{};\\n][^{};\\n]*var\\(--(${keys.join("|")})\\).*?(?=[;{}\\n]))`,
"gs",
);
const regex2 = new RegExp(`var\\(--(${keys.join("|")})\\)`);
return css.replace(
regex,
(_, line) =>
line.replace(
regex2,
(_: string, varName: string) => theme[varName as keyof Theme],
) +
";" + line.slice(1),
);
}
export function styleKey(
cssImports: string[],
theme: Theme,
) {
cssImports = cssImports
.map((file) =>
(path.isAbsolute(file) ? path.relative(hot.projectSrc, file) : file)
.replaceAll("\\", "/")
)
.sort();
return cssImports.join(":") + ":" +
Object.entries(theme).map(([k, v]) => `${k}=${v}`);
}
export async function bundleCssFiles(
io: Io,
{ cssImports, theme, dev }: {
cssImports: string[];
theme: Theme;
dev: boolean;
},
) {
cssImports = await Promise.all(
cssImports.map((file) => io.trackFile("src/" + file)),
);
const plugin = {
name: "clover css",
setup(b) {
b.onLoad(
{ filter: /\.css$/ },
async ({ path: file }) => ({
loader: "css",
contents: preprocess(await fs.readFile(file, "utf-8"), theme),
}),
);
},
} satisfies esbuild.Plugin;
const build = await esbuild.build({
bundle: true,
entryPoints: ["$input$"],
external: ["*.woff2", "*.ttf", "*.png", "*.jpeg"],
metafile: true,
minify: !dev,
plugins: [
virtualFiles({
"$input$": {
contents: cssImports.map((path) =>
`@import url(${JSON.stringify(path)});`
)
.join("\n") + stringifyTheme(theme),
loader: "css",
},
}),
plugin,
],
target: ["ie11"],
write: false,
});
const { errors, warnings, outputFiles, metafile } = build;
if (errors.length > 0) {
throw new AggregateError(errors, "CSS Build Failed");
}
if (warnings.length > 0) {
throw new AggregateError(warnings, "CSS Build Failed");
}
if (outputFiles.length > 1) throw new Error("Too many output files");
return outputFiles[0].text;
}
import * as esbuild from "esbuild";
import * as fs from "#sitegen/fs";
import * as hot from "./hot.ts";
import * as path from "node:path";
import { virtualFiles } from "./esbuild-support.ts";
import type { Io } from "./incremental.ts";