+```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`.
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/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: '' });
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"