amannn / next-intl

🌐 Internationalization (i18n) for Next.js
https://next-intl-docs.vercel.app
MIT License
2.58k stars 236 forks source link

Add support for partially translated routes #990

Open maxijonson opened 7 months ago

maxijonson commented 7 months ago

Is your feature request related to a problem? Please describe.

We support 6 languages on our website: en, fr, es, pt, ja and ru. All pages are initially written in english, but not always in all of the other languages. This puts us in the following use-cases:

All of these are not blockers though, since there are workarounds to all of them. It's just repetitive to have to apply those workarounds every time we encounter one of those situations.

Describe the solution you'd like

It would be nice for next-intl to provide ways of working with partially translated pages:

Describe alternatives you've considered

Link

There are 2 alternatives. Either use conditional rendering:

import { useLocale, useTranslations } from "next-intl";
import { Link } from "@/src/i18n/navigation";

const Page = () => {
    const locale = useLocale();
    const t = useTranslations();

    return (
        <div>
            {/* ... */}
            {["en", "fr", "es"].includes(locale) && (
                <Link href="/about-us">{t("Footer.about_us")}</Link>
            )}
            {/* ... */}
        </div>
    )
};

or make a wrapper component that encapsulate the above logic:

import { useLocale, useTranslations } from "next-intl";
import { Link } from "@/src/i18n/navigation";

const LocalizedLink = ({ availableLocales, ...props }) => {
    const locale = useLocale();
    if (!availableLocales.includes(locale)) return null;

    return <Link {...props} />;
}

const Page = () => {
    const t = useTranslations();

    return (
        <div>
            {/* ... */}
            <LocalizedLink availableLocales={["en", "fr", "es"]} href="/about-us">
                {t("Footer.about_us")}
            </LocalizedLink>
            {/* ... */}
        </div>
    )
};

Pathnames

The solution is just to provide an arbitrary path (usually the en one)

const makePartiallyLocalizedPathname = (
  paths: {
    [key in Exclude<Language, "en">]?: string;
  } & { en: string },
): Record<Language, string> => {
  return {
    en: paths.en,
    fr: paths.fr ?? paths.en,
    es: paths.es ?? paths.en,
    pt: paths.pt ?? paths.en,
    ja: paths.ja ?? paths.en,
    ru: paths.ru ?? paths.en,
    ko: paths.ko ?? paths.en,
  };
};

export const pathnames = {
  "/about-us": makePartiallyLocalizedPathname({
    en: "/about-us",
    fr: "/a-propos-de-nous",
    es: "/about-us", // we could have omitted it, but it's just a hint for us that this is translated
  }),
} satisfies Pathnames<typeof languages>;

Alternate links

Again, we haven't looked into this yet, but prior to integrating next-intl, we were handling i18n manually (using the documented way by NextJS, which doesn't require any 3rd party lib). So the alternative would be to just turn off alternates from next-intl and create them by ourselves: (this isn't exactly how it's implemented, we have a couple helpers we built to help us, but that's the general idea of what's being done):

