项目实战 - 国际化之基于Power Tools开发vscode代码提示插件

604 阅读2分钟

系列

项目实战 - 国际化之规范

项目实战 - 国际化之AST提取中文

项目实战 - 国际化之基于Power Tools开发vscode代码提示插件

开发vscode插件

  • 预先安装 Power Tools 插件,用于可快速开发vscode插件

image.png

目标

github项目地址,下载下来后安装包,重启vscode,之后可以看到目标效果

  • 鼠标 Hover 放到国际化key上面,不需要选中,就会看到对应中英原文,点击图标可跳转到配置文件位置,后面会具体讲解开发过程

unnamed.png

方法

  • 获取鼠标悬停位置,前后匹配出完整国际化Key
  • 国际化Key = 路由Key + Ukey,拆开得到路由Key,进而得到国际化配置的json文件
  • 从中英两个json文件中得到Ukey的中英文,鼠标悬浮展示
  • 点击进入按钮,跳转到中、英配置文件

power tools 配置

image.png

主提逻辑

image.png

registerHoverProvider 监听悬浮事件

  • 生成markdown字符串
  • vscode.Hover显示悬浮窗
vscode.languages.registerHoverProvider('*', {
    provideHover(document, position) {
      const markdownText = DashUI.createHover(document, position)
      return new vscode.Hover(markdownText)
    }
  })

registerCommand 接收自定义悬浮窗中点击铅笔图标事件

  • 点击后跳转到自定文件的自定行,参数是所在行的文件信息

  vscode.commands.registerCommand(Commands.open_key, async curNode => {
    const thisDocment = await vscode.workspace.openTextDocument(
      vscode.Uri.file(curNode.filepath)
    )
    const {
      loc: { start, end }
    } = curNode
    vscode.window.showTextDocument(thisDocment, {
      selection: new vscode.Range(
        start.line - 1,
        0, // 用start.column不方便
        end.line - 1,
        end.column
      )
    })
  })

注意小点

  • 对ast不熟悉可以看前一篇文章 recast 用法,但是这个插件当时不熟悉也没用visit和jscodeshift,可优化
  • 插件对文件路径定义要求高
  • 悬浮弹窗需要用markdown格式字符串
  • 铅笔点击事件是使用链接方式,并监听
  • 国际化配置文件多的时候,会很慢,已优化改为得到国际化Key之后,在根据路由去找实时找特定文件,就很快了

防止打不开github

package.json

{
  "version": "1.2.0",
  "devDependencies": {
    "json-to-ast": "^2.1.0",
    "recast": "0.20.5"
  },
  "dependencies": {
    "lodash": "^4.17.21"
  }
}

startup.js代码

// args => https://egodigital.github.io/vscode-powertools/api/interfaces/_contracts_.buttonactionscriptarguments.html
// s. https://code.visualstudio.com/api/references/vscode-api

const vscode = require('vscode')
const fs = require('fs')
const path = require('path')
const recast = require('recast')
const jecast = require('json-to-ast')
const lodash = require('lodash')

// 外部参数
let ArgsOptions = {}

