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