diff --git a/examples/app/.gitignore b/examples/app/.gitignore new file mode 100644 index 0000000..e985853 --- /dev/null +++ b/examples/app/.gitignore @@ -0,0 +1 @@ +.vercel diff --git a/examples/app/README.md b/examples/app/README.md new file mode 100644 index 0000000..c403366 --- /dev/null +++ b/examples/app/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. diff --git a/examples/app/app/[locale]/[...notFound]/page.tsx b/examples/app/app/[locale]/[...notFound]/page.tsx new file mode 100644 index 0000000..2c4bcd6 --- /dev/null +++ b/examples/app/app/[locale]/[...notFound]/page.tsx @@ -0,0 +1,10 @@ +import React from 'react'; + +export default function NotFound() { + return ( +
+

Custom Not Found page

+

Could not find requested resource

+
+ ); +} diff --git a/examples/app/app/[locale]/client-component.tsx b/examples/app/app/[locale]/client-component.tsx new file mode 100644 index 0000000..490f656 --- /dev/null +++ b/examples/app/app/[locale]/client-component.tsx @@ -0,0 +1,46 @@ +'use client'; + +// @ts-expect-error - missing import +import React, { use } from 'react'; +import { useChangeLocale, getI18n, getLocale, getScopedI18n } from '@/locales'; + +export function ClientComponent() { + const t = use(getI18n()); + const scopedT = use(getScopedI18n('hello')); + const locale = getLocale(); + const changeLocale = useChangeLocale(); + + return ( +
+

{t('hello.world')}

+

{scopedT('world')}

+

+ {t('hello.param', { + name: 'John', + })} +

+

+ {scopedT('param', { + name: 'John', + })} +

+

Current locale: {locale}

