boxwood 2.10.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.10.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,62 +41,10 @@ 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
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
- }
47
+ const INLINE_COMPONENTS = { A, Img, Code, Strong, Em }
93
48
 
94
49
  function Markdown(params, children) {
95
50
  if (Array.isArray(children)) {
@@ -100,47 +55,95 @@ function Markdown(params, children) {
100
55
  return null
101
56
  }
102
57
 
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 }
120
- }
58
+ // First pass: detect code blocks before processing lines
59
+ const allLines = children.trim().split("\n")
60
+ const items = []
61
+ let i = 0
121
62
 
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 }
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)
82
+ i++
126
83
  }
127
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 })
109
+ }
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 })
114
+ }
115
+ } else {
116
+ let matched = false
128
117
  for (const { prefix, type } of HEADINGS) {
129
- if (text.startsWith(prefix)) {
130
- return { type, content: text.substring(prefix.length), indent }
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
131
126
  }
132
127
  }
133
128
 
134
- if (text.startsWith("> ")) {
135
- return { type: "blockquote", content: text.substring(2), indent }
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
+ }
136
139
  }
140
+ }
137
141
 
138
- return { type: "p", content: text, indent }
139
- })
140
- .filter(Boolean)
142
+ i++
143
+ }
141
144
 
142
145
  const nodes = []
143
- let i = 0
146
+ i = 0
144
147
 
145
148
  function parseList(startIndex, parentIndent) {
146
149
  const list = []
@@ -166,7 +169,7 @@ function Markdown(params, children) {
166
169
  }
167
170
 
168
171
  // Process current item at the correct indent level
169
- const content = [format(item.content)]
172
+ const content = [format(item.content, INLINE_COMPONENTS)]
170
173
  currentIndex++
171
174
 
172
175
  // Check if next item is a nested list
@@ -201,11 +204,26 @@ function Markdown(params, children) {
201
204
  i++
202
205
  }
203
206
 
204
- 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++
205
223
  } else {
206
224
  const { type, content } = item
207
225
  const Component = COMPONENTS[type] || P
208
- nodes.push(Component(params, format(content)))
226
+ nodes.push(Component(params, format(content, INLINE_COMPONENTS)))
209
227
  i++
210
228
  }
211
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 }