From 45649ac44cd5ad18a1be62ba422694172d2f8cda Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Mon, 27 Jul 2026 17:50:55 -0600 Subject: [PATCH] Support inline code diff notation --- docs/guides/annotations.md | 11 ++++++++ docs/reference/markdown.md | 15 +++++++++-- src/markdown.ts | 54 +++++++++++++++++++++++++++++++------- test/adapters.test.ts | 45 ++++++++++++++++++++++++++++++- 4 files changed, 113 insertions(+), 12 deletions(-) diff --git a/docs/guides/annotations.md b/docs/guides/annotations.md index 1916691..a68675f 100644 --- a/docs/guides/annotations.md +++ b/docs/guides/annotations.md @@ -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: diff --git a/docs/reference/markdown.md b/docs/reference/markdown.md index 80b3962..ff65233 100644 --- a/docs/reference/markdown.md +++ b/docs/reference/markdown.md @@ -33,6 +33,17 @@ Returns escaped inner token markup for a renderer-owned `` element. Highli ## Metadata +### `parseCodeDiffNotation` + +```ts +function parseCodeDiffNotation(code: string): { + code: string + decorations: Array +} +``` + +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 @@ -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` @@ -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 diff --git a/src/markdown.ts b/src/markdown.ts index 21c6f23..04753a9 100644 --- a/src/markdown.ts +++ b/src/markdown.ts @@ -51,6 +51,33 @@ export type TanStackMarkdownHighlighter = ( options?: TanStackMarkdownHighlighterOptions, ) => string +const codeDiffNotation = + /[ \t]*(?:(?:\/\/|#)[ \t]*\[!code[ \t]+(\+\+|--)\]|\/\*[ \t]*\[!code[ \t]+(\+\+|--)\][ \t]*\*\/|)[ \t]*$/ + +export function parseCodeDiffNotation(code: string) { + const decorations: Array = [] + 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 } @@ -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 || []), + ] const resolvedLineNumbers = lineNumbers ?? parsed.lineNumbers const rendered = highlighter.renderCodeBlockData({ - code, + code: annotated.code, decorations: resolvedDecorations, lang: lang || undefined, lineNumbers: resolvedLineNumbers, @@ -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, + })), + ], } : {}), }), diff --git a/test/adapters.test.ts b/test/adapters.test.ts index badaf5f..f82b291 100644 --- a/test/adapters.test.ts +++ b/test/adapters.test.ts @@ -5,6 +5,7 @@ import { codeFenceToHast, createTanStackMarkdownHighlighter, getCodeFenceTitle, + parseCodeDiffNotation, parseCodeFenceMeta, renderCodeFence, } from '../src/markdown' @@ -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 --]`, + ` `, + ].join('\n'), + ), + ).toEqual({ + code: [ + `- const oldValue = true`, + `+ const newValue = true`, + `color: red;`, + `echo old`, + ``, + ].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') @@ -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"', }, @@ -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') @@ -102,6 +138,13 @@ describe('markdown helpers', () => { expect(html).not.toContain('x', 'unknown') expect(unknown).toBe('<script>x</script>') })