boxwood 2.16.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.16.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,7 +1,155 @@
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
+
1
33
  /**
2
- * Resolve a path like "images[0].src" or "user.name" from a data object
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
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) {
@@ -11,23 +159,35 @@ function resolvePath(data, path) {
11
159
  }
12
160
 
13
161
  // Handle simple variable names (backwards compatibility)
14
- if (!/[.\[]/.test(path)) {
162
+ if (!/[.\[(]/.test(path)) {
15
163
  return data[path]
16
164
  }
17
165
 
18
- // Split the path into parts, handling both dot notation and bracket notation
19
- // e.g., "images[0].src" -> ["images", "0", "src"]
20
- const parts = path
21
- .replace(/\[(\d+)\]/g, ".$1") // Convert [0] to .0
22
- .split(".")
23
- .filter(Boolean)
166
+ const segments = parsePathSegments(path)
167
+ if (!segments) {
168
+ return undefined
169
+ }
24
170
 
25
171
  let current = data
26
- for (const part of parts) {
172
+ for (const segment of segments) {
27
173
  if (current === null || current === undefined) {
28
174
  return undefined
29
175
  }
30
- 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
+ }
31
191
  }
32
192
 
33
193
  return current