@llui/vike 0.0.15 → 0.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,25 +1,44 @@
1
1
  import { hydrateApp, mountApp } from '@llui/dom';
2
+ import { _consumePendingSlot, _resetPendingSlot } from './page-slot.js';
3
+ // Re-exported so `@llui/vike/client` is a one-stop-shop for everything
4
+ // a pages/+onRenderClient.ts / +Layout.ts file needs.
5
+ export { pageSlot } from './page-slot.js';
6
+ /**
7
+ * Resolves the layout chain for a given pageContext. A single layout
8
+ * becomes a one-element chain; a function resolver gives callers full
9
+ * control to return different chains for different routes (e.g. nested
10
+ * layouts keyed on Vike's `pageContext.urlPathname`).
11
+ */
12
+ function resolveLayoutChain(layoutOption, pageContext) {
13
+ if (!layoutOption)
14
+ return [];
15
+ if (typeof layoutOption === 'function') {
16
+ return layoutOption(pageContext) ?? [];
17
+ }
18
+ if (Array.isArray(layoutOption))
19
+ return layoutOption;
20
+ return [layoutOption];
21
+ }
2
22
  /**
3
23
  * Adapt a `TransitionOptions` object (e.g. the output of
4
- * `routeTransition()` from `@llui/transitions`, or any preset like
5
- * `fade()` / `slide()`) into the `onLeave` / `onEnter` shape expected
6
- * by `createOnRenderClient`.
24
+ * `routeTransition()` from `@llui/transitions`, or a preset like `fade`
25
+ * / `slide`) into the `onLeave` / `onEnter` pair expected by
26
+ * `createOnRenderClient`.
7
27
  *
8
- * ```typescript
28
+ * ```ts
9
29
  * import { createOnRenderClient, fromTransition } from '@llui/vike/client'
10
30
  * import { routeTransition } from '@llui/transitions'
11
31
  *
12
32
  * export const onRenderClient = createOnRenderClient({
33
+ * Layout: AppLayout,
13
34
  * ...fromTransition(routeTransition({ duration: 200 })),
14
35
  * })
15
36
  * ```
16
37
  *
17
- * The transition operates on the container element itself its
18
- * opacity / transform fades out the outgoing page, then the new page
19
- * fades in when it mounts. If the preset doesn't restore its starting
20
- * style on `leave`, the container may still carry leftover properties
21
- * when the new page mounts; use `enter` to reset them explicitly or
22
- * pick presets that self-clean.
38
+ * The transition operates on the slot element — in a no-layout setup,
39
+ * the root container; in a layout setup, the innermost surviving
40
+ * layer's `pageSlot()` element. Opacity / transform fades apply to the
41
+ * outgoing page content, then the new page fades in.
23
42
  */
