UNPKG

wpsjs

Version:

用于开发wps加载项的工具包

426 lines (376 loc) 13 kB
'use strict' const ts = require("typescript") const fs = require("node:fs") const fs_extra = require('fs-extra') const { dirname } = require("node:path") const { resolve } = require("node:path") const functionNamePattern = new RegExp("^[a-zA-Z_][a-zA-Z0-9_]*$") /** * @field {string} path * @field {string} namespace * @field {ts.SourceFile} sourceFile * @field {ts.Program} program * @field {ts.TypeChecker} typeChecker */ class TsParser { /** * @param {string} path */ constructor(path) { this.path = resolve(path) this.program = ts.createProgram([this.path], { allowJs: true }); this.sourceFile = this.program.getSourceFile(this.path) if (this.sourceFile === undefined) { throw new Error(`文件 "${this.path}" 无法解析`) } this.typeChecker = this.program.getTypeChecker() } /** * @param {string | ts.NodeArray<ts.JSDocComment> | undefined} comment * @returns {string} * */ parseComment(comment) { let comments = [] if (typeof comment == "string") { comments.push(comment) } else if (comment) { for (const commentChild of comment) { comments.push(commentChild.text) } } return comments.join() } /** * @param {ts.Type} type * @apram {number} [arrayLevel] * @returns {string} * */ parseType(type, arrayLevel = 0) { const symbol = type.getSymbol() if (symbol && symbol.getName() === "Array") { arrayLevel += 1 const params = this.typeChecker.getTypeArguments(type) if (params.length !== 1) { throw new Error("数组类型元素类型不正确") } const elementType = params[0] if (elementType?.getSymbol()?.getName() !== "Array") { if (arrayLevel > 2) throw new Error("多维数组不支持, 数组必须是二维数组") else if (arrayLevel === 1) throw new Error("一维数组不支持, 数组必须是二维数组") } return this.parseType(elementType, arrayLevel) + "[]" } if (type.isUnion()) { if (type.types.every(t => t.getFlags() === ts.TypeFlags.BooleanLiteral)) return "boolean" return "any" } const flag = type.getFlags() if (flag & ts.TypeFlags.String) return "string" if (flag & ts.TypeFlags.Number) return "number" if (flag & ts.TypeFlags.Boolean) return "bool" if (flag & ts.TypeFlags.Enum) return "number" return "any" } /** * @param {ts.TypeNode} typeNode * @returns {string} * */ parseTypeNode(typeNode) { try { const type = this.typeChecker.getTypeFromTypeNode(typeNode) return this.parseType(type) } catch (e) { throw new Error(`类型解析错误(${typeNode.getText()}): ` + e.message) } } /** * @param {ts.JSDocTypeExpression} typeExpr * @returns {string} * */ parseTypeDoc(typeExpr) { return typeExpr.type && this.parseTypeNode(typeExpr.type) } /** * @param {ts.TypeNode|undefined} typeNode * @param {ts.JSDocTypeExpression|undefined} typeExpr * @returns {string} * */ parseTypeNodeOrDoc(typeNode, typeExpr) { return (typeNode && this.parseTypeNode(typeNode)) ?? (typeExpr && this.parseTypeDoc(typeExpr)) ?? "any" } /** * @param {ts.ParameterDeclaration} node * @returns {{name: string, description: string, type: string}} * */ parseParameter(node) { const doc = ts.getJSDocParameterTags(node)[0] let typeNode = node.type; let typeExpr = doc?.typeExpression; let type = (typeNode && this.parseTypeNode(typeNode)) ?? (typeExpr && this.parseTypeDoc(typeExpr)) ?? "any" let description = this.parseComment(doc?.comment) if (description.startsWith("-")) description = description.substring(1) description = description.trim() if (doc && doc.isBracketed || node.questionToken !== undefined) type += "?" return { name: node.name.text, description, type } } /** * @param {ts.FunctionDeclaration} node * @returns {{name: string, parameters: object[], description: string, result: {type: string}}} * */ parseFunction(node) { const name = node.name.text if (name.match(functionNamePattern) === null) { throw new Error(`函数名 "${name}" 不符合规范. 函数名只能 "${functionNamePattern}"`) } let parameters = [] for (const parameter of node.parameters) { parameters.push(this.parseParameter(parameter)) } let comments = [] for (const commentTag of ts.getJSDocCommentsAndTags(node)) { comments = comments.concat(this.parseComment(commentTag.comment)) } let typeNode = node.type; let typeExpr = ts.getJSDocReturnType(node); const returnType = (typeNode && this.parseTypeNode(typeNode)) ?? (typeExpr && this.parseTypeNode(typeExpr)) ?? "any" return { name: node.name.text, parameters, description: comments.join(), result: { type: returnType } } } /** * @param {string} path * @returns {ts.SourceFile} */ getAst(path) { return this.program.getSourceFileByPath(path) } /** * @param {ts.Node} node * @param {(node: ts.FunctionDeclaration)=>void} callback * @returns {object[]} output */ foreachCustomFunction(node, callback) { switch (node.kind) { case ts.SyntaxKind.SourceFile: case ts.SyntaxKind.SyntaxList: for (const child of node.getChildren()) { this.foreachCustomFunction(child, callback) } break; case ts.SyntaxKind.FunctionDeclaration: let isCustomFunction = ts.getJSDocTags(node).some((docTag) => docTag.tagName.text === "customfunction") if (isCustomFunction && node.name) callback(node) break; default: } } /** * @param {string} namespace * @returns {string} */ generateRegister(namespace, code) { const sourceFile = ts.createSourceFile(this.path, code, { allowJs: true }); let stmts = Array.from(sourceFile.statements) let registers = [] this.foreachCustomFunction(this.sourceFile, (func) => { let ast = ts.factory.createExpressionStatement( ts.factory.createCallExpression( ts.factory.createPropertyAccessExpression( ts.factory.createIdentifier("wps"), ts.factory.createIdentifier("AddCustomFunction"), ), undefined, [ ts.factory.createStringLiteral(namespace), ts.factory.createStringLiteral(func.name.text), ts.factory.createIdentifier(func.name.text) ])); registers.push(ast) }) let block = ts.factory.createIfStatement( ts.factory.createPropertyAccessExpression( ts.factory.createIdentifier("wps"), ts.factory.createIdentifier("AddCustomFunction"), ), ts.factory.createBlock(registers, true) ) stmts.push(block) let printer = ts.createPrinter() let rootAst = ts.factory.createSourceFile(stmts, ts.factory.createToken(ts.SyntaxKind.EndOfFileToken), ts.NodeFlags.None) return printer.printFile(rootAst) } /** * @returns {{functions: object[]}} */ generateFunctionsJson() { let functions = [] this.foreachCustomFunction(this.sourceFile, (func) => { try { let info = this.parseFunction(func) functions.push(info) } catch (e) { throw new Error(`函数 "${func.name.text}" 解析错误: ${e.message}`) } }) return { functions, } } } /** * @param {string} inputPath * @param {string} code * @param {string} namespace * @returns {string} code */ function generateRegister(inputPath, code, namespace) { let parser = new TsParser(inputPath) let generatedCode = parser.generateRegister(namespace, code) return generatedCode } /** * @param {string} inputPath * @param {string} outputPath * @returns {string} */ function generateFunctionsJson(inputPath, outputPath) { let parser = new TsParser(inputPath) let json = parser.generateFunctionsJson() let data = JSON.stringify(json, null, 2) fs.mkdirSync(dirname(outputPath), { recursive: true }) fs.writeFileSync(outputPath, data) return data } /** * @param {{inputJsPath: string, outputJsonPath: string, namespace: string}} config */ function functionsScanner(config) { if (config.outputJsonPath.startsWith("./")) config.outputJsonPath = config.outputJsonPath.substring(2) let viteConfig let generated = false let data = "" let doGenerateFunctionsJson = function() { data = generateFunctionsJson( resolve(viteConfig.root, config.inputJsPath), resolve(viteConfig.root, viteConfig.build.outDir, config.outputJsonPath), ) generated = true } let getOrGenerateFunctionsJson = function() { if (!generated) { doGenerateFunctionsJson() } return data } return { name: 'functions-scanner', configResolved: (config) => { viteConfig = config }, configureServer: (server) => { let inputPath = resolve(viteConfig.root, config.inputJsPath) server.watcher.on('change', (path) => { if (resolve(path) === inputPath) { doGenerateFunctionsJson() } }) server.middlewares.use((req, res, next) => { const path = req.url.split('?')[0] if (path == '/' + config.outputJsonPath) { res.writeHead(200, { 'Content-Type': 'application/json', }) res.write(getOrGenerateFunctionsJson()) res.end() } else { next() } }) }, /** * @param {string} code */ transform: (code, id) => { let inputJsPath = resolve(viteConfig.root, config.inputJsPath) if (resolve(id) === inputJsPath) { return { code: generateRegister(inputJsPath, code, config.namespace), map: null } } else { return false } }, closeBundle: () => { doGenerateFunctionsJson() }, } } /** * @param {{src: string, dest: string}} config */ function copyFile(config) { config.dest = config.dest || config.src let viteConfig let doCopyFile = async function() { fs.mkdirSync(resolve(viteConfig.root, viteConfig.build.outDir), { recursive: true }) await fs_extra.copy( resolve(viteConfig.root, config.src), resolve(viteConfig.root, viteConfig.build.outDir, config.dest) ) } return { name: 'copy-file', configResolved: (config) => { viteConfig = config }, generateBundle: async (options, bundle) => { await doCopyFile() } } } function traitJsAsJsx() { return { name: "trait-js-as-jsx", setup(build) { build.onLoad({ filter: /src\/.*\.js$/ }, async (args) => ({ loader: "jsx", contents: fs.readFileSync(args.path, "utf8"), })); }, } } module.exports = { functionsScanner, copyFile, traitJsAsJsx, TsParser, generateFunctionsJson, generateRegister, }