Files
my_wiki/web/lib/wiki.ts
T
Junjian Wang 3b6dc2e9fc 添加 Next.js Web 应用
- 创建 Next.js + TypeScript + Tailwind CSS Web 应用
- 支持 Markdown 渲染和 GFM 格式
- 支持 Obsidian 风格 wiki 链接 [[Page|Label]]
- 侧边栏导航,按分类组织页面
- 图片资源支持(通过 API 路由从 wiki/assets 提供)
- 响应式设计
- 更新 README 添加 Web 应用使用说明
2026-04-13 20:54:14 +08:00

207 lines
6.1 KiB
TypeScript

import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
import { remark } from 'remark'
import remarkHtml from 'remark-html'
import remarkGfm from 'remark-gfm'
const WIKI_ROOT = path.join(process.cwd(), '../wiki')
export interface WikiPage {
slug: string
title: string
content: string
html: string
frontmatter: Record<string, any>
category?: string
}
export interface WikiLink {
target: string
label: string
}
// 全局缓存:链接目标 -> slug
let linkMap: Map<string, string> | null = null
// 构建链接映射
async function buildLinkMap(): Promise<Map<string, string>> {
if (linkMap) return linkMap
linkMap = new Map()
const pages = await getAllPagesBasic()
for (const page of pages) {
// 用文件名作为键(不带扩展名)
const filename = path.basename(page.slug.replace(/--/g, '/'))
linkMap.set(filename.toLowerCase(), page.slug)
// 也用标题作为键
if (page.title) {
const normalizedTitle = page.title.toLowerCase().replace(/\s+/g, '-')
linkMap.set(normalizedTitle, page.slug)
}
// 也用完整 slug 作为键
linkMap.set(page.slug.toLowerCase(), page.slug)
}
return linkMap
}
// 基本版本,不处理链接和图片(避免循环依赖)
async function getAllPagesBasic(): Promise<WikiPage[]> {
const pages: WikiPage[] = []
function traverse(dir: string, category?: string) {
const entries = fs.readdirSync(dir, { withFileTypes: true })
for (const entry of entries) {
if (entry.isDirectory() && entry.name !== 'assets' && entry.name !== 'visual') {
traverse(path.join(dir, entry.name), entry.name)
} else if (entry.name.endsWith('.md')) {
const page = getPageBasic(path.join(dir, entry.name), category)
if (page) {
pages.push(page)
}
}
}
}
traverse(WIKI_ROOT)
return pages.sort((a, b) => a.title.localeCompare(b.title))
}
// 基本版本,不处理链接和图片
function getPageBasic(filePath: string, category?: string): WikiPage | null {
try {
const content = fs.readFileSync(filePath, 'utf8')
const { data } = matter(content)
const relativePath = path.relative(WIKI_ROOT, filePath)
const slug = relativePath.replace(/\.md$/, '').replace(/\//g, '--')
return {
slug,
title: data.title || path.basename(filePath, '.md'),
content: '',
html: '',
frontmatter: data,
category,
}
} catch (e) {
return null
}
}
// 获取所有 wiki 页面
export async function getAllPages(): Promise<WikiPage[]> {
const pages: WikiPage[] = []
const map = await buildLinkMap()
async function traverse(dir: string, category?: string) {
const entries = fs.readdirSync(dir, { withFileTypes: true })
for (const entry of entries) {
if (entry.isDirectory() && entry.name !== 'assets' && entry.name !== 'visual') {
await traverse(path.join(dir, entry.name), entry.name)
} else if (entry.name.endsWith('.md')) {
const page = await getPageByPath(path.join(dir, entry.name), category, map)
if (page) {
pages.push(page)
}
}
}
}
await traverse(WIKI_ROOT)
return pages.sort((a, b) => a.title.localeCompare(b.title))
}
// 通过路径获取页面
export async function getPageByPath(filePath: string, category?: string, linkMap?: Map<string, string>): Promise<WikiPage | null> {
try {
const content = fs.readFileSync(filePath, 'utf8')
const { data, content: markdown } = matter(content)
const relativePath = path.relative(WIKI_ROOT, filePath)
const slug = relativePath.replace(/\.md$/, '').replace(/\//g, '--')
const map = linkMap || await buildLinkMap()
let processedMarkdown = parseWikiLinks(markdown, map)
processedMarkdown = parseImagePaths(processedMarkdown)
const processed = await remark()
.use(remarkGfm)
.use(remarkHtml)
.process(processedMarkdown)
const html = String(processed)
const frontmatter: Record<string, any> = {}
for (const [key, value] of Object.entries(data)) {
frontmatter[key] = value instanceof Date ? value.toISOString().split('T')[0] : value
}
return {
slug,
title: frontmatter.title || path.basename(filePath, '.md'),
content: markdown,
html,
frontmatter,
category,
}
} catch (e) {
console.error('Error reading page:', filePath, e)
return null
}
}
// 通过 slug 获取页面
export async function getPageBySlug(slug: string): Promise<WikiPage | null> {
const map = await buildLinkMap()
// 先尝试直接找
let filePath = path.join(WIKI_ROOT, slug.replace(/--/g, '/') + '.md')
if (fs.existsSync(filePath)) {
const category = slug.includes('--') ? slug.split('--')[0] : undefined
return getPageByPath(filePath, category, map)
}
// 尝试用链接映射找
const actualSlug = map.get(slug.toLowerCase())
if (actualSlug) {
filePath = path.join(WIKI_ROOT, actualSlug.replace(/--/g, '/') + '.md')
const category = actualSlug.includes('--') ? actualSlug.split('--')[0] : undefined
return getPageByPath(filePath, category, map)
}
return null
}
// 解析 Obsidian 风格的 wiki 链接 [[Target|Label]]
function parseWikiLinks(content: string, linkMap: Map<string, string>): string {
return content.replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_match, target, label) => {
const linkLabel = label || target
const normalizedTarget = target.toLowerCase().replace(/\s+/g, '-')
const actualSlug = linkMap.get(normalizedTarget) || target.replace(/\s+/g, '-')
return `[${linkLabel}](/wiki/${actualSlug})`
})
}
// 解析图片路径
function parseImagePaths(content: string): string {
return content.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, imgPath) => {
if (imgPath.startsWith('http://') || imgPath.startsWith('https://')) {
return match
}
const filename = path.posix.basename(imgPath)
return `![${alt}](/assets/${filename})`
})
}
// 获取页面分类
export function getCategories(pages: WikiPage[]): string[] {
const categories = new Set<string>()
pages.forEach(p => p.category && categories.add(p.category))
return Array.from(categories).sort()
}