Repository: dai-shi/waku
Pull request #922 (2024-09-30 04:29:05)

docs: add example of dynamic layout with static page
you can see the time change as navigating the site which shows that the layout is indeed dynamic

The rest of the home page stays unchanged and can be cached

Example for #885

vercel[bot]: [vc]: #jb1YS/0MoK+5x0zSUbL2Rr66NsLtbulHOv5Pn3Q3R2A=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJ3YWt1Iiwicm9vdERpcmVjdG9yeSI6bnVsbCwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL2RhaXNoaXMtcHJvamVjdHMvd2FrdS85Vk1ERlFiV0FVZU5yVVBadzlnajh3UG5kcEh0IiwicHJldmlld1VybCI6Indha3UtZ2l0LWZvcmstdHlsZXJsYXdzMG4tZG9jcy1leGFtcGxlLWRhaXNoaXMtcHJvamVjdHMudmVyY2VsLmFwcCIsIm5leHRDb21taXRTdGF0dXMiOiJQRU5ESU5HIn1dfQ==
**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/9VMDFQbWAUeNrUPZw9gj8wPndpHt)) | [Visit Preview](https://waku-git-fork-tylerlaws0n-docs-example-daishis-projects.vercel.app) | Sep 30, 2024 4:29am |


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/922/builds/546122) or the icon next to each commit SHA.
tylersayshi: @dai-shi is your general experience with the _layout pattern positive? Or is it not something you use in practice very much?
dai-shi: > @dai-shi is your general experience with the _layout pattern positive? Or is it not something you use in practice very much?

I'm not quite sure if my usage would be similar to everyone's. With fs-router, the root layout would be necessary, but in general, I wouldn't need to use other layouts unless I care performance much.

There are a few things I would like to reconsider, and you can probably help on them:
- separation of unstable_defineRouter and createPages
  - currently, it's a bit confusing. defineRouter can be more primitive, or it's probably just naming.
- improve client cache system with defineRouter
  - Currently it's only layout/layout/page pattern, which is too related with createPages.
  - Components could be freely placed, independent from route path.
- Likewise, shouldSkip mechanism with defineRouter should be revisited.
  - It's hardly undestandable.

