Repository: dai-shi/waku
Pull request #966 (2024-10-15 06:27:03)

feat: regenerate fs-router types
This moves the generation from fs-router types off of `createPages`

Happy to move things around or update examples as needed, but I think this solution simplifies our current uses nicely

vercel[bot]: [vc]: #xIZFewQXbX3ZJCXBpJVKfUsg2+bAcU44HQuP3DCHxdw=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJ3YWt1Iiwicm9vdERpcmVjdG9yeSI6bnVsbCwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL2RhaXNoaXMtcHJvamVjdHMvd2FrdS8xUkhjbUU1Z0t4YVBvaHQ1c0Q4OHNDZTV5RVZLIiwicHJldmlld1VybCI6Indha3UtZ2l0LWZvcmstdHlsZXJzYXlzaGktZ2VuZXJhdGUtZnMtcm91dGVyLWRhaXNoaXMtcHJvamVjdHMudmVyY2VsLmFwcCIsIm5leHRDb21taXRTdGF0dXMiOiJQRU5ESU5HIn1dfQ==
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Updated (UTC) |
| :--- | :----- | :------ | :------ |
| **waku** | 🔄 Building ([Inspect](https://vercel.com/daishis-projects/waku/1RHcmE5gKxaPoht5sD88sCe5yEVK)) | [Visit Preview](https://waku-git-fork-tylersayshi-generate-fs-router-daishis-projects.vercel.app) | Oct 15, 2024 6:27am |


codesandbox-ci[bot]: This pull request is automatically built and testable in [CodeSandbox](https://codesandbox.io).

  To see build info of the built libraries, click [here](https://ci.codesandbox.io/status/dai-shi/waku/pr/966/builds/551166) or the icon next to each commit SHA.
tylersayshi: marking draft to fix the test where this can be generated to a broken file: 

<img width="438" alt="image" src="https://github.com/user-attachments/assets/a5a50730-a9e1-454b-9ef0-b67cfd6ca5c3">

tylersayshi: this: https://github.com/dai-shi/waku/pull/966#issuecomment-2415080194 is fixed ✅ 

Patch:
diff --git a/.gitignore b/.gitignore
index 77c0d84c..2fdb850c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,4 +11,4 @@ dist
 .cache
 .nvmrc
 .node-version
-entries.gen.tsx
+pages.gen.ts
diff --git a/packages/waku/src/lib/plugins/vite-plugin-fs-router-typegen.ts b/packages/waku/src/lib/plugins/vite-plugin-fs-router-typegen.ts
index dd9538d0..89a38740 100644
--- a/packages/waku/src/lib/plugins/vite-plugin-fs-router-typegen.ts
+++ b/packages/waku/src/lib/plugins/vite-plugin-fs-router-typegen.ts
@@ -1,8 +1,9 @@
 import type { Plugin } from 'vite';
 import { readdir, writeFile } from 'node:fs/promises';
-import { existsSync, readFileSync } from 'node:fs';
+import { existsSync } from 'node:fs';
 import { SRC_ENTRIES, EXTENSIONS } from '../constants.js';
 import { joinPath } from '../utils/path.js';
+import { parseFileSync } from '@swc/core';
 
 const SRC_PAGES = 'pages';
 
@@ -66,7 +67,7 @@ export const fsRouterTypegenPlugin = (opts: { srcDir: string }): Plugin => {
       entriesFilePossibilities = EXTENSIONS.map((ext) =>
         joinPath(config.root, opts.srcDir, SRC_ENTRIES + ext),
       );
-      outputFile = joinPath(config.root, opts.srcDir, `${SRC_ENTRIES}.gen.tsx`);
+      outputFile = joinPath(config.root, opts.srcDir, 'pages.gen.ts');
 
       try {
         const prettier = await import('prettier');
@@ -117,16 +118,40 @@ export const fsRouterTypegenPlugin = (opts: { srcDir: string }): Plugin => {
         if (!pagesDir) {
           return false;
         }
-        const file = readFileSync(pagesDir + filePath).toString();
+        const file = parseFileSync(pagesDir + filePath, {
+          syntax: 'typescript',
+          tsx: true,
+        });
+
+        return file.body.some((node) => {
+          if (node.type === 'ExportNamedDeclaration') {
+            return node.specifiers.some(
+              (specifier) =>
+                specifier.type === 'ExportSpecifier' &&
+                !specifier.isTypeOnly &&
+                ((!specifier.exported &&
+                  specifier.orig.value === 'getConfig') ||
+                  specifier.exported?.value === 'getConfig'),
+            );
+          }
 
-        return (
-          file.includes('const getConfig') ||
-          file.includes('function getConfig')
-        );
+          return (
+            node.type === 'ExportDeclaration' &&
+            ((node.declaration.type === 'VariableDeclaration' &&
+              node.declaration.declarations.some(
+                (decl) =>
+                  decl.id.type === 'Identifier' &&
+                  decl.id.value === 'getConfig',
+              )) ||
+              (node.declaration.type === 'FunctionDeclaration' &&
+                node.declaration.identifier.value === 'getConfig'))
+          );
+        });
       };
 
       const generateFile = (filePaths: string[]): string => {
-        const fileInfo = [];
+        const fileInfo: { path: string; src: string; hasGetConfig: boolean }[] =
+          [];
         const moduleNames = getImportModuleNames(filePaths);
 
         for (const filePath of filePaths) {
@@ -135,23 +160,16 @@ export const fsRouterTypegenPlugin = (opts: { srcDir: string }): Plugin => {
           const hasGetConfig = fileExportsGetConfig(filePath);
 
           if (filePath.endsWith('/_layout.tsx')) {
-            fileInfo.push({
-              type: 'layout',
-              path: filePath.replace('_layout.tsx', ''),
-              src,
-              hasGetConfig,
-            });
+            continue;
           } else if (filePath.endsWith('/index.tsx')) {
             const path = filePath.slice(0, -'/index.tsx'.length);
             fileInfo.push({
-              type: 'page',
               path: path || '/',
               src,
               hasGetConfig,
             });
           } else {
             fileInfo.push({
-              type: 'page',
               path: filePath.replace('.tsx', ''),
               src,
               hasGetConfig,
@@ -159,33 +177,37 @@ export const fsRouterTypegenPlugin = (opts: { srcDir: string }): Plugin => {
           }
         }
 
-        let result = `import { createPages } from 'waku';
-import type { PathsForPages } from 'waku/router';\n\n`;
+        let result = `import type { PathsForPages, GetConfigResponse } from 'waku/router';\n\n`;
 
         for (const file of fileInfo) {
           const moduleName = moduleNames[file.src];
-          result += `import ${moduleName}${file.hasGetConfig ? `, { getConfig as ${moduleName}_getConfig }` : ''} from './${SRC_PAGES}/${file.src.replace('.tsx', '')}';\n`;
+          if (file.hasGetConfig) {
+            result += `import type { getConfig as ${moduleName}_getConfig } from './${SRC_PAGES}/${file.src.replace('.tsx', '')}';\n`;
+          }
         }
 
-        result += `\nconst _pages = createPages(async (pagesFns) => [\n`;
+        result += `\ntype Page = {
+  DO_NOT_USE_pages:`;
 
         for (const file of fileInfo) {
           const moduleName = moduleNames[file.src];
-          result += `  pagesFns.${file.type === 'layout' ? 'createLayout' : 'createPage'}({ path: '${file.path}', component: ${moduleName}, ${file.hasGetConfig ? `...(await ${moduleName}_getConfig())` : `render: '${file.type === 'layout' ? 'static' : 'dynamic'}'`} }),\n`;
+          if (file.hasGetConfig) {
+            result += `| ({path: '${file.path}'} & GetConfigResponse<typeof ${moduleName}_getConfig>)\n`;
+          } else {
+            result += `| {path: '${file.path}'; render: 'dynamic'}\n`;
+          }
         }
 
-        result += `]);
+        result += `};
 
   declare module 'waku/router' {
     interface RouteConfig {
-      paths: PathsForPages<typeof _pages>;
+      paths: PathsForPages<Page>;
     }
     interface CreatePagesConfig {
-      pages: typeof _pages;
+      pages: Page;
     }
   }
-
-  export default _pages;
   `;
 
         return result;
@@ -196,6 +218,9 @@ import type { PathsForPages } from 'waku/router';\n\n`;
           return;
         }
         const files = await collectFiles(pagesDir);
+        if (!files.length) {
+          return;
+        }
         const formatted = await formatter(generateFile(files));
         await writeFile(outputFile, formatted, 'utf-8');
       };
diff --git a/packages/waku/src/router/base-types.ts b/packages/waku/src/router/base-types.ts
index 866473a2..91924547 100644
--- a/packages/waku/src/router/base-types.ts
+++ b/packages/waku/src/router/base-types.ts
@@ -4,7 +4,11 @@ import type {
 } from './create-pages-utils/inferred-path-types.js';
 
 // PathsForPages collects the paths from the `createPages` result type
-export type { PathsForPages } from './create-pages-utils/inferred-path-types.js';
+// GetConfigResponse is a helper for generating types with fs-router
+export type {
+  PathsForPages,
+  GetConfigResponse,
+} from './create-pages-utils/inferred-path-types.js';
 
 // eslint-disable-next-line @typescript-eslint/no-empty-object-type
 export interface RouteConfig {
diff --git a/packages/waku/src/router/create-pages-utils/inferred-path-types.ts b/packages/waku/src/router/create-pages-utils/inferred-path-types.ts
index b6992b0e..47496426 100644
--- a/packages/waku/src/router/create-pages-utils/inferred-path-types.ts
+++ b/packages/waku/src/router/create-pages-utils/inferred-path-types.ts
@@ -186,3 +186,18 @@ export type PropsForPages<Path extends string> = Prettify<
   Omit<RouteProps<ReplaceAll<Path, `[${string}]`, string>>, 'hash'> &
     SlugTypes<Path>
 >;
+
+type GetResponseType<Response extends { render: string }> =
+  string extends Response['render'] ? { render: 'dynamic' } : Response;
+
+/**
+ * Helper used for generation of types with fs-router for
+ * collecting the type of the getConfig function response and
+ * falling back to {render: 'dynamic'} if inference fails.
+ */
+export type GetConfigResponse<
+  Fn extends () => Promise<{ render: string }> | { render: string },
+> =
+  ReturnType<Fn> extends { render: string }
+    ? GetResponseType<ReturnType<Fn>>
+    : GetResponseType<Awaited<ReturnType<Fn>>>;


Test patch:
diff --git a/packages/waku/tests/vite-plugin-fs-router-typegen.test.ts b/packages/waku/tests/vite-plugin-fs-router-typegen.test.ts
index 9e0640c8..36044d01 100644
--- a/packages/waku/tests/vite-plugin-fs-router-typegen.test.ts
+++ b/packages/waku/tests/vite-plugin-fs-router-typegen.test.ts
@@ -87,15 +87,13 @@ describe('vite-plugin-fs-router-typegen', () => {
   test('creates the expected imports the generated entries file', async () => {
     await runTest(
       root,
-      `import CategoryTagsIndex, { getConfig as CategoryTagsIndex_getConfig } from './pages/[category]/[...tags]/index';
-import CategoryLayout, { getConfig as CategoryLayout_getConfig } from './pages/[category]/_layout';
-import Layout, { getConfig as Layout_getConfig } from './pages/_layout';
-import Root, { getConfig as Root_getConfig } from './pages/_root';
-import Index, { getConfig as Index_getConfig } from './pages/index';
-import OneTwoThree, { getConfig as OneTwoThree_getConfig } from './pages/one-two-three';
-import OneTwoThree_1, { getConfig as OneTwoThree_1_getConfig } from './pages/one__two_three';
-import OneTwoThree_2, { getConfig as OneTwoThree_2_getConfig } from './pages/one_two_three';
-import ØnéTwoThree, { getConfig as ØnéTwoThree_getConfig } from './pages/øné_two_three';`,
+      `import type { getConfig as CategoryTagsIndex_getConfig } from './pages/[category]/[...tags]/index';
+import type { getConfig as Root_getConfig } from './pages/_root';
+import type { getConfig as Index_getConfig } from './pages/index';
+import type { getConfig as OneTwoThree_getConfig } from './pages/one-two-three';
+import type { getConfig as OneTwoThree_1_getConfig } from './pages/one__two_three';
+import type { getConfig as OneTwoThree_2_getConfig } from './pages/one_two_three';
+import type { getConfig as ØnéTwoThree_getConfig } from './pages/øné_two_three';`,
     );
   });
 });