Skip to content

Commit c397ea5

Browse files
authored
Preserve literal bindings inside passive code fences (#1055)
1 parent 6b85f31 commit c397ea5

14 files changed

Lines changed: 515 additions & 87 deletions

‎packages/cli/src/documents/sidekick/bindings.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ Sidekick responses, and the root bindings a REPL entry inherits.
2626
execute JavaScript and does not re-parse the result as XMD; interpolating an
2727
object is not JSON serialization. Write `\{` for a literal brace. Inline
2828
backticks do not prevent interpolation.
29+
- Passive fenced source preserves its tags, references and escaped braces even
30+
when the response or entry has already bound the same names. Capture it with
31+
`<Let as="code" select="code">` for later execution; use a longer outer fence
32+
when that source contains fences of its own. Read `<Guide.Entries />` for the
33+
complete authoring pattern.
2934
- Bindings created inside a component body or an `Each` iteration do not escape
3035
to later siblings. Use that construct's documented return or capture.
3136
- An entry inherits the JSON state earlier entries published, so a value an

‎packages/cli/src/documents/sidekick/entries.md‎

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ Sidekick responses that write an entry, and the REPL entry program they write.
2222
does not.
2323
- A fenced block with no `exec` or `eval` modifier is passive text, including
2424
its tags and braces. The language label alone does not make a fence
25-
executable, and executable fences are unavailable in a response.
25+
executable. References inside it stay literal even when your response or an
26+
entry already bound the same names. Executable fences are unavailable in a
27+
response.
2628
- Capture passive source with `<Let as="code" select="code">`, then pass the
2729
captured string to `<REPL.EntryInput>`. That fills the shared draft and runs
2830
none of it.
@@ -39,6 +41,7 @@ Sidekick responses that write an entry, and the REPL entry program they write.
3941
## Example
4042

4143
````xmd
44+
<Let as="names" value={["response-only"]} />
4245
<Let as="code" select="code">
4346
```xmd
4447
<Let as="names" value={["Ada", "Lin"]} />
@@ -53,11 +56,29 @@ Hello, {name}!
5356
````
5457

5558
The shared draft now holds that program as literal source and nothing has run.
56-
The `names` binding belongs to the future entry; `code` belongs to this
57-
response. `select="code"` extracts the first matching fenced block without its
59+
The response's `names` does not replace `{names}` in the fenced source. The
60+
entry binds its own `names` when run; `code` belongs to this response.
61+
`select="code"` extracts the first matching fenced block without its
5862
fence, and trims trailing whitespace — pass a direct string value when exact
5963
string boundaries matter.
6064

65+
Use a longer outer fence when the saved entry contains an executable block:
66+
67+
`````xmd
68+
<Let as="code" select="code">
69+
````xmd
70+
<Let as="message" value="hello" />
71+
```sh exec
72+
printf '%s\n' {message}
73+
```
74+
````
75+
</Let>
76+
<REPL.EntryInput>{code}</REPL.EntryInput>
77+
`````
78+
79+
This fills the draft with the Let and exec block as source. Explicit submission
80+
runs that entry, where `{message}` resolves to `hello`.
81+
6182
### Create and use a session component across responses
6283

6384
Read `<Guide.Environment />`, `<Guide.Entries />`, `<REPL.Environment />` and

‎packages/core/src/expand.ts‎

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ import { SourceOwner } from "./source-owner.ts";
149149
import { SchemaValidationError, validateProps, validateReturnValue } from "./validate.ts";
150150
import { isJsonObject, parseJson } from "./json.ts";
151151
import { healSegment } from "./heal.ts";
152-
import { scanSegments } from "./scanner.ts";
152+
import { scanSegments, splitTextFences } from "./scanner.ts";
153153
import { declareChildAnswers, expandAnswers, strayAnswerError } from "./answers.ts";
154154
import { DeclarationScan } from "./declaration-scan.ts";
155155
import { RESERVED_STRUCTURAL } from "./structural.ts";
@@ -995,23 +995,27 @@ function* expandListSegments(
995995
// Heal incomplete markdown constructs at segment boundaries (spec §2.3)
996996
// Runs synchronously — no yield, no journal entry
997997
const healed = healSegment(segment.content);
998-
const protectedEscapes = healed.replaceAll("\\{", ESCAPED_BRACE_PLACEHOLDER);
999998
const textEvalEnv = yield* env;
1000999
const textProps =
10011000
textEvalEnv !== undefined && "props" in textEvalEnv.values
10021001
? textEvalEnv.values.props
10031002
: parentProps;
1004-
// Interpolate {meta.key} and {props.key} — runtime, no journal
1005-
const interpolated = interpolate(protectedEscapes, parentMeta, textProps);
1006-
// Interpolate bare {name} refs from eval bindings (spec §6.4/§6.6).
1007-
// Runs after meta/props interpolation so component contract takes
1008-
// precedence. Only runs when a binding environment is in scope.
1009-
const final = textEvalEnv
1010-
? interpolateEvalBindings(interpolated, textEvalEnv.values)
1011-
: interpolated;
1003+
const final = splitTextFences(segment, healed)
1004+
.map(({ content, passive }) => {
1005+
if (passive) {
1006+
return content;
1007+
}
1008+
const protectedEscapes = content.replaceAll("\\{", ESCAPED_BRACE_PLACEHOLDER);
1009+
const interpolated = interpolate(protectedEscapes, parentMeta, textProps);
1010+
const bound = textEvalEnv
1011+
? interpolateEvalBindings(interpolated, textEvalEnv.values)
1012+
: interpolated;
1013+
return bound.replaceAll(ESCAPED_BRACE_PLACEHOLDER, "{");
1014+
})
1015+
.join("");
10121016
result.push({
10131017
type: "text",
1014-
content: final.replaceAll(ESCAPED_BRACE_PLACEHOLDER, "{"),
1018+
content: final,
10151019
});
10161020
break;
10171021
}

‎packages/core/src/generated-interpolation.ts‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,19 @@
2323
* whether it will change when evaluated is exactly its question.
2424
*/
2525

26+
import { splitTextFences } from "./scanner.ts";
27+
import type { TextSegment } from "./types.ts";
28+
2629
const ESCAPED_BRACE_PLACEHOLDER = "";
2730
const FRONTMATTER_REFERENCE = /\{(meta|props)\.[^}]+\}/;
2831
const BINDING_REFERENCE = /\{[a-zA-Z_$][a-zA-Z0-9_$]*(?:\.[a-zA-Z_$][a-zA-Z0-9_$]*)*\}/;
2932

30-
export function readsBinding(content: string): boolean {
31-
const protectedEscapes = content.replaceAll("\\{", ESCAPED_BRACE_PLACEHOLDER);
32-
return FRONTMATTER_REFERENCE.test(protectedEscapes) || BINDING_REFERENCE.test(protectedEscapes);
33+
export function readsBinding(segment: TextSegment): boolean {
34+
return splitTextFences(segment).some((slice) => {
35+
if (slice.passive) {
36+
return false;
37+
}
38+
const protectedEscapes = slice.content.replaceAll("\\{", ESCAPED_BRACE_PLACEHOLDER);
39+
return FRONTMATTER_REFERENCE.test(protectedEscapes) || BINDING_REFERENCE.test(protectedEscapes);
40+
});
3341
}

‎packages/core/src/generated-xmd-preview.ts‎

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ import type { Result } from "effection";
4949

5050
import { readsBinding } from "./generated-interpolation.ts";
5151
import { outputPropsViolation, validateBodyStructure } from "./body-structure.ts";
52-
import { scanSegments } from "./scanner.ts";
52+
import { scanSegments, splitTextFences } from "./scanner.ts";
5353
import type { UnfinishedConstruct } from "./scanner.ts";
5454
import type { ComponentElement, Segment } from "./types.ts";
5555

@@ -161,8 +161,34 @@ function unfinishedRegion(
161161
}
162162
// The same walk again over the content delivered so far, so what counts as a
163163
// nested tag, a fence or an inline code span is decided once.
164-
const content = source.slice(pending.contentStart);
165-
return { output: literal(scanSegments(settledTail(content))) };
164+
const content = source.slice(
165+
0,
166+
pending.contentStart + settledTail(source.slice(pending.contentStart)).length,
167+
);
168+
const unfinished: UnfinishedConstruct[] = [];
169+
const segments = scanSegments(
170+
content,
171+
undefined,
172+
undefined,
173+
undefined,
174+
unfinished,
175+
pending.contentStart,
176+
);
177+
const first = earliest(unfinished);
178+
return {
179+
output: literal(
180+
first === undefined
181+
? segments
182+
: scanSegments(
183+
content.slice(0, first.start),
184+
undefined,
185+
undefined,
186+
undefined,
187+
undefined,
188+
pending.contentStart,
189+
),
190+
),
191+
};
166192
}
167193

168194
/**
@@ -228,8 +254,13 @@ function regions(segments: readonly Segment[]): { element: ComponentElement }[]
228254
function literal(segments: readonly Segment[]): string {
229255
let text = "";
230256
for (const segment of segments) {
231-
if (segment.type === "text" && !readsBinding(segment.content)) {
232-
text += segment.content;
257+
if (segment.type === "text") {
258+
text += readsBinding(segment)
259+
? splitTextFences(segment)
260+
.filter((slice) => slice.passive)
261+
.map((slice) => slice.content)
262+
.join("")
263+
: segment.content;
233264
}
234265
}
235266
return text;

‎packages/core/src/generated-xmd.ts‎

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ import { GeneratedDataExpressions, validateDataExpression } from "./generated-ex
140140
import { capturedBinding } from "./invocation-rules.ts";
141141
import { renderSegments } from "./render.ts";
142142
import { scanSegments } from "./scanner.ts";
143+
import { readsBinding } from "./generated-interpolation.ts";
143144
import { SourceOwner, sourceOwnerDescription } from "./source-owner.ts";
144145
import { RESERVED_STRUCTURAL } from "./structural.ts";
145146
// The ordinary source rules, read rather than reimplemented: a construct means
@@ -1350,25 +1351,6 @@ function admitted(entries: readonly Entry[]): Map<string, Entry[]> {
13501351
return table;
13511352
}
13521353

1353-
/**
1354-
* Whether this text would read anything.
1355-
*
1356-
* The two interpolation passes a text segment goes through are definitive
1357-
* on what a reference is, so this asks them rather than guessing: `\{` is
1358-
* protected exactly as expansion protects it, and what remains is matched by
1359-
* the same shapes `interpolate()` and `interpolateEvalBindings()` consume.
1360-
* Braces that neither pass would read — prose, a JSON sample, a CSS rule — are
1361-
* left alone.
1362-
*/
1363-
const ESCAPED_BRACE_PLACEHOLDER = "\uE000";
1364-
const FRONTMATTER_REFERENCE = /\{(meta|props)\.[^}]+\}/;
1365-
const BINDING_REFERENCE = /\{[a-zA-Z_$][a-zA-Z0-9_$]*(?:\.[a-zA-Z_$][a-zA-Z0-9_$]*)*\}/;
1366-
1367-
function reads(content: string): boolean {
1368-
const protectedEscapes = content.replaceAll("\\{", ESCAPED_BRACE_PLACEHOLDER);
1369-
return FRONTMATTER_REFERENCE.test(protectedEscapes) || BINDING_REFERENCE.test(protectedEscapes);
1370-
}
1371-
13721354
/** Two lists of the same strings, in the same order. */
13731355
function sameStrings(one: readonly string[], other: readonly string[]): boolean {
13741356
return one.length === other.length && one.every((value, index) => value === other[index]);
@@ -1671,7 +1653,7 @@ function* walk(
16711653
// own `<Let>` capture — and with it the one way a response has of
16721654
// writing an entry draft, whose source is entry text that legitimately
16731655
// carries braces of its own.
1674-
if (lexical.language !== "root" && reads(segment.content)) {
1656+
if (lexical.language !== "root" && readsBinding(segment)) {
16751657
throw new Refusal("interpolation");
16761658
}
16771659
break;

0 commit comments

Comments
 (0)