Skip to content

Commit

Permalink
feat(i18n): domain support (#9143)
Browse files Browse the repository at this point in the history
* 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 <sarah@rainsberger.ca>
Co-authored-by:  Matthew Phillips <matthew@skypack.dev>

* Update .changeset/tidy-carrots-jump.md

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* 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 <sarah@rainsberger.ca>

* Update packages/astro/src/@types/astro.ts

Co-authored-by: Bjorn Lu <bjornlu.dev@gmail.com>

* 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 <contact@florian-lefebvre.dev>

* ⚡️ simplified normalizeConfigPath

---------

Co-authored-by: Florian Lefebvre <contact@florian-lefebvre.dev>

* 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 <natemoo-re@users.noreply.github.com>

* chore: address feedback

* chore: fix regressions

* chore: refactor naming

* Update packages/astro/src/core/app/index.ts

Co-authored-by: Bjorn Lu <bjornlu.dev@gmail.com>

* 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 <sarah@rainsberger.ca>

* Update packages/astro/src/@types/astro.ts

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* Update .changeset/tidy-carrots-jump.md

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* 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 <sarah@rainsberger.ca>

* apply suggestion

---------

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
Co-authored-by: Matthew Phillips <matthew@skypack.dev>
Co-authored-by: Bjorn Lu <bjornlu.dev@gmail.com>
Co-authored-by: Lou Cyx <git@lou.cx>
Co-authored-by: Florian Lefebvre <contact@florian-lefebvre.dev>
Co-authored-by: Florian Lefebvre <ematipico@users.noreply.github.com>
Co-authored-by: Nate Moore <natemoo-re@users.noreply.github.com>
  • Loading branch information
8 people authored Jan 31, 2024
1 parent 43391ac commit 041fdd5
Show file tree
Hide file tree
Showing 39 changed files with 1,841 additions and 560 deletions.
6 changes: 6 additions & 0 deletions .changeset/four-masks-smell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@astrojs/vercel": minor
"@astrojs/node": minor
---

Adds experimental support for internationalization domains
5 changes: 5 additions & 0 deletions .changeset/little-panthers-relate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"astro": patch
---

Fixes an issue where the function `getLocaleRelativeUrlList` wasn't normalising the paths by default
52 changes: 52 additions & 0 deletions .changeset/tidy-carrots-jump.md
Original file line number Diff line number Diff line change
@@ -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.
87 changes: 84 additions & 3 deletions packages/astro/src/@types/astro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> }
* @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<string, string>;
};
};

Expand Down Expand Up @@ -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;
};
}

Expand Down Expand Up @@ -2133,7 +2214,7 @@ export type AstroFeatureMap = {
/**
* List of features that orbit around the i18n routing
*/
i18n?: AstroInternationalizationFeature;
i18nDomains?: SupportsKind;
};

export interface AstroAssetsFeature {
Expand All @@ -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 })[];
Expand Down
81 changes: 78 additions & 3 deletions packages/astro/src/core/app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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');
Expand Down Expand Up @@ -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<Response>;
/**
* @deprecated Instead of passing `RouteData` and locals individually, pass an object with `routeData` and `locals` properties.
Expand Down
1 change: 1 addition & 0 deletions packages/astro/src/core/app/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export type SSRManifestI18n = {
routing: RoutingStrategies;
locales: Locales;
defaultLocale: string;
domainLookupTable: Record<string, string>;
};

export type SerializedSSRManifest = Omit<
Expand Down
13 changes: 12 additions & 1 deletion packages/astro/src/core/build/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<string>();
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) {
Expand Down Expand Up @@ -429,7 +439,7 @@ function getInvalidRouteSegmentError(
route.route,
JSON.stringify(invalidParam),
JSON.stringify(received)
)
)
: `Generated path for ${route.route} is invalid.`,
hint,
});
Expand Down Expand Up @@ -652,6 +662,7 @@ function createBuildManifest(
routing: settings.config.i18n.routing,
defaultLocale: settings.config.i18n.defaultLocale,
locales: settings.config.i18n.locales,
domainLookupTable: {},
};
}
return {
Expand Down
20 changes: 20 additions & 0 deletions packages/astro/src/core/build/plugins/plugin-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -153,6 +154,7 @@ function buildManifest(
const { settings } = opts;

const routes: SerializedRouteInfo[] = [];
const domainLookupTable: Record<string, string> = {};
const entryModules = Object.fromEntries(internals.entrySpecifierToBundleMap.entries());
if (settings.scripts.some((script) => script.stage === 'page')) {
staticFiles.push(entryModules[PAGE_SCRIPT_ID]);
Expand Down Expand Up @@ -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.
Expand All @@ -241,6 +260,7 @@ function buildManifest(
routing: settings.config.i18n.routing,
locales: settings.config.i18n.locales,
defaultLocale: settings.config.i18n.defaultLocale,
domainLookupTable,
};
}

Expand Down
Loading

0 comments on commit 041fdd5

Please sign in to comment.