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
2 changes: 2 additions & 0 deletions docs/guide/pages-routing/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ Pages can be flat files such as `about.vue` or directory-based routes such as `a

<PagesFileTree />

Keep one file per route. Void reports an error when two page files resolve to the same path, such as `about.vue` and `about/index.vue`, or the same page in two route groups.

Each page can have a companion `.server.ts` file that runs exclusively on the server. It can export:

- A [**loader**](./loaders), which runs on `GET` and returns the data that becomes the page component's props
Expand Down
3 changes: 2 additions & 1 deletion packages/react/src/runtime/pages-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ export type ReactPagesConfig = Readonly<{
components: Record<string, () => Promise<ComponentModule>>;
layoutTree: Record<string, Array<string>>;
routeMeta: Record<string, { island?: boolean }>;
matchRoute(url: string): string | null;
/** Page id for a decoded pathname. */
matchRoute(path: string): string | null;
staticPageData: boolean;
staticPageDataFastPath: boolean;
viewTransitions: boolean;
Expand Down
2 changes: 2 additions & 0 deletions packages/void/src/application-routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export async function completeApplicationAnalysis(
const scan = await scanRoutes(analysis.root, {
output: analysis.config.output,
paths: analysis.paths,
// A framework app's pages/ follows that framework's rules, not Void Pages.
checkPageRoutes: !analysis.framework,
inventory: {
routes: analysis.routes,
middleware: analysis.middleware,
Expand Down
30 changes: 23 additions & 7 deletions packages/void/src/pages/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,24 @@ import type { PageScanResult } from './types.ts';
* 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.
* (`components`, `layoutTree`, `routeMeta`, `matchRoute`) keep their shape;
* `matchRoute` takes a decoded pathname (see `decodePagePath`).
*/
export function generateComponentManifest(scan: PageScanResult, pagesDir: string): string {
const load = (filePath: string) => `() => import(${JSON.stringify(`${pagesDir}/${filePath}`)})`;

// A page in a folder named "layout" (layout/index.tsx) has the id of the
// layout file beside it (layout.tsx). No page path has a segment starting
// with "_", so that layout is keyed "_layout" instead.
const pageIds = new Set(scan.pages.map((page) => page.componentId));
const layoutKey = (id: string): string => {
if (!pageIds.has(id)) {
return id;
}
const slash = id.lastIndexOf('/');
return id.slice(0, slash + 1) + '_' + id.slice(slash + 1);
};

const chains: Array<string> = [];
const chainIndexes = new Map<string, number>();
const staticRows: Array<string> = [];
Expand All @@ -21,7 +34,9 @@ export function generateComponentManifest(scan: PageScanResult, pagesDir: string
// can be looked up exactly without changing which route matches first.
let inStaticPrefix = true;
for (const page of scan.pages) {
const chain = JSON.stringify(resolveLayoutIds(page, scan.layouts, scan.namedLayouts));
const chain = JSON.stringify(
resolveLayoutIds(page, scan.layouts, scan.namedLayouts).map(layoutKey),
);
let chainIndex = chain === '[]' ? -1 : chainIndexes.get(chain);
if (chainIndex === undefined) {
chainIndex = chains.push(chain) - 1;
Expand Down Expand Up @@ -60,9 +75,10 @@ for (const [pattern, id, load, chain] of __voidDynamicRoutes) {
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(
`components[${JSON.stringify(layoutKey(toLayoutId(layout)))}] = ${load(layout.filePath)};`,
);
}

// Route metadata (sparse — only island pages)
Expand All @@ -74,8 +90,7 @@ for (const [pattern, id, load, chain] of __voidDynamicRoutes) {
}
lines.push('};');

lines.push(`function matchRoute(url) {
const path = new URL(url, "http://x").pathname;
lines.push(`function matchRoute(path) {
const id = __voidStaticRouteIds.get(path);
if (id !== undefined) return id;
for (const r of routes) {
Expand Down Expand Up @@ -106,7 +121,8 @@ function isStaticPattern(pattern: string): boolean {
return pattern.split('/').every((segment) => captureGroup(segment) === undefined);
}

function patternToRegex(pattern: string): string {
/** The RegExp literal a page pattern matches with, the same on the client and the server. */
export function patternToRegex(pattern: string): string {
const regexSource = pattern
.split('/')
.map((segment) => {
Expand Down
9 changes: 6 additions & 3 deletions packages/void/src/pages/navigation-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { PrefetchCache, parseCacheFor } from './prefetch.ts';
import type { CacheForInput, CacheForResult } from './prefetch.ts';
import type { PageObject } from './protocol.ts';
import { createScrollRestoration } from './scroll-restoration.ts';
import { isSameOrigin } from './url.ts';
import { decodePagePath, isSameOrigin } from './url.ts';

/**
* The server leaves a named action's key out of the page URL. When the page URL
Expand Down Expand Up @@ -77,7 +77,8 @@ export type PageNavigationEngineConfig = {
components: Record<string, () => Promise<PageComponentModule>>;
layoutTree: Record<string, Array<string>>;
routeMeta: Record<string, { island?: boolean }>;
matchRoute(url: string): string | null;
/** Page id for a decoded pathname (see `decodePagePath`). */
matchRoute(path: string): string | null;
staticPageData?: boolean;
staticPageDataFastPath?: boolean;
viewTransitions: boolean;
Expand Down Expand Up @@ -239,14 +240,16 @@ export async function createPageNavigationEngine(
components,
layoutTree,
routeMeta,
matchRoute,
staticPageData: STATIC_PAGE_DATA_ENABLED = false,
staticPageDataFastPath: STATIC_PAGE_DATA_FAST_PATH = STATIC_PAGE_DATA_ENABLED,
prefetchHoverDelay: PREFETCH_HOVER_DELAY,
prefetchDefaultCacheFor: PREFETCH_DEFAULT_CACHE_FOR,
cacheVisitedPageData: CACHE_VISITED_PAGE_DATA = false,
} = config;

const matchRoute = (url: string) =>
config.matchRoute(decodePagePath(new URL(url, 'http://x').pathname));

const _prefetchCache = new PrefetchCache();
let _nextNavigationId = 0;
let _pendingResource: PageNavigationResource | null = null;
Expand Down
94 changes: 93 additions & 1 deletion packages/void/src/pages/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
import { staticExportInitializers, staticLiteral } from '../shared/static-exports.ts';
import { scriptBlocks } from './island-imports.ts';
import { resolveNamedLayout } from './layout-resolution.ts';
import { patternToRegex } from './manifest.ts';
import type {
ExplicitBoolean,
PageDefinition,
Expand All @@ -37,6 +38,8 @@ export const MAX_REVALIDATE = 31536000; // 1 year in seconds

/** Layout paths already reported; a dev server rescans pages on every file change. */
const warnedLayoutPaths = new Set<string>();
/** Unreachable pages already reported, for the same reason. */
const warnedShadowedPages = new Set<string>();

type PageRenderingSource = {
componentPath: string;
Expand All @@ -49,7 +52,13 @@ type PageRenderingSource = {
hasGetPrerenderPaths?: boolean;
};

type PageScanOptions = { output?: 'server' | 'static'; paths?: ProjectPaths; pagesDir?: string };
type PageScanOptions = {
output?: 'server' | 'static';
paths?: ProjectPaths;
pagesDir?: string;
/** Report pages that share a route. Off when another framework owns pages/ (Nuxt nests users.vue and users/index.vue). */
checkRoutes?: boolean;
};

type ResolvedPageRendering = {
ssrMode: SsrMode;
Expand Down Expand Up @@ -363,6 +372,11 @@ export async function scanPages(root: string, options?: PageScanOptions): Promis
return a.pattern.localeCompare(b.pattern);
});

if (options?.checkRoutes !== false) {
assertOnePagePerRoute(pages);
warnShadowedPages(pagesDir, pages);
}

// Validate: throw when regular pages are under island layouts
for (const page of pages) {
if (page.island) {
Expand Down Expand Up @@ -405,6 +419,84 @@ export async function scanPages(root: string, options?: PageScanOptions): Promis
return { pages, layouts, namedLayouts };
}

function compareText(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}

/** Group sorted pages by `key`, keeping only groups with more than one page. */
function pagesSharing(
pages: Array<PageDefinition>,
key: (page: PageDefinition) => string,
): Array<Array<PageDefinition>> {
const groups = new Map<string, Array<PageDefinition>>();
for (const page of pages) {
const group = groups.get(key(page));
if (group) {
group.push(page);
} else {
groups.set(key(page), [page]);
}
}
return [...groups.values()].filter((group) => group.length > 1);
}

/**
* Two files for one path have no stable winner: the server and the client can
* each pick a different one, and the order can change between runs.
*/
function assertOnePagePerRoute(pages: Array<PageDefinition>): void {
assertNoneShare(
pages,
(page) => page.pattern,
'pages: Multiple page files resolve to the same route:',
'Keep one page file per route and rename or remove the others.',
);
// index.tsx and index/index.tsx have different routes but the same page id.
assertNoneShare(
pages,
(page) => page.componentId,
'pages: Multiple page files resolve to the same page id:',
'Rename or remove one of them.',
);
}

function assertNoneShare(
pages: Array<PageDefinition>,
key: (page: PageDefinition) => string,
title: string,
hint: string,
): void {
const clashes = pagesSharing(pages, key)
.map((group) => ({
key: key(group[0]),
files: group.map((page) => page.componentPath).sort(compareText),
}))
.sort((left, right) => compareText(left.key, right.key))
.map(
(clash) =>
` ${JSON.stringify(clash.key)}: ${clash.files.map((file) => JSON.stringify(file)).join(', ')}`,
);
if (clashes.length > 0) {
throw new Error([title, ...clashes, hint].join('\n'));
}
}

/** Dynamic routes that match the same URLs, such as [id] and [slug]: only the first is ever used. */
function warnShadowedPages(pagesDir: string, pages: Array<PageDefinition>): void {
for (const [used, ...shadowed] of pagesSharing(pages, (page) => patternToRegex(page.pattern))) {
for (const page of shadowed) {
const key = `${pagesDir}\0${used.componentPath}\0${page.componentPath}`;
if (warnedShadowedPages.has(key)) {
continue;
}
warnedShadowedPages.add(key);
voidWarn(
`pages: Page '${page.componentPath}' (${page.pattern}) is never used: '${used.componentPath}' (${used.pattern}) matches the same URLs first. Remove one of them.`,
);
}
}
}

// Same leading-script rule as @void/md's extractScript: a markdown page may open
// with a <script> block, and its frontmatter starts right after that block.
const LEADING_SCRIPT_RE = /^<script\b[^>]*>[\s\S]*?<\/script>\s*\n?/;
Expand Down
47 changes: 47 additions & 0 deletions packages/void/src/pages/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,50 @@ export function isSameOrigin(url: URL, origin: string): boolean {
}
return url.origin === origin;
}

function isHexDigit(char: string | undefined): boolean {
return (
char !== undefined &&
((char >= '0' && char <= '9') || (char >= 'a' && char <= 'f') || (char >= 'A' && char <= 'F'))
);
}

function tryDecodeURI(text: string): string {
try {
return decodeURI(text);
} catch {
return text;
}
}

/**
* The path a page is matched on, decoded the way the server (Hono's getPath)
* decodes it: `/caf%C3%A9` becomes `/café`, while `%25`, reserved escapes such
* as `%2F` and malformed escapes stay encoded.
*/
export function decodePagePath(path: string): string {
if (!path.includes('%')) {
return path;
}
const escaped = path.split('%25').join('%2525');
try {
return decodeURI(escaped);
} catch {
// Decode each run of escapes on its own, keeping the runs that fail.
}
let decoded = '';
let index = 0;
while (index < escaped.length) {
let end = index;
while (escaped[end] === '%' && isHexDigit(escaped[end + 1]) && isHexDigit(escaped[end + 2])) {
end += 3;
}
if (end === index) {
decoded += escaped[index++];
} else {
decoded += tryDecodeURI(escaped.slice(index, end));
index = end;
}
}
return decoded;
}
21 changes: 20 additions & 1 deletion packages/void/src/plugin-routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -680,7 +680,26 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug
// Re-scan and invalidate the virtual module in ALL environments.
// The route table virtual module lives in the workerd environment,
// not the client — so we must iterate all environments.
await rescan(true);
try {
await rescan(true);
} catch (error) {
// Keep the last good route table; the next change rescans.
const message = error instanceof Error ? error.message : String(error);
// An adapter's pages plugin rescans page adds, removals and .md saves
// and logs the same page error, so log it once.
const adapterLogs =
message.startsWith('pages:') &&
isInDirectory(normalizedPath, normalizedPagesDir) &&
(event !== 'change' || normalizedPath.endsWith('.md')) &&
resolvedConfig?.plugins?.some((plugin) => plugin.name.endsWith(':pages')) === true;
if (!adapterLogs) {
devServer.config.logger.error(message, {
error: error instanceof Error ? error : undefined,
timestamp: true,
});
}
return;
}
resolveAuthCompileOptions();
const shouldRestartForAssetRouting =
event !== 'change' &&
Expand Down
7 changes: 6 additions & 1 deletion packages/void/src/router/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,15 @@ export async function scanRoutes(
output?: 'server' | 'static';
paths: ProjectPaths;
inventory: Omit<ScanResult, 'pages'>;
checkPageRoutes?: boolean;
},
): Promise<ScanResult> {
const { paths, inventory } = options;
const pages = await scanPages(root, { output: options.output, paths });
const pages = await scanPages(root, {
output: options.output,
paths,
checkRoutes: options.checkPageRoutes,
});
const routes = inventory.routes
.map((route) => ({
...route,
Expand Down
Loading
Loading