Compare commits

...

10 Commits

Author SHA1 Message Date
hypercross e57e09ae05 fix: misc 2026-02-26 16:35:57 +08:00
hypercross 04dfa77eaf fix: cli dist/web path 2026-02-26 16:11:56 +08:00
hypercross 3efc3b59ad fix: cli path 2026-02-26 16:06:24 +08:00
hypercross e4bdd06182 refactor: path handling 2026-02-26 15:40:58 +08:00
hypercross 2ab6ed9687 feat: reactive sidebar 2026-02-26 15:22:40 +08:00
hypercross b6718927c7 fix: active 2026-02-26 15:16:13 +08:00
hypercross 53faa775be fix: heading 2026-02-26 15:10:03 +08:00
hypercross c923d80d30 refactor: FileTree 2026-02-26 15:04:17 +08:00
hypercross 1ea2899bf4 refactor: animated sidebar 2026-02-26 15:02:28 +08:00
hypercross 83e8a89f22 fix: opacity 2026-02-26 14:57:18 +08:00
12 changed files with 287 additions and 185 deletions

View File

@ -1,6 +1,6 @@
label,body,adj,noun,group
🔥,**{{adj}}** 的{{noun}},高大,战士,基本
💧,{{adj}}的{{noun}},矮小,法师,基本
🌪️,{{adj}}的{{noun}},帅气,弓手,高级
🌱,{{adj}}的{{noun}},丑陋,盗贼,高级
⚡,{{adj}}的{{noun}},平庸,牧师,高级
label,body,adj,noun
1,**{{adj}}** 的{{noun}},高大,战士
2,{{adj}}的{{noun}},矮小,法师
3,{{adj}}的{{noun}},帅气,弓手
4,{{adj}}的{{noun}},丑陋,盗贼
5,{{adj}}的{{noun}},平庸,牧师

1 label body adj noun group
2 🔥 1 **{{adj}}** 的{{noun}} 高大 战士 基本
3 💧 2 {{adj}}的{{noun}} 矮小 法师 基本
4 🌪️ 3 {{adj}}的{{noun}} 帅气 弓手 高级
5 🌱 4 {{adj}}的{{noun}} 丑陋 盗贼 高级
6 5 {{adj}}的{{noun}} 平庸 牧师 高级

View File

@ -4,7 +4,7 @@
"description": "A TTRPG toolbox based on solid.js and rsbuild",
"type": "module",
"bin": {
"ttrpg": "./dist/cli.js"
"ttrpg": "./dist/cli/index.js"
},
"main": "./dist/index.js",
"module": "./dist/index.js",

View File

