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 +1 -1
- package/ui/markdown/index.js +149 -96
- package/ui/markdown/utilities/brackets.js +21 -0
- package/ui/markdown/utilities/format.js +181 -0
package/package.json
CHANGED
package/ui/markdown/index.js
CHANGED
|
@@ -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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
|
|
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 (
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
95
|
-
if (Array.isArray(children)) {
|
|
96
|
-
return children.map((child) => Markdown(params, child))
|
|
142
|
+
i++
|
|
97
143
|
}
|
|
98
144
|
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
166
|
+
// Skip items with greater indent (they belong to nested lists)
|
|
167
|
+
if (item.indent > parentIndent) {
|
|
168
|
+
break
|
|
127
169
|
}
|
|
128
170
|
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
134
|
-
}
|
|
135
|
-
.filter(Boolean)
|
|
183
|
+
list.push(Li(params, content))
|
|
184
|
+
}
|
|
136
185
|
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
145
|
-
|
|
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(
|
|
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 
|
|
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 }
|