hanzio 3.0.0 → 3.1.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 (112) hide show
  1. package/README.md +114 -3
  2. package/dist/api-wrapper/connection-auth.d.ts +61 -0
  3. package/dist/cli/fields.d.ts +30 -0
  4. package/dist/cli/index.d.ts +122 -0
  5. package/dist/format/index.d.ts +91 -0
  6. package/dist/format/phone.d.ts +19 -0
  7. package/dist/http/index.d.ts +14 -0
  8. package/dist/i18n/catalog.d.ts +47 -0
  9. package/dist/i18n/check.d.ts +45 -0
  10. package/dist/i18n/cli.d.ts +2 -0
  11. package/dist/i18n/content-rules.d.ts +55 -0
  12. package/dist/i18n/i18next.d.ts +39 -0
  13. package/dist/i18n/index.d.ts +16 -1
  14. package/dist/i18n/locales.d.ts +62 -0
  15. package/dist/i18n/vite.d.ts +16 -0
  16. package/dist/i18n/vue.d.ts +93 -0
  17. package/dist/{index-kj1phgrx.js → index-29mmh343.js} +24 -1
  18. package/dist/{index-zbj9s3s3.js → index-2kz392vw.js} +1 -1
  19. package/dist/index-5n2e326q.js +409 -0
  20. package/dist/{index-hy7kbb99.js → index-5tzqfmnj.js} +1 -1
  21. package/dist/{index-0zvtcbzy.js → index-6f2w90pp.js} +69 -1
  22. package/dist/index-6ragggbs.js +473 -0
  23. package/dist/index-mq2qgc20.js +116 -0
  24. package/dist/index-najbyzpr.js +99 -0
  25. package/dist/index-ww09bm49.js +250 -0
  26. package/dist/iso/countries.d.ts +262 -0
  27. package/dist/iso/currencies.d.ts +165 -0
  28. package/dist/iso/index.d.ts +4 -0
  29. package/dist/iso/languages.d.ts +195 -0
  30. package/dist/iso/phone-language.d.ts +5 -0
  31. package/dist/promise/index.d.ts +31 -0
  32. package/dist/redis/client.d.ts +49 -0
  33. package/dist/redis/concurrency-limit.d.ts +63 -0
  34. package/dist/redis/index.d.ts +3 -0
  35. package/dist/redis/lua-script.d.ts +13 -0
  36. package/dist/secrets/infisical-identity-file.d.ts +12 -0
  37. package/dist/src/api-wrapper/connection-auth.js +82 -0
  38. package/dist/src/api-wrapper/index.js +1 -1
  39. package/dist/src/cli/index.js +10 -0
  40. package/dist/src/format/index.js +79 -2
  41. package/dist/src/format/phone.js +21 -0
  42. package/dist/src/http/index.js +69 -0
  43. package/dist/src/i18n/check.js +15 -0
  44. package/dist/src/i18n/cli.js +71 -0
  45. package/dist/src/i18n/i18next.js +48 -0
  46. package/dist/src/i18n/index.js +16 -34
  47. package/dist/src/i18n/vite.js +25 -0
  48. package/dist/src/i18n/vue.js +51 -0
  49. package/dist/src/index.js +14 -4
  50. package/dist/src/iso/index.js +655 -0
  51. package/dist/src/redis/index.js +577 -0
  52. package/dist/src/secrets/index.js +78 -35
  53. package/dist/src/sitemap/index.js +1 -1
  54. package/dist/src/storage/index.js +8 -117
  55. package/dist/src/testing/index.js +348 -0
  56. package/dist/src/zod/index.js +20 -253
  57. package/dist/string/index.d.ts +21 -0
  58. package/dist/testing/connection-auth-contract.d.ts +43 -0
  59. package/dist/testing/helpers.d.ts +26 -0
  60. package/dist/testing/index.d.ts +5 -0
  61. package/dist/testing/module-mocker.d.ts +30 -0
  62. package/dist/testing/prisma.d.ts +30 -0
  63. package/dist/testing/window.d.ts +18 -0
  64. package/dist/zod/index.d.ts +10 -0
  65. package/dist/zod/introspect.d.ts +2 -1
  66. package/package.json +96 -2
  67. package/src/api-wrapper/README.md +52 -0
  68. package/src/api-wrapper/connection-auth.ts +142 -0
  69. package/src/cli/README.md +220 -0
  70. package/src/cli/fields.ts +213 -0
  71. package/src/cli/index.ts +532 -0
  72. package/src/format/README.md +82 -2
  73. package/src/format/index.ts +148 -0
  74. package/src/format/phone.ts +43 -0
  75. package/src/http/README.md +25 -0
  76. package/src/http/index.ts +94 -0
  77. package/src/i18n/README.md +206 -3
  78. package/src/i18n/catalog.ts +139 -0
  79. package/src/i18n/check.ts +376 -0
  80. package/src/i18n/cli.ts +90 -0
  81. package/src/i18n/content-rules.ts +179 -0
  82. package/src/i18n/i18next.ts +89 -0
  83. package/src/i18n/index.ts +34 -0
  84. package/src/i18n/locales.ts +135 -0
  85. package/src/i18n/vite.ts +40 -0
  86. package/src/i18n/vue.ts +82 -0
  87. package/src/index.ts +1 -1
  88. package/src/iso/README.md +44 -0
  89. package/src/iso/countries.ts +272 -0
  90. package/src/iso/currencies.ts +172 -0
  91. package/src/iso/index.ts +8 -0
  92. package/src/iso/languages.ts +208 -0
  93. package/src/iso/phone-language.ts +46 -0
  94. package/src/promise/index.ts +100 -0
  95. package/src/redis/README.md +112 -0
  96. package/src/redis/client.ts +239 -0
  97. package/src/redis/concurrency-limit.ts +591 -0
  98. package/src/redis/index.ts +15 -0
  99. package/src/redis/lua-script.ts +43 -0
  100. package/src/secrets/README.md +5 -1
  101. package/src/secrets/infisical-identity-file.ts +69 -0
  102. package/src/secrets/infisical.ts +18 -3
  103. package/src/string/index.ts +59 -0
  104. package/src/testing/README.md +119 -0
  105. package/src/testing/connection-auth-contract.ts +303 -0
  106. package/src/testing/helpers.ts +41 -0
  107. package/src/testing/index.ts +12 -0
  108. package/src/testing/module-mocker.ts +50 -0
  109. package/src/testing/prisma.ts +78 -0
  110. package/src/testing/window.ts +119 -0
  111. package/src/zod/index.ts +20 -0
  112. package/src/zod/introspect.ts +1 -1
