@barefootjs/jsx 0.21.4 → 0.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/adapters/env-signal.d.ts +8 -0
  2. package/dist/adapters/env-signal.d.ts.map +1 -1
  3. package/dist/analyzer-context.d.ts +16 -5
  4. package/dist/analyzer-context.d.ts.map +1 -1
  5. package/dist/analyzer.d.ts +10 -4
  6. package/dist/analyzer.d.ts.map +1 -1
  7. package/dist/builtin-lowering-plugins.d.ts.map +1 -1
  8. package/dist/date-lowering.d.ts +16 -0
  9. package/dist/date-lowering.d.ts.map +1 -1
  10. package/dist/errors.d.ts +3 -0
  11. package/dist/errors.d.ts.map +1 -1
  12. package/dist/format-date-lowering.d.ts +30 -0
  13. package/dist/format-date-lowering.d.ts.map +1 -0
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +1124 -74
  17. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/html-template.d.ts +1 -0
  19. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/imports.d.ts +2 -2
  21. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  22. package/dist/jsx-to-ir.d.ts.map +1 -1
  23. package/dist/to-locale-date-lowering.d.ts +111 -0
  24. package/dist/to-locale-date-lowering.d.ts.map +1 -0
  25. package/dist/types.d.ts +47 -1
  26. package/dist/types.d.ts.map +1 -1
  27. package/package.json +2 -2
  28. package/src/__tests__/format-date-lowering.test.ts +125 -0
  29. package/src/__tests__/reactive-factory-cross-file.test.ts +502 -0
  30. package/src/__tests__/reactive-factory-inlining.test.ts +293 -4
  31. package/src/__tests__/to-locale-date-lowering.test.ts +382 -0
  32. package/src/adapters/env-signal.ts +26 -3
  33. package/src/analyzer-context.ts +19 -4
  34. package/src/analyzer.ts +1012 -93
  35. package/src/builtin-lowering-plugins.ts +8 -1
  36. package/src/date-lowering.ts +1 -1
  37. package/src/errors.ts +19 -0
  38. package/src/format-date-lowering.ts +55 -0
  39. package/src/index.ts +1 -1
  40. package/src/ir-to-client-js/emit-reactive.ts +90 -1
  41. package/src/ir-to-client-js/html-template.ts +36 -2
  42. package/src/ir-to-client-js/imports.ts +4 -0
  43. package/src/jsx-to-ir.ts +90 -1
  44. package/src/rich-type-refusal.ts +9 -1
  45. package/src/to-locale-date-lowering.ts +563 -0
  46. package/src/types.ts +49 -1
@@ -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,9 @@ 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',
92
+ REACTIVE_FACTORY_IMPORT_COLLISION: 'BF113',
90
93
  } as const
91
94
 
92
95
  export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes]
@@ -165,6 +168,22 @@ const errorMessages: Record<ErrorCode, string> = {
165
168
 
166
169
  [ErrorCodes.UNRECOGNIZED_REACTIVE_FACTORY]:
167
170
  '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.',
171
+
172
+ [ErrorCodes.REACTIVE_FACTORY_RENAME_UNSUPPORTED]:
173
+ 'Reactive factory object return/destructure must use shorthand properties only. ' +
174
+ 'Property renames (`{ lists: myLists }`), defaults, and rest elements are not ' +
175
+ 'supported — destructure with the factory\'s own property names.',
176
+
177
+ [ErrorCodes.REACTIVE_FACTORY_MODULE_CAPTURE]:
178
+ 'Imported reactive factory references bindings from its own module scope, so its ' +
179
+ 'body cannot be inlined into the component file. Move those helpers into the ' +
180
+ 'component file, pass them to the factory as parameters, or define the factory ' +
181
+ 'in the component file.',
182
+
183
+ [ErrorCodes.REACTIVE_FACTORY_IMPORT_COLLISION]:
184
+ 'Inlining an imported reactive factory requires re-importing one of its helper ' +
185
+ 'imports into this file, but that name is already bound here to something else. ' +
186
+ "Rename the conflicting binding in this file, or alias the import in the factory's own file.",
168
187
  }
169
188
 
170
189
  // =============================================================================
