From dece3315013dd7678825fe47e10368fb76a86ec5 Mon Sep 17 00:00:00 2001 From: iandxssxx Date: Fri, 26 Jan 2024 07:32:17 -0500 Subject: [PATCH] init: inula jsx view-parser --- .gitignore | 2 + packages/inula-novdom/tests/no-vdom.test.js | 24 +- .../babel-preset-inula-jsx/package.json | 49 ++ .../babel-preset-inula-jsx/src/generator.ts | 0 .../babel-preset-inula-jsx/src/global.d.ts | 1 + .../babel-preset-inula-jsx/src/index.ts | 21 + .../babel-preset-inula-jsx/src/plugin.ts | 22 + .../src/pluginProvider.ts | 106 ++++ .../src/test/entering.test.ts | 10 + .../babel-preset-inula-jsx/src/test/mock.ts | 8 + .../babel-preset-inula-jsx/src/types.ts | 13 + .../babel-preset-inula-jsx/tsconfig.json | 13 + packages/transpiler/view-parser/package.json | 44 ++ packages/transpiler/view-parser/src/index.ts | 17 + packages/transpiler/view-parser/src/parser.ts | 512 ++++++++++++++++++ .../view-parser/src/test/ElementUnit.test.ts | 205 +++++++ .../view-parser/src/test/ExpUnit.test.ts | 89 +++ .../view-parser/src/test/IfUnit.test.ts | 138 +++++ .../view-parser/src/test/TemplateUnit.test.ts | 82 +++ .../view-parser/src/test/TextUnit.test.ts | 73 +++ .../view-parser/src/test/global.d.ts | 1 + .../transpiler/view-parser/src/test/mock.ts | 230 ++++++++ packages/transpiler/view-parser/src/types.ts | 89 +++ packages/transpiler/view-parser/tsconfig.json | 13 + pnpm-workspace.yaml | 1 + 25 files changed, 1758 insertions(+), 5 deletions(-) create mode 100644 packages/transpiler/babel-preset-inula-jsx/package.json create mode 100644 packages/transpiler/babel-preset-inula-jsx/src/generator.ts create mode 100644 packages/transpiler/babel-preset-inula-jsx/src/global.d.ts create mode 100644 packages/transpiler/babel-preset-inula-jsx/src/index.ts create mode 100644 packages/transpiler/babel-preset-inula-jsx/src/plugin.ts create mode 100644 packages/transpiler/babel-preset-inula-jsx/src/pluginProvider.ts create mode 100644 packages/transpiler/babel-preset-inula-jsx/src/test/entering.test.ts create mode 100644 packages/transpiler/babel-preset-inula-jsx/src/test/mock.ts create mode 100644 packages/transpiler/babel-preset-inula-jsx/src/types.ts create mode 100644 packages/transpiler/babel-preset-inula-jsx/tsconfig.json create mode 100755 packages/transpiler/view-parser/package.json create mode 100644 packages/transpiler/view-parser/src/index.ts create mode 100644 packages/transpiler/view-parser/src/parser.ts create mode 100644 packages/transpiler/view-parser/src/test/ElementUnit.test.ts create mode 100644 packages/transpiler/view-parser/src/test/ExpUnit.test.ts create mode 100644 packages/transpiler/view-parser/src/test/IfUnit.test.ts create mode 100644 packages/transpiler/view-parser/src/test/TemplateUnit.test.ts create mode 100644 packages/transpiler/view-parser/src/test/TextUnit.test.ts create mode 100644 packages/transpiler/view-parser/src/test/global.d.ts create mode 100644 packages/transpiler/view-parser/src/test/mock.ts create mode 100644 packages/transpiler/view-parser/src/types.ts create mode 100644 packages/transpiler/view-parser/tsconfig.json diff --git a/.gitignore b/.gitignore index fc678b38..59f6dba6 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ package-lock.json pnpm-lock.yaml /packages/**/node_modules + +.history \ No newline at end of file diff --git a/packages/inula-novdom/tests/no-vdom.test.js b/packages/inula-novdom/tests/no-vdom.test.js index f09acaf8..54de71ff 100644 --- a/packages/inula-novdom/tests/no-vdom.test.js +++ b/packages/inula-novdom/tests/no-vdom.test.js @@ -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); diff --git a/packages/transpiler/babel-preset-inula-jsx/package.json b/packages/transpiler/babel-preset-inula-jsx/package.json new file mode 100644 index 00000000..42721ea7 --- /dev/null +++ b/packages/transpiler/babel-preset-inula-jsx/package.json @@ -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 + } +} diff --git a/packages/transpiler/babel-preset-inula-jsx/src/generator.ts b/packages/transpiler/babel-preset-inula-jsx/src/generator.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/transpiler/babel-preset-inula-jsx/src/global.d.ts b/packages/transpiler/babel-preset-inula-jsx/src/global.d.ts new file mode 100644 index 00000000..018bd3a4 --- /dev/null +++ b/packages/transpiler/babel-preset-inula-jsx/src/global.d.ts @@ -0,0 +1 @@ +declare module "@babel/plugin-syntax-jsx" diff --git a/packages/transpiler/babel-preset-inula-jsx/src/index.ts b/packages/transpiler/babel-preset-inula-jsx/src/index.ts new file mode 100644 index 00000000..62c85c21 --- /dev/null +++ b/packages/transpiler/babel-preset-inula-jsx/src/index.ts @@ -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 }; diff --git a/packages/transpiler/babel-preset-inula-jsx/src/plugin.ts b/packages/transpiler/babel-preset-inula-jsx/src/plugin.ts new file mode 100644 index 00000000..aa6a3fa6 --- /dev/null +++ b/packages/transpiler/babel-preset-inula-jsx/src/plugin.ts @@ -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'); + }, + }, + }, + }; +} \ No newline at end of file diff --git a/packages/transpiler/babel-preset-inula-jsx/src/pluginProvider.ts b/packages/transpiler/babel-preset-inula-jsx/src/pluginProvider.ts new file mode 100644 index 00000000..18efbdc9 --- /dev/null +++ b/packages/transpiler/babel-preset-inula-jsx/src/pluginProvider.ts @@ -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, + 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): void { + if (!this.fileEnter) return; + + } + + + jsxElementVisitor(path: NodePath): 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), + ]) + ); + } +} + + + diff --git a/packages/transpiler/babel-preset-inula-jsx/src/test/entering.test.ts b/packages/transpiler/babel-preset-inula-jsx/src/test/entering.test.ts new file mode 100644 index 00000000..78b7bde9 --- /dev/null +++ b/packages/transpiler/babel-preset-inula-jsx/src/test/entering.test.ts @@ -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); + }); + +}); \ No newline at end of file diff --git a/packages/transpiler/babel-preset-inula-jsx/src/test/mock.ts b/packages/transpiler/babel-preset-inula-jsx/src/test/mock.ts new file mode 100644 index 00000000..a88d0bb5 --- /dev/null +++ b/packages/transpiler/babel-preset-inula-jsx/src/test/mock.ts @@ -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; +} diff --git a/packages/transpiler/babel-preset-inula-jsx/src/types.ts b/packages/transpiler/babel-preset-inula-jsx/src/types.ts new file mode 100644 index 00000000..c84ba949 --- /dev/null +++ b/packages/transpiler/babel-preset-inula-jsx/src/types.ts @@ -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[] +} diff --git a/packages/transpiler/babel-preset-inula-jsx/tsconfig.json b/packages/transpiler/babel-preset-inula-jsx/tsconfig.json new file mode 100644 index 00000000..e0932d78 --- /dev/null +++ b/packages/transpiler/babel-preset-inula-jsx/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "lib": ["ESNext", "DOM"], + "moduleResolution": "Node", + "strict": true, + "esModuleInterop": true + }, + "ts-node": { + "esm": true + } +} \ No newline at end of file diff --git a/packages/transpiler/view-parser/package.json b/packages/transpiler/view-parser/package.json new file mode 100755 index 00000000..afe36eaa --- /dev/null +++ b/packages/transpiler/view-parser/package.json @@ -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 + } +} diff --git a/packages/transpiler/view-parser/src/index.ts b/packages/transpiler/view-parser/src/index.ts new file mode 100644 index 00000000..17a88c01 --- /dev/null +++ b/packages/transpiler/view-parser/src/index.ts @@ -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'; diff --git a/packages/transpiler/view-parser/src/parser.ts b/packages/transpiler/view-parser/src/parser.ts new file mode 100644 index 00000000..3b4222be --- /dev/null +++ b/packages/transpiler/view-parser/src/parser.ts @@ -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 ${}`} + 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.,
+ 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., + // 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] + // 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"] + // the tag will be treated as a string, i.e., + 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., => ["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 = 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 = 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 = {}; + const parseViewProp = (innerPath: NodePath): 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( + 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); + } +} diff --git a/packages/transpiler/view-parser/src/test/ElementUnit.test.ts b/packages/transpiler/view-parser/src/test/ElementUnit.test.ts new file mode 100644 index 00000000..116670f3 --- /dev/null +++ b/packages/transpiler/view-parser/src/test/ElementUnit.test.ts @@ -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('
'); + 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(''); + 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(''); + 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(''); + 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(''); + 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(''); + expect(viewUnits.length).toBe(1); + expect(viewUnits[0].type).toBe('env'); + }); + + // ---- Tag + it('should correctly parse the tag of an HTMLUnit', () => { + const viewUnits = parse('
'); + 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(''); + 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(''); + 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(''); + 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(''); + 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('
'); + + 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('
{console.log("ok")}}>
'); + 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('
'); + + 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('
'); + 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(''); + 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('Ok
>'); + 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(`
+
ok
+
ok
+ + +
`); + 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(`
+
ok
+
ok
+ {count} + {count} +
`); + 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(`
+
ok
+
ok
+ <> + + + +
`); + const htmlUnit = viewUnits[0] as HTMLUnit; + expect(htmlUnit.children!.length).toBe(4); + }); + + it('should correctly parse the children', () => { + const viewUnits = parse('
ok
'); + 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'); + }); +}); \ No newline at end of file diff --git a/packages/transpiler/view-parser/src/test/ExpUnit.test.ts b/packages/transpiler/view-parser/src/test/ExpUnit.test.ts new file mode 100644 index 00000000..1d26ae0a --- /dev/null +++ b/packages/transpiler/view-parser/src/test/ExpUnit.test.ts @@ -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', () => { + // ----
Ok
will be replaced with a random string and stored in props.viewPropMap + const viewUnits = parse('<>{
Ok
}'); + 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
Ok
+ 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', () => { + // ----
Ok
will be replaced with a random string and stored in props.viewPropMap + const ast = parseCode(`<>{ + someFunc(() => { + console.log("hello world") + doWhatever() + return
Ok
+ }) + }`); + 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
Ok
+ 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); + }); +}); diff --git a/packages/transpiler/view-parser/src/test/IfUnit.test.ts b/packages/transpiler/view-parser/src/test/IfUnit.test.ts new file mode 100644 index 00000000..26b142e4 --- /dev/null +++ b/packages/transpiler/view-parser/src/test/IfUnit.test.ts @@ -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('true'); + expect(viewUnits.length).toBe(1); + expect(viewUnits[0].type).toBe('if'); + }); + + it('should identify if unit with else', () => { + const viewUnits = parse(`<> + true + false + `); + expect(viewUnits.length).toBe(1); + expect(viewUnits[0].type).toBe('if'); + }); + + it('should identify if unit with else-if', () => { + const viewUnits = parse(`<> + true + false + `); + expect(viewUnits.length).toBe(1); + expect(viewUnits[0].type).toBe('if'); + }); + + it('should identify if unit with else-if and else', () => { + const viewUnits = parse(`<> + true + false + else + `); + expect(viewUnits.length).toBe(1); + expect(viewUnits[0].type).toBe('if'); + }); + + it('should identify if unit with multiple else-if', () => { + const viewUnits = parse(`<> + true + flag1 + flag2 + 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(`<> + true + flag1 + flag2 + 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('true'); + 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('true'); + 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(`<> + 1 + 2 + `); + 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(`<> + true + false + `); + 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(`<> + 1 + 2 + `); + 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(`<> + true + false + `); + 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'); + }); +}); \ No newline at end of file diff --git a/packages/transpiler/view-parser/src/test/TemplateUnit.test.ts b/packages/transpiler/view-parser/src/test/TemplateUnit.test.ts new file mode 100644 index 00000000..af95950b --- /dev/null +++ b/packages/transpiler/view-parser/src/test/TemplateUnit.test.ts @@ -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('
'); + expect(viewUnits.length).toBe(1); + expect(viewUnits[0].type).toBe('html'); + }); + + it('should parse a nested HTMLUnit to a TemplateUnit', () => { + const viewUnits = parse('
'); + 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('
'); + 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('
'); + 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('
'); + 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('
'); + 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('
'); + 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('
'); + 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); + }); +}); diff --git a/packages/transpiler/view-parser/src/test/TextUnit.test.ts b/packages/transpiler/view-parser/src/test/TextUnit.test.ts new file mode 100644 index 00000000..bcd435de --- /dev/null +++ b/packages/transpiler/view-parser/src/test/TextUnit.test.ts @@ -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(); + }); +}); diff --git a/packages/transpiler/view-parser/src/test/global.d.ts b/packages/transpiler/view-parser/src/test/global.d.ts new file mode 100644 index 00000000..96c0761c --- /dev/null +++ b/packages/transpiler/view-parser/src/test/global.d.ts @@ -0,0 +1 @@ +declare module "@babel/plugin-syntax-jsx" \ No newline at end of file diff --git a/packages/transpiler/view-parser/src/test/mock.ts b/packages/transpiler/view-parser/src/test/mock.ts new file mode 100644 index 00000000..7c843a17 --- /dev/null +++ b/packages/transpiler/view-parser/src/test/mock.ts @@ -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)])); +} \ No newline at end of file diff --git a/packages/transpiler/view-parser/src/types.ts b/packages/transpiler/view-parser/src/types.ts new file mode 100644 index 00000000..4b961e4c --- /dev/null +++ b/packages/transpiler/view-parser/src/types.ts @@ -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 + 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 + children?: ViewUnit[] +} + +export interface CompUnit { + type: 'comp' + tag: t.Expression + content?: UnitProp + props?: Record + 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 + 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 diff --git a/packages/transpiler/view-parser/tsconfig.json b/packages/transpiler/view-parser/tsconfig.json new file mode 100644 index 00000000..e0932d78 --- /dev/null +++ b/packages/transpiler/view-parser/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "lib": ["ESNext", "DOM"], + "moduleResolution": "Node", + "strict": true, + "esModuleInterop": true + }, + "ts-node": { + "esm": true + } +} \ No newline at end of file diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 067a01bf..c6fafc8b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,4 @@ packages: # all packages in direct subdirs of packages/ - 'packages/*' + - 'packages/**/*'