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.
@@ -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(htmlParams, format(processedContent, allComponents)),
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
  }
Binary file
@@ -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,
@@ -1,8 +1,36 @@
1
- const { resolveExpression } = require("./replaceVariables")
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
- // Check for single-line tag: <tag>content</tag> or <tag attr="value">content</tag>
18
- const singleLineMatch = trimmed.match(
19
- /^<([A-Za-z][A-Za-z0-9-]*)(\s+[^>]*)?>(.+?)<\/\1>\s*$/,
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-single-line",
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
- // Check for self-closing tag: <ComponentName ... /> (space before / is optional)
38
- const selfClosingMatch = trimmed.match(
39
- /^<([A-Za-z][A-Za-z0-9-]*)(\s+[^>]*)?\s*\/>\s*$/,
40
- )
41
- if (selfClosingMatch) {
42
- const [, tagName, attributesStr] = selfClosingMatch
43
- const component = customComponents[tagName]
44
- if (component) {
45
- const attributes = parseAttributes(attributesStr || "")
46
- return {
47
- type: "custom-component",
48
- tagName,
49
- component,
50
- attributes,
51
- selfClosing: true,
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
- // Check for opening tag: <ComponentName ...>
57
- const openTagMatch = trimmed.match(
58
- /^<([A-Za-z][A-Za-z0-9-]*)(\s+[^>]*)?>\s*$/,
59
- )
60
- if (openTagMatch) {
61
- const [, tagName, attributesStr] = openTagMatch
62
- const component = customComponents[tagName]
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
- // Check for closing tag: </ComponentName>
76
- const closeTagMatch = trimmed.match(/^<\/([A-Za-z][A-Za-z0-9-]*)>\s*$/)
77
- if (closeTagMatch) {
78
- const [, tagName] = closeTagMatch
79
- const component = customComponents[tagName]
80
- 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) {
81
120
  return {
82
- type: "custom-component-close",
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
- // Check if the entire quoted value is a variable reference: "{variable}"
164
- if (value.startsWith("{") && value.endsWith("}")) {
165
- const variableName = value.substring(1, value.length - 1).trim()
166
- if (variableName) {
167
- attributes[name] = { __variable: variableName }
168
- } else {
169
- attributes[name] = value
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
  }