boxwood 2.13.0 → 2.14.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,218 @@
1
+ /**
2
+ * Parse a custom component tag from markdown
3
+ * Supports both <Component attr="value"> and <Component attr={variable}>
4
+ * @param {string} line - The line to parse
5
+ * @param {Object} customComponents - Map of component names to functions
6
+ * @returns {Object|null} - Parsed tag info or null if not a custom tag
7
+ */
8
+ function parseCustomTag(line, customComponents) {
9
+ if (!customComponents || typeof customComponents !== "object") {
10
+ return null
11
+ }
12
+
13
+ const trimmed = line.trim()
14
+
15
+ // Check for single-line tag: <tag>content</tag> or <tag attr="value">content</tag>
16
+ const singleLineMatch = trimmed.match(
17
+ /^<([A-Za-z][A-Za-z0-9-]*)(\s+[^>]*)?>(.+?)<\/\1>\s*$/,
18
+ )
19
+ if (singleLineMatch) {
20
+ const [, tagName, attributesStr, content] = singleLineMatch
21
+ const component = customComponents[tagName]
22
+ if (component) {
23
+ const attributes = parseAttributes(attributesStr || "")
24
+ return {
25
+ type: "custom-component-single-line",
26
+ tagName,
27
+ component,
28
+ attributes,
29
+ content,
30
+ selfClosing: false,
31
+ }
32
+ }
33
+ }
34
+
35
+ // Check for self-closing tag: <ComponentName ... /> (space before / is optional)
36
+ const selfClosingMatch = trimmed.match(
37
+ /^<([A-Za-z][A-Za-z0-9-]*)(\s+[^>]*)?\s*\/>\s*$/,
38
+ )
39
+ if (selfClosingMatch) {
40
+ const [, tagName, attributesStr] = selfClosingMatch
41
+ const component = customComponents[tagName]
42
+ if (component) {
43
+ const attributes = parseAttributes(attributesStr || "")
44
+ return {
45
+ type: "custom-component",
46
+ tagName,
47
+ component,
48
+ attributes,
49
+ selfClosing: true,
50
+ }
51
+ }
52
+ }
53
+
54
+ // Check for opening tag: <ComponentName ...>
55
+ const openTagMatch = trimmed.match(
56
+ /^<([A-Za-z][A-Za-z0-9-]*)(\s+[^>]*)?>\s*$/,
57
+ )
58
+ if (openTagMatch) {
59
+ const [, tagName, attributesStr] = openTagMatch
60
+ const component = customComponents[tagName]
61
+ if (component) {
62
+ const attributes = parseAttributes(attributesStr || "")
63
+ return {
64
+ type: "custom-component-open",
65
+ tagName,
66
+ component,
67
+ attributes,
68
+ selfClosing: false,
69
+ }
70
+ }
71
+ }
72
+
73
+ // Check for closing tag: </ComponentName>
74
+ const closeTagMatch = trimmed.match(/^<\/([A-Za-z][A-Za-z0-9-]*)>\s*$/)
75
+ if (closeTagMatch) {
76
+ const [, tagName] = closeTagMatch
77
+ const component = customComponents[tagName]
78
+ if (component) {
79
+ return {
80
+ type: "custom-component-close",
81
+ tagName,
82
+ component,
83
+ }
84
+ }
85
+ }
86
+
87
+ return null
88
+ }
89
+
90
+ /**
91
+ * Parse attributes from a tag string
92
+ * Supports: attr="value", attr='value', attr={variable}, attr
93
+ * @param {string} attributesStr - The attributes string
94
+ * @returns {Object} - Parsed attributes
95
+ */
96
+ function parseAttributes(attributesStr) {
97
+ const attributes = {}
98
+ if (!attributesStr || !attributesStr.trim()) {
99
+ return attributes
100
+ }
101
+
102
+ const str = attributesStr.trim()
103
+ let i = 0
104
+
105
+ while (i < str.length) {
106
+ // Skip whitespace
107
+ while (i < str.length && /\s/.test(str[i])) {
108
+ i++
109
+ }
110
+
111
+ if (i >= str.length) break
112
+
113
+ // Parse attribute name
114
+ let nameStart = i
115
+ while (i < str.length && /[a-zA-Z0-9-_:]/.test(str[i])) {
116
+ i++
117
+ }
118
+
119
+ const name = str.substring(nameStart, i)
120
+ if (!name) break
121
+
122
+ // Skip whitespace after name
123
+ while (i < str.length && /\s/.test(str[i])) {
124
+ i++
125
+ }
126
+
127
+ // Check for =
128
+ if (i >= str.length || str[i] !== "=") {
129
+ // Boolean attribute
130
+ attributes[name] = true
131
+ continue
132
+ }
133
+
134
+ i++ // Skip =
135
+
136
+ // Skip whitespace after =
137
+ while (i < str.length && /\s/.test(str[i])) {
138
+ i++
139
+ }
140
+
141
+ if (i >= str.length) break
142
+
143
+ // Parse value
144
+ const quote = str[i]
145
+
146
+ if (quote === '"' || quote === "'") {
147
+ // Quoted string
148
+ i++ // Skip opening quote
149
+ let value = ""
150
+ while (i < str.length && str[i] !== quote) {
151
+ if (str[i] === "\\" && i + 1 < str.length) {
152
+ // Escaped character - add the next character literally
153
+ value += str[i + 1]
154
+ i += 2
155
+ } else {
156
+ value += str[i]
157
+ i++
158
+ }
159
+ }
160
+ attributes[name] = value
161
+ if (i < str.length) i++ // Skip closing quote
162
+ } else if (str[i] === "{") {
163
+ // Variable reference
164
+ i++ // Skip {
165
+ const valueStart = i
166
+ while (i < str.length && str[i] !== "}") {
167
+ i++
168
+ }
169
+ const variableName = str.substring(valueStart, i).trim()
170
+ attributes[name] = { __variable: variableName }
171
+ if (i < str.length) i++ // Skip }
172
+ } else {
173
+ // Unquoted value (until space or end)
174
+ const valueStart = i
175
+ while (i < str.length && !/\s/.test(str[i])) {
176
+ i++
177
+ }
178
+ const value = str.substring(valueStart, i)
179
+ attributes[name] = value
180
+ }
181
+ }
182
+
183
+ return attributes
184
+ }
185
+
186
+ /**
187
+ * Resolve attributes by replacing variable references with actual values
188
+ * @param {Object} attributes - Attributes with possible variable references
189
+ * @param {Object} data - Data object containing variable values
190
+ * @returns {Object} - Resolved attributes
191
+ */
192
+ function resolveAttributes(attributes, data) {
193
+ if (!attributes || typeof attributes !== "object") {
194
+ return {}
195
+ }
196
+
197
+ const resolved = {}
198
+ for (const [key, value] of Object.entries(attributes)) {
199
+ 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
206
+ } else {
207
+ resolved[key] = value
208
+ }
209
+ }
210
+
211
+ return resolved
212
+ }
213
+
214
+ module.exports = {
215
+ parseCustomTag,
216
+ parseAttributes,
217
+ resolveAttributes,
218
+ }
@@ -0,0 +1,264 @@
1
+ const { resolvePath } = require("./replaceVariables")
2
+
3
+ /**
4
+ * Check if a value is truthy for conditional rendering
5
+ * @param {*} value - The value to check
6
+ * @returns {boolean} - Whether the value is truthy
7
+ */
8
+ function isTruthy(value) {
9
+ // null and undefined are falsy
10
+ if (value === null || value === undefined) {
11
+ return false
12
+ }
13
+ // Empty string is falsy
14
+ if (value === "") {
15
+ return false
16
+ }
17
+ // 0 is falsy
18
+ if (value === 0) {
19
+ return false
20
+ }
21
+ // false is falsy
22
+ if (value === false) {
23
+ return false
24
+ }
25
+ // Empty array is falsy
26
+ if (Array.isArray(value) && value.length === 0) {
27
+ return false
28
+ }
29
+ // Everything else is truthy
30
+ return true
31
+ }
32
+
33
+ /**
34
+ * Evaluate a condition expression
35
+ * Supports: variable, variable > value, variable < value, etc.
36
+ * Also supports negation with ! prefix
37
+ * @param {string} condition - The condition to evaluate
38
+ * @param {Object} data - Data object with variable values
39
+ * @returns {boolean} - Whether the condition is true
40
+ */
41
+ function evaluateCondition(condition, data) {
42
+ // Check for negation operator at the start
43
+ let negated = false
44
+ let conditionToEvaluate = condition.trim()
45
+
46
+ if (conditionToEvaluate.startsWith("!")) {
47
+ negated = true
48
+ conditionToEvaluate = conditionToEvaluate.substring(1).trim()
49
+ }
50
+
51
+ let result
52
+
53
+ // Check for comparison operators
54
+ const comparisonMatch = conditionToEvaluate.match(
55
+ /^(.+?)\s*(>=|<=|>|<|==|!=)\s*(.+)$/,
56
+ )
57
+
58
+ if (comparisonMatch) {
59
+ const [, leftExpr, operator, rightExpr] = comparisonMatch
60
+
61
+ // Resolve left side (variable or path)
62
+ const leftValue = resolvePath(data, leftExpr.trim())
63
+
64
+ // Resolve right side (could be a number, string, or variable)
65
+ let rightValue = rightExpr.trim()
66
+
67
+ // Try to parse as number
68
+ if (/^-?\d+(\.\d+)?$/.test(rightValue)) {
69
+ rightValue = parseFloat(rightValue)
70
+ }
71
+ // Try to parse as boolean
72
+ else if (rightValue === "true") {
73
+ rightValue = true
74
+ } else if (rightValue === "false") {
75
+ rightValue = false
76
+ }
77
+ // Try to parse as string literal (quoted)
78
+ else if (
79
+ (rightValue.startsWith('"') && rightValue.endsWith('"')) ||
80
+ (rightValue.startsWith("'") && rightValue.endsWith("'"))
81
+ ) {
82
+ rightValue = rightValue.slice(1, -1)
83
+ }
84
+ // Otherwise treat as a variable path
85
+ else {
86
+ rightValue = resolvePath(data, rightValue)
87
+ }
88
+
89
+ // Perform comparison
90
+ switch (operator) {
91
+ case ">":
92
+ result = leftValue > rightValue
93
+ break
94
+ case "<":
95
+ result = leftValue < rightValue
96
+ break
97
+ case ">=":
98
+ result = leftValue >= rightValue
99
+ break
100
+ case "<=":
101
+ result = leftValue <= rightValue
102
+ break
103
+ case "==":
104
+ result = leftValue == rightValue
105
+ break
106
+ case "!=":
107
+ result = leftValue != rightValue
108
+ break
109
+ default:
110
+ result = false
111
+ }
112
+ } else {
113
+ // No comparison operator, just evaluate truthiness
114
+ const value = resolvePath(data, conditionToEvaluate)
115
+ result = isTruthy(value)
116
+ }
117
+
118
+ // Apply negation if present
119
+ return negated ? !result : result
120
+ }
121
+
122
+ /**
123
+ * Process {#if condition}...{/if} blocks in text
124
+ * Supports {#elseif condition} and {#else} blocks
125
+ * Removes blocks where the condition is falsy
126
+ * @param {string} text - Text containing conditional blocks
127
+ * @param {Object} data - Data object with variable values
128
+ * @returns {string} - Text with conditionals processed
129
+ */
130
+ function processConditionals(text, data) {
131
+ if (!text || typeof text !== "string") {
132
+ return text
133
+ }
134
+
135
+ // If no data, treat all conditions as falsy
136
+ if (!data || typeof data !== "object") {
137
+ // Remove all {#if}...{/if} blocks, but keep final {#else} content
138
+ let result = text
139
+ // Handle {#if}...{#elseif}...{#else}...{/if}
140
+ result = result.replace(
141
+ /\{#if\s+[^}]+\}[\s\S]*?(?:\{#elseif\s+[^}]+\}[\s\S]*?)*\{#else\}([\s\S]*?)\{\/if\}/g,
142
+ "$1",
143
+ )
144
+ // Handle blocks without {#else}
145
+ result = result.replace(/\{#if\s+[^}]+\}[\s\S]*?\{\/if\}/g, "")
146
+ return result
147
+ }
148
+
149
+ let result = text
150
+ let maxIterations = 100 // Prevent infinite loops
151
+ let iterations = 0
152
+
153
+ // Process all {#if} blocks (handles nested blocks by processing innermost first)
154
+ while (/{#if\s+[^}]+\}/.test(result) && iterations < maxIterations) {
155
+ iterations++
156
+
157
+ // Find the next {#if} block
158
+ const ifMatch = result.match(/\{#if\s+([^}]+)\}/)
159
+ if (!ifMatch) break
160
+
161
+ const fullIfTag = ifMatch[0]
162
+ const firstCondition = ifMatch[1].trim()
163
+ const ifStart = result.indexOf(fullIfTag)
164
+ const contentStart = ifStart + fullIfTag.length
165
+
166
+ // Find the matching {/if} and collect all {#elseif} and {#else} blocks
167
+ let depth = 1
168
+ let i = contentStart
169
+ let endIfStart = -1
170
+ const branches = []
171
+ let currentBranchStart = contentStart
172
+ let currentBranchCondition = firstCondition
173
+ let currentBranchType = "if"
174
+
175
+ while (i < result.length && depth > 0) {
176
+ if (result.substring(i, i + 4) === "{#if") {
177
+ depth++
178
+ i += 4
179
+ } else if (result.substring(i, i + 5) === "{/if}") {
180
+ depth--
181
+ if (depth === 0) {
182
+ // Save the last branch content
183
+ branches.push({
184
+ type: currentBranchType,
185
+ condition: currentBranchCondition,
186
+ content: result.substring(currentBranchStart, i),
187
+ })
188
+ endIfStart = i
189
+ break
190
+ }
191
+ i += 5
192
+ } else if (result.substring(i, i + 9) === "{#elseif " && depth === 1) {
193
+ // Found {#elseif} at the same depth level
194
+ // Save the current branch
195
+ branches.push({
196
+ type: currentBranchType,
197
+ condition: currentBranchCondition,
198
+ content: result.substring(currentBranchStart, i),
199
+ })
200
+
201
+ // Parse the new condition
202
+ const elseifMatch = result.substring(i).match(/^\{#elseif\s+([^}]+)\}/)
203
+ if (elseifMatch) {
204
+ const elseifTag = elseifMatch[0]
205
+ currentBranchCondition = elseifMatch[1].trim()
206
+ currentBranchType = "elseif"
207
+ currentBranchStart = i + elseifTag.length
208
+ i += elseifTag.length
209
+ } else {
210
+ i++
211
+ }
212
+ } else if (result.substring(i, i + 7) === "{#else}" && depth === 1) {
213
+ // Found {#else} at the same depth level
214
+ // Save the current branch
215
+ branches.push({
216
+ type: currentBranchType,
217
+ condition: currentBranchCondition,
218
+ content: result.substring(currentBranchStart, i),
219
+ })
220
+
221
+ // Start else branch
222
+ currentBranchType = "else"
223
+ currentBranchCondition = null
224
+ currentBranchStart = i + 7 // +7 for "{#else}"
225
+ i += 7
226
+ } else {
227
+ i++
228
+ }
229
+ }
230
+
231
+ // If no matching {/if} found, break to avoid issues
232
+ if (endIfStart === -1) {
233
+ break
234
+ }
235
+
236
+ const endIfEnd = endIfStart + 5 // length of "{/if}"
237
+
238
+ // Evaluate branches in order and pick the first one that matches
239
+ let selectedContent = ""
240
+ for (const branch of branches) {
241
+ if (branch.type === "else") {
242
+ // Else branch always matches if we get here
243
+ selectedContent = branch.content
244
+ break
245
+ } else if (branch.type === "if" || branch.type === "elseif") {
246
+ // Evaluate the condition
247
+ if (evaluateCondition(branch.condition, data)) {
248
+ selectedContent = branch.content
249
+ break
250
+ }
251
+ }
252
+ }
253
+
254
+ // Replace the entire {#if}...{/if} block with the selected content
255
+ result =
256
+ result.substring(0, ifStart) +
257
+ selectedContent +
258
+ result.substring(endIfEnd)
259
+ }
260
+
261
+ return result
262
+ }
263
+
264
+ module.exports = { processConditionals, isTruthy, evaluateCondition }
@@ -0,0 +1,171 @@
1
+ const { resolvePath } = require("./replaceVariables")
2
+ const { processConditionals } = require("./processConditionals")
3
+
4
+ /**
5
+ * Process {#each items}...{/each} blocks in text
6
+ * Repeats content for each item in an array
7
+ * Supports:
8
+ * - {#each items} - basic iteration, access current item with {item}
9
+ * - {#each items as item} - custom item variable name
10
+ * - {#each items as item, index} - access item and index
11
+ * @param {string} text - Text containing loop blocks
12
+ * @param {Object} data - Data object with variable values
13
+ * @returns {string} - Text with loops expanded
14
+ */
15
+ function processLoops(text, data) {
16
+ if (!text || typeof text !== "string") {
17
+ return text
18
+ }
19
+
20
+ // If no data, remove all {#each}...{/each} blocks
21
+ if (!data || typeof data !== "object") {
22
+ return text.replace(/\{#each\s+[^}]+\}[\s\S]*?\{\/each\}/g, "")
23
+ }
24
+
25
+ let result = text
26
+ let maxIterations = 100 // Prevent infinite loops
27
+ let iterations = 0
28
+
29
+ // Process all {#each} blocks (handles nested blocks by processing innermost first)
30
+ while (/{#each\s+[^}]+\}/.test(result) && iterations < maxIterations) {
31
+ iterations++
32
+
33
+ // Find the next {#each} block
34
+ // Matches: {#each items}, {#each items as item}, {#each items as item, index}
35
+ const eachMatch = result.match(
36
+ /\{#each\s+([a-zA-Z0-9_.[\]]+)(?:\s+as\s+([a-zA-Z0-9_]+)(?:\s*,\s*([a-zA-Z0-9_]+))?)?\}/,
37
+ )
38
+ if (!eachMatch) break
39
+
40
+ const fullEachTag = eachMatch[0]
41
+ const arrayPath = eachMatch[1].trim()
42
+ const itemName = eachMatch[2] ? eachMatch[2].trim() : "item"
43
+ const indexName = eachMatch[3] ? eachMatch[3].trim() : null
44
+
45
+ const eachStart = result.indexOf(fullEachTag)
46
+ const contentStart = eachStart + fullEachTag.length
47
+
48
+ // Find the matching {/each}
49
+ let depth = 1
50
+ let i = contentStart
51
+ let endEachStart = -1
52
+
53
+ while (i < result.length && depth > 0) {
54
+ if (result.substring(i, i + 6) === "{#each") {
55
+ depth++
56
+ i += 6
57
+ } else if (result.substring(i, i + 7) === "{/each}") {
58
+ depth--
59
+ if (depth === 0) {
60
+ endEachStart = i
61
+ break
62
+ }
63
+ i += 7
64
+ } else {
65
+ i++
66
+ }
67
+ }
68
+
69
+ // If no matching {/each} found, break to avoid issues
70
+ if (endEachStart === -1) {
71
+ break
72
+ }
73
+
74
+ const blockContent = result.substring(contentStart, endEachStart)
75
+ const endEachEnd = endEachStart + 7 // length of "{/each}"
76
+
77
+ // Resolve the array
78
+ const array = resolvePath(data, arrayPath)
79
+
80
+ // Generate repeated content
81
+ let expandedContent = ""
82
+
83
+ if (Array.isArray(array) && array.length > 0) {
84
+ for (let idx = 0; idx < array.length; idx++) {
85
+ const item = array[idx]
86
+
87
+ // Create a new data context with the item and index
88
+ const loopData = { ...data }
89
+ loopData[itemName] = item
90
+
91
+ if (indexName) {
92
+ loopData[indexName] = idx
93
+ }
94
+
95
+ // First, process any conditionals within the loop content using the loop data
96
+ let itemContent = processConditionals(blockContent, loopData)
97
+
98
+ // Then replace variables in the block content
99
+ itemContent = replaceLoopVariables(itemContent, loopData)
100
+
101
+ expandedContent += itemContent
102
+ }
103
+ }
104
+ // If array is empty or not an array, remove the block
105
+
106
+ // Replace the entire {#each}...{/each} block
107
+ result =
108
+ result.substring(0, eachStart) +
109
+ expandedContent +
110
+ result.substring(endEachEnd)
111
+ }
112
+
113
+ return result
114
+ }
115
+
116
+ /**
117
+ * Replace variables in text with values from loop data
118
+ * @param {string} text - Text containing variable placeholders
119
+ * @param {Object} loopData - Data object with loop variables
120
+ * @returns {string} - Text with variables replaced
121
+ */
122
+ function replaceLoopVariables(text, loopData) {
123
+ if (!text || typeof text !== "string") {
124
+ return text
125
+ }
126
+
127
+ let result = text
128
+ const regex = /\{([a-zA-Z0-9_.[\]]+)\}/g
129
+ let match
130
+
131
+ // Collect all variable matches first to avoid issues with overlapping replacements
132
+ const matches = []
133
+ while ((match = regex.exec(text)) !== null) {
134
+ matches.push({
135
+ fullMatch: match[0],
136
+ path: match[1],
137
+ index: match.index,
138
+ })
139
+ }
140
+
141
+ // Process matches in reverse order to maintain correct indices
142
+ for (let i = matches.length - 1; i >= 0; i--) {
143
+ const { fullMatch, path, index } = matches[i]
144
+
145
+ // Skip if it's a control structure tag
146
+ if (
147
+ path.startsWith("#") ||
148
+ path.startsWith("/") ||
149
+ fullMatch === "{#each}" ||
150
+ fullMatch === "{/each}" ||
151
+ fullMatch === "{#if}" ||
152
+ fullMatch === "{/if}"
153
+ ) {
154
+ continue
155
+ }
156
+
157
+ // Resolve the variable value
158
+ const value = resolvePath(loopData, path)
159
+
160
+ if (value !== undefined && value !== null) {
161
+ result =
162
+ result.substring(0, index) +
163
+ String(value) +
164
+ result.substring(index + fullMatch.length)
165
+ }
166
+ }
167
+
168
+ return result
169
+ }
170
+
171
+ module.exports = { processLoops }