Compare commits

...

29 commits

Author SHA1 Message Date
Matthew Phillips
2700c125c9 Do a simple push for priority 2023-05-30 18:33:43 -04:00
Matthew Phillips
d3895a2d71
Redirects: Allow preventing the output of the static HTML file (#7245) 2023-05-30 18:18:40 -04:00
Matthew Phillips
a39eb51d4c Fix ordering again 2023-05-30 18:04:21 -04:00
Matthew Phillips
fd52bd6bb1
Merge branch 'main' into redirects-ssg 2023-05-30 17:52:21 -04:00
Matthew Phillips
d7d0b22e96 Fix netlify test ordering 2023-05-30 17:49:22 -04:00
Matthew Phillips
eb7617d719 Refactor 2023-05-30 17:41:24 -04:00
Matthew Phillips
02a8506e22 Merge branch 'main' into redirects-ssg 2023-05-30 17:23:32 -04:00
Matthew Phillips
fa03a41a7a
Implement priority (#7210) 2023-05-30 08:31:00 -04:00
Matthew Phillips
ef9a456f25
Test that redirects can come from middleware (#7213)
* Test that redirects can come from middleware

* Allow non-promise returns for middleware
2023-05-26 06:49:10 -04:00
Matthew Phillips
8b4d248a36
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>
2023-05-25 10:53:58 -04:00
Matthew Phillips
af2ceea276
Merge branch 'main' into redirects-ssg 2023-05-24 11:07:40 -04:00
Matthew Phillips
c2f889bec6
Use status 308 for non-GET redirects (#7186) 2023-05-24 08:39:23 -04:00
Matthew Phillips
2904ceddf6
Merge branch 'main' into redirects-ssg 2023-05-23 15:47:04 -04:00
Matthew Phillips
f55e42222c Add support for the object notation in redirects 2023-05-23 15:45:29 -04:00
Matthew Phillips
ffc771e746
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
2023-05-23 15:43:24 -04:00
Matthew Phillips
11a517b1f1
Merge branch 'main' into redirects-ssg 2023-05-23 09:34:22 -04:00
Matthew Phillips
25d7d208ba
Implement support for dynamic routes in redirects (#7173)
* Implement support for dynamic routes in redirects

* Remove the .only

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

View file

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

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

@ -452,6 +452,11 @@ export interface AstroUserConfig {
*/
cacheDir?: string;
/**
* TODO
*/
redirects?: Record<string, string>;
/**
* @docs
* @name site
@ -733,6 +738,28 @@ export interface AstroUserConfig {
* ```
*/
serverEntry?: string;
/**
* @docs
* @name build.redirects
* @type {boolean}
* @default `true`
* @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;
};
/**
@ -1572,6 +1599,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>> {
/**
@ -1601,7 +1630,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
@ -1799,7 +1828,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>;
@ -1816,7 +1845,7 @@ export interface AstroPluginOptions {
logging: LogOptions;
}
export type RouteType = 'page' | 'endpoint';
export type RouteType = 'page' | 'endpoint' | 'redirect';
export interface RoutePart {
content: string;
@ -1824,6 +1853,11 @@ export interface RoutePart {
spread: boolean;
}
type RedirectConfig = string | {
status: ValidRedirectStatus;
destination: string;
}
export interface RouteData {
route: string;
component: string;
@ -1836,6 +1870,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 } 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) {
@ -166,7 +209,7 @@ async function generatePage(
builtPaths: Set<string>
) {
let timeStart = performance.now();
const renderers = ssrEntry.renderers;
const renderers = ssrEntry?.renderers;
const pageInfo = getPageDataByComponent(internals, pageData.route.component);
@ -178,8 +221,8 @@ async function generatePage(
.map(({ sheet }) => sheet)
.reduce(mergeInlineCss, []);
const pageModulePromise = ssrEntry.page;
const middleware = ssrEntry.middleware;
let pageModulePromise = ssrEntry?.page;
const middleware = ssrEntry?.middleware;
if (!pageModulePromise) {
throw new Error(
@ -518,10 +561,29 @@ 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();
switch(true) {
case (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;
}
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);

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

@ -22,6 +22,7 @@ const ASTRO_CONFIG_DEFAULTS: AstroUserConfig & any = {
server: './dist/server/',
assets: '_astro',
serverEntry: 'entry.mjs',
redirects: true,
},
compressHTML: false,
server: {
@ -37,6 +38,7 @@ const ASTRO_CONFIG_DEFAULTS: AstroUserConfig & any = {
},
vite: {},
legacy: {},
redirects: {},
experimental: {
assets: false,
hybridOutput: false,
@ -115,6 +117,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 +140,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({
@ -277,6 +281,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;
@ -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, 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

@ -208,23 +208,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

@ -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,147 @@
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': '/'
},
});
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: {
'/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
}
});
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,9 @@ describe('mode: "directory"', () => {
root: './fixtures/basics/',
output: 'server',
adapter: cloudflare({ mode: 'directory' }),
redirects: {
'/old': '/'
}
});
await fixture.build();
});
@ -19,4 +22,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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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
@ -4384,6 +4390,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
@ -4836,6 +4845,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
@ -4895,6 +4907,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':
@ -4953,6 +4974,12 @@ importers:
specifier: workspace:*
version: link:../../../../../astro
packages/internal-helpers:
devDependencies:
astro-scripts:
specifier: workspace:*
version: link:../../scripts
packages/markdown/component:
devDependencies:
'@types/mocha':
@ -5225,6 +5252,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:
@ -8818,11 +8866,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==}