@barefootjs/jsx 0.21.3 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/adapters/env-signal.d.ts +8 -0
  2. package/dist/adapters/env-signal.d.ts.map +1 -1
  3. package/dist/analyzer-context.d.ts +16 -5
  4. package/dist/analyzer-context.d.ts.map +1 -1
  5. package/dist/analyzer.d.ts +10 -4
  6. package/dist/analyzer.d.ts.map +1 -1
  7. package/dist/builtin-lowering-plugins.d.ts.map +1 -1
  8. package/dist/date-lowering.d.ts +16 -0
  9. package/dist/date-lowering.d.ts.map +1 -1
  10. package/dist/errors.d.ts +2 -0
  11. package/dist/errors.d.ts.map +1 -1
  12. package/dist/format-date-lowering.d.ts +27 -0
  13. package/dist/format-date-lowering.d.ts.map +1 -0
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +706 -74
  17. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/html-template.d.ts +1 -0
  19. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/imports.d.ts +2 -2
  21. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  22. package/dist/jsx-to-ir.d.ts.map +1 -1
  23. package/dist/to-locale-date-lowering.d.ts +72 -0
  24. package/dist/to-locale-date-lowering.d.ts.map +1 -0
  25. package/dist/types.d.ts +23 -1
  26. package/dist/types.d.ts.map +1 -1
  27. package/package.json +2 -2
  28. package/src/__tests__/format-date-lowering.test.ts +109 -0
  29. package/src/__tests__/reactive-factory-cross-file.test.ts +239 -0
  30. package/src/__tests__/reactive-factory-inlining.test.ts +293 -4
  31. package/src/__tests__/to-locale-date-lowering.test.ts +181 -0
  32. package/src/adapters/env-signal.ts +26 -3
  33. package/src/analyzer-context.ts +19 -4
  34. package/src/analyzer.ts +712 -91
  35. package/src/builtin-lowering-plugins.ts +8 -1
  36. package/src/date-lowering.ts +1 -1
  37. package/src/errors.ts +13 -0
  38. package/src/format-date-lowering.ts +51 -0
  39. package/src/index.ts +1 -1
  40. package/src/ir-to-client-js/emit-reactive.ts +80 -1
  41. package/src/ir-to-client-js/html-template.ts +36 -2
  42. package/src/ir-to-client-js/imports.ts +4 -0
  43. package/src/jsx-to-ir.ts +79 -1
  44. package/src/rich-type-refusal.ts +9 -1
  45. package/src/to-locale-date-lowering.ts +175 -0
  46. package/src/types.ts +24 -1
@@ -17,6 +17,8 @@ import { registerLoweringPlugin } from './lowering-registry.ts'
17
17
  import { queryHrefLocalNames } from './adapters/env-signal.ts'
18
18
  import { matchQueryHrefCall } from './query-href-lowering.ts'
19
19
  import { datePlugin } from './date-lowering.ts'
20
+ import { formatDatePlugin } from './format-date-lowering.ts'
21
+ import { toLocaleDatePlugin } from './to-locale-date-lowering.ts'
20
22
 
21
23
  /**
22
24
  * `queryHref(base, { … })` — the pure URL-query builder (#2042). Its runtime
@@ -42,7 +44,12 @@ export const queryHrefPlugin: LoweringPlugin = {
42
44
  }
43
45
 
44
46
  /** Every plugin the compiler ships and applies by default. */
45
- export const BUILTIN_LOWERING_PLUGINS: readonly LoweringPlugin[] = [queryHrefPlugin, datePlugin]
47
+ export const BUILTIN_LOWERING_PLUGINS: readonly LoweringPlugin[] = [
48
+ queryHrefPlugin,
49
+ datePlugin,
50
+ formatDatePlugin,
51
+ toLocaleDatePlugin,
52
+ ]
46
53
 
47
54
  /**
48
55
  * Register the built-in plugins into the shared registry. Called for its side
@@ -62,7 +62,7 @@ const EMPTY_BINDINGS: Bindings = new Map()
62
62
  * properties of the SAME named type aren't short-circuited against each
63
63
  * other.
64
64
  */
