Skip to content

Commit bbfef6b

Browse files
Brooooooklynclaude
andauthored
fix(md): preserve fence line-highlight ranges from markdown-it-attrs (#228)
## Problem Code-fence **line highlighting** via the Shiki `{1,3-5}` meta syntax silently does nothing in `@void/md`: ````md ```ts {2} const a = 1 const b = 2 ``` ```` No line gets the `highlighted` class. ## Root cause `createCompiler` registers `transformerMetaHighlight()` and passes `meta: { __raw: token.info }` to Shiki — correct. But `markdown.use(attrs)` (`markdown-it-attrs`) treats the `{…}` after the fence language as an attribute block and **strips it from `token.info` at parse time**. By render time `token.info` is just `"ts"`, so `transformerMetaHighlight` finds no range and no-ops. ``` MarkdownExit → token.info = "ts {2}" markdown-it-attrs → token.info = "ts" ← {2} eaten here codeToHtml(meta:{__raw:"ts"}) → no .highlighted ``` ## Fix Snapshot the original fence info onto `token.meta.rawInfo` with a core rule anchored `after('block')` (fence tokens exist) and before `curly_attributes` (which runs after `inline`), then feed `rawInfo` to Shiki via `meta.__raw`. `markdown-it-attrs` still applies to non-fence content unchanged. ## Tests Added `compile.test.ts` cases: - single `{N}`, range `{N-M}`, combined `{a,c-d}` - a comment-less language (`text {1}`) — works, unlike comment-notation highlighting - no curly meta → stays unhighlighted - `{.class}` on prose → markdown-it-attrs still applies > Note: verified locally by running `compile()` directly against the source (all cases pass). I could not run `vp test`/`vp fmt`/`tsgo` in my checkout because `vite-plus`/`tsgo` weren't installed there — CI should exercise the full harness. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Targeted markdown pipeline fix with unit tests; no auth, data, or API surface changes. > > **Overview** > Restores **Shiki fence line highlighting** (`{2}`, `{2-3}`, `{1,3-4}`, etc.) in `@void/md` by keeping the full fence info string before `markdown-it-attrs` strips the `{…}` portion from `token.info`. > > A new core rule **`stash_fence_info`** runs after `block` and copies each fence’s original `token.info` to **`token.meta.rawInfo`**. The custom fence renderer passes that value to Shiki as **`meta.__raw`** instead of the stripped `token.info`, so **`transformerMetaHighlight`** receives the line ranges again. > > **Tests** in `compile.test.ts` assert highlighted line indices for single lines, ranges, combined specs, and `text` fences; they also cover fences without curly meta and confirm `{.class}` on prose still works via `markdown-it-attrs`. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit cad773f. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 047f48a commit bbfef6b

2 files changed

Lines changed: 62 additions & 2 deletions

File tree

‎packages/md/src/compile.ts‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
} from '@shikijs/transformers';
99
import matter from 'gray-matter';
1010
import MarkdownExit from 'markdown-exit';
11-
import type { Token } from 'markdown-exit';
11+
import type { RuleCore, Token } from 'markdown-exit';
1212
import anchor from 'markdown-it-anchor';
1313
import attrs from 'markdown-it-attrs';
1414
import { full as emoji } from 'markdown-it-emoji';
@@ -82,12 +82,20 @@ async function createCompiler(options?: CompileOptions): Promise<MarkdownExit> {
8282
lang = 'text';
8383
}
8484
}
85+
// `markdown-it-attrs` strips the `{1,3-5}` line-highlight ranges out of
86+
// `token.info` at parse time (it treats `{…}` as an attribute block), so by
87+
// render time only the language remains. The `stash_fence_info` core rule
88+
// preserves the original info string on `token.meta.rawInfo` before that
89+
// happens; fall back to `token.info` when it is absent (a fence with no
90+
// curly meta). Without this, `transformerMetaHighlight` never sees the
91+
// ranges and line highlighting silently no-ops.
92+
const rawInfo = (token.meta as { rawInfo?: string } | null)?.rawInfo ?? token.info;
8593
return highlighter.codeToHtml(code, {
8694
lang,
8795
themes,
8896
defaultColor: 'light',
8997
transformers: shikiTransformers,
90-
meta: { __raw: token.info },
98+
meta: { __raw: rawInfo },
9199
});
92100
};
93101

