Repository: dai-shi/waku
Pull request #1215 (2025-02-06 05:23:57)

feat(create-pages): support for exactPath
escape hatch for allowing any routing conventions at all

vercel[bot]: [vc]: #rs2SEd90qQsUTs6BOiXVNmiy+Wyn59G9poqvxOYe//M=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJ3YWt1Iiwicm9vdERpcmVjdG9yeSI6bnVsbCwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL2RhaXNoaXMtcHJvamVjdHMvd2FrdS84QkhGZ25BMkJMQVlEbUR1dlJSQTZZZlVkckU0IiwicHJldmlld1VybCI6Indha3UtZ2l0LWZvcmstdHlsZXJzYXlzaGktZXhhY3QtcGF0aC1kYWlzaGlzLXByb2plY3RzLnZlcmNlbC5hcHAiLCJuZXh0Q29tbWl0U3RhdHVzIjoiUEVORElORyJ9XX0=
**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/8BHFgnA2BLAYDmDuvRRA6YfUdrE4)) | [Visit Preview](https://waku-git-fork-tylersayshi-exact-path-daishis-projects.vercel.app) | Feb 6, 2025 5:24am |


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/1215/builds/585783) or the icon next to each commit SHA.

Patch:
diff --git a/packages/waku/src/lib/utils/path.ts b/packages/waku/src/lib/utils/path.ts
index 5aaf52ee..d702e61e 100644
--- a/packages/waku/src/lib/utils/path.ts
+++ b/packages/waku/src/lib/utils/path.ts
@@ -61,7 +61,13 @@ export const joinPath = (...paths: string[]) => {
 
 export const extname = (filePath: string) => {
   const index = filePath.lastIndexOf('.');
-  return index > 0 ? filePath.slice(index) : '';
+  if (index <= 0) {
+    return '';
+  }
+  if (['/', '.'].includes(filePath[index - 1]!)) {
+    return '';
+  }
+  return filePath.slice(index);
 };
 
 export type PathSpecItem =
@@ -89,6 +95,12 @@ export const parsePathWithSlug = (path: string): PathSpec =>
       return { type, name };
     });
 
+export const parseExactPath = (path: string): PathSpec =>
+  path
+    .split('/')
+    .filter(Boolean)
+    .map((name) => ({ type: 'literal', name }));
+
 /**
  * Transform a path spec to a regular expression.
  */
diff --git a/packages/waku/src/router/create-pages.ts b/packages/waku/src/router/create-pages.ts
index c40f80e4..020bfdc1 100644
--- a/packages/waku/src/router/create-pages.ts
+++ b/packages/waku/src/router/create-pages.ts
@@ -8,6 +8,7 @@ import {
   parsePathWithSlug,
   getPathMapping,
   pathSpecAsString,
+  parseExactPath,
 } from '../lib/utils/path.js';
 import type { PathSpec } from '../lib/utils/path.js';
 import type {
@@ -107,6 +108,7 @@ export type CreatePage = <
   WildSlugKey extends string,
   Render extends 'static' | 'dynamic',
   StaticPaths extends StaticSlugRoutePaths<Path>,
+  ExactPath extends boolean | undefined = undefined,
 >(
   page: (
     | {
@@ -114,12 +116,11 @@ export type CreatePage = <
         path: PathWithoutSlug<Path>;
         component: FunctionComponent<PropsForPages<Path>>;
       }
-    | {
+    | ({
         render: Extract<Render, 'static'>;
         path: PathWithStaticSlugs<Path>;
-        staticPaths: StaticPaths;
         component: FunctionComponent<PropsForPages<Path>>;
-      }
+      } & (ExactPath extends true ? {} : { staticPaths: StaticPaths }))
     | {
         render: Extract<Render, 'dynamic'>;
         path: PathWithoutSlug<Path>;
@@ -130,7 +131,14 @@ export type CreatePage = <
         path: PathWithWildcard<Path, SlugKey, WildSlugKey>;
         component: FunctionComponent<PropsForPages<Path>>;
       }
-  ) & { unstable_disableSSR?: boolean },
+  ) & {
+    unstable_disableSSR?: boolean;
+    /**
+     * If true, the path will be matched exactly, without wildcards or slugs.
+     * This is intended for extending support to create custom routers.
+     */
+    exactPath?: ExactPath;
+  },
 ) => Omit<
   Exclude<typeof page, { path: never } | { render: never }>,
   'unstable_disableSSR'
@@ -337,7 +345,19 @@ export const createPages = <
       }
       return { numSlugs, numWildcards };
     })();
