Skip to content

Bug: A line starting with three backticks and with more backticks later is imported as a code block, including Lexical's own export of inline code #9404

Open

Description

@kyoshino

Lexical version: 0.52.0 (main at 98e936d gives the same results)

Steps to reproduce

Make a paragraph whose first child is inline code containing two or more backticks in a row, optionally followed by text. Export it with $convertToMarkdownString(TRANSFORMERS), then import the result with $convertFromMarkdownString(markdown, TRANSFORMERS):

inline code  a``b   + text " x"  → exported  ``` a``b ``` x      → imported as code block  a``b ``` x
inline code  ```    + text " x"  → exported  ```` ``` ```` x     → imported as code block  ``` ```` x   (and every line after it)
inline code  a``b                → exported  ``` a``b ```         → imported as code block  " a``b "
inline code  `a`    + text " x"  → exported  `` `a` `` x          → imported as a paragraph ✅

Importing Markdown written elsewhere behaves the same:

```` ``` ```` x

next

is imported as one code block holding ``` ```` x, a blank line and next. CommonMark reads it as a paragraph with the code span ``` and the text x, followed by a paragraph next.

Current behaviour

A line that starts with three or more backticks is taken as an opening fence, even when backticks follow later on the line. CommonMark says the info string of a backtick fence may not contain backticks, so such a line isn't a fence. It's a paragraph, usually starting with a code span. When no closing fence follows, the code block runs to the end of the document.

exportTextFormat() writes inline code with a fence longer than any backtick run inside it, so inline code that holds `` at the start of a paragraph always produces such a line. The editor's own output therefore turns into a code block on the next load.

Cause

CODE_START_REGEX in packages/lexical-markdown/src/MarkdownTransformers.ts is /^([ \t]*{3,})([\w-]+)?[ \t]?/, which accepts anything after the fence. CODE.handleImportAfterStartMatch()then either takes the line as the single-line form (closing backticks at the end of the line) or opens a multi-line block.normalizeMarkdown()` uses the same expression, so it also stops merging the lines that follow.

The single-line form, ```Single line Code``` as a code block, is covered by a test ('```Single line Code```' in LexicalMarkdown.test.ts), so the fix below keeps it when its code has no backticks.

Suggested fix

--- a/packages/lexical-markdown/src/MarkdownTransformers.ts
+++ b/packages/lexical-markdown/src/MarkdownTransformers.ts
@@ -222,7 +222,11 @@ const UNORDERED_LIST_REGEX = /^(\s*)[-*+]\s/;
 const CHECK_LIST_REGEX = /^(\s*)(?:[-*+]\s)?\s?(\[(\s|x)?\])\s/i;
 const HEADING_REGEX = /^(#{1,6})\s/;
 const QUOTE_REGEX = /^>\s/;
-const CODE_START_REGEX = /^([ \t]*`{3,})([\w-]+)?[ \t]?/;
+// The info string of a backtick fence cannot contain a backtick, so a line
+// with one after the fence is not a fence, except the single-line form
+// ```code``` whose code has none.
+// https://spec.commonmark.org/0.31.2/#fenced-code-blocks
+const CODE_START_REGEX = /^([ \t]*`{3,})(?=[^`]*$|[^`]+`{3,}$)([\w-]+)?[ \t]?/;
 const CODE_END_REGEX = /^[ \t]*`{3,}$/;
 const CODE_SINGLE_LINE_REGEX =
   /^[ \t]*```[^`]+(?:(?:`{1,2}|`{4,})[^`]+)*```(?:[^`]|$)/;
@@ -912,6 +916,12 @@ export const CODE: MultilineElementTransformer = {
       return [true, startLineIndex];
     }
 
+    // A closing run shorter than the opening one, as in ````code```, leaves a
+    // backtick in the info string, so the line is not a fence.
+    if (afterFence.includes('`')) {
+      return null;
+    }
+
     const multilineEndRegex = new RegExp(`^[ \\t]*\`{${fenceLength},}$`);

With this change, all the rows above round-trip unchanged as paragraphs with inline code, and next stays its own paragraph. These still import as before: ```Single line Code``` and ```a``` (single-line code blocks), ```js with code and a closing fence, a fence with title="x" metadata, nested / ```` fences, and the unclosed ````javascript Incomplete tag . `` a``` `` is now a paragraph instead of a code block with the language a.

Tested with headless Lexical against main at 98e936d. The unit tests for lexical-markdown, lexical-mdast and lexical-playground/__tests__/unit pass (1175), along with tsc, ESLint and Prettier. No new unit tests are added, and the typing shortcut wasn't tested in a browser. It uses the same expression, and a fence typed as ``` plus a space still matches.

Notes:

  • The single-line form isn't CommonMark: ```a``` is a paragraph with the code span a there. Dropping the form would make Lexical's import fully match CommonMark here, but it would break the existing test, so the diff keeps it.
  • @lexical/mdast reads all these lines as CommonMark does.

Related: #8109 and #8116 (nested fences), #8734 (fence-like lines inside a code block), #9055 (info string metadata).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions