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(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("")) { 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";