@byline/i18n 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/LICENSE +373 -0
  2. package/README.md +166 -0
  3. package/dist/admin/en.js +424 -0
  4. package/dist/admin/fr.js +424 -0
  5. package/dist/admin/index.d.ts +44 -0
  6. package/dist/admin/index.js +27 -0
  7. package/dist/formatter.d.ts +40 -0
  8. package/dist/formatter.js +65 -0
  9. package/dist/index.d.ts +33 -0
  10. package/dist/index.js +3 -0
  11. package/dist/merge.d.ts +39 -0
  12. package/dist/merge.js +58 -0
  13. package/dist/react/i18n-context.d.ts +29 -0
  14. package/dist/react/i18n-context.js +3 -0
  15. package/dist/react/i18n-provider.d.ts +31 -0
  16. package/dist/react/i18n-provider.js +38 -0
  17. package/dist/react/index.d.ts +24 -0
  18. package/dist/react/index.js +4 -0
  19. package/dist/react/language-menu.d.ts +15 -0
  20. package/dist/react/language-menu.js +78 -0
  21. package/dist/react/use-translation.d.ts +24 -0
  22. package/dist/react/use-translation.js +16 -0
  23. package/dist/resolve.d.ts +21 -0
  24. package/dist/resolve.js +28 -0
  25. package/dist/types.d.ts +65 -0
  26. package/dist/types.js +1 -0
  27. package/package.json +100 -0
  28. package/src/admin/en.json +423 -0
  29. package/src/admin/fr.json +423 -0
  30. package/src/admin/index.test.node.ts +68 -0
  31. package/src/admin/index.ts +99 -0
  32. package/src/formatter.test.node.ts +163 -0
  33. package/src/formatter.ts +166 -0
  34. package/src/index.ts +47 -0
  35. package/src/merge.test.node.ts +85 -0
  36. package/src/merge.ts +135 -0
  37. package/src/react/i18n-context.ts +45 -0
  38. package/src/react/i18n-provider.tsx +76 -0
  39. package/src/react/index.ts +26 -0
  40. package/src/react/language-menu.tsx +115 -0
  41. package/src/react/use-translation.ts +45 -0
  42. package/src/resolve.test.node.ts +128 -0
  43. package/src/resolve.ts +84 -0
  44. package/src/types.ts +72 -0
