!151 feat: Add jsx babel compiler

* feat: inula transpiler
This commit is contained in:
IanDxSSXX 2024-02-20 02:38:14 +00:00 committed by 陈超涛
parent 07e2ce09b4
commit 676fdf1ef6
52 changed files with 3203 additions and 59 deletions

View File

@ -22,7 +22,9 @@
"test": "vitest --ui" "test": "vitest --ui"
}, },
"devDependencies": { "devDependencies": {
"@babel/generator": "^7.23.6",
"@types/babel__core": "^7.20.5", "@types/babel__core": "^7.20.5",
"@types/babel__generator": "^7.6.8",
"@types/node": "^20.10.5", "@types/node": "^20.10.5",
"@vitest/ui": "^1.2.1", "@vitest/ui": "^1.2.1",
"tsup": "^6.7.0", "tsup": "^6.7.0",
@ -31,6 +33,9 @@
}, },
"dependencies": { "dependencies": {
"@babel/plugin-syntax-jsx": "7.16.7", "@babel/plugin-syntax-jsx": "7.16.7",
"@babel/plugin-syntax-typescript": "^7.23.3",
"@inula/jsx-view-generator": "workspace:*",
"@inula/jsx-view-parser": "workspace:*",
"babel-plugin-syntax-typescript-new": "^1.0.0", "babel-plugin-syntax-typescript-new": "^1.0.0",
"minimatch": "^9.0.3" "minimatch": "^9.0.3"
}, },

View File

@ -0,0 +1,513 @@
export const importMap = [
'createElement',
'setStyle',
'setAttribute',
'setDataset',
'setProperty',
'setEvent',
'delegateEvent',
'addEventListener',
'watch',
'insert',
'createComponent',
'createText'
].reduce<Record<string, string>>((acc, cur) => {
acc[cur] = cur;
return acc;
}, {});
export 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',
];
/**
* @brief HTML internal attribute map, can be accessed as js property
*/
export const attributeMap = {
// ---- Other property as attribute
textContent: ['*'],
innerHTML: ['*'],
// ---- Source: https://developer.mozilla.org/zh-CN/docs/Web/HTML/Attributes
accept: ['form', 'input'],
// ---- Original: accept-charset
acceptCharset: ['form'],
accesskey: ['*'],
action: ['form'],
align: [
'caption',
'col',
'colgroup',
'hr',
'iframe',
'img',
'table',
'tbody',
'td',
'tfoot',
'th',
'thead',
'tr',
],
allow: ['iframe'],
alt: ['area', 'img', 'input'],
async: ['script'],
autocapitalize: ['*'],
autocomplete: ['form', 'input', 'select', 'textarea'],
autofocus: ['button', 'input', 'select', 'textarea'],
autoplay: ['audio', 'video'],
background: ['body', 'table', 'td', 'th'],
// ---- Original: base
bgColor: [
'body',
'col',
'colgroup',
'marquee',
'table',
'tbody',
'tfoot',
'td',
'th',
'tr',
],
border: ['img', 'object', 'table'],
buffered: ['audio', 'video'],
capture: ['input'],
charset: ['meta'],
checked: ['input'],
cite: ['blockquote', 'del', 'ins', 'q'],
class: ['*'],
color: ['font', 'hr'],
cols: ['textarea'],
// ---- Original: colspan
colSpan: ['td', 'th'],
content: ['meta'],
// ---- Original: contenteditable
contentEditable: ['*'],
contextmenu: ['*'],
controls: ['audio', 'video'],
coords: ['area'],
crossOrigin: ['audio', 'img', 'link', 'script', 'video'],
csp: ['iframe'],
data: ['object'],
// ---- Original: datetime
dateTime: ['del', 'ins', 'time'],
decoding: ['img'],
default: ['track'],
defer: ['script'],
dir: ['*'],
dirname: ['input', 'textarea'],
disabled: [
'button',
'fieldset',
'input',
'optgroup',
'option',
'select',
'textarea',
],
download: ['a', 'area'],
draggable: ['*'],
enctype: ['form'],
// ---- Original: enterkeyhint
enterKeyHint: ['textarea', 'contenteditable'],
for: ['label', 'output'],
form: [
'button',
'fieldset',
'input',
'label',
'meter',
'object',
'output',
'progress',
'select',
'textarea',
],
// ---- Original: formaction
formAction: ['input', 'button'],
// ---- Original: formenctype
formEnctype: ['button', 'input'],
// ---- Original: formmethod
formMethod: ['button', 'input'],
// ---- Original: formnovalidate
formNoValidate: ['button', 'input'],
// ---- Original: formtarget
formTarget: ['button', 'input'],
headers: ['td', 'th'],
height: ['canvas', 'embed', 'iframe', 'img', 'input', 'object', 'video'],
hidden: ['*'],
high: ['meter'],
href: ['a', 'area', 'base', 'link'],
hreflang: ['a', 'link'],
// ---- Original: http-equiv
httpEquiv: ['meta'],
id: ['*'],
integrity: ['link', 'script'],
// ---- Original: intrinsicsize
intrinsicSize: ['img'],
// ---- Original: inputmode
inputMode: ['textarea', 'contenteditable'],
ismap: ['img'],
// ---- Original: itemprop
itemProp: ['*'],
kind: ['track'],
label: ['optgroup', 'option', 'track'],
lang: ['*'],
language: ['script'],
loading: ['img', 'iframe'],
list: ['input'],
loop: ['audio', 'marquee', 'video'],
low: ['meter'],
manifest: ['html'],
max: ['input', 'meter', 'progress'],
// ---- Original: maxlength
maxLength: ['input', 'textarea'],
// ---- Original: minlength
minLength: ['input', 'textarea'],
media: ['a', 'area', 'link', 'source', 'style'],
method: ['form'],
min: ['input', 'meter'],
multiple: ['input', 'select'],
muted: ['audio', 'video'],
name: [
'button',
'form',
'fieldset',
'iframe',
'input',
'object',
'output',
'select',
'textarea',
'map',
'meta',
'param',
],
// ---- Original: novalidate
noValidate: ['form'],
open: ['details', 'dialog'],
optimum: ['meter'],
pattern: ['input'],
ping: ['a', 'area'],
placeholder: ['input', 'textarea'],
// ---- Original: playsinline
playsInline: ['video'],
poster: ['video'],
preload: ['audio', 'video'],
readonly: ['input', 'textarea'],
// ---- Original: referrerpolicy
referrerPolicy: ['a', 'area', 'iframe', 'img', 'link', 'script'],
rel: ['a', 'area', 'link'],
required: ['input', 'select', 'textarea'],
reversed: ['ol'],
role: ['*'],
rows: ['textarea'],
// ---- Original: rowspan
rowSpan: ['td', 'th'],
sandbox: ['iframe'],
scope: ['th'],
scoped: ['style'],
selected: ['option'],
shape: ['a', 'area'],
size: ['input', 'select'],
sizes: ['link', 'img', 'source'],
slot: ['*'],
span: ['col', 'colgroup'],
spellcheck: ['*'],
src: [
'audio',
'embed',
'iframe',
'img',
'input',
'script',
'source',
'track',
'video',
],
srcdoc: ['iframe'],
srclang: ['track'],
srcset: ['img', 'source'],
start: ['ol'],
step: ['input'],
style: ['*'],
summary: ['table'],
// ---- Original: tabindex
tabIndex: ['*'],
target: ['a', 'area', 'base', 'form'],
title: ['*'],
translate: ['*'],
type: [
'button',
'input',
'embed',
'object',
'ol',
'script',
'source',
'style',
'menu',
'link',
],
usemap: ['img', 'input', 'object'],
value: [
'button',
'data',
'input',
'li',
'meter',
'option',
'progress',
'param',
'text' /** extra for TextNode */,
],
width: ['canvas', 'embed', 'iframe', 'img', 'input', 'object', 'video'],
wrap: ['textarea'],
// --- ARIA attributes
// Source: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes
ariaAutocomplete: ['*'],
ariaChecked: ['*'],
ariaDisabled: ['*'],
ariaErrorMessage: ['*'],
ariaExpanded: ['*'],
ariaHasPopup: ['*'],
ariaHidden: ['*'],
ariaInvalid: ['*'],
ariaLabel: ['*'],
ariaLevel: ['*'],
ariaModal: ['*'],
ariaMultiline: ['*'],
ariaMultiSelectable: ['*'],
ariaOrientation: ['*'],
ariaPlaceholder: ['*'],
ariaPressed: ['*'],
ariaReadonly: ['*'],
ariaRequired: ['*'],
ariaSelected: ['*'],
ariaSort: ['*'],
ariaValuemax: ['*'],
ariaValuemin: ['*'],
ariaValueNow: ['*'],
ariaValueText: ['*'],
ariaBusy: ['*'],
ariaLive: ['*'],
ariaRelevant: ['*'],
ariaAtomic: ['*'],
ariaDropEffect: ['*'],
ariaGrabbed: ['*'],
ariaActiveDescendant: ['*'],
ariaColCount: ['*'],
ariaColIndex: ['*'],
ariaColSpan: ['*'],
ariaControls: ['*'],
ariaDescribedBy: ['*'],
ariaDescription: ['*'],
ariaDetails: ['*'],
ariaFlowTo: ['*'],
ariaLabelledBy: ['*'],
ariaOwns: ['*'],
ariaPosInset: ['*'],
ariaRowCount: ['*'],
ariaRowIndex: ['*'],
ariaRowSpan: ['*'],
ariaSetSize: ['*'],
};

View File

@ -1,5 +1,3 @@
import syntaxTypescript from 'babel-plugin-syntax-typescript-new';
import syntaxJSX from '@babel/plugin-syntax-jsx';
import { InulaOption } from './types'; import { InulaOption } from './types';
import type { ConfigAPI, TransformOptions } from '@babel/core'; import type { ConfigAPI, TransformOptions } from '@babel/core';
import inula from './plugin'; import inula from './plugin';
@ -11,8 +9,8 @@ export default function (
): TransformOptions { ): TransformOptions {
return { return {
plugins: [ plugins: [
[syntaxJSX.default ?? syntaxJSX], ['@babel/plugin-syntax-jsx'],
[syntaxTypescript, {isJsx: true}], ['@babel/plugin-syntax-typescript', { isTSX: true }],
[inula, options], [inula, options],
], ],
}; };

View File

@ -10,13 +10,19 @@ export default function (api: typeof babel, options: InulaOption): PluginObj {
name: 'babel-plugin-inula-jsx', name: 'babel-plugin-inula-jsx',
visitor: { visitor: {
Program: { Program: {
enter(path) { enter(path, { filename }) {
console.log('babel-plugin-inula-jsx: Program enter'); pluginProvider.programEnterVisitor(path, filename);
}, },
exit(path) { exit(path) {
console.log('babel-plugin-inula-jsx: Program exit'); pluginProvider.programExitVisitor(path);
}, },
}, },
JSXElement(path) {
pluginProvider.jsxElementVisitor(path);
},
JSXFragment(path) {
pluginProvider.jsxElementVisitor(path);
}
}, },
}; };
} }

