diff --git a/e2e/fixtures/code-block-runtime/index.test.ts b/e2e/fixtures/code-block-runtime/index.test.ts
index 851335d7d..91c50293d 100644
--- a/e2e/fixtures/code-block-runtime/index.test.ts
+++ b/e2e/fixtures/code-block-runtime/index.test.ts
@@ -31,7 +31,7 @@ test.describe('
+ ,
},
Fragment,
});
diff --git a/packages/theme-default/src/components/DocContent/doc.scss b/packages/theme-default/src/components/DocContent/doc.scss
index b336d6103..19ae1b878 100644
--- a/packages/theme-default/src/components/DocContent/doc.scss
+++ b/packages/theme-default/src/components/DocContent/doc.scss
@@ -242,7 +242,7 @@
}
// #endregion inline code
- &:where(div[class^='language-']) {
+ &:where(.rp-codeblock) {
position: relative;
margin: 16px 0;
border: var(--rp-code-block-border);
@@ -304,14 +304,14 @@
}
}
- &:where(.rspress-code-title) {
+ &:where(.rp-codeblock__title) {
font-family: var(--rp-font-family-mono);
padding: 12px 16px;
font-size: var(--rp-code-font-size);
background-color: var(--rp-code-title-bg);
}
- &:where(.rspress-code-content) {
+ &:where(.rp-codeblock__content) {
font-size: var(--rp-code-font-size);
font-family: var(--rp-font-family-mono);
position: relative;
diff --git a/packages/theme-default/src/components/DocContent/docComponents/codeblock/CodeButtonGroup.scss b/packages/theme-default/src/components/DocContent/docComponents/codeblock/CodeButtonGroup.scss
index bcaf23631..64a08f196 100644
--- a/packages/theme-default/src/components/DocContent/docComponents/codeblock/CodeButtonGroup.scss
+++ b/packages/theme-default/src/components/DocContent/docComponents/codeblock/CodeButtonGroup.scss
@@ -1,4 +1,4 @@
-.rspress-code-content:hover .rp-code-button-group__button {
+.rp-codeblock__content:hover .rp-code-button-group__button {
opacity: 1;
}
diff --git a/packages/theme-default/src/components/DocContent/docComponents/codeblock/pre.tsx b/packages/theme-default/src/components/DocContent/docComponents/codeblock/pre.tsx
index d092ee324..e49c28782 100644
--- a/packages/theme-default/src/components/DocContent/docComponents/codeblock/pre.tsx
+++ b/packages/theme-default/src/components/DocContent/docComponents/codeblock/pre.tsx
@@ -32,15 +32,19 @@ function ShikiPre({
}: ShikiPreProps) {
const { codeWrap, toggleCodeWrap } = useCodeWrap();
return (
-
{child}
@@ -72,16 +76,16 @@ export interface PreWithCodeButtonGroupProps
* expected wrapped pre element is:
* ```html
*
- * test.js
- *
+ * test.js
+ *
*
- *
+ *
*
*
*
*
*
*
diff --git a/packages/theme-default/src/components/FileTree/index.scss b/packages/theme-default/src/components/FileTree/index.scss
new file mode 100644
index 000000000..f73c6b61a
--- /dev/null
+++ b/packages/theme-default/src/components/FileTree/index.scss
@@ -0,0 +1,114 @@
+.rp-file-tree {
+ display: flex;
+ border: var(--rp-code-block-border);
+ border-radius: var(--rp-radius);
+ overflow: hidden;
+ background-color: var(--rp-code-block-bg);
+
+ &__sidebar {
+ flex: 0 0 220px;
+ box-sizing: border-box;
+ max-width: 100%;
+ border-right: var(--rp-code-block-border);
+ background-color: var(--rp-code-title-bg);
+ padding: 12px 16px;
+ overflow-y: auto;
+ }
+
+ &__content {
+ flex: 1 1 auto;
+ min-width: 0;
+ padding: 12px 16px;
+ background-color: var(--rp-code-block-bg);
+ overflow-x: auto;
+
+ .rp-codeblock {
+ border: none;
+ border-radius: 0;
+ padding: 0;
+ margin: 0;
+ .rp-codeblock__title {
+ display: none;
+ }
+ }
+ }
+
+ &__list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+
+ & & {
+ margin-top: 4px;
+ padding-left: 12px;
+ border-left: 1px solid rgba(0, 0, 0, 0.05);
+ }
+
+ .dark & & {
+ border-left: 1px solid rgba(255, 255, 255, 0.08);
+ }
+ }
+
+ &__item {
+ margin: 2px 0;
+ }
+
+ &__dir {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--rp-c-text-2);
+ padding: 4px 0;
+
+ &--active {
+ color: var(--rp-c-text-1);
+ }
+ }
+
+ &__file {
+ width: 100%;
+ text-align: left;
+ border: none;
+ background: none;
+ padding: 6px 8px;
+ border-radius: var(--rp-radius-small);
+ color: var(--rp-c-text-1);
+ font-size: 13px;
+ line-height: 1.4;
+ cursor: pointer;
+ transition:
+ background-color 0.2s ease,
+ color 0.2s ease;
+
+ &:hover,
+ &:focus {
+ background-color: var(--rp-c-brand-tint);
+ color: var(--rp-c-text-1);
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--rp-c-brand);
+ outline-offset: 2px;
+ }
+
+ &--active {
+ background-color: var(--rp-c-brand-tint);
+ color: var(--rp-c-brand-dark);
+ font-weight: 600;
+ }
+ }
+
+ @media (max-width: 960px) {
+ flex-direction: column;
+
+ &__sidebar {
+ flex: none;
+ width: 100%;
+ border-right: none;
+ border-bottom: var(--rp-code-block-border);
+ }
+
+ &__content {
+ padding-top: 8px;
+ }
+ }
+}
diff --git a/packages/theme-default/src/components/FileTree/index.tsx b/packages/theme-default/src/components/FileTree/index.tsx
new file mode 100644
index 000000000..591d0e006
--- /dev/null
+++ b/packages/theme-default/src/components/FileTree/index.tsx
@@ -0,0 +1,216 @@
+import clsx from 'clsx';
+import {
+ Children,
+ type CSSProperties,
+ cloneElement,
+ isValidElement,
+ type ReactElement,
+ type ReactNode,
+ useEffect,
+ useMemo,
+ useState,
+} from 'react';
+import './index.scss';
+
+interface FileEntry {
+ id: string;
+ path: string;
+ label: string;
+ segments: string[];
+ fullTitle: string;
+ element: ReactElement;
+}
+
+interface TreeNode {
+ name: string;
+ path: string;
+ isFile: boolean;
+ fileId?: string;
+ children: TreeNode[];
+}
+
+export interface FileTreeProps {
+ children: ReactNode;
+ className?: string;
+ style?: CSSProperties;
+}
+
+function buildTree(entries: FileEntry[]): TreeNode[] {
+ const root: TreeNode = {
+ name: '',
+ path: '',
+ isFile: false,
+ children: [],
+ };
+
+ entries.forEach(entry => {
+ let current = root;
+ entry.segments.forEach((segment, index) => {
+ const isLast = index === entry.segments.length - 1;
+ const nextPath = current.path ? `${current.path}/${segment}` : segment;
+ let child = current.children.find(
+ node => node.name === segment && node.isFile === isLast,
+ );
+
+ if (!child) {
+ child = {
+ name: segment,
+ path: nextPath,
+ isFile: isLast,
+ fileId: isLast ? entry.id : undefined,
+ children: [],
+ };
+ current.children.push(child);
+ }
+
+ if (isLast) {
+ child.isFile = true;
+ child.fileId = entry.id;
+ }
+
+ current = child;
+ });
+ });
+
+ return root.children;
+}
+
+export function FileTree({ children, className, style }: FileTreeProps) {
+ const entries = useMemo(() => {
+ const normalized: FileEntry[] = [];
+
+ Children.forEach(children, (child, index) => {
+ if (!isValidElement(child)) return;
+ console.log(child.props, 111111);
+
+ const { title: rawTitle } = (child.props as any).children?.props as {
+ title?: string;
+ };
+ const trimmedTitle = typeof rawTitle === 'string' ? rawTitle.trim() : '';
+ const fallbackLabel = `File ${normalized.length + 1}`;
+ const sourceForSegments = trimmedTitle || fallbackLabel;
+ const segments = sourceForSegments
+ .split('/')
+ .map(segment => segment.trim())
+ .filter(Boolean);
+
+ if (!segments.length) {
+ segments.push(fallbackLabel);
+ }
+
+ const path = segments.join('/');
+ const label = segments[segments.length - 1];
+ const fullTitle = trimmedTitle || path;
+
+ normalized.push({
+ id: String(index),
+ path,
+ label,
+ segments,
+ fullTitle,
+ element: child,
+ });
+ });
+
+ return normalized;
+ }, [children]);
+
+ const entryMap = useMemo(() => {
+ return new Map(entries.map(entry => [entry.id, entry]));
+ }, [entries]);
+
+ const [activeId, setActiveId] = useState(entries[0]?.id);
+
+ useEffect(() => {
+ if (!entries.length) {
+ if (activeId !== undefined) {
+ setActiveId(undefined);
+ }
+ return;
+ }
+
+ const hasActiveEntry = activeId
+ ? entries.some(entry => entry.id === activeId)
+ : false;
+
+ if (!hasActiveEntry) {
+ setActiveId(entries[0]?.id);
+ }
+ }, [entries, activeId]);
+
+ const activeEntry = activeId ? entryMap.get(activeId) : undefined;
+ const activePath = activeEntry?.path ?? '';
+ const tree = useMemo(() => buildTree(entries), [entries]);
+
+ const renderNodes = (nodes: TreeNode[]): ReactNode => {
+ if (!nodes.length) return null;
+
+ return (
+
+ {nodes.map(node => {
+ const isActiveFile = node.isFile && node.fileId === activeId;
+ const isActiveBranch =
+ !node.isFile &&
+ Boolean(activePath) &&
+ (activePath === node.path ||
+ activePath.startsWith(`${node.path}/`));
+
+ if (node.isFile) {
+ const entry = node.fileId ? entryMap.get(node.fileId) : undefined;
+ return (
+ -
+
+
+ );
+ }
+
+ return (
+ -
+
+ {node.name}
+
+ {renderNodes(node.children)}
+
+ );
+ })}
+
+ );
+ };
+
+ if (!entries.length) {
+ return <>{children}>;
+ }
+
+ const content = activeEntry
+ ? cloneElement(activeEntry.element, { key: activeEntry.id })
+ : null;
+
+ return (
+
+
+ {content}
+
+ );
+}
diff --git a/packages/theme-default/src/index.ts b/packages/theme-default/src/index.ts
index 9dc7493cc..72327e0b7 100644
--- a/packages/theme-default/src/index.ts
+++ b/packages/theme-default/src/index.ts
@@ -22,6 +22,7 @@ export { DocContent } from './components/DocContent/index';
export { DocFooter } from './components/DocFooter/index';
export { EditLink } from './components/EditLink/index';
export { useEditLink } from './components/EditLink/useEditLink';
+export { FileTree, type FileTreeProps } from './components/FileTree/index';
export { HomeBackground } from './components/HomeBackground/index';
export { HomeFeature } from './components/HomeFeature/index';
export { HomeFooter } from './components/HomeFooter/index';
diff --git a/packages/theme-default/src/styles/shiki.scss b/packages/theme-default/src/styles/shiki.scss
index 0166adca1..91fa17ecb 100644
--- a/packages/theme-default/src/styles/shiki.scss
+++ b/packages/theme-default/src/styles/shiki.scss
@@ -36,34 +36,32 @@
color: #f47481;
}
-.has-diff [class*='language-'] .diff.add {
+.shiki.has-diff code .diff.add {
background-color: rgba(16, 185, 129, 0.1);
padding: 0 20px 0 19px;
}
-[class*='language-'] code .diff.remove {
+.shiki.has-diff code .diff.remove {
background-color: rgba(244, 63, 94, 0.1);
padding: 0 20px 0 19px;
}
-.rspress-code-content {
- .has-highlighted [class*='language-'] {
- .line.highlighted {
- width: 100%;
- position: static;
- display: inline-block;
- background-color: rgba(0, 99, 199, 0.1);
- }
- .line.highlighted.error {
- background-color: rgba(244, 63, 94, 0.1);
- }
- .line.highlighted.warning {
- background-color: rgba(234, 179, 8, 0.1);
- }
+.shiki.has-highlighted {
+ .line.highlighted {
+ width: 100%;
+ position: static;
+ display: inline-block;
+ background-color: rgba(0, 99, 199, 0.1);
+ }
+ .line.highlighted.error {
+ background-color: rgba(244, 63, 94, 0.1);
+ }
+ .line.highlighted.warning {
+ background-color: rgba(234, 179, 8, 0.1);
}
}
-.has-focused [class*='language-'] {
+.shiki.has-focused {
.line:not(.focused) {
filter: blur(0.095rem);
opacity: 0.4;
@@ -79,7 +77,7 @@
}
}
-.has-line-number code {
+.shiki.has-line-number code {
counter-reset: step;
counter-increment: step 0;
& .line-number::before {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 87ca947e4..9978bd076 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -151,6 +151,19 @@ importers:
specifier: ^22.8.1
version: 22.10.2
+ e2e/fixtures/code-block:
+ dependencies:
+ '@rspress/core':
+ specifier: workspace:*
+ version: link:../../../packages/core
+ '@shikijs/transformers':
+ specifier: ^3.12.2
+ version: 3.12.2
+ devDependencies:
+ '@types/node':
+ specifier: ^22.8.1
+ version: 22.10.2
+
e2e/fixtures/code-block-runtime:
dependencies:
'@rspress/core':
@@ -529,19 +542,6 @@ importers:
specifier: ^22.8.1
version: 22.10.2
- e2e/fixtures/plugin-shiki:
- dependencies:
- '@rspress/core':
- specifier: workspace:*
- version: link:../../../packages/core
- '@shikijs/transformers':
- specifier: ^3.12.2
- version: 3.12.2
- devDependencies:
- '@types/node':
- specifier: ^22.8.1
- version: 22.10.2
-
e2e/fixtures/plugin-twoslash:
dependencies:
'@rspress/core':
diff --git a/website/docs/components/LiveCodeEditor.module.scss b/website/docs/components/LiveCodeEditor.module.scss
index d170bf30d..b46aae2a6 100644
--- a/website/docs/components/LiveCodeEditor.module.scss
+++ b/website/docs/components/LiveCodeEditor.module.scss
@@ -7,7 +7,7 @@
position: relative;
width: 100%;
overflow: auto;
- div[class^='language-'] {
+ :global(.rp-codeblock) {
margin: 0;
}
}