@schukai/monster 4.91.1 → 4.93.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/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
 
4
4
 
5
+ ## [4.93.0] - 2026-01-13
6
+
7
+ ### Add Features
8
+
9
+ - Enhance formatter with recursion detection and cycle handling
10
+
11
+
12
+
13
+ ## [4.92.0] - 2026-01-12
14
+
15
+ ### Add Features
16
+
17
+ - Improve data resolution in form handling [#372](https://gitlab.schukai.com/oss/libraries/javascript/monster/issues/372)
18
+
19
+
20
+
5
21
  ## [4.91.1] - 2026-01-12
6
22
 
7
23
  ### Bug Fixes
package/package.json CHANGED
@@ -1 +1 @@
1
- {"author":"Volker Schukai","dependencies":{"@floating-ui/dom":"^1.7.4","@popperjs/core":"^2.11.8"},"description":"Monster is a simple library for creating fast, robust and lightweight websites.","homepage":"https://monsterjs.org/","keywords":["framework","web","dom","css","sass","mobile-first","app","front-end","templates","schukai","core","shopcloud","alvine","monster","buildmap","stack","observer","observable","uuid","node","nodelist","css-in-js","logger","log","theme"],"license":"AGPL 3.0","main":"source/monster.mjs","module":"source/monster.mjs","name":"@schukai/monster","repository":{"type":"git","url":"https://gitlab.schukai.com/oss/libraries/javascript/monster.git"},"type":"module","version":"4.91.1"}
1
+ {"author":"Volker Schukai","dependencies":{"@floating-ui/dom":"^1.7.4","@popperjs/core":"^2.11.8"},"description":"Monster is a simple library for creating fast, robust and lightweight websites.","homepage":"https://monsterjs.org/","keywords":["framework","web","dom","css","sass","mobile-first","app","front-end","templates","schukai","core","shopcloud","alvine","monster","buildmap","stack","observer","observable","uuid","node","nodelist","css-in-js","logger","log","theme"],"license":"AGPL 3.0","main":"source/monster.mjs","module":"source/monster.mjs","name":"@schukai/monster","repository":{"type":"git","url":"https://gitlab.schukai.com/oss/libraries/javascript/monster.git"},"type":"module","version":"4.93.0"}
@@ -1043,34 +1043,32 @@ function syncOptionData() {
1043
1043
  * @return {object}
1044
1044
  */
1045
1045
  function resolveFormRecord(form, datasource) {
1046
- let data = datasource?.data;
1046
+ const { data, dataPath } = resolveDatasourceBasePath(form, datasource);
1047
1047
  if (!isObject(data) && !isArray(data)) {
1048
1048
  return {};
1049
1049
  }
1050
1050
 
1051
- const mappingData = form.getOption?.("mapping.data");
1052
- if (isString(mappingData) && mappingData.trim() !== "") {
1053
- try {
1054
- data = new Pathfinder(data).getVia(mappingData.trim());
1055
- } catch {
1056
- data = {};
1051
+ let resolvedData = data;
1052
+ if (isObject(resolvedData) && !isArray(resolvedData)) {
1053
+ if (isArray(resolvedData.data)) {
1054
+ resolvedData = resolvedData.data;
1055
+ } else if (isArray(resolvedData.dataset)) {
1056
+ resolvedData = resolvedData.dataset;
1057
+ } else if (dataPath === "") {
1058
+ resolvedData = Object.values(resolvedData);
1057
1059
  }
1058
1060
  }
1059
1061
 
1060
- if (isObject(data)) {
1061
- data = Object.values(data);
1062
- }
1063
-
1064
1062
  const mappingIndex = form.getOption?.("mapping.index");
1065
1063
  if (mappingIndex !== null && mappingIndex !== undefined) {
1066
- data = data?.[mappingIndex];
1064
+ resolvedData = resolvedData?.[mappingIndex];
1067
1065
  }
1068
1066
 
1069
- if (!isObject(data) && !isArray(data)) {
1067
+ if (!isObject(resolvedData) && !isArray(resolvedData)) {
1070
1068
  return {};
1071
1069
  }
1072
1070
 
1073
- return data;
1071
+ return resolvedData;
1074
1072
  }
1075
1073
 
1076
1074
  /**
@@ -1085,14 +1083,14 @@ function getDatasourceContext() {
1085
1083
  return null;
1086
1084
  }
1087
1085
 
1088
- const mappingData = form?.getOption?.("mapping.data");
1089
1086
  const mappingIndex = form?.getOption?.("mapping.index");
1087
+ const { dataPath } = resolveDatasourceBasePath(form, datasource);
1090
1088
  const record = resolveFormRecord(form, datasource);
1091
1089
  const normalizedPath = normalizePathForRecord(path, record);
1092
1090
  let basePath = "";
1093
1091
 
1094
- if (isString(mappingData) && mappingData.trim() !== "") {
1095
- basePath = mappingData.trim();
1092
+ if (isString(dataPath) && dataPath.trim() !== "") {
1093
+ basePath = dataPath.trim();
1096
1094
  }
1097
1095
 
1098
1096
  if (mappingIndex !== null && mappingIndex !== undefined) {
@@ -1104,6 +1102,42 @@ function getDatasourceContext() {
1104
1102
  return { target: datasource, path: finalPath };
1105
1103
  }
1106
1104
 
1105
+ /**
1106
+ * @private
1107
+ * @param {HTMLElement} form
1108
+ * @param {HTMLElement} datasource
1109
+ * @return {{data: object|Array, dataPath: string}}
1110
+ */
1111
+ function resolveDatasourceBasePath(form, datasource) {
1112
+ let data = datasource?.data;
1113
+ if (!isObject(data) && !isArray(data)) {
1114
+ return { data: {}, dataPath: "" };
1115
+ }
1116
+
1117
+ let dataPath = "";
1118
+ const mappingData = form?.getOption?.("mapping.data");
1119
+ if (isString(mappingData) && mappingData.trim() !== "") {
1120
+ const trimmed = mappingData.trim();
1121
+ try {
1122
+ const mapped = new Pathfinder(data).getVia(trimmed);
1123
+ if (mapped !== undefined) {
1124
+ data = mapped;
1125
+ dataPath = trimmed;
1126
+ }
1127
+ } catch {}
1128
+ }
1129
+
1130
+ if (dataPath === "" && isObject(data) && !isArray(data)) {
1131
+ if (isArray(data.data)) {
1132
+ dataPath = "data";
1133
+ } else if (isArray(data.dataset)) {
1134
+ dataPath = "dataset";
1135
+ }
1136
+ }
1137
+
1138
+ return { data, dataPath };
1139
+ }
1140
+
1107
1141
  /**
1108
1142
  * @private
1109
1143
  * @return {string}
@@ -34,6 +34,12 @@ const internalObjectSymbol = Symbol("internalObject");
34
34
  */
35
35
  const watchdogSymbol = Symbol("watchdog");
36
36
 
37
+ /**
38
+ * @private
39
+ * @type {symbol}
40
+ */
41
+ const recursionStackSymbol = Symbol("recursionStack");
42
+
37
43
  /**
38
44
  * @private
39
45
  * @type {symbol}
@@ -145,6 +151,10 @@ class Formatter extends BaseWithOptions {
145
151
  open: ["${"],
146
152
  close: ["}"],
147
153
  },
154
+ recursion: {
155
+ maxDepth: 20,
156
+ detectCycles: true,
157
+ },
148
158
  parameter: {
149
159
  delimiter: "::",
150
160
  assignment: "=",
@@ -226,6 +236,7 @@ class Formatter extends BaseWithOptions {
226
236
  this[markerOpenIndexSymbol] = 0;
227
237
  this[markerCloseIndexSymbol] = 0;
228
238
  this[workingDataSymbol] = {};
239
+ this[recursionStackSymbol] = [];
229
240
  return format.call(this, text);
230
241
  }
231
242
  }
@@ -236,7 +247,14 @@ class Formatter extends BaseWithOptions {
236
247
  */
237
248
  function format(text) {
238
249
  this[watchdogSymbol]++;
239
- if (this[watchdogSymbol] > 20) {
250
+ const recursionOptions = this[internalSymbol]["recursion"] || {};
251
+ const maxDepth =
252
+ typeof recursionOptions.maxDepth === "number"
253
+ ? recursionOptions.maxDepth
254
+ : 20;
255
+ const detectCycles = recursionOptions.detectCycles !== false;
256
+
257
+ if (this[watchdogSymbol] > maxDepth) {
240
258
  throw new Error("too deep nesting");
241
259
  }
242
260
 
@@ -252,13 +270,22 @@ function format(text) {
252
270
  return text;
253
271
  }
254
272
 
255
- let result = tokenize.call(
273
+ const { text: tokenizedText, keys } = tokenize.call(
256
274
  this,
257
275
  validateString(text),
258
276
  openMarker,
259
277
  closeMarker,
278
+ detectCycles,
260
279
  );
261
280
 
281
+ let pushedKeys = 0;
282
+ if (detectCycles && keys.length > 0) {
283
+ for (const key of keys) {
284
+ this[recursionStackSymbol].push(key);
285
+ }
286
+ pushedKeys = keys.length;
287
+ }
288
+
262
289
  if (
263
290
  this[internalSymbol]["marker"]["open"]?.[this[markerOpenIndexSymbol] + 1]
264
291
  ) {
@@ -271,7 +298,14 @@ function format(text) {
271
298
  this[markerCloseIndexSymbol]++;
272
299
  }
273
300
 
274
- result = format.call(this, result);
301
+ let result;
302
+ try {
303
+ result = format.call(this, tokenizedText);
304
+ } finally {
305
+ if (pushedKeys > 0) {
306
+ this[recursionStackSymbol].splice(-pushedKeys);
307
+ }
308
+ }
275
309
 
276
310
  return result;
277
311
  }
@@ -286,12 +320,14 @@ function format(text) {
286
320
  * @param {string} closeMarker
287
321
  * @return {string}
288
322
  */
289
- function tokenize(text, openMarker, closeMarker) {
323
+ function tokenize(text, openMarker, closeMarker, detectCycles) {
290
324
  const formatted = [];
325
+ const keys = [];
291
326
 
292
327
  const parameterAssignment = this[internalSymbol]["parameter"]["assignment"];
293
328
  const parameterDelimiter = this[internalSymbol]["parameter"]["delimiter"];
294
329
  const callbacks = this[internalSymbol]["callbacks"];
330
+ const activeKeys = this[recursionStackSymbol] || [];
295
331
 
296
332
  while (true) {
297
333
  const startIndex = text.indexOf(openMarker);
@@ -317,8 +353,16 @@ function tokenize(text, openMarker, closeMarker) {
317
353
  text.substring(insideStartIndex),
318
354
  openMarker,
319
355
  closeMarker,
356
+ detectCycles,
320
357
  );
321
- text = text.substring(0, insideStartIndex) + result;
358
+ text = text.substring(0, insideStartIndex) + result.text;
359
+ if (detectCycles && result.keys?.length) {
360
+ for (const key of result.keys) {
361
+ if (!keys.includes(key)) {
362
+ keys.push(key);
363
+ }
364
+ }
365
+ }
322
366
  endIndex = text.substring(openMarker.length).indexOf(closeMarker);
323
367
  if (endIndex !== -1) endIndex += openMarker.length;
324
368
  }
@@ -331,6 +375,16 @@ function tokenize(text, openMarker, closeMarker) {
331
375
  const key = text.substring(openMarker.length, endIndex);
332
376
  const parts = key.split(parameterDelimiter);
333
377
  const currentPipe = parts.shift();
378
+ const recursionKey = getRecursionKey(currentPipe);
379
+
380
+ if (detectCycles && recursionKey) {
381
+ if (activeKeys.includes(recursionKey)) {
382
+ throw new Error("cycle detected in formatter");
383
+ }
384
+ if (!keys.includes(recursionKey)) {
385
+ keys.push(recursionKey);
386
+ }
387
+ }
334
388
 
335
389
  this[workingDataSymbol] = extend(
336
390
  {},
@@ -372,5 +426,20 @@ function tokenize(text, openMarker, closeMarker) {
372
426
  text = text.substring(endIndex + closeMarker.length);
373
427
  }
374
428
 
375
- return formatted.join("");
429
+ return { text: formatted.join(""), keys };
430
+ }
431
+
432
+ /**
433
+ * @private
434
+ * @param {string} pipe
435
+ * @return {string|null}
436
+ */
437
+ function getRecursionKey(pipe) {
438
+ if (!pipe) return null;
439
+ const rawKey = pipe.split("|").shift().trim();
440
+ if (rawKey === "") return null;
441
+ if (rawKey.indexOf("path:") === 0) return rawKey.substring("path:".length);
442
+ if (rawKey.indexOf("static:") === 0)
443
+ return rawKey.substring("static:".length);
444
+ return rawKey;
376
445
  }
@@ -288,7 +288,11 @@ describe('Formatter', function () {
288
288
  mykey: '${mykey}',
289
289
  };
290
290
 
291
- const formatter = new Formatter(inputObj);
291
+ const formatter = new Formatter(inputObj, {
292
+ recursion: {
293
+ detectCycles: false,
294
+ },
295
+ });
292
296
 
293
297
  const text = '${mykey}';
294
298
  let formattedText = text;
@@ -300,8 +304,28 @@ describe('Formatter', function () {
300
304
 
301
305
  expect(() => formatter.format(formattedText)).to.throw('too deep nesting');
302
306
  });
307
+
308
+ it('should detect recursion cycles by default', () => {
309
+ const formatter = new Formatter({
310
+ a: '${b}',
311
+ b: '${a}',
312
+ });
313
+
314
+ expect(() => formatter.format('${a}')).to.throw('cycle detected in formatter');
315
+ });
316
+
317
+ it('should allow configuring max depth', () => {
318
+ const formatter = new Formatter({value: '${value}'}, {
319
+ recursion: {
320
+ maxDepth: 3,
321
+ detectCycles: false,
322
+ },
323
+ });
324
+
325
+ expect(() => formatter.format('${value}')).to.throw('too deep nesting');
326
+ });
303
327
 
304
328
  });
305
329
 
306
330
 
307
- });
331
+ });