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 CHANGED
@@ -194,6 +194,32 @@ Content-Security-Policy: script-src 'nonce-rAnd0m123' 'strict-dynamic';
194
194
 
195
195
  ## Features
196
196
 
197
+ ### Prose - markdown with templating
198
+
199
+ Render markdown content with safe interpolation, conditionals, loops,
200
+ tables, heading anchors and custom components:
201
+
202
+ ```js
203
+ const { Prose } = require("boxwood/ui")
204
+
205
+ Prose({ data, components: { Gallery } }, `
206
+ # {title}
207
+
208
+ Published {date.toLocaleDateString('en-US')}
209
+
210
+ {#each posts.slice(0, limit) as post, i}
211
+ Part {i + 1}: **{post.title ?? 'Untitled'}**
212
+ {/each}
213
+
214
+ <Gallery images="{images.slice(0, 3)}" />
215
+ `)
216
+ ```
217
+
218
+ Templates never execute arbitrary code - expressions resolve against `data`
219
+ with a whitelist of non-mutating methods, and `Prose.validate` reports
220
+ unknown variables, unsafe methods and malformed blocks with line numbers.
221
+ See the full [Prose syntax reference](docs/prose.md).
222
+
197
223
  ### Components with CSS
198
224
 
199
225
  ```js
package/index.js CHANGED
@@ -79,6 +79,10 @@ function compile(path) {
79
79
  head: [],
80
80
  body: [],
81
81
  }
82
+ // JSON-LD scripts are collected, deduplicated by content and moved
83
+ // to head - a component rendering structured data can be used many
84
+ // times without emitting duplicate entries
85
+ const schemas = { keys: new Set(), nodes: [] }
82
86
  const walk = (node) => {
83
87
  if (!node) {
84
88
  return
@@ -98,14 +102,19 @@ function compile(path) {
98
102
  }
99
103
  if (node.name === "script") {
100
104
  const attributes = node.attributes || {}
101
- if (
102
- attributes.src ||
103
- ["application/json", "application/ld+json"].includes(
104
- attributes.type,
105
- )
106
- ) {
105
+ if (attributes.src || attributes.type === "application/json") {
107
106
  node.ignore = false
108
107
  return
108
+ } else if (attributes.type === "application/ld+json") {
109
+ const key = render(node.children, false)
110
+ if (schemas.keys.has(key)) {
111
+ // Duplicate structured data - drop it
112
+ node.ignore = true
113
+ } else {
114
+ schemas.keys.add(key)
115
+ schemas.nodes.push(node)
116
+ }
117
+ return
109
118
  } else {
110
119
  const script = node.children
111
120
  if (script) {
@@ -150,6 +159,19 @@ function compile(path) {
150
159
  }
151
160
  nodes.head.children.push(scriptNode)
152
161
  }
162
+ // Move JSON-LD scripts into head, keeping their attributes
163
+ // Without a head node the first occurrence stays in place
164
+ for (const scriptNode of schemas.nodes) {
165
+ scriptNode.ignore = true
166
+ const copy = {
167
+ name: "script",
168
+ children: scriptNode.children,
169
+ }
170
+ if (scriptNode.attributes) {
171
+ copy.attributes = scriptNode.attributes
172
+ }
173
+ nodes.head.children.push(copy)
174
+ }
153
175
  }
154
176
  if (nodes.body) {
155
177
  if (scripts.body.length > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "boxwood",
3
- "version": "2.18.1",
3
+ "version": "2.19.0",
4
4
  "description": "Compile HTML templates into JS",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -18,6 +18,7 @@ const BASIC_HTML_COMPONENTS = {
18
18
  code: nodes.Code,
19
19
  strong: nodes.Strong,
20
20
  em: nodes.Em,
21
+ del: nodes.Del,
21
22
  p: nodes.P,
22
23
  ul: nodes.Ul,
23
24
  ol: nodes.Ol,
@@ -25,6 +26,12 @@ const BASIC_HTML_COMPONENTS = {
25
26
  pre: nodes.Pre,
26
27
  blockquote: nodes.Blockquote,
27
28
  hr: nodes.Hr,
29
+ table: nodes.Table,
30
+ thead: nodes.Thead,
31
+ tbody: nodes.Tbody,
32
+ tr: nodes.Tr,
33
+ th: nodes.Th,
34
+ td: nodes.Td,
28
35
  }
29
36
 
30
37
  function Markdown(params, children) {
package/ui/prose/index.js CHANGED
@@ -145,6 +145,13 @@ function Prose(params, children) {
145
145
  // no templating pass (comments, loops, conditionals, interpolation) may touch it
146
146
  const { text: maskedChildren, tokens } = maskCodeSegments(children)
147
147
 
148
+ // Inherit tokens from the outer Prose call (nested component content is
149
+ // masked by the outer call) so heading anchors can restore code text
150
+ const outerTokens = params && params.__codeTokens
151
+ const codeTokens = outerTokens
152
+ ? new Map([...outerTokens, ...tokens])
153
+ : tokens
154
+
148
155
  // Remove {!-- ... --} author comments
149
156
  let processedChildren = stripComments(maskedChildren)
150
157
 
@@ -159,9 +166,12 @@ function Prose(params, children) {
159
166
  const items = parseMarkdownLines(processedChildren, allComponents, data)
160
167
 
161
168
  // Convert parsed items into final node tree
169
+ // __codeTokens travels with params so recursive Prose calls (multi-line
170
+ // component children) can also restore code text in heading anchors
171
+ // __headingAnchors opts into anchor ids - a Prose feature, not plain Markdown
162
172
  const nodes = convertItemsToNodes(
163
173
  items,
164
- params,
174
+ { ...params, __codeTokens: codeTokens, __headingAnchors: true },
165
175
  htmlParams,
166
176
  data,
167
177
  allComponents,
@@ -1,4 +1,8 @@
1
- const { resolvePath } = require("../../utilities/replaceVariables")
1
+ const {
2
+ resolveExpression,
3
+ splitLogicalOr,
4
+ splitLogicalAnd,
5
+ } = require("../../utilities/replaceVariables")
2
6
 
3
7
  /**
4
8
  * Check if a value is truthy for conditional rendering
@@ -33,12 +37,28 @@ function isTruthy(value) {
33
37
  /**
34
38
  * Evaluate a condition expression
35
39
  * Supports: variable, variable > value, variable < value, etc.
36
- * Also supports negation with ! prefix
40
+ * Also supports negation with ! prefix and logical || and && operators
41
+ * with JS precedence (&& binds tighter than ||),
42
+ * e.g. "articles.length == 0 || user.role == 'admin'"
43
+ * Both comparison sides and bare conditions accept full expressions:
44
+ * ?? fallbacks, arithmetic ("items.length - 1 > 0") and array literals
37
45
  * @param {string} condition - The condition to evaluate
38
46
  * @param {Object} data - Data object with variable values
39
47
  * @returns {boolean} - Whether the condition is true
40
48
  */
41
49
  function evaluateCondition(condition, data) {
50
+ // Logical operators, || first so && binds tighter (JS precedence)
51
+ // Each operand is evaluated recursively, including its own ! negation
52
+ const orOperands = splitLogicalOr(condition)
53
+ if (orOperands.length > 1) {
54
+ return orOperands.some((operand) => evaluateCondition(operand, data))
55
+ }
56
+
57
+ const andOperands = splitLogicalAnd(condition)
58
+ if (andOperands.length > 1) {
59
+ return andOperands.every((operand) => evaluateCondition(operand, data))
60
+ }
61
+
42
62
  // Check for negation operator at the start
43
63
  let negated = false
44
64
  let conditionToEvaluate = condition.trim()
@@ -50,16 +70,16 @@ function evaluateCondition(condition, data) {
50
70
 
51
71
  let result
52
72
 
53
- // Check for comparison operators
73
+ // Check for comparison operators - longest first so === wins over ==
54
74
  const comparisonMatch = conditionToEvaluate.match(
55
- /^(.+?)\s*(>=|<=|>|<|==|!=)\s*(.+)$/,
75
+ /^(.+?)\s*(===|!==|>=|<=|>|<|==|!=)\s*(.+)$/,
56
76
  )
57
77
 
58
78
  if (comparisonMatch) {
59
79
  const [, leftExpr, operator, rightExpr] = comparisonMatch
60
80
 
61
- // Resolve left side (variable or path)
62
- const leftValue = resolvePath(data, leftExpr.trim())
81
+ // Resolve left side - any expression, e.g. "items.length - 1"
82
+ const leftValue = resolveExpression(data, leftExpr.trim())
63
83
 
64
84
  // Resolve right side (could be a number, string, or variable)
65
85
  let rightValue = rightExpr.trim()
@@ -81,9 +101,9 @@ function evaluateCondition(condition, data) {
81
101
  ) {
82
102
  rightValue = rightValue.slice(1, -1)
83
103
  }
84
- // Otherwise treat as a variable path
104
+ // Otherwise treat as an expression
85
105
  else {
86
- rightValue = resolvePath(data, rightValue)
106
+ rightValue = resolveExpression(data, rightValue)
87
107
  }
88
108
 
89
109
  // Perform comparison
@@ -106,12 +126,19 @@ function evaluateCondition(condition, data) {
106
126
  case "!=":
107
127
  result = leftValue != rightValue
108
128
  break
129
+ case "===":
130
+ result = leftValue === rightValue
131
+ break
132
+ case "!==":
133
+ result = leftValue !== rightValue
134
+ break
109
135
  default:
110
136
  result = false
111
137
  }
112
138
  } else {
113
139
  // No comparison operator, just evaluate truthiness
114
- const value = resolvePath(data, conditionToEvaluate)
140
+ // Expressions are supported, e.g. "title ?? subtitle"
141
+ const value = resolveExpression(data, conditionToEvaluate)
115
142
  result = isTruthy(value)
116
143
  }
117
144
 
@@ -1,7 +1,4 @@
1
- const {
2
- resolvePath,
3
- resolveExpression,
4
- } = require("../../utilities/replaceVariables")
1
+ const { resolveExpression } = require("../../utilities/replaceVariables")
5
2
  const { processConditionals } = require("./processConditionals")
6
3
 
7
4
  // Upper bound for expanded loop output - {#each} blocks nested over large
@@ -17,6 +14,8 @@ const MAX_EXPANSION_LENGTH = 1000000
17
14
  * - {#each items as item} - custom item variable name
18
15
  * - {#each items as item, index} - access item and index
19
16
  * - {#each items}...{#else}...{/each} - fallback content for empty arrays
17
+ * - {#each posts.slice(0, 3) as post} - any expression producing an array,
18
+ * including array literals, spread and ?? fallbacks
20
19
  * @param {string} text - Text containing loop blocks
21
20
  * @param {Object} data - Data object with variable values
22
21
  * @returns {string} - Text with loops expanded
@@ -41,8 +40,9 @@ function processLoops(text, data) {
41
40
 
42
41
  // Find the next {#each} block
43
42
  // Matches: {#each items}, {#each items as item}, {#each items as item, index}
43
+ // The array part is any expression, e.g. {#each posts.slice(0, 3) as post}
44
44
  const eachMatch = result.match(
45
- /\{#each\s+([a-zA-Z0-9_.[\]]+)(?:\s+as\s+([a-zA-Z0-9_]+)(?:\s*,\s*([a-zA-Z0-9_]+))?)?\}/,
45
+ /\{#each\s+(.+?)(?:\s+as\s+([a-zA-Z0-9_]+)(?:\s*,\s*([a-zA-Z0-9_]+))?)?\}/,
46
46
  )
47
47
  if (!eachMatch) break
48
48
 
@@ -86,8 +86,9 @@ function processLoops(text, data) {
86
86
  // Split the block into the item branch and an optional {#else} branch
87
87
  const { itemBranch, elseBranch } = splitLoopBranches(blockContent)
88
88
 
89
- // Resolve the array
90
- const array = resolvePath(data, arrayPath)
89
+ // Resolve the array - expressions are supported, e.g. "posts.slice(0, 3)",
90
+ // "[...featured, ...latest]" or "posts ?? []"
91
+ const array = resolveExpression(data, arrayPath)
91
92
 
92
93
  // Generate repeated content
93
94
  let expandedContent = ""
@@ -1,18 +1,25 @@
1
1
  const {
2
2
  SAFE_METHODS,
3
+ FORBIDDEN_KEYS,
4
+ MAX_EXPRESSION_LENGTH,
3
5
  parseArgument,
4
6
  parsePathSegments,
5
7
  splitNullishCoalescing,
8
+ splitLogicalOr,
9
+ splitLogicalAnd,
10
+ splitArithmetic,
6
11
  isArrayLiteral,
7
12
  splitArrayElements,
8
13
  } = require("../../utilities/replaceVariables")
14
+ const { scanUnquotedGt } = require("../../utilities/parseBlock")
9
15
 
10
16
  // Matches the {#each} header accepted by processLoops
17
+ // The array part is any expression, e.g. "posts.slice(0, 3)"
11
18
  const EACH_HEADER_REGEXP =
12
- /^#each\s+([a-zA-Z0-9_.[\]]+)(?:\s+as\s+([a-zA-Z0-9_]+)(?:\s*,\s*([a-zA-Z0-9_]+))?)?$/
19
+ /^#each\s+(.+?)(?:\s+as\s+([a-zA-Z0-9_]+)(?:\s*,\s*([a-zA-Z0-9_]+))?)?$/
13
20
 
14
21
  // Matches the comparison conditions accepted by processConditionals
15
- const CONDITION_REGEXP = /^(.+?)\s*(>=|<=|>|<|==|!=)\s*(.+)$/
22
+ const CONDITION_REGEXP = /^(.+?)\s*(===|!==|>=|<=|>|<|==|!=)\s*(.+)$/
16
23
 
17
24
  const INLINE_CODE_REGEXP = /(`+)([^`]+)\1/g
18
25
  const BRACE_REGEXP = /\{([^{}]*)\}/g
@@ -60,13 +67,28 @@ function validate(text, options = {}) {
60
67
  return
61
68
  }
62
69
  for (const segment of segments) {
63
- if (segment.type === "method" && !SAFE_METHODS.has(segment.name)) {
70
+ if (FORBIDDEN_KEYS.has(segment.name)) {
64
71
  report(
65
72
  line,
66
- "unsafe-method",
67
- `Method not allowed: ${segment.name}() - allowed methods: ${[...SAFE_METHODS].join(", ")}`,
73
+ "forbidden-property",
74
+ `Property not allowed: ${segment.name} - prototype access is blocked`,
68
75
  )
69
76
  }
77
+ if (segment.type === "method") {
78
+ if (!SAFE_METHODS.has(segment.name)) {
79
+ report(
80
+ line,
81
+ "unsafe-method",
82
+ `Method not allowed: ${segment.name}() - allowed methods: ${[...SAFE_METHODS].join(", ")}`,
83
+ )
84
+ }
85
+ // Path arguments are full expressions, e.g. "slice(0, n - 1)"
86
+ for (const arg of segment.args) {
87
+ if (arg.type === "path") {
88
+ validateExpression(arg.path, line)
89
+ }
90
+ }
91
+ }
70
92
  }
71
93
  const root = segments[0]
72
94
  if (
@@ -85,7 +107,80 @@ function validate(text, options = {}) {
85
107
  const trimmed = expression.trim()
86
108
  if (!trimmed) return
87
109
 
88
- for (const operand of splitNullishCoalescing(trimmed)) {
110
+ // Oversized expressions are rejected by the resolver too - report and
111
+ // stop before the recursive scanners run on a pathological input
112
+ if (expression.length > MAX_EXPRESSION_LENGTH) {
113
+ report(
114
+ line,
115
+ "malformed-expression",
116
+ `Expression too long (over ${MAX_EXPRESSION_LENGTH} characters)`,
117
+ )
118
+ return
119
+ }
120
+
121
+ const nullishOperands = splitNullishCoalescing(trimmed)
122
+ const orOperands = splitLogicalOr(trimmed)
123
+ const andOperands = splitLogicalAnd(trimmed)
124
+
125
+ // Mixing ?? with || or && without parentheses is invalid, same as in JS
126
+ if (
127
+ nullishOperands.length > 1 &&
128
+ (orOperands.length > 1 || andOperands.length > 1)
129
+ ) {
130
+ report(
131
+ line,
132
+ "malformed-expression",
133
+ `Cannot mix ?? with || or && in one expression: {${trimmed}}`,
134
+ )
135
+ return
136
+ }
137
+
138
+ // || first so && binds tighter (JS precedence) - operands of || may
139
+ // contain && and recurse through validateExpression
140
+ const operands =
141
+ orOperands.length > 1
142
+ ? orOperands
143
+ : andOperands.length > 1
144
+ ? andOperands
145
+ : nullishOperands
146
+
147
+ if (operands.length > 1) {
148
+ for (const operand of operands) {
149
+ const raw = operand.trim()
150
+ if (!raw) {
151
+ report(
152
+ line,
153
+ "malformed-expression",
154
+ `Malformed expression: {${trimmed}}`,
155
+ )
156
+ continue
157
+ }
158
+ validateExpression(raw, line)
159
+ }
160
+ return
161
+ }
162
+
163
+ // Arithmetic operands validate recursively, e.g. {i + 1}
164
+ const sums = splitArithmetic(trimmed, ["+", "-"])
165
+ const arithmetic =
166
+ sums.operators.length > 0 ? sums : splitArithmetic(trimmed, ["*", "/"])
167
+ if (arithmetic.operators.length > 0) {
168
+ for (const operand of arithmetic.operands) {
169
+ const raw = operand.trim()
170
+ if (!raw) {
171
+ report(
172
+ line,
173
+ "malformed-expression",
174
+ `Malformed expression: {${trimmed}}`,
175
+ )
176
+ continue
177
+ }
178
+ validateExpression(raw, line)
179
+ }
180
+ return
181
+ }
182
+
183
+ for (const operand of operands) {
89
184
  const raw = operand.trim()
90
185
  if (!raw) {
91
186
  report(
@@ -100,7 +195,12 @@ function validate(text, options = {}) {
100
195
  }
101
196
  if (isArrayLiteral(raw)) {
102
197
  for (const element of splitArrayElements(raw.substring(1, raw.length - 1))) {
103
- validateExpression(element, line)
198
+ // Spread elements validate the spread expression itself
199
+ const rawElement = element.trim()
200
+ validateExpression(
201
+ rawElement.startsWith("...") ? rawElement.substring(3) : rawElement,
202
+ line,
203
+ )
104
204
  }
105
205
  continue
106
206
  }
@@ -109,6 +209,25 @@ function validate(text, options = {}) {
109
209
  }
110
210
 
111
211
  const validateCondition = (condition, line) => {
212
+ // Logical operators - validate each operand on its own
213
+ const orOperands = splitLogicalOr(condition)
214
+ const operands =
215
+ orOperands.length > 1 ? orOperands : splitLogicalAnd(condition)
216
+ if (operands.length > 1) {
217
+ for (const operand of operands) {
218
+ if (!operand.trim()) {
219
+ report(
220
+ line,
221
+ "malformed-expression",
222
+ `Malformed condition: {#if ${condition.trim()}}`,
223
+ )
224
+ continue
225
+ }
226
+ validateCondition(operand, line)
227
+ }
228
+ return
229
+ }
230
+
112
231
  let expr = condition.trim()
