* Redirects spike

* Allow redirects in static mode

* Support in Netlify as well

* Adding a changeset

* Rename file

* Fix build problem

* Refactor to be more modular

* Fix location ref

* Late test should only run in SSR

* Support redirects in Netlify SSR configuration (#7167)

* 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

* 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

* Add support for the object notation in redirects

* Use status 308 for non-GET redirects (#7186)

* Implement redirects in Cloudflare (#7198)

* Implement redirects in Cloudflare

* Fix build

* Update tests b/c of new ordering

* Debug issue

* Use posix.join

* Update packages/underscore-redirects/package.json

Co-authored-by: Emanuele Stoppa <my.burning@gmail.com>

* Update based on review comments

* Update broken test

---------

Co-authored-by: Emanuele Stoppa <my.burning@gmail.com>

* Test that redirects can come from middleware (#7213)

* Test that redirects can come from middleware

* Allow non-promise returns for middleware

* Implement priority (#7210)

* Refactor

* Fix netlify test ordering

* Fix ordering again

* Redirects: Allow preventing the output of the static HTML file (#7245)

* Do a simple push for priority

* Adding changesets

* Put the implementation behind a flag.

* Self review

* Update .changeset/chatty-actors-stare.md

Co-authored-by: Chris Swithinbank <swithinbank@gmail.com>

* Update packages/astro/src/@types/astro.ts

Co-authored-by: Chris Swithinbank <swithinbank@gmail.com>

* Update packages/astro/src/@types/astro.ts

Co-authored-by: Chris Swithinbank <swithinbank@gmail.com>

* Update packages/astro/src/@types/astro.ts

Co-authored-by: Chris Swithinbank <swithinbank@gmail.com>

* Update packages/astro/src/@types/astro.ts

Co-authored-by: Chris Swithinbank <swithinbank@gmail.com>

* Update docs on dynamic restrictions.

* Update packages/astro/src/@types/astro.ts

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* Update packages/astro/src/@types/astro.ts

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* Code review changes

* Document netlify static adapter

* Update packages/astro/src/@types/astro.ts

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* Slight reword

* Update .changeset/twenty-suns-vanish.md

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* Add a note about public/_redirects file

* Update packages/astro/src/@types/astro.ts

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

---------

Co-authored-by: Emanuele Stoppa <my.burning@gmail.com>
Co-authored-by: Chris Swithinbank <swithinbank@gmail.com>
Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
Co-authored-by: Nate Moore <natemoo-re@users.noreply.github.com>
This commit is contained in:
Matthew Phillips 2023-06-05 09:03:20 -04:00 committed by GitHub
parent dd1a6b6c94
commit 57f8d14c02
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
70 changed files with 1733 additions and 319 deletions

View file

@ -0,0 +1,33 @@
---
'astro': minor
---
Experimental redirects support
This change adds support for the redirects RFC, currently in stage 3: https://github.com/withastro/roadmap/pull/587
Now you can specify redirects in your Astro config:
```js
import { defineConfig } from 'astro/config';
export defineConfig({
redirects: {
'/blog/old-post': '/blog/new-post'
}
});
```
You can also specify spread routes using the same syntax as in file-based routing:
```js
import { defineConfig } from 'astro/config';
export defineConfig({
redirects: {
'/blog/[...slug]': '/articles/[...slug]'
}
});
```
By default Astro will build HTML files that contain the `<meta http-equiv="refresh">` tag. Adapters can also support redirect routes and create configuration for real HTTP-level redirects in production.

View file

@ -0,0 +1,7 @@
---
'@astrojs/cloudflare': minor
---
Support for experimental redirects
This adds support for the redirects RFC in the Cloudflare adapter. No changes are necessary, simply use configured redirects and the adapter will update your `_redirects` file.

View file

@ -0,0 +1,7 @@
---
'@astrojs/vercel': minor
---
Support for experimental redirects
This adds support for the redirects RFC in the Vercel adapter. No changes are necessary, simply use configured redirects and the adapter will output the vercel.json file with the configuration values.

View file

@ -0,0 +1,9 @@
---
'@astrojs/netlify': minor
---
Support for experimental redirects
This adds support for the redirects RFC in the Netlify adapter, including a new `@astrojs/netlify/static` adapter for static sites.
No changes are necessary when using SSR. Simply use configured redirects and the adapter will update your `_redirects` file.

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

@ -109,6 +109,7 @@ export interface CLIFlags {
open?: boolean;
experimentalAssets?: boolean;
experimentalMiddleware?: boolean;
experimentalRedirects?: boolean;
}
export interface BuildConfig {
@ -452,6 +453,53 @@ export interface AstroUserConfig {
*/
cacheDir?: string;
/**
* @docs
* @name redirects (Experimental)
* @type {RedirectConfig}
* @default `{}`
* @version 2.6.0
* @description Specify a mapping of redirects where the key is the route to match
* and the value is the path to redirect to.
*
* You can redirect both static and dynamic routes, but only to the same kind of route.
* For example you cannot have a `'/article': '/blog/[...slug]'` redirect.
*
*
* ```js
* {
* redirects: {
* '/old': '/new',
* '/blog/[...slug]': '/articles/[...slug]',
* }
* }
* ```
*
*
* For statically-generated sites with no adapter installed, this will produce a client redirect using a [`<meta http-equiv="refresh">` tag](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta#http-equiv) and does not support status codes.
*
* When using SSR or with a static adapter in `output: static`
* mode, status codes are supported.
* Astro will serve redirected GET requests with a status of `301`
* and use a status of `308` for any other request method.
*
* You can customize the [redirection status code](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status#redirection_messages) using an object in the redirect config:
*
* ```js
* {
* redirects: {
* '/other': {
* status: 302,
* destination: '/place',
* },
* }
* }
* ```
*/
redirects?: RedirectConfig;
/**
* @docs
* @name site
@ -733,6 +781,29 @@ export interface AstroUserConfig {
* ```
*/
serverEntry?: string;
/**
* @docs
* @name build.redirects
* @type {boolean}
* @default `true`
* @version 2.6.0
* @description
* Specifies whether redirects will be output to HTML during the build.
* This option only applies to `output: 'static'` mode; in SSR redirects
* are treated the same as all responses.
*
* This option is mostly meant to be used by adapters that have special
* configuration files for redirects and do not need/want HTML based redirects.
*
* ```js
* {
* build: {
* redirects: false
* }
* }
* ```
*/
redirects?: boolean;
};
/**
@ -1179,6 +1250,27 @@ export interface AstroUserConfig {
* ```
*/
hybridOutput?: boolean;
/**
* @docs
* @name experimental.redirects
* @type {boolean}
* @default `false`
* @version 2.6.0
* @description
* Enable experimental support for redirect configuration. With this enabled
* you can set redirects via the top-level `redirects` property. To enable
* this feature, set `experimental.redirects` to `true`.
*
* ```js
* {
* experimental: {
* redirects: true,
* },
* }
* ```
*/
redirects?: boolean;
};
// Legacy options to be removed
@ -1578,6 +1670,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>> {
/**
@ -1607,7 +1701,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
@ -1805,7 +1899,7 @@ export type MiddlewareNext<R> = () => Promise<R>;
export type MiddlewareHandler<R> = (
context: APIContext,
next: MiddlewareNext<R>
) => Promise<R> | Promise<void> | void;
) => Promise<R> | R | Promise<void> | void;
export type MiddlewareResponseHandler = MiddlewareHandler<Response>;
export type MiddlewareEndpointHandler = MiddlewareHandler<Response | EndpointOutput>;
@ -1822,7 +1916,7 @@ export interface AstroPluginOptions {
logging: LogOptions;
}
export type RouteType = 'page' | 'endpoint';
export type RouteType = 'page' | 'endpoint' | 'redirect';
export interface RoutePart {
content: string;
@ -1830,6 +1924,11 @@ export interface RoutePart {
spread: boolean;
}
type RedirectConfig = string | {
status: ValidRedirectStatus;
destination: string;
}
export interface RouteData {
route: string;
component: string;
@ -1842,6 +1941,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'> & {

View file

@ -28,6 +28,7 @@ import {
createStylesheetElementSet,
} from '../render/ssr-element.js';
import { matchRoute } from '../routing/match.js';
import { RedirectComponentInstance } from '../redirects/index.js';
export { deserializeManifest } from './common.js';
const clientLocalsSymbol = Symbol.for('astro.locals');
@ -137,22 +138,20 @@ export class App {
defaultStatus = 404;
}
let page = await this.#manifest.pageMap.get(routeData.component)!();
let mod = await page.page();
let mod = await this.#getModuleForRoute(routeData);
if (routeData.type === 'page') {
if (routeData.type === 'page' || routeData.type === 'redirect') {
let response = await this.#renderPage(request, routeData, mod, defaultStatus);
// If there was a known error code, try sending the according page (e.g. 404.astro / 500.astro).
if (response.status === 500 || response.status === 404) {
const errorPageData = matchRoute('/' + response.status, this.#manifestData);
if (errorPageData && errorPageData.route !== routeData.route) {
page = await this.#manifest.pageMap.get(errorPageData.component)!();
mod = await page.page();
const errorRouteData = matchRoute('/' + response.status, this.#manifestData);
if (errorRouteData && errorRouteData.route !== routeData.route) {
mod = await this.#getModuleForRoute(errorRouteData);
try {
let errorResponse = await this.#renderPage(
request,
errorPageData,
errorRouteData,
mod,
response.status
);
@ -172,6 +171,19 @@ export class App {
return getSetCookiesFromResponse(response);
}
async #getModuleForRoute(route: RouteData): Promise<ComponentInstance> {
if(route.type === 'redirect') {
return RedirectComponentInstance;
} else {
const importComponentInstance = this.#manifest.pageMap.get(route.component);
if(!importComponentInstance) {
throw new Error(`Unexpectedly unable to find a component instance for route ${route.route}`);
}
const built = await importComponentInstance();
return built.page();
}
}
async #renderPage(
request: Request,
routeData: RouteData,

View file

@ -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)) {

View file

@ -12,6 +12,8 @@ import type {
EndpointOutput,
ImageTransform,
MiddlewareResponseHandler,
RedirectRouteData,
RouteData,
RouteType,
SSRError,
SSRLoadedRenderer,
@ -24,6 +26,7 @@ import {
eachPageDataFromEntryPoint,
hasPrerenderedPages,
type BuildInternals,
eachRedirectPageData,
} from '../../core/build/internal.js';
import {
prependForwardSlash,
@ -39,7 +42,8 @@ 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 {
import { getRedirectLocationOrThrow, RedirectComponentInstance, routeIsRedirect } from '../redirects/index.js';
import {
createAssetLink,
createModuleScriptsSet,
createStylesheetElementSet,
@ -48,7 +52,7 @@ import { createRequest } from '../request.js';
import { matchRoute } from '../routing/match.js';
import { getOutputFilename } from '../util.js';
import { getOutDirWithinCwd, getOutFile, getOutFolder } from './common.js';
import { cssOrder, getPageDataByComponent, mergeInlineCss } from './internal.js';
import { cssOrder, getPageDataByComponent, mergeInlineCss, getEntryFilePathFromComponentPath } from './internal.js';
import type {
PageBuildData,
SinglePageBuiltModule,
@ -57,6 +61,38 @@ import type {
} from './types';
import { getTimeStat } from './util.js';
const StaticMiddlewareInstance: AstroMiddlewareInstance<unknown> = {
onRequest: (ctx, next) => next()
};
function createEntryURL(filePath: string, outFolder: URL) {
return new URL('./' + filePath + `?time=${Date.now()}`, outFolder);
}
async function getEntryForRedirectRoute(
route: RouteData,
internals: BuildInternals,
outFolder: URL
): Promise<SinglePageBuiltModule> {
if(route.type !== 'redirect') {
throw new Error(`Expected a redirect route.`);
}
if(route.redirectRoute) {
const filePath = getEntryFilePathFromComponentPath(internals, route.redirectRoute.component);
if(filePath) {
const url = createEntryURL(filePath, outFolder);
const ssrEntryPage: SinglePageBuiltModule = await import(url.toString());
return ssrEntryPage;
}
}
return {
page: () => Promise.resolve(RedirectComponentInstance),
middleware: StaticMiddlewareInstance,
renderers: []
}
}
function shouldSkipDraft(pageModule: ComponentInstance, settings: AstroSettings): boolean {
return (
// Drafts are disabled
@ -95,7 +131,6 @@ export function chunkIsPage(
export async function generatePages(opts: StaticBuildOptions, internals: BuildInternals) {
const timer = performance.now();
const ssr = opts.settings.config.output === 'server' || isHybridOutput(opts.settings.config); // hybrid mode is essentially SSR with prerender by default
const serverEntry = opts.buildConfig.serverEntry;
const outFolder = ssr ? opts.buildConfig.server : getOutDirWithinCwd(opts.settings.config.outDir);
if (ssr && !hasPrerenderedPages(internals)) return;
@ -108,19 +143,27 @@ export async function generatePages(opts: StaticBuildOptions, internals: BuildIn
if (ssr) {
for (const [pageData, filePath] of eachPageDataFromEntryPoint(internals)) {
if (pageData.route.prerender) {
const ssrEntryURLPage = new URL('./' + filePath + `?time=${Date.now()}`, outFolder);
const ssrEntryPage = await import(ssrEntryURLPage.toString());
const ssrEntryURLPage =createEntryURL(filePath, outFolder);
const ssrEntryPage: SinglePageBuiltModule = await import(ssrEntryURLPage.toString());
await generatePage(opts, internals, pageData, ssrEntryPage, builtPaths);
}
}
for(const pageData of eachRedirectPageData(internals)) {
const entry = await getEntryForRedirectRoute(pageData.route, internals, outFolder);
await generatePage(opts, internals, pageData, entry, builtPaths);
}
} else {
for (const [pageData, filePath] of eachPageDataFromEntryPoint(internals)) {
const ssrEntryURLPage = new URL('./' + filePath + `?time=${Date.now()}`, outFolder);
const ssrEntryPage = await import(ssrEntryURLPage.toString());
const ssrEntryURLPage = createEntryURL(filePath, outFolder);
const ssrEntryPage: SinglePageBuiltModule = await import(ssrEntryURLPage.toString());
await generatePage(opts, internals, pageData, ssrEntryPage, builtPaths);
}
for(const pageData of eachRedirectPageData(internals)) {
const entry = await getEntryForRedirectRoute(pageData.route, internals, outFolder);
await generatePage(opts, internals, pageData, entry, builtPaths);
}
}
if (opts.settings.config.experimental.assets) {
@ -165,6 +208,10 @@ async function generatePage(
ssrEntry: SinglePageBuiltModule,
builtPaths: Set<string>
) {
if(routeIsRedirect(pageData.route) &&!opts.settings.config.experimental.redirects) {
throw new Error(`To use redirects first set experimental.redirects to \`true\``);
}
let timeStart = performance.now();
const renderers = ssrEntry.renderers;
@ -530,10 +577,25 @@ async function generatePath(
}
throw err;
}
throwIfRedirectNotAllowed(response, opts.settings.config);
// If there's no body, do nothing
if (!response.body) return;
body = await response.text();
if(response.status >= 300 && response.status < 400) {
// If redirects is set to false, don't output the HTML
if(!opts.settings.config.build.redirects) {
return;
}
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;
}
} else {
// If there's no body, do nothing
if (!response.body) return;
body = await response.text();
}
}
const outFolder = getOutFolder(settings.config, pathname, pageData.route.type);

View file

@ -3,7 +3,7 @@ import type { SSRResult } from '../../@types/astro';
import type { PageOptions } from '../../vite-plugin-astro/types';
import { prependForwardSlash, removeFileExtension } from '../path.js';
import { viteID } from '../util.js';
import { ASTRO_PAGE_EXTENSION_POST_PATTERN, ASTRO_PAGE_MODULE_ID } from './plugins/plugin-pages.js';
import { ASTRO_PAGE_EXTENSION_POST_PATTERN, ASTRO_PAGE_MODULE_ID, getVirtualModulePageIdFromPath } from './plugins/plugin-pages.js';
import type { PageBuildData, StylesheetAsset, ViteID } from './types';
export interface BuildInternals {
@ -217,6 +217,14 @@ export function* eachPageData(internals: BuildInternals) {
yield* internals.pagesByComponent.values();
}
export function* eachRedirectPageData(internals: BuildInternals) {
for(const pageData of eachPageData(internals)) {
if(pageData.route.type === 'redirect') {
yield pageData;
}
}
}
export function* eachPageDataFromEntryPoint(
internals: BuildInternals
): Generator<[PageBuildData, string]> {
@ -316,3 +324,9 @@ export function* getPageDatasByHoistedScriptId(
}
}
}
// From a component path such as pages/index.astro find the entrypoint module
export function getEntryFilePathFromComponentPath(internals: BuildInternals, path: string) {
const id = getVirtualModulePageIdFromPath(path);
return internals.entrySpecifierToBundleMap.get(id);
}

View file

@ -5,6 +5,7 @@ import { 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';
import { RENDERERS_MODULE_ID } from './plugin-renderers.js';
export const ASTRO_PAGE_MODULE_ID = '@astro-page:';
@ -29,6 +30,11 @@ export function getVirtualModulePageNameFromPath(path: string) {
)}`;
}
export function getVirtualModulePageIdFromPath(path: string) {
const name = getVirtualModulePageNameFromPath(path);
return '\x00' + name;
}
function vitePluginPages(opts: StaticBuildOptions, internals: BuildInternals): VitePlugin {
return {
name: '@astro/plugin-build-pages',
@ -37,7 +43,10 @@ function vitePluginPages(opts: StaticBuildOptions, internals: BuildInternals): V
if (opts.settings.config.output === 'static') {
const inputs: Set<string> = new Set();
for (const path of Object.keys(opts.allPages)) {
for (const [path, pageData] of Object.entries(opts.allPages)) {
if(routeIsRedirect(pageData.route)) {
continue;
}
inputs.add(getVirtualModulePageNameFromPath(path));
}
@ -55,6 +64,7 @@ function vitePluginPages(opts: StaticBuildOptions, internals: BuildInternals): V
if (id.startsWith(ASTRO_PAGE_RESOLVED_MODULE_ID)) {
const imports: string[] = [];
const exports: string[] = [];
// we remove the module name prefix from id, this will result into a string that will start with "src/..."
const pageName = id.slice(ASTRO_PAGE_RESOLVED_MODULE_ID.length);
// We replaced the `.` of the extension with ASTRO_PAGE_EXTENSION_POST_PATTERN, let's replace it back

View file

@ -14,6 +14,7 @@ import { cssOrder, mergeInlineCss, 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';
import { getVirtualModulePageNameFromPath } from './plugin-pages.js';
import { RENDERERS_MODULE_ID } from './plugin-renderers.js';
@ -55,7 +56,10 @@ function vitePluginSSR(
let i = 0;
const pageMap: string[] = [];
for (const path of Object.keys(allPages)) {
for (const [path, pageData] of Object.entries(allPages)) {
if(routeIsRedirect(pageData.route)) {
continue;
}
const virtualModuleName = getVirtualModulePageNameFromPath(path);
let module = await this.resolve(virtualModuleName);
if (module) {
@ -63,9 +67,9 @@ function vitePluginSSR(
// we need to use the non-resolved ID in order to resolve correctly the virtual module
imports.push(`const ${variable} = () => import("${virtualModuleName}");`);
const pageData = internals.pagesByComponent.get(path);
if (pageData) {
pageMap.push(`[${JSON.stringify(pageData.component)}, ${variable}]`);
const pageData2 = internals.pagesByComponent.get(path);
if (pageData2) {
pageMap.push(`[${JSON.stringify(pageData2.component)}, ${variable}]`);
}
i++;
}

View file

@ -106,6 +106,8 @@ export function resolveFlags(flags: Partial<Flags>): CLIFlags {
typeof flags.experimentalAssets === 'boolean' ? flags.experimentalAssets : undefined,
experimentalMiddleware:
typeof flags.experimentalMiddleware === 'boolean' ? flags.experimentalMiddleware : undefined,
experimentalRedirects:
typeof flags.experimentalRedirects === 'boolean' ? flags.experimentalRedirects : undefined
};
}

View file

@ -22,6 +22,7 @@ const ASTRO_CONFIG_DEFAULTS: AstroUserConfig & any = {
server: './dist/server/',
assets: '_astro',
serverEntry: 'entry.mjs',
redirects: true,
},
compressHTML: false,
server: {
@ -37,12 +38,14 @@ const ASTRO_CONFIG_DEFAULTS: AstroUserConfig & any = {
},
vite: {},
legacy: {},
redirects: {},
experimental: {
assets: false,
hybridOutput: false,
customClientDirectives: false,
inlineStylesheets: 'never',
middleware: false,
redirects: false,
},
};
@ -115,6 +118,7 @@ export const AstroConfigSchema = z.object({
assets: z.string().optional().default(ASTRO_CONFIG_DEFAULTS.build.assets),
assetsPrefix: z.string().optional(),
serverEntry: z.string().optional().default(ASTRO_CONFIG_DEFAULTS.build.serverEntry),
redirects: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.build.redirects),
})
.optional()
.default({}),
@ -137,6 +141,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({
@ -209,6 +214,7 @@ export const AstroConfigSchema = z.object({
.default(ASTRO_CONFIG_DEFAULTS.experimental.inlineStylesheets),
middleware: z.oboolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.middleware),
hybridOutput: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.hybridOutput),
redirects: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.redirects),
})
.passthrough()
.refine(
@ -277,6 +283,7 @@ export function createRelativeSchema(cmd: string, fileProtocolRoot: URL) {
assets: z.string().optional().default(ASTRO_CONFIG_DEFAULTS.build.assets),
assetsPrefix: z.string().optional(),
serverEntry: z.string().optional().default(ASTRO_CONFIG_DEFAULTS.build.serverEntry),
redirects: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.build.redirects),
})
.optional()
.default({}),

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.
*
* 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.',
@ -693,7 +694,7 @@ See https://docs.astro.build/en/guides/server-side-rendering/ for more informati
'`locals` can only be assigned to an object. Other values like numbers, strings, etc. are not accepted.',
hint: 'If you tried to remove some information from the `locals` object, try to use `delete` or set the property to `undefined`.',
},
/**
/*
* @docs
* @see
* - [Assets (Experimental)](https://docs.astro.build/en/guides/assets/)
@ -748,7 +749,17 @@ See https://docs.astro.build/en/guides/server-side-rendering/ for more informati
message: (globStr: string) =>
`\`Astro.glob(${globStr})\` did not return any matching files. Check the pattern for typos.`,
},
/**
* @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: {
title: 'A redirect must be given a location with the `Location` header.',
code: 3037,
},
// No headings here, that way Vite errors are merged with Astro ones in the docs, which makes more sense to users.
// Vite Errors - 4xxx
/**

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

@ -0,0 +1,10 @@
import type { ComponentInstance } from '../../@types/astro';
// A stub of a component instance for a given route
export const RedirectComponentInstance: ComponentInstance = {
default() {
return new Response(null, {
status: 301
});
}
};

View file

@ -0,0 +1,29 @@
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, method = 'GET'): ValidRedirectStatus {
const routeData = redirectRoute.redirectRoute;
if(typeof routeData?.redirect === 'object') {
return routeData.redirect.status;
} else if(method !== 'GET') {
return 308;
}
return 301;
}

View file

@ -0,0 +1,3 @@
export { getRedirectLocationOrThrow } from './validate.js';
export { routeIsRedirect, redirectRouteGenerate, redirectRouteStatus } from './helpers.js';
export { RedirectComponentInstance } from './component.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 { createResult } from './result.js';
import { callGetStaticPaths, findPathItemByKey, RouteCache } from './route-cache.js';
import { routeIsRedirect, redirectRouteGenerate, redirectRouteStatus } from '../redirects/index.js';
interface GetParamsAndPropsOptions {
mod: ComponentInstance;
@ -112,12 +113,21 @@ export type RenderPage = {
};
export async function renderPage({
mod,
renderContext,
env,
apiContext,
isCompressHTML = false,
mod,
renderContext,
env,
apiContext,
isCompressHTML = false,
}: RenderPage) {
if(routeIsRedirect(renderContext.route)) {
return new Response(null, {
status: redirectRouteStatus(renderContext.route, renderContext.request.method),
headers: {
location: redirectRouteGenerate(renderContext.route, renderContext.params)
}
});
}
// Validate the page component before rendering the page
const Component = mod.default;
if (!Component)

View file

@ -209,23 +209,21 @@ export function createResult(args: CreateResultArgs): SSRResult {
locals,
request,
url,
redirect: args.ssr
? (path, status) => {
// If the response is already sent, error as we cannot proceed with the redirect.
if ((request as any)[responseSentSymbol]) {
throw new AstroError({
...AstroErrorData.ResponseSentError,
});
}
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({
...AstroErrorData.ResponseSentError,
});
}
return new Response(null, {
status: status || 302,
headers: {
Location: path,
},
});
}
: onlyAvailableInSSR('Astro.redirect'),
return new Response(null, {
status: status || 302,
headers: {
Location: path,
},
});
},
response: response as AstroGlobal['response'],
slots: astroSlots as unknown as AstroGlobal['slots'],
};

View file

@ -62,6 +62,10 @@ function getParts(part: string, file: string) {
return result;
}
function areSamePart(a: RoutePart, b: RoutePart) {
return a.content === b.content && a.dynamic === b.dynamic && a.spread === b.spread;
}
function getPattern(
segments: RoutePart[][],
base: string,
@ -205,6 +209,25 @@ function injectedRouteToItem(
};
}
// Seeings if the two routes are siblings of each other, with `b` being the route
// in focus. If it is in the same parent folder as `a`, they are siblings.
function areSiblings(a: RouteData, b: RouteData) {
if(a.segments.length < b.segments.length) return false;
for(let i = 0; i < b.segments.length - 1; i++) {
let segment = b.segments[i];
if(segment.length === a.segments[i].length) {
for(let j = 0; j < segment.length; j++) {
if(!areSamePart(segment[j], a.segments[i][j])) {
return false;
}
}
} else {
return false;
}
}
return true;
}
export interface CreateRouteManifestParams {
/** Astro Settings object */
settings: AstroSettings;
@ -421,7 +444,50 @@ 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();
const routeData: RouteData = {
type: 'redirect',
route,
pattern,
segments,
params,
component: from,
generate,
pathname: pathname || void 0,
prerender: false,
redirect: to,
redirectRoute: routes.find(r => r.route === to)
};
// Push so that redirects are selected last.
routes.push(routeData);
});
return {
routes,
};
}

View file

@ -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());

View file

@ -129,6 +129,13 @@ export async function handleRoute(
return handle404Response(origin, req, res);
}
if(matchedRoute.route.type === 'redirect' && !settings.config.experimental.redirects) {
writeWebResponse(res, new Response(`To enable redirect set experimental.redirects to \`true\`.`, {
status: 400
}));
return;
}
const { config } = settings;
const filePath: URL | undefined = matchedRoute.filePath;
const { route, preloadedComponent, mod } = matchedRoute;

View file

@ -0,0 +1,13 @@
import { defineMiddleware } from 'astro/middleware';
export const onRequest = defineMiddleware(({ request }, next) => {
if(new URL(request.url).pathname === '/middleware-redirect/') {
return new Response(null, {
status: 301,
headers: {
'Location': '/'
}
});
}
return next();
});

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

View file

@ -0,0 +1,10 @@
---
---
<html>
<head>
<title>This page should have been redirected</title>
</head>
<body>
<h1>This page should have been redirected</h1>
</body>
</html>

View file

@ -0,0 +1,154 @@
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(),
redirects: {
'/api/redirect': '/'
},
experimental: {
redirects: true,
},
});
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('Redirects config', () => {
it('Returns the redirect', async () => {
const app = await fixture.loadTestAdapterApp();
const request = new Request('http://example.com/api/redirect');
const response = await app.render(request);
expect(response.status).to.equal(301);
expect(response.headers.get('Location')).to.equal('/');
});
it('Uses 308 for non-GET methods', async () => {
const app = await fixture.loadTestAdapterApp();
const request = new Request('http://example.com/api/redirect', {
method: 'POST'
});
const response = await app.render(request);
expect(response.status).to.equal(308);
});
});
});
describe('output: "static"', () => {
before(async () => {
process.env.STATIC_MODE = true;
fixture = await loadFixture({
root: './fixtures/ssr-redirect/',
output: 'static',
experimental: {
middleware: true,
redirects: true,
},
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');
});
it('Generates redirect pages for redirects created by middleware', async () => {
let html = await fixture.readFile('/middleware-redirect/index.html');
expect(html).to.include('http-equiv="refresh');
expect(html).to.include('url=/');
});
});
describe('config.build.redirects = false', () => {
before(async () => {
process.env.STATIC_MODE = true;
fixture = await loadFixture({
root: './fixtures/ssr-redirect/',
output: 'static',
redirects: {
'/one': '/'
},
build: {
redirects: false
},
experimental: {
redirects: true,
},
});
await fixture.build();
});
it('Does not output redirect HTML', async () => {
let oneHtml = undefined;
try {
oneHtml = await fixture.readFile('/one/index.html');
} catch {}
expect(oneHtml).be.an('undefined');
})
})
});

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

