@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,55 @@
|
|
|
1
|
+
import { DiService, SingleDiToken } from '@mosetta/di';
|
|
2
|
+
import type { IBlueprint } from '@mosetta/core';
|
|
3
|
+
|
|
4
|
+
// moved here from the legacy @mosetta/page package (its node-SSR remainder is deleted) —
|
|
5
|
+
// the page manager is a client-runtime concern; the react hook (usePage) lives in the binding
|
|
6
|
+
// (@mosetta/react), the manager itself is framework-agnostic.
|
|
7
|
+
//
|
|
8
|
+
// The page is MUTABLE and observable. Not for navigation — a route change means a new document and
|
|
9
|
+
// a new closure — but for LIVE COMPOSITION: the editor's preview swaps the tree under an already
|
|
10
|
+
// mounted application instead of reloading the frame around it, and everything the realm has built
|
|
11
|
+
// up (loaded modules, the server-action cache, whatever a module holds in state) survives the
|
|
12
|
+
// swap. Outside that one use the page is set at birth and never moves.
|
|
13
|
+
export abstract class PageManager {
|
|
14
|
+
abstract getCurrentPageImpl(): IBlueprint;
|
|
15
|
+
|
|
16
|
+
getCurrentPage(): IBlueprint {
|
|
17
|
+
return this.getCurrentPageImpl();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** render this tree from now on; subscribers are told */
|
|
21
|
+
abstract setCurrentPage(page: IBlueprint): void;
|
|
22
|
+
|
|
23
|
+
/** the useSyncExternalStore contract — the returned function unsubscribes */
|
|
24
|
+
abstract subscribe(listener: () => void): () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const PAGE_MANAGER_TOKEN = new SingleDiToken<PageManager>('PAGE_MANAGER_TOKEN');
|
|
28
|
+
|
|
29
|
+
@DiService(PAGE_MANAGER_TOKEN)
|
|
30
|
+
export class DefaultPageManager extends PageManager {
|
|
31
|
+
private readonly listeners = new Set<() => void>();
|
|
32
|
+
|
|
33
|
+
constructor(private page: IBlueprint) {
|
|
34
|
+
super();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
getCurrentPageImpl(): IBlueprint {
|
|
38
|
+
return this.page;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
setCurrentPage(page: IBlueprint): void {
|
|
42
|
+
if (page === this.page) return; // snapshots are compared by identity — this would be a lie
|
|
43
|
+
this.page = page;
|
|
44
|
+
for (const listener of [...this.listeners]) listener();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// an arrow property rather than a method: useSyncExternalStore resubscribes whenever the
|
|
48
|
+
// function it was handed changes identity
|
|
49
|
+
readonly subscribe = (listener: () => void): (() => void) => {
|
|
50
|
+
this.listeners.add(listener);
|
|
51
|
+
return () => {
|
|
52
|
+
this.listeners.delete(listener);
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { SingleDiToken } from '@mosetta/di';
|
|
2
|
+
import type { IRouteMatch } from '@mosetta/core';
|
|
3
|
+
|
|
4
|
+
// REFERENCES IN SETTINGS. A setting's value may be a literal or a reference to a value another
|
|
5
|
+
// block hands out: `{ "$ref": "i18n/k3" }` — provider name, slash, key. The block that hands it
|
|
6
|
+
// out is the PROVIDER: it declares a name and an adapter over its own settings in its module
|
|
7
|
+
// definition, and the same adapter answers in the page (resolution while rendering) and in the
|
|
8
|
+
// editor (a form reading and writing through the reference). This file is the framework-agnostic
|
|
9
|
+
// half: the value shape, the adapter contract and where a reference looks for its provider.
|
|
10
|
+
|
|
11
|
+
/** a setting that points at a value a provider hands out */
|
|
12
|
+
export interface IRefValue {
|
|
13
|
+
readonly $ref: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const isRefValue = (value: unknown): value is IRefValue => typeof value === 'object'
|
|
17
|
+
&& value !== null
|
|
18
|
+
&& !Array.isArray(value)
|
|
19
|
+
&& typeof (value as { $ref?: unknown }).$ref === 'string';
|
|
20
|
+
|
|
21
|
+
export const refValue = (name: string, key: string): IRefValue => ({ $ref: `${name}/${key}` });
|
|
22
|
+
|
|
23
|
+
/** `i18n/k3` → { name: 'i18n', key: 'k3' }; anything without both halves is not a reference */
|
|
24
|
+
export function parseRef(ref: string): { readonly name: string; readonly key: string } | null {
|
|
25
|
+
const at = ref.indexOf('/');
|
|
26
|
+
if (at <= 0 || at === ref.length - 1) return null;
|
|
27
|
+
return { name: ref.slice(0, at), key: ref.slice(at + 1) };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* What a provider reads a value against: the VIEW the editor chose for it (i18n: which language
|
|
32
|
+
* is being edited — null outside the editor) and the ROUTE of the request (null in the editor).
|
|
33
|
+
*/
|
|
34
|
+
export interface IRefContext {
|
|
35
|
+
readonly view: unknown;
|
|
36
|
+
readonly route: IRouteMatch | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** one value a provider offers for picking: its key, what a person recognizes it by, and the value */
|
|
40
|
+
export interface IRefEntry {
|
|
41
|
+
readonly key: string;
|
|
42
|
+
readonly label: string;
|
|
43
|
+
readonly value: unknown;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A provider's values, as pure functions over its OWN settings. Pure on purpose: the page runs
|
|
48
|
+
* them while rendering, the editor runs them on the document it holds — one code, two realms.
|
|
49
|
+
*/
|
|
50
|
+
/** where a value a provider is asked to keep comes from: a node's setting */
|
|
51
|
+
export interface IRefOrigin {
|
|
52
|
+
readonly node: string;
|
|
53
|
+
readonly setting: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface IRefAdapter<TSettings = Record<string, unknown>> {
|
|
57
|
+
read(settings: TSettings, key: string, ctx: IRefContext): unknown;
|
|
58
|
+
/** the provider's settings with `key` holding `value` */
|
|
59
|
+
write(settings: TSettings, key: string, value: unknown, ctx: IRefContext): TSettings;
|
|
60
|
+
/**
|
|
61
|
+
* A fresh key holding `value` — how a literal setting becomes a reference to a value the
|
|
62
|
+
* provider will now keep (i18n). `origin` — the node and setting the value comes from, for a
|
|
63
|
+
* key a person recognizes (`hero-title`). Absent: the provider only hands out values it has.
|
|
64
|
+
*/
|
|
65
|
+
create?(settings: TSettings, value: unknown, ctx: IRefContext, origin?: IRefOrigin): { readonly settings: TSettings; readonly key: string };
|
|
66
|
+
/** the values a person may pick from (the editor's reference picker) */
|
|
67
|
+
list?(settings: TSettings, ctx: IRefContext): readonly IRefEntry[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* What a component module declares to become a provider (`provides` in its definition). Two ways
|
|
72
|
+
* to be addressed, the provider's choice:
|
|
73
|
+
* - by ROLE (`addressedBy: 'name'`, the default): `i18n/t3` means "the nearest i18n around me" —
|
|
74
|
+
* a block copied to another page, or living in a layout or a prefab, finds that page's provider;
|
|
75
|
+
* - by INSTANCE (`addressedBy: 'id'`): `#prices/pro` means "the node `prices` of this document" —
|
|
76
|
+
* three tables of one kind on a page are three different things to point at.
|
|
77
|
+
*/
|
|
78
|
+
export interface IRefProviderDeclaration<TSettings = Record<string, unknown>> {
|
|
79
|
+
/** the role name references use (`<name>/<key>`) */
|
|
80
|
+
readonly name: string;
|
|
81
|
+
readonly addressedBy?: 'name' | 'id';
|
|
82
|
+
readonly refs: IRefAdapter<TSettings>;
|
|
83
|
+
/**
|
|
84
|
+
* The TYPE of the values it hands out (`'text'` — words a person reads: i18n). A field declared
|
|
85
|
+
* bindable with a type is offered only providers of that type, and only such fields are linked
|
|
86
|
+
* to it in bulk or automatically (bindableFields, canBind). Absent: a provider of anything.
|
|
87
|
+
*/
|
|
88
|
+
readonly type?: TBindableType;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* What a setting holds, as far as references go — the tag that matches a field to the providers
|
|
93
|
+
* that can feed it: `'text'` (words a person reads, translatable), or a kind of the provider's
|
|
94
|
+
* own naming (`'url'`, `'image'`, …).
|
|
95
|
+
*/
|
|
96
|
+
export type TBindableType = 'text' | (string & {});
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Which settings of a block may be references, as its module declares them (`bindable`): a map
|
|
100
|
+
* of setting → type, or — the first form, kept working — a list of settings of no declared type.
|
|
101
|
+
*/
|
|
102
|
+
export type TBindableDeclaration = readonly string[] | Readonly<Record<string, TBindableType>>;
|
|
103
|
+
|
|
104
|
+
/** one bindable setting and its type — null for a setting declared without one */
|
|
105
|
+
export interface IBindableField {
|
|
106
|
+
readonly field: string;
|
|
107
|
+
readonly type: TBindableType | null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** a module's bindable declaration as fields with their types */
|
|
111
|
+
export function bindableFields(declaration: TBindableDeclaration | undefined): IBindableField[] {
|
|
112
|
+
if (!declaration) return [];
|
|
113
|
+
if (Array.isArray(declaration)) return (declaration as readonly string[]).map((field) => ({ field, type: null }));
|
|
114
|
+
return Object.entries(declaration as Readonly<Record<string, TBindableType>>).map(([field, type]) => ({ field, type }));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Whether a field may be bound to a provider. `manual` — the author picking one in a field's
|
|
119
|
+
* picker: anything not known to differ (a side without a type takes any). Otherwise — linking in
|
|
120
|
+
* bulk or automatically, nobody looking at each field: both sides must SAY the same type, so a
|
|
121
|
+
* provider of words never takes an address, a colour or a count.
|
|
122
|
+
*/
|
|
123
|
+
export function canBind(fieldType: TBindableType | null, providerType: TBindableType | undefined, manual: boolean): boolean {
|
|
124
|
+
if (manual) return !fieldType || !providerType || fieldType === providerType;
|
|
125
|
+
return fieldType !== null && providerType !== undefined && fieldType === providerType;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** the part of a tree node the lookup reads */
|
|
129
|
+
export interface IRefTreeNode {
|
|
130
|
+
readonly id: string;
|
|
131
|
+
readonly reference: string;
|
|
132
|
+
readonly settings?: Record<string, unknown>;
|
|
133
|
+
readonly children?: readonly IRefTreeNode[];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The document a node belongs to. A layout's blocks are expanded into the page with their ids
|
|
138
|
+
* prefixed by the layout node's id (`n1/hdr`, see the content package's unit expansion), a page's
|
|
139
|
+
* own blocks keep bare ids. References never cross documents: an article's words are stored in
|
|
140
|
+
* the article, a layout's in the layout.
|
|
141
|
+
*/
|
|
142
|
+
export const documentOf = (id: string): string => {
|
|
143
|
+
const at = id.indexOf('/');
|
|
144
|
+
return at > 0 ? id.slice(0, at) : '';
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/** an instance address: `#<node id>` */
|
|
148
|
+
export const INSTANCE_REF_PREFIX = '#';
|
|
149
|
+
|
|
150
|
+
/** how a reference names this provider node: its role, or `#<id>` (the id inside its own document) */
|
|
151
|
+
export function providerAddressOf(declaration: IRefProviderDeclaration, nodeId: string): string {
|
|
152
|
+
if (declaration.addressedBy !== 'id') return declaration.name;
|
|
153
|
+
const doc = documentOf(nodeId);
|
|
154
|
+
return `${INSTANCE_REF_PREFIX}${doc ? nodeId.slice(doc.length + 1) : nodeId}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const parentMaps = new WeakMap<IRefTreeNode, Map<string, IRefTreeNode>>();
|
|
158
|
+
|
|
159
|
+
function parentsOf(root: IRefTreeNode): Map<string, IRefTreeNode> {
|
|
160
|
+
let parents = parentMaps.get(root);
|
|
161
|
+
if (!parents) {
|
|
162
|
+
parents = new Map();
|
|
163
|
+
const walk = (node: IRefTreeNode) => {
|
|
164
|
+
for (const child of node.children ?? []) {
|
|
165
|
+
parents!.set(child.id, node);
|
|
166
|
+
walk(child);
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
walk(root);
|
|
170
|
+
parentMaps.set(root, parents);
|
|
171
|
+
}
|
|
172
|
+
return parents;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** every node of the tree, depth first */
|
|
176
|
+
export function* treeNodes(root: IRefTreeNode): Generator<IRefTreeNode> {
|
|
177
|
+
yield root;
|
|
178
|
+
for (const child of root.children ?? []) yield* treeNodes(child);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function findNode(root: IRefTreeNode, id: string): IRefTreeNode | null {
|
|
182
|
+
if (root.id === id) return root;
|
|
183
|
+
const parent = parentsOf(root).get(id);
|
|
184
|
+
return parent?.children?.find((child) => child.id === id) ?? null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface IFoundProvider {
|
|
188
|
+
readonly node: IRefTreeNode;
|
|
189
|
+
readonly provides: IRefProviderDeclaration;
|
|
190
|
+
/** how a reference addresses it: the role, or `#<id>` */
|
|
191
|
+
readonly name: string;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Every provider `consumerId` can reach, nearest first, one per name. LEXICAL, by the document:
|
|
196
|
+
* a provider serves its parent's subtree (its siblings and everything under them) and its own;
|
|
197
|
+
* walking up from the consumer, the nearest level that holds a name wins it, children before
|
|
198
|
+
* the level's own node. Only providers of the consumer's own document count. A pure function of
|
|
199
|
+
* the tree — the same answer on the server, in the browser and in the editor, whatever order
|
|
200
|
+
* things render in.
|
|
201
|
+
*/
|
|
202
|
+
export function reachableRefProviders(
|
|
203
|
+
root: IRefTreeNode,
|
|
204
|
+
consumerId: string,
|
|
205
|
+
declarationOf: (reference: string) => IRefProviderDeclaration | null | undefined,
|
|
206
|
+
only?: string,
|
|
207
|
+
): IFoundProvider[] {
|
|
208
|
+
const doc = documentOf(consumerId);
|
|
209
|
+
const parents = parentsOf(root);
|
|
210
|
+
const found: IFoundProvider[] = [];
|
|
211
|
+
const names = new Set<string>();
|
|
212
|
+
const consider = (node: IRefTreeNode): boolean => {
|
|
213
|
+
if (documentOf(node.id) !== doc) return false;
|
|
214
|
+
const declaration = declarationOf(node.reference);
|
|
215
|
+
// instance providers are addressed by id wherever they sit — not by the nearest-role walk
|
|
216
|
+
if (!declaration || declaration.addressedBy === 'id') return false;
|
|
217
|
+
const name = declaration.name;
|
|
218
|
+
if (names.has(name) || (only !== undefined && name !== only)) return false;
|
|
219
|
+
names.add(name);
|
|
220
|
+
found.push({ node, provides: declaration, name });
|
|
221
|
+
return only !== undefined;
|
|
222
|
+
};
|
|
223
|
+
let level: IRefTreeNode | null | undefined = findNode(root, consumerId);
|
|
224
|
+
let first = true;
|
|
225
|
+
while (level) {
|
|
226
|
+
for (const child of level.children ?? []) {
|
|
227
|
+
if (child.id !== consumerId && consider(child)) return found;
|
|
228
|
+
}
|
|
229
|
+
if (!first && consider(level)) return found;
|
|
230
|
+
first = false;
|
|
231
|
+
level = parents.get(level.id);
|
|
232
|
+
}
|
|
233
|
+
return found;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The provider a reference named `name` in `consumerId`'s settings resolves against: the nearest
|
|
238
|
+
* one playing that role, or — for `#<id>` — that node of the consumer's own document, wherever it
|
|
239
|
+
* sits, as long as it provides by instance.
|
|
240
|
+
*/
|
|
241
|
+
export function findRefProvider(
|
|
242
|
+
root: IRefTreeNode,
|
|
243
|
+
consumerId: string,
|
|
244
|
+
name: string,
|
|
245
|
+
declarationOf: (reference: string) => IRefProviderDeclaration | null | undefined,
|
|
246
|
+
): IFoundProvider | null {
|
|
247
|
+
if (name.startsWith(INSTANCE_REF_PREFIX)) {
|
|
248
|
+
const doc = documentOf(consumerId);
|
|
249
|
+
const id = name.slice(INSTANCE_REF_PREFIX.length);
|
|
250
|
+
const node = findNode(root, doc ? `${doc}/${id}` : id);
|
|
251
|
+
const declaration = node ? declarationOf(node.reference) : null;
|
|
252
|
+
return node && declaration?.addressedBy === 'id' ? { node, provides: declaration, name } : null;
|
|
253
|
+
}
|
|
254
|
+
return reachableRefProviders(root, consumerId, declarationOf, name)[0] ?? null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** every instance-addressed provider of `consumerId`'s document — what a picker offers beside the reachable roles */
|
|
258
|
+
export function instanceRefProviders(
|
|
259
|
+
root: IRefTreeNode,
|
|
260
|
+
consumerId: string,
|
|
261
|
+
declarationOf: (reference: string) => IRefProviderDeclaration | null | undefined,
|
|
262
|
+
): IFoundProvider[] {
|
|
263
|
+
const doc = documentOf(consumerId);
|
|
264
|
+
const out: IFoundProvider[] = [];
|
|
265
|
+
for (const node of treeNodes(root)) {
|
|
266
|
+
if (node.id === consumerId || documentOf(node.id) !== doc) continue;
|
|
267
|
+
const declaration = declarationOf(node.reference);
|
|
268
|
+
if (declaration?.addressedBy === 'id') out.push({ node, provides: declaration, name: providerAddressOf(declaration, node.id) });
|
|
269
|
+
}
|
|
270
|
+
return out;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** every reference in a settings object resolved (top-level values only); the same object when none */
|
|
274
|
+
export function resolveRefs(
|
|
275
|
+
settings: Record<string, unknown>,
|
|
276
|
+
resolve: (ref: IRefValue) => unknown,
|
|
277
|
+
): Record<string, unknown> {
|
|
278
|
+
let out: Record<string, unknown> | null = null;
|
|
279
|
+
for (const [field, value] of Object.entries(settings)) {
|
|
280
|
+
if (!isRefValue(value)) continue;
|
|
281
|
+
out ??= { ...settings };
|
|
282
|
+
out[field] = resolve(value);
|
|
283
|
+
}
|
|
284
|
+
return out ?? settings;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The views the editor chose for providers, by provider name (i18n → { lang: 'ru' }). Every
|
|
289
|
+
* application gets an empty store at birth; in a preview the editor's bridge fills it, on a live
|
|
290
|
+
* page it stays empty — every view is null there.
|
|
291
|
+
*/
|
|
292
|
+
export interface IRefViews {
|
|
293
|
+
snapshot(): Readonly<Record<string, unknown>>;
|
|
294
|
+
subscribe(listener: () => void): () => void;
|
|
295
|
+
set(views: Readonly<Record<string, unknown>>): void;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export const REF_VIEWS_TOKEN = new SingleDiToken<IRefViews>('REF_VIEWS_TOKEN');
|
|
299
|
+
|
|
300
|
+
export class MemoryRefViews implements IRefViews {
|
|
301
|
+
private views: Readonly<Record<string, unknown>> = Object.freeze({});
|
|
302
|
+
|
|
303
|
+
private readonly listeners = new Set<() => void>();
|
|
304
|
+
|
|
305
|
+
readonly snapshot = (): Readonly<Record<string, unknown>> => this.views;
|
|
306
|
+
|
|
307
|
+
readonly subscribe = (listener: () => void): (() => void) => {
|
|
308
|
+
this.listeners.add(listener);
|
|
309
|
+
return () => {
|
|
310
|
+
this.listeners.delete(listener);
|
|
311
|
+
};
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
set(views: Readonly<Record<string, unknown>>): void {
|
|
315
|
+
if (views === this.views) return;
|
|
316
|
+
this.views = views;
|
|
317
|
+
for (const listener of [...this.listeners]) listener();
|
|
318
|
+
}
|
|
319
|
+
}
|