View File

@ -2,7 +2,9 @@ import type { types as t, NodePath } from '@babel/core';
import type babel from '@babel/core'; import type babel from '@babel/core';
import { minimatch } from 'minimatch'; import { minimatch } from 'minimatch';
import { InulaOption } from './types'; import { InulaOption } from './types';
import { generateView } from '@inula/jsx-view-generator';
import { parseView } from '@inula/jsx-view-parser';
import { attributeMap, htmlTags, importMap } from './const';
export class PluginProvider { export class PluginProvider {
@ -14,6 +16,12 @@ export class PluginProvider {
private readonly includes: string[] private readonly includes: string[]
private readonly excludes: string[] private readonly excludes: string[]
private readonly htmlTags
private readonly parseTemplate
private readonly attributeMap
private programNode: t.Program | undefined
constructor(babelApi: typeof babel, options: InulaOption) { constructor(babelApi: typeof babel, options: InulaOption) {
this.babelApi = babelApi; this.babelApi = babelApi;
this.t = babelApi.types; this.t = babelApi.types;
@ -22,6 +30,10 @@ export class PluginProvider {
const excludes = options.excludeFiles ?? ['**/{dist,node_modules,lib}']; const excludes = options.excludeFiles ?? ['**/{dist,node_modules,lib}'];
this.includes = Array.isArray(includes) ? includes : [includes]; this.includes = Array.isArray(includes) ? includes : [includes];
this.excludes = Array.isArray(excludes) ? excludes : [excludes]; this.excludes = Array.isArray(excludes) ? excludes : [excludes];
this.htmlTags = options.htmlTags ?? htmlTags;
this.parseTemplate = options.parseTemplate ?? true;
this.attributeMap = options.attributeMap ?? attributeMap;
} }
// ---- Two levels of enter: // ---- Two levels of enter:
@ -30,13 +42,9 @@ export class PluginProvider {
private fileEnter = true private fileEnter = true
// ---- File Level // ---- File Level
private programNode?: t.Program
private allImports: t.ImportDeclaration[] = [] private allImports: t.ImportDeclaration[] = []
private didAlterImports = false
private transformedCount = 0
// ---- Component Level ----
private templateIdx = -1
programEnterVisitor( programEnterVisitor(
path: NodePath<t.Program>, path: NodePath<t.Program>,
@ -56,17 +64,34 @@ export class PluginProvider {
return; return;
} }
this.programNode = path.node; this.programNode = path.node;
this.transformedCount = 0; this.templateIdx = -1;
} }
programExitVisitor(path: NodePath<t.Program>): void { programExitVisitor(path: NodePath<t.Program>): void {
if (!this.fileEnter) return; if (!this.fileEnter) return;
this.fileEnter = false;
} }
jsxElementVisitor(path: NodePath<t.JSXElement>): void { jsxElementVisitor(path: NodePath<t.JSXElement | t.JSXFragment>): void {
if (!this.fileEnter) return;
const viewUnits = parseView(path.node, {
babelApi: this.babelApi,
htmlTags: this.htmlTags,
parseTemplate: this.parseTemplate,
});
const [templates, viewAst] = generateView(viewUnits, {
babelApi: this.babelApi,
importMap,
attributeMap: this.attributeMap,
}, this.templateIdx);
this.templateIdx += templates.length;
// ---- Add templates to the program
this.programNode!.body.unshift(...templates);
// ---- Replace the JSXElement with the viewAst
path.replaceWith(viewAst);
path.skip();
} }
@ -87,19 +112,6 @@ export class PluginProvider {
return false; return false;
return true; 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),
])
);
}
} }

View File

@ -1,10 +0,0 @@
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);
});
});

View File

@ -1,8 +1,22 @@
import babel, { transform, types as t } from '@babel/core'; import babel, { transform, types as t, parseSync } from '@babel/core';
import inula from '../'; import inula from '../';
import babelJSX from '@babel/plugin-syntax-jsx';
import generate from '@babel/generator';
import { expect as ep } from 'vitest';
function formatCode(code: string) {
return generate(
parseSync(code, {plugins: [babelJSX]})!
)!.code;
}
export function transformInula(code: string) { export function transformInula(code: string) {
return transform(code, { return transform(code, {
presets: [inula] presets: [[inula, {'files': '*'}]]
})?.code; })?.code;
} }
export function expect(ori: string, expected: string) {
const transformed = transformInula(ori)!;
ep(formatCode(transformed)).toBe(formatCode(expected));
}

View File

@ -0,0 +1,80 @@
import { describe, it } from 'vitest';
import { expect } from './mock';
describe('Entering', () => {
it('should use inula jsx preset in babel', () => {
const code = 'console.log(\'hello world\');';
expect(code, code);
});
it('should transform jsx to inula view', () => {
expect(/*jsx*/`
import A from "inula"
function App() {
return (
<div></div>
)
}
`, /*js*/`
import A from "inula";
function App() {
return (() => {
const $node0 = createElement("div");
return [$node0];
})();
}
`);
});
it('should transform jsx to inula view with props', () => {
expect(/*jsx*/`
import A from "inula"
function App() {
return (
<div id="myDiv"></div>
)
}
`, /*js*/`
import A from "inula";
function App() {
return (() => {
const $node0 = createElement("div");
$node0.id = "myDiv";
return [$node0];
})();
}
`);
});
it('should transform jsx to inula view with template', () => {
expect(/*jsx*/`
import A from "inula"
function App() {
return (
<div>
<p>ok</p>
<h1>fine</h1>
</div>
)
}
`, /*js*/`
const $template0 = (() => {
const $node0 = createElement("div");
const $node1 = createElement("p");
$node1.textContent = "ok";
insert($node0, $node1);
const $node2 = createElement("h1");
$node2.textContent = "fine";
insert($node0, $node2);
return $node0;
});
import A from "inula";
function App() {
return (() => {
const $node0 = $template0.cloneNode(true);
return [$node0];
})();
}
`);
});
});

View File

