From cad773f16de4700dfd80d96b25163eba8ba17da5 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 19 Jun 2026 17:41:19 +0800 Subject: [PATCH] fix(md): preserve fence line-highlight ranges from markdown-it-attrs `markdown-it-attrs` treats the `{1,3-5}` after a fence language as an attribute block and strips it from `token.info` at parse time. By the time the fence renderer runs only the language remains, so Shiki's `transformerMetaHighlight` never sees the ranges and code-fence line highlighting silently no-ops. Snapshot the original fence info onto `token.meta.rawInfo` via a core rule anchored after `block` (so fence tokens exist) and before `curly_attributes` (which runs after `inline`), then feed that raw info to Shiki via `meta.__raw`. markdown-it-attrs still applies to non-fence content unchanged. Co-Authored-By: Claude Opus 4.8 --- packages/md/src/compile.ts | 26 ++++++++++++++++-- packages/md/test/unit/compile.test.ts | 38 +++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/packages/md/src/compile.ts b/packages/md/src/compile.ts index 773e14be3..70f05dd00 100644 --- a/packages/md/src/compile.ts +++ b/packages/md/src/compile.ts @@ -8,7 +8,7 @@ import { } from '@shikijs/transformers'; import matter from 'gray-matter'; import MarkdownExit from 'markdown-exit'; -import type { Token } from 'markdown-exit'; +import type { RuleCore, Token } from 'markdown-exit'; import anchor from 'markdown-it-anchor'; import attrs from 'markdown-it-attrs'; import { full as emoji } from 'markdown-it-emoji'; @@ -82,12 +82,20 @@ async function createCompiler(options?: CompileOptions): Promise { lang = 'text'; } } + // `markdown-it-attrs` strips the `{1,3-5}` line-highlight ranges out of + // `token.info` at parse time (it treats `{…}` as an attribute block), so by + // render time only the language remains. The `stash_fence_info` core rule + // preserves the original info string on `token.meta.rawInfo` before that + // happens; fall back to `token.info` when it is absent (a fence with no + // curly meta). Without this, `transformerMetaHighlight` never sees the + // ranges and line highlighting silently no-ops. + const rawInfo = (token.meta as { rawInfo?: string } | null)?.rawInfo ?? token.info; return highlighter.codeToHtml(code, { lang, themes, defaultColor: 'light', transformers: shikiTransformers, - meta: { __raw: token.info }, + meta: { __raw: rawInfo }, }); }; @@ -109,6 +117,20 @@ async function createCompiler(options?: CompileOptions): Promise { // GitHub alerts: > [!NOTE], > [!TIP], etc. markdown.use(gitHubAlertsPlugin); + // Preserve the raw fence info string (e.g. `ts {1,3-5}`) on the token before + // `markdown-it-attrs` consumes the `{…}` as an attribute block. Anchored + // after `block` so fence tokens exist, and before `curly_attributes` (which + // runs after `inline`), so the fence renderer can still feed the ranges to + // Shiki's `transformerMetaHighlight` via `meta.__raw`. + const stashFenceInfo: RuleCore = (state) => { + for (const token of state.tokens) { + if (token.type === 'fence') { + token.meta = { ...token.meta, rawInfo: token.info }; + } + } + }; + markdown.core.ruler.after('block', 'stash_fence_info', stashFenceInfo); + // Attribute syntax: {.class #id} markdown.use(attrs); diff --git a/packages/md/test/unit/compile.test.ts b/packages/md/test/unit/compile.test.ts index 43debbc86..9cabbc33a 100644 --- a/packages/md/test/unit/compile.test.ts +++ b/packages/md/test/unit/compile.test.ts @@ -38,6 +38,44 @@ describe('compile', () => { expect(result.html).toContain('` per line plus a trailing empty line, + // so we compare highlighted positions rather than a fixed line count.) + const highlightedLineIndices = (html: string): ReadonlyArray => + [...html.matchAll(/ (match[1].includes('highlighted') ? index : -1)) + .filter((index) => index >= 0); + + it('highlights a single {N} fence line (markdown-it-attrs must not eat it)', async () => { + const result = await compile('```ts {2}\nconst a = 1;\nconst b = 2;\nconst c = 3;\n```'); + expect(highlightedLineIndices(result.html)).toEqual([1]); + }); + + it('highlights a {N-M} line range and a combined {a,c-d} set', async () => { + const range = await compile('```ts {2-3}\nlet a = 1;\nlet b = 2;\nlet c = 3;\nlet d = 4;\n```'); + expect(highlightedLineIndices(range.html)).toEqual([1, 2]); + + const combined = await compile( + '```ts {1,3-4}\nlet a = 1;\nlet b = 2;\nlet c = 3;\nlet d = 4;\n```', + ); + expect(highlightedLineIndices(combined.html)).toEqual([0, 2, 3]); + }); + + it('highlights {N} even for languages without comment syntax (e.g. text)', async () => { + const result = await compile('```text {1}\nalpha\nbeta\n```'); + expect(highlightedLineIndices(result.html)).toEqual([0]); + }); + + it('leaves fences without curly meta unhighlighted', async () => { + const result = await compile('```ts\nconst a = 1;\nconst b = 2;\n```'); + expect(result.html).not.toContain('highlighted'); + }); + + it('still applies markdown-it-attrs to non-fence content', async () => { + const result = await compile('Hello {.greeting}\n'); + expect(result.html).toContain('greeting'); + }); + it('converts emoji shortcodes', async () => { const result = await compile('Hello :tada:'); expect(result.html).toContain('\u{1F389}'); // 🎉