Skip to content

MVP: Support Sandpack snippets in TypeScript #5426

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 16 commits into
base: main
Choose a base branch
from
Draft
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
1 change: 1 addition & 0 deletions beta/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"@babel/core": "^7.12.9",
"@babel/plugin-transform-modules-commonjs": "^7.18.6",
"@babel/preset-react": "^7.18.6",
"@babel/preset-typescript": "^7.18.6",
"@mdx-js/mdx": "^2.1.3",
"@types/body-scroll-lock": "^2.6.1",
"@types/classnames": "^2.2.10",
Expand Down
3 changes: 3 additions & 0 deletions beta/plugins/markdownToHtml.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const images = require('remark-images'); // Improved image syntax
const unrwapImages = require('remark-unwrap-images'); // Removes <p> wrapper around images
const smartyPants = require('./remark-smartypants'); // Cleans up typography
const html = require('remark-html');
const sandpackTargetLanguages = require('./remark-sandpack-target-languages');

module.exports = {
remarkPlugins: [
Expand All @@ -13,6 +14,7 @@ module.exports = {
images,
unrwapImages,
smartyPants,
sandpackTargetLanguages,
],
markdownToHtml,
};
Expand All @@ -24,6 +26,7 @@ async function markdownToHtml(markdown) {
.use(images)
.use(unrwapImages)
.use(smartyPants)
.use(sandpackTargetLanguages)
Copy link
Member Author

Choose a reason for hiding this comment

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

I don't understand why the plugins are listed in remarkPlugins and composed via .use. Just .use didn't produce any change.

.use(html)
.process(markdown);
return result.toString();
Expand Down
80 changes: 80 additions & 0 deletions beta/plugins/remark-sandpack-target-languages.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
const {transform} = require('@babel/core');
const path = require('path');
const prettier = require('prettier');
const visit = require('unist-util-visit');

/**
* Adds an additional JS codeblock to <Sandpack> children which which is based on the corresponding TypeScript codeblock.
* The resulting JS code is formatted with Prettier.
*/
module.exports = () => {
return async function transformer(tree, file) {
const prettierConfig = await prettier.resolveConfig(
file.path ??
// TODO: When testing, `file.path` was always undefined
// Now the formatting of the TS codeblock might use a different formatting config than the resolved config here.
// E.g. when `content/learn/.prettierrc` exists it would be ignored at this point.
file.cwd
);

visit(tree, 'mdxJsxFlowElement', (node) => {
if (node.name === 'Sandpack') {
const childrenWithJSTargetLanguage = node.children.flatMap((child) => {
if (
child.type === 'code' &&
(child.lang === 'tsx' || child.lang === 'ts')
) {
const codeTSNode = child;
// By default we assume `/App.tsx`
// TODO: We should just require a filename i.e. `meta` so that we don't have an assumption that spreads throughout the codebase.
const [tsFileName = '/App.tsx', ...rest] =
codeTSNode.meta?.split(' ') ?? [];
// Gallery.tsx -> Gallery.js
// data.ts -> data.js
const jsFileName = tsFileName.replace(/\.(ts|tsx)$/, '.js');
const meta = [jsFileName, ...rest].join(' ');
const codeTS = codeTSNode.value;
let codeJS = codeTS;
try {
codeJS = transform(codeTS, {
filename: tsFileName,
presets: [
[
'@babel/preset-typescript',
{allExtensions: true, isTSX: tsFileName.endsWith('.tsx')},
],
],
}).code;
codeJS = prettier.format(codeJS, {
...prettierConfig,
filepath: jsFileName,
});
} catch (error) {
throw new Error(
`Failed to compile ${tsFileName}:\n${codeTS}\n${error}`
);
}
const codeJSNode = {
type: 'code',
lang: 'js',
meta,
value: codeJS,
};

// We can't just append since this would result in a different order of snippets if some snippets are not TS and visible:
// App.tsx, styles.css -> styles.css, App.js
// So we splice the transpiled version in instead:
// App.tsx, styles.css -> App.tsx, App.js, styles.css
return [codeTSNode, codeJSNode];
} else {
return child;
}
});

if (childrenWithJSTargetLanguage.length !== node.children) {
node.children = childrenWithJSTargetLanguage;
}
}
});
};
};
24 changes: 23 additions & 1 deletion beta/src/components/Layout/MarkdownPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ import {Seo} from 'components/Seo';
import PageHeading from 'components/PageHeading';
import {useRouteMeta} from './useRouteMeta';
import {useActiveSection} from '../../hooks/useActiveSection';
import {
SnippetTargetLanguage,
SnippetTargetLanguageContext,
SnippetTargetLanguageContextValue,
} from '../MDX/Sandpack/SnippetLanguage';
import {TocContext} from '../MDX/TocContext';

import(/* webpackPrefetch: true */ '../MDX/CodeBlock/CodeBlock');
Expand All @@ -31,6 +36,18 @@ export function MarkdownPage<
const title = meta.title || route?.title || '';
const description = meta.description || route?.description || '';
const isHomePage = section === 'home';

const defaultSnippetTargetLanguage = 'ts';
const [snippetTargetLanguage, setSnippetTargetLanguage] =
React.useState<SnippetTargetLanguage>(defaultSnippetTargetLanguage);
const snippetTargetLanguageContextValue =
React.useMemo((): SnippetTargetLanguageContextValue => {
return {
snippetTargetLanguage,
setSnippetTargetLanguage,
};
}, [snippetTargetLanguage]);

return (
<>
<div className="pl-0">
Expand All @@ -44,7 +61,12 @@ export function MarkdownPage<
)}
<div className="px-5 sm:px-12">
<div className="max-w-7xl mx-auto">
<TocContext.Provider value={toc}>{children}</TocContext.Provider>
<TocContext.Provider value={toc}>
<SnippetTargetLanguageContext.Provider
value={snippetTargetLanguageContextValue}>
{children}
</SnippetTargetLanguageContext.Provider>
</TocContext.Provider>
</div>
<DocsPageFooter
route={route}
Expand Down
10 changes: 9 additions & 1 deletion beta/src/components/MDX/Sandpack/CustomPreset.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,13 @@ export const CustomPreset = memo(function CustomPreset({
showDevTools,
onDevToolsLoad,
devToolsLoaded,
hasTSVersion,
providedFiles,
}: {
showDevTools: boolean;
devToolsLoaded: boolean;
onDevToolsLoad: () => void;
hasTSVersion: boolean;
providedFiles: Array<string>;
}) {
const {lintErrors, lintExtensions} = useSandpackLint();
Expand All @@ -44,6 +46,7 @@ export const CustomPreset = memo(function CustomPreset({
showDevTools={showDevTools}
onDevToolsLoad={onDevToolsLoad}
devToolsLoaded={devToolsLoaded}
hasTSVersion={hasTSVersion}
providedFiles={providedFiles}
lintErrors={lintErrors}
lintExtensions={lintExtensions}
Expand All @@ -56,6 +59,7 @@ const SandboxShell = memo(function SandboxShell({
showDevTools,
onDevToolsLoad,
devToolsLoaded,
hasTSVersion,
providedFiles,
lintErrors,
lintExtensions,
Expand All @@ -64,6 +68,7 @@ const SandboxShell = memo(function SandboxShell({
showDevTools: boolean;
devToolsLoaded: boolean;
onDevToolsLoad: () => void;
hasTSVersion: boolean;
providedFiles: Array<string>;
lintErrors: Array<any>;
lintExtensions: Array<any>;
Expand All @@ -76,7 +81,10 @@ const SandboxShell = memo(function SandboxShell({
<div
className="shadow-lg dark:shadow-lg-dark rounded-lg"
ref={containerRef}>
<NavigationBar providedFiles={providedFiles} />
<NavigationBar
hasTSVersion={hasTSVersion}
providedFiles={providedFiles}
/>
<SandpackLayout
className={cn(
showDevTools && devToolsLoaded && 'sp-layout-devtools',
Expand Down
74 changes: 72 additions & 2 deletions beta/src/components/MDX/Sandpack/NavigationBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@
* Copyright (c) Facebook, Inc. and its affiliates.
*/

import {
import React, {
useRef,
useInsertionEffect,
useCallback,
useState,
useEffect,
Fragment,
useContext,
} from 'react';
import cn from 'classnames';
import {
Expand All @@ -19,8 +20,13 @@ import {
import {OpenInCodeSandboxButton} from './OpenInCodeSandboxButton';
import {ResetButton} from './ResetButton';
import {DownloadButton} from './DownloadButton';
import {
SnippetTargetLanguage,
SnippetTargetLanguageContext,
} from './SnippetLanguage';
import {IconChevron} from '../../Icon/IconChevron';
import {Listbox} from '@headlessui/react';
import classNames from 'classnames';

export function useEvent(fn: any): any {
const ref = useRef(null);
Expand All @@ -39,7 +45,70 @@ const getFileName = (filePath: string): string => {
return filePath.slice(lastIndexOfSlash + 1);
};

export function NavigationBar({providedFiles}: {providedFiles: Array<string>}) {
function SnippetTargetLanguageButton({
children,
active,
disabled,
snippetTargetLanguage,
}: {
children: React.ReactNode;
active: boolean;
disabled?: boolean | undefined;
snippetTargetLanguage: SnippetTargetLanguage;
}) {
const {setSnippetTargetLanguage} = useContext(SnippetTargetLanguageContext);

return (
<button
aria-pressed={active}
aria-disabled={disabled}
className={classNames(
'text-sm text-primary dark:text-primary-dark inline-flex mx-1 border-black',
active && 'border-b-4',
disabled && 'cursor-not-allowed'
)}
onClick={() => {
if (!disabled) {
setSnippetTargetLanguage(snippetTargetLanguage);
}
}}
title={disabled ? 'Language not available at the moment.' : undefined}
type="button">
{children}
</button>
);
}

function SnippetTargetLanguageToggle({hasTSVersion}: {hasTSVersion: boolean}) {
const {snippetTargetLanguage} = useContext(SnippetTargetLanguageContext);

return (
<div role="group" aria-label="Snippet target language">
<SnippetTargetLanguageButton
key="js"
active={!hasTSVersion || snippetTargetLanguage === 'js'}
snippetTargetLanguage="js">
JS
</SnippetTargetLanguageButton>

<SnippetTargetLanguageButton
key="ts"
active={hasTSVersion && snippetTargetLanguage === 'ts'}
disabled={!hasTSVersion}
snippetTargetLanguage="ts">
TS
</SnippetTargetLanguageButton>
</div>
);
}

export function NavigationBar({
hasTSVersion,
providedFiles,
}: {
hasTSVersion: boolean;
providedFiles: Array<string>;
}) {
const {sandpack} = useSandpack();
const containerRef = useRef<HTMLDivElement | null>(null);
const tabsRef = useRef<HTMLDivElement | null>(null);
Expand Down Expand Up @@ -179,6 +248,7 @@ export function NavigationBar({providedFiles}: {providedFiles: Array<string>}) {
<div
className="px-3 flex items-center justify-end text-right"
translate="yes">
<SnippetTargetLanguageToggle hasTSVersion={hasTSVersion} />
<DownloadButton providedFiles={providedFiles} />
<ResetButton onReset={handleReset} />
<OpenInCodeSandboxButton />
Expand Down
23 changes: 19 additions & 4 deletions beta/src/components/MDX/Sandpack/SandpackRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import {SandpackLogLevel} from '@codesandbox/sandpack-client';
import {CustomPreset} from './CustomPreset';
import {createFileMap} from './createFileMap';
import {CustomTheme} from './Themes';
import {SnippetTargetLanguageContext} from './SnippetLanguage';

type SandpackProps = {
children: React.ReactNode;
autorun?: boolean;
defaultActiveFile: string;
showDevTools?: boolean;
};

Expand Down Expand Up @@ -67,23 +69,35 @@ ul {
`.trim();

function SandpackRoot(props: SandpackProps) {
let {children, autorun = true, showDevTools = false} = props;
let {
children,
autorun = true,
defaultActiveFile,
showDevTools = false,
} = props;
const [devToolsLoaded, setDevToolsLoaded] = useState(false);
const codeSnippets = Children.toArray(children) as React.ReactElement[];
const files = createFileMap(codeSnippets);
const {snippetTargetLanguage} = React.useContext(
SnippetTargetLanguageContext
);
const {files, hasTSVersion} = createFileMap(
codeSnippets,
snippetTargetLanguage
);

files['/styles.css'] = {
code: [sandboxStyle, files['/styles.css']?.code ?? ''].join('\n\n'),
hidden: !files['/styles.css']?.visible,
hidden: files['/styles.css']?.hidden,
};

return (
<div className="sandpack sandpack--playground my-8">
<SandpackProvider
template="react"
template={snippetTargetLanguage === 'ts' ? 'react-ts' : 'react'}
files={files}
theme={CustomTheme}
options={{
activeFile: defaultActiveFile,
autorun,
initMode: 'user-visible',
initModeObserverOptions: {rootMargin: '1400px 0px'},
Expand All @@ -94,6 +108,7 @@ function SandpackRoot(props: SandpackProps) {
showDevTools={showDevTools}
onDevToolsLoad={() => setDevToolsLoaded(true)}
devToolsLoaded={devToolsLoaded}
hasTSVersion={hasTSVersion}
providedFiles={Object.keys(files)}
/>
</SandpackProvider>
Expand Down
Loading