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,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