import fs from 'fs'; import path from 'path'; import matter from 'gray-matter'; import { remark } from 'remark'; import html from 'remark-html'; import remarkGfm from 'remark-gfm'; import { cache } from 'react'; const contentDir = path.join(process.cwd(), 'content'); // Simple in-memory cache for development performance const docCache = new Map(); const sidebarCache: { data: any | null } = { data: null }; export interface DocMeta { title: string; slug: string[]; description?: string; summary?: string; } export interface DocPage { meta: DocMeta; contentHtml: string; } export interface SidebarItem { title: string; slug: string; children?: SidebarItem[]; } // Category display name mapping const categoryNames: Record = { gateway: '网关配置', channels: '渠道接入', install: '安装部署', cli: '命令行工具', concepts: '核心概念', tools: '工具系统', providers: '模型供应商', platforms: '平台适配', start: '快速入门', automation: '自动化', help: '帮助中心', nodes: '节点管理', plugins: '插件系统', web: 'Web 界面', security: '安全配置', reference: '参考手册', experiments: '实验功能', debug: '调试排查', diagnostics: '诊断工具', refactor: '重构指南', design: '设计文档', pipelines: '开发流水线', }; export function getCategoryName(key: string): string { return categoryNames[key] || key; } function getAllMdFiles(dir: string, baseSlug: string[] = []): { filePath: string; slug: string[] }[] { const items: { filePath: string; slug: string[] }[] = []; if (!fs.existsSync(dir)) return items; const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { items.push(...getAllMdFiles(fullPath, [...baseSlug, entry.name])); } else if (entry.name.endsWith('.md')) { const name = entry.name.replace(/\.md$/, ''); items.push({ filePath: fullPath, slug: [...baseSlug, name] }); } } return items; } export function getAllDocSlugs(): string[][] { return getAllMdFiles(contentDir).map(f => f.slug); } export const getDocBySlug = cache(async (slug: string[]): Promise => { const cacheKey = slug.join('/'); if (docCache.has(cacheKey)) return docCache.get(cacheKey); // Try exact path first let filePath = path.join(contentDir, ...slug) + '.md'; if (!fs.existsSync(filePath)) { // Try as directory with index.md filePath = path.join(contentDir, ...slug, 'index.md'); if (!fs.existsSync(filePath)) { return null; } } const fileContent = fs.readFileSync(filePath, 'utf-8'); const { data, content } = matter(fileContent); // Process markdown const processedContent = await remark() .use(remarkGfm) .use(html, { sanitize: false }) .process(content); let contentHtml = processedContent.toString(); // Fix internal links: // 1. Convert unparsed markdown links inside tags: [text](/link) -> text // 2. Prefix absolute links with /docs (e.g. /gateway/config -> /docs/gateway/config) // 3. Strip .md from links (e.g. config.md -> config) contentHtml = contentHtml // Fix [text](/link) -> text (happens inside custom tags like ) .replace(/\[([^\]]+)\]\(\/([^)]+)\)/g, (match, text, link) => { const cleanLink = link.startsWith('docs/') ? link : `docs/${link.replace(/\.md$/, '')}`; return `${text}`; }) // Fix href="/link" in standard tags or cards .replace(/href="\/([^/][^"]*)"/g, (match, p1) => { if (p1.startsWith('docs/')) return match; if (p1.startsWith('http')) return match; return `href="/docs/${p1.replace(/\.md$/, '')}"`; }) // Fix any remaining .md in hrefs .replace(/href="([^"]+)\.md(#?[^"]*)"/g, 'href="$1$2"'); // Clear cache to ensure changes are picked up immediately docCache.clear(); sidebarCache.data = null; const result = { meta: { title: data.title || slug[slug.length - 1], slug, description: data.description, summary: data.summary, }, contentHtml, }; docCache.set(cacheKey, result); return result; }); export const getSidebarStructure = cache((): SidebarItem[] => { if (sidebarCache.data) return sidebarCache.data; const items: SidebarItem[] = []; if (!fs.existsSync(contentDir)) return items; const entries = fs.readdirSync(contentDir, { withFileTypes: true }); // Root-level .md files const rootFiles = entries.filter(e => !e.isDirectory() && e.name.endsWith('.md')); for (const f of rootFiles) { const name = f.name.replace(/\.md$/, ''); const filePath = path.join(contentDir, f.name); const fileContent = fs.readFileSync(filePath, 'utf-8'); const { data } = matter(fileContent); items.push({ title: data.title || name, slug: `/docs/${name}`, }); } // Directories const dirs = entries.filter(e => e.isDirectory()).sort((a, b) => { const order = ['start', 'install', 'gateway', 'channels', 'concepts', 'tools', 'providers', 'platforms', 'automation', 'cli', 'plugins', 'nodes', 'web', 'security', 'reference', 'help']; const ai = order.indexOf(a.name); const bi = order.indexOf(b.name); return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi); }); for (const dir of dirs) { const dirPath = path.join(contentDir, dir.name); const children: SidebarItem[] = []; const subEntries = fs.readdirSync(dirPath, { withFileTypes: true }); for (const sub of subEntries) { if (sub.isDirectory()) continue; if (!sub.name.endsWith('.md')) continue; const subName = sub.name.replace(/\.md$/, ''); const subFilePath = path.join(dirPath, sub.name); const subContent = fs.readFileSync(subFilePath, 'utf-8'); const { data } = matter(subContent); children.push({ title: data.title || subName, slug: `/docs/${dir.name}/${subName}`, }); } if (children.length > 0) { items.push({ title: getCategoryName(dir.name), slug: `/docs/${dir.name}`, children, }); } } sidebarCache.data = items; return items; });