@barefootjs/jsx 0.23.0 → 0.25.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.
@@ -19,22 +19,38 @@
19
19
  * `rich-type-refusal.ts`, whose gate exempts exactly what a registered
20
20
  * plugin claims).
21
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
+ *
22
29
  * Deliberately NOT lowered (decline → loud BF021, never a silent guess):
23
30
  * - zero-arg / locale-only calls — they read the host's locale and/or
24
31
  * timezone, the implicit-environment hole #2273 closed;
25
- * - a non-literal locale (`props.locale`) — build-time CLDR resolution is
26
- * impossible; the app's i18n layer owns locale → pattern there, feeding
27
- * `formatDate` directly;
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;
28
37
  * - an IANA `timeZone` name — couples output to the host's tzdata version
29
38
  * (only `'UTC'` and fixed `±HH:MM` offsets are deterministic);
30
- * - options beyond `timeZone` (`dateStyle`, `month: 'long'`, …) the
31
- * name-table stage of #2324, not this slice;
32
- * - a locale whose default format needs anything beyond numeric
33
- * year/month/day in latin digits on the gregorian calendar (e.g.
34
- * `ar-SA`: islamic-umalqura calendar, arabic-indic digits).
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.
35
51
  */
36
52
 
37
- import type { IRMetadata } from './types.ts'
53
+ import type { IRMetadata, TypeInfo } from './types.ts'
38
54
  import type { ParsedExpr } from './expression-parser.ts'
39
55
  import type { LoweringNode, LoweringPlugin } from './lowering-registry.ts'
40
56
  import { resolveReceiverType, baseTypeName } from './rich-type-evidence.ts'
@@ -59,68 +75,276 @@ export const TO_LOCALE_TZ_RE = /^(?:UTC|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/
59
75
  */
60
76
  const PROBE_UTC = new Date(Date.UTC(2001, 1, 3))
61
77
 
62
- /** Build-time cache: locale tag → derived pattern (or null = not representable). */
63
- const patternCache = new Map<string, string | null>()
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
+ }
64
124
 
65
125
  /**
66
- * Resolve a locale literal to its default date pattern in the v1
67
- * `format_date` token language (`YYYY`/`MM`/`M`/`DD`/`D` + literal text), or
68
- * null when the locale's default format is not representable. The gate is
69
- * structural, not an allowlist: the format must resolve to the gregorian
70
- * calendar in latin digits and consist solely of numeric year/month/day
71
- * parts (4-digit year) plus separator literals that cannot collide with the
72
- * token alphabet. `en-US` `M/D/YYYY`, `ja-JP` → `YYYY/M/D`, `en-GB` →
73
- * `DD/MM/YYYY`, `de-DE` `D.M.YYYY`; `ar-SA` (islamic-umalqura/arab) and
74
- * any 2-digit-year or era/weekday-bearing default → null.
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).
75
174
  */
76
175
  export function resolveLocaleDatePattern(locale: string): string | null {
77
- const cached = patternCache.get(locale)
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)
78
199
  if (cached !== undefined) return cached
79
- const derived = derivePattern(locale)
80
- patternCache.set(locale, derived)
200
+ const derived = deriveFormat(locale, probeOptions)
201
+ formatCache.set(key, derived)
81
202
  return derived
82
203
  }
83
204
 
