Skip to content

fix: preserve leading content tabs in list fences - #4129

Open
jovial-liu wants to merge 3 commits into
markedjs:masterfrom
jovial-liu:fix-list-fence-leading-tabs
Open

jovial-liu wants to merge 3 commits into
markedjs:masterfrom
jovial-liu:fix-list-fence-leading-tabs

Conversation

@jovial-liu

Copy link
Copy Markdown

Marked version: master at 7e8754d60b3c37be30aa17106ada623d1f0cf9db

Markdown flavor: CommonMark / GitHub Flavored Markdown

Description

Addresses the leading content-tab case reported in #4050. Given "- ```\n \tfoo\n ```\n", the current list parser expands the content tab to two spaces before the fenced-code tokenizer receives it. This changes the rendered code and what a user copies from it. The fenced-code text should retain "\tfoo".

Keep the existing normalized list text and structural parsing, but track the spans created by original tabs. Restore a tab in built-in fenced-code text only when its complete expansion survives list and fence indentation removal. Partly consumed indentation tabs remain spaces. Nested lists carry the spans through their own indentation removal; recursive calls scope and restore the context, including same-source reentry and exceptions. Custom tokenizer output is preserved.

This does not attempt to resolve every tab-related parsing case in #4050. Existing tilde-fence indentation compensation, ordered-list interruption, and indented-code/blank-line edge cases are outside this change.

Validation

  • Full local npm test passes on Node 26.8.1: 1,911 spec executions and 253 unit tests, including builds, module/type checks and ESLint. Unmodified master passes 1,907 specs and 199 units.
  • Adds 54 unit cases: on unmodified master, 34 fail and 20 controls pass; all 54 pass with the patch. Covers ordered/nested/multiple fences, backticks/tilde, partly consumed tabs, task lists, CRLF, normalized raw text, custom tokenizers, reentry and exception reuse.
  • Adds two exact HTML fixture scenarios, contributing four spec executions. Both fail on unmodified master and match byte-for-byte with the patch.
  • A separate 31-case public API probe passes. A generated 1,172-observation comparison of code contents against commonmark 0.31.2 finds 972 newly matching observations, preserves all 136 previously matching observations, and retains 64 observations outside the addressed behavior. This is a code-content comparison, not a claim of full CommonMark conformance.

Performance cost

A local parse-only microbenchmark (one fixed input per workload, 15 warmups, 9 samples of 35 iterations, median time) measured tab-free lists at 0.316 → 0.327 ms (+3.3%, overlapping sample ranges), sparse-tab fences at 0.172 → 0.185 ms (+7.7%), dense-tab fences at 0.180 → 0.224 ms (+24.7%), and other tab-containing lists at 0.307 → 0.315 ms (+2.5%). These are small local workloads, not whole-library performance results; the dense-tab overhead is a tradeoff for preserving code content.

Implementation and validation used OpenAI Codex.

Contributor

  • Test(s) exist to ensure functionality and minimize regression.
  • no tests required for this PR.
  • If submitting new feature, it has been documented in the appropriate places. (Bug fix.)

Committer

  • CI is green (no forced merge required).
  • Squash and Merge PR following conventional commit guidelines.

Implementation and validation used OpenAI Codex.
@vercel

vercel Bot commented Oct 9, 2026

Copy link
Copy Markdown

@jovial-liu is attempting to deploy a commit to the MarkedJS Team on Vercel.

A member of the Team first needs to authorize it.

Comment thread src/Tokenizer.ts Outdated
if (cap) {
const raw = cap[0];
const text = indentCodeCompensation(raw, cap[3] || '', this.rules);
const sourceRanges = this.lexer?._getTabRanges?.(src);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We shouldn't be calling lexer functions from a tokenizer (other than inlineTokens and blockTokens) since an extension can supply the lexer in a provideLexer hook. If you need something from the lexer it should be state or passed in like maskedSrc in emStrong.

Comment thread src/Lexer.ts Outdated
blockTokens(src: string, tokens?: Token[], lastParagraphClipped?: boolean): Token[];
blockTokens(src: string, tokens?: TokensList, lastParagraphClipped?: boolean): TokensList;
blockTokens(src: string, tokens: Token[] = [], lastParagraphClipped = false) {
blockTokens(src: string, tokens?: Token[], lastParagraphClipped?: boolean, tabRanges?: readonly TabRange[]): Token[];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is a breaking change if it updates a public function signature

Comment thread src/Lexer.ts Outdated
private enteringTabScope = false;

/** Return a view, so later code blocks do not copy all remaining ranges. */
_getTabRanges(src: string): { ranges: readonly TabRange[], offset: number } | undefined {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Don't underscore prefix functions. If it is public just remove the underscore. If it is private prefix with #.

@vercel

vercel Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
marked-website Ready Ready Preview Oct 13, 2026 5:53pm UTC

Request Review

@jovial-liu

jovial-liu commented Oct 13, 2026 via email

Copy link
Copy Markdown
Author

This branch was successfully deployed

1 active deployment
Preview — 4da4fc80 Deployed Oct 13, 2026 by vercel[bot]
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.

2 participants