Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions packages/md/src/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -82,12 +82,20 @@ async function createCompiler(options?: CompileOptions): Promise<MarkdownExit> {
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 },
});
};

Expand All @@ -109,6 +117,20 @@ async function createCompiler(options?: CompileOptions): Promise<MarkdownExit> {
// 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);

Expand Down
38 changes: 38 additions & 0 deletions packages/md/test/unit/compile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,44 @@ describe('compile', () => {
expect(result.html).toContain('<span');
});

// The 0-based indices of code lines that carry the shiki `highlighted` class.
// (Shiki emits one `<span class="line">` per line plus a trailing empty line,
// so we compare highlighted positions rather than a fixed line count.)
const highlightedLineIndices = (html: string): ReadonlyArray<number> =>
[...html.matchAll(/<span class="(line[^"]*)"/g)]
.map((match, index) => (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}'); // 🎉
Expand Down