Skip to content

Commit

Permalink
feat(i18n): manual routing (#10193)
Browse files Browse the repository at this point in the history
* feat(i18n): manual routing

* one more function

* different typing

* tests

* fix merge

* throw error for missing middleware

* rename function

* fix conflicts

* lock file update

* fix options, error thrown and added tests

* rebase

* add tests

* docs

* lock file black magic

* increase timeout?

* fix regression

* merge conflict

* add changeset

* chore: apply suggestions

* apply suggestion

* Update .changeset/little-hornets-give.md

Co-authored-by: Erika <3019731+Princesseuh@users.noreply.github.com>

* chore: address feedback

* fix regression of last commit

* update name

* add comments

* fix regression

* remove unused code

* Apply suggestions from code review

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

* chore: update reference

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

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

* chore: improve types

* fix regression in tests

* apply Sarah's suggestion

---------

Co-authored-by: Erika <3019731+Princesseuh@users.noreply.github.com>
Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
  • Loading branch information
3 people committed Apr 10, 2024
1 parent 9e14a78 commit 440681e
Show file tree
Hide file tree
Showing 45 changed files with 1,176 additions and 258 deletions.
48 changes: 48 additions & 0 deletions .changeset/little-hornets-give.md
@@ -0,0 +1,48 @@
---
"astro": minor
---

Adds a new i18n routing option `manual` to allow you to write your own i18n middleware:

```js
import { defineConfig } from "astro/config"
// astro.config.mjs
export default defineConfig({
i18n: {
locales: ["en", "fr"],
defaultLocale: "fr",
routing: "manual"
}
})
```

Adding `routing: "manual"` to your i18n config disables Astro's own i18n middleware and provides you with helper functions to write your own: `redirectToDefaultLocale`, `notFound`, and `redirectToFallback`:

```js
// middleware.js
import { redirectToDefaultLocale } from "astro:i18n";
export const onRequest = defineMiddleware(async (context, next) => {
if (context.url.startsWith("/about")) {
return next()
} else {
return redirectToDefaultLocale(context, 302);
}
})
```

Also adds a `middleware` function that manually creates Astro's i18n middleware. This allows you to extend Astro's i18n routing instead of completely replacing it. Run `middleware` in combination with your own middleware, using the `sequence` utility to determine the order:

```js title="src/middleware.js"
import {defineMiddleware, sequence} from "astro:middleware";
import { middleware } from "astro:i18n"; // Astro's own i18n routing config

export const userMiddleware = defineMiddleware();

export const onRequest = sequence(
userMiddleware,
middleware({
redirectToDefaultLocale: false,
prefixDefaultLocale: true
})
)
```
124 changes: 69 additions & 55 deletions packages/astro/src/@types/astro.ts
Expand Up @@ -1494,66 +1494,80 @@ export interface AstroUserConfig {
*
* Controls the routing strategy to determine your site URLs. Set this based on your folder/URL path configuration for your default language.
*/
routing?: {
// prettier-ignore
routing?:
/**
* @docs
* @name i18n.routing.prefixDefaultLocale
* @kind h4
* @type {boolean}
* @default `false`
* @version 3.7.0
* @description
*
* When `false`, only non-default languages will display a language prefix.
* The `defaultLocale` will not show a language prefix and content files do not exist in a localized folder.
* URLs will be of the form `example.com/[locale]/content/` for all non-default languages, but `example.com/content/` for the default locale.
*
* When `true`, all URLs will display a language prefix.
* URLs will be of the form `example.com/[locale]/content/` for every route, including the default language.
* Localized folders are used for every language, including the default.
*/
prefixDefaultLocale?: boolean;

/**
* @docs
* @name i18n.routing.redirectToDefaultLocale
* @kind h4
* @type {boolean}
* @default `true`
* @version 4.2.0
* @description
*
* Configures whether or not the home URL (`/`) generated by `src/pages/index.astro`
* will redirect to `/[defaultLocale]` when `prefixDefaultLocale: true` is set.
*
* Set `redirectToDefaultLocale: false` to disable this automatic redirection at the root of your site:
* ```js
* // astro.config.mjs
* export default defineConfig({
* i18n:{
* defaultLocale: "en",
* locales: ["en", "fr"],
* routing: {
* prefixDefaultLocale: true,
* redirectToDefaultLocale: false
* }
* }
* })
*```
* */
redirectToDefaultLocale?: boolean;

/**
* @name i18n.routing.strategy
* @type {"pathname"}
* @default `"pathname"`
* @version 3.7.0
* @name i18n.routing.manual
* @type {string}
* @version 4.6.0
* @description
* When this option is enabled, Astro will **disable** its i18n middleware so that you can implement your own custom logic. No other `routing` options (e.g. `prefixDefaultLocale`) may be configured with `routing: "manual"`.
*
* - `"pathname": The strategy is applied to the pathname of the URLs
* You will be responsible for writing your own routing logic, or executing Astro's i18n middleware manually alongside your own.
*/
strategy?: 'pathname';
};
'manual'
| {
/**
* @docs
* @name i18n.routing.prefixDefaultLocale
* @kind h4
* @type {boolean}
* @default `false`
* @version 3.7.0
* @description
*
* When `false`, only non-default languages will display a language prefix.
* The `defaultLocale` will not show a language prefix and content files do not exist in a localized folder.
* URLs will be of the form `example.com/[locale]/content/` for all non-default languages, but `example.com/content/` for the default locale.
*
* When `true`, all URLs will display a language prefix.
* URLs will be of the form `example.com/[locale]/content/` for every route, including the default language.
* Localized folders are used for every language, including the default.
*/
prefixDefaultLocale?: boolean;

/**
* @docs
* @name i18n.routing.redirectToDefaultLocale
* @kind h4
* @type {boolean}
* @default `true`
* @version 4.2.0
* @description
*
* Configures whether or not the home URL (`/`) generated by `src/pages/index.astro`
* will redirect to `/[defaultLocale]` when `prefixDefaultLocale: true` is set.
*
* Set `redirectToDefaultLocale: false` to disable this automatic redirection at the root of your site:
* ```js
* // astro.config.mjs
* export default defineConfig({
* i18n:{
* defaultLocale: "en",
* locales: ["en", "fr"],
* routing: {
* prefixDefaultLocale: true,
* redirectToDefaultLocale: false
* }
* }
* })
*```
* */
redirectToDefaultLocale?: boolean;

/**
* @name i18n.routing.strategy
* @type {"pathname"}
* @default `"pathname"`
* @version 3.7.0
* @description
*
* - `"pathname": The strategy is applied to the pathname of the URLs
*/
strategy?: 'pathname';
};

/**
* @name i18n.domains
Expand Down Expand Up @@ -1589,7 +1603,7 @@ export interface AstroUserConfig {
* })
* ```
*
* 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`.
* Both page routes built and URLs returned by the `astro:i18n` helper functions [`getAbsoluteLocaleUrl()`](https://docs.astro.build/en/reference/api-reference/#getabsolutelocaleurl) and [`getAbsoluteLocaleUrlList()`](https://docs.astro.build/en/reference/api-reference/#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.
*/
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/core/app/types.ts
Expand Up @@ -68,7 +68,7 @@ export type SSRManifest = {
};

export type SSRManifestI18n = {
fallback?: Record<string, string>;
fallback: Record<string, string> | undefined;
strategy: RoutingStrategies;
locales: Locales;
defaultLocale: string;
Expand Down
10 changes: 7 additions & 3 deletions packages/astro/src/core/base-pipeline.ts
Expand Up @@ -48,9 +48,13 @@ export abstract class Pipeline {
*/
readonly site = manifest.site ? new URL(manifest.site) : undefined
) {
this.internalMiddleware = [
createI18nMiddleware(i18n, manifest.base, manifest.trailingSlash, manifest.buildFormat),
];
this.internalMiddleware = [];
// We do use our middleware only if the user isn't using the manual setup
if (i18n?.strategy !== 'manual') {
this.internalMiddleware.push(
createI18nMiddleware(i18n, manifest.base, manifest.trailingSlash, manifest.buildFormat)
);
}
}

abstract headElements(routeData: RouteData): Promise<HeadElements> | HeadElements;
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/core/build/generate.ts
Expand Up @@ -592,7 +592,7 @@ function createBuildManifest(
if (settings.config.i18n) {
i18nManifest = {
fallback: settings.config.i18n.fallback,
strategy: toRoutingStrategy(settings.config.i18n),
strategy: toRoutingStrategy(settings.config.i18n.routing, settings.config.i18n.domains),
defaultLocale: settings.config.i18n.defaultLocale,
locales: settings.config.i18n.locales,
domainLookupTable: {},
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/core/build/plugins/plugin-manifest.ts
Expand Up @@ -253,7 +253,7 @@ function buildManifest(
if (settings.config.i18n) {
i18nManifest = {
fallback: settings.config.i18n.fallback,
strategy: toRoutingStrategy(settings.config.i18n),
strategy: toRoutingStrategy(settings.config.i18n.routing, settings.config.i18n.domains),
locales: settings.config.i18n.locales,
defaultLocale: settings.config.i18n.defaultLocale,
domainLookupTable,
Expand Down
34 changes: 19 additions & 15 deletions packages/astro/src/core/config/schema.ts
Expand Up @@ -387,21 +387,25 @@ export const AstroConfigSchema = z.object({
.optional(),
fallback: z.record(z.string(), z.string()).optional(),
routing: z
.object({
prefixDefaultLocale: z.boolean().default(false),
redirectToDefaultLocale: z.boolean().default(true),
strategy: z.enum(['pathname']).default('pathname'),
})
.default({})
.refine(
({ prefixDefaultLocale, redirectToDefaultLocale }) => {
return !(prefixDefaultLocale === false && redirectToDefaultLocale === false);
},
{
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`.',
}
),
.literal('manual')
.or(
z
.object({
prefixDefaultLocale: z.boolean().optional().default(false),
redirectToDefaultLocale: z.boolean().optional().default(true),
})
.refine(
({ prefixDefaultLocale, redirectToDefaultLocale }) => {
return !(prefixDefaultLocale === false && redirectToDefaultLocale === false);
},
{
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`.',
}
)
)
.optional()
.default({}),
})
.optional()
.superRefine((i18n, ctx) => {
Expand Down
29 changes: 28 additions & 1 deletion packages/astro/src/core/errors/errors-data.ts
Expand Up @@ -1067,6 +1067,21 @@ export const MissingIndexForInternationalization = {
hint: (src: string) => `Create an index page (\`index.astro, index.md, etc.\`) in \`${src}\`.`,
} satisfies ErrorData;

/**
* @docs
* @description
* Some internationalization functions are only available when Astro's own i18n routing is disabled by the configuration setting `i18n.routing: "manual"`.
*
* @see
* - [`i18n` routing](https://docs.astro.build/en/guides/internationalization/#routing)
*/
export const IncorrectStrategyForI18n = {
name: 'IncorrectStrategyForI18n',
title: "You can't use the current function with the current strategy",
message: (functionName: string) =>
`The function \`${functionName}\' can only be used when the \`i18n.routing.strategy\` is set to \`"manual"\`.`,
} satisfies ErrorData;

/**
* @docs
* @description
Expand All @@ -1076,7 +1091,19 @@ export const NoPrerenderedRoutesWithDomains = {
name: 'NoPrerenderedRoutesWithDomains',
title: "Prerendered 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 prerendering for the page ${component}`,
`Static pages aren't yet supported with multiple domains. To enable this feature, you must disable prerendering for the page ${component}`,
} satisfies ErrorData;

/**
* @docs
* @description
* Astro throws an error if the user enables manual routing, but it doesn't have a middleware file.
*/
export const MissingMiddlewareForInternationalization = {
name: 'MissingMiddlewareForInternationalization',
title: 'Enabled manual internationalization routing without having a middleware.',
message:
"Your configuration setting `i18n.routing: 'manual'` requires you to provide your own i18n `middleware` file.",
} satisfies ErrorData;

/**
Expand Down
8 changes: 8 additions & 0 deletions packages/astro/src/core/middleware/vite-plugin.ts
Expand Up @@ -6,6 +6,8 @@ import { addRollupInput } from '../build/add-rollup-input.js';
import type { BuildInternals } from '../build/internal.js';
import type { StaticBuildOptions } from '../build/types.js';
import { MIDDLEWARE_PATH_SEGMENT_NAME } from '../constants.js';
import { MissingMiddlewareForInternationalization } from '../errors/errors-data.js';
import { AstroError } from '../errors/index.js';

export const MIDDLEWARE_MODULE_ID = '\0astro-internal:middleware';
const NOOP_MIDDLEWARE = '\0noop-middleware';
Expand Down Expand Up @@ -44,8 +46,14 @@ export function vitePluginMiddleware({ settings }: { settings: AstroSettings }):
},
async load(id) {
if (id === NOOP_MIDDLEWARE) {
if (!userMiddlewareIsPresent && settings.config.i18n?.routing === 'manual') {
throw new AstroError(MissingMiddlewareForInternationalization);
}
return 'export const onRequest = (_, next) => next()';
} else if (id === MIDDLEWARE_MODULE_ID) {
if (!userMiddlewareIsPresent && settings.config.i18n?.routing === 'manual') {
throw new AstroError(MissingMiddlewareForInternationalization);
}
// In the build, tell Vite to emit this file
if (isCommandBuild) {
this.emitFile({
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/core/routing/manifest/create.ts
Expand Up @@ -589,7 +589,7 @@ export function createRouteManifest(

const i18n = settings.config.i18n;
if (i18n) {
const strategy = toRoutingStrategy(i18n);
const strategy = toRoutingStrategy(i18n.routing, i18n.domains);
// First we check if the user doesn't have an index page.
if (strategy === 'pathname-prefix-always') {
let index = routes.find((route) => route.route === '/');
Expand Down

0 comments on commit 440681e

Please sign in to comment.