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.
- package/LICENSE +21 -0
- package/README.md +332 -0
- package/package.json +48 -0
- package/src/components/FBind.js +76 -0
- package/src/components/FEach.js +194 -0
- package/src/components/FField.js +153 -0
- package/src/components/FStore.js +332 -0
- package/src/components/FText.js +73 -0
- package/src/components/FWhen.js +253 -0
- package/src/components/index.js +10 -0
- package/src/core/Signal.js +220 -0
- package/src/core/State.js +168 -0
- package/src/core/index.js +6 -0
- package/src/dom/find.js +128 -0
- package/src/dom/index.js +5 -0
- package/src/index.js +63 -0
- package/src/sync/Channel.js +113 -0
- package/src/sync/Persist.js +216 -0
- package/src/sync/index.js +6 -0
- package/src/xml/Node.js +396 -0
- package/src/xml/Path.js +176 -0
- package/src/xml/Tree.js +279 -0
- package/src/xml/index.js +7 -0
package/src/xml/Tree.js
ADDED
|
@@ -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
|
+
}
|