84
- function derivePattern(locale: string): string | null {
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
85
254
  let parts: Intl.DateTimeFormatPart[]
86
255
  try {
87
- const dtf = new Intl.DateTimeFormat(locale, { timeZone: 'UTC' })
256
+ dtf = new Intl.DateTimeFormat(locale, {
257
+ ...(probeOptions as Intl.DateTimeFormatOptions),
258
+ timeZone: 'UTC',
259
+ })
88
260
  const resolved = dtf.resolvedOptions()
89
261
  if (resolved.calendar !== 'gregory' || resolved.numberingSystem !== 'latn') return null
90
262
  parts = dtf.formatToParts(PROBE_UTC)
91
263
  } catch {
92
- return null // invalid language tag
264
+ return null // invalid language tag or invalid/conflicting options
93
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
94
281
  let pattern = ''
282
+ let usesNames = false
95
283
  for (const part of parts) {
96
284
  switch (part.type) {
97
285
  case 'year':
98
- if (part.value !== '2001') return null // 2-digit-year default has no v1 token
286
+ if (part.value !== '2001') return null // 2-digit-year form has no token
99
287
  pattern += 'YYYY'
100
288
  break
101
- case 'month':
102
- if (part.value === '2') pattern += 'M'
103
- else if (part.value === '02') pattern += 'MM'
104
- else return null // name/narrow month — the later name-table stage
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
105
305
  break
306
+ }
106
307
  case 'day':
107
308
  if (part.value === '3') pattern += 'D'
108
309
  else if (part.value === '03') pattern += 'DD'
109
310
  else return null
110
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
+ }
111
322
  case 'literal':
112
- // A literal containing the token alphabet would be re-tokenized by
113
- // the helper's scan; no real numeric-format separator does, but the
114
- // gate must prove it rather than assume it.
115
- if (/[YMD]/.test(part.value)) return null
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
116
327
  pattern += part.value
117
328
  break
118
329
  default:
119
- return null // era, weekday, dayPeriod, … — not representable
330
+ return null // era, dayPeriod, … — not representable
120
331
  }
121
332
  }
122
- if (!pattern.includes('YYYY') || !/M/.test(pattern) || !/D/.test(pattern)) return null
123
- return pattern
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 }
124
348
  }
125
349
 
126
350
  /**
@@ -131,6 +355,95 @@ function derivePattern(locale: string): string | null {
131
355
  * `date-lowering.ts`'s `matchDateCall` exactly (prop-rooted, `Date`-typed,
132
356
  * no in-file type shadow, `EMPTY_BINDINGS`).
133
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
+
134
447
  export function matchToLocaleDateStringCall(
135
448
  callee: ParsedExpr,
136
449
  args: readonly ParsedExpr[],
@@ -139,13 +452,25 @@ export function matchToLocaleDateStringCall(
139
452
  if (callee.kind !== 'member' || callee.computed) return null
140
453
  if (callee.property !== 'toLocaleDateString' || args.length !== 2) return null
141
454
  const [locale, options] = args
142
- if (locale.kind !== 'literal' || locale.literalType !== 'string') return null
143
- if (options.kind !== 'object-literal' || options.properties.length !== 1) return null
144
- const prop = options.properties[0]
145
- if (prop.key !== 'timeZone') return null
146
- if (prop.value.kind !== 'literal' || prop.value.literalType !== 'string') return null
147
- const tz = String(prop.value.value)
148
- if (!TO_LOCALE_TZ_RE.test(tz)) return null
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
149
474
 
150
475
  const receiverType = resolveReceiverType(callee.object, metadata, new Map())
151
476
  if (!receiverType || receiverType.kind !== 'interface') return null
@@ -153,19 +478,82 @@ export function matchToLocaleDateStringCall(
153
478
  if (typeName !== 'Date') return null
154
479
  if (metadata.typeDefinitions.some((d) => d.name === typeName)) return null
155
480
 
156
- const pattern = resolveLocaleDatePattern(String(locale.value))
157
- if (pattern === null) return null
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 ?? []))
158
508
  return {
159
509
  kind: 'helper-call',
160
510
  helper: 'format_date',
161
511
  args: [
162
512
  callee.object,
163
- { kind: 'literal', value: pattern, literalType: 'string' },
164
- { kind: 'literal', value: tz, literalType: 'string' },
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
+ ),
165
521
  ],
166
522
  }
167
523
  }
168
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
+
169
557
  export const toLocaleDatePlugin: LoweringPlugin = {
170
558
  name: 'toLocaleDateString',
171
559
  prepare(metadata) {
package/src/types.ts CHANGED
@@ -1455,6 +1455,19 @@ export interface NamedExportSpecifier {
1455
1455
  isTypeOnly: boolean
1456
1456
  }
1457
1457
 
1458
+ /** One identifier occurrence in a factory body that call-site inlining may
1459
+ * rename (#2341 BUG-1). Offsets are relative to ReactiveFactoryInfo.bodySource. */
1460
+ export interface FactoryRenameSite {
1461
+ name: string
1462
+ start: number
1463
+ end: number
1464
+ /** 'shorthand' = the identifier is simultaneously a property key and a
1465
+ * value/binding reference ({ name } literal, or { name } object-pattern
1466
+ * element). Renaming it must EXPAND to `name: <replacement>` to preserve
1467
+ * the key. 'plain' = ordinary reference or declaration name. */
1468
+ form: 'plain' | 'shorthand'
1469
+ }
1470
+
1458
1471
  /**
1459
1472
  * Reactive factory helper metadata (#931). Collected when a same-file
1460
1473
  * function matches the factory shape: exactly one top-level `return` whose
@@ -1492,16 +1505,49 @@ export interface ReactiveFactoryInfo {
1492
1505
  * (local factories win), not by consulting this field.
1493
1506
  */
