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
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/README.md
CHANGED
|
@@ -175,6 +175,17 @@ literals:
|
|
|
175
175
|
<Hero sources="{[cover ?? 'default.jpg', banner]}" />
|
|
176
176
|
```
|
|
177
177
|
|
|
178
|
+
## Attribute Interpolation
|
|
179
|
+
|
|
180
|
+
An attribute whose entire value is a single `{expression}` resolves to the
|
|
181
|
+
raw value (array, object, number) - that is what makes
|
|
182
|
+
`images="{images.slice(0, 2)}"` work. Expressions can also be embedded in a
|
|
183
|
+
longer attribute string, which always produces a string:
|
|
184
|
+
|
|
185
|
+
```markdown
|
|
186
|
+
<Card href="/products/{id}" alt="Photo of {name ?? 'someone'}" />
|
|
187
|
+
```
|
|
188
|
+
|
|
178
189
|
## Conditional Rendering
|
|
179
190
|
|
|
180
191
|
Use `{#if}` blocks to conditionally render content based on data:
|
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,23 +1,29 @@
|
|
|
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
|
|
19
26
|
const COMPONENT_TAG_REGEXP = /^<\/?([A-Z][A-Za-z0-9-]*)/
|
|
20
|
-
const ATTRIBUTE_EXPRESSION_REGEXP = /=\s*(?:"\{([^}"]*)\}"|'\{([^}']*)\}'|\{([^}]*)\})/g
|
|
21
27
|
|
|
22
28
|
/**
|
|
23
29
|
* Validate a Prose template and report problems that the renderer
|
|
@@ -61,13 +67,28 @@ function validate(text, options = {}) {
|
|
|
61
67
|
return
|
|
62
68
|
}
|
|
63
69
|
for (const segment of segments) {
|
|
64
|
-
if (
|
|
70
|
+
if (FORBIDDEN_KEYS.has(segment.name)) {
|
|
65
71
|
report(
|
|
66
72
|
line,
|
|
67
|
-
"
|
|
68
|
-
`
|
|
73
|
+
"forbidden-property",
|
|
74
|
+
`Property not allowed: ${segment.name} - prototype access is blocked`,
|
|
69
75
|
)
|
|
70
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
|
+
}
|
|
71
92
|
}
|
|
72
93
|
const root = segments[0]
|
|
73
94
|
if (
|
|
@@ -86,7 +107,80 @@ function validate(text, options = {}) {
|
|
|
86
107
|
const trimmed = expression.trim()
|
|
87
108
|
if (!trimmed) return
|
|
88
109
|
|
|
89
|
-
|
|
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) {
|
|
90
184
|
const raw = operand.trim()
|
|
91
185
|
if (!raw) {
|
|
92
186
|
report(
|
|
@@ -101,7 +195,12 @@ function validate(text, options = {}) {
|
|
|
101
195
|
}
|
|
102
196
|
if (isArrayLiteral(raw)) {
|
|
103
197
|
for (const element of splitArrayElements(raw.substring(1, raw.length - 1))) {
|
|
104
|
-
|
|
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
|
+
)
|
|
105
204
|
}
|
|
106
205
|
continue
|
|
107
206
|
}
|
|
@@ -110,6 +209,25 @@ function validate(text, options = {}) {
|
|
|
110
209
|
}
|
|
111
210
|
|
|
112
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
|
+
|
|
113
231
|
let expr = condition.trim()
|
|
114
232
|
if (expr.startsWith("!")) {
|
|
115
233
|
expr = expr.substring(1).trim()
|
|
@@ -158,7 +276,12 @@ function validate(text, options = {}) {
|
|
|
158
276
|
"{#elseif} without a matching {#if}",
|
|
159
277
|
)
|
|
160
278
|
}
|
|
161
|
-
|
|
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)
|
|
162
285
|
return
|
|
163
286
|
}
|
|
164
287
|
|
|
@@ -174,8 +297,15 @@ function validate(text, options = {}) {
|
|
|
174
297
|
}
|
|
175
298
|
|
|
176
299
|
if (content.startsWith("#if")) {
|
|
300
|
+
// Push before validating so a matching {/if} does not report an error
|
|
177
301
|
stack.push({ type: "if", line })
|
|
178
|
-
|
|
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)
|
|
179
309
|
return
|
|
180
310
|
}
|
|
181
311
|
|
|
@@ -253,19 +383,30 @@ function validate(text, options = {}) {
|
|
|
253
383
|
)
|
|
254
384
|
}
|
|
255
385
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
|
264
406
|
}
|
|
265
|
-
continue
|
|
266
407
|
}
|
|
267
|
-
|
|
268
|
-
//
|
|
408
|
+
// Braced tags and expressions - on component lines this also covers
|
|
409
|
+
// attribute expressions, both whole-value ({expr}) and partial (/p/{id})
|
|
269
410
|
let braceMatch
|
|
270
411
|
while ((braceMatch = BRACE_REGEXP.exec(line)) !== null) {
|
|
271
412
|
// Escaped braces are literal
|
|
@@ -273,7 +414,16 @@ function validate(text, options = {}) {
|
|
|
273
414
|
continue
|
|
274
415
|
}
|
|
275
416
|
const content = braceMatch[1].trim()
|
|
276
|
-
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
|
+
}
|
|
277
427
|
|
|
278
428
|
if (content.startsWith("#") || content.startsWith("/")) {
|
|
279
429
|
handleControlTag(content, lineNumber)
|