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 "./components";
|
||||||
import { Article } from "./components";
|
import { Article, Sidebar } from "./components";
|
||||||
|
|
||||||
const App: Component = () => {
|
const App: Component = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [currentPath, setCurrentPath] = createSignal("");
|
const [currentPath, setCurrentPath] = createSignal("");
|
||||||
|
const [isSidebarOpen, setIsSidebarOpen] = createSignal(false);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// 根据路由加载对应的 markdown 文件
|
// 根据路由加载对应的 markdown 文件
|
||||||
|
|
@ -22,8 +23,19 @@ const App: Component = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="min-h-screen bg-gray-50">
|
<div class="min-h-screen bg-gray-50">
|
||||||
<header class="fixed top-0 left-0 right-0 bg-white shadow z-50">
|
<Sidebar
|
||||||
<div class="max-w-4xl mx-auto px-4 py-4">
|
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>
|
<h1 class="text-2xl font-bold text-gray-900">TTRPG Tools</h1>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Component, createSignal, onMount, onCleanup, Show } from 'solid-js';
|
import { Component, createSignal, onMount, onCleanup, Show } from 'solid-js';
|
||||||
import { parseMarkdown } from '../markdown';
|
import { parseMarkdown } from '../markdown';
|
||||||
import { fetchData } from '../data-loader';
|
import { fetchData, extractSection } from '../data-loader';
|
||||||
|
|
||||||
export interface ArticleProps {
|
export interface ArticleProps {
|
||||||
src: string;
|
src: string;
|
||||||
|
|
@ -9,51 +9,6 @@ export interface ArticleProps {
|
||||||
onError?: (error: Error) => void;
|
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 组件
|
* Article 组件
|
||||||
* 用于将特定 src 位置的 md 文件显示为 markdown 文章
|
* 用于将特定 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 { Article } from './Article';
|
||||||
export type { ArticleProps } from './Article';
|
export type { ArticleProps } from './Article';
|
||||||
|
export { Sidebar } from './Sidebar';
|
||||||
|
export type { SidebarProps } from './Sidebar';
|
||||||
|
|
||||||
// 导出数据类型
|
// 导出数据类型
|
||||||
export type { DiceProps } from './dice';
|
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 dataIndex: Record<string, string> | null = null;
|
||||||
let isDevIndexLoaded = false;
|
let isDevIndexLoaded = false;
|
||||||
let isCliIndexLoaded = false;
|
let isCliIndexLoaded = false;
|
||||||
|
|
@ -12,6 +16,32 @@ function getIndexedData(path: string): string | null {
|
||||||
return 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 索引
|
* 在 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