@lovrozagar/honey 0.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 (131) hide show
  1. package/README.md +51 -0
  2. package/docs/api-ref/go.md +399 -0
  3. package/docs/api-ref/python.md +399 -0
  4. package/docs/api-ref/rust.md +399 -0
  5. package/docs/api-ref/typescript.md +399 -0
  6. package/docs/invalidate-spec.md +303 -0
  7. package/docs/sdk.md +722 -0
  8. package/package.json +120 -0
  9. package/src/accepts.ts +78 -0
  10. package/src/body-limit.ts +100 -0
  11. package/src/build/index.ts +174 -0
  12. package/src/cli-go/auth.go +15 -0
  13. package/src/cli-go/config.go +165 -0
  14. package/src/cli-go/errors.go +57 -0
  15. package/src/cli-go/multipart.go +55 -0
  16. package/src/cli-go/output.go +109 -0
  17. package/src/cli-go/root.go +30 -0
  18. package/src/cli-go/stream.go +88 -0
  19. package/src/cli-go/version.go +5 -0
  20. package/src/cli.ts +204 -0
  21. package/src/client/error.ts +27 -0
  22. package/src/client/http.ts +502 -0
  23. package/src/client/index.ts +127 -0
  24. package/src/client/sdk.ts +265 -0
  25. package/src/client/sse.ts +102 -0
  26. package/src/client/types.ts +211 -0
  27. package/src/client/ws.ts +115 -0
  28. package/src/client-go/errors.go +207 -0
  29. package/src/client-go/invalidation.go +373 -0
  30. package/src/client-go/realtime.go +317 -0
  31. package/src/client-go/result.go +20 -0
  32. package/src/client-go/runtime.go +413 -0
  33. package/src/client-go/sse.go +149 -0
  34. package/src/client-go/transport.go +452 -0
  35. package/src/client-go/ws.go +104 -0
  36. package/src/client-mcp/README.md +10 -0
  37. package/src/client-mcp/server.ts +94 -0
  38. package/src/client-mcp/types.ts +19 -0
  39. package/src/client-python/_errors.py +115 -0
  40. package/src/client-python/_invalidation.py +578 -0
  41. package/src/client-python/_realtime.py +156 -0
  42. package/src/client-python/_runtime.py +469 -0
  43. package/src/client-python/_sse.py +99 -0
  44. package/src/client-python/_transport.py +335 -0
  45. package/src/client-python/_ws.py +131 -0
  46. package/src/client-python/py.typed +0 -0
  47. package/src/client-rust/errors.rs +200 -0
  48. package/src/client-rust/invalidation.rs +441 -0
  49. package/src/client-rust/invalidation_sync.rs +306 -0
  50. package/src/client-rust/realtime.rs +695 -0
  51. package/src/client-rust/result.rs +22 -0
  52. package/src/client-rust/runtime.rs +547 -0
  53. package/src/client-rust/runtime_sync.rs +452 -0
  54. package/src/client-rust/sse.rs +167 -0
  55. package/src/client-rust/ws.rs +143 -0
  56. package/src/cloudflare/realtime.ts +236 -0
  57. package/src/codegen-go-cli.ts +1180 -0
  58. package/src/codegen-go.ts +1010 -0
  59. package/src/codegen-hash.ts +8 -0
  60. package/src/codegen-ir.ts +517 -0
  61. package/src/codegen-loaders.ts +53 -0
  62. package/src/codegen-mcp.ts +364 -0
  63. package/src/codegen-python.ts +1028 -0
  64. package/src/codegen-rust.ts +1652 -0
  65. package/src/codegen-sanitize.ts +67 -0
  66. package/src/codegen-schema-naming.ts +146 -0
  67. package/src/codegen.ts +5015 -0
  68. package/src/context.ts +210 -0
  69. package/src/cookie-sign.ts +58 -0
  70. package/src/cookie.ts +89 -0
  71. package/src/cors.ts +96 -0
  72. package/src/crypto.ts +19 -0
  73. package/src/csrf.ts +54 -0
  74. package/src/curl-logger.ts +235 -0
  75. package/src/detect-runtime.ts +19 -0
  76. package/src/error.ts +52 -0
  77. package/src/errors.ts +87 -0
  78. package/src/etag.ts +74 -0
  79. package/src/feature-load.ts +23 -0
  80. package/src/go-type-emitter.ts +404 -0
  81. package/src/honey-response.ts +354 -0
  82. package/src/i18n-slot.ts +20 -0
  83. package/src/i18n.ts +234 -0
  84. package/src/index.ts +3843 -0
  85. package/src/init.ts +149 -0
  86. package/src/input.ts +8 -0
  87. package/src/ip-restrict.ts +192 -0
  88. package/src/logger.ts +149 -0
  89. package/src/middleware.ts +199 -0
  90. package/src/node-request.ts +240 -0
  91. package/src/node.ts +212 -0
  92. package/src/openapi/register.ts +19 -0
  93. package/src/openapi/scalar.ts +33 -0
  94. package/src/openapi/spec-factory.ts +46 -0
  95. package/src/openapi/spec.ts +31 -0
  96. package/src/openapi/swagger.ts +34 -0
  97. package/src/plugin.ts +771 -0
  98. package/src/powered-by.ts +17 -0
  99. package/src/pretty-json.ts +39 -0
  100. package/src/proxy.ts +155 -0
  101. package/src/python-type-emitter.ts +144 -0
  102. package/src/realtime/buffer.ts +101 -0
  103. package/src/realtime/bus.ts +93 -0
  104. package/src/realtime/longpoll.ts +148 -0
  105. package/src/realtime/protocol.ts +128 -0
  106. package/src/realtime/route.ts +104 -0
  107. package/src/realtime/runtime.ts +430 -0
  108. package/src/request-id.ts +23 -0
  109. package/src/request-to-curl.ts +34 -0
  110. package/src/response.ts +414 -0
  111. package/src/rust-type-emitter.ts +412 -0
  112. package/src/secure-headers.ts +69 -0
  113. package/src/serve-register.ts +8 -0
  114. package/src/serve-slot.ts +20 -0
  115. package/src/serve.ts +141 -0
  116. package/src/server-timing.ts +66 -0
  117. package/src/static.ts +60 -0
  118. package/src/telemetry/otel.ts +139 -0
  119. package/src/testing.ts +114 -0
  120. package/src/timeout.ts +36 -0
  121. package/src/tree.ts +464 -0
  122. package/src/ts-type-emitter.ts +106 -0
  123. package/src/type-emitter.ts +524 -0
  124. package/src/type-extractor.ts +749 -0
  125. package/src/types.ts +638 -0
  126. package/src/validation.ts +300 -0
  127. package/src/ws/bun.ts +89 -0
  128. package/src/ws/cloudflare.ts +133 -0
  129. package/src/ws/deno.ts +109 -0
  130. package/src/ws/node.ts +161 -0
  131. package/src/yaml.ts +80 -0
