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,556 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// rendering.js
|
|
3
|
+
//
|
|
4
|
+
// The view -> HTML compiler and DOM wiring layer.
|
|
5
|
+
//
|
|
6
|
+
// This is the single biggest feature, so it's worth understanding its three
|
|
7
|
+
// phases before reading the code:
|
|
8
|
+
//
|
|
9
|
+
// PHASE 1: render to HTML string + binding records
|
|
10
|
+
//
|
|
11
|
+
// We walk the `<view>` subtree and produce an HTML string. Whenever we
|
|
12
|
+
// encounter a directive that needs *runtime* logic (e.g. an event
|
|
13
|
+
// handler, a two-way bind, an attached behavior), we record a small
|
|
14
|
+
// `binding` object describing what should happen and stamp the element
|
|
15
|
+
// with `data-xes-id="...."` so we can find it after the morph.
|
|
16
|
+
//
|
|
17
|
+
// PHASE 2: morph the live DOM
|
|
18
|
+
//
|
|
19
|
+
// The component's `renderNow()` (in component.js) parses our string,
|
|
20
|
+
// then calls `morph` to update the live DOM in place. This preserves
|
|
21
|
+
// focus, value, scroll, etc.
|
|
22
|
+
//
|
|
23
|
+
// PHASE 3: install bindings on live elements
|
|
24
|
+
//
|
|
25
|
+
// Once the DOM is fresh, we walk the `bindings` array, look up each
|
|
26
|
+
// element by its `data-xes-id`, and attach event listeners, two-way
|
|
27
|
+
// binds, behaviors, and drag/drop. Every listener is collected by the
|
|
28
|
+
// instance's `renderScope`, which is disposed at the start of the next
|
|
29
|
+
// render pass - no listener leaks.
|
|
30
|
+
//
|
|
31
|
+
// Special tags handled directly (NOT emitted as plain HTML):
|
|
32
|
+
//
|
|
33
|
+
// <repeat ref="path" as="x">..</repeat> - iterate over selected nodes
|
|
34
|
+
// <items source="path" template="name" /> - iterate using a datatemplate
|
|
35
|
+
// <if test="expr">..[<else>..</else>]</if> - conditional with optional else
|
|
36
|
+
// <text value="path|expr" /> - escaped text output
|
|
37
|
+
//
|
|
38
|
+
// Special directives parsed off ANY element:
|
|
39
|
+
//
|
|
40
|
+
// on:click="expr | #actionName" - event listener
|
|
41
|
+
// bind:property="path|signal" - two-way bind
|
|
42
|
+
// use:behavior="value" - install attached behavior
|
|
43
|
+
// drag:source="payload" - mark draggable
|
|
44
|
+
// drop:target="payload" - mark drop zone
|
|
45
|
+
// drop:command="cmd" - command run on drop
|
|
46
|
+
// class:foo="expr" - toggle class `foo`
|
|
47
|
+
// command="cmd" - bind a button to a command
|
|
48
|
+
// disabled="{expr}" - conditional disabled
|
|
49
|
+
// class="..." - regular interpolated class
|
|
50
|
+
// anything-else="..." - interpolated attribute
|
|
51
|
+
// =============================================================================
|
|
52
|
+
|
|
53
|
+
export function renderingFeature(language) {
|
|
54
|
+
// HTML void tags - we self-close these with `<tag ...>` and never emit a
|
|
55
|
+
// closing tag. The off-screen template would tolerate either, but the
|
|
56
|
+
// morph compares text length and we want predictable output.
|
|
57
|
+
const voidTags = new Set([
|
|
58
|
+
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link',
|
|
59
|
+
'meta', 'source', 'track', 'wbr',
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
// Tags that the renderer should *display* as their serialized XML rather
|
|
63
|
+
// than recurse into. They are "control plane" tags - if you accidentally
|
|
64
|
+
// write `<commandset>` inside `<view>`, you get a code box, not silent
|
|
65
|
+
// misbehavior.
|
|
66
|
+
const controlTags = new Set([
|
|
67
|
+
'xes', 'galath', 'component', 'application', 'model', 'instance',
|
|
68
|
+
'data', 'view', 'commandset', 'controller', 'listeners', 'datatemplate',
|
|
69
|
+
'on:mount', 'on:unmount', 'style', 'computed', 'map', 'import',
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Public: render a list of nodes
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
language.renderChildren = (nodes, instance, local, bindings) =>
|
|
76
|
+
nodes.map(node => renderNode(node, instance, local, bindings)).join('');
|
|
77
|
+
|
|
78
|
+
// Dispatch one node to the right renderer.
|
|
79
|
+
function renderNode(node, instance, local, bindings) {
|
|
80
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
81
|
+
// Skip pure whitespace text - leaves in HTML are noisy. Non-empty
|
|
82
|
+
// text is interpolated and HTML-escaped.
|
|
83
|
+
return node.textContent.trim()
|
|
84
|
+
? language.interpolate(node.textContent, instance, local)
|
|
85
|
+
: '';
|
|
86
|
+
}
|
|
87
|
+
if (node.nodeType !== Node.ELEMENT_NODE) return '';
|
|
88
|
+
|
|
89
|
+
if (node.localName === 'repeat') return renderRepeat(node, instance, local, bindings);
|
|
90
|
+
if (node.localName === 'items') return renderItems(node, instance, local, bindings);
|
|
91
|
+
if (node.localName === 'if') return renderIf(node, instance, local, bindings);
|
|
92
|
+
if (node.localName === 'switch') return renderSwitch(node, instance, local, bindings);
|
|
93
|
+
if (node.localName === 'slot') return renderSlot(node, instance, local, bindings);
|
|
94
|
+
if (node.localName === 'text') {
|
|
95
|
+
// <text value="path|expr" /> - resolve and HTML-escape.
|
|
96
|
+
return language.escapeHtml(
|
|
97
|
+
language.valueOf(node.getAttribute('value') || '', instance, local),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return renderElement(node, instance, local, bindings);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// <repeat>: low-level loop over a node-set
|
|
105
|
+
//
|
|
106
|
+
// Optional `key="@id"` (or any expression) makes the renderer stamp the
|
|
107
|
+
// first emitted element of each iteration with `data-xes-key="..."`. The
|
|
108
|
+
// morph layer detects fully keyed siblings and reorders by key instead of
|
|
109
|
+
// by position - so reorders preserve focus / scroll / state inside rows.
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
function renderRepeat(node, instance, local, bindings) {
|
|
112
|
+
const ref =
|
|
113
|
+
node.getAttribute('ref') ||
|
|
114
|
+
node.getAttribute('nodeset') ||
|
|
115
|
+
node.getAttribute('each');
|
|
116
|
+
const as = node.getAttribute('as') || 'item';
|
|
117
|
+
const keyExpr = node.getAttribute('key');
|
|
118
|
+
const items = instance.tree?.select(ref, local) ?? [];
|
|
119
|
+
return items
|
|
120
|
+
.map((item, index) => {
|
|
121
|
+
const childLocal = { ...local, [as]: item, [`$${as}`]: item, index };
|
|
122
|
+
const html = language.renderChildren(
|
|
123
|
+
[...node.childNodes],
|
|
124
|
+
instance,
|
|
125
|
+
childLocal,
|
|
126
|
+
bindings,
|
|
127
|
+
);
|
|
128
|
+
if (!keyExpr) return html;
|
|
129
|
+
const key = String(language.evaluate(keyExpr, instance, childLocal, '') ?? '');
|
|
130
|
+
return injectKeyAttribute(html, key);
|
|
131
|
+
})
|
|
132
|
+
.join('');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// <items>: high-level loop using a datatemplate
|
|
137
|
+
// Same `key` semantics as <repeat>; honored on either the <items> tag or
|
|
138
|
+
// its <datatemplate> definition.
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
function renderItems(node, instance, local, bindings) {
|
|
141
|
+
const template = instance.templates.get(node.getAttribute('template'));
|
|
142
|
+
if (!template) {
|
|
143
|
+
console.warn(
|
|
144
|
+
`[galath] no <datatemplate name="${node.getAttribute('template')}"> found`,
|
|
145
|
+
);
|
|
146
|
+
return '';
|
|
147
|
+
}
|
|
148
|
+
const as = node.getAttribute('as') || template.getAttribute('for') || 'item';
|
|
149
|
+
const keyExpr = node.getAttribute('key') || template.getAttribute('key');
|
|
150
|
+
const items = instance.tree?.select(node.getAttribute('source'), local) ?? [];
|
|
151
|
+
return items
|
|
152
|
+
.map((item, index) => {
|
|
153
|
+
const childLocal = { ...local, [as]: item, [`$${as}`]: item, index };
|
|
154
|
+
const html = language.renderChildren(
|
|
155
|
+
[...template.childNodes],
|
|
156
|
+
instance,
|
|
157
|
+
childLocal,
|
|
158
|
+
bindings,
|
|
159
|
+
);
|
|
160
|
+
if (!keyExpr) return html;
|
|
161
|
+
const key = String(language.evaluate(keyExpr, instance, childLocal, '') ?? '');
|
|
162
|
+
return injectKeyAttribute(html, key);
|
|
163
|
+
})
|
|
164
|
+
.join('');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Stamp `data-xes-key="..."` onto the first element opening tag in `html`.
|
|
168
|
+
// Leaves leading whitespace and any leading text alone. This is purely a
|
|
169
|
+
// string rewrite because the rendering pipeline emits HTML strings.
|
|
170
|
+
function injectKeyAttribute(html, key) {
|
|
171
|
+
const safe = String(key).replace(/&/g, '&').replace(/"/g, '"');
|
|
172
|
+
return html.replace(
|
|
173
|
+
/(<\s*[a-zA-Z][\w:-]*)/,
|
|
174
|
+
(m) => `${m} data-xes-key="${safe}"`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// <if test="..."> ...optional <else>... </if>
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
function renderIf(node, instance, local, bindings) {
|
|
182
|
+
const test = language.evaluate(node.getAttribute('test') || 'false', instance, local);
|
|
183
|
+
// Children that are NOT <else> are rendered when test is truthy. The
|
|
184
|
+
// (single) <else> child is rendered when test is falsy. Multiple
|
|
185
|
+
// <else>s render in order, in case authors really want that.
|
|
186
|
+
const elseChildren = [...node.children].filter(c => c.localName === 'else');
|
|
187
|
+
const thenChildren = [...node.childNodes].filter(
|
|
188
|
+
n => !(n.nodeType === Node.ELEMENT_NODE && n.localName === 'else'),
|
|
189
|
+
);
|
|
190
|
+
if (test) return language.renderChildren(thenChildren, instance, local, bindings);
|
|
191
|
+
return elseChildren
|
|
192
|
+
.flatMap(el => [...el.childNodes])
|
|
193
|
+
.map(n => renderNode(n, instance, local, bindings))
|
|
194
|
+
.join('');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
// <switch on="expr">
|
|
199
|
+
// <case value="literal">...</case>
|
|
200
|
+
// <case test="expr">...</case> (alternate: explicit boolean expression)
|
|
201
|
+
// <default>...</default>
|
|
202
|
+
// </switch>
|
|
203
|
+
//
|
|
204
|
+
// Picks the first matching <case> and renders its children. Falls back to
|
|
205
|
+
// <default> when nothing matches. Cleaner than chaining <if>s.
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
function renderSwitch(node, instance, local, bindings) {
|
|
208
|
+
const onAttr = node.getAttribute('on');
|
|
209
|
+
const subject = onAttr != null
|
|
210
|
+
? language.evaluate(onAttr, instance, local)
|
|
211
|
+
: undefined;
|
|
212
|
+
const cases = [...node.children].filter(c => c.localName === 'case');
|
|
213
|
+
for (const c of cases) {
|
|
214
|
+
let match = false;
|
|
215
|
+
if (c.hasAttribute('value')) {
|
|
216
|
+
const candidate = language.evaluate(c.getAttribute('value'), instance, local);
|
|
217
|
+
// String-coerced equality so "2" matches 2 and "true" matches true.
|
|
218
|
+
match = String(subject) === String(candidate);
|
|
219
|
+
} else if (c.hasAttribute('test')) {
|
|
220
|
+
match = Boolean(language.evaluate(c.getAttribute('test'), instance, local));
|
|
221
|
+
}
|
|
222
|
+
if (match) return language.renderChildren([...c.childNodes], instance, local, bindings);
|
|
223
|
+
}
|
|
224
|
+
const fallback = [...node.children].find(c => c.localName === 'default');
|
|
225
|
+
if (fallback) return language.renderChildren([...fallback.childNodes], instance, local, bindings);
|
|
226
|
+
return '';
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
// <slot />: insert children that the host wrote between <my-tag>...</my-tag>.
|
|
231
|
+
//
|
|
232
|
+
// The children were captured in connectedCallback before our xesRoot was
|
|
233
|
+
// attached, and live on `instance.slotNodes`. We render a marker the install
|
|
234
|
+
// phase will replace with the captured DOM. The marker carries
|
|
235
|
+
// `data-xes-frozen` so subsequent morphs leave it alone (see morph.js).
|
|
236
|
+
//
|
|
237
|
+
// If the host provided no slot content, we render the <slot>'s own children
|
|
238
|
+
// as a default (familiar from web-components / Vue / Svelte).
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
function renderSlot(node, instance, local, bindings) {
|
|
241
|
+
const hasSlotContent = (instance.slotNodes?.length ?? 0) > 0;
|
|
242
|
+
if (!hasSlotContent) {
|
|
243
|
+
return language.renderChildren([...node.childNodes], instance, local, bindings);
|
|
244
|
+
}
|
|
245
|
+
const id = `s${bindings.length.toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
246
|
+
bindings.push({
|
|
247
|
+
id,
|
|
248
|
+
slot: true,
|
|
249
|
+
events: [],
|
|
250
|
+
binds: [],
|
|
251
|
+
behaviors: [],
|
|
252
|
+
drag: null,
|
|
253
|
+
drop: null,
|
|
254
|
+
dropCommand: null,
|
|
255
|
+
command: null,
|
|
256
|
+
local,
|
|
257
|
+
});
|
|
258
|
+
// The wrapper inherits any tag the author chose ("slot" by default), so
|
|
259
|
+
// styling/layout still works. `data-xes-frozen` keeps morph out of its
|
|
260
|
+
// children once we install slot DOM in there.
|
|
261
|
+
return `<slot data-xes-id="${id}" data-xes-slot="1"></slot>`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
// Generic element rendering. Walks attributes, parses framework
|
|
266
|
+
// directives, and emits HTML.
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
function renderElement(node, instance, local, bindings) {
|
|
269
|
+
if (controlTags.has(node.localName)) {
|
|
270
|
+
// The author wrote a control-plane tag inside the view. Show its
|
|
271
|
+
// serialization instead of pretending it works.
|
|
272
|
+
return `<pre class="xes-code rounded p-3"><code>${language.escapeHtml(language.serialize(node))}</code></pre>`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Stable id used to find this element after the morph.
|
|
276
|
+
const id = `x${bindings.length.toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
277
|
+
const binding = {
|
|
278
|
+
id,
|
|
279
|
+
events: [],
|
|
280
|
+
binds: [],
|
|
281
|
+
behaviors: [],
|
|
282
|
+
drag: null,
|
|
283
|
+
drop: null,
|
|
284
|
+
dropCommand: null,
|
|
285
|
+
command: null,
|
|
286
|
+
local,
|
|
287
|
+
};
|
|
288
|
+
const attrs = [`data-xes-id="${id}"`];
|
|
289
|
+
// Class is special: `class="..."` and `class:foo="expr"` may both
|
|
290
|
+
// appear in any order. We accumulate every class fragment here and
|
|
291
|
+
// emit a single `class="..."` at the end so we never produce two
|
|
292
|
+
// class attributes (which would be invalid HTML).
|
|
293
|
+
const classParts = [];
|
|
294
|
+
|
|
295
|
+
for (const attr of [...node.attributes]) {
|
|
296
|
+
const name = attr.name;
|
|
297
|
+
const value = attr.value;
|
|
298
|
+
|
|
299
|
+
// -- on:event="code|#action" ----------------------------------------
|
|
300
|
+
if (name.startsWith('on:')) {
|
|
301
|
+
binding.events.push({ event: name.slice(3), code: value });
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// -- bind:prop="signalName|path" ------------------------------------
|
|
306
|
+
if (name.startsWith('bind:')) {
|
|
307
|
+
const property = name.slice(5);
|
|
308
|
+
binding.binds.push({ property, target: value });
|
|
309
|
+
const current = readBindingValue(value, instance, local);
|
|
310
|
+
if (property === 'checked') {
|
|
311
|
+
// Booleans render as a presence-only HTML attribute.
|
|
312
|
+
if (Boolean(current)) attrs.push('checked');
|
|
313
|
+
} else {
|
|
314
|
+
attrs.push(`${property}="${language.escapeHtml(current)}"`);
|
|
315
|
+
}
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// -- use:behavior="value" -------------------------------------------
|
|
320
|
+
if (name.startsWith('use:')) {
|
|
321
|
+
binding.behaviors.push({ name: name.slice(4), value });
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// -- drag:* / drop:* ------------------------------------------------
|
|
326
|
+
// We collect them on the binding record so installBindings can hook
|
|
327
|
+
// them up against the live element.
|
|
328
|
+
if (name.startsWith('drag:')) {
|
|
329
|
+
binding.drag = { kind: name.slice(5), value };
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
if (name.startsWith('drop:')) {
|
|
333
|
+
const which = name.slice(5);
|
|
334
|
+
if (which === 'command') binding.dropCommand = value;
|
|
335
|
+
else binding.drop = { kind: which, value };
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// -- class:foo="expr" -----------------------------------------------
|
|
340
|
+
if (name.startsWith('class:')) {
|
|
341
|
+
if (language.evaluate(value, instance, local)) classParts.push(name.slice(6));
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// -- command="..." --------------------------------------------------
|
|
346
|
+
if (name === 'command') {
|
|
347
|
+
binding.command = value;
|
|
348
|
+
if (!language.commandEnabled(instance, value, local)) attrs.push('disabled');
|
|
349
|
+
attrs.push(`data-command="${language.escapeHtml(value)}"`);
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// -- disabled="{expr}" ----------------------------------------------
|
|
354
|
+
if (name === 'disabled' && value.startsWith('{') && value.endsWith('}')) {
|
|
355
|
+
if (language.evaluate(value.slice(1, -1), instance, local)) attrs.push('disabled');
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// -- class="..." (interpolated; merged with class:* below) ----------
|
|
360
|
+
if (name === 'class') {
|
|
361
|
+
classParts.unshift(language.interpolate(value, instance, local));
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// -- everything else: interpolate ------------------------------------
|
|
366
|
+
attrs.push(`${name}="${language.interpolate(value, instance, local)}"`);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Single class attribute, regardless of source ordering.
|
|
370
|
+
if (classParts.length) {
|
|
371
|
+
const merged = classParts.filter(Boolean).join(' ').trim();
|
|
372
|
+
if (merged) attrs.push(`class="${merged}"`);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
bindings.push(binding);
|
|
376
|
+
|
|
377
|
+
const children = language.renderChildren([...node.childNodes], instance, local, bindings);
|
|
378
|
+
if (voidTags.has(node.localName)) return `<${node.localName} ${attrs.join(' ')}>`;
|
|
379
|
+
return `<${node.localName} ${attrs.join(' ')}>${children}</${node.localName}>`;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
// Reading and writing the value behind a `bind:`. `/path/@attr` and `$x`
|
|
384
|
+
// hit the instance tree; bare names hit the signal map.
|
|
385
|
+
// ---------------------------------------------------------------------------
|
|
386
|
+
function readBindingValue(target, instance, local) {
|
|
387
|
+
if (target.startsWith('/') || target.startsWith('$')) {
|
|
388
|
+
return instance.tree?.valueOf(target, local) ?? '';
|
|
389
|
+
}
|
|
390
|
+
const sig = instance.scope.signal(target);
|
|
391
|
+
if (sig) {
|
|
392
|
+
// Two-way binds re-render when the underlying signal moves; record
|
|
393
|
+
// the read so the per-render subscription pass can pick it up.
|
|
394
|
+
instance.readSignals?.add(target);
|
|
395
|
+
return sig.value;
|
|
396
|
+
}
|
|
397
|
+
return '';
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function writeBindingValue(target, value, instance, local) {
|
|
401
|
+
if (target.startsWith('/') || target.startsWith('$')) {
|
|
402
|
+
instance.tree?.setValue(target, value, local);
|
|
403
|
+
} else {
|
|
404
|
+
const sig = instance.scope.signal(target);
|
|
405
|
+
if (sig) sig.value = value;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
// Phase 3: install all bindings on the freshly morphed DOM.
|
|
411
|
+
//
|
|
412
|
+
// Every listener registered here is collected by the instance's
|
|
413
|
+
// renderScope. The next render pass disposes that scope, which
|
|
414
|
+
// automatically detaches all listeners. No leaks.
|
|
415
|
+
// ---------------------------------------------------------------------------
|
|
416
|
+
language.installBindings = (instance, bindings) => {
|
|
417
|
+
for (const binding of bindings) {
|
|
418
|
+
const element = instance.xesRoot?.querySelector(`[data-xes-id="${binding.id}"]`);
|
|
419
|
+
if (!element) continue;
|
|
420
|
+
|
|
421
|
+
// <slot> markers: move (don't clone) captured slot nodes into the
|
|
422
|
+
// wrapper, then freeze it so morph won't touch the children. Idempotent
|
|
423
|
+
// - if already frozen and populated, we do nothing.
|
|
424
|
+
if (binding.slot) {
|
|
425
|
+
if (!element.hasAttribute('data-xes-frozen')) {
|
|
426
|
+
for (const slotNode of instance.slotNodes ?? []) {
|
|
427
|
+
if (slotNode.parentNode) slotNode.parentNode.removeChild(slotNode);
|
|
428
|
+
element.appendChild(slotNode);
|
|
429
|
+
}
|
|
430
|
+
element.setAttribute('data-xes-frozen', '1');
|
|
431
|
+
}
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Buttons / menu items wired to a named command.
|
|
436
|
+
if (binding.command) {
|
|
437
|
+
const handler = event =>
|
|
438
|
+
language.executeCommand(instance, binding.command, binding.local, event);
|
|
439
|
+
element.addEventListener('click', handler);
|
|
440
|
+
instance.renderScope.collect(() =>
|
|
441
|
+
element.removeEventListener('click', handler),
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// on:event="code|#action"
|
|
446
|
+
for (const eventBinding of binding.events) {
|
|
447
|
+
const handler = event =>
|
|
448
|
+
eventBinding.code.startsWith('#')
|
|
449
|
+
? language.executeAction(
|
|
450
|
+
instance,
|
|
451
|
+
eventBinding.code.slice(1),
|
|
452
|
+
binding.local,
|
|
453
|
+
event,
|
|
454
|
+
)
|
|
455
|
+
: language.run(eventBinding.code, instance, binding.local, event);
|
|
456
|
+
element.addEventListener(eventBinding.event, handler);
|
|
457
|
+
instance.renderScope.collect(() =>
|
|
458
|
+
element.removeEventListener(eventBinding.event, handler),
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// bind:property="path|signal"
|
|
463
|
+
for (const bind of binding.binds) {
|
|
464
|
+
// Pick a sensible event for each property: form fields use
|
|
465
|
+
// `input`/`change`, others use `change` as a safe default.
|
|
466
|
+
const eventName = bind.property === 'checked' ? 'change' : 'input';
|
|
467
|
+
const handler = () =>
|
|
468
|
+
writeBindingValue(
|
|
469
|
+
bind.target,
|
|
470
|
+
readElementBindingValue(element, bind.property),
|
|
471
|
+
instance,
|
|
472
|
+
binding.local,
|
|
473
|
+
);
|
|
474
|
+
element.addEventListener(eventName, handler);
|
|
475
|
+
instance.renderScope.collect(() =>
|
|
476
|
+
element.removeEventListener(eventName, handler),
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// use:* attached behaviors.
|
|
481
|
+
for (const behavior of binding.behaviors) {
|
|
482
|
+
language.installBehavior(
|
|
483
|
+
behavior.name,
|
|
484
|
+
element,
|
|
485
|
+
behavior.value,
|
|
486
|
+
instance,
|
|
487
|
+
binding.local,
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Drag/drop: only meaningful when one of the four pieces is present.
|
|
492
|
+
if (binding.drag) {
|
|
493
|
+
instance.renderScope.collect(
|
|
494
|
+
language.installDragDrop(
|
|
495
|
+
element,
|
|
496
|
+
'source',
|
|
497
|
+
binding.drag.value,
|
|
498
|
+
instance,
|
|
499
|
+
binding.local,
|
|
500
|
+
null,
|
|
501
|
+
),
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
if (binding.drop || binding.dropCommand) {
|
|
505
|
+
instance.renderScope.collect(
|
|
506
|
+
language.installDragDrop(
|
|
507
|
+
element,
|
|
508
|
+
'target',
|
|
509
|
+
binding.drop?.value ?? '',
|
|
510
|
+
instance,
|
|
511
|
+
binding.local,
|
|
512
|
+
binding.dropCommand,
|
|
513
|
+
),
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
function readElementBindingValue(element, property) {
|
|
520
|
+
if (property === 'checked') return element.checked;
|
|
521
|
+
if (property === 'value' && isNumericInput(element)) {
|
|
522
|
+
return element.value === '' ? '' : element.valueAsNumber;
|
|
523
|
+
}
|
|
524
|
+
return element[property];
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function isNumericInput(element) {
|
|
528
|
+
const type = String(element.getAttribute?.('type') || element.type || '').toLowerCase();
|
|
529
|
+
return element.localName === 'input' && (type === 'number' || type === 'range');
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// ---------------------------------------------------------------------------
|
|
533
|
+
// Self-tests
|
|
534
|
+
// ---------------------------------------------------------------------------
|
|
535
|
+
language.test('rendering: <text> escapes embedded markup', () => {
|
|
536
|
+
const fake = { scope: new language.Concern('fake'), tree: null };
|
|
537
|
+
fake.scope.signal('snippet', new language.Signal('<x-live></x-live>'));
|
|
538
|
+
const xml = new DOMParser().parseFromString('<text value="snippet"/>', 'application/xml').documentElement;
|
|
539
|
+
const html = language.renderChildren([xml], fake, {}, []);
|
|
540
|
+
if (!html.includes('<x-live>')) {
|
|
541
|
+
throw new Error('snippet was rendered as live markup');
|
|
542
|
+
}
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
language.test('rendering: number inputs bind numeric values', () => {
|
|
546
|
+
const fake = { scope: new language.Concern('fake'), tree: null };
|
|
547
|
+
fake.scope.signal('step', new language.Signal(1));
|
|
548
|
+
const input = document.createElement('input');
|
|
549
|
+
input.type = 'number';
|
|
550
|
+
input.value = '10';
|
|
551
|
+
writeBindingValue('step', readElementBindingValue(input, 'value'), fake, {});
|
|
552
|
+
if (fake.scope.signal('step').value !== 10) {
|
|
553
|
+
throw new Error('number input value was not written as a number');
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
}
|