From fcc734e05fb73fd282b466bba2d202add7eca939 Mon Sep 17 00:00:00 2001 From: Hoikan <30694822+HoikanChan@users.noreply.github.com> Date: Fri, 19 Apr 2024 17:58:52 +0800 Subject: [PATCH] feat: init --- .../babel-inula-next-core/CHANGELOG.md | 14 + .../babel-inula-next-core/package.json | 62 + .../src/analyze/earlyReturnAnalyze.ts | 90 + .../src/analyze/index.ts | 87 + .../src/analyze/jsxSliceAnalyze.ts | 75 + .../src/analyze/lifeCycleAnalyze.ts | 46 + .../src/analyze/nodeFactory.ts | 82 + .../src/analyze/propertiesAnalyze.ts | 103 ++ .../src/analyze/propsAnalyze.ts | 0 .../src/analyze/types.ts | 105 ++ .../src/analyze/utils.ts | 47 + .../babel-inula-next-core/src/const.ts | 490 ++++++ .../babel-inula-next-core/src/constants.ts | 5 + .../babel-inula-next-core/src/global.d.ts | 4 + .../babel-inula-next-core/src/index.ts | 37 + .../babel-inula-next-core/src/main.ts | 62 + .../babel-inula-next-core/src/plugin.ts | 43 + .../src/pluginProvider.ts | 1515 +++++++++++++++++ .../babel-inula-next-core/src/types.ts | 53 + .../babel-inula-next-core/src/utils.ts | 22 + .../test/condition.test.tsx | 71 + .../test/earlyReturnAnanlyze.test.ts | 87 + .../babel-inula-next-core/test/index.test.tsx | 233 +++ .../babel-inula-next-core/test/mock.ts | 79 + .../babel-inula-next-core/test/presets.ts | 24 + .../test/propertiesAnalyze.test.ts | 63 + .../babel-inula-next-core/tsconfig.json | 13 + .../babel-preset-inula-next/package.json | 14 +- .../src/analyze/earlyReturnAnalyze.ts | 90 + .../src/analyze/index.ts | 86 + .../src/analyze/jsxSliceAnalyze.ts | 75 + .../src/analyze/lifeCycleAnalyze.ts | 0 .../src/analyze/nodeFactory.ts | 74 + .../src/analyze/propertiesAnalyze.ts | 103 ++ .../src/analyze/propsAnalyze.ts | 0 .../src/analyze/types.ts | 103 ++ .../src/analyze/utils.ts | 47 + .../babel-preset-inula-next/src/index.ts | 16 + .../babel-preset-inula-next/src/main.ts | 46 + .../test/condition.test.tsx | 41 +- .../test/earlyReturnAnanlyze.test.ts | 87 + .../babel-preset-inula-next/test/mock.ts | 79 + .../test/propertiesAnalyze.test.ts | 63 + .../transpiler/reactivity-parser/package.json | 2 +- 44 files changed, 4433 insertions(+), 5 deletions(-) create mode 100644 packages/transpiler/babel-inula-next-core/CHANGELOG.md create mode 100644 packages/transpiler/babel-inula-next-core/package.json create mode 100644 packages/transpiler/babel-inula-next-core/src/analyze/earlyReturnAnalyze.ts create mode 100644 packages/transpiler/babel-inula-next-core/src/analyze/index.ts create mode 100644 packages/transpiler/babel-inula-next-core/src/analyze/jsxSliceAnalyze.ts create mode 100644 packages/transpiler/babel-inula-next-core/src/analyze/lifeCycleAnalyze.ts create mode 100644 packages/transpiler/babel-inula-next-core/src/analyze/nodeFactory.ts create mode 100644 packages/transpiler/babel-inula-next-core/src/analyze/propertiesAnalyze.ts create mode 100644 packages/transpiler/babel-inula-next-core/src/analyze/propsAnalyze.ts create mode 100644 packages/transpiler/babel-inula-next-core/src/analyze/types.ts create mode 100644 packages/transpiler/babel-inula-next-core/src/analyze/utils.ts create mode 100644 packages/transpiler/babel-inula-next-core/src/const.ts create mode 100644 packages/transpiler/babel-inula-next-core/src/constants.ts create mode 100644 packages/transpiler/babel-inula-next-core/src/global.d.ts create mode 100644 packages/transpiler/babel-inula-next-core/src/index.ts create mode 100644 packages/transpiler/babel-inula-next-core/src/main.ts create mode 100644 packages/transpiler/babel-inula-next-core/src/plugin.ts create mode 100644 packages/transpiler/babel-inula-next-core/src/pluginProvider.ts create mode 100644 packages/transpiler/babel-inula-next-core/src/types.ts create mode 100644 packages/transpiler/babel-inula-next-core/src/utils.ts create mode 100644 packages/transpiler/babel-inula-next-core/test/condition.test.tsx create mode 100644 packages/transpiler/babel-inula-next-core/test/earlyReturnAnanlyze.test.ts create mode 100644 packages/transpiler/babel-inula-next-core/test/index.test.tsx create mode 100644 packages/transpiler/babel-inula-next-core/test/mock.ts create mode 100644 packages/transpiler/babel-inula-next-core/test/presets.ts create mode 100644 packages/transpiler/babel-inula-next-core/test/propertiesAnalyze.test.ts create mode 100644 packages/transpiler/babel-inula-next-core/tsconfig.json create mode 100644 packages/transpiler/babel-preset-inula-next/src/analyze/earlyReturnAnalyze.ts create mode 100644 packages/transpiler/babel-preset-inula-next/src/analyze/index.ts create mode 100644 packages/transpiler/babel-preset-inula-next/src/analyze/jsxSliceAnalyze.ts create mode 100644 packages/transpiler/babel-preset-inula-next/src/analyze/lifeCycleAnalyze.ts create mode 100644 packages/transpiler/babel-preset-inula-next/src/analyze/nodeFactory.ts create mode 100644 packages/transpiler/babel-preset-inula-next/src/analyze/propertiesAnalyze.ts create mode 100644 packages/transpiler/babel-preset-inula-next/src/analyze/propsAnalyze.ts create mode 100644 packages/transpiler/babel-preset-inula-next/src/analyze/types.ts create mode 100644 packages/transpiler/babel-preset-inula-next/src/analyze/utils.ts create mode 100644 packages/transpiler/babel-preset-inula-next/src/main.ts create mode 100644 packages/transpiler/babel-preset-inula-next/test/earlyReturnAnanlyze.test.ts create mode 100644 packages/transpiler/babel-preset-inula-next/test/mock.ts create mode 100644 packages/transpiler/babel-preset-inula-next/test/propertiesAnalyze.test.ts diff --git a/packages/transpiler/babel-inula-next-core/CHANGELOG.md b/packages/transpiler/babel-inula-next-core/CHANGELOG.md new file mode 100644 index 00000000..263ea4f5 --- /dev/null +++ b/packages/transpiler/babel-inula-next-core/CHANGELOG.md @@ -0,0 +1,14 @@ +# babel-preset-inula-next + +## 0.0.3 + +### Patch Changes + +- Updated dependencies + - @openinula/class-transformer@0.0.2 + +## 0.0.2 + +### Patch Changes + +- 2f9d373: feat: change babel import diff --git a/packages/transpiler/babel-inula-next-core/package.json b/packages/transpiler/babel-inula-next-core/package.json new file mode 100644 index 00000000..a7b33eec --- /dev/null +++ b/packages/transpiler/babel-inula-next-core/package.json @@ -0,0 +1,62 @@ +{ + "name": "babel-preset-inula-next", + "version": "0.0.3", + "author": { + "name": "IanDx", + "email": "iandxssxx@gmail.com" + }, + "keywords": [ + "dlight.js", + "babel-preset" + ], + "license": "MIT", + "files": [ + "dist" + ], + "type": "module", + "main": "dist/index.cjs", + "module": "dist/index.js", + "typings": "dist/index.d.ts", + "scripts": { + "build": "tsup --sourcemap", + "test": "vitest" + }, + "devDependencies": { + "@types/babel__core": "^7.20.5", + "@types/node": "^20.10.5", + "tsup": "^6.7.0", + "typescript": "^5.3.2" + }, + "dependencies": { + "@babel/core": "^7.23.3", + "@babel/generator": "^7.23.6", + "@babel/parser": "^7.24.4", + "@babel/plugin-syntax-decorators": "^7.23.3", + "@babel/plugin-syntax-jsx": "7.16.7", + "@babel/plugin-syntax-typescript": "^7.23.3", + "@babel/traverse": "^7.24.1", + "@babel/types": "^7.24.0", + "@openinula/class-transformer": "workspace:*", + "@openinula/reactivity-parser": "workspace:*", + "@openinula/view-generator": "workspace:*", + "@openinula/view-parser": "workspace:*", + "@types/babel-types": "^7.0.15", + "@types/babel__generator": "^7.6.8", + "@types/babel__parser": "^7.1.1", + "@types/babel__traverse": "^7.6.8", + "jsx-view-parser": "workspace:*", + "minimatch": "^9.0.3", + "vitest": "^1.4.0" + }, + "tsup": { + "entry": [ + "src/index.ts" + ], + "format": [ + "cjs", + "esm" + ], + "clean": true, + "dts": true + } +} diff --git a/packages/transpiler/babel-inula-next-core/src/analyze/earlyReturnAnalyze.ts b/packages/transpiler/babel-inula-next-core/src/analyze/earlyReturnAnalyze.ts new file mode 100644 index 00000000..842993d1 --- /dev/null +++ b/packages/transpiler/babel-inula-next-core/src/analyze/earlyReturnAnalyze.ts @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2024 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { NodePath, type types as t } from '@babel/core'; +import { createComponentNode, createCondNode, createJSXNode } from './nodeFactory'; +import { AnalyzeContext, Branch, Visitor } from './types'; +import { isValidPath } from './utils'; + +export function earlyReturnAnalyze(): Visitor { + return { + ReturnStatement(path: NodePath, context: AnalyzeContext) { + const currentComp = context.currentComponent; + + const argument = path.get('argument'); + if (argument.isJSXElement()) { + currentComp.child = createJSXNode(currentComp, argument); + } + }, + IfStatement(ifStmt: NodePath, context: AnalyzeContext) { + if (!hasEarlyReturn(ifStmt)) { + return; + } + const currentComp = context.currentComponent; + + const branches: Branch[] = []; + let next: NodePath | null = ifStmt; + let branchIdx = 0; + + // Walk through the if-else chain to create branches + while (next && next.isIfStatement()) { + const nextConditions = [next.get('test')]; + // gen id for branch with babel + const name = `$$branch-${branchIdx}`; + branches.push({ + conditions: nextConditions, + content: createComponentNode(name, getStatements(ifStmt.get('consequent')), currentComp), + }); + + const elseBranch: NodePath = next.get('alternate'); + next = isValidPath(elseBranch) ? elseBranch : null; + branchIdx++; + } + + // Time for the else branch + // We merge the else branch with the rest statements in fc body to form the children + const elseBranch = next ? getStatements(next) : []; + const defaultComponent = createComponentNode( + '$$branch-default', + elseBranch.concat(context.restStmt), + currentComp + ); + context.skipRest(); + + currentComp.child = createCondNode(currentComp, defaultComponent, branches); + }, + }; +} + +function getStatements(next: NodePath) { + return next.isBlockStatement() ? next.get('body') : [next]; +} + +function hasEarlyReturn(path: NodePath) { + let hasReturn = false; + path.traverse({ + ReturnStatement(path: NodePath) { + if ( + path.parentPath.isFunctionDeclaration() || + path.parentPath.isFunctionExpression() || + path.parentPath.isArrowFunctionExpression() + ) { + return; + } + hasReturn = true; + }, + }); + return hasReturn; +} diff --git a/packages/transpiler/babel-inula-next-core/src/analyze/index.ts b/packages/transpiler/babel-inula-next-core/src/analyze/index.ts new file mode 100644 index 00000000..c5d28dbd --- /dev/null +++ b/packages/transpiler/babel-inula-next-core/src/analyze/index.ts @@ -0,0 +1,87 @@ +import { NodePath } from '@babel/core'; +import { jsxSlicesAnalyze } from './jsxSliceAnalyze'; +import { earlyReturnAnalyze } from './earlyReturnAnalyze'; +import { AnalyzeContext, Analyzer, ComponentNode, CondNode, Visitor } from './types'; +import { createComponentNode } from './nodeFactory'; +import { propertiesAnalyze } from './propertiesAnalyze'; +import { isValidComponent } from './utils'; +import * as t from '@babel/types'; +import { getFnBody } from '../utils'; +const builtinAnalyzers = [jsxSlicesAnalyze, earlyReturnAnalyze, propertiesAnalyze]; +let analyzers: Analyzer[] = builtinAnalyzers; + +export function isCondNode(node: any): node is CondNode { + return node && node.type === 'cond'; +} + +function mergeVisitor(...visitors: Analyzer[]): Visitor { + return visitors.reduce((acc, cur) => { + return { + ...acc, + ...cur(), + }; + }, {}); +} + +// walk through the function component body +export function iterateFCBody(bodyStatements: NodePath[], componentNode: ComponentNode, level = 0) { + const visitor = mergeVisitor(...analyzers); + const visit = (p: NodePath, ctx: AnalyzeContext) => { + const type = p.node.type; + + // TODO: More type safe way to handle this + visitor[type]?.(p as unknown as any, ctx); + }; + for (let i = 0; i < bodyStatements.length; i++) { + const p = bodyStatements[i]; + let skipRest = false; + const context: AnalyzeContext = { + level, + index: i, + currentComponent: componentNode, + restStmt: bodyStatements.slice(i + 1), + skipRest() { + skipRest = true; + }, + traverse: (path: NodePath, ctx: AnalyzeContext) => { + // @ts-expect-error TODO: fix visitor type incompatibility + path.traverse(visitor, ctx); + }, + }; + + visit(p, context); + + if (p.isReturnStatement()) { + visitor.ReturnStatement?.(p, context); + break; + } + + if (skipRest) { + break; + } + } +} + +/** + * The process of analyzing the component + * 1. identify the component + * 2. identify the jsx slice in the component + * 2. identify the component's props, including children, alias, and default value + * 3. analyze the early return of the component, build into the branch + * + * @param path + * @param customAnalyzers + */ +export function analyze( + fnName: string, + path: NodePath, + customAnalyzers?: Analyzer[] +) { + if (customAnalyzers) { + analyzers = customAnalyzers; + } + + const root = createComponentNode(fnName, getFnBody(path)); + + return root; +} diff --git a/packages/transpiler/babel-inula-next-core/src/analyze/jsxSliceAnalyze.ts b/packages/transpiler/babel-inula-next-core/src/analyze/jsxSliceAnalyze.ts new file mode 100644 index 00000000..0afdda3e --- /dev/null +++ b/packages/transpiler/babel-inula-next-core/src/analyze/jsxSliceAnalyze.ts @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2024 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { NodePath } from '@babel/core'; +import { AnalyzeContext, Visitor } from './types'; +import { createSubCompNode } from './nodeFactory'; +import * as t from '@babel/types'; + +function genName(tagName: string, ctx: AnalyzeContext) { + return `$$${tagName}-Sub${ctx.currentComponent.subComponents.length}`; +} + +function genNameFromJSX(path: NodePath, ctx: AnalyzeContext) { + const tagId = path.get('openingElement').get('name'); + if (tagId.isJSXIdentifier()) { + const jsxName = tagId.node.name; + return genName(jsxName, ctx); + } + throw new Error('JSXMemberExpression is not supported yet'); +} + +function replaceJSXSliceWithSubComp(name: string, ctx: AnalyzeContext, path: NodePath) { + // create a subComponent node and add it to the current component + const subComp = createSubCompNode(name, ctx.currentComponent, path.node); + ctx.currentComponent.subComponents.push(subComp); + + // replace with the subComp jsxElement + const subCompJSX = t.jsxElement( + t.jsxOpeningElement(t.jsxIdentifier(name), [], true), + t.jsxClosingElement(t.jsxIdentifier(name)), + [], + true + ); + path.replaceWith(subCompJSX); +} + +/** + * Analyze the JSX slice in the function component + * 1. VariableDeclaration, like `const a =
` + * 2. SubComponent, like `function Sub() { return
}` + * + * i.e. + * ```jsx + * let jsxSlice =
{count}
+ * // => + * function Comp_$id$() { + * return
{count}
+ * } + * let jsxSlice = + * ``` + */ +export function jsxSlicesAnalyze(): Visitor { + return { + JSXElement(path: NodePath, ctx) { + const name = genNameFromJSX(path, ctx); + replaceJSXSliceWithSubComp(name, ctx, path); + path.skip(); + }, + JSXFragment(path: NodePath, ctx) { + replaceJSXSliceWithSubComp('frag', ctx, path); + }, + }; +} diff --git a/packages/transpiler/babel-inula-next-core/src/analyze/lifeCycleAnalyze.ts b/packages/transpiler/babel-inula-next-core/src/analyze/lifeCycleAnalyze.ts new file mode 100644 index 00000000..9dcf2000 --- /dev/null +++ b/packages/transpiler/babel-inula-next-core/src/analyze/lifeCycleAnalyze.ts @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2024 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { NodePath } from '@babel/core'; +import { LifeCycle, Visitor } from './types'; +import { addLifecycle } from './nodeFactory'; +import * as t from '@babel/types'; +import { ON_MOUNT, ON_UNMOUNT, WILL_MOUNT, WILL_UNMOUNT } from '../constants'; +import { extractFnFromMacro, getFnBody } from '../utils'; + +function isLifeCycleName(name: string): name is LifeCycle { + return [WILL_MOUNT, ON_MOUNT, WILL_UNMOUNT, ON_UNMOUNT].includes(name); +} +/** + * Analyze the lifeCycle in the function component + * 1. willMount + * 2. onMount + * 3. willUnMount + * 4. onUnmount + */ +export function lifeCycleAnalyze(): Visitor { + return { + CallExpression(path: NodePath, ctx) { + const callee = path.get('callee'); + if (callee.isIdentifier(path)) { + const lifeCycleName = callee.node.name; + if (isLifeCycleName(lifeCycleName)) { + const fnNode = extractFnFromMacro(path, lifeCycleName); + addLifecycle(ctx.currentComponent, lifeCycleName, getFnBody(fnNode)); + } + } + }, + }; +} diff --git a/packages/transpiler/babel-inula-next-core/src/analyze/nodeFactory.ts b/packages/transpiler/babel-inula-next-core/src/analyze/nodeFactory.ts new file mode 100644 index 00000000..51f2fc02 --- /dev/null +++ b/packages/transpiler/babel-inula-next-core/src/analyze/nodeFactory.ts @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2024 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { NodePath, type types as t } from '@babel/core'; +import { Branch, ComponentNode, CondNode, InulaNode, JSX, JSXNode, LifeCycle, SubCompNode } from './types'; +import { iterateFCBody } from './index'; + +export function createComponentNode( + name: string, + fnBody: NodePath[], + parent?: ComponentNode +): ComponentNode { + const comp: ComponentNode = { + type: 'comp', + name, + props: {}, + child: undefined, + subComponents: [], + methods: [], + state: [], + parent, + fnBody, + }; + + iterateFCBody(fnBody, comp); + + return comp; +} + +export function addState(comp: ComponentNode, name: string, value: t.Expression | null) { + comp.state.push({ name, value }); +} + +export function addMethod(comp: ComponentNode, method: NodePath) { + comp.methods.push(method); +} + +export function addLifecycle(comp: ComponentNode, lifeCycle: LifeCycle, stmts: NodePath[]) { + const compLifecycle = comp.lifecycle; + if (!compLifecycle[lifeCycle]) { + compLifecycle[lifeCycle] = []; + } + compLifecycle[lifeCycle].push(stmts); +} + +export function createJSXNode(parent: ComponentNode, content: NodePath): JSXNode { + return { + type: 'jsx', + parent, + child: content, + }; +} + +export function createCondNode(parent: ComponentNode, child: InulaNode, branches: Branch[]): CondNode { + return { + type: 'cond', + branches, + child, + parent, + }; +} + +export function createSubCompNode(name: string, parent: ComponentNode, child: JSX): SubCompNode { + return { + type: 'subComp', + name, + parent, + child, + }; +} diff --git a/packages/transpiler/babel-inula-next-core/src/analyze/propertiesAnalyze.ts b/packages/transpiler/babel-inula-next-core/src/analyze/propertiesAnalyze.ts new file mode 100644 index 00000000..d585983c --- /dev/null +++ b/packages/transpiler/babel-inula-next-core/src/analyze/propertiesAnalyze.ts @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2024 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { NodePath } from '@babel/core'; + +import { Visitor } from './types'; +import { addMethod, addState } from './nodeFactory'; +import { hasJSX, isValidComponentName, isValidPath } from './utils'; +import { jsxSlicesAnalyze } from './jsxSliceAnalyze'; +import * as t from '@babel/types'; + +// Analyze the JSX slice in the function component, including: +// 1. VariableDeclaration, like `const a =
` +// 2. SubComponent, like `function Sub() { return
}` +function handleFn(fnName: string, fnBody: NodePath) { + if (isValidComponentName(fnName)) { + // This is a subcomponent, treat it as a normal component + } else { + // This is jsx creation function + // function jsxFunc() { + // // This is a function that returns JSX + // // because the function name is smallCamelCased + // return
{count}
+ // } + // => + // function jsxFunc() { + // function Comp_$id4$() { + // return
{count}
+ // } + // // This is a function that returns JSX + // // because the function name is smallCamelCased + // return + // } + } +} + +// 3. jsx creation function, like `function create() { return
}` +export function propertiesAnalyze(): Visitor { + return { + VariableDeclaration(path: NodePath, ctx) { + const declarations = path.get('declarations'); + // iterate the declarations + declarations.forEach(declaration => { + const id = declaration.get('id'); + // handle destructuring + if (id.isObjectPattern()) { + throw new Error('Object destructuring is not supported yet'); + } else if (id.isArrayPattern()) { + // TODO: handle array destructuring + throw new Error('Array destructuring is not supported yet'); + } else if (id.isIdentifier()) { + const init = declaration.get('init'); + if (isValidPath(init) && hasJSX(init)) { + if (init.isArrowFunctionExpression()) { + const fnName = id.node.name; + const fnBody = init.get('body'); + + // handle case like `const jsxFunc = () =>
` + if (fnBody.isExpression()) { + // turn expression into block statement for consistency + fnBody.replaceWith(t.blockStatement([t.returnStatement(fnBody.node)])); + } + + // We switched to the block statement above, so we can safely call handleFn + handleFn(fnName, fnBody as NodePath); + } + // handle jsx slice + ctx.traverse(path, ctx); + } + addState(ctx.currentComponent, id.node.name, declaration.node.init || null); + } + }); + }, + FunctionDeclaration(path: NodePath, ctx) { + const fnId = path.node.id; + if (!fnId) { + // This is an anonymous function, collect into lifecycle + //TODO + return; + } + + if (!hasJSX(path)) { + // This is a normal function, collect into methods + addMethod(ctx.currentComponent, path); + return; + } + + handleFn(fnId.name, path.get('body')); + }, + }; +} diff --git a/packages/transpiler/babel-inula-next-core/src/analyze/propsAnalyze.ts b/packages/transpiler/babel-inula-next-core/src/analyze/propsAnalyze.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/transpiler/babel-inula-next-core/src/analyze/types.ts b/packages/transpiler/babel-inula-next-core/src/analyze/types.ts new file mode 100644 index 00000000..24cf3439 --- /dev/null +++ b/packages/transpiler/babel-inula-next-core/src/analyze/types.ts @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2024 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { NodePath, types as t } from '@babel/core'; +import { Node } from '@babel/traverse'; +import { ON_MOUNT, ON_UNMOUNT, WILL_MOUNT, WILL_UNMOUNT } from '../constants'; + +// --- Node shape --- +export type InulaNode = ComponentNode | CondNode | JSXNode; +export type JSX = t.JSXElement | t.JSXFragment; +export type LifeCycle = typeof WILL_MOUNT | typeof ON_MOUNT | typeof WILL_UNMOUNT | typeof ON_UNMOUNT; +type defaultVal = any | null; +type Bitmap = number; +interface Reactive { + name: string; + value: t.Expression | null; + // indicate the value is a state or computed or watch + listeners: string[]; + bitmap: Bitmap; + // need a flag for computed to gen a getter + // watch is a static computed + isComputed: boolean; +} + +export interface ComponentNode { + type: 'comp'; + name: string; + props: Record; + // A valuable could be a state or computed + valuable: Reactive; + methods: NodePath[]; + child?: InulaNode; + subComponents: ComponentNode[]; + parent?: ComponentNode; + /** + * The function body of the fn component code + */ + // fnBody: NodePath[]; + // a map to find the state + reactiveMap: Record; + level: number; + lifecycle: Record[][]>; +} + +export interface SubCompNode { + type: 'subComp'; + name: string; + parent: ComponentNode; + child: JSX; +} + +export interface JSXNode { + type: 'jsx'; + parent: ComponentNode; + child: NodePath; +} + +export interface CondNode { + type: 'cond'; + branches: Branch[]; + parent: ComponentNode; + /** + * The default branch + */ + child: InulaNode; +} + +export interface Branch { + conditions: NodePath[]; + content: InulaNode; +} + +export interface AnalyzeContext { + level: number; + index: number; + currentComponent: ComponentNode; + restStmt: NodePath[]; + // --- flow control --- + /** + * ignore the rest of the statements + */ + skipRest: () => void; + traverse: (p: NodePath, ctx: AnalyzeContext) => void; +} + +export type Visitor = { + [Type in Node['type']]?: (path: NodePath>, state: S) => void; +}; +export type Analyzer = () => Visitor; + +export interface FnComponentDeclaration extends t.FunctionDeclaration { + id: t.Identifier; +} diff --git a/packages/transpiler/babel-inula-next-core/src/analyze/utils.ts b/packages/transpiler/babel-inula-next-core/src/analyze/utils.ts new file mode 100644 index 00000000..5795e3ca --- /dev/null +++ b/packages/transpiler/babel-inula-next-core/src/analyze/utils.ts @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2024 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { NodePath, type types as t } from '@babel/core'; +import { FnComponentDeclaration } from './types'; + +export function isValidPath(path: NodePath): path is NodePath> { + return !!path.node; +} + +// The component name must be UpperCamelCase +export function isValidComponent(node: t.FunctionDeclaration): node is FnComponentDeclaration { + // the first letter of the component name must be uppercase + return node.id ? isValidComponentName(node.id.name) : false; +} + +export function isValidComponentName(name: string) { + // the first letter of the component name must be uppercase + return /^[A-Z]/.test(name); +} + +export function hasJSX(path: NodePath) { + if (path.isJSXElement()) { + return true; + } + + // check if there is JSXElement in the children + let seen = false; + path.traverse({ + JSXElement() { + seen = true; + }, + }); + return seen; +} diff --git a/packages/transpiler/babel-inula-next-core/src/const.ts b/packages/transpiler/babel-inula-next-core/src/const.ts new file mode 100644 index 00000000..461f7594 --- /dev/null +++ b/packages/transpiler/babel-inula-next-core/src/const.ts @@ -0,0 +1,490 @@ +export const devMode = process.env.NODE_ENV === 'development'; + +export const alterAttributeMap = { + class: 'className', + for: 'htmlFor', +}; + +export const reactivityFuncNames = [ + // ---- Array + 'push', + 'pop', + 'shift', + 'unshift', + 'splice', + 'sort', + 'reverse', + // ---- Set + 'add', + 'delete', + 'clear', + // ---- Map + 'set', + 'delete', + 'clear', +]; + +export const defaultHTMLTags = [ + 'a', + 'abbr', + 'address', + 'area', + 'article', + 'aside', + 'audio', + 'b', + 'base', + 'bdi', + 'bdo', + 'blockquote', + 'body', + 'br', + 'button', + 'canvas', + 'caption', + 'cite', + 'code', + 'col', + 'colgroup', + 'data', + 'datalist', + 'dd', + 'del', + 'details', + 'dfn', + 'dialog', + 'div', + 'dl', + 'dt', + 'em', + 'embed', + 'fieldset', + 'figcaption', + 'figure', + 'footer', + 'form', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'head', + 'header', + 'hgroup', + 'hr', + 'html', + 'i', + 'iframe', + 'img', + 'input', + 'ins', + 'kbd', + 'label', + 'legend', + 'li', + 'link', + 'main', + 'map', + 'mark', + 'menu', + 'meta', + 'meter', + 'nav', + 'noscript', + 'object', + 'ol', + 'optgroup', + 'option', + 'output', + 'p', + 'picture', + 'pre', + 'progress', + 'q', + 'rp', + 'rt', + 'ruby', + 's', + 'samp', + 'script', + 'section', + 'select', + 'slot', + 'small', + 'source', + 'span', + 'strong', + 'style', + 'sub', + 'summary', + 'sup', + 'table', + 'tbody', + 'td', + 'template', + 'textarea', + 'tfoot', + 'th', + 'thead', + 'time', + 'title', + 'tr', + 'track', + 'u', + 'ul', + 'var', + 'video', + 'wbr', + 'acronym', + 'applet', + 'basefont', + 'bgsound', + 'big', + 'blink', + 'center', + 'dir', + 'font', + 'frame', + 'frameset', + 'isindex', + 'keygen', + 'listing', + 'marquee', + 'menuitem', + 'multicol', + 'nextid', + 'nobr', + 'noembed', + 'noframes', + 'param', + 'plaintext', + 'rb', + 'rtc', + 'spacer', + 'strike', + 'tt', + 'xmp', + 'animate', + 'animateMotion', + 'animateTransform', + 'circle', + 'clipPath', + 'defs', + 'desc', + 'ellipse', + 'feBlend', + 'feColorMatrix', + 'feComponentTransfer', + 'feComposite', + 'feConvolveMatrix', + 'feDiffuseLighting', + 'feDisplacementMap', + 'feDistantLight', + 'feDropShadow', + 'feFlood', + 'feFuncA', + 'feFuncB', + 'feFuncG', + 'feFuncR', + 'feGaussianBlur', + 'feImage', + 'feMerge', + 'feMergeNode', + 'feMorphology', + 'feOffset', + 'fePointLight', + 'feSpecularLighting', + 'feSpotLight', + 'feTile', + 'feTurbulence', + 'filter', + 'foreignObject', + 'g', + 'image', + 'line', + 'linearGradient', + 'marker', + 'mask', + 'metadata', + 'mpath', + 'path', + 'pattern', + 'polygon', + 'polyline', + 'radialGradient', + 'rect', + 'set', + 'stop', + 'svg', + 'switch', + 'symbol', + 'text', + 'textPath', + 'tspan', + 'use', + 'view', +]; + +export const availableDecoNames = ['Static', 'Prop', 'Env', 'Content', 'Children']; +export const dlightDefaultPackageName = '@openinula/next'; + +export const importMap = Object.fromEntries( + [ + 'createElement', + 'setStyle', + 'setDataset', + 'setEvent', + 'delegateEvent', + 'setHTMLProp', + 'setHTMLAttr', + 'setHTMLProps', + 'setHTMLAttrs', + 'createTextNode', + 'updateText', + 'insertNode', + 'ForNode', + 'CondNode', + 'ExpNode', + 'EnvNode', + 'TryNode', + 'SnippetNode', + 'PropView', + 'render', + ].map(name => [name, `$$${name}`]) +); + +export const importsToDelete = [ + 'Static', + 'Children', + 'Content', + 'Prop', + 'Env', + 'Watch', + 'ForwardProps', + 'Main', + 'App', + 'Mount', + '_', + 'env', + 'Snippet', + ...defaultHTMLTags.filter(tag => tag !== 'use'), +]; + +/** + * @brief HTML internal attribute map, can be accessed as js property + */ +export const defaultAttributeMap = { + // ---- Other property as attribute + textContent: ['*'], + innerHTML: ['*'], + // ---- Source: https://developer.mozilla.org/zh-CN/docs/Web/HTML/Attributes + accept: ['form', 'input'], + // ---- Original: accept-charset + acceptCharset: ['form'], + accesskey: ['*'], + action: ['form'], + align: ['caption', 'col', 'colgroup', 'hr', 'iframe', 'img', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr'], + allow: ['iframe'], + alt: ['area', 'img', 'input'], + async: ['script'], + autocapitalize: ['*'], + autocomplete: ['form', 'input', 'select', 'textarea'], + autofocus: ['button', 'input', 'select', 'textarea'], + autoplay: ['audio', 'video'], + background: ['body', 'table', 'td', 'th'], + // ---- Original: base + bgColor: ['body', 'col', 'colgroup', 'marquee', 'table', 'tbody', 'tfoot', 'td', 'th', 'tr'], + border: ['img', 'object', 'table'], + buffered: ['audio', 'video'], + capture: ['input'], + charset: ['meta'], + checked: ['input'], + cite: ['blockquote', 'del', 'ins', 'q'], + className: ['*'], + color: ['font', 'hr'], + cols: ['textarea'], + // ---- Original: colspan + colSpan: ['td', 'th'], + content: ['meta'], + // ---- Original: contenteditable + contentEditable: ['*'], + contextmenu: ['*'], + controls: ['audio', 'video'], + coords: ['area'], + crossOrigin: ['audio', 'img', 'link', 'script', 'video'], + csp: ['iframe'], + data: ['object'], + // ---- Original: datetime + dateTime: ['del', 'ins', 'time'], + decoding: ['img'], + default: ['track'], + defer: ['script'], + dir: ['*'], + dirname: ['input', 'textarea'], + disabled: ['button', 'fieldset', 'input', 'optgroup', 'option', 'select', 'textarea'], + download: ['a', 'area'], + draggable: ['*'], + enctype: ['form'], + // ---- Original: enterkeyhint + enterKeyHint: ['textarea', 'contenteditable'], + htmlFor: ['label', 'output'], + form: ['button', 'fieldset', 'input', 'label', 'meter', 'object', 'output', 'progress', 'select', 'textarea'], + // ---- Original: formaction + formAction: ['input', 'button'], + // ---- Original: formenctype + formEnctype: ['button', 'input'], + // ---- Original: formmethod + formMethod: ['button', 'input'], + // ---- Original: formnovalidate + formNoValidate: ['button', 'input'], + // ---- Original: formtarget + formTarget: ['button', 'input'], + headers: ['td', 'th'], + height: ['canvas', 'embed', 'iframe', 'img', 'input', 'object', 'video'], + hidden: ['*'], + high: ['meter'], + href: ['a', 'area', 'base', 'link'], + hreflang: ['a', 'link'], + // ---- Original: http-equiv + httpEquiv: ['meta'], + id: ['*'], + integrity: ['link', 'script'], + // ---- Original: intrinsicsize + intrinsicSize: ['img'], + // ---- Original: inputmode + inputMode: ['textarea', 'contenteditable'], + ismap: ['img'], + // ---- Original: itemprop + itemProp: ['*'], + kind: ['track'], + label: ['optgroup', 'option', 'track'], + lang: ['*'], + language: ['script'], + loading: ['img', 'iframe'], + list: ['input'], + loop: ['audio', 'marquee', 'video'], + low: ['meter'], + manifest: ['html'], + max: ['input', 'meter', 'progress'], + // ---- Original: maxlength + maxLength: ['input', 'textarea'], + // ---- Original: minlength + minLength: ['input', 'textarea'], + media: ['a', 'area', 'link', 'source', 'style'], + method: ['form'], + min: ['input', 'meter'], + multiple: ['input', 'select'], + muted: ['audio', 'video'], + name: [ + 'button', + 'form', + 'fieldset', + 'iframe', + 'input', + 'object', + 'output', + 'select', + 'textarea', + 'map', + 'meta', + 'param', + ], + // ---- Original: novalidate + noValidate: ['form'], + open: ['details', 'dialog'], + optimum: ['meter'], + pattern: ['input'], + ping: ['a', 'area'], + placeholder: ['input', 'textarea'], + // ---- Original: playsinline + playsInline: ['video'], + poster: ['video'], + preload: ['audio', 'video'], + readonly: ['input', 'textarea'], + // ---- Original: referrerpolicy + referrerPolicy: ['a', 'area', 'iframe', 'img', 'link', 'script'], + rel: ['a', 'area', 'link'], + required: ['input', 'select', 'textarea'], + reversed: ['ol'], + role: ['*'], + rows: ['textarea'], + // ---- Original: rowspan + rowSpan: ['td', 'th'], + sandbox: ['iframe'], + scope: ['th'], + scoped: ['style'], + selected: ['option'], + shape: ['a', 'area'], + size: ['input', 'select'], + sizes: ['link', 'img', 'source'], + slot: ['*'], + span: ['col', 'colgroup'], + spellcheck: ['*'], + src: ['audio', 'embed', 'iframe', 'img', 'input', 'script', 'source', 'track', 'video'], + srcdoc: ['iframe'], + srclang: ['track'], + srcset: ['img', 'source'], + start: ['ol'], + step: ['input'], + style: ['*'], + summary: ['table'], + // ---- Original: tabindex + tabIndex: ['*'], + target: ['a', 'area', 'base', 'form'], + title: ['*'], + translate: ['*'], + type: ['button', 'input', 'embed', 'object', 'ol', 'script', 'source', 'style', 'menu', 'link'], + usemap: ['img', 'input', 'object'], + value: ['button', 'data', 'input', 'li', 'meter', 'option', 'progress', 'param', 'text' /** extra for TextNode */], + width: ['canvas', 'embed', 'iframe', 'img', 'input', 'object', 'video'], + wrap: ['textarea'], + // --- ARIA attributes + // Source: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes + ariaAutocomplete: ['*'], + ariaChecked: ['*'], + ariaDisabled: ['*'], + ariaErrorMessage: ['*'], + ariaExpanded: ['*'], + ariaHasPopup: ['*'], + ariaHidden: ['*'], + ariaInvalid: ['*'], + ariaLabel: ['*'], + ariaLevel: ['*'], + ariaModal: ['*'], + ariaMultiline: ['*'], + ariaMultiSelectable: ['*'], + ariaOrientation: ['*'], + ariaPlaceholder: ['*'], + ariaPressed: ['*'], + ariaReadonly: ['*'], + ariaRequired: ['*'], + ariaSelected: ['*'], + ariaSort: ['*'], + ariaValuemax: ['*'], + ariaValuemin: ['*'], + ariaValueNow: ['*'], + ariaValueText: ['*'], + ariaBusy: ['*'], + ariaLive: ['*'], + ariaRelevant: ['*'], + ariaAtomic: ['*'], + ariaDropEffect: ['*'], + ariaGrabbed: ['*'], + ariaActiveDescendant: ['*'], + ariaColCount: ['*'], + ariaColIndex: ['*'], + ariaColSpan: ['*'], + ariaControls: ['*'], + ariaDescribedBy: ['*'], + ariaDescription: ['*'], + ariaDetails: ['*'], + ariaFlowTo: ['*'], + ariaLabelledBy: ['*'], + ariaOwns: ['*'], + ariaPosInset: ['*'], + ariaRowCount: ['*'], + ariaRowIndex: ['*'], + ariaRowSpan: ['*'], + ariaSetSize: ['*'], +}; diff --git a/packages/transpiler/babel-inula-next-core/src/constants.ts b/packages/transpiler/babel-inula-next-core/src/constants.ts new file mode 100644 index 00000000..51758c1b --- /dev/null +++ b/packages/transpiler/babel-inula-next-core/src/constants.ts @@ -0,0 +1,5 @@ +export const COMPONENT = 'Component'; +export const WILL_MOUNT = 'willMount'; +export const ON_MOUNT = 'onMount'; +export const WILL_UNMOUNT = 'willUnMount'; +export const ON_UNMOUNT = 'onUnmount'; diff --git a/packages/transpiler/babel-inula-next-core/src/global.d.ts b/packages/transpiler/babel-inula-next-core/src/global.d.ts new file mode 100644 index 00000000..34bd908c --- /dev/null +++ b/packages/transpiler/babel-inula-next-core/src/global.d.ts @@ -0,0 +1,4 @@ +declare module '@babel/plugin-syntax-do-expressions'; +declare module '@babel/plugin-syntax-decorators'; +declare module '@babel/plugin-syntax-jsx'; +declare module '@babel/plugin-syntax-typescript'; diff --git a/packages/transpiler/babel-inula-next-core/src/index.ts b/packages/transpiler/babel-inula-next-core/src/index.ts new file mode 100644 index 00000000..c50d8feb --- /dev/null +++ b/packages/transpiler/babel-inula-next-core/src/index.ts @@ -0,0 +1,37 @@ +import syntaxDecorators from '@babel/plugin-syntax-decorators'; +import syntaxJSX from '@babel/plugin-syntax-jsx'; +import syntaxTypescript from '@babel/plugin-syntax-typescript'; +import dlight from './plugin'; +import { type DLightOption } from './types'; +import { type ConfigAPI, type TransformOptions } from '@babel/core'; +import { plugin as fn2Class } from '@openinula/class-transformer'; +import { parse as babelParse } from '@babel/parser'; + +export default function (_: ConfigAPI, options: DLightOption): TransformOptions { + return { + plugins: [ + syntaxJSX.default ?? syntaxJSX, + [syntaxTypescript.default ?? syntaxTypescript, { isTSX: true }], + [syntaxDecorators.default ?? syntaxDecorators, { legacy: true }], + fn2Class, + [dlight, options], + ], + }; +} + +export { type DLightOption }; + +export function parse(code: string) { + const result = babelParse(code, { + // parse in strict mode and allow module declarations + sourceType: 'module', + + plugins: ['jsx'], + }); + + if (result.errors.length) { + throw new Error(result.errors[0].message); + } + + const program = result.program; +} diff --git a/packages/transpiler/babel-inula-next-core/src/main.ts b/packages/transpiler/babel-inula-next-core/src/main.ts new file mode 100644 index 00000000..45ab3958 --- /dev/null +++ b/packages/transpiler/babel-inula-next-core/src/main.ts @@ -0,0 +1,62 @@ +import type babel from '@babel/core'; +import { type PluginObj } from '@babel/core'; +import { PluginProviderClass } from './pluginProvider'; +import { type DLightOption } from './types'; +import { defaultAttributeMap } from './const'; +import { analyze } from './analyze'; +import { NodePath, type types as t } from '@babel/core'; +import { COMPONENT } from './constants'; +import { extractFnFromMacro } from './utils'; + +export default function (api: typeof babel, options: DLightOption): PluginObj { + const { types } = api; + const { + files = '**/*.{js,ts,jsx,tsx}', + excludeFiles = '**/{dist,node_modules,lib}/*', + enableDevTools = false, + htmlTags = defaultHtmlTags => defaultHtmlTags, + attributeMap = defaultAttributeMap, + } = options; + + const pluginProvider = new PluginProviderClass( + api, + types, + Array.isArray(files) ? files : [files], + Array.isArray(excludeFiles) ? excludeFiles : [excludeFiles], + enableDevTools, + htmlTags, + attributeMap + ); + + return { + visitor: { + Program: { + enter(path, { filename }) { + return pluginProvider.programEnterVisitor(path, filename); + }, + exit: pluginProvider.programExitVisitor.bind(pluginProvider), + }, + CallExpression(path: NodePath) { + // find the component, like: Component(() => {}) + const callee = path.get('callee'); + + if (callee.isIdentifier() && callee.node.name === COMPONENT) { + const componentNode = extractFnFromMacro(path, COMPONENT); + let name = ''; + // try to get the component name, when parent is a variable declarator + if (path.parentPath.isVariableDeclarator()) { + const lVal = path.parentPath.get('id'); + if (lVal.isIdentifier()) { + name = lVal.node.name; + } else { + console.error('Component macro must be assigned to a variable'); + } + } + const root = analyze(name, componentNode); + // The sub path has been visited, so we just skip + path.skip(); + } + }, + }, + }; +} diff --git a/packages/transpiler/babel-inula-next-core/src/plugin.ts b/packages/transpiler/babel-inula-next-core/src/plugin.ts new file mode 100644 index 00000000..e5778cd5 --- /dev/null +++ b/packages/transpiler/babel-inula-next-core/src/plugin.ts @@ -0,0 +1,43 @@ +import type babel from '@babel/core'; +import { type PluginObj } from '@babel/core'; +import { PluginProviderClass } from './pluginProvider'; +import { type DLightOption } from './types'; +import { defaultAttributeMap } from './const'; + +export default function (api: typeof babel, options: DLightOption): PluginObj { + const { types } = api; + const { + files = '**/*.{js,ts,jsx,tsx}', + excludeFiles = '**/{dist,node_modules,lib}/*', + enableDevTools = false, + htmlTags = defaultHtmlTags => defaultHtmlTags, + attributeMap = defaultAttributeMap, + } = options; + + const pluginProvider = new PluginProviderClass( + api, + types, + Array.isArray(files) ? files : [files], + Array.isArray(excludeFiles) ? excludeFiles : [excludeFiles], + enableDevTools, + htmlTags, + attributeMap + ); + + return { + visitor: { + Program: { + enter(path, { filename }) { + return pluginProvider.programEnterVisitor(path, filename); + }, + exit: pluginProvider.programExitVisitor.bind(pluginProvider), + }, + ClassDeclaration: { + enter: pluginProvider.classEnter.bind(pluginProvider), + exit: pluginProvider.classExit.bind(pluginProvider), + }, + ClassMethod: pluginProvider.classMethodVisitor.bind(pluginProvider), + ClassProperty: pluginProvider.classPropertyVisitor.bind(pluginProvider), + }, + }; +} diff --git a/packages/transpiler/babel-inula-next-core/src/pluginProvider.ts b/packages/transpiler/babel-inula-next-core/src/pluginProvider.ts new file mode 100644 index 00000000..386fec16 --- /dev/null +++ b/packages/transpiler/babel-inula-next-core/src/pluginProvider.ts @@ -0,0 +1,1515 @@ +import type babel from '@babel/core'; +import { type types as t, type NodePath } from '@babel/core'; +import { type PropertyContainer, type HTMLTags, type SnippetPropSubDepMap } from './types'; +import { minimatch } from 'minimatch'; +import { parseView, ViewUnit } from '@openinula/view-parser'; +import { parseView as parseJSX } from 'jsx-view-parser'; +import { parseReactivity } from '@openinula/reactivity-parser'; +import { generateSnippet, generateView } from '@openinula/view-generator'; +import { + alterAttributeMap, + availableDecoNames, + defaultHTMLTags, + devMode, + dlightDefaultPackageName, + importMap, + importsToDelete, + reactivityFuncNames, +} from './const'; + +export class PluginProvider { + private readonly dlightPackageName = dlightDefaultPackageName; + + // ---- Plugin Level + private readonly babelApi: typeof babel; + private readonly t: typeof t; + private readonly traverse: typeof babel.traverse; + private readonly enableDevTools: boolean; + private readonly includes: string[]; + private readonly excludes: string[]; + private readonly htmlTags: string[]; + private readonly attributeMap: Record; + // when parsing JSX, use JSX parser instead of the default parser + private isJsx = false; + + constructor( + babelApi: typeof babel, + types: typeof t, + includes: string[], + excludes: string[], + enableDevTools: boolean, + htmlTags: HTMLTags, + attributeMap: Record + ) { + this.babelApi = babelApi; + this.t = types; + this.traverse = babelApi.traverse; + this.includes = includes; + this.excludes = excludes; + this.enableDevTools = devMode && enableDevTools; + this.htmlTags = + typeof htmlTags === 'function' + ? htmlTags(defaultHTMLTags) + : htmlTags.includes('*') + ? [...new Set([...defaultHTMLTags, ...htmlTags])].filter(tag => tag !== '*') + : htmlTags; + this.attributeMap = attributeMap; + } + + // ---- DLight class Level + private classDeclarationNode?: t.ClassDeclaration; + private classBodyNode?: t.ClassBody; + private propertiesContainer: PropertyContainer = {}; + private dependencyMap: Record = {}; + private enter = true; + private dLightModel = false; + private enterClassNode = false; + private className?: string; + + // ---- File Level + private programNode?: t.Program; + private allImports: t.ImportDeclaration[] = []; + private didAlterImports = false; + + /* ---- DLight Class Level Hooks ---- */ + /** + * @brief Clear all DLight Node Level variables after a class is transformed + */ + clearNode() { + this.classDeclarationNode = undefined; + this.classBodyNode = undefined; + this.propertiesContainer = {}; + this.dependencyMap = {}; + this.enter = true; + this.enterClassNode = false; + this.dLightModel = false; + this.className = undefined; + } + + private get availableProperties(): string[] { + return Object.entries(this.propertiesContainer) + .filter( + ([key, { isWatcher, isStatic, isChildren }]) => key !== '_$compName' && !isWatcher && !isStatic && !isChildren + ) + .map(([key]) => key); + } + + /** + * @brief Initialize DLight Node Level variables when entering a class + * @param path + */ + initNode(path: NodePath): void { + const node: t.ClassDeclaration = path.node; + this.classDeclarationNode = node; + this.classBodyNode = node.body; + this.propertiesContainer = {}; + + if (!node.id?.name) { + node.id = this.t.identifier(`Anonymous_${PluginProvider.uid()}`); + } + this.className = node.id?.name; + + // ---- Custom decorators + this.handleClassCustomDecorators(); + + // ---- If devtools is enabled, add _$compName property to the class + if (this.enableDevTools) { + this.classBodyNode.body.unshift( + this.t.classProperty(this.t.identifier('_$compName'), this.t.stringLiteral(this.className)) + ); + } + + // ---- Add dlight import and alter import name, + // Only do this when enter the first dlight class + if (!this.didAlterImports) { + // ---- Get DLight imports + const dlightImports = this.allImports.filter(n => n.source.value === dlightDefaultPackageName); + // ---- Alter import name, e.g. "@dlight/dlight-client" + if (this.dlightPackageName !== dlightDefaultPackageName) { + dlightImports.forEach(i => { + i.source.value = this.dlightPackageName; + }); + } + + // ---- remove all tag-import + dlightImports.forEach(importNode => { + importNode.specifiers = importNode.specifiers.filter( + specifier => + !( + this.t.isImportSpecifier(specifier) && + this.t.isIdentifier(specifier.imported) && + importsToDelete.includes(specifier.imported.name) + ) + ); + }); + + // ---- Add nodes import to the head of file + this.programNode!.body.unshift( + this.t.importDeclaration( + Object.entries(importMap).map(([key, value]) => + this.t.importSpecifier(this.t.identifier(value), this.t.identifier(key)) + ), + this.t.stringLiteral(this.dlightPackageName) + ) + ); + this.didAlterImports = true; + } + } + + /* ---- Babel Visitors ---- */ + programEnterVisitor(path: NodePath, filename: string | undefined): void { + this.enter = this.fileAllowed(filename); + if (!this.enter) return; + // switch to JSX parser according to the file extension + if ((filename && filename.endsWith('.jsx')) || (filename && filename.endsWith('.tsx'))) { + this.isJsx = true; + } + + this.allImports = path.node.body.filter(n => this.t.isImportDeclaration(n)) as t.ImportDeclaration[]; + // const dlightImports = this.allImports.filter( + // n => n.source.value === dlightDefaultPackageName + // ) + // if (dlightImports.length === 0) { + // this.enter = false + // return + // } + this.programNode = path.node; + } + + programExitVisitor(): void { + if (!this.enter) return; + this.didAlterImports = false; + this.allImports = []; + this.programNode = undefined; + } + + classEnter(path: NodePath): void { + if (!this.enter) return; + this.enterClassNode = this.isDLightClass(path); + if (!this.enterClassNode) return; + this.initNode(path); + this.resolveMounting(path); + } + + classExit(): void { + if (!this.enter) return; + if (!this.enterClassNode) return; + this.transformDLightClass(); + this.clearNode(); + this.enterClassNode = false; + } + + classMethodVisitor(path: NodePath): void { + if (!this.enterClassNode) return; + if (!this.t.isIdentifier(path.node.key)) return; + const key = path.node.key.name; + if (key === 'Body') return; + + const isSnippet = this.findDecoratorByName(path.node.decorators, 'Snippet'); + if (isSnippet) return; + const node = path.node; + + // ---- Handle watcher + // ---- Get watcher decorator or watcher function decorator + // ---- Watcher auto collect deps: + // @Watch + // watcher() { myFunc() } + // ---- Watcher function manual set deps: + // @Watch(["count", "flag"]) + // watcherFunc() { myFunc() } + const watchDeco = this.findDecoratorByName(node.decorators, 'Watch'); + if (this.t.isIdentifier(node.key, { name: 'constructor' })) return; + this.autoBindMethods(node); + if (!watchDeco) return; + + // ---- Get dependencies from watcher decorator or watcher function decorator + let deps: string[] = []; + let depsNode; + if (this.t.isIdentifier(watchDeco)) { + [deps, depsNode] = this.getDependencies(node); + } else { + const listenDepStrings = watchDeco.arguments + .filter(arg => this.t.isStringLiteral(arg)) + .map(arg => (arg as t.StringLiteral).value); + const pseudoMethod = this.t.classMethod( + 'method', + node.key, + [], + this.t.blockStatement([ + this.t.expressionStatement( + this.t.arrayExpression( + listenDepStrings.map(str => this.t.memberExpression(this.t.thisExpression(), this.t.identifier(str))) + ) + ), + ]) + ); + + [deps, depsNode] = this.getDependencies(pseudoMethod); + } + // ---- Register watcher to propertiesContainer + this.propertiesContainer[key] = { + node, + deps, + depsNode, + isWatcher: true, + }; + node.decorators = this.removeDecorators(node.decorators, ['Watch']); + } + + classPropertyVisitor(path: NodePath): void { + if (!this.enterClassNode) return; + const node = path.node; + if (!this.t.isIdentifier(node.key)) return; + const key = node.key.name; + if (key === 'Body') return; + const decorators = node.decorators; + const isSnippet = this.findDecoratorByName(decorators, 'Snippet'); + if (isSnippet) return; + // ---- Parse model + const isModel = this.parseModel(path); + + const isProp = !!this.findDecoratorByName(decorators, 'Prop'); + const isEnv = !!this.findDecoratorByName(decorators, 'Env'); + + const isChildren = !!this.findDecoratorByName(node.decorators, 'Children'); + + const [deps, depsNode] = !isChildren ? this.getDependencies(node) : [[]]; + + this.propertiesContainer[key] = { + node, + deps, + depsNode, + isStatic: !!this.findDecoratorByName(decorators, 'Static'), + isContent: !!this.findDecoratorByName(decorators, 'Content'), + isChildren, + isPropOrEnv: isProp ? 'Prop' : isEnv ? 'Env' : undefined, + isModel, + }; + + node.decorators = this.removeDecorators(decorators, availableDecoNames); + } + + /* ---- Decorator Resolvers ---- */ + /** + * @brief Decorator resolver: Watcher + * Add: + * $wW${key} + * in watcher: + * watchxx() { + * if (this._$cache(${key}, ${deps})) return + * ... + * } + * @param node + */ + resolveWatcherDecorator(node: t.ClassMethod, depsNode: t.ArrayExpression): void { + if (!this.t.isIdentifier(node.key)) return; + const key = node.key.name; + const propertyIdx = this.classBodyNode!.body.indexOf(node); + const watcherNode = this.t.classProperty(this.t.identifier(`$w$${key}`)); + this.classBodyNode!.body.splice(propertyIdx, 0, watcherNode); + node.body.body.unshift( + this.t.ifStatement( + this.t.callExpression(this.t.memberExpression(this.t.thisExpression(), this.t.identifier('_$cache')), [ + this.t.stringLiteral(key), + depsNode, + ]), + this.t.blockStatement([this.t.returnStatement()]) + ) + ); + } + + /** + * @brief Decorator resolver: Children + * Add: + * get ${key}() { + * return this._$children + * } + * @param node + */ + resolveChildrenDecorator(node: t.ClassProperty) { + if (!this.classBodyNode) return; + if (!this.t.isIdentifier(node.key)) return; + const key = node.key.name; + const propertyIdx = this.classBodyNode.body.indexOf(node); + + const childrenFuncCallNode = this.t.memberExpression(this.t.thisExpression(), this.t.identifier('_$children')); + + const getterNode = this.t.classMethod( + 'get', + this.t.identifier(key), + [], + this.t.blockStatement([this.t.returnStatement(childrenFuncCallNode)]) + ); + this.classBodyNode.body.splice(propertyIdx, 1, getterNode); + } + + /** + * @brief Decorator resolver: Content + * Add: + * _$contentKey = "key" + * @param node + */ + resolveContentDecorator(node: t.ClassProperty) { + if (!this.classBodyNode) return; + if (!this.t.isIdentifier(node.key)) return; + + // ---- Already has _$contentKey + if (this.classBodyNode.body.some(n => this.t.isClassProperty(n) && (n.key as t.Identifier).name === '_$contentKey')) + return; + const key = node.key.name; + const propertyIdx = this.classBodyNode.body.indexOf(node); + + const derivedStatusKey = this.t.classProperty(this.t.identifier('_$contentKey'), this.t.stringLiteral(key)); + this.classBodyNode.body.splice(propertyIdx, 0, derivedStatusKey); + } + + /** + * @brief Decorator resolver: Prop/Env + * Add: + * $p/e$${key} + * @param node + */ + resolvePropDecorator(node: t.ClassProperty, decoratorName: 'Prop' | 'Env') { + if (!this.classBodyNode) return; + if (!this.t.isIdentifier(node.key)) return; + const key = node.key.name; + const propertyIdx = this.classBodyNode.body.indexOf(node); + const tag = decoratorName.toLowerCase() === 'prop' ? 'p' : 'e'; + const derivedStatusKey = this.t.classProperty(this.t.identifier(`$${tag}$${key}`)); + this.classBodyNode.body.splice(propertyIdx, 0, derivedStatusKey); + } + + /** + * @brief Decorator resolver: State + * Add: + * $$${key} = ${depIdx} + * $sub$${key} = [${reversedDeps}] + * @param node + */ + resolveStateDecorator(node: t.ClassProperty, idx: number, reverseDeps: Set | undefined) { + if (!this.classBodyNode) return; + if (!this.t.isIdentifier(node.key)) return; + const key = node.key.name; + const propertyIdx = this.classBodyNode.body.indexOf(node); + + const idxNode = !this.dLightModel + ? [this.t.classProperty(this.t.identifier(`$$${key}`), this.t.numericLiteral(1 << idx))] + : []; + + const depsNode = reverseDeps + ? [ + this.t.classProperty( + this.t.identifier(`$s$${key}`), + this.t.arrayExpression([...reverseDeps].map(d => this.t.stringLiteral(d))) + ), + ] + : []; + + this.classBodyNode.body.splice(propertyIdx + 1, 0, ...idxNode, ...depsNode); + } + + /* ---- Helper Functions ---- */ + handleClassCustomDecorators() { + if (!this.classBodyNode) return; + const decorators = this.classDeclarationNode?.decorators; + if (!decorators) return; + // ---- Forward Prop + const forwardPropDeco = this.findDecoratorByName(decorators, 'ForwardProps'); + /** + * _$forwardProp + * _$forwardPropMap = new Set() + * _$forwardPropsId = [] + */ + if (forwardPropDeco) { + this.classBodyNode.body.unshift( + this.t.classProperty(this.t.identifier('_$forwardProps')), + this.t.classProperty( + this.t.identifier('_$forwardPropsSet'), + this.t.newExpression(this.t.identifier('Set'), []) + ), + this.t.classProperty(this.t.identifier('_$forwardPropsId'), this.t.arrayExpression([])) + ); + this.classDeclarationNode!.decorators = this.removeDecorators(decorators, ['ForwardProps']); + } + } + + /** + * @brief Transform the whole DLight class when exiting the class + * 1. Alter all the state properties + * 2. Transform MainView and Snippets with DLight syntax + */ + transformDLightClass(): void { + const usedProperties = this.handleView(); + this.addAutoUpdate(this.dLightModel ? this.availableProperties : usedProperties); + const propertyArr = Object.entries(this.propertiesContainer).reverse(); + const depReversedMap = this.dependencyMapReversed(); + + for (const [ + key, + { node, deps, isStatic, isChildren, isPropOrEnv, isWatcher, isContent, isModel, depsNode }, + ] of propertyArr) { + if (isChildren) { + this.resolveChildrenDecorator(node as t.ClassProperty); + continue; + } + if (deps.length > 0) { + usedProperties.push(...deps); + if (isWatcher) { + this.resolveWatcherDecorator(node as t.ClassMethod, depsNode!); + } else if (!isModel) { + this.handleDerivedProperty(node as t.ClassProperty, depsNode!); + } + } + if (isPropOrEnv) { + this.resolvePropDecorator(node as t.ClassProperty, isPropOrEnv); + } + if (isContent) { + this.resolvePropDecorator(node as t.ClassProperty, 'Prop'); + this.resolveContentDecorator(node as t.ClassProperty); + } + if (isStatic) continue; + if (usedProperties.includes(key)) { + this.resolveStateDecorator(node as t.ClassProperty, this.availableProperties.indexOf(key), depReversedMap[key]); + } + } + } + + /** + * @brief Add updateProp and updateDerived if there's a assignment + * @param usedProperties + * @returns + */ + private addAutoUpdate(usedProperties: string[]) { + if (!this.classBodyNode) return; + const nonViewNodes = this.classBodyNode.body.filter( + n => + !( + ((this.t.isClassProperty(n) || this.t.isClassMethod(n)) && + ['constructor', '_$compName'].includes((n.key as t.Identifier).name)) || + this.t.isClassMethod(n, { static: true }) || + this.t.isClassProperty(n, { static: true }) + ) + ); + nonViewNodes.forEach(n => { + const value = this.t.isClassProperty(n) ? n.value : this.t.isClassMethod(n) ? n.body : null; + if (!value) return; + this.addUpdateDerived(value, usedProperties); + }); + } + + /** + * @Brief Add updateView and updateDerived to the node + * @param node + * @param usedProperties + */ + private addUpdateDerived(node: t.Expression | t.BlockStatement, usedProperties: string[]) { + const newUpdateProp = (node: t.Expression, key: string) => + this.t.callExpression(this.t.memberExpression(this.t.thisExpression(), this.t.identifier('_$ud')), [ + node, + this.t.stringLiteral(key), + ]); + this.traverse(this.valueWrapper(node), { + MemberExpression: path => { + if (!this.t.isThisExpression(path.node.object) || !this.t.isIdentifier(path.node.property)) return; + const key = path.node.property.name; + if (!usedProperties.includes(key)) return; + const assignPath = this.isAssignmentExpressionLeft(path); + if (!assignPath) return; + assignPath.replaceWith(newUpdateProp(assignPath.node as t.Expression, key)); + assignPath.skip(); + }, + CallExpression: path => { + if (!this.t.isMemberExpression(path.node.callee)) return; + const funcNameNode = path.node.callee.property; + if (!this.t.isIdentifier(funcNameNode)) return; + if (!reactivityFuncNames.includes(funcNameNode.name)) return; + let callee = path.get('callee').get('object') as NodePath; + + while (this.t.isMemberExpression(callee.node)) { + callee = callee.get('object') as NodePath; + } + if (!this.t.isThisExpression(callee?.node)) return; + const key = ((callee.parentPath!.node as t.MemberExpression).property as t.Identifier).name; + path.replaceWith(newUpdateProp(path.node, key)); + path.skip(); + }, + }); + } + + /* ---- DLight Class View Handlers ---- */ + /** + * @brief Transform Body and Snippets with DLight syntax + * @returns used properties + */ + handleView(): string[] { + if (!this.classBodyNode) return []; + const usedPropertySet = new Set(); + let mainView: undefined | t.ClassMethod; + const snippetNodes: t.ClassMethod[] = []; + for (let viewNode of this.classBodyNode.body) { + if (!this.t.isClassProperty(viewNode) && !this.t.isClassMethod(viewNode)) continue; + if (!this.t.isIdentifier(viewNode.key)) continue; + const isSnippet = this.findDecoratorByName(viewNode.decorators, 'Snippet'); + const isMainView = viewNode.key.name === 'Body'; + if (!isSnippet && !isMainView) continue; + + if (this.t.isClassProperty(viewNode)) { + // ---- Handle TSAsExpression, e.g. MyView = (() => {}) as Type1 as Type2 + let exp = viewNode.value; + while (this.t.isTSAsExpression(exp)) exp = exp.expression; + if (!this.t.isArrowFunctionExpression(exp)) continue; + viewNode.value = exp; + // ---- Transform arrow function property into method + const newViewNode = this.arrowFunctionPropertyToMethod(viewNode); + if (!newViewNode) continue; + viewNode = newViewNode; + } + + if (isSnippet) { + viewNode.decorators = null; + snippetNodes.push(viewNode); + } else { + mainView = viewNode; + } + } + + const snippetNames = snippetNodes.map(v => (v.key as t.Identifier).name); + const snippetPropSubDepMap: SnippetPropSubDepMap = Object.fromEntries( + snippetNodes + .map(v => { + const prop = v.params[0]; + if (!prop || !this.t.isObjectPattern(prop)) return ['-', null as any]; + const props = Object.fromEntries( + prop.properties + .map(p => { + if (!this.t.isObjectProperty(p)) return ['-', null]; + const key = (p.key as t.Identifier).name; + // ---- Get identifiers that depend on this prop + const subDeps = this.getIdentifiers( + // ---- Some unimportant value wrapper + this.t.assignmentExpression( + '=', + this.t.objectPattern([this.t.objectProperty(this.t.numericLiteral(0), p.value)]), + this.t.numericLiteral(0) + ) + ).filter(v => v !== key); + return [key, subDeps]; + }) + .filter(([_, props]) => props) + ); + return [(v.key as t.Identifier).name, props]; + }) + .filter(([_, props]) => props) + ); + let templateIdx = -1; + if (mainView) { + let usedProperties; + [usedProperties, templateIdx] = this.alterMainView(mainView, snippetNames, snippetPropSubDepMap); + usedProperties.forEach(usedPropertySet.add.bind(usedPropertySet)); + } + + snippetNodes.forEach(viewNode => { + let usedProperties; + [usedProperties, templateIdx] = this.alterSnippet(viewNode, snippetNames, snippetPropSubDepMap, templateIdx); + usedProperties.forEach(usedPropertySet.add.bind(usedPropertySet)); + }); + + const usedProperties: string[] = []; + this.availableProperties.forEach(p => { + if (usedPropertySet.has(p)) usedProperties.push(p); + }); + // const usedProperties = usedPropertyDeps.map(dep => dep.slice(1, -4)) + return usedProperties; + } + + /** + * @brief Transform Views with DLight syntax + * @param viewNode + * @param snippetNames + * @param isSnippet + * @returns Used properties + */ + alterMainView( + viewNode: t.ClassMethod, + snippetNames: string[], + snippetPropSubDepMap: SnippetPropSubDepMap + ): [Set, number] { + let viewUnits: ViewUnit[] = []; + // if the body method return a JSX element, parse it + if (this.isJsx) { + // Assume there should not early return + const returnNode = viewNode.body.body.find(v => this.t.isReturnStatement(v)) as t.ReturnStatement; + + // check if the return statement is a JSX element + if (this.t.isJSXElement(returnNode.argument) || this.t.isJSXFragment(returnNode.argument)) { + viewUnits = parseJSX(returnNode.argument, { + babelApi: this.babelApi, + htmlTags: this.htmlTags, + parseTemplate: false, + }); + } + } else { + viewUnits = parseView(viewNode.body, { + babelApi: this.babelApi, + snippetNames: snippetNames, + htmlTags: this.htmlTags, + }); + } + + const [viewParticles, usedPropertySet] = parseReactivity(viewUnits, { + babelApi: this.babelApi, + availableProperties: this.availableProperties, + dependencyMap: this.dependencyMap, + reactivityFuncNames, + }); + + const [body, classProperties, templateIdx] = generateView(viewParticles, { + babelApi: this.babelApi, + className: this.className!, + importMap, + snippetPropMap: Object.fromEntries( + Object.entries(snippetPropSubDepMap).map(([key, props]) => [key, Object.keys(props)]) + ), + templateIdx: -1, + attributeMap: this.attributeMap, + alterAttributeMap, + }); + viewNode.body = body; + this.classBodyNode?.body.push(...classProperties); + + return [usedPropertySet, templateIdx]; + } + + /** + * @brief Transform Snippets with DLight syntax + * @param viewNode + * @param snippetNames + * @param snippetPropSubDepMap + * @param templateIdx + * @returns + */ + alterSnippet( + viewNode: t.ClassMethod, + snippetNames: string[], + snippetPropSubDepMap: SnippetPropSubDepMap, + templateIdx: number + ): [Set, number] { + // ---- Add prop => Sub() => Sub(_$, $snippetNode) + if (viewNode.params.length === 0) { + viewNode.params.push(this.t.identifier('_$'), this.t.identifier('$snippetNode')); + } else if (viewNode.params.length === 1) { + viewNode.params.push(this.t.identifier('$snippetNode')); + } else { + viewNode.params[1] = this.t.identifier('$snippetNode'); + viewNode.params.length = 2; + } + const viewUnits = parseView(viewNode.body, { + babelApi: this.babelApi, + snippetNames: snippetNames, + htmlTags: this.htmlTags, + }); + + const snippetProp = snippetPropSubDepMap[(viewNode.key as t.Identifier).name] ?? []; + const identifierDepMap: Record = {}; + Object.entries(snippetProp).forEach(([key, subDeps]) => { + subDeps.forEach(dep => { + identifierDepMap[dep] = [key]; + }); + }); + + const [viewParticlesProperty, usedPropertySet] = parseReactivity(viewUnits, { + babelApi: this.babelApi, + availableProperties: this.availableProperties, + availableIdentifiers: Object.keys(snippetProp), + dependencyMap: this.dependencyMap, + dependencyParseType: 'property', + reactivityFuncNames, + }); + + const [viewParticlesIdentifier] = parseReactivity(viewUnits, { + babelApi: this.babelApi, + availableProperties: Object.keys(snippetProp), + dependencyMap: this.dependencyMap, + dependencyParseType: 'identifier', + identifierDepMap, + reactivityFuncNames, + }); + + const snippetPropMap = Object.fromEntries( + Object.entries(snippetPropSubDepMap).map(([key, props]) => [key, Object.keys(props)]) + ); + const [body, classProperties, newTemplateIdx] = generateSnippet( + viewParticlesProperty, + viewParticlesIdentifier, + viewNode.params[0] as t.ObjectPattern, + { + babelApi: this.babelApi, + className: this.className!, + importMap, + snippetPropMap, + templateIdx, + attributeMap: this.attributeMap, + alterAttributeMap, + } + ); + viewNode.body = body; + this.classBodyNode?.body.push(...classProperties); + + return [usedPropertySet, newTemplateIdx]; + } + + /** + * @brief Test if the file is allowed to be transformed + * @param fileName + * @returns is file allowed + */ + private fileAllowed(fileName: string | undefined): boolean { + if (this.includes.includes('*')) return true; + if (!fileName) return false; + if (this.excludes.some(pattern => minimatch(fileName, pattern))) return false; + if (!this.includes.some(pattern => minimatch(fileName, pattern))) return false; + return true; + } + + /** + * @brief Test if the class is a dlight view + * @param path + * @returns + */ + private isDLightView(path: NodePath): boolean { + const node = path.node; + const decorators = node.decorators ?? []; + const isViewDecorator = decorators.find((deco: t.Decorator) => + this.t.isIdentifier(deco.expression, { name: 'View' }) + ); + if (isViewDecorator) { + node.superClass = this.t.identifier('View'); + node.decorators = node.decorators?.filter( + (deco: t.Decorator) => !this.t.isIdentifier(deco.expression, { name: 'View' }) + ); + } + return this.t.isIdentifier(node.superClass, { name: 'View' }); + } + + /** + * @brief Test if the class is a dlight model + * @param path + * @returns + */ + private isDLightModel(path: NodePath): boolean { + const node = path.node; + const decorators = node.decorators ?? []; + const isModelDecorator = decorators.find((deco: t.Decorator) => + this.t.isIdentifier(deco.expression, { name: 'Model' }) + ); + if (isModelDecorator) { + node.superClass = this.t.identifier('Model'); + node.decorators = node.decorators?.filter( + (deco: t.Decorator) => !this.t.isIdentifier(deco.expression, { name: 'Model' }) + ); + } + + // ---- Add property _$model + node.body.body.unshift(this.t.classProperty(this.t.identifier('_$model'))); + + // ---- Delete all views + node.body.body = node.body.body.filter( + n => + !( + (this.t.isClassProperty(n) || this.t.isClassMethod(n, { kind: 'method' })) && + (this.findDecoratorByName(n.decorators, 'Snippet') || (this.t.isIdentifier(n.key) && n.key.name === 'Body')) + ) + ); + this.dLightModel = true; + + return this.t.isIdentifier(node.superClass, { name: 'Model' }); + } + + /** + * @brief Test if the class is a dlight class + * @param path + * @returns + */ + isDLightClass(path: NodePath): boolean { + return this.isDLightView(path) || this.isDLightModel(path); + } + + /** + * @brief Parse any use(Model) inside a property + * @param path + * @returns + */ + private parseModel(path: NodePath) { + const hasUseImport = this.allImports.some( + imp => + imp.source.value === this.dlightPackageName && + imp.specifiers.some(s => { + if (this.t.isImportSpecifier(s) && this.t.isIdentifier(s.imported, { name: 'use' })) { + return true; + } + }) + ); + if (!hasUseImport) return; + const node = path.node; + const key = node.key; + if (!this.t.isIdentifier(key)) return; + const value = node.value; + if (!this.t.isCallExpression(value)) return; + if (!this.t.isIdentifier(value.callee, { name: 'use' })) return; + const args = value.arguments; + const propsArg = args[1]; + const contentArg = args[2]; + let propsNode: t.Expression = this.t.nullLiteral(); + if (propsArg) { + const mergedPropsNode: [t.Expression, t.ArrayExpression | t.NullLiteral][] = []; + const spreadPropsNode: [t.Expression, t.Expression, t.ArrayExpression | t.NullLiteral][] = []; + // ---- Get props deps + if (this.t.isObjectExpression(propsArg)) { + propsArg.properties.forEach(prop => { + if (this.t.isSpreadElement(prop)) { + const [, depsNode] = this.getDependenciesFromNode(prop.argument as t.Expression); + mergedPropsNode.push([prop.argument as t.Expression, depsNode ?? this.t.nullLiteral()]); + } else if (this.t.isObjectProperty(prop)) { + const [, depsNode] = this.getDependenciesFromNode(prop.value as t.Expression); + spreadPropsNode.push([ + !prop.computed && this.t.isIdentifier(prop.key) + ? this.t.stringLiteral(prop.key.name) + : (prop.key as t.Expression), + prop.value as t.Expression, + depsNode ?? this.t.nullLiteral(), + ]); + } else { + spreadPropsNode.push([ + !prop.computed && this.t.isIdentifier(prop.key) + ? this.t.stringLiteral(prop.key.name) + : (prop.key as t.Expression), + this.t.arrowFunctionExpression([], prop.body), + this.t.nullLiteral(), + ]); + } + }); + } else { + const [, depsNode] = this.getDependenciesFromNode(propsArg as t.Expression); + mergedPropsNode.push([propsArg as t.Expression, depsNode ?? this.t.nullLiteral()]); + } + /** + * @View { ok: this.count, ...this.props } + * { + * m: [[this.props, []]] + * s: [["ok", this.count, [this.count]]] + * } + */ + propsNode = this.t.objectExpression([ + this.t.objectProperty( + this.t.identifier('m'), + this.t.arrayExpression(mergedPropsNode.map(n => this.t.arrayExpression(n))) + ), + this.t.objectProperty( + this.t.identifier('s'), + this.t.arrayExpression(spreadPropsNode.map(n => this.t.arrayExpression(n))) + ), + ]); + } + + let contentNode: t.Expression = this.t.nullLiteral(); + if (contentArg) { + const [, depsNode] = this.getDependenciesFromNode(contentArg as t.Expression); + contentNode = this.t.arrayExpression([contentArg as t.Expression, depsNode ?? this.t.nullLiteral()]); + } + args[1] = this.t.arrowFunctionExpression([], propsNode); + args[2] = this.t.arrowFunctionExpression([], contentNode); + args[3] = this.t.stringLiteral(key.name); + value.callee = this.t.memberExpression(this.t.thisExpression(), this.t.identifier('_$injectModel')); + // ---- Wrap a function for lazy evaluation + node.value = this.t.arrowFunctionExpression([], value); + // ---- Add $md$${key} + const propertyIdx = this.classBodyNode!.body.indexOf(node); + const modelDecorator = this.t.classProperty(this.t.identifier(`$md$${key.name}`)); + this.classBodyNode!.body.splice(propertyIdx, 0, modelDecorator); + return true; + } + + /** + * @brief Remove decorators by name + * Only search for Identifier and CallExpression, e.g, @Ok, @Ok() + * @param decorators + * @param names + * @returns new decorators + */ + private removeDecorators(decorators: t.Decorator[] | undefined | null, names: string[]): t.Decorator[] { + if (!decorators) return []; + return decorators.filter( + d => + !( + (this.t.isIdentifier(d.expression) && names.includes(d.expression.name)) || + (this.t.isCallExpression(d.expression) && + this.t.isIdentifier(d.expression.callee) && + names.includes(d.expression.callee.name)) + ) + ); + } + + /** + * @brief Find decorator by name, + * Only search for Identifier and CallExpression, e.g, @Ok, @Ok() + * @param decorators + * @param name + * @returns Identifier or CallExpression or nothing + */ + private findDecoratorByName( + decorators: t.Decorator[] | undefined | null, + name: string + ): t.Identifier | t.CallExpression | undefined { + if (!decorators) return; + return decorators.find( + deco => + this.t.isIdentifier(deco.expression, { name }) || + (this.t.isCallExpression(deco.expression) && this.t.isIdentifier(deco.expression.callee, { name })) + )?.expression as t.Identifier | t.CallExpression | undefined; + } + + /** + * @brief Generate a dependency node from a dependency identifier, + * loop until the parent node is not a binary expression or a member expression + * @param path + * @returns + */ + private geneDependencyNode(path: NodePath): t.Node { + let parentPath = path; + while (parentPath?.parentPath) { + const pParentPath = parentPath.parentPath; + if ( + !( + this.t.isMemberExpression(pParentPath.node, { computed: false }) || + this.t.isOptionalMemberExpression(pParentPath.node) + ) + ) { + break; + } + parentPath = pParentPath; + } + const depNode = this.t.cloneNode(parentPath.node); + // ---- Turn memberExpression to optionalMemberExpression + this.traverse(this.valueWrapper(depNode as t.Expression), { + MemberExpression: innerPath => { + if (this.t.isThisExpression(innerPath.node.object)) return; + innerPath.node.optional = true; + innerPath.node.type = 'OptionalMemberExpression' as any; + }, + }); + return depNode; + } + + /** + * constructor() { + * super() + * } + */ + private addConstructor(): t.ClassMethod { + let constructor = this.classBodyNode!.body.find(n => + this.t.isClassMethod(n, { kind: 'constructor' }) + ) as t.ClassMethod; + if (constructor) return constructor; + + constructor = this.t.classMethod( + 'constructor', + this.t.identifier('constructor'), + [], + this.t.blockStatement([this.t.expressionStatement(this.t.callExpression(this.t.super(), []))]) + ); + + this.classBodyNode!.body.unshift(constructor); + return constructor; + } + + private autoBindMethods(node: t.ClassMethod) { + const constructorNode = this.addConstructor(); + constructorNode.body.body.push( + this.t.expressionStatement( + this.t.assignmentExpression( + '=', + this.t.memberExpression(this.t.thisExpression(), node.key), + this.t.callExpression( + this.t.memberExpression( + this.t.memberExpression(this.t.thisExpression(), node.key), + this.t.identifier('bind') + ), + [this.t.thisExpression()] + ) + ) + ) + ); + } + + /** + * ${key} + * get $f$${key}() { + * if (this._$cache(${key}, ${deps})) return this.${key} + * return ${value} + * } + */ + private handleDerivedProperty(node: t.ClassProperty, depsNode: t.ArrayExpression) { + if (!this.t.isIdentifier(node.key)) return; + const key = node.key.name; + const value = node.value; + const propertyIdx = this.classBodyNode!.body.indexOf(node); + const getterNode = this.t.classMethod( + 'get', + this.t.identifier(`$f$${key}`), + [], + this.t.blockStatement([ + this.t.ifStatement( + this.t.callExpression(this.t.memberExpression(this.t.thisExpression(), this.t.identifier('_$cache')), [ + this.t.stringLiteral(key), + depsNode, + ]), + this.t.blockStatement([ + this.t.returnStatement(this.t.memberExpression(this.t.thisExpression(), this.t.identifier(key))), + ]) + ), + this.t.returnStatement(value), + ]) + ); + this.classBodyNode!.body.splice(propertyIdx + 1, 0, getterNode); + node.value = null; + } + + private getDependenciesFromNode( + node: t.Expression | t.ClassDeclaration, + isClassLevel = false + ): [string[], t.ArrayExpression | undefined] { + // ---- Deps: console.log(this.count) + const deps = new Set(); + // ---- Assign deps: this.count = 1 / this.count++ + const assignDeps = new Set(); + const depNodes: Record = {}; + + this.traverse(this.valueWrapper(node), { + MemberExpression: innerPath => { + if (!this.t.isIdentifier(innerPath.node.property) || !this.t.isThisExpression(innerPath.node.object)) return; + + const propertyKey = innerPath.node.property.name; + if (this.isAssignmentExpressionLeft(innerPath) || this.isAssignmentFunction(innerPath)) { + assignDeps.add(propertyKey); + } else if ( + this.availableProperties.includes(propertyKey) && + !this.isMemberInEscapeFunction(innerPath, this.classDeclarationNode!) && + !this.isMemberInManualFunction(innerPath, this.classDeclarationNode!) + ) { + deps.add(propertyKey); + if (isClassLevel) this.dependencyMap[propertyKey]?.forEach(deps.add.bind(deps)); + if (!depNodes[propertyKey]) depNodes[propertyKey] = []; + depNodes[propertyKey].push(this.geneDependencyNode(innerPath)); + } + }, + }); + + // ---- Eliminate deps that are assigned in the same method + // e.g. { console.log(this.count); this.count = 1 } + // this will cause infinite loop + // so we eliminate "count" from deps + assignDeps.forEach(dep => { + deps.delete(dep); + delete depNodes[dep]; + }); + + let dependencyNodes = Object.values(depNodes).flat(); + // ---- deduplicate the dependency nodes + dependencyNodes = dependencyNodes.filter((n, i) => { + const idx = dependencyNodes.findIndex(m => this.t.isNodesEquivalent(m, n)); + return idx === i; + }); + + // ---- Add deps to dependencyMap + const depArr = [...deps]; + if (isClassLevel && deps.size > 0) { + const propertyKey = (((node as t.ClassDeclaration).body.body[0] as t.ClassMethod).key as t.Identifier).name; + this.dependencyMap[propertyKey] = depArr; + } + + return [depArr, this.t.arrayExpression(dependencyNodes)]; + } + /** + * @brief Get all valid dependencies of a babel path + * @param path + * @returns dependencies + */ + private getDependencies(node: t.ClassMethod | t.ClassProperty): [string[], t.ArrayExpression | undefined] { + if (!this.t.isIdentifier(node.key)) return [[], undefined]; + const wrappedNode = this.t.classDeclaration(null, null, this.t.classBody([node])); + return this.getDependenciesFromNode(wrappedNode, true); + } + + private dependencyMapReversed() { + const reversedMap: Record> = {}; + Object.entries(this.dependencyMap).forEach(([key, deps]) => { + deps.forEach(dep => { + if (!reversedMap[dep]) reversedMap[dep] = new Set(); + reversedMap[dep].add(key); + }); + }); + + return reversedMap; + } + + private resolveMounting(path: NodePath) { + const node = path.node; + if (!this.t.isIdentifier(node.id)) return; + const decorators = node.decorators ?? []; + const findEntry = (name: string) => { + const found = decorators.find(deco => this.t.isIdentifier(deco.expression, { name })); + if (found) + decorators.splice( + decorators.findIndex(deco => deco === found), + 1 + ); + return found; + }; + + // ---- Find "@Main" for mounting "main", "@App" for mounting "app" + const entryValue = findEntry('Main') ? 'main' : findEntry('App') ? 'app' : null; + let mountNode: t.Expression; + if (entryValue) { + mountNode = this.t.stringLiteral(entryValue); + } else { + // ---- Find "@Mount("any-id")" + const mounting = decorators.find( + deco => + this.t.isCallExpression(deco.expression) && + this.t.isIdentifier(deco.expression.callee, { name: 'Mount' }) && + deco.expression.arguments.length === 1 + ) as t.Decorator; + if (!mounting) return; + decorators.splice( + decorators.findIndex(deco => deco === mounting), + 1 + ); + mountNode = (mounting.expression as t.CallExpression).arguments[0] as t.Expression; + } + + // ---- ${importMap.render}("main", ${node.id}) + const parentNode = path.parentPath.node; + if (!this.t.isBlockStatement(parentNode) && !this.t.isProgram(parentNode)) return; + const idx = parentNode.body.indexOf(node); + parentNode.body.splice( + idx + 1, + 0, + this.t.expressionStatement( + this.t.callExpression(this.t.identifier(importMap.render), [mountNode, node.id as t.Identifier]) + ) + ); + } + + /** + * @brief Transform arrow function property to method + * @param propertyNode + * @returns new method node + */ + arrowFunctionPropertyToMethod(propertyNode: t.ClassProperty): t.ClassMethod | undefined { + if (!this.t.isArrowFunctionExpression(propertyNode.value)) return; + const value = propertyNode.value; + if (!this.t.isBlockStatement(value.body)) return; + // ---- Remove property + const propertyIdx = this.classBodyNode!.body.indexOf(propertyNode); + // ---- Add method + const methodNode = this.t.classMethod('method', propertyNode.key, value.params, value.body); + this.classBodyNode!.body.splice(propertyIdx, 1, methodNode); + + return methodNode; + } + + /** + * @brief Check if a member expression is a property of a member expression + * @param parentNode + * @param currentNode + * @returns is a property of a member expression + */ + isMemberExpressionProperty(parentNode: t.Node, currentNode: t.Node): boolean { + return this.t.isMemberExpression(parentNode) && !parentNode.computed && parentNode.property === currentNode; + } + + /** + * @brief Check if a member expression is a key of an object + * @param parentNode + * @param currentNode + * @returns is a key of an object + */ + isObjectKey(parentNode: t.Node, currentNode: t.Node): boolean { + return this.t.isObjectProperty(parentNode) && parentNode.key === currentNode; + } + + /** + * @brief Add arrow function to property value + * @param node + */ + valueWithArrowFunc(node: t.ClassProperty): void { + if (!node.value) { + node.value = this.t.identifier('undefined'); + } + node.value = this.t.arrowFunctionExpression([], node.value); + } + + /** + * @brief Get all top level return statements in a block statement, + * ignore nested function returns + * @param node + * @returns + */ + getAllTopLevelReturnBlock(node: t.BlockStatement): t.BlockStatement[] { + const returns: t.BlockStatement[] = []; + + let inNestedFunction = false; + this.traverse(this.valueWrapper(node), { + Function: path => { + if (inNestedFunction) return; + inNestedFunction = true; + path.skip(); + }, + ReturnStatement: path => { + if (inNestedFunction) return; + const parentNode = path.parentPath.node; + if (!this.t.isBlockStatement(parentNode)) { + const newNode = this.t.blockStatement([path.node]); + path.replaceWith(newNode); + returns.push(newNode); + } else { + returns.push(parentNode); + } + path.skip(); + }, + exit: path => { + if (this.t.isFunction(path.node)) inNestedFunction = false; + }, + }); + + return returns; + } + + /** + * @brief Wrap the value in a file + * @param node + * @returns wrapped value + */ + private valueWrapper(node: t.Expression | t.Statement): t.File { + return this.t.file(this.t.program([this.t.isStatement(node) ? node : this.t.expressionStatement(node)])); + } + + /** + * @brief check if the identifier is from a function param till the stopNode + * e.g: + * function myFunc1(ok) { // stopNode = functionBody + * const myFunc2 = ok => ok // from function param + * console.log(ok) // not from function param + * } + * @param path + * @param idName + */ + private isAttrFromFunction(path: NodePath, idName: string) { + let reversePath = path.parentPath; + + const checkParam: (param: t.Node) => boolean = (param: t.Node) => { + // ---- 3 general types: + // * represent allow nesting + // ---0 Identifier: (a) + // ---1 RestElement: (...a) * + // ---1 Pattern: 3 sub Pattern + // -----0 AssignmentPattern: (a=1) * + // -----1 ArrayPattern: ([a, b]) * + // -----2 ObjectPattern: ({a, b}) + if (this.t.isIdentifier(param)) return param.name === idName; + if (this.t.isAssignmentPattern(param)) return checkParam(param.left); + if (this.t.isArrayPattern(param)) { + return param.elements + .filter(Boolean) + .map(el => checkParam(el!)) + .includes(true); + } + if (this.t.isObjectPattern(param)) { + return ( + param.properties.filter( + prop => this.t.isObjectProperty(prop) && this.t.isIdentifier(prop.key) + ) as t.ObjectProperty[] + ) + .map(prop => (prop.key as t.Identifier).name) + .includes(idName); + } + if (this.t.isRestElement(param)) return checkParam(param.argument); + + return false; + }; + + while (reversePath) { + const node = reversePath.node; + if (this.t.isArrowFunctionExpression(node) || this.t.isFunctionDeclaration(node)) { + for (const param of node.params) { + if (checkParam(param)) return true; + } + } + reversePath = reversePath.parentPath; + } + + return false; + } + + /** + * @brief Check if an identifier is a simple identifier, i.e., not a member expression, or a function param + * @param path + * 1. not a member expression + * 2. not a function param + * 3. not in a declaration + * 4. not as object property's not computed key + */ + private isStandAloneIdentifier(path: NodePath) { + const node = path.node; + const parentNode = path.parentPath?.node; + const isMemberExpression = this.t.isMemberExpression(parentNode) && parentNode.property === node; + if (isMemberExpression) return false; + const isFunctionParam = this.isAttrFromFunction(path, node.name); + if (isFunctionParam) return false; + while (path.parentPath) { + if (this.t.isVariableDeclarator(path.parentPath.node)) return false; + if ( + this.t.isObjectProperty(path.parentPath.node) && + path.parentPath.node.key === path.node && + !path.parentPath.node.computed + ) + return false; + path = path.parentPath as any; + } + return true; + } + + /** + * @brief Get all identifiers as strings in a node + * @param node + * @returns identifiers + */ + private getIdentifiers(node: t.Node): string[] { + if (this.t.isIdentifier(node)) return [node.name]; + const identifierKeys = new Set(); + this.traverse(this.valueWrapper(node as any), { + Identifier: innerPath => { + if (!this.isStandAloneIdentifier(innerPath)) return; + identifierKeys.add(innerPath.node.name); + }, + }); + return [...identifierKeys]; + } + + static escapeNamings = ['escape', '$']; + + /** + * @brief Check if it's the left side of an assignment expression, e.g. this.count = 1 + * @param innerPath + * @returns assignment expression + */ + isAssignmentExpressionLeft(innerPath: NodePath): NodePath | null { + let parentPath = innerPath.parentPath; + while (parentPath && !this.t.isStatement(parentPath.node)) { + if (this.t.isAssignmentExpression(parentPath.node)) { + if (parentPath.node.left === innerPath.node) return parentPath; + const leftPath = parentPath.get('left') as NodePath; + if (innerPath.isDescendant(leftPath)) return parentPath; + } else if (this.t.isUpdateExpression(parentPath.node)) { + return parentPath; + } + parentPath = parentPath.parentPath; + } + + return null; + } + + /** + * @brief Check if it's a reactivity function, e.g. arr.push + * @param innerPath + * @returns + */ + isAssignmentFunction(innerPath: NodePath): boolean { + let parentPath = innerPath.parentPath; + + while (parentPath && this.t.isMemberExpression(parentPath.node)) { + parentPath = parentPath.parentPath; + } + if (!parentPath) return false; + return ( + this.t.isCallExpression(parentPath.node) && + this.t.isMemberExpression(parentPath.node.callee) && + this.t.isIdentifier(parentPath.node.callee.property) && + reactivityFuncNames.includes(parentPath.node.callee.property.name) + ); + } + + /** + * @brief Check if it's in an "escape" function, + * e.g. escape(() => { console.log(this.count) }) + * deps will be empty instead of ["count"] + * @param innerPath + * @param classDeclarationNode + * @returns is in escape function + */ + isMemberInEscapeFunction(innerPath: NodePath, stopNode: t.Node): boolean { + let isInFunction = false; + let reversePath = innerPath.parentPath; + while (reversePath && reversePath.node !== stopNode) { + const node = reversePath.node; + if ( + this.t.isCallExpression(node) && + this.t.isIdentifier(node.callee) && + PluginProvider.escapeNamings.includes(node.callee.name) + ) { + isInFunction = true; + break; + } + reversePath = reversePath.parentPath; + } + return isInFunction; + } + + /** + * @brief Check if it's in a "manual" function, + * e.g. manual(() => { console.log(this.count) }, ["flag"]) + * deps will be ["flag"] instead of ["count"] + * @param innerPath + * @param classDeclarationNode + * @returns is in manual function + */ + isMemberInManualFunction(innerPath: NodePath, stopNode: t.Node): boolean { + let isInFunction = false; + let reversePath = innerPath.parentPath; + while (reversePath && reversePath.node !== stopNode) { + const node = reversePath.node; + const parentNode = reversePath.parentPath?.node; + const isFunction = this.t.isFunctionExpression(node) || this.t.isArrowFunctionExpression(node); + const isManual = + this.t.isCallExpression(parentNode) && + this.t.isIdentifier(parentNode.callee) && + parentNode.callee.name === 'manual'; + if (isFunction && isManual) { + isInFunction = true; + break; + } + reversePath = reversePath.parentPath; + } + + return isInFunction; + } + + /** + * @brief Generate a random string + * @param length + * @returns random string + */ + private static uid(length = 4): string { + return Math.random() + .toString(32) + .slice(2, length + 2); + } +} + +/** + * @brief Change the PluginProvider class for class inheritance + */ +export let PluginProviderClass = PluginProvider; +export function changePluginProviderClass(cls: typeof PluginProvider) { + PluginProviderClass = cls; +} diff --git a/packages/transpiler/babel-inula-next-core/src/types.ts b/packages/transpiler/babel-inula-next-core/src/types.ts new file mode 100644 index 00000000..a2e2049b --- /dev/null +++ b/packages/transpiler/babel-inula-next-core/src/types.ts @@ -0,0 +1,53 @@ +import { type types as t } from '@babel/core'; + +export type HTMLTags = string[] | ((defaultHtmlTags: string[]) => string[]); +export interface DLightOption { + /** + * Files that will be included + * @default ** /*.{js,jsx,ts,tsx} + */ + files?: string | string[]; + /** + * Files that will be excludes + * @default ** /{dist,node_modules,lib}/*.{js,ts} + */ + excludeFiles?: string | string[]; + /** + * Enable devtools + * @default false + */ + enableDevTools?: boolean; + /** + * Custom HTML tags. + * Accepts 2 types: + * 1. string[], e.g. ["div", "span"] + * if contains "*", then all default tags will be included + * 2. (defaultHtmlTags: string[]) => string[] + * @default defaultHtmlTags => defaultHtmlTags + */ + htmlTags?: HTMLTags; + /** + * Allowed HTML tags from attributes + * e.g. { alt: ["area", "img", "input"] } + */ + attributeMap?: Record; +} + +export type PropertyContainer = Record< + string, + { + node: t.ClassProperty | t.ClassMethod; + deps: string[]; + isStatic?: boolean; + isContent?: boolean; + isChildren?: boolean | number; + isModel?: boolean; + isWatcher?: boolean; + isPropOrEnv?: 'Prop' | 'Env'; + depsNode?: t.ArrayExpression; + } +>; + +export type IdentifierToDepNode = t.SpreadElement | t.Expression; + +export type SnippetPropSubDepMap = Record>; diff --git a/packages/transpiler/babel-inula-next-core/src/utils.ts b/packages/transpiler/babel-inula-next-core/src/utils.ts new file mode 100644 index 00000000..d626967a --- /dev/null +++ b/packages/transpiler/babel-inula-next-core/src/utils.ts @@ -0,0 +1,22 @@ +import { NodePath } from '@babel/core'; +import * as t from '@babel/types'; + +export function extractFnFromMacro(path: NodePath, macroName: string) { + const args = path.get('arguments'); + + const fnNode = args[0]; + if (fnNode.isFunctionExpression() || fnNode.isArrowFunctionExpression()) { + return fnNode; + } + + throw new Error(`${macroName} macro must have a function argument`); +} + +export function getFnBody(path: NodePath) { + const fnBody = path.get('body'); + if (fnBody.isExpression()) { + // turn expression into block statement for consistency + fnBody.replaceWith(t.blockStatement([t.returnStatement(fnBody.node)])); + } + return (fnBody as NodePath).get('body'); +} diff --git a/packages/transpiler/babel-inula-next-core/test/condition.test.tsx b/packages/transpiler/babel-inula-next-core/test/condition.test.tsx new file mode 100644 index 00000000..4e95cf02 --- /dev/null +++ b/packages/transpiler/babel-inula-next-core/test/condition.test.tsx @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2024 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { describe, expect, it } from 'vitest'; +import { transform } from './presets'; + +describe('condition', () => { + it('should transform jsx', () => { + expect( + transform(` + function App() { + return
+ 1}>{count} is bigger than is 1 + {count} is smaller than 1 +
; + } + `) + ).toMatchInlineSnapshot(` + "import { createElement as $$createElement, setStyle as $$setStyle, setDataset as $$setDataset, setEvent as $$setEvent, delegateEvent as $$delegateEvent, setHTMLProp as $$setHTMLProp, setHTMLAttr as $$setHTMLAttr, setHTMLProps as $$setHTMLProps, setHTMLAttrs as $$setHTMLAttrs, createTextNode as $$createTextNode, updateText as $$updateText, insertNode as $$insertNode, ForNode as $$ForNode, CondNode as $$CondNode, ExpNode as $$ExpNode, EnvNode as $$EnvNode, TryNode as $$TryNode, SnippetNode as $$SnippetNode, PropView as $$PropView, render as $$render } from "@openinula/next"; + class App extends View { + Body() { + let $node0, $node1; + this._$update = $changed => { + $node1 && $node1.update($changed); + }; + $node0 = $$createElement("div"); + $node1 = new $$CondNode(0, $thisCond => { + if (count > 1) { + if ($thisCond.cond === 0) { + $thisCond.didntChange = true; + return []; + } + $thisCond.cond = 0; + let $node0, $node1; + $thisCond.updateFunc = $changed => {}; + $node0 = new $$ExpNode(count, []); + $node1 = $$createTextNode(" is bigger than is 1", []); + return $thisCond.cond === 0 ? [$node0, $node1] : $thisCond.updateCond(); + } else { + if ($thisCond.cond === 1) { + $thisCond.didntChange = true; + return []; + } + $thisCond.cond = 1; + let $node0, $node1; + $thisCond.updateFunc = $changed => {}; + $node0 = new $$ExpNode(count, []); + $node1 = $$createTextNode(" is smaller than 1", []); + return $thisCond.cond === 1 ? [$node0, $node1] : $thisCond.updateCond(); + } + }); + $$insertNode($node0, $node1, 0); + $node0._$nodes = [$node1]; + return [$node0]; + } + }" + `); + }); +}); diff --git a/packages/transpiler/babel-inula-next-core/test/earlyReturnAnanlyze.test.ts b/packages/transpiler/babel-inula-next-core/test/earlyReturnAnanlyze.test.ts new file mode 100644 index 00000000..51284d14 --- /dev/null +++ b/packages/transpiler/babel-inula-next-core/test/earlyReturnAnanlyze.test.ts @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2024 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { describe, expect, it } from 'vitest'; +import { isCondNode } from '../src/analyze'; +import { mockAnalyze } from './mock'; + +describe('analyze early return', () => { + it('should work', () => { + const root = mockAnalyze(` + function App() { + if (count > 1) { + return
1
+ } + return
+ 1}>{count} is bigger than is 1 + {count} is smaller than 1 +
; + } + `); + const branchNode = root?.child; + if (!isCondNode(branchNode)) { + throw new Error('Should be branch node'); + } + expect(branchNode.branches.length).toBe(1); + }); + + it('should work with multi if', () => { + const root = mockAnalyze(` + function App() { + if (count > 1) { + return
1
+ } + if (count > 2) { + return
2
+ } + return
; + } + `); + const branchNode = root?.child; + if (!isCondNode(branchNode)) { + throw new Error('Should be branch node'); + } + expect(branchNode.branches.length).toBe(1); + const subBranch = branchNode.child.child; + if (!isCondNode(subBranch)) { + throw new Error('SubBranchNode should be branch node'); + } + expect(subBranch.branches.length).toBe(1); + }); + + it('should work with nested if', () => { + const root = mockAnalyze(` + function App() { + if (count > 1) { + if (count > 2) { + return
2
+ } + return
1
+ } + return
; + } + `); + const branchNode = root?.child; + if (!isCondNode(branchNode)) { + throw new Error('Should be branch node'); + } + expect(branchNode.branches.length).toBe(1); + const subBranchNode = branchNode.branches[0].content.child; + if (!isCondNode(subBranchNode)) { + throw new Error('SubBranchNode should be branch node'); + } + expect(subBranchNode.branches.length).toBe(1); + }); +}); diff --git a/packages/transpiler/babel-inula-next-core/test/index.test.tsx b/packages/transpiler/babel-inula-next-core/test/index.test.tsx new file mode 100644 index 00000000..b07ce5bc --- /dev/null +++ b/packages/transpiler/babel-inula-next-core/test/index.test.tsx @@ -0,0 +1,233 @@ +import { describe, expect, it } from 'vitest'; +import { transform } from './presets'; + +describe('fn2Class', () => { + it('should transform jsx', () => { + expect( + transform(` + @View + class A { + Body() { + return
+ } + }`) + ).toMatchInlineSnapshot(` + "import { createElement as $$createElement, setStyle as $$setStyle, setDataset as $$setDataset, setEvent as $$setEvent, delegateEvent as $$delegateEvent, setHTMLProp as $$setHTMLProp, setHTMLAttr as $$setHTMLAttr, setHTMLProps as $$setHTMLProps, setHTMLAttrs as $$setHTMLAttrs, createTextNode as $$createTextNode, updateText as $$updateText, insertNode as $$insertNode, ForNode as $$ForNode, CondNode as $$CondNode, ExpNode as $$ExpNode, EnvNode as $$EnvNode, TryNode as $$TryNode, SnippetNode as $$SnippetNode, PropView as $$PropView, render as $$render } from "@dlightjs/dlight"; + class A extends View { + Body() { + let $node0; + $node0 = $$createElement("div"); + return [$node0]; + } + }" + `); + }); + + it('should transform jsx with reactive', () => { + expect( + transform(` + @Main + @View + class A { + count = 1 + Body() { + return
this.count++}>{this.count}
+ } + }`) + ).toMatchInlineSnapshot(` + "import { createElement as $$createElement, setStyle as $$setStyle, setDataset as $$setDataset, setEvent as $$setEvent, delegateEvent as $$delegateEvent, setHTMLProp as $$setHTMLProp, setHTMLAttr as $$setHTMLAttr, setHTMLProps as $$setHTMLProps, setHTMLAttrs as $$setHTMLAttrs, createTextNode as $$createTextNode, updateText as $$updateText, insertNode as $$insertNode, ForNode as $$ForNode, CondNode as $$CondNode, ExpNode as $$ExpNode, EnvNode as $$EnvNode, TryNode as $$TryNode, SnippetNode as $$SnippetNode, PropView as $$PropView, render as $$render } from "@dlightjs/dlight"; + class A extends View { + count = 1; + $$count = 1; + Body() { + let $node0, $node1; + this._$update = $changed => { + if ($changed & 1) { + $node1 && $node1.update(() => this.count, [this.count]); + } + }; + $node0 = $$createElement("div"); + $$delegateEvent($node0, "click", () => this._$ud(this.count++, "count")); + $node1 = new $$ExpNode(this.count, [this.count]); + $$insertNode($node0, $node1, 0); + $node0._$nodes = [$node1]; + return [$node0]; + } + } + $$render("main", A);" + `); + }); + + it('should transform fragment', () => { + expect( + transform(` + @View + class A { + Body() { + return <> +
+ + } + }`) + ).toMatchInlineSnapshot(` + "import { createElement as $$createElement, setStyle as $$setStyle, setDataset as $$setDataset, setEvent as $$setEvent, delegateEvent as $$delegateEvent, setHTMLProp as $$setHTMLProp, setHTMLAttr as $$setHTMLAttr, setHTMLProps as $$setHTMLProps, setHTMLAttrs as $$setHTMLAttrs, createTextNode as $$createTextNode, updateText as $$updateText, insertNode as $$insertNode, ForNode as $$ForNode, CondNode as $$CondNode, ExpNode as $$ExpNode, EnvNode as $$EnvNode, TryNode as $$TryNode, SnippetNode as $$SnippetNode, PropView as $$PropView, render as $$render } from "@dlightjs/dlight"; + class A extends View { + Body() { + let $node0; + $node0 = $$createElement("div"); + return [$node0]; + } + }" + `); + }); + + it('should transform function component', () => { + expect( + transform(` + function MyApp() { + let count = 0; + return
count++}>{count}
+ }`) + ).toMatchInlineSnapshot(` + "import { createElement as $$createElement, setStyle as $$setStyle, setDataset as $$setDataset, setEvent as $$setEvent, delegateEvent as $$delegateEvent, setHTMLProp as $$setHTMLProp, setHTMLAttr as $$setHTMLAttr, setHTMLProps as $$setHTMLProps, setHTMLAttrs as $$setHTMLAttrs, createTextNode as $$createTextNode, updateText as $$updateText, insertNode as $$insertNode, ForNode as $$ForNode, CondNode as $$CondNode, ExpNode as $$ExpNode, EnvNode as $$EnvNode, TryNode as $$TryNode, SnippetNode as $$SnippetNode, PropView as $$PropView, render as $$render } from "@dlightjs/dlight"; + class MyApp extends View { + count = 0; + $$count = 1; + Body() { + let $node0, $node1; + this._$update = $changed => { + if ($changed & 1) { + $node1 && $node1.update(() => this.count, [this.count]); + } + }; + $node0 = $$createElement("div"); + $$delegateEvent($node0, "click", () => this._$ud(this.count++, "count")); + $node1 = new $$ExpNode(this.count, [this.count]); + $$insertNode($node0, $node1, 0); + $node0._$nodes = [$node1]; + return [$node0]; + } + }" + `); + }); + + it('should transform function component reactively', () => { + expect( + transform(` + function MyComp() { + let count = 0 + return <> +

Hello dlight fn, {count}

+ +