boxwood 2.15.0 → 2.17.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/package.json
CHANGED
package/ui/prose/README.md
CHANGED
|
@@ -19,6 +19,7 @@ All standard markdown features:
|
|
|
19
19
|
### Templating Features
|
|
20
20
|
|
|
21
21
|
- **Variable replacement**: `{variable}`, `{user.name}`, `{images[0].src}`
|
|
22
|
+
- **Method calls**: `{images.slice(0, 2)}`, `{tags.join(', ')}`, `{name.toUpperCase()}`
|
|
22
23
|
- **Conditionals**: `{#if variable}...{/if}` blocks for conditional rendering
|
|
23
24
|
- **Loops**: `{#each items}...{/each}` for iteration over arrays
|
|
24
25
|
- **Custom components**: `<Alert type="warning">...</Alert>`
|
|
@@ -99,6 +100,39 @@ Prose(
|
|
|
99
100
|
)
|
|
100
101
|
```
|
|
101
102
|
|
|
103
|
+
## Method Calls
|
|
104
|
+
|
|
105
|
+
Variable paths can include calls to safe, non-mutating methods. This is especially useful for passing a part of an array to a component:
|
|
106
|
+
|
|
107
|
+
```javascript
|
|
108
|
+
Prose(
|
|
109
|
+
{
|
|
110
|
+
data: { images: ["a.jpg", "b.jpg", "c.jpg", "d.jpg"] },
|
|
111
|
+
components: { Gallery },
|
|
112
|
+
},
|
|
113
|
+
`
|
|
114
|
+
<Gallery images="{images.slice(0, 2)}" />
|
|
115
|
+
|
|
116
|
+
<Gallery images="{images.slice(2)}" />
|
|
117
|
+
`,
|
|
118
|
+
)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Method calls also work in text content:
|
|
122
|
+
|
|
123
|
+
```markdown
|
|
124
|
+
**Tags:** {tags.join(', ')}
|
|
125
|
+
|
|
126
|
+
# {title.toUpperCase()}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Rules:
|
|
130
|
+
|
|
131
|
+
- Only whitelisted, non-mutating methods are allowed: `slice`, `at`, `concat`, `includes`, `indexOf`, `lastIndexOf`, `join`, `toUpperCase`, `toLowerCase`, `trim`, `charAt`, `substring`, `split`, `padStart`, `padEnd`, `repeat`, `replace`, `replaceAll`, `startsWith`, `endsWith`, `toFixed`, `toString`
|
|
132
|
+
- Arguments must be literals: numbers, quoted strings, `true`, `false` or `null` (variables are not supported as arguments)
|
|
133
|
+
- Mutating methods like `push`, `pop` or `splice` are rejected and the placeholder is kept as-is
|
|
134
|
+
- Method calls can be chained with property access: `{gallery.images.slice(1)[0]}`
|
|
135
|
+
|
|
102
136
|
## Conditional Rendering
|
|
103
137
|
|
|
104
138
|
Use `{#if}` blocks to conditionally render content based on data:
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
const { resolvePath } = require("./replaceVariables")
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Parse a custom component tag from markdown
|
|
3
5
|
* Supports both <Component attr="value"> and <Component attr={variable}>
|
|
@@ -157,7 +159,19 @@ function parseAttributes(attributesStr) {
|
|
|
157
159
|
i++
|
|
158
160
|
}
|
|
159
161
|
}
|
|
160
|
-
|
|
162
|
+
|
|
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
|
+
}
|
|
171
|
+
} else {
|
|
172
|
+
attributes[name] = value
|
|
173
|
+
}
|
|
174
|
+
|
|
161
175
|
if (i < str.length) i++ // Skip closing quote
|
|
162
176
|
} else if (str[i] === "{") {
|
|
163
177
|
// Variable reference
|
|
@@ -197,12 +211,10 @@ function resolveAttributes(attributes, data) {
|
|
|
197
211
|
const resolved = {}
|
|
198
212
|
for (const [key, value] of Object.entries(attributes)) {
|
|
199
213
|
if (value && typeof value === "object" && value.__variable) {
|
|
200
|
-
// Resolve variable
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
? data[variableName]
|
|
205
|
-
: undefined
|
|
214
|
+
// Resolve variable using resolvePath to support complex paths
|
|
215
|
+
const variablePath = value.__variable
|
|
216
|
+
const resolvedValue = resolvePath(data, variablePath)
|
|
217
|
+
resolved[key] = resolvedValue
|
|
206
218
|
} else {
|
|
207
219
|
resolved[key] = value
|
|
208
220
|
}
|
|
@@ -1,28 +1,193 @@
|
|
|
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
|
+
|
|
1
148
|
/**
|
|
2
|
-
* Resolve a path like "images[0].src"
|
|
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
|
|
3
151
|
* @param {Object} data - The data object to resolve the path from
|
|
4
|
-
* @param {string} path - The path to resolve (e.g., "images[0].src", "
|
|
152
|
+
* @param {string} path - The path to resolve (e.g., "images[0].src", "images.slice(0, 2)")
|
|
5
153
|
* @returns {*} - The resolved value or undefined
|
|
6
154
|
*/
|
|
7
155
|
function resolvePath(data, path) {
|
|
156
|
+
// Handle null or undefined data
|
|
157
|
+
if (data === null || data === undefined) {
|
|
158
|
+
return undefined
|
|
159
|
+
}
|
|
160
|
+
|
|
8
161
|
// Handle simple variable names (backwards compatibility)
|
|
9
|
-
if (!/[.\[]/.test(path)) {
|
|
162
|
+
if (!/[.\[(]/.test(path)) {
|
|
10
163
|
return data[path]
|
|
11
164
|
}
|
|
12
165
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
.split(".")
|
|
18
|
-
.filter(Boolean)
|
|
166
|
+
const segments = parsePathSegments(path)
|
|
167
|
+
if (!segments) {
|
|
168
|
+
return undefined
|
|
169
|
+
}
|
|
19
170
|
|
|
20
171
|
let current = data
|
|
21
|
-
for (const
|
|
172
|
+
for (const segment of segments) {
|
|
22
173
|
if (current === null || current === undefined) {
|
|
23
174
|
return undefined
|
|
24
175
|
}
|
|
25
|
-
|
|
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
|
+
}
|
|
26
191
|
}
|
|
27
192
|
|
|
28
193
|
return current
|