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/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
package/ui/markdown/index.js
CHANGED
|
@@ -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 {
|
|
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*(
|
|
75
|
+
/^(.+?)\s*(===|!==|>=|<=|>|<|==|!=)\s*(.+)$/,
|
|
56
76
|
)
|
|
57
77
|
|
|
58
78
|
if (comparisonMatch) {
|
|
59
79
|
const [, leftExpr, operator, rightExpr] = comparisonMatch
|
|
60
80
|
|
|
61
|
-
// Resolve left side
|
|
62
|
-
const leftValue =
|
|
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
|
|
104
|
+
// Otherwise treat as an expression
|
|
85
105
|
else {
|
|
86
|
-
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
|
-
|
|
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+(
|
|
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
|
-
|
|
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+(
|
|
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*(
|
|
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 (
|
|
70
|
+
if (FORBIDDEN_KEYS.has(segment.name)) {
|
|
64
71
|
report(
|
|
65
72
|
line,
|
|
66
|
-
"
|
|
67
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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(
|
|
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
|