From 57f8d14c027c30919363e12c664ccff4ed64d0fc Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Mon, 5 Jun 2023 09:03:20 -0400 Subject: [PATCH] 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 * Update based on review comments * Update broken test --------- Co-authored-by: Emanuele Stoppa * 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 * Update packages/astro/src/@types/astro.ts Co-authored-by: Chris Swithinbank * Update packages/astro/src/@types/astro.ts Co-authored-by: Chris Swithinbank * Update packages/astro/src/@types/astro.ts Co-authored-by: Chris Swithinbank * Update packages/astro/src/@types/astro.ts Co-authored-by: Chris Swithinbank * Update docs on dynamic restrictions. * Update packages/astro/src/@types/astro.ts Co-authored-by: Sarah Rainsberger * Update packages/astro/src/@types/astro.ts Co-authored-by: Sarah Rainsberger * Code review changes * Document netlify static adapter * Update packages/astro/src/@types/astro.ts Co-authored-by: Sarah Rainsberger * Slight reword * Update .changeset/twenty-suns-vanish.md Co-authored-by: Sarah Rainsberger * Add a note about public/_redirects file * Update packages/astro/src/@types/astro.ts Co-authored-by: Sarah Rainsberger --------- Co-authored-by: Emanuele Stoppa Co-authored-by: Chris Swithinbank Co-authored-by: Sarah Rainsberger Co-authored-by: Nate Moore --- .changeset/chatty-actors-stare.md | 33 ++++ .changeset/fuzzy-ladybugs-jump.md | 7 + .changeset/hip-news-clean.md | 7 + .changeset/twenty-suns-vanish.md | 9 + packages/astro/package.json | 1 + packages/astro/src/@types/astro.ts | 111 ++++++++++++- packages/astro/src/core/app/index.ts | 28 +++- packages/astro/src/core/build/common.ts | 2 + packages/astro/src/core/build/generate.ts | 84 ++++++++-- packages/astro/src/core/build/internal.ts | 16 +- .../src/core/build/plugins/plugin-pages.ts | 12 +- .../src/core/build/plugins/plugin-ssr.ts | 12 +- packages/astro/src/core/config/config.ts | 2 + packages/astro/src/core/config/schema.ts | 7 + packages/astro/src/core/errors/errors-data.ts | 15 +- packages/astro/src/core/path.ts | 82 +--------- .../astro/src/core/redirects/component.ts | 10 ++ packages/astro/src/core/redirects/helpers.ts | 29 ++++ packages/astro/src/core/redirects/index.ts | 3 + packages/astro/src/core/redirects/validate.ts | 13 ++ packages/astro/src/core/render/core.ts | 20 ++- packages/astro/src/core/render/result.ts | 30 ++-- .../astro/src/core/routing/manifest/create.ts | 66 ++++++++ .../astro/src/runtime/server/render/common.ts | 7 + .../src/vite-plugin-astro-server/route.ts | 7 + .../fixtures/ssr-redirect/src/middleware.ts | 13 ++ .../src/pages/articles/[...slug].astro | 25 +++ .../ssr-redirect/src/pages/index.astro | 10 ++ .../ssr-redirect/src/pages/late.astro | 5 +- .../src/pages/middleware-redirect.astro | 10 ++ packages/astro/test/redirects.test.js | 154 ++++++++++++++++++ packages/astro/test/ssr-redirect.test.js | 39 ----- .../astro/test/units/routing/manifest.test.js | 30 ++++ packages/integrations/cloudflare/package.json | 1 + packages/integrations/cloudflare/src/index.ts | 17 +- .../cloudflare/test/directory.test.js | 18 ++ packages/integrations/netlify/README.md | 24 +++ packages/integrations/netlify/package.json | 1 + packages/integrations/netlify/src/index.ts | 1 + .../netlify/src/integration-static.ts | 26 +++ packages/integrations/netlify/src/shared.ts | 140 ++-------------- .../netlify/test/functions/redirects.test.js | 44 +++++ .../fixtures/redirects/src/pages/index.astro | 6 + .../fixtures/redirects/src/pages/nope.astro | 3 + .../src/pages/team/articles/[...slug].astro | 25 +++ .../netlify/test/static/redirects.test.js | 43 +++++ .../netlify/test/static/test-utils.js | 29 ++++ packages/integrations/vercel/package.json | 1 + .../integrations/vercel/src/lib/redirects.ts | 58 +++++-- .../integrations/vercel/src/static/adapter.ts | 1 + .../test/fixtures/redirects/astro.config.mjs | 9 + .../test/fixtures/redirects/package.json | 9 + .../fixtures/redirects/src/pages/index.astro | 8 + .../src/pages/team/articles/[...slug].astro | 25 +++ .../vercel/test/redirects.test.js | 59 +++++++ packages/internal-helpers/package.json | 41 +++++ packages/internal-helpers/readme.md | 3 + packages/internal-helpers/src/path.ts | 86 ++++++++++ packages/internal-helpers/tsconfig.json | 10 ++ packages/underscore-redirects/package.json | 42 +++++ packages/underscore-redirects/readme.md | 3 + packages/underscore-redirects/src/astro.ts | 145 +++++++++++++++++ packages/underscore-redirects/src/index.ts | 8 + packages/underscore-redirects/src/print.ts | 36 ++++ .../underscore-redirects/src/redirects.ts | 69 ++++++++ .../underscore-redirects/test/astro.test.js | 25 +++ .../underscore-redirects/test/print.test.js | 44 +++++ .../underscore-redirects/test/weight.test.js | 32 ++++ packages/underscore-redirects/tsconfig.json | 10 ++ pnpm-lock.yaml | 51 +++++- 70 files changed, 1733 insertions(+), 319 deletions(-) create mode 100644 .changeset/chatty-actors-stare.md create mode 100644 .changeset/fuzzy-ladybugs-jump.md create mode 100644 .changeset/hip-news-clean.md create mode 100644 .changeset/twenty-suns-vanish.md create mode 100644 packages/astro/src/core/redirects/component.ts create mode 100644 packages/astro/src/core/redirects/helpers.ts create mode 100644 packages/astro/src/core/redirects/index.ts create mode 100644 packages/astro/src/core/redirects/validate.ts create mode 100644 packages/astro/test/fixtures/ssr-redirect/src/middleware.ts create mode 100644 packages/astro/test/fixtures/ssr-redirect/src/pages/articles/[...slug].astro create mode 100644 packages/astro/test/fixtures/ssr-redirect/src/pages/index.astro create mode 100644 packages/astro/test/fixtures/ssr-redirect/src/pages/middleware-redirect.astro create mode 100644 packages/astro/test/redirects.test.js delete mode 100644 packages/astro/test/ssr-redirect.test.js create mode 100644 packages/integrations/netlify/src/integration-static.ts create mode 100644 packages/integrations/netlify/test/functions/redirects.test.js create mode 100644 packages/integrations/netlify/test/static/fixtures/redirects/src/pages/index.astro create mode 100644 packages/integrations/netlify/test/static/fixtures/redirects/src/pages/nope.astro create mode 100644 packages/integrations/netlify/test/static/fixtures/redirects/src/pages/team/articles/[...slug].astro create mode 100644 packages/integrations/netlify/test/static/redirects.test.js create mode 100644 packages/integrations/netlify/test/static/test-utils.js create mode 100644 packages/integrations/vercel/test/fixtures/redirects/astro.config.mjs create mode 100644 packages/integrations/vercel/test/fixtures/redirects/package.json create mode 100644 packages/integrations/vercel/test/fixtures/redirects/src/pages/index.astro create mode 100644 packages/integrations/vercel/test/fixtures/redirects/src/pages/team/articles/[...slug].astro create mode 100644 packages/integrations/vercel/test/redirects.test.js create mode 100644 packages/internal-helpers/package.json create mode 100644 packages/internal-helpers/readme.md create mode 100644 packages/internal-helpers/src/path.ts create mode 100644 packages/internal-helpers/tsconfig.json create mode 100644 packages/underscore-redirects/package.json create mode 100644 packages/underscore-redirects/readme.md create mode 100644 packages/underscore-redirects/src/astro.ts create mode 100644 packages/underscore-redirects/src/index.ts create mode 100644 packages/underscore-redirects/src/print.ts create mode 100644 packages/underscore-redirects/src/redirects.ts create mode 100644 packages/underscore-redirects/test/astro.test.js create mode 100644 packages/underscore-redirects/test/print.test.js create mode 100644 packages/underscore-redirects/test/weight.test.js create mode 100644 packages/underscore-redirects/tsconfig.json diff --git a/.changeset/chatty-actors-stare.md b/.changeset/chatty-actors-stare.md new file mode 100644 index 000000000000..e8d42848a2b0 --- /dev/null +++ b/.changeset/chatty-actors-stare.md @@ -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 `` tag. Adapters can also support redirect routes and create configuration for real HTTP-level redirects in production. diff --git a/.changeset/fuzzy-ladybugs-jump.md b/.changeset/fuzzy-ladybugs-jump.md new file mode 100644 index 000000000000..fecabbeaca53 --- /dev/null +++ b/.changeset/fuzzy-ladybugs-jump.md @@ -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. diff --git a/.changeset/hip-news-clean.md b/.changeset/hip-news-clean.md new file mode 100644 index 000000000000..2b0dc1db10aa --- /dev/null +++ b/.changeset/hip-news-clean.md @@ -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. diff --git a/.changeset/twenty-suns-vanish.md b/.changeset/twenty-suns-vanish.md new file mode 100644 index 000000000000..987876cc8200 --- /dev/null +++ b/.changeset/twenty-suns-vanish.md @@ -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. diff --git a/packages/astro/package.json b/packages/astro/package.json index 9016f86bd5f3..333b88a985d4 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -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", diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index e1e71c5017d2..e38d33f86d5e 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -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 [`` 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 = Record> { /** @@ -1607,7 +1701,7 @@ interface AstroSharedContext = Record = () => Promise; export type MiddlewareHandler = ( context: APIContext, next: MiddlewareNext -) => Promise | Promise | void; +) => Promise | R | Promise | void; export type MiddlewareResponseHandler = MiddlewareHandler; export type MiddlewareEndpointHandler = MiddlewareHandler; @@ -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 & { diff --git a/packages/astro/src/core/app/index.ts b/packages/astro/src/core/app/index.ts index 90e17f438cc6..5a849153de4a 100644 --- a/packages/astro/src/core/app/index.ts +++ b/packages/astro/src/core/app/index.ts @@ -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 { + 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, diff --git a/packages/astro/src/core/build/common.ts b/packages/astro/src/core/build/common.ts index 74be830f0356..ed0c08d5bc51 100644 --- a/packages/astro/src/core/build/common.ts +++ b/packages/astro/src/core/build/common.ts @@ -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)) { diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index 9a1bf647dfb1..e2e3a24f00f0 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -12,6 +12,8 @@ import type { EndpointOutput, ImageTransform, MiddlewareResponseHandler, + RedirectRouteData, + RouteData, RouteType, SSRError, SSRLoadedRenderer, @@ -24,6 +26,7 @@ import { eachPageDataFromEntryPoint, hasPrerenderedPages, type BuildInternals, + eachRedirectPageData, } from '../../core/build/internal.js'; import { prependForwardSlash, @@ -39,7 +42,8 @@ import { debug, info } from '../logger/core.js'; import { callMiddleware } from '../middleware/callMiddleware.js'; import { createEnvironment, createRenderContext, renderPage } from '../render/index.js'; import { callGetStaticPaths } from '../render/route-cache.js'; -import { +import { getRedirectLocationOrThrow, RedirectComponentInstance, routeIsRedirect } from '../redirects/index.js'; +import { createAssetLink, createModuleScriptsSet, createStylesheetElementSet, @@ -48,7 +52,7 @@ import { createRequest } from '../request.js'; import { matchRoute } from '../routing/match.js'; import { getOutputFilename } from '../util.js'; import { getOutDirWithinCwd, getOutFile, getOutFolder } from './common.js'; -import { cssOrder, getPageDataByComponent, mergeInlineCss } from './internal.js'; +import { cssOrder, getPageDataByComponent, mergeInlineCss, getEntryFilePathFromComponentPath } from './internal.js'; import type { PageBuildData, SinglePageBuiltModule, @@ -57,6 +61,38 @@ import type { } from './types'; import { getTimeStat } from './util.js'; +const StaticMiddlewareInstance: AstroMiddlewareInstance = { + 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 { + 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 ) { + 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 = ` +Redirecting to: ${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); diff --git a/packages/astro/src/core/build/internal.ts b/packages/astro/src/core/build/internal.ts index d1e2404d6d8a..2383dd14af54 100644 --- a/packages/astro/src/core/build/internal.ts +++ b/packages/astro/src/core/build/internal.ts @@ -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); +} diff --git a/packages/astro/src/core/build/plugins/plugin-pages.ts b/packages/astro/src/core/build/plugins/plugin-pages.ts index 4428d8ac6340..79f19cd06b6e 100644 --- a/packages/astro/src/core/build/plugins/plugin-pages.ts +++ b/packages/astro/src/core/build/plugins/plugin-pages.ts @@ -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 = 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 diff --git a/packages/astro/src/core/build/plugins/plugin-ssr.ts b/packages/astro/src/core/build/plugins/plugin-ssr.ts index accdd01ad6a9..50c08c642570 100644 --- a/packages/astro/src/core/build/plugins/plugin-ssr.ts +++ b/packages/astro/src/core/build/plugins/plugin-ssr.ts @@ -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++; } diff --git a/packages/astro/src/core/config/config.ts b/packages/astro/src/core/config/config.ts index 0ca13a220936..81ec93d9b395 100644 --- a/packages/astro/src/core/config/config.ts +++ b/packages/astro/src/core/config/config.ts @@ -106,6 +106,8 @@ export function resolveFlags(flags: Partial): CLIFlags { typeof flags.experimentalAssets === 'boolean' ? flags.experimentalAssets : undefined, experimentalMiddleware: typeof flags.experimentalMiddleware === 'boolean' ? flags.experimentalMiddleware : undefined, + experimentalRedirects: + typeof flags.experimentalRedirects === 'boolean' ? flags.experimentalRedirects : undefined }; } diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts index 1c246a4a8433..cb92e297d47e 100644 --- a/packages/astro/src/core/config/schema.ts +++ b/packages/astro/src/core/config/schema.ts @@ -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({}), diff --git a/packages/astro/src/core/errors/errors-data.ts b/packages/astro/src/core/errors/errors-data.ts index 49ada2a1927d..13d4b57be166 100644 --- a/packages/astro/src/core/errors/errors-data.ts +++ b/packages/astro/src/core/errors/errors-data.ts @@ -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 /** diff --git a/packages/astro/src/core/path.ts b/packages/astro/src/core/path.ts index cbf959f69f57..cbc3b6900e29 100644 --- a/packages/astro/src/core/path.ts +++ b/packages/astro/src/core/path.ts @@ -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'; diff --git a/packages/astro/src/core/redirects/component.ts b/packages/astro/src/core/redirects/component.ts new file mode 100644 index 000000000000..1471af1f40fd --- /dev/null +++ b/packages/astro/src/core/redirects/component.ts @@ -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 + }); + } +}; diff --git a/packages/astro/src/core/redirects/helpers.ts b/packages/astro/src/core/redirects/helpers.ts new file mode 100644 index 000000000000..c5c54ee35d98 --- /dev/null +++ b/packages/astro/src/core/redirects/helpers.ts @@ -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; +} diff --git a/packages/astro/src/core/redirects/index.ts b/packages/astro/src/core/redirects/index.ts new file mode 100644 index 000000000000..f494230f56d8 --- /dev/null +++ b/packages/astro/src/core/redirects/index.ts @@ -0,0 +1,3 @@ +export { getRedirectLocationOrThrow } from './validate.js'; +export { routeIsRedirect, redirectRouteGenerate, redirectRouteStatus } from './helpers.js'; +export { RedirectComponentInstance } from './component.js'; diff --git a/packages/astro/src/core/redirects/validate.ts b/packages/astro/src/core/redirects/validate.ts new file mode 100644 index 000000000000..523d9f5783e8 --- /dev/null +++ b/packages/astro/src/core/redirects/validate.ts @@ -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; +} diff --git a/packages/astro/src/core/render/core.ts b/packages/astro/src/core/render/core.ts index e82c97e0bbb1..a6c503a38ef9 100644 --- a/packages/astro/src/core/render/core.ts +++ b/packages/astro/src/core/render/core.ts @@ -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) diff --git a/packages/astro/src/core/render/result.ts b/packages/astro/src/core/render/result.ts index 41a2fb9a44f0..35155306c18e 100644 --- a/packages/astro/src/core/render/result.ts +++ b/packages/astro/src/core/render/result.ts @@ -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'], }; diff --git a/packages/astro/src/core/routing/manifest/create.ts b/packages/astro/src/core/routing/manifest/create.ts index a8680a104d46..a193621299ac 100644 --- a/packages/astro/src/core/routing/manifest/create.ts +++ b/packages/astro/src/core/routing/manifest/create.ts @@ -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, }; } + diff --git a/packages/astro/src/runtime/server/render/common.ts b/packages/astro/src/runtime/server/render/common.ts index e9be3bf8ba04..ad6330f71163 100644 --- a/packages/astro/src/runtime/server/render/common.ts +++ b/packages/astro/src/runtime/server/render/common.ts @@ -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()); diff --git a/packages/astro/src/vite-plugin-astro-server/route.ts b/packages/astro/src/vite-plugin-astro-server/route.ts index dae4162296f6..ecf9a0c35f26 100644 --- a/packages/astro/src/vite-plugin-astro-server/route.ts +++ b/packages/astro/src/vite-plugin-astro-server/route.ts @@ -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; diff --git a/packages/astro/test/fixtures/ssr-redirect/src/middleware.ts b/packages/astro/test/fixtures/ssr-redirect/src/middleware.ts new file mode 100644 index 000000000000..5f8243a43d09 --- /dev/null +++ b/packages/astro/test/fixtures/ssr-redirect/src/middleware.ts @@ -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(); +}); diff --git a/packages/astro/test/fixtures/ssr-redirect/src/pages/articles/[...slug].astro b/packages/astro/test/fixtures/ssr-redirect/src/pages/articles/[...slug].astro new file mode 100644 index 000000000000..716d3bd5d4af --- /dev/null +++ b/packages/astro/test/fixtures/ssr-redirect/src/pages/articles/[...slug].astro @@ -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; +--- + + + { title } + + +