@ -3,7 +3,7 @@ import { useLocation } from "@solidjs/router";
// 导入组件以注册自定义元素
import "./components";
import { Article, Sidebar } from "./components";
import { Article, MobileSidebar, DesktopSidebar } from "./components";
const App: Component = () => {
const location = useLocation();
@ -22,15 +22,19 @@ const App: Component = () => {
return (
<div class="min-h-screen bg-gray-50">
<Sidebar
{/* 桌面端固定侧边栏 */}
<DesktopSidebar />
{/* 移动端抽屉式侧边栏 */}
<MobileSidebar
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"
class="md:hidden text-gray-600 hover:text-gray-900 p-1 rounded hover:bg-gray-100"
title="目录"
>
@ -38,7 +42,7 @@ const App: Component = () => {
<h1 class="text-2xl font-bold text-gray-900">TTRPG Tools</h1>
</div>
</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()} />
</main>
</div>

View File

@ -2,13 +2,21 @@ import type { ServeCommandHandler } from "../types.js";
import { createServer, Server, IncomingMessage, ServerResponse } from "http";
import { readdirSync, statSync, readFileSync, existsSync } 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 { fileURLToPath } from "url";
interface ContentIndex {
[path: string]: string;
}
/**
* CLI dist
*/
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const distDir = resolve(__dirname, "..", "..", "..", "dist", "web");
/**
* MIME
*/
@ -172,7 +180,6 @@ function createRequestHandler(
*/
export const serveCommand: ServeCommandHandler = async (dir, options) => {
const contentDir = resolve(dir);
const distDir = resolve(process.cwd(), "dist/web");
let contentIndex: ContentIndex = {};
// 扫描内容目录生成索引
@ -236,6 +243,7 @@ export const serveCommand: ServeCommandHandler = async (dir, options) => {
server.listen(port, () => {
console.log(`\n开发服务器已启动http://localhost:${port}`);
console.log(`内容目录:${contentDir}`);
console.log(`静态资源目录:${distDir}`);
console.log(`索引文件http://localhost:${port}/__CONTENT_INDEX.json\n`);
});
};

117
src/components/FileTree.tsx Normal file
View File

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

View File

@ -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 { useLocation, useNavigate } from "@solidjs/router";
import { useLocation } from "@solidjs/router";
import { FileTreeNode, HeadingNode } from "./FileTree";
interface SidebarProps {
isOpen: boolean;
onClose: () => void;
isDesktop?: boolean;
}
/**
*
*
*/
const FileTreeNode: Component<{
node: FileNode;
currentPath: string;
const SidebarContent: Component<{
fileTree: FileNode[];
pathHeadings: Record<string, TocNode[]>;
depth: number;
currentPath: string;
onClose: () => void;
}> = (props) => {
const navigate = useNavigate();
const [isExpanded, setIsExpanded] = createSignal(true);
const isDir = !!props.node.children;
const isActive = props.currentPath === props.node.path;
const location = useLocation();
const handleClick = () => {
if (isDir) {
setIsExpanded(!isExpanded());
} else {
navigate(props.node.path);
props.onClose();
}
};
const indent = props.depth * 12;
// 响应式获取当前文件的标题列表
const currentFileHeadings = createMemo(() => {
const pathname = location.pathname;
return props.pathHeadings[pathname] || props.pathHeadings[`${pathname}.md`] || [];
});
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>
<div class="p-4">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-bold text-gray-900"></h2>
<Show when={!props.isDesktop}>
<button
onClick={props.onClose}
class="text-gray-500 hover:text-gray-700"
title="关闭"
>
</button>
</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 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>
{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) => (
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2 px-2">
</h3>
{currentFileHeadings().map((node) => (
<HeadingNode
node={child}
basePath={props.basePath}
depth={props.depth + 1}
node={node}
basePath={location.pathname}
depth={0}
/>
))}
</div>
@ -113,88 +77,74 @@ const HeadingNode: Component<{
};
/**
*
*
*/
export const Sidebar: Component<SidebarProps> = (props) => {
export const MobileSidebar: Component<SidebarProps> = (props) => {
const location = useLocation();
const [fileTree, setFileTree] = createSignal<FileNode[]>([]);
const [pathHeadings, setPathHeadings] = createSignal<
Record<string, TocNode[]>
>({});
const [currentFileHeadings, setCurrentFileHeadings] = createSignal<TocNode[]>(
[],
);
// 加载目录数据
onMount(async () => {
const toc = await 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"
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}
/>
{/* 侧边栏 */}
<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
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 ${
props.isOpen ? "translate-x-0" : "-translate-x-full"
}`}
>
<SidebarContent
fileTree={fileTree()}
pathHeadings={pathHeadings()}
currentPath={location.pathname}
onClose={props.onClose}
/>
</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>
);
};

View File

@ -6,8 +6,9 @@ import './md-link';
// 导出组件
export { Article } from './Article';
export type { ArticleProps } from './Article';
export { Sidebar } from './Sidebar';
export { MobileSidebar, DesktopSidebar } from './Sidebar';
export type { SidebarProps } from './Sidebar';
export { FileTreeNode, HeadingNode } from './FileTree';
// 导出数据类型
export type { DiceProps } from './dice';

View File

@ -2,6 +2,7 @@ import { customElement, noShadowDOM } from "solid-element";
import { createSignal, onCleanup } from "solid-js";
import { render } from "solid-js/web";
import { Article } from "./Article";
import { resolvePath } from "../utils/path";
customElement("md-link", {}, (props, { element }) => {
noShadowDOM();
@ -12,7 +13,7 @@ customElement("md-link", {}, (props, { element }) => {
// 从 element 的 textContent 获取链接目标(支持 path#section 语法)
const rawLinkSrc = element?.textContent?.trim() || "";
// 解析 section支持 path#section 语法)
const hashIndex = rawLinkSrc.indexOf('#');
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 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);
// 查找包含此元素的 p 标签

View File

@ -2,6 +2,7 @@ import { customElement, noShadowDOM } from 'solid-element';
import { createSignal, For, Show, createEffect, createMemo, createResource } from 'solid-js';
import { parse } from 'csv-parse/browser/esm/sync';
import { marked } from '../markdown';
import { resolvePath } from '../utils/path';
interface TableRow {
label: string;
@ -32,15 +33,7 @@ customElement('md-table', { roll: false, remix: false }, (props, { element }) =>
const articleEl = element?.closest('article[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);
// 加载 CSV 文件的函数

View File

@ -5,7 +5,7 @@ import { createDirectives } from 'marked-directive';
const marked = new Marked().use(createDirectives());
export function parseMarkdown(content: string): string {
return marked.parse(content) as string;
return marked.parse(content.trimStart()) as string;
}
export { marked };

1
src/utils/index.ts Normal file
View File

@ -0,0 +1 @@
export { resolvePath } from './path';

35
src/utils/path.ts Normal file
View File

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