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: Add built-in pathname localization #416

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion examples/example-next-13/messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
},
"AboutPage": {
"title": "Über",
"description": "<p>Auch das Routing ist internationalisiert.</p><p>Wenn du die Standardsprache Englisch verwendest, siehst du <code>/about</code> in der Adressleiste des Browsers auf dieser Seite.</p><p>Wenn du die Sprache auf Deutsch änderst, wird die URL mit der Locale ergänzt (<code>/de/about</code>).</p>"
"description": "<p>Auch das Routing ist internationalisiert.</p><p>Wenn du die Standardsprache Englisch verwendest, siehst du <code>/about</code> in der Adressleiste des Browsers auf dieser Seite.</p><p>Wenn du die Sprache auf Deutsch änderst, wird die URL mit der Locale ergänzt und lokalisiert (<code>/de/über</code>).</p>"
},
"Error": {
"title": "Etwas ist schief gelaufen!",
Expand Down
2 changes: 1 addition & 1 deletion examples/example-next-13/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
},
"AboutPage": {
"title": "About",
"description": "<p>The routing is internationalized too.</p><p>If you're using the default language English, you'll see <code>/about</code> in the browser address bar on this page.</p><p>If you change the locale to German, the URL is prefixed with the locale (<code>/de/about</code>).</p>"
"description": "<p>The routing is internationalized too.</p><p>If you're using the default language English, you'll see <code>/about</code> in the browser address bar on this page.</p><p>If you change the locale to German, the URL is prefixed with the locale and localized accordingly (<code>/de/über</code>).</p>"
},
"Error": {
"title": "Something went wrong!",
Expand Down
2 changes: 1 addition & 1 deletion examples/example-next-13/src/app/[locale]/about/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export default function AboutPage() {

return (
<PageLayout title={t('title')}>
<div className="max-w-[460px]">
<div className="max-w-[520px]">
{t.rich('description', {
p: (chunks) => <p className="mt-4">{chunks}</p>,
code: (chunks) => (
Expand Down
2 changes: 2 additions & 0 deletions examples/example-next-13/src/components/NavigationLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ type Props = Omit<ComponentProps<typeof Link>, 'href'> & {

export default function NavigationLink({href, ...rest}: Props) {
const pathname = usePathname();

// TODO: We need to consult the pathnames map here
const isActive = pathname === href;

return (
Expand Down
9 changes: 8 additions & 1 deletion examples/example-next-13/src/middleware.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,14 @@ import createMiddleware from 'next-intl/middleware';

export default createMiddleware({
locales: ['en', 'de'],
defaultLocale: 'en'
defaultLocale: 'en',
pathnames: {
home: '/',
about: {
en: '/about',
de: '/ueber'
}
}
});

export const config = {
Expand Down
4 changes: 3 additions & 1 deletion packages/next-intl/middleware.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import createMiddleware from './dist/middleware';
// dts-cli still uses TypeScript 4 and isn't able to
// compile the types for the middlware correctly.
import createMiddleware from './dist/src/middleware';

export = createMiddleware;
1 change: 1 addition & 0 deletions packages/next-intl/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"dependencies": {
"@formatjs/intl-localematcher": "^0.2.32",
"negotiator": "^0.6.3",
"path-to-regexp": "^6.2.1",
"use-intl": "^2.19.0"
},
"peerDependencies": {
Expand Down
106 changes: 106 additions & 0 deletions packages/next-intl/src/middleware/LocalizedPathnames.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import {NextRequest} from 'next/server';
import {
AllLocales,
MiddlewareConfigWithDefaults
} from './NextIntlMiddlewareConfig';
import {
formatPathname,
getKnownLocaleFromPathname,
getPathWithSearch,
getRouteParams,
matchesPathname
} from './utils';

export function getLocalizedRedirectPathname<Locales extends AllLocales>(
request: NextRequest,
resolvedLocale: Locales[number],
configWithDefaults: MiddlewareConfigWithDefaults<Locales>
) {
if (!configWithDefaults.pathnames) return;

const {pathname} = request.nextUrl;
const pathLocale = getKnownLocaleFromPathname(
request.nextUrl.pathname,
configWithDefaults.locales
);

for (const [, routePath] of Object.entries(configWithDefaults.pathnames)) {
if (typeof routePath === 'string') {
// No redirect is necessary if all locales use the same pathname
continue;
}

for (const [locale, localePathname] of Object.entries(routePath)) {
if (resolvedLocale === locale) {
continue;
}

let template = '';
if (pathLocale) template = `/${pathLocale}`;
template += localePathname;

const matches = matchesPathname(template, pathname);
if (matches) {
const params = getRouteParams(template, pathname);

let targetPathname = '';
if (resolvedLocale !== configWithDefaults.defaultLocale || pathLocale) {
targetPathname = `/${resolvedLocale}`;
}
targetPathname += formatPathname(routePath[resolvedLocale], params);

return getPathWithSearch(targetPathname, request.nextUrl.search);
}
}
}

return;
}

/**
* Checks if the request matches a localized route
* and returns the rewritten pathname if so.
*/
export function getLocalizedRewritePathname<Locales extends AllLocales>(
request: NextRequest,
configWithDefaults: MiddlewareConfigWithDefaults<Locales>
) {
if (!configWithDefaults.pathnames) return;

const {pathname} = request.nextUrl;
const pathLocale = getKnownLocaleFromPathname(
request.nextUrl.pathname,
configWithDefaults.locales
);

if (
// When using unprefixed routing, we assume that the
// pathname uses routes from the default locale
!pathLocale ||
// Internal routes are set up based on the default locale
pathLocale === configWithDefaults.defaultLocale
) {
return;
}

for (const [, routePath] of Object.entries(configWithDefaults.pathnames)) {
if (typeof routePath === 'string') {
// No rewrite is necessary if all locales use the same pathname
continue;
}

const defaultLocalePathname = routePath[configWithDefaults.defaultLocale];
const pathLocalePathname = `/${pathLocale}${routePath[pathLocale]}`;
const matches = matchesPathname(pathLocalePathname, pathname);

if (matches) {
const params = getRouteParams(pathLocalePathname, pathname);
return getPathWithSearch(
`/${pathLocale}` + formatPathname(defaultLocalePathname, params),
request.nextUrl.search
);
}
}

return;
}
59 changes: 37 additions & 22 deletions packages/next-intl/src/middleware/NextIntlMiddlewareConfig.tsx
Original file line number Diff line number Diff line change
@@ -1,42 +1,57 @@
type LocalePrefix = 'as-needed' | 'always' | 'never';

type RoutingBaseConfig = {
type Locale = string;
export type AllLocales = ReadonlyArray<Locale>;

type RoutingBaseConfig<Locales extends AllLocales> = {
/** A list of all locales that are supported. */
locales: Array<string>;
locales: Locales;

/* Used by default if none of the defined locales match. */
defaultLocale: string;
defaultLocale: Locales[number];

/** The default locale can be used without a prefix (e.g. `/about`). If you prefer to have a prefix for the default locale as well (e.g. `/en/about`), you can switch this option to `always`.
*/
localePrefix?: LocalePrefix;
};

export type DomainConfig = Omit<
RoutingBaseConfig,
export type DomainConfig<Locales extends AllLocales> = Omit<
RoutingBaseConfig<Locales>,
'locales' | 'localePrefix'
> & {
/** The domain name (e.g. "example.com", "www.example.com" or "fr.example.com"). Note that the `x-forwarded-host` or alternatively the `host` header will be used to determine the requested domain. */
domain: string;
// Optional here
locales?: RoutingBaseConfig['locales'];
};

type MiddlewareConfig = RoutingBaseConfig & {
/** Can be used to change the locale handling per domain. */
domains?: Array<DomainConfig>;

/** By setting this to `false`, the `accept-language` header will no longer be used for locale detection. */
localeDetection?: boolean;

/** Sets the `Link` response header to notify search engines about content in other languages (defaults to `true`). See https://developers.google.com/search/docs/specialty/international/localized-versions#http */
alternateLinks?: boolean;
/** The locales availabe on this particular domain. */
locales?: RoutingBaseConfig<Array<Locales[number]>>['locales'];
};

export type MiddlewareConfigWithDefaults = MiddlewareConfig & {
alternateLinks: boolean;
localePrefix: LocalePrefix;
localeDetection: boolean;
};
export type Pathnames<Locales extends AllLocales> = Record<
string,
{[Key in Locales[number]]: string} | string
>;

// TODO: Default or not?
type MiddlewareConfig<Locales extends AllLocales> =
RoutingBaseConfig<Locales> & {
/** Can be used to change the locale handling per domain. */
domains?: Array<DomainConfig<Locales>>;

/** By setting this to `false`, the `accept-language` header will no longer be used for locale detection. */
localeDetection?: boolean;

/** Sets the `Link` response header to notify search engines about content in other languages (defaults to `true`). See https://developers.google.com/search/docs/specialty/international/localized-versions#http */
alternateLinks?: boolean;

/** TODO */
pathnames?: Pathnames<Locales>;
};

export type MiddlewareConfigWithDefaults<Locales extends AllLocales> =
MiddlewareConfig<Locales> & {
alternateLinks: boolean;
localePrefix: LocalePrefix;
localeDetection: boolean;
};

export default MiddlewareConfig;
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import {NextRequest} from 'next/server';
import MiddlewareConfig, {
AllLocales,
MiddlewareConfigWithDefaults
} from './NextIntlMiddlewareConfig';
import {isLocaleSupportedOnDomain} from './utils';

function getUnprefixedUrl(config: MiddlewareConfig, request: NextRequest) {
function getUnprefixedUrl<Locales extends AllLocales>(
config: MiddlewareConfig<Locales>,
request: NextRequest
) {
const url = new URL(request.url);
if (!url.pathname.endsWith('/')) {
url.pathname += '/';
Expand All @@ -30,10 +34,9 @@ function getAlternateEntry(url: string, locale: string) {
/**
* See https://developers.google.com/search/docs/specialty/international/localized-versions
*/
export default function getAlternateLinksHeaderValue(
config: MiddlewareConfigWithDefaults,
request: NextRequest
) {
export default function getAlternateLinksHeaderValue<
Locales extends AllLocales
>(config: MiddlewareConfigWithDefaults<Locales>, request: NextRequest) {
const unprefixedUrl = getUnprefixedUrl(config, request);

const links = config.locales.flatMap((locale) => {
Expand Down
Loading
Loading