Skip to content

[Beta] Fully SSR CodeBlock #5110

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

Merged
merged 1 commit into from
Sep 25, 2022
Merged
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
322 changes: 256 additions & 66 deletions beta/src/components/MDX/CodeBlock/CodeBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,9 @@
*/

import cn from 'classnames';
import {
SandpackCodeViewer,
SandpackProvider,
} from '@codesandbox/sandpack-react';
import {highlightTree} from '@codemirror/highlight';
import {javascript} from '@codemirror/lang-javascript';
import {HighlightStyle, tags} from '@codemirror/highlight';
import rangeParser from 'parse-numeric-range';
import {CustomTheme} from '../Sandpack/Themes';

Expand All @@ -33,84 +32,275 @@ const CodeBlock = function CodeBlock({
className?: string;
noMargin?: boolean;
}) {
const getDecoratedLineInfo = () => {
if (!meta) {
return [];
code = code.trimEnd();
const tree = language.language.parser.parse(code);
let tokenStarts = new Map();
let tokenEnds = new Map();
const highlightTheme = getSyntaxHighlight(CustomTheme);
highlightTree(tree, highlightTheme.match, (from, to, className) => {
tokenStarts.set(from, className);
tokenEnds.set(to, className);
});

const highlightedLines = new Map();
const lines = code.split('\n');
const lineDecorators = getLineDecorators(code, meta);
for (let decorator of lineDecorators) {
highlightedLines.set(decorator.line - 1, decorator.className);
}

const inlineDecorators = getInlineDecorators(code, meta);
const decoratorStarts = new Map();
const decoratorEnds = new Map();
for (let decorator of inlineDecorators) {
// Find where inline highlight starts and ends.
let decoratorStart = 0;
for (let i = 0; i < decorator.line - 1; i++) {
decoratorStart += lines[i].length + 1;
}
decoratorStart += decorator.startColumn;
const decoratorEnd =
decoratorStart + (decorator.endColumn - decorator.startColumn);
if (decoratorStarts.has(decoratorStart)) {
throw Error('Already opened decorator at ' + decoratorStart);
}
decoratorStarts.set(decoratorStart, decorator.className);
if (decoratorEnds.has(decoratorEnd)) {
throw Error('Already closed decorator at ' + decoratorEnd);
}
decoratorEnds.set(decoratorEnd, decorator.className);
}

const linesToHighlight = getHighlightLines(meta);
const highlightedLineConfig = linesToHighlight.map((line) => {
return {
className: 'bg-github-highlight dark:bg-opacity-10',
line,
};
});

const inlineHighlightLines = getInlineHighlights(meta, code);
const inlineHighlightConfig = inlineHighlightLines.map(
(line: InlineHiglight) => ({
...line,
elementAttributes: {'data-step': `${line.step}`},
className: cn(
'code-step bg-opacity-10 dark:bg-opacity-20 relative rounded px-1 py-[1.5px] border-b-[2px] border-opacity-60',
{
'bg-blue-40 border-blue-40 text-blue-60 dark:text-blue-30 font-bold':
line.step === 1,
'bg-yellow-40 border-yellow-40 text-yellow-60 dark:text-yellow-30 font-bold':
line.step === 2,
'bg-purple-40 border-purple-40 text-purple-60 dark:text-purple-30 font-bold':
line.step === 3,
'bg-green-40 border-green-40 text-green-60 dark:text-green-30 font-bold':
line.step === 4,
}
),
})
);

return highlightedLineConfig.concat(inlineHighlightConfig);
};
// Produce output based on tokens and decorators.
// We assume tokens never overlap other tokens, and
// decorators never overlap with other decorators.
// However, tokens and decorators may mutually overlap.
// In that case, decorators always take precedence.
let currentDecorator = null;
let currentToken = null;
let buffer = '';
let lineIndex = 0;
let lineOutput = [];
let finalOutput = [];
for (let i = 0; i < code.length; i++) {
if (tokenEnds.has(i)) {
if (!currentToken) {
throw Error('Cannot close token at ' + i + ' because it was not open.');
}
if (!currentDecorator) {
lineOutput.push(
<span key={i + '/t'} className={currentToken}>
{buffer}
</span>
);
buffer = '';
}
currentToken = null;
}
if (decoratorEnds.has(i)) {
if (!currentDecorator) {
throw Error(
'Cannot close decorator at ' + i + ' because it was not open.'
);
}
lineOutput.push(
<span key={i + '/d'} className={currentDecorator}>
{buffer}
</span>
);
buffer = '';
currentDecorator = null;
}
if (decoratorStarts.has(i)) {
if (currentDecorator) {
throw Error(
'Cannot open decorator at ' + i + ' before closing last one.'
);
}
if (currentToken) {
lineOutput.push(
<span key={i + 'd'} className={currentToken}>
{buffer}
</span>
);
buffer = '';
} else {
lineOutput.push(buffer);
buffer = '';
}
currentDecorator = decoratorStarts.get(i);
}
if (tokenStarts.has(i)) {
if (currentToken) {
throw Error('Cannot open token at ' + i + ' before closing last one.');
}
currentToken = tokenStarts.get(i);
if (!currentDecorator) {
lineOutput.push(buffer);
buffer = '';
}
}
if (code[i] === '\n') {
lineOutput.push(buffer);
buffer = '';
finalOutput.push(
<div
key={lineIndex}
className={'cm-line ' + highlightedLines.get(lineIndex)}>
{lineOutput}
<br />
</div>
);
lineOutput = [];
lineIndex++;
} else {
buffer += code[i];
}
}
lineOutput.push(buffer);
finalOutput.push(
<div
key={lineIndex}
className={'cm-line ' + highlightedLines.get(lineIndex)}>
{lineOutput}
</div>
);

// e.g. "language-js"
const language = className.substring(9);
const filename = '/index.' + language;
const decorators = getDecoratedLineInfo();
return (
<div
key={
// HACK: There seems to be a bug where the rendered result
// "lags behind" the edits to it. For now, force it to reset.
process.env.NODE_ENV === 'development' ? code : ''
}
className={cn(
'sandpack sandpack--codeblock',
'rounded-lg h-full w-full overflow-x-auto flex items-center bg-wash dark:bg-gray-95 shadow-lg',
!noMargin && 'my-8'
)}>
<SandpackProvider
files={{
[filename]: {
code: code.trimEnd(),
},
}}
customSetup={{
entry: filename,
}}
options={{
initMode: 'immediate',
}}
theme={CustomTheme}>
<SandpackCodeViewer
key={code.trimEnd()}
showLineNumbers={false}
decorators={decorators}
/>
</SandpackProvider>
{/* These classes are fragile and depend on Sandpack. TODO: some better way. */}
<div className="sp-wrapper sp-121717251 sp-c-fVPbOs sp-c-fVPbOs-LrWkf-variant-dark">
<div className="sp-stack sp-c-kLppIp">
<div className="sp-code-editor sp-c-bNbSGz">
<pre className="sp-cm sp-pristine sp-javascript sp-c-jcgexo sp-c-jkvvao">
<code className="sp-pre-placeholder sp-c-fWymNx">
{finalOutput}
</code>
</pre>
</div>
</div>
</div>
</div>
);
};

export default CodeBlock;

const language = javascript({jsx: true, typescript: false});

function classNameToken(name: string): string {
return `sp-syntax-${name}`;
}

function getSyntaxHighlight(theme: any): HighlightStyle {
return HighlightStyle.define([
{tag: tags.link, textdecorator: 'underline'},
{tag: tags.emphasis, fontStyle: 'italic'},
{tag: tags.strong, fontWeight: 'bold'},

{
tag: tags.keyword,
class: classNameToken('keyword'),
},
{
tag: [tags.atom, tags.number, tags.bool],
class: classNameToken('static'),
},
{
tag: tags.tagName,
class: classNameToken('tag'),
},
{tag: tags.variableName, class: classNameToken('plain')},
{
// Highlight function call
tag: tags.function(tags.variableName),
class: classNameToken('definition'),
},
{
// Highlight function definition differently (eg: functional component def in React)
tag: tags.definition(tags.function(tags.variableName)),
class: classNameToken('definition'),
},
{
tag: tags.propertyName,
class: classNameToken('property'),
},
{
tag: [tags.literal, tags.inserted],
class: classNameToken(theme.syntax.string ? 'string' : 'static'),
},
{
tag: tags.punctuation,
class: classNameToken('punctuation'),
},
{
tag: [tags.comment, tags.quote],
class: classNameToken('comment'),
},
]);
}

function getLineDecorators(
code: string,
meta: string
): Array<{
line: number;
className: string;
}> {
if (!meta) {
return [];
}
const linesToHighlight = getHighlightLines(meta);
const highlightedLineConfig = linesToHighlight.map((line) => {
return {
className: 'bg-github-highlight dark:bg-opacity-10',
line,
};
});
return highlightedLineConfig;
}

function getInlineDecorators(
code: string,
meta: string
): Array<{
step: number;
line: number;
startColumn: number;
endColumn: number;
className: string;
}> {
if (!meta) {
return [];
}
const inlineHighlightLines = getInlineHighlights(meta, code);
const inlineHighlightConfig = inlineHighlightLines.map(
(line: InlineHiglight) => ({
...line,
elementAttributes: {'data-step': `${line.step}`},
className: cn(
'code-step bg-opacity-10 dark:bg-opacity-20 relative rounded px-1 py-[1.5px] border-b-[2px] border-opacity-60',
{
'bg-blue-40 border-blue-40 text-blue-60 dark:text-blue-30 font-bold':
line.step === 1,
'bg-yellow-40 border-yellow-40 text-yellow-60 dark:text-yellow-30 font-bold':
line.step === 2,
'bg-purple-40 border-purple-40 text-purple-60 dark:text-purple-30 font-bold':
line.step === 3,
'bg-green-40 border-green-40 text-green-60 dark:text-green-30 font-bold':
line.step === 4,
}
),
})
);
return inlineHighlightConfig;
}

/**
*
* @param meta string provided after the language in a markdown block
Expand Down
6 changes: 4 additions & 2 deletions beta/src/styles/sandpack.css
Original file line number Diff line number Diff line change
Expand Up @@ -227,8 +227,9 @@ html.dark .sandpack--playground .sp-overlay {
padding: 0;
}

.sandpack--codeblock .cm-content.cm-readonly .cm-line {
padding: 0 var(--sp-space-3);
.sandpack--codeblock .cm-line {
margin-left: -20px;
padding-left: 20px;
}

/**
Expand All @@ -239,6 +240,7 @@ html.dark .sandpack--playground .sp-overlay {
font-size: 13.6px;
line-height: 24px;
padding: 18px 0;
-webkit-font-smoothing: auto;
}

.sandpack--codeblock .sp-code-editor .sp-pre-placeholder {
Expand Down