boxwood 2.9.0 → 2.11.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.9.0",
3
+ "version": "2.11.0",
4
4
  "description": "Compile HTML templates into JS",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -14,10 +14,17 @@ 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
+
19
25
  const ORDERED_LIST_REGEXP = /^\d+\.\s/
20
26
  const UNORDERED_MARKERS = ["- ", "— ", "– ", "• "]
27
+ const HORIZONTAL_RULE_REGEXP = /^(?:\*\s*\*\s*\*+|-\s*-\s*-+|_\s*_\s*_+)\s*$/
21
28
  const HEADINGS = [
22
29
  { prefix: "###### ", type: "h6" },
23
30
  { prefix: "##### ", type: "h5" },
@@ -34,130 +41,161 @@ const COMPONENTS = {
34
41
  h5: H5,
35
42
  h6: H6,
36
43
  blockquote: Blockquote,
44
+ hr: Hr,
37
45
  }
38
46
 
39
- function format(text) {
40
- if (!text.includes("*") && !text.includes("`")) {
41
- return text
47
+ const INLINE_COMPONENTS = { A, Img, Code, Strong, Em }
48
+
49
+ function Markdown(params, children) {
50
+ if (Array.isArray(children)) {
51
+ return children.map((child) => Markdown(params, child))
52
+ }
53
+
54
+ if (typeof children !== "string") {
55
+ return null
42
56
  }
43
57
 
44
- const result = []
58
+ // First pass: detect code blocks before processing lines
59
+ const allLines = children.trim().split("\n")
60
+ const items = []
45
61
  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])
62
+
63
+ while (i < allLines.length) {
64
+ const line = allLines[i]
65
+ const trimmed = line.trim()
66
+
67
+ // Check for code block start
68
+ if (trimmed.startsWith("```")) {
69
+ const language = trimmed.substring(3).trim()
70
+ const codeLines = []
71
+ i++ // Move past the opening ```
72
+
73
+ // Collect code block lines until closing ```
74
+ while (i < allLines.length) {
75
+ const codeLine = allLines[i]
76
+ if (codeLine.trim().startsWith("```")) {
77
+ // Found closing ```, don't include it
78
+ i++
79
+ break
80
+ }
81
+ codeLines.push(codeLine)
52
82
  i++
53
- } else {
54
- result.push(Code({}, text.substring(start, end)))
55
- i = end + 1
56
83
  }
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
84
+
85
+ items.push({
86
+ type: "pre",
87
+ content: codeLines.join("\n"),
88
+ language: language || undefined,
89
+ indent: 0,
90
+ })
91
+ continue
92
+ }
93
+
94
+ // Skip empty lines
95
+ if (trimmed.length === 0) {
96
+ i++
97
+ continue
98
+ }
99
+
100
+ const leadingSpaces = line.length - line.trimStart().length
101
+
102
+ // Check for other block types
103
+ if (HORIZONTAL_RULE_REGEXP.test(trimmed)) {
104
+ items.push({ type: "hr", indent: leadingSpaces })
105
+ } else if (UNORDERED_MARKERS.some((marker) => trimmed.startsWith(marker))) {
106
+ const content = trimmed.substring(2)
107
+ if (content) {
108
+ items.push({ type: "li", list: "ul", content, indent: leadingSpaces })
66
109
  }
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
110
+ } else if (ORDERED_LIST_REGEXP.test(trimmed)) {
111
+ const content = trimmed.replace(ORDERED_LIST_REGEXP, "")
112
+ if (content) {
113
+ items.push({ type: "li", list: "ol", content, indent: leadingSpaces })
76
114
  }
77
115
  } 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
116
+ let matched = false
117
+ for (const { prefix, type } of HEADINGS) {
118
+ if (trimmed.startsWith(prefix)) {
119
+ items.push({
120
+ type,
121
+ content: trimmed.substring(prefix.length),
122
+ indent: leadingSpaces,
123
+ })
124
+ matched = true
125
+ break
126
+ }
127
+ }
128
+
129
+ if (!matched) {
130
+ if (trimmed.startsWith("> ")) {
131
+ items.push({
132
+ type: "blockquote",
133
+ content: trimmed.substring(2),
134
+ indent: leadingSpaces,
135
+ })
136
+ } else {
137
+ items.push({ type: "p", content: trimmed, indent: leadingSpaces })
138
+ }
86
139
  }
87
- result.push(text.substring(i, next))
88
- i = next
89
140
  }
90
- }
91
- return result.length > 0 ? result : text
92
- }
93
141
 