const AstWorker = {
  helpFullFile(filepath) {
    if (/\.(json|js)$/.test(filepath)) {
      return filepath
    }
    const jsState = fs.existsSync(filepath + '.js')
    if (jsState) return filepath + '.js'
    const jsonState = fs.existsSync(filepath + '.json')
    if (jsonState) return filepath + '.json'
    const jsIndexState = fs.existsSync(filepath + '/index.js')
    if (jsIndexState) return filepath + '/index.js'
    const jsonIndexState = fs.existsSync(filepath + '/index.json')
    if (jsonIndexState) return filepath + '/index.json'
  },
  parseJsVariable(variableMap, property, filepath) {
    const { key, value } = property
    // 字符串
    if (value.type == 'Literal') {
      return {
        [key.name]: {
          value: value.value,
          filepath,
          loc: {
            start: value.loc.start,
            end: value.loc.end
          },
          __leefnode: true
        }
      }
    }
    // 对象
    else if (value.type == 'ObjectExpression') {
      return {
        [key.name]: value.properties.reduce((preItem, item) => {
          return {
            ...preItem,
            ...AstWorker.parseJsVariable(variableMap, item, filepath)
          }
        }, {})
      }
    }
    // 变量
    else if (value.type == 'Identifier') {
      // return {
      //     [key.name]: variableMap[key.name]
      // }
      return variableMap[key.name]
    }
    // webpack系统变量
    else if (value.type == 'MemberExpression') {
      return {
        [key.name]: {
          value: value.property?.name,
          filepath,
          loc: {
            start: value.loc.start,
            end: value.loc.end
          },
          __leefnode: true
        }
      }
    }
  },
  parseJsonVariable(property, filepath) {
    const { key, value } = property
    // 字符串
    if (value.type == 'Literal') {
      return {
        [key.value]: {
          value: value.value,
          filepath,
          loc: {
            start: value.loc.start,
            end: value.loc.end
          },
          __leefnode: true
        }
      }
    }
    // 对象
    else if (value.type == 'Object') {
      return {
        [key.value]: value.children.reduce((preItem, item) => {
          return {
            ...preItem,
            ...AstWorker.parseJsonVariable(item, filepath)
          }
        }, {})
      }
    }
  },
  parseAst(filepath, message) {
    if (/\.json$/.test(filepath)) {
      return AstWorker.parse2Json(filepath, message)
    } else if (/\.js$/.test(filepath)) {
      return AstWorker.parse2Js(filepath, message)
    }
  },
  parse2Json(filepath, message) {
    const conent = fs.readFileSync(filepath, 'utf-8')
    const astTree = jecast(conent)
    return astTree.children
      .filter(property => {
        const { key } = property
        return message ? key.value == message : true // 优化计算速度,只要message的对象
      })
      .reduce((preItem, item) => {
        return {
          ...preItem,
          ...AstWorker.parseJsonVariable(item, filepath)
        }
      }, {})
  },
  parse2Js(filepath, message) {
    const conent = fs.readFileSync(filepath, 'utf-8')
    const ast = recast.parse(conent)
    const { body } = ast.program
    /**
     * 处理 import
     */
    const bodyImportDeclarationMap = body
      .filter(item => item.type == 'ImportDeclaration')
      .filter(item => {
        if (!message) return true
        // 减少计算量 只取以message开头的变量
        return message.startsWith(item.specifiers[0].local.name)
      })
      .reduce((preItem, item) => {
        const { value } = item.source
        const name = item.specifiers[0].local.name
        return {
          ...preItem,
          [name]: AstWorker.parseAst(
            AstWorker.helpFullFile(path.join(filepath, '/../', value)),
            message?.substr(name.length + 1)
          )
        }
      }, {})
    /**
     * 处理变量
     */
    const bodyVariableDeclarationMap = lodash
      .flattenDeep(
        body
          .filter(item => item.type == 'VariableDeclaration')
          .map(item => item.declarations)
      )
      .reduce((preItem, item) => {
        return {
          ...preItem,
          [item.id.name]: {
            ...item.init.properties.reduce((preProperty, property) => {
              const innerMap = AstWorker.parseJsVariable(
                bodyImportDeclarationMap,
                property,
                filepath
              )
              // es6 简写
              if (property.value.type == 'Identifier') {
                return {
                  ...preProperty,
                  [property.key.name]: {
                    ...innerMap
                  }
                }
              }
              // json对象
              else {
                return {
                  ...preProperty,
                  ...innerMap
                }
              }
            }, {})
          }
        }
      }, {})

    /**
     * 处理导出Default
     */
    const defaultExportDeclaration = body.find(
      item => item.type == 'ExportDefaultDeclaration'
    ).declaration
    const combileMap = {
      ...bodyImportDeclarationMap,
      ...bodyVariableDeclarationMap
    }

    let bodyExportDefaultDeclarationMap = null
    if (defaultExportDeclaration.type == 'Identifier') {
      bodyExportDefaultDeclarationMap = {
        [defaultExportDeclaration.name]:
          combileMap[defaultExportDeclaration.name]
      }
    } else {
      bodyExportDefaultDeclarationMap = defaultExportDeclaration.properties.reduce(
        (preItem, item) => {
          return {
            ...preItem,
            ...AstWorker.parseJsVariable(combileMap, item, filepath)
          }
        },
        {}
      )
    }
    return bodyExportDefaultDeclarationMap
  },
  flattenJson(sourceTree, locale, outsiteKey) {
    const copyTree = {}
    const travese = (tree, parentKey) => {
      for (const key in tree) {
        const newKey = parentKey ? parentKey + '.' + key : key
        if (tree[key]) {
          if (tree[key].__leefnode) {
            copyTree[newKey] = tree[key]
            copyTree[newKey].locale = locale
          } else {
            travese(tree[key], newKey)
          }
        }
      }
    }
    travese(sourceTree, outsiteKey)
    return copyTree
  },
  requestCN(message) {
    return ArgsOptions.cnRoot.reduce((preTree, root) => {
      const sourceTree = AstWorker.parseAst(
        AstWorker.helpFullFile(path.join(__dirname, '../', root)),
        !/\.json$/.test(root) ? message : null
      )
      const rootKeys = Object.keys(sourceTree)
      const currentTree =
        rootKeys.length > 1
          ? AstWorker.flattenJson(sourceTree, '中')
          : AstWorker.flattenJson(sourceTree[rootKeys[0]], '中')
      return {
        ...preTree,
        ...currentTree
      }
    }, {})
  },
  requestEN(message) {
    return ArgsOptions.enRoot.reduce((preTree, root) => {
      const sourceTree = AstWorker.parseAst(
        AstWorker.helpFullFile(path.join(__dirname, '../', root)),
        !/\.json$/.test(root) ? message : null
      )
      const rootKeys = Object.keys(sourceTree)
      const currentTree =
        rootKeys.length > 1
          ? AstWorker.flattenJson(sourceTree, 'EN')
          : AstWorker.flattenJson(sourceTree[rootKeys[0]], 'EN')
      return {
        ...preTree,
        ...currentTree
      }
    }, {})
  }
}

