feat: init

This commit is contained in:
Hoikan 2024-04-19 17:58:52 +08:00
parent a536958ad4
commit fcc734e05f
44 changed files with 4433 additions and 5 deletions

View File

@ -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

View File

@ -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
}
}

View File

@ -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<t.ReturnStatement>, context: AnalyzeContext) {
const currentComp = context.currentComponent;
const argument = path.get('argument');
if (argument.isJSXElement()) {
currentComp.child = createJSXNode(currentComp, argument);
}
},
IfStatement(ifStmt: NodePath<t.IfStatement>, context: AnalyzeContext) {
if (!hasEarlyReturn(ifStmt)) {
return;
}
const currentComp = context.currentComponent;
const branches: Branch[] = [];
let next: NodePath<t.Statement> | 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<t.Statement | null | undefined> = 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<t.Statement>) {
return next.isBlockStatement() ? next.get('body') : [next];
}
function hasEarlyReturn(path: NodePath<t.Node>) {
let hasReturn = false;
path.traverse({
ReturnStatement(path: NodePath<t.ReturnStatement>) {
if (
path.parentPath.isFunctionDeclaration() ||
path.parentPath.isFunctionExpression() ||
path.parentPath.isArrowFunctionExpression()
) {
return;
}
hasReturn = true;
},
});
return hasReturn;
}

View File

@ -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<t.Statement>[], componentNode: ComponentNode, level = 0) {
const visitor = mergeVisitor(...analyzers);
const visit = (p: NodePath<t.Statement>, 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<t.Statement>, 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<t.FunctionExpression | t.ArrowFunctionExpression>,
customAnalyzers?: Analyzer[]
) {
if (customAnalyzers) {
analyzers = customAnalyzers;
}
const root = createComponentNode(fnName, getFnBody(path));
return root;
}

View File

@ -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<t.JSXElement>, 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<t.JSXElement | t.JSXFragment>) {
// 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 = <div />`
* 2. SubComponent, like `function Sub() { return <div /> }`
*
* i.e.
* ```jsx
* let jsxSlice = <div>{count}</div>
* // =>
* function Comp_$id$() {
* return <div>{count}</div>
* }
* let jsxSlice = <Comp_$id$/>
* ```
*/
export function jsxSlicesAnalyze(): Visitor {
return {
JSXElement(path: NodePath<t.JSXElement>, ctx) {
const name = genNameFromJSX(path, ctx);
replaceJSXSliceWithSubComp(name, ctx, path);
path.skip();
},
JSXFragment(path: NodePath<t.JSXFragment>, ctx) {
replaceJSXSliceWithSubComp('frag', ctx, path);
},
};
}

View File

@ -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<t.CallExpression>, 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));
}
}
},
};
}

View File

@ -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<t.Statement>[],
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<t.FunctionDeclaration>) {
comp.methods.push(method);
}
export function addLifecycle(comp: ComponentNode, lifeCycle: LifeCycle, stmts: NodePath<t.Statement>[]) {
const compLifecycle = comp.lifecycle;
if (!compLifecycle[lifeCycle]) {
compLifecycle[lifeCycle] = [];
}
compLifecycle[lifeCycle].push(stmts);
}
export function createJSXNode(parent: ComponentNode, content: NodePath<JSX>): 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,
};
}

View File

@ -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 = <div />`
// 2. SubComponent, like `function Sub() { return <div /> }`
function handleFn(fnName: string, fnBody: NodePath<t.BlockStatement>) {
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 <div>{count}</div>
// }
// =>
// function jsxFunc() {
// function Comp_$id4$() {
// return <div>{count}</div>
// }
// // This is a function that returns JSX
// // because the function name is smallCamelCased
// return <Comp_$id4$/>
// }
}
}
// 3. jsx creation function, like `function create() { return <div /> }`
export function propertiesAnalyze(): Visitor {
return {
VariableDeclaration(path: NodePath<t.VariableDeclaration>, 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 = () => <div />`
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<t.BlockStatement>);
}
// handle jsx slice
ctx.traverse(path, ctx);
}
addState(ctx.currentComponent, id.node.name, declaration.node.init || null);
}
});
},
FunctionDeclaration(path: NodePath<t.FunctionDeclaration>, 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'));
},
};
}

