diff --git a/src/App.tsx b/src/App.tsx
index 9a33738..b0850f1 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,16 +1,15 @@
import { Component, createSignal, onMount } from 'solid-js';
import { useLocation } from '@solidjs/router';
-import { parseMarkdown } from './markdown';
// 导入组件以注册自定义元素
import './components';
+import { Article } from './components';
const App: Component = () => {
const location = useLocation();
- const [content, setContent] = createSignal('');
const [currentPath, setCurrentPath] = createSignal('');
- onMount(async () => {
+ onMount(() => {
// 根据路由加载对应的 markdown 文件
let path = decodeURIComponent(location.pathname.slice(1));
if (!path) {
@@ -23,17 +22,6 @@ const App: Component = () => {
path = `${path}.md`;
}
setCurrentPath(path);
- try {
- const response = await fetch(path);
- if (response.ok) {
- const text = await response.text();
- setContent(text);
- } else {
- setContent('# 欢迎使用 TTRPG Tools\n\n没有找到对应的内容文件。');
- }
- } catch (error) {
- setContent('# 欢迎使用 TTRPG Tools\n\n这是一个 TTRPG 文档工具箱。\n\n## 功能\n\n- 使用 `:md-dice[2d6+d8]` 插入骰子组件\n- 使用 `:md-table[./data.csv]` 插入表格组件');
- }
});
return (
@@ -44,9 +32,7 @@ const App: Component = () => {
-
-
-
+
);
diff --git a/src/components/Article.tsx b/src/components/Article.tsx
new file mode 100644
index 0000000..9ddda4a
--- /dev/null
+++ b/src/components/Article.tsx
@@ -0,0 +1,57 @@
+import { Component, createSignal, onMount, onCleanup, Show } from 'solid-js';
+import { parseMarkdown } from '../markdown';
+import { fetchData } from '../data-loader';
+
+export interface ArticleProps {
+ src: string;
+ onLoaded?: () => void;
+ onError?: (error: Error) => void;
+}
+
+/**
+ * Article 组件
+ * 用于将特定 src 位置的 md 文件显示为 markdown 文章
+ */
+export const Article: Component = (props) => {
+ const [content, setContent] = createSignal('');
+ const [loading, setLoading] = createSignal(true);
+ const [error, setError] = createSignal(null);
+
+ let articleRef: HTMLArticleElement | undefined;
+
+ onMount(async () => {
+ setLoading(true);
+ try {
+ const text = await fetchData(props.src);
+ setContent(text);
+ setLoading(false);
+ props.onLoaded?.();
+ } catch (err) {
+ const errorObj = err instanceof Error ? err : new Error(String(err));
+ setError(errorObj);
+ setLoading(false);
+ props.onError?.(errorObj);
+ }
+ });
+
+ onCleanup(() => {
+ // 清理时清空内容,触发内部组件的销毁
+ setContent('');
+ });
+
+ return (
+
+
+ 加载中...
+
+
+ 加载失败:{error()?.message}
+
+
+
+
+
+ );
+};
+
+export default Article;
diff --git a/src/components/index.ts b/src/components/index.ts
index f12a7aa..36ae1f9 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -1,6 +1,11 @@
// 导入以注册自定义元素
import './dice';
import './table';
+import './md-link';
+
+// 导出组件
+export { Article } from './Article';
+export type { ArticleProps } from './Article';
// 导出类型
export type { DiceProps } from './dice';
diff --git a/src/components/md-link.tsx b/src/components/md-link.tsx
new file mode 100644
index 0000000..316bdbb
--- /dev/null
+++ b/src/components/md-link.tsx
@@ -0,0 +1,72 @@
+import { customElement, noShadowDOM } from "solid-element";
+import { createSignal, onCleanup } from "solid-js";
+import { render } from "solid-js/web";
+import { Article } from "./Article";
+
+customElement("md-link", {}, (props, { element }) => {
+ noShadowDOM();
+
+ const [showArticle, setShowArticle] = createSignal(false);
+ let articleContainer: HTMLDivElement | undefined;
+ let disposeArticle: (() => void) | null = null;
+
+ // 从 element 的 textContent 获取链接目标
+ const linkSrc = element?.textContent?.trim() || "";
+
+ // 查找包含此元素的 p 标签
+ const parentP = element?.closest("p");
+
+ const toggleArticle = () => {
+ if (!parentP) return;
+
+ if (showArticle()) {
+ // 隐藏文章
+ if (articleContainer) {
+ if (disposeArticle) {
+ disposeArticle();
+ disposeArticle = null;
+ }
+ articleContainer.remove();
+ articleContainer = undefined;
+ }
+ setShowArticle(false);
+ } else {
+ // 显示文章
+ articleContainer = document.createElement("div");
+ articleContainer.classList.add("md-link-article");
+ articleContainer.classList.add("mt-4", "ml-4", "border-l-2", "border-gray-200", "pl-4");
+ parentP.after(articleContainer);
+
+ // 渲染 Article 组件
+ disposeArticle = render(() => (
+ console.log("Article loaded:", linkSrc)}
+ onError={(err) => console.error("Article error:", err)}
+ />
+ ), articleContainer);
+
+ setShowArticle(true);
+ }
+ };
+
+ onCleanup(() => {
+ if (disposeArticle) {
+ disposeArticle();
+ disposeArticle = null;
+ }
+ });
+
+ return (
+ {
+ e.preventDefault();
+ toggleArticle();
+ }}
+ class="text-blue-600 hover:text-blue-800 hover:underline"
+ >
+ {linkSrc}
+
+ );
+});
diff --git a/src/data-loader/index.ts b/src/data-loader/index.ts
new file mode 100644
index 0000000..b3a66e6
--- /dev/null
+++ b/src/data-loader/index.ts
@@ -0,0 +1,72 @@
+/**
+ * 数据加载索引接口
+ */
+export interface DataIndex {
+ [key: string]: string;
+}
+
+/**
+ * 获取数据索引
+ * 在 dev 环境使用 import.meta.glob 创建索引
+ * 在 cli 环境检索目录创建并注入
+ */
+export function getDataIndex(): DataIndex {
+ // @ts-ignore - import.meta.glob 在构建时会被处理
+ if (typeof import.meta !== 'undefined' && import.meta.glob) {
+ // Dev 环境:使用 import.meta.glob 动态导入
+ // @ts-ignore
+ const modules = import.meta.glob('../../content/**/*', { as: 'raw', eager: false });
+ const index: DataIndex = {};
+ for (const [path, importer] of Object.entries(modules)) {
+ const normalizedPath = path.replace('/src/', '/');
+ index[normalizedPath] = normalizedPath;
+ }
+ return index;
+ }
+
+ // CLI 环境:返回空索引,由 CLI 注入
+ return {};
+}
+
+/**
+ * 设置全局数据索引(CLI 环境使用)
+ */
+export function setDataIndex(index: DataIndex) {
+ (window as any).__TTRPG_DATA_INDEX__ = index;
+}
+
+/**
+ * 从全局索引获取数据
+ */
+function getIndexedData(path: string): string | null {
+ const index = (window as any).__TTRPG_DATA_INDEX__ as DataIndex | undefined;
+ if (index && index[path]) {
+ return index[path];
+ }
+ return null;
+}
+
+/**
+ * 异步加载数据
+ * @param path 数据路径
+ * @returns 数据内容
+ */
+export async function fetchData(path: string): Promise {
+ // 首先尝试从索引获取
+ const indexedData = getIndexedData(path);
+ if (indexedData) {
+ return indexedData;
+ }
+
+ // 索引不存在时,使用 fetch 加载
+ try {
+ const response = await fetch(path);
+ if (!response.ok) {
+ throw new Error(`Failed to fetch: ${path}`);
+ }
+ return await response.text();
+ } catch (error) {
+ console.error('fetchData error:', error);
+ throw error;
+ }
+}