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.
Files changed (184) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +3 -3
  3. package/bin/commands/add-esbuild.js +7 -3
  4. package/bin/commands/add-tailwind.js +9 -5
  5. package/bin/commands/build.js +20 -4
  6. package/bin/commands/dev.js +57 -7
  7. package/bin/commands/doctor.js +42 -1
  8. package/bin/commands/favicon-generate.js +10 -3
  9. package/bin/commands/new.js +8 -3
  10. package/bin/commands/start.js +3 -1
  11. package/bin/utils/add-command.js +16 -0
  12. package/core/app.js +45 -3
  13. package/core/auth/authenticate.js +3 -3
  14. package/core/auth/hash.js +167 -43
  15. package/core/auth/index.js +13 -1
  16. package/core/auth/jwt.js +21 -2
  17. package/core/auth/manager.js +63 -14
  18. package/core/auth/middleware.js +4 -2
  19. package/core/auth/oauth.js +5 -1
  20. package/core/auth/tokens.js +7 -2
  21. package/core/auth/totp.js +42 -6
  22. package/core/auth/user-agent.js +2 -0
  23. package/core/client/adapter/datastar.js +1401 -0
  24. package/core/client/api.js +1337 -0
  25. package/core/client/controller.js +1706 -0
  26. package/core/client/debug.js +367 -0
  27. package/core/client/devtool.js +1134 -0
  28. package/core/client/forms.js +1164 -0
  29. package/core/client/fragments.js +894 -0
  30. package/core/client/index.d.ts +59 -0
  31. package/core/client/index.js +391 -0
  32. package/core/client/lite.js +175 -0
  33. package/core/client/server-fragments.js +868 -0
  34. package/core/client/storage.js +339 -0
  35. package/core/client/utils.js +281 -0
  36. package/core/compression/middleware.js +277 -26
  37. package/core/context/app-context.js +25 -3
  38. package/core/context/request.js +32 -4
  39. package/core/errors/middleware.js +14 -0
  40. package/core/events/dispatcher.js +33 -0
  41. package/core/forms/flash.js +136 -102
  42. package/core/health/middleware.js +36 -6
  43. package/core/http/pipeline.js +45 -6
  44. package/core/index.js +25 -7
  45. package/core/kernel/view.js +41 -10
  46. package/core/metrics/middleware.js +30 -6
  47. package/core/metrics/registry.js +13 -5
  48. package/core/orm/cache/fingerprint.js +32 -11
  49. package/core/orm/cache/layer.js +19 -2
  50. package/core/orm/hidden.js +145 -0
  51. package/core/orm/model.js +48 -2
  52. package/core/orm/query-builder.js +343 -6
  53. package/core/orm/query-filters.js +14 -2
  54. package/core/orm/repository.js +64 -13
  55. package/core/orm/schema-helpers.js +9 -6
  56. package/core/orm/transaction.js +50 -6
  57. package/core/orm/types.js +4 -0
  58. package/core/queue/adapters/database.js +184 -2
  59. package/core/queue/adapters/memory.js +153 -5
  60. package/core/queue/adapters/redis.js +59 -4
  61. package/core/queue/job.js +14 -1
  62. package/core/queue/manager.js +115 -0
  63. package/core/resilience/circuit-breaker.js +27 -8
  64. package/core/services/builtins/exchange.js +174 -7
  65. package/core/services/builtins/file-manager.js +36 -0
  66. package/core/services/builtins/mail.js +27 -0
  67. package/core/services/builtins/queue.js +144 -0
  68. package/core/services/builtins/security.js +118 -0
  69. package/core/services/builtins/webhook.js +110 -0
  70. package/core/services/executor.js +38 -16
  71. package/core/services/index.js +4 -1
  72. package/core/sse/stream.js +28 -5
  73. package/core/utils/timeout.js +8 -1
  74. package/core/validation/compiler.js +10 -7
  75. package/index.d.ts +1320 -74
  76. package/index.js +128 -8
  77. package/package.json +75 -27
  78. package/plugins/admin-panel/client/parts/08-record-list.js +717 -70
  79. package/plugins/admin-panel/index.js +19 -8
  80. package/plugins/csrf/index.js +3 -3
  81. package/plugins/data-exchange/export.js +214 -0
  82. package/plugins/data-exchange/import.js +48 -5
  83. package/plugins/data-exchange/index.js +70 -28
  84. package/plugins/data-exchange/preview.js +266 -0
  85. package/plugins/data-exchange/template.js +242 -0
  86. package/plugins/email/admin-component.js +783 -141
  87. package/plugins/email/api-handlers.js +51 -1
  88. package/plugins/email/index.js +5 -0
  89. package/plugins/email/service.js +48 -0
  90. package/plugins/file-manager/admin-component.js +1546 -70
  91. package/plugins/file-manager/api-handlers.js +166 -1
  92. package/plugins/file-manager/core.js +598 -8
  93. package/plugins/file-manager/index.js +8 -0
  94. package/plugins/index.js +7 -0
  95. package/plugins/mcp/auth.js +3 -3
  96. package/plugins/oauth/index.js +36 -3
  97. package/plugins/queue/admin-component.js +1092 -0
  98. package/plugins/queue/api-handlers.js +248 -0
  99. package/plugins/queue/index.js +69 -0
  100. package/plugins/rest-resources/index.js +7 -2
  101. package/plugins/security/admin-component.js +689 -0
  102. package/plugins/security/api-handlers.js +169 -0
  103. package/plugins/security/audit.js +153 -0
  104. package/plugins/security/bruteforce.js +244 -0
  105. package/plugins/security/firewall.js +459 -0
  106. package/plugins/security/headers.js +214 -0
  107. package/plugins/security/honeypot.js +234 -0
  108. package/plugins/security/incident-recorder.js +137 -0
  109. package/plugins/security/index.js +211 -0
  110. package/plugins/seo-checker/analyzer.js +20 -7
  111. package/plugins/upload/index.js +36 -0
  112. package/plugins/webhooks/admin-component.js +702 -175
  113. package/plugins/webhooks/api-handlers.js +75 -1
  114. package/plugins/webhooks/dispatcher.js +290 -11
  115. package/plugins/webhooks/index.js +37 -2
  116. package/plugins/webhooks/sender.js +56 -0
  117. package/plugins/xlsx/engine.js +19 -4
  118. package/src/api/api-loader.js +19 -1
  119. package/src/bundle/index.js +443 -17
  120. package/src/client-runtime/bootstrap-swup.js +9 -0
  121. package/src/client-runtime/mount.js +15 -20
  122. package/src/client-runtime/resolve.js +3 -2
  123. package/src/dev/api-typegen.js +1300 -0
  124. package/src/dev/banner.js +10 -1
  125. package/src/dev/colors.js +1 -1
  126. package/src/dev/index.js +7 -1
  127. package/src/dev/typegen.js +173 -17
  128. package/src/dev-reload/index.js +1 -1
  129. package/src/discovery/file-route-parser.js +17 -3
  130. package/src/discovery/scan-routes.js +4 -1
  131. package/src/file-router.js +253 -69
  132. package/src/helpers.js +46 -26
  133. package/src/njk-frontmatter.js +30 -4
  134. package/src/routing/cascading.js +26 -8
  135. package/src/routing/mount-discovered-routes.js +76 -0
  136. package/src/server.js +231 -70
  137. package/views/partials/webspresso-client-runtime.njk +1 -9
  138. package/.agents/ADMIN.md +0 -426
  139. package/.agents/AGENTS.md +0 -96
  140. package/.agents/ARCHITECTURE.md +0 -163
  141. package/.agents/AUTH.md +0 -581
  142. package/.agents/CACHE.md +0 -148
  143. package/.agents/CLI.md +0 -649
  144. package/.agents/COMPRESSION.md +0 -59
  145. package/.agents/CONTEXT.md +0 -152
  146. package/.agents/CONVENTIONS.md +0 -50
  147. package/.agents/EARLY_HINTS.md +0 -120
  148. package/.agents/EMAIL.md +0 -223
  149. package/.agents/ERRORS.md +0 -260
  150. package/.agents/EVENTS.md +0 -179
  151. package/.agents/FLAGS.md +0 -118
  152. package/.agents/HEALTH.md +0 -118
  153. package/.agents/LIMITS.md +0 -109
  154. package/.agents/LOGGING.md +0 -93
  155. package/.agents/MCP.md +0 -103
  156. package/.agents/METRICS.md +0 -127
  157. package/.agents/MIGRATION_JOBS.md +0 -95
  158. package/.agents/ORM.md +0 -700
  159. package/.agents/PLUGINS.md +0 -729
  160. package/.agents/QUEUE.md +0 -290
  161. package/.agents/RATE_LIMIT.md +0 -147
  162. package/.agents/REALTIME.md +0 -213
  163. package/.agents/RESILIENCE.md +0 -235
  164. package/.agents/ROUTING.md +0 -605
  165. package/.agents/SCALE_OUT.md +0 -109
  166. package/.agents/SECRETS.md +0 -117
  167. package/.agents/SERVICES.md +0 -341
  168. package/.agents/SHUTDOWN.md +0 -74
  169. package/.agents/STREAMING.md +0 -108
  170. package/.agents/WORKFLOW.md +0 -27
  171. package/.agents/internal/DOCUMENTATION_REVIEW.md +0 -485
  172. package/.agents/internal/TECHNICAL_REVIEW.md +0 -428
  173. package/.agents/skills/webspresso-admin-and-plugins/SKILL.md +0 -122
  174. package/.agents/skills/webspresso-framework/SKILL.md +0 -89
  175. package/.agents/skills/webspresso-orm-and-database/SKILL.md +0 -127
  176. package/.agents/skills/webspresso-routing-and-api/SKILL.md +0 -130
  177. package/.agents/skills/webspresso-services/SKILL.md +0 -134
  178. package/.cursorrules +0 -14
  179. package/AGENTS.md +0 -203
  180. package/CLAUDE.md +0 -54
  181. package/core/kernel/run-demo.js +0 -58
  182. package/plugins/admin-panel/styles.js +0 -9
  183. package/plugins/data-exchange/export-xlsx.js +0 -119
  184. 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
+