boxwood 2.17.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,304 @@
1
+ const {
2
+ SAFE_METHODS,
3
+ parseArgument,
4
+ parsePathSegments,
5
+ splitNullishCoalescing,
6
+ isArrayLiteral,
7
+ splitArrayElements,
8
+ } = require("../../utilities/replaceVariables")
9
+
10
+ // Matches the {#each} header accepted by processLoops
11
+ const EACH_HEADER_REGEXP =
12
+ /^#each\s+([a-zA-Z0-9_.[\]]+)(?:\s+as\s+([a-zA-Z0-9_]+)(?:\s*,\s*([a-zA-Z0-9_]+))?)?$/
13
+
14
+ // Matches the comparison conditions accepted by processConditionals
15
+ const CONDITION_REGEXP = /^(.+?)\s*(>=|<=|>|<|==|!=)\s*(.+)$/
16
+
17
+ const INLINE_CODE_REGEXP = /(`+)([^`]+)\1/g
18
+ const BRACE_REGEXP = /\{([^{}]*)\}/g
19
+ const COMPONENT_TAG_REGEXP = /^<\/?([A-Z][A-Za-z0-9-]*)/
20
+ const ATTRIBUTE_EXPRESSION_REGEXP = /=\s*(?:"\{([^}"]*)\}"|'\{([^}']*)\}'|\{([^}]*)\})/g
21
+
22
+ /**
23
+ * Validate a Prose template and report problems that the renderer
24
+ * would otherwise tolerate silently
25
+ * @param {string} text - The prose template text
26
+ * @param {Object} options - { data, components } - both optional
27
+ * - data: when provided, unknown top-level variables are reported
28
+ * - components: map of known components (including builtins)
29
+ * @returns {Array<{line: number, type: string, message: string}>} - Problems found
30
+ */
31
+ function validate(text, options = {}) {
32
+ const issues = []
33
+
34
+ if (!text || typeof text !== "string") {
35
+ return issues
36
+ }
37
+
38
+ const { data, components } = options
39
+
40
+ // Track open {#if}/{#each} blocks; each frame: { type, line, vars }
41
+ const stack = []
42
+
43
+ const report = (line, type, message) => {
44
+ issues.push({ line, type, message })
45
+ }
46
+
47
+ const scopeVariables = () => {
48
+ const vars = new Set()
49
+ for (const frame of stack) {
50
+ if (frame.vars) {
51
+ for (const name of frame.vars) vars.add(name)
52
+ }
53
+ }
54
+ return vars
55
+ }
56
+
57
+ const validatePath = (path, line) => {
58
+ const segments = parsePathSegments(path)
59
+ if (!segments || segments.length === 0) {
60
+ report(line, "malformed-expression", `Malformed expression: {${path}}`)
61
+ return
62
+ }
63
+ for (const segment of segments) {
64
+ if (segment.type === "method" && !SAFE_METHODS.has(segment.name)) {
65
+ report(
66
+ line,
67
+ "unsafe-method",
68
+ `Method not allowed: ${segment.name}() - allowed methods: ${[...SAFE_METHODS].join(", ")}`,
69
+ )
70
+ }
71
+ }
72
+ const root = segments[0]
73
+ if (
74
+ data &&
75
+ typeof data === "object" &&
76
+ root.type === "property" &&
77
+ !/^\d+$/.test(root.name) &&
78
+ !(root.name in data) &&
79
+ !scopeVariables().has(root.name)
80
+ ) {
81
+ report(line, "unknown-variable", `Unknown variable: ${root.name}`)
82
+ }
83
+ }
84
+
85
+ const validateExpression = (expression, line) => {
86
+ const trimmed = expression.trim()
87
+ if (!trimmed) return
88
+
89
+ for (const operand of splitNullishCoalescing(trimmed)) {
90
+ const raw = operand.trim()
91
+ if (!raw) {
92
+ report(
93
+ line,
94
+ "malformed-expression",
95
+ `Malformed expression: {${trimmed}}`,
96
+ )
97
+ continue
98
+ }
99
+ if (parseArgument(raw)) {
100
+ continue
101
+ }
102
+ if (isArrayLiteral(raw)) {
103
+ for (const element of splitArrayElements(raw.substring(1, raw.length - 1))) {
104
+ validateExpression(element, line)
105
+ }
106
+ continue
107
+ }
108
+ validatePath(raw, line)
109
+ }
110
+ }
111
+
112
+ const validateCondition = (condition, line) => {
113
+ let expr = condition.trim()
114
+ if (expr.startsWith("!")) {
115
+ expr = expr.substring(1).trim()
116
+ }
117
+ const match = expr.match(CONDITION_REGEXP)
118
+ if (match) {
119
+ const [, left, , right] = match
120
+ validateExpression(left, line)
121
+ const rawRight = right.trim()
122
+ if (
123
+ !parseArgument(rawRight) &&
124
+ rawRight !== "true" &&
125
+ rawRight !== "false" &&
126
+ rawRight !== "null"
127
+ ) {
128
+ validateExpression(rawRight, line)
129
+ }
130
+ } else {
131
+ validateExpression(expr, line)
132
+ }
133
+ }
134
+
135
+ const handleControlTag = (content, line) => {
136
+ if (content.startsWith("#each")) {
137
+ const header = content.match(EACH_HEADER_REGEXP)
138
+ if (!header) {
139
+ report(line, "malformed-block", `Malformed block: {${content}}`)
140
+ // Push anyway so the matching {/each} does not report a false error
141
+ stack.push({ type: "each", line, vars: ["item"] })
142
+ return
143
+ }
144
+ const [, arrayPath, itemName, indexName] = header
145
+ const vars = [itemName || "item"]
146
+ if (indexName) vars.push(indexName)
147
+ validateExpression(arrayPath, line)
148
+ stack.push({ type: "each", line, vars })
149
+ return
150
+ }
151
+
152
+ if (content.startsWith("#elseif")) {
153
+ const top = stack[stack.length - 1]
154
+ if (!top || top.type !== "if") {
155
+ report(
156
+ line,
157
+ "unmatched-block",
158
+ "{#elseif} without a matching {#if}",
159
+ )
160
+ }
161
+ validateCondition(content.substring("#elseif".length), line)
162
+ return
163
+ }
164
+
165
+ if (content === "#else") {
166
+ if (stack.length === 0) {
167
+ report(
168
+ line,
169
+ "unmatched-block",
170
+ "{#else} without a matching {#if} or {#each}",
171
+ )
172
+ }
173
+ return
174
+ }
175
+
176
+ if (content.startsWith("#if")) {
177
+ stack.push({ type: "if", line })
178
+ validateCondition(content.substring("#if".length), line)
179
+ return
180
+ }
181
+
182
+ if (content === "/each" || content === "/if") {
183
+ const expected = content === "/each" ? "each" : "if"
184
+ const top = stack[stack.length - 1]
185
+ if (!top) {
186
+ report(line, "unmatched-block", `{${content}} without a matching {#${expected}}`)
187
+ } else if (top.type !== expected) {
188
+ report(
189
+ line,
190
+ "unmatched-block",
191
+ `{${content}} closes {#${top.type}} opened on line ${top.line}`,
192
+ )
193
+ stack.pop()
194
+ } else {
195
+ stack.pop()
196
+ }
197
+ return
198
+ }
199
+
200
+ report(line, "malformed-block", `Unknown block tag: {${content}}`)
201
+ }
202
+
203
+ const lines = text.split("\n")
204
+ let inFence = false
205
+ let fenceLine = 0
206
+ let inComment = false
207
+ let commentLine = 0
208
+
209
+ for (let i = 0; i < lines.length; i++) {
210
+ const lineNumber = i + 1
211
+ let line = lines[i]
212
+
213
+ // Fenced code blocks are literal content - skip them entirely
214
+ if (line.trim().startsWith("```")) {
215
+ inFence = !inFence
216
+ if (inFence) fenceLine = lineNumber
217
+ continue
218
+ }
219
+ if (inFence) continue
220
+
221
+ // Author comments - skip their content, track unclosed openings
222
+ if (inComment) {
223
+ const close = line.indexOf("--}")
224
+ if (close === -1) continue
225
+ line = line.substring(close + 3)
226
+ inComment = false
227
+ }
228
+ while (line.includes("{!--")) {
229
+ const open = line.indexOf("{!--")
230
+ const close = line.indexOf("--}", open + 4)
231
+ if (close === -1) {
232
+ line = line.substring(0, open)
233
+ inComment = true
234
+ commentLine = lineNumber
235
+ break
236
+ }
237
+ line = line.substring(0, open) + line.substring(close + 3)
238
+ }
239
+
240
+ // Inline code spans are literal content
241
+ line = line.replace(INLINE_CODE_REGEXP, "")
242
+
243
+ // Component tags
244
+ const trimmed = line.trim()
245
+ const componentMatch = trimmed.match(COMPONENT_TAG_REGEXP)
246
+ if (componentMatch && components && typeof components === "object") {
247
+ const tagName = componentMatch[1]
248
+ if (!components[tagName]) {
249
+ report(
250
+ lineNumber,
251
+ "unknown-component",
252
+ `Unknown component: <${tagName}>`,
253
+ )
254
+ }
255
+ }
256
+ if (componentMatch) {
257
+ // Validate attribute expressions: attr="{expr}" or attr={expr}
258
+ let attrMatch
259
+ while ((attrMatch = ATTRIBUTE_EXPRESSION_REGEXP.exec(trimmed)) !== null) {
260
+ const expression = attrMatch[1] ?? attrMatch[2] ?? attrMatch[3]
261
+ if (expression && expression.trim()) {
262
+ validateExpression(expression, lineNumber)
263
+ }
264
+ }
265
+ continue
266
+ }
267
+
268
+ // Braced tags and expressions
269
+ let braceMatch
270
+ while ((braceMatch = BRACE_REGEXP.exec(line)) !== null) {
271
+ // Escaped braces are literal
272
+ if (braceMatch.index > 0 && line[braceMatch.index - 1] === "\\") {
273
+ continue
274
+ }
275
+ const content = braceMatch[1].trim()
276
+ if (!content) continue
277
+
278
+ if (content.startsWith("#") || content.startsWith("/")) {
279
+ handleControlTag(content, lineNumber)
280
+ } else {
281
+ validateExpression(content, lineNumber)
282
+ }
283
+ }
284
+ }
285
+
286
+ if (inFence) {
287
+ report(fenceLine, "unclosed-code-block", "Unclosed code block (```)")
288
+ }
289
+ if (inComment) {
290
+ report(commentLine, "unclosed-comment", "Unclosed comment ({!-- without --})")
291
+ }
292
+
293
+ for (const frame of stack) {
294
+ report(
295
+ frame.line,
296
+ "unclosed-block",
297
+ `Unclosed {#${frame.type}} opened on line ${frame.line}`,
298
+ )
299
+ }
300
+
301
+ return issues.sort((a, b) => a.line - b.line)
302
+ }
303
+
304
+ module.exports = { validate }
@@ -1,4 +1,4 @@
1
- const { resolvePath } = require("./replaceVariables")
1
+ const { resolveExpression } = require("./replaceVariables")
2
2
 