View File

@ -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<string, defaultVal>;
// A valuable could be a state or computed
valuable: Reactive;
methods: NodePath<t.FunctionDeclaration>[];
child?: InulaNode;
subComponents: ComponentNode[];
parent?: ComponentNode;
/**
* The function body of the fn component code
*/
// fnBody: NodePath<t.Statement>[];
// a map to find the state
reactiveMap: Record<string, Bitmap>;
level: number;
lifecycle: Record<LifeCycle, NodePath<t.Statement>[][]>;
}
export interface SubCompNode {
type: 'subComp';
name: string;
parent: ComponentNode;
child: JSX;
}
export interface JSXNode {
type: 'jsx';
parent: ComponentNode;
child: NodePath<JSX>;
}
export interface CondNode {
type: 'cond';
branches: Branch[];
parent: ComponentNode;
/**
* The default branch
*/
child: InulaNode;
}
export interface Branch {
conditions: NodePath<t.Expression>[];
content: InulaNode;
}
export interface AnalyzeContext {
level: number;
index: number;
currentComponent: ComponentNode;
restStmt: NodePath<t.Statement>[];
// --- flow control ---
/**
* ignore the rest of the statements
*/
skipRest: () => void;
traverse: (p: NodePath<t.Statement>, ctx: AnalyzeContext) => void;
}
export type Visitor<S = AnalyzeContext> = {
[Type in Node['type']]?: (path: NodePath<Extract<Node, { type: Type }>>, state: S) => void;
};
export type Analyzer = () => Visitor;
export interface FnComponentDeclaration extends t.FunctionDeclaration {
id: t.Identifier;
}

View File

@ -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<T>(path: NodePath<T>): path is NodePath<Exclude<T, undefined | null>> {
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<t.Node>) {
if (path.isJSXElement()) {
return true;
}
// check if there is JSXElement in the children
let seen = false;
path.traverse({
JSXElement() {
seen = true;
},
});
return seen;
}

View File

@ -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: ['*'],
};

View File

@ -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';

View File

@ -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';

View File

@ -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;
}

View File

@ -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<t.CallExpression>) {
// 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();
}
},
},
};
}

View File

@ -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),
},
};
}

File diff suppressed because it is too large Load Diff

View File

@ -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<string, string[]>;
}
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<string, Record<string, string[]>>;

View File

@ -0,0 +1,22 @@
import { NodePath } from '@babel/core';
import * as t from '@babel/types';
export function extractFnFromMacro(path: NodePath<t.CallExpression>, 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<t.FunctionExpression | t.ArrowFunctionExpression>) {
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<t.BlockStatement>).get('body');
}

View File

@ -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 <div>
<if cond={count > 1}>{count} is bigger than is 1</if>
<else>{count} is smaller than 1</else>
</div>;
}
`)
).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];
}
}"
`);
});
});

View File