113
232
  if (expr.startsWith("!")) {
114
233
  expr = expr.substring(1).trim()
@@ -157,7 +276,12 @@ function validate(text, options = {}) {
157
276
  "{#elseif} without a matching {#if}",
158
277
  )
159
278
  }
160
- validateCondition(content.substring("#elseif".length), line)
279
+ const condition = content.substring("#elseif".length)
280
+ if (!condition.trim()) {
281
+ report(line, "malformed-block", "Empty condition: {#elseif}")
282
+ return
283
+ }
284
+ validateCondition(condition, line)
161
285
  return
162
286
  }
163
287
 
@@ -173,8 +297,15 @@ function validate(text, options = {}) {
173
297
  }
174
298
 
175
299
  if (content.startsWith("#if")) {
300
+ // Push before validating so a matching {/if} does not report an error
176
301
  stack.push({ type: "if", line })
177
- validateCondition(content.substring("#if".length), line)
302
+ const condition = content.substring("#if".length)
303
+ if (!condition.trim()) {
304
+ // The renderer leaves {#if } blocks as literal text
305
+ report(line, "malformed-block", "Empty condition: {#if}")
306
+ return
307
+ }
308
+ validateCondition(condition, line)
178
309
  return
179
310
  }
180
311
 
@@ -252,6 +383,28 @@ function validate(text, options = {}) {
252
383
  )
