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'); //