@ -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 <div>1</div>
}
return <div>
<if cond={count > 1}>{count} is bigger than is 1</if>
<else>{count} is smaller than 1</else>
</div>;
}
`);
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 <div>1</div>
}
if (count > 2) {
return <div>2</div>
}
return <div></div>;
}
`);
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 <div>2</div>
}
return <div>1</div>
}
return <div></div>;
}
`);
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);
});
});

View File

@ -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 <div></div>
}
}`)
).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 <div onClick={() => this.count++}>{this.count}</div>
}
}`)
).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 <>
<div></div>
</>
}
}`)
).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 <div onClick={() => count++}>{count}</div>
}`)
).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 <>
<h1>Hello dlight fn, {count}</h1>
<button onClick={() => count +=1}>Add</button>
<Button />
</>
}`)
).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 MyComp extends View {
count = 0;
$$count = 1;
Body() {
let $node0, $node1, $node2, $node3, $node4;
this._$update = $changed => {
if ($changed & 1) {
$node2 && $node2.update(() => this.count, [this.count]);
}
};
$node0 = $$createElement("h1");
$node1 = $$createTextNode("Hello dlight fn, ", []);
$$insertNode($node0, $node1, 0);
$node2 = new $$ExpNode(this.count, [this.count]);
$$insertNode($node0, $node2, 1);
$node0._$nodes = [$node1, $node2];
$node3 = $$createElement("button");
$$delegateEvent($node3, "click", () => this._$ud(this.count += 1, "count"));
$node3.textContent = "Add";
$node4 = new Button();
$node4._$init(null, null, null, null);
return [$node0, $node3, $node4];
}
}"
`);
});
it('should transform children props', () => {
expect(
transform(`
function App({ children}) {
return <h1>{children}</h1>
}
`)
).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 App extends View {
get children() {
return this._$children;
}
Body() {
let $node0, $node1;
$node0 = $$createElement("h1");
$node1 = new $$ExpNode(this.children, []);
$$insertNode($node0, $node1, 0);
$node0._$nodes = [$node1];
return [$node0];
}
}"
`);
});
it('should transform component composition', () => {
expect(
transform(`
function ArrayModification({name}) {
let arr = 1
return <section>
<div>{arr}</div>
</section>
}
function MyComp() {
return <>
<ArrayModification name="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 "@dlightjs/dlight";
class ArrayModification extends View {
$p$name;
name;
arr = 1;
$$arr = 2;
Body() {
let $node0, $node1, $node2;
this._$update = $changed => {
if ($changed & 2) {
$node2 && $node2.update(() => this.arr, [this.arr]);
}
};
$node0 = ArrayModification.$t0.cloneNode(true);
$node1 = $node0.firstChild;
$node2 = new $$ExpNode(this.arr, [this.arr]);
$$insertNode($node1, $node2, 0);
return [$node0];
}
static $t0 = (() => {
let $node0, $node1;
$node0 = $$createElement("section");
$node1 = $$createElement("div");
$node0.appendChild($node1);
return $node0;
})();
}
class MyComp extends View {
Body() {
let $node0;
$node0 = new ArrayModification();
$node0._$init([["name", "1", []]], null, null, null);
return [$node0];
}
}"
`);
});
});

View File