@@ -0,0 +1,354 @@
1
+ /** NodeRequest sets this so HoneyRes can skip `new Response()` on Node. */
2
+ export const NODE_OUTBOUND = Symbol.for("honey.nodeOut")
3
+
4
+ const HONEY_RESPONSE = Symbol.for("honey.response")
5
+
6
+ export function isNodeOutbound(req: Request): boolean {
7
+ return (req as unknown as Record<symbol, unknown>)[NODE_OUTBOUND] === true
8
+ }
9
+
10
+ export function isHoneyResponse(value: unknown): value is HoneyResponse {
11
+ return typeof value === "object" && value !== null && HONEY_RESPONSE in value
12
+ }
13
+
14
+ /**
15
+ * Headers view over a plain map. `get`/`has` stay on the record.
16
+ * A real `Headers` is built only if something iterates or mutates.
17
+ */
18
+ export class HoneyOutHeaders {
19
+ #map: Record<string, string | string[]>
20
+ #native: Headers | null = null
21
+
22
+ constructor(map: Record<string, string | string[]>) {
23
+ this.#map = map
24
+ }
25
+
26
+ append(name: string, value: string): void {
27
+ this.#ensureNative().append(name, value)
28
+ }
29
+
30
+ delete(name: string): void {
31
+ this.#ensureNative().delete(name)
32
+ }
33
+
34
+ get(name: string): string | null {
35
+ if (this.#native) return this.#native.get(name)
36
+ const raw = this.#map[name.toLowerCase()]
37
+ if (raw === undefined) return null
38
+ return Array.isArray(raw) ? raw.join(", ") : raw
39
+ }
40
+
41
+ getSetCookie(): string[] {
42
+ if (this.#native) return this.#native.getSetCookie()
43
+ const raw = this.#map["set-cookie"]
44
+ if (raw === undefined) return []
45
+ return Array.isArray(raw) ? raw : [raw]
46
+ }
47
+
48
+ has(name: string): boolean {
49
+ if (this.#native) return this.#native.has(name)
50
+ return this.#map[name.toLowerCase()] !== undefined
51
+ }
52
+
53
+ set(name: string, value: string): void {
54
+ this.#ensureNative().set(name, value)
55
+ }
56
+
57
+ forEach(callback: (value: string, key: string, parent: Headers) => void, thisArg?: unknown): void {
58
+ this.#ensureNative().forEach(callback, thisArg)
59
+ }
60
+
61
+ keys(): IterableIterator<string> {
62
+ return this.#ensureNative().keys()
63
+ }
64
+
65
+ values(): IterableIterator<string> {
66
+ return this.#ensureNative().values()
67
+ }
68
+
69
+ entries(): IterableIterator<[string, string]> {
70
+ return this.#ensureNative().entries()
71
+ }
72
+
73
+ [Symbol.iterator](): IterableIterator<[string, string]> {
74
+ if (this.#native) return this.#native[Symbol.iterator]()
75
+ return iterateRecord(this.#map)
76
+ }
77
+
78
+ get [Symbol.toStringTag](): string {
79
+ return "Headers"
80
+ }
81
+
82
+ toRecord(): Record<string, string | string[]> {
83
+ if (!this.#native) return this.#map
84
+ const out: Record<string, string | string[]> = {}
85
+ this.#native.forEach((value, key) => {
86
+ if (key === "set-cookie") return
87
+ out[key] = value
88
+ })
89
+ const cookies = this.#native.getSetCookie()
90
+ if (cookies.length > 0) out["set-cookie"] = cookies
91
+ return out
92
+ }
93
+
94
+ #ensureNative(): Headers {
95
+ if (this.#native) return this.#native
96
+ const headers = new Headers()
97
+ for (const [name, value] of iterateRecord(this.#map)) {
98
+ headers.append(name, value)
99
+ }
100
+ this.#native = headers
101
+ return headers
102
+ }
103
+ }
104
+
105
+ function* iterateRecord(map: Record<string, string | string[]>): IterableIterator<[string, string]> {
106
+ for (const key in map) {
107
+ const value = map[key]
108
+ if (Array.isArray(value)) {
109
+ for (const item of value) yield [key, item]
110
+ } else if (value !== undefined) {
111
+ yield [key, value]
112
+ }
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Response-shaped bag for Node. Avoids `new Response()` until something
118
+ * needs a real Fetch body stream (cors/etag wrap, clone).
119
+ */
120
+ export class HoneyResponse {
121
+ readonly [HONEY_RESPONSE] = true
122
+ readonly ok: boolean
123
+ readonly redirected = false
124
+ readonly status: number
125
+ readonly statusText: string
126
+ readonly type = "default" as ResponseType
127
+ readonly url = ""
128
+
129
+ #headerMap: Record<string, string | string[]>
130
+ #headers: HoneyOutHeaders | null = null
131
+ #raw: string | Uint8Array | null
132
+ #stream: ReadableStream<Uint8Array> | null | undefined
133
+ #bodyUsed = false
134
+
135
+ constructor(init: {
136
+ headers: Record<string, string | string[]>
137
+ raw?: string | Uint8Array | null
138
+ status: number
139
+ statusText?: string
140
+ stream?: ReadableStream<Uint8Array> | null
141
+ }) {
142
+ this.status = init.status
143
+ this.statusText = init.statusText ?? ""
144
+ this.ok = init.status >= 200 && init.status < 300
145
+ this.#headerMap = init.headers
146
+ this.#raw = init.raw === undefined ? null : init.raw
147
+ this.#stream = init.stream
148
+ }
149
+
150
+ get headers(): HoneyOutHeaders {
151
+ if (!this.#headers) this.#headers = new HoneyOutHeaders(this.#headerMap)
152
+ return this.#headers
153
+ }
154
+
155
+ get body(): ReadableStream<Uint8Array> | null {
156
+ if (this.#stream !== undefined) return this.#stream
157
+ if (this.#raw === null) {
158
+ this.#stream = null
159
+ return null
160
+ }
161
+ const raw = this.#raw
162
+ this.#stream = new ReadableStream({
163
+ start(controller) {
164
+ controller.enqueue(typeof raw === "string" ? new TextEncoder().encode(raw) : raw)
165
+ controller.close()
166
+ },
167
+ })
168
+ return this.#stream
169
+ }
170
+
171
+ get bodyUsed(): boolean {
172
+ return this.#bodyUsed
173
+ }
174
+
175
+ /** Plain header map for `res.writeHead`. */
176
+ get plainHeaders(): Record<string, string | string[]> {
177
+ return this.#headers ? this.#headers.toRecord() : this.#headerMap
178
+ }
179
+
180
+ /** Known-size body for `res.end`. */
181
+ get rawBody(): string | Uint8Array | null {
182
+ return this.#raw
183
+ }
184
+
185
+ async arrayBuffer(): Promise<ArrayBuffer> {
186
+ this.#bodyUsed = true
187
+ if (this.#raw === null) {
188
+ if (this.#stream) return streamToArrayBuffer(this.#stream)
189
+ return new ArrayBuffer(0)
190
+ }
191
+ if (typeof this.#raw === "string") {
192
+ const bytes = new TextEncoder().encode(this.#raw)
193
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
194
+ }
195
+ return this.#raw.buffer.slice(this.#raw.byteOffset, this.#raw.byteOffset + this.#raw.byteLength) as ArrayBuffer
196
+ }
197
+
198
+ async blob(): Promise<Blob> {
199
+ const buf = await this.arrayBuffer()
200
+ return new Blob([buf], { type: this.headers.get("content-type") ?? "" })
201
+ }
202
+
203
+ async bytes(): Promise<Uint8Array> {
204
+ const buf = await this.arrayBuffer()
205
+ return new Uint8Array(buf)
206
+ }
207
+
208
+ clone(): Response {
209
+ return new HoneyResponse({
210
+ headers: { ...this.plainHeaders },
211
+ raw: this.#raw,
212
+ status: this.status,
213
+ statusText: this.statusText,
214
+ stream: this.#stream === undefined || this.#stream === null ? this.#stream : this.#stream.tee()[1],
215
+ }) as unknown as Response
216
+ }
217
+
218
+ async formData(): Promise<FormData> {
219
+ return new Response(this.body, {
220
+ headers: this.plainHeaders as HeadersInit,
221
+ status: this.status,
222
+ }).formData()
223
+ }
224
+
225
+ async json(): Promise<unknown> {
226
+ const text = await this.text()
227
+ return text.length === 0 ? null : JSON.parse(text)
228
+ }
229
+
230
+ async text(): Promise<string> {
231
+ this.#bodyUsed = true
232
+ if (this.#raw === null) {
233
+ if (this.#stream) return new TextDecoder().decode(await streamToArrayBuffer(this.#stream))
234
+ return ""
235
+ }
236
+ return typeof this.#raw === "string" ? this.#raw : new TextDecoder().decode(this.#raw)
237
+ }
238
+
239
+ /** Copy this bag with new headers/status/body — keeps the Node `res.end` path. */
240
+ derive(init: {
241
+ headers?: Record<string, string | string[]> | Headers | HoneyOutHeaders
242
+ raw?: string | Uint8Array | null
243
+ status?: number
244
+ stream?: ReadableStream<Uint8Array> | null
245
+ }): HoneyResponse {
246
+ return new HoneyResponse({
247
+ headers: init.headers === undefined ? { ...this.plainHeaders } : headersInitToRecord(init.headers),
248
+ raw: init.raw === undefined ? this.#raw : init.raw,
249
+ status: init.status ?? this.status,
250
+ statusText: this.statusText,
251
+ stream: init.stream === undefined ? this.#stream : init.stream,
252
+ })
253
+ }
254
+ }
255
+
256
+ export function createHoneyResponse(init: {
257
+ headers: Record<string, string | string[]>
258
+ raw?: string | Uint8Array | null
259
+ status: number
260
+ stream?: ReadableStream<Uint8Array> | null
261
+ }): Response {
262
+ return new HoneyResponse(init) as unknown as Response
263
+ }
264
+
265
+ /** Rebuild a response without dropping a HoneyResponse back to `new Response()`. */
266
+ export function replaceResponse(
267
+ response: Response,
268
+ init: {
269
+ body?: BodyInit | null
270
+ headers?: Headers | HoneyOutHeaders | Record<string, string | string[]>
271
+ status?: number
272
+ },
273
+ ): Response {
274
+ if (isHoneyResponse(response)) {
275
+ if (init.body === undefined) {
276
+ return response.derive({ headers: init.headers, status: init.status }) as unknown as Response
277
+ }
278
+ if (typeof init.body === "string" || init.body instanceof Uint8Array) {
279
+ return response.derive({
280
+ headers: init.headers,
281
+ raw: init.body,
282
+ status: init.status,
283
+ stream: null,
284
+ }) as unknown as Response
285
+ }
286
+ if (init.body instanceof ArrayBuffer) {
287
+ return response.derive({
288
+ headers: init.headers,
289
+ raw: new Uint8Array(init.body),
290
+ status: init.status,
291
+ stream: null,
292
+ }) as unknown as Response
293
+ }
294
+ if (init.body === null) {
295
+ return response.derive({
296
+ headers: init.headers,
297
+ raw: null,
298
+ status: init.status,
299
+ stream: null,
300
+ }) as unknown as Response
301
+ }
302
+ }
303
+ return new Response(init.body !== undefined ? init.body : response.body, {
304
+ headers: init.headers === undefined ? undefined : headersToInit(init.headers),
305
+ status: init.status ?? response.status,
306
+ })
307
+ }
308
+
309
+ function headersToInit(
310
+ headers: Headers | HoneyOutHeaders | Record<string, string | string[]>,
311
+ ): HeadersInit {
312
+ if (headers instanceof HoneyOutHeaders) return headers.toRecord() as HeadersInit
313
+ if (headers instanceof Headers) return headers
314
+ return headers as HeadersInit
315
+ }
316
+
317
+ function headersInitToRecord(
318
+ headers: Record<string, string | string[]> | Headers | HoneyOutHeaders,
319
+ ): Record<string, string | string[]> {
320
+ if (headers instanceof HoneyOutHeaders) return headers.toRecord()
321
+ if (!(headers instanceof Headers) && !("forEach" in headers)) {
322
+ return { ...headers }
323
+ }
324
+ const out: Record<string, string | string[]> = {}
325
+ const h = headers as Headers
326
+ h.forEach((value, key) => {
327
+ if (key === "set-cookie") return
328
+ out[key] = value
329
+ })
330
+ if (typeof h.getSetCookie === "function") {
331
+ const cookies = h.getSetCookie()
332
+ if (cookies.length > 0) out["set-cookie"] = cookies
333
+ }
334
+ return out
335
+ }
336
+
337
+ async function streamToArrayBuffer(stream: ReadableStream<Uint8Array>): Promise<ArrayBuffer> {
338
+ const reader = stream.getReader()
339
+ const chunks: Uint8Array[] = []
340
+ let total = 0
341
+ while (true) {
342
+ const { done, value } = await reader.read()
343
+ if (done) break
344
+ chunks.push(value)
345
+ total += value.byteLength
346
+ }
347
+ const out = new Uint8Array(total)
348
+ let offset = 0
349
+ for (const chunk of chunks) {
350
+ out.set(chunk, offset)
351
+ offset += chunk.byteLength
352
+ }
353
+ return out.buffer
354
+ }
@@ -0,0 +1,20 @@
1
+ export type I18nRuntime = {
2
+ interpolate: (template: string, vars: Record<string, unknown>, locale?: string) => string
3
+ }
4
+
5
+ const MISSING = 'Honey.errorI18n() requires `import "@lovrozagar/honey/i18n"` in the app entry.'
6
+
7
+ let runtime: I18nRuntime | undefined
8
+
9
+ export function registerI18nRuntime(next: I18nRuntime): void {
10
+ runtime = next
11
+ }
12
+
13
+ export function resetI18nRuntime(): void {
14
+ runtime = undefined
15
+ }
16
+
17
+ export function getI18nRuntime(): I18nRuntime {
18
+ if (!runtime) throw new Error(MISSING)
19
+ return runtime
20
+ }
package/src/i18n.ts ADDED
@@ -0,0 +1,234 @@
1
+ import { registerI18nRuntime } from "./i18n-slot.ts"
2
+
3
+ /**
4
+ * Extracts {varName} patterns from ICU MessageFormat string literals at the type level.
5
+ * "cause" is excluded (reserved by HoneyError).
6
+ * Returns {} when no variables found — makes `vars` optional at call site.
7
+ */
8
+ export type ExtractICUVars<T extends string> = T extends `${string}{${infer Var}}${infer Rest}`
9
+ ? Var extends "cause"
10
+ ? ExtractICUVars<Rest>
11
+ : { [K in Var | keyof ExtractICUVars<Rest>]: string | number }
12
+ : {}
13
+
14
+ type TranslationMap = Record<string, string>
15
+
16
+ export type TranslationSource = (() => Promise<TranslationMap> | TranslationMap) | TranslationMap
17
+
18
+ export class TranslationRegistry {
19
+ private cache = new Map<string, TranslationMap>()
20
+ private sources: Record<string, TranslationSource>
21
+
22
+ constructor(sources: Record<string, TranslationSource>) {
23
+ this.sources = sources
24
+ }
25
+
26
+ async get(locale: string): Promise<TranslationMap | undefined> {
27
+ if (this.cache.has(locale)) {
28
+ return this.cache.get(locale)
29
+ }
30
+
31
+ const source = this.sources[locale]
32
+ if (source === undefined) {
33
+ return undefined
34
+ }
35
+
36
+ const map = typeof source === "function" ? await source() : source
37
+ this.cache.set(locale, map)
38
+ return map
39
+ }
40
+ }
41
+
42
+ /* ---- ICU MessageFormat parser ---- */
43
+
44
+ type PluralCategory = "few" | "many" | "one" | "other" | "two" | "zero"
45
+
46
+ function getPluralCategory(n: number): PluralCategory {
47
+ if (n === 1) return "one"
48
+ return "other"
49
+ }
50
+
51
+ type ParsedBlock = { branches: Record<string, string>; type: "plural" | "select"; varName: string }
52
+ type ParsedNumber = { type: "number"; varName: string }
53
+ type ParsedSimple = { type: "simple"; varName: string }
54
+ type ParsedToken = ParsedBlock | ParsedNumber | ParsedSimple | string
55
+
56
+ function findMatchingBrace(str: string, start: number): number {
57
+ let depth = 0
58
+ for (let i = start; i < str.length; i++) {
59
+ if (str[i] === "{") depth++
60
+ else if (str[i] === "}") {
61
+ depth--
62
+ if (depth === 0) return i
63
+ }
64
+ }
65
+ return -1
66
+ }
67
+
68
+ function parseBranches(content: string): Record<string, string> {
69
+ const branches: Record<string, string> = {}
70
+ let i = 0
71
+ while (i < content.length) {
72
+ while (i < content.length && /\s/.test(content[i] ?? "")) i++
73
+ if (i >= content.length) break
74
+
75
+ let category = ""
76
+ while (i < content.length && content[i] !== "{" && !/\s/.test(content[i] ?? "")) {
77
+ category += content[i]
78
+ i++
79
+ }
80
+ if (!category) break
81
+
82
+ while (i < content.length && /\s/.test(content[i] ?? "")) i++
83
+ if (i >= content.length || content[i] !== "{") break
84
+
85
+ const end = findMatchingBrace(content, i)
86
+ if (end === -1) break
87
+
88
+ branches[category] = content.slice(i + 1, end)
89
+ i = end + 1
90
+ }
91
+ return branches
92
+ }
93
+
94
+ function tokenize(message: string): ParsedToken[] {
95
+ const tokens: ParsedToken[] = []
96
+ let i = 0
97
+
98
+ while (i < message.length) {
99
+ const braceIdx = message.indexOf("{", i)
100
+ if (braceIdx === -1) {
101
+ tokens.push(message.slice(i))
102
+ break
103
+ }
104
+
105
+ if (braceIdx > i) {
106
+ tokens.push(message.slice(i, braceIdx))
107
+ }
108
+
109
+ const endBrace = findMatchingBrace(message, braceIdx)
110
+ if (endBrace === -1) {
111
+ tokens.push(message.slice(braceIdx))
112
+ break
113
+ }
114
+
115
+ const inner = message.slice(braceIdx + 1, endBrace)
116
+ const commaIdx = inner.indexOf(",")
117
+
118
+ if (commaIdx === -1) {
119
+ tokens.push({ type: "simple", varName: inner.trim() })
120
+ } else {
121
+ const varName = inner.slice(0, commaIdx).trim()
122
+ const rest = inner.slice(commaIdx + 1).trim()
123
+ const secondComma = rest.indexOf(",")
124
+
125
+ if (secondComma === -1) {
126
+ const formatType = rest.trim()
127
+ if (formatType === "number") {
128
+ tokens.push({ type: "number", varName })
129
+ } else {
130
+ tokens.push({ type: "simple", varName })
131
+ }
132
+ } else {
133
+ const formatType = rest.slice(0, secondComma).trim()
134
+ const branchContent = rest.slice(secondComma + 1).trim()
135
+ const branches = parseBranches(branchContent)
136
+
137
+ if (formatType === "plural" || formatType === "select") {
138
+ tokens.push({ branches, type: formatType, varName })
139
+ } else {
140
+ tokens.push({ type: "simple", varName })
141
+ }
142
+ }
143
+ }
144
+
145
+ i = endBrace + 1
146
+ }
147
+
148
+ return tokens
149
+ }
150
+
151
+ function resolveToken(
152
+ token: ParsedToken,
153
+ values: Record<string, unknown>,
154
+ locale?: string,
155
+ ): string {
156
+ if (typeof token === "string") return token
157
+
158
+ if (token.type === "simple") {
159
+ const val = values[token.varName]
160
+ return val !== undefined && val !== null ? String(val) : `{${token.varName}}`
161
+ }
162
+
163
+ if (token.type === "number") {
164
+ const val = values[token.varName]
165
+ if (val === undefined || val === null) return `{${token.varName}}`
166
+ try {
167
+ return new Intl.NumberFormat(locale).format(Number(val))
168
+ } catch {
169
+ return String(val)
170
+ }
171
+ }
172
+
173
+ /* plural or select */
174
+ const val = values[token.varName]
175
+
176
+ if (token.type === "plural") {
177
+ const num = Number(val ?? 0)
178
+ const exactKey = `=${num}`
179
+ const category = getPluralCategory(num)
180
+ const template =
181
+ token.branches[exactKey] ?? token.branches[category] ?? token.branches["other"] ?? ""
182
+ const resolved = template.replace(/#/g, String(num))
183
+ return interpolate(resolved, values, locale)
184
+ }
185
+
186
+ /* select */
187
+ const strVal = val !== undefined && val !== null ? String(val) : ""
188
+ const template = token.branches[strVal] ?? token.branches["other"] ?? ""
189
+ return interpolate(template, values, locale)
190
+ }
191
+
192
+ /**
193
+ * ICU MessageFormat interpolation.
194
+ * Supports: {var}, {var, plural, one{...} other{...}}, {var, select, ...}, {var, number}
195
+ */
196
+ export function interpolate(
197
+ template: string,
198
+ vars: Record<string, unknown>,
199
+ locale?: string,
200
+ ): string {
201
+ if (!template) return template
202
+ const tokens = tokenize(template)
203
+ return tokens.map((t) => resolveToken(t, vars, locale)).join("")
204
+ }
205
+
206
+ export async function resolveTranslation(
207
+ registry: TranslationRegistry,
208
+ locale: string,
209
+ key: string,
210
+ vars: Record<string, string | number>,
211
+ ): Promise<string> {
212
+ const map = await registry.get(locale)
213
+ if (map === undefined) return key
214
+ const template = map[key]
215
+ if (template === undefined) return key
216
+ return interpolate(template, vars, locale)
217
+ }
218
+
219
+ export async function resolveFieldName(
220
+ registry: TranslationRegistry,
221
+ locale: string,
222
+ fieldPath: string,
223
+ ): Promise<string> {
224
+ const map = await registry.get(locale)
225
+ if (map === undefined) return fieldPath
226
+ const name = map[fieldPath]
227
+ return name ?? fieldPath
228
+ }
229
+
230
+ export function enableI18n(): void {
231
+ registerI18nRuntime({ interpolate })
232
+ }
233
+
234
+ enableI18n()