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.
- package/README.md +114 -3
- package/dist/api-wrapper/connection-auth.d.ts +61 -0
- package/dist/cli/fields.d.ts +30 -0
- package/dist/cli/index.d.ts +122 -0
- package/dist/format/index.d.ts +91 -0
- package/dist/format/phone.d.ts +19 -0
- package/dist/http/index.d.ts +14 -0
- package/dist/i18n/catalog.d.ts +47 -0
- package/dist/i18n/check.d.ts +45 -0
- package/dist/i18n/cli.d.ts +2 -0
- package/dist/i18n/content-rules.d.ts +55 -0
- package/dist/i18n/i18next.d.ts +39 -0
- package/dist/i18n/index.d.ts +16 -1
- package/dist/i18n/locales.d.ts +62 -0
- package/dist/i18n/vite.d.ts +16 -0
- package/dist/i18n/vue.d.ts +93 -0
- package/dist/{index-kj1phgrx.js → index-29mmh343.js} +24 -1
- package/dist/{index-zbj9s3s3.js → index-2kz392vw.js} +1 -1
- package/dist/index-5n2e326q.js +409 -0
- package/dist/{index-hy7kbb99.js → index-5tzqfmnj.js} +1 -1
- package/dist/{index-0zvtcbzy.js → index-6f2w90pp.js} +69 -1
- package/dist/index-6ragggbs.js +473 -0
- package/dist/index-mq2qgc20.js +116 -0
- package/dist/index-najbyzpr.js +99 -0
- package/dist/index-ww09bm49.js +250 -0
- package/dist/iso/countries.d.ts +262 -0
- package/dist/iso/currencies.d.ts +165 -0
- package/dist/iso/index.d.ts +4 -0
- package/dist/iso/languages.d.ts +195 -0
- package/dist/iso/phone-language.d.ts +5 -0
- package/dist/promise/index.d.ts +31 -0
- package/dist/redis/client.d.ts +49 -0
- package/dist/redis/concurrency-limit.d.ts +63 -0
- package/dist/redis/index.d.ts +3 -0
- package/dist/redis/lua-script.d.ts +13 -0
- package/dist/secrets/infisical-identity-file.d.ts +12 -0
- package/dist/src/api-wrapper/connection-auth.js +82 -0
- package/dist/src/api-wrapper/index.js +1 -1
- package/dist/src/cli/index.js +10 -0
- package/dist/src/format/index.js +79 -2
- package/dist/src/format/phone.js +21 -0
- package/dist/src/http/index.js +69 -0
- package/dist/src/i18n/check.js +15 -0
- package/dist/src/i18n/cli.js +71 -0
- package/dist/src/i18n/i18next.js +48 -0
- package/dist/src/i18n/index.js +16 -34
- package/dist/src/i18n/vite.js +25 -0
- package/dist/src/i18n/vue.js +51 -0
- package/dist/src/index.js +14 -4
- package/dist/src/iso/index.js +655 -0
- package/dist/src/redis/index.js +577 -0
- package/dist/src/secrets/index.js +78 -35
- package/dist/src/sitemap/index.js +1 -1
- package/dist/src/storage/index.js +8 -117
- package/dist/src/testing/index.js +348 -0
- package/dist/src/zod/index.js +20 -253
- package/dist/string/index.d.ts +21 -0
- package/dist/testing/connection-auth-contract.d.ts +43 -0
- package/dist/testing/helpers.d.ts +26 -0
- package/dist/testing/index.d.ts +5 -0
- package/dist/testing/module-mocker.d.ts +30 -0
- package/dist/testing/prisma.d.ts +30 -0
- package/dist/testing/window.d.ts +18 -0
- package/dist/zod/index.d.ts +10 -0
- package/dist/zod/introspect.d.ts +2 -1
- package/package.json +96 -2
- package/src/api-wrapper/README.md +52 -0
- package/src/api-wrapper/connection-auth.ts +142 -0
- package/src/cli/README.md +220 -0
- package/src/cli/fields.ts +213 -0
- package/src/cli/index.ts +532 -0
- package/src/format/README.md +82 -2
- package/src/format/index.ts +148 -0
- package/src/format/phone.ts +43 -0
- package/src/http/README.md +25 -0
- package/src/http/index.ts +94 -0
- package/src/i18n/README.md +206 -3
- package/src/i18n/catalog.ts +139 -0
- package/src/i18n/check.ts +376 -0
- package/src/i18n/cli.ts +90 -0
- package/src/i18n/content-rules.ts +179 -0
- package/src/i18n/i18next.ts +89 -0
- package/src/i18n/index.ts +34 -0
- package/src/i18n/locales.ts +135 -0
- package/src/i18n/vite.ts +40 -0
- package/src/i18n/vue.ts +82 -0
- package/src/index.ts +1 -1
- package/src/iso/README.md +44 -0
- package/src/iso/countries.ts +272 -0
- package/src/iso/currencies.ts +172 -0
- package/src/iso/index.ts +8 -0
- package/src/iso/languages.ts +208 -0
- package/src/iso/phone-language.ts +46 -0
- package/src/promise/index.ts +100 -0
- package/src/redis/README.md +112 -0
- package/src/redis/client.ts +239 -0
- package/src/redis/concurrency-limit.ts +591 -0
- package/src/redis/index.ts +15 -0
- package/src/redis/lua-script.ts +43 -0
- package/src/secrets/README.md +5 -1
- package/src/secrets/infisical-identity-file.ts +69 -0
- package/src/secrets/infisical.ts +18 -3
- package/src/string/index.ts +59 -0
- package/src/testing/README.md +119 -0
- package/src/testing/connection-auth-contract.ts +303 -0
- package/src/testing/helpers.ts +41 -0
- package/src/testing/index.ts +12 -0
- package/src/testing/module-mocker.ts +50 -0
- package/src/testing/prisma.ts +78 -0
- package/src/testing/window.ts +119 -0
- package/src/zod/index.ts +20 -0
- package/src/zod/introspect.ts +1 -1
package/src/format/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/i18n/README.md
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Typed `{name}` placeholders,
|
|
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
|
-
|
|
8
|
-
|
|
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
|
+
}
|