-    if (page.render === 'static' && numSlugs === 0) {
+
+    if (page.exactPath) {
+      const spec = parseExactPath(page.path);
+      if (page.render === 'static') {
+        staticPathMap.set(page.path, {
+          literalSpec: parseExactPath(page.path),
+        });
+        const id = joinPath(page.path, 'page').replace(/^\//, '');
+        registerStaticComponent(id, page.component);
+      } else {
+        dynamicPagePathMap.set(page.path, [spec, page.component]);
+      }
+    } else if (page.render === 'static' && numSlugs === 0) {
       staticPathMap.set(page.path, { literalSpec: pathSpec });
       const id = joinPath(page.path, 'page').replace(/^\//, '');
       registerStaticComponent(id, page.component);


Test patch:
diff --git a/e2e/create-pages.spec.ts b/e2e/create-pages.spec.ts
index 67634278..9e702dd8 100644
--- a/e2e/create-pages.spec.ts
+++ b/e2e/create-pages.spec.ts
@@ -143,5 +143,12 @@ for (const mode of ['DEV', 'PRD'] as const) {
       expect(res.status).toBe(200);
       expect(await res.text()).toBe('POST to hello world! from the test!');
     });
+
+    test('exactPath', async ({ page }) => {
+      await page.goto(`http://localhost:${port}/exact/[slug]/[...wild]`);
+      await expect(
+        page.getByRole('heading', { name: 'EXACTLY!!' }),
+      ).toBeVisible();
+    });
   });
 }
diff --git a/e2e/fixtures/create-pages/src/components/HomeLayout.tsx b/e2e/fixtures/create-pages/src/components/HomeLayout.tsx
index fab63a7c..7e991d5d 100644
--- a/e2e/fixtures/create-pages/src/components/HomeLayout.tsx
+++ b/e2e/fixtures/create-pages/src/components/HomeLayout.tsx
@@ -61,6 +61,9 @@ const HomeLayout = ({ children }: { children: ReactNode }) => (
       <li>
         <Link to="/error">Error</Link>
       </li>
+      <li>
+        <Link to="/exact/[slug]/[...wild]">Exact Path</Link>
+      </li>
     </ul>
     {children}
   </div>
diff --git a/e2e/fixtures/create-pages/src/entries.tsx b/e2e/fixtures/create-pages/src/entries.tsx
index 1e04f4d8..8dae9d49 100644
--- a/e2e/fixtures/create-pages/src/entries.tsx
+++ b/e2e/fixtures/create-pages/src/entries.tsx
@@ -133,6 +133,13 @@ const pages: ReturnType<typeof createPages> = createPages(
         return new Response(null);
       },
     }),
+
+    createPage({
+      render: 'static',
+      path: '/exact/[slug]/[...wild]',
+      exactPath: true,
+      component: () => <h1>EXACTLY!!</h1>,
+    }),
   ],
 );
 
diff --git a/packages/waku/tests/create-pages.test.ts b/packages/waku/tests/create-pages.test.ts
index 6c9dd3a3..5765d1a7 100644
--- a/packages/waku/tests/create-pages.test.ts
+++ b/packages/waku/tests/create-pages.test.ts
@@ -1385,3 +1385,204 @@ describe('createPages api', () => {
     expect(res.status).toEqual(200);
   });
 });
