2025-06-11 00:17:58 -07:00
|
|
|
// This file contains the main site generator build process.
|
2025-08-02 17:31:58 -07:00
|
|
|
// By using `incr.work`'s ability to cache work between runs,
|
|
|
|
// the site generator is very fast to re-run.
|
|
|
|
//
|
|
|
|
// See `watch.ts` for a live development environment.
|
|
|
|
const { toRel, toAbs } = incr;
|
|
|
|
const globalCssPath = toAbs("src/global.css");
|
2025-06-11 00:17:58 -07:00
|
|
|
|
2025-08-02 17:31:58 -07:00
|
|
|
export async function main() {
|
2025-08-11 22:43:27 -07:00
|
|
|
if (!process.argv.includes("-f")) await incr.restore();
|
2025-08-02 17:31:58 -07:00
|
|
|
await incr.compile(generate);
|
2025-06-07 01:24:32 -07:00
|
|
|
}
|
|
|
|
|
2025-08-02 17:31:58 -07:00
|
|
|
export async function generate() {
|
|
|
|
// -- read config and discover files --
|
|
|
|
const siteConfig = await incr.work(readManifest);
|
2025-08-15 22:30:58 -07:00
|
|
|
const metaTemplate: meta.Template = {
|
|
|
|
...siteConfig.meta,
|
|
|
|
base: new URL(siteConfig.meta.base),
|
|
|
|
};
|
|
|
|
const {
|
|
|
|
staticFiles,
|
|
|
|
scripts,
|
|
|
|
views,
|
|
|
|
pages,
|
|
|
|
} = await discoverAllFiles(siteConfig);
|
2025-06-10 20:06:32 -07:00
|
|
|
|
2025-08-02 17:31:58 -07:00
|
|
|
// TODO: make sure that `static` and `pages` does not overlap
|
|
|
|
|
|
|
|
// TODO: loadMarkoCache
|
|
|
|
|
2025-08-14 20:35:33 -07:00
|
|
|
// -- start font work --
|
|
|
|
const builtFonts = fonts.buildFonts(siteConfig.fonts);
|
|
|
|
|
2025-08-02 17:31:58 -07:00
|
|
|
// -- perform build-time rendering --
|
2025-08-15 22:30:58 -07:00
|
|
|
const builtPages = pages.map((item) =>
|
|
|
|
incr.work(preparePage, { item, metaTemplate })
|
|
|
|
);
|
2025-08-02 17:31:58 -07:00
|
|
|
const builtViews = views.map((item) => incr.work(prepareView, item));
|
2025-08-02 19:22:07 -07:00
|
|
|
const builtStaticFiles = Promise.all(
|
|
|
|
staticFiles.map((item) =>
|
|
|
|
incr.work(
|
|
|
|
async (io, { id, file }) =>
|
2025-08-11 22:43:27 -07:00
|
|
|
void (await io.writeAsset({
|
|
|
|
pathname: id,
|
|
|
|
buffer: await io.readFile(file),
|
|
|
|
})),
|
2025-08-02 19:22:07 -07:00
|
|
|
item,
|
2025-08-11 22:43:27 -07:00
|
|
|
)
|
2025-08-02 19:22:07 -07:00
|
|
|
),
|
|
|
|
);
|
2025-08-02 17:31:58 -07:00
|
|
|
const routes = await Promise.all([...builtViews, ...builtPages]);
|
|
|
|
|
2025-08-11 22:43:27 -07:00
|
|
|
const viewsAndDynPages: incr.Ref<PageOrView>[] = [
|
|
|
|
...builtViews,
|
|
|
|
...builtPages.filter((page) => UNWRAP(page.value).regenerate),
|
|
|
|
];
|
|
|
|
|
2025-08-02 17:31:58 -07:00
|
|
|
// -- page resources --
|
|
|
|
const scriptMap = incr.work(bundle.bundleClientJavaScript, {
|
|
|
|
clientRefs: routes.flatMap((x) => x.clientRefs),
|
|
|
|
extraPublicScripts: scripts.map((entry) => entry.file),
|
|
|
|
dev: false,
|
|
|
|
});
|
|
|
|
const styleMap = prepareInlineCss(routes);
|
|
|
|
|
|
|
|
// -- backend --
|
|
|
|
const builtBackend = bundle.bundleServerJavaScript({
|
|
|
|
entries: siteConfig.backends,
|
2025-08-02 19:22:07 -07:00
|
|
|
platform: "node",
|
2025-08-02 17:31:58 -07:00
|
|
|
styleMap,
|
|
|
|
scriptMap,
|
2025-08-11 22:43:27 -07:00
|
|
|
viewItems: viewsAndDynPages.map((ref) => {
|
|
|
|
const { id, file, type } = UNWRAP(ref.value);
|
|
|
|
return { id: type === "page" ? `page:${id}` : id, file };
|
|
|
|
}),
|
|
|
|
viewRefs: viewsAndDynPages,
|
2025-08-15 22:30:58 -07:00
|
|
|
metaTemplate,
|
2025-08-02 19:22:07 -07:00
|
|
|
});
|
2025-08-02 17:31:58 -07:00
|
|
|
|
|
|
|
// -- assemble page assets --
|
|
|
|
const pAssemblePages = builtPages.map((page) =>
|
2025-08-11 22:43:27 -07:00
|
|
|
assembleAndWritePage(page, styleMap, scriptMap)
|
2025-08-02 17:31:58 -07:00
|
|
|
);
|
|
|
|
|
2025-08-14 20:35:33 -07:00
|
|
|
await Promise.all([
|
|
|
|
builtBackend,
|
|
|
|
builtStaticFiles,
|
|
|
|
...pAssemblePages,
|
|
|
|
builtFonts,
|
|
|
|
]);
|
2025-08-02 17:31:58 -07:00
|
|
|
}
|
2025-06-07 01:24:32 -07:00
|
|
|
|
2025-08-02 17:31:58 -07:00
|
|
|
export async function readManifest(io: Io) {
|
|
|
|
const cfg = await io.import<typeof import("../src/site.ts")>("src/site.ts");
|
|
|
|
return {
|
|
|
|
siteSections: cfg.siteSections.map((section) => ({
|
|
|
|
root: toRel(section.root),
|
|
|
|
})),
|
|
|
|
backends: cfg.backends.map(toRel),
|
2025-08-14 20:35:33 -07:00
|
|
|
fonts: cfg.fonts,
|
2025-08-15 22:30:58 -07:00
|
|
|
meta: {
|
|
|
|
...cfg.meta,
|
|
|
|
base: cfg.meta.base.toString(),
|
|
|
|
},
|
2025-08-02 17:31:58 -07:00
|
|
|
};
|
|
|
|
}
|
2025-06-07 02:25:06 -07:00
|
|
|
|
2025-08-02 17:31:58 -07:00
|
|
|
export async function discoverAllFiles(
|
|
|
|
siteConfig: Awaited<ReturnType<typeof readManifest>>,
|
|
|
|
) {
|
|
|
|
return (
|
|
|
|
await Promise.all(
|
|
|
|
siteConfig.siteSections.map(({ root: sectionRoot }) =>
|
2025-08-11 22:43:27 -07:00
|
|
|
incr.work(scanSiteSection, toAbs(sectionRoot))
|
2025-08-02 17:31:58 -07:00
|
|
|
),
|
|
|
|
)
|
|
|
|
).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),
|
|
|
|
}));
|
|
|
|
}
|
2025-06-07 02:25:06 -07:00
|
|
|
|
2025-08-02 17:31:58 -07:00
|
|
|
export async function scanSiteSection(io: Io, sectionRoot: string) {
|
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 01:24:32 -07:00
|
|
|
|
2025-08-02 17:31:58 -07:00
|
|
|
const sectionPath = (...sub: string[]) => path.join(sectionRoot, ...sub);
|
2025-08-11 22:43:27 -07:00
|
|
|
const rootPrefix = hot.projectSrc === sectionRoot
|
|
|
|
? ""
|
|
|
|
: path.relative(hot.projectSrc, sectionRoot) + "/";
|
2025-08-02 17:31:58 -07:00
|
|
|
const kinds = [
|
|
|
|
{
|
|
|
|
dir: sectionPath("pages"),
|
|
|
|
list: pages,
|
|
|
|
prefix: "/",
|
|
|
|
include: [".tsx", ".mdx", ".marko"],
|
|
|
|
exclude: [".client.ts", ".client.tsx"],
|
2025-06-11 00:17:58 -07:00
|
|
|
},
|
2025-08-02 17:31:58 -07:00
|
|
|
{
|
|
|
|
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;
|
2025-06-11 00:17:58 -07:00
|
|
|
|
2025-08-02 17:31:58 -07:00
|
|
|
let items;
|
|
|
|
try {
|
|
|
|
items = await io.readDirRecursive(dir);
|
|
|
|
} catch (err: any) {
|
|
|
|
if (err.code === "ENOENT") continue;
|
|
|
|
throw err;
|
2025-06-11 00:17:58 -07:00
|
|
|
}
|
2025-08-02 17:31:58 -07:00
|
|
|
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: path.relative(hot.projectRoot, file) });
|
2025-06-07 01:24:32 -07:00
|
|
|
}
|
2025-08-02 17:31:58 -07:00
|
|
|
}
|
2025-06-11 00:17:58 -07:00
|
|
|
|
2025-08-02 17:31:58 -07:00
|
|
|
return { staticFiles, pages, views, scripts };
|
|
|
|
}
|
2025-06-07 02:25:06 -07:00
|
|
|
|
2025-08-15 22:30:58 -07:00
|
|
|
export async function preparePage(
|
|
|
|
io: Io,
|
|
|
|
{ item, metaTemplate }: { item: sg.FileItem; metaTemplate: meta.Template },
|
|
|
|
) {
|
2025-08-02 17:31:58 -07:00
|
|
|
// -- load and validate module --
|
|
|
|
let {
|
|
|
|
default: Page,
|
|
|
|
meta: metadata,
|
|
|
|
theme: pageTheme,
|
|
|
|
layout,
|
2025-08-11 22:43:27 -07:00
|
|
|
regenerate,
|
|
|
|
} = await io.import<sg.PageExports>(item.file);
|
2025-08-02 19:22:07 -07:00
|
|
|
if (!Page) throw new Error("Page is missing a 'default' export.");
|
|
|
|
if (!metadata) throw new Error("Page is missing 'meta' export with a title.");
|
2025-06-11 00:17:58 -07:00
|
|
|
|
2025-08-02 17:31:58 -07:00
|
|
|
// -- 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),
|
|
|
|
);
|
2025-06-11 00:17:58 -07:00
|
|
|
|
2025-08-02 17:31:58 -07:00
|
|
|
// -- metadata --
|
|
|
|
const renderedMetaPromise = Promise.resolve(
|
|
|
|
typeof metadata === "function" ? metadata({ ssr: true }) : metadata,
|
2025-08-15 22:30:58 -07:00
|
|
|
).then((m) => meta.render({
|
|
|
|
canonical: item.id,
|
|
|
|
...m,
|
|
|
|
}, metaTemplate));
|
2025-06-13 00:13:22 -07:00
|
|
|
|
2025-08-02 17:31:58 -07:00
|
|
|
// -- html --
|
2025-08-02 19:22:07 -07:00
|
|
|
let page = render.element(Page);
|
2025-08-02 17:31:58 -07:00
|
|
|
if (layout?.default) {
|
2025-08-02 19:22:07 -07:00
|
|
|
page = render.element(layout.default, { children: page });
|
2025-08-02 17:31:58 -07:00
|
|
|
}
|
2025-08-02 19:22:07 -07:00
|
|
|
const bodyPromise = render.async(page, {
|
|
|
|
[sg.userData.key]: sg.initRender(),
|
2025-06-11 00:17:58 -07:00
|
|
|
});
|
2025-06-09 21:13:51 -07:00
|
|
|
|
2025-08-02 17:31:58 -07:00
|
|
|
const [{ text, addon }, renderedMeta] = await Promise.all([
|
|
|
|
bodyPromise,
|
|
|
|
renderedMetaPromise,
|
2025-06-13 00:13:22 -07:00
|
|
|
]);
|
2025-08-02 17:31:58 -07:00
|
|
|
if (!renderedMeta.includes("<title>")) {
|
|
|
|
throw new Error(
|
|
|
|
"Page is missing 'meta.title'. " + "All pages need a title tag.",
|
|
|
|
);
|
|
|
|
}
|
2025-08-11 22:43:27 -07:00
|
|
|
|
2025-08-02 17:31:58 -07:00
|
|
|
const styleKey = css.styleKey(cssImports, theme);
|
|
|
|
return {
|
2025-08-11 22:43:27 -07:00
|
|
|
type: "page",
|
2025-08-02 17:31:58 -07:00
|
|
|
id: item.id,
|
2025-08-11 22:43:27 -07:00
|
|
|
file: item.file,
|
|
|
|
regenerate,
|
2025-08-02 17:31:58 -07:00
|
|
|
html: text,
|
|
|
|
meta: renderedMeta,
|
|
|
|
cssImports,
|
|
|
|
theme: theme ?? null,
|
|
|
|
styleKey,
|
2025-08-02 19:22:07 -07:00
|
|
|
clientRefs: Array.from(addon[sg.userData.key].scripts),
|
2025-08-11 22:43:27 -07:00
|
|
|
} as const;
|
2025-08-02 17:31:58 -07:00
|
|
|
}
|
2025-06-10 01:13:59 -07:00
|
|
|
|
2025-08-02 17:31:58 -07:00
|
|
|
export async function prepareView(io: Io, item: sg.FileItem) {
|
|
|
|
const module = await io.import<any>(item.file);
|
2025-08-02 19:22:07 -07:00
|
|
|
if (!module.meta) throw new Error(`View is missing 'export const meta'`);
|
|
|
|
if (!module.default) throw new Error(`View is missing a default export.`);
|
2025-08-02 17:31:58 -07:00
|
|
|
const pageTheme = module.layout?.theme ?? module.theme;
|
2025-08-02 19:22:07 -07:00
|
|
|
const theme: css.Theme = { ...css.defaultTheme, ...pageTheme };
|
2025-08-02 17:31:58 -07:00
|
|
|
const cssImports = Array.from(
|
|
|
|
new Set([globalCssPath, ...hot.getCssImports(item.file)]),
|
|
|
|
(file) => path.relative(hot.projectSrc, file),
|
2025-06-09 00:12:41 -07:00
|
|
|
);
|
2025-08-02 17:31:58 -07:00
|
|
|
const styleKey = css.styleKey(cssImports, theme);
|
|
|
|
return {
|
2025-08-11 22:43:27 -07:00
|
|
|
type: "view",
|
|
|
|
id: item.id,
|
|
|
|
file: item.file,
|
2025-08-02 17:31:58 -07:00
|
|
|
cssImports,
|
|
|
|
theme,
|
|
|
|
clientRefs: hot.getClientScriptRefs(item.file),
|
|
|
|
hasLayout: !!module.layout?.default,
|
|
|
|
styleKey,
|
2025-08-11 22:43:27 -07:00
|
|
|
} as const;
|
2025-08-02 17:31:58 -07:00
|
|
|
}
|
|
|
|
export type PreparedView = Awaited<ReturnType<typeof prepareView>>;
|
2025-06-07 02:25:06 -07:00
|
|
|
|
2025-08-02 17:31:58 -07:00
|
|
|
export function prepareInlineCss(
|
2025-08-02 19:22:07 -07:00
|
|
|
items: Array<{ styleKey: string; cssImports: string[]; theme: css.Theme }>,
|
2025-08-02 17:31:58 -07:00
|
|
|
) {
|
|
|
|
const map = new Map<string, incr.Ref<string>>();
|
|
|
|
for (const { styleKey, cssImports, theme } of items) {
|
|
|
|
if (map.has(styleKey)) continue;
|
|
|
|
map.set(
|
|
|
|
styleKey,
|
2025-08-02 19:22:07 -07:00
|
|
|
incr.work(css.bundleCssFiles, { cssImports, theme, dev: false }),
|
2025-06-11 00:17:58 -07:00
|
|
|
);
|
2025-08-02 17:31:58 -07:00
|
|
|
}
|
|
|
|
return map;
|
|
|
|
}
|
2025-06-07 02:25:06 -07:00
|
|
|
|
2025-08-02 17:31:58 -07:00
|
|
|
export type PreparedPage = Awaited<ReturnType<typeof preparePage>>;
|
|
|
|
export async function assembleAndWritePage(
|
|
|
|
pageWork: incr.Ref<PreparedPage>,
|
|
|
|
styleMap: Map<string, incr.Ref<string>>,
|
|
|
|
scriptWork: incr.Ref<Record<string, string>>,
|
|
|
|
) {
|
|
|
|
const page = await pageWork;
|
2025-08-11 22:43:27 -07:00
|
|
|
return incr.work(
|
|
|
|
async (io, { id, html, meta, styleKey, clientRefs, regenerate }) => {
|
|
|
|
const inlineCss = await io.readWork(UNWRAP(styleMap.get(styleKey)));
|
2025-06-13 00:13:22 -07:00
|
|
|
|
2025-08-11 22:43:27 -07:00
|
|
|
const scriptIds = clientRefs.map(hot.getScriptId);
|
|
|
|
const scriptMap = await io.readWork(scriptWork);
|
|
|
|
const scripts = scriptIds
|
|
|
|
.map((ref) => UNWRAP(scriptMap[ref], `Missing script ${ref}`))
|
|
|
|
.map((x) => `{${x}}`)
|
|
|
|
.join("\n");
|
2025-06-07 02:25:06 -07:00
|
|
|
|
2025-08-11 22:43:27 -07:00
|
|
|
const buffer = sg.wrapDocument({
|
|
|
|
body: html,
|
|
|
|
head: meta,
|
|
|
|
inlineCss,
|
|
|
|
scripts,
|
|
|
|
});
|
|
|
|
await io.writeAsset({
|
|
|
|
pathname: id,
|
|
|
|
buffer,
|
|
|
|
headers: {
|
2025-08-15 22:30:58 -07:00
|
|
|
"Content-Type": "text/html;charset=utf8",
|
2025-08-11 22:43:27 -07:00
|
|
|
},
|
|
|
|
regenerative: !!regenerate,
|
|
|
|
});
|
|
|
|
},
|
|
|
|
page,
|
|
|
|
);
|
2025-06-07 02:25:06 -07:00
|
|
|
}
|
|
|
|
|
2025-08-11 22:43:27 -07:00
|
|
|
export type PageOrView = PreparedPage | PreparedView;
|
|
|
|
|
2025-08-14 20:35:33 -07:00
|
|
|
import * as path from "node:path";
|
|
|
|
|
|
|
|
import * as fs from "#sitegen/fs";
|
|
|
|
import * as meta from "#sitegen/meta";
|
|
|
|
import * as render from "#engine/render";
|
2025-08-02 17:31:58 -07:00
|
|
|
import * as sg from "#sitegen";
|
2025-08-14 20:35:33 -07:00
|
|
|
import type { FileItem } from "#sitegen";
|
|
|
|
|
2025-06-07 01:24:32 -07:00
|
|
|
import * as bundle from "./bundle.ts";
|
|
|
|
import * as css from "./css.ts";
|
2025-08-14 20:35:33 -07:00
|
|
|
import * as fonts from "./font.ts";
|
2025-06-07 02:25:06 -07:00
|
|
|
import * as hot from "./hot.ts";
|
2025-08-14 20:35:33 -07:00
|
|
|
import * as incr from "./incremental.ts";
|
|
|
|
import { Io } from "./incremental.ts";
|