boxwood 2.18.1 → 2.19.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/README.md +26 -0
- package/index.js +28 -6
- package/package.json +1 -1
- package/ui/markdown/index.js +7 -0
- package/ui/prose/index.js +11 -1
- package/ui/prose/utilities/processConditionals.js +36 -9
- package/ui/prose/utilities/processLoops.js +8 -7
- package/ui/prose/utilities/validate.js +172 -10
- package/ui/utilities/convertNodes.js +99 -1
- package/ui/utilities/format.js +0 -0
- package/ui/utilities/params.js +9 -1
- package/ui/utilities/parseBlock.js +214 -4
- package/ui/utilities/parseCustomTag.js +90 -50
- package/ui/utilities/replaceVariables.js +320 -35
- package/ui/utilities/slugify.js +39 -0
package/ui/utilities/params.js
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
// Internal params threaded through recursive calls - never HTML attributes
|
|
2
|
+
const INTERNAL_KEYS = new Set([
|
|
3
|
+
"components",
|
|
4
|
+
"data",
|
|
5
|
+
"__codeTokens",
|
|
6
|
+
"__headingAnchors",
|
|
7
|
+
])
|
|
8
|
+
|
|
1
9
|
/**
|
|
2
10
|
* Extracts HTML parameters from params object by filtering out special keys
|
|
3
11
|
*/
|
|
@@ -5,7 +13,7 @@ function extractHtmlParams(params) {
|
|
|
5
13
|
if (!params) return {}
|
|
6
14
|
|
|
7
15
|
return Object.keys(params).reduce((acc, key) => {
|
|
8
|
-
if (key
|
|
16
|
+
if (!INTERNAL_KEYS.has(key)) {
|
|
9
17
|
acc[key] = params[key]
|
|
10
18
|
}
|
|
11
19
|
return acc
|
|
@@ -54,6 +54,73 @@ function parseCodeBlock(allLines, startIndex) {
|
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Scan a line for a > outside quoted attribute values
|
|
59
|
+
* Quote state carries across lines via the state object, so quoted values
|
|
60
|
+
* may span multiple lines
|
|
61
|
+
* @param {string} line - The line to scan
|
|
62
|
+
* @param {{quote: string|null}} state - Carried quote state (mutated)
|
|
63
|
+
* @returns {boolean} - True when the line contains an unquoted >
|
|
64
|
+
*/
|
|
65
|
+
function scanUnquotedGt(line, state) {
|
|
66
|
+
let found = false
|
|
67
|
+
for (const char of line) {
|
|
68
|
+
if (state.quote) {
|
|
69
|
+
if (char === state.quote) state.quote = null
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
if (char === '"' || char === "'") {
|
|
73
|
+
state.quote = char
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
if (char === ">") found = true
|
|
77
|
+
}
|
|
78
|
+
return found
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Parses a custom component tag whose attributes span multiple lines, e.g.
|
|
83
|
+
* <Gallery images="{[
|
|
84
|
+
* images[0],
|
|
85
|
+
* images[1]
|
|
86
|
+
* ]}" />
|
|
87
|
+
* Joins lines until the first > outside quoted values and parses the result
|
|
88
|
+
* as a single tag (a quoted "5 > 3" does not end the tag)
|
|
89
|
+
* Returns the parsed tag and the index of the line that closes it, or null
|
|
90
|
+
*/
|
|
91
|
+
function parseMultilineTag(allLines, startIndex, allComponents) {
|
|
92
|
+
if (!allComponents || typeof allComponents !== "object") {
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const trimmed = allLines[startIndex].trim()
|
|
97
|
+
const match = trimmed.match(/^<([A-Za-z][A-Za-z0-9-]*)(\s|$)/)
|
|
98
|
+
if (!match || !allComponents[match[1]]) {
|
|
99
|
+
return null
|
|
100
|
+
}
|
|
101
|
+
// The tag closes on this line - not a multi-line tag
|
|
102
|
+
const state = { quote: null }
|
|
103
|
+
if (scanUnquotedGt(allLines[startIndex], state)) {
|
|
104
|
+
return null
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const lines = [allLines[startIndex]]
|
|
108
|
+
for (let i = startIndex + 1; i < allLines.length; i++) {
|
|
109
|
+
// A blank line ends the markdown block - the candidate is not a tag,
|
|
110
|
+
// so plain text starting with a component name is never swallowed
|
|
111
|
+
if (!allLines[i].trim()) {
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
lines.push(allLines[i])
|
|
115
|
+
if (scanUnquotedGt(allLines[i], state)) {
|
|
116
|
+
const tag = parseCustomTag(lines.join("\n"), allComponents)
|
|
117
|
+
return tag ? { tag, endIndex: i } : null
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return null
|
|
122
|
+
}
|
|
123
|
+
|
|
57
124
|
/**
|
|
58
125
|
* Collects content lines for a multi-line custom component
|
|
59
126
|
*/
|
|
@@ -78,6 +145,21 @@ function collectComponentContent(allLines, startIndex, tagName, allComponents) {
|
|
|
78
145
|
}
|
|
79
146
|
contentLines.push(contentLine)
|
|
80
147
|
}
|
|
148
|
+
} else if (!contentTag) {
|
|
149
|
+
// A nested same-name tag whose attributes span multiple lines also
|
|
150
|
+
// affects depth - consume its whole span so its closing tag matches
|
|
151
|
+
const multiline = parseMultilineTag(allLines, i, allComponents)
|
|
152
|
+
if (multiline && multiline.tag.tagName === tagName) {
|
|
153
|
+
if (multiline.tag.type === "custom-component-open") {
|
|
154
|
+
depth++
|
|
155
|
+
}
|
|
156
|
+
for (let j = i; j <= multiline.endIndex; j++) {
|
|
157
|
+
contentLines.push(allLines[j])
|
|
158
|
+
}
|
|
159
|
+
i = multiline.endIndex + 1
|
|
160
|
+
continue
|
|
161
|
+
}
|
|
162
|
+
contentLines.push(contentLine)
|
|
81
163
|
} else {
|
|
82
164
|
contentLines.push(contentLine)
|
|
83
165
|
}
|
|
@@ -163,6 +245,91 @@ function processCustomComponent(
|
|
|
163
245
|
return null
|
|
164
246
|
}
|
|
165
247
|
|
|
248
|
+
/**
|
|
249
|
+
* Split a table row into trimmed cells, honoring escaped \| pipes
|
|
250
|
+
* "| a | b |" -> ["a", "b"]
|
|
251
|
+
*/
|
|
252
|
+
function splitTableRow(line) {
|
|
253
|
+
let trimmed = line.trim()
|
|
254
|
+
if (trimmed.startsWith("|")) {
|
|
255
|
+
trimmed = trimmed.substring(1)
|
|
256
|
+
}
|
|
257
|
+
if (trimmed.endsWith("|") && !trimmed.endsWith("\\|")) {
|
|
258
|
+
trimmed = trimmed.substring(0, trimmed.length - 1)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const cells = []
|
|
262
|
+
let current = ""
|
|
263
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
264
|
+
const char = trimmed[i]
|
|
265
|
+
if (char === "\\" && trimmed[i + 1] === "|") {
|
|
266
|
+
current += "|"
|
|
267
|
+
i++
|
|
268
|
+
continue
|
|
269
|
+
}
|
|
270
|
+
if (char === "|") {
|
|
271
|
+
cells.push(current.trim())
|
|
272
|
+
current = ""
|
|
273
|
+
continue
|
|
274
|
+
}
|
|
275
|
+
current += char
|
|
276
|
+
}
|
|
277
|
+
cells.push(current.trim())
|
|
278
|
+
return cells
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Checks if a line is a table separator row, e.g. | --- | :---: | ---: |
|
|
283
|
+
*/
|
|
284
|
+
function isTableSeparator(trimmed) {
|
|
285
|
+
if (!trimmed.startsWith("|")) {
|
|
286
|
+
return false
|
|
287
|
+
}
|
|
288
|
+
const cells = splitTableRow(trimmed)
|
|
289
|
+
return cells.length > 0 && cells.every((cell) => /^:?-+:?$/.test(cell))
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Parses a table block: a header row, a separator row and body rows
|
|
294
|
+
* Returns the table item and the next index to process
|
|
295
|
+
*/
|
|
296
|
+
function parseTable(allLines, startIndex) {
|
|
297
|
+
const line = allLines[startIndex]
|
|
298
|
+
const header = splitTableRow(line)
|
|
299
|
+
|
|
300
|
+
// Column alignment comes from the separator row (:--- :---: ---:)
|
|
301
|
+
const aligns = splitTableRow(allLines[startIndex + 1]).map((cell) => {
|
|
302
|
+
const left = cell.startsWith(":")
|
|
303
|
+
const right = cell.endsWith(":")
|
|
304
|
+
if (left && right) return "center"
|
|
305
|
+
if (right) return "right"
|
|
306
|
+
return null
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
const rows = []
|
|
310
|
+
let i = startIndex + 2
|
|
311
|
+
while (i < allLines.length && allLines[i].trim().startsWith("|")) {
|
|
312
|
+
const cells = splitTableRow(allLines[i]).slice(0, header.length)
|
|
313
|
+
// Normalize short rows to the header width
|
|
314
|
+
while (cells.length < header.length) {
|
|
315
|
+
cells.push("")
|
|
316
|
+
}
|
|
317
|
+
rows.push(cells)
|
|
318
|
+
i++
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
item: {
|
|
323
|
+
type: "table",
|
|
324
|
+
header,
|
|
325
|
+
aligns,
|
|
326
|
+
rows,
|
|
327
|
+
indent: line.length - line.trimStart().length,
|
|
328
|
+
},
|
|
329
|
+
nextIndex: i,
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
166
333
|
/**
|
|
167
334
|
* Determines the type and content of a markdown line
|
|
168
335
|
*/
|
|
@@ -175,6 +342,23 @@ function parseMarkdownLine(line) {
|
|
|
175
342
|
return { type: "hr", indent: leadingSpaces }
|
|
176
343
|
}
|
|
177
344
|
|
|
345
|
+
// Task list items: [ ] todo, [x] done - GFM allows them in both list types
|
|
346
|
+
// The text may be empty ("- [ ]" renders a bare checkbox)
|
|
347
|
+
const listItem = (list, content) => {
|
|
348
|
+
const task = content.match(/^\[([ xX])\](?:\s+(.*))?$/)
|
|
349
|
+
if (task) {
|
|
350
|
+
return {
|
|
351
|
+
type: "li",
|
|
352
|
+
list,
|
|
353
|
+
task: true,
|
|
354
|
+
checked: task[1] !== " ",
|
|
355
|
+
content: task[2] || "",
|
|
356
|
+
indent: leadingSpaces,
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return { type: "li", list, content, indent: leadingSpaces }
|
|
360
|
+
}
|
|
361
|
+
|
|
178
362
|
// Unordered list
|
|
179
363
|
const unorderedMarker = UNORDERED_MARKERS.find((marker) =>
|
|
180
364
|
trimmed.startsWith(marker),
|
|
@@ -182,7 +366,7 @@ function parseMarkdownLine(line) {
|
|
|
182
366
|
if (unorderedMarker) {
|
|
183
367
|
const content = trimmed.substring(2)
|
|
184
368
|
if (content) {
|
|
185
|
-
return
|
|
369
|
+
return listItem("ul", content)
|
|
186
370
|
}
|
|
187
371
|
}
|
|
188
372
|
|
|
@@ -190,7 +374,7 @@ function parseMarkdownLine(line) {
|
|
|
190
374
|
if (ORDERED_LIST_REGEXP.test(trimmed)) {
|
|
191
375
|
const content = trimmed.replace(ORDERED_LIST_REGEXP, "")
|
|
192
376
|
if (content) {
|
|
193
|
-
return
|
|
377
|
+
return listItem("ol", content)
|
|
194
378
|
}
|
|
195
379
|
}
|
|
196
380
|
|
|
@@ -237,12 +421,21 @@ function parseMarkdownLines(children, allComponents, data) {
|
|
|
237
421
|
}
|
|
238
422
|
|
|
239
423
|
// Check for custom component tags
|
|
240
|
-
|
|
424
|
+
let customTag = parseCustomTag(line, allComponents)
|
|
425
|
+
let tagEndIndex = i
|
|
426
|
+
if (!customTag) {
|
|
427
|
+
// Tag attributes may span multiple lines
|
|
428
|
+
const multiline = parseMultilineTag(allLines, i, allComponents)
|
|
429
|
+
if (multiline) {
|
|
430
|
+
customTag = multiline.tag
|
|
431
|
+
tagEndIndex = multiline.endIndex
|
|
432
|
+
}
|
|
433
|
+
}
|
|
241
434
|
if (customTag) {
|
|
242
435
|
const result = processCustomComponent(
|
|
243
436
|
line,
|
|
244
437
|
allLines,
|
|
245
|
-
|
|
438
|
+
tagEndIndex,
|
|
246
439
|
customTag,
|
|
247
440
|
data,
|
|
248
441
|
allComponents,
|
|
@@ -262,6 +455,18 @@ function parseMarkdownLines(children, allComponents, data) {
|
|
|
262
455
|
continue
|
|
263
456
|
}
|
|
264
457
|
|
|
458
|
+
// Check for tables: a header row followed by a separator row
|
|
459
|
+
if (
|
|
460
|
+
trimmed.startsWith("|") &&
|
|
461
|
+
i + 1 < allLines.length &&
|
|
462
|
+
isTableSeparator(allLines[i + 1].trim())
|
|
463
|
+
) {
|
|
464
|
+
const { item, nextIndex } = parseTable(allLines, i)
|
|
465
|
+
items.push(item)
|
|
466
|
+
i = nextIndex
|
|
467
|
+
continue
|
|
468
|
+
}
|
|
469
|
+
|
|
265
470
|
// Parse regular markdown line
|
|
266
471
|
const item = parseMarkdownLine(line)
|
|
267
472
|
if (item) {
|
|
@@ -277,6 +482,11 @@ function parseMarkdownLines(children, allComponents, data) {
|
|
|
277
482
|
module.exports = {
|
|
278
483
|
isCodeBlockDelimiter,
|
|
279
484
|
parseCodeBlock,
|
|
485
|
+
parseMultilineTag,
|
|
486
|
+
scanUnquotedGt,
|
|
487
|
+
splitTableRow,
|
|
488
|
+
isTableSeparator,
|
|
489
|
+
parseTable,
|
|
280
490
|
collectComponentContent,
|
|
281
491
|
processCustomComponent,
|
|
282
492
|
parseMarkdownLine,
|
|
@@ -3,9 +3,34 @@ const {
|
|
|
3
3
|
replaceVariables,
|
|
4
4
|
} = require("./replaceVariables")
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Find the first > that is not inside a quoted attribute value
|
|
8
|
+
* @param {string} text - The text to scan
|
|
9
|
+
* @param {number} from - Index to start scanning at
|
|
10
|
+
* @returns {number} - Index of the unquoted > or -1
|
|
11
|
+
*/
|
|
12
|
+
function findUnquotedGt(text, from) {
|
|
13
|
+
let quote = null
|
|
14
|
+
for (let i = from; i < text.length; i++) {
|
|
15
|
+
const char = text[i]
|
|
16
|
+
if (quote) {
|
|
17
|
+
if (char === quote) quote = null
|
|
18
|
+
continue
|
|
19
|
+
}
|
|
20
|
+
if (char === '"' || char === "'") {
|
|
21
|
+
quote = char
|
|
22
|
+
continue
|
|
23
|
+
}
|
|
24
|
+
if (char === ">") return i
|
|
25
|
+
}
|
|
26
|
+
return -1
|
|
27
|
+
}
|
|
28
|
+
|
|
6
29
|
/**
|
|
7
30
|
* Parse a custom component tag from markdown
|
|
8
31
|
* Supports both <Component attr="value"> and <Component attr={variable}>
|
|
32
|
+
* Attribute values may contain > when quoted, e.g. title="5 > 3",
|
|
33
|
+
* and may span multiple lines (the line then contains newlines)
|
|
9
34
|
* @param {string} line - The line to parse
|
|
10
35
|
* @param {Object} customComponents - Map of component names to functions
|
|
11
36
|
* @returns {Object|null} - Parsed tag info or null if not a custom tag
|
|
@@ -17,74 +42,88 @@ function parseCustomTag(line, customComponents) {
|
|
|
17
42
|
|
|
18
43
|
const trimmed = line.trim()
|
|
19
44
|
|
|
20
|
-
//
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if (singleLineMatch) {
|
|
25
|
-
const [, tagName, attributesStr, content] = singleLineMatch
|
|
45
|
+
// Closing tag: </ComponentName>
|
|
46
|
+
const closeTagMatch = trimmed.match(/^<\/([A-Za-z][A-Za-z0-9-]*)>\s*$/)
|
|
47
|
+
if (closeTagMatch) {
|
|
48
|
+
const [, tagName] = closeTagMatch
|
|
26
49
|
const component = customComponents[tagName]
|
|
27
50
|
if (component) {
|
|
28
|
-
const attributes = parseAttributes(attributesStr || "")
|
|
29
51
|
return {
|
|
30
|
-
type: "custom-component-
|
|
52
|
+
type: "custom-component-close",
|
|
31
53
|
tagName,
|
|
32
54
|
component,
|
|
33
|
-
attributes,
|
|
34
|
-
content,
|
|
35
|
-
selfClosing: false,
|
|
36
55
|
}
|
|
37
56
|
}
|
|
57
|
+
return null
|
|
38
58
|
}
|
|
39
59
|
|
|
40
|
-
//
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
60
|
+
// Tag name followed by whitespace, / or >
|
|
61
|
+
const nameMatch = trimmed.match(/^<([A-Za-z][A-Za-z0-9-]*)(?=[\s/>])/)
|
|
62
|
+
if (!nameMatch) {
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
const tagName = nameMatch[1]
|
|
66
|
+
const component = customComponents[tagName]
|
|
67
|
+
if (!component) {
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const gt = findUnquotedGt(trimmed, nameMatch[0].length)
|
|
72
|
+
if (gt === -1) {
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Self-closing when / directly precedes the > (whitespace allowed between)
|
|
77
|
+
let attributesEnd = gt
|
|
78
|
+
let selfClosing = false
|
|
79
|
+
let before = gt - 1
|
|
80
|
+
while (before > 0 && /\s/.test(trimmed[before])) {
|
|
81
|
+
before--
|
|
82
|
+
}
|
|
83
|
+
if (trimmed[before] === "/") {
|
|
84
|
+
selfClosing = true
|
|
85
|
+
attributesEnd = before
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const attributesStr = trimmed.substring(nameMatch[0].length, attributesEnd)
|
|
89
|
+
const after = trimmed.substring(gt + 1)
|
|
90
|
+
|
|
91
|
+
if (selfClosing) {
|
|
92
|
+
if (after.trim() !== "") {
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
type: "custom-component",
|
|
97
|
+
tagName,
|
|
98
|
+
component,
|
|
99
|
+
attributes: parseAttributes(attributesStr),
|
|
100
|
+
selfClosing: true,
|
|
56
101
|
}
|
|
57
102
|
}
|
|
58
103
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if (component) {
|
|
67
|
-
const attributes = parseAttributes(attributesStr || "")
|
|
68
|
-
return {
|
|
69
|
-
type: "custom-component-open",
|
|
70
|
-
tagName,
|
|
71
|
-
component,
|
|
72
|
-
attributes,
|
|
73
|
-
selfClosing: false,
|
|
74
|
-
}
|
|
104
|
+
if (after.trim() === "") {
|
|
105
|
+
return {
|
|
106
|
+
type: "custom-component-open",
|
|
107
|
+
tagName,
|
|
108
|
+
component,
|
|
109
|
+
attributes: parseAttributes(attributesStr),
|
|
110
|
+
selfClosing: false,
|
|
75
111
|
}
|
|
76
112
|
}
|
|
77
113
|
|
|
78
|
-
//
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
if (
|
|
114
|
+
// Single-line tag with content: <tag ...>content</tag>
|
|
115
|
+
const closeToken = `</${tagName}>`
|
|
116
|
+
const content = after.replace(/\s+$/, "")
|
|
117
|
+
if (content.endsWith(closeToken)) {
|
|
118
|
+
const inner = content.substring(0, content.length - closeToken.length)
|
|
119
|
+
if (inner) {
|
|
84
120
|
return {
|
|
85
|
-
type: "custom-component-
|
|
121
|
+
type: "custom-component-single-line",
|
|
86
122
|
tagName,
|
|
87
123
|
component,
|
|
124
|
+
attributes: parseAttributes(attributesStr),
|
|
125
|
+
content: inner,
|
|
126
|
+
selfClosing: false,
|
|
88
127
|
}
|
|
89
128
|
}
|
|
90
129
|
}
|
|
@@ -240,4 +279,5 @@ module.exports = {
|
|
|
240
279
|
parseCustomTag,
|
|
241
280
|
parseAttributes,
|
|
242
281
|
resolveAttributes,
|
|
282
|
+
findUnquotedGt,
|
|
243
283
|
}
|