galath 1.0.5 → 1.0.6

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/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "galath",
3
- "version": "1.0.5",
3
+ "version": "1.0.6",
4
4
  "description": "XML language for web applications",
5
5
  "scripts": {
6
6
  "save": "git add .; git commit -m 'Updated Release'; npm version patch; npm publish; git push --follow-tags;",
7
- "serve": "http-server -c-1 -o"
7
+ "dev": "http-server -c-1 -o"
8
8
  },
9
9
  "keywords": [
10
10
  "xml",
package/src/binding.js CHANGED
@@ -133,6 +133,23 @@ export function bindingFeature(language) {
133
133
  return '';
134
134
  }
135
135
 
136
+ /**
137
+ * Translate XML-friendly operator keywords to their JS equivalents so
138
+ * authors can avoid XML-escaping `&&` and `||` inside attribute values.
139
+ *
140
+ * count and enabled → count && enabled
141
+ * x or y → x || y
142
+ *
143
+ * Word-boundary matching prevents false hits inside identifiers like
144
+ * `android` or `format`. The XML parser already unescapes `&&`
145
+ * to `&&` before we see it, so both styles continue to work.
146
+ */
147
+ function normalizeExpr(expr) {
148
+ return String(expr ?? '')
149
+ .replace(/\band\b/g, '&&')
150
+ .replace(/\bor\b/g, '||');
151
+ }
152
+
136
153
  /**
137
154
  * The hot path. Evaluate `expr` against the instance + local context.
138
155
  *
@@ -147,7 +164,7 @@ export function bindingFeature(language) {
147
164
  // eslint-disable-next-line no-new-func
148
165
  return Function(
149
166
  'ctx',
150
- `with (ctx) { return (${expr}); }`,
167
+ `with (ctx) { return (${normalizeExpr(expr)}); }`,
151
168
  )(buildContext(instance, local, event));
152
169
  } catch (error) {
153
170
  console.warn('[galath] expression failed:', expr, error);
@@ -164,7 +181,7 @@ export function bindingFeature(language) {
164
181
  // eslint-disable-next-line no-new-func
165
182
  return Function(
166
183
  'ctx',
167
- `with (ctx) { ${code}; }`,
184
+ `with (ctx) { ${normalizeExpr(code)}; }`,
168
185
  )(buildContext(instance, local, event));
169
186
  } catch (error) {
170
187
  console.error('[galath] handler failed:', code, error);
package/src/boot.js CHANGED
@@ -47,6 +47,20 @@ export async function boot({ source, mount }) {
47
47
  .use(componentFeature)
48
48
  .use(renderingFeature);
49
49
 
50
- await language.start();
50
+ try {
51
+ await language.start();
52
+ } catch (error) {
53
+ if (mount) {
54
+ const msg = String(error?.message ?? error);
55
+ mount.innerHTML = `<pre style="
56
+ margin:0;padding:1rem 1.25rem;
57
+ background:#1e1e1e;color:#f14c4c;
58
+ font-family:monospace;font-size:.85rem;
59
+ white-space:pre-wrap;word-break:break-word;
60
+ border-left:4px solid #f14c4c;
61
+ "><b>[galath] XML parse error</b>\n${msg.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}</pre>`;
62
+ }
63
+ throw error;
64
+ }
51
65
  return language;
52
66
  }
package/src/component.js CHANGED
@@ -192,16 +192,59 @@ export function componentFeature(language) {
192
192
  }
193
193
 
194
194
  // <computed name="x" from="y">expr</computed> - derived signal.
195
- // The legacy <map> spelling still works.
195
+ // `from` accepts a comma-separated list ("a, b, c") to depend on
196
+ // multiple sources; the body re-runs whenever any source changes.
197
+ // When `from` is empty (or names only unknown signals) we fall back
198
+ // to the instance-tree version counter so the body still re-evaluates
199
+ // on any tree mutation. The legacy <map> spelling still works.
196
200
  for (const tagName of ['computed', 'map']) {
197
201
  for (const el of language.childElements(model, tagName)) {
198
- const sourceName = el.getAttribute('from');
199
- const source = this.scope.signal(sourceName) || this.tree?.version;
200
- if (!source) continue;
201
- const expr = (el.querySelector('expression')?.textContent || el.textContent || 'value').trim();
202
- this.scope.map(el.getAttribute('name'), source, value =>
203
- language.evaluate(expr, this, { value, [sourceName]: value }, value),
204
- );
202
+ const fromAttr = el.getAttribute('from') || '';
203
+ const sourceNames = fromAttr
204
+ .split(',')
205
+ .map(s => s.trim())
206
+ .filter(Boolean);
207
+ const sources = sourceNames
208
+ .map(name => this.scope.signal(name))
209
+ .filter(Boolean);
210
+ if (sources.length === 0 && this.tree) sources.push(this.tree.version);
211
+ if (sources.length === 0) continue;
212
+
213
+ const expr = (
214
+ el.querySelector('expression')?.textContent ||
215
+ el.textContent ||
216
+ 'value'
217
+ ).trim();
218
+ const name = el.getAttribute('name');
219
+
220
+ const recompute = () => {
221
+ const ctx = {};
222
+ // Expose each named source value as a same-named local so
223
+ // the expression can use them directly even when the
224
+ // proxy-scope path is bypassed.
225
+ for (const sourceName of sourceNames) {
226
+ const sig = this.scope.signal(sourceName);
227
+ if (sig) ctx[sourceName] = sig.value;
228
+ }
229
+ // Single-source legacy convenience: `value` aliases the
230
+ // single source's current value. Multi-source computeds
231
+ // get the *first* declared source under `value`.
232
+ if (sourceNames.length > 0) {
233
+ const first = this.scope.signal(sourceNames[0]);
234
+ if (first) ctx.value = first.value;
235
+ }
236
+ return language.evaluate(expr, this, ctx, '');
237
+ };
238
+
239
+ const derived = new language.Signal(recompute());
240
+ this.scope.signal(name, derived);
241
+ for (const sig of sources) {
242
+ this.scope.collect(
243
+ sig.subscribe(() => {
244
+ derived.value = recompute();
245
+ }, false),
246
+ );
247
+ }
205
248
  }
206
249
  }
207
250
  }
package/src/controller.js CHANGED
@@ -141,12 +141,57 @@ export function controllerFeature(language) {
141
141
  continue;
142
142
  }
143
143
 
144
- // <fetch url="..." into="signalName" as="json|text"> - asynchronous
145
- // HTTP load. The result is written to the named signal when the
146
- // response resolves. We also flip an optional `loading` signal and
147
- // an `error` signal so the view can render spinners / error states
148
- // without writing JS. Operations after <fetch> in the same block
149
- // run immediately - this is fire-and-forget.
144
+ // <store signal="name" key="storageKey" /> - persist a signal to localStorage.
145
+ // <restore signal="name" key="storageKey" default="value" /> - read it back.
146
+ // Use <restore> in <on:mount> to rehydrate on page load; pair with a
147
+ // signal-change <listener> that calls <store> to keep it in sync.
148
+ if (op.localName === 'store') {
149
+ const sig = instance.scope.signal(op.getAttribute('signal'));
150
+ const key = language.evaluate(op.getAttribute('key') ?? '', instance, local, '', event);
151
+ if (sig && key) {
152
+ try { localStorage.setItem(key, JSON.stringify(sig.value)); } catch { /* quota or private-mode */ }
153
+ }
154
+ continue;
155
+ }
156
+
157
+ if (op.localName === 'restore') {
158
+ const sig = instance.scope.signal(op.getAttribute('signal'));
159
+ const key = language.evaluate(op.getAttribute('key') ?? '', instance, local, '', event);
160
+ if (sig && key) {
161
+ try {
162
+ const raw = localStorage.getItem(key);
163
+ if (raw != null) {
164
+ sig.value = JSON.parse(raw);
165
+ } else if (op.hasAttribute('default')) {
166
+ sig.value = language.evaluate(op.getAttribute('default'), instance, local, sig.value, event);
167
+ }
168
+ } catch {
169
+ if (op.hasAttribute('default')) {
170
+ sig.value = language.evaluate(op.getAttribute('default'), instance, local, sig.value, event);
171
+ }
172
+ }
173
+ }
174
+ continue;
175
+ }
176
+
177
+ // <fetch url="..." into="signalName" as="json|text"
178
+ // method="POST" body="expr" headers="expr" loading="sig"
179
+ // error="sig"> - asynchronous HTTP load.
180
+ //
181
+ // url - evaluated expression yielding the absolute or relative URL
182
+ // method - HTTP method, default GET (literal string, not evaluated)
183
+ // body - evaluated expression; objects are JSON-encoded, strings
184
+ // pass through, FormData/Blob/etc are sent as-is
185
+ // headers - evaluated expression; should yield a plain object whose
186
+ // keys/values are added to the request (auto-injects
187
+ // Content-Type: application/json when body is an object)
188
+ // into - signal to receive the response value
189
+ // as - parse mode: json (default), text, or response (raw)
190
+ // loading - signal toggled true while in-flight
191
+ // error - signal set to an error message when the request fails
192
+ //
193
+ // The op is fire-and-forget: operations after <fetch> in the same
194
+ // block run synchronously, before the response resolves.
150
195
  if (op.localName === 'fetch') {
151
196
  const url = language.evaluate(
152
197
  op.getAttribute('url') ?? "''",
@@ -157,6 +202,7 @@ export function controllerFeature(language) {
157
202
  );
158
203
  const into = op.getAttribute('into');
159
204
  const as = (op.getAttribute('as') || 'json').toLowerCase();
205
+ const method = (op.getAttribute('method') || 'GET').toUpperCase();
160
206
  const loadingSig = op.getAttribute('loading');
161
207
  const errorSig = op.getAttribute('error');
162
208
  const setSig = (name, value) => {
@@ -164,11 +210,39 @@ export function controllerFeature(language) {
164
210
  const sig = instance.scope.signal(name);
165
211
  if (sig) sig.value = value;
166
212
  };
213
+
214
+ const init = { method };
215
+ const headersAttr = op.getAttribute('headers');
216
+ const headers = headersAttr
217
+ ? language.evaluate(headersAttr, instance, local, {}, event)
218
+ : {};
219
+ if (op.hasAttribute('body')) {
220
+ const raw = language.evaluate(op.getAttribute('body'), instance, local, null, event);
221
+ if (raw == null) {
222
+ // null body -> send nothing
223
+ } else if (
224
+ typeof raw === 'string' ||
225
+ raw instanceof FormData ||
226
+ raw instanceof Blob ||
227
+ raw instanceof ArrayBuffer ||
228
+ (typeof URLSearchParams !== 'undefined' && raw instanceof URLSearchParams)
229
+ ) {
230
+ init.body = raw;
231
+ } else {
232
+ init.body = JSON.stringify(raw);
233
+ if (!('content-type' in lowerKeyed(headers))) {
234
+ headers['Content-Type'] = 'application/json';
235
+ }
236
+ }
237
+ }
238
+ if (Object.keys(headers).length) init.headers = headers;
239
+
167
240
  setSig(loadingSig, true);
168
241
  setSig(errorSig, '');
169
- fetch(url)
242
+ fetch(url, init)
170
243
  .then(response => {
171
244
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
245
+ if (as === 'response') return response;
172
246
  return as === 'text' ? response.text() : response.json();
173
247
  })
174
248
  .then(data => setSig(into, data))
@@ -176,6 +250,35 @@ export function controllerFeature(language) {
176
250
  .finally(() => setSig(loadingSig, false));
177
251
  continue;
178
252
  }
253
+
254
+ // <emit name="my-event" detail="expr" bubbles="true|false"
255
+ // composed="true|false" /> - dispatch a CustomEvent on the
256
+ // component host element. Parents listen with on:my-event="..."
257
+ // and read $event.detail in the handler. This is the standard
258
+ // child-to-parent signaling pattern for Custom Elements.
259
+ if (op.localName === 'emit') {
260
+ const name = op.getAttribute('name');
261
+ if (!name) continue;
262
+ const detail = op.hasAttribute('detail')
263
+ ? language.evaluate(op.getAttribute('detail'), instance, local, null, event)
264
+ : null;
265
+ const bubbles = op.getAttribute('bubbles') !== 'false';
266
+ const composed = op.getAttribute('composed') === 'true';
267
+ try {
268
+ instance.dispatchEvent(new CustomEvent(name, { detail, bubbles, composed }));
269
+ } catch (error) {
270
+ console.warn('[galath] <emit> failed:', name, error);
271
+ }
272
+ continue;
273
+ }
179
274
  }
180
275
  };
276
+
277
+ // Helper for case-insensitive header lookup. The fetch op auto-injects
278
+ // Content-Type only when the user has not already provided one.
279
+ function lowerKeyed(obj) {
280
+ const out = {};
281
+ for (const k of Object.keys(obj || {})) out[String(k).toLowerCase()] = obj[k];
282
+ return out;
283
+ }
181
284
  }
