Compare commits

...

16 commits

Author SHA1 Message Date
Matthew Phillips
4adedbaafe Add support for the object notation in redirects 2023-05-23 14:46:00 -04:00
Matthew Phillips
b8ef648f8c Implement support for redirects config in the Vercel adapter 2023-05-23 12:34:11 -04:00
Matthew Phillips
11a517b1f1
Merge branch 'main' into redirects-ssg 2023-05-23 09:34:22 -04:00
Matthew Phillips
25d7d208ba
Implement support for dynamic routes in redirects (#7173)
* Implement support for dynamic routes in redirects

* Remove the .only

* No need to special-case redirects in static build
2023-05-23 09:01:46 -04:00
Matthew Phillips
4857c7d317
Support redirects in Netlify SSR configuration (#7167) 2023-05-23 08:14:18 -04:00
Matthew Phillips
e9e4d72598 Merge branch 'main' into redirects-ssg 2023-05-22 11:43:26 -04:00
Matthew Phillips
83ed3669be Late test should only run in SSR 2023-05-19 15:42:18 -04:00
Matthew Phillips
ab0539b951 Fix location ref 2023-05-19 15:18:44 -04:00
Matthew Phillips
1749ce5d08 Refactor to be more modular 2023-05-19 15:07:14 -04:00
Matthew Phillips
eed6a72a2a Fix build problem 2023-05-19 14:48:21 -04:00
Matthew Phillips
475294a60e
Merge branch 'main' into redirects-ssg 2023-05-19 14:34:44 -04:00
Matthew Phillips
a70820be15 Rename file 2023-05-11 16:25:02 -04:00
Matthew Phillips
f52116ac03 Adding a changeset 2023-05-11 16:05:29 -04:00
Matthew Phillips
d6b7104722 Support in Netlify as well 2023-05-11 15:54:10 -04:00
Matthew Phillips
ef3ea942cc Allow redirects in static mode 2023-05-11 15:19:38 -04:00
Matthew Phillips
46e726960f Redirects spike 2023-05-10 14:47:14 -04:00
35 changed files with 697 additions and 104 deletions

View file

@ -0,0 +1,6 @@
---
'@astrojs/netlify': minor
'astro': minor
---
Implements the redirects proposal

View file

@ -452,6 +452,11 @@ export interface AstroUserConfig {
*/ */
cacheDir?: string; cacheDir?: string;
/**
* TODO
*/
redirects?: Record<string, string>;
/** /**
* @docs * @docs
* @name site * @name site
@ -1577,6 +1582,8 @@ export interface AstroAdapter {
type Body = string; type Body = string;
export type ValidRedirectStatus = 300 | 301 | 302 | 303 | 304 | 307 | 308;
// Shared types between `Astro` global and API context object // Shared types between `Astro` global and API context object
interface AstroSharedContext<Props extends Record<string, any> = Record<string, any>> { interface AstroSharedContext<Props extends Record<string, any> = Record<string, any>> {
/** /**
@ -1606,7 +1613,7 @@ interface AstroSharedContext<Props extends Record<string, any> = Record<string,
/** /**
* Redirect to another page (**SSR Only**). * Redirect to another page (**SSR Only**).
*/ */
redirect(path: string, status?: 301 | 302 | 303 | 307 | 308): Response; redirect(path: string, status?: ValidRedirectStatus): Response;
/** /**
* Object accessed via Astro middleware * Object accessed via Astro middleware
@ -1821,7 +1828,7 @@ export interface AstroPluginOptions {
logging: LogOptions; logging: LogOptions;
} }
export type RouteType = 'page' | 'endpoint'; export type RouteType = 'page' | 'endpoint' | 'redirect';
export interface RoutePart { export interface RoutePart {
content: string; content: string;
@ -1829,6 +1836,11 @@ export interface RoutePart {
spread: boolean; spread: boolean;
} }
type RedirectConfig = string | {
status: ValidRedirectStatus;
destination: string;
}
export interface RouteData { export interface RouteData {
route: string; route: string;
component: string; component: string;
@ -1841,6 +1853,12 @@ export interface RouteData {
segments: RoutePart[][]; segments: RoutePart[][];
type: RouteType; type: RouteType;
prerender: boolean; prerender: boolean;
redirect?: RedirectConfig;
redirectRoute?: RouteData;
}
export type RedirectRouteData = RouteData & {
redirect: string;
} }
export type SerializedRouteData = Omit<RouteData, 'generate' | 'pattern'> & { export type SerializedRouteData = Omit<RouteData, 'generate' | 'pattern'> & {

View file

@ -26,6 +26,7 @@ export function getOutFolder(
case 'endpoint': case 'endpoint':
return new URL('.' + appendForwardSlash(npath.dirname(pathname)), outRoot); return new URL('.' + appendForwardSlash(npath.dirname(pathname)), outRoot);
case 'page': case 'page':
case 'redirect':
switch (astroConfig.build.format) { switch (astroConfig.build.format) {
case 'directory': { case 'directory': {
if (STATUS_CODE_PAGES.has(pathname)) { if (STATUS_CODE_PAGES.has(pathname)) {
@ -51,6 +52,7 @@ export function getOutFile(
case 'endpoint': case 'endpoint':
return new URL(npath.basename(pathname), outFolder); return new URL(npath.basename(pathname), outFolder);
case 'page': case 'page':
case 'redirect':
switch (astroConfig.build.format) { switch (astroConfig.build.format) {
case 'directory': { case 'directory': {
if (STATUS_CODE_PAGES.has(pathname)) { if (STATUS_CODE_PAGES.has(pathname)) {

View file

@ -35,6 +35,7 @@ import { debug, info } from '../logger/core.js';
import { callMiddleware } from '../middleware/callMiddleware.js'; import { callMiddleware } from '../middleware/callMiddleware.js';
import { createEnvironment, createRenderContext, renderPage } from '../render/index.js'; import { createEnvironment, createRenderContext, renderPage } from '../render/index.js';
import { callGetStaticPaths } from '../render/route-cache.js'; import { callGetStaticPaths } from '../render/route-cache.js';
import { getRedirectLocationOrThrow, routeIsRedirect } from '../redirects/index.js';
import { import {
createAssetLink, createAssetLink,
createModuleScriptsSet, createModuleScriptsSet,
@ -169,9 +170,16 @@ async function generatePage(
.map(({ sheet }) => sheet) .map(({ sheet }) => sheet)
.reduce(mergeInlineCss, []); .reduce(mergeInlineCss, []);
const pageModulePromise = ssrEntry.pageMap?.get(pageData.component); let pageModulePromise = ssrEntry.pageMap?.get(pageData.component);
const middleware = ssrEntry.middleware; const middleware = ssrEntry.middleware;
if (!pageModulePromise && routeIsRedirect(pageData.route)) {
if(pageData.route.redirectRoute) {
pageModulePromise = ssrEntry.pageMap?.get(pageData.route.redirectRoute!.component);
} else {
pageModulePromise = () => Promise.resolve<any>({ default: () => {} });
}
}
if (!pageModulePromise) { if (!pageModulePromise) {
throw new Error( throw new Error(
`Unable to find the module for ${pageData.component}. This is unexpected and likely a bug in Astro, please report.` `Unable to find the module for ${pageData.component}. This is unexpected and likely a bug in Astro, please report.`
@ -509,11 +517,26 @@ async function generatePath(
} }
throw err; throw err;
} }
throwIfRedirectNotAllowed(response, opts.settings.config);
switch(true) {
case (response.status >= 300 && response.status < 400): {
const location = getRedirectLocationOrThrow(response.headers);
body = `<!doctype html>
<title>Redirecting to: ${location}</title>
<meta http-equiv="refresh" content="0;url=${location}" />`;
// A dynamic redirect, set the location so that integrations know about it.
if(pageData.route.type !== 'redirect') {
pageData.route.redirect = location;
}
break;
}
default: {
// If there's no body, do nothing // If there's no body, do nothing
if (!response.body) return; if (!response.body) return;
body = await response.text(); body = await response.text();
} }
}
}
const outFolder = getOutFolder(settings.config, pathname, pageData.route.type); const outFolder = getOutFolder(settings.config, pathname, pageData.route.type);
const outFile = getOutFile(settings.config, outFolder, pathname, pageData.route.type); const outFile = getOutFile(settings.config, outFolder, pathname, pageData.route.type);

View file

@ -5,6 +5,7 @@ import { eachPageData, type BuildInternals } from '../internal.js';
import type { AstroBuildPlugin } from '../plugin'; import type { AstroBuildPlugin } from '../plugin';
import type { StaticBuildOptions } from '../types'; import type { StaticBuildOptions } from '../types';
import { MIDDLEWARE_MODULE_ID } from './plugin-middleware.js'; import { MIDDLEWARE_MODULE_ID } from './plugin-middleware.js';
import { routeIsRedirect } from '../../redirects/index.js';
function vitePluginPages(opts: StaticBuildOptions, internals: BuildInternals): VitePlugin { function vitePluginPages(opts: StaticBuildOptions, internals: BuildInternals): VitePlugin {
return { return {
@ -28,6 +29,9 @@ function vitePluginPages(opts: StaticBuildOptions, internals: BuildInternals): V
let imports = []; let imports = [];
let i = 0; let i = 0;
for (const pageData of eachPageData(internals)) { for (const pageData of eachPageData(internals)) {
if(routeIsRedirect(pageData.route)) {
continue;
}
const variable = `_page${i}`; const variable = `_page${i}`;
imports.push( imports.push(
`const ${variable} = () => import(${JSON.stringify(pageData.moduleSpecifier)});` `const ${variable} = () => import(${JSON.stringify(pageData.moduleSpecifier)});`

View file

@ -37,6 +37,7 @@ const ASTRO_CONFIG_DEFAULTS: AstroUserConfig & any = {
}, },
vite: {}, vite: {},
legacy: {}, legacy: {},
redirects: {},
experimental: { experimental: {
assets: false, assets: false,
hybridOutput: false, hybridOutput: false,
@ -137,6 +138,7 @@ export const AstroConfigSchema = z.object({
.optional() .optional()
.default({}) .default({})
), ),
redirects: z.record(z.string(), z.string()).default(ASTRO_CONFIG_DEFAULTS.redirects),
image: z image: z
.object({ .object({
service: z.object({ service: z.object({

View file

@ -43,6 +43,7 @@ export const AstroErrorData = {
* The `Astro.redirect` function is only available when [Server-side rendering](/en/guides/server-side-rendering/) is enabled. * The `Astro.redirect` function is only available when [Server-side rendering](/en/guides/server-side-rendering/) is enabled.
* *
* To redirect on a static website, the [meta refresh attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta) can be used. Certain hosts also provide config-based redirects (ex: [Netlify redirects](https://docs.netlify.com/routing/redirects/)). * To redirect on a static website, the [meta refresh attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta) can be used. Certain hosts also provide config-based redirects (ex: [Netlify redirects](https://docs.netlify.com/routing/redirects/)).
* @deprecated since version 2.6
*/ */
StaticRedirectNotAvailable: { StaticRedirectNotAvailable: {
title: '`Astro.redirect` is not available in static mode.', title: '`Astro.redirect` is not available in static mode.',
@ -719,6 +720,18 @@ See https://docs.astro.build/en/guides/server-side-rendering/ for more informati
return `The information stored in \`Astro.locals\` for the path "${href}" is not serializable.\nMake sure you store only serializable data.`; return `The information stored in \`Astro.locals\` for the path "${href}" is not serializable.\nMake sure you store only serializable data.`;
}, },
}, },
/**
* @docs
* @see
* - [Astro.redirect](https://docs.astro.build/en/guides/server-side-rendering/#astroredirect)
* @description
* A redirect must be given a location with the `Location` header.
*/
RedirectWithNoLocation: {
// TODO remove
title: 'A redirect must be given a location with the `Location` header.',
code: 3035,
},
// No headings here, that way Vite errors are merged with Astro ones in the docs, which makes more sense to users. // No headings here, that way Vite errors are merged with Astro ones in the docs, which makes more sense to users.
// Vite Errors - 4xxx // Vite Errors - 4xxx
/** /**

View file

@ -0,0 +1,27 @@
import type { RouteData, RedirectRouteData, Params, ValidRedirectStatus } from '../../@types/astro';
export function routeIsRedirect(route: RouteData | undefined): route is RedirectRouteData {
return route?.type === 'redirect';
}
export function redirectRouteGenerate(redirectRoute: RouteData, data: Params): string {
const routeData = redirectRoute.redirectRoute;
const route = redirectRoute.redirect;
if(typeof routeData !== 'undefined') {
return routeData?.generate(data) || routeData?.pathname || '/';
} else if(typeof route === 'string') {
return route;
} else if(typeof route === 'undefined') {
return '/';
}
return route.destination;
}
export function redirectRouteStatus(redirectRoute: RouteData): ValidRedirectStatus {
const routeData = redirectRoute.redirectRoute;
if(typeof routeData?.redirect === 'object') {
return routeData.redirect.status;
}
return 301;
}

View file

@ -0,0 +1,2 @@
export { getRedirectLocationOrThrow } from './validate.js';
export { routeIsRedirect, redirectRouteGenerate, redirectRouteStatus } from './helpers.js';

View file

@ -0,0 +1,13 @@
import { AstroError, AstroErrorData } from '../errors/index.js';
export function getRedirectLocationOrThrow(headers: Headers): string {
let location = headers.get('location');
if(!location) {
throw new AstroError({
...AstroErrorData.RedirectWithNoLocation
});
}
return location;
}

View file

@ -8,6 +8,7 @@ import type { RenderContext } from './context.js';
import type { Environment } from './environment.js'; import type { Environment } from './environment.js';
import { createResult } from './result.js'; import { createResult } from './result.js';
import { callGetStaticPaths, findPathItemByKey, RouteCache } from './route-cache.js'; import { callGetStaticPaths, findPathItemByKey, RouteCache } from './route-cache.js';
import { routeIsRedirect, redirectRouteGenerate, redirectRouteStatus } from '../redirects/index.js';
interface GetParamsAndPropsOptions { interface GetParamsAndPropsOptions {
mod: ComponentInstance; mod: ComponentInstance;
@ -111,6 +112,15 @@ export type RenderPage = {
}; };
export async function renderPage({ mod, renderContext, env, apiContext }: RenderPage) { export async function renderPage({ mod, renderContext, env, apiContext }: RenderPage) {
if(routeIsRedirect(renderContext.route)) {
return new Response(null, {
status: redirectRouteStatus(renderContext.route),
headers: {
location: redirectRouteGenerate(renderContext.route, renderContext.params)
}
});
}
// Validate the page component before rendering the page // Validate the page component before rendering the page
const Component = mod.default; const Component = mod.default;
if (!Component) if (!Component)

View file

@ -206,8 +206,7 @@ export function createResult(args: CreateResultArgs): SSRResult {
locals, locals,
request, request,
url, url,
redirect: args.ssr redirect(path, status) {
? (path, status) => {
// If the response is already sent, error as we cannot proceed with the redirect. // If the response is already sent, error as we cannot proceed with the redirect.
if ((request as any)[responseSentSymbol]) { if ((request as any)[responseSentSymbol]) {
throw new AstroError({ throw new AstroError({
@ -221,8 +220,7 @@ export function createResult(args: CreateResultArgs): SSRResult {
Location: path, Location: path,
}, },
}); });
} },
: onlyAvailableInSSR('Astro.redirect'),
response: response as AstroGlobal['response'], response: response as AstroGlobal['response'],
slots: astroSlots as unknown as AstroGlobal['slots'], slots: astroSlots as unknown as AstroGlobal['slots'],
}; };

View file

@ -420,6 +420,47 @@ export function createRouteManifest(
}); });
}); });
Object.entries(settings.config.redirects).forEach(([from, to]) => {
const trailingSlash = config.trailingSlash;
const segments = removeLeadingForwardSlash(from)
.split(path.posix.sep)
.filter(Boolean)
.map((s: string) => {
validateSegment(s);
return getParts(s, from);
});
const pattern = getPattern(segments, settings.config.base, trailingSlash);
const generate = getRouteGenerator(segments, trailingSlash);
const pathname = segments.every((segment) => segment.length === 1 && !segment[0].dynamic)
? `/${segments.map((segment) => segment[0].content).join('/')}`
: null;
const params = segments
.flat()
.filter((p) => p.dynamic)
.map((p) => p.content);
const route = `/${segments
.map(([{ dynamic, content }]) => (dynamic ? `[${content}]` : content))
.join('/')}`.toLowerCase();
routes.unshift({
type: 'redirect',
route,
pattern,
segments,
params,
component: from,
generate,
pathname: pathname || void 0,
prerender: false,
redirect: to,
redirectRoute: routes.find(r => r.route === to)
});
});
return { return {
routes, routes,
}; };

View file

@ -57,6 +57,12 @@ export function stringifyChunk(
} }
return renderAllHeadContent(result); return renderAllHeadContent(result);
} }
default: {
if(chunk instanceof Response) {
return '';
}
throw new Error(`Unknown chunk type: ${(chunk as any).type}`);
}
} }
} else { } else {
if (isSlotString(chunk as string)) { if (isSlotString(chunk as string)) {
@ -102,6 +108,7 @@ export function chunkToByteArray(
if (chunk instanceof Uint8Array) { if (chunk instanceof Uint8Array) {
return chunk as Uint8Array; return chunk as Uint8Array;
} }
// stringify chunk might return a HTMLString // stringify chunk might return a HTMLString
let stringified = stringifyChunk(result, chunk); let stringified = stringifyChunk(result, chunk);
return encoder.encode(stringified.toString()); return encoder.encode(stringified.toString());

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,10 @@
---
---
<html>
<head>
<title>Testing</title>
</head>
<body>
<h1>Testing</h1>
</body>
</html>

View file

@ -1,5 +1,6 @@
--- ---
import Redirect from '../components/redirect.astro'; import Redirect from '../components/redirect.astro';
const staticMode = import.meta.env.STATIC_MODE;
--- ---
<html> <html>
<head> <head>
@ -7,6 +8,8 @@ import Redirect from '../components/redirect.astro';
</head> </head>
<body> <body>
<h1>Testing</h1> <h1>Testing</h1>
{ !staticMode ? (
<Redirect /> <Redirect />
) : <div></div>}
</body> </body>
</html> </html>

View file

@ -0,0 +1,91 @@
import { expect } from 'chai';
import { loadFixture } from './test-utils.js';
import testAdapter from './test-adapter.js';
describe('Astro.redirect', () => {
/** @type {import('./test-utils').Fixture} */
let fixture;
describe('output: "server"', () => {
before(async () => {
fixture = await loadFixture({
root: './fixtures/ssr-redirect/',
output: 'server',
adapter: testAdapter(),
});
await fixture.build();
});
it('Returns a 302 status', async () => {
const app = await fixture.loadTestAdapterApp();
const request = new Request('http://example.com/secret');
const response = await app.render(request);
expect(response.status).to.equal(302);
expect(response.headers.get('location')).to.equal('/login');
});
it('Warns when used inside a component', async () => {
const app = await fixture.loadTestAdapterApp();
const request = new Request('http://example.com/late');
const response = await app.render(request);
try {
await response.text();
expect(false).to.equal(true);
} catch (e) {
expect(e.message).to.equal(
'The response has already been sent to the browser and cannot be altered.'
);
}
});
});
describe('output: "static"', () => {
before(async () => {
process.env.STATIC_MODE = true;
fixture = await loadFixture({
root: './fixtures/ssr-redirect/',
output: 'static',
redirects: {
'/one': '/',
'/two': '/',
'/blog/[...slug]': '/articles/[...slug]',
'/three': {
status: 302,
destination: '/'
}
}
});
await fixture.build();
});
it('Includes the meta refresh tag in Astro.redirect pages', async () => {
const html = await fixture.readFile('/secret/index.html');
expect(html).to.include('http-equiv="refresh');
expect(html).to.include('url=/login');
});
it('Includes the meta refresh tag in `redirect` config pages', async () => {
let html = await fixture.readFile('/one/index.html');
expect(html).to.include('http-equiv="refresh');
expect(html).to.include('url=/');
html = await fixture.readFile('/two/index.html');
expect(html).to.include('http-equiv="refresh');
expect(html).to.include('url=/');
html = await fixture.readFile('/three/index.html');
expect(html).to.include('http-equiv="refresh');
expect(html).to.include('url=/');
});
it('Generates page for dynamic routes', async () => {
let html = await fixture.readFile('/blog/one/index.html');
expect(html).to.include('http-equiv="refresh');
expect(html).to.include('url=/articles/one');
html = await fixture.readFile('/blog/two/index.html');
expect(html).to.include('http-equiv="refresh');
expect(html).to.include('url=/articles/two');
});
});
});

