boxwood 2.17.0 → 2.18.1
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/.claude/settings.local.json +14 -0
- package/package.json +1 -1
- package/ui/markdown/index.js +3 -3
- package/ui/prose/README.md +145 -16
- package/ui/prose/index.js +36 -5
- package/ui/prose/utilities/processConditionals.js +1 -1
- package/ui/prose/utilities/processLoops.js +70 -9
- package/ui/prose/utilities/protectCode.js +111 -0
- package/ui/prose/utilities/stripComments.js +19 -0
- package/ui/prose/utilities/validate.js +292 -0
- package/ui/{prose/utilities → utilities}/parseCustomTag.js +25 -12
- package/ui/{prose/utilities → utilities}/replaceVariables.js +180 -3
- package/ui/markdown/utilities/parseCustomTag.js +0 -218
- package/ui/markdown/utilities/replaceVariables.js +0 -115
- package/ui/prose/utilities/brackets.js +0 -21
- package/ui/prose/utilities/convertNodes.js +0 -193
- package/ui/prose/utilities/format.js +0 -184
- package/ui/prose/utilities/params.js +0 -29
- package/ui/prose/utilities/parseBlock.js +0 -284
- /package/ui/{markdown/utilities → utilities}/brackets.js +0 -0
- /package/ui/{markdown/utilities → utilities}/convertNodes.js +0 -0
- /package/ui/{markdown/utilities → utilities}/format.js +0 -0
- /package/ui/{markdown/utilities → utilities}/params.js +0 -0
- /package/ui/{markdown/utilities → utilities}/parseBlock.js +0 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permissions": {
|
|
3
|
+
"allow": [
|
|
4
|
+
"Bash(grep -rn \"js\\\\`\\\\|const js\\\\|export.*js\" /Users/emilos/Projects/buxlabs/boxwood/src --include=\"*.js\" -l)",
|
|
5
|
+
"Bash(grep -rln \"js\\\\`\" /Users/emilos/Projects/buxlabs/boxwood/ui /Users/emilos/Projects/buxlabs/boxwood/test)",
|
|
6
|
+
"Bash(grep -rn -A8 \"js\\\\`\" /Users/emilos/Projects/buxlabs/boxwood/ui)",
|
|
7
|
+
"Bash(node --test ui/prose/utilities/replaceVariables.test.js ui/prose/utilities/parseCustomTag.test.js test/ui/prose/gallery/index.test.js)",
|
|
8
|
+
"Bash(npm test *)",
|
|
9
|
+
"Bash(echo \"exit: $?\")",
|
|
10
|
+
"Bash(node -e ' *)",
|
|
11
|
+
"Bash(node --test ui/prose/utilities/processLoops.test.js)"
|
|
12
|
+
]
|
|
13
|
+
}
|
|
14
|
+
}
|
package/package.json
CHANGED
package/ui/markdown/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
const nodes = require("../..")
|
|
2
2
|
const { component } = nodes
|
|
3
3
|
|
|
4
|
-
const { extractHtmlParams, mergeComponents } = require("
|
|
5
|
-
const { parseMarkdownLines } = require("
|
|
6
|
-
const { convertItemsToNodes } = require("
|
|
4
|
+
const { extractHtmlParams, mergeComponents } = require("../utilities/params")
|
|
5
|
+
const { parseMarkdownLines } = require("../utilities/parseBlock")
|
|
6
|
+
const { convertItemsToNodes } = require("../utilities/convertNodes")
|
|
7
7
|
|
|
8
8
|
// Basic HTML components needed for markdown formatting (links, images, code, etc.)
|
|
9
9
|
const BASIC_HTML_COMPONENTS = {
|
package/ui/prose/README.md
CHANGED
|
@@ -19,15 +19,19 @@ All standard markdown features:
|
|
|
19
19
|
### Templating Features
|
|
20
20
|
|
|
21
21
|
- **Variable replacement**: `{variable}`, `{user.name}`, `{images[0].src}`
|
|
22
|
+
- **Fallbacks**: `{nickname ?? name ?? "Guest"}` with JS nullish semantics
|
|
22
23
|
- **Method calls**: `{images.slice(0, 2)}`, `{tags.join(', ')}`, `{name.toUpperCase()}`
|
|
23
|
-
- **
|
|
24
|
-
- **
|
|
24
|
+
- **Array literals**: `<Gallery images="{[images[0], images[2]]}" />`
|
|
25
|
+
- **Conditionals**: `{#if}...{#elseif}...{#else}...{/if}` blocks
|
|
26
|
+
- **Loops**: `{#each items}...{#else}...{/each}` with an empty state
|
|
25
27
|
- **Custom components**: `<Alert type="warning">...</Alert>`
|
|
26
28
|
- **Builtin HTML tags**: `<article>`, `<section>`, `<div>`, etc.
|
|
29
|
+
- **Author comments**: `{!-- never rendered --}`
|
|
30
|
+
- **Literal code**: templating never runs inside code blocks or inline code
|
|
31
|
+
- **Validation**: `Prose.validate(text, { data, components })` reports problems
|
|
27
32
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
None at the moment - all core features are implemented!
|
|
33
|
+
The language is closed by design - see
|
|
34
|
+
[What is deliberately missing](#what-is-deliberately-missing).
|
|
31
35
|
|
|
32
36
|
## Usage
|
|
33
37
|
|
|
@@ -133,6 +137,55 @@ Rules:
|
|
|
133
137
|
- Mutating methods like `push`, `pop` or `splice` are rejected and the placeholder is kept as-is
|
|
134
138
|
- Method calls can be chained with property access: `{gallery.images.slice(1)[0]}`
|
|
135
139
|
|
|
140
|
+
## Fallbacks with ??
|
|
141
|
+
|
|
142
|
+
Provide a fallback for missing values with the `??` operator:
|
|
143
|
+
|
|
144
|
+
```markdown
|
|
145
|
+
Hello {nickname ?? name ?? "Guest"}
|
|
146
|
+
|
|
147
|
+
Price: {price ?? "TBD"}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
The semantics match JavaScript: the fallback applies only when the value is
|
|
151
|
+
`null` or `undefined`. Zero, empty strings and `false` are real values and do
|
|
152
|
+
not trigger the fallback:
|
|
153
|
+
|
|
154
|
+
```markdown
|
|
155
|
+
Count: {count ?? "none"}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
With `count: 0` this renders `Count: 0`, not `Count: none`.
|
|
159
|
+
|
|
160
|
+
Operands can be paths, literals (`"text"`, `0`, `true`, `null`), method calls
|
|
161
|
+
or array literals, and `??` works in text, in loops and in component
|
|
162
|
+
attributes:
|
|
163
|
+
|
|
164
|
+
```markdown
|
|
165
|
+
<Gallery images="{gallery ?? ['placeholder.jpg']}" />
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Array Literals
|
|
169
|
+
|
|
170
|
+
Component attributes accept array literals whose elements are paths or
|
|
171
|
+
literals:
|
|
172
|
+
|
|
173
|
+
```markdown
|
|
174
|
+
<Gallery images="{[images[0], images[2]]}" />
|
|
175
|
+
<Hero sources="{[cover ?? 'default.jpg', banner]}" />
|
|
176
|
+
```
|
|
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
|
+
|
|
136
189
|
## Conditional Rendering
|
|
137
190
|
|
|
138
191
|
Use `{#if}` blocks to conditionally render content based on data:
|
|
@@ -502,22 +555,98 @@ Prose(
|
|
|
502
555
|
|
|
503
556
|
Note: Since template literals evaluate expressions like `{i + 1}`, you may need to use alternative approaches if you want to perform calculations. In this case, pre-process your data.
|
|
504
557
|
|
|
505
|
-
|
|
558
|
+
### Empty State with {#else}
|
|
506
559
|
|
|
507
|
-
When
|
|
560
|
+
When the array is empty or missing, the `{#else}` branch renders instead:
|
|
508
561
|
|
|
509
|
-
```
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
- {item}
|
|
562
|
+
```markdown
|
|
563
|
+
{#each products as product}
|
|
564
|
+
- {product.title}
|
|
565
|
+
{#else}
|
|
566
|
+
No products yet.
|
|
515
567
|
{/each}
|
|
516
|
-
`,
|
|
517
|
-
)
|
|
518
|
-
// Renders nothing
|
|
519
568
|
```
|
|
520
569
|
|
|
570
|
+
Without an `{#else}` branch, an empty array renders nothing. An `{#else}`
|
|
571
|
+
that belongs to an `{#if}` inside the loop is not confused with the loop's
|
|
572
|
+
own `{#else}`.
|
|
573
|
+
|
|
574
|
+
### Expansion Limit
|
|
575
|
+
|
|
576
|
+
Loop expansion is capped at 1,000,000 characters. Nested loops over large
|
|
577
|
+
arrays multiply quickly - exceeding the cap throws a clear error instead of
|
|
578
|
+
freezing the page.
|
|
579
|
+
|
|
580
|
+
## Author Comments
|
|
581
|
+
|
|
582
|
+
Notes for editors that never render:
|
|
583
|
+
|
|
584
|
+
```markdown
|
|
585
|
+
{!-- TODO: replace the photo after the shoot --}
|
|
586
|
+
|
|
587
|
+
# The Article
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
Comments can span multiple lines. Templating inside a comment is never
|
|
591
|
+
executed.
|
|
592
|
+
|
|
593
|
+
## Code Is Literal
|
|
594
|
+
|
|
595
|
+
Templating never runs inside fenced code blocks or inline code - braces,
|
|
596
|
+
loops and conditionals stay exactly as written:
|
|
597
|
+
|
|
598
|
+
````markdown
|
|
599
|
+
Inline `{name}` stays literal, and so does everything inside fences:
|
|
600
|
+
|
|
601
|
+
```js
|
|
602
|
+
const x = {count}
|
|
603
|
+
{#if debug}console.log(x){/if}
|
|
604
|
+
```
|
|
605
|
+
````
|
|
606
|
+
|
|
607
|
+
## Escaping
|
|
608
|
+
|
|
609
|
+
Use `\{` to render a literal brace in regular text: `\{name}` renders as
|
|
610
|
+
`{name}`.
|
|
611
|
+
|
|
612
|
+
## Validation
|
|
613
|
+
|
|
614
|
+
`Prose.validate` reports the problems the renderer tolerates silently -
|
|
615
|
+
ideal for a CMS preview:
|
|
616
|
+
|
|
617
|
+
```javascript
|
|
618
|
+
const { Prose } = require("boxwood/ui")
|
|
619
|
+
|
|
620
|
+
const issues = Prose.validate(text, { data, components })
|
|
621
|
+
// [
|
|
622
|
+
// { line: 3, type: "unknown-component", message: "Unknown component: <Galery>" },
|
|
623
|
+
// { line: 3, type: "unknown-variable", message: "Unknown variable: imges" },
|
|
624
|
+
// { line: 4, type: "unclosed-block", message: "Unclosed {#each} opened on line 4" },
|
|
625
|
+
// ]
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
Both options are optional: without `data` the unknown-variable check is
|
|
629
|
+
skipped, without `components` the unknown-component check only knows the
|
|
630
|
+
builtin components. Issue types: `unknown-variable`, `unknown-component`,
|
|
631
|
+
`unsafe-method`, `malformed-expression`, `malformed-block`,
|
|
632
|
+
`unmatched-block`, `unclosed-block`, `unclosed-comment`,
|
|
633
|
+
`unclosed-code-block`.
|
|
634
|
+
|
|
635
|
+
## What Is Deliberately Missing
|
|
636
|
+
|
|
637
|
+
The language is closed by design. When content needs something more, prepare
|
|
638
|
+
it in the data instead of extending the syntax:
|
|
639
|
+
|
|
640
|
+
- **Filters/formatters** (`{price | currency}`) - format in JS and pass
|
|
641
|
+
`priceFormatted` in `data`
|
|
642
|
+
- **Arithmetic** (`{price * 1.23}`) - compute in JS, pass the result
|
|
643
|
+
- **Variable definitions** (`{#set ...}`) - shape the data before rendering
|
|
644
|
+
- **Ternaries** (`{a ? b : c}`) - use `{#if}` blocks or `??`
|
|
645
|
+
- **Logical operators in conditions** (`a && b`) - nest `{#if}` blocks
|
|
646
|
+
|
|
647
|
+
If a template needs one of these, that is a signal the data is missing a
|
|
648
|
+
field, not that the language is missing a feature.
|
|
649
|
+
|
|
521
650
|
## Difference from Markdown Component
|
|
522
651
|
|
|
523
652
|
- **Markdown**: Pure markdown rendering, no variables or custom components
|
package/ui/prose/index.js
CHANGED
|
@@ -7,11 +7,17 @@ const Grid = require("../grid")
|
|
|
7
7
|
const Group = require("../group")
|
|
8
8
|
const Stack = require("../stack")
|
|
9
9
|
|
|
10
|
-
const { extractHtmlParams, mergeComponents } = require("
|
|
11
|
-
const { parseMarkdownLines } = require("
|
|
12
|
-
const { convertItemsToNodes } = require("
|
|
10
|
+
const { extractHtmlParams, mergeComponents } = require("../utilities/params")
|
|
11
|
+
const { parseMarkdownLines } = require("../utilities/parseBlock")
|
|
12
|
+
const { convertItemsToNodes } = require("../utilities/convertNodes")
|
|
13
13
|
const { processConditionals } = require("./utilities/processConditionals")
|
|
14
14
|
const { processLoops } = require("./utilities/processLoops")
|
|
15
|
+
const {
|
|
16
|
+
maskCodeSegments,
|
|
17
|
+
restoreCodeSegments,
|
|
18
|
+
} = require("./utilities/protectCode")
|
|
19
|
+
const { stripComments } = require("./utilities/stripComments")
|
|
20
|
+
const { validate } = require("./utilities/validate")
|
|
15
21
|
|
|
16
22
|
// Safe builtin HTML tags that can be used as custom components in markdown
|
|
17
23
|
// These are always available and don't need to be explicitly passed in params.components
|
|
@@ -135,9 +141,16 @@ function Prose(params, children) {
|
|
|
135
141
|
const allComponents = mergeComponents(BUILTIN_COMPONENTS, customComponents)
|
|
136
142
|
const htmlParams = extractHtmlParams(params)
|
|
137
143
|
|
|
144
|
+
// Mask code blocks and inline code first - code is literal content and
|
|
145
|
+
// no templating pass (comments, loops, conditionals, interpolation) may touch it
|
|
146
|
+
const { text: maskedChildren, tokens } = maskCodeSegments(children)
|
|
147
|
+
|
|
148
|
+
// Remove {!-- ... --} author comments
|
|
149
|
+
let processedChildren = stripComments(maskedChildren)
|
|
150
|
+
|
|
138
151
|
// Process {#each}...{/each} loop blocks first
|
|
139
152
|
// Conditionals within loops are processed during loop expansion
|
|
140
|
-
|
|
153
|
+
processedChildren = processLoops(processedChildren, data)
|
|
141
154
|
|
|
142
155
|
// Process any remaining {#if}...{/if} conditional blocks (those outside loops)
|
|
143
156
|
processedChildren = processConditionals(processedChildren, data)
|
|
@@ -146,7 +159,7 @@ function Prose(params, children) {
|
|
|
146
159
|
const items = parseMarkdownLines(processedChildren, allComponents, data)
|
|
147
160
|
|
|
148
161
|
// Convert parsed items into final node tree
|
|
149
|
-
|
|
162
|
+
const nodes = convertItemsToNodes(
|
|
150
163
|
items,
|
|
151
164
|
params,
|
|
152
165
|
htmlParams,
|
|
@@ -154,6 +167,24 @@ function Prose(params, children) {
|
|
|
154
167
|
allComponents,
|
|
155
168
|
Prose,
|
|
156
169
|
)
|
|
170
|
+
|
|
171
|
+
// Put the original code content back into the final tree
|
|
172
|
+
return restoreCodeSegments(nodes, tokens)
|
|
157
173
|
}
|
|
158
174
|
|
|
159
175
|
module.exports = component(Prose)
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Validate a Prose template without rendering it
|
|
179
|
+
* Reports problems the renderer tolerates silently: unknown variables,
|
|
180
|
+
* unclosed blocks, unsafe methods, unknown components
|
|
181
|
+
* @param {string} text - The prose template text
|
|
182
|
+
* @param {Object} options - { data, components } - both optional
|
|
183
|
+
* @returns {Array<{line: number, type: string, message: string}>}
|
|
184
|
+
*/
|
|
185
|
+
module.exports.validate = (text, options = {}) => {
|
|
186
|
+
return validate(text, {
|
|
187
|
+
...options,
|
|
188
|
+
components: mergeComponents(BUILTIN_COMPONENTS, options.components),
|
|
189
|
+
})
|
|
190
|
+
}
|
|
@@ -1,6 +1,14 @@
|
|
|
1
|
-
const {
|
|
1
|
+
const {
|
|
2
|
+
resolvePath,
|
|
3
|
+
resolveExpression,
|
|
4
|
+
} = require("../../utilities/replaceVariables")
|
|
2
5
|
const { processConditionals } = require("./processConditionals")
|
|
3
6
|
|
|
7
|
+
// Upper bound for expanded loop output - {#each} blocks nested over large
|
|
8
|
+
// arrays multiply, and content authors should get a clear error instead of
|
|
9
|
+
// an unresponsive page
|
|
10
|
+
const MAX_EXPANSION_LENGTH = 1000000
|
|
11
|
+
|
|
4
12
|
/**
|
|
5
13
|
* Process {#each items}...{/each} blocks in text
|
|
6
14
|
* Repeats content for each item in an array
|
|
@@ -8,6 +16,7 @@ const { processConditionals } = require("./processConditionals")
|
|
|
8
16
|
* - {#each items} - basic iteration, access current item with {item}
|
|
9
17
|
* - {#each items as item} - custom item variable name
|
|
10
18
|
* - {#each items as item, index} - access item and index
|
|
19
|
+
* - {#each items}...{#else}...{/each} - fallback content for empty arrays
|
|
11
20
|
* @param {string} text - Text containing loop blocks
|
|
12
21
|
* @param {Object} data - Data object with variable values
|
|
13
22
|
* @returns {string} - Text with loops expanded
|
|
@@ -17,9 +26,9 @@ function processLoops(text, data) {
|
|
|
17
26
|
return text
|
|
18
27
|
}
|
|
19
28
|
|
|
20
|
-
//
|
|
29
|
+
// Without data every array is missing, so loops render their {#else} branch
|
|
21
30
|
if (!data || typeof data !== "object") {
|
|
22
|
-
|
|
31
|
+
data = {}
|
|
23
32
|
}
|
|
24
33
|
|
|
25
34
|
let result = text
|
|
@@ -74,6 +83,9 @@ function processLoops(text, data) {
|
|
|
74
83
|
const blockContent = result.substring(contentStart, endEachStart)
|
|
75
84
|
const endEachEnd = endEachStart + 7 // length of "{/each}"
|
|
76
85
|
|
|
86
|
+
// Split the block into the item branch and an optional {#else} branch
|
|
87
|
+
const { itemBranch, elseBranch } = splitLoopBranches(blockContent)
|
|
88
|
+
|
|
77
89
|
// Resolve the array
|
|
78
90
|
const array = resolvePath(data, arrayPath)
|
|
79
91
|
|
|
@@ -92,16 +104,30 @@ function processLoops(text, data) {
|
|
|
92
104
|
loopData[indexName] = idx
|
|
93
105
|
}
|
|
94
106
|
|
|
95
|
-
// First,
|
|
96
|
-
|
|
107
|
+
// First, expand nested loops recursively so they can reference
|
|
108
|
+
// the current item (e.g. {#each group.members} inside {#each groups as group})
|
|
109
|
+
let itemContent = processLoops(itemBranch, loopData)
|
|
110
|
+
|
|
111
|
+
// Then process any conditionals within the loop content using the loop data
|
|
112
|
+
itemContent = processConditionals(itemContent, loopData)
|
|
97
113
|
|
|
98
114
|
// Then replace variables in the block content
|
|
99
115
|
itemContent = replaceLoopVariables(itemContent, loopData)
|
|
100
116
|
|
|
101
117
|
expandedContent += itemContent
|
|
118
|
+
|
|
119
|
+
if (expandedContent.length > MAX_EXPANSION_LENGTH) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`Prose: {#each ${arrayPath}} expanded past ${MAX_EXPANSION_LENGTH} characters - reduce the array size or nesting`,
|
|
122
|
+
)
|
|
123
|
+
}
|
|
102
124
|
}
|
|
125
|
+
} else {
|
|
126
|
+
// Empty or missing array - render the {#else} branch if present
|
|
127
|
+
// It is inserted as-is: nested loops are handled by the next iterations
|
|
128
|
+
// of this while loop, conditionals and variables by the later passes
|
|
129
|
+
expandedContent = elseBranch
|
|
103
130
|
}
|
|
104
|
-
// If array is empty or not an array, remove the block
|
|
105
131
|
|
|
106
132
|
// Replace the entire {#each}...{/each} block
|
|
107
133
|
result =
|
|
@@ -113,6 +139,40 @@ function processLoops(text, data) {
|
|
|
113
139
|
return result
|
|
114
140
|
}
|
|
115
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Split loop block content into the item branch and an optional {#else} branch
|
|
144
|
+
* Only a top-level {#else} splits the block - an {#else} that belongs to a
|
|
145
|
+
* nested {#each} or {#if} inside the block is ignored
|
|
146
|
+
* @param {string} blockContent - Content between {#each} and {/each}
|
|
147
|
+
* @returns {{itemBranch: string, elseBranch: string}} - The two branches
|
|
148
|
+
*/
|
|
149
|
+
function splitLoopBranches(blockContent) {
|
|
150
|
+
let depth = 0
|
|
151
|
+
let i = 0
|
|
152
|
+
|
|
153
|
+
while (i < blockContent.length) {
|
|
154
|
+
if (
|
|
155
|
+
blockContent.substring(i, i + 6) === "{#each" ||
|
|
156
|
+
blockContent.substring(i, i + 4) === "{#if"
|
|
157
|
+
) {
|
|
158
|
+
depth++
|
|
159
|
+
} else if (
|
|
160
|
+
blockContent.substring(i, i + 7) === "{/each}" ||
|
|
161
|
+
blockContent.substring(i, i + 5) === "{/if}"
|
|
162
|
+
) {
|
|
163
|
+
depth--
|
|
164
|
+
} else if (depth === 0 && blockContent.substring(i, i + 7) === "{#else}") {
|
|
165
|
+
return {
|
|
166
|
+
itemBranch: blockContent.substring(0, i),
|
|
167
|
+
elseBranch: blockContent.substring(i + 7),
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
i++
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return { itemBranch: blockContent, elseBranch: "" }
|
|
174
|
+
}
|
|
175
|
+
|
|
116
176
|
/**
|
|
117
177
|
* Replace variables in text with values from loop data
|
|
118
178
|
* @param {string} text - Text containing variable placeholders
|
|
@@ -125,7 +185,7 @@ function replaceLoopVariables(text, loopData) {
|
|
|
125
185
|
}
|
|
126
186
|
|
|
127
187
|
let result = text
|
|
128
|
-
const regex = /\{([
|
|
188
|
+
const regex = /\{([^{}]+)\}/g
|
|
129
189
|
let match
|
|
130
190
|
|
|
131
191
|
// Collect all variable matches first to avoid issues with overlapping replacements
|
|
@@ -154,8 +214,9 @@ function replaceLoopVariables(text, loopData) {
|
|
|
154
214
|
continue
|
|
155
215
|
}
|
|
156
216
|
|
|
157
|
-
// Resolve the
|
|
158
|
-
|
|
217
|
+
// Resolve the expression (supports paths, safe method calls,
|
|
218
|
+
// array literals and ?? fallbacks)
|
|
219
|
+
const value = resolveExpression(loopData, path)
|
|
159
220
|
|
|
160
221
|
if (value !== undefined && value !== null) {
|
|
161
222
|
result =
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Code is literal content - templating (loops, conditionals, interpolation)
|
|
2
|
+
// must never run inside fenced code blocks or inline code spans.
|
|
3
|
+
// maskCodeSegments swaps code content for opaque tokens before the templating
|
|
4
|
+
// passes run, and restoreCodeSegments swaps the original content back into
|
|
5
|
+
// the final node tree.
|
|
6
|
+
|
|
7
|
+
// Global counter so tokens never collide across nested Prose calls
|
|
8
|
+
// (a component's children are rendered by a recursive Prose invocation)
|
|
9
|
+
let tokenId = 0
|
|
10
|
+
|
|
11
|
+
// Tokens are wrapped in NUL characters, which cannot appear in typed content
|
|
12
|
+
const TOKEN_PREFIX = "\u0000code:"
|
|
13
|
+
const TOKEN_SUFFIX = "\u0000"
|
|
14
|
+
const TOKEN_REGEXP = /\u0000code:\d+\u0000/g
|
|
15
|
+
|
|
16
|
+
// Backslash-escaped backticks (\`) are literal text, not code delimiters
|
|
17
|
+
const INLINE_CODE_REGEXP = /(?<!\\)(`+)([^`]+)\1/g
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Replace the content of fenced code blocks and inline code spans with
|
|
21
|
+
* opaque tokens so templating passes leave it untouched
|
|
22
|
+
* Fence delimiters and backticks stay in place so markdown parsing still
|
|
23
|
+
* recognizes the code structure
|
|
24
|
+
* @param {string} text - The raw prose text
|
|
25
|
+
* @returns {{text: string, tokens: Map<string, string>}} - Masked text and token map
|
|
26
|
+
*/
|
|
27
|
+
function maskCodeSegments(text) {
|
|
28
|
+
const tokens = new Map()
|
|
29
|
+
|
|
30
|
+
if (!text || typeof text !== "string") {
|
|
31
|
+
return { text, tokens }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const mask = (content) => {
|
|
35
|
+
const token = `${TOKEN_PREFIX}${tokenId++}${TOKEN_SUFFIX}`
|
|
36
|
+
tokens.set(token, content)
|
|
37
|
+
return token
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const lines = text.split("\n")
|
|
41
|
+
const result = []
|
|
42
|
+
let i = 0
|
|
43
|
+
|
|
44
|
+
while (i < lines.length) {
|
|
45
|
+
const line = lines[i]
|
|
46
|
+
|
|
47
|
+
if (line.trim().startsWith("```")) {
|
|
48
|
+
// Fenced code block - mask everything until the closing fence
|
|
49
|
+
result.push(line)
|
|
50
|
+
i++
|
|
51
|
+
const codeLines = []
|
|
52
|
+
while (i < lines.length && !lines[i].trim().startsWith("```")) {
|
|
53
|
+
codeLines.push(lines[i])
|
|
54
|
+
i++
|
|
55
|
+
}
|
|
56
|
+
if (codeLines.length > 0) {
|
|
57
|
+
result.push(mask(codeLines.join("\n")))
|
|
58
|
+
}
|
|
59
|
+
if (i < lines.length) {
|
|
60
|
+
result.push(lines[i]) // closing fence
|
|
61
|
+
i++
|
|
62
|
+
}
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Inline code spans - mask the content between backticks
|
|
67
|
+
result.push(
|
|
68
|
+
line.replace(
|
|
69
|
+
INLINE_CODE_REGEXP,
|
|
70
|
+
(match, ticks, content) => `${ticks}${mask(content)}${ticks}`,
|
|
71
|
+
),
|
|
72
|
+
)
|
|
73
|
+
i++
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { text: result.join("\n"), tokens }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Restore masked code content in the final node tree
|
|
81
|
+
* Walks strings, arrays and node objects (children and attributes)
|
|
82
|
+
* @param {*} node - A node tree, array, string or any other value
|
|
83
|
+
* @param {Map<string, string>} tokens - Token map from maskCodeSegments
|
|
84
|
+
* @returns {*} - The tree with original code content restored
|
|
85
|
+
*/
|
|
86
|
+
function restoreCodeSegments(node, tokens) {
|
|
87
|
+
if (!tokens || tokens.size === 0) {
|
|
88
|
+
return node
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (typeof node === "string") {
|
|
92
|
+
return node.replace(TOKEN_REGEXP, (token) =>
|
|
93
|
+
tokens.has(token) ? tokens.get(token) : token,
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (Array.isArray(node)) {
|
|
98
|
+
return node.map((child) => restoreCodeSegments(child, tokens))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (node && typeof node === "object") {
|
|
102
|
+
for (const key of Object.keys(node)) {
|
|
103
|
+
node[key] = restoreCodeSegments(node[key], tokens)
|
|
104
|
+
}
|
|
105
|
+
return node
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return node
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = { maskCodeSegments, restoreCodeSegments }
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Author comments: {!-- note to self --} never renders
|
|
2
|
+
// Comments can span multiple lines and are removed before any other
|
|
3
|
+
// templating pass, so their content is never interpolated or executed
|
|
4
|
+
const COMMENT_REGEXP = /\{!--[\s\S]*?--\}/g
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Remove {!-- ... --} author comments from text
|
|
8
|
+
* An unclosed comment is left as-is (the validator reports it)
|
|
9
|
+
* @param {string} text - Text possibly containing comments
|
|
10
|
+
* @returns {string} - Text without comments
|
|
11
|
+
*/
|
|
12
|
+
function stripComments(text) {
|
|
13
|
+
if (!text || typeof text !== "string") {
|
|
14
|
+
return text
|
|
15
|
+
}
|
|
16
|
+
return text.replace(COMMENT_REGEXP, "")
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
module.exports = { stripComments, COMMENT_REGEXP }
|