Fast Car

Back

Mintlify 是一个现代化的技术写作产品,支持使用 MDX 写文档,界面简洁易用,针对需要写公开文档的技术工作非常友好。作为一名全栈工程师和 Mintlify 的深度用户,我在日常技术写作过程中遇到了一个让人头疼的问题:

当我需要调整文档的目录结构时,重命名或移动文件后,所有引用这些文件的 import 语句和内部链接都需要手动逐一查找和修改。这个过程不仅耗时费力,而且极容易出错。一个大型文档项目可能包含数百上千个文件,它们之间通过复杂的引用关系相互连接,手动维护这些关系简直是噩梦。

技术写作者应该把重心放在内容架构和核心价值传达上,而不是把时间花费在这些机械性的维护工作上。受到现代 IDE 智能重构功能的启发,我决定为 FlashMintlify 插件实现一个基于 LanguageServer 的文件引用跟踪系统,让文件重命名和移动变得智能无忧。

演示和下载#

功能点详解#

用户痛点分析#

为什么需要重命名文件或者移动文件?因为对于 Mintlify 来说,最终的文档生成页面后的可访问 URL 是与文件路径及文件名对应的。如果因为产品调整、框架调整等原因要调整最终对外 URL 以便更好做 SEO ,就会需要调整文件名和目录结构。

在传统的 Mintlify 文档维护过程中,开发者或文档工程师经常面临以下痛点:

  1. 引用关系复杂:文档之间通过 import 语句和内部链接形成复杂的依赖网络
  2. 手动维护困难:重命名文件后需要逐个查找和修改所有引用位置
  3. 容易遗漏错误:人工查找容易遗漏某些引用,导致链接失效
  4. 重构成本高:大规模目录调整变成了高风险、高成本的操作
  5. 开发效率低:大量时间浪费在机械性的维护工作上

设计思路#

LanguageServer 文件引用跟踪系统的设计灵感来源于现代 IDE 的智能重构功能:

  • VSCode 的 TypeScript 重构:重命名变量时自动更新所有引用
  • IntelliJ IDEA 的智能重构:移动类文件时自动更新 import 语句
  • 现代编辑器的依赖分析:实时分析代码依赖关系

核心设计原则:

  1. 实时监听:通过 FileSystemWatcher 实时监听文件系统变化
  2. 智能识别:区分文件重命名、移动和删除操作
  3. 批量处理:支持文件夹级别的批量操作
  4. 多类型支持:同时处理 import 语句、内部链接和导航配置
  5. 进度反馈:提供详细的处理进度和结果摘要

架构图解#

整体架构图#

graph TB A[文件系统变化] --> B[FileWatcher] B --> C{操作类型检测} C -->|重命名| D[单文件处理] C -->|文件夹移动| E[批量文件处理] D --> F[MintlifyLanguageService] E --> F F --> G[LinkUpdater] F --> H[ImportUpdater] F --> I[NavigationUpdater] F --> J[ProgressReporter] G --> K[扫描Markdown文件] H --> L[扫描所有文件] I --> M[更新docs.json] K --> N[更新内部链接] L --> O[更新import语句] M --> P[更新导航配置] N --> Q[应用WorkspaceEdit] O --> Q P --> Q J --> R[显示进度和结果]

文件重命名检测流程#

sequenceDiagram participant FS as 文件系统 participant FW as FileWatcher participant LS as LanguageService participant U as 更新器组件 FS->>FW: 文件删除事件 FW->>FW: 记录删除时间戳 FS->>FW: 文件创建事件 FW->>FW: 检查是否为重命名 alt 在时间窗口内找到匹配 FW->>LS: 触发重命名事件 LS->>U: 并行执行更新任务 U->>U: 更新内部链接 (30%) U->>U: 更新import语句 (30%) U->>U: 更新导航配置 (30%) U->>LS: 返回更新结果 LS->>LS: 合并结果并显示摘要 else 超时或无匹配 FW->>FW: 清理缓存,视为独立操作 end

引用更新处理流程#

