mirror of
https://github.com/withastro/astro.git
synced 2025-02-03 22:29:08 -05:00
Fix regression in the routing priority of index routes (#9726)
* fix: Fix regression in the routing priority of index routes * chore: Add changeset * Update .changeset/smart-rules-train.md Co-authored-by: Florian Lefebvre <contact@florian-lefebvre.dev> --------- Co-authored-by: Matthew Phillips <matthew@matthewphillips.info> Co-authored-by: Florian Lefebvre <contact@florian-lefebvre.dev>
This commit is contained in:
parent
259c30e7fc
commit
a4b696def3
3 changed files with 112 additions and 47 deletions
5
.changeset/smart-rules-train.md
Normal file
5
.changeset/smart-rules-train.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
"astro": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fixes a regression in routing priority between `index.astro` and dynamic routes with rest parameters
|
|
@ -33,6 +33,10 @@ interface Item {
|
||||||
routeSuffix: string;
|
routeSuffix: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ManifestRouteData extends RouteData {
|
||||||
|
isIndex: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
function countOccurrences(needle: string, haystack: string) {
|
function countOccurrences(needle: string, haystack: string) {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (const hay of haystack) {
|
for (const hay of haystack) {
|
||||||
|
@ -134,6 +138,40 @@ function validateSegment(segment: string, file = '') {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether two route segments are semantically equivalent.
|
||||||
|
*
|
||||||
|
* Two segments are equivalent if they would match the same paths. This happens when:
|
||||||
|
* - They have the same length.
|
||||||
|
* - Each part in the same position is either:
|
||||||
|
* - Both static and with the same content (e.g. `/foo` and `/foo`).
|
||||||
|
* - Both dynamic, regardless of the content (e.g. `/[bar]` and `/[baz]`).
|
||||||
|
* - Both rest parameters, regardless of the content (e.g. `/[...bar]` and `/[...baz]`).
|
||||||
|
*/
|
||||||
|
function isSemanticallyEqualSegment(segmentA: RoutePart[], segmentB: RoutePart[]) {
|
||||||
|
if (segmentA.length !== segmentB.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [index, partA] of segmentA.entries()) {
|
||||||
|
// Safe to use the index of one segment for the other because the segments have the same length
|
||||||
|
const partB = segmentB[index];
|
||||||
|
|
||||||
|
if (partA.dynamic !== partB.dynamic || partA.spread !== partB.spread) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only compare the content on non-dynamic segments
|
||||||
|
// `/[bar]` and `/[baz]` are effectively the same route,
|
||||||
|
// only bound to a different path parameter.
|
||||||
|
if (!partA.dynamic && partA.content !== partB.content) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Comparator for sorting routes in resolution order.
|
* Comparator for sorting routes in resolution order.
|
||||||
*
|
*
|
||||||
|
@ -142,6 +180,8 @@ function validateSegment(segment: string, file = '') {
|
||||||
* - More specific routes are sorted before less specific routes. Here, "specific" means
|
* - More specific routes are sorted before less specific routes. Here, "specific" means
|
||||||
* the number of segments in the route, so a parent route is always sorted after its children.
|
* the number of segments in the route, so a parent route is always sorted after its children.
|
||||||
* For example, `/foo/bar` is sorted before `/foo`.
|
* For example, `/foo/bar` is sorted before `/foo`.
|
||||||
|
* Index routes, originating from a file named `index.astro`, are considered to have one more
|
||||||
|
* segment than the URL they represent.
|
||||||
* - Static routes are sorted before dynamic routes.
|
* - Static routes are sorted before dynamic routes.
|
||||||
* For example, `/foo/bar` is sorted before `/foo/[bar]`.
|
* For example, `/foo/bar` is sorted before `/foo/[bar]`.
|
||||||
* - Dynamic routes with single parameters are sorted before dynamic routes with rest parameters.
|
* - Dynamic routes with single parameters are sorted before dynamic routes with rest parameters.
|
||||||
|
@ -153,10 +193,14 @@ function validateSegment(segment: string, file = '') {
|
||||||
* For example, `/bar` is sorted before `/foo`.
|
* For example, `/bar` is sorted before `/foo`.
|
||||||
* The definition of "alphabetically" is dependent on the default locale of the running system.
|
* The definition of "alphabetically" is dependent on the default locale of the running system.
|
||||||
*/
|
*/
|
||||||
function routeComparator(a: RouteData, b: RouteData) {
|
function routeComparator(a: ManifestRouteData, b: ManifestRouteData) {
|
||||||
|
// For sorting purposes, an index route is considered to have one more segment than the URL it represents.
|
||||||
|
const aLength = a.isIndex ? a.segments.length + 1 : a.segments.length;
|
||||||
|
const bLength = b.isIndex ? b.segments.length + 1 : b.segments.length;
|
||||||
|
|
||||||
// Sort more specific routes before less specific routes
|
// Sort more specific routes before less specific routes
|
||||||
if (a.segments.length !== b.segments.length) {
|
if (aLength !== bLength) {
|
||||||
return a.segments.length > b.segments.length ? -1 : 1;
|
return aLength > bLength ? -1 : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
const aIsStatic = a.segments.every((segment) =>
|
const aIsStatic = a.segments.every((segment) =>
|
||||||
|
@ -206,9 +250,9 @@ export interface CreateRouteManifestParams {
|
||||||
function createFileBasedRoutes(
|
function createFileBasedRoutes(
|
||||||
{ settings, cwd, fsMod }: CreateRouteManifestParams,
|
{ settings, cwd, fsMod }: CreateRouteManifestParams,
|
||||||
logger: Logger
|
logger: Logger
|
||||||
): RouteData[] {
|
): ManifestRouteData[] {
|
||||||
const components: string[] = [];
|
const components: string[] = [];
|
||||||
const routes: RouteData[] = [];
|
const routes: ManifestRouteData[] = [];
|
||||||
const validPageExtensions = new Set<string>([
|
const validPageExtensions = new Set<string>([
|
||||||
'.astro',
|
'.astro',
|
||||||
...SUPPORTED_MARKDOWN_FILE_EXTENSIONS,
|
...SUPPORTED_MARKDOWN_FILE_EXTENSIONS,
|
||||||
|
@ -321,6 +365,7 @@ function createFileBasedRoutes(
|
||||||
.join('/')}`.toLowerCase();
|
.join('/')}`.toLowerCase();
|
||||||
routes.push({
|
routes.push({
|
||||||
route,
|
route,
|
||||||
|
isIndex: item.isIndex,
|
||||||
type: item.isPage ? 'page' : 'endpoint',
|
type: item.isPage ? 'page' : 'endpoint',
|
||||||
pattern,
|
pattern,
|
||||||
segments,
|
segments,
|
||||||
|
@ -348,7 +393,7 @@ function createFileBasedRoutes(
|
||||||
return routes;
|
return routes;
|
||||||
}
|
}
|
||||||
|
|
||||||
type PrioritizedRoutesData = Record<RoutePriorityOverride, RouteData[]>;
|
type PrioritizedRoutesData = Record<RoutePriorityOverride, ManifestRouteData[]>;
|
||||||
|
|
||||||
function createInjectedRoutes({ settings, cwd }: CreateRouteManifestParams): PrioritizedRoutesData {
|
function createInjectedRoutes({ settings, cwd }: CreateRouteManifestParams): PrioritizedRoutesData {
|
||||||
const { config } = settings;
|
const { config } = settings;
|
||||||
|
@ -398,6 +443,8 @@ function createInjectedRoutes({ settings, cwd }: CreateRouteManifestParams): Pri
|
||||||
|
|
||||||
routes[priority].push({
|
routes[priority].push({
|
||||||
type,
|
type,
|
||||||
|
// For backwards compatibility, an injected route is never considered an index route.
|
||||||
|
isIndex: false,
|
||||||
route,
|
route,
|
||||||
pattern,
|
pattern,
|
||||||
segments,
|
segments,
|
||||||
|
@ -468,6 +515,8 @@ function createRedirectRoutes(
|
||||||
|
|
||||||
routes[priority].push({
|
routes[priority].push({
|
||||||
type: 'redirect',
|
type: 'redirect',
|
||||||
|
// For backwards compatibility, a redirect is never considered an index route.
|
||||||
|
isIndex: false,
|
||||||
route,
|
route,
|
||||||
pattern,
|
pattern,
|
||||||
segments,
|
segments,
|
||||||
|
@ -492,40 +541,6 @@ function isStaticSegment(segment: RoutePart[]) {
|
||||||
return segment.every((part) => !part.dynamic && !part.spread);
|
return segment.every((part) => !part.dynamic && !part.spread);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks whether two route segments are semantically equivalent.
|
|
||||||
*
|
|
||||||
* Two segments are equivalent if they would match the same paths. This happens when:
|
|
||||||
* - They have the same length.
|
|
||||||
* - Each part in the same position is either:
|
|
||||||
* - Both static and with the same content (e.g. `/foo` and `/foo`).
|
|
||||||
* - Both dynamic, regardless of the content (e.g. `/[bar]` and `/[baz]`).
|
|
||||||
* - Both rest parameters, regardless of the content (e.g. `/[...bar]` and `/[...baz]`).
|
|
||||||
*/
|
|
||||||
function isSemanticallyEqualSegment(segmentA: RoutePart[], segmentB: RoutePart[]) {
|
|
||||||
if (segmentA.length !== segmentB.length) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [index, partA] of segmentA.entries()) {
|
|
||||||
// Safe to use the index of one segment for the other because the segments have the same length
|
|
||||||
const partB = segmentB[index];
|
|
||||||
|
|
||||||
if (partA.dynamic !== partB.dynamic || partA.spread !== partB.spread) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only compare the content on non-dynamic segments
|
|
||||||
// `/[bar]` and `/[baz]` are effectively the same route,
|
|
||||||
// only bound to a different path parameter.
|
|
||||||
if (!partA.dynamic && partA.content !== partB.content) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check whether two are sure to collide in clearly unintended ways report appropriately.
|
* Check whether two are sure to collide in clearly unintended ways report appropriately.
|
||||||
*
|
*
|
||||||
|
@ -624,7 +639,7 @@ export function createRouteManifest(
|
||||||
|
|
||||||
const redirectRoutes = createRedirectRoutes(params, routeMap, logger);
|
const redirectRoutes = createRedirectRoutes(params, routeMap, logger);
|
||||||
|
|
||||||
const routes: RouteData[] = [
|
const routes: ManifestRouteData[] = [
|
||||||
...injectedRoutes['legacy'].sort(routeComparator),
|
...injectedRoutes['legacy'].sort(routeComparator),
|
||||||
...[...fileBasedRoutes, ...injectedRoutes['normal'], ...redirectRoutes['normal']].sort(
|
...[...fileBasedRoutes, ...injectedRoutes['normal'], ...redirectRoutes['normal']].sort(
|
||||||
routeComparator
|
routeComparator
|
||||||
|
@ -660,8 +675,8 @@ export function createRouteManifest(
|
||||||
|
|
||||||
// In this block of code we group routes based on their locale
|
// In this block of code we group routes based on their locale
|
||||||
|
|
||||||
// A map like: locale => RouteData[]
|
// A map like: locale => ManifestRouteData[]
|
||||||
const routesByLocale = new Map<string, RouteData[]>();
|
const routesByLocale = new Map<string, ManifestRouteData[]>();
|
||||||
// This type is here only as a helper. We copy the routes and make them unique, so we don't "process" the same route twice.
|
// This type is here only as a helper. We copy the routes and make them unique, so we don't "process" the same route twice.
|
||||||
// The assumption is that a route in the file system belongs to only one locale.
|
// The assumption is that a route in the file system belongs to only one locale.
|
||||||
const setRoutes = new Set(routes.filter((route) => route.type === 'page'));
|
const setRoutes = new Set(routes.filter((route) => route.type === 'page'));
|
||||||
|
|
|
@ -103,6 +103,51 @@ describe('routing - createRouteManifest', () => {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('static routes are sorted before dynamic and rest routes', async () => {
|
||||||
|
const fs = createFs(
|
||||||
|
{
|
||||||
|
'/src/pages/[dynamic].astro': `<h1>test</h1>`,
|
||||||
|
'/src/pages/[...rest].astro': `<h1>test</h1>`,
|
||||||
|
'/src/pages/static.astro': `<h1>test</h1>`,
|
||||||
|
'/src/pages/index.astro': `<h1>test</h1>`,
|
||||||
|
},
|
||||||
|
root
|
||||||
|
);
|
||||||
|
const settings = await createBasicSettings({
|
||||||
|
root: fileURLToPath(root),
|
||||||
|
base: '/search',
|
||||||
|
trailingSlash: 'never',
|
||||||
|
experimental: {
|
||||||
|
globalRoutePriority: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const manifest = createRouteManifest({
|
||||||
|
cwd: fileURLToPath(root),
|
||||||
|
settings,
|
||||||
|
fsMod: fs,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getManifestRoutes(manifest)).to.deep.equal([
|
||||||
|
{
|
||||||
|
"route": "/",
|
||||||
|
"type": "page",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route": "/static",
|
||||||
|
"type": "page",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route": "/[dynamic]",
|
||||||
|
"type": "page",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route": "/[...rest]",
|
||||||
|
"type": "page",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('injected routes are sorted in legacy mode above filesystem routes', async () => {
|
it('injected routes are sorted in legacy mode above filesystem routes', async () => {
|
||||||
const fs = createFs(
|
const fs = createFs(
|
||||||
{
|
{
|
||||||
|
@ -196,6 +241,10 @@ describe('routing - createRouteManifest', () => {
|
||||||
route: '/blog/[...slug]',
|
route: '/blog/[...slug]',
|
||||||
type: 'page',
|
type: 'page',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
route: '/',
|
||||||
|
type: 'page',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
route: '/contributing',
|
route: '/contributing',
|
||||||
type: 'page',
|
type: 'page',
|
||||||
|
@ -204,10 +253,6 @@ describe('routing - createRouteManifest', () => {
|
||||||
route: '/[...slug]',
|
route: '/[...slug]',
|
||||||
type: 'page',
|
type: 'page',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
route: '/',
|
|
||||||
type: 'page',
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue