Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

仿jsDoc写一个最简单的文档生成 #37

Open
CatsAndMice opened this issue Jul 12, 2022 · 0 comments
Open

仿jsDoc写一个最简单的文档生成 #37

CatsAndMice opened this issue Jul 12, 2022 · 0 comments

Comments

@CatsAndMice
Copy link
Owner

玩转nodeJs文件模块

50+行代码搞定一行命令更新Npm包

上列两篇文章介绍了作者在开发一个工具库中,拥抱NodeJs简化一些重复性工作的实践。

但还不够!!!还可以再简化。

上图,从isNaN.ts 转成isNaN.md,在/*...*/写好注释直接生成该方法的文档介绍,这是我想要的。

类似jsDoc工具提供的功能。

其实,学习编程的过程就是学习造轮子的过程,咱也不重新造轮子,仿写一个最简单的功能即可。

step1:前置构思

  • JavaScript注释一般有两种方式:单行注释、多行注释

    单行注释

    //单行注释

    多行注释

    /**
    *多行注释
    *
    */

    使用的是TypeScript语言,它是JavaScript的超集,注释方式与JavaScript完全是一样的。

    我们只考虑多行注释,忽略掉单行注释。

  • 多行注释应该包含以下部分:

    • 方法的描述,具体做什么的

    • 使用者需要知晓该方法是哪个版本新增的

    • 调用方法需要传入的参数详情

    • 调用方法后,它返回的值

    • 使用该方法的具体示例代码

    多行注释时,需要规定关键字段用于表示上述部分的内容。

    规定:@desc、@version、@param、@return、@example 分别表示方法描述、该方法新增的版本、方法传入的参数详情、方法调用返回的值、示例代码。

