@barefootjs/jsx 0.21.4 → 0.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/adapters/env-signal.d.ts +8 -0
  2. package/dist/adapters/env-signal.d.ts.map +1 -1
  3. package/dist/analyzer-context.d.ts +16 -5
  4. package/dist/analyzer-context.d.ts.map +1 -1
  5. package/dist/analyzer.d.ts +10 -4
  6. package/dist/analyzer.d.ts.map +1 -1
  7. package/dist/builtin-lowering-plugins.d.ts.map +1 -1
  8. package/dist/date-lowering.d.ts +16 -0
  9. package/dist/date-lowering.d.ts.map +1 -1
  10. package/dist/errors.d.ts +3 -0
  11. package/dist/errors.d.ts.map +1 -1
  12. package/dist/format-date-lowering.d.ts +30 -0
  13. package/dist/format-date-lowering.d.ts.map +1 -0
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +1124 -74
  17. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/html-template.d.ts +1 -0
  19. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/imports.d.ts +2 -2
  21. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  22. package/dist/jsx-to-ir.d.ts.map +1 -1
  23. package/dist/to-locale-date-lowering.d.ts +111 -0
  24. package/dist/to-locale-date-lowering.d.ts.map +1 -0
  25. package/dist/types.d.ts +47 -1
  26. package/dist/types.d.ts.map +1 -1
  27. package/package.json +2 -2
  28. package/src/__tests__/format-date-lowering.test.ts +125 -0
  29. package/src/__tests__/reactive-factory-cross-file.test.ts +502 -0
  30. package/src/__tests__/reactive-factory-inlining.test.ts +293 -4
  31. package/src/__tests__/to-locale-date-lowering.test.ts +382 -0
  32. package/src/adapters/env-signal.ts +26 -3
  33. package/src/analyzer-context.ts +19 -4
  34. package/src/analyzer.ts +1012 -93
  35. package/src/builtin-lowering-plugins.ts +8 -1
  36. package/src/date-lowering.ts +1 -1
  37. package/src/errors.ts +19 -0
  38. package/src/format-date-lowering.ts +55 -0
  39. package/src/index.ts +1 -1
  40. package/src/ir-to-client-js/emit-reactive.ts +90 -1
  41. package/src/ir-to-client-js/html-template.ts +36 -2
  42. package/src/ir-to-client-js/imports.ts +4 -0
  43. package/src/jsx-to-ir.ts +90 -1
  44. package/src/rich-type-refusal.ts +9 -1
  45. package/src/to-locale-date-lowering.ts +563 -0
  46. package/src/types.ts +49 -1
