Skip to content

Commit 0f7204b

Browse files
authored
SSG: support wildcards in static paths (#666)
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.
1 parent 91c4c84 commit 0f7204b

File tree

10 files changed

+237
-19
lines changed

10 files changed

+237
-19
lines changed
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"name": "waku-example",
3+
"version": "0.1.0",
4+
"type": "module",
5+
"private": true,
6+
"scripts": {
7+
"dev": "waku dev",
8+
"build": "waku build",
9+
"start": "waku start"
10+
},
11+
"dependencies": {
12+
"react": "19.0.0-beta-4508873393-20240430",
13+
"react-dom": "19.0.0-beta-4508873393-20240430",
14+
"react-server-dom-webpack": "19.0.0-beta-4508873393-20240430",
15+
"waku": "workspace:*"
16+
},
17+
"devDependencies": {
18+
"@types/react": "18.3.1",
19+
"@types/react-dom": "18.3.0",
20+
"typescript": "5.4.4"
21+
}
22+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { StrictMode } from 'react';
2+
import { createRoot, hydrateRoot } from 'react-dom/client';
3+
import { Router } from 'waku/router/client';
4+
5+
const rootElement = (
6+
<StrictMode>
7+
<Router />
8+
</StrictMode>
9+
);
10+
11+
if (document.body.dataset.hydrate) {
12+
hydrateRoot(document.body, rootElement);
13+
} else {
14+
createRoot(document.body).render(rootElement);
15+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
const Page = ({ wildcard }: { wildcard: string[] }) => (
2+
<div>
3+
<h1>/{wildcard.join('/')}</h1>
4+
</div>
5+
);
6+
7+
export const getConfig = async () => {
8+
return {
9+
render: 'static',
10+
staticPaths: [[], 'foo', ['bar', 'baz']],
11+
};
12+
};
13+
14+
export default Page;
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { ReactNode } from 'react';
2+
3+
const Layout = ({ children }: { children: ReactNode }) => (
4+
<div>
5+
<title>Waku</title>
6+
{children}
7+
</div>
8+
);
9+
10+
export default Layout;
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"compilerOptions": {
3+
"composite": true,
4+
"strict": true,
5+
"target": "esnext",
6+
"downlevelIteration": true,
7+
"esModuleInterop": true,
8+
"module": "esnext",
9+
"moduleResolution": "bundler",
10+
"skipLibCheck": true,
11+
"noUncheckedIndexedAccess": true,
12+
"exactOptionalPropertyTypes": true,
13+
"types": ["react/experimental"],
14+
"jsx": "react-jsx"
15+
}
16+
}

e2e/ssg-wildcard.spec.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { debugChildProcess, getFreePort, terminate, test } from './utils.js';
2+
import { fileURLToPath } from 'node:url';
3+
import { cp, mkdtemp } from 'node:fs/promises';
4+
import { exec, execSync } from 'node:child_process';
5+
import { expect } from '@playwright/test';
6+
import waitPort from 'wait-port';
7+
import { join } from 'node:path';
8+
import { tmpdir } from 'node:os';
9+
import { createRequire } from 'node:module';
10+
11+
let standaloneDir: string;
12+
const exampleDir = fileURLToPath(
13+
new URL('./fixtures/ssg-wildcard', import.meta.url),
14+
);
15+
const wakuDir = fileURLToPath(new URL('../packages/waku', import.meta.url));
16+
const { version } = createRequire(import.meta.url)(
17+
join(wakuDir, 'package.json'),
18+
);
19+
20+
test.describe('ssg wildcard', async () => {
21+
test.beforeEach(async () => {
22+
// GitHub Action on Windows doesn't support mkdtemp on global temp dir,
23+
// Which will cause files in `src` folder to be empty.
24+
// I don't know why
25+
const tmpDir = process.env.TEMP_DIR ? process.env.TEMP_DIR : tmpdir();
26+
standaloneDir = await mkdtemp(join(tmpDir, 'waku-ssg-wildcard-'));
27+
await cp(exampleDir, standaloneDir, {
28+
filter: (src) => {
29+
return !src.includes('node_modules') && !src.includes('dist');
30+
},
31+
recursive: true,
32+
});
33+
execSync(`pnpm pack --pack-destination ${standaloneDir}`, {
34+
cwd: wakuDir,
35+
stdio: 'inherit',
36+
});
37+
const name = `waku-${version}.tgz`;
38+
execSync(`npm install ${join(standaloneDir, name)}`, {
39+
cwd: standaloneDir,
40+
stdio: 'inherit',
41+
});
42+
});
43+
44+
test(`works`, async ({ page }) => {
45+
execSync(
46+
`node ${join(standaloneDir, './node_modules/waku/dist/cli.js')} build`,
47+
{
48+
cwd: standaloneDir,
49+
stdio: 'inherit',
50+
},
51+
);
52+
const port = await getFreePort();
53+
const cp = exec(
54+
`node ${join(standaloneDir, './node_modules/waku/dist/cli.js')} start --port ${port}`,
55+
{ cwd: standaloneDir },
56+
);
57+
debugChildProcess(cp, fileURLToPath(import.meta.url), [
58+
/ExperimentalWarning: Custom ESM Loaders is an experimental feature and might change at any time/,
59+
]);
60+
61+
await waitPort({ port });
62+
63+
await page.goto(`http://localhost:${port}`);
64+
await expect(page.getByRole('heading', { name: '/' })).toBeVisible();
65+
66+
await page.goto(`http://localhost:${port}/foo`);
67+
await expect(page.getByRole('heading', { name: '/foo' })).toBeVisible();
68+
69+
await page.goto(`http://localhost:${port}/bar/baz`);
70+
await expect(page.getByRole('heading', { name: '/bar/baz' })).toBeVisible();
71+
72+
await terminate(cp.pid!);
73+
});
74+
});

packages/waku/src/router/create-pages.ts

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ export type CreatePage = <
8282
}
8383
| {
8484
render: 'static';
85-
path: PathWithSlug<Path, SlugKey>;
85+
path: PathWithWildcard<Path, SlugKey, WildSlugKey>;
8686
staticPaths: string[] | string[][];
8787
component: FunctionComponent<RouteProps & Record<SlugKey, string>>;
8888
}
@@ -170,27 +170,35 @@ export function createPages(
170170
staticPathSet.add([page.path, pathSpec]);
171171
const id = joinPath(page.path, 'page').replace(/^\//, '');
172172
registerStaticComponent(id, page.component);
173-
} else if (page.render === 'static' && numSlugs > 0 && numWildcards === 0) {
173+
} else if (page.render === 'static' && numSlugs > 0) {
174174
const staticPaths = (
175175
page as {
176176
staticPaths: string[] | string[][];
177177
}
178178
).staticPaths.map((item) => (Array.isArray(item) ? item : [item]));
179179
for (const staticPath of staticPaths) {
180-
if (staticPath.length !== numSlugs) {
180+
if (staticPath.length !== numSlugs && numWildcards === 0) {
181181
throw new Error('staticPaths does not match with slug pattern');
182182
}
183-
const mapping: Record<string, string> = {};
183+
const mapping: Record<string, string | string[]> = {};
184184
let slugIndex = 0;
185-
const pathItems = pathSpec.map(({ type, name }) => {
186-
if (type !== 'literal') {
187-
const actualName = staticPath[slugIndex++]!;
188-
if (name) {
189-
mapping[name] = actualName;
190-
}
191-
return actualName;
185+
const pathItems = [] as string[];
186+
pathSpec.forEach(({ type, name }) => {
187+
switch (type) {
188+
case 'literal':
189+
pathItems.push(name!);
190+
break;
191+
case 'wildcard':
192+
mapping[name!] = staticPath.slice(slugIndex);
193+
staticPath.slice(slugIndex++).forEach((slug) => {
194+
pathItems.push(slug);
195+
});
196+
break;
197+
case 'group':
198+
pathItems.push(staticPath[slugIndex++]!);
199+
mapping[name!] = pathItems[pathItems.length - 1]!;
200+
break;
192201
}
193-
return name;
194202
});
195203
staticPathSet.add([
196204
page.path,

packages/waku/tests/create-pages.test.ts

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -515,19 +515,50 @@ describe('createPages', () => {
515515
expect(TestPage).toHaveBeenCalledWith({ a: 'w', b: 'x' }, undefined);
516516
});
517517

518-
it('fails when trying to create a static page with wildcards', async () => {
518+
it('creates a static page with wildcards', async () => {
519+
const TestPage = vi.fn();
519520
createPages(async ({ createPage }) => {
520-
// @ts-expect-error: this already fails at type level, but we also want to test runtime
521521
createPage({
522522
render: 'static',
523523
path: '/test/[...path]',
524-
component: () => null,
524+
staticPaths: [['a', 'b']],
525+
component: TestPage,
525526
});
526527
});
527-
const { getPathConfig } = injectedFunctions();
528-
await expect(getPathConfig).rejects.toThrowError(
529-
`Invalid page configuration`,
530-
);
528+
const { getPathConfig, getComponent } = injectedFunctions();
529+
expect(await getPathConfig!()).toEqual([
530+
{
531+
data: undefined,
532+
isStatic: true,
533+
noSsr: false,
534+
path: [
535+
{
536+
name: 'test',
537+
type: 'literal',
538+
},
539+
{
540+
name: 'a',
541+
type: 'literal',
542+
},
543+
{
544+
name: 'b',
545+
type: 'literal',
546+
},
547+
],
548+
pattern: '^/test/(.*)$',
549+
},
550+
]);
551+
const setShouldSkip = vi.fn();
552+
const WrappedComponent = await getComponent('test/a/b/page', {
553+
unstable_setShouldSkip: setShouldSkip,
554+
unstable_buildConfig: undefined,
555+
});
556+
assert(WrappedComponent);
557+
expect(setShouldSkip).toHaveBeenCalledTimes(1);
558+
expect(setShouldSkip).toHaveBeenCalledWith([]);
559+
renderToString(createElement(WrappedComponent as any));
560+
expect(TestPage).toHaveBeenCalledTimes(1);
561+
expect(TestPage).toHaveBeenCalledWith({ path: ['a', 'b'] }, undefined);
531562
});
532563

533564
it('creates a dynamic page with wildcards', async () => {

pnpm-lock.yaml

Lines changed: 25 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tsconfig.e2e.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@
3333
{
3434
"path": "./e2e/fixtures/ssg-performance/tsconfig.json"
3535
},
36+
{
37+
"path": "./e2e/fixtures/ssg-wildcard/tsconfig.json"
38+
},
3639
{
3740
"path": "./e2e/fixtures/partial-build/tsconfig.json"
3841
}

0 commit comments

Comments
 (0)