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,194 @@
1
+ const { resolveAttributes } = require("./parseCustomTag")
2
+ const { replaceVariables } = require("./replaceVariables")
3
+ const { format } = require("./format")
4
+
5
+ /**
6
+ * Recursively parses nested lists with proper indentation handling
7
+ */
8
+ function parseList(
9
+ items,
10
+ startIndex,
11
+ parentIndent,
12
+ htmlParams,
13
+ data,
14
+ allComponents,
15
+ ) {
16
+ const list = []
17
+ let currentIndex = startIndex
18
+ const parentListType = items[startIndex].list
19
+
20
+ while (currentIndex < items.length) {
21
+ const item = items[currentIndex]
22
+
23
+ // Stop conditions
24
+ if (item.type !== "li" || item.indent < parentIndent) {
25
+ break
26
+ }
27
+
28
+ if (item.indent === parentIndent && item.list !== parentListType) {
29
+ break
30
+ }
31
+
32
+ if (item.indent > parentIndent) {
33
+ break
34
+ }
35
+
36
+ // Process current list item
37
+ const processedContent = replaceVariables(item.content, data)
38
+ const content = [format(processedContent, allComponents)]
39
+ currentIndex++
40
+
41
+ // Check for nested lists
42
+ const nextItem = items[currentIndex]
43
+ if (nextItem?.type === "li" && nextItem.indent > item.indent) {
44
+ const nestedResult = parseList(
45
+ items,
46
+ currentIndex,
47
+ nextItem.indent,
48
+ htmlParams,
49
+ data,
50
+ allComponents,
51
+ )
52
+ content.push(nestedResult.list)
53
+ currentIndex = nestedResult.nextIndex
54
+ }
55
+
56
+ list.push(allComponents.li(htmlParams, content))
57
+ }
58
+
59
+ const listElement =
60
+ parentListType === "ul"
61
+ ? allComponents.ul(htmlParams, list)
62
+ : allComponents.ol(htmlParams, list)
63
+
64
+ return { list: listElement, nextIndex: currentIndex }
65
+ }
66
+
67
+ /**
68
+ * Processes a custom component item into a node
69
+ */
70
+ function processCustomComponentItem(item, params, data, markdownRenderer) {
71
+ const resolvedAttrs = resolveAttributes(item.attributes, data)
72
+
73
+ if (item.selfClosing) {
74
+ return item.component(resolvedAttrs)
75
+ }
76
+
77
+ if (item.singleLine) {
78
+ return item.component(resolvedAttrs, item.content)
79
+ }
80
+
81
+ // Multi-line component with children - recursively process markdown
82
+ const childContent = item.content
83
+ ? markdownRenderer(params, item.content)
84
+ : []
85
+ return item.component(resolvedAttrs, childContent)
86
+ }
87
+
88
+ /**
89
+ * Processes blockquote items into a single blockquote node
90
+ */
91
+ function processBlockquotes(
92
+ items,
93
+ startIndex,
94
+ htmlParams,
95
+ data,
96
+ allComponents,
97
+ ) {
98
+ const lines = []
99
+ let i = startIndex
100
+
101
+ while (i < items.length && items[i].type === "blockquote") {
102
+ lines.push(items[i].content)
103
+ i++
104
+ }
105
+
106
+ const content = replaceVariables(lines.join("\n"), data)
107
+ const blockquote = allComponents.blockquote(
108
+ htmlParams,
109
+ allComponents.p(htmlParams, format(content, allComponents)),
110
+ )
111
+
112
+ return { node: blockquote, nextIndex: i }
113
+ }
114
+
115
+ /**
116
+ * Converts parsed items into final node tree
117
+ */
118
+ function convertItemsToNodes(
119
+ items,
120
+ params,
121
+ htmlParams,
122
+ data,
123
+ allComponents,
124
+ components,
125
+ markdownRenderer,
126
+ ) {
127
+ const result = []
128
+ let i = 0
129
+
130
+ while (i < items.length) {
131
+ const item = items[i]
132
+
133
+ if (item.type === "custom-component") {
134
+ result.push(
135
+ processCustomComponentItem(item, params, data, markdownRenderer),
136
+ )
137
+ i++
138
+ } else if (item.type === "li") {
139
+ const listResult = parseList(
140
+ items,
141
+ i,
142
+ item.indent,
143
+ htmlParams,
144
+ data,
145
+ allComponents,
146
+ )
147
+ result.push(listResult.list)
148
+ i = listResult.nextIndex
149
+ } else if (item.type === "blockquote") {
150
+ const { node, nextIndex } = processBlockquotes(
151
+ items,
152
+ i,
153
+ htmlParams,
154
+ data,
155
+ allComponents,
156
+ )
157
+ result.push(node)
158
+ i = nextIndex
159
+ } else if (item.type === "hr") {
160
+ result.push(allComponents.hr(htmlParams))
161
+ i++
162
+ } else if (item.type === "pre") {
163
+ // Code blocks - wrap in <pre><code>
164
+ const codeParams = item.language
165
+ ? { class: `language-${item.language}` }
166
+ : {}
167
+ result.push(
168
+ allComponents.pre(
169
+ htmlParams,
170
+ allComponents.code(codeParams, item.content),
171
+ ),
172
+ )
173
+ i++
174
+ } else {
175
+ // Regular block elements (h1-h6, p, etc.)
176
+ const { type, content } = item
177
+ const Component = components[type] || allComponents.p
178
+ const processedContent = replaceVariables(content, data)
179
+ result.push(
180
+ Component(htmlParams, format(processedContent, allComponents)),
181
+ )
182
+ i++
183
+ }
184
+ }
185
+
186
+ return result
187
+ }
188
+
189
+ module.exports = {
190
+ parseList,
191
+ processCustomComponentItem,
192
+ processBlockquotes,
193
+ convertItemsToNodes,
194
+ }
@@ -0,0 +1,184 @@
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} allComponents - HTML component functions (contains a, img, code, strong, em, etc.)
8
+ * @returns {Array|string} - Formatted content as array of components and strings
9
+ */
10
+ function format(text, allComponents) {
11
+ const { a: A, img: Img, code: Code, strong: Strong, em: Em } = allComponents
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, allComponents)))
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 (
118
+ content.includes("@") &&
119
+ /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(content)
120
+ ) {
121
+ result.push(A({ href: `mailto:${content}` }, content))
122
+ i = end + 1
123
+ continue
124
+ }
125
+ }
126
+
127
+ // Not a valid autolink, treat as regular text
128
+ result.push(text[i])
129
+ i++
130
+ } else if (text[i] === "`") {
131
+ const end = text.indexOf("`", i + 1)
132
+ if (end === -1) {
133
+ result.push(text[i])
134
+ i++
135
+ } else {
136
+ result.push(Code({}, text.substring(i + 1, end)))
137
+ i = end + 1
138
+ }
139
+ } else if (text[i] === "*" && text[i + 1] === "*") {
140
+ const end = text.indexOf("**", i + 2)
141
+ if (end === -1) {
142
+ result.push(text[i])
143
+ i++
144
+ } else {
145
+ result.push(Strong(format(text.substring(i + 2, end), allComponents)))
146
+ i = end + 2
147
+ }
148
+ } else if (text[i] === "*") {
149
+ const end = text.indexOf("*", i + 1)
150
+ if (end === -1) {
151
+ result.push(text[i])
152
+ i++
153
+ } else {
154
+ result.push(Em(format(text.substring(i + 1, end), allComponents)))
155
+ i = end + 1
156
+ }
157
+ } else {
158
+ // Find next special character
159
+ const positions = [
160
+ text.indexOf("`", i),
161
+ text.indexOf("*", i),
162
+ text.indexOf("[", i),
163
+ text.indexOf("<", i),
164
+ text.indexOf("\\", i),
165
+ ].filter((pos) => pos !== -1)
166
+
167
+ // Look for image pattern ![
168
+ const exclamPos = text.indexOf("!", i)
169
+ if (exclamPos !== -1 && text[exclamPos + 1] === "[") {
170
+ positions.push(exclamPos)
171
+ }
172
+
173
+ const next = positions.length > 0 ? Math.min(...positions) : text.length
174
+
175
+ result.push(text.substring(i, next))
176
+ if (next === text.length) break
177
+ i = next
178
+ }
179
+ }
180
+
181
+ return result.length > 0 ? result : text
182
+ }
183
+
184
+ module.exports = { format }
@@ -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
+ }