Skip to content

Commit

Permalink
SSG: support wildcards in static paths (#666)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
pmelab authored May 5, 2024
1 parent 91c4c84 commit 0f7204b
Show file tree
Hide file tree
Showing 10 changed files with 237 additions and 19 deletions.
22 changes: 22 additions & 0 deletions e2e/fixtures/ssg-wildcard/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
15 changes: 15 additions & 0 deletions e2e/fixtures/ssg-wildcard/src/main.tsx
Original file line number Diff line number Diff line change
@@ -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);
}
14 changes: 14 additions & 0 deletions e2e/fixtures/ssg-wildcard/src/pages/[...wildcard].tsx
Original file line number Diff line number Diff line change
@@ -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;
10 changes: 10 additions & 0 deletions e2e/fixtures/ssg-wildcard/src/pages/_layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { ReactNode } from 'react';

const Layout = ({ children }: { children: ReactNode }) => (
<div>
<title>Waku</title>
{children}
</div>
);

export default Layout;
16 changes: 16 additions & 0 deletions e2e/fixtures/ssg-wildcard/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
74 changes: 74 additions & 0 deletions e2e/ssg-wildcard.spec.ts
Original file line number Diff line number Diff line change
@@ -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!);
});
});
32 changes: 20 additions & 12 deletions packages/waku/src/router/create-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>>;
}
Expand Down Expand Up @@ -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,
Expand Down
45 changes: 38 additions & 7 deletions packages/waku/tests/create-pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
25 changes: 25 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions tsconfig.e2e.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down

0 comments on commit 0f7204b

Please sign in to comment.