Add base I18n implementation
Lint / lint (push) Successful in 27s

This commit is contained in:
Julien Valverdé
2026-08-16 17:55:22 +02:00
parent 44bea41e39
commit 661064d940
6 changed files with 413 additions and 0 deletions
+8
View File
@@ -32,6 +32,10 @@
"types": "./dist/Form.d.ts",
"default": "./dist/Form.js"
},
"./I18n": {
"types": "./dist/I18n.d.ts",
"default": "./dist/I18n.js"
},
"./Lens": {
"types": "./dist/Lens.d.ts",
"default": "./dist/Lens.js"
@@ -40,6 +44,10 @@
"types": "./dist/LensForm.d.ts",
"default": "./dist/LensForm.js"
},
"./Locale": {
"types": "./dist/Locale.d.ts",
"default": "./dist/Locale.js"
},
"./Memoized": {
"types": "./dist/Memoized.d.ts",
"default": "./dist/Memoized.js"
+113
View File
@@ -0,0 +1,113 @@
import { Effect, Layer } from "effect"
import { describe, expect, it } from "vitest"
import * as I18n from "./I18n.js"
import * as Locale from "./Locale.js"
const Messages = I18n.contract({
title: I18n.text(),
welcome: I18n.message<{ readonly name: string }>(),
})
const English = I18n.catalog(Messages, {
title: "Welcome",
welcome: ({ name }) => `Hello, ${name}!`,
})
const French = I18n.catalog(Messages, {
title: "Bienvenue",
welcome: ({ name }) => `Bonjour, ${name}!`,
})
const AppI18n = I18n.make({
contract: Messages,
fallback: "en",
loaders: {
en: () => Effect.succeed(English),
fr: () => Effect.succeed(French),
},
})
const _assertTypeSafety = (service: I18n.I18nService<typeof Messages, "en" | "fr">) => Effect.gen(function*() {
yield* service.translate("title")
yield* service.translate("welcome", { name: "Ada" })
// @ts-expect-error Unknown message keys are rejected.
yield* service.translate("missing")
// @ts-expect-error Parameterized messages require their parameters.
yield* service.translate("welcome")
// @ts-expect-error Parameters must match the message contract.
yield* service.translate("welcome", { userId: 1 })
})
const run = <A, E>(
effect: Effect.Effect<A, E, I18n.I18nService<typeof Messages, "en" | "fr">>,
language: string,
) => effect.pipe(
Effect.provide(
AppI18n.layer.pipe(
Layer.provide(Layer.succeed(Locale.Locale, {
language,
languages: [language],
})),
),
),
Effect.runPromise,
)
describe("I18n", () => {
it("selects the exact preferred locale", async () => {
const result = await run(Effect.gen(function*() {
const i18n = yield* AppI18n.service
return [i18n.locale, yield* i18n.translate("title"), yield* i18n.translate("welcome", { name: "Ada" })]
}), "fr")
expect(result).toEqual(["fr", "Bienvenue", "Bonjour, Ada!"])
})
it("matches a base language before using the fallback", async () => {
const result = await run(Effect.gen(function*() {
const i18n = yield* AppI18n.service
return [i18n.locale, yield* i18n.translate("title")]
}), "fr-CA")
expect(result).toEqual(["fr", "Bienvenue"])
})
it("uses the configured fallback when no locale matches", async () => {
const result = await run(Effect.gen(function*() {
const i18n = yield* AppI18n.service
return [i18n.locale, yield* i18n.translate("title")]
}), "de")
expect(result).toEqual(["en", "Welcome"])
})
it("supports asynchronous code-split loaders", async () => {
const i18n = I18n.make({
contract: Messages,
fallback: "en",
loaders: {
en: () => Effect.promise(async () => English),
fr: () => Effect.promise(async () => French),
},
})
const result = await Effect.gen(function*() {
const service = yield* i18n.service
return yield* service.translate("welcome", { name: "Grace" })
}).pipe(
Effect.provide(
i18n.layer.pipe(
Layer.provide(Layer.succeed(Locale.Locale, {
language: "en",
languages: ["en"],
})),
),
),
Effect.runPromise,
)
expect(result).toBe("Hello, Grace!")
})
})
+173
View File
@@ -0,0 +1,173 @@
import { Context, Effect, Layer } from "effect"
import * as Locale from "./Locale.js"
export interface MessageDefinition<Params = never> {
readonly _tag: "MessageDefinition"
readonly _params?: Params
}
export type Contract = Readonly<Record<string, MessageDefinition<unknown>>>
export type MessageParams<Definition> = Definition extends MessageDefinition<infer Params>
? Params
: never
export type MessageKey<Messages extends Contract> = keyof Messages & string
export type Translation<Definition> = [MessageParams<Definition>] extends [never]
? string
: (params: MessageParams<Definition>) => string
export type Catalog<Messages extends Contract> = {
readonly [Key in keyof Messages]: Translation<Messages[Key]>
}
export const text = (): MessageDefinition => ({
_tag: "MessageDefinition",
})
export const message = <Params>(): MessageDefinition<Params> => ({
_tag: "MessageDefinition",
})
export const contract = <const Messages extends Contract>(
messages: Messages,
): Messages => messages
export const catalog = <Messages extends Contract>(
_contract: Messages,
translations: Catalog<Messages>,
): Catalog<Messages> => translations
export class CatalogLoadError extends Error {
readonly _tag = "CatalogLoadError"
constructor(
readonly locale: string,
readonly cause: unknown,
) {
super(`Unable to load the ${locale} translation catalog`)
}
}
export class MissingMessageError extends Error {
readonly _tag = "MissingMessageError"
constructor(
readonly locale: string,
readonly key: string,
) {
super(`Missing translation for ${key} in locale ${locale}`)
}
}
export interface I18nService<Messages extends Contract, Language extends string> {
readonly locale: Language
readonly translate: <Key extends MessageKey<Messages>>(
...args: TranslateArguments<Messages, Key>
) => Effect.Effect<string, MissingMessageError>
}
export type TranslateArguments<
Messages extends Contract,
Key extends MessageKey<Messages>,
> = [MessageParams<Messages[Key]>] extends [never]
? [key: Key]
: [key: Key, params: MessageParams<Messages[Key]>]
export type Loader<Messages extends Contract> = () => Effect.Effect<Catalog<Messages>, unknown>
export type LoaderMap<Messages extends Contract> = Readonly<Record<string, Loader<Messages>>>
export interface I18n<Messages extends Contract, Language extends string> {
readonly service: Context.Service<
I18nService<Messages, Language>,
I18nService<Messages, Language>
>
readonly layer: Layer.Layer<
I18nService<Messages, Language>,
CatalogLoadError,
Locale.Locale
>
}
export interface MakeOptions<Messages extends Contract, Loaders extends LoaderMap<Messages>> {
readonly contract: Messages
readonly loaders: Loaders
readonly fallback: keyof Loaders & string
/** Optional stable identifier when an application creates multiple translators. */
readonly key?: string
}
let nextServiceId = 0
const baseLanguage = (language: string): string => language.toLowerCase().split("-", 1)[0] ?? ""
const resolveLanguage = <Loaders extends Readonly<Record<string, unknown>>>(
preferred: readonly string[],
loaders: Loaders,
fallback: keyof Loaders & string,
): keyof Loaders & string => {
const available = Object.keys(loaders) as Array<keyof Loaders & string>
for (const language of preferred) {
const exact = available.find(candidate => candidate.toLowerCase() === language.toLowerCase())
if (exact !== undefined) return exact
const base = baseLanguage(language)
const languageOnly = available.find(candidate => baseLanguage(candidate) === base)
if (languageOnly !== undefined) return languageOnly
}
return fallback
}
export const make = <
const Messages extends Contract,
const Loaders extends LoaderMap<Messages>,
>(
options: MakeOptions<Messages, Loaders>,
): I18n<Messages, keyof Loaders & string> => {
type Language = keyof Loaders & string
const service = Context.Service<I18nService<Messages, Language>>(
options.key ?? `@effect-view/I18n/I18n/${nextServiceId++}`,
)
const layer = Layer.effect(
service,
Effect.gen(function*() {
const locale = yield* Locale.Locale
const language = resolveLanguage(locale.languages, options.loaders, options.fallback) as Language
const load = options.loaders[language]
const translations = yield* load().pipe(
Effect.mapError(error => new CatalogLoadError(language, error)),
)
return {
locale: language,
translate: <Key extends MessageKey<Messages>>(
...args: TranslateArguments<Messages, Key>
): Effect.Effect<string, MissingMessageError> => {
const key = args[0]
const translation = translations[key]
if (translation === undefined) {
return Effect.fail(new MissingMessageError(language, key))
}
if (typeof translation === "function") {
const format = translation as (params: MessageParams<Messages[Key]>) => string
return Effect.sync(() => format(args[1] as MessageParams<Messages[Key]>))
}
return Effect.succeed(translation as string)
},
}
}),
)
return { service, layer }
}
+74
View File
@@ -0,0 +1,74 @@
import { Effect } from "effect"
import { describe, expect, it } from "vitest"
import * as Locale from "./Locale.js"
const readLocale = Effect.gen(function*() {
const locale = yield* Locale.Locale
return locale
})
describe("Locale", () => {
it("reads the ordered browser language preferences", async () => {
Object.defineProperty(globalThis.navigator, "languages", {
configurable: true,
value: ["fr-CA", "fr", "en-US"],
})
Object.defineProperty(globalThis.navigator, "language", {
configurable: true,
value: "fr-CA",
})
const locale = await Effect.runPromise(
readLocale.pipe(Effect.provide(Locale.layerBrowser())),
)
expect(locale).toEqual({
language: "fr-CA",
languages: ["fr-CA", "fr", "en-US"],
})
})
it("uses the singular language when the preference list is empty", async () => {
Object.defineProperty(globalThis.navigator, "languages", {
configurable: true,
value: [],
})
Object.defineProperty(globalThis.navigator, "language", {
configurable: true,
value: "de-DE",
})
const locale = await Effect.runPromise(
readLocale.pipe(Effect.provide(Locale.layerBrowser())),
)
expect(locale).toEqual({
language: "de-DE",
languages: ["de-DE"],
})
})
it("supports a fallback when browser APIs are unavailable", async () => {
const originalNavigator = globalThis.navigator
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: undefined,
})
const locale = await Effect.runPromise(
readLocale.pipe(Effect.provide(Locale.layerBrowser({ fallback: "en-GB" }))),
)
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: originalNavigator,
})
expect(locale).toEqual({
language: "en-GB",
languages: [],
})
})
})
+43
View File
@@ -0,0 +1,43 @@
import { Context, Effect, Layer } from "effect"
export interface LocaleService {
/** The first preferred BCP 47 language tag, when one is available. */
readonly language: string
/** The user's preferred BCP 47 language tags, in preference order. */
readonly languages: readonly string[]
}
export class Locale extends Context.Service<Locale, LocaleService>()(
"@effect-view/Locale/Locale",
) {}
export interface BrowserOptions {
/** Used when the layer is constructed outside a browser, such as during SSR. */
readonly fallback?: string
}
/**
* Reads the browser's preferred languages without accessing `navigator` at
* module evaluation time. This keeps the module safe to import during SSR.
*/
export const fromBrowser = (
options: BrowserOptions = {},
): Effect.Effect<LocaleService> => Effect.sync(() => {
const navigator = globalThis.navigator
const languages = navigator?.languages.length > 0
? [...navigator.languages]
: navigator?.language
? [navigator.language]
: []
return {
language: languages[0] ?? options.fallback ?? "en",
languages,
}
})
/** Provides the user's browser language preferences as an Effect service. */
export const layerBrowser = (
options: BrowserOptions = {},
): Layer.Layer<Locale> => Layer.effect(Locale, fromBrowser(options))
+2
View File
@@ -1,8 +1,10 @@
export * as Async from "./Async.js"
export * as Component from "./Component.js"
export * as Form from "./Form.js"
export * as I18n from "./I18n.js"
export * as Lens from "./Lens.js"
export * as LensForm from "./LensForm.js"
export * as Locale from "./Locale.js"
export * as Memoized from "./Memoized.js"
export * as Mutation from "./Mutation.js"
export * as MutationForm from "./MutationForm.js"