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.
- package/AGENTS.md +1 -0
- package/README.md +206 -0
- package/TODO.md +140 -0
- package/index.html +188 -0
- package/logo.jpg +0 -0
- package/logo.svg +96 -0
- package/package.json +32 -0
- package/packages/galath/package.json +28 -0
- package/packages/galath/src/behavior.js +193 -0
- package/packages/galath/src/binding.js +247 -0
- package/packages/galath/src/boot.js +52 -0
- package/packages/galath/src/command.js +117 -0
- package/packages/galath/src/component.js +505 -0
- package/packages/galath/src/controller.js +181 -0
- package/packages/galath/src/core.js +190 -0
- package/packages/galath/src/imports.js +132 -0
- package/packages/galath/src/index.js +38 -0
- package/packages/galath/src/instance-model.js +343 -0
- package/packages/galath/src/morph.js +237 -0
- package/packages/galath/src/rendering.js +556 -0
- package/packages/galath/src/signals.js +215 -0
- package/packages/galath/src/templates.js +24 -0
- package/packages/galath/src/xml-events.js +53 -0
- package/packages/galath-css/css/bootstrap-icons.min.css +5 -0
- package/packages/galath-css/css/bootstrap.min.css +6 -0
- package/packages/galath-css/css/fonts/bootstrap-icons.json +2077 -0
- package/packages/galath-css/css/fonts/bootstrap-icons.woff +0 -0
- package/packages/galath-css/css/fonts/bootstrap-icons.woff2 +0 -0
- package/packages/galath-css/js/bootstrap.bundle.min.js +7 -0
- package/packages/galath-css/package.json +13 -0
- package/playground/app.xml +214 -0
- package/playground/chapters/01-welcome.xml +94 -0
- package/playground/chapters/02-signals.xml +166 -0
- package/playground/chapters/03-instance.xml +130 -0
- package/playground/chapters/04-bindings.xml +156 -0
- package/playground/chapters/05-lists.xml +138 -0
- package/playground/chapters/06-commands.xml +144 -0
- package/playground/chapters/07-controller.xml +115 -0
- package/playground/chapters/08-events.xml +126 -0
- package/playground/chapters/09-behaviors.xml +210 -0
- package/playground/chapters/10-components.xml +152 -0
- package/playground/chapters/11-imports.xml +108 -0
- package/playground/chapters/12-expressions.xml +161 -0
- package/playground/chapters/13-paths.xml +197 -0
- package/playground/components/chapter-shell.xml +29 -0
- package/playground/components/highlighter.js +111 -0
- package/playground/components/run-snippet.js +120 -0
- package/public/basic/bootstrap-icons.min.css +5 -0
- package/public/basic/bootstrap.bundle.min.js +7 -0
- package/public/basic/bootstrap.min.css +6 -0
- package/public/basic/fonts/bootstrap-icons.json +2077 -0
- package/public/basic/fonts/bootstrap-icons.woff +0 -0
- package/public/basic/fonts/bootstrap-icons.woff2 +0 -0
- package/public/basic/theme.css +209 -0
- 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
|
+
}
|