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
11 changes: 11 additions & 0 deletions docs/guides/annotations.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,17 @@ The Markdown helpers recognize common metadata:

`title`, `filename`, `file`, and `name` are accepted title keys. `lineNumbers` and `showLineNumbers` enable numbers.

## Inline diff notation

Markdown adapters also recognize the diff notation used by Shiki:

```ts
- const oldValue = true // [!code --]
+ const newValue = true // [!code ++]
```

The directives are removed before tokenization and copying. Their lines receive `th-line--deleted` and `th-line--inserted`, respectively. JavaScript-style line and block comments, `#` line comments, and HTML comments are supported.

## Styling

Annotation classes are intentionally unstyled:
Expand Down
15 changes: 13 additions & 2 deletions docs/reference/markdown.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,17 @@ Returns escaped inner token markup for a renderer-owned `<code>` element. Highli

## Metadata

### `parseCodeDiffNotation`

```ts
function parseCodeDiffNotation(code: string): {
code: string
decorations: Array<HighlightDecoration>
}
```

Removes trailing `[!code ++]` and `[!code --]` directives and returns `th-line--inserted` and `th-line--deleted` decorations for their one-based line numbers. JavaScript-style line and block comments, `#` line comments, and HTML comments are supported.

### `CodeFenceMeta`

```ts
Expand Down Expand Up @@ -74,7 +85,7 @@ type CodeFenceInput = {
}
```

Explicit `decorations` are appended after metadata decorations. Explicit `lineNumbers` takes precedence over metadata. Explicit `title` takes precedence when it is non-empty.
Inline diff notation is converted first, followed by metadata and explicit `decorations`. Explicit `lineNumbers` takes precedence over metadata. Explicit `title` takes precedence when it is non-empty.

### `HighlightedCodeFence`

Expand All @@ -89,7 +100,7 @@ function renderCodeFence(
): HighlightedCodeFence
```

Parses metadata and delegates to the supplied highlighter.
Parses inline diff notation and metadata, then delegates to the supplied highlighter.

## HAST

Expand Down
54 changes: 45 additions & 9 deletions src/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,33 @@ export type TanStackMarkdownHighlighter = (
options?: TanStackMarkdownHighlighterOptions,
) => string

const codeDiffNotation =
/[ \t]*(?:(?:\/\/|#)[ \t]*\[!code[ \t]+(\+\+|--)\]|\/\*[ \t]*\[!code[ \t]+(\+\+|--)\][ \t]*\*\/|<!--[ \t]*\[!code[ \t]+(\+\+|--)\][ \t]*-->)[ \t]*$/

export function parseCodeDiffNotation(code: string) {
const decorations: Array<HighlightDecoration> = []
const lines = code.split('\n')

const cleanLines = lines.map((line, index) => {
const match = codeDiffNotation.exec(line)
if (!match) return line

const notation = match[1] || match[2] || match[3]
decorations.push({
className:
notation === '++' ? 'th-line--inserted' : 'th-line--deleted',
lines: index + 1,
})

return line.slice(0, match.index)
})

return {
code: cleanLines.join('\n'),
decorations,
}
}

export function parseCodeFenceMeta(meta?: string | null): CodeFenceMeta {
if (!meta) return { decorations: [], lineNumbers: false }

Expand Down Expand Up @@ -116,14 +143,19 @@ export function renderCodeFence(
lineNumbers,
meta,
title,
}: CodeFenceInput,
}: CodeFenceInput,
highlighter: Highlighter,
): HighlightedCodeFence {
const annotated = parseCodeDiffNotation(code)
const parsed = parseCodeFenceMeta(meta)
const resolvedDecorations = [...parsed.decorations, ...(decorations || [])]
const resolvedDecorations = [
...annotated.decorations,
...parsed.decorations,
...(decorations || []),
]
Comment on lines +149 to +155

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Rebase range decorations after stripping directives.

decorations can contain character-range offsets for the original code. Removing a directive shifts all later offsets, so caller-provided range decorations render on the wrong text. Preserve removed-span mappings and rebase range decorations before rendering; add coverage for a range on a line after a directive.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/markdown.ts` around lines 149 - 155, Update the decoration assembly in
the markdown rendering flow around parseCodeDiffNotation and parseCodeFenceMeta
to preserve mappings for stripped directive spans and rebase caller-provided
range decorations from the original code onto the cleaned text before combining
them. Keep parsed and annotated decorations intact, and add coverage for a range
on a line following a removed directive to verify it renders at the corrected
offset.

