webspresso 0.0.109 → 0.0.111

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.
@@ -24,7 +24,7 @@
24
24
  * external package is not installed in `node_modules`.
25
25
  */
26
26
 
27
- const { getByPath, setByPath } = require('../utils');
27
+ const { isArray, getByPath, setByPath } = require('../utils');
28
28
 
29
29
  const PluginType = {
30
30
  Attribute: 1,
@@ -124,6 +124,16 @@ function createSignalsEngine() {
124
124
  };
125
125
  }
126
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
+
127
137
  /**
128
138
  * Implements Datastar's public `SignalsRoot` contract:
129
139
  * exists(path), signal(path), value(path), setValue(path, val),
@@ -213,6 +223,7 @@ function createSignalsEngine() {
213
223
  return {
214
224
  signals: new CompatibleSignalsRoot(),
215
225
  effect,
226
+ untracked,
216
227
  };
217
228
  }
218
229
 
@@ -238,6 +249,123 @@ function resolveOperand(state, token) {
238
249
  return getByPath(state, t);
239
250
  }
240
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
+
241
369
  /**
242
370
  * Safely evaluate a declarative binding expression against controller state without
243
371
  * naive regex transformations or `eval`/`new Function`.
@@ -246,6 +374,8 @@ function resolveOperand(state, token) {
246
374
  * - Property path: `count`, `user.name`, `items.length`
247
375
  * - Negation: `!pending`, `!!error`
248
376
  * - Simple comparisons: `status === 'active'`, `count > 0`, `step !== 1`
377
+ * - Logical operators: `isReady && !hasError`, `status === 'a' || status === 'b'`
378
+ * - Parenthesized groups: `(a || b) && c`
249
379
  *
250
380
  * @param {object} state
251
381
  * @param {string} rawExpr
@@ -253,39 +383,55 @@ function resolveOperand(state, token) {
253
383
  */
254
384
  function evaluateStateExpression(state, rawExpr) {
255
385
  if (typeof rawExpr !== 'string') return undefined;
256
- const expr = rawExpr.trim();
386
+ const expr = stripOuterParens(rawExpr.trim());
257
387
  if (!expr) return undefined;
258
388
 
259
- // Check simple comparison operators in precedence order
260
- const operators = ['===', '!==', '==', '!=', '>=', '<=', '>', '<'];
261
- for (const op of operators) {
262
- const idx = expr.indexOf(op);
263
- if (idx > 0) {
264
- const leftRaw = expr.slice(0, idx).trim();
265
- const rightRaw = expr.slice(idx + op.length).trim();
266
- if (leftRaw && rightRaw) {
267
- const left = resolveOperand(state, leftRaw);
268
- const right = resolveOperand(state, rightRaw);
269
- switch (op) {
270
- case '===':
271
- case '==':
272
- return left === right;
273
- case '!==':
274
- case '!=':
275
- return left !== right;
276
- case '>=':
277
- return Number(left) >= Number(right);
278
- case '<=':
279
- return Number(left) <= Number(right);
280
- case '>':
281
- return Number(left) > Number(right);
282
- case '<':
283
- return Number(left) < Number(right);
284
- }
285
- }
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;
286
396
  }
397
+ return result;
287
398
  }
288
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 (!!, !)
289
435
  if (expr.startsWith('!!')) {
290
436
  return Boolean(evaluateStateExpression(state, expr.slice(2)));
291
437
  }
@@ -293,6 +439,7 @@ function evaluateStateExpression(state, rawExpr) {
293
439
  return !Boolean(evaluateStateExpression(state, expr.slice(1)));
294
440
  }
295
441
 
442
+ // 5. Literal token or property path lookup
296
443
  return resolveOperand(state, expr);
297
444
  }
298
445
 
@@ -360,11 +507,15 @@ function createIsolatedControllerState(
360
507
  return Reflect.get(obj, prop, receiver);
361
508
  }
362
509
 
363
- // Allow accessing tasks on root state
510
+ // Allow accessing tasks and busy status on root state
364
511
  if (!basePath && prop === 'tasks') {
365
512
  const ctx = getContext();
366
513
  return ctx && ctx.tasks ? ctx.tasks : undefined;
367
514
  }
515
+ if (!basePath && (prop === '$busy' || prop === 'isBusy') && !Object.prototype.hasOwnProperty.call(obj, prop)) {
516
+ const ctx = getContext();
517
+ return ctx ? Boolean(ctx.isBusy) : false;
518
+ }
368
519
 
369
520
  // Check if accessing a root-level computed property