const Commands = {
  open_key: 'open_key'
}

const DashUI = {
  showMessage: vscode.window.showInformationMessage,
  getMessage(document, position) {
    const { character } = position
    const { text } = document.lineAt(position.line)
    let start = character - 1,
      end = character
    for (; start >= 0; start--) {
      if (/['"`]/.test(text[start])) break
    }
    for (; end < text.length; end++) {
      if (/['"`]/.test(text[end])) break
    }
    return text.substring(start + 1, end)
  },
  queryNodes(document, position) {
    const message = DashUI.getMessage(document, position)
    if (!message) return []
    let i18nCN = {},
      i18nEN = {}
    try {
      i18nCN = AstWorker.requestCN(message)
    } catch (e) {
      DashUI.showMessage('中文json国际化配置错误')
    }
    try {
      i18nEN = AstWorker.requestEN(message)
    } catch (e) {
      // DashUI.showMessage('English intel config file error')
    }
    return [i18nCN[message], i18nEN[message]]
  },
  createHover(document, position) {
    const curNodes = DashUI.queryNodes(document, position)
    const isExistEntity = curNodes.find(ae => ae)
    if (!isExistEntity) return undefined
    const markdown = DashUI.createTable(curNodes)
    const markdownText = new vscode.MarkdownString(`${markdown}`, true)
    markdownText.isTrusted = true
    return markdownText
  },
  createTable(records) {
    const transTable = records
      .filter(ae => ae)
      .map(record => {
        const command = record ? DashUI.getAvaliableCommands(record) : ''
        return `| | **${record.locale}** | | ${record?.value} | ${command} |`
      })
      .join('\n')
    return `| | | | | |\n|---|---:|---|---|---:|\n${transTable}\n| | | | | |`
  },
  getAvaliableCommands(record) {
    return [
      {
        text: 'Go',
        icon: '✏️',
        command: DashUI.makeMarkdownCommand(Commands.open_key, { ...record })
      }
    ]
      .map(c =>
        typeof c === 'string' ? c : `[${c.icon}](${c.command} "${c.text}")`
      )
      .join(' ')
  },
  makeMarkdownCommand(command, args) {
    return `command:${command}?${encodeURIComponent(JSON.stringify(args))}`
  }
}

exports.execute = async args => {
  ArgsOptions = args.options

  /**
   * this.$t('memberManage.member.tab.transHistory')
   */
  vscode.languages.registerHoverProvider('*', {
    provideHover(document, position) {
      const markdownText = DashUI.createHover(document, position)
      return new vscode.Hover(markdownText)
    }
  })

  /**
   * curNode 格式
   * {"value":"早上好","filepath":"d:\\XXXXXX\\XXXX.js","loc":{"start":{"line":86,"column":21,"token":331},"end":{"line":86,"column":31,"token":332}},"__leefnode":true,"locale":"EN"}
   */
  vscode.commands.registerCommand(Commands.open_key, async curNode => {
    const thisDocment = await vscode.workspace.openTextDocument(
      vscode.Uri.file(curNode.filepath)
    )
    const {
      loc: { start, end }
    } = curNode
    vscode.window.showTextDocument(thisDocment, {
      selection: new vscode.Range(
        start.line - 1,
        0, // 用start.column不方便
        end.line - 1,
        end.column
      )
    })
  })
}