2026-02-26 09:24:26 +08:00
|
|
|
|
import { Component, createSignal, onMount, onCleanup, Show } from 'solid-js';
|
|
|
|
|
|
import { parseMarkdown } from '../markdown';
|
|
|
|
|
|
import { fetchData } from '../data-loader';
|
|
|
|
|
|
|
|
|
|
|
|
export interface ArticleProps {
|
|
|
|
|
|
src: string;
|
2026-02-26 09:53:30 +08:00
|
|
|
|
section?: string; // 指定要显示的标题(不含 #)
|
2026-02-26 09:24:26 +08:00
|
|
|
|
onLoaded?: () => void;
|
|
|
|
|
|
onError?: (error: Error) => void;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-26 09:53:30 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 从 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, '\\$&');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-26 09:24:26 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* Article 组件
|
|
|
|
|
|
* 用于将特定 src 位置的 md 文件显示为 markdown 文章
|
|
|
|
|
|
*/
|
|
|
|
|
|
export const Article: Component<ArticleProps> = (props) => {
|
|
|
|
|
|
const [content, setContent] = createSignal('');
|
|
|
|
|
|
const [loading, setLoading] = createSignal(true);
|
|
|
|
|
|
const [error, setError] = createSignal<Error | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
let articleRef: HTMLArticleElement | undefined;
|
|
|
|
|
|
|
|
|
|
|
|
onMount(async () => {
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const text = await fetchData(props.src);
|
2026-02-26 09:53:30 +08:00
|
|
|
|
// 如果指定了 section,提取对应内容
|
|
|
|
|
|
const finalContent = props.section
|
|
|
|
|
|
? extractSection(text, props.section)
|
|
|
|
|
|
: text;
|
|
|
|
|
|
setContent(finalContent);
|
2026-02-26 09:24:26 +08:00
|
|
|
|
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 (
|
2026-02-26 09:47:21 +08:00
|
|
|
|
<article ref={articleRef} class="prose" data-src={props.src}>
|
2026-02-26 09:24:26 +08:00
|
|
|
|
<Show when={loading()}>
|
|
|
|
|
|
<div class="text-gray-500">加载中...</div>
|
|
|
|
|
|
</Show>
|
|
|
|
|
|
<Show when={error()}>
|
|
|
|
|
|
<div class="text-red-500">加载失败:{error()?.message}</div>
|
|
|
|
|
|
</Show>
|
|
|
|
|
|
<Show when={!loading() && !error()}>
|
|
|
|
|
|
<div innerHTML={parseMarkdown(content())} />
|
|
|
|
|
|
</Show>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export default Article;
|