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
|
||||
🔥,**{{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}},平庸,牧师
|
||||
|
|
|
|||
|
|
|
@ -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",
|
||||
|
|
|
|||
12
src/App.tsx
12
src/App.tsx
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,158 +1,36 @@
|
|||
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 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(async () => {
|
||||
const toc = await generateToc();
|
||||
setFileTree(toc.fileTree);
|
||||
setPathHeadings(toc.pathHeadings);
|
||||
});
|
||||
|
||||
// 根据当前路径更新当前文件的标题列表
|
||||
onMount(() => {
|
||||
const updateHeadings = () => {
|
||||
// 响应式获取当前文件的标题列表
|
||||
const currentFileHeadings = createMemo(() => {
|
||||
const pathname = location.pathname;
|
||||
const headings = pathHeadings()[pathname] || pathHeadings()[`${pathname}.md`];
|
||||
setCurrentFileHeadings(headings || []);
|
||||
};
|
||||
updateHeadings();
|
||||
return props.pathHeadings[pathname] || props.pathHeadings[`${pathname}.md`] || [];
|
||||
});
|
||||
|
||||
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>
|
||||
<Show when={!props.isDesktop}>
|
||||
<button
|
||||
onClick={props.onClose}
|
||||
class="text-gray-500 hover:text-gray-700"
|
||||
|
|
@ -160,6 +38,7 @@ export const Sidebar: Component<SidebarProps> = (props) => {
|
|||
>
|
||||
✕
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* 文件树 */}
|
||||
|
|
@ -167,11 +46,11 @@ export const Sidebar: Component<SidebarProps> = (props) => {
|
|||
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2 px-2">
|
||||
文件
|
||||
</h3>
|
||||
{fileTree().map((node) => (
|
||||
{props.fileTree.map((node) => (
|
||||
<FileTreeNode
|
||||
node={node}
|
||||
currentPath={location.pathname}
|
||||
pathHeadings={pathHeadings()}
|
||||
currentPath={props.currentPath}
|
||||
pathHeadings={props.pathHeadings}
|
||||
depth={0}
|
||||
onClose={props.onClose}
|
||||
/>
|
||||
|
|
@ -194,7 +73,78 @@ export const Sidebar: Component<SidebarProps> = (props) => {
|
|||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</aside>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 侧边栏组件(移动端抽屉)
|
||||
*/
|
||||
export const MobileSidebar: Component<SidebarProps> = (props) => {
|
||||
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 (
|
||||
<>
|
||||
{/* 遮罩层 */}
|
||||
<div
|
||||
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 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 桌面端固定侧边栏
|
||||
*/
|
||||
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 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';
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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 标签
|
||||
|
|
|
|||
|
|
@ -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 文件的函数
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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