@funstack/static 0.0.5 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -37,7 +37,7 @@ export default defineConfig({
37
37
 
38
38
  ## Documentation
39
39
 
40
- For detailed API documentation and guides, visit the **[Documentation](https://uhyo.github.io/funstack-static/)**.
40
+ For detailed API documentation and guides, visit the **[Documentation](https://static.funstack.work/)**.
41
41
 
42
42
  ### :robot: FUNSTACK Static Skill
43
43
 
@@ -3,6 +3,7 @@ import { getRscPayloadPath, rscPayloadPlaceholder } from "./rscPath.mjs";
3
3
  import { drainStream } from "../util/drainStream.mjs";
4
4
  import { computeContentHash } from "./contentHash.mjs";
5
5
  import { processRscComponents } from "./rscProcessor.mjs";
6
+ import { checkDuplicatePaths, validateEntryPath } from "./validateEntryPath.mjs";
6
7
  import path from "node:path";
7
8
  import { mkdir, writeFile } from "node:fs/promises";
8
9
  import { pathToFileURL } from "node:url";
@@ -12,16 +13,44 @@ async function buildApp(builder, context) {
12
13
  const { config } = builder;
13
14
  const entry = await import(pathToFileURL(path.join(config.environments.rsc.build.outDir, "index.js")).href);
14
15
  const baseDir = config.environments.client.build.outDir;
15
- const { html, appRsc, deferRegistry } = await entry.build();
16
+ const base = normalizeBase(config.base);
17
+ const { entries, deferRegistry } = await entry.build();
18
+ const paths = [];
19
+ for (const result of entries) {
20
+ const error = validateEntryPath(result.path);
21
+ if (error) throw new Error(error);
22
+ paths.push(result.path);
23
+ }
24
+ const dupError = checkDuplicatePaths(paths);
25
+ if (dupError) throw new Error(dupError);
26
+ const dummyStream = new ReadableStream({ start(controller) {
27
+ controller.close();
28
+ } });
29
+ const { components, idMapping } = await processRscComponents(deferRegistry.loadAll(), dummyStream, context);
30
+ for (const result of entries) await buildSingleEntry(result, idMapping, baseDir, base, context);
31
+ for (const { finalId, finalContent, name } of components) await writeFileNormal(path.join(baseDir, getModulePathFor(finalId).replace(/^\//, "")), finalContent, context, name);
32
+ }
33
+ function normalizeBase(base) {
34
+ const normalized = base.endsWith("/") ? base.slice(0, -1) : base;
35
+ return normalized === "/" ? "" : normalized;
36
+ }
37
+ /**
38
+ * Replaces temporary IDs with final hashed IDs in content.
39
+ */
40
+ function replaceIdsInContent(content, idMapping) {
41
+ let result = content;
42
+ for (const [oldId, newId] of idMapping) if (oldId !== newId) result = result.replaceAll(oldId, newId);
43
+ return result;
44
+ }
45
+ async function buildSingleEntry(result, idMapping, baseDir, base, context) {
46
+ const { path: entryPath, html, appRsc } = result;
16
47
  const htmlContent = await drainStream(html);
17
- const { components, appRscContent } = await processRscComponents(deferRegistry.loadAll(), appRsc, context);
48
+ const appRscContent = replaceIdsInContent(await drainStream(appRsc), idMapping);
18
49
  const mainPayloadHash = await computeContentHash(appRscContent);
19
- const base = config.base.endsWith("/") ? config.base.slice(0, -1) : config.base;
20
- const mainPayloadPath = base === "/" ? getRscPayloadPath(mainPayloadHash) : base + getRscPayloadPath(mainPayloadHash);
50
+ const mainPayloadPath = base === "" ? getRscPayloadPath(mainPayloadHash) : base + getRscPayloadPath(mainPayloadHash);
21
51
  const finalHtmlContent = htmlContent.replaceAll(rscPayloadPlaceholder, mainPayloadPath);
22
- await writeFileNormal(path.join(baseDir, "index.html"), finalHtmlContent, context);
52
+ await writeFileNormal(path.join(baseDir, entryPath), finalHtmlContent, context);
23
53
  await writeFileNormal(path.join(baseDir, getRscPayloadPath(mainPayloadHash).replace(/^\//, "")), appRscContent, context);
24
- for (const { finalId, finalContent, name } of components) await writeFileNormal(path.join(baseDir, getModulePathFor(finalId).replace(/^\//, "")), finalContent, context, name);
25
54
  }
26
55
  async function writeFileNormal(filePath, data, context, name) {
27
56
  await mkdir(path.dirname(filePath), { recursive: true });
@@ -1 +1 @@
1
- {"version":3,"file":"buildApp.mjs","names":[],"sources":["../../src/build/buildApp.ts"],"sourcesContent":["import { mkdir, writeFile } from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { pathToFileURL } from \"node:url\";\nimport type { ViteBuilder, MinimalPluginContextWithoutEnvironment } from \"vite\";\nimport { rscPayloadPlaceholder, getRscPayloadPath } from \"./rscPath\";\nimport { getModulePathFor } from \"../rsc/rscModule\";\nimport { processRscComponents } from \"./rscProcessor\";\nimport { computeContentHash } from \"./contentHash\";\nimport { drainStream } from \"../util/drainStream\";\n\nexport async function buildApp(\n builder: ViteBuilder,\n context: MinimalPluginContextWithoutEnvironment,\n) {\n const { config } = builder;\n // import server entry\n const entryPath = path.join(config.environments.rsc.build.outDir, \"index.js\");\n const entry: typeof import(\"../rsc/entry\") = await import(\n pathToFileURL(entryPath).href\n );\n\n // render rsc and html\n const baseDir = config.environments.client.build.outDir;\n const { html, appRsc, deferRegistry } = await entry.build();\n\n // Drain HTML stream to string (needed for placeholder replacement later)\n const htmlContent = await drainStream(html);\n\n // Process RSC components with content-based hashes for deterministic file names\n const { components, appRscContent } = await processRscComponents(\n deferRegistry.loadAll(),\n appRsc,\n context,\n );\n\n // Compute hash for main RSC payload and apply base path\n const mainPayloadHash = await computeContentHash(appRscContent);\n const base = config.base.endsWith(\"/\")\n ? config.base.slice(0, -1)\n : config.base;\n const mainPayloadPath =\n base === \"/\"\n ? getRscPayloadPath(mainPayloadHash)\n : base + getRscPayloadPath(mainPayloadHash);\n\n // Replace placeholder with final hashed path (including base path)\n const finalHtmlContent = htmlContent.replaceAll(\n rscPayloadPlaceholder,\n mainPayloadPath,\n );\n\n // Write HTML with replaced path\n await writeFileNormal(\n path.join(baseDir, \"index.html\"),\n finalHtmlContent,\n context,\n );\n\n // Write main RSC payload with hashed filename\n await writeFileNormal(\n path.join(baseDir, getRscPayloadPath(mainPayloadHash).replace(/^\\//, \"\")),\n appRscContent,\n context,\n );\n\n // Write processed components with hash-based IDs\n for (const { finalId, finalContent, name } of components) {\n const filePath = path.join(\n baseDir,\n getModulePathFor(finalId).replace(/^\\//, \"\"),\n );\n await writeFileNormal(filePath, finalContent, context, name);\n }\n}\n\nasync function writeFileNormal(\n filePath: string,\n data: string,\n context: MinimalPluginContextWithoutEnvironment,\n name?: string,\n) {\n await mkdir(path.dirname(filePath), { recursive: true });\n const nameInfo = name ? ` (${name})` : \"\";\n context.info(`[funstack] Writing ${filePath}${nameInfo}`);\n await writeFile(filePath, data);\n}\n"],"mappings":";;;;;;;;;;AAUA,eAAsB,SACpB,SACA,SACA;CACA,MAAM,EAAE,WAAW;CAGnB,MAAM,QAAuC,MAAM,OACjD,cAFgB,KAAK,KAAK,OAAO,aAAa,IAAI,MAAM,QAAQ,WAAW,CAEnD,CAAC;CAI3B,MAAM,UAAU,OAAO,aAAa,OAAO,MAAM;CACjD,MAAM,EAAE,MAAM,QAAQ,kBAAkB,MAAM,MAAM,OAAO;CAG3D,MAAM,cAAc,MAAM,YAAY,KAAK;CAG3C,MAAM,EAAE,YAAY,kBAAkB,MAAM,qBAC1C,cAAc,SAAS,EACvB,QACA,QACD;CAGD,MAAM,kBAAkB,MAAM,mBAAmB,cAAc;CAC/D,MAAM,OAAO,OAAO,KAAK,SAAS,IAAI,GAClC,OAAO,KAAK,MAAM,GAAG,GAAG,GACxB,OAAO;CACX,MAAM,kBACJ,SAAS,MACL,kBAAkB,gBAAgB,GAClC,OAAO,kBAAkB,gBAAgB;CAG/C,MAAM,mBAAmB,YAAY,WACnC,uBACA,gBACD;AAGD,OAAM,gBACJ,KAAK,KAAK,SAAS,aAAa,EAChC,kBACA,QACD;AAGD,OAAM,gBACJ,KAAK,KAAK,SAAS,kBAAkB,gBAAgB,CAAC,QAAQ,OAAO,GAAG,CAAC,EACzE,eACA,QACD;AAGD,MAAK,MAAM,EAAE,SAAS,cAAc,UAAU,WAK5C,OAAM,gBAJW,KAAK,KACpB,SACA,iBAAiB,QAAQ,CAAC,QAAQ,OAAO,GAAG,CAC7C,EAC+B,cAAc,SAAS,KAAK;;AAIhE,eAAe,gBACb,UACA,MACA,SACA,MACA;AACA,OAAM,MAAM,KAAK,QAAQ,SAAS,EAAE,EAAE,WAAW,MAAM,CAAC;CACxD,MAAM,WAAW,OAAO,KAAK,KAAK,KAAK;AACvC,SAAQ,KAAK,sBAAsB,WAAW,WAAW;AACzD,OAAM,UAAU,UAAU,KAAK"}
1
+ {"version":3,"file":"buildApp.mjs","names":[],"sources":["../../src/build/buildApp.ts"],"sourcesContent":["import { mkdir, writeFile } from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { pathToFileURL } from \"node:url\";\nimport type { ViteBuilder, MinimalPluginContextWithoutEnvironment } from \"vite\";\nimport { rscPayloadPlaceholder, getRscPayloadPath } from \"./rscPath\";\nimport { getModulePathFor } from \"../rsc/rscModule\";\nimport { processRscComponents } from \"./rscProcessor\";\nimport { computeContentHash } from \"./contentHash\";\nimport { drainStream } from \"../util/drainStream\";\nimport { validateEntryPath, checkDuplicatePaths } from \"./validateEntryPath\";\nimport type { EntryBuildResult } from \"../rsc/entry\";\n\nexport async function buildApp(\n builder: ViteBuilder,\n context: MinimalPluginContextWithoutEnvironment,\n) {\n const { config } = builder;\n // import server entry\n const entryPath = path.join(config.environments.rsc.build.outDir, \"index.js\");\n const entry: typeof import(\"../rsc/entry\") = await import(\n pathToFileURL(entryPath).href\n );\n\n const baseDir = config.environments.client.build.outDir;\n const base = normalizeBase(config.base);\n\n const { entries, deferRegistry } = await entry.build();\n\n // Validate all entry paths\n const paths: string[] = [];\n for (const result of entries) {\n const error = validateEntryPath(result.path);\n if (error) {\n throw new Error(error);\n }\n paths.push(result.path);\n }\n const dupError = checkDuplicatePaths(paths);\n if (dupError) {\n throw new Error(dupError);\n }\n\n // Process all deferred components once across all entries.\n // We pass a dummy empty stream since we handle per-entry RSC payloads separately.\n const dummyStream = new ReadableStream<Uint8Array>({\n start(controller) {\n controller.close();\n },\n });\n const { components, idMapping } = await processRscComponents(\n deferRegistry.loadAll(),\n dummyStream,\n context,\n );\n\n // Write each entry's HTML and RSC payload\n for (const result of entries) {\n await buildSingleEntry(result, idMapping, baseDir, base, context);\n }\n\n // Write all deferred component payloads\n for (const { finalId, finalContent, name } of components) {\n const filePath = path.join(\n baseDir,\n getModulePathFor(finalId).replace(/^\\//, \"\"),\n );\n await writeFileNormal(filePath, finalContent, context, name);\n }\n}\n\nfunction normalizeBase(base: string): string {\n const normalized = base.endsWith(\"/\") ? base.slice(0, -1) : base;\n return normalized === \"/\" ? \"\" : normalized;\n}\n\n/**\n * Replaces temporary IDs with final hashed IDs in content.\n */\nfunction replaceIdsInContent(\n content: string,\n idMapping: Map<string, string>,\n): string {\n let result = content;\n for (const [oldId, newId] of idMapping) {\n if (oldId !== newId) {\n result = result.replaceAll(oldId, newId);\n }\n }\n return result;\n}\n\nasync function buildSingleEntry(\n result: EntryBuildResult,\n idMapping: Map<string, string>,\n baseDir: string,\n base: string,\n context: MinimalPluginContextWithoutEnvironment,\n) {\n const { path: entryPath, html, appRsc } = result;\n\n // Drain HTML stream to string\n const htmlContent = await drainStream(html);\n\n // Drain and process RSC payload: replace temp IDs with final hashed IDs\n const rawAppRscContent = await drainStream(appRsc);\n const appRscContent = replaceIdsInContent(rawAppRscContent, idMapping);\n\n // Compute hash for this entry's RSC payload\n const mainPayloadHash = await computeContentHash(appRscContent);\n const mainPayloadPath =\n base === \"\"\n ? getRscPayloadPath(mainPayloadHash)\n : base + getRscPayloadPath(mainPayloadHash);\n\n // Replace placeholder with final hashed path\n const finalHtmlContent = htmlContent.replaceAll(\n rscPayloadPlaceholder,\n mainPayloadPath,\n );\n\n // entryPath is already a file name (e.g. \"index.html\", \"about.html\")\n await writeFileNormal(\n path.join(baseDir, entryPath),\n finalHtmlContent,\n context,\n );\n\n // Write RSC payload with hashed filename\n await writeFileNormal(\n path.join(baseDir, getRscPayloadPath(mainPayloadHash).replace(/^\\//, \"\")),\n appRscContent,\n context,\n );\n}\n\nasync function writeFileNormal(\n filePath: string,\n data: string,\n context: MinimalPluginContextWithoutEnvironment,\n name?: string,\n) {\n await mkdir(path.dirname(filePath), { recursive: true });\n const nameInfo = name ? ` (${name})` : \"\";\n context.info(`[funstack] Writing ${filePath}${nameInfo}`);\n await writeFile(filePath, data);\n}\n"],"mappings":";;;;;;;;;;;AAYA,eAAsB,SACpB,SACA,SACA;CACA,MAAM,EAAE,WAAW;CAGnB,MAAM,QAAuC,MAAM,OACjD,cAFgB,KAAK,KAAK,OAAO,aAAa,IAAI,MAAM,QAAQ,WAAW,CAEnD,CAAC;CAG3B,MAAM,UAAU,OAAO,aAAa,OAAO,MAAM;CACjD,MAAM,OAAO,cAAc,OAAO,KAAK;CAEvC,MAAM,EAAE,SAAS,kBAAkB,MAAM,MAAM,OAAO;CAGtD,MAAM,QAAkB,EAAE;AAC1B,MAAK,MAAM,UAAU,SAAS;EAC5B,MAAM,QAAQ,kBAAkB,OAAO,KAAK;AAC5C,MAAI,MACF,OAAM,IAAI,MAAM,MAAM;AAExB,QAAM,KAAK,OAAO,KAAK;;CAEzB,MAAM,WAAW,oBAAoB,MAAM;AAC3C,KAAI,SACF,OAAM,IAAI,MAAM,SAAS;CAK3B,MAAM,cAAc,IAAI,eAA2B,EACjD,MAAM,YAAY;AAChB,aAAW,OAAO;IAErB,CAAC;CACF,MAAM,EAAE,YAAY,cAAc,MAAM,qBACtC,cAAc,SAAS,EACvB,aACA,QACD;AAGD,MAAK,MAAM,UAAU,QACnB,OAAM,iBAAiB,QAAQ,WAAW,SAAS,MAAM,QAAQ;AAInE,MAAK,MAAM,EAAE,SAAS,cAAc,UAAU,WAK5C,OAAM,gBAJW,KAAK,KACpB,SACA,iBAAiB,QAAQ,CAAC,QAAQ,OAAO,GAAG,CAC7C,EAC+B,cAAc,SAAS,KAAK;;AAIhE,SAAS,cAAc,MAAsB;CAC3C,MAAM,aAAa,KAAK,SAAS,IAAI,GAAG,KAAK,MAAM,GAAG,GAAG,GAAG;AAC5D,QAAO,eAAe,MAAM,KAAK;;;;;AAMnC,SAAS,oBACP,SACA,WACQ;CACR,IAAI,SAAS;AACb,MAAK,MAAM,CAAC,OAAO,UAAU,UAC3B,KAAI,UAAU,MACZ,UAAS,OAAO,WAAW,OAAO,MAAM;AAG5C,QAAO;;AAGT,eAAe,iBACb,QACA,WACA,SACA,MACA,SACA;CACA,MAAM,EAAE,MAAM,WAAW,MAAM,WAAW;CAG1C,MAAM,cAAc,MAAM,YAAY,KAAK;CAI3C,MAAM,gBAAgB,oBADG,MAAM,YAAY,OAAO,EACU,UAAU;CAGtE,MAAM,kBAAkB,MAAM,mBAAmB,cAAc;CAC/D,MAAM,kBACJ,SAAS,KACL,kBAAkB,gBAAgB,GAClC,OAAO,kBAAkB,gBAAgB;CAG/C,MAAM,mBAAmB,YAAY,WACnC,uBACA,gBACD;AAGD,OAAM,gBACJ,KAAK,KAAK,SAAS,UAAU,EAC7B,kBACA,QACD;AAGD,OAAM,gBACJ,KAAK,KAAK,SAAS,kBAAkB,gBAAgB,CAAC,QAAQ,OAAO,GAAG,CAAC,EACzE,eACA,QACD;;AAGH,eAAe,gBACb,UACA,MACA,SACA,MACA;AACA,OAAM,MAAM,KAAK,QAAQ,SAAS,EAAE,EAAE,WAAW,MAAM,CAAC;CACxD,MAAM,WAAW,OAAO,KAAK,KAAK,KAAK;AACvC,SAAQ,KAAK,sBAAsB,WAAW,WAAW;AACzD,OAAM,UAAU,UAAU,KAAK"}
@@ -0,0 +1,28 @@
1
+ //#region src/build/validateEntryPath.ts
2
+ /**
3
+ * Validates an entry path string.
4
+ * - Must end with ".html"
5
+ * - Must not start with "/"
6
+ *
7
+ * @returns An error message if invalid, or undefined if valid.
8
+ */
9
+ function validateEntryPath(entryPath) {
10
+ if (entryPath.startsWith("/")) return `Entry path must not start with "/": "${entryPath}". Paths are relative to the output directory.`;
11
+ if (!entryPath.endsWith(".html")) return `Entry path must end with ".html": "${entryPath}"`;
12
+ }
13
+ /**
14
+ * Checks an array of entry paths for duplicates.
15
+ *
16
+ * @returns An error message if duplicates found, or undefined if all unique.
17
+ */
18
+ function checkDuplicatePaths(paths) {
19
+ const seen = /* @__PURE__ */ new Set();
20
+ for (const p of paths) {
21
+ if (seen.has(p)) return `Duplicate entry path: "${p}"`;
22
+ seen.add(p);
23
+ }
24
+ }
25
+
26
+ //#endregion
27
+ export { checkDuplicatePaths, validateEntryPath };
28
+ //# sourceMappingURL=validateEntryPath.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validateEntryPath.mjs","names":[],"sources":["../../src/build/validateEntryPath.ts"],"sourcesContent":["/**\n * Validates an entry path string.\n * - Must end with \".html\"\n * - Must not start with \"/\"\n *\n * @returns An error message if invalid, or undefined if valid.\n */\nexport function validateEntryPath(entryPath: string): string | undefined {\n if (entryPath.startsWith(\"/\")) {\n return `Entry path must not start with \"/\": \"${entryPath}\". Paths are relative to the output directory.`;\n }\n if (!entryPath.endsWith(\".html\")) {\n return `Entry path must end with \".html\": \"${entryPath}\"`;\n }\n return undefined;\n}\n\n/**\n * Checks an array of entry paths for duplicates.\n *\n * @returns An error message if duplicates found, or undefined if all unique.\n */\nexport function checkDuplicatePaths(paths: string[]): string | undefined {\n const seen = new Set<string>();\n for (const p of paths) {\n if (seen.has(p)) {\n return `Duplicate entry path: \"${p}\"`;\n }\n seen.add(p);\n }\n return undefined;\n}\n"],"mappings":";;;;;;;;AAOA,SAAgB,kBAAkB,WAAuC;AACvE,KAAI,UAAU,WAAW,IAAI,CAC3B,QAAO,wCAAwC,UAAU;AAE3D,KAAI,CAAC,UAAU,SAAS,QAAQ,CAC9B,QAAO,sCAAsC,UAAU;;;;;;;AAU3D,SAAgB,oBAAoB,OAAqC;CACvE,MAAM,uBAAO,IAAI,KAAa;AAC9B,MAAK,MAAM,KAAK,OAAO;AACrB,MAAI,KAAK,IAAI,EAAE,CACb,QAAO,0BAA0B,EAAE;AAErC,OAAK,IAAI,EAAE"}
@@ -177,4 +177,5 @@ This registers the `funstack-static-knowledge` skill, which provides your AI ass
177
177
 
178
178
  - Learn about the [funstackStatic() Plugin API](/funstack-static/api/funstack-static) for configuration options
179
179
  - Understand [defer()](/funstack-static/api/defer) for Server Component chunk splitting
180
+ - Build multi-page static sites with [Multiple Entrypoints](/funstack-static/learn/multiple-entrypoints)
180
181
  - Dive into [React Server Components](/funstack-static/learn/rsc) concepts
@@ -0,0 +1,139 @@
1
+ # EntryDefinition
2
+
3
+ The `EntryDefinition` type defines a single entry in a multi-page static site. Each entry produces one HTML file with its own RSC payload.
4
+
5
+ ## Import
6
+
7
+ ```typescript
8
+ import type { EntryDefinition } from "@funstack/static/entries";
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```tsx
14
+ import type { EntryDefinition } from "@funstack/static/entries";
15
+
16
+ export default function getEntries(): EntryDefinition[] {
17
+ return [
18
+ {
19
+ path: "index.html",
20
+ root: () => import("./root"),
21
+ app: () => import("./pages/Home"),
22
+ },
23
+ {
24
+ path: "about.html",
25
+ root: () => import("./root"),
26
+ app: () => import("./pages/About"),
27
+ },
28
+ ];
29
+ }
30
+ ```
31
+
32
+ ## Type Definition
33
+
34
+ ```typescript
35
+ interface EntryDefinition {
36
+ path: string;
37
+ root: MaybePromise<RootModule> | (() => MaybePromise<RootModule>);
38
+ app: ReactNode | MaybePromise<AppModule> | (() => MaybePromise<AppModule>);
39
+ }
40
+
41
+ type MaybePromise<T> = T | Promise<T>;
42
+ type RootModule = {
43
+ default: React.ComponentType<{ children: React.ReactNode }>;
44
+ };
45
+ type AppModule = { default: React.ComponentType };
46
+ ```
47
+
48
+ ## Properties
49
+
50
+ ### path
51
+
52
+ **Type:** `string`
53
+
54
+ Output file path relative to the build output directory.
55
+
56
+ - Must end with `.html`
57
+ - Must not start with `/`
58
+ - Duplicate paths cause a build error
59
+
60
+ ```typescript
61
+ "index.html"; // -> /
62
+ "about.html"; // -> /about
63
+ "blog/post-1.html"; // -> /blog/post-1
64
+ "blog/post-1/index.html"; // -> /blog/post-1/
65
+ ```
66
+
67
+ ### root
68
+
69
+ **Type:** `MaybePromise<RootModule> | (() => MaybePromise<RootModule>)`
70
+
71
+ The root component module. The module must have a `default` export of a component that accepts `{ children: React.ReactNode }`.
72
+
73
+ ```tsx
74
+ // Lazy import (recommended)
75
+ root: () => import("./root"),
76
+
77
+ // Synchronous module object
78
+ import Root from "./root";
79
+ root: { default: Root },
80
+
81
+ // Promise
82
+ root: import("./root"),
83
+ ```
84
+
85
+ Using lazy imports (`() => import(...)`) is recommended to keep memory usage low when generating many entries.
86
+
87
+ ### app
88
+
89
+ **Type:** `ReactNode | MaybePromise<AppModule> | (() => MaybePromise<AppModule>)`
90
+
91
+ The app content for this entry. Accepts three forms:
92
+
93
+ **Module (sync or lazy)** -- the module must have a `default` export component:
94
+
95
+ ```tsx
96
+ // Lazy import
97
+ app: () => import("./pages/Home"),
98
+
99
+ // Synchronous module
100
+ import Home from "./pages/Home";
101
+ app: { default: Home },
102
+ ```
103
+
104
+ **React node** -- server component JSX rendered directly:
105
+
106
+ ```tsx
107
+ app: <BlogPost slug="hello-world" content={content} />,
108
+ ```
109
+
110
+ The React node form enables parameterized rendering for SSG, where each entry passes different data to the same component without needing a separate module file.
111
+
112
+ ## Return Type of getEntries
113
+
114
+ The entries function can return either a synchronous iterable or an async iterable:
115
+
116
+ ```typescript
117
+ type GetEntriesResult =
118
+ | Iterable<EntryDefinition>
119
+ | AsyncIterable<EntryDefinition>;
120
+ ```
121
+
122
+ This allows returning arrays, generators, or async generators:
123
+
124
+ ```tsx
125
+ // Array
126
+ export default function getEntries(): EntryDefinition[] {
127
+ return [{ path: "index.html", root: ..., app: ... }];
128
+ }
129
+
130
+ // Async generator
131
+ export default async function* getEntries() {
132
+ yield { path: "index.html", root: ..., app: ... };
133
+ }
134
+ ```
135
+
136
+ ## See Also
137
+
138
+ - [Multiple Entrypoints](/funstack-static/learn/multiple-entrypoints) - Guide and examples
139
+ - [funstackStatic()](/funstack-static/api/funstack-static) - Plugin configuration
@@ -10,6 +10,12 @@ import funstackStatic from "@funstack/static";
10
10
 
11
11
  ## Usage
12
12
 
13
+ There are two configuration modes: **single-entry** (one HTML page) and **multiple entries** (multiple HTML pages).
14
+
15
+ ### Single-Entry Mode
16
+
17
+ Use `root` and `app` to produce a single `index.html`:
18
+
13
19
  ```typescript
14
20
  // vite.config.ts
15
21
  import funstackStatic from "@funstack/static";
@@ -25,14 +31,41 @@ export default defineConfig({
25
31
  });
26
32
  ```
27
33
 
34
+ ### Multiple Entries Mode
35
+
36
+ Use `entries` to produce multiple HTML pages from a single project:
37
+
38
+ ```typescript
39
+ // vite.config.ts
40
+ import funstackStatic from "@funstack/static";
41
+ import react from "@vitejs/plugin-react";
42
+ import { defineConfig } from "vite";
43
+
44
+ export default defineConfig({
45
+ plugins: [
46
+ funstackStatic({
47
+ entries: "./src/entries.tsx",
48
+ }),
49
+ react(),
50
+ ],
51
+ });
52
+ ```
53
+
54
+ See [Multiple Entrypoints](/funstack-static/learn/multiple-entrypoints) for a full guide.
55
+
28
56
  ## Options
29
57
 
30
- ### root (required)
58
+ The plugin accepts either `root` + `app` (single-entry) or `entries` (multiple entries). These two modes are mutually exclusive.
59
+
60
+ ### root
31
61
 
32
62
  **Type:** `string`
63
+ **Required in:** single-entry mode
33
64
 
34
65
  Path to the root component file. This component wraps your entire application and defines the HTML document structure (`<html>`, `<head>`, `<body>`).
35
66
 
67
+ Cannot be used together with `entries`.
68
+
36
69
  ```typescript
37
70
  funstackStatic({
38
71
  root: "./src/root.tsx",
@@ -57,12 +90,15 @@ export default function Root({ children }: { children: React.ReactNode }) {
57
90
  }
58
91
  ```
59
92
 
60
- ### app (required)
93
+ ### app
61
94
 
62
95
  **Type:** `string`
96
+ **Required in:** single-entry mode
63
97
 
64
98
  Path to the app component file. This component defines your application's content.
65
99
 
100
+ Cannot be used together with `entries`.
101
+
66
102
  ```typescript
67
103
  funstackStatic({
68
104
  root: "./src/root.tsx",
@@ -85,6 +121,45 @@ export default function App() {
85
121
 
86
122
  **Note:** if your app has multiple pages, you can use a routing library here just like in a traditional SPA.
87
123
 
124
+ ### entries
125
+
126
+ **Type:** `string`
127
+ **Required in:** multiple entries mode
128
+
129
+ Path to an entries module that exports a function returning entry definitions. Each entry produces its own HTML file.
130
+
131
+ Cannot be used together with `root` or `app`.
132
+
133
+ ```typescript
134
+ funstackStatic({
135
+ entries: "./src/entries.tsx",
136
+ });
137
+ ```
138
+
139
+ The entries module must default-export a function that returns entry definitions:
140
+
141
+ ```tsx
142
+ // src/entries.tsx
143
+ import type { EntryDefinition } from "@funstack/static/entries";
144
+
145
+ export default function getEntries(): EntryDefinition[] {
146
+ return [
147
+ {
148
+ path: "index.html",
149
+ root: () => import("./root"),
150
+ app: () => import("./pages/Home"),
151
+ },
152
+ {
153
+ path: "about.html",
154
+ root: () => import("./root"),
155
+ app: () => import("./pages/About"),
156
+ },
157
+ ];
158
+ }
159
+ ```
160
+
161
+ See [Multiple Entrypoints](/funstack-static/learn/multiple-entrypoints) for details on the `EntryDefinition` type and advanced usage patterns like async generators.
162
+
88
163
  ### publicOutDir (optional)
89
164
 
90
165
  **Type:** `string`
@@ -153,6 +228,8 @@ Sentry.init({
153
228
 
154
229
  ## Full Example
155
230
 
231
+ ### Single-Entry
232
+
156
233
  ```typescript
157
234
  // vite.config.ts
158
235
  import { funstackStatic } from "@funstack/static";
@@ -169,6 +246,24 @@ export default defineConfig({
169
246
  });
170
247
  ```
171
248
 
249
+ ### Multiple Entries
250
+
251
+ ```typescript
252
+ // vite.config.ts
253
+ import funstackStatic from "@funstack/static";
254
+ import react from "@vitejs/plugin-react";
255
+ import { defineConfig } from "vite";
256
+
257
+ export default defineConfig({
258
+ plugins: [
259
+ funstackStatic({
260
+ entries: "./src/entries.tsx",
261
+ }),
262
+ react(),
263
+ ],
264
+ });
265
+ ```
266
+
172
267
  ## Vite Commands
173
268
 
174
269
  You can use the same Vite commands you would use in a normal Vite project:
@@ -180,5 +275,6 @@ You can use the same Vite commands you would use in a normal Vite project:
180
275
  ## See Also
181
276
 
182
277
  - [Getting Started](/funstack-static/getting-started) - Quick start guide
278
+ - [Multiple Entrypoints](/funstack-static/learn/multiple-entrypoints) - Multi-page static site generation
183
279
  - [defer()](/funstack-static/api/defer) - Deferred rendering for streaming
184
280
  - [React Server Components](/funstack-static/learn/rsc) - Understanding RSC
@@ -11,12 +11,15 @@ A Vite plugin for building static sites with React Server Components.
11
11
  ### API
12
12
 
13
13
  - [defer()](./api/Defer.md) - The `defer()` function enables deferred rendering for React Server Components, reducing initial data load.
14
+ - [EntryDefinition](./api/EntryDefinition.md) - The `EntryDefinition` type defines a single entry in a multi-page static site. Each entry produces one HTML file with its own RSC payload.
14
15
  - [funstackStatic()](./api/FunstackStatic.md) - The `funstackStatic()` function is the main Vite plugin that enables React Server Components for building SPAs without a runtime server.
15
16
 
16
17
  ### Learn
17
18
 
19
+ - [Prefetching with defer() and Activity](./learn/DeferAndActivity.md) - When using `defer()` to split RSC payloads, content is fetched on-demand as it renders. But what if you want to start fetching _before_ the user actually needs it? By combining `defer()` with React 19's `<Activity>` component, you can prefetch deferred payloads in the background so they're ready instantly when revealed.
18
20
  - [How It Works](./learn/HowItWorks.md) - FUNSTACK Static is a React framework that leverages React Server Components (RSC) to build a fully static Single Page Application. The result is a set of files that can be deployed to **any static file hosting service** - no server required at runtime.
19
21
  - [Using lazy() in Server Components](./learn/LazyServerComponents.md) - React's `lazy()` API is typically associated with client-side code splitting. However, it can also be used in server environments to reduce the initial response time of the development server by deferring the work needed to compute your application.
22
+ - [Multiple Entrypoints](./learn/MultipleEntrypoints.md) - By default, FUNSTACK Static produces a single `index.html` from one `root` + `app` pair. The **multiple entries** feature lets you produce multiple HTML pages from a single project, targeting SSG (Static Site Generation) use cases where a site has distinct pages like `index.html`, `about.html`, and `blog/post-1.html`.
20
23
  - [Optimizing RSC Payloads](./learn/OptimizingPayloads.md) - FUNSTACK Static uses React Server Components (RSC) to pre-render your application at build time. By default, all content is bundled into a single RSC payload. This page explains how to split that payload into smaller chunks for better loading performance.
21
24
  - [React Server Components](./learn/RSC.md) - [React Server Components (RSC)](https://react.dev/reference/rsc/server-components) are a new paradigm for building React applications where components can run on the server (or at build time) rather than in the browser.
22
25
  - [Server-Side Rendering](./learn/SSR.md) - In FUNSTACK Static, **Server-Side Rendering (SSR)** means a build-time process that pre-renders your React components (including client components) to HTML. This can make the initial paint faster.
@@ -0,0 +1,176 @@
1
+ # Prefetching with defer() and Activity
2
+
3
+ When using `defer()` to split RSC payloads, content is fetched on-demand as it renders. But what if you want to start fetching _before_ the user actually needs it? By combining `defer()` with React 19's `<Activity>` component, you can prefetch deferred payloads in the background so they're ready instantly when revealed.
4
+
5
+ ## What Is Activity?
6
+
7
+ `<Activity>` is a React 19 component that controls whether its children are visible or hidden:
8
+
9
+ ```tsx
10
+ import { Activity } from "react";
11
+
12
+ <Activity mode="visible">
13
+ <Panel /> {/* Rendered and visible */}
14
+ </Activity>
15
+
16
+ <Activity mode="hidden">
17
+ <Panel /> {/* Rendered but not visible in the DOM */}
18
+ </Activity>
19
+ ```
20
+
21
+ When `mode` is `"hidden"`, React still renders the children, but the output is hidden from the user and effects are not run until the `mode` becomes `"visible"`. The hidden content is rendered at a lower priority so it doesn't affect the performance of visible content. This is useful for keeping off-screen UI alive (preserving state, pre-warming components) without showing it.
22
+
23
+ ## How defer() and Activity Work Together
24
+
25
+ Recall that `defer()` wraps a server component so its RSC payload is fetched separately from the main payload. The fetch starts when the deferred component renders on the client.
26
+
27
+ The key insight is: **rendering under `<Activity mode="hidden">` still triggers the fetch**. The deferred component renders (starting the network request for its RSC payload), but the result isn't displayed. When you later switch the Activity to `"visible"`, the content appears immediately because the payload has already been downloaded.
28
+
29
+ 1. `<Activity mode="hidden">` renders defer()'ed content
30
+ 2. Client starts fetching the RSC payload in the background
31
+ 3. Payload arrives and is cached
32
+ 4. User triggers the UI to become visible
33
+ 5. `<Activity mode="visible">` — content shows instantly, no loading state
34
+
35
+ ## Example: Tabbed Interface
36
+
37
+ Consider a tabbed interface where each tab contains heavy server-rendered content. Without prefetching, switching tabs shows a loading spinner while the payload is fetched. With `<Activity>`, you can prefetch all tabs on initial load.
38
+
39
+ ```tsx
40
+ "use client";
41
+
42
+ import { Activity, Suspense, useState } from "react";
43
+
44
+ export function Tabs({
45
+ tabs,
46
+ }: {
47
+ tabs: { label: string; content: React.ReactNode }[];
48
+ }) {
49
+ const [activeIndex, setActiveIndex] = useState(0);
50
+
51
+ return (
52
+ <div>
53
+ <div role="tablist">
54
+ {tabs.map((tab, i) => (
55
+ <button
56
+ key={i}
57
+ role="tab"
58
+ aria-selected={i === activeIndex}
59
+ onClick={() => setActiveIndex(i)}
60
+ >
61
+ {tab.label}
62
+ </button>
63
+ ))}
64
+ </div>
65
+ {tabs.map((tab, i) => (
66
+ <Activity key={i} mode={i === activeIndex ? "visible" : "hidden"}>
67
+ <div role="tabpanel">
68
+ <Suspense fallback={<p>Loading...</p>}>{tab.content}</Suspense>
69
+ </div>
70
+ </Activity>
71
+ ))}
72
+ </div>
73
+ );
74
+ }
75
+ ```
76
+
77
+ On the server side, each tab's content is wrapped with `defer()`:
78
+
79
+ ```tsx
80
+ import { defer } from "@funstack/static/server";
81
+ import { Tabs } from "./Tabs";
82
+ import Overview from "./tabs/Overview";
83
+ import Specifications from "./tabs/Specifications";
84
+ import Reviews from "./tabs/Reviews";
85
+
86
+ function ProductPage() {
87
+ return (
88
+ <Tabs
89
+ tabs={[
90
+ {
91
+ label: "Overview",
92
+ content: defer(<Overview />, { name: "Overview" }),
93
+ },
94
+ {
95
+ label: "Specifications",
96
+ content: defer(<Specifications />, { name: "Specifications" }),
97
+ },
98
+ {
99
+ label: "Reviews",
100
+ content: defer(<Reviews />, { name: "Reviews" }),
101
+ },
102
+ ]}
103
+ />
104
+ );
105
+ }
106
+ ```
107
+
108
+ When this page loads:
109
+
110
+ 1. The active tab ("Overview") renders visibly and fetches its payload
111
+ 2. The hidden tabs ("Specifications", "Reviews") also render under `<Activity mode="hidden">`, triggering their payload fetches in the background
112
+ 3. When the user clicks "Specifications", it appears instantly — no spinner
113
+
114
+ ## Example: Collapsible Sections
115
+
116
+ Another common pattern is pre-fetching content inside a collapsible section:
117
+
118
+ ```tsx
119
+ "use client";
120
+
121
+ import { Activity, Suspense, useState } from "react";
122
+
123
+ export function Collapsible({
124
+ title,
125
+ children,
126
+ }: {
127
+ title: string;
128
+ children: React.ReactNode;
129
+ }) {
130
+ const [isOpen, setIsOpen] = useState(false);
131
+
132
+ return (
133
+ <div>
134
+ <button onClick={() => setIsOpen(!isOpen)}>{title}</button>
135
+ <Activity mode={isOpen ? "visible" : "hidden"}>
136
+ <Suspense fallback={<p>Loading...</p>}>{children}</Suspense>
137
+ </Activity>
138
+ </div>
139
+ );
140
+ }
141
+ ```
142
+
143
+ ```tsx
144
+ import { defer } from "@funstack/static/server";
145
+ import { Collapsible } from "./Collapsible";
146
+ import DetailedSpecs from "./DetailedSpecs";
147
+
148
+ function Product() {
149
+ return (
150
+ <Collapsible title="Show detailed specifications">
151
+ {defer(<DetailedSpecs />, { name: "DetailedSpecs" })}
152
+ </Collapsible>
153
+ );
154
+ }
155
+ ```
156
+
157
+ Even though the section starts collapsed, `<Activity mode="hidden">` causes the deferred content to start fetching immediately. When the user expands the section, the content is already there.
158
+
159
+ ## When to Use This Pattern
160
+
161
+ This pattern works best when:
162
+
163
+ - **Content is likely to be needed soon** — If the user will probably view a tab or expand a section, prefetching eliminates the wait.
164
+ - **Payload sizes are reasonable** — Prefetching multiple large payloads may waste bandwidth if the user never views them. Use judgment based on your typical user behavior.
165
+ - **You want instant transitions** — Tabs, accordions, and similar reveal-based UI patterns benefit most from this approach.
166
+
167
+ Avoid this pattern when:
168
+
169
+ - **Content is rarely accessed** — Prefetching content most users never see wastes bandwidth.
170
+ - **You have many deferred sections** — Prefetching dozens of payloads simultaneously may slow down the initial page load. Consider prefetching only the most likely targets.
171
+
172
+ ## See Also
173
+
174
+ - [Optimizing RSC Payloads](/funstack-static/learn/optimizing-payloads) - Using `defer()` to split RSC payloads
175
+ - [defer()](/funstack-static/api/defer) - API reference with full signature and technical details
176
+ - [React Server Components](/funstack-static/learn/rsc) - Understanding RSC fundamentals