boxwood 2.10.0 → 2.12.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.10.0",
3
+ "version": "2.12.0",
4
4
  "description": "Compile HTML templates into JS",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -14,10 +14,22 @@ const {
14
14
  Strong,
15
15
  Em,
16
16
  Code,
17
+ A,
18
+ Hr,
19
+ Img,
20
+ Pre,
17
21
  } = require("../..")
18
22
 
23
+ const { format } = require("./utilities/format")
24
+ const {
25
+ parseCustomTag,
26
+ resolveAttributes,
27
+ } = require("./utilities/parseCustomTag")
28
+ const { replaceVariables } = require("./utilities/replaceVariables")
29
+
19
30
  const ORDERED_LIST_REGEXP = /^\d+\.\s/
20
31
  const UNORDERED_MARKERS = ["- ", "— ", "– ", "• "]
32
+ const HORIZONTAL_RULE_REGEXP = /^(?:\*\s*\*\s*\*+|-\s*-\s*-+|_\s*_\s*_+)\s*$/
21
33
  const HEADINGS = [
22
34
  { prefix: "###### ", type: "h6" },
23
35
  { prefix: "##### ", type: "h5" },
@@ -34,62 +46,10 @@ const COMPONENTS = {
34
46
  h5: H5,
35
47
  h6: H6,
36
48
  blockquote: Blockquote,
49
+ hr: Hr,
37
50
  }
38
51
 
39
- function format(text) {
40
- if (!text.includes("*") && !text.includes("`")) {
41
- return text
42
- }
43
-
44
- const result = []
45
- let i = 0
46
- while (i < text.length) {
47
- if (text[i] === "`") {
48
- const start = i + 1
49
- const end = text.indexOf("`", start)
50
- if (end === -1) {
51
- result.push(text[i])
52
- i++
53
- } else {
54
- result.push(Code({}, text.substring(start, end)))
55
- i = end + 1
56
- }
57
- } else if (text[i] === "*" && text[i + 1] === "*") {
58
- const start = i + 2
59
- const end = text.indexOf("**", start)
60
- if (end === -1) {
61
- result.push(text[i])
62
- i++
63
- } else {
64
- result.push(Strong(text.substring(start, end)))
65
- i = end + 2
66
- }
67
- } else if (text[i] === "*") {
68
- const start = i + 1
69
- const end = text.indexOf("*", start)
70
- if (end === -1) {
71
- result.push(text[i])
72
- i++
73
- } else {
74
- result.push(Em(text.substring(start, end)))
75
- i = end + 1
76
- }
77
- } else {
78
- let next = text.length
79
- const nextCode = text.indexOf("`", i)
80
- const nextStar = text.indexOf("*", i)
81
- if (nextCode !== -1 && nextCode < next) next = nextCode
82
- if (nextStar !== -1 && nextStar < next) next = nextStar
83
- if (next === text.length) {
84
- result.push(text.substring(i))
85
- break
86
- }
87
- result.push(text.substring(i, next))
88
- i = next
89
- }
90
- }
91
- return result.length > 0 ? result : text
92
- }
52
+ const INLINE_COMPONENTS = { A, Img, Code, Strong, Em }
93
53
 
94
54
  function Markdown(params, children) {
95
55
  if (Array.isArray(children)) {
@@ -100,47 +60,170 @@ function Markdown(params, children) {
100
60
  return null
101
61
  }
102
62
 
103
- const lines = children
104
- .trim()
105
- .split("\n")
106
- .filter((line) => line.trim().length > 0)
107
- .map((line) => {
108
- // Preserve leading spaces for indentation tracking
109
- const trimmed = line.trim()
110
- const leadingSpaces = line.length - line.trimStart().length
111
- return { text: trimmed, indent: leadingSpaces }
112
- })
113
-
114
- const items = lines
115
- .map(({ text, indent }) => {
116
- if (UNORDERED_MARKERS.some((marker) => text.startsWith(marker))) {
117
- const content = text.substring(2)
118
- if (!content) return null
119
- return { type: "li", list: "ul", content, indent }
63
+ const customComponents = params && params.components
64
+ const data = params && params.data
65
+
66
+ // Filter out special params that shouldn't be passed to HTML elements
67
+ const htmlParams = params
68
+ ? Object.keys(params).reduce((acc, key) => {
69
+ if (key !== "components" && key !== "data") {
70
+ acc[key] = params[key]
71
+ }
72
+ return acc
73
+ }, {})
74
+ : {}
75
+
76
+ // First pass: detect code blocks before processing lines
77
+ const allLines = children.trim().split("\n")
78
+ const items = []
79
+ let i = 0
80
+
81
+ while (i < allLines.length) {
82
+ const line = allLines[i]
83
+ const trimmed = line.trim()
84
+
85
+ // Check for custom component tags first
86
+ const customTag = parseCustomTag(line, customComponents)
87
+ if (customTag) {
88
+ if (customTag.type === "custom-component" && customTag.selfClosing) {
89
+ // Self-closing custom component
90
+ items.push({
91
+ type: "custom-component",
92
+ tagName: customTag.tagName,
93
+ component: customTag.component,
94
+ attributes: customTag.attributes,
95
+ selfClosing: true,
96
+ indent: line.length - line.trimStart().length,
97
+ })
98
+ i++
99
+ continue
100
+ } else if (customTag.type === "custom-component-open") {
101
+ // Opening tag of a custom component
102
+ const openIndent = line.length - line.trimStart().length
103
+ const tagName = customTag.tagName
104
+ const componentFn = customTag.component
105
+ const attributes = customTag.attributes
106
+ const contentLines = []
107
+ i++ // Move past opening tag
108
+
109
+ // Collect content until closing tag
110
+ let depth = 1
111
+ while (i < allLines.length && depth > 0) {
112
+ const contentLine = allLines[i]
113
+ const contentTag = parseCustomTag(contentLine, customComponents)
114
+
115
+ if (contentTag && contentTag.tagName === tagName) {
116
+ if (contentTag.type === "custom-component-open") {
117
+ depth++
118
+ contentLines.push(contentLine)
119
+ } else if (contentTag.type === "custom-component-close") {
120
+ depth--
121
+ if (depth === 0) {
122
+ // Found matching closing tag
123
+ i++
124
+ break
125
+ }
126
+ contentLines.push(contentLine)
127
+ }
128
+ } else {
129
+ contentLines.push(contentLine)
130
+ }
131
+ i++
132
+ }
133
+
134
+ items.push({
135
+ type: "custom-component",
136
+ tagName,
137
+ component: componentFn,
138
+ attributes,
139
+ content: contentLines.join("\n"),
140
+ selfClosing: false,
141
+ indent: openIndent,
142
+ })
143
+ continue
120
144
  }
145
+ }
146
+
147
+ // Check for code block start
148
+ if (trimmed.startsWith("```")) {
149
+ const language = trimmed.substring(3).trim()
150
+ const codeLines = []
151
+ i++ // Move past the opening ```
121
152
 
122
- if (ORDERED_LIST_REGEXP.test(text)) {
123
- const content = text.replace(ORDERED_LIST_REGEXP, "")
124
- if (!content) return null
125
- return { type: "li", list: "ol", content, indent }
153
+ // Collect code block lines until closing ```
154
+ while (i < allLines.length) {
155
+ const codeLine = allLines[i]
156
+ if (codeLine.trim().startsWith("```")) {
157
+ // Found closing ```, don't include it
158
+ i++
159
+ break
160
+ }
161
+ codeLines.push(codeLine)
162
+ i++
126
163
  }
127
164
 
165
+ items.push({
166
+ type: "pre",
167
+ content: codeLines.join("\n"),
168
+ language: language || undefined,
169
+ indent: 0,
170
+ })
171
+ continue
172
+ }
173
+
174
+ // Skip empty lines
175
+ if (trimmed.length === 0) {
176
+ i++
177
+ continue
178
+ }
179
+
180
+ const leadingSpaces = line.length - line.trimStart().length
181
+
182
+ // Check for other block types
183
+ if (HORIZONTAL_RULE_REGEXP.test(trimmed)) {
184
+ items.push({ type: "hr", indent: leadingSpaces })
185
+ } else if (UNORDERED_MARKERS.some((marker) => trimmed.startsWith(marker))) {
186
+ const content = trimmed.substring(2)
187
+ if (content) {
188
+ items.push({ type: "li", list: "ul", content, indent: leadingSpaces })
189
+ }
190
+ } else if (ORDERED_LIST_REGEXP.test(trimmed)) {
191
+ const content = trimmed.replace(ORDERED_LIST_REGEXP, "")
192
+ if (content) {
193
+ items.push({ type: "li", list: "ol", content, indent: leadingSpaces })
194
+ }
195
+ } else {
196
+ let matched = false
128
197
  for (const { prefix, type } of HEADINGS) {
129
- if (text.startsWith(prefix)) {
130
- return { type, content: text.substring(prefix.length), indent }
198
+ if (trimmed.startsWith(prefix)) {
199
+ items.push({
200
+ type,
201
+ content: trimmed.substring(prefix.length),
202
+ indent: leadingSpaces,
203
+ })
204
+ matched = true
205
+ break
131
206
  }
132
207
  }
133
208
 
134
- if (text.startsWith("> ")) {
135
- return { type: "blockquote", content: text.substring(2), indent }
209
+ if (!matched) {
210
+ if (trimmed.startsWith("> ")) {
211
+ items.push({
212
+ type: "blockquote",
213
+ content: trimmed.substring(2),
214
+ indent: leadingSpaces,
215
+ })
216
+ } else {
217
+ items.push({ type: "p", content: trimmed, indent: leadingSpaces })
218
+ }
136
219
  }
220
+ }
137
221
 
138
- return { type: "p", content: text, indent }
139
- })
140
- .filter(Boolean)
222
+ i++
223
+ }
141
224
 
142
225
  const nodes = []
143
- let i = 0
226
+ i = 0
144
227
 
145
228
  function parseList(startIndex, parentIndent) {
146
229
  const list = []
@@ -166,7 +249,8 @@ function Markdown(params, children) {
166
249
  }
167
250
 
168
251
  // Process current item at the correct indent level
169
- const content = [format(item.content)]
252
+ const processedContent = replaceVariables(item.content, data)
253
+ const content = [format(processedContent, INLINE_COMPONENTS)]
170
254
  currentIndex++
171
255
 
172
256
  // Check if next item is a nested list
@@ -177,11 +261,11 @@ function Markdown(params, children) {
177
261
  currentIndex = nestedResult.nextIndex
178
262
  }
179
263
 
180
- list.push(Li(params, content))
264
+ list.push(Li(htmlParams, content))
181
265
  }
182
266
 
183
267
  const listElement =
184
- parentListType === "ul" ? Ul(params, list) : Ol(params, list)
268
+ parentListType === "ul" ? Ul(htmlParams, list) : Ol(htmlParams, list)
185
269
 
186
270
  return { list: listElement, nextIndex: currentIndex }
187
271
  }
@@ -189,7 +273,20 @@ function Markdown(params, children) {
189
273
  while (i < items.length) {
190
274
  const item = items[i]
191
275
 
192
- if (item.type === "li") {
276
+ if (item.type === "custom-component") {
277
+ // Handle custom component
278
+ const resolvedAttrs = resolveAttributes(item.attributes, data)
279
+
280
+ if (item.selfClosing) {
281
+ // Self-closing component
282
+ nodes.push(item.component(resolvedAttrs))
283
+ } else {
284
+ // Component with children - recursively process markdown content
285
+ const childContent = item.content ? Markdown(params, item.content) : []
286
+ nodes.push(item.component(resolvedAttrs, childContent))
287
+ }
288
+ i++
289
+ } else if (item.type === "li") {
193
290
  const result = parseList(i, item.indent)
194
291
  nodes.push(result.list)
195
292
  i = result.nextIndex
@@ -201,11 +298,30 @@ function Markdown(params, children) {
201
298
  i++
202
299
  }
203
300
 
204
- nodes.push(Blockquote(params, P(params, format(lines.join("\n")))))
301
+ const content = replaceVariables(lines.join("\n"), data)
302
+ nodes.push(
303
+ Blockquote(
304
+ htmlParams,
305
+ P(htmlParams, format(content, INLINE_COMPONENTS)),
306
+ ),
307
+ )
308
+ } else if (item.type === "hr") {
309
+ nodes.push(Hr(htmlParams))
310
+ i++
311
+ } else if (item.type === "pre") {
312
+ // Code blocks - wrap in <pre><code>
313
+ const codeParams = item.language
314
+ ? { class: `language-${item.language}` }
315
+ : {}
316
+ nodes.push(Pre(htmlParams, Code(codeParams, item.content)))
317
+ i++
205
318
  } else {
206
319
  const { type, content } = item
207
320
  const Component = COMPONENTS[type] || P
208
- nodes.push(Component(params, format(content)))
321
+ const processedContent = replaceVariables(content, data)
322
+ nodes.push(
323
+ Component(htmlParams, format(processedContent, INLINE_COMPONENTS)),
324
+ )
209
325
  i++
210
326
  }
211
327
  }
@@ -0,0 +1,21 @@
1
+ // Find the matching closing bracket, accounting for nested brackets
2
+ function findMatchingBracket(text, startPos) {
3
+ let depth = 1
4
+ let i = startPos + 1
5
+
6
+ while (i < text.length && depth > 0) {
7
+ if (text[i] === "[" && text[i - 1] !== "\\") {
8
+ depth++
9
+ } else if (text[i] === "]" && text[i - 1] !== "\\") {
10
+ depth--
11
+ if (depth === 0) {
12
+ return i
13
+ }
14
+ }
15
+ i++
16
+ }
17
+
18
+ return -1
19
+ }
20
+
21
+ module.exports = { findMatchingBracket }
@@ -0,0 +1,181 @@
1
+ const { findMatchingBracket } = require("./brackets")
2
+
3
+ /**
4
+ * Format inline markdown elements within text
5
+ * Handles: images, links, code, bold, italic
6
+ * @param {string} text - The text to format
7
+ * @param {Object} components - HTML component functions (A, Img, Code, Strong, Em)
8
+ * @returns {Array|string} - Formatted content as array of components and strings
9
+ */
10
+ function format(text, components) {
11
+ const { A, Img, Code, Strong, Em } = components
12
+
13
+ if (
14
+ !text.includes("*") &&
15
+ !text.includes("`") &&
16
+ !text.includes("[") &&
17
+ !text.includes("!") &&
18
+ !text.includes("<") &&
19
+ !text.includes("\\")
20
+ ) {
21
+ return text
22
+ }
23
+
24
+ const result = []
25
+ let i = 0
26
+
27
+ while (i < text.length) {
28
+ // Handle escape characters
29
+ if (text[i] === "\\") {
30
+ if (i + 1 < text.length) {
31
+ const nextChar = text[i + 1]
32
+ // Check if next char is a special markdown character
33
+ if (
34
+ nextChar === "*" ||
35
+ nextChar === "`" ||
36
+ nextChar === "[" ||
37
+ nextChar === "]" ||
38
+ nextChar === "(" ||
39
+ nextChar === ")" ||
40
+ nextChar === "!" ||
41
+ nextChar === "<" ||
42
+ nextChar === ">" ||
43
+ nextChar === "\\"
44
+ ) {
45
+ // Escape the next character - just add it as literal text
46
+ result.push(nextChar)
47
+ i += 2 // Skip both \ and the escaped character
48
+ continue
49
+ }
50
+ // If not a special char, keep both the backslash and the next character
51
+ result.push(text[i])
52
+ result.push(nextChar)
53
+ i += 2
54
+ continue
55
+ } else {
56
+ // Backslash at end of string
57
+ result.push(text[i])
58
+ i++
59
+ continue
60
+ }
61
+ }
62
+
63
+ if (text[i] === "!" && text[i + 1] === "[") {
64
+ // Try to parse markdown image ![alt](url)
65
+ const altEnd = text.indexOf("]", i + 2)
66
+
67
+ if (altEnd !== -1 && text[altEnd + 1] === "(") {
68
+ const urlEnd = text.indexOf(")", altEnd + 2)
69
+
70
+ if (urlEnd !== -1) {
71
+ const alt = text.substring(i + 2, altEnd)
72
+ const src = text.substring(altEnd + 2, urlEnd)
73
+ result.push(Img({ src, alt }))
74
+ i = urlEnd + 1
75
+ continue
76
+ }
77
+ }
78
+
79
+ // Not a valid image, treat as regular text (skip both ! and [)
80
+ result.push(text[i])
81
+ i++
82
+ } else if (text[i] === "[") {
83
+ // Try to parse markdown link [text](url)
84
+ const textEnd = findMatchingBracket(text, i)
85
+
86
+ if (textEnd !== -1 && text[textEnd + 1] === "(") {
87
+ const urlEnd = text.indexOf(")", textEnd + 2)
88
+
89
+ if (urlEnd !== -1) {
90
+ const linkText = text.substring(i + 1, textEnd)
91
+ const url = text.substring(textEnd + 2, urlEnd)
92
+ // Recursively format the link text to support images, bold, italic inside links
93
+ result.push(A({ href: url }, format(linkText, components)))
94
+ i = urlEnd + 1
95
+ continue
96
+ }
97
+ }
98
+
99
+ // Not a valid link, treat as regular text
100
+ result.push(text[i])
101
+ i++
102
+ } else if (text[i] === "<") {
103
+ // Try to parse autolink <url> or <email>
104
+ const end = text.indexOf(">", i + 1)
105
+
106
+ if (end !== -1) {
107
+ const content = text.substring(i + 1, end)
108
+
109
+ // Check if it's a URL (starts with http:// or https://)
110
+ if (content.startsWith("http://") || content.startsWith("https://")) {
111
+ result.push(A({ href: content }, content))
112
+ i = end + 1
113
+ continue
114
+ }
115
+
116
+ // Check if it's an email (contains @ and looks like email)
117
+ if (content.includes("@") && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(content)) {
118
+ result.push(A({ href: `mailto:${content}` }, content))
119
+ i = end + 1
120
+ continue
121
+ }
122
+ }
123
+
124
+ // Not a valid autolink, treat as regular text
125
+ result.push(text[i])
126
+ i++
127
+ } else if (text[i] === "`") {
128
+ const end = text.indexOf("`", i + 1)
129
+ if (end === -1) {
130
+ result.push(text[i])
131
+ i++
132
+ } else {
133
+ result.push(Code({}, text.substring(i + 1, end)))
134
+ i = end + 1
135
+ }
136
+ } else if (text[i] === "*" && text[i + 1] === "*") {
137
+ const end = text.indexOf("**", i + 2)
138
+ if (end === -1) {
139
+ result.push(text[i])
140
+ i++
141
+ } else {
142
+ result.push(Strong(format(text.substring(i + 2, end), components)))
143
+ i = end + 2
144
+ }
145
+ } else if (text[i] === "*") {
146
+ const end = text.indexOf("*", i + 1)
147
+ if (end === -1) {
148
+ result.push(text[i])
149
+ i++
150
+ } else {
151
+ result.push(Em(format(text.substring(i + 1, end), components)))
152
+ i = end + 1
153
+ }
154
+ } else {
155
+ // Find next special character
156
+ const positions = [
157
+ text.indexOf("`", i),
158
+ text.indexOf("*", i),
159
+ text.indexOf("[", i),
160
+ text.indexOf("<", i),
161
+ text.indexOf("\\", i),
162
+ ].filter((pos) => pos !== -1)
163
+
164
+ // Look for image pattern ![
165
+ const exclamPos = text.indexOf("!", i)
166
+ if (exclamPos !== -1 && text[exclamPos + 1] === "[") {
167
+ positions.push(exclamPos)
168
+ }
169
+
170
+ const next = positions.length > 0 ? Math.min(...positions) : text.length
171
+
172
+ result.push(text.substring(i, next))
173
+ if (next === text.length) break
174
+ i = next
175
+ }
176
+ }
177
+
178
+ return result.length > 0 ? result : text
179
+ }
180
+
181
+ module.exports = { format }
@@ -0,0 +1,198 @@
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 self-closing tag: <ComponentName ... /> (space before / is optional)
16
+ const selfClosingMatch = trimmed.match(
17
+ /^<([A-Za-z][A-Za-z0-9-]*)(\s+[^>]*)?\s*\/>\s*$/,
18
+ )
19
+ if (selfClosingMatch) {
20
+ const [, tagName, attributesStr] = selfClosingMatch
21
+ const component = customComponents[tagName]
22
+ if (component) {
23
+ const attributes = parseAttributes(attributesStr || "")
24
+ return {
25
+ type: "custom-component",
26
+ tagName,
27
+ component,
28
+ attributes,
29
+ selfClosing: true,
30
+ }
31
+ }
32
+ }
33
+
34
+ // Check for opening tag: <ComponentName ...>
35
+ const openTagMatch = trimmed.match(
36
+ /^<([A-Za-z][A-Za-z0-9-]*)(\s+[^>]*)?>\s*$/,
37
+ )
38
+ if (openTagMatch) {
39
+ const [, tagName, attributesStr] = openTagMatch
40
+ const component = customComponents[tagName]
41
+ if (component) {
42
+ const attributes = parseAttributes(attributesStr || "")
43
+ return {
44
+ type: "custom-component-open",
45
+ tagName,
46
+ component,
47
+ attributes,
48
+ selfClosing: false,
49
+ }
50
+ }
51
+ }
52
+
53
+ // Check for closing tag: </ComponentName>
54
+ const closeTagMatch = trimmed.match(/^<\/([A-Za-z][A-Za-z0-9-]*)>\s*$/)
55
+ if (closeTagMatch) {
56
+ const [, tagName] = closeTagMatch
57
+ const component = customComponents[tagName]
58
+ if (component) {
59
+ return {
60
+ type: "custom-component-close",
61
+ tagName,
62
+ component,
63
+ }
64
+ }
65
+ }
66
+
67
+ return null
68
+ }
69
+
70
+ /**
71
+ * Parse attributes from a tag string
72
+ * Supports: attr="value", attr='value', attr={variable}, attr
73
+ * @param {string} attributesStr - The attributes string
74
+ * @returns {Object} - Parsed attributes
75
+ */
76
+ function parseAttributes(attributesStr) {
77
+ const attributes = {}
78
+ if (!attributesStr || !attributesStr.trim()) {
79
+ return attributes
80
+ }
81
+
82
+ const str = attributesStr.trim()
83
+ let i = 0
84
+
85
+ while (i < str.length) {
86
+ // Skip whitespace
87
+ while (i < str.length && /\s/.test(str[i])) {
88
+ i++
89
+ }
90
+
91
+ if (i >= str.length) break
92
+
93
+ // Parse attribute name
94
+ let nameStart = i
95
+ while (i < str.length && /[a-zA-Z0-9-_:]/.test(str[i])) {
96
+ i++
97
+ }
98
+
99
+ const name = str.substring(nameStart, i)
100
+ if (!name) break
101
+
102
+ // Skip whitespace after name
103
+ while (i < str.length && /\s/.test(str[i])) {
104
+ i++
105
+ }
106
+
107
+ // Check for =
108
+ if (i >= str.length || str[i] !== "=") {
109
+ // Boolean attribute
110
+ attributes[name] = true
111
+ continue
112
+ }
113
+
114
+ i++ // Skip =
115
+
116
+ // Skip whitespace after =
117
+ while (i < str.length && /\s/.test(str[i])) {
118
+ i++
119
+ }
120
+
121
+ if (i >= str.length) break
122
+
123
+ // Parse value
124
+ const quote = str[i]
125
+
126
+ if (quote === '"' || quote === "'") {
127
+ // Quoted string
128
+ i++ // Skip opening quote
129
+ let value = ""
130
+ while (i < str.length && str[i] !== quote) {
131
+ if (str[i] === "\\" && i + 1 < str.length) {
132
+ // Escaped character - add the next character literally
133
+ value += str[i + 1]
134
+ i += 2
135
+ } else {
136
+ value += str[i]
137
+ i++
138
+ }
139
+ }
140
+ attributes[name] = value
141
+ if (i < str.length) i++ // Skip closing quote
142
+ } else if (str[i] === "{") {
143
+ // Variable reference
144
+ i++ // Skip {
145
+ const valueStart = i
146
+ while (i < str.length && str[i] !== "}") {
147
+ i++
148
+ }
149
+ const variableName = str.substring(valueStart, i).trim()
150
+ attributes[name] = { __variable: variableName }
151
+ if (i < str.length) i++ // Skip }
152
+ } else {
153
+ // Unquoted value (until space or end)
154
+ const valueStart = i
155
+ while (i < str.length && !/\s/.test(str[i])) {
156
+ i++
157
+ }
158
+ const value = str.substring(valueStart, i)
159
+ attributes[name] = value
160
+ }
161
+ }
162
+
163
+ return attributes
164
+ }
165
+
166
+ /**
167
+ * Resolve attributes by replacing variable references with actual values
168
+ * @param {Object} attributes - Attributes with possible variable references
169
+ * @param {Object} data - Data object containing variable values
170
+ * @returns {Object} - Resolved attributes
171
+ */
172
+ function resolveAttributes(attributes, data) {
173
+ if (!attributes || typeof attributes !== "object") {
174
+ return {}
175
+ }
176
+
177
+ const resolved = {}
178
+ for (const [key, value] of Object.entries(attributes)) {
179
+ if (value && typeof value === "object" && value.__variable) {
180
+ // Resolve variable
181
+ const variableName = value.__variable
182
+ resolved[key] =
183
+ data && data[variableName] !== undefined
184
+ ? data[variableName]
185
+ : undefined
186
+ } else {
187
+ resolved[key] = value
188
+ }
189
+ }
190
+
191
+ return resolved
192
+ }
193
+
194
+ module.exports = {
195
+ parseCustomTag,
196
+ parseAttributes,
197
+ resolveAttributes,
198
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Replace {variableName} placeholders in text with actual values from data
3
+ * @param {string} text - Text containing variable placeholders
4
+ * @param {Object} data - Data object with variable values
5
+ * @returns {string|Array} - Text with variables replaced, or array if mixed content
6
+ */
7
+ function replaceVariables(text, data) {
8
+ if (!text || typeof text !== "string") {
9
+ return text
10
+ }
11
+
12
+ if (!data || typeof data !== "object") {
13
+ return text
14
+ }
15
+
16
+ // Check if text contains any variables
17
+ if (!text.includes("{") || !text.includes("}")) {
18
+ return text
19
+ }
20
+
21
+ const result = []
22
+ let i = 0
23
+ let lastIndex = 0
24
+
25
+ while (i < text.length) {
26
+ if (text[i] === "\\" && text[i + 1] === "{") {
27
+ // Escaped opening brace
28
+ result.push(text.substring(lastIndex, i))
29
+ result.push("{")
30
+ i += 2
31
+ lastIndex = i
32
+ continue
33
+ }
34
+
35
+ if (text[i] === "{") {
36
+ const closeIndex = text.indexOf("}", i + 1)
37
+
38
+ if (closeIndex !== -1) {
39
+ // Found a variable placeholder
40
+ const variableName = text.substring(i + 1, closeIndex).trim()
41
+
42
+ if (variableName) {
43
+ // Add text before the variable
44
+ if (i > lastIndex) {
45
+ result.push(text.substring(lastIndex, i))
46
+ }
47
+
48
+ // Add the variable value
49
+ const value = data[variableName]
50
+ if (value !== undefined && value !== null) {
51
+ result.push(String(value))
52
+ } else {
53
+ // Variable not found, keep the placeholder
54
+ result.push(text.substring(i, closeIndex + 1))
55
+ }
56
+
57
+ i = closeIndex + 1
58
+ lastIndex = i
59
+ continue
60
+ }
61
+ }
62
+ }
63
+
64
+ i++
65
+ }
66
+
67
+ // Add remaining text
68
+ if (lastIndex < text.length) {
69
+ result.push(text.substring(lastIndex))
70
+ }
71
+
72
+ // If no substitutions were made, return original text
73
+ if (result.length === 0) {
74
+ return text
75
+ }
76
+
77
+ return result.join("")
78
+ }
79
+
80
+ module.exports = { replaceVariables }