feat: ai's attempt
This commit is contained in:
parent
b370e6c302
commit
ba7b264a97
18
src/App.tsx
18
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 (
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<header class="fixed top-0 left-0 right-0 bg-white shadow z-50">
|
||||
<div class="max-w-4xl mx-auto px-4 py-4">
|
||||
<Sidebar
|
||||
isOpen={isSidebarOpen()}
|
||||
onClose={() => setIsSidebarOpen(false)}
|
||||
/>
|
||||
<header class="fixed top-0 left-0 right-0 bg-white shadow z-30">
|
||||
<div class="max-w-4xl mx-auto px-4 py-4 flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => setIsSidebarOpen(true)}
|
||||
class="text-gray-600 hover:text-gray-900 p-1 rounded hover:bg-gray-100"
|
||||
title="目录"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
<h1 class="text-2xl font-bold text-gray-900">TTRPG Tools</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
|
|
|||
|
|
@ -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 文章
|
||||
|
|
|
|||
|
|
@ -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<string, TocNode[]>;
|
||||
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 (
|
||||
<div>
|
||||
<div
|
||||
class={`flex items-center py-1 px-2 cursor-pointer hover:bg-gray-100 rounded ${
|
||||
isActive ? "bg-blue-50 text-blue-700" : "text-gray-700"
|
||||
}`}
|
||||
style={{ "padding-left": `${indent + 8}px` }}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Show when={isDir}>
|
||||
<span class="mr-1 text-gray-400">
|
||||
{isExpanded() ? "📂" : "📁"}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={!isDir}>
|
||||
<span class="mr-1 text-gray-400">📄</span>
|
||||
</Show>
|
||||
<span class="text-sm truncate">{props.node.name}</span>
|
||||
</div>
|
||||
<Show when={isDir && isExpanded() && props.node.children}>
|
||||
<div>
|
||||
{props.node.children!.map((child) => (
|
||||
<FileTreeNode
|
||||
node={child}
|
||||
currentPath={props.currentPath}
|
||||
pathHeadings={props.pathHeadings}
|
||||
depth={props.depth + 1}
|
||||
onClose={props.onClose}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 标题节点组件
|
||||
*/
|
||||
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 (
|
||||
<div>
|
||||
<a
|
||||
href={href}
|
||||
class="block py-0.5 px-2 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-50 rounded truncate"
|
||||
style={{ "padding-left": `${indent + 8}px` }}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{props.node.title}
|
||||
</a>
|
||||
<Show when={props.node.children}>
|
||||
<div>
|
||||
{props.node.children!.map((child) => (
|
||||
<HeadingNode
|
||||
node={child}
|
||||
basePath={props.basePath}
|
||||
depth={props.depth + 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 侧边栏组件
|
||||
*/
|
||||
export const Sidebar: Component<SidebarProps> = (props) => {
|
||||
const location = useLocation();
|
||||
const [fileTree, setFileTree] = createSignal<FileNode[]>([]);
|
||||
const [pathHeadings, setPathHeadings] = createSignal<
|
||||
Record<string, TocNode[]>
|
||||
>({});
|
||||
const [currentFileHeadings, setCurrentFileHeadings] = createSignal<TocNode[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
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 (
|
||||
<Show when={props.isOpen}>
|
||||
{/* 遮罩层 */}
|
||||
<div
|
||||
class="fixed inset-0 bg-black bg-opacity-50 z-40"
|
||||
onClick={props.onClose}
|
||||
/>
|
||||
{/* 侧边栏 */}
|
||||
<aside class="fixed top-0 left-0 h-full w-64 bg-white shadow-lg z-50 overflow-y-auto">
|
||||
<div class="p-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-bold text-gray-900">目录</h2>
|
||||
<button
|
||||
onClick={props.onClose}
|
||||
class="text-gray-500 hover:text-gray-700"
|
||||
title="关闭"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 文件树 */}
|
||||
<div class="mb-4">
|
||||
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2 px-2">
|
||||
文件
|
||||
</h3>
|
||||
{fileTree().map((node) => (
|
||||
<FileTreeNode
|
||||
node={node}
|
||||
currentPath={location.pathname}
|
||||
pathHeadings={pathHeadings()}
|
||||
depth={0}
|
||||
onClose={props.onClose}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 当前文件标题 */}
|
||||
<Show when={currentFileHeadings().length > 0}>
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2 px-2">
|
||||
本页
|
||||
</h3>
|
||||
{currentFileHeadings().map((node) => (
|
||||
<HeadingNode
|
||||
node={node}
|
||||
basePath={location.pathname}
|
||||
depth={0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</aside>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
import { buildFileTree, extractHeadings, extractSection, type TocNode, type FileNode } from "./toc";
|
||||
|
||||
export { TocNode, FileNode, extractHeadings, buildFileTree, extractSection };
|
||||
|
||||
let dataIndex: Record<string, string> | 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<string, TocNode[]> } {
|
||||
const paths = getIndexedPaths();
|
||||
const fileTree = buildFileTree(paths);
|
||||
const pathHeadings: Record<string, TocNode[]> = {};
|
||||
|
||||
for (const path of paths) {
|
||||
const content = getIndexedData(path);
|
||||
if (content) {
|
||||
pathHeadings[path] = extractHeadings(content);
|
||||
}
|
||||
}
|
||||
|
||||
return { fileTree, pathHeadings };
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 dev 环境加载 glob 索引
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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<string, FileNode>();
|
||||
|
||||
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();
|
||||
}
|
||||
Loading…
Reference in New Issue