| ... | ... | @@ -9,20 +9,44 @@ function main() { |
| 9 | 9 | }, sitegen); |
| 10 | 10 | } |
| 11 | 11 | |
| 12 | | async function sitegen(status) { |
| 12 | /** |
| 13 | * A filesystem object associated with some ID, |
| 14 | * such as a page's route to it's source file. |
| 15 | */ |
| 16 | interface FileItem { |
| 17 | id: string; |
| 18 | file: string; |
| 19 | } |
| 20 | |
| 21 | async function sitegen(status: Spinner) { |
| 13 | 22 | const startTime = performance.now(); |
| 14 | 23 | |
| 15 | | let root = path.resolve(import_meta.dirname, "../src"); |
| 16 | | const join = (...sub) => path.join(root, ...sub); |
| 24 | let root = path.resolve(import.meta.dirname, "../src"); |
| 25 | const join = (...sub: string[]) => path.join(root, ...sub); |
| 17 | 26 | const incr = new Incremental(); |
| 27 | |
| 28 | // Sitegen reviews every defined section for resources to process |
| 18 | 29 | const sections: Section[] = |
| 19 | 30 | require(path.join(root, "sections.ts")).siteSections; |
| 20 | | const views = []; |
| 21 | | const staticFiles = []; |
| 22 | | let pages = []; |
| 23 | | let scripts = []; |
| 31 | |
| 32 | // Static files are compressed and served as-is. |
| 33 | // - "{section}/static/*.png" |
| 34 | let staticFiles: FileItem[] = []; |
| 35 | // Pages are rendered then served as static files. |
| 36 | // - "{section}/pages/*.marko" |
| 37 | let pages: FileItem[] = []; |
| 38 | // Views are dynamically rendered pages called via backend code. |
| 39 | // - "{section}/views/*.tsx" |
| 40 | let views: FileItem[] = []; |
| 41 | // Public scripts are bundled for the client as static assets under "/js/[...]" |
| 42 | // This is used for the file viewer's canvases. |
| 43 | // Note that '.client.ts' can be placed anywhere in the file structure. |
| 44 | // - "{section}/scripts/*.client.ts" |
| 45 | let scripts: FileItem[] = []; |
| 46 | // 'backend.ts' |
| 24 | 47 | const backendFiles = []; |
| 25 | 48 | |
| 49 | // -- Scan for files -- |
| 26 | 50 | status.text = "Scanning Project"; |
| 27 | 51 | for (const section of sections) { |
| 28 | 52 | const { root: sectionRoot } = section; |
| ... | ... | @@ -39,16 +63,21 @@ async function sitegen(status) { |
| 39 | 63 | }, |
| 40 | 64 | { dir: sectionPath("static"), list: staticFiles, prefix: "/", ext: true }, |
| 41 | 65 | { dir: sectionPath("scripts"), list: scripts, prefix: rootPrefix }, |
| 42 | | { dir: sectionPath("views"), list: views, prefix: rootPrefix }, |
| 66 | { |
| 67 | dir: sectionPath("views"), |
| 68 | list: views, |
| 69 | prefix: rootPrefix, |
| 70 | exclude: [".css", ".client.ts", ".client.tsx"], |
| 71 | }, |
| 43 | 72 | ]; |
| 44 | 73 | for (const { dir, list, prefix, exclude = [], ext = false } of kinds) { |
| 45 | | const pages2 = fs.readDirRecOptional(dir); |
| 46 | | page: for (const page of pages2) { |
| 47 | | if (page.isDirectory()) continue; |
| 48 | | for (const ext2 of exclude) { |
| 49 | | if (page.name.endsWith(ext2)) continue page; |
| 74 | const items = fs.readDirRecOptionalSync(dir); |
| 75 | item: for (const item of items) { |
| 76 | if (item.isDirectory()) continue; |
| 77 | for (const e of exclude) { |
| 78 | if (item.name.endsWith(e)) continue item; |
| 50 | 79 | } |
| 51 | | const file = path.relative(dir, page.parentPath + "/" + page.name); |
| 80 | const file = path.relative(dir, item.parentPath + "/" + item.name); |
| 52 | 81 | const trim = ext |
| 53 | 82 | ? file |
| 54 | 83 | : file.slice(0, -path.extname(file).length).replaceAll(".", "/"); |
| ... | ... | @@ -56,7 +85,7 @@ async function sitegen(status) { |
| 56 | 85 | if (prefix === "/" && id.endsWith("/index")) { |
| 57 | 86 | id = id.slice(0, -"/index".length) || "/"; |
| 58 | 87 | } |
| 59 | | list.push({ id, file: path.join(page.parentPath, page.name) }); |
| 88 | list.push({ id, file: path.join(item.parentPath, item.name) }); |
| 60 | 89 | } |
| 61 | 90 | } |
| 62 | 91 | let backendFile = [ |
| ... | ... | @@ -67,86 +96,110 @@ async function sitegen(status) { |
| 67 | 96 | } |
| 68 | 97 | scripts = scripts.filter(({ file }) => !file.match(/\.client\.[tj]sx?/)); |
| 69 | 98 | const globalCssPath = join("global.css"); |
| 99 | |
| 100 | // TODO: invalidate incremental resources |
| 101 | |
| 102 | // -- server side render -- |
| 70 | 103 | status.text = "Building"; |
| 71 | 104 | const cssOnce = new OnceMap(); |
| 72 | | const cssQueue = new Queue({ |
| 105 | const cssQueue = new Queue<[string, string[], css.Theme], string>({ |
| 73 | 106 | name: "Bundle", |
| 74 | 107 | fn: ([, files, theme]) => css.bundleCssFiles(files, theme), |
| 75 | 108 | passive: true, |
| 76 | 109 | getItemText: ([id]) => id, |
| 77 | 110 | maxJobs: 2, |
| 78 | 111 | }); |
| 79 | | const ssrResults = []; |
| 80 | | function loadSsrModule(page) { |
| 81 | | require(page.file); |
| 112 | interface RenderResult { |
| 113 | body: string; |
| 114 | head: string; |
| 115 | inlineCss: string; |
| 116 | scriptFiles: string[]; |
| 117 | item: FileItem; |
| 82 | 118 | } |
| 83 | | async function doSsrPage(page) { |
| 84 | | const module2 = require(page.file); |
| 85 | | const Page = module2.default; |
| 86 | | if (!Page) { |
| 87 | | throw new Error("Page is missing a 'default' export."); |
| 88 | | } |
| 89 | | const metadata = module2.meta; |
| 119 | const renderResults: RenderResult[] = []; |
| 120 | async function loadPageModule({ file }: FileItem) { |
| 121 | require(file); |
| 122 | } |
| 123 | async function renderPage(item: FileItem) { |
| 124 | // -- load and validate module -- |
| 125 | let { default: Page, meta: metadata, theme: pageTheme, layout } = require( |
| 126 | item.file, |
| 127 | ); |
| 128 | if (!Page) throw new Error("Page is missing a 'default' export."); |
| 90 | 129 | if (!metadata) { |
| 91 | | throw new Error("Page is missing 'meta' attribute with a title."); |
| 130 | throw new Error("Page is missing 'meta' export with a title."); |
| 92 | 131 | } |
| 132 | if (layout?.theme) pageTheme = layout.theme; |
| 93 | 133 | const theme = { |
| 94 | 134 | bg: "#fff", |
| 95 | 135 | fg: "#050505", |
| 96 | 136 | primary: "#2e7dab", |
| 97 | | ...module2.theme, |
| 137 | ...pageTheme, |
| 98 | 138 | }; |
| 139 | |
| 140 | // -- metadata -- |
| 99 | 141 | const renderedMetaPromise = Promise.resolve( |
| 100 | 142 | typeof metadata === "function" ? metadata({ ssr: true }) : metadata, |
| 101 | 143 | ).then((m) => meta.resolveAndRenderMetadata(m)); |
| 102 | | const cssImports = [globalCssPath, ...hot.getCssImports(page.file)]; |
| 144 | // -- css -- |
| 145 | const cssImports = [globalCssPath, ...hot.getCssImports(item.file)]; |
| 103 | 146 | const cssPromise = cssOnce.get( |
| 104 | 147 | cssImports.join(":") + JSON.stringify(theme), |
| 105 | | () => cssQueue.add([page.id, cssImports, theme]), |
| 148 | () => cssQueue.add([item.id, cssImports, theme]), |
| 106 | 149 | ); |
| 150 | // -- html -- |
| 107 | 151 | const sitegenApi = sg.initRender(); |
| 108 | | const body = await (0, import_ssr.ssrAsync)( |
| 109 | | /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Page, {}), |
| 110 | | { |
| 111 | | sitegen: sitegenApi, |
| 112 | | }, |
| 113 | | ); |
| 114 | | const inlineCss = await cssPromise; |
| 115 | | const renderedMeta = await renderedMetaPromise; |
| 152 | const bodyPromise = await ssr.ssrAsync(<Page />, { |
| 153 | sitegen: sitegenApi, |
| 154 | }); |
| 155 | |
| 156 | const [body, inlineCss, renderedMeta] = await Promise.all([ |
| 157 | bodyPromise, |
| 158 | cssPromise, |
| 159 | renderedMetaPromise, |
| 160 | ]); |
| 116 | 161 | if (!renderedMeta.includes("<title>")) { |
| 117 | 162 | throw new Error( |
| 118 | | "Page is missing 'meta.title'. All pages need a title tag.", |
| 163 | "Page is missing 'meta.title'. " + |
| 164 | "All pages need a title tag.", |
| 119 | 165 | ); |
| 120 | 166 | } |
| 121 | | ssrResults.push({ |
| 167 | // The script content is not ready, allow another page to Render. The page |
| 168 | // contents will be rebuilt at the end. This is more convenient anyways |
| 169 | // because it means client scripts don't re-render the page. |
| 170 | renderResults.push({ |
| 122 | 171 | body, |
| 123 | 172 | head: renderedMeta, |
| 124 | 173 | inlineCss, |
| 125 | 174 | scriptFiles: Array.from(sitegenApi.scripts), |
| 126 | | page, |
| 175 | item: item, |
| 127 | 176 | }); |
| 128 | 177 | } |
| 178 | // This is done in two passes so that a page that throws during evaluation |
| 179 | // will report "Load Render Module" instead of "Render Static Page". |
| 129 | 180 | const spinnerFormat = status.format; |
| 130 | 181 | status.format = () => ""; |
| 131 | | const moduleLoadQueue = new import_queue.Queue({ |
| 182 | const moduleLoadQueue = new Queue({ |
| 132 | 183 | name: "Load Render Module", |
| 133 | | fn: loadSSRModule, |
| 184 | fn: loadPageModule, |
| 134 | 185 | getItemText, |
| 135 | 186 | maxJobs: 1, |
| 136 | 187 | }); |
| 137 | 188 | moduleLoadQueue.addMany(pages); |
| 138 | 189 | await moduleLoadQueue.done({ method: "stop" }); |
| 139 | | const pageQueue = new import_queue.Queue({ |
| 140 | | name: "Render", |
| 141 | | fn: doSsrPage, |
| 190 | const pageQueue = new Queue({ |
| 191 | name: "Render Static Page", |
| 192 | fn: renderPage, |
| 142 | 193 | getItemText, |
| 143 | 194 | maxJobs: 2, |
| 144 | 195 | }); |
| 145 | 196 | pageQueue.addMany(pages); |
| 146 | 197 | await pageQueue.done({ method: "stop" }); |
| 147 | 198 | status.format = spinnerFormat; |
| 199 | |
| 200 | // -- bundle scripts -- |
| 148 | 201 | const referencedScripts = Array.from( |
| 149 | | new Set(ssrResults.flatMap((r) => r.scriptFiles)), |
| 202 | new Set(renderResults.flatMap((r) => r.scriptFiles)), |
| 150 | 203 | ); |
| 151 | 204 | const extraPublicScripts = scripts.map((entry) => entry.file); |
| 152 | 205 | const uniqueCount = new Set([ |
| ... | ... | @@ -159,15 +212,17 @@ async function sitegen(status) { |
| 159 | 212 | extraPublicScripts, |
| 160 | 213 | incr, |
| 161 | 214 | ); |
| 162 | | async function doStaticFile(page) { |
| 163 | | const body = await fs.readFile(page.file); |
| 215 | |
| 216 | // -- copy/compress static files -- |
| 217 | async function doStaticFile(item: FileItem) { |
| 218 | const body = await fs.readFile(item.file); |
| 164 | 219 | incr.putAsset({ |
| 165 | | srcId: "static:" + page.file, |
| 166 | | key: page.id, |
| 220 | srcId: "static:" + item.file, |
| 221 | key: item.id, |
| 167 | 222 | body, |
| 168 | 223 | }); |
| 169 | 224 | } |
| 170 | | const staticQueue = new import_queue.Queue({ |
| 225 | const staticQueue = new Queue({ |
| 171 | 226 | name: "Load Static", |
| 172 | 227 | fn: doStaticFile, |
| 173 | 228 | getItemText, |
| ... | ... | @@ -177,40 +232,68 @@ async function sitegen(status) { |
| 177 | 232 | staticQueue.addMany(staticFiles); |
| 178 | 233 | await staticQueue.done({ method: "stop" }); |
| 179 | 234 | status.format = spinnerFormat; |
| 180 | | status.text = `Concat ${ssrResults.length} Pages`; |
| 235 | |
| 236 | // -- concatenate static rendered pages -- |
| 237 | status.text = `Concat ${renderResults.length} Pages`; |
| 181 | 238 | await Promise.all( |
| 182 | | ssrResults.map(async ({ page, body, head, inlineCss, scriptFiles }) => { |
| 183 | | const doc = wrapDocument({ |
| 184 | | body, |
| 185 | | head, |
| 186 | | inlineCss, |
| 187 | | scripts: scriptFiles.map( |
| 188 | | (id) => |
| 189 | | UNWRAP( |
| 190 | | incr.out.script.get( |
| 191 | | path.basename(id).replace(/\.client\.[jt]sx?$/, ""), |
| 239 | renderResults.map( |
| 240 | async ({ item: page, body, head, inlineCss, scriptFiles }) => { |
| 241 | const doc = wrapDocument({ |
| 242 | body, |
| 243 | head, |
| 244 | inlineCss, |
| 245 | scripts: scriptFiles.map( |
| 246 | (id) => |
| 247 | UNWRAP( |
| 248 | incr.out.script.get( |
| 249 | path.basename(id).replace(/\.client\.[jt]sx?$/, ""), |
| 250 | ), |
| 192 | 251 | ), |
| 193 | | ), |
| 194 | | ).map((x) => `{${x}}`).join(""), |
| 195 | | }); |
| 196 | | incr.putAsset({ |
| 197 | | srcId: "page:" + page.file, |
| 198 | | key: page.id, |
| 199 | | body: doc, |
| 200 | | headers: { |
| 201 | | "Content-Type": "text/html", |
| 202 | | }, |
| 203 | | }); |
| 204 | | }), |
| 252 | ).map((x) => `{${x}}`).join("\n"), |
| 253 | }); |
| 254 | incr.putAsset({ |
| 255 | srcId: "page:" + page.file, |
| 256 | key: page.id, |
| 257 | body: doc, |
| 258 | headers: { |
| 259 | "Content-Type": "text/html", |
| 260 | }, |
| 261 | }); |
| 262 | }, |
| 263 | ), |
| 205 | 264 | ); |
| 206 | 265 | status.format = () => ""; |
| 207 | 266 | status.text = ``; |
| 267 | // This will wait for all compression jobs to finish, which up |
| 268 | // to this point have been left as dangling promises. |
| 208 | 269 | await incr.wait(); |
| 270 | |
| 271 | // Flush the site to disk. |
| 209 | 272 | status.format = spinnerFormat; |
| 210 | 273 | status.text = `Incremental Flush`; |
| 211 | 274 | incr.flush(); |
| 212 | | incr.serializeToDisk(); |
| 213 | | return { elapsed: (performance.now() - startTime) / 1e3 }; |
| 275 | incr.serializeToDisk(); // Allows picking up this state again |
| 276 | return { elapsed: (performance.now() - startTime) / 1000 }; |
| 277 | } |
| 278 | |
| 279 | function getItemText({ file }: FileItem) { |
| 280 | return path.relative(hot.projectSrc, file).replaceAll("\\", "/"); |
| 281 | } |
| 282 | |
| 283 | function wrapDocument({ |
| 284 | body, |
| 285 | head, |
| 286 | inlineCss, |
| 287 | scripts, |
| 288 | }: { |
| 289 | head: string; |
| 290 | body: string; |
| 291 | inlineCss: string; |
| 292 | scripts: string; |
| 293 | }) { |
| 294 | return `<!doctype html><head>${head}${ |
| 295 | inlineCss ? `<style>${inlineCss}</style>` : "" |
| 296 | }</head><body>${body}${scripts ? `<script>${scripts}</script>` : ""}</body>`; |
| 214 | 297 | } |
| 215 | 298 | |
| 216 | 299 | import type { Section } from "./sitegen-lib.ts"; |
| ... | ... | @@ -218,5 +301,10 @@ import { OnceMap, Queue } from "./queue.ts"; |
| 218 | 301 | import { Incremental } from "./incremental.ts"; |
| 219 | 302 | import * as bundle from "./bundle.ts"; |
| 220 | 303 | import * as css from "./css.ts"; |
| 221 | | import * as fs from './fs.ts'; |
| 222 | | import { withSpinner, Spinner } from "@paperclover/console/Spinner"; |
| 304 | import * as fs from "./fs.ts"; |
| 305 | import { Spinner, withSpinner } from "@paperclover/console/Spinner"; |
| 306 | import * as meta from "./meta"; |
| 307 | import * as ssr from "./engine/ssr.ts"; |
| 308 | import * as sg from "./sitegen-lib.ts"; |
| 309 | import * as hot from "./hot.ts"; |
| 310 | import * as path from "node:path"; |