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/Node.js
ADDED
|
@@ -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
|
+
}
|
package/src/xml/Path.js
ADDED
|
@@ -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 };
|