feat: ai's attempt

This commit is contained in:
hypercross 2026-02-26 14:24:48 +08:00
parent b370e6c302
commit ba7b264a97
6 changed files with 405 additions and 49 deletions

View File

@ -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>

View File

@ -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

200
src/components/Sidebar.tsx Normal file
View File

@ -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>
);
};

View File

@ -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';

View File

@ -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
*/

157
src/data-loader/toc.ts Normal file
View File

@ -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();
}