From ba7b264a973e622ccc2feea55785bdaf95345c67 Mon Sep 17 00:00:00 2001 From: hypercross Date: Thu, 26 Feb 2026 14:24:48 +0800 Subject: [PATCH] feat: ai's attempt --- src/App.tsx | 18 +++- src/components/Article.tsx | 47 +-------- src/components/Sidebar.tsx | 200 +++++++++++++++++++++++++++++++++++++ src/components/index.ts | 2 + src/data-loader/index.ts | 30 ++++++ src/data-loader/toc.ts | 157 +++++++++++++++++++++++++++++ 6 files changed, 405 insertions(+), 49 deletions(-) create mode 100644 src/components/Sidebar.tsx create mode 100644 src/data-loader/toc.ts 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)} + /> +
+
+

TTRPG Tools

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 ( +
+ + {props.node.title} + + +
+ {props.node.children!.map((child) => ( + + ))} +
+
+
+ ); +}; + +/** + * 侧边栏组件 + */ +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(); +}