talkdom 0.1.0

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
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Eringen
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 ADDED
@@ -0,0 +1,221 @@
1
+ # talkDOM
2
+
3
+ Smalltalk _inspired_ message passing for the DOM. Declarative HTTP interactions via HTML attributes. No build step, no dependencies. As a big admirer of [htmx](https://htmx.org), it was a major muse when starting this project. ALL HAIL THE HORSEY!
4
+
5
+ ## How it works
6
+
7
+ Receivers are named DOM elements. Senders dispatch keyword messages to receivers.
8
+ which is currently in use @ [eringen.com](https://eringen.com)
9
+
10
+ ```html
11
+ <div receiver="content"></div>
12
+ <button sender="content get: /partial apply: inner">Load</button>
13
+ ```
14
+
15
+ The sender attribute is parsed as a Smalltalk keyword message:
16
+
17
+ ```
18
+ content get: /partial apply: inner
19
+ ^^^^^^^ receiver name
20
+ ^^^^ keyword 1
21
+ ^^^^^^^^ arg 1
22
+ ^^^^^^ keyword 2
23
+ ^^^^^ arg 2
24
+
25
+ selector: "get:apply:"
26
+ args: ["/partial", "inner"]
27
+ ```
28
+
29
+ ## Features
30
+
31
+ - `get:`, `post:`, `put:`, `delete:` selectors (return response for piping)
32
+ - `get:apply:`, `post:apply:`, `put:apply:`, `delete:apply:` shorthand selectors
33
+ - `apply:` consumes piped content
34
+ - Apply operations: `inner`, `text`, `append`, `outer`
35
+ - Pipes (`|`) chain return values between messages
36
+ - Independent messages (`;`) fire separately
37
+ - An element can be both sender and receiver
38
+ - Receivers declare allowed operations via `accepts`
39
+ - Polling with `poll:` keyword
40
+ - Persistent state via `persist` attribute
41
+ - URL persistence via `push-url` attribute
42
+ - Server-triggered messages via `X-TalkDOM-Trigger` response header
43
+ - Lifecycle events (`talkdom:done`, `talkdom:error`) on receiver elements
44
+ - Programmatic API via `talkDOM.send` (returns a promise)
45
+ - Extensible methods via `talkDOM.methods`
46
+
47
+ ## Usage
48
+
49
+ ```html
50
+ <!-- jsDelivr -->
51
+ <script src="https://cdn.jsdelivr.net/npm/talkdom/dist/talkdom.min.js"></script>
52
+
53
+ <!-- unpkg -->
54
+ <script src="https://unpkg.com/talkdom/dist/talkdom.min.js"></script>
55
+
56
+ <!-- local -->
57
+ <script src="index.js"></script>
58
+ ```
59
+
60
+ ## Multiple targets
61
+
62
+ A sender can address multiple receivers with `;`:
63
+
64
+ ```html
65
+ <button sender="content get: /page apply: inner; log get: /page apply: text">Load</button>
66
+ ```
67
+
68
+ Multiple elements can share the same receiver name. All matching elements receive the message:
69
+
70
+ ```html
71
+ <div receiver="alert" class="top-banner"></div>
72
+ <div receiver="alert" class="bottom-banner"></div>
73
+ <button sender="alert get: /notice apply: inner">Notify both</button>
74
+ ```
75
+
76
+ ## Pipes
77
+
78
+ `|` chains the return value of one message into the next as the first argument.
79
+
80
+ ```html
81
+ <!-- fetch then apply -->
82
+ <button sender="content get: /partial | content apply: inner">Load</button>
83
+
84
+ <!-- pipe to a different receiver -->
85
+ <button sender="content get: /partial | sidebar apply: append">Load to sidebar</button>
86
+ ```
87
+
88
+ ## Accepts
89
+
90
+ Receivers declare what operations they allow.
91
+
92
+ ```html
93
+ <div receiver="content" accepts="inner text"></div>
94
+ ```
95
+
96
+ ## Polling
97
+
98
+ Receivers poll by adding `poll:` as the last keyword with an interval (`s` or `ms`) as its argument. The method keywords before `poll:` run on each tick.
99
+
100
+ ```html
101
+ <div receiver="feed get:apply: /updates inner poll: 10s"></div>
102
+ ```
103
+
104
+ Polling stops automatically when the element is removed from the DOM.
105
+
106
+ ## Persist
107
+
108
+ Receivers with `persist` save their content to `localStorage` after each apply and restore it on page load.
109
+
110
+ ```html
111
+ <div receiver="sidebar" persist></div>
112
+ ```
113
+
114
+ ## Push URL
115
+
116
+ Senders with `push-url` update the browser URL via `history.pushState`. The message replays on back/forward navigation.
117
+
118
+ ```html
119
+ <button sender="content get: /about apply: inner" push-url="/about">About</button>
120
+ ```
121
+
122
+ If `push-url` has no value, the first message's first arg is used as the URL.
123
+
124
+ ## Server trigger
125
+
126
+ The server can trigger client-side messages by setting the `X-TalkDOM-Trigger` response header. The value uses the same message syntax.
127
+
128
+ ```
129
+ X-TalkDOM-Trigger: toast apply: Saved inner
130
+ ```
131
+
132
+ Multiple triggers separated by `;`:
133
+
134
+ ```
135
+ X-TalkDOM-Trigger: toast apply: Saved inner; counter get: /count apply: text
136
+ ```
137
+
138
+ Works with pipes, extended methods, and everything else — it dispatches through the same path as sender clicks.
139
+
140
+ For CORS, expose the header: `Access-Control-Expose-Headers: X-TalkDOM-Trigger`.
141
+
142
+ ## Request headers
143
+
144
+ Every fetch sends:
145
+
146
+ | Header | Value |
147
+ |---|---|
148
+ | `X-TalkDOM-Request` | `"true"` |
149
+ | `X-TalkDOM-Current-URL` | `location.href` |
150
+ | `X-TalkDOM-Receiver` | receiver name (if element has one) |
151
+ | `X-CSRF-Token` | from `<meta name="csrf-token">` (non-GET only) |
152
+
153
+ ## Self-replacing elements
154
+
155
+ ```html
156
+ <button receiver="btn" sender="btn get: /next-step.html apply: outer">Click me</button>
157
+ ```
158
+
159
+ ## Lifecycle events
160
+
161
+ Every operation dispatches a `CustomEvent` on the receiver element after completion. Events bubble, so you can listen at any ancestor or `document`.
162
+
163
+ | Event | When | Detail |
164
+ |---|---|---|
165
+ | `talkdom:done` | Method completed successfully | `{ receiver, selector, args }` |
166
+ | `talkdom:error` | Method rejected (HTTP error, network failure, confirm cancel) | `{ receiver, selector, args, error }` |
167
+
168
+ ```js
169
+ // per-element
170
+ document.getElementById("content").addEventListener("talkdom:done", function (e) {
171
+ console.log(e.detail.selector, "finished");
172
+ });
173
+
174
+ // global
175
+ document.addEventListener("talkdom:error", function (e) {
176
+ alert("Failed: " + e.detail.error);
177
+ });
178
+ ```
179
+
180
+ For `apply: outer`, the event fires on the replacement element (looked up by receiver name) so it still bubbles.
181
+
182
+ ## Programmatic API
183
+
184
+ `talkDOM.send` accepts the same message syntax as the `sender` attribute and returns a promise.
185
+
186
+ ```js
187
+ // single operation
188
+ talkDOM.send("#content get:apply: /api/data inner").then(function () {
189
+ console.log("done");
190
+ });
191
+
192
+ // pipes
193
+ await talkDOM.send("#content get: /api/data | #output apply: inner");
194
+
195
+ // parallel chains
196
+ await talkDOM.send("#a get:apply: /x inner ; #b get:apply: /y inner");
197
+
198
+ // errors propagate
199
+ talkDOM.send("#content get:apply: /bad-url inner").catch(function (err) {
200
+ console.error("failed", err);
201
+ });
202
+ ```
203
+
204
+ ## Extending
205
+
206
+ ```js
207
+ talkDOM.methods["toggle:"] = function (el, cls) {
208
+ el.classList.toggle(cls);
209
+ };
210
+ ```
211
+
212
+ ```js
213
+ talkDOM.methods["show:"] = function (el, message) {
214
+ el.textContent = message;
215
+ el.style.display = "block";
216
+ };
217
+ ```
218
+
219
+ ## License
220
+
221
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1 @@
1
+ !function(){function e(e){for(var t=e.trim(),r=t.split(/\s+/),n=r[0],o=t.substring(n.length).trim(),i=r.slice(1),c=[],s=[],u=[],a=0;a<i.length;a++){var l=i[a];l.endsWith(":")?(c.length>0&&u.length>0?(s.push(u.join(" ")),u=[]):c.length>0&&s.push(""),c.push(l)):u.push(l)}return c.length>0&&s.push(u.join(" ")),{receiver:n,selector:c.join(""),keywords:c,args:s,body:o}}function t(e){return e.getAttribute("receiver").trim().split(/\s+/)[0]}function r(e){return document.querySelectorAll('[receiver~="'+e+'"]')}function n(e,r,n){if(function(e,t){var r=e.getAttribute("accepts");return!r||-1!==r.split(/\s+/).indexOf(t)}(e,r)){switch(r){case"inner":e.innerHTML=n;break;case"text":e.textContent=n;break;case"append":e.insertAdjacentHTML("beforeend",n);break;case"outer":e.outerHTML=n}return function(e,r){if(e.hasAttribute("receiver")&&e.hasAttribute("persist")){var n="talkDOM:"+t(e);"outer"===r?localStorage.setItem(n,JSON.stringify({op:r,content:e.outerHTML})):localStorage.setItem(n,JSON.stringify({op:r,content:e.innerHTML}))}}(e,r),n}console.error(t(e)+" does not accept "+r)}function o(e,t,r){var n,o={"X-TalkDOM-Request":"true","X-TalkDOM-Current-URL":location.href};if(r&&(o["X-TalkDOM-Receiver"]=r),"GET"!==e){var i=(n=document.querySelector('meta[name="csrf-token"]'))?n.getAttribute("content"):"";i&&(o["X-CSRF-Token"]=i)}return fetch(t,{method:e,headers:o}).then(function(r){if(!r.ok)return console.error("talkDOM: "+e+" "+t+" "+r.status),Promise.reject(r.status);var n=r.headers.get("X-TalkDOM-Trigger");return r.text().then(function(e){return n&&f(n),e})},function(r){return console.error("talkDOM: "+e+" "+t+" failed",r),Promise.reject(r)})}function i(e){return e.hasAttribute("receiver")?t(e):""}const c={"get:":function(e,t){return o("GET",t,i(e))},"post:":function(e,t){return o("POST",t,i(e))},"put:":function(e,t){return o("PUT",t,i(e))},"delete:":function(e,t){return o("DELETE",t,i(e))},"confirm:":function(e,t){if(!confirm(t))return Promise.reject("cancelled")},"apply:":function(e,t,r){n(e,r,t)},"get:apply:":function(e,t,r){return o("GET",t,i(e)).then(function(t){n(e,r,t)})},"post:apply:":function(e,t,r){return o("POST",t,i(e)).then(function(t){n(e,r,t)})},"put:apply:":function(e,t,r){return o("PUT",t,i(e)).then(function(t){n(e,r,t)})},"delete:apply:":function(e,t,r){return o("DELETE",t,i(e)).then(function(t){n(e,r,t)})}};var s=!1;function u(e){e&&e.sender&&(s=!0,f(e.sender),s=!1)}function a(e,t){var n=r(e.receiver);if(0!==n.length){var o=c[e.selector];if(o){var i,s=void 0!==t?[t].concat(e.args):e.args;return n.forEach(function(t){var n={receiver:e.receiver,selector:e.selector,args:e.args},c=t.parentNode,u=t.nextElementSibling;function a(){return t.isConnected?t:(u&&u.isConnected?u.previousElementSibling:c&&c.isConnected?c.lastElementChild:null)||r(e.receiver)[0]}if((i=o(t,...s))&&"function"==typeof i.then)i.then(function(){var e=a();e&&e.dispatchEvent(new CustomEvent("talkdom:done",{bubbles:!0,detail:n}))},function(e){n.error=e;var t=a();t&&t.dispatchEvent(new CustomEvent("talkdom:error",{bubbles:!0,detail:n}))});else{var l=a();l&&l.dispatchEvent(new CustomEvent("talkdom:done",{bubbles:!0,detail:n}))}}),i}console.error(e.receiver+" does not understand "+e.selector)}else console.error(e.receiver+" not found")}function l(t){var r=t.split(";").map(function(t){var r=t.trim();if(!r)return Promise.resolve();var n=r.split("|").map(function(e){return e.trim()}).filter(Boolean);return 1===n.length?Promise.resolve(a(e(n[0]))):n.reduce(function(t,r){var n=e(r);return Promise.resolve(t).then(function(e){return a(n,e)})},void 0)});return Promise.all(r)}function f(e){l(e).catch(function(){})}function d(t){var r=t.getAttribute("sender");f(r),s||function(t,r){if(t.hasAttribute("push-url")){var n=t.getAttribute("push-url");n||(n=e(r.split(";")[0].split("|")[0].trim()).args[0]||""),n&&location.pathname+location.search!==n&&history.pushState({sender:r},"",n)}}(t,r)}window.addEventListener("popstate",function(e){u(e.state)}),document.addEventListener("click",function(e){const t=e.target.closest("[sender]");t&&(e.preventDefault(),d(t))}),document.querySelectorAll("[persist]").forEach(function(e){if(e.hasAttribute("receiver")){var r=t(e),n=localStorage.getItem("talkDOM:"+r);if(n){var o;try{o=JSON.parse(n)}catch(e){return void localStorage.removeItem("talkDOM:"+r)}"outer"===o.op?e.outerHTML=o.content:e.innerHTML=o.content}}}),u(history.state),document.querySelectorAll("[receiver]").forEach(function(t){var n=e(t.getAttribute("receiver"));if("poll:"===n.keywords[n.keywords.length-1]){var o=function(e){var t=e.match(/^(\d+)(s|ms)$/);if(!t)return null;var r=parseInt(t[1],10);return"s"===t[2]?1e3*r:r}(n.args[n.args.length-1]);if(o)var i=n.keywords.slice(0,-1).join(""),s=n.args.slice(0,-1),u=n.receiver,a=r(u),l=setInterval(function(){if(t.isConnected){if(0!==a.length&&a[0].isConnected||(a=r(u)),0!==a.length){var e=c[i];e?a.forEach(function(t){e(t,...s)}):console.error(u+" does not understand "+i)}}else clearInterval(l)},o);else console.error("poll: invalid interval for "+n.receiver)}}),window.talkDOM={methods:c,send:l}}();
package/index.js ADDED
@@ -0,0 +1,307 @@
1
+ (function () {
2
+
3
+ // Parse "receiver keyword: arg keyword: arg" into structured message object.
4
+ // Tokens ending with ":" are keywords, everything else fills args.
5
+ function parseMessage(str) {
6
+ var trimmed = str.trim();
7
+ var tokens = trimmed.split(/\s+/);
8
+ var receiver = tokens[0];
9
+ var body = trimmed.substring(receiver.length).trim();
10
+ var rest = tokens.slice(1);
11
+ var keywords = [];
12
+ var args = [];
13
+ var currentArg = [];
14
+
15
+ for (var i = 0; i < rest.length; i++) {
16
+ var token = rest[i];
17
+ if (token.endsWith(":")) {
18
+ if (keywords.length > 0 && currentArg.length > 0) {
19
+ args.push(currentArg.join(" "));
20
+ currentArg = [];
21
+ } else if (keywords.length > 0) {
22
+ args.push("");
23
+ }
24
+ keywords.push(token);
25
+ } else {
26
+ currentArg.push(token);
27
+ }
28
+ }
29
+ if (keywords.length > 0) {
30
+ args.push(currentArg.join(" "));
31
+ }
32
+
33
+ return { receiver: receiver, selector: keywords.join(""), keywords: keywords, args: args, body: body };
34
+ }
35
+
36
+ // Extract the first word from the receiver attribute (the name).
37
+ function receiverName(el) {
38
+ return el.getAttribute("receiver").trim().split(/\s+/)[0];
39
+ }
40
+
41
+ // Find all elements whose receiver attribute contains the given name.
42
+ function findReceivers(name) {
43
+ return document.querySelectorAll('[receiver~="' + name + '"]');
44
+ }
45
+
46
+ // Check if a receiver allows a given apply operation (inner, text, append, outer).
47
+ // No "accepts" attribute means everything is allowed.
48
+ function accepts(el, op) {
49
+ var attr = el.getAttribute("accepts");
50
+ if (!attr) return true;
51
+ return attr.split(/\s+/).indexOf(op) !== -1;
52
+ }
53
+
54
+ // Save receiver content to localStorage after apply, keyed by receiver name.
55
+ function persist(el, op) {
56
+ if (!el.hasAttribute("receiver") || !el.hasAttribute("persist")) return;
57
+ var name = receiverName(el);
58
+ var key = "talkDOM:" + name;
59
+ if (op === "outer") {
60
+ localStorage.setItem(key, JSON.stringify({ op: op, content: el.outerHTML }));
61
+ } else {
62
+ localStorage.setItem(key, JSON.stringify({ op: op, content: el.innerHTML }));
63
+ }
64
+ }
65
+
66
+ // On page load, restore persisted receiver content from localStorage.
67
+ function restore() {
68
+ document.querySelectorAll("[persist]").forEach(function (el) {
69
+ if (!el.hasAttribute("receiver")) return;
70
+ var name = receiverName(el);
71
+ var raw = localStorage.getItem("talkDOM:" + name);
72
+ if (!raw) return;
73
+ var state;
74
+ try { state = JSON.parse(raw); } catch (e) { localStorage.removeItem("talkDOM:" + name); return; }
75
+ if (state.op === "outer") {
76
+ el.outerHTML = state.content;
77
+ } else {
78
+ el.innerHTML = state.content;
79
+ }
80
+ });
81
+ }
82
+
83
+ // Apply content to an element using the specified operation (inner, text, append, outer).
84
+ function apply(el, op, content) {
85
+ if (!accepts(el, op)) {
86
+ console.error(receiverName(el) + " does not accept " + op);
87
+ return;
88
+ }
89
+ switch (op) {
90
+ case "inner": el.innerHTML = content; break;
91
+ case "text": el.textContent = content; break;
92
+ case "append": el.insertAdjacentHTML("beforeend", content); break;
93
+ case "outer": el.outerHTML = content; break;
94
+ }
95
+ persist(el, op);
96
+ return content;
97
+ }
98
+
99
+ function csrfToken() {
100
+ var meta = document.querySelector('meta[name="csrf-token"]');
101
+ return meta ? meta.getAttribute("content") : "";
102
+ }
103
+
104
+ // Perform a fetch with talkDOM headers. Returns a promise resolving to response text.
105
+ // Fires server-triggered messages from X-TalkDOM-Trigger header if present.
106
+ function request(method, url, receiver) {
107
+ var headers = {
108
+ "X-TalkDOM-Request": "true",
109
+ "X-TalkDOM-Current-URL": location.href,
110
+ };
111
+ if (receiver) {
112
+ headers["X-TalkDOM-Receiver"] = receiver;
113
+ }
114
+ if (method !== "GET") {
115
+ var token = csrfToken();
116
+ if (token) headers["X-CSRF-Token"] = token;
117
+ }
118
+ return fetch(url, { method: method, headers: headers }).then(function (r) {
119
+ if (!r.ok) {
120
+ console.error("talkDOM: " + method + " " + url + " " + r.status);
121
+ return Promise.reject(r.status);
122
+ }
123
+ var trigger = r.headers.get("X-TalkDOM-Trigger");
124
+ return r.text().then(function (text) {
125
+ if (trigger) dispatchRaw(trigger);
126
+ return text;
127
+ });
128
+ }, function (err) {
129
+ console.error("talkDOM: " + method + " " + url + " failed", err);
130
+ return Promise.reject(err);
131
+ });
132
+ }
133
+
134
+ function recName(el) {
135
+ return el.hasAttribute("receiver") ? receiverName(el) : "";
136
+ }
137
+
138
+ // Built-in method table. Each method receives (el, ...args) from the parsed message.
139
+ // Extensible via talkDOM.methods at runtime.
140
+ const methods = {
141
+ "get:": function (el, url) { return request("GET", url, recName(el)); },
142
+ "post:": function (el, url) { return request("POST", url, recName(el)); },
143
+ "put:": function (el, url) { return request("PUT", url, recName(el)); },
144
+ "delete:": function (el, url) { return request("DELETE", url, recName(el)); },
145
+ "confirm:": function (el, message) { if (!confirm(message)) return Promise.reject("cancelled"); },
146
+ "apply:": function (el, content, op) { apply(el, op, content); },
147
+ "get:apply:": function (el, url, op) { return request("GET", url, recName(el)).then(function (t) { apply(el, op, t); }); },
148
+ "post:apply:": function (el, url, op) { return request("POST", url, recName(el)).then(function (t) { apply(el, op, t); }); },
149
+ "put:apply:": function (el, url, op) { return request("PUT", url, recName(el)).then(function (t) { apply(el, op, t); }); },
150
+ "delete:apply:": function (el, url, op) { return request("DELETE", url, recName(el)).then(function (t) { apply(el, op, t); }); },
151
+ };
152
+
153
+ var pushing = false;
154
+
155
+ // Push URL to browser history. Uses push-url attr value, or falls back to first message arg.
156
+ function pushUrl(senderEl, raw) {
157
+ if (!senderEl.hasAttribute("push-url")) return;
158
+ var url = senderEl.getAttribute("push-url");
159
+ if (!url) {
160
+ var firstMsg = parseMessage(raw.split(";")[0].split("|")[0].trim());
161
+ url = firstMsg.args[0] || "";
162
+ }
163
+ if (url && (location.pathname + location.search) !== url) {
164
+ history.pushState({ sender: raw }, "", url);
165
+ }
166
+ }
167
+
168
+ // Re-dispatch a sender message from history state (back/forward navigation).
169
+ function replayState(state) {
170
+ if (!state || !state.sender) return;
171
+ pushing = true;
172
+ dispatchRaw(state.sender);
173
+ pushing = false;
174
+ }
175
+
176
+ window.addEventListener("popstate", function (e) {
177
+ replayState(e.state);
178
+ });
179
+
180
+ // Deliver a parsed message to all matching receivers. Fires talkdom:done or talkdom:error
181
+ // lifecycle events on the receiver element (or its replacement if outer-swapped).
182
+ function send(msg, piped) {
183
+ var els = findReceivers(msg.receiver);
184
+ if (els.length === 0) {
185
+ console.error(msg.receiver + " not found");
186
+ return;
187
+ }
188
+ var method = methods[msg.selector];
189
+ if (!method) {
190
+ console.error(msg.receiver + " does not understand " + msg.selector);
191
+ return;
192
+ }
193
+ var args = piped !== undefined ? [piped].concat(msg.args) : msg.args;
194
+ var result;
195
+ els.forEach(function (el) {
196
+ var detail = { receiver: msg.receiver, selector: msg.selector, args: msg.args };
197
+ var parent = el.parentNode;
198
+ var next = el.nextElementSibling;
199
+ result = method(el, ...args);
200
+ function resolveTarget() {
201
+ if (el.isConnected) return el;
202
+ var candidate = next && next.isConnected ? next.previousElementSibling
203
+ : parent && parent.isConnected ? parent.lastElementChild : null;
204
+ return candidate || findReceivers(msg.receiver)[0];
205
+ }
206
+ if (result && typeof result.then === "function") {
207
+ result.then(function () {
208
+ var target = resolveTarget();
209
+ if (target) target.dispatchEvent(new CustomEvent("talkdom:done", { bubbles: true, detail: detail }));
210
+ }, function (err) {
211
+ detail.error = err;
212
+ var target = resolveTarget();
213
+ if (target) target.dispatchEvent(new CustomEvent("talkdom:error", { bubbles: true, detail: detail }));
214
+ });
215
+ } else {
216
+ var target = resolveTarget();
217
+ if (target) target.dispatchEvent(new CustomEvent("talkdom:done", { bubbles: true, detail: detail }));
218
+ }
219
+ });
220
+ return result;
221
+ }
222
+
223
+ // Programmatic API: parse and execute a raw message string (supports pipes and semicolons).
224
+ // Returns a promise that resolves when all chains complete.
225
+ function run(raw) {
226
+ var chains = raw.split(";").map(function (chain) {
227
+ var trimmed = chain.trim();
228
+ if (!trimmed) return Promise.resolve();
229
+ var steps = trimmed.split("|").map(function (s) { return s.trim(); }).filter(Boolean);
230
+ if (steps.length === 1) {
231
+ return Promise.resolve(send(parseMessage(steps[0])));
232
+ }
233
+ return steps.reduce(function (prev, step) {
234
+ var msg = parseMessage(step);
235
+ return Promise.resolve(prev).then(function (piped) {
236
+ return send(msg, piped);
237
+ });
238
+ }, undefined);
239
+ });
240
+ return Promise.all(chains);
241
+ }
242
+
243
+ // Fire-and-forget dispatch used by declarative senders and server triggers.
244
+ function dispatchRaw(raw) {
245
+ run(raw).catch(function () {});
246
+ }
247
+
248
+ // Entry point for a sender click: dispatch its message and optionally push URL.
249
+ function dispatch(senderEl) {
250
+ var raw = senderEl.getAttribute("sender");
251
+ dispatchRaw(raw);
252
+ if (!pushing) pushUrl(senderEl, raw);
253
+ }
254
+
255
+ function parseInterval(str) {
256
+ var match = str.match(/^(\d+)(s|ms)$/);
257
+ if (!match) return null;
258
+ var n = parseInt(match[1], 10);
259
+ return match[2] === "s" ? n * 1000 : n;
260
+ }
261
+
262
+ // Set up a repeating interval for receivers with a poll: keyword.
263
+ // Stops automatically when the element is removed from the DOM.
264
+ function startPolling(el) {
265
+ var attr = el.getAttribute("receiver");
266
+ var msg = parseMessage(attr);
267
+ if (msg.keywords[msg.keywords.length - 1] !== "poll:") return;
268
+ var interval = parseInterval(msg.args[msg.args.length - 1]);
269
+ if (!interval) {
270
+ console.error("poll: invalid interval for " + msg.receiver);
271
+ return;
272
+ }
273
+ var selector = msg.keywords.slice(0, -1).join("");
274
+ var args = msg.args.slice(0, -1);
275
+ var name = msg.receiver;
276
+ var cachedTargets = findReceivers(name);
277
+ var id = setInterval(function () {
278
+ if (!el.isConnected) { clearInterval(id); return; }
279
+ if (cachedTargets.length === 0 || !cachedTargets[0].isConnected) {
280
+ cachedTargets = findReceivers(name);
281
+ }
282
+ if (cachedTargets.length === 0) return;
283
+ var method = methods[selector];
284
+ if (!method) {
285
+ console.error(name + " does not understand " + selector);
286
+ return;
287
+ }
288
+ cachedTargets.forEach(function (target) { method(target, ...args); });
289
+ }, interval);
290
+ }
291
+
292
+ // Global click handler: delegate to any element with a sender attribute.
293
+ document.addEventListener("click", function (e) {
294
+ const sender = e.target.closest("[sender]");
295
+ if (sender) {
296
+ e.preventDefault();
297
+ dispatch(sender);
298
+ }
299
+ });
300
+
301
+ restore();
302
+ replayState(history.state);
303
+ document.querySelectorAll("[receiver]").forEach(startPolling);
304
+
305
+ window.talkDOM = { methods: methods, send: run };
306
+
307
+ }());
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "talkdom",
3
+ "version": "0.1.0",
4
+ "description": "Smalltalk-inspired message passing for the DOM",
5
+ "main": "index.js",
6
+ "jsdelivr": "dist/talkdom.min.js",
7
+ "unpkg": "dist/talkdom.min.js",
8
+ "files": [
9
+ "index.js",
10
+ "dist/"
11
+ ],
12
+ "scripts": {
13
+ "build": "mkdir -p dist && terser index.js -o dist/talkdom.min.js -c -m",
14
+ "prepublishOnly": "npm run build"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/eringen/talkdom.git"
19
+ },
20
+ "keywords": [
21
+ "dom",
22
+ "html",
23
+ "declarative",
24
+ "htmx",
25
+ "smalltalk"
26
+ ],
27
+ "license": "MIT",
28
+ "devDependencies": {
29
+ "terser": "^5.46.1"
30
+ }
31
+ }