Redirects (#7067)
* 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:
parent
dd1a6b6c94
commit
57f8d14c02
70 changed files with 1733 additions and 319 deletions
33
.changeset/chatty-actors-stare.md
Normal file
33
.changeset/chatty-actors-stare.md
Normal 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.
|
7
.changeset/fuzzy-ladybugs-jump.md
Normal file
7
.changeset/fuzzy-ladybugs-jump.md
Normal 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.
|
7
.changeset/hip-news-clean.md
Normal file
7
.changeset/hip-news-clean.md
Normal 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.
|
9
.changeset/twenty-suns-vanish.md
Normal file
9
.changeset/twenty-suns-vanish.md
Normal 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.
|
|
@ -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",
|
||||
|
|
|
@ -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'> & {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -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,6 +42,7 @@ import { debug, info } from '../logger/core.js';
|
|||
import { callMiddleware } from '../middleware/callMiddleware.js';
|
||||
import { createEnvironment, createRenderContext, renderPage } from '../render/index.js';
|
||||
import { callGetStaticPaths } from '../render/route-cache.js';
|
||||
import { getRedirectLocationOrThrow, RedirectComponentInstance, routeIsRedirect } from '../redirects/index.js';
|
||||
import {
|
||||
createAssetLink,
|
||||
createModuleScriptsSet,
|
||||
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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++;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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({}),
|
||||
|
|
|
@ -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
|
||||
/**
|
||||
|
|
|
@ -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';
|
||||
|
|
10
packages/astro/src/core/redirects/component.ts
Normal file
10
packages/astro/src/core/redirects/component.ts
Normal 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
|
||||
});
|
||||
}
|
||||
};
|
29
packages/astro/src/core/redirects/helpers.ts
Normal file
29
packages/astro/src/core/redirects/helpers.ts
Normal 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;
|
||||
}
|
3
packages/astro/src/core/redirects/index.ts
Normal file
3
packages/astro/src/core/redirects/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export { getRedirectLocationOrThrow } from './validate.js';
|
||||
export { routeIsRedirect, redirectRouteGenerate, redirectRouteStatus } from './helpers.js';
|
||||
export { RedirectComponentInstance } from './component.js';
|
13
packages/astro/src/core/redirects/validate.ts
Normal file
13
packages/astro/src/core/redirects/validate.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { AstroError, AstroErrorData } from '../errors/index.js';
|
||||
|
||||
export function getRedirectLocationOrThrow(headers: Headers): string {
|
||||
let location = headers.get('location');
|
||||
|
||||
if(!location) {
|
||||
throw new AstroError({
|
||||
...AstroErrorData.RedirectWithNoLocation
|
||||
});
|
||||
}
|
||||
|
||||
return location;
|
||||
}
|
|
@ -8,6 +8,7 @@ import type { RenderContext } from './context.js';
|
|||
import type { Environment } from './environment.js';
|
||||
import { createResult } from './result.js';
|
||||
import { callGetStaticPaths, findPathItemByKey, RouteCache } from './route-cache.js';
|
||||
import { routeIsRedirect, redirectRouteGenerate, redirectRouteStatus } from '../redirects/index.js';
|
||||
|
||||
interface GetParamsAndPropsOptions {
|
||||
mod: ComponentInstance;
|
||||
|
@ -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)
|
||||
|
|
|
@ -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'],
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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;
|
||||
|
|
13
packages/astro/test/fixtures/ssr-redirect/src/middleware.ts
vendored
Normal file
13
packages/astro/test/fixtures/ssr-redirect/src/middleware.ts
vendored
Normal 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();
|
||||
});
|
25
packages/astro/test/fixtures/ssr-redirect/src/pages/articles/[...slug].astro
vendored
Normal file
25
packages/astro/test/fixtures/ssr-redirect/src/pages/articles/[...slug].astro
vendored
Normal file
|
@ -0,0 +1,25 @@
|
|||
---
|
||||
export const getStaticPaths = (async () => {
|
||||
const posts = [
|
||||
{ slug: 'one', data: {draft: false, title: 'One'} },
|
||||
{ slug: 'two', data: {draft: false, title: 'Two'} }
|
||||
];
|
||||
return posts.map((post) => {
|
||||
return {
|
||||
params: { slug: post.slug },
|
||||
props: { draft: post.data.draft, title: post.data.title },
|
||||
};
|
||||
});
|
||||
})
|
||||
|
||||
const { slug } = Astro.params;
|
||||
const { title } = Astro.props;
|
||||
---
|
||||
<html>
|
||||
<head>
|
||||
<title>{ title }</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>{ title }</h1>
|
||||
</body>
|
||||
</html>
|
10
packages/astro/test/fixtures/ssr-redirect/src/pages/index.astro
vendored
Normal file
10
packages/astro/test/fixtures/ssr-redirect/src/pages/index.astro
vendored
Normal file
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
---
|
||||
<html>
|
||||
<head>
|
||||
<title>Testing</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Testing</h1>
|
||||
</body>
|
||||
</html>
|
|
@ -1,5 +1,6 @@
|
|||
---
|
||||
import Redirect from '../components/redirect.astro';
|
||||
const staticMode = import.meta.env.STATIC_MODE;
|
||||
---
|
||||
<html>
|
||||
<head>
|
||||
|
@ -7,6 +8,8 @@ import Redirect from '../components/redirect.astro';
|
|||
</head>
|
||||
<body>
|
||||
<h1>Testing</h1>
|
||||
<Redirect />
|
||||
{ !staticMode ? (
|
||||
<Redirect />
|
||||
) : <div></div>}
|
||||
</body>
|
||||
</html>
|
||||
|
|
10
packages/astro/test/fixtures/ssr-redirect/src/pages/middleware-redirect.astro
vendored
Normal file
10
packages/astro/test/fixtures/ssr-redirect/src/pages/middleware-redirect.astro
vendored
Normal 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>
|
154
packages/astro/test/redirects.test.js
Normal file
154
packages/astro/test/redirects.test.js
Normal 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');
|
||||
})
|
||||
})
|
||||
});
|
|
@ -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.'
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
|
@ -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]');
|
||||
})
|
||||
});
|
||||
|
|
|
@ -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"
|
||||
},
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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/)
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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';
|
||||
|
|
26
packages/integrations/netlify/src/integration-static.ts
Normal file
26
packages/integrations/netlify/src/integration-static.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,6 @@
|
|||
<html>
|
||||
<head><title>Testing</title></head>
|
||||
<body>
|
||||
<h1>Testing</h1>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,3 @@
|
|||
---
|
||||
return Astro.redirect('/');
|
||||
---
|
|
@ -0,0 +1,25 @@
|
|||
---
|
||||
export const getStaticPaths = (async () => {
|
||||
const posts = [
|
||||
{ slug: 'one', data: {draft: false, title: 'One'} },
|
||||
{ slug: 'two', data: {draft: false, title: 'Two'} }
|
||||
];
|
||||
return posts.map((post) => {
|
||||
return {
|
||||
params: { slug: post.slug },
|
||||
props: { draft: post.data.draft, title: post.data.title },
|
||||
};
|
||||
});
|
||||
})
|
||||
|
||||
const { slug } = Astro.params;
|
||||
const { title } = Astro.props;
|
||||
---
|
||||
<html>
|
||||
<head>
|
||||
<title>{ title }</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>{ title }</h1>
|
||||
</body>
|
||||
</html>
|
43
packages/integrations/netlify/test/static/redirects.test.js
Normal file
43
packages/integrations/netlify/test/static/redirects.test.js
Normal 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',
|
||||
]);
|
||||
});
|
||||
});
|
29
packages/integrations/netlify/test/static/test-utils.js
Normal file
29
packages/integrations/netlify/test/static/test-utils.js
Normal file
|
@ -0,0 +1,29 @@
|
|||
// @ts-check
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
export * from '../../../../astro/test/test-utils.js';
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {import('../../../../astro/dist/types/@types/astro').AstroIntegration}
|
||||
*/
|
||||
export function testIntegration() {
|
||||
return {
|
||||
name: '@astrojs/netlify/test-integration',
|
||||
hooks: {
|
||||
'astro:config:setup': ({ updateConfig }) => {
|
||||
updateConfig({
|
||||
vite: {
|
||||
resolve: {
|
||||
alias: {
|
||||
'@astrojs/netlify/netlify-functions.js': fileURLToPath(
|
||||
new URL('../../dist/netlify-functions.js', import.meta.url)
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -43,6 +43,7 @@ export default function vercelStatic({
|
|||
outDir,
|
||||
build: {
|
||||
format: 'directory',
|
||||
redirects: false,
|
||||
},
|
||||
vite: {
|
||||
define: viteDefine,
|
||||
|
|
9
packages/integrations/vercel/test/fixtures/redirects/astro.config.mjs
vendored
Normal file
9
packages/integrations/vercel/test/fixtures/redirects/astro.config.mjs
vendored
Normal file
|
@ -0,0 +1,9 @@
|
|||
import vercel from '@astrojs/vercel/static';
|
||||
import { defineConfig } from 'astro/config';
|
||||
|
||||
export default defineConfig({
|
||||
adapter: vercel({imageService: true}),
|
||||
experimental: {
|
||||
assets: true
|
||||
}
|
||||
});
|
9
packages/integrations/vercel/test/fixtures/redirects/package.json
vendored
Normal file
9
packages/integrations/vercel/test/fixtures/redirects/package.json
vendored
Normal file
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"name": "@test/astro-vercel-redirects",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@astrojs/vercel": "workspace:*",
|
||||
"astro": "workspace:*"
|
||||
}
|
||||
}
|
8
packages/integrations/vercel/test/fixtures/redirects/src/pages/index.astro
vendored
Normal file
8
packages/integrations/vercel/test/fixtures/redirects/src/pages/index.astro
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
<html>
|
||||
<head>
|
||||
<title>Testing</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Testing</h1>
|
||||
</body>
|
||||
</html>
|
25
packages/integrations/vercel/test/fixtures/redirects/src/pages/team/articles/[...slug].astro
vendored
Normal file
25
packages/integrations/vercel/test/fixtures/redirects/src/pages/team/articles/[...slug].astro
vendored
Normal file
|
@ -0,0 +1,25 @@
|
|||
---
|
||||
export const getStaticPaths = (async () => {
|
||||
const posts = [
|
||||
{ slug: 'one', data: {draft: false, title: 'One'} },
|
||||
{ slug: 'two', data: {draft: false, title: 'Two'} }
|
||||
];
|
||||
return posts.map((post) => {
|
||||
return {
|
||||
params: { slug: post.slug },
|
||||
props: { draft: post.data.draft, title: post.data.title },
|
||||
};
|
||||
});
|
||||
})
|
||||
|
||||
const { slug } = Astro.params;
|
||||
const { title } = Astro.props;
|
||||
---
|
||||
<html>
|
||||
<head>
|
||||
<title>{ title }</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>{ title }</h1>
|
||||
</body>
|
||||
</html>
|
59
packages/integrations/vercel/test/redirects.test.js
Normal file
59
packages/integrations/vercel/test/redirects.test.js
Normal 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);
|
||||
});
|
||||
});
|
41
packages/internal-helpers/package.json
Normal file
41
packages/internal-helpers/package.json
Normal 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"
|
||||
]
|
||||
}
|
3
packages/internal-helpers/readme.md
Normal file
3
packages/internal-helpers/readme.md
Normal 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.
|
86
packages/internal-helpers/src/path.ts
Normal file
86
packages/internal-helpers/src/path.ts
Normal 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:');
|
||||
}
|
10
packages/internal-helpers/tsconfig.json
Normal file
10
packages/internal-helpers/tsconfig.json
Normal file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src"],
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"target": "ES2021",
|
||||
"module": "ES2022",
|
||||
"outDir": "./dist"
|
||||
}
|
||||
}
|
42
packages/underscore-redirects/package.json
Normal file
42
packages/underscore-redirects/package.json
Normal 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"
|
||||
]
|
||||
}
|
3
packages/underscore-redirects/readme.md
Normal file
3
packages/underscore-redirects/readme.md
Normal 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.
|
145
packages/underscore-redirects/src/astro.ts
Normal file
145
packages/underscore-redirects/src/astro.ts
Normal 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;
|
||||
}
|
8
packages/underscore-redirects/src/index.ts
Normal file
8
packages/underscore-redirects/src/index.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
export {
|
||||
Redirects,
|
||||
type RedirectDefinition
|
||||
} from './redirects.js';
|
||||
|
||||
export {
|
||||
createRedirectsFromAstroRoutes
|
||||
} from './astro.js';
|
36
packages/underscore-redirects/src/print.ts
Normal file
36
packages/underscore-redirects/src/print.ts
Normal 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;
|
||||
}
|
69
packages/underscore-redirects/src/redirects.ts
Normal file
69
packages/underscore-redirects/src/redirects.ts
Normal 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;
|
||||
}
|
25
packages/underscore-redirects/test/astro.test.js
Normal file
25
packages/underscore-redirects/test/astro.test.js
Normal 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);
|
||||
});
|
||||
});
|
44
packages/underscore-redirects/test/print.test.js
Normal file
44
packages/underscore-redirects/test/print.test.js
Normal 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',
|
||||
])
|
||||
});
|
||||
});
|
32
packages/underscore-redirects/test/weight.test.js
Normal file
32
packages/underscore-redirects/test/weight.test.js
Normal 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');
|
||||
});
|
||||
});
|
10
packages/underscore-redirects/tsconfig.json
Normal file
10
packages/underscore-redirects/tsconfig.json
Normal file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src"],
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"target": "ES2021",
|
||||
"module": "ES2022",
|
||||
"outDir": "./dist"
|
||||
}
|
||||
}
|
|
@ -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==}
|
||||
|
|
Loading…
Reference in a new issue