graph LR A[文件路径变更] --> B[FilePathResolver] B --> C[计算新旧路径] C --> D{更新类型} D -->|内部链接| E[LinkUpdater] D -->|Import语句| F[ImportUpdater] D -->|导航配置| G[NavigationUpdater] E --> E1[扫描所有Markdown文件] E1 --> E2[正则匹配链接] E2 --> E3[替换链接路径] F --> F1[扫描所有相关文件] F1 --> F2[正则匹配import] F2 --> F3[替换import路径] G --> G1[读取docs.json] G1 --> G2[递归更新导航] G2 --> G3[写入更新配置] E3 --> H[WorkspaceEdit] F3 --> H G3 --> H H --> I[应用所有更改]

代码实现#

核心 LanguageService 实现#

export class MintlifyLanguageService {
    private pathResolver: FilePathResolver;
    private fileWatcher: FileWatcher;
    private linkUpdater: LinkUpdater;
    private importUpdater: ImportUpdater;
    private navigationUpdater: NavigationUpdater;
    private progressReporter: ProgressReporter;

    constructor() {
        this.pathResolver = new FilePathResolver();
        this.fileWatcher = new FileWatcher(this.pathResolver);
        this.linkUpdater = new LinkUpdater(this.pathResolver);
        this.importUpdater = new ImportUpdater(this.pathResolver);
        this.navigationUpdater = new NavigationUpdater(this.pathResolver);
        this.progressReporter = new ProgressReporter();
    }

    start(): void {
        // 开始监听文件变更
        this.fileWatcher.startWatching(
            this.handleFileChange.bind(this),
            this.handleFolderChange.bind(this)
        );
    }

    private async handleFileChange(event: FileChangeEvent): Promise<void> {
        try {
            const progressPromise = this.progressReporter.start('Updating file references...', 100);
            const result = await this.updateReferencesForFile(event.oldPath, event.newPath);
            this.progressReporter.complete();
            await progressPromise;
            showResultSummary(result);
        } catch (error) {
            this.progressReporter.complete();
            vscode.window.showErrorMessage(`更新文件引用时出错: ${error.message}`);
        }
    }
}
typescript

FileWatcher 重命名检测#

export class FileWatcher {
    private deletedFiles = new Map<string, number>();
    private createdFiles = new Map<string, number>();
    private readonly RENAME_DETECTION_TIMEOUT = 1000; // 1秒检测窗口

    private onFileCreated(uri: vscode.Uri): void {
        const filePath = uri.fsPath;
        const now = Date.now();

        // 检查是否为重命名操作(在短时间内有对应的删除事件)
        const renamedFromPath = this.findRecentlyDeletedFile(filePath, now);

        if (renamedFromPath) {
            // 检测到重命名操作
            this.deletedFiles.delete(renamedFromPath);
            
            const event: FileChangeEvent = {
                type: 'rename',
                oldPath: renamedFromPath,
                newPath: filePath,
                isDirectory: false
            };

            this.onFileChangeCallback?.(event);
        } else {
            // 记录创建事件,可能是移动操作的一部分
            this.createdFiles.set(filePath, now);
        }
    }

    private findRecentlyDeletedFile(newPath: string, currentTime: number): string | null {
        for (const [deletedPath, deleteTime] of this.deletedFiles.entries()) {
            if (currentTime - deleteTime <= this.RENAME_DETECTION_TIMEOUT) {
                // 检查是否为同一文件的重命名(基于文件名或路径相似性)
                if (this.isLikelyRename(deletedPath, newPath)) {
                    return deletedPath;
                }
            }
        }
        return null;
    }
}
typescript

LinkUpdater 内部链接更新#

export class LinkUpdater {
    async updateLinksForFile(
        oldFilePath: string, 
        newFilePath: string,
        progressCallback?: (message: string) => void
    ): Promise<UpdateResult> {
        const result = createEmptyResult();

        try {
            const markdownFiles = await this.pathResolver.getAllMarkdownFiles();
            const oldLinkPath = this.pathResolver.toInternalLinkPath(oldFilePath);
            const newLinkPath = this.pathResolver.toInternalLinkPath(newFilePath);

            for (let i = 0; i < markdownFiles.length; i++) {
                const filePath = markdownFiles[i];
                progressCallback?.(`Processing ${this.pathResolver.toRelativePath(filePath)} (${i + 1}/${markdownFiles.length})`);

                const fileResult = await this.updateLinksInFile(filePath, oldLinkPath, newLinkPath);
                if (fileResult.linksUpdated > 0) {
                    result.linksUpdated += fileResult.linksUpdated;
                    result.updatedFiles.push(this.pathResolver.toRelativePath(filePath));
                }
            }
        } catch (error) {
            result.errors.push(`Error updating internal links: ${error.message}`);
        }

        return result;
    }

