@statorjs/stator 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +104 -0
  3. package/package.json +99 -0
  4. package/src/build/build.ts +244 -0
  5. package/src/build/head.ts +43 -0
  6. package/src/build/index.ts +10 -0
  7. package/src/build/sync.ts +65 -0
  8. package/src/client/bind.ts +47 -0
  9. package/src/client/client-id.ts +10 -0
  10. package/src/client/dispatch.ts +62 -0
  11. package/src/client/element.ts +156 -0
  12. package/src/client/index.ts +13 -0
  13. package/src/client/inspector.ts +237 -0
  14. package/src/client/machine.ts +39 -0
  15. package/src/client/runtime.ts +246 -0
  16. package/src/client/use.ts +119 -0
  17. package/src/compiler/client-emit.ts +180 -0
  18. package/src/compiler/client-script.ts +256 -0
  19. package/src/compiler/compile.ts +459 -0
  20. package/src/compiler/diagnostics.ts +65 -0
  21. package/src/compiler/dts.ts +87 -0
  22. package/src/compiler/hash.ts +8 -0
  23. package/src/compiler/index.ts +48 -0
  24. package/src/compiler/lower.ts +0 -0
  25. package/src/compiler/regions.ts +79 -0
  26. package/src/compiler/split.ts +168 -0
  27. package/src/compiler/styles.ts +200 -0
  28. package/src/compiler/virtual-code.ts +184 -0
  29. package/src/components/index.ts +2 -0
  30. package/src/components/json-ld.ts +85 -0
  31. package/src/engine/actor.ts +248 -0
  32. package/src/engine/define-machine.ts +113 -0
  33. package/src/engine/index.ts +37 -0
  34. package/src/engine/types.ts +236 -0
  35. package/src/server/api-route.ts +149 -0
  36. package/src/server/app-dispatch.ts +52 -0
  37. package/src/server/app-store.ts +35 -0
  38. package/src/server/cached-store.ts +117 -0
  39. package/src/server/create-app.ts +83 -0
  40. package/src/server/define-machine.ts +10 -0
  41. package/src/server/dev.ts +255 -0
  42. package/src/server/discovery.ts +111 -0
  43. package/src/server/dispatch-context.ts +39 -0
  44. package/src/server/effects.ts +120 -0
  45. package/src/server/http.ts +475 -0
  46. package/src/server/index.ts +101 -0
  47. package/src/server/instance-proxy.ts +95 -0
  48. package/src/server/logger.ts +52 -0
  49. package/src/server/machine-store.ts +355 -0
  50. package/src/server/reads-helpers.ts +40 -0
  51. package/src/server/recompute.ts +287 -0
  52. package/src/server/redis-store.ts +116 -0
  53. package/src/server/render-context.ts +228 -0
  54. package/src/server/render.ts +111 -0
  55. package/src/server/route-discovery.ts +226 -0
  56. package/src/server/route-request.ts +36 -0
  57. package/src/server/routing.ts +175 -0
  58. package/src/server/session-lock.ts +33 -0
  59. package/src/server/session-runtime.ts +242 -0
  60. package/src/server/session.ts +29 -0
  61. package/src/server/sse.ts +175 -0
  62. package/src/server/store.ts +95 -0
  63. package/src/stator-modules.d.ts +12 -0
  64. package/src/template/client-shell.ts +65 -0
  65. package/src/template/conditional.ts +166 -0
  66. package/src/template/directives/core.ts +58 -0
  67. package/src/template/directives/list-attr.ts +183 -0
  68. package/src/template/directives/on.ts +22 -0
  69. package/src/template/each.ts +295 -0
  70. package/src/template/html.ts +180 -0
  71. package/src/template/index.ts +29 -0
  72. package/src/template/parser.ts +341 -0
  73. package/src/template/read.ts +50 -0
  74. package/src/template/types.ts +33 -0
  75. package/src/vite/index.ts +6 -0
  76. package/src/vite/plugin.ts +151 -0
  77. package/src/vite/stub.ts +79 -0
  78. package/src/wire/apply.ts +103 -0
  79. package/src/wire/index.ts +67 -0
