refactor(live-codeblock): refactor live code block theme components (#11077)

This commit is contained in:
Sébastien Lorber 2025-04-10 15:55:02 +02:00 committed by GitHub
parent 387157205a
commit 29d19a6884
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 354 additions and 180 deletions

View file

@ -25,6 +25,7 @@ const plugin: Plugin<unknown[], Root> = function plugin(): Transformer<Root> {
node.data.hProperties = node.data.hProperties || {};
node.data.hProperties.metastring = node.meta;
// TODO Docusaurus v4: remove special case
// Retrocompatible support for live codeblock metastring
// Not really the appropriate place to handle that :s
node.data.hProperties.live = node.meta?.split(' ').includes('live');

View file

@ -104,6 +104,7 @@ export type TableOfContents = {
maxHeadingLevel: number;
};
// TODO Docusaurus v4: use interface + declaration merging to enhance
// Theme config after validation/normalization
export type ThemeConfig = {
docs: {

View file

@ -16,6 +16,14 @@ declare module '@docusaurus/theme-live-codeblock' {
};
}
declare module '@theme/LiveCodeBlock' {
import type {Props as BaseProps} from '@theme/CodeBlock';
export interface Props extends BaseProps {}
export default function LiveCodeBlock(props: Props): ReactNode;
}
declare module '@theme/Playground' {
import type {ReactNode} from 'react';
import type {Props as BaseProps} from '@theme/CodeBlock';
@ -31,6 +39,64 @@ declare module '@theme/Playground' {
export default function Playground(props: LiveProviderProps): ReactNode;
}
declare module '@theme/Playground/Provider' {
import type {ReactNode} from 'react';
import type {Props as PlaygroundProps} from '@theme/Playground';
export interface Props extends Omit<PlaygroundProps, 'children'> {
code: string | undefined;
children: ReactNode;
}
export default function PlaygroundProvider(props: Props): ReactNode;
}
declare module '@theme/Playground/Container' {
import type {ReactNode} from 'react';
export interface Props {
children: ReactNode;
}
export default function PlaygroundContainer(props: Props): ReactNode;
}
declare module '@theme/Playground/Layout' {
import type {ReactNode} from 'react';
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface Props {}
export default function PlaygroundLayout(props: Props): ReactNode;
}
declare module '@theme/Playground/Preview' {
import type {ReactNode} from 'react';
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface Props {}
export default function PlaygroundPreview(props: Props): ReactNode;
}
declare module '@theme/Playground/Editor' {
import type {ReactNode} from 'react';
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface Props {}
export default function PlaygroundEditor(props: Props): ReactNode;
}
declare module '@theme/Playground/Header' {
import type {ReactNode} from 'react';
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface Props {}
export default function PlaygroundHeader(props: Props): ReactNode;
}
declare module '@theme/ReactLiveScope' {
type Scope = {
[key: string]: unknown;

View file

@ -5,21 +5,28 @@
* LICENSE file in the root directory of this source tree.
*/
import React from 'react';
import Playground from '@theme/Playground';
import ReactLiveScope from '@theme/ReactLiveScope';
import CodeBlock, {type Props} from '@theme-init/CodeBlock';
import React, {type ReactNode} from 'react';
import type {Props as CodeBlockProps} from '@theme/CodeBlock';
import OriginalCodeBlock from '@theme-init/CodeBlock';
import LiveCodeBlock from '@theme/LiveCodeBlock';
const withLiveEditor = (Component: typeof CodeBlock) => {
function WrappedComponent(props: Props) {
if (props.live) {
return <Playground scope={ReactLiveScope} {...props} />;
}
return <Component {...props} />;
// TODO Docusaurus v4: remove special case
// see packages/docusaurus-mdx-loader/src/remark/mdx1Compat/codeCompatPlugin.ts
// we can just use the metastring instead
declare module '@theme/CodeBlock' {
interface Props {
live?: boolean;
}
}
return WrappedComponent;
};
function isLiveCodeBlock(props: CodeBlockProps): boolean {
return !!props.live;
}
export default withLiveEditor(CodeBlock);
export default function CodeBlockEnhancer(props: CodeBlockProps): ReactNode {
return isLiveCodeBlock(props) ? (
<LiveCodeBlock {...props} />
) : (
<OriginalCodeBlock {...props} />
);
}

View file

@ -0,0 +1,15 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React, {type ReactNode} from 'react';
import Playground from '@theme/Playground';
import ReactLiveScope from '@theme/ReactLiveScope';
import type {Props} from '@theme/LiveCodeBlock';
export default function LiveCodeBlock(props: Props): ReactNode {
return <Playground scope={ReactLiveScope} {...props} />;
}

View file

@ -0,0 +1,16 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React, {type ReactNode} from 'react';
import type {Props} from '@theme/Playground/Container';
import styles from './styles.module.css';
export default function PlaygroundContainer({children}: Props): ReactNode {
return <div className={styles.playgroundContainer}>{children}</div>;
}

View file

@ -0,0 +1,13 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
.playgroundContainer {
margin-bottom: var(--ifm-leading);
border-radius: var(--ifm-global-radius);
box-shadow: var(--ifm-global-shadow-lw);
overflow: hidden;
}

View file

@ -0,0 +1,35 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React, {type ReactNode} from 'react';
import {LiveEditor} from 'react-live';
import useIsBrowser from '@docusaurus/useIsBrowser';
import Translate from '@docusaurus/Translate';
import PlaygroundHeader from '@theme/Playground/Header';
import styles from './styles.module.css';
export default function PlaygroundEditor(): ReactNode {
const isBrowser = useIsBrowser();
return (
<>
<PlaygroundHeader>
<Translate
id="theme.Playground.liveEditor"
description="The live editor label of the live codeblocks">
Live Editor
</Translate>
</PlaygroundHeader>
<LiveEditor
// We force remount the editor on hydration,
// otherwise dark prism theme is not applied
key={String(isBrowser)}
className={styles.playgroundEditor}
/>
</>
);
}

View file

@ -0,0 +1,17 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
.playgroundEditor {
font: var(--ifm-code-font-size) / var(--ifm-pre-line-height)
var(--ifm-font-family-monospace) !important;
/* rtl:ignore */
direction: ltr;
}
.playgroundEditor pre {
border-radius: 0;
}

View file

@ -0,0 +1,19 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React, {type ReactNode} from 'react';
import clsx from 'clsx';
import styles from './styles.module.css';
export default function PlaygroundHeader({
children,
}: {
children: ReactNode;
}): ReactNode {
return <div className={clsx(styles.playgroundHeader)}>{children}</div>;
}

View file

@ -5,13 +5,6 @@
* LICENSE file in the root directory of this source tree.
*/
.playgroundContainer {
margin-bottom: var(--ifm-leading);
border-radius: var(--ifm-global-radius);
box-shadow: var(--ifm-global-shadow-lw);
overflow: hidden;
}
.playgroundHeader {
letter-spacing: 0.08rem;
padding: 0.75rem;
@ -26,19 +19,3 @@
background: var(--ifm-color-emphasis-600);
color: var(--ifm-color-content-inverse);
}
.playgroundEditor {
font: var(--ifm-code-font-size) / var(--ifm-pre-line-height)
var(--ifm-font-family-monospace) !important;
/* rtl:ignore */
direction: ltr;
}
.playgroundEditor pre {
border-radius: 0;
}
.playgroundPreview {
padding: 1rem;
background-color: var(--ifm-pre-background);
}

View file

@ -0,0 +1,37 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React, {type ReactNode} from 'react';
import {useThemeConfig} from '@docusaurus/theme-common';
import PlaygroundPreview from '@theme/Playground/Preview';
import PlaygroundEditor from '@theme/Playground/Editor';
import type {ThemeConfig} from '@docusaurus/theme-live-codeblock';
function useLiveCodeBlockThemeConfig() {
const themeConfig = useThemeConfig() as unknown as ThemeConfig;
return themeConfig.liveCodeBlock;
}
export default function PlaygroundLayout(): ReactNode {
const {playgroundPosition} = useLiveCodeBlockThemeConfig();
return (
<>
{playgroundPosition === 'top' ? (
<>
<PlaygroundPreview />
<PlaygroundEditor />
</>
) : (
<>
<PlaygroundEditor />
<PlaygroundPreview />
</>
)}
</>
);
}

View file

@ -0,0 +1,59 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React, {type ReactNode} from 'react';
import {LiveError, LivePreview} from 'react-live';
import BrowserOnly from '@docusaurus/BrowserOnly';
import {ErrorBoundaryErrorMessageFallback} from '@docusaurus/theme-common';
import ErrorBoundary from '@docusaurus/ErrorBoundary';
import Translate from '@docusaurus/Translate';
import PlaygroundHeader from '@theme/Playground/Header';
import styles from './styles.module.css';
function Loader() {
// Is it worth improving/translating?
// eslint-disable-next-line @docusaurus/no-untranslated-text
return <div>Loading...</div>;
}
function PlaygroundLivePreview(): ReactNode {
// No SSR for the live preview
// See https://github.com/facebook/docusaurus/issues/5747
return (
<BrowserOnly fallback={<Loader />}>
{() => (
<>
<ErrorBoundary
fallback={(params) => (
<ErrorBoundaryErrorMessageFallback {...params} />
)}>
<LivePreview />
</ErrorBoundary>
<LiveError />
</>
)}
</BrowserOnly>
);
}
export default function PlaygroundPreview(): ReactNode {
return (
<>
<PlaygroundHeader>
<Translate
id="theme.Playground.result"
description="The result label of the live codeblocks">
Result
</Translate>
</PlaygroundHeader>
<div className={styles.playgroundPreview}>
<PlaygroundLivePreview />
</div>
</>
);
}

View file

@ -0,0 +1,11 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
.playgroundPreview {
padding: 1rem;
background-color: var(--ifm-pre-background);
}

View file

@ -0,0 +1,35 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React, {type ReactNode} from 'react';
import {LiveProvider} from 'react-live';
import {usePrismTheme} from '@docusaurus/theme-common';
import type {Props} from '@theme/Playground/Provider';
// this should rather be a stable function
// see https://github.com/facebook/docusaurus/issues/9630#issuecomment-1855682643
const DEFAULT_TRANSFORM_CODE = (code: string) => `${code};`;
export default function PlaygroundProvider({
code,
children,
...props
}: Props): ReactNode {
const prismTheme = usePrismTheme();
const noInline = props.metastring?.includes('noInline') ?? false;
return (
<LiveProvider
noInline={noInline}
theme={prismTheme}
{...props}
code={code?.replace(/\n$/, '')}
transformCode={props.transformCode ?? DEFAULT_TRANSFORM_CODE}>
{children}
</LiveProvider>
);
}

View file

@ -6,137 +6,22 @@
*/
import React, {type ReactNode} from 'react';
import clsx from 'clsx';
import useIsBrowser from '@docusaurus/useIsBrowser';
import {LiveProvider, LiveEditor, LiveError, LivePreview} from 'react-live';
import Translate from '@docusaurus/Translate';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import BrowserOnly from '@docusaurus/BrowserOnly';
import {
ErrorBoundaryErrorMessageFallback,
usePrismTheme,
} from '@docusaurus/theme-common';
import ErrorBoundary from '@docusaurus/ErrorBoundary';
import PlaygroundProvider from '@theme/Playground/Provider';
import PlaygroundContainer from '@theme/Playground/Container';
import PlaygroundLayout from '@theme/Playground/Layout';
import type {Props} from '@theme/Playground';
import type {ThemeConfig} from '@docusaurus/theme-live-codeblock';
import styles from './styles.module.css';
function Header({children}: {children: ReactNode}) {
return <div className={clsx(styles.playgroundHeader)}>{children}</div>;
}
function LivePreviewLoader() {
// Is it worth improving/translating?
// eslint-disable-next-line @docusaurus/no-untranslated-text
return <div>Loading...</div>;
}
function Preview() {
// No SSR for the live preview
// See https://github.com/facebook/docusaurus/issues/5747
return (
<BrowserOnly fallback={<LivePreviewLoader />}>
{() => (
<>
<ErrorBoundary
fallback={(params) => (
<ErrorBoundaryErrorMessageFallback {...params} />
)}>
<LivePreview />
</ErrorBoundary>
<LiveError />
</>
)}
</BrowserOnly>
);
}
function ResultWithHeader() {
return (
<>
<Header>
<Translate
id="theme.Playground.result"
description="The result label of the live codeblocks">
Result
</Translate>
</Header>
{/* https://github.com/facebook/docusaurus/issues/5747 */}
<div className={styles.playgroundPreview}>
<Preview />
</div>
</>
);
}
function ThemedLiveEditor() {
const isBrowser = useIsBrowser();
return (
<LiveEditor
// We force remount the editor on hydration,
// otherwise dark prism theme is not applied
key={String(isBrowser)}
className={styles.playgroundEditor}
/>
);
}
function EditorWithHeader() {
return (
<>
<Header>
<Translate
id="theme.Playground.liveEditor"
description="The live editor label of the live codeblocks">
Live Editor
</Translate>
</Header>
<ThemedLiveEditor />
</>
);
}
// this should rather be a stable function
// see https://github.com/facebook/docusaurus/issues/9630#issuecomment-1855682643
const DEFAULT_TRANSFORM_CODE = (code: string) => `${code};`;
export default function Playground({
children,
transformCode,
...props
}: Props): ReactNode {
const {
siteConfig: {themeConfig},
} = useDocusaurusContext();
const {
liveCodeBlock: {playgroundPosition},
} = themeConfig as ThemeConfig;
const prismTheme = usePrismTheme();
const noInline = props.metastring?.includes('noInline') ?? false;
return (
<div className={styles.playgroundContainer}>
<LiveProvider
code={children?.replace(/\n$/, '')}
noInline={noInline}
transformCode={transformCode ?? DEFAULT_TRANSFORM_CODE}
theme={prismTheme}
{...props}>
{playgroundPosition === 'top' ? (
<>
<ResultWithHeader />
<EditorWithHeader />
</>
) : (
<>
<EditorWithHeader />
<ResultWithHeader />
</>
)}
</LiveProvider>
</div>
<PlaygroundContainer>
<PlaygroundProvider code={children} {...props}>
<PlaygroundLayout />
</PlaygroundProvider>
</PlaygroundContainer>
);
}

View file

@ -1,20 +0,0 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/// <reference types="@docusaurus/theme-classic" />
/// <reference types="@docusaurus/module-type-aliases" />
declare module '@theme-init/CodeBlock' {
import type CodeBlock from '@theme/CodeBlock';
import type {Props as BaseProps} from '@theme/CodeBlock';
export interface Props extends BaseProps {
live?: boolean;
}
const CodeBlockComp: typeof CodeBlock;
export default CodeBlockComp;
}