@pyreon/i18n 0.0.1

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.
@@ -0,0 +1 @@
1
+ import '@happy-dom/global-registrator'
package/src/trans.ts ADDED
@@ -0,0 +1,111 @@
1
+ import { h, Fragment } from '@pyreon/core'
2
+ import type { VNode, Props } from '@pyreon/core'
3
+ import type { InterpolationValues } from './types'
4
+
5
+ const TAG_RE = /<(\w+)>(.*?)<\/\1>/gs
6
+
7
+ interface RichPart {
8
+ tag: string
9
+ children: string
10
+ }
11
+
12
+ /**
13
+ * Parse a translated string into an array of plain text and rich tag segments.
14
+ *
15
+ * @example
16
+ * parseRichText("Hello <bold>world</bold>, click <link>here</link>")
17
+ * // → ["Hello ", { tag: "bold", children: "world" }, ", click ", { tag: "link", children: "here" }]
18
+ */
19
+ export function parseRichText(text: string): (string | RichPart)[] {
20
+ const parts: (string | RichPart)[] = []
21
+ let lastIndex = 0
22
+
23
+ for (const match of text.matchAll(TAG_RE)) {
24
+ const before = text.slice(lastIndex, match.index)
25
+ if (before) parts.push(before)
26
+ parts.push({ tag: match[1]!, children: match[2]! })
27
+ lastIndex = match.index! + match[0].length
28
+ }
29
+
30
+ const after = text.slice(lastIndex)
31
+ if (after) parts.push(after)
32
+
33
+ return parts
34
+ }
35
+
36
+ export interface TransProps extends Props {
37
+ /** Translation key (supports namespace:key syntax). */
38
+ i18nKey: string
39
+ /** Interpolation values for {{placeholder}} syntax. */
40
+ values?: InterpolationValues
41
+ /**
42
+ * Component map for rich interpolation.
43
+ * Keys match tag names in the translation string.
44
+ * Values are component functions: `(children: any) => VNode`
45
+ *
46
+ * @example
47
+ * // Translation: "Read the <terms>terms</terms> and <privacy>policy</privacy>"
48
+ * components={{
49
+ * terms: (children) => <a href="/terms">{children}</a>,
50
+ * privacy: (children) => <a href="/privacy">{children}</a>,
51
+ * }}
52
+ */
53
+ components?: Record<string, (children: any) => any>
54
+ /**
55
+ * The i18n instance's `t` function.
56
+ * Can be obtained from `useI18n()` or passed directly.
57
+ */
58
+ t: (key: string, values?: InterpolationValues) => string
59
+ }
60
+
61
+ /**
62
+ * Rich JSX interpolation component for translations.
63
+ *
64
+ * Allows embedding JSX components within translated strings using XML-like tags.
65
+ * The `t` function resolves the translation and interpolates `{{values}}` first,
66
+ * then `<tag>content</tag>` patterns are mapped to the provided components.
67
+ *
68
+ * @example
69
+ * // Translation: "You have <bold>{{count}}</bold> unread messages"
70
+ * const { t } = useI18n()
71
+ * <Trans
72
+ * t={t}
73
+ * i18nKey="messages.unread"
74
+ * values={{ count: 5 }}
75
+ * components={{
76
+ * bold: (children) => <strong>{children}</strong>,
77
+ * }}
78
+ * />
79
+ * // Renders: You have <strong>5</strong> unread messages
80
+ *
81
+ * @example
82
+ * // Translation: "Read our <terms>terms of service</terms> and <privacy>privacy policy</privacy>"
83
+ * <Trans
84
+ * t={t}
85
+ * i18nKey="legal"
86
+ * components={{
87
+ * terms: (children) => <a href="/terms">{children}</a>,
88
+ * privacy: (children) => <a href="/privacy">{children}</a>,
89
+ * }}
90
+ * />
91
+ */
92
+ export function Trans(props: TransProps): VNode | string {
93
+ const translated = props.t(props.i18nKey, props.values)
94
+
95
+ if (!props.components) return translated
96
+
97
+ const parts = parseRichText(translated)
98
+
99
+ // If the result is a single plain string, return it directly
100
+ if (parts.length === 1 && typeof parts[0] === 'string') return parts[0]
101
+
102
+ const children = parts.map((part) => {
103
+ if (typeof part === 'string') return part
104
+ const component = props.components![part.tag]
105
+ // Unmatched tags: render children as plain text (no raw HTML markup)
106
+ if (!component) return part.children
107
+ return component(part.children)
108
+ })
109
+
110
+ return h(Fragment, null, ...children)
111
+ }
package/src/types.ts ADDED
@@ -0,0 +1,112 @@
1
+ import type { Signal, Computed } from '@pyreon/reactivity'
2
+
3
+ /** A nested dictionary of translation strings. */
4
+ export type TranslationDictionary = {
5
+ [key: string]: string | TranslationDictionary
6
+ }
7
+
8
+ /** Map of locale → dictionary (or namespace → dictionary). */
9
+ export type TranslationMessages = Record<string, TranslationDictionary>
10
+
11
+ /**
12
+ * Async function that loads translations for a locale and namespace.
13
+ * Return the dictionary for that namespace, or undefined if not found.
14
+ */
15
+ export type NamespaceLoader = (
16
+ locale: string,
17
+ namespace: string,
18
+ ) => Promise<TranslationDictionary | undefined>
19
+
20
+ /** Interpolation values for translation strings. */
21
+ export type InterpolationValues = Record<string, string | number>
22
+
23
+ /** Pluralization rules map — locale → function that picks the plural form. */
24
+ export type PluralRules = Record<string, (count: number) => string>
25
+
26
+ /** Options for creating an i18n instance. */
27
+ export interface I18nOptions {
28
+ /** The initial locale (e.g. "en"). */
29
+ locale: string
30
+ /** Fallback locale used when a key is missing in the active locale. */
31
+ fallbackLocale?: string
32
+ /** Static messages keyed by locale. */
33
+ messages?: Record<string, TranslationDictionary>
34
+ /**
35
+ * Async loader for namespace-based translation loading.
36
+ * Called with (locale, namespace) when `loadNamespace()` is invoked.
37
+ */
38
+ loader?: NamespaceLoader
39
+ /**
40
+ * Default namespace used when `t()` is called without a namespace prefix.
41
+ * Defaults to "common".
42
+ */
43
+ defaultNamespace?: string
44
+ /**
45
+ * Custom plural rules per locale.
46
+ * If not provided, uses `Intl.PluralRules` where available.
47
+ */
48
+ pluralRules?: PluralRules
49
+ /**
50
+ * Missing key handler — called when a translation key is not found.
51
+ * Useful for logging, reporting, or returning a custom fallback.
52
+ */
53
+ onMissingKey?: (
54
+ locale: string,
55
+ key: string,
56
+ namespace?: string,
57
+ ) => string | undefined
58
+ }
59
+
60
+ /** The public i18n instance returned by `createI18n()`. */
61
+ export interface I18nInstance {
62
+ /**
63
+ * Translate a key with optional interpolation.
64
+ * Reads the current locale reactively — re-evaluates in effects/computeds.
65
+ *
66
+ * Key format: "key" (uses default namespace) or "namespace:key".
67
+ * Nested keys use dots: "user.greeting" or "auth:errors.invalid".
68
+ *
69
+ * Interpolation: "Hello {{name}}" + { name: "Alice" } → "Hello Alice"
70
+ * Pluralization: key with "_one", "_other" etc. suffixes + { count: N }
71
+ */
72
+ t: (key: string, values?: InterpolationValues) => string
73
+
74
+ /** Current locale (reactive signal). */
75
+ locale: Signal<string>
76
+
77
+ /**
78
+ * Load a namespace's translations for the given locale (or current locale).
79
+ * Returns a promise that resolves when loading is complete.
80
+ */
81
+ loadNamespace: (namespace: string, locale?: string) => Promise<void>
82
+
83
+ /**
84
+ * Whether any namespace is currently being loaded.
85
+ */
86
+ isLoading: Computed<boolean>
87
+
88
+ /**
89
+ * Set of namespaces that have been loaded for the current locale.
90
+ */
91
+ loadedNamespaces: Computed<Set<string>>
92
+
93
+ /**
94
+ * Check if a translation key exists in the current locale.
95
+ */
96
+ exists: (key: string) => boolean
97
+
98
+ /**
99
+ * Add translations for a locale (merged with existing).
100
+ * Useful for adding translations at runtime without async loading.
101
+ */
102
+ addMessages: (
103
+ locale: string,
104
+ messages: TranslationDictionary,
105
+ namespace?: string,
106
+ ) => void
107
+
108
+ /**
109
+ * Get all available locales (those with any registered messages).
110
+ */
111
+ availableLocales: Computed<string[]>
112
+ }