Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/react/test/unit/plugin-pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ describe('pagesPlugin dev config', () => {
const load = getHookHandler(plugin.load);
const renderer = await load('\0virtual:void-pages-renderer');

expect(renderer).toContain('"test": () => import');
expect(renderer).toContain('["test", () => import');
});

it('logs a page scan error from the watcher and keeps the last scan', async () => {
Expand All @@ -153,7 +153,7 @@ describe('pagesPlugin dev config', () => {
expect(logError.mock.calls[0]?.[0]).toContain("specifies layout 'docs'");
expect(invalidateModule).not.toHaveBeenCalled();
const renderer = await getHookHandler(plugin.load)('\0virtual:void-pages-renderer');
expect(renderer).toContain('"index": () => import');
expect(renderer).toContain('["index", () => import');
expect(renderer).not.toContain('"about"');

writePage(
Expand Down
4 changes: 2 additions & 2 deletions packages/solid/test/unit/plugin-pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ describe('pagesPlugin dev HMR', () => {
const load = getHookHandler(plugin.load);
const renderer = await load('\0virtual:void-pages-renderer');

expect(renderer).toContain('"test": () => import');
expect(renderer).toContain('["test", () => import');
});

it('logs a page scan error from the watcher and keeps the last scan', async () => {
Expand All @@ -100,7 +100,7 @@ describe('pagesPlugin dev HMR', () => {
expect(logError.mock.calls[0]?.[0]).toContain("specifies layout 'docs'");
expect(invalidateModule).not.toHaveBeenCalled();
const renderer = await getHookHandler(plugin.load)('\0virtual:void-pages-renderer');
expect(renderer).toContain('"index": () => import');
expect(renderer).toContain('["index", () => import');
expect(renderer).not.toContain('"about"');

writePage(
Expand Down
4 changes: 2 additions & 2 deletions packages/svelte/test/unit/plugin-pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ describe('pagesPlugin dev HMR', () => {
const load = getHookHandler(plugin.load);
const renderer = await load('\0virtual:void-pages-renderer');

expect(renderer).toContain('"test": () => import');
expect(renderer).toContain('["test", () => import');
});

it('logs a page scan error from the watcher and keeps the last scan', async () => {
Expand All @@ -100,7 +100,7 @@ describe('pagesPlugin dev HMR', () => {
expect(logError.mock.calls[0]?.[0]).toContain("specifies layout 'docs'");
expect(invalidateModule).not.toHaveBeenCalled();
const renderer = await getHookHandler(plugin.load)('\0virtual:void-pages-renderer');
expect(renderer).toContain('"index": () => import');
expect(renderer).toContain('["index", () => import');
expect(renderer).not.toContain('"about"');

writePage(
Expand Down
122 changes: 82 additions & 40 deletions packages/void/src/pages/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,35 +4,66 @@ import type { PageScanResult } from './types.ts';
/**
* Generates the component import map and layout tree as code strings.
* Used by both SSR renderer and client entry virtual modules.
*
* Client entries load this eagerly, so it grows with every page. Each page id
* is written once, identical layout chains are shared, and static routes are
* matched by exact lookup instead of a RegExp per page. The emitted bindings
* (`components`, `layoutTree`, `routeMeta`, `matchRoute`) keep their shape.
*/
export function generateComponentManifest(scan: PageScanResult, pagesDir: string): string {
const lines: Array<string> = [];
const load = (filePath: string) => `() => import(${JSON.stringify(`${pagesDir}/${filePath}`)})`;

// Component lazy imports
lines.push('const components = {');
const chains: Array<string> = [];
const chainIndexes = new Map<string, number>();
const staticRows: Array<string> = [];
const dynamicRows: Array<string> = [];
// Routes are in precedence order. Static routes ahead of every dynamic route
// can be looked up exactly without changing which route matches first.
let inStaticPrefix = true;
for (const page of scan.pages) {
const importPath = `${pagesDir}/${page.componentPath}`;
lines.push(
` ${JSON.stringify(page.componentId)}: () => import(${JSON.stringify(importPath)}),`,
);
}
for (const layout of [...scan.layouts, ...scan.namedLayouts]) {
const importPath = `${pagesDir}/${layout.filePath}`;
lines.push(
` ${JSON.stringify(toLayoutId(layout))}: () => import(${JSON.stringify(importPath)}),`,
);
const chain = JSON.stringify(resolveLayoutIds(page, scan.layouts, scan.namedLayouts));
let chainIndex = chain === '[]' ? -1 : chainIndexes.get(chain);
if (chainIndex === undefined) {
chainIndex = chains.push(chain) - 1;
chainIndexes.set(chain, chainIndex);
}
const id = JSON.stringify(page.componentId);
inStaticPrefix &&= isStaticPattern(page.pattern);
if (inStaticPrefix) {
const pattern =
page.pattern === `/${page.componentId}` ? '' : `, ${JSON.stringify(page.pattern)}`;
staticRows.push(` [${id}, ${load(page.componentPath)}, ${chainIndex}${pattern}],`);
} else {
dynamicRows.push(
` [${patternToRegex(page.pattern)}, ${id}, ${load(page.componentPath)}, ${chainIndex}],`,
);
}
}
lines.push('};');

// Layout tree (component ID → ordered layout IDs)
lines.push('const layoutTree = {');
for (const page of scan.pages) {
const chain = resolveLayoutIds(page, scan.layouts, scan.namedLayouts);
if (chain.length > 0) {
lines.push(` ${JSON.stringify(page.componentId)}: ${JSON.stringify(chain)},`);
}
const lines: Array<string> = [];
lines.push('const components = {};');
lines.push(`const __voidLayoutChains = [${chains.join(', ')}];`);
// [componentId, load, layout chain index (-1 for none), pattern unless "/" + componentId]
lines.push('const __voidStaticRoutes = [', ...staticRows, '];');
// [pattern, componentId, load, layout chain index]
lines.push('const __voidDynamicRoutes = [', ...dynamicRows, '];');
lines.push(`const layoutTree = {};
const __voidStaticRouteIds = new Map();
for (const [id, load, chain, pattern = "/" + id] of __voidStaticRoutes) {
components[id] = load;
if (chain >= 0) layoutTree[id] = __voidLayoutChains[chain];
if (!__voidStaticRouteIds.has(pattern)) __voidStaticRouteIds.set(pattern, id);
}
const routes = [];
for (const [pattern, id, load, chain] of __voidDynamicRoutes) {
components[id] = load;
if (chain >= 0) layoutTree[id] = __voidLayoutChains[chain];
routes.push({ pattern, id });
}`);
// Layouts are set after pages, so a layout keeps an id it shares with a page.
for (const layout of [...scan.layouts, ...scan.namedLayouts]) {
lines.push(`components[${JSON.stringify(toLayoutId(layout))}] = ${load(layout.filePath)};`);
}
lines.push('};');

// Route metadata (sparse — only island pages)
lines.push('const routeMeta = {');
Expand All @@ -43,16 +74,10 @@ export function generateComponentManifest(scan: PageScanResult, pagesDir: string
}
lines.push('};');

// Route pattern table for client-side URL matching
lines.push('const routes = [');
for (const page of scan.pages) {
const regex = patternToRegex(page.pattern);
lines.push(` { pattern: ${regex}, id: ${JSON.stringify(page.componentId)} },`);
}
lines.push('];');

lines.push(`function matchRoute(url) {
const path = new URL(url, "http://x").pathname;
const id = __voidStaticRouteIds.get(path);
if (id !== undefined) return id;
for (const r of routes) {
if (r.pattern.test(path)) return r.id;
}
Expand All @@ -62,22 +87,39 @@ export function generateComponentManifest(scan: PageScanResult, pagesDir: string
return lines.join('\n');
}

const REGEX_SYNTAX = '.*+?^${}()|[]\\';

/** The capture group for `*`, `:name{.+}` and `:name`; other segments match as written. */
function captureGroup(segment: string): string | undefined {
if (segment === '*') {
return '(.*)';
}
if (segment.length < 2 || segment[0] !== ':') {
return undefined;
}
const name = segment.endsWith('{.+}') ? segment.slice(1, -4) : '';
return name && !name.includes('{') && !name.includes('}') ? '(.+)' : '([^\\/]+)';
}

/** Whether `patternToRegex` matches only the pattern's own text. */
function isStaticPattern(pattern: string): boolean {
return pattern.split('/').every((segment) => captureGroup(segment) === undefined);
}

function patternToRegex(pattern: string): string {
const regexSource = pattern
.split('/')
.map((segment) => {
if (segment === '*') {
return '(.*)';
}
if (/^:[^/{}]+\{\.\+\}$/.test(segment)) {
return '(.+)';
const group = captureGroup(segment);
if (group) {
return group;
}
if (/^:[^/]+$/.test(segment)) {
return '([^/]+)';
let literal = '';
for (const char of segment) {
literal += REGEX_SYNTAX.includes(char) ? '\\' + char : char;
}
return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return literal;
})
.join('/')
.replace(/\//g, '\\/');
.join('\\/');
return `/^${regexSource}$/`;
}
144 changes: 143 additions & 1 deletion packages/void/test/unit/pages-manifest.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { describe, expect, it } from 'vite-plus/test';
import { resolveLayoutIds } from '../../src/pages/layout-resolution.ts';
import { generateComponentManifest } from '../../src/pages/manifest.ts';
import type { PageDefinition } from '../../src/pages/types.ts';
import type {
LayoutDefinition,
NamedLayoutDefinition,
PageDefinition,
PageScanResult,
} from '../../src/pages/types.ts';

function matchRouteFor(pattern: string): (url: string) => string | null {
const page: PageDefinition = {
Expand Down Expand Up @@ -34,3 +40,139 @@ describe('generated Pages client route manifest', () => {
expect(matchRoute('/docs')).toBeNull();
});
});

type GeneratedManifest = {
components: Record<string, () => Promise<unknown>>;
layoutTree: Record<string, Array<string>>;
routeMeta: Record<string, { island?: boolean }>;
matchRoute(url: string): string | null;
};

function evaluateManifest(scan: PageScanResult): GeneratedManifest {
const source = generateComponentManifest(scan, '/project/pages');
return new Function(
'URL',
`${source}\nreturn { components, layoutTree, routeMeta, matchRoute };`,
)(URL) as GeneratedManifest;
}

function definePage(
componentId: string,
pattern: string,
componentPath: string,
extra: Partial<PageDefinition> = {},
): PageDefinition {
return {
pattern,
params: [],
catchAll: false,
componentPath,
serverPath: null,
methods: [],
componentId,
ssrMode: 'auto',
prerenderMode: 'auto',
renderMode: 'server',
shouldPrerender: false,
...extra,
};
}

/**
* The ordered RegExp scan alone: a dynamic route placed first leaves no static
* prefix, so every page is tested by its RegExp in route order.
*/
function regexOnlyMatchRoute(scan: PageScanResult): (url: string) => string | null {
const first = definePage('__first', '/__first/:never', '__first.tsx', { params: ['never'] });
return evaluateManifest({ ...scan, pages: [first, ...scan.pages] }).matchRoute;
}

const layouts: Array<LayoutDefinition> = [
{ directory: '', filePath: 'layout.tsx' },
{ directory: 'blog', filePath: 'blog/layout.tsx' },
];
const namedLayouts: Array<NamedLayoutDefinition> = [
{ name: 'marketing', directory: '', filePath: '_layouts/marketing.tsx' },
];
// Route order as the scanner sorts it, plus a static route after a dynamic one.
const pages = [
definePage('index', '/', 'index.tsx'),
definePage('about', '/about', 'about.tsx'),
definePage('about/index', '/about', 'about/index.tsx'),
definePage('blog', '/blog', 'blog/index.tsx'),
definePage('(marketing)/pricing', '/pricing', '(marketing)/pricing.tsx', {
layout: 'marketing',
}),
definePage('files/a*b', '/files/a*b', 'files/a*b.tsx', { layout: false }),
definePage('blog/[slug]', '/blog/:slug', 'blog/[slug].tsx', { params: ['slug'] }),
definePage('blog/featured', '/blog/featured', 'blog/featured.tsx'),
definePage('legacy/[...rest]', '/legacy/*', 'legacy/[...rest].tsx', { catchAll: true }),
definePage('docs/[...slug]', '/docs/:slug{.+}', 'docs/[...slug].tsx', {
params: ['slug'],
catchAll: true,
island: true,
}),
];

describe('compact Pages route manifest', () => {
it('matches the same route as testing each page pattern in order', () => {
const { matchRoute } = evaluateManifest({ pages, layouts, namedLayouts });
const regexOnly = regexOnlyMatchRoute({ pages, layouts, namedLayouts });
const urls = [
'/',
'/about',
'/about/',
'/blog',
'/blog/post',
'/blog/featured',
'/pricing',
'/(marketing)/pricing',
'/files/a*b',
'/files/axb',
'/legacy/',
'/legacy/a/b',
'/docs',
'/docs/a/b',
'/caf%C3%A9',
'/about?tab=1#top',
'https://example.com/blog',
'/missing',
];
for (const url of urls) {
expect([url, matchRoute(url)]).toEqual([url, regexOnly(url)]);
}
});

it('keeps every component, layout chain and island flag', () => {
const manifest = evaluateManifest({ pages, layouts, namedLayouts });

expect(Object.keys(manifest.components).sort()).toEqual(
[
...pages.map((page) => page.componentId),
'layout',
'blog/layout',
'_layouts/marketing',
].sort(),
);
for (const page of pages) {
const chain = resolveLayoutIds(page, layouts, namedLayouts);
expect([page.componentId, manifest.layoutTree[page.componentId]]).toEqual([
page.componentId,
chain.length > 0 ? chain : undefined,
]);
}
expect(manifest.routeMeta).toEqual({ 'docs/[...slug]': { island: true } });
});

it('emits no RegExp for static routes', () => {
const source = generateComponentManifest(
{
pages: [definePage('index', '/', 'index.tsx'), definePage('about', '/about', 'about.tsx')],
layouts,
namedLayouts: [],
},
'/project/pages',
);
expect(source).not.toContain('/^');
});
});
4 changes: 2 additions & 2 deletions packages/vue/test/unit/plugin-pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ describe('pagesPlugin dev HMR', () => {
const load = getHookHandler(plugin.load);
const renderer = await load('\0virtual:void-pages-renderer');

expect(renderer).toContain('"test": () => import');
expect(renderer).toContain('["test", () => import');
});

it('logs a page scan error from the watcher and keeps the last scan', async () => {
Expand Down Expand Up @@ -110,7 +110,7 @@ describe('pagesPlugin dev HMR', () => {
expect(logError.mock.calls[0]?.[0]).toContain("specifies layout 'docs'");
expect(invalidateModule).not.toHaveBeenCalled();
const renderer = await getHookHandler(plugin.load)('\0virtual:void-pages-renderer');
expect(renderer).toContain('"index": () => import');
expect(renderer).toContain('["index", () => import');
expect(renderer).not.toContain('"about"');

writePage('_layouts/docs.vue', '<template><main><slot /></main></template>\n');
Expand Down
Loading