boxwood 2.13.0 → 2.15.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 +2 -3
- package/ui/grid/index.js +2 -5
- package/ui/index.js +2 -0
- package/ui/markdown/README.md +48 -0
- package/ui/markdown/index.js +43 -315
- package/ui/markdown/utilities/convertNodes.js +193 -0
- package/ui/markdown/utilities/format.js +15 -12
- package/ui/markdown/utilities/params.js +29 -0
- package/ui/markdown/utilities/parseBlock.js +284 -0
- package/ui/markdown/utilities/parseCustomTag.js +20 -0
- package/ui/markdown/utilities/replaceVariables.js +39 -4
- package/ui/normalize.js +1 -1
- package/ui/prose/README.md +492 -0
- package/ui/prose/index.js +159 -0
- package/ui/prose/utilities/brackets.js +21 -0
- package/ui/prose/utilities/convertNodes.js +193 -0
- package/ui/prose/utilities/format.js +184 -0
- package/ui/prose/utilities/params.js +29 -0
- package/ui/prose/utilities/parseBlock.js +284 -0
- package/ui/prose/utilities/parseCustomTag.js +218 -0
- package/ui/prose/utilities/processConditionals.js +264 -0
- package/ui/prose/utilities/processLoops.js +171 -0
- package/ui/prose/utilities/replaceVariables.js +115 -0
- package/examples/typescript-example.ts +0 -49
|
@@ -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 }
|