@@ -0,0 +1,246 @@
1
+ /**
2
+ * stator client runtime.
3
+ *
4
+ * Responsibilities:
5
+ * 1. Attach delegated event listeners on document.body for a fixed set of
6
+ * DOM event types. On fire, look for the nearest ancestor carrying
7
+ * `data-event-<type>="..."` and POST the JSON descriptor to /__events.
8
+ * 2. Intercept form submissions: when the form has a `data-event-submit`
9
+ * descriptor, follow the descriptor path; otherwise POST the form's
10
+ * FormData to its `action` URL.
11
+ * 3. Apply patches in the response according to the wire protocol — see
12
+ * WIRE.md. Two target kinds (slot, element) × three ops (text, html,
13
+ * attr) currently implemented.
14
+ * 4. Apply directives (navigate, reload, etc.) after patches.
15
+ * 5. Dispatch `stator:*` CustomEvents on `window` at protocol edges so
16
+ * inspectors and devtools can observe the traffic without monkey-
17
+ * patching. See "Observability hooks" below for the contract.
18
+ */
19
+
20
+ import { applyDirectives, applyPatches } from '../wire/apply.ts'
21
+ import type { WireEnvelope } from '../wire/index.ts'
22
+ import { clientId } from './client-id.ts'
23
+
24
+ const EVENT_TYPES = ['click', 'submit', 'change', 'input'] as const
25
+
26
+ /* ------------------------------------------------------------------ */
27
+ /* Observability hooks */
28
+ /* ------------------------------------------------------------------ */
29
+
30
+ function emit(name: string, detail: unknown): void {
31
+ window.dispatchEvent(new CustomEvent(name, { detail }))
32
+ }
33
+
34
+ /* ------------------------------------------------------------------ */
35
+ /* Event delegation + dispatch */
36
+ /* ------------------------------------------------------------------ */
37
+
38
+ function init(): void {
39
+ for (const type of EVENT_TYPES) {
40
+ document.body.addEventListener(type, handleEvent)
41
+ }
42
+ initLiveChannel()
43
+ }
44
+
45
+ function initLiveChannel(): void {
46
+ const meta = document.querySelector('meta[name="stator-live"][content="true"]')
47
+ if (!meta) return
48
+
49
+ const routeKey = `GET ${location.pathname}${location.search}`
50
+ const url = `/__sse?route=${encodeURIComponent(routeKey)}&client=${encodeURIComponent(clientId)}`
51
+ const sse = new EventSource(url, { withCredentials: true })
52
+
53
+ let everOpened = false
54
+ sse.addEventListener('open', () => {
55
+ if (everOpened) {
56
+ // Reconnect — reload rather than risk stale state.
57
+ location.reload()
58
+ return
59
+ }
60
+ everOpened = true
61
+ })
62
+
63
+ sse.addEventListener('message', (e) => {
64
+ let data: WireEnvelope
65
+ try {
66
+ data = JSON.parse(e.data)
67
+ } catch (err) {
68
+ console.error('stator: malformed SSE message', err)
69
+ return
70
+ }
71
+ if (data.patches) {
72
+ emit('stator:patches-received', {
73
+ patches: data.patches,
74
+ source: 'sse',
75
+ timestamp: Date.now(),
76
+ })
77
+ applyPatches(data.patches)
78
+ }
79
+ if (data.directives && data.directives.length > 0) {
80
+ applyDirectives(data.directives)
81
+ }
82
+ })
83
+
84
+ sse.addEventListener('error', () => {
85
+ if (sse.readyState === EventSource.CLOSED) {
86
+ console.warn('stator: SSE permanently closed')
87
+ }
88
+ })
89
+ }
90
+
91
+ function handleEvent(e: Event): void {
92
+ const target = e.target as Element | null
93
+ if (!target) return
94
+
95
+ // Form submissions: prefer data-event-submit descriptor if present,
96
+ // otherwise intercept based on form's action attribute.
97
+ if (e.type === 'submit') {
98
+ const form = target.closest('form') as HTMLFormElement | null
99
+ if (form) {
100
+ const descriptorAttr = form.getAttribute('data-event-submit')
101
+ if (descriptorAttr) {
102
+ e.preventDefault()
103
+ let descriptor: { machine: string; event: { type: string } }
104
+ try {
105
+ descriptor = JSON.parse(descriptorAttr)
106
+ } catch {
107
+ console.error('stator: malformed event descriptor on form', form, descriptorAttr)
108
+ return
109
+ }
110
+ void dispatchEvent(descriptor)
111
+ return
112
+ }
113
+ // Opt-in interception via `data-stator-enhance`. Plain forms without
114
+ // the attribute submit normally — they may legitimately point at
115
+ // third-party endpoints, or want browser-default behavior for SEO,
116
+ // accessibility, or focus management. Auto-intercepting every form
117
+ // would silently change HTML semantics in ways the developer never
118
+ // asked for.
119
+ if (
120
+ form.hasAttribute('data-stator-enhance') &&
121
+ form.action &&
122
+ form.method.toLowerCase() === 'post'
123
+ ) {
124
+ e.preventDefault()
125
+ void submitForm(form)
126
+ return
127
+ }
128
+ // Fall through: nothing to intercept, browser default submit.
129
+ return
130
+ }
131
+ }
132
+
133
+ const attrName = `data-event-${e.type}`
134
+ const el = target.closest(`[${attrName}]`)
135
+ if (!el) return
136
+ const raw = el.getAttribute(attrName)
137
+ if (!raw) return
138
+
139
+ let descriptor: { machine: string; event: { type: string } }
140
+ try {
141
+ descriptor = JSON.parse(raw)
142
+ } catch {
143
+ console.error('stator: malformed event descriptor on', el, raw)
144
+ return
145
+ }
146
+
147
+ void dispatchEvent(descriptor)
148
+ }
149
+
150
+ async function dispatchEvent(descriptor: {
151
+ machine: string
152
+ event: { type: string }
153
+ }): Promise<void> {
154
+ const routeKey = `GET ${location.pathname}${location.search}`
155
+
156
+ emit('stator:event-sent', {
157
+ machine: descriptor.machine,
158
+ event: descriptor.event,
159
+ routeKey,
160
+ timestamp: Date.now(),
161
+ })
162
+
163
+ const startedAt = performance.now()
164
+ let res: Response
165
+ try {
166
+ res = await fetch('/__events', {
167
+ method: 'POST',
168
+ headers: {
169
+ 'Content-Type': 'application/json',
170
+ Accept: 'application/json',
171
+ 'X-Stator-Route': routeKey,
172
+ 'X-Stator-Client': clientId,
173
+ },
174
+ credentials: 'same-origin',
175
+ body: JSON.stringify(descriptor),
176
+ })
177
+ } catch (err) {
178
+ console.error('stator: network error during dispatch', err)
179
+ return
180
+ }
181
+ if (!res.ok) {
182
+ console.error('stator: event POST failed', res.status, await res.text())
183
+ return
184
+ }
185
+ await applyEnvelopeFromResponse(res, startedAt, 'post')
186
+ }
187
+
188
+ /**
189
+ * Submit a plain HTML form to its action URL with FormData. Signals
190
+ * `Accept: application/json` so the server returns the directives envelope
191
+ * even though the form looks like a normal browser submission.
192
+ */
193
+ async function submitForm(form: HTMLFormElement): Promise<void> {
194
+ const formData = new FormData(form)
195
+ const startedAt = performance.now()
196
+ let res: Response
197
+ try {
198
+ res = await fetch(form.action, {
199
+ method: 'POST',
200
+ headers: { Accept: 'application/json' },
201
+ credentials: 'same-origin',
202
+ body: formData,
203
+ })
204
+ } catch (err) {
205
+ console.error('stator: network error during form submit', err)
206
+ return
207
+ }
208
+ if (!res.ok) {
209
+ console.error('stator: form submit failed', res.status, await res.text())
210
+ return
211
+ }
212
+ await applyEnvelopeFromResponse(res, startedAt, 'post')
213
+ }
214
+
215
+ async function applyEnvelopeFromResponse(
216
+ res: Response,
217
+ startedAt: number,
218
+ source: 'post' | 'sse',
219
+ ): Promise<void> {
220
+ let data: WireEnvelope
221
+ try {
222
+ data = await res.json()
223
+ } catch (err) {
224
+ console.error('stator: malformed response', err)
225
+ return
226
+ }
227
+ const durationMs = Math.round(performance.now() - startedAt)
228
+ if (data.patches) {
229
+ emit('stator:patches-received', {
230
+ patches: data.patches,
231
+ source,
232
+ durationMs,
233
+ timestamp: Date.now(),
234
+ })
235
+ applyPatches(data.patches)
236
+ }
237
+ if (data.directives && data.directives.length > 0) {
238
+ applyDirectives(data.directives)
239
+ }
240
+ }
241
+
242
+ if (document.readyState === 'loading') {
243
+ document.addEventListener('DOMContentLoaded', init)
244
+ } else {
245
+ init()
246
+ }
@@ -0,0 +1,119 @@
1
+ import {
2
+ type AnyActor,
3
+ type AnyMachineDef,
4
+ createActor,
5
+ type MachineDef,
6
+ type Snapshot,
7
+ } from '../engine/index.ts'
8
+
9
+ const CLIENT_HELPERS = {
10
+ reads: new Proxy(
11
+ {},
12
+ {
13
+ get(_t, prop: string) {
14
+ throw new Error(`stator: client machines have no reads (accessed reads.${prop})`)
15
+ },
16
+ },
17
+ ),
18
+ } as never
19
+
20
+ /**
21
+ * The client-side reactive handle for a machine. Returned by `use()`, held as a
22
+ * class field (`qty = use(Qty)`). Exposes:
23
+ * - each selector / context key as a live property (read through the actor's
24
+ * current snapshot on every access — the client mirror of the server
25
+ * instance proxy). This is what `bind:text={qty.count}` reads.
26
+ * - `send(event)` to drive transitions.
27
+ * - the underlying actor (non-enumerable) for the binding loop to subscribe.
28
+ */
29
+ export interface ClientInstance {
30
+ send(event: { type: string; [k: string]: unknown } | string): void
31
+ /** @internal — the actor a binding subscribes to. */
32
+ readonly __actor: AnyActor
33
+ }
34
+
35
+ /** An actor plus an optional deferred seed thunk — evaluated at the element's
36
+ * connect (when attributes are available), not at construction. */
37
+ export interface CollectedActor {
38
+ actor: AnyActor
39
+ seedThunk?: () => Record<string, unknown>
40
+ }
41
+
42
+ /** Active collector: `use()` registers its actor here during element
43
+ * construction, so `StatorElement` can start/seed/stop them on connect/disconnect.
44
+ * A stack supports nested construction (rare, but correct). */
45
+ const collectors: CollectedActor[][] = []
46
+
47
+ export function pushCollector(): CollectedActor[] {
48
+ const bucket: CollectedActor[] = []
49
+ collectors.push(bucket)
50
+ return bucket
51
+ }
52
+
53
+ export function popCollector(): void {
54
+ collectors.pop()
55
+ }
56
+
57
+ /**
58
+ * Instantiate a client machine, owned by the constructing element. The optional
59
+ * seed sets initial context (the narrow hydration seed). A plain object is
60
+ * applied eagerly; a **thunk** `() => ({...})` is deferred to the element's
61
+ * connect — required when the seed reads `this.attrs`, since attributes aren't
62
+ * available at construction (the custom-element upgrade-timing rule).
63
+ */
64
+ export function use(
65
+ def: MachineDef,
66
+ seed?: Record<string, unknown> | (() => Record<string, unknown>),
67
+ ): ClientInstance {
68
+ const eager = typeof seed === 'object' ? seed : undefined
69
+ const snapshot: Snapshot<object> | undefined = eager
70
+ ? {
71
+ value: [def.initial],
72
+ context: { ...(def.context as object), ...eager },
73
+ }
74
+ : undefined
75
+ const actor = createActor(def as AnyMachineDef, { snapshot })
76
+
77
+ // Register with the element under construction so its lifecycle owns the actor.
78
+ const bucket = collectors[collectors.length - 1]
79
+ if (bucket)
80
+ bucket.push({
81
+ actor,
82
+ seedThunk: typeof seed === 'function' ? seed : undefined,
83
+ })
84
+
85
+ const inst = Object.create(null) as Record<string | symbol, unknown>
86
+
87
+ // Selectors + context keys as live getters reading the current snapshot.
88
+ const selectorNames = Object.keys(def.selectors)
89
+ const contextNames = Object.keys(def.context as object)
90
+ for (const key of new Set([...selectorNames, ...contextNames])) {
91
+ Object.defineProperty(inst, key, {
92
+ enumerable: true,
93
+ get: () => {
94
+ const ctx = actor.getSnapshot().context
95
+ const sel = def.selectors[key]
96
+ // Client machines have no cross-machine reads; pass a stub so the
97
+ // shared SelectorMap signature holds.
98
+ return sel ? sel(ctx, CLIENT_HELPERS) : (ctx as Record<string, unknown>)[key]
99
+ },
100
+ })
101
+ }
102
+
103
+ Object.defineProperty(inst, 'send', {
104
+ enumerable: false,
105
+ value: (event: { type: string; [k: string]: unknown } | string) =>
106
+ actor.send(typeof event === 'string' ? ({ type: event } as never) : (event as never)),
107
+ })
108
+ Object.defineProperty(inst, '__actor', {
109
+ enumerable: false,
110
+ get: () => actor,
111
+ })
112
+
113
+ return inst as unknown as ClientInstance
114
+ }
115
+
116
+ /** Extract the actor from a `use()` instance (for the binding loop). */
117
+ export function actorOf(inst: ClientInstance): AnyActor {
118
+ return inst.__actor
119
+ }
@@ -0,0 +1,180 @@
1
+ import ts from 'typescript'
2
+ import type { ClientDirective, ClientElement } from './client-script.ts'
3
+ import { CompileError } from './diagnostics.ts'
4
+
5
+ /**
6
+ * Phase 3b stage 5 — emit the client entry module for a client component: the
7
+ * author's `<script>` (with auto-injected primitives) plus a generated subclass
8
+ * whose `setup()` wires the collected directives, then `defineElement`.
9
+ *
10
+ * Member references in template expressions (`qty.count`, `inc`) are class
11
+ * members, so they're rewritten to `this.<member>` inside the generated setup().
12
+ * A subclass — rather than AST surgery on the author's class — keeps the user
13
+ * class as written; the subclass overrides `setup()`.
14
+ */
15
+
16
+ const PRIMITIVES =
17
+ "import { StatorElement, defineElement, use, machine, bind, effect, dispatch } from '@statorjs/stator/client'"
18
+
19
+ export interface EmitClientInput {
20
+ /** The author's `<script>` source. */
21
+ script: string
22
+ element: ClientElement
23
+ directives: ClientDirective[]
24
+ /** All class member names (fields + methods) for `this.` rewriting. */
25
+ members: Set<string>
26
+ }
27
+
28
+ export function emitClientModule(input: EmitClientInput): string {
29
+ const { script, element, directives, members } = input
30
+ const impl = `__${element.className}Impl`
31
+
32
+ // Group directives by node marker (one querySelector per marked element).
33
+ const byMarker = new Map<string, ClientDirective[]>()
34
+ for (const d of directives) {
35
+ const list = byMarker.get(d.marker) ?? []
36
+ list.push(d)
37
+ byMarker.set(d.marker, list)
38
+ }
39
+
40
+ const lines: string[] = []
41
+ let i = 0
42
+ for (const [marker, group] of byMarker) {
43
+ const node = `n${i++}`
44
+ lines.push(` const ${node} = this.querySelector('[data-b="${marker}"]')`)
45
+ lines.push(` if (${node}) {`)
46
+ for (const d of group) lines.push(` ${wireDirective(node, d, members)}`)
47
+ lines.push(' }')
48
+ }
49
+
50
+ return [
51
+ PRIMITIVES,
52
+ '',
53
+ stripClientPrimitiveImports(script).trim(),
54
+ '',
55
+ `class ${impl} extends ${element.className} {`,
56
+ ' setup() {',
57
+ ...lines,
58
+ ' }',
59
+ '}',
60
+ `defineElement(${impl}, ${JSON.stringify(element.tag)})`,
61
+ '',
62
+ ].join('\n')
63
+ }
64
+
65
+ function wireDirective(node: string, d: ClientDirective, members: Set<string>): string {
66
+ if (d.kind === 'on') {
67
+ const handler = emitHandler(d.expr, members)
68
+ return `${node}.addEventListener(${JSON.stringify(d.event)}, ${handler})`
69
+ }
70
+ // bind:
71
+ const target = d.target ?? 'text'
72
+ const thunk = `() => (${rewriteMembers(d.expr, members)})`
73
+ const deps = `[${d.deps.map((dep) => `this.${dep}`).join(', ')}]`
74
+
75
+ // value / checked are TWO-WAY: state→DOM (below) PLUS a DOM→state listener
76
+ // that `@set`s the bound context key. The expression must be a settable
77
+ // `<actor>.<key>` path (a derived selector can't be assigned).
78
+ if (target === 'value' || target === 'checked') {
79
+ const path = parseTwoWayPath(d.expr)
80
+ if (!path) {
81
+ throw new CompileError(
82
+ `stator: bind:${target}={${d.expr}} must bind to a settable context path ` +
83
+ `like \`actor.key\` (a derived value can't be two-way bound).`,
84
+ )
85
+ }
86
+ const writer = emitWriter(node, target)
87
+ const read = target === 'checked' ? `${node}.checked` : `${node}.value`
88
+ const guard = target === 'value' ? `if (e.isComposing) return; ` : ''
89
+ const setter =
90
+ `${node}.addEventListener(${JSON.stringify(target === 'value' ? 'input' : 'change')}, (e) => { ` +
91
+ `${guard}this.${path.actor}.send({ type: '@set', key: ${JSON.stringify(path.key)}, value: ${read} }) })`
92
+ return `this.track(bind(${deps}, ${thunk}, ${writer}));\n ${setter}`
93
+ }
94
+
95
+ const writer = emitWriter(node, target)
96
+ return `this.track(bind(${deps}, ${thunk}, ${writer}))`
97
+ }
98
+
99
+ /** Parse a two-way bind expression `actor.key` into its parts; null if it isn't
100
+ * a simple single-level member access (not assignable). */
101
+ function parseTwoWayPath(expr: string): { actor: string; key: string } | null {
102
+ const m = expr.trim().match(/^([A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$]*)$/)
103
+ return m ? { actor: m[1]!, key: m[2]! } : null
104
+ }
105
+
106
+ /** on: handler — a bare method reference becomes `(e) => this.m(e)`; any other
107
+ * expression is used directly (with member references rewritten). */
108
+ function emitHandler(expr: string, members: Set<string>): string {
109
+ const t = expr.trim()
110
+ if (/^[A-Za-z_$][\w$]*$/.test(t) && members.has(t)) {
111
+ return `(e) => this.${t}(e)`
112
+ }
113
+ return rewriteMembers(expr, members)
114
+ }
115
+
116
+ function emitWriter(node: string, target: string): string {
117
+ switch (target) {
118
+ case 'text':
119
+ return `(v) => { ${node}.textContent = v == null ? '' : String(v) }`
120
+ case 'html':
121
+ return `(v) => { ${node}.innerHTML = v == null ? '' : String(v) }`
122
+ case 'value':
123
+ // Loop-break: only write when the DOM differs, so the echo from the user's
124
+ // own keystroke no-ops and the caret is preserved.
125
+ return `(v) => { const s = v == null ? '' : String(v); if (${node}.value !== s) ${node}.value = s }`
126
+ case 'disabled':
127
+ case 'hidden':
128
+ case 'checked':
129
+ return `(v) => { if (${node}.${target} !== !!v) ${node}.${target} = !!v }`
130
+ default:
131
+ // arbitrary attribute
132
+ return `(v) => { if (v == null || v === false) ${node}.removeAttribute(${JSON.stringify(target)}); else ${node}.setAttribute(${JSON.stringify(target)}, v === true ? '' : String(v)) }`
133
+ }
134
+ }
135
+
136
+ /** Remove any author import from `@statorjs/stator/client` — those primitives are
137
+ * auto-injected (the `PRIMITIVES` line), so an author's habit-import would be a
138
+ * duplicate binding. Server machine imports (for `dispatch`) are untouched. */
139
+ function stripClientPrimitiveImports(script: string): string {
140
+ return script.replace(
141
+ /^\s*import\s+\{[^}]*\}\s+from\s+['"]@statorjs\/stator\/client['"]\s*;?\s*$/gm,
142
+ '',
143
+ )
144
+ }
145
+
146
+ /** Prefix class-member identifiers in an expression with `this.` (skipping the
147
+ * property-name side of member access, so `qty.count` → `this.qty.count`). */
148
+ export function rewriteMembers(expr: string, members: Set<string>): string {
149
+ const sf = ts.createSourceFile(
150
+ 'e.ts',
151
+ `(${expr})`,
152
+ ts.ScriptTarget.Latest,
153
+ true,
154
+ ts.ScriptKind.TS,
155
+ )
156
+ const repls: Array<[start: number, end: number]> = []
157
+ const visit = (n: ts.Node): void => {
158
+ if (ts.isIdentifier(n)) {
159
+ const isPropName = n.parent && ts.isPropertyAccessExpression(n.parent) && n.parent.name === n
160
+ // skip shorthand/binding contexts where `this.` would be invalid
161
+ const isDeclName =
162
+ n.parent &&
163
+ (ts.isParameter(n.parent) || ts.isBindingElement(n.parent)) &&
164
+ n.parent.name === n
165
+ if (!isPropName && !isDeclName && members.has(n.text)) {
166
+ repls.push([n.getStart(sf), n.getEnd()])
167
+ }
168
+ }
169
+ ts.forEachChild(n, visit)
170
+ }
171
+ visit(sf)
172
+
173
+ // `(${expr})` adds a leading `(` (offset 1). Strip it back out by slicing.
174
+ let text = `(${expr})`
175
+ repls.sort((a, b) => b[0] - a[0])
176
+ for (const [start, end] of repls) {
177
+ text = `${text.slice(0, start)}this.${text.slice(start, end)}${text.slice(end)}`
178
+ }
179
+ return text.slice(1, -1) // remove the wrapping parens
180
+ }