boxwood 2.18.0 → 2.19.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.
@@ -26,10 +26,29 @@ const SAFE_METHODS = new Set([
26
26
  "endsWith",
27
27
  // Numbers
28
28
  "toFixed",
29
+ // Dates and numbers - locale-aware formatting, e.g. toLocaleDateString('pl-PL')
30
+ "toLocaleDateString",
31
+ "toLocaleTimeString",
32
+ "toLocaleString",
33
+ "toISOString",
29
34
  // Anything
30
35
  "toString",
31
36
  ])
32
37
 
38
+ // Property names that expose the prototype chain - never resolved, so a
39
+ // path like "x.constructor.constructor" cannot reach Function or pollute
40
+ // prototypes. Blocked for both dot and bracket access, at any depth.
41
+ const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"])
42
+
43
+ // Guard against pathological nesting like "{[[[[...]]]]}" - real expressions
44
+ // never nest this deeply, and unbounded recursion would exhaust the stack
45
+ const MAX_EXPRESSION_DEPTH = 50
46
+
47
+ // Real expressions are short (a path, a call, a small array literal); an
48
+ // oversized one is an attack or a typo. Cutting it off before the O(n)
49
+ // scanners run keeps a malicious "{[[[...]]]}" from allocating megabytes.
50
+ const MAX_EXPRESSION_LENGTH = 1000
51
+
33
52
  /**
34
53
  * Parse a single method argument literal
35
54
  * Supports numbers, quoted strings, booleans and null
@@ -56,7 +75,13 @@ function parseArgument(raw) {
56
75
 
57
76
  /**
58
77
  * Parse a path into segments of property accesses and method calls
59
- * e.g. "images.slice(0, 2)" -> [{type: "property", name: "images"}, {type: "method", name: "slice", args: [0, 2]}]
78
+ * e.g. "images.slice(0, limit)" -> [
79
+ * {type: "property", name: "images"},
80
+ * {type: "method", name: "slice", args: [
81
+ * {type: "literal", value: 0},
82
+ * {type: "path", path: "limit"},
83
+ * ]},
84
+ * ]
60
85
  * @param {string} path - The path to parse
61
86
  * @returns {Array|null} - Parsed segments or null if the path is malformed
62
87
  */
