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

Add support for partially translated routes #990

Open
maxijonson opened this issue Apr 9, 2024 · 6 comments
Open

Add support for partially translated routes #990

maxijonson opened this issue Apr 9, 2024 · 6 comments
Labels
contributions welcome Good for people looking to contribute enhancement New feature or request has-workaround

Comments

@maxijonson
Copy link

maxijonson commented Apr 9, 2024

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:

  • Any time we need to render links to partially translated, we need to conditionally check if the current locale is "eligible" to have it shown. In our particular case, defaulting to an english link is not an option (even if it's possible, we don't want that).
  • When working with localized pathnames, we also need to define a path for other languages, even though there are no pages for it (to satisfy types)
  • We haven't started development on alternate links yet, but I expect we'll have a similar issues for them, where we only want alternate links for languages that exist.

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:

  • For Link created with createLocalizedPathnamesNavigation or createSharedPathnamesNavigation, this could potentially be achieved with a prop like availableLocales. Alternatively, for createSharedPathnamesNavigation, it could use the pathnames to know if the page is translated (only when the languages are specified)
  • For pathnames passed to createLocalizedPathnamesNavigation, this could be achieved by allowing only some languages to be specified.
  • For alternate links, I didn't dive into this yet, so maybe there's a better solution, but looking at the pathnames of the middleware configuration could hint to which links are available in which locale. "/blog": "/blog" would mean it is available in all languages, while the following would mean it is available only in en, fr and es:
    "/about-us": {
        en: "/about-us",
        fr: "/a-propos-de-nous",
        es: "/about-us"
    }
    

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 maxijonson added enhancement New feature or request unconfirmed Needs triage. labels Apr 9, 2024
@maxijonson
Copy link
Author

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.

Copy link

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.

@github-actions github-actions bot added the Stale label May 11, 2024
@github-actions github-actions bot closed this as not planned Won't fix, can't repro, duplicate, stale May 11, 2024
@amannn amannn reopened this May 11, 2024
@amannn
Copy link
Owner

amannn commented May 11, 2024

The same issue has been discussed in #1009 and #955.

A potential solution is discussed in #1009 (reply in thread). In case someone is interested in implementing this, I'd be happy to review a PR!

@amannn amannn added contributions welcome Good for people looking to contribute and removed unconfirmed Needs triage. Stale labels May 11, 2024
@amannn
Copy link
Owner

amannn commented Jun 5, 2024

Related: After working on #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
Copy link
Author

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
Copy link
Owner

amannn commented Jun 6, 2024

Oh cool, thanks a lot for sharing your implementation!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
contributions welcome Good for people looking to contribute enhancement New feature or request has-workaround
Projects
None yet
Development

No branches or pull requests

2 participants