galath 1.0.2

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 (55) hide show
  1. package/AGENTS.md +1 -0
  2. package/README.md +206 -0
  3. package/TODO.md +140 -0
  4. package/index.html +188 -0
  5. package/logo.jpg +0 -0
  6. package/logo.svg +96 -0
  7. package/package.json +32 -0
  8. package/packages/galath/package.json +28 -0
  9. package/packages/galath/src/behavior.js +193 -0
  10. package/packages/galath/src/binding.js +247 -0
  11. package/packages/galath/src/boot.js +52 -0
  12. package/packages/galath/src/command.js +117 -0
  13. package/packages/galath/src/component.js +505 -0
  14. package/packages/galath/src/controller.js +181 -0
  15. package/packages/galath/src/core.js +190 -0
  16. package/packages/galath/src/imports.js +132 -0
  17. package/packages/galath/src/index.js +38 -0
  18. package/packages/galath/src/instance-model.js +343 -0
  19. package/packages/galath/src/morph.js +237 -0
  20. package/packages/galath/src/rendering.js +556 -0
  21. package/packages/galath/src/signals.js +215 -0
  22. package/packages/galath/src/templates.js +24 -0
  23. package/packages/galath/src/xml-events.js +53 -0
  24. package/packages/galath-css/css/bootstrap-icons.min.css +5 -0
  25. package/packages/galath-css/css/bootstrap.min.css +6 -0
  26. package/packages/galath-css/css/fonts/bootstrap-icons.json +2077 -0
  27. package/packages/galath-css/css/fonts/bootstrap-icons.woff +0 -0
  28. package/packages/galath-css/css/fonts/bootstrap-icons.woff2 +0 -0
  29. package/packages/galath-css/js/bootstrap.bundle.min.js +7 -0
  30. package/packages/galath-css/package.json +13 -0
  31. package/playground/app.xml +214 -0
  32. package/playground/chapters/01-welcome.xml +94 -0
  33. package/playground/chapters/02-signals.xml +166 -0
  34. package/playground/chapters/03-instance.xml +130 -0
  35. package/playground/chapters/04-bindings.xml +156 -0
  36. package/playground/chapters/05-lists.xml +138 -0
  37. package/playground/chapters/06-commands.xml +144 -0
  38. package/playground/chapters/07-controller.xml +115 -0
  39. package/playground/chapters/08-events.xml +126 -0
  40. package/playground/chapters/09-behaviors.xml +210 -0
  41. package/playground/chapters/10-components.xml +152 -0
  42. package/playground/chapters/11-imports.xml +108 -0
  43. package/playground/chapters/12-expressions.xml +161 -0
  44. package/playground/chapters/13-paths.xml +197 -0
  45. package/playground/components/chapter-shell.xml +29 -0
  46. package/playground/components/highlighter.js +111 -0
  47. package/playground/components/run-snippet.js +120 -0
  48. package/public/basic/bootstrap-icons.min.css +5 -0
  49. package/public/basic/bootstrap.bundle.min.js +7 -0
  50. package/public/basic/bootstrap.min.css +6 -0
  51. package/public/basic/fonts/bootstrap-icons.json +2077 -0
  52. package/public/basic/fonts/bootstrap-icons.woff +0 -0
  53. package/public/basic/fonts/bootstrap-icons.woff2 +0 -0
  54. package/public/basic/theme.css +209 -0
  55. package/seed.html +321 -0
