@@ -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"
|
||||
|
||||
@@ -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!")
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user