@ -10,4 +10,21 @@ export interface InulaOption {
* @default ** /{dist,node_modules,lib}/*.{js,ts} * @default ** /{dist,node_modules,lib}/*.{js,ts}
*/ */
excludeFiles?: string | string[] excludeFiles?: string | string[]
/**
* @brief Using AttributeMap to identify propertyfied attributes
* Reason for adding this:
* `el.prop = xxx` is faster than `el.setAttribute('prop', xxx)`
* @example { href: ["a", "area", "base", "link"], id: ["*"] }
*/
attributeMap?: Record<string, string[]>
/**
* @brief Using htmlTags to identify the html tags
* @example ["a", "area", "base", "link"]
*/
htmlTags?: string[]
/**
* @brief Using importMap to identify the import names
* @example { createElement: 'createElement' }
*/
parseTemplate?: boolean
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,217 @@
import { ViewUnit, UnitProp } from '@inula/jsx-view-parser';
import Babel, { types, traverse } from '@babel/core';
interface ViewGeneratorConfig {
babelApi: typeof Babel;
importMap: Record<string, string>;
/**
* @brief Using AttributeMap to identify propertyfied attributes
* Reason for adding this:
* `el.prop = xxx` is faster than `el.setAttribute('prop', xxx)`
* @example { href: ["a", "area", "base", "link"], id: ["*"] }
*/
attributeMap?: Record<string, string[]>;
}
declare class BaseGenerator {
readonly viewUnit: ViewUnit;
readonly config: ViewGeneratorConfig;
readonly t: typeof types;
readonly traverse: typeof traverse;
readonly elementAttributeMap: Record<string, string[]>;
readonly importMap: Record<string, string>;
constructor(viewUnit: ViewUnit, config: ViewGeneratorConfig);
private readonly initStatements;
addStatement(...statements: types.Statement[]): void;
private readonly templates;
addTemplate(...template: types.Statement[]): void;
/**
* @brief To be implemented by the subclass
*/
run(): string;
generate(): [string, types.Statement[], types.Statement[]];
/**
* @brief Check if the expression is reactive, which satisfies the following conditions:
* 1. Contains .get() property
* 2. The whole expression is not a function
* @param expression
* @returns
*/
checkReactive(expression: types.Expression): boolean;
private readonly prefixMap;
nodeIdx: number;
geneNodeName(idx?: number): string;
templateIdx: number;
generateTemplateName(): string;
/**
* @brief Wrap the value in a file
* @param node
* @returns wrapped value
*/
wrapWithFile(node: types.Expression): types.File;
addWatch(value: types.Expression): types.CallExpression;
createCollector(): [types.Statement[], (statement: types.Statement | types.Statement[] | null) => void];
parseViewProp(prop: UnitProp, generateView: (units: ViewUnit[], config: ViewGeneratorConfig, templateIdx: number) => [types.Statement[], types.ExpressionStatement]): types.Expression;
parseProps(props: Record<string, UnitProp>, generateView: (units: ViewUnit[], config: ViewGeneratorConfig) => [types.Statement[], types.ExpressionStatement]): {
[k: string]: types.Expression;
};
}
declare class CompGenerator extends BaseGenerator {
run(): string;
/**
* @View
* const $el = createComponent(tag, {
* ...props
* }, spreadProps)
*/
declareCompNode(tag: types.Expression, props: Record<string, types.Expression>, children: ViewUnit[]): [string, types.Statement];
}
declare class HTMLPropGenerator extends BaseGenerator {
static DelegatedEvents: Set<string>;
addHTMLProp(nodeName: string, tag: string, key: string, value: types.Expression): types.Statement;
/**
* @View
* setStyle($node, value)
*/
private setStyle;
/**
* @View
* setDataset($node, value)
*/
private setDataset;
/**
* @View
* $node.key = value
*/
private setStaticProperty;
/**
* @View
* setProperty($node, key, value)
*/
private setDynamicProperty;
/**
* @View
* $node.setAttribute(key, value)
*/
private setStaticAttribute;
/**
* @View
* setAttribute($node, key, value)
*/
private setDynamicAttribute;
/**
* @View
* delegateEvent($node, eventName, value)
*/
private setDelegatedEvent;
/**
* @View
* $node.addEventListener(eventName, value)
*/
private setStaticEvent;
/**
* @View
* addEventListener($node, eventName, value)
*/
private setDynamicEvent;
private setDynamicHTMLProp;
/**
* @brief Check if the attribute is internal, i.e., can be accessed as js property
* @param tag
* @param attribute
* @returns true if the attribute is internal
*/
isInternalAttribute(tag: string, attribute: string): boolean;
}
declare class HTMLGenerator extends HTMLPropGenerator {
run(): string;
/**
* @View
* const $el = createElement(tag)
*/
declareHTMLNode(tag: types.Expression): [string, types.Statement];
/**
* @View
* $insert($el, childNode)
*/
private insertChildNode;
}
declare class TemplateGenerator extends HTMLPropGenerator {
run(): string;
private generateTemplate;
/**
* @View
* const $el = template.cloneNode(true)
*/
private declareTemplateNode;
/**
* @View
* $insert($el, childNode)
*/
private insertChildNode;
/**
* @View
* ${dlNodeName}.firstChild
* or
* ${dlNodeName}.firstChild.nextSibling
* or
* ...
* ${dlNodeName}.childNodes[${num}]
*/
private insertElement;
/**
* @brief Insert elements to the template node from the paths
* @param paths
* @param dlNodeName
* @returns
*/
private insertElements;
/**
* @brief Extract common prefix from paths
* e.g.
* [0, 1, 2, 3] + [0, 1, 2, 4] => [0, 1, 2], [0, 1, 2, 3], [0, 1, 2, 4]
* [0, 1, 2] is the common prefix
* @param paths
* @returns paths with common prefix
*/
private static pathWithCommonPrefix;
/**
* @brief Find the best node name and path for the given path by looking into the nameMap.
* If there's a full match, return the name and an empty path
* If there's a partly match, return the name and the remaining path
* If there's a nextSibling match, return the name and the remaining path with sibling offset
* @param nameMap
* @param path
* @param defaultName
* @returns [name, path, siblingOffset]
*/
private static findBestNodeAndPath;
}
declare class TextGenerator extends BaseGenerator {
run(): string;
declareTextNode(content: types.Literal): [string, types.Statement];
}
declare class ExpressionGenerator extends BaseGenerator {
run(): string;
declareExpressionNode(expression: types.Expression): [string, types.Statement];
}
declare const viewGeneratorMap: {
readonly html: typeof HTMLGenerator;
readonly comp: typeof CompGenerator;
readonly template: typeof TemplateGenerator;
readonly text: typeof TextGenerator;
readonly exp: typeof ExpressionGenerator;
readonly if: typeof CompGenerator;
readonly env: typeof CompGenerator;
};
declare function generateNew(oldGenerator: any, viewUnit: ViewUnit, resetIdx?: boolean): [string, types.Statement[], types.Statement[]];
declare function generateView(viewUnits: ViewUnit[], config: ViewGeneratorConfig, templateIdx?: number): [types.Statement[], types.ExpressionStatement];
export { ViewGeneratorConfig, generateNew, generateView, viewGeneratorMap };

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,47 @@
{
"name": "@inula/jsx-view-generator",
"version": "0.0.0",
"description": "Inula view generator",
"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",
"@babel/generator": "^7.23.6",
"@babel/plugin-syntax-jsx": "7.16.7",
"@inula/jsx-view-parser": "workspace:*",
"@types/babel__core": "^7.20.5",
"@types/babel__generator": "^7.6.8",
"@vitest/ui": "^0.34.5",
"tsup": "^6.7.0",
"typescript": "^5.3.2",
"vitest": "^0.34.5"
},
"tsup": {
"entry": [
"src/index.ts"
],
"format": [
"cjs",
"esm"
],
"clean": true,
"dts": true,
"minify": true
}
}

View File

@ -0,0 +1,158 @@
import { UnitProp, ViewUnit } from '@inula/jsx-view-parser';
import { ViewGeneratorConfig } from '../types';
import type { types as t, traverse } from '@babel/core';
export default class BaseGenerator {
readonly viewUnit: ViewUnit
readonly config: ViewGeneratorConfig
readonly t
readonly traverse: typeof traverse
readonly elementAttributeMap
readonly importMap
constructor(viewUnit: ViewUnit, config: ViewGeneratorConfig) {
this.config = config;
this.t = config.babelApi.types;
this.traverse = config.babelApi.traverse;
this.importMap = config.importMap;
this.viewUnit = viewUnit;
this.elementAttributeMap = config.attributeMap
? Object.entries(config.attributeMap).reduce<Record<string, string[]>>(
(acc, [key, elements]) => {
elements.forEach(element => {
if (!acc[element]) acc[element] = [];
acc[element].push(key);
});
return acc;
},
{}
)
: {};
}
// ---- Init Statements
private readonly initStatements: t.Statement[] = []
addStatement(...statements: t.Statement[]) {
this.initStatements.push(...statements);
}
private readonly templates: t.Statement[] = []
addTemplate(...template: t.Statement[]) {
this.templates.push(...template);
}
// ---- Generate ----
/**
* @brief To be implemented by the subclass
*/
run(): string {
return '';
}
generate(): [string, t.Statement[], t.Statement[]]{
const nodeName = this.run();
return [nodeName, this.initStatements, this.templates];
}
// ---- Reactivity ----
/**
* @brief Check if the expression is reactive, which satisfies the following conditions:
* 1. Contains .get() property
* 2. The whole expression is not a function
* @param expression
* @returns
*/
checkReactive(expression: t.Expression) {
if (this.t.isFunction(expression)) return false;
let reactive = false;
this.traverse(this.wrapWithFile(expression), {
MemberExpression: path => {
if (this.t.isIdentifier(path.node.property, { name: 'get' })) {
reactive = true;
path.stop();
}
}
});
return reactive;
}
// ---- Names ----
private readonly prefixMap = {
node: '$node',
template: '$template',
}
nodeIdx = -1;
geneNodeName(idx?: number): string {
return `${this.prefixMap.node}${idx ?? ++this.nodeIdx}`;
}
templateIdx = -1;
generateTemplateName() {
return `${this.prefixMap.template}${++this.templateIdx}`;
}
// ---- Utils ----
/**
* @brief Wrap the value in a file
* @param node
* @returns wrapped value
*/
wrapWithFile(node: t.Expression): t.File {
return this.t.file(this.t.program([this.t.expressionStatement(node)]));
}
addWatch(value: t.Expression) {
return this.t.callExpression(
this.t.identifier(this.importMap.watch),
[this.t.arrowFunctionExpression([], value)]
);
}
createCollector(): [t.Statement[], (statement: t.Statement | t.Statement[] | null) => void]{
const statements: t.Statement[] = [];
function collect(statement: t.Statement | t.Statement[] | null) {
if (Array.isArray(statement)) {
statements.push(...statement);
} else if (statement) {
statements.push(statement);
}
}
return [statements, collect];
}
parseViewProp(prop: UnitProp, generateView: (units: ViewUnit[], config: ViewGeneratorConfig, templateIdx: number) => [t.Statement[], t.ExpressionStatement]): t.Expression {
let value = prop.value;
const viewPropMap = prop.viewPropMap;
const propNodeMap = Object.fromEntries(
Object.entries(viewPropMap).map(([key, units]) => {
const [templates, statement] = generateView(units, this.config, this.templateIdx);
this.addTemplate(...templates);
return [key, statement];
})
);
this.traverse(this.wrapWithFile(value), {
StringLiteral: path => {
const key = path.node.value;
if (propNodeMap[key]) {
if (this.t.isNodesEquivalent(value, path.node)) {
value = (propNodeMap[key] as t.ExpressionStatement).expression;
}
path.replaceWith(propNodeMap[key]);
}
}
});
return value;
}
parseProps(props: Record<string, UnitProp>, generateView: (units: ViewUnit[], config: ViewGeneratorConfig) => [t.Statement[], t.ExpressionStatement]) {
return Object.fromEntries(
Object.entries(props).map(([key, prop]) => {
return [key, this.parseViewProp(prop, generateView)];
})
);
}
}

View File

