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.
- package/.claude/settings.local.json +14 -0
- package/package.json +1 -1
- package/ui/markdown/index.js +3 -3
- package/ui/prose/README.md +145 -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 +292 -0
- package/ui/{prose/utilities → utilities}/parseCustomTag.js +25 -12
- package/ui/{prose/utilities → utilities}/replaceVariables.js +180 -3
- 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/{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,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 {
|
|
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
|
-
//
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
}
|
|
169
|
-
|
|
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
|
|
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 =
|
|
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
|
|
249
|
-
|
|
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 = {
|
|
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
|
+
}
|