Compare commits
29 commits
main
...
redirects-
Author | SHA1 | Date | |
---|---|---|---|
|
2700c125c9 | ||
|
d3895a2d71 | ||
|
a39eb51d4c | ||
|
fd52bd6bb1 | ||
|
d7d0b22e96 | ||
|
eb7617d719 | ||
|
02a8506e22 | ||
|
fa03a41a7a | ||
|
ef9a456f25 | ||
|
8b4d248a36 | ||
|
af2ceea276 | ||
|
c2f889bec6 | ||
|
2904ceddf6 | ||
|
f55e42222c | ||
|
ffc771e746 | ||
|
11a517b1f1 | ||
|
25d7d208ba | ||
|
4857c7d317 | ||
|
e9e4d72598 | ||
|
83ed3669be | ||
|
ab0539b951 | ||
|
1749ce5d08 | ||
|
eed6a72a2a | ||
|
475294a60e | ||
|
a70820be15 | ||
|
f52116ac03 | ||
|
d6b7104722 | ||
|
ef3ea942cc | ||
|
46e726960f |
64 changed files with 1557 additions and 317 deletions
6
.changeset/chatty-actors-stare.md
Normal file
6
.changeset/chatty-actors-stare.md
Normal file
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
'@astrojs/netlify': minor
|
||||
'astro': minor
|
||||
---
|
||||
|
||||
Implements the redirects proposal
|
|
@ -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",
|
||||
|
|
|
@ -452,6 +452,11 @@ export interface AstroUserConfig {
|
|||
*/
|
||||
cacheDir?: string;
|
||||
|
||||
/**
|
||||
* TODO
|
||||
*/
|
||||
redirects?: Record<string, string>;
|
||||
|
||||
/**
|
||||
* @docs
|
||||
* @name site
|
||||
|
@ -733,6 +738,28 @@ export interface AstroUserConfig {
|
|||
* ```
|
||||
*/
|
||||
serverEntry?: string;
|
||||
/**
|
||||
* @docs
|
||||
* @name build.redirects
|
||||
* @type {boolean}
|
||||
* @default `true`
|
||||
* @description
|
||||
* Specifies whether redirects will be output to HTML during the build.
|
||||
* This option only applies to `output: 'static'` mode; in SSR redirects
|
||||
* are treated the same as all responses.
|
||||
*
|
||||
* This option is mostly meant to be used by adapters that have special
|
||||
* configuration files for redirects and do not need/want HTML based redirects.
|
||||
*
|
||||
* ```js
|
||||
* {
|
||||
* build: {
|
||||
* redirects: false
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
redirects?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -1572,6 +1599,8 @@ export interface AstroAdapter {
|
|||
|
||||
type Body = string;
|
||||
|
||||
export type ValidRedirectStatus = 300 | 301 | 302 | 303 | 304 | 307 | 308;
|
||||
|
||||
// Shared types between `Astro` global and API context object
|
||||
interface AstroSharedContext<Props extends Record<string, any> = Record<string, any>> {
|
||||
/**
|
||||
|
@ -1601,7 +1630,7 @@ interface AstroSharedContext<Props extends Record<string, any> = Record<string,
|
|||
/**
|
||||
* Redirect to another page (**SSR Only**).
|
||||
*/
|
||||
redirect(path: string, status?: 301 | 302 | 303 | 307 | 308): Response;
|
||||
redirect(path: string, status?: ValidRedirectStatus): Response;
|
||||
|
||||
/**
|
||||
* Object accessed via Astro middleware
|
||||
|
@ -1799,7 +1828,7 @@ export type MiddlewareNext<R> = () => Promise<R>;
|
|||
export type MiddlewareHandler<R> = (
|
||||
context: APIContext,
|
||||
next: MiddlewareNext<R>
|
||||
) => Promise<R> | Promise<void> | void;
|
||||
) => Promise<R> | R | Promise<void> | void;
|
||||
|
||||
export type MiddlewareResponseHandler = MiddlewareHandler<Response>;
|
||||
export type MiddlewareEndpointHandler = MiddlewareHandler<Response | EndpointOutput>;
|
||||
|
@ -1816,7 +1845,7 @@ export interface AstroPluginOptions {
|
|||
logging: LogOptions;
|
||||
}
|
||||
|
||||
export type RouteType = 'page' | 'endpoint';
|
||||
export type RouteType = 'page' | 'endpoint' | 'redirect';
|
||||
|
||||
export interface RoutePart {
|
||||
content: string;
|
||||
|
@ -1824,6 +1853,11 @@ export interface RoutePart {
|
|||
spread: boolean;
|
||||
}
|
||||
|
||||
type RedirectConfig = string | {
|
||||
status: ValidRedirectStatus;
|
||||
destination: string;
|
||||
}
|
||||
|
||||
export interface RouteData {
|
||||
route: string;
|
||||
component: string;
|
||||
|
@ -1836,6 +1870,12 @@ export interface RouteData {
|
|||
segments: RoutePart[][];
|
||||
type: RouteType;
|
||||
prerender: boolean;
|
||||
redirect?: RedirectConfig;
|
||||
redirectRoute?: RouteData;
|
||||
}
|
||||
|
||||
export type RedirectRouteData = RouteData & {
|
||||
redirect: string;
|
||||
}
|
||||
|
||||
export type SerializedRouteData = Omit<RouteData, 'generate' | 'pattern'> & {
|
||||
|
|
|
@ -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,7 +42,8 @@ import { debug, info } from '../logger/core.js';
|
|||
import { callMiddleware } from '../middleware/callMiddleware.js';
|
||||
import { createEnvironment, createRenderContext, renderPage } from '../render/index.js';
|
||||
import { callGetStaticPaths } from '../render/route-cache.js';
|
||||
import {
|
||||
import { getRedirectLocationOrThrow, RedirectComponentInstance } from '../redirects/index.js';
|
||||
import {
|
||||
createAssetLink,
|
||||
createModuleScriptsSet,
|
||||
createStylesheetElementSet,
|
||||
|
@ -48,7 +52,7 @@ import { createRequest } from '../request.js';
|
|||
import { matchRoute } from '../routing/match.js';
|
||||
import { getOutputFilename } from '../util.js';
|
||||
import { getOutDirWithinCwd, getOutFile, getOutFolder } from './common.js';
|
||||
import { cssOrder, getPageDataByComponent, mergeInlineCss } from './internal.js';
|
||||
import { cssOrder, getPageDataByComponent, mergeInlineCss, getEntryFilePathFromComponentPath } from './internal.js';
|
||||
import type {
|
||||
PageBuildData,
|
||||
SinglePageBuiltModule,
|
||||
|
@ -57,6 +61,38 @@ import type {
|
|||
} from './types';
|
||||
import { getTimeStat } from './util.js';
|
||||
|
||||
const StaticMiddlewareInstance: AstroMiddlewareInstance<unknown> = {
|
||||
onRequest: (ctx, next) => next()
|
||||
};
|
||||
|
||||
function createEntryURL(filePath: string, outFolder: URL) {
|
||||
return new URL('./' + filePath + `?time=${Date.now()}`, outFolder);
|
||||
}
|
||||
|
||||
async function getEntryForRedirectRoute(
|
||||
route: RouteData,
|
||||
internals: BuildInternals,
|
||||
outFolder: URL
|
||||
): Promise<SinglePageBuiltModule> {
|
||||
if(route.type !== 'redirect') {
|
||||
throw new Error(`Expected a redirect route.`);
|
||||
}
|
||||
if(route.redirectRoute) {
|
||||
const filePath = getEntryFilePathFromComponentPath(internals, route.redirectRoute.component);
|
||||
if(filePath) {
|
||||
const url = createEntryURL(filePath, outFolder);
|
||||
const ssrEntryPage: SinglePageBuiltModule = await import(url.toString());
|
||||
return ssrEntryPage;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
page: () => Promise.resolve(RedirectComponentInstance),
|
||||
middleware: StaticMiddlewareInstance,
|
||||
renderers: []
|
||||
}
|
||||
}
|
||||
|
||||
function shouldSkipDraft(pageModule: ComponentInstance, settings: AstroSettings): boolean {
|
||||
return (
|
||||
// Drafts are disabled
|
||||
|
@ -95,7 +131,6 @@ export function chunkIsPage(
|
|||
export async function generatePages(opts: StaticBuildOptions, internals: BuildInternals) {
|
||||
const timer = performance.now();
|
||||
const ssr = opts.settings.config.output === 'server' || isHybridOutput(opts.settings.config); // hybrid mode is essentially SSR with prerender by default
|
||||
const serverEntry = opts.buildConfig.serverEntry;
|
||||
const outFolder = ssr ? opts.buildConfig.server : getOutDirWithinCwd(opts.settings.config.outDir);
|
||||
|
||||
if (ssr && !hasPrerenderedPages(internals)) return;
|
||||
|
@ -108,19 +143,27 @@ export async function generatePages(opts: StaticBuildOptions, internals: BuildIn
|
|||
if (ssr) {
|
||||
for (const [pageData, filePath] of eachPageDataFromEntryPoint(internals)) {
|
||||
if (pageData.route.prerender) {
|
||||
const ssrEntryURLPage = new URL('./' + filePath + `?time=${Date.now()}`, outFolder);
|
||||
const ssrEntryPage = await import(ssrEntryURLPage.toString());
|
||||
const ssrEntryURLPage =createEntryURL(filePath, outFolder);
|
||||
const ssrEntryPage: SinglePageBuiltModule = await import(ssrEntryURLPage.toString());
|
||||
|
||||
await generatePage(opts, internals, pageData, ssrEntryPage, builtPaths);
|
||||
}
|
||||
}
|
||||
for(const pageData of eachRedirectPageData(internals)) {
|
||||
const entry = await getEntryForRedirectRoute(pageData.route, internals, outFolder);
|
||||
await generatePage(opts, internals, pageData, entry, builtPaths);
|
||||
}
|
||||
} else {
|
||||
for (const [pageData, filePath] of eachPageDataFromEntryPoint(internals)) {
|
||||
const ssrEntryURLPage = new URL('./' + filePath + `?time=${Date.now()}`, outFolder);
|
||||
const ssrEntryPage = await import(ssrEntryURLPage.toString());
|
||||
const ssrEntryURLPage =createEntryURL(filePath, outFolder);
|
||||
const ssrEntryPage: SinglePageBuiltModule = await import(ssrEntryURLPage.toString());
|
||||
|
||||
await generatePage(opts, internals, pageData, ssrEntryPage, builtPaths);
|
||||
}
|
||||
for(const pageData of eachRedirectPageData(internals)) {
|
||||
const entry = await getEntryForRedirectRoute(pageData.route, internals, outFolder);
|
||||
await generatePage(opts, internals, pageData, entry, builtPaths);
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.settings.config.experimental.assets) {
|
||||
|
@ -166,7 +209,7 @@ async function generatePage(
|
|||
builtPaths: Set<string>
|
||||
) {
|
||||
let timeStart = performance.now();
|
||||
const renderers = ssrEntry.renderers;
|
||||
const renderers = ssrEntry?.renderers;
|
||||
|
||||
const pageInfo = getPageDataByComponent(internals, pageData.route.component);
|
||||
|
||||
|
@ -178,8 +221,8 @@ async function generatePage(
|
|||
.map(({ sheet }) => sheet)
|
||||
.reduce(mergeInlineCss, []);
|
||||
|
||||
const pageModulePromise = ssrEntry.page;
|
||||
const middleware = ssrEntry.middleware;
|
||||
let pageModulePromise = ssrEntry?.page;
|
||||
const middleware = ssrEntry?.middleware;
|
||||
|
||||
if (!pageModulePromise) {
|
||||
throw new Error(
|
||||
|
@ -518,10 +561,29 @@ async function generatePath(
|
|||
}
|
||||
throw err;
|
||||
}
|
||||
throwIfRedirectNotAllowed(response, opts.settings.config);
|
||||
// If there's no body, do nothing
|
||||
if (!response.body) return;
|
||||
body = await response.text();
|
||||
|
||||
switch(true) {
|
||||
case (response.status >= 300 && response.status < 400): {
|
||||
// If redirects is set to false, don't output the HTML
|
||||
if(!opts.settings.config.build.redirects) {
|
||||
return;
|
||||
}
|
||||
const location = getRedirectLocationOrThrow(response.headers);
|
||||
body = `<!doctype html>
|
||||
<title>Redirecting to: ${location}</title>
|
||||
<meta http-equiv="refresh" content="0;url=${location}" />`;
|
||||
// A dynamic redirect, set the location so that integrations know about it.
|
||||
if(pageData.route.type !== 'redirect') {
|
||||
pageData.route.redirect = location;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// If there's no body, do nothing
|
||||
if (!response.body) return;
|
||||
body = await response.text();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const outFolder = getOutFolder(settings.config, pathname, pageData.route.type);
|
||||
|
|
|
@ -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++;
|
||||
}
|
||||
|
|
|
@ -22,6 +22,7 @@ const ASTRO_CONFIG_DEFAULTS: AstroUserConfig & any = {
|
|||
server: './dist/server/',
|
||||
assets: '_astro',
|
||||
serverEntry: 'entry.mjs',
|
||||
redirects: true,
|
||||
},
|
||||
compressHTML: false,
|
||||
server: {
|
||||
|
@ -37,6 +38,7 @@ const ASTRO_CONFIG_DEFAULTS: AstroUserConfig & any = {
|
|||
},
|
||||
vite: {},
|
||||
legacy: {},
|
||||
redirects: {},
|
||||
experimental: {
|
||||
assets: false,
|
||||
hybridOutput: false,
|
||||
|
@ -115,6 +117,7 @@ export const AstroConfigSchema = z.object({
|
|||
assets: z.string().optional().default(ASTRO_CONFIG_DEFAULTS.build.assets),
|
||||
assetsPrefix: z.string().optional(),
|
||||
serverEntry: z.string().optional().default(ASTRO_CONFIG_DEFAULTS.build.serverEntry),
|
||||
redirects: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.build.redirects),
|
||||
})
|
||||
.optional()
|
||||
.default({}),
|
||||
|
@ -137,6 +140,7 @@ export const AstroConfigSchema = z.object({
|
|||
.optional()
|
||||
.default({})
|
||||
),
|
||||
redirects: z.record(z.string(), z.string()).default(ASTRO_CONFIG_DEFAULTS.redirects),
|
||||
image: z
|
||||
.object({
|
||||
service: z.object({
|
||||
|
@ -277,6 +281,7 @@ export function createRelativeSchema(cmd: string, fileProtocolRoot: URL) {
|
|||
assets: z.string().optional().default(ASTRO_CONFIG_DEFAULTS.build.assets),
|
||||
assetsPrefix: z.string().optional(),
|
||||
serverEntry: z.string().optional().default(ASTRO_CONFIG_DEFAULTS.build.serverEntry),
|
||||
redirects: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.build.redirects),
|
||||
})
|
||||
.optional()
|
||||
.default({}),
|
||||
|
|
|
@ -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;
|
||||
|
@ -111,6 +112,15 @@ export type RenderPage = {
|
|||
};
|
||||
|
||||
export async function renderPage({ mod, renderContext, env, apiContext }: RenderPage) {
|
||||
if(routeIsRedirect(renderContext.route)) {
|
||||
return new Response(null, {
|
||||
status: redirectRouteStatus(renderContext.route, renderContext.request.method),
|
||||
headers: {
|
||||
location: redirectRouteGenerate(renderContext.route, renderContext.params)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Validate the page component before rendering the page
|
||||
const Component = mod.default;
|
||||
if (!Component)
|
||||
|
|
|
@ -208,23 +208,21 @@ export function createResult(args: CreateResultArgs): SSRResult {
|
|||
locals,
|
||||
request,
|
||||
url,
|
||||
redirect: args.ssr
|
||||
? (path, status) => {
|
||||
// If the response is already sent, error as we cannot proceed with the redirect.
|
||||
if ((request as any)[responseSentSymbol]) {
|
||||
throw new AstroError({
|
||||
...AstroErrorData.ResponseSentError,
|
||||
});
|
||||
}
|
||||
redirect(path, status) {
|
||||
// If the response is already sent, error as we cannot proceed with the redirect.
|
||||
if ((request as any)[responseSentSymbol]) {
|
||||
throw new AstroError({
|
||||
...AstroErrorData.ResponseSentError,
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(null, {
|
||||
status: status || 302,
|
||||
headers: {
|
||||
Location: path,
|
||||
},
|
||||
});
|
||||
}
|
||||
: onlyAvailableInSSR('Astro.redirect'),
|
||||
return new Response(null, {
|
||||
status: status || 302,
|
||||
headers: {
|
||||
Location: path,
|
||||
},
|
||||
});
|
||||
},
|
||||
response: response as AstroGlobal['response'],
|
||||
slots: astroSlots as unknown as AstroGlobal['slots'],
|
||||
};
|
||||
|
|
|
@ -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());
|
||||
|
|
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>
|
147
packages/astro/test/redirects.test.js
Normal file
147
packages/astro/test/redirects.test.js
Normal file
|
@ -0,0 +1,147 @@
|
|||
import { expect } from 'chai';
|
||||
import { loadFixture } from './test-utils.js';
|
||||
import testAdapter from './test-adapter.js';
|
||||
|
||||
describe('Astro.redirect', () => {
|
||||
/** @type {import('./test-utils').Fixture} */
|
||||
let fixture;
|
||||
|
||||
describe('output: "server"', () => {
|
||||
before(async () => {
|
||||
fixture = await loadFixture({
|
||||
root: './fixtures/ssr-redirect/',
|
||||
output: 'server',
|
||||
adapter: testAdapter(),
|
||||
redirects: {
|
||||
'/api/redirect': '/'
|
||||
},
|
||||
});
|
||||
await fixture.build();
|
||||
});
|
||||
|
||||
it('Returns a 302 status', async () => {
|
||||
const app = await fixture.loadTestAdapterApp();
|
||||
const request = new Request('http://example.com/secret');
|
||||
const response = await app.render(request);
|
||||
expect(response.status).to.equal(302);
|
||||
expect(response.headers.get('location')).to.equal('/login');
|
||||
});
|
||||
|
||||
it('Warns when used inside a component', async () => {
|
||||
const app = await fixture.loadTestAdapterApp();
|
||||
const request = new Request('http://example.com/late');
|
||||
const response = await app.render(request);
|
||||
try {
|
||||
await response.text();
|
||||
expect(false).to.equal(true);
|
||||
} catch (e) {
|
||||
expect(e.message).to.equal(
|
||||
'The response has already been sent to the browser and cannot be altered.'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
describe('Redirects config', () => {
|
||||
it('Returns the redirect', async () => {
|
||||
const app = await fixture.loadTestAdapterApp();
|
||||
const request = new Request('http://example.com/api/redirect');
|
||||
const response = await app.render(request);
|
||||
expect(response.status).to.equal(301);
|
||||
expect(response.headers.get('Location')).to.equal('/');
|
||||
});
|
||||
|
||||
it('Uses 308 for non-GET methods', async () => {
|
||||
const app = await fixture.loadTestAdapterApp();
|
||||
const request = new Request('http://example.com/api/redirect', {
|
||||
method: 'POST'
|
||||
});
|
||||
const response = await app.render(request);
|
||||
expect(response.status).to.equal(308);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('output: "static"', () => {
|
||||
before(async () => {
|
||||
process.env.STATIC_MODE = true;
|
||||
fixture = await loadFixture({
|
||||
root: './fixtures/ssr-redirect/',
|
||||
output: 'static',
|
||||
experimental: {
|
||||
middleware: true
|
||||
},
|
||||
redirects: {
|
||||
'/one': '/',
|
||||
'/two': '/',
|
||||
'/blog/[...slug]': '/articles/[...slug]',
|
||||
'/three': {
|
||||
status: 302,
|
||||
destination: '/'
|
||||
}
|
||||
}
|
||||
});
|
||||
await fixture.build();
|
||||
});
|
||||
|
||||
it('Includes the meta refresh tag in Astro.redirect pages', async () => {
|
||||
const html = await fixture.readFile('/secret/index.html');
|
||||
expect(html).to.include('http-equiv="refresh');
|
||||
expect(html).to.include('url=/login');
|
||||
});
|
||||
|
||||
it('Includes the meta refresh tag in `redirect` config pages', async () => {
|
||||
let html = await fixture.readFile('/one/index.html');
|
||||
expect(html).to.include('http-equiv="refresh');
|
||||
expect(html).to.include('url=/');
|
||||
|
||||
html = await fixture.readFile('/two/index.html');
|
||||
expect(html).to.include('http-equiv="refresh');
|
||||
expect(html).to.include('url=/');
|
||||
|
||||
html = await fixture.readFile('/three/index.html');
|
||||
expect(html).to.include('http-equiv="refresh');
|
||||
expect(html).to.include('url=/');
|
||||
});
|
||||
|
||||
it('Generates page for dynamic routes', async () => {
|
||||
let html = await fixture.readFile('/blog/one/index.html');
|
||||
expect(html).to.include('http-equiv="refresh');
|
||||
expect(html).to.include('url=/articles/one');
|
||||
|
||||
html = await fixture.readFile('/blog/two/index.html');
|
||||
expect(html).to.include('http-equiv="refresh');
|
||||
expect(html).to.include('url=/articles/two');
|
||||
});
|
||||
|
||||
it('Generates redirect pages for redirects created by middleware', async () => {
|
||||
let html = await fixture.readFile('/middleware-redirect/index.html');
|
||||
expect(html).to.include('http-equiv="refresh');
|
||||
expect(html).to.include('url=/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('config.build.redirects = false', () => {
|
||||
before(async () => {
|
||||
process.env.STATIC_MODE = true;
|
||||
fixture = await loadFixture({
|
||||
root: './fixtures/ssr-redirect/',
|
||||
output: 'static',
|
||||
redirects: {
|
||||
'/one': '/'
|
||||
},
|
||||
build: {
|
||||
redirects: false
|
||||
}
|
||||
});
|
||||
await fixture.build();
|
||||
});
|
||||
|
||||
it('Does not output redirect HTML', async () => {
|
||||
let oneHtml = undefined;
|
||||
try {
|
||||
oneHtml = await fixture.readFile('/one/index.html');
|
||||
} catch {}
|
||||
expect(oneHtml).be.an('undefined');
|
||||
})
|
||||
})
|
||||
});
|
|
@ -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,9 @@ describe('mode: "directory"', () => {
|
|||
root: './fixtures/basics/',
|
||||
output: 'server',
|
||||
adapter: cloudflare({ mode: 'directory' }),
|
||||
redirects: {
|
||||
'/old': '/'
|
||||
}
|
||||
});
|
||||
await fixture.build();
|
||||
});
|
||||
|
@ -19,4 +22,16 @@ describe('mode: "directory"', () => {
|
|||
expect(await fixture.pathExists('../functions')).to.be.true;
|
||||
expect(await fixture.pathExists('../functions/[[path]].js')).to.be.true;
|
||||
});
|
||||
|
||||
it('generates a redirects file', async () => {
|
||||
try {
|
||||
let _redirects = await fixture.readFile('/_redirects');
|
||||
let parts = _redirects.split(/\s+/);
|
||||
expect(parts).to.deep.equal([
|
||||
'/old', '/', '301'
|
||||
]);
|
||||
} catch {
|
||||
expect(false).to.equal(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,41 @@
|
|||
import { expect } from 'chai';
|
||||
import { load as cheerioLoad } from 'cheerio';
|
||||
import { loadFixture, testIntegration } from './test-utils.js';
|
||||
import netlifyAdapter from '../../dist/index.js';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
describe('SSG - Redirects', () => {
|
||||
/** @type {import('../../../astro/test/test-utils').Fixture} */
|
||||
let fixture;
|
||||
|
||||
before(async () => {
|
||||
fixture = await loadFixture({
|
||||
root: new URL('../static/fixtures/redirects/', import.meta.url).toString(),
|
||||
output: 'server',
|
||||
adapter: netlifyAdapter({
|
||||
dist: new URL('../static/fixtures/redirects/dist/', import.meta.url),
|
||||
}),
|
||||
site: `http://example.com`,
|
||||
integrations: [testIntegration()],
|
||||
redirects: {
|
||||
'/other': '/'
|
||||
}
|
||||
});
|
||||
await fixture.build();
|
||||
});
|
||||
|
||||
it('Creates a redirects file', async () => {
|
||||
let redirects = await fixture.readFile('/_redirects');
|
||||
let parts = redirects.split(/\s+/);
|
||||
expect(parts).to.deep.equal([
|
||||
'/other', '/', '301',
|
||||
// This uses the dynamic Astro.redirect, so we don't know that it's a redirect
|
||||
// until runtime. This is correct!
|
||||
'/nope', '/.netlify/functions/entry', '200',
|
||||
'/', '/.netlify/functions/entry', '200',
|
||||
|
||||
// A real route
|
||||
'/team/articles/*', '/.netlify/functions/entry', '200',
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -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>
|
40
packages/integrations/netlify/test/static/redirects.test.js
Normal file
40
packages/integrations/netlify/test/static/redirects.test.js
Normal file
|
@ -0,0 +1,40 @@
|
|||
import { expect } from 'chai';
|
||||
import { loadFixture, testIntegration } from './test-utils.js';
|
||||
import { netlifyStatic } from '../../dist/index.js';
|
||||
|
||||
describe('SSG - Redirects', () => {
|
||||
/** @type {import('../../../astro/test/test-utils').Fixture} */
|
||||
let fixture;
|
||||
|
||||
before(async () => {
|
||||
fixture = await loadFixture({
|
||||
root: new URL('./fixtures/redirects/', import.meta.url).toString(),
|
||||
output: 'static',
|
||||
adapter: netlifyStatic(),
|
||||
site: `http://example.com`,
|
||||
integrations: [testIntegration()],
|
||||
redirects: {
|
||||
'/other': '/',
|
||||
'/two': {
|
||||
status: 302,
|
||||
destination: '/'
|
||||
},
|
||||
'/blog/[...slug]': '/team/articles/[...slug]'
|
||||
}
|
||||
});
|
||||
await fixture.build();
|
||||
});
|
||||
|
||||
it('Creates a redirects file', async () => {
|
||||
let redirects = await fixture.readFile('/_redirects');
|
||||
let parts = redirects.split(/\s+/);
|
||||
expect(parts).to.deep.equal([
|
||||
'/two', '/', '302',
|
||||
'/other', '/', '301',
|
||||
'/nope', '/', '301',
|
||||
|
||||
'/blog/*', '/team/articles/*/index.html', '301',
|
||||
'/team/articles/*', '/team/articles/*/index.html', '200',
|
||||
]);
|
||||
});
|
||||
});
|
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>
|
56
packages/integrations/vercel/test/redirects.test.js
Normal file
56
packages/integrations/vercel/test/redirects.test.js
Normal file
|
@ -0,0 +1,56 @@
|
|||
import { expect } from 'chai';
|
||||
import * as cheerio from 'cheerio';
|
||||
import { loadFixture } from './test-utils.js';
|
||||
|
||||
describe('Redirects', () => {
|
||||
/** @type {import('../../../astro/test/test-utils.js').Fixture} */
|
||||
let fixture;
|
||||
|
||||
before(async () => {
|
||||
fixture = await loadFixture({
|
||||
root: './fixtures/redirects/',
|
||||
redirects: {
|
||||
'/one': '/',
|
||||
'/two': '/',
|
||||
'/three': {
|
||||
status: 302,
|
||||
destination: '/'
|
||||
},
|
||||
'/blog/[...slug]': '/team/articles/[...slug]',
|
||||
}
|
||||
});
|
||||
await fixture.build();
|
||||
});
|
||||
|
||||
async function getConfig() {
|
||||
const json = await fixture.readFile('../.vercel/output/config.json');
|
||||
const config = JSON.parse(json);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
it('define static routes', async () => {
|
||||
const config = await getConfig();
|
||||
|
||||
const oneRoute = config.routes.find(r => r.src === '/\\/one');
|
||||
expect(oneRoute.headers.Location).to.equal('/');
|
||||
expect(oneRoute.status).to.equal(301);
|
||||
|
||||
const twoRoute = config.routes.find(r => r.src === '/\\/two');
|
||||
expect(twoRoute.headers.Location).to.equal('/');
|
||||
expect(twoRoute.status).to.equal(301);
|
||||
|
||||
const threeRoute = config.routes.find(r => r.src === '/\\/three');
|
||||
expect(threeRoute.headers.Location).to.equal('/');
|
||||
expect(threeRoute.status).to.equal(302);
|
||||
});
|
||||
|
||||
it('defines dynamic routes', async () => {
|
||||
const config = await getConfig();
|
||||
|
||||
const blogRoute = config.routes.find(r => r.src.startsWith('/\\/blog'));
|
||||
expect(blogRoute).to.not.be.undefined;
|
||||
expect(blogRoute.headers.Location.startsWith('/team/articles')).to.equal(true);
|
||||
expect(blogRoute.status).to.equal(301);
|
||||
});
|
||||
});
|
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.
|
81
packages/internal-helpers/src/path.ts
Normal file
81
packages/internal-helpers/src/path.ts
Normal file
|
@ -0,0 +1,81 @@
|
|||
export function appendExtension(path: string, extension: string) {
|
||||
return path + '.' + extension;
|
||||
}
|
||||
|
||||
export function appendForwardSlash(path: string) {
|
||||
return path.endsWith('/') ? path : path + '/';
|
||||
}
|
||||
|
||||
export function prependForwardSlash(path: string) {
|
||||
return path[0] === '/' ? path : '/' + path;
|
||||
}
|
||||
|
||||
export function removeTrailingForwardSlash(path: string) {
|
||||
return path.endsWith('/') ? path.slice(0, path.length - 1) : path;
|
||||
}
|
||||
|
||||
export function removeLeadingForwardSlash(path: string) {
|
||||
return path.startsWith('/') ? path.substring(1) : path;
|
||||
}
|
||||
|
||||
export function removeLeadingForwardSlashWindows(path: string) {
|
||||
return path.startsWith('/') && path[2] === ':' ? path.substring(1) : path;
|
||||
}
|
||||
|
||||
export function trimSlashes(path: string) {
|
||||
return path.replace(/^\/|\/$/g, '');
|
||||
}
|
||||
|
||||
export function startsWithForwardSlash(path: string) {
|
||||
return path[0] === '/';
|
||||
}
|
||||
|
||||
export function startsWithDotDotSlash(path: string) {
|
||||
const c1 = path[0];
|
||||
const c2 = path[1];
|
||||
const c3 = path[2];
|
||||
return c1 === '.' && c2 === '.' && c3 === '/';
|
||||
}
|
||||
|
||||
export function startsWithDotSlash(path: string) {
|
||||
const c1 = path[0];
|
||||
const c2 = path[1];
|
||||
return c1 === '.' && c2 === '/';
|
||||
}
|
||||
|
||||
export function isRelativePath(path: string) {
|
||||
return startsWithDotDotSlash(path) || startsWithDotSlash(path);
|
||||
}
|
||||
|
||||
function isString(path: unknown): path is string {
|
||||
return typeof path === 'string' || path instanceof String;
|
||||
}
|
||||
|
||||
export function joinPaths(...paths: (string | undefined)[]) {
|
||||
return paths
|
||||
.filter(isString)
|
||||
.map((path, i) => {
|
||||
if (i === 0) {
|
||||
return removeTrailingForwardSlash(path);
|
||||
} else if (i === paths.length - 1) {
|
||||
return removeLeadingForwardSlash(path);
|
||||
} else {
|
||||
return trimSlashes(path);
|
||||
}
|
||||
})
|
||||
.join('/');
|
||||
}
|
||||
|
||||
export function removeFileExtension(path: string) {
|
||||
let idx = path.lastIndexOf('.');
|
||||
return idx === -1 ? path : path.slice(0, idx);
|
||||
}
|
||||
|
||||
export function removeQueryString(path: string) {
|
||||
const index = path.lastIndexOf('?');
|
||||
return index > 0 ? path.substring(0, index) : path;
|
||||
}
|
||||
|
||||
export function isRemotePath(src: string) {
|
||||
return /^(http|ftp|https):?\/\//.test(src) || src.startsWith('data:');
|
||||
}
|
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
|
||||
|
@ -4384,6 +4390,9 @@ importers:
|
|||
|
||||
packages/integrations/netlify:
|
||||
dependencies:
|
||||
'@astrojs/underscore-redirects':
|
||||
specifier: ^0.1.0
|
||||
version: link:../../underscore-redirects
|
||||
'@astrojs/webapi':
|
||||
specifier: ^2.2.0
|
||||
version: link:../../webapi
|
||||
|
@ -4836,6 +4845,9 @@ importers:
|
|||
|
||||
packages/integrations/vercel:
|
||||
dependencies:
|
||||
'@astrojs/internal-helpers':
|
||||
specifier: ^0.1.0
|
||||
version: link:../../internal-helpers
|
||||
'@astrojs/webapi':
|
||||
specifier: ^2.2.0
|
||||
version: link:../../webapi
|
||||
|
@ -4895,6 +4907,15 @@ importers:
|
|||
specifier: workspace:*
|
||||
version: link:../../../../../astro
|
||||
|
||||
packages/integrations/vercel/test/fixtures/redirects:
|
||||
dependencies:
|
||||
'@astrojs/vercel':
|
||||
specifier: workspace:*
|
||||
version: link:../../..
|
||||
astro:
|
||||
specifier: workspace:*
|
||||
version: link:../../../../../astro
|
||||
|
||||
packages/integrations/vercel/test/fixtures/serverless-prerender:
|
||||
dependencies:
|
||||
'@astrojs/vercel':
|
||||
|
@ -4953,6 +4974,12 @@ importers:
|
|||
specifier: workspace:*
|
||||
version: link:../../../../../astro
|
||||
|
||||
packages/internal-helpers:
|
||||
devDependencies:
|
||||
astro-scripts:
|
||||
specifier: workspace:*
|
||||
version: link:../../scripts
|
||||
|
||||
packages/markdown/component:
|
||||
devDependencies:
|
||||
'@types/mocha':
|
||||
|
@ -5225,6 +5252,27 @@ importers:
|
|||
specifier: ^9.2.2
|
||||
version: 9.2.2
|
||||
|
||||
packages/underscore-redirects:
|
||||
devDependencies:
|
||||
'@types/chai':
|
||||
specifier: ^4.3.1
|
||||
version: 4.3.3
|
||||
'@types/mocha':
|
||||
specifier: ^9.1.1
|
||||
version: 9.1.1
|
||||
astro:
|
||||
specifier: workspace:*
|
||||
version: link:../astro
|
||||
astro-scripts:
|
||||
specifier: workspace:*
|
||||
version: link:../../scripts
|
||||
chai:
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
mocha:
|
||||
specifier: ^9.2.2
|
||||
version: 9.2.2
|
||||
|
||||
packages/webapi:
|
||||
dependencies:
|
||||
undici:
|
||||
|
@ -8818,11 +8866,12 @@ packages:
|
|||
/@types/chai-subset@1.3.3:
|
||||
resolution: {integrity: sha512-frBecisrNGz+F4T6bcc+NLeolfiojh5FxW2klu669+8BARtyQv2C/GkNW6FUodVe4BroGMP/wER/YDGc7rEllw==}
|
||||
dependencies:
|
||||
'@types/chai': 4.3.3
|
||||
'@types/chai': 4.3.5
|
||||
dev: false
|
||||
|
||||
/@types/chai@4.3.3:
|
||||
resolution: {integrity: sha512-hC7OMnszpxhZPduX+m+nrx+uFoLkWOMiR4oa/AZF3MuSETYTZmFfJAHqZEM8MVlvfG7BEUcgvtwoCTxBp6hm3g==}
|
||||
dev: true
|
||||
|
||||
/@types/chai@4.3.5:
|
||||
resolution: {integrity: sha512-mEo1sAde+UCE6b2hxn332f1g1E8WfYRu6p5SvTKr2ZKC1f7gFJXk4h5PyGP9Dt6gCaG8y8XhwnXWC6Iy2cmBng==}
|
||||
|
|
Loading…
Reference in a new issue