export const generateMetadata = async ({
  params: { locale },
}): Promise<Metadata> => {
  const canonical = (() => {
    switch (locale) {
      case "fr":
        return "/fr/a-propos-de-nous";
      case "es":
        return "/es/about-us";
      default:
        return "/about-us";
    }
  })();

return {
  canonical,
  languages: {
    "x-default": "/about-us",
    fr: "/fr/a-propos-de-nous",
    es: "/es/about-us",
    en: "/about-us",
  },
};

I've also seen the docs has a FAQ answer on customizing your own alternate links, but it's unclear at this time if this would suit us (since we haven't looked into it too far yet! 😛)

maxijonson commented 7 months ago

After finally digging into alternate links, it does indeed do what I thought it would: every path gets an alternate link for each locale. This is problematic in our case, because like I mentioned, some pages (like /blog) are not available in all of the configured locales. So an alternate link pointing to something like /ru/blog shouldn't exist. Same thing goes for blog posts (/blog/[slug] in our pathnames). Almost all of our posts are in a single language and when they're translated, they'll always have a different slug.

However, instead of going for my previous solution, which was to disable alternateLinks in the middleware and just generate them myself with generateMetadata, I came up with a middleware to help me. It uses some derived pathnames which doesn't necessarily define the path for all of the locales and removes the alternate links if their language doesn't appear in the pathname definition.

I also came up with a Link wrapper called LocalizedLink, a bit like I mentioned above, but I did make a couple modifications.

It's still in development though, so it hasn't been thoroughly tested yet, but if it ends up working well, I'll probably share how I've done it, in case other people are in a similar situation.

github-actions[bot] commented 6 months ago

This issue has been automatically closed because there was no recent activity and it was marked as unconfirmed. Note that issues are regularly checked and if they remain in unconfirmed state, they might miss information required to be actionable or are potentially out-of-scope. If you'd like to discuss this topic further, feel free to open a discussion instead.

amannn commented 6 months ago

The same issue has been discussed in https://github.com/amannn/next-intl/discussions/1009 and https://github.com/amannn/next-intl/discussions/955.

A potential solution is discussed in https://github.com/amannn/next-intl/discussions/1009#discussioncomment-9261306. In case someone is interested in implementing this, I'd be happy to review a PR!

amannn commented 5 months ago

Related: After working on https://github.com/amannn/next-intl/pull/1086, I could imagine that entries for a given locale could be partial objects, where the internal route is used as a default in case not further specified. Still, we could accept null as a way to instruct the middleware that this particular path is not available in a given locale.

I'm not sure yet if this is a good idea though. It provides convenience, but comes at the expense of potentially missing the localization of a pathname to a given locale. The situation is a bit different with custom prefixes, where a) the configuration object is much smaller and b) the default is typically desired.

maxijonson commented 5 months ago

Yeah I agree with you, there could be risks associated with accepting null as a path's locale.

After building our own workaround for this issue, we don't have something that is 100% generalized. Although the majority of simple use-cases work well, there were still some use-cases, in the middleware, where we needed to halt the "automatic" resolution of available locales and handle it manually.

Right now, these cases seem limited to dynamic routes, like /blog/[[...slug]], because we can't know in advance which [[...slug]] is available in what language (some posts are available in multiple language, but most are only for one language). So, in our middleware, we have some logic for detecting when the pathname is /blog/[[...slug]] and just skip processing for the request.

Static routes, however, are easily handled by our automatic locales resolver, because the paths are known ahead of time, so we can explicitly specify which language is available for them.

Here's the updated navigation.ts of how we're handling partially translated routes now.

import {
  createLocalizedPathnamesNavigation,
  Pathnames,
} from "next-intl/navigation";

const languages = ["en", "fr", "es", "pt", "ja", "ru", "ko"] as const;
type Language = "en" | "fr" | "es" | "pt" | "ja" | "ru" | "ko";

const makePartiallyLocalizedPathname = (
  paths: {
    [key in Exclude<Language, "en">]?: string;
  } & { en: string }
): Record<Language, string> => {
  return {
    en: paths.en,
    // Fallback to paths.en to satisfy next-intl's requirements
    fr: paths.fr ?? paths.en,
    es: paths.es ?? paths.en,
    pt: paths.pt ?? paths.en,
    ja: paths.ja ?? paths.en,
    ru: paths.ru ?? paths.en,
    ko: paths.ko ?? paths.en,
  };
};

// This is our actual available pathnames, not used by next-intl directly
export const availablePathnames = {
  // Static route: '/' is available in all languages, except for 'ko'
  "/": {
    en: "/",
    fr: "/",
    es: "/",
    pt: "/",
    ja: "/",
    ru: "/",
  },
  // Static route: '/gift' is available in english and french, but is '/cadeau' in french
  "/gift": {
    en: "/gift",
    fr: "/cadeau",
  },
  // Static route: '/for-work' is available in english only
  "/for-work": {
    en: "/for-work",
  },
  // Dynamic route: '/blog/[[...slug]]' is available in en, fr, es and pt, but we don't know in advance what the slugs will be.
  // We'll have to manually make checks, at build/run time.
  "/blog/[[...slug]]": {
    en: "/blog/[[...slug]]",
    fr: "/blog/[[...slug]]",
    es: "/blog/[[...slug]]",
    pt: "/blog/[[...slug]]",
  },
} as const satisfies Record<
  string,
  Parameters<typeof makePartiallyLocalizedPathname>[0]
>;

// Since next-intl needs the pathnames to be fully localized, we need to provide a path for each language
// This is what we'll provide to next-intl
export const pathnames = (() => {
  const pathnames: Pathnames<typeof languages> = {};
  for (const [pathname, pathnamesByLocale] of Object.entries(
    availablePathnames,
  )) {
    pathnames[pathname] =
      typeof pathnamesByLocale === "string"
        ? pathnamesByLocale
        : makePartiallyLocalizedPathname(pathnamesByLocale);
  }
  return pathnames;
})();

type ArbitraryPathnames = typeof availablePathnames &
  Record<string & NonNullable<unknown>, string>;

export const localePrefix = "as-needed";
export const {
  Link: NextIntlLink,
  redirect: localizedRedirect,
  permanentRedirect: localizedPermanentRedirect,
  usePathname: useLocalizedPathname,
  useRouter: useLocalizedRouter,
  getPathname: getLocalizedPathname,
} = createLocalizedPathnamesNavigation({
  locales: languages,
  pathnames: pathnames as ArbitraryPathnames,
  localePrefix,
});

/**
 * Find the corresponding key of `availablePathnames` object that corresponds to the `pathname` string
 *
 * @param pathname The concrete pathname to be matched (e.g. "/blog/some-post-slug"). The pathname should be normalized (start with "/") and unlocalized (no locale prefix, like "/fr/blog", and no locale-specific path, like "/cadeau")
 * @example
 * ```
 * pathname = "/", matchedPathname = "/"
 * pathname = "/gift", matchedPathname = "/gift" (/cadeau will not match, must use the english version at all times)
 * pathname = "/for-work", matchedPathname = "/for-work"
 * pathname = "/blog/some-slug", matchedPathname = "/blog/[[...slug]]"
 * ```
 */
export const getMatchedPathname = (
  pathname: string,
): keyof typeof availablePathnames | null => {
  const matchedPathname = Object.keys(availablePathnames).find((path) => {
    const regexStr = path.replace(/\[.*?\]/g, "[^/]*");
    const regex = new RegExp(`^${regexStr}$`);
    return regex.test(pathname);
  });
  return (matchedPathname as keyof typeof availablePathnames) ?? null;
};

/**
 * Given a concrete `pathname`, matches it to an `availablePathnames` key and return the locales in which the `pathname` is available in
 *
 * @param pathname The concrete pathname to be matched (e.g. "/blog/some-post-slug"). The pathname should be normalized (start with "/") and unlocalized (no locale prefix, like "/fr/blog", and no locale-specific path, like "/cadeau")
 * @returns The locales in which the `pathname` is available in
 */
export const getLocalesForPathname = (pathname: string) => {
  const matchedPathname = getMatchedPathname(pathname);
  if (!matchedPathname) return languages;

  const availablePathnamesByLocale = availablePathnames[matchedPathname];
  if (
    typeof availablePathnamesByLocale !== "object" || // Most likely string, which means available in all locales
    Array.isArray(availablePathnamesByLocale) // Shouldn't happen, but since an array is technically an object...
  ) {
    return languages;
  }

  return Object.keys(availablePathnamesByLocale) as Language[];
};

getLocalesForPathname has been a very useful utility to help us easily build our workaround. When using a pathname, we just need to be careful to always use the availablePathnames' keys as pathname, never a localized pathname like /cadeau. This could probably be possible by modifying getMatchedPathname and getLocalesForPathname to go deep in availablePathnames and look for a matching path among the languages, but we're satisfied with this constraint so far.

amannn commented 5 months ago

Oh cool, thanks a lot for sharing your implementation!