@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
@@ -0,0 +1,382 @@
1
+ /**
2
+ * Literal-locale `toLocaleDateString` sugar (#2324 slice 2). Covers the
3
+ * build-time pattern derivation's structural gate, the matcher's
4
+ * accept/decline table (only the explicit-input literal shape lowers; every
5
+ * implicit-environment or runtime-value shape declines to BF021), the
6
+ * BF021-exemption round trip through the real registry, and the client-JS
7
+ * rewrite to `formatDate(recv, pattern, tz)` (mirrors
8
+ * `date-lowering.test.ts`'s #2292 section).
9
+ */
10
+ import { describe, test, expect } from 'bun:test'
11
+ import { compileJSX, type ComponentIR } from '../index'
12
+ import { TestAdapter } from '../adapters/test-adapter'
13
+ import { parseExpression, type ParsedExpr } from '../expression-parser'
14
+ import {
15
+ resolveLocaleDatePattern,
16
+ matchToLocaleDateStringCall,
17
+ toLocaleDatePlugin,
18
+ } from '../to-locale-date-lowering'
19
+ import { ErrorCodes } from '../errors'
20
+
21
+ function compile(src: string) {
22
+ return compileJSX(src.trimStart(), 'T.tsx', { adapter: new TestAdapter(), outputIR: true })
23
+ }
24
+
25
+ function metadata(src: string): ComponentIR['metadata'] {
26
+ const result = compile(src)
27
+ const ir = JSON.parse(result.files.find((f) => f.type === 'ir')!.content) as ComponentIR
28
+ return ir.metadata
29
+ }
30
+
31
+ function callParts(expr: string): { callee: ParsedExpr; args: ParsedExpr[] } {
32
+ const parsed = parseExpression(expr)
33
+ if (parsed.kind !== 'call') throw new Error(`expected a call expression, got ${parsed.kind}`)
34
+ return { callee: parsed.callee, args: parsed.args }
35
+ }
36
+
37
+ const DATE_PROP_SRC = `
38
+ export function Foo({ createdAt }: { createdAt: Date }) {
39
+ return <div>{createdAt.toLocaleDateString('en-US', { timeZone: 'UTC' })}</div>
40
+ }
41
+ `
42
+
43
+ describe('resolveLocaleDatePattern (build-time derivation)', () => {
44
+ test('derives numeric default patterns per locale', () => {
45
+ expect(resolveLocaleDatePattern('en-US')).toBe('M/D/YYYY')
46
+ expect(resolveLocaleDatePattern('ja-JP')).toBe('YYYY/M/D')
47
+ expect(resolveLocaleDatePattern('en-GB')).toBe('DD/MM/YYYY')
48
+ })
49
+
50
+ test('declines non-gregorian / non-latin-digit defaults (ar-SA) and invalid tags', () => {
51
+ expect(resolveLocaleDatePattern('ar-SA')).toBeNull()
52
+ expect(resolveLocaleDatePattern('not a locale !!')).toBeNull()
53
+ })
54
+ })
55
+
56
+ describe('matchToLocaleDateStringCall accept/decline table', () => {
57
+ const md = metadata(DATE_PROP_SRC)
58
+
59
+ function match(expr: string) {
60
+ const { callee, args } = callParts(expr)
61
+ return matchToLocaleDateStringCall(callee, args, md)
62
+ }
63
+
64
+ test('literal locale + literal UTC timeZone lowers to format_date with the frozen pattern', () => {
65
+ const node = match(`createdAt.toLocaleDateString('en-US', { timeZone: 'UTC' })`)
66
+ expect(node).toEqual({
67
+ kind: 'helper-call',
68
+ helper: 'format_date',
69
+ args: [
70
+ { kind: 'identifier', name: 'createdAt' },
71
+ { kind: 'literal', value: 'M/D/YYYY', literalType: 'string' },
72
+ { kind: 'literal', value: 'UTC', literalType: 'string' },
73
+ { kind: 'array-literal', elements: [], raw: '[]' },
74
+ ],
75
+ })
76
+ })
77
+
78
+ test('a fixed ±HH:MM offset timeZone is admitted', () => {
79
+ const node = match(`createdAt.toLocaleDateString('ja-JP', { timeZone: '+09:00' })`)
80
+ expect(node).toMatchObject({
81
+ helper: 'format_date',
82
+ args: [
83
+ { kind: 'identifier', name: 'createdAt' },
84
+ { kind: 'literal', value: 'YYYY/M/D' },
85
+ { kind: 'literal', value: '+09:00' },
86
+ { kind: 'array-literal', elements: [] },
87
+ ],
88
+ })
89
+ })
90
+
91
+ test('implicit-environment and runtime-value shapes all decline', () => {
92
+ // zero-arg / locale-only: reads host locale and/or timezone
93
+ expect(match(`createdAt.toLocaleDateString()`)).toBeNull()
94
+ expect(match(`createdAt.toLocaleDateString('ja-JP')`)).toBeNull()
95
+ // non-literal locale: no build-time CLDR resolution
96
+ expect(match(`createdAt.toLocaleDateString(locale, { timeZone: 'UTC' })`)).toBeNull()
97
+ // IANA zone name: host-tzdata coupling
98
+ expect(match(`createdAt.toLocaleDateString('ja-JP', { timeZone: 'Asia/Tokyo' })`)).toBeNull()
99
+ // non-literal timeZone
100
+ expect(match(`createdAt.toLocaleDateString('ja-JP', { timeZone: tz })`)).toBeNull()
101
+ // out-of-range fixed offsets: real toLocaleDateString throws RangeError
102
+ // on these, so lowering them would diverge from JS semantics
103
+ expect(match(`createdAt.toLocaleDateString('ja-JP', { timeZone: '+25:00' })`)).toBeNull()
104
+ expect(match(`createdAt.toLocaleDateString('ja-JP', { timeZone: '+99:99' })`)).toBeNull()
105
+ expect(match(`createdAt.toLocaleDateString('ja-JP', { timeZone: '-12:60' })`)).toBeNull()
106
+ // an options bag WITHOUT timeZone still declines (host-TZ read)
107
+ expect(match(`createdAt.toLocaleDateString('ja-JP', { dateStyle: 'long' })`)).toBeNull()
108
+ // a non-literal option value declines (unprobeable)
109
+ expect(match(`createdAt.toLocaleDateString('en-US', { timeZone: 'UTC', dateStyle: style })`)).toBeNull()
110
+ // unrepresentable locale default
111
+ expect(match(`createdAt.toLocaleDateString('ar-SA', { timeZone: 'UTC' })`)).toBeNull()
112
+ })
113
+
114
+ test('a non-Date receiver never activates the plugin', () => {
115
+ const stringMd = metadata(`
116
+ export function Foo({ label }: { label: string }) {
117
+ return <div>{label}</div>
118
+ }
119
+ `)
120
+ expect(toLocaleDatePlugin.prepare(stringMd)).toBeNull()
121
+ })
122
+ })
123
+
124
+ describe('name tokens via the options bag (#2334)', () => {
125
+ const md = metadata(DATE_PROP_SRC)
126
+
127
+ function match(expr: string) {
128
+ const { callee, args } = callParts(expr)
129
+ return matchToLocaleDateStringCall(callee, args, md)
130
+ }
131
+
132
+ test('dateStyle long resolves a named pattern plus the 38-slot table', () => {
133
+ const node = match(`createdAt.toLocaleDateString('en-US', { dateStyle: 'long', timeZone: 'UTC' })`)
134
+ expect(node).toMatchObject({
135
+ helper: 'format_date',
136
+ args: [
137
+ { kind: 'identifier', name: 'createdAt' },
138
+ { kind: 'literal', value: 'MMMM D, YYYY' },
139
+ { kind: 'literal', value: 'UTC' },
140
+ { kind: 'array-literal' },
141
+ ],
142
+ })
143
+ const names = (node as { args: ParsedExpr[] }).args[3] as Extract<ParsedExpr, { kind: 'array-literal' }>
144
+ expect(names.elements).toHaveLength(38)
145
+ expect(names.elements[2]).toEqual({ kind: 'literal', value: 'March', literalType: 'string' })
146
+ expect(names.elements[24]).toEqual({ kind: 'literal', value: 'Sunday', literalType: 'string' })
147
+ })
148
+
149
+ test('dateStyle full adds the weekday token; medium picks abbreviated names', () => {
150
+ expect(match(`createdAt.toLocaleDateString('en-US', { dateStyle: 'full', timeZone: 'UTC' })`)).toMatchObject({
151
+ args: [expect.anything(), { kind: 'literal', value: 'dddd, MMMM D, YYYY' }, expect.anything(), expect.anything()],
152
+ })
153
+ expect(match(`createdAt.toLocaleDateString('en-US', { dateStyle: 'medium', timeZone: 'UTC' })`)).toMatchObject({
154
+ args: [expect.anything(), { kind: 'literal', value: 'MMM D, YYYY' }, expect.anything(), expect.anything()],
155
+ })
156
+ })
157
+
158
+ test("ja-JP's long form is numeric — the probe ships no table", () => {
159
+ expect(match(`createdAt.toLocaleDateString('ja-JP', { dateStyle: 'long', timeZone: 'UTC' })`)).toMatchObject({
160
+ args: [
161
+ expect.anything(),
162
+ { kind: 'literal', value: 'YYYY年M月D日' },
163
+ { kind: 'literal', value: 'UTC' },
164
+ { kind: 'array-literal', elements: [] },
165
+ ],
166
+ })
167
+ })
168
+
169
+ test('context-inflected month names pick the form the format actually uses (Copilot, #2336)', () => {
170
+ // Russian inflects month names by date context: dateStyle 'long'
171
+ // renders genitive `марта`, а month-only format nominative `март`.
172
+ // Each call site ships the table whose form ICU uses THERE, verified
173
+ // at a second instant so a probe-index coincidence can't slip through.
174
+ const long = match(`createdAt.toLocaleDateString('ru-RU', { dateStyle: 'long', timeZone: 'UTC' })`)
175
+ // NOTE: read args BEFORE any toMatchObject with expect.anything() —
176
+ // bun 1.3.11's toMatchObject MUTATES the received object, emptying
177
+ // entries an expect.anything() matched (minimal repro pinned in this
178
+ // PR's description; upstream bun bug).
179
+ const longNames = (long as { args: ParsedExpr[] }).args[3] as Extract<ParsedExpr, { kind: 'array-literal' }>
180
+ const longPattern = (long as { args: ParsedExpr[] }).args[1]
181
+ expect(longPattern).toEqual({ kind: 'literal', value: 'D MMMM YYYY г.', literalType: 'string' })
182
+ expect(longNames.elements[2]).toEqual({ kind: 'literal', value: 'марта', literalType: 'string' })
183
+
184
+ const monthOnly = match(`createdAt.toLocaleDateString('ru-RU', { month: 'long', timeZone: 'UTC' })`)
185
+ const standaloneNames = (monthOnly as { args: ParsedExpr[] }).args[3] as Extract<ParsedExpr, { kind: 'array-literal' }>
186
+ expect(standaloneNames.elements[2]).toEqual({ kind: 'literal', value: 'март', literalType: 'string' })
187
+ })
188
+
189
+ test('unreproducible forms decline loudly: 2-digit year, era', () => {
190
+ expect(match(`createdAt.toLocaleDateString('en-US', { dateStyle: 'short', timeZone: 'UTC' })`)).toBeNull()
191
+ expect(match(`createdAt.toLocaleDateString('en-US', { era: 'short', year: 'numeric', timeZone: 'UTC' })`)).toBeNull()
192
+ })
193
+
194
+ test('client rewrite carries the names table', () => {
195
+ const result = compile(`
196
+ export function Foo({ createdAt }: { createdAt: Date }) {
197
+ return <div>{createdAt.toLocaleDateString('en-US', { dateStyle: 'long', timeZone: 'UTC' })}</div>
198
+ }
199
+ `)
200
+ expect(result.errors.filter((e) => e.code === ErrorCodes.UNSUPPORTED_JSX_PATTERN)).toEqual([])
201
+ const js = result.files.find((f) => f.type === 'clientJs')!.content
202
+ expect(js).toContain('formatDate(_p.createdAt, "MMMM D, YYYY", "UTC", ["January","February"')
203
+ expect(js).not.toContain('toLocaleDateString')
204
+ })
205
+ })
206
+
207
+ describe('union-typed locale (#2324 union stage)', () => {
208
+ const UNION_SRC = `
209
+ function Foo({ createdAt, locale }: { createdAt: Date; locale: 'en-US' | 'ja-JP' }) {
210
+ return <div>{createdAt.toLocaleDateString(locale, { timeZone: 'UTC' })}</div>
211
+ }
212
+ export { Foo }
213
+ `
214
+
215
+ test('a required closed string-literal union lowers to a ternary pattern', () => {
216
+ const md = metadata(UNION_SRC)
217
+ const { callee, args } = callParts(`createdAt.toLocaleDateString(locale, { timeZone: 'UTC' })`)
218
+ expect(matchToLocaleDateStringCall(callee, args, md)).toEqual({
219
+ kind: 'helper-call',
220
+ helper: 'format_date',
221
+ args: [
222
+ { kind: 'identifier', name: 'createdAt' },
223
+ {
224
+ kind: 'conditional',
225
+ test: {
226
+ kind: 'binary',
227
+ op: '===',
228
+ left: { kind: 'identifier', name: 'locale' },
229
+ right: { kind: 'literal', value: 'en-US', literalType: 'string' },
230
+ },
231
+ consequent: { kind: 'literal', value: 'M/D/YYYY', literalType: 'string' },
232
+ alternate: { kind: 'literal', value: 'YYYY/M/D', literalType: 'string' },
233
+ },
234
+ { kind: 'literal', value: 'UTC', literalType: 'string' },
235
+ // both members are numeric-only, so the names tables are equal ([])
236
+ // and collapse to a single empty leaf
237
+ { kind: 'array-literal', elements: [], raw: '[]' },
238
+ ],
239
+ })
240
+ })
241
+
242
+ test('members sharing one pattern collapse the ternary to a literal', () => {
243
+ const md = metadata(`
244
+ function Foo({ createdAt, locale }: { createdAt: Date; locale: 'en-US' | 'en' }) {
245
+ return <div>{createdAt.toLocaleDateString(locale, { timeZone: 'UTC' })}</div>
246
+ }
247
+ export { Foo }
248
+ `)
249
+ const { callee, args } = callParts(`createdAt.toLocaleDateString(locale, { timeZone: 'UTC' })`)
250
+ expect(matchToLocaleDateStringCall(callee, args, md)).toMatchObject({
251
+ helper: 'format_date',
252
+ args: [
253
+ { kind: 'identifier', name: 'createdAt' },
254
+ { kind: 'literal', value: 'M/D/YYYY' },
255
+ { kind: 'literal', value: 'UTC' },
256
+ { kind: 'array-literal', elements: [] },
257
+ ],
258
+ })
259
+ })
260
+
261
+ test('object-props mode: accepts props.<name>, declines a bare identifier (never a prop there)', () => {
262
+ const md = metadata(`
263
+ export function Foo(props: { createdAt: Date; locale: 'en-US' | 'ja-JP' }) {
264
+ return <div>{props.createdAt.toLocaleDateString(props.locale, { timeZone: 'UTC' })}</div>
265
+ }
266
+ `)
267
+ const member = callParts(`props.createdAt.toLocaleDateString(props.locale, { timeZone: 'UTC' })`)
268
+ expect(matchToLocaleDateStringCall(member.callee, member.args, md)).toMatchObject({
269
+ helper: 'format_date',
270
+ args: [
271
+ expect.anything(),
272
+ { kind: 'conditional' },
273
+ { kind: 'literal', value: 'UTC' },
274
+ { kind: 'array-literal', elements: [] },
275
+ ],
276
+ })
277
+ // A bare `locale` identifier in object-props mode is a LOCAL binding,
278
+ // not the prop — even when a same-named prop exists (Copilot, #2331).
279
+ const bare = callParts(`props.createdAt.toLocaleDateString(locale, { timeZone: 'UTC' })`)
280
+ expect(matchToLocaleDateStringCall(bare.callee, bare.args, md)).toBeNull()
281
+ })
282
+
283
+ test('destructured mode: declines a props.<name> member (no props object exists)', () => {
284
+ const md = metadata(UNION_SRC)
285
+ const { callee, args } = callParts(`createdAt.toLocaleDateString(props.locale, { timeZone: 'UTC' })`)
286
+ expect(matchToLocaleDateStringCall(callee, args, md)).toBeNull()
287
+ })
288
+
289
+ test('declines an OPTIONAL union prop (undefined would read the host locale)', () => {
290
+ const md = metadata(`
291
+ function Foo({ createdAt, locale }: { createdAt: Date; locale?: 'en-US' | 'ja-JP' }) {
292
+ return <div>{/* @client */ createdAt.toLocaleDateString(locale ?? 'en-US', { timeZone: 'UTC' })}</div>
293
+ }
294
+ export { Foo }
295
+ `)
296
+ const { callee, args } = callParts(`createdAt.toLocaleDateString(locale, { timeZone: 'UTC' })`)
297
+ expect(matchToLocaleDateStringCall(callee, args, md)).toBeNull()
298
+ })
299
+
300
+ test('declines a union containing an unrepresentable member', () => {
301
+ const md = metadata(`
302
+ function Foo({ createdAt, locale }: { createdAt: Date; locale: 'en-US' | 'ar-SA' }) {
303
+ return <div>{/* @client */ createdAt.toLocaleDateString(locale, { timeZone: 'UTC' })}</div>
304
+ }
305
+ export { Foo }
306
+ `)
307
+ const { callee, args } = callParts(`createdAt.toLocaleDateString(locale, { timeZone: 'UTC' })`)
308
+ expect(matchToLocaleDateStringCall(callee, args, md)).toBeNull()
309
+ })
310
+
311
+ test('compiles clean (no BF021) and rewrites client JS to a ternary formatDate', () => {
312
+ const result = compile(UNION_SRC)
313
+ expect(result.errors.filter((e) => e.code === ErrorCodes.UNSUPPORTED_JSX_PATTERN)).toEqual([])
314
+ const js = result.files.find((f) => f.type === 'clientJs')!.content
315
+ expect(js).toContain(
316
+ 'formatDate(_p.createdAt, _p.locale === "en-US" ? "M/D/YYYY" : "YYYY/M/D", "UTC")',
317
+ )
318
+ expect(js).not.toContain('toLocaleDateString')
319
+ })
320
+ })
321
+
322
+ describe('BF021 exemption round trip (#2273 seam)', () => {
323
+ test('the claimed literal shape compiles clean; the runtime-locale shape still fires BF021', () => {
324
+ const clean = compile(DATE_PROP_SRC)
325
+ expect(clean.errors.filter((e) => e.code === ErrorCodes.UNSUPPORTED_JSX_PATTERN)).toEqual([])
326
+
327
+ const refused = compile(`
328
+ export function Foo({ createdAt, locale }: { createdAt: Date; locale: string }) {
329
+ return <div>{createdAt.toLocaleDateString(locale, { timeZone: 'UTC' })}</div>
330
+ }
331
+ `)
332
+ const bf021 = refused.errors.filter((e) => e.code === ErrorCodes.UNSUPPORTED_JSX_PATTERN)
333
+ expect(bf021.length).toBeGreaterThan(0)
334
+ // the refusal now points at the explicit-input forms
335
+ expect(bf021[0].suggestion?.message).toContain('formatDate')
336
+ })
337
+
338
+ test('the zero-arg form (date-method-uncatalogued shape) still fires BF021', () => {
339
+ const refused = compile(`
340
+ export function Foo({ createdAt }: { createdAt: Date }) {
341
+ return <div>{createdAt.toLocaleDateString()}</div>
342
+ }
343
+ `)
344
+ expect(refused.errors.some((e) => e.code === ErrorCodes.UNSUPPORTED_JSX_PATTERN)).toBe(true)
345
+ })
346
+ })
347
+
348
+ describe('client-JS rewrite (#2292-style parity)', () => {
349
+ function clientJs(src: string): string {
350
+ const result = compileJSX(src.trimStart(), 'T.tsx', { adapter: new TestAdapter() })
351
+ return result.files.find((f) => f.type === 'clientJs')!.content
352
+ }
353
+
354
+ test('rewrites the literal shape to formatDate with the frozen pattern and auto-imports it', () => {
355
+ const js = clientJs(DATE_PROP_SRC)
356
+ expect(js).toContain('formatDate(_p.createdAt, "M/D/YYYY", "UTC")')
357
+ expect(js).toMatch(/import\s*\{[^}]*\bformatDate\b[^}]*\}\s*from\s*'@barefootjs\/client\/runtime'/)
358
+ })
359
+
360
+ test('rewrites inside a reactive effect for a signal-conditioned expression', () => {
361
+ const js = clientJs(`
362
+ 'use client'
363
+ import { createSignal } from '@barefootjs/client'
364
+ export function Foo({ createdAt }: { createdAt: Date }) {
365
+ const [suffix, setSuffix] = createSignal('')
366
+ return <div onClick={() => setSuffix('!')}>{createdAt.toLocaleDateString('ja-JP', { timeZone: '+09:00' }) + suffix()}</div>
367
+ }
368
+ `)
369
+ expect(js).toContain('formatDate(_p.createdAt, "YYYY/M/D", "+09:00")')
370
+ expect(js).not.toContain('toLocaleDateString')
371
+ })
372
+
373
+ test('leaves the declined runtime-locale shape raw', () => {
374
+ const js = clientJs(`
375
+ export function Foo({ createdAt, locale }: { createdAt: Date; locale: string }) {
376
+ return <div>{/* @client */ createdAt.toLocaleDateString(locale, { timeZone: 'UTC' })}</div>
377
+ }
378
+ `)
379
+ expect(js).toContain('toLocaleDateString(')
380
+ expect(js).not.toContain('formatDate(')
381
+ })
382
+ })
@@ -117,7 +117,7 @@ export function importsSearchParams(metadata: IRMetadata): boolean {
117
117
  export function queryHrefLocalNames(metadata: IRMetadata): Set<string> {
118
118
  const names = new Set<string>()
119
119
  for (const imp of metadata.imports) {
120
- if (!QUERY_HREF_SOURCES.has(imp.source) || imp.isTypeOnly) continue
120
+ if (!CLIENT_HELPER_SOURCES.has(imp.source) || imp.isTypeOnly) continue
121
121
  for (const s of imp.specifiers) {
122
122
  if (s.isTypeOnly || s.isNamespace || s.isDefault) continue
123
123
  if (s.name === 'queryHref') names.add(s.alias ?? s.name)
@@ -126,12 +126,35 @@ export function queryHrefLocalNames(metadata: IRMetadata): Set<string> {
126
126
  return names
127
127
  }
128
128
 
129
- /** Entry points that re-export `queryHref` (main + the runtime re-export). */
130
- const QUERY_HREF_SOURCES: ReadonlySet<string> = new Set([
129
+ /**
130
+ * Entry points that re-export the pure client helpers with an SSR lowering
131
+ * (`queryHref` #2042, `formatDate` #2324) — the main entry and the runtime
132
+ * re-export. Importing from either must enable the lowering.
133
+ */
134
+ const CLIENT_HELPER_SOURCES: ReadonlySet<string> = new Set([
131
135
  '@barefootjs/client',
132
136
  '@barefootjs/client/runtime',
133
137
  ])
134
138
 
139
+ /**
140
+ * The local binding name(s) that `formatDate` is imported under in this
141
+ * component (#2324) — the pure-function date formatter an adapter lowers to
142
+ * its `format_date` helper (spec/template-helpers.md). Same resolution rules
143
+ * as {@link queryHrefLocalNames}: matched by exported name, gated on the LOCAL
144
+ * alias, accepted from both the main entry and the runtime re-export.
145
+ */
146
+ export function formatDateLocalNames(metadata: IRMetadata): Set<string> {
147
+ const names = new Set<string>()
148
+ for (const imp of metadata.imports) {
149
+ if (!CLIENT_HELPER_SOURCES.has(imp.source) || imp.isTypeOnly) continue
150
+ for (const s of imp.specifiers) {
151
+ if (s.isTypeOnly || s.isNamespace || s.isDefault) continue
152
+ if (s.name === 'formatDate') names.add(s.alias ?? s.name)
153
+ }
154
+ }
155
+ return names
156
+ }
157
+
135
158
  /**
136
159
  * Recognise a `<binding>().<method>(<args>)` env-signal method call from a
137
160
  * `call` node's callee + args, where `<binding>` is one of the local names
@@ -22,6 +22,7 @@ import type {
22
22
  ParamInfo,
23
23
  PropertyInfo,
24
24
  ReactiveFactoryInfo,
25
+ DeclinedReactiveFactory,
25
26
  } from './types.ts'
26
27
  import { type ExcludeRange, collectAllTypeRanges, reconstructWithoutTypes } from './strip-types.ts'
27
28
 
@@ -146,15 +147,26 @@ export interface AnalyzerContext {
146
147
  /** Maps multi-return JSX helper functions for conditional inlining at call sites. */
147
148
  jsxMultiReturnFunctions: Map<string, MultiReturnJsxInfo>
148
149
  /**
149
- * Maps function names to reactive-factory info (#931). A reactive factory
150
- * is a same-file helper whose body declares reactive primitives and
151
- * returns a tuple of identifiers, e.g.
150
+ * Maps factory-call-site-local names to reactive-factory info (#931,
151
+ * #2325). A reactive factory is a helper whose body declares reactive
152
+ * primitives and returns a tuple or shorthand-object of identifiers, e.g.
152
153
  * `function createCounter(initial) { const [c,s] = createSignal(initial); return [c, s] as const }`.
153
154
  * When a component destructures the result of a factory call, the factory
154
155
  * body is inlined at the call site so the compiler sees ordinary
155
- * `createSignal` declarations.
156
+ * `createSignal` declarations. Populated by `analyzeComponent` from the
157
+ * factory prescan (same-file declarations plus relative-imported
158
+ * factories resolved by `prescanImportedReactiveFactories`); consumed by
159
+ * `validateReactiveFactoryCalls`.
156
160
  */
157
161
  reactiveFactories: Map<string, ReactiveFactoryInfo>
162
+ /** Factories recognized but declined for inlining, keyed by call-site-local name (#2325). */
163
+ declinedReactiveFactories: Map<string, DeclinedReactiveFactory>
164
+ /** Module-scope helpers (same-file or resolved import) whose body contains a
165
+ * reactive primitive call but whose shape is not an inlinable factory. */
166
+ reactiveShapedHelpers: Set<string>
167
+ /** Imported destructured-callee names whose helper file was resolved, read,
168
+ * and found reactive-free / factory-free — proven safe to leave alone. */
169
+ cleanFactoryImports: Set<string>
158
170
  /**
159
171
  * Intermediate `const s = createSignal(...)` tuples awaiting `s[0]`/`s[1]`
160
172
  * extraction. Flushed into `signals` at the end of visitComponentBody.
@@ -247,6 +259,9 @@ export function createAnalyzerContext(
247
259
  jsxFunctions: new Map(),
248
260
  jsxMultiReturnFunctions: new Map(),
249
261
  reactiveFactories: new Map(),
262
+ declinedReactiveFactories: new Map(),
263
+ reactiveShapedHelpers: new Set(),
264
+ cleanFactoryImports: new Set(),
250
265
  signalTupleRefs: new Map(),
251
266
 
252
267
  propsType: null,