From 041fdd5c89920f7ccf944b095f29e451f78b0e28 Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Wed, 31 Jan 2024 13:55:03 +0000 Subject: [PATCH] feat(i18n): domain support (#9143) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * i18n(domains): validation and updated logic (#9099) * feat(i18n): domain with lookup table (#9112) * chore: add changelog, fix types and enable experimental support in node/vercel * rebase and update lock file * chore: fix failing test * Apply suggestions from code review Co-authored-by: Sarah Rainsberger Co-authored-by: Matthew Phillips * Update .changeset/tidy-carrots-jump.md Co-authored-by: Sarah Rainsberger * wip * chore: rebase, conflicts and tests * update lock file * chore: correct configuration * chore: correct configuration * fix: regressions * chore: fix conflicts and add more tests * chore: add more validation * chore: more tests and add more restrictions * fix changeset * change and revert adapters * add another restriction * lock file update * Update packages/astro/src/@types/astro.ts Co-authored-by: Sarah Rainsberger * Update packages/astro/src/@types/astro.ts Co-authored-by: Bjorn Lu * wat * fix syntax error * fix config example * Fix for #9673 (#9680) * Fix for #9673 * 🦋 add changeset file * Update breezy-plants-smoke.md Co-authored-by: Florian Lefebvre * ⚡️ simplified normalizeConfigPath --------- Co-authored-by: Florian Lefebvre * Fix env var replacement for export const prerender (#9807) * feat(alpinejs): allow customizing the Alpine instance (#9751) * feat(alpinejs): allows customzing the Alpine instance * chore: add e2e tests * fix: rename script * Update index.ts * fix: lockfile * [ci] format * chore: use correct lock file * chore: rebase * fix regressions in tests * fix regressions in tests * fix build * add description * fix missing types * chore: fix tests, again :D * eslint * Update packages/astro/src/@types/astro.ts Co-authored-by: Nate Moore * chore: address feedback * chore: fix regressions * chore: refactor naming * Update packages/astro/src/core/app/index.ts Co-authored-by: Bjorn Lu * chore: address feedback * update lock file * chore: infer routing from options, not strategy * merge from main * merge from main * Experimental support in vercel adapter * Update packages/astro/src/@types/astro.ts Co-authored-by: Sarah Rainsberger * Update packages/astro/src/@types/astro.ts Co-authored-by: Sarah Rainsberger * Update .changeset/tidy-carrots-jump.md Co-authored-by: Sarah Rainsberger * better changesets * Updates both experimental.i18nDomains and i18ndomains for experimental strategy * fix link syntax * consistent tabs/spaces * Update packages/astro/src/@types/astro.ts Co-authored-by: Sarah Rainsberger * apply suggestion --------- Co-authored-by: Sarah Rainsberger Co-authored-by: Matthew Phillips Co-authored-by: Bjorn Lu Co-authored-by: Lou Cyx Co-authored-by: Florian Lefebvre Co-authored-by: Florian Lefebvre Co-authored-by: Nate Moore --- .changeset/four-masks-smell.md | 6 + .changeset/little-panthers-relate.md | 5 + .changeset/tidy-carrots-jump.md | 52 + packages/astro/src/@types/astro.ts | 87 +- packages/astro/src/core/app/index.ts | 81 +- packages/astro/src/core/app/types.ts | 1 + packages/astro/src/core/build/generate.ts | 13 +- .../src/core/build/plugins/plugin-manifest.ts | 20 + packages/astro/src/core/config/schema.ts | 136 +- packages/astro/src/core/errors/errors-data.ts | 12 + packages/astro/src/core/render/context.ts | 5 +- packages/astro/src/i18n/index.ts | 90 +- packages/astro/src/i18n/middleware.ts | 158 +- packages/astro/src/i18n/vite-plugin-i18n.ts | 12 +- .../integrations/astroFeaturesValidation.ts | 21 + packages/astro/src/integrations/index.ts | 10 +- packages/astro/src/virtual-modules/i18n.ts | 15 +- .../src/vite-plugin-astro-server/plugin.ts | 1 + .../src/vite-plugin-astro-server/route.ts | 2 +- .../astro.config.mjs | 2 +- .../i18n-routing-subdomain/astro.config.mjs | 24 + .../i18n-routing-subdomain/package.json | 8 + .../src/pages/en/blog/[id].astro | 18 + .../src/pages/en/index.astro | 8 + .../src/pages/en/start.astro | 8 + .../src/pages/index.astro | 19 + .../src/pages/pt/blog/[id].astro | 18 + .../src/pages/pt/start.astro | 8 + .../fixtures/i18n-routing/astro.config.mjs | 2 +- .../i18n-routing/src/pages/dynamic/[id].astro | 2 +- .../src/pages/virtual-module.astro | 5 +- packages/astro/test/i18n-routing.test.js | 91 ++ .../test/units/config/config-validate.test.js | 127 ++ .../astro/test/units/i18n/astro_i18n.test.js | 1313 +++++++++++------ .../astro/test/units/integrations/api.test.js | 12 + .../create-astro/src/actions/typescript.ts | 1 - packages/integrations/node/src/index.ts | 1 + .../vercel/src/serverless/adapter.ts | 1 + pnpm-lock.yaml | 6 + 39 files changed, 1841 insertions(+), 560 deletions(-) create mode 100644 .changeset/four-masks-smell.md create mode 100644 .changeset/little-panthers-relate.md create mode 100644 .changeset/tidy-carrots-jump.md create mode 100644 packages/astro/test/fixtures/i18n-routing-subdomain/astro.config.mjs create mode 100644 packages/astro/test/fixtures/i18n-routing-subdomain/package.json create mode 100644 packages/astro/test/fixtures/i18n-routing-subdomain/src/pages/en/blog/[id].astro create mode 100644 packages/astro/test/fixtures/i18n-routing-subdomain/src/pages/en/index.astro create mode 100644 packages/astro/test/fixtures/i18n-routing-subdomain/src/pages/en/start.astro create mode 100644 packages/astro/test/fixtures/i18n-routing-subdomain/src/pages/index.astro create mode 100644 packages/astro/test/fixtures/i18n-routing-subdomain/src/pages/pt/blog/[id].astro create mode 100644 packages/astro/test/fixtures/i18n-routing-subdomain/src/pages/pt/start.astro diff --git a/.changeset/four-masks-smell.md b/.changeset/four-masks-smell.md new file mode 100644 index 000000000000..e0207fbe40d0 --- /dev/null +++ b/.changeset/four-masks-smell.md @@ -0,0 +1,6 @@ +--- +"@astrojs/vercel": minor +"@astrojs/node": minor +--- + +Adds experimental support for internationalization domains diff --git a/.changeset/little-panthers-relate.md b/.changeset/little-panthers-relate.md new file mode 100644 index 000000000000..5fb2c523f237 --- /dev/null +++ b/.changeset/little-panthers-relate.md @@ -0,0 +1,5 @@ +--- +"astro": patch +--- + +Fixes an issue where the function `getLocaleRelativeUrlList` wasn't normalising the paths by default diff --git a/.changeset/tidy-carrots-jump.md b/.changeset/tidy-carrots-jump.md new file mode 100644 index 000000000000..e18f235e3aaf --- /dev/null +++ b/.changeset/tidy-carrots-jump.md @@ -0,0 +1,52 @@ +--- +'astro': minor +--- + +Adds experimental support for a new i18n domain routing option (`"domains"`) that allows you to configure different domains for individual locales in entirely server-rendered projects. + +To enable this in your project, first configure your `server`-rendered project's i18n routing with your preferences if you have not already done so. Then, set the `experimental.i18nDomains` flag to `true` and add `i18n.domains` to map any of your supported `locales` to custom URLs: + +```js +//astro.config.mjs" +import { defineConfig } from "astro/config" +export default defineConfig({ + site: "https://example.com", + output: "server", // required, with no prerendered pages + adapter: node({ + mode: 'standalone', + }), + i18n: { + defaultLocale: "en", + locales: ["es", "en", "fr", "ja"], + routing: { + prefixDefaultLocale: false + }, + domains: { + fr: "https://fr.example.com", + es: "https://example.es" + } + }, + experimental: { + i18nDomains: true + } +}) +``` +With `"domains"` configured, the URLs emitted by `getAbsoluteLocaleUrl()` and `getAbsoluteLocaleUrlList()` will use the options set in `i18n.domains`. + +```js +import { getAbsoluteLocaleUrl } from "astro:i18n"; + +getAbsoluteLocaleUrl("en", "about"); // will return "https://example.com/about" +getAbsoluteLocaleUrl("fr", "about"); // will return "https://fr.example.com/about" +getAbsoluteLocaleUrl("es", "about"); // will return "https://example.es/about" +getAbsoluteLocaleUrl("ja", "about"); // will return "https://example.com/ja/about" +``` + +Similarly, your localized files will create routes at corresponding URLs: + +- The file `/en/about.astro` will be reachable at the URL `https://example.com/about`. +- The file `/fr/about.astro` will be reachable at the URL `https://fr.example.com/about`. +- The file `/es/about.astro` will be reachable at the URL `https://example.es/about`. +- The file `/ja/about.astro` will be reachable at the URL `https://example.com/ja/about`. + +See our [Internationalization Guide](https://docs.astro.build/en/guides/internationalization/#domains-experimental) for more details and limitations on this experimental routing feature. diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 17c087f2d670..0c15a9649fce 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -1532,6 +1532,45 @@ export interface AstroUserConfig { * - `"pathanme": The strategy is applied to the pathname of the URLs */ strategy: 'pathname'; + + /** + * @name i18n.domains + * @type {Record } + * @default '{}' + * @version 4.3.0 + * @description + * + * Configures the URL pattern of one or more supported languages to use a custom domain (or sub-domain). + * + * When a locale is mapped to a domain, a `/[locale]/` path prefix will not be used. + * However, localized folders within `src/pages/` are still required, including for your configured `defaultLocale`. + * + * Any other locale not configured will default to a localized path-based URL according to your `prefixDefaultLocale` strategy (e.g. `https://example.com/[locale]/blog`). + * + * ```js + * //astro.config.mjs + * export default defineConfig({ + * site: "https://example.com", + * output: "server", // required, with no prerendered pages + * adapter: node({ + * mode: 'standalone', + * }), + * i18n: { + * defaultLocale: "en", + * locales: ["en", "fr", "pt-br", "es"], + * prefixDefaultLocale: false, + * domains: { + * fr: "https://fr.example.com", + * es: "https://example.es" + * }, + * }) + * ``` + * + * Both page routes built and URLs returned by the `astro:i18n` helper functions [`getAbsoluteLocaleUrl()`](https://docs.astro.build/en/guides/internationalization/#getabsolutelocaleurl) and [`getAbsoluteLocaleUrlList()`](https://docs.astro.build/en/guides/internationalization/#getabsolutelocaleurllist) will use the options set in `i18n.domains`. + * + * See the [Internationalization Guide](https://docs.astro.build/en/guides/internationalization/#domains) for more details, including the limitations of this feature. + */ + domains?: Record; }; }; @@ -1664,6 +1703,48 @@ export interface AstroUserConfig { * In the event of route collisions, where two routes of equal route priority attempt to build the same URL, Astro will log a warning identifying the conflicting routes. */ globalRoutePriority?: boolean; + + /** + * @docs + * @name experimental.i18nDomains + * @type {boolean} + * @default `false` + * @version 4.3.0 + * @description + * + * Enables domain support for the [experimental `domains` routing strategy](https://docs.astro.build/en/guides/internationalization/#domains-experimental) which allows you to configure the URL pattern of one or more supported languages to use a custom domain (or sub-domain). + * + * When a locale is mapped to a domain, a `/[locale]/` path prefix will not be used. However, localized folders within `src/pages/` are still required, including for your configured `defaultLocale`. + * + * Any other locale not configured will default to a localized path-based URL according to your `prefixDefaultLocale` strategy (e.g. `https://example.com/[locale]/blog`). + * + * ```js + * //astro.config.mjs + * export default defineConfig({ + * site: "https://example.com", + * output: "server", // required, with no prerendered pages + * adapter: node({ + * mode: 'standalone', + * }), + * i18n: { + * defaultLocale: "en", + * locales: ["en", "fr", "pt-br", "es"], + * prefixDefaultLocale: false, + * domains: { + * fr: "https://fr.example.com", + * es: "https://example.es" + * }, + * experimental: { + * i18nDomains: true + * } + * }) + * ``` + * + * Both page routes built and URLs returned by the `astro:i18n` helper functions [`getAbsoluteLocaleUrl()`](https://docs.astro.build/en/guides/internationalization/#getabsolutelocaleurl) and [`getAbsoluteLocaleUrlList()`](https://docs.astro.build/en/guides/internationalization/#getabsolutelocaleurllist) will use the options set in `i18n.domains`. + * + * See the [Internationalization Guide](https://docs.astro.build/en/guides/internationalization/#domains-experimental) for more details, including the limitations of this experimental feature. + */ + i18nDomains?: boolean; }; } @@ -2133,7 +2214,7 @@ export type AstroFeatureMap = { /** * List of features that orbit around the i18n routing */ - i18n?: AstroInternationalizationFeature; + i18nDomains?: SupportsKind; }; export interface AstroAssetsFeature { @@ -2150,9 +2231,9 @@ export interface AstroAssetsFeature { export interface AstroInternationalizationFeature { /** - * Whether the adapter is able to detect the language of the browser, usually using the `Accept-Language` header. + * The adapter should be able to create the proper redirects */ - detectBrowserLanguage?: SupportsKind; + domains?: SupportsKind; } export type Locales = (string | { codes: string[]; path: string })[]; diff --git a/packages/astro/src/core/app/index.ts b/packages/astro/src/core/app/index.ts index c7dca57ec23c..108b0f231a77 100644 --- a/packages/astro/src/core/app/index.ts +++ b/packages/astro/src/core/app/index.ts @@ -13,7 +13,9 @@ import { consoleLogDestination } from '../logger/console.js'; import { AstroIntegrationLogger, Logger } from '../logger/core.js'; import { sequence } from '../middleware/index.js'; import { + appendForwardSlash, collapseDuplicateSlashes, + joinPaths, prependForwardSlash, removeTrailingForwardSlash, } from '../path.js'; @@ -28,6 +30,7 @@ import { import { matchRoute } from '../routing/match.js'; import { SSRRoutePipeline } from './ssrPipeline.js'; import type { RouteInfo } from './types.js'; +import { normalizeTheLocale } from '../../i18n/index.js'; export { deserializeManifest } from './common.js'; const localsSymbol = Symbol.for('astro.locals'); @@ -172,13 +175,85 @@ export class App { const url = new URL(request.url); // ignore requests matching public assets if (this.#manifest.assets.has(url.pathname)) return undefined; - const pathname = prependForwardSlash(this.removeBase(url.pathname)); - const routeData = matchRoute(pathname, this.#manifestData); - // missing routes fall-through, prerendered are handled by static layer + let pathname = this.#computePathnameFromDomain(request); + if (!pathname) { + pathname = prependForwardSlash(this.removeBase(url.pathname)); + } + let routeData = matchRoute(pathname, this.#manifestData); + + // missing routes fall-through, pre rendered are handled by static layer if (!routeData || routeData.prerender) return undefined; return routeData; } + #computePathnameFromDomain(request: Request): string | undefined { + let pathname: string | undefined = undefined; + const url = new URL(request.url); + + if ( + this.#manifest.i18n && + (this.#manifest.i18n.routing === 'domains-prefix-always' || + this.#manifest.i18n.routing === 'domains-prefix-other-locales' || + this.#manifest.i18n.routing === 'domains-prefix-other-no-redirect') + ) { + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Host + let host = request.headers.get('X-Forwarded-Host'); + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto + let protocol = request.headers.get('X-Forwarded-Proto'); + if (protocol) { + // this header doesn't have the colum at the end, so we added to be in line with URL#protocol, which has it + protocol = protocol + ':'; + } else { + // we fall back to the protocol of the request + protocol = url.protocol; + } + if (!host) { + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Host + host = request.headers.get('Host'); + } + // If we don't have a host and a protocol, it's impossible to proceed + if (host && protocol) { + // The header might have a port in their name, so we remove it + host = host.split(':')[0]; + try { + let locale; + const hostAsUrl = new URL(`${protocol}//${host}`); + for (const [domainKey, localeValue] of Object.entries( + this.#manifest.i18n.domainLookupTable + )) { + // This operation should be safe because we force the protocol via zod inside the configuration + // If not, then it means that the manifest was tampered + const domainKeyAsUrl = new URL(domainKey); + + if ( + hostAsUrl.host === domainKeyAsUrl.host && + hostAsUrl.protocol === domainKeyAsUrl.protocol + ) { + locale = localeValue; + break; + } + } + + if (locale) { + pathname = prependForwardSlash( + joinPaths(normalizeTheLocale(locale), this.removeBase(url.pathname)) + ); + if (url.pathname.endsWith('/')) { + pathname = appendForwardSlash(pathname); + } + } + } catch (e: any) { + this.#logger.error( + 'router', + `Astro tried to parse ${protocol}//${host} as an URL, but it threw a parsing error. Check the X-Forwarded-Host and X-Forwarded-Proto headers.` + ); + this.#logger.error('router', `Error: ${e}`); + } + } + } + return pathname; + } + async render(request: Request, options?: RenderOptions): Promise; /** * @deprecated Instead of passing `RouteData` and locals individually, pass an object with `routeData` and `locals` properties. diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts index a5b93fb5006a..184f3b1d5bfe 100644 --- a/packages/astro/src/core/app/types.ts +++ b/packages/astro/src/core/app/types.ts @@ -63,6 +63,7 @@ export type SSRManifestI18n = { routing: RoutingStrategies; locales: Locales; defaultLocale: string; + domainLookupTable: Record; }; export type SerializedSSRManifest = Omit< diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index 9f3a9076919c..e73d34c71eb2 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -67,6 +67,7 @@ import type { StylesheetAsset, } from './types.js'; import { getTimeStat, shouldAppendForwardSlash } from './util.js'; +import { NoPrerenderedRoutesWithDomains } from '../errors/errors-data.js'; function createEntryURL(filePath: string, outFolder: URL) { return new URL('./' + filePath + `?time=${Date.now()}`, outFolder); @@ -180,9 +181,18 @@ export async function generatePages(opts: StaticBuildOptions, internals: BuildIn logger.info('SKIP_FORMAT', `\n${bgGreen(black(` ${verb} static routes `))}`); const builtPaths = new Set(); const pagesToGenerate = pipeline.retrieveRoutesToGenerate(); + const config = pipeline.getConfig(); if (ssr) { for (const [pageData, filePath] of pagesToGenerate) { if (pageData.route.prerender) { + // i18n domains won't work with pre rendered routes at the moment, so we need to to throw an error + if (config.experimental.i18nDomains) { + throw new AstroError({ + ...NoPrerenderedRoutesWithDomains, + message: NoPrerenderedRoutesWithDomains.message(pageData.component), + }); + } + const ssrEntryURLPage = createEntryURL(filePath, outFolder); const ssrEntryPage = await import(ssrEntryURLPage.toString()); if (opts.settings.adapter?.adapterFeatures?.functionPerRoute) { @@ -429,7 +439,7 @@ function getInvalidRouteSegmentError( route.route, JSON.stringify(invalidParam), JSON.stringify(received) - ) + ) : `Generated path for ${route.route} is invalid.`, hint, }); @@ -652,6 +662,7 @@ function createBuildManifest( routing: settings.config.i18n.routing, defaultLocale: settings.config.i18n.defaultLocale, locales: settings.config.i18n.locales, + domainLookupTable: {}, }; } return { diff --git a/packages/astro/src/core/build/plugins/plugin-manifest.ts b/packages/astro/src/core/build/plugins/plugin-manifest.ts index d57eb76f3218..0c2717f4e0a9 100644 --- a/packages/astro/src/core/build/plugins/plugin-manifest.ts +++ b/packages/astro/src/core/build/plugins/plugin-manifest.ts @@ -16,6 +16,7 @@ import { getOutFile, getOutFolder } from '../common.js'; import { cssOrder, mergeInlineCss, type BuildInternals } from '../internal.js'; import type { AstroBuildPlugin } from '../plugin.js'; import type { StaticBuildOptions } from '../types.js'; +import { normalizeTheLocale } from '../../../i18n/index.js'; const manifestReplace = '@@ASTRO_MANIFEST_REPLACE@@'; const replaceExp = new RegExp(`['"](${manifestReplace})['"]`, 'g'); @@ -153,6 +154,7 @@ function buildManifest( const { settings } = opts; const routes: SerializedRouteInfo[] = []; + const domainLookupTable: Record = {}; const entryModules = Object.fromEntries(internals.entrySpecifierToBundleMap.entries()); if (settings.scripts.some((script) => script.stage === 'page')) { staticFiles.push(entryModules[PAGE_SCRIPT_ID]); @@ -229,6 +231,23 @@ function buildManifest( }); } + /** + * logic meant for i18n domain support, where we fill the lookup table + */ + const i18n = settings.config.i18n; + if ( + settings.config.experimental.i18nDomains && + i18n && + i18n.domains && + (i18n.routing === 'domains-prefix-always' || + i18n.routing === 'domains-prefix-other-locales' || + i18n.routing === 'domains-prefix-other-no-redirect') + ) { + for (const [locale, domainValue] of Object.entries(i18n.domains)) { + domainLookupTable[domainValue] = normalizeTheLocale(locale); + } + } + // HACK! Patch this special one. if (!(BEFORE_HYDRATION_SCRIPT_ID in entryModules)) { // Set this to an empty string so that the runtime knows not to try and load this. @@ -241,6 +260,7 @@ function buildManifest( routing: settings.config.i18n.routing, locales: settings.config.i18n.locales, defaultLocale: settings.config.i18n.defaultLocale, + domainLookupTable, }; } diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts index 29c817bce052..255360eead80 100644 --- a/packages/astro/src/core/config/schema.ts +++ b/packages/astro/src/core/config/schema.ts @@ -62,13 +62,17 @@ const ASTRO_CONFIG_DEFAULTS = { contentCollectionCache: false, clientPrerender: false, globalRoutePriority: false, + i18nDomains: false, }, } satisfies AstroUserConfig & { server: { open: boolean } }; export type RoutingStrategies = | 'pathname-prefix-always' | 'pathname-prefix-other-locales' - | 'pathname-prefix-always-no-redirect'; + | 'pathname-prefix-always-no-redirect' + | 'domains-prefix-always' + | 'domains-prefix-other-locales' + | 'domains-prefix-other-no-redirect'; export const AstroConfigSchema = z.object({ root: z @@ -330,6 +334,16 @@ export const AstroConfigSchema = z.object({ }), ]) ), + domains: z + .record( + z.string(), + z + .string() + .url( + "The domain value must be a valid URL, and it has to start with 'https' or 'http'." + ) + ) + .optional(), fallback: z.record(z.string(), z.string()).optional(), routing: z .object({ @@ -346,29 +360,43 @@ export const AstroConfigSchema = z.object({ message: 'The option `i18n.redirectToDefaultLocale` is only useful when the `i18n.prefixDefaultLocale` is set to `true`. Remove the option `i18n.redirectToDefaultLocale`, or change its value to `true`.', } - ) - .transform((routing) => { - let strategy: RoutingStrategies; - switch (routing.strategy) { - case 'pathname': { - if (routing.prefixDefaultLocale === true) { - if (routing.redirectToDefaultLocale) { - strategy = 'pathname-prefix-always'; - } else { - strategy = 'pathname-prefix-always-no-redirect'; - } - } else { - strategy = 'pathname-prefix-other-locales'; - } + ), + }) + .optional() + .transform((i18n) => { + if (i18n) { + let { routing, domains } = i18n; + let strategy: RoutingStrategies; + const hasDomains = domains ? Object.keys(domains).length > 0 : false; + if (!hasDomains) { + if (routing.prefixDefaultLocale === true) { + if (routing.redirectToDefaultLocale) { + strategy = 'pathname-prefix-always'; + } else { + strategy = 'pathname-prefix-always-no-redirect'; + } + } else { + strategy = 'pathname-prefix-other-locales'; + } + } else { + if (routing.prefixDefaultLocale === true) { + if (routing.redirectToDefaultLocale) { + strategy = 'domains-prefix-always'; + } else { + strategy = 'domains-prefix-other-no-redirect'; } + } else { + strategy = 'domains-prefix-other-locales'; } - return strategy; - }), + } + + return { ...i18n, routing: strategy }; + } + return undefined; }) - .optional() .superRefine((i18n, ctx) => { if (i18n) { - const { defaultLocale, locales: _locales, fallback } = i18n; + const { defaultLocale, locales: _locales, fallback, domains, routing } = i18n; const locales = _locales.map((locale) => { if (typeof locale === 'string') { return locale; @@ -406,6 +434,51 @@ export const AstroConfigSchema = z.object({ } } } + if (domains) { + const entries = Object.entries(domains); + if (entries.length > 0) { + if ( + routing !== 'domains-prefix-other-locales' && + routing !== 'domains-prefix-other-no-redirect' && + routing !== 'domains-prefix-always' + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `When specifying some domains, the property \`i18n.routingStrategy\` must be set to \`"domains"\`.`, + }); + } + } + + for (const [domainKey, domainValue] of Object.entries(domains)) { + if (!locales.includes(domainKey)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `The locale \`${domainKey}\` key in the \`i18n.domains\` record doesn't exist in the \`i18n.locales\` array.`, + }); + } + if (!domainValue.startsWith('https') && !domainValue.startsWith('http')) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "The domain value must be a valid URL, and it has to start with 'https' or 'http'.", + path: ['domains'], + }); + } else { + try { + const domainUrl = new URL(domainValue); + if (domainUrl.pathname !== '/') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `The URL \`${domainValue}\` must contain only the origin. A subsequent pathname isn't allowed here. Remove \`${domainUrl.pathname}\`.`, + path: ['domains'], + }); + } + } catch { + // no need to catch the error + } + } + } + } } }) ), @@ -427,6 +500,7 @@ export const AstroConfigSchema = z.object({ .boolean() .optional() .default(ASTRO_CONFIG_DEFAULTS.experimental.globalRoutePriority), + i18nDomains: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.i18nDomains), }) .strict( `Invalid or outdated experimental feature.\nCheck for incorrect spelling or outdated Astro version.\nSee https://docs.astro.build/en/reference/configuration-reference/#experimental-flags for a list of all current experiments.` @@ -547,6 +621,30 @@ export function createRelativeSchema(cmd: string, fileProtocolRoot: string) { .refine((obj) => !obj.outDir.toString().startsWith(obj.publicDir.toString()), { message: 'The value of `outDir` must not point to a path within the folder set as `publicDir`, this will cause an infinite loop', + }) + .superRefine((configuration, ctx) => { + const { site, experimental, i18n, output } = configuration; + if (experimental.i18nDomains) { + if ( + i18n?.routing === 'domains-prefix-other-locales' || + i18n?.routing === 'domains-prefix-other-no-redirect' || + i18n?.routing === 'domains-prefix-always' + ) { + if (!site) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "The option `site` isn't set. When using the 'domains' strategy for `i18n`, `site` is required to create absolute URLs for locales that aren't mapped to a domain.", + }); + } + if (output !== 'server') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Domain support is only available when `output` is `"server"`.', + }); + } + } + } }); return AstroConfigRelativeSchema; diff --git a/packages/astro/src/core/errors/errors-data.ts b/packages/astro/src/core/errors/errors-data.ts index 4af4d2e6430a..3fc459ef8bfb 100644 --- a/packages/astro/src/core/errors/errors-data.ts +++ b/packages/astro/src/core/errors/errors-data.ts @@ -1017,6 +1017,18 @@ export const MissingIndexForInternationalization = { hint: (src: string) => `Create an index page (\`index.astro, index.md, etc.\`) in \`${src}\`.`, } satisfies ErrorData; +/** + * @docs + * @description + * Static pages aren't yet supported with i18n domains. If you wish to enable this feature, you have to disable pre-rendering. + */ +export const NoPrerenderedRoutesWithDomains = { + name: 'NoPrerenderedRoutesWithDomains', + title: "Pre-rendered routes aren't supported when internationalization domains are enabled.", + message: (component: string) => + `Static pages aren't yet supported with multiple domains. If you wish to enable this feature, you have to disable pre-rendering for the page ${component}`, +} satisfies ErrorData; + /** * @docs * @description diff --git a/packages/astro/src/core/render/context.ts b/packages/astro/src/core/render/context.ts index 898e74d8f396..faba9b86a266 100644 --- a/packages/astro/src/core/render/context.ts +++ b/packages/astro/src/core/render/context.ts @@ -263,7 +263,10 @@ export function computeCurrentLocale( } } } - if (routingStrategy === 'pathname-prefix-other-locales') { + if ( + routingStrategy === 'pathname-prefix-other-locales' || + routingStrategy === 'domains-prefix-other-locales' + ) { return defaultLocale; } return undefined; diff --git a/packages/astro/src/i18n/index.ts b/packages/astro/src/i18n/index.ts index 4b882b7829df..6a8834b541d5 100644 --- a/packages/astro/src/i18n/index.ts +++ b/packages/astro/src/i18n/index.ts @@ -13,6 +13,8 @@ type GetLocaleRelativeUrl = GetLocaleOptions & { format: AstroConfig['build']['format']; routing?: RoutingStrategies; defaultLocale: string; + domains: Record | undefined; + path?: string; }; export type GetLocaleOptions = { @@ -21,10 +23,6 @@ export type GetLocaleOptions = { * @default true */ normalizeLocale?: boolean; - /** - * An optional path to add after the `locale`. - */ - path?: string; /** * An optional path to prepend to `locale`. */ @@ -33,6 +31,7 @@ export type GetLocaleOptions = { type GetLocaleAbsoluteUrl = GetLocaleRelativeUrl & { site: AstroConfig['site']; + isBuild: boolean; }; /** * The base URL @@ -75,22 +74,37 @@ export function getLocaleRelativeUrl({ /** * The absolute URL */ -export function getLocaleAbsoluteUrl({ site, ...rest }: GetLocaleAbsoluteUrl) { - const locale = getLocaleRelativeUrl(rest); - if (site) { - return joinPaths(site, locale); +export function getLocaleAbsoluteUrl({ site, isBuild, ...rest }: GetLocaleAbsoluteUrl) { + const localeUrl = getLocaleRelativeUrl(rest); + const { domains, locale } = rest; + let url; + if (isBuild && domains) { + const base = domains[locale]; + url = joinPaths(base, localeUrl.replace(`/${rest.locale}`, '')); + } else { + if (site) { + url = joinPaths(site, localeUrl); + } else { + url = localeUrl; + } + } + + if (shouldAppendForwardSlash(rest.trailingSlash, rest.format)) { + return appendForwardSlash(url); } else { - return locale; + return url; } } interface GetLocalesRelativeUrlList extends GetLocaleOptions { base: string; + path?: string; locales: Locales; trailingSlash: AstroConfig['trailingSlash']; format: AstroConfig['build']['format']; routing?: RoutingStrategies; defaultLocale: string; + domains: Record | undefined; } export function getLocaleRelativeUrlList({ @@ -100,7 +114,7 @@ export function getLocaleRelativeUrlList({ format, path, prependWith, - normalizeLocale = false, + normalizeLocale = true, routing = 'pathname-prefix-other-locales', defaultLocale, }: GetLocalesRelativeUrlList) { @@ -124,16 +138,58 @@ export function getLocaleRelativeUrlList({ } interface GetLocalesAbsoluteUrlList extends GetLocalesRelativeUrlList { - site?: string; + site?: AstroConfig['site']; + isBuild: boolean; } -export function getLocaleAbsoluteUrlList({ site, ...rest }: GetLocalesAbsoluteUrlList) { - const locales = getLocaleRelativeUrlList(rest); - return locales.map((locale) => { - if (site) { - return joinPaths(site, locale); +export function getLocaleAbsoluteUrlList({ + base, + locales: _locales, + trailingSlash, + format, + path, + prependWith, + normalizeLocale = true, + routing = 'pathname-prefix-other-locales', + defaultLocale, + isBuild, + domains, + site, +}: GetLocalesAbsoluteUrlList) { + const locales = toPaths(_locales); + return locales.map((currentLocale) => { + const pathsToJoin = []; + const normalizedLocale = normalizeLocale ? normalizeTheLocale(currentLocale) : currentLocale; + const domainBase = domains ? domains[currentLocale] : undefined; + if (isBuild && domainBase) { + if (domainBase) { + pathsToJoin.push(domainBase); + } else { + pathsToJoin.push(site); + } + pathsToJoin.push(base); + pathsToJoin.push(prependWith); } else { - return locale; + if (site) { + pathsToJoin.push(site); + } + pathsToJoin.push(base); + pathsToJoin.push(prependWith); + if ( + routing === 'pathname-prefix-always' || + routing === 'pathname-prefix-always-no-redirect' + ) { + pathsToJoin.push(normalizedLocale); + } else if (currentLocale !== defaultLocale) { + pathsToJoin.push(normalizedLocale); + } + } + + pathsToJoin.push(path); + if (shouldAppendForwardSlash(trailingSlash, format)) { + return appendForwardSlash(joinPaths(...pathsToJoin)); + } else { + return joinPaths(...pathsToJoin); } }); } diff --git a/packages/astro/src/i18n/middleware.ts b/packages/astro/src/i18n/middleware.ts index a7c50dc82d53..9fabff13af41 100644 --- a/packages/astro/src/i18n/middleware.ts +++ b/packages/astro/src/i18n/middleware.ts @@ -1,9 +1,16 @@ import { appendForwardSlash, joinPaths } from '@astrojs/internal-helpers/path'; -import type { Locales, MiddlewareHandler, RouteData, SSRManifest } from '../@types/astro.js'; +import type { + APIContext, + Locales, + MiddlewareHandler, + RouteData, + SSRManifest, +} from '../@types/astro.js'; import type { PipelineHookFunction } from '../core/pipeline.js'; import { getPathByLocale, normalizeTheLocale } from './index.js'; import { shouldAppendForwardSlash } from '../core/build/util.js'; import { ROUTE_DATA_SYMBOL } from '../core/constants.js'; +import type { SSRManifestI18n } from '../core/app/types.js'; const routeDataSymbol = Symbol.for(ROUTE_DATA_SYMBOL); @@ -25,6 +32,13 @@ function pathnameHasLocale(pathname: string, locales: Locales): boolean { return false; } +type MiddlewareOptions = { + i18n: SSRManifest['i18n']; + base: SSRManifest['base']; + trailingSlash: SSRManifest['trailingSlash']; + buildFormat: SSRManifest['buildFormat']; +}; + export function createI18nMiddleware( i18n: SSRManifest['i18n'], base: SSRManifest['base'], @@ -33,62 +47,130 @@ export function createI18nMiddleware( ): MiddlewareHandler { if (!i18n) return (_, next) => next(); + const prefixAlways = ( + url: URL, + response: Response, + context: APIContext + ): Response | undefined => { + if (url.pathname === base + '/' || url.pathname === base) { + if (shouldAppendForwardSlash(trailingSlash, buildFormat)) { + return context.redirect(`${appendForwardSlash(joinPaths(base, i18n.defaultLocale))}`); + } else { + return context.redirect(`${joinPaths(base, i18n.defaultLocale)}`); + } + } + + // Astro can't know where the default locale is supposed to be, so it returns a 404 with no content. + else if (!pathnameHasLocale(url.pathname, i18n.locales)) { + return new Response(null, { + status: 404, + headers: response.headers, + }); + } + + return undefined; + }; + + const prefixOtherLocales = (url: URL, response: Response): Response | undefined => { + const pathnameContainsDefaultLocale = url.pathname.includes(`/${i18n.defaultLocale}`); + if (pathnameContainsDefaultLocale) { + const newLocation = url.pathname.replace(`/${i18n.defaultLocale}`, ''); + response.headers.set('Location', newLocation); + return new Response(null, { + status: 404, + headers: response.headers, + }); + } + + return undefined; + }; + + /** + * We return a 404 if: + * - the current path isn't a root. e.g. / or / + * - the URL doesn't contain a locale + * @param url + * @param response + */ + const prefixAlwaysNoRedirect = (url: URL, response: Response): Response | undefined => { + // We return a 404 if: + // - the current path isn't a root. e.g. / or / + // - the URL doesn't contain a locale + const isRoot = url.pathname === base + '/' || url.pathname === base; + if (!(isRoot || pathnameHasLocale(url.pathname, i18n.locales))) { + return new Response(null, { + status: 404, + headers: response.headers, + }); + } + + return undefined; + }; + return async (context, next) => { const routeData: RouteData | undefined = Reflect.get(context.request, routeDataSymbol); // If the route we're processing is not a page, then we ignore it if (routeData?.type !== 'page' && routeData?.type !== 'fallback') { return await next(); } + const currentLocale = context.currentLocale; const url = context.url; const { locales, defaultLocale, fallback, routing } = i18n; const response = await next(); if (response instanceof Response) { - const pathnameContainsDefaultLocale = url.pathname.includes(`/${defaultLocale}`); switch (i18n.routing) { + case 'domains-prefix-other-locales': { + if (localeHasntDomain(i18n, currentLocale)) { + const result = prefixOtherLocales(url, response); + if (result) { + return result; + } + } + break; + } case 'pathname-prefix-other-locales': { - if (pathnameContainsDefaultLocale) { - const newLocation = url.pathname.replace(`/${defaultLocale}`, ''); - response.headers.set('Location', newLocation); - return new Response(null, { - status: 404, - headers: response.headers, - }); + const result = prefixOtherLocales(url, response); + if (result) { + return result; + } + break; + } + + case 'domains-prefix-other-no-redirect': { + if (localeHasntDomain(i18n, currentLocale)) { + const result = prefixAlwaysNoRedirect(url, response); + if (result) { + return result; + } } break; } case 'pathname-prefix-always-no-redirect': { - // We return a 404 if: - // - the current path isn't a root. e.g. / or / - // - the URL doesn't contain a locale - const isRoot = url.pathname === base + '/' || url.pathname === base; - if (!(isRoot || pathnameHasLocale(url.pathname, i18n.locales))) { - return new Response(null, { - status: 404, - headers: response.headers, - }); + const result = prefixAlwaysNoRedirect(url, response); + if (result) { + return result; } break; } case 'pathname-prefix-always': { - if (url.pathname === base + '/' || url.pathname === base) { - if (shouldAppendForwardSlash(trailingSlash, buildFormat)) { - return context.redirect(`${appendForwardSlash(joinPaths(base, i18n.defaultLocale))}`); - } else { - return context.redirect(`${joinPaths(base, i18n.defaultLocale)}`); - } + const result = prefixAlways(url, response, context); + if (result) { + return result; } - - // Astro can't know where the default locale is supposed to be, so it returns a 404 with no content. - else if (!pathnameHasLocale(url.pathname, i18n.locales)) { - return new Response(null, { - status: 404, - headers: response.headers, - }); + break; + } + case 'domains-prefix-always': { + if (localeHasntDomain(i18n, currentLocale)) { + const result = prefixAlways(url, response, context); + if (result) { + return result; + } } + break; } } @@ -138,3 +220,17 @@ export function createI18nMiddleware( export const i18nPipelineHook: PipelineHookFunction = (ctx) => { Reflect.set(ctx.request, routeDataSymbol, ctx.route); }; + +/** + * Checks if the current locale doesn't belong to a configured domain + * @param i18n + * @param currentLocale + */ +function localeHasntDomain(i18n: SSRManifestI18n, currentLocale: string | undefined) { + for (const domainLocale of Object.values(i18n.domainLookupTable)) { + if (domainLocale === currentLocale) { + return false; + } + } + return true; +} diff --git a/packages/astro/src/i18n/vite-plugin-i18n.ts b/packages/astro/src/i18n/vite-plugin-i18n.ts index 2ce015c26521..7aa4d327b223 100644 --- a/packages/astro/src/i18n/vite-plugin-i18n.ts +++ b/packages/astro/src/i18n/vite-plugin-i18n.ts @@ -13,6 +13,7 @@ export interface I18nInternalConfig extends Pick, Pick { i18n: AstroConfig['i18n']; + isBuild: boolean; } export default function astroInternationalization({ @@ -28,8 +29,15 @@ export default function astroInternationalization({ return { name: 'astro:i18n', enforce: 'pre', - config(config) { - const i18nConfig: I18nInternalConfig = { base, format, site, trailingSlash, i18n }; + config(config, { command }) { + const i18nConfig: I18nInternalConfig = { + base, + format, + site, + trailingSlash, + i18n, + isBuild: command === 'build', + }; return { define: { __ASTRO_INTERNAL_I18N_CONFIG__: JSON.stringify(i18nConfig), diff --git a/packages/astro/src/integrations/astroFeaturesValidation.ts b/packages/astro/src/integrations/astroFeaturesValidation.ts index 8bae77846c29..ce0f1b9c41ee 100644 --- a/packages/astro/src/integrations/astroFeaturesValidation.ts +++ b/packages/astro/src/integrations/astroFeaturesValidation.ts @@ -1,4 +1,5 @@ import type { + AstroAdapterFeatures, AstroAssetsFeature, AstroConfig, AstroFeatureMap, @@ -32,6 +33,7 @@ export function validateSupportedFeatures( adapterName: string, featureMap: AstroFeatureMap, config: AstroConfig, + adapterFeatures: AstroAdapterFeatures | undefined, logger: Logger ): ValidationResult { const { @@ -39,6 +41,7 @@ export function validateSupportedFeatures( serverOutput = UNSUPPORTED, staticOutput = UNSUPPORTED, hybridOutput = UNSUPPORTED, + i18nDomains = UNSUPPORTED, } = featureMap; const validationResult: ValidationResult = {}; @@ -67,6 +70,24 @@ export function validateSupportedFeatures( ); validationResult.assets = validateAssetsFeature(assets, adapterName, config, logger); + if (i18nDomains) { + validationResult.i18nDomains = validateSupportKind( + i18nDomains, + adapterName, + logger, + 'i18nDomains', + () => { + return config?.output === 'server' && !config?.site; + } + ); + if (adapterFeatures?.functionPerRoute) { + logger.error( + 'config', + 'The Astro feature `i18nDomains` is incompatible with the Adapter feature `functionPerRoute`' + ); + } + } + return validationResult; } diff --git a/packages/astro/src/integrations/index.ts b/packages/astro/src/integrations/index.ts index d082c438fbaa..63d526ad4c52 100644 --- a/packages/astro/src/integrations/index.ts +++ b/packages/astro/src/integrations/index.ts @@ -257,6 +257,8 @@ export async function runHookConfigDone({ adapter.name, adapter.supportedAstroFeatures, settings.config, + // SAFETY: we checked before if it's not present, and we throw an error + adapter.adapterFeatures, logger ); for (const [featureName, supported] of Object.entries(validationResult)) { @@ -503,11 +505,3 @@ export function isFunctionPerRouteEnabled(adapter: AstroAdapter | undefined): bo return false; } } - -export function isEdgeMiddlewareEnabled(adapter: AstroAdapter | undefined): boolean { - if (adapter?.adapterFeatures?.edgeMiddleware === true) { - return true; - } else { - return false; - } -} diff --git a/packages/astro/src/virtual-modules/i18n.ts b/packages/astro/src/virtual-modules/i18n.ts index aff49a492dc0..f71068667787 100644 --- a/packages/astro/src/virtual-modules/i18n.ts +++ b/packages/astro/src/virtual-modules/i18n.ts @@ -2,9 +2,10 @@ import * as I18nInternals from '../i18n/index.js'; import type { I18nInternalConfig } from '../i18n/vite-plugin-i18n.js'; export { normalizeTheLocale, toCodes, toPaths } from '../i18n/index.js'; -// @ts-expect-error -const { trailingSlash, format, site, i18n } = __ASTRO_INTERNAL_I18N_CONFIG__ as I18nInternalConfig; -const { defaultLocale, locales, routing } = i18n!; +const { trailingSlash, format, site, i18n, isBuild } = + // @ts-expect-error + __ASTRO_INTERNAL_I18N_CONFIG__ as I18nInternalConfig; +const { defaultLocale, locales, routing, domains } = i18n!; const base = import.meta.env.BASE_URL; export type GetLocaleOptions = I18nInternals.GetLocaleOptions; @@ -40,6 +41,7 @@ export const getRelativeLocaleUrl = (locale: string, path?: string, options?: Ge defaultLocale, locales, routing, + domains, ...options, }); @@ -68,7 +70,7 @@ export const getRelativeLocaleUrl = (locale: string, path?: string, options?: Ge * getAbsoluteLocaleUrl("es_US", "getting-started", { prependWith: "blog", normalizeLocale: false }); // https://example.com/blog/es_US/getting-started * ``` */ -export const getAbsoluteLocaleUrl = (locale: string, path = '', options?: GetLocaleOptions) => +export const getAbsoluteLocaleUrl = (locale: string, path?: string, options?: GetLocaleOptions) => I18nInternals.getLocaleAbsoluteUrl({ locale, path, @@ -79,6 +81,8 @@ export const getAbsoluteLocaleUrl = (locale: string, path = '', options?: GetLoc defaultLocale, locales, routing, + domains, + isBuild, ...options, }); @@ -97,6 +101,7 @@ export const getRelativeLocaleUrlList = (path?: string, options?: GetLocaleOptio defaultLocale, locales, routing, + domains, ...options, }); @@ -116,6 +121,8 @@ export const getAbsoluteLocaleUrlList = (path?: string, options?: GetLocaleOptio defaultLocale, locales, routing, + domains, + isBuild, ...options, }); diff --git a/packages/astro/src/vite-plugin-astro-server/plugin.ts b/packages/astro/src/vite-plugin-astro-server/plugin.ts index f1162b7bc967..ba33c3ebd44b 100644 --- a/packages/astro/src/vite-plugin-astro-server/plugin.ts +++ b/packages/astro/src/vite-plugin-astro-server/plugin.ts @@ -121,6 +121,7 @@ export function createDevelopmentManifest(settings: AstroSettings): SSRManifest routing: settings.config.i18n.routing, defaultLocale: settings.config.i18n.defaultLocale, locales: settings.config.i18n.locales, + domainLookupTable: {}, }; } return { diff --git a/packages/astro/src/vite-plugin-astro-server/route.ts b/packages/astro/src/vite-plugin-astro-server/route.ts index b4b18112437d..35d4b7278e91 100644 --- a/packages/astro/src/vite-plugin-astro-server/route.ts +++ b/packages/astro/src/vite-plugin-astro-server/route.ts @@ -308,7 +308,7 @@ export async function handleRoute({ const onRequest: MiddlewareHandler = middleware.onRequest; if (config.i18n) { const i18Middleware = createI18nMiddleware( - config.i18n, + manifest.i18n, config.base, config.trailingSlash, config.build.format diff --git a/packages/astro/test/fixtures/i18n-routing-prefix-always/astro.config.mjs b/packages/astro/test/fixtures/i18n-routing-prefix-always/astro.config.mjs index 58eb50540ec1..7a612488d4ca 100644 --- a/packages/astro/test/fixtures/i18n-routing-prefix-always/astro.config.mjs +++ b/packages/astro/test/fixtures/i18n-routing-prefix-always/astro.config.mjs @@ -13,5 +13,5 @@ export default defineConfig({ prefixDefaultLocale: true } }, - base: "/new-site" + base: "/new-site" }) diff --git a/packages/astro/test/fixtures/i18n-routing-subdomain/astro.config.mjs b/packages/astro/test/fixtures/i18n-routing-subdomain/astro.config.mjs new file mode 100644 index 000000000000..d8fe7b4a5599 --- /dev/null +++ b/packages/astro/test/fixtures/i18n-routing-subdomain/astro.config.mjs @@ -0,0 +1,24 @@ +import { defineConfig} from "astro/config"; + +export default defineConfig({ + output: "server", + trailingSlash: "never", + i18n: { + defaultLocale: 'en', + locales: [ + 'en', 'pt', 'it' + ], + domains: { + pt: "https://example.pt", + it: "http://it.example.com" + }, + routing: { + prefixDefaultLocale: true, + redirectToDefaultLocale: false + } + }, + experimental: { + i18nDomains: true + }, + site: "https://example.com", +}) diff --git a/packages/astro/test/fixtures/i18n-routing-subdomain/package.json b/packages/astro/test/fixtures/i18n-routing-subdomain/package.json new file mode 100644 index 000000000000..931425fa6206 --- /dev/null +++ b/packages/astro/test/fixtures/i18n-routing-subdomain/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/i18n-routing-subdomain", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/i18n-routing-subdomain/src/pages/en/blog/[id].astro b/packages/astro/test/fixtures/i18n-routing-subdomain/src/pages/en/blog/[id].astro new file mode 100644 index 000000000000..97b41230d6e9 --- /dev/null +++ b/packages/astro/test/fixtures/i18n-routing-subdomain/src/pages/en/blog/[id].astro @@ -0,0 +1,18 @@ +--- +export function getStaticPaths() { + return [ + {params: {id: '1'}, props: { content: "Hello world" }}, + {params: {id: '2'}, props: { content: "Eat Something" }}, + {params: {id: '3'}, props: { content: "How are you?" }}, + ]; +} +const { content } = Astro.props; +--- + + + Astro + + +{content} + + diff --git a/packages/astro/test/fixtures/i18n-routing-subdomain/src/pages/en/index.astro b/packages/astro/test/fixtures/i18n-routing-subdomain/src/pages/en/index.astro new file mode 100644 index 000000000000..3e50ac6bf3cb --- /dev/null +++ b/packages/astro/test/fixtures/i18n-routing-subdomain/src/pages/en/index.astro @@ -0,0 +1,8 @@ + + + Astro + + +Hello + + diff --git a/packages/astro/test/fixtures/i18n-routing-subdomain/src/pages/en/start.astro b/packages/astro/test/fixtures/i18n-routing-subdomain/src/pages/en/start.astro new file mode 100644 index 000000000000..990baecd9a8c --- /dev/null +++ b/packages/astro/test/fixtures/i18n-routing-subdomain/src/pages/en/start.astro @@ -0,0 +1,8 @@ + + + Astro + + +Start + + diff --git a/packages/astro/test/fixtures/i18n-routing-subdomain/src/pages/index.astro b/packages/astro/test/fixtures/i18n-routing-subdomain/src/pages/index.astro new file mode 100644 index 000000000000..d138455a3e3f --- /dev/null +++ b/packages/astro/test/fixtures/i18n-routing-subdomain/src/pages/index.astro @@ -0,0 +1,19 @@ +--- +import { getRelativeLocaleUrl, getAbsoluteLocaleUrl, getPathByLocale, getLocaleByPath } from "astro:i18n"; + +let absoluteLocaleUrl_pt = getAbsoluteLocaleUrl("pt", "about"); +let absoluteLocaleUrl_it = getAbsoluteLocaleUrl("it"); + +--- + + + + Astro + + + Virtual module doesn't break + + Absolute URL pt: {absoluteLocaleUrl_pt} + Absolute URL it: {absoluteLocaleUrl_it} + + diff --git a/packages/astro/test/fixtures/i18n-routing-subdomain/src/pages/pt/blog/[id].astro b/packages/astro/test/fixtures/i18n-routing-subdomain/src/pages/pt/blog/[id].astro new file mode 100644 index 000000000000..e37f83a30243 --- /dev/null +++ b/packages/astro/test/fixtures/i18n-routing-subdomain/src/pages/pt/blog/[id].astro @@ -0,0 +1,18 @@ +--- +export function getStaticPaths() { + return [ + {params: {id: '1'}, props: { content: "Hola mundo" }}, + {params: {id: '2'}, props: { content: "Eat Something" }}, + {params: {id: '3'}, props: { content: "How are you?" }}, + ]; +} +const { content } = Astro.props; +--- + + + Astro + + +{content} + + diff --git a/packages/astro/test/fixtures/i18n-routing-subdomain/src/pages/pt/start.astro b/packages/astro/test/fixtures/i18n-routing-subdomain/src/pages/pt/start.astro new file mode 100644 index 000000000000..5a4a84c2cf0c --- /dev/null +++ b/packages/astro/test/fixtures/i18n-routing-subdomain/src/pages/pt/start.astro @@ -0,0 +1,8 @@ + + + Astro + + +Oi essa e start + + diff --git a/packages/astro/test/fixtures/i18n-routing/astro.config.mjs b/packages/astro/test/fixtures/i18n-routing/astro.config.mjs index 42559e778a30..8868d2b1f665 100644 --- a/packages/astro/test/fixtures/i18n-routing/astro.config.mjs +++ b/packages/astro/test/fixtures/i18n-routing/astro.config.mjs @@ -11,6 +11,6 @@ export default defineConfig({ path: "spanish", codes: ["es", "es-SP"] } - ] + ], } }) diff --git a/packages/astro/test/fixtures/i18n-routing/src/pages/dynamic/[id].astro b/packages/astro/test/fixtures/i18n-routing/src/pages/dynamic/[id].astro index 58141fec05ce..4b0a97b6eed5 100644 --- a/packages/astro/test/fixtures/i18n-routing/src/pages/dynamic/[id].astro +++ b/packages/astro/test/fixtures/i18n-routing/src/pages/dynamic/[id].astro @@ -2,7 +2,7 @@ --- export function getStaticPaths() { return [ - { id: "lorem" } + { params: {id: "lorem"}} ] } const currentLocale = Astro.currentLocale; diff --git a/packages/astro/test/fixtures/i18n-routing/src/pages/virtual-module.astro b/packages/astro/test/fixtures/i18n-routing/src/pages/virtual-module.astro index ca33030dbed0..f65356e8fab1 100644 --- a/packages/astro/test/fixtures/i18n-routing/src/pages/virtual-module.astro +++ b/packages/astro/test/fixtures/i18n-routing/src/pages/virtual-module.astro @@ -1,10 +1,12 @@ --- -import { getRelativeLocaleUrl, getPathByLocale, getLocaleByPath } from "astro:i18n"; +import { getRelativeLocaleUrl, getAbsoluteLocaleUrl, getPathByLocale, getLocaleByPath } from "astro:i18n"; let about = getRelativeLocaleUrl("pt", "about"); let spanish = getRelativeLocaleUrl("es", "about"); let spainPath = getPathByLocale("es-SP"); let localeByPath = getLocaleByPath("spanish"); +let italianAbout = getAbsoluteLocaleUrl("it", "about"); + --- @@ -18,5 +20,6 @@ let localeByPath = getLocaleByPath("spanish"); About spanish: {spanish} Spain path: {spainPath} Preferred path: {localeByPath} + About it: {italianAbout} diff --git a/packages/astro/test/i18n-routing.test.js b/packages/astro/test/i18n-routing.test.js index da9fdc7c8316..b3d496ff41ba 100644 --- a/packages/astro/test/i18n-routing.test.js +++ b/packages/astro/test/i18n-routing.test.js @@ -29,6 +29,34 @@ describe('astro:i18n virtual module', () => { expect(text).includes('About spanish: /spanish/about'); expect(text).includes('Spain path: spanish'); expect(text).includes('Preferred path: es'); + expect(text).includes('About it: /it/about'); + }); + + describe('absolute URLs', () => { + let app; + before(async () => { + fixture = await loadFixture({ + root: './fixtures/i18n-routing-subdomain/', + output: 'server', + adapter: testAdapter(), + }); + await fixture.build(); + app = await fixture.loadTestAdapterApp(); + }); + + it('correctly renders the absolute URL', async () => { + let request = new Request('http://example.com/'); + let response = await app.render(request); + expect(response.status).to.equal(200); + + let html = await response.text(); + let $ = cheerio.load(html); + + console.log(html); + expect($('body').text()).includes("Virtual module doesn't break"); + expect($('body').text()).includes('Absolute URL pt: https://example.pt/about'); + expect($('body').text()).includes('Absolute URL it: http://it.example.com/'); + }); }); }); describe('[DEV] i18n routing', () => { @@ -1613,4 +1641,67 @@ describe('i18n routing does not break assets and endpoints', () => { expect(await response.text()).includes('lorem'); }); }); + + describe('i18n routing with routing strategy [subdomain]', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + let app; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/i18n-routing-subdomain/', + output: 'server', + adapter: testAdapter(), + }); + await fixture.build(); + app = await fixture.loadTestAdapterApp(); + }); + + it('should render the en locale when X-Forwarded-Host header is passed', async () => { + let request = new Request('http://example.pt/start', { + headers: { + 'X-Forwarded-Host': 'example.pt', + 'X-Forwarded-Proto': 'https', + }, + }); + let response = await app.render(request); + expect(response.status).to.equal(200); + expect(await response.text()).includes('Oi essa e start\n'); + }); + + it('should render the en locale when Host header is passed', async () => { + let request = new Request('http://example.pt/start', { + headers: { + Host: 'example.pt', + 'X-Forwarded-Proto': 'https', + }, + }); + let response = await app.render(request); + expect(response.status).to.equal(200); + expect(await response.text()).includes('Oi essa e start\n'); + }); + + it('should render the en locale when Host header is passed and it has the port', async () => { + let request = new Request('http://example.pt/start', { + headers: { + Host: 'example.pt:8080', + 'X-Forwarded-Proto': 'https', + }, + }); + let response = await app.render(request); + expect(response.status).to.equal(200); + expect(await response.text()).includes('Oi essa e start\n'); + }); + + it('should render when the protocol header we fallback to the one of the host', async () => { + let request = new Request('https://example.pt/start', { + headers: { + Host: 'example.pt', + }, + }); + let response = await app.render(request); + expect(response.status).to.equal(200); + expect(await response.text()).includes('Oi essa e start\n'); + }); + }); }); diff --git a/packages/astro/test/units/config/config-validate.test.js b/packages/astro/test/units/config/config-validate.test.js index 477bc4bedac3..d2efbbafba4c 100644 --- a/packages/astro/test/units/config/config-validate.test.js +++ b/packages/astro/test/units/config/config-validate.test.js @@ -213,5 +213,132 @@ describe('Config Validation', () => { 'The option `i18n.redirectToDefaultLocale` is only useful when the `i18n.prefixDefaultLocale` is set to `true`. Remove the option `i18n.redirectToDefaultLocale`, or change its value to `true`.' ); }); + + it('errors if a domains key does not exist', async () => { + const configError = await validateConfig( + { + output: 'server', + i18n: { + defaultLocale: 'en', + locales: ['es', 'en'], + domains: { + lorem: 'https://example.com', + }, + }, + }, + process.cwd() + ).catch((err) => err); + expect(configError instanceof z.ZodError).to.equal(true); + expect(configError.errors[0].message).to.equal( + "The locale `lorem` key in the `i18n.domains` record doesn't exist in the `i18n.locales` array." + ); + }); + + it('errors if a domains value is not an URL', async () => { + const configError = await validateConfig( + { + output: 'server', + i18n: { + defaultLocale: 'en', + locales: ['es', 'en'], + domains: { + en: 'www.example.com', + }, + }, + }, + process.cwd() + ).catch((err) => err); + expect(configError instanceof z.ZodError).to.equal(true); + expect(configError.errors[0].message).to.equal( + "The domain value must be a valid URL, and it has to start with 'https' or 'http'." + ); + }); + + it('errors if a domains value is not an URL with incorrect protocol', async () => { + const configError = await validateConfig( + { + output: 'server', + i18n: { + defaultLocale: 'en', + locales: ['es', 'en'], + domains: { + en: 'tcp://www.example.com', + }, + }, + }, + process.cwd() + ).catch((err) => err); + expect(configError instanceof z.ZodError).to.equal(true); + expect(configError.errors[0].message).to.equal( + "The domain value must be a valid URL, and it has to start with 'https' or 'http'." + ); + }); + + it('errors if a domain is a URL with a pathname that is not the home', async () => { + const configError = await validateConfig( + { + output: 'server', + i18n: { + defaultLocale: 'en', + locales: ['es', 'en'], + domains: { + en: 'https://www.example.com/blog/page/', + }, + }, + }, + process.cwd() + ).catch((err) => err); + expect(configError instanceof z.ZodError).to.equal(true); + expect(configError.errors[0].message).to.equal( + "The URL `https://www.example.com/blog/page/` must contain only the origin. A subsequent pathname isn't allowed here. Remove `/blog/page/`." + ); + }); + + it('errors if domains is enabled but site is not provided', async () => { + const configError = await validateConfig( + { + output: 'server', + i18n: { + defaultLocale: 'en', + locales: ['es', 'en'], + domains: { + en: 'https://www.example.com/', + }, + }, + experimental: { + i18nDomains: true, + }, + }, + process.cwd() + ).catch((err) => err); + expect(configError instanceof z.ZodError).to.equal(true); + expect(configError.errors[0].message).to.equal( + "The option `site` isn't set. When using the 'domains' strategy for `i18n`, `site` is required to create absolute URLs for locales that aren't mapped to a domain." + ); + }); + + it('errors if domains is enabled but the `output` is not "server"', async () => { + const configError = await validateConfig( + { + output: 'static', + i18n: { + defaultLocale: 'en', + locales: ['es', 'en'], + domains: { + en: 'https://www.example.com/', + }, + }, + experimental: { + i18nDomains: true, + }, + site: 'https://foo.org', + }, + process.cwd() + ).catch((err) => err); + expect(configError instanceof z.ZodError).to.equal(true); + expect(configError.errors[0].message).to.equal( + 'Domain support is only available when `output` is `"server"`.' + ); + }); }); }); diff --git a/packages/astro/test/units/i18n/astro_i18n.test.js b/packages/astro/test/units/i18n/astro_i18n.test.js index 011193accdeb..e3364924af8f 100644 --- a/packages/astro/test/units/i18n/astro_i18n.test.js +++ b/packages/astro/test/units/i18n/astro_i18n.test.js @@ -142,18 +142,16 @@ describe('getLocaleRelativeUrl', () => { * @type {import("../../../dist/@types").AstroUserConfig} */ const config = { - experimental: { - i18n: { - defaultLocale: 'en', - locales: [ - 'en', - 'es', - { - path: 'italiano', - codes: ['it', 'it-VA'], - }, - ], - }, + i18n: { + defaultLocale: 'en', + locales: [ + 'en', + 'es', + { + path: 'italiano', + codes: ['it', 'it-VA'], + }, + ], }, }; // directory format @@ -161,7 +159,7 @@ describe('getLocaleRelativeUrl', () => { getLocaleRelativeUrl({ locale: 'en', base: '/blog', - ...config.experimental.i18n, + ...config.i18n, trailingSlash: 'never', format: 'directory', }) @@ -170,7 +168,7 @@ describe('getLocaleRelativeUrl', () => { getLocaleRelativeUrl({ locale: 'es', base: '/blog/', - ...config.experimental.i18n, + ...config.i18n, trailingSlash: 'always', format: 'directory', }) @@ -180,7 +178,7 @@ describe('getLocaleRelativeUrl', () => { getLocaleRelativeUrl({ locale: 'it-VA', base: '/blog/', - ...config.experimental.i18n, + ...config.i18n, trailingSlash: 'always', format: 'file', }) @@ -190,7 +188,7 @@ describe('getLocaleRelativeUrl', () => { getLocaleRelativeUrl({ locale: 'en', base: '/blog/', - ...config.experimental.i18n, + ...config.i18n, trailingSlash: 'ignore', format: 'directory', }) @@ -201,7 +199,7 @@ describe('getLocaleRelativeUrl', () => { getLocaleRelativeUrl({ locale: 'en', base: '/blog', - ...config.experimental.i18n, + ...config.i18n, trailingSlash: 'never', format: 'file', }) @@ -210,7 +208,7 @@ describe('getLocaleRelativeUrl', () => { getLocaleRelativeUrl({ locale: 'es', base: '/blog/', - ...config.experimental.i18n, + ...config.i18n, trailingSlash: 'always', format: 'file', }) @@ -221,7 +219,7 @@ describe('getLocaleRelativeUrl', () => { locale: 'en', // ignore + file => no trailing slash base: '/blog', - ...config.experimental.i18n, + ...config.i18n, trailingSlash: 'ignore', format: 'file', }) @@ -461,7 +459,7 @@ describe('getLocaleRelativeUrlList', () => { trailingSlash: 'never', format: 'directory', }) - ).to.have.members(['/blog', '/blog/en_US', '/blog/es', '/blog/italiano']); + ).to.have.members(['/blog', '/blog/en-us', '/blog/es', '/blog/italiano']); }); it('should retrieve the correct list of base URL with locales [format: directory, trailingSlash: always]', () => { @@ -494,7 +492,7 @@ describe('getLocaleRelativeUrlList', () => { trailingSlash: 'always', format: 'directory', }) - ).to.have.members(['/blog/', '/blog/en_US/', '/blog/es/', '/blog/italiano/']); + ).to.have.members(['/blog/', '/blog/en-us/', '/blog/es/', '/blog/italiano/']); }); it('should retrieve the correct list of base URL with locales [format: file, trailingSlash: always]', () => { @@ -519,7 +517,7 @@ describe('getLocaleRelativeUrlList', () => { trailingSlash: 'always', format: 'file', }) - ).to.have.members(['/blog/', '/blog/en_US/', '/blog/es/']); + ).to.have.members(['/blog/', '/blog/en-us/', '/blog/es/']); }); it('should retrieve the correct list of base URL with locales [format: file, trailingSlash: never]', () => { @@ -544,7 +542,7 @@ describe('getLocaleRelativeUrlList', () => { trailingSlash: 'never', format: 'file', }) - ).to.have.members(['/blog', '/blog/en_US', '/blog/es']); + ).to.have.members(['/blog', '/blog/en-us', '/blog/es']); }); it('should retrieve the correct list of base URL with locales [format: file, trailingSlash: ignore]', () => { @@ -569,7 +567,7 @@ describe('getLocaleRelativeUrlList', () => { trailingSlash: 'ignore', format: 'file', }) - ).to.have.members(['/blog', '/blog/en_US', '/blog/es']); + ).to.have.members(['/blog', '/blog/en-us', '/blog/es']); }); it('should retrieve the correct list of base URL with locales [format: directory, trailingSlash: ignore]', () => { @@ -594,7 +592,7 @@ describe('getLocaleRelativeUrlList', () => { trailingSlash: 'ignore', format: 'directory', }) - ).to.have.members(['/blog/', '/blog/en_US/', '/blog/es/']); + ).to.have.members(['/blog/', '/blog/en-us/', '/blog/es/']); }); it('should retrieve the correct list of base URL with locales [format: directory, trailingSlash: never, routingStategy: pathname-prefix-always]', () => { @@ -620,7 +618,7 @@ describe('getLocaleRelativeUrlList', () => { trailingSlash: 'never', format: 'directory', }) - ).to.have.members(['/blog/en', '/blog/en_US', '/blog/es']); + ).to.have.members(['/blog/en', '/blog/en-us', '/blog/es']); }); it('should retrieve the correct list of base URL with locales [format: directory, trailingSlash: never, routingStategy: pathname-prefix-always-no-redirect]', () => { @@ -646,456 +644,806 @@ describe('getLocaleRelativeUrlList', () => { trailingSlash: 'never', format: 'directory', }) - ).to.have.members(['/blog/en', '/blog/en_US', '/blog/es']); + ).to.have.members(['/blog/en', '/blog/en-us', '/blog/es']); }); }); describe('getLocaleAbsoluteUrl', () => { - it('should correctly return the URL with the base', () => { - /** - * - * @type {import("../../../dist/@types").AstroUserConfig} - */ - const config = { - base: '/blog', - experimental: { - i18n: { - defaultLocale: 'en', - locales: [ - 'en', - 'en_US', - 'es', - { - path: 'italiano', - codes: ['it', 'it-VA'], + describe('with [prefix-other-locales]', () => { + it('should correctly return the URL with the base', () => { + /** + * + * @type {import("../../../dist/@types").AstroUserConfig} + */ + const config = { + base: '/blog', + experimental: { + i18n: { + defaultLocale: 'en', + locales: [ + 'en', + 'en_US', + 'es', + { + path: 'italiano', + codes: ['it', 'it-VA'], + }, + ], + domains: { + es: 'https://es.example.com', }, - ], + routingStrategy: 'prefix-other-locales', + }, }, - }, - }; - - // directory format - expect( - getLocaleAbsoluteUrl({ - locale: 'en', - base: '/blog/', - trailingSlash: 'always', - format: 'directory', - site: 'https://example.com', - ...config.experimental.i18n, - }) - ).to.eq('https://example.com/blog/'); - expect( - getLocaleAbsoluteUrl({ - locale: 'es', - base: '/blog/', - ...config.experimental.i18n, - trailingSlash: 'always', - format: 'directory', - site: 'https://example.com', - }) - ).to.eq('https://example.com/blog/es/'); - - expect( - getLocaleAbsoluteUrl({ - locale: 'en_US', - base: '/blog/', - ...config.experimental.i18n, - trailingSlash: 'always', - format: 'directory', - site: 'https://example.com', - }) - ).to.throw; - - // file format - expect( - getLocaleAbsoluteUrl({ - locale: 'en', - base: '/blog/', - ...config.experimental.i18n, - trailingSlash: 'always', - format: 'file', - site: 'https://example.com', - }) - ).to.eq('https://example.com/blog/'); - expect( - getLocaleAbsoluteUrl({ - locale: 'es', - base: '/blog/', - ...config.experimental.i18n, - trailingSlash: 'always', - format: 'file', - site: 'https://example.com', - }) - ).to.eq('https://example.com/blog/es/'); - - expect( - getLocaleAbsoluteUrl({ - locale: 'en_US', - base: '/blog/', - ...config.experimental.i18n, - trailingSlash: 'always', - format: 'file', - site: 'https://example.com', - }) - ).to.throw; - expect( - getLocaleAbsoluteUrl({ - locale: 'it-VA', - base: '/blog/', - ...config.experimental.i18n, - trailingSlash: 'always', - format: 'file', - site: 'https://example.com', - }) - ).to.eq('https://example.com/blog/italiano/'); + }; + + // directory format + expect( + getLocaleAbsoluteUrl({ + locale: 'en', + base: '/blog/', + trailingSlash: 'always', + format: 'directory', + site: 'https://example.com', + ...config.experimental.i18n, + }) + ).to.eq('https://example.com/blog/'); + expect( + getLocaleAbsoluteUrl({ + locale: 'es', + base: '/blog/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'directory', + site: 'https://example.com', + }) + ).to.eq('https://example.com/blog/es/'); + + expect( + getLocaleAbsoluteUrl({ + locale: 'es', + base: '/blog/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'directory', + site: 'https://example.com', + isBuild: true, + }) + ).to.eq('https://es.example.com/blog/'); + + expect( + getLocaleAbsoluteUrl({ + locale: 'en_US', + base: '/blog/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'directory', + site: 'https://example.com', + }) + ).to.throw; + + // file format + expect( + getLocaleAbsoluteUrl({ + locale: 'en', + base: '/blog/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'file', + site: 'https://example.com', + }) + ).to.eq('https://example.com/blog/'); + expect( + getLocaleAbsoluteUrl({ + locale: 'es', + base: '/blog/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'file', + site: 'https://example.com', + }) + ).to.eq('https://example.com/blog/es/'); + + expect( + getLocaleAbsoluteUrl({ + locale: 'en_US', + base: '/blog/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'file', + site: 'https://example.com', + }) + ).to.throw; + expect( + getLocaleAbsoluteUrl({ + locale: 'it-VA', + base: '/blog/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'file', + site: 'https://example.com', + }) + ).to.eq('https://example.com/blog/italiano/'); + + expect( + getLocaleAbsoluteUrl({ + locale: 'en_US', + base: '/blog/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'file', + site: 'https://example.com', + }) + ).to.throw; + + expect( + getLocaleAbsoluteUrl({ + locale: 'es', + base: '/blog/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'file', + site: 'https://example.com', + isBuild: true, + }) + ).to.eq('https://es.example.com/blog/'); + + expect( + getLocaleAbsoluteUrl({ + locale: 'es', + base: '/blog/', + prependWith: 'some-name', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'file', + site: 'https://example.com', + path: 'first-post', + isBuild: true, + }) + ).to.eq('https://es.example.com/blog/some-name/first-post/'); + + // en isn't mapped to a domain + expect( + getLocaleAbsoluteUrl({ + locale: 'en', + base: '/blog/', + prependWith: 'some-name', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'file', + site: 'https://example.com', + path: 'first-post', + isBuild: true, + }) + ).to.eq('/blog/some-name/first-post/'); + }); }); - - it('should correctly return the URL without base', () => { - /** - * - * @type {import("../../../dist/@types").AstroUserConfig} - */ - const config = { - experimental: { - i18n: { - defaultLocale: 'en', - locales: [ - 'en', - 'es', - { - path: 'italiano', - codes: ['it', 'it-VA'], + describe('with [prefix-always]', () => { + it('should correctly return the URL with the base', () => { + /** + * + * @type {import("../../../dist/@types").AstroUserConfig} + */ + const config = { + base: '/blog', + experimental: { + i18n: { + defaultLocale: 'en', + locales: ['en', 'en_US', 'es'], + domains: { + es: 'https://es.example.com', }, - ], + routing: 'pathname-prefix-always', + }, }, - }, - }; - - expect( - getLocaleAbsoluteUrl({ - locale: 'en', - base: '/', - ...config.experimental.i18n, - trailingSlash: 'always', - format: 'directory', - site: 'https://example.com', - }) - ).to.eq('https://example.com/'); - expect( - getLocaleAbsoluteUrl({ - locale: 'es', - base: '/', - ...config.experimental.i18n, - trailingSlash: 'always', - format: 'directory', - site: 'https://example.com', - }) - ).to.eq('https://example.com/es/'); - expect( - getLocaleAbsoluteUrl({ - locale: 'it-VA', - base: '/', - ...config.experimental.i18n, - trailingSlash: 'always', - format: 'directory', - site: 'https://example.com', - }) - ).to.eq('https://example.com/italiano/'); - }); - - it('should correctly handle the trailing slash', () => { - /** - * - * @type {import("../../../dist/@types").AstroUserConfig} - */ - const config = { - experimental: { + }; + + // directory format + expect( + getLocaleAbsoluteUrl({ + locale: 'en', + base: '/blog/', + trailingSlash: 'always', + format: 'directory', + site: 'https://example.com', + ...config.experimental.i18n, + }) + ).to.eq('https://example.com/blog/en/'); + + expect( + getLocaleAbsoluteUrl({ + locale: 'es', + base: '/blog/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'directory', + site: 'https://example.com', + }) + ).to.eq('https://example.com/blog/es/'); + + expect( + getLocaleAbsoluteUrl({ + locale: 'en_US', + base: '/blog/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'directory', + site: 'https://example.com', + }) + ).to.throw; + + // file format + expect( + getLocaleAbsoluteUrl({ + locale: 'en', + base: '/blog/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'file', + site: 'https://example.com', + }) + ).to.eq('https://example.com/blog/en/'); + expect( + getLocaleAbsoluteUrl({ + locale: 'es', + base: '/blog/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'file', + site: 'https://example.com', + }) + ).to.eq('https://example.com/blog/es/'); + + expect( + getLocaleAbsoluteUrl({ + locale: 'en_US', + base: '/blog/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'file', + site: 'https://example.com', + }) + ).to.throw; + + expect( + getLocaleAbsoluteUrl({ + locale: 'es', + base: '/blog/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'file', + site: 'https://example.com', + isBuild: true, + }) + ).to.eq('https://es.example.com/blog/'); + + expect( + getLocaleAbsoluteUrl({ + locale: 'es', + base: '/blog/', + prependWith: 'some-name', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'file', + site: 'https://example.com', + path: 'first-post', + isBuild: true, + }) + ).to.eq('https://es.example.com/blog/some-name/first-post/'); + }); + it('should correctly return the URL without base', () => { + /** + * + * @type {import("../../../dist/@types").AstroUserConfig} + */ + const config = { + experimental: { + i18n: { + defaultLocale: 'en', + locales: ['en', 'es'], + routing: 'pathname-prefix-always', + }, + }, + }; + + expect( + getLocaleAbsoluteUrl({ + locale: 'en', + base: '/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'directory', + site: 'https://example.com', + }) + ).to.eq('https://example.com/en/'); + expect( + getLocaleAbsoluteUrl({ + locale: 'es', + base: '/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'directory', + site: 'https://example.com', + }) + ).to.eq('https://example.com/es/'); + }); + + it('should correctly handle the trailing slash', () => { + /** + * + * @type {import("../../../dist/@types").AstroUserConfig} + */ + const config = { i18n: { defaultLocale: 'en', locales: ['en', 'es'], + routing: 'pathname-prefix-always', }, - }, - }; - // directory format - expect( - getLocaleAbsoluteUrl({ - locale: 'en', + }; + // directory format + expect( + getLocaleAbsoluteUrl({ + locale: 'en', + base: '/blog', + ...config.i18n, + trailingSlash: 'never', + format: 'directory', + site: 'https://example.com', + }) + ).to.eq('https://example.com/blog/en'); + expect( + getLocaleAbsoluteUrl({ + locale: 'es', + base: '/blog/', + ...config.i18n, + trailingSlash: 'always', + format: 'directory', + site: 'https://example.com', + }) + ).to.eq('https://example.com/blog/es/'); + + expect( + getLocaleAbsoluteUrl({ + locale: 'en', + base: '/blog/', + ...config.i18n, + trailingSlash: 'ignore', + format: 'directory', + site: 'https://example.com', + }) + ).to.eq('https://example.com/blog/en/'); + + // directory file + expect( + getLocaleAbsoluteUrl({ + locale: 'en', + base: '/blog', + ...config.i18n, + trailingSlash: 'never', + format: 'file', + site: 'https://example.com', + }) + ).to.eq('https://example.com/blog/en'); + expect( + getLocaleAbsoluteUrl({ + locale: 'es', + base: '/blog/', + ...config.i18n, + trailingSlash: 'always', + format: 'file', + site: 'https://example.com', + }) + ).to.eq('https://example.com/blog/es/'); + + expect( + getLocaleAbsoluteUrl({ + locale: 'en', + // ignore + file => no trailing slash + base: '/blog', + ...config.i18n, + trailingSlash: 'ignore', + format: 'file', + site: 'https://example.com', + }) + ).to.eq('https://example.com/blog/en'); + }); + + it('should normalize locales', () => { + /** + * + * @type {import("../../../dist/@types").AstroUserConfig} + */ + const config = { base: '/blog', - ...config.experimental.i18n, - trailingSlash: 'never', - format: 'directory', - site: 'https://example.com', - }) - ).to.eq('https://example.com/blog'); - expect( - getLocaleAbsoluteUrl({ - locale: 'es', - base: '/blog/', - ...config.experimental.i18n, - trailingSlash: 'always', - format: 'directory', - site: 'https://example.com', - }) - ).to.eq('https://example.com/blog/es/'); - - expect( - getLocaleAbsoluteUrl({ - locale: 'en', - base: '/blog/', - ...config.experimental.i18n, - trailingSlash: 'ignore', - format: 'directory', - site: 'https://example.com', - }) - ).to.eq('https://example.com/blog/'); - - // directory file - expect( - getLocaleAbsoluteUrl({ - locale: 'en', + experimental: { + i18n: { + defaultLocale: 'en', + locales: ['en', 'en_US', 'en_AU'], + routingStrategy: 'pathname-prefix-always', + }, + }, + }; + + expect( + getLocaleAbsoluteUrl({ + locale: 'en_US', + base: '/blog/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'directory', + }) + ).to.eq('/blog/en-us/'); + + expect( + getLocaleAbsoluteUrl({ + locale: 'en_AU', + base: '/blog/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'directory', + }) + ).to.eq('/blog/en-au/'); + + expect( + getLocaleAbsoluteUrl({ + locale: 'en_US', + base: '/blog/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'directory', + normalizeLocale: true, + }) + ).to.eq('/blog/en-us/'); + }); + + it('should return the default locale when routing strategy is [pathname-prefix-always]', () => { + /** + * + * @type {import("../../../dist/@types").AstroUserConfig} + */ + const config = { base: '/blog', - ...config.experimental.i18n, - trailingSlash: 'never', - format: 'file', - site: 'https://example.com', - }) - ).to.eq('https://example.com/blog'); - expect( - getLocaleAbsoluteUrl({ - locale: 'es', - base: '/blog/', - ...config.experimental.i18n, - trailingSlash: 'always', - format: 'file', - site: 'https://example.com', - }) - ).to.eq('https://example.com/blog/es/'); - - expect( - getLocaleAbsoluteUrl({ - locale: 'en', - // ignore + file => no trailing slash + experimental: { + i18n: { + defaultLocale: 'en', + locales: ['en', 'es', 'en_US', 'en_AU'], + routing: 'pathname-prefix-always', + }, + }, + }; + + // directory format + expect( + getLocaleAbsoluteUrl({ + locale: 'en', + base: '/blog/', + trailingSlash: 'always', + site: 'https://example.com', + format: 'directory', + ...config.experimental.i18n, + }) + ).to.eq('https://example.com/blog/en/'); + expect( + getLocaleAbsoluteUrl({ + locale: 'es', + base: '/blog/', + ...config.experimental.i18n, + site: 'https://example.com', + trailingSlash: 'always', + format: 'directory', + }) + ).to.eq('https://example.com/blog/es/'); + + expect( + getLocaleAbsoluteUrl({ + locale: 'en_US', + base: '/blog/', + ...config.experimental.i18n, + site: 'https://example.com', + trailingSlash: 'always', + format: 'directory', + }) + ).to.throw; + + // file format + expect( + getLocaleAbsoluteUrl({ + locale: 'en', + base: '/blog/', + ...config.experimental.i18n, + site: 'https://example.com', + trailingSlash: 'always', + format: 'file', + }) + ).to.eq('https://example.com/blog/en/'); + expect( + getLocaleAbsoluteUrl({ + locale: 'es', + base: '/blog/', + ...config.experimental.i18n, + site: 'https://example.com', + trailingSlash: 'always', + format: 'file', + }) + ).to.eq('https://example.com/blog/es/'); + + expect( + getLocaleAbsoluteUrl({ + locale: 'en_US', + base: '/blog/', + ...config.experimental.i18n, + site: 'https://example.com', + trailingSlash: 'always', + format: 'file', + }) + ).to.throw; + }); + + it('should return the default locale when routing strategy is [pathname-prefix-always-no-redirect]', () => { + /** + * + * @type {import("../../../dist/@types").AstroUserConfig} + */ + const config = { base: '/blog', - ...config.experimental.i18n, - trailingSlash: 'ignore', - format: 'file', - site: 'https://example.com', - }) - ).to.eq('https://example.com/blog'); - }); - - it('should normalize locales', () => { - /** - * - * @type {import("../../../dist/@types").AstroUserConfig} - */ - const config = { - base: '/blog', - experimental: { - i18n: { - defaultLocale: 'en', - locales: ['en', 'en_US', 'en_AU'], + experimental: { + i18n: { + defaultLocale: 'en', + locales: ['en', 'es', 'en_US', 'en_AU'], + routing: 'pathname-prefix-always-no-redirect', + }, }, - }, - }; - - expect( - getLocaleAbsoluteUrl({ - locale: 'en_US', - base: '/blog/', - ...config.experimental.i18n, - trailingSlash: 'always', - format: 'directory', - }) - ).to.eq('/blog/en-us/'); - - expect( - getLocaleAbsoluteUrl({ - locale: 'en_AU', - base: '/blog/', - ...config.experimental.i18n, - trailingSlash: 'always', - format: 'directory', - }) - ).to.eq('/blog/en-au/'); - - expect( - getLocaleAbsoluteUrl({ - locale: 'en_US', - base: '/blog/', - ...config.experimental.i18n, - trailingSlash: 'always', - format: 'directory', - normalizeLocale: true, - }) - ).to.eq('/blog/en-us/'); - }); - - it('should return the default locale when routing strategy is [pathname-prefix-always]', () => { - /** - * - * @type {import("../../../dist/@types").AstroUserConfig} - */ - const config = { - base: '/blog', - experimental: { - i18n: { - defaultLocale: 'en', - locales: ['en', 'es', 'en_US', 'en_AU'], - routing: 'pathname-prefix-always', + }; + + // directory format + expect( + getLocaleAbsoluteUrl({ + locale: 'en', + base: '/blog/', + trailingSlash: 'always', + site: 'https://example.com', + format: 'directory', + ...config.experimental.i18n, + }) + ).to.eq('https://example.com/blog/en/'); + expect( + getLocaleAbsoluteUrl({ + locale: 'es', + base: '/blog/', + ...config.experimental.i18n, + site: 'https://example.com', + trailingSlash: 'always', + format: 'directory', + }) + ).to.eq('https://example.com/blog/es/'); + + expect( + getLocaleAbsoluteUrl({ + locale: 'en_US', + base: '/blog/', + ...config.experimental.i18n, + site: 'https://example.com', + trailingSlash: 'always', + format: 'directory', + }) + ).to.throw; + + // file format + expect( + getLocaleAbsoluteUrl({ + locale: 'en', + base: '/blog/', + ...config.experimental.i18n, + site: 'https://example.com', + trailingSlash: 'always', + format: 'file', + }) + ).to.eq('https://example.com/blog/en/'); + expect( + getLocaleAbsoluteUrl({ + locale: 'es', + base: '/blog/', + ...config.experimental.i18n, + site: 'https://example.com', + trailingSlash: 'always', + format: 'file', + }) + ).to.eq('https://example.com/blog/es/'); + + expect( + getLocaleAbsoluteUrl({ + locale: 'en_US', + base: '/blog/', + ...config.experimental.i18n, + site: 'https://example.com', + trailingSlash: 'always', + format: 'file', + }) + ).to.throw; + }); + it('should correctly return the URL without base', () => { + /** + * + * @type {import("../../../dist/@types").AstroUserConfig} + */ + const config = { + experimental: { + i18n: { + defaultLocale: 'en', + locales: [ + 'en', + 'es', + { + path: 'italiano', + codes: ['it', 'it-VA'], + }, + ], + routingStrategy: 'prefix-other-locales', + }, }, - }, - }; - - // directory format - expect( - getLocaleAbsoluteUrl({ - locale: 'en', - base: '/blog/', - trailingSlash: 'always', - site: 'https://example.com', - format: 'directory', - ...config.experimental.i18n, - }) - ).to.eq('https://example.com/blog/en/'); - expect( - getLocaleAbsoluteUrl({ - locale: 'es', - base: '/blog/', - ...config.experimental.i18n, - site: 'https://example.com', - trailingSlash: 'always', - format: 'directory', - }) - ).to.eq('https://example.com/blog/es/'); - - expect( - getLocaleAbsoluteUrl({ - locale: 'en_US', - base: '/blog/', - ...config.experimental.i18n, - site: 'https://example.com', - trailingSlash: 'always', - format: 'directory', - }) - ).to.throw; - - // file format - expect( - getLocaleAbsoluteUrl({ - locale: 'en', - base: '/blog/', - ...config.experimental.i18n, - site: 'https://example.com', - trailingSlash: 'always', - format: 'file', - }) - ).to.eq('https://example.com/blog/en/'); - expect( - getLocaleAbsoluteUrl({ - locale: 'es', - base: '/blog/', - ...config.experimental.i18n, - site: 'https://example.com', - trailingSlash: 'always', - format: 'file', - }) - ).to.eq('https://example.com/blog/es/'); - - expect( - getLocaleAbsoluteUrl({ - locale: 'en_US', - base: '/blog/', - ...config.experimental.i18n, - site: 'https://example.com', - trailingSlash: 'always', - format: 'file', - }) - ).to.throw; - }); - - it('should return the default locale when routing strategy is [pathname-prefix-always-no-redirect]', () => { - /** - * - * @type {import("../../../dist/@types").AstroUserConfig} - */ - const config = { - base: '/blog', - experimental: { - i18n: { - defaultLocale: 'en', - locales: ['en', 'es', 'en_US', 'en_AU'], - routing: 'pathname-prefix-always-no-redirect', + }; + + expect( + getLocaleAbsoluteUrl({ + locale: 'en', + base: '/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'directory', + site: 'https://example.com', + }) + ).to.eq('https://example.com/'); + expect( + getLocaleAbsoluteUrl({ + locale: 'es', + base: '/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'directory', + site: 'https://example.com', + }) + ).to.eq('https://example.com/es/'); + expect( + getLocaleAbsoluteUrl({ + locale: 'it-VA', + base: '/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'directory', + site: 'https://example.com', + }) + ).to.eq('https://example.com/italiano/'); + }); + + it('should correctly handle the trailing slash', () => { + /** + * + * @type {import("../../../dist/@types").AstroUserConfig} + */ + const config = { + experimental: { + i18n: { + defaultLocale: 'en', + locales: ['en', 'es'], + routingStrategy: 'prefix-other-locales', + }, }, - }, - }; - - // directory format - expect( - getLocaleAbsoluteUrl({ - locale: 'en', - base: '/blog/', - trailingSlash: 'always', - site: 'https://example.com', - format: 'directory', - ...config.experimental.i18n, - }) - ).to.eq('https://example.com/blog/en/'); - expect( - getLocaleAbsoluteUrl({ - locale: 'es', - base: '/blog/', - ...config.experimental.i18n, - site: 'https://example.com', - trailingSlash: 'always', - format: 'directory', - }) - ).to.eq('https://example.com/blog/es/'); - - expect( - getLocaleAbsoluteUrl({ - locale: 'en_US', - base: '/blog/', - ...config.experimental.i18n, - site: 'https://example.com', - trailingSlash: 'always', - format: 'directory', - }) - ).to.throw; - - // file format - expect( - getLocaleAbsoluteUrl({ - locale: 'en', - base: '/blog/', - ...config.experimental.i18n, - site: 'https://example.com', - trailingSlash: 'always', - format: 'file', - }) - ).to.eq('https://example.com/blog/en/'); - expect( - getLocaleAbsoluteUrl({ - locale: 'es', - base: '/blog/', - ...config.experimental.i18n, - site: 'https://example.com', - trailingSlash: 'always', - format: 'file', - }) - ).to.eq('https://example.com/blog/es/'); - - expect( - getLocaleAbsoluteUrl({ - locale: 'en_US', - base: '/blog/', - ...config.experimental.i18n, - site: 'https://example.com', - trailingSlash: 'always', - format: 'file', - }) - ).to.throw; + }; + // directory format + expect( + getLocaleAbsoluteUrl({ + locale: 'en', + base: '/blog', + ...config.experimental.i18n, + trailingSlash: 'never', + format: 'directory', + site: 'https://example.com', + }) + ).to.eq('https://example.com/blog'); + expect( + getLocaleAbsoluteUrl({ + locale: 'es', + base: '/blog/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'directory', + site: 'https://example.com', + }) + ).to.eq('https://example.com/blog/es/'); + + expect( + getLocaleAbsoluteUrl({ + locale: 'en', + base: '/blog/', + ...config.experimental.i18n, + trailingSlash: 'ignore', + format: 'directory', + site: 'https://example.com', + }) + ).to.eq('https://example.com/blog/'); + + // directory file + expect( + getLocaleAbsoluteUrl({ + locale: 'en', + base: '/blog', + ...config.experimental.i18n, + trailingSlash: 'never', + format: 'file', + site: 'https://example.com', + }) + ).to.eq('https://example.com/blog'); + expect( + getLocaleAbsoluteUrl({ + locale: 'es', + base: '/blog/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'file', + site: 'https://example.com', + }) + ).to.eq('https://example.com/blog/es/'); + + expect( + getLocaleAbsoluteUrl({ + locale: 'en', + // ignore + file => no trailing slash + base: '/blog', + ...config.experimental.i18n, + trailingSlash: 'ignore', + format: 'file', + site: 'https://example.com', + }) + ).to.eq('https://example.com/blog'); + }); + + it('should normalize locales', () => { + /** + * + * @type {import("../../../dist/@types").AstroUserConfig} + */ + const config = { + base: '/blog', + experimental: { + i18n: { + defaultLocale: 'en', + locales: ['en', 'en_US', 'en_AU'], + routingStrategy: 'prefix-other-locales', + }, + }, + }; + + expect( + getLocaleAbsoluteUrl({ + locale: 'en_US', + base: '/blog/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'directory', + }) + ).to.eq('/blog/en-us/'); + + expect( + getLocaleAbsoluteUrl({ + locale: 'en_AU', + base: '/blog/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'directory', + }) + ).to.eq('/blog/en-au/'); + + expect( + getLocaleAbsoluteUrl({ + locale: 'en_US', + base: '/blog/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'directory', + normalizeLocale: true, + }) + ).to.eq('/blog/en-us/'); + }); }); }); @@ -1133,7 +1481,7 @@ describe('getLocaleAbsoluteUrlList', () => { }) ).to.have.members([ 'https://example.com/blog', - 'https://example.com/blog/en_US', + 'https://example.com/blog/en-us', 'https://example.com/blog/es', 'https://example.com/blog/italiano', ]); @@ -1164,7 +1512,7 @@ describe('getLocaleAbsoluteUrlList', () => { }) ).to.have.members([ 'https://example.com/blog/', - 'https://example.com/blog/en_US/', + 'https://example.com/blog/en-us/', 'https://example.com/blog/es/', ]); }); @@ -1202,7 +1550,7 @@ describe('getLocaleAbsoluteUrlList', () => { }) ).to.have.members([ 'https://example.com/blog/', - 'https://example.com/blog/en_US/', + 'https://example.com/blog/en-us/', 'https://example.com/blog/es/', 'https://example.com/blog/italiano/', ]); @@ -1233,7 +1581,7 @@ describe('getLocaleAbsoluteUrlList', () => { }) ).to.have.members([ 'https://example.com/blog', - 'https://example.com/blog/en_US', + 'https://example.com/blog/en-us', 'https://example.com/blog/es', ]); }); @@ -1263,7 +1611,7 @@ describe('getLocaleAbsoluteUrlList', () => { }) ).to.have.members([ 'https://example.com/blog', - 'https://example.com/blog/en_US', + 'https://example.com/blog/en-us', 'https://example.com/blog/es', ]); }); @@ -1293,7 +1641,7 @@ describe('getLocaleAbsoluteUrlList', () => { }) ).to.have.members([ 'https://example.com/blog/', - 'https://example.com/blog/en_US/', + 'https://example.com/blog/en-us/', 'https://example.com/blog/es/', ]); }); @@ -1324,7 +1672,7 @@ describe('getLocaleAbsoluteUrlList', () => { }) ).to.have.members([ 'https://example.com/blog/en/', - 'https://example.com/blog/en_US/', + 'https://example.com/blog/en-us/', 'https://example.com/blog/es/', ]); }); @@ -1355,10 +1703,45 @@ describe('getLocaleAbsoluteUrlList', () => { }) ).to.have.members([ 'https://example.com/blog/en/', - 'https://example.com/blog/en_US/', + 'https://example.com/blog/en-us/', 'https://example.com/blog/es/', ]); }); + + it('should retrieve the correct list of base URLs, swapped with the correct domain', () => { + /** + * + * @type {import("../../../dist/@types").AstroUserConfig} + */ + const config = { + experimental: { + i18n: { + defaultLocale: 'en', + locales: ['en', 'en_US', 'es'], + routingStrategy: 'pathname-prefix-always', + domains: { + es: 'https://es.example.com', + en: 'https://example.uk', + }, + }, + }, + }; + // directory format + expect( + getLocaleAbsoluteUrlList({ + base: '/blog/', + ...config.experimental.i18n, + trailingSlash: 'ignore', + format: 'directory', + site: 'https://example.com', + isBuild: true, + }) + ).to.have.members([ + 'https://example.uk/blog/', + 'https://example.com/blog/en-us/', + 'https://es.example.com/blog/', + ]); + }); }); describe('parse accept-header', () => { diff --git a/packages/astro/test/units/integrations/api.test.js b/packages/astro/test/units/integrations/api.test.js index ff700833ef84..882570db2589 100644 --- a/packages/astro/test/units/integrations/api.test.js +++ b/packages/astro/test/units/integrations/api.test.js @@ -125,6 +125,7 @@ describe('Astro feature map', function () { { output: 'hybrid', }, + {}, defaultLogger ); expect(result['hybridOutput']).to.be.true; @@ -137,6 +138,7 @@ describe('Astro feature map', function () { { output: 'hybrid', }, + {}, defaultLogger ); expect(result['hybridOutput']).to.be.false; @@ -149,6 +151,7 @@ describe('Astro feature map', function () { { output: 'hybrid', }, + {}, defaultLogger ); expect(result['hybridOutput']).to.be.false; @@ -162,6 +165,7 @@ describe('Astro feature map', function () { { output: 'static', }, + {}, defaultLogger ); expect(result['staticOutput']).to.be.true; @@ -174,6 +178,7 @@ describe('Astro feature map', function () { { output: 'static', }, + {}, defaultLogger ); expect(result['staticOutput']).to.be.false; @@ -187,6 +192,7 @@ describe('Astro feature map', function () { { output: 'hybrid', }, + {}, defaultLogger ); expect(result['hybridOutput']).to.be.true; @@ -201,6 +207,7 @@ describe('Astro feature map', function () { { output: 'hybrid', }, + {}, defaultLogger ); expect(result['hybridOutput']).to.be.false; @@ -214,6 +221,7 @@ describe('Astro feature map', function () { { output: 'server', }, + {}, defaultLogger ); expect(result['serverOutput']).to.be.true; @@ -228,6 +236,7 @@ describe('Astro feature map', function () { { output: 'server', }, + {}, defaultLogger ); expect(result['serverOutput']).to.be.false; @@ -251,6 +260,7 @@ describe('Astro feature map', function () { }, }, }, + {}, defaultLogger ); expect(result['assets']).to.be.true; @@ -271,6 +281,7 @@ describe('Astro feature map', function () { }, }, }, + {}, defaultLogger ); expect(result['assets']).to.be.true; @@ -292,6 +303,7 @@ describe('Astro feature map', function () { }, }, }, + {}, defaultLogger ); expect(result['assets']).to.be.false; diff --git a/packages/create-astro/src/actions/typescript.ts b/packages/create-astro/src/actions/typescript.ts index efff6099453e..78f75daf523e 100644 --- a/packages/create-astro/src/actions/typescript.ts +++ b/packages/create-astro/src/actions/typescript.ts @@ -1,5 +1,4 @@ import type { Context } from './context.js'; - import { color } from '@astrojs/cli-kit'; import { readFile, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; diff --git a/packages/integrations/node/src/index.ts b/packages/integrations/node/src/index.ts index e7d655403be1..61ea5ad7471b 100644 --- a/packages/integrations/node/src/index.ts +++ b/packages/integrations/node/src/index.ts @@ -18,6 +18,7 @@ export function getAdapter(options: Options): AstroAdapter { isSharpCompatible: true, isSquooshCompatible: true, }, + i18nDomains: 'experimental', }, }; } diff --git a/packages/integrations/vercel/src/serverless/adapter.ts b/packages/integrations/vercel/src/serverless/adapter.ts index f1c75e4b4d9e..11efdc16f62d 100644 --- a/packages/integrations/vercel/src/serverless/adapter.ts +++ b/packages/integrations/vercel/src/serverless/adapter.ts @@ -82,6 +82,7 @@ function getAdapter({ isSharpCompatible: true, isSquooshCompatible: true, }, + i18nDomains: 'experimental', }, }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 943fe25c91e1..307fc01b65ec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2900,6 +2900,12 @@ importers: specifier: workspace:* version: link:../../.. + packages/astro/test/fixtures/i18n-routing-subdomain: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + packages/astro/test/fixtures/import-ts-with-js: dependencies: astro: