sitegen/framework/generate.ts

301 lines
9.2 KiB
TypeScript
Raw Normal View History

2025-06-07 16:45:45 -07:00
export function main() {
return withSpinner({
text: "Recovering State",
successText: ({ elapsed }) =>
"sitegen! update in " + elapsed.toFixed(1) + "s",
failureText: () => "sitegen FAIL",
}, sitegen);
}
2025-06-07 02:25:06 -07:00
async function sitegen(status: Spinner) {
const startTime = performance.now();
2025-06-07 02:25:06 -07:00
let root = path.resolve(import.meta.dirname, "../src");
const join = (...sub: string[]) => path.join(root, ...sub);
2025-06-09 00:12:41 -07:00
const incr = Incremental.fromDisk();
await incr.statAllFiles();
2025-06-07 02:25:06 -07:00
// Sitegen reviews every defined section for resources to process
2025-06-08 15:12:04 -07:00
const sections: sg.Section[] =
require(path.join(root, "site.ts")).siteSections;
2025-06-07 02:25:06 -07:00
// Static files are compressed and served as-is.
// - "{section}/static/*.png"
let staticFiles: FileItem[] = [];
// Pages are rendered then served as static files.
// - "{section}/pages/*.marko"
let pages: FileItem[] = [];
// Views are dynamically rendered pages called via backend code.
// - "{section}/views/*.tsx"
let views: FileItem[] = [];
// Public scripts are bundled for the client as static assets under "/js/[...]"
// This is used for the file viewer's canvases.
// Note that '.client.ts' can be placed anywhere in the file structure.
// - "{section}/scripts/*.client.ts"
let scripts: FileItem[] = [];
2025-06-07 02:25:06 -07:00
// -- Scan for files --
status.text = "Scanning Project";
for (const section of sections) {
const { root: sectionRoot } = section;
2025-06-07 16:45:45 -07:00
const sectionPath = (...sub: string[]) => path.join(sectionRoot, ...sub);
const rootPrefix = root === sectionRoot
? ""
: path.relative(root, sectionRoot) + "/";
const kinds = [
{
dir: sectionPath("pages"),
list: pages,
prefix: "/",
exclude: [".css", ".client.ts", ".client.tsx"],
},
{ dir: sectionPath("static"), list: staticFiles, prefix: "/", ext: true },
{ dir: sectionPath("scripts"), list: scripts, prefix: rootPrefix },
2025-06-07 02:25:06 -07:00
{
dir: sectionPath("views"),
list: views,
prefix: rootPrefix,
exclude: [".css", ".client.ts", ".client.tsx"],
},
];
for (const { dir, list, prefix, exclude = [], ext = false } of kinds) {
2025-06-07 02:25:06 -07:00
const items = fs.readDirRecOptionalSync(dir);
2025-06-09 00:12:41 -07:00
item: for (const subPath of items) {
const file = path.join(dir, subPath);
const stat = fs.statSync(file);
if (stat.isDirectory()) continue;
2025-06-07 02:25:06 -07:00
for (const e of exclude) {
2025-06-09 00:12:41 -07:00
if (subPath.endsWith(e)) continue item;
}
const trim = ext
2025-06-09 00:12:41 -07:00
? subPath
: subPath.slice(0, -path.extname(subPath).length).replaceAll(
".",
"/",
);
let id = prefix + trim.replaceAll("\\", "/");
if (prefix === "/" && id.endsWith("/index")) {
id = id.slice(0, -"/index".length) || "/";
}
2025-06-09 00:12:41 -07:00
list.push({ id, file: file });
}
}
}
scripts = scripts.filter(({ file }) => !file.match(/\.client\.[tj]sx?/));
const globalCssPath = join("global.css");
2025-06-07 02:25:06 -07:00
// TODO: invalidate incremental resources
// -- server side render --
status.text = "Building";
2025-06-09 00:12:41 -07:00
const cssOnce = new OnceMap<css.Output>();
const cssQueue = new Queue<[string, string[], css.Theme], css.Output>({
name: "Bundle",
fn: ([, files, theme]) => css.bundleCssFiles(files, theme),
passive: true,
getItemText: ([id]) => id,
maxJobs: 2,
});
2025-06-07 02:25:06 -07:00
interface RenderResult {
body: string;
head: string;
2025-06-09 00:12:41 -07:00
css: css.Output;
2025-06-07 02:25:06 -07:00
scriptFiles: string[];
item: FileItem;
}
2025-06-07 02:25:06 -07:00
const renderResults: RenderResult[] = [];
async function loadPageModule({ file }: FileItem) {
require(file);
}
async function renderPage(item: FileItem) {
// -- load and validate module --
2025-06-09 21:13:51 -07:00
let {
default: Page,
meta: metadata,
theme: pageTheme,
layout,
} = require(item.file);
2025-06-07 02:25:06 -07:00
if (!Page) throw new Error("Page is missing a 'default' export.");
if (!metadata) {
2025-06-07 02:25:06 -07:00
throw new Error("Page is missing 'meta' export with a title.");
}
2025-06-07 02:25:06 -07:00
if (layout?.theme) pageTheme = layout.theme;
const theme = {
bg: "#fff",
fg: "#050505",
primary: "#2e7dab",
2025-06-07 02:25:06 -07:00
...pageTheme,
};
2025-06-07 02:25:06 -07:00
// -- metadata --
const renderedMetaPromise = Promise.resolve(
typeof metadata === "function" ? metadata({ ssr: true }) : metadata,
2025-06-07 16:45:45 -07:00
).then((m) => meta.renderMeta(m));
2025-06-07 02:25:06 -07:00
// -- css --
const cssImports = [globalCssPath, ...hot.getCssImports(item.file)];
const cssPromise = cssOnce.get(
cssImports.join(":") + JSON.stringify(theme),
2025-06-07 02:25:06 -07:00
() => cssQueue.add([item.id, cssImports, theme]),
);
2025-06-07 02:25:06 -07:00
// -- html --
2025-06-09 21:13:51 -07:00
let page = [engine.kElement, Page, {}];
2025-06-09 00:12:41 -07:00
if (layout?.default) {
2025-06-09 21:13:51 -07:00
page = [engine.kElement, layout.default, { children: page }];
2025-06-09 00:12:41 -07:00
}
2025-06-09 21:13:51 -07:00
const bodyPromise = engine.ssrAsync(page, {
2025-06-07 16:45:45 -07:00
sitegen: sg.initRender(),
2025-06-07 02:25:06 -07:00
});
2025-06-09 00:12:41 -07:00
const [{ text, addon }, cssBundle, renderedMeta] = await Promise.all([
2025-06-07 02:25:06 -07:00
bodyPromise,
cssPromise,
renderedMetaPromise,
]);
if (!renderedMeta.includes("<title>")) {
throw new Error(
2025-06-07 02:25:06 -07:00
"Page is missing 'meta.title'. " +
"All pages need a title tag.",
);
}
2025-06-07 02:25:06 -07:00
// The script content is not ready, allow another page to Render. The page
// contents will be rebuilt at the end. This is more convenient anyways
// because it means client scripts don't re-render the page.
renderResults.push({
2025-06-07 16:45:45 -07:00
body: text,
head: renderedMeta,
2025-06-09 00:12:41 -07:00
css: cssBundle,
2025-06-07 16:45:45 -07:00
scriptFiles: Array.from(addon.sitegen.scripts),
2025-06-07 02:25:06 -07:00
item: item,
});
}
2025-06-07 02:25:06 -07:00
// This is done in two passes so that a page that throws during evaluation
// will report "Load Render Module" instead of "Render Static Page".
2025-06-09 00:12:41 -07:00
const neededPages = pages.filter((page) => incr.needsBuild("asset", page.id));
const spinnerFormat = status.format;
status.format = () => "";
2025-06-07 02:25:06 -07:00
const moduleLoadQueue = new Queue({
name: "Load Render Module",
2025-06-07 02:25:06 -07:00
fn: loadPageModule,
getItemText,
maxJobs: 1,
});
2025-06-09 00:12:41 -07:00
moduleLoadQueue.addMany(neededPages);
await moduleLoadQueue.done({ method: "stop" });
2025-06-07 02:25:06 -07:00
const pageQueue = new Queue({
name: "Render Static Page",
fn: renderPage,
getItemText,
maxJobs: 2,
});
2025-06-09 00:12:41 -07:00
pageQueue.addMany(neededPages);
await pageQueue.done({ method: "stop" });
status.format = spinnerFormat;
2025-06-07 02:25:06 -07:00
2025-06-09 21:13:51 -07:00
// -- bundle backend and views --
status.text = "Bundle backend code";
const {} = await bundle.bundleServerJavaScript(
join("backend.ts"),
views,
);
2025-06-07 02:25:06 -07:00
// -- bundle scripts --
const referencedScripts = Array.from(
2025-06-07 02:25:06 -07:00
new Set(renderResults.flatMap((r) => r.scriptFiles)),
);
const extraPublicScripts = scripts.map((entry) => entry.file);
const uniqueCount = new Set([
...referencedScripts,
...extraPublicScripts,
]).size;
status.text = `Bundle ${uniqueCount} Scripts`;
await bundle.bundleClientJavaScript(
referencedScripts,
extraPublicScripts,
incr,
);
2025-06-07 02:25:06 -07:00
// -- copy/compress static files --
async function doStaticFile(item: FileItem) {
const body = await fs.readFile(item.file);
2025-06-08 15:12:04 -07:00
await incr.putAsset({
2025-06-09 00:12:41 -07:00
sources: [item.file],
2025-06-07 02:25:06 -07:00
key: item.id,
body,
});
}
2025-06-07 02:25:06 -07:00
const staticQueue = new Queue({
name: "Load Static",
fn: doStaticFile,
getItemText,
maxJobs: 16,
});
status.format = () => "";
2025-06-09 00:12:41 -07:00
staticQueue.addMany(
staticFiles.filter((file) => incr.needsBuild("asset", file.id)),
);
await staticQueue.done({ method: "stop" });
status.format = spinnerFormat;
2025-06-07 02:25:06 -07:00
// -- concatenate static rendered pages --
status.text = `Concat ${renderResults.length} Pages`;
await Promise.all(
2025-06-07 02:25:06 -07:00
renderResults.map(
2025-06-09 00:12:41 -07:00
async (
{ item: page, body, head, css, scriptFiles },
) => {
2025-06-07 02:25:06 -07:00
const doc = wrapDocument({
body,
head,
2025-06-09 00:12:41 -07:00
inlineCss: css.text,
2025-06-07 02:25:06 -07:00
scripts: scriptFiles.map(
(id) =>
UNWRAP(
incr.out.script.get(
path.basename(id).replace(/\.client\.[jt]sx?$/, ""),
),
),
2025-06-07 02:25:06 -07:00
).map((x) => `{${x}}`).join("\n"),
});
2025-06-08 15:12:04 -07:00
await incr.putAsset({
2025-06-09 00:12:41 -07:00
sources: [page.file, ...css.sources],
2025-06-07 02:25:06 -07:00
key: page.id,
body: doc,
headers: {
"Content-Type": "text/html",
},
});
},
),
);
status.format = () => "";
status.text = ``;
2025-06-07 02:25:06 -07:00
// This will wait for all compression jobs to finish, which up
// to this point have been left as dangling promises.
await incr.wait();
2025-06-07 02:25:06 -07:00
// Flush the site to disk.
status.format = spinnerFormat;
status.text = `Incremental Flush`;
2025-06-09 21:13:51 -07:00
incr.flush(); // Write outputs
2025-06-09 00:12:41 -07:00
incr.toDisk(); // Allows picking up this state again
2025-06-07 02:25:06 -07:00
return { elapsed: (performance.now() - startTime) / 1000 };
}
function getItemText({ file }: FileItem) {
return path.relative(hot.projectSrc, file).replaceAll("\\", "/");
}
import { OnceMap, Queue } from "./queue.ts";
import { Incremental } from "./incremental.ts";
import * as bundle from "./bundle.ts";
import * as css from "./css.ts";
2025-06-09 21:13:51 -07:00
import * as engine from "./engine/ssr.ts";
2025-06-07 02:25:06 -07:00
import * as hot from "./hot.ts";
2025-06-08 17:31:03 -07:00
import * as fs from "#sitegen/fs";
import * as sg from "#sitegen";
2025-06-09 21:13:51 -07:00
import type { FileItem } from "#sitegen";
2025-06-07 02:25:06 -07:00
import * as path from "node:path";
2025-06-08 17:31:03 -07:00
import * as meta from "#sitegen/meta";
import { Spinner, withSpinner } from "@paperclover/console/Spinner";
2025-06-09 21:13:51 -07:00
import { wrapDocument } from "./lib/view.ts";