boxwood 2.17.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.
@@ -0,0 +1,14 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(grep -rn \"js\\\\`\\\\|const js\\\\|export.*js\" /Users/emilos/Projects/buxlabs/boxwood/src --include=\"*.js\" -l)",
5
+ "Bash(grep -rln \"js\\\\`\" /Users/emilos/Projects/buxlabs/boxwood/ui /Users/emilos/Projects/buxlabs/boxwood/test)",
6
+ "Bash(grep -rn -A8 \"js\\\\`\" /Users/emilos/Projects/buxlabs/boxwood/ui)",
7
+ "Bash(node --test ui/prose/utilities/replaceVariables.test.js ui/prose/utilities/parseCustomTag.test.js test/ui/prose/gallery/index.test.js)",
8
+ "Bash(npm test *)",
9
+ "Bash(echo \"exit: $?\")",
10
+ "Bash(node -e ' *)",
11
+ "Bash(node --test ui/prose/utilities/processLoops.test.js)"
12
+ ]
13
+ }
14
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "boxwood",
3
- "version": "2.17.0",
3
+ "version": "2.18.0",
4
4
  "description": "Compile HTML templates into JS",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -1,9 +1,9 @@
1
1
  const nodes = require("../..")
2
2
  const { component } = nodes
3
3
 
4
- const { extractHtmlParams, mergeComponents } = require("./utilities/params")
5
- const { parseMarkdownLines } = require("./utilities/parseBlock")
6
- const { convertItemsToNodes } = require("./utilities/convertNodes")
4
+ const { extractHtmlParams, mergeComponents } = require("../utilities/params")
5
+ const { parseMarkdownLines } = require("../utilities/parseBlock")
6
+ const { convertItemsToNodes } = require("../utilities/convertNodes")
7
7
 
8
8
  // Basic HTML components needed for markdown formatting (links, images, code, etc.)