View file

@ -1,39 +0,0 @@
import { expect } from 'chai';
import { loadFixture } from './test-utils.js';
import testAdapter from './test-adapter.js';
describe('Astro.redirect', () => {
/** @type {import('./test-utils').Fixture} */
let fixture;
before(async () => {
fixture = await loadFixture({
root: './fixtures/ssr-redirect/',
output: 'server',
adapter: testAdapter(),
});
await fixture.build();
});
it('Returns a 302 status', async () => {
const app = await fixture.loadTestAdapterApp();
const request = new Request('http://example.com/secret');
const response = await app.render(request);
expect(response.status).to.equal(302);
expect(response.headers.get('location')).to.equal('/login');
});
it('Warns when used inside a component', async () => {
const app = await fixture.loadTestAdapterApp();
const request = new Request('http://example.com/late');
const response = await app.render(request);
try {
const text = await response.text();
expect(false).to.equal(true);
} catch (e) {
expect(e.message).to.equal(
'The response has already been sent to the browser and cannot be altered.'
);
}
});
});

View file

@ -1,2 +1,3 @@
export { netlifyEdgeFunctions } from './integration-edge-functions.js'; export { netlifyEdgeFunctions } from './integration-edge-functions.js';
export { netlifyFunctions as default, netlifyFunctions } from './integration-functions.js'; export { netlifyFunctions as default, netlifyFunctions } from './integration-functions.js';
export { netlifyStatic } from './integration-static.js';

