@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.
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/errors.d.ts +2 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/format-date-lowering.d.ts +6 -3
- package/dist/format-date-lowering.d.ts.map +1 -1
- package/dist/index.js +696 -108
- package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
- package/dist/jsx-to-ir.d.ts.map +1 -1
- package/dist/to-locale-date-lowering.d.ts +62 -23
- package/dist/to-locale-date-lowering.d.ts.map +1 -1
- package/dist/types.d.ts +47 -3
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/format-date-lowering.test.ts +22 -6
- package/src/__tests__/reactive-factory-cross-file.test.ts +845 -0
- package/src/__tests__/reactive-factory-inlining.test.ts +527 -1
- package/src/__tests__/reactive-factory-rename-fidelity.test.ts +318 -0
- package/src/__tests__/to-locale-date-lowering.test.ts +203 -2
- package/src/analyzer.ts +730 -131
- package/src/errors.ts +10 -0
- package/src/format-date-lowering.ts +9 -5
- package/src/ir-to-client-js/emit-reactive.ts +14 -4
- package/src/jsx-to-ir.ts +15 -4
- package/src/to-locale-date-lowering.ts +437 -49
- package/src/types.ts +49 -3
|
@@ -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
|
-
* -
|
|
26
|
-
* impossible; the app's i18n layer owns locale → pattern
|
|
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
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* `ar-SA`: islamic-umalqura
|
|
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
|
-
/**
|
|
63
|
-
|
|
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
|
-
*
|
|
67
|
-
*
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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 =
|
|
80
|
-
|
|
200
|
+
const derived = deriveFormat(locale, probeOptions)
|
|
201
|
+
formatCache.set(key, derived)
|
|
81
202
|
return derived
|
|
82
203
|
}
|
|
83
204
|
|
|
84
|
-
|
|
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
|
-
|
|
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
|
|
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')
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
113
|
-
// the helper's scan
|
|
114
|
-
//
|
|
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,
|
|
330
|
+
return null // era, dayPeriod, … — not representable
|
|
120
331
|
}
|
|
121
332
|
}
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
157
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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
|
|
1501
|
-
* the call site instead of
|
|
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). */
|