@@ -0,0 +1,76 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import { type ReactNode, useMemo } from 'react'
10
+
11
+ import { createFormatter, type MissingTranslationEvent } from '../formatter.js'
12
+ import { I18nContext, type I18nContextValue } from './i18n-context.js'
13
+ import type { LocaleCode, LocaleDefinition, TranslationBundle } from '../types.js'
14
+
15
+ export interface I18nProviderProps {
16
+ bundle: TranslationBundle
17
+ activeLocale: LocaleCode
18
+ defaultLocale: LocaleCode
19
+ /** Permitted locale set with native names — drives `<LanguageMenu>`. */
20
+ localeDefinitions: readonly LocaleDefinition[]
21
+ /**
22
+ * Optional handler the host wires to its language-switcher server fn.
23
+ * Receives the new locale; expected to persist it and trigger a re-
24
+ * render with the updated `activeLocale` prop.
25
+ */
26
+ setLocale?: (next: LocaleCode) => void | Promise<void>
27
+ /**
28
+ * Override the dev-time `console.warn` on missing translations.
29
+ * Defaults to a one-shot warn per `(locale, namespace, key)` triple
30
+ * when `process.env.NODE_ENV !== 'production'`.
31
+ */
32
+ onMissing?: (event: MissingTranslationEvent) => void
33
+ children: ReactNode
34
+ }
35
+
36
+ export function I18nProvider({
37
+ bundle,
38
+ activeLocale,
39
+ defaultLocale,
40
+ localeDefinitions,
41
+ setLocale,
42
+ onMissing,
43
+ children,
44
+ }: I18nProviderProps) {
45
+ const value = useMemo<I18nContextValue>(() => {
46
+ const onMissingResolved =
47
+ onMissing ?? (process.env.NODE_ENV !== 'production' ? defaultMissingWarner : undefined)
48
+ return {
49
+ formatter: createFormatter({
50
+ bundle,
51
+ activeLocale,
52
+ defaultLocale,
53
+ onMissing: onMissingResolved,
54
+ }),
55
+ bundle,
56
+ activeLocale,
57
+ defaultLocale,
58
+ localeDefinitions,
59
+ setLocale,
60
+ }
61
+ }, [bundle, activeLocale, defaultLocale, localeDefinitions, setLocale, onMissing])
62
+
63
+ return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>
64
+ }
65
+
66
+ function defaultMissingWarner(event: MissingTranslationEvent): void {
67
+ if (event.fellThroughToKey) {
68
+ console.warn(
69
+ `[@byline/i18n] missing translation: ${event.activeLocale}.${event.namespace}.${event.key} — also missing in default locale; rendered raw key.`
70
+ )
71
+ } else {
72
+ console.warn(
73
+ `[@byline/i18n] missing translation: ${event.activeLocale}.${event.namespace}.${event.key} — using default-locale fallback.`
74
+ )
75
+ }
76
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ /**
10
+ * Single React-side barrel. Provider + hook + language switcher all
11
+ * share one React Context identity by virtue of living behind this
12
+ * single subpath export — splitting across more subpaths would risk
13
+ * Vite `optimizeDeps` pre-bundling each subpath into a private copy of
14
+ * the Context module, breaking provider/consumer identity (see the
15
+ * `@byline/ui/src/react.ts` comment for the same trap in another
16
+ * package).
17
+ */
18
+
19
+ export { I18nContext } from './i18n-context.js'
20
+ export { I18nProvider } from './i18n-provider.js'
21
+ export { LanguageMenu } from './language-menu.js'
22
+ export { useTranslation } from './use-translation.js'
23
+ export type { I18nContextValue } from './i18n-context.js'
24
+ export type { I18nProviderProps } from './i18n-provider.js'
25
+ export type { LanguageMenuProps } from './language-menu.js'
26
+ export type { UseTranslationReturn } from './use-translation.js'
@@ -0,0 +1,115 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ /**
10
+ * Self-contained language switcher. Reads `localeDefinitions`,
11
+ * `activeLocale`, and `setLocale` from `<I18nProvider>`'s context —
12
+ * the host wires the actual server fn (and admin-user update) behind
13
+ * `setLocale`; this component is pure UI.
14
+ *
15
+ * Renders nothing when fewer than two locales are configured, since
16
+ * the affordance has nothing to switch between.
17
+ *
18
+ * Host adapters typically mount this in the admin chrome top bar. It
19
+ * can also be embedded inside Account preferences (`<LanguageMenu />`
20
+ * with no className override) or anywhere else the menu makes sense.
21
+ */
22
+
23
+ import { useContext, useState } from 'react'
24
+
25
+ import { CheckIcon, Dropdown as DropdownMenu, GlobeIcon } from '@byline/ui/react'
26
+ import cx from 'classnames'
27
+
28
+ import { I18nContext } from './i18n-context.js'
29
+ import type { LocaleCode } from '../types.js'
30
+
31
+ export interface LanguageMenuProps {
32
+ className?: string
33
+ /** Tailwind / CSS class applied to the icon + label colour. */
34
+ color?: string
35
+ /** Render the menu disabled (loading, no `setLocale`, etc.). */
36
+ disabled?: boolean
37
+ }
38
+
39
+ export function LanguageMenu({ className, color, disabled }: LanguageMenuProps) {
40
+ const context = useContext(I18nContext)
41
+ const [busy, setBusy] = useState(false)
42
+
43
+ if (context == null) {
44
+ throw new Error(
45
+ '[@byline/i18n] <LanguageMenu> must be used inside <I18nProvider>. Mount the provider in your admin shell root.'
46
+ )
47
+ }
48
+
49
+ const { activeLocale, localeDefinitions, setLocale } = context
50
+
51
+ if (localeDefinitions.length < 2) return null
52
+
53
+ const active = localeDefinitions.find((d) => d.code === activeLocale)
54
+ const isDisabled = disabled || setLocale == null || busy
55
+
56
+ const handleSelect = async (next: LocaleCode) => {
57
+ if (next === activeLocale || setLocale == null || busy) return
58
+ setBusy(true)
59
+ try {
60
+ await setLocale(next)
61
+ } finally {
62
+ setBusy(false)
63
+ }
64
+ }
65
+
66
+ return (
67
+ <div className={className}>
68
+ <DropdownMenu.Root modal={false}>
69
+ <DropdownMenu.Trigger
70
+ render={
71
+ <button
72
+ type="button"
73
+ aria-label={active?.nativeName ?? activeLocale}
74
+ disabled={isDisabled}
75
+ className="component--byline-language-menu rounded flex items-center justify-between gap-1 outline-none disabled:opacity-50"
76
+ />
77
+ }
78
+ >
79
+ <GlobeIcon svgClassName={color} />
80
+ <span className={cx(color, 'hidden sm:inline mr-[4px]')}>
81
+ {active?.nativeName ?? activeLocale}
82
+ </span>
83
+ </DropdownMenu.Trigger>
84
+
85
+ <DropdownMenu.Portal>
86
+ <DropdownMenu.Content
87
+ align="center"
88
+ sideOffset={10}
89
+ className={cx(
90
+ 'z-40 rounded radix-side-bottom:animate-slide-down radix-side-top:animate-slide-up',
91
+ 'w-32 px-1.5 py-1 shadow-md',
92
+ 'bg-white dark:bg-canvas-800 border dark:border-canvas-700 shadow'
93
+ )}
94
+ >
95
+ {localeDefinitions.map((def) => {
96
+ const isActive = def.code === activeLocale
97
+ return (
98
+ <DropdownMenu.Item key={def.code} onClick={() => handleSelect(def.code)}>
99
+ <div className="flex">
100
+ <span className="inline-block w-[22px]">
101
+ {isActive && <CheckIcon width="18px" height="18px" />}
102
+ </span>
103
+ <span className="text-left inline-block w-full flex-1 self-start text-black dark:text-gray-300">
104
+ {def.nativeName}
105
+ </span>
106
+ </div>
107
+ </DropdownMenu.Item>
108
+ )
109
+ })}
110
+ </DropdownMenu.Content>
111
+ </DropdownMenu.Portal>
112
+ </DropdownMenu.Root>
113
+ </div>
114
+ )
115
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import { useContext, useMemo } from 'react'
10
+
11
+ import { I18nContext } from './i18n-context.js'
12
+ import type { LocaleCode, MessageKey, Namespace, TranslationValues } from '../types.js'
13
+
14
+ export interface UseTranslationReturn {
15
+ /** Format `namespace.key` against the provider's active locale. */
16
+ t: (key: MessageKey, values?: TranslationValues) => string
17
+ /** The active locale — useful for `<html lang>` and direction logic. */
18
+ locale: LocaleCode
19
+ }
20
+
21
+ /**
22
+ * Bind to a single namespace for the lifetime of the component. The
23
+ * returned `t` resolves keys against the active locale with default-
24
+ * locale fallback baked in (see `createFormatter` for the cascade).
25
+ *
26
+ * Throws if called outside `<I18nProvider>` — the loud failure is
27
+ * deliberate. A silently-broken admin shell with raw keys on screen
28
+ * is harder to notice than a thrown error during development.
29
+ */
30
+ export function useTranslation(namespace: Namespace): UseTranslationReturn {
31
+ const context = useContext(I18nContext)
32
+ if (context == null) {
33
+ throw new Error(
34
+ '[@byline/i18n] useTranslation must be used inside <I18nProvider>. Mount the provider in your admin shell root.'
35
+ )
36
+ }
37
+ const { formatter, activeLocale } = context
38
+ return useMemo(
39
+ () => ({
40
+ t: (key, values) => formatter.t(namespace, key, values),
41
+ locale: activeLocale,
42
+ }),
43
+ [formatter, namespace, activeLocale]
44
+ )
45
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import { describe, expect, it } from 'vitest'
10
+
11
+ import { resolveInterfaceLocale } from './resolve.js'
12
+
13
+ const locales = ['en', 'fr', 'es']
14
+ const defaultLocale = 'en'
15
+
16
+ describe('resolveInterfaceLocale — tier 1 (preferred)', () => {
17
+ it('wins over every other signal when set and valid', () => {
18
+ const out = resolveInterfaceLocale({
19
+ locales,
20
+ defaultLocale,
21
+ preferred: 'fr',
22
+ cookie: 'es',
23
+ acceptLanguage: 'es;q=0.9, en;q=0.8',
24
+ })
25
+ expect(out).toBe('fr')
26
+ })
27
+
28
+ it('falls through when preferred is not in the permitted set', () => {
29
+ const out = resolveInterfaceLocale({
30
+ locales,
31
+ defaultLocale,
32
+ preferred: 'de',
33
+ cookie: 'es',
34
+ })
35
+ expect(out).toBe('es')
36
+ })
37
+
38
+ it('falls through when preferred is null', () => {
39
+ const out = resolveInterfaceLocale({
40
+ locales,
41
+ defaultLocale,
42
+ preferred: null,
43
+ cookie: 'fr',
44
+ })
45
+ expect(out).toBe('fr')
46
+ })
47
+ })
48
+
49
+ describe('resolveInterfaceLocale — tier 2 (cookie)', () => {
50
+ it('wins over Accept-Language', () => {
51
+ const out = resolveInterfaceLocale({
52
+ locales,
53
+ defaultLocale,
54
+ cookie: 'es',
55
+ acceptLanguage: 'fr;q=0.9',
56
+ })
57
+ expect(out).toBe('es')
58
+ })
59
+
60
+ it('falls through when cookie points at a removed locale', () => {
61
+ const out = resolveInterfaceLocale({
62
+ locales,
63
+ defaultLocale,
64
+ cookie: 'de',
65
+ acceptLanguage: 'fr;q=0.9',
66
+ })
67
+ expect(out).toBe('fr')
68
+ })
69
+
70
+ it('falls through when cookie is null', () => {
71
+ const out = resolveInterfaceLocale({
72
+ locales,
73
+ defaultLocale,
74
+ cookie: null,
75
+ acceptLanguage: 'fr;q=0.9',
76
+ })
77
+ expect(out).toBe('fr')
78
+ })
79
+ })
80
+
81
+ describe('resolveInterfaceLocale — tier 3 (Accept-Language)', () => {
82
+ it('picks the best match from the header', () => {
83
+ const out = resolveInterfaceLocale({
84
+ locales,
85
+ defaultLocale,
86
+ acceptLanguage: 'fr-CA;q=0.9, fr;q=0.8, en;q=0.5',
87
+ })
88
+ expect(out).toBe('fr')
89
+ })
90
+
91
+ it('falls back to default when no header language is in the permitted set', () => {
92
+ const out = resolveInterfaceLocale({
93
+ locales,
94
+ defaultLocale,
95
+ acceptLanguage: 'de;q=0.9, ja;q=0.5',
96
+ })
97
+ expect(out).toBe(defaultLocale)
98
+ })
99
+
100
+ it('handles malformed headers gracefully', () => {
101
+ const out = resolveInterfaceLocale({
102
+ locales,
103
+ defaultLocale,
104
+ acceptLanguage: 'this-is-not-a-valid-header',
105
+ })
106
+ // intl-localematcher returns the default when no match; this is the
107
+ // pure-fallback path.
108
+ expect(out).toBe(defaultLocale)
109
+ })
110
+ })
111
+
112
+ describe('resolveInterfaceLocale — tier 4 (default)', () => {
113
+ it('returns defaultLocale when every other tier produces nothing', () => {
114
+ const out = resolveInterfaceLocale({ locales, defaultLocale })
115
+ expect(out).toBe('en')
116
+ })
117
+
118
+ it('returns defaultLocale when every signal is empty', () => {
119
+ const out = resolveInterfaceLocale({
120
+ locales,
121
+ defaultLocale,
122
+ preferred: null,
123
+ cookie: null,
124
+ acceptLanguage: null,
125
+ })
126
+ expect(out).toBe('en')
127
+ })
128
+ })
package/src/resolve.ts ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ /**
10
+ * Pure locale-resolution cascade. Called once per request — server-side
11
+ * by the host adapter during request-context resolution; client-side
12
+ * during admin shell mount.
13
+ *
14
+ * Cascade (first non-null wins):
15
+ * 1. `preferred` — the authenticated admin user's stored preference
16
+ * (`admin_users.preferred_locale`). Always wins when set.
17
+ * 2. `cookie` — the `byline_admin_lng` cookie from the last language switch.
18
+ * 3. `acceptLanguage` — standards-compliant negotiation against the
19
+ * permitted locale set via `@formatjs/intl-localematcher`.
20
+ * 4. `defaultLocale` — last-resort fallback.
21
+ *
22
+ * Every step validates the candidate against the permitted `locales`
23
+ * set, so a stale cookie pointing at a removed locale falls through
24
+ * cleanly rather than producing a locale the bundle can't satisfy.
25
+ */
26
+
27
+ import { match } from '@formatjs/intl-localematcher'
28
+ import Negotiator from 'negotiator'
29
+
30
+ import type { LocaleCode } from './types.js'
31
+
32
+ export interface ResolveInterfaceLocaleOptions {
33
+ /** Permitted locale set — from `i18n.interface.locales`. */
34
+ locales: readonly LocaleCode[]
35
+ /** Last-resort fallback — from `i18n.interface.defaultLocale`. */
36
+ defaultLocale: LocaleCode
37
+ /** `admin_users.preferred_locale` for the authenticated request, if any. */
38
+ preferred?: LocaleCode | null
39
+ /** Value of the `byline_admin_lng` cookie. */
40
+ cookie?: string | null
41
+ /** Raw `Accept-Language` request header. */
42
+ acceptLanguage?: string | null
43
+ }
44
+
45
+ export function resolveInterfaceLocale(options: ResolveInterfaceLocaleOptions): LocaleCode {
46
+ const { locales, defaultLocale, preferred, cookie, acceptLanguage } = options
47
+
48
+ // Tier 1 — admin user preference.
49
+ if (preferred != null && locales.includes(preferred)) {
50
+ return preferred
51
+ }
52
+
53
+ // Tier 2 — cookie.
54
+ if (cookie != null && locales.includes(cookie)) {
55
+ return cookie
56
+ }
57
+
58
+ // Tier 3 — Accept-Language negotiation.
59
+ if (acceptLanguage != null && acceptLanguage.length > 0) {
60
+ const matched = negotiateAcceptLanguage(acceptLanguage, locales, defaultLocale)
61
+ if (matched != null) return matched
62
+ }
63
+
64
+ // Tier 4 — default.
65
+ return defaultLocale
66
+ }
67
+
68
+ function negotiateAcceptLanguage(
69
+ header: string,
70
+ locales: readonly LocaleCode[],
71
+ defaultLocale: LocaleCode
72
+ ): LocaleCode | null {
73
+ try {
74
+ const negotiator = new Negotiator({
75
+ headers: { 'accept-language': header },
76
+ })
77
+ const requested = negotiator.languages()
78
+ if (requested.length === 0) return null
79
+ const matched = match(requested, locales as LocaleCode[], defaultLocale)
80
+ return locales.includes(matched) ? matched : null
81
+ } catch {
82
+ return null
83
+ }
84
+ }
package/src/types.ts ADDED
@@ -0,0 +1,72 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ /**
10
+ * Locale code — kept as a plain `string` so the package stays open to
11
+ * any BCP 47 tag (`en`, `pt-BR`, `zh-Hans-CN`). The host's
12
+ * `i18n.interface.locales` config is the canonical allow-list at
13
+ * runtime; the resolver / validator narrow against it.
14
+ */
15
+ export type LocaleCode = string
16
+
17
+ /**
18
+ * Lightweight locale definition used by `<LanguageMenu>` to render the
19
+ * dropdown. The `code` matches a string in `i18n.interface.locales`;
20
+ * the `nativeName` is what the user sees ("English", "Español",
21
+ * "Français", "Deutsch", "日本語").
22
+ */
23
+ export interface LocaleDefinition {
24
+ code: LocaleCode
25
+ nativeName: string
26
+ }
27
+
28
+ /**
29
+ * Namespace string — convention is `byline-<package>` for Byline-shipped
30
+ * code and `<org>-<plugin>` for third-party plugins. The runtime treats
31
+ * namespaces as opaque keys.
32
+ */
33
+ export type Namespace = string
34
+
35
+ /**
36
+ * Message key inside a namespace — dot-segmented by convention
37
+ * (`chrome.sidebar.collapse`), but the runtime treats keys as opaque.
38
+ */
39
+ export type MessageKey = string
40
+
41
+ /**
42
+ * One namespace's translations for one locale — flat key → ICU
43
+ * MessageFormat-encoded string.
44
+ */
45
+ export type NamespaceTranslations = Readonly<Record<MessageKey, string>>
46
+
47
+ /**
48
+ * The complete translation registry: locale → namespace → key →
49
+ * ICU-encoded message. Built by `mergeTranslations(...)`, passed to
50
+ * `<I18nProvider>`, and validated at boot by `@byline/core`.
51
+ *
52
+ * Bundles are deliberately plain JSON-shaped data — no functions, no
53
+ * React, no per-key metadata — so they can be authored as `.json`
54
+ * files, published as standalone npm packages, and round-tripped
55
+ * through any translation tool. Authoring-time metadata
56
+ * (descriptions, plural hints, deprecation markers) is a deferred
57
+ * Phase 4 surface; see `docs/I18N.md`.
58
+ */
59
+ export type TranslationBundle = Readonly<{
60
+ [locale: LocaleCode]: Readonly<{
61
+ [namespace: Namespace]: NamespaceTranslations
62
+ }>
63
+ }>
64
+
65
+ /**
66
+ * Values argument to `t(key, values)`. `intl-messageformat` accepts
67
+ * strings, numbers, booleans, Dates, and React elements — but we narrow
68
+ * the public API to JSON-safe primitives + Date so the `t` return type
69
+ * stays `string`. Rich-element interpolation (e.g. inline links) goes
70
+ * through a separate `<Trans>` component (not part of the PR 1 surface).
71
+ */
72
+ export type TranslationValues = Readonly<Record<string, string | number | boolean | Date | null>>