@xoong/core 0.0.0-stage → 0.0.35

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Duong Tran Xuan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,32 @@
1
- # Temporary Holding Version
1
+ # @xoong/core
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The reactive base of xoong: signals, `html` templates and Light DOM custom elements. No dependencies.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @xoong/core
9
+ ```
10
+
11
+ ## Use
12
+
13
+ ```js
14
+ import { signal, computed, effect, html, render, XoElement, define } from '@xoong/core';
15
+
16
+ const count = signal(0);
17
+ class ClickCounter extends XoElement {
18
+ render() { return html`<button @click=${() => count.value++}>Clicked ${() => count.value}</button>`; }
19
+ }
20
+ define('click-counter', ClickCounter);
21
+ ```
22
+
23
+ `XoControl` (also here) is the base of form controls: value, validity, `dirty` and `touched` through `ElementInternals`.
24
+
25
+ ## More
26
+
27
+ * Documentation and demos: https://github.com/fpt-duongtx/xoong#readme
28
+ * Source: https://github.com/fpt-duongtx/xoong/tree/main/packages/core
29
+ * All packages share one version number and are released together: https://github.com/fpt-duongtx/xoong/releases
30
+ * xoong is a working prototype (0.0.x): the HTML syntax is still Draft.
31
+
32
+ MIT licence.
package/package.json CHANGED
@@ -1,6 +1,40 @@
1
1
  {
2
2
  "name": "@xoong/core",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.35",
4
+ "description": "xoong core: signals, Light DOM custom elements, html templates. Zero dependencies.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js",
9
+ "./signals": "./src/signals.js",
10
+ "./closest": "./src/closest.js",
11
+ "./syntax": "./src/syntax.js"
12
+ },
13
+ "sideEffects": false,
14
+ "license": "MIT",
15
+ "files": [
16
+ "src"
17
+ ],
18
+ "keywords": [
19
+ "xoong",
20
+ "web-framework",
21
+ "openapi",
22
+ "custom-elements"
23
+ ],
24
+ "author": "Duong Tran Xuan <duongtx1505@icloud.com>",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/fpt-duongtx/xoong.git",
28
+ "directory": "packages/core"
29
+ },
30
+ "bugs": {
31
+ "url": "https://github.com/fpt-duongtx/xoong/issues"
32
+ },
33
+ "homepage": "https://github.com/fpt-duongtx/xoong#readme",
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "engines": {
38
+ "node": ">=22.12"
39
+ }
40
+ }
package/src/closest.js ADDED
@@ -0,0 +1,24 @@
1
+ // @ts-check
2
+ /** Edit distance between two strings (Levenshtein). */
3
+ export function distance(a, b) {
4
+ const dp = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
5
+ for (let j = 1; j <= b.length; j++) dp[0][j] = j;
6
+ for (let i = 1; i <= a.length; i++)
7
+ for (let j = 1; j <= b.length; j++)
8
+ dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
9
+ return dp[a.length][b.length];
10
+ }
11
+
12
+ /**
13
+ * The candidate closest to `ref` (case-insensitive), or undefined when nothing is close enough.
14
+ * Used for "unknown operation ... closest: ..." messages in the runtime and in `xoong check`.
15
+ * @param {string} ref @param {Iterable<string>} candidates
16
+ */
17
+ export function nearest(ref, candidates) {
18
+ let best, bestD = Infinity;
19
+ for (const c of candidates) {
20
+ const d = distance(ref.toLowerCase(), c.toLowerCase());
21
+ if (d < bestD) { bestD = d; best = c; }
22
+ }
23
+ return bestD <= Math.max(3, Math.floor(ref.length / 3)) ? best : undefined;
24
+ }
package/src/control.js ADDED
@@ -0,0 +1,230 @@
1
+ // @ts-check
2
+ import { signal, batch } from './signals.js';
3
+ import { XoElement } from './element.js';
4
+ import { text } from './text.js';
5
+
6
+ /**
7
+ * Base class for custom form controls (rich text, combobox, tag input, your own image field).
8
+ *
9
+ * A control built on it behaves like a native input in a <form>:
10
+ * - it submits its value (`name`, `value`) through ElementInternals, so FormData and `xo-submit` see it;
11
+ * - it takes part in constraint validation (`required`, `setCustomValidity`, `validity`, `checkValidity`,
12
+ * `reportValidity`, the form's own submit validation);
13
+ * - it resets with the form, and is disabled with its fieldset;
14
+ * - it tracks `dirty`, `touched` and `pristine` as signals, so markup and JS can read them the same way for every control.
15
+ *
16
+ * Subclass it, render the inner editing UI (Light DOM), and call `userInput(value)` when the person changes the value:
17
+ *
18
+ * class StarRating extends XoControl {
19
+ * static props = { ...XoControl.props, max: Number };
20
+ * render() { return html`<button @click=${() => this.userInput(Number(this.value) + 1)}>+</button>`; }
21
+ * validate(value) { return Number(value) > this.max ? 'Too many stars' : ''; } // optional: '' means valid
22
+ * }
23
+ * define('star-rating', StarRating);
24
+ *
25
+ * Where ElementInternals does not exist (older DOMs, happy-dom) the control keeps working on its own: `value`, validity and
26
+ * state still behave; it just does not appear in a native FormData. `xo-form` reads controls through these properties.
27
+ */
28
+ export class XoControl extends XoElement {
29
+ static formAssociated = true;
30
+ static props = { name: String, required: Boolean, disabled: Boolean, readonly: Boolean };
31
+
32
+ /** @type {ElementInternals | null} */ #internals = null;
33
+ #value = signal(/** @type {any} */ (''));
34
+ #initial = signal(/** @type {any} */ (''));
35
+ #touched = signal(false);
36
+ #custom = signal('');
37
+ #hook = signal('');
38
+ #started = false;
39
+ #formDisabled = false; // disabled by a <fieldset disabled>, tracked apart from the `disabled` attribute
40
+
41
+ constructor() {
42
+ super();
43
+ try { this.#internals = this.attachInternals?.() ?? null; } catch { this.#internals = null; }
44
+ this.addEventListener('focusout', (e) => { if (!this.contains(/** @type {Node | null} */ (e.relatedTarget))) this.markTouched(); });
45
+ // A value set on the element before it was upgraded (the tag was used before `define`) is an own property that would hide the accessor.
46
+ // It is kept aside and becomes the start value at the first use: a subclass is not ready to run setValue while this constructor runs.
47
+ if (Object.prototype.hasOwnProperty.call(this, 'value')) { this.#early = { value: /** @type {any} */ (this).value }; delete (/** @type {any} */ (this)).value; }
48
+ }
49
+
50
+ /** @type {{ value: any } | null} */ #early = null;
51
+ #applyEarly() {
52
+ if (!this.#early) return;
53
+ const { value } = this.#early;
54
+ this.#early = null;
55
+ this.setValue(value);
56
+ }
57
+
58
+ // ---- value ----------------------------------------------------------------------------------------------------------
59
+
60
+ /** The value. Strings by default; a subclass may hold anything and override `serialize`. */
61
+ get value() { this.#applyEarly(); return this.#value.value; }
62
+ set value(v) { this.#applyEarly(); this.setValue(v); }
63
+
64
+ /**
65
+ * Set the value programmatically (like `input.value = x`: no events). The first value set while the control is not yet
66
+ * started (an attribute or property before connection) becomes the initial value that `reset` and `dirty` compare with.
67
+ */
68
+ setValue(v) {
69
+ batch(() => {
70
+ this.#value.value = v;
71
+ if (!this.#started) this.#initial.value = v;
72
+ });
73
+ this.#sync();
74
+ }
75
+
76
+ /** The person changed the value: set it and fire `input`, like a native control. */
77
+ userInput(v) {
78
+ this.setValue(v);
79
+ this.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
80
+ }
81
+
82
+ /** The person finished a change (blur after editing, a selection): fire `change`. */
83
+ userChange() {
84
+ this.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
85
+ }
86
+
87
+ /** What the form submits for a value: a string, a File, FormData, or null for nothing. Override for structured values. */
88
+ serialize(v) {
89
+ if (v == null || v === '') return v === '' ? '' : null;
90
+ if (typeof FormData !== 'undefined' && v instanceof FormData) return v;
91
+ if (typeof File !== 'undefined' && v instanceof File) return v; // a file input-like control submits the file itself
92
+ return typeof v === 'object' ? JSON.stringify(v) : String(v);
93
+ }
94
+
95
+ /** Whether a value counts as "nothing entered" for `required`. Override for structured values. */
96
+ isEmpty(v) { return v == null || v === '' || (Array.isArray(v) && v.length === 0); }
97
+
98
+ /** Optional validation hook: return an error message for an invalid value, '' when valid. Runs on every change. */
99
+ validate(_value) { return ''; }
100
+
101
+ // ---- state (signals: reading them inside a template or effect is tracked) -----------------------------------------------
102
+
103
+ get initialValue() { return this.#initial.value; }
104
+ get dirty() { return !same(this.#value.value, this.#initial.value); }
105
+ get pristine() { return !this.dirty; }
106
+ get touched() { return this.#touched.value; }
107
+ /** True when the control has no validation error. */
108
+ get valid() { return this.validationMessage === ''; }
109
+ get invalid() { return !this.valid; }
110
+
111
+ /** Mark the control as visited (blur does this). */
112
+ markTouched() {
113
+ if (this.#touched.peek()) return;
114
+ this.#touched.value = true;
115
+ this.#reflect();
116
+ }
117
+
118
+ // ---- validation -------------------------------------------------------------------------------------------------------
119
+
120
+ /** The message to show, or '' when valid. Reading it is tracked. */
121
+ get validationMessage() {
122
+ this.#value.value; // track
123
+ this.#hook.value;
124
+ if (this.#custom.value) return this.#custom.value;
125
+ if (this.required && this.isEmpty(this.#value.value)) return text('xoong.validation.required', 'Please fill in this field.');
126
+ return this.#hook.value;
127
+ }
128
+ /** Disabled by its own attribute or by a disabled fieldset. */
129
+ get isDisabled() { return this.disabled || this.#formDisabled; }
130
+ get willValidate() { return !this.isDisabled && !this.readonly; }
131
+ get validity() {
132
+ const message = this.validationMessage;
133
+ const missing = this.required && this.isEmpty(this.#value.peek()) && !this.#custom.peek();
134
+ return { valid: !message, valueMissing: missing, customError: !!(this.#custom.peek() || (this.#hook.peek() && !missing)), badInput: false, typeMismatch: false, patternMismatch: false, rangeOverflow: false, rangeUnderflow: false, stepMismatch: false, tooLong: false, tooShort: false };
135
+ }
136
+ /** Set your own error (cross-field rules, server errors). An empty string clears it. */
137
+ setCustomValidity(message) {
138
+ this.#custom.value = String(message ?? '');
139
+ this.#sync();
140
+ }
141
+ checkValidity() {
142
+ if (!this.willValidate || this.valid) return true;
143
+ this.dispatchEvent(new Event('invalid', { cancelable: true }));
144
+ return false;
145
+ }
146
+ reportValidity() {
147
+ const ok = this.checkValidity();
148
+ if (!ok) { this.markTouched(); this.focusTarget?.focus?.(); }
149
+ return ok;
150
+ }
151
+
152
+ /** The element inside that takes focus (and anchors the browser's validation bubble). Defaults to the first focusable child. */
153
+ get focusTarget() {
154
+ return /** @type {HTMLElement | null} */ (this.querySelector('input:not([type=hidden]),select,textarea,button,[contenteditable="true"],[tabindex]:not([tabindex="-1"])')) ?? (this.hasAttribute('tabindex') ? this : null);
155
+ }
156
+
157
+ // ---- reset to initial -------------------------------------------------------------------------------------------------
158
+
159
+ /** Back to the initial value, untouched. The form calls this on reset. */
160
+ reset() {
161
+ batch(() => { this.#value.value = this.#initial.peek(); this.#touched.value = false; this.#custom.value = ''; });
162
+ this.#sync();
163
+ }
164
+ /** Make the current value the new baseline, as after a successful save: `dirty` becomes false. */
165
+ markPristine() {
166
+ this.#initial.value = this.#value.peek();
167
+ this.#reflect();
168
+ }
169
+
170
+ // ---- form-associated custom element callbacks ---------------------------------------------------------------------------
171
+
172
+ formResetCallback() { this.reset(); }
173
+ /** A fieldset disabled or enabled us. Not reflected into `disabled`: the browser would then keep us disabled for good. */
174
+ formDisabledCallback(disabled) {
175
+ this.#formDisabled = !!disabled;
176
+ this.toggleAttribute('data-form-disabled', !!disabled);
177
+ this.#sync();
178
+ }
179
+ formStateRestoreCallback(state) { if (state != null) this.setValue(state); }
180
+ get form() { return this.#internals?.form ?? this.closest('form'); }
181
+
182
+ // ---- lifecycle ----------------------------------------------------------------------------------------------------------
183
+
184
+ connectedCallback() {
185
+ this.#applyEarly();
186
+ super.connectedCallback();
187
+ if (!this.#started) {
188
+ this.#started = true;
189
+ if (this.hasAttribute('value') && this.#value.peek() === '') { this.#value.value = this.getAttribute('value'); this.#initial.value = this.#value.peek(); }
190
+ }
191
+ this.#sync();
192
+ }
193
+
194
+ attributeChangedCallback(attr, old, value) {
195
+ super.attributeChangedCallback(attr, old, value);
196
+ if (attr === 'value' && !this.#started) this.setValue(value);
197
+ if (attr === 'required' || attr === 'disabled' || attr === 'readonly') this.#sync();
198
+ }
199
+
200
+ // ---- internals ----------------------------------------------------------------------------------------------------------
201
+
202
+ /** Push value and validity to the browser and reflect the state as attributes. */
203
+ #sync() {
204
+ this.#hook.value = this.validate(this.#value.peek()) || '';
205
+ const internals = this.#internals;
206
+ if (internals) {
207
+ try {
208
+ internals.setFormValue(this.isDisabled ? null : this.serialize(this.#value.peek()));
209
+ const message = this.validationMessage;
210
+ if (message) internals.setValidity({ valueMissing: this.validity.valueMissing, customError: this.validity.customError }, message, this.focusTarget ?? undefined);
211
+ else internals.setValidity({});
212
+ } catch { /* a DOM with a partial ElementInternals: keep our own state */ }
213
+ }
214
+ this.#reflect();
215
+ }
216
+
217
+ #reflect() {
218
+ this.toggleAttribute('data-dirty', this.dirty);
219
+ this.toggleAttribute('data-touched', this.#touched.peek());
220
+ this.toggleAttribute('data-invalid', !this.valid && this.willValidate);
221
+ }
222
+ }
223
+
224
+ /** Equality for control values: primitives by value, structured values by their JSON form. */
225
+ function same(a, b) {
226
+ if (Object.is(a, b)) return true;
227
+ if (a && b && typeof a === 'object' && typeof b === 'object') { try { return JSON.stringify(a) === JSON.stringify(b); } catch { return false; } }
228
+ return false;
229
+ }
230
+
package/src/element.js ADDED
@@ -0,0 +1,118 @@
1
+ // @ts-check
2
+ import { signal, effect } from './signals.js';
3
+ import { render } from './html.js';
4
+
5
+ const kebab = (s) => s.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase());
6
+
7
+ function coerce(type, raw) {
8
+ if (type === Boolean) return raw !== null && raw !== 'false';
9
+ if (raw === null) return type === Number ? 0 : '';
10
+ return type === Number ? Number(raw) : String(raw);
11
+ }
12
+
13
+ function fromProp(type, v) {
14
+ if (type === Boolean) return v !== null && v !== undefined && v !== false && v !== 'false';
15
+ if (type === Number) return Number(v ?? 0);
16
+ return v == null ? '' : String(v);
17
+ }
18
+
19
+ /**
20
+ * Light DOM base element.
21
+ *
22
+ * class UserCard extends XoElement {
23
+ * static props = { name: String, age: Number } // attributes <-> signals
24
+ * render() { return html`<h3>${() => this.name}</h3>` }
25
+ * }
26
+ * define('user-card', UserCard)
27
+ *
28
+ * - `props` are reflected from attributes and readable/writable as properties;
29
+ * reading `this.name` inside a template function is tracked automatically.
30
+ * - Implement `render()` only if the element OWNS its content. To enhance
31
+ * server-rendered HTML in place, implement `connected()` instead.
32
+ * - Use `this.effect(fn)` for side effects: it is disposed on disconnect.
33
+ */
34
+ // Importable outside a browser (SSR, tooling, tests); elements only run in a DOM.
35
+ const Base = /** @type {typeof HTMLElement} */ (globalThis.HTMLElement ?? class {});
36
+
37
+ export class XoElement extends Base {
38
+ /** @type {Record<string, typeof String | typeof Number | typeof Boolean>} */
39
+ static props = {};
40
+
41
+ static get observedAttributes() {
42
+ return Object.keys(this.props).map(kebab);
43
+ }
44
+
45
+ /** @type {Record<string, import('./signals.js').Signal<any>>} */
46
+ #sigs = {};
47
+ /** @type {(() => void)[]} */
48
+ #disposers = [];
49
+
50
+ constructor() {
51
+ super();
52
+ const props = /** @type {typeof XoElement} */ (this.constructor).props;
53
+ for (const [name, type] of Object.entries(props)) {
54
+ // Properties set before the element was upgraded win over defaults.
55
+ const own = Object.getOwnPropertyDescriptor(this, name);
56
+ if (own) delete (/** @type {any} */ (this))[name];
57
+ this.#sigs[name] = signal(own ? own.value : coerce(type, null));
58
+ }
59
+ }
60
+
61
+ /** @param {string} name */
62
+ _sig(name) {
63
+ return this.#sigs[name];
64
+ }
65
+
66
+ attributeChangedCallback(attr, _old, value) {
67
+ const props = /** @type {typeof XoElement} */ (this.constructor).props;
68
+ const name = Object.keys(props).find((n) => kebab(n) === attr);
69
+ if (name) this.#sigs[name].value = coerce(props[name], value);
70
+ }
71
+
72
+ connectedCallback() {
73
+ const result = this.render?.();
74
+ if (result) this.#disposers.push(render(result, this));
75
+ this.connected?.();
76
+ }
77
+
78
+ disconnectedCallback() {
79
+ this.#disposers.splice(0).forEach((d) => d());
80
+ this.disconnected?.();
81
+ }
82
+
83
+ /** Resolves when the element's children are there: at once when the page is parsed, else after the parser reached the end of the document. Await it in connected(). */
84
+ ready() {
85
+ return new Promise((r) => (document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', () => r(this), { once: true }) : r(this)));
86
+ }
87
+
88
+ /** Side effect bound to this element's lifetime. @param {() => void | (() => void)} fn */
89
+ effect(fn) {
90
+ this.#disposers.push(effect(fn));
91
+ }
92
+
93
+ /** Dispatch a bubbling CustomEvent. */
94
+ emit(name, detail) {
95
+ this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }));
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Register a custom element (idempotent). Tag must contain a hyphen.
101
+ * @param {string} tag
102
+ * @param {typeof XoElement} cls
103
+ */
104
+ export function define(tag, cls) {
105
+ if (customElements.get(tag)) return;
106
+ for (const name of Object.keys(cls.props)) {
107
+ Object.defineProperty(cls.prototype, name, {
108
+ get() {
109
+ return this._sig(name).value;
110
+ },
111
+ set(v) {
112
+ this._sig(name).value = fromProp(cls.props[name], v);
113
+ },
114
+ configurable: true,
115
+ });
116
+ }
117
+ customElements.define(tag, cls);
118
+ }
package/src/html.js ADDED
@@ -0,0 +1,153 @@
1
+ // @ts-check
2
+ /**
3
+ * `html` tagged template with fine-grained reactive bindings (Light DOM, no VDOM).
4
+ *
5
+ * html`<h3 class=${() => cls.value}>${() => name.value}</h3>
6
+ * <button @click=${() => save()}>Save</button>`
7
+ *
8
+ * Binding rules (the whole language):
9
+ * child position : function => reactive, value => static; arrays, nodes and
10
+ * nested `html` results are supported.
11
+ * name=${v} : attribute; function => reactive. null/false removes it.
12
+ * ?name=${v} : boolean attribute.
13
+ * .name=${v} : DOM property (use lowercase-insensitive names; original case is kept).
14
+ * @event=${fn} : event listener.
15
+ * An attribute value must be exactly one expression (no mixing text and `${}`).
16
+ */
17
+ import { effect } from './signals.js';
18
+
19
+ const MARK = '__xo_';
20
+ const cache = new WeakMap();
21
+
22
+ export class TemplateResult {
23
+ /** @param {TemplateStringsArray} strings @param {unknown[]} values */
24
+ constructor(strings, values) {
25
+ this.strings = strings;
26
+ this.values = values;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * @param {TemplateStringsArray} strings
32
+ * @param {...unknown} values
33
+ */
34
+ export const html = (strings, ...values) => new TemplateResult(strings, values);
35
+
36
+ /** Parse once per call site. */
37
+ function compile(strings) {
38
+ let compiled = cache.get(strings);
39
+ if (compiled) return compiled;
40
+ let out = '';
41
+ let cur = strings[0];
42
+ /** @type {({kind:'child'}|{kind:'attr', name:string, prefix:string})[]} */
43
+ const parts = [];
44
+ for (let i = 0; i < strings.length - 1; i++) {
45
+ out += cur;
46
+ cur = strings[i + 1];
47
+ if (out.lastIndexOf('<') > out.lastIndexOf('>')) {
48
+ const m = /([@.?]?)([^\s"'<>=/@.?]+)=(["']?)$/.exec(out);
49
+ if (!m) throw new Error('xoong html: an expression inside a tag must be a whole attribute value, e.g. class=${...}');
50
+ out = out.slice(0, m.index) + `${m[2].toLowerCase()}="${MARK}${i}"`;
51
+ if (m[3] && cur.startsWith(m[3])) cur = cur.slice(1); // drop the closing quote that wrapped the expression
52
+ parts.push({ kind: 'attr', name: m[2], prefix: m[1] });
53
+ } else {
54
+ out += `<!--${MARK}${i}-->`;
55
+ parts.push({ kind: 'child' });
56
+ }
57
+ }
58
+ out += cur;
59
+ const template = document.createElement('template');
60
+ template.innerHTML = out;
61
+ compiled = { template, parts };
62
+ cache.set(strings, compiled);
63
+ return compiled;
64
+ }
65
+
66
+ /**
67
+ * Render a template result into `container`. Returns a dispose function.
68
+ * @param {TemplateResult} result
69
+ * @param {Element | DocumentFragment} container
70
+ * @returns {() => void}
71
+ */
72
+ export function render(result, container) {
73
+ const disposers = [];
74
+ const frag = instantiate(result, disposers);
75
+ container.replaceChildren(frag);
76
+ return () => disposers.splice(0).forEach((d) => d());
77
+ }
78
+
79
+ function instantiate(result, disposers) {
80
+ const { template, parts } = compile(result.strings);
81
+ const frag = /** @type {DocumentFragment} */ (template.content.cloneNode(true));
82
+ const walker = document.createTreeWalker(frag, NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_ELEMENT);
83
+ /** @type {Comment[]} */ const comments = [];
84
+ /** @type {Element[]} */ const elements = [];
85
+ while (walker.nextNode()) {
86
+ const n = walker.currentNode;
87
+ if (n.nodeType === 8) {
88
+ if (/** @type {Comment} */ (n).data.startsWith(MARK)) comments.push(/** @type {Comment} */ (n));
89
+ } else {
90
+ elements.push(/** @type {Element} */ (n));
91
+ }
92
+ }
93
+ for (const c of comments) {
94
+ const i = Number(c.data.slice(MARK.length));
95
+ bindChild(c, result.values[i], disposers);
96
+ }
97
+ for (const el of elements) {
98
+ for (const attr of [...el.attributes]) {
99
+ if (!attr.value.startsWith(MARK)) continue;
100
+ const i = Number(attr.value.slice(MARK.length));
101
+ const part = /** @type {any} */ (parts[i]);
102
+ el.removeAttribute(attr.name);
103
+ bindAttr(el, part, result.values[i], disposers);
104
+ }
105
+ }
106
+ return frag;
107
+ }
108
+
109
+ function bindAttr(el, part, value, disposers) {
110
+ const { prefix, name } = part;
111
+ if (prefix === '@') {
112
+ el.addEventListener(name, /** @type {EventListener} */ (value));
113
+ disposers.push(() => el.removeEventListener(name, /** @type {EventListener} */ (value)));
114
+ return;
115
+ }
116
+ const apply = (v) => {
117
+ if (name.startsWith('style:')) el.style[v == null || v === false ? 'removeProperty' : 'setProperty'](name.slice(6), v); // style:--x=${v}: the CSS object model, so a strict CSP (no style="") allows it
118
+ else if (prefix === '.') el[name] = v;
119
+ else if (prefix === '?') el.toggleAttribute(name, !!v);
120
+ else if (v == null || v === false) el.removeAttribute(name);
121
+ else el.setAttribute(name, v === true ? '' : String(v));
122
+ };
123
+ if (typeof value === 'function') disposers.push(effect(() => apply(value())));
124
+ else apply(value);
125
+ }
126
+
127
+ function bindChild(marker, value, disposers) {
128
+ const end = document.createComment('/xo');
129
+ marker.after(end);
130
+ marker.data = 'xo';
131
+ let inner = [];
132
+ const clear = () => {
133
+ inner.splice(0).forEach((d) => d());
134
+ while (marker.nextSibling && marker.nextSibling !== end) marker.nextSibling.remove();
135
+ };
136
+ const put = (v) => {
137
+ clear();
138
+ const frag = document.createDocumentFragment();
139
+ appendValue(frag, v, inner);
140
+ end.before(frag);
141
+ };
142
+ if (typeof value === 'function') disposers.push(effect(() => put(value())));
143
+ else put(value);
144
+ disposers.push(clear);
145
+ }
146
+
147
+ function appendValue(frag, v, disposers) {
148
+ if (v == null || v === false || v === true) return;
149
+ if (v instanceof TemplateResult) frag.append(instantiate(v, disposers));
150
+ else if (v instanceof Node) frag.append(v);
151
+ else if (Array.isArray(v)) v.forEach((x) => appendValue(frag, x, disposers));
152
+ else frag.append(document.createTextNode(String(v)));
153
+ }
package/src/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { signal, computed, effect, batch, untracked, disposeComputed, Signal } from './signals.js';
2
+ export { html, render, TemplateResult } from './html.js';
3
+ export { XoElement, define } from './element.js';
4
+ export { XoControl } from './control.js';
5
+ export { text, setTextProvider } from './text.js';
package/src/signals.js ADDED
@@ -0,0 +1,235 @@
1
+ // @ts-check
2
+ /**
3
+ * Minimal signals: signal, computed, effect, batch, untracked.
4
+ *
5
+ * Semantics (kept deliberately small):
6
+ * - Reads inside a computed/effect are tracked automatically.
7
+ * - Writes outside a batch flush effects synchronously.
8
+ * - Writes inside `batch` flush once at the end of the outermost batch.
9
+ * - `effect` is for side effects only. Never fetch data in an effect:
10
+ * declare the operation on a view instead.
11
+ */
12
+
13
+ /** @typedef {{ run(): void }} Observer */
14
+
15
+ /** @type {Observer | null} */
16
+ let active = null;
17
+ let batchDepth = 0;
18
+ /** @type {Set<Effect>} */
19
+ const pending = new Set();
20
+ let flushing = false;
21
+
22
+ /** Track one source in the active observer. */
23
+ function track(source) {
24
+ if (active) {
25
+ source.subs.add(active);
26
+ active.deps.add(source);
27
+ }
28
+ }
29
+
30
+ function flush() {
31
+ if (flushing) return;
32
+ flushing = true;
33
+ /** @type {unknown[]} */
34
+ const errors = [];
35
+ try {
36
+ // Effects may schedule more effects while running; loop until stable.
37
+ let guard = 0;
38
+ while (pending.size) {
39
+ if (++guard > 10000) {
40
+ pending.clear();
41
+ throw new Error('xoong: effect loop did not settle (an effect keeps writing a signal it reads)');
42
+ }
43
+ const [next] = pending;
44
+ pending.delete(next);
45
+ // One broken effect must not stop the others from running.
46
+ try { next.run(); } catch (e) { errors.push(e); }
47
+ }
48
+ } finally {
49
+ flushing = false;
50
+ }
51
+ if (errors.length === 1) throw errors[0];
52
+ if (errors.length > 1) throw new AggregateError(errors, `xoong: ${errors.length} effects threw`);
53
+ }
54
+
55
+ /** Remove `observer` from `source`; a computed nobody watches lets go of its sources. */
56
+ function unsubscribe(source, observer) {
57
+ source.subs.delete(observer);
58
+ if (source.subs.size === 0 && source instanceof Computed) source.detach();
59
+ }
60
+
61
+ /**
62
+ * @template T
63
+ */
64
+ export class Signal {
65
+ /** @type {T} */ #value;
66
+ /** @type {Set<any>} */ subs = new Set();
67
+ /** @param {T} value */
68
+ constructor(value) {
69
+ this.#value = value;
70
+ }
71
+ get value() {
72
+ track(this);
73
+ return this.#value;
74
+ }
75
+ set value(next) {
76
+ if (Object.is(next, this.#value)) return;
77
+ this.#value = next;
78
+ for (const sub of [...this.subs]) sub.notify();
79
+ if (batchDepth === 0) flush();
80
+ }
81
+ /** Read without tracking. */
82
+ peek() {
83
+ return this.#value;
84
+ }
85
+ /** Update from the previous value. */
86
+ update(fn) {
87
+ this.value = fn(this.#value);
88
+ }
89
+ }
90
+
91
+ /**
92
+ * @template T
93
+ * @param {T} value
94
+ * @returns {Signal<T>}
95
+ */
96
+ export const signal = (value) => new Signal(value);
97
+
98
+ class Computed {
99
+ /** @type {Set<any>} */ deps = new Set();
100
+ /** @type {Set<any>} */ subs = new Set();
101
+ dirty = true;
102
+ /** @type {any} */ #value;
103
+ /** @type {() => any} */ #fn;
104
+ constructor(fn) {
105
+ this.#fn = fn;
106
+ }
107
+ notify() {
108
+ if (this.dirty) return;
109
+ this.dirty = true;
110
+ for (const sub of [...this.subs]) sub.notify();
111
+ }
112
+ /** Stop listening to sources (no observers left, or the owner is discarding it). */
113
+ detach() {
114
+ for (const dep of this.deps) dep.subs.delete(this);
115
+ this.deps.clear();
116
+ this.dirty = true; // next read recomputes and re-attaches
117
+ }
118
+ run() {
119
+ // Called when re-evaluation is needed.
120
+ for (const dep of this.deps) dep.subs.delete(this);
121
+ this.deps.clear();
122
+ const prev = active;
123
+ active = this;
124
+ try {
125
+ this.#value = this.#fn();
126
+ this.dirty = false;
127
+ } finally {
128
+ active = prev;
129
+ }
130
+ }
131
+ get value() {
132
+ if (this.dirty) this.run();
133
+ track(this);
134
+ return this.#value;
135
+ }
136
+ peek() {
137
+ if (this.dirty) this.run();
138
+ return this.#value;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * @template T
144
+ * @param {() => T} fn
145
+ * @returns {{ readonly value: T, peek(): T }}
146
+ */
147
+ export const computed = (fn) => new Computed(fn);
148
+
149
+ /** Release a computed's subscriptions when its owner is discarded. @param {{detach?: () => void}} c */
150
+ export const disposeComputed = (c) => c.detach?.();
151
+
152
+ class Effect {
153
+ /** @type {Set<any>} */ deps = new Set();
154
+ disposed = false;
155
+ /** @type {void | (() => void)} */ cleanup;
156
+ /** @param {() => void | (() => void)} fn */
157
+ constructor(fn) {
158
+ this.fn = fn;
159
+ }
160
+ notify() {
161
+ if (!this.disposed) pending.add(this);
162
+ }
163
+ run() {
164
+ if (this.disposed) return;
165
+ this.cleanup?.();
166
+ for (const dep of [...this.deps]) unsubscribe(dep, this);
167
+ this.deps.clear();
168
+ const prev = active;
169
+ active = this;
170
+ try {
171
+ const out = this.fn();
172
+ this.cleanup = typeof out === 'function' ? out : undefined;
173
+ } finally {
174
+ active = prev;
175
+ }
176
+ }
177
+ dispose() {
178
+ this.disposed = true;
179
+ pending.delete(this);
180
+ this.cleanup?.();
181
+ for (const dep of [...this.deps]) unsubscribe(dep, this);
182
+ this.deps.clear();
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Run `fn` now and again whenever a signal it read changes.
188
+ * The function may return a cleanup callback.
189
+ * @param {() => void | (() => void)} fn
190
+ * @returns {() => void} dispose
191
+ */
192
+ export function effect(fn) {
193
+ const e = new Effect(fn);
194
+ // Run inside a batch so an effect created within a write sees a stable graph.
195
+ batchDepth++;
196
+ try {
197
+ e.run();
198
+ } finally {
199
+ batchDepth--;
200
+ }
201
+ if (batchDepth === 0) flush();
202
+ return () => e.dispose();
203
+ }
204
+
205
+ /**
206
+ * Group writes; effects flush once after the outermost batch.
207
+ * @template T
208
+ * @param {() => T} fn
209
+ * @returns {T}
210
+ */
211
+ export function batch(fn) {
212
+ batchDepth++;
213
+ try {
214
+ return fn();
215
+ } finally {
216
+ batchDepth--;
217
+ if (batchDepth === 0) flush();
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Read signals without tracking them.
223
+ * @template T
224
+ * @param {() => T} fn
225
+ * @returns {T}
226
+ */
227
+ export function untracked(fn) {
228
+ const prev = active;
229
+ active = null;
230
+ try {
231
+ return fn();
232
+ } finally {
233
+ active = prev;
234
+ }
235
+ }
package/src/syntax.js ADDED
@@ -0,0 +1,19 @@
1
+ // @ts-check
2
+ /**
3
+ * The names of xoong's declarative HTML syntax. `xoong check` validates against these lists and
4
+ * llms.txt documents them; a test keeps the runtime (data/src/view.js) and both in step.
5
+ */
6
+
7
+ /** Attributes understood inside <xo-view>. */
8
+ export const XO_ATTRIBUTES = ['xo-each', 'xo-if', 'xo-click', 'xo-submit', 'xo-body', 'xo-keep', 'xo-param', 'xo-refresh', 'xo-optimistic', 'xo-select', 'xo-selected', 'xo-with', 'xo-confirm', 'xo-notice', 'xo-add', 'xo-remove', 'xo-move', 'xo-drag', 'xo-t'];
9
+
10
+ /** Custom elements shipped by xoong. */
11
+ export const XO_ELEMENTS = ['xo-view'];
12
+
13
+ /** Attributes and elements that come with @xoong/ui (the admin set). */
14
+ export const XO_UI_ATTRIBUTES = ['xo-open', 'xo-close', 'xo-route', 'xo-layout', 'xo-outlet', 'xo-guard'];
15
+ export const XO_UI_ELEMENTS = ['xo-table', 'xo-form', 'xo-dialog', 'xo-toast', 'xo-router'];
16
+
17
+ /** Attributes and elements of @xoong/controls and @xoong/richtext. */
18
+ export const XO_CONTROL_ATTRIBUTES = ['xo-tab', 'xo-menu-trigger', 'xo-tooltip'];
19
+ export const XO_CONTROL_ELEMENTS = ['xo-tabs', 'xo-menu', 'xo-combobox', 'xo-tags', 'xo-richtext', 'xo-carousel', 'xo-switch', 'xo-date', 'xo-time', 'xo-slider', 'xo-stepper', 'xo-password', 'xo-copy'];
package/src/text.js ADDED
@@ -0,0 +1,28 @@
1
+ // @ts-check
2
+ /**
3
+ * The text hook: how xoong's own packages (validation messages, table labels, confirm buttons) ask for words without knowing about
4
+ * translation. `text('xoong.table.next', 'Next')` returns the English default, unless a provider is installed (`@xoong/i18n` installs one) that
5
+ * knows a translation for the key in the active language. Applications never call this directly: they use `t()` from @xoong/i18n.
6
+ */
7
+
8
+ /** @type {((key: string, args?: Record<string, any>) => string | undefined) | null} */
9
+ let provider = null;
10
+
11
+ /** Install (or remove, with null) the provider. It returns undefined for a key it has no message for. */
12
+ export function setTextProvider(fn) { provider = fn; }
13
+
14
+ /**
15
+ * @param {string} key a stable key such as "xoong.table.next"
16
+ * @param {string} fallback the English text; `{name}` placeholders are filled from `args` when no provider answers
17
+ * @param {Record<string, any>} [args]
18
+ */
19
+ export function text(key, fallback, args) {
20
+ const translated = provider?.(key, args);
21
+ if (translated !== undefined) return translated;
22
+ if (!args) return fallback;
23
+ // The English defaults use `{name}` and the simple form `{n, plural, one {# thing} other {# things}}`; @xoong/i18n handles the whole message language.
24
+ return fallback.replace(/\{(\w+), plural, one \{([^{}]*)\} other \{([^{}]*)\}\}|\{(\w+)\}/g, (m, n, one, other, name) => {
25
+ if (n) { const v = Number(args[n]); return (v === 1 ? one : other).replace('#', String(v)); }
26
+ return name in args ? String(args[name]) : m;
27
+ });
28
+ }