开头介绍

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

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

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

演示和下载

  • 功能演示

    FlashMintlify插件智能重命名

  • 插件功能完全演示

    一行代码不写完全用 AI 实现 VSCode 插件:FlashMintlify

  • VSCode 插件市场下载地址:https://marketplace.visualstudio.com/items?itemName=FlashDocs.flash-mintlify
  • GitHub 仓库链接:https://github.com/Match-Yang/FlashMintlify

功能点详解

用户痛点分析

为什么需要重命名文件或者移动文件?因为对于 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. 进度反馈:提供详细的处理进度和结果摘要

架构图解

整体架构图

重命名
文件夹移动
文件系统变化
FileWatcher
操作类型检测
单文件处理
批量文件处理
MintlifyLanguageService
LinkUpdater
ImportUpdater
NavigationUpdater
ProgressReporter
扫描Markdown文件
扫描所有文件
更新docs.json
更新内部链接
更新import语句
更新导航配置
应用WorkspaceEdit
显示进度和结果

文件重命名检测流程

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

引用更新处理流程

内部链接
Import语句
导航配置
文件路径变更
FilePathResolver
计算新旧路径
更新类型
LinkUpdater
ImportUpdater
NavigationUpdater
扫描所有Markdown文件
正则匹配链接
替换链接路径
扫描所有相关文件
正则匹配import
替换import路径
读取docs.json
递归更新导航
写入更新配置
WorkspaceEdit
应用所有更改

代码实现

核心 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}`);
        }
    }
}

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;
    }
}

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(/\/$/, '');
    }
}

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;
    }
}

关键实现要点

  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 文档维护变得轻松愉快,真正实现了"让写作回归本质"的目标。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