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,396 @@
1
+ /**
2
+ * Node - Reactive wrapper for XML elements
3
+ *
4
+ * Each Node is:
5
+ * - Independently reactive (has its own Signal)
6
+ * - Uniquely identifiable (UUID)
7
+ * - Version-tracked (rev for conflict resolution)
8
+ * - Serializable on its own
9
+ *
10
+ * @example
11
+ * const node = Node.wrap(element);
12
+ *
13
+ * node.text; // read text content
14
+ * node.text = 'new value'; // write (increments rev)
15
+ *
16
+ * node.subscribe(text => console.log(text));
17
+ *
18
+ * node.uuid; // unique identifier
19
+ * node.rev; // revision number
20
+ *
21
+ * node.serialize(); // just this node as XML string
22
+ */
23
+
24
+ import Signal from '../core/Signal.js';
25
+
26
+ // WeakMap cache: same element → same Node instance
27
+ const cache = new WeakMap();
28
+
29
+ /**
30
+ * Generate UUID with fallback for non-HTTPS
31
+ */
32
+ function uuid() {
33
+ if (crypto.randomUUID) {
34
+ return crypto.randomUUID();
35
+ }
36
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
37
+ const r = Math.random() * 16 | 0;
38
+ return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
39
+ });
40
+ }
41
+
42
+ export default class Node {
43
+ #element;
44
+ #signal;
45
+ #observer;
46
+ #disposed = false;
47
+
48
+ /**
49
+ * Get or create Node wrapper for an element
50
+ * @param {Element} element
51
+ * @returns {Node}
52
+ */
53
+ static wrap(element) {
54
+ if (cache.has(element)) {
55
+ return cache.get(element);
56
+ }
57
+ const node = new Node(element);
58
+ cache.set(element, node);
59
+ return node;
60
+ }
61
+
62
+ /**
63
+ * Check if element has a Node wrapper
64
+ * @param {Element} element
65
+ * @returns {boolean}
66
+ */
67
+ static has(element) {
68
+ return cache.has(element);
69
+ }
70
+
71
+ /**
72
+ * Remove wrapper from cache (for cleanup)
73
+ * @param {Element} element
74
+ */
75
+ static unwrap(element) {
76
+ const node = cache.get(element);
77
+ if (node) {
78
+ node.dispose();
79
+ cache.delete(element);
80
+ }
81
+ }
82
+
83
+ constructor(element) {
84
+ this.#element = element;
85
+ this.#signal = new Signal(element.textContent);
86
+
87
+ // Ensure UUID
88
+ if (!element.hasAttribute('uuid')) {
89
+ element.setAttribute('uuid', uuid());
90
+ }
91
+
92
+ // Ensure rev
93
+ if (!element.hasAttribute('rev')) {
94
+ element.setAttribute('rev', '0');
95
+ }
96
+
97
+ // Observe mutations to sync signal
98
+ this.#observer = new MutationObserver(() => {
99
+ if (!this.#disposed) {
100
+ this.#signal.value = element.textContent;
101
+ }
102
+ });
103
+
104
+ this.#observer.observe(element, {
105
+ childList: true,
106
+ characterData: true,
107
+ subtree: true
108
+ });
109
+ }
110
+
111
+ /**
112
+ * Get text content
113
+ */
114
+ get text() {
115
+ return this.#signal.value;
116
+ }
117
+
118
+ /**
119
+ * Set text content (increments rev)
120
+ */
121
+ set text(value) {
122
+ this.#element.textContent = value;
123
+ this.#incrementRev();
124
+ }
125
+
126
+ /**
127
+ * Alias for text (compatibility)
128
+ */
129
+ get value() {
130
+ return this.text;
131
+ }
132
+
133
+ set value(v) {
134
+ this.text = v;
135
+ }
136
+
137
+ /**
138
+ * Get underlying element
139
+ */
140
+ get element() {
141
+ return this.#element;
142
+ }
143
+
144
+ /**
145
+ * Get tag name
146
+ */
147
+ get tag() {
148
+ return this.#element.tagName;
149
+ }
150
+
151
+ /**
152
+ * Get UUID
153
+ */
154
+ get uuid() {
155
+ return this.#element.getAttribute('uuid');
156
+ }
157
+
158
+ /**
159
+ * Get revision number
160
+ */
161
+ get rev() {
162
+ return parseInt(this.#element.getAttribute('rev') || '0', 10);
163
+ }
164
+
165
+ /**
166
+ * Get attribute value
167
+ * @param {string} name
168
+ * @returns {string|null}
169
+ */
170
+ attr(name) {
171
+ return this.#element.getAttribute(name);
172
+ }
173
+
174
+ /**
175
+ * Set attribute (increments rev)
176
+ * @param {string} name
177
+ * @param {string} value
178
+ */
179
+ setAttr(name, value) {
180
+ this.#element.setAttribute(name, value);
181
+ this.#incrementRev();
182
+ }
183
+
184
+ /**
185
+ * Subscribe to text changes
186
+ * @param {Function} fn
187
+ * @param {boolean} autorun
188
+ * @returns {Function} Unsubscribe
189
+ */
190
+ subscribe(fn, autorun = true) {
191
+ return this.#signal.subscribe(fn, autorun);
192
+ }
193
+
194
+ /**
195
+ * Map to derived signal
196
+ * @param {Function} fn
197
+ * @returns {Signal}
198
+ */
199
+ map(fn) {
200
+ return this.#signal.map(fn);
201
+ }
202
+
203
+ /**
204
+ * Peek value without tracking
205
+ */
206
+ peek() {
207
+ return this.#signal.peek();
208
+ }
209
+
210
+ /**
211
+ * Serialize this node to XML string
212
+ * @returns {string}
213
+ */
214
+ serialize() {
215
+ return this.#element.outerHTML;
216
+ }
217
+
218
+ /**
219
+ * Serialize inner content only
220
+ * @returns {string}
221
+ */
222
+ serializeContent() {
223
+ return this.#element.innerHTML;
224
+ }
225
+
226
+ /**
227
+ * Get child nodes as Node wrappers
228
+ * @returns {Node[]}
229
+ */
230
+ children() {
231
+ return Array.from(this.#element.children).map(el => Node.wrap(el));
232
+ }
233
+
234
+ /**
235
+ * Query child by path segment
236
+ * @param {string} tag
237
+ * @param {number|null} index
238
+ * @returns {Node|null}
239
+ */
240
+ child(tag, index = null) {
241
+ const tagLower = tag.toLowerCase();
242
+ const matches = Array.from(this.#element.children).filter(
243
+ el => el.tagName.toLowerCase() === tagLower
244
+ );
245
+ const element = index !== null ? matches[index] : matches[0];
246
+ return element ? Node.wrap(element) : null;
247
+ }
248
+
249
+ /**
250
+ * Query all children with tag
251
+ * @param {string} tag
252
+ * @returns {Node[]}
253
+ */
254
+ childrenByTag(tag) {
255
+ const tagLower = tag.toLowerCase();
256
+ return Array.from(this.#element.children)
257
+ .filter(el => el.tagName.toLowerCase() === tagLower)
258
+ .map(el => Node.wrap(el));
259
+ }
260
+
261
+ /**
262
+ * Compare with another node for conflict resolution
263
+ * Higher rev wins, UUID tiebreaker (alphabetically first wins)
264
+ * @param {Node} other
265
+ * @returns {Node} Winner
266
+ */
267
+ static resolve(a, b) {
268
+ if (a.rev !== b.rev) {
269
+ return a.rev > b.rev ? a : b;
270
+ }
271
+ return a.uuid <= b.uuid ? a : b;
272
+ }
273
+
274
+ /**
275
+ * Merge remote changes into this node
276
+ * @param {string} remoteXml
277
+ * @returns {boolean} True if remote won
278
+ */
279
+ merge(remoteXml) {
280
+ const parser = new DOMParser();
281
+ const doc = parser.parseFromString(remoteXml, 'application/xml');
282
+ const remoteEl = doc.documentElement;
283
+
284
+ const remoteRev = parseInt(remoteEl.getAttribute('rev') || '0', 10);
285
+ const remoteUuid = remoteEl.getAttribute('uuid');
286
+
287
+ // Compare versions
288
+ if (remoteRev > this.rev ||
289
+ (remoteRev === this.rev && remoteUuid < this.uuid)) {
290
+ // Remote wins - apply changes
291
+ this.#element.innerHTML = remoteEl.innerHTML;
292
+ this.#element.setAttribute('rev', String(remoteRev));
293
+ this.#signal.value = this.#element.textContent;
294
+ return true;
295
+ }
296
+
297
+ return false;
298
+ }
299
+
300
+ /**
301
+ * Dispose this node wrapper
302
+ */
303
+ dispose() {
304
+ if (this.#disposed) return;
305
+ this.#disposed = true;
306
+
307
+ this.#observer.disconnect();
308
+ this.#signal.dispose();
309
+ cache.delete(this.#element);
310
+ }
311
+
312
+ #incrementRev() {
313
+ const rev = this.rev + 1;
314
+ this.#element.setAttribute('rev', String(rev));
315
+ }
316
+ }
317
+
318
+ /**
319
+ * AttrNode - Reactive wrapper for attributes
320
+ */
321
+ export class AttrNode {
322
+ #element;
323
+ #name;
324
+ #signal;
325
+ #observer;
326
+ #disposed = false;
327
+
328
+ static #cache = new WeakMap();
329
+
330
+ static wrap(element, name) {
331
+ let attrMap = AttrNode.#cache.get(element);
332
+ if (!attrMap) {
333
+ attrMap = new Map();
334
+ AttrNode.#cache.set(element, attrMap);
335
+ }
336
+
337
+ if (attrMap.has(name)) {
338
+ return attrMap.get(name);
339
+ }
340
+
341
+ const node = new AttrNode(element, name);
342
+ attrMap.set(name, node);
343
+ return node;
344
+ }
345
+
346
+ constructor(element, name) {
347
+ this.#element = element;
348
+ this.#name = name;
349
+ this.#signal = new Signal(element.getAttribute(name));
350
+
351
+ this.#observer = new MutationObserver(mutations => {
352
+ for (const m of mutations) {
353
+ if (m.attributeName === name) {
354
+ this.#signal.value = element.getAttribute(name);
355
+ }
356
+ }
357
+ });
358
+
359
+ this.#observer.observe(element, {
360
+ attributes: true,
361
+ attributeFilter: [name]
362
+ });
363
+ }
364
+
365
+ get value() {
366
+ return this.#signal.value;
367
+ }
368
+
369
+ set value(v) {
370
+ this.#element.setAttribute(this.#name, v);
371
+ // Observer will update signal
372
+ }
373
+
374
+ get name() {
375
+ return this.#name;
376
+ }
377
+
378
+ subscribe(fn, autorun = true) {
379
+ return this.#signal.subscribe(fn, autorun);
380
+ }
381
+
382
+ map(fn) {
383
+ return this.#signal.map(fn);
384
+ }
385
+
386
+ peek() {
387
+ return this.#signal.peek();
388
+ }
389
+
390
+ dispose() {
391
+ if (this.#disposed) return;
392
+ this.#disposed = true;
393
+ this.#observer.disconnect();
394
+ this.#signal.dispose();
395
+ }
396
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Path - XML path parsing and resolution
3
+ *
4
+ * Supports:
5
+ * - Element paths: Product.Details.Price
6
+ * - Array indexing: Product.Items[0]
7
+ * - Attributes: Product.Price@currency
8
+ * - Relative: .Name (within iterations)
9
+ *
10
+ * @example
11
+ * parse('Product.Items[0]@id')
12
+ * // { segments: [{tag: 'Product'}, {tag: 'Items', index: 0}], attr: 'id' }
13
+ */
14
+
15
+ /**
16
+ * Parse a path string into structured segments
17
+ * @param {string} path
18
+ * @returns {Object} { segments, attr, relative }
19
+ */
20
+ export function parse(path) {
21
+ if (!path || path === '.') {
22
+ return { segments: [], attr: null, relative: path === '.' };
23
+ }
24
+
25
+ const relative = path.startsWith('.');
26
+ let cleanPath = relative ? path.slice(1) : path;
27
+
28
+ // Handle leading dot for relative paths like ".Name"
29
+ if (cleanPath.startsWith('.')) {
30
+ cleanPath = cleanPath.slice(1);
31
+ }
32
+
33
+ // Split off attribute: Product.Price@currency
34
+ let attr = null;
35
+ const attrIndex = cleanPath.lastIndexOf('@');
36
+ if (attrIndex > 0) {
37
+ attr = cleanPath.slice(attrIndex + 1);
38
+ cleanPath = cleanPath.slice(0, attrIndex);
39
+ }
40
+
41
+ // Handle empty path after attribute extraction
42
+ if (!cleanPath) {
43
+ return { segments: [], attr, relative };
44
+ }
45
+
46
+ // Parse segments
47
+ const segments = cleanPath.split('.').map(part => {
48
+ const match = part.match(/^([A-Za-z_][\w-]*)(?:\[(\d+)\])?$/);
49
+ if (!match) {
50
+ throw new Error(`Invalid path segment: "${part}"`);
51
+ }
52
+ return {
53
+ tag: match[1],
54
+ index: match[2] !== undefined ? parseInt(match[2], 10) : null
55
+ };
56
+ });
57
+
58
+ return { segments, attr, relative };
59
+ }
60
+
61
+ /**
62
+ * Resolve a path against an XML element
63
+ * @param {Element} root - Starting element
64
+ * @param {string} path - Path to resolve
65
+ * @returns {Object|null} { element, attr } or null
66
+ *
67
+ * NOTE: Tag matching is case-insensitive because the DOM
68
+ * lowercases all tag names when parsing HTML.
69
+ */
70
+ export function resolve(root, path) {
71
+ const { segments, attr } = parse(path);
72
+ let current = root;
73
+
74
+ for (const { tag, index } of segments) {
75
+ const tagLower = tag.toLowerCase();
76
+ const children = Array.from(current.children).filter(
77
+ el => el.tagName.toLowerCase() === tagLower
78
+ );
79
+
80
+ if (children.length === 0) return null;
81
+
82
+ current = index !== null ? children[index] : children[0];
83
+ if (!current) return null;
84
+ }
85
+
86
+ return { element: current, attr };
87
+ }
88
+
89
+ /**
90
+ * Resolve all matching elements for a path
91
+ * @param {Element} root - Starting element
92
+ * @param {string} path - Path to resolve
93
+ * @returns {Element[]} Array of matching elements
94
+ *
95
+ * NOTE: Tag matching is case-insensitive because the DOM
96
+ * lowercases all tag names when parsing HTML.
97
+ */
98
+ export function resolveAll(root, path) {
99
+ const { segments } = parse(path);
100
+ let current = [root];
101
+
102
+ for (const { tag, index } of segments) {
103
+ const tagLower = tag.toLowerCase();
104
+ current = current.flatMap(el =>
105
+ Array.from(el.children).filter(child => child.tagName.toLowerCase() === tagLower)
106
+ );
107
+
108
+ if (index !== null) {
109
+ current = current[index] ? [current[index]] : [];
110
+ }
111
+ }
112
+
113
+ return current;
114
+ }
115
+
116
+ /**
117
+ * Get parent path
118
+ * @param {string} path
119
+ * @returns {string}
120
+ */
121
+ export function parent(path) {
122
+ const { segments, attr } = parse(path);
123
+
124
+ if (attr) {
125
+ // Return path without attribute
126
+ return segments.map(s =>
127
+ s.index !== null ? `${s.tag}[${s.index}]` : s.tag
128
+ ).join('.');
129
+ }
130
+
131
+ if (segments.length <= 1) return '';
132
+
133
+ return segments.slice(0, -1).map(s =>
134
+ s.index !== null ? `${s.tag}[${s.index}]` : s.tag
135
+ ).join('.');
136
+ }
137
+
138
+ /**
139
+ * Get last segment tag name
140
+ * @param {string} path
141
+ * @returns {string}
142
+ */
143
+ export function leaf(path) {
144
+ const { segments, attr } = parse(path);
145
+ if (attr) return attr;
146
+ if (segments.length === 0) return '';
147
+ return segments[segments.length - 1].tag;
148
+ }
149
+
150
+ /**
151
+ * Build path string from segments
152
+ * @param {Array} segments
153
+ * @param {string|null} attr
154
+ * @returns {string}
155
+ */
156
+ export function build(segments, attr = null) {
157
+ const elementPath = segments.map(s =>
158
+ s.index !== null ? `${s.tag}[${s.index}]` : s.tag
159
+ ).join('.');
160
+
161
+ return attr ? `${elementPath}@${attr}` : elementPath;
162
+ }
163
+
164
+ /**
165
+ * Join two paths
166
+ * @param {string} base
167
+ * @param {string} relative
168
+ * @returns {string}
169
+ */
170
+ export function join(base, relative) {
171
+ if (!base) return relative;
172
+ if (!relative || relative === '.') return base;
173
+ return `${base}.${relative}`;
174
+ }
175
+
176
+ export default { parse, resolve, resolveAll, parent, leaf, build, join };