wpsjs
Version:
用于开发wps加载项的工具包
426 lines (376 loc) • 13 kB
JavaScript
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,
}