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.
- package/.claude/settings.local.json +14 -0
- package/package.json +1 -1
- package/ui/markdown/index.js +3 -3
- package/ui/prose/README.md +168 -16
- package/ui/prose/index.js +36 -5
- package/ui/prose/utilities/processConditionals.js +1 -1
- package/ui/prose/utilities/processLoops.js +70 -9
- package/ui/prose/utilities/protectCode.js +111 -0
- package/ui/prose/utilities/stripComments.js +19 -0
- package/ui/prose/utilities/validate.js +304 -0
- package/ui/{prose/utilities → utilities}/parseCustomTag.js +4 -3
- package/ui/utilities/replaceVariables.js +457 -0
- package/ui/markdown/utilities/parseCustomTag.js +0 -218
- package/ui/markdown/utilities/replaceVariables.js +0 -115
- package/ui/prose/utilities/brackets.js +0 -21
- package/ui/prose/utilities/convertNodes.js +0 -193
- package/ui/prose/utilities/format.js +0 -184
- package/ui/prose/utilities/params.js +0 -29
- package/ui/prose/utilities/parseBlock.js +0 -284
- package/ui/prose/utilities/replaceVariables.js +0 -120
- /package/ui/{markdown/utilities → utilities}/brackets.js +0 -0
- /package/ui/{markdown/utilities → utilities}/convertNodes.js +0 -0
- /package/ui/{markdown/utilities → utilities}/format.js +0 -0
- /package/ui/{markdown/utilities → utilities}/params.js +0 -0
- /package/ui/{markdown/utilities → utilities}/parseBlock.js +0 -0
|
@@ -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 {
|
|
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
|
|
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 =
|
|
217
|
+
const resolvedValue = resolveExpression(data, variablePath)
|
|
217
218
|
resolved[key] = resolvedValue
|
|
218
219
|
} else {
|
|
219
220
|
resolved[key] = value
|