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
package/README.md CHANGED
@@ -9,7 +9,7 @@ logging.
9
9
 
10
10
  ```bash
11
11
  bun add hanzio
12
- # only needed for hanzio/zod, hanzio/api-wrapper and hanzio/secrets
12
+ # only needed for hanzio/zod, hanzio/api-wrapper, hanzio/secrets and hanzio/cli
13
13
  bun add zod
14
14
  ```
15
15
 
@@ -19,6 +19,11 @@ from `dist`. `zod` is an optional peer dependency: the Zod helpers, the API
19
19
  wrapper and the secrets loader share the consumer application's Zod instance,
20
20
  while the root entry never loads it.
21
21
 
22
+ The other optional peers belong to one subpath each: `libphonenumber-js` for
23
+ `hanzio/format/phone`, `vue-i18n` for `hanzio/i18n/vue`, `i18next` for
24
+ `hanzio/i18n/i18next`, `vite` for `hanzio/i18n/vite`, and
25
+ `@intlify/message-compiler` for checking vue-i18n catalogs with `hanzio-i18n`.
26
+
22
27
  ## Imports
23
28
 
24
29
  The root export contains only dependency-free, runtime-neutral helpers (array,
@@ -37,17 +42,28 @@ subpath export:
37
42
  | `hanzio/zod` | Branded IDs, JSON schemas, schema introspection and defaults, schema-to-type rendering, min/max filters |
38
43
  | `hanzio/api-wrapper` | Typed HTTP clients ([docs](src/api-wrapper/README.md)) |
39
44
  | `hanzio/api-wrapper/testing` | `createMockFetch` / `jsonResponse` for testing API clients |
45
+ | `hanzio/api-wrapper/connection-auth` | One shared token exchange per connection for API clients; needs `node:crypto` ([docs](src/api-wrapper/README.md#sharing-a-login-across-clients)) |
40
46
  | `hanzio/jwt` | HS256/384/512 JWTs via Web Crypto ([docs](src/jwt/README.md)) |
41
47
  | `hanzio/sitemap` | Sitemap discovery and crawling ([docs](src/sitemap/README.md)) |
42
48
  | `hanzio/p-queue` | Promise queue ([docs](src/p-queue/README.md)) |
43
49
  | `hanzio/cool-console-log` | Colored terminal logging |
44
- | `hanzio/format` | Cached `Intl` formatters: numbers, currency, bytes, durations, relative time ([docs](src/format/README.md)) |
50
+ | `hanzio/format` | Cached `Intl` formatters: numbers, currency, bytes, durations, dates, relative time ([docs](src/format/README.md)) |
51
+ | `hanzio/format/phone` | Phone numbers as people read them; needs `libphonenumber-js` ([docs](src/format/README.md#phone-numbers)) |
45
52
  | `hanzio/crypto` | Web Crypto helpers: constant-time compare, HMAC/webhook verification, sealing, tokens ([docs](src/crypto/README.md)) |
46
53
  | `hanzio/storage` | Typed localStorage/AsyncStorage items that never throw ([docs](src/storage/README.md)) |
47
- | `hanzio/i18n` | Typed `{placeholder}` interpolation and translators ([docs](src/i18n/README.md)) |
54
+ | `hanzio/i18n` | Typed `{placeholder}` interpolation, translators, `TypedTranslate` and locale registries ([docs](src/i18n/README.md)) |
55
+ | `hanzio/i18n/vue` | vue-i18n setup with locale persistence and a typed composer ([docs](src/i18n/README.md#vue-i18n)) |
56
+ | `hanzio/i18n/i18next` | i18next setup with device locale, persistence and server sync ([docs](src/i18n/README.md#i18next)) |
57
+ | `hanzio/i18n/vite` | Vite dev plugin that reloads edited vue-i18n catalogs ([docs](src/i18n/README.md#vue-i18n)) |
58
+ | `hanzio/i18n/check` | Catalog checks behind the `hanzio-i18n` bin ([docs](src/i18n/README.md#catalog-checks)) |
59
+ | `hanzio/iso` | ISO country, currency and language codes with English names ([docs](src/iso/README.md)) |
48
60
  | `hanzio/secrets` | Typed secret sets from env/Infisical ([docs](src/secrets/README.md)) |
49
61
  | `hanzio/secrets/vite` | Vite `define` helpers for secret sets |
50
62
  | `hanzio/secrets/worker` | Secret sets for Cloudflare Workers |
63
+ | `hanzio/redis` | Bun Redis: self-healing client with shared subscriptions, fleet-wide concurrency limiter ([docs](src/redis/README.md)) |
64
+ | `hanzio/http` | SSE keep-alive comments and anti-buffering headers ([docs](src/http/README.md)) |
65
+ | `hanzio/cli` | Typed CLIs from Zod schemas: flags, help, completion, exit codes; needs `node:util` ([docs](src/cli/README.md)) |
66
+ | `hanzio/testing` | `bun:test` kit: restorable module mocks, Prisma deep mock, window shim, auth race contract ([docs](src/testing/README.md)) |
51
67
 
52
68
  `scripts/verify-exports.mjs` imports every entry above from the packed tarball
53
69
  in CI, so this table and `package.json#exports` can't drift apart.
@@ -257,6 +273,28 @@ if (parsed.ok) parsed.data
257
273
  new RegExp(`\\b${escapeRegExp(term)}\\b`, 'i')
258
274
  ```
259
275
 
276
+ `escapeHtml` escapes text for HTML. `stripEmoji` removes emoji from text that
277
+ is spoken, tidying the spaces they leave. `fileExtension` reads a lowercased
278
+ extension, or `undefined`.
279
+
280
+ ```ts
281
+ import { escapeHtml, fileExtension, stripEmoji } from 'hanzio'
282
+
283
+ `<title>${escapeHtml(name)}</title>`
284
+ stripEmoji('Hi there 👋, welcome') // "Hi there, welcome"
285
+ fileExtension('uploads/Photo.JPG') // "jpg"
286
+ fileExtension('README') ?? 'bin' // "bin"
287
+ ```
288
+
289
+ `toJsonValue` normalizes a value the way a JSON round trip does (dropping
290
+ `undefined` properties), so it can go into a JSON column.
291
+
292
+ ```ts
293
+ import { toJsonValue } from 'hanzio'
294
+
295
+ toJsonValue({ ok: true, error: undefined }) // { ok: true }
296
+ ```
297
+
260
298
  ### Promise
261
299
 
262
300
  `tryCatch` converts a promise, or a function that may throw synchronously, into
@@ -300,6 +338,19 @@ import { pMap } from 'hanzio'
300
338
  const users = await pMap(ids, (id) => fetchUser(id), { concurrency: 4 })
301
339
  ```
302
340
 
341
+ `createTaskPool` bounds work that arrives over time, such as pages a crawler
342
+ discovers. The first failure or an abort discards queued tasks, and `drain`
343
+ rejects with it once the active tasks settle. Pass `stopOnError: false` to keep
344
+ going after a failed task.
345
+
346
+ ```ts
347
+ import { createTaskPool } from 'hanzio'
348
+
349
+ const pool = createTaskPool({ concurrency: 4, signal })
350
+ for await (const page of discoverPages(signal)) void pool.run(() => scrape(page, signal))
351
+ await pool.drain()
352
+ ```
353
+
303
354
  `promiseTimeout` resolves after a delay (and rejects early if its signal
304
355
  aborts). `backgroundPromise` runs async work and reports success or failure
305
356
  through hooks; `backgroundPromiseSync` schedules it in a microtask.
@@ -773,6 +824,17 @@ type UserId = GenericId<'User'>
773
824
  const userId: UserId = UserId.parse('usr_123')
774
825
  ```
775
826
 
827
+ `zOptionalE164` is a form field for an optional phone number: `''` and `null`
828
+ parse to `null`, anything else must match `E164_REGEX`.
829
+
830
+ ```ts
831
+ import { E164_REGEX, zOptionalE164 } from 'hanzio/zod'
832
+
833
+ zOptionalE164.parse('+4512345678') // '+4512345678'
834
+ zOptionalE164.parse('') // null
835
+ z.string().regex(E164_REGEX, 'Use E.164, e.g. +4512345678')
836
+ ```
837
+
776
838
  `zSingleOrArray` accepts either one schema value or an array of schema values.
777
839
 
778
840
  ```ts
@@ -1160,6 +1222,55 @@ Cloudflare bindings without Node compatibility. See the
1160
1222
  [migration notes](src/secrets/README.md#migration-notes) for stricter environment
1161
1223
  validation and browser exposure rules.
1162
1224
 
1225
+ ### Redis (`hanzio/redis`)
1226
+
1227
+ For Bun servers. `createManagedRedis` keeps one command connection and one
1228
+ subscriber connection that reconnect on their own; subscriptions are shared
1229
+ per channel and come back after a reconnect. `createRedisConcurrencyLimit` caps
1230
+ how many calls run at once across every process, using Redis' clock, and runs
1231
+ uncapped when Redis is unreachable. See the [Redis docs](src/redis/README.md).
1232
+
1233
+ ```ts
1234
+ import { createManagedRedis, createRedisConcurrencyLimit } from 'hanzio/redis'
1235
+
1236
+ const redis = createManagedRedis({ url: () => getSecret('REDIS_URL') })
1237
+ const unsubscribe = await redis.subscribe('events', (message) => handle(message))
1238
+
1239
+ const withLimit = createRedisConcurrencyLimit({
1240
+ key: 'openai',
1241
+ max: 20,
1242
+ slotTtlMs: 30_000,
1243
+ send: (command, args) => redis.getClient().send(command, args)
1244
+ })
1245
+ await withLimit(() => callOpenAi(request), signal)
1246
+ ```
1247
+
1248
+ ### HTTP (`hanzio/http`)
1249
+
1250
+ `withSseKeepAlive(response)` adds `:` comments to a quiet SSE stream and
1251
+ headers that stop proxies from buffering it. See the [HTTP docs](src/http/README.md).
1252
+
1253
+ ```ts
1254
+ import { withSseKeepAlive } from 'hanzio/http'
1255
+
1256
+ return withSseKeepAlive(createUIMessageStreamResponse({ stream }))
1257
+ ```
1258
+
1259
+ ### Testing (`hanzio/testing`)
1260
+
1261
+ Helpers for `bun:test`: a `mock.module` that can be undone, a deep Prisma mock,
1262
+ a minimal `window` for browser-facing code, and a contract suite for API
1263
+ clients built on `ConnectionAuthCache`. See the [testing docs](src/testing/README.md).
1264
+
1265
+ ```ts
1266
+ import { createPrismaMock, ModuleMocker } from 'hanzio/testing'
1267
+
1268
+ const prismaMock = createPrismaMock<typeof prisma>(mock)
1269
+ const moduleMocker = new ModuleMocker()
1270
+ await moduleMocker.mock(posthogModulePath, () => ({ captureException: mock() }))
1271
+ afterAll(() => moduleMocker.clear())
1272
+ ```
1273
+
1163
1274
  ## Development
1164
1275
 
1165
1276
  ```bash
@@ -0,0 +1,61 @@
1
+ export type ConnectionAuthToken = {
2
+ token: string;
3
+ expiresAt: number;
4
+ };
5
+ /**
6
+ * Shares one login per connection across every client instance in the process,
7
+ * for APIs that exchange credentials for a short-lived token.
8
+ *
9
+ * - Concurrent callers share one exchange; aborting one caller's wait leaves the
10
+ * exchange running for the others.
11
+ * - Changing any of `authInputs` (rotated password, new client id) retires the
12
+ * old token: a retired client may finish its request but cannot store its
13
+ * token. Inputs are kept only as an HMAC under a random per-cache key, so
14
+ * the cache never holds, and cannot be used to guess, the credentials.
15
+ * - On a 401, `reject(token)` drops the refused token, unless a newer one has
16
+ * already replaced it, and the next `get()` exchanges again.
17
+ * - `get({ fresh: true })` always exchanges, for an explicit credential check;
18
+ * its failure never falls back to the previous token.
19
+ * - At most `maxEntries` connections are kept, least recently used first;
20
+ * connections with an exchange in flight are never evicted.
21
+ *
22
+ * @example
23
+ * const auth = new ConnectionAuthCache()
24
+ *
25
+ * class ShopApi {
26
+ * private readonly token
27
+ * constructor(credentials: { connectionId: string; apiKey: string; secret: string }) {
28
+ * this.token = auth.bind({
29
+ * connectionId: credentials.connectionId,
30
+ * authInputs: [credentials.apiKey, credentials.secret],
31
+ * refreshMarginMs: 60_000,
32
+ * exchange: () => login(credentials) // resolves { token, expiresAt }
33
+ * })
34
+ * }
35
+ * async read(signal?: AbortSignal) {
36
+ * const current = await this.token.get({ signal })
37
+ * // On a 401: this.token.reject(current), then retry once with this.token.get().
38
+ * }
39
+ * }
40
+ */
41
+ export declare class ConnectionAuthCache {
42
+ private readonly maxEntries;
43
+ private readonly key;
44
+ private readonly entries;
45
+ constructor(maxEntries?: number);
46
+ get size(): number;
47
+ clear(): void;
48
+ private prune;
49
+ bind(input: {
50
+ connectionId: string;
51
+ authInputs: readonly (string | number)[];
52
+ refreshMarginMs: number;
53
+ exchange: () => Promise<ConnectionAuthToken>;
54
+ }): {
55
+ get: (options?: {
56
+ fresh?: boolean | undefined;
57
+ signal?: AbortSignal | undefined;
58
+ } | undefined) => Promise<ConnectionAuthToken>;
59
+ reject(token: ConnectionAuthToken): void;
60
+ };
61
+ }
@@ -0,0 +1,30 @@
1
+ import { z } from 'zod';
2
+ /** Thrown for bad argv. The CLI prints it with a `--help` hint and exits 2. */
3
+ export declare class UsageError extends Error {
4
+ }
5
+ /** One key of an input or globals object, read as a flag or a positional. */
6
+ export type Field = {
7
+ key: string;
8
+ flag: string;
9
+ kind: 'string' | 'number' | 'boolean';
10
+ multiple: boolean;
11
+ positional: boolean;
12
+ optional: boolean;
13
+ hasDefault: boolean;
14
+ defaultValue: unknown;
15
+ description?: string;
16
+ alias?: string;
17
+ env?: string;
18
+ choices?: string[];
19
+ };
20
+ export declare function fieldsOf(schema: z.ZodObject | undefined): Field[];
21
+ /**
22
+ * Builds the raw object Zod validates: flag values and positionals by key,
23
+ * numbers coerced, `.meta({ env })` as fallback, and a missing required
24
+ * boolean or array read as `false` or `[]`.
25
+ */
26
+ export declare function readFields(fields: Field[], values: Record<string, string | boolean | (string | boolean)[] | undefined>, positionals: string[], env: Record<string, string | undefined>): Record<string, unknown>;
27
+ export declare function fieldLabel(field: Field): string;
28
+ export declare function fieldHelp(field: Field): string;
29
+ /** Closest candidate within a few edits, for "Did you mean ...?" hints. */
30
+ export declare function closest(input: string, candidates: readonly string[]): string | undefined;
@@ -0,0 +1,122 @@
1
+ import type { z } from 'zod';
2
+ declare module 'zod' {
3
+ interface GlobalMeta {
4
+ /** hanzio/cli: read this key from positional arguments, in key order. */
5
+ positional?: boolean;
6
+ /** hanzio/cli: one-letter short flag, e.g. `'r'` for `-r`. */
7
+ alias?: string;
8
+ /** hanzio/cli: environment variable read when the flag is missing. */
9
+ env?: string;
10
+ }
11
+ }
12
+ declare const commandKey: unique symbol;
13
+ /**
14
+ * An expected failure: the CLI prints the message without a stack trace and
15
+ * exits with `exitCode` (default 1).
16
+ */
17
+ export declare class CliError extends Error {
18
+ readonly exitCode: number;
19
+ constructor(message: string, options?: {
20
+ exitCode?: number;
21
+ cause?: unknown;
22
+ });
23
+ }
24
+ export type CliWriter = {
25
+ write(text: string): unknown;
26
+ };
27
+ /** Where `cli.run()` writes and reads env fallbacks; defaults to `process`. */
28
+ export type CliIO = {
29
+ stdout?: CliWriter;
30
+ stderr?: CliWriter;
31
+ env?: Record<string, string | undefined>;
32
+ };
33
+ export type CommandContext<C = unknown> = {
34
+ /** What `defineCli({ context })` returned. */
35
+ readonly context: C;
36
+ /** The validated `defineCli({ globals })` values. */
37
+ readonly globals: Record<string, unknown>;
38
+ /** Command names from the root, e.g. `['deal', 'list']`. */
39
+ readonly path: readonly string[];
40
+ /** Every argument after the command names, unparsed. */
41
+ readonly argv: readonly string[];
42
+ /** Arguments after `--`. */
43
+ readonly passthrough: readonly string[];
44
+ /** `--json` was passed (as a global or a command option). */
45
+ readonly json: boolean;
46
+ readonly stdout: CliWriter;
47
+ readonly stderr: CliWriter;
48
+ readonly env: Record<string, string | undefined>;
49
+ /** Set to exit non-zero without printing an error. */
50
+ exitCode: number;
51
+ };
52
+ export type Command<C = unknown> = {
53
+ readonly [commandKey]: true;
54
+ readonly description?: string;
55
+ readonly input?: AnyObject;
56
+ readonly raw?: boolean;
57
+ readonly run?: (input: never, ctx: CommandContext<C>) => unknown;
58
+ readonly output?: (result: never, ctx: CommandContext<C>) => unknown;
59
+ readonly load?: LazyCommand<C>;
60
+ };
61
+ /** `() => import('./commands/heavy')`: loaded only when the command runs. */
62
+ export type LazyCommand<C = unknown> = () => Promise<Command<C> | {
63
+ default: Command<C>;
64
+ }>;
65
+ export type CommandGroup<C = unknown> = {
66
+ readonly [name: string]: CommandNode<C>;
67
+ };
68
+ export type CommandNode<C = unknown> = Command<C> | LazyCommand<C> | CommandGroup<C>;
69
+ type AnyObject = z.ZodObject<z.core.$ZodShape, z.core.$ZodObjectConfig>;
70
+ type EmptyObject = z.ZodObject<Record<never, never>>;
71
+ export type Cli = {
72
+ readonly name: string;
73
+ /** Runs the CLI and resolves to its exit code: 0 ok, 1 failure, 2 usage error. */
74
+ run(argv?: readonly string[], io?: CliIO): Promise<number>;
75
+ };
76
+ export type CliConfig<G extends AnyObject, C> = {
77
+ name: string;
78
+ version?: string;
79
+ description?: string;
80
+ /** Options accepted before or after any command, e.g. `--org`. */
81
+ globals?: G;
82
+ /** Runs before every command; throw a `CliError` to refuse it. */
83
+ guard?: (call: {
84
+ path: readonly string[];
85
+ input: Record<string, unknown>;
86
+ globals: z.output<G>;
87
+ context: C;
88
+ }) => unknown;
89
+ /** A command (single-command CLI) or nested groups of commands. */
90
+ commands: CommandNode<C>;
91
+ } & (undefined extends C ? {
92
+ context?: ContextFunction<G, C>;
93
+ } : {
94
+ context: ContextFunction<G, C>;
95
+ });
96
+ /** Builds the `ctx.context` every command receives. */
97
+ type ContextFunction<G extends AnyObject, C> = (globals: z.output<G>) => C | Promise<C>;
98
+ /**
99
+ * A command. `input` is a Zod object whose keys become `--kebab-case` flags,
100
+ * or positionals with `.meta({ positional: true })`. `run`'s return value is
101
+ * printed by `output` (default: strings as-is, anything else as JSON).
102
+ */
103
+ export declare function defineCommand<S extends AnyObject = EmptyObject, R = unknown, C = unknown>(definition: {
104
+ description?: string;
105
+ input?: S;
106
+ run: (input: z.output<S>, ctx: CommandContext<C>) => R;
107
+ output?: (result: Awaited<R>, ctx: CommandContext<C>) => unknown;
108
+ }): Command<C>;
109
+ /** A command that skips parsing: everything after its name is in `ctx.argv`. */
110
+ export declare function defineCommand<R = unknown, C = unknown>(definition: {
111
+ description?: string;
112
+ raw: true;
113
+ run: (input: Record<string, never>, ctx: CommandContext<C>) => R;
114
+ output?: (result: Awaited<R>, ctx: CommandContext<C>) => unknown;
115
+ }): Command<C>;
116
+ /** A lazy command whose description shows in help before it loads. */
117
+ export declare function defineCommand<C = unknown>(definition: {
118
+ description?: string;
119
+ load: LazyCommand<C>;
120
+ }): Command<C>;
121
+ export declare function defineCli<G extends AnyObject = EmptyObject, C = undefined>(config: CliConfig<G, C>): Cli;
122
+ export {};
@@ -17,6 +17,12 @@ export declare function formatPercent(value: number, locale: string, options?: I
17
17
  * `{ maximumFractionDigits: 0 }` for whole-unit prices.
18
18
  */
19
19
  export declare function formatCurrency(amount: number, currency: string, locale: string, options?: Intl.NumberFormatOptions): string;
20
+ /**
21
+ * A price: whole amounts without minor units (`'2.500 kr.'`), fractional ones
22
+ * with the currency's usual digits (`'199,95 kr.'`) instead of rounding them
23
+ * into a price nobody charges.
24
+ */
25
+ export declare function formatPrice(amount: number, currency: string, locale: string): string;
20
26
  /** Short compact notation: `1234` -> `"1.2K"` (en-US), `"1,2 t"` (da-DK). */
21
27
  export declare function formatCompactNumber(value: number, locale: string): string;
22
28
  export type FormatBytesOptions = {
@@ -88,8 +94,79 @@ export declare function formatWait(value: DateInput, locale: string, options?: {
88
94
  * and friends combine with that default.
89
95
  */
90
96
  export declare function formatDateRange(start: DateInput, end: DateInput, locale: string, options?: Intl.DateTimeFormatOptions): string;
97
+ declare const DATE_PRESETS: {
98
+ readonly 'DD/MM/YYYY': {
99
+ readonly day: "2-digit";
100
+ readonly month: "2-digit";
101
+ readonly year: "numeric";
102
+ };
103
+ readonly 'DD/MM/YYYY HH:mm': {
104
+ readonly day: "2-digit";
105
+ readonly month: "2-digit";
106
+ readonly year: "numeric";
107
+ readonly hour: "2-digit";
108
+ readonly minute: "2-digit";
109
+ readonly hourCycle: "h23";
110
+ };
111
+ readonly 'HH:mm': {
112
+ readonly hour: "2-digit";
113
+ readonly minute: "2-digit";
114
+ readonly hourCycle: "h23";
115
+ };
116
+ readonly ddd: {
117
+ readonly weekday: "short";
118
+ };
119
+ readonly 'D MMM': {
120
+ readonly day: "numeric";
121
+ readonly month: "short";
122
+ };
123
+ readonly 'D MMM YYYY': {
124
+ readonly day: "numeric";
125
+ readonly month: "short";
126
+ readonly year: "numeric";
127
+ };
128
+ readonly 'dddd, MMMM D': {
129
+ readonly weekday: "long";
130
+ readonly day: "numeric";
131
+ readonly month: "long";
132
+ };
133
+ readonly 'MMMM YYYY': {
134
+ readonly month: "long";
135
+ readonly year: "numeric";
136
+ };
137
+ };
138
+ /**
139
+ * A named date layout. The names read like dayjs tokens, but the output
140
+ * follows the locale: `'D MMM'` is "5 Mar" in en-GB and "Mar 5" in en-US.
141
+ */
142
+ export type DatePreset = keyof typeof DATE_PRESETS;
143
+ /** A date in one of the `DatePreset` layouts, in local time. */
144
+ export declare function formatDate(date: DateInput, preset: DatePreset, locale: string): string;
145
+ /** Medium date and short time: "5 Mar 2026, 14:30" (en-GB). */
146
+ export declare function formatDateTime(date: DateInput, locale: string): string;
147
+ /**
148
+ * Compact activity timestamp for a list row: the time today, "yesterday",
149
+ * else the date (with the year when it is not this year). Unparseable input
150
+ * returns `''`.
151
+ */
152
+ export declare function formatCompactTime(date: DateInput, locale: string, options?: {
153
+ now?: DateInput;
154
+ }): string;
155
+ /**
156
+ * Day-divider label for a chat timeline: "today", "yesterday", else weekday
157
+ * and date (with the year when it is not this year). Unparseable input
158
+ * returns `''`.
159
+ */
160
+ export declare function formatDayDivider(date: DateInput, locale: string, options?: {
161
+ now?: DateInput;
162
+ }): string;
91
163
  /** "a, b, and c". Falls back to a comma join where `Intl.ListFormat` is missing. */
92
164
  export declare function formatList(items: readonly string[], locale: string, options?: Intl.ListFormatOptions): string;
165
+ /**
166
+ * Stands for "no value" in a cell or detail row that must keep its height, so
167
+ * absent values cannot drift between a hyphen and an em dash. Not translated.
168
+ */
169
+ export declare const EM_DASH = "\u2014";
93
170
  /** Badge count: `150` -> `"99+"`. Negative or NaN is `"0"`. */
94
171
  export declare function formatCappedCount(count: number, max?: number): string;
95
172
  /**
@@ -99,4 +176,18 @@ export declare function formatCappedCount(count: number, max?: number): string;
99
176
  export declare function pluralize(count: number, forms: Partial<Record<Intl.LDMLPluralRule, string>> & {
100
177
  other: string;
101
178
  }, locale: string): string;
179
+ /**
180
+ * The date formatters with the locale read on every call, for apps whose
181
+ * locale lives in their i18n runtime:
182
+ * `export const fmt = withLocale(() => uiLocales.tag(appI18n.locale()))`.
183
+ */
184
+ export declare function withLocale(getLocale: () => string): {
185
+ formatDate: (date: DateInput, preset: "D MMM" | "D MMM YYYY" | "DD/MM/YYYY" | "DD/MM/YYYY HH:mm" | "HH:mm" | "MMMM YYYY" | "ddd" | "dddd, MMMM D") => string;
186
+ formatDateTime: (date: DateInput) => string;
187
+ formatCompactTime: (date: DateInput) => string;
188
+ formatDayDivider: (date: DateInput) => string;
189
+ formatRelativeTime: (date: DateInput, options?: FormatRelativeTimeOptions | undefined) => string;
190
+ formatNumber: (value: number, options?: Intl.NumberFormatOptions | undefined) => string;
191
+ formatPrice: (amount: number, currency: string) => string;
192
+ };
102
193
  export {};
@@ -0,0 +1,19 @@
1
+ /**
2
+ * A stored phone number as a person reads it. `country` is the READER's ISO
3
+ * 3166-1 alpha-2 country (the organization's, say), not the number's: a local
4
+ * number prints the local way, any other with its country code.
5
+ *
6
+ * ```ts
7
+ * formatPhone('+4520304050', 'DK') // '20 30 40 50'
8
+ * formatPhone('+4520304050') // '+45 20 30 40 50'
9
+ * formatPhone('+4915112345678', 'DK') // '+49 1511 2345678'
10
+ * ```
11
+ *
12
+ * Nothing is lost: an unparseable value comes back trimmed but verbatim, and
13
+ * the national form is used only for a VALID number (`+4512345678` parses as
14
+ * DK but is not a real number, so it keeps its `+45`). A country
15
+ * libphonenumber cannot dial falls back to international. Empty input is
16
+ * `''`; the caller decides what stands in for "no number". Use the raw E.164
17
+ * value in `tel:` links.
18
+ */
19
+ export declare function formatPhone(value: string | null | undefined, country?: string | null): string;
@@ -0,0 +1,14 @@
1
+ export declare const SSE_KEEPALIVE_COMMENT: NodeJS.NonSharedUint8Array;
2
+ /** Passes bytes through, writing a comment first and after every `intervalMs` of silence. */
3
+ export declare function createSseKeepAliveTransform(intervalMs?: number, timers?: {
4
+ setTimeout: (fn: () => void, ms: number) => unknown;
5
+ clearTimeout: (id: unknown) => void;
6
+ }): TransformStream<Uint8Array<ArrayBufferLike>, Uint8Array<ArrayBufferLike>>;
7
+ /**
8
+ * Copies an SSE response with keep-alive comments in its body and headers that
9
+ * stop proxies from buffering it.
10
+ *
11
+ * @example
12
+ * return withSseKeepAlive(createUIMessageStreamResponse({ stream }))
13
+ */
14
+ export declare function withSseKeepAlive(response: Response, intervalMs?: number): Response;
@@ -0,0 +1,47 @@
1
+ import { type Messages } from '.';
2
+ /** How a catalog's runtime reads its messages: plural syntax and escaping. */
3
+ export type CatalogRuntime = 'vue-i18n' | 'i18next' | 'plain';
4
+ export type CatalogConfig = {
5
+ /** Shown in check output. */
6
+ name: string;
7
+ /** Folder of `<locale>.json` files, relative to the config file. */
8
+ dir: string;
9
+ runtime: CatalogRuntime;
10
+ /** Where `hanzio-i18n generate` writes the key → placeholder map. */
11
+ generatedTypes?: string;
12
+ /** Exported type name in `generatedTypes`. Default `TranslationParams`. */
13
+ generatedTypeName?: string;
14
+ /**
15
+ * Source folders scanned for key references. Set them and the check also
16
+ * fails on keys nothing references. Leave unset when keys are built at
17
+ * runtime from a prefix (`t(\`widget.${key}\`)` everywhere), since every key
18
+ * would read as used.
19
+ */
20
+ sourceRoots?: readonly string[];
21
+ /**
22
+ * Key prefixes composed from data rather than written in source (labels
23
+ * sent by the backend, for example), kept even when nothing references them.
24
+ * A prefix a template literal spells out is found by the scan already.
25
+ */
26
+ dynamicKeyPrefixes?: readonly string[];
27
+ };
28
+ export type CatalogIssue = {
29
+ catalog: string;
30
+ locale?: string;
31
+ message: string;
32
+ };
33
+ /**
34
+ * Validates one parsed JSON catalog: every leaf a string, no empty or dotted
35
+ * key segments, NFC-normalized text and no mojibake.
36
+ */
37
+ export declare function parseMessages(value: unknown, catalog: string, locale: string): {
38
+ messages?: Messages;
39
+ issues: CatalogIssue[];
40
+ };
41
+ export declare function flattenCatalog(messages: Messages): Map<string, string>;
42
+ /**
43
+ * The TypeScript key → placeholder-name map for `TypedTranslate`, generated
44
+ * from the base catalog. Plural messages also take `count`: vue-i18n's
45
+ * `a | b | c` forms, and i18next's `key_one`/`key_other` (merged into `key`).
46
+ */
47
+ export declare function generateCatalogTypes(catalog: Pick<CatalogConfig, 'runtime' | 'generatedTypeName'>, baseMessages: Messages, command?: string): string;
@@ -0,0 +1,45 @@
1
+ import { type CatalogConfig, type CatalogIssue } from './catalog';
2
+ import { type ContentRulesConfig } from './content-rules';
3
+ export type { CatalogConfig, CatalogIssue, CatalogRuntime } from './catalog';
4
+ export { generateCatalogTypes, parseMessages } from './catalog';
5
+ export type { ContentRulesConfig } from './content-rules';
6
+ export type I18nConfig<Locale extends string = string, Catalog extends string = string> = {
7
+ /** The source-of-truth locale; usually `'en'`. */
8
+ baseLocale: NoInfer<Locale>;
9
+ /** Every shipped locale, the base included. Each needs a catalog file. */
10
+ locales: readonly Locale[];
11
+ catalogs: readonly (CatalogConfig & {
12
+ name: Catalog;
13
+ })[];
14
+ /**
15
+ * Groups of catalogs merged into one runtime (`{ ...platform, ...widget }`),
16
+ * so their top-level keys must not overlap.
17
+ */
18
+ disjointNamespaces?: readonly (readonly NoInfer<Catalog>[])[];
19
+ rules?: ContentRulesConfig<NoInfer<Locale>>;
20
+ };
21
+ /**
22
+ * Types an `i18n.config.ts` default export: `baseLocale`, the catalog names
23
+ * in `disjointNamespaces` and the locales in `rules.identical.allowKeys` must
24
+ * be ones the config declares.
25
+ */
26
+ export declare function defineI18nConfig<const Locale extends string, const Catalog extends string>(config: I18nConfig<Locale, Catalog>): I18nConfig<Locale, Catalog>;
27
+ /**
28
+ * Every problem in the configured catalogs, empty when they are valid. Paths
29
+ * in `config` are relative to `root`.
30
+ */
31
+ export declare function validateCatalogs(config: I18nConfig, options: {
32
+ root: string;
33
+ checkGeneratedTypes?: boolean;
34
+ }): Promise<CatalogIssue[]>;
35
+ /**
36
+ * Writes each catalog's `generatedTypes` from its base locale. Returns the
37
+ * written paths, or the issues that stopped generation (nothing is written
38
+ * when any base catalog is invalid).
39
+ */
40
+ export declare function writeCatalogTypes(config: I18nConfig, options: {
41
+ root: string;
42
+ }): {
43
+ written: string[];
44
+ issues: CatalogIssue[];
45
+ };
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};