24
43
  export function fromTransition(t) {
25
44
  return {
@@ -38,40 +57,53 @@ export function fromTransition(t) {
38
57
  : undefined,
39
58
  };
40
59
  }
41
- // Track the current app handle so we can dispose it on client navigation.
42
- // Module-level state: there's exactly one Vike-managed app per page load.
43
- let currentHandle = null;
60
+ // Live chain of mounted layers. Module-level state: there's exactly
61
+ // one chain per Vike-managed app per page load.
62
+ let chainHandles = [];
44
63
  /**
45
- * @internal — test helper. Disposes the current handle (if any) and clears
46
- * the module-level state so subsequent calls behave as a first mount.
47
- * Not part of the public API; subject to change without notice.
64
+ * @internal — test helper. Disposes every layer in the current chain
65
+ * and clears the module state so subsequent calls behave as a first
66
+ * mount. Not part of the public API; subject to change without notice.
48
67
  */
49
- export function _resetCurrentHandleForTest() {
50
- if (currentHandle) {
51
- currentHandle.dispose();
52
- currentHandle = null;
68
+ export function _resetChainForTest() {
69
+ // Dispose innermost-first to match the normal teardown path.
70
+ for (let i = chainHandles.length - 1; i >= 0; i--) {
71
+ chainHandles[i].handle.dispose();
53
72
  }
73
+ chainHandles = [];
74
+ _resetPendingSlot();
54
75
  }
55
76
  /**
56
- * Default onRenderClient hook no animation hooks. Hydrates if
57
- * `isHydration` is true, otherwise mounts fresh. Use `createOnRenderClient`
77
+ * Back-compat alias for the pre-layout test helper name.
78
+ * @internal
79
+ * @deprecated — use `_resetChainForTest` instead.
80
+ */
81
+ export function _resetCurrentHandleForTest() {
82
+ _resetChainForTest();
83
+ }
84
+ /**
85
+ * Default onRenderClient hook — no layout, no animation hooks. Hydrates
86
+ * on first load, mounts fresh on subsequent navs. Use `createOnRenderClient`
58
87
  * for the customizable factory form.
59
88
  */
60
89
  export async function onRenderClient(pageContext) {
61
90
  await renderClient(pageContext, {});
62
91
  }
63
92
  /**
64
- * Factory to create a customized onRenderClient hook.
93
+ * Factory to create a customized onRenderClient hook. See `RenderClientOptions`
94
+ * for the full option surface — this is the entry point for persistent
95
+ * layouts, route transitions, and lifecycle hooks.
65
96
  *
66
- * ```typescript
97
+ * ```ts
67
98
  * // pages/+onRenderClient.ts
68
- * import { createOnRenderClient } from '@llui/vike/client'
99
+ * import { createOnRenderClient, fromTransition } from '@llui/vike/client'
100
+ * import { routeTransition } from '@llui/transitions'
101
+ * import { AppLayout } from './+Layout'
69
102
  *
70
103
  * export const onRenderClient = createOnRenderClient({
71
- * container: '#root',
72
- * onLeave: (el) => el.animate({ opacity: [1, 0] }, 200).finished,
73
- * onEnter: (el) => el.animate({ opacity: [0, 1] }, 200),
74
- * onMount: () => console.log('Page ready'),
104
+ * Layout: AppLayout,
105
+ * ...fromTransition(routeTransition({ duration: 200 })),
106
+ * onMount: () => console.log('page rendered'),
75
107
  * })
76
108
  * ```
77
109
  */
@@ -79,37 +111,188 @@ export function createOnRenderClient(options) {
79
111
  return (pageContext) => renderClient(pageContext, options);
80
112
  }
81
113
  async function renderClient(pageContext, options) {
82
- const { Page } = pageContext;
83
114
  const selector = options.container ?? '#app';
84
115
  const container = document.querySelector(selector);
85
116
  if (!container) {
86
117
  throw new Error(`@llui/vike: container "${selector}" not found in DOM`);
87
118
  }
88
- const el = container;
89
- // Dispose the previous page's component on client navigation. If the
90
- // caller supplied an onLeave hook and this isn't the initial hydration,
91
- // await it BEFORE tearing down — that's the only moment where the
92
- // outgoing page's DOM still exists for an animation to read/write.
93
- if (currentHandle) {
94
- if (!pageContext.isHydration && options.onLeave) {
95
- await options.onLeave(el);
96
- }
97
- currentHandle.dispose();
98
- currentHandle = null;
99
- }
119
+ const rootEl = container;
120
+ // Resolve the chain for this render. The page component is always
121
+ // the innermost entry, regardless of layout configuration.
122
+ const layoutChain = resolveLayoutChain(options.Layout, pageContext);
123
+ const layoutData = pageContext.lluiLayoutData ?? [];
124
+ const newChain = [...layoutChain, pageContext.Page];
125
+ const newChainData = [...layoutData, pageContext.data];
100
126
  if (pageContext.isHydration) {
101
- const serverState = window.__LLUI_STATE__;
102
- currentHandle = hydrateApp(el, Page, serverState);
127
+ // First load — the chain starts empty and we hydrate every layer
128
+ // against server-rendered HTML. No onLeave/onEnter on hydration.
129
+ await mountOrHydrateChain(newChain, newChainData, rootEl, {
130
+ mode: 'hydrate',
131
+ serverStateEnvelope: window.__LLUI_STATE__,
132
+ });
133
+ options.onMount?.();
134
+ return;
135
+ }
136
+ // Subsequent nav — diff the chain to find the divergent suffix.
137
+ let firstMismatch = 0;
138
+ const minLen = Math.min(chainHandles.length, newChain.length);
139
+ while (firstMismatch < minLen && chainHandles[firstMismatch].def === newChain[firstMismatch]) {
140
+ firstMismatch++;
103
141
  }
104
- else {
105
- // Clear old DOM before mounting the new page
106
- el.textContent = '';
107
- currentHandle = mountApp(el, Page, pageContext.data);
108
- // onEnter fires AFTER mount so the hook can animate the freshly
109
- // rendered children. It's intentionally sync a promise return is
110
- // ignored, matching typical enter-animation ergonomics (fire-and-forget).
111
- options.onEnter?.(el);
142
+ // Find the slot element whose contents will change. Shared prefix =
143
+ // everything before firstMismatch. The slot we're about to replace
144
+ // content in sits in the layer at firstMismatch - 1 (if any);
145
+ // otherwise we're swapping the whole app at the root container.
146
+ const leaveTarget = firstMismatch === 0 ? rootEl : (chainHandles[firstMismatch - 1].slotMarker ?? rootEl);
147
+ // If everything matches (same chain end-to-end with same defs), this
148
+ // is effectively a no-op nav — the page def hasn't changed. We still
149
+ // fire onMount so callers can run per-render side effects, but there's
150
+ // nothing to dispose or mount.
151
+ const isNoOp = firstMismatch === chainHandles.length && firstMismatch === newChain.length;
152
+ if (isNoOp) {
153
+ options.onMount?.();
154
+ return;
112
155
  }
156
+ // onLeave runs BEFORE any teardown. Outgoing DOM still mounted here.
157
+ // Skip on the very first mount — there's no outgoing page to leave.
158
+ const isFirstMount = chainHandles.length === 0;
159
+ if (options.onLeave && !isFirstMount) {
160
+ await options.onLeave(leaveTarget);
161
+ }
162
+ // Dispose the divergent suffix, innermost first. Each handle.dispose()
163
+ // calls disposeScope on that layer's rootScope, which cascades through
164
+ // every child scope the layer owned (bindings, portals, onMount
165
+ // cleanups, dialog focus traps, etc.). The surviving layers are
166
+ // untouched because their scopes live above the disposal roots.
167
+ for (let i = chainHandles.length - 1; i >= firstMismatch; i--) {
168
+ chainHandles[i].handle.dispose();
169
+ }
170
+ chainHandles = chainHandles.slice(0, firstMismatch);
171
+ // Clear the slot element before mounting the new suffix. handle.dispose()
172
+ // above already did this for the innermost layer's container, but the
173
+ // slot at firstMismatch - 1 keeps its marker element (it's owned by the
174
+ // surviving layer) and we mount fresh children into it.
175
+ leaveTarget.textContent = '';
176
+ // Mount the new suffix starting at firstMismatch.
177
+ const parentScope = firstMismatch === 0 ? undefined : (chainHandles[firstMismatch - 1].slotScope ?? undefined);
178
+ mountChainSuffix(newChain, newChainData, firstMismatch, leaveTarget, parentScope, {
179
+ mode: 'mount',
180
+ });
181
+ // onEnter fires after the new suffix is in place. Fire-and-forget.
182
+ options.onEnter?.(leaveTarget);
113
183
  options.onMount?.();
114
184
  }
185
+ /**
186
+ * Walk the full chain for the first mount or hydration. Starts from
187
+ * depth 0 at the root container, threads each layer's slot into the
188
+ * next layer's mount target + parentScope.
189
+ */
190
+ async function mountOrHydrateChain(chain, chainData, rootEl, opts) {
191
+ mountChainSuffix(chain, chainData, 0, rootEl, undefined, opts);
192
+ }
193
+ /**
194
+ * Mount (or hydrate) `chain[startAt..end]` into `initialTarget`, with
195
+ * the initial layer's rootScope parented at `initialParentScope`.
196
+ * Threads slot → next-target → next-parentScope through the chain.
197
+ *
198
+ * Fails loudly if a non-innermost layer forgot to call `pageSlot()`,
199
+ * or if the innermost layer called `pageSlot()` unnecessarily.
200
+ */
201
+ function mountChainSuffix(chain, chainData, startAt, initialTarget, initialParentScope, opts) {
202
+ let mountTarget = initialTarget;
203
+ let parentScope = initialParentScope;
204
+ for (let i = startAt; i < chain.length; i++) {
205
+ const def = chain[i];
206
+ const layerData = chainData[i];
207
+ const isInnermost = i === chain.length - 1;
208
+ // Defensive: clear any stale slot from a prior failed mount.
209
+ _resetPendingSlot();
210
+ let handle;
211
+ if (opts.mode === 'hydrate') {
212
+ // Hydration envelope: each layer pulls its own state slice. The
213
+ // envelope shape is `{ layouts: [...], page: {...} }` with each
214
+ // entry carrying `{ name, state }`. We match by name so a server/
215
+ // client mismatch throws with a clear error instead of silently
216
+ // hydrating the wrong state into the wrong instance.
217
+ const layerState = extractHydrationState(opts.serverStateEnvelope, i, chain.length, def);
218
+ handle = hydrateApp(mountTarget, def, layerState, { parentScope });
219
+ }
220
+ else {
221
+ handle = mountApp(mountTarget, def, layerData, { parentScope });
222
+ }
223
+ const slot = _consumePendingSlot();
224
+ if (isInnermost && slot !== null) {
225
+ // Innermost layer declared a slot with nothing to fill it —
226
+ // probably a misuse of pageSlot() in the page component itself.
227
+ handle.dispose();
228
+ throw new Error(`[llui/vike] <${def.name}> is the innermost component in the chain ` +
229
+ `but called pageSlot(). pageSlot() only belongs in layout components ` +
230
+ `that wrap a nested page or layout — not in the page itself.`);
231
+ }
232
+ if (!isInnermost && slot === null) {
233
+ // Non-innermost layer didn't declare a slot — there's nowhere to
234
+ // mount the remaining chain.
235
+ handle.dispose();
236
+ throw new Error(`[llui/vike] <${def.name}> is a layout layer at depth ${i} but did not ` +
237
+ `call pageSlot() in its view(). There are ${chain.length - i - 1} more ` +
238
+ `layer(s) to mount and no slot to mount them into. Add pageSlot() from ` +
239
+ `@llui/vike/client to the view at the position where nested content renders.`);
240
+ }
241
+ chainHandles.push({
242
+ def,
243
+ handle,
244
+ slotMarker: slot?.marker ?? null,
245
+ slotScope: slot?.slotScope ?? null,
246
+ });
247
+ if (slot !== null) {
248
+ mountTarget = slot.marker;
249
+ parentScope = slot.slotScope;
250
+ }
251
+ }
252
+ }
253
+ /**
254
+ * Pull the per-layer state from the hydration envelope. Supports both
255
+ * the new chain-aware shape (`{ layouts: [...], page: {...} }`) and the
256
+ * legacy flat shape (`window.__LLUI_STATE__` is the state object itself)
257
+ * for backward compatibility with pages written against 0.0.15 or earlier.
258
+ *
259
+ * Throws on envelope shape mismatch — missing entries, wrong component
260
+ * name at a given index — so server/client drift fails loud instead of
261
+ * silently binding the wrong state to the wrong instance.
262
+ */
263
+ function extractHydrationState(envelope, layerIndex, chainLength, def) {
264
+ // Legacy flat envelope — no layout chain at render time. Only valid
265
+ // when the chain has a single layer (the page).
266
+ const isLegacyFlat = envelope !== null &&
267
+ typeof envelope === 'object' &&
268
+ !('layouts' in envelope) &&
269
+ !('page' in envelope);
270
+ if (isLegacyFlat) {
271
+ if (chainLength !== 1) {
272
+ throw new Error(`[llui/vike] Hydration envelope is in the legacy flat shape but the ` +
273
+ `current render has ${chainLength} chain layers. The server must emit ` +
274
+ `the chain-aware shape ({ layouts, page }) when rendering with a layout.`);
275
+ }
276
+ return envelope;
277
+ }
278
+ const chainEnvelope = envelope;
279
+ if (!chainEnvelope) {
280
+ throw new Error(`[llui/vike] Hydration envelope is missing. Server-side onRenderHtml must ` +
281
+ `populate window.__LLUI_STATE__ with the full chain before client hydration.`);
282
+ }
283
+ const isPageLayer = layerIndex === chainLength - 1;
284
+ const layoutEntries = chainEnvelope.layouts ?? [];
285
+ const expected = isPageLayer ? chainEnvelope.page : layoutEntries[layerIndex];
286
+ if (!expected) {
287
+ throw new Error(`[llui/vike] Hydration envelope has no entry for chain layer ${layerIndex} ` +
288
+ `(<${def.name}>). Server rendered ${layoutEntries.length} layouts + ${chainEnvelope.page ? 'a page' : 'no page'}, client expected ${chainLength} total entries.`);
289
+ }
290
+ if (expected.name !== def.name) {
291
+ throw new Error(`[llui/vike] Hydration mismatch at chain layer ${layerIndex}: server ` +
292
+ `rendered <${expected.name}> but client is trying to hydrate <${def.name}>. ` +
293
+ `This usually means the layout chain resolver returns different layouts ` +
294
+ `on the server and the client for the same route.`);
295
+ }
296
+ return expected.state;
297
+ }
115
298
  //# sourceMappingURL=on-render-client.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"on-render-client.js","sourceRoot":"","sources":["../src/on-render-client.ts"],"names":[],"mappings":"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","sourcesContent":["import { hydrateApp, mountApp } from '@llui/dom'\nimport type { ComponentDef, AppHandle, TransitionOptions } from '@llui/dom'\n\ndeclare global {\n interface Window {\n __LLUI_STATE__?: unknown\n }\n}\n\nexport interface ClientPageContext {\n Page: ComponentDef<unknown, unknown, unknown, unknown>\n data?: unknown\n isHydration?: boolean\n}\n\n/**\n * Page-lifecycle hooks that fire around the dispose → clear → mount\n * sequence on client navigation. Use these to animate page transitions,\n * save scroll state, emit analytics events, or defer the swap behind\n * any async work that must complete before the next page appears.\n *\n * The sequence is:\n *\n * ```\n * client nav triggered\n * │\n * ▼\n * onLeave(el) ← awaited if it returns a promise\n * │ (the outgoing page's DOM is still mounted here)\n * ▼\n * currentHandle.dispose()\n * │ (all scopes torn down — portals, focus traps,\n * │ onMount cleanups all fire synchronously here)\n * ▼\n * el.textContent = ''\n * │ (old DOM removed)\n * ▼\n * mountApp(el, Page, data)\n * │ (new page mounted)\n * ▼\n * onEnter(el) ← not awaited; animate in-place\n * │\n * ▼\n * onMount() ← legacy shim, still fires last\n * ```\n *\n * On the initial render (hydration), `onLeave` and `onEnter` are NOT\n * called — there's no outgoing page to leave and no animation to enter.\n * If you need to run code after hydration, use `onMount`.\n */\nexport interface RenderClientOptions {\n /** CSS selector for the mount container. Default: '#app' */\n container?: string\n\n /**\n * Called on the outgoing page's container BEFORE dispose + clear + mount.\n * Return a promise to defer the swap until the leave animation finishes.\n * The container element is passed as the argument — its children are\n * still the previous page's DOM at this point.\n *\n * Not called on the initial hydration render.\n */\n onLeave?: (el: HTMLElement) => void | Promise<void>\n\n /**\n * Called after the new page is mounted into the container. Use this to\n * kick off an enter animation on the freshly-rendered content. Not\n * awaited — if you return a promise, the resolution is ignored.\n *\n * Not called on the initial hydration render.\n */\n onEnter?: (el: HTMLElement) => void\n\n /**\n * Called after mount or hydration completes. Fires on every render\n * including the initial hydration. Use this for per-render side\n * effects that don't fit the animation hooks (analytics, focus\n * management, etc.).\n */\n onMount?: () => void\n}\n\n/**\n * Adapt a `TransitionOptions` object (e.g. the output of\n * `routeTransition()` from `@llui/transitions`, or any preset like\n * `fade()` / `slide()`) into the `onLeave` / `onEnter` shape expected\n * by `createOnRenderClient`.\n *\n * ```typescript\n * import { createOnRenderClient, fromTransition } from '@llui/vike/client'\n * import { routeTransition } from '@llui/transitions'\n *\n * export const onRenderClient = createOnRenderClient({\n * ...fromTransition(routeTransition({ duration: 200 })),\n * })\n * ```\n *\n * The transition operates on the container element itself — its\n * opacity / transform fades out the outgoing page, then the new page\n * fades in when it mounts. If the preset doesn't restore its starting\n * style on `leave`, the container may still carry leftover properties\n * when the new page mounts; use `enter` to reset them explicitly or\n * pick presets that self-clean.\n */\nexport function fromTransition(\n t: TransitionOptions,\n): Pick<RenderClientOptions, 'onLeave' | 'onEnter'> {\n return {\n onLeave: t.leave\n ? (el): void | Promise<void> => {\n const result = t.leave!([el])\n return result && typeof (result as Promise<void>).then === 'function'\n ? (result as Promise<void>)\n : undefined\n }\n : undefined,\n onEnter: t.enter\n ? (el): void => {\n t.enter!([el])\n }\n : undefined,\n }\n}\n\n// Track the current app handle so we can dispose it on client navigation.\n// Module-level state: there's exactly one Vike-managed app per page load.\nlet currentHandle: AppHandle | null = null\n\n/**\n * @internal — test helper. Disposes the current handle (if any) and clears\n * the module-level state so subsequent calls behave as a first mount.\n * Not part of the public API; subject to change without notice.\n */\nexport function _resetCurrentHandleForTest(): void {\n if (currentHandle) {\n currentHandle.dispose()\n currentHandle = null\n }\n}\n\n/**\n * Default onRenderClient hook — no animation hooks. Hydrates if\n * `isHydration` is true, otherwise mounts fresh. Use `createOnRenderClient`\n * for the customizable factory form.\n */\nexport async function onRenderClient(pageContext: ClientPageContext): Promise<void> {\n await renderClient(pageContext, {})\n}\n\n/**\n * Factory to create a customized onRenderClient hook.\n *\n * ```typescript\n * // pages/+onRenderClient.ts\n * import { createOnRenderClient } from '@llui/vike/client'\n *\n * export const onRenderClient = createOnRenderClient({\n * container: '#root',\n * onLeave: (el) => el.animate({ opacity: [1, 0] }, 200).finished,\n * onEnter: (el) => el.animate({ opacity: [0, 1] }, 200),\n * onMount: () => console.log('Page ready'),\n * })\n * ```\n */\nexport function createOnRenderClient(\n options: RenderClientOptions,\n): (pageContext: ClientPageContext) => Promise<void> {\n return (pageContext) => renderClient(pageContext, options)\n}\n\nasync function renderClient(\n pageContext: ClientPageContext,\n options: RenderClientOptions,\n): Promise<void> {\n const { Page } = pageContext\n const selector = options.container ?? '#app'\n const container = document.querySelector(selector)\n\n if (!container) {\n throw new Error(`@llui/vike: container \"${selector}\" not found in DOM`)\n }\n\n const el = container as HTMLElement\n\n // Dispose the previous page's component on client navigation. If the\n // caller supplied an onLeave hook and this isn't the initial hydration,\n // await it BEFORE tearing down — that's the only moment where the\n // outgoing page's DOM still exists for an animation to read/write.\n if (currentHandle) {\n if (!pageContext.isHydration && options.onLeave) {\n await options.onLeave(el)\n }\n currentHandle.dispose()\n currentHandle = null\n }\n\n if (pageContext.isHydration) {\n const serverState = window.__LLUI_STATE__\n currentHandle = hydrateApp(el, Page, serverState)\n } else {\n // Clear old DOM before mounting the new page\n el.textContent = ''\n currentHandle = mountApp(el, Page, pageContext.data)\n // onEnter fires AFTER mount so the hook can animate the freshly\n // rendered children. It's intentionally sync — a promise return is\n // ignored, matching typical enter-animation ergonomics (fire-and-forget).\n options.onEnter?.(el)\n }\n\n options.onMount?.()\n}\n"]}
1
+ {"version":3,"file":"on-render-client.js","sourceRoot":"","sources":["../src/on-render-client.ts"],"names":[],"mappings":"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","sourcesContent":["import { hydrateApp, mountApp } from '@llui/dom'\nimport type { ComponentDef, AppHandle, TransitionOptions, Scope } from '@llui/dom'\nimport { _consumePendingSlot, _resetPendingSlot } from './page-slot.js'\n\n// Re-exported so `@llui/vike/client` is a one-stop-shop for everything\n// a pages/+onRenderClient.ts / +Layout.ts file needs.\nexport { pageSlot } from './page-slot.js'\n\ndeclare global {\n interface Window {\n __LLUI_STATE__?: unknown\n }\n}\n\n/**\n * Page context shape as seen by `@llui/vike`'s client-side hooks. The\n * `Page` and `data` fields come from whichever `+Page.ts` and `+data.ts`\n * Vike resolved for the current route.\n *\n * `lluiLayoutData` is optional and carries per-layer data for the layout\n * chain configured via `createOnRenderClient({ Layout })`. It's indexed\n * outermost-to-innermost, one entry per layout layer. Absent entries\n * mean the corresponding layout's `init()` receives `undefined`. Users\n * wire this from their Vike `+data.ts` files by merging layout-owned\n * data under the `lluiLayoutData` key.\n */\nexport interface ClientPageContext {\n Page: ComponentDef<unknown, unknown, unknown, unknown>\n data?: unknown\n lluiLayoutData?: readonly unknown[]\n isHydration?: boolean\n}\n\ntype AnyComponentDef = ComponentDef<unknown, unknown, unknown, unknown>\ntype LayoutChain = ReadonlyArray<AnyComponentDef>\n\n/**\n * Resolves the layout chain for a given pageContext. A single layout\n * becomes a one-element chain; a function resolver gives callers full\n * control to return different chains for different routes (e.g. nested\n * layouts keyed on Vike's `pageContext.urlPathname`).\n */\nfunction resolveLayoutChain(\n layoutOption: RenderClientOptions['Layout'],\n pageContext: ClientPageContext,\n): LayoutChain {\n if (!layoutOption) return []\n if (typeof layoutOption === 'function') {\n return layoutOption(pageContext) ?? []\n }\n if (Array.isArray(layoutOption)) return layoutOption\n return [layoutOption as AnyComponentDef]\n}\n\n/**\n * Page-lifecycle hooks that fire around the dispose → mount cycle on\n * client navigation. With persistent layouts in play the cycle only\n * tears down the *divergent* suffix of the layout chain — any layers\n * shared between the old and new routes stay mounted.\n *\n * Navigation sequence for an already-mounted app:\n *\n * ```\n * client nav triggered\n * │\n * ▼\n * compare old chain to new chain → find first mismatch index K\n * │\n * ▼\n * onLeave(leaveTarget) ← awaited; leaveTarget is the slot element\n * │ at depth K-1 (or the root container if K=0)\n * │ whose contents are about to be replaced\n * ▼\n * dispose chainHandles[K..end] innermost first\n * │\n * ▼\n * leaveTarget.textContent = ''\n * │\n * ▼\n * mount newChain[K..end] into leaveTarget, outermost first\n * │\n * ▼\n * onEnter(leaveTarget) ← fire-and-forget; fresh DOM in place\n * │\n * ▼\n * onMount()\n * ```\n *\n * On the initial hydration render, `onLeave` and `onEnter` are NOT\n * called — there's no outgoing page to leave and no animation to enter.\n * Use `onMount` for code that should run on every render including the\n * initial one.\n */\nexport interface RenderClientOptions {\n /** CSS selector for the mount container. Default: `'#app'`. */\n container?: string\n\n /**\n * Persistent layout chain. One of:\n *\n * - A single `ComponentDef` — becomes a one-layout chain.\n * - An array of `ComponentDef`s — outermost layout first, innermost\n * layout last. Every layer except the innermost must call\n * `pageSlot()` in its view to declare where nested content renders.\n * - A function that returns a chain from the current `pageContext` —\n * lets different routes use different chains, e.g. by reading\n * Vike's `pageContext.urlPathname` or `pageContext.config.Layout`.\n *\n * Layers that are shared between the previous and next navigation\n * stay mounted. Only the divergent suffix is disposed and re-mounted.\n * Dialogs, focus traps, and effect subscriptions rooted in a surviving\n * layer are unaffected by the nav.\n */\n Layout?: AnyComponentDef | LayoutChain | ((pageContext: ClientPageContext) => LayoutChain)\n\n /**\n * Called on the slot element whose contents are about to be replaced,\n * BEFORE the divergent suffix is disposed and re-mounted. The slot's\n * current DOM is still attached when this runs — the only moment a\n * leave animation can read/write it. Return a promise to defer the\n * swap until the animation completes.\n *\n * For a plain no-layout setup, the slot element is the root container.\n * Not called on the initial hydration render.\n */\n onLeave?: (el: HTMLElement) => void | Promise<void>\n\n /**\n * Called after the new divergent suffix is mounted, on the same slot\n * element that was passed to `onLeave`. Use this to kick off an enter\n * animation. Fire-and-forget — promise returns are ignored.\n *\n * Not called on the initial hydration render.\n */\n onEnter?: (el: HTMLElement) => void\n\n /**\n * Called after mount or hydration completes. Fires on every render\n * including the initial hydration. Use for per-render side effects\n * that don't fit the animation hooks.\n */\n onMount?: () => void\n}\n\n/**\n * Adapt a `TransitionOptions` object (e.g. the output of\n * `routeTransition()` from `@llui/transitions`, or a preset like `fade`\n * / `slide`) into the `onLeave` / `onEnter` pair expected by\n * `createOnRenderClient`.\n *\n * ```ts\n * import { createOnRenderClient, fromTransition } from '@llui/vike/client'\n * import { routeTransition } from '@llui/transitions'\n *\n * export const onRenderClient = createOnRenderClient({\n * Layout: AppLayout,\n * ...fromTransition(routeTransition({ duration: 200 })),\n * })\n * ```\n *\n * The transition operates on the slot element — in a no-layout setup,\n * the root container; in a layout setup, the innermost surviving\n * layer's `pageSlot()` element. Opacity / transform fades apply to the\n * outgoing page content, then the new page fades in.\n */\nexport function fromTransition(\n t: TransitionOptions,\n): Pick<RenderClientOptions, 'onLeave' | 'onEnter'> {\n return {\n onLeave: t.leave\n ? (el): void | Promise<void> => {\n const result = t.leave!([el])\n return result && typeof (result as Promise<void>).then === 'function'\n ? (result as Promise<void>)\n : undefined\n }\n : undefined,\n onEnter: t.enter\n ? (el): void => {\n t.enter!([el])\n }\n : undefined,\n }\n}\n\n/**\n * One element of the live chain the adapter keeps between navs.\n * `handle` is the AppHandle returned by mountApp/hydrateApp for this\n * layer. `slotMarker` / `slotScope` are set when the layer called\n * `pageSlot()` during its view pass; they're null for the innermost\n * layer (typically the page component, which doesn't have a slot).\n */\ninterface ChainEntry {\n def: AnyComponentDef\n handle: AppHandle\n slotMarker: HTMLElement | null\n slotScope: Scope | null\n}\n\n// Live chain of mounted layers. Module-level state: there's exactly\n// one chain per Vike-managed app per page load.\nlet chainHandles: ChainEntry[] = []\n\n/**\n * @internal — test helper. Disposes every layer in the current chain\n * and clears the module state so subsequent calls behave as a first\n * mount. Not part of the public API; subject to change without notice.\n */\nexport function _resetChainForTest(): void {\n // Dispose innermost-first to match the normal teardown path.\n for (let i = chainHandles.length - 1; i >= 0; i--) {\n chainHandles[i]!.handle.dispose()\n }\n chainHandles = []\n _resetPendingSlot()\n}\n\n/**\n * Back-compat alias for the pre-layout test helper name.\n * @internal\n * @deprecated — use `_resetChainForTest` instead.\n */\nexport function _resetCurrentHandleForTest(): void {\n _resetChainForTest()\n}\n\n/**\n * Default onRenderClient hook — no layout, no animation hooks. Hydrates\n * on first load, mounts fresh on subsequent navs. Use `createOnRenderClient`\n * for the customizable factory form.\n */\nexport async function onRenderClient(pageContext: ClientPageContext): Promise<void> {\n await renderClient(pageContext, {})\n}\n\n/**\n * Factory to create a customized onRenderClient hook. See `RenderClientOptions`\n * for the full option surface — this is the entry point for persistent\n * layouts, route transitions, and lifecycle hooks.\n *\n * ```ts\n * // pages/+onRenderClient.ts\n * import { createOnRenderClient, fromTransition } from '@llui/vike/client'\n * import { routeTransition } from '@llui/transitions'\n * import { AppLayout } from './+Layout'\n *\n * export const onRenderClient = createOnRenderClient({\n * Layout: AppLayout,\n * ...fromTransition(routeTransition({ duration: 200 })),\n * onMount: () => console.log('page rendered'),\n * })\n * ```\n */\nexport function createOnRenderClient(\n options: RenderClientOptions,\n): (pageContext: ClientPageContext) => Promise<void> {\n return (pageContext) => renderClient(pageContext, options)\n}\n\nasync function renderClient(\n pageContext: ClientPageContext,\n options: RenderClientOptions,\n): Promise<void> {\n const selector = options.container ?? '#app'\n const container = document.querySelector(selector)\n if (!container) {\n throw new Error(`@llui/vike: container \"${selector}\" not found in DOM`)\n }\n const rootEl = container as HTMLElement\n\n // Resolve the chain for this render. The page component is always\n // the innermost entry, regardless of layout configuration.\n const layoutChain = resolveLayoutChain(options.Layout, pageContext)\n const layoutData = pageContext.lluiLayoutData ?? []\n const newChain: LayoutChain = [...layoutChain, pageContext.Page]\n const newChainData: readonly unknown[] = [...layoutData, pageContext.data]\n\n if (pageContext.isHydration) {\n // First load — the chain starts empty and we hydrate every layer\n // against server-rendered HTML. No onLeave/onEnter on hydration.\n await mountOrHydrateChain(newChain, newChainData, rootEl, {\n mode: 'hydrate',\n serverStateEnvelope: window.__LLUI_STATE__,\n })\n options.onMount?.()\n return\n }\n\n // Subsequent nav — diff the chain to find the divergent suffix.\n let firstMismatch = 0\n const minLen = Math.min(chainHandles.length, newChain.length)\n while (firstMismatch < minLen && chainHandles[firstMismatch]!.def === newChain[firstMismatch]) {\n firstMismatch++\n }\n\n // Find the slot element whose contents will change. Shared prefix =\n // everything before firstMismatch. The slot we're about to replace\n // content in sits in the layer at firstMismatch - 1 (if any);\n // otherwise we're swapping the whole app at the root container.\n const leaveTarget =\n firstMismatch === 0 ? rootEl : (chainHandles[firstMismatch - 1]!.slotMarker ?? rootEl)\n\n // If everything matches (same chain end-to-end with same defs), this\n // is effectively a no-op nav — the page def hasn't changed. We still\n // fire onMount so callers can run per-render side effects, but there's\n // nothing to dispose or mount.\n const isNoOp = firstMismatch === chainHandles.length && firstMismatch === newChain.length\n if (isNoOp) {\n options.onMount?.()\n return\n }\n\n // onLeave runs BEFORE any teardown. Outgoing DOM still mounted here.\n // Skip on the very first mount — there's no outgoing page to leave.\n const isFirstMount = chainHandles.length === 0\n if (options.onLeave && !isFirstMount) {\n await options.onLeave(leaveTarget)\n }\n\n // Dispose the divergent suffix, innermost first. Each handle.dispose()\n // calls disposeScope on that layer's rootScope, which cascades through\n // every child scope the layer owned (bindings, portals, onMount\n // cleanups, dialog focus traps, etc.). The surviving layers are\n // untouched because their scopes live above the disposal roots.\n for (let i = chainHandles.length - 1; i >= firstMismatch; i--) {\n chainHandles[i]!.handle.dispose()\n }\n chainHandles = chainHandles.slice(0, firstMismatch)\n\n // Clear the slot element before mounting the new suffix. handle.dispose()\n // above already did this for the innermost layer's container, but the\n // slot at firstMismatch - 1 keeps its marker element (it's owned by the\n // surviving layer) and we mount fresh children into it.\n leaveTarget.textContent = ''\n\n // Mount the new suffix starting at firstMismatch.\n const parentScope =\n firstMismatch === 0 ? undefined : (chainHandles[firstMismatch - 1]!.slotScope ?? undefined)\n mountChainSuffix(newChain, newChainData, firstMismatch, leaveTarget, parentScope, {\n mode: 'mount',\n })\n\n // onEnter fires after the new suffix is in place. Fire-and-forget.\n options.onEnter?.(leaveTarget)\n options.onMount?.()\n}\n\n/**\n * Walk the full chain for the first mount or hydration. Starts from\n * depth 0 at the root container, threads each layer's slot into the\n * next layer's mount target + parentScope.\n */\nasync function mountOrHydrateChain(\n chain: LayoutChain,\n chainData: readonly unknown[],\n rootEl: HTMLElement,\n opts: MountOpts,\n): Promise<void> {\n mountChainSuffix(chain, chainData, 0, rootEl, undefined, opts)\n}\n\ninterface MountOpts {\n mode: 'mount' | 'hydrate'\n /** For hydration: the full `window.__LLUI_STATE__` envelope. */\n serverStateEnvelope?: unknown\n}\n\n/**\n * Mount (or hydrate) `chain[startAt..end]` into `initialTarget`, with\n * the initial layer's rootScope parented at `initialParentScope`.\n * Threads slot → next-target → next-parentScope through the chain.\n *\n * Fails loudly if a non-innermost layer forgot to call `pageSlot()`,\n * or if the innermost layer called `pageSlot()` unnecessarily.\n */\nfunction mountChainSuffix(\n chain: LayoutChain,\n chainData: readonly unknown[],\n startAt: number,\n initialTarget: HTMLElement,\n initialParentScope: Scope | undefined,\n opts: MountOpts,\n): void {\n let mountTarget: HTMLElement = initialTarget\n let parentScope: Scope | undefined = initialParentScope\n\n for (let i = startAt; i < chain.length; i++) {\n const def = chain[i]!\n const layerData = chainData[i]\n const isInnermost = i === chain.length - 1\n\n // Defensive: clear any stale slot from a prior failed mount.\n _resetPendingSlot()\n\n let handle: AppHandle\n if (opts.mode === 'hydrate') {\n // Hydration envelope: each layer pulls its own state slice. The\n // envelope shape is `{ layouts: [...], page: {...} }` with each\n // entry carrying `{ name, state }`. We match by name so a server/\n // client mismatch throws with a clear error instead of silently\n // hydrating the wrong state into the wrong instance.\n const layerState = extractHydrationState(opts.serverStateEnvelope, i, chain.length, def)\n handle = hydrateApp(mountTarget, def, layerState, { parentScope })\n } else {\n handle = mountApp(mountTarget, def, layerData, { parentScope })\n }\n\n const slot = _consumePendingSlot()\n\n if (isInnermost && slot !== null) {\n // Innermost layer declared a slot with nothing to fill it —\n // probably a misuse of pageSlot() in the page component itself.\n handle.dispose()\n throw new Error(\n `[llui/vike] <${def.name}> is the innermost component in the chain ` +\n `but called pageSlot(). pageSlot() only belongs in layout components ` +\n `that wrap a nested page or layout — not in the page itself.`,\n )\n }\n if (!isInnermost && slot === null) {\n // Non-innermost layer didn't declare a slot — there's nowhere to\n // mount the remaining chain.\n handle.dispose()\n throw new Error(\n `[llui/vike] <${def.name}> is a layout layer at depth ${i} but did not ` +\n `call pageSlot() in its view(). There are ${chain.length - i - 1} more ` +\n `layer(s) to mount and no slot to mount them into. Add pageSlot() from ` +\n `@llui/vike/client to the view at the position where nested content renders.`,\n )\n }\n\n chainHandles.push({\n def,\n handle,\n slotMarker: slot?.marker ?? null,\n slotScope: slot?.slotScope ?? null,\n })\n\n if (slot !== null) {\n mountTarget = slot.marker\n parentScope = slot.slotScope\n }\n }\n}\n\n/**\n * Pull the per-layer state from the hydration envelope. Supports both\n * the new chain-aware shape (`{ layouts: [...], page: {...} }`) and the\n * legacy flat shape (`window.__LLUI_STATE__` is the state object itself)\n * for backward compatibility with pages written against 0.0.15 or earlier.\n *\n * Throws on envelope shape mismatch — missing entries, wrong component\n * name at a given index — so server/client drift fails loud instead of\n * silently binding the wrong state to the wrong instance.\n */\nfunction extractHydrationState(\n envelope: unknown,\n layerIndex: number,\n chainLength: number,\n def: AnyComponentDef,\n): unknown {\n // Legacy flat envelope — no layout chain at render time. Only valid\n // when the chain has a single layer (the page).\n const isLegacyFlat =\n envelope !== null &&\n typeof envelope === 'object' &&\n !('layouts' in (envelope as object)) &&\n !('page' in (envelope as object))\n\n if (isLegacyFlat) {\n if (chainLength !== 1) {\n throw new Error(\n `[llui/vike] Hydration envelope is in the legacy flat shape but the ` +\n `current render has ${chainLength} chain layers. The server must emit ` +\n `the chain-aware shape ({ layouts, page }) when rendering with a layout.`,\n )\n }\n return envelope\n }\n\n const chainEnvelope = envelope as\n | { layouts?: Array<{ name: string; state: unknown }>; page?: { name: string; state: unknown } }\n | undefined\n if (!chainEnvelope) {\n throw new Error(\n `[llui/vike] Hydration envelope is missing. Server-side onRenderHtml must ` +\n `populate window.__LLUI_STATE__ with the full chain before client hydration.`,\n )\n }\n\n const isPageLayer = layerIndex === chainLength - 1\n const layoutEntries = chainEnvelope.layouts ?? []\n const expected = isPageLayer ? chainEnvelope.page : layoutEntries[layerIndex]\n\n if (!expected) {\n throw new Error(\n `[llui/vike] Hydration envelope has no entry for chain layer ${layerIndex} ` +\n `(<${def.name}>). Server rendered ${layoutEntries.length} layouts + ${\n chainEnvelope.page ? 'a page' : 'no page'\n }, client expected ${chainLength} total entries.`,\n )\n }\n\n if (expected.name !== def.name) {\n throw new Error(\n `[llui/vike] Hydration mismatch at chain layer ${layerIndex}: server ` +\n `rendered <${expected.name}> but client is trying to hydrate <${def.name}>. ` +\n `This usually means the layout chain resolver returns different layouts ` +\n `on the server and the client for the same route.`,\n )\n }\n\n return expected.state\n}\n"]}
@@ -1,13 +1,22 @@
1
1
  import type { ComponentDef } from '@llui/dom';
2
+ type AnyComponentDef = ComponentDef<unknown, unknown, unknown, unknown>;
3
+ type LayoutChain = ReadonlyArray<AnyComponentDef>;
4
+ /**
5
+ * Page context shape as seen by `@llui/vike`'s server hook. `Page` and
6
+ * `data` are whichever `+Page.ts` and `+data.ts` Vike resolved for the
7
+ * current route; `lluiLayoutData` is an optional array of per-layer
8
+ * layout data matching the chain configured on `createOnRenderHtml`.
9
+ */
2
10
  export interface PageContext {
3
- Page: ComponentDef<unknown, unknown, unknown, unknown>;
11
+ Page: AnyComponentDef;
4
12
  data?: unknown;
13
+ lluiLayoutData?: readonly unknown[];
5
14
  head?: string;
6
15
  }
7
16
  export interface DocumentContext {
8
- /** Rendered component HTML */
17
+ /** Rendered component HTML (layout + page composed if a Layout is configured) */
9
18
  html: string;
10
- /** JSON-serialized initial state */
19
+ /** JSON-serialized hydration envelope (chain-aware when Layout is configured) */
11
20
  state: string;
12
21
  /** Head content from pageContext.head (e.g. from +Head.ts) */
13
22
  head: string;
@@ -23,28 +32,49 @@ export interface RenderHtmlResult {
23
32
  };
24
33
  }
25
34
  /**
26
- * Default onRenderHtml hook for simple cases.
27
- * Uses a minimal HTML document template.
35
+ * Options for the customized `createOnRenderHtml` factory. Mirrors
36
+ * `@llui/vike/client`'s `RenderClientOptions.Layout` the same chain
37
+ * shape is accepted for consistency between server and client render.
38
+ */
39
+ export interface RenderHtmlOptions {
40
+ /** Custom HTML document template. Defaults to a minimal layout. */
41
+ document?: (ctx: DocumentContext) => string;
42
+ /**
43
+ * Persistent layout chain. One of:
44
+ *
45
+ * - A single `ComponentDef` — becomes a one-layout chain.
46
+ * - An array of `ComponentDef`s — outermost first, innermost last.
47
+ * Every layer except the innermost must call `pageSlot()` in its view.
48
+ * - A function that returns a chain from the current `pageContext` —
49
+ * enables per-route chains (e.g. reading Vike's `urlPathname`).
50
+ *
51
+ * The server renders the full chain as one composed HTML tree. Client
52
+ * hydration reads the matching envelope and reconstructs the chain
53
+ * layer-by-layer.
54
+ */
55
+ Layout?: AnyComponentDef | LayoutChain | ((pageContext: PageContext) => LayoutChain);
56
+ }
57
+ /**
58
+ * Default onRenderHtml hook — no layout, minimal document template.
28
59
  */
29
60
  export declare function onRenderHtml(pageContext: PageContext): Promise<RenderHtmlResult>;
30
61
  /**
31
62
  * Factory to create a customized onRenderHtml hook.
32
63
  *
33
- * ```typescript
64
+ * ```ts
34
65
  * // pages/+onRenderHtml.ts
35
- * import { createOnRenderHtml } from '@llui/vike'
66
+ * import { createOnRenderHtml } from '@llui/vike/server'
67
+ * import { AppLayout } from './+Layout'
36
68
  *
37
69
  * export const onRenderHtml = createOnRenderHtml({
70
+ * Layout: AppLayout,
38
71
  * document: ({ html, state, head }) => `<!DOCTYPE html>
39
- * <html>
40
- * <head>${head}<link rel="stylesheet" href="/styles.css" /></head>
41
- * <body><div id="app">${html}</div>
42
- * <script>window.__LLUI_STATE__ = ${state}</script></body>
43
- * </html>`,
72
+ * <html><head>${head}<link rel="stylesheet" href="/styles.css" /></head>
73
+ * <body><div id="app">${html}</div>
74
+ * <script>window.__LLUI_STATE__ = ${state}</script></body></html>`,
44
75
  * })
45
76
  * ```
46
77
  */
47
- export declare function createOnRenderHtml(options: {
48
- document: (ctx: DocumentContext) => string;
49
- }): (pageContext: PageContext) => Promise<RenderHtmlResult>;
78
+ export declare function createOnRenderHtml(options: RenderHtmlOptions): (pageContext: PageContext) => Promise<RenderHtmlResult>;
79
+ export {};
50
80
  //# sourceMappingURL=on-render-html.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"on-render-html.d.ts","sourceRoot":"","sources":["../src/on-render-html.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,WAAW,CAAA;AAE7C,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,YAAY,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,CAAC,CAAA;IACtD,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AAED,MAAM,WAAW,eAAe;IAC9B,8BAA8B;IAC9B,IAAI,EAAE,MAAM,CAAA;IACZ,oCAAoC;IACpC,KAAK,EAAE,MAAM,CAAA;IACb,8DAA8D;IAC9D,IAAI,EAAE,MAAM,CAAA;IACZ,yCAAyC;IACzC,WAAW,EAAE,WAAW,CAAA;CACzB;AAED,MAAM,WAAW,gBAAgB;IAC/B,YAAY,EAAE,MAAM,GAAG;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAA;IAC3C,WAAW,EAAE;QAAE,SAAS,EAAE,OAAO,CAAA;KAAE,CAAA;CACpC;AAcD;;;GAGG;AACH,wBAAsB,YAAY,CAAC,WAAW,EAAE,WAAW,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAEtF;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE;IAC1C,QAAQ,EAAE,CAAC,GAAG,EAAE,eAAe,KAAK,MAAM,CAAA;CAC3C,GAAG,CAAC,WAAW,EAAE,WAAW,KAAK,OAAO,CAAC,gBAAgB,CAAC,CAE1D"}
1
+ {"version":3,"file":"on-render-html.d.ts","sourceRoot":"","sources":["../src/on-render-html.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAkB,MAAM,WAAW,CAAA;AAG7D,KAAK,eAAe,GAAG,YAAY,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,CAAC,CAAA;AACvE,KAAK,WAAW,GAAG,aAAa,CAAC,eAAe,CAAC,CAAA;AAEjD;;;;;GAKG;AACH,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,eAAe,CAAA;IACrB,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,cAAc,CAAC,EAAE,SAAS,OAAO,EAAE,CAAA;IACnC,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AAED,MAAM,WAAW,eAAe;IAC9B,iFAAiF;IACjF,IAAI,EAAE,MAAM,CAAA;IACZ,iFAAiF;IACjF,KAAK,EAAE,MAAM,CAAA;IACb,8DAA8D;IAC9D,IAAI,EAAE,MAAM,CAAA;IACZ,yCAAyC;IACzC,WAAW,EAAE,WAAW,CAAA;CACzB;AAED,MAAM,WAAW,gBAAgB;IAC/B,YAAY,EAAE,MAAM,GAAG;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAA;IAC3C,WAAW,EAAE;QAAE,SAAS,EAAE,OAAO,CAAA;KAAE,CAAA;CACpC;AAcD;;;;GAIG;AACH,MAAM,WAAW,iBAAiB;IAChC,mEAAmE;IACnE,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,eAAe,KAAK,MAAM,CAAA;IAE3C;;;;;;;;;;;;OAYG;IACH,MAAM,CAAC,EAAE,eAAe,GAAG,WAAW,GAAG,CAAC,CAAC,WAAW,EAAE,WAAW,KAAK,WAAW,CAAC,CAAA;CACrF;AAcD;;GAEG;AACH,wBAAsB,YAAY,CAAC,WAAW,EAAE,WAAW,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAEtF;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,iBAAiB,GACzB,CAAC,WAAW,EAAE,WAAW,KAAK,OAAO,CAAC,gBAAgB,CAAC,CAEzD"}