@ -31,4 +31,34 @@ describe('routing - createRouteManifest', () => {
expect(pattern.test('')).to.equal(true);
expect(pattern.test('/')).to.equal(false);
});
it('redirects are sorted alongside the filesystem routes', async () => {
const fs = createFs(
{
'/src/pages/index.astro': `<h1>test</h1>`,
'/src/pages/blog/contributing.astro': `<h1>test</h1>`,
},
root
);
const settings = await createDefaultDevSettings(
{
base: '/search',
trailingSlash: 'never',
redirects: {
'/blog/[...slug]': '/',
'/blog/contributing': '/another',
}
},
root
);
const manifest = createRouteManifest({
cwd: fileURLToPath(root),
settings,
fsMod: fs,
});
expect(manifest.routes[1].route).to.equal('/blog/contributing');
expect(manifest.routes[1].type).to.equal('page');
expect(manifest.routes[2].route).to.equal('/blog/[...slug]');
})
});

View file

@ -38,6 +38,7 @@
"test": "mocha --exit --timeout 30000 test/"
},
"dependencies": {
"@astrojs/underscore-redirects": "^0.1.0",
"esbuild": "^0.17.12",
"tiny-glob": "^0.2.9"
},

View file

@ -1,4 +1,5 @@
import type { AstroAdapter, AstroConfig, AstroIntegration } from 'astro';
import { createRedirectsFromAstroRoutes, type Redirects } from '@astrojs/underscore-redirects';
import esbuild from 'esbuild';
import * as fs from 'fs';
import * as os from 'os';
@ -50,6 +51,7 @@ export default function createIntegration(args?: Options): AstroIntegration {
client: new URL(`.${config.base}`, config.outDir),
server: new URL(`.${SERVER_BUILD_FOLDER}`, config.outDir),
serverEntry: '_worker.mjs',
redirects: false,
},
});
},
@ -88,7 +90,7 @@ export default function createIntegration(args?: Options): AstroIntegration {
vite.ssr.target = 'webworker';
}
},
'astro:build:done': async ({ pages }) => {
'astro:build:done': async ({ pages, routes, dir }) => {
const entryPath = fileURLToPath(new URL(_buildConfig.serverEntry, _buildConfig.server));
const entryUrl = new URL(_buildConfig.serverEntry, _config.outDir);
const buildPath = fileURLToPath(entryUrl);
@ -197,6 +199,19 @@ export default function createIntegration(args?: Options): AstroIntegration {
}
}
const redirectRoutes = routes.filter(r => r.type === 'redirect');
const trueRedirects = createRedirectsFromAstroRoutes({
config: _config,
routes: redirectRoutes,
dir,
});
if(!trueRedirects.empty()) {
await fs.promises.appendFile(
new URL('./_redirects', _config.outDir),
trueRedirects.print()
);
}
await fs.promises.writeFile(
new URL('./_routes.json', _config.outDir),
JSON.stringify(

View file

@ -11,6 +11,12 @@ describe('mode: "directory"', () => {
root: './fixtures/basics/',
output: 'server',
adapter: cloudflare({ mode: 'directory' }),
redirects: {
'/old': '/'
},
experimental: {
redirects: true,
},
});
await fixture.build();
});
@ -19,4 +25,16 @@ describe('mode: "directory"', () => {
expect(await fixture.pathExists('../functions')).to.be.true;
expect(await fixture.pathExists('../functions/[[path]].js')).to.be.true;
});
it('generates a redirects file', async () => {
try {
let _redirects = await fixture.readFile('/_redirects');
let parts = _redirects.split(/\s+/);
expect(parts).to.deep.equal([
'/old', '/', '301'
]);
} catch {
expect(false).to.equal(true);
}
});
});

