boxwood 2.17.0 → 2.18.1

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