diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c0e4ddf --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +jobs: + test: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Enable Corepack + run: corepack enable + - name: Install dependencies + run: yarn install --immutable + - name: Type check + run: yarn typecheck + - name: Test + run: yarn test diff --git a/.gitignore b/.gitignore index 9b26ed0..4ee8c0a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,11 @@ node_modules -lib \ No newline at end of file +lib + +# Yarn (Berry) - 不使用 Zero-Installs +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions +.pnp.* diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 0000000..063ed60 --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1,8 @@ +approvedGitRepositories: + - "**" + +enableScripts: true + +nodeLinker: node-modules + +npmMinimalAgeGate: 0 diff --git a/package.json b/package.json index cde492b..d786833 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,12 @@ "types.d.ts" ], "scripts": { - "test": "echo \"no test specified\"", + "build": "tsc", + "typecheck": "tsc --noEmit", + "test": "yarn test:unit", + "test:unit": "yarn build && node --test ./test/**/*.test.mjs", "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s", - "version": "npm run changelog && git add CHANGELOG.md" + "version": "yarn changelog && git add CHANGELOG.md" }, "dependencies": { "change-case-all": "^2.1.0", @@ -27,5 +30,6 @@ "devDependencies": { "@types/react": "^18.2.23", "typescript": "^5.4.2" - } + }, + "packageManager": "yarn@4.15.0" } diff --git a/src/binding.ts b/src/binding.ts index 6d1e7bc..feb45e9 100644 --- a/src/binding.ts +++ b/src/binding.ts @@ -115,7 +115,7 @@ export type Binding = BindingBase & { }; export type ObjectBinding = Binding; -const typeNameMap = { +const typeNameMap: Partial> = { [CType.Double]: "double", [CType.Size]: "size_t", [CType.Int]: "int", @@ -137,9 +137,9 @@ export function getObjectTypeName(obj: ObjectBinding) { } switch (init.kind) { case SyntaxKind.NumericLiteral: - return typeNameMap[init.type]; + return typeNameMap[init.type] || "unknown"; case SyntaxKind.StringLiteral: - return typeNameMap[CType.String]; + return typeNameMap[CType.String] || "char*"; case SyntaxKind.NewExpression: return init.identifier; default: @@ -330,13 +330,9 @@ function compileFunction({ return [ signature, "{", - formatFuncBody( - locals.map((item) => compiler.compileVariableDeclaration(item)) - ), + formatFuncBody(locals.map((item) => compileVariableDeclaration(item))), formatFuncBody(body), - formatFuncBody( - locals.map((item) => compiler.compileObjectDestroyer(item.initializer)) - ), + formatFuncBody(locals.map((item) => compileObjectDestroyer(item.initializer))), "}", ] .filter(Boolean) @@ -347,7 +343,7 @@ function compileComponentMethod({ ctx, name, args = "", - body = ctx.body, + body, thatId = "w", }: { ctx?: FunctionContext; @@ -357,16 +353,17 @@ function compileComponentMethod({ body?: string[]; }) { const className = getComponentContext().name; + const methodBody = [ + `${className}_react_t *_that = ui_widget_get_data(${thatId}, ${className}_proto)`, + ...(body || ctx?.body || []), + ctx?.hasStateOperation ? `${className}_react_update(${thatId})` : undefined, + ].filter((line): line is string => Boolean(line)); return compileFunction({ locals: ctx?.locals || [], signature: `static void ${className}_${ name || ctx?.name || "unnamed_func" }(ui_widget_t *w${args})`, - body: [ - `${className}_react_t *_that = ui_widget_get_data(${thatId}, ${className}_proto)`, - ...(body || []), - ctx?.hasStateOperation && `${className}_react_update(${thatId})`, - ], + body: methodBody, }); } @@ -492,8 +489,8 @@ export function pushFunctionComponent(ctx: FunctionContext) { contextList.push(ctx); } -export function popFunctionComponent(ctx: FunctionContext) { - contextList.push(ctx); +export function popFunctionComponent() { + contextList.pop(); } export function setComponentContext(ctx: ComponentContext) { @@ -515,13 +512,16 @@ function createStringLiteral(value: string | null = null): StringLiteral { }; } -function createBinding(meta: BindingMeta, data: Record = {}) { +function createBinding( + meta: BindingMeta, + data: T = {} as T +) { const binding = new Proxy( - { __meta__: meta, ...data }, + { __meta__: meta, ...data } as Record, { get(target, p, receiver) { if (p in target) { - return target[p]; + return Reflect.get(target, p, receiver); } if (typeof p !== "string") { return null; @@ -533,7 +533,7 @@ function createBinding(meta: BindingMeta, data: Record = {}) { name: p, }); }, - construct(target, args) { + construct(target: { __meta__: BindingMeta }, args) { if (target.__meta__.kind !== BindingKind.Object) { throw new SyntaxError("Module cannot be used as a constructor"); } @@ -542,28 +542,32 @@ function createBinding(meta: BindingMeta, data: Record = {}) { } return createVariable(meta.name, args); }, - apply(target: Binding, _thisArg, args) { + apply(target: { __meta__: BindingMeta }, _thisArg, args) { if (target.__meta__.kind === BindingKind.Module) { throw new SyntaxError("Module cannot be used as a function"); } const ctx = getFunctionContext(); ctx.body.push( compileCallExpression( - resolveBindingIdentify(target as ObjectBinding), + resolveBindingIdentify(target as unknown as ObjectBinding), args ) ); + return undefined; }, } - ) as Binding; + ) as unknown as Binding & T; return binding; } -function createObjectBinding(meta: Omit, data = {}) { +function createObjectBinding( + meta: Omit, + data: T = {} as T +): ObjectBinding & T { return createBinding( { ...meta, kind: BindingKind.Object }, data - ) as ObjectBinding; + ) as ObjectBinding & T; } export function isObjectBinding(val: any): val is ObjectBinding { diff --git a/src/compile.ts b/src/compile.ts index 17797ac..74300a7 100644 --- a/src/compile.ts +++ b/src/compile.ts @@ -7,6 +7,7 @@ import { getComponentContext, setComponentContext, isObjectBinding, + ObjectBinding, compiler, } from "./binding.js"; import fmt from "./fmt.js"; @@ -25,19 +26,26 @@ function isElement(el: ReactElement): el is Element { return typeof el.type === "function"; } +interface Node { + type: string; + name: string; + text: string; + attributes: Record; + children: Node[]; + isRoot: boolean; +} + function createNode(name = "") { return { type: "element", name, text: "", - attributes: {} as Record, - children: [], + attributes: {} as Record, + children: [] as Node[], isRoot: false, }; } -type Node = ReturnType; - function allocRef(ctx: ComponentContext, node: Node, prefix = "ref_") { if (node.isRoot) { return { @@ -80,10 +88,10 @@ function transformNodeStyle(node: Node, style: Record) { }); } -function transformNodeChildren(node: Node, rawChildren: ReactNode[]) { +function transformNodeChildren(node: Node, rawChildren: ReactNode) { let isPureText = true; let needFormat = true; - const children = []; + const children: (string | ObjectBinding | ReactElement)[] = []; React.Children.forEach(rawChildren, (child) => { switch (typeof child) { @@ -120,26 +128,28 @@ function transformNodeChildren(node: Node, rawChildren: ReactNode[]) { return; } - node.children = children.map((child) => { - if (typeof child === "string") { - return { - ...createNode("text"), - text: child, - }; - } - if (isObjectBinding(child)) { - const str = fmt(child); - const childNode = createNode("text"); - const ref = allocRef(ctx, childNode); + node.children = children + .map((child) => { + if (typeof child === "string") { + return { + ...createNode("text"), + text: child, + }; + } + if (isObjectBinding(child)) { + const str = fmt(child); + const childNode = createNode("text"); + const ref = allocRef(ctx, childNode); - ctx.body.push(`ui_widget_set_text(${ref.cName}, ${str.__meta__.name})`); - return childNode; - } - return transformReactNode(child); - }); + ctx.body.push(`ui_widget_set_text(${ref.cName}, ${str.__meta__.name})`); + return childNode; + } + return transformReactNode(child); + }) + .filter((n): n is Node => n !== undefined); } -function transformReactNode(el: ReactNode, isRoot = false) { +function transformReactNode(el: ReactNode, isRoot = false): Node | undefined { let node = createNode(); node.isRoot = isRoot; @@ -169,15 +179,16 @@ function transformReactNode(el: ReactNode, isRoot = false) { return; } - const attrMap = { + const props = el.props as Record; + const attrMap: Record = { className: "class", $ref: "ref", }; - const handlerNames = []; + const handlerNames: string[] = []; - Object.keys(el.props).forEach((propKey) => { + Object.keys(props).forEach((propKey) => { let key = propKey; - let value = el.props[key]; + let value = props[key]; if (key in attrMap) { key = attrMap[key]; @@ -204,24 +215,19 @@ function transformReactNode(el: ReactNode, isRoot = false) { ref.current.type = typeof el.type === "string" ? el.type : el.type.name; } } - if (el.props.children) { - transformNodeChildren(node, el.props.children); + if (props.children) { + transformNodeChildren(node, props.children); } - if (el.props.style) { - if (typeof el.props.style !== "object") { + if (props.style) { + if (typeof props.style !== "object") { throw SyntaxError( - `The style attribute value must be an object, not ${typeof el.props - .style}` + `The style attribute value must be an object, not ${typeof props.style}` ); } - transformNodeStyle(node, el.props.style); + transformNodeStyle(node, props.style); } handlerNames.forEach((name) => { - transformEventHandler( - node, - name.substring(2).toLocaleLowerCase(), - el.props[name] - ); + transformEventHandler(node, name.substring(2).toLocaleLowerCase(), props[name]); }); return node; } @@ -296,18 +302,17 @@ export default function compile( setComponentContext(ctx); - let el: ReactElement; - switch (options?.target) { - case "AppRouter": - el = componentFunc({ - ...props, - children: React.createElement(RouterView), - }); - break; - default: - el = componentFunc(props); - break; - } + const el: ReactElement = (() => { + switch (options?.target) { + case "AppRouter": + return componentFunc({ + ...props, + children: React.createElement(RouterView), + }); + default: + return componentFunc(props); + } + })(); const hasBaseType = el.type !== "div" && el.type !== Widget; return { name: options.name || ctx.name, diff --git a/src/fmt.ts b/src/fmt.ts index 378bcba..2938748 100644 --- a/src/fmt.ts +++ b/src/fmt.ts @@ -1,5 +1,6 @@ import { CType, + ObjectBinding, Value, factory, getComponentContext, @@ -7,7 +8,7 @@ import { stringifyValue, } from "./binding.js"; -export default function fmt(...args: Value[]) { +export default function fmt(...args: Value[]): ObjectBinding { const component = getComponentContext(); const ctx = getFunctionContext(); const str = factory.createStringVariable(); diff --git a/src/jsx-runtime.ts b/src/jsx-runtime.ts index c9278a4..71fbd1b 100644 --- a/src/jsx-runtime.ts +++ b/src/jsx-runtime.ts @@ -5,14 +5,9 @@ import { ObjectBinding, isObjectBinding } from "./binding.js"; type JSXFactor = ( type: React.ElementType, props: Record, - key?: string + key?: React.Key ) => React.ReactElement; -declare module "react/jsx-runtime" { - export const jsx: JSXFactor; - export const jsxs: JSXFactor; -} - export function JSXObjectBinding({ value }: { value: ObjectBinding }) { return `[JSXObjectBinding ${value.__meta__.name}]`; } @@ -37,7 +32,11 @@ function transformElementProps(props: Record) { } export const jsx: JSXFactor = (type, props, key) => - rt.jsx(type, transformElementProps(props), key); + (rt as unknown as { jsx: JSXFactor }).jsx(type, transformElementProps(props), key); export const jsxs: JSXFactor = (type, props, key) => - rt.jsxs(type, transformElementProps(props), key); + (rt as unknown as { jsxs: JSXFactor }).jsxs( + type, + transformElementProps(props), + key + ); diff --git a/src/useRef.ts b/src/useRef.ts index a3cac90..48ae3e5 100644 --- a/src/useRef.ts +++ b/src/useRef.ts @@ -3,6 +3,7 @@ import { factory, getComponentContext, getFunctionContext, + ObjectBinding, stringifyValue, } from "./binding.js"; @@ -14,7 +15,7 @@ class WidgetInstance { this.ident = ident; } - getTextInputValue() { + getTextInputValue(): ObjectBinding { const ctx = getFunctionContext(); const str = factory.createStringVariable(); const len = factory.createNumericVariable( @@ -36,13 +37,13 @@ class WidgetInstance { return str; } - setTextInputValue(value: string) { + setTextInputValue(value: string | ObjectBinding) { const ctx = getFunctionContext(); const str = stringifyValue(value); - ctx.body.push(`ui_textinput_set_text(${this.ident}, ${str.__meta__.name}`); + ctx.body.push(`ui_textinput_set_text(${this.ident}, ${str.__meta__.name})`); } - get value() { + get value(): ObjectBinding { switch (this.type) { case "textinput": return this.getTextInputValue(); @@ -52,7 +53,7 @@ class WidgetInstance { throw SyntaxError(`Unable to get value of ${this.type} type component`); } - set value(newValue: any) { + set value(newValue: string | ObjectBinding) { switch (this.type) { case "textinput": this.setTextInputValue(newValue); @@ -64,7 +65,12 @@ class WidgetInstance { } } -export default function useRef() { +interface WidgetRefBinding { + name: string; + current: WidgetInstance; +} + +export default function useRef(): ObjectBinding & WidgetRefBinding { const ctx = getComponentContext(); const name = ctx.refNames[ctx.refs.length] || `ref_${ctx.refs.length}`; const cName = `_that->refs.${name}`; @@ -72,14 +78,11 @@ export default function useRef() { ctx.refs.push(name); ctx.headerFiles.add(''); ctx.headerFiles.add(''); - return factory.createObjectBinding( + return factory.createObjectBinding( { name: cName, type: CType.Object, }, { name, current: new WidgetInstance(cName) } - ) as unknown as { - name: string; - current: WidgetInstance; - }; + ); } diff --git a/src/useState.ts b/src/useState.ts index e8f3aa2..142d7b4 100644 --- a/src/useState.ts +++ b/src/useState.ts @@ -84,7 +84,7 @@ export default function useState(initialValue: Value, valueType?: CType) { value = factory.createStringBinding(stateCName, initialValue); break; case "number": - if (isNumericType(valueType)) { + if (valueType !== undefined && isNumericType(valueType)) { value = factory.createNumericBinding( stateCName, initialValue, @@ -92,6 +92,7 @@ export default function useState(initialValue: Value, valueType?: CType) { ); break; } + throw new SyntaxError(`Unsupported numeric CType: ${valueType}`); default: throw new SyntaxError(`Unsupported type: ${typeof initialValue}`); } diff --git a/src/widgets.tsx b/src/widgets.tsx index 5896f6e..06509bf 100644 --- a/src/widgets.tsx +++ b/src/widgets.tsx @@ -55,6 +55,10 @@ export type LinkProps = LinkAttributes; export type RouterViewProps = RouterViewAttributes; export type ScrollbarProps = ScrollbarAttributes; +export type FunctionWidget = ((props: T) => React.ReactElement) & { + shouldPreRender?: boolean; +}; + export interface RouterLinkProps extends WidgetBaseProps { to: string; exact?: boolean; @@ -66,45 +70,36 @@ export interface TextInputProps extends WidgetBaseProps { placeholder?: string; } -export function Text(props: WidgetBaseProps) { - return ; -} - -export function TextInput(props: TextInputProps) { - return ; -} - -export function Link(props: LinkProps) { - return ; -} - -export function Button(props: WidgetBaseProps) { - return