diff --git a/README.md b/README.md
index b287380c7..ae2fcad23 100644
--- a/README.md
+++ b/README.md
@@ -11,9 +11,14 @@
- [ ] Code block syntax highlighting
- Theme
- [ ] Toggler
+- Page feedback
+- Edit on GitHub
+- Header links
## Differences
-- Full-width mode:
+- Changed: Full-width mode:
- When off, layout is centered
- It now detects based on the page
+- New: Scroll to top button in page aside
+- New: Proper RTL support
\ No newline at end of file
diff --git a/src/components/PageAside/PageAside.tsx b/src/components/PageAside/PageAside.tsx
new file mode 100644
index 000000000..8bec0459c
--- /dev/null
+++ b/src/components/PageAside/PageAside.tsx
@@ -0,0 +1,49 @@
+import { getDocumentSections } from "@/lib/document";
+import { tcls } from "@/lib/tailwind";
+import { RevisionPageDocument } from "@gitbook/api";
+import React from "react";
+import { ScrollSectionsList } from "./ScrollSectionsList";
+
+/**
+ * Aside listing the headings in the document.
+ */
+export function PageAside(props: {
+ page: RevisionPageDocument;
+ document: any;
+}) {
+ const { document } = props;
+ const sections = getDocumentSections(document);
+
+ return (
+
+ )
+}
\ No newline at end of file
diff --git a/src/components/PageAside/ScrollSectionsList.tsx b/src/components/PageAside/ScrollSectionsList.tsx
new file mode 100644
index 000000000..fa216bb36
--- /dev/null
+++ b/src/components/PageAside/ScrollSectionsList.tsx
@@ -0,0 +1,39 @@
+'use client';
+
+import { DocumentSection } from "@/lib/document";
+import { tcls } from "@/lib/tailwind";
+import Link from "next/link";
+import React from "react";
+import { IconChevronRight } from "@/components/icons/IconChevronRight";
+
+export function ScrollSectionsList(props: {
+ sections: DocumentSection[];
+}) {
+ const { sections } = props;
+ const [activeId, setActiveId] = React.useState(null);
+
+ React.useEffect(() => {
+ //
+ }, []);
+
+ return (
+
+ {sections.map(section => (
+ -
+ 1 ? ['font-light', 'ps-3', 'py-1', 'text-slate-500', ] : ['py-2', 'text-slate-700', ],
+
+ activeId === section.id ? 'text-primary-500' : 'hover:text-slate-900',
+ )}>
+ {section.depth > 1 ? (
+
+ ) : null}
+ {section.title}
+
+
+ ))}
+
+ )
+}
\ No newline at end of file
diff --git a/src/components/PageAside/index.ts b/src/components/PageAside/index.ts
new file mode 100644
index 000000000..9ce564872
--- /dev/null
+++ b/src/components/PageAside/index.ts
@@ -0,0 +1 @@
+export * from './PageAside';
diff --git a/src/components/SpaceContent/SpaceContent.tsx b/src/components/SpaceContent/SpaceContent.tsx
index 1d190670f..3e11b16f2 100644
--- a/src/components/SpaceContent/SpaceContent.tsx
+++ b/src/components/SpaceContent/SpaceContent.tsx
@@ -9,6 +9,7 @@ import { SearchModal } from '@/components/Search';
import { Footer } from '@/components/Footer';
import { hasFullWidthBlock } from '@/lib/document';
import { CONTAINER_MAX_WIDTH_NORMAL, CONTAINER_PADDING } from '@/components/layout';
+import { PageAside } from '../PageAside';
/**
* Render the entire content of the space (header, table of contents, footer, and page content).
@@ -36,6 +37,7 @@ export function SpaceContent(props: {
>
+
diff --git a/src/lib/document.ts b/src/lib/document.ts
index 772a68e5e..e39aec470 100644
--- a/src/lib/document.ts
+++ b/src/lib/document.ts
@@ -1,3 +1,10 @@
+
+export interface DocumentSection {
+ id: string;
+ title: string;
+ depth: number;
+}
+
/**
* Check if the document contains one block that should be rendered in full-width mode.
*/
@@ -6,3 +13,44 @@ export function hasFullWidthBlock(document: any): boolean {
return node.data.fullWidth || node.type === 'images';
});
}
+
+/**
+ * Extract a list of sections from a document.
+ */
+export function getDocumentSections(document: any): DocumentSection[] {
+ const sections: DocumentSection[] = [];
+ let depth = 0;
+
+ document.nodes.forEach(block => {
+ if (block.type === 'heading-1' || block.type === 'heading-2' || block.type === 'heading-3') {
+ if (block.type === 'heading-1') {
+ depth = 1;
+ }
+
+ const title = getNodeText(block);
+
+ sections.push({
+ id: block.data.id ?? title, // TODO: use slugify
+ title,
+ depth: block.type === 'heading-1' ? 1 : (depth > 0 ? 2 : 1),
+ });
+ }
+ });
+
+ return sections;
+}
+
+/**
+ * Get the text of a block/inline.
+ */
+export function getNodeText(node: any): string {
+ switch (node.object) {
+ case 'text':
+ return node.leaves.map((leaf) => leaf.text).join('');
+ case 'block':
+ case 'inline':
+ return node.nodes.map(child => getNodeText(child)).join('');
+ default:
+ throw new Error('Invalid node');
+ }
+}