chore: resolve conflict
This commit is contained in:
commit
7b6713bc6c
|
@ -5,3 +5,4 @@ package-lock.json
|
|||
pnpm-lock.yaml
|
||||
/packages/**/node_modules
|
||||
dist
|
||||
.history
|
||||
|
|
|
@ -41,16 +41,30 @@ describe('test no-vdom', () => {
|
|||
const CountingComponent = () => {
|
||||
const count = reactive(0);
|
||||
g_count = count;
|
||||
let View
|
||||
|
||||
return (() => {
|
||||
const _el$ = _tmpl$(),
|
||||
watch: if (count > 0) {
|
||||
View.$viewValue = createView(() => {})
|
||||
}
|
||||
|
||||
View = createView((() => {
|
||||
const _el$ = tmp.cloneNode(true),
|
||||
_el$2 = _el$.firstChild,
|
||||
_el$4 = _el$2.nextSibling,
|
||||
_el$3 = _el$4.nextSibling;
|
||||
_$insert(_el$, count, _el$4);
|
||||
return _el$;
|
||||
})();
|
||||
_$insert(_el$, count, _el$4);
|
||||
return _el$;
|
||||
})());
|
||||
|
||||
return View
|
||||
};
|
||||
function createView(el) {
|
||||
Object.defineProperty(el, '$viewValue', {
|
||||
set: value => {
|
||||
el.replaceWith(value);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
render(() => _$createComponent(CountingComponent, {}), container);
|
||||
|
||||
|
|
|
@ -0,0 +1,49 @@
|
|||
{
|
||||
"name": "babel-preset-inula-jsx",
|
||||
"version": "0.0.0",
|
||||
"description": "Inula jsx transpiler babel preset",
|
||||
"author": {
|
||||
"name": "IanDx",
|
||||
"email": "iandxssxx@gmail.com"
|
||||
},
|
||||
"keywords": [
|
||||
"inula",
|
||||
"babel-preset"
|
||||
],
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "dist/index.cjs",
|
||||
"module": "dist/index.js",
|
||||
"typings": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsup --sourcemap",
|
||||
"test": "vitest --ui"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"@types/node": "^20.10.5",
|
||||
"@vitest/ui": "^1.2.1",
|
||||
"tsup": "^6.7.0",
|
||||
"typescript": "^5.3.2",
|
||||
"vitest": "^1.2.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/plugin-syntax-jsx": "7.16.7",
|
||||
"babel-plugin-syntax-typescript-new": "^1.0.0",
|
||||
"minimatch": "^9.0.3"
|
||||
},
|
||||
"tsup": {
|
||||
"entry": [
|
||||
"src/index.ts"
|
||||
],
|
||||
"format": [
|
||||
"cjs",
|
||||
"esm"
|
||||
],
|
||||
"clean": true,
|
||||
"dts": true,
|
||||
"minify": true
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
declare module "@babel/plugin-syntax-jsx"
|
|
@ -0,0 +1,21 @@
|
|||
import syntaxTypescript from 'babel-plugin-syntax-typescript-new';
|
||||
import syntaxJSX from '@babel/plugin-syntax-jsx';
|
||||
import { InulaOption } from './types';
|
||||
import type { ConfigAPI, TransformOptions } from '@babel/core';
|
||||
import inula from './plugin';
|
||||
|
||||
|
||||
export default function (
|
||||
_: ConfigAPI,
|
||||
options: InulaOption
|
||||
): TransformOptions {
|
||||
return {
|
||||
plugins: [
|
||||
[syntaxJSX.default ?? syntaxJSX],
|
||||
[syntaxTypescript, {isJsx: true}],
|
||||
[inula, options],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export type { InulaOption };
|
|
@ -0,0 +1,22 @@
|
|||
import { PluginObj } from '@babel/core';
|
||||
import { InulaOption } from './types';
|
||||
import * as babel from '@babel/core';
|
||||
import { PluginProvider } from './pluginProvider';
|
||||
|
||||
export default function (api: typeof babel, options: InulaOption): PluginObj {
|
||||
const pluginProvider = new PluginProvider(api, options);
|
||||
|
||||
return {
|
||||
name: 'babel-plugin-inula-jsx',
|
||||
visitor: {
|
||||
Program: {
|
||||
enter(path) {
|
||||
console.log('babel-plugin-inula-jsx: Program enter');
|
||||
},
|
||||
exit(path) {
|
||||
console.log('babel-plugin-inula-jsx: Program exit');
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
|
@ -0,0 +1,106 @@
|
|||
import type { types as t, NodePath } from '@babel/core';
|
||||
import type babel from '@babel/core';
|
||||
import { minimatch } from 'minimatch';
|
||||
import { InulaOption } from './types';
|
||||
|
||||
|
||||
|
||||
export class PluginProvider {
|
||||
private static readonly inulaPackageName = 'inula';
|
||||
// ---- Plugin Level ----
|
||||
private readonly babelApi: typeof babel
|
||||
private readonly t: typeof t
|
||||
private readonly traverse: typeof babel.traverse
|
||||
private readonly includes: string[]
|
||||
private readonly excludes: string[]
|
||||
|
||||
constructor(babelApi: typeof babel, options: InulaOption) {
|
||||
this.babelApi = babelApi;
|
||||
this.t = babelApi.types;
|
||||
this.traverse = babelApi.traverse;
|
||||
const includes = options.files ?? ['**/*.{jsx,tsx}'];
|
||||
const excludes = options.excludeFiles ?? ['**/{dist,node_modules,lib}'];
|
||||
this.includes = Array.isArray(includes) ? includes : [includes];
|
||||
this.excludes = Array.isArray(excludes) ? excludes : [excludes];
|
||||
}
|
||||
|
||||
// ---- Two levels of enter:
|
||||
// 1. File Level: controlled by includes/excludes
|
||||
// 2. File Level: controlled by imports(must import inula)
|
||||
private fileEnter = true
|
||||
|
||||
// ---- File Level
|
||||
private programNode?: t.Program
|
||||
private allImports: t.ImportDeclaration[] = []
|
||||
private didAlterImports = false
|
||||
private transformedCount = 0
|
||||
|
||||
// ---- Component Level ----
|
||||
|
||||
|
||||
programEnterVisitor(
|
||||
path: NodePath<t.Program>,
|
||||
filename: string | undefined
|
||||
): void {
|
||||
this.fileEnter = this.fileAllowed(filename);
|
||||
if (!this.fileEnter) return;
|
||||
this.allImports = path.node.body.filter(n =>
|
||||
this.t.isImportDeclaration(n)
|
||||
) as t.ImportDeclaration[];
|
||||
const inulaImports = this.allImports.filter(
|
||||
n => n.source.value === PluginProvider.inulaPackageName
|
||||
);
|
||||
// ---- @Enter2
|
||||
if (inulaImports.length === 0) {
|
||||
this.fileEnter = false;
|
||||
return;
|
||||
}
|
||||
this.programNode = path.node;
|
||||
this.transformedCount = 0;
|
||||
}
|
||||
|
||||
programExitVisitor(path: NodePath<t.Program>): void {
|
||||
if (!this.fileEnter) return;
|
||||
|
||||
}
|
||||
|
||||
|
||||
jsxElementVisitor(path: NodePath<t.JSXElement>): void {
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @brief Test if the file is allowed to be transformed
|
||||
* @enter1
|
||||
* @param path
|
||||
* @param fileName
|
||||
* @returns is file allowed
|
||||
*/
|
||||
private fileAllowed(fileName: string | undefined): boolean {
|
||||
if (this.includes.includes('*')) return true;
|
||||
// ---- Filename is not provided for web-based standalone transform
|
||||
if (!fileName) return true;
|
||||
if (this.excludes.some(pattern => minimatch(fileName, pattern)))
|
||||
return false;
|
||||
if (!this.includes.some(pattern => minimatch(fileName, pattern)))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Wrap the value in a file
|
||||
* @param node
|
||||
* @returns wrapped value
|
||||
*/
|
||||
private wrapWithFile(node: t.Expression | t.Statement): t.File {
|
||||
return this.t.file(
|
||||
this.t.program([
|
||||
this.t.isStatement(node) ? node : this.t.expressionStatement(node),
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { transformInula } from './mock';
|
||||
|
||||
describe('Entering', () => {
|
||||
it('should use inula jsx preset in babel', () => {
|
||||
const code = 'console.log(\'hello world\');';
|
||||
expect(transformInula(code)).toBe(code);
|
||||
});
|
||||
|
||||
});
|
|
@ -0,0 +1,8 @@
|
|||
import babel, { transform, types as t } from '@babel/core';
|
||||
import inula from '../';
|
||||
|
||||
export function transformInula(code: string) {
|
||||
return transform(code, {
|
||||
presets: [inula]
|
||||
})?.code;
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
|
||||
export interface InulaOption {
|
||||
/**
|
||||
* Files that will be included
|
||||
* @default ** /*.{jsx,tsx}
|
||||
*/
|
||||
files?: string | string[]
|
||||
/**
|
||||
* Files that will be excludes
|
||||
* @default ** /{dist,node_modules,lib}/*.{js,ts}
|
||||
*/
|
||||
excludeFiles?: string | string[]
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"moduleResolution": "Node",
|
||||
"strict": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"ts-node": {
|
||||
"esm": true
|
||||
}
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
{
|
||||
"name": "@inula/view-parser",
|
||||
"version": "0.0.0",
|
||||
"description": "Inula jsx parser",
|
||||
"author": {
|
||||
"name": "IanDx",
|
||||
"email": "iandxssxx@gmail.com"
|
||||
},
|
||||
"keywords": [
|
||||
"inula"
|
||||
],
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "dist/index.cjs",
|
||||
"module": "dist/index.js",
|
||||
"typings": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsup --sourcemap",
|
||||
"test": "vitest --ui"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.20.12",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"@vitest/ui": "^0.34.5",
|
||||
"tsup": "^6.7.0",
|
||||
"typescript": "^5.3.2",
|
||||
"@babel/plugin-syntax-jsx": "7.16.7",
|
||||
"vitest": "^0.34.5"
|
||||
},
|
||||
"tsup": {
|
||||
"entry": [
|
||||
"src/index.ts"
|
||||
],
|
||||
"format": [
|
||||
"cjs",
|
||||
"esm"
|
||||
],
|
||||
"clean": true,
|
||||
"dts": true,
|
||||
"minify": true
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
import { ViewParser } from './parser';
|
||||
import type { ViewUnit, ViewParserConfig, AllowedJSXNode } from './types';
|
||||
|
||||
/**
|
||||
* @brief Generate view units from a babel ast
|
||||
* @param statement
|
||||
* @param config
|
||||
* @returns ViewUnit[]
|
||||
*/
|
||||
export function parseView(
|
||||
node: AllowedJSXNode,
|
||||
config: ViewParserConfig
|
||||
): ViewUnit[] {
|
||||
return new ViewParser(config).parse(node);
|
||||
}
|
||||
|
||||
export type * from './types';
|
|
@ -0,0 +1,512 @@
|
|||
import type { NodePath, types as t, traverse as tr } from '@babel/core';
|
||||
import type {
|
||||
UnitProp,
|
||||
ViewUnit,
|
||||
ViewParserConfig,
|
||||
AllowedJSXNode,
|
||||
HTMLUnit,
|
||||
TextUnit,
|
||||
MutableUnit,
|
||||
TemplateProp,
|
||||
} from './types';
|
||||
|
||||
export class ViewParser {
|
||||
// ---- Namespace and tag name
|
||||
private readonly htmlNamespace: string = 'html'
|
||||
private readonly htmlTagNamespace: string = 'tag'
|
||||
private readonly compTagNamespace: string = 'comp'
|
||||
private readonly envTagName: string = 'env'
|
||||
private readonly ifTagName: string = 'if'
|
||||
private readonly elseIfTagName: string = 'else-if'
|
||||
private readonly elseTagName: string = 'else'
|
||||
private readonly customHTMLProps: string[] = ['ref']
|
||||
|
||||
private readonly config: ViewParserConfig
|
||||
private readonly htmlTags: string[]
|
||||
private readonly willParseTemplate: boolean
|
||||
|
||||
private readonly t: typeof t
|
||||
private readonly traverse: typeof tr
|
||||
|
||||
private readonly viewUnits: ViewUnit[] = []
|
||||
|
||||
/**
|
||||
* @brief Constructor
|
||||
* @param config
|
||||
*/
|
||||
constructor(config: ViewParserConfig) {
|
||||
this.config = config;
|
||||
this.t = config.babelApi.types;
|
||||
this.traverse = config.babelApi.traverse;
|
||||
this.htmlTags = config.htmlTags;
|
||||
this.willParseTemplate = config.parseTemplate ?? true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Parse the node into view units
|
||||
* @param node
|
||||
* @returns ViewUnit[]
|
||||
*/
|
||||
parse(node: AllowedJSXNode): ViewUnit[] {
|
||||
if (this.t.isJSXText(node)) this.parseText(node);
|
||||
else if (this.t.isJSXExpressionContainer(node)) this.parseExpression(node.expression);
|
||||
else if (this.t.isJSXElement(node)) this.parseElement(node);
|
||||
else if (this.t.isJSXFragment(node)) {
|
||||
node.children.forEach(child => {
|
||||
this.parse(child);
|
||||
});
|
||||
}
|
||||
|
||||
return this.viewUnits;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Parse JSXText
|
||||
* @param node
|
||||
*/
|
||||
private parseText(node: t.JSXText): void {
|
||||
const text = node.value.trim();
|
||||
if (!text) return;
|
||||
this.viewUnits.push({
|
||||
type: 'text',
|
||||
content: this.t.stringLiteral(text),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Parse JSXExpressionContainer
|
||||
* @param node
|
||||
*/
|
||||
private parseExpression(node: t.Expression | t.JSXEmptyExpression): void {
|
||||
if (this.t.isJSXEmptyExpression(node)) return;
|
||||
if (this.t.isLiteral(node) && !this.t.isTemplateLiteral(node)) {
|
||||
// ---- Treat literal as text except template literal
|
||||
// Cuz template literal may have viewProp inside like:
|
||||
// <>{i18n`hello ${<MyView/>}`}</>
|
||||
this.viewUnits.push({
|
||||
type: 'text',
|
||||
content: node
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.viewUnits.push({
|
||||
type: 'exp',
|
||||
content: this.parseProp(node),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Parse JSXElement
|
||||
* @param node
|
||||
*/
|
||||
private parseElement(node: t.JSXElement): void {
|
||||
let type: 'html' | 'comp';
|
||||
let tag: t.Expression;
|
||||
|
||||
// ---- Parse tag and type
|
||||
const openingName = node.openingElement.name;
|
||||
if (this.t.isJSXIdentifier(openingName)) {
|
||||
// ---- Opening name is a JSXIdentifier, e.g., <div>
|
||||
const name = openingName.name;
|
||||
// ---- Specially parse if and env
|
||||
if ([this.ifTagName, this.elseIfTagName, this.elseTagName].includes(name))
|
||||
return this.parseIf(node);
|
||||
if (name === this.envTagName) return this.parseEnv(node);
|
||||
else if (this.htmlTags.includes(name)) {
|
||||
type = 'html';
|
||||
tag = this.t.stringLiteral(name);
|
||||
} else {
|
||||
// ---- If the name is not in htmlTags, treat it as a comp
|
||||
type = 'comp';
|
||||
tag = this.t.identifier(name);
|
||||
}
|
||||
} else if (this.t.isJSXMemberExpression(openingName)) {
|
||||
// ---- Opening name is a JSXMemberExpression, e.g., <Comp.Div>
|
||||
// Treat it as a comp and set the tag as the opening name
|
||||
type = 'comp';
|
||||
// ---- Turn JSXMemberExpression into MemberExpression recursively
|
||||
const toMemberExpression = (node: t.JSXMemberExpression): t.MemberExpression => {
|
||||
if (this.t.isJSXMemberExpression(node.object)) {
|
||||
return this.t.memberExpression(
|
||||
toMemberExpression(node.object),
|
||||
this.t.identifier(node.property.name)
|
||||
);
|
||||
}
|
||||
return this.t.memberExpression(
|
||||
this.t.identifier(node.object.name),
|
||||
this.t.identifier(node.property.name)
|
||||
);
|
||||
};
|
||||
tag = toMemberExpression(openingName);
|
||||
} else {
|
||||
// ---- isJSXNamespacedName
|
||||
const namespace = openingName.namespace.name;
|
||||
switch (namespace) {
|
||||
case this.compTagNamespace:
|
||||
// ---- If the namespace is the same as the compTagNamespace, treat it as a comp
|
||||
// and set the tag as an identifier
|
||||
// e.g., <comp:div/> => ["comp", div]
|
||||
// this means you've declared a component named "div" and force it to be a comp instead an html
|
||||
type = 'comp';
|
||||
tag = this.t.identifier(openingName.name.name);
|
||||
break;
|
||||
case this.htmlNamespace:
|
||||
// ---- If the namespace is the same as the htmlTagNamespace, treat it as an html
|
||||
// and set the tag as a string literal
|
||||
// e.g., <html:MyWebComponent/> => ["html", "MyWebComponent"]
|
||||
// the tag will be treated as a string, i.e., <MyWebComponent/>
|
||||
type = 'html';
|
||||
tag = this.t.stringLiteral(openingName.name.name);
|
||||
break;
|
||||
case this.htmlTagNamespace:
|
||||
// ---- If the namespace is the same as the htmlTagNamespace, treat it as an html
|
||||
// and set the tag as an identifier
|
||||
// e.g., <tag:variable/> => ["html", variable]
|
||||
// this unit will be htmlUnit and the html string tag is stored in "variable"
|
||||
type = 'html';
|
||||
tag = this.t.identifier(openingName.name.name);
|
||||
break;
|
||||
default:
|
||||
// ---- Otherwise, treat it as an html tag and make the tag as the namespace:name
|
||||
type = 'html';
|
||||
tag = this.t.stringLiteral(`${namespace}:${openingName.name.name}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Parse the props
|
||||
const props = node.openingElement.attributes;
|
||||
const propMap: Record<string, UnitProp> = Object.fromEntries(
|
||||
props.map(prop => this.parseJSXProp(prop))
|
||||
);
|
||||
|
||||
// ---- Parse the children
|
||||
const childUnits = node.children.map(child => this.parseView(child)).flat();
|
||||
|
||||
let unit: ViewUnit = { type, tag, props: propMap, children: childUnits };
|
||||
if (unit.type === 'html' && this.willParseTemplate)
|
||||
unit = this.transformTemplate(unit);
|
||||
|
||||
this.viewUnits.push(unit);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Parse EnvUnit
|
||||
* @param node
|
||||
*/
|
||||
private parseEnv(node: t.JSXElement): void {
|
||||
const props = node.openingElement.attributes;
|
||||
const propMap: Record<string, UnitProp> = Object.fromEntries(
|
||||
props.map(prop => this.parseJSXProp(prop))
|
||||
);
|
||||
const children = node.children.map(child => this.parseView(child)).flat();
|
||||
this.viewUnits.push({
|
||||
type: 'env',
|
||||
props: propMap,
|
||||
children,
|
||||
});
|
||||
}
|
||||
|
||||
private parseIf(node: t.JSXElement): void {
|
||||
const name = (node.openingElement.name as t.JSXIdentifier).name;
|
||||
// ---- else
|
||||
if (name === this.elseTagName) {
|
||||
const lastUnit = this.viewUnits[this.viewUnits.length - 1];
|
||||
if (!lastUnit || lastUnit.type !== 'if') throw new Error(`Missing if for ${name}`);
|
||||
lastUnit.branches.push({
|
||||
condition: this.t.booleanLiteral(true),
|
||||
children: node.children.map(child => this.parseView(child)).flat(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const condition = node.openingElement.attributes.filter(attr =>
|
||||
this.t.isJSXAttribute(attr) && attr.name.name === 'cond'
|
||||
)[0];
|
||||
if (!condition) throw new Error(`Missing condition for ${name}`);
|
||||
if (!this.t.isJSXAttribute(condition)) throw new Error(`JSXSpreadAttribute is not supported for ${name} condition`);
|
||||
if (!this.t.isJSXExpressionContainer(condition.value) || !this.t.isExpression(condition.value.expression))
|
||||
throw new Error(`Invalid condition for ${name}`);
|
||||
|
||||
// ---- if
|
||||
if (name === this.ifTagName) {
|
||||
this.viewUnits.push({
|
||||
type: 'if',
|
||||
branches: [{
|
||||
condition: condition.value.expression,
|
||||
children: node.children.map(child => this.parseView(child)).flat(),
|
||||
}],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- else-if
|
||||
const lastUnit = this.viewUnits[this.viewUnits.length - 1];
|
||||
if (!lastUnit || lastUnit.type !== 'if')
|
||||
throw new Error(`Missing if for ${name}`);
|
||||
|
||||
lastUnit.branches.push({
|
||||
condition: condition.value.expression,
|
||||
children: node.children.map(child => this.parseView(child)).flat(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Parse JSXAttribute or JSXSpreadAttribute into UnitProp,
|
||||
* considering both namespace and expression
|
||||
* @param prop
|
||||
* @returns [propName, propValue]
|
||||
*/
|
||||
private parseJSXProp(prop: t.JSXAttribute | t.JSXSpreadAttribute): [string, UnitProp] {
|
||||
if (this.t.isJSXAttribute(prop)) {
|
||||
let propName: string, specifier: string | undefined;
|
||||
if (this.t.isJSXNamespacedName(prop.name)) {
|
||||
// ---- If the prop name is a JSXNamespacedName, e.g., bind:value
|
||||
// give it a special tag
|
||||
propName = prop.name.name.name;
|
||||
specifier = prop.name.namespace.name;
|
||||
} else {
|
||||
propName = prop.name.name;
|
||||
}
|
||||
let value = this.t.isJSXExpressionContainer(prop.value) ? prop.value.expression : prop.value;
|
||||
if (this.t.isJSXEmptyExpression(value)) value = undefined;
|
||||
return [propName, this.parseProp(value, specifier)];
|
||||
}
|
||||
// ---- Use *spread* as the propName to avoid conflict with other props
|
||||
return ['*spread*', this.parseProp(prop.argument)];
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Parse the prop node into UnitProp
|
||||
* @param propNode
|
||||
* @param specifier
|
||||
* @returns UnitProp
|
||||
*/
|
||||
private parseProp(propNode: t.Expression | undefined | null, specifier?: string): UnitProp {
|
||||
// ---- If there is no propNode, set the default prop as true
|
||||
if (!propNode) {
|
||||
return {
|
||||
value: this.t.booleanLiteral(true),
|
||||
viewPropMap: {},
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Collect sub jsx nodes as Prop
|
||||
const viewPropMap: Record<string, ViewUnit[]> = {};
|
||||
const parseViewProp = (innerPath: NodePath<t.JSXElement|t.JSXFragment>): void => {
|
||||
const id = this.uid();
|
||||
const node = innerPath.node;
|
||||
viewPropMap[id] = this.parseView(node);
|
||||
const newNode = this.t.stringLiteral(id);
|
||||
if (node === propNode) {
|
||||
// ---- If the node is the propNode, replace it with the new node
|
||||
propNode = newNode;
|
||||
}
|
||||
// ---- Replace the node and skip the inner path
|
||||
innerPath.replaceWith(newNode);
|
||||
innerPath.skip();
|
||||
};
|
||||
|
||||
// ---- Apply the parseViewProp to JSXElement and JSXFragment
|
||||
this.traverse(this.wrapWithFile(propNode), {
|
||||
JSXElement: parseViewProp,
|
||||
JSXFragment: parseViewProp,
|
||||
});
|
||||
|
||||
return {
|
||||
value: propNode,
|
||||
viewPropMap,
|
||||
specifier,
|
||||
};
|
||||
}
|
||||
|
||||
private transformTemplate(unit: ViewUnit): ViewUnit {
|
||||
if (!this.isHTMLTemplate(unit)) return unit;
|
||||
unit = unit as HTMLUnit;
|
||||
return {
|
||||
type: 'template',
|
||||
template: this.generateTemplate(unit),
|
||||
mutableUnits: this.generateMutableUnits(unit),
|
||||
props: this.parseTemplateProps(unit),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Generate the entire HTMLUnit
|
||||
* @param unit
|
||||
* @returns HTMLUnit
|
||||
*/
|
||||
private generateTemplate(unit: HTMLUnit): HTMLUnit {
|
||||
const staticProps = Object.fromEntries(this.filterTemplateProps(
|
||||
// ---- Get all the static props
|
||||
Object.entries(unit.props ?? []).filter(
|
||||
([, prop]) =>
|
||||
this.isStaticProp(prop) &&
|
||||
// ---- Filter out props with false values
|
||||
!(this.t.isBooleanLiteral(prop.value) && !prop.value.value)
|
||||
)
|
||||
));
|
||||
|
||||
let children: (HTMLUnit | TextUnit)[] | undefined;
|
||||
if (unit.children) {
|
||||
children = unit.children
|
||||
.map(unit => {
|
||||
if (unit.type === 'text') return unit;
|
||||
if (unit.type === 'html' && this.t.isStringLiteral(unit.tag)) {
|
||||
return this.generateTemplate(unit);
|
||||
}
|
||||
})
|
||||
.filter(Boolean) as (HTMLUnit | TextUnit)[];
|
||||
}
|
||||
return {
|
||||
type: 'html',
|
||||
tag: unit.tag,
|
||||
props: staticProps,
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Collect all the mutable nodes in a static HTMLUnit
|
||||
* We use this function to collect mutable nodes' path and props,
|
||||
* so that in the generator, we know which position to insert the mutable nodes
|
||||
* @param htmlUnit
|
||||
* @returns mutable particles
|
||||
*/
|
||||
private generateMutableUnits(htmlUnit: HTMLUnit): MutableUnit[] {
|
||||
const mutableUnits: MutableUnit[] = [];
|
||||
const generateMutableUnit = (unit: HTMLUnit, path: number[] = []) => {
|
||||
// ---- Generate mutable unit for current HTMLUnit
|
||||
unit.children?.forEach((child, idx) => {
|
||||
if (
|
||||
!(child.type === 'html' && this.t.isStringLiteral(child.tag)) &&
|
||||
!(child.type === 'text')
|
||||
) {
|
||||
mutableUnits.push({
|
||||
path: [...path, idx],
|
||||
...this.transformTemplate(child),
|
||||
});
|
||||
}
|
||||
});
|
||||
// ---- Recursively generate mutable units for static HTMLUnit children
|
||||
unit.children
|
||||
?.filter(
|
||||
child => child.type === 'html' && this.t.isStringLiteral(child.tag)
|
||||
)
|
||||
.forEach((child, idx) => {
|
||||
generateMutableUnit(child as HTMLUnit, [...path, idx]);
|
||||
});
|
||||
};
|
||||
generateMutableUnit(htmlUnit);
|
||||
|
||||
return mutableUnits;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Collect all the props in a static HTMLUnit or its nested HTMLUnit children
|
||||
* Just like the mutable nodes, props are also equipped with path,
|
||||
* so that we know which HTML ChildNode to insert the props
|
||||
* @param htmlUnit
|
||||
* @returns props
|
||||
*/
|
||||
private parseTemplateProps(htmlUnit: HTMLUnit): TemplateProp[] {
|
||||
const templateProps: TemplateProp[] = [];
|
||||
const generateVariableProp = (unit: HTMLUnit, path: number[]) => {
|
||||
// ---- Generate all non-static(string/number/boolean) props for current HTMLUnit
|
||||
// to be inserted further in the generator
|
||||
unit.props && Object.entries(unit.props)
|
||||
.filter(([, prop]) => !this.isStaticProp(prop))
|
||||
.forEach(([key, prop]) => {
|
||||
templateProps.push({
|
||||
name: (unit.tag as t.StringLiteral).value,
|
||||
key,
|
||||
path,
|
||||
value: prop.value,
|
||||
});
|
||||
});
|
||||
// ---- Recursively generate props for static HTMLUnit children
|
||||
unit.children
|
||||
?.forEach((child, idx) => {
|
||||
if (child.type !== 'html') return;
|
||||
generateVariableProp(child, [...path, idx]);
|
||||
});
|
||||
};
|
||||
generateVariableProp(htmlUnit, []);
|
||||
|
||||
return templateProps;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Check if a ViewUnit is a static HTMLUnit that can be parsed into a template
|
||||
* Must satisfy:
|
||||
* 1. type is html
|
||||
* 2. tag is a string literal, i.e., non-dynamic tag
|
||||
* 3. has at least one child that is a static HTMLUnit,
|
||||
* or else just call a createElement function, no need for template clone
|
||||
* @param viewUnit
|
||||
* @returns is a static HTMLUnit
|
||||
*/
|
||||
private isHTMLTemplate(viewUnit: ViewUnit): boolean {
|
||||
return (
|
||||
viewUnit.type === 'html' &&
|
||||
this.t.isStringLiteral(viewUnit.tag) &&
|
||||
!!viewUnit.children?.some(
|
||||
child => child.type === 'html' && this.t.isStringLiteral(child.tag)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private isStaticProp(prop: UnitProp): boolean {
|
||||
return (
|
||||
this.t.isStringLiteral(prop.value) ||
|
||||
this.t.isNumericLiteral(prop.value) ||
|
||||
this.t.isBooleanLiteral(prop.value) ||
|
||||
this.t.isNullLiteral(prop.value)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Filter out some props that are not needed in the template,
|
||||
* these are all special props to be parsed differently in the generator
|
||||
* @param props
|
||||
* @returns filtered props
|
||||
*/
|
||||
private filterTemplateProps<T>(
|
||||
props: Array<[string, T]>
|
||||
): Array<[string, T]> {
|
||||
return (
|
||||
props
|
||||
// ---- Filter out event listeners
|
||||
.filter(([key]) => !key.startsWith('on'))
|
||||
// ---- Filter out specific props
|
||||
.filter(([key]) => !this.customHTMLProps.includes(key))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Parse the view by duplicating current parser's classRootPath, statements and htmlTags
|
||||
* @param statements
|
||||
* @returns ViewUnit[]
|
||||
*/
|
||||
private parseView(node: AllowedJSXNode): ViewUnit[] {
|
||||
return new ViewParser(this.config).parse(node);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @brief Wrap the value in a file
|
||||
* @param node
|
||||
* @returns wrapped value
|
||||
*/
|
||||
private wrapWithFile(node: t.Expression): t.File {
|
||||
return this.t.file(this.t.program([this.t.expressionStatement(node)]));
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Generate a unique id
|
||||
* @returns a unique id
|
||||
*/
|
||||
private uid(): string {
|
||||
return Math.random().toString(36).slice(2);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,205 @@
|
|||
import { describe, expect, it, afterAll, beforeAll } from 'vitest';
|
||||
import { config, parse, parseCode, parseView } from './mock';
|
||||
import { types as t } from '@babel/core';
|
||||
import type { CompUnit, HTMLUnit } from '../index';
|
||||
|
||||
describe('ElementUnit', () => {
|
||||
beforeAll(() => {
|
||||
// ---- Ignore template for this test
|
||||
config.parseTemplate = false;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
config.parseTemplate = true;
|
||||
});
|
||||
|
||||
// ---- Type
|
||||
it('should identify a JSX element with tag in htmlTags as an HTMLUnit', () => {
|
||||
const viewUnits = parse('<div></div>');
|
||||
expect(viewUnits.length).toBe(1);
|
||||
expect(viewUnits[0].type).toBe('html');
|
||||
});
|
||||
|
||||
it('should identify a JSX element with tag not in htmlTags as an CompUnit', () => {
|
||||
const viewUnits = parse('<Comp></Comp>');
|
||||
expect(viewUnits.length).toBe(1);
|
||||
expect(viewUnits[0].type).toBe('comp');
|
||||
});
|
||||
|
||||
it('should identify a JSX element with namespaced "html" outside htmlTags as an HTMLUnit', () => {
|
||||
const viewUnits = parse('<html:MyWebComponent></html:MyWebComponent>');
|
||||
expect(viewUnits.length).toBe(1);
|
||||
expect(viewUnits[0].type).toBe('html');
|
||||
});
|
||||
|
||||
it('should identify a JSX element with namespaced "tag" outside htmlTags as an HTMLUnit', () => {
|
||||
const viewUnits = parse('<tag:variable></tag:variable>');
|
||||
expect(viewUnits.length).toBe(1);
|
||||
expect(viewUnits[0].type).toBe('html');
|
||||
});
|
||||
|
||||
it('should identify a JSX element with namespaced "comp" inside htmlTags as an HTMLUnit', () => {
|
||||
const viewUnits = parse('<comp:div></comp:div>');
|
||||
expect(viewUnits.length).toBe(1);
|
||||
expect(viewUnits[0].type).toBe('comp');
|
||||
});
|
||||
|
||||
it('should identify a JSX element with name equal to "env" as an EnvUnit', () => {
|
||||
const viewUnits = parse('<env></env>');
|
||||
expect(viewUnits.length).toBe(1);
|
||||
expect(viewUnits[0].type).toBe('env');
|
||||
});
|
||||
|
||||
// ---- Tag
|
||||
it('should correctly parse the tag of an HTMLUnit', () => {
|
||||
const viewUnits = parse('<div></div>');
|
||||
const tag = (viewUnits[0] as HTMLUnit).tag;
|
||||
|
||||
expect(t.isStringLiteral(tag, { value: 'div' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should correctly parse the tag of an HTMLUnit with namespaced "html"', () => {
|
||||
const viewUnits = parse('<html:MyWebComponent></html:MyWebComponent>');
|
||||
const tag = (viewUnits[0] as HTMLUnit).tag;
|
||||
|
||||
expect(t.isStringLiteral(tag, { value: 'MyWebComponent' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should correctly parse the tag of an HTMLUnit with namespaced "tag"', () => {
|
||||
const viewUnits = parse('<tag:variable></tag:variable>');
|
||||
const tag = (viewUnits[0] as HTMLUnit).tag;
|
||||
|
||||
expect(t.isIdentifier(tag, { name: 'variable' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should correctly parse the tag of an CompUnit', () => {
|
||||
const viewUnits = parse('<Comp></Comp>');
|
||||
const tag = (viewUnits[0] as HTMLUnit).tag;
|
||||
|
||||
expect(t.isIdentifier(tag, { name: 'Comp' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should correctly parse the tag of an CompUnit with namespaced "comp"', () => {
|
||||
const viewUnits = parse('<comp:div></comp:div>');
|
||||
const tag = (viewUnits[0] as HTMLUnit).tag;
|
||||
|
||||
expect(t.isIdentifier(tag, { name: 'div' })).toBeTruthy();
|
||||
});
|
||||
|
||||
// ---- Props(for both HTMLUnit and CompUnit)
|
||||
it('should correctly parse the props', () => {
|
||||
const viewUnits = parse('<div id="myId"></div>');
|
||||
|
||||
const htmlUnit = viewUnits[0] as HTMLUnit;
|
||||
const props = htmlUnit.props!;
|
||||
expect(
|
||||
t.isStringLiteral(props.id.value, { value: 'myId' })
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should correctly parse the props with a complex expression', () => {
|
||||
const ast = parseCode('<div onClick={() => {console.log("ok")}}></div>');
|
||||
const viewUnits = parseView(ast);
|
||||
|
||||
const originalExpression = ((
|
||||
(ast as t.JSXElement).openingElement.attributes[0] as t.JSXAttribute
|
||||
).value as t.JSXExpressionContainer).expression;
|
||||
|
||||
const htmlUnit = viewUnits[0] as HTMLUnit;
|
||||
expect(htmlUnit.props!.onClick.value).toBe(originalExpression);
|
||||
});
|
||||
|
||||
it('should correctly parse multiple props', () => {
|
||||
const viewUnits = parse('<div id="myId" class="myClass"></div>');
|
||||
|
||||
const htmlUnit = viewUnits[0] as HTMLUnit;
|
||||
const props = htmlUnit.props!;
|
||||
expect(Object.keys(props).length).toBe(2);
|
||||
expect(
|
||||
t.isStringLiteral(props.id.value, { value: 'myId' })
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
t.isStringLiteral(props.class.value, { value: 'myClass' })
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should correctly parse props with namespace as its specifier', () => {
|
||||
const viewUnits = parse('<div bind:id="myId"></div>');
|
||||
const htmlUnit = viewUnits[0] as HTMLUnit;
|
||||
const props = htmlUnit.props!;
|
||||
expect(props.id.specifier).toBe('bind');
|
||||
expect(t.isStringLiteral(props.id.value, { value: 'myId' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should correctly parse spread props', () => {
|
||||
const viewUnits = parse('<Comp {...props}></Comp>');
|
||||
const htmlUnit = viewUnits[0] as CompUnit;
|
||||
const props = htmlUnit.props!;
|
||||
expect(t.isIdentifier(props['*spread*'].value, { name: 'props' })).toBeTruthy();
|
||||
});
|
||||
|
||||
// ---- View prop (other test cases can be found in ExpUnit.test.ts)
|
||||
it('should correctly parse sub jsx attribute as view prop', () => {
|
||||
const ast = parseCode('<Comp sub=<div>Ok</div>></Comp>');
|
||||
const viewUnits = parseView(ast);
|
||||
|
||||
const props = (viewUnits[0] as CompUnit).props!;
|
||||
const viewPropMap = props.sub.viewPropMap!;
|
||||
expect(Object.keys(viewPropMap).length).toBe(1);
|
||||
|
||||
const key = Object.keys(viewPropMap)[0];
|
||||
const viewProp = viewPropMap[key];
|
||||
expect(viewProp.length).toBe(1);
|
||||
expect(viewProp[0].type).toBe('html');
|
||||
|
||||
// ---- Prop View will be replaced with a random string and stored in props.viewPropMap
|
||||
const value = props.sub.value;
|
||||
expect(t.isStringLiteral(value, { value: key })).toBeTruthy();
|
||||
});
|
||||
|
||||
|
||||
// ---- Children(for both HTMLUnit and CompUnit)
|
||||
it('should correctly parse the count of children', () => {
|
||||
const viewUnits = parse(`<div>
|
||||
<div>ok</div>
|
||||
<div>ok</div>
|
||||
<Comp></Comp>
|
||||
<Comp></Comp>
|
||||
</div>`);
|
||||
const htmlUnit = viewUnits[0] as HTMLUnit;
|
||||
expect(htmlUnit.children!.length).toBe(4);
|
||||
});
|
||||
|
||||
it('should correctly parse the count of children with JSXExpressionContainer', () => {
|
||||
const viewUnits = parse(`<div>
|
||||
<div>ok</div>
|
||||
<div>ok</div>
|
||||
{count}
|
||||
{count}
|
||||
</div>`);
|
||||
const htmlUnit = viewUnits[0] as HTMLUnit;
|
||||
expect(htmlUnit.children!.length).toBe(4);
|
||||
});
|
||||
|
||||
it('should correctly parse the count of children with JSXFragment', () => {
|
||||
const viewUnits = parse(`<div>
|
||||
<div>ok</div>
|
||||
<div>ok</div>
|
||||
<>
|
||||
<Comp></Comp>
|
||||
<Comp></Comp>
|
||||
</>
|
||||
</div>`);
|
||||
const htmlUnit = viewUnits[0] as HTMLUnit;
|
||||
expect(htmlUnit.children!.length).toBe(4);
|
||||
});
|
||||
|
||||
it('should correctly parse the children', () => {
|
||||
const viewUnits = parse('<div><div>ok</div></div>');
|
||||
const htmlUnit = viewUnits[0] as HTMLUnit;
|
||||
const firstChild = htmlUnit.children![0];
|
||||
expect(firstChild.type).toBe('html');
|
||||
expect(t.isStringLiteral((firstChild as HTMLUnit).tag, { value: 'div' })).toBeTruthy();
|
||||
expect((firstChild as HTMLUnit).children![0].type).toBe('text');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,89 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { parse, parseCode, parseView, wrapWithFile } from './mock';
|
||||
import { types as t } from '@babel/core';
|
||||
import type { ExpUnit } from '../index';
|
||||
import { traverse } from '@babel/core';
|
||||
|
||||
describe('ExpUnit', () => {
|
||||
// ---- Type
|
||||
it('should identify expression unit', () => {
|
||||
const viewUnits = parse('<>{count}</>');
|
||||
expect(viewUnits.length).toBe(1);
|
||||
expect(viewUnits[0].type).toBe('exp');
|
||||
});
|
||||
|
||||
it('should not identify literals as expression unit', () => {
|
||||
const viewUnits = parse('<>{1}</>');
|
||||
expect(viewUnits.length).toBe(1);
|
||||
expect(viewUnits[0].type).not.toBe('exp');
|
||||
});
|
||||
|
||||
// ---- Content
|
||||
it('should correctly parse content for expression unit', () => {
|
||||
const viewUnits = parse('<>{count}</>');
|
||||
const content = (viewUnits[0] as ExpUnit).content;
|
||||
|
||||
expect(t.isIdentifier(content.value, { name: 'count' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should correctly parse complex content for expression unit', () => {
|
||||
const ast = parseCode('<>{!console.log("hello world") && myComplexFunc(count + 100)}</>');
|
||||
const viewUnits = parseView(ast);
|
||||
|
||||
const originalExpression = (
|
||||
(ast as t.JSXFragment).children[0] as t.JSXExpressionContainer
|
||||
).expression;
|
||||
|
||||
const content = (viewUnits[0] as ExpUnit).content;
|
||||
expect(content.value).toBe(originalExpression);
|
||||
});
|
||||
|
||||
it('should correctly parse content with view prop for expression unit', () => {
|
||||
// ---- <div>Ok</div> will be replaced with a random string and stored in props.viewPropMap
|
||||
const viewUnits = parse('<>{<div>Ok</div>}</>');
|
||||
const content = (viewUnits[0] as ExpUnit).content;
|
||||
const viewPropMap = content.viewPropMap;
|
||||
|
||||
expect(Object.keys(viewPropMap).length).toBe(1);
|
||||
const key = Object.keys(viewPropMap)[0];
|
||||
const viewProp = viewPropMap[key];
|
||||
// ---- Only one view unit for <div>Ok</div>
|
||||
expect(viewProp.length).toBe(1);
|
||||
expect(viewProp[0].type).toBe('html');
|
||||
|
||||
// ---- The value of the replaced prop should be the key of the viewPropMap
|
||||
const value = content.value;
|
||||
expect(t.isStringLiteral(value, { value: key })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should correctly parse content with view prop for expression unit with complex expression', () => {
|
||||
// ---- <div>Ok</div> will be replaced with a random string and stored in props.viewPropMap
|
||||
const ast = parseCode(`<>{
|
||||
someFunc(() => {
|
||||
console.log("hello world")
|
||||
doWhatever()
|
||||
return <div>Ok</div>
|
||||
})
|
||||
}</>`);
|
||||
const viewUnits = parseView(ast);
|
||||
|
||||
const content = (viewUnits[0] as ExpUnit).content;
|
||||
const viewPropMap = content.viewPropMap;
|
||||
|
||||
expect(Object.keys(viewPropMap).length).toBe(1);
|
||||
const key = Object.keys(viewPropMap)[0];
|
||||
const viewProp = viewPropMap[key];
|
||||
// ---- Only one view unit for <div>Ok</div>
|
||||
expect(viewProp.length).toBe(1);
|
||||
|
||||
// ---- Check the value of the replaced prop
|
||||
let idExistCount = 0;
|
||||
traverse(wrapWithFile(content.value), {
|
||||
StringLiteral(path) {
|
||||
if (path.node.value === key) idExistCount++;
|
||||
}
|
||||
});
|
||||
// ---- Expect the count of the id matching to be exactly 1
|
||||
expect(idExistCount).toBe(1);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,138 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { parse } from './mock';
|
||||
import { types as t } from '@babel/core';
|
||||
import { IfUnit } from '../types';
|
||||
|
||||
describe('IfUnit', () => {
|
||||
// ---- Type
|
||||
it('should identify if unit', () => {
|
||||
const viewUnits = parse('<if cond={true}>true</if>');
|
||||
expect(viewUnits.length).toBe(1);
|
||||
expect(viewUnits[0].type).toBe('if');
|
||||
});
|
||||
|
||||
it('should identify if unit with else', () => {
|
||||
const viewUnits = parse(`<>
|
||||
<if cond={true}>true</if>
|
||||
<else>false</else>
|
||||
</>`);
|
||||
expect(viewUnits.length).toBe(1);
|
||||
expect(viewUnits[0].type).toBe('if');
|
||||
});
|
||||
|
||||
it('should identify if unit with else-if', () => {
|
||||
const viewUnits = parse(`<>
|
||||
<if cond={true}>true</if>
|
||||
<else-if cond={false}>false</else-if>
|
||||
</>`);
|
||||
expect(viewUnits.length).toBe(1);
|
||||
expect(viewUnits[0].type).toBe('if');
|
||||
});
|
||||
|
||||
it('should identify if unit with else-if and else', () => {
|
||||
const viewUnits = parse(`<>
|
||||
<if cond={true}>true</if>
|
||||
<else-if cond={false}>false</else-if>
|
||||
<else>else</else>
|
||||
</>`);
|
||||
expect(viewUnits.length).toBe(1);
|
||||
expect(viewUnits[0].type).toBe('if');
|
||||
});
|
||||
|
||||
it('should identify if unit with multiple else-if', () => {
|
||||
const viewUnits = parse(`<>
|
||||
<if cond={true}>true</if>
|
||||
<else-if cond={flag1}>flag1</else-if>
|
||||
<else-if cond={flag2}>flag2</else-if>
|
||||
<else>else</else>
|
||||
</>`);
|
||||
expect(viewUnits.length).toBe(1);
|
||||
expect(viewUnits[0].type).toBe('if');
|
||||
});
|
||||
|
||||
// ---- Branches
|
||||
it('should correctly parse branches count for if unit', () => {
|
||||
const viewUnits = parse(`<>
|
||||
<if cond={true}>true</if>
|
||||
<else-if cond={flag1}>flag1</else-if>
|
||||
<else-if cond={flag2}>flag2</else-if>
|
||||
<else>else</else>
|
||||
</>`);
|
||||
|
||||
const branches = (viewUnits[0] as IfUnit).branches;
|
||||
expect(branches.length).toBe(4);
|
||||
});
|
||||
|
||||
it('should correctly parse branches\' condition for if unit', () => {
|
||||
const viewUnits = parse('<if cond={true}>true</if>');
|
||||
const branches = (viewUnits[0] as IfUnit).branches;
|
||||
|
||||
expect(t.isBooleanLiteral(branches[0].condition, { value: true })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should correctly parse branches\' children for if unit', () => {
|
||||
const viewUnits = parse('<if cond={true}>true</if>');
|
||||
const branches = (viewUnits[0] as IfUnit).branches;
|
||||
|
||||
expect(branches[0].children.length).toBe(1);
|
||||
expect(branches[0].children[0].type).toBe('text');
|
||||
});
|
||||
|
||||
it('should correctly parse branches\' condition for if unit with else', () => {
|
||||
const viewUnits = parse(`<>
|
||||
<if cond={flag1}>1</if>
|
||||
<else>2</else>
|
||||
</>`);
|
||||
const branches = (viewUnits[0] as IfUnit).branches;
|
||||
|
||||
expect(t.isIdentifier(branches[0].condition, { name: 'flag1' })).toBeTruthy();
|
||||
expect(t.isBooleanLiteral(branches[1].condition, { value: true })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should correctly parse branches\' children for if unit with else', () => {
|
||||
const viewUnits = parse(`<>
|
||||
<if cond={true}>true</if>
|
||||
<else>false</else>
|
||||
</>`);
|
||||
const branches = (viewUnits[0] as IfUnit).branches;
|
||||
|
||||
expect(branches[0].children.length).toBe(1);
|
||||
expect(branches[0].children[0].type).toBe('text');
|
||||
expect(branches[1].children.length).toBe(1);
|
||||
expect(branches[1].children[0].type).toBe('text');
|
||||
});
|
||||
|
||||
it('should correctly parse branches\' condition for if unit with else-if', () => {
|
||||
const viewUnits = parse(`<>
|
||||
<if cond={flag1}>1</if>
|
||||
<else-if cond={flag2}>2</else-if>
|
||||
</>`);
|
||||
const branches = (viewUnits[0] as IfUnit).branches;
|
||||
/**
|
||||
* () => {
|
||||
* if (flag1) {
|
||||
* this._prevCond
|
||||
* return 1
|
||||
* } else () {
|
||||
* if (flag2) {
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
|
||||
expect(t.isIdentifier(branches[0].condition, { name: 'flag1' })).toBeTruthy();
|
||||
expect(t.isIdentifier(branches[1].condition, { name: 'flag2' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should correctly parse branches\' children for if unit with else-if', () => {
|
||||
const viewUnits = parse(`<>
|
||||
<if cond={true}>true</if>
|
||||
<else-if cond={false}>false</else-if>
|
||||
</>`);
|
||||
const branches = (viewUnits[0] as IfUnit).branches;
|
||||
|
||||
expect(branches[0].children.length).toBe(1);
|
||||
expect(branches[0].children[0].type).toBe('text');
|
||||
expect(branches[1].children.length).toBe(1);
|
||||
expect(branches[1].children[0].type).toBe('text');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,82 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { parse } from './mock';
|
||||
import { types as t } from '@babel/core';
|
||||
import type { HTMLUnit, TemplateUnit } from '../index';
|
||||
|
||||
describe('TemplateUnit', () => {
|
||||
// ---- Type
|
||||
it('should not parse a single HTMLUnit to a TemplateUnit', () => {
|
||||
const viewUnits = parse('<div></div>');
|
||||
expect(viewUnits.length).toBe(1);
|
||||
expect(viewUnits[0].type).toBe('html');
|
||||
});
|
||||
|
||||
it('should parse a nested HTMLUnit to a TemplateUnit', () => {
|
||||
const viewUnits = parse('<div><div></div></div>');
|
||||
expect(viewUnits.length).toBe(1);
|
||||
expect(viewUnits[0].type).toBe('template');
|
||||
});
|
||||
|
||||
it('should correctly parse a nested HTMLUnit\'s structure into a template', () => {
|
||||
const viewUnits = parse('<div><div></div></div>');
|
||||
const template = (viewUnits[0] as TemplateUnit).template;
|
||||
|
||||
expect(t.isStringLiteral(template.tag, { value: 'div' })).toBeTruthy();
|
||||
expect(template.children).toHaveLength(1);
|
||||
const firstChild = template.children![0] as HTMLUnit;
|
||||
expect(t.isStringLiteral(firstChild.tag, { value: 'div' })).toBeTruthy();
|
||||
});
|
||||
|
||||
// ---- Props
|
||||
it('should correctly parse the path of TemplateUnit\'s dynamic props in root element', () => {
|
||||
const viewUnits = parse('<div class={this.name}><div></div></div>');
|
||||
const dynamicProps = (viewUnits[0] as TemplateUnit).props;
|
||||
|
||||
expect(dynamicProps).toHaveLength(1);
|
||||
const prop = dynamicProps[0];
|
||||
expect(prop.path).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should correctly parse the path of TemplateUnit\'s dynamic props in nested element', () => {
|
||||
const viewUnits = parse('<div><div class={this.name}></div></div>');
|
||||
const dynamicProps = (viewUnits[0] as TemplateUnit).props!;
|
||||
|
||||
expect(dynamicProps).toHaveLength(1);
|
||||
const prop = dynamicProps[0]!;
|
||||
expect(prop.path).toHaveLength(1);
|
||||
expect(prop.path[0]).toBe(0);
|
||||
});
|
||||
|
||||
it('should correctly parse the path of TemplateUnit\'s dynamic props with mutable particles ahead', () => {
|
||||
const viewUnits = parse('<div><Comp/><div class={this.name}></div></div>');
|
||||
const dynamicProps = (viewUnits[0] as TemplateUnit).props!;
|
||||
|
||||
expect(dynamicProps).toHaveLength(1);
|
||||
const prop = dynamicProps[0]!;
|
||||
expect(prop.path).toHaveLength(1);
|
||||
expect(prop.path[0]).toBe(1);
|
||||
});
|
||||
|
||||
it('should correctly parse the path of TemplateUnit\'s mutableUnits', () => {
|
||||
const viewUnits = parse('<div><Comp/><div class={this.name}></div></div>');
|
||||
const mutableParticles = (viewUnits[0] as TemplateUnit).mutableUnits!;
|
||||
|
||||
expect(mutableParticles).toHaveLength(1);
|
||||
const particle = mutableParticles[0]!;
|
||||
expect(particle.path).toHaveLength(1);
|
||||
expect(particle.path[0]).toBe(0);
|
||||
});
|
||||
|
||||
it('should correctly parse the path of multiple TemplateUnit\'s mutableUnits', () => {
|
||||
const viewUnits = parse('<div><Comp/><div class={this.name}></div><Comp/></div>');
|
||||
const mutableParticles = (viewUnits[0] as TemplateUnit).mutableUnits!;
|
||||
|
||||
expect(mutableParticles).toHaveLength(2);
|
||||
const firstParticle = mutableParticles[0]!;
|
||||
expect(firstParticle.path).toHaveLength(1);
|
||||
expect(firstParticle.path[0]).toBe(0);
|
||||
const secondParticle = mutableParticles[1]!;
|
||||
expect(secondParticle.path).toHaveLength(1);
|
||||
expect(secondParticle.path[0]).toBe(2);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,73 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { parse } from './mock';
|
||||
import { types as t } from '@babel/core';
|
||||
import type { TextUnit } from '../index';
|
||||
|
||||
describe('TextUnit', () => {
|
||||
// ---- Type
|
||||
it('should identify text unit', () => {
|
||||
const viewUnits = parse('<>hello world</>');
|
||||
expect(viewUnits.length).toBe(1);
|
||||
expect(viewUnits[0].type).toBe('text');
|
||||
});
|
||||
|
||||
it('should identify text unit with boolean expression', () => {
|
||||
const viewUnits = parse('<>{true}</>');
|
||||
expect(viewUnits.length).toBe(1);
|
||||
expect(viewUnits[0].type).toBe('text');
|
||||
});
|
||||
|
||||
it('should identify text unit with number expression', () => {
|
||||
const viewUnits = parse('<>{1}</>');
|
||||
expect(viewUnits.length).toBe(1);
|
||||
expect(viewUnits[0].type).toBe('text');
|
||||
});
|
||||
|
||||
it('should identify text unit with null expression', () => {
|
||||
const viewUnits = parse('<>{null}</>');
|
||||
expect(viewUnits.length).toBe(1);
|
||||
expect(viewUnits[0].type).toBe('text');
|
||||
});
|
||||
|
||||
it('should identify text unit with string literal expression', () => {
|
||||
const viewUnits = parse('<>{"hello world"}</>');
|
||||
expect(viewUnits.length).toBe(1);
|
||||
expect(viewUnits[0].type).toBe('text');
|
||||
});
|
||||
|
||||
// ---- Content
|
||||
it('should correctly parse content for text unit', () => {
|
||||
const viewUnits = parse('<>hello world</>');
|
||||
const content = (viewUnits[0] as TextUnit).content;
|
||||
|
||||
expect(t.isStringLiteral(content, { value: 'hello world' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should correctly parse content for boolean text unit', () => {
|
||||
const viewUnits = parse('<>{true}</>');
|
||||
const content = (viewUnits[0] as TextUnit).content;
|
||||
|
||||
expect(t.isBooleanLiteral(content, { value: true })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should correctly parse content for number text unit', () => {
|
||||
const viewUnits = parse('<>{1}</>');
|
||||
const content = (viewUnits[0] as TextUnit).content;
|
||||
|
||||
expect(t.isNumericLiteral(content, { value: 1 })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should correctly parse content for null text unit', () => {
|
||||
const viewUnits = parse('<>{null}</>');
|
||||
const content = (viewUnits[0] as TextUnit).content;
|
||||
|
||||
expect(t.isNullLiteral(content)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should correctly parse content for string literal text unit', () => {
|
||||
const viewUnits = parse('<>{"hello world"}</>');
|
||||
const content = (viewUnits[0] as TextUnit).content;
|
||||
|
||||
expect(t.isStringLiteral(content)).toBeTruthy();
|
||||
});
|
||||
});
|
|
@ -0,0 +1 @@
|
|||
declare module "@babel/plugin-syntax-jsx"
|
|
@ -0,0 +1,230 @@
|
|||
import babel, { parseSync, types as t } from '@babel/core';
|
||||
import { AllowedJSXNode, ViewParserConfig } from '../types';
|
||||
import { parseView as pV } from '..';
|
||||
import babelJSX from '@babel/plugin-syntax-jsx';
|
||||
|
||||
const htmlTags = [
|
||||
'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 config: ViewParserConfig = {
|
||||
babelApi: babel,
|
||||
htmlTags,
|
||||
};
|
||||
|
||||
export function parseCode(code: string) {
|
||||
return (
|
||||
parseSync(code, {plugins: [babelJSX]})!.program
|
||||
.body[0] as t.ExpressionStatement
|
||||
).expression as AllowedJSXNode;
|
||||
}
|
||||
|
||||
export function parseView(node: AllowedJSXNode) {
|
||||
return pV(node, config);
|
||||
}
|
||||
|
||||
export function parse(code: string) {
|
||||
return parseView(parseCode(code));
|
||||
}
|
||||
|
||||
export function wrapWithFile(node: t.Expression): t.File {
|
||||
return t.file(t.program([t.expressionStatement(node)]));
|
||||
}
|
|
@ -0,0 +1,89 @@
|
|||
import type Babel from '@babel/core';
|
||||
import type { types as t } from '@babel/core';
|
||||
|
||||
export interface UnitProp {
|
||||
value: t.Expression
|
||||
viewPropMap: Record<string, ViewUnit[]>
|
||||
specifier?: string
|
||||
}
|
||||
|
||||
export interface TextUnit {
|
||||
type: 'text'
|
||||
content: t.Literal
|
||||
}
|
||||
|
||||
export type MutableUnit = ViewUnit & { path: number[] }
|
||||
|
||||
export interface TemplateProp {
|
||||
name: string
|
||||
key: string
|
||||
path: number[]
|
||||
value: t.Expression
|
||||
}
|
||||
|
||||
export interface TemplateUnit {
|
||||
type: 'template'
|
||||
template: HTMLUnit
|
||||
mutableUnits: MutableUnit[]
|
||||
props: TemplateProp[]
|
||||
}
|
||||
|
||||
export interface HTMLUnit {
|
||||
type: 'html'
|
||||
tag: t.Expression
|
||||
content?: UnitProp
|
||||
props?: Record<string, UnitProp>
|
||||
children?: ViewUnit[]
|
||||
}
|
||||
|
||||
export interface CompUnit {
|
||||
type: 'comp'
|
||||
tag: t.Expression
|
||||
content?: UnitProp
|
||||
props?: Record<string, UnitProp>
|
||||
children?: ViewUnit[]
|
||||
}
|
||||
|
||||
export interface IfBranch {
|
||||
condition: t.Expression
|
||||
children: ViewUnit[]
|
||||
}
|
||||
|
||||
export interface IfUnit {
|
||||
type: 'if'
|
||||
branches: IfBranch[]
|
||||
}
|
||||
|
||||
export interface ExpUnit {
|
||||
type: 'exp'
|
||||
content: UnitProp
|
||||
}
|
||||
|
||||
export interface EnvUnit {
|
||||
type: 'env'
|
||||
props: Record<string, UnitProp>
|
||||
children: ViewUnit[]
|
||||
}
|
||||
|
||||
export type ViewUnit =
|
||||
| TextUnit
|
||||
| HTMLUnit
|
||||
| CompUnit
|
||||
| IfUnit
|
||||
| ExpUnit
|
||||
| EnvUnit
|
||||
| TemplateUnit
|
||||
|
||||
export interface ViewParserConfig {
|
||||
babelApi: typeof Babel
|
||||
htmlTags: string[]
|
||||
parseTemplate?: boolean
|
||||
}
|
||||
|
||||
|
||||
export type AllowedJSXNode =
|
||||
| t.JSXElement
|
||||
| t.JSXFragment
|
||||
| t.JSXText
|
||||
| t.JSXExpressionContainer
|
||||
| t.JSXSpreadChild
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"moduleResolution": "Node",
|
||||
"strict": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"ts-node": {
|
||||
"esm": true
|
||||
}
|
||||
}
|
|
@ -1,3 +1,4 @@
|
|||
packages:
|
||||
# all packages in direct subdirs of packages/
|
||||
- 'packages/*'
|
||||
- 'packages/**/*'
|
||||
|
|
Loading…
Reference in New Issue