Implement support for redirects config in the Vercel adapter (#7182)

* Implement support for redirects config in the Vercel adapter

* Remove unused condition

* Move to a internal helper package
This commit is contained in:
Matthew Phillips 2023-05-23 15:43:24 -04:00 committed by GitHub
parent 11a517b1f1
commit ffc771e746
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 288 additions and 98 deletions

View file

@ -113,6 +113,7 @@
},
"dependencies": {
"@astrojs/compiler": "^1.4.0",
"@astrojs/internal-helpers": "^0.1.0",
"@astrojs/language-server": "^1.0.0",
"@astrojs/markdown-remark": "^2.2.1",
"@astrojs/telemetry": "^2.1.1",

View file

@ -177,7 +177,7 @@ async function generatePage(
if(pageData.route.redirectRoute) {
pageModulePromise = ssrEntry.pageMap?.get(pageData.route.redirectRoute!.component);
} else {
pageModulePromise = { default: () => {} } as any;
pageModulePromise = () => Promise.resolve<any>({ default: () => {} });
}
}
if (!pageModulePromise) {

View file

@ -1,81 +1 @@
export function appendExtension(path: string, extension: string) {
return path + '.' + extension;
}
export function appendForwardSlash(path: string) {
return path.endsWith('/') ? path : path + '/';
}
export function prependForwardSlash(path: string) {
return path[0] === '/' ? path : '/' + path;
}
export function removeTrailingForwardSlash(path: string) {
return path.endsWith('/') ? path.slice(0, path.length - 1) : path;
}
export function removeLeadingForwardSlash(path: string) {
return path.startsWith('/') ? path.substring(1) : path;
}
export function removeLeadingForwardSlashWindows(path: string) {
return path.startsWith('/') && path[2] === ':' ? path.substring(1) : path;
}
export function trimSlashes(path: string) {
return path.replace(/^\/|\/$/g, '');
}
export function startsWithForwardSlash(path: string) {
return path[0] === '/';
}
export function startsWithDotDotSlash(path: string) {
const c1 = path[0];
const c2 = path[1];
const c3 = path[2];
return c1 === '.' && c2 === '.' && c3 === '/';
}
export function startsWithDotSlash(path: string) {
const c1 = path[0];
const c2 = path[1];
return c1 === '.' && c2 === '/';
}
export function isRelativePath(path: string) {
return startsWithDotDotSlash(path) || startsWithDotSlash(path);
}
function isString(path: unknown): path is string {
return typeof path === 'string' || path instanceof String;
}
export function joinPaths(...paths: (string | undefined)[]) {
return paths
.filter(isString)
.map((path, i) => {
if (i === 0) {
return removeTrailingForwardSlash(path);
} else if (i === paths.length - 1) {
return removeLeadingForwardSlash(path);
} else {
return trimSlashes(path);
}
})
.join('/');
}
export function removeFileExtension(path: string) {
let idx = path.lastIndexOf('.');
return idx === -1 ? path : path.slice(0, idx);
}
export function removeQueryString(path: string) {
const index = path.lastIndexOf('?');
return index > 0 ? path.substring(0, index) : path;
}
export function isRemotePath(src: string) {
return /^(http|ftp|https):?\/\//.test(src) || src.startsWith('data:');
}
export * from '@astrojs/internal-helpers/path';

View file

@ -51,6 +51,7 @@
},
"dependencies": {
"@astrojs/webapi": "^2.1.1",
"@astrojs/internal-helpers": "^0.1.0",
"@vercel/analytics": "^0.1.8",
"@vercel/nft": "^0.22.1",
"esbuild": "^0.17.12",

View file

@ -1,4 +1,5 @@
import type { AstroConfig, RouteData, RoutePart } from 'astro';
import { appendForwardSlash } from '@astrojs/internal-helpers/path';
// https://vercel.com/docs/project-configuration#legacy/routes
interface VercelRoute {
@ -54,28 +55,40 @@ function getReplacePattern(segments: RoutePart[][]) {
return result;
}
function getRedirectLocation(route: RouteData, config: AstroConfig): string {
if(route.redirectRoute) {
const pattern = getReplacePattern(route.redirectRoute.segments);
const path = (config.trailingSlash === 'always' ? appendForwardSlash(pattern) : pattern);
return config.base + path;
} else {
return config.base + route.redirect;
}
}
export function getRedirects(routes: RouteData[], config: AstroConfig): VercelRoute[] {
let redirects: VercelRoute[] = [];
if (config.trailingSlash === 'always') {
for (const route of routes) {
if (route.type !== 'page' || route.segments.length === 0) continue;
for(const route of routes) {
if(route.type === 'redirect') {
redirects.push({
src: config.base + getMatchPattern(route.segments),
headers: { Location: config.base + getReplacePattern(route.segments) + '/' },
status: 308,
});
}
} else if (config.trailingSlash === 'never') {
for (const route of routes) {
if (route.type !== 'page' || route.segments.length === 0) continue;
redirects.push({
src: config.base + getMatchPattern(route.segments) + '/',
headers: { Location: config.base + getReplacePattern(route.segments) },
status: 308,
headers: { Location: getRedirectLocation(route, config) },
status: 301
});
} else if (route.type === 'page') {
if (config.trailingSlash === 'always') {
redirects.push({
src: config.base + getMatchPattern(route.segments),
headers: { Location: config.base + getReplacePattern(route.segments) + '/' },
status: 308,
});
} else if (config.trailingSlash === 'never') {
redirects.push({
src: config.base + getMatchPattern(route.segments) + '/',
headers: { Location: config.base + getReplacePattern(route.segments) },
status: 308,
});
}
}
}

View file

@ -0,0 +1,9 @@
import vercel from '@astrojs/vercel/static';
import { defineConfig } from 'astro/config';
export default defineConfig({
adapter: vercel({imageService: true}),
experimental: {
assets: true
}
});

View file

@ -0,0 +1,9 @@
{
"name": "@test/astro-vercel-redirects",
"version": "0.0.0",
"private": true,
"dependencies": {
"@astrojs/vercel": "workspace:*",
"astro": "workspace:*"
}
}

View file

@ -0,0 +1,8 @@
<html>
<head>
<title>Testing</title>
</head>
<body>
<h1>Testing</h1>
</body>
</html>

View file

@ -0,0 +1,25 @@
---
export const getStaticPaths = (async () => {
const posts = [
{ slug: 'one', data: {draft: false, title: 'One'} },
{ slug: 'two', data: {draft: false, title: 'Two'} }
];
return posts.map((post) => {
return {
params: { slug: post.slug },
props: { draft: post.data.draft, title: post.data.title },
};
});
})
const { slug } = Astro.params;
const { title } = Astro.props;
---
<html>
<head>
<title>{ title }</title>
</head>
<body>
<h1>{ title }</h1>
</body>
</html>

View file

@ -0,0 +1,48 @@
import { expect } from 'chai';
import * as cheerio from 'cheerio';
import { loadFixture } from './test-utils.js';
describe('Redirects', () => {
/** @type {import('../../../astro/test/test-utils.js').Fixture} */
let fixture;
before(async () => {
fixture = await loadFixture({
root: './fixtures/redirects/',
redirects: {
'/one': '/',
'/two': '/',
'/blog/[...slug]': '/team/articles/[...slug]',
}
});
await fixture.build();
});
async function getConfig() {
const json = await fixture.readFile('../.vercel/output/config.json');
const config = JSON.parse(json);
return config;
}
it('define static routes', async () => {
const config = await getConfig();
const oneRoute = config.routes.find(r => r.src === '/\\/one');
expect(oneRoute.headers.Location).to.equal('/');
expect(oneRoute.status).to.equal(301);
const twoRoute = config.routes.find(r => r.src === '/\\/one');
expect(twoRoute.headers.Location).to.equal('/');
expect(twoRoute.status).to.equal(301);
});
it('defines dynamic routes', async () => {
const config = await getConfig();
const blogRoute = config.routes.find(r => r.src.startsWith('/\\/blog'));
expect(blogRoute).to.not.be.undefined;
expect(blogRoute.headers.Location.startsWith('/team/articles')).to.equal(true);
expect(blogRoute.status).to.equal(301);
});
});

View file

@ -0,0 +1,41 @@
{
"name": "@astrojs/internal-helpers",
"description": "Internal helpers used by core Astro packages.",
"version": "0.1.0",
"type": "module",
"author": "withastro",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/withastro/astro.git",
"directory": "packages/internal-helpers"
},
"bugs": "https://github.com/withastro/astro/issues",
"exports": {
"./path": "./dist/path.js"
},
"typesVersions": {
"*": {
"path": [
"./dist/path.d.ts"
]
}
},
"files": [
"dist"
],
"scripts": {
"prepublish": "pnpm build",
"build": "astro-scripts build \"src/**/*.ts\" && tsc -p tsconfig.json",
"build:ci": "astro-scripts build \"src/**/*.ts\"",
"postbuild": "astro-scripts copy \"src/**/*.js\"",
"dev": "astro-scripts dev \"src/**/*.ts\""
},
"devDependencies": {
"astro-scripts": "workspace:*"
},
"keywords": [
"astro",
"astro-component"
]
}

View file

@ -0,0 +1,3 @@
# @astrojs/internal-helpers
These are internal helpers used by core Astro packages. This package does not follow semver and should not be used externally.

View file

@ -0,0 +1,81 @@
export function appendExtension(path: string, extension: string) {
return path + '.' + extension;
}
export function appendForwardSlash(path: string) {
return path.endsWith('/') ? path : path + '/';
}
export function prependForwardSlash(path: string) {
return path[0] === '/' ? path : '/' + path;
}
export function removeTrailingForwardSlash(path: string) {
return path.endsWith('/') ? path.slice(0, path.length - 1) : path;
}
export function removeLeadingForwardSlash(path: string) {
return path.startsWith('/') ? path.substring(1) : path;
}
export function removeLeadingForwardSlashWindows(path: string) {
return path.startsWith('/') && path[2] === ':' ? path.substring(1) : path;
}
export function trimSlashes(path: string) {
return path.replace(/^\/|\/$/g, '');
}
export function startsWithForwardSlash(path: string) {
return path[0] === '/';
}
export function startsWithDotDotSlash(path: string) {
const c1 = path[0];
const c2 = path[1];
const c3 = path[2];
return c1 === '.' && c2 === '.' && c3 === '/';
}
export function startsWithDotSlash(path: string) {
const c1 = path[0];
const c2 = path[1];
return c1 === '.' && c2 === '/';
}
export function isRelativePath(path: string) {
return startsWithDotDotSlash(path) || startsWithDotSlash(path);
}
function isString(path: unknown): path is string {
return typeof path === 'string' || path instanceof String;
}
export function joinPaths(...paths: (string | undefined)[]) {
return paths
.filter(isString)
.map((path, i) => {
if (i === 0) {
return removeTrailingForwardSlash(path);
} else if (i === paths.length - 1) {
return removeLeadingForwardSlash(path);
} else {
return trimSlashes(path);
}
})
.join('/');
}
export function removeFileExtension(path: string) {
let idx = path.lastIndexOf('.');
return idx === -1 ? path : path.slice(0, idx);
}
export function removeQueryString(path: string) {
const index = path.lastIndexOf('?');
return index > 0 ? path.substring(0, index) : path;
}
export function isRemotePath(src: string) {
return /^(http|ftp|https):?\/\//.test(src) || src.startsWith('data:');
}

View file

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src"],
"compilerOptions": {
"allowJs": true,
"target": "ES2021",
"module": "ES2022",
"outDir": "./dist"
}
}

View file

@ -534,6 +534,9 @@ importers:
'@astrojs/compiler':
specifier: ^1.4.0
version: 1.4.0
'@astrojs/internal-helpers':
specifier: ^0.1.0
version: link:../internal-helpers
'@astrojs/language-server':
specifier: ^1.0.0
version: 1.0.0
@ -4809,6 +4812,9 @@ importers:
packages/integrations/vercel:
dependencies:
'@astrojs/internal-helpers':
specifier: ^0.1.0
version: link:../../internal-helpers
'@astrojs/webapi':
specifier: ^2.1.1
version: link:../../webapi
@ -4868,6 +4874,15 @@ importers:
specifier: workspace:*
version: link:../../../../../astro
packages/integrations/vercel/test/fixtures/redirects:
dependencies:
'@astrojs/vercel':
specifier: workspace:*
version: link:../../..
astro:
specifier: workspace:*
version: link:../../../../../astro
packages/integrations/vercel/test/fixtures/serverless-prerender:
dependencies:
'@astrojs/vercel':
@ -4926,6 +4941,12 @@ importers:
specifier: workspace:*
version: link:../../../../../astro
packages/internal-helpers:
devDependencies:
astro-scripts:
specifier: workspace:*
version: link:../../scripts
packages/markdown/component:
devDependencies:
'@types/mocha':