370
521
  if (!basePath && computedDefs && Object.prototype.hasOwnProperty.call(computedDefs, prop)) {
@@ -376,7 +527,7 @@ function createIsolatedControllerState(
376
527
  // Track signal dependency in Datastar signals store
377
528
  engine.signals.value(signalKeyFor(path));
378
529
  // If accessing array length or methods, also subscribe to the array path itself
379
- if (Array.isArray(obj) && basePath) {
530
+ if (isArray(obj) && basePath) {
380
531
  engine.signals.value(signalKeyFor(basePath));
381
532
  }
382
533
  const val = Reflect.get(obj, prop, receiver);
@@ -415,21 +566,21 @@ function createIsolatedControllerState(
415
566
  const prevValue = obj[prop];
416
567
  const path = basePath ? `${basePath}.${String(prop)}` : String(prop);
417
568
 
418
- if (Object.is(prevValue, rawNext) && !Array.isArray(obj)) {
569
+ if (Object.is(prevValue, rawNext) && !isArray(obj)) {
419
570
  return true;
420
571
  }
421
572
 
422
573
  Reflect.set(obj, prop, rawNext, receiver);
423
574
 
424
575
  // Seed nested keys if setting an object
425
- if (rawNext !== null && typeof rawNext === 'object' && !Array.isArray(rawNext)) {
576
+ if (rawNext !== null && typeof rawNext === 'object' && !isArray(rawNext)) {
426
577
  for (const [k, v] of Object.entries(rawNext)) {
427
578
  engine.signals.setValue(signalKeyFor(`${path}.${k}`), v);
428
579
  }
429
580
  }
430
581
  engine.signals.setValue(signalKeyFor(path), rawNext);
431
582
  if (basePath) {
432
- engine.signals.setValue(signalKeyFor(basePath), Array.isArray(obj) ? [...obj] : { ...obj });
583
+ engine.signals.setValue(signalKeyFor(basePath), isArray(obj) ? [...obj] : { ...obj });
433
584
  }
434
585
  return true;
435
586
  },
@@ -462,13 +613,13 @@ function createIsolatedControllerState(
462
613
  for (const [key, val] of Object.entries(initialState)) {
463
614
  const cloned =
464
615
  val !== null && typeof val === 'object'
465
- ? Array.isArray(val)
616
+ ? isArray(val)
466
617
  ? [...val]
467
618
  : { ...val }
468
619
  : val;
469
620
  rawStore[key] = cloned;
470
621
  engine.signals.setValue(signalKeyFor(key), cloned);
471
- if (cloned !== null && typeof cloned === 'object' && !Array.isArray(cloned)) {
622
+ if (cloned !== null && typeof cloned === 'object' && !isArray(cloned)) {
472
623
  for (const [subKey, subVal] of Object.entries(cloned)) {
473
624
  engine.signals.setValue(signalKeyFor(`${key}.${subKey}`), subVal);
474
625
  }
@@ -587,6 +738,8 @@ function getMorphNodeKey(node) {
587
738
  if (node.id) return `id:${node.id}`;
588
739
  const wKey = node.getAttribute('data-w-key');
589
740
  if (wKey) return `wkey:${wKey}`;
741
+ const hostKey = node.getAttribute('data-w-host');
742
+ if (hostKey) return `host:${hostKey}`;
590
743
  const fragId = node.getAttribute('data-w-fragment-id');
591
744
  if (fragId) return `frag:${fragId}`;
592
745
  const formName = node.getAttribute('data-w-form');
@@ -703,6 +856,11 @@ function morphDomTrees(toEl, fromEl, ctx = {}) {
703
856
  }
704
857
  }
705
858
 
859
+ // If `toEl` is a client service UI host (`data-w-host`), the service owns its DOM contents
860
+ if (toEl.hasAttribute && toEl.hasAttribute('data-w-host')) {
861
+ return toEl;
862
+ }
863
+
706
864
  // If `toEl` has `data-w-text`, client state owns its textContent; do not overwrite from server HTML
707
865
  if (ownership.text) {
708
866
  return toEl;
@@ -804,10 +962,13 @@ function morphDomTrees(toEl, fromEl, ctx = {}) {
804
962
  }
805
963
  }
806
964
 
807
- // Remove leftover existing children that were not matched
965
+ // Remove leftover existing children that were not matched (preserve client service UI hosts)
808
966
  for (let i = 0; i < existingChildren.length; i++) {
809
967
  const oldChild = existingChildren[i];
810
968
  if (!usedExisting.has(oldChild) && oldChild.parentNode === toEl) {
969
+ if (oldChild.nodeType === 1 && oldChild.hasAttribute && oldChild.hasAttribute('data-w-host')) {
970
+ continue;
971
+ }
811
972
  if (oldChild.nodeType === 1 && typeof ctx.onBeforeNodeRemove === 'function') {
812
973
  ctx.onBeforeNodeRemove(oldChild);
813
974
  }
@@ -977,10 +1138,31 @@ function createDatastarAdapter(options = {}) {
977
1138
  return bindings;
978
1139
  }
979
1140
 
1141
+ function toggleElementVisibility(el, visible, initialDisplay) {
1142
+ if (visible) {
1143
+ el.removeAttribute('hidden');
1144
+ if (el.style && el.style.display === 'none') {
1145
+ el.style.display = initialDisplay;
1146
+ }
1147
+ } else {
1148
+ el.setAttribute('hidden', '');
1149
+ if (el.style) {
1150
+ el.style.display = 'none';
1151
+ }
1152
+ }
1153
+ }
1154
+
980
1155
  /**
981
1156
  * Bind V1 declarative attributes inside `rootEl` for a controller instance.
982
1157
  */
983
- function bindControllerDom({ instanceId, rootEl, state, invokeAction, isConnected }) {
1158
+ function bindControllerDom({
1159
+ instanceId,
1160
+ rootEl,
1161
+ state,
1162
+ invokeAction,
1163
+ isConnected,
1164
+ isLoadingTargetActive,
1165
+ }) {
984
1166
  if (!boundElementsByInstance.has(instanceId)) {
985
1167
  boundElementsByInstance.set(instanceId, new WeakSet());
986
1168
  }
@@ -1014,16 +1196,31 @@ function createDatastarAdapter(options = {}) {
1014
1196
  const initialDisplay = el.style && el.style.display && el.style.display !== 'none' ? el.style.display : '';
1015
1197
  const stop = engine.effect(() => {
1016
1198
  if (!isConnected()) return;
1017
- const visible = Boolean(evaluateStateExpression(state, expr));
1018
- if (visible) {
1019
- el.removeAttribute('hidden');
1020
- if (el.style && el.style.display === 'none') {
1021
- el.style.display = initialDisplay;
1022
- }
1023
- } else {
1024
- el.setAttribute('hidden', '');
1025
- if (el.style) {
1026
- el.style.display = 'none';
1199
+ toggleElementVisibility(el, Boolean(evaluateStateExpression(state, expr)), initialDisplay);
1200
+ });
1201
+ disposers.push(stop);
1202
+ }
1203
+
1204
+ // 2b. data-w-loading & data-w-loading-hide (declarative action/task busy indicators)
1205
+ if (el.hasAttribute('data-w-loading') || el.hasAttribute('data-w-loading-hide')) {
1206
+ const isHide = el.hasAttribute('data-w-loading-hide');
1207
+ const rawSpec = el.getAttribute(isHide ? 'data-w-loading-hide' : 'data-w-loading') || '';
1208
+ const initialDisplay = el.style && el.style.display && el.style.display !== 'none' ? el.style.display : '';
1209
+ const loadingClasses = (el.getAttribute('data-w-loading-class') || '')
1210
+ .trim()
1211
+ .split(/\s+/)
1212
+ .filter(Boolean);
1213
+ const stop = engine.effect(() => {
1214
+ if (!isConnected()) return;
1215
+ const active =
1216
+ typeof isLoadingTargetActive === 'function'
1217
+ ? Boolean(isLoadingTargetActive(rawSpec))
1218
+ : Boolean(evaluateStateExpression(state, rawSpec || '$busy'));
1219
+ toggleElementVisibility(el, isHide ? !active : active, initialDisplay);
1220
+ if (loadingClasses.length > 0 && el.classList) {
1221
+ for (let c = 0; c < loadingClasses.length; c++) {
1222
+ if (active) el.classList.add(loadingClasses[c]);
1223
+ else el.classList.remove(loadingClasses[c]);
1027
1224
  }
1028
1225
  }
1029
1226
  });
@@ -1102,7 +1299,7 @@ function createDatastarAdapter(options = {}) {
1102
1299
  const currentVal = getByPath(state, modelPath);
1103
1300
 
1104
1301
  if (type === 'checkbox') {
1105
- if (Array.isArray(currentVal)) {
1302
+ if (isArray(currentVal)) {
1106
1303
  el.checked = currentVal.map(String).includes(String(el.value));
1107
1304
  } else {
1108
1305
  el.checked = Boolean(currentVal);
@@ -1110,7 +1307,7 @@ function createDatastarAdapter(options = {}) {
1110
1307
  } else if (type === 'radio') {
1111
1308
  el.checked = String(currentVal) === String(el.value);
1112
1309
  } else if (el.tagName === 'SELECT' && el.multiple) {
1113
- const arr = Array.isArray(currentVal) ? currentVal.map(String) : [];
1310
+ const arr = isArray(currentVal) ? currentVal.map(String) : [];
1114
1311
  const opts = el.options || [];
1115
1312
  for (let j = 0; j < opts.length; j++) {
1116
1313
  opts[j].selected = arr.includes(String(opts[j].value));
@@ -1135,7 +1332,7 @@ function createDatastarAdapter(options = {}) {
1135
1332
 
1136
1333
  if (type === 'checkbox') {
1137
1334
  const cur = getByPath(state, modelPath);
1138
- if (Array.isArray(cur)) {
1335
+ if (isArray(cur)) {
1139
1336
  const next = el.checked
1140
1337
  ? Array.from(new Set([...cur, el.value]))
1141
1338
  : cur.filter((item) => String(item) !== String(el.value));