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,457 @@
|
|
|
1
|
+
// Non-mutating methods that can be called inside a path, e.g. {images.slice(0, 2)}
|
|
2
|
+
// Only methods without side effects are allowed - arbitrary code is never evaluated
|
|
3
|
+
const SAFE_METHODS = new Set([
|
|
4
|
+
// Arrays and strings
|
|
5
|
+
"slice",
|
|
6
|
+
"at",
|
|
7
|
+
"concat",
|
|
8
|
+
"includes",
|
|
9
|
+
"indexOf",
|
|
10
|
+
"lastIndexOf",
|
|
11
|
+
// Arrays
|
|
12
|
+
"join",
|
|
13
|
+
// Strings
|
|
14
|
+
"toUpperCase",
|
|
15
|
+
"toLowerCase",
|
|
16
|
+
"trim",
|
|
17
|
+
"charAt",
|
|
18
|
+
"substring",
|
|
19
|
+
"split",
|
|
20
|
+
"padStart",
|
|
21
|
+
"padEnd",
|
|
22
|
+
"repeat",
|
|
23
|
+
"replace",
|
|
24
|
+
"replaceAll",
|
|
25
|
+
"startsWith",
|
|
26
|
+
"endsWith",
|
|
27
|
+
// Numbers
|
|
28
|
+
"toFixed",
|
|
29
|
+
// Anything
|
|
30
|
+
"toString",
|
|
31
|
+
])
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Parse a single method argument literal
|
|
35
|
+
* Supports numbers, quoted strings, booleans and null
|
|
36
|
+
* @param {string} raw - The raw argument text
|
|
37
|
+
* @returns {{value: *}|null} - Wrapped value or null if not a valid literal
|
|
38
|
+
*/
|
|
39
|
+
function parseArgument(raw) {
|
|
40
|
+
const arg = raw.trim()
|
|
41
|
+
if (/^-?\d+(\.\d+)?$/.test(arg)) {
|
|
42
|
+
return { value: Number(arg) }
|
|
43
|
+
}
|
|
44
|
+
if (
|
|
45
|
+
arg.length >= 2 &&
|
|
46
|
+
((arg.startsWith('"') && arg.endsWith('"')) ||
|
|
47
|
+
(arg.startsWith("'") && arg.endsWith("'")))
|
|
48
|
+
) {
|
|
49
|
+
return { value: arg.substring(1, arg.length - 1) }
|
|
50
|
+
}
|
|
51
|
+
if (arg === "true") return { value: true }
|
|
52
|
+
if (arg === "false") return { value: false }
|
|
53
|
+
if (arg === "null") return { value: null }
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Parse a path into segments of property accesses and method calls
|
|
59
|
+
* e.g. "images.slice(0, 2)" -> [{type: "property", name: "images"}, {type: "method", name: "slice", args: [0, 2]}]
|
|
60
|
+
* @param {string} path - The path to parse
|
|
61
|
+
* @returns {Array|null} - Parsed segments or null if the path is malformed
|
|
62
|
+
*/
|
|
63
|
+
function parsePathSegments(path) {
|
|
64
|
+
const segments = []
|
|
65
|
+
let i = 0
|
|
66
|
+
|
|
67
|
+
while (i < path.length) {
|
|
68
|
+
const char = path[i]
|
|
69
|
+
|
|
70
|
+
if (char === ".") {
|
|
71
|
+
i++
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (char === "[") {
|
|
76
|
+
const close = path.indexOf("]", i)
|
|
77
|
+
if (close === -1) return null
|
|
78
|
+
const index = path.substring(i + 1, close).trim()
|
|
79
|
+
if (!/^\d+$/.test(index)) return null
|
|
80
|
+
segments.push({ type: "property", name: index })
|
|
81
|
+
i = close + 1
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Read an identifier up to the next separator
|
|
86
|
+
const start = i
|
|
87
|
+
while (i < path.length && !/[.\[(]/.test(path[i])) {
|
|
88
|
+
i++
|
|
89
|
+
}
|
|
90
|
+
const name = path.substring(start, i).trim()
|
|
91
|
+
if (!name) return null
|
|
92
|
+
|
|
93
|
+
if (path[i] === "(") {
|
|
94
|
+
// Method call - collect literal arguments, respecting quoted strings
|
|
95
|
+
i++
|
|
96
|
+
const rawArgs = []
|
|
97
|
+
let current = ""
|
|
98
|
+
let quote = null
|
|
99
|
+
let closed = false
|
|
100
|
+
while (i < path.length) {
|
|
101
|
+
const c = path[i]
|
|
102
|
+
if (quote) {
|
|
103
|
+
current += c
|
|
104
|
+
if (c === quote) quote = null
|
|
105
|
+
i++
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
if (c === '"' || c === "'") {
|
|
109
|
+
quote = c
|
|
110
|
+
current += c
|
|
111
|
+
i++
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
if (c === ",") {
|
|
115
|
+
rawArgs.push(current)
|
|
116
|
+
current = ""
|
|
117
|
+
i++
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
if (c === ")") {
|
|
121
|
+
closed = true
|
|
122
|
+
i++
|
|
123
|
+
break
|
|
124
|
+
}
|
|
125
|
+
current += c
|
|
126
|
+
i++
|
|
127
|
+
}
|
|
128
|
+
if (!closed) return null
|
|
129
|
+
if (current.trim() || rawArgs.length > 0) {
|
|
130
|
+
rawArgs.push(current)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const args = []
|
|
134
|
+
for (const raw of rawArgs) {
|
|
135
|
+
const parsed = parseArgument(raw)
|
|
136
|
+
if (!parsed) return null
|
|
137
|
+
args.push(parsed.value)
|
|
138
|
+
}
|
|
139
|
+
segments.push({ type: "method", name, args })
|
|
140
|
+
} else {
|
|
141
|
+
segments.push({ type: "property", name })
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return segments
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Resolve a path like "images[0].src", "user.name" or "images.slice(0, 2)" from a data object
|
|
150
|
+
* Method calls are restricted to a whitelist of non-mutating methods with literal arguments
|
|
151
|
+
* @param {Object} data - The data object to resolve the path from
|
|
152
|
+
* @param {string} path - The path to resolve (e.g., "images[0].src", "images.slice(0, 2)")
|
|
153
|
+
* @returns {*} - The resolved value or undefined
|
|
154
|
+
*/
|
|
155
|
+
function resolvePath(data, path) {
|
|
156
|
+
// Handle null or undefined data
|
|
157
|
+
if (data === null || data === undefined) {
|
|
158
|
+
return undefined
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Handle simple variable names (backwards compatibility)
|
|
162
|
+
if (!/[.\[(]/.test(path)) {
|
|
163
|
+
return data[path]
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const segments = parsePathSegments(path)
|
|
167
|
+
if (!segments) {
|
|
168
|
+
return undefined
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let current = data
|
|
172
|
+
for (const segment of segments) {
|
|
173
|
+
if (current === null || current === undefined) {
|
|
174
|
+
return undefined
|
|
175
|
+
}
|
|
176
|
+
if (segment.type === "method") {
|
|
177
|
+
if (
|
|
178
|
+
!SAFE_METHODS.has(segment.name) ||
|
|
179
|
+
typeof current[segment.name] !== "function"
|
|
180
|
+
) {
|
|
181
|
+
return undefined
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
current = current[segment.name](...segment.args)
|
|
185
|
+
} catch (error) {
|
|
186
|
+
return undefined
|
|
187
|
+
}
|
|
188
|
+
} else {
|
|
189
|
+
current = current[segment.name]
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return current
|
|
194
|
+
}
|
|
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
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Replace {variableName} placeholders in text with actual values from data
|
|
362
|
+
* Supports:
|
|
363
|
+
* - Simple variables: {name}
|
|
364
|
+
* - Array indexing: {images[0]}
|
|
365
|
+
* - Property access: {user.name}
|
|
366
|
+
* - Combined: {images[0].src}
|
|
367
|
+
* - Fallbacks: {name ?? "Guest"}
|
|
368
|
+
* @param {string} text - Text containing variable placeholders
|
|
369
|
+
* @param {Object} data - Data object with variable values
|
|
370
|
+
* @returns {string|Array} - Text with variables replaced, or array if mixed content
|
|
371
|
+
*/
|
|
372
|
+
function replaceVariables(text, data) {
|
|
373
|
+
if (!text || typeof text !== "string") {
|
|
374
|
+
return text
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (!data || typeof data !== "object") {
|
|
378
|
+
return text
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Check if text contains any variables
|
|
382
|
+
if (!text.includes("{") || !text.includes("}")) {
|
|
383
|
+
return text
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const result = []
|
|
387
|
+
let i = 0
|
|
388
|
+
let lastIndex = 0
|
|
389
|
+
|
|
390
|
+
while (i < text.length) {
|
|
391
|
+
if (text[i] === "\\" && text[i + 1] === "{") {
|
|
392
|
+
// Escaped opening brace
|
|
393
|
+
result.push(text.substring(lastIndex, i))
|
|
394
|
+
result.push("{")
|
|
395
|
+
i += 2
|
|
396
|
+
lastIndex = i
|
|
397
|
+
continue
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (text[i] === "{") {
|
|
401
|
+
const closeIndex = text.indexOf("}", i + 1)
|
|
402
|
+
|
|
403
|
+
if (closeIndex !== -1) {
|
|
404
|
+
// Found a variable placeholder
|
|
405
|
+
const variablePath = text.substring(i + 1, closeIndex).trim()
|
|
406
|
+
|
|
407
|
+
if (variablePath) {
|
|
408
|
+
// Add text before the variable
|
|
409
|
+
if (i > lastIndex) {
|
|
410
|
+
result.push(text.substring(lastIndex, i))
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Resolve the expression (supports paths like "images[0].src",
|
|
414
|
+
// safe method calls, array literals and ?? fallbacks)
|
|
415
|
+
const value = resolveExpression(data, variablePath)
|
|
416
|
+
if (value !== undefined && value !== null) {
|
|
417
|
+
result.push(String(value))
|
|
418
|
+
} else {
|
|
419
|
+
// Variable not found, keep the placeholder
|
|
420
|
+
result.push(text.substring(i, closeIndex + 1))
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
i = closeIndex + 1
|
|
424
|
+
lastIndex = i
|
|
425
|
+
continue
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
i++
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Add remaining text
|
|
434
|
+
if (lastIndex < text.length) {
|
|
435
|
+
result.push(text.substring(lastIndex))
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// If no substitutions were made, return original text
|
|
439
|
+
if (result.length === 0) {
|
|
440
|
+
return text
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return result.join("")
|
|
444
|
+
}
|
|
445
|
+
|
|
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
|
+
}
|
|
@@ -1,218 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Parse a custom component tag from markdown
|
|
3
|
-
* Supports both <Component attr="value"> and <Component attr={variable}>
|
|
4
|
-
* @param {string} line - The line to parse
|
|
5
|
-
* @param {Object} customComponents - Map of component names to functions
|
|
6
|
-
* @returns {Object|null} - Parsed tag info or null if not a custom tag
|
|
7
|
-
*/
|
|
8
|
-
function parseCustomTag(line, customComponents) {
|
|
9
|
-
if (!customComponents || typeof customComponents !== "object") {
|
|
10
|
-
return null
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
const trimmed = line.trim()
|
|
14
|
-
|
|
15
|
-
// Check for single-line tag: <tag>content</tag> or <tag attr="value">content</tag>
|
|
16
|
-
const singleLineMatch = trimmed.match(
|
|
17
|
-
/^<([A-Za-z][A-Za-z0-9-]*)(\s+[^>]*)?>(.+?)<\/\1>\s*$/,
|
|
18
|
-
)
|
|
19
|
-
if (singleLineMatch) {
|
|
20
|
-
const [, tagName, attributesStr, content] = singleLineMatch
|
|
21
|
-
const component = customComponents[tagName]
|
|
22
|
-
if (component) {
|
|
23
|
-
const attributes = parseAttributes(attributesStr || "")
|
|
24
|
-
return {
|
|
25
|
-
type: "custom-component-single-line",
|
|
26
|
-
tagName,
|
|
27
|
-
component,
|
|
28
|
-
attributes,
|
|
29
|
-
content,
|
|
30
|
-
selfClosing: false,
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// Check for self-closing tag: <ComponentName ... /> (space before / is optional)
|
|
36
|
-
const selfClosingMatch = trimmed.match(
|
|
37
|
-
/^<([A-Za-z][A-Za-z0-9-]*)(\s+[^>]*)?\s*\/>\s*$/,
|
|
38
|
-
)
|
|
39
|
-
if (selfClosingMatch) {
|
|
40
|
-
const [, tagName, attributesStr] = selfClosingMatch
|
|
41
|
-
const component = customComponents[tagName]
|
|
42
|
-
if (component) {
|
|
43
|
-
const attributes = parseAttributes(attributesStr || "")
|
|
44
|
-
return {
|
|
45
|
-
type: "custom-component",
|
|
46
|
-
tagName,
|
|
47
|
-
component,
|
|
48
|
-
attributes,
|
|
49
|
-
selfClosing: true,
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// Check for opening tag: <ComponentName ...>
|
|
55
|
-
const openTagMatch = trimmed.match(
|
|
56
|
-
/^<([A-Za-z][A-Za-z0-9-]*)(\s+[^>]*)?>\s*$/,
|
|
57
|
-
)
|
|
58
|
-
if (openTagMatch) {
|
|
59
|
-
const [, tagName, attributesStr] = openTagMatch
|
|
60
|
-
const component = customComponents[tagName]
|
|
61
|
-
if (component) {
|
|
62
|
-
const attributes = parseAttributes(attributesStr || "")
|
|
63
|
-
return {
|
|
64
|
-
type: "custom-component-open",
|
|
65
|
-
tagName,
|
|
66
|
-
component,
|
|
67
|
-
attributes,
|
|
68
|
-
selfClosing: false,
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// Check for closing tag: </ComponentName>
|
|
74
|
-
const closeTagMatch = trimmed.match(/^<\/([A-Za-z][A-Za-z0-9-]*)>\s*$/)
|
|
75
|
-
if (closeTagMatch) {
|
|
76
|
-
const [, tagName] = closeTagMatch
|
|
77
|
-
const component = customComponents[tagName]
|
|
78
|
-
if (component) {
|
|
79
|
-
return {
|
|
80
|
-
type: "custom-component-close",
|
|
81
|
-
tagName,
|
|
82
|
-
component,
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
return null
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Parse attributes from a tag string
|
|
92
|
-
* Supports: attr="value", attr='value', attr={variable}, attr
|
|
93
|
-
* @param {string} attributesStr - The attributes string
|
|
94
|
-
* @returns {Object} - Parsed attributes
|
|
95
|
-
*/
|
|
96
|
-
function parseAttributes(attributesStr) {
|
|
97
|
-
const attributes = {}
|
|
98
|
-
if (!attributesStr || !attributesStr.trim()) {
|
|
99
|
-
return attributes
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const str = attributesStr.trim()
|
|
103
|
-
let i = 0
|
|
104
|
-
|
|
105
|
-
while (i < str.length) {
|
|
106
|
-
// Skip whitespace
|
|
107
|
-
while (i < str.length && /\s/.test(str[i])) {
|
|
108
|
-
i++
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
if (i >= str.length) break
|
|
112
|
-
|
|
113
|
-
// Parse attribute name
|
|
114
|
-
let nameStart = i
|
|
115
|
-
while (i < str.length && /[a-zA-Z0-9-_:]/.test(str[i])) {
|
|
116
|
-
i++
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const name = str.substring(nameStart, i)
|
|
120
|
-
if (!name) break
|
|
121
|
-
|
|
122
|
-
// Skip whitespace after name
|
|
123
|
-
while (i < str.length && /\s/.test(str[i])) {
|
|
124
|
-
i++
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// Check for =
|
|
128
|
-
if (i >= str.length || str[i] !== "=") {
|
|
129
|
-
// Boolean attribute
|
|
130
|
-
attributes[name] = true
|
|
131
|
-
continue
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
i++ // Skip =
|
|
135
|
-
|
|
136
|
-
// Skip whitespace after =
|
|
137
|
-
while (i < str.length && /\s/.test(str[i])) {
|
|
138
|
-
i++
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
if (i >= str.length) break
|
|
142
|
-
|
|
143
|
-
// Parse value
|
|
144
|
-
const quote = str[i]
|
|
145
|
-
|
|
146
|
-
if (quote === '"' || quote === "'") {
|
|
147
|
-
// Quoted string
|
|
148
|
-
i++ // Skip opening quote
|
|
149
|
-
let value = ""
|
|
150
|
-
while (i < str.length && str[i] !== quote) {
|
|
151
|
-
if (str[i] === "\\" && i + 1 < str.length) {
|
|
152
|
-
// Escaped character - add the next character literally
|
|
153
|
-
value += str[i + 1]
|
|
154
|
-
i += 2
|
|
155
|
-
} else {
|
|
156
|
-
value += str[i]
|
|
157
|
-
i++
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
attributes[name] = value
|
|
161
|
-
if (i < str.length) i++ // Skip closing quote
|
|
162
|
-
} else if (str[i] === "{") {
|
|
163
|
-
// Variable reference
|
|
164
|
-
i++ // Skip {
|
|
165
|
-
const valueStart = i
|
|
166
|
-
while (i < str.length && str[i] !== "}") {
|
|
167
|
-
i++
|
|
168
|
-
}
|
|
169
|
-
const variableName = str.substring(valueStart, i).trim()
|
|
170
|
-
attributes[name] = { __variable: variableName }
|
|
171
|
-
if (i < str.length) i++ // Skip }
|
|
172
|
-
} else {
|
|
173
|
-
// Unquoted value (until space or end)
|
|
174
|
-
const valueStart = i
|
|
175
|
-
while (i < str.length && !/\s/.test(str[i])) {
|
|
176
|
-
i++
|
|
177
|
-
}
|
|
178
|
-
const value = str.substring(valueStart, i)
|
|
179
|
-
attributes[name] = value
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
return attributes
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* Resolve attributes by replacing variable references with actual values
|
|
188
|
-
* @param {Object} attributes - Attributes with possible variable references
|
|
189
|
-
* @param {Object} data - Data object containing variable values
|
|
190
|
-
* @returns {Object} - Resolved attributes
|
|
191
|
-
*/
|
|
192
|
-
function resolveAttributes(attributes, data) {
|
|
193
|
-
if (!attributes || typeof attributes !== "object") {
|
|
194
|
-
return {}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
const resolved = {}
|
|
198
|
-
for (const [key, value] of Object.entries(attributes)) {
|
|
199
|
-
if (value && typeof value === "object" && value.__variable) {
|
|
200
|
-
// Resolve variable
|
|
201
|
-
const variableName = value.__variable
|
|
202
|
-
resolved[key] =
|
|
203
|
-
data && data[variableName] !== undefined
|
|
204
|
-
? data[variableName]
|
|
205
|
-
: undefined
|
|
206
|
-
} else {
|
|
207
|
-
resolved[key] = value
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
return resolved
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
module.exports = {
|
|
215
|
-
parseCustomTag,
|
|
216
|
-
parseAttributes,
|
|
217
|
-
resolveAttributes,
|
|
218
|
-
}
|