@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.
- package/dist/client-nav.d.ts +46 -0
- package/dist/client-nav.js +186 -0
- package/dist/dom.d.ts +21 -2
- package/dist/dom.js +1502 -180
- package/dist/serve-host.mjs +544 -88
- package/dist/server.js +97 -11
- package/package.json +5 -1
|
@@ -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).
|