@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
|
@@ -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[] = [
|
|
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
|
package/src/date-lowering.ts
CHANGED
|
@@ -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 =
|
|
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(
|
|
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
|
-
|
|
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
|
package/src/rich-type-refusal.ts
CHANGED
|
@@ -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:
|
|
113
|
+
message: suggestion,
|
|
106
114
|
},
|
|
107
115
|
})
|
|
108
116
|
}
|