@ -0,0 +1,223 @@
import BaseGenerator from './BaseGenerator';
import type { types as t } from '@babel/core';
export class HTMLPropGenerator extends BaseGenerator {
static DelegatedEvents = new Set([
'beforeinput',
'click',
'dblclick',
'contextmenu',
'focusin',
'focusout',
'input',
'keydown',
'keyup',
'mousedown',
'mousemove',
'mouseout',
'mouseover',
'mouseup',
'pointerdown',
'pointermove',
'pointerout',
'pointerover',
'pointerup',
'touchend',
'touchmove',
'touchstart',
])
addHTMLProp(
nodeName: string,
tag: string,
key: string,
value: t.Expression,
): t.Statement {
const shouldWatch = this.checkReactive(value);
let expression = this.setDynamicHTMLProp(nodeName, tag, key, value, shouldWatch);
if (shouldWatch) expression = this.addWatch(expression);
return this.t.expressionStatement(expression);
}
/**
* @View
* setStyle($node, value)
*/
private setStyle(
nodeName: string,
value: t.Expression,
) {
return this.t.callExpression(
this.t.identifier(this.importMap.setStyle),
[this.t.identifier(nodeName), value]
);
}
/**
* @View
* setDataset($node, value)
*/
private setDataset(
nodeName: string,
value: t.Expression,
) {
return this.t.callExpression(
this.t.identifier(this.importMap.setDataset),
[this.t.identifier(nodeName), value]
);
}
/**
* @View
* $node.key = value
*/
private setStaticProperty(
nodeName: string,
key: string,
value: t.Expression,
) {
return this.t.assignmentExpression(
'=',
this.t.memberExpression(
this.t.identifier(nodeName),
this.t.identifier(key),
),
value
);
}
/**
* @View
* setProperty($node, key, value)
*/
private setDynamicProperty(
nodeName: string,
key: string,
value: t.Expression,
) {
return this.t.callExpression(
this.t.identifier(this.importMap.setProperty),
[this.t.identifier(nodeName), this.t.stringLiteral(key), value]
);
}
/**
* @View
* $node.setAttribute(key, value)
*/
private setStaticAttribute(
nodeName: string,
key: string,
value: t.Expression,
) {
return this.t.callExpression(
this.t.memberExpression(
this.t.identifier(nodeName),
this.t.identifier('setAttribute'),
),
[this.t.stringLiteral(key), value]
);
}
/**
* @View
* setAttribute($node, key, value)
*/
private setDynamicAttribute(
nodeName: string,
key: string,
value: t.Expression,
) {
return this.t.callExpression(
this.t.identifier(this.importMap.setAttribute),
[this.t.identifier(nodeName), this.t.stringLiteral(key), value]
);
}
/**
* @View
* delegateEvent($node, eventName, value)
*/
private setDelegatedEvent(
nodeName: string,
eventName: string,
value: t.Expression,
) {
return this.t.callExpression(
this.t.identifier(this.importMap.delegateEvent),
[this.t.identifier(nodeName), this.t.stringLiteral(eventName), value]
);
}
/**
* @View
* $node.addEventListener(eventName, value)
*/
private setStaticEvent(
nodeName: string,
eventName: string,
value: t.Expression,
) {
return this.t.callExpression(
this.t.memberExpression(
this.t.identifier(nodeName),
this.t.identifier('addEventListener'),
),
[this.t.stringLiteral(eventName), value]
);
}
/**
* @View
* addEventListener($node, eventName, value)
*/
private setDynamicEvent(
nodeName: string,
eventName: string,
value: t.Expression,
) {
return this.t.callExpression(
this.t.identifier(this.importMap.addEventListener),
[this.t.identifier(nodeName), this.t.stringLiteral(eventName), value]
);
}
private setDynamicHTMLProp(
nodeName: string,
tag: string,
key: string,
value: t.Expression,
dynamic: boolean
): t.Expression {
if (key === 'style') return this.setStyle(nodeName, value);
if (key === 'dataset') return this.setDataset(nodeName, value);
if (key.startsWith('on')) {
const event = key.slice(2).toLowerCase();
if (HTMLPropGenerator.DelegatedEvents.has(event)) {
return this.setDelegatedEvent(nodeName, event, value);
}
return this[dynamic ? 'setDynamicEvent' : 'setStaticEvent'](nodeName, event, value);
}
if (this.isInternalAttribute(tag, key)) {
if (key === 'class') key = 'className';
else if (key === 'for') key = 'htmlFor';
return this[dynamic ? 'setDynamicProperty' : 'setStaticProperty'](nodeName, key, value);
}
return this[dynamic ? 'setDynamicAttribute' : 'setStaticAttribute'](nodeName, key, value);
}
/**
* @brief Check if the attribute is internal, i.e., can be accessed as js property
* @param tag
* @param attribute
* @returns true if the attribute is internal
*/
isInternalAttribute(tag: string, attribute: string): boolean {
return (
this.elementAttributeMap['*']?.includes(attribute) ||
this.elementAttributeMap[tag]?.includes(attribute)
);
}
}

View File

@ -0,0 +1,69 @@
import { CompUnit, ViewUnit } from '@inula/jsx-view-parser';
import BaseGenerator from '../HelperGenerators/BaseGenerator';
import type { types as t } from '@babel/core';
import { generateBlock, generateView } from '../generate';
export class CompGenerator extends BaseGenerator {
run(): string {
const { tag, props: propsWithView, children } = this.viewUnit as CompUnit;
const props = this.parseProps(propsWithView, generateView);
const [nodeName, statement] = this.declareCompNode(tag, props, children);
this.addStatement(statement);
return nodeName;
}
/**
* @View
* const $el = createComponent(tag, {
* ...props
* }, spreadProps)
*/
declareCompNode(tag: t.Expression, props: Record<string, t.Expression>, children: ViewUnit[]): [string, t.Statement] {
const name = this.geneNodeName();
const nodes = [];
if (Object.keys(props).length > 0 || children.length > 0) {
const propNode = this.t.objectExpression(Object.entries(props).map(([key, value]) => {
const isReactive = this.checkReactive(value);
if (isReactive) {
/**
* @View
* get reactiveValue() {
* return value
* }
*/
return (
this.t.objectMethod('get', this.t.identifier(key), [],
this.t.blockStatement([
this.t.returnStatement(value)
])
)
);
}
return this.t.objectProperty(
this.t.identifier(key),
value
);
}));
if (children.length > 0) {
const statement = generateBlock(children, this.config);
propNode.properties.push(
this.t.objectMethod('get', this.t.identifier('children'), [], statement)
);
}
nodes.push(propNode);
}
return [name, this.t.variableDeclaration('const', [
this.t.variableDeclarator(
this.t.identifier(name),
this.t.callExpression(
this.t.identifier(this.importMap.createComponent),
[tag, ...nodes]
)
)
])];
}
}

View File

@ -0,0 +1,25 @@
import { ExpUnit } from '@inula/jsx-view-parser';
import BaseGenerator from '../HelperGenerators/BaseGenerator';
import type { types as t } from '@babel/core';
import { generateView } from '../generate';
export class ExpressionGenerator extends BaseGenerator {
run() {
const { content: contentWithProp } = this.viewUnit as ExpUnit;
const content = this.parseViewProp(contentWithProp, generateView);
const [nodeName, statement] = this.declareExpressionNode(content);
this.addStatement(statement);
return nodeName;
}
declareExpressionNode(expression: t.Expression): [string, t.Statement] {
const name = this.geneNodeName();
return [name, this.t.variableDeclaration('const', [
this.t.variableDeclarator(
this.t.identifier(name),
expression
)
])];
}
}

View File

@ -0,0 +1,63 @@
import { HTMLUnit } from '@inula/jsx-view-parser';
import type { types as t } from '@babel/core';
import { HTMLPropGenerator } from '../HelperGenerators/HTMLPropGenerator';
import { generateNew, generateView } from '../generate';
export class HTMLGenerator extends HTMLPropGenerator {
run(){
const { tag, props: propsWithView, children } = this.viewUnit as HTMLUnit;
const props = this.parseProps(propsWithView, generateView);
const [nodeName, statement] = this.declareHTMLNode(tag);
this.addStatement(statement);
// ---- Use the tag name to check if the prop is internal for the tag,
// for dynamic tag, we can't check it, so we just assume it's not internal
// represent by the "ANY" tag name
const tagName = this.t.isStringLiteral(tag) ? tag.value : 'ANY';
Object.entries(props).forEach(([key, prop]) => {
this.addStatement(this.addHTMLProp(nodeName, tagName, key, prop));
});
children.forEach(child => {
const [childNodeName, childStatements] = generateNew(this, child);
this.addStatement(...childStatements);
this.addStatement(this.insertChildNode(nodeName, childNodeName));
});
return nodeName;
}
/**
* @View
* const $el = createElement(tag)
*/
declareHTMLNode(tag: t.Expression): [string, t.Statement] {
const name = this.geneNodeName();
return [name, this.t.variableDeclaration('const', [
this.t.variableDeclarator(
this.t.identifier(name),
this.t.callExpression(
this.t.identifier('createElement'),
[tag]
)
)
])];
}
/**
* @View
* $insert($el, childNode)
*/
private insertChildNode(
parent: string,
child: string
) {
return this.t.expressionStatement(
this.t.callExpression(
this.t.identifier(this.importMap.insert),
[this.t.identifier(parent), this.t.identifier(child)]
)
);
}
}

View File

