From 65783ec85b8eaf3504a7a7a1e76fc6eff9d4aee8 Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Tue, 29 Sep 2026 23:43:49 -0500 Subject: [PATCH] feat(ui-core,vue): inline same-origin embedding of ZK views without iframe - ui-core/embed: mountInline() fetches a server-rendered ZK view, injects its deduplicated head assets, appends the body re-creating scripts and tracks the desktop id announced by the response (dt:'...'); destroy() releases the desktop client- and server-side (zAu._rmDesktop). - uses it (light DOM, same-origin only). - @dynamia-tools/vue: wrapper (load/error events, loading/error slots). - docs/frontend/INLINE_ZK_EMBED.md; happy-dom devDependency for DOM tests. No backend endpoint added: the response is already the bootstrap manifest. Refs #113 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01GXVz2G6v1fqFdQjRSh9XZF --- docs/frontend/INLINE_ZK_EMBED.md | 68 +++++ docs/frontend/README.md | 5 + platform/packages/ui-core/package.json | 1 + .../ui-core/src/embed/DynamiaEmbed.ts | 50 +++- platform/packages/ui-core/src/embed/README.md | 12 + platform/packages/ui-core/src/embed/index.ts | 2 + platform/packages/ui-core/src/embed/inline.ts | 273 ++++++++++++++++++ .../ui-core/test/embed/inline.test.ts | 231 +++++++++++++++ platform/packages/vue/README.md | 16 + .../packages/vue/src/components/ZkEmbed.vue | 90 ++++++ .../packages/vue/src/global-components.ts | 2 + platform/packages/vue/src/index.ts | 1 + platform/packages/vue/src/plugin.ts | 2 + platform/packages/vue/vite.config.ts | 3 +- pnpm-lock.yaml | 86 +++++- 15 files changed, 828 insertions(+), 14 deletions(-) create mode 100644 docs/frontend/INLINE_ZK_EMBED.md create mode 100644 platform/packages/ui-core/src/embed/inline.ts create mode 100644 platform/packages/ui-core/test/embed/inline.test.ts create mode 100644 platform/packages/vue/src/components/ZkEmbed.vue diff --git a/docs/frontend/INLINE_ZK_EMBED.md b/docs/frontend/INLINE_ZK_EMBED.md new file mode 100644 index 00000000..263b3136 --- /dev/null +++ b/docs/frontend/INLINE_ZK_EMBED.md @@ -0,0 +1,68 @@ +# Inline (no-iframe) embedding of ZK views + +Embed a server-rendered ZK view/page into a Vue (or any) host **without an iframe**, same origin only. +Issue: #113. Code: `platform/packages/ui-core/src/embed/inline.ts`, ``, +`` in `@dynamia-tools/vue`. + +## Usage + +```vue + + +``` + +```html + + +``` + +```ts +// Programmatic +import { mountInline } from '@dynamia-tools/ui-core/embed'; +const handle = await mountInline(container, '/books'); +// ... +handle.destroy(); // releases the ZK desktop client- and server-side +``` + +`src` can be any same-origin URL that answers with a ZK-rendered document: a plain `.zul` view (`/books`) or a +navigation page through `PageEmbedController` (`/page-embed///`). No server change is needed. + +## How it works (non-obvious parts) + +- A `.zul` without `` root is answered as a **full HTML document**. `` lists every script/CSS the ZK client + and the page need (URLs are versioned, e.g. `/zkau/web/57aacf5b/js/zk.wpd`); `` has the widgets plus one inline + ``. +- `mountInline` parses that document, injects the `` assets into the host (scripts strictly in order, deduplicated + per document by absolute URL, shared between concurrent embeds), appends the body **re-creating its ` + + + ${extraHead} + +
hello
+ + + `; +} + +function fakeFetch(pages: Record): typeof fetch { + return vi.fn(async (input: RequestInfo | URL) => { + const path = new URL(String(input)).pathname; + const page = pages[path]; + if (typeof page === 'number') return new Response('nope', { status: page }); + return new Response(page ?? '', { status: page === undefined ? 404 : 200 }); + }) as unknown as typeof fetch; +} + +/** Intercepts head injection: records the element and fires `load` (happy-dom doesn't fetch scripts). */ +function autoLoadHead(): { injected: string[]; restore: () => void } { + const injected: string[] = []; + const original = document.head.appendChild.bind(document.head); + document.head.appendChild = ((node: Node) => { + const el = node as HTMLElement; + const url = el.getAttribute?.('src') ?? el.getAttribute?.('href'); + if (url && (el.tagName === 'SCRIPT' || el.tagName === 'LINK')) { + injected.push(`${el.tagName.toLowerCase()}:${new URL(url, document.baseURI).pathname}`); + queueMicrotask(() => el.dispatchEvent(new Event('load'))); + return node; // not really attached: keeps the DOM free of un-fetchable assets + } + return original(node); + }) as typeof document.head.appendChild; + return { injected, restore: () => { document.head.appendChild = original; } }; +} + +/** happy-dom does not evaluate inline scripts: run them when they are appended, like a browser would. */ +function scriptRunningContainer(): HTMLElement { + const el = document.createElement('div'); + const append = el.appendChild.bind(el); + el.appendChild = ((node: Node) => { + const result = append(node); + if (node.nodeName === 'SCRIPT') new Function((node as HTMLScriptElement).textContent ?? '')(); + return result; + }) as typeof el.appendChild; + document.body.appendChild(el); + return el; +} + +describe('mountInline', () => { + let container: HTMLElement; + let head: ReturnType; + let rmDesktop: Mock<(d: unknown, keep: boolean) => void>; + + beforeEach(async () => { + vi.resetModules(); + ({ mountInline } = await import('../../src/embed/inline.js')); + document.head.innerHTML = ''; + document.body.innerHTML = ''; + container = scriptRunningContainer(); + win.zk = { Desktop: { all: {} }, mounting: false }; + rmDesktop = vi.fn<(d: unknown, keep: boolean) => void>(); + win.zAu = { _rmDesktop: rmDesktop }; + head = autoLoadHead(); + }); + + afterEach(() => { + head.restore(); + delete win.zk; + delete win.zAu; + }); + + it('injects head assets in order, appends the body and re-executes its script', async () => { + const handle = await mountInline(container, '/books', { fetchImpl: fakeFetch({ '/books': zkPage('dt1') }) }); + + expect(head.injected).toContain('link:/zkau/web/aaa/zk.wcs'); + expect(head.injected.filter(i => i.startsWith('script:'))).toEqual([ + 'script:/zkau/web/aaa/js/zk.wpd', + 'script:/zkau/web/aaa/js/zkbind.wpd', + ]); + expect(container.querySelector('#w-dt1')).not.toBeNull(); + expect(container.textContent).not.toContain('no js'); //