@ -0,0 +1,79 @@
/*
* 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 { ComponentNode, InulaNode } from '../src/analyze/types';
import babel, { type PluginObj, transform as transformWithBabel } from '@babel/core';
import syntaxJSX from '@babel/plugin-syntax-jsx';
import { analyze } from '../src/analyze';
import generate from '@babel/generator';
import * as t from '@babel/types';
export function mockAnalyze(code: string): ComponentNode {
let root: ComponentNode | null = null;
transformWithBabel(code, {
plugins: [
syntaxJSX.default ?? syntaxJSX,
function (api: typeof babel): PluginObj {
const { types } = api;
return {
visitor: {
FunctionDeclaration: {
enter: path => {
root = analyze(path);
},
},
},
};
},
],
filename: 'test.tsx',
});
if (!root) {
throw new Error('root is null');
}
return root;
}
export function genCode(ast: t.Node | null) {
if (!ast) {
throw new Error('ast is null');
}
return generate(ast).code;
}
export function printTree(node: InulaNode | undefined): any {
if (!node) {
return 'empty';
}
if (node.type === 'cond') {
return {
type: node.type,
branch: node.branches.map(b => printTree(b.content)),
children: printTree(node.child),
};
} else if (node.type === 'comp') {
return {
type: node.type,
children: printTree(node.child),
};
} else if (node.type === 'jsx') {
return {
type: node.type,
};
}
return 'unknown';
}

View File

@ -0,0 +1,24 @@
/*
* 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 plugin from '../dist';
import { transform as transformWithBabel } from '@babel/core';
export function transform(code: string) {
return transformWithBabel(code, {
presets: [plugin],
filename: 'test.tsx',
})?.code;
}

View File

@ -0,0 +1,63 @@
/*
* 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 { genCode, mockAnalyze } from './mock';
import generate from '@babel/generator';
describe('propertiesAnalyze', () => {
describe('state', () => {
it('should work with jsx slice', () => {
const root = mockAnalyze(`
function App() {
const a = <div></div>
}
`);
expect(root.state[0].name).toBe('a');
expect(genCode(root.state[0].value)).toMatchInlineSnapshot(`"<$$div-Sub0 />"`);
});
it('should work with jsx slice in ternary operator', () => {
const root = mockAnalyze(`
function App() {
const a = true ? <div></div> : <h1></h1>
}
`);
expect(root.state[0].name).toBe('a');
expect(root.subComponents[0].name).toBe('$$div-Sub0');
expect(genCode(root.subComponents[0].child)).toMatchInlineSnapshot(`"<div></div>"`);
expect(root.subComponents[1].name).toBe('$$h1-Sub1');
expect(genCode(root.subComponents[1].child)).toMatchInlineSnapshot(`"<h1></h1>"`);
expect(genCode(root.state[0].value)).toMatchInlineSnapshot(`"true ? <$$div-Sub0 /> : <$$h1-Sub1 />"`);
});
it('should work with jsx slice in arr', () => {
const root = mockAnalyze(`
function App() {
const arr = [<div></div>,<h1></h1>]
}
`);
expect(root.state[0].name).toBe('a');
expect(root.subComponents[0].name).toBe('$$div-Sub0');
expect(genCode(root.subComponents[0].child)).toMatchInlineSnapshot(`"<div></div>"`);
expect(root.subComponents[1].name).toBe('$$h1-Sub1');
expect(genCode(root.subComponents[1].child)).toMatchInlineSnapshot(`"<h1></h1>"`);
expect(genCode(root.state[0].value)).toMatchInlineSnapshot(`"true ? <$$div-Sub0 /> : <$$h1-Sub1 />"`);
});
});
});

View File

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"lib": ["ESNext", "DOM"],
"moduleResolution": "Node",
"strict": true,
"esModuleInterop": true
},
"ts-node": {
"esm": true
}
}

View File

@ -19,7 +19,7 @@
"typings": "dist/index.d.ts",
"scripts": {
"build": "tsup --sourcemap",
"test": "vitest --ui"
"test": "vitest"
},
"devDependencies": {
"@types/babel__core": "^7.20.5",
@ -28,14 +28,22 @@
"typescript": "^5.3.2"
},
"dependencies": {
"@babel/plugin-syntax-decorators": "^7.23.3",
"@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:*",
"@openinula/class-transformer": "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"

View File

@ -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<t.ReturnStatement>, context: AnalyzeContext) {
const currentComp = context.currentComponent;
const argument = path.get('argument');
if (argument.isJSXElement()) {
currentComp.child = createJSXNode(currentComp, argument);
}
},
IfStatement(ifStmt: NodePath<t.IfStatement>, context: AnalyzeContext) {
if (!hasEarlyReturn(ifStmt)) {
return;
}
const currentComp = context.currentComponent;
const branches: Branch[] = [];
let next: NodePath<t.Statement> | 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<t.Statement | null | undefined> = 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<t.Statement>) {
return next.isBlockStatement() ? next.get('body') : [next];
}
function hasEarlyReturn(path: NodePath<t.Node>) {
let hasReturn = false;
path.traverse({
ReturnStatement(path: NodePath<t.ReturnStatement>) {
if (
path.parentPath.isFunctionDeclaration() ||
path.parentPath.isFunctionExpression() ||
path.parentPath.isArrowFunctionExpression()
) {
return;
}
hasReturn = true;
},
});
return hasReturn;
}

View File

@ -0,0 +1,86 @@
import { NodePath, type types as t } 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';
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<t.Statement>[], componentNode: ComponentNode, level = 0) {
const visitor = mergeVisitor(...analyzers);
const visit = (p: NodePath<t.Statement>, 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<t.Statement>, 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(path: NodePath<t.FunctionDeclaration>, customAnalyzers?: Analyzer[]) {
const node = path.node;
if (!isValidComponent(node)) {
return null;
}
if (customAnalyzers) {
analyzers = customAnalyzers;
}
const fnName = node.id.name;
const root = createComponentNode(fnName, path.get('body').get('body'));
return root;
}

View File

@ -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<t.JSXElement>, 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<t.JSXElement | t.JSXFragment>) {
// 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 = <div />`
* 2. SubComponent, like `function Sub() { return <div /> }`
*
* i.e.
* ```jsx
* let jsxSlice = <div>{count}</div>
* // =>
* function Comp_$id$() {
* return <div>{count}</div>
* }
* let jsxSlice = <Comp_$id$/>
* ```
*/
export function jsxSlicesAnalyze(): Visitor {
return {
JSXElement(path: NodePath<t.JSXElement>, ctx) {
const name = genNameFromJSX(path, ctx);
replaceJSXSliceWithSubComp(name, ctx, path);
path.skip();
},
JSXFragment(path: NodePath<t.JSXFragment>, ctx) {
replaceJSXSliceWithSubComp('frag', ctx, path);
},
};
}

