Compare commits
16 commits
main
...
redirects-
Author | SHA1 | Date | |
---|---|---|---|
|
4adedbaafe | ||
|
b8ef648f8c | ||
|
11a517b1f1 | ||
|
25d7d208ba | ||
|
4857c7d317 | ||
|
e9e4d72598 | ||
|
83ed3669be | ||
|
ab0539b951 | ||
|
1749ce5d08 | ||
|
eed6a72a2a | ||
|
475294a60e | ||
|
a70820be15 | ||
|
f52116ac03 | ||
|
d6b7104722 | ||
|
ef3ea942cc | ||
|
46e726960f |
35 changed files with 697 additions and 104 deletions
6
.changeset/chatty-actors-stare.md
Normal file
6
.changeset/chatty-actors-stare.md
Normal file
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
'@astrojs/netlify': minor
|
||||
'astro': minor
|
||||
---
|
||||
|
||||
Implements the redirects proposal
|
|
@ -452,6 +452,11 @@ export interface AstroUserConfig {
|
|||
*/
|
||||
cacheDir?: string;
|
||||
|
||||
/**
|
||||
* TODO
|
||||
*/
|
||||
redirects?: Record<string, string>;
|
||||
|
||||
/**
|
||||
* @docs
|
||||
* @name site
|
||||
|
@ -1577,6 +1582,8 @@ export interface AstroAdapter {
|
|||
|
||||
type Body = string;
|
||||
|
||||
export type ValidRedirectStatus = 300 | 301 | 302 | 303 | 304 | 307 | 308;
|
||||
|
||||
// Shared types between `Astro` global and API context object
|
||||
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(path: string, status?: 301 | 302 | 303 | 307 | 308): Response;
|
||||
redirect(path: string, status?: ValidRedirectStatus): Response;
|
||||
|
||||
/**
|
||||
* Object accessed via Astro middleware
|
||||
|
@ -1821,7 +1828,7 @@ export interface AstroPluginOptions {
|
|||
logging: LogOptions;
|
||||
}
|
||||
|
||||
export type RouteType = 'page' | 'endpoint';
|
||||
export type RouteType = 'page' | 'endpoint' | 'redirect';
|
||||
|
||||
export interface RoutePart {
|
||||
content: string;
|
||||
|
@ -1829,6 +1836,11 @@ export interface RoutePart {
|
|||
spread: boolean;
|
||||
}
|
||||
|
||||
type RedirectConfig = string | {
|
||||
status: ValidRedirectStatus;
|
||||
destination: string;
|
||||
}
|
||||
|
||||
export interface RouteData {
|
||||
route: string;
|
||||
component: string;
|
||||
|
@ -1841,6 +1853,12 @@ export interface RouteData {
|
|||
segments: RoutePart[][];
|
||||
type: RouteType;
|
||||
prerender: boolean;
|
||||
redirect?: RedirectConfig;
|
||||
redirectRoute?: RouteData;
|
||||
}
|
||||
|
||||
export type RedirectRouteData = RouteData & {
|
||||
redirect: string;
|
||||
}
|
||||
|
||||
export type SerializedRouteData = Omit<RouteData, 'generate' | 'pattern'> & {
|
||||
|
|
|
@ -26,6 +26,7 @@ export function getOutFolder(
|
|||
case 'endpoint':
|
||||
return new URL('.' + appendForwardSlash(npath.dirname(pathname)), outRoot);
|
||||
case 'page':
|
||||
case 'redirect':
|
||||
switch (astroConfig.build.format) {
|
||||
case 'directory': {
|
||||
if (STATUS_CODE_PAGES.has(pathname)) {
|
||||
|
@ -51,6 +52,7 @@ export function getOutFile(
|
|||
case 'endpoint':
|
||||
return new URL(npath.basename(pathname), outFolder);
|
||||
case 'page':
|
||||
case 'redirect':
|
||||
switch (astroConfig.build.format) {
|
||||
case 'directory': {
|
||||
if (STATUS_CODE_PAGES.has(pathname)) {
|
||||
|
|
|
@ -35,6 +35,7 @@ import { debug, info } from '../logger/core.js';
|
|||
import { callMiddleware } from '../middleware/callMiddleware.js';
|
||||
import { createEnvironment, createRenderContext, renderPage } from '../render/index.js';
|
||||
import { callGetStaticPaths } from '../render/route-cache.js';
|
||||
import { getRedirectLocationOrThrow, routeIsRedirect } from '../redirects/index.js';
|
||||
import {
|
||||
createAssetLink,
|
||||
createModuleScriptsSet,
|
||||
|
@ -169,9 +170,16 @@ async function generatePage(
|
|||
.map(({ sheet }) => sheet)
|
||||
.reduce(mergeInlineCss, []);
|
||||
|
||||
const pageModulePromise = ssrEntry.pageMap?.get(pageData.component);
|
||||
let pageModulePromise = ssrEntry.pageMap?.get(pageData.component);
|
||||
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) {
|
||||
throw new Error(
|
||||
`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;
|
||||
}
|
||||
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 (!response.body) return;
|
||||
body = await response.text();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const outFolder = getOutFolder(settings.config, pathname, pageData.route.type);
|
||||
const outFile = getOutFile(settings.config, outFolder, pathname, pageData.route.type);
|
||||
|
|
|
@ -5,6 +5,7 @@ import { eachPageData, type BuildInternals } from '../internal.js';
|
|||
import type { AstroBuildPlugin } from '../plugin';
|
||||
import type { StaticBuildOptions } from '../types';
|
||||
import { MIDDLEWARE_MODULE_ID } from './plugin-middleware.js';
|
||||
import { routeIsRedirect } from '../../redirects/index.js';
|
||||
|
||||
function vitePluginPages(opts: StaticBuildOptions, internals: BuildInternals): VitePlugin {
|
||||
return {
|
||||
|
@ -28,6 +29,9 @@ function vitePluginPages(opts: StaticBuildOptions, internals: BuildInternals): V
|
|||
let imports = [];
|
||||
let i = 0;
|
||||
for (const pageData of eachPageData(internals)) {
|
||||
if(routeIsRedirect(pageData.route)) {
|
||||
continue;
|
||||
}
|
||||
const variable = `_page${i}`;
|
||||
imports.push(
|
||||
`const ${variable} = () => import(${JSON.stringify(pageData.moduleSpecifier)});`
|
||||
|
|
|
@ -37,6 +37,7 @@ const ASTRO_CONFIG_DEFAULTS: AstroUserConfig & any = {
|
|||
},
|
||||
vite: {},
|
||||
legacy: {},
|
||||
redirects: {},
|
||||
experimental: {
|
||||
assets: false,
|
||||
hybridOutput: false,
|
||||
|
@ -137,6 +138,7 @@ export const AstroConfigSchema = z.object({
|
|||
.optional()
|
||||
.default({})
|
||||
),
|
||||
redirects: z.record(z.string(), z.string()).default(ASTRO_CONFIG_DEFAULTS.redirects),
|
||||
image: z
|
||||
.object({
|
||||
service: z.object({
|
||||
|
|
|
@ -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.
|
||||
*
|
||||
* 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: {
|
||||
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.`;
|
||||
},
|
||||
},
|
||||
/**
|
||||
* @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.
|
||||
// Vite Errors - 4xxx
|
||||
/**
|
||||
|
|
27
packages/astro/src/core/redirects/helpers.ts
Normal file
27
packages/astro/src/core/redirects/helpers.ts
Normal 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;
|
||||
}
|
2
packages/astro/src/core/redirects/index.ts
Normal file
2
packages/astro/src/core/redirects/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export { getRedirectLocationOrThrow } from './validate.js';
|
||||
export { routeIsRedirect, redirectRouteGenerate, redirectRouteStatus } from './helpers.js';
|
13
packages/astro/src/core/redirects/validate.ts
Normal file
13
packages/astro/src/core/redirects/validate.ts
Normal 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;
|
||||
}
|
|
@ -8,6 +8,7 @@ import type { RenderContext } from './context.js';
|
|||
import type { Environment } from './environment.js';
|
||||
import { createResult } from './result.js';
|
||||
import { callGetStaticPaths, findPathItemByKey, RouteCache } from './route-cache.js';
|
||||
import { routeIsRedirect, redirectRouteGenerate, redirectRouteStatus } from '../redirects/index.js';
|
||||
|
||||
interface GetParamsAndPropsOptions {
|
||||
mod: ComponentInstance;
|
||||
|
@ -111,6 +112,15 @@ export type 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
|
||||
const Component = mod.default;
|
||||
if (!Component)
|
||||
|
|
|
@ -206,8 +206,7 @@ export function createResult(args: CreateResultArgs): SSRResult {
|
|||
locals,
|
||||
request,
|
||||
url,
|
||||
redirect: args.ssr
|
||||
? (path, status) => {
|
||||
redirect(path, status) {
|
||||
// If the response is already sent, error as we cannot proceed with the redirect.
|
||||
if ((request as any)[responseSentSymbol]) {
|
||||
throw new AstroError({
|
||||
|
@ -221,8 +220,7 @@ export function createResult(args: CreateResultArgs): SSRResult {
|
|||
Location: path,
|
||||
},
|
||||
});
|
||||
}
|
||||
: onlyAvailableInSSR('Astro.redirect'),
|
||||
},
|
||||
response: response as AstroGlobal['response'],
|
||||
slots: astroSlots as unknown as AstroGlobal['slots'],
|
||||
};
|
||||
|
|
|
@ -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 {
|
||||
routes,
|
||||
};
|
||||
|
|
|
@ -57,6 +57,12 @@ export function stringifyChunk(
|
|||
}
|
||||
return renderAllHeadContent(result);
|
||||
}
|
||||
default: {
|
||||
if(chunk instanceof Response) {
|
||||
return '';
|
||||
}
|
||||
throw new Error(`Unknown chunk type: ${(chunk as any).type}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (isSlotString(chunk as string)) {
|
||||
|
@ -102,6 +108,7 @@ export function chunkToByteArray(
|
|||
if (chunk instanceof Uint8Array) {
|
||||
return chunk as Uint8Array;
|
||||
}
|
||||
|
||||
// stringify chunk might return a HTMLString
|
||||
let stringified = stringifyChunk(result, chunk);
|
||||
return encoder.encode(stringified.toString());
|
||||
|
|
25
packages/astro/test/fixtures/ssr-redirect/src/pages/articles/[...slug].astro
vendored
Normal file
25
packages/astro/test/fixtures/ssr-redirect/src/pages/articles/[...slug].astro
vendored
Normal 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>
|
10
packages/astro/test/fixtures/ssr-redirect/src/pages/index.astro
vendored
Normal file
10
packages/astro/test/fixtures/ssr-redirect/src/pages/index.astro
vendored
Normal file
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
---
|
||||
<html>
|
||||
<head>
|
||||
<title>Testing</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Testing</h1>
|
||||
</body>
|
||||
</html>
|
|
@ -1,5 +1,6 @@
|
|||
---
|
||||
import Redirect from '../components/redirect.astro';
|
||||
const staticMode = import.meta.env.STATIC_MODE;
|
||||
---
|
||||
<html>
|
||||
<head>
|
||||
|
@ -7,6 +8,8 @@ import Redirect from '../components/redirect.astro';
|
|||
</head>
|
||||
<body>
|
||||
<h1>Testing</h1>
|
||||
{ !staticMode ? (
|
||||
<Redirect />
|
||||
) : <div></div>}
|
||||
</body>
|
||||
</html>
|
||||
|
|
91
packages/astro/test/redirects.test.js
Normal file
91
packages/astro/test/redirects.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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.'
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
|
@ -1,2 +1,3 @@
|
|||
export { netlifyEdgeFunctions } from './integration-edge-functions.js';
|
||||
export { netlifyFunctions as default, netlifyFunctions } from './integration-functions.js';
|
||||
export { netlifyStatic } from './integration-static.js';
|
||||
|
|
18
packages/integrations/netlify/src/integration-static.ts
Normal file
18
packages/integrations/netlify/src/integration-static.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
|
@ -1,20 +1,27 @@
|
|||
import type { AstroConfig, RouteData } from 'astro';
|
||||
import type { AstroConfig, RouteData, ValidRedirectStatus } from 'astro';
|
||||
import fs from 'fs';
|
||||
|
||||
type RedirectDefinition = {
|
||||
export type RedirectDefinition = {
|
||||
dynamic: boolean;
|
||||
input: string;
|
||||
target: string;
|
||||
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(
|
||||
config: AstroConfig,
|
||||
routes: RouteData[],
|
||||
dir: URL,
|
||||
entryFile: string,
|
||||
type: 'functions' | 'edge-functions' | 'builders'
|
||||
type: 'functions' | 'edge-functions' | 'builders' | 'static'
|
||||
) {
|
||||
const _redirectsURL = new URL('./_redirects', dir);
|
||||
const kind = type ?? 'functions';
|
||||
|
@ -23,7 +30,21 @@ export async function createRedirects(
|
|||
|
||||
for (const route of routes) {
|
||||
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({
|
||||
dynamic: false,
|
||||
input: route.pathname,
|
||||
|
@ -51,31 +72,18 @@ export async function createRedirects(
|
|||
}
|
||||
}
|
||||
} else {
|
||||
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('/');
|
||||
const pattern = generateDynamicPattern(route);
|
||||
|
||||
if (route.distURL) {
|
||||
const targetRoute = route.redirectRoute ?? route;
|
||||
const targetPattern = generateDynamicPattern(targetRoute);
|
||||
const target =
|
||||
`${pattern}` + (config.build.format === 'directory' ? '/index.html' : '.html');
|
||||
`${targetPattern}` + (config.build.format === 'directory' ? '/index.html' : '.html');
|
||||
definitions.push({
|
||||
dynamic: true,
|
||||
input: pattern,
|
||||
target,
|
||||
status: 200,
|
||||
status: route.type === 'redirect' ? 301 : 200,
|
||||
weight: 1,
|
||||
});
|
||||
} else {
|
||||
|
@ -98,9 +106,29 @@ export async function createRedirects(
|
|||
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[]) {
|
||||
let minInputLength = 0,
|
||||
minTargetLength = 0;
|
||||
let minInputLength = 4,
|
||||
minTargetLength = 4;
|
||||
definitions.sort((a, b) => {
|
||||
// Find the longest input, so we can format things nicely
|
||||
if (a.input.length > minInputLength) {
|
||||
|
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,6 @@
|
|||
<html>
|
||||
<head><title>Testing</title></head>
|
||||
<body>
|
||||
<h1>Testing</h1>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,3 @@
|
|||
---
|
||||
return Astro.redirect('/');
|
||||
---
|
|
@ -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>
|
39
packages/integrations/netlify/test/static/redirects.test.js
Normal file
39
packages/integrations/netlify/test/static/redirects.test.js
Normal 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'
|
||||
]);
|
||||
});
|
||||
});
|
29
packages/integrations/netlify/test/static/test-utils.js
Normal file
29
packages/integrations/netlify/test/static/test-utils.js
Normal 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)
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
|
@ -1,4 +1,7 @@
|
|||
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
|
||||
interface VercelRoute {
|
||||
|
@ -36,6 +39,10 @@ function getMatchPattern(segments: RoutePart[][]) {
|
|||
.join('');
|
||||
}
|
||||
|
||||
function appendTrailingSlash(route: string): string {
|
||||
return route.at(-1) === '/' ? route : route + '/';
|
||||
}
|
||||
|
||||
function getReplacePattern(segments: RoutePart[][]) {
|
||||
let n = 0;
|
||||
let result = '';
|
||||
|
@ -54,23 +61,45 @@ 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' ? 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[] {
|
||||
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({
|
||||
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) },
|
||||
|
@ -78,6 +107,7 @@ export function getRedirects(routes: RouteData[], config: AstroConfig): VercelRo
|
|||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return redirects;
|
||||
}
|
||||
|
|
9
packages/integrations/vercel/test/fixtures/redirects/astro.config.mjs
vendored
Normal file
9
packages/integrations/vercel/test/fixtures/redirects/astro.config.mjs
vendored
Normal 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
|
||||
}
|
||||
});
|
9
packages/integrations/vercel/test/fixtures/redirects/package.json
vendored
Normal file
9
packages/integrations/vercel/test/fixtures/redirects/package.json
vendored
Normal file
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"name": "@test/astro-vercel-redirects",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@astrojs/vercel": "workspace:*",
|
||||
"astro": "workspace:*"
|
||||
}
|
||||
}
|
8
packages/integrations/vercel/test/fixtures/redirects/src/pages/index.astro
vendored
Normal file
8
packages/integrations/vercel/test/fixtures/redirects/src/pages/index.astro
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
<html>
|
||||
<head>
|
||||
<title>Testing</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Testing</h1>
|
||||
</body>
|
||||
</html>
|
25
packages/integrations/vercel/test/fixtures/redirects/src/pages/team/articles/[...slug].astro
vendored
Normal file
25
packages/integrations/vercel/test/fixtures/redirects/src/pages/team/articles/[...slug].astro
vendored
Normal 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>
|
56
packages/integrations/vercel/test/redirects.test.js
Normal file
56
packages/integrations/vercel/test/redirects.test.js
Normal 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);
|
||||
});
|
||||
});
|
|
@ -4868,6 +4868,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':
|
||||
|
|
Loading…
Reference in a new issue