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.
@@ -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 !== "components" && key !== "data") {
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 { type: "li", list: "ul", content, indent: leadingSpaces }
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 { type: "li", list: "ol", content, indent: leadingSpaces }
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
- const customTag = parseCustomTag(line, allComponents)
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
- i,
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
- // Check for single-line tag: <tag>content</tag> or <tag attr="value">content</tag>
21
- const singleLineMatch = trimmed.match(
22
- /^<([A-Za-z][A-Za-z0-9-]*)(\s+[^>]*)?>(.+?)<\/\1>\s*$/,
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-single-line",
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
- // Check for self-closing tag: <ComponentName ... /> (space before / is optional)
41
- const selfClosingMatch = trimmed.match(
42
- /^<([A-Za-z][A-Za-z0-9-]*)(\s+[^>]*)?\s*\/>\s*$/,
43
- )
44
- if (selfClosingMatch) {
45
- const [, tagName, attributesStr] = selfClosingMatch
46
- const component = customComponents[tagName]
47
- if (component) {
48
- const attributes = parseAttributes(attributesStr || "")
49
- return {
50
- type: "custom-component",
51
- tagName,
52
- component,
53
- attributes,
54
- selfClosing: true,
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
- // Check for opening tag: <ComponentName ...>
60
- const openTagMatch = trimmed.match(
61
- /^<([A-Za-z][A-Za-z0-9-]*)(\s+[^>]*)?>\s*$/,
62
- )
63
- if (openTagMatch) {
64
- const [, tagName, attributesStr] = openTagMatch
65
- const component = customComponents[tagName]
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
- // Check for closing tag: </ComponentName>
79
- const closeTagMatch = trimmed.match(/^<\/([A-Za-z][A-Za-z0-9-]*)>\s*$/)
80
- if (closeTagMatch) {
81
- const [, tagName] = closeTagMatch
82
- const component = customComponents[tagName]
83
- if (component) {
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-close",
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
  }