{ title }

+ + diff --git a/packages/astro/test/fixtures/ssr-redirect/src/pages/index.astro b/packages/astro/test/fixtures/ssr-redirect/src/pages/index.astro new file mode 100644 index 000000000000..e06d49b853b1 --- /dev/null +++ b/packages/astro/test/fixtures/ssr-redirect/src/pages/index.astro @@ -0,0 +1,10 @@ +--- +--- + + + Testing + + +

Testing

+ + diff --git a/packages/astro/test/fixtures/ssr-redirect/src/pages/late.astro b/packages/astro/test/fixtures/ssr-redirect/src/pages/late.astro index dcfedb8da6a1..62d35411927e 100644 --- a/packages/astro/test/fixtures/ssr-redirect/src/pages/late.astro +++ b/packages/astro/test/fixtures/ssr-redirect/src/pages/late.astro @@ -1,5 +1,6 @@ --- import Redirect from '../components/redirect.astro'; +const staticMode = import.meta.env.STATIC_MODE; --- @@ -7,6 +8,8 @@ import Redirect from '../components/redirect.astro';

Testing

- + { !staticMode ? ( + + ) :
} diff --git a/packages/astro/test/fixtures/ssr-redirect/src/pages/middleware-redirect.astro b/packages/astro/test/fixtures/ssr-redirect/src/pages/middleware-redirect.astro new file mode 100644 index 000000000000..04f62cd89d45 --- /dev/null +++ b/packages/astro/test/fixtures/ssr-redirect/src/pages/middleware-redirect.astro @@ -0,0 +1,10 @@ +--- +--- + + + This page should have been redirected + + +