1494
1507
  sourceFilePath?: string
1508
+ /**
1509
+ * Imports to re-provision into the component file when this cross-file
1510
+ * factory is inlined (#2332). Absent/empty for same-file factories and
1511
+ * for factories whose body references no helper-file import. Entries
1512
+ * already satisfied by an identical top-level import in the component
1513
+ * file are dropped at prescan time.
1514
+ */
1515
+ requiredImports?: RequiredFactoryImport[]
1516
+ /**
1517
+ * Identifier occurrences within `bodySource` that call-site inlining may
1518
+ * rename (#2341 BUG-1) — collected by the same AST walk that produces
1519
+ * `bodySource`, so offsets stay in lockstep with the serialized text.
1520
+ * Same-file and cross-file factories both get this (both flow through
1521
+ * `detectReactiveFactory`).
1522
+ */
1523
+ renameSites: FactoryRenameSite[]
1524
+ }
1525
+
1526
+ /**
1527
+ * An import the helper file holds that an inlined factory body references
1528
+ * (#2332). Not a BF112 capture: the component file can import the same
1529
+ * binding itself. Collected at prescan with the specifier ALREADY rewritten
1530
+ * relative to the component file (or unchanged for bare/npm specifiers);
1531
+ * the source rewriter injects one deduped import statement per specifier.
1532
+ */
1533
+ export interface RequiredFactoryImport {
1534
+ /** Local binding name as referenced inside the factory body. */
1535
+ localName: string
1536
+ /** Name exported by the target module (`import { exportedName as localName }`). */
1537
+ exportedName: string
1538
+ /** Component-file-relative specifier (`../lib/mathmod`), or the unchanged bare specifier. */
1539
+ specifier: string
1495
1540
  }
1496
1541
 
1497
1542
  /**
1498
1543
  * A helper that was recognized as a would-be reactive factory but declined
1499
1544
  * for inlining (#2325). Recorded so validateReactiveFactoryCalls can emit
1500
- * the specific diagnostic (BF111 rename / BF112 module-scope capture) at
1501
- * the call site instead of the generic BF110.
1545
+ * the specific diagnostic (BF111 rename / BF112 module-scope capture /
1546
+ * BF113 re-provisioned-import name collision) at the call site instead of
1547
+ * the generic BF110.
1502
1548
  */
1503
1549
  export interface DeclinedReactiveFactory {
1504
- code: 'BF111' | 'BF112'
1550
+ code: 'BF111' | 'BF112' | 'BF113' | 'BF114'
1505
1551
  /** Detail spliced into the call-site message (e.g. offending identifier list). */
1506
1552
  detail: string
1507
1553
  /** Definition site (in the helper file for cross-file declines). */