From 5e016892e2ebfbed52540df1503999c2ce94e1cd Mon Sep 17 00:00:00 2001 From: matt423 Date: Wed, 11 Feb 2026 10:49:14 +0000 Subject: [PATCH 1/3] feat: add line highlighting support to code blocks Add highlight="..." syntax to MDX code fences to visually highlight lines as additions, removals, or neutral highlights. Uses a remark plugin to preserve code fence meta strings as data-meta attributes through the MDX pipeline. --- gatsby-config.ts | 20 ++++ package.json | 2 +- setupTests.js | 12 ++ src/components/Markdown/CodeBlock.test.tsx | 132 +++++++++++++++++++++ src/components/Markdown/CodeBlock.tsx | 55 ++++++--- yarn.lock | 39 ++---- 6 files changed, 212 insertions(+), 48 deletions(-) create mode 100644 src/components/Markdown/CodeBlock.test.tsx diff --git a/gatsby-config.ts b/gatsby-config.ts index cbee718aee..cc56590dd8 100644 --- a/gatsby-config.ts +++ b/gatsby-config.ts @@ -1,5 +1,23 @@ import dotenv from 'dotenv'; import remarkGfm from 'remark-gfm'; +import { visit } from 'unist-util-visit'; + +/** + * Remark plugin that preserves the code fence meta string (everything after the + * language) as a `data-meta` attribute on the element. MDX v2 drops the + * meta by default; setting data.hProperties on the MDAST node causes + * mdast-util-to-hast's applyData() to merge it into the HAST element properties, + * which then flow through to JSX props. + */ +const remarkCodeMeta = () => (tree: any) => { + visit(tree, 'code', (node: any) => { + if (node.meta) { + node.data = node.data || {}; + node.data.hProperties = node.data.hProperties || {}; + node.data.hProperties['data-meta'] = node.meta; + } + }); +}; dotenv.config({ path: `.env.${process.env.NODE_ENV}`, @@ -94,6 +112,8 @@ export const plugins = [ remarkPlugins: [ // Add GitHub Flavored Markdown (GFM) support remarkGfm, + // Preserve code fence meta strings (e.g. highlight="...") as data-meta attributes + remarkCodeMeta, ], }, }, diff --git a/package.json b/package.json index 9f599e4b5e..35c59ca896 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "validate-llms-txt": "node bin/validate-llms.txt.ts" }, "dependencies": { - "@ably/ui": "17.13.2", + "@ably/ui": "v17.14.0-dev.fc30134778", "@codesandbox/sandpack-react": "^2.20.0", "@codesandbox/sandpack-themes": "^2.0.21", "@gfx/zopfli": "^1.0.15", diff --git a/setupTests.js b/setupTests.js index 3d598cd7aa..d81e46f5a7 100644 --- a/setupTests.js +++ b/setupTests.js @@ -11,7 +11,19 @@ window.ResizeObserver = ResizeObserver; jest.mock('@ably/ui/core/utils/syntax-highlighter', () => ({ highlightSnippet: jest.fn, + LINE_HIGHLIGHT_CLASSES: { + addition: 'code-line-addition', + removal: 'code-line-removal', + highlight: 'code-line-highlight', + }, + parseLineHighlights: (lang) => ({ lang, highlights: {} }), registerDefaultLanguages: jest.fn, + splitHtmlLines: (html) => html.split('\n'), +})); + +jest.mock('@ably/ui/core/Code', () => ({ + __esModule: true, + default: () => null, })); jest.mock('@ably/ui/core/utils/syntax-highlighter-registry', () => ({ diff --git a/src/components/Markdown/CodeBlock.test.tsx b/src/components/Markdown/CodeBlock.test.tsx new file mode 100644 index 0000000000..0920f23383 --- /dev/null +++ b/src/components/Markdown/CodeBlock.test.tsx @@ -0,0 +1,132 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { CodeBlock } from './CodeBlock'; + +jest.mock('@ably/ui/core/Icon', () => { + return function MockIcon() { + return ; + }; +}); + +jest.mock('src/components/ButtonWithTooltip', () => ({ + ButtonWithTooltip: ({ children }: any) => , +})); + +jest.mock('src/external-scripts/google-tag-manager/events', () => ({ + copyCodeBlockContentTracker: jest.fn(), +})); + +jest.mock('dompurify', () => ({ + sanitize: (html: string) => html, +})); + +const mockHighlightSnippet = jest.fn(); +const mockParseLineHighlights = jest.fn(); +const mockSplitHtmlLines = jest.fn(); + +jest.mock('@ably/ui/core/utils/syntax-highlighter', () => ({ + highlightSnippet: (...args: any[]) => mockHighlightSnippet(...args), + LINE_HIGHLIGHT_CLASSES: { + addition: 'code-line-addition', + removal: 'code-line-removal', + highlight: 'code-line-highlight', + }, + parseLineHighlights: (...args: any[]) => mockParseLineHighlights(...args), + splitHtmlLines: (...args: any[]) => mockSplitHtmlLines(...args), + registerDefaultLanguages: jest.fn(), +})); + +jest.mock('@ably/ui/core/utils/syntax-highlighter-registry', () => ({ + __esModule: true, + default: [], +})); + +const buildCodeChild = (code: string, language: string, meta?: string) => { + const codeProps: Record = { + className: `language-${language}`, + children: code, + }; + if (meta) { + codeProps['data-meta'] = meta; + } + return ; +}; + +describe('', () => { + beforeEach(() => { + mockHighlightSnippet.mockReset(); + mockParseLineHighlights.mockReset(); + mockSplitHtmlLines.mockReset(); + mockHighlightSnippet.mockReturnValue('highlighted'); + mockSplitHtmlLines.mockReturnValue(['highlighted']); + }); + + it('renders a single element and skips splitHtmlLines when no highlights', () => { + mockParseLineHighlights.mockReturnValue({ lang: 'javascript', highlights: {} }); + + const { container } = render( + {buildCodeChild('const x = 1;', 'javascript')}, + ); + + expect(mockParseLineHighlights).toHaveBeenCalledWith('javascript', undefined); + expect(mockSplitHtmlLines).not.toHaveBeenCalled(); + expect(container.querySelectorAll('code.ui-text-code')).toHaveLength(1); + expect(container.querySelector('.code-line-addition')).toBeNull(); + expect(container.querySelector('.code-line-removal')).toBeNull(); + expect(container.querySelector('.code-line-highlight')).toBeNull(); + }); + + it('passes data-meta to parseLineHighlights', () => { + const meta = 'highlight="+1,2,-3"'; + mockParseLineHighlights.mockReturnValue({ + lang: 'javascript', + highlights: { 1: 'addition', 2: 'highlight', 3: 'removal' }, + }); + mockSplitHtmlLines.mockReturnValue(['line1', 'line2', 'line3']); + + render({buildCodeChild('line1\nline2\nline3', 'javascript', meta)}); + + expect(mockParseLineHighlights).toHaveBeenCalledWith('javascript', meta); + }); + + it('renders per-line with highlight classes when highlights are present', () => { + mockParseLineHighlights.mockReturnValue({ + lang: 'javascript', + highlights: { 1: 'addition', 2: 'highlight', 3: 'removal' }, + }); + mockSplitHtmlLines.mockReturnValue(['line1', 'line2', 'line3']); + + const { container } = render( + + {buildCodeChild('line1\nline2\nline3', 'javascript', 'highlight="+1,2,-3"')} + , + ); + + expect(container.querySelector('.code-line-addition')).not.toBeNull(); + expect(container.querySelector('.code-line-highlight')).not.toBeNull(); + expect(container.querySelector('.code-line-removal')).not.toBeNull(); + + // Both paths wrap content in a single element + expect(container.querySelectorAll('code.ui-text-code')).toHaveLength(1); + }); + + it('does not apply highlight classes when no highlights exist', () => { + mockParseLineHighlights.mockReturnValue({ lang: 'javascript', highlights: {} }); + + const { container } = render( + {buildCodeChild('const x = 1;', 'javascript')}, + ); + + expect(container.querySelector('.code-line-addition')).toBeNull(); + expect(container.querySelector('.code-line-removal')).toBeNull(); + expect(container.querySelector('.code-line-highlight')).toBeNull(); + }); + + it('calls highlightSnippet with correct language and content', () => { + mockParseLineHighlights.mockReturnValue({ lang: 'python', highlights: {} }); + + render({buildCodeChild('print("hi")', 'python')}); + + expect(mockHighlightSnippet).toHaveBeenCalledWith('python', 'print("hi")'); + }); +}); diff --git a/src/components/Markdown/CodeBlock.tsx b/src/components/Markdown/CodeBlock.tsx index 31daf85e38..4261af1db2 100644 --- a/src/components/Markdown/CodeBlock.tsx +++ b/src/components/Markdown/CodeBlock.tsx @@ -1,7 +1,13 @@ import React, { FC, useMemo } from 'react'; import DOMPurify from 'dompurify'; import Icon from '@ably/ui/core/Icon'; -import { highlightSnippet, registerDefaultLanguages } from '@ably/ui/core/utils/syntax-highlighter'; +import { + highlightSnippet, + LINE_HIGHLIGHT_CLASSES, + registerDefaultLanguages, + parseLineHighlights, + splitHtmlLines, +} from '@ably/ui/core/utils/syntax-highlighter'; import languagesRegistry from '@ably/ui/core/utils/syntax-highlighter-registry'; registerDefaultLanguages(languagesRegistry); @@ -10,10 +16,23 @@ import { ButtonWithTooltip } from 'src/components/ButtonWithTooltip'; import { safeWindow } from 'src/utilities'; import { copyCodeBlockContentTracker } from 'src/external-scripts/google-tag-manager/events'; +const sanitize = (html: string) => + DOMPurify.sanitize + ? DOMPurify.sanitize(html, { + // The SVG and Math tags have been used in the past as attack vectors for mXSS, + // but if we really need them should be safe enough to enable. + // This is probably too cautious but we have no need for them at time of writing, so forbidding them is free. + FORBID_TAGS: ['svg', 'math'], + }) + : html; + export const CodeBlock: FC<{ children: React.ReactNode; language: string }> = ({ children, language = 'javascript', }) => { + const meta: string | undefined = (children as React.ReactElement)?.props?.['data-meta']; + const { highlights } = useMemo(() => parseLineHighlights(language, meta), [language, meta]); + const hasHighlights = Object.keys(highlights).length > 0; const content = children.props.children; // hack-ish, but we get the content const highlightedContent = useMemo(() => { return highlightSnippet(language, content); @@ -31,20 +50,26 @@ export const CodeBlock: FC<{ children: React.ReactNode; language: string }> = ({ return (
       
- + {hasHighlights ? ( + + {splitHtmlLines(sanitize(highlightedContent ?? '')).map((lineHtml, i) => { + const lineNum = i + 1; + const highlightType = highlights[lineNum]; + const highlightClass = highlightType ? LINE_HIGHLIGHT_CLASSES[highlightType] : undefined; + return ( + + + + ); + })} + + ) : ( + + )}
diff --git a/yarn.lock b/yarn.lock index 7b3d5aa45b..63fab4a53b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,10 +2,10 @@ # yarn lockfile v1 -"@ably/ui@17.13.2": - version "17.13.2" - resolved "https://registry.yarnpkg.com/@ably/ui/-/ui-17.13.2.tgz#d8b103f15b7bdec03dcbd8858c3812391ca638e6" - integrity sha512-lJ/fOxf9j4jSayonieb6g6/8lCvrLxRRmvi0r/CJUHSXCO8tgf+Kz/mY4ms9dQpCwO5/Fclkp9tiN/gZkiMjNg== +"@ably/ui@v17.14.0-dev.fc30134778": + version "17.14.0-dev.fc30134778" + resolved "https://registry.yarnpkg.com/@ably/ui/-/ui-17.14.0-dev.fc30134778.tgz#24a69be7529230792c9edf0e258e27c1668411f4" + integrity sha512-5BmVfLLuPnKBlcBAxcoMlEVIwJlAwmUkwLsISpzlcfibLZQcR+oTwTQ8pGdA5gTSXbsbkXu6v8qdp0xbyYgejQ== dependencies: "@heroicons/react" "^2.2.0" "@radix-ui/react-accordion" "^1.2.1" @@ -14817,16 +14817,7 @@ string-similarity@^1.2.2: lodash.map "^4.6.0" lodash.maxby "^4.6.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -14936,7 +14927,7 @@ stringify-entities@^4.0.0: character-entities-html4 "^2.0.0" character-entities-legacy "^3.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -14957,13 +14948,6 @@ strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1: version "7.1.2" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.2.tgz#132875abde678c7ea8d691533f2e7e22bb744dba" @@ -16415,7 +16399,7 @@ word-wrap@^1.2.5: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -16433,15 +16417,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.0.1, wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" From 7ccc1176bb74f6d497150dad1eeb39a7f0b9b32b Mon Sep 17 00:00:00 2001 From: matt423 Date: Thu, 12 Feb 2026 11:06:08 +0000 Subject: [PATCH 2/3] chore: Highlight append pattern in message per token --- .../docs/ai-transport/token-streaming/message-per-response.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/docs/ai-transport/token-streaming/message-per-response.mdx b/src/pages/docs/ai-transport/token-streaming/message-per-response.mdx index c258c8455d..6c48256493 100644 --- a/src/pages/docs/ai-transport/token-streaming/message-per-response.mdx +++ b/src/pages/docs/ai-transport/token-streaming/message-per-response.mdx @@ -64,7 +64,7 @@ Channel channel = realtime.channels.get("ai:{{RANDOM_CHANNEL_NAME}}"); To start streaming an AI response, publish the initial message. The message is identified by a server-assigned identifier called a [`serial`](/docs/messages#properties). Use the `serial` to append each subsequent token to the message as it arrives from the AI model: -```javascript +```javascript highlight="2,8" // Publish initial message and capture the serial for appending tokens const { serials: [msgSerial] } = await channel.publish({ name: 'response', data: '' }); From b2cfed0b1ba3e898ee4d209795475017bc6b168c Mon Sep 17 00:00:00 2001 From: matt423 Date: Thu, 12 Feb 2026 12:21:01 +0000 Subject: [PATCH 3/3] chore: document line highlighting for agents and humans CLAUDE.md and CONTRIBUTING.md --- CLAUDE.md | 26 ++++++++++++++++++++++++++ CONTRIBUTING.md | 24 ++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 3bb7221dd0..e8226d5129 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,6 +125,32 @@ channel = rest.channels.get('channelName') ``` +### Line Highlighting + +Add `highlight="..."` to a code fence to highlight specific lines. Supports individual lines and ranges, with optional `+` (addition/green) or `-` (removal/orange) prefixes. Unprefixed lines get a neutral blue highlight. + +```mdx + +```javascript highlight="+1-2,3,-5-6,7-8" +const client = new Ably.Realtime('your-api-key'); +const channel = client.channels.get('my-channel'); +channel.unsubscribe(); +// This line has no highlight +console.log('done'); +console.log('highlighted'); +console.log('neutral range start'); +console.log('neutral range end'); +``` + +``` + +Syntax: +- `3`: highlight line 3 (blue) +- `1-6`: highlight lines 1 through 6 (blue) +- `+3` or `+1-6`: addition highlight (green) +- `-3` or `-1-6`: removal highlight (orange) +- Comma-separated for multiple specs + ### Variables in Codeblocks - `{{API_KEY}}`: Demo API key or user's key selector diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6b287138ec..e58821c75d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -118,6 +118,30 @@ To use nested codeblocks when describing features that are available to the real ``` ``` +### Line Highlighting + +Add `highlight="..."` after the language identifier to highlight specific lines. Supports individual lines and ranges, with optional `+` (addition/green) or `-` (removal/orange) prefixes. Unprefixed lines get a neutral blue highlight. + +```plaintext + ```javascript highlight="+1-2,3,-5-6,7-8" + const client = new Ably.Realtime('your-api-key'); + const channel = client.channels.get('my-channel'); + channel.unsubscribe(); + // This line has no highlight + console.log('done'); + console.log('highlighted'); + console.log('neutral range start'); + console.log('neutral range end'); + ``` +``` + +Syntax: +- `3`: highlight line 3 (blue) +- `1-6`: highlight lines 1 through 6 (blue) +- `+3` or `+1-6`: addition highlight (green) +- `-3` or `-1-6`: removal highlight (orange) +- Comma-separated for multiple specs + ### In-line code In-line code should be written between `@` symbols. For example, `the @get()@ method`.