diff --git a/packages/docs/public/sitemap.xml b/packages/docs/public/sitemap.xml deleted file mode 100644 index 3ff7374..0000000 --- a/packages/docs/public/sitemap.xml +++ /dev/null @@ -1,48 +0,0 @@ - - - - https://static.funstack.work/ - - - https://static.funstack.work/getting-started - - - https://static.funstack.work/getting-started/migrating-from-vite-spa - - - https://static.funstack.work/faq - - - https://static.funstack.work/api/funstack-static - - - https://static.funstack.work/api/defer - - - https://static.funstack.work/api/entry-definition - - - https://static.funstack.work/learn/how-it-works - - - https://static.funstack.work/learn/rsc - - - https://static.funstack.work/learn/optimizing-payloads - - - https://static.funstack.work/learn/lazy-server-components - - - https://static.funstack.work/learn/defer-and-activity - - - https://static.funstack.work/learn/file-system-routing - - - https://static.funstack.work/advanced/multiple-entrypoints - - - https://static.funstack.work/advanced/ssr - - diff --git a/packages/docs/src/build.ts b/packages/docs/src/build.ts new file mode 100644 index 0000000..620adb5 --- /dev/null +++ b/packages/docs/src/build.ts @@ -0,0 +1,43 @@ +import { writeFile } from "node:fs/promises"; +import path from "node:path"; +import type { BuildEntryFunction } from "@funstack/static/server"; +import type { RouteDefinition } from "@funstack/router/server"; +import { routes } from "./App"; + +const siteUrl = "https://static.funstack.work"; + +function collectPaths(routes: RouteDefinition[]): string[] { + const paths: string[] = []; + for (const route of routes) { + if (route.children) { + paths.push(...collectPaths(route.children)); + } else if (route.path !== undefined && route.path !== "*") { + paths.push(route.path); + } + } + return paths; +} + +function generateSitemap(paths: string[]): string { + const urls = paths + .map((p) => { + const loc = p === "/" ? siteUrl + "/" : `${siteUrl}${p}`; + return ` \n ${loc}\n `; + }) + .join("\n"); + + return ` + +${urls} + +`; +} + +export default (async ({ build, outDir }) => { + const paths = collectPaths(routes); + + await Promise.all([ + build(), + writeFile(path.join(outDir, "sitemap.xml"), generateSitemap(paths)), + ]); +}) satisfies BuildEntryFunction; diff --git a/packages/docs/vite.config.ts b/packages/docs/vite.config.ts index 0cc5aed..258cdbd 100644 --- a/packages/docs/vite.config.ts +++ b/packages/docs/vite.config.ts @@ -14,6 +14,7 @@ export default defineConfig(async () => { funstackStatic({ entries: "./src/entries.tsx", ssr: true, + build: "./src/build.ts", }), { // to make .mdx loading lazy diff --git a/packages/static/src/build/buildApp.ts b/packages/static/src/build/buildApp.ts index 35b6a5d..aadee83 100644 --- a/packages/static/src/build/buildApp.ts +++ b/packages/static/src/build/buildApp.ts @@ -25,55 +25,63 @@ export async function buildApp( const baseDir = config.environments.client.build.outDir; const base = normalizeBase(config.base); - const { entries, deferRegistry } = await entry.build(); - - // Validate all entry paths - const paths: string[] = []; - for (const result of entries) { - const error = validateEntryPath(result.path); - if (error) { - throw new Error(error); + async function doBuild() { + const { entries, deferRegistry } = await entry.build(); + + // Validate all entry paths + const paths: string[] = []; + for (const result of entries) { + const error = validateEntryPath(result.path); + if (error) { + throw new Error(error); + } + paths.push(result.path); + } + const dupError = checkDuplicatePaths(paths); + if (dupError) { + throw new Error(dupError); } - paths.push(result.path); - } - const dupError = checkDuplicatePaths(paths); - if (dupError) { - throw new Error(dupError); - } - - // Process all deferred components once across all entries. - // We pass a dummy empty stream since we handle per-entry RSC payloads separately. - const dummyStream = new ReadableStream({ - start(controller) { - controller.close(); - }, - }); - const { components, idMapping } = await processRscComponents( - deferRegistry.loadAll(), - dummyStream, - options.rscPayloadDir, - context, - ); - // Write each entry's HTML and RSC payload - for (const result of entries) { - await buildSingleEntry( - result, - idMapping, - baseDir, - base, + // Process all deferred components once across all entries. + // We pass a dummy empty stream since we handle per-entry RSC payloads separately. + const dummyStream = new ReadableStream({ + start(controller) { + controller.close(); + }, + }); + const { components, idMapping } = await processRscComponents( + deferRegistry.loadAll(), + dummyStream, options.rscPayloadDir, context, ); + + // Write each entry's HTML and RSC payload + for (const result of entries) { + await buildSingleEntry( + result, + idMapping, + baseDir, + base, + options.rscPayloadDir, + context, + ); + } + + // Write all deferred component payloads + for (const { finalId, finalContent, name } of components) { + const filePath = path.join( + baseDir, + getModulePathFor(finalId).replace(/^\//, ""), + ); + await writeFileNormal(filePath, finalContent, context, name); + } } - // Write all deferred component payloads - for (const { finalId, finalContent, name } of components) { - const filePath = path.join( - baseDir, - getModulePathFor(finalId).replace(/^\//, ""), - ); - await writeFileNormal(filePath, finalContent, context, name); + if (entry.buildEntry) { + await entry.buildEntry({ build: doBuild, outDir: baseDir }); + } else { + await doBuild(); } } diff --git a/packages/static/src/buildEntryDefinition.ts b/packages/static/src/buildEntryDefinition.ts new file mode 100644 index 0000000..5f0f9b2 --- /dev/null +++ b/packages/static/src/buildEntryDefinition.ts @@ -0,0 +1,23 @@ +/** + * Context passed to the build entry function. + */ +export interface BuildEntryContext { + /** + * Performs the default build flow (rendering entries and writing output files). + * Call this to execute the standard build process. + * You can run additional work before, after, or in parallel with this function. + */ + build: () => Promise; + /** + * Absolute path to the output directory where built files are written. + * Use this to write additional files (e.g. sitemap.xml) alongside the build output. + */ + outDir: string; +} + +/** + * The build entry module should default-export a function with this signature. + */ +export type BuildEntryFunction = ( + context: BuildEntryContext, +) => Promise | void; diff --git a/packages/static/src/entries/server.ts b/packages/static/src/entries/server.ts index d8d1256..93c83f2 100644 --- a/packages/static/src/entries/server.ts +++ b/packages/static/src/entries/server.ts @@ -1 +1,5 @@ export { defer, type DeferOptions } from "../rsc/defer"; +export type { + BuildEntryContext, + BuildEntryFunction, +} from "../buildEntryDefinition"; diff --git a/packages/static/src/plugin/index.ts b/packages/static/src/plugin/index.ts index b5b7d29..acd1f1d 100644 --- a/packages/static/src/plugin/index.ts +++ b/packages/static/src/plugin/index.ts @@ -40,6 +40,19 @@ interface FunstackStaticBaseOptions { * @default "fun__rsc-payload" */ rscPayloadDir?: string; + /** + * Path to a module that customizes the build process. + * The module should `export default` an async function that receives + * `{ build }` where `build` is a function that performs the default + * build flow. + * + * This allows you to run additional work before/after the build, + * or to control the build execution (e.g. parallel work). + * Only called during production builds, not in dev mode. + * + * The module runs in the RSC environment. + */ + build?: string; } interface SingleEntryOptions { @@ -95,6 +108,7 @@ export default function funstackStatic( let resolvedEntriesModule: string = "__uninitialized__"; let resolvedClientInitEntry: string | undefined; + let resolvedBuildEntry: string | undefined; // Determine whether user specified entries or root+app const isMultiEntry = "entries" in options && options.entries !== undefined; @@ -152,6 +166,11 @@ export default function funstackStatic( path.resolve(config.root, clientInit), ); } + if (options.build) { + resolvedBuildEntry = normalizePath( + path.resolve(config.root, options.build), + ); + } }, configEnvironment(_name, config) { if (!config.optimizeDeps) { @@ -189,6 +208,9 @@ export default function funstackStatic( if (id === "virtual:funstack/client-init") { return "\0virtual:funstack/client-init"; } + if (id === "virtual:funstack/build-entry") { + return "\0virtual:funstack/build-entry"; + } }, load(id) { if (id === "\0virtual:funstack/entries") { @@ -218,6 +240,12 @@ export default function funstackStatic( } return ""; } + if (id === "\0virtual:funstack/build-entry") { + if (resolvedBuildEntry) { + return `export { default } from "${resolvedBuildEntry}";`; + } + return "export default undefined;"; + } }, }, { diff --git a/packages/static/src/rsc/entry.tsx b/packages/static/src/rsc/entry.tsx index 678e873..8bf07c3 100644 --- a/packages/static/src/rsc/entry.tsx +++ b/packages/static/src/rsc/entry.tsx @@ -316,6 +316,7 @@ export async function build() { } export { defer } from "./defer"; +export { default as buildEntry } from "virtual:funstack/build-entry"; if (import.meta.hot) { import.meta.hot.accept(); diff --git a/packages/static/src/virtual.d.ts b/packages/static/src/virtual.d.ts index 0f79119..95e5118 100644 --- a/packages/static/src/virtual.d.ts +++ b/packages/static/src/virtual.d.ts @@ -8,3 +8,8 @@ declare module "virtual:funstack/config" { export const rscPayloadDir: string; } declare module "virtual:funstack/client-init" {} +declare module "virtual:funstack/build-entry" { + import type { BuildEntryFunction } from "./buildEntryDefinition"; + const buildEntry: BuildEntryFunction | undefined; + export default buildEntry; +}