next-international icon indicating copy to clipboard operation
next-international copied to clipboard

Nested object scopes

Open saphewilliam opened this issue 3 years ago • 2 comments

Hi! I really like this project, definitely my favorite nextjs i18n library that I've seen. Keep it up!

One thing I didn't like about the locale definition was that scopes are defined with dot-separated strings, so I wrote a code snippet that allows me to define a locale in terms of a nested object like so:

export interface OldLocale {
  'pages.landing.title': string;
  'pages.landing.description': string;
}

export interface Locale {
    pages: {
        landing: {
            title: string;
            description: string;
        };
    };
}

The code snippet is:

import { createI18n } from "next-international";
import type { Locale } from "@locales";

const i18n = createI18n<Record<LocaleKeys<Locale>, string>>({
    en: () => import("@locales/en"),
    nl: () => import("@locales/nl"),
});

export const { useI18n, useChangeLocale, I18nProvider, getLocaleStaticProps } = i18n;

type LocaleDefinition = Record<string, string | object>;
type LocaleKeys<T> = keyof {
    [P in keyof T as T[P] extends string ? P : `${string & P}.${string & LocaleKeys<T[P]>}`]: true;
};
type DefinedLocale = Record<LocaleKeys<Locale>, string>;

export function defineLocale(locale: Locale): DefinedLocale {
    const flatten = (l: Locale, prefix = ""): LocaleDefinition =>
        Object.entries(l).reduce(
            (prev, [name, value]) => ({
                ...prev,
                ...(typeof value === "string"
                    ? { [prefix + name]: value }
                    : flatten(value as Locale, `${prefix}${name}.`)),
            }),
            {},
        );
    return flatten(locale) as DefinedLocale;
}

Then I can define a locale like this

import { defineLocale } from "@hooks/useI18n";

export default defineLocale({
    landing: {
        hero: {
            title: "Welcome to my website",
            description: "This is a really cool website!",
        },
    },
});

Type-checking works within a locale definition, and the type system after createI18n seems to be unbothered.

Just thought I'd share my solution, maybe you like it enough to implement it in the library. If not, feel free to close this issue 😃

saphewilliam avatar Jul 30 '22 16:07 saphewilliam

Thanks for the kind words!

Cool idea, but I think you are losing parameters type-safety due to the generic of createI18n:

Record<LocaleKeys<Locale>, string> ..................................................^ due to this string, you won't be able to get proper type-safety if one of your translations is Hello {name}. Not really sure how that could be avoided though.

IMO, nested objects are a lot harder to read when you have a lot of keys, but that's a personal preference. If anyone prefers nested objects over dot-separated strings, please react with a 👍 on the initial comment, so we could know if this is needed or not.

For now, I'll keep this issue open in case anyone wants to do the same, and until more folks find this solution useful so we could integrate it inside the core package.

QuiiBz avatar Jul 31 '22 07:07 QuiiBz

Thanks for the feedback! Totally missed that. I've updated the code snippet to support parameters type-safety! I understand your point with nested objects, so no pressure to integrate this in the core package. I'm just providing this for whoever would like this functionality 😃

// @hooks/useI18n

import { createI18n } from "next-international";
import type Locale from "@locales";

type LocaleKeys<T> = keyof { [P in keyof T as T[P] extends string ? P : `${string & P}.${string & LocaleKeys<T[P]>}`]: true };
type LocaleValue<T, P extends string> = P extends `${infer Key}.${infer Tail}` ? Key extends keyof T ? LocaleValue<T[Key], Tail> : never : P extends keyof T ? T[P] : never;
type DefinedLocale = { [L in LocaleKeys<Locale>]: LocaleValue<Locale, L> };
type LocaleDefinition<T> = T extends string ? string : { [P in keyof T]: LocaleDefinition<T[P]> };
type RecursiveLocale = Record<string, string | object>;

export const { useI18n, useChangeLocale, I18nProvider, getLocaleStaticProps } =
    createI18n<DefinedLocale>({
        en: () => import("@locales/en"),
        nl: () => import("@locales/nl"),
    });

export function defineLocale(locale: LocaleDefinition<Locale>): DefinedLocale {
    const flatten = (l: RecursiveLocale, prefix = ""): RecursiveLocale =>
        Object.entries(l).reduce(
            (prev, [name, value]) => ({
                ...prev,
                ...(typeof value === "string"
                    ? { [prefix + name]: value }
                    : flatten(value as RecursiveLocale, `${prefix}${name}.`)),
            }),
            {},
        );
    return flatten(locale) as DefinedLocale;
}
// @locales/index.ts

export default interface Locale {
    pages: {
        landing: {
            title: string;
            helloMessage: "{contactName}{contactEmail}";
        };
    };
}

saphewilliam avatar Jul 31 '22 17:07 saphewilliam

Thoughts on implementing this? Wouln't it be nice to support both patterns (if is there a way haha)

Sn0wye avatar Feb 10 '23 11:02 Sn0wye

We only support the first pattern by default, but you can still use the second pattern using the code shared above: https://github.com/QuiiBz/next-international/issues/19#issuecomment-1200468516

QuiiBz avatar Feb 13 '23 18:02 QuiiBz

We only support the first pattern by default, but you can still use the second pattern using the code shared above: #19 (comment)

Just saying, but if both patterns/strategies were supported the library would get so more visibility and community support, you know? Given the commentary shared above, there's already a great head start, and IMO, seems like a good evolution to the library.

Sn0wye avatar Feb 14 '23 18:02 Sn0wye

This needs to be officially implemented. Users shouldn't be forced to flatten the entire locale object with dot-separated keys. It ruins the entire point of having a structured, neatly formatted lookup dictionary.

acdvs avatar Jul 09 '23 20:07 acdvs

Mostly agree, both patterns should be supported out of the box. Unfortunately, I don't have the time to implement it in the next few days, maybe in the next few weeks. Anyone can also send a PR and I'll happily merge it!

QuiiBz avatar Jul 10 '23 05:07 QuiiBz

I started working on it in #64, you'll only have to update your locales to match the syntax you prefer:

The current syntax is:

export default {
  hello: 'Hello',
  welcome: 'Hello {name}!',
  'about.you': 'Hello {name}! You have {age} yo',
  'scope.test': 'A scope',
  'scope.more.test': 'A scope',
  'scope.more.param': 'A scope with {param}',
  'scope.more.and.more.test': 'A scope',
  'missing.translation.in.fr': 'This should work',
} as const

The object syntax would be the following, and that's the only change required in your application, everything will work the same:

export default {
  hello: 'Hello',
  welcome: 'Hello {name}!',
  about: {
    you: 'Hello {name}! You have {age} yo',
  },
  scope: {
    test: 'A scope',
    more: {
      test: 'A scope',
      param: 'A scope with {param}',
      and: {
        more: {
          test: 'A scope',
        },
      },
    },
  },
  missing: {
    translation: {
      in: {
        fr: 'This should work',
      },
    },
  },
} as const

QuiiBz avatar Jul 14 '23 12:07 QuiiBz

Released in 0.6.0, let me know if you found any issues.

QuiiBz avatar Jul 14 '23 15:07 QuiiBz