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.
- package/package.json +2 -3
- package/ui/grid/index.js +2 -5
- package/ui/index.js +2 -0
- package/ui/markdown/README.md +48 -0
- package/ui/markdown/index.js +43 -315
- package/ui/markdown/utilities/convertNodes.js +193 -0
- package/ui/markdown/utilities/format.js +15 -12
- package/ui/markdown/utilities/params.js +29 -0
- package/ui/markdown/utilities/parseBlock.js +284 -0
- package/ui/markdown/utilities/parseCustomTag.js +20 -0
- package/ui/markdown/utilities/replaceVariables.js +39 -4
- package/ui/normalize.js +1 -1
- package/ui/prose/README.md +492 -0
- package/ui/prose/index.js +159 -0
- package/ui/prose/utilities/brackets.js +21 -0
- package/ui/prose/utilities/convertNodes.js +193 -0
- package/ui/prose/utilities/format.js +184 -0
- package/ui/prose/utilities/params.js +29 -0
- package/ui/prose/utilities/parseBlock.js +284 -0
- package/ui/prose/utilities/parseCustomTag.js +218 -0
- package/ui/prose/utilities/processConditionals.js +264 -0
- package/ui/prose/utilities/processLoops.js +171 -0
- package/ui/prose/utilities/replaceVariables.js +115 -0
- package/examples/typescript-example.ts +0 -49
|
@@ -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}
|
|
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,
|
|
11
|
-
const { A, Img, Code, Strong, Em } =
|
|
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 
|
|
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,
|
|
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 (
|
|
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),
|
|
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),
|
|
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
|
|
75
|
+
const variablePath = text.substring(i + 1, closeIndex).trim()
|
|
41
76
|
|
|
42
|
-
if (
|
|
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
|
-
//
|
|
49
|
-
const value = data
|
|
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 {
|