galath 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/AGENTS.md +1 -0
  2. package/README.md +206 -0
  3. package/TODO.md +140 -0
  4. package/index.html +188 -0
  5. package/logo.jpg +0 -0
  6. package/logo.svg +96 -0
  7. package/package.json +32 -0
  8. package/packages/galath/package.json +28 -0
  9. package/packages/galath/src/behavior.js +193 -0
  10. package/packages/galath/src/binding.js +247 -0
  11. package/packages/galath/src/boot.js +52 -0
  12. package/packages/galath/src/command.js +117 -0
  13. package/packages/galath/src/component.js +505 -0
  14. package/packages/galath/src/controller.js +181 -0
  15. package/packages/galath/src/core.js +190 -0
  16. package/packages/galath/src/imports.js +132 -0
  17. package/packages/galath/src/index.js +38 -0
  18. package/packages/galath/src/instance-model.js +343 -0
  19. package/packages/galath/src/morph.js +237 -0
  20. package/packages/galath/src/rendering.js +556 -0
  21. package/packages/galath/src/signals.js +215 -0
  22. package/packages/galath/src/templates.js +24 -0
  23. package/packages/galath/src/xml-events.js +53 -0
  24. package/packages/galath-css/css/bootstrap-icons.min.css +5 -0
  25. package/packages/galath-css/css/bootstrap.min.css +6 -0
  26. package/packages/galath-css/css/fonts/bootstrap-icons.json +2077 -0
  27. package/packages/galath-css/css/fonts/bootstrap-icons.woff +0 -0
  28. package/packages/galath-css/css/fonts/bootstrap-icons.woff2 +0 -0
  29. package/packages/galath-css/js/bootstrap.bundle.min.js +7 -0
  30. package/packages/galath-css/package.json +13 -0
  31. package/playground/app.xml +214 -0
  32. package/playground/chapters/01-welcome.xml +94 -0
  33. package/playground/chapters/02-signals.xml +166 -0
  34. package/playground/chapters/03-instance.xml +130 -0
  35. package/playground/chapters/04-bindings.xml +156 -0
  36. package/playground/chapters/05-lists.xml +138 -0
  37. package/playground/chapters/06-commands.xml +144 -0
  38. package/playground/chapters/07-controller.xml +115 -0
  39. package/playground/chapters/08-events.xml +126 -0
  40. package/playground/chapters/09-behaviors.xml +210 -0
  41. package/playground/chapters/10-components.xml +152 -0
  42. package/playground/chapters/11-imports.xml +108 -0
  43. package/playground/chapters/12-expressions.xml +161 -0
  44. package/playground/chapters/13-paths.xml +197 -0
  45. package/playground/components/chapter-shell.xml +29 -0
  46. package/playground/components/highlighter.js +111 -0
  47. package/playground/components/run-snippet.js +120 -0
  48. package/public/basic/bootstrap-icons.min.css +5 -0
  49. package/public/basic/bootstrap.bundle.min.js +7 -0
  50. package/public/basic/bootstrap.min.css +6 -0
  51. package/public/basic/fonts/bootstrap-icons.json +2077 -0
  52. package/public/basic/fonts/bootstrap-icons.woff +0 -0
  53. package/public/basic/fonts/bootstrap-icons.woff2 +0 -0
  54. package/public/basic/theme.css +209 -0
  55. package/seed.html +321 -0
