Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(i18n): domain support #9143

Merged
merged 70 commits into from
Jan 31, 2024
Merged
Show file tree
Hide file tree
Changes from 41 commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
5f7f011
i18n(domains): validation and updated logic (#9099)
ematipico Nov 15, 2023
daefc9e
feat(i18n): domain with lookup table (#9112)
ematipico Nov 17, 2023
dbd63d8
chore: add changelog, fix types and enable experimental support in no…
ematipico Nov 21, 2023
a749caa
rebase and update lock file
ematipico Nov 21, 2023
6b4c762
chore: fix failing test
ematipico Nov 21, 2023
56f5e3c
Apply suggestions from code review
ematipico Nov 21, 2023
85957c0
Update .changeset/tidy-carrots-jump.md
ematipico Nov 21, 2023
8e548b8
Merge remote-tracking branch 'origin/main' into feat/i18n-domain
ematipico Dec 14, 2023
db9b5e8
wip
ematipico Dec 14, 2023
9abdfd7
Merge branch 'main' into feat/i18n-domain
ematipico Dec 21, 2023
18c1aa5
chore: rebase, conflicts and tests
ematipico Dec 21, 2023
e49a187
update lock file
ematipico Dec 21, 2023
cf0b8de
chore: correct configuration
ematipico Dec 21, 2023
2980403
chore: correct configuration
ematipico Jan 2, 2024
7f36c60
Merge remote-tracking branch 'origin/main' into feat/i18n-domain
ematipico Jan 12, 2024
39c6850
fix: regressions
ematipico Jan 12, 2024
deb124e
Merge remote-tracking branch 'origin/main' into feat/i18n-domain
ematipico Jan 23, 2024
d7b5560
chore: fix conflicts and add more tests
ematipico Jan 23, 2024
ff14f70
chore: add more validation
ematipico Jan 23, 2024
9e7376b
chore: more tests and add more restrictions
ematipico Jan 24, 2024
9ed59c7
fix changeset
ematipico Jan 24, 2024
e763ab7
change and revert adapters
ematipico Jan 24, 2024
4a165b7
add another restriction
ematipico Jan 24, 2024
2d4490d
Merge remote-tracking branch 'origin/main' into feat/i18n-domain
ematipico Jan 24, 2024
2710bb6
lock file update
ematipico Jan 24, 2024
be7f6ad
Update packages/astro/src/@types/astro.ts
ematipico Jan 24, 2024
9f25484
Update packages/astro/src/@types/astro.ts
ematipico Jan 24, 2024
face797
wat
ematipico Jan 24, 2024
6f3d781
fix syntax error
ematipico Jan 24, 2024
b872987
fix config example
ematipico Jan 24, 2024
e054909
Fix for #9673 (#9680)
loucyx Jan 24, 2024
3f16c1f
Fix env var replacement for export const prerender (#9807)
bluwy Jan 24, 2024
273b44d
feat(alpinejs): allow customizing the Alpine instance (#9751)
florian-lefebvre Jan 24, 2024
b0c74c9
[ci] format
ematipico Jan 24, 2024
9503daa
chore: use correct lock file
ematipico Jan 24, 2024
9caceb1
Merge remote-tracking branch 'origin/main' into feat/i18n-domain
ematipico Jan 25, 2024
f27d645
chore: rebase
ematipico Jan 25, 2024
b06a496
fix regressions in tests
ematipico Jan 25, 2024
09c9c51
fix regressions in tests
ematipico Jan 25, 2024
023003d
Merge remote-tracking branch 'origin/main' into feat/i18n-domain
ematipico Jan 25, 2024
d9fa3cd
fix build
ematipico Jan 25, 2024
55c79f8
add description
ematipico Jan 25, 2024
cff584d
fix missing types
ematipico Jan 25, 2024
13c2f86
chore: fix tests, again :D
ematipico Jan 25, 2024
49865c0
eslint
ematipico Jan 25, 2024
00c512d
Update packages/astro/src/@types/astro.ts
ematipico Jan 26, 2024
5c9ca39
chore: address feedback
ematipico Jan 26, 2024
4f2f721
chore: fix regressions
ematipico Jan 26, 2024
f29825e
chore: refactor naming
ematipico Jan 29, 2024
7420cdb
Update packages/astro/src/core/app/index.ts
ematipico Jan 29, 2024
d735536
chore: address feedback
ematipico Jan 29, 2024
c598fa3
Merge remote-tracking branch 'origin/main' into feat/i18n-domain
ematipico Jan 29, 2024
abd22a3
update lock file
ematipico Jan 29, 2024
5198f5e
chore: infer routing from options, not strategy
ematipico Jan 30, 2024
b370253
Merge remote-tracking branch 'origin/main' into feat/i18n-domain
ematipico Jan 30, 2024
8b63b04
merge from main
ematipico Jan 30, 2024
cfa8bc9
merge from main
ematipico Jan 30, 2024
4e0b3a4
Experimental support in vercel adapter
ematipico Jan 30, 2024
24eac1c
Update packages/astro/src/@types/astro.ts
ematipico Jan 30, 2024
4c16ea7
Update packages/astro/src/@types/astro.ts
ematipico Jan 30, 2024
ab6cfe8
Update .changeset/tidy-carrots-jump.md
ematipico Jan 30, 2024
090dfec
Merge branch 'main' into feat/i18n-domain
ematipico Jan 30, 2024
8033d7d
better changesets
ematipico Jan 30, 2024
f0b2bfb
Merge remote-tracking branch 'origin/main' into feat/i18n-domain
ematipico Jan 30, 2024
5c28342
Updates both experimental.i18nDomains and i18ndomains for experimenta…
sarah11918 Jan 30, 2024
36b73ac
fix link syntax
sarah11918 Jan 30, 2024
afaaadf
consistent tabs/spaces
sarah11918 Jan 30, 2024
2565610
Update packages/astro/src/@types/astro.ts
ematipico Jan 30, 2024
b5e20f3
apply suggestion
ematipico Jan 31, 2024
666f3e3
Merge branch 'main' into feat/i18n-domain
ematipico Jan 31, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .changeset/tidy-carrots-jump.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
'@astrojs/node': minor
sarah11918 marked this conversation as resolved.
Show resolved Hide resolved
'astro': minor
---

Adds experimental support for a new i18n domain routing strategy (`"domains"`) that allows you to configure different domains for certain locales:

```js
// astro.config.mjs
import { defineConfig } from "astro/config"
export default defineConfig({
i18n: {
defaultLocale: "en",
locales: ["es", "en", "fr"],
domains: {
fr: "https://fr.example.com",
es: "https://example.es"
},
routing: {
prefixDefaultLocale: true,
strategy: "domains"
}
},
experimental: {
i18nDomains: true
},
site: "https://example.com",
output: "server"
})
```

With `routing.strategy` set to `"domains"`, 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/en/about"
getAbsoluteLocaleUrl("fr", "about"); // will return "https://fr.example.com/about"
getAbsoluteLocaleUrl("es", "about"); // will return "https://example.es/about"
```

For the above configuration:

- The file `/fr/about.astro` will create the URL `https://fr.example.com/about`.
- The file `/es/about.astro` will create the URL `https://example.es/about`.
- The file `/en/about.astro` will create the URL `https://example.com/en/about`.
ematipico marked this conversation as resolved.
Show resolved Hide resolved
61 changes: 57 additions & 4 deletions packages/astro/src/@types/astro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1530,8 +1530,42 @@ export interface AstroUserConfig {
* @description
*
* - `"pathanme": The strategy is applied to the pathname of the URLs
* - `"domain"`: SSR only, it enables support for different domains. When a locale is mapped to domain, all the URLs won't have the language prefix.
* You map `fr` to `fr.example.com`, if you want a to have a blog page to look like `fr.example.com/blog` instead of `example.com/fr/blog`.
* The localised folders be must in the `src/pages/` folder.
*/
strategy: 'pathname';
strategy: 'pathname' | 'domain';

/**
* @docs
ematipico marked this conversation as resolved.
Show resolved Hide resolved
* @name experimental.i18n.domains
ematipico marked this conversation as resolved.
Show resolved Hide resolved
* @type {Record<string, string> }
* @default '{}'
* @version 4.3.0
* @description
*
* Configures the URL pattern of one or more supported languages to use a domain (or sub-domain). When a locale is mapped to a domain, a `/[locale]/` path prefix will not be used.
*
* For example, you can configure the `fr` URLs to be of the form `https://fr.example.com/blog`. If not configured in `domains`, the `defaultLocale` will default to `https://example.com/blog` and any other locale not configured will default to `https://example.com/[locale]/blog`.
*
* ```js
* export default defineConfig({
* output: "server",
* site: "https://example.com",
* i18n: {
* defaultLocale: "en",
* locales: ["en", "fr", "pt-br", "es"],
* domains: {
* fr: "https://fr.example.com",
* },
* routing: {
* strategy: "domains"
* }
* }
* })
* ```
ematipico marked this conversation as resolved.
Show resolved Hide resolved
*/
domains?: Record<string, string>;
};
};

Expand Down Expand Up @@ -1664,6 +1698,25 @@ 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.1.0
ematipico marked this conversation as resolved.
Show resolved Hide resolved
* @description
* Enables domain support for internationalization routing
*
* ```js
* {
* experimental: {
* i18nDomains: true,
* },
* }
* ```
*/
i18nDomains?: boolean;
};
}

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

export interface AstroAssetsFeature {
Expand All @@ -2150,9 +2203,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
83 changes: 78 additions & 5 deletions packages/astro/src/core/app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,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 @@ -27,6 +29,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 @@ -171,13 +174,83 @@ 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' ||
this.#manifest.i18n.routing === 'domains-prefix-default')
) {
// 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) {
// waiting to decide what to do here
// TODO: What kind of error should we try? This happens if we have an invalid value inside the X-Forwarded-Host and X-Forwarded-Proto headers
ematipico marked this conversation as resolved.
Show resolved Hide resolved
// eslint-disable-next-line no-console
console.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 Expand Up @@ -476,8 +549,8 @@ export class App {
const status = override?.status
? override.status
: originalResponse.status === 200
? newResponse.status
: originalResponse.status;
? newResponse.status
: originalResponse.status;

try {
// this function could throw an error...
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 @@ -61,6 +61,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 @@ -65,6 +65,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 @@ -169,9 +170,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 @@ -421,7 +431,7 @@ function getInvalidRouteSegmentError(
route.route,
JSON.stringify(invalidParam),
JSON.stringify(received)
)
)
: `Generated path for ${route.route} is invalid.`,
hint,
});
Expand Down Expand Up @@ -641,6 +651,7 @@ export function createBuildManifest(
routing: settings.config.i18n.routing,
defaultLocale: settings.config.i18n.defaultLocale,
locales: settings.config.i18n.locales,
domainLookupTable: {},
};
}
return {
Expand Down
18 changes: 18 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 @@ -159,6 +160,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 @@ -235,6 +237,21 @@ 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' || i18n.routing === 'domains-prefix-default')
) {
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 @@ -247,6 +264,7 @@ function buildManifest(
routing: settings.config.i18n.routing,
locales: settings.config.i18n.locales,
defaultLocale: settings.config.i18n.defaultLocale,
domainLookupTable,
};
}

Expand Down
Loading
Loading