Compare commits
10 Commits
588ae49f5f
...
e57e09ae05
| Author | SHA1 | Date |
|---|---|---|
|
|
e57e09ae05 | |
|
|
04dfa77eaf | |
|
|
3efc3b59ad | |
|
|
e4bdd06182 | |
|
|
2ab6ed9687 | |
|
|
b6718927c7 | |
|
|
53faa775be | |
|
|
c923d80d30 | |
|
|
1ea2899bf4 | |
|
|
83e8a89f22 |
|
|
@ -1,6 +1,6 @@
|
||||||
label,body,adj,noun,group
|
label,body,adj,noun
|
||||||
🔥,**{{adj}}** 的{{noun}},高大,战士,基本
|
1,**{{adj}}** 的{{noun}},高大,战士
|
||||||
💧,{{adj}}的{{noun}},矮小,法师,基本
|
2,{{adj}}的{{noun}},矮小,法师
|
||||||
🌪️,{{adj}}的{{noun}},帅气,弓手,高级
|
3,{{adj}}的{{noun}},帅气,弓手
|
||||||
🌱,{{adj}}的{{noun}},丑陋,盗贼,高级
|
4,{{adj}}的{{noun}},丑陋,盗贼
|
||||||
⚡,{{adj}}的{{noun}},平庸,牧师,高级
|
5,{{adj}}的{{noun}},平庸,牧师
|
||||||
|
|
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
"description": "A TTRPG toolbox based on solid.js and rsbuild",
|
"description": "A TTRPG toolbox based on solid.js and rsbuild",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
"ttrpg": "./dist/cli.js"
|
"ttrpg": "./dist/cli/index.js"
|
||||||
},
|
},
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"module": "./dist/index.js",
|
"module": "./dist/index.js",
|
||||||
|
|
|
||||||
12
src/App.tsx
12
src/App.tsx
|
|
@ -3,7 +3,7 @@ import { useLocation } from "@solidjs/router";
|
||||||
|
|
||||||
// 导入组件以注册自定义元素
|
// 导入组件以注册自定义元素
|
||||||
import "./components";
|
import "./components";
|
||||||
import { Article, Sidebar } from "./components";
|
import { Article, MobileSidebar, DesktopSidebar } from "./components";
|
||||||
|
|
||||||
const App: Component = () => {
|
const App: Component = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
@ -22,15 +22,19 @@ const App: Component = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="min-h-screen bg-gray-50">
|
<div class="min-h-screen bg-gray-50">
|
||||||
<Sidebar
|
{/* 桌面端固定侧边栏 */}
|
||||||
|
<DesktopSidebar />
|
||||||
|
{/* 移动端抽屉式侧边栏 */}
|
||||||
|
<MobileSidebar
|
||||||
isOpen={isSidebarOpen()}
|
isOpen={isSidebarOpen()}
|
||||||
onClose={() => setIsSidebarOpen(false)}
|
onClose={() => setIsSidebarOpen(false)}
|
||||||
/>
|
/>
|
||||||
<header class="fixed top-0 left-0 right-0 bg-white shadow z-30">
|
<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">
|
<div class="max-w-4xl mx-auto px-4 py-4 flex items-center gap-4">
|
||||||
|
{/* 仅在移动端显示菜单按钮 */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsSidebarOpen(true)}
|
onClick={() => setIsSidebarOpen(true)}
|
||||||
class="text-gray-600 hover:text-gray-900 p-1 rounded hover:bg-gray-100"
|
class="md:hidden text-gray-600 hover:text-gray-900 p-1 rounded hover:bg-gray-100"
|
||||||
title="目录"
|
title="目录"
|
||||||
>
|
>
|
||||||
☰
|
☰
|
||||||
|
|
@ -38,7 +42,7 @@ const App: Component = () => {
|
||||||
<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>
|
||||||
<main class="max-w-4xl mx-auto px-4 py-8 pt-20">
|
<main class="max-w-4xl mx-auto px-4 py-8 pt-20 md:ml-64">
|
||||||
<Article src={currentPath()} />
|
<Article src={currentPath()} />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,21 @@ import type { ServeCommandHandler } from "../types.js";
|
||||||
import { createServer, Server, IncomingMessage, ServerResponse } from "http";
|
import { createServer, Server, IncomingMessage, ServerResponse } from "http";
|
||||||
import { readdirSync, statSync, readFileSync, existsSync } from "fs";
|
import { readdirSync, statSync, readFileSync, existsSync } from "fs";
|
||||||
import { createReadStream } from "fs";
|
import { createReadStream } from "fs";
|
||||||
import { join, resolve, extname, sep, relative } from "path";
|
import { join, resolve, extname, sep, relative, dirname } from "path";
|
||||||
import { watch } from "chokidar";
|
import { watch } from "chokidar";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
interface ContentIndex {
|
interface ContentIndex {
|
||||||
[path: string]: string;
|
[path: string]: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 CLI 脚本文件所在目录路径(用于定位 dist 文件夹)
|
||||||
|
*/
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
const distDir = resolve(__dirname, "..", "..", "..", "dist", "web");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MIME 类型映射
|
* MIME 类型映射
|
||||||
*/
|
*/
|
||||||
|
|
@ -172,7 +180,6 @@ function createRequestHandler(
|
||||||
*/
|
*/
|
||||||
export const serveCommand: ServeCommandHandler = async (dir, options) => {
|
export const serveCommand: ServeCommandHandler = async (dir, options) => {
|
||||||
const contentDir = resolve(dir);
|
const contentDir = resolve(dir);
|
||||||
const distDir = resolve(process.cwd(), "dist/web");
|
|
||||||
let contentIndex: ContentIndex = {};
|
let contentIndex: ContentIndex = {};
|
||||||
|
|
||||||
// 扫描内容目录生成索引
|
// 扫描内容目录生成索引
|
||||||
|
|
@ -236,6 +243,7 @@ export const serveCommand: ServeCommandHandler = async (dir, options) => {
|
||||||
server.listen(port, () => {
|
server.listen(port, () => {
|
||||||
console.log(`\n开发服务器已启动:http://localhost:${port}`);
|
console.log(`\n开发服务器已启动:http://localhost:${port}`);
|
||||||
console.log(`内容目录:${contentDir}`);
|
console.log(`内容目录:${contentDir}`);
|
||||||
|
console.log(`静态资源目录:${distDir}`);
|
||||||
console.log(`索引文件:http://localhost:${port}/__CONTENT_INDEX.json\n`);
|
console.log(`索引文件:http://localhost:${port}/__CONTENT_INDEX.json\n`);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
import { Component, createMemo, createSignal, Show } from "solid-js";
|
||||||
|
import { type FileNode, type TocNode } from "../data-loader";
|
||||||
|
import { useNavigate } from "@solidjs/router";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查当前文件路径是否在文件夹内
|
||||||
|
*/
|
||||||
|
function isPathInDir(currentPath: string, dirPath: string): boolean {
|
||||||
|
// 确保 dirPath 以 / 结尾,用于前缀匹配
|
||||||
|
const dirPathPrefix = dirPath.endsWith('/') ? dirPath : dirPath + '/';
|
||||||
|
return currentPath.startsWith(dirPathPrefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件树节点组件
|
||||||
|
*/
|
||||||
|
export const FileTreeNode: Component<{
|
||||||
|
node: FileNode;
|
||||||
|
currentPath: string;
|
||||||
|
pathHeadings: Record<string, TocNode[]>;
|
||||||
|
depth: number;
|
||||||
|
onClose: () => void;
|
||||||
|
}> = (props) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isDir = !!props.node.children;
|
||||||
|
const isActive = createMemo(() => props.currentPath === props.node.path);
|
||||||
|
// 默认收起,除非当前文件在该文件夹内
|
||||||
|
const [isExpanded, setIsExpanded] = createSignal(isDir && isPathInDir(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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标题节点组件
|
||||||
|
*/
|
||||||
|
export 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,109 +1,73 @@
|
||||||
import { Component, createSignal, onMount, Show } from "solid-js";
|
import { Component, createMemo, createSignal, onMount, Show } from "solid-js";
|
||||||
import { generateToc, type FileNode, type TocNode } from "../data-loader";
|
import { generateToc, type FileNode, type TocNode } from "../data-loader";
|
||||||
import { useLocation, useNavigate } from "@solidjs/router";
|
import { useLocation } from "@solidjs/router";
|
||||||
|
import { FileTreeNode, HeadingNode } from "./FileTree";
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
isDesktop?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 文件树节点组件
|
* 侧边栏内容组件
|
||||||
*/
|
*/
|
||||||
const FileTreeNode: Component<{
|
const SidebarContent: Component<{
|
||||||
node: FileNode;
|
fileTree: FileNode[];
|
||||||
currentPath: string;
|
|
||||||
pathHeadings: Record<string, TocNode[]>;
|
pathHeadings: Record<string, TocNode[]>;
|
||||||
depth: number;
|
currentPath: string;
|
||||||
|
onClose: () => void;
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const navigate = useNavigate();
|
const location = useLocation();
|
||||||
const [isExpanded, setIsExpanded] = createSignal(true);
|
|
||||||
const isDir = !!props.node.children;
|
|
||||||
const isActive = props.currentPath === props.node.path;
|
|
||||||
|
|
||||||
const handleClick = () => {
|
// 响应式获取当前文件的标题列表
|
||||||
if (isDir) {
|
const currentFileHeadings = createMemo(() => {
|
||||||
setIsExpanded(!isExpanded());
|
const pathname = location.pathname;
|
||||||
} else {
|
return props.pathHeadings[pathname] || props.pathHeadings[`${pathname}.md`] || [];
|
||||||
navigate(props.node.path);
|
});
|
||||||
props.onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const indent = props.depth * 12;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div class="p-4">
|
||||||
<div
|
<div class="flex items-center justify-between mb-4">
|
||||||
class={`flex items-center py-1 px-2 cursor-pointer hover:bg-gray-100 rounded ${
|
<h2 class="text-lg font-bold text-gray-900">目录</h2>
|
||||||
isActive ? "bg-blue-50 text-blue-700" : "text-gray-700"
|
<Show when={!props.isDesktop}>
|
||||||
}`}
|
<button
|
||||||
style={{ "padding-left": `${indent + 8}px` }}
|
onClick={props.onClose}
|
||||||
onClick={handleClick}
|
class="text-gray-500 hover:text-gray-700"
|
||||||
>
|
title="关闭"
|
||||||
<Show when={isDir}>
|
>
|
||||||
<span class="mr-1 text-gray-400">
|
✕
|
||||||
{isExpanded() ? "📂" : "📁"}
|
</button>
|
||||||
</span>
|
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={!isDir}>
|
|
||||||
<span class="mr-1 text-gray-400">📄</span>
|
|
||||||
</Show>
|
|
||||||
<span class="text-sm truncate">{props.node.name}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<Show when={isDir && isExpanded() && props.node.children}>
|
|
||||||
|
{/* 文件树 */}
|
||||||
|
<div class="mb-4">
|
||||||
|
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2 px-2">
|
||||||
|
文件
|
||||||
|
</h3>
|
||||||
|
{props.fileTree.map((node) => (
|
||||||
|
<FileTreeNode
|
||||||
|
node={node}
|
||||||
|
currentPath={props.currentPath}
|
||||||
|
pathHeadings={props.pathHeadings}
|
||||||
|
depth={0}
|
||||||
|
onClose={props.onClose}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 当前文件标题 */}
|
||||||
|
<Show when={currentFileHeadings().length > 0}>
|
||||||
<div>
|
<div>
|
||||||
{props.node.children!.map((child) => (
|
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2 px-2">
|
||||||
<FileTreeNode
|
本页
|
||||||
node={child}
|
</h3>
|
||||||
currentPath={props.currentPath}
|
{currentFileHeadings().map((node) => (
|
||||||
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
|
<HeadingNode
|
||||||
node={child}
|
node={node}
|
||||||
basePath={props.basePath}
|
basePath={location.pathname}
|
||||||
depth={props.depth + 1}
|
depth={0}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -113,88 +77,74 @@ const HeadingNode: Component<{
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 侧边栏组件
|
* 侧边栏组件(移动端抽屉)
|
||||||
*/
|
*/
|
||||||
export const Sidebar: Component<SidebarProps> = (props) => {
|
export const MobileSidebar: Component<SidebarProps> = (props) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [fileTree, setFileTree] = createSignal<FileNode[]>([]);
|
const [fileTree, setFileTree] = createSignal<FileNode[]>([]);
|
||||||
const [pathHeadings, setPathHeadings] = createSignal<
|
const [pathHeadings, setPathHeadings] = createSignal<
|
||||||
Record<string, TocNode[]>
|
Record<string, TocNode[]>
|
||||||
>({});
|
>({});
|
||||||
const [currentFileHeadings, setCurrentFileHeadings] = createSignal<TocNode[]>(
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
|
// 加载目录数据
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const toc = await generateToc();
|
const toc = await generateToc();
|
||||||
setFileTree(toc.fileTree);
|
setFileTree(toc.fileTree);
|
||||||
setPathHeadings(toc.pathHeadings);
|
setPathHeadings(toc.pathHeadings);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 根据当前路径更新当前文件的标题列表
|
|
||||||
onMount(() => {
|
|
||||||
const updateHeadings = () => {
|
|
||||||
const pathname = location.pathname;
|
|
||||||
const headings = pathHeadings()[pathname] || pathHeadings()[`${pathname}.md`];
|
|
||||||
setCurrentFileHeadings(headings || []);
|
|
||||||
};
|
|
||||||
updateHeadings();
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={props.isOpen}>
|
<>
|
||||||
{/* 遮罩层 */}
|
{/* 遮罩层 */}
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 bg-black bg-opacity-50 z-40"
|
class={`fixed inset-0 bg-black/50 z-40 transition-opacity duration-300 ease-in-out md:hidden ${
|
||||||
|
props.isOpen ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||||
|
}`}
|
||||||
onClick={props.onClose}
|
onClick={props.onClose}
|
||||||
/>
|
/>
|
||||||
{/* 侧边栏 */}
|
{/* 侧边栏 */}
|
||||||
<aside class="fixed top-0 left-0 h-full w-64 bg-white shadow-lg z-50 overflow-y-auto">
|
<aside
|
||||||
<div class="p-4">
|
class={`fixed top-0 left-0 h-full w-64 bg-white shadow-lg z-50 overflow-y-auto transform transition-transform duration-300 ease-in-out md:hidden ${
|
||||||
<div class="flex items-center justify-between mb-4">
|
props.isOpen ? "translate-x-0" : "-translate-x-full"
|
||||||
<h2 class="text-lg font-bold text-gray-900">目录</h2>
|
}`}
|
||||||
<button
|
>
|
||||||
onClick={props.onClose}
|
<SidebarContent
|
||||||
class="text-gray-500 hover:text-gray-700"
|
fileTree={fileTree()}
|
||||||
title="关闭"
|
pathHeadings={pathHeadings()}
|
||||||
>
|
currentPath={location.pathname}
|
||||||
✕
|
onClose={props.onClose}
|
||||||
</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>
|
</aside>
|
||||||
</Show>
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 桌面端固定侧边栏
|
||||||
|
*/
|
||||||
|
export const DesktopSidebar: Component = () => {
|
||||||
|
const location = useLocation();
|
||||||
|
const [fileTree, setFileTree] = createSignal<FileNode[]>([]);
|
||||||
|
const [pathHeadings, setPathHeadings] = createSignal<
|
||||||
|
Record<string, TocNode[]>
|
||||||
|
>({});
|
||||||
|
|
||||||
|
// 加载目录数据
|
||||||
|
onMount(async () => {
|
||||||
|
const toc = await generateToc();
|
||||||
|
setFileTree(toc.fileTree);
|
||||||
|
setPathHeadings(toc.pathHeadings);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside class="hidden md:block fixed top-0 left-0 h-full w-64 bg-white shadow-lg z-30 overflow-y-auto pt-16">
|
||||||
|
<SidebarContent
|
||||||
|
fileTree={fileTree()}
|
||||||
|
pathHeadings={pathHeadings()}
|
||||||
|
currentPath={location.pathname}
|
||||||
|
onClose={() => {}}
|
||||||
|
isDesktop={true}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,9 @@ 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 { MobileSidebar, DesktopSidebar } from './Sidebar';
|
||||||
export type { SidebarProps } from './Sidebar';
|
export type { SidebarProps } from './Sidebar';
|
||||||
|
export { FileTreeNode, HeadingNode } from './FileTree';
|
||||||
|
|
||||||
// 导出数据类型
|
// 导出数据类型
|
||||||
export type { DiceProps } from './dice';
|
export type { DiceProps } from './dice';
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { customElement, noShadowDOM } from "solid-element";
|
||||||
import { createSignal, onCleanup } from "solid-js";
|
import { createSignal, onCleanup } from "solid-js";
|
||||||
import { render } from "solid-js/web";
|
import { render } from "solid-js/web";
|
||||||
import { Article } from "./Article";
|
import { Article } from "./Article";
|
||||||
|
import { resolvePath } from "../utils/path";
|
||||||
|
|
||||||
customElement("md-link", {}, (props, { element }) => {
|
customElement("md-link", {}, (props, { element }) => {
|
||||||
noShadowDOM();
|
noShadowDOM();
|
||||||
|
|
@ -12,7 +13,7 @@ customElement("md-link", {}, (props, { element }) => {
|
||||||
|
|
||||||
// 从 element 的 textContent 获取链接目标(支持 path#section 语法)
|
// 从 element 的 textContent 获取链接目标(支持 path#section 语法)
|
||||||
const rawLinkSrc = element?.textContent?.trim() || "";
|
const rawLinkSrc = element?.textContent?.trim() || "";
|
||||||
|
|
||||||
// 解析 section(支持 path#section 语法)
|
// 解析 section(支持 path#section 语法)
|
||||||
const hashIndex = rawLinkSrc.indexOf('#');
|
const hashIndex = rawLinkSrc.indexOf('#');
|
||||||
const path = hashIndex >= 0 ? rawLinkSrc.slice(0, hashIndex) : rawLinkSrc;
|
const path = hashIndex >= 0 ? rawLinkSrc.slice(0, hashIndex) : rawLinkSrc;
|
||||||
|
|
@ -27,15 +28,7 @@ customElement("md-link", {}, (props, { element }) => {
|
||||||
const articleEl = element?.closest('article[data-src]');
|
const articleEl = element?.closest('article[data-src]');
|
||||||
const articlePath = articleEl?.getAttribute('data-src') || '';
|
const articlePath = articleEl?.getAttribute('data-src') || '';
|
||||||
|
|
||||||
// 解析相对路径(相对于 markdown 文件所在目录)
|
// 解析相对路径
|
||||||
const resolvePath = (base: string, relative: string): string => {
|
|
||||||
if (relative.startsWith('/')) {
|
|
||||||
return relative;
|
|
||||||
}
|
|
||||||
const baseDir = base.substring(0, base.lastIndexOf('/') + 1);
|
|
||||||
return baseDir + relative;
|
|
||||||
};
|
|
||||||
|
|
||||||
const linkSrc = resolvePath(articlePath, path);
|
const linkSrc = resolvePath(articlePath, path);
|
||||||
|
|
||||||
// 查找包含此元素的 p 标签
|
// 查找包含此元素的 p 标签
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { customElement, noShadowDOM } from 'solid-element';
|
||||||
import { createSignal, For, Show, createEffect, createMemo, createResource } from 'solid-js';
|
import { createSignal, For, Show, createEffect, createMemo, createResource } from 'solid-js';
|
||||||
import { parse } from 'csv-parse/browser/esm/sync';
|
import { parse } from 'csv-parse/browser/esm/sync';
|
||||||
import { marked } from '../markdown';
|
import { marked } from '../markdown';
|
||||||
|
import { resolvePath } from '../utils/path';
|
||||||
|
|
||||||
interface TableRow {
|
interface TableRow {
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -32,15 +33,7 @@ customElement('md-table', { roll: false, remix: false }, (props, { element }) =>
|
||||||
const articleEl = element?.closest('article[data-src]');
|
const articleEl = element?.closest('article[data-src]');
|
||||||
const articlePath = articleEl?.getAttribute('data-src') || '';
|
const articlePath = articleEl?.getAttribute('data-src') || '';
|
||||||
|
|
||||||
// 解析相对路径(相对于 markdown 文件所在目录)
|
// 解析相对路径
|
||||||
const resolvePath = (base: string, relative: string): string => {
|
|
||||||
if (relative.startsWith('/')) {
|
|
||||||
return relative;
|
|
||||||
}
|
|
||||||
const baseDir = base.substring(0, base.lastIndexOf('/') + 1);
|
|
||||||
return baseDir + relative;
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolvedSrc = resolvePath(articlePath, src);
|
const resolvedSrc = resolvePath(articlePath, src);
|
||||||
|
|
||||||
// 加载 CSV 文件的函数
|
// 加载 CSV 文件的函数
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { createDirectives } from 'marked-directive';
|
||||||
const marked = new Marked().use(createDirectives());
|
const marked = new Marked().use(createDirectives());
|
||||||
|
|
||||||
export function parseMarkdown(content: string): string {
|
export function parseMarkdown(content: string): string {
|
||||||
return marked.parse(content) as string;
|
return marked.parse(content.trimStart()) as string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { marked };
|
export { marked };
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export { resolvePath } from './path';
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
/**
|
||||||
|
* 解析相对路径为绝对路径
|
||||||
|
* 支持 ./ 和 ../ 语法
|
||||||
|
* @param base - 基准路径(通常是当前 markdown 文件的路径)
|
||||||
|
* @param relative - 相对路径或绝对路径
|
||||||
|
* @returns 解析后的路径
|
||||||
|
*/
|
||||||
|
export function resolvePath(base: string, relative: string): string {
|
||||||
|
if (relative.startsWith('/')) {
|
||||||
|
return relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseParts = base.split('/').filter(Boolean);
|
||||||
|
// 移除文件名,保留目录路径
|
||||||
|
if (baseParts.length > 0 && !base.endsWith('/')) {
|
||||||
|
baseParts.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
const relativeParts = relative.split('/');
|
||||||
|
|
||||||
|
for (const part of relativeParts) {
|
||||||
|
if (part === '.' || part === '') {
|
||||||
|
// 跳过 . 和空字符串
|
||||||
|
continue;
|
||||||
|
} else if (part === '..') {
|
||||||
|
// 向上一级
|
||||||
|
baseParts.pop();
|
||||||
|
} else {
|
||||||
|
// 普通路径段
|
||||||
|
baseParts.push(part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '/' + baseParts.join('/');
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue