boxwood 2.18.0 → 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/README.md +11 -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 -22
- 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 +112 -60
- package/ui/utilities/replaceVariables.js +320 -35
- package/ui/utilities/slugify.js +39 -0
|
@@ -1,6 +1,22 @@
|
|
|
1
|
+
const { Input } = require("../..")
|
|
1
2
|
const { resolveAttributes } = require("./parseCustomTag")
|
|
2
3
|
const { replaceVariables } = require("./replaceVariables")
|
|
3
4
|
const { format } = require("./format")
|
|
5
|
+
const { slugify } = require("./slugify")
|
|
6
|
+
const { restoreCodeSegments } = require("../prose/utilities/protectCode")
|
|
7
|
+
|
|
8
|
+
const HEADING_TYPES = new Set(["h1", "h2", "h3", "h4", "h5", "h6"])
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Strip inline markdown syntax so anchor slugs come from the visible text,
|
|
12
|
+
* e.g. "## [Link](https://x.com)" -> slug "link"
|
|
13
|
+
*/
|
|
14
|
+
function stripInlineMarkdown(text) {
|
|
15
|
+
return text
|
|
16
|
+
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
17
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
18
|
+
.replace(/[`*_~]/g, "")
|
|
19
|
+
}
|
|
4
20
|
|
|
5
21
|
/**
|
|
6
22
|
* Recursively parses nested lists with proper indentation handling
|
|
@@ -36,6 +52,16 @@ function parseList(
|
|
|
36
52
|
// Process current list item
|
|
37
53
|
const processedContent = replaceVariables(item.content, data)
|
|
38
54
|
const content = [format(processedContent, allComponents)]
|
|
55
|
+
|
|
56
|
+
// Task list items get a disabled checkbox: - [x] done
|
|
57
|
+
if (item.task) {
|
|
58
|
+
const checkbox = { type: "checkbox", disabled: true }
|
|
59
|
+
if (item.checked) {
|
|
60
|
+
checkbox.checked = true
|
|
61
|
+
}
|
|
62
|
+
content.unshift(Input(checkbox), " ")
|
|
63
|
+
}
|
|
64
|
+
|
|
39
65
|
currentIndex++
|
|
40
66
|
|
|
41
67
|
// Check for nested lists
|
|
@@ -112,6 +138,42 @@ function processBlockquotes(
|
|
|
112
138
|
return { node: blockquote, nextIndex: i }
|
|
113
139
|
}
|
|
114
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Converts a table item into table/thead/tbody nodes
|
|
143
|
+
* Column alignment renders as an inline text-align style
|
|
144
|
+
*/
|
|
145
|
+
function processTable(item, htmlParams, data, allComponents) {
|
|
146
|
+
const cellParams = (index) => {
|
|
147
|
+
const align = item.aligns[index]
|
|
148
|
+
return align ? { ...htmlParams, style: { textAlign: align } } : htmlParams
|
|
149
|
+
}
|
|
150
|
+
const cellContent = (cell) =>
|
|
151
|
+
format(replaceVariables(cell, data), allComponents)
|
|
152
|
+
|
|
153
|
+
const head = allComponents.thead(
|
|
154
|
+
htmlParams,
|
|
155
|
+
allComponents.tr(
|
|
156
|
+
htmlParams,
|
|
157
|
+
item.header.map((cell, index) =>
|
|
158
|
+
allComponents.th(cellParams(index), cellContent(cell)),
|
|
159
|
+
),
|
|
160
|
+
),
|
|
161
|
+
)
|
|
162
|
+
const body = allComponents.tbody(
|
|
163
|
+
htmlParams,
|
|
164
|
+
item.rows.map((row) =>
|
|
165
|
+
allComponents.tr(
|
|
166
|
+
htmlParams,
|
|
167
|
+
row.map((cell, index) =>
|
|
168
|
+
allComponents.td(cellParams(index), cellContent(cell)),
|
|
169
|
+
),
|
|
170
|
+
),
|
|
171
|
+
),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
return allComponents.table(htmlParams, [head, body])
|
|
175
|
+
}
|
|
176
|
+
|
|
115
177
|
/**
|
|
116
178
|
* Converts parsed items into final node tree
|
|
117
179
|
*/
|
|
@@ -124,6 +186,14 @@ function convertItemsToNodes(
|
|
|
124
186
|
markdownRenderer,
|
|
125
187
|
) {
|
|
126
188
|
const result = []
|
|
189
|
+
// Anchor slugs used so far - repeated headings get -2, -3 suffixes
|
|
190
|
+
const slugs = new Map()
|
|
191
|
+
// Masked-code tokens from Prose - restored in anchor slug sources so
|
|
192
|
+
// "## Title with `code`" slugs from the real code text, not the mask
|
|
193
|
+
const codeTokens = params && params.__codeTokens
|
|
194
|
+
// Heading anchors are opt-in - Prose enables them, plain Markdown stays a
|
|
195
|
+
// faithful GFM renderer with untouched headings
|
|
196
|
+
const headingAnchors = params && params.__headingAnchors
|
|
127
197
|
let i = 0
|
|
128
198
|
|
|
129
199
|
while (i < items.length) {
|
|
@@ -158,6 +228,9 @@ function convertItemsToNodes(
|
|
|
158
228
|
} else if (item.type === "hr") {
|
|
159
229
|
result.push(allComponents.hr(htmlParams))
|
|
160
230
|
i++
|
|
231
|
+
} else if (item.type === "table") {
|
|
232
|
+
result.push(processTable(item, htmlParams, data, allComponents))
|
|
233
|
+
i++
|
|
161
234
|
} else if (item.type === "pre") {
|
|
162
235
|
// Code blocks - wrap in <pre><code>
|
|
163
236
|
const codeParams = item.language
|
|
@@ -175,8 +248,32 @@ function convertItemsToNodes(
|
|
|
175
248
|
const { type, content } = item
|
|
176
249
|
const Component = allComponents[type] || allComponents.p
|
|
177
250
|
const processedContent = replaceVariables(content, data)
|
|
251
|
+
let elementParams = htmlParams
|
|
252
|
+
|
|
253
|
+
// Headings get an anchor id derived from their text,
|
|
254
|
+
// e.g. "## Mój tytuł" -> <h2 id="moj-tytul">
|
|
255
|
+
if (headingAnchors && HEADING_TYPES.has(type) && !htmlParams.id) {
|
|
256
|
+
// Tokens can chain across nested Prose calls (an inner call masks
|
|
257
|
+
// the outer call's token again) - restore until stable
|
|
258
|
+
let slugSource = processedContent
|
|
259
|
+
if (codeTokens) {
|
|
260
|
+
let previous
|
|
261
|
+
do {
|
|
262
|
+
previous = slugSource
|
|
263
|
+
slugSource = restoreCodeSegments(slugSource, codeTokens)
|
|
264
|
+
} while (slugSource !== previous)
|
|
265
|
+
}
|
|
266
|
+
const slug = slugify(stripInlineMarkdown(slugSource))
|
|
267
|
+
if (slug) {
|
|
268
|
+
const count = slugs.get(slug) || 0
|
|
269
|
+
slugs.set(slug, count + 1)
|
|
270
|
+
const id = count === 0 ? slug : `${slug}-${count + 1}`
|
|
271
|
+
elementParams = { ...htmlParams, id }
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
178
275
|
result.push(
|
|
179
|
-
Component(
|
|
276
|
+
Component(elementParams, format(processedContent, allComponents)),
|
|
180
277
|
)
|
|
181
278
|
i++
|
|
182
279
|
}
|
|
@@ -189,5 +286,6 @@ module.exports = {
|
|
|
189
286
|
parseList,
|
|
190
287
|
processCustomComponentItem,
|
|
191
288
|
processBlockquotes,
|
|
289
|
+
processTable,
|
|
192
290
|
convertItemsToNodes,
|
|
193
291
|
}
|
package/ui/utilities/format.js
CHANGED
|
Binary file
|
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,
|
|
@@ -1,8 +1,36 @@
|
|
|
1
|
-
const {
|
|
1
|
+
const {
|
|
2
|
+
resolveExpression,
|
|
3
|
+
replaceVariables,
|
|
4
|
+
} = require("./replaceVariables")
|
|
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
|
+
}
|
|
2
28
|
|
|
3
29
|
/**
|
|
4
30
|
* Parse a custom component tag from markdown
|
|
5
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)
|
|
6
34
|
* @param {string} line - The line to parse
|
|
7
35
|
* @param {Object} customComponents - Map of component names to functions
|
|
8
36
|
* @returns {Object|null} - Parsed tag info or null if not a custom tag
|
|
@@ -14,74 +42,88 @@ function parseCustomTag(line, customComponents) {
|
|
|
14
42
|
|
|
15
43
|
const trimmed = line.trim()
|
|
16
44
|
|
|
17
|
-
//
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
if (singleLineMatch) {
|
|
22
|
-
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
|
|
23
49
|
const component = customComponents[tagName]
|
|
24
50
|
if (component) {
|
|
25
|
-
const attributes = parseAttributes(attributesStr || "")
|
|
26
51
|
return {
|
|
27
|
-
type: "custom-component-
|
|
52
|
+
type: "custom-component-close",
|
|
28
53
|
tagName,
|
|
29
54
|
component,
|
|
30
|
-
attributes,
|
|
31
|
-
content,
|
|
32
|
-
selfClosing: false,
|
|
33
55
|
}
|
|
34
56
|
}
|
|
57
|
+
return null
|
|
35
58
|
}
|
|
36
59
|
|
|
37
|
-
//
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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,
|
|
53
101
|
}
|
|
54
102
|
}
|
|
55
103
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if (component) {
|
|
64
|
-
const attributes = parseAttributes(attributesStr || "")
|
|
65
|
-
return {
|
|
66
|
-
type: "custom-component-open",
|
|
67
|
-
tagName,
|
|
68
|
-
component,
|
|
69
|
-
attributes,
|
|
70
|
-
selfClosing: false,
|
|
71
|
-
}
|
|
104
|
+
if (after.trim() === "") {
|
|
105
|
+
return {
|
|
106
|
+
type: "custom-component-open",
|
|
107
|
+
tagName,
|
|
108
|
+
component,
|
|
109
|
+
attributes: parseAttributes(attributesStr),
|
|
110
|
+
selfClosing: false,
|
|
72
111
|
}
|
|
73
112
|
}
|
|
74
113
|
|
|
75
|
-
//
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
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) {
|
|
81
120
|
return {
|
|
82
|
-
type: "custom-component-
|
|
121
|
+
type: "custom-component-single-line",
|
|
83
122
|
tagName,
|
|
84
123
|
component,
|
|
124
|
+
attributes: parseAttributes(attributesStr),
|
|
125
|
+
content: inner,
|
|
126
|
+
selfClosing: false,
|
|
85
127
|
}
|
|
86
128
|
}
|
|
87
129
|
}
|
|
@@ -160,18 +202,24 @@ function parseAttributes(attributesStr) {
|
|
|
160
202
|
}
|
|
161
203
|
}
|
|
162
204
|
|
|
163
|
-
//
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
|
|
205
|
+
// Whole-value variable reference: "{variable}" - resolves to the raw
|
|
206
|
+
// value (array, object, number), not a string
|
|
207
|
+
const inner = value.substring(1, value.length - 1)
|
|
208
|
+
if (
|
|
209
|
+
value.startsWith("{") &&
|
|
210
|
+
value.endsWith("}") &&
|
|
211
|
+
inner.trim() &&
|
|
212
|
+
!inner.includes("{") &&
|
|
213
|
+
!inner.includes("}")
|
|
214
|
+
) {
|
|
215
|
+
attributes[name] = { __variable: inner.trim() }
|
|
216
|
+
} else if (/\{[^{}]+\}/.test(value)) {
|
|
217
|
+
// Partial interpolation: "/products/{id}" or "Photo of {name}"
|
|
218
|
+
attributes[name] = { __interpolate: value }
|
|
171
219
|
} else {
|
|
172
220
|
attributes[name] = value
|
|
173
221
|
}
|
|
174
|
-
|
|
222
|
+
|
|
175
223
|
if (i < str.length) i++ // Skip closing quote
|
|
176
224
|
} else if (str[i] === "{") {
|
|
177
225
|
// Variable reference
|
|
@@ -216,6 +264,9 @@ function resolveAttributes(attributes, data) {
|
|
|
216
264
|
const variablePath = value.__variable
|
|
217
265
|
const resolvedValue = resolveExpression(data, variablePath)
|
|
218
266
|
resolved[key] = resolvedValue
|
|
267
|
+
} else if (value && typeof value === "object" && value.__interpolate) {
|
|
268
|
+
// Partial interpolation always produces a string
|
|
269
|
+
resolved[key] = replaceVariables(value.__interpolate, data)
|
|
219
270
|
} else {
|
|
220
271
|
resolved[key] = value
|
|
221
272
|
}
|
|
@@ -228,4 +279,5 @@ module.exports = {
|
|
|
228
279
|
parseCustomTag,
|
|
229
280
|
parseAttributes,
|
|
230
281
|
resolveAttributes,
|
|
282
|
+
findUnquotedGt,
|
|
231
283
|
}
|