65
- function typeReachesDate(type: TypeInfo | null, meta: IRMetadata, seen: Set<string>): boolean {
65
+ export function typeReachesDate(type: TypeInfo | null, meta: IRMetadata, seen: Set<string>): boolean {
66
66
  const stripped = stripUnion(type)
67
67
  if (!stripped) return false
68
68
  // `kind: 'object'` is an INLINE type literal (`{ createdAt: Date }` — the
package/src/errors.ts CHANGED
@@ -87,6 +87,8 @@ export const ErrorCodes = {
87
87
 
88
88
  // Reactive factory errors (BF110-BF119)
89
89
  UNRECOGNIZED_REACTIVE_FACTORY: 'BF110',
90
+ REACTIVE_FACTORY_RENAME_UNSUPPORTED: 'BF111',
91
+ REACTIVE_FACTORY_MODULE_CAPTURE: 'BF112',
90
92
  } as const
91
93
 
92
94
  export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes]
@@ -165,6 +167,17 @@ const errorMessages: Record<ErrorCode, string> = {
165
167
 
166
168
  [ErrorCodes.UNRECOGNIZED_REACTIVE_FACTORY]:
167
169
  'Tuple destructuring of a non-reactive factory call. The compiler only recognizes createSignal / createMemo calls and same-file helpers that wrap them with a single `return [a, b]` exit.',
170
+
171
+ [ErrorCodes.REACTIVE_FACTORY_RENAME_UNSUPPORTED]:
172
+ 'Reactive factory object return/destructure must use shorthand properties only. ' +
173
+ 'Property renames (`{ lists: myLists }`), defaults, and rest elements are not ' +
174
+ 'supported — destructure with the factory\'s own property names.',
175
+
176
+ [ErrorCodes.REACTIVE_FACTORY_MODULE_CAPTURE]:
177
+ 'Imported reactive factory references bindings from its own module scope, so its ' +
178
+ 'body cannot be inlined into the component file. Move those helpers into the ' +
179
+ 'component file, pass them to the factory as parameters, or define the factory ' +
180
+ 'in the component file.',
168
181
  }
169
182
 
170
183
  // =============================================================================
@@ -0,0 +1,51 @@
1
+ /**
2
+ * `formatDate` lowering plugin (#2324) — the pure-function date formatter
3
+ * (`packages/client/src/format-date.ts`). A call to the `formatDate` binding
4
+ * imported from `@barefootjs/client` lowers to a backend-neutral
5
+ * `helper-call` on the `format_date` helper (spec/template-helpers.md), which
6
+ * every adapter renders through its generic helper-call path (#2069) — no
7
+ * adapter-specific recognition code, only the runtime helper each backend
8
+ * ships.
9
+ *
10
+ * The canonical helper arity is 3: a two-arg call site
11
+ * (`formatDate(d, 'YYYY/M/D')`) is normalized here by supplying the
12
+ * `'UTC'` literal the client function defaults to, so backend helpers stay
13
+ * fixed-arity. Unlike the `date` plugin there is no receiver-type gate —
14
+ * `formatDate` is recognised by its import binding (like `queryHref`), and
15
+ * its own receiver contract (native date / ISO string / nil → `''`) is total,
16
+ * so any argument expression the adapter can evaluate is admissible.
17
+ */
18
+
19
+ import type { ParsedExpr } from './expression-parser.ts'
20
+ import type { LoweringNode, LoweringPlugin } from './lowering-registry.ts'
21
+ import { formatDateLocalNames } from './adapters/env-signal.ts'
22
+
23
+ const UTC_LITERAL: ParsedExpr = { kind: 'literal', value: 'UTC', literalType: 'string' }
24
+
25
+ /**
26
+ * Recognise `formatDate(date, pattern[, timeZone])` against the component's
27
+ * local import bindings, or decline (null): a non-identifier callee, a name
28
+ * not bound to the `@barefootjs/client` import, or an arity outside 2–3.
29
+ */
30
+ export function matchFormatDateCall(
31
+ callee: ParsedExpr,
32
+ args: readonly ParsedExpr[],
33
+ locals: ReadonlySet<string>,
34
+ ): LoweringNode | null {
35
+ if (callee.kind !== 'identifier' || !locals.has(callee.name)) return null
36
+ if (args.length < 2 || args.length > 3) return null
37
+ return {
38
+ kind: 'helper-call',
39
+ helper: 'format_date',
40
+ args: [args[0], args[1], args[2] ?? UTC_LITERAL],
41
+ }
42
+ }
43
+
44
+ export const formatDatePlugin: LoweringPlugin = {
45
+ name: 'formatDate',
46
+ prepare(metadata) {
47
+ const locals = formatDateLocalNames(metadata)
48
+ if (locals.size === 0) return null
49
+ return (callee, args) => matchFormatDateCall(callee, args, locals)
50
+ },
51
+ }
package/src/index.ts CHANGED
@@ -91,7 +91,7 @@ export type { ParsedExprEmitter, HigherOrderMethod, ArrayMethod, SortMethod, Lit
91
91
  export { collectLoopBoundNames } from './adapters/loop-bound-names.ts'
92
92
  export { evaluateSignalInit, tryEvaluateSignalInit, type SignalInitEvalResult } from './signal-init-eval.ts'
93
93
  export { evaluateStaticLiteral, isFullyStaticLiteral, resolveStaticLoopSource } from './static-literal.ts'
94
- export { importsSearchParams, searchParamsLocalNames, envSignalLocalNames, envSignalReaderFor, ENV_SIGNAL_READERS, queryHrefLocalNames, matchSearchParamsMethodCall } from './adapters/env-signal.ts'
94
+ export { importsSearchParams, searchParamsLocalNames, envSignalLocalNames, envSignalReaderFor, ENV_SIGNAL_READERS, queryHrefLocalNames, formatDateLocalNames, matchSearchParamsMethodCall } from './adapters/env-signal.ts'
95
95
  export type { EnvSignalReader } from './adapters/env-signal.ts'
96
96
  export { matchQueryHrefCall, queryHrefArgs, type QueryHrefCall, type QueryHrefTriple } from './query-href-lowering.ts'
97
97
  export {
@@ -11,6 +11,7 @@ import type { ClientJsContext } from './types.ts'
11
11
  import { toHtmlAttrName, varSlotId, PROPS_PARAM } from './utils.ts'
12
12
  import { createTemplateAwareStringProtector } from './html-template.ts'
13
13
  import { datePlugin, DATE_METHODS } from '../date-lowering.ts'
14
+ import { toLocaleDatePlugin } from '../to-locale-date-lowering.ts'
14
15
  import { tsNodeToParsedExpr } from '../expression-parser.ts'
15
16
  import type { LoweringMatcher } from '../lowering-registry.ts'
16
17
 
@@ -122,6 +123,80 @@ function getReactiveDateLoweringMatcher(ctx: ClientJsContext): LoweringMatcher |
122
123
  return datePlugin.prepare(metadataSlice as unknown as IRMetadata)
123
124
  }
124
125
 
126
+ /** `getReactiveDateLoweringMatcher`'s twin for `toLocaleDatePlugin` (#2324 slice 2). */
127
+ function getReactiveToLocaleMatcher(ctx: ClientJsContext): LoweringMatcher | null {
128
+ if (!ctx.propsType) return null
129
+ const metadataSlice: Pick<IRMetadata, 'propsType' | 'propsObjectName' | 'propsParams' | 'typeDefinitions'> = {
130
+ propsType: ctx.propsType,
131
+ propsObjectName: ctx.propsObjectName,
132
+ propsParams: ctx.propsParams,
133
+ typeDefinitions: ctx.typeDefinitions ?? [],
134
+ }
135
+ return toLocaleDatePlugin.prepare(metadataSlice as unknown as IRMetadata)
136
+ }
137
+
138
+ /**
139
+ * Reactive-path counterpart to `jsx-to-ir.ts`'s `lowerToLocaleDateCalls`
140
+ * (#2324 slice 2): rewrite a literal-locale `toLocaleDateString` call the
141
+ * SAME `toLocaleDatePlugin` matcher claims to `formatDate(recv, pattern,
142
+ * tz)` with the build-time-frozen pattern, for the same two reasons as the
143
+ * `date` rewrite above — the hydrated prop is an ISO STRING (a raw
144
+ * `.toLocaleDateString()` on it throws), and the client must render the
145
+ * frozen pattern, not the browser's own ICU output.
146
+ */
147
+ function lowerToLocaleCallsInReactiveExpr(expr: string, matcher: LoweringMatcher | null): string {
148
+ if (!matcher) return expr
149
+ let sourceFile: ts.SourceFile
150
+ try {
151
+ sourceFile = ts.createSourceFile('__reactive_expr__.ts', `(${expr});`, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
152
+ } catch {
153
+ return expr
154
+ }
155
+ const stmt = sourceFile.statements[0]
156
+ if (!stmt || !ts.isExpressionStatement(stmt)) return expr
157
+ const root = ts.isParenthesizedExpression(stmt.expression) ? stmt.expression.expression : stmt.expression
158
+
159
+ const candidates: ts.CallExpression[] = []
160
+ const visit = (n: ts.Node): void => {
161
+ if (
162
+ ts.isCallExpression(n) &&
163
+ n.arguments.length === 2 &&
164
+ ts.isPropertyAccessExpression(n.expression) &&
165
+ !n.expression.questionDotToken &&
166
+ n.expression.name.text === 'toLocaleDateString'
167
+ ) {
168
+ candidates.push(n)
169
+ }
170
+ ts.forEachChild(n, visit)
171
+ }
172
+ visit(root)
173
+ if (candidates.length === 0) return expr
174
+
175
+ const { protect, restore, replaceProtectedCall } = createTemplateAwareStringProtector()
176
+ let result = protect(expr)
177
+ for (const call of candidates) {
178
+ const propAccess = call.expression as ts.PropertyAccessExpression
179
+ const node = matcher(
180
+ tsNodeToParsedExpr(propAccess),
181
+ call.arguments.map((a) => tsNodeToParsedExpr(a)),
182
+ )
183
+ if (!node || node.kind !== 'helper-call' || node.helper !== 'format_date') continue
184
+ const [, patternArg, tzArg] = node.args
185
+ if (patternArg?.kind !== 'literal' || tzArg?.kind !== 'literal') continue
186
+ const receiverText = propAccess.expression.getText(sourceFile)
187
+ const matchText = call.getText(sourceFile)
188
+ // The call text contains string literals (placeholders in the protected
189
+ // haystack) — go through the protector's stash-verified matcher, same
190
+ // as the static-path rewrite in jsx-to-ir.ts.
191
+ result = replaceProtectedCall(
192
+ result,
193
+ matchText,
194
+ () => `formatDate(${receiverText}, ${JSON.stringify(patternArg.value)}, ${JSON.stringify(tzArg.value)})`,
195
+ )
196
+ }
197
+ return restore(result)
198
+ }
199
+
125
200
  /**
126
201
  * Reactive-path counterpart to `jsx-to-ir.ts`'s `lowerDateCalls` (#2292):
127
202
  * without this, a Date-typed prop's catalogued accessor call re-evaluated
@@ -197,6 +272,7 @@ function lowerDateCallsInReactiveExpr(expr: string, matcher: LoweringMatcher | n
197
272
  /** Emit createEffect blocks that update text nodes for reactive expressions. */
198
273
  export function emitDynamicTextUpdates(lines: string[], ctx: ClientJsContext): void {
199
274
  const dateLoweringMatcher = getReactiveDateLoweringMatcher(ctx)
275
+ const toLocaleMatcher = getReactiveToLocaleMatcher(ctx)
200
276
  // Group elements by expression to consolidate effects with same dependencies
201
277
  const byExpression = new Map<string, typeof ctx.dynamicElements>()
202
278
  for (const elem of ctx.dynamicElements) {
@@ -208,7 +284,10 @@ export function emitDynamicTextUpdates(lines: string[], ctx: ClientJsContext): v
208
284
  }
209
285
 
210
286
  for (const [rawExpr, elems] of byExpression) {
211
- const expr = lowerDateCallsInReactiveExpr(rawExpr, dateLoweringMatcher)
287
+ const expr = lowerToLocaleCallsInReactiveExpr(
288
+ lowerDateCallsInReactiveExpr(rawExpr, dateLoweringMatcher),
289
+ toLocaleMatcher,
290
+ )
212
291
  // Separate conditional vs non-conditional elements
213
292
  const conditionalElems = elems.filter(e => e.insideConditional)
214
293
  const normalElems = elems.filter(e => !e.insideConditional)
@@ -75,21 +75,55 @@ export function splitTemplateInterpolations(inner: string): string[] {
75
75
  export function createTemplateAwareStringProtector(): {
76
76
  protect: (s: string) => string
77
77
  restore: (s: string) => string
78
+ replaceProtectedCall: (haystack: string, needle: string, replacement: () => string) => string
78
79
  } {
79
80
  const stash: string[] = []
80
81
  const save = (s: string) => { const i = stash.length; stash.push(s); return `__STRLIT_${i}__` }
82
+ const STRING_LIT_RE = /'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"/g
81
83
  const protect = (s: string): string => {
82
84
  s = s.replace(/`([^`]*)`/g, (_full, inner: string) => {
83
85
  const parts = splitTemplateInterpolations(inner)
84
86
  return '`' + parts.map(p => p.startsWith('${') ? p : save(p)).join('') + '`'
85
87
  })
86
- s = s.replace(/'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"/g, m => save(m))
88
+ s = s.replace(STRING_LIT_RE, m => save(m))
87
89
  return s
88
90
  }
89
91
  const restore = (s: string): string => {
90
92
  return s.replace(/__STRLIT_(\d+)__/g, (_, i) => stash[Number(i)])
91
93
  }
92
- return { protect, restore }
94
+ /**
95
+ * Replace one occurrence of `needle` (raw source text that may CONTAIN
96
+ * string literals, e.g. a `toLocaleDateString('en-US', { timeZone:
97
+ * 'UTC' })` call) inside an already-`protect`ed `haystack`. A plain
98
+ * `.replace(needle, …)` can't work there: the haystack's literals are
99
+ * `__STRLIT_i__` placeholders while the needle still carries quotes. The
100
+ * needle's literal segments become placeholder wildcards, and each
101
+ * candidate occurrence is verified against the stash CONTENT — so two
102
+ * same-shaped calls differing only in their literals (two locales in one
103
+ * expression) can't be cross-replaced, and a protected template-literal
104
+ * static segment (stashed wholesale) can never match (#2294's hazard
105
+ * class). Replaces the first verified occurrence only.
106
+ */
107
+ const replaceProtectedCall = (haystack: string, needle: string, replacement: () => string): string => {
108
+ const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
109
+ const litValues: string[] = []
110
+ let pattern = ''
111
+ let last = 0
112
+ for (const m of needle.matchAll(STRING_LIT_RE)) {
113
+ pattern += escape(needle.slice(last, m.index))
114
+ pattern += '__STRLIT_(\\d+)__'
115
+ litValues.push(m[0])
116
+ last = m.index + m[0].length
117
+ }
118
+ pattern += escape(needle.slice(last))
119
+ for (const m of haystack.matchAll(new RegExp(pattern, 'g'))) {
120
+ const verified = m.slice(1).every((idx, i) => stash[Number(idx)] === litValues[i])
121
+ if (!verified) continue
122
+ return haystack.slice(0, m.index) + replacement() + haystack.slice(m.index + m[0].length)
123
+ }
124
+ return haystack
125
+ }
126
+ return { protect, restore, replaceProtectedCall }
93
127
  }
94
128
 
95
129
  const VOID_ELEMENTS = new Set([
@@ -21,6 +21,10 @@ export const RUNTIME_IMPORT_CANDIDATES = [
21
21
  // every SSR adapter's `date` runtime helper (`date-lowering.ts`'s
22
22
  // `datePlugin`).
23
23
  'date',
24
+ // Literal-locale `toLocaleDateString` sugar (#2324 slice 2) — the client
25
+ // rewrite targets `formatDate(recv, pattern, tz)`, so the emitted code
26
+ // needs the runtime export when the component didn't import it itself.
27
+ 'formatDate',
24
28
  ] as const
25
29
 
26
30
  /** @deprecated Use RUNTIME_IMPORT_CANDIDATES */
package/src/jsx-to-ir.ts CHANGED
@@ -49,6 +49,7 @@ import { resolveFreeRefs, isNameBound as isNameBoundInEnv, type BindingEnvironme
49
49
  import { computeFileScope } from './ir-to-client-js/component-scope.ts'
50
50
  import { createTemplateAwareStringProtector } from './ir-to-client-js/html-template.ts'
51
51
  import { datePlugin, DATE_METHODS } from './date-lowering.ts'
52
+ import { toLocaleDatePlugin } from './to-locale-date-lowering.ts'
52
53
  import type { LoweringMatcher } from './lowering-registry.ts'
53
54
  import { extractFreeIdentifiersFromNode, initializerShapeContainsJsx } from './analyzer.ts'
54
55
  import { iterateJsTokens, replaceInExprContexts } from './scanner/js-scanner.ts'
@@ -175,6 +176,11 @@ interface TransformContext {
175
176
  * own `prepare` gate). See `getDateLoweringMatcher`.
176
177
  */
177
178
  _dateLoweringMatcher?: LoweringMatcher | null
179
+ /**
180
+ * Cached `toLocaleDatePlugin` matcher (#2324 slice 2), same lifecycle as
181
+ * `_dateLoweringMatcher`. See `getToLocaleDateLoweringMatcher`.
182
+ */
183
+ _toLocaleDateLoweringMatcher?: LoweringMatcher | null
178
184
  }
179
185
 
180
186
  /**
@@ -326,6 +332,21 @@ function getDateLoweringMatcher(ctx: TransformContext): LoweringMatcher | null {
326
332
  return ctx._dateLoweringMatcher
327
333
  }
328
334
 
335
+ /** `getDateLoweringMatcher`'s twin for `toLocaleDatePlugin` (#2324 slice 2) — same metadata slice, same cache lifecycle. */
336
+ function getToLocaleDateLoweringMatcher(ctx: TransformContext): LoweringMatcher | null {
337
+ if (ctx._toLocaleDateLoweringMatcher === undefined) {
338
+ const a = ctx.analyzer
339
+ const metadataSlice: Pick<IRMetadata, 'propsType' | 'propsObjectName' | 'propsParams' | 'typeDefinitions'> = {
340
+ propsType: a.propsType,
341
+ propsObjectName: a.propsObjectName,
342
+ propsParams: a.propsParams,
343
+ typeDefinitions: a.typeDefinitions,
344
+ }
345
+ ctx._toLocaleDateLoweringMatcher = toLocaleDatePlugin.prepare(metadataSlice as unknown as IRMetadata)
346
+ }
347
+ return ctx._toLocaleDateLoweringMatcher
348
+ }
349
+
329
350
  /**
330
351
  * Client-side counterpart to `datePlugin` (#2274 was SSR-only; #2292
331
352
  * closes the gap). The client emitter (`ir-to-client-js/`) emits raw,
@@ -401,6 +422,63 @@ function lowerDateCalls(text: string, expr: ts.Node, ctx: TransformContext): str
401
422
  return restore(result)
402
423
  }
403
424
 
425
+ /**
426
+ * `lowerDateCalls`' twin for the literal-locale `toLocaleDateString` sugar
427
+ * (#2324 slice 2). A call the SAME `toLocaleDatePlugin` matcher claims (so
428
+ * client and SSR lower under identical evidence, mandatory per #2292)
429
+ * rewrites to `formatDate(recv, "<pattern>", "<tz>")` — the pattern and tz
430
+ * literals come off the matched helper-call node, so the client renders the
431
+ * exact build-time-frozen pattern the templates render, and the ISO-string
432
+ * prop value the client actually holds post-hydration (no type-aware JSON
433
+ * revival) flows through `formatDate`'s string-receiver normalization
434
+ * instead of throwing on a raw `.toLocaleDateString()` string call.
435
+ */
436
+ function lowerToLocaleDateCalls(text: string, expr: ts.Node, ctx: TransformContext): string {
437
+ const matcher = getToLocaleDateLoweringMatcher(ctx)
438
+ if (!matcher) return text
439
+
440
+ const candidates: ts.CallExpression[] = []
441
+ function visit(n: ts.Node) {
442
+ if (
443
+ ts.isCallExpression(n) &&
444
+ n.arguments.length === 2 &&
445
+ ts.isPropertyAccessExpression(n.expression) &&
446
+ !n.expression.questionDotToken &&
447
+ n.expression.name.text === 'toLocaleDateString'
448
+ ) {
449
+ candidates.push(n)
450
+ }
451
+ ts.forEachChild(n, visit)
452
+ }
453
+ visit(expr)
454
+ if (candidates.length === 0) return text
455
+
456
+ const { protect, restore, replaceProtectedCall } = createTemplateAwareStringProtector()
457
+ let result = protect(text)
458
+ for (const call of candidates) {
459
+ const propAccess = call.expression as ts.PropertyAccessExpression
460
+ const node = matcher(
461
+ tsNodeToParsedExpr(propAccess),
462
+ call.arguments.map((a) => tsNodeToParsedExpr(a)),
463
+ )
464
+ if (!node || node.kind !== 'helper-call' || node.helper !== 'format_date') continue
465
+ const [, patternArg, tzArg] = node.args
466
+ if (patternArg?.kind !== 'literal' || tzArg?.kind !== 'literal') continue
467
+ const receiverText = ctx.getJS(propAccess.expression)
468
+ const matchText = ctx.getJS(call)
469
+ // Unlike `lowerDateCalls`' zero-arg needle, this call text CONTAINS
470
+ // string literals, which the protected haystack holds as placeholders —
471
+ // so the replacement must go through the protector's stash-verified
472
+ // matcher rather than a plain `.replace`.
473
+ result = replaceProtectedCall(
474
+ result,
475
+ matchText,
476
+ () => `formatDate(${receiverText}, ${JSON.stringify(patternArg.value)}, ${JSON.stringify(tzArg.value)})`,
477
+ )
478
+ }
479
+ return restore(result)
480
+ }
481
+
404
482
  /**
405
483
  * Rewrite bare destructured prop references in expression text.
406
484
  * Thin wrapper that caches prop names on ctx and delegates to the shared core.
@@ -415,7 +493,7 @@ function rewriteBarePropRefs(text: string, expr: ts.Node, ctx: TransformContext)
415
493
  // unconditionally — ahead of the `propNames` gate — because Date
416
494
  // evidence comes from `ctx.analyzer.propsType`, independent of whether
417
495
  // this component destructures its props.
418
- const dateLowered = lowerDateCalls(text, expr, ctx)
496
+ const dateLowered = lowerToLocaleDateCalls(lowerDateCalls(text, expr, ctx), expr, ctx)
419
497
  let propNames = getDestructuredPropNames(ctx)
420
498
  if (!propNames) return dateLowered === text ? undefined : dateLowered
421
499
  // #2222: a name bound as an enclosing loop callback's item/index param
@@ -96,13 +96,21 @@ function pushDiagnostic(
96
96
  if (seen.has(key)) return
97
97
  seen.add(key)
98
98
  const receiver = isProp ? `prop '${receiverPath}'` : `'${receiverPath}'`
99
+ // `toLocaleDateString` has a catalogued explicit-input form (#2324 slice
100
+ // 2) — point the fix at it instead of the generic escape hatches alone.
101
+ // The implicit-environment forms (zero-arg, locale-only, non-literal
102
+ // locale, IANA timeZone) stay refused by design.
103
+ const suggestion =
104
+ method === 'toLocaleDateString' && typeName === 'Date'
105
+ ? "Pass a literal locale and an explicit literal timeZone — .toLocaleDateString('ja-JP', { timeZone: 'UTC' }) (or a fixed '±HH:MM' offset) — to compile it to the format_date helper; for a runtime locale, resolve the pattern in your i18n layer and use formatDate(date, pattern, tz) from @barefootjs/client. Alternatively add /* @client */ or pre-compute server-side."
106
+ : 'Add /* @client */ to evaluate this expression on the client only, or pre-compute the value server-side.'
99
107
  errors.push({
100
108
  code: ErrorCodes.UNSUPPORTED_JSX_PATTERN,
101
109
  severity: 'error',
102
110
  message: `Expression cannot be compiled to marked template: method '.${method}()' on ${receiver} of host type '${typeName}' has no catalogued lowering.`,
103
111
  loc,
104
112
  suggestion: {
105
- message: 'Add /* @client */ to evaluate this expression on the client only, or pre-compute the value server-side.',
113
+ message: suggestion,
106
114
  },
107
115
  })
108
116
  }
@@ -0,0 +1,175 @@
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
+ * Deliberately NOT lowered (decline → loud BF021, never a silent guess):
23
+ * - zero-arg / locale-only calls — they read the host's locale and/or
24
+ * 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;
28
+ * - an IANA `timeZone` name — couples output to the host's tzdata version
29
+ * (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).
35
+ */
36
+
37
+ import type { IRMetadata } from './types.ts'
38
+ import type { ParsedExpr } from './expression-parser.ts'
39
+ import type { LoweringNode, LoweringPlugin } from './lowering-registry.ts'
40
+ import { resolveReceiverType, baseTypeName } from './rich-type-evidence.ts'
41
+ import { typeReachesDate } from './date-lowering.ts'
42
+
43
+ /**
44
+ * `timeZone` literals the lowering admits: `'UTC'` or a fixed `±HH:MM`
45
+ * offset **within ECMA-402's valid offset range** (hours 00–23, minutes
46
+ * 00–59). An out-of-range shape like `'+25:00'` or `'+99:99'` must DECLINE
47
+ * (→ BF021), not lower: real `toLocaleDateString` throws a RangeError on
48
+ * it, so compiling it would render a nonsense offset on the template
49
+ * adapters while the JS-native path (Hono, and the pre-rewrite semantics
50
+ * the sugar stands in for) crashes — the exact divergence the sugar exists
51
+ * to rule out.
52
+ */
53
+ export const TO_LOCALE_TZ_RE = /^(?:UTC|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/
54
+
55
+ /**
56
+ * Probe instant for pattern derivation: 2001-02-03 UTC. Month and day are
57
+ * distinct single-digit values, so the rendered part text distinguishes both
58
+ * the field order (`M/D` vs `D.M`) and zero-padding (`02` → `MM`, `2` → `M`).
59
+ */
60
+ const PROBE_UTC = new Date(Date.UTC(2001, 1, 3))
61
+
62
+ /** Build-time cache: locale tag → derived pattern (or null = not representable). */
63
+ const patternCache = new Map<string, string | null>()
64
+
65
+ /**
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.
75
+ */
76
+ export function resolveLocaleDatePattern(locale: string): string | null {
77
+ const cached = patternCache.get(locale)
78
+ if (cached !== undefined) return cached
79
+ const derived = derivePattern(locale)
80
+ patternCache.set(locale, derived)
81
+ return derived
82
+ }
83
+
84
+ function derivePattern(locale: string): string | null {
85
+ let parts: Intl.DateTimeFormatPart[]
86
+ try {
87
+ const dtf = new Intl.DateTimeFormat(locale, { timeZone: 'UTC' })
88
+ const resolved = dtf.resolvedOptions()
89
+ if (resolved.calendar !== 'gregory' || resolved.numberingSystem !== 'latn') return null
90
+ parts = dtf.formatToParts(PROBE_UTC)
91
+ } catch {
92
+ return null // invalid language tag
93
+ }
94
+ let pattern = ''
95
+ for (const part of parts) {
96
+ switch (part.type) {
97
+ case 'year':
98
+ if (part.value !== '2001') return null // 2-digit-year default has no v1 token
99
+ pattern += 'YYYY'
100
+ 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
105
+ break
106
+ case 'day':
107
+ if (part.value === '3') pattern += 'D'
108
+ else if (part.value === '03') pattern += 'DD'
109
+ else return null
110
+ break
111
+ 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
116
+ pattern += part.value
117
+ break
118
+ default:
119
+ return null // era, weekday, dayPeriod, … — not representable
120
+ }
121
+ }
122
+ if (!pattern.includes('YYYY') || !/M/.test(pattern) || !/D/.test(pattern)) return null
123
+ return pattern
124
+ }
125
+
126
+ /**
127
+ * Recognise `<Date-typed prop>.toLocaleDateString(<locale literal>,
128
+ * { timeZone: <'UTC' | '±HH:MM' literal> })` per the module doc and return
129
+ * the `format_date` helper-call with the build-time-resolved pattern, or
130
+ * decline (null) for every other shape. Receiver evidence mirrors
131
+ * `date-lowering.ts`'s `matchDateCall` exactly (prop-rooted, `Date`-typed,
132
+ * no in-file type shadow, `EMPTY_BINDINGS`).
133
+ */
134
+ export function matchToLocaleDateStringCall(
135
+ callee: ParsedExpr,
136
+ args: readonly ParsedExpr[],
137
+ metadata: IRMetadata,
138
+ ): LoweringNode | null {
139
+ if (callee.kind !== 'member' || callee.computed) return null
140
+ if (callee.property !== 'toLocaleDateString' || args.length !== 2) return null
141
+ 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
149
+
150
+ const receiverType = resolveReceiverType(callee.object, metadata, new Map())
151
+ if (!receiverType || receiverType.kind !== 'interface') return null
152
+ const typeName = baseTypeName(receiverType.raw)
153
+ if (typeName !== 'Date') return null
154
+ if (metadata.typeDefinitions.some((d) => d.name === typeName)) return null
155
+
156
+ const pattern = resolveLocaleDatePattern(String(locale.value))
157
+ if (pattern === null) return null
158
+ return {
159
+ kind: 'helper-call',
160
+ helper: 'format_date',
161
+ args: [
162
+ callee.object,
163
+ { kind: 'literal', value: pattern, literalType: 'string' },
164
+ { kind: 'literal', value: tz, literalType: 'string' },
165
+ ],
166
+ }
167
+ }
168
+
169
+ export const toLocaleDatePlugin: LoweringPlugin = {
170
+ name: 'toLocaleDateString',
171
+ prepare(metadata) {
172
+ if (!metadata.propsType || !typeReachesDate(metadata.propsType, metadata, new Set())) return null
173
+ return (callee, args) => matchToLocaleDateStringCall(callee, args, metadata)
174
+ },
175
+ }
package/src/types.ts CHANGED
@@ -1474,8 +1474,10 @@ export interface ReactiveFactoryInfo {
1474
1474
  * return tuple removed. Identifiers are renamed at the call site.
1475
1475
  */
1476
1476
  bodySource: string
1477
- /** Identifier names inside the returned array literal, in order. */
1477
+ /** Ordered return binding names (tuple elements or object shorthand property names). */
1478
1478
  returnTupleIdentifiers: string[]
1479
+ /** Return shape: `[a, b] as const` (tuple) or `{ a, b }` shorthand object (#2325). */
1480
+ returnKind: 'tuple' | 'object'
1479
1481
  /**
1480
1482
  * Names declared anywhere in the factory body (local bindings). Used by
1481
1483
  * the call-site inliner to apply unique-suffix renaming and keep
@@ -1483,6 +1485,27 @@ 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
+
1497
+ /**
1498
+ * A helper that was recognized as a would-be reactive factory but declined
1499
+ * 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.
1502
+ */
1503
+ export interface DeclinedReactiveFactory {
1504
+ code: 'BF111' | 'BF112'
1505
+ /** Detail spliced into the call-site message (e.g. offending identifier list). */
1506
+ detail: string
1507
+ /** Definition site (in the helper file for cross-file declines). */
1508
+ loc: SourceLocation
1486
1509
  }
1487
1510
 
1488
1511
  export interface ImportSpecifier {