Skip to content

Commit a222e69

Browse files
committed
feat: introduce support for MDX templating
1 parent 390e716 commit a222e69

File tree

15 files changed

+2924
-2106
lines changed

15 files changed

+2924
-2106
lines changed

.prettierignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Ignore all mdx files in src/pages - we need to be able to render components on one line for inlining
2+
src/pages/**/*.mdx
3+
src/pages/**/*.md

content/chat/index.textile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,4 @@ h3(#reactions). Room reactions
4040

4141
h2. Demo
4242

43-
Take a look at a "livestream basketball game":https://ably-livestream-chat-demo.vercel.app with some simulated users chatting built using the Chat SDK. The "source code":https://github.com/ably/ably-chat-js/tree/main/demo is available in GitHub.
43+
Take a look at a "livestream basketball game":https://ably-livestream-chat-demo.vercel.app with some simulated users chatting built using the Chat SDK. The "source code":https://github.com/ably/ably-chat-js/tree/main/demo is available in GitHub.

content/chat/rooms/messages.textile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -573,4 +573,4 @@ Applying an action to a message produces a new version, which is uniquely identi
573573

574574
The @Message@ object also has convenience methods "@isOlderVersionOf@":https://sdk.ably.com/builds/ably/ably-chat-js/main/typedoc/interfaces/chat-js.Message.html#isolderversionof, "@isNewerVersionOf@":https://sdk.ably.com/builds/ably/ably-chat-js/main/typedoc/interfaces/chat-js.Message.html#isnewerversionof and "@isSameVersionAs@":https://sdk.ably.com/builds/ably/ably-chat-js/main/typedoc/interfaces/chat-js.Message.html#issameversionas which provide the same comparison.
575575

576-
Update and Delete events provide the full message payload, so may be used to replace the entire earlier version of the message.
576+
Update and Delete events provide the full message payload, so may be used to replace the entire earlier version of the message.

data/onCreatePage.ts

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { GatsbyNode } from 'gatsby';
2+
import path from 'path';
3+
import fs from 'fs';
24

35
export type LayoutOptions = { sidebar: boolean; searchBar: boolean; template: string };
46

7+
const mdxWrapper = path.resolve('src/components/Layout/MDXWrapper.tsx');
8+
59
const pageLayoutOptions: Record<string, LayoutOptions> = {
610
'/docs': { sidebar: false, searchBar: false, template: 'index' },
711
'/docs/api/control-api': { sidebar: false, searchBar: true, template: 'control-api' },
@@ -11,17 +15,49 @@ const pageLayoutOptions: Record<string, LayoutOptions> = {
1115
'/docs/404': { sidebar: false, searchBar: false, template: '404' },
1216
};
1317

14-
export const onCreatePage: GatsbyNode['onCreatePage'] = ({ page, actions }) => {
15-
const { createPage } = actions;
18+
// Function to extract code element classes from an MDX file
19+
const extractCodeLanguages = async (filePath: string): Promise<Set<string>> => {
20+
try {
21+
// Check if the file exists
22+
if (!fs.existsSync(filePath)) {
23+
return new Set();
24+
}
1625

17-
const pathOptions = Object.entries(pageLayoutOptions).find(([path]) => page.path === path);
26+
// Read the file content
27+
const fileContent = fs.readFileSync(filePath, 'utf8');
1828

19-
if (pathOptions) {
20-
page.context = {
21-
...page.context,
22-
layout: pathOptions[1],
23-
};
29+
// Find all instances of code blocks with language specifiers (```language)
30+
const codeBlockRegex = /```(\w+)/g;
31+
let match;
32+
const languages = new Set<string>();
33+
34+
while ((match = codeBlockRegex.exec(fileContent)) !== null) {
35+
if (match[1] && match[1].trim()) {
36+
languages.add(match[1].trim());
37+
}
38+
}
39+
return languages;
40+
} catch (error) {
41+
console.error(`Error extracting code element classes from ${filePath}:`, error);
42+
return new Set();
43+
}
44+
};
45+
46+
export const onCreatePage: GatsbyNode['onCreatePage'] = async ({ page, actions }) => {
47+
const { createPage } = actions;
48+
const pathOptions = Object.entries(pageLayoutOptions).find(([path]) => page.path === path);
49+
const isMDX = page.component.endsWith('.mdx');
50+
const detectedLanguages = isMDX ? await extractCodeLanguages(page.component) : new Set();
2451

25-
createPage(page);
52+
if (pathOptions || isMDX) {
53+
createPage({
54+
...page,
55+
context: {
56+
...page.context,
57+
layout: pathOptions ? pathOptions[1] : { sidebar: true, searchBar: true, template: 'base' },
58+
...(isMDX ? { languages: Array.from(detectedLanguages) } : {}),
59+
},
60+
component: isMDX ? `${mdxWrapper}?__contentFilePath=${page.component}` : page.component,
61+
});
2662
}
2763
};

gatsby-config.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import dotenv from 'dotenv';
2+
import remarkGfm from 'remark-gfm';
23

34
dotenv.config({
45
path: `.env.${process.env.NODE_ENV}`,
@@ -38,6 +39,8 @@ export const siteMetadata = {
3839

3940
export const graphqlTypegen = true;
4041

42+
const headerLinkIcon = `<svg aria-hidden="true" height="20" version="1.1" viewBox="0 0 16 16" width="20"><path fill-rule="evenodd" d="M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z"></path></svg>`;
43+
4144
export const plugins = [
4245
'gatsby-plugin-postcss',
4346
'gatsby-plugin-image',
@@ -51,7 +54,37 @@ export const plugins = [
5154
'how-tos': `${__dirname}/how-tos`,
5255
},
5356
},
54-
'gatsby-plugin-mdx',
57+
{
58+
resolve: 'gatsby-plugin-mdx',
59+
options: {
60+
gatsbyRemarkPlugins: [
61+
{
62+
resolve: `gatsby-remark-autolink-headers`,
63+
options: {
64+
offsetY: `100`,
65+
icon: headerLinkIcon,
66+
className: `gatsby-copyable-header`,
67+
removeAccents: true,
68+
isIconAfterHeader: true,
69+
elements: [`h2`, `h3`],
70+
},
71+
},
72+
],
73+
mdxOptions: {
74+
remarkPlugins: [
75+
// Add GitHub Flavored Markdown (GFM) support
76+
remarkGfm,
77+
],
78+
},
79+
},
80+
},
81+
{
82+
resolve: `gatsby-source-filesystem`,
83+
options: {
84+
name: `pages`,
85+
path: `${__dirname}/src/pages`,
86+
},
87+
},
5588
// Images
5689
{
5790
resolve: 'gatsby-source-filesystem',

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,12 @@
5959
"gatsby-plugin-image": "^3.3.0",
6060
"gatsby-plugin-layout": "^4.14.0",
6161
"gatsby-plugin-manifest": "^5.3.0",
62-
"gatsby-plugin-mdx": "^5.12.0",
62+
"gatsby-plugin-mdx": "^5.14.0",
6363
"gatsby-plugin-react-helmet": "^6.3.0",
6464
"gatsby-plugin-root-import": "^2.0.9",
6565
"gatsby-plugin-sharp": "^5.8.1",
6666
"gatsby-plugin-sitemap": "^6.12.1",
67+
"gatsby-remark-autolink-headers": "^6.14.0",
6768
"gatsby-source-filesystem": "^5.12.0",
6869
"gatsby-transformer-remark": "^6.12.0",
6970
"gatsby-transformer-sharp": "^5.3.0",
@@ -81,6 +82,7 @@
8182
"react-helmet": "^6.1.0",
8283
"react-medium-image-zoom": "^5.1.2",
8384
"react-select": "^5.7.0",
85+
"remark-gfm": "^1.0.0",
8486
"textile-js": "^2.1.1",
8587
"turndown": "^7.1.1",
8688
"typescript": "^4.6.3",

src/components/Layout/Layout.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,12 @@ import Header from './Header';
1212
import LeftSidebar from './LeftSidebar';
1313
import RightSidebar from './RightSidebar';
1414

15-
type PageContextType = {
15+
export type PageContextType = {
1616
layout: LayoutOptions;
17+
languages?: string[];
18+
frontmatter?: {
19+
title: string;
20+
};
1721
};
1822

1923
type LayoutProps = PageProps<unknown, PageContextType>;
@@ -26,7 +30,7 @@ const Layout: React.FC<LayoutProps> = ({ children, pageContext }) => {
2630
<Header searchBar={searchBar} />
2731
<div className="flex pt-64 md:gap-48 lg:gap-64 xl:gap-80 justify-center ui-standard-container mx-auto">
2832
{sidebar ? <LeftSidebar /> : null}
29-
<Container as="main" className="flex-1">
33+
<Container as="main" className="flex-1 overflow-x-auto">
3034
{sidebar ? <Breadcrumbs /> : null}
3135
{children}
3236
<Footer />
@@ -38,7 +42,7 @@ const Layout: React.FC<LayoutProps> = ({ children, pageContext }) => {
3842
};
3943

4044
const WrappedLayout: React.FC<LayoutProps> = (props) => (
41-
<LayoutProvider>
45+
<LayoutProvider pageContext={props.pageContext}>
4246
<Layout {...props} />
4347
</LayoutProvider>
4448
);
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import React, { ReactNode } from 'react';
2+
import { render, screen } from '@testing-library/react';
3+
import If from './mdx/If';
4+
import CodeSnippet from '@ably/ui/core/CodeSnippet';
5+
6+
// Mock the dependencies we need for testing
7+
jest.mock('./MDXWrapper', () => {
8+
return {
9+
__esModule: true,
10+
default: ({ children, pageContext }: { children: ReactNode; pageContext: any }) => (
11+
<div data-testid="mdx-wrapper">
12+
{pageContext?.frontmatter?.title && <h1>{pageContext.frontmatter.title}</h1>}
13+
<div data-testid="mdx-content">{children}</div>
14+
</div>
15+
),
16+
};
17+
});
18+
19+
// Mock the layout context
20+
jest.mock('src/contexts/layout-context', () => ({
21+
useLayoutContext: () => ({
22+
activePage: { language: 'javascript' },
23+
setLanguage: jest.fn(),
24+
}),
25+
LayoutProvider: ({ children }: { children: ReactNode }) => <div data-testid="layout-provider">{children}</div>,
26+
}));
27+
28+
// We need to mock minimal implementation of other dependencies that CodeSnippet might use
29+
jest.mock('@ably/ui/core/Icon', () => {
30+
return {
31+
__esModule: true,
32+
default: ({ name, size, additionalCSS, color }: any) => (
33+
<span
34+
data-testid={`icon-${name}`}
35+
className={`${additionalCSS || ''} ${color || ''}`}
36+
style={{ width: size, height: size }}
37+
>
38+
{name}
39+
</span>
40+
),
41+
};
42+
});
43+
44+
// Mock Code component used by CodeSnippet
45+
jest.mock('@ably/ui/core/Code', () => {
46+
return {
47+
__esModule: true,
48+
default: ({ language, snippet }: any) => (
49+
<pre data-testid={`code-${language}`}>
50+
<code>{snippet}</code>
51+
</pre>
52+
),
53+
};
54+
});
55+
56+
describe('MDX component integration', () => {
57+
it('renders basic content correctly', () => {
58+
render(
59+
<div>
60+
<h1>Test Heading</h1>
61+
<p>Test paragraph</p>
62+
</div>,
63+
);
64+
65+
expect(screen.getByText('Test Heading')).toBeInTheDocument();
66+
expect(screen.getByText('Test paragraph')).toBeInTheDocument();
67+
});
68+
69+
it('conditionally renders content with If component', () => {
70+
render(
71+
<div>
72+
<If lang="javascript">This should be visible</If>
73+
<If lang="ruby">This should not be visible</If>
74+
</div>,
75+
);
76+
77+
expect(screen.getByText('This should be visible')).toBeInTheDocument();
78+
expect(screen.queryByText('This should not be visible')).not.toBeInTheDocument();
79+
});
80+
81+
it('renders code snippets with different languages (JavaScript active)', () => {
82+
render(
83+
<div>
84+
<CodeSnippet>
85+
<pre>
86+
<code className="language-javascript">
87+
{`var ably = new Ably.Realtime('API_KEY');
88+
var channel = ably.channels.get('channel-name');
89+
90+
// Subscribe to messages on channel
91+
channel.subscribe('event', function(message) {
92+
console.log(message.data);
93+
});`}
94+
</code>
95+
</pre>
96+
<pre>
97+
<code className="language-swift">
98+
{`let realtime = ARTRealtime(key: "API_KEY")
99+
let channel = realtime.channels.get("channel-name")
100+
101+
// Subscribe to messages on channel
102+
channel.subscribe("event") { message in
103+
print(message.data)
104+
}`}
105+
</code>
106+
</pre>
107+
</CodeSnippet>
108+
</div>,
109+
);
110+
111+
const javascriptElement = screen.queryByTestId('code-javascript');
112+
const swiftElement = screen.queryByTestId('code-swift');
113+
114+
expect(javascriptElement).toBeInTheDocument();
115+
expect(swiftElement).not.toBeInTheDocument();
116+
});
117+
118+
it('renders code snippets (TypeScript active)', () => {
119+
render(
120+
<div>
121+
<CodeSnippet lang="typescript">
122+
<pre>
123+
<code className="language-javascript">
124+
{`var ably = new Ably.Realtime('API_KEY');
125+
var channel = ably.channels.get('channel-name');
126+
127+
// Subscribe to messages on channel
128+
channel.subscribe('event', function(message) {
129+
console.log(message.data);
130+
});`}
131+
</code>
132+
</pre>
133+
<pre>
134+
<code className="language-typescript">
135+
{`const ably = new Ably.Realtime('API_KEY');
136+
const channel = ably.channels.get('channel-name');
137+
138+
// Subscribe to messages on channel
139+
channel.subscribe('event', (message: Ably.Types.Message) => {
140+
console.log(message.data);
141+
});`}
142+
</code>
143+
</pre>
144+
</CodeSnippet>
145+
</div>,
146+
);
147+
148+
const javascriptElement = screen.queryByTestId('code-javascript');
149+
const typescriptElement = screen.queryByTestId('code-typescript');
150+
151+
expect(javascriptElement).not.toBeInTheDocument();
152+
expect(typescriptElement).toBeInTheDocument();
153+
});
154+
});

0 commit comments

Comments
 (0)