View file

@ -74,6 +74,30 @@ export default defineConfig({
});
```
### Static sites
For static sites you usually don't need an adapter. However, if you use `redirects` configuration (experimental) in your Astro config, the Netlify adapter can be used to translate this to the proper `_redirects` format.
```js
import { defineConfig } from 'astro/config';
import netlify from '@astrojs/netlify/static';
export default defineConfig({
adapter: netlify(),
redirects: {
'/blog/old-post': '/blog/new-post'
},
experimental: {
redirects: true
}
});
```
Once you run `astro build` there will be a `dist/_redirects` file. Netlify will use that to properly route pages in production.
> __Note__, you can still include a `public/_redirects` file for manual redirects. Any redirects you specify in the redirects config are appended to the end of your own.
## Usage
[Read the full deployment guide here.](https://docs.astro.build/en/guides/deploy/netlify/)

View file

@ -37,6 +37,7 @@
"test": "npm run test-fn"
},
"dependencies": {
"@astrojs/underscore-redirects": "^0.1.0",
"@astrojs/webapi": "^2.2.0",
"@netlify/functions": "^1.0.0",
"esbuild": "^0.15.18"

View file

@ -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';

View file

@ -0,0 +1,26 @@
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:setup': ({ updateConfig }) => {
updateConfig({
build: {
// Do not output HTML redirects because we are building a `_redirects` file.
redirects: false,
},
});
},
'astro:config:done': ({ config }) => {
_config = config;
},
'astro:build:done': async ({ dir, routes }) => {
await createRedirects(_config, routes, dir, '', 'static');
}
}
};
}

View file

@ -1,145 +1,25 @@
import type { AstroConfig, RouteData } from 'astro';
import fs from 'fs';
type RedirectDefinition = {
dynamic: boolean;
input: string;
target: string;
weight: 0 | 1;
status: 200 | 404;
};
import { createRedirectsFromAstroRoutes } from '@astrojs/underscore-redirects';
import fs from 'node:fs';
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';
const dynamicTarget = `/.netlify/${kind}/${entryFile}`;
const _redirectsURL = new URL('./_redirects', dir);
const definitions: RedirectDefinition[] = [];
for (const route of routes) {
if (route.pathname) {
if (route.distURL) {
definitions.push({
dynamic: false,
input: route.pathname,
target: prependForwardSlash(route.distURL.toString().replace(dir.toString(), '')),
status: 200,
weight: 1,
});
} else {
definitions.push({
dynamic: false,
input: route.pathname,
target: `/.netlify/${kind}/${entryFile}`,
status: 200,
weight: 1,
});
if (route.route === '/404') {
definitions.push({
dynamic: true,
input: '/*',
target: `/.netlify/${kind}/${entryFile}`,
status: 404,
weight: 0,
});
}
}
} 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('/');
if (route.distURL) {
const target =
`${pattern}` + (config.build.format === 'directory' ? '/index.html' : '.html');
definitions.push({
dynamic: true,
input: pattern,
target,
status: 200,
weight: 1,
});
} else {
definitions.push({
dynamic: true,
input: pattern,
target: `/.netlify/${kind}/${entryFile}`,
status: 200,
weight: 1,
});
}
}
}
let _redirects = prettify(definitions);
const _redirects = createRedirectsFromAstroRoutes({
config, routes, dir, dynamicTarget
});
const content = _redirects.print();
// Always use appendFile() because the redirects file could already exist,
// e.g. due to a `/public/_redirects` file that got copied to the output dir.
// If the file does not exist yet, appendFile() automatically creates it.
await fs.promises.appendFile(_redirectsURL, _redirects, 'utf-8');
}
function prettify(definitions: RedirectDefinition[]) {
let minInputLength = 0,
minTargetLength = 0;
definitions.sort((a, b) => {
// Find the longest input, so we can format things nicely
if (a.input.length > minInputLength) {
minInputLength = a.input.length;
}
if (b.input.length > minInputLength) {
minInputLength = b.input.length;
}
// Same for the target
if (a.target.length > minTargetLength) {
minTargetLength = a.target.length;
}
if (b.target.length > minTargetLength) {
minTargetLength = b.target.length;
}
// Sort dynamic routes on top
return b.weight - a.weight;
});
let _redirects = '';
// Loop over the definitions
definitions.forEach((defn, i) => {
// Figure out the number of spaces to add. We want at least 4 spaces
// after the input. This ensure that all targets line up together.
let inputSpaces = minInputLength - defn.input.length + 4;
let targetSpaces = minTargetLength - defn.target.length + 4;
_redirects +=
(i === 0 ? '' : '\n') +
defn.input +
' '.repeat(inputSpaces) +
defn.target +
' '.repeat(Math.abs(targetSpaces)) +
defn.status;
});
return _redirects;
}
function prependForwardSlash(str: string) {
return str[0] === '/' ? str : '/' + str;
await fs.promises.appendFile(_redirectsURL, content, 'utf-8');
}

View file

@ -0,0 +1,44 @@
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': '/'
},
experimental: {
redirects: true,
},
});
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',
// 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',
'/', '/.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,43 @@
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(),
experimental: {
redirects: true,
},
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([
'/two', '/', '302',
'/other', '/', '301',
'/nope', '/', '301',
'/blog/*', '/team/articles/*/index.html', '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

@ -50,6 +50,7 @@
"test": "mocha --exit --timeout 20000 test/"
},
"dependencies": {
"@astrojs/internal-helpers": "^0.1.0",
"@astrojs/webapi": "^2.2.0",
"@vercel/analytics": "^0.1.8",
"@vercel/nft": "^0.22.1",

View file

@ -1,4 +1,9 @@
import type { AstroConfig, RouteData, RoutePart } from 'astro';
import { appendForwardSlash } from '@astrojs/internal-helpers/path';
import nodePath from 'node:path';
const pathJoin = nodePath.posix.join;
// https://vercel.com/docs/project-configuration#legacy/routes
interface VercelRoute {
@ -54,28 +59,51 @@ 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 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: 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: 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') {
redirects.push({
src: config.base + getMatchPattern(route.segments) + '/',
headers: { Location: config.base + getReplacePattern(route.segments) },
status: 308,
});
}
}
}

View file

@ -43,6 +43,7 @@ export default function vercelStatic({
outDir,
build: {
format: 'directory',
redirects: false,
},
vite: {
define: viteDefine,

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,59 @@
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]',
},
experimental: {
redirects: true,
},
});
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

@ -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,86 @@
/**
* A set of common path utilities commonly used through the Astro core and integration
* projects. These do things like ensure a forward slash prepends paths.
*/
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|ws):?\/\//.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

@ -0,0 +1,42 @@
{
"name": "@astrojs/underscore-redirects",
"description": "Utilities to generate _redirects files in Astro projects",
"version": "0.1.0",
"type": "module",
"author": "withastro",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/withastro/astro.git",
"directory": "packages/underscore-redirects"
},
"bugs": "https://github.com/withastro/astro/issues",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": "./dist/index.js"
},
"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\"",
"test": "mocha --exit --timeout 20000"
},
"devDependencies": {
"astro": "workspace:*",
"astro-scripts": "workspace:*",
"@types/chai": "^4.3.1",
"@types/mocha": "^9.1.1",
"chai": "^4.3.6",
"mocha": "^9.2.2"
},
"keywords": [
"astro",
"astro-component"
]
}

View file

@ -0,0 +1,3 @@
# @astrojs/underscore-redirects
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,145 @@
import type { AstroConfig, RouteData, ValidRedirectStatus } from 'astro';
import { Redirects } from './redirects.js';
import { posix } from 'node:path';
const pathJoin = posix.join;
function getRedirectStatus(route: RouteData): ValidRedirectStatus {
if(typeof route.redirect === 'object') {
return route.redirect.status;
}
return 301;
}
interface CreateRedirectsFromAstroRoutesParams {
config: Pick<AstroConfig, 'output' | 'build'>;
routes: RouteData[];
dir: URL;
dynamicTarget?: string;
}
/**
* Takes a set of routes and creates a Redirects object from them.
*/
export function createRedirectsFromAstroRoutes({
config,
routes,
dir,
dynamicTarget = '',
}: CreateRedirectsFromAstroRoutesParams) {
const output = config.output;
const _redirects = new Redirects();
for (const route of routes) {
// A route with a `pathname` is as static route.
if (route.pathname) {
if(route.redirect) {
// A redirect route without dynamic parts. Get the redirect status
// from the user if provided.
_redirects.add({
dynamic: false,
input: route.pathname,
target: typeof route.redirect === 'object' ? route.redirect.destination : route.redirect,
status: getRedirectStatus(route),
weight: 2
});
continue;
}
// If this is a static build we don't want to add redirects to the HTML file.
if(output === 'static') {
continue;
}
else if (route.distURL) {
_redirects.add({
dynamic: false,
input: route.pathname,
target: prependForwardSlash(route.distURL.toString().replace(dir.toString(), '')),
status: 200,
weight: 2,
});
} else {
_redirects.add({
dynamic: false,
input: route.pathname,
target: dynamicTarget,
status: 200,
weight: 2,
});
if (route.route === '/404') {
_redirects.add({
dynamic: true,
input: '/*',
target: dynamicTarget,
status: 404,
weight: 0,
});
}
}
} else {
// This is the dynamic route code. This generates a pattern from a dynamic
// route formatted with *s in place of the Astro dynamic/spread syntax.
const pattern = generateDynamicPattern(route);
// This route was prerendered and should be forwarded to the HTML file.
if (route.distURL) {
const targetRoute = route.redirectRoute ?? route;
const targetPattern = generateDynamicPattern(targetRoute);
let target = targetPattern;
if(config.build.format === 'directory') {
target = pathJoin(target, 'index.html');
} else {
target += '.html';
}
_redirects.add({
dynamic: true,
input: pattern,
target,
status: route.type === 'redirect' ? 301 : 200,
weight: 1,
});
} else {
_redirects.add({
dynamic: true,
input: pattern,
target: dynamicTarget,
status: 200,
weight: 1,
});
}
}
}
return _redirects;
}
/**
* Converts an Astro dynamic route into one formatted like:
* /team/articles/*
* With stars replacing spread and :id syntax replacing [id]
*/
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 prependForwardSlash(str: string) {
return str[0] === '/' ? str : '/' + str;
}

View file

@ -0,0 +1,8 @@
export {
Redirects,
type RedirectDefinition
} from './redirects.js';
export {
createRedirectsFromAstroRoutes
} from './astro.js';

View file

@ -0,0 +1,36 @@
import type { RedirectDefinition } from './redirects';
/**
* Pretty print a list of definitions into the output format. Keeps
* things readable for humans. Ex:
* /nope / 301
* /other / 301
* /two / 302
* /team/articles/* /team/articles/*\/index.html 200
* /blog/* /team/articles/*\/index.html 301
*/
export function print(
definitions: RedirectDefinition[],
minInputLength: number,
minTargetLength: number
) {
let _redirects = '';
// Loop over the definitions
for(let i = 0; i < definitions.length; i++) {
let definition = definitions[i];
// Figure out the number of spaces to add. We want at least 4 spaces
// after the input. This ensure that all targets line up together.
let inputSpaces = minInputLength - definition.input.length + 4;
let targetSpaces = minTargetLength - definition.target.length + 4;
_redirects +=
(i === 0 ? '' : '\n') +
definition.input +
' '.repeat(inputSpaces) +
definition.target +
' '.repeat(Math.abs(targetSpaces)) +
definition.status;
}
return _redirects;
}

View file

@ -0,0 +1,69 @@
import { print } from './print.js';
export type RedirectDefinition = {
dynamic: boolean;
input: string;
target: string;
// Allows specifying a weight to the definition.
// This allows insertion of definitions out of order but having
// a priority once inserted.
weight: number;
status: number;
};
export class Redirects {
public definitions: RedirectDefinition[] = [];
public minInputLength = 4;
public minTargetLength = 4;
/**
* Adds a new definition by inserting it into the list of definitions
* prioritized by the given weight. This keeps higher priority definitions
* At the top of the list once printed.
*/
add(definition: RedirectDefinition) {
// Find the longest input, so we can format things nicely
if (definition.input.length > this.minInputLength) {
this.minInputLength = definition.input.length;
}
// Same for the target
if (definition.target.length > this.minTargetLength) {
this.minTargetLength = definition.target.length;
}
binaryInsert(this.definitions, definition, (a, b) => {
return a.weight > b.weight;
});
}
print(): string {
return print(this.definitions, this.minInputLength, this.minTargetLength);
}
empty(): boolean {
return this.definitions.length === 0;
}
}
function binaryInsert<T>(sorted: T[], item: T, comparator: (a: T, b: T) => boolean) {
if(sorted.length === 0) {
sorted.push(item);
return 0;
}
let low = 0, high = sorted.length - 1, mid = 0;
while (low <= high) {
mid = low + (high - low >> 1);
if(comparator(sorted[mid], item)) {
low = mid + 1;
} else {
high = mid -1;
}
}
if(comparator(sorted[mid], item)) {
mid++;
}
sorted.splice(mid, 0, item);
return mid;
}

View file

@ -0,0 +1,25 @@
import { createRedirectsFromAstroRoutes } from '../dist/index.js';
import { expect } from 'chai';
describe('Astro', () => {
const serverConfig = {
output: 'server',
build: { format: 'directory' }
};
it('Creates a Redirects object from routes', () => {
const routes = [
{ pathname: '/', distURL: new URL('./index.html', import.meta.url), segments: [] },
{ pathname: '/one', distURL: new URL('./one/index.html', import.meta.url), segments: [] }
];
const dynamicTarget = './.adapter/dist/entry.mjs';
const _redirects = createRedirectsFromAstroRoutes({
config: serverConfig,
routes,
dir: new URL(import.meta.url),
dynamicTarget
});
expect(_redirects.definitions).to.have.a.lengthOf(2);
});
});

View file

@ -0,0 +1,44 @@
import { Redirects } from '../dist/index.js';
import { expect } from 'chai';
describe('Printing', () => {
it('Formats long lines in a pretty way', () => {
const _redirects = new Redirects();
_redirects.add({
dynamic: false,
input: '/a',
target: '/b',
weight: 0,
status: 200
});
_redirects.add({
dynamic: false,
input: '/some-pretty-long-input-line',
target: '/b',
weight: 0,
status: 200
});
let out = _redirects.print();
let [lineOne, lineTwo] = out.split('\n');
expect(lineOne.indexOf('/b')).to.equal(lineTwo.indexOf('/b'), 'destinations lined up');
expect(lineOne.indexOf('200')).to.equal(lineTwo.indexOf('200'), 'statuses lined up');
});
it('Properly prints dynamic routes', () => {
const _redirects = new Redirects();
_redirects.add({
dynamic: true,
input: '/pets/:cat',
target: '/pets/:cat/index.html',
status: 200,
weight: 1
});
let out = _redirects.print();
let parts = out.split(/\s+/);
expect(parts).to.deep.equal([
'/pets/:cat', '/pets/:cat/index.html', '200',
])
});
});

View file

@ -0,0 +1,32 @@
import { Redirects } from '../dist/index.js';
import { expect } from 'chai';
describe('Weight', () => {
it('Puts higher weighted definitions on top', () => {
const _redirects = new Redirects();
_redirects.add({
dynamic: false,
input: '/a',
target: '/b',
weight: 0,
status: 200
});
_redirects.add({
dynamic: false,
input: '/c',
target: '/d',
weight: 0,
status: 200
});
_redirects.add({
dynamic: false,
input: '/e',
target: '/f',
weight: 1,
status: 200
});
const firstDefn = _redirects.definitions[0];
expect(firstDefn.weight).to.equal(1);
expect(firstDefn.input).to.equal('/e');
});
});

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
@ -3627,6 +3630,9 @@ importers:
packages/integrations/cloudflare:
dependencies:
'@astrojs/underscore-redirects':
specifier: ^0.1.0
version: link:../../underscore-redirects
esbuild:
specifier: ^0.17.12
version: 0.17.12
@ -4393,6 +4399,9 @@ importers:
packages/integrations/netlify:
dependencies:
'@astrojs/underscore-redirects':
specifier: ^0.1.0
version: link:../../underscore-redirects
'@astrojs/webapi':
specifier: ^2.2.0
version: link:../../webapi
@ -4845,6 +4854,9 @@ importers:
packages/integrations/vercel:
dependencies:
'@astrojs/internal-helpers':
specifier: ^0.1.0
version: link:../../internal-helpers
'@astrojs/webapi':
specifier: ^2.2.0
version: link:../../webapi
@ -4904,6 +4916,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':
@ -4962,6 +4983,12 @@ importers:
specifier: workspace:*
version: link:../../../../../astro
packages/internal-helpers:
devDependencies:
astro-scripts:
specifier: workspace:*
version: link:../../scripts
packages/markdown/component:
devDependencies:
'@types/mocha':
@ -5234,6 +5261,27 @@ importers:
specifier: ^9.2.2
version: 9.2.2
packages/underscore-redirects:
devDependencies:
'@types/chai':
specifier: ^4.3.1
version: 4.3.3
'@types/mocha':
specifier: ^9.1.1
version: 9.1.1
astro:
specifier: workspace:*
version: link:../astro
astro-scripts:
specifier: workspace:*
version: link:../../scripts
chai:
specifier: ^4.3.6
version: 4.3.6
mocha:
specifier: ^9.2.2
version: 9.2.2
packages/webapi:
dependencies:
undici:
@ -8813,11 +8861,12 @@ packages:
/@types/chai-subset@1.3.3:
resolution: {integrity: sha512-frBecisrNGz+F4T6bcc+NLeolfiojh5FxW2klu669+8BARtyQv2C/GkNW6FUodVe4BroGMP/wER/YDGc7rEllw==}
dependencies:
'@types/chai': 4.3.3
'@types/chai': 4.3.5
dev: false
/@types/chai@4.3.3:
resolution: {integrity: sha512-hC7OMnszpxhZPduX+m+nrx+uFoLkWOMiR4oa/AZF3MuSETYTZmFfJAHqZEM8MVlvfG7BEUcgvtwoCTxBp6hm3g==}
dev: true
/@types/chai@4.3.5:
resolution: {integrity: sha512-mEo1sAde+UCE6b2hxn332f1g1E8WfYRu6p5SvTKr2ZKC1f7gFJXk4h5PyGP9Dt6gCaG8y8XhwnXWC6Iy2cmBng==}