    private async updateLinksInFile(
        filePath: string, 
        oldLinkPath: string, 
        newLinkPath: string
    ): Promise<UpdateResult> {
        const result = createEmptyResult();
        
        try {
            const content = fs.readFileSync(filePath, 'utf8');
            let updatedContent = content;
            let linksUpdated = 0;

            // 匹配 Markdown 链接的正则表达式: [link text](link path)
            const linkRegex = /\[([^\]]*)\]\(([^)]+)\)/g;

            updatedContent = content.replace(linkRegex, (match, linkText, linkPath) => {
                if (linkPath.startsWith('/') && !linkPath.includes('http')) {
                    let cleanLinkPath = linkPath.split('#')[0].split('?')[0];
                    cleanLinkPath = cleanLinkPath.replace(/\\/g, '/');
                    
                    const normalizedOld = oldLinkPath.replace(/\.(md|mdx)$/i, '');
                    
                    if (cleanLinkPath === normalizedOld) {
                        linksUpdated++;
                        // 保留原始的锚点和查询参数
                        const anchorIndex = linkPath.indexOf('#');
                        const queryIndex = linkPath.indexOf('?');
                        const suffixStart = [anchorIndex, queryIndex]
                            .filter(i => i >= 0)
                            .sort((a, b) => a - b)[0] ?? linkPath.length;
                        const suffix = linkPath.substring(suffixStart);

                        return `[${linkText}](${newLinkPath}${suffix})`;
                    }
                }
                return match;
            });

            // 处理 MDX/JSX 风格的链接: <a href="/..."> 或 <Link href="/...">
            const attrRegex = /(\b(href|to)\s*=\s*["'])([^"']+)(["'])/gi;
            updatedContent = updatedContent.replace(attrRegex, (match, prefix, _attr, url, suffixQuote) => {
                if (url && url.startsWith('/') && !url.includes('http')) {
                    const normalizedUrl = this.normalize(url);
                    const normalizedOldNoExt = this.normalize(oldLinkPath);
                    
                    if (normalizedUrl === normalizedOldNoExt) {
                        const anchorIndex = url.indexOf('#');
                        const queryIndex = url.indexOf('?');
                        const suffixStart = [anchorIndex, queryIndex]
                            .filter(i => i >= 0)
                            .sort((a,b)=>a-b)[0] ?? url.length;
                        const suffix = url.substring(suffixStart);
                        
                        linksUpdated++;
                        return `${prefix}${newLinkPath}${suffix}${suffixQuote}`;
                    }
                }
                return match;
            });

            if (linksUpdated > 0) {
                fs.writeFileSync(filePath, updatedContent, 'utf8');
                result.linksUpdated = linksUpdated;
            }
        } catch (error) {
            result.errors.push(`Error updating links in ${filePath}: ${error.message}`);
        }

        return result;
    }

    private normalize(path: string): string {
        return path
            .replace(/#.*$/, '')
            .replace(/\?.*$/, '')
            .replace(/\\/g, '/')
            .replace(/\.(md|mdx)$/i, '')
            .replace(/\/$/, '');
    }
}
typescript

ImportUpdater import 语句更新#