+
+describe('createPages - exactPath', () => {
+  it('creates a simple static page', async () => {
+    const TestPage = () => null;
+    createPages(async ({ createPage }) => [
+      createPage({
+        render: 'static',
+        path: '/test',
+        exactPath: true,
+        component: TestPage,
+      }),
+    ]);
+    const { getRouteConfig, handleRoute } = injectedFunctions();
+    expect(await getRouteConfig()).toEqual([
+      {
+        elements: {
+          'page:/test': { isStatic: true },
+        },
+        rootElement: { isStatic: true },
+        routeElement: { isStatic: true },
+        noSsr: false,
+        path: [{ name: 'test', type: 'literal' }],
+      },
+    ]);
+    const route = await handleRoute('/test', {
+      query: '?skip=[]',
+    });
+    expect(route).toBeDefined();
+    expect(route.rootElement).toBeDefined();
+    expect(route.routeElement).toBeDefined();
+    expect(Object.keys(route.elements)).toEqual(['page:/test']);
+  });
+
+  it('creates a simple dynamic page', async () => {
+    const TestPage = () => null;
+    createPages(async ({ createPage }) => [
+      createPage({
+        render: 'dynamic',
+        path: '/test',
+        exactPath: true,
+        component: TestPage,
+      }),
+    ]);
+    const { getRouteConfig, handleRoute } = injectedFunctions();
+    expect(await getRouteConfig()).toEqual([
+      {
+        elements: {
+          'page:/test': { isStatic: false },
+        },
+        rootElement: { isStatic: true },
+        routeElement: { isStatic: true },
+        noSsr: false,
+        path: [{ name: 'test', type: 'literal' }],
+      },
+    ]);
+    const route = await handleRoute('/test', {
+      query: '?skip=[]',
+    });
+    expect(route).toBeDefined();
+    expect(route.rootElement).toBeDefined();
+    expect(route.routeElement).toBeDefined();
+    expect(Object.keys(route.elements)).toEqual(['page:/test']);
+  });
+
+  it('works with a slug path', async () => {
+    const TestPage = vi.fn();
+    createPages(async ({ createPage }) => [
+      createPage({
+        render: 'static',
+        path: '/test/[slug]',
+        exactPath: true,
+        component: TestPage,
+      }),
+    ]);
+    const { getRouteConfig, handleRoute } = injectedFunctions();
+    expect(await getRouteConfig()).toEqual([
+      {
+        elements: {
+          'page:/test/[slug]': { isStatic: true },
+        },
+        rootElement: { isStatic: true },
+        routeElement: { isStatic: true },
+        noSsr: false,
+        path: [
+          { name: 'test', type: 'literal' },
+          { name: '[slug]', type: 'literal' },
+        ],
+      },
+    ]);
+    const route = await handleRoute('/test/[slug]', {
+      query: '?skip=[]',
+    });
+    expect(route).toBeDefined();
+    expect(route.rootElement).toBeDefined();
+    expect(route.routeElement).toBeDefined();
+    expect(Object.keys(route.elements)).toEqual(['page:/test/[slug]']);
+  });
+
+  it('works with a wildcard path', async () => {
+    const TestPage = vi.fn();
+    createPages(async ({ createPage }) => [
+      createPage({
+        render: 'static',
+        path: '/test/[...wildcard]',
+        exactPath: true,
+        component: TestPage,
+      }),
+    ]);
+    const { getRouteConfig, handleRoute } = injectedFunctions();
+    expect(await getRouteConfig()).toEqual([
+      {
+        elements: {
+          'page:/test/[...wildcard]': { isStatic: true },
+        },
+        rootElement: { isStatic: true },
+        routeElement: { isStatic: true },
+        noSsr: false,
+        path: [
+          { name: 'test', type: 'literal' },
+          { name: '[...wildcard]', type: 'literal' },
+        ],
+      },
+    ]);
+    const route = await handleRoute('/test/[...wildcard]', {
+      query: '?skip=[]',
+    });
+    expect(route).toBeDefined();
+    expect(route.rootElement).toBeDefined();
+    expect(route.routeElement).toBeDefined();
+    expect(Object.keys(route.elements)).toEqual(['page:/test/[...wildcard]']);
+  });
+
+  it('works with wildcard and slug path', async () => {
+    const TestPage = vi.fn();
+    createPages(async ({ createPage }) => [
+      createPage({
+        render: 'static',
+        path: '/test/[...wildcard]/[slug]',
+        exactPath: true,
+        component: TestPage,
+      }),
+    ]);
+    const { getRouteConfig, handleRoute } = injectedFunctions();
+    expect(await getRouteConfig()).toEqual([
+      {
+        elements: {
+          'page:/test/[...wildcard]/[slug]': { isStatic: true },
+        },
+        rootElement: { isStatic: true },
+        routeElement: { isStatic: true },
+        noSsr: false,
+        path: [
+          { name: 'test', type: 'literal' },
+          { name: '[...wildcard]', type: 'literal' },
+          { name: '[slug]', type: 'literal' },
+        ],
+      },
+    ]);
+    const route = await handleRoute('/test/[...wildcard]/[slug]', {
+      query: '?skip=[]',
+    });
+    expect(route).toBeDefined();
+    expect(route.rootElement).toBeDefined();
+    expect(route.routeElement).toBeDefined();
+    expect(Object.keys(route.elements)).toEqual([
+      'page:/test/[...wildcard]/[slug]',
+    ]);
+  });
+
+  it('does not work with slug match', async () => {
+    const TestPage = vi.fn();
+    createPages(async ({ createPage }) => [
+      createPage({
+        render: 'static',
+        path: '/test/[slug]',
+        exactPath: true,
+        component: TestPage,
+      }),
+    ]);
+    const { getRouteConfig, handleRoute } = injectedFunctions();
+    expect(await getRouteConfig()).toEqual([
+      {
+        elements: {
+          'page:/test/[slug]': { isStatic: true },
+        },
+        rootElement: { isStatic: true },
+        routeElement: { isStatic: true },
+        noSsr: false,
+        path: [
+          { name: 'test', type: 'literal' },
+          { name: '[slug]', type: 'literal' },
+        ],
+      },
+    ]);
+    await expect(async () => {
+      return handleRoute('/test/foo', {
+        query: '?skip=[]',
+      });
+    }).rejects.toThrowError();
+  });
+});
diff --git a/packages/waku/tests/path.test.ts b/packages/waku/tests/path.test.ts
index ca7925c2..94600514 100644
--- a/packages/waku/tests/path.test.ts
+++ b/packages/waku/tests/path.test.ts
@@ -1,10 +1,23 @@
 import { test, describe, expect } from 'vitest';
-import { parsePathWithSlug, path2regexp } from '../src/lib/utils/path.js';
+import {
+  extname,
+  parsePathWithSlug,
+  path2regexp,
+} from '../src/lib/utils/path.js';
 
 function matchPath(path: string, input: string) {
   return new RegExp(path2regexp(parsePathWithSlug(path))).test(input);
 }
 
+describe('extname', () => {
+  test('returns the extension of a path', () => {
+    expect(extname('foo/bar/baz.js')).toBe('.js');
+    expect(extname('foo/bar/baz')).toBe('');
+    expect(extname('foo/bar/.baz')).toBe('');
+    expect(extname('foo/bar/..baz')).toBe('');
+  });
+});
+
 describe('path2regexp', () => {
   test('handles paths without slugs', () => {
     expect(matchPath('/foo/bar', '/foo/bar')).toBe(true);