View File

@ -0,0 +1,74 @@
/*
* 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, SubCompNode } from './types';
import { iterateFCBody } from './index';
export function createComponentNode(
name: string,
fnBody: NodePath<t.Statement>[],
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<t.FunctionDeclaration>) {
comp.methods.push(method);
}
export function createJSXNode(parent: ComponentNode, content: NodePath<JSX>): 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,
};
}

View File

@ -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 = <div />`
// 2. SubComponent, like `function Sub() { return <div /> }`
function handleFn(fnName: string, fnBody: NodePath<t.BlockStatement>) {
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 <div>{count}</div>
// }
// =>
// function jsxFunc() {
// function Comp_$id4$() {
// return <div>{count}</div>
// }
// // This is a function that returns JSX
// // because the function name is smallCamelCased
// return <Comp_$id4$/>
// }
}
}
// 3. jsx creation function, like `function create() { return <div /> }`
export function propertiesAnalyze(): Visitor {
return {
VariableDeclaration(path: NodePath<t.VariableDeclaration>, 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 = () => <div />`
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<t.BlockStatement>);
}
// handle jsx slice
ctx.traverse(path, ctx);
}
addState(ctx.currentComponent, id.node.name, declaration.node.init || null);
}
});
},
FunctionDeclaration(path: NodePath<t.FunctionDeclaration>, 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'));
},
};
}

View File

@ -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, types as t } from '@babel/core';
import { Node } from '@babel/traverse';
// --- Node shape ---
export type InulaNode = ComponentNode | CondNode | JSXNode;
export type JSX = t.JSXElement | t.JSXFragment;
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<string, defaultVal>;
// A valuable could be a state or computed
valuable: Reactive;
methods: NodePath<t.FunctionDeclaration>[];
child?: InulaNode;
subComponents: ComponentNode[];
parent?: ComponentNode;
/**
* The function body of the fn component code
*/
// fnBody: NodePath<t.Statement>[];
// a map to find the state
reactiveMap: Record<string, Bitmap>;
level: number;
}
export interface SubCompNode {
type: 'subComp';
name: string;
parent: ComponentNode;
child: JSX;
}
export interface JSXNode {
type: 'jsx';
parent: ComponentNode;
child: NodePath<JSX>;
}
export interface CondNode {
type: 'cond';
branches: Branch[];
parent: ComponentNode;
/**
* The default branch
*/
child: InulaNode;
}
export interface Branch {
conditions: NodePath<t.Expression>[];
content: InulaNode;
}
export interface AnalyzeContext {
level: number;
index: number;
currentComponent: ComponentNode;
restStmt: NodePath<t.Statement>[];
// --- flow control ---
/**
* ignore the rest of the statements
*/
skipRest: () => void;
traverse: (p: NodePath<t.Statement>, ctx: AnalyzeContext) => void;
}
export type Visitor<S = AnalyzeContext> = {
[Type in Node['type']]?: (path: NodePath<Extract<Node, { type: Type }>>, state: S) => void;
};
export type Analyzer = () => Visitor;
export interface FnComponentDeclaration extends t.FunctionDeclaration {
id: t.Identifier;
}

View File

@ -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<T>(path: NodePath<T>): path is NodePath<Exclude<T, undefined | null>> {
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<t.Node>) {
if (path.isJSXElement()) {
return true;
}
// check if there is JSXElement in the children
let seen = false;
path.traverse({
JSXElement() {
seen = true;
},
});
return seen;
}

View File

@ -5,6 +5,7 @@ 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 {
@ -19,3 +20,18 @@ export default function (_: ConfigAPI, options: DLightOption): TransformOptions
}
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;
}

View File

@ -0,0 +1,46 @@
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';
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),
},
FunctionDeclaration: {
enter: path => {
analyze(path);
},
exit: pluginProvider.classExit.bind(pluginProvider),
},
ClassMethod: pluginProvider.classMethodVisitor.bind(pluginProvider),
ClassProperty: pluginProvider.classPropertyVisitor.bind(pluginProvider),
},
};
}