@ -0,0 +1,320 @@
import { HTMLUnit, TemplateUnit } from '@inula/jsx-view-parser';
import { HTMLPropGenerator } from '../HelperGenerators/HTMLPropGenerator';
import { generateNew } from '../generate';
import type { types as t } from '@babel/core';
export class TemplateGenerator extends HTMLPropGenerator{
run(): string {
const { template, mutableUnits, props } = this.viewUnit as TemplateUnit;
const templateName = this.generateTemplate(template);
const [nodeName, statement] = this.declareTemplateNode(templateName);
this.addStatement(statement);
// ---- Insert elements first
const paths: number[][] = [];
props.forEach(({ path }) => {
paths.push(path);
});
mutableUnits.forEach(({ path }) => {
// ---- ParentPath and NextPath
paths.push(path.slice(0, -1));
if (path[path.length - 1] !== -1) paths.push(path);
});
const [insertElementStatements, pathNameMap] = this.insertElements(
paths,
nodeName
);
this.addStatement(...insertElementStatements);
// ---- Resolve props
props.forEach(
({
tag,
path,
key,
value,
}) => {
const name = pathNameMap[path.join('.')];
const tagName = this.t.isStringLiteral(tag) ? tag.value : 'ANY';
this.addStatement(
this.addHTMLProp(
name,
tagName,
key,
value,
)
);
}
);
// ---- Resolve mutable units
mutableUnits.forEach(unit => {
const path = unit.path;
// ---- Find parent htmlElement
const parentName = pathNameMap[path.slice(0, -1).join('.')];
const nextName = pathNameMap[path.join('.')];
console.log(nextName, path);
const [childName, childStatements] = generateNew(this, unit);
this.addStatement(...childStatements);
this.addStatement(this.insertChildNode(parentName, childName, nextName));
});
return nodeName;
}
private generateTemplate(template: HTMLUnit): string {
const templateName = this.generateTemplateName();
const [name, statements] = generateNew(this, template, false);
const returnStatement = this.t.returnStatement(this.t.identifier(name));
this.addTemplate(
this.t.variableDeclaration('const', [
this.t.variableDeclarator(
this.t.identifier(templateName),
this.t.arrowFunctionExpression([], this.t.blockStatement([...statements, returnStatement]))
)
])
);
return templateName;
}
/**
* @View
* const $el = template.cloneNode(true)
*/
private declareTemplateNode(templateName: string): [string, t.Statement]{
const name = this.geneNodeName();
return [name, this.t.variableDeclaration('const', [
this.t.variableDeclarator(
this.t.identifier(name),
this.t.callExpression(
this.t.memberExpression(
this.t.identifier(templateName),
this.t.identifier('cloneNode')
), [this.t.identifier('true')]
)
)
])];
}
/**
* @View
* $insert($el, childNode)
*/
private insertChildNode(
parent: string,
child: string,
nextName: string
) {
const nextNode = nextName ? [this.t.identifier(nextName)] : [];
return this.t.expressionStatement(
this.t.callExpression(
this.t.identifier(this.importMap.insert),
[this.t.identifier(parent), this.t.identifier(child), ...nextNode]
)
);
}
/**
* @View
* ${dlNodeName}.firstChild
* or
* ${dlNodeName}.firstChild.nextSibling
* or
* ...
* ${dlNodeName}.childNodes[${num}]
*/
private insertElement(
dlNodeName: string,
path: number[],
offset: number
): t.Statement {
const newNodeName = this.geneNodeName();
if (path.length === 0) {
return this.t.variableDeclaration('const', [
this.t.variableDeclarator(
this.t.identifier(newNodeName),
Array.from({ length: offset }).reduce(
(acc: t.Expression) =>
this.t.memberExpression(acc, this.t.identifier('nextSibling')),
this.t.identifier(dlNodeName)
)
)
]);
}
const addFirstChild = (object: t.Expression) =>
// ---- ${object}.firstChild
this.t.memberExpression(object, this.t.identifier('firstChild'));
const addSecondChild = (object: t.Expression) =>
// ---- ${object}.firstChild.nextSibling
this.t.memberExpression(
addFirstChild(object),
this.t.identifier('nextSibling')
);
const addThirdChild = (object: t.Expression) =>
// ---- ${object}.firstChild.nextSibling.nextSibling
this.t.memberExpression(
addSecondChild(object),
this.t.identifier('nextSibling')
);
const addOtherChild = (object: t.Expression, num: number) =>
// ---- ${object}.childNodes[${num}]
this.t.memberExpression(
this.t.memberExpression(object, this.t.identifier('childNodes')),
this.t.numericLiteral(num),
true
);
const addNextSibling = (object: t.Expression) =>
// ---- ${object}.nextSibling
this.t.memberExpression(object, this.t.identifier('nextSibling'));
return this.t.variableDeclaration('const', [
this.t.variableDeclarator(
this.t.identifier(newNodeName),
path.reduce((acc: t.Expression, cur: number, idx) => {
if (idx === 0 && offset > 0) {
for (let i = 0; i < offset; i++) acc = addNextSibling(acc);
}
if (cur === 0) return addFirstChild(acc);
if (cur === 1) return addSecondChild(acc);
if (cur === 2) return addThirdChild(acc);
return addOtherChild(acc, cur);
}, this.t.identifier(dlNodeName))
)
]);
}
/**
* @brief Insert elements to the template node from the paths
* @param paths
* @param dlNodeName
* @returns
*/
private insertElements(
paths: number[][],
dlNodeName: string
): [t.Statement[], Record<string, string>] {
const [statements, collect] = this.createCollector();
const nameMap: Record<string, number[]> = { [dlNodeName]: [] };
const commonPrefixPaths = TemplateGenerator.pathWithCommonPrefix(paths);
commonPrefixPaths.forEach(path => {
const res = TemplateGenerator.findBestNodeAndPath(
nameMap,
path,
dlNodeName
);
const [, pat, offset] = res;
let name = res[0];
if (pat.length !== 0 || offset !== 0) {
collect(this.insertElement(name, pat, offset));
name = this.geneNodeName(this.nodeIdx);
nameMap[name] = path;
}
});
const pathNameMap = Object.fromEntries(
Object.entries(nameMap).map(([name, path]) => [path.join('.'), name])
);
return [statements, pathNameMap];
}
// ---- Path related
/**
* @brief Extract common prefix from paths
* e.g.
* [0, 1, 2, 3] + [0, 1, 2, 4] => [0, 1, 2], [0, 1, 2, 3], [0, 1, 2, 4]
* [0, 1, 2] is the common prefix
* @param paths
* @returns paths with common prefix
*/
private static pathWithCommonPrefix(paths: number[][]): number[][] {
const allPaths = [...paths].sort();
const ps = [...allPaths];
ps.forEach(path0 => {
ps.forEach(path1 => {
if (path0 === path1) return;
for (let i = 0; i < path0.length; i++) {
if (path0[i] !== path1[i]) {
if (i !== 0) {
allPaths.push(path0.slice(0, i));
}
break;
}
}
});
});
// ---- Sort by length and then by first element, small to large
const sortedPaths = allPaths.sort((a, b) => {
if (a.length !== b.length) return a.length - b.length;
return a[0] - b[0];
});
// ---- Deduplicate
const deduplicatedPaths = [
...new Set(sortedPaths.map(path => path.join('.'))),
].map(path => path.split('.').filter(Boolean).map(Number));
return deduplicatedPaths;
}
/**
* @brief Find the best node name and path for the given path by looking into the nameMap.
* If there's a full match, return the name and an empty path
* If there's a partly match, return the name and the remaining path
* If there's a nextSibling match, return the name and the remaining path with sibling offset
* @param nameMap
* @param path
* @param defaultName
* @returns [name, path, siblingOffset]
*/
private static findBestNodeAndPath(
nameMap: Record<string, number[]>,
path: number[],
defaultName: string
): [string, number[], number] {
let bestMatchCount = 0;
let bestMatchName: string | undefined;
let bestHalfMatch: [string, number, number] | undefined;
Object.entries(nameMap).forEach(([name, pat]) => {
let matchCount = 0;
const pathLength = pat.length;
for (let i = 0; i < pathLength; i++) {
if (pat[i] === path[i]) matchCount++;
}
// console.log(name, matchCount, pathLength - 1, pat);
if (matchCount === pathLength - 1) {
const offset = path[pathLength - 1] - pat[pathLength - 1];
if (offset > 0 && offset <= 3) {
bestHalfMatch = [name, matchCount, offset];
}
}
if (matchCount !== pat.length) return;
if (matchCount > bestMatchCount) {
bestMatchName = name;
bestMatchCount = matchCount;
}
});
if (bestHalfMatch) {
return [
bestHalfMatch[0],
path.slice(bestHalfMatch[1] + 1),
bestHalfMatch[2],
];
}
if (!bestMatchName) {
return [defaultName, path, 0];
}
return [bestMatchName, path.slice(bestMatchCount), 0];
}
}

View File

@ -0,0 +1,25 @@
import { TextUnit } from '@inula/jsx-view-parser';
import BaseGenerator from '../HelperGenerators/BaseGenerator';
import type { types as t } from '@babel/core';
export class TextGenerator extends BaseGenerator {
run() {
const { content } = this.viewUnit as TextUnit;
const [nodeName, statement] = this.declareTextNode(content);
this.addStatement(statement);
return nodeName;
}
declareTextNode(content: t.Literal): [string, t.Statement] {
const name = this.geneNodeName();
return [name, this.t.variableDeclaration('const', [
this.t.variableDeclarator(
this.t.identifier(name),
this.t.callExpression(
this.t.identifier(this.importMap.createText),
[content]
)
)
])];
}
}

View File

@ -0,0 +1,72 @@
import { ViewUnit } from '@inula/jsx-view-parser';
import { CompGenerator } from './NodeGenerators/CompGenerator';
import { HTMLGenerator } from './NodeGenerators/HTMLGenerator';
import { ViewGeneratorConfig } from './types';
import type { types as t } from '@babel/core';
import { TemplateGenerator } from './NodeGenerators/TemplateGenerator';
import { TextGenerator } from './NodeGenerators/TextGenerator';
import { ExpressionGenerator } from './NodeGenerators/ExpressionGenerator';
export const viewGeneratorMap = {
html: HTMLGenerator,
comp: CompGenerator,
template: TemplateGenerator,
text: TextGenerator,
exp: ExpressionGenerator,
if: CompGenerator,
env: CompGenerator,
} as const;
export function generateNew(oldGenerator: any, viewUnit: ViewUnit, resetIdx = true): [string, t.Statement[], t.Statement[]]{
const generator = new viewGeneratorMap[viewUnit.type](viewUnit, oldGenerator.config);
if (resetIdx) generator.nodeIdx = oldGenerator.nodeIdx;
const [name, statements, templates] = generator.generate();
if (resetIdx) oldGenerator.nodeIdx = generator.nodeIdx;
return [name, statements, templates];
}
export function generateBlock(viewUnits: ViewUnit[], config: ViewGeneratorConfig) {
const t = config.babelApi.types;
const names: string[] = [];
const statements = viewUnits.flatMap(viewUnit => {
const generator = new viewGeneratorMap[viewUnit.type](viewUnit, config);
const [name, statements] = generator.generate();
names.push(name);
return statements;
});
const returnStatement = t.returnStatement(t.arrayExpression(names.map(name => t.identifier(name))));
return (
t.blockStatement(statements.concat(returnStatement))
);
}
export function generateView(viewUnits: ViewUnit[], config: ViewGeneratorConfig, templateIdx=-1): [t.Statement[], t.ExpressionStatement] {
const t = config.babelApi.types;
const names: string[] = [];
const allTemplates: t.Statement[] = [];
let nodeIdx = -1;
const statements = viewUnits.flatMap(viewUnit => {
const generator = new viewGeneratorMap[viewUnit.type](viewUnit, config);
generator.templateIdx = templateIdx;
generator.nodeIdx = nodeIdx;
const [name, statements, templates] = generator.generate();
templateIdx = generator.templateIdx;
nodeIdx = generator.nodeIdx;
names.push(name);
allTemplates.push(...templates);
return statements;
});
const returnStatement = t.returnStatement(t.arrayExpression(names.map(name => t.identifier(name))));
return [allTemplates, (
t.expressionStatement(
t.callExpression(
t.arrowFunctionExpression([], t.blockStatement(statements.concat(returnStatement))),
[]
)
)
)];
}

View File

@ -0,0 +1,2 @@
export {generateView, generateNew, viewGeneratorMap} from './generate';
export type {ViewGeneratorConfig} from './types';

View File

