diff --git a/examples/demo-zk-books/pom.xml b/examples/demo-zk-books/pom.xml index fbe29497..186ce3e7 100644 --- a/examples/demo-zk-books/pom.xml +++ b/examples/demo-zk-books/pom.xml @@ -47,7 +47,7 @@ ${maven.build.timestamp} yyyyMMdd - 26.6.0 + 26.7.0 diff --git a/examples/demo-zk-books/src/main/java/mybookstore/providers/DemoHostContextProvider.java b/examples/demo-zk-books/src/main/java/mybookstore/providers/DemoHostContextProvider.java new file mode 100644 index 00000000..b8280347 --- /dev/null +++ b/examples/demo-zk-books/src/main/java/mybookstore/providers/DemoHostContextProvider.java @@ -0,0 +1,24 @@ +package mybookstore.providers; + +import tools.dynamia.integration.sterotypes.Component; +import tools.dynamia.zk.ui.MicroFrontendHostContextProvider; + +import java.util.Map; + +/** + * Demonstrates tools.dynamia.zk.ui.MicroFrontendHostContextProvider: every value returned here + * reaches every mounted microfrontend on this app automatically as the "dynamiaHost" prop, without + * any ZUL author having to bind it by hand. See microfrontend-demo.zul. + */ +@Component +public class DemoHostContextProvider implements MicroFrontendHostContextProvider { + + @Override + public Map getHostContext() { + return Map.of( + "tenantId", "demo-bookstore", + "locale", "es_CO", + "apiBaseUrl", "/api" + ); + } +} diff --git a/examples/demo-zk-books/src/main/java/mybookstore/providers/MyBookStoreModuleProvider.java b/examples/demo-zk-books/src/main/java/mybookstore/providers/MyBookStoreModuleProvider.java index 94662ba7..b88f99c9 100644 --- a/examples/demo-zk-books/src/main/java/mybookstore/providers/MyBookStoreModuleProvider.java +++ b/examples/demo-zk-books/src/main/java/mybookstore/providers/MyBookStoreModuleProvider.java @@ -45,6 +45,7 @@ public Module getModule() { //<2> .addPage( new Page("components", "Custom Components", "classpath:/pages/custom-components.zul"), new Page("vue", "Vue Example", "classpath:/pages/vue-integration.zul"), + new Page("microfrontend", "MicroFrontend", "classpath:/pages/microfrontend-demo.zul"), new Page("mvvm", "Standard ZK MVVM", "classpath:/pages/standard-mvvm.zul"), new Page("chartjs", "Charts for ZK", "classpath:/pages/chartjs.zul"), new Page("aceditor", "Ace Code Editor", "classpath:/pages/aceditor.zul"), diff --git a/examples/demo-zk-books/src/main/java/mybookstore/viewmodel/MicroFrontendDemoVM.java b/examples/demo-zk-books/src/main/java/mybookstore/viewmodel/MicroFrontendDemoVM.java new file mode 100644 index 00000000..4e85cebc --- /dev/null +++ b/examples/demo-zk-books/src/main/java/mybookstore/viewmodel/MicroFrontendDemoVM.java @@ -0,0 +1,95 @@ +package mybookstore.viewmodel; + +import org.zkoss.bind.annotation.BindingParam; +import org.zkoss.bind.annotation.Command; +import org.zkoss.bind.annotation.NotifyChange; + +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.util.Random; + +/** + * Backs the "MVVM mode" panel of the MicroFrontend demo (microfrontend-demo.zul). Every bound + * value below is a plain bean property, so it works with {@code @bind(vm.xxx)} with zero extra + * code in tools.dynamia.zk.ui.MicroFrontend. {@link #getUser()} in particular proves that binding + * a Java object (not just Strings/numbers) reaches the browser as real JSON: MicroFrontend's + * buildConfig() serializes the whole props map through StringPojoParser.convertMapToJson, which is + * backed by Jackson and recursively serializes nested POJOs, not toString(). + */ +public class MicroFrontendDemoVM { + + private static final String[] ROLES = {"admin", "editor", "viewer"}; + private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm:ss"); + private final Random random = new Random(); + + private String theme = "dark"; + private boolean shadow; + private DemoUser user = new DemoUser(1L, "Ada Lovelace", "admin"); + private String lastEvent = "(none yet)"; + + public String getTheme() { + return theme; + } + + public boolean isShadow() { + return shadow; + } + + public DemoUser getUser() { + return user; + } + + public String getLastEvent() { + return lastEvent; + } + + @Command + @NotifyChange("theme") + public void toggleTheme() { + theme = "dark".equals(theme) ? "light" : "dark"; + } + + @Command + @NotifyChange("shadow") + public void toggleShadow() { + shadow = !shadow; + } + + @Command + @NotifyChange("user") + public void randomizeUser() { + user = new DemoUser(random.nextLong(1000), "User " + random.nextInt(1000), ROLES[random.nextInt(ROLES.length)]); + } + + @Command + @NotifyChange("lastEvent") + public void handleMicrofrontendEvent(@BindingParam("data") Object data) { + lastEvent = LocalTime.now().format(TIME_FORMAT) + " -> " + data; + } + + /** Bound as a whole to the "user" prop, proving nested POJO-to-JSON serialization. */ + public static class DemoUser { + + private final Long id; + private final String name; + private final String role; + + public DemoUser(Long id, String name, String role) { + this.id = id; + this.name = name; + this.role = role; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public String getRole() { + return role; + } + } +} diff --git a/examples/demo-zk-books/src/main/resources/pages/microfrontend-demo.zul b/examples/demo-zk-books/src/main/resources/pages/microfrontend-demo.zul new file mode 100644 index 00000000..196f0f95 --- /dev/null +++ b/examples/demo-zk-books/src/main/resources/pages/microfrontend-demo.zul @@ -0,0 +1,76 @@ + + + + +
+ MicroFrontend Component Demo + +
+ + + + + + + +
+ +
+
diff --git a/examples/demo-zk-books/src/main/resources/static/css/microfrontend-demo.css b/examples/demo-zk-books/src/main/resources/static/css/microfrontend-demo.css new file mode 100644 index 00000000..022c38b2 --- /dev/null +++ b/examples/demo-zk-books/src/main/resources/static/css/microfrontend-demo.css @@ -0,0 +1,10 @@ +/* Loaded via the MicroFrontend "css" attribute, proves external stylesheets ship with a bundle. */ +.demo-widget-emit-btn { + margin-top: .5rem; + background: #6c5ce7; + color: #fff; + border: none; + border-radius: 6px; + padding: .4rem .8rem; + cursor: pointer; +} diff --git a/examples/demo-zk-books/src/main/resources/static/js/microfrontend-demo.js b/examples/demo-zk-books/src/main/resources/static/js/microfrontend-demo.js new file mode 100644 index 00000000..518201fc --- /dev/null +++ b/examples/demo-zk-books/src/main/resources/static/js/microfrontend-demo.js @@ -0,0 +1,87 @@ +/** + * Fake "microfrontend" bundle used to manually verify tools.dynamia.zk.ui.MicroFrontend. + * Simulates what a Vue/React build would ship: a custom element AND a mount/unmount pair. + */ +(function () { + class DemoWidget extends HTMLElement { + connectedCallback() { + console.log('[demo-widget] connectedCallback', { + userId: this.userId, theme: this.theme, user: this.user, dynamiaHost: this.dynamiaHost + }); + var userLine = ''; + if (this.user && typeof this.user === 'object') { + userLine = 'user: ' + JSON.stringify(this.user) + ' (typeof ' + typeof this.user + ')
'; + } + var hostLine = ''; + if (this.dynamiaHost && typeof this.dynamiaHost === 'object') { + hostLine = 'dynamiaHost: ' + JSON.stringify(this.dynamiaHost) + '
'; + } + this.innerHTML = + '
' + + 'custom-element mode
' + + 'userId: ' + this.userId + '
' + + 'theme: ' + this.theme + '
' + + userLine + hostLine + + '
' + + '' + + '
'; + var btn = this.querySelector('#inc'); + var clicks = 0; + btn.addEventListener('click', function () { + clicks++; + btn.textContent = 'clicks: ' + clicks; + }); + var dynamiaEmit = this.dynamiaEmit; + this.querySelector('#emit').addEventListener('click', function () { + if (typeof dynamiaEmit === 'function') { + dynamiaEmit({from: 'custom-element', clicks: clicks}); + } + }); + } + + disconnectedCallback() { + console.log('[demo-widget] disconnectedCallback (auto cleanup by browser)'); + } + } + + if (!customElements.get('demo-widget')) { + customElements.define('demo-widget', DemoWidget); + } + + function renderMountDemoApp(container, props) { + var hostLine = props.dynamiaHost ? ('dynamiaHost: ' + JSON.stringify(props.dynamiaHost) + '
') : ''; + container.innerHTML = + '
' + + 'mount-fn mode
userId: ' + props.userId + '
' + + hostLine + + 'in-place updates: ' + (container._dynamiaUpdateCount || 0) + '
' + + '
'; + container.querySelector('#emit').addEventListener('click', function () { + if (typeof props.dynamiaEmit === 'function') { + props.dynamiaEmit({from: 'mount-fn', userId: props.userId}); + } + }); + } + + window.mountDemoApp = function (container, props) { + container._dynamiaUpdateCount = 0; + renderMountDemoApp(container, props); + console.log('[demo-app] mounted via mount-fn', props); + }; + + /** + * Optional single-spa-style "update" hook (MicroFrontend's updateFn=): called instead of + * unmount+mount when only props changed, so the bundle can preserve its own state. Here we + * just bump a counter on the container to prove in the UI/console that no full remount happened. + */ + window.updateDemoApp = function (container, props) { + container._dynamiaUpdateCount = (container._dynamiaUpdateCount || 0) + 1; + renderMountDemoApp(container, props); + console.log('[demo-app] updated in place via updateFn (#' + container._dynamiaUpdateCount + ')', props); + }; + + window.unmountDemoApp = function (container) { + container.innerHTML = ''; + console.log('[demo-app] unmounted via mount-fn'); + }; +})(); diff --git a/examples/demo-zk-books/src/main/resources/static/next/demo-app/assets/index-a1b2c3d4.js b/examples/demo-zk-books/src/main/resources/static/next/demo-app/assets/index-a1b2c3d4.js new file mode 100644 index 00000000..abbbf8da --- /dev/null +++ b/examples/demo-zk-books/src/main/resources/static/next/demo-app/assets/index-a1b2c3d4.js @@ -0,0 +1,18 @@ +/** + * Fake hashed Vite chunk used to manually verify MicroFrontend's "app" auto-discovery + "auto" + * mount mode: a plain self-mounting SPA bundle, like `createApp(App).mount('#demo-app-root')` + * would produce, with no custom element and no window mount/unmount functions. + */ +(function () { + var root = document.getElementById('demo-app-root'); + if (!root) { + console.error('[demo-app] #demo-app-root not found, was the index.html body replicated?'); + return; + } + console.log('[demo-app] self-mounted into #demo-app-root'); + root.innerHTML = + '
' + + 'app auto-discovery mode
' + + 'self-mounted, no tag/mountFn needed' + + '
'; +})(); diff --git a/examples/demo-zk-books/src/main/resources/static/next/demo-app/assets/index-e5f6g7h8.css b/examples/demo-zk-books/src/main/resources/static/next/demo-app/assets/index-e5f6g7h8.css new file mode 100644 index 00000000..af2078b0 --- /dev/null +++ b/examples/demo-zk-books/src/main/resources/static/next/demo-app/assets/index-e5f6g7h8.css @@ -0,0 +1,6 @@ +/* Fake hashed Vite CSS chunk, loaded automatically via MicroFrontend's "app" discovery. */ +.demo-app-widget-box { + padding: 1rem; + border: 2px dotted #e17055; + border-radius: 8px; +} diff --git a/examples/demo-zk-books/src/main/resources/static/next/demo-app/index.html b/examples/demo-zk-books/src/main/resources/static/next/demo-app/index.html new file mode 100644 index 00000000..0897a72f --- /dev/null +++ b/examples/demo-zk-books/src/main/resources/static/next/demo-app/index.html @@ -0,0 +1,14 @@ + + + + + + demo-app + + + + +
+ + diff --git a/platform/packages/microfrontend-bridge/README.md b/platform/packages/microfrontend-bridge/README.md new file mode 100644 index 00000000..e923bfc6 --- /dev/null +++ b/platform/packages/microfrontend-bridge/README.md @@ -0,0 +1,110 @@ +# @dynamia-tools/microfrontend-bridge + +> Typed helpers for JS/TS bundles (Vue, React, Svelte, plain JS) embedded via `tools.dynamia.zk.ui.MicroFrontend`. + +`MicroFrontend` embeds an external JS bundle inside a ZK page and talks to it through a small, +ad-hoc protocol: an injected `dynamiaEmit(data)` callback to notify the server, and a +`dynamiaHost` prop with server-registered context (tenant, locale, API base URL, ...). This +package wraps that protocol in three small, fully-typed functions so bundle authors don't have to +know the wire details or duck-type props by hand. + +Zero runtime dependencies, works with any bundler/framework — this only touches plain objects. + +## Installation + +```bash +npm install @dynamia-tools/microfrontend-bridge +``` + +## API + +- `isDynamiaHost(target)` — whether `dynamiaEmit` was actually injected (false in local/standalone + dev, and always false in `MicroFrontend`'s `"auto"` mode, which has no prop channel at all). +- `emitToHost(target, data)` — sends `data` back to the server, which fires `onMicrofrontendEvent`. + No-ops with a console warning (never throws) when not running inside a Dynamia host. +- `getHostContext(target)` — reads the merged `dynamiaHost` context, or `{}` if none was registered. +- Types: `DynamiaMicrofrontendProps`, `DynamiaHostContext`. + +In every function, `target` is whatever object actually carries the props: the custom element +instance itself (`this`) in `MODE_CUSTOM_ELEMENT`, or the `props` argument in `MODE_MOUNT_FN`'s +`mountFn`/`updateFn`. + +## Usage + +### Custom element (e.g. Vue's `defineCustomElement`) + +```ts +import { defineCustomElement } from 'vue'; +import { emitToHost, getHostContext, type DynamiaMicrofrontendProps } from '@dynamia-tools/microfrontend-bridge'; +import App from './App.vue'; + +const MyWidget = defineCustomElement({ + props: ['userId'], + setup(props) { + // `this` inside a Vue defineCustomElement's methods is the element instance itself, + // which is exactly what MicroFrontend assigns dynamiaEmit/dynamiaHost onto. + function notifyHost(data: unknown) { + emitToHost(this as unknown as DynamiaMicrofrontendProps, data); + } + const host = getHostContext(this as unknown as DynamiaMicrofrontendProps); + // ...use props.userId, host.tenantId, host.locale, etc. + }, +}); + +customElements.define('my-widget', MyWidget); +``` + +```xml + + +``` + +### `mount-fn` convention (single-spa style) + +```ts +import { emitToHost, getHostContext, type DynamiaMicrofrontendProps } from '@dynamia-tools/microfrontend-bridge'; + +window.mountMyApp = (container: HTMLElement, props: DynamiaMicrofrontendProps) => { + const host = getHostContext(props); + fetch(`${host.apiBaseUrl ?? '/api'}/things?tenant=${host.tenantId}`) + .then((res) => res.json()) + .then((things) => render(container, things, () => emitToHost(props, { type: 'clicked' }))); +}; + +window.updateMyApp = (container: HTMLElement, props: DynamiaMicrofrontendProps) => { + // called instead of a full unmount+mount when only props changed (MicroFrontend's updateFn=) + rerender(container, props); +}; + +window.unmountMyApp = (container: HTMLElement) => { + container.innerHTML = ''; +}; +``` + +```xml + + +``` + +## Providing host context (Java side) + +```java +@Component +public class TenantHostContextProvider implements MicroFrontendHostContextProvider { + @Override + public Map getHostContext() { + Account account = AccountServiceAPI.getCurrentAccount(); + return Map.of( + "tenantId", account.getId(), + "locale", LocaleUtils.getCurrentLocale().toLanguageTag(), + "apiBaseUrl", "/api" + ); + } +} +``` + +## License + +Apache-2.0 diff --git a/platform/packages/microfrontend-bridge/package.json b/platform/packages/microfrontend-bridge/package.json new file mode 100644 index 00000000..40a6e13e --- /dev/null +++ b/platform/packages/microfrontend-bridge/package.json @@ -0,0 +1,72 @@ +{ + "name": "@dynamia-tools/microfrontend-bridge", + "version": "26.7.0", + "website": "https://dynamia.tools", + "description": "Typed helpers for JS/TS bundles (Vue, React, Svelte, plain JS) embedded via tools.dynamia.zk.ui.MicroFrontend", + "keywords": [ + "dynamia", + "dynamia-platform", + "microfrontend", + "zk", + "custom-elements", + "typescript" + ], + "homepage": "https://dynamia.tools", + "bugs": { + "url": "https://github.com/dynamiatools/framework/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/dynamiatools/framework.git", + "directory": "platform/packages/microfrontend-bridge" + }, + "license": "Apache-2.0", + "author": "Dynamia Soluciones IT SAS", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "development": "./src/index.ts", + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "vite build", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "typecheck": "tsc --noEmit", + "lint": "eslint src", + "clean": "rm -rf dist", + "prepublishOnly": "pnpm run build && pnpm run test" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "devDependencies": { + "@types/node": "^26.1.1", + "@vitest/coverage-v8": "^4.1.10", + "typescript": "^6.0.3", + "vite": "^8.1.4", + "vite-plugin-dts": "^5.0.3", + "vitest": "^4.1.10" + }, + "engines": { + "node": ">=24" + } +} diff --git a/platform/packages/microfrontend-bridge/src/bridge.ts b/platform/packages/microfrontend-bridge/src/bridge.ts new file mode 100644 index 00000000..71891a9e --- /dev/null +++ b/platform/packages/microfrontend-bridge/src/bridge.ts @@ -0,0 +1,44 @@ +import type { DynamiaHostContext, DynamiaMicrofrontendProps } from './types.js'; + +/** + * Whether `target` looks like it's actually running inside a `tools.dynamia.zk.ui.MicroFrontend` + * host, i.e. `dynamiaEmit` was injected. False for MicroFrontend's "auto" mode (no prop channel at + * all) and for local/standalone dev runs outside any Dynamia host. + * + * @param target a custom element instance (pass `this`) or the props object a mount-fn/updateFn receives + */ +export function isDynamiaHost(target: DynamiaMicrofrontendProps | null | undefined): boolean { + return typeof target?.dynamiaEmit === 'function'; +} + +/** + * Sends `data` back to the server-side MicroFrontend component hosting this bundle, which fires + * "onMicrofrontendEvent" server-side (bindable with `onMicrofrontendEvent="@command(...)"`). Works + * the same whether `target` is a custom element instance (pass `this`) or the `props` object a + * mount-fn/updateFn receives — both carry `dynamiaEmit` the same way. No-ops with a console warning + * instead of throwing when not running inside a Dynamia host (see {@link isDynamiaHost}), so + * bundles keep working standalone during local development. + * + * @param target a custom element instance (pass `this`) or the props object a mount-fn/updateFn receives + * @param data JSON-serializable payload, delivered as `event.getData()` server-side + */ +export function emitToHost(target: DynamiaMicrofrontendProps, data: unknown): void { + if (typeof target?.dynamiaEmit === 'function') { + target.dynamiaEmit(data); + } else if (typeof console !== 'undefined') { + console.warn( + '[@dynamia-tools/microfrontend-bridge] emitToHost() called but dynamiaEmit is not available ' + + '- not running inside a MicroFrontend host, or this is "auto" mode (no prop channel).' + ); + } +} + +/** + * Returns the host context merged from every registered `MicroFrontendHostContextProvider`, or + * `{}` if none were registered (or not running inside a Dynamia host). + * + * @param target a custom element instance (pass `this`) or the props object a mount-fn/updateFn receives + */ +export function getHostContext(target: DynamiaMicrofrontendProps | null | undefined): DynamiaHostContext { + return target?.dynamiaHost ?? {}; +} diff --git a/platform/packages/microfrontend-bridge/src/index.ts b/platform/packages/microfrontend-bridge/src/index.ts new file mode 100644 index 00000000..9df9a699 --- /dev/null +++ b/platform/packages/microfrontend-bridge/src/index.ts @@ -0,0 +1,2 @@ +export {emitToHost, getHostContext, isDynamiaHost} from './bridge.js'; +export type {DynamiaHostContext, DynamiaMicrofrontendProps} from './types.js'; diff --git a/platform/packages/microfrontend-bridge/src/types.ts b/platform/packages/microfrontend-bridge/src/types.ts new file mode 100644 index 00000000..1816e92c --- /dev/null +++ b/platform/packages/microfrontend-bridge/src/types.ts @@ -0,0 +1,36 @@ +/** + * Cross-cutting context values registered server-side via one or more + * `tools.dynamia.zk.ui.MicroFrontendHostContextProvider` beans, merged and delivered as the + * `dynamiaHost` prop on every mount (except MicroFrontend's "auto" mode, which has no prop channel + * at all). Shape is entirely app-defined; the fields below are just common examples. + */ +export interface DynamiaHostContext { + tenantId?: string; + locale?: string; + apiBaseUrl?: string; + + [key: string]: unknown; +} + +/** + * Shape of what `tools.dynamia.zk.ui.MicroFrontend` actually delivers to a mounted bundle: + * - In "custom-element" mode, these are assigned as properties directly on the element instance + * (`this.dynamiaEmit`, `this.dynamiaHost`, ...). + * - In "mount-fn" mode, they're the second argument passed to `mountFn(container, props)` / + * `updateFn(container, props)`. + * + * Any other key is an app-defined prop bound via a ZUL attribute (e.g. `userId="@bind(vm.userId)"`) + * or `MicroFrontend#addProp`/`#setProps`. + */ +export interface DynamiaMicrofrontendProps { + /** + * Sends `data` back to the server-side MicroFrontend component, which fires + * "onMicrofrontendEvent" (bindable with `onMicrofrontendEvent="@command(...)"`). Undefined + * when not running inside a Dynamia host — see {@link isDynamiaHost}. + */ + dynamiaEmit?: (data: unknown) => void; + /** Merged {@link DynamiaHostContext} from every registered MicroFrontendHostContextProvider. */ + dynamiaHost?: DynamiaHostContext; + + [key: string]: unknown; +} diff --git a/platform/packages/microfrontend-bridge/test/bridge.test.ts b/platform/packages/microfrontend-bridge/test/bridge.test.ts new file mode 100644 index 00000000..c5c8c7d6 --- /dev/null +++ b/platform/packages/microfrontend-bridge/test/bridge.test.ts @@ -0,0 +1,41 @@ +import {describe, expect, it, vi} from 'vitest'; +import {emitToHost, getHostContext, isDynamiaHost} from '../src/bridge.js'; + +describe('isDynamiaHost', () => { + it('is true when dynamiaEmit is a function', () => { + expect(isDynamiaHost({dynamiaEmit: () => undefined})).toBe(true); + }); + + it('is false when dynamiaEmit is missing or not a function', () => { + expect(isDynamiaHost({})).toBe(false); + expect(isDynamiaHost(null)).toBe(false); + expect(isDynamiaHost(undefined)).toBe(false); + }); +}); + +describe('emitToHost', () => { + it('calls dynamiaEmit with the given data', () => { + const dynamiaEmit = vi.fn(); + emitToHost({dynamiaEmit}, {foo: 'bar'}); + expect(dynamiaEmit).toHaveBeenCalledWith({foo: 'bar'}); + }); + + it('warns instead of throwing when dynamiaEmit is missing', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + expect(() => emitToHost({}, {foo: 'bar'})).not.toThrow(); + expect(warn).toHaveBeenCalledOnce(); + warn.mockRestore(); + }); +}); + +describe('getHostContext', () => { + it('returns dynamiaHost when present', () => { + expect(getHostContext({dynamiaHost: {tenantId: 't1'}})).toEqual({tenantId: 't1'}); + }); + + it('returns {} when absent', () => { + expect(getHostContext({})).toEqual({}); + expect(getHostContext(null)).toEqual({}); + expect(getHostContext(undefined)).toEqual({}); + }); +}); diff --git a/platform/packages/microfrontend-bridge/tsconfig.build.json b/platform/packages/microfrontend-bridge/tsconfig.build.json new file mode 100644 index 00000000..8bfff951 --- /dev/null +++ b/platform/packages/microfrontend-bridge/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "noEmit": false, + "declaration": true, + "declarationMap": true + }, + "include": ["src"] +} diff --git a/platform/packages/microfrontend-bridge/tsconfig.json b/platform/packages/microfrontend-bridge/tsconfig.json new file mode 100644 index 00000000..d6d13237 --- /dev/null +++ b/platform/packages/microfrontend-bridge/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "customConditions": ["development"], + "noEmit": true + }, + "include": ["src", "test"] +} diff --git a/platform/packages/microfrontend-bridge/vite.config.ts b/platform/packages/microfrontend-bridge/vite.config.ts new file mode 100644 index 00000000..8246c875 --- /dev/null +++ b/platform/packages/microfrontend-bridge/vite.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from 'vite'; +import dts from 'vite-plugin-dts'; +import { resolve } from 'path'; + +export default defineConfig({ + plugins: [ + dts({ + include: ['src'], + outDir: 'dist', + insertTypesEntry: true, + tsconfigPath: './tsconfig.build.json', + }), + ], + build: { + lib: { + entry: resolve(__dirname, 'src/index.ts'), + name: 'DynamiaMicrofrontendBridge', + formats: ['es', 'cjs'], + fileName: (format) => (format === 'es' ? 'index.js' : 'index.cjs'), + }, + rollupOptions: { + external: [], + }, + sourcemap: true, + minify: false, + }, +}); diff --git a/platform/packages/microfrontend-bridge/vitest.config.ts b/platform/packages/microfrontend-bridge/vitest.config.ts new file mode 100644 index 00000000..001cbcb7 --- /dev/null +++ b/platform/packages/microfrontend-bridge/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + coverage: { + provider: 'v8', + reporter: ['text', 'lcov'], + }, + }, +}); diff --git a/platform/ui/zk/src/main/java/tools/dynamia/zk/ComponentAliasIndex.java b/platform/ui/zk/src/main/java/tools/dynamia/zk/ComponentAliasIndex.java index e51fc592..cc755081 100644 --- a/platform/ui/zk/src/main/java/tools/dynamia/zk/ComponentAliasIndex.java +++ b/platform/ui/zk/src/main/java/tools/dynamia/zk/ComponentAliasIndex.java @@ -86,6 +86,7 @@ public class ComponentAliasIndex extends HashMap + * The bundle is loaded once per {@code src} (loads are cached/shared client-side across every + * {@code MicroFrontend} instance on the page) and mounted using one of two strategies, selected + * with {@link #setMode(String)}: + *
    + *
  • {@link #MODE_CUSTOM_ELEMENT} (default): the bundle registers a + * Custom Element + * (e.g. via {@code customElements.define(...)}, or Vue's {@code defineCustomElement}). This + * component creates that element, assigns {@link #setProps(Map)} as properties/attributes and + * appends it to its container. The browser mounts/unmounts it automatically through the + * element's own {@code connectedCallback}/{@code disconnectedCallback}.
  • + *
  • {@link #MODE_MOUNT_FN}: the bundle exposes global mount/unmount functions (a convention + * similar to single-spa), invoked explicitly as {@code window[mountFn](container, props)} and + * {@code window[unmountFn](container)} when this component is rendered and detached. If the + * bundle also exposes {@link #setUpdateFn}, prop-only changes call + * {@code window[updateFn](container, props)} instead of unmounting/remounting, so the bundle + * can preserve its internal state (e.g. an in-progress form) across a reactive prop update.
  • + *
  • {@link #MODE_AUTO}: for a bundle that self-mounts into a hardcoded selector at import + * time, as a default {@code vite build} of an app (not a custom-element library) does, e.g. + * {@code createApp(App).mount('#app')}. Selected automatically when {@link #setApp(String)} is + * used and neither {@link #setTag} nor {@link #setMountFn} is set: the {@code } markup + * of the discovered {@code index.html} (its mount target element(s), stripped of any + * {@code