@jinntec/fore 2.9.0 → 3.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.
Files changed (42) hide show
  1. package/README.md +0 -2
  2. package/dist/fore-dev.js +9505 -6057
  3. package/dist/fore.js +9218 -5996
  4. package/index.js +1 -0
  5. package/package.json +4 -2
  6. package/src/DependentXPathQueries.js +37 -2
  7. package/src/ForeElementMixin.js +64 -38
  8. package/src/actions/fx-delete.js +424 -73
  9. package/src/actions/fx-insert.js +472 -73
  10. package/src/actions/fx-setattribute.js +5 -5
  11. package/src/actions/fx-setvalue.js +11 -9
  12. package/src/authoring-check.js +231 -0
  13. package/src/fore.js +32 -77
  14. package/src/functions/registerFunction.js +65 -20
  15. package/src/fx-bind.js +128 -73
  16. package/src/fx-fore.js +653 -219
  17. package/src/fx-instance.js +145 -143
  18. package/src/fx-model.js +292 -102
  19. package/src/fx-speech.js +268 -0
  20. package/src/fx-submission.js +246 -135
  21. package/src/fx-var.js +28 -4
  22. package/src/json/JSONDomFacade.js +84 -0
  23. package/src/json/JSONLens.js +67 -0
  24. package/src/json/JSONNode.js +248 -0
  25. package/src/json/lensFromNode.js +5 -0
  26. package/src/modelitem.js +16 -2
  27. package/src/tools/fx-action-log.js +1 -1
  28. package/src/ui/UIElement.js +16 -2
  29. package/src/ui/abstract-control.js +14 -7
  30. package/src/ui/fx-case.js +3 -1
  31. package/src/ui/fx-control.js +4 -2
  32. package/src/ui/fx-group.js +1 -1
  33. package/src/ui/fx-items.js +26 -32
  34. package/src/ui/fx-output.js +8 -38
  35. package/src/ui/fx-repeat-attributes.js +1 -1
  36. package/src/ui/fx-repeat.js +683 -247
  37. package/src/ui/fx-repeatitem.js +16 -1
  38. package/src/ui/repeat-base.js +9 -5
  39. package/src/withDraggability.js +0 -1
  40. package/src/xpath-evaluation.js +1763 -740
  41. package/src/xpath-path.js +274 -24
  42. package/src/xpath-util.js +92 -46
