@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.
- package/LICENSE +21 -0
- package/README.md +104 -0
- package/package.json +99 -0
- package/src/build/build.ts +244 -0
- package/src/build/head.ts +43 -0
- package/src/build/index.ts +10 -0
- package/src/build/sync.ts +65 -0
- package/src/client/bind.ts +47 -0
- package/src/client/client-id.ts +10 -0
- package/src/client/dispatch.ts +62 -0
- package/src/client/element.ts +156 -0
- package/src/client/index.ts +13 -0
- package/src/client/inspector.ts +237 -0
- package/src/client/machine.ts +39 -0
- package/src/client/runtime.ts +246 -0
- package/src/client/use.ts +119 -0
- package/src/compiler/client-emit.ts +180 -0
- package/src/compiler/client-script.ts +256 -0
- package/src/compiler/compile.ts +459 -0
- package/src/compiler/diagnostics.ts +65 -0
- package/src/compiler/dts.ts +87 -0
- package/src/compiler/hash.ts +8 -0
- package/src/compiler/index.ts +48 -0
- package/src/compiler/lower.ts +0 -0
- package/src/compiler/regions.ts +79 -0
- package/src/compiler/split.ts +168 -0
- package/src/compiler/styles.ts +200 -0
- package/src/compiler/virtual-code.ts +184 -0
- package/src/components/index.ts +2 -0
- package/src/components/json-ld.ts +85 -0
- package/src/engine/actor.ts +248 -0
- package/src/engine/define-machine.ts +113 -0
- package/src/engine/index.ts +37 -0
- package/src/engine/types.ts +236 -0
- package/src/server/api-route.ts +149 -0
- package/src/server/app-dispatch.ts +52 -0
- package/src/server/app-store.ts +35 -0
- package/src/server/cached-store.ts +117 -0
- package/src/server/create-app.ts +83 -0
- package/src/server/define-machine.ts +10 -0
- package/src/server/dev.ts +255 -0
- package/src/server/discovery.ts +111 -0
- package/src/server/dispatch-context.ts +39 -0
- package/src/server/effects.ts +120 -0
- package/src/server/http.ts +475 -0
- package/src/server/index.ts +101 -0
- package/src/server/instance-proxy.ts +95 -0
- package/src/server/logger.ts +52 -0
- package/src/server/machine-store.ts +355 -0
- package/src/server/reads-helpers.ts +40 -0
- package/src/server/recompute.ts +287 -0
- package/src/server/redis-store.ts +116 -0
- package/src/server/render-context.ts +228 -0
- package/src/server/render.ts +111 -0
- package/src/server/route-discovery.ts +226 -0
- package/src/server/route-request.ts +36 -0
- package/src/server/routing.ts +175 -0
- package/src/server/session-lock.ts +33 -0
- package/src/server/session-runtime.ts +242 -0
- package/src/server/session.ts +29 -0
- package/src/server/sse.ts +175 -0
- package/src/server/store.ts +95 -0
- package/src/stator-modules.d.ts +12 -0
- package/src/template/client-shell.ts +65 -0
- package/src/template/conditional.ts +166 -0
- package/src/template/directives/core.ts +58 -0
- package/src/template/directives/list-attr.ts +183 -0
- package/src/template/directives/on.ts +22 -0
- package/src/template/each.ts +295 -0
- package/src/template/html.ts +180 -0
- package/src/template/index.ts +29 -0
- package/src/template/parser.ts +341 -0
- package/src/template/read.ts +50 -0
- package/src/template/types.ts +33 -0
- package/src/vite/index.ts +6 -0
- package/src/vite/plugin.ts +151 -0
- package/src/vite/stub.ts +79 -0
- package/src/wire/apply.ts +103 -0
- 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
|
+
}
|