boxwood 2.16.0 → 2.18.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,457 @@
1
+ // Non-mutating methods that can be called inside a path, e.g. {images.slice(0, 2)}
2
+ // Only methods without side effects are allowed - arbitrary code is never evaluated
3
+ const SAFE_METHODS = new Set([
4
+ // Arrays and strings
5
+ "slice",
6
+ "at",
7
+ "concat",
8
+ "includes",
9
+ "indexOf",
10
+ "lastIndexOf",
11
+ // Arrays
12
+ "join",
13
+ // Strings
14
+ "toUpperCase",
15
+ "toLowerCase",
16
+ "trim",
17
+ "charAt",
18
+ "substring",
19
+ "split",
20
+ "padStart",
21
+ "padEnd",
22
+ "repeat",
23
+ "replace",
24
+ "replaceAll",
25
+ "startsWith",
26
+ "endsWith",
27
+ // Numbers
28
+ "toFixed",
29
+ // Anything
30
+ "toString",
31
+ ])
32
+
33
+ /**
34
+ * Parse a single method argument literal
35
+ * Supports numbers, quoted strings, booleans and null
36
+ * @param {string} raw - The raw argument text
37
+ * @returns {{value: *}|null} - Wrapped value or null if not a valid literal
38
+ */
39
+ function parseArgument(raw) {
40
+ const arg = raw.trim()
41
+ if (/^-?\d+(\.\d+)?$/.test(arg)) {
42
+ return { value: Number(arg) }
43
+ }
44
+ if (
45
+ arg.length >= 2 &&
46
+ ((arg.startsWith('"') && arg.endsWith('"')) ||
47
+ (arg.startsWith("'") && arg.endsWith("'")))
48
+ ) {
49
+ return { value: arg.substring(1, arg.length - 1) }
50
+ }
51
+ if (arg === "true") return { value: true }
52
+ if (arg === "false") return { value: false }
53
+ if (arg === "null") return { value: null }
54
+ return null
55
+ }
56
+
57
+ /**
58
+ * 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]}]
60
+ * @param {string} path - The path to parse
61
+ * @returns {Array|null} - Parsed segments or null if the path is malformed
62
+ */
63
+ function parsePathSegments(path) {
64
+ const segments = []
65
+ let i = 0
66
+
67
+ while (i < path.length) {
68
+ const char = path[i]
69
+
70
+ if (char === ".") {
71
+ i++
72
+ continue
73
+ }
74
+
75
+ if (char === "[") {
76
+ const close = path.indexOf("]", i)
77
+ if (close === -1) return null
78
+ const index = path.substring(i + 1, close).trim()
79
+ if (!/^\d+$/.test(index)) return null
80
+ segments.push({ type: "property", name: index })
81
+ i = close + 1
82
+ continue
83
+ }
84
+
85
+ // Read an identifier up to the next separator
86
+ const start = i
87
+ while (i < path.length && !/[.\[(]/.test(path[i])) {
88
+ i++
89
+ }
90
+ const name = path.substring(start, i).trim()
91
+ if (!name) return null
92
+
93
+ if (path[i] === "(") {
94
+ // Method call - collect literal arguments, respecting quoted strings
95
+ i++
96
+ const rawArgs = []
97
+ let current = ""
98
+ let quote = null
99
+ let closed = false
100
+ while (i < path.length) {
101
+ const c = path[i]
102
+ if (quote) {
103
+ current += c
104
+ if (c === quote) quote = null
105
+ i++
106
+ continue
107
+ }
108
+ if (c === '"' || c === "'") {
109
+ quote = c
110
+ current += c
111
+ i++
112
+ continue
113
+ }
114
+ if (c === ",") {
115
+ rawArgs.push(current)
116
+ current = ""
117
+ i++
118
+ continue
119
+ }
120
+ if (c === ")") {
121
+ closed = true
122
+ i++
123
+ break
124
+ }
125
+ current += c
126
+ i++
127
+ }
128
+ if (!closed) return null
129
+ if (current.trim() || rawArgs.length > 0) {
130
+ rawArgs.push(current)
131
+ }
132
+
133
+ const args = []
134
+ for (const raw of rawArgs) {
135
+ const parsed = parseArgument(raw)
136
+ if (!parsed) return null
137
+ args.push(parsed.value)
138
+ }
139
+ segments.push({ type: "method", name, args })
140
+ } else {
141
+ segments.push({ type: "property", name })
142
+ }
143
+ }
144
+
145
+ return segments
146
+ }
147
+
148
+ /**
149
+ * 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
151
+ * @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)")
153
+ * @returns {*} - The resolved value or undefined
154
+ */
155
+ function resolvePath(data, path) {
156
+ // Handle null or undefined data
157
+ if (data === null || data === undefined) {
158
+ return undefined
159
+ }
160
+
161
+ // Handle simple variable names (backwards compatibility)
162
+ if (!/[.\[(]/.test(path)) {
163
+ return data[path]
164
+ }
165
+
166
+ const segments = parsePathSegments(path)
167
+ if (!segments) {
168
+ return undefined
169
+ }
170
+
171
+ let current = data
172
+ for (const segment of segments) {
173
+ if (current === null || current === undefined) {
174
+ return undefined
175
+ }
176
+ if (segment.type === "method") {
177
+ if (
178
+ !SAFE_METHODS.has(segment.name) ||
179
+ typeof current[segment.name] !== "function"
180
+ ) {
181
+ return undefined
182
+ }
183
+ try {
184
+ current = current[segment.name](...segment.args)
185
+ } catch (error) {
186
+ return undefined
187
+ }
188
+ } else {
189
+ current = current[segment.name]
190
+ }
191
+ }
192
+
193
+ return current
194
+ }
195
+
196
+ /**
197
+ * Check if an expression is an array literal like "[images[0], images[2]]"
198
+ * The opening bracket must be closed at the very end of the expression
199
+ * Pure numeric indexes like "[0]" keep their index-access meaning
200
+ * @param {string} expression - The expression to check
201
+ * @returns {boolean} - True if the expression is an array literal
202
+ */
203
+ function isArrayLiteral(expression) {
204
+ if (!expression.startsWith("[") || !expression.endsWith("]")) {
205
+ return false
206
+ }
207
+ const inner = expression.substring(1, expression.length - 1).trim()
208
+ if (/^\d+$/.test(inner)) {
209
+ return false
210
+ }
211
+ let depth = 0
212
+ let quote = null
213
+ for (let i = 0; i < expression.length; i++) {
214
+ const char = expression[i]
215
+ if (quote) {
216
+ if (char === quote) quote = null
217
+ continue
218
+ }
219
+ if (char === '"' || char === "'") {
220
+ quote = char
221
+ continue
222
+ }
223
+ if (char === "[") depth++
224
+ if (char === "]") {
225
+ depth--
226
+ if (depth === 0) {
227
+ return i === expression.length - 1
228
+ }
229
+ }
230
+ }
231
+ return false
232
+ }
233
+
234
+ /**
235
+ * Split the inner part of an array literal into elements on top-level commas
236
+ * Respects nested brackets, parentheses and quoted strings
237
+ * @param {string} inner - The content between the outer brackets
238
+ * @returns {Array<string>} - Raw element expressions
239
+ */
240
+ function splitArrayElements(inner) {
241
+ const elements = []
242
+ let current = ""
243
+ let depth = 0
244
+ let quote = null
245
+
246
+ for (const char of inner) {
247
+ if (quote) {
248
+ current += char
249
+ if (char === quote) quote = null
250
+ continue
251
+ }
252
+ if (char === '"' || char === "'") {
253
+ quote = char
254
+ current += char
255
+ continue
256
+ }
257
+ if (char === "[" || char === "(") depth++
258
+ if (char === "]" || char === ")") depth--
259
+ if (char === "," && depth === 0) {
260
+ elements.push(current)
261
+ current = ""
262
+ continue
263
+ }
264
+ current += char
265
+ }
266
+
267
+ if (current.trim()) {
268
+ elements.push(current)
269
+ }
270
+
271
+ return elements
272
+ }
273
+
274
+ /**
275
+ * Split an expression on top-level ?? operators
276
+ * Respects quoted strings, brackets and parentheses
277
+ * @param {string} expression - The expression to split
278
+ * @returns {Array<string>} - Operands (a single element when there is no ??)
279
+ */
280
+ function splitNullishCoalescing(expression) {
281
+ const operands = []
282
+ let current = ""
283
+ let depth = 0
284
+ let quote = null
285
+ let i = 0
286
+
287
+ while (i < expression.length) {
288
+ const char = expression[i]
289
+ if (quote) {
290
+ current += char
291
+ if (char === quote) quote = null
292
+ i++
293
+ continue
294
+ }
295
+ if (char === '"' || char === "'") {
296
+ quote = char
297
+ current += char
298
+ i++
299
+ continue
300
+ }
301
+ if (char === "[" || char === "(") depth++
302
+ if (char === "]" || char === ")") depth--
303
+ if (depth === 0 && char === "?" && expression[i + 1] === "?") {
304
+ operands.push(current)
305
+ current = ""
306
+ i += 2
307
+ continue
308
+ }
309
+ current += char
310
+ i++
311
+ }
312
+
313
+ operands.push(current)
314
+ return operands
315
+ }
316
+
317
+ /**
318
+ * Resolve an expression from a data object
319
+ * Supports everything resolvePath does, plus:
320
+ * - array literals whose elements are paths or literals,
321
+ * e.g. "[images[0], images[2]]" or "['a', user.name]"
322
+ * - nullish coalescing with JS semantics (fallback only for null/undefined),
323
+ * e.g. "name ?? 'Guest'" or "nickname ?? name ?? 'Guest'"
324
+ * @param {Object} data - The data object to resolve the expression from
325
+ * @param {string} expression - The expression to resolve
326
+ * @returns {*} - The resolved value or undefined
327
+ */
328
+ function resolveExpression(data, expression) {
329
+ const trimmed = expression.trim()
330
+
331
+ const operands = splitNullishCoalescing(trimmed)
332
+ if (operands.length > 1) {
333
+ let value
334
+ for (const operand of operands) {
335
+ const raw = operand.trim()
336
+ const literal = parseArgument(raw)
337
+ value = literal ? literal.value : resolveExpression(data, raw)
338
+ if (value !== undefined && value !== null) {
339
+ return value
340
+ }
341
+ }
342
+ return value
343
+ }
344
+
345
+ if (isArrayLiteral(trimmed)) {
346
+ const inner = trimmed.substring(1, trimmed.length - 1)
347
+ return splitArrayElements(inner).map((element) => {
348
+ const raw = element.trim()
349
+ const literal = parseArgument(raw)
350
+ if (literal) {
351
+ return literal.value
352
+ }
353
+ return resolveExpression(data, raw)
354
+ })
355
+ }
356
+
357
+ return resolvePath(data, trimmed)
358
+ }
359
+
360
+ /**
361
+ * Replace {variableName} placeholders in text with actual values from data
362
+ * Supports:
363
+ * - Simple variables: {name}
364
+ * - Array indexing: {images[0]}
365
+ * - Property access: {user.name}
366
+ * - Combined: {images[0].src}
367
+ * - Fallbacks: {name ?? "Guest"}
368
+ * @param {string} text - Text containing variable placeholders
369
+ * @param {Object} data - Data object with variable values
370
+ * @returns {string|Array} - Text with variables replaced, or array if mixed content
371
+ */
372
+ function replaceVariables(text, data) {
373
+ if (!text || typeof text !== "string") {
374
+ return text
375
+ }
376
+
377
+ if (!data || typeof data !== "object") {
378
+ return text
379
+ }
380
+
381
+ // Check if text contains any variables
382
+ if (!text.includes("{") || !text.includes("}")) {
383
+ return text
384
+ }
385
+
386
+ const result = []
387
+ let i = 0
388
+ let lastIndex = 0
389
+
390
+ while (i < text.length) {
391
+ if (text[i] === "\\" && text[i + 1] === "{") {
392
+ // Escaped opening brace
393
+ result.push(text.substring(lastIndex, i))
394
+ result.push("{")
395
+ i += 2
396
+ lastIndex = i
397
+ continue
398
+ }
399
+
400
+ if (text[i] === "{") {
401
+ const closeIndex = text.indexOf("}", i + 1)
402
+
403
+ if (closeIndex !== -1) {
404
+ // Found a variable placeholder
405
+ const variablePath = text.substring(i + 1, closeIndex).trim()
406
+
407
+ if (variablePath) {
408
+ // Add text before the variable
409
+ if (i > lastIndex) {
410
+ result.push(text.substring(lastIndex, i))
411
+ }
412
+
413
+ // Resolve the expression (supports paths like "images[0].src",
414
+ // safe method calls, array literals and ?? fallbacks)
415
+ const value = resolveExpression(data, variablePath)
416
+ if (value !== undefined && value !== null) {
417
+ result.push(String(value))
418
+ } else {
419
+ // Variable not found, keep the placeholder
420
+ result.push(text.substring(i, closeIndex + 1))
421
+ }
422
+
423
+ i = closeIndex + 1
424
+ lastIndex = i
425
+ continue
426
+ }
427
+ }
428
+ }
429
+
430
+ i++
431
+ }
432
+
433
+ // Add remaining text
434
+ if (lastIndex < text.length) {
435
+ result.push(text.substring(lastIndex))
436
+ }
437
+
438
+ // If no substitutions were made, return original text
439
+ if (result.length === 0) {
440
+ return text
441
+ }
442
+
443
+ return result.join("")
444
+ }
445
+
446
+ module.exports = {
447
+ replaceVariables,
448
+ resolvePath,
449
+ resolveExpression,
450
+ // Parsing internals, exported for the Prose validator
451
+ SAFE_METHODS,
452
+ parseArgument,
453
+ parsePathSegments,
454
+ splitNullishCoalescing,
455
+ isArrayLiteral,
456
+ splitArrayElements,
457
+ }
@@ -1,218 +0,0 @@
1
- /**
2
- * Parse a custom component tag from markdown
3
- * Supports both <Component attr="value"> and <Component attr={variable}>
4
- * @param {string} line - The line to parse
5
- * @param {Object} customComponents - Map of component names to functions
6
- * @returns {Object|null} - Parsed tag info or null if not a custom tag
7
- */
8
- function parseCustomTag(line, customComponents) {
9
- if (!customComponents || typeof customComponents !== "object") {
10
- return null
11
- }
12
-
13
- const trimmed = line.trim()
14
-
15
- // Check for single-line tag: <tag>content</tag> or <tag attr="value">content</tag>
16
- const singleLineMatch = trimmed.match(
17
- /^<([A-Za-z][A-Za-z0-9-]*)(\s+[^>]*)?>(.+?)<\/\1>\s*$/,
18
- )
19
- if (singleLineMatch) {
20
- const [, tagName, attributesStr, content] = singleLineMatch
21
- const component = customComponents[tagName]
22
- if (component) {
23
- const attributes = parseAttributes(attributesStr || "")
24
- return {
25
- type: "custom-component-single-line",
26
- tagName,
27
- component,
28
- attributes,
29
- content,
30
- selfClosing: false,
31
- }
32
- }
33
- }
34
-
35
- // Check for self-closing tag: <ComponentName ... /> (space before / is optional)
36
- const selfClosingMatch = trimmed.match(
37
- /^<([A-Za-z][A-Za-z0-9-]*)(\s+[^>]*)?\s*\/>\s*$/,
38
- )
39
- if (selfClosingMatch) {
40
- const [, tagName, attributesStr] = selfClosingMatch
41
- const component = customComponents[tagName]
42
- if (component) {
43
- const attributes = parseAttributes(attributesStr || "")
44
- return {
45
- type: "custom-component",
46
- tagName,
47
- component,
48
- attributes,
49
- selfClosing: true,
50
- }
51
- }
52
- }
53
-
54
- // Check for opening tag: <ComponentName ...>
55
- const openTagMatch = trimmed.match(
56
- /^<([A-Za-z][A-Za-z0-9-]*)(\s+[^>]*)?>\s*$/,
57
- )
58
- if (openTagMatch) {
59
- const [, tagName, attributesStr] = openTagMatch
60
- const component = customComponents[tagName]
61
- if (component) {
62
- const attributes = parseAttributes(attributesStr || "")
63
- return {
64
- type: "custom-component-open",
65
- tagName,
66
- component,
67
- attributes,
68
- selfClosing: false,
69
- }
70
- }
71
- }
72
-
73
- // Check for closing tag: </ComponentName>
74
- const closeTagMatch = trimmed.match(/^<\/([A-Za-z][A-Za-z0-9-]*)>\s*$/)
75
- if (closeTagMatch) {
76
- const [, tagName] = closeTagMatch
77
- const component = customComponents[tagName]
78
- if (component) {
79
- return {
80
- type: "custom-component-close",
81
- tagName,
82
- component,
83
- }
84
- }
85
- }
86
-
87
- return null
88
- }
89
-
90
- /**
91
- * Parse attributes from a tag string
92
- * Supports: attr="value", attr='value', attr={variable}, attr
93
- * @param {string} attributesStr - The attributes string
94
- * @returns {Object} - Parsed attributes
95
- */
96
- function parseAttributes(attributesStr) {
97
- const attributes = {}
98
- if (!attributesStr || !attributesStr.trim()) {
99
- return attributes
100
- }
101
-
102
- const str = attributesStr.trim()
103
- let i = 0
104
-
105
- while (i < str.length) {
106
- // Skip whitespace
107
- while (i < str.length && /\s/.test(str[i])) {
108
- i++
109
- }
110
-
111
- if (i >= str.length) break
112
-
113
- // Parse attribute name
114
- let nameStart = i
115
- while (i < str.length && /[a-zA-Z0-9-_:]/.test(str[i])) {
116
- i++
117
- }
118
-
119
- const name = str.substring(nameStart, i)
120
- if (!name) break
121
-
122
- // Skip whitespace after name
123
- while (i < str.length && /\s/.test(str[i])) {
124
- i++
125
- }
126
-
127
- // Check for =
128
- if (i >= str.length || str[i] !== "=") {
129
- // Boolean attribute
130
- attributes[name] = true
131
- continue
132
- }
133
-
134
- i++ // Skip =
135
-
136
- // Skip whitespace after =
137
- while (i < str.length && /\s/.test(str[i])) {
138
- i++
139
- }
140
-
141
- if (i >= str.length) break
142
-
143
- // Parse value
144
- const quote = str[i]
145
-
146
- if (quote === '"' || quote === "'") {
147
- // Quoted string
148
- i++ // Skip opening quote
149
- let value = ""
150
- while (i < str.length && str[i] !== quote) {
151
- if (str[i] === "\\" && i + 1 < str.length) {
152
- // Escaped character - add the next character literally
153
- value += str[i + 1]
154
- i += 2
155
- } else {
156
- value += str[i]
157
- i++
158
- }
159
- }
160
- attributes[name] = value
161
- if (i < str.length) i++ // Skip closing quote
162
- } else if (str[i] === "{") {
163
- // Variable reference
164
- i++ // Skip {
165
- const valueStart = i
166
- while (i < str.length && str[i] !== "}") {
167
- i++
168
- }
169
- const variableName = str.substring(valueStart, i).trim()
170
- attributes[name] = { __variable: variableName }
171
- if (i < str.length) i++ // Skip }
172
- } else {
173
- // Unquoted value (until space or end)
174
- const valueStart = i
175
- while (i < str.length && !/\s/.test(str[i])) {
176
- i++
177
- }
178
- const value = str.substring(valueStart, i)
179
- attributes[name] = value
180
- }
181
- }
182
-
183
- return attributes
184
- }
185
-
186
- /**
187
- * Resolve attributes by replacing variable references with actual values
188
- * @param {Object} attributes - Attributes with possible variable references
189
- * @param {Object} data - Data object containing variable values
190
- * @returns {Object} - Resolved attributes
191
- */
192
- function resolveAttributes(attributes, data) {
193
- if (!attributes || typeof attributes !== "object") {
194
- return {}
195
- }
196
-
197
- const resolved = {}
198
- for (const [key, value] of Object.entries(attributes)) {
199
- if (value && typeof value === "object" && value.__variable) {
200
- // Resolve variable
201
- const variableName = value.__variable
202
- resolved[key] =
203
- data && data[variableName] !== undefined
204
- ? data[variableName]
205
- : undefined
206
- } else {
207
- resolved[key] = value
208
- }
209
- }
210
-
211
- return resolved
212
- }
213
-
214
- module.exports = {
215
- parseCustomTag,
216
- parseAttributes,
217
- resolveAttributes,
218
- }