View File

@ -27,6 +27,45 @@ describe('condition', () => {
</div>;
}
`)
).toMatchInlineSnapshot();
).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];
}
}"
`);
});
});

View File

@ -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 <div>1</div>
}
return <div>
<if cond={count > 1}>{count} is bigger than is 1</if>
<else>{count} is smaller than 1</else>
</div>;
}
`);
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 <div>1</div>
}
if (count > 2) {
return <div>2</div>
}
return <div></div>;
}
`);
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 <div>2</div>
}
return <div>1</div>
}
return <div></div>;
}
`);
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);
});
});

View File

@ -0,0 +1,79 @@
/*
* 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 { ComponentNode, InulaNode } from '../src/analyze/types';
import babel, { type PluginObj, transform as transformWithBabel } from '@babel/core';
import syntaxJSX from '@babel/plugin-syntax-jsx';
import { analyze } from '../src/analyze';
import generate from '@babel/generator';
import * as t from '@babel/types';
export function mockAnalyze(code: string): ComponentNode {
let root: ComponentNode | null = null;
transformWithBabel(code, {
plugins: [
syntaxJSX.default ?? syntaxJSX,
function (api: typeof babel): PluginObj {
const { types } = api;
return {
visitor: {
FunctionDeclaration: {
enter: path => {
root = analyze(path);
},
},
},
};
},
],
filename: 'test.tsx',
});
if (!root) {
throw new Error('root is null');
}
return root;
}
export function genCode(ast: t.Node | null) {
if (!ast) {
throw new Error('ast is null');
}
return generate(ast).code;
}
export function printTree(node: InulaNode | undefined): any {
if (!node) {
return 'empty';
}
if (node.type === 'cond') {
return {
type: node.type,
branch: node.branches.map(b => printTree(b.content)),
children: printTree(node.child),
};
} else if (node.type === 'comp') {
return {
type: node.type,
children: printTree(node.child),
};
} else if (node.type === 'jsx') {
return {
type: node.type,
};
}
return 'unknown';
}

View File

@ -0,0 +1,63 @@
/*
* 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 { genCode, mockAnalyze } from './mock';
import generate from '@babel/generator';
describe('propertiesAnalyze', () => {
describe('state', () => {
it('should work with jsx slice', () => {
const root = mockAnalyze(`
function App() {
const a = <div></div>
}
`);
expect(root.state[0].name).toBe('a');
expect(genCode(root.state[0].value)).toMatchInlineSnapshot(`"<$$div-Sub0 />"`);
});
it('should work with jsx slice in ternary operator', () => {
const root = mockAnalyze(`
function App() {
const a = true ? <div></div> : <h1></h1>
}
`);
expect(root.state[0].name).toBe('a');
expect(root.subComponents[0].name).toBe('$$div-Sub0');
expect(genCode(root.subComponents[0].child)).toMatchInlineSnapshot(`"<div></div>"`);
expect(root.subComponents[1].name).toBe('$$h1-Sub1');
expect(genCode(root.subComponents[1].child)).toMatchInlineSnapshot(`"<h1></h1>"`);
expect(genCode(root.state[0].value)).toMatchInlineSnapshot(`"true ? <$$div-Sub0 /> : <$$h1-Sub1 />"`);
});
it('should work with jsx slice in arr', () => {
const root = mockAnalyze(`
function App() {
const arr = [<div></div>,<h1></h1>]
}
`);
expect(root.state[0].name).toBe('a');
expect(root.subComponents[0].name).toBe('$$div-Sub0');
expect(genCode(root.subComponents[0].child)).toMatchInlineSnapshot(`"<div></div>"`);
expect(root.subComponents[1].name).toBe('$$h1-Sub1');
expect(genCode(root.subComponents[1].child)).toMatchInlineSnapshot(`"<h1></h1>"`);
expect(genCode(root.state[0].value)).toMatchInlineSnapshot(`"true ? <$$div-Sub0 /> : <$$h1-Sub1 />"`);
});
});
});

View File

@ -13,7 +13,7 @@
"dist"
],
"type": "module",
"main": "dist/index.cjs",
"main": "src/index.ts",
"module": "dist/index.js",
"typings": "dist/index.d.ts",
"scripts": {