export class ImportUpdater {
    async updateImportsForFile(
        oldFilePath: string, 
        newFilePath: string,
        progressCallback?: (message: string) => void
    ): Promise<UpdateResult> {
        const result = createEmptyResult();

        try {
            const mintlifyFiles = await this.pathResolver.getAllMintlifyFiles();
            const oldImportPath = this.pathResolver.toImportPath(oldFilePath);
            const newImportPath = this.pathResolver.toImportPath(newFilePath);

            for (let i = 0; i < mintlifyFiles.length; i++) {
                const filePath = mintlifyFiles[i];
                progressCallback?.(`Processing ${this.pathResolver.toRelativePath(filePath)} (${i + 1}/${mintlifyFiles.length})`);

                const fileResult = await this.updateImportsInFile(filePath, oldImportPath, newImportPath);
                if (fileResult.importsUpdated > 0) {
                    result.importsUpdated += fileResult.importsUpdated;
                    result.updatedFiles.push(this.pathResolver.toRelativePath(filePath));
                }
            }
        } catch (error) {
            result.errors.push(`Error updating import statements: ${error.message}`);
        }

        return result;
    }

    private async updateImportsInFile(
        filePath: string, 
        oldImportPath: string, 
        newImportPath: string
    ): Promise<UpdateResult> {
        const result = createEmptyResult();

        try {
            const content = fs.readFileSync(filePath, 'utf8');
            let updatedContent = content;
            let importsUpdated = 0;

            // 匹配各种 import 语句格式的正则表达式
            // import MySnippet from '/path/to/file.mdx'
            // import { myName, myObject } from '/path/to/file.mdx'
            // import * as Something from '/path/to/file.mdx'
            const importRegex = /import\s+(?:(?:\{[^}]+\}|\w+|\*\s+as\s+\w+)\s+from\s+)?['"]([^'"]+)['"]/g;
            
            updatedContent = content.replace(importRegex, (match, importPath) => {
                // 检查是否为内部 import(以 / 开头)
                if (importPath.startsWith('/')) {
                    if (importPath === oldImportPath) {
                        importsUpdated++;
                        return match.replace(oldImportPath, newImportPath);
                    }
                }
                return match;
            });

            if (importsUpdated > 0) {
                fs.writeFileSync(filePath, updatedContent, 'utf8');
                result.importsUpdated = importsUpdated;
            }
        } catch (error) {
            result.errors.push(`Error updating imports in ${filePath}: ${error.message}`);
        }

        return result;
    }
}
typescript

关键实现要点#

  1. 智能重命名检测:通过时间窗口和路径相似性算法识别文件重命名操作
  2. 并行处理架构:LinkUpdater、ImportUpdater 和 NavigationUpdater 并行工作
  3. 正则表达式匹配:支持多种 Markdown 链接和 import 语句格式
  4. 路径规范化:统一处理不同操作系统的路径分隔符
  5. 进度反馈机制:实时显示处理进度和详细结果摘要
  6. 错误处理:完善的异常捕获和用户友好的错误提示

简单总结#

基于 LanguageServer 的文件引用跟踪系统成功解决了 Mintlify 文档维护中的核心痛点。通过智能的文件系统监听和自动化的引用更新,我们实现了:

  1. 零手动维护:文件重命名和移动后自动更新所有引用
  2. 高准确性:通过多重检测机制确保引用更新的准确性
  3. 批量处理能力:支持大规模目录结构调整
  4. 实时反馈:详细的进度显示和结果摘要
  5. 多格式支持:同时处理内部链接、import 语句和导航配置

这个功能对技术写作效率的提升是革命性的,让开发者可以放心地进行大规模的文档重构,而不用担心破坏现有的引用关系。在 AI IDE 如 Cursor 的加持下,整个开发过程变得更加高效,从架构设计到代码实现,AI 助手提供了强有力的支持。

后续可以改进的方向包括:

  • 增加对更多文件类型的支持(如 JSON、YAML 配置文件)
  • 实现引用关系的可视化展示
  • 添加引用完整性检查功能
  • 支持自定义引用格式的配置

通过这样的智能化功能,FlashMintlify 插件正在成为技术写作者的得力助手,让 Mintlify 文档维护变得轻松愉快,真正实现了”让写作回归本质”的目标。

(Part4)用 AI IDE 实现 VSCode 插件系列:LanguageServer 让文件重命名智能无忧
https://astro-pure.js.org/blog/vscode-extension/p4-language-server-file-rename-tracking
Author Oliver Yeung
Published at 2025年9月8日
Comment seems to stuck. Try to refresh?✨