diff --git a/.changeset/rich-aliens-fold.md b/.changeset/rich-aliens-fold.md new file mode 100644 index 00000000..62c710a2 --- /dev/null +++ b/.changeset/rich-aliens-fold.md @@ -0,0 +1,5 @@ +--- +"@stackoverflow/stacks-editor": patch +--- + +fix parsing of multiple snippets diff --git a/plugins/official/stack-snippets/src/schema.ts b/plugins/official/stack-snippets/src/schema.ts index 1cae7ad7..eca7ec22 100644 --- a/plugins/official/stack-snippets/src/schema.ts +++ b/plugins/official/stack-snippets/src/schema.ts @@ -8,6 +8,7 @@ import { RawContext, validateMetaLines, validSnippetRegex, + MetaLine, } from "./common"; import { Node as ProseMirrorNode, NodeSpec } from "prosemirror-model"; @@ -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++) { @@ -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 } = { diff --git a/plugins/official/stack-snippets/test/markdownit-plugin.test.ts b/plugins/official/stack-snippets/test/markdownit-plugin.test.ts index 3ef541a1..13266764 100644 --- a/plugins/official/stack-snippets/test/markdownit-plugin.test.ts +++ b/plugins/official/stack-snippets/test/markdownit-plugin.test.ts @@ -3,6 +3,9 @@ import { stackSnippetPlugin } from "../src/schema"; import { invalidSnippetRenderCases, validSnippetRenderCases, + validBegin, + validJs, + validEnd, } from "./stack-snippet-helpers"; describe("stackSnippetPlugin (Markdown-it)", () => { @@ -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"); + }); });