diff --git a/.changeset/curly-donkeys-invent.md b/.changeset/curly-donkeys-invent.md new file mode 100644 index 0000000..bc7e517 --- /dev/null +++ b/.changeset/curly-donkeys-invent.md @@ -0,0 +1,18 @@ +--- +"@geajs/a2ui": minor +"@geajs/core": patch +"@geajs/vite-plugin": patch +--- + +### @geajs/a2ui (minor) + +- **New package**: A2UI v0.9.1 renderer for Gea. Interprets agent-authored UI composition onto compiled Gea leaves — leaves come from `@geajs/ui` and local `.tsx`, state is a Gea `Store` addressed by JSON Pointer, and only the adjacency list is interpreted (one parent-directed walk at mount). + +### @geajs/core (patch) + +- **Export runtime types from `./compiler-runtime`**: `Disposer`, `Entry`, `ItemObservable`, and `KeyedListConfig` were reachable at runtime but had no type exports, so external consumers of `mount()` / `keyedList()` could not type their call sites. +- **`Change.itemDirty`**: declared the field that `keyed-list`'s `itemDirtyOnly` fast path reads. It fixes a `tsc --noEmit` failure in `keyed-list.ts`. No runtime behavior change — nothing sets the field today, so that path remains disabled. + +### @geajs/vite-plugin (patch) + +- Version bump only (linked to `@geajs/core`). diff --git a/package-lock.json b/package-lock.json index 913c075..e1ccc64 100644 --- a/package-lock.json +++ b/package-lock.json @@ -191,6 +191,7 @@ "integrity": "sha512-gA8oJOV1LnQQkDf91iebNnFInHuW0gRPEgLSOQ7EfipCEjYTHm5swm1DlH9H5RaRw4RrHuzHBegnlzc0MAstcg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@algolia/client-common": "5.52.1", "@algolia/requester-browser-xhr": "5.52.1", @@ -957,6 +958,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -1003,6 +1005,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -1058,44 +1061,11 @@ } } }, - "node_modules/@docsearch/js/node_modules/@types/react": { - "version": "18.3.28", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", - "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.2.2" - } - }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1114,7 +1084,6 @@ "os": [ "aix" ], - "peer": true, "engines": { "node": ">=18" } @@ -1131,7 +1100,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -1148,7 +1116,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -1165,7 +1132,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -1182,7 +1148,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -1199,7 +1164,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -1216,7 +1180,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -1233,7 +1196,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -1250,7 +1212,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1267,7 +1228,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1284,7 +1244,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1301,7 +1260,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1318,7 +1276,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1335,7 +1292,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1352,7 +1308,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1369,7 +1324,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1386,7 +1340,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1403,7 +1356,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -1420,7 +1372,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -1437,7 +1388,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -1454,7 +1404,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -1471,7 +1420,6 @@ "os": [ "openharmony" ], - "peer": true, "engines": { "node": ">=18" } @@ -1488,7 +1436,6 @@ "os": [ "sunos" ], - "peer": true, "engines": { "node": ">=18" } @@ -1505,7 +1452,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -1522,7 +1468,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -1539,7 +1484,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -1714,6 +1658,10 @@ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, + "node_modules/@geajs/a2ui": { + "resolved": "packages/a2ui", + "link": true + }, "node_modules/@geajs/core": { "resolved": "packages/gea", "link": true @@ -3374,8 +3322,7 @@ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/@types/react": { "version": "19.2.14", @@ -3459,6 +3406,7 @@ "integrity": "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/types": "8.59.2", @@ -4490,6 +4438,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4530,6 +4479,7 @@ "integrity": "sha512-fHA8+kXTbjagw3jkLiaS7KKrH8qe2DyOsiUhGlN4cdT77PEsfqXZl7ewDk1hsg+pJnPlnE50XtLxjR91iJOpmg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@algolia/abtesting": "1.18.1", "@algolia/client-abtesting": "5.52.1", @@ -5564,6 +5514,7 @@ "integrity": "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -5620,6 +5571,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -6065,6 +6017,7 @@ "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "tabbable": "^6.4.0" } @@ -6851,7 +6804,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6872,7 +6824,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6893,7 +6844,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6914,7 +6864,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6935,7 +6884,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6956,7 +6904,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6977,7 +6924,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6998,7 +6944,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -7019,7 +6964,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -7040,7 +6984,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -7061,7 +7004,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8437,6 +8379,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -8516,6 +8459,7 @@ "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -8899,6 +8843,7 @@ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", "license": "MIT", + "peer": true, "dependencies": { "@oxc-project/types": "=0.127.0", "@rolldown/pluginutils": "1.0.0-rc.17" @@ -9047,6 +8992,7 @@ "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -9448,6 +9394,7 @@ "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@pkgr/core": "^0.2.9" }, @@ -10345,7 +10292,6 @@ "os": [ "aix" ], - "peer": true, "engines": { "node": ">=18" } @@ -10362,7 +10308,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -10379,7 +10324,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -10396,7 +10340,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -10413,7 +10356,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -10430,7 +10372,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -10447,7 +10388,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -10464,7 +10404,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -10481,7 +10420,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -10498,7 +10436,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -10515,7 +10452,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -10532,7 +10468,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -10549,7 +10484,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -10566,7 +10500,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -10583,7 +10516,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -10600,7 +10532,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -10617,7 +10548,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -10634,7 +10564,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -10651,7 +10580,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -10668,7 +10596,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -10685,7 +10612,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -10702,7 +10628,6 @@ "os": [ "openharmony" ], - "peer": true, "engines": { "node": ">=18" } @@ -10719,7 +10644,6 @@ "os": [ "sunos" ], - "peer": true, "engines": { "node": ">=18" } @@ -10736,7 +10660,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -10753,7 +10676,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -10770,7 +10692,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -10827,7 +10748,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -10851,6 +10771,7 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11173,6 +11094,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -11795,6 +11717,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -11939,6 +11862,7 @@ "integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.34", "@vue/compiler-sfc": "3.5.34", @@ -12128,6 +12052,39 @@ "url": "https://github.com/sponsors/wooorm" } }, + "packages/a2ui": { + "name": "@geajs/a2ui", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@geajs/core": "^1.4.0", + "@geajs/ui": "^0.2.9", + "@geajs/vite-plugin": "^1.4.1", + "esbuild": "^0.28.0", + "jsdom": "^29.0.0", + "tsdown": "^0.21.2", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + }, + "peerDependencies": { + "@geajs/core": "^1.4.0", + "@geajs/ui": "^0.2.9" + } + }, + "packages/a2ui/node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "packages/create-gea": { "version": "1.0.3", "license": "MIT", @@ -12137,7 +12094,7 @@ }, "packages/gea": { "name": "@geajs/core", - "version": "1.3.0", + "version": "1.4.0", "license": "MIT", "dependencies": { "@types/react": "^19.0.0" @@ -12168,7 +12125,7 @@ }, "packages/gea-ssr": { "name": "@geajs/ssr", - "version": "1.0.2", + "version": "1.0.4", "license": "MIT", "dependencies": { "devalue": "^5.6.4", @@ -12270,7 +12227,7 @@ }, "packages/vite-plugin-gea": { "name": "@geajs/vite-plugin", - "version": "1.3.1", + "version": "1.4.1", "license": "MIT", "dependencies": { "@acemir/cssom": "^0.9.31", diff --git a/packages/a2ui/README.md b/packages/a2ui/README.md new file mode 100644 index 0000000..c112f9f --- /dev/null +++ b/packages/a2ui/README.md @@ -0,0 +1,45 @@ +# @geajs/a2ui + +An [A2UI](https://a2ui.org) v0.9.1 renderer for Gea. + +Three layers, one interpreted: + +- **Leaves** are compiled Gea components (`@geajs/ui` and local `.tsx`), instantiated by reference through `mount()`. +- **State** is a Gea `Store` (a Proxy), addressed by JSON Pointer. +- **Composition** — the agent-authored adjacency list — is the only interpreted part: one parent-directed walk at mount, then idle. + +The interpreter owns no reactivity. It passes lazy **prop thunks** to `mount()`; the compiled leaf reads them inside its own binding, which runs under Gea's tracking scope, so store reads auto-subscribe. + +## Usage + +```ts +import { SurfaceRegistry, dispatch, createBasicCatalog, BUILTIN_FUNCTIONS } from '@geajs/a2ui' + +const registry = new SurfaceRegistry(() => document.getElementById('app')!) +const opts = { + catalog: createBasicCatalog(), + functions: BUILTIN_FUNCTIONS, + transport: { sendAction: (a) => socket.send(JSON.stringify({ version: 'v0.9.1', action: a })) }, +} + +for await (const envelope of stream) dispatch(envelope, registry, opts) +``` + +## Scope + +Happy path only: one surface, basic catalog, in-order delivery, schema-valid input, `root` present in the mounting `updateComponents`. + +Not implemented: schema validation and the `VALIDATION_FAILED` loop, out-of-order buffering beyond the root rule, capability negotiation, multi-surface orchestration, `deleteSurface` teardown. + +`Tabs`, `Modal`, `Slider`, and `ChoicePicker` are registered so the catalog covers all 18 spec types, but their props pass straight through. Their Zag prop shapes have not been mapped, and because `Slider` and `ChoicePicker` are input components, **their write-back (`onValueChange`) is not wired** — two-way binding does not work for them yet. Their tests assert registration only, never behavior. + +Accepts both `v0.9` and `v0.9.1` envelopes. A2UI v1.0 is a release candidate; `dispatcher.ts` is the single place that reads `version`. + +## Notes for contributors + +Two Gea details the renderer depends on, both verified against the runtime: + +- **Containers must not render `{props.children}`.** The compiler turns a child expression into a reactive text marker whose placeholder (`"0"`) stays in the DOM when no `children` thunk is passed. Local containers omit the slot; gea-ui containers get `children: () => ''`. +- **`keyedList` needs the array-path form**, not the `() => any[]` getter. The getter branch reconciles without change records, so `reverse()`/`sort()` take the dirty-scan path and never move DOM nodes. + +Tests run under `node:test` with jsdom. `tests/gea-loader.ts` registers a Node module hook that applies `geaPlugin().transform` to `.tsx` — `tsx` alone transpiles JSX but does not compile Gea components, and an uncompiled leaf mounts to nothing. Gea delegates DOM events at the document root, so every DOM test must attach its host to `document.body`. diff --git a/packages/a2ui/package.json b/packages/a2ui/package.json new file mode 100644 index 0000000..43a5415 --- /dev/null +++ b/packages/a2ui/package.json @@ -0,0 +1,39 @@ +{ + "name": "@geajs/a2ui", + "version": "0.1.0", + "description": "A2UI v0.9.1 renderer for Gea", + "type": "module", + "main": "dist/index.mjs", + "module": "dist/index.mjs", + "types": "dist/index.d.mts", + "files": ["dist"], + "exports": { + ".": { + "source": "./src/index.ts", + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + } + }, + "publishConfig": { "access": "public" }, + "license": "MIT", + "scripts": { + "build": "tsdown --config-loader unrun", + "test": "tsx --conditions source --import ./tests/preload.ts --test tests/*.test.ts", + "ts-type-check": "tsc --noEmit", + "lint": "eslint src" + }, + "peerDependencies": { + "@geajs/core": "^1.4.0", + "@geajs/ui": "^0.2.9" + }, + "devDependencies": { + "@geajs/core": "^1.4.0", + "@geajs/ui": "^0.2.9", + "@geajs/vite-plugin": "^1.4.1", + "esbuild": "^0.28.0", + "jsdom": "^29.0.0", + "tsdown": "^0.21.2", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/packages/a2ui/src/actions.ts b/packages/a2ui/src/actions.ts new file mode 100644 index 0000000..ad1510a --- /dev/null +++ b/packages/a2ui/src/actions.ts @@ -0,0 +1,35 @@ +import { resolveOnce, type BindingContext } from './binding' +import type { Action, ClientAction, ComponentId, Transport } from './types' + +export interface ActionContext { + surfaceId: string + sourceComponentId: ComponentId + binding: BindingContext + transport: Transport + /** Injected for determinism in tests. */ + now: () => string +} + +/** + * `event` goes up the transport with its context resolved to concrete values. + * `functionCall` stays local — it names a pre-registered function and never + * reaches the server. + */ +export function dispatchAction(action: Action, ctx: ActionContext): void { + if ('functionCall' in action) { + resolveOnce(action.functionCall, ctx.binding) + return + } + const context: Record = {} + for (const [key, value] of Object.entries(action.event.context ?? {})) { + context[key] = resolveOnce(value, ctx.binding) + } + const message: ClientAction = { + name: action.event.name, + surfaceId: ctx.surfaceId, + sourceComponentId: ctx.sourceComponentId, + timestamp: ctx.now(), + context, + } + ctx.transport.sendAction(message) +} diff --git a/packages/a2ui/src/binding.ts b/packages/a2ui/src/binding.ts new file mode 100644 index 0000000..da2c13c --- /dev/null +++ b/packages/a2ui/src/binding.ts @@ -0,0 +1,46 @@ +import { isDataBinding, isFunctionCall, type DynamicValue, type FunctionCall } from './types' +import { resolveRead, type Scope } from './scope' +import type { RegisteredFunction } from './functions' + +export interface BindingContext { + store: Record + scope: Scope + functions: Record +} + +function callFunction(fc: FunctionCall, ctx: BindingContext): unknown { + const fn = ctx.functions[fc.call] + if (!fn) throw new Error(`Unknown A2UI function: ${fc.call}`) + const args: Record = {} + // `args` is optional per schema — only `call` is required. + for (const [key, value] of Object.entries(fc.args ?? {})) { + args[key] = resolveOnce(value, ctx) + } + return fn(args) +} + +/** + * Turn a DynamicValue into a lazy prop thunk. + * + * The thunk MUST stay lazy. `mount()` hands it to the compiled leaf, which + * invokes it inside its own reactive binding (running under `withTracking`). + * The store read therefore happens in a tracking scope and auto-subscribes. + * Pre-computing the value here would silently kill reactivity. + */ +export function toThunk(value: DynamicValue, ctx: BindingContext): () => unknown { + if (isDataBinding(value)) { + const path = value.path + return () => resolveRead(ctx.store, path, ctx.scope) + } + if (isFunctionCall(value)) { + return () => callFunction(value, ctx) + } + return () => value +} + +/** Eager resolution. Only for action `context`, which is sent over the wire. */ +export function resolveOnce(value: DynamicValue, ctx: BindingContext): unknown { + if (isDataBinding(value)) return resolveRead(ctx.store, value.path, ctx.scope) + if (isFunctionCall(value)) return callFunction(value, ctx) + return value +} diff --git a/packages/a2ui/src/catalog.ts b/packages/a2ui/src/catalog.ts new file mode 100644 index 0000000..a2e0c3f --- /dev/null +++ b/packages/a2ui/src/catalog.ts @@ -0,0 +1,35 @@ +import type { ComponentDefinition } from './types' +import { toThunk, type BindingContext } from './binding' + +export type PropThunks = Record unknown> + +/** Writes `next` to the Store path this component's `value` is bound to. */ +export type WriteFn = (next: unknown) => void + +export interface CatalogEntry { + typeName: string + /** A compiled Gea component class, passed by reference to `mount()`. */ + component: unknown + /** + * `write` is non-null exactly when the definition has a DataBinding `value`. + * The entry attaches it to whichever handler prop its component actually + * uses — there is no universal change event. + */ + mapProps(def: ComponentDefinition, ctx: BindingContext, write: WriteFn | null): PropThunks +} + +export type Catalog = Map + +const STRUCTURAL_KEYS = new Set(['id', 'component', 'child', 'children']) + +/** Every non-structural property is a DynamicValue and becomes a lazy thunk. */ +export function resolveProps(def: ComponentDefinition, ctx: BindingContext): PropThunks { + const props: PropThunks = {} + for (const [key, value] of Object.entries(def)) { + if (STRUCTURAL_KEYS.has(key)) continue + props[key] = toThunk(value as never, ctx) + } + return props +} + +export { createBasicCatalog } from './catalog/basic' diff --git a/packages/a2ui/src/catalog/basic.ts b/packages/a2ui/src/catalog/basic.ts new file mode 100644 index 0000000..738b5a4 --- /dev/null +++ b/packages/a2ui/src/catalog/basic.ts @@ -0,0 +1,140 @@ +// Per-component subpaths, not the `@geajs/ui` barrel. The barrel eagerly loads +// every component, including NumberInput, whose @zag-js dependency fails to +// resolve its named exports under Node's ESM loader. gea-ui ships one entry per +// component (tsdown.config.ts), so these subpaths are supported and tree-shaken. +import Button from '@geajs/ui/button' +import { Card } from '@geajs/ui/card' +import Checkbox from '@geajs/ui/checkbox' +import Dialog from '@geajs/ui/dialog' +import Input from '@geajs/ui/input' +import Select from '@geajs/ui/select' +import Separator from '@geajs/ui/separator' +import Slider from '@geajs/ui/slider' +import Tabs from '@geajs/ui/tabs' +import type { ComponentDefinition } from '../types' +import type { BindingContext } from '../binding' +import { + resolveProps, + type Catalog, + type CatalogEntry, + type PropThunks, + type WriteFn, +} from '../catalog' +import AudioPlayer from '../components/audio-player' +import Column from '../components/column' +import DateTimeInput from '../components/date-time-input' +import Icon from '../components/icon' +import Image from '../components/image' +import List from '../components/list' +import Row from '../components/row' +import Text from '../components/text' +import Video from '../components/video' + +type MapProps = (def: ComponentDefinition, ctx: BindingContext, write: WriteFn | null) => PropThunks + +function entry(typeName: string, component: unknown, mapProps: MapProps = resolveProps): CatalogEntry { + return { typeName, component, mapProps } +} + +/** A2UI Button variants are not gea-ui's enum (`primary` is not a member). */ +const BUTTON_VARIANTS: Record = { + primary: 'default', + secondary: 'secondary', + destructive: 'destructive', + outline: 'outline', + ghost: 'ghost', + link: 'link', +} + +/** + * gea-ui containers render `{props.children}`, whose compiled marker shows up + * as a literal "0" text node when no `children` thunk is supplied. The + * interpreter appends real children into `inst.el`, so bind the slot to an + * empty string to erase the marker. Local containers omit the slot entirely. + */ +const EMPTY_SLOT: MapProps = (def, ctx) => { + const props = resolveProps(def, ctx) + props.children = () => '' + return props +} + +/** + * A2UI prop names do not match @geajs/ui prop names, so each entry owns its + * translation AND its write-back handler. instantiate.ts decides WHERE a + * write lands; the entry decides WHICH prop carries it. + */ +export function createBasicCatalog(): Catalog { + const catalog: Catalog = new Map() + + catalog.set('Text', entry('Text', Text)) + catalog.set('Row', entry('Row', Row)) + catalog.set('Column', entry('Column', Column)) + catalog.set('Card', entry('Card', Card, EMPTY_SLOT)) + + catalog.set( + 'Button', + entry('Button', Button, (def, ctx) => { + const props = EMPTY_SLOT(def, ctx, null) + if (props.variant) { + const source = props.variant + props.variant = () => BUTTON_VARIANTS[String(source())] ?? 'default' + } + return props + }), + ) + + catalog.set( + 'TextField', + entry('TextField', Input, (def, ctx, write) => { + const props = resolveProps(def, ctx) + // A2UI `label` renders as the Input placeholder; `value` binds through. + if (props.label) props.placeholder = props.label + // Input calls `props.onInput?.(e)` with a DOM Event (input.tsx:19). + if (write) props.onInput = () => (e: Event) => write((e.target as HTMLInputElement).value) + return props + }), + ) + + catalog.set( + 'CheckBox', + entry('CheckBox', Checkbox, (def, ctx, write) => { + const props = resolveProps(def, ctx) + // Zag's `value` is the HTML FORM VALUE string (defaults to 'on'), not the + // checked state (checkbox.tsx:23). A2UI's `value` is the checked state. + if (props.value) props.checked = props.value + delete props.value + // Zag reports changes via onCheckedChange(details) (checkbox.tsx:24). + if (write) props.onCheckedChange = () => (d: { checked: boolean }) => write(d.checked) + return props + }), + ) + + catalog.set('Image', entry('Image', Image)) + catalog.set('Icon', entry('Icon', Icon)) + catalog.set('Video', entry('Video', Video)) + catalog.set('AudioPlayer', entry('AudioPlayer', AudioPlayer)) + catalog.set('List', entry('List', List)) + catalog.set('Divider', entry('Divider', Separator)) + + // DateTimeInput is a local leaf, so we own its handler prop: plain `onInput`. + catalog.set( + 'DateTimeInput', + entry('DateTimeInput', DateTimeInput, (def, ctx, write) => { + const props = resolveProps(def, ctx) + if (write) props.onInput = () => (e: Event) => write((e.target as HTMLInputElement).value) + return props + }), + ) + + // Registered so the catalog is complete (18 types). Their prop translations + // are pass-through: Tabs/Modal/Slider/ChoicePicker have Zag prop shapes that + // have NOT been read yet, and Slider/ChoicePicker are input components whose + // write-back (onValueChange) is therefore not wired. Follow-up plan required + // before these are used in anger — see the README's Scope section. + catalog.set('Tabs', entry('Tabs', Tabs)) + catalog.set('Modal', entry('Modal', Dialog)) + catalog.set('Slider', entry('Slider', Slider)) + catalog.set('ChoicePicker', entry('ChoicePicker', Select)) + + return catalog +} diff --git a/packages/a2ui/src/components/audio-player.tsx b/packages/a2ui/src/components/audio-player.tsx new file mode 100644 index 0000000..3f31285 --- /dev/null +++ b/packages/a2ui/src/components/audio-player.tsx @@ -0,0 +1,11 @@ +import { Component } from '@geajs/core' + +interface AudioPlayerProps { + url?: string +} + +export default class AudioPlayer extends Component { + template(props: AudioPlayerProps) { + return + } +} diff --git a/packages/a2ui/src/components/column.tsx b/packages/a2ui/src/components/column.tsx new file mode 100644 index 0000000..30d1521 --- /dev/null +++ b/packages/a2ui/src/components/column.tsx @@ -0,0 +1,16 @@ +import { Component } from '@geajs/core' + +/** + * A2UI `Column`. Renders an empty element; the interpreter appends children + * into `inst.el` directly (see instantiate.ts). + * + * Deliberately no `{props.children}` expression: the compiler turns a child + * expression into a reactive text marker, and when no `children` thunk is + * passed the marker's placeholder ("0") stays in the DOM as a literal text + * node. A container the interpreter fills has no use for the slot anyway. + */ +export default class Column extends Component { + template() { + return
+ } +} diff --git a/packages/a2ui/src/components/date-time-input.tsx b/packages/a2ui/src/components/date-time-input.tsx new file mode 100644 index 0000000..fd51708 --- /dev/null +++ b/packages/a2ui/src/components/date-time-input.tsx @@ -0,0 +1,24 @@ +import { Component } from '@geajs/core' + +interface DateTimeInputProps { + value?: string + onInput?: (e: Event) => void +} + +/** + * The DOM event is wired with `input=`, Gea's delegated-event attribute — + * `onInput=` would compile to a plain DOM attribute and never fire. Same + * convention as gea-ui's `Input` (input.tsx:19). + */ +export default class DateTimeInput extends Component { + template(props: DateTimeInputProps) { + return ( + props.onInput?.(e)} + /> + ) + } +} diff --git a/packages/a2ui/src/components/icon.tsx b/packages/a2ui/src/components/icon.tsx new file mode 100644 index 0000000..7c5fbe6 --- /dev/null +++ b/packages/a2ui/src/components/icon.tsx @@ -0,0 +1,11 @@ +import { Component } from '@geajs/core' + +interface IconProps { + name?: string +} + +export default class Icon extends Component { + template(props: IconProps) { + return + } +} diff --git a/packages/a2ui/src/components/image.tsx b/packages/a2ui/src/components/image.tsx new file mode 100644 index 0000000..2b445e4 --- /dev/null +++ b/packages/a2ui/src/components/image.tsx @@ -0,0 +1,12 @@ +import { Component } from '@geajs/core' + +interface ImageProps { + url?: string + alt?: string +} + +export default class Image extends Component { + template(props: ImageProps) { + return {props.alt + } +} diff --git a/packages/a2ui/src/components/list.tsx b/packages/a2ui/src/components/list.tsx new file mode 100644 index 0000000..44d3347 --- /dev/null +++ b/packages/a2ui/src/components/list.tsx @@ -0,0 +1,8 @@ +import { Component } from '@geajs/core' + +/** A2UI `List`. Empty by design — see the note in `column.tsx`. */ +export default class List extends Component { + template() { + return
+ } +} diff --git a/packages/a2ui/src/components/row.tsx b/packages/a2ui/src/components/row.tsx new file mode 100644 index 0000000..bc0efeb --- /dev/null +++ b/packages/a2ui/src/components/row.tsx @@ -0,0 +1,8 @@ +import { Component } from '@geajs/core' + +/** A2UI `Row`. Empty by design — see the note in `column.tsx`. */ +export default class Row extends Component { + template() { + return
+ } +} diff --git a/packages/a2ui/src/components/text.tsx b/packages/a2ui/src/components/text.tsx new file mode 100644 index 0000000..69dcd93 --- /dev/null +++ b/packages/a2ui/src/components/text.tsx @@ -0,0 +1,13 @@ +import { Component } from '@geajs/core' + +interface TextProps { + text?: string + variant?: string +} + +/** A2UI `Text`. `variant` (heading/caption/body) maps to a class. */ +export default class Text extends Component { + template(props: TextProps) { + return {props.text} + } +} diff --git a/packages/a2ui/src/components/video.tsx b/packages/a2ui/src/components/video.tsx new file mode 100644 index 0000000..629bcaa --- /dev/null +++ b/packages/a2ui/src/components/video.tsx @@ -0,0 +1,11 @@ +import { Component } from '@geajs/core' + +interface VideoProps { + url?: string +} + +export default class Video extends Component { + template(props: VideoProps) { + return + } +} diff --git a/packages/a2ui/src/dispatcher.ts b/packages/a2ui/src/dispatcher.ts new file mode 100644 index 0000000..68e7993 --- /dev/null +++ b/packages/a2ui/src/dispatcher.ts @@ -0,0 +1,86 @@ +import type { RegisteredFunction } from './functions' +import type { Catalog } from './catalog' +import { deletePointer, parsePointer, writePointer } from './pointer' +import { mountSurface, type Surface, type SurfaceRegistry } from './surface' +import type { + ServerEnvelope, + Transport, + UpdateComponentsPayload, + UpdateDataModelPayload, +} from './types' + +export interface DispatchOptions { + catalog: Catalog + functions: Record + /** Omit only for surfaces with no actions — firing one then throws. */ + transport?: Transport + /** Injected for determinism in tests; defaults to wall-clock UTC. */ + now?: () => string +} + +const NO_TRANSPORT: Transport = { + sendAction(action) { + throw new Error(`A2UI: action "${action.name}" fired but no transport was configured`) + }, +} + +function requireSurface(registry: SurfaceRegistry, surfaceId: string): Surface { + const surface = registry.get(surfaceId) + if (!surface) throw new Error(`A2UI: unknown surfaceId "${surfaceId}"`) + return surface +} + +function handleUpdateComponents( + payload: UpdateComponentsPayload, + registry: SurfaceRegistry, + opts: DispatchOptions, +): void { + const surface = requireSurface(registry, payload.surfaceId) + // Order is irrelevant: the adjacency list is resolved at mount. + for (const definition of payload.components) { + surface.componentDefinitions.set(definition.id, definition) + } + // The whole happy-path buffering rule: nothing renders until `root` exists. + if (!surface.isMounted && surface.componentDefinitions.has('root')) { + mountSurface(surface, { + catalog: opts.catalog, + functions: opts.functions, + transport: opts.transport ?? NO_TRANSPORT, + now: opts.now ?? (() => new Date().toISOString()), + }) + } +} + +function handleUpdateDataModel(payload: UpdateDataModelPayload, registry: SurfaceRegistry): void { + const surface = requireSurface(registry, payload.surfaceId) + const model = surface.dataModel as unknown as Record + const parts = parsePointer(payload.path ?? '/') + // Upsert semantics: `value` present writes; `value` absent removes. + if ('value' in payload) writePointer(model, parts, payload.value) + else deletePointer(model, parts) +} + +/** + * The ONLY place that reads `version`. v0.9 and v0.9.1 are wire-compatible; + * a future v1.0 migration is a single edit here. + */ +export function dispatch( + envelope: ServerEnvelope, + registry: SurfaceRegistry, + opts: DispatchOptions, +): void { + if ('createSurface' in envelope) { + registry.create(envelope.createSurface) + return + } + if ('updateComponents' in envelope) { + handleUpdateComponents(envelope.updateComponents, registry, opts) + return + } + if ('updateDataModel' in envelope) { + handleUpdateDataModel(envelope.updateDataModel, registry) + return + } + // deleteSurface teardown is out of scope for the happy path. + registry.delete(envelope.deleteSurface.surfaceId) +} diff --git a/packages/a2ui/src/functions.ts b/packages/a2ui/src/functions.ts new file mode 100644 index 0000000..1c0ba41 --- /dev/null +++ b/packages/a2ui/src/functions.ts @@ -0,0 +1,48 @@ +/** + * The A2UI security model is *naming, not shipping code*: a FunctionCall names + * an entry in this map. Nothing here evaluates agent-supplied source. + */ + +export type RegisteredFunction = (args: Record) => unknown + +/** Deliberately simple and total: `@` with a dot in the domain, no spaces. */ +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + +export const BUILTIN_FUNCTIONS: Record = { + /** Empty string / null / undefined fail. `0` and `false` are present values. */ + required(args) { + const v = args.value + if (v === null || v === undefined) return false + if (typeof v === 'string') return v.length > 0 + return true + }, + + regex(args) { + const { value, pattern } = args + if (typeof value !== 'string' || typeof pattern !== 'string') return false + return new RegExp(pattern).test(value) + }, + + email(args) { + const v = args.value + return typeof v === 'string' && EMAIL_RE.test(v) + }, + + formatString(args) { + const template = args.template + if (typeof template !== 'string') return '' + return template.replace(/\{(\w+)\}/g, (_m, key: string) => { + const v = args[key] + return v === undefined || v === null ? '' : String(v) + }) + }, + + /** UTC so output does not depend on the host timezone. */ + formatDate(args) { + const v = args.value + if (typeof v !== 'string' && typeof v !== 'number') return '' + const d = new Date(v) + if (Number.isNaN(d.getTime())) return '' + return d.toISOString().slice(0, 10) + }, +} diff --git a/packages/a2ui/src/index.ts b/packages/a2ui/src/index.ts new file mode 100644 index 0000000..299f903 --- /dev/null +++ b/packages/a2ui/src/index.ts @@ -0,0 +1,19 @@ +export const VERSION = '0.1.0' + +export * from './types' +export { parsePointer, readPointer, writePointer, deletePointer } from './pointer' +export { createRootScope, createCollectionScope, resolveRead, resolveWriteParts } from './scope' +export type { Scope, RootScope, CollectionScope } from './scope' +export { BUILTIN_FUNCTIONS } from './functions' +export type { RegisteredFunction } from './functions' +export { toThunk, resolveOnce } from './binding' +export type { BindingContext } from './binding' +export { resolveProps, createBasicCatalog } from './catalog' +export type { Catalog, CatalogEntry, PropThunks } from './catalog' +export { instantiateNode } from './instantiate' +export { SurfaceRegistry, mountSurface } from './surface' +export type { Surface, MountOptions } from './surface' +export { dispatch } from './dispatcher' +export type { DispatchOptions } from './dispatcher' +export { dispatchAction } from './actions' +export type { ActionContext } from './actions' diff --git a/packages/a2ui/src/instantiate.ts b/packages/a2ui/src/instantiate.ts new file mode 100644 index 0000000..15b73ea --- /dev/null +++ b/packages/a2ui/src/instantiate.ts @@ -0,0 +1,195 @@ +import { keyedList, mount, type Disposer, type Entry } from '@geajs/core/compiler-runtime' +import type { + Action, + ComponentDefinition, + ComponentId, + TemplateChildList, + Transport, +} from './types' +import { isDataBinding, isTemplateChildList } from './types' +import type { Catalog, PropThunks, WriteFn } from './catalog' +import type { BindingContext } from './binding' +import type { RegisteredFunction } from './functions' +import { createCollectionScope, resolveWriteParts, type CollectionScope, type Scope } from './scope' +import { parsePointer, writePointer } from './pointer' +import { dispatchAction } from './actions' + +export interface InstantiateContext { + definitions: Map + catalog: Catalog + store: Record + functions: Record + disposer: Disposer + surfaceId: string + transport: Transport + /** Injected for determinism in tests. */ + now: () => string +} + +function bindingContext(ictx: InstantiateContext, scope: Scope): BindingContext { + return { store: ictx.store, scope, functions: ictx.functions } +} + +/** + * Write direction: local, immediate, no network. We resolve WHERE the write + * lands; the catalog entry decides WHICH handler prop carries it, because + * every input names its change event differently (`onInput`, + * `onCheckedChange`, `onValueChange`). + */ +function writeFor( + definition: ComponentDefinition, + ictx: InstantiateContext, + scope: Scope, +): WriteFn | null { + if (!isDataBinding(definition.value)) return null + const parts = resolveWriteParts(definition.value.path, scope) + return (next: unknown) => writePointer(ictx.store, parts, next) +} + +/** Actions are uniform: gea-ui's Button accepts both `click` and `onClick`. */ +function injectAction( + definition: ComponentDefinition, + ictx: InstantiateContext, + props: PropThunks, + scope: Scope, +): void { + if (!definition.action) return + const action = definition.action as Action + const handler = () => + dispatchAction(action, { + surfaceId: ictx.surfaceId, + sourceComponentId: definition.id, + binding: bindingContext(ictx, scope), + transport: ictx.transport, + now: ictx.now, + }) + props.onClick = () => handler + props.click = () => handler +} + +/** + * Mount `nodeId` into `parent`, then recurse mounting its children into the + * element the node produced. + * + * Parent-directed by necessity: `mount()` appends into `parent` and returns + * the component instance, not a Node (runtime/mount.ts:123). Reading back + * `inst.el` and appending children into it also avoids the compiled + * `children` thunk, which can only carry a SINGLE Node. + */ +export function instantiateNode( + nodeId: ComponentId, + ictx: InstantiateContext, + parent: Element, + scope: Scope, +): Element { + const definition = ictx.definitions.get(nodeId) + if (!definition) throw new Error(`A2UI: no component definition for id "${nodeId}"`) + + const entry = ictx.catalog.get(definition.component) + if (!entry) throw new Error(`A2UI: component type "${definition.component}" is not in the catalog`) + + const ctx = bindingContext(ictx, scope) + const props = entry.mapProps(definition, ctx, writeFor(definition, ictx, scope)) + injectAction(definition, ictx, props, scope) + + const instance = mount(entry.component as never, parent, props as never, ictx.disposer.child()) + const element: Element | null = instance && (instance as { el?: Element | null }).el + if (!element) throw new Error(`A2UI: component "${definition.component}" produced no element`) + + instantiateChildren(definition, ictx, element, scope) + return element +} + +function instantiateChildren( + definition: ComponentDefinition, + ictx: InstantiateContext, + host: Element, + scope: Scope, +): void { + if (typeof definition.child === 'string') { + instantiateNode(definition.child, ictx, host, scope) + return + } + const children = definition.children + if (Array.isArray(children)) { + for (const childId of children) instantiateNode(childId, ictx, host, scope) + return + } + if (isTemplateChildList(children)) { + instantiateTemplateList(children, ictx, host, scope) + } +} + +/** `item.id` / `item.key` identify a row; index is the last resort. */ +function entryKey(item: any, idx: number): string { + return String(item?.id ?? item?.key ?? idx) +} + +/** + * A container whose `children` is a template instantiates the template once + * per array item, each in its own collection scope. + * + * `keyedList` takes a CONFIG OBJECT (runtime/keyed-list.ts:48). `cfg.root` + * must be the Store — its dirty-bit protocol is what enables the append / + * remove / swap fast paths. `createEntry` returns an `Entry`, not a Node. + * + * Each row's CollectionScope carries a MUTABLE `item` and `index`, rewritten + * by `patchEntry`. Relative paths resolve against `scope.item`, so rows stay + * correct across reorders without re-reading an index-based pointer. + */ +function instantiateTemplateList( + template: TemplateChildList, + ictx: InstantiateContext, + host: Element, + parentScope: Scope, +): void { + const basePath = parsePointer(template.path) + const anchor = document.createComment('a2ui-list') + host.appendChild(anchor) + + const scopes = new WeakMap() + + keyedList({ + container: host, + anchor, + disposer: ictx.disposer.child(), + root: ictx.store, + // Array-path form, NOT the `() => any[]` getter. The getter branch calls + // `reconcile(arr)` with no change records, so a same-ref/same-length + // mutation (`reverse()`, `sort()`) hits the dirty-scan path and never + // moves DOM nodes. The array-path branch subscribes and receives the + // store's `reorder` record. `parsePointer` already yields these parts. + path: basePath, + key: entryKey, + createEntry: (item: any, idx: number): Entry => { + const rowDisposer = ictx.disposer.child() + const scope = createCollectionScope(basePath, idx, item, parentScope) + // `mount()` appends into a parent, so give the row a scratch parent and + // take the element back out. keyedList inserts it before the anchor. + const scratch = document.createElement('div') + const element = instantiateNode( + template.componentId, + { ...ictx, disposer: rowDisposer }, + scratch, + scope, + ) + const entry: Entry = { + key: entryKey(item, idx), + item, + element, + disposer: rowDisposer, + obs: null as never, + } + scopes.set(entry, scope) + return entry + }, + patchEntry: (entry: Entry, newItem: any, newIdx: number): void => { + entry.item = newItem + const scope = scopes.get(entry) + if (scope) { + scope.item = newItem + scope.index = newIdx + } + }, + }) +} diff --git a/packages/a2ui/src/pointer.ts b/packages/a2ui/src/pointer.ts new file mode 100644 index 0000000..5bf610f --- /dev/null +++ b/packages/a2ui/src/pointer.ts @@ -0,0 +1,82 @@ +/** + * JSON Pointer (RFC 6901) over a Gea Store proxy. + * + * Gea's Store has no pointer support: it exposes property access and + * `observe(dotPath)` only. This module is the ONLY place that knows pointer + * syntax. + * + * DELIBERATE RFC 6901 DEVIATION: both "" and "/" parse to [] (the whole + * model). Strict RFC 6901 says "/" addresses the empty-string key "". A2UI's + * updateDataModel treats a missing path (and "/") as "replace the whole + * model", so we follow A2UI. + * + * Reads here are plain property access on the Proxy, so when a read happens + * inside a Gea tracking scope it auto-subscribes. That is why the interpreter + * never needs an effect primitive of its own. + */ + +/** Unescape one pointer token: ~1 -> "/" then ~0 -> "~" (order is required). */ +function unescapeToken(token: string): string { + return token.replace(/~1/g, '/').replace(/~0/g, '~') +} + +export function parsePointer(pointer: string): string[] { + if (pointer === '' || pointer === '/') return [] + const body = pointer.startsWith('/') ? pointer.slice(1) : pointer + return body.split('/').map(unescapeToken) +} + +export function readPointer(root: unknown, parts: readonly string[]): unknown { + let cur: any = root + for (let i = 0; i < parts.length; i++) { + if (cur === null || cur === undefined) return undefined + cur = cur[parts[i]] + } + return cur +} + +/** Walk to the parent of `parts`, creating missing intermediate objects. */ +function walkToParent(root: any, parts: readonly string[]): any { + let cur: any = root + for (let i = 0; i < parts.length - 1; i++) { + const key = parts[i] + if (cur[key] === null || typeof cur[key] !== 'object') cur[key] = {} + cur = cur[key] + } + return cur +} + +/** + * Empty `parts` replaces the whole model IN PLACE. The Store identity must be + * preserved: keyedList and every mounted binding hold a reference to it. + */ +export function writePointer( + root: Record, + parts: readonly string[], + value: unknown, +): void { + if (parts.length === 0) { + for (const key of Object.keys(root)) delete (root as any)[key] + if (value && typeof value === 'object' && !Array.isArray(value)) { + for (const [k, v] of Object.entries(value as Record)) { + ;(root as any)[k] = v + } + } + return + } + const parent = walkToParent(root, parts) + parent[parts[parts.length - 1]] = value +} + +export function deletePointer(root: Record, parts: readonly string[]): void { + if (parts.length === 0) { + for (const key of Object.keys(root)) delete (root as any)[key] + return + } + let cur: any = root + for (let i = 0; i < parts.length - 1; i++) { + if (cur === null || typeof cur !== 'object') return + cur = cur[parts[i]] + } + if (cur && typeof cur === 'object') delete cur[parts[parts.length - 1]] +} diff --git a/packages/a2ui/src/scope.ts b/packages/a2ui/src/scope.ts new file mode 100644 index 0000000..0411a06 --- /dev/null +++ b/packages/a2ui/src/scope.ts @@ -0,0 +1,54 @@ +import { parsePointer, readPointer } from './pointer' + +export interface RootScope { + kind: 'root' +} + +export interface CollectionScope { + kind: 'collection' + basePath: string[] + /** Mutable: keyedList's patchEntry rewrites this on reorder. */ + index: number + /** Mutable: the current item. Relative reads resolve against this. */ + item: unknown + parent: Scope +} + +export type Scope = RootScope | CollectionScope + +export function createRootScope(): RootScope { + return { kind: 'root' } +} + +export function createCollectionScope( + basePath: string[], + index: number, + item: unknown, + parent: Scope, +): CollectionScope { + return { kind: 'collection', basePath, index, item, parent } +} + +function isAbsolute(path: string): boolean { + return path.startsWith('/') +} + +/** + * Absolute paths resolve from the model root. Relative paths (an A2UI + * extension to RFC 6901) resolve against the current collection item, which + * is what keeps rows correct under reorder. A relative path in root scope is + * not legal per spec; we return undefined rather than throw, per the + * progressive-rendering note. + */ +export function resolveRead(store: unknown, path: string, scope: Scope): unknown { + if (isAbsolute(path)) return readPointer(store, parsePointer(path)) + if (scope.kind !== 'collection') return undefined + return readPointer(scope.item, parsePointer('/' + path)) +} + +/** Writes always land on the Store, so relative paths become absolute parts. */ +export function resolveWriteParts(path: string, scope: Scope): string[] { + if (isAbsolute(path)) return parsePointer(path) + if (scope.kind !== 'collection') return parsePointer('/' + path) + return [...scope.basePath, String(scope.index), ...parsePointer('/' + path)] +} diff --git a/packages/a2ui/src/surface.ts b/packages/a2ui/src/surface.ts new file mode 100644 index 0000000..20e077f --- /dev/null +++ b/packages/a2ui/src/surface.ts @@ -0,0 +1,75 @@ +import { Store } from '@geajs/core' +import { createDisposer, type Disposer } from '@geajs/core/compiler-runtime' +import type { ComponentDefinition, ComponentId, CreateSurfacePayload, Transport } from './types' +import type { Catalog } from './catalog' +import type { RegisteredFunction } from './functions' +import { instantiateNode } from './instantiate' +import { createRootScope } from './scope' + +export interface MountOptions { + catalog: Catalog + functions: Record + transport: Transport + now: () => string +} + +export interface Surface { + surfaceId: string + /** Fixed at creation. Reconfiguration requires deleteSurface then createSurface. */ + catalogId: string + componentDefinitions: Map + /** Identity is permanent — mutate in place, never reassign. */ + dataModel: Store + isMounted: boolean + rootHostElement: Element + disposer: Disposer +} + +export class SurfaceRegistry { + private readonly surfaces = new Map() + + constructor(private readonly hostFor: (surfaceId: string) => Element) {} + + create(payload: CreateSurfacePayload): Surface { + const surface: Surface = { + surfaceId: payload.surfaceId, + catalogId: payload.catalogId, + componentDefinitions: new Map(), + dataModel: new Store({}), + isMounted: false, + rootHostElement: this.hostFor(payload.surfaceId), + disposer: createDisposer(), + } + this.surfaces.set(payload.surfaceId, surface) + return surface + } + + get(surfaceId: string): Surface | undefined { + return this.surfaces.get(surfaceId) + } + + delete(surfaceId: string): void { + this.surfaces.delete(surfaceId) + } +} + +/** The one interpreter walk. Runs once; after this the interpreter is idle. */ +export function mountSurface(surface: Surface, opts: MountOptions): void { + surface.rootHostElement.replaceChildren() + instantiateNode( + 'root', + { + definitions: surface.componentDefinitions, + catalog: opts.catalog, + store: surface.dataModel as unknown as Record, + functions: opts.functions, + disposer: surface.disposer, + surfaceId: surface.surfaceId, + transport: opts.transport, + now: opts.now, + }, + surface.rootHostElement, + createRootScope(), + ) + surface.isMounted = true +} diff --git a/packages/a2ui/src/types.ts b/packages/a2ui/src/types.ts new file mode 100644 index 0000000..e420b60 --- /dev/null +++ b/packages/a2ui/src/types.ts @@ -0,0 +1,105 @@ +/** A2UI v0.9.1 protocol types. Anchored to specification/v0_9_1/json/*.json. */ + +export type ComponentId = string + +/** `version` is enum ["v0.9", "v0.9.1"] — accept both. */ +export type ProtocolVersion = 'v0.9' | 'v0.9.1' + +export interface DataBinding { + /** A JSON Pointer. Relative (no leading `/`) is legal inside a collection scope. */ + path: string +} + +export type ReturnType_ = 'string' | 'number' | 'boolean' | 'array' | 'object' | 'any' | 'void' + +export interface FunctionCall { + call: string + /** Optional per schema — only `call` is required. */ + args?: Record + /** Optional; schema default is "boolean". */ + returnType?: ReturnType_ +} + +export type DynamicValue = string | number | boolean | unknown[] | DataBinding | FunctionCall + +export interface TemplateChildList { + componentId: ComponentId + path: string +} + +export type ChildList = ComponentId[] | TemplateChildList + +export interface ComponentDefinition { + id: ComponentId + component: string + child?: ComponentId + children?: ChildList + [prop: string]: unknown +} + +export interface EventAction { + event: { name: string; context?: Record } +} +export interface FunctionCallAction { + functionCall: FunctionCall +} +export type Action = EventAction | FunctionCallAction + +export interface CreateSurfacePayload { + surfaceId: string + catalogId: string + theme?: unknown + sendDataModel?: boolean +} +export interface UpdateComponentsPayload { + surfaceId: string + /** minItems: 1; exactly one component must have id === "root". */ + components: ComponentDefinition[] +} +export interface UpdateDataModelPayload { + surfaceId: string + path?: string + value?: unknown +} +export interface DeleteSurfacePayload { + surfaceId: string +} + +export type ServerEnvelope = + | { version: ProtocolVersion; createSurface: CreateSurfacePayload } + | { version: ProtocolVersion; updateComponents: UpdateComponentsPayload } + | { version: ProtocolVersion; updateDataModel: UpdateDataModelPayload } + | { version: ProtocolVersion; deleteSurface: DeleteSurfacePayload } + +export interface ClientAction { + name: string + surfaceId: string + sourceComponentId: ComponentId + timestamp: string + context: Record +} + +export interface Transport { + sendAction(action: ClientAction): void +} + +function isPlainObject(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v) +} + +/** DataBinding is `{ path }` with additionalProperties:false — exactly one key. */ +export function isDataBinding(v: unknown): v is DataBinding { + if (!isPlainObject(v)) return false + const keys = Object.keys(v) + return keys.length === 1 && keys[0] === 'path' && typeof v.path === 'string' +} + +/** FunctionCall requires only `call`. */ +export function isFunctionCall(v: unknown): v is FunctionCall { + return isPlainObject(v) && typeof v.call === 'string' +} + +/** ChildList template branch: `{ componentId, path }`, both required. */ +export function isTemplateChildList(v: unknown): v is TemplateChildList { + return isPlainObject(v) && typeof v.componentId === 'string' && typeof v.path === 'string' +} diff --git a/packages/a2ui/tests/actions.test.ts b/packages/a2ui/tests/actions.test.ts new file mode 100644 index 0000000..aa59b39 --- /dev/null +++ b/packages/a2ui/tests/actions.test.ts @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { Store } from '@geajs/core' +import { BUILTIN_FUNCTIONS } from '../src/functions' +import { createRootScope } from '../src/scope' +import { dispatchAction } from '../src/actions' +import type { ClientAction } from '../src/types' + +function harness(data: Record = {}) { + const sent: ClientAction[] = [] + const store = new Store(data) as any + return { + sent, + store, + ctx: { + surfaceId: 'contact_form_1', + sourceComponentId: 'submit_button', + binding: { store, scope: createRootScope(), functions: BUILTIN_FUNCTIONS }, + transport: { sendAction: (a: ClientAction) => sent.push(a) }, + now: () => '2026-07-09T00:00:00.000Z', + }, + } +} + +test('event action resolves context bindings and sends a schema-shaped message', () => { + const { sent, ctx } = harness({ contact: { subscribe: true } }) + dispatchAction( + { + event: { + name: 'submitContactForm', + context: { isNewsletterSubscribed: { path: '/contact/subscribe' } }, + }, + }, + ctx, + ) + assert.equal(sent.length, 1) + assert.deepEqual(sent[0], { + name: 'submitContactForm', + surfaceId: 'contact_form_1', + sourceComponentId: 'submit_button', + timestamp: '2026-07-09T00:00:00.000Z', + context: { isNewsletterSubscribed: true }, + }) +}) + +test('event action with no context sends an empty context object', () => { + const { sent, ctx } = harness() + dispatchAction({ event: { name: 'ping' } }, ctx) + assert.deepEqual(sent[0].context, {}) +}) + +test('event context resolves FunctionCall values locally', () => { + const { sent, ctx } = harness({ d: '2026-07-09T12:00:00Z' }) + dispatchAction( + { event: { name: 'x', context: { when: { call: 'formatDate', args: { value: { path: '/d' } } } } } }, + ctx, + ) + assert.equal(sent[0].context.when, '2026-07-09') +}) + +test('functionCall action never reaches the transport', () => { + const { sent, ctx } = harness({ contact: { email: 'a@b.c' } }) + dispatchAction({ functionCall: { call: 'email', args: { value: { path: '/contact/email' } } } }, ctx) + assert.equal(sent.length, 0) +}) + +test('functionCall with an unregistered name throws', () => { + const { ctx } = harness() + assert.throws( + () => dispatchAction({ functionCall: { call: 'exfiltrate' } }, ctx), + /Unknown A2UI function/, + ) +}) diff --git a/packages/a2ui/tests/binding.test.ts b/packages/a2ui/tests/binding.test.ts new file mode 100644 index 0000000..f4b38cd --- /dev/null +++ b/packages/a2ui/tests/binding.test.ts @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { Store } from '@geajs/core' +import { BUILTIN_FUNCTIONS } from '../src/functions' +import { createRootScope, createCollectionScope } from '../src/scope' +import { toThunk, resolveOnce } from '../src/binding' + +function ctx(store: any, scope = createRootScope()) { + return { store, scope, functions: BUILTIN_FUNCTIONS } +} + +test('literals pass through as constant thunks', () => { + const c = ctx(new Store({})) + assert.equal(toThunk('Email Address', c)(), 'Email Address') + assert.equal(toThunk(42, c)(), 42) + assert.equal(toThunk(true, c)(), true) + assert.deepEqual(toThunk(['a'], c)(), ['a']) +}) + +test('DataBinding thunk is lazy and re-reads the store on each call', () => { + const s = new Store({ contact: { email: 'a@b.c' } }) as any + const thunk = toThunk({ path: '/contact/email' }, ctx(s)) + assert.equal(thunk(), 'a@b.c') + s.contact.email = 'z@z.z' + assert.equal(thunk(), 'z@z.z') // lazy: not captured at build time +}) + +test('DataBinding resolves to undefined before its updateDataModel arrives', () => { + const s = new Store({}) as any + assert.equal(toThunk({ path: '/contact/email' }, ctx(s))(), undefined) +}) + +test('FunctionCall runs a registered function with resolved args', () => { + const s = new Store({ contact: { email: 'bad' } }) as any + const c = ctx(s) + const thunk = toThunk({ call: 'email', args: { value: { path: '/contact/email' } } }, c) + assert.equal(thunk(), false) + s.contact.email = 'john.doe@example.com' + assert.equal(thunk(), true) +}) + +test('FunctionCall with no args is legal (args optional per schema)', () => { + const c = ctx(new Store({})) + assert.equal(toThunk({ call: 'required' }, c)(), false) +}) + +test('FunctionCall args may nest FunctionCalls', () => { + const s = new Store({ d: '2026-07-09T12:00:00Z' }) as any + const thunk = toThunk( + { + call: 'formatString', + args: { + template: 'On {when}', + when: { call: 'formatDate', args: { value: { path: '/d' } } }, + }, + }, + ctx(s), + ) + assert.equal(thunk(), 'On 2026-07-09') +}) + +test('unknown function name throws rather than evaluating anything', () => { + const c = ctx(new Store({})) + assert.throws(() => toThunk({ call: 'rm -rf' }, c)(), /Unknown A2UI function: rm -rf/) +}) + +test('relative binding reads the collection item', () => { + const s = new Store({ employees: [{ name: 'Ada' }] }) as any + const scope = createCollectionScope(['employees'], 0, s.employees[0], createRootScope()) + assert.equal(toThunk({ path: 'name' }, ctx(s, scope))(), 'Ada') +}) + +test('resolveOnce returns a concrete value, not a thunk', () => { + const s = new Store({ contact: { subscribe: true } }) as any + assert.equal(resolveOnce({ path: '/contact/subscribe' }, ctx(s)), true) +}) diff --git a/packages/a2ui/tests/catalog-complete.test.ts b/packages/a2ui/tests/catalog-complete.test.ts new file mode 100644 index 0000000..e41b7de --- /dev/null +++ b/packages/a2ui/tests/catalog-complete.test.ts @@ -0,0 +1,107 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { Store } from '@geajs/core' +import { createDisposer } from '@geajs/core/compiler-runtime' +import { createBasicCatalog } from '../src/catalog' +import { BUILTIN_FUNCTIONS } from '../src/functions' +import { instantiateNode } from '../src/instantiate' +import { createRootScope } from '../src/scope' +import type { ComponentDefinition } from '../src/types' + +const BASIC_18 = [ + 'Text', + 'Image', + 'Icon', + 'Video', + 'AudioPlayer', + 'Row', + 'Column', + 'List', + 'Card', + 'Tabs', + 'Modal', + 'Divider', + 'Button', + 'TextField', + 'CheckBox', + 'ChoicePicker', + 'Slider', + 'DateTimeInput', +] + +test('basic catalog registers exactly the 18 spec component types', () => { + const catalog = createBasicCatalog() + assert.equal(catalog.size, 18) + assert.deepEqual([...catalog.keys()].sort(), [...BASIC_18].sort()) +}) + +test('every entry carries a component reference and a mapProps', () => { + for (const [name, e] of createBasicCatalog()) { + assert.equal(e.typeName, name) + assert.ok(e.component, `${name} has no component`) + assert.equal(typeof e.mapProps, 'function') + } +}) + +function build(defs: ComponentDefinition[], data: Record = {}) { + const store = new Store(data) as any + const host = document.createElement('div') + document.body.appendChild(host) + instantiateNode( + 'root', + { + definitions: new Map(defs.map((d) => [d.id, d])), + catalog: createBasicCatalog(), + store, + functions: BUILTIN_FUNCTIONS, + disposer: createDisposer(), + surfaceId: 's1', + transport: { sendAction() {} }, + now: () => '2026-07-09T00:00:00.000Z', + }, + host, + createRootScope(), + ) + return { host, store } +} + +test('the local media leaves render their bound source', () => { + const { host } = build([{ id: 'root', component: 'Image', url: '/a.png', alt: 'A' }]) + const img = host.querySelector('img.a2ui-image') as HTMLImageElement + assert.ok(img) + assert.match(img.getAttribute('src') ?? '', /a\.png$/) + assert.equal(img.getAttribute('alt'), 'A') + + const video = build([{ id: 'root', component: 'Video', url: '/v.mp4' }]).host + assert.ok(video.querySelector('video.a2ui-video')) + + const audio = build([{ id: 'root', component: 'AudioPlayer', url: '/a.mp3' }]).host + assert.ok(audio.querySelector('audio.a2ui-audio')) + + const icon = build([{ id: 'root', component: 'Icon', name: 'star' }]).host + assert.equal(icon.querySelector('.a2ui-icon')!.getAttribute('data-icon'), 'star') +}) + +test('Divider maps onto gea-ui Separator and renders', () => { + const { host } = build([{ id: 'root', component: 'Divider' }]) + assert.ok(host.firstElementChild, 'Divider rendered an element') +}) + +test('List is a container the interpreter fills, with no stray slot text', () => { + const { host } = build([ + { id: 'root', component: 'List', children: ['a'] }, + { id: 'a', component: 'Text', text: 'Item' }, + ]) + assert.equal(host.querySelector('.a2ui-list')!.textContent, 'Item') +}) + +test('DateTimeInput writes back through Gea’s delegated input event', () => { + const { host, store } = build([{ id: 'root', component: 'DateTimeInput', value: { path: '/when' } }], { + when: '', + }) + const input = host.querySelector('input.a2ui-datetime') as HTMLInputElement + assert.ok(input, 'datetime input rendered') + input.value = '2026-07-09T12:00' + input.dispatchEvent(new window.Event('input', { bubbles: true })) + assert.equal(store.when, '2026-07-09T12:00') +}) diff --git a/packages/a2ui/tests/catalog.test.ts b/packages/a2ui/tests/catalog.test.ts new file mode 100644 index 0000000..7c4deae --- /dev/null +++ b/packages/a2ui/tests/catalog.test.ts @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { Store } from '@geajs/core' +import { BUILTIN_FUNCTIONS } from '../src/functions' +import { createRootScope } from '../src/scope' +import { resolveProps, createBasicCatalog } from '../src/catalog' + +function ctx(store: any) { + return { store, scope: createRootScope(), functions: BUILTIN_FUNCTIONS } +} + +test('resolveProps skips structural keys and thunks the rest', () => { + const s = new Store({ contact: { email: 'a@b.c' } }) + const props = resolveProps( + { + id: 'f', + component: 'TextField', + child: 'x', + children: ['y'], + label: 'Email', + value: { path: '/contact/email' }, + }, + ctx(s), + ) + assert.deepEqual(Object.keys(props).sort(), ['label', 'value']) + assert.equal(props.label(), 'Email') + assert.equal(props.value(), 'a@b.c') +}) + +test('basic catalog registers the contact-form subset', () => { + const catalog = createBasicCatalog() + for (const name of ['Card', 'Column', 'Row', 'Text', 'Button', 'TextField', 'CheckBox']) { + assert.ok(catalog.has(name), `missing ${name}`) + assert.equal(catalog.get(name)!.typeName, name) + assert.equal(typeof catalog.get(name)!.mapProps, 'function') + } +}) + +test('TextField mapProps translates A2UI props to the Input component props', () => { + const s = new Store({ contact: { email: 'a@b.c' } }) + const entry = createBasicCatalog().get('TextField')! + const props = entry.mapProps( + { id: 'email_field', component: 'TextField', label: 'Email', value: { path: '/contact/email' } }, + ctx(s), + null, + ) + assert.equal(props.value(), 'a@b.c') + assert.equal(props.placeholder(), 'Email') // A2UI `label` -> Input `placeholder` +}) + +test('TextField attaches write to onInput, reading e.target.value', () => { + const written: unknown[] = [] + const entry = createBasicCatalog().get('TextField')! + const props = entry.mapProps( + { id: 'f', component: 'TextField', value: { path: '/a' } }, + ctx(new Store({})), + (next) => written.push(next), + ) + const handler = props.onInput!() as (e: Event) => void + handler({ target: { value: 'typed' } } as unknown as Event) + assert.deepEqual(written, ['typed']) +}) + +test('CheckBox maps A2UI value -> Zag checked, and never leaks value (the form-value string)', () => { + const s = new Store({ contact: { subscribe: true } }) + const entry = createBasicCatalog().get('CheckBox')! + const props = entry.mapProps( + { id: 'cb', component: 'CheckBox', label: 'Sub', value: { path: '/contact/subscribe' } }, + ctx(s), + null, + ) + assert.equal(props.checked(), true) + assert.equal(props.value, undefined, 'A2UI value must not reach Zag `value`') +}) + +test('CheckBox attaches write to onCheckedChange, reading details.checked', () => { + const written: unknown[] = [] + const entry = createBasicCatalog().get('CheckBox')! + const props = entry.mapProps( + { id: 'cb', component: 'CheckBox', value: { path: '/a' } }, + ctx(new Store({})), + (next) => written.push(next), + ) + const handler = props.onCheckedChange!() as (d: { checked: boolean }) => void + handler({ checked: true }) + assert.deepEqual(written, [true]) +}) + +test('Button maps the A2UI "primary" variant onto a real gea-ui variant', () => { + const entry = createBasicCatalog().get('Button')! + const props = entry.mapProps( + { id: 'b', component: 'Button', variant: 'primary' }, + ctx(new Store({})), + null, + ) + assert.equal(props.variant(), 'default') +}) diff --git a/packages/a2ui/tests/contact-form.test.ts b/packages/a2ui/tests/contact-form.test.ts new file mode 100644 index 0000000..4e40fea --- /dev/null +++ b/packages/a2ui/tests/contact-form.test.ts @@ -0,0 +1,139 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { flushMicrotasks } from '../../../tests/helpers/jsdom-setup' +import { SurfaceRegistry, dispatch, createBasicCatalog, BUILTIN_FUNCTIONS } from '../src/index' +import type { ClientAction } from '../src/types' + +const CATALOG_ID = 'https://a2ui.org/specification/v0_9_1/catalogs/basic/catalog.json' + +const COMPONENTS = [ + { id: 'root', component: 'Card', child: 'form_container' }, + { + id: 'form_container', + component: 'Column', + children: ['email_group', 'newsletter_checkbox', 'submit_button'], + }, + { id: 'email_group', component: 'Column', children: ['email_label', 'email_field'] }, + { id: 'email_label', component: 'Text', text: 'Email Address', variant: 'caption' }, + { + id: 'email_field', + component: 'TextField', + label: 'Email', + value: { path: '/contact/email' }, + variant: 'shortText', + }, + { + id: 'newsletter_checkbox', + component: 'CheckBox', + label: 'Subscribe to our newsletter', + value: { path: '/contact/subscribe' }, + }, + { id: 'submit_button_label', component: 'Text', text: 'Send Message' }, + { + id: 'submit_button', + component: 'Button', + child: 'submit_button_label', + variant: 'primary', + action: { + event: { + name: 'submitContactForm', + context: { isNewsletterSubscribed: { path: '/contact/subscribe' } }, + }, + }, + }, +] + +test('canonical contact form: create -> components -> data -> input -> action', async () => { + const host = document.createElement('div') + // Gea delegates events at the document root; a detached host never sees them. + document.body.appendChild(host) + const sent: ClientAction[] = [] + const registry = new SurfaceRegistry(() => host) + const opts = { + catalog: createBasicCatalog(), + functions: BUILTIN_FUNCTIONS, + transport: { sendAction: (a: ClientAction) => sent.push(a) }, + now: () => '2026-07-09T00:00:00.000Z', + } + + // Step 1: createSurface — nothing renders. + dispatch( + { version: 'v0.9.1', createSurface: { surfaceId: 'contact_form_1', catalogId: CATALOG_ID } }, + registry, + opts, + ) + assert.equal(host.childNodes.length, 0) + + // Step 2 + 3: updateComponents carries `root` -> mount. + dispatch( + { version: 'v0.9.1', updateComponents: { surfaceId: 'contact_form_1', components: COMPONENTS } }, + registry, + opts, + ) + const surface = registry.get('contact_form_1')! + assert.equal(surface.isMounted, true) + assert.match(host.textContent ?? '', /Email Address/) + assert.match(host.textContent ?? '', /Send Message/) + + // Step 5: updateDataModel -> surgical update, no remount. + const rootElBefore = host.firstElementChild + dispatch( + { + version: 'v0.9.1', + updateDataModel: { + surfaceId: 'contact_form_1', + path: '/contact', + value: { email: 'john.doe@example.com', subscribe: false }, + }, + }, + registry, + opts, + ) + await flushMicrotasks() + assert.equal(host.firstElementChild, rootElBefore, 'no remount on data change') + assert.equal(host.querySelector('input[type="text"]')!.value, 'john.doe@example.com') + + // Step 7: user input writes the local store immediately, no network. + const input = host.querySelector('input[type="text"]') as HTMLInputElement + input.value = 'ada@lovelace.dev' + input.dispatchEvent(new window.Event('input', { bubbles: true })) + assert.equal((surface.dataModel as any).contact.email, 'ada@lovelace.dev') + assert.equal(sent.length, 0, 'typing must not hit the transport') + + // Step 8: action resolves context against the store and sends upstream. + ;(surface.dataModel as any).contact.subscribe = true + await flushMicrotasks() + host.querySelector('button')!.click() + assert.equal(sent.length, 1) + assert.deepEqual(sent[0], { + name: 'submitContactForm', + surfaceId: 'contact_form_1', + sourceComponentId: 'submit_button', + timestamp: '2026-07-09T00:00:00.000Z', + context: { isNewsletterSubscribed: true }, + }) +}) + +test('the mounted contact form renders no stray container markers', () => { + const host = document.createElement('div') + document.body.appendChild(host) + const registry = new SurfaceRegistry(() => host) + const opts = { + catalog: createBasicCatalog(), + functions: BUILTIN_FUNCTIONS, + transport: { sendAction() {} }, + now: () => '2026-07-09T00:00:00.000Z', + } + dispatch( + { version: 'v0.9.1', createSurface: { surfaceId: 's', catalogId: CATALOG_ID } }, + registry, + opts, + ) + dispatch( + { version: 'v0.9.1', updateComponents: { surfaceId: 's', components: COMPONENTS } }, + registry, + opts, + ) + // The compiler's children-slot placeholder is a literal "0" text node. + assert.doesNotMatch(host.textContent ?? '', /0/, 'no compiler slot markers leaked into the DOM') +}) diff --git a/packages/a2ui/tests/functions.test.ts b/packages/a2ui/tests/functions.test.ts new file mode 100644 index 0000000..5c4250e --- /dev/null +++ b/packages/a2ui/tests/functions.test.ts @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { BUILTIN_FUNCTIONS } from '../src/functions' + +test('required rejects empty, null, undefined; accepts 0 and false', () => { + const f = BUILTIN_FUNCTIONS.required + assert.equal(f({ value: 'x' }), true) + assert.equal(f({ value: '' }), false) + assert.equal(f({ value: null }), false) + assert.equal(f({ value: undefined }), false) + assert.equal(f({ value: 0 }), true) + assert.equal(f({ value: false }), true) +}) + +test('email validates the bound value', () => { + const f = BUILTIN_FUNCTIONS.email + assert.equal(f({ value: 'john.doe@example.com' }), true) + assert.equal(f({ value: 'nope' }), false) + assert.equal(f({ value: '' }), false) + assert.equal(f({ value: undefined }), false) +}) + +test('regex matches value against pattern', () => { + const f = BUILTIN_FUNCTIONS.regex + assert.equal(f({ value: 'abc123', pattern: '^[a-z]+\\d+$' }), true) + assert.equal(f({ value: 'ABC', pattern: '^[a-z]+$' }), false) + assert.equal(f({ value: undefined, pattern: '^a$' }), false) +}) + +test('formatString substitutes {name} placeholders', () => { + const f = BUILTIN_FUNCTIONS.formatString + assert.equal(f({ template: 'Hi {name}!', name: 'Ada' }), 'Hi Ada!') + assert.equal(f({ template: 'a{x}b{x}', x: '-' }), 'a-b-') + assert.equal(f({ template: 'Hi {missing}' }), 'Hi ') +}) + +test('formatDate renders an ISO date deterministically in UTC', () => { + const f = BUILTIN_FUNCTIONS.formatDate + assert.equal(f({ value: '2026-07-09T12:00:00Z' }), '2026-07-09') + assert.equal(f({ value: 'not-a-date' }), '') +}) + +test('unknown function is not reachable — the map has exactly five entries', () => { + assert.deepEqual(Object.keys(BUILTIN_FUNCTIONS).sort(), [ + 'email', + 'formatDate', + 'formatString', + 'regex', + 'required', + ]) +}) diff --git a/packages/a2ui/tests/gea-loader.ts b/packages/a2ui/tests/gea-loader.ts new file mode 100644 index 0000000..1ae7eb7 --- /dev/null +++ b/packages/a2ui/tests/gea-loader.ts @@ -0,0 +1,67 @@ +/** + * Node module hooks that compile Gea `.tsx` leaves for `node:test`. + * + * `tsx` transpiles JSX but never runs the Gea compiler, so an uncompiled + * `Component` subclass mounts to nothing (`inst.el === null`). At build time + * `geaPlugin()` rewrites each class into a `CompiledComponent` with a + * `GEA_CREATE_TEMPLATE` method; tests need the same transform. + * + * `geaPlugin().transform` is synchronous, so the synchronous `registerHooks` + * API is enough — no off-thread loader required. + * + * The compiled output imports `virtual:gea-compiler-runtime`, a specifier only + * Vite resolves. The resolve hook maps it onto the real module. + */ +import { readFileSync } from 'node:fs' +import { registerHooks } from 'node:module' +import { dirname, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { transformSync } from 'esbuild' +import { geaPlugin } from '../../vite-plugin-gea/src/index' + +const plugin = geaPlugin() as { + transform?: unknown +} +const rawTransform = plugin.transform as + | ((code: string, id: string) => { code: string } | string | null | undefined) + | { handler: (code: string, id: string) => { code: string } | string | null | undefined } +const transform = typeof rawTransform === 'function' ? rawTransform : rawTransform.handler + +const HERE = dirname(fileURLToPath(import.meta.url)) + +const VIRTUAL_MODULES: Record = { + 'virtual:gea-compiler-runtime': pathToFileURL( + resolve(HERE, '../../gea/src/compiler-runtime.ts'), + ).href, +} + +registerHooks({ + resolve(specifier, context, nextResolve) { + const mapped = VIRTUAL_MODULES[specifier] + if (mapped) return { url: mapped, format: 'module', shortCircuit: true } + return nextResolve(specifier, context) + }, + + load(url, context, nextLoad) { + if (!url.startsWith('file:') || !url.endsWith('.tsx')) return nextLoad(url, context) + + const path = fileURLToPath(url) + const source = readFileSync(path, 'utf8') + const transformed = transform.call({} as never, source, path) + const compiled = + transformed == null + ? source + : typeof transformed === 'string' + ? transformed + : transformed.code + + const { code } = transformSync(compiled, { + loader: 'tsx', + format: 'esm', + target: 'esnext', + sourcefile: path, + }) + + return { format: 'module', shortCircuit: true, source: code } + }, +}) diff --git a/packages/a2ui/tests/instantiate.test.ts b/packages/a2ui/tests/instantiate.test.ts new file mode 100644 index 0000000..671adf5 --- /dev/null +++ b/packages/a2ui/tests/instantiate.test.ts @@ -0,0 +1,164 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { GEA_DOM_COMPONENT, Store } from '@geajs/core' +import { createDisposer } from '@geajs/core/compiler-runtime' +import { flushMicrotasks } from '../../../tests/helpers/jsdom-setup' +import { BUILTIN_FUNCTIONS } from '../src/functions' +import { createRootScope } from '../src/scope' +import { createBasicCatalog } from '../src/catalog' +import { instantiateNode } from '../src/instantiate' +import type { ClientAction, ComponentDefinition } from '../src/types' + +function build(defs: ComponentDefinition[], data: Record = {}) { + const definitions = new Map(defs.map((d) => [d.id, d])) + const store = new Store(data) as any + const sent: ClientAction[] = [] + const host = document.createElement('div') + // Gea delegates events at the document root, so a detached host never sees + // them. Every DOM test mounts into the live document. + document.body.appendChild(host) + const ictx = { + definitions, + catalog: createBasicCatalog(), + store, + functions: BUILTIN_FUNCTIONS, + disposer: createDisposer(), + surfaceId: 's1', + transport: { sendAction: (a: ClientAction) => sent.push(a) }, + now: () => '2026-07-09T00:00:00.000Z', + } + instantiateNode('root', ictx, host, createRootScope()) + return { host, store, sent } +} + +test('instantiates a single leaf and returns its element', () => { + const { host } = build([{ id: 'root', component: 'Text', text: 'Hello' }]) + assert.match(host.textContent ?? '', /Hello/) +}) + +test('resolves `child` into the parent element', () => { + const { host } = build([ + { id: 'root', component: 'Column', child: 'label' }, + { id: 'label', component: 'Text', text: 'Inside' }, + ]) + const column = host.querySelector('.a2ui-column') + assert.ok(column, 'column rendered') + assert.match(column!.textContent ?? '', /Inside/) +}) + +test('resolves `children` array in order', () => { + const { host } = build([ + { id: 'root', component: 'Column', children: ['a', 'b'] }, + { id: 'a', component: 'Text', text: 'First' }, + { id: 'b', component: 'Text', text: 'Second' }, + ]) + const text = host.querySelector('.a2ui-column')!.textContent ?? '' + assert.ok(text.indexOf('First') < text.indexOf('Second'), `order wrong: ${text}`) +}) + +test('nested containers compose depth-first', () => { + const { host } = build([ + { id: 'root', component: 'Column', children: ['inner'] }, + { id: 'inner', component: 'Row', children: ['leaf'] }, + { id: 'leaf', component: 'Text', text: 'Deep' }, + ]) + assert.match(host.querySelector('.a2ui-column .a2ui-row')!.textContent ?? '', /Deep/) +}) + +test('a bound prop renders the store value', () => { + const { host } = build([{ id: 'root', component: 'Text', text: { path: '/greeting' } }], { + greeting: 'Bound', + }) + assert.match(host.textContent ?? '', /Bound/) +}) + +test('containers render no stray text node from the children slot', () => { + const { host } = build([ + { id: 'root', component: 'Column', children: ['a'] }, + { id: 'a', component: 'Text', text: 'Only' }, + ]) + assert.equal(host.querySelector('.a2ui-column')!.textContent, 'Only') +}) + +test('an unknown component type is rejected by name', () => { + assert.throws( + () => build([{ id: 'root', component: 'Hologram' }]), + /component type "Hologram" is not in the catalog/, + ) +}) + +test('a dangling child id is rejected by name', () => { + assert.throws( + () => build([{ id: 'root', component: 'Column', child: 'ghost' }]), + /no component definition for id "ghost"/, + ) +}) + +test('TextField input writes back to the bound store path immediately', () => { + const { host, store, sent } = build( + [{ id: 'root', component: 'TextField', label: 'Email', value: { path: '/contact/email' } }], + { contact: { email: '' } }, + ) + const input = host.querySelector('input')! + input.value = 'typed@example.com' + input.dispatchEvent(new window.Event('input', { bubbles: true })) + assert.equal(store.contact.email, 'typed@example.com') + assert.equal(sent.length, 0, 'typing must not hit the transport') +}) + +test('CheckBox toggle writes the checked state, not the form-value string', async () => { + const { host, store } = build( + [{ id: 'root', component: 'CheckBox', label: 'Sub', value: { path: '/contact/subscribe' } }], + { contact: { subscribe: false } }, + ) + // Zag components are not clickable under jsdom; gea-ui's own tests drive the + // machine API instead (see gea-ui/tests/zag-bindings.test.ts). This still + // exercises the real Zag -> onCheckedChange -> writePointer path. + const root = host.querySelector('[data-part="root"]')! + const checkbox = (root as any)[GEA_DOM_COMPONENT] + checkbox._api.setChecked(true) + await flushMicrotasks() + assert.equal(store.contact.subscribe, true) +}) + +test('a Button action dispatches through the transport with resolved context', () => { + const { host, sent } = build( + [ + { + id: 'root', + component: 'Button', + action: { + event: { + name: 'submitContactForm', + context: { isNewsletterSubscribed: { path: '/contact/subscribe' } }, + }, + }, + }, + ], + { contact: { subscribe: true } }, + ) + host.querySelector('button')!.click() + assert.deepEqual(sent, [ + { + name: 'submitContactForm', + surfaceId: 's1', + sourceComponentId: 'root', + timestamp: '2026-07-09T00:00:00.000Z', + context: { isNewsletterSubscribed: true }, + }, + ]) +}) + +test('a write inside a collection scope lands on the indexed store path', () => { + const { host, store } = build( + [ + { id: 'root', component: 'Column', child: 'field' }, + { id: 'field', component: 'TextField', value: { path: '/employees/0/name' } }, + ], + { employees: [{ name: 'Ada' }] }, + ) + const input = host.querySelector('input')! + input.value = 'Grace' + input.dispatchEvent(new window.Event('input', { bubbles: true })) + assert.equal(store.employees[0].name, 'Grace') +}) diff --git a/packages/a2ui/tests/pointer.test.ts b/packages/a2ui/tests/pointer.test.ts new file mode 100644 index 0000000..1151691 --- /dev/null +++ b/packages/a2ui/tests/pointer.test.ts @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { Store } from '@geajs/core' +import { parsePointer, readPointer, writePointer, deletePointer } from '../src/pointer' + +test('parsePointer handles root, escapes, and segments', () => { + assert.deepEqual(parsePointer(''), []) + assert.deepEqual(parsePointer('/'), []) // A2UI root convention, not strict RFC 6901 + assert.deepEqual(parsePointer('/contact/email'), ['contact', 'email']) + assert.deepEqual(parsePointer('/a~1b'), ['a/b']) // ~1 -> / + assert.deepEqual(parsePointer('/a~0b'), ['a~b']) // ~0 -> ~ + assert.deepEqual(parsePointer('/m~01'), ['m~1']) // ~0 first, then ~1 + assert.deepEqual(parsePointer('/items/0/name'), ['items', '0', 'name']) +}) + +test('readPointer walks objects and arrays, undefined on holes', () => { + const s = new Store({ contact: { email: 'a@b.c' }, items: [{ name: 'x' }] }) + assert.equal(readPointer(s, ['contact', 'email']), 'a@b.c') + assert.equal(readPointer(s, ['items', '0', 'name']), 'x') + assert.equal(readPointer(s, ['nope', 'deep']), undefined) + assert.equal(readPointer(s, []), s) +}) + +test('writePointer creates intermediates and writes through the proxy', () => { + const s = new Store({}) as any + writePointer(s, ['contact', 'email'], 'j@d.com') + assert.equal(s.contact.email, 'j@d.com') + writePointer(s, ['contact'], { email: 'k@d.com' }) + assert.equal(s.contact.email, 'k@d.com') +}) + +test('writePointer with empty parts replaces the whole model in place', () => { + const s = new Store({ a: 1, b: 2 }) as any + writePointer(s, [], { c: 3 }) + assert.equal(s.a, undefined) + assert.equal(s.b, undefined) + assert.equal(s.c, 3) +}) + +test('deletePointer removes a key; empty parts clears the model', () => { + const s = new Store({ contact: { email: 'x', name: 'y' } }) as any + deletePointer(s, ['contact', 'email']) + assert.equal(s.contact.email, undefined) + assert.equal(s.contact.name, 'y') + deletePointer(s, []) + assert.equal(s.contact, undefined) +}) diff --git a/packages/a2ui/tests/preload.ts b/packages/a2ui/tests/preload.ts new file mode 100644 index 0000000..6ef0a6f --- /dev/null +++ b/packages/a2ui/tests/preload.ts @@ -0,0 +1,9 @@ +import { installDom } from '../../../tests/helpers/jsdom-setup' + +if (typeof globalThis.document === 'undefined') { + installDom() +} + +// Must come after the DOM exists: compiled templates call `document.createElement` +// at module scope when a leaf's static template is first cloned. +import './gea-loader' diff --git a/packages/a2ui/tests/scope.test.ts b/packages/a2ui/tests/scope.test.ts new file mode 100644 index 0000000..22f1b0e --- /dev/null +++ b/packages/a2ui/tests/scope.test.ts @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { Store } from '@geajs/core' +import { + createRootScope, + createCollectionScope, + resolveRead, + resolveWriteParts, +} from '../src/scope' + +test('absolute path reads from the model root at any depth', () => { + const s = new Store({ contact: { email: 'a@b.c' }, employees: [{ name: 'Ada' }] }) + const root = createRootScope() + const row = createCollectionScope(['employees'], 0, (s as any).employees[0], root) + assert.equal(resolveRead(s, '/contact/email', root), 'a@b.c') + assert.equal(resolveRead(s, '/contact/email', row), 'a@b.c') +}) + +test('relative path reads from the current item inside a collection scope', () => { + const s = new Store({ employees: [{ name: 'Ada' }, { name: 'Grace' }] }) as any + const root = createRootScope() + const row1 = createCollectionScope(['employees'], 1, s.employees[1], root) + assert.equal(resolveRead(s, 'name', row1), 'Grace') +}) + +test('relative path in root scope is undefined (not legal per spec)', () => { + const s = new Store({ name: 'nope' }) + assert.equal(resolveRead(s, 'name', createRootScope()), undefined) +}) + +test('resolveWriteParts maps relative to absolute parts through basePath/index', () => { + const root = createRootScope() + const row = createCollectionScope(['employees'], 2, { name: 'X' }, root) + assert.deepEqual(resolveWriteParts('name', row), ['employees', '2', 'name']) + assert.deepEqual(resolveWriteParts('/contact/email', row), ['contact', 'email']) + assert.deepEqual(resolveWriteParts('/a', root), ['a']) +}) + +test('collection scope index is mutable and reflected in write parts', () => { + const row = createCollectionScope(['employees'], 0, { name: 'X' }, createRootScope()) + row.index = 3 + assert.deepEqual(resolveWriteParts('name', row), ['employees', '3', 'name']) +}) diff --git a/packages/a2ui/tests/smoke.test.ts b/packages/a2ui/tests/smoke.test.ts new file mode 100644 index 0000000..bf85ec6 --- /dev/null +++ b/packages/a2ui/tests/smoke.test.ts @@ -0,0 +1,13 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { Store } from '@geajs/core' +import { mount, createDisposer } from '@geajs/core/compiler-runtime' +import { VERSION } from '../src/index' + +test('package resolves core, compiler-runtime, and a DOM', () => { + assert.equal(typeof document, 'object') + assert.equal(typeof Store, 'function') + assert.equal(typeof mount, 'function') + assert.equal(typeof createDisposer, 'function') + assert.equal(VERSION, '0.1.0') +}) diff --git a/packages/a2ui/tests/surface.test.ts b/packages/a2ui/tests/surface.test.ts new file mode 100644 index 0000000..a5f2c2c --- /dev/null +++ b/packages/a2ui/tests/surface.test.ts @@ -0,0 +1,173 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { flushMicrotasks } from '../../../tests/helpers/jsdom-setup' +import { SurfaceRegistry, dispatch } from '../src/index' +import { createBasicCatalog } from '../src/catalog' +import { BUILTIN_FUNCTIONS } from '../src/functions' + +const CATALOG_ID = 'https://a2ui.org/specification/v0_9_1/catalogs/basic/catalog.json' + +function harness() { + const host = document.createElement('div') + document.body.appendChild(host) + const registry = new SurfaceRegistry(() => host) + const opts = { catalog: createBasicCatalog(), functions: BUILTIN_FUNCTIONS } + return { host, registry, opts } +} + +test('createSurface renders nothing and does not mount', () => { + const { host, registry, opts } = harness() + dispatch( + { version: 'v0.9.1', createSurface: { surfaceId: 's1', catalogId: CATALOG_ID } }, + registry, + opts, + ) + assert.equal(registry.get('s1')!.isMounted, false) + assert.equal(host.childNodes.length, 0) +}) + +test('accepts both v0.9 and v0.9.1 envelopes', () => { + const { registry, opts } = harness() + dispatch( + { version: 'v0.9', createSurface: { surfaceId: 's1', catalogId: CATALOG_ID } }, + registry, + opts, + ) + assert.ok(registry.get('s1')) +}) + +test('updateComponents without root buffers and does not mount', () => { + const { host, registry, opts } = harness() + dispatch( + { version: 'v0.9.1', createSurface: { surfaceId: 's1', catalogId: CATALOG_ID } }, + registry, + opts, + ) + dispatch( + { + version: 'v0.9.1', + updateComponents: { + surfaceId: 's1', + components: [{ id: 'orphan', component: 'Text', text: 'x' }], + }, + }, + registry, + opts, + ) + assert.equal(registry.get('s1')!.isMounted, false) + assert.equal(host.childNodes.length, 0) + assert.ok(registry.get('s1')!.componentDefinitions.has('orphan')) +}) + +test('updateComponents with root mounts once, order-independent', () => { + const { host, registry, opts } = harness() + dispatch( + { version: 'v0.9.1', createSurface: { surfaceId: 's1', catalogId: CATALOG_ID } }, + registry, + opts, + ) + dispatch( + { + version: 'v0.9.1', + updateComponents: { + surfaceId: 's1', + components: [ + { id: 'leaf', component: 'Text', text: 'Hi' }, + { id: 'root', component: 'Column', children: ['leaf'] }, + ], + }, + }, + registry, + opts, + ) + assert.equal(registry.get('s1')!.isMounted, true) + assert.match(host.textContent ?? '', /Hi/) +}) + +test('updateDataModel upserts and updates a bound leaf without remounting', async () => { + const { host, registry, opts } = harness() + dispatch( + { version: 'v0.9.1', createSurface: { surfaceId: 's1', catalogId: CATALOG_ID } }, + registry, + opts, + ) + dispatch( + { + version: 'v0.9.1', + updateComponents: { + surfaceId: 's1', + components: [{ id: 'root', component: 'Text', text: { path: '/contact/email' } }], + }, + }, + registry, + opts, + ) + const before = host.firstElementChild + dispatch( + { + version: 'v0.9.1', + updateDataModel: { + surfaceId: 's1', + path: '/contact', + value: { email: 'john.doe@example.com' }, + }, + }, + registry, + opts, + ) + await flushMicrotasks() + assert.match(host.textContent ?? '', /john\.doe@example\.com/) + assert.equal(host.firstElementChild, before, 'no remount: same element identity') +}) + +test('updateDataModel without value removes the path', async () => { + const { registry, opts } = harness() + dispatch( + { version: 'v0.9.1', createSurface: { surfaceId: 's1', catalogId: CATALOG_ID } }, + registry, + opts, + ) + const s = registry.get('s1')!.dataModel as any + s.contact = { email: 'x' } + dispatch( + { version: 'v0.9.1', updateDataModel: { surfaceId: 's1', path: '/contact/email' } }, + registry, + opts, + ) + await flushMicrotasks() + assert.equal(s.contact.email, undefined) +}) + +test('updateDataModel with no path replaces the whole model, preserving Store identity', async () => { + const { registry, opts } = harness() + dispatch( + { version: 'v0.9.1', createSurface: { surfaceId: 's1', catalogId: CATALOG_ID } }, + registry, + opts, + ) + const surface = registry.get('s1')! + const identity = surface.dataModel + ;(surface.dataModel as any).old = 1 + dispatch( + { version: 'v0.9.1', updateDataModel: { surfaceId: 's1', value: { fresh: 2 } } }, + registry, + opts, + ) + await flushMicrotasks() + assert.equal(surface.dataModel, identity) + assert.equal((surface.dataModel as any).old, undefined) + assert.equal((surface.dataModel as any).fresh, 2) +}) + +test('an envelope for an unknown surface is rejected by id', () => { + const { registry, opts } = harness() + assert.throws( + () => + dispatch( + { version: 'v0.9.1', updateDataModel: { surfaceId: 'nope', value: {} } }, + registry, + opts, + ), + /unknown surfaceId "nope"/, + ) +}) diff --git a/packages/a2ui/tests/template-list.test.ts b/packages/a2ui/tests/template-list.test.ts new file mode 100644 index 0000000..70a4e8d --- /dev/null +++ b/packages/a2ui/tests/template-list.test.ts @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { Store } from '@geajs/core' +import { createDisposer } from '@geajs/core/compiler-runtime' +import { flushMicrotasks } from '../../../tests/helpers/jsdom-setup' +import { BUILTIN_FUNCTIONS } from '../src/functions' +import { createRootScope } from '../src/scope' +import { createBasicCatalog } from '../src/catalog' +import { instantiateNode } from '../src/instantiate' +import type { ComponentDefinition } from '../src/types' + +function build(defs: ComponentDefinition[], data: Record) { + const store = new Store(data) as any + const host = document.createElement('div') + document.body.appendChild(host) + instantiateNode( + 'root', + { + definitions: new Map(defs.map((d) => [d.id, d])), + catalog: createBasicCatalog(), + store, + functions: BUILTIN_FUNCTIONS, + disposer: createDisposer(), + surfaceId: 's1', + transport: { sendAction() {} }, + now: () => '2026-07-09T00:00:00.000Z', + }, + host, + createRootScope(), + ) + return { host, store } +} + +const DEFS: ComponentDefinition[] = [ + { id: 'root', component: 'Column', children: { componentId: 'row_tpl', path: '/employees' } }, + { id: 'row_tpl', component: 'Text', text: { path: 'name' } }, +] + +function names(host: Element): string[] { + return [...host.querySelectorAll('.a2ui-text')].map((n) => n.textContent ?? '') +} + +test('template list renders one row per array item using relative paths', () => { + const { host } = build(DEFS, { + employees: [ + { id: 1, name: 'Ada' }, + { id: 2, name: 'Grace' }, + ], + }) + assert.deepEqual(names(host), ['Ada', 'Grace']) +}) + +test('appending an item adds exactly one row', async () => { + const { host, store } = build(DEFS, { employees: [{ id: 1, name: 'Ada' }] }) + store.employees.push({ id: 2, name: 'Grace' }) + await flushMicrotasks() + assert.deepEqual(names(host), ['Ada', 'Grace']) +}) + +test('removing an item removes its row', async () => { + const { host, store } = build(DEFS, { + employees: [ + { id: 1, name: 'Ada' }, + { id: 2, name: 'Grace' }, + ], + }) + store.employees.splice(0, 1) + await flushMicrotasks() + assert.deepEqual(names(host), ['Grace']) +}) + +test('reordering preserves row element identity (LIS-minimal moves)', async () => { + const { host, store } = build(DEFS, { + employees: [ + { id: 1, name: 'Ada' }, + { id: 2, name: 'Grace' }, + ], + }) + const adaBefore = [...host.querySelectorAll('.a2ui-text')].find((n) => n.textContent === 'Ada') + store.employees.reverse() + await flushMicrotasks() + assert.deepEqual(names(host), ['Grace', 'Ada']) + const adaAfter = [...host.querySelectorAll('.a2ui-text')].find((n) => n.textContent === 'Ada') + assert.equal(adaAfter, adaBefore, 'Ada row was moved, not recreated') +}) + +test('an empty array renders no rows', () => { + const { host } = build(DEFS, { employees: [] }) + assert.deepEqual(names(host), []) +}) diff --git a/packages/a2ui/tests/types.test.ts b/packages/a2ui/tests/types.test.ts new file mode 100644 index 0000000..2e48cfe --- /dev/null +++ b/packages/a2ui/tests/types.test.ts @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { isDataBinding, isFunctionCall, isTemplateChildList } from '../src/types' + +test('isDataBinding requires exactly one key named path', () => { + assert.equal(isDataBinding({ path: '/a' }), true) + assert.equal(isDataBinding({ path: '/a', extra: 1 }), false) // additionalProperties: false + assert.equal(isDataBinding({ call: 'email' }), false) + assert.equal(isDataBinding('/a'), false) + assert.equal(isDataBinding(null), false) + assert.equal(isDataBinding([{ path: '/a' }]), false) +}) + +test('isFunctionCall requires call; args is optional', () => { + assert.equal(isFunctionCall({ call: 'required' }), true) // args optional per schema + assert.equal(isFunctionCall({ call: 'email', args: { value: '/a' } }), true) + assert.equal(isFunctionCall({ args: {} }), false) + assert.equal(isFunctionCall({ path: '/a' }), false) +}) + +test('isTemplateChildList distinguishes template object from static array', () => { + assert.equal(isTemplateChildList({ componentId: 'row', path: '/items' }), true) + assert.equal(isTemplateChildList(['a', 'b']), false) + assert.equal(isTemplateChildList({ componentId: 'row' }), false) // path required +}) diff --git a/packages/a2ui/tsconfig.json b/packages/a2ui/tsconfig.json new file mode 100644 index 0000000..0f361cf --- /dev/null +++ b/packages/a2ui/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../gea/tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "@geajs/core", + "paths": { + "@geajs/core": ["../gea/src"], + "@geajs/core/*": ["../gea/src/*"], + "@geajs/ui": ["../gea-ui/src"], + "@geajs/ui/*": ["../gea-ui/src/components/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/a2ui/tsdown.config.ts b/packages/a2ui/tsdown.config.ts new file mode 100644 index 0000000..5cdf71a --- /dev/null +++ b/packages/a2ui/tsdown.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'tsdown' +import { geaPlugin } from '../vite-plugin-gea/src/index' + +export default defineConfig({ + entry: { index: 'src/index.ts' }, + plugins: [geaPlugin() as any], + format: 'esm', + outDir: 'dist', + clean: true, + sourcemap: true, + dts: { build: true }, + target: 'es2022', + platform: 'browser', + external: [/^@geajs\/core/, /^@geajs\/ui/], + define: { 'import.meta.hot': 'undefined', 'import.meta.url': '""' }, + hash: false, + fixedExtension: true, +}) diff --git a/packages/gea/src/compiler-runtime.ts b/packages/gea/src/compiler-runtime.ts index 3846908..b9adc7b 100644 --- a/packages/gea/src/compiler-runtime.ts +++ b/packages/gea/src/compiler-runtime.ts @@ -1,4 +1,5 @@ export { createDisposer, NOOP_DISPOSER } from './runtime/disposer' +export type { Disposer } from './runtime/disposer' export { scheduleAfterRenderAsync } from './runtime/after-render-async' export { CompiledComponent } from './runtime/compiled-component' export { CompiledLeanReactiveComponent } from './runtime/compiled-lean-reactive-component' @@ -25,6 +26,8 @@ export { mount } from './runtime/mount' export { conditional } from './runtime/conditional' export { conditionalTruthy } from './runtime/conditional-truthy' export { keyedList } from './runtime/keyed-list' +export type { KeyedListConfig } from './runtime/keyed-list' +export type { Entry, ItemObservable } from './runtime/keyed-list/types' export { GEA_DOM_ITEM, GEA_DOM_KEY } from './runtime/keyed-list-symbols' export { keyedListSimple } from './runtime/keyed-list-simple' export { keyedListProp } from './runtime/keyed-list-prop' diff --git a/packages/gea/src/store.ts b/packages/gea/src/store.ts index 7eeef32..c165c2f 100644 --- a/packages/gea/src/store.ts +++ b/packages/gea/src/store.ts @@ -44,6 +44,12 @@ export interface Change { /** In-place array-item property update — arix = item index in the parent array. */ aipu?: boolean arix?: number + /** + * Read by keyed-list's `itemDirtyOnly` fast path. Nothing in the store sets + * it today, so that path stays disabled; the field is declared because the + * reader exists and `Change` is a public type. + */ + itemDirty?: boolean /** Value after change. */ newValue?: any /** Target object that was mutated (proxy or raw). */