package/src/morph.js CHANGED
@@ -70,17 +70,45 @@ function isCustomElement(el) {
70
70
  // Walk the new attribute set and copy values across; remove attributes that
71
71
  // don't exist in the new tree. We special-case form fields so that an input
72
72
  // being edited doesn't have its `value` ripped out from under the user.
73
+ //
74
+ // CLASS MERGING
75
+ // Galath stamps `data-xes-classes` on every element whose classes it manages.
76
+ // When that attribute is present on either side, we do a surgical merge instead
77
+ // of a full class replace:
78
+ // - classes galath owned before but no longer wants → removed
79
+ // - classes galath now wants → added
80
+ // - all other classes (added by external JS like bogan-css) → untouched
81
+ //
82
+ // This eliminates the "is-active flicker" where bogan-css adds a class and
83
+ // galath's next morph wipes it out.
73
84
  // -----------------------------------------------------------------------------
74
85
  function syncAttributes(fromEl, toEl) {
75
86
  const isFocusedInput =
76
87
  fromEl === document.activeElement && FORM_TAGS.has(fromEl.tagName);
77
88
 
89
+ // Read the galath-owned class sets from both sides up front so we can
90
+ // apply smart merging whenever `class` is touched below.
91
+ const newGalathClasses = new Set(
92
+ (toEl.getAttribute('data-xes-classes') ?? '').split(/\s+/).filter(Boolean),
93
+ );
94
+ const oldGalathClasses = new Set(
95
+ (fromEl.getAttribute('data-xes-classes') ?? '').split(/\s+/).filter(Boolean),
96
+ );
97
+ const hasClassTracking =
98
+ toEl.hasAttribute('data-xes-classes') || fromEl.hasAttribute('data-xes-classes');
99
+
78
100
  // Copy/replace attributes from the new element.
79
101
  for (const attr of toEl.attributes) {
80
102
  // The DOM `value` attribute is only the *initial* value of an input, but
81
103
  // many devs (and Galath bindings) write to .value as a property. If the
82
104
  // input is currently focused we leave its live value alone.
83
105
  if (isFocusedInput && (attr.name === 'value' || attr.name === 'checked')) continue;
106
+
107
+ if (attr.name === 'class' && hasClassTracking) {
108
+ mergeClasses(fromEl, oldGalathClasses, newGalathClasses);
109
+ continue;
110
+ }
111
+
84
112
  if (fromEl.getAttribute(attr.name) !== attr.value) {
85
113
  fromEl.setAttribute(attr.name, attr.value);
86
114
  }
@@ -91,6 +119,13 @@ function syncAttributes(fromEl, toEl) {
91
119
  for (const attr of [...fromEl.attributes]) {
92
120
  if (!toEl.hasAttribute(attr.name)) {
93
121
  if (isFocusedInput && (attr.name === 'value' || attr.name === 'checked')) continue;
122
+
123
+ if (attr.name === 'class' && hasClassTracking) {
124
+ // Galath dropped all its classes this render; preserve external ones.
125
+ mergeClasses(fromEl, oldGalathClasses, newGalathClasses);
126
+ continue;
127
+ }
128
+
94
129
  fromEl.removeAttribute(attr.name);
95
130
  }
96
131
  }
@@ -108,6 +143,23 @@ function syncAttributes(fromEl, toEl) {
108
143
  }
109
144
  }
110
145
 
146
+ // Surgically update `fromEl`'s class list:
147
+ // remove classes galath no longer owns, add classes it now owns,
148
+ // leave everything else (external JS additions) untouched.
149
+ function mergeClasses(fromEl, oldGalathClasses, newGalathClasses) {
150
+ const live = new Set(fromEl.className.split(/\s+/).filter(Boolean));
151
+ for (const cls of oldGalathClasses) {
152
+ if (!newGalathClasses.has(cls)) live.delete(cls);
153
+ }
154
+ for (const cls of newGalathClasses) live.add(cls);
155
+ if (live.size === 0) {
156
+ if (fromEl.hasAttribute('class')) fromEl.removeAttribute('class');
157
+ return;
158
+ }
159
+ const merged = [...live].join(' ');
160
+ if (fromEl.className !== merged) fromEl.className = merged;
161
+ }
162
+
111
163
  // -----------------------------------------------------------------------------
112
164
  // Children sync
113
165
  // -----------------------------------------------------------------------------
package/src/rendering.js CHANGED
@@ -59,6 +59,19 @@ export function renderingFeature(language) {
59
59
  'meta', 'source', 'track', 'wbr',
60
60
  ]);
61
61
 
62
+ // HTML boolean attributes: their presence means true, absence means false.
63
+ // When authored as `attr="{expr}"`, galath evaluates the expression and
64
+ // emits the attribute name only when truthy — never emits `attr="false"`.
65
+ // This covers every standard HTML boolean attr so authors never have to
66
+ // write `novalidate="novalidate"` or `selected="selected"` for dynamic cases.
67
+ const htmlBooleanAttrs = new Set([
68
+ 'allowfullscreen', 'async', 'autofocus', 'autoplay', 'checked',
69
+ 'controls', 'default', 'defer', 'disabled', 'formnovalidate',
70
+ 'hidden', 'ismap', 'loop', 'multiple', 'muted', 'nomodule',
71
+ 'novalidate', 'open', 'readonly', 'required', 'reversed',
72
+ 'selected', 'typemustmatch',
73
+ ]);
74
+
62
75
  // Tags that the renderer should *display* as their serialized XML rather
63
76
  // than recurse into. They are "control plane" tags - if you accidentally
64
77
  // write `<commandset>` inside `<view>`, you get a code box, not silent
@@ -118,7 +131,7 @@ export function renderingFeature(language) {
118
131
  const items = instance.tree?.select(ref, local) ?? [];
119
132
  return items
120
133
  .map((item, index) => {
121
- const childLocal = { ...local, [as]: item, [`$${as}`]: item, index };
134
+ const childLocal = makeIterationLocal(local, as, item, index, items.length);
122
135
  const html = language.renderChildren(
123
136
  [...node.childNodes],
124
137
  instance,
@@ -150,7 +163,7 @@ export function renderingFeature(language) {
150
163
  const items = instance.tree?.select(node.getAttribute('source'), local) ?? [];
151
164
  return items
152
165
  .map((item, index) => {
153
- const childLocal = { ...local, [as]: item, [`$${as}`]: item, index };
166
+ const childLocal = makeIterationLocal(local, as, item, index, items.length);
154
167
  const html = language.renderChildren(
155
168
  [...template.childNodes],
156
169
  instance,
@@ -164,6 +177,35 @@ export function renderingFeature(language) {
164
177
  .join('');
165
178
  }
166
179
 
180
+ // Build the per-iteration local scope for <repeat>/<items>:
181
+ //
182
+ // $name / name -> the loop item (XNode)
183
+ // index, $index -> zero-based offset
184
+ // first, $first -> true on the first iteration
185
+ // last, $last -> true on the final iteration
186
+ // count, $count -> total number of items in this iteration
187
+ //
188
+ // Both `$x` and `x` spellings work; pick whichever reads better in
189
+ // your expression. The `$`-prefixed form mirrors how path expressions
190
+ // refer to locals (`$todo/@text`) so authors can be consistent.
191
+ function makeIterationLocal(local, as, item, index, count) {
192
+ const first = index === 0;
193
+ const last = index === count - 1;
194
+ return {
195
+ ...local,
196
+ [as]: item,
197
+ [`$${as}`]: item,
198
+ index,
199
+ $index: index,
200
+ first,
201
+ $first: first,
202
+ last,
203
+ $last: last,
204
+ count,
205
+ $count: count,
206
+ };
207
+ }
208
+
167
209
  // Stamp `data-xes-key="..."` onto the first element opening tag in `html`.
168
210
  // Leaves leading whitespace and any leading text alone. This is purely a
169
211
  // string rewrite because the rendering pipeline emits HTML strings.
@@ -289,7 +331,12 @@ export function renderingFeature(language) {
289
331
  // appear in any order. We accumulate every class fragment here and
290
332
  // emit a single `class="..."` at the end so we never produce two
291
333
  // class attributes (which would be invalid HTML).
334
+ // `hasClassDirectives` stays true even when all class: expressions are
335
+ // currently false, so `data-xes-classes` is always emitted on elements
336
+ // that declare class management — morph needs it to know the element is
337
+ // tracked and should preserve external classes across every render pass.
292
338
  const classParts = [];
339
+ let hasClassDirectives = false;
293
340
 
294
341
  for (const attr of [...node.attributes]) {
295
342
  const name = attr.name;
@@ -337,6 +384,7 @@ export function renderingFeature(language) {
337
384
 
338
385
  // -- class:foo="expr" -----------------------------------------------
339
386
  if (name.startsWith('class:')) {
387
+ hasClassDirectives = true;
340
388
  if (language.evaluate(value, instance, local)) classParts.push(name.slice(6));
341
389
  continue;
342
390
  }
@@ -349,14 +397,17 @@ export function renderingFeature(language) {
349
397
  continue;
350
398
  }
351
399
 
352
- // -- disabled="{expr}" ----------------------------------------------
353
- if (name === 'disabled' && value.startsWith('{') && value.endsWith('}')) {
354
- if (language.evaluate(value.slice(1, -1), instance, local)) attrs.push('disabled');
400
+ // -- HTML boolean attributes: attr="{expr}" -------------------------
401
+ // Works for disabled, selected, required, readonly, novalidate, etc.
402
+ // Emit only the attribute name when truthy; emit nothing when falsy.
403
+ if (htmlBooleanAttrs.has(name) && value.startsWith('{') && value.endsWith('}')) {
404
+ if (language.evaluate(value.slice(1, -1), instance, local)) attrs.push(name);
355
405
  continue;
356
406
  }
357
407
 
358
408
  // -- class="..." (interpolated; merged with class:* below) ----------
359
409
  if (name === 'class') {
410
+ hasClassDirectives = true;
360
411
  classParts.unshift(language.interpolate(value, instance, local));
361
412
  continue;
362
413
  }
@@ -366,9 +417,16 @@ export function renderingFeature(language) {
366
417
  }
367
418
 
368
419
  // Single class attribute, regardless of source ordering.
369
- if (classParts.length) {
420
+ // `data-xes-classes` records exactly which classes galath owns on this
421
+ // element so morph can merge rather than replace — preserving any classes
422
+ // that external JS (e.g. bogan-css components.js) has added at runtime.
423
+ // It is emitted whenever ANY class directive was declared, even when all
424
+ // class: expressions are currently false and the merged value is empty.
425
+ // Without it, morph loses the tracking marker and reverts to naive replace.
426
+ if (hasClassDirectives) {
370
427
  const merged = classParts.filter(Boolean).join(' ').trim();
371
428
  if (merged) attrs.push(`class="${merged}"`);
429
+ attrs.push(`data-xes-classes="${merged}"`);
372
430
  }
373
431
 
374
432
  bindings.push(binding);
@@ -474,6 +532,19 @@ export function renderingFeature(language) {
474
532
  instance.renderScope.collect(() =>
475
533
  element.removeEventListener(eventName, handler),
476
534
  );
535
+
536
+ // Seed the initial property value. Morph sets properties only on
537
+ // *existing* elements; nodes appended on first render arrive via
538
+ // cloneNode and need an explicit property push here. Skip focused
539
+ // inputs so we don't clobber text the user is actively typing.
540
+ if (element !== document.activeElement) {
541
+ const init = readBindingValue(bind.target, instance, binding.local);
542
+ if (bind.property === 'checked') {
543
+ element.checked = Boolean(init);
544
+ } else if (bind.property in element) {
545
+ element[bind.property] = init ?? '';
546
+ }
547
+ }
477
548
  }
478
549
 
479
550
  // use:* attached behaviors.
package/src/xml-events.js CHANGED
@@ -32,14 +32,41 @@
32
32
  export function xmlEventsFeature(language) {
33
33
  language.setupListeners = instance => {
34
34
  const listeners = language.firstChildElement(instance.definition, 'listeners');
35
- if (!listeners || !instance.tree) return;
35
+ if (!listeners) return;
36
36
  for (const listener of language.childElements(listeners, 'listener')) {
37
+ const handler = listener.getAttribute('handler');
38
+
39
+ // Signal-change listener: fires when the named signal's value changes.
40
+ // Syntax: <listener signal="theme" handler="#onThemeChange" />
41
+ // <listener signal="pinned"><store signal="pinned" key="app:pinned"/></listener>
42
+ // `$value` is available in inline operations and as a local in the action.
43
+ if (listener.hasAttribute('signal')) {
44
+ const signalName = listener.getAttribute('signal');
45
+ const sig = instance.scope.signal(signalName);
46
+ if (!sig) {
47
+ console.warn(`[galath] <listener signal="${signalName}"> — no such signal`);
48
+ continue;
49
+ }
50
+ instance.scope.collect(
51
+ sig.subscribe(value => {
52
+ const local = { $value: value, value };
53
+ if (handler?.startsWith('#')) {
54
+ language.executeAction(instance, handler.slice(1), local, null);
55
+ } else {
56
+ language.runOperations([...listener.children], instance, local, null);
57
+ }
58
+ }, false), // false = don't fire immediately on subscribe
59
+ );
60
+ continue;
61
+ }
62
+
63
+ // Data-tree event listener (original behaviour).
64
+ // Requires an instance tree; skip silently when the component has none.
65
+ if (!instance.tree) continue;
37
66
  const eventName = listener.getAttribute('event') || '*';
38
67
  const observer = listener.getAttribute('observer');
39
- const handler = listener.getAttribute('handler');
40
68
  instance.scope.collect(
41
69
  instance.tree.on(eventName, event => {
42
- // Path filter: only fire when the event is at or under `observer`.
43
70
  if (observer && !event.path.startsWith(observer)) return;
44
71
  if (handler?.startsWith('#')) {
45
72
  language.executeAction(instance, handler.slice(1), {}, event);