Patch:
diff --git a/examples/25_weave_render/package.json b/examples/25_weave_render/package.json
new file mode 100644
index 00000000..9f2b8551
--- /dev/null
+++ b/examples/25_weave_render/package.json
@@ -0,0 +1,23 @@
+{
+  "name": "waku-example",
+  "version": "0.1.0",
+  "type": "module",
+  "private": true,
+  "scripts": {
+    "dev": "waku dev",
+    "build": "waku build",
+    "start": "waku start"
+  },
+  "dependencies": {
+    "react": "19.0.0-rc-d6cb4e77-20240911",
+    "react-dom": "19.0.0-rc-d6cb4e77-20240911",
+    "react-server-dom-webpack": "19.0.0-rc-d6cb4e77-20240911",
+    "waku": "0.21.2"
+  },
+  "devDependencies": {
+    "@types/react": "18.3.5",
+    "@types/react-dom": "18.3.0",
+    "server-only": "0.0.1",
+    "typescript": "5.6.2"
+  }
+}
diff --git a/examples/25_weave_render/src/components/BarLayout.tsx b/examples/25_weave_render/src/components/BarLayout.tsx
new file mode 100644
index 00000000..62a66f5c
--- /dev/null
+++ b/examples/25_weave_render/src/components/BarLayout.tsx
@@ -0,0 +1,64 @@
+import type { ReactNode } from 'react';
+
+import { Link } from 'waku/router/client';
+
+import '../styles.css';
+
+const Pending = ({ isPending }: { isPending: boolean }) => (
+  <span
+    style={{
+      marginLeft: 5,
+      transition: 'opacity 75ms 100ms',
+      opacity: isPending ? 1 : 0,
+    }}
+  >
+    Pending...
+  </span>
+);
+
+const BarLayout = ({ children }: { children: ReactNode }) => {
+  return (
+    <html>
+      <head>
+        <title>Waku</title>
+      </head>
+      <body>
+        <div>
+          <p>This Layout is expected to be static</p>
+          <ul>
+            <li>
+              <Link
+                to="/"
+                pending={<Pending isPending />}
+                notPending={<Pending isPending={false} />}
+              >
+                Home
+              </Link>
+            </li>
+            <li>
+              <Link
+                to="/foo"
+                pending={<Pending isPending />}
+                notPending={<Pending isPending={false} />}
+              >
+                Foo
+              </Link>
+            </li>
+            <li>
+              <Link
+                to="/nested/bar"
+                pending={<Pending isPending />}
+                notPending={<Pending isPending={false} />}
+              >
+                Nested / Bar
+              </Link>
+            </li>
+          </ul>
+          {children}
+        </div>
+      </body>
+    </html>
+  );
+};
+
+export default BarLayout;
diff --git a/examples/25_weave_render/src/components/BarPage.tsx b/examples/25_weave_render/src/components/BarPage.tsx
new file mode 100644
index 00000000..37c1768d
--- /dev/null
+++ b/examples/25_weave_render/src/components/BarPage.tsx
@@ -0,0 +1,10 @@
+import { Counter } from './Counter';
+
+const Bar = () => (
+  <div>
+    <h2>Bar</h2>
+    <Counter />
+  </div>
+);
+
+export default Bar;
diff --git a/examples/25_weave_render/src/components/Counter.tsx b/examples/25_weave_render/src/components/Counter.tsx
new file mode 100644
index 00000000..34c88cd8
--- /dev/null
+++ b/examples/25_weave_render/src/components/Counter.tsx
@@ -0,0 +1,19 @@
+'use client';
+
+import { useState } from 'react';
+
+import { Link, useRouter_UNSTABLE as useRouter } from 'waku/router/client';
+
+export const Counter = () => {
+  const { path } = useRouter();
+  const [count, setCount] = useState(0);
+  return (
+    <div style={{ border: '3px blue dashed', margin: '1em', padding: '1em' }}>
+      <p>Count: {count}</p>
+      <button onClick={() => setCount((c) => c + 1)}>Increment</button>
+      <h3>This is a client component.</h3>
+      <span>path: {path}</span>
+      <Link to="/">Go to Home</Link>
+    </div>
+  );
+};
diff --git a/examples/25_weave_render/src/components/FooLayout.tsx b/examples/25_weave_render/src/components/FooLayout.tsx
new file mode 100644
index 00000000..fa26b3c7
--- /dev/null
+++ b/examples/25_weave_render/src/components/FooLayout.tsx
@@ -0,0 +1,16 @@
+import type { ReactNode } from 'react';
+
+const FooLayout = ({ children }: { children: ReactNode }) => {
+  return (
+    <html>
+      <body>
+        <div>
+          <h1>FOO layout</h1>
+          {children}
+        </div>
+      </body>
+    </html>
+  );
+};
+
+export default FooLayout;
diff --git a/examples/25_weave_render/src/components/FooPage.tsx b/examples/25_weave_render/src/components/FooPage.tsx
new file mode 100644
index 00000000..97cd5d3b
--- /dev/null
+++ b/examples/25_weave_render/src/components/FooPage.tsx
@@ -0,0 +1,12 @@
+import 'server-only';
+
+import { Counter } from './Counter';
+
+const Foo = () => (
+  <div>
+    <h2>Foo</h2>
+    <Counter />
+  </div>
+);
+
+export default Foo;
diff --git a/examples/25_weave_render/src/components/HomeLayout.tsx b/examples/25_weave_render/src/components/HomeLayout.tsx
new file mode 100644
index 00000000..508d1cdf
--- /dev/null
+++ b/examples/25_weave_render/src/components/HomeLayout.tsx
@@ -0,0 +1,68 @@
+import type { ReactNode } from 'react';
+
+import { Link } from 'waku/router/client';
+
+import '../styles.css';
+
+const Pending = ({ isPending }: { isPending: boolean }) => (
+  <span
+    style={{
+      marginLeft: 5,
+      transition: 'opacity 75ms 100ms',
+      opacity: isPending ? 1 : 0,
+    }}
+  >
+    Pending...
+  </span>
+);
+
+const getCurrentTime = () => new Date();
+
+const HomeLayout = ({ children }: { children: ReactNode }) => {
+  const currentTime = getCurrentTime();
+  return (
+    <html>
+      <head>
+        <title>Waku</title>
+      </head>
+      <body>
+        <div>
+          <h1>Home layout</h1>
+          <p>Last render time: {currentTime.toISOString()}</p>
+          <ul>
+            <li>
+              <Link
+                to="/"
+                pending={<Pending isPending />}
+                notPending={<Pending isPending={false} />}
+              >
+                Home
+              </Link>
+            </li>
+            <li>
+              <Link
+                to="/foo"
+                pending={<Pending isPending />}
+                notPending={<Pending isPending={false} />}
+              >
+                Foo
+              </Link>
+            </li>
+            <li>
+              <Link
+                to="/nested/bar"
+                pending={<Pending isPending />}
+                notPending={<Pending isPending={false} />}
+              >
+                Nested / Bar
+              </Link>
+            </li>
+          </ul>
+          {children}
+        </div>
+      </body>
+    </html>
+  );
+};
+
+export default HomeLayout;
diff --git a/examples/25_weave_render/src/components/HomePage.tsx b/examples/25_weave_render/src/components/HomePage.tsx
new file mode 100644
index 00000000..5de06aa0
--- /dev/null
+++ b/examples/25_weave_render/src/components/HomePage.tsx
@@ -0,0 +1,8 @@
+const Home = () => (
+  <div>
+    <h2>Home</h2>
+    <p>This is the home page.</p>
+  </div>
+);
+
+export default Home;
diff --git a/examples/25_weave_render/src/entries.tsx b/examples/25_weave_render/src/entries.tsx
new file mode 100644
index 00000000..462e6289
--- /dev/null
+++ b/examples/25_weave_render/src/entries.tsx
@@ -0,0 +1,57 @@
+import { lazy } from 'react';
+import { createPages } from 'waku';
+import type { PathsForPages } from 'waku/router';
+import FooPage from './components/FooPage';
+import BarPage from './components/BarPage';
+import BarLayout from './components/BarLayout';
+import FooLayout from './components/FooLayout';
+
+// The use of `lazy` is optional and you can use import statements too.
+const HomeLayout = lazy(() => import('./components/HomeLayout'));
+const HomePage = lazy(() => import('./components/HomePage'));
+
+const pages = createPages(async ({ createPage, createLayout }) => [
+  createLayout({
+    render: 'static',
+    path: '/',
+    component: HomeLayout,
+  }),
+
+  createPage({
+    render: 'static',
+    path: '/',
+    component: HomePage,
+  }),
+
+  createLayout({
+    render: 'static',
+    path: '/foo',
+    component: FooLayout,
+  }),
+
+  createPage({
+    render: 'dynamic',
+    path: '/foo',
+    component: FooPage,
+  }),
+
+  createLayout({
+    render: 'dynamic',
+    path: '/bar',
+    component: BarLayout,
+  }),
+
+  createPage({
+    render: 'static',
+    path: '/bar',
+    component: BarPage,
+  }),
+]);
+
+declare module 'waku/router' {
+  interface RouteConfig {
+    paths: PathsForPages<typeof pages>;
+  }
+}
+
+export default pages;
diff --git a/examples/25_weave_render/src/main.tsx b/examples/25_weave_render/src/main.tsx
new file mode 100644
index 00000000..bddb502d
--- /dev/null
+++ b/examples/25_weave_render/src/main.tsx
@@ -0,0 +1,15 @@
+import { StrictMode } from 'react';
+import { createRoot, hydrateRoot } from 'react-dom/client';
+import { Router } from 'waku/router/client';
+
+const rootElement = (
+  <StrictMode>
+    <Router />
+  </StrictMode>
+);
+
+if ((globalThis as any).__WAKU_HYDRATE__) {
+  hydrateRoot(document, rootElement);
+} else {
+  createRoot(document as any).render(rootElement);
+}
diff --git a/examples/25_weave_render/src/styles.css b/examples/25_weave_render/src/styles.css
new file mode 100644
index 00000000..5eb2b9ca
--- /dev/null
+++ b/examples/25_weave_render/src/styles.css
@@ -0,0 +1,3 @@
+body {
+  background-color: #fefefe;
+}
diff --git a/examples/25_weave_render/tsconfig.json b/examples/25_weave_render/tsconfig.json
new file mode 100644
index 00000000..84d0d542
--- /dev/null
+++ b/examples/25_weave_render/tsconfig.json
@@ -0,0 +1,15 @@
+{
+  "compilerOptions": {
+    "strict": true,
+    "target": "esnext",
+    "downlevelIteration": true,
+    "esModuleInterop": true,
+    "module": "esnext",
+    "moduleResolution": "bundler",
+    "skipLibCheck": true,
+    "noUncheckedIndexedAccess": true,
+    "exactOptionalPropertyTypes": true,
+    "types": ["react/experimental"],
+    "jsx": "react-jsx"
+  }
+}
diff --git a/packages/waku/src/lib/middleware/dev-server-impl.ts b/packages/waku/src/lib/middleware/dev-server-impl.ts
index 9c21e2e9..6c8bfda7 100644
--- a/packages/waku/src/lib/middleware/dev-server-impl.ts
+++ b/packages/waku/src/lib/middleware/dev-server-impl.ts
@@ -12,6 +12,7 @@ import {
   fileURLToFilePath,
   encodeFilePathToAbsolute,
   decodeFilePathFromAbsolute,
+  filePathToFileURL,
 } from '../utils/path.js';
 import { patchReactRefresh } from '../plugins/patch-react-refresh.js';
 import { nonjsResolvePlugin } from '../plugins/vite-plugin-nonjs-resolve.js';
