flarp 1.0.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.
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Tree - XML tree management
3
+ *
4
+ * Handles parsing, serialization, and tree operations.
5
+ * Built to support per-node reactivity and collaborative editing.
6
+ *
7
+ * @example
8
+ * const tree = new Tree();
9
+ * tree.parse('<User><n>Alice</n></User>');
10
+ *
11
+ * const name = tree.at('User.Name');
12
+ * name.subscribe(v => console.log(v));
13
+ *
14
+ * tree.add('User', '<Email>alice@example.com</Email>');
15
+ */
16
+
17
+ import Signal from '../core/Signal.js';
18
+ import State from '../core/State.js';
19
+ import Node, { AttrNode } from './Node.js';
20
+ import * as Path from './Path.js';
21
+
22
+ export default class Tree {
23
+ #root;
24
+ #parser = new DOMParser();
25
+ #serializer = new XMLSerializer();
26
+ #state = new State();
27
+ #changeSignal = new Signal(0);
28
+
29
+ constructor(container = null) {
30
+ this.#root = container || document.createElement('div');
31
+ }
32
+
33
+ /**
34
+ * Get the state machine
35
+ */
36
+ get state() {
37
+ return this.#state;
38
+ }
39
+
40
+ /**
41
+ * Get the root container element
42
+ */
43
+ get root() {
44
+ return this.#root;
45
+ }
46
+
47
+ /**
48
+ * Get a reactive node at a path
49
+ * @param {string} path
50
+ * @returns {Node|AttrNode|null}
51
+ */
52
+ at(path) {
53
+ const resolved = Path.resolve(this.#root, path);
54
+ if (!resolved) return null;
55
+
56
+ if (resolved.attr) {
57
+ return AttrNode.wrap(resolved.element, resolved.attr);
58
+ }
59
+ return Node.wrap(resolved.element);
60
+ }
61
+
62
+ /**
63
+ * Get all matching nodes at a path
64
+ * @param {string} path
65
+ * @returns {Node[]}
66
+ */
67
+ query(path) {
68
+ return Path.resolveAll(this.#root, path).map(el => Node.wrap(el));
69
+ }
70
+
71
+ /**
72
+ * Set value at a path (creates if needed)
73
+ * @param {string} path
74
+ * @param {string} value
75
+ */
76
+ set(path, value) {
77
+ let node = this.at(path);
78
+
79
+ if (!node) {
80
+ this.#ensurePath(path);
81
+ node = this.at(path);
82
+ }
83
+
84
+ if (node) {
85
+ node.value = value;
86
+ this.#notifyChange();
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Add XML fragment as child
92
+ * @param {string} parentPath
93
+ * @param {string} xml
94
+ * @returns {Node|Node[]} Added node(s)
95
+ */
96
+ add(parentPath, xml) {
97
+ const resolved = Path.resolve(this.#root, parentPath);
98
+ if (!resolved) {
99
+ throw new Error(`Parent path not found: ${parentPath}`);
100
+ }
101
+
102
+ const doc = this.#parser.parseFromString(
103
+ `<root>${xml}</root>`,
104
+ 'application/xml'
105
+ );
106
+
107
+ const error = doc.querySelector('parsererror');
108
+ if (error) {
109
+ throw new Error(`Invalid XML: ${error.textContent}`);
110
+ }
111
+
112
+ const added = [];
113
+ for (const child of Array.from(doc.documentElement.children)) {
114
+ const imported = document.importNode(child, true);
115
+
116
+ // Ensure UUID/rev
117
+ if (!imported.hasAttribute('uuid')) {
118
+ imported.setAttribute('uuid', crypto.randomUUID?.() || Math.random().toString(36).slice(2));
119
+ }
120
+ if (!imported.hasAttribute('rev')) {
121
+ imported.setAttribute('rev', '1');
122
+ }
123
+
124
+ resolved.element.appendChild(imported);
125
+ added.push(Node.wrap(imported));
126
+ }
127
+
128
+ this.#notifyChange();
129
+ return added.length === 1 ? added[0] : added;
130
+ }
131
+
132
+ /**
133
+ * Remove node at path
134
+ * @param {string} path
135
+ * @returns {boolean}
136
+ */
137
+ remove(path) {
138
+ const resolved = Path.resolve(this.#root, path);
139
+ if (!resolved) return false;
140
+
141
+ Node.unwrap(resolved.element);
142
+ resolved.element.remove();
143
+ this.#notifyChange();
144
+ return true;
145
+ }
146
+
147
+ /**
148
+ * Parse and set XML content
149
+ * @param {string} xml
150
+ */
151
+ parse(xml) {
152
+ const doc = this.#parser.parseFromString(
153
+ `<root>${xml}</root>`,
154
+ 'application/xml'
155
+ );
156
+
157
+ const error = doc.querySelector('parsererror');
158
+ if (error) {
159
+ throw new Error(`Parse error: ${error.textContent}`);
160
+ }
161
+
162
+ this.#root.innerHTML = '';
163
+ for (const child of Array.from(doc.documentElement.children)) {
164
+ this.#root.appendChild(document.importNode(child, true));
165
+ }
166
+
167
+ this.#notifyChange();
168
+ }
169
+
170
+ /**
171
+ * Serialize entire tree to XML string
172
+ * @returns {string}
173
+ */
174
+ serialize() {
175
+ return this.#root.innerHTML;
176
+ }
177
+
178
+ /**
179
+ * Serialize single node at path
180
+ * @param {string} path
181
+ * @returns {string|null}
182
+ */
183
+ serializeAt(path) {
184
+ const node = this.at(path);
185
+ return node?.serialize?.() || null;
186
+ }
187
+
188
+ /**
189
+ * Get raw XML (alias for serialize)
190
+ */
191
+ get xml() {
192
+ return this.serialize();
193
+ }
194
+
195
+ /**
196
+ * Set raw XML (alias for parse)
197
+ */
198
+ set xml(value) {
199
+ this.parse(value);
200
+ }
201
+
202
+ /**
203
+ * Subscribe to any change in the tree
204
+ * @param {Function} fn - Callback receiving XML string
205
+ * @returns {Function} Unsubscribe
206
+ */
207
+ onChange(fn) {
208
+ // Subscribe without autorun, only fire on actual changes
209
+ return this.#changeSignal.subscribe(() => fn(this.serialize()), false);
210
+ }
211
+
212
+ /**
213
+ * Subscribe with immediate callback
214
+ * @param {Function} fn - Callback receiving XML string
215
+ * @returns {Function} Unsubscribe
216
+ */
217
+ watch(fn) {
218
+ // Subscribe with autorun for immediate + changes
219
+ return this.#changeSignal.subscribe(() => fn(this.serialize()), true);
220
+ }
221
+
222
+ /**
223
+ * Check if tree has content
224
+ * @returns {boolean}
225
+ */
226
+ get empty() {
227
+ return !this.#root.firstElementChild;
228
+ }
229
+
230
+ #ensurePath(path) {
231
+ const { segments, attr } = Path.parse(path);
232
+ let current = this.#root;
233
+
234
+ for (const { tag, index } of segments) {
235
+ const tagLower = tag.toLowerCase();
236
+ const matches = Array.from(current.children).filter(
237
+ el => el.tagName.toLowerCase() === tagLower
238
+ );
239
+
240
+ if (matches.length === 0) {
241
+ const newEl = document.createElement(tag);
242
+ newEl.setAttribute('uuid', crypto.randomUUID?.() || Math.random().toString(36).slice(2));
243
+ newEl.setAttribute('rev', '1');
244
+ current.appendChild(newEl);
245
+ current = newEl;
246
+ } else if (index !== null && !matches[index]) {
247
+ throw new Error(`Index out of bounds: ${tag}[${index}]`);
248
+ } else {
249
+ current = index !== null ? matches[index] : matches[0];
250
+ }
251
+ }
252
+
253
+ return current;
254
+ }
255
+
256
+ #notifyChange() {
257
+ this.#changeSignal.value++;
258
+ }
259
+
260
+ /**
261
+ * Dispose tree and all node wrappers
262
+ */
263
+ dispose() {
264
+ // Dispose all wrapped nodes
265
+ const walk = (el) => {
266
+ Node.unwrap(el);
267
+ for (const child of el.children) {
268
+ walk(child);
269
+ }
270
+ };
271
+
272
+ for (const child of this.#root.children) {
273
+ walk(child);
274
+ }
275
+
276
+ this.#state.dispose();
277
+ this.#changeSignal.dispose();
278
+ }
279
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * XML - XML tree management utilities
3
+ */
4
+
5
+ export { default as Node, AttrNode } from './Node.js';
6
+ export { default as Tree } from './Tree.js';
7
+ export * as Path from './Path.js';