@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.
- package/dist/adapters/env-signal.d.ts +8 -0
- package/dist/adapters/env-signal.d.ts.map +1 -1
- package/dist/analyzer-context.d.ts +16 -5
- package/dist/analyzer-context.d.ts.map +1 -1
- package/dist/analyzer.d.ts +10 -4
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/builtin-lowering-plugins.d.ts.map +1 -1
- package/dist/date-lowering.d.ts +16 -0
- package/dist/date-lowering.d.ts.map +1 -1
- package/dist/errors.d.ts +3 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/format-date-lowering.d.ts +30 -0
- package/dist/format-date-lowering.d.ts.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1124 -74
- package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts +1 -0
- package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
- package/dist/ir-to-client-js/imports.d.ts +2 -2
- package/dist/ir-to-client-js/imports.d.ts.map +1 -1
- package/dist/jsx-to-ir.d.ts.map +1 -1
- package/dist/to-locale-date-lowering.d.ts +111 -0
- package/dist/to-locale-date-lowering.d.ts.map +1 -0
- package/dist/types.d.ts +47 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/format-date-lowering.test.ts +125 -0
- package/src/__tests__/reactive-factory-cross-file.test.ts +502 -0
- package/src/__tests__/reactive-factory-inlining.test.ts +293 -4
- package/src/__tests__/to-locale-date-lowering.test.ts +382 -0
- package/src/adapters/env-signal.ts +26 -3
- package/src/analyzer-context.ts +19 -4
- package/src/analyzer.ts +1012 -93
- package/src/builtin-lowering-plugins.ts +8 -1
- package/src/date-lowering.ts +1 -1
- package/src/errors.ts +19 -0
- package/src/format-date-lowering.ts +55 -0
- package/src/index.ts +1 -1
- package/src/ir-to-client-js/emit-reactive.ts +90 -1
- package/src/ir-to-client-js/html-template.ts +36 -2
- package/src/ir-to-client-js/imports.ts +4 -0
- package/src/jsx-to-ir.ts +90 -1
- package/src/rich-type-refusal.ts +9 -1
- package/src/to-locale-date-lowering.ts +563 -0
- 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
|
-
/**
|
|
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 {
|