@@ -109,6 +117,20 @@ async function createCompiler(options?: CompileOptions): Promise<MarkdownExit> {
109117
// GitHub alerts: > [!NOTE], > [!TIP], etc.
110118
markdown.use(gitHubAlertsPlugin);
111119

120+
// Preserve the raw fence info string (e.g. `ts {1,3-5}`) on the token before
121+
// `markdown-it-attrs` consumes the `{…}` as an attribute block. Anchored
122+
// after `block` so fence tokens exist, and before `curly_attributes` (which
123+
// runs after `inline`), so the fence renderer can still feed the ranges to
124+
// Shiki's `transformerMetaHighlight` via `meta.__raw`.
125+
const stashFenceInfo: RuleCore = (state) => {
126+
for (const token of state.tokens) {
127+
if (token.type === 'fence') {
128+
token.meta = { ...token.meta, rawInfo: token.info };
129+
}
130+
}
131+
};
132+
markdown.core.ruler.after('block', 'stash_fence_info', stashFenceInfo);
133+
112134
// Attribute syntax: {.class #id}
113135
markdown.use(attrs);
114136

‎packages/md/test/unit/compile.test.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,44 @@ describe('compile', () => {
3838
expect(result.html).toContain('<span');
3939
});
4040

41+
// The 0-based indices of code lines that carry the shiki `highlighted` class.
42+
// (Shiki emits one `<span class="line">` per line plus a trailing empty line,
43+
// so we compare highlighted positions rather than a fixed line count.)
44+
const highlightedLineIndices = (html: string): ReadonlyArray<number> =>
45+
[...html.matchAll(/<span class="(line[^"]*)"/g)]
46+
.map((match, index) => (match[1].includes('highlighted') ? index : -1))
47+
.filter((index) => index >= 0);
48+
49+
it('highlights a single {N} fence line (markdown-it-attrs must not eat it)', async () => {
50+
const result = await compile('```ts {2}\nconst a = 1;\nconst b = 2;\nconst c = 3;\n```');
51+
expect(highlightedLineIndices(result.html)).toEqual([1]);
52+
});
53+
54+
it('highlights a {N-M} line range and a combined {a,c-d} set', async () => {
55+
const range = await compile('```ts {2-3}\nlet a = 1;\nlet b = 2;\nlet c = 3;\nlet d = 4;\n```');
56+
expect(highlightedLineIndices(range.html)).toEqual([1, 2]);
57+
58+
const combined = await compile(
59+
'```ts {1,3-4}\nlet a = 1;\nlet b = 2;\nlet c = 3;\nlet d = 4;\n```',
60+
);
61+
expect(highlightedLineIndices(combined.html)).toEqual([0, 2, 3]);
62+
});
63+
64+
it('highlights {N} even for languages without comment syntax (e.g. text)', async () => {
65+
const result = await compile('```text {1}\nalpha\nbeta\n```');
66+
expect(highlightedLineIndices(result.html)).toEqual([0]);
67+
});
68+
69+
it('leaves fences without curly meta unhighlighted', async () => {
70+
const result = await compile('```ts\nconst a = 1;\nconst b = 2;\n```');
71+
expect(result.html).not.toContain('highlighted');
72+
});
73+
74+
it('still applies markdown-it-attrs to non-fence content', async () => {
75+
const result = await compile('Hello {.greeting}\n');
76+
expect(result.html).toContain('greeting');
77+
});
78+
4179
it('converts emoji shortcodes', async () => {
4280
const result = await compile('Hello :tada:');
4381
expect(result.html).toContain('\u{1F389}'); // 🎉

0 commit comments

Comments
 (0)