@ -0,0 +1,82 @@
import { describe, it } from 'vitest';
import { expectView } from './mock';
describe('Comp', () => {
it('should generate a Component', () => {
expectView(/*jsx*/`
<Comp/>
`, /*js*/ `
const $node0 = createComponent(Comp)
`);
});
it('should generate a Component with props', () => {
expectView(/*jsx*/`
<Comp prop1="value1" prop2={value2}/>
`, /*js*/`
const $node0 = createComponent(Comp, {
prop1: "value1",
prop2: value2
})
`);
});
it('should generate a Component with children', () => {
expectView(/*jsx*/`
<Comp>
<div></div>
</Comp>
`, /*js*/`
const $node0 = createComponent(Comp, {
get children() {
const $node0 = createElement("div")
return [$node0]
}
})
`);
});
it('should generate a Component with props and children', () => {
expectView(/*jsx*/`
<Comp prop1="value1" prop2={value2}>
<div></div>
</Comp>
`, /*js*/`
const $node0 = createComponent(Comp, {
prop1: "value1",
prop2: value2,
get children() {
const $node0 = createElement("div")
return [$node0]
}
})
`);
});
it('should generate a Component with reactive props', () => {
expectView(/*jsx*/`
<Comp prop1={value.get()}/>
`, /*js*/`
const $node0 = createComponent(Comp, {
get prop1() {
return value.get()
}
})
`);
});
it('should generate a Component with render/view props', () => {
expectView(/*jsx*/`
<Comp render={<div>ok</div>}/>
`, /*js*/`
const $node0 = createComponent(Comp, {
render: (() => {
const $node0 = createElement("div")
$node0.textContent = "ok"
return [$node0]
})()
})
`);
});
});

View File

@ -0,0 +1,513 @@
export const importMap = [
'createElement',
'setStyle',
'setAttribute',
'setDataset',
'setProperty',
'setEvent',
'delegateEvent',
'addEventListener',
'watch',
'insert',
'createComponent',
'createText'
].reduce<Record<string, string>>((acc, cur) => {
acc[cur] = cur;
return acc;
}, {});
export 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',
];
/**
* @brief HTML internal attribute map, can be accessed as js property
*/
export const attributeMap = {
// ---- Other property as attribute
textContent: ['*'],
innerHTML: ['*'],
// ---- Source: https://developer.mozilla.org/zh-CN/docs/Web/HTML/Attributes
accept: ['form', 'input'],
// ---- Original: accept-charset
acceptCharset: ['form'],
accesskey: ['*'],
action: ['form'],
align: [
'caption',
'col',
'colgroup',
'hr',
'iframe',
'img',
'table',
'tbody',
'td',
'tfoot',
'th',
'thead',
'tr',
],
allow: ['iframe'],
alt: ['area', 'img', 'input'],
async: ['script'],
autocapitalize: ['*'],
autocomplete: ['form', 'input', 'select', 'textarea'],
autofocus: ['button', 'input', 'select', 'textarea'],
autoplay: ['audio', 'video'],
background: ['body', 'table', 'td', 'th'],
// ---- Original: base
bgColor: [
'body',
'col',
'colgroup',
'marquee',
'table',
'tbody',
'tfoot',
'td',
'th',
'tr',
],
border: ['img', 'object', 'table'],
buffered: ['audio', 'video'],
capture: ['input'],
charset: ['meta'],
checked: ['input'],
cite: ['blockquote', 'del', 'ins', 'q'],
class: ['*'],
color: ['font', 'hr'],
cols: ['textarea'],
// ---- Original: colspan
colSpan: ['td', 'th'],
content: ['meta'],
// ---- Original: contenteditable
contentEditable: ['*'],
contextmenu: ['*'],
controls: ['audio', 'video'],
coords: ['area'],
crossOrigin: ['audio', 'img', 'link', 'script', 'video'],
csp: ['iframe'],
data: ['object'],
// ---- Original: datetime
dateTime: ['del', 'ins', 'time'],
decoding: ['img'],
default: ['track'],
defer: ['script'],
dir: ['*'],
dirname: ['input', 'textarea'],
disabled: [
'button',
'fieldset',
'input',
'optgroup',
'option',
'select',
'textarea',
],
download: ['a', 'area'],
draggable: ['*'],
enctype: ['form'],
// ---- Original: enterkeyhint
enterKeyHint: ['textarea', 'contenteditable'],
for: ['label', 'output'],
form: [
'button',
'fieldset',
'input',
'label',
'meter',
'object',
'output',
'progress',
'select',
'textarea',
],
// ---- Original: formaction
formAction: ['input', 'button'],
// ---- Original: formenctype
formEnctype: ['button', 'input'],
// ---- Original: formmethod
formMethod: ['button', 'input'],
// ---- Original: formnovalidate
formNoValidate: ['button', 'input'],
// ---- Original: formtarget
formTarget: ['button', 'input'],
headers: ['td', 'th'],
height: ['canvas', 'embed', 'iframe', 'img', 'input', 'object', 'video'],
hidden: ['*'],
high: ['meter'],
href: ['a', 'area', 'base', 'link'],
hreflang: ['a', 'link'],
// ---- Original: http-equiv
httpEquiv: ['meta'],
id: ['*'],
integrity: ['link', 'script'],
// ---- Original: intrinsicsize
intrinsicSize: ['img'],
// ---- Original: inputmode
inputMode: ['textarea', 'contenteditable'],
ismap: ['img'],
// ---- Original: itemprop
itemProp: ['*'],
kind: ['track'],
label: ['optgroup', 'option', 'track'],
lang: ['*'],
language: ['script'],
loading: ['img', 'iframe'],
list: ['input'],
loop: ['audio', 'marquee', 'video'],
low: ['meter'],
manifest: ['html'],
max: ['input', 'meter', 'progress'],
// ---- Original: maxlength
maxLength: ['input', 'textarea'],
// ---- Original: minlength
minLength: ['input', 'textarea'],
media: ['a', 'area', 'link', 'source', 'style'],
method: ['form'],
min: ['input', 'meter'],
multiple: ['input', 'select'],
muted: ['audio', 'video'],
name: [
'button',
'form',
'fieldset',
'iframe',
'input',
'object',
'output',
'select',
'textarea',
'map',
'meta',
'param',
],
// ---- Original: novalidate
noValidate: ['form'],
open: ['details', 'dialog'],
optimum: ['meter'],
pattern: ['input'],
ping: ['a', 'area'],
placeholder: ['input', 'textarea'],
// ---- Original: playsinline
playsInline: ['video'],
poster: ['video'],
preload: ['audio', 'video'],
readonly: ['input', 'textarea'],
// ---- Original: referrerpolicy
referrerPolicy: ['a', 'area', 'iframe', 'img', 'link', 'script'],
rel: ['a', 'area', 'link'],
required: ['input', 'select', 'textarea'],
reversed: ['ol'],
role: ['*'],
rows: ['textarea'],
// ---- Original: rowspan
rowSpan: ['td', 'th'],
sandbox: ['iframe'],
scope: ['th'],
scoped: ['style'],
selected: ['option'],
shape: ['a', 'area'],
size: ['input', 'select'],
sizes: ['link', 'img', 'source'],
slot: ['*'],
span: ['col', 'colgroup'],
spellcheck: ['*'],
src: [
'audio',
'embed',
'iframe',
'img',
'input',
'script',
'source',
'track',
'video',
],
srcdoc: ['iframe'],
srclang: ['track'],
srcset: ['img', 'source'],
start: ['ol'],
step: ['input'],
style: ['*'],
summary: ['table'],
// ---- Original: tabindex
tabIndex: ['*'],
target: ['a', 'area', 'base', 'form'],
title: ['*'],
translate: ['*'],
type: [
'button',
'input',
'embed',
'object',
'ol',
'script',
'source',
'style',
'menu',
'link',
],
usemap: ['img', 'input', 'object'],
value: [
'button',
'data',
'input',
'li',
'meter',
'option',
'progress',
'param',
'text' /** extra for TextNode */,
],
width: ['canvas', 'embed', 'iframe', 'img', 'input', 'object', 'video'],
wrap: ['textarea'],
// --- ARIA attributes
// Source: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes
ariaAutocomplete: ['*'],
ariaChecked: ['*'],
ariaDisabled: ['*'],
ariaErrorMessage: ['*'],
ariaExpanded: ['*'],
ariaHasPopup: ['*'],
ariaHidden: ['*'],
ariaInvalid: ['*'],
ariaLabel: ['*'],
ariaLevel: ['*'],
ariaModal: ['*'],
ariaMultiline: ['*'],
ariaMultiSelectable: ['*'],
ariaOrientation: ['*'],
ariaPlaceholder: ['*'],
ariaPressed: ['*'],
ariaReadonly: ['*'],
ariaRequired: ['*'],
ariaSelected: ['*'],
ariaSort: ['*'],
ariaValuemax: ['*'],
ariaValuemin: ['*'],
ariaValueNow: ['*'],
ariaValueText: ['*'],
ariaBusy: ['*'],
ariaLive: ['*'],
ariaRelevant: ['*'],
ariaAtomic: ['*'],
ariaDropEffect: ['*'],
ariaGrabbed: ['*'],
ariaActiveDescendant: ['*'],
ariaColCount: ['*'],
ariaColIndex: ['*'],
ariaColSpan: ['*'],
ariaControls: ['*'],
ariaDescribedBy: ['*'],
ariaDescription: ['*'],
ariaDetails: ['*'],
ariaFlowTo: ['*'],
ariaLabelledBy: ['*'],
ariaOwns: ['*'],
ariaPosInset: ['*'],
ariaRowCount: ['*'],
ariaRowIndex: ['*'],
ariaRowSpan: ['*'],
ariaSetSize: ['*'],
};

View File

@ -0,0 +1,14 @@
import { describe, it } from 'vitest';
import { expectView } from './mock';
describe('Expression', () => {
it('should generate a expression Node', () => {
expectView(/*jsx*/`
<>{expr}</>
`, /*js*/`
const $node0 = expr
`);
});
});

View File

