Skip to content

Commit 9e623f6

Browse files
committed
Fix Sandpack MDX parsing and typegen
1 parent 9ea70de commit 9e623f6

File tree

8 files changed

+182
-60
lines changed

8 files changed

+182
-60
lines changed

next-env.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/// <reference types="next" />
22
/// <reference types="next/image-types/global" />
33
/// <reference types="next/navigation-types/compat/navigation" />
4-
import './.next/dev/types/routes.d.ts';
4+
import './.next/types/routes.d.ts';
55

66
// NOTE: This file should not be edited
77
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

src/components/MDX/ExpandableExample.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ interface ExpandableExampleProps {
2828
type: 'DeepDive' | 'Example';
2929
}
3030

31+
type ExpandableTitleElement = React.ReactElement<{
32+
id?: string;
33+
children?: React.ReactNode;
34+
}>;
35+
3136
function getExpandableChildren(children: React.ReactNode) {
3237
return Children.toArray(children).filter((child) => {
3338
return !(typeof child === 'string' && child.trim() === '');
@@ -50,10 +55,11 @@ function ExpandableExample({children, excerpt, type}: ExpandableExampleProps) {
5055
`Expandable content ${type} is missing a corresponding title at the beginning`
5156
);
5257
}
58+
const titleElement = titleChild as ExpandableTitleElement;
5359

5460
const isDeepDive = type === 'DeepDive';
5561
const isExample = type === 'Example';
56-
const id = titleChild.props.id;
62+
const id = titleElement.props.id;
5763
const hash = useLocationHash();
5864
const [isExpanded, setIsExpanded] = useState(false);
5965
const [isAutoExpandedDismissed, setIsAutoExpandedDismissed] = useState(false);
@@ -105,7 +111,7 @@ function ExpandableExample({children, excerpt, type}: ExpandableExampleProps) {
105111
<H4
106112
id={id}
107113
className="text-xl font-bold text-primary dark:text-primary-dark">
108-
{titleChild.props.children}
114+
{titleElement.props.children}
109115
</H4>
110116
{excerpt && <div>{excerpt}</div>}
111117
</div>

src/components/MDX/Sandpack/SandpackRSCRoot.tsx

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
'use client';
2+
13
/**
24
* Copyright (c) Meta Platforms, Inc. and affiliates.
35
*
@@ -9,18 +11,18 @@
911
* Copyright (c) Facebook, Inc. and its affiliates.
1012
*/
1113

12-
import {Children} from 'react';
1314
import * as React from 'react';
1415
import {SandpackProvider} from '@codesandbox/sandpack-react/unstyled';
1516
import {SandpackLogLevel} from '@codesandbox/sandpack-client';
1617
import {CustomPreset} from './CustomPreset';
17-
import {createFileMap} from './createFileMap';
18+
import type {SandpackSnippetFile} from './createFileMap';
1819
import {CustomTheme} from './Themes';
1920
import {templateRSC} from './templateRSC';
2021
import {RscFileBridge} from './sandpack-rsc/RscFileBridge';
2122

2223
type SandpackProps = {
23-
children: React.ReactNode;
24+
files: Record<string, SandpackSnippetFile>;
25+
providedFiles: string[];
2426
autorun?: boolean;
2527
};
2628

@@ -75,9 +77,7 @@ ul {
7577
`.trim();
7678

7779
function SandpackRSCRoot(props: SandpackProps) {
78-
const {children, autorun = true} = props;
79-
const codeSnippets = Children.toArray(children) as React.ReactElement[];
80-
const files = createFileMap(codeSnippets);
80+
const {files, providedFiles, autorun = true} = props;
8181

8282
if ('/index.html' in files) {
8383
throw new Error(
@@ -86,15 +86,18 @@ function SandpackRSCRoot(props: SandpackProps) {
8686
);
8787
}
8888

89-
files['/src/styles.css'] = {
90-
code: [sandboxStyle, files['/src/styles.css']?.code ?? ''].join('\n\n'),
91-
hidden: !files['/src/styles.css']?.visible,
89+
const sandpackFiles = {
90+
...files,
91+
'/src/styles.css': {
92+
code: [sandboxStyle, files['/src/styles.css']?.code ?? ''].join('\n\n'),
93+
hidden: !files['/src/styles.css']?.visible,
94+
},
9295
};
9396

9497
return (
9598
<div className="sandpack sandpack--playground w-full my-8" dir="ltr">
9699
<SandpackProvider
97-
files={{...templateRSC, ...files}}
100+
files={{...templateRSC, ...sandpackFiles}}
98101
theme={CustomTheme}
99102
customSetup={{
100103
dependencies: {},
@@ -108,7 +111,7 @@ function SandpackRSCRoot(props: SandpackProps) {
108111
}}>
109112
<RscFileBridge />
110113
<CustomPreset
111-
providedFiles={Object.keys(files)}
114+
providedFiles={providedFiles}
112115
showOpenInCodeSandbox={false}
113116
/>
114117
</SandpackProvider>

src/components/MDX/Sandpack/SandpackRoot.tsx

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
'use client';
2+
13
/**
24
* Copyright (c) Meta Platforms, Inc. and affiliates.
35
*
@@ -9,17 +11,17 @@
911
* Copyright (c) Facebook, Inc. and its affiliates.
1012
*/
1113

12-
import {Children} from 'react';
1314
import * as React from 'react';
1415
import {SandpackProvider} from '@codesandbox/sandpack-react/unstyled';
1516
import {SandpackLogLevel} from '@codesandbox/sandpack-client';
1617
import {CustomPreset} from './CustomPreset';
17-
import {createFileMap} from './createFileMap';
18+
import type {SandpackSnippetFile} from './createFileMap';
1819
import {CustomTheme} from './Themes';
1920
import {template} from './template';
2021

2122
type SandpackProps = {
22-
children: React.ReactNode;
23+
files: Record<string, SandpackSnippetFile>;
24+
providedFiles: string[];
2325
autorun?: boolean;
2426
};
2527

@@ -74,9 +76,7 @@ ul {
7476
`.trim();
7577

7678
function SandpackRoot(props: SandpackProps) {
77-
let {children, autorun = true} = props;
78-
const codeSnippets = Children.toArray(children) as React.ReactElement[];
79-
const files = createFileMap(codeSnippets);
79+
const {files, providedFiles, autorun = true} = props;
8080

8181
if ('/index.html' in files) {
8282
throw new Error(
@@ -85,15 +85,18 @@ function SandpackRoot(props: SandpackProps) {
8585
);
8686
}
8787

88-
files['/src/styles.css'] = {
89-
code: [sandboxStyle, files['/src/styles.css']?.code ?? ''].join('\n\n'),
90-
hidden: !files['/src/styles.css']?.visible,
88+
const sandpackFiles = {
89+
...files,
90+
'/src/styles.css': {
91+
code: [sandboxStyle, files['/src/styles.css']?.code ?? ''].join('\n\n'),
92+
hidden: !files['/src/styles.css']?.visible,
93+
},
9194
};
9295

9396
return (
9497
<div className="sandpack sandpack--playground w-full my-8" dir="ltr">
9598
<SandpackProvider
96-
files={{...template, ...files}}
99+
files={{...template, ...sandpackFiles}}
97100
theme={CustomTheme}
98101
customSetup={{
99102
environment: 'create-react-app',
@@ -105,7 +108,7 @@ function SandpackRoot(props: SandpackProps) {
105108
bundlerURL: 'https://786946de.sandpack-bundler-4bw.pages.dev',
106109
logLevel: SandpackLogLevel.None,
107110
}}>
108-
<CustomPreset providedFiles={Object.keys(files)} />
111+
<CustomPreset providedFiles={providedFiles} />
109112
</SandpackProvider>
110113
</div>
111114
);

src/components/MDX/Sandpack/createFileMap.ts

Lines changed: 110 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,23 @@
1010
*/
1111

1212
import type {SandpackFile} from '@codesandbox/sandpack-react/unstyled';
13-
import type {PropsWithChildren, ReactElement, HTMLAttributes} from 'react';
13+
import {Children, isValidElement} from 'react';
14+
import type {
15+
PropsWithChildren,
16+
ReactElement,
17+
HTMLAttributes,
18+
ReactNode,
19+
} from 'react';
1420
import {getMDXName} from '../getMDXName';
1521

1622
export const AppJSPath = `/src/App.js`;
1723
export const StylesCSSPath = `/src/styles.css`;
1824
export const SUPPORTED_FILES = [AppJSPath, StylesCSSPath];
1925

26+
export type SandpackSnippetFile = SandpackFile & {
27+
visible?: boolean;
28+
};
29+
2030
/**
2131
* Tokenize meta attributes while ignoring brace-wrapped metadata (e.g. {expectedErrors: …}).
2232
*/
@@ -77,22 +87,105 @@ function splitMeta(meta: string): string[] {
7787
return tokens;
7888
}
7989

90+
function collectCodeSnippets(children: ReactNode): ReactElement[] {
91+
const codeSnippets: ReactElement[] = [];
92+
93+
Children.forEach(children, (child) => {
94+
if (!isValidElement(child)) {
95+
return;
96+
}
97+
98+
if (getMDXName(child) === 'pre') {
99+
codeSnippets.push(child);
100+
return;
101+
}
102+
103+
const props = child.props as {children?: ReactNode} | null;
104+
if (props?.children != null) {
105+
codeSnippets.push(...collectCodeSnippets(props.children));
106+
}
107+
});
108+
109+
return codeSnippets;
110+
}
111+
112+
type CodeElementProps = HTMLAttributes<HTMLDivElement> & {
113+
meta?: string;
114+
children?: ReactNode;
115+
};
116+
117+
function findCodeElement(
118+
children: ReactNode
119+
): ReactElement<CodeElementProps> | null {
120+
let codeElement: ReactElement<CodeElementProps> | null = null;
121+
122+
Children.forEach(children, (child) => {
123+
if (codeElement || !isValidElement(child)) {
124+
return;
125+
}
126+
127+
const props = child.props as CodeElementProps | null;
128+
if (
129+
getMDXName(child) === 'code' ||
130+
typeof props?.meta === 'string' ||
131+
(typeof props?.className === 'string' &&
132+
props.className.startsWith('language-'))
133+
) {
134+
codeElement = child as ReactElement<CodeElementProps>;
135+
return;
136+
}
137+
138+
if (props?.children != null) {
139+
codeElement = findCodeElement(props.children);
140+
}
141+
});
142+
143+
return codeElement;
144+
}
145+
146+
function getTextContent(node: ReactNode): string {
147+
let text = '';
148+
149+
Children.forEach(node, (child) => {
150+
if (typeof child === 'string' || typeof child === 'number') {
151+
text += child;
152+
return;
153+
}
154+
155+
if (!isValidElement(child)) {
156+
return;
157+
}
158+
159+
const props = child.props as {children?: ReactNode} | null;
160+
text += getTextContent(props?.children ?? null);
161+
});
162+
163+
return text;
164+
}
165+
80166
export const createFileMap = (codeSnippets: any) => {
81-
return codeSnippets.reduce(
82-
(result: Record<string, SandpackFile>, codeSnippet: React.ReactElement) => {
83-
if (getMDXName(codeSnippet) !== 'pre') {
84-
return result;
167+
return collectCodeSnippets(codeSnippets).reduce(
168+
(
169+
result: Record<string, SandpackSnippetFile>,
170+
codeSnippet: React.ReactElement
171+
) => {
172+
const codeElement = findCodeElement(
173+
(
174+
codeSnippet.props as PropsWithChildren<{
175+
children?: ReactNode;
176+
}>
177+
).children
178+
);
179+
180+
if (!codeElement) {
181+
throw new Error('Code block is missing a code element.');
85182
}
86-
const {props} = (
87-
codeSnippet.props as PropsWithChildren<{
88-
children: ReactElement<
89-
HTMLAttributes<HTMLDivElement> & {meta?: string}
90-
>;
91-
}>
92-
).children;
183+
184+
const props = codeElement.props;
93185
let filePath; // path in the folder structure
94186
let fileHidden = false; // if the file is available as a tab
95187
let fileActive = false; // if the file tab is shown by default
188+
let fileVisible = false; // if the file tab should be forced visible
96189

97190
if (props.meta) {
98191
const tokens = splitMeta(props.meta);
@@ -108,6 +201,9 @@ export const createFileMap = (codeSnippets: any) => {
108201
if (tokens.includes('active')) {
109202
fileActive = true;
110203
}
204+
if (tokens.includes('visible')) {
205+
fileVisible = true;
206+
}
111207
} else {
112208
if (props.className === 'language-js') {
113209
filePath = AppJSPath;
@@ -138,9 +234,10 @@ export const createFileMap = (codeSnippets: any) => {
138234
);
139235
}
140236
result[filePath] = {
141-
code: (props.children || '') as string,
237+
code: getTextContent(props.children ?? null),
142238
hidden: fileHidden,
143239
active: fileActive,
240+
visible: fileVisible,
144241
};
145242

146243
return result;

0 commit comments

Comments
 (0)