Skip to content

Commit 9c8dee2

Browse files
authored
fix "capsule leak" when no empty line between text and code block (#780)
* fix code block capsule leak by inspecting str tokens * add capsule-leak snapshot test
1 parent c2f847e commit 9c8dee2

File tree

6 files changed

+72
-26
lines changed

6 files changed

+72
-26
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
The lack of newline between this text and the code block previously caused a capsule leak. See https://github.com/quarto-dev/quarto/pull/780
2+
```{{python}}
3+
1+2
4+
```
5+
lets also snapshot what happens to this text after the code block
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
The lack of newline between this text and the code block previously caused a capsule leak. See https://github.com/quarto-dev/quarto/pull/780
2+
3+
```{{python}}
4+
1+2
5+
```
6+
7+
lets also snapshot what happens to this text after the code block

apps/vscode/src/test/examples/roundtrip-changes.qmd

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,6 @@ hi
44
```
55
``````
66

7-
kBlockCapsuleSentinel uuid sentinel leak during SE→VE
8-
``````{{python}}
9-
```
10-
dog
11-
```
12-
``````
13-
147
`````
158
```{python}
169
a = 3

apps/vscode/src/test/quartoDoc.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,11 @@ suite("Quarto basics", function () {
5757

5858
assert.equal(after, await readOrCreateSnapshot("roundtripped-invalid.qmd", after));
5959
});
60+
test("Roundtripped capsule-leak.qmd matches snapshot", async function () {
61+
const { doc } = await openAndShowTextDocument("capsule-leak.qmd");
62+
63+
const { after } = await roundtrip(doc);
64+
65+
assert.equal(after, await readOrCreateSnapshot("roundtripped-capsule-leak.qmd", after));
66+
});
6067
});

packages/editor/src/api/pandoc_capsule.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,28 @@ export function blockCapsuleParagraphTokenHandler(type: string) {
259259
};
260260
}
261261

262+
export function blockCapsuleStrTokenHandler(type: string) {
263+
const tokenRegex = encodedBlockCapsuleRegex('^', '$');
264+
return (tok: PandocToken) => {
265+
if (tok.t === PandocTokenType.Str) {
266+
const text = tok.c as string;
267+
const match = text.match(tokenRegex);
268+
if (match) {
269+
const capsuleRecord = parsePandocBlockCapsule(match[0]);
270+
if (capsuleRecord.type === type) {
271+
return match[0];
272+
}
273+
}
274+
}
275+
return null;
276+
};
277+
}
278+
279+
export const blockCapsuleHandlerOr = (
280+
handler1: (tok: PandocToken) => string | null,
281+
handler2: (tok: PandocToken) => string | null
282+
) => (tok: PandocToken) => handler1(tok) ?? handler2(tok);
283+
262284
// create a regex that can be used to match a block capsule
263285
export function encodedBlockCapsuleRegex(prefix?: string, suffix?: string, flags?: string) {
264286
return new RegExp(

packages/editor/src/nodes/code_block.ts

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import { hasFencedCodeBlocks } from '../api/pandoc_format';
3535
import { precedingListItemInsertPos, precedingListItemInsert } from '../api/list';
3636
import { EditorOptions } from '../api/options';
3737
import { OmniInsertGroup } from '../api/omni_insert';
38-
import { blockCapsuleParagraphTokenHandler, blockCapsuleSourceWithoutPrefix, blockCapsuleTextHandler, encodedBlockCapsuleRegex, PandocBlockCapsule, PandocBlockCapsuleFilter } from '../api/pandoc_capsule';
38+
import { blockCapsuleHandlerOr, blockCapsuleParagraphTokenHandler, blockCapsuleSourceWithoutPrefix, blockCapsuleStrTokenHandler, blockCapsuleTextHandler, encodedBlockCapsuleRegex, PandocBlockCapsule, PandocBlockCapsuleFilter } from '../api/pandoc_capsule';
3939

4040
const kNoAttributesSentinel = 'CEF7FA46';
4141

@@ -70,12 +70,12 @@ const extension = (context: ExtensionContext): Extension => {
7070
const fontClass = 'pm-fixedwidth-font';
7171
const attrs = hasAttr
7272
? pandocAttrToDomAttr({
73-
...node.attrs,
74-
classes: [...node.attrs.classes, fontClass],
75-
})
73+
...node.attrs,
74+
classes: [...node.attrs.classes, fontClass],
75+
})
7676
: {
77-
class: fontClass,
78-
};
77+
class: fontClass,
78+
};
7979
return ['pre', attrs, ['code', 0]];
8080
},
8181
},
@@ -114,19 +114,19 @@ const extension = (context: ExtensionContext): Extension => {
114114
}
115115
}
116116
}
117-
117+
118118
output.writeToken(PandocTokenType.CodeBlock, () => {
119119
if (hasAttr) {
120120
const id = pandocExtensions.fenced_code_attributes ? node.attrs.id : '';
121121
const keyvalue = pandocExtensions.fenced_code_attributes ? node.attrs.keyvalue : [];
122-
122+
123123
// if there are no attributes this will end up outputting a code block
124124
// without the fence markers (rather indenting the code block 4 spaces).
125125
// we don't want this so we add a sentinel class to the attributes to
126126
// force the fence markers (which we then cleanup below in the postprocessor)
127127
const classes = [...node.attrs.classes];
128128
if (!pandocAttrAvailable(node.attrs) && pandocExtensions.backtick_code_blocks) {
129-
classes.push(kNoAttributesSentinel)
129+
classes.push(kNoAttributesSentinel);
130130
}
131131

132132
output.writeAttr(id, classes, keyvalue);
@@ -138,11 +138,11 @@ const extension = (context: ExtensionContext): Extension => {
138138
},
139139
blockCapsuleFilter: escapedRmdChunkBlockCapsuleFilter(),
140140
markdownPostProcessor: (markdown: string) => {
141-
// cleanup the sentinel classes we may have added above
141+
// cleanup the sentinel classes we may have added above
142142
if (pandocExtensions.backtick_code_blocks) {
143143
markdown = markdown.replace(
144-
new RegExp("``` " + kNoAttributesSentinel, 'g'),
145-
"``` " + " ".repeat(kNoAttributesSentinel.length)
144+
new RegExp("``` " + kNoAttributesSentinel, 'g'),
145+
"``` " + " ".repeat(kNoAttributesSentinel.length)
146146
);
147147
}
148148
return markdown;
@@ -301,9 +301,9 @@ function codeBlockAttrEdit(pandocExtensions: PandocExtensions, pandocCapabilitie
301301
tags.push(`#${node.attrs.id}`);
302302
}
303303
if (node.attrs.classes) {
304-
for (let i=1; i<node.attrs.classes.length; i++) {
304+
for (let i = 1; i < node.attrs.classes.length; i++) {
305305
tags.push(`.${node.attrs.classes[i]}`);
306-
}
306+
}
307307
if (node.attrs.classes.length > 0) {
308308
const lang = node.attrs.classes[0];
309309
if (pandocCapabilities.highlight_languages.includes(lang) || lang === 'tex') {
@@ -315,7 +315,7 @@ function codeBlockAttrEdit(pandocExtensions: PandocExtensions, pandocCapabilitie
315315
}
316316
if (node.attrs.keyvalue && node.attrs.keyvalue.length) {
317317
tags.push(`${node.attrs.keyvalue.map(
318-
(kv: [string,string]) => kv[0] + '="' + (kv[1] || '1') + '"').join(' ')}
318+
(kv: [string, string]) => kv[0] + '="' + (kv[1] || '1') + '"').join(' ')}
319319
`);
320320
}
321321
return tags;
@@ -364,9 +364,17 @@ export function escapedRmdChunkBlockCapsuleFilter(): PandocBlockCapsuleFilter {
364364
encodedBlockCapsuleRegex(undefined, undefined, 'gm'),
365365
),
366366

367-
// we are looking for a paragraph token consisting entirely of a block capsule of our type.
368-
// if find that then return the block capsule text
369-
handleToken: blockCapsuleParagraphTokenHandler(kEscapedRmdChunkBlockCapsuleType),
367+
// we are looking for a paragraph token consisting entirely of a block capsule of our type
368+
// OR a string token with a block capsule of our type. if find that then return the
369+
// block capsule text.
370+
// Historical note: we were previously only using the paragraph handler, but it did not work if the
371+
// code block did not have a blank line between it and the previous paragraph becuase
372+
// Pandoc would parse the block capsule into the end of the that paragraph.
373+
handleToken:
374+
blockCapsuleHandlerOr(
375+
blockCapsuleParagraphTokenHandler(kEscapedRmdChunkBlockCapsuleType),
376+
blockCapsuleStrTokenHandler(kEscapedRmdChunkBlockCapsuleType)
377+
),
370378

371379
// write the node
372380
writeNode: (schema: Schema, writer: ProsemirrorWriter, capsule: PandocBlockCapsule) => {
@@ -377,8 +385,12 @@ export function escapedRmdChunkBlockCapsuleFilter(): PandocBlockCapsuleFilter {
377385
const sourceLines = lines(source);
378386
sourceLines[0] = sourceLines[0].replace(/^(```+)\{(\{+[^}]+\}+)\}([ \t]*)$/, "$1$2$3");
379387

380-
// write the node
388+
const isWritingInsideParagraph = writer.isNodeOpen(schema.nodes.paragraph);
389+
// We can't write code blocks inside of paragraphs, so let's temporarily leave the paragraph
390+
// before reopening it after writing the code block
391+
if (isWritingInsideParagraph) writer.closeNode();
381392
writer.addNode(schema.nodes.code_block, {}, [schema.text(sourceLines.join("\n"))]);
393+
if (isWritingInsideParagraph) writer.openNode(schema.nodes.paragraph, {});
382394
},
383395
};
384396
}

0 commit comments

Comments
 (0)