@ -0,0 +1,128 @@
import { describe, it } from 'vitest';
import { expectView } from './mock';
describe('HTML', () => {
it('should generate a div element', () => {
expectView(/*jsx*/`
<div></div>
`, /*js*/ `
const $node0 = createElement("div")
`);
});
// ---- Static Props
it('should generate a div element with a static id', () => {
expectView(/*jsx*/`
<div id="myDiv"></div>
`, /*js*/`
const $node0 = createElement("div")
$node0.id = "myDiv"
`);
});
it('should generate a div element with a static class', () => {
expectView(/*jsx*/`
<div class="myClass"></div>
`, /*js*/`
const $node0 = createElement("div")
$node0.className = "myClass"
`);
});
it('should generate a div element with a static for', () => {
expectView(/*jsx*/`
<label for="myFor"></label>
`, /*js*/`
const $node0 = createElement("label")
$node0.htmlFor = "myFor"
`);
});
it('should generate a div element with a static style', () => {
expectView(/*jsx*/`
<div style="color: red"></div>
`, /*js*/`
const $node0 = createElement("div")
setStyle($node0, "color: red")
`);
});
it('should generate a div element with a static dataset', () => {
expectView(/*jsx*/`
<div dataset={myDataset}></div>
`, /*js*/`
const $node0 = createElement("div")
setDataset($node0, myDataset)
`);
});
it('should generate a div element with a static event', () => {
expectView(/*jsx*/`
<div onClick={myFunction}></div>
`, /*js*/`
const $node0 = createElement("div")
delegateEvent($node0, "click", myFunction)
`);
});
it('should generate a div element with a static custom event', () => {
expectView(/*jsx*/`
<div onMyEvent={myFunction}></div>
`, /*js*/`
const $node0 = createElement("div")
$node0.addEventListener("myevent", myFunction)
`);
});
// ---- Dynamic Props
it('should generate a div element with a dynamic id', () => {
expectView(/*jsx*/`
<div id={count.get()}></div>
`, /*js*/`
const $node0 = createElement("div")
watch(() => setProperty($node0, "id", count.get()))
`);
});
it('should generate a div element with a dynamic style', () => {
expectView(/*jsx*/`
<div style={styleList[count.get()]}></div>
`, /*js*/`
const $node0 = createElement("div")
watch(() => setStyle($node0, styleList[count.get()]))
`);
});
it('should generate a div element with a dynamic dataset', () => {
expectView(/*jsx*/`
<div dataset={datasetList[count.get()]}></div>
`, /*js*/`
const $node0 = createElement("div")
watch(() => setDataset($node0, datasetList[count.get()]))
`);
});
it('should generate a div element with a dynamic event', () => {
expectView(/*jsx*/`
<div onClick={() => { console.log(count.get()) }}></div>
`, /*js*/`
const $node0 = createElement("div")
delegateEvent(
$node0, "click",
() => { console.log(count.get()) }
)
`);
});
it('should insert a element into a div', () => {
expectView(/*jsx*/`
<div>
<Comp />
</div>
`, /*js*/`
const $node0 = createElement("div")
const $node1 = createComponent(Comp)
insert($node0, $node1)
`);
});
});

View File

@ -0,0 +1,64 @@
import { ViewGeneratorConfig } from '../types';
import babel, { parseSync, types as t } from '@babel/core';
import { attributeMap, htmlTags, importMap } from './const';
import { AllowedJSXNode, ViewParserConfig, parseView } from '@inula/jsx-view-parser';
import babelJSX from '@babel/plugin-syntax-jsx';
import { generateView as gV } from '..';
import { expect } from 'vitest';
import generate from '@babel/generator';
export const parserConfig: ViewParserConfig = {
babelApi: babel,
htmlTags,
};
export function parseCode(code: string) {
return (
parseSync(code, {plugins: [babelJSX]})!.program
.body[0] as t.ExpressionStatement
).expression as AllowedJSXNode;
}
export const generatorConfig: ViewGeneratorConfig = {
babelApi: babel,
importMap,
attributeMap,
};
export function generateView(code: string) {
const viewUnits = parseView(parseCode(code), parserConfig);
return gV(viewUnits, generatorConfig);
}
function formatCode(code: string) {
return generate(
parseSync(code, {plugins: [babelJSX]})!
)!.code;
}
export function expectView(code: string, expected: string, expectedTemplates?: string[]) {
const [templates, viewAst] = generateView(code);
const statements = (((viewAst.expression as t.CallExpression)
.callee as t.ArrowFunctionExpression)
.body as t.BlockStatement)
.body.slice(0, -1);
const viewCode = generate(
t.file(t.program(statements)),
)!.code!;
const expectedCode = formatCode(expected);
expect(viewCode).toBe(expectedCode);
if (expectedTemplates) {
const templateCode = templates.map(template => generate(template).code);
expectedTemplates = expectedTemplates.map(formatCode);
expect(templateCode).toEqual(expectedTemplates.map(formatCode));
}
}
export function js(strings: TemplateStringsArray, ...values: any[]) {
return strings.reduce((acc, cur, i) => {
return acc + cur + (values[i] || '');
}, '');
}

View File

@ -0,0 +1,302 @@
import { describe, it } from 'vitest';
import { expectView } from './mock';
describe('Template', () => {
it('should generate a Template', () => {
expectView(/*jsx*/`
<div>
<p>ok</p>
<h1>fine</h1>
</div>
`, /*js*/`
const $node0 = $template0.cloneNode(true)
`, [
/*js*/`
const $template0 = (() => {
const $node0 = createElement("div")
const $node1 = createElement("p")
$node1.textContent = "ok"
insert($node0, $node1)
const $node2 = createElement("h1")
$node2.textContent = "fine"
insert($node0, $node2)
return $node0
})
`
]);
});
it('should generate a Template with props', () => {
expectView(/*jsx*/`
<div id="myDiv">
<p class="ok">ok</p>
<h1>fine</h1>
</div>
`, /*js*/`
const $node0 = $template0.cloneNode(true)
`, [
/*js*/`
const $template0 = (() => {
const $node0 = createElement("div")
$node0.id = "myDiv"
const $node1 = createElement("p")
$node1.className = "ok"
$node1.textContent = "ok"
insert($node0, $node1)
const $node2 = createElement("h1")
$node2.textContent = "fine"
insert($node0, $node2)
return $node0
})
`
]);
});
it('should generate a Template with dynamic props', () => {
expectView(/*jsx*/`
<div id={id}>
<p class={cls}></p>
<h1></h1>
</div>
`, /*js*/`
const $node0 = $template0.cloneNode(true)
const $node1 = $node0.firstChild
$node0.id = id
$node1.className = cls
`, [
/*js*/`
const $template0 = (() => {
const $node0 = createElement("div")
const $node1 = createElement("p")
insert($node0, $node1)
const $node2 = createElement("h1")
insert($node0, $node2)
return $node0
})
`
]);
});
it('should generate a Template with reactive props', () => {
expectView(/*jsx*/`
<div id={id.get()}>
<p class={cls.get()}></p>
<h1></h1>
</div>
`, /*js*/`
const $node0 = $template0.cloneNode(true)
const $node1 = $node0.firstChild
watch(() => setProperty($node0, "id", id.get()))
watch(() => setProperty($node1, "className", cls.get()))
`, [
/*js*/`
const $template0 = (() => {
const $node0 = createElement("div")
const $node1 = createElement("p")
insert($node0, $node1)
const $node2 = createElement("h1")
insert($node0, $node2)
return $node0
})
`
]);
});
it('should generate multiple Template', () => {
expectView(/*jsx*/`
<>
<div>
<p>ok</p>
<h1>fine</h1>
</div>
<section>
<div>second temp</div>
<p>ok</p>
<h1>fine</h1>
</section>
</>
`, /*js*/`
const $node0 = $template0.cloneNode(true)
const $node1 = $template1.cloneNode(true)
`, [
/*js*/`
const $template0 = (() => {
const $node0 = createElement("div")
const $node1 = createElement("p")
$node1.textContent = "ok"
insert($node0, $node1)
const $node2 = createElement("h1")
$node2.textContent = "fine"
insert($node0, $node2)
return $node0
})
`,
/*js*/`
const $template1 = (() => {
const $node0 = createElement("section")
const $node1 = createElement("div")
$node1.textContent = "second temp"
insert($node0, $node1)
const $node2 = createElement("p")
$node2.textContent = "ok"
insert($node0, $node2)
const $node3 = createElement("h1")
$node3.textContent = "fine"
insert($node0, $node3)
return $node0
})
`
]);
});
it('should generate a Template with Components at end', () => {
expectView(/*jsx*/`
<div>
<p/>
<Comp />
</div>
`, /*js*/`
const $node0 = $template0.cloneNode(true)
const $node1 = createComponent(Comp)
insert($node0, $node1)
`, [
/*js*/`
const $template0 = (() => {
const $node0 = createElement("div")
const $node1 = createElement("p")
insert($node0, $node1)
return $node0
})
`
]);
});
it('should generate a Template with Components in the middle', () => {
expectView(/*jsx*/`
<div>
<p/>
<Comp />
<section/>
</div>
`, /*js*/`
const $node0 = $template0.cloneNode(true)
const $node1 = $node0.firstChild.nextSibling;
const $node2 = createComponent(Comp);
insert($node0, $node2, $node1);
`, [
/*js*/`
const $template0 = (() => {
const $node0 = createElement("div")
const $node1 = createElement("p")
insert($node0, $node1)
const $node2 = createElement("section")
insert($node0, $node2)
return $node0
})
`
]);
});
it('should generate a Template with a very very complex structure', () => {
expectView(/*jsx*/`
<>
<div>
<p/>
<Comp myProp={prop} reactiveProp={prop.get()}/>
<section>
<div onClick={() => {console.log(prop.get())}}>second temp</div>
<p class={cls}>ok</p>
<h1 id={id.get()}>fine</h1>
</section>
</div>
<div>
<p class="pp"/>
<Comp />
<section>
<div>second temp</div>
<div id={id.get()} class="very deep">
<div class="nono">
<div>
<div>
<p>stop here</p>
</div>
</div>
</div>
</div>
</section>
</div>
</>
`, /*js*/`
const $node0 = $template0.cloneNode(true)
const $node1 = $node0.firstChild.nextSibling;
const $node2 = $node1.firstChild;
const $node3 = $node2.nextSibling;
const $node4 = $node3.nextSibling;
delegateEvent($node2, "click", () => {
console.log(prop.get());
});
$node3.className = cls;
watch(() => setProperty($node4, "id", id.get()));
const $node5 = createComponent(Comp, {
myProp: prop,
get reactiveProp() {
return prop.get();
}
});
insert($node0, $node5, $node1);
const $node6 = $template1.cloneNode(true);
const $node7 = $node6.firstChild.nextSibling;
const $node8 = $node7.firstChild.nextSibling;
watch(() => setProperty($node8, "id", id.get()));
const $node9 = createComponent(Comp);
insert($node6, $node9, $node7);
`, [
/*js*/`
const $template0 = (() => {
const $node0 = createElement("div")
const $node1 = createElement("p")
insert($node0, $node1)
const $node2 = createElement("section")
const $node3 = createElement("div")
$node3.textContent = "second temp"
insert($node2, $node3)
const $node4 = createElement("p")
$node4.textContent = "ok"
insert($node2, $node4)
const $node5 = createElement("h1")
$node5.textContent = "fine"
insert($node2, $node5)
insert($node0, $node2)
return $node0
})
`, /*js*/`
const $template1 = (() => {
const $node0 = createElement("div")
const $node1 = createElement("p")
$node1.className = "pp"
insert($node0, $node1)
const $node2 = createElement("section");
const $node3 = createElement("div");
$node3.textContent = "second temp";
insert($node2, $node3);
const $node4 = createElement("div");
$node4.className = "very deep";
const $node5 = createElement("div");
$node5.className = "nono";
const $node6 = createElement("div");
const $node7 = createElement("div");
const $node8 = createElement("p");
$node8.textContent = "stop here";
insert($node7, $node8);
insert($node6, $node7);
insert($node5, $node6);
insert($node4, $node5);
insert($node2, $node4);
insert($node0, $node2);
return $node0
})
`
]);
});
});