const resolvedLineNumbers = lineNumbers ?? parsed.lineNumbers
const rendered = highlighter.renderCodeBlockData({
code,
code: annotated.code,
decorations: resolvedDecorations,
lang: lang || undefined,
lineNumbers: resolvedLineNumbers,
Expand Down Expand Up @@ -153,19 +185,23 @@ export function createTanStackMarkdownHighlighter(
highlighter: Highlighter,
): TanStackMarkdownHighlighter {
return (code, lang = 'plaintext', options = {}) => {
const result = highlighter.tokenize(code, { lang })
const annotated = parseCodeDiffNotation(code)
const result = highlighter.tokenize(annotated.code, { lang })

return renderNodesToHtml(
renderTokens(result.tokens, {
...(options.lineNumbers !== undefined
? { lineNumbers: options.lineNumbers }
: {}),
...(options.highlightLines?.length
...(annotated.decorations.length || options.highlightLines?.length
? {
decorations: options.highlightLines.map((lines) => ({
className: 'th-line--highlighted',
lines,
})),
decorations: [
...annotated.decorations,
...(options.highlightLines || []).map((lines) => ({
className: 'th-line--highlighted',
lines,
})),
],
}
: {}),
}),
Expand Down
45 changes: 44 additions & 1 deletion test/adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
codeFenceToHast,
createTanStackMarkdownHighlighter,
getCodeFenceTitle,
parseCodeDiffNotation,
parseCodeFenceMeta,
renderCodeFence,
} from '../src/markdown'
Expand All @@ -30,6 +31,35 @@ describe('token output', () => {
})

describe('markdown helpers', () => {
it('turns inline diff notation into clean code and line decorations', () => {
expect(
parseCodeDiffNotation(
[
`- const oldValue = true // [!code --]`,
`+ const newValue = true // [!code ++]`,
`color: red; /* [!code ++] */`,
`echo old # [!code --]`,
`<old-tag /> <!-- [!code --] -->`,
].join('\n'),
),
).toEqual({
code: [
`- const oldValue = true`,
`+ const newValue = true`,
`color: red;`,
`echo old`,
`<old-tag />`,
].join('\n'),
decorations: [
{ className: 'th-line--deleted', lines: 1 },
{ className: 'th-line--inserted', lines: 2 },
{ className: 'th-line--inserted', lines: 3 },
{ className: 'th-line--deleted', lines: 4 },
{ className: 'th-line--deleted', lines: 5 },
],
})
})

it('parses common code fence title metadata', () => {
expect(getCodeFenceTitle('title="app.tsx"')).toBe('app.tsx')
expect(getCodeFenceTitle("{filename='route.ts'}")).toBe('route.ts')
Expand All @@ -54,7 +84,7 @@ describe('markdown helpers', () => {
it('renders a code fence into data and hast', () => {
const rendered = renderCodeFence(
{
code: `const value = 'x'\n`,
code: `const value = 'x' // [!code ++]\n`,
lang: 'typescript',
meta: 'title="example.ts"',
},
Expand All @@ -70,6 +100,12 @@ describe('markdown helpers', () => {
)

expect(rendered.copyText).toBe(`const value = 'x'`)
expect(rendered.decorations).toContainEqual({
className: 'th-line--inserted',
lines: 1,
})
expect(rendered.htmlMarkup).toContain('th-line--inserted')
expect(rendered.htmlMarkup).not.toContain('!code')
expect(rendered.lang).toBe('ts')
expect(hast.properties?.dataTitle).toBe('example.ts')
expect(hast.tagName).toBe('pre')
Expand Down Expand Up @@ -102,6 +138,13 @@ describe('markdown helpers', () => {
expect(html).not.toContain('<pre')
expect(html).not.toContain('<code')

const diff = highlightMarkdownCode(
`- const oldValue = true // [!code --]`,
'ts',
)
expect(diff).toContain('class="th-line th-line--deleted"')
expect(diff).not.toContain('!code')

const unknown = highlightMarkdownCode('<script>x</script>', 'unknown')
expect(unknown).toBe('&lt;script&gt;x&lt;/script&gt;')
})
Expand Down