From 7678ef33a0d283cde814e7a4d56165afa94c2b5d Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Mon, 9 Oct 2023 13:24:53 +0100 Subject: [PATCH] feat(i18n): better virtual module functions (#8778) Co-authored-by: Martin Trapp <94928215+martrapp@users.noreply.github.com> --- packages/astro/client.d.ts | 5 + packages/astro/package.json | 2 +- packages/astro/src/core/build/generate.ts | 23 +- packages/astro/src/core/build/util.ts | 25 ++ packages/astro/src/core/create-vite.ts | 2 + packages/astro/src/core/errors/errors-data.ts | 10 + packages/astro/src/i18n/index.ts | 59 ++- packages/astro/src/i18n/vite-plugin-i18n.ts | 19 +- .../test/units/i18n/getI18nBaseUrl.test.js | 350 +++++++++++++++++- packages/create-astro/src/index.ts | 1 + pnpm-lock.yaml | 13 +- 11 files changed, 463 insertions(+), 46 deletions(-) diff --git a/packages/astro/client.d.ts b/packages/astro/client.d.ts index e489756f0..a5f7d47f0 100644 --- a/packages/astro/client.d.ts +++ b/packages/astro/client.d.ts @@ -129,7 +129,12 @@ declare module 'astro:transitions/client' { declare module 'astro:i18n' { type I18nModule = typeof import('./dist/i18n/index.js'); + + // TODO: documentation export const getI18nBaseUrl: (locale: string) => string; + + // TODO: documentation + export const getLocalesBaseUrl: () => string[]; } declare module 'astro:middleware' { diff --git a/packages/astro/package.json b/packages/astro/package.json index a3f4158e4..c387531d8 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -78,7 +78,7 @@ "default": "./dist/core/middleware/namespace.js" }, "./transitions": "./dist/transitions/index.js", - "./transitions": "./dist/transitions/index.js", + "./transitions/router": "./dist/transitions/router.js", "./i18n": "./dist/i18n/index.js" }, "imports": { diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index a5b316554..ed6893add 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -5,7 +5,6 @@ import { fileURLToPath } from 'node:url'; import type { OutputAsset, OutputChunk } from 'rollup'; import type { BufferEncoding } from 'vfile'; import type { - AstroConfig, AstroSettings, ComponentInstance, GetStaticPathsItem, @@ -58,7 +57,7 @@ import type { StaticBuildOptions, StylesheetAsset, } from './types.js'; -import { getTimeStat } from './util.js'; +import { getTimeStat, shouldAppendForwardSlash } from './util.js'; function createEntryURL(filePath: string, outFolder: URL) { return new URL('./' + filePath + `?time=${Date.now()}`, outFolder); @@ -431,26 +430,6 @@ interface GeneratePathOptions { mod: ComponentInstance; } -function shouldAppendForwardSlash( - trailingSlash: AstroConfig['trailingSlash'], - buildFormat: AstroConfig['build']['format'] -): boolean { - switch (trailingSlash) { - case 'always': - return true; - case 'never': - return false; - case 'ignore': { - switch (buildFormat) { - case 'directory': - return true; - case 'file': - return false; - } - } - } -} - function addPageName(pathname: string, opts: StaticBuildOptions): void { const trailingSlash = opts.settings.config.trailingSlash; const buildFormat = opts.settings.config.build.format; diff --git a/packages/astro/src/core/build/util.ts b/packages/astro/src/core/build/util.ts index 8e558f9bb..2449caef7 100644 --- a/packages/astro/src/core/build/util.ts +++ b/packages/astro/src/core/build/util.ts @@ -1,4 +1,29 @@ +import type { AstroConfig } from '../../@types/astro.js'; + export function getTimeStat(timeStart: number, timeEnd: number) { const buildTime = timeEnd - timeStart; return buildTime < 750 ? `${Math.round(buildTime)}ms` : `${(buildTime / 1000).toFixed(2)}s`; } + +/** + * Given the Astro configuration, it tells if a slash should be appended or not + */ +export function shouldAppendForwardSlash( + trailingSlash: AstroConfig['trailingSlash'], + buildFormat: AstroConfig['build']['format'] +): boolean { + switch (trailingSlash) { + case 'always': + return true; + case 'never': + return false; + case 'ignore': { + switch (buildFormat) { + case 'directory': + return true; + case 'file': + return false; + } + } + } +} diff --git a/packages/astro/src/core/create-vite.ts b/packages/astro/src/core/create-vite.ts index 3c59b1fb4..27a437d53 100644 --- a/packages/astro/src/core/create-vite.ts +++ b/packages/astro/src/core/create-vite.ts @@ -29,6 +29,7 @@ import astroScriptsPlugin from '../vite-plugin-scripts/index.js'; import astroScriptsPageSSRPlugin from '../vite-plugin-scripts/page-ssr.js'; import { vitePluginSSRManifest } from '../vite-plugin-ssr-manifest/index.js'; import { joinPaths } from './path.js'; +import astroInternalization from '../i18n/vite-plugin-i18n.js'; interface CreateViteOptions { settings: AstroSettings; @@ -134,6 +135,7 @@ export async function createVite( vitePluginSSRManifest(), astroAssetsPlugin({ settings, logger, mode }), astroTransitions(), + !!settings.config.experimental.i18n && astroInternalization({ settings, logger }), ], publicDir: fileURLToPath(settings.config.publicDir), root: fileURLToPath(settings.config.root), diff --git a/packages/astro/src/core/errors/errors-data.ts b/packages/astro/src/core/errors/errors-data.ts index d805bb6ff..aab699a1c 100644 --- a/packages/astro/src/core/errors/errors-data.ts +++ b/packages/astro/src/core/errors/errors-data.ts @@ -1243,5 +1243,15 @@ export const UnsupportedConfigTransformError = { hint: 'See the devalue library for all supported types: https://github.com/rich-harris/devalue', } satisfies ErrorData; +export const MissingLocale = { + name: 'MissingLocaleError', + title: 'The provided locale does not exist.', + message: (locale: string, locales: string[]) => { + return `The locale \`${locale}\` does not exist in the configured locales. Available locales: ${locales.join( + ', ' + )}.`; + }, +} satisfies ErrorData; + // Generic catch-all - Only use this in extreme cases, like if there was a cosmic ray bit flip export const UnknownError = { name: 'UnknownError', title: 'Unknown Error.' } satisfies ErrorData; diff --git a/packages/astro/src/i18n/index.ts b/packages/astro/src/i18n/index.ts index f32ec8e23..ff6e02ee3 100644 --- a/packages/astro/src/i18n/index.ts +++ b/packages/astro/src/i18n/index.ts @@ -1,21 +1,58 @@ +import { AstroError } from '../core/errors/index.js'; +import { MissingLocale } from '../core/errors/errors-data.js'; import type { AstroConfig } from '../@types/astro.js'; -import type { Logger } from '../core/logger/core.js'; +import { shouldAppendForwardSlash } from '../core/build/util.js'; +type GetI18nBaseUrl = { + locale: string; + base: string; + locales: string[]; + trailingSlash: AstroConfig['trailingSlash']; + format: AstroConfig['build']['format']; +}; /** * The base URL */ -export function getI18nBaseUrl(locale: string, config: AstroConfig, logger: Logger) { - const base = config.base; - - if (!config.experimental.i18n) { - logger.error('i18n', "The project isn't using i18n features, no need to use this function."); - return base ? `/${base}/` : '/'; +export function getI18nBaseUrl({ locale, base, locales, trailingSlash, format }: GetI18nBaseUrl) { + if (!locales.includes(locale)) { + throw new AstroError({ + ...MissingLocale, + message: MissingLocale.message(locale, locales), + }); } - if (base) { - logger.debug('i18n', 'The project has a base directory, using it.'); - return `${base}/${locale}/`; + const normalizedLocale = normalizeLocale(locale); + if (shouldAppendForwardSlash(trailingSlash, format)) { + return `${base}${normalizedLocale}/`; } else { - return `/${locale}/`; + return `${base}/${normalizedLocale}`; } } + +type GetLocalesBaseUrl = { + base: string; + locales: string[]; + trailingSlash: AstroConfig['trailingSlash']; + format: AstroConfig['build']['format']; +}; + +export function getLocalesBaseUrl({ base, locales, trailingSlash, format }: GetLocalesBaseUrl) { + return locales.map((locale) => { + const normalizedLocale = normalizeLocale(locale); + if (shouldAppendForwardSlash(trailingSlash, format)) { + return `${base}${normalizedLocale}/`; + } else { + return `${base}/${normalizedLocale}`; + } + }); +} + +/** + * + * Given a locale, this function: + * - replaces the `_` with a `-`; + * - transforms all letters to be lower case; + */ +function normalizeLocale(locale: string): string { + return locale.replaceAll('_', '-').toLowerCase(); +} diff --git a/packages/astro/src/i18n/vite-plugin-i18n.ts b/packages/astro/src/i18n/vite-plugin-i18n.ts index 9f504ea07..fa8f342b1 100644 --- a/packages/astro/src/i18n/vite-plugin-i18n.ts +++ b/packages/astro/src/i18n/vite-plugin-i18n.ts @@ -13,12 +13,25 @@ type AstroInternalization = { export default function astroInternalization({ settings }: AstroInternalization): vite.Plugin { return { name: 'astro:i18n', + async resolveId(id) { + if (id === virtualModuleId) { + return resolvedVirtualModuleId; + } + }, load(id) { if (id === resolvedVirtualModuleId) { return ` - import { getI18nBaseUrl as getI18nBaseUrlInternal } from "astro/i18n"; - - export getI18nBaseUrl = (locale) => getI18nBaseUrlInternal(locale, ${settings.config}); + import { getI18nBaseUrl as getI18nBaseUrlInternal, getLocalesBaseUrl as _getLocalesBaseUrl } from "astro/i18n"; + + const defaultLocale = ${JSON.stringify(settings.config.experimental.i18n!.defaultLocale)}; + const locales = ${JSON.stringify(settings.config.experimental.i18n!.locales)}; + const fallback = ${JSON.stringify(settings.config.experimental.i18n!.fallback)}; + const base = ${JSON.stringify(settings.config.base)}; + const trailingSlash = ${JSON.stringify(settings.config.trailingSlash)}; + const format = ${JSON.stringify(settings.config.build.format)}; + + export const getI18nBaseUrl = (locale) => getI18nBaseUrlInternal({ locale, base, locales, trailingSlash, format }); + export const getLocalesBaseUrl = () => _getLocalesBaseUrl({ base, locales, trailingSlash, format }); `; } }, diff --git a/packages/astro/test/units/i18n/getI18nBaseUrl.test.js b/packages/astro/test/units/i18n/getI18nBaseUrl.test.js index b4c5a32f4..31cc1290d 100644 --- a/packages/astro/test/units/i18n/getI18nBaseUrl.test.js +++ b/packages/astro/test/units/i18n/getI18nBaseUrl.test.js @@ -1,8 +1,6 @@ -import { getI18nBaseUrl } from '../../../dist/i18n/index.js'; +import { getI18nBaseUrl, getLocalesBaseUrl } from '../../../dist/i18n/index.js'; import { expect } from 'chai'; -import { Logger } from '../../../dist/core/logger/core.js'; -const logger = new Logger(); describe('getI18nBaseUrl', () => { it('should correctly return the URL with the base', () => { /** @@ -14,13 +12,70 @@ describe('getI18nBaseUrl', () => { experimental: { i18n: { defaultLocale: 'en', - locales: ['en', 'es'], + locales: ['en', 'en_US', 'es'], }, }, }; - expect(getI18nBaseUrl('en', config, logger)).to.eq('/blog/en/'); - expect(getI18nBaseUrl('es', config, logger)).to.eq('/blog/es/'); + // directory format + expect( + getI18nBaseUrl({ + locale: 'en', + base: '/blog/', + locales: config.experimental.i18n.locales, + trailingSlash: 'always', + format: 'directory', + }) + ).to.eq('/blog/en/'); + expect( + getI18nBaseUrl({ + locale: 'es', + base: '/blog/', + locales: config.experimental.i18n.locales, + trailingSlash: 'always', + format: 'directory', + }) + ).to.eq('/blog/es/'); + + expect( + getI18nBaseUrl({ + locale: 'en_US', + base: '/blog/', + locales: config.experimental.i18n.locales, + trailingSlash: 'always', + format: 'directory', + }) + ).to.throw; + + // file format + expect( + getI18nBaseUrl({ + locale: 'en', + base: '/blog/', + locales: config.experimental.i18n.locales, + trailingSlash: 'always', + format: 'file', + }) + ).to.eq('/blog/en/'); + expect( + getI18nBaseUrl({ + locale: 'es', + base: '/blog/', + locales: config.experimental.i18n.locales, + trailingSlash: 'always', + format: 'file', + }) + ).to.eq('/blog/es/'); + + expect( + getI18nBaseUrl({ + locale: 'en_US', + base: '/blog/', + locales: config.experimental.i18n.locales, + trailingSlash: 'always', + format: 'file', + }) + ).to.throw; }); it('should correctly return the URL without base', () => { @@ -37,7 +92,286 @@ describe('getI18nBaseUrl', () => { }, }; - expect(getI18nBaseUrl('en', config, logger)).to.eq('/en/'); - expect(getI18nBaseUrl('es', config, logger)).to.eq('/es/'); + expect( + getI18nBaseUrl({ + locale: 'en', + base: '/', + locales: config.experimental.i18n.locales, + trailingSlash: 'always', + format: 'directory', + }) + ).to.eq('/en/'); + expect( + getI18nBaseUrl({ + locale: 'es', + base: '/', + locales: config.experimental.i18n.locales, + trailingSlash: 'always', + format: 'directory', + }) + ).to.eq('/es/'); + }); + + it('should correctly handle the trailing slash', () => { + /** + * + * @type {import("../../../dist/@types").AstroUserConfig} + */ + const config = { + experimental: { + i18n: { + defaultLocale: 'en', + locales: ['en', 'es'], + }, + }, + }; + // directory format + expect( + getI18nBaseUrl({ + locale: 'en', + base: '/blog', + locales: config.experimental.i18n.locales, + trailingSlash: 'never', + format: 'directory', + }) + ).to.eq('/blog/en'); + expect( + getI18nBaseUrl({ + locale: 'es', + base: '/blog/', + locales: config.experimental.i18n.locales, + trailingSlash: 'always', + format: 'directory', + }) + ).to.eq('/blog/es/'); + + expect( + getI18nBaseUrl({ + locale: 'en', + base: '/blog/', + locales: config.experimental.i18n.locales, + trailingSlash: 'ignore', + format: 'directory', + }) + ).to.eq('/blog/en/'); + + // directory file + expect( + getI18nBaseUrl({ + locale: 'en', + base: '/blog', + locales: config.experimental.i18n.locales, + trailingSlash: 'never', + format: 'file', + }) + ).to.eq('/blog/en'); + expect( + getI18nBaseUrl({ + locale: 'es', + base: '/blog/', + locales: config.experimental.i18n.locales, + trailingSlash: 'always', + format: 'file', + }) + ).to.eq('/blog/es/'); + + expect( + getI18nBaseUrl({ + locale: 'en', + // ignore + file => no trailing slash + base: '/blog', + locales: config.experimental.i18n.locales, + trailingSlash: 'ignore', + format: 'file', + }) + ).to.eq('/blog/en'); + }); + + it('should normalize locales', () => { + /** + * + * @type {import("../../../dist/@types").AstroUserConfig} + */ + const config = { + base: '/blog', + experimental: { + i18n: { + defaultLocale: 'en', + locales: ['en', 'en_US', 'en_AU'], + }, + }, + }; + + expect( + getI18nBaseUrl({ + locale: 'en_US', + base: '/blog/', + locales: config.experimental.i18n.locales, + trailingSlash: 'always', + format: 'directory', + }) + ).to.eq('/blog/en-us/'); + + expect( + getI18nBaseUrl({ + locale: 'en_AU', + base: '/blog/', + locales: config.experimental.i18n.locales, + trailingSlash: 'always', + format: 'directory', + }) + ).to.eq('/blog/en-au/'); + }); +}); + +describe('getLocalesBaseUrl', () => { + it('should retrieve the correct list of base URL with locales [format: directory, trailingSlash: never]', () => { + /** + * + * @type {import("../../../dist/@types").AstroUserConfig} + */ + const config = { + experimental: { + i18n: { + defaultLocale: 'en', + locales: ['en', 'en_US', 'es'], + }, + }, + }; + // directory format + expect( + getLocalesBaseUrl({ + locale: 'en', + base: '/blog', + locales: config.experimental.i18n.locales, + trailingSlash: 'never', + format: 'directory', + }) + ).to.have.members(['/blog/en', '/blog/en-us', '/blog/es']); + }); + + it('should retrieve the correct list of base URL with locales [format: directory, trailingSlash: always]', () => { + /** + * + * @type {import("../../../dist/@types").AstroUserConfig} + */ + const config = { + experimental: { + i18n: { + defaultLocale: 'en', + locales: ['en', 'en_US', 'es'], + }, + }, + }; + // directory format + expect( + getLocalesBaseUrl({ + locale: 'en', + base: '/blog/', + locales: config.experimental.i18n.locales, + trailingSlash: 'always', + format: 'directory', + }) + ).to.have.members(['/blog/en/', '/blog/en-us/', '/blog/es/']); + }); + + it('should retrieve the correct list of base URL with locales [format: file, trailingSlash: always]', () => { + /** + * + * @type {import("../../../dist/@types").AstroUserConfig} + */ + const config = { + experimental: { + i18n: { + defaultLocale: 'en', + locales: ['en', 'en_US', 'es'], + }, + }, + }; + // directory format + expect( + getLocalesBaseUrl({ + locale: 'en', + base: '/blog/', + locales: config.experimental.i18n.locales, + trailingSlash: 'always', + format: 'file', + }) + ).to.have.members(['/blog/en/', '/blog/en-us/', '/blog/es/']); + }); + + it('should retrieve the correct list of base URL with locales [format: file, trailingSlash: never]', () => { + /** + * + * @type {import("../../../dist/@types").AstroUserConfig} + */ + const config = { + experimental: { + i18n: { + defaultLocale: 'en', + locales: ['en', 'en_US', 'es'], + }, + }, + }; + // directory format + expect( + getLocalesBaseUrl({ + locale: 'en', + base: '/blog', + locales: config.experimental.i18n.locales, + trailingSlash: 'never', + format: 'file', + }) + ).to.have.members(['/blog/en', '/blog/en-us', '/blog/es']); + }); + + it('should retrieve the correct list of base URL with locales [format: file, trailingSlash: ignore]', () => { + /** + * + * @type {import("../../../dist/@types").AstroUserConfig} + */ + const config = { + experimental: { + i18n: { + defaultLocale: 'en', + locales: ['en', 'en_US', 'es'], + }, + }, + }; + // directory format + expect( + getLocalesBaseUrl({ + locale: 'en', + base: '/blog', + locales: config.experimental.i18n.locales, + trailingSlash: 'ignore', + format: 'file', + }) + ).to.have.members(['/blog/en', '/blog/en-us', '/blog/es']); + }); + + it('should retrieve the correct list of base URL with locales [format: directory, trailingSlash: ignore]', () => { + /** + * + * @type {import("../../../dist/@types").AstroUserConfig} + */ + const config = { + experimental: { + i18n: { + defaultLocale: 'en', + locales: ['en', 'en_US', 'es'], + }, + }, + }; + // directory format + expect( + getLocalesBaseUrl({ + locale: 'en', + base: '/blog/', + locales: config.experimental.i18n.locales, + trailingSlash: 'ignore', + format: 'directory', + }) + ).to.have.members(['/blog/en/', '/blog/en-us/', '/blog/es/']); }); }); diff --git a/packages/create-astro/src/index.ts b/packages/create-astro/src/index.ts index 6f6fd9bc1..3ac5d231b 100644 --- a/packages/create-astro/src/index.ts +++ b/packages/create-astro/src/index.ts @@ -20,6 +20,7 @@ process.on('SIGTERM', exit); // if you make any changes to the flow or wording here. export async function main() { // Clear console because PNPM startup is super ugly + // eslint-disable-next-line no-console console.clear(); // NOTE: In the v7.x version of npm, the default behavior of `npm init` was changed // to no longer require `--` to pass args and instead pass `--` directly to us. This diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cd7051c04..a58c463be 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2755,6 +2755,18 @@ importers: specifier: ^10.17.1 version: 10.17.1 + packages/astro/test/fixtures/i18n-routing: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + + packages/astro/test/fixtures/i18n-routing-base: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + packages/astro/test/fixtures/import-ts-with-js: dependencies: astro: @@ -17267,7 +17279,6 @@ packages: engines: {node: '>=14.0'} dependencies: busboy: 1.6.0 - dev: true /unherit@3.0.1: resolution: {integrity: sha512-akOOQ/Yln8a2sgcLj4U0Jmx0R5jpIg2IUyRrWOzmEbjBtGzBdHtSeFKgoEcoH4KYIG/Pb8GQ/BwtYm0GCq1Sqg==}