View File

@ -0,0 +1,46 @@
import { describe, it } from 'vitest';
import { expectView } from './mock';
describe('Text', () => {
it('should generate a text node', () => {
expectView(/*jsx*/`
<>hello world</>
`, /*js*/`
const $node0 = createText("hello world")
`);
});
it('should generate a text node with a boolean expression', () => {
expectView(/*jsx*/`
<>{true}</>
`, /*js*/`
const $node0 = createText(true)
`);
});
it('should generate a text node with a number expression', () => {
expectView(/*jsx*/`
<>{1}</>
`, /*js*/`
const $node0 = createText(1)
`);
});
it('should generate a text node with a null expression', () => {
expectView(/*jsx*/`
<>{null}</>
`, /*js*/`
const $node0 = createText(null)
`);
});
it('should generate a text node with a string literal expression', () => {
expectView(/*jsx*/`
<>{"hello world"}</>
`, /*js*/`
const $node0 = createText("hello world")
`);
});
});

View File

@ -0,0 +1,14 @@
import type Babel from '@babel/core';
export interface ViewGeneratorConfig {
babelApi: typeof Babel;
importMap: Record<string, string>;
/**
* @brief Using AttributeMap to identify propertyfied attributes
* Reason for adding this:
* `el.prop = xxx` is faster than `el.setAttribute('prop', xxx)`
* @example { href: ["a", "area", "base", "link"], id: ["*"] }
*/
attributeMap?: Record<string, string[]>
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,73 @@
import Babel, { types } from '@babel/core';
interface UnitProp {
value: types.Expression;
viewPropMap: Record<string, ViewUnit[]>;
specifier?: string;
}
interface TextUnit {
type: 'text';
content: types.Literal;
}
type MutableUnit = ViewUnit & {
path: number[];
};
interface TemplateProp {
tag: types.Expression;
name: string;
key: string;
path: number[];
value: types.Expression;
}
interface TemplateUnit {
type: 'template';
template: HTMLUnit;
mutableUnits: MutableUnit[];
props: TemplateProp[];
}
interface HTMLUnit {
type: 'html';
tag: types.Expression;
props: Record<string, UnitProp>;
children: ViewUnit[];
}
interface CompUnit {
type: 'comp';
tag: types.Expression;
props: Record<string, UnitProp>;
children: ViewUnit[];
}
interface IfBranch {
condition: types.Expression;
children: ViewUnit[];
}
interface IfUnit {
type: 'if';
branches: IfBranch[];
}
interface ExpUnit {
type: 'exp';
content: UnitProp;
}
interface EnvUnit {
type: 'env';
props: Record<string, UnitProp>;
children: ViewUnit[];
}
type ViewUnit = TextUnit | HTMLUnit | CompUnit | IfUnit | ExpUnit | EnvUnit | TemplateUnit;
interface ViewParserConfig {
babelApi: typeof Babel;
htmlTags: string[];
parseTemplate?: boolean;
}
type AllowedJSXNode = types.JSXElement | types.JSXFragment | types.JSXText | types.JSXExpressionContainer | types.JSXSpreadChild;
/**
* @brief Generate view units from a babel ast
* @param statement
* @param config
* @returns ViewUnit[]
*/
declare function parseView(node: AllowedJSXNode, config: ViewParserConfig): ViewUnit[];
export { AllowedJSXNode, CompUnit, EnvUnit, ExpUnit, HTMLUnit, IfBranch, IfUnit, MutableUnit, TemplateProp, TemplateUnit, TextUnit, UnitProp, ViewParserConfig, ViewUnit, parseView };

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,5 @@
{ {
"name": "@inula/view-parser", "name": "@inula/jsx-view-parser",
"version": "0.0.0", "version": "0.0.0",
"description": "Inula jsx parser", "description": "Inula jsx parser",
"author": { "author": {

View File

@ -184,8 +184,24 @@ export class ViewParser {
const childUnits = node.children.map(child => this.parseView(child)).flat(); const childUnits = node.children.map(child => this.parseView(child)).flat();
let unit: ViewUnit = { type, tag, props: propMap, children: childUnits }; let unit: ViewUnit = { type, tag, props: propMap, children: childUnits };
if (unit.type === 'html' && this.willParseTemplate)
unit = this.transformTemplate(unit); if (unit.type === 'html' && childUnits.length === 1 && childUnits[0].type === 'text') {
// ---- If the html unit only has one text child, merge the text into the html unit
const text = childUnits[0] as TextUnit;
unit = {
...unit,
children: [],
props: {
...unit.props,
textContent: {
value: text.content,
viewPropMap: {},
},
},
};
}
if (unit.type === 'html') unit = this.transformTemplate(unit);
this.viewUnits.push(unit); this.viewUnits.push(unit);
} }
@ -320,7 +336,8 @@ export class ViewParser {
}; };
} }
private transformTemplate(unit: ViewUnit): ViewUnit { transformTemplate(unit: ViewUnit): ViewUnit {
if (!this.willParseTemplate) return unit;
if (!this.isHTMLTemplate(unit)) return unit; if (!this.isHTMLTemplate(unit)) return unit;
unit = unit as HTMLUnit; unit = unit as HTMLUnit;
return { return {
@ -347,7 +364,7 @@ export class ViewParser {
) )
)); ));
let children: (HTMLUnit | TextUnit)[] | undefined; let children: (HTMLUnit | TextUnit)[] = [];
if (unit.children) { if (unit.children) {
children = unit.children children = unit.children
.map(unit => { .map(unit => {
@ -375,17 +392,26 @@ export class ViewParser {
*/ */
private generateMutableUnits(htmlUnit: HTMLUnit): MutableUnit[] { private generateMutableUnits(htmlUnit: HTMLUnit): MutableUnit[] {
const mutableUnits: MutableUnit[] = []; const mutableUnits: MutableUnit[] = [];
const generateMutableUnit = (unit: HTMLUnit, path: number[] = []) => { const generateMutableUnit = (unit: HTMLUnit, path: number[] = []) => {
const maxHtmlIdx = unit.children?.filter(
child => (child.type === 'html' && this.t.isStringLiteral(child.tag)) ||
child.type === 'text'
).length;
let htmlIdx = -1;
// ---- Generate mutable unit for current HTMLUnit // ---- Generate mutable unit for current HTMLUnit
unit.children?.forEach((child, idx) => { unit.children?.forEach((child) => {
if ( if (
!(child.type === 'html' && this.t.isStringLiteral(child.tag)) && !(child.type === 'html' && this.t.isStringLiteral(child.tag)) &&
!(child.type === 'text') !(child.type === 'text')
) { ) {
const idx = htmlIdx + 1 >= maxHtmlIdx ? -1 : htmlIdx + 1;
mutableUnits.push({ mutableUnits.push({
path: [...path, idx], path: [...path, idx],
...this.transformTemplate(child), ...this.transformTemplate(child),
}); });
} else {
htmlIdx++;
} }
}); });
// ---- Recursively generate mutable units for static HTMLUnit children // ---- Recursively generate mutable units for static HTMLUnit children
@ -418,6 +444,7 @@ export class ViewParser {
.filter(([, prop]) => !this.isStaticProp(prop)) .filter(([, prop]) => !this.isStaticProp(prop))
.forEach(([key, prop]) => { .forEach(([key, prop]) => {
templateProps.push({ templateProps.push({
tag: unit.tag,
name: (unit.tag as t.StringLiteral).value, name: (unit.tag as t.StringLiteral).value,
key, key,
path, path,
@ -426,9 +453,9 @@ export class ViewParser {
}); });
// ---- Recursively generate props for static HTMLUnit children // ---- Recursively generate props for static HTMLUnit children
unit.children unit.children
?.forEach((child, idx) => { ?.filter(child => child.type === 'html' && this.t.isStringLiteral(child.tag))
if (child.type !== 'html') return; .forEach((child, idx) => {
generateVariableProp(child, [...path, idx]); generateVariableProp(child as HTMLUnit, [...path, idx]);
}); });
}; };
generateVariableProp(htmlUnit, []); generateVariableProp(htmlUnit, []);
@ -489,7 +516,7 @@ export class ViewParser {
* @returns ViewUnit[] * @returns ViewUnit[]
*/ */
private parseView(node: AllowedJSXNode): ViewUnit[] { private parseView(node: AllowedJSXNode): ViewUnit[] {
return new ViewParser(this.config).parse(node); return new ViewParser({...this.config, parseTemplate:false}).parse(node);
} }

View File

@ -0,0 +1 @@
declare module "@babel/plugin-syntax-jsx"

View File

@ -15,6 +15,7 @@ export interface TextUnit {
export type MutableUnit = ViewUnit & { path: number[]} export type MutableUnit = ViewUnit & { path: number[]}
export interface TemplateProp { export interface TemplateProp {
tag: t.Expression
name: string name: string
key: string key: string
path: number[] path: number[]
@ -31,17 +32,15 @@ export interface TemplateUnit {
export interface HTMLUnit { export interface HTMLUnit {
type: 'html' type: 'html'
tag: t.Expression tag: t.Expression
content?: UnitProp props: Record<string, UnitProp>
props?: Record<string, UnitProp> children: ViewUnit[]
children?: ViewUnit[]
} }
export interface CompUnit { export interface CompUnit {
type: 'comp' type: 'comp'
tag: t.Expression tag: t.Expression
content?: UnitProp props: Record<string, UnitProp>
props?: Record<string, UnitProp> children: ViewUnit[]
children?: ViewUnit[]
} }
export interface IfBranch { export interface IfBranch {

View File

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