@@ -91,12 +116,14 @@ function parsePathSegments(path) {
91
116
  if (!name) return null
92
117
 
93
118
  if (path[i] === "(") {
94
- // Method call - collect literal arguments, respecting quoted strings
119
+ // Method call - collect arguments, respecting quoted strings and
120
+ // nested brackets/parentheses (e.g. "slice(0, items.indexOf('x'))")
95
121
  i++
96
122
  const rawArgs = []
97
123
  let current = ""
98
124
  let quote = null
99
125
  let closed = false
126
+ let depth = 0
100
127
  while (i < path.length) {
101
128
  const c = path[i]
102
129
  if (quote) {
@@ -111,16 +138,34 @@ function parsePathSegments(path) {
111
138
  i++
112
139
  continue
113
140
  }
114
- if (c === ",") {
115
- rawArgs.push(current)
116
- current = ""
141
+ if (c === "(" || c === "[") {
142
+ depth++
143
+ current += c
144
+ i++
145
+ continue
146
+ }
147
+ if (c === "]") {
148
+ depth--
149
+ current += c
117
150
  i++
118
151
  continue
119
152
  }
120
153
  if (c === ")") {
121
- closed = true
154
+ if (depth === 0) {
155
+ closed = true
156
+ i++
157
+ break
158
+ }
159
+ depth--
160
+ current += c
161
+ i++
162
+ continue
163
+ }
164
+ if (c === "," && depth === 0) {
165
+ rawArgs.push(current)
166
+ current = ""
122
167
  i++
123
- break
168
+ continue
124
169
  }
125
170
  current += c
126
171
  i++
@@ -130,11 +175,19 @@ function parsePathSegments(path) {
130
175
  rawArgs.push(current)
131
176
  }
132
177
 
178
+ // Arguments are literals or data paths, e.g. "slice(0, limit)" -
179
+ // paths are stored unresolved and looked up against data at resolve time
133
180
  const args = []
134
181
  for (const raw of rawArgs) {
135
182
  const parsed = parseArgument(raw)
136
- if (!parsed) return null
137
- args.push(parsed.value)
183
+ if (parsed) {
184
+ args.push({ type: "literal", value: parsed.value })
185
+ continue
186
+ }
187
+ const argPath = raw.trim()
188
+ const argSegments = parsePathSegments(argPath)
189
+ if (!argSegments || argSegments.length === 0) return null
190
+ args.push({ type: "path", path: argPath })
138
191
  }
139
192
  segments.push({ type: "method", name, args })
140
193
  } else {
@@ -147,9 +200,10 @@ function parsePathSegments(path) {
147
200
 
148
201
  /**
149
202
  * Resolve a path like "images[0].src", "user.name" or "images.slice(0, 2)" from a data object
150
- * Method calls are restricted to a whitelist of non-mutating methods with literal arguments
203
+ * Method calls are restricted to a whitelist of non-mutating methods
204
+ * Arguments are literals or data paths, e.g. "images.slice(0, limit)"
151
205
  * @param {Object} data - The data object to resolve the path from
152
- * @param {string} path - The path to resolve (e.g., "images[0].src", "images.slice(0, 2)")
206
+ * @param {string} path - The path to resolve (e.g., "images[0].src", "images.slice(0, limit)")
153
207
  * @returns {*} - The resolved value or undefined
154
208
  */
155
209
  function resolvePath(data, path) {
@@ -160,6 +214,9 @@ function resolvePath(data, path) {
160
214
 
161
215
  // Handle simple variable names (backwards compatibility)
162
216
  if (!/[.\[(]/.test(path)) {
217
+ if (FORBIDDEN_KEYS.has(path)) {
218
+ return undefined
219
+ }
163
220
  return data[path]
164
221
  }
165
222
 
@@ -173,6 +230,10 @@ function resolvePath(data, path) {
173
230
  if (current === null || current === undefined) {
174
231
  return undefined
175
232
  }
233
+ // Block prototype-chain access at every hop
234
+ if (FORBIDDEN_KEYS.has(segment.name)) {
235
+ return undefined
236
+ }
176
237
  if (segment.type === "method") {
177
238
  if (
178
239
  !SAFE_METHODS.has(segment.name) ||
@@ -180,8 +241,12 @@ function resolvePath(data, path) {
180
241
  ) {
181
242
  return undefined
182
243
  }
244
+ // Path arguments are full expressions, e.g. "slice(0, n - 1)"
245
+ const args = segment.args.map((arg) =>
246
+ arg.type === "path" ? resolveExpression(data, arg.path) : arg.value,
247
+ )
183
248
  try {
184
- current = current[segment.name](...segment.args)
249
+ current = current[segment.name](...args)
185
250
  } catch (error) {
186
251
  return undefined
187
252
  }
@@ -272,46 +337,166 @@ function splitArrayElements(inner) {
272
337
  }
273
338
 
274
339
  /**
275
- * Split an expression on top-level ?? operators
340
+ * Split an expression on a top-level two-character operator (?? or ||)
276
341
  * Respects quoted strings, brackets and parentheses
277
342
  * @param {string} expression - The expression to split
343
+ * @param {string} char - The operator character, repeated twice ("?" or "|")
344
+ * @returns {Array<string>} - Operands (a single element when there is no operator)
345
+ */
346
+ function splitDoubleOperator(expression, char) {
347
+ const operands = []
348
+ let current = ""
349
+ let depth = 0
350
+ let quote = null
351
+ let i = 0
352
+
353
+ while (i < expression.length) {
354
+ const c = expression[i]
355
+ if (quote) {
356
+ current += c
357
+ if (c === quote) quote = null
358
+ i++
359
+ continue
360
+ }
361
+ if (c === '"' || c === "'") {
362
+ quote = c
363
+ current += c
364
+ i++
365
+ continue
366
+ }
367
+ if (c === "[" || c === "(") depth++
368
+ if (c === "]" || c === ")") depth--
369
+ if (depth === 0 && c === char && expression[i + 1] === char) {
370
+ operands.push(current)
371
+ current = ""
372
+ i += 2
373
+ continue
374
+ }
375
+ current += c
376
+ i++
377
+ }
378
+
379
+ operands.push(current)
380
+ return operands
381
+ }
382
+
383
+ /**
384
+ * Split an expression on top-level ?? operators
385
+ * @param {string} expression - The expression to split
278
386
  * @returns {Array<string>} - Operands (a single element when there is no ??)
279
387
  */
280
388
  function splitNullishCoalescing(expression) {
389
+ return splitDoubleOperator(expression, "?")
390
+ }
391
+
392
+ /**
393
+ * Split an expression on top-level || operators
394
+ * @param {string} expression - The expression to split
395
+ * @returns {Array<string>} - Operands (a single element when there is no ||)
396
+ */
397
+ function splitLogicalOr(expression) {
398
+ return splitDoubleOperator(expression, "|")
399
+ }
400
+
401
+ /**
402
+ * Split an expression on top-level && operators
403
+ * @param {string} expression - The expression to split
404
+ * @returns {Array<string>} - Operands (a single element when there is no &&)
405
+ */
406
+ function splitLogicalAnd(expression) {
407
+ return splitDoubleOperator(expression, "&")
408
+ }
409
+
410
+ /**
411
+ * Split an expression on top-level arithmetic operators from the given set
412
+ * Operators must be surrounded by spaces ("i + 1", not "i+1") to stay
413
+ * unambiguous with negative literals and identifiers
414
+ * Respects quoted strings, brackets and parentheses
415
+ * @param {string} expression - The expression to split
416
+ * @param {Array<string>} characters - Operator characters, e.g. ["+", "-"]
417
+ * @returns {{operands: Array<string>, operators: Array<string>}}
418
+ */
419
+ function splitArithmetic(expression, characters) {
281
420
  const operands = []
421
+ const operators = []
282
422
  let current = ""
283
423
  let depth = 0
284
424
  let quote = null
285
425
  let i = 0
286
426
 
287
427
  while (i < expression.length) {
288
- const char = expression[i]
428
+ const c = expression[i]
289
429
  if (quote) {
290
- current += char
291
- if (char === quote) quote = null
430
+ current += c
431
+ if (c === quote) quote = null
292
432
  i++
293
433
  continue
294
434
  }
295
- if (char === '"' || char === "'") {
296
- quote = char
297
- current += char
435
+ if (c === '"' || c === "'") {
436
+ quote = c
437
+ current += c
298
438
  i++
299
439
  continue
300
440
  }
301
- if (char === "[" || char === "(") depth++
302
- if (char === "]" || char === ")") depth--
303
- if (depth === 0 && char === "?" && expression[i + 1] === "?") {
441
+ if (c === "[" || c === "(") depth++
442
+ if (c === "]" || c === ")") depth--
443
+ if (
444
+ depth === 0 &&
445
+ characters.includes(c) &&
446
+ expression[i - 1] === " " &&
447
+ expression[i + 1] === " "
448
+ ) {
304
449
  operands.push(current)
450
+ operators.push(c)
305
451
  current = ""
306
452
  i += 2
307
453
  continue
308
454
  }
309
- current += char
455
+ current += c
310
456
  i++
311
457
  }
312
458
 
313
459
  operands.push(current)
314
- return operands
460
+ return { operands, operators }
461
+ }
462
+
463
+ /**
464
+ * Apply a single arithmetic operator with JS semantics
465
+ * "+" works for numbers and strings (concatenation), the rest require numbers
466
+ * Nullish operands and NaN results resolve to undefined
467
+ */
468
+ function applyArithmetic(left, operator, right) {
469
+ if (
470
+ left === undefined ||
471
+ left === null ||
472
+ right === undefined ||
473
+ right === null
474
+ ) {
475
+ return undefined
476
+ }
477
+ if (operator === "+") {
478
+ const addable = (value) =>
479
+ typeof value === "number" || typeof value === "string"
480
+ if (!addable(left) || !addable(right)) {
481
+ return undefined
482
+ }
483
+ const result = left + right
484
+ return typeof result === "number" && Number.isNaN(result)
485
+ ? undefined
486
+ : result
487
+ }
488
+ if (typeof left !== "number" || typeof right !== "number") {
489
+ return undefined
490
+ }
491
+ let result
492
+ if (operator === "-") {
493
+ result = left - right
494
+ } else if (operator === "*") {
495
+ result = left * right
496
+ } else {
497
+ result = left / right
498
+ }
499
+ return Number.isNaN(result) ? undefined : result
315
500
  }
316
501
 
317
502
  /**
@@ -319,22 +504,52 @@ function splitNullishCoalescing(expression) {
319
504
  * Supports everything resolvePath does, plus:
320
505
  * - array literals whose elements are paths or literals,
321
506
  * e.g. "[images[0], images[2]]" or "['a', user.name]"
507
+ * - spread elements inside array literals,
508
+ * e.g. "[...images.slice(0, 2), ...images.slice(4)]"
322
509
  * - nullish coalescing with JS semantics (fallback only for null/undefined),
323
510
  * e.g. "name ?? 'Guest'" or "nickname ?? name ?? 'Guest'"
511
+ * - logical or with JS semantics (fallback for any falsy value),
512
+ * e.g. "title || 'Untitled'"
513
+ * - logical and with JS semantics (first falsy operand, or the last one),
514
+ * with JS precedence (&& binds tighter than ||)
515
+ * - arithmetic with space-separated + - * / operators and JS precedence,
516
+ * e.g. "i + 1", "items.length - 1", "price * quantity"
517
+ * Mixing ?? with || or && in one expression is a syntax error in JS
518
+ * (parentheses are required) - such expressions resolve to undefined
324
519
  * @param {Object} data - The data object to resolve the expression from
325
520
  * @param {string} expression - The expression to resolve
326
521
  * @returns {*} - The resolved value or undefined
327
522
  */
328
- function resolveExpression(data, expression) {
523
+ function resolveExpression(data, expression, depth = 0) {
524
+ // Bail out on pathological nesting before the stack is exhausted
525
+ if (depth > MAX_EXPRESSION_DEPTH) {
526
+ return undefined
527
+ }
528
+ // Oversized expressions are never legitimate - reject before scanning
529
+ if (expression.length > MAX_EXPRESSION_LENGTH) {
530
+ return undefined
531
+ }
329
532
  const trimmed = expression.trim()
533
+ const recurse = (raw) => resolveExpression(data, raw, depth + 1)
330
534
 
331
- const operands = splitNullishCoalescing(trimmed)
332
- if (operands.length > 1) {
535
+ const nullishOperands = splitNullishCoalescing(trimmed)
536
+ const orOperands = splitLogicalOr(trimmed)
537
+ const andOperands = splitLogicalAnd(trimmed)
538
+
539
+ // Mixing ?? with || or && without parentheses is invalid, same as in JS
540
+ if (
541
+ nullishOperands.length > 1 &&
542
+ (orOperands.length > 1 || andOperands.length > 1)
543
+ ) {
544
+ return undefined
545
+ }
546
+
547
+ if (nullishOperands.length > 1) {
333
548
  let value
334
- for (const operand of operands) {
549
+ for (const operand of nullishOperands) {
335
550
  const raw = operand.trim()
336
551
  const literal = parseArgument(raw)
337
- value = literal ? literal.value : resolveExpression(data, raw)
552
+ value = literal ? literal.value : recurse(raw)
338
553
  if (value !== undefined && value !== null) {
339
554
  return value
340
555
  }
@@ -342,16 +557,81 @@ function resolveExpression(data, expression) {
342
557
  return value
343
558
  }
344
559
 
560
+ // || first so && binds tighter (JS precedence) - each || operand may
561
+ // contain && which the recursive call resolves
562
+ if (orOperands.length > 1) {
563
+ let value
564
+ for (const operand of orOperands) {
565
+ const raw = operand.trim()
566
+ const literal = parseArgument(raw)
567
+ value = literal ? literal.value : recurse(raw)
568
+ if (value) {
569
+ return value
570
+ }
571
+ }
572
+ return value
573
+ }
574
+
575
+ if (andOperands.length > 1) {
576
+ let value
577
+ for (const operand of andOperands) {
578
+ const raw = operand.trim()
579
+ const literal = parseArgument(raw)
580
+ value = literal ? literal.value : recurse(raw)
581
+ if (!value) {
582
+ return value
583
+ }
584
+ }
585
+ return value
586
+ }
587
+
588
+ // Arithmetic - operators must be surrounded by spaces
589
+ // Sums split first so * and / bind tighter (their operands resolve
590
+ // through the recursive call), chains evaluate left to right
591
+ const sums = splitArithmetic(trimmed, ["+", "-"])
592
+ const arithmetic =
593
+ sums.operators.length > 0 ? sums : splitArithmetic(trimmed, ["*", "/"])
594
+ if (arithmetic.operators.length > 0) {
595
+ const resolveOperand = (raw) => {
596
+ const operand = raw.trim()
597
+ const literal = parseArgument(operand)
598
+ return literal ? literal.value : recurse(operand)
599
+ }
600
+ let value = resolveOperand(arithmetic.operands[0])
601
+ for (let index = 0; index < arithmetic.operators.length; index++) {
602
+ value = applyArithmetic(
603
+ value,
604
+ arithmetic.operators[index],
605
+ resolveOperand(arithmetic.operands[index + 1]),
606
+ )
607
+ if (value === undefined) {
608
+ return undefined
609
+ }
610
+ }
611
+ return value
612
+ }
613
+
345
614
  if (isArrayLiteral(trimmed)) {
346
615
  const inner = trimmed.substring(1, trimmed.length - 1)
347
- return splitArrayElements(inner).map((element) => {
616
+ const result = []
617
+ for (const element of splitArrayElements(inner)) {
348
618
  const raw = element.trim()
349
- const literal = parseArgument(raw)
350
- if (literal) {
351
- return literal.value
619
+ if (raw.startsWith("...")) {
620
+ // Spread element - flatten the resolved array into the result,
621
+ // e.g. "[...images.slice(0, 2), ...images.slice(4)]"
622
+ // Nullish values disappear, other non-array values are kept as-is
623
+ const value = recurse(raw.substring(3))
624
+ if (Array.isArray(value)) {
625
+ result.push(...value)
626
+ } else if (value !== undefined && value !== null) {
627
+ result.push(value)
628
+ }
629
+ continue
352
630
  }
353
- return resolveExpression(data, raw)
354
- })
631
+ const literal = parseArgument(raw)
632
+ result.push(literal ? literal.value : recurse(raw))
633
+ }
634
+ return result
355
635
  }
356
636
 
357
637
  return resolvePath(data, trimmed)
@@ -449,9 +729,14 @@ module.exports = {
449
729
  resolveExpression,
450
730
  // Parsing internals, exported for the Prose validator
451
731
  SAFE_METHODS,
732
+ FORBIDDEN_KEYS,
733
+ MAX_EXPRESSION_LENGTH,
452
734
  parseArgument,
453
735
  parsePathSegments,
454
736
  splitNullishCoalescing,
737
+ splitLogicalOr,
738
+ splitLogicalAnd,
739
+ splitArithmetic,
455
740
  isArrayLiteral,
456
741
  splitArrayElements,
457
742
  }
@@ -0,0 +1,39 @@
1
+ // Characters that do not decompose to ASCII via NFD normalization
2
+ const CHARACTER_MAP = {
3
+ ł: "l",
4
+ Ł: "L",
5
+ ø: "o",
6
+ Ø: "O",
7
+ đ: "d",
8
+ Đ: "D",
9
+ ß: "ss",
10
+ æ: "ae",
11
+ Æ: "AE",
12
+ œ: "oe",
13
+ Œ: "OE",
14
+ }
15
+
16
+ // Combining diacritical marks left over after NFD normalization
17
+ const DIACRITICS_REGEXP = /[̀-ͯ]/g
18
+
19
+ /**
20
+ * Convert a heading text into a URL-friendly anchor slug
21
+ * "Mój tytuł" -> "moj-tytul"
22
+ * @param {string} text - The text to slugify
23
+ * @returns {string} - The slug (may be empty for non-alphanumeric input)
24
+ */
25
+ function slugify(text) {
26
+ if (!text || typeof text !== "string") {
27
+ return ""
28
+ }
29
+
30
+ return text
31
+ .replace(/[łŁøØđĐßæÆœŒ]/g, (character) => CHARACTER_MAP[character])
32
+ .normalize("NFD")
33
+ .replace(DIACRITICS_REGEXP, "")
34
+ .toLowerCase()
35
+ .replace(/[^a-z0-9]+/g, "-")
36
+ .replace(/^-+|-+$/g, "")
37
+ }
38
+
39
+ module.exports = { slugify }