feat: migrate to react-markdown for robust markdown rendering
- Replace custom markdown parser with react-markdown + remark-gfm - Fix document link navigation (./ and ../ references) - Simplify doc viewing flow (direct markdown content instead of parsed structure) - Update electron main process to only use api folder for docs - Add blueprint loading from docs/blueprint.md dynamically - Fix sidebar file selection path matching - Update preload scripts for new API structure
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -3,3 +3,6 @@ dist/
|
|||||||
.vite/
|
.vite/
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
release/
|
||||||
|
nul
|
||||||
|
skills/
|
||||||
|
|||||||
@@ -1,139 +1,123 @@
|
|||||||
import { app, BrowserWindow, ipcMain, dialog } from 'electron';
|
import { app, BrowserWindow, ipcMain } from 'electron';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import http from 'http';
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
let mainWindow = null;
|
let mainWindow = null;
|
||||||
let docsPath = '';
|
const LOG_FILE = path.join(__dirname, '..', 'electron.log');
|
||||||
let port = 3001;
|
|
||||||
|
|
||||||
const DEFAULT_PORT = 3001;
|
function log(...args) {
|
||||||
|
const msg = args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ');
|
||||||
|
const ts = new Date().toISOString();
|
||||||
|
const line = `[${ts}] [main] ${msg}\n`;
|
||||||
|
fs.appendFileSync(LOG_FILE, line);
|
||||||
|
console.log(line.trim());
|
||||||
|
}
|
||||||
|
|
||||||
function parseArgs() {
|
function parseArgs() {
|
||||||
const args = { port: DEFAULT_PORT, docs: null, headless: false };
|
const args = { docs: null, headless: false };
|
||||||
const portIndex = process.argv.indexOf('--port');
|
|
||||||
if (portIndex !== -1 && process.argv[portIndex + 1]) {
|
|
||||||
args.port = parseInt(process.argv[portIndex + 1], 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
const docsIndex = process.argv.indexOf('--docs');
|
const docsIndex = process.argv.indexOf('--docs');
|
||||||
if (docsIndex !== -1 && process.argv[docsIndex + 1]) {
|
if (docsIndex !== -1 && process.argv[docsIndex + 1]) {
|
||||||
args.docs = process.argv[docsIndex + 1];
|
args.docs = process.argv[docsIndex + 1];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.argv.includes('--headless')) {
|
if (process.argv.includes('--headless')) {
|
||||||
args.headless = true;
|
args.headless = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return args;
|
return args;
|
||||||
}
|
}
|
||||||
|
|
||||||
const args = parseArgs();
|
const args = parseArgs();
|
||||||
port = args.port;
|
|
||||||
docsPath = args.docs || path.join(process.cwd(), 'src', 'docs');
|
|
||||||
|
|
||||||
function getDistPath() {
|
function getIndexPath() {
|
||||||
if (app.isPackaged) {
|
if (app.isPackaged) {
|
||||||
return path.join(__dirname, '..', 'dist');
|
return path.join(__dirname, '..', 'dist', 'index.html');
|
||||||
}
|
}
|
||||||
return path.join(__dirname, '..', 'dist');
|
return path.join(__dirname, '..', 'dist', 'index.html');
|
||||||
}
|
|
||||||
|
|
||||||
function loadDistServer() {
|
|
||||||
const distPath = getDistPath();
|
|
||||||
|
|
||||||
const server = http.createServer((req, res) => {
|
|
||||||
let filePath = path.join(distPath, req.url === '/' ? 'index.html' : req.url);
|
|
||||||
|
|
||||||
if (!filePath.startsWith(distPath)) {
|
|
||||||
res.writeHead(403);
|
|
||||||
res.end('Forbidden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ext = path.extname(filePath);
|
|
||||||
const contentTypes = {
|
|
||||||
'.html': 'text/html',
|
|
||||||
'.js': 'application/javascript',
|
|
||||||
'.css': 'text/css',
|
|
||||||
'.json': 'application/json',
|
|
||||||
'.png': 'image/png',
|
|
||||||
'.jpg': 'image/jpeg',
|
|
||||||
'.svg': 'image/svg+xml',
|
|
||||||
'.md': 'text/markdown',
|
|
||||||
};
|
|
||||||
|
|
||||||
if (fs.existsSync(filePath)) {
|
|
||||||
const contentType = contentTypes[ext] || 'application/octet-stream';
|
|
||||||
res.writeHead(200, { 'Content-Type': contentType });
|
|
||||||
res.end(fs.readFileSync(filePath));
|
|
||||||
} else {
|
|
||||||
res.writeHead(404);
|
|
||||||
res.end('Not Found');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
server.listen(port, () => {
|
|
||||||
console.log(`XCSDD Server running on http://localhost:${port}`);
|
|
||||||
console.log(`Docs path: ${docsPath}`);
|
|
||||||
if (args.headless) {
|
|
||||||
console.log(`Headless mode: enabled`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return server;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
if (args.headless) {
|
const indexPath = getIndexPath();
|
||||||
loadDistServer();
|
log('Creating window, indexPath:', indexPath);
|
||||||
return;
|
log('__dirname:', __dirname);
|
||||||
}
|
log('dist exists:', fs.existsSync(path.join(__dirname, '..', 'dist')));
|
||||||
|
log('index.html exists:', fs.existsSync(indexPath));
|
||||||
loadDistServer();
|
|
||||||
|
|
||||||
mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
|
show: !args.headless,
|
||||||
width: 1200,
|
width: 1200,
|
||||||
height: 800,
|
height: 800,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: path.join(__dirname, 'preload.mjs'),
|
preload: path.join(__dirname, 'preload.cjs'),
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
|
devTools: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
mainWindow.loadURL(`http://localhost:${port}`);
|
mainWindow.loadFile(indexPath);
|
||||||
|
|
||||||
|
mainWindow.webContents.on('did-fail-load', (_event, errorCode, errorDesc) => {
|
||||||
|
log('FAIL LOAD:', errorCode, errorDesc);
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.webContents.on('did-finish-load', () => {
|
||||||
|
log('Page finished loading');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!args.headless) {
|
||||||
|
mainWindow.webContents.openDevTools();
|
||||||
|
}
|
||||||
|
|
||||||
mainWindow.on('closed', () => {
|
mainWindow.on('closed', () => {
|
||||||
mainWindow = null;
|
mainWindow = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ipcMain.handle('select-folder', async () => {
|
ipcMain.handle('list-docs-files', (_event, basePath) => {
|
||||||
if (!mainWindow) return null;
|
const docsPath = path.join(basePath, 'api');
|
||||||
|
if (!fs.existsSync(docsPath)) {
|
||||||
const result = await dialog.showOpenDialog(mainWindow, {
|
return [];
|
||||||
properties: ['openDirectory'],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.canceled || result.filePaths.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
docsPath = result.filePaths[0];
|
const result = [];
|
||||||
mainWindow.webContents.send('docs-path-changed', docsPath);
|
|
||||||
return docsPath;
|
function walkDir(dir, baseDir) {
|
||||||
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
walkDir(fullPath, baseDir);
|
||||||
|
} else if (entry.name.endsWith('.md')) {
|
||||||
|
const relativePath = path.relative(baseDir, fullPath);
|
||||||
|
result.push({
|
||||||
|
name: entry.name.replace('.md', ''),
|
||||||
|
path: fullPath,
|
||||||
|
relativePath: relativePath.replace(/\\/g, '/')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
walkDir(docsPath, docsPath);
|
||||||
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('get-docs-path', () => {
|
ipcMain.handle('read-doc-file', (_event, filePath) => {
|
||||||
return docsPath;
|
if (!fs.existsSync(filePath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return fs.readFileSync(filePath, 'utf-8');
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.on('renderer-log', (_event, level, ...args) => {
|
||||||
|
log(`[renderer][${level}]`, ...args);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
|
log('App ready, creating window');
|
||||||
createWindow();
|
createWindow();
|
||||||
|
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
@@ -141,6 +125,9 @@ app.whenReady().then(() => {
|
|||||||
createWindow();
|
createWindow();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}).catch(err => {
|
||||||
|
log('FATAL ERROR:', err);
|
||||||
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on('window-all-closed', () => {
|
app.on('window-all-closed', () => {
|
||||||
@@ -148,3 +135,11 @@ app.on('window-all-closed', () => {
|
|||||||
app.quit();
|
app.quit();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
process.on('uncaughtException', (err) => {
|
||||||
|
log('Uncaught exception:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('unhandledRejection', (reason) => {
|
||||||
|
log('Unhandled rejection:', reason);
|
||||||
|
});
|
||||||
|
|||||||
7
electron/preload.cjs
Normal file
7
electron/preload.cjs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const { contextBridge, ipcRenderer } = require('electron');
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
|
listDocsFiles: (basePath) => ipcRenderer.invoke('list-docs-files', basePath),
|
||||||
|
readDocFile: (filePath) => ipcRenderer.invoke('read-doc-file', filePath),
|
||||||
|
log: (level, ...args) => ipcRenderer.send('renderer-log', level, ...args),
|
||||||
|
});
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
import { contextBridge, ipcRenderer } from 'electron';
|
import { contextBridge, ipcRenderer } from 'electron';
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('electronAPI', {
|
contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
selectFolder: () => ipcRenderer.invoke('select-folder'),
|
listDocsFiles: (basePath) => ipcRenderer.invoke('list-docs-files', basePath),
|
||||||
getDocsPath: () => ipcRenderer.invoke('get-docs-path'),
|
readDocFile: (filePath) => ipcRenderer.invoke('read-doc-file', filePath),
|
||||||
onDocsPathChanged: (callback: (path: string) => void) => {
|
log: (level, ...args) => ipcRenderer.send('renderer-log', level, ...args),
|
||||||
ipcRenderer.on('docs-path-changed', (_event, path) => callback(path));
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
1460
package-lock.json
generated
1460
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,8 @@
|
|||||||
"lucide-react": "^0.511.0",
|
"lucide-react": "^0.511.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"three": "^0.183.2",
|
"three": "^0.183.2",
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
|
|||||||
47
src/App.tsx
47
src/App.tsx
@@ -1,17 +1,24 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { FileText, Box, FolderOpen } from 'lucide-react';
|
import { FileText, Box } from 'lucide-react';
|
||||||
import { ApiDocViewer } from './components/ApiDocViewer';
|
import { ApiDocViewer } from './components/ApiDocViewer';
|
||||||
import BlueprintPage from './components/blueprint/BlueprintPage';
|
import BlueprintPage from './components/blueprint/BlueprintPage';
|
||||||
import { config } from './config';
|
import { config } from './config';
|
||||||
|
|
||||||
|
const _origLog = console.log;
|
||||||
|
const _origError = console.error;
|
||||||
|
const _origWarn = console.warn;
|
||||||
|
console.log = (...args) => { window.electronAPI?.log('log', ...args); _origLog.apply(console, args); };
|
||||||
|
console.error = (...args) => { window.electronAPI?.log('error', ...args); _origError.apply(console, args); };
|
||||||
|
console.warn = (...args) => { window.electronAPI?.log('warn', ...args); _origWarn.apply(console, args); };
|
||||||
|
|
||||||
type Page = 'docs' | 'blueprint';
|
type Page = 'docs' | 'blueprint';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
electronAPI?: {
|
electronAPI?: {
|
||||||
selectFolder: () => Promise<string | null>;
|
listDocsFiles: (basePath: string) => Promise<{ name: string; path: string; relativePath: string }[]>;
|
||||||
getDocsPath: () => Promise<string>;
|
readDocFile: (filePath: string) => Promise<string | null>;
|
||||||
onDocsPathChanged: (callback: (path: string) => void) => void;
|
log: (level: string, ...args: unknown[]) => void;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -22,41 +29,15 @@ function App() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = config.projectName;
|
document.title = config.projectName;
|
||||||
|
|
||||||
if (window.electronAPI) {
|
|
||||||
window.electronAPI.getDocsPath().then(setDocsPath);
|
|
||||||
window.electronAPI.onDocsPathChanged(setDocsPath);
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSelectFolder = async () => {
|
|
||||||
if (window.electronAPI) {
|
|
||||||
const path = await window.electronAPI.selectFolder();
|
|
||||||
if (path) {
|
|
||||||
setDocsPath(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex flex-col">
|
<div className="h-screen flex flex-col">
|
||||||
<header className="h-12 bg-zinc-900 border-b border-zinc-800 flex items-center px-4 justify-between">
|
<header className="h-12 bg-zinc-900 border-b border-zinc-800 flex items-center px-4 justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-sm font-medium text-white">{config.projectName}</h1>
|
<h1 className="text-sm font-medium text-white">{config.projectName}</h1>
|
||||||
{docsPath && (
|
|
||||||
<span className="text-xs text-zinc-500">📁 {docsPath}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{window.electronAPI && (
|
|
||||||
<button
|
|
||||||
onClick={handleSelectFolder}
|
|
||||||
className="flex items-center gap-2 px-3 py-1.5 rounded text-sm text-zinc-400 hover:text-white hover:bg-zinc-800/50"
|
|
||||||
title="选择文档文件夹"
|
|
||||||
>
|
|
||||||
<FolderOpen size={16} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<nav className="flex gap-1">
|
<nav className="flex gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage('docs')}
|
onClick={() => setCurrentPage('docs')}
|
||||||
@@ -84,7 +65,11 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="flex-1 overflow-hidden">
|
<main className="flex-1 overflow-hidden">
|
||||||
{currentPage === 'docs' ? <ApiDocViewer /> : <BlueprintPage />}
|
{currentPage === 'docs' ? (
|
||||||
|
<ApiDocViewer onDocsPathChange={setDocsPath} />
|
||||||
|
) : (
|
||||||
|
<BlueprintPage docsPath={docsPath} />
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,65 +1,129 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useCallback } from 'react'
|
||||||
|
import { X, Plus, AlertCircle } from 'lucide-react'
|
||||||
import { DocTree } from './DocTree'
|
import { DocTree } from './DocTree'
|
||||||
import { DocContent } from './DocContent'
|
import { DocContent } from './DocContent'
|
||||||
import { parseMarkdown, buildFileTree } from '@/lib/parser'
|
import { buildFileTree } from '@/lib/parser'
|
||||||
import type { DocFile, ParsedDoc } from '@/lib/types'
|
import type { DocFile } from '@/lib/types'
|
||||||
import { config } from '@/config'
|
|
||||||
|
|
||||||
const modules = import.meta.glob('../docs/api/**/*.md', { as: 'raw', eager: true })
|
interface ExternalDoc {
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
relativePath: string
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
const DOCS_FILES: Record<string, string> = Object.fromEntries(
|
interface ApiDocViewerProps {
|
||||||
Object.entries(modules).map(([path, content]) => {
|
onDocsPathChange?: (path: string) => void;
|
||||||
const relativePath = path.replace('../docs/api/', '')
|
}
|
||||||
const fileContent = typeof content === 'string' ? content : ''
|
|
||||||
return [relativePath, fileContent]
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
export const ApiDocViewer = () => {
|
export const ApiDocViewer = ({ onDocsPathChange }: ApiDocViewerProps) => {
|
||||||
const [fileTree, setFileTree] = useState<DocFile[]>([])
|
const [fileTree, setFileTree] = useState<DocFile[]>([])
|
||||||
const [selectedPath, setSelectedPath] = useState<string | undefined>()
|
const [selectedPath, setSelectedPath] = useState<string | undefined>()
|
||||||
const [currentDoc, setCurrentDoc] = useState<ParsedDoc | null>(null)
|
const [currentContent, setCurrentContent] = useState<string>('')
|
||||||
|
const [showModal, setShowModal] = useState(false)
|
||||||
|
const [docsPath, setDocsPath] = useState('')
|
||||||
|
const [externalDocs, setExternalDocs] = useState<ExternalDoc[]>([])
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [errorMsg, setErrorMsg] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
const loadDocsFromPath = async (basePath: string): Promise<boolean> => {
|
||||||
const files = Object.keys(DOCS_FILES)
|
if (!basePath) {
|
||||||
const tree = buildFileTree(files, '/')
|
setErrorMsg('请输入文档路径')
|
||||||
setFileTree(tree)
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (files.length > 0) {
|
if (!window.electronAPI) {
|
||||||
setSelectedPath(files[0])
|
setErrorMsg('Electron API 不可用,请使用打包后的应用')
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}, [])
|
|
||||||
|
setIsLoading(true)
|
||||||
useEffect(() => {
|
setErrorMsg(null)
|
||||||
if (selectedPath) {
|
|
||||||
const content = DOCS_FILES[selectedPath]
|
try {
|
||||||
if (content) {
|
const files = await window.electronAPI.listDocsFiles(basePath)
|
||||||
const parsed = parseMarkdown(content)
|
|
||||||
setCurrentDoc(parsed)
|
if (files.length === 0) {
|
||||||
|
setErrorMsg(`路径 "${basePath}/api" 下没有找到 .md 文件`)
|
||||||
|
setExternalDocs([])
|
||||||
|
setFileTree([])
|
||||||
|
setSelectedPath(undefined)
|
||||||
|
setCurrentContent('')
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const docs: ExternalDoc[] = []
|
||||||
|
for (const file of files) {
|
||||||
|
const content = await window.electronAPI.readDocFile(file.path)
|
||||||
|
if (content) {
|
||||||
|
docs.push({ name: file.name, path: file.path, relativePath: file.relativePath, content })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setExternalDocs(docs)
|
||||||
|
const fileList = docs.map(d => d.relativePath.replace(/^api\//, ''))
|
||||||
|
const tree = buildFileTree(fileList, '/')
|
||||||
|
setFileTree(tree)
|
||||||
|
|
||||||
|
if (fileList.length > 0) {
|
||||||
|
setSelectedPath(fileList[0])
|
||||||
|
setCurrentContent(docs[0].content)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
setErrorMsg(`加载失败: ${err}`)
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
}, [selectedPath])
|
}
|
||||||
|
|
||||||
const handleSelect = useCallback((file: DocFile) => {
|
const handleSelect = useCallback((file: DocFile) => {
|
||||||
if (!file.isDir) {
|
if (!file.isDir) {
|
||||||
setSelectedPath(file.relativePath)
|
setSelectedPath(file.relativePath)
|
||||||
|
const doc = externalDocs.find(d => d.relativePath === file.relativePath)
|
||||||
|
if (doc) {
|
||||||
|
setCurrentContent(doc.content)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [])
|
}, [externalDocs])
|
||||||
|
|
||||||
const handleReferenceClick = useCallback((ref: string) => {
|
const handleAddDocs = async () => {
|
||||||
const normalizedRef = ref.replace('.md', '').replace(/^\.\.\//, '')
|
if (isLoading) return;
|
||||||
const allFiles = Object.keys(DOCS_FILES)
|
const success = await loadDocsFromPath(docsPath.trim())
|
||||||
const match = allFiles.find(f => f.replace('.md', '') === normalizedRef)
|
if (success) {
|
||||||
if (match) {
|
setShowModal(false)
|
||||||
setSelectedPath(match)
|
onDocsPathChange?.(docsPath.trim())
|
||||||
}
|
}
|
||||||
}, [])
|
}
|
||||||
|
|
||||||
|
const handleReferenceClick = useCallback((href: string) => {
|
||||||
|
const ref = href.replace(/\.md$/, '')
|
||||||
|
const match = externalDocs.find(d => {
|
||||||
|
const docPath = d.relativePath.replace(/\.md$/, '')
|
||||||
|
return docPath === ref || docPath.endsWith('/' + ref.split('/').pop())
|
||||||
|
})
|
||||||
|
if (match) {
|
||||||
|
setSelectedPath(match.relativePath.replace(/^api\//, ''))
|
||||||
|
setCurrentContent(match.content)
|
||||||
|
}
|
||||||
|
}, [externalDocs])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-[#1e1e1e]">
|
<div className="flex h-full bg-[#1e1e1e]">
|
||||||
<aside className="w-64 bg-[#252526] border-r border-[#3c3c3c] flex flex-col">
|
<aside className="w-64 bg-[#252526] border-r border-[#3c3c3c] flex flex-col">
|
||||||
<div className="p-3 border-b border-[#3c3c3c]">
|
<div className="p-3 border-b border-[#3c3c3c] flex items-center justify-between">
|
||||||
<h2 className="text-sm font-semibold text-gray-200">{config.sidebarTitle}</h2>
|
<h2 className="text-sm font-semibold text-gray-200">文档目录</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowModal(true)
|
||||||
|
setErrorMsg(null)
|
||||||
|
}}
|
||||||
|
className="p-1 hover:bg-[#3c3c3c] rounded"
|
||||||
|
title="添加文档路径"
|
||||||
|
>
|
||||||
|
<Plus size={16} className="text-gray-400" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<DocTree
|
<DocTree
|
||||||
@@ -72,10 +136,64 @@ export const ApiDocViewer = () => {
|
|||||||
|
|
||||||
<main className="flex-1 overflow-hidden">
|
<main className="flex-1 overflow-hidden">
|
||||||
<DocContent
|
<DocContent
|
||||||
doc={currentDoc}
|
content={currentContent}
|
||||||
onReferenceClick={handleReferenceClick}
|
onReferenceClick={handleReferenceClick}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{showModal && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-[#252526] rounded-lg border border-[#3c3c3c] w-96 p-4">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-200">添加文档路径</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowModal(false)}
|
||||||
|
className="p-1 hover:bg-[#3c3c3c] rounded"
|
||||||
|
>
|
||||||
|
<X size={16} className="text-gray-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errorMsg && (
|
||||||
|
<div className="mb-4 p-3 bg-red-900/30 border border-red-800 rounded flex items-start gap-2">
|
||||||
|
<AlertCircle size={16} className="text-red-400 mt-0.5 flex-shrink-0" />
|
||||||
|
<span className="text-sm text-red-300">{errorMsg}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={docsPath}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDocsPath(e.target.value)
|
||||||
|
setErrorMsg(null)
|
||||||
|
}}
|
||||||
|
placeholder="输入 docs 文件夹路径"
|
||||||
|
className="w-full px-3 py-2 bg-[#1e1e1e] border border-[#3c3c3c] rounded text-sm text-gray-200 placeholder-gray-500"
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleAddDocs()}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="text-xs text-gray-500 mb-4">
|
||||||
|
示例: C:\project\docs (会自动读取 api/*.md)
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowModal(false)}
|
||||||
|
className="px-4 py-2 text-sm text-gray-400 hover:text-white"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleAddDocs}
|
||||||
|
disabled={!docsPath.trim() || isLoading}
|
||||||
|
className="px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 disabled:bg-blue-800 disabled:cursor-not-allowed rounded text-white"
|
||||||
|
>
|
||||||
|
{isLoading ? '加载中...' : '添加'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import clsx from 'clsx'
|
import ReactMarkdown from 'react-markdown'
|
||||||
import type { ParsedDoc, DocTable, DocCodeBlock } from '@/lib/types'
|
import remarkGfm from 'remark-gfm'
|
||||||
|
import type { Components } from 'react-markdown'
|
||||||
|
|
||||||
interface DocContentProps {
|
interface DocContentProps {
|
||||||
doc: ParsedDoc | null
|
content: string
|
||||||
onReferenceClick: (ref: string) => void
|
onReferenceClick: (ref: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DocContent = ({ doc, onReferenceClick }: DocContentProps) => {
|
export const DocContent = ({ content, onReferenceClick }: DocContentProps) => {
|
||||||
if (!doc) {
|
if (!content) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full text-gray-500">
|
<div className="flex items-center justify-center h-full text-gray-500">
|
||||||
<p>Select a document from the sidebar</p>
|
<p>Select a document from the sidebar</p>
|
||||||
@@ -15,175 +16,83 @@ export const DocContent = ({ doc, onReferenceClick }: DocContentProps) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const components: Components = {
|
||||||
<div className="h-full overflow-auto p-6">
|
a: ({ href, children }) => {
|
||||||
<div className="max-w-4xl mx-auto">
|
if (href && (href.startsWith('./') || href.startsWith('../'))) {
|
||||||
<h1 className="text-2xl font-bold text-white mb-4">{doc.title}</h1>
|
|
||||||
|
|
||||||
{doc.metadata.namespace && (
|
|
||||||
<div className="mb-4">
|
|
||||||
<span className="text-gray-400">Namespace: </span>
|
|
||||||
<code className="bg-gray-800 px-2 py-1 rounded text-blue-400">{doc.metadata.namespace}</code>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{doc.metadata.package && (
|
|
||||||
<div className="mb-4">
|
|
||||||
<span className="text-gray-400">Package: </span>
|
|
||||||
<code className="bg-gray-800 px-2 py-1 rounded text-blue-400">{doc.metadata.package}</code>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{doc.metadata.type && (
|
|
||||||
<div className="mb-4">
|
|
||||||
<span className="text-gray-400">Type: </span>
|
|
||||||
<span className="text-yellow-400">{doc.metadata.type}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{doc.metadata.inherits && (
|
|
||||||
<div className="mb-4">
|
|
||||||
<span className="text-gray-400">Inherits: </span>
|
|
||||||
<code className="bg-gray-800 px-2 py-1 rounded text-purple-400">{doc.metadata.inherits}</code>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{doc.metadata.description && (
|
|
||||||
<p className="text-gray-300 mb-6">{doc.metadata.description}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{doc.sections.map((section, idx) => (
|
|
||||||
<Section
|
|
||||||
key={idx}
|
|
||||||
section={section}
|
|
||||||
onReferenceClick={onReferenceClick}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{doc.references.length > 0 && (
|
|
||||||
<div className="mt-8 pt-4 border-t border-gray-700">
|
|
||||||
<h3 className="text-lg font-semibold text-white mb-2">See Also</h3>
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{doc.references.map((ref, idx) => (
|
|
||||||
<li key={idx}>
|
|
||||||
<button
|
|
||||||
onClick={() => onReferenceClick(ref)}
|
|
||||||
className="text-blue-400 hover:text-blue-300 hover:underline"
|
|
||||||
>
|
|
||||||
{ref}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SectionProps {
|
|
||||||
section: import('@/lib/types').DocSection
|
|
||||||
onReferenceClick: (ref: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const Section = ({ section, onReferenceClick }: SectionProps) => {
|
|
||||||
const level = Math.min(section.level + 1, 6)
|
|
||||||
const headingClass = clsx(
|
|
||||||
'font-semibold text-white mb-3',
|
|
||||||
section.level === 1 ? 'text-xl' : 'text-lg'
|
|
||||||
)
|
|
||||||
|
|
||||||
const renderHeading = () => {
|
|
||||||
switch (level) {
|
|
||||||
case 2: return <h2 className={headingClass}>{section.title}</h2>
|
|
||||||
case 3: return <h3 className={headingClass}>{section.title}</h3>
|
|
||||||
case 4: return <h4 className={headingClass}>{section.title}</h4>
|
|
||||||
case 5: return <h5 className={headingClass}>{section.title}</h5>
|
|
||||||
case 6: return <h6 className={headingClass}>{section.title}</h6>
|
|
||||||
default: return <h2 className={headingClass}>{section.title}</h2>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="mb-6">
|
|
||||||
{renderHeading()}
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
{section.content.map((item, idx) => {
|
|
||||||
if (item.type === 'table') {
|
|
||||||
return <TableContent key={idx} table={item.data as DocTable} />
|
|
||||||
}
|
|
||||||
if (item.type === 'code') {
|
|
||||||
return <CodeContent key={idx} code={item.data as DocCodeBlock} />
|
|
||||||
}
|
|
||||||
return <TextContent key={idx} text={item.data as string} onReferenceClick={onReferenceClick} />
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const TextContent = ({ text, onReferenceClick }: { text: string; onReferenceClick: (ref: string) => void }) => {
|
|
||||||
const renderText = () => {
|
|
||||||
const parts = text.split(/(@see\s+[^\s]+)/g)
|
|
||||||
return parts.map((part, idx) => {
|
|
||||||
const match = part.match(/@see\s+([^\s]+)/)
|
|
||||||
if (match) {
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={idx}
|
onClick={() => onReferenceClick(href)}
|
||||||
onClick={() => onReferenceClick(match[1])}
|
|
||||||
className="text-blue-400 hover:text-blue-300 hover:underline"
|
className="text-blue-400 hover:text-blue-300 hover:underline"
|
||||||
>
|
>
|
||||||
{part}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return <span key={idx}>{part}</span>
|
return (
|
||||||
})
|
<a href={href} className="text-blue-400 hover:text-blue-300 hover:underline">
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
code: ({ className, children, ...props }) => {
|
||||||
|
const match = /language-(\w+)/.exec(className || '')
|
||||||
|
if (match) {
|
||||||
|
return (
|
||||||
|
<code className={className} {...props}>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<code className="bg-gray-800 px-1.5 py-0.5 rounded text-blue-300 text-sm font-mono" {...props}>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
pre: ({ children }) => (
|
||||||
|
<pre className="bg-gray-900 border border-gray-700 rounded-lg p-4 overflow-x-auto mb-4">
|
||||||
|
{children}
|
||||||
|
</pre>
|
||||||
|
),
|
||||||
|
table: ({ children }) => (
|
||||||
|
<div className="overflow-x-auto mb-4">
|
||||||
|
<table className="w-full border-collapse text-sm">
|
||||||
|
{children}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
thead: ({ children }) => (
|
||||||
|
<thead className="bg-gray-800">
|
||||||
|
{children}
|
||||||
|
</thead>
|
||||||
|
),
|
||||||
|
th: ({ children }) => (
|
||||||
|
<th className="border border-gray-600 px-3 py-2 text-left text-gray-200 font-semibold">
|
||||||
|
{children}
|
||||||
|
</th>
|
||||||
|
),
|
||||||
|
td: ({ children }) => (
|
||||||
|
<td className="border border-gray-600 px-3 py-2 text-gray-300">
|
||||||
|
{children}
|
||||||
|
</td>
|
||||||
|
),
|
||||||
|
tr: ({ children }) => (
|
||||||
|
<tr className="hover:bg-gray-800/50">
|
||||||
|
{children}
|
||||||
|
</tr>
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
return <p className="text-gray-300">{renderText()}</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
const TableContent = ({ table }: { table: DocTable }) => {
|
|
||||||
if (!table.headers.length || !table.rows.length) return null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto">
|
<div className="h-full overflow-auto p-6">
|
||||||
<table className="w-full border-collapse text-sm">
|
<div className="max-w-4xl mx-auto text-gray-200">
|
||||||
<thead>
|
<ReactMarkdown
|
||||||
<tr className="bg-gray-800">
|
remarkPlugins={[remarkGfm]}
|
||||||
{table.headers.map((header, idx) => (
|
components={components}
|
||||||
<th key={idx} className="border border-gray-600 px-3 py-2 text-left text-gray-200 font-semibold">
|
>
|
||||||
{header}
|
{content}
|
||||||
</th>
|
</ReactMarkdown>
|
||||||
))}
|
</div>
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{table.rows.map((row, rowIdx) => (
|
|
||||||
<tr key={rowIdx} className="hover:bg-gray-800/50">
|
|
||||||
{row.map((cell, cellIdx) => (
|
|
||||||
<td key={cellIdx} className="border border-gray-600 px-3 py-2 text-gray-300">
|
|
||||||
{cell}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const CodeContent = ({ code }: { code: DocCodeBlock }) => {
|
|
||||||
return (
|
|
||||||
<pre className="bg-gray-900 border border-gray-700 rounded-lg p-4 overflow-x-auto">
|
|
||||||
<code className={`language-${code.language || 'text'} text-sm text-gray-200`}>
|
|
||||||
{code.code}
|
|
||||||
</code>
|
|
||||||
</pre>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,31 @@
|
|||||||
import Scene3D from './Scene3D';
|
import Scene3D from './Scene3D';
|
||||||
import DetailPanel from './DetailPanel';
|
import DetailPanel from './DetailPanel';
|
||||||
|
import { useBlueprintStore } from '../../store/blueprintStore';
|
||||||
|
import { useEffect, useCallback } from 'react';
|
||||||
|
import { parseBlueprintFromMd } from '../../data/blueprintParser';
|
||||||
|
|
||||||
export default function BlueprintPage() {
|
interface BlueprintPageProps {
|
||||||
|
docsPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BlueprintPage({ docsPath }: BlueprintPageProps) {
|
||||||
|
const setBlueprintData = useBlueprintStore(state => state.setBlueprintData);
|
||||||
|
|
||||||
|
const loadBlueprint = useCallback(async () => {
|
||||||
|
if (!docsPath || !window.electronAPI) return;
|
||||||
|
|
||||||
|
const blueprintPath = docsPath.replace(/\\/g, '/') + '/blueprint.md';
|
||||||
|
const content = await window.electronAPI.readDocFile(blueprintPath);
|
||||||
|
if (content) {
|
||||||
|
const data = parseBlueprintFromMd(content);
|
||||||
|
setBlueprintData(data);
|
||||||
|
}
|
||||||
|
}, [docsPath, setBlueprintData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadBlueprint();
|
||||||
|
}, [loadBlueprint]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full">
|
<div className="flex h-full">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
|||||||
@@ -55,15 +55,31 @@ function parseSubsystems(yaml: string): Subsystem[] {
|
|||||||
let current: Partial<Subsystem> = {};
|
let current: Partial<Subsystem> = {};
|
||||||
let currentField: string | null = null;
|
let currentField: string | null = null;
|
||||||
let inSubsystem = false;
|
let inSubsystem = false;
|
||||||
|
let inModules = false;
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
|
|
||||||
if (trimmed.startsWith('modules:')) {
|
if (trimmed.startsWith('modules:') || trimmed.startsWith('root:')) {
|
||||||
|
if (current.name) {
|
||||||
|
subsystems.push({
|
||||||
|
id: current.name,
|
||||||
|
name: current.name,
|
||||||
|
responsibilities: current.responsibilities || [],
|
||||||
|
provides: current.provides || [],
|
||||||
|
depends_on: current.depends_on || [],
|
||||||
|
boundary: current.boundary
|
||||||
|
});
|
||||||
|
}
|
||||||
|
current = {};
|
||||||
|
inModules = trimmed.startsWith('modules:');
|
||||||
|
inSubsystem = false;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trimmed.startsWith('- name:')) {
|
if (trimmed.startsWith('- name:')) {
|
||||||
|
if (inModules) continue;
|
||||||
|
|
||||||
if (current.name) {
|
if (current.name) {
|
||||||
subsystems.push({
|
subsystems.push({
|
||||||
id: current.name,
|
id: current.name,
|
||||||
|
|||||||
@@ -31,148 +31,181 @@ export function parseMarkdown(content: string): ParsedDoc {
|
|||||||
let tableBuffer: string[] = []
|
let tableBuffer: string[] = []
|
||||||
let inTable = false
|
let inTable = false
|
||||||
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
try {
|
||||||
const line = lines[i]
|
for (let i = 0; i < lines.length; i++) {
|
||||||
const trimmedLine = line.trim()
|
const line = lines[i]
|
||||||
|
const trimmedLine = line.trim()
|
||||||
|
|
||||||
const refMatch = trimmedLine.match(REFERENCE_REGEX)
|
try {
|
||||||
if (refMatch) {
|
const refMatch = trimmedLine.match(REFERENCE_REGEX)
|
||||||
let match
|
if (refMatch) {
|
||||||
while ((match = REFERENCE_REGEX.exec(trimmedLine)) !== null) {
|
let match
|
||||||
references.push(match[1])
|
const refRegex = /@see\s+([^\s]+)/g
|
||||||
}
|
while ((match = refRegex.exec(trimmedLine)) !== null) {
|
||||||
}
|
references.push(match[1])
|
||||||
|
}
|
||||||
const codeMatch = line.match(CODE_BLOCK_REGEX)
|
|
||||||
if (codeMatch) {
|
|
||||||
if (currentSection) {
|
|
||||||
currentSection.content = [...currentContent]
|
|
||||||
sections.push(currentSection)
|
|
||||||
currentSection = null
|
|
||||||
}
|
|
||||||
inCodeBlock = true
|
|
||||||
codeBlockLanguage = codeMatch[1] || ''
|
|
||||||
currentContent = []
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inCodeBlock) {
|
|
||||||
if (trimmedLine === '```') {
|
|
||||||
currentContent.push({
|
|
||||||
type: 'code',
|
|
||||||
data: { language: codeBlockLanguage, code: codeBlockLines.join('\n') },
|
|
||||||
} as { type: 'code'; data: DocCodeBlock })
|
|
||||||
codeBlockLines = []
|
|
||||||
codeBlockLanguage = ''
|
|
||||||
inCodeBlock = false
|
|
||||||
|
|
||||||
if (!currentSection) {
|
|
||||||
currentSection = { title: '', level: 2, content: [] }
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
codeBlockLines.push(line)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (HR_REGEX.test(trimmedLine)) {
|
const codeMatch = line.match(CODE_BLOCK_REGEX)
|
||||||
if (inTable && tableBuffer.length > 0) {
|
if (codeMatch) {
|
||||||
const table = parseTable(tableBuffer)
|
if (currentSection) {
|
||||||
if (table) {
|
currentSection.content = [...currentContent]
|
||||||
currentContent.push({ type: 'table', data: table })
|
sections.push(currentSection)
|
||||||
|
currentSection = null
|
||||||
|
}
|
||||||
|
inCodeBlock = true
|
||||||
|
codeBlockLanguage = codeMatch[1] || ''
|
||||||
|
currentContent = []
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
tableBuffer = []
|
|
||||||
inTable = false
|
if (inCodeBlock) {
|
||||||
|
if (trimmedLine === '```' || trimmedLine.startsWith('```')) {
|
||||||
|
currentContent.push({
|
||||||
|
type: 'code',
|
||||||
|
data: { language: codeBlockLanguage, code: codeBlockLines.join('\n') },
|
||||||
|
} as { type: 'code'; data: DocCodeBlock })
|
||||||
|
codeBlockLines = []
|
||||||
|
codeBlockLanguage = ''
|
||||||
|
inCodeBlock = false
|
||||||
|
|
||||||
|
if (!currentSection) {
|
||||||
|
currentSection = { title: '', level: 2, content: [] }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
codeBlockLines.push(line)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (HR_REGEX.test(trimmedLine)) {
|
||||||
|
if (inTable && tableBuffer.length > 0) {
|
||||||
|
const table = parseTable(tableBuffer)
|
||||||
|
if (table) {
|
||||||
|
currentContent.push({ type: 'table', data: table })
|
||||||
|
}
|
||||||
|
tableBuffer = []
|
||||||
|
inTable = false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.title && line.startsWith('# ')) {
|
||||||
|
result.title = line.slice(2).trim()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const headingMatch = line.match(HEADING_REGEX)
|
||||||
|
if (headingMatch) {
|
||||||
|
if (currentSection) {
|
||||||
|
currentSection.content = [...currentContent]
|
||||||
|
sections.push(currentSection)
|
||||||
|
}
|
||||||
|
const level = headingMatch[1].length
|
||||||
|
const title = headingMatch[2].trim()
|
||||||
|
currentSection = { title, level, content: [] }
|
||||||
|
currentContent = []
|
||||||
|
inTable = false
|
||||||
|
tableBuffer = []
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const metaMatch = trimmedLine.match(METADATA_REGEX)
|
||||||
|
if (metaMatch) {
|
||||||
|
const key = metaMatch[1].toLowerCase()
|
||||||
|
const value = metaMatch[2].trim()
|
||||||
|
if (key === 'namespace') metadata.namespace = value
|
||||||
|
else if (key === 'description') metadata.description = value
|
||||||
|
else if (key === 'type') metadata.type = value
|
||||||
|
else if (key === 'inherits') metadata.inherits = value
|
||||||
|
else if (key === 'package') metadata.package = value
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableMatch = line.match(TABLE_REGEX)
|
||||||
|
if (tableMatch) {
|
||||||
|
tableBuffer.push(trimmedLine)
|
||||||
|
inTable = true
|
||||||
|
continue
|
||||||
|
} else if (inTable && tableBuffer.length > 0) {
|
||||||
|
const table = parseTable(tableBuffer)
|
||||||
|
if (table) {
|
||||||
|
currentContent.push({ type: 'table', data: table })
|
||||||
|
}
|
||||||
|
tableBuffer = []
|
||||||
|
inTable = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedLine) {
|
||||||
|
currentContent.push({ type: 'text', data: trimmedLine })
|
||||||
|
}
|
||||||
|
} catch (lineError) {
|
||||||
|
console.warn('Error parsing line', i, ':', lineError)
|
||||||
}
|
}
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!result.title && line.startsWith('# ')) {
|
// 结束未闭合的代码块
|
||||||
result.title = line.slice(2).trim()
|
if (inCodeBlock && codeBlockLines.length > 0) {
|
||||||
continue
|
currentContent.push({
|
||||||
|
type: 'code',
|
||||||
|
data: { language: codeBlockLanguage, code: codeBlockLines.join('\n') },
|
||||||
|
} as { type: 'code'; data: DocCodeBlock })
|
||||||
}
|
}
|
||||||
|
|
||||||
const headingMatch = line.match(HEADING_REGEX)
|
// 结束未闭合的表格
|
||||||
if (headingMatch) {
|
if (inTable && tableBuffer.length > 0) {
|
||||||
if (currentSection) {
|
|
||||||
currentSection.content = [...currentContent]
|
|
||||||
sections.push(currentSection)
|
|
||||||
}
|
|
||||||
const level = headingMatch[1].length
|
|
||||||
const title = headingMatch[2].trim()
|
|
||||||
currentSection = { title, level, content: [] }
|
|
||||||
currentContent = []
|
|
||||||
inTable = false
|
|
||||||
tableBuffer = []
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const metaMatch = trimmedLine.match(METADATA_REGEX)
|
|
||||||
if (metaMatch) {
|
|
||||||
const key = metaMatch[1].toLowerCase()
|
|
||||||
const value = metaMatch[2].trim()
|
|
||||||
if (key === 'namespace') metadata.namespace = value
|
|
||||||
else if (key === 'description') metadata.description = value
|
|
||||||
else if (key === 'type') metadata.type = value
|
|
||||||
else if (key === 'inherits') metadata.inherits = value
|
|
||||||
else if (key === 'package') metadata.package = value
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const tableMatch = line.match(TABLE_REGEX)
|
|
||||||
if (tableMatch) {
|
|
||||||
const isSeparator = /^[\s|*-]+$/.test(trimmedLine.replace(/\|/g, '').trim())
|
|
||||||
if (!isSeparator) {
|
|
||||||
tableBuffer.push(trimmedLine)
|
|
||||||
inTable = true
|
|
||||||
} else {
|
|
||||||
inTable = true
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
} else if (inTable && tableBuffer.length > 0) {
|
|
||||||
const table = parseTable(tableBuffer)
|
const table = parseTable(tableBuffer)
|
||||||
if (table) {
|
if (table) {
|
||||||
currentContent.push({ type: 'table', data: table })
|
currentContent.push({ type: 'table', data: table })
|
||||||
}
|
}
|
||||||
tableBuffer = []
|
|
||||||
inTable = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trimmedLine) {
|
if (currentSection) {
|
||||||
currentContent.push({ type: 'text', data: trimmedLine })
|
currentSection.content = [...currentContent]
|
||||||
|
sections.push(currentSection)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.metadata = metadata
|
||||||
|
result.sections = sections
|
||||||
|
result.references = references
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Markdown parse error:', error)
|
||||||
|
return {
|
||||||
|
title: '',
|
||||||
|
metadata: {},
|
||||||
|
sections: [],
|
||||||
|
references: [],
|
||||||
|
parseError: true,
|
||||||
|
rawContent: content
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentSection) {
|
|
||||||
currentSection.content = [...currentContent]
|
|
||||||
sections.push(currentSection)
|
|
||||||
}
|
|
||||||
|
|
||||||
result.metadata = metadata
|
|
||||||
result.sections = sections
|
|
||||||
result.references = references
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseTable(tableLines: string[]): DocTable | null {
|
function parseTable(tableLines: string[]): DocTable | null {
|
||||||
if (tableLines.length < 1) return null
|
try {
|
||||||
|
if (tableLines.length < 1) return null
|
||||||
|
|
||||||
const headers = tableLines[0].split('|').filter(h => h.trim()).map(h => h.trim())
|
const headers = tableLines[0].split('|').filter(h => h.trim()).map(h => h.trim())
|
||||||
const rows: string[][] = []
|
const rows: string[][] = []
|
||||||
|
|
||||||
for (let i = 1; i < tableLines.length; i++) {
|
for (let i = 1; i < tableLines.length; i++) {
|
||||||
const line = tableLines[i]
|
const line = tableLines[i]
|
||||||
const isSeparator = /^[\s|*-]+$/.test(line.replace(/\|/g, '').trim())
|
const cleaned = line.replace(/\|/g, '').trim()
|
||||||
if (isSeparator) continue
|
const isSeparator = /^[-\s]+$/.test(cleaned)
|
||||||
|
if (isSeparator) continue
|
||||||
const cells = line.split('|').filter(c => c.trim()).map(c => c.trim())
|
|
||||||
if (cells.length > 0) {
|
const cells = line.split('|').filter(c => c.trim()).map(c => c.trim())
|
||||||
rows.push(cells)
|
if (cells.length > 0) {
|
||||||
|
rows.push(cells)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return { headers, rows }
|
return { headers, rows }
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDisplayName(filename: string): string {
|
export function getDisplayName(filename: string): string {
|
||||||
|
|||||||
@@ -35,4 +35,6 @@ export interface ParsedDoc {
|
|||||||
metadata: DocMetadata
|
metadata: DocMetadata
|
||||||
sections: DocSection[]
|
sections: DocSection[]
|
||||||
references: string[]
|
references: string[]
|
||||||
|
parseError?: boolean
|
||||||
|
rawContent?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { blueprintData, type BlueprintData } from '../data/blueprintParser';
|
import { type BlueprintData } from '../data/blueprintParser';
|
||||||
|
|
||||||
interface BlueprintStore {
|
interface BlueprintStore {
|
||||||
blueprint: BlueprintData;
|
blueprint: BlueprintData;
|
||||||
selectedNode: string | null;
|
selectedNode: string | null;
|
||||||
setSelectedNode: (id: string | null) => void;
|
setSelectedNode: (id: string | null) => void;
|
||||||
|
setBlueprintData: (data: BlueprintData) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultBlueprint: BlueprintData = {
|
||||||
|
meta: { name: 'Unknown', version: '0.0.0', type: 'unknown', description: '', target_runtime: '' },
|
||||||
|
subsystems: [],
|
||||||
|
modules: []
|
||||||
|
};
|
||||||
|
|
||||||
export const useBlueprintStore = create<BlueprintStore>((set) => ({
|
export const useBlueprintStore = create<BlueprintStore>((set) => ({
|
||||||
blueprint: blueprintData,
|
blueprint: defaultBlueprint,
|
||||||
selectedNode: null,
|
selectedNode: null,
|
||||||
setSelectedNode: (id) => set({ selectedNode: id }),
|
setSelectedNode: (id) => set({ selectedNode: id }),
|
||||||
|
setBlueprintData: (data) => set({ blueprint: data }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import react from '@vitejs/plugin-react';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
base: './',
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, './src'),
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import path from 'path'
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
base: './',
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, './src'),
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
|||||||
Reference in New Issue
Block a user