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.
@@ -4,11 +4,11 @@ const { findMatchingBracket } = require("./brackets")
4
4
  * Format inline markdown elements within text
5
5
  * Handles: images, links, code, bold, italic
6
6
  * @param {string} text - The text to format
7
- * @param {Object} components - HTML component functions (A, Img, Code, Strong, Em)
7
+ * @param {Object} allComponents - HTML component functions (contains a, img, code, strong, em, etc.)
8
8
  * @returns {Array|string} - Formatted content as array of components and strings
9
9
  */
10
- function format(text, components) {
11
- const { A, Img, Code, Strong, Em } = components
10
+ function format(text, allComponents) {
11
+ const { a: A, img: Img, code: Code, strong: Strong, em: Em } = allComponents
12
12
 
13
13
  if (
14
14
  !text.includes("*") &&
@@ -59,7 +59,7 @@ function format(text, components) {
59
59
  continue
60
60
  }
61
61
  }
62
-
62
+
63
63
  if (text[i] === "!" && text[i + 1] === "[") {
64
64
  // Try to parse markdown image ![alt](url)
65
65
  const altEnd = text.indexOf("]", i + 2)
@@ -90,7 +90,7 @@ function format(text, components) {
90
90
  const linkText = text.substring(i + 1, textEnd)
91
91
  const url = text.substring(textEnd + 2, urlEnd)
92
92
  // Recursively format the link text to support images, bold, italic inside links
93
- result.push(A({ href: url }, format(linkText, components)))
93
+ result.push(A({ href: url }, format(linkText, allComponents)))
94
94
  i = urlEnd + 1
95
95
  continue
96
96
  }
@@ -102,25 +102,28 @@ function format(text, components) {
102
102
  } else if (text[i] === "<") {
103
103
  // Try to parse autolink <url> or <email>
104
104
  const end = text.indexOf(">", i + 1)
105
-
105
+
106
106
  if (end !== -1) {
107
107
  const content = text.substring(i + 1, end)
108
-
108
+
109
109
  // Check if it's a URL (starts with http:// or https://)
110
110
  if (content.startsWith("http://") || content.startsWith("https://")) {
111
111
  result.push(A({ href: content }, content))
112
112
  i = end + 1
113
113
  continue
114
114
  }
115
-
115
+
116
116
  // Check if it's an email (contains @ and looks like email)
117
- if (content.includes("@") && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(content)) {
117
+ if (
118
+ content.includes("@") &&
119
+ /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(content)
120
+ ) {
118
121
  result.push(A({ href: `mailto:${content}` }, content))
119
122
  i = end + 1
120
123
  continue
121
124
  }
122
125
  }
123
-
126
+
124
127
  // Not a valid autolink, treat as regular text
125
128
  result.push(text[i])
126
129
  i++
@@ -139,7 +142,7 @@ function format(text, components) {
139
142
  result.push(text[i])
140
143
  i++
141
144
  } else {
142
- result.push(Strong(format(text.substring(i + 2, end), components)))
145
+ result.push(Strong(format(text.substring(i + 2, end), allComponents)))
143
146
  i = end + 2
144
147
  }
145
148
  } else if (text[i] === "*") {
@@ -148,7 +151,7 @@ function format(text, components) {
148
151
  result.push(text[i])
149
152
  i++
150
153
  } else {
151
- result.push(Em(format(text.substring(i + 1, end), components)))
154
+ result.push(Em(format(text.substring(i + 1, end), allComponents)))
152
155
  i = end + 1
153
156
  }
154
157
  } else {
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Extracts HTML parameters from params object by filtering out special keys
3
+ */
4
+ function extractHtmlParams(params) {
5
+ if (!params) return {}
6
+
7
+ return Object.keys(params).reduce((acc, key) => {
8
+ if (key !== "components" && key !== "data") {
9
+ acc[key] = params[key]
10
+ }
11
+ return acc
12
+ }, {})
13
+ }
14
+
15
+ /**
16
+ * Merges builtin HTML tags with custom components
17
+ * Custom components can override builtin tags if needed
18
+ */
19
+ function mergeComponents(builtinHtmlTags, customComponents) {
20
+ return {
21
+ ...builtinHtmlTags,
22
+ ...customComponents,
23
+ }
24
+ }
25
+
26
+ module.exports = {
27
+ extractHtmlParams,
28
+ mergeComponents,
29
+ }
@@ -0,0 +1,284 @@
1
+ const { parseCustomTag } = require("./parseCustomTag")
2
+ const { replaceVariables } = require("./replaceVariables")
3
+ const { format } = require("./format")
4
+
5
+ // Markdown patterns
6
+ const ORDERED_LIST_REGEXP = /^\d+\.\s/
7
+ const UNORDERED_MARKERS = ["- ", "— ", "– ", "• "]
8
+ const HORIZONTAL_RULE_REGEXP = /^(?:\*\s*\*\s*\*+|-\s*-\s*-+|_\s*_\s*_+)\s*$/
9
+ const HEADINGS = [
10
+ { prefix: "###### ", type: "h6" },
11
+ { prefix: "##### ", type: "h5" },
12
+ { prefix: "#### ", type: "h4" },
13
+ { prefix: "### ", type: "h3" },
14
+ { prefix: "## ", type: "h2" },
15
+ { prefix: "# ", type: "h1" },
16
+ ]
17
+
18
+ /**
19
+ * Checks if a line is a code block delimiter (```)
20
+ */
21
+ function isCodeBlockDelimiter(trimmedLine) {
22
+ return trimmedLine.startsWith("```")
23
+ }
24
+
25
+ /**
26
+ * Parses a code block starting from the current index
27
+ * Returns the code block item and the next index to process
28
+ */
29
+ function parseCodeBlock(allLines, startIndex) {
30
+ const line = allLines[startIndex]
31
+ const language = line.trim().substring(3).trim()
32
+ const codeLines = []
33
+ let i = startIndex + 1
34
+
35
+ // Collect lines until closing ```
36
+ while (i < allLines.length) {
37
+ const codeLine = allLines[i]
38
+ if (isCodeBlockDelimiter(codeLine.trim())) {
39
+ i++ // Skip closing delimiter
40
+ break
41
+ }
42
+ codeLines.push(codeLine)
43
+ i++
44
+ }
45
+
46
+ return {
47
+ item: {
48
+ type: "pre",
49
+ content: codeLines.join("\n"),
50
+ language: language || undefined,
51
+ indent: 0,
52
+ },
53
+ nextIndex: i,
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Collects content lines for a multi-line custom component
59
+ */
60
+ function collectComponentContent(allLines, startIndex, tagName, allComponents) {
61
+ const contentLines = []
62
+ let i = startIndex
63
+ let depth = 1
64
+
65
+ while (i < allLines.length && depth > 0) {
66
+ const contentLine = allLines[i]
67
+ const contentTag = parseCustomTag(contentLine, allComponents)
68
+
69
+ if (contentTag && contentTag.tagName === tagName) {
70
+ if (contentTag.type === "custom-component-open") {
71
+ depth++
72
+ contentLines.push(contentLine)
73
+ } else if (contentTag.type === "custom-component-close") {
74
+ depth--
75
+ if (depth === 0) {
76
+ i++
77
+ break
78
+ }
79
+ contentLines.push(contentLine)
80
+ }
81
+ } else {
82
+ contentLines.push(contentLine)
83
+ }
84
+ i++
85
+ }
86
+
87
+ return {
88
+ content: contentLines.join("\n"),
89
+ nextIndex: i,
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Processes a custom component tag and returns the appropriate item
95
+ */
96
+ function processCustomComponent(
97
+ line,
98
+ allLines,
99
+ currentIndex,
100
+ customTag,
101
+ data,
102
+ allComponents,
103
+ ) {
104
+ const indent = line.length - line.trimStart().length
105
+
106
+ // Single-line component with content
107
+ if (customTag.type === "custom-component-single-line") {
108
+ const processedContent = replaceVariables(customTag.content, data)
109
+ const formattedContent = format(processedContent, allComponents)
110
+ return {
111
+ item: {
112
+ type: "custom-component",
113
+ tagName: customTag.tagName,
114
+ component: customTag.component,
115
+ attributes: customTag.attributes,
116
+ content: formattedContent,
117
+ selfClosing: false,
118
+ singleLine: true,
119
+ indent,
120
+ },
121
+ nextIndex: currentIndex + 1,
122
+ }
123
+ }
124
+
125
+ // Self-closing component
126
+ if (customTag.type === "custom-component" && customTag.selfClosing) {
127
+ return {
128
+ item: {
129
+ type: "custom-component",
130
+ tagName: customTag.tagName,
131
+ component: customTag.component,
132
+ attributes: customTag.attributes,
133
+ selfClosing: true,
134
+ indent,
135
+ },
136
+ nextIndex: currentIndex + 1,
137
+ }
138
+ }
139
+
140
+ // Multi-line component
141
+ if (customTag.type === "custom-component-open") {
142
+ const { content, nextIndex } = collectComponentContent(
143
+ allLines,
144
+ currentIndex + 1,
145
+ customTag.tagName,
146
+ allComponents,
147
+ )
148
+
149
+ return {
150
+ item: {
151
+ type: "custom-component",
152
+ tagName: customTag.tagName,
153
+ component: customTag.component,
154
+ attributes: customTag.attributes,
155
+ content,
156
+ selfClosing: false,
157
+ indent,
158
+ },
159
+ nextIndex,
160
+ }
161
+ }
162
+
163
+ return null
164
+ }
165
+
166
+ /**
167
+ * Determines the type and content of a markdown line
168
+ */
169
+ function parseMarkdownLine(line) {
170
+ const trimmed = line.trim()
171
+ const leadingSpaces = line.length - line.trimStart().length
172
+
173
+ // Horizontal rule
174
+ if (HORIZONTAL_RULE_REGEXP.test(trimmed)) {
175
+ return { type: "hr", indent: leadingSpaces }
176
+ }
177
+
178
+ // Unordered list
179
+ const unorderedMarker = UNORDERED_MARKERS.find((marker) =>
180
+ trimmed.startsWith(marker),
181
+ )
182
+ if (unorderedMarker) {
183
+ const content = trimmed.substring(2)
184
+ if (content) {
185
+ return { type: "li", list: "ul", content, indent: leadingSpaces }
186
+ }
187
+ }
188
+
189
+ // Ordered list
190
+ if (ORDERED_LIST_REGEXP.test(trimmed)) {
191
+ const content = trimmed.replace(ORDERED_LIST_REGEXP, "")
192
+ if (content) {
193
+ return { type: "li", list: "ol", content, indent: leadingSpaces }
194
+ }
195
+ }
196
+
197
+ // Headings
198
+ for (const { prefix, type } of HEADINGS) {
199
+ if (trimmed.startsWith(prefix)) {
200
+ return {
201
+ type,
202
+ content: trimmed.substring(prefix.length),
203
+ indent: leadingSpaces,
204
+ }
205
+ }
206
+ }
207
+
208
+ // Blockquote
209
+ if (trimmed.startsWith("> ")) {
210
+ return {
211
+ type: "blockquote",
212
+ content: trimmed.substring(2),
213
+ indent: leadingSpaces,
214
+ }
215
+ }
216
+
217
+ // Default to paragraph
218
+ return { type: "p", content: trimmed, indent: leadingSpaces }
219
+ }
220
+
221
+ /**
222
+ * Parses all lines of markdown into structured items
223
+ */
224
+ function parseMarkdownLines(children, allComponents, data) {
225
+ const allLines = children.trim().split("\n")
226
+ const items = []
227
+ let i = 0
228
+
229
+ while (i < allLines.length) {
230
+ const line = allLines[i]
231
+ const trimmed = line.trim()
232
+
233
+ // Skip empty lines
234
+ if (trimmed.length === 0) {
235
+ i++
236
+ continue
237
+ }
238
+
239
+ // Check for custom component tags
240
+ const customTag = parseCustomTag(line, allComponents)
241
+ if (customTag) {
242
+ const result = processCustomComponent(
243
+ line,
244
+ allLines,
245
+ i,
246
+ customTag,
247
+ data,
248
+ allComponents,
249
+ )
250
+ if (result) {
251
+ items.push(result.item)
252
+ i = result.nextIndex
253
+ continue
254
+ }
255
+ }
256
+
257
+ // Check for code blocks
258
+ if (isCodeBlockDelimiter(trimmed)) {
259
+ const { item, nextIndex } = parseCodeBlock(allLines, i)
260
+ items.push(item)
261
+ i = nextIndex
262
+ continue
263
+ }
264
+
265
+ // Parse regular markdown line
266
+ const item = parseMarkdownLine(line)
267
+ if (item) {
268
+ items.push(item)
269
+ }
270
+
271
+ i++
272
+ }
273
+
274
+ return items
275
+ }
276
+
277
+ module.exports = {
278
+ isCodeBlockDelimiter,
279
+ parseCodeBlock,
280
+ collectComponentContent,
281
+ processCustomComponent,
282
+ parseMarkdownLine,
283
+ parseMarkdownLines,
284
+ }
@@ -12,6 +12,26 @@ function parseCustomTag(line, customComponents) {
12
12
 
13
13
  const trimmed = line.trim()
14
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
+
15
35
  // Check for self-closing tag: <ComponentName ... /> (space before / is optional)
16
36
  const selfClosingMatch = trimmed.match(
17
37
  /^<([A-Za-z][A-Za-z0-9-]*)(\s+[^>]*)?\s*\/>\s*$/,
@@ -1,5 +1,40 @@
1
+ /**
2
+ * Resolve a path like "images[0].src" or "user.name" from a data object
3
+ * @param {Object} data - The data object to resolve the path from
4
+ * @param {string} path - The path to resolve (e.g., "images[0].src", "user.name")
5
+ * @returns {*} - The resolved value or undefined
6
+ */
7
+ function resolvePath(data, path) {
8
+ // Handle simple variable names (backwards compatibility)
9
+ if (!/[.\[]/.test(path)) {
10
+ return data[path]
11
+ }
12
+
13
+ // Split the path into parts, handling both dot notation and bracket notation
14
+ // e.g., "images[0].src" -> ["images", "0", "src"]
15
+ const parts = path
16
+ .replace(/\[(\d+)\]/g, ".$1") // Convert [0] to .0
17
+ .split(".")
18
+ .filter(Boolean)
19
+
20
+ let current = data
21
+ for (const part of parts) {
22
+ if (current === null || current === undefined) {
23
+ return undefined
24
+ }
25
+ current = current[part]
26
+ }
27
+
28
+ return current
29
+ }
30
+
1
31
  /**
2
32
  * Replace {variableName} placeholders in text with actual values from data
33
+ * Supports:
34
+ * - Simple variables: {name}
35
+ * - Array indexing: {images[0]}
36
+ * - Property access: {user.name}
37
+ * - Combined: {images[0].src}
3
38
  * @param {string} text - Text containing variable placeholders
4
39
  * @param {Object} data - Data object with variable values
5
40
  * @returns {string|Array} - Text with variables replaced, or array if mixed content
@@ -37,16 +72,16 @@ function replaceVariables(text, data) {
37
72
 
38
73
  if (closeIndex !== -1) {
39
74
  // Found a variable placeholder
40
- const variableName = text.substring(i + 1, closeIndex).trim()
75
+ const variablePath = text.substring(i + 1, closeIndex).trim()
41
76
 
42
- if (variableName) {
77
+ if (variablePath) {
43
78
  // Add text before the variable
44
79
  if (i > lastIndex) {
45
80
  result.push(text.substring(lastIndex, i))
46
81
  }
47
82
 
48
- // Add the variable value
49
- const value = data[variableName]
83
+ // Resolve the variable value (supports paths like "images[0].src")
84
+ const value = resolvePath(data, variablePath)
50
85
  if (value !== undefined && value !== null) {
51
86
  result.push(String(value))
52
87
  } else {
package/ui/normalize.js CHANGED
@@ -47,7 +47,7 @@ function normalizeGap(gap) {
47
47
  if (!gap) {
48
48
  return "1rem"
49
49
  }
50
-
50
+
51
51
  if (GAP_MAP.hasOwnProperty(gap)) {
52
52
  return GAP_MAP[gap]
53
53
  }