Repository: dai-shi/waku
Pull request #666 (2024-04-23 08:06:56)

SSG: support wildcards in static paths
Allow wildcards in static paths. The main use case would be scenarios where waku is used as a static site generator and paths are controlled by a headless CMS.


vercel[bot]: [vc]: #12aIXe1rg7tjb2pFuFzvkdz+SEO22k5Ju2GFfqoQwcU=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJ3YWt1Iiwicm9vdERpcmVjdG9yeSI6bnVsbCwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL2RhaXNoaXMtcHJvamVjdHMvd2FrdS9GM29lNDZuaGtrN0R4ZHZSMTd6QzZNUlM4NGc5IiwicHJldmlld1VybCI6Indha3UtZ2l0LWZvcmstcG1lbGFiLXN0YXRpYy13aWxkY2FyZC1kYWlzaGlzLXByb2plY3RzLnZlcmNlbC5hcHAiLCJuZXh0Q29tbWl0U3RhdHVzIjoiUEVORElORyJ9XX0=
**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/F3oe46nhkk7DxdvR17zC6MRS84g9)) | [Visit Preview](https://waku-git-fork-pmelab-static-wildcard-daishis-projects.vercel.app) | Apr 23, 2024 8:07am |


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/666/builds/497290) or the icon next to each commit SHA.
dai-shi: Wow, thanks for working on it. Let me do a quick first review.

Patch:
diff --git a/packages/waku/src/router/create-pages.ts b/packages/waku/src/router/create-pages.ts
index 86302933..1227b7d1 100644
--- a/packages/waku/src/router/create-pages.ts
+++ b/packages/waku/src/router/create-pages.ts
@@ -82,7 +82,7 @@ export type CreatePage = <
       }
     | {
         render: 'static';
-        path: PathWithSlug<Path, SlugKey>;
+        path: PathWithWildcard<Path, SlugKey, WildSlugKey>;
         staticPaths: string[] | string[][];
         component: FunctionComponent<RouteProps & Record<SlugKey, string>>;
       }
