@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.
- 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 +2 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/format-date-lowering.d.ts +27 -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 +706 -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 +72 -0
- package/dist/to-locale-date-lowering.d.ts.map +1 -0
- package/dist/types.d.ts +23 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/format-date-lowering.test.ts +109 -0
- package/src/__tests__/reactive-factory-cross-file.test.ts +239 -0
- package/src/__tests__/reactive-factory-inlining.test.ts +293 -4
- package/src/__tests__/to-locale-date-lowering.test.ts +181 -0
- package/src/adapters/env-signal.ts +26 -3
- package/src/analyzer-context.ts +19 -4
- package/src/analyzer.ts +712 -91
- package/src/builtin-lowering-plugins.ts +8 -1
- package/src/date-lowering.ts +1 -1
- package/src/errors.ts +13 -0
- package/src/format-date-lowering.ts +51 -0
- package/src/index.ts +1 -1
- package/src/ir-to-client-js/emit-reactive.ts +80 -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 +79 -1
- package/src/rich-type-refusal.ts +9 -1
- package/src/to-locale-date-lowering.ts +175 -0
- 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[] = [
|
|
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,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 =
|
|
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(
|
|
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 } 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
|
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
|
}
|
|
@@ -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
|
-
/**
|
|
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 {
|