webspresso 0.0.108 → 0.0.110
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 +3 -3
- package/bin/commands/add-esbuild.js +7 -3
- package/bin/commands/add-tailwind.js +9 -5
- package/bin/commands/build.js +20 -4
- package/bin/commands/dev.js +57 -7
- package/bin/commands/doctor.js +42 -1
- package/bin/commands/favicon-generate.js +10 -3
- package/bin/commands/new.js +8 -3
- package/bin/commands/start.js +3 -1
- package/bin/utils/add-command.js +16 -0
- package/core/app.js +45 -3
- package/core/auth/authenticate.js +3 -3
- package/core/auth/hash.js +167 -43
- package/core/auth/index.js +13 -1
- package/core/auth/jwt.js +21 -2
- package/core/auth/manager.js +63 -14
- package/core/auth/middleware.js +4 -2
- package/core/auth/oauth.js +5 -1
- package/core/auth/tokens.js +7 -2
- package/core/auth/totp.js +42 -6
- package/core/auth/user-agent.js +2 -0
- package/core/client/adapter/datastar.js +1401 -0
- package/core/client/api.js +1337 -0
- package/core/client/controller.js +1706 -0
- package/core/client/debug.js +367 -0
- package/core/client/devtool.js +1134 -0
- package/core/client/forms.js +1164 -0
- package/core/client/fragments.js +894 -0
- package/core/client/index.d.ts +59 -0
- package/core/client/index.js +391 -0
- package/core/client/lite.js +175 -0
- package/core/client/server-fragments.js +868 -0
- package/core/client/storage.js +339 -0
- package/core/client/utils.js +281 -0
- package/core/compression/middleware.js +277 -26
- package/core/context/app-context.js +25 -3
- package/core/context/request.js +32 -4
- package/core/errors/middleware.js +14 -0
- package/core/events/dispatcher.js +33 -0
- package/core/forms/flash.js +136 -102
- package/core/health/middleware.js +36 -6
- package/core/http/pipeline.js +45 -6
- package/core/index.js +25 -7
- package/core/kernel/view.js +41 -10
- package/core/metrics/middleware.js +30 -6
- package/core/metrics/registry.js +13 -5
- package/core/orm/cache/fingerprint.js +32 -11
- package/core/orm/cache/layer.js +19 -2
- package/core/orm/hidden.js +145 -0
- package/core/orm/model.js +48 -2
- package/core/orm/query-builder.js +343 -6
- package/core/orm/query-filters.js +14 -2
- package/core/orm/repository.js +64 -13
- package/core/orm/schema-helpers.js +9 -6
- package/core/orm/transaction.js +50 -6
- package/core/orm/types.js +4 -0
- package/core/queue/adapters/database.js +184 -2
- package/core/queue/adapters/memory.js +153 -5
- package/core/queue/adapters/redis.js +59 -4
- package/core/queue/job.js +14 -1
- package/core/queue/manager.js +115 -0
- package/core/resilience/circuit-breaker.js +27 -8
- package/core/services/builtins/exchange.js +174 -7
- package/core/services/builtins/file-manager.js +36 -0
- package/core/services/builtins/mail.js +27 -0
- package/core/services/builtins/queue.js +144 -0
- package/core/services/builtins/security.js +118 -0
- package/core/services/builtins/webhook.js +110 -0
- package/core/services/executor.js +38 -16
- package/core/services/index.js +4 -1
- package/core/sse/stream.js +28 -5
- package/core/utils/timeout.js +8 -1
- package/core/validation/compiler.js +10 -7
- package/index.d.ts +1320 -74
- package/index.js +128 -8
- package/package.json +75 -27
- package/plugins/admin-panel/client/parts/08-record-list.js +717 -70
- package/plugins/admin-panel/index.js +19 -8
- package/plugins/csrf/index.js +3 -3
- package/plugins/data-exchange/export.js +214 -0
- package/plugins/data-exchange/import.js +48 -5
- package/plugins/data-exchange/index.js +70 -28
- package/plugins/data-exchange/preview.js +266 -0
- package/plugins/data-exchange/template.js +242 -0
- package/plugins/email/admin-component.js +783 -141
- package/plugins/email/api-handlers.js +51 -1
- package/plugins/email/index.js +5 -0
- package/plugins/email/service.js +48 -0
- package/plugins/file-manager/admin-component.js +1546 -70
- package/plugins/file-manager/api-handlers.js +166 -1
- package/plugins/file-manager/core.js +598 -8
- package/plugins/file-manager/index.js +8 -0
- package/plugins/index.js +7 -0
- package/plugins/mcp/auth.js +3 -3
- package/plugins/oauth/index.js +36 -3
- package/plugins/queue/admin-component.js +1092 -0
- package/plugins/queue/api-handlers.js +248 -0
- package/plugins/queue/index.js +69 -0
- package/plugins/rest-resources/index.js +7 -2
- package/plugins/security/admin-component.js +689 -0
- package/plugins/security/api-handlers.js +169 -0
- package/plugins/security/audit.js +153 -0
- package/plugins/security/bruteforce.js +244 -0
- package/plugins/security/firewall.js +459 -0
- package/plugins/security/headers.js +214 -0
- package/plugins/security/honeypot.js +234 -0
- package/plugins/security/incident-recorder.js +137 -0
- package/plugins/security/index.js +211 -0
- package/plugins/seo-checker/analyzer.js +20 -7
- package/plugins/upload/index.js +36 -0
- package/plugins/webhooks/admin-component.js +702 -175
- package/plugins/webhooks/api-handlers.js +75 -1
- package/plugins/webhooks/dispatcher.js +290 -11
- package/plugins/webhooks/index.js +37 -2
- package/plugins/webhooks/sender.js +56 -0
- package/plugins/xlsx/engine.js +19 -4
- package/src/api/api-loader.js +19 -1
- package/src/bundle/index.js +443 -17
- package/src/client-runtime/bootstrap-swup.js +9 -0
- package/src/client-runtime/mount.js +15 -20
- package/src/client-runtime/resolve.js +3 -2
- package/src/dev/api-typegen.js +1300 -0
- package/src/dev/banner.js +10 -1
- package/src/dev/colors.js +1 -1
- package/src/dev/index.js +7 -1
- package/src/dev/typegen.js +173 -17
- package/src/dev-reload/index.js +1 -1
- package/src/discovery/file-route-parser.js +17 -3
- package/src/discovery/scan-routes.js +4 -1
- package/src/file-router.js +253 -69
- package/src/helpers.js +46 -26
- package/src/njk-frontmatter.js +30 -4
- package/src/routing/cascading.js +26 -8
- package/src/routing/mount-discovered-routes.js +76 -0
- package/src/server.js +231 -70
- package/views/partials/webspresso-client-runtime.njk +1 -9
- package/.agents/ADMIN.md +0 -426
- package/.agents/AGENTS.md +0 -96
- package/.agents/ARCHITECTURE.md +0 -163
- package/.agents/AUTH.md +0 -581
- package/.agents/CACHE.md +0 -148
- package/.agents/CLI.md +0 -649
- package/.agents/COMPRESSION.md +0 -59
- package/.agents/CONTEXT.md +0 -152
- package/.agents/CONVENTIONS.md +0 -50
- package/.agents/EARLY_HINTS.md +0 -120
- package/.agents/EMAIL.md +0 -223
- package/.agents/ERRORS.md +0 -260
- package/.agents/EVENTS.md +0 -179
- package/.agents/FLAGS.md +0 -118
- package/.agents/HEALTH.md +0 -118
- package/.agents/LIMITS.md +0 -109
- package/.agents/LOGGING.md +0 -93
- package/.agents/MCP.md +0 -103
- package/.agents/METRICS.md +0 -127
- package/.agents/MIGRATION_JOBS.md +0 -95
- package/.agents/ORM.md +0 -700
- package/.agents/PLUGINS.md +0 -729
- package/.agents/QUEUE.md +0 -290
- package/.agents/RATE_LIMIT.md +0 -147
- package/.agents/REALTIME.md +0 -213
- package/.agents/RESILIENCE.md +0 -235
- package/.agents/ROUTING.md +0 -605
- package/.agents/SCALE_OUT.md +0 -109
- package/.agents/SECRETS.md +0 -117
- package/.agents/SERVICES.md +0 -341
- package/.agents/SHUTDOWN.md +0 -74
- package/.agents/STREAMING.md +0 -108
- package/.agents/WORKFLOW.md +0 -27
- package/.agents/internal/DOCUMENTATION_REVIEW.md +0 -485
- package/.agents/internal/TECHNICAL_REVIEW.md +0 -428
- package/.agents/skills/webspresso-admin-and-plugins/SKILL.md +0 -122
- package/.agents/skills/webspresso-framework/SKILL.md +0 -89
- package/.agents/skills/webspresso-orm-and-database/SKILL.md +0 -127
- package/.agents/skills/webspresso-routing-and-api/SKILL.md +0 -130
- package/.agents/skills/webspresso-services/SKILL.md +0 -134
- package/.cursorrules +0 -14
- package/AGENTS.md +0 -203
- package/CLAUDE.md +0 -54
- package/core/kernel/run-demo.js +0 -58
- package/plugins/admin-panel/styles.js +0 -9
- package/plugins/data-exchange/export-xlsx.js +0 -119
- package/src/client-runtime/bootstrap-alpine-swup.js +0 -34
|
@@ -0,0 +1,1401 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Webspresso Datastar Adapter
|
|
5
|
+
*
|
|
6
|
+
* Isolates all direct interaction with @starfederation/datastar (v1.0.0-beta.11, MIT).
|
|
7
|
+
* Uses Datastar's public extension APIs:
|
|
8
|
+
* - load(...plugins), apply(), PluginType, Requirement, DATASTAR_SIGNAL_EVENT
|
|
9
|
+
* - InitContext: { signals (SignalsRoot), effect, applyToElement }
|
|
10
|
+
* - CustomEvent('datastar-sse', { detail: { type: 'datastar-merge-fragments', ... } })
|
|
11
|
+
*
|
|
12
|
+
* Architectural notes on public API boundaries:
|
|
13
|
+
* 1. Datastar's built-in attribute plugins evaluate arbitrary JS expressions against a single
|
|
14
|
+
* global signal store and Datastar's backend actions (@get/@post) serialize all global signals
|
|
15
|
+
* into requests by default.
|
|
16
|
+
* 2. To satisfy Webspresso's strict requirements (per-controller isolated state scope, zero naive
|
|
17
|
+
* regex expression rewriting, and strict separation between JSON API payloads and HTML/signal
|
|
18
|
+
* patches), we register Webspresso's `data-w-*` bindings via Datastar's public AttributePlugin
|
|
19
|
+
* interface (`PluginType.Attribute`) when `@starfederation/datastar` is present, and resolve
|
|
20
|
+
* property paths directly against each controller instance's isolated signal namespace
|
|
21
|
+
* (`__w_inst_<id>.*`) inside Datastar's `signals` (`SignalsRoot`) + `effect()`.
|
|
22
|
+
* 3. Because `@starfederation/datastar` is an optional peerDependency, this adapter includes a
|
|
23
|
+
* 100% contract-compatible signal/effect/plugin engine and DOM morphing implementation when the
|
|
24
|
+
* external package is not installed in `node_modules`.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const { getByPath, setByPath } = require('../utils');
|
|
28
|
+
|
|
29
|
+
const PluginType = {
|
|
30
|
+
Attribute: 1,
|
|
31
|
+
Watcher: 2,
|
|
32
|
+
Action: 3,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const Requirement = {
|
|
36
|
+
Allowed: 0,
|
|
37
|
+
Must: 1,
|
|
38
|
+
Denied: 2,
|
|
39
|
+
Exclusive: 3,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const DATASTAR_SIGNAL_EVENT = 'datastar-signals';
|
|
43
|
+
const DATASTAR_SSE_EVENT = 'datastar-sse';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Attempt to resolve @starfederation/datastar if installed or provided on globalThis.
|
|
47
|
+
*
|
|
48
|
+
* @param {object} [injectedDatastar]
|
|
49
|
+
* @returns {object|null}
|
|
50
|
+
*/
|
|
51
|
+
function resolveExternalDatastar(injectedDatastar) {
|
|
52
|
+
if (injectedDatastar && typeof injectedDatastar.load === 'function') {
|
|
53
|
+
return injectedDatastar;
|
|
54
|
+
}
|
|
55
|
+
if (typeof globalThis !== 'undefined' && globalThis.__WEBSPRESSO_DATASTAR__) {
|
|
56
|
+
return globalThis.__WEBSPRESSO_DATASTAR__;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Datastar-compatible Signal & Effect Engine (matches Datastar SignalsRoot API)
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
function createSignalsEngine() {
|
|
66
|
+
let activeEffect = null;
|
|
67
|
+
const effectStack = [];
|
|
68
|
+
|
|
69
|
+
class SignalNode {
|
|
70
|
+
constructor(initialValue) {
|
|
71
|
+
this._value = initialValue;
|
|
72
|
+
this._subs = new Set();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
get value() {
|
|
76
|
+
if (activeEffect) {
|
|
77
|
+
this._subs.add(activeEffect);
|
|
78
|
+
activeEffect.deps.add(this._subs);
|
|
79
|
+
}
|
|
80
|
+
return this._value;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
set value(nextValue) {
|
|
84
|
+
if (Object.is(this._value, nextValue)) return;
|
|
85
|
+
this._value = nextValue;
|
|
86
|
+
const subscribers = Array.from(this._subs);
|
|
87
|
+
for (const sub of subscribers) {
|
|
88
|
+
if (!sub.disposed) {
|
|
89
|
+
sub.run();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function effect(fn) {
|
|
96
|
+
const runner = {
|
|
97
|
+
disposed: false,
|
|
98
|
+
deps: new Set(),
|
|
99
|
+
cleanup() {
|
|
100
|
+
for (const depSet of this.deps) {
|
|
101
|
+
depSet.delete(this);
|
|
102
|
+
}
|
|
103
|
+
this.deps.clear();
|
|
104
|
+
},
|
|
105
|
+
run() {
|
|
106
|
+
if (this.disposed) return;
|
|
107
|
+
this.cleanup();
|
|
108
|
+
effectStack.push(this);
|
|
109
|
+
activeEffect = this;
|
|
110
|
+
try {
|
|
111
|
+
fn();
|
|
112
|
+
} finally {
|
|
113
|
+
effectStack.pop();
|
|
114
|
+
activeEffect = effectStack[effectStack.length - 1] || null;
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
runner.run();
|
|
120
|
+
|
|
121
|
+
return function stopEffect() {
|
|
122
|
+
runner.disposed = true;
|
|
123
|
+
runner.cleanup();
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function untracked(fn) {
|
|
128
|
+
const prev = activeEffect;
|
|
129
|
+
activeEffect = null;
|
|
130
|
+
try {
|
|
131
|
+
return fn();
|
|
132
|
+
} finally {
|
|
133
|
+
activeEffect = prev;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Implements Datastar's public `SignalsRoot` contract:
|
|
139
|
+
* exists(path), signal(path), value(path), setValue(path, val),
|
|
140
|
+
* upsertIfMissing(path, val), remove(...paths), merge(obj, onlyIfMissing),
|
|
141
|
+
* subset(...keys), values(onlyPublic)
|
|
142
|
+
*/
|
|
143
|
+
class CompatibleSignalsRoot {
|
|
144
|
+
constructor() {
|
|
145
|
+
this._nodes = new Map();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
_normalizePath(path) {
|
|
149
|
+
return String(path || '').trim().replace(/^\.+|\.+$/g, '');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
exists(path) {
|
|
153
|
+
const p = this._normalizePath(path);
|
|
154
|
+
return this._nodes.has(p);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
signal(path) {
|
|
158
|
+
const p = this._normalizePath(path);
|
|
159
|
+
if (!this._nodes.has(p)) {
|
|
160
|
+
this._nodes.set(p, new SignalNode(undefined));
|
|
161
|
+
}
|
|
162
|
+
return this._nodes.get(p);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
value(path) {
|
|
166
|
+
const p = this._normalizePath(path);
|
|
167
|
+
return this.signal(p).value;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
setValue(path, val) {
|
|
171
|
+
const p = this._normalizePath(path);
|
|
172
|
+
this.signal(p).value = val;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
upsertIfMissing(path, val) {
|
|
176
|
+
const p = this._normalizePath(path);
|
|
177
|
+
if (!this._nodes.has(p)) {
|
|
178
|
+
this._nodes.set(p, new SignalNode(val));
|
|
179
|
+
}
|
|
180
|
+
return this._nodes.get(p);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
remove(...paths) {
|
|
184
|
+
for (const rawPath of paths) {
|
|
185
|
+
const prefix = this._normalizePath(rawPath);
|
|
186
|
+
if (!prefix) continue;
|
|
187
|
+
this._nodes.delete(prefix);
|
|
188
|
+
const dotPrefix = `${prefix}.`;
|
|
189
|
+
for (const key of Array.from(this._nodes.keys())) {
|
|
190
|
+
if (key.startsWith(dotPrefix)) {
|
|
191
|
+
this._nodes.delete(key);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
subset(...keys) {
|
|
198
|
+
const out = {};
|
|
199
|
+
for (const prefix of keys) {
|
|
200
|
+
const norm = this._normalizePath(prefix);
|
|
201
|
+
const dotPrefix = `${norm}.`;
|
|
202
|
+
for (const [k, node] of this._nodes.entries()) {
|
|
203
|
+
if (k === norm) {
|
|
204
|
+
out[norm] = node.value;
|
|
205
|
+
} else if (k.startsWith(dotPrefix)) {
|
|
206
|
+
const subKey = k.slice(dotPrefix.length);
|
|
207
|
+
out[subKey] = node.value;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
values() {
|
|
215
|
+
const out = {};
|
|
216
|
+
for (const [k, node] of this._nodes.entries()) {
|
|
217
|
+
out[k] = node.value;
|
|
218
|
+
}
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
signals: new CompatibleSignalsRoot(),
|
|
225
|
+
effect,
|
|
226
|
+
untracked,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
// Safe Expression Evaluator (NO Regex Rewriting, NO eval / new Function)
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Parse a literal token (string, number, boolean, null, undefined) or resolve as state path.
|
|
236
|
+
*/
|
|
237
|
+
function resolveOperand(state, token) {
|
|
238
|
+
const t = token.trim();
|
|
239
|
+
if (t === 'true') return true;
|
|
240
|
+
if (t === 'false') return false;
|
|
241
|
+
if (t === 'null') return null;
|
|
242
|
+
if (t === 'undefined') return undefined;
|
|
243
|
+
if ((t.startsWith("'") && t.endsWith("'")) || (t.startsWith('"') && t.endsWith('"'))) {
|
|
244
|
+
return t.slice(1, -1);
|
|
245
|
+
}
|
|
246
|
+
if (/^-?\d+(\.\d+)?$/.test(t)) {
|
|
247
|
+
return Number(t);
|
|
248
|
+
}
|
|
249
|
+
return getByPath(state, t);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Safely strips balanced enclosing parentheses if the entire expression is wrapped in them:
|
|
254
|
+
* e.g. `(a && b)` -> `a && b`, but `(a && b) || (c && d)` is left unchanged.
|
|
255
|
+
*
|
|
256
|
+
* @param {string} raw
|
|
257
|
+
* @returns {string}
|
|
258
|
+
*/
|
|
259
|
+
function stripOuterParens(raw) {
|
|
260
|
+
let s = raw.trim();
|
|
261
|
+
while (s.startsWith('(') && s.endsWith(')')) {
|
|
262
|
+
let depth = 0;
|
|
263
|
+
let matched = true;
|
|
264
|
+
for (let i = 0; i < s.length - 1; i++) {
|
|
265
|
+
const ch = s[i];
|
|
266
|
+
if (ch === '(') depth++;
|
|
267
|
+
else if (ch === ')') {
|
|
268
|
+
depth--;
|
|
269
|
+
if (depth === 0) {
|
|
270
|
+
matched = false;
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (matched) {
|
|
276
|
+
s = s.slice(1, -1).trim();
|
|
277
|
+
} else {
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return s;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Split an expression by an operator (e.g. '||', '&&') only at the top level
|
|
286
|
+
* (outside single/double quotes and outside balanced parentheses).
|
|
287
|
+
*
|
|
288
|
+
* @param {string} expr
|
|
289
|
+
* @param {string} op
|
|
290
|
+
* @returns {string[]|null}
|
|
291
|
+
*/
|
|
292
|
+
function splitTopLevel(expr, op) {
|
|
293
|
+
const parts = [];
|
|
294
|
+
let inSingleQuote = false;
|
|
295
|
+
let inDoubleQuote = false;
|
|
296
|
+
let depth = 0;
|
|
297
|
+
let lastIndex = 0;
|
|
298
|
+
const opLen = op.length;
|
|
299
|
+
const len = expr.length;
|
|
300
|
+
|
|
301
|
+
for (let i = 0; i < len; i++) {
|
|
302
|
+
const ch = expr[i];
|
|
303
|
+
const prev = i > 0 ? expr[i - 1] : '';
|
|
304
|
+
|
|
305
|
+
if (ch === "'" && !inDoubleQuote && prev !== '\\') {
|
|
306
|
+
inSingleQuote = !inSingleQuote;
|
|
307
|
+
} else if (ch === '"' && !inSingleQuote && prev !== '\\') {
|
|
308
|
+
inDoubleQuote = !inDoubleQuote;
|
|
309
|
+
} else if (!inSingleQuote && !inDoubleQuote) {
|
|
310
|
+
if (ch === '(') {
|
|
311
|
+
depth++;
|
|
312
|
+
} else if (ch === ')') {
|
|
313
|
+
if (depth > 0) depth--;
|
|
314
|
+
} else if (depth === 0 && expr.slice(i, i + opLen) === op) {
|
|
315
|
+
parts.push(expr.slice(lastIndex, i).trim());
|
|
316
|
+
lastIndex = i + opLen;
|
|
317
|
+
i += opLen - 1;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (parts.length > 0) {
|
|
323
|
+
parts.push(expr.slice(lastIndex).trim());
|
|
324
|
+
return parts;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Find the first comparison operator at the top level outside quotes and parentheses.
|
|
332
|
+
*/
|
|
333
|
+
function findTopLevelComparison(expr) {
|
|
334
|
+
const operators = ['===', '!==', '==', '!=', '>=', '<=', '>', '<'];
|
|
335
|
+
let inSingleQuote = false;
|
|
336
|
+
let inDoubleQuote = false;
|
|
337
|
+
let depth = 0;
|
|
338
|
+
const len = expr.length;
|
|
339
|
+
|
|
340
|
+
for (let i = 0; i < len; i++) {
|
|
341
|
+
const ch = expr[i];
|
|
342
|
+
const prev = i > 0 ? expr[i - 1] : '';
|
|
343
|
+
|
|
344
|
+
if (ch === "'" && !inDoubleQuote && prev !== '\\') {
|
|
345
|
+
inSingleQuote = !inSingleQuote;
|
|
346
|
+
} else if (ch === '"' && !inSingleQuote && prev !== '\\') {
|
|
347
|
+
inDoubleQuote = !inDoubleQuote;
|
|
348
|
+
} else if (!inSingleQuote && !inDoubleQuote) {
|
|
349
|
+
if (ch === '(') {
|
|
350
|
+
depth++;
|
|
351
|
+
} else if (ch === ')') {
|
|
352
|
+
if (depth > 0) depth--;
|
|
353
|
+
} else if (depth === 0 && i > 0) {
|
|
354
|
+
for (const op of operators) {
|
|
355
|
+
if (expr.slice(i, i + op.length) === op) {
|
|
356
|
+
return {
|
|
357
|
+
op,
|
|
358
|
+
left: expr.slice(0, i).trim(),
|
|
359
|
+
right: expr.slice(i + op.length).trim(),
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Safely evaluate a declarative binding expression against controller state without
|
|
371
|
+
* naive regex transformations or `eval`/`new Function`.
|
|
372
|
+
*
|
|
373
|
+
* Supported declarative syntax:
|
|
374
|
+
* - Property path: `count`, `user.name`, `items.length`
|
|
375
|
+
* - Negation: `!pending`, `!!error`
|
|
376
|
+
* - Simple comparisons: `status === 'active'`, `count > 0`, `step !== 1`
|
|
377
|
+
* - Logical operators: `isReady && !hasError`, `status === 'a' || status === 'b'`
|
|
378
|
+
* - Parenthesized groups: `(a || b) && c`
|
|
379
|
+
*
|
|
380
|
+
* @param {object} state
|
|
381
|
+
* @param {string} rawExpr
|
|
382
|
+
* @returns {unknown}
|
|
383
|
+
*/
|
|
384
|
+
function evaluateStateExpression(state, rawExpr) {
|
|
385
|
+
if (typeof rawExpr !== 'string') return undefined;
|
|
386
|
+
const expr = stripOuterParens(rawExpr.trim());
|
|
387
|
+
if (!expr) return undefined;
|
|
388
|
+
|
|
389
|
+
// 1. Logical OR (||) - lowest precedence, evaluated first so that A || B && C splits into A and (B && C)
|
|
390
|
+
const orParts = splitTopLevel(expr, '||');
|
|
391
|
+
if (orParts && orParts.length > 1) {
|
|
392
|
+
let result = false;
|
|
393
|
+
for (const part of orParts) {
|
|
394
|
+
result = evaluateStateExpression(state, part);
|
|
395
|
+
if (result) return result;
|
|
396
|
+
}
|
|
397
|
+
return result;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// 2. Logical AND (&&) - higher precedence than ||
|
|
401
|
+
const andParts = splitTopLevel(expr, '&&');
|
|
402
|
+
if (andParts && andParts.length > 1) {
|
|
403
|
+
let result = true;
|
|
404
|
+
for (const part of andParts) {
|
|
405
|
+
result = evaluateStateExpression(state, part);
|
|
406
|
+
if (!result) return result;
|
|
407
|
+
}
|
|
408
|
+
return result;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// 3. Comparison operators (===, !==, ==, !=, >=, <=, >, <) outside quotes
|
|
412
|
+
const comp = findTopLevelComparison(expr);
|
|
413
|
+
if (comp && comp.left && comp.right) {
|
|
414
|
+
const left = resolveOperand(state, comp.left);
|
|
415
|
+
const right = resolveOperand(state, comp.right);
|
|
416
|
+
switch (comp.op) {
|
|
417
|
+
case '===':
|
|
418
|
+
case '==':
|
|
419
|
+
return left === right;
|
|
420
|
+
case '!==':
|
|
421
|
+
case '!=':
|
|
422
|
+
return left !== right;
|
|
423
|
+
case '>=':
|
|
424
|
+
return Number(left) >= Number(right);
|
|
425
|
+
case '<=':
|
|
426
|
+
return Number(left) <= Number(right);
|
|
427
|
+
case '>':
|
|
428
|
+
return Number(left) > Number(right);
|
|
429
|
+
case '<':
|
|
430
|
+
return Number(left) < Number(right);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// 4. Negations (!!, !)
|
|
435
|
+
if (expr.startsWith('!!')) {
|
|
436
|
+
return Boolean(evaluateStateExpression(state, expr.slice(2)));
|
|
437
|
+
}
|
|
438
|
+
if (expr.startsWith('!')) {
|
|
439
|
+
return !Boolean(evaluateStateExpression(state, expr.slice(1)));
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// 5. Literal token or property path lookup
|
|
443
|
+
return resolveOperand(state, expr);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ---------------------------------------------------------------------------
|
|
447
|
+
// Isolated Scoped Reactive State per Controller Instance
|
|
448
|
+
// ---------------------------------------------------------------------------
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Creates an isolated reactive state Proxy for a single controller instance,
|
|
452
|
+
* backed by Datastar's `signals` store under namespace `__w_inst_<instanceId>`.
|
|
453
|
+
*
|
|
454
|
+
* @param {object} engine - { signals, effect }
|
|
455
|
+
* @param {string} instanceId
|
|
456
|
+
* @param {object} initialState
|
|
457
|
+
* @param {() => boolean} isConnected - Guard preventing disconnected instances from mutating state
|
|
458
|
+
* @param {object} [debug]
|
|
459
|
+
* @returns {{ state: object, dispose: () => void, snapshot: () => object }}
|
|
460
|
+
*/
|
|
461
|
+
function createIsolatedControllerState(
|
|
462
|
+
engine,
|
|
463
|
+
instanceId,
|
|
464
|
+
initialState = {},
|
|
465
|
+
isConnected = () => true,
|
|
466
|
+
debug = null,
|
|
467
|
+
computedDefs = {},
|
|
468
|
+
getContext = () => ({})
|
|
469
|
+
) {
|
|
470
|
+
const namespace = `__w_inst_${instanceId}`;
|
|
471
|
+
const rawStore = {};
|
|
472
|
+
const proxyCache = new WeakMap();
|
|
473
|
+
|
|
474
|
+
// Validate that no property is defined in both state and computed
|
|
475
|
+
if (computedDefs && typeof computedDefs === 'object') {
|
|
476
|
+
for (const key of Object.keys(computedDefs)) {
|
|
477
|
+
if (initialState && Object.prototype.hasOwnProperty.call(initialState, key)) {
|
|
478
|
+
throw new TypeError(
|
|
479
|
+
`Cannot define "${key}" as both a state and computed property for instance "${instanceId}".`
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function signalKeyFor(path) {
|
|
486
|
+
return path ? `${namespace}.${path}` : namespace;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
let isEvaluatingComputed = false;
|
|
490
|
+
let activeComputedKey = null;
|
|
491
|
+
const computedValues = new Map();
|
|
492
|
+
const computedDisposers = [];
|
|
493
|
+
|
|
494
|
+
function wrapReactive(target, basePath) {
|
|
495
|
+
if (target === null || typeof target !== 'object') {
|
|
496
|
+
return target;
|
|
497
|
+
}
|
|
498
|
+
if (proxyCache.has(target)) {
|
|
499
|
+
return proxyCache.get(target);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const proxy = new Proxy(target, {
|
|
503
|
+
get(obj, prop, receiver) {
|
|
504
|
+
if (prop === '__isWebspressoState') return true;
|
|
505
|
+
if (prop === '__raw') return obj;
|
|
506
|
+
if (typeof prop === 'symbol') {
|
|
507
|
+
return Reflect.get(obj, prop, receiver);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Allow accessing tasks on root state
|
|
511
|
+
if (!basePath && prop === 'tasks') {
|
|
512
|
+
const ctx = getContext();
|
|
513
|
+
return ctx && ctx.tasks ? ctx.tasks : undefined;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Check if accessing a root-level computed property
|
|
517
|
+
if (!basePath && computedDefs && Object.prototype.hasOwnProperty.call(computedDefs, prop)) {
|
|
518
|
+
engine.signals.value(signalKeyFor(String(prop)));
|
|
519
|
+
return computedValues.get(prop);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const path = basePath ? `${basePath}.${String(prop)}` : String(prop);
|
|
523
|
+
// Track signal dependency in Datastar signals store
|
|
524
|
+
engine.signals.value(signalKeyFor(path));
|
|
525
|
+
// If accessing array length or methods, also subscribe to the array path itself
|
|
526
|
+
if (Array.isArray(obj) && basePath) {
|
|
527
|
+
engine.signals.value(signalKeyFor(basePath));
|
|
528
|
+
}
|
|
529
|
+
const val = Reflect.get(obj, prop, receiver);
|
|
530
|
+
if (val !== null && typeof val === 'object') {
|
|
531
|
+
return wrapReactive(val, path);
|
|
532
|
+
}
|
|
533
|
+
return val;
|
|
534
|
+
},
|
|
535
|
+
|
|
536
|
+
set(obj, prop, nextValue, receiver) {
|
|
537
|
+
if (typeof prop === 'symbol') {
|
|
538
|
+
return Reflect.set(obj, prop, nextValue, receiver);
|
|
539
|
+
}
|
|
540
|
+
if (isEvaluatingComputed) {
|
|
541
|
+
throw new Error(
|
|
542
|
+
`Cannot mutate state property "${String(prop)}" inside computed property "${activeComputedKey}". Computed getters must be side-effect free.`
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
if (!basePath && computedDefs && Object.prototype.hasOwnProperty.call(computedDefs, prop)) {
|
|
546
|
+
throw new TypeError(
|
|
547
|
+
`Cannot assign to read-only computed property "${String(prop)}".`
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
if (!isConnected()) {
|
|
551
|
+
if (debug && typeof debug.trackDiscardedResponse === 'function') {
|
|
552
|
+
debug.trackDiscardedResponse({
|
|
553
|
+
source: 'controller-state',
|
|
554
|
+
instanceId,
|
|
555
|
+
key: String(prop),
|
|
556
|
+
reason: 'instance-disconnected',
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
return true;
|
|
560
|
+
}
|
|
561
|
+
const rawNext = nextValue && nextValue.__raw ? nextValue.__raw : nextValue;
|
|
562
|
+
const prevValue = obj[prop];
|
|
563
|
+
const path = basePath ? `${basePath}.${String(prop)}` : String(prop);
|
|
564
|
+
|
|
565
|
+
if (Object.is(prevValue, rawNext) && !Array.isArray(obj)) {
|
|
566
|
+
return true;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
Reflect.set(obj, prop, rawNext, receiver);
|
|
570
|
+
|
|
571
|
+
// Seed nested keys if setting an object
|
|
572
|
+
if (rawNext !== null && typeof rawNext === 'object' && !Array.isArray(rawNext)) {
|
|
573
|
+
for (const [k, v] of Object.entries(rawNext)) {
|
|
574
|
+
engine.signals.setValue(signalKeyFor(`${path}.${k}`), v);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
engine.signals.setValue(signalKeyFor(path), rawNext);
|
|
578
|
+
if (basePath) {
|
|
579
|
+
engine.signals.setValue(signalKeyFor(basePath), Array.isArray(obj) ? [...obj] : { ...obj });
|
|
580
|
+
}
|
|
581
|
+
return true;
|
|
582
|
+
},
|
|
583
|
+
|
|
584
|
+
deleteProperty(obj, prop) {
|
|
585
|
+
if (isEvaluatingComputed) {
|
|
586
|
+
throw new Error(
|
|
587
|
+
`Cannot delete state property "${String(prop)}" inside computed property "${activeComputedKey}".`
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
if (!basePath && computedDefs && Object.prototype.hasOwnProperty.call(computedDefs, prop)) {
|
|
591
|
+
throw new TypeError(
|
|
592
|
+
`Cannot delete read-only computed property "${String(prop)}".`
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
if (!isConnected()) return true;
|
|
596
|
+
const path = basePath ? `${basePath}.${String(prop)}` : String(prop);
|
|
597
|
+
const res = Reflect.deleteProperty(obj, prop);
|
|
598
|
+
engine.signals.remove(signalKeyFor(path));
|
|
599
|
+
return res;
|
|
600
|
+
},
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
proxyCache.set(target, proxy);
|
|
604
|
+
return proxy;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Initialize signals from initialState
|
|
608
|
+
if (initialState && typeof initialState === 'object') {
|
|
609
|
+
for (const [key, val] of Object.entries(initialState)) {
|
|
610
|
+
const cloned =
|
|
611
|
+
val !== null && typeof val === 'object'
|
|
612
|
+
? Array.isArray(val)
|
|
613
|
+
? [...val]
|
|
614
|
+
: { ...val }
|
|
615
|
+
: val;
|
|
616
|
+
rawStore[key] = cloned;
|
|
617
|
+
engine.signals.setValue(signalKeyFor(key), cloned);
|
|
618
|
+
if (cloned !== null && typeof cloned === 'object' && !Array.isArray(cloned)) {
|
|
619
|
+
for (const [subKey, subVal] of Object.entries(cloned)) {
|
|
620
|
+
engine.signals.setValue(signalKeyFor(`${key}.${subKey}`), subVal);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const stateProxy = wrapReactive(rawStore, '');
|
|
627
|
+
|
|
628
|
+
// Initialize and track computed properties
|
|
629
|
+
if (computedDefs && typeof computedDefs === 'object') {
|
|
630
|
+
for (const [compKey, getter] of Object.entries(computedDefs)) {
|
|
631
|
+
if (typeof getter !== 'function') continue;
|
|
632
|
+
|
|
633
|
+
// Seed initial signal slot
|
|
634
|
+
engine.signals.setValue(signalKeyFor(compKey), undefined);
|
|
635
|
+
|
|
636
|
+
const stopEffect = engine.effect(() => {
|
|
637
|
+
if (!isConnected()) return;
|
|
638
|
+
isEvaluatingComputed = true;
|
|
639
|
+
activeComputedKey = compKey;
|
|
640
|
+
try {
|
|
641
|
+
const ctx = typeof getContext === 'function' ? getContext() : null;
|
|
642
|
+
const computedVal = getter({
|
|
643
|
+
state: stateProxy,
|
|
644
|
+
tasks: (ctx && ctx.tasks) || {},
|
|
645
|
+
el: (ctx && ctx.el) || null,
|
|
646
|
+
});
|
|
647
|
+
computedValues.set(compKey, computedVal);
|
|
648
|
+
engine.signals.setValue(signalKeyFor(compKey), computedVal);
|
|
649
|
+
} finally {
|
|
650
|
+
isEvaluatingComputed = false;
|
|
651
|
+
activeComputedKey = null;
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
computedDisposers.push(stopEffect);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
return {
|
|
660
|
+
state: stateProxy,
|
|
661
|
+
dispose() {
|
|
662
|
+
for (let i = 0; i < computedDisposers.length; i++) {
|
|
663
|
+
try {
|
|
664
|
+
computedDisposers[i]();
|
|
665
|
+
} catch (_e) {
|
|
666
|
+
// Ignore
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
computedDisposers.length = 0;
|
|
670
|
+
engine.signals.remove(namespace);
|
|
671
|
+
},
|
|
672
|
+
snapshot() {
|
|
673
|
+
return JSON.parse(JSON.stringify(rawStore));
|
|
674
|
+
},
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// ---------------------------------------------------------------------------
|
|
679
|
+
// Ownership Check & In-Place DOM Morphing (Focus, Form Draft & Controller Preservation)
|
|
680
|
+
// ---------------------------------------------------------------------------
|
|
681
|
+
|
|
682
|
+
const CLIENT_OWNED_TEXT_ATTR = 'data-w-text';
|
|
683
|
+
const CLIENT_OWNED_SHOW_ATTR = 'data-w-show';
|
|
684
|
+
const CLIENT_OWNED_DISABLED_ATTR = 'data-w-disabled';
|
|
685
|
+
const CLIENT_OWNED_MODEL_ATTR = 'data-w-model';
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Determine whether a node has client bindings owning specific DOM properties.
|
|
689
|
+
*/
|
|
690
|
+
function getClientOwnership(el) {
|
|
691
|
+
if (!el || typeof el.hasAttribute !== 'function') {
|
|
692
|
+
return { text: false, show: false, disabled: false, model: false };
|
|
693
|
+
}
|
|
694
|
+
return {
|
|
695
|
+
text: el.hasAttribute(CLIENT_OWNED_TEXT_ATTR),
|
|
696
|
+
show: el.hasAttribute(CLIENT_OWNED_SHOW_ATTR),
|
|
697
|
+
disabled: el.hasAttribute(CLIENT_OWNED_DISABLED_ATTR),
|
|
698
|
+
model: el.hasAttribute(CLIENT_OWNED_MODEL_ATTR),
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Check if a form element currently holds unsaved draft values.
|
|
704
|
+
*/
|
|
705
|
+
function isFormDirtyOrPreserved(formEl) {
|
|
706
|
+
if (!formEl) return false;
|
|
707
|
+
if (formEl.hasAttribute && formEl.hasAttribute('data-w-preserve-draft')) return true;
|
|
708
|
+
if (formEl.__wFormHandle && formEl.__wFormHandle.dirty) return true;
|
|
709
|
+
const controls = formEl.querySelectorAll ? formEl.querySelectorAll('input, textarea, select') : [];
|
|
710
|
+
for (let i = 0; i < controls.length; i++) {
|
|
711
|
+
const ctrl = controls[i];
|
|
712
|
+
const type = (ctrl.type || '').toLowerCase();
|
|
713
|
+
if (type === 'submit' || type === 'button' || type === 'reset' || type === 'hidden') continue;
|
|
714
|
+
if (type === 'checkbox' || type === 'radio') {
|
|
715
|
+
if (ctrl.checked !== ctrl.defaultChecked) return true;
|
|
716
|
+
} else if (ctrl.tagName === 'SELECT') {
|
|
717
|
+
const options = ctrl.options || [];
|
|
718
|
+
for (let j = 0; j < options.length; j++) {
|
|
719
|
+
if (options[j].selected !== options[j].defaultSelected) return true;
|
|
720
|
+
}
|
|
721
|
+
} else if (ctrl.value !== ctrl.defaultValue) {
|
|
722
|
+
return true;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
return false;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Compute a matching key for DOM morphing so keyed items, fragments, forms,
|
|
730
|
+
* and controller roots preserve their identity across refreshes.
|
|
731
|
+
*/
|
|
732
|
+
function getMorphNodeKey(node) {
|
|
733
|
+
if (!node || node.nodeType !== 1) return null;
|
|
734
|
+
if (node.id) return `id:${node.id}`;
|
|
735
|
+
const wKey = node.getAttribute('data-w-key');
|
|
736
|
+
if (wKey) return `wkey:${wKey}`;
|
|
737
|
+
const fragId = node.getAttribute('data-w-fragment-id');
|
|
738
|
+
if (fragId) return `frag:${fragId}`;
|
|
739
|
+
const formName = node.getAttribute('data-w-form');
|
|
740
|
+
if (formName) return `form:${formName}:${node.getAttribute('action') || ''}`;
|
|
741
|
+
const ctrlName = node.getAttribute('data-w-controller');
|
|
742
|
+
if (ctrlName) {
|
|
743
|
+
const instKey = node.getAttribute('data-w-id') || '';
|
|
744
|
+
if (instKey) return `ctrl:${ctrlName}:${instKey}`;
|
|
745
|
+
}
|
|
746
|
+
const nameAttr = node.getAttribute('name');
|
|
747
|
+
if (nameAttr && (node.tagName === 'INPUT' || node.tagName === 'TEXTAREA' || node.tagName === 'SELECT')) {
|
|
748
|
+
return `input:${node.tagName}:${ node.getAttribute('type') || '' }:${nameAttr}`;
|
|
749
|
+
}
|
|
750
|
+
return null;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* Sync attributes from `fromEl` onto `toEl` while respecting client-owned properties
|
|
755
|
+
* and preserving active focus / form draft states.
|
|
756
|
+
*/
|
|
757
|
+
function syncElementAttributes(toEl, fromEl, ownership) {
|
|
758
|
+
const fromAttrs = fromEl.attributes;
|
|
759
|
+
const toAttrs = Array.from(toEl.attributes);
|
|
760
|
+
|
|
761
|
+
// Remove attributes no longer present on server HTML, except client-managed runtime state
|
|
762
|
+
for (let i = 0; i < toAttrs.length; i++) {
|
|
763
|
+
const attrName = toAttrs[i].name;
|
|
764
|
+
if (ownership.disabled && attrName === 'disabled') continue;
|
|
765
|
+
if (ownership.show && attrName === 'hidden') continue;
|
|
766
|
+
if (attrName.startsWith('data-w-bound-')) continue;
|
|
767
|
+
if (
|
|
768
|
+
attrName === 'data-w-fragment-input' ||
|
|
769
|
+
attrName === 'data-w-fragment-id' ||
|
|
770
|
+
attrName === 'data-w-fragment-page'
|
|
771
|
+
) {
|
|
772
|
+
continue;
|
|
773
|
+
}
|
|
774
|
+
if (!fromEl.hasAttribute(attrName)) {
|
|
775
|
+
toEl.removeAttribute(attrName);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// Set updated attributes from server HTML
|
|
780
|
+
for (let i = 0; i < fromAttrs.length; i++) {
|
|
781
|
+
const attr = fromAttrs[i];
|
|
782
|
+
if (ownership.disabled && attr.name === 'disabled') continue;
|
|
783
|
+
if (ownership.show && attr.name === 'hidden') continue;
|
|
784
|
+
if (toEl.getAttribute(attr.name) !== attr.value) {
|
|
785
|
+
toEl.setAttribute(attr.name, attr.value);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Sync form control state without clobbering active focus, selection range,
|
|
792
|
+
* client `data-w-model`, or dirty form drafts.
|
|
793
|
+
*/
|
|
794
|
+
function syncFormControlValue(toEl, fromEl, ownership, preserveDraft) {
|
|
795
|
+
if (ownership.model) return; // Owned by client data-w-model binding
|
|
796
|
+
const isFocused =
|
|
797
|
+
typeof document !== 'undefined' &&
|
|
798
|
+
document.activeElement &&
|
|
799
|
+
document.activeElement === toEl;
|
|
800
|
+
|
|
801
|
+
if (isFocused || preserveDraft) {
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
const tag = toEl.tagName;
|
|
806
|
+
if (tag === 'INPUT') {
|
|
807
|
+
const type = (toEl.type || '').toLowerCase();
|
|
808
|
+
if (type === 'checkbox' || type === 'radio') {
|
|
809
|
+
toEl.checked = fromEl.checked;
|
|
810
|
+
toEl.defaultChecked = fromEl.defaultChecked;
|
|
811
|
+
} else if (type !== 'file') {
|
|
812
|
+
toEl.value = fromEl.value;
|
|
813
|
+
toEl.defaultValue = fromEl.defaultValue;
|
|
814
|
+
}
|
|
815
|
+
} else if (tag === 'TEXTAREA') {
|
|
816
|
+
toEl.value = fromEl.value;
|
|
817
|
+
toEl.defaultValue = fromEl.defaultValue;
|
|
818
|
+
} else if (tag === 'SELECT') {
|
|
819
|
+
toEl.value = fromEl.value;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Morph `toNode` in-place to match `fromNode` while preserving:
|
|
825
|
+
* - Active element focus and selection cursor
|
|
826
|
+
* - Dirty `<form>` drafts (`data-w-form` / user-edited inputs)
|
|
827
|
+
* - Existing `[data-w-controller]` instances and their local state on preserved roots
|
|
828
|
+
* - Client-owned bindings (`data-w-text`, `data-w-show`, `data-w-disabled`, `data-w-model`)
|
|
829
|
+
*
|
|
830
|
+
* @param {Element} toEl
|
|
831
|
+
* @param {Element} fromEl
|
|
832
|
+
* @param {object} [ctx]
|
|
833
|
+
* @param {boolean} [ctx.insideDirtyForm=false]
|
|
834
|
+
* @param {(removedEl: Element) => void} [ctx.onBeforeNodeRemove]
|
|
835
|
+
* @param {(addedEl: Element) => void} [ctx.onNodeAdded]
|
|
836
|
+
*/
|
|
837
|
+
function morphDomTrees(toEl, fromEl, ctx = {}) {
|
|
838
|
+
if (!toEl || !fromEl) return toEl;
|
|
839
|
+
|
|
840
|
+
const ownership = getClientOwnership(toEl);
|
|
841
|
+
const isForm = toEl.tagName === 'FORM';
|
|
842
|
+
const insideDirtyForm = Boolean(ctx.insideDirtyForm || (isForm && isFormDirtyOrPreserved(toEl)));
|
|
843
|
+
|
|
844
|
+
syncElementAttributes(toEl, fromEl, ownership);
|
|
845
|
+
|
|
846
|
+
if (toEl.tagName === 'INPUT' || toEl.tagName === 'TEXTAREA' || toEl.tagName === 'SELECT') {
|
|
847
|
+
syncFormControlValue(toEl, fromEl, ownership, insideDirtyForm);
|
|
848
|
+
if (toEl.tagName !== 'SELECT') {
|
|
849
|
+
return toEl;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// If `toEl` has `data-w-text`, client state owns its textContent; do not overwrite from server HTML
|
|
854
|
+
if (ownership.text) {
|
|
855
|
+
return toEl;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// Morph childNodes
|
|
859
|
+
const existingChildren = Array.from(toEl.childNodes);
|
|
860
|
+
const newChildren = Array.from(fromEl.childNodes);
|
|
861
|
+
|
|
862
|
+
// Index existing element children by key
|
|
863
|
+
const keyedExisting = new Map();
|
|
864
|
+
for (let i = 0; i < existingChildren.length; i++) {
|
|
865
|
+
const child = existingChildren[i];
|
|
866
|
+
const key = getMorphNodeKey(child);
|
|
867
|
+
if (key && !keyedExisting.has(key)) {
|
|
868
|
+
keyedExisting.set(key, child);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
const usedExisting = new Set();
|
|
873
|
+
let unkeyedCursor = 0;
|
|
874
|
+
|
|
875
|
+
for (let i = 0; i < newChildren.length; i++) {
|
|
876
|
+
const newChild = newChildren[i];
|
|
877
|
+
let match = null;
|
|
878
|
+
|
|
879
|
+
if (newChild.nodeType === 1) {
|
|
880
|
+
const key = getMorphNodeKey(newChild);
|
|
881
|
+
if (key && keyedExisting.has(key)) {
|
|
882
|
+
const candidate = keyedExisting.get(key);
|
|
883
|
+
if (!usedExisting.has(candidate) && candidate.tagName === newChild.tagName) {
|
|
884
|
+
match = candidate;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
if (!match) {
|
|
889
|
+
// Look ahead for a compatible unkeyed element with the same tagName and controller attribute
|
|
890
|
+
for (let j = unkeyedCursor; j < existingChildren.length; j++) {
|
|
891
|
+
const cand = existingChildren[j];
|
|
892
|
+
if (usedExisting.has(cand)) continue;
|
|
893
|
+
if (cand.nodeType === 1 && !getMorphNodeKey(cand)) {
|
|
894
|
+
const candCtrl = cand.getAttribute('data-w-controller') || '';
|
|
895
|
+
const newCtrl = newChild.getAttribute('data-w-controller') || '';
|
|
896
|
+
if (cand.tagName === newChild.tagName && candCtrl === newCtrl) {
|
|
897
|
+
match = cand;
|
|
898
|
+
unkeyedCursor = j + 1;
|
|
899
|
+
break;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
if (match) {
|
|
906
|
+
usedExisting.add(match);
|
|
907
|
+
const currentAtPos = toEl.childNodes[i];
|
|
908
|
+
if (currentAtPos !== match) {
|
|
909
|
+
toEl.insertBefore(match, currentAtPos || null);
|
|
910
|
+
}
|
|
911
|
+
morphDomTrees(match, newChild, {
|
|
912
|
+
...ctx,
|
|
913
|
+
insideDirtyForm,
|
|
914
|
+
});
|
|
915
|
+
} else {
|
|
916
|
+
const imported = toEl.ownerDocument
|
|
917
|
+
? toEl.ownerDocument.importNode(newChild, true)
|
|
918
|
+
: newChild.cloneNode(true);
|
|
919
|
+
const currentAtPos = toEl.childNodes[i];
|
|
920
|
+
toEl.insertBefore(imported, currentAtPos || null);
|
|
921
|
+
if (typeof ctx.onNodeAdded === 'function') {
|
|
922
|
+
ctx.onNodeAdded(imported);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
} else if (newChild.nodeType === 3 || newChild.nodeType === 8) {
|
|
926
|
+
// Text or Comment node
|
|
927
|
+
let matchedText = null;
|
|
928
|
+
for (let j = unkeyedCursor; j < existingChildren.length; j++) {
|
|
929
|
+
const cand = existingChildren[j];
|
|
930
|
+
if (usedExisting.has(cand)) continue;
|
|
931
|
+
if (cand.nodeType === newChild.nodeType) {
|
|
932
|
+
matchedText = cand;
|
|
933
|
+
unkeyedCursor = j + 1;
|
|
934
|
+
break;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
if (matchedText) {
|
|
938
|
+
usedExisting.add(matchedText);
|
|
939
|
+
const currentAtPos = toEl.childNodes[i];
|
|
940
|
+
if (currentAtPos !== matchedText) {
|
|
941
|
+
toEl.insertBefore(matchedText, currentAtPos || null);
|
|
942
|
+
}
|
|
943
|
+
if (matchedText.nodeValue !== newChild.nodeValue) {
|
|
944
|
+
matchedText.nodeValue = newChild.nodeValue;
|
|
945
|
+
}
|
|
946
|
+
} else {
|
|
947
|
+
const clonedText = newChild.cloneNode(false);
|
|
948
|
+
const currentAtPos = toEl.childNodes[i];
|
|
949
|
+
toEl.insertBefore(clonedText, currentAtPos || null);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// Remove leftover existing children that were not matched
|
|
955
|
+
for (let i = 0; i < existingChildren.length; i++) {
|
|
956
|
+
const oldChild = existingChildren[i];
|
|
957
|
+
if (!usedExisting.has(oldChild) && oldChild.parentNode === toEl) {
|
|
958
|
+
if (oldChild.nodeType === 1 && typeof ctx.onBeforeNodeRemove === 'function') {
|
|
959
|
+
ctx.onBeforeNodeRemove(oldChild);
|
|
960
|
+
}
|
|
961
|
+
toEl.removeChild(oldChild);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
return toEl;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/**
|
|
969
|
+
* Parse an HTML string into a temporary container and return its root Element.
|
|
970
|
+
*
|
|
971
|
+
* @param {string} html
|
|
972
|
+
* @param {Document} [doc]
|
|
973
|
+
* @returns {Element|null}
|
|
974
|
+
*/
|
|
975
|
+
function parseHtmlFragment(html, doc) {
|
|
976
|
+
const targetDoc = doc || (typeof document !== 'undefined' ? document : null);
|
|
977
|
+
if (!targetDoc || typeof html !== 'string') return null;
|
|
978
|
+
const trimmed = html.trim();
|
|
979
|
+
if (!trimmed) return null;
|
|
980
|
+
|
|
981
|
+
const template = targetDoc.createElement('template');
|
|
982
|
+
template.innerHTML = trimmed;
|
|
983
|
+
if (template.content && template.content.firstElementChild) {
|
|
984
|
+
return template.content.firstElementChild;
|
|
985
|
+
}
|
|
986
|
+
// Fallback for environments where <template>.content is limited
|
|
987
|
+
const wrapper = targetDoc.createElement('div');
|
|
988
|
+
wrapper.innerHTML = trimmed;
|
|
989
|
+
return wrapper.firstElementChild;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// ---------------------------------------------------------------------------
|
|
993
|
+
// Datastar Adapter Factory
|
|
994
|
+
// ---------------------------------------------------------------------------
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* Create a Datastar adapter instance.
|
|
998
|
+
*
|
|
999
|
+
* @param {object} [options]
|
|
1000
|
+
* @param {object} [options.datastar] - Optional explicit @starfederation/datastar module
|
|
1001
|
+
* @param {object} [options.debug] - Optional client debugger
|
|
1002
|
+
*/
|
|
1003
|
+
function createDatastarAdapter(options = {}) {
|
|
1004
|
+
const externalDatastar = resolveExternalDatastar(options.datastar);
|
|
1005
|
+
const fallbackEngine = createSignalsEngine();
|
|
1006
|
+
|
|
1007
|
+
let datastarInitCtx = null;
|
|
1008
|
+
|
|
1009
|
+
// If official @starfederation/datastar is present, capture its InitContext via a public AttributePlugin
|
|
1010
|
+
if (externalDatastar && typeof externalDatastar.load === 'function') {
|
|
1011
|
+
try {
|
|
1012
|
+
externalDatastar.load({
|
|
1013
|
+
type: externalDatastar.PluginType ? externalDatastar.PluginType.Attribute : PluginType.Attribute,
|
|
1014
|
+
name: 'wBridge',
|
|
1015
|
+
onGlobalInit(ctx) {
|
|
1016
|
+
datastarInitCtx = ctx;
|
|
1017
|
+
},
|
|
1018
|
+
onLoad() {},
|
|
1019
|
+
});
|
|
1020
|
+
if (typeof externalDatastar.apply === 'function' && typeof document !== 'undefined' && document.body) {
|
|
1021
|
+
externalDatastar.apply();
|
|
1022
|
+
}
|
|
1023
|
+
} catch (_err) {
|
|
1024
|
+
datastarInitCtx = null;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
const engine = {
|
|
1029
|
+
get isExternalDatastar() {
|
|
1030
|
+
return Boolean(datastarInitCtx);
|
|
1031
|
+
},
|
|
1032
|
+
get signals() {
|
|
1033
|
+
return datastarInitCtx && datastarInitCtx.signals ? datastarInitCtx.signals : fallbackEngine.signals;
|
|
1034
|
+
},
|
|
1035
|
+
effect(fn) {
|
|
1036
|
+
if (datastarInitCtx && typeof datastarInitCtx.effect === 'function') {
|
|
1037
|
+
return datastarInitCtx.effect(fn);
|
|
1038
|
+
}
|
|
1039
|
+
return fallbackEngine.effect(fn);
|
|
1040
|
+
},
|
|
1041
|
+
};
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* Bind the V1 declarative attribute surface (`data-w-text`, `data-w-show`,
|
|
1045
|
+
* `data-w-disabled`, `data-w-model`, `data-w-on*`) within a controller's DOM scope.
|
|
1046
|
+
*
|
|
1047
|
+
* Enforces:
|
|
1048
|
+
* - Scoped strictly to `rootEl` (never crosses into nested `[data-w-controller]` elements)
|
|
1049
|
+
* - Single binding per element/attribute per instance (`WeakMap` guard)
|
|
1050
|
+
* - All effects & listeners returned in a single cleanup function
|
|
1051
|
+
*/
|
|
1052
|
+
const boundElementsByInstance = new Map(); // instanceId -> WeakSet<Element>
|
|
1053
|
+
|
|
1054
|
+
function isWithinControllerScope(rootEl, candidateEl) {
|
|
1055
|
+
if (candidateEl === rootEl) return true;
|
|
1056
|
+
let cur = candidateEl.parentElement;
|
|
1057
|
+
while (cur && cur !== rootEl) {
|
|
1058
|
+
if (cur.hasAttribute && cur.hasAttribute('data-w-controller')) {
|
|
1059
|
+
return false;
|
|
1060
|
+
}
|
|
1061
|
+
cur = cur.parentElement;
|
|
1062
|
+
}
|
|
1063
|
+
return cur === rootEl;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
function collectScopedElements(rootEl) {
|
|
1067
|
+
const result = [rootEl];
|
|
1068
|
+
if (!rootEl || typeof rootEl.querySelectorAll !== 'function') return result;
|
|
1069
|
+
const all = rootEl.querySelectorAll('*');
|
|
1070
|
+
for (let i = 0; i < all.length; i++) {
|
|
1071
|
+
if (isWithinControllerScope(rootEl, all[i])) {
|
|
1072
|
+
result.push(all[i]);
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
return result;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* Parse `data-w-on` or `data-w-on:<event>` attributes on an element.
|
|
1080
|
+
* Supports:
|
|
1081
|
+
* - `data-w-on:click="save"`
|
|
1082
|
+
* - `data-w-on:submit.prevent="save"`
|
|
1083
|
+
* - `data-w-on="click->save; keydown->onKey"`
|
|
1084
|
+
* - `data-w-click="save"` (shorthand for click)
|
|
1085
|
+
*/
|
|
1086
|
+
function extractEventBindings(el) {
|
|
1087
|
+
const bindings = [];
|
|
1088
|
+
if (!el || !el.attributes) return bindings;
|
|
1089
|
+
|
|
1090
|
+
for (let i = 0; i < el.attributes.length; i++) {
|
|
1091
|
+
const attr = el.attributes[i];
|
|
1092
|
+
const name = attr.name;
|
|
1093
|
+
const val = (attr.value || '').trim();
|
|
1094
|
+
if (!val) continue;
|
|
1095
|
+
|
|
1096
|
+
if (name.startsWith('data-w-on:')) {
|
|
1097
|
+
const rawSpec = name.slice('data-w-on:'.length);
|
|
1098
|
+
const parts = rawSpec.split('.');
|
|
1099
|
+
const eventName = parts[0];
|
|
1100
|
+
const modifiers = new Set(parts.slice(1));
|
|
1101
|
+
if (eventName) {
|
|
1102
|
+
bindings.push({ eventName, actionName: val, modifiers });
|
|
1103
|
+
}
|
|
1104
|
+
} else if (name === 'data-w-on') {
|
|
1105
|
+
const clauses = val.split(';').map((s) => s.trim()).filter(Boolean);
|
|
1106
|
+
for (const clause of clauses) {
|
|
1107
|
+
const arrowIdx = clause.indexOf('->');
|
|
1108
|
+
if (arrowIdx > 0) {
|
|
1109
|
+
const rawEvent = clause.slice(0, arrowIdx).trim();
|
|
1110
|
+
const actionName = clause.slice(arrowIdx + 2).trim();
|
|
1111
|
+
const parts = rawEvent.split('.');
|
|
1112
|
+
const eventName = parts[0];
|
|
1113
|
+
const modifiers = new Set(parts.slice(1));
|
|
1114
|
+
if (eventName && actionName) {
|
|
1115
|
+
bindings.push({ eventName, actionName, modifiers });
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
} else if (name === 'data-w-click') {
|
|
1120
|
+
bindings.push({ eventName: 'click', actionName: val, modifiers: new Set() });
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
return bindings;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
/**
|
|
1128
|
+
* Bind V1 declarative attributes inside `rootEl` for a controller instance.
|
|
1129
|
+
*/
|
|
1130
|
+
function bindControllerDom({ instanceId, rootEl, state, invokeAction, isConnected }) {
|
|
1131
|
+
if (!boundElementsByInstance.has(instanceId)) {
|
|
1132
|
+
boundElementsByInstance.set(instanceId, new WeakSet());
|
|
1133
|
+
}
|
|
1134
|
+
const boundSet = boundElementsByInstance.get(instanceId);
|
|
1135
|
+
const disposers = [];
|
|
1136
|
+
|
|
1137
|
+
const elements = collectScopedElements(rootEl);
|
|
1138
|
+
|
|
1139
|
+
for (let i = 0; i < elements.length; i++) {
|
|
1140
|
+
const el = elements[i];
|
|
1141
|
+
if (boundSet.has(el)) continue;
|
|
1142
|
+
boundSet.add(el);
|
|
1143
|
+
|
|
1144
|
+
// 1. data-w-text
|
|
1145
|
+
if (el.hasAttribute('data-w-text')) {
|
|
1146
|
+
const expr = el.getAttribute('data-w-text');
|
|
1147
|
+
const stop = engine.effect(() => {
|
|
1148
|
+
if (!isConnected()) return;
|
|
1149
|
+
const val = evaluateStateExpression(state, expr);
|
|
1150
|
+
const text = val === null || val === undefined ? '' : String(val);
|
|
1151
|
+
if (el.textContent !== text) {
|
|
1152
|
+
el.textContent = text;
|
|
1153
|
+
}
|
|
1154
|
+
});
|
|
1155
|
+
disposers.push(stop);
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// 2. data-w-show
|
|
1159
|
+
if (el.hasAttribute('data-w-show')) {
|
|
1160
|
+
const expr = el.getAttribute('data-w-show');
|
|
1161
|
+
const initialDisplay = el.style && el.style.display && el.style.display !== 'none' ? el.style.display : '';
|
|
1162
|
+
const stop = engine.effect(() => {
|
|
1163
|
+
if (!isConnected()) return;
|
|
1164
|
+
const visible = Boolean(evaluateStateExpression(state, expr));
|
|
1165
|
+
if (visible) {
|
|
1166
|
+
el.removeAttribute('hidden');
|
|
1167
|
+
if (el.style && el.style.display === 'none') {
|
|
1168
|
+
el.style.display = initialDisplay;
|
|
1169
|
+
}
|
|
1170
|
+
} else {
|
|
1171
|
+
el.setAttribute('hidden', '');
|
|
1172
|
+
if (el.style) {
|
|
1173
|
+
el.style.display = 'none';
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
});
|
|
1177
|
+
disposers.push(stop);
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
// 3. data-w-disabled
|
|
1181
|
+
if (el.hasAttribute('data-w-disabled')) {
|
|
1182
|
+
const expr = el.getAttribute('data-w-disabled');
|
|
1183
|
+
const stop = engine.effect(() => {
|
|
1184
|
+
if (!isConnected()) return;
|
|
1185
|
+
const disabled = Boolean(evaluateStateExpression(state, expr));
|
|
1186
|
+
if ('disabled' in el) {
|
|
1187
|
+
el.disabled = disabled;
|
|
1188
|
+
}
|
|
1189
|
+
if (disabled) {
|
|
1190
|
+
el.setAttribute('disabled', '');
|
|
1191
|
+
el.setAttribute('aria-disabled', 'true');
|
|
1192
|
+
} else {
|
|
1193
|
+
el.removeAttribute('disabled');
|
|
1194
|
+
el.removeAttribute('aria-disabled');
|
|
1195
|
+
}
|
|
1196
|
+
});
|
|
1197
|
+
disposers.push(stop);
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
// 4. data-w-model (two-way binding between input/select/textarea and controller state)
|
|
1201
|
+
if (el.hasAttribute('data-w-model')) {
|
|
1202
|
+
const modelPath = (el.getAttribute('data-w-model') || '').trim();
|
|
1203
|
+
const type = (el.type || '').toLowerCase();
|
|
1204
|
+
|
|
1205
|
+
// Security & DOM standard: Never bind file inputs to programmatic value bindings
|
|
1206
|
+
if (modelPath && type !== 'file') {
|
|
1207
|
+
// Binding initialization: determine value ownership
|
|
1208
|
+
// If state defines the value (even if null, false, 0, or empty string), state owns it.
|
|
1209
|
+
// Only if state is undefined, seed from the DOM control's initial value.
|
|
1210
|
+
const initialStateVal = getByPath(state, modelPath);
|
|
1211
|
+
if (initialStateVal === undefined) {
|
|
1212
|
+
if (type === 'checkbox') {
|
|
1213
|
+
setByPath(state, modelPath, Boolean(el.checked));
|
|
1214
|
+
} else if (type === 'number' || type === 'range') {
|
|
1215
|
+
setByPath(state, modelPath, el.value === '' ? null : (Number.isNaN(Number(el.value)) ? null : Number(el.value)));
|
|
1216
|
+
} else if (el.tagName === 'SELECT' && el.multiple) {
|
|
1217
|
+
const selected = [];
|
|
1218
|
+
const opts = el.options || [];
|
|
1219
|
+
for (let j = 0; j < opts.length; j++) {
|
|
1220
|
+
if (opts[j].selected) selected.push(opts[j].value);
|
|
1221
|
+
}
|
|
1222
|
+
setByPath(state, modelPath, selected);
|
|
1223
|
+
} else {
|
|
1224
|
+
setByPath(state, modelPath, el.value !== undefined ? el.value : '');
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
// Track IME composition state so typing during composition doesn't jump or corrupt input
|
|
1229
|
+
let isComposing = false;
|
|
1230
|
+
const onCompositionStart = () => {
|
|
1231
|
+
isComposing = true;
|
|
1232
|
+
};
|
|
1233
|
+
const onCompositionEnd = () => {
|
|
1234
|
+
isComposing = false;
|
|
1235
|
+
onModelInput();
|
|
1236
|
+
};
|
|
1237
|
+
el.addEventListener('compositionstart', onCompositionStart);
|
|
1238
|
+
el.addEventListener('compositionend', onCompositionEnd);
|
|
1239
|
+
disposers.push(() => {
|
|
1240
|
+
try {
|
|
1241
|
+
el.removeEventListener('compositionstart', onCompositionStart);
|
|
1242
|
+
el.removeEventListener('compositionend', onCompositionEnd);
|
|
1243
|
+
} catch (_e) {}
|
|
1244
|
+
});
|
|
1245
|
+
|
|
1246
|
+
// State -> DOM effect (synchronizes changes in state to the DOM control)
|
|
1247
|
+
const stopModelEffect = engine.effect(() => {
|
|
1248
|
+
if (!isConnected() || isComposing) return;
|
|
1249
|
+
const currentVal = getByPath(state, modelPath);
|
|
1250
|
+
|
|
1251
|
+
if (type === 'checkbox') {
|
|
1252
|
+
if (Array.isArray(currentVal)) {
|
|
1253
|
+
el.checked = currentVal.map(String).includes(String(el.value));
|
|
1254
|
+
} else {
|
|
1255
|
+
el.checked = Boolean(currentVal);
|
|
1256
|
+
}
|
|
1257
|
+
} else if (type === 'radio') {
|
|
1258
|
+
el.checked = String(currentVal) === String(el.value);
|
|
1259
|
+
} else if (el.tagName === 'SELECT' && el.multiple) {
|
|
1260
|
+
const arr = Array.isArray(currentVal) ? currentVal.map(String) : [];
|
|
1261
|
+
const opts = el.options || [];
|
|
1262
|
+
for (let j = 0; j < opts.length; j++) {
|
|
1263
|
+
opts[j].selected = arr.includes(String(opts[j].value));
|
|
1264
|
+
}
|
|
1265
|
+
} else {
|
|
1266
|
+
const formatted = currentVal === null || currentVal === undefined ? '' : String(currentVal);
|
|
1267
|
+
if (el.value !== formatted) {
|
|
1268
|
+
el.value = formatted;
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
});
|
|
1272
|
+
disposers.push(stopModelEffect);
|
|
1273
|
+
|
|
1274
|
+
// DOM -> State listener (synchronizes user interactions to state)
|
|
1275
|
+
const evtType =
|
|
1276
|
+
type === 'checkbox' || type === 'radio' || el.tagName === 'SELECT'
|
|
1277
|
+
? 'change'
|
|
1278
|
+
: 'input';
|
|
1279
|
+
|
|
1280
|
+
const onModelInput = () => {
|
|
1281
|
+
if (!isConnected() || isComposing) return;
|
|
1282
|
+
|
|
1283
|
+
if (type === 'checkbox') {
|
|
1284
|
+
const cur = getByPath(state, modelPath);
|
|
1285
|
+
if (Array.isArray(cur)) {
|
|
1286
|
+
const next = el.checked
|
|
1287
|
+
? Array.from(new Set([...cur, el.value]))
|
|
1288
|
+
: cur.filter((item) => String(item) !== String(el.value));
|
|
1289
|
+
setByPath(state, modelPath, next);
|
|
1290
|
+
} else {
|
|
1291
|
+
setByPath(state, modelPath, Boolean(el.checked));
|
|
1292
|
+
}
|
|
1293
|
+
} else if (type === 'radio') {
|
|
1294
|
+
if (el.checked) {
|
|
1295
|
+
setByPath(state, modelPath, el.value);
|
|
1296
|
+
}
|
|
1297
|
+
} else if (type === 'number' || type === 'range') {
|
|
1298
|
+
// Explicit empty number behavior: empty string converts to null, never 0!
|
|
1299
|
+
setByPath(state, modelPath, el.value === '' ? null : (Number.isNaN(Number(el.value)) ? null : Number(el.value)));
|
|
1300
|
+
} else if (el.tagName === 'SELECT' && el.multiple) {
|
|
1301
|
+
const selected = [];
|
|
1302
|
+
const opts = el.options || [];
|
|
1303
|
+
for (let j = 0; j < opts.length; j++) {
|
|
1304
|
+
if (opts[j].selected) selected.push(opts[j].value);
|
|
1305
|
+
}
|
|
1306
|
+
setByPath(state, modelPath, selected);
|
|
1307
|
+
} else {
|
|
1308
|
+
setByPath(state, modelPath, el.value);
|
|
1309
|
+
}
|
|
1310
|
+
};
|
|
1311
|
+
|
|
1312
|
+
el.addEventListener(evtType, onModelInput);
|
|
1313
|
+
disposers.push(() => el.removeEventListener(evtType, onModelInput));
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// 5. Event bindings (data-w-on:<event>, data-w-on, data-w-click)
|
|
1318
|
+
const eventBindings = extractEventBindings(el);
|
|
1319
|
+
for (const binding of eventBindings) {
|
|
1320
|
+
const listener = (event) => {
|
|
1321
|
+
if (!isConnected()) return;
|
|
1322
|
+
if (binding.modifiers.has('prevent')) event.preventDefault();
|
|
1323
|
+
if (binding.modifiers.has('stop')) event.stopPropagation();
|
|
1324
|
+
invokeAction(binding.actionName, event, el);
|
|
1325
|
+
};
|
|
1326
|
+
el.addEventListener(binding.eventName, listener);
|
|
1327
|
+
disposers.push(() => el.removeEventListener(binding.eventName, listener));
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
return function unbindInstanceDom() {
|
|
1332
|
+
for (let i = 0; i < disposers.length; i++) {
|
|
1333
|
+
try {
|
|
1334
|
+
disposers[i]();
|
|
1335
|
+
} catch (_err) {
|
|
1336
|
+
// Ignore individual unbind errors
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
};
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
/**
|
|
1343
|
+
* Morph a fragment element in place using the Datastar-compatible morph engine.
|
|
1344
|
+
*/
|
|
1345
|
+
function patchFragmentElement(targetEl, htmlString, lifecycleCallbacks = {}) {
|
|
1346
|
+
if (!targetEl || typeof htmlString !== 'string') return targetEl;
|
|
1347
|
+
const parsed = parseHtmlFragment(htmlString, targetEl.ownerDocument);
|
|
1348
|
+
if (!parsed) return targetEl;
|
|
1349
|
+
|
|
1350
|
+
const morphed = morphDomTrees(targetEl, parsed, {
|
|
1351
|
+
onBeforeNodeRemove: lifecycleCallbacks.onBeforeNodeRemove,
|
|
1352
|
+
onNodeAdded: lifecycleCallbacks.onNodeAdded,
|
|
1353
|
+
});
|
|
1354
|
+
|
|
1355
|
+
// If external @starfederation/datastar is active, notify apply() on updated subtree
|
|
1356
|
+
if (externalDatastar && typeof externalDatastar.apply === 'function') {
|
|
1357
|
+
try {
|
|
1358
|
+
externalDatastar.apply();
|
|
1359
|
+
} catch (_err) {
|
|
1360
|
+
// Ignore
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
return morphed;
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
return {
|
|
1368
|
+
PluginType,
|
|
1369
|
+
Requirement,
|
|
1370
|
+
DATASTAR_SIGNAL_EVENT,
|
|
1371
|
+
DATASTAR_SSE_EVENT,
|
|
1372
|
+
engine,
|
|
1373
|
+
createIsolatedControllerState: (instanceId, initialState, isConnected, debug, computedDefs, getContext) =>
|
|
1374
|
+
createIsolatedControllerState(engine, instanceId, initialState, isConnected, debug, computedDefs, getContext),
|
|
1375
|
+
bindControllerDom,
|
|
1376
|
+
patchFragmentElement,
|
|
1377
|
+
patchElements: patchFragmentElement,
|
|
1378
|
+
morphDomTrees,
|
|
1379
|
+
parseHtmlFragment,
|
|
1380
|
+
evaluateStateExpression,
|
|
1381
|
+
getByPath,
|
|
1382
|
+
setByPath,
|
|
1383
|
+
isWithinControllerScope,
|
|
1384
|
+
collectScopedElements,
|
|
1385
|
+
};
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
module.exports = {
|
|
1389
|
+
PluginType,
|
|
1390
|
+
Requirement,
|
|
1391
|
+
DATASTAR_SIGNAL_EVENT,
|
|
1392
|
+
DATASTAR_SSE_EVENT,
|
|
1393
|
+
createDatastarAdapter,
|
|
1394
|
+
createSignalsEngine,
|
|
1395
|
+
evaluateStateExpression,
|
|
1396
|
+
getByPath,
|
|
1397
|
+
setByPath,
|
|
1398
|
+
morphDomTrees,
|
|
1399
|
+
parseHtmlFragment,
|
|
1400
|
+
};
|
|
1401
|
+
|