@@ -0,0 +1,247 @@
1
+ // =============================================================================
2
+ // binding.js
3
+ //
4
+ // Expression evaluation, value resolution, and HTML escaping for templates.
5
+ //
6
+ // Galath expressions appear in three places:
7
+ //
8
+ // 1. Attribute interpolation: class="card {active ? 'on' : ''}"
9
+ // 2. Bind shorthand: bind:value="/draft/@text"
10
+ // 3. Inline event code: on:click="set('x', 1)"
11
+ //
12
+ // All three flow through `evaluate(expr, instance, local, fallback, event)`,
13
+ // which:
14
+ //
15
+ // * Tries a *path* shortcut first (`/foo/@bar`, `$todo`, `signalName`).
16
+ // Plain identifiers and paths skip JS entirely - that's both faster and
17
+ // safer (no eval needed for the 90% case).
18
+ //
19
+ // * Falls back to `Function('ctx', 'with(ctx) { return (expr); }')` so
20
+ // real expressions like `count > 0 && !disabled` still work. The `with`
21
+ // block puts every signal value, every helper (`select`, `valueOf`,
22
+ // `set`, `uid`, `deleteNode`...), and every loop-local (`$todo`,
23
+ // `index`) in scope. We *do* run user code via Function() - this is the
24
+ // same threat surface as putting code in `<eval>`. Galath sources are
25
+ // authored, not user-supplied; treat them like any other web page code.
26
+ //
27
+ // * Catches exceptions and returns a fallback so a single broken
28
+ // attribute doesn't blank out the whole view. The renderer logs the
29
+ // failure to the console.
30
+ //
31
+ // `interpolate(text)` runs `{...}` placeholders through `evaluate` and HTML-
32
+ // escapes the result by default - this is *crucial* for safety. A signal
33
+ // containing user-supplied text must never be injected as raw markup.
34
+ // =============================================================================
35
+
36
+ import { assert } from './core.js';
37
+
38
+ export function bindingFeature(language) {
39
+ /**
40
+ * Standard HTML escape. Used for any value that goes into the rendered
41
+ * HTML string before the browser parses it.
42
+ */
43
+ function escapeHtml(value) {
44
+ return String(value ?? '')
45
+ .replaceAll('&', '&amp;')
46
+ .replaceAll('<', '&lt;')
47
+ .replaceAll('>', '&gt;')
48
+ .replaceAll('"', '&quot;')
49
+ .replaceAll("'", '&#039;');
50
+ }
51
+
52
+ /**
53
+ * Build the context object passed to evaluated expressions. This is the
54
+ * runtime "global" of Galath expressions:
55
+ *
56
+ * * Every signal name is a property holding its current value.
57
+ * * Every loop variable from `<repeat as="x">` (and its `$x` form).
58
+ * * `$event` - the originating DOM event (for on:* handlers).
59
+ * * Helper functions: `uid`, `select`, `valueOf`, `set`, `attr`,
60
+ * `deleteNode`, `setNode`.
61
+ *
62
+ * Helpers are intentionally minimal. Anything more advanced should live
63
+ * in a `<controller>` action where it's reviewable and reusable.
64
+ *
65
+ * The context is a Proxy so that signal reads are *recorded* on
66
+ * `instance.readSignals` (when set). The renderer uses that record to
67
+ * subscribe only to signals that the last render actually consulted -
68
+ * unrelated signal changes no longer schedule a render. Helpers and
69
+ * locals fall through to the underlying object; unrecognized names
70
+ * fall through to the outer scope (via `with`'s standard chain) so
71
+ * globals like `Number`, `Math`, etc. still resolve.
72
+ */
73
+ function buildContext(instance, local = {}, event = null) {
74
+ const base = {
75
+ ...local,
76
+ $event: event,
77
+ uid: language.uid,
78
+ select: path => instance.tree?.select(path, local) ?? [],
79
+ valueOf: path => valueOf(path, instance, local),
80
+ set: (name, value) => {
81
+ const sig = instance.scope.signal(name);
82
+ if (sig) sig.value = value;
83
+ },
84
+ attr: (node, name) => node?.get?.(name) ?? '',
85
+ deleteNode: node => node?.remove(),
86
+ setNode: (node, name, value) => node?.set?.(name, value),
87
+ // Climb to the nearest enclosing component instance and read one of
88
+ // its signals. Returns '' when no parent is found - keeps
89
+ // expressions safe even if the component is mounted standalone.
90
+ parentSignal: name => language.parentSignal?.(instance, name) ?? '',
91
+ // XForms-style validation helpers. They read the `<bind>` declarations
92
+ // collected during setupModel and evaluate them against the live
93
+ // instance value. `valid('/path')` returns true when every applicable
94
+ // bind passes; `invalid` is its inverse. `required('/path')` reports
95
+ // whether the path was declared required. `validity('/path')` returns
96
+ // a summary object so views can show targeted error messages.
97
+ valid: path => language.checkValidity?.(instance, path, local).valid ?? true,
98
+ invalid: path => !(language.checkValidity?.(instance, path, local).valid ?? true),
99
+ required: path => language.checkValidity?.(instance, path, local).required ?? false,
100
+ validity: path => language.checkValidity?.(instance, path, local) ?? { valid: true },
101
+ };
102
+ return new Proxy(base, {
103
+ get(target, key) {
104
+ if (key in target) return target[key];
105
+ const sig = instance.scope?.signal?.(key);
106
+ if (sig) {
107
+ instance.readSignals?.add(key);
108
+ return sig.value;
109
+ }
110
+ return undefined;
111
+ },
112
+ has(target, key) {
113
+ if (key in target) return true;
114
+ return Boolean(instance.scope?.signal?.(key));
115
+ },
116
+ });
117
+ }
118
+
119
+ /**
120
+ * Return the value at `path` from the instance tree, falling back to a
121
+ * named signal if the tree has no match.
122
+ */
123
+ function valueOf(path, instance, local = {}) {
124
+ if (instance.tree) {
125
+ const treeValue = instance.tree.valueOf(path, local);
126
+ if (treeValue !== '') return treeValue;
127
+ }
128
+ const sig = instance.scope?.signal?.(path);
129
+ if (sig) {
130
+ instance.readSignals?.add(path);
131
+ return sig.value;
132
+ }
133
+ return '';
134
+ }
135
+
136
+ /**
137
+ * The hot path. Evaluate `expr` against the instance + local context.
138
+ *
139
+ * Returns `fallback` if evaluation throws (and logs to the console). We
140
+ * pick the cheap path - signal name or path lookup - first. Only when
141
+ * neither matches do we compile a Function.
142
+ */
143
+ function evaluate(expr, instance, local = {}, fallback = '', event = null) {
144
+ const direct = simplePathValue(expr, instance, local);
145
+ if (direct.found) return direct.value;
146
+ try {
147
+ // eslint-disable-next-line no-new-func
148
+ return Function(
149
+ 'ctx',
150
+ `with (ctx) { return (${expr}); }`,
151
+ )(buildContext(instance, local, event));
152
+ } catch (error) {
153
+ console.warn('[galath] expression failed:', expr, error);
154
+ return fallback;
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Run `code` for side effects (no return value). Used by `on:click` and
160
+ * `<eval>` operations.
161
+ */
162
+ function run(code, instance, local = {}, event = null) {
163
+ try {
164
+ // eslint-disable-next-line no-new-func
165
+ return Function(
166
+ 'ctx',
167
+ `with (ctx) { ${code}; }`,
168
+ )(buildContext(instance, local, event));
169
+ } catch (error) {
170
+ console.error('[galath] handler failed:', code, error);
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Cheap shortcut for "the expression is just a name or a path". Avoids
176
+ * compiling a Function for the most common case.
177
+ *
178
+ * IMPORTANT: only return a path-style match when the expression has no
179
+ * JS operators / whitespace. Otherwise something like
180
+ * $book/@done ? 'on' : 'off'
181
+ * would short-circuit on the leading `$`, return the boolean, and drop
182
+ * the ternary.
183
+ */
184
+ function simplePathValue(expr, instance, local) {
185
+ expr = String(expr ?? '').trim();
186
+ const sig = instance.scope?.signal?.(expr);
187
+ if (sig) {
188
+ instance.readSignals?.add(expr);
189
+ return { found: true, value: sig.value };
190
+ }
191
+ if (
192
+ (expr.startsWith('/') || expr.startsWith('$')) &&
193
+ !JS_EXPRESSION_HINT.test(expr)
194
+ ) {
195
+ return { found: true, value: instance.tree?.valueOf(expr, local) ?? '' };
196
+ }
197
+ return { found: false, value: undefined };
198
+ }
199
+
200
+ // Any of these characters mean the expression isn't a bare path - it's
201
+ // JS we need to compile. We do NOT include parens because `text()`
202
+ // selectors use them and the JS evaluator can't parse `/foo/text()`
203
+ // (it parses as `/regex/ / text()`). We do NOT include `*` because it
204
+ // appears in wildcard path steps. If you write `$a + $b`, the `+`
205
+ // catches it. Dot is included so `$event.currentTarget...` remains normal
206
+ // JavaScript property access instead of being mistaken for an XML path.
207
+ const JS_EXPRESSION_HINT = /[\s.?<>&|,;!{}]/;
208
+
209
+ /**
210
+ * Replace `{expr}` placeholders inside a template string. By default the
211
+ * result is HTML-escaped. Pass `{ raw: true }` to skip escaping (for
212
+ * places like component attributes whose value is parsed again later).
213
+ */
214
+ function interpolate(text, instance, local = {}, options = {}) {
215
+ const rendered = String(text ?? '').replace(/\{([^}]+)\}/g, (_, expression) =>
216
+ String(evaluate(expression.trim(), instance, local)),
217
+ );
218
+ return options.raw ? rendered : escapeHtml(rendered);
219
+ }
220
+
221
+ // Publish helpers.
222
+ language.escapeHtml = escapeHtml;
223
+ language.evaluate = evaluate;
224
+ language.run = run;
225
+ language.valueOf = valueOf;
226
+ language.interpolate = interpolate;
227
+ language.buildContext = buildContext;
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // Self-tests
231
+ // ---------------------------------------------------------------------------
232
+ language.test('binding: interpolation escapes embedded markup', () => {
233
+ const fake = { scope: new language.Concern('test'), tree: null };
234
+ fake.scope.signal('snippet', new language.Signal('<x-unsafe></x-unsafe>'));
235
+ const r = interpolate('{snippet}', fake);
236
+ assert(r.includes('&lt;x-unsafe&gt;'), 'snippet was not escaped');
237
+ });
238
+
239
+ language.test('binding: dotted event expressions evaluate as JavaScript', () => {
240
+ const fake = { scope: new language.Concern('test'), tree: null };
241
+ const event = { currentTarget: { dataset: { chapterId: 'signals' } } };
242
+ assert(
243
+ evaluate('$event.currentTarget.dataset.chapterId', fake, {}, '', event) === 'signals',
244
+ 'dotted $event expression was treated as a data path',
245
+ );
246
+ });
247
+ }
@@ -0,0 +1,52 @@
1
+ // =============================================================================
2
+ // boot.js
3
+ //
4
+ // `boot({ source, mount })` - the one-call setup. Creates a language, plugs
5
+ // every standard feature in the right order, and starts it. Returns the
6
+ // language object so callers can poke at internals (instance trees,
7
+ // signals, etc.) for debugging / testing.
8
+ //
9
+ // Order of features matters: core must come first (others depend on
10
+ // `language.parseSource`); rendering must come last (it uses helpers added
11
+ // by every other feature). The order encoded here is the canonical one.
12
+ // =============================================================================
13
+
14
+ import { createLanguage, coreFeature } from './core.js';
15
+ import { signalsAndScopesFeature } from './signals.js';
16
+ import { instanceModelFeature } from './instance-model.js';
17
+ import { bindingFeature } from './binding.js';
18
+ import { commandFeature } from './command.js';
19
+ import { controllerFeature } from './controller.js';
20
+ import { templateItemsFeature } from './templates.js';
21
+ import { behaviorFeature } from './behavior.js';
22
+ import { xmlEventsFeature } from './xml-events.js';
23
+ import { importFeature } from './imports.js';
24
+ import { componentFeature } from './component.js';
25
+ import { renderingFeature } from './rendering.js';
26
+
27
+ /**
28
+ * Build a fully-loaded Galath language and `start()` it.
29
+ *
30
+ * @param {object} options
31
+ * @param {string} options.source - The Galath XML source.
32
+ * @param {Element} options.mount - Mount point for `<application>`.
33
+ * @returns {Promise<object>} the started language.
34
+ */
35
+ export async function boot({ source, mount }) {
36
+ const language = createLanguage({ source, mount })
37
+ .use(coreFeature)
38
+ .use(signalsAndScopesFeature)
39
+ .use(instanceModelFeature)
40
+ .use(bindingFeature)
41
+ .use(commandFeature)
42
+ .use(controllerFeature)
43
+ .use(templateItemsFeature)
44
+ .use(behaviorFeature)
45
+ .use(xmlEventsFeature)
46
+ .use(importFeature)
47
+ .use(componentFeature)
48
+ .use(renderingFeature);
49
+
50
+ await language.start();
51
+ return language;
52
+ }
@@ -0,0 +1,117 @@
1
+ // =============================================================================
2
+ // command.js
3
+ //
4
+ // XUL-style commandsets. A `<commandset>` block declares named commands
5
+ // with optional `enabled="..."` predicates. Buttons / menu items reference
6
+ // them with `command="addTodo"` and the runtime wires up:
7
+ //
8
+ // * Click -> execute the command operations.
9
+ // * Enabled state -> the button is `disabled` whenever the predicate is
10
+ // falsey, automatically re-evaluated as part of the render pass.
11
+ //
12
+ // Operations live as the children of `<command>` and are interpreted by
13
+ // `controller.runOperations` (see controller.js). This file only registers
14
+ // commands and exposes execute/enabled helpers.
15
+ // =============================================================================
16
+
17
+ export function commandFeature(language) {
18
+ /**
19
+ * Scan a component definition for its `<commandset>` and index commands
20
+ * by name. Called once during component connect.
21
+ *
22
+ * Commands declaring a `shortcut="ctrl+s"` (etc) get a global keydown
23
+ * listener installed against the host element. Shortcut grammar is
24
+ * intentionally tiny: zero or more modifiers from {ctrl, alt, shift,
25
+ * meta} plus a single key, joined by `+`. Matching is
26
+ * case-insensitive on the key.
27
+ */
28
+ language.setupCommands = instance => {
29
+ instance.commands = new Map();
30
+ const commandset = language.firstChildElement(instance.definition, 'commandset');
31
+ if (!commandset) return;
32
+ for (const command of language.childElements(commandset, 'command')) {
33
+ instance.commands.set(command.getAttribute('name'), command);
34
+ }
35
+ installCommandShortcuts(instance);
36
+ };
37
+
38
+ /**
39
+ * Wire up `shortcut="ctrl+s"` keybindings. The listener lives on
40
+ * `document` so the shortcut works no matter where focus is. We only
41
+ * install one listener per instance; it dispatches to the correct
42
+ * command by walking the indexed map. Listener is collected on
43
+ * instance.scope so it's removed at unmount.
44
+ */
45
+ function installCommandShortcuts(instance) {
46
+ const entries = [];
47
+ for (const [name, command] of instance.commands) {
48
+ const shortcut = command.getAttribute('shortcut');
49
+ if (shortcut) entries.push({ name, combo: parseShortcut(shortcut) });
50
+ }
51
+ if (entries.length === 0) return;
52
+
53
+ const handler = event => {
54
+ // Don't poach typing in editable controls unless the shortcut is
55
+ // explicitly modifier-bearing (Ctrl/Cmd/Alt). Plain alphanumeric
56
+ // shortcuts would be hostile in an <input>.
57
+ const target = event.target;
58
+ const editable =
59
+ target?.isContentEditable ||
60
+ target?.matches?.('input, textarea, select');
61
+ for (const entry of entries) {
62
+ if (!matches(entry.combo, event)) continue;
63
+ if (editable && !(entry.combo.ctrl || entry.combo.meta || entry.combo.alt)) continue;
64
+ event.preventDefault();
65
+ language.executeCommand(instance, entry.name, {}, event);
66
+ return;
67
+ }
68
+ };
69
+ document.addEventListener('keydown', handler);
70
+ instance.scope.collect(() =>
71
+ document.removeEventListener('keydown', handler),
72
+ );
73
+ }
74
+
75
+ function parseShortcut(spec) {
76
+ const parts = String(spec).toLowerCase().split('+').map(s => s.trim()).filter(Boolean);
77
+ const combo = { ctrl: false, alt: false, shift: false, meta: false, key: '' };
78
+ for (const part of parts) {
79
+ if (part === 'ctrl' || part === 'control') combo.ctrl = true;
80
+ else if (part === 'alt' || part === 'option') combo.alt = true;
81
+ else if (part === 'shift') combo.shift = true;
82
+ else if (part === 'meta' || part === 'cmd' || part === 'command') combo.meta = true;
83
+ else combo.key = part;
84
+ }
85
+ return combo;
86
+ }
87
+
88
+ function matches(combo, event) {
89
+ if (Boolean(event.ctrlKey) !== combo.ctrl) return false;
90
+ if (Boolean(event.altKey) !== combo.alt) return false;
91
+ if (Boolean(event.shiftKey) !== combo.shift) return false;
92
+ if (Boolean(event.metaKey) !== combo.meta) return false;
93
+ return String(event.key || '').toLowerCase() === combo.key;
94
+ }
95
+
96
+ /**
97
+ * `true` if the command has no `enabled` attribute, otherwise the value
98
+ * of evaluating that expression against the current scope.
99
+ */
100
+ language.commandEnabled = (instance, name, local = {}) => {
101
+ const command = instance.commands?.get(name);
102
+ if (!command) return false;
103
+ const expression = command.getAttribute('enabled');
104
+ return expression ? Boolean(language.evaluate(expression, instance, local)) : true;
105
+ };
106
+
107
+ /**
108
+ * Execute the command. Silently no-ops when the command is missing OR
109
+ * disabled (so a user double-click on a disabled button can't sneak
110
+ * through a stale handler).
111
+ */
112
+ language.executeCommand = (instance, name, local = {}, event = null) => {
113
+ const command = instance.commands?.get(name);
114
+ if (!command || !language.commandEnabled(instance, name, local)) return;
115
+ language.runOperations([...command.children], instance, local, event);
116
+ };
117
+ }