Skip to content

fix(markdown): escape pipes in code spans inside table cells - #85

Open
codeAnqiang-ma wants to merge 2 commits into
firecrawl:mainfrom
codeAnqiang-ma:fix/code-span-table-pipe-escape
Open

fix(markdown): escape pipes in code spans inside table cells#85
codeAnqiang-ma wants to merge 2 commits into
firecrawl:mainfrom
codeAnqiang-ma:fix/code-span-table-pipe-escape

Conversation

@codeAnqiang-ma

@codeAnqiang-ma codeAnqiang-ma commented Aug 12, 2026

Copy link
Copy Markdown

Fixes #84.

What

| is escaped in table cells for plain text, emphasis, and URLs, but not for code spans, so a code span containing a pipe splits the GFM row and silently drops every column to its right.

push_code_span() took no InlineContext, so it could not know it was rendering inside a table cell. This gives it the context and escapes pipes there, matching what escape_text() already does at escape.rs:82 and what format_url() does under the comment // Raw pipes split GFM table cells. (escape.rs:151).

Per the GFM spec (§4.10 Tables, Example 200), escaping applies inside inline spans: | b | az |<td>b <code>|</code> az</td>.

Why this way

The escape is applied in push_code_span rather than at the call sites so both paths that reach it are covered by one change — inline code runs (inline.rs:208) and cell-level Block::CodeBlock (table.rs:169). Behaviour outside InlineContext::TableCell is untouched, so paragraph-level code spans are unchanged.

The escape happens before backtick_fence() so the fence is computed on the text as emitted, and \| adds no backticks that could affect fence width.

Diff

3 files, +34/−3 — 4 production lines and 2 regression tests. No reformatting, no unrelated changes.

Tests

Two regression tests named after the existing url_pipes_cannot_split_table_cells, placed next to it: code_span_pipes_cannot_split_table_cells (styled run) and code_block_pipes_cannot_split_table_cells (cell-level CodeBlock).

Both fail before the change and pass after:

$ cargo test --lib pipes_cannot_split      # before
test render::markdown::tests::url_pipes_cannot_split_table_cells ... ok
test render::markdown::tests::code_block_pipes_cannot_split_table_cells ... FAILED
test render::markdown::tests::code_span_pipes_cannot_split_table_cells ... FAILED

  left: "| Operator | Meaning |\n| --- | --- |\n| `a | b` | bitwise or |\n"
 right: "| Operator | Meaning |\n| --- | --- |\n| `a \| b` | bitwise or |\n"

test result: FAILED. 1 passed; 2 failed
$ cargo test --locked                      # after
running 210 tests
test result: ok. 210 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

     Running tests/robustness.rs
test result: ok. 1 passed; 0 failed

     Running tests/snapshots.rs
test result: ok. 8 passed; 0 failed; 1 ignored

   Doc-tests anydoc
test result: ok. 0 passed; 0 failed

No snapshot churn — git status is clean apart from the three files in this diff. The fixture corpus has no table cell containing code and a pipe, which is why this was never caught.

$ cargo fmt --all --check
(clean)

End-to-end on a minimal .epub with <code>a | b</code> and <pre>ls | wc -l</pre> in a two-column table:

 | Operator | Meaning |
 | --- | --- |
-| `a | b` | bitwise or |
-| `ls | wc -l` | shell pipeline |
+| `a \| b` | bitwise or |
+| `ls \| wc -l` | shell pipeline |
 | plain a \| b | escaped correctly |

Rendered through marked 18 with gfm: true, before → after:

<tr><td>`a</td><td>b`</td></tr>                      <!-- before: "bitwise or" dropped -->
<tr><td><code>a | b</code></td><td>bitwise or</td></tr>  <!-- after: both columns intact -->

Note on clippy

cargo clippy --workspace --all-targets --all-features -- -D warnings fails on my machine with 3 collapsible_if errors in src/formats/rtf/tables.rs:293,302,422. These are pre-existing and unrelated — I verified they reproduce identically on a clean checkout of 4e3089b with the stash popped, and that file is not in this diff. It looks like a lint that newer clippy (1.95.0) flags but the toolchain CI pinned at release time did not. I left it alone to keep this diff minimal; happy to send a separate PR if you want it cleaned up.

Not run locally: the node, wasm, and python binding jobs. This change is confined to the Rust Markdown renderer and does not touch any binding surface.


This change was made with AI assistance; I reproduced the bug, reviewed the patch, and ran every test above locally myself.