package/src/xpath-path.js CHANGED
@@ -30,13 +30,147 @@ export function getDocPath(node) {
30
30
  * @param {string} instanceId
31
31
  * @returns string
32
32
  */
33
- export function getPath(node, instanceId) {
33
+ /**
34
+ * @param {Node} node
35
+ * @param {string} instanceId
36
+ * @returns string
37
+ */
38
+ /**
39
+ * Compute a stable Fore path for a node.
40
+ *
41
+ * NOTE:
42
+ * During bind graph build we often deal with XML nodes that live in a separate XML Document
43
+ * (instance document). Those nodes are not in the HTML DOM and therefore cannot "see" <fx-instance>
44
+ * via ancestor traversal, shadow root, or document.querySelector.
45
+ *
46
+ * For that reason, getPath MUST be able to compute $default/... without requiring that an
47
+ * <fx-instance id="default"> element exists in the HTML DOM.
48
+ *
49
+ * @param {Node|any} node
50
+ * @param {string} instanceId
51
+ * @returns {string}
52
+ */
53
+ export function getPath(node, instanceId = 'default') {
54
+ const wantedId = (instanceId ?? 'default').trim() || 'default';
55
+
56
+ // JSON lens nodes carry their own path – no need to resolve <fx-instance>
57
+ if (node && node.__jsonlens__ === true) {
58
+ return getJsonPath(node);
59
+ }
60
+
61
+ // Try to find the corresponding fx-instance in the current HTML document.
62
+ // This is useful for sanity checks / detecting JSON instances, but MUST NOT be required
63
+ // for computing an XML path (especially for the default instance).
64
+ let instanceEl = null;
65
+ try {
66
+ instanceEl = document.querySelector(`fx-instance[id='${wantedId}']`);
67
+
68
+ // Many Fore documents use an id-less first instance as the default instance.
69
+ if (!instanceEl && (wantedId === 'default' || wantedId === '')) {
70
+ instanceEl =
71
+ document.querySelector('fx-instance:not([id])') ||
72
+ document.querySelector("fx-instance[id='default']");
73
+ }
74
+ } catch (_e) {
75
+ // ignore
76
+ }
77
+
78
+ // If we *did* find an instance element and it is JSON, then the caller is using the wrong node type.
79
+ // (JSON instances are addressed via JSON lens nodes.)
80
+ if (instanceEl) {
81
+ const isJson = instanceEl.getAttribute('type') === 'json' || instanceEl.type === 'json';
82
+ if (isJson) {
83
+ throw new Error(`getPath: Instance '${wantedId}' is JSON but node is not a JSON lens node.`);
84
+ }
85
+ } else {
86
+ // IMPORTANT BEHAVIOR CHANGE:
87
+ // - If default instance element can't be found (common for detached XML documents),
88
+ // we still compute an XML path. Do NOT throw.
89
+ // - For non-default ids, keep the old strict behavior.
90
+ if (wantedId !== 'default') {
91
+ throw new Error(`Instance with id '${wantedId}' not found.`);
92
+ }
93
+ }
94
+
95
+ // XML nodes: compute path purely from the XML tree
96
+ if (node && node.nodeType !== undefined) {
97
+ return getXmlPath(node, wantedId);
98
+ }
99
+
100
+ throw new Error('Unsupported node type for getPath');
101
+ }
102
+
103
+ function getXmlPath(node, instanceId) {
34
104
  const path = fx.evaluateXPathToString('path()', node);
35
105
  // Path is like `$default/x[1]/y[1]`
36
106
  const shortened = shortenPath(path);
37
107
  return shortened.startsWith('/') ? `$${instanceId}${shortened}` : `$${instanceId}/${shortened}`;
38
108
  }
39
109
 
110
+ export function getJsonPath(node) {
111
+ if (!node || !node.__jsonlens__) {
112
+ throw new Error('getJsonPath called on non-JSONLens node');
113
+ }
114
+
115
+ const pathSegments = [];
116
+ let current = node;
117
+ const instanceId = node.instanceId || 'default';
118
+
119
+ while (current && current.parent) {
120
+ const { keyOrIndex, parent } = current;
121
+ if (typeof keyOrIndex === 'number') {
122
+ pathSegments.unshift(`[${keyOrIndex + 1}]`); // XPath is 1-based
123
+ } else {
124
+ pathSegments.unshift(`/${keyOrIndex}`);
125
+ }
126
+ current = parent;
127
+ }
128
+
129
+ return pathSegments.length > 0 ? `$${instanceId}${pathSegments.join('')}` : `$${instanceId}/`;
130
+ }
131
+
132
+ /**
133
+ * Parses a JSON-style binding expression like `?automobiles?1?maker`
134
+ * into a list of steps: ['automobiles', 0, 'maker']
135
+ *
136
+ * @param {string} ref
137
+ * @returns {Array<string|number>}
138
+ */
139
+ // returns null if it's not a JSON-lens style ref
140
+ // otherwise returns { instanceId: string, steps: Array<string|number> }
141
+ export function parseJsonRef(ref, defaultInstanceId = 'default') {
142
+ if (!ref) return null;
143
+
144
+ const s = String(ref).trim();
145
+
146
+ // Optional leading instance('id') / instance("id")
147
+ const instMatch = s.match(/^instance\s*\(\s*(['"])(.*?)\1\s*\)\s*(\?.*)?$/);
148
+
149
+ let instanceId;
150
+ let lensPart;
151
+
152
+ if (instMatch) {
153
+ instanceId = instMatch[2];
154
+ lensPart = instMatch[3] || '';
155
+ } else {
156
+ // No instance(...): must be a lens ref starting with '?'
157
+ if (!s.startsWith('?')) return null;
158
+ instanceId = defaultInstanceId;
159
+ lensPart = s;
160
+ }
161
+
162
+ const steps = lensPart
163
+ .split('?')
164
+ .filter(Boolean)
165
+ .map(part => {
166
+ if (part === '*') return '*';
167
+ if (/^\d+$/.test(part)) return Number(part) - 1; // 1-based -> 0-based
168
+ return part;
169
+ });
170
+
171
+ return { instanceId, steps };
172
+ }
173
+
40
174
  /**
41
175
  * Checks if a template expression is dynamic, i.e. refers to data (via XPath) or functions.
42
176
  * Used to determine whether to set up data observers or evaluate on every refresh.
@@ -45,35 +179,151 @@ export function getPath(node, instanceId) {
45
179
  * @returns {boolean} - True if the expression is dynamic (data/function dependent)
46
180
  */
47
181
  export function isDynamic(expr) {
48
- try {
49
- const result = evaluateXPathToString(expr, document, null);
50
-
51
- // 1. Check for function calls with non-literal arguments
52
- const functionCallPattern = /\b([\w-]+)\s*\(\s*([^)]+)\)/g;
53
- let match;
54
- while ((match = functionCallPattern.exec(expr)) !== null) {
55
- const arg = match[2].trim();
56
- if (!/^(['"]).*\1$/.test(arg) && isNaN(arg)) {
57
- return true;
182
+ const s = String(expr ?? '').trim();
183
+ if (s === '') return false;
184
+
185
+ const isLiteral = token => {
186
+ const t = token.trim();
187
+ // string literal or number literal
188
+ return /^(['"]).*\1$/.test(t) || /^-?\d+(\.\d+)?$/.test(t);
189
+ };
190
+
191
+ const splitArgsTopLevel = argsStr => {
192
+ const args = [];
193
+ let cur = '';
194
+ let depth = 0;
195
+ let quote = null;
196
+
197
+ for (let i = 0; i < argsStr.length; i++) {
198
+ const ch = argsStr[i];
199
+
200
+ if (quote) {
201
+ cur += ch;
202
+ if (ch === quote) quote = null;
203
+ continue;
204
+ }
205
+
206
+ if (ch === '"' || ch === "'") {
207
+ quote = ch;
208
+ cur += ch;
209
+ continue;
210
+ }
211
+
212
+ if (ch === '(') {
213
+ depth++;
214
+ cur += ch;
215
+ continue;
216
+ }
217
+ if (ch === ')') {
218
+ depth = Math.max(0, depth - 1);
219
+ cur += ch;
220
+ continue;
221
+ }
222
+
223
+ if (ch === ',' && depth === 0) {
224
+ args.push(cur.trim());
225
+ cur = '';
226
+ continue;
227
+ }
228
+
229
+ cur += ch;
230
+ }
231
+
232
+ if (cur.trim() !== '') args.push(cur.trim());
233
+ return args;
234
+ };
235
+
236
+ // 1) variables and predicates => dynamic
237
+ {
238
+ let quote = null;
239
+ for (let i = 0; i < s.length; i++) {
240
+ const ch = s[i];
241
+ if (quote) {
242
+ if (ch === quote) quote = null;
243
+ continue;
58
244
  }
245
+ if (ch === '"' || ch === "'") {
246
+ quote = ch;
247
+ continue;
248
+ }
249
+ if (ch === '$') return true;
250
+ if (ch === '[') return true;
251
+ }
252
+ }
253
+
254
+ // 2) function calls
255
+ // Only treat a small set of known pure string functions as static when all args are literals.
256
+ const staticIfLiteral = new Set(['concat', 'string-length']);
257
+
258
+ let quote = null;
259
+ for (let i = 0; i < s.length; i++) {
260
+ const ch = s[i];
261
+
262
+ if (quote) {
263
+ if (ch === quote) quote = null;
264
+ continue;
59
265
  }
266
+ if (ch === '"' || ch === "'") {
267
+ quote = ch;
268
+ continue;
269
+ }
270
+
271
+ if (ch !== '(') continue;
272
+
273
+ // find function name just before '('
274
+ let j = i - 1;
275
+ while (j >= 0 && /\s/.test(s[j])) j--;
276
+
277
+ const end = j;
278
+ while (j >= 0 && /[\w:-]/.test(s[j])) j--;
279
+
280
+ const fnName = s.slice(j + 1, end + 1);
281
+ if (!fnName) continue;
60
282
 
61
- // 2. Check for variable references
62
- if (expr.includes('$')) return true;
63
-
64
- // 3. Check for unquoted predicates
65
- let inQuote = false;
66
- for (let i = 0; i < expr.length; i++) {
67
- const char = expr[i];
68
- if (char === '"' || char === "'") {
69
- inQuote = inQuote === char ? false : char;
70
- } else if (!inQuote && char === '[') {
71
- return true;
283
+ // parse argument substring until matching ')'
284
+ let k = i + 1;
285
+ let depth = 1;
286
+ let q2 = null;
287
+
288
+ while (k < s.length && depth > 0) {
289
+ const c = s[k];
290
+ if (q2) {
291
+ if (c === q2) q2 = null;
292
+ k++;
293
+ continue;
294
+ }
295
+ if (c === '"' || c === "'") {
296
+ q2 = c;
297
+ k++;
298
+ continue;
72
299
  }
300
+ if (c === '(') depth++;
301
+ else if (c === ')') depth--;
302
+ k++;
303
+ }
304
+
305
+ const argsStr = s.slice(i + 1, k - 1);
306
+ const fnLower = fnName.toLowerCase();
307
+
308
+ // Anything not explicitly “static-if-literal” is dynamic (matches your tests for myfunc(), instance(), index(), etc.)
309
+ if (!staticIfLiteral.has(fnLower)) return true;
310
+
311
+ const args = splitArgsTopLevel(argsStr);
312
+
313
+ // concat("a","b") is static; concat(foo,"b") is dynamic
314
+ if (fnLower === 'concat') {
315
+ return !args.every(isLiteral);
73
316
  }
74
317
 
75
- return false;
76
- } catch {
318
+ // string-length("x") is static; string-length(foo) is dynamic
319
+ if (fnLower === 'string-length') {
320
+ return !(args.length === 1 && isLiteral(args[0]));
321
+ }
322
+
323
+ // fallback: be safe
77
324
  return true;
78
325
  }
326
+
327
+ // If none of the dynamic markers matched, it's static
328
+ return false;
79
329
  }
package/src/xpath-util.js CHANGED
@@ -23,9 +23,7 @@ export class XPathUtil {
23
23
  for (const item of astNode[key]) {
24
24
  if (XPathUtil.containsDynamicContent(item)) return true;
25
25
  }
26
- } else {
27
- if (XPathUtil.containsDynamicContent(astNode[key])) return true;
28
- }
26
+ } else if (XPathUtil.containsDynamicContent(astNode[key])) return true;
29
27
  }
30
28
  }
31
29
 
@@ -247,22 +245,30 @@ export class XPathUtil {
247
245
  * @returns {*|null}
248
246
  */
249
247
  static getParentBindingElement(start) {
250
- /* if (start.parentNode.host) {
251
- const { host } = start.parentNode;
252
- if (host.hasAttribute('ref')) {
253
- return host;
254
- }
255
- } else */
256
- if (
257
- start.parentNode &&
258
- (start.parentNode.nodeType !== Node.DOCUMENT_NODE ||
259
- start.parentNode.nodeType !== Node.DOCUMENT_FRAGMENT_NODE)
248
+ // JSON lens case
249
+ if (start && start.__jsonlens__ === true) {
250
+ let current = start.parent;
251
+ while (current) {
252
+ if (current.bindingElement) return current.bindingElement;
253
+ current = current.parent;
254
+ }
255
+ return null;
256
+ }
257
+
258
+ // DOM case
259
+ let node = start?.parentNode;
260
+
261
+ while (
262
+ node &&
263
+ node.nodeType !== Node.DOCUMENT_NODE &&
264
+ node.nodeType !== Node.DOCUMENT_FRAGMENT_NODE
260
265
  ) {
261
- return this.getClosest(
262
- 'fx-control[ref],fx-upload[ref],fx-group[ref],fx-repeat[ref], fx-switch[ref],fx-repeatitem',
263
- start.parentNode,
264
- );
266
+ if (node.matches?.('[ref],fx-repeatitem')) {
267
+ return node;
268
+ }
269
+ node = node.parentNode;
265
270
  }
271
+
266
272
  return null;
267
273
  }
268
274
 
@@ -275,7 +281,7 @@ export class XPathUtil {
275
281
  */
276
282
  static isAbsolutePath(path) {
277
283
  return (
278
- path != null && (path.startsWith('/') || path.startsWith('instance(') || path.startsWith('$'))
284
+ path != null && (path.startsWith('/') || path.startsWith('instance(') || path.startsWith('$') || path.startsWith('?'))
279
285
  );
280
286
  }
281
287
 
@@ -297,34 +303,86 @@ export class XPathUtil {
297
303
  * @returns {string}
298
304
  */
299
305
  static getInstanceId(ref, boundElement) {
300
- if (!ref) {
301
- return 'default';
306
+ const refStr = typeof ref === 'string' ? ref.trim() : '';
307
+
308
+ // Variant A: instance-vars ($default, $foo) must count as instance references
309
+ // for dependency tracking, otherwise repeats won't refresh when they change.
310
+ try {
311
+ const host =
312
+ boundElement?.nodeType === Node.ATTRIBUTE_NODE ? boundElement.ownerElement : boundElement;
313
+ const fore = host?.closest?.('fx-fore');
314
+ const bindings = fore?._instanceVarBindings;
315
+
316
+ if (bindings) {
317
+ // $default => default instance
318
+ if (/\$default(?![\w.-])/.test(refStr)) return 'default';
319
+
320
+ // $<id> => instance <id> (only if it's a known binding key)
321
+ for (const k of Object.keys(bindings)) {
322
+ if (k === 'default') continue;
323
+ const re = new RegExp(
324
+ `\\$${k.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}(?![\\w.-])`,
325
+ );
326
+ if (re.test(refStr)) return k;
327
+ }
328
+ }
329
+ } catch (_e) {
330
+ // ignore
302
331
  }
303
- if (ref.startsWith('instance()')) {
332
+
333
+ // Explicit "default instance" selector
334
+ if (refStr.startsWith('instance()')) {
304
335
  return 'default';
305
336
  }
306
- if (ref.startsWith('instance(')) {
307
- const result = ref.substring(ref.indexOf('(') + 1);
308
- return result.substring(1, result.indexOf(')') - 1);
337
+
338
+ // Explicit instance('id') selector at the START of the expression only
339
+ // (Do NOT use refStr.includes('instance(') because predicates may reference other instances.)
340
+ {
341
+ const m = refStr.match(/^instance\(\s*(['"])(?<id>.*?)\1\s*\)/);
342
+ if (m?.groups?.id != null) {
343
+ return m.groups.id;
344
+ }
309
345
  }
310
- if (ref.startsWith('$')) {
311
- // this variable might actually point to an instance
312
- const variableName = ref.match(/\$(?<variableName>[a-zA-Z0-9\-\_]+).*/)?.groups?.variableName;
346
+
347
+ // Variable indirection (may ultimately point to instance(...))
348
+ if (refStr.startsWith('$')) {
349
+ const variableName = refStr.match(/^\$(?<variableName>[a-zA-Z0-9\-_]+)/)?.groups
350
+ ?.variableName;
351
+
313
352
  let closestActualFormElement = boundElement;
314
353
  while (closestActualFormElement && !('inScopeVariables' in closestActualFormElement)) {
315
354
  closestActualFormElement =
316
- closestActualFormElement.nodeType === Node.ATTRIBUTE_NODE
317
- ? closestActualFormElement.ownerElement
318
- : closestActualFormElement.parentNode;
355
+ closestActualFormElement.nodeType === Node.ATTRIBUTE_NODE
356
+ ? closestActualFormElement.ownerElement
357
+ : closestActualFormElement.parentNode;
319
358
  }
320
359
 
321
360
  const correspondingVariable = closestActualFormElement?.inScopeVariables?.get(variableName);
322
- if (!correspondingVariable) {
323
- return null;
324
- }
361
+ if (!correspondingVariable) return null;
362
+
325
363
  return this.getInstanceId(correspondingVariable.valueQuery, correspondingVariable);
326
364
  }
327
- return null;
365
+
366
+ // If we can't decide from the ref itself (relative paths, '/', '.', missing ref, fx-repeatitem),
367
+ // inherit from the nearest ancestor that *does* have a ref or explicit instance().
368
+ const parentBinding = XPathUtil.getParentBindingElement(boundElement);
369
+ if (parentBinding) {
370
+ // If this is a repeatitem boundary with no ref, keep climbing
371
+ if (parentBinding.matches?.('fx-repeatitem') && !parentBinding.getAttribute?.('ref')) {
372
+ return this.getInstanceId(null, parentBinding);
373
+ }
374
+
375
+ const parentRef = parentBinding.getAttribute?.('ref');
376
+ if (parentRef) {
377
+ return this.getInstanceId(parentRef, parentBinding);
378
+ }
379
+
380
+ // Parent binding exists but has no ref (rare, but safe): keep climbing
381
+ return this.getInstanceId(null, parentBinding);
382
+ }
383
+
384
+ // No parent binding => top of scope. If ref wasn't explicit, default.
385
+ return 'default';
328
386
  }
329
387
 
330
388
  /**
@@ -361,18 +419,6 @@ export class XPathUtil {
361
419
  }
362
420
  */
363
421
 
364
- /**
365
- * @param {Node} node
366
- * @param {string} instanceId
367
- * @returns string
368
- */
369
- static getPath(node, instanceId) {
370
- const path = fx.evaluateXPathToString('path()', node);
371
- // Path is like `$default/x[1]/y[1]`
372
- const shortened = XPathUtil.shortenPath(path);
373
- return shortened.startsWith('/') ? `$${instanceId}${shortened}` : `$${instanceId}/${shortened}`;
374
- }
375
-
376
422
  /**
377
423
  * @param {string} path
378
424
  * @returns string