@vmz/core 0.0.3 → 0.0.4

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.
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Browser client navigation — same-app `<Link>` SPA takeover.
3
+ * Zero-JS still works via real `<a href>`; with JS, intercept same-origin
4
+ * `a[data-vmz-route]` clicks, pushState, fetch SSR HTML, swap #app, hydrate.
5
+ */
6
+ /**
7
+ * @param {{
8
+ * fetchImpl?: typeof fetch,
9
+ * document?: Document,
10
+ * history?: History,
11
+ * location?: Location,
12
+ * hydrate?: (Ctor: any, root: Element, props: object) => Promise<unknown>,
13
+ * hydrateRoute?: (Page: any, root: Element, props: object, layouts?: any[]) => Promise<unknown>,
14
+ * destroy?: (inst: object) => void,
15
+ * importPage?: (chunkId: string) => Promise<any>,
16
+ * }} [opts]
17
+ */
18
+ export declare function installClientNavigation(opts?: {}): {
19
+ ok: boolean;
20
+ reason: string;
21
+ transitionTo?: undefined;
22
+ dispose?: undefined;
23
+ } | {
24
+ ok: boolean;
25
+ transitionTo: (url: any, { replace, fromPop }?: {
26
+ replace?: boolean;
27
+ fromPop?: boolean;
28
+ }) => Promise<{
29
+ ok: boolean;
30
+ reason: string;
31
+ href?: undefined;
32
+ chunkId?: undefined;
33
+ } | {
34
+ ok: boolean;
35
+ href: string;
36
+ chunkId: any;
37
+ reason?: undefined;
38
+ }>;
39
+ dispose(): void;
40
+ reason?: undefined;
41
+ };
42
+ /**
43
+ * @param {string} html
44
+ * @param {Document} doc
45
+ */
46
+ export declare function extractAppHtml(html: any, doc: any): any;
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Browser client navigation — same-app `<Link>` SPA takeover.
3
+ * Zero-JS still works via real `<a href>`; with JS, intercept same-origin
4
+ * `a[data-vmz-route]` clicks, pushState, fetch SSR HTML, swap #app, hydrate.
5
+ */
6
+ // @ts-nocheck
7
+ /**
8
+ * @param {{
9
+ * fetchImpl?: typeof fetch,
10
+ * document?: Document,
11
+ * history?: History,
12
+ * location?: Location,
13
+ * hydrate?: (Ctor: any, root: Element, props: object) => Promise<unknown>,
14
+ * hydrateRoute?: (Page: any, root: Element, props: object, layouts?: any[]) => Promise<unknown>,
15
+ * destroy?: (inst: object) => void,
16
+ * importPage?: (chunkId: string) => Promise<any>,
17
+ * }} [opts]
18
+ */
19
+ export function installClientNavigation(opts = {}) {
20
+ const doc = opts.document || (typeof document !== 'undefined' ? document : null);
21
+ const win = typeof window !== 'undefined' ? window : null;
22
+ const hist = opts.history || win?.history;
23
+ const loc = opts.location || win?.location;
24
+ const fetchImpl = opts.fetchImpl || (typeof fetch === 'function' ? fetch.bind(globalThis) : null);
25
+ if (!doc || !hist || !loc || !fetchImpl) {
26
+ return { ok: false, reason: 'missing document/history/fetch' };
27
+ }
28
+ if (win && !win.__vmzBootId) {
29
+ win.__vmzBootId = `boot-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
30
+ }
31
+ if (win)
32
+ win.__vmzClientNavInstalled = true;
33
+ /** @type {AbortController | null} */
34
+ let inflight = null;
35
+ let navigating = false;
36
+ async function transitionTo(url, { replace = false, fromPop = false } = {}) {
37
+ const target = new URL(url, loc.href);
38
+ if (target.origin !== loc.origin) {
39
+ loc.assign(target.href);
40
+ return { ok: false, reason: 'cross-origin' };
41
+ }
42
+ if (inflight)
43
+ inflight.abort();
44
+ inflight = new AbortController();
45
+ const signal = inflight.signal;
46
+ navigating = true;
47
+ try {
48
+ const res = await fetchImpl(target.pathname + target.search, {
49
+ method: 'GET',
50
+ headers: { accept: 'text/html', 'x-vmz-client-nav': '1' },
51
+ signal,
52
+ });
53
+ if (!res.ok) {
54
+ // Fall back to full navigation on hard errors.
55
+ if (!fromPop)
56
+ loc.assign(target.href);
57
+ return { ok: false, reason: `http ${res.status}` };
58
+ }
59
+ const html = await res.text();
60
+ const nextApp = extractAppHtml(html, doc);
61
+ if (!nextApp) {
62
+ if (!fromPop)
63
+ loc.assign(target.href);
64
+ return { ok: false, reason: 'missing #app in response' };
65
+ }
66
+ const root = doc.getElementById('app');
67
+ if (!root) {
68
+ if (!fromPop)
69
+ loc.assign(target.href);
70
+ return { ok: false, reason: 'missing #app' };
71
+ }
72
+ // Dispose previous Direct instance if present.
73
+ const prev = root.__vmzInst;
74
+ if (prev && typeof opts.destroy === 'function') {
75
+ opts.destroy(prev);
76
+ }
77
+ else if (prev && win?.vmzDestroy) {
78
+ win.vmzDestroy(prev);
79
+ }
80
+ root.__vmzInst = null;
81
+ root.outerHTML = nextApp.outerHTML;
82
+ const fresh = doc.getElementById('app');
83
+ if (!fresh)
84
+ return { ok: false, reason: 'swap lost #app' };
85
+ if (!fromPop) {
86
+ if (replace)
87
+ hist.replaceState({ vmzClientNav: true }, '', target.href);
88
+ else
89
+ hist.pushState({ vmzClientNav: true }, '', target.href);
90
+ }
91
+ const chunkId = fresh.getAttribute('data-vmz-page') || '';
92
+ let props = {};
93
+ try {
94
+ const raw = fresh.getAttribute('data-vmz-props');
95
+ if (raw)
96
+ props = JSON.parse(raw);
97
+ }
98
+ catch {
99
+ /* ignore */
100
+ }
101
+ if (chunkId) {
102
+ const importChunk = opts.importPage || (async (id) => (await import(/* @vite-ignore */ `/${id}.client.js`)).default);
103
+ const Page = await importChunk(chunkId);
104
+ const layoutChain = (fresh.getAttribute('data-vmz-layout') || '')
105
+ .split(',')
106
+ .map((s) => s.trim())
107
+ .filter(Boolean);
108
+ /** @type {any[]} */
109
+ const layoutCtors = [];
110
+ for (const id of layoutChain) {
111
+ layoutCtors.push(await importChunk(id));
112
+ }
113
+ const dom = await import(/* @vite-ignore */ '/vmz-dom.js');
114
+ const hydrateRoute = opts.hydrateRoute || dom.hydrateRoute;
115
+ if (typeof hydrateRoute === 'function') {
116
+ await hydrateRoute(Page, fresh, props, layoutCtors);
117
+ }
118
+ else {
119
+ const hydrate = opts.hydrate || dom.hydrate;
120
+ await hydrate(Page, fresh, props);
121
+ }
122
+ }
123
+ if (win) {
124
+ win.__vmzClientNavCount = (win.__vmzClientNavCount || 0) + 1;
125
+ win.__vmzLastClientNav = {
126
+ href: target.pathname + target.search,
127
+ routeId: nextApp.getAttribute?.('data-vmz-route') || null,
128
+ chunkId,
129
+ bootId: win.__vmzBootId,
130
+ };
131
+ }
132
+ return { ok: true, href: target.pathname + target.search, chunkId };
133
+ }
134
+ finally {
135
+ navigating = false;
136
+ }
137
+ }
138
+ function onClick(ev) {
139
+ if (navigating)
140
+ return;
141
+ if (ev.defaultPrevented)
142
+ return;
143
+ if (ev.button !== 0)
144
+ return;
145
+ if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey)
146
+ return;
147
+ const a = ev.target?.closest?.('a[data-vmz-route][href]');
148
+ if (!a)
149
+ return;
150
+ const href = a.getAttribute('href');
151
+ if (!href || href.startsWith('#') || /^(mailto|tel|javascript):/i.test(href))
152
+ return;
153
+ const u = new URL(href, loc.href);
154
+ if (u.origin !== loc.origin)
155
+ return;
156
+ // Download / new tab
157
+ if (a.hasAttribute('download') || a.getAttribute('target') === '_blank')
158
+ return;
159
+ ev.preventDefault();
160
+ void transitionTo(u.pathname + u.search + u.hash, {
161
+ replace: a.getAttribute('data-vmz-replace') === 'true',
162
+ });
163
+ }
164
+ function onPopState() {
165
+ void transitionTo(loc.pathname + loc.search + loc.hash, { fromPop: true });
166
+ }
167
+ doc.addEventListener('click', onClick);
168
+ win?.addEventListener?.('popstate', onPopState);
169
+ return {
170
+ ok: true,
171
+ transitionTo,
172
+ dispose() {
173
+ doc.removeEventListener('click', onClick);
174
+ win?.removeEventListener?.('popstate', onPopState);
175
+ },
176
+ };
177
+ }
178
+ /**
179
+ * @param {string} html
180
+ * @param {Document} doc
181
+ */
182
+ export function extractAppHtml(html, doc) {
183
+ const parser = new (doc.defaultView?.DOMParser || globalThis.DOMParser)();
184
+ const parsed = parser.parseFromString(html, 'text/html');
185
+ return parsed.getElementById('app');
186
+ }
package/dist/dom.d.ts CHANGED
@@ -44,17 +44,36 @@ export declare function registerComponents(map: any): void;
44
44
  * @param {new (props?: object) => any} Component
45
45
  * @param {object} [props]
46
46
  */
47
- export declare function renderToString(Component: any, props?: {}): Promise<any>;
47
+ export declare function renderToString(Component: any, props?: {}, opts?: {}): Promise<any>;
48
48
  /**
49
49
  * Stream SSR via the same Direct serialize schedule as `renderToString`.
50
50
  * Yields HTML chunks (open tag → children → close). Joining chunks equals `renderToString`.
51
51
  * Supports AbortSignal for cancel; consumers should respect backpressure (await between chunks).
52
52
  * @param {new (props?: object) => any} Component
53
53
  * @param {object} [props]
54
- * @param {{ signal?: AbortSignal }} [opts]
54
+ * @param {{ signal?: AbortSignal, slotHtml?: string }} [opts]
55
55
  * @returns {AsyncGenerator<string, void, void>}
56
56
  */
57
57
  export declare function renderToStream(Component: any, props?: {}, opts?: {}): AsyncGenerator<any, void, unknown>;
58
+ /**
59
+ * Live-DOM counterpart: first default `<slot>` owned by this tree, not by a nested
60
+ * `[data-vmz]` component (Button/Link labels, etc.).
61
+ * @param {Element | null | undefined} root
62
+ * @returns {Element | null}
63
+ */
64
+ export declare function findOwnedDefaultSlot(root: any): any;
65
+ /**
66
+ * Hydrate/mount a file-route page inside an optional layout chain (outer → inner).
67
+ * Mirrors SSR `slotHtml` wrapping: each layout's owned default slot becomes the
68
+ * outlet for the next layout or the page. Retains layout instances on `container`
69
+ * so SPA transitions can dispose only the page host.
70
+ * @param {new (props?: object) => any} Page
71
+ * @param {Element} container
72
+ * @param {object} [props]
73
+ * @param {Array<new (props?: object) => any>} [layoutCtors] outer → inner
74
+ * @param {{ preserveState?: boolean | Record<string, unknown>, skipOnMount?: boolean }} [opts]
75
+ */
76
+ export declare function hydrateRoute(Page: any, container: any, props?: {}, layoutCtors?: any[], opts?: {}): Promise<any>;
58
77
  /**
59
78
  * Mount once; later updates are dep patches only (never re-run structure).
60
79
  * Requires compiler `__vmzCreate` (production Direct emit — no blueprint fallback).