@mosetta/runtime 0.0.0-stage → 0.0.9
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/LICENSE +21 -0
- package/README.md +15 -2
- package/package.json +26 -3
- package/src/assetRuntime.js +13 -0
- package/src/bootstrap.ts +363 -0
- package/src/client/appContainer.ts +86 -0
- package/src/client/client.ts +12 -0
- package/src/client/getIsClient.ts +3 -0
- package/src/client/headWriter.ts +85 -0
- package/src/client/pageManager.ts +55 -0
- package/src/client/refs.ts +319 -0
- package/src/client/router.ts +453 -0
- package/src/client/serverRender.ts +12 -0
- package/src/client/tile.ts +7 -0
- package/src/client/tokens.ts +70 -0
- package/src/domEvents.ts +118 -0
- package/src/naming.d.ts +6 -0
- package/src/naming.js +25 -0
- package/src/resolver.d.ts +29 -0
- package/src/resolver.js +137 -0
- package/src/tileLoader.js +42 -0
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
import { SingleDiToken } from '@mosetta/di';
|
|
2
|
+
import type { IBlueprint, IRouteMatch } from '@mosetta/core';
|
|
3
|
+
import { DOCUMENT_QUERY_PARAM, isRightToLeft, languageScheme, matchPattern, parsePattern } from '@mosetta/core';
|
|
4
|
+
import type { PageManager } from './pageManager';
|
|
5
|
+
import type { IHeadWriter } from './headWriter';
|
|
6
|
+
import type { ITilesHolder } from './tokens';
|
|
7
|
+
|
|
8
|
+
// The CLIENT ROUTER (ADR-0051): a move to another address of the site without a document load.
|
|
9
|
+
// The page's document is fetched as JSON from the same address (DOCUMENT_QUERY_PARAM), the realm
|
|
10
|
+
// is made ready for its tree (modules, css), the tree is swapped under the running application
|
|
11
|
+
// through the page manager — the very mechanism the editor's preview uses for live composition —
|
|
12
|
+
// and history, scroll and the head follow. Nodes with the same id on both pages stay mounted:
|
|
13
|
+
// a layout shared by two pages is one layout.
|
|
14
|
+
//
|
|
15
|
+
// Framework-agnostic: the react binding wraps it in `useRouter()` and `<Link>`. Alive only for
|
|
16
|
+
// the page's own boot in a browser; in a preview realm, a hosted mount and on the server an
|
|
17
|
+
// INERT twin answers "not mine" and a link behaves as a link.
|
|
18
|
+
|
|
19
|
+
export interface INavigateOptions {
|
|
20
|
+
/** replace the current history entry instead of adding one */
|
|
21
|
+
readonly replace?: boolean;
|
|
22
|
+
/** scroll to the top (or to the hash) once the page is in; default true */
|
|
23
|
+
readonly scroll?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* SHALLOW: a new address of the SAME page — the route is re-read against the page's own pattern,
|
|
26
|
+
* no document is fetched (tabs and widths living in the query). Refused (answers false) when
|
|
27
|
+
* the address is not this page's.
|
|
28
|
+
*/
|
|
29
|
+
readonly shallow?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface IRouterSnapshot {
|
|
33
|
+
/** a move is in flight */
|
|
34
|
+
readonly pending: boolean;
|
|
35
|
+
/** the address the router last moved to (or was born at) — path, search and hash */
|
|
36
|
+
readonly href: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface IRouter {
|
|
40
|
+
/** whether moves happen in place here at all */
|
|
41
|
+
readonly active: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Move to an address. Resolves true when the router took the move (in place, or by starting a
|
|
44
|
+
* full load itself when the document could not be used); false when it is not the router's —
|
|
45
|
+
* another origin, an inert router — and the caller lets the browser go.
|
|
46
|
+
*/
|
|
47
|
+
navigate(href: string, options?: INavigateOptions): Promise<boolean>;
|
|
48
|
+
/** one step back in history */
|
|
49
|
+
back(): void;
|
|
50
|
+
snapshot(): IRouterSnapshot;
|
|
51
|
+
/** the useSyncExternalStore contract */
|
|
52
|
+
subscribe(listener: () => void): () => void;
|
|
53
|
+
/** start listening to history; the returned function stops */
|
|
54
|
+
attach(): () => void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const ROUTER_TOKEN = new SingleDiToken<IRouter>('ROUTER_TOKEN');
|
|
58
|
+
|
|
59
|
+
/** what a page's document carries beyond its tree, as the server answers DOCUMENT_QUERY_PARAM */
|
|
60
|
+
export interface IPageDocument extends IBlueprint {
|
|
61
|
+
readonly meta?: { readonly title?: string; readonly description?: string } & Record<string, unknown>;
|
|
62
|
+
readonly hostBase?: string;
|
|
63
|
+
readonly settings?: { readonly versions?: unknown } & Record<string, unknown>;
|
|
64
|
+
readonly head?: readonly IHeadTagOfDocument[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface IHeadTagOfDocument {
|
|
68
|
+
readonly key: string;
|
|
69
|
+
readonly tag: string;
|
|
70
|
+
readonly attributes: Record<string, string>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** the browser as the router sees it — a seam, so the router's logic is tested without one */
|
|
74
|
+
export interface IRouterHost {
|
|
75
|
+
href(): string;
|
|
76
|
+
origin(): string;
|
|
77
|
+
pushState(state: unknown, url: string): void;
|
|
78
|
+
replaceState(state: unknown, url: string): void;
|
|
79
|
+
state(): unknown;
|
|
80
|
+
back(): void;
|
|
81
|
+
onPopState(listener: (state: unknown) => void): () => void;
|
|
82
|
+
/** the page's document at this address, or null when the answer is not one (a 404, a sign-in, html) */
|
|
83
|
+
fetchDocument(url: string): Promise<IPageDocument | null>;
|
|
84
|
+
/** a full document load */
|
|
85
|
+
assign(url: string): void;
|
|
86
|
+
scrollTo(x: number, y: number): void;
|
|
87
|
+
scrollPosition(): { x: number; y: number };
|
|
88
|
+
/** scroll to the element the hash names; false when there is none */
|
|
89
|
+
scrollToHash(hash: string): boolean;
|
|
90
|
+
/** the document's own words: title, description, the language of the html element */
|
|
91
|
+
describe(words: { title?: string; description?: string; lang?: string; rtl: boolean }): void;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The mark the router leaves on the history entries it makes: a number of its own, and the
|
|
96
|
+
* pattern of the page shown there — a pop onto an entry of the SAME page re-reads the route
|
|
97
|
+
* instead of fetching (a page whose pattern swallows addresses of other pages, `/x/{...rest}`,
|
|
98
|
+
* would otherwise claim them all on the way back).
|
|
99
|
+
*/
|
|
100
|
+
interface IRouterState {
|
|
101
|
+
readonly mosetta: { readonly seq: number; readonly pattern?: string };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isRouterState(state: unknown): state is IRouterState {
|
|
105
|
+
return typeof state === 'object' && state !== null && 'mosetta' in state && typeof (state as IRouterState).mosetta === 'object';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** path, search and hash of an address — what history is given and what `href` reports */
|
|
109
|
+
const addressOf = (url: URL): string => url.pathname + url.search + url.hash;
|
|
110
|
+
|
|
111
|
+
/** head keys written per NODE — swept when the node is gone with its page */
|
|
112
|
+
const NODE_KEYED_HEAD = /^(styler|theme|theme-fonts):(.+)$/;
|
|
113
|
+
const SEO_HEAD_PREFIX = 'seo:';
|
|
114
|
+
|
|
115
|
+
/** every node id of a tree */
|
|
116
|
+
function idsOf(page: IBlueprint): Set<string> {
|
|
117
|
+
const ids = new Set<string>();
|
|
118
|
+
const walk = (node: IBlueprint): void => {
|
|
119
|
+
if (node.id) ids.add(node.id);
|
|
120
|
+
for (const child of node.children ?? []) walk(child as IBlueprint);
|
|
121
|
+
};
|
|
122
|
+
walk(page);
|
|
123
|
+
return ids;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The route the page would carry at this address — when the address is this page's (its own
|
|
128
|
+
* pattern, in the same language); null when it is another page's. Params and the query come
|
|
129
|
+
* from the address, the language scheme stays the server's word.
|
|
130
|
+
*/
|
|
131
|
+
export function rerouteOf(page: IBlueprint, url: URL): IRouteMatch | null {
|
|
132
|
+
const route = page.route;
|
|
133
|
+
if (!route) return null;
|
|
134
|
+
const languages = route.addressing;
|
|
135
|
+
const read: { lang: string | undefined; path: string } = languages
|
|
136
|
+
? languageScheme(languages.scheme).read(url.pathname, languages)
|
|
137
|
+
: { lang: undefined, path: url.pathname };
|
|
138
|
+
if ((read.lang ?? languages?.defaultLanguage) !== (route.lang ?? languages?.defaultLanguage)) return null;
|
|
139
|
+
const pattern = typeof route.pattern === 'string' ? route.pattern : page.pattern;
|
|
140
|
+
if (typeof pattern !== 'string') return null;
|
|
141
|
+
const target = new URL(url.href);
|
|
142
|
+
target.pathname = read.path;
|
|
143
|
+
let match: IRouteMatch | null;
|
|
144
|
+
try {
|
|
145
|
+
match = matchPattern(parsePattern(pattern), target);
|
|
146
|
+
} catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
if (!match) return null;
|
|
150
|
+
const search: Record<string, string> = {};
|
|
151
|
+
url.searchParams.forEach((value, key) => {
|
|
152
|
+
if (key !== DOCUMENT_QUERY_PARAM) search[key] = value;
|
|
153
|
+
});
|
|
154
|
+
return { ...match, lang: route.lang, addressing: route.addressing, path: read.path, search };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The same realm serves both pages: one root module, one version policy, one host base — the
|
|
159
|
+
* last compared only when both documents name it (a page mounted by hand, a static build's,
|
|
160
|
+
* names none: the resolver installed at boot is what there is).
|
|
161
|
+
*/
|
|
162
|
+
function sameRealm(current: IPageDocument, next: IPageDocument): boolean {
|
|
163
|
+
return current.reference === next.reference
|
|
164
|
+
&& (!current.hostBase || !next.hostBase || current.hostBase === next.hostBase)
|
|
165
|
+
&& JSON.stringify(current.settings?.versions ?? null) === JSON.stringify(next.settings?.versions ?? null);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export class ClientRouter implements IRouter {
|
|
169
|
+
readonly active = true;
|
|
170
|
+
|
|
171
|
+
private readonly listeners = new Set<() => void>();
|
|
172
|
+
|
|
173
|
+
private seq = 0;
|
|
174
|
+
|
|
175
|
+
private pending = false;
|
|
176
|
+
|
|
177
|
+
private href: string;
|
|
178
|
+
|
|
179
|
+
/** the mark of the entry the browser is on, when this router made it; null on someone else's */
|
|
180
|
+
private ownSeq: number | null = null;
|
|
181
|
+
|
|
182
|
+
/** where each of the router's entries was scrolled to when it was left, by its mark */
|
|
183
|
+
private readonly scrolls = new Map<number, { x: number; y: number }>();
|
|
184
|
+
|
|
185
|
+
/** the snapshot as a VALUE: the same object until the state moves (useSyncExternalStore reads it by identity) */
|
|
186
|
+
private snap: IRouterSnapshot;
|
|
187
|
+
|
|
188
|
+
constructor(
|
|
189
|
+
private readonly host: IRouterHost,
|
|
190
|
+
private readonly manager: PageManager,
|
|
191
|
+
private readonly holder: ITilesHolder,
|
|
192
|
+
private readonly head: IHeadWriter,
|
|
193
|
+
) {
|
|
194
|
+
this.href = addressOf(new URL(host.href()));
|
|
195
|
+
this.snap = { pending: false, href: this.href };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Start listening to history. The entry the page was loaded on gets the router's mark too
|
|
200
|
+
* (its address stays): a pop back onto it is then the router's to answer, like any other.
|
|
201
|
+
*/
|
|
202
|
+
attach(): () => void {
|
|
203
|
+
const state = this.host.state();
|
|
204
|
+
if (!isRouterState(state)) {
|
|
205
|
+
const current = this.manager.getCurrentPage() as IPageDocument;
|
|
206
|
+
this.host.replaceState({ mosetta: { seq: this.seq, ...(current.route?.pattern !== undefined ? { pattern: current.route.pattern } : {}) } }, this.href);
|
|
207
|
+
this.ownSeq = this.seq;
|
|
208
|
+
} else {
|
|
209
|
+
this.ownSeq = state.mosetta.seq;
|
|
210
|
+
}
|
|
211
|
+
return this.host.onPopState((state) => this.onPopState(state));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
snapshot(): IRouterSnapshot {
|
|
215
|
+
return this.snap;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
readonly subscribe = (listener: () => void): (() => void) => {
|
|
219
|
+
this.listeners.add(listener);
|
|
220
|
+
return () => {
|
|
221
|
+
this.listeners.delete(listener);
|
|
222
|
+
};
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
back(): void {
|
|
226
|
+
this.host.back();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async navigate(href: string, options: INavigateOptions = {}): Promise<boolean> {
|
|
230
|
+
const target = new URL(href, this.host.href());
|
|
231
|
+
if (target.origin !== this.host.origin()) return false;
|
|
232
|
+
const current = this.manager.getCurrentPage() as IPageDocument;
|
|
233
|
+
if (options.shallow) {
|
|
234
|
+
const route = rerouteOf(current, target);
|
|
235
|
+
if (!route) return false;
|
|
236
|
+
this.rememberScroll();
|
|
237
|
+
this.commit(target, options.replace === true, route.pattern);
|
|
238
|
+
this.manager.setCurrentPage({ ...current, route });
|
|
239
|
+
this.notify();
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
await this.move(target, { history: options.replace ? 'replace' : 'push', scroll: options.scroll !== false ? 'top' : 'keep' });
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* A move onto an address: the document fetched and prepared, then history, the tree, the head
|
|
248
|
+
* and the scroll — in that order, so a listener of history sees the new tree. A later move
|
|
249
|
+
* supersedes this one at every await.
|
|
250
|
+
*/
|
|
251
|
+
private async move(target: URL, how: { history: 'push' | 'replace' | 'none'; scroll: 'top' | 'keep' | { x: number; y: number } }): Promise<void> {
|
|
252
|
+
const seq = ++this.seq;
|
|
253
|
+
this.pending = true;
|
|
254
|
+
this.notify();
|
|
255
|
+
const current = this.manager.getCurrentPage() as IPageDocument;
|
|
256
|
+
const settle = (): void => {
|
|
257
|
+
if (seq !== this.seq) return;
|
|
258
|
+
this.pending = false;
|
|
259
|
+
this.notify();
|
|
260
|
+
};
|
|
261
|
+
let next: IPageDocument | null = null;
|
|
262
|
+
try {
|
|
263
|
+
next = await this.host.fetchDocument(this.documentUrl(target));
|
|
264
|
+
} catch {
|
|
265
|
+
next = null;
|
|
266
|
+
}
|
|
267
|
+
if (seq !== this.seq) return;
|
|
268
|
+
if (!next || !next.reference || !sameRealm(current, next)) {
|
|
269
|
+
// not a document of this realm — the browser loads it whole (a sign-in, a 404, another root)
|
|
270
|
+
settle();
|
|
271
|
+
this.host.assign(target.href);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
await this.holder.prepare?.(next);
|
|
276
|
+
} catch {
|
|
277
|
+
settle();
|
|
278
|
+
this.host.assign(target.href);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (seq !== this.seq) return;
|
|
282
|
+
if (how.history !== 'none') {
|
|
283
|
+
this.rememberScroll();
|
|
284
|
+
this.commit(target, how.history === 'replace', next.route?.pattern);
|
|
285
|
+
} else {
|
|
286
|
+
this.href = addressOf(target);
|
|
287
|
+
const state = this.host.state();
|
|
288
|
+
this.ownSeq = isRouterState(state) ? state.mosetta.seq : null;
|
|
289
|
+
}
|
|
290
|
+
this.manager.setCurrentPage(next);
|
|
291
|
+
this.writeHead(next);
|
|
292
|
+
settle();
|
|
293
|
+
if (how.scroll === 'top') {
|
|
294
|
+
if (!target.hash || !this.host.scrollToHash(target.hash)) this.host.scrollTo(0, 0);
|
|
295
|
+
} else if (how.scroll !== 'keep') {
|
|
296
|
+
this.host.scrollTo(how.scroll.x, how.scroll.y);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private documentUrl(target: URL): string {
|
|
301
|
+
const url = new URL(target.href);
|
|
302
|
+
url.hash = '';
|
|
303
|
+
url.searchParams.set(DOCUMENT_QUERY_PARAM, '1');
|
|
304
|
+
return url.href;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** the history entry for the address: marked as the router's own, with the page shown there */
|
|
308
|
+
private commit(target: URL, replace: boolean, pattern: string | undefined): void {
|
|
309
|
+
const state: IRouterState = { mosetta: { seq: this.seq, ...(pattern !== undefined ? { pattern } : {}) } };
|
|
310
|
+
const url = addressOf(target);
|
|
311
|
+
if (replace) this.host.replaceState(state, url);
|
|
312
|
+
else this.host.pushState(state, url);
|
|
313
|
+
this.href = url;
|
|
314
|
+
this.ownSeq = this.seq;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* The scroll position of the entry being left, kept for the way back — read BEFORE anything
|
|
319
|
+
* renders, while the page is still where the reader left it (own entries only).
|
|
320
|
+
*/
|
|
321
|
+
private rememberScroll(): void {
|
|
322
|
+
if (this.ownSeq !== null) this.scrolls.set(this.ownSeq, this.host.scrollPosition());
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Back and forward. The router answers for the entries it marked — the one the page was
|
|
327
|
+
* loaded on, the ones its moves made; an entry someone else made (a shell with history of its
|
|
328
|
+
* own) is not its business.
|
|
329
|
+
*/
|
|
330
|
+
private onPopState(state: unknown): void {
|
|
331
|
+
if (!isRouterState(state)) return;
|
|
332
|
+
// the entry being left: its scroll, while the page still shows it (the browser moved the
|
|
333
|
+
// history already — the mark remembered before this is the entry left)
|
|
334
|
+
this.rememberScroll();
|
|
335
|
+
const target = new URL(this.host.href());
|
|
336
|
+
const scroll = this.scrolls.get(state.mosetta.seq) ?? null;
|
|
337
|
+
const current = this.manager.getCurrentPage() as IPageDocument;
|
|
338
|
+
// the same page only when the entry says so: a pattern alone would let a catch-all page
|
|
339
|
+
// claim every address under it
|
|
340
|
+
const samePage = state.mosetta.pattern !== undefined && state.mosetta.pattern === current.route?.pattern;
|
|
341
|
+
const route = samePage ? rerouteOf(current, target) : null;
|
|
342
|
+
if (route) {
|
|
343
|
+
// the same page at another of its addresses: the route is re-read, nothing is fetched
|
|
344
|
+
this.seq++;
|
|
345
|
+
this.pending = false;
|
|
346
|
+
this.href = addressOf(target);
|
|
347
|
+
this.ownSeq = state.mosetta.seq;
|
|
348
|
+
this.manager.setCurrentPage({ ...current, route });
|
|
349
|
+
this.notify();
|
|
350
|
+
if (scroll) this.host.scrollTo(scroll.x, scroll.y);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
void this.move(target, { history: 'none', scroll: scroll ?? 'top' });
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** the document's words and the protocol's tags; what the page before left behind is swept */
|
|
357
|
+
private writeHead(next: IPageDocument): void {
|
|
358
|
+
const lang = next.route?.lang ?? next.route?.addressing?.defaultLanguage;
|
|
359
|
+
this.host.describe({ title: next.meta?.title, description: next.meta?.description, lang, rtl: isRightToLeft(lang) });
|
|
360
|
+
const keep = new Set<string>();
|
|
361
|
+
for (const tag of next.head ?? []) {
|
|
362
|
+
keep.add(tag.key);
|
|
363
|
+
this.head.addToHead(tag.key, tag.tag, tag.attributes);
|
|
364
|
+
}
|
|
365
|
+
const ids = idsOf(next);
|
|
366
|
+
for (const key of this.head.headKeys?.() ?? []) {
|
|
367
|
+
if (key.startsWith(SEO_HEAD_PREFIX) && !keep.has(key)) this.head.removeFromHead?.(key);
|
|
368
|
+
const node = NODE_KEYED_HEAD.exec(key);
|
|
369
|
+
if (node && !ids.has(node[2])) this.head.removeFromHead?.(key);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private notify(): void {
|
|
374
|
+
if (this.snap.pending !== this.pending || this.snap.href !== this.href) this.snap = { pending: this.pending, href: this.href };
|
|
375
|
+
for (const listener of [...this.listeners]) listener();
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const INERT_SNAPSHOT: IRouterSnapshot = { pending: false, href: '' };
|
|
380
|
+
|
|
381
|
+
/** where moves do not happen in place: the server, a preview realm, a hosted mount */
|
|
382
|
+
export class InertRouter implements IRouter {
|
|
383
|
+
readonly active = false;
|
|
384
|
+
|
|
385
|
+
navigate(): Promise<boolean> {
|
|
386
|
+
return Promise.resolve(false);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
back(): void {
|
|
390
|
+
if (typeof history !== 'undefined') history.back();
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
snapshot(): IRouterSnapshot {
|
|
394
|
+
return INERT_SNAPSHOT;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
subscribe(): () => void {
|
|
398
|
+
return () => undefined;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
attach(): () => void {
|
|
402
|
+
return () => undefined;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** the browser behind the router */
|
|
407
|
+
export function browserRouterHost(): IRouterHost {
|
|
408
|
+
return {
|
|
409
|
+
href: () => window.location.href,
|
|
410
|
+
origin: () => window.location.origin,
|
|
411
|
+
pushState: (state, url) => window.history.pushState(state, '', url),
|
|
412
|
+
replaceState: (state, url) => window.history.replaceState(state, '', url),
|
|
413
|
+
state: (): unknown => window.history.state as unknown,
|
|
414
|
+
back: () => window.history.back(),
|
|
415
|
+
onPopState: (listener) => {
|
|
416
|
+
const handler = (event: PopStateEvent): void => listener(event.state);
|
|
417
|
+
window.addEventListener('popstate', handler);
|
|
418
|
+
return () => window.removeEventListener('popstate', handler);
|
|
419
|
+
},
|
|
420
|
+
fetchDocument: async (url) => {
|
|
421
|
+
const response = await fetch(url, { headers: { accept: 'application/json' }, credentials: 'same-origin' });
|
|
422
|
+
if (!response.ok || !(response.headers.get('content-type') ?? '').includes('application/json')) return null;
|
|
423
|
+
return response.json() as Promise<IPageDocument>;
|
|
424
|
+
},
|
|
425
|
+
assign: (url) => window.location.assign(url),
|
|
426
|
+
scrollTo: (x, y) => window.scrollTo(x, y),
|
|
427
|
+
scrollPosition: () => ({ x: window.scrollX, y: window.scrollY }),
|
|
428
|
+
scrollToHash: (hash) => {
|
|
429
|
+
const id = decodeURIComponent(hash.replace(/^#/, ''));
|
|
430
|
+
const el = id ? document.getElementById(id) : null;
|
|
431
|
+
if (!el) return false;
|
|
432
|
+
el.scrollIntoView();
|
|
433
|
+
return true;
|
|
434
|
+
},
|
|
435
|
+
describe: ({ title, description, lang, rtl }) => {
|
|
436
|
+
if (title !== undefined) document.title = title;
|
|
437
|
+
let meta = document.head.querySelector<HTMLMetaElement>('meta[name="description"]');
|
|
438
|
+
if (description) {
|
|
439
|
+
if (!meta) {
|
|
440
|
+
meta = document.createElement('meta');
|
|
441
|
+
meta.name = 'description';
|
|
442
|
+
document.head.appendChild(meta);
|
|
443
|
+
}
|
|
444
|
+
meta.content = description;
|
|
445
|
+
} else {
|
|
446
|
+
meta?.remove();
|
|
447
|
+
}
|
|
448
|
+
if (lang) document.documentElement.lang = lang;
|
|
449
|
+
if (rtl) document.documentElement.dir = 'rtl';
|
|
450
|
+
else document.documentElement.removeAttribute('dir');
|
|
451
|
+
},
|
|
452
|
+
};
|
|
453
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Effective-ssr — the pure half of the client gate (SSR epic stage 4; the react scope/gate live
|
|
2
|
+
// in @mosetta/react). A node is server-rendered iff the branch is enabled AND neither the node
|
|
3
|
+
// nor an ancestor opted out (ssr:false) or is lazy. The SAME rule the server's closure walk
|
|
4
|
+
// applies (server router/closure.ts).
|
|
5
|
+
export interface ISsrFlags {
|
|
6
|
+
ssr?: unknown;
|
|
7
|
+
lazy?: unknown;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function isNodeServerRendered(node: ISsrFlags, inherited: boolean): boolean {
|
|
11
|
+
return inherited && node.ssr !== false && node.lazy !== true;
|
|
12
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { IWithError, IWithTileReference } from '@mosetta/core';
|
|
2
|
+
|
|
3
|
+
// The unified module envelope every loader resolves to ({module} or {error} — never a reject);
|
|
4
|
+
// the holder, useTile and the safe loaders all speak this shape. Framework-agnostic.
|
|
5
|
+
export interface ILoadedTile<T = unknown> extends IWithTileReference, IWithError {
|
|
6
|
+
readonly module: T;
|
|
7
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { SingleDiToken } from '@mosetta/di';
|
|
2
|
+
import type { Constructable, DiContainer } from '@mosetta/di';
|
|
3
|
+
import { ILoadedTile } from './tile';
|
|
4
|
+
import type { ISsrRenderChannel } from './headWriter';
|
|
5
|
+
|
|
6
|
+
export interface ITilesHolder {
|
|
7
|
+
// sync accessor for an already-loaded module (its failure, if any, travels in ILoadedTile.error)
|
|
8
|
+
getModule(moduleId: string): ILoadedTile;
|
|
9
|
+
// unified safe loader — always resolves to an ILoadedTile ({module} or {error}); used for dynamic/lazy
|
|
10
|
+
// loads on top of the same mechanism the initial bootstrap uses. Optional: a holder may be preload-only.
|
|
11
|
+
loadModule?(moduleId: string, providerBase?: string): Promise<ILoadedTile>;
|
|
12
|
+
/**
|
|
13
|
+
* The realm made ready for another page's tree (ADR-0051): its catalog slice joined, the css
|
|
14
|
+
* of its eager closure in place, its modules loaded. The browser bootstrap's holder has it;
|
|
15
|
+
* the server's twin and a host's do not — no client-side move happens there.
|
|
16
|
+
*/
|
|
17
|
+
prepare?(tree: object): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const TILES_HOLDER_TOKEN = new SingleDiToken<ITilesHolder>('TILES_HOLDER_TOKEN');
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The HOST application's own container, when this application was MOUNTED by one
|
|
24
|
+
* (IMountOptions.parentDi); null for a page's own boot. The Unity-editor door: a mount is
|
|
25
|
+
* same-realm by construction (it takes a live DOM element), tokens are STRING-keyed with
|
|
26
|
+
* duck-typed guards (a foreign copy of the di package reads a foreign container fine), and
|
|
27
|
+
* in-realm "isolation" was only ever a convention — the enforced boundary is the realm (the
|
|
28
|
+
* canvas iframe). So editor-time code gets the parent environment whole, and what a host means
|
|
29
|
+
* to EXPOSE it registers in its own container under a named token (the settings draft).
|
|
30
|
+
*/
|
|
31
|
+
export const PARENT_DI_TOKEN = new SingleDiToken<DiContainer | null>('PARENT_DI_TOKEN');
|
|
32
|
+
|
|
33
|
+
// The per-request ssr render channel AS DATA: createAppContainer always registers it
|
|
34
|
+
// (null in the browser) so lazily-wired slices (useServerAction) can flip their server wiring
|
|
35
|
+
// without the ROOT knowing about any optional feature. Zero feature code lives behind this token.
|
|
36
|
+
export const SSR_RENDER_CHANNEL_TOKEN = new SingleDiToken<ISsrRenderChannel | null>('SSR_RENDER_CHANNEL_TOKEN');
|
|
37
|
+
|
|
38
|
+
/** a whole preview realm — the canvas iframe, marked by its shell before bootstrap */
|
|
39
|
+
export const FULL_APP_PREVIEW = 'fullApp';
|
|
40
|
+
|
|
41
|
+
/** one mounted specimen — the palette's bubble (and the future storybook) */
|
|
42
|
+
export const PALETTE_PREVIEW = 'palette';
|
|
43
|
+
|
|
44
|
+
export type TPreviewType = typeof FULL_APP_PREVIEW | typeof PALETTE_PREVIEW;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A host's word that the application it mounts is a SPECIMEN — a typed MOUNT OPTION
|
|
48
|
+
* (IMountOptions.preview), deliberately NOT a host contract: `host` is the bridge for data
|
|
49
|
+
* addressed to the hosted MODULES (read via HOST_CONTRACTS_TOKEN), while this is addressed to
|
|
50
|
+
* the CONTAINER — the type feeds PREVIEW_TYPE_TOKEN, and the services are DI classes no module
|
|
51
|
+
* may ever read as data.
|
|
52
|
+
*/
|
|
53
|
+
export interface IMountPreview {
|
|
54
|
+
readonly type: TPreviewType;
|
|
55
|
+
/**
|
|
56
|
+
* DI services that make the specimen's environment REAL ENOUGH for the live component to
|
|
57
|
+
* render (a seeded editing session, a canned data source) — registered into the specimen's
|
|
58
|
+
* container before anything renders. The preview stays the real component; these arrange its
|
|
59
|
+
* world, never its markup.
|
|
60
|
+
*/
|
|
61
|
+
readonly services?: readonly Constructable[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Previewness as a CONTAINER fact, not a flag smuggled through markup: createAppContainer
|
|
66
|
+
* computes it once per application — the mount option wins (it speaks about this one mounted
|
|
67
|
+
* specimen), the realm mark `__mosettaPreview` covers the whole preview iframe, and everything
|
|
68
|
+
* else is an ordinary live application (null).
|
|
69
|
+
*/
|
|
70
|
+
export const PREVIEW_TYPE_TOKEN = new SingleDiToken<TPreviewType | null>('PREVIEW_TYPE_TOKEN');
|
package/src/domEvents.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { DiContainer, SingleDiToken } from '@mosetta/di';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Window-level custom events, built in the realm they are dispatched to.
|
|
5
|
+
*
|
|
6
|
+
* Take this instead of reaching for `new CustomEvent(...)` directly. An Event constructed in one
|
|
7
|
+
* realm is not an Event to a target in another — an iframe, a preview, jsdom under node — and the
|
|
8
|
+
* failure surfaces as a TypeError inside `dispatchEvent`, far from the code that picked the
|
|
9
|
+
* constructor. The rule is easy to state and easy to forget, so it lives here once: the event is
|
|
10
|
+
* built by the very window it is sent to.
|
|
11
|
+
*
|
|
12
|
+
* The window is a constructor parameter, so a test hands over a fake one instead of patching
|
|
13
|
+
* globals; left out, it is read from the environment at call time (a module may well be evaluated
|
|
14
|
+
* on the server, where there is no window at all).
|
|
15
|
+
*/
|
|
16
|
+
export class DomEventBus {
|
|
17
|
+
// `Window & typeof globalThis` and not a bare Window: the event CONSTRUCTORS live on the global
|
|
18
|
+
// scope of that window, and they are the whole point of this class
|
|
19
|
+
constructor(private readonly host?: Window & typeof globalThis) {}
|
|
20
|
+
|
|
21
|
+
/** send `detail` to everyone listening for `type` on this window */
|
|
22
|
+
emit<TDetail>(type: string, detail: TDetail): void {
|
|
23
|
+
const target = this.target();
|
|
24
|
+
target.dispatchEvent(new target.CustomEvent(type, { detail }));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** listen for `type`; the returned callback stops listening */
|
|
28
|
+
on<TDetail>(type: string, listener: (detail: TDetail) => void): () => void {
|
|
29
|
+
const target = this.target();
|
|
30
|
+
const handler = (event: Event): void => listener((event as CustomEvent<TDetail>).detail);
|
|
31
|
+
target.addEventListener(type, handler);
|
|
32
|
+
return () => target.removeEventListener(type, handler);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private target(): Window & typeof globalThis {
|
|
36
|
+
return this.host ?? window;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** the ambient window's bus — for modules that have no reason to own one */
|
|
41
|
+
export const windowEvents = new DomEventBus();
|
|
42
|
+
|
|
43
|
+
// ---- the URL QUERY as a DI-resolvable tool ----------------------------------------------------
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* READ AND WRITE the page URL's query parameters — the address-bar face of workbench state that
|
|
47
|
+
* should survive a reload (e.g. which drafts are open in which canvas tabs). Writing goes through
|
|
48
|
+
* history.replaceState: the address changes, nothing navigates, nothing lands in history.
|
|
49
|
+
*/
|
|
50
|
+
export abstract class QueryParams {
|
|
51
|
+
abstract get(name: string): string | null;
|
|
52
|
+
|
|
53
|
+
/** set (or, with null, remove) one parameter — the rest of the URL stays as it was */
|
|
54
|
+
abstract set(name: string, value: string | null): void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* URLSearchParams SERIALIZES conservatively (form-urlencoding): it percent-encodes every character
|
|
59
|
+
* outside `A-Za-z0-9*-._`, turning a value like `20:(tree,palette)` into `20%3A%28tree%2C...` in
|
|
60
|
+
* the address bar — three times the length and none of the readability. RFC 3986 allows far more
|
|
61
|
+
* in a query RAW, and URLSearchParams DECODES those characters just fine, so writing goes through
|
|
62
|
+
* this minimal encoder instead: encodeURIComponent, with the query-legal punctuation restored.
|
|
63
|
+
* `+` stays out of the friendly set on purpose — a raw `+` form-decodes to a space.
|
|
64
|
+
*/
|
|
65
|
+
function queryText(part: string): string {
|
|
66
|
+
return encodeURIComponent(part)
|
|
67
|
+
.replace(/%2C/gi, ',')
|
|
68
|
+
.replace(/%3A/gi, ':')
|
|
69
|
+
.replace(/%3B/gi, ';')
|
|
70
|
+
.replace(/%40/gi, '@')
|
|
71
|
+
.replace(/%2F/gi, '/')
|
|
72
|
+
.replace(/%7E/gi, '~');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
class LocationQueryParams extends QueryParams {
|
|
76
|
+
constructor(private readonly host: Window & typeof globalThis) {
|
|
77
|
+
super();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
get(name: string): string | null {
|
|
81
|
+
return new this.host.URLSearchParams(this.host.location.search).get(name);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
set(name: string, value: string | null): void {
|
|
85
|
+
const url = new this.host.URL(this.host.location.href);
|
|
86
|
+
const params = new this.host.URLSearchParams(url.search);
|
|
87
|
+
if (value === null) params.delete(name);
|
|
88
|
+
else params.set(name, value);
|
|
89
|
+
url.search = [...params].map(([key, item]) => `${queryText(key)}=${queryText(item)}`).join('&');
|
|
90
|
+
if (url.href === this.host.location.href) return;
|
|
91
|
+
try {
|
|
92
|
+
this.host.history.replaceState(this.host.history.state, '', url.href);
|
|
93
|
+
} catch {
|
|
94
|
+
// some browsers throttle replaceState (Safari: ~100 per 30s) and THROW past the limit —
|
|
95
|
+
// the address is a convenience, never correctness, so a dropped write stays dropped
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** the ambient window's query, or null where there is no address bar (server renders) */
|
|
101
|
+
export function queryParamsOf(host?: Window & typeof globalThis): QueryParams | null {
|
|
102
|
+
const target = host ?? (typeof window === 'undefined' ? null : window);
|
|
103
|
+
return target ? new LocationQueryParams(target) : null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const QUERY_PARAMS_TOKEN = new SingleDiToken<QueryParams>('QUERY_PARAMS_TOKEN');
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The DI face: registered lazily by whoever asks first (the same pattern as the editing session),
|
|
110
|
+
* resolvable by everyone after — and overridable by a test or an environment that registers its
|
|
111
|
+
* own QueryParams before anyone ensures the ambient one. Null where there is no window.
|
|
112
|
+
*/
|
|
113
|
+
export function ensureQueryParams(di: DiContainer): QueryParams | null {
|
|
114
|
+
if (di.hasByToken(QUERY_PARAMS_TOKEN)) return di.getByToken(QUERY_PARAMS_TOKEN);
|
|
115
|
+
const params = queryParamsOf();
|
|
116
|
+
if (params) di.registerValueOfToken(QUERY_PARAMS_TOKEN, params);
|
|
117
|
+
return params;
|
|
118
|
+
}
|
package/src/naming.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Types for the naming canon (plain JS — see resolver.d.ts for why they live here).
|
|
2
|
+
export function normalizeSpecifier(specifier: string): string;
|
|
3
|
+
export function normalizeVersion(version: string): string;
|
|
4
|
+
export function dependencyFullId(specifier: string, version: string): string;
|
|
5
|
+
export function parseDependencyFullId(id: string): { normSpecifier: string; normVersion: string } | null;
|
|
6
|
+
export function isDependencyFullId(id: string): boolean;
|