@@ -0,0 +1,505 @@
1
+ // =============================================================================
2
+ // component.js
3
+ //
4
+ // Compiles `<component name="..." tag="...">` definitions into Custom
5
+ // Elements. Each Galath component becomes a real `<custom-tag>` you can
6
+ // drop into HTML or other Galath views.
7
+ //
8
+ // What lives inside a `<component>`:
9
+ //
10
+ // <model> - signals, computed signals, and the instance tree
11
+ // <signal name="x" value="..." />
12
+ // <computed name="y" from="x">expr</computed> (or <map> - same)
13
+ // <instance>...your XML data...</instance>
14
+ // </model>
15
+ //
16
+ // <commandset> - addressable commands (button command="add")
17
+ // <controller> - named actions (#actionName)
18
+ // <listeners> - data-tree event listeners
19
+ // <datatemplate> - reusable view fragments
20
+ // <style> - scoped CSS (auto-prefixed by component tag)
21
+ // <on:mount> - operations to run after the element is connected
22
+ // <on:unmount> - operations to run before the element is removed
23
+ // <view> - the renderable subtree
24
+ //
25
+ // Reactivity is whole-component: when ANY signal in the component scope (or
26
+ // the instance-tree version counter) changes, the renderer is queued for
27
+ // the next microtask. The renderer rebuilds the view, then `morph.js`
28
+ // surgically updates the live DOM. Inputs in focus keep their value /
29
+ // selection (see morph.js for the gory details).
30
+ // =============================================================================
31
+
32
+ import { morph } from './morph.js';
33
+
34
+ export function componentFeature(language) {
35
+ // ---------------------------------------------------------------------------
36
+ // Track which component scoped-style sheets we've already injected so
37
+ // multiple instances of the same component don't keep re-adding rules.
38
+ // Keyed by tag name.
39
+ // ---------------------------------------------------------------------------
40
+ const installedStyles = new Set();
41
+
42
+ /**
43
+ * Register every `<component>` in the parsed document as a Custom Element.
44
+ * Idempotent for already-registered tags (a re-`start()` will not throw).
45
+ */
46
+ language.registerComponents = () => {
47
+ for (const definition of language.childElements(language.root, 'component')) {
48
+ registerComponent(definition);
49
+ }
50
+ };
51
+
52
+ function registerComponent(definition) {
53
+ const name = definition.getAttribute('name');
54
+ const tag = definition.getAttribute('tag') || `xes-${name}`;
55
+ const defaults = componentDefaults(definition);
56
+ language.components.set(tag, { name, tag, definition, defaults });
57
+
58
+ // Inject scoped <style> contents into the document head once per tag.
59
+ installComponentStyles(tag, definition);
60
+
61
+ // `customElements.define` throws when called twice for the same name.
62
+ // Skip silently in that case so feature.use().start() can be re-run in
63
+ // dev playgrounds without nuking the page.
64
+ if (customElements.get(tag)) return;
65
+
66
+ customElements.define(
67
+ tag,
68
+ class extends HTMLElement {
69
+ // The runtime feeds attributes into signals automatically. We tell
70
+ // the platform which ones to observe via the defaults map.
71
+ static get observedAttributes() {
72
+ return Object.keys(defaults);
73
+ }
74
+
75
+ constructor() {
76
+ super();
77
+ // Stable references - never replaced on re-render.
78
+ this.definition = definition;
79
+ this.defaults = defaults;
80
+ this.scope = null; // top-level Concern, lives until disconnect.
81
+ this.renderScope = null; // child scope reset on every render.
82
+ this.tree = null; // XTree (if the component declared an instance).
83
+ this.xesRoot = null; // private mount point inside `this`.
84
+ this.renderQueued = false;
85
+ this.commands = new Map();
86
+ this.actions = new Map();
87
+ this.templates = new Map();
88
+ }
89
+
90
+ connectedCallback() {
91
+ if (this.scope) return; // already connected; ignore reparenting.
92
+ this.scope = new language.Concern(tag);
93
+
94
+ // Children authored between <my-tag>...</my-tag> are *slot*
95
+ // content. Capture them before inserting our own root so a
96
+ // <slot> in the view can place them. We move - not clone - so
97
+ // any listeners the parent attached survive.
98
+ if (!this.slotNodes) {
99
+ this.slotNodes = [...this.childNodes];
100
+ for (const node of this.slotNodes) super.removeChild(node);
101
+ }
102
+
103
+ this.xesRoot = document.createElement('div');
104
+ this.xesRoot.setAttribute('data-xes-root', tag);
105
+ super.appendChild(this.xesRoot);
106
+
107
+ this.setupAttributes();
108
+ this.setupModel();
109
+ this.setupCrossScopeBinds();
110
+ language.setupTemplates(this);
111
+ language.setupCommands(this);
112
+ language.setupController(this);
113
+ language.setupListeners(this);
114
+ this.subscribeForRender();
115
+ this.renderNow();
116
+ this.runLifecycle('on:mount');
117
+ }
118
+
119
+ disconnectedCallback() {
120
+ this.runLifecycle('on:unmount');
121
+ this.renderScope?.dispose();
122
+ this.scope?.dispose();
123
+ this.renderScope = null;
124
+ this.scope = null;
125
+ }
126
+
127
+ attributeChangedCallback(name, oldValue, newValue) {
128
+ if (!this.scope || Object.is(oldValue, newValue)) return;
129
+ const sig = this.scope.signal(name);
130
+ if (sig) sig.value = language.coerce(newValue ?? this.defaults[name] ?? '');
131
+ }
132
+
133
+ // -- setup phases ------------------------------------------------------
134
+
135
+ // Map every "default" attribute to a signal of the same name. This
136
+ // is the entry point for component props - parents pass them as
137
+ // attributes, the component reads them as `{name}` in the view.
138
+ setupAttributes() {
139
+ for (const [name, value] of Object.entries(this.defaults)) {
140
+ this.scope.signal(
141
+ name,
142
+ new language.Signal(
143
+ language.coerce(this.hasAttribute(name) ? this.getAttribute(name) : value),
144
+ ),
145
+ );
146
+ }
147
+ }
148
+
149
+ // <model>: signals, computed, and the instance tree. We accept the
150
+ // <model> wrapper, but if missing, we look for signals/instance
151
+ // directly under <component> too (for terse single-purpose
152
+ // components in the docs).
153
+ setupModel() {
154
+ const model = language.firstChildElement(this.definition, 'model') ?? this.definition;
155
+
156
+ // <signal name="x" value="..." /> - or text content if `value`
157
+ // attribute absent. CDATA-wrapped strings are common in docs.
158
+ for (const signalEl of language.childElements(model, 'signal')) {
159
+ const sigName = signalEl.getAttribute('name');
160
+ const raw = signalEl.hasAttribute('value')
161
+ ? signalEl.getAttribute('value')
162
+ : signalEl.textContent.trim();
163
+ this.scope.signal(sigName, new language.Signal(language.coerce(raw)));
164
+ }
165
+
166
+ // <instance>...</instance> -> XTree of XNodes.
167
+ const instanceEl =
168
+ language.firstChildElement(model, 'instance') ??
169
+ language.firstChildElement(model, 'data');
170
+ if (instanceEl) {
171
+ const root = new language.XNode('data');
172
+ this.tree = new language.XTree(root);
173
+ for (const child of [...instanceEl.children]) {
174
+ root.append(language.parseDataElement(child), { silent: true });
175
+ }
176
+ }
177
+
178
+ // <bind ref="/path" required="true" constraint="value > 0"
179
+ // type="email|number|date" /> - XForms-style validation
180
+ // declarations. Stored on the instance so the `valid()` /
181
+ // `invalid()` / `required()` expression helpers can read them.
182
+ this.binds = [];
183
+ for (const bindEl of language.childElements(model, 'bind')) {
184
+ const ref = bindEl.getAttribute('ref');
185
+ if (!ref) continue;
186
+ this.binds.push({
187
+ ref,
188
+ required: bindEl.getAttribute('required'),
189
+ constraint: bindEl.getAttribute('constraint'),
190
+ type: bindEl.getAttribute('type'),
191
+ });
192
+ }
193
+
194
+ // <computed name="x" from="y">expr</computed> - derived signal.
195
+ // The legacy <map> spelling still works.
196
+ for (const tagName of ['computed', 'map']) {
197
+ 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
+ );
205
+ }
206
+ }
207
+ }
208
+
209
+ // Read `bind:from-parent="signalName"` (and the alias `from-parent`)
210
+ // attributes from the host element. For each, find the nearest
211
+ // ancestor Galath component, look up the named signal there, and
212
+ // mirror it into a same-named signal on this component. Edits to
213
+ // the parent signal flow down automatically; we don't push back
214
+ // to the parent (read-only by design - keeps the dataflow clear).
215
+ setupCrossScopeBinds() {
216
+ const specs = [];
217
+ for (const attr of [...this.attributes]) {
218
+ if (attr.name === 'bind:from-parent' || attr.name === 'from-parent') {
219
+ specs.push(attr.value);
220
+ }
221
+ }
222
+ if (specs.length === 0) return;
223
+ const parentInstance = language.findParentComponent(this);
224
+ if (!parentInstance) return;
225
+ for (const name of specs) {
226
+ const parentSig = parentInstance.scope?.signal(name);
227
+ if (!parentSig) continue;
228
+ const local = new language.Signal(parentSig.value);
229
+ this.scope.signal(name, local);
230
+ this.scope.collect(parentSig.subscribe(v => { local.value = v; }, false));
231
+ }
232
+ }
233
+
234
+ // Wire signals -> render. Tree mutations always queue a render
235
+ // (selects/predicates can depend on any node), but per-signal
236
+ // subscriptions are now installed by `renderNow()` based on which
237
+ // signals the last render *actually read*. Unrelated signal
238
+ // changes no longer trigger work.
239
+ subscribeForRender() {
240
+ if (this.tree) {
241
+ this.scope.collect(
242
+ this.tree.version.subscribe(() => this.scheduleRender(), false),
243
+ );
244
+ }
245
+ }
246
+
247
+ scheduleRender() {
248
+ if (this.renderQueued) return;
249
+ this.renderQueued = true;
250
+ queueMicrotask(() => {
251
+ this.renderQueued = false;
252
+ if (this.isConnected) this.renderNow();
253
+ });
254
+ }
255
+
256
+ renderNow() {
257
+ if (!this.xesRoot) return;
258
+ // Tear down everything attached to the previous render: bindings,
259
+ // behaviors, listeners, AND the signal subscriptions we set up
260
+ // at the end of the last render. Then open a fresh render scope.
261
+ this.renderScope?.dispose();
262
+ this.renderScope = this.scope.scope('render');
263
+
264
+ // Reset the read-tracking set; the proxy in buildContext records
265
+ // every signal name actually accessed during this render.
266
+ this.readSignals = new Set();
267
+
268
+ const view = language.firstChildElement(this.definition, 'view');
269
+ const bindings = [];
270
+ const html = view
271
+ ? language.renderChildren([...view.childNodes], this, {}, bindings)
272
+ : '';
273
+
274
+ // Build an off-screen template root, then morph the live root to
275
+ // match it. Morph preserves focus/value of inputs that are
276
+ // currently in focus.
277
+ const nextRoot = document.createElement('div');
278
+ nextRoot.setAttribute(
279
+ 'data-xes-root',
280
+ this.definition.getAttribute('tag') || this.definition.getAttribute('name'),
281
+ );
282
+ nextRoot.innerHTML = html;
283
+ morph(this.xesRoot, nextRoot);
284
+
285
+ // After the DOM is in place, attach listeners and behaviors to
286
+ // the live elements based on the binding records the renderer
287
+ // emitted alongside the HTML.
288
+ language.installBindings(this, bindings);
289
+
290
+ // Subscribe only to the signals this render actually read. The
291
+ // subscriptions live on `renderScope`, so the next render
292
+ // disposes them automatically.
293
+ const schedule = () => this.scheduleRender();
294
+ for (const name of this.readSignals) {
295
+ const sig = this.scope.signal(name);
296
+ if (sig) this.renderScope.collect(sig.subscribe(schedule, false));
297
+ }
298
+ }
299
+
300
+ // Run <on:mount> / <on:unmount> ops, if defined.
301
+ runLifecycle(hookName) {
302
+ const hook = language.firstChildElement(this.definition, hookName);
303
+ if (!hook) return;
304
+ language.runOperations([...hook.children], this, {}, null);
305
+ }
306
+ },
307
+ );
308
+ }
309
+
310
+ /**
311
+ * Read the component definition's "prop default" attributes. We exclude
312
+ * reserved names (`name`, `tag`) and namespaced attributes (which are
313
+ * framework directives, not props).
314
+ */
315
+ function componentDefaults(definition) {
316
+ const reserved = new Set(['name', 'tag']);
317
+ const out = {};
318
+ for (const attr of [...definition.attributes]) {
319
+ if (!reserved.has(attr.name) && !attr.name.includes(':')) {
320
+ out[attr.name] = attr.value;
321
+ }
322
+ }
323
+ return out;
324
+ }
325
+
326
+ /**
327
+ * Pull `<style>` blocks out of the component definition and inject them
328
+ * into the document head, prefixed so each rule only matches inside this
329
+ * component's own root. We use the `data-xes-root` attribute the runtime
330
+ * stamps on every component root. This is "scoped CSS, lite" - simple,
331
+ * predictable, and a 5-line implementation.
332
+ */
333
+ function installComponentStyles(tag, definition) {
334
+ if (installedStyles.has(tag)) return;
335
+ const blocks = [...definition.children].filter(c => c.localName === 'style');
336
+ if (blocks.length === 0) {
337
+ installedStyles.add(tag);
338
+ return;
339
+ }
340
+ const scope = `[data-xes-root="${tag}"]`;
341
+ const css = blocks.map(b => prefixCss(b.textContent, scope)).join('\n');
342
+ const styleEl = document.createElement('style');
343
+ styleEl.setAttribute('data-xes-style-for', tag);
344
+ styleEl.textContent = css;
345
+ document.head.appendChild(styleEl);
346
+ installedStyles.add(tag);
347
+ }
348
+
349
+ /**
350
+ * Prepend `prefix ` to every selector so the CSS only matches inside
351
+ * the component root. We rely on splitting at top-level commas - this is
352
+ * not a full CSS parser, but covers nearly everything sane authors write.
353
+ *
354
+ * .card .body { ... } -> [data-xes-root="..."] .card .body { ... }
355
+ * .a, .b > p { ... } -> [data-xes-root="..."] .a, [data-xes-root="..."] .b > p { ... }
356
+ * :host(.dark) .x -> [data-xes-root="..."].dark .x (sugar)
357
+ */
358
+ function prefixCss(css, prefix) {
359
+ // Walk the source and split on top-level rule blocks.
360
+ let out = '';
361
+ let i = 0;
362
+ while (i < css.length) {
363
+ const braceAt = css.indexOf('{', i);
364
+ if (braceAt < 0) {
365
+ out += css.slice(i);
366
+ break;
367
+ }
368
+ const selectorBlock = css.slice(i, braceAt);
369
+ const ruleEnd = matchingBrace(css, braceAt);
370
+ const ruleBody = css.slice(braceAt, ruleEnd + 1);
371
+
372
+ // @-rules: pass through unchanged. (Authoring `@media` inside a
373
+ // scoped style still works because its child rules will be scoped
374
+ // when CSSOM evaluates them; we keep the @ wrapper as-is.)
375
+ if (selectorBlock.trim().startsWith('@')) {
376
+ out += selectorBlock + ruleBody;
377
+ i = ruleEnd + 1;
378
+ continue;
379
+ }
380
+
381
+ const scoped = selectorBlock
382
+ .split(',')
383
+ .map(sel => {
384
+ const trimmed = sel.trim();
385
+ if (!trimmed) return trimmed;
386
+ // `:host` is a Web Components convention. We treat `:host` as
387
+ // "the component root itself" and `:host(.x)` as a class on it.
388
+ if (trimmed.startsWith(':host')) {
389
+ return trimmed.replace(/^:host(\([^)]*\))?/, (_, mod) =>
390
+ mod ? `${prefix}${mod}` : prefix,
391
+ );
392
+ }
393
+ return `${prefix} ${trimmed}`;
394
+ })
395
+ .join(', ');
396
+
397
+ out += scoped + ruleBody;
398
+ i = ruleEnd + 1;
399
+ }
400
+ return out;
401
+ }
402
+
403
+ function matchingBrace(text, open) {
404
+ let depth = 0;
405
+ for (let i = open; i < text.length; i++) {
406
+ if (text[i] === '{') depth++;
407
+ else if (text[i] === '}' && --depth === 0) return i;
408
+ }
409
+ return text.length - 1;
410
+ }
411
+
412
+ /**
413
+ * Run every `<bind>` declaration that targets `path` against the current
414
+ * value at that path and return a summary `{value, required, type,
415
+ * constraint, valid}`. Used by the `valid()` / `invalid()` /
416
+ * `required()` / `validity()` expression helpers.
417
+ *
418
+ * Type validators are intentionally minimal - just enough that authors
419
+ * can wire up Bootstrap is-invalid styling without dropping into JS.
420
+ */
421
+ language.checkValidity = (instance, path, local = {}) => {
422
+ const summary = { value: '', required: false, type: true, constraint: true, valid: true };
423
+ if (!instance.binds) return summary;
424
+ const value = instance.tree?.valueOf(path, local) ?? '';
425
+ summary.value = value;
426
+ for (const bind of instance.binds) {
427
+ if (bind.ref !== path) continue;
428
+ if (bind.required) {
429
+ const isRequired = bind.required === 'true' || bind.required === true ||
430
+ Boolean(language.evaluate(bind.required, instance, local, false));
431
+ if (isRequired) {
432
+ summary.required = true;
433
+ if (value === '' || value == null) summary.valid = false;
434
+ }
435
+ }
436
+ if (bind.type && !checkType(bind.type, value)) {
437
+ summary.type = false;
438
+ summary.valid = false;
439
+ }
440
+ if (bind.constraint) {
441
+ const ok = Boolean(
442
+ language.evaluate(bind.constraint, instance, { ...local, value }, false),
443
+ );
444
+ if (!ok) {
445
+ summary.constraint = false;
446
+ summary.valid = false;
447
+ }
448
+ }
449
+ }
450
+ return summary;
451
+ };
452
+
453
+ function checkType(type, value) {
454
+ if (value === '' || value == null) return true; // empty handled by `required`
455
+ switch (String(type).toLowerCase()) {
456
+ case 'email': return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(value));
457
+ case 'number': return !Number.isNaN(Number(value));
458
+ case 'integer': return /^-?\d+$/.test(String(value));
459
+ case 'url': try { new URL(String(value)); return true; } catch { return false; }
460
+ case 'date': return !Number.isNaN(Date.parse(String(value)));
461
+ default: return true;
462
+ }
463
+ }
464
+
465
+ /**
466
+ * Walk up from `el` looking for the nearest ancestor Custom Element that
467
+ * is one of our registered Galath components. Returns the element (which
468
+ * is its own instance) or null.
469
+ */
470
+ language.findParentComponent = el => {
471
+ let cur = el?.parentNode;
472
+ while (cur) {
473
+ // Crossing shadow boundaries is unusual in Galath but harmless.
474
+ if (cur.host) cur = cur.host;
475
+ if (cur.nodeType === Node.ELEMENT_NODE) {
476
+ const tag = cur.localName;
477
+ if (tag.includes('-') && language.components.has(tag)) return cur;
478
+ }
479
+ cur = cur.parentNode;
480
+ }
481
+ return null;
482
+ };
483
+
484
+ /**
485
+ * Read `name` from the nearest enclosing Galath component scope. Used by
486
+ * the `parentSignal(name)` expression helper.
487
+ */
488
+ language.parentSignal = (instance, name) => {
489
+ const parent = language.findParentComponent(instance);
490
+ return parent?.scope?.signal(name)?.value ?? '';
491
+ };
492
+
493
+ /**
494
+ * Mount the application. Every top-level `<application>` child becomes
495
+ * HTML inside `language.mount`. Custom-element tags resolve through the
496
+ * components we just registered.
497
+ */
498
+ language.mountApplication = () => {
499
+ const application = language.firstChildElement(language.root, 'application');
500
+ if (!application) throw new Error('No <application> found in source.');
501
+ language.mount.innerHTML = [...application.childNodes]
502
+ .map(node => (node.nodeType === Node.TEXT_NODE ? '' : language.serialize(node)))
503
+ .join('\n');
504
+ };
505
+ }
@@ -0,0 +1,181 @@
1
+ // =============================================================================
2
+ // controller.js
3
+ //
4
+ // FXML-style controller actions and the operation interpreter.
5
+ //
6
+ // A `<controller>` is a sibling of `<view>` that contains `<action>`s. Views
7
+ // invoke them by name via `on:click="#actionName"` or
8
+ // `<listener handler="#actionName">`. Keeping behavior in named actions
9
+ // makes it easy to:
10
+ //
11
+ // * Read the view as pure structure - no inline business logic.
12
+ // * Reuse the same action from multiple events / commands.
13
+ // * Test the action logic separately (it just runs operations).
14
+ //
15
+ // Operations supported here (also used by `<command>`):
16
+ //
17
+ // <set signal="x" value="expr" /> - update a signal
18
+ // <setvalue ref="/foo/@bar" value="..." /> - update an instance attribute
19
+ // <insert ref="/parent"> - append child(ren) into a node
20
+ // <child .../> - the children to insert
21
+ // </insert>
22
+ // <delete ref="/foo/bar[@id=...]" /> - remove matching nodes
23
+ // <eval>...js...</eval> - escape hatch (last resort)
24
+ // <log value="expr" /> - debug print to console
25
+ //
26
+ // Adding a new operation? Drop it in `runOperations` and document it in the
27
+ // playground "Controller" chapter.
28
+ // =============================================================================
29
+
30
+ export function controllerFeature(language) {
31
+ /**
32
+ * Index a component's controller actions by name during connect.
33
+ */
34
+ language.setupController = instance => {
35
+ instance.actions = new Map();
36
+ const controller = language.firstChildElement(instance.definition, 'controller');
37
+ if (!controller) return;
38
+ for (const action of language.childElements(controller, 'action')) {
39
+ instance.actions.set(action.getAttribute('name'), action);
40
+ }
41
+ };
42
+
43
+ /**
44
+ * Invoke a controller action by name. Quietly no-ops if the action does
45
+ * not exist - the view is already broken at that point and we'd rather
46
+ * the user see a console warning than a runtime crash.
47
+ */
48
+ language.executeAction = (instance, name, local = {}, event = null) => {
49
+ const action = instance.actions?.get(name);
50
+ if (!action) {
51
+ console.warn(`[galath] no controller action named "${name}"`);
52
+ return;
53
+ }
54
+ language.runOperations([...action.children], instance, local, event);
55
+ };
56
+
57
+ /**
58
+ * Walk a list of operation elements and apply each one. Operations are
59
+ * all synchronous - if you need async work, do it in an `<eval>` op or
60
+ * in JS that updates a signal.
61
+ */
62
+ language.runOperations = (ops, instance, local = {}, event = null) => {
63
+ for (const op of ops) {
64
+ if (op.nodeType !== Node.ELEMENT_NODE) continue;
65
+
66
+ // <set signal="x" value="expr" />
67
+ if (op.localName === 'set') {
68
+ const sig = instance.scope.signal(op.getAttribute('signal'));
69
+ if (sig) {
70
+ sig.value = language.evaluate(
71
+ op.getAttribute('value') ?? op.textContent.trim(),
72
+ instance,
73
+ local,
74
+ sig.value,
75
+ event,
76
+ );
77
+ }
78
+ continue;
79
+ }
80
+
81
+ // <setvalue ref="/path/@attr" value="expr" />
82
+ if (op.localName === 'setvalue') {
83
+ instance.tree?.setValue(
84
+ op.getAttribute('ref'),
85
+ language.evaluate(
86
+ op.getAttribute('value') ?? op.textContent.trim(),
87
+ instance,
88
+ local,
89
+ '',
90
+ event,
91
+ ),
92
+ local,
93
+ );
94
+ continue;
95
+ }
96
+
97
+ // <insert ref="/parent">child elements</insert>
98
+ if (op.localName === 'insert') {
99
+ const parent = instance.tree?.select(op.getAttribute('ref'), local)[0];
100
+ if (!parent) continue;
101
+ for (const childElement of [...op.children]) {
102
+ parent.append(language.parseDataElement(childElement, instance, local));
103
+ }
104
+ continue;
105
+ }
106
+
107
+ // <delete ref="/path[@predicate]" />
108
+ if (op.localName === 'delete') {
109
+ const targets = [
110
+ ...(instance.tree?.select(op.getAttribute('ref'), local) ?? []),
111
+ ];
112
+ for (const target of targets) target.remove();
113
+ continue;
114
+ }
115
+
116
+ // <log value="expr" /> - debug helper, prints to console.
117
+ if (op.localName === 'log') {
118
+ const value = language.evaluate(
119
+ op.getAttribute('value') ?? op.textContent.trim(),
120
+ instance,
121
+ local,
122
+ '',
123
+ event,
124
+ );
125
+ console.info('[galath log]', value);
126
+ continue;
127
+ }
128
+
129
+ // <call action="name" /> - call another action (handy for shared
130
+ // behavior between commands and event handlers).
131
+ if (op.localName === 'call') {
132
+ const name = op.getAttribute('action');
133
+ if (name) language.executeAction(instance, name, local, event);
134
+ continue;
135
+ }
136
+
137
+ // <eval>...</eval> - last resort. Use sparingly; keep app code in
138
+ // actions and signals.
139
+ if (op.localName === 'eval') {
140
+ language.run(op.textContent, instance, local, event);
141
+ continue;
142
+ }
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.
150
+ if (op.localName === 'fetch') {
151
+ const url = language.evaluate(
152
+ op.getAttribute('url') ?? "''",
153
+ instance,
154
+ local,
155
+ '',
156
+ event,
157
+ );
158
+ const into = op.getAttribute('into');
159
+ const as = (op.getAttribute('as') || 'json').toLowerCase();
160
+ const loadingSig = op.getAttribute('loading');
161
+ const errorSig = op.getAttribute('error');
162
+ const setSig = (name, value) => {
163
+ if (!name) return;
164
+ const sig = instance.scope.signal(name);
165
+ if (sig) sig.value = value;
166
+ };
167
+ setSig(loadingSig, true);
168
+ setSig(errorSig, '');
169
+ fetch(url)
170
+ .then(response => {
171
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
172
+ return as === 'text' ? response.text() : response.json();
173
+ })
174
+ .then(data => setSig(into, data))
175
+ .catch(err => setSig(errorSig, String(err?.message || err)))
176
+ .finally(() => setSig(loadingSig, false));
177
+ continue;
178
+ }
179
+ }
180
+ };
181
+ }