diff --git a/src/App.tsx b/src/App.tsx
index cb53e1e..08e1553 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -3,11 +3,12 @@ import { useLocation } from "@solidjs/router";
// 导入组件以注册自定义元素
import "./components";
-import { Article } from "./components";
+import { Article, Sidebar } from "./components";
const App: Component = () => {
const location = useLocation();
const [currentPath, setCurrentPath] = createSignal("");
+ const [isSidebarOpen, setIsSidebarOpen] = createSignal(false);
onMount(() => {
// 根据路由加载对应的 markdown 文件
@@ -22,8 +23,19 @@ const App: Component = () => {
return (
-
-
+
setIsSidebarOpen(false)}
+ />
+
diff --git a/src/components/Article.tsx b/src/components/Article.tsx
index 81a2ad1..a45aea9 100644
--- a/src/components/Article.tsx
+++ b/src/components/Article.tsx
@@ -1,6 +1,6 @@
import { Component, createSignal, onMount, onCleanup, Show } from 'solid-js';
import { parseMarkdown } from '../markdown';
-import { fetchData } from '../data-loader';
+import { fetchData, extractSection } from '../data-loader';
export interface ArticleProps {
src: string;
@@ -9,51 +9,6 @@ export interface ArticleProps {
onError?: (error: Error) => void;
}
-/**
- * 从 markdown 内容中提取指定标题下的内容
- */
-function extractSection(content: string, sectionTitle: string): string {
- // 匹配标题(支持 1-6 级标题)
- const sectionRegex = new RegExp(
- `^(#{1,6})\\s*${escapeRegExp(sectionTitle)}\\s*$`,
- 'im'
- );
-
- const match = content.match(sectionRegex);
- if (!match) {
- // 没有找到指定标题,返回空字符串
- return '';
- }
-
- const headerLevel = match[1].length;
- const startIndex = match.index!;
-
- // 查找下一个同级或更高级别的标题
- const nextHeaderRegex = new RegExp(
- `^#{1,${headerLevel}}\\s+.+$`,
- 'gm'
- );
-
- // 从标题后开始搜索
- nextHeaderRegex.lastIndex = startIndex + match[0].length;
- const nextMatch = nextHeaderRegex.exec(content);
-
- if (nextMatch) {
- // 返回从当前标题到下一个同级/更高级标题之间的内容
- return content.slice(startIndex, nextMatch.index).trim();
- }
-
- // 返回从当前标题到文件末尾的内容
- return content.slice(startIndex).trim();
-}
-
-/**
- * 转义正则表达式特殊字符
- */
-function escapeRegExp(str: string): string {
- return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
-}
-
/**
* Article 组件
* 用于将特定 src 位置的 md 文件显示为 markdown 文章
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx
new file mode 100644
index 0000000..5da73ab
--- /dev/null
+++ b/src/components/Sidebar.tsx
@@ -0,0 +1,200 @@
+import { Component, createSignal, onMount, Show } from "solid-js";
+import { generateToc, type FileNode, type TocNode } from "../data-loader";
+import { useLocation, useNavigate } from "@solidjs/router";
+
+interface SidebarProps {
+ isOpen: boolean;
+ onClose: () => void;
+}
+
+/**
+ * 文件树节点组件
+ */
+const FileTreeNode: Component<{
+ node: FileNode;
+ currentPath: string;
+ pathHeadings: Record;
+ depth: number;
+}> = (props) => {
+ const navigate = useNavigate();
+ const [isExpanded, setIsExpanded] = createSignal(true);
+ const isDir = !!props.node.children;
+ const isActive = props.currentPath === props.node.path;
+
+ const handleClick = () => {
+ if (isDir) {
+ setIsExpanded(!isExpanded());
+ } else {
+ navigate(props.node.path);
+ props.onClose();
+ }
+ };
+
+ const indent = props.depth * 12;
+
+ return (
+
+
+
+
+ {isExpanded() ? "📂" : "📁"}
+
+
+
+ 📄
+
+ {props.node.name}
+
+
+
+ {props.node.children!.map((child) => (
+
+ ))}
+
+
+
+ );
+};
+
+/**
+ * 标题节点组件
+ */
+const HeadingNode: Component<{
+ node: TocNode;
+ basePath: string;
+ depth: number;
+}> = (props) => {
+ const navigate = useNavigate();
+ const anchor = props.node.title.toLowerCase().replace(/\s+/g, "-");
+ const href = `${props.basePath}#${anchor}`;
+
+ const handleClick = (e: MouseEvent) => {
+ e.preventDefault();
+ navigate(href);
+ };
+
+ const indent = props.depth * 12;
+
+ return (
+
+ );
+};
+
+/**
+ * 侧边栏组件
+ */
+export const Sidebar: Component = (props) => {
+ const location = useLocation();
+ const [fileTree, setFileTree] = createSignal([]);
+ const [pathHeadings, setPathHeadings] = createSignal<
+ Record
+ >({});
+ const [currentFileHeadings, setCurrentFileHeadings] = createSignal(
+ [],
+ );
+
+ onMount(() => {
+ const toc = generateToc();
+ setFileTree(toc.fileTree);
+ setPathHeadings(toc.pathHeadings);
+ });
+
+ // 根据当前路径更新当前文件的标题列表
+ onMount(() => {
+ const updateHeadings = () => {
+ const pathname = location.pathname;
+ const headings = pathHeadings()[pathname] || pathHeadings()[`${pathname}.md`];
+ setCurrentFileHeadings(headings || []);
+ };
+ updateHeadings();
+ });
+
+ return (
+
+ {/* 遮罩层 */}
+
+ {/* 侧边栏 */}
+
+
+ );
+};
diff --git a/src/components/index.ts b/src/components/index.ts
index 7507236..bf2a490 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -6,6 +6,8 @@ import './md-link';
// 导出组件
export { Article } from './Article';
export type { ArticleProps } from './Article';
+export { Sidebar } from './Sidebar';
+export type { SidebarProps } from './Sidebar';
// 导出数据类型
export type { DiceProps } from './dice';
diff --git a/src/data-loader/index.ts b/src/data-loader/index.ts
index 5e449a7..d513f41 100644
--- a/src/data-loader/index.ts
+++ b/src/data-loader/index.ts
@@ -1,3 +1,7 @@
+import { buildFileTree, extractHeadings, extractSection, type TocNode, type FileNode } from "./toc";
+
+export { TocNode, FileNode, extractHeadings, buildFileTree, extractSection };
+
let dataIndex: Record | null = null;
let isDevIndexLoaded = false;
let isCliIndexLoaded = false;
@@ -12,6 +16,32 @@ function getIndexedData(path: string): string | null {
return null;
}
+/**
+ * 获取所有索引路径
+ */
+export function getIndexedPaths(): string[] {
+ if (!dataIndex) return [];
+ return Object.keys(dataIndex);
+}
+
+/**
+ * 生成目录树(文件树 + 标题结构)
+ */
+export function generateToc(): { fileTree: FileNode[]; pathHeadings: Record } {
+ const paths = getIndexedPaths();
+ const fileTree = buildFileTree(paths);
+ const pathHeadings: Record = {};
+
+ for (const path of paths) {
+ const content = getIndexedData(path);
+ if (content) {
+ pathHeadings[path] = extractHeadings(content);
+ }
+ }
+
+ return { fileTree, pathHeadings };
+}
+
/**
* 在 dev 环境加载 glob 索引
*/
diff --git a/src/data-loader/toc.ts b/src/data-loader/toc.ts
new file mode 100644
index 0000000..b682d68
--- /dev/null
+++ b/src/data-loader/toc.ts
@@ -0,0 +1,157 @@
+/**
+ * 目录树节点
+ */
+export interface TocNode {
+ title: string;
+ path?: string;
+ level: number;
+ children?: TocNode[];
+}
+
+/**
+ * 文件树节点
+ */
+export interface FileNode {
+ name: string;
+ path: string;
+ children?: FileNode[];
+}
+
+/**
+ * 从 markdown 内容提取标题结构
+ */
+export function extractHeadings(content: string): TocNode[] {
+ const headings: TocNode[] = [];
+ const lines = content.split("\n");
+ const stack: { node: TocNode; level: number }[] = [];
+
+ for (const line of lines) {
+ const match = line.match(/^(#{1,6})\s+(.+)$/);
+ if (!match) continue;
+
+ const level = match[1].length;
+ const title = match[2].trim();
+ const node: TocNode = { title, level };
+
+ // 找到合适的父节点
+ while (stack.length > 0 && stack[stack.length - 1].level >= level) {
+ stack.pop();
+ }
+
+ if (stack.length === 0) {
+ headings.push(node);
+ } else {
+ const parent = stack[stack.length - 1].node;
+ if (!parent.children) parent.children = [];
+ parent.children.push(node);
+ }
+
+ stack.push({ node, level });
+ }
+
+ return headings;
+}
+
+/**
+ * 从路径列表生成文件树
+ */
+export function buildFileTree(paths: string[]): FileNode[] {
+ const root: FileNode[] = [];
+ const map = new Map();
+
+ for (const path of paths) {
+ const parts = path.split("/").filter(Boolean);
+ let currentPath = "";
+
+ for (let i = 0; i < parts.length; i++) {
+ const part = parts[i];
+ const isFile = i === parts.length - 1 && part.includes(".");
+ currentPath += "/" + part;
+
+ if (map.has(currentPath)) continue;
+
+ const node: FileNode = {
+ name: part.replace(/\.md$/, ""),
+ path: currentPath,
+ };
+
+ if (!isFile) {
+ node.children = [];
+ }
+
+ map.set(currentPath, node);
+
+ // 找到父节点并添加
+ const parentPath = currentPath.substring(0, currentPath.lastIndexOf("/"));
+ const parent = parentPath ? map.get(parentPath) : null;
+
+ if (parent?.children) {
+ parent.children.push(node);
+ } else {
+ root.push(node);
+ }
+ }
+ }
+
+ // 对节点排序:文件夹在前,文件在后,按名称字母排序
+ const sortNodes = (nodes: FileNode[]) => {
+ nodes.sort((a, b) => {
+ const aIsDir = !!a.children;
+ const bIsDir = !!b.children;
+ if (aIsDir && !bIsDir) return -1;
+ if (!aIsDir && bIsDir) return 1;
+ return a.name.localeCompare(b.name);
+ });
+ for (const node of nodes) {
+ if (node.children) sortNodes(node.children);
+ }
+ };
+
+ sortNodes(root);
+ return root;
+}
+
+/**
+ * 转义正则表达式特殊字符
+ */
+function escapeRegExp(str: string): string {
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+/**
+ * 从 markdown 内容中提取指定标题下的内容
+ */
+export function extractSection(content: string, sectionTitle: string): string {
+ // 匹配标题(支持 1-6 级标题)
+ const sectionRegex = new RegExp(
+ `^(#{1,6})\\s*${escapeRegExp(sectionTitle)}\\s*$`,
+ "im"
+ );
+
+ const match = content.match(sectionRegex);
+ if (!match) {
+ // 没有找到指定标题,返回空字符串
+ return "";
+ }
+
+ const headerLevel = match[1].length;
+ const startIndex = match.index!;
+
+ // 查找下一个同级或更高级别的标题
+ const nextHeaderRegex = new RegExp(
+ `^#{1,${headerLevel}}\\s+.+$`,
+ "gm"
+ );
+
+ // 从标题后开始搜索
+ nextHeaderRegex.lastIndex = startIndex + match[0].length;
+ const nextMatch = nextHeaderRegex.exec(content);
+
+ if (nextMatch) {
+ // 返回从当前标题到下一个同级/更高级标题之间的内容
+ return content.slice(startIndex, nextMatch.index).trim();
+ }
+
+ // 返回从当前标题到文件末尾的内容
+ return content.slice(startIndex).trim();
+}