Summary by cubic

Escapes pipe characters in code spans inside GFM table cells so rows are not split. Before: a code span with | in a cell emitted a raw pipe and dropped columns to the right; now the pipe is emitted as | and the table structure is preserved. Existing backslashes before | are preserved by emitting \| so round-tripping remains correct.

  • push_code_span now takes an InlineContext and escapes | only when ctx == TableCell; other contexts are unchanged.
  • Centralizes the logic so both inline code runs and cell-level CodeBlock in table cells are handled.
  • Escaping occurs before backtick fence calculation to keep fence width correct.
  • Adds three regression tests, including a backslash-before-pipe round-trip case.

Written for commit d6fdf04. Summary will update on new commits.

Review in cubic

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 3 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/render/markdown/inline.rs">

<violation number="1" location="src/render/markdown/inline.rs:238">
P1: When code content already has an odd number of backslashes before `|`, this replacement makes the run even and the generated GFM row can still split at that pipe. Add a backslash only when the existing run is even, and cover code such as `a \| b` with a regression test.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

pub(crate) fn push_code_span(text: &str, ctx: InlineContext, out: &mut String) {
let text = text.replace('\n', " ");
// A raw pipe splits a GFM table cell even inside a code span.
let text = if ctx == InlineContext::TableCell { text.replace('|', "\\|") } else { text };

@cubic-dev-ai cubic-dev-ai Bot Aug 12, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When code content already has an odd number of backslashes before |, this replacement makes the run even and the generated GFM row can still split at that pipe. Add a backslash only when the existing run is even, and cover code such as a \| b with a regression test.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/render/markdown/inline.rs, line 238:

<comment>When code content already has an odd number of backslashes before `|`, this replacement makes the run even and the generated GFM row can still split at that pipe. Add a backslash only when the existing run is even, and cover code such as `a \| b` with a regression test.</comment>

<file context>
@@ -232,8 +232,10 @@ fn render_text_run(
+pub(crate) fn push_code_span(text: &str, ctx: InlineContext, out: &mut String) {
     let text = text.replace('\n', " ");
+    // A raw pipe splits a GFM table cell even inside a code span.
+    let text = if ctx == InlineContext::TableCell { text.replace('|', "\\|") } else { text };
     let fence = backtick_fence(&text, 1);
     let pad = if text.starts_with('`') || text.ends_with('`') { " " } else { "" };
</file context>
Suggested change
let text = if ctx == InlineContext::TableCell { text.replace('|', "\\|") } else { text };
let text = if ctx == InlineContext::TableCell {
let mut escaped = String::with_capacity(text.len());
let mut backslashes = 0;
for c in text.chars() {
if c == '|' && backslashes % 2 == 0 {
escaped.push('\\');
}
escaped.push(c);
backslashes = if c == '\\' { backslashes + 1 } else { 0 };
}
escaped
} else {
text
};
Fix with cubic

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the careful look — I checked this against the GFM reference implementation and against GitHub itself, and the row cannot split there: GFM's cell scanning is not parity-based. The cell scanner (ext_scanners.re L32–L35, table_cell = (escaped_char|[^|\r\n])+) matches greedily, so a pipe preceded by any backslash never ends a cell, and unescape_pipes() then strips exactly one backslash before each |. So for code text a \| b, the emitted `a \\| b` renders back as <code>a \| b</code> — an exact round-trip.

Verified via GitHub's own renderer (gh api /markdown):

| A | B |
| --- | --- |
| `one \| two` | X |
| `one \\| two` | Y |

Both rows keep two columns, and the cells come back as <code>one | two</code> and <code>one \| two</code>. pulldown-cmark matches GitHub exactly; comrak also keeps every row intact.

The parity variant would instead leave a \| b unescaped, and GFM unescapes \|| inside code spans too (spec §4.10, example 200), so the backslash would be silently dropped — on every renderer I tested (GitHub, comrak, pulldown-cmark, marked, micromark).

For completeness: marked and micromark do split on `one \\| two` — their cell splitters count backslash parity and deviate from cmark-gfm here. Under those parsers a backslash directly before a pipe inside a code span is unrepresentable either way (escaped, the row splits; unescaped, the backslash is lost), so I kept the encoding that GitHub and the reference implementation render exactly.

Added a regression test covering a \| b in d6fdf04.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

markdown: a pipe inside a code span in a table cell is not escaped, splitting the GFM row and dropping later columns

1 participant