step2: 实现思路

  • 提取代码文件中的多行注释

    代码文件不止一个,这边需要遍历获取/src/xx/ 路径下所有的.ts 文件:

    import { srcPath } from "../build/const"
    import getSrcLists from "../build/getSrcLists"
    
    (async () => {
        const lists = await getSrcLists(srcPath)
    })()

    srcPath 即为目录src 路径, const srcPath = path.join(__dirname, '../src'); 一行代码得到src绝对路径。

    getSrcLists 方法用于获取src目录下所有的文件或文件夹:

    import fsPromises from "fs/promises";
    export default async (examplePath: string) => {
        const dirs = await fsPromises.readdir(examplePath)
        const isIncludes = (dir: any[] | string) => dir.includes('.DS_Store')
        if (isIncludes(dirs)) {
            const index = dirs.findIndex((dir) => isIncludes(dir))
            dirs.splice(index, 1)
        }
        return dirs
    }

    调用NodeJS原生文件模块fs/promises下的fsPromises.readdir(examplePath)方法,即可获取到examplePath路径下所有的文件或文件夹。

    有坑小心,MacBook系统有文件夹下会自动生成.DS_Store 文件的问题,至于什么是.DS_Store 各位读者自己去寻找答案了。.DS_Store我们不需要,而且会导致拼接路径读取文件时报错,这里需要将其移除掉。

    src目录结构如下:

    /src
      /Array
        /customKey.ts
        /getMax.ts
        /...
      /Date
        /isDate.ts
        /...
      /...
        
    

    想获取.ts文件,即路径上还需要再拼接一个文件夹名,再次调用getSrcLists方法:

    import { srcPath } from "../build/const"
    import getSrcLists from "../build/getSrcLists"
    import path from "path"
    (async () => {
        const lists = await getSrcLists(srcPath)
        lists.forEach(async list => {
            const filePath = path.resolve(srcPath, list)
            const files = await getSrcLists(filePath)
        })
    })()

    接下来,重复调用path.resolve(filePath, file)路径再拼接上xxx.ts文件名,即为完整的xxx.ts 路径:

    import { srcPath } from "../build/const"
    import getSrcLists from "../build/getSrcLists"
    import path from "path"
    (async () => {
        const lists = await getSrcLists(srcPath)
        lists.forEach(async list => {
            const filePath = path.resolve(srcPath, list)
            const files = await getSrcLists(filePath)
            files.forEach(async file => {
                const srcFilePath = path.resolve(filePath, file)
                const content = await fsPromises.readFile(srcFilePath, 'utf-8')
            })
        })
    })()

    最后终于可以fsPromises.readFile(srcFilePath, 'utf-8') 把拼接完整路径的xxx.ts文件内容读取出来。

    每一个xxx.ts 文件可能导出不止一个方法,例如:

    对于这种情况,我们依然往对应的.md文件内叠加内容。

    针对上述问题,需要把每一个xxx.ts文件中的每一个多行注释块提取单独处理。

    TypeScript使用的是ES6模块化,import ... from "..." 导入,export 关键字导出。每一个导出的方法必须使用export,这个是不变的。如此针对读取xxx.ts内容后,以export分割内容,即可把每个xxx.ts文件内的每一个多行注释块分离出来:

    import { srcPath } from "../build/const"
    import getSrcLists from "../build/getSrcLists"
    import path from "path"
    
    (async () => {
        const lists = await getSrcLists(srcPath)
        lists.forEach(async list => {
            const filePath = path.resolve(srcPath, list)
            const files = await getSrcLists(filePath)
            files.forEach(async file => {
                const srcFilePath = path.resolve(filePath, file)
                const content = await fsPromises.readFile(srcFilePath, 'utf-8')
                //新增
                const exportsArray = content.split('export')
            })
        })
    })()

    为方便下一步提取多行注释中的关键字段,要做的是把多行注释干净的提取出来,不需要有任何其他代码片段,正则exec这个时候就能派上用场:

    import { srcPath } from "../build/const"
    import getSrcLists from "../build/getSrcLists"
    import path from "path"
    import { ANNOTATION } from "./const"
    
    //新增
    function getAnnotation(content: string) {
        const execContent = ANNOTATION.exec(content)
        if (isEmpty(execContent)) return
        const comment = (execContent as any[])[0]
        return comment
    }
    
    (async () => {
        const lists = await getSrcLists(srcPath)
        lists.forEach(async list => {
            const filePath = path.resolve(srcPath, list)
            const files = await getSrcLists(filePath)
            files.forEach(async file => {
                const srcFilePath = path.resolve(filePath, file)
                const content = await fsPromises.readFile(srcFilePath, 'utf-8')
                const exportsArray = content.split('export')
                //新增
                const promises: any[] = []
                exportsArray.forEach(exportsContent => {
                    if (isEmpty(exportsContent)) return
                    promises.push(Promise.resolve().then(() => getAnnotation(exportsContent)))
                })
            })
        })
    })()

    其中ANNOTATION 值即为正则/\/\*(\s|.)*?\*\//

  • 提取多行注释中的关键字段

    通过上面步骤,我们就可以完整的获取到干净的多行注释,现在要获取多行注释中的关键字段,如@desc

    多行注释是以/**开头,*/结束,它们对于我们来说是多余字符,所以在获取关键字段前,我们先将其处理掉。

    //省略
    import { ANNOTATION, CHAR } from "./const"
    
    //新增
    function getQuery(comment: string) {
        comment = comment.replace(/\*\/$/, '')//  */替换成""
        let splitComment = comment.split(CHAR)
        splitComment = splitComment.slice(1, splitComment.length).map(val => (CHAR + val.replace(/((\* $)|(\* ))/gm, '')).trim())
        return splitComment
    }
    
    function getAnnotation(content: string) {
        const execContent = ANNOTATION.exec(content)
        if (isEmpty(execContent)) return
        const comment = (execContent as any[])[0]
        return getQuery(comment)
    }
    
    //省略

    CHAR 常量值为@,关键字段均是以@ 开头定义,所以用@再次分割内容,将内容分割多个部分,分割后数据清理/**。这是多行注释的开头,分割后索引为0,将其剔除splitComment.slice(1, splitComment.length),保留的数据由于只对@、/**、*/做了处理,所以现有数据头部存在* 特殊字符。

    (CHAR + val.replace(/((\* $)|(\* ))/gm, '')).trim() 这行它做了三件事:替换*为""、去除字符串两端空字符、重新将@拼接。

  • 整理关键字段内容,以合适的数据结构存储

    这个时候,splitComment 变量值即存储了所以的关键字段内容,数据结构为:

    ["@desc 判断参数是否为`NaN`","@version v3.3.5",...]

    这样的数据结构并不方便取值,所以要把现在的数据结构转变下。

    //省略
    
    //新增
    function cacheMap(comments: string[]) {
        const map = new Map<string, Set<string>>()
        const reg = /^@([a-z]*) /
        comments.forEach((comment) => {
            const regComment = reg.exec(comment)
            if (isEmpty(regComment)) return
            const readyComment = comment.replace(reg, '')
            const key = (((regComment as any[])[0]) as string).trim().replace(CHAR, '')
            const mapValue = map.get(key)
            if (mapValue) {
                mapValue.add(readyComment)
                return
            }
            const set = new Set<string>()
            set.add(readyComment)
            map.set(key, set)
        })
    
        return map
    }
    
    function getQuery(comment: string) {
        comment = comment.replace(/\*\/$/, '')
        let splitComment = comment.split(CHAR)
        splitComment = splitComment.slice(1, splitComment.length).map(val => (CHAR + val.replace(/((\* $)|(\* ))/gm, '')).trim())
        return cacheMap(splitComment)
    }
    
    //省略

    依然是使用正则const reg = /^@([a-z]*) /,在遍历comments数组时,将@desc 等关键字段捕获。如"@desc 判断参数是否为`NaN`" 捕获出"@desc ",并在原字符串上将其替换成"",原字符串变成"判断参数是否为`NaN`"

    捕获出的"@desc "再去除掉@、去除掉两端空字符,把desc 作为Map对象的Key值;原字符串"判断参数是否为`NaN`"作为Set对象的Value值;将该Set对象作为Map对象中Key值为desc的Value。

    使用Set对象的原因:支持多个相同关键字段

    /**
     * ...
     * @param value(any):参数1
     * @param value(any):参数2
     * ...
     */

    如上注释,多个@param 描述参数,实际中多个参数是正常的,因此我们要做的是获取多个相同关键字段内容时去重,使用Set对象是最方便的。

    经过cacheMap 方法处理后,最终的数据结构为:

    Map:{
      desc:Set["判断参数是否为`NaN`"],
      version: Set["v3.3.5"],
      ...
    }
  • 提前定好文档模板,对应位置填写入关键字段内容

    template.ts:

    //省略
    
    export const generateDocs = async (doc: docs, callBack: () => string) => {
        const filePath = callBack()
        const splitFilePath = filePath.split(path.sep)
        const file = splitFilePath[splitFilePath.length - 1]
        if (or(isEmptyObj(doc), isEmpty(doc.version))) {
            err(`请完善${file}文档`)
            process.exit(1);
        }
        return `${doc.desc ? getSetValue(doc.desc) : ''}  
    **添加版本**  
    ${getSetValue(doc.version)}
    **参数**   
    ${doc.param ? getSetValue(doc.param) : ''}
    **返回**  
    ${doc.return ? getSetValue(doc.return) : ''}
    **例子**  
    ${doc.example ? getExample(getSetValue(doc.example)) : await generateExample(filePath)}`
    }

    仅粘贴核心部分,其中该部分很容易理解。doc 为对象,由Map对象转化成Object对象,然后判断param、return 等字段是否存在,存在则将字段值传递给getSetValue 方法。

    function getSetValue(set: Set<string>) {
        let setValue = ''
        set.forEach((s) => {
            setValue += `${s}  \n`
        })
        return setValue
    }

    其他逻辑是处理一些边界。

    完整代码:medash/template.ts at dev · CatsAndMice/medash (github.com)

  • 最后创建.md文件,写入内容

    (async () => {
        const lists = await getSrcLists(srcPath)
        lists.forEach(async list => {
            const filePath = path.resolve(srcPath, list)
            const files = await getSrcLists(filePath)
            files.forEach(async file => {
              //省略
              
                const promises: any[] = []
                exportsArray.forEach(exportsContent => {
                    if (isEmpty(exportsContent)) return
                    promises.push(Promise.resolve().then(() => getAnnotation(exportsContent)))
                })
                
                let docsContent = ''
                Promise.all(promises).then(async (result) => {
                    const docsPromises: any[] = []
                    result.forEach((res) => {
                        //拼接字符串
                        const promiseFn = async () => {
                            const isMapNoSize = isMap(res) && isEmpty(res.size)
                            if (or(isEmpty(res), isMapNoSize)) return
                            const docs = await generateDocs((mapToObj(res as Map<string, Set<string>>)) as docs, () => getLastPath(srcFilePath))
                            if (isEmpty(docs)) return
                            docsContent += `${docs}  \n`
                            return docsContent
                        }
                        //Promise类型统一添加至数组中
                        docsPromises.push(promiseFn())
                    })
                })
            })
        })
    })()

    Promise.all(promises) 等待所有的Promise结束后,我们再遍历所有Promise返回的结果,将生成的内容逻辑代码,使用async函数promiseFn包裹,执行promiseFn()pushdocsPromises数组。

    同样的逻辑,还是使用 Promise.all,等待docsPromises数组中所有的Promise对象出结果后,说明所有生成的内容已拼接赋值于docsContent 变量:

    (async () => {
        const lists = await getSrcLists(srcPath)
        lists.forEach(async list => {
            const filePath = path.resolve(srcPath, list)
            const files = await getSrcLists(filePath)
            files.forEach(async file => {
              //省略
                        
                let docsContent = ''
                Promise.all(promises).then(async (result) => {
                    const docsPromises: any[] = []
                    result.forEach((res) => {
                        //拼接字符串
                        const promiseFn = async () => {
                            const isMapNoSize = isMap(res) && isEmpty(res.size)
                            if (or(isEmpty(res), isMapNoSize)) return
                            const docs = await generateDocs((mapToObj(res as Map<string, Set<string>>)) as docs, () => getLastPath(srcFilePath))
                            if (isEmpty(docs)) return
                            docsContent += `${docs}  \n`
                            return docsContent
                        }
                        //Promise类型统一添加至数组中
                        docsPromises.push(promiseFn())
                    })
                    
                    //新增
                    //所有的Promise完成后,docsContent已拼接完成
                    Promise.all(docsPromises).then(() => {
                        const splitFilePath = filePath.split(path.sep)
                        const mdPath = path.join(docsPath, splitFilePath[splitFilePath.length - 1])
                        
                        isEmpty(docsContent) ? null : createDocs(mdPath, file.replace(/\.[a-z]*$/, ''), docsContent)
                    })
                    
                })
            })
        })
    })()

    createDocs 就是一个创建、写入内容的方法逻辑,相对容易不进行粘贴了。

至此,一个最简单的文档生成功能就完成了。

完整代码:medash/index.ts at dev · CatsAndMice/medash (github.com)

最后

原创不易!如果我的文章对你有帮助,你的👍就是对我的最大支持^ _^。

点赞+评论+收藏 = 学会。

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant