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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "boxwood",
3
- "version": "2.15.0",
3
+ "version": "2.17.0",
4
4
  "description": "Compile HTML templates into JS",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -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
- attributes[name] = value
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 variableName = value.__variable
202
- resolved[key] =
203
- data && data[variableName] !== undefined
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" or "user.name" from a data object
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", "user.name")
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
- // Split the path into parts, handling both dot notation and bracket notation
14
- // e.g., "images[0].src" -> ["images", "0", "src"]
15
- const parts = path
16
- .replace(/\[(\d+)\]/g, ".$1") // Convert [0] to .0
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 part of parts) {
172
+ for (const segment of segments) {
22
173
  if (current === null || current === undefined) {
23
174
  return undefined
24
175
  }
25
- current = current[part]
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