sitegen/framework/sitegen.tsx

222 lines
6.7 KiB
TypeScript

// Sitegen! Clover's static site generator, built with love.
function main() {
return withSpinner({
text: "Recovering State",
successText: ({ elapsed }) =>
"sitegen! update in " + elapsed.toFixed(1) + "s",
failureText: () => "sitegen FAIL",
}, sitegen);
}
async function sitegen(status) {
const startTime = performance.now();
let root = path.resolve(import_meta.dirname, "../src");
const join = (...sub) => path.join(root, ...sub);
const incr = new Incremental();
const sections: Section[] =
require(path.join(root, "sections.ts")).siteSections;
const views = [];
const staticFiles = [];
let pages = [];
let scripts = [];
const backendFiles = [];
status.text = "Scanning Project";
for (const section of sections) {
const { root: sectionRoot } = section;
const sectionPath = (...sub) => 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 },
{ dir: sectionPath("views"), list: views, prefix: rootPrefix },
];
for (const { dir, list, prefix, exclude = [], ext = false } of kinds) {
const pages2 = fs.readDirRecOptional(dir);
page: for (const page of pages2) {
if (page.isDirectory()) continue;
for (const ext2 of exclude) {
if (page.name.endsWith(ext2)) continue page;
}
const file = path.relative(dir, page.parentPath + "/" + page.name);
const trim = ext
? file
: file.slice(0, -path.extname(file).length).replaceAll(".", "/");
let id = prefix + trim.replaceAll("\\", "/");
if (prefix === "/" && id.endsWith("/index")) {
id = id.slice(0, -"/index".length) || "/";
}
list.push({ id, file: path.join(page.parentPath, page.name) });
}
}
let backendFile = [
sectionPath("backend.ts"),
sectionPath("backend.tsx"),
].find((file) => fs.existsSync(file));
if (backendFile) backendFiles.push(backendFile);
}
scripts = scripts.filter(({ file }) => !file.match(/\.client\.[tj]sx?/));
const globalCssPath = join("global.css");
status.text = "Building";
const cssOnce = new OnceMap();
const cssQueue = new Queue({
name: "Bundle",
fn: ([, files, theme]) => css.bundleCssFiles(files, theme),
passive: true,
getItemText: ([id]) => id,
maxJobs: 2,
});
const ssrResults = [];
function loadSsrModule(page) {
require(page.file);
}
async function doSsrPage(page) {
const module2 = require(page.file);
const Page = module2.default;
if (!Page) {
throw new Error("Page is missing a 'default' export.");
}
const metadata = module2.meta;
if (!metadata) {
throw new Error("Page is missing 'meta' attribute with a title.");
}
const theme = {
bg: "#fff",
fg: "#050505",
primary: "#2e7dab",
...module2.theme,
};
const renderedMetaPromise = Promise.resolve(
typeof metadata === "function" ? metadata({ ssr: true }) : metadata,
).then((m) => meta.resolveAndRenderMetadata(m));
const cssImports = [globalCssPath, ...hot.getCssImports(page.file)];
const cssPromise = cssOnce.get(
cssImports.join(":") + JSON.stringify(theme),
() => cssQueue.add([page.id, cssImports, theme]),
);
const sitegenApi = sg.initRender();
const body = await (0, import_ssr.ssrAsync)(
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Page, {}),
{
sitegen: sitegenApi,
},
);
const inlineCss = await cssPromise;
const renderedMeta = await renderedMetaPromise;
if (!renderedMeta.includes("<title>")) {
throw new Error(
"Page is missing 'meta.title'. All pages need a title tag.",
);
}
ssrResults.push({
body,
head: renderedMeta,
inlineCss,
scriptFiles: Array.from(sitegenApi.scripts),
page,
});
}
const spinnerFormat = status.format;
status.format = () => "";
const moduleLoadQueue = new import_queue.Queue({
name: "Load Render Module",
fn: loadSSRModule,
getItemText,
maxJobs: 1,
});
moduleLoadQueue.addMany(pages);
await moduleLoadQueue.done({ method: "stop" });
const pageQueue = new import_queue.Queue({
name: "Render",
fn: doSsrPage,
getItemText,
maxJobs: 2,
});
pageQueue.addMany(pages);
await pageQueue.done({ method: "stop" });
status.format = spinnerFormat;
const referencedScripts = Array.from(
new Set(ssrResults.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,
);
async function doStaticFile(page) {
const body = await fs.readFile(page.file);
incr.putAsset({
srcId: "static:" + page.file,
key: page.id,
body,
});
}
const staticQueue = new import_queue.Queue({
name: "Load Static",
fn: doStaticFile,
getItemText,
maxJobs: 16,
});
status.format = () => "";
staticQueue.addMany(staticFiles);
await staticQueue.done({ method: "stop" });
status.format = spinnerFormat;
status.text = `Concat ${ssrResults.length} Pages`;
await Promise.all(
ssrResults.map(async ({ page, body, head, inlineCss, scriptFiles }) => {
const doc = wrapDocument({
body,
head,
inlineCss,
scripts: scriptFiles.map(
(id) =>
UNWRAP(
incr.out.script.get(
path.basename(id).replace(/\.client\.[jt]sx?$/, ""),
),
),
).map((x) => `{${x}}`).join(""),
});
incr.putAsset({
srcId: "page:" + page.file,
key: page.id,
body: doc,
headers: {
"Content-Type": "text/html",
},
});
}),
);
status.format = () => "";
status.text = ``;
await incr.wait();
status.format = spinnerFormat;
status.text = `Incremental Flush`;
incr.flush();
incr.serializeToDisk();
return { elapsed: (performance.now() - startTime) / 1e3 };
}
import type { Section } from "./sitegen-lib.ts";
import { OnceMap, Queue } from "./queue.ts";
import { Incremental } from "./incremental.ts";
import * as bundle from "./bundle.ts";
import * as css from "./css.ts";
import * as fs from './fs.ts';
import { withSpinner, Spinner } from "@paperclover/console/Spinner";