@@ -170,27 +170,35 @@ export function createPages(
       staticPathSet.add([page.path, pathSpec]);
       const id = joinPath(page.path, 'page').replace(/^\//, '');
       registerStaticComponent(id, page.component);
-    } else if (page.render === 'static' && numSlugs > 0 && numWildcards === 0) {
+    } else if (page.render === 'static' && numSlugs > 0) {
       const staticPaths = (
         page as {
           staticPaths: string[] | string[][];
         }
       ).staticPaths.map((item) => (Array.isArray(item) ? item : [item]));
       for (const staticPath of staticPaths) {
-        if (staticPath.length !== numSlugs) {
+        if (staticPath.length !== numSlugs && numWildcards === 0) {
           throw new Error('staticPaths does not match with slug pattern');
         }
-        const mapping: Record<string, string> = {};
+        const mapping: Record<string, string | string[]> = {};
         let slugIndex = 0;
-        const pathItems = pathSpec.map(({ type, name }) => {
-          if (type !== 'literal') {
-            const actualName = staticPath[slugIndex++]!;
-            if (name) {
-              mapping[name] = actualName;
-            }
-            return actualName;
+        const pathItems = [] as string[];
+        pathSpec.forEach(({ type, name }) => {
+          switch (type) {
+            case 'literal':
+              pathItems.push(name!);
+              break;
+            case 'wildcard':
+              mapping[name!] = staticPath.slice(slugIndex);
+              staticPath.slice(slugIndex++).forEach((slug) => {
+                pathItems.push(slug);
+              });
+              break;
+            case 'group':
+              pathItems.push(staticPath[slugIndex++]!);
+              mapping[name!] = pathItems[pathItems.length - 1]!;
+              break;
           }
-          return name;
         });
         staticPathSet.add([
           page.path,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index dc917646..c1e33da0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -228,6 +228,31 @@ importers:
         specifier: 5.4.4
         version: 5.4.4
 
+  e2e/fixtures/ssg-wildcard:
+    dependencies:
+      react:
+        specifier: 19.0.0-beta-4508873393-20240430
+        version: 19.0.0-beta-4508873393-20240430
+      react-dom:
+        specifier: 19.0.0-beta-4508873393-20240430
+        version: 19.0.0-beta-4508873393-20240430(react@19.0.0-beta-4508873393-20240430)
+      react-server-dom-webpack:
+        specifier: 19.0.0-beta-4508873393-20240430
+        version: 19.0.0-beta-4508873393-20240430(react-dom@19.0.0-beta-4508873393-20240430)(react@19.0.0-beta-4508873393-20240430)(webpack@5.91.0)
+      waku:
+        specifier: workspace:*
+        version: link:../../../packages/waku
+    devDependencies:
+      '@types/react':
+        specifier: 18.3.1
+        version: 18.3.1
+      '@types/react-dom':
+        specifier: 18.3.0
+        version: 18.3.0
+      typescript:
+        specifier: 5.4.4
+        version: 5.4.4
+
   e2e/fixtures/ssr-basic:
     dependencies:
       react:


Test patch:
diff --git a/e2e/fixtures/ssg-wildcard/package.json b/e2e/fixtures/ssg-wildcard/package.json
new file mode 100644
index 00000000..36c47f55
--- /dev/null
+++ b/e2e/fixtures/ssg-wildcard/package.json
@@ -0,0 +1,22 @@
+{
+  "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-beta-4508873393-20240430",
+    "react-dom": "19.0.0-beta-4508873393-20240430",
+    "react-server-dom-webpack": "19.0.0-beta-4508873393-20240430",
+    "waku": "workspace:*"
+  },
+  "devDependencies": {
+    "@types/react": "18.3.1",
+    "@types/react-dom": "18.3.0",
+    "typescript": "5.4.4"
+  }
+}
diff --git a/e2e/fixtures/ssg-wildcard/src/main.tsx b/e2e/fixtures/ssg-wildcard/src/main.tsx
new file mode 100644
index 00000000..5cb25b43
--- /dev/null
+++ b/e2e/fixtures/ssg-wildcard/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 (document.body.dataset.hydrate) {
+  hydrateRoot(document.body, rootElement);
+} else {
+  createRoot(document.body).render(rootElement);
+}
diff --git a/e2e/fixtures/ssg-wildcard/src/pages/[...wildcard].tsx b/e2e/fixtures/ssg-wildcard/src/pages/[...wildcard].tsx
new file mode 100644
index 00000000..eb9b8213
--- /dev/null
+++ b/e2e/fixtures/ssg-wildcard/src/pages/[...wildcard].tsx
@@ -0,0 +1,14 @@
+const Page = ({ wildcard }: { wildcard: string[] }) => (
+  <div>
+    <h1>/{wildcard.join('/')}</h1>
+  </div>
+);
+
+export const getConfig = async () => {
+  return {
+    render: 'static',
+    staticPaths: [[], 'foo', ['bar', 'baz']],
+  };
+};
+
+export default Page;
diff --git a/e2e/fixtures/ssg-wildcard/src/pages/_layout.tsx b/e2e/fixtures/ssg-wildcard/src/pages/_layout.tsx
new file mode 100644
index 00000000..94843f71
--- /dev/null
+++ b/e2e/fixtures/ssg-wildcard/src/pages/_layout.tsx
@@ -0,0 +1,10 @@
+import type { ReactNode } from 'react';
+
+const Layout = ({ children }: { children: ReactNode }) => (
+  <div>
+    <title>Waku</title>
+    {children}
+  </div>
+);
+
+export default Layout;
diff --git a/e2e/fixtures/ssg-wildcard/tsconfig.json b/e2e/fixtures/ssg-wildcard/tsconfig.json
new file mode 100644
index 00000000..46dc2950
--- /dev/null
+++ b/e2e/fixtures/ssg-wildcard/tsconfig.json
@@ -0,0 +1,16 @@
+{
+  "compilerOptions": {
+    "composite": true,
+    "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/e2e/ssg-wildcard.spec.ts b/e2e/ssg-wildcard.spec.ts
new file mode 100644
index 00000000..a2399b11
--- /dev/null
+++ b/e2e/ssg-wildcard.spec.ts
@@ -0,0 +1,74 @@
+import { debugChildProcess, getFreePort, terminate, test } from './utils.js';
+import { fileURLToPath } from 'node:url';
+import { cp, mkdtemp } from 'node:fs/promises';
+import { exec, execSync } from 'node:child_process';
+import { expect } from '@playwright/test';
+import waitPort from 'wait-port';
+import { join } from 'node:path';
+import { tmpdir } from 'node:os';
+import { createRequire } from 'node:module';
+
+let standaloneDir: string;
+const exampleDir = fileURLToPath(
+  new URL('./fixtures/ssg-wildcard', import.meta.url),
+);
+const wakuDir = fileURLToPath(new URL('../packages/waku', import.meta.url));
+const { version } = createRequire(import.meta.url)(
+  join(wakuDir, 'package.json'),
+);
+
+test.describe('ssg wildcard', async () => {
+  test.beforeEach(async () => {
+    // GitHub Action on Windows doesn't support mkdtemp on global temp dir,
+    // Which will cause files in `src` folder to be empty.
+    // I don't know why
+    const tmpDir = process.env.TEMP_DIR ? process.env.TEMP_DIR : tmpdir();
+    standaloneDir = await mkdtemp(join(tmpDir, 'waku-ssg-wildcard-'));
+    await cp(exampleDir, standaloneDir, {
+      filter: (src) => {
+        return !src.includes('node_modules') && !src.includes('dist');
+      },
+      recursive: true,
+    });
+    execSync(`pnpm pack --pack-destination ${standaloneDir}`, {
+      cwd: wakuDir,
+      stdio: 'inherit',
+    });
+    const name = `waku-${version}.tgz`;
+    execSync(`npm install ${join(standaloneDir, name)}`, {
+      cwd: standaloneDir,
+      stdio: 'inherit',
+    });
+  });
+
+  test(`works`, async ({ page }) => {
+    execSync(
+      `node ${join(standaloneDir, './node_modules/waku/dist/cli.js')} build`,
+      {
+        cwd: standaloneDir,
+        stdio: 'inherit',
+      },
+    );
+    const port = await getFreePort();
+    const cp = exec(
+      `node ${join(standaloneDir, './node_modules/waku/dist/cli.js')} start --port ${port}`,
+      { cwd: standaloneDir },
+    );
+    debugChildProcess(cp, fileURLToPath(import.meta.url), [
+      /ExperimentalWarning: Custom ESM Loaders is an experimental feature and might change at any time/,
+    ]);
+
+    await waitPort({ port });
+
+    await page.goto(`http://localhost:${port}`);
+    await expect(page.getByRole('heading', { name: '/' })).toBeVisible();
+
+    await page.goto(`http://localhost:${port}/foo`);
+    await expect(page.getByRole('heading', { name: '/foo' })).toBeVisible();
+
+    await page.goto(`http://localhost:${port}/bar/baz`);
+    await expect(page.getByRole('heading', { name: '/bar/baz' })).toBeVisible();
+
+    await terminate(cp.pid!);
+  });
+});
diff --git a/packages/waku/tests/create-pages.test.ts b/packages/waku/tests/create-pages.test.ts
index 88d3c021..62ebe77d 100644
--- a/packages/waku/tests/create-pages.test.ts
+++ b/packages/waku/tests/create-pages.test.ts
@@ -515,19 +515,50 @@ describe('createPages', () => {
     expect(TestPage).toHaveBeenCalledWith({ a: 'w', b: 'x' }, undefined);
   });
 
-  it('fails when trying to create a static page with wildcards', async () => {
+  it('creates a static page with wildcards', async () => {
+    const TestPage = vi.fn();
     createPages(async ({ createPage }) => {
-      // @ts-expect-error: this already fails at type level, but we also want to test runtime
       createPage({
         render: 'static',
         path: '/test/[...path]',
-        component: () => null,
+        staticPaths: [['a', 'b']],
+        component: TestPage,
       });
     });
-    const { getPathConfig } = injectedFunctions();
-    await expect(getPathConfig).rejects.toThrowError(
-      `Invalid page configuration`,
-    );
+    const { getPathConfig, getComponent } = injectedFunctions();
+    expect(await getPathConfig!()).toEqual([
+      {
+        data: undefined,
+        isStatic: true,
+        noSsr: false,
+        path: [
+          {
+            name: 'test',
+            type: 'literal',
+          },
+          {
+            name: 'a',
+            type: 'literal',
+          },
+          {
+            name: 'b',
+            type: 'literal',
+          },
+        ],
+        pattern: '^/test/(.*)$',
+      },
+    ]);
+    const setShouldSkip = vi.fn();
+    const WrappedComponent = await getComponent('test/a/b/page', {
+      unstable_setShouldSkip: setShouldSkip,
+      unstable_buildConfig: undefined,
+    });
+    assert(WrappedComponent);
+    expect(setShouldSkip).toHaveBeenCalledTimes(1);
+    expect(setShouldSkip).toHaveBeenCalledWith([]);
+    renderToString(createElement(WrappedComponent as any));
+    expect(TestPage).toHaveBeenCalledTimes(1);
+    expect(TestPage).toHaveBeenCalledWith({ path: ['a', 'b'] }, undefined);
   });
 
   it('creates a dynamic page with wildcards', async () => {
diff --git a/tsconfig.e2e.json b/tsconfig.e2e.json
index e374ed53..5a1d7896 100644
--- a/tsconfig.e2e.json
+++ b/tsconfig.e2e.json
@@ -33,6 +33,9 @@
     {
       "path": "./e2e/fixtures/ssg-performance/tsconfig.json"
     },
+    {
+      "path": "./e2e/fixtures/ssg-wildcard/tsconfig.json"
+    },
     {
       "path": "./e2e/fixtures/partial-build/tsconfig.json"
     }