@@ -0,0 +1,563 @@
1
+ /**
2
+ * Literal-locale `toLocaleDateString` lowering plugin (#2324 slice 2 — the
3
+ * "upper layer" sugar over the `format_date` primitive).
4
+ *
5
+ * `createdAt.toLocaleDateString('ja-JP', { timeZone: 'UTC' })` on a
6
+ * `Date`-typed prop, with a **compile-time literal** locale and an explicit
7
+ * literal `timeZone`, resolves the locale's default date pattern ONCE at
8
+ * build time (via the build machine's own `Intl.DateTimeFormat`) and lowers
9
+ * to the exact same backend-neutral `helper-call` on `format_date` that
10
+ * `formatDate(date, pattern, tz)` produces. Consequences:
11
+ *
12
+ * - no runtime ICU/CLDR on any backend — the CLDR lookup happens once, in
13
+ * the compiler;
14
+ * - SSR and (rewritten) client JS render from the same frozen pattern, so
15
+ * output is byte-identical by construction;
16
+ * - no locale allowlist: any locale whose default date format the
17
+ * structural gate below can prove representable in the v1 token set is
18
+ * admitted, and every other shape declines (→ BF021 via
19
+ * `rich-type-refusal.ts`, whose gate exempts exactly what a registered
20
+ * plugin claims).
21
+ *
22
+ * A NON-literal locale is admitted in exactly one shape (#2324's
23
+ * union-typed-locale stage): a REQUIRED prop whose TS type is a closed
24
+ * string-literal union (`locale: 'en-US' | 'ja-JP'`). Every member's pattern
25
+ * resolves at build time and the pattern argument lowers to a ternary over
26
+ * the runtime value — runtime locale switching, still zero runtime CLDR.
27
+ * The type IS the contract: TS keeps the runtime value inside the union.
28
+ *
29
+ * Deliberately NOT lowered (decline → loud BF021, never a silent guess):
30
+ * - zero-arg / locale-only calls — they read the host's locale and/or
31
+ * timezone, the implicit-environment hole #2273 closed;
32
+ * - an OPEN-typed runtime locale (`locale: string`) — build-time CLDR
33
+ * resolution is impossible; the app's i18n layer owns locale → pattern
34
+ * there, feeding `formatDate` directly. An OPTIONAL union prop also
35
+ * declines: `undefined` at runtime makes real `toLocaleDateString` read
36
+ * the host locale, which no frozen pattern table can reproduce;
37
+ * - an IANA `timeZone` name — couples output to the host's tzdata version
38
+ * (only `'UTC'` and fixed `±HH:MM` offsets are deterministic);
39
+ * - an options bag the probe cannot reproduce EXACTLY in the token+table
40
+ * vocabulary — era, dayPeriod, 2-digit year, narrow name forms,
41
+ * non-literal option values ("faithful or loud", never approximate);
42
+ * - a locale/options combination needing non-latin digits or a
43
+ * non-gregorian calendar (e.g. `ar-SA`: islamic-umalqura, arabic-indic
44
+ * digits).
45
+ *
46
+ * Options beyond `timeZone` ARE admitted when literal (#2334): the compiler
47
+ * probes the exact bag with `formatToParts`; month/weekday NAME parts
48
+ * resolve to the `MMMM`/`MMM`/`dddd`/`ddd` tokens plus the 38-slot name
49
+ * table shipped as an ordinary array argument — the backend receives
50
+ * values, never locale knowledge.
51
+ */
52
+
53
+ import type { IRMetadata, TypeInfo } from './types.ts'
54
+ import type { ParsedExpr } from './expression-parser.ts'
55
+ import type { LoweringNode, LoweringPlugin } from './lowering-registry.ts'
56
+ import { resolveReceiverType, baseTypeName } from './rich-type-evidence.ts'
57
+ import { typeReachesDate } from './date-lowering.ts'
58
+
59
+ /**
60
+ * `timeZone` literals the lowering admits: `'UTC'` or a fixed `±HH:MM`
61
+ * offset **within ECMA-402's valid offset range** (hours 00–23, minutes
62
+ * 00–59). An out-of-range shape like `'+25:00'` or `'+99:99'` must DECLINE
63
+ * (→ BF021), not lower: real `toLocaleDateString` throws a RangeError on
64
+ * it, so compiling it would render a nonsense offset on the template
65
+ * adapters while the JS-native path (Hono, and the pre-rewrite semantics
66
+ * the sugar stands in for) crashes — the exact divergence the sugar exists
67
+ * to rule out.
68
+ */
69
+ export const TO_LOCALE_TZ_RE = /^(?:UTC|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/
70
+
71
+ /**
72
+ * Probe instant for pattern derivation: 2001-02-03 UTC. Month and day are
73
+ * distinct single-digit values, so the rendered part text distinguishes both
74
+ * the field order (`M/D` vs `D.M`) and zero-padding (`02` → `MM`, `2` → `M`).
75
+ */
76
+ const PROBE_UTC = new Date(Date.UTC(2001, 1, 3))
77
+
78
+ /**
79
+ * The 38-slot `names` table layout (spec/template-helpers.md "format_date"):
80
+ * [0..11] wide months, [12..23] abbreviated months, [24..30] wide weekdays
81
+ * (Sunday-first), [31..37] abbreviated weekdays.
82
+ */
83
+ export interface LocaleDateFormat {
84
+ pattern: string
85
+ /** The 38-slot table, present iff `pattern` contains a name token. */
86
+ names: string[] | null
87
+ }
88
+
89
+ /** Build-time caches: locale tag (+ options key) → derived result (null = not representable). */
90
+ const formatCache = new Map<string, LocaleDateFormat | null>()
91
+ const namesCache = new Map<string, string[] | null>()
92
+
93
+ /**
94
+ * Name-derivation context (Copilot review on #2336): many locales inflect
95
+ * month (and sometimes weekday) names by DATE CONTEXT — Russian renders
96
+ * `{month:'long'}` alone as nominative `март` but `dateStyle:'long'` as
97
+ * genitive `марта`. So each section of the table is derived under BOTH a
98
+ * `formatting` probe (name alongside a day — the form full date styles
99
+ * use) and a `standalone` probe (name alone), and `deriveFormat` ships
100
+ * whichever table the probed output actually matches.
101
+ */
102
+ type NameContext = 'formatting' | 'standalone'
103
+
104
+ /**
105
+ * Derive a locale's 24 month names (12 wide + 12 abbreviated) under one
106
+ * {@link NameContext}, or null when any probe fails. The compiler is the
107
+ * only owner of locale data; backends receive the values as an ordinary
108
+ * array argument and stay type-only (#2334).
109
+ */
110
+ function deriveMonthNames(locale: string, ctx: NameContext): string[] | null {
111
+ return deriveNamesCached(`${locale}|m|${ctx}`, () => {
112
+ const months = (width: 'long' | 'short') =>
113
+ Array.from({ length: 12 }, (_, m) =>
114
+ probePart(
115
+ locale,
116
+ ctx === 'formatting' ? { month: width, day: 'numeric' } : { month: width },
117
+ Date.UTC(2001, m, 15),
118
+ 'month',
119
+ ),
120
+ )
121
+ return [...months('long'), ...months('short')]
122
+ })
123
+ }
124
+
125
+ /**
126
+ * Derive a locale's 14 weekday names (7 wide + 7 abbreviated,
127
+ * Sunday-first — matching `Date.prototype.getUTCDay`) under one context.
128
+ */
129
+ function deriveWeekdayNames(locale: string, ctx: NameContext): string[] | null {
130
+ return deriveNamesCached(`${locale}|w|${ctx}`, () => {
131
+ // 2023-01-01 was a Sunday; day offsets walk Sunday..Saturday.
132
+ const weekdays = (width: 'long' | 'short') =>
133
+ Array.from({ length: 7 }, (_, d) =>
134
+ probePart(
135
+ locale,
136
+ ctx === 'formatting' ? { weekday: width, month: 'numeric', day: 'numeric' } : { weekday: width },
137
+ Date.UTC(2023, 0, 1 + d),
138
+ 'weekday',
139
+ ),
140
+ )
141
+ return [...weekdays('long'), ...weekdays('short')]
142
+ })
143
+ }
144
+
145
+ function probePart(
146
+ locale: string,
147
+ options: Intl.DateTimeFormatOptions,
148
+ utc: number,
149
+ type: string,
150
+ ): string {
151
+ const parts = new Intl.DateTimeFormat(locale, { ...options, timeZone: 'UTC' }).formatToParts(new Date(utc))
152
+ const found = parts.find((p) => p.type === type)
153
+ if (!found || !found.value) throw new Error('missing part')
154
+ return found.value
155
+ }
156
+
157
+ function deriveNamesCached(key: string, derive: () => string[]): string[] | null {
158
+ const cached = namesCache.get(key)
159
+ if (cached !== undefined) return cached
160
+ let derived: string[] | null
161
+ try {
162
+ derived = derive()
163
+ } catch {
164
+ derived = null
165
+ }
166
+ namesCache.set(key, derived)
167
+ return derived
168
+ }
169
+
170
+ /**
171
+ * Back-compat convenience over {@link resolveLocaleDateFormat}: the
172
+ * default-options pattern (numeric-only or it doesn't resolve — the default
173
+ * date format of every locale is name-free, so `names` is always null here).
174
+ */
175
+ export function resolveLocaleDatePattern(locale: string): string | null {
176
+ return resolveLocaleDateFormat(locale, {})?.pattern ?? null
177
+ }
178
+
179
+ /**
180
+ * Resolve (locale, probe options) to a `format_date` pattern — and, when the
181
+ * probed format contains month/weekday NAMES, the 38-slot table those tokens
182
+ * read (#2334). The fidelity contract: the result is exactly what the user's
183
+ * `toLocaleDateString(locale, options)` evaluates to under the build
184
+ * machine's ECMA-402 — reproduce it exactly or decline (null), never
185
+ * approximate. The gate is structural, not an allowlist: gregorian calendar,
186
+ * latin digits, and every part must be a numeric 4-digit year / numeric or
187
+ * named month / numeric day / named weekday / non-colliding literal.
188
+ * `en-US` default → `M/D/YYYY`; `en-US` + `{dateStyle:'long'}` → `MMMM D,
189
+ * YYYY` + names; `ja-JP` + `{dateStyle:'long'}` → `YYYY年M月D日` (numeric —
190
+ * no names needed, which the probe discovers naturally); era / dayPeriod /
191
+ * 2-digit-year / non-latn digits → null.
192
+ */
193
+ export function resolveLocaleDateFormat(
194
+ locale: string,
195
+ probeOptions: Record<string, string>,
196
+ ): LocaleDateFormat | null {
197
+ const key = `${locale}|${JSON.stringify(probeOptions, Object.keys(probeOptions).sort())}`
198
+ const cached = formatCache.get(key)
199
+ if (cached !== undefined) return cached
200
+ const derived = deriveFormat(locale, probeOptions)
201
+ formatCache.set(key, derived)
202
+ return derived
203
+ }
204
+
205
+ /**
206
+ * Second verification instant: 2001-05-13 UTC — a SUNDAY in MAY, so both
207
+ * the month and weekday indexes differ from {@link PROBE_UTC}'s. The
208
+ * chosen name tables are re-verified against the real ICU output at this
209
+ * instant, closing the coincidental-match hole: a table whose form only
210
+ * happens to agree with the probed format AT the probe month/weekday (but
211
+ * diverges elsewhere) would otherwise ship a silently-wrong name — the
212
+ * one failure class the fidelity rule ("reproduce exactly or decline")
213
+ * cannot tolerate (Copilot review on #2336).
214
+ */
215
+ const VERIFY_UTC = new Date(Date.UTC(2001, 4, 13))
216
+
217
+ /** Render `pattern` + `names` at a fixed calendar point — the compiler-side
218
+ * mirror of the runtime token scan, used only for the VERIFY_UTC check. */
219
+ function renderPatternAt(
220
+ pattern: string,
221
+ names: readonly string[],
222
+ y: number,
223
+ m: number,
224
+ d: number,
225
+ wd: number,
226
+ ): string {
227
+ const pad2 = (n: number) => String(n).padStart(2, '0')
228
+ return pattern.replace(/YYYY|MMMM|MMM|MM|DD|dddd|ddd|M|D/g, (token) => {
229
+ switch (token) {
230
+ case 'YYYY':
231
+ return String(y).padStart(4, '0')
232
+ case 'MMMM':
233
+ return names[m - 1] ?? ''
234
+ case 'MMM':
235
+ return names[12 + m - 1] ?? ''
236
+ case 'MM':
237
+ return pad2(m)
238
+ case 'M':
239
+ return String(m)
240
+ case 'DD':
241
+ return pad2(d)
242
+ case 'D':
243
+ return String(d)
244
+ case 'dddd':
245
+ return names[24 + wd] ?? ''
246
+ default:
247
+ return names[31 + wd] ?? ''
248
+ }
249
+ })
250
+ }
251
+
252
+ function deriveFormat(locale: string, probeOptions: Record<string, string>): LocaleDateFormat | null {
253
+ let dtf: Intl.DateTimeFormat
254
+ let parts: Intl.DateTimeFormatPart[]
255
+ try {
256
+ dtf = new Intl.DateTimeFormat(locale, {
257
+ ...(probeOptions as Intl.DateTimeFormatOptions),
258
+ timeZone: 'UTC',
259
+ })
260
+ const resolved = dtf.resolvedOptions()
261
+ if (resolved.calendar !== 'gregory' || resolved.numberingSystem !== 'latn') return null
262
+ parts = dtf.formatToParts(PROBE_UTC)
263
+ } catch {
264
+ return null // invalid language tag or invalid/conflicting options
265
+ }
266
+ // Probe instant 2001-02-03 is a Saturday in February: month index 1,
267
+ // weekday index 6 in the Sunday-first table. Each name part is matched
268
+ // against BOTH derivation contexts (formatting first — full date styles
269
+ // use the in-context form) so context-inflecting locales (ru: `марта`
270
+ // vs `март`) resolve to the table whose form the format actually uses.
271
+ const monthTables: Array<string[] | null> = [
272
+ deriveMonthNames(locale, 'formatting'),
273
+ deriveMonthNames(locale, 'standalone'),
274
+ ]
275
+ const weekdayTables: Array<string[] | null> = [
276
+ deriveWeekdayNames(locale, 'formatting'),
277
+ deriveWeekdayNames(locale, 'standalone'),
278
+ ]
279
+ let monthTable: string[] | null = null
280
+ let weekdayTable: string[] | null = null
281
+ let pattern = ''
282
+ let usesNames = false
283
+ for (const part of parts) {
284
+ switch (part.type) {
285
+ case 'year':
286
+ if (part.value !== '2001') return null // 2-digit-year form has no token
287
+ pattern += 'YYYY'
288
+ break
289
+ case 'month': {
290
+ if (part.value === '2') {
291
+ pattern += 'M'
292
+ break
293
+ }
294
+ if (part.value === '02') {
295
+ pattern += 'MM'
296
+ break
297
+ }
298
+ const wide = monthTables.find((t) => t && part.value === t[1]) ?? null
299
+ const abbr = wide ? null : (monthTables.find((t) => t && part.value === t[12 + 1]) ?? null)
300
+ if (wide) pattern += 'MMMM'
301
+ else if (abbr) pattern += 'MMM'
302
+ else return null // narrow / unmatched month form
303
+ monthTable = wide ?? abbr
304
+ usesNames = true
305
+ break
306
+ }
307
+ case 'day':
308
+ if (part.value === '3') pattern += 'D'
309
+ else if (part.value === '03') pattern += 'DD'
310
+ else return null
311
+ break
312
+ case 'weekday': {
313
+ const wide = weekdayTables.find((t) => t && part.value === t[6]) ?? null
314
+ const abbr = wide ? null : (weekdayTables.find((t) => t && part.value === t[7 + 6]) ?? null)
315
+ if (wide) pattern += 'dddd'
316
+ else if (abbr) pattern += 'ddd'
317
+ else return null // narrow / unmatched weekday form
318
+ weekdayTable = wide ?? abbr
319
+ usesNames = true
320
+ break
321
+ }
322
+ case 'literal':
323
+ // A literal colliding with the token alphabet would be re-tokenized
324
+ // by the helper's scan: any uppercase Y/M/D, or a lowercase run of
325
+ // three-plus `d`s (single/double `d` is not a token).
326
+ if (/[YMD]/.test(part.value) || /ddd/.test(part.value)) return null
327
+ pattern += part.value
328
+ break
329
+ default:
330
+ return null // era, dayPeriod, … — not representable
331
+ }
332
+ }
333
+ // The probed format must actually be a date (guards a pathological
334
+ // options bag that yields, say, only a weekday).
335
+ if (!/YYYY|MMMM|MMM|MM|M/.test(pattern) && !/DD|D/.test(pattern)) return null
336
+ if (!usesNames) return { pattern, names: null }
337
+ // Compose the shipped 38-slot table from whichever context matched each
338
+ // section (unused sections default to the formatting context).
339
+ const names = [
340
+ ...(monthTable ?? monthTables[0] ?? monthTables[1] ?? Array<string>(24).fill('')),
341
+ ...(weekdayTable ?? weekdayTables[0] ?? weekdayTables[1] ?? Array<string>(14).fill('')),
342
+ ]
343
+ // Two-point verification: reproduce the SECOND instant (Sunday, May 13)
344
+ // with the frozen pattern + table and byte-compare against real ICU. A
345
+ // probe-index coincidence between contexts cannot survive both points.
346
+ if (renderPatternAt(pattern, names, 2001, 5, 13, 0) !== dtf.format(VERIFY_UTC)) return null
347
+ return { pattern, names }
348
+ }
349
+
350
+ /**
351
+ * Recognise `<Date-typed prop>.toLocaleDateString(<locale literal>,
352
+ * { timeZone: <'UTC' | '±HH:MM' literal> })` per the module doc and return
353
+ * the `format_date` helper-call with the build-time-resolved pattern, or
354
+ * decline (null) for every other shape. Receiver evidence mirrors
355
+ * `date-lowering.ts`'s `matchDateCall` exactly (prop-rooted, `Date`-typed,
356
+ * no in-file type shadow, `EMPTY_BINDINGS`).
357
+ */
358
+ /** A quoted string-literal union member's value, or null when the member is anything else. */
359
+ function unionMemberLiteral(member: TypeInfo): string | null {
360
+ const m = /^'([^'\\]*)'$|^"([^"\\]*)"$/.exec(member.raw.trim())
361
+ return m ? (m[1] ?? m[2]) : null
362
+ }
363
+
364
+ /**
365
+ * Resolve a NON-literal `locale` argument to its closed set of string-literal
366
+ * union members (#2324's union-typed-locale stage), or null to decline.
367
+ * Prop-rooting follows `resolveReceiverType`'s rules exactly, per props
368
+ * mode (Copilot review on #2331 — the looser first cut could mis-identify a
369
+ * same-named LOCAL binding as the prop):
370
+ * - object-props mode (`propsObjectName` set): ONLY a
371
+ * `<propsObjectName>.<name>` member — a bare identifier is never a prop
372
+ * there;
373
+ * - destructured mode: ONLY a bare identifier that is one of
374
+ * `propsParams` (resolved through `sourceName` for aliased bindings) —
375
+ * there is no props object to member-access.
376
+ * The prop must be REQUIRED (an optional union can be `undefined` at
377
+ * runtime, and real `toLocaleDateString(undefined, …)` falls back to the
378
+ * HOST locale — the implicit-environment read this plugin exists to rule
379
+ * out) and every union member a quoted string literal.
380
+ */
381
+ function resolveLocaleUnionMembers(locale: ParsedExpr, metadata: IRMetadata): string[] | null {
382
+ let sourcePropName: string | null = null
383
+ if (metadata.propsObjectName) {
384
+ if (
385
+ locale.kind === 'member' &&
386
+ !locale.computed &&
387
+ locale.object.kind === 'identifier' &&
388
+ locale.object.name === metadata.propsObjectName
389
+ ) {
390
+ sourcePropName = locale.property
391
+ }
392
+ } else if (locale.kind === 'identifier') {
393
+ const name = locale.name
394
+ const param = metadata.propsParams?.find((pp) => pp.name === name)
395
+ if (param) sourcePropName = param.sourceName ?? param.name
396
+ }
397
+ if (!sourcePropName) return null
398
+ const target = sourcePropName
399
+ const prop = metadata.propsType?.properties?.find((p) => p.name === target)
400
+ if (!prop || prop.optional) return null
401
+ const type = prop.type
402
+ if (type.kind !== 'union' || !type.unionTypes || type.unionTypes.length === 0) return null
403
+ const members: string[] = []
404
+ for (const member of type.unionTypes) {
405
+ const value = unionMemberLiteral(member)
406
+ if (value === null) return null
407
+ members.push(value)
408
+ }
409
+ return members
410
+ }
411
+
412
+ const strLit = (value: string): ParsedExpr => ({ kind: 'literal', value, literalType: 'string' })
413
+
414
+ /** Array-literal ParsedExpr over string values (the `names` helper argument). */
415
+ function strArr(values: readonly string[]): ParsedExpr {
416
+ return {
417
+ kind: 'array-literal',
418
+ elements: values.map((v) => strLit(v)),
419
+ raw: JSON.stringify(values),
420
+ } as ParsedExpr
421
+ }
422
+
423
+ /**
424
+ * Fold per-union-member values into a right-folded ternary over the runtime
425
+ * locale expression (last member needs no guard — the TS type keeps the
426
+ * value inside the union). Equal values collapse to the plain leaf.
427
+ */
428
+ function foldMembers(
429
+ locale: ParsedExpr,
430
+ members: readonly string[],
431
+ leaves: readonly ParsedExpr[],
432
+ allEqual: boolean,
433
+ ): ParsedExpr {
434
+ let expr = leaves[leaves.length - 1]
435
+ if (allEqual) return expr
436
+ for (let i = leaves.length - 2; i >= 0; i--) {
437
+ expr = {
438
+ kind: 'conditional',
439
+ test: { kind: 'binary', op: '===', left: locale, right: strLit(members[i]) },
440
+ consequent: leaves[i],
441
+ alternate: expr,
442
+ }
443
+ }
444
+ return expr
445
+ }
446
+
447
+ export function matchToLocaleDateStringCall(
448
+ callee: ParsedExpr,
449
+ args: readonly ParsedExpr[],
450
+ metadata: IRMetadata,
451
+ ): LoweringNode | null {
452
+ if (callee.kind !== 'member' || callee.computed) return null
453
+ if (callee.property !== 'toLocaleDateString' || args.length !== 2) return null
454
+ const [locale, options] = args
455
+ // Options bag: `timeZone` is REQUIRED (a literal 'UTC' | valid ±HH:MM —
456
+ // its omission would read the host timezone); every OTHER key rides into
457
+ // the Intl probe as-is when its value is a string literal (#2334's
458
+ // fidelity rule: admit any literal options bag the probe can reproduce
459
+ // exactly, decline everything else — dateStyle, month/weekday forms, …).
460
+ if (options.kind !== 'object-literal') return null
461
+ let tz: string | null = null
462
+ const probeOptions: Record<string, string> = {}
463
+ for (const prop of options.properties) {
464
+ if (prop.value.kind !== 'literal' || prop.value.literalType !== 'string') return null
465
+ const value = String(prop.value.value)
466
+ if (prop.key === 'timeZone') {
467
+ if (!TO_LOCALE_TZ_RE.test(value)) return null
468
+ tz = value
469
+ } else {
470
+ probeOptions[prop.key] = value
471
+ }
472
+ }
473
+ if (tz === null) return null
474
+
475
+ const receiverType = resolveReceiverType(callee.object, metadata, new Map())
476
+ if (!receiverType || receiverType.kind !== 'interface') return null
477
+ const typeName = baseTypeName(receiverType.raw)
478
+ if (typeName !== 'Date') return null
479
+ if (metadata.typeDefinitions.some((d) => d.name === typeName)) return null
480
+
481
+ // Literal locale: resolve one pattern (+ name table when the probed
482
+ // format contains month/weekday names) at build time.
483
+ if (locale.kind === 'literal' && locale.literalType === 'string') {
484
+ const format = resolveLocaleDateFormat(String(locale.value), probeOptions)
485
+ if (format === null) return null
486
+ return {
487
+ kind: 'helper-call',
488
+ helper: 'format_date',
489
+ args: [callee.object, strLit(format.pattern), strLit(tz), strArr(format.names ?? [])],
490
+ }
491
+ }
492
+
493
+ // Union-typed locale (#2324's union stage): a REQUIRED prop typed as a
494
+ // closed string-literal union resolves every member's format at build
495
+ // time; the pattern AND names arguments each lower to a right-folded
496
+ // ternary over the runtime locale value — runtime locale switching with
497
+ // zero runtime CLDR.
498
+ const members = resolveLocaleUnionMembers(locale, metadata)
499
+ if (!members) return null
500
+ const formats: LocaleDateFormat[] = []
501
+ for (const member of members) {
502
+ const format = resolveLocaleDateFormat(member, probeOptions)
503
+ if (format === null) return null
504
+ formats.push(format)
505
+ }
506
+ const patterns = formats.map((f) => f.pattern)
507
+ const nameTables = formats.map((f) => JSON.stringify(f.names ?? []))
508
+ return {
509
+ kind: 'helper-call',
510
+ helper: 'format_date',
511
+ args: [
512
+ callee.object,
513
+ foldMembers(locale, members, patterns.map(strLit), new Set(patterns).size === 1),
514
+ strLit(tz),
515
+ foldMembers(
516
+ locale,
517
+ members,
518
+ formats.map((f) => strArr(f.names ?? [])),
519
+ new Set(nameTables).size === 1,
520
+ ),
521
+ ],
522
+ }
523
+ }
524
+
525
+ /**
526
+ * Render a matched node's helper argument — the pattern (`strLit` leaf) or
527
+ * the #2334 names table (`strArr` leaf), either possibly wrapped in
528
+ * `foldMembers`' right-folded ternary — as client-JS text for the
529
+ * #2292-style rewrite sites (`jsx-to-ir.ts` / `emit-reactive.ts`). Leaves
530
+ * stringify directly; a fold re-serializes its tests against `localeText` —
531
+ * the rewrite site's own source text for the locale argument, so downstream
532
+ * prop-prefix rewrites treat it like any other reference. Returns null for
533
+ * any shape this module didn't build (the caller then leaves the expression
534
+ * raw rather than guessing).
535
+ */
536
+ export function foldedArgToClientJs(arg: ParsedExpr, localeText: string): string | null {
537
+ if (arg.kind === 'literal') return JSON.stringify(arg.value)
538
+ if (arg.kind === 'array-literal') {
539
+ const values: string[] = []
540
+ for (const el of arg.elements) {
541
+ if (el.kind !== 'literal') return null
542
+ values.push(String(el.value))
543
+ }
544
+ return JSON.stringify(values)
545
+ }
546
+ if (arg.kind !== 'conditional') return null
547
+ const t = arg.test
548
+ if (t.kind !== 'binary' || t.op !== '===' || t.right.kind !== 'literal') return null
549
+ // Right-fold shape: the consequent must be a leaf (only alternates nest).
550
+ if (arg.consequent.kind !== 'literal' && arg.consequent.kind !== 'array-literal') return null
551
+ const cons = foldedArgToClientJs(arg.consequent, localeText)
552
+ const rest = foldedArgToClientJs(arg.alternate, localeText)
553
+ if (cons === null || rest === null) return null
554
+ return `${localeText} === ${JSON.stringify(t.right.value)} ? ${cons} : ${rest}`
555
+ }
556
+
557
+ export const toLocaleDatePlugin: LoweringPlugin = {
558
+ name: 'toLocaleDateString',
559
+ prepare(metadata) {
560
+ if (!metadata.propsType || !typeReachesDate(metadata.propsType, metadata, new Set())) return null
561
+ return (callee, args) => matchToLocaleDateStringCall(callee, args, metadata)
562
+ },
563
+ }
package/src/types.ts CHANGED
@@ -1474,8 +1474,10 @@ export interface ReactiveFactoryInfo {
1474
1474
  * return tuple removed. Identifiers are renamed at the call site.
1475
1475
  */
1476
1476
  bodySource: string
1477
- /** Identifier names inside the returned array literal, in order. */
1477
+ /** Ordered return binding names (tuple elements or object shorthand property names). */
1478
1478
  returnTupleIdentifiers: string[]
1479
+ /** Return shape: `[a, b] as const` (tuple) or `{ a, b }` shorthand object (#2325). */
1480
+ returnKind: 'tuple' | 'object'
1479
1481
  /**
1480
1482
  * Names declared anywhere in the factory body (local bindings). Used by
1481
1483
  * the call-site inliner to apply unique-suffix renaming and keep
@@ -1483,6 +1485,52 @@ export interface ReactiveFactoryInfo {
1483
1485
  */
1484
1486
  localBindings: string[]
1485
1487
  loc: SourceLocation
1488
+ /**
1489
+ * Absolute path of the defining file when the factory was resolved from a
1490
+ * relative import (#2325). Undefined for same-file factories. Diagnostic
1491
+ * detail only — name-collision precedence is enforced at merge time
1492
+ * (local factories win), not by consulting this field.
1493
+ */
1494
+ sourceFilePath?: string
1495
+ /**
1496
+ * Imports to re-provision into the component file when this cross-file
1497
+ * factory is inlined (#2332). Absent/empty for same-file factories and
1498
+ * for factories whose body references no helper-file import. Entries
1499
+ * already satisfied by an identical top-level import in the component
1500
+ * file are dropped at prescan time.
1501
+ */
1502
+ requiredImports?: RequiredFactoryImport[]
1503
+ }
1504
+
1505
+ /**
1506
+ * An import the helper file holds that an inlined factory body references
1507
+ * (#2332). Not a BF112 capture: the component file can import the same
1508
+ * binding itself. Collected at prescan with the specifier ALREADY rewritten
1509
+ * relative to the component file (or unchanged for bare/npm specifiers);
1510
+ * the source rewriter injects one deduped import statement per specifier.
1511
+ */
1512
+ export interface RequiredFactoryImport {
1513
+ /** Local binding name as referenced inside the factory body. */
1514
+ localName: string
1515
+ /** Name exported by the target module (`import { exportedName as localName }`). */
1516
+ exportedName: string
1517
+ /** Component-file-relative specifier (`../lib/mathmod`), or the unchanged bare specifier. */
1518
+ specifier: string
1519
+ }
1520
+
1521
+ /**
1522
+ * A helper that was recognized as a would-be reactive factory but declined
1523
+ * for inlining (#2325). Recorded so validateReactiveFactoryCalls can emit
1524
+ * the specific diagnostic (BF111 rename / BF112 module-scope capture /
1525
+ * BF113 re-provisioned-import name collision) at the call site instead of
1526
+ * the generic BF110.
1527
+ */
1528
+ export interface DeclinedReactiveFactory {
1529
+ code: 'BF111' | 'BF112' | 'BF113'
1530
+ /** Detail spliced into the call-site message (e.g. offending identifier list). */
1531
+ detail: string
1532
+ /** Definition site (in the helper file for cross-file declines). */
1533
+ loc: SourceLocation
1486
1534
  }
1487
1535
 
1488
1536
  export interface ImportSpecifier {