3
3
  /**
4
4
  * Parse a custom component tag from markdown
@@ -211,9 +211,10 @@ function resolveAttributes(attributes, data) {
211
211
  const resolved = {}
212
212
  for (const [key, value] of Object.entries(attributes)) {
213
213
  if (value && typeof value === "object" && value.__variable) {
214
- // Resolve variable using resolvePath to support complex paths
214
+ // Resolve variable using resolveExpression to support complex paths
215
+ // and array literals like "[images[0], images[2]]"
215
216
  const variablePath = value.__variable
216
- const resolvedValue = resolvePath(data, variablePath)
217
+ const resolvedValue = resolveExpression(data, variablePath)
217
218
  resolved[key] = resolvedValue
218
219
  } else {
219
220
  resolved[key] = value
@@ -193,6 +193,170 @@ function resolvePath(data, path) {
193
193
  return current
194
194
  }
195
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
+
196
360
  /**
197
361
  * Replace {variableName} placeholders in text with actual values from data
198
362
  * Supports:
@@ -200,6 +364,7 @@ function resolvePath(data, path) {
200
364
  * - Array indexing: {images[0]}
201
365
  * - Property access: {user.name}
202
366
  * - Combined: {images[0].src}
367
+ * - Fallbacks: {name ?? "Guest"}
203
368
  * @param {string} text - Text containing variable placeholders
204
369
  * @param {Object} data - Data object with variable values
205
370
  * @returns {string|Array} - Text with variables replaced, or array if mixed content
@@ -245,8 +410,9 @@ function replaceVariables(text, data) {
245
410
  result.push(text.substring(lastIndex, i))
246
411
  }
247
412
 
248
- // Resolve the variable value (supports paths like "images[0].src")
249
- const value = resolvePath(data, variablePath)
413
+ // Resolve the expression (supports paths like "images[0].src",
414
+ // safe method calls, array literals and ?? fallbacks)
415
+ const value = resolveExpression(data, variablePath)
250
416
  if (value !== undefined && value !== null) {
251
417
  result.push(String(value))
252
418
  } else {
@@ -277,4 +443,15 @@ function replaceVariables(text, data) {
277
443
  return result.join("")
278
444
  }
279
445
 
280
- module.exports = { replaceVariables, resolvePath }
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
+ }