View file

@ -0,0 +1,18 @@
import type { AstroAdapter, AstroConfig, AstroIntegration, RouteData } from 'astro';
import type { Args } from './netlify-functions.js';
import { createRedirects } from './shared.js';
export function netlifyStatic(): AstroIntegration {
let _config: any;
return {
name: '@astrojs/netlify',
hooks: {
'astro:config:done': ({ config }) => {
_config = config;
},
'astro:build:done': async ({ dir, routes }) => {
await createRedirects(_config, routes, dir, '', 'static');
}
}
};
}

View file

@ -1,20 +1,27 @@
import type { AstroConfig, RouteData } from 'astro'; import type { AstroConfig, RouteData, ValidRedirectStatus } from 'astro';
import fs from 'fs'; import fs from 'fs';
type RedirectDefinition = { export type RedirectDefinition = {
dynamic: boolean; dynamic: boolean;
input: string; input: string;
target: string; target: string;
weight: 0 | 1; weight: 0 | 1;
status: 200 | 404; status: 200 | 404 | ValidRedirectStatus;
}; };
function getRedirectStatus(route: RouteData): ValidRedirectStatus {
if(typeof route.redirect === 'object') {
return route.redirect.status;
}
return 301;
}
export async function createRedirects( export async function createRedirects(
config: AstroConfig, config: AstroConfig,
routes: RouteData[], routes: RouteData[],
dir: URL, dir: URL,
entryFile: string, entryFile: string,
type: 'functions' | 'edge-functions' | 'builders' type: 'functions' | 'edge-functions' | 'builders' | 'static'
) { ) {
const _redirectsURL = new URL('./_redirects', dir); const _redirectsURL = new URL('./_redirects', dir);
const kind = type ?? 'functions'; const kind = type ?? 'functions';
@ -23,7 +30,21 @@ export async function createRedirects(
for (const route of routes) { for (const route of routes) {
if (route.pathname) { if (route.pathname) {
if (route.distURL) { if(route.redirect) {
definitions.push({
dynamic: false,
input: route.pathname,
target: typeof route.redirect === 'object' ? route.redirect.destination : route.redirect,
status: getRedirectStatus(route),
weight: 1
});
continue;
}
if(kind === 'static') {
continue;
}
else if (route.distURL) {
definitions.push({ definitions.push({
dynamic: false, dynamic: false,
input: route.pathname, input: route.pathname,
@ -51,31 +72,18 @@ export async function createRedirects(
} }
} }
} else { } else {
const pattern = const pattern = generateDynamicPattern(route);
'/' +
route.segments
.map(([part]) => {
//(part.dynamic ? '*' : part.content)
if (part.dynamic) {
if (part.spread) {
return '*';
} else {
return ':' + part.content;
}
} else {
return part.content;
}
})
.join('/');
if (route.distURL) { if (route.distURL) {
const targetRoute = route.redirectRoute ?? route;
const targetPattern = generateDynamicPattern(targetRoute);
const target = const target =
`${pattern}` + (config.build.format === 'directory' ? '/index.html' : '.html'); `${targetPattern}` + (config.build.format === 'directory' ? '/index.html' : '.html');
definitions.push({ definitions.push({
dynamic: true, dynamic: true,
input: pattern, input: pattern,
target, target,
status: 200, status: route.type === 'redirect' ? 301 : 200,
weight: 1, weight: 1,
}); });
} else { } else {
@ -98,9 +106,29 @@ export async function createRedirects(
await fs.promises.appendFile(_redirectsURL, _redirects, 'utf-8'); await fs.promises.appendFile(_redirectsURL, _redirects, 'utf-8');
} }
function generateDynamicPattern(route: RouteData) {
const pattern =
'/' +
route.segments
.map(([part]) => {
//(part.dynamic ? '*' : part.content)
if (part.dynamic) {
if (part.spread) {
return '*';
} else {
return ':' + part.content;
}
} else {
return part.content;
}
})
.join('/');
return pattern;
}
function prettify(definitions: RedirectDefinition[]) { function prettify(definitions: RedirectDefinition[]) {
let minInputLength = 0, let minInputLength = 4,
minTargetLength = 0; minTargetLength = 4;
definitions.sort((a, b) => { definitions.sort((a, b) => {
// Find the longest input, so we can format things nicely // Find the longest input, so we can format things nicely
if (a.input.length > minInputLength) { if (a.input.length > minInputLength) {

View file

@ -0,0 +1,42 @@
import { expect } from 'chai';
import { load as cheerioLoad } from 'cheerio';
import { loadFixture, testIntegration } from './test-utils.js';
import netlifyAdapter from '../../dist/index.js';
import { fileURLToPath } from 'url';
describe('SSG - Redirects', () => {
/** @type {import('../../../astro/test/test-utils').Fixture} */
let fixture;
before(async () => {
fixture = await loadFixture({
root: new URL('../static/fixtures/redirects/', import.meta.url).toString(),
output: 'server',
adapter: netlifyAdapter({
dist: new URL('../static/fixtures/redirects/dist/', import.meta.url),
}),
site: `http://example.com`,
integrations: [testIntegration()],
redirects: {
'/other': '/'
}
});
await fixture.build();
});
it('Creates a redirects file', async () => {
let redirects = await fixture.readFile('/_redirects');
let parts = redirects.split(/\s+/);
expect(parts).to.deep.equal([
'/other', '/', '301',
'/', '/.netlify/functions/entry', '200',
// This uses the dynamic Astro.redirect, so we don't know that it's a redirect
// until runtime. This is correct!
'/nope', '/.netlify/functions/entry', '200',
// A real route
'/team/articles/*', '/.netlify/functions/entry', '200',
]);
});
});

View file

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

View file

@ -0,0 +1,3 @@
---
return Astro.redirect('/');
---

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,39 @@
import { expect } from 'chai';
import { loadFixture, testIntegration } from './test-utils.js';
import { netlifyStatic } from '../../dist/index.js';
describe('SSG - Redirects', () => {
/** @type {import('../../../astro/test/test-utils').Fixture} */
let fixture;
before(async () => {
fixture = await loadFixture({
root: new URL('./fixtures/redirects/', import.meta.url).toString(),
output: 'static',
adapter: netlifyStatic(),
site: `http://example.com`,
integrations: [testIntegration()],
redirects: {
'/other': '/',
'/two': {
status: 302,
destination: '/'
},
'/blog/[...slug]': '/team/articles/[...slug]'
}
});
await fixture.build();
});
it('Creates a redirects file', async () => {
let redirects = await fixture.readFile('/_redirects');
let parts = redirects.split(/\s+/);
expect(parts).to.deep.equal([
'/blog/*', '/team/articles/*/index.html', '301',
'/two', '/', '302',
'/other', '/', '301',
'/nope', '/', '301',
'/team/articles/*', '/team/articles/*/index.html', '200'
]);
});
});

View file

@ -0,0 +1,29 @@
// @ts-check
import { fileURLToPath } from 'url';
export * from '../../../../astro/test/test-utils.js';
/**
*
* @returns {import('../../../../astro/dist/types/@types/astro').AstroIntegration}
*/
export function testIntegration() {
return {
name: '@astrojs/netlify/test-integration',
hooks: {
'astro:config:setup': ({ updateConfig }) => {
updateConfig({
vite: {
resolve: {
alias: {
'@astrojs/netlify/netlify-functions.js': fileURLToPath(
new URL('../../dist/netlify-functions.js', import.meta.url)
),
},
},
},
});
},
},
};
}

View file

@ -1,4 +1,7 @@
import type { AstroConfig, RouteData, RoutePart } from 'astro'; import type { AstroConfig, RouteData, RoutePart } from 'astro';
import nodePath from 'node:path';
const pathJoin = nodePath.posix.join;
// https://vercel.com/docs/project-configuration#legacy/routes // https://vercel.com/docs/project-configuration#legacy/routes
interface VercelRoute { interface VercelRoute {
@ -36,6 +39,10 @@ function getMatchPattern(segments: RoutePart[][]) {
.join(''); .join('');
} }
function appendTrailingSlash(route: string): string {
return route.at(-1) === '/' ? route : route + '/';
}
function getReplacePattern(segments: RoutePart[][]) { function getReplacePattern(segments: RoutePart[][]) {
let n = 0; let n = 0;
let result = ''; let result = '';
@ -54,23 +61,45 @@ function getReplacePattern(segments: RoutePart[][]) {
return result; return result;
} }
function getRedirectLocation(route: RouteData, config: AstroConfig): string {
if(route.redirectRoute) {
const pattern = getReplacePattern(route.redirectRoute.segments);
const path = (config.trailingSlash === 'always' ? appendTrailingSlash(pattern) : pattern);
return pathJoin(config.base, path);
} else if(typeof route.redirect === 'object') {
return pathJoin(config.base, route.redirect.destination);
} else {
return pathJoin(config.base, route.redirect || '');
}
}
function getRedirectStatus(route: RouteData): number {
if(typeof route.redirect === 'object') {
return route.redirect.status;
}
return 301;
}
export function getRedirects(routes: RouteData[], config: AstroConfig): VercelRoute[] { export function getRedirects(routes: RouteData[], config: AstroConfig): VercelRoute[] {
let redirects: 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: getRedirectLocation(route, config) },
status: getRedirectStatus(route)
});
} else if (route.type === 'page') {
if (config.trailingSlash === 'always') {
redirects.push({ redirects.push({
src: config.base + getMatchPattern(route.segments), src: config.base + getMatchPattern(route.segments),
headers: { Location: config.base + getReplacePattern(route.segments) + '/' }, headers: { Location: config.base + getReplacePattern(route.segments) + '/' },
status: 308, status: 308,
}); });
}
} else if (config.trailingSlash === 'never') { } else if (config.trailingSlash === 'never') {
for (const route of routes) {
if (route.type !== 'page' || route.segments.length === 0) continue;
redirects.push({ redirects.push({
src: config.base + getMatchPattern(route.segments) + '/', src: config.base + getMatchPattern(route.segments) + '/',
headers: { Location: config.base + getReplacePattern(route.segments) }, headers: { Location: config.base + getReplacePattern(route.segments) },
@ -78,6 +107,7 @@ export function getRedirects(routes: RouteData[], config: AstroConfig): VercelRo
}); });
} }
} }
}
return redirects; return redirects;
} }

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,56 @@
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': '/',
'/three': {
status: 302,
destination: '/'
},
'/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 === '/\\/two');
expect(twoRoute.headers.Location).to.equal('/');
expect(twoRoute.status).to.equal(301);
const threeRoute = config.routes.find(r => r.src === '/\\/three');
expect(threeRoute.headers.Location).to.equal('/');
expect(threeRoute.status).to.equal(302);
});
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

@ -4868,6 +4868,15 @@ importers:
specifier: workspace:* specifier: workspace:*
version: link:../../../../../astro 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: packages/integrations/vercel/test/fixtures/serverless-prerender:
dependencies: dependencies:
'@astrojs/vercel': '@astrojs/vercel':