9
9
  const BASIC_HTML_COMPONENTS = {
@@ -19,15 +19,19 @@ All standard markdown features:
19
19
  ### Templating Features
20
20
 
21
21
  - **Variable replacement**: `{variable}`, `{user.name}`, `{images[0].src}`
22
+ - **Fallbacks**: `{nickname ?? name ?? "Guest"}` with JS nullish semantics
22
23
  - **Method calls**: `{images.slice(0, 2)}`, `{tags.join(', ')}`, `{name.toUpperCase()}`
23
- - **Conditionals**: `{#if variable}...{/if}` blocks for conditional rendering
24
- - **Loops**: `{#each items}...{/each}` for iteration over arrays
24
+ - **Array literals**: `<Gallery images="{[images[0], images[2]]}" />`
25
+ - **Conditionals**: `{#if}...{#elseif}...{#else}...{/if}` blocks
26
+ - **Loops**: `{#each items}...{#else}...{/each}` with an empty state
25
27
  - **Custom components**: `<Alert type="warning">...</Alert>`
26
28
  - **Builtin HTML tags**: `<article>`, `<section>`, `<div>`, etc.
29
+ - **Author comments**: `{!-- never rendered --}`
30
+ - **Literal code**: templating never runs inside code blocks or inline code
31
+ - **Validation**: `Prose.validate(text, { data, components })` reports problems
27
32
 
28
- ### Planned Features
29
-
30
- None at the moment - all core features are implemented!
33
+ The language is closed by design - see
34
+ [What is deliberately missing](#what-is-deliberately-missing).
31
35
 
32
36
  ## Usage
33
37
 
@@ -133,6 +137,44 @@ Rules:
133
137
  - Mutating methods like `push`, `pop` or `splice` are rejected and the placeholder is kept as-is
134
138
  - Method calls can be chained with property access: `{gallery.images.slice(1)[0]}`
135
139
 
140
+ ## Fallbacks with ??
141
+
142
+ Provide a fallback for missing values with the `??` operator:
143
+
144
+ ```markdown
145
+ Hello {nickname ?? name ?? "Guest"}
146
+
147
+ Price: {price ?? "TBD"}
148
+ ```
149
+
150
+ The semantics match JavaScript: the fallback applies only when the value is
151
+ `null` or `undefined`. Zero, empty strings and `false` are real values and do
152
+ not trigger the fallback:
153
+
154
+ ```markdown
155
+ Count: {count ?? "none"}
156
+ ```
157
+
158
+ With `count: 0` this renders `Count: 0`, not `Count: none`.
159
+
160
+ Operands can be paths, literals (`"text"`, `0`, `true`, `null`), method calls
161
+ or array literals, and `??` works in text, in loops and in component
162
+ attributes:
163
+
164
+ ```markdown
165
+ <Gallery images="{gallery ?? ['placeholder.jpg']}" />
166
+ ```
167
+
168
+ ## Array Literals
169
+
170
+ Component attributes accept array literals whose elements are paths or
171
+ literals:
172
+
173
+ ```markdown
174
+ <Gallery images="{[images[0], images[2]]}" />
175
+ <Hero sources="{[cover ?? 'default.jpg', banner]}" />
176
+ ```
177
+
136
178
  ## Conditional Rendering
137
179
 
138
180
  Use `{#if}` blocks to conditionally render content based on data:
@@ -502,22 +544,98 @@ Prose(
502
544
 
503
545
  Note: Since template literals evaluate expressions like `{i + 1}`, you may need to use alternative approaches if you want to perform calculations. In this case, pre-process your data.
504
546
 
505
- **Empty arrays:**
547
+ ### Empty State with {#else}
506
548
 
507
- When an array is empty, the loop content is not rendered:
549
+ When the array is empty or missing, the `{#else}` branch renders instead:
508
550
 
509
- ```javascript
510
- Prose(
511
- { data: { items: [] } },
512
- `
513
- {#each items as item}
514
- - {item}
551
+ ```markdown
552
+ {#each products as product}
553
+ - {product.title}
554
+ {#else}
555
+ No products yet.
515
556
  {/each}
516
- `,
517
- )
518
- // Renders nothing
519
557
  ```
520
558
 
559
+ Without an `{#else}` branch, an empty array renders nothing. An `{#else}`
560
+ that belongs to an `{#if}` inside the loop is not confused with the loop's
561
+ own `{#else}`.
562
+
563
+ ### Expansion Limit
564
+
565
+ Loop expansion is capped at 1,000,000 characters. Nested loops over large
566
+ arrays multiply quickly - exceeding the cap throws a clear error instead of
567
+ freezing the page.
568
+
569
+ ## Author Comments
570
+
571
+ Notes for editors that never render:
572
+
573
+ ```markdown
574
+ {!-- TODO: replace the photo after the shoot --}
575
+
576
+ # The Article
577
+ ```
578
+
579
+ Comments can span multiple lines. Templating inside a comment is never
580
+ executed.
581
+
582
+ ## Code Is Literal
583
+
584
+ Templating never runs inside fenced code blocks or inline code - braces,
585
+ loops and conditionals stay exactly as written:
586
+
587
+ ````markdown
588
+ Inline `{name}` stays literal, and so does everything inside fences:
589
+
590
+ ```js
591
+ const x = {count}
592
+ {#if debug}console.log(x){/if}
593
+ ```
594
+ ````
595
+
596
+ ## Escaping
597
+
598
+ Use `\{` to render a literal brace in regular text: `\{name}` renders as
599
+ `{name}`.
600
+
601
+ ## Validation
602
+
603
+ `Prose.validate` reports the problems the renderer tolerates silently -
604
+ ideal for a CMS preview:
605
+
606
+ ```javascript
607
+ const { Prose } = require("boxwood/ui")
608
+
609
+ const issues = Prose.validate(text, { data, components })
610
+ // [
611
+ // { line: 3, type: "unknown-component", message: "Unknown component: <Galery>" },
612
+ // { line: 3, type: "unknown-variable", message: "Unknown variable: imges" },
613
+ // { line: 4, type: "unclosed-block", message: "Unclosed {#each} opened on line 4" },
614
+ // ]
615
+ ```
616
+
617
+ Both options are optional: without `data` the unknown-variable check is
618
+ skipped, without `components` the unknown-component check only knows the
619
+ builtin components. Issue types: `unknown-variable`, `unknown-component`,
620
+ `unsafe-method`, `malformed-expression`, `malformed-block`,
621
+ `unmatched-block`, `unclosed-block`, `unclosed-comment`,
622
+ `unclosed-code-block`.
623
+
624
+ ## What Is Deliberately Missing
625
+
626
+ The language is closed by design. When content needs something more, prepare
627
+ it in the data instead of extending the syntax:
628
+
629
+ - **Filters/formatters** (`{price | currency}`) - format in JS and pass
630
+ `priceFormatted` in `data`
631
+ - **Arithmetic** (`{price * 1.23}`) - compute in JS, pass the result
632
+ - **Variable definitions** (`{#set ...}`) - shape the data before rendering
633
+ - **Ternaries** (`{a ? b : c}`) - use `{#if}` blocks or `??`
634
+ - **Logical operators in conditions** (`a && b`) - nest `{#if}` blocks
635
+
636
+ If a template needs one of these, that is a signal the data is missing a
637
+ field, not that the language is missing a feature.
638
+
521
639
  ## Difference from Markdown Component
522
640
 
523
641
  - **Markdown**: Pure markdown rendering, no variables or custom components
package/ui/prose/index.js CHANGED
@@ -7,11 +7,17 @@ const Grid = require("../grid")
7
7
  const Group = require("../group")
8
8
  const Stack = require("../stack")
9
9
 
10
- const { extractHtmlParams, mergeComponents } = require("./utilities/params")
11
- const { parseMarkdownLines } = require("./utilities/parseBlock")
12
- const { convertItemsToNodes } = require("./utilities/convertNodes")
10
+ const { extractHtmlParams, mergeComponents } = require("../utilities/params")
11
+ const { parseMarkdownLines } = require("../utilities/parseBlock")
12
+ const { convertItemsToNodes } = require("../utilities/convertNodes")
13
13
  const { processConditionals } = require("./utilities/processConditionals")
14
14
  const { processLoops } = require("./utilities/processLoops")
15
+ const {
16
+ maskCodeSegments,
17
+ restoreCodeSegments,
18
+ } = require("./utilities/protectCode")
19
+ const { stripComments } = require("./utilities/stripComments")
20
+ const { validate } = require("./utilities/validate")
15
21
 
16
22
  // Safe builtin HTML tags that can be used as custom components in markdown
17
23
  // These are always available and don't need to be explicitly passed in params.components
@@ -135,9 +141,16 @@ function Prose(params, children) {
135
141
  const allComponents = mergeComponents(BUILTIN_COMPONENTS, customComponents)
136
142
  const htmlParams = extractHtmlParams(params)
137
143
 
144
+ // Mask code blocks and inline code first - code is literal content and
145
+ // no templating pass (comments, loops, conditionals, interpolation) may touch it
146
+ const { text: maskedChildren, tokens } = maskCodeSegments(children)
147
+
148
+ // Remove {!-- ... --} author comments
149
+ let processedChildren = stripComments(maskedChildren)
150
+
138
151
  // Process {#each}...{/each} loop blocks first
139
152
  // Conditionals within loops are processed during loop expansion
140
- let processedChildren = processLoops(children, data)
153
+ processedChildren = processLoops(processedChildren, data)
141
154
 
142
155
  // Process any remaining {#if}...{/if} conditional blocks (those outside loops)
143
156
  processedChildren = processConditionals(processedChildren, data)
@@ -146,7 +159,7 @@ function Prose(params, children) {
146
159
  const items = parseMarkdownLines(processedChildren, allComponents, data)
147
160
 
148
161
  // Convert parsed items into final node tree
149
- return convertItemsToNodes(
162
+ const nodes = convertItemsToNodes(
150
163
  items,
151
164
  params,
152
165
  htmlParams,
@@ -154,6 +167,24 @@ function Prose(params, children) {
154
167
  allComponents,
155
168
  Prose,
156
169
  )
170
+
171
+ // Put the original code content back into the final tree
172
+ return restoreCodeSegments(nodes, tokens)
157
173
  }
158
174
 
159
175
  module.exports = component(Prose)
176
+
177
+ /**
178
+ * Validate a Prose template without rendering it
179
+ * Reports problems the renderer tolerates silently: unknown variables,
180
+ * unclosed blocks, unsafe methods, unknown components
181
+ * @param {string} text - The prose template text
182
+ * @param {Object} options - { data, components } - both optional
183
+ * @returns {Array<{line: number, type: string, message: string}>}
184
+ */
185
+ module.exports.validate = (text, options = {}) => {
186
+ return validate(text, {
187
+ ...options,
188
+ components: mergeComponents(BUILTIN_COMPONENTS, options.components),
189
+ })
190
+ }
@@ -1,4 +1,4 @@
1
- const { resolvePath } = require("./replaceVariables")
1
+ const { resolvePath } = require("../../utilities/replaceVariables")
2
2
 
3
3
  /**
4
4
  * Check if a value is truthy for conditional rendering
@@ -1,6 +1,14 @@
1
- const { resolvePath } = require("./replaceVariables")
1
+ const {
2
+ resolvePath,
3
+ resolveExpression,
4
+ } = require("../../utilities/replaceVariables")
2
5
  const { processConditionals } = require("./processConditionals")
3
6
 
7
+ // Upper bound for expanded loop output - {#each} blocks nested over large
8
+ // arrays multiply, and content authors should get a clear error instead of
9
+ // an unresponsive page
10
+ const MAX_EXPANSION_LENGTH = 1000000
11
+
4
12
  /**
5
13
  * Process {#each items}...{/each} blocks in text
6
14
  * Repeats content for each item in an array
@@ -8,6 +16,7 @@ const { processConditionals } = require("./processConditionals")
8
16
  * - {#each items} - basic iteration, access current item with {item}
9
17
  * - {#each items as item} - custom item variable name
10
18
  * - {#each items as item, index} - access item and index
19
+ * - {#each items}...{#else}...{/each} - fallback content for empty arrays
11
20
  * @param {string} text - Text containing loop blocks
12
21
  * @param {Object} data - Data object with variable values
13
22
  * @returns {string} - Text with loops expanded
@@ -17,9 +26,9 @@ function processLoops(text, data) {
17
26
  return text
18
27
  }
19
28
 
20
- // If no data, remove all {#each}...{/each} blocks
29
+ // Without data every array is missing, so loops render their {#else} branch
21
30
  if (!data || typeof data !== "object") {
22
- return text.replace(/\{#each\s+[^}]+\}[\s\S]*?\{\/each\}/g, "")
31
+ data = {}
23
32
  }
24
33
 
25
34
  let result = text
@@ -74,6 +83,9 @@ function processLoops(text, data) {
74
83
  const blockContent = result.substring(contentStart, endEachStart)
75
84
  const endEachEnd = endEachStart + 7 // length of "{/each}"
76
85
 
86
+ // Split the block into the item branch and an optional {#else} branch
87
+ const { itemBranch, elseBranch } = splitLoopBranches(blockContent)
88
+
77
89
  // Resolve the array
78
90
  const array = resolvePath(data, arrayPath)
79
91
 
@@ -92,16 +104,30 @@ function processLoops(text, data) {
92
104
  loopData[indexName] = idx
93
105
  }
94
106
 
95
- // First, process any conditionals within the loop content using the loop data
96
- let itemContent = processConditionals(blockContent, loopData)
107
+ // First, expand nested loops recursively so they can reference
108
+ // the current item (e.g. {#each group.members} inside {#each groups as group})
109
+ let itemContent = processLoops(itemBranch, loopData)
110
+
111
+ // Then process any conditionals within the loop content using the loop data
112
+ itemContent = processConditionals(itemContent, loopData)
97
113
 
98
114
  // Then replace variables in the block content
99
115
  itemContent = replaceLoopVariables(itemContent, loopData)
100
116
 
101
117
  expandedContent += itemContent
118
+
119
+ if (expandedContent.length > MAX_EXPANSION_LENGTH) {
120
+ throw new Error(
121
+ `Prose: {#each ${arrayPath}} expanded past ${MAX_EXPANSION_LENGTH} characters - reduce the array size or nesting`,
122
+ )
123
+ }
102
124
  }
125
+ } else {
126
+ // Empty or missing array - render the {#else} branch if present
127
+ // It is inserted as-is: nested loops are handled by the next iterations
128
+ // of this while loop, conditionals and variables by the later passes
129
+ expandedContent = elseBranch
103
130
  }
104
- // If array is empty or not an array, remove the block
105
131
 
106
132
  // Replace the entire {#each}...{/each} block
107
133
  result =
@@ -113,6 +139,40 @@ function processLoops(text, data) {
113
139
  return result
114
140
  }
115
141
 
142
+ /**
143
+ * Split loop block content into the item branch and an optional {#else} branch
144
+ * Only a top-level {#else} splits the block - an {#else} that belongs to a
145
+ * nested {#each} or {#if} inside the block is ignored
146
+ * @param {string} blockContent - Content between {#each} and {/each}
147
+ * @returns {{itemBranch: string, elseBranch: string}} - The two branches
148
+ */
149
+ function splitLoopBranches(blockContent) {
150
+ let depth = 0
151
+ let i = 0
152
+
153
+ while (i < blockContent.length) {
154
+ if (
155
+ blockContent.substring(i, i + 6) === "{#each" ||
156
+ blockContent.substring(i, i + 4) === "{#if"
157
+ ) {
158
+ depth++
159
+ } else if (
160
+ blockContent.substring(i, i + 7) === "{/each}" ||
161
+ blockContent.substring(i, i + 5) === "{/if}"
162
+ ) {
163
+ depth--
164
+ } else if (depth === 0 && blockContent.substring(i, i + 7) === "{#else}") {
165
+ return {
166
+ itemBranch: blockContent.substring(0, i),
167
+ elseBranch: blockContent.substring(i + 7),
168
+ }
169
+ }
170
+ i++
171
+ }
172
+
173
+ return { itemBranch: blockContent, elseBranch: "" }
174
+ }
175
+
116
176
  /**
117
177
  * Replace variables in text with values from loop data
118
178
  * @param {string} text - Text containing variable placeholders
@@ -125,7 +185,7 @@ function replaceLoopVariables(text, loopData) {
125
185
  }
126
186
 
127
187
  let result = text
128
- const regex = /\{([a-zA-Z0-9_.[\]]+)\}/g
188
+ const regex = /\{([^{}]+)\}/g
129
189
  let match
130
190
 
131
191
  // Collect all variable matches first to avoid issues with overlapping replacements
@@ -154,8 +214,9 @@ function replaceLoopVariables(text, loopData) {
154
214
  continue
155
215
  }
156
216
 
157
- // Resolve the variable value
158
- const value = resolvePath(loopData, path)
217
+ // Resolve the expression (supports paths, safe method calls,
218
+ // array literals and ?? fallbacks)
219
+ const value = resolveExpression(loopData, path)
159
220
 
160
221
  if (value !== undefined && value !== null) {
161
222
  result =
@@ -0,0 +1,111 @@
1
+ // Code is literal content - templating (loops, conditionals, interpolation)
2
+ // must never run inside fenced code blocks or inline code spans.
3
+ // maskCodeSegments swaps code content for opaque tokens before the templating
4
+ // passes run, and restoreCodeSegments swaps the original content back into
5
+ // the final node tree.
6
+
7
+ // Global counter so tokens never collide across nested Prose calls
8
+ // (a component's children are rendered by a recursive Prose invocation)
9
+ let tokenId = 0
10
+
11
+ // Tokens are wrapped in NUL characters, which cannot appear in typed content
12
+ const TOKEN_PREFIX = "\u0000code:"
13
+ const TOKEN_SUFFIX = "\u0000"
14
+ const TOKEN_REGEXP = /\u0000code:\d+\u0000/g
15
+
16
+ // Backslash-escaped backticks (\`) are literal text, not code delimiters
17
+ const INLINE_CODE_REGEXP = /(?<!\\)(`+)([^`]+)\1/g
18
+
19
+ /**
20
+ * Replace the content of fenced code blocks and inline code spans with
21
+ * opaque tokens so templating passes leave it untouched
22
+ * Fence delimiters and backticks stay in place so markdown parsing still
23
+ * recognizes the code structure
24
+ * @param {string} text - The raw prose text
25
+ * @returns {{text: string, tokens: Map<string, string>}} - Masked text and token map
26
+ */
27
+ function maskCodeSegments(text) {
28
+ const tokens = new Map()
29
+
30
+ if (!text || typeof text !== "string") {
31
+ return { text, tokens }
32
+ }
33
+
34
+ const mask = (content) => {
35
+ const token = `${TOKEN_PREFIX}${tokenId++}${TOKEN_SUFFIX}`
36
+ tokens.set(token, content)
37
+ return token
38
+ }
39
+
40
+ const lines = text.split("\n")
41
+ const result = []
42
+ let i = 0
43
+
44
+ while (i < lines.length) {
45
+ const line = lines[i]
46
+
47
+ if (line.trim().startsWith("```")) {
48
+ // Fenced code block - mask everything until the closing fence
49
+ result.push(line)
50
+ i++
51
+ const codeLines = []
52
+ while (i < lines.length && !lines[i].trim().startsWith("```")) {
53
+ codeLines.push(lines[i])
54
+ i++
55
+ }
56
+ if (codeLines.length > 0) {
57
+ result.push(mask(codeLines.join("\n")))
58
+ }
59
+ if (i < lines.length) {
60
+ result.push(lines[i]) // closing fence
61
+ i++
62
+ }
63
+ continue
64
+ }
65
+
66
+ // Inline code spans - mask the content between backticks
67
+ result.push(
68
+ line.replace(
69
+ INLINE_CODE_REGEXP,
70
+ (match, ticks, content) => `${ticks}${mask(content)}${ticks}`,
71
+ ),
72
+ )
73
+ i++
74
+ }
75
+
76
+ return { text: result.join("\n"), tokens }
77
+ }
78
+
79
+ /**
80
+ * Restore masked code content in the final node tree
81
+ * Walks strings, arrays and node objects (children and attributes)
82
+ * @param {*} node - A node tree, array, string or any other value
83
+ * @param {Map<string, string>} tokens - Token map from maskCodeSegments
84
+ * @returns {*} - The tree with original code content restored
85
+ */
86
+ function restoreCodeSegments(node, tokens) {
87
+ if (!tokens || tokens.size === 0) {
88
+ return node
89
+ }
90
+
91
+ if (typeof node === "string") {
92
+ return node.replace(TOKEN_REGEXP, (token) =>
93
+ tokens.has(token) ? tokens.get(token) : token,
94
+ )
95
+ }
96
+
97
+ if (Array.isArray(node)) {
98
+ return node.map((child) => restoreCodeSegments(child, tokens))
99
+ }
100
+
101
+ if (node && typeof node === "object") {
102
+ for (const key of Object.keys(node)) {
103
+ node[key] = restoreCodeSegments(node[key], tokens)
104
+ }
105
+ return node
106
+ }
107
+
108
+ return node
109
+ }
110
+
111
+ module.exports = { maskCodeSegments, restoreCodeSegments }
@@ -0,0 +1,19 @@
1
+ // Author comments: {!-- note to self --} never renders
2
+ // Comments can span multiple lines and are removed before any other
3
+ // templating pass, so their content is never interpolated or executed
4
+ const COMMENT_REGEXP = /\{!--[\s\S]*?--\}/g
5
+
6
+ /**
7
+ * Remove {!-- ... --} author comments from text
8
+ * An unclosed comment is left as-is (the validator reports it)
9
+ * @param {string} text - Text possibly containing comments
10
+ * @returns {string} - Text without comments
11
+ */
12
+ function stripComments(text) {
13
+ if (!text || typeof text !== "string") {
14
+ return text
15
+ }
16
+ return text.replace(COMMENT_REGEXP, "")
17
+ }
18
+
19
+ module.exports = { stripComments, COMMENT_REGEXP }