Files
openclawdoc/lib/docs.ts
2026-02-28 23:01:30 +08:00

204 lines
6.7 KiB
TypeScript

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<string, any>();
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<string, string> = {
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<DocPage | null> => {
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) -> <a href="/docs/link">text</a>
// 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) -> <a href="/docs/link">text</a> (happens inside custom tags like <Note>)
.replace(/\[([^\]]+)\]\(\/([^)]+)\)/g, (match, text, link) => {
const cleanLink = link.startsWith('docs/') ? link : `docs/${link.replace(/\.md$/, '')}`;
return `<a href="/${cleanLink}">${text}</a>`;
})
// 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;
});