sitegen/framework/generate2.ts

260 lines
7.8 KiB
TypeScript
Raw Normal View History

2025-07-30 11:21:52 -07:00
export async function main() {
// const startTime = performance.now();
// -- readdir to find all site files --
const siteConfig = await incr.work({
label: "reading manifest",
run: (io) => io.import<{ siteSections: sg.Section[] }>("site.ts"),
});
const {
staticFiles,
scripts,
views,
pages,
} = (await Promise.all(
siteConfig.siteSections.map(({ root: sectionRoot }) =>
incr.work({
key: sectionRoot,
label: "discovering files in " + sectionRoot,
run: (io) => scanSiteSection(io, sectionRoot),
})
),
)).reduce((acc, next) => ({
staticFiles: acc.staticFiles.concat(next.staticFiles),
pages: acc.pages.concat(next.pages),
views: acc.views.concat(next.views),
scripts: acc.scripts.concat(next.scripts),
}));
const globalCssPath = path.join(hot.projectSrc, "global.css");
// TODO: loadMarkoCache
const builtPages = pages.map((item) =>
incr.work({
label: item.id,
key: item,
async run(io) {
// -- load and validate module --
let {
default: Page,
meta: metadata,
theme: pageTheme,
layout,
} = await io.import<any>(item.file);
if (!Page) {
throw new Error("Page is missing a 'default' export.");
}
if (!metadata) {
throw new Error("Page is missing 'meta' export with a title.");
}
// -- css --
if (layout?.theme) pageTheme = layout.theme;
const theme: css.Theme = {
...css.defaultTheme,
...pageTheme,
};
const cssImports = Array.from(
new Set([globalCssPath, ...hot.getCssImports(item.file)]),
(file) => path.relative(hot.projectSrc, file),
);
// -- metadata --
const renderedMetaPromise = Promise.resolve(
typeof metadata === "function" ? metadata({ ssr: true }) : metadata,
).then((m) => meta.renderMeta(m));
// -- html --
let page = [engine.kElement, Page, {}];
if (layout?.default) {
page = [engine.kElement, layout.default, { children: page }];
}
const bodyPromise = engine.ssrAsync(page, {
sitegen: sg.initRender(),
});
const [{ text, addon }, renderedMeta] = await Promise.all([
bodyPromise,
renderedMetaPromise,
]);
if (!renderedMeta.includes("<title>")) {
throw new Error(
"Page is missing 'meta.title'. " +
"All pages need a title tag.",
);
}
const styleKey = css.styleKey(cssImports, theme);
return {
html: text,
meta: renderedMeta,
cssImports,
theme: theme ?? null,
styleKey,
clientRefs: Array.from(addon.sitegen.scripts),
};
},
})
);
// const builtViews = views.map((item) =>
// incr.work({
// label: item.id,
// key: item,
// async run(io) {
// const module = require(item.file);
// if (!module.meta) {
// throw new Error(`${item.file} is missing 'export const meta'`);
// }
// if (!module.default) {
// throw new Error(`${item.file} is missing a default export.`);
// }
// const pageTheme = module.layout?.theme ?? module.theme;
// const theme: css.Theme = {
// ...css.defaultTheme,
// ...pageTheme,
// };
// const cssImports = Array.from(
// new Set([globalCssPath, ...hot.getCssImports(item.file)]),
// (file) => path.relative(hot.projectSrc, file),
// );
// const styleKey = css.styleKey(cssImports, theme);
// return {
// file: path.relative(hot.projectRoot, item.file),
// cssImports,
// theme,
// clientRefs: hot.getClientScriptRefs(item.file),
// hasLayout: !!module.layout?.default,
// styleKey,
// };
// },
// })
// );
//
// // -- inline style sheets, used and shared by pages and views --
// const builtCss = Promise.all([...builtViews, ...builtPages]).then((items) => {
// const map = new Map<string, {}>();
// for (const { styleKey, cssImports, theme } of items) {
// if (map.has(styleKey)) continue;
// map.set(
// styleKey,
// incr.work({
// label: `bundle css ${styleKey}`,
// async run(io) {
// await Promise.all(cssImports.map((file) => io.trackFile(file)));
// const { text } = await css.bundleCssFiles(cssImports, theme);
// return text;
// },
// }),
// );
// }
// });
// TODO: make sure that `static` and `pages` does not overlap
await Promise.all(builtPages);
incr.serializeToDisk();
// -- bundle server javascript (backend and views) --
}
async function scanSiteSection(io: incr.Io, sectionRoot: string) {
// 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[] = [];
const sectionPath = (...sub: string[]) => path.join(sectionRoot, ...sub);
const rootPrefix = hot.projectSrc === sectionRoot
? ""
: path.relative(hot.projectSrc, sectionRoot) + "/";
const kinds = [
{
dir: sectionPath("pages"),
list: pages,
prefix: "/",
include: [".tsx", ".mdx", ".marko"],
exclude: [".client.ts", ".client.tsx"],
},
{
dir: sectionPath("static"),
list: staticFiles,
prefix: "/",
ext: true,
},
{
dir: sectionPath("scripts"),
list: scripts,
prefix: rootPrefix,
include: [".client.ts", ".client.tsx"],
},
{
dir: sectionPath("views"),
list: views,
prefix: rootPrefix,
include: [".tsx", ".mdx", ".marko"],
exclude: [".client.ts", ".client.tsx"],
},
];
for (const kind of kinds) {
const {
dir,
list,
prefix,
include = [""],
exclude = [],
ext = false,
} = kind;
let items;
try {
items = await io.readDirRecursive(dir);
} catch (err: any) {
if (err.code === "ENOENT") continue;
throw err;
}
for (const subPath of items) {
const file = path.join(dir, subPath);
const stat = fs.statSync(file);
if (stat.isDirectory()) continue;
if (!include.some((e) => subPath.endsWith(e))) continue;
if (exclude.some((e) => subPath.endsWith(e))) continue;
const trim = ext
? 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) || "/";
}
list.push({ id, file: file });
}
}
return { staticFiles, pages, views, scripts };
}
import * as sg from "#sitegen";
import * as incr from "./incremental2.ts";
import { OnceMap, Queue } from "#sitegen/async";
import * as bundle from "./bundle.ts";
import * as css from "./css.ts";
import * as engine from "./engine/ssr.ts";
import * as hot from "./hot.ts";
import * as fs from "#sitegen/fs";
import type { FileItem } from "#sitegen";
import * as path from "node:path";
import * as meta from "#sitegen/meta";
import { Spinner, withSpinner } from "@paperclover/console/Spinner";
import { wrapDocument } from "./lib/view.ts";