boxwood 2.18.0 → 2.19.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/README.md CHANGED
@@ -194,6 +194,32 @@ Content-Security-Policy: script-src 'nonce-rAnd0m123' 'strict-dynamic';
194
194
 
195
195
  ## Features
196
196
 
197
+ ### Prose - markdown with templating
198
+
199
+ Render markdown content with safe interpolation, conditionals, loops,
200
+ tables, heading anchors and custom components:
201
+
202
+ ```js
203
+ const { Prose } = require("boxwood/ui")
204
+
205
+ Prose({ data, components: { Gallery } }, `
206
+ # {title}
207
+
208
+ Published {date.toLocaleDateString('en-US')}
209
+
210
+ {#each posts.slice(0, limit) as post, i}
211
+ Part {i + 1}: **{post.title ?? 'Untitled'}**
212
+ {/each}
213
+
214
+ <Gallery images="{images.slice(0, 3)}" />
215
+ `)
216
+ ```
217
+
218
+ Templates never execute arbitrary code - expressions resolve against `data`
219
+ with a whitelist of non-mutating methods, and `Prose.validate` reports
220
+ unknown variables, unsafe methods and malformed blocks with line numbers.
221
+ See the full [Prose syntax reference](docs/prose.md).
222
+
197
223
  ### Components with CSS
198
224
 
199
225
  ```js
package/index.js CHANGED
@@ -79,6 +79,10 @@ function compile(path) {
79
79
  head: [],
80
80
  body: [],
81
81
  }
82
+ // JSON-LD scripts are collected, deduplicated by content and moved
83
+ // to head - a component rendering structured data can be used many
84
+ // times without emitting duplicate entries
85
+ const schemas = { keys: new Set(), nodes: [] }
82
86
  const walk = (node) => {
83
87
  if (!node) {
84
88
  return
@@ -98,14 +102,19 @@ function compile(path) {
98
102
  }
99
103
  if (node.name === "script") {
100
104
  const attributes = node.attributes || {}
101
- if (
102
- attributes.src ||
103
- ["application/json", "application/ld+json"].includes(
104
- attributes.type,
105
- )
106
- ) {
105
+ if (attributes.src || attributes.type === "application/json") {
107
106
  node.ignore = false
108
107
  return
108
+ } else if (attributes.type === "application/ld+json") {
109
+ const key = render(node.children, false)
110
+ if (schemas.keys.has(key)) {
111
+ // Duplicate structured data - drop it
112
+ node.ignore = true
113
+ } else {
114
+ schemas.keys.add(key)
115
+ schemas.nodes.push(node)
116
+ }
117
+ return
109
118
  } else {
110
119
  const script = node.children
111
120
  if (script) {
@@ -150,6 +159,19 @@ function compile(path) {
150
159
  }
151
160
  nodes.head.children.push(scriptNode)
152
161
  }
162
+ // Move JSON-LD scripts into head, keeping their attributes
163
+ // Without a head node the first occurrence stays in place
164
+ for (const scriptNode of schemas.nodes) {
165
+ scriptNode.ignore = true
166
+ const copy = {
167
+ name: "script",
168
+ children: scriptNode.children,
169
+ }
170
+ if (scriptNode.attributes) {
171
+ copy.attributes = scriptNode.attributes
172
+ }
173
+ nodes.head.children.push(copy)
174
+ }
153
175
  }
154
176
  if (nodes.body) {
155
177
  if (scripts.body.length > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "boxwood",
3
- "version": "2.18.0",
3
+ "version": "2.19.0",
4
4
  "description": "Compile HTML templates into JS",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -18,6 +18,7 @@ const BASIC_HTML_COMPONENTS = {
18
18
  code: nodes.Code,
19
19
  strong: nodes.Strong,
20
20
  em: nodes.Em,
21
+ del: nodes.Del,
21
22
  p: nodes.P,
22
23
  ul: nodes.Ul,
23
24
  ol: nodes.Ol,
@@ -25,6 +26,12 @@ const BASIC_HTML_COMPONENTS = {
25
26
  pre: nodes.Pre,
26
27
  blockquote: nodes.Blockquote,
27
28
  hr: nodes.Hr,
29
+ table: nodes.Table,
30
+ thead: nodes.Thead,
31
+ tbody: nodes.Tbody,
32
+ tr: nodes.Tr,
33
+ th: nodes.Th,
34
+ td: nodes.Td,
28
35
  }
29
36
 
30
37
  function Markdown(params, children) {
@@ -175,6 +175,17 @@ literals:
175
175
  <Hero sources="{[cover ?? 'default.jpg', banner]}" />
176
176
  ```
177
177
 
178
+ ## Attribute Interpolation
179
+
180
+ An attribute whose entire value is a single `{expression}` resolves to the
181
+ raw value (array, object, number) - that is what makes
182
+ `images="{images.slice(0, 2)}"` work. Expressions can also be embedded in a
183
+ longer attribute string, which always produces a string:
184
+
185
+ ```markdown
186
+ <Card href="/products/{id}" alt="Photo of {name ?? 'someone'}" />
187
+ ```
188
+
178
189
  ## Conditional Rendering
179
190
 
180
191
  Use `{#if}` blocks to conditionally render content based on data:
package/ui/prose/index.js CHANGED
@@ -145,6 +145,13 @@ function Prose(params, children) {
145
145
  // no templating pass (comments, loops, conditionals, interpolation) may touch it
146
146
  const { text: maskedChildren, tokens } = maskCodeSegments(children)
147
147
 
148
+ // Inherit tokens from the outer Prose call (nested component content is
149
+ // masked by the outer call) so heading anchors can restore code text
150
+ const outerTokens = params && params.__codeTokens
151
+ const codeTokens = outerTokens
152
+ ? new Map([...outerTokens, ...tokens])
153
+ : tokens
154
+
148
155
  // Remove {!-- ... --} author comments
149
156
  let processedChildren = stripComments(maskedChildren)
150
157
 
@@ -159,9 +166,12 @@ function Prose(params, children) {
159
166
  const items = parseMarkdownLines(processedChildren, allComponents, data)
160
167
 
161
168
  // Convert parsed items into final node tree
169
+ // __codeTokens travels with params so recursive Prose calls (multi-line
170
+ // component children) can also restore code text in heading anchors
171
+ // __headingAnchors opts into anchor ids - a Prose feature, not plain Markdown
162
172
  const nodes = convertItemsToNodes(
163
173
  items,
164
- params,
174
+ { ...params, __codeTokens: codeTokens, __headingAnchors: true },
165
175
  htmlParams,
166
176
  data,
167
177
  allComponents,
@@ -1,4 +1,8 @@
1
- const { resolvePath } = require("../../utilities/replaceVariables")
1
+ const {
2
+ resolveExpression,
3
+ splitLogicalOr,
4
+ splitLogicalAnd,
5
+ } = require("../../utilities/replaceVariables")
2
6
 
3
7
  /**
4
8
  * Check if a value is truthy for conditional rendering
@@ -33,12 +37,28 @@ function isTruthy(value) {
33
37
  /**
34
38
  * Evaluate a condition expression
35
39
  * Supports: variable, variable > value, variable < value, etc.
36
- * Also supports negation with ! prefix
40
+ * Also supports negation with ! prefix and logical || and && operators
41
+ * with JS precedence (&& binds tighter than ||),
42
+ * e.g. "articles.length == 0 || user.role == 'admin'"
43
+ * Both comparison sides and bare conditions accept full expressions:
44
+ * ?? fallbacks, arithmetic ("items.length - 1 > 0") and array literals
37
45
  * @param {string} condition - The condition to evaluate
38
46
  * @param {Object} data - Data object with variable values
39
47
  * @returns {boolean} - Whether the condition is true
40
48
  */
41
49
  function evaluateCondition(condition, data) {
50
+ // Logical operators, || first so && binds tighter (JS precedence)
51
+ // Each operand is evaluated recursively, including its own ! negation
52
+ const orOperands = splitLogicalOr(condition)
53
+ if (orOperands.length > 1) {
54
+ return orOperands.some((operand) => evaluateCondition(operand, data))
55
+ }
56
+
57
+ const andOperands = splitLogicalAnd(condition)
58
+ if (andOperands.length > 1) {
59
+ return andOperands.every((operand) => evaluateCondition(operand, data))
60
+ }
61
+
42
62
  // Check for negation operator at the start
43
63
  let negated = false
44
64
  let conditionToEvaluate = condition.trim()
@@ -50,16 +70,16 @@ function evaluateCondition(condition, data) {
50
70
 
51
71
  let result
52
72
 
53
- // Check for comparison operators
73
+ // Check for comparison operators - longest first so === wins over ==
54
74
  const comparisonMatch = conditionToEvaluate.match(
55
- /^(.+?)\s*(>=|<=|>|<|==|!=)\s*(.+)$/,
75
+ /^(.+?)\s*(===|!==|>=|<=|>|<|==|!=)\s*(.+)$/,
56
76
  )
57
77
 
58
78
  if (comparisonMatch) {
59
79
  const [, leftExpr, operator, rightExpr] = comparisonMatch
60
80
 
61
- // Resolve left side (variable or path)
62
- const leftValue = resolvePath(data, leftExpr.trim())
81
+ // Resolve left side - any expression, e.g. "items.length - 1"
82
+ const leftValue = resolveExpression(data, leftExpr.trim())
63
83
 
64
84
  // Resolve right side (could be a number, string, or variable)
65
85
  let rightValue = rightExpr.trim()
@@ -81,9 +101,9 @@ function evaluateCondition(condition, data) {
81
101
  ) {
82
102
  rightValue = rightValue.slice(1, -1)
83
103
  }
84
- // Otherwise treat as a variable path
104
+ // Otherwise treat as an expression
85
105
  else {
86
- rightValue = resolvePath(data, rightValue)
106
+ rightValue = resolveExpression(data, rightValue)
87
107
  }
88
108
 
89
109
  // Perform comparison
@@ -106,12 +126,19 @@ function evaluateCondition(condition, data) {
106
126
  case "!=":
107
127
  result = leftValue != rightValue
108
128
  break
129
+ case "===":
130
+ result = leftValue === rightValue
131
+ break
132
+ case "!==":
133
+ result = leftValue !== rightValue
134
+ break
109
135
  default:
110
136
  result = false
111
137
  }
112
138
  } else {
113
139
  // No comparison operator, just evaluate truthiness
114
- const value = resolvePath(data, conditionToEvaluate)
140
+ // Expressions are supported, e.g. "title ?? subtitle"
141
+ const value = resolveExpression(data, conditionToEvaluate)
115
142
  result = isTruthy(value)
116
143
  }
117
144
 
@@ -1,7 +1,4 @@
1
- const {
2
- resolvePath,
3
- resolveExpression,
4
- } = require("../../utilities/replaceVariables")
1
+ const { resolveExpression } = require("../../utilities/replaceVariables")
5
2
  const { processConditionals } = require("./processConditionals")
6
3
 
7
4
  // Upper bound for expanded loop output - {#each} blocks nested over large
@@ -17,6 +14,8 @@ const MAX_EXPANSION_LENGTH = 1000000
17
14
  * - {#each items as item} - custom item variable name
18
15
  * - {#each items as item, index} - access item and index
19
16
  * - {#each items}...{#else}...{/each} - fallback content for empty arrays
17
+ * - {#each posts.slice(0, 3) as post} - any expression producing an array,
18
+ * including array literals, spread and ?? fallbacks
20
19
  * @param {string} text - Text containing loop blocks
21
20
  * @param {Object} data - Data object with variable values
22
21
  * @returns {string} - Text with loops expanded
@@ -41,8 +40,9 @@ function processLoops(text, data) {
41
40
 
42
41
  // Find the next {#each} block
43
42
  // Matches: {#each items}, {#each items as item}, {#each items as item, index}
43
+ // The array part is any expression, e.g. {#each posts.slice(0, 3) as post}
44
44
  const eachMatch = result.match(
45
- /\{#each\s+([a-zA-Z0-9_.[\]]+)(?:\s+as\s+([a-zA-Z0-9_]+)(?:\s*,\s*([a-zA-Z0-9_]+))?)?\}/,
45
+ /\{#each\s+(.+?)(?:\s+as\s+([a-zA-Z0-9_]+)(?:\s*,\s*([a-zA-Z0-9_]+))?)?\}/,
46
46
  )
47
47
  if (!eachMatch) break
48
48
 
@@ -86,8 +86,9 @@ function processLoops(text, data) {
86
86
  // Split the block into the item branch and an optional {#else} branch
87
87
  const { itemBranch, elseBranch } = splitLoopBranches(blockContent)
88
88
 
89
- // Resolve the array
90
- const array = resolvePath(data, arrayPath)
89
+ // Resolve the array - expressions are supported, e.g. "posts.slice(0, 3)",
90
+ // "[...featured, ...latest]" or "posts ?? []"
91
+ const array = resolveExpression(data, arrayPath)
91
92
 
92
93
  // Generate repeated content
93
94
  let expandedContent = ""
@@ -1,23 +1,29 @@
1
1
  const {
2
2
  SAFE_METHODS,
3
+ FORBIDDEN_KEYS,
4
+ MAX_EXPRESSION_LENGTH,
3
5
  parseArgument,
4
6
  parsePathSegments,
5
7
  splitNullishCoalescing,
8
+ splitLogicalOr,
9
+ splitLogicalAnd,
10
+ splitArithmetic,
6
11
  isArrayLiteral,
7
12
  splitArrayElements,
8
13
  } = require("../../utilities/replaceVariables")
14
+ const { scanUnquotedGt } = require("../../utilities/parseBlock")
9
15
 
10
16
  // Matches the {#each} header accepted by processLoops
17
+ // The array part is any expression, e.g. "posts.slice(0, 3)"
11
18
  const EACH_HEADER_REGEXP =
12
- /^#each\s+([a-zA-Z0-9_.[\]]+)(?:\s+as\s+([a-zA-Z0-9_]+)(?:\s*,\s*([a-zA-Z0-9_]+))?)?$/
19
+ /^#each\s+(.+?)(?:\s+as\s+([a-zA-Z0-9_]+)(?:\s*,\s*([a-zA-Z0-9_]+))?)?$/
13
20
 
14
21
  // Matches the comparison conditions accepted by processConditionals
15
- const CONDITION_REGEXP = /^(.+?)\s*(>=|<=|>|<|==|!=)\s*(.+)$/
22
+ const CONDITION_REGEXP = /^(.+?)\s*(===|!==|>=|<=|>|<|==|!=)\s*(.+)$/
16
23
 
17
24
  const INLINE_CODE_REGEXP = /(`+)([^`]+)\1/g
18
25
  const BRACE_REGEXP = /\{([^{}]*)\}/g
19
26
  const COMPONENT_TAG_REGEXP = /^<\/?([A-Z][A-Za-z0-9-]*)/
20
- const ATTRIBUTE_EXPRESSION_REGEXP = /=\s*(?:"\{([^}"]*)\}"|'\{([^}']*)\}'|\{([^}]*)\})/g
21
27
 
22
28
  /**
23
29
  * Validate a Prose template and report problems that the renderer
@@ -61,13 +67,28 @@ function validate(text, options = {}) {
61
67
  return
62
68
  }
63
69
  for (const segment of segments) {
64
- if (segment.type === "method" && !SAFE_METHODS.has(segment.name)) {
70
+ if (FORBIDDEN_KEYS.has(segment.name)) {
65
71
  report(
66
72
  line,
67
- "unsafe-method",
68
- `Method not allowed: ${segment.name}() - allowed methods: ${[...SAFE_METHODS].join(", ")}`,
73
+ "forbidden-property",
74
+ `Property not allowed: ${segment.name} - prototype access is blocked`,
69
75
  )
70
76
  }
77
+ if (segment.type === "method") {
78
+ if (!SAFE_METHODS.has(segment.name)) {
79
+ report(
80
+ line,
81
+ "unsafe-method",
82
+ `Method not allowed: ${segment.name}() - allowed methods: ${[...SAFE_METHODS].join(", ")}`,
83
+ )
84
+ }
85
+ // Path arguments are full expressions, e.g. "slice(0, n - 1)"
86
+ for (const arg of segment.args) {
87
+ if (arg.type === "path") {
88
+ validateExpression(arg.path, line)
89
+ }
90
+ }
91
+ }
71
92
  }
72
93
  const root = segments[0]
73
94
  if (
@@ -86,7 +107,80 @@ function validate(text, options = {}) {
86
107
  const trimmed = expression.trim()
87
108
  if (!trimmed) return
88
109
 
89
- for (const operand of splitNullishCoalescing(trimmed)) {
110
+ // Oversized expressions are rejected by the resolver too - report and
111
+ // stop before the recursive scanners run on a pathological input
112
+ if (expression.length > MAX_EXPRESSION_LENGTH) {
113
+ report(
114
+ line,
115
+ "malformed-expression",
116
+ `Expression too long (over ${MAX_EXPRESSION_LENGTH} characters)`,
117
+ )
118
+ return
119
+ }
120
+
121
+ const nullishOperands = splitNullishCoalescing(trimmed)
122
+ const orOperands = splitLogicalOr(trimmed)
123
+ const andOperands = splitLogicalAnd(trimmed)
124
+
125
+ // Mixing ?? with || or && without parentheses is invalid, same as in JS
126
+ if (
127
+ nullishOperands.length > 1 &&
128
+ (orOperands.length > 1 || andOperands.length > 1)
129
+ ) {
130
+ report(
131
+ line,
132
+ "malformed-expression",
133
+ `Cannot mix ?? with || or && in one expression: {${trimmed}}`,
134
+ )
135
+ return
136
+ }
137
+
138
+ // || first so && binds tighter (JS precedence) - operands of || may
139
+ // contain && and recurse through validateExpression
140
+ const operands =
141
+ orOperands.length > 1
142
+ ? orOperands
143
+ : andOperands.length > 1
144
+ ? andOperands
145
+ : nullishOperands
146
+
147
+ if (operands.length > 1) {
148
+ for (const operand of operands) {
149
+ const raw = operand.trim()
150
+ if (!raw) {
151
+ report(
152
+ line,
153
+ "malformed-expression",
154
+ `Malformed expression: {${trimmed}}`,
155
+ )
156
+ continue
157
+ }
158
+ validateExpression(raw, line)
159
+ }
160
+ return
161
+ }
162
+
163
+ // Arithmetic operands validate recursively, e.g. {i + 1}
164
+ const sums = splitArithmetic(trimmed, ["+", "-"])
165
+ const arithmetic =
166
+ sums.operators.length > 0 ? sums : splitArithmetic(trimmed, ["*", "/"])
167
+ if (arithmetic.operators.length > 0) {
168
+ for (const operand of arithmetic.operands) {
169
+ const raw = operand.trim()
170
+ if (!raw) {
171
+ report(
172
+ line,
173
+ "malformed-expression",
174
+ `Malformed expression: {${trimmed}}`,
175
+ )
176
+ continue
177
+ }
178
+ validateExpression(raw, line)
179
+ }
180
+ return
181
+ }
182
+
183
+ for (const operand of operands) {
90
184
  const raw = operand.trim()
91
185
  if (!raw) {
92
186
  report(
@@ -101,7 +195,12 @@ function validate(text, options = {}) {
101
195
  }
102
196
  if (isArrayLiteral(raw)) {
103
197
  for (const element of splitArrayElements(raw.substring(1, raw.length - 1))) {
104
- validateExpression(element, line)
198
+ // Spread elements validate the spread expression itself
199
+ const rawElement = element.trim()
200
+ validateExpression(
201
+ rawElement.startsWith("...") ? rawElement.substring(3) : rawElement,
202
+ line,
203
+ )
105
204
  }
106
205
  continue
107
206
  }
@@ -110,6 +209,25 @@ function validate(text, options = {}) {
110
209
  }
111
210
 
112
211
  const validateCondition = (condition, line) => {
212
+ // Logical operators - validate each operand on its own
213
+ const orOperands = splitLogicalOr(condition)
214
+ const operands =
215
+ orOperands.length > 1 ? orOperands : splitLogicalAnd(condition)
216
+ if (operands.length > 1) {
217
+ for (const operand of operands) {
218
+ if (!operand.trim()) {
219
+ report(
220
+ line,
221
+ "malformed-expression",
222
+ `Malformed condition: {#if ${condition.trim()}}`,
223
+ )
224
+ continue
225
+ }
226
+ validateCondition(operand, line)
227
+ }
228
+ return
229
+ }
230
+
113
231
  let expr = condition.trim()
114
232
  if (expr.startsWith("!")) {
115
233
  expr = expr.substring(1).trim()
@@ -158,7 +276,12 @@ function validate(text, options = {}) {
158
276
  "{#elseif} without a matching {#if}",
159
277
  )
160
278
  }
161
- validateCondition(content.substring("#elseif".length), line)
279
+ const condition = content.substring("#elseif".length)
280
+ if (!condition.trim()) {
281
+ report(line, "malformed-block", "Empty condition: {#elseif}")
282
+ return
283
+ }
284
+ validateCondition(condition, line)
162
285
  return
163
286
  }
164
287
 
@@ -174,8 +297,15 @@ function validate(text, options = {}) {
174
297
  }
175
298
 
176
299
  if (content.startsWith("#if")) {
300
+ // Push before validating so a matching {/if} does not report an error
177
301
  stack.push({ type: "if", line })
178
- validateCondition(content.substring("#if".length), line)
302
+ const condition = content.substring("#if".length)
303
+ if (!condition.trim()) {
304
+ // The renderer leaves {#if } blocks as literal text
305
+ report(line, "malformed-block", "Empty condition: {#if}")
306
+ return
307
+ }
308
+ validateCondition(condition, line)
179
309
  return
180
310
  }
181
311
 
@@ -253,19 +383,30 @@ function validate(text, options = {}) {
253
383
  )
254
384
  }
255
385
  }
256
- if (componentMatch) {
257
- // Validate attribute expressions: attr="{expr}" or attr={expr}
258
- let attrMatch
259
- while ((attrMatch = ATTRIBUTE_EXPRESSION_REGEXP.exec(trimmed)) !== null) {
260
- const expression = attrMatch[1] ?? attrMatch[2] ?? attrMatch[3]
261
- if (expression && expression.trim()) {
262
- validateExpression(expression, lineNumber)
263
- }
386
+ // Multi-line component tags - join the attribute lines so expressions
387
+ // spanning lines (e.g. multi-line array literals) are validated as one
388
+ // The tag ends at the first > outside quoted attribute values
389
+ const gtState = { quote: null }
390
+ if (componentMatch && !scanUnquotedGt(line, gtState)) {
391
+ const parts = [line]
392
+ let j = i + 1
393
+ // A blank line ends the markdown block - stop joining there
394
+ while (
395
+ j < lines.length &&
396
+ lines[j].trim() &&
397
+ !scanUnquotedGt(lines[j], gtState)
398
+ ) {
399
+ parts.push(lines[j])
400
+ j++
401
+ }
402
+ if (j < lines.length && lines[j].trim()) {
403
+ parts.push(lines[j])
404
+ line = parts.join("\n")
405
+ i = j
264
406
  }
265
- continue
266
407
  }
267
-
268
- // Braced tags and expressions
408
+ // Braced tags and expressions - on component lines this also covers
409
+ // attribute expressions, both whole-value ({expr}) and partial (/p/{id})
269
410
  let braceMatch
270
411
  while ((braceMatch = BRACE_REGEXP.exec(line)) !== null) {
271
412
  // Escaped braces are literal
@@ -273,7 +414,16 @@ function validate(text, options = {}) {
273
414
  continue
274
415
  }
275
416
  const content = braceMatch[1].trim()
276
- if (!content) continue
417
+ if (!content) {
418
+ // A forgotten value - the renderer keeps {} as literal text,
419
+ // and an attribute value "{}" is passed through as a string
420
+ report(
421
+ lineNumber,
422
+ "malformed-expression",
423
+ "Empty expression: {} - escape with \\{ for literal braces",
424
+ )
425
+ continue
426
+ }
277
427
 
278
428
  if (content.startsWith("#") || content.startsWith("/")) {
279
429
  handleControlTag(content, lineNumber)