@@ -100,6 +100,26 @@ export function formatCurrency(
100
100
  })
101
101
  }
102
102
 
103
+ /**
104
+ * A price: whole amounts without minor units (`'2.500 kr.'`), fractional ones
105
+ * with the currency's usual digits (`'199,95 kr.'`) instead of rounding them
106
+ * into a price nobody charges.
107
+ */
108
+ export function formatPrice(
109
+ amount: number,
110
+ currency: string,
111
+ locale: string
112
+ ): string {
113
+ return formatCurrency(
114
+ amount,
115
+ currency,
116
+ locale,
117
+ Number.isInteger(amount)
118
+ ? { minimumFractionDigits: 0, maximumFractionDigits: 0 }
119
+ : undefined
120
+ )
121
+ }
122
+
103
123
  /** Short compact notation: `1234` -> `"1.2K"` (en-US), `"1,2 t"` (da-DK). */
104
124
  export function formatCompactNumber(value: number, locale: string): string {
105
125
  return formatNumber(value, locale, { notation: 'compact' })
@@ -355,6 +375,102 @@ export function formatDateRange(
355
375
  : `${format.format(from)} – ${format.format(to)}`
356
376
  }
357
377
 
378
+ // `hourCycle: 'h23'` on the `HH` presets: the name promises a 24-hour clock,
379
+ // but `hour: '2-digit'` alone still renders "07:00 AM" under en-US.
380
+ const DATE_PRESETS = {
381
+ 'DD/MM/YYYY': { day: '2-digit', month: '2-digit', year: 'numeric' },
382
+ 'DD/MM/YYYY HH:mm': {
383
+ day: '2-digit',
384
+ month: '2-digit',
385
+ year: 'numeric',
386
+ hour: '2-digit',
387
+ minute: '2-digit',
388
+ hourCycle: 'h23'
389
+ },
390
+ 'HH:mm': { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' },
391
+ ddd: { weekday: 'short' },
392
+ 'D MMM': { day: 'numeric', month: 'short' },
393
+ 'D MMM YYYY': { day: 'numeric', month: 'short', year: 'numeric' },
394
+ 'dddd, MMMM D': { weekday: 'long', day: 'numeric', month: 'long' },
395
+ 'MMMM YYYY': { month: 'long', year: 'numeric' }
396
+ } as const satisfies Record<string, Intl.DateTimeFormatOptions>
397
+
398
+ /**
399
+ * A named date layout. The names read like dayjs tokens, but the output
400
+ * follows the locale: `'D MMM'` is "5 Mar" in en-GB and "Mar 5" in en-US.
401
+ */
402
+ export type DatePreset = keyof typeof DATE_PRESETS
403
+
404
+ /** A date in one of the `DatePreset` layouts, in local time. */
405
+ export function formatDate(
406
+ date: DateInput,
407
+ preset: DatePreset,
408
+ locale: string
409
+ ): string {
410
+ return getDateTimeFormat(locale, DATE_PRESETS[preset]).format(toTime(date))
411
+ }
412
+
413
+ /** Medium date and short time: "5 Mar 2026, 14:30" (en-GB). */
414
+ export function formatDateTime(date: DateInput, locale: string): string {
415
+ return getDateTimeFormat(locale, {
416
+ dateStyle: 'medium',
417
+ timeStyle: 'short'
418
+ }).format(toTime(date))
419
+ }
420
+
421
+ /**
422
+ * Calendar days from today in local time: 0 today, -1 yesterday. Rounded,
423
+ * because a DST change makes one local day 23 or 25 hours long.
424
+ */
425
+ function dayOffset(date: Date, now: Date): number {
426
+ const start = (d: Date) =>
427
+ new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
428
+ return Math.round((start(date) - start(now)) / DAY_MS)
429
+ }
430
+
431
+ /** "today" / "yesterday" in the locale's own words. */
432
+ const relativeDay = (offset: 0 | -1, locale: string) =>
433
+ getRelativeTimeFormat(locale, { numeric: 'auto' }).format(offset, 'day')
434
+
435
+ /**
436
+ * Compact activity timestamp for a list row: the time today, "yesterday",
437
+ * else the date (with the year when it is not this year). Unparseable input
438
+ * returns `''`.
439
+ */
440
+ export function formatCompactTime(
441
+ date: DateInput,
442
+ locale: string,
443
+ options: { now?: DateInput } = {}
444
+ ): string {
445
+ const value = new Date(toTime(date))
446
+ if (Number.isNaN(value.getTime())) return ''
447
+ const now = new Date(toTime(options.now ?? Date.now()))
448
+ const offset = dayOffset(value, now)
449
+ if (offset === 0) return formatDate(value, 'HH:mm', locale)
450
+ if (offset === -1) return relativeDay(-1, locale)
451
+ const sameYear = value.getFullYear() === now.getFullYear()
452
+ return formatDate(value, sameYear ? 'D MMM' : 'D MMM YYYY', locale)
453
+ }
454
+
455
+ /**
456
+ * Day-divider label for a chat timeline: "today", "yesterday", else weekday
457
+ * and date (with the year when it is not this year). Unparseable input
458
+ * returns `''`.
459
+ */
460
+ export function formatDayDivider(
461
+ date: DateInput,
462
+ locale: string,
463
+ options: { now?: DateInput } = {}
464
+ ): string {
465
+ const value = new Date(toTime(date))
466
+ if (Number.isNaN(value.getTime())) return ''
467
+ const now = new Date(toTime(options.now ?? Date.now()))
468
+ const offset = dayOffset(value, now)
469
+ if (offset === 0 || offset === -1) return relativeDay(offset, locale)
470
+ const sameYear = value.getFullYear() === now.getFullYear()
471
+ return formatDate(value, sameYear ? 'dddd, MMMM D' : 'D MMM YYYY', locale)
472
+ }
473
+
358
474
  /** "a, b, and c". Falls back to a comma join where `Intl.ListFormat` is missing. */
359
475
  export function formatList(
360
476
  items: readonly string[],
@@ -369,6 +485,12 @@ export function formatList(
369
485
 
370
486
  // ─── Counts ─────────────────────────────────────────────────────────────────
371
487
 
488
+ /**
489
+ * Stands for "no value" in a cell or detail row that must keep its height, so
490
+ * absent values cannot drift between a hyphen and an em dash. Not translated.
491
+ */
492
+ export const EM_DASH = '—'
493
+
372
494
  /** Badge count: `150` -> `"99+"`. Negative or NaN is `"0"`. */
373
495
  export function formatCappedCount(count: number, max = 99): string {
374
496
  if (!(count > 0)) return '0'
@@ -387,3 +509,29 @@ export function pluralize(
387
509
  const form = forms[getPluralRules(locale).select(count)] ?? forms.other
388
510
  return form.replaceAll('{count}', formatNumber(count, locale))
389
511
  }
512
+
513
+ // ─── Bound to a locale ──────────────────────────────────────────────────────
514
+
515
+ /**
516
+ * The date formatters with the locale read on every call, for apps whose
517
+ * locale lives in their i18n runtime:
518
+ * `export const fmt = withLocale(() => uiLocales.tag(appI18n.locale()))`.
519
+ */
520
+ export function withLocale(getLocale: () => string) {
521
+ return {
522
+ formatDate: (date: DateInput, preset: DatePreset) =>
523
+ formatDate(date, preset, getLocale()),
524
+ formatDateTime: (date: DateInput) => formatDateTime(date, getLocale()),
525
+ formatCompactTime: (date: DateInput) =>
526
+ formatCompactTime(date, getLocale()),
527
+ formatDayDivider: (date: DateInput) => formatDayDivider(date, getLocale()),
528
+ formatRelativeTime: (
529
+ date: DateInput,
530
+ options?: FormatRelativeTimeOptions
531
+ ) => formatRelativeTime(date, getLocale(), options),
532
+ formatNumber: (value: number, options?: Intl.NumberFormatOptions) =>
533
+ formatNumber(value, getLocale(), options),
534
+ formatPrice: (amount: number, currency: string) =>
535
+ formatPrice(amount, currency, getLocale())
536
+ }
537
+ }
@@ -0,0 +1,43 @@
1
+ // Phone numbers for people to read. Needs `libphonenumber-js` (its default
2
+ // "min" metadata is about 84 KB, which is why this is not in `hanzio/format`).
3
+
4
+ import {
5
+ isSupportedCountry,
6
+ parsePhoneNumberFromString
7
+ } from 'libphonenumber-js'
8
+
9
+ /**
10
+ * A stored phone number as a person reads it. `country` is the READER's ISO
11
+ * 3166-1 alpha-2 country (the organization's, say), not the number's: a local
12
+ * number prints the local way, any other with its country code.
13
+ *
14
+ * ```ts
15
+ * formatPhone('+4520304050', 'DK') // '20 30 40 50'
16
+ * formatPhone('+4520304050') // '+45 20 30 40 50'
17
+ * formatPhone('+4915112345678', 'DK') // '+49 1511 2345678'
18
+ * ```
19
+ *
20
+ * Nothing is lost: an unparseable value comes back trimmed but verbatim, and
21
+ * the national form is used only for a VALID number (`+4512345678` parses as
22
+ * DK but is not a real number, so it keeps its `+45`). A country
23
+ * libphonenumber cannot dial falls back to international. Empty input is
24
+ * `''`; the caller decides what stands in for "no number". Use the raw E.164
25
+ * value in `tel:` links.
26
+ */
27
+ export function formatPhone(
28
+ value: string | null | undefined,
29
+ country?: string | null
30
+ ): string {
31
+ const raw = value?.trim() ?? ''
32
+ if (!raw) return ''
33
+ const region = country && isSupportedCountry(country) ? country : undefined
34
+ const parsed = parsePhoneNumberFromString(
35
+ raw,
36
+ region ? { defaultCountry: region } : undefined
37
+ )
38
+ if (!parsed) return raw
39
+ if (region && parsed.country === region && parsed.isValid()) {
40
+ return parsed.formatNational()
41
+ }
42
+ return parsed.formatInternational()
43
+ }
@@ -0,0 +1,25 @@
1
+ ---
2
+ description: SSE keep-alive comments and anti-buffering headers for streaming responses.
3
+ ---
4
+
5
+ # hanzio/http
6
+
7
+ Web-standard (`Response`, `TransformStream`), so it runs in Bun, Node and
8
+ Workers.
9
+
10
+ ```ts
11
+ import { withSseKeepAlive } from 'hanzio/http'
12
+
13
+ return withSseKeepAlive(createUIMessageStreamResponse({ stream }))
14
+ ```
15
+
16
+ `withSseKeepAlive(response, intervalMs = 15_000)` returns a copy of the
17
+ response whose body writes an SSE comment (`:\n\n`) right away and again after
18
+ every `intervalMs` without data, so proxy idle timers (Railway, nginx) don't
19
+ close a stream that goes quiet. It also sets `Cache-Control: no-cache`,
20
+ `X-Accel-Buffering: no` and `Connection: keep-alive`.
21
+
22
+ `createSseKeepAliveTransform(intervalMs, timers?)` is the transform on its own,
23
+ for a stream you pipe yourself; `timers` takes fake `setTimeout` /
24
+ `clearTimeout` functions in tests. `SSE_KEEPALIVE_COMMENT` is the comment's
25
+ bytes.
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Byte-level SSE keep-alives. Empty `:` comments keep proxy and load-balancer
3
+ * idle timers (Railway, nginx) from resetting a stream that goes quiet, such
4
+ * as an LLM thinking or a slow tool call.
5
+ */
6
+ const SSE_KEEPALIVE_INTERVAL_MS = 15_000
7
+ export const SSE_KEEPALIVE_COMMENT = new TextEncoder().encode(':\n\n')
8
+
9
+ function defaultClearTimeout(id: unknown) {
10
+ clearTimeout(id as ReturnType<typeof setTimeout>)
11
+ }
12
+
13
+ /** Passes bytes through, writing a comment first and after every `intervalMs` of silence. */
14
+ export function createSseKeepAliveTransform(
15
+ intervalMs = SSE_KEEPALIVE_INTERVAL_MS,
16
+ timers: {
17
+ setTimeout: (fn: () => void, ms: number) => unknown
18
+ clearTimeout: (id: unknown) => void
19
+ } = { setTimeout, clearTimeout: defaultClearTimeout }
20
+ ) {
21
+ let timer: unknown
22
+ let closed = false
23
+ let enqueueKeepAlive: (() => void) | undefined
24
+
25
+ const clear = () => {
26
+ if (timer === undefined) return
27
+ timers.clearTimeout(timer)
28
+ timer = undefined
29
+ }
30
+
31
+ const arm = () => {
32
+ clear()
33
+ timer = timers.setTimeout(() => {
34
+ enqueueKeepAlive?.()
35
+ }, intervalMs)
36
+ }
37
+
38
+ return new TransformStream<Uint8Array, Uint8Array>(
39
+ {
40
+ start(controller) {
41
+ enqueueKeepAlive = () => {
42
+ if (closed) return
43
+ try {
44
+ controller.enqueue(SSE_KEEPALIVE_COMMENT)
45
+ } catch {
46
+ closed = true
47
+ clear()
48
+ return
49
+ }
50
+ arm()
51
+ }
52
+ // Immediate comment so the edge sees body bytes before the first
53
+ // event, which can take a long time to arrive.
54
+ enqueueKeepAlive()
55
+ },
56
+ transform(chunk, controller) {
57
+ if (closed) return
58
+ controller.enqueue(chunk)
59
+ arm()
60
+ },
61
+ flush() {
62
+ closed = true
63
+ clear()
64
+ }
65
+ },
66
+ { highWaterMark: 16 },
67
+ { highWaterMark: 16 }
68
+ )
69
+ }
70
+
71
+ /**
72
+ * Copies an SSE response with keep-alive comments in its body and headers that
73
+ * stop proxies from buffering it.
74
+ *
75
+ * @example
76
+ * return withSseKeepAlive(createUIMessageStreamResponse({ stream }))
77
+ */
78
+ export function withSseKeepAlive(
79
+ response: Response,
80
+ intervalMs = SSE_KEEPALIVE_INTERVAL_MS
81
+ ) {
82
+ const headers = new Headers(response.headers)
83
+ headers.set('Cache-Control', 'no-cache')
84
+ headers.set('X-Accel-Buffering', 'no')
85
+ headers.set('Connection', 'keep-alive')
86
+ const body = response.body
87
+ ? response.body.pipeThrough(createSseKeepAliveTransform(intervalMs))
88
+ : response.body
89
+ return new Response(body, {
90
+ status: response.status,
91
+ statusText: response.statusText,
92
+ headers
93
+ })
94
+ }
@@ -1,11 +1,14 @@
1
1
  ---
2
- description: Typed `{name}` placeholders, interpolation and a small typed translator.
2
+ description: Typed `{name}` placeholders, translators, locale registries, vue-i18n and i18next setup, and a catalog checker.
3
3
  ---
4
4
 
5
5
  # hanzio/i18n
6
6
 
7
- A typed helper for `{name}` message templates, not an i18n framework: no
8
- plurals, ICU, locale negotiation or escaping.
7
+ Typed `{name}` message templates and the glue around an i18n framework: a
8
+ locale registry, typed `t` for vue-i18n and i18next, their setup, and a
9
+ `hanzio-i18n` bin that checks JSON catalogs and generates their key maps.
10
+ `hanzio/i18n` itself has no dependencies; plurals and ICU syntax are the
11
+ framework's job.
9
12
 
10
13
  ```ts
11
14
  import { createTranslator, extractPlaceholders, interpolate } from 'hanzio/i18n'
@@ -63,3 +66,203 @@ messages inline or `as const` so the templates stay literal; with plain
63
66
  and params are optional. A key that is missing at runtime returns the key.
64
67
 
65
68
  `flattenMessages(messages)` gives the flat map: `{ 'common.save': 'Save', greeting: 'Hi {name}' }`.
69
+
70
+ ## Typed `t` for a framework
71
+
72
+ `hanzio-i18n generate` writes a key → placeholder-name map from your base
73
+ catalog (see [Catalog checks](#catalog-checks)). `TypedTranslate<Params>`
74
+ turns it into a strict `t`, and `typedTranslate` narrows a framework's `t`:
75
+
76
+ ```ts
77
+ import { typedTranslate } from 'hanzio/i18n'
78
+ import type { AppTranslationParams } from './locales/i18n.generated'
79
+
80
+ // { 'common.save': never; 'greeting': 'name' }
81
+ export const t = typedTranslate<AppTranslationParams>(i18n.t.bind(i18n))
82
+
83
+ t('greeting', { name: 'Ada' })
84
+ t('greeting') // type error: params required
85
+ t('common.save', { name: 'Ada' }) // type error: no placeholders
86
+ ```
87
+
88
+ `DynamicTranslate` is the explicit escape hatch for data-driven keys.
89
+
90
+ ## Locales
91
+
92
+ `defineLocales` is the one list of shipped UI locales, shared by every runtime
93
+ and the catalog check:
94
+
95
+ ```ts
96
+ import { defineLocales, type LocaleOf } from 'hanzio/i18n'
97
+
98
+ export const uiLocales = defineLocales({
99
+ locales: {
100
+ en: { tag: 'en-US', name: 'English' },
101
+ da: { tag: 'da-DK', name: 'Dansk' },
102
+ nb: { tag: 'nb-NO', name: 'Norsk bokmål' }
103
+ },
104
+ defaultLocale: 'en',
105
+ aliases: { no: 'nb' }
106
+ })
107
+ export type UiLocale = LocaleOf<typeof uiLocales> // 'en' | 'da' | 'nb'
108
+
109
+ uiLocales.match('da_DK') // 'da' (id, tag, language subtag, then aliases)
110
+ uiLocales.match('no-NO') // 'nb'
111
+ uiLocales.resolve(saved, navigator.language) // first match, else 'en'
112
+ uiLocales.tag('da') // 'da-DK', for Intl
113
+ uiLocales.name('nb') // 'Norsk bokmål', for pickers
114
+ ```
115
+
116
+ Ids name the catalog files (`da.json`). A server enum such as `da_DK` matches
117
+ too, and `tag(locale).replace('-', '_')` goes the other way.
118
+
119
+ `createLocaleSelector` switches the locale at once and rolls back when saving
120
+ the choice fails:
121
+
122
+ ```ts
123
+ const selectLocale = createLocaleSelector({
124
+ getLocale: () => locale.value,
125
+ setLocale,
126
+ persist: (locale) => api.user.updateLocale({ locale }),
127
+ onError: () => toast.error(t('settings.language.error'))
128
+ })
129
+ ```
130
+
131
+ ## vue-i18n
132
+
133
+ `hanzio/i18n/vue` needs `vue-i18n`. `createVueI18n` calls `createI18n` in
134
+ composition mode, starting in the persisted locale, then the browser language,
135
+ then the default (also the fallback). `messages` needs a catalog for every
136
+ locale in the registry. `setLocale` switches, persists and sets `<html lang>`.
137
+ Other options go to `createI18n` unchanged.
138
+
139
+ ```ts
140
+ import { createVueI18n } from 'hanzio/i18n/vue'
141
+
142
+ export const { i18n, setLocale, useI18n } = createVueI18n({
143
+ locales: uiLocales,
144
+ messages: { en, da, nb },
145
+ storageKey: 'app.ui-locale',
146
+ datetimeFormats: { en: dateFormats, da: dateFormats, nb: dateFormats }
147
+ })
148
+
149
+ // The app's only useI18n: locale is UiLocale, t is checked against the map.
150
+ export const useAppI18n = () => useI18n<AppTranslationParams>()
151
+ ```
152
+
153
+ The stored value is the raw locale id. After sign-in, call `setLocale` with the
154
+ user's saved locale so pre-auth screens match on the next visit.
155
+
156
+ `hanzio/i18n/vite` has `localeCatalogReloadPlugin()`, a dev-server plugin for
157
+ `@intlify/unplugin-vue-i18n`: an edited `locales/*.json` reloads the page with
158
+ fresh catalogs instead of serving the old ones until a restart. Pass
159
+ `{ match }` if your catalogs are not in `locales` folders.
160
+
161
+ ## i18next
162
+
163
+ `hanzio/i18n/i18next` takes your `i18next` instance (`.use(initReactI18next)`
164
+ first for React). It starts in the persisted locale, then the first supported
165
+ device locale, then the default, and interpolates `{name}` without escaping,
166
+ the same syntax the rest of this module uses.
167
+
168
+ ```ts
169
+ import AsyncStorage from '@react-native-async-storage/async-storage'
170
+ import { getLocales } from 'expo-localization'
171
+ import i18n from 'i18next'
172
+ import { initReactI18next } from 'react-i18next'
173
+ import { initI18next } from 'hanzio/i18n/i18next'
174
+
175
+ export const appI18n = initI18next(i18n.use(initReactI18next), {
176
+ locales: uiLocales,
177
+ resources: { en: { translation: en }, da: { translation: da } },
178
+ deviceLocales: () => getLocales().flatMap((l) => [l.languageTag, l.languageCode]),
179
+ storageKey: 'app.ui-locale',
180
+ storage: AsyncStorage
181
+ })
182
+
183
+ await appI18n.ready // before the first render
184
+ appI18n.locale() // 'da'
185
+ await appI18n.setLocale('en') // switches and persists
186
+ await appI18n.syncLocale(me?.locale) // follow the user's saved locale; unknown tags do nothing
187
+ ```
188
+
189
+ Other options go to `i18n.init`. Type `useTranslation()`'s `t` with
190
+ `typedTranslate`.
191
+
192
+ ## Catalog checks
193
+
194
+ The `hanzio-i18n` bin checks catalogs stored as one JSON file per locale
195
+ (`<dir>/<locale>.json`), with the base locale as the source of truth:
196
+
197
+ ```bash
198
+ hanzio-i18n check # fails with one line per problem
199
+ hanzio-i18n generate # writes each catalog's generatedTypes
200
+ ```
201
+
202
+ Both read `i18n.config.ts` (or `.mts`, `.js`, `.mjs`, or `--config <file>`)
203
+ from the current directory; paths in it are relative to the config file.
204
+ `defineI18nConfig` rejects a `baseLocale`, `disjointNamespaces` catalog or
205
+ `identical.allowKeys` locale the config does not declare. A
206
+ `.ts` config needs Bun, or a Node version that strips types. The bin is built
207
+ on [`hanzio/cli`](../cli/README.md), so it needs `zod` installed; `--help`
208
+ lists its options and it exits 1 on catalog problems, 2 on bad arguments.
209
+
210
+ ```ts
211
+ import { defineI18nConfig } from 'hanzio/i18n/check'
212
+ import { uiLocales } from './src/locales'
213
+
214
+ export default defineI18nConfig({
215
+ baseLocale: 'en',
216
+ locales: uiLocales.locales,
217
+ catalogs: [
218
+ {
219
+ name: 'platform',
220
+ dir: 'apps/platform/src/locales',
221
+ runtime: 'vue-i18n',
222
+ generatedTypes: 'apps/platform/src/locales/i18n.generated.ts',
223
+ generatedTypeName: 'PlatformTranslationParams',
224
+ sourceRoots: ['apps/platform/src'],
225
+ dynamicKeyPrefixes: ['entities.']
226
+ },
227
+ { name: 'expo', dir: 'apps/expo/src/locales', runtime: 'i18next' }
228
+ ],
229
+ disjointNamespaces: [['platform', 'widget']],
230
+ rules: {
231
+ exemptKeyPrefixes: ['admin.'],
232
+ bannedWords: { translations: ['workflow', 'payload'] },
233
+ wrongLanguage: { markers: ['og', 'til', 'ikke', 'vi'], letters: 'æøåÆØÅ' },
234
+ identical: { brands: ['Acme'], allowKeys: { 'common.send': ['da', 'nb'] } }
235
+ }
236
+ })
237
+ ```
238
+
239
+ What `check` reports:
240
+
241
+ - Locale files missing from `locales`, or not in it.
242
+ - Leaves that are not strings, empty or dotted key segments, text that is not
243
+ NFC-normalized, likely mojibake (`æ`).
244
+ - Keys missing from or stale in a translation, and placeholder sets that
245
+ differ from the base value.
246
+ - Values that look like source code (`row.name`, `carTitle(car)`,
247
+ `LABELS[key]`, `x ?? y`), in every locale. `rules.allowCodeShapes` lists the
248
+ exceptions.
249
+ - Translations of two or more words identical to the base value: an untranslated
250
+ label or sentence. Single words, brands, acronyms, paths and URLs, and
251
+ `rules.identical.allowKeys` are allowed.
252
+ - With `rules.wrongLanguage`: base values written in that language (two marker
253
+ words, or one plus one of its `letters`).
254
+ - With `rules.bannedWords`: `everywhere` words in any catalog, `translations`
255
+ words outside the base. Placeholders never count.
256
+ - `runtime: 'vue-i18n'`: HTML and syntax that vue-i18n's `strictMessage`
257
+ compile rejects. Install `@intlify/message-compiler` for this.
258
+ - `sourceRoots`: keys no source file references. Any dotted path in a file
259
+ counts, and `` `prefix.${x}` `` keeps a whole family; `dynamicKeyPrefixes`
260
+ keeps families built from data.
261
+ - `generatedTypes` files that do not match the base catalog.
262
+ - `disjointNamespaces`: catalogs merged into one runtime that share a
263
+ top-level key.
264
+
265
+ `runtime` also decides plural keys in the generated map: vue-i18n's `a | b`
266
+ messages and i18next's `key_one`/`key_other` (merged into `key`) take a
267
+ `count`. The same checks are available as `validateCatalogs(config, { root })`
268
+ and `writeCatalogTypes(config, { root })`.
@@ -0,0 +1,139 @@
1
+ import { isPlainObject } from '../guard'
2
+ import { extractPlaceholders, flattenMessages, type Messages } from '.'
3
+
4
+ /** How a catalog's runtime reads its messages: plural syntax and escaping. */
5
+ export type CatalogRuntime = 'vue-i18n' | 'i18next' | 'plain'
6
+
7
+ export type CatalogConfig = {
8
+ /** Shown in check output. */
9
+ name: string
10
+ /** Folder of `<locale>.json` files, relative to the config file. */
11
+ dir: string
12
+ runtime: CatalogRuntime
13
+ /** Where `hanzio-i18n generate` writes the key → placeholder map. */
14
+ generatedTypes?: string
15
+ /** Exported type name in `generatedTypes`. Default `TranslationParams`. */
16
+ generatedTypeName?: string
17
+ /**
18
+ * Source folders scanned for key references. Set them and the check also
19
+ * fails on keys nothing references. Leave unset when keys are built at
20
+ * runtime from a prefix (`t(\`widget.${key}\`)` everywhere), since every key
21
+ * would read as used.
22
+ */
23
+ sourceRoots?: readonly string[]
24
+ /**
25
+ * Key prefixes composed from data rather than written in source (labels
26
+ * sent by the backend, for example), kept even when nothing references them.
27
+ * A prefix a template literal spells out is found by the scan already.
28
+ */
29
+ dynamicKeyPrefixes?: readonly string[]
30
+ }
31
+
32
+ export type CatalogIssue = {
33
+ catalog: string
34
+ locale?: string
35
+ message: string
36
+ }
37
+
38
+ const MOJIBAKE = /(?:Ã.|Â.|â€|�)/
39
+ const I18NEXT_PLURAL_SUFFIX = /_(zero|one|two|few|many|other)$/
40
+
41
+ /**
42
+ * Validates one parsed JSON catalog: every leaf a string, no empty or dotted
43
+ * key segments, NFC-normalized text and no mojibake.
44
+ */
45
+ export function parseMessages(
46
+ value: unknown,
47
+ catalog: string,
48
+ locale: string
49
+ ): { messages?: Messages; issues: CatalogIssue[] } {
50
+ const issues: CatalogIssue[] = []
51
+ const issue = (message: string) => issues.push({ catalog, locale, message })
52
+
53
+ function visit(node: unknown, path: string): string | Messages | undefined {
54
+ if (typeof node === 'string') {
55
+ if (node.normalize('NFC') !== node) {
56
+ issue(`"${path}" is not Unicode NFC-normalized`)
57
+ }
58
+ if (MOJIBAKE.test(node)) issue(`"${path}" contains likely mojibake`)
59
+ return node
60
+ }
61
+ if (!isPlainObject(node)) {
62
+ issue(`"${path || '<root>'}" must be an object or string`)
63
+ return undefined
64
+ }
65
+ const result: Record<string, string | Messages> = {}
66
+ for (const [key, child] of Object.entries(node)) {
67
+ if (!key || key.includes('.')) {
68
+ issue(`"${path ? `${path}.` : ''}${key}" has an invalid key segment`)
69
+ continue
70
+ }
71
+ const parsed = visit(child, path ? `${path}.${key}` : key)
72
+ if (parsed !== undefined) result[key] = parsed
73
+ }
74
+ return result
75
+ }
76
+
77
+ const messages = visit(value, '')
78
+ return {
79
+ messages: typeof messages === 'object' ? messages : undefined,
80
+ issues
81
+ }
82
+ }
83
+
84
+ export function flattenCatalog(messages: Messages): Map<string, string> {
85
+ return new Map(Object.entries(flattenMessages(messages)))
86
+ }
87
+
88
+ const quote = (value: string) =>
89
+ `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`
90
+
91
+ /**
92
+ * The TypeScript key → placeholder-name map for `TypedTranslate`, generated
93
+ * from the base catalog. Plural messages also take `count`: vue-i18n's
94
+ * `a | b | c` forms, and i18next's `key_one`/`key_other` (merged into `key`).
95
+ */
96
+ export function generateCatalogTypes(
97
+ catalog: Pick<CatalogConfig, 'runtime' | 'generatedTypeName'>,
98
+ baseMessages: Messages,
99
+ command = 'hanzio-i18n generate'
100
+ ): string {
101
+ const entries = new Map<string, Set<string>>()
102
+
103
+ for (const [rawKey, template] of flattenCatalog(baseMessages)) {
104
+ const isI18nextPlural =
105
+ catalog.runtime === 'i18next' && I18NEXT_PLURAL_SUFFIX.test(rawKey)
106
+ const key = isI18nextPlural
107
+ ? rawKey.replace(I18NEXT_PLURAL_SUFFIX, '')
108
+ : rawKey
109
+ const params = entries.get(key) ?? new Set<string>()
110
+ for (const name of extractPlaceholders(template)) params.add(name)
111
+ const isVuePlural =
112
+ catalog.runtime === 'vue-i18n' &&
113
+ template
114
+ .split('|')
115
+ .map((part) => part.trim())
116
+ .filter(Boolean).length > 1
117
+ if (isVuePlural || isI18nextPlural) params.add('count')
118
+ entries.set(key, params)
119
+ }
120
+
121
+ const lines = [...entries]
122
+ .sort(([a], [b]) => a.localeCompare(b))
123
+ .map(([key, params]) => {
124
+ const value =
125
+ params.size === 0 ? 'never' : [...params].sort().map(quote).join(' | ')
126
+ return `\t${quote(key)}: ${value}`
127
+ })
128
+
129
+ return [
130
+ '/**',
131
+ ` * Generated by \`${command}\` from the base catalog.`,
132
+ ' * Do not edit by hand.',
133
+ ' */',
134
+ `export type ${catalog.generatedTypeName ?? 'TranslationParams'} = {`,
135
+ ...lines,
136
+ '}',
137
+ ''
138
+ ].join('\n')
139
+ }