+ + +
+ ); +} diff --git a/examples/app/app/[locale]/layout.tsx b/examples/app/app/[locale]/layout.tsx new file mode 100644 index 0000000..8a7456d --- /dev/null +++ b/examples/app/app/[locale]/layout.tsx @@ -0,0 +1,21 @@ +import { generateI18nStaticParams } from '@/locales'; +import React from 'react'; + +export const dynamicParams = false; +export function generateStaticParams() { + return generateI18nStaticParams(); +} + +export default function RootLayout({ + children, + params: { locale }, +}: Readonly<{ + children: React.ReactNode; + params: { locale: string }; +}>) { + return ( + + {children} + + ); +} diff --git a/examples/app/app/[locale]/page.tsx b/examples/app/app/[locale]/page.tsx new file mode 100644 index 0000000..55eeeb3 --- /dev/null +++ b/examples/app/app/[locale]/page.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { getI18n, getLocale, getScopedI18n } from '@/locales'; +import { ClientComponent } from './client-component'; +import { Suspense } from 'react'; + +export default async function Home() { + const t = await getI18n(); + const scopedT = await getScopedI18n('hello'); + const locale = getLocale(); + + return ( +
+

{t('hello.world')}

+

{scopedT('world')}

+

+ {t('hello.param', { + name: 'John', + })} +

+

+ {scopedT('param', { + name: 'John', + })} +

+

Current locale: {locale}

+
+ + + +
+ ); +} diff --git a/examples/app/locales/en.ts b/examples/app/locales/en.ts new file mode 100644 index 0000000..4e48224 --- /dev/null +++ b/examples/app/locales/en.ts @@ -0,0 +1,4 @@ +export default { + 'hello.world': 'Hello, World!', + 'hello.param': 'Hello, {name}!', +} as const; diff --git a/examples/app/locales/fr.ts b/examples/app/locales/fr.ts new file mode 100644 index 0000000..5728a7e --- /dev/null +++ b/examples/app/locales/fr.ts @@ -0,0 +1,4 @@ +export default { + 'hello.world': 'Bonjour, le monde !', + 'hello.param': 'Bonjour, {name} !', +} as const; diff --git a/examples/app/locales/index.ts b/examples/app/locales/index.ts new file mode 100644 index 0000000..d50d6cb --- /dev/null +++ b/examples/app/locales/index.ts @@ -0,0 +1,14 @@ +import { createI18n } from 'next-international/app'; +// import en from './en'; + +export const { getI18n, getScopedI18n, generateI18nStaticParams, getLocale, useChangeLocale } = createI18n( + { + en: () => import('./en'), + fr: () => import('./fr'), + }, + { + // segmentName: 'locale', + // basePath: '/base', + // fallbackLocale: en, + }, +); diff --git a/examples/app/middleware.ts b/examples/app/middleware.ts new file mode 100644 index 0000000..4be91f1 --- /dev/null +++ b/examples/app/middleware.ts @@ -0,0 +1,18 @@ +import { NextResponse } from 'next/server'; +import { createI18nMiddleware } from 'next-international/middleware'; + +export const middleware = createI18nMiddleware( + request => { + console.log('User middleware:', request.url); + return NextResponse.next(); + }, + { + locales: ['en', 'fr'], + defaultLocale: 'en', + // urlMappingStrategy: 'rewrite', + }, +); + +export const config = { + matcher: ['/((?!api|static|.*\\..*|_next|favicon.ico|robots.txt).*)'], +}; diff --git a/examples/app/next-env.d.ts b/examples/app/next-env.d.ts new file mode 100644 index 0000000..4f11a03 --- /dev/null +++ b/examples/app/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/examples/app/next.config.mjs b/examples/app/next.config.mjs new file mode 100644 index 0000000..4678774 --- /dev/null +++ b/examples/app/next.config.mjs @@ -0,0 +1,4 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = {}; + +export default nextConfig; diff --git a/examples/app/package.json b/examples/app/package.json new file mode 100644 index 0000000..4ec9f72 --- /dev/null +++ b/examples/app/package.json @@ -0,0 +1,23 @@ +{ + "name": "app", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "next": "14.1.1", + "next-international": "workspace:*", + "react": "^18", + "react-dom": "^18" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "typescript": "^5" + } +} diff --git a/examples/app/tsconfig.json b/examples/app/tsconfig.json new file mode 100644 index 0000000..e7ff90f --- /dev/null +++ b/examples/app/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/international-types/index.ts b/packages/international-types/index.ts index 8fba9a1..e396bff 100644 --- a/packages/international-types/index.ts +++ b/packages/international-types/index.ts @@ -1,42 +1,8 @@ -export type LocaleValue = string | number | boolean | null | undefined | Date; -export type BaseLocale = Record; -export type ImportedLocales = Record Promise>; -export type ExplicitLocales = Record; - -export type LocaleKeys< - Locale extends BaseLocale, - Scope extends Scopes | undefined, - Key extends string = Extract, -> = Scope extends undefined ? RemovePlural : Key extends `${Scope}.${infer Test}` ? RemovePlural : never; - -type Delimiter = `=${number}` | 'other'; - -type ExtractParams = Value extends '' - ? [] - : Value extends `${string}{${infer Param}}${infer Tail}` - ? [Param, ...ExtractParams] - : []; - -export type Params = Value extends '' - ? [] - : // Plural with 3 cases - Value extends `{${infer Param}, plural, ${Delimiter} {${infer Content}} ${Delimiter} {${infer Content2}} ${Delimiter} {${infer Content3}}}` - ? [Param, ...ExtractParams, ...ExtractParams, ...ExtractParams] - : // Plural with 2 cases - Value extends `{${infer Param}, plural, ${Delimiter} {${infer Content}} ${Delimiter} {${infer Content2}}}` - ? [Param, ...ExtractParams, ...ExtractParams] - : // Simple cases (e.g `This is a {param}`) - Value extends `${string}{${infer Param}}${infer Tail}` - ? [Param, ...Params] - : []; - -export type GetParams = Value extends '' - ? [] - : Value extends `${string}{${infer Param}}${infer Tail}` - ? [Param, ...Params] - : []; - -export type ParamsObject = Record[number], LocaleValue>; +export type Param = string | number; +export type LocaleType = Record; +export type LocalesObject = { + [locale: string]: () => Promise<{ default: LocaleType }>; +}; type ExtractScopes< Value extends string, @@ -48,91 +14,34 @@ type ExtractScopes< ] : []; -export type Scopes = ExtractScopes>[number]; +export type Scopes< + Locale extends LocaleType, + TheKeys extends string = Extract, +> = ExtractScopes[number]; -export type ScopedValue< - Locale extends BaseLocale, +export type Keys< + Locale extends LocaleType, Scope extends Scopes | undefined, - Key extends LocaleKeys, + TheKeys extends string = Extract, > = Scope extends undefined - ? IsPlural extends true - ? Locale[`${Key}#${PluralSuffix}`] - : Locale[Key] - : IsPlural extends true - ? Locale[`${Scope}.${Key}#${PluralSuffix}`] - : Locale[`${Scope}.${Key}`]; - -// From https://github.com/microsoft/TypeScript/issues/13298#issuecomment-885980381 -type UnionToIntersection = (U extends never ? never : (arg: U) => never) extends (arg: infer I) => void ? I : never; - -type UnionToTuple = UnionToIntersection T> extends (_: never) => infer W - ? [...UnionToTuple>, W] - : []; - -// Given a object type with string keys, return the "first" key. -// Because the key ordering is not guaranteed, this type should be used -// only when the key order is not important. -type SomeKey> = UnionToTuple[0] extends string - ? UnionToTuple[0] - : never; - -// Gets a single locale type from an object of the shape of BaseLocales. -export type GetLocaleType = Locales extends ImportedLocales - ? Awaited]>>['default'] - : Locales[SomeKey]; - -type Join = K extends string | number - ? P extends string | number - ? `${K}${'' extends P ? '' : '.'}${P}` - : never - : never; - -type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, ...0[]]; - -type Leaves = [D] extends [never] - ? never - : T extends object - ? { [K in keyof T]-?: Join> }[keyof T] - : ''; - -type FollowPath = P extends `${infer U}.${infer R}` - ? U extends keyof T - ? FollowPath - : P extends keyof T - ? T[P] - : never - : P extends keyof T - ? T[P] + ? RemovePlural + : TheKeys extends `${Scope}.${infer Tail}` + ? RemovePlural : never; -export type FlattenLocale> = { - [K in Leaves]: FollowPath; -}; - -type PluralSuffix = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other'; +export type ExtractParams = Value extends '' + ? [] + : Value extends `${string}{${infer Param}}${infer Tail}` + ? [Param, ...ExtractParams] + : []; -type RemovePlural = Key extends `${infer Head}#${PluralSuffix}` ? Head : Key; +export type PluralSuffix = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other'; +export type RemovePlural = Key extends `${infer Head}#${PluralSuffix}` ? Head : Key; -type GetPlural< - Key extends string, +export type IsPlural< + Locale extends LocaleType, Scope extends Scopes | undefined, - Locale extends BaseLocale, -> = Scope extends undefined - ? `${Key}#${PluralSuffix}` & keyof Locale extends infer PluralKey - ? PluralKey extends `${string}#${infer Plural extends PluralSuffix}` - ? Plural - : never - : never - : `${Scope}.${Key}#${PluralSuffix}` & keyof Locale extends infer PluralKey - ? PluralKey extends `${string}#${infer Plural extends PluralSuffix}` - ? Plural - : never - : never; - -type IsPlural< Key extends string, - Scope extends Scopes | undefined, - Locale extends BaseLocale, > = Scope extends undefined ? `${Key}#${PluralSuffix}` & keyof Locale extends never ? false @@ -141,65 +50,45 @@ type IsPlural< ? false : true; -type GetCountUnion< - Key extends string, - Scope extends Scopes | undefined, - Locale extends BaseLocale, - Plural extends PluralSuffix = GetPlural, -> = Plural extends 'zero' - ? 0 - : Plural extends 'one' - ? // eslint-disable-next-line @typescript-eslint/ban-types - 1 | 21 | 31 | 41 | 51 | 61 | 71 | 81 | 91 | 101 | (number & {}) - : Plural extends 'two' - ? // eslint-disable-next-line @typescript-eslint/ban-types - 2 | 22 | 32 | 42 | 52 | 62 | 72 | 82 | 92 | 102 | (number & {}) - : number; +export type GetPluralCount = number; -type AddCount | undefined, Locale extends BaseLocale> = T extends [] - ? [ +export type Params< + Locale extends LocaleType, + Scope extends Scopes | undefined, + Key extends Keys, + Plural extends boolean = IsPlural, + Value extends string = Scope extends undefined + ? Plural extends true + ? Locale[`${Key}#${PluralSuffix}`] + : Locale[Key] + : Plural extends true + ? Locale[`${Scope}.${Key}#${PluralSuffix}`] + : Locale[`${Scope}.${Key}`], + TheParams extends string[] = ExtractParams, +> = Plural extends true + ? TheParams['length'] extends 0 + ? [{ count: GetPluralCount }] + : [ + { count: GetPluralCount } & { + [K in TheParams[number]]: Param; + }, + ] + : TheParams['length'] extends 0 + ? [] + : [ { - /** - * The `count` depends on the plural tags defined in your locale, - * and the current locale rules. - * - * - `zero` allows 0 - * - `one` autocompletes 1, 21, 31, 41... but allows any number - * - `two` autocompletes 2, 22, 32, 42... but allows any number - * - `few`, `many` and `other` allow any number - * - * @see https://www.unicode.org/cldr/charts/43/supplemental/language_plural_rules.html - */ - count: GetCountUnion; + [K in TheParams[number]]: Param; }, - ] - : T extends [infer R] - ? [ - { - /** - * The `count` depends on the plural tags defined in your locale, - * and the current locale rules. - * - * - `zero` allows 0 - * - `one` autocompletes 1, 21, 31, 41... but allows any number - * - `two` autocompletes 2, 22, 32, 42... but allows any number - * - `few`, `many` and `other` allow any number - * - * @see https://www.unicode.org/cldr/charts/43/supplemental/language_plural_rules.html - */ - count: GetCountUnion; - } & R, - ] + ]; + +type UnionToIntersection = (U extends never ? never : (arg: U) => never) extends (arg: infer I) => void ? I : never; + +type UnionToTuple = UnionToIntersection T> extends (_: never) => infer W + ? [...UnionToTuple>, W] + : []; + +type SomeKey> = UnionToTuple[0] extends string + ? UnionToTuple[0] : never; -export type CreateParams< - T, - Locale extends BaseLocale, - Scope extends Scopes | undefined, - Key extends LocaleKeys, - Value extends LocaleValue = ScopedValue, -> = IsPlural extends true - ? AddCount['length'] extends 0 ? [] : [T], Key, Scope, Locale> - : GetParams['length'] extends 0 - ? [] - : [T]; +export type GetLocale = Awaited]>>['default']; diff --git a/packages/international-types/package.json b/packages/international-types/package.json index dcdae36..21a1e0b 100644 --- a/packages/international-types/package.json +++ b/packages/international-types/package.json @@ -23,6 +23,7 @@ "homepage": "https://next-international.vercel.app", "license": "MIT", "scripts": { - "build": "tsc --declaration --emitDeclarationOnly --outDir dist index.ts" + "build": "tsc --declaration --emitDeclarationOnly --outDir dist index.ts", + "watch": "pnpm build --watch" } } diff --git a/packages/next-international/app.d.ts b/packages/next-international/app.d.ts new file mode 100644 index 0000000..0bcbf3f --- /dev/null +++ b/packages/next-international/app.d.ts @@ -0,0 +1 @@ +export * from './dist/app/app/app/client'; diff --git a/packages/next-international/package.json b/packages/next-international/package.json index 7949d9a..1bcd6ab 100644 --- a/packages/next-international/package.json +++ b/packages/next-international/package.json @@ -19,8 +19,13 @@ "default": "./dist/app/server/index.js" }, "./middleware": { - "types": "./dist/app/middleware/index.d.ts", - "default": "./dist/app/middleware/index.js" + "types": "./dist/app/app/middleware/index.d.ts", + "default": "./dist/app/app/middleware/index.js" + }, + "./app": { + "types": "./dist/app/app/app/client.d.ts", + "react-server": "./dist/app/app/app/server.js", + "default": "./dist/app/app/app/client.js" } }, "keywords": [ @@ -33,7 +38,8 @@ "dist", "client.d.ts", "server.d.ts", - "middleware.d.ts" + "middleware.d.ts", + "app.d.ts" ], "repository": { "type": "git", @@ -45,12 +51,12 @@ "homepage": "https://next-international.vercel.app", "license": "MIT", "scripts": { - "build": "tsup src/index.ts src/app/client/index.ts src/app/server/index.ts src/app/middleware/index.ts --external next --external react --dts", + "build": "tsup src/app/app/client.ts src/app/app/server.ts src/app/middleware/index.ts --out-dir dist/app/app --external next --external react --dts", "watch": "pnpm build --watch" }, "devDependencies": { "@types/react": "^18.2.45", - "next": "^14.0.4", + "next": "^14.1.1", "react": "^18.2.0", "tsup": "^8.0.1" }, diff --git a/packages/next-international/src/app/app/client.ts b/packages/next-international/src/app/app/client.ts new file mode 100644 index 0000000..aa05eec --- /dev/null +++ b/packages/next-international/src/app/app/client.ts @@ -0,0 +1,98 @@ +import type { GetLocale, LocaleType, LocalesObject } from 'international-types'; +import type { + CreateI18n, + GenerateI18nStaticParams, + I18nConfig, + UseChangeLocale, + GetI18n, + UseLocale, + GetScopedI18n, +} from './types'; +import { SEGMENT_NAME } from './constants'; +import { useParams, useRouter, usePathname, useSearchParams } from 'next/navigation'; +import { createT } from './utils'; + +function getLocaleCache(config: I18nConfig) { + // eslint-disable-next-line react-hooks/rules-of-hooks + const params = useParams(); + const locale = params[config.segmentName ?? SEGMENT_NAME]; + + if (typeof locale !== 'string') { + throw new Error(`Invariant: locale not found in params: ${JSON.stringify(params, null, 2)}`); + } + + return locale; +} + +export function createI18n>( + locales: Locales, + config: I18nConfig = {}, +): CreateI18n { + const localesCache = new Map>(); + + const getI18n: GetI18n = async () => { + const locale = getLocaleCache(config); + const data = localesCache.get(locale) ?? (await locales[locale]()).default; + + // @ts-expect-error - no types + return (key, ...params) => createT(locale, data, undefined, key, ...params); + }; + + const getScopedI18n: GetScopedI18n = async scope => { + const locale = getLocaleCache(config); + const data = localesCache.get(locale) ?? (await locales[locale]()).default; + + // @ts-expect-error - no types + return (key, ...params) => createT(locale, data, scope, key, ...params); + }; + + const generateI18nStaticParams: GenerateI18nStaticParams = () => { + return Object.keys(locales).map(locale => ({ [config.segmentName ?? SEGMENT_NAME]: locale })); + }; + + const getLocale: UseLocale = () => { + return getLocaleCache(config); + }; + + const useChangeLocale: UseChangeLocale = changeLocaleConfig => { + const router = useRouter(); + const currentLocale = getLocaleCache(config); + const path = usePathname(); + // We call the hook conditionally to avoid always opting out of Static Rendering. + // eslint-disable-next-line react-hooks/rules-of-hooks + const searchParams = changeLocaleConfig?.preserveSearchParams ? useSearchParams().toString() : undefined; + const finalSearchParams = searchParams ? `?${searchParams}` : ''; + + let pathWithoutLocale = path; + + if (config.basePath) { + pathWithoutLocale = pathWithoutLocale.replace(config.basePath, ''); + } + + if (pathWithoutLocale.startsWith(`/${currentLocale}/`)) { + pathWithoutLocale = pathWithoutLocale.replace(`/${currentLocale}/`, '/'); + } else if (pathWithoutLocale === `/${currentLocale}`) { + pathWithoutLocale = '/'; + } + + return newLocale => { + if (newLocale === currentLocale) return; + + locales[newLocale]().then(module => { + const finalLocale = newLocale as string; + + localesCache.set(finalLocale, module.default); + document.cookie = `locale=${finalLocale};`; + router.push(`/${newLocale as string}${pathWithoutLocale}${finalSearchParams}`); + }); + }; + }; + + return { + getI18n, + getScopedI18n, + generateI18nStaticParams, + getLocale, + useChangeLocale, + }; +} diff --git a/packages/next-international/src/app/app/constants.ts b/packages/next-international/src/app/app/constants.ts new file mode 100644 index 0000000..b6bfe90 --- /dev/null +++ b/packages/next-international/src/app/app/constants.ts @@ -0,0 +1 @@ +export const SEGMENT_NAME = 'locale'; diff --git a/packages/next-international/src/app/app/server.ts b/packages/next-international/src/app/app/server.ts new file mode 100644 index 0000000..0264853 --- /dev/null +++ b/packages/next-international/src/app/app/server.ts @@ -0,0 +1,83 @@ +import type { GetLocale, LocaleType, LocalesObject } from 'international-types'; +import type { + CreateI18n, + GenerateI18nStaticParams, + I18nConfig, + UseChangeLocale, + GetI18n, + UseLocale, + GetScopedI18n, +} from './types'; +import { SEGMENT_NAME } from './constants'; +// @ts-expect-error - no types +import { cache } from 'react'; +import { staticGenerationAsyncStorage } from 'next/dist/client/components/static-generation-async-storage.external'; +import { createT } from './utils'; + +const getLocaleCache = cache(() => { + const store = staticGenerationAsyncStorage.getStore(); + const url = store?.urlPathname; + + if (typeof url !== 'string') { + throw new Error('Invariant: urlPathname is not a string: ' + JSON.stringify(store, null, 2)); + } + + let locale = url.split('/')[1].split('?')[0]; + + if (locale === '') { + const cookie = (store?.incrementalCache?.requestHeaders?.['cookie'] as string) + ?.split(';') + .find(c => c.trim().startsWith('locale=')) + ?.split('=')[1]; + + if (!cookie) { + throw new Error('Invariant: locale cookie not found'); + } + + locale = cookie; + } + + return locale; +}); + +export function createI18n>( + locales: Locales, + config: I18nConfig = {}, +): CreateI18n { + const getI18n: GetI18n = async () => { + const locale = getLocaleCache(); + const data = (await locales[locale]()).default; + + return (key, ...params) => createT(locale, data, undefined, key, ...params); + }; + + const getScopedI18n: GetScopedI18n = async scope => { + const locale = getLocaleCache(); + const data = (await locales[locale]()).default; + + // @ts-expect-error - no types + return (key, ...params) => createT(locale, data, scope, key, ...params); + }; + + const generateI18nStaticParams: GenerateI18nStaticParams = () => { + return Object.keys(locales).map(locale => ({ [config.segmentName ?? SEGMENT_NAME]: locale })); + }; + + const getLocale: UseLocale = () => { + return getLocaleCache(); + }; + + const useChangeLocale: UseChangeLocale = () => { + return () => { + throw new Error('Invariant: useChangeLocale only works in Client Components'); + }; + }; + + return { + getI18n, + getScopedI18n, + generateI18nStaticParams, + getLocale, + useChangeLocale, + }; +} diff --git a/packages/next-international/src/app/app/types.ts b/packages/next-international/src/app/app/types.ts new file mode 100644 index 0000000..3985ed7 --- /dev/null +++ b/packages/next-international/src/app/app/types.ts @@ -0,0 +1,60 @@ +import type { Keys, LocaleType, LocalesObject, Params, Scopes } from 'international-types'; +import type { ReactNode } from 'react'; + +export type GetI18n = () => Promise< + >(key: Key, ...params: Params) => string | ReactNode[] +>; + +export type GetScopedI18n = >( + scope: Scope, +) => Promise< + >(key: Key, ...params: Params) => string | ReactNode[] +>; + +export type GenerateI18nStaticParams = () => Array>; + +export type UseLocale = () => keyof Locales; + +type UseChangeLocaleConfig = { + /** + * If `true`, the search params will be preserved when changing the locale. + * Don't forget to **wrap the component in a `Suspense` boundary to avoid opting out the page from Static Rendering**. + * + * @see https://nextjs.org/docs/app/api-reference/functions/use-search-params#static-rendering + * @default false + */ + preserveSearchParams?: boolean; +}; + +export type UseChangeLocale = ( + config?: UseChangeLocaleConfig, +) => (locale: keyof Locales) => void; + +export type CreateI18n = { + getI18n: GetI18n; + getScopedI18n: GetScopedI18n; + generateI18nStaticParams: GenerateI18nStaticParams; + getLocale: UseLocale; + useChangeLocale: UseChangeLocale; +}; + +export type I18nConfig = { + /** + * The name of the Next.js layout segment param that will be used to determine the locale in a client component. + * + * An app directory folder hierarchy that looks like `app/[locale]/products/[category]/[subCategory]/page.tsx` would be `locale`. + * + * @default locale + */ + segmentName?: string; + /** + * If you are using a custom basePath inside `next.config.js`, you must also specify it here. + * + * @see https://nextjs.org/docs/app/api-reference/next-config-js/basePath + */ + basePath?: string; + /** + * A locale to use if some keys aren't translated, to fallback to this locale instead of showing the translation key. + */ + fallbackLocale?: Record; +}; diff --git a/packages/next-international/src/app/app/utils.ts b/packages/next-international/src/app/app/utils.ts new file mode 100644 index 0000000..f739da5 --- /dev/null +++ b/packages/next-international/src/app/app/utils.ts @@ -0,0 +1,106 @@ +import type { Keys, LocaleType, Params, Scopes } from 'international-types'; +import type { ReactNode } from 'react'; +import { cloneElement, isValidElement } from 'react'; + +function flattenLocale(locale: Record, prefix = ''): Locale { + return Object.entries(locale).reduce( + (prev, [name, value]) => ({ + ...prev, + ...(typeof value === 'string' + ? { [prefix + name]: value } + : flattenLocale(value as unknown as Locale, `${prefix}${name}.`)), + }), + {} as Locale, + ); +} + +type Cache = { + content: LocaleType; + pluralKeys: Set; +}; + +const LOCALE_CACHE = new Map(); + +export function createT< + Locale extends LocaleType, + Scope extends Scopes | undefined, + Key extends Keys, + Param extends Params, +>(locale: string, data: Locale, scope: Scope, key: Key, ...params: Param) { + let cache = LOCALE_CACHE.get(locale); + + if (!cache) { + const content = flattenLocale(data); + const pluralKeys = new Set( + Object.keys(content) + .filter(key => key.includes('#')) + .map(key => key.split('#', 1)[0]), + ); + + const newCache = { + content, + pluralKeys, + }; + + cache = newCache; + LOCALE_CACHE.set(locale, newCache); + } + + const { content, pluralKeys } = cache; + const pluralRules = new Intl.PluralRules(locale); + + function getPluralKey(count: number) { + if (count === 0) return 'zero'; + return pluralRules.select(count); + } + + const paramObject = params[0]; + let isPlural = false; + + if (paramObject && 'count' in paramObject) { + const isPluralKey = scope ? pluralKeys.has(`${scope}.${key}`) : pluralKeys.has(key); + + if (isPluralKey) { + // @ts-expect-error - no types + key = `${key}#${getPluralKey(paramObject.count)}` as Key; + isPlural = true; + } + } + + let value = scope ? content[`${scope}.${key}`] : content[key]; + + if (!value && isPlural) { + const baseKey = key.split('#', 1)[0] as Key; + value = (content[`${baseKey}#other`] || key)?.toString(); + } else { + value = (value || key)?.toString(); + } + + if (!paramObject) { + return value; + } + + let isString = true; + + const result = value?.split(/({[^}]*})/).map((part, index) => { + const match = part.match(/{(.*)}/); + + if (match) { + const param = match[1] as keyof Locale; + // @ts-expect-error - no types + const paramValue = paramObject[param]; + + if (isValidElement(paramValue)) { + isString = false; + return cloneElement(paramValue, { key: `${String(param)}-${index}` }); + } + + return paramValue as ReactNode; + } + + // if there's no match - it's not a variable and just a normal string + return part; + }); + + return isString ? result?.join('') : result; +} diff --git a/packages/next-international/src/app/middleware/index.ts b/packages/next-international/src/app/middleware/index.ts index 427bad9..7d27853 100644 --- a/packages/next-international/src/app/middleware/index.ts +++ b/packages/next-international/src/app/middleware/index.ts @@ -1,114 +1,104 @@ -import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; +import type { NextMiddleware, NextRequest } from 'next/server'; + +type I18nMiddlewareConfig = { + locales: Locales; + defaultLocale: Locales[number]; + /** + * When a url is not prefixed with a locale, this setting determines whether the middleware should perform a *redirect* or *rewrite* to the default locale. + * + * **redirect**: `https://example.com/products` -> *redirect* to `https://example.com/en/products` -> client sees the locale in the url + * + * **rewrite**: `https://example.com/products` -> *rewrite* to `https://example.com/en/products` -> client doesn't see the locale in the url + * + * **rewriteDefault**: `https://example.com/products` -> use *rewrite* for the default locale, *redirect* for all other locales + * + * @default redirect + */ + urlMappingStrategy?: 'redirect' | 'rewrite' | 'rewriteDefault'; + /** + * Override the resolution of a locale from a `Request`, which by default will try to extract it from the `Accept-Language` header. This can be useful to force the use of a specific locale regardless of the `Accept-Language` header. + * + * @description This function will only be called if the user doesn't already have a `Next-Locale` cookie. + */ + resolveLocaleFromRequest?: (request: NextRequest) => string | null; +}; -import { LOCALE_COOKIE, LOCALE_HEADER } from '../../common/constants'; -import { warn } from '../../helpers/log'; -import type { I18nMiddlewareConfig } from '../../types'; - -const DEFAULT_STRATEGY: NonNullable['urlMappingStrategy']> = 'redirect'; - -export function createI18nMiddleware(config: I18nMiddlewareConfig) { - return function I18nMiddleware(request: NextRequest) { - const locale = localeFromRequest(config.locales, request, config.resolveLocaleFromRequest) ?? config.defaultLocale; - const nextUrl = request.nextUrl; - - // If the locale from the request is not an handled locale, then redirect to the same URL with the default locale - if (noLocalePrefix(config.locales, nextUrl.pathname)) { - nextUrl.pathname = `/${locale}${nextUrl.pathname}`; - - const strategy = config.urlMappingStrategy ?? DEFAULT_STRATEGY; - if (strategy === 'rewrite' || (strategy === 'rewriteDefault' && locale === config.defaultLocale)) { - const response = NextResponse.rewrite(nextUrl); - return addLocaleToResponse(request, response, locale); - } else { - if (!['redirect', 'rewriteDefault'].includes(strategy)) { - warn(`Invalid urlMappingStrategy: ${strategy}. Defaulting to redirect.`); - } - - const response = NextResponse.redirect(nextUrl); - return addLocaleToResponse(request, response, locale); - } - } - - let response = NextResponse.next(); - const pathnameLocale = nextUrl.pathname.split('/', 2)?.[1]; - - if (!pathnameLocale || config.locales.includes(pathnameLocale)) { - // If the URL mapping strategy is set to 'rewrite' and the locale from the request doesn't match the locale in the pathname, - // or if the URL mapping strategy is set to 'rewriteDefault' and the locale from the request doesn't match the locale in the pathname - // or is the same as the default locale, then proceed with the following logic - if ( - (config.urlMappingStrategy === 'rewrite' && pathnameLocale !== locale) || - (config.urlMappingStrategy === 'rewriteDefault' && - (pathnameLocale !== locale || pathnameLocale === config.defaultLocale)) - ) { - // Remove the locale from the pathname - const pathnameWithoutLocale = nextUrl.pathname.slice(pathnameLocale.length + 1); - - // Create a new URL without the locale in the pathname - const newUrl = new URL(pathnameWithoutLocale || '/', request.url); - - // Preserve the original search parameters - newUrl.search = nextUrl.search; - response = NextResponse.redirect(newUrl); - } - - return addLocaleToResponse(request, response, pathnameLocale ?? config.defaultLocale); - } - - return response; - }; -} - -/** - * Retrieve `Next-Locale` header from request - * and check if it is an handled locale. - */ -function localeFromRequest( - locales: Locales, +function getLocaleFromRequest( request: NextRequest, - resolveLocaleFromRequest: NonNullable< - I18nMiddlewareConfig['resolveLocaleFromRequest'] - > = defaultResolveLocaleFromRequest, + config: I18nMiddlewareConfig, ) { - const locale = request.cookies.get(LOCALE_COOKIE)?.value ?? resolveLocaleFromRequest(request); + if (config.resolveLocaleFromRequest) { + return config.resolveLocaleFromRequest(request); + } + + const locale = request.cookies.get('locale')?.value; - if (!locale || !locales.includes(locale)) { + if (!locale || !config.locales.includes(locale)) { return null; } return locale; } -/** - * Default implementation of the `resolveLocaleFromRequest` function for the I18nMiddlewareConfig. - * This function extracts the locale from the 'Accept-Language' header of the request. - */ -const defaultResolveLocaleFromRequest: NonNullable['resolveLocaleFromRequest']> = request => { - const header = request.headers.get('Accept-Language'); - const locale = header?.split(',', 1)?.[0]?.split('-', 1)?.[0]; - return locale ?? null; -}; - -/** - * Returns `true` if the pathname does not start with an handled locale - */ function noLocalePrefix(locales: readonly string[], pathname: string) { return locales.every(locale => { return !(pathname === `/${locale}` || pathname.startsWith(`/${locale}/`)); }); } -/** - * Add `X-Next-Locale` header and `Next-Locale` cookie to response - * - * **NOTE:** The cookie is only set if the locale is different from the one in the cookie - */ -function addLocaleToResponse(request: NextRequest, response: NextResponse, locale: string) { - response.headers.set(LOCALE_HEADER, locale); +export function createI18nMiddleware( + middleware: NextMiddleware, + config: I18nMiddlewareConfig, +): NextMiddleware { + return async (request, event) => { + let currentLocale = getLocaleFromRequest(request, config); + + if (currentLocale === null) { + currentLocale = config.defaultLocale; + const response = await middleware(request, event); + + if (response instanceof NextResponse) { + response.cookies.set('locale', currentLocale, { sameSite: 'strict' }); + } else if (response instanceof Response) { + const cookies = response.headers.get('set-cookie') ?? ''; + response.headers.set('set-cookie', `${cookies}; locale=${currentLocale}; SameSite=Strict`); + } - if (request.cookies.get(LOCALE_COOKIE)?.value !== locale) { - response.cookies.set(LOCALE_COOKIE, locale, { sameSite: 'strict' }); - } - return response; + return response; + } + + if (!config.urlMappingStrategy || config.urlMappingStrategy === 'redirect') { + const nextUrl = request.nextUrl; + const pathname = new URL(request.url).pathname; + + if (noLocalePrefix(config.locales, pathname)) { + nextUrl.pathname = `/${currentLocale}${pathname}`; + const response = NextResponse.redirect(nextUrl); + return response; + } + } + + if ( + (config.urlMappingStrategy === 'rewriteDefault' && currentLocale === config.defaultLocale) || + config.urlMappingStrategy === 'rewrite' + ) { + const nextUrl = request.nextUrl; + + if (noLocalePrefix(config.locales, nextUrl.pathname)) { + nextUrl.pathname = `/${currentLocale}${nextUrl.pathname}`; + const response = NextResponse.rewrite(nextUrl); + return response; + } + + const urlWithoutLocale = nextUrl.pathname.slice(currentLocale.length + 1); + const newUrl = new URL(urlWithoutLocale || '/', request.url); + newUrl.search = nextUrl.search; + + const response = NextResponse.redirect(newUrl); + return response; + } + + return middleware(request, event); + }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 13d7d81..0fd7c42 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -127,6 +127,34 @@ importers: specifier: ^5.2.2 version: 5.2.2 + examples/app: + dependencies: + next: + specifier: 14.1.1 + version: 14.1.1(@babel/core@7.23.2)(react-dom@18.2.0)(react@18.2.0) + next-international: + specifier: workspace:* + version: link:../../packages/next-international + react: + specifier: ^18 + version: 18.2.0 + react-dom: + specifier: ^18 + version: 18.2.0(react@18.2.0) + devDependencies: + '@types/node': + specifier: ^20 + version: 20.8.7 + '@types/react': + specifier: ^18 + version: 18.2.45 + '@types/react-dom': + specifier: ^18 + version: 18.0.6 + typescript: + specifier: ^5 + version: 5.2.2 + examples/next-app: dependencies: next: @@ -207,8 +235,8 @@ importers: specifier: ^18.2.45 version: 18.2.45 next: - specifier: ^14.0.4 - version: 14.0.4(@babel/core@7.23.2)(react-dom@18.2.0)(react@18.2.0) + specifier: ^14.1.1 + version: 14.1.1(@babel/core@7.23.2)(react-dom@18.2.0)(react@18.2.0) react: specifier: ^18.2.0 version: 18.2.0 @@ -2373,6 +2401,10 @@ packages: /@next/env@14.0.4: resolution: {integrity: sha512-irQnbMLbUNQpP1wcE5NstJtbuA/69kRfzBrpAD7Gsn8zm/CY6YQYc3HQBz8QPxwISG26tIm5afvvVbu508oBeQ==} + dev: false + + /@next/env@14.1.1: + resolution: {integrity: sha512-7CnQyD5G8shHxQIIg3c7/pSeYFeMhsNbpU/bmvH7ZnDql7mNRgg8O2JZrhrc/soFnfBnKP4/xXNiiSIPn2w8gA==} /@next/eslint-plugin-next@13.5.6: resolution: {integrity: sha512-ng7pU/DDsxPgT6ZPvuprxrkeew3XaRf4LAT4FabaEO/hAbvVx4P7wqnqdbTdDn1kgTvsI4tpIgT4Awn/m0bGbg==} @@ -2386,6 +2418,15 @@ packages: cpu: [arm64] os: [darwin] requiresBuild: true + dev: false + optional: true + + /@next/swc-darwin-arm64@14.1.1: + resolution: {integrity: sha512-yDjSFKQKTIjyT7cFv+DqQfW5jsD+tVxXTckSe1KIouKk75t1qZmj/mV3wzdmFb0XHVGtyRjDMulfVG8uCKemOQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + requiresBuild: true optional: true /@next/swc-darwin-x64@14.0.4: @@ -2394,6 +2435,15 @@ packages: cpu: [x64] os: [darwin] requiresBuild: true + dev: false + optional: true + + /@next/swc-darwin-x64@14.1.1: + resolution: {integrity: sha512-KCQmBL0CmFmN8D64FHIZVD9I4ugQsDBBEJKiblXGgwn7wBCSe8N4Dx47sdzl4JAg39IkSN5NNrr8AniXLMb3aw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + requiresBuild: true optional: true /@next/swc-linux-arm64-gnu@14.0.4: @@ -2402,6 +2452,15 @@ packages: cpu: [arm64] os: [linux] requiresBuild: true + dev: false + optional: true + + /@next/swc-linux-arm64-gnu@14.1.1: + resolution: {integrity: sha512-YDQfbWyW0JMKhJf/T4eyFr4b3tceTorQ5w2n7I0mNVTFOvu6CGEzfwT3RSAQGTi/FFMTFcuspPec/7dFHuP7Eg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + requiresBuild: true optional: true /@next/swc-linux-arm64-musl@14.0.4: @@ -2410,6 +2469,15 @@ packages: cpu: [arm64] os: [linux] requiresBuild: true + dev: false + optional: true + + /@next/swc-linux-arm64-musl@14.1.1: + resolution: {integrity: sha512-fiuN/OG6sNGRN/bRFxRvV5LyzLB8gaL8cbDH5o3mEiVwfcMzyE5T//ilMmaTrnA8HLMS6hoz4cHOu6Qcp9vxgQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + requiresBuild: true optional: true /@next/swc-linux-x64-gnu@14.0.4: @@ -2418,6 +2486,15 @@ packages: cpu: [x64] os: [linux] requiresBuild: true + dev: false + optional: true + + /@next/swc-linux-x64-gnu@14.1.1: + resolution: {integrity: sha512-rv6AAdEXoezjbdfp3ouMuVqeLjE1Bin0AuE6qxE6V9g3Giz5/R3xpocHoAi7CufRR+lnkuUjRBn05SYJ83oKNQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + requiresBuild: true optional: true /@next/swc-linux-x64-musl@14.0.4: @@ -2426,6 +2503,15 @@ packages: cpu: [x64] os: [linux] requiresBuild: true + dev: false + optional: true + + /@next/swc-linux-x64-musl@14.1.1: + resolution: {integrity: sha512-YAZLGsaNeChSrpz/G7MxO3TIBLaMN8QWMr3X8bt6rCvKovwU7GqQlDu99WdvF33kI8ZahvcdbFsy4jAFzFX7og==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + requiresBuild: true optional: true /@next/swc-win32-arm64-msvc@14.0.4: @@ -2434,6 +2520,15 @@ packages: cpu: [arm64] os: [win32] requiresBuild: true + dev: false + optional: true + + /@next/swc-win32-arm64-msvc@14.1.1: + resolution: {integrity: sha512-1L4mUYPBMvVDMZg1inUYyPvFSduot0g73hgfD9CODgbr4xiTYe0VOMTZzaRqYJYBA9mana0x4eaAaypmWo1r5A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + requiresBuild: true optional: true /@next/swc-win32-ia32-msvc@14.0.4: @@ -2442,6 +2537,15 @@ packages: cpu: [ia32] os: [win32] requiresBuild: true + dev: false + optional: true + + /@next/swc-win32-ia32-msvc@14.1.1: + resolution: {integrity: sha512-jvIE9tsuj9vpbbXlR5YxrghRfMuG0Qm/nZ/1KDHc+y6FpnZ/apsgh+G6t15vefU0zp3WSpTMIdXRUsNl/7RSuw==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + requiresBuild: true optional: true /@next/swc-win32-x64-msvc@14.0.4: @@ -2450,6 +2554,15 @@ packages: cpu: [x64] os: [win32] requiresBuild: true + dev: false + optional: true + + /@next/swc-win32-x64-msvc@14.1.1: + resolution: {integrity: sha512-S6K6EHDU5+1KrBDLko7/c1MNy/Ya73pIAmvKeFwsF4RmBFJSO7/7YeD4FnZ4iBdzE69PpQ4sOMU9ORKeNuxe8A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + requiresBuild: true optional: true /@nodelib/fs.scandir@2.1.5: @@ -3568,7 +3681,7 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true dependencies: - caniuse-lite: 1.0.30001509 + caniuse-lite: 1.0.30001587 electron-to-chromium: 1.4.447 node-releases: 2.0.12 update-browserslist-db: 1.0.11(browserslist@4.21.9) @@ -3620,6 +3733,10 @@ packages: /caniuse-lite@1.0.30001509: resolution: {integrity: sha512-2uDDk+TRiTX5hMcUYT/7CSyzMZxjfGu0vAUjS2g0LSD8UoXOv0LtpH4LxGMemsiPq6LCVIUjNwVM0erkOkGCDA==} + dev: false + + /caniuse-lite@1.0.30001587: + resolution: {integrity: sha512-HMFNotUmLXn71BQxg8cijvqxnIAofforZOwGsxyXJ0qugTdspUF4sPSJ2vhgprHCB996tIDzEq1ubumPDV8ULA==} /ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -5359,6 +5476,7 @@ packages: /glob-to-regexp@0.4.1: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + dev: false /glob@7.1.6: resolution: {integrity: sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==} @@ -7253,6 +7371,45 @@ packages: transitivePeerDependencies: - '@babel/core' - babel-plugin-macros + dev: false + + /next@14.1.1(@babel/core@7.23.2)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-McrGJqlGSHeaz2yTRPkEucxQKe5Zq7uPwyeHNmJaZNY4wx9E9QdxmTp310agFRoMuIYgQrCrT3petg13fSVOww==} + engines: {node: '>=18.17.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + react: ^18.2.0 + react-dom: ^18.2.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + sass: + optional: true + dependencies: + '@next/env': 14.1.1 + '@swc/helpers': 0.5.2 + busboy: 1.6.0 + caniuse-lite: 1.0.30001587 + graceful-fs: 4.2.11 + postcss: 8.4.31 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + styled-jsx: 5.1.1(@babel/core@7.23.2)(react@18.2.0) + optionalDependencies: + '@next/swc-darwin-arm64': 14.1.1 + '@next/swc-darwin-x64': 14.1.1 + '@next/swc-linux-arm64-gnu': 14.1.1 + '@next/swc-linux-arm64-musl': 14.1.1 + '@next/swc-linux-x64-gnu': 14.1.1 + '@next/swc-linux-x64-musl': 14.1.1 + '@next/swc-win32-arm64-msvc': 14.1.1 + '@next/swc-win32-ia32-msvc': 14.1.1 + '@next/swc-win32-x64-msvc': 14.1.1 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros /nextra-theme-docs@2.13.2(next@14.0.4)(nextra@2.13.2)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-yE4umXaImp1/kf/sFciPj2+EFrNSwd9Db26hi98sIIiujzGf3+9eUgAz45vF9CwBw50FSXxm1QGRcY+slQ4xQQ==} @@ -9222,6 +9379,7 @@ packages: dependencies: glob-to-regexp: 0.4.1 graceful-fs: 4.2.11 + dev: false /web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}