Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/rich-aliens-fold.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@stackoverflow/stacks-editor": patch
---

fix parsing of multiple snippets
140 changes: 92 additions & 48 deletions plugins/official/stack-snippets/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
RawContext,
validateMetaLines,
validSnippetRegex,
MetaLine,
} from "./common";
import { Node as ProseMirrorNode, NodeSpec } from "prosemirror-model";

Expand Down Expand Up @@ -86,6 +87,9 @@ const parseSnippetBlockForMarkdownIt: MarkdownIt.ParserBlock.RuleBlock = (
}

let rawMetaLines: RawContext[] = [];
let inSnippet = false;
let snippetBegin: MetaLine | null = null;
let currentLangLines: RawContext[] = [];

//Next up, we want to find and test all the <!-- --> blocks we find.
for (let i = startLine; i < endLine; i++) {
Expand All @@ -97,59 +101,99 @@ const parseSnippetBlockForMarkdownIt: MarkdownIt.ParserBlock.RuleBlock = (
if (!validSnippetRegex.test(line)) {
continue;
}
rawMetaLines = [...rawMetaLines, { line, index: i }];
}

const metaLines = rawMetaLines.map(mapMetaLine).filter((m) => m != null);
const validationResult = validateMetaLines(metaLines);
const metaLine = mapMetaLine({ line, index: i });
if (!metaLine) {
continue;
}

if (metaLine.type === "begin") {
if (inSnippet) {
// Found a new begin while still in a snippet - invalid state
state.line = i + 1;
return false;
}
inSnippet = true;
snippetBegin = metaLine;
rawMetaLines = [{ line, index: i }];
currentLangLines = [];
} else if (metaLine.type === "lang") {
if (!inSnippet) {
state.line = i + 1;
return false;
}
currentLangLines.push({ line, index: i });
rawMetaLines.push({ line, index: i });
} else if (metaLine.type === "end" && inSnippet) {
rawMetaLines.push({ line, index: i });

const metaLines = rawMetaLines
.map(mapMetaLine)
.filter((m) => m != null);
const validationResult = validateMetaLines(metaLines);

//We now know this is a valid snippet. Last call before we start processing
if (silent || !validationResult.valid) {
state.line = i + 1;
return validationResult.valid;
}

// Create the snippet tokens
const openToken = state.push("stack_snippet_open", "code", 1);
// This value is not serialized, and so is different on every new session of Rich Text (i.e. every mode switch)
openToken.attrSet("id", Utils.generateRandomId());
if (!snippetBegin || snippetBegin.type !== "begin") {
state.line = i + 1;
return false;
}
openToken.attrSet("hide", snippetBegin.hide);
openToken.attrSet("console", snippetBegin.console);
openToken.attrSet("babel", snippetBegin.babel);
openToken.attrSet(
"babelPresetReact",
snippetBegin.babelPresetReact
);
openToken.attrSet("babelPresetTS", snippetBegin.babelPresetTS);

// Sort and process language blocks
const langSort = currentLangLines.sort((a, b) => a.index - b.index);

for (let j = 0; j < langSort.length; j++) {
const langMeta = mapMetaLine(langSort[j]);
if (!langMeta || langMeta.type !== "lang") continue;

//Use the beginning of the next block to establish the end of this one, or the end of the snippet
const langEnd =
j + 1 == langSort.length ? i : langSort[j + 1].index;
//Start after the header of the lang block (+1) and the following empty line (+1)
//End on the beginning of the next metaLine, less the preceding empty line (-1)
//All lang blocks are forcefully indented 4 spaces, so cleave those away.
const langBlock = state.getLines(
langSort[j].index + 2,
langEnd - 1,
4,
false
);
const langToken = state.push("stack_snippet_lang", "code", 1);
langToken.content = langBlock;
langToken.map = [langSort[j].index, langEnd];
langToken.attrSet("language", langMeta.language);
}

//We now know this is a valid snippet. Last call before we start processing
if (silent || !validationResult.valid) {
return validationResult.valid;
state.push("stack_snippet_close", "code", -1);
state.line = i + 1;

return true;
}
}

//A valid block must start with a begin and end, so cleave the opening and closing from the lines
const begin = metaLines.shift();
if (begin.type !== "begin") return false;
const end = metaLines.pop();
if (end.type !== "end") return false;

//The rest must be langs, sort them by index
const langSort = metaLines
.filter((m) => m.type == "lang") //Not strictly necessary, but useful for typing
.sort((a, b) => a.index - b.index);
if (!langSort.every((l) => l.type === "lang")) return false;

const openToken = state.push("stack_snippet_open", "code", 1);
// This value is not serialized, and so is different on every new session of Rich Text (i.e. every mode switch)
openToken.attrSet("id", Utils.generateRandomId());
openToken.attrSet("hide", begin.hide);
openToken.attrSet("console", begin.console);
openToken.attrSet("babel", begin.babel);
openToken.attrSet("babelPresetReact", begin.babelPresetReact);
openToken.attrSet("babelPresetTS", begin.babelPresetTS);

for (let i = 0; i < langSort.length; i++) {
//Use the beginning of the next block to establish the end of this one, or the end of the snippet
const langEnd =
i + 1 == langSort.length ? end.index : langSort[i + 1].index;
//Start after the header of the lang block (+1) and the following empty line (+1)
//End on the beginning of the next metaLine, less the preceding empty line (-1)
//All lang blocks are forcefully indented 4 spaces, so cleave those away.
const langBlock = state.getLines(
langSort[i].index + 2,
langEnd - 1,
4,
false
);
const langToken = state.push("stack_snippet_lang", "code", 1);
langToken.content = langBlock;
langToken.map = [langSort[i].index, langEnd];
langToken.attrSet("language", langSort[i].language);
// If we're still in a snippet at the end, it means we never found an end marker
if (inSnippet) {
state.line = endLine;
return false;
}
state.push("stack_snippet_close", "code", -1);
state.line = end.index + 1;
return true;

return false;
};

export const stackSnippetRichTextNodeSpec: { [name: string]: NodeSpec } = {
Expand Down
34 changes: 34 additions & 0 deletions plugins/official/stack-snippets/test/markdownit-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import { stackSnippetPlugin } from "../src/schema";
import {
invalidSnippetRenderCases,
validSnippetRenderCases,
validBegin,
validJs,
validEnd,
} from "./stack-snippet-helpers";

describe("stackSnippetPlugin (Markdown-it)", () => {
Expand Down Expand Up @@ -46,4 +49,35 @@ describe("stackSnippetPlugin (Markdown-it)", () => {
}
}
);

it("should correctly parse multiple consecutive snippets", () => {
const multipleSnippets = `${validBegin}${validJs}${validEnd}

Some text in between snippets.

${validBegin}${validJs}${validEnd}`;

const tokens = mdit.parse(multipleSnippets, {});

// We expect:
// - First snippet: open + lang + close (3 tokens)
// - Paragraph with text (3 tokens: paragraph_open, inline, paragraph_close)
// - Second snippet: open + lang + close (3 tokens)
expect(tokens).toHaveLength(9);

// First snippet
expect(tokens[0].type).toBe("stack_snippet_open");
expect(tokens[1].type).toBe("stack_snippet_lang");
expect(tokens[2].type).toBe("stack_snippet_close");

// Text in between
expect(tokens[3].type).toBe("paragraph_open");
expect(tokens[4].type).toBe("inline");
expect(tokens[5].type).toBe("paragraph_close");

// Second snippet
expect(tokens[6].type).toBe("stack_snippet_open");
expect(tokens[7].type).toBe("stack_snippet_lang");
expect(tokens[8].type).toBe("stack_snippet_close");
});
});
Loading