Skip to content
Open
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: 26 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,32 @@ channel = rest.channels.get('channelName')
</Code>
```

### 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
<Code>
```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');
```
</Code>
```

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
Expand Down
24 changes: 24 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
20 changes: 20 additions & 0 deletions gatsby-config.ts
Original file line number Diff line number Diff line change
@@ -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 <code> 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}`,
Expand Down Expand Up @@ -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,
],
},
},
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Copy link
Member Author

Choose a reason for hiding this comment

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

dev package will be dropped before merge

"@codesandbox/sandpack-react": "^2.20.0",
"@codesandbox/sandpack-themes": "^2.0.21",
"@gfx/zopfli": "^1.0.15",
Expand Down
12 changes: 12 additions & 0 deletions setupTests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down
132 changes: 132 additions & 0 deletions src/components/Markdown/CodeBlock.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <span data-testid="mock-icon" />;
};
});

jest.mock('src/components/ButtonWithTooltip', () => ({
ButtonWithTooltip: ({ children }: any) => <button>{children}</button>,
}));

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<string, any> = {
className: `language-${language}`,
children: code,
};
if (meta) {
codeProps['data-meta'] = meta;
}
return <code {...codeProps} />;
};

describe('<CodeBlock />', () => {
beforeEach(() => {
mockHighlightSnippet.mockReset();
mockParseLineHighlights.mockReset();
mockSplitHtmlLines.mockReset();
mockHighlightSnippet.mockReturnValue('<span>highlighted</span>');
mockSplitHtmlLines.mockReturnValue(['<span>highlighted</span>']);
});

it('renders a single <code> element and skips splitHtmlLines when no highlights', () => {
mockParseLineHighlights.mockReturnValue({ lang: 'javascript', highlights: {} });

const { container } = render(
<CodeBlock language="javascript">{buildCodeChild('const x = 1;', 'javascript')}</CodeBlock>,
);

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(['<span>line1</span>', '<span>line2</span>', '<span>line3</span>']);

render(<CodeBlock language="javascript">{buildCodeChild('line1\nline2\nline3', 'javascript', meta)}</CodeBlock>);

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(['<span>line1</span>', '<span>line2</span>', '<span>line3</span>']);

const { container } = render(
<CodeBlock language="javascript">
{buildCodeChild('line1\nline2\nline3', 'javascript', 'highlight="+1,2,-3"')}
</CodeBlock>,
);

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 <code> 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(
<CodeBlock language="javascript">{buildCodeChild('const x = 1;', 'javascript')}</CodeBlock>,
);

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(<CodeBlock language="python">{buildCodeChild('print("hi")', 'python')}</CodeBlock>);

expect(mockHighlightSnippet).toHaveBeenCalledWith('python', 'print("hi")');
});
});
55 changes: 40 additions & 15 deletions src/components/Markdown/CodeBlock.tsx
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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);
Expand All @@ -31,20 +50,26 @@ export const CodeBlock: FC<{ children: React.ReactNode; language: string }> = ({
return (
<pre className="ui-theme-dark bg-cool-black text-white p-0 rounded-lg relative max-w-[calc(100vw-48px)] sm:max-w-full">
<div className="overflow-auto relative p-4 pr-8">
<code
className="ui-text-code"
style={{ whiteSpace: 'pre-wrap' }}
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize
? DOMPurify.sanitize(highlightedContent, {
// 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'],
})
: highlightedContent,
}}
/>
{hasHighlights ? (
<code className="ui-text-code" style={{ whiteSpace: 'pre-wrap' }}>
{splitHtmlLines(sanitize(highlightedContent ?? '')).map((lineHtml, i) => {
const lineNum = i + 1;
const highlightType = highlights[lineNum];
const highlightClass = highlightType ? LINE_HIGHLIGHT_CLASSES[highlightType] : undefined;
return (
<span key={i} className={highlightClass} style={{ display: 'flex', minWidth: '100%' }}>
<span style={{ flex: 1 }} dangerouslySetInnerHTML={{ __html: lineHtml || '&nbsp;' }} />
</span>
);
})}
</code>
) : (
<code
className="ui-text-code"
style={{ whiteSpace: 'pre-wrap' }}
dangerouslySetInnerHTML={{ __html: sanitize(highlightedContent ?? '') }}
/>
)}
</div>
<div className="absolute top-4 right-2">
<ButtonWithTooltip tooltip="Copy" notification="Copied!" onClick={handleCopy} className="text-white">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:

<Code>
```javascript
```javascript highlight="2,8"
// Publish initial message and capture the serial for appending tokens
const { serials: [msgSerial] } = await channel.publish({ name: 'response', data: '' });

Expand Down
Loading