@@ -0,0 +1,55 @@
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
+ const EMPTY_NAMES: ParsedExpr = { kind: 'array-literal', elements: [], raw: '[]' } as ParsedExpr
25
+
26
+ /**
27
+ * Recognise `formatDate(date, pattern[, timeZone[, names]])` against the
28
+ * component's local import bindings, or decline (null): a non-identifier
29
+ * callee, a name not bound to the `@barefootjs/client` import, or an arity
30
+ * outside 2–4. The canonical helper arity is 4 (#2334): omitted `timeZone` /
31
+ * `names` normalize to the `'UTC'` literal and the empty table the client
32
+ * function defaults to, so backend helpers stay fixed-arity.
33
+ */
34
+ export function matchFormatDateCall(
35
+ callee: ParsedExpr,
36
+ args: readonly ParsedExpr[],
37
+ locals: ReadonlySet<string>,
38
+ ): LoweringNode | null {
39
+ if (callee.kind !== 'identifier' || !locals.has(callee.name)) return null
40
+ if (args.length < 2 || args.length > 4) return null
41
+ return {
42
+ kind: 'helper-call',
43
+ helper: 'format_date',
44
+ args: [args[0], args[1], args[2] ?? UTC_LITERAL, args[3] ?? EMPTY_NAMES],
45
+ }
46
+ }
47
+
48
+ export const formatDatePlugin: LoweringPlugin = {
49
+ name: 'formatDate',
50
+ prepare(metadata) {
51
+ const locals = formatDateLocalNames(metadata)
52
+ if (locals.size === 0) return null
53
+ return (callee, args) => matchFormatDateCall(callee, args, locals)
54
+ },
55
+ }
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, foldedArgToClientJs } 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,90 @@ 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, namesArg] = node.args
185
+ if (!patternArg || tzArg?.kind !== 'literal') continue
186
+ const localeText = call.arguments[0].getText(sourceFile)
187
+ const patternJs = foldedArgToClientJs(patternArg, localeText)
188
+ if (patternJs === null) continue
189
+ // The names table (#2334) — omitted when empty, same as the static path.
190
+ let namesJs: string | null = null
191
+ if (namesArg && !(namesArg.kind === 'array-literal' && namesArg.elements.length === 0)) {
192
+ namesJs = foldedArgToClientJs(namesArg, localeText)
193
+ if (namesJs === null) continue
194
+ }
195
+ const receiverText = propAccess.expression.getText(sourceFile)
196
+ const matchText = call.getText(sourceFile)
197
+ // The call text contains string literals (placeholders in the protected
198
+ // haystack) — go through the protector's stash-verified matcher, same
199
+ // as the static-path rewrite in jsx-to-ir.ts.
200
+ result = replaceProtectedCall(
201
+ result,
202
+ matchText,
203
+ () =>
204
+ `formatDate(${receiverText}, ${patternJs}, ${JSON.stringify(tzArg.value)}${namesJs !== null ? `, ${namesJs}` : ''})`,
205
+ )
206
+ }
207
+ return restore(result)
208
+ }
209
+
125
210
  /**
126
211
  * Reactive-path counterpart to `jsx-to-ir.ts`'s `lowerDateCalls` (#2292):
127
212
  * without this, a Date-typed prop's catalogued accessor call re-evaluated
@@ -197,6 +282,7 @@ function lowerDateCallsInReactiveExpr(expr: string, matcher: LoweringMatcher | n
197
282
  /** Emit createEffect blocks that update text nodes for reactive expressions. */
198
283
  export function emitDynamicTextUpdates(lines: string[], ctx: ClientJsContext): void {
199
284
  const dateLoweringMatcher = getReactiveDateLoweringMatcher(ctx)
285
+ const toLocaleMatcher = getReactiveToLocaleMatcher(ctx)
200
286
  // Group elements by expression to consolidate effects with same dependencies
201
287
  const byExpression = new Map<string, typeof ctx.dynamicElements>()
202
288
  for (const elem of ctx.dynamicElements) {
@@ -208,7 +294,10 @@ export function emitDynamicTextUpdates(lines: string[], ctx: ClientJsContext): v
208
294
  }
209
295
 
210
296
  for (const [rawExpr, elems] of byExpression) {
211
- const expr = lowerDateCallsInReactiveExpr(rawExpr, dateLoweringMatcher)
297
+ const expr = lowerToLocaleCallsInReactiveExpr(
298
+ lowerDateCallsInReactiveExpr(rawExpr, dateLoweringMatcher),
299
+ toLocaleMatcher,
300
+ )
212
301
  // Separate conditional vs non-conditional elements
213
302
  const conditionalElems = elems.filter(e => e.insideConditional)
214
303
  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, foldedArgToClientJs } 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,74 @@ 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, namesArg] = node.args
466
+ if (!patternArg || tzArg?.kind !== 'literal') continue
467
+ const localeText = ctx.getJS(call.arguments[0])
468
+ const patternJs = foldedArgToClientJs(patternArg, localeText)
469
+ if (patternJs === null) continue
470
+ // The names table (#2334) — omitted from the client call when empty
471
+ // (the client function defaults to []).
472
+ let namesJs: string | null = null
473
+ if (namesArg && !(namesArg.kind === 'array-literal' && namesArg.elements.length === 0)) {
474
+ namesJs = foldedArgToClientJs(namesArg, localeText)
475
+ if (namesJs === null) continue
476
+ }
477
+ const receiverText = ctx.getJS(propAccess.expression)
478
+ const matchText = ctx.getJS(call)
479
+ // Unlike `lowerDateCalls`' zero-arg needle, this call text CONTAINS
480
+ // string literals, which the protected haystack holds as placeholders —
481
+ // so the replacement must go through the protector's stash-verified
482
+ // matcher rather than a plain `.replace`.
483
+ result = replaceProtectedCall(
484
+ result,
485
+ matchText,
486
+ () =>
487
+ `formatDate(${receiverText}, ${patternJs}, ${JSON.stringify(tzArg.value)}${namesJs !== null ? `, ${namesJs}` : ''})`,
488
+ )
489
+ }
490
+ return restore(result)
491
+ }
492
+
404
493
  /**
405
494
  * Rewrite bare destructured prop references in expression text.
406
495
  * Thin wrapper that caches prop names on ctx and delegates to the shared core.
@@ -415,7 +504,7 @@ function rewriteBarePropRefs(text: string, expr: ts.Node, ctx: TransformContext)
415
504
  // unconditionally — ahead of the `propNames` gate — because Date
416
505
  // evidence comes from `ctx.analyzer.propsType`, independent of whether
417
506
  // this component destructures its props.
418
- const dateLowered = lowerDateCalls(text, expr, ctx)
507
+ const dateLowered = lowerToLocaleDateCalls(lowerDateCalls(text, expr, ctx), expr, ctx)
419
508
  let propNames = getDestructuredPropNames(ctx)
420
509
  if (!propNames) return dateLowered === text ? undefined : dateLowered
421
510
  // #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
  }