@@ -147,7 +148,7 @@ const createMainViteServer = (
       const fileWithAbsolutePath = file.startsWith('/')
         ? file
         : joinPath(vite.config.root, file);
-      return import(/* @vite-ignore */ fileWithAbsolutePath);
+      return import(/* @vite-ignore */ filePathToFileURL(fileWithAbsolutePath));
     }
     return vite.ssrLoadModule(
       idOrFileURL.startsWith('file://')
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 d7b5e73a..0250e536 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
@@ -3,53 +3,54 @@ import { readdir, writeFile } from 'node:fs/promises';
 import { existsSync, readFileSync } from 'node:fs';
 import { SRC_ENTRIES, EXTENSIONS } from '../constants.js';
 import { joinPath } from '../utils/path.js';
+import { getInputString } from '../../router/common.js';
 
 const SRC_PAGES = 'pages';
 
-const srcToName = (src: string) => {
-  const split = src
-    .split('/')
-    .map((part) => part[0]!.toUpperCase() + part.slice(1));
-
-  if (split.at(-1) === '_layout.tsx') {
-    return split.slice(0, -1).join('') + '_Layout';
-  } else if (split.at(-1) === 'index.tsx') {
-    return split.slice(0, -1).join('') + 'Index';
-  } else if (split.at(-1)?.startsWith('[...')) {
-    const fileName = split
-      .at(-1)!
-      .replace('-', '_')
-      .replace('.tsx', '')
-      .replace('[...', '')
-      .replace(']', '');
-    return (
-      split.slice(0, -1).join('') +
-      'Wild' +
-      fileName[0]!.toUpperCase() +
-      fileName.slice(1)
-    );
-  } else if (split.at(-1)?.startsWith('[')) {
-    const fileName = split
-      .at(-1)!
-      .replace('-', '_')
-      .replace('.tsx', '')
-      .replace('[', '')
-      .replace(']', '');
-    return (
-      split.slice(0, -1).join('') +
-      'Slug' +
-      fileName[0]!.toUpperCase() +
-      fileName.slice(1)
-    );
-  } else {
-    const fileName = split.at(-1)!.replace('-', '_').replace('.tsx', '');
-    return (
-      split.slice(0, -1).join('') +
-      fileName[0]!.toUpperCase() +
-      fileName.slice(1)
-    );
+// https://tc39.es/ecma262/multipage/ecmascript-language-lexical-grammar.html#sec-names-and-keywords
+// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Lexical_grammar#identifiers
+// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Lexical_grammar#reserved_words
+export function toIdentifier(input: string): string {
+  // Strip the file extension
+  let identifier = input.includes('.')
+    ? input.split('.').slice(0, -1).join('.')
+    : input;
+  // Replace any characters besides letters, numbers, underscores, and dollar signs with underscores
+  identifier = identifier.replace(/[^\p{L}\p{N}_$]/gu, '_');
+  // Ensure it starts with a letter
+  if (/^\d/.test(identifier)) {
+    identifier = '_' + identifier;
   }
-};
+  // Turn it into PascalCase
+  // Since the first letter is uppercased, it will not be a reserved word
+  return identifier
+    .split('_')
+    .map((part) => {
+      if (part[0] === undefined) return '';
+      return part[0].toUpperCase() + part.slice(1);
+    })
+    .join('');
+}
+
+export function getImportModuleNames(filePaths: string[]): {
+  [k: string]: string;
+} {
+  const moduleNameCount: { [k: string]: number } = {};
+  const moduleNames: { [k: string]: string } = {};
+  for (const filePath of filePaths) {
+    let identifier = toIdentifier(filePath);
+    moduleNameCount[identifier] = (moduleNameCount[identifier] ?? -1) + 1;
+    if (moduleNameCount[identifier]) {
+      identifier = `${identifier}_${moduleNameCount[identifier]}`;
+    }
+    try {
+      moduleNames[getInputString(filePath)] = identifier;
+    } catch (e) {
+      console.log(e);
+    }
+  }
+  return moduleNames;
+}
 
 export const fsRouterTypegenPlugin = (opts: { srcDir: string }): Plugin => {
   let entriesFilePossibilities: string[] | undefined;
@@ -91,20 +92,24 @@ export const fsRouterTypegenPlugin = (opts: { srcDir: string }): Plugin => {
       }
 
       // Recursively collect `.tsx` files in the given directory
-      const collectFiles = async (dir: string): Promise<string[]> => {
-        if (!pagesDir) return [];
-        const results: string[] = [];
-        const files = await readdir(dir, {
-          withFileTypes: true,
-          recursive: true,
-        });
-
-        for (const file of files) {
-          if (file.name.endsWith('.tsx')) {
-            results.push('/' + file.name);
+      const collectFiles = async (
+        dir: string,
+        files: string[] = [],
+      ): Promise<string[]> => {
+        // TODO revisit recursive option for readdir once more stable
+        // https://nodejs.org/docs/latest-v20.x/api/fs.html#direntparentpath
+        const entries = await readdir(dir, { withFileTypes: true });
+        for (const entry of entries) {
+          const fullPath = joinPath(dir, entry.name);
+          if (entry.isDirectory()) {
+            await collectFiles(fullPath, files);
+          } else {
+            if (entry.name.endsWith('.tsx')) {
+              files.push(pagesDir ? fullPath.slice(pagesDir.length) : fullPath);
+            }
           }
         }
-        return results;
+        return files;
       };
 
       const fileExportsGetConfig = (filePath: string) => {
@@ -119,19 +124,21 @@ export const fsRouterTypegenPlugin = (opts: { srcDir: string }): Plugin => {
 
       const generateFile = (filePaths: string[]): string => {
         const fileInfo = [];
+        const moduleNames = getImportModuleNames(filePaths);
+
         for (const filePath of filePaths) {
           // where to import the component from
-          const src = filePath.slice(1);
+          const src = getInputString(filePath);
           const hasGetConfig = fileExportsGetConfig(filePath);
 
-          if (filePath === '/_layout.tsx') {
+          if (filePath.endsWith('/_layout.tsx')) {
             fileInfo.push({
               type: 'layout',
               path: filePath.replace('_layout.tsx', ''),
               src,
               hasGetConfig,
             });
-          } else if (filePath === '/index.tsx') {
+          } else if (filePath.endsWith('/index.tsx')) {
             fileInfo.push({
               type: 'page',
               path: filePath.replace('index.tsx', ''),
@@ -152,19 +159,19 @@ export const fsRouterTypegenPlugin = (opts: { srcDir: string }): Plugin => {
 import type { PathsForPages } from 'waku/router';\n\n`;
 
         for (const file of fileInfo) {
-          const moduleName = srcToName(file.src);
+          const moduleName = moduleNames[file.src];
           result += `import ${moduleName}${file.hasGetConfig ? `, { getConfig as ${moduleName}_getConfig }` : ''} from './${SRC_PAGES}/${file.src.replace('.tsx', '')}';\n`;
         }
 
         result += `\nconst _pages = createPages(async (pagesFns) => [\n`;
 
         for (const file of fileInfo) {
-          const moduleName = srcToName(file.src);
+          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`;
         }
 
         result += `]);
-  
+
   declare module 'waku/router' {
     interface RouteConfig {
       paths: PathsForPages<typeof _pages>;
diff --git "a/packages/waku/tests/fixtures/plugin-fs-router-typegen/pages/\303\270n\303\251_two_three.tsx" "b/packages/waku/tests/fixtures/plugin-fs-router-typegen/pages/\303\270n\303\251_two_three.tsx"
new file mode 100644
index 00000000..c046a3f8
--- /dev/null
+++ "b/packages/waku/tests/fixtures/plugin-fs-router-typegen/pages/\303\270n\303\251_two_three.tsx"
@@ -0,0 +1,9 @@
+export default function Page() {
+  return null;
+}
+
+export const getConfig = async () => {
+  return {
+    render: 'dynamic',
+  };
+};
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a951d674..fbde2cd3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -955,6 +955,34 @@ importers:
         specifier: 5.6.2
         version: 5.6.2
 
+  examples/25_weave_render:
+    dependencies:
+      react:
+        specifier: 19.0.0-rc-d6cb4e77-20240911
+        version: 19.0.0-rc-d6cb4e77-20240911
+      react-dom:
+        specifier: 19.0.0-rc-d6cb4e77-20240911
+        version: 19.0.0-rc-d6cb4e77-20240911(react@19.0.0-rc-d6cb4e77-20240911)
+      react-server-dom-webpack:
+        specifier: 19.0.0-rc-d6cb4e77-20240911
+        version: 19.0.0-rc-d6cb4e77-20240911(react-dom@19.0.0-rc-d6cb4e77-20240911(react@19.0.0-rc-d6cb4e77-20240911))(react@19.0.0-rc-d6cb4e77-20240911)(webpack@5.94.0)
+      waku:
+        specifier: 0.21.2
+        version: link:../../packages/waku
+    devDependencies:
+      '@types/react':
+        specifier: 18.3.5
+        version: 18.3.5
+      '@types/react-dom':
+        specifier: 18.3.0
+        version: 18.3.0
+      server-only:
+        specifier: 0.0.1
+        version: 0.0.1
+      typescript:
+        specifier: 5.6.2
+        version: 5.6.2
+
   packages/create-waku:
     devDependencies:
       '@types/fs-extra':


Test patch:
diff --git a/packages/waku/tests/fixtures/plugin-fs-router-typegen/pages/[category]/[...tags]/index.tsx b/packages/waku/tests/fixtures/plugin-fs-router-typegen/pages/[category]/[...tags]/index.tsx
new file mode 100644
index 00000000..c046a3f8
--- /dev/null
+++ b/packages/waku/tests/fixtures/plugin-fs-router-typegen/pages/[category]/[...tags]/index.tsx
@@ -0,0 +1,9 @@
+export default function Page() {
+  return null;
+}
+
+export const getConfig = async () => {
+  return {
+    render: 'dynamic',
+  };
+};
diff --git a/packages/waku/tests/fixtures/plugin-fs-router-typegen/pages/[category]/_layout.tsx b/packages/waku/tests/fixtures/plugin-fs-router-typegen/pages/[category]/_layout.tsx
new file mode 100644
index 00000000..0b96b0dc
--- /dev/null
+++ b/packages/waku/tests/fixtures/plugin-fs-router-typegen/pages/[category]/_layout.tsx
@@ -0,0 +1,9 @@
+export default function Layout() {
+  return null;
+}
+
+export const getConfig = async () => {
+  return {
+    render: 'static',
+  };
+};
diff --git a/packages/waku/tests/fixtures/plugin-fs-router-typegen/pages/_layout.tsx b/packages/waku/tests/fixtures/plugin-fs-router-typegen/pages/_layout.tsx
new file mode 100644
index 00000000..0b96b0dc
--- /dev/null
+++ b/packages/waku/tests/fixtures/plugin-fs-router-typegen/pages/_layout.tsx
@@ -0,0 +1,9 @@
+export default function Layout() {
+  return null;
+}
+
+export const getConfig = async () => {
+  return {
+    render: 'static',
+  };
+};
diff --git a/packages/waku/tests/fixtures/plugin-fs-router-typegen/pages/index.tsx b/packages/waku/tests/fixtures/plugin-fs-router-typegen/pages/index.tsx
new file mode 100644
index 00000000..c046a3f8
--- /dev/null
+++ b/packages/waku/tests/fixtures/plugin-fs-router-typegen/pages/index.tsx
@@ -0,0 +1,9 @@
+export default function Page() {
+  return null;
+}
+
+export const getConfig = async () => {
+  return {
+    render: 'dynamic',
+  };
+};
diff --git a/packages/waku/tests/fixtures/plugin-fs-router-typegen/pages/one-two-three.tsx b/packages/waku/tests/fixtures/plugin-fs-router-typegen/pages/one-two-three.tsx
new file mode 100644
index 00000000..c046a3f8
--- /dev/null
+++ b/packages/waku/tests/fixtures/plugin-fs-router-typegen/pages/one-two-three.tsx
@@ -0,0 +1,9 @@
+export default function Page() {
+  return null;
+}
+
+export const getConfig = async () => {
+  return {
+    render: 'dynamic',
+  };
+};
diff --git a/packages/waku/tests/fixtures/plugin-fs-router-typegen/pages/one__two_three.tsx b/packages/waku/tests/fixtures/plugin-fs-router-typegen/pages/one__two_three.tsx
new file mode 100644
index 00000000..c046a3f8
--- /dev/null
+++ b/packages/waku/tests/fixtures/plugin-fs-router-typegen/pages/one__two_three.tsx
@@ -0,0 +1,9 @@
+export default function Page() {
+  return null;
+}
+
+export const getConfig = async () => {
+  return {
+    render: 'dynamic',
+  };
+};
diff --git a/packages/waku/tests/fixtures/plugin-fs-router-typegen/pages/one_two_three.tsx b/packages/waku/tests/fixtures/plugin-fs-router-typegen/pages/one_two_three.tsx
new file mode 100644
index 00000000..c046a3f8
--- /dev/null
+++ b/packages/waku/tests/fixtures/plugin-fs-router-typegen/pages/one_two_three.tsx
@@ -0,0 +1,9 @@
+export default function Page() {
+  return null;
+}
+
+export const getConfig = async () => {
+  return {
+    render: 'dynamic',
+  };
+};
diff --git a/packages/waku/tests/vite-plugin-fs-router-typegen.test.ts b/packages/waku/tests/vite-plugin-fs-router-typegen.test.ts
new file mode 100644
index 00000000..9d02f7c5
--- /dev/null
+++ b/packages/waku/tests/vite-plugin-fs-router-typegen.test.ts
@@ -0,0 +1,98 @@
+import { describe, expect, test, vi } from 'vitest';
+import {
+  fsRouterTypegenPlugin,
+  getImportModuleNames,
+  toIdentifier,
+} from '../src/lib/plugins/vite-plugin-fs-router-typegen.js';
+import { fileURLToPath } from 'node:url';
+import { FSWatcher, ViteDevServer } from 'vite';
+import { writeFile } from 'node:fs/promises';
+
+const root = fileURLToPath(new URL('./fixtures', import.meta.url));
+
+vi.mock('prettier', () => {
+  return { format: (x: string) => x, resolveConfig: () => ({}) };
+});
+vi.mock('node:fs/promises', async (importOriginal) => {
+  const mod = await importOriginal();
+  return {
+    // https://vitest.dev/api/vi.html#vi-mock
+    // @ts-expect-error - docs say this should be inferred...
+    ...mod,
+    writeFile: vi.fn(),
+  };
+});
+
+async function runTest(
+  root: string,
+  expectedEntriesGen: string,
+  srcDir = 'plugin-fs-router-typegen',
+) {
+  const plugin = fsRouterTypegenPlugin({
+    srcDir,
+  });
+  expect(plugin.configureServer).toBeDefined();
+  expect(typeof plugin.configureServer).toBe('function');
+  expect(plugin.configResolved).toBeDefined();
+  expect(typeof plugin.configResolved).toBe('function');
+  if (
+    typeof plugin.configureServer !== 'function' ||
+    typeof plugin.configResolved !== 'function'
+  ) {
+    return;
+  }
+  // @ts-expect-error - we're not passing the full Vite config
+  await plugin.configResolved?.({ root });
+  await plugin.configureServer?.({
+    watcher: { add: () => {}, on: () => {} } as unknown as FSWatcher,
+  } as ViteDevServer);
+  await vi.waitFor(async () => {
+    if (vi.mocked(writeFile).mock.lastCall === undefined) {
+      throw new Error('writeFile not called');
+    }
+  });
+  expect(vi.mocked(writeFile).mock.lastCall?.[1]).toContain(expectedEntriesGen);
+}
+
+describe('vite-plugin-fs-router-typegen', () => {
+  test('generates valid module names for fs entries', async () => {
+    expect(toIdentifier('/_layout.tsx')).toBe('Layout');
+    expect(toIdentifier('/[category]/[...tags]/index.tsx')).toBe(
+      'CategoryTagsIndex',
+    );
+  });
+
+  test('allows unicode characters in module names', async () => {
+    expect(toIdentifier('/øné_two_three.tsx')).toBe('ØnéTwoThree');
+  });
+
+  test('handles collisions of fs entry module names', async () => {
+    expect(
+      getImportModuleNames([
+        '/one-two-three.tsx',
+        '/one/two/three.tsx',
+        '/one_two_three.tsx',
+        '/one__two_three.tsx',
+      ]),
+    ).toEqual({
+      'one-two-three.tsx': 'OneTwoThree',
+      'one/two/three.tsx': 'OneTwoThree_1',
+      'one_two_three.tsx': 'OneTwoThree_2',
+      'one__two_three.tsx': 'OneTwoThree_3',
+    });
+  });
+
+  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 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';`,
+    );
+  });
+});