This page should have been redirected

+ + diff --git a/packages/astro/test/redirects.test.js b/packages/astro/test/redirects.test.js new file mode 100644 index 000000000000..d5b3e5663997 --- /dev/null +++ b/packages/astro/test/redirects.test.js @@ -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'); + }) + }) +}); diff --git a/packages/astro/test/ssr-redirect.test.js b/packages/astro/test/ssr-redirect.test.js deleted file mode 100644 index bb4f747cdd62..000000000000 --- a/packages/astro/test/ssr-redirect.test.js +++ /dev/null @@ -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.' - ); - } - }); -}); diff --git a/packages/astro/test/units/routing/manifest.test.js b/packages/astro/test/units/routing/manifest.test.js index 05789d7526ff..4a8a96175d99 100644 --- a/packages/astro/test/units/routing/manifest.test.js +++ b/packages/astro/test/units/routing/manifest.test.js @@ -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': `

test

`, + '/src/pages/blog/contributing.astro': `

test

`, + }, + 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]'); + }) }); diff --git a/packages/integrations/cloudflare/package.json b/packages/integrations/cloudflare/package.json index b7add31777d3..b6f2caef26b1 100644 --- a/packages/integrations/cloudflare/package.json +++ b/packages/integrations/cloudflare/package.json @@ -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" }, diff --git a/packages/integrations/cloudflare/src/index.ts b/packages/integrations/cloudflare/src/index.ts index 2f6b36e8718a..ca755432ee8b 100644 --- a/packages/integrations/cloudflare/src/index.ts +++ b/packages/integrations/cloudflare/src/index.ts @@ -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( diff --git a/packages/integrations/cloudflare/test/directory.test.js b/packages/integrations/cloudflare/test/directory.test.js index 67693310a7e0..e88019401dca 100644 --- a/packages/integrations/cloudflare/test/directory.test.js +++ b/packages/integrations/cloudflare/test/directory.test.js @@ -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); + } + }); }); diff --git a/packages/integrations/netlify/README.md b/packages/integrations/netlify/README.md index ec72f2a2cad6..cee5fa5c2962 100644 --- a/packages/integrations/netlify/README.md +++ b/packages/integrations/netlify/README.md @@ -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/) diff --git a/packages/integrations/netlify/package.json b/packages/integrations/netlify/package.json index 26ba3873fe92..64a106c8478f 100644 --- a/packages/integrations/netlify/package.json +++ b/packages/integrations/netlify/package.json @@ -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" diff --git a/packages/integrations/netlify/src/index.ts b/packages/integrations/netlify/src/index.ts index fd7fd5fed05d..510e560f1128 100644 --- a/packages/integrations/netlify/src/index.ts +++ b/packages/integrations/netlify/src/index.ts @@ -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'; diff --git a/packages/integrations/netlify/src/integration-static.ts b/packages/integrations/netlify/src/integration-static.ts new file mode 100644 index 000000000000..8814f9d2af4d --- /dev/null +++ b/packages/integrations/netlify/src/integration-static.ts @@ -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'); + } + } + }; +} diff --git a/packages/integrations/netlify/src/shared.ts b/packages/integrations/netlify/src/shared.ts index 78a61a800ab4..d452ada10252 100644 --- a/packages/integrations/netlify/src/shared.ts +++ b/packages/integrations/netlify/src/shared.ts @@ -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'); } diff --git a/packages/integrations/netlify/test/functions/redirects.test.js b/packages/integrations/netlify/test/functions/redirects.test.js new file mode 100644 index 000000000000..8a6d36694894 --- /dev/null +++ b/packages/integrations/netlify/test/functions/redirects.test.js @@ -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', + ]); + }); +}); diff --git a/packages/integrations/netlify/test/static/fixtures/redirects/src/pages/index.astro b/packages/integrations/netlify/test/static/fixtures/redirects/src/pages/index.astro new file mode 100644 index 000000000000..53e029f04983 --- /dev/null +++ b/packages/integrations/netlify/test/static/fixtures/redirects/src/pages/index.astro @@ -0,0 +1,6 @@ + +Testing + +

Testing

+ + diff --git a/packages/integrations/netlify/test/static/fixtures/redirects/src/pages/nope.astro b/packages/integrations/netlify/test/static/fixtures/redirects/src/pages/nope.astro new file mode 100644 index 000000000000..f48d767ee180 --- /dev/null +++ b/packages/integrations/netlify/test/static/fixtures/redirects/src/pages/nope.astro @@ -0,0 +1,3 @@ +--- +return Astro.redirect('/'); +--- diff --git a/packages/integrations/netlify/test/static/fixtures/redirects/src/pages/team/articles/[...slug].astro b/packages/integrations/netlify/test/static/fixtures/redirects/src/pages/team/articles/[...slug].astro new file mode 100644 index 000000000000..716d3bd5d4af --- /dev/null +++ b/packages/integrations/netlify/test/static/fixtures/redirects/src/pages/team/articles/[...slug].astro @@ -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; +--- + + + { title } + + +

{ title }

+ + diff --git a/packages/integrations/netlify/test/static/redirects.test.js b/packages/integrations/netlify/test/static/redirects.test.js new file mode 100644 index 000000000000..0b153b31c0fd --- /dev/null +++ b/packages/integrations/netlify/test/static/redirects.test.js @@ -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', + ]); + }); +}); diff --git a/packages/integrations/netlify/test/static/test-utils.js b/packages/integrations/netlify/test/static/test-utils.js new file mode 100644 index 000000000000..02b5d2ad90b7 --- /dev/null +++ b/packages/integrations/netlify/test/static/test-utils.js @@ -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) + ), + }, + }, + }, + }); + }, + }, + }; +} diff --git a/packages/integrations/vercel/package.json b/packages/integrations/vercel/package.json index d57b882c1cac..cd2df20d7cc2 100644 --- a/packages/integrations/vercel/package.json +++ b/packages/integrations/vercel/package.json @@ -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", diff --git a/packages/integrations/vercel/src/lib/redirects.ts b/packages/integrations/vercel/src/lib/redirects.ts index c11d748024fe..1ec19bfac36a 100644 --- a/packages/integrations/vercel/src/lib/redirects.ts +++ b/packages/integrations/vercel/src/lib/redirects.ts @@ -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; - 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; + 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, + src: config.base + getMatchPattern(route.segments), + headers: { Location: getRedirectLocation(route, config) }, + status: getRedirectStatus(route) }); + } else if (route.type === 'page') { + if (config.trailingSlash === 'always') { + redirects.push({ + src: config.base + getMatchPattern(route.segments), + headers: { Location: config.base + getReplacePattern(route.segments) + '/' }, + status: 308, + }); + } else if (config.trailingSlash === 'never') { + redirects.push({ + src: config.base + getMatchPattern(route.segments) + '/', + headers: { Location: config.base + getReplacePattern(route.segments) }, + status: 308, + }); + } } } diff --git a/packages/integrations/vercel/src/static/adapter.ts b/packages/integrations/vercel/src/static/adapter.ts index 0b3579cdd11d..e0cc14322fcb 100644 --- a/packages/integrations/vercel/src/static/adapter.ts +++ b/packages/integrations/vercel/src/static/adapter.ts @@ -43,6 +43,7 @@ export default function vercelStatic({ outDir, build: { format: 'directory', + redirects: false, }, vite: { define: viteDefine, diff --git a/packages/integrations/vercel/test/fixtures/redirects/astro.config.mjs b/packages/integrations/vercel/test/fixtures/redirects/astro.config.mjs new file mode 100644 index 000000000000..a38be5065f8e --- /dev/null +++ b/packages/integrations/vercel/test/fixtures/redirects/astro.config.mjs @@ -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 + } +}); diff --git a/packages/integrations/vercel/test/fixtures/redirects/package.json b/packages/integrations/vercel/test/fixtures/redirects/package.json new file mode 100644 index 000000000000..d7dcc54718c6 --- /dev/null +++ b/packages/integrations/vercel/test/fixtures/redirects/package.json @@ -0,0 +1,9 @@ +{ + "name": "@test/astro-vercel-redirects", + "version": "0.0.0", + "private": true, + "dependencies": { + "@astrojs/vercel": "workspace:*", + "astro": "workspace:*" + } +} diff --git a/packages/integrations/vercel/test/fixtures/redirects/src/pages/index.astro b/packages/integrations/vercel/test/fixtures/redirects/src/pages/index.astro new file mode 100644 index 000000000000..9c077e2a381b --- /dev/null +++ b/packages/integrations/vercel/test/fixtures/redirects/src/pages/index.astro @@ -0,0 +1,8 @@ + + + Testing + + +

Testing

+ + diff --git a/packages/integrations/vercel/test/fixtures/redirects/src/pages/team/articles/[...slug].astro b/packages/integrations/vercel/test/fixtures/redirects/src/pages/team/articles/[...slug].astro new file mode 100644 index 000000000000..716d3bd5d4af --- /dev/null +++ b/packages/integrations/vercel/test/fixtures/redirects/src/pages/team/articles/[...slug].astro @@ -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; +--- + + + { title } + + +

{ title }

+ + diff --git a/packages/integrations/vercel/test/redirects.test.js b/packages/integrations/vercel/test/redirects.test.js new file mode 100644 index 000000000000..0d54589fc564 --- /dev/null +++ b/packages/integrations/vercel/test/redirects.test.js @@ -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); + }); +}); diff --git a/packages/internal-helpers/package.json b/packages/internal-helpers/package.json new file mode 100644 index 000000000000..29df367262cb --- /dev/null +++ b/packages/internal-helpers/package.json @@ -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" + ] +} diff --git a/packages/internal-helpers/readme.md b/packages/internal-helpers/readme.md new file mode 100644 index 000000000000..283913dc5708 --- /dev/null +++ b/packages/internal-helpers/readme.md @@ -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. diff --git a/packages/internal-helpers/src/path.ts b/packages/internal-helpers/src/path.ts new file mode 100644 index 000000000000..2f2a974c43f6 --- /dev/null +++ b/packages/internal-helpers/src/path.ts @@ -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:'); +} diff --git a/packages/internal-helpers/tsconfig.json b/packages/internal-helpers/tsconfig.json new file mode 100644 index 000000000000..569016e9d844 --- /dev/null +++ b/packages/internal-helpers/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src"], + "compilerOptions": { + "allowJs": true, + "target": "ES2021", + "module": "ES2022", + "outDir": "./dist" + } +} diff --git a/packages/underscore-redirects/package.json b/packages/underscore-redirects/package.json new file mode 100644 index 000000000000..1c9643dde981 --- /dev/null +++ b/packages/underscore-redirects/package.json @@ -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" + ] +} diff --git a/packages/underscore-redirects/readme.md b/packages/underscore-redirects/readme.md new file mode 100644 index 000000000000..8eb29603b00a --- /dev/null +++ b/packages/underscore-redirects/readme.md @@ -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. diff --git a/packages/underscore-redirects/src/astro.ts b/packages/underscore-redirects/src/astro.ts new file mode 100644 index 000000000000..db84bb6e7f3d --- /dev/null +++ b/packages/underscore-redirects/src/astro.ts @@ -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; + 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; +} diff --git a/packages/underscore-redirects/src/index.ts b/packages/underscore-redirects/src/index.ts new file mode 100644 index 000000000000..07e240218a0a --- /dev/null +++ b/packages/underscore-redirects/src/index.ts @@ -0,0 +1,8 @@ +export { + Redirects, + type RedirectDefinition +} from './redirects.js'; + +export { + createRedirectsFromAstroRoutes +} from './astro.js'; diff --git a/packages/underscore-redirects/src/print.ts b/packages/underscore-redirects/src/print.ts new file mode 100644 index 000000000000..a0d9564b907a --- /dev/null +++ b/packages/underscore-redirects/src/print.ts @@ -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; +} diff --git a/packages/underscore-redirects/src/redirects.ts b/packages/underscore-redirects/src/redirects.ts new file mode 100644 index 000000000000..c46d41628251 --- /dev/null +++ b/packages/underscore-redirects/src/redirects.ts @@ -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(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; +} diff --git a/packages/underscore-redirects/test/astro.test.js b/packages/underscore-redirects/test/astro.test.js new file mode 100644 index 000000000000..15a8aa0584d3 --- /dev/null +++ b/packages/underscore-redirects/test/astro.test.js @@ -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); + }); +}); diff --git a/packages/underscore-redirects/test/print.test.js b/packages/underscore-redirects/test/print.test.js new file mode 100644 index 000000000000..c04a8e9a9ebf --- /dev/null +++ b/packages/underscore-redirects/test/print.test.js @@ -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', + ]) + }); +}); diff --git a/packages/underscore-redirects/test/weight.test.js b/packages/underscore-redirects/test/weight.test.js new file mode 100644 index 000000000000..0c6014427e74 --- /dev/null +++ b/packages/underscore-redirects/test/weight.test.js @@ -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'); + }); +}); diff --git a/packages/underscore-redirects/tsconfig.json b/packages/underscore-redirects/tsconfig.json new file mode 100644 index 000000000000..569016e9d844 --- /dev/null +++ b/packages/underscore-redirects/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src"], + "compilerOptions": { + "allowJs": true, + "target": "ES2021", + "module": "ES2022", + "outDir": "./dist" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 27042e66bfe1..107b9e2c0e24 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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==}