253
384
  }
254
385
  }
386
+ // Multi-line component tags - join the attribute lines so expressions
387
+ // spanning lines (e.g. multi-line array literals) are validated as one
388
+ // The tag ends at the first > outside quoted attribute values
389
+ const gtState = { quote: null }
390
+ if (componentMatch && !scanUnquotedGt(line, gtState)) {
391
+ const parts = [line]
392
+ let j = i + 1
393
+ // A blank line ends the markdown block - stop joining there
394
+ while (
395
+ j < lines.length &&
396
+ lines[j].trim() &&
397
+ !scanUnquotedGt(lines[j], gtState)
398
+ ) {
399
+ parts.push(lines[j])
400
+ j++
401
+ }
402
+ if (j < lines.length && lines[j].trim()) {
403
+ parts.push(lines[j])
404
+ line = parts.join("\n")
405
+ i = j
406
+ }
407
+ }
255
408
  // Braced tags and expressions - on component lines this also covers
256
409
  // attribute expressions, both whole-value ({expr}) and partial (/p/{id})
257
410
  let braceMatch
@@ -261,7 +414,16 @@ function validate(text, options = {}) {
261
414
  continue
262
415
  }
263
416
  const content = braceMatch[1].trim()
264
- if (!content) continue
417
+ if (!content) {
418
+ // A forgotten value - the renderer keeps {} as literal text,
419
+ // and an attribute value "{}" is passed through as a string
420
+ report(
421
+ lineNumber,
422
+ "malformed-expression",
423
+ "Empty expression: {} - escape with \\{ for literal braces",
424
+ )
425
+ continue
426
+ }
265
427
 
266
428
  if (content.startsWith("#") || content.startsWith("/")) {
267
429
  handleControlTag(content, lineNumber)
@@ -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