94
- function Markdown(params, children) {
95
- if (Array.isArray(children)) {
96
- return children.map((child) => Markdown(params, child))
142
+ i++
97
143
  }
98
144
 
99
- if (typeof children !== "string") {
100
- return null
101
- }
145
+ const nodes = []
146
+ i = 0
147
+
148
+ function parseList(startIndex, parentIndent) {
149
+ const list = []
150
+ let currentIndex = startIndex
151
+ const parentListType = items[startIndex].list
102
152
 
103
- const lines = children
104
- .trim()
105
- .split("\n")
106
- .map((line) => line.trim())
107
- .filter(Boolean)
108
-
109
- const items = lines
110
- .map((line) => {
111
- if (UNORDERED_MARKERS.some((marker) => line.startsWith(marker))) {
112
- const content = line.substring(2)
113
- if (!content) return null
114
- return { type: "li", list: "ul", content }
153
+ while (currentIndex < items.length) {
154
+ const item = items[currentIndex]
155
+
156
+ // Stop if not a list item or indent is less than expected
157
+ if (item.type !== "li" || item.indent < parentIndent) {
158
+ break
115
159
  }
116
160
 
117
- if (ORDERED_LIST_REGEXP.test(line)) {
118
- const content = line.replace(ORDERED_LIST_REGEXP, "")
119
- if (!content) return null
120
- return { type: "li", list: "ol", content }
161
+ // Stop if indent matches but list type differs
162
+ if (item.indent === parentIndent && item.list !== parentListType) {
163
+ break
121
164
  }
122
165
 
123
- for (const { prefix, type } of HEADINGS) {
124
- if (line.startsWith(prefix)) {
125
- return { type, content: line.substring(prefix.length) }
126
- }
166
+ // Skip items with greater indent (they belong to nested lists)
167
+ if (item.indent > parentIndent) {
168
+ break
127
169
  }
128
170
 
129
- if (line.startsWith("> ")) {
130
- return { type: "blockquote", content: line.substring(2) }
171
+ // Process current item at the correct indent level
172
+ const content = [format(item.content, INLINE_COMPONENTS)]
173
+ currentIndex++
174
+
175
+ // Check if next item is a nested list
176
+ const nextItem = items[currentIndex]
177
+ if (nextItem?.type === "li" && nextItem.indent > item.indent) {
178
+ const nestedResult = parseList(currentIndex, nextItem.indent)
179
+ content.push(nestedResult.list)
180
+ currentIndex = nestedResult.nextIndex
131
181
  }
132
182
 
133
- return { type: "p", content: line }
134
- })
135
- .filter(Boolean)
183
+ list.push(Li(params, content))
184
+ }
136
185
 
137
- const nodes = []
138
- let i = 0
186
+ const listElement =
187
+ parentListType === "ul" ? Ul(params, list) : Ol(params, list)
188
+
189
+ return { list: listElement, nextIndex: currentIndex }
190
+ }
139
191
 
140
192
  while (i < items.length) {
141
193
  const item = items[i]
142
194
 
143
195
  if (item.type === "li") {
144
- const list = []
145
- const parent = item.list
146
-
147
- while (
148
- i < items.length &&
149
- items[i].type === "li" &&
150
- items[i].list === parent
151
- ) {
152
- list.push(Li(params, format(items[i].content)))
153
- i++
154
- }
155
-
156
- if (parent === "ul") {
157
- nodes.push(Ul(params, list))
158
- } else if (parent === "ol") {
159
- nodes.push(Ol(params, list))
160
- }
196
+ const result = parseList(i, item.indent)
197
+ nodes.push(result.list)
198
+ i = result.nextIndex
161
199
  } else if (item.type === "blockquote") {
162
200
  const lines = []
163
201
 
@@ -166,11 +204,26 @@ function Markdown(params, children) {
166
204
  i++
167
205
  }
168
206
 
169
- nodes.push(Blockquote(params, P(params, format(lines.join("\n")))))
207
+ nodes.push(
208
+ Blockquote(
209
+ params,
210
+ P(params, format(lines.join("\n"), INLINE_COMPONENTS)),
211
+ ),
212
+ )
213
+ } else if (item.type === "hr") {
214
+ nodes.push(Hr(params))
215
+ i++
216
+ } else if (item.type === "pre") {
217
+ // Code blocks - wrap in <pre><code>
218
+ const codeParams = item.language
219
+ ? { class: `language-${item.language}` }
220
+ : {}
221
+ nodes.push(Pre(params, Code(codeParams, item.content)))
222
+ i++
170
223
  } else {
171
224
  const { type, content } = item
172
225
  const Component = COMPONENTS[type] || P
173
- nodes.push(Component(params, format(content)))
226
+ nodes.push(Component(params, format(content, INLINE_COMPONENTS)))
174
227
  i++
175
228
  }
176
229
  }
@@ -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 }