@mosetta/content 0.0.0-stage → 0.0.10

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/src/units.ts ADDED
@@ -0,0 +1,396 @@
1
+ // A LIVE PREFAB is a tree embedded in a page by reference and always taken at its CURRENT
2
+ // published form. The reference names its AUTHOR, like a module's does, and no version:
3
+ //
4
+ // _ns_<namespace>_t_prefab_n_<name>_v_live
5
+ //
6
+ // A prefab of the project itself (its namespace — a header, a styled button, the blog's frame)
7
+ // comes from the project's content; a prefab of a library in the project's code distribution (the
8
+ // editor's workbench — `mosetta-editor`) comes from the distribution. Which is which is the
9
+ // caller's business (it knows the project's namespace); this module only expands.
10
+ //
11
+ // Expansion happens on the server before a render (the live route and the draft preview alike):
12
+ // the node is replaced by the prefab's blocks, and the node's own children — an article — land
13
+ // inside the prefab's OUTLET block (a frame is a prefab with an outlet). Ids of the prefab's blocks
14
+ // are prefixed with the node's id (`<node>/<inner>`), which is how the editor tells a prefab's
15
+ // blocks from the page's own; a setting that names a sibling block by id names it as authored (see
16
+ // localIdOf). The prefab's structure and styles are its own; an instance may set only the OPEN
17
+ // FIELDS the prefab declares — a button's text and address — kept in the node's `fields`. One
18
+ // level only: a prefab inside a prefab is left as it is. Pure data in, pure data out.
19
+ import type { IBlueprintNodeDoc, IPageDoc, IPrefabDoc, IPrefabField } from './model';
20
+
21
+ export type { IBlueprintNodeDoc, IPageDoc, IPrefabDoc, IPrefabField } from './model';
22
+
23
+ export const PREFAB_REFERENCE_RE = /^_ns_([a-z0-9][a-z0-9-]*)_t_prefab_n_([a-z][A-Za-z0-9]*)_v_live$/;
24
+ /** kept for the callers that say "unit": a live prefab is the only kind */
25
+ export const UNIT_REFERENCE_RE = PREFAB_REFERENCE_RE;
26
+
27
+ export interface IUnitRef {
28
+ /** the author: the project's namespace, or a library's */
29
+ readonly namespace: string;
30
+ readonly name: string;
31
+ }
32
+
33
+ /** the reference a page tree uses to embed a prefab live */
34
+ export const prefabReference = (namespace: string, name: string): string => `_ns_${namespace}_t_prefab_n_${name}_v_live`;
35
+ export const unitReference = prefabReference;
36
+
37
+ export const unitOf = (reference: string): IUnitRef | null => {
38
+ const match = PREFAB_REFERENCE_RE.exec(reference);
39
+ return match ? { namespace: match[1], name: match[2] } : null;
40
+ };
41
+
42
+ /** the prefab's name — null for a module reference */
43
+ export const unitNameOf = (reference: string): string | null => unitOf(reference)?.name ?? null;
44
+
45
+ /** where a distribution keeps a library's prefab in its public code store (what the build writes) */
46
+ export const prefabPath = (namespace: string, name: string): string => `prefabs/${namespace}/${name}.json`;
47
+
48
+ /** the outlet block: whatever module is named `outlet` (the ui-kit ships one) — its children are the page */
49
+ export const isOutletReference = (reference: string): boolean => /_t_component_n_outlet_v_/.test(reference);
50
+
51
+ /** the separator between a prefab node's id and the ids of the blocks it expands into */
52
+ export const UNIT_ID_SEPARATOR = '/';
53
+
54
+ /** the prefab node an expanded block belongs to, or null for a page's own block */
55
+ export const unitOwnerOf = (id: string): string | null => {
56
+ const at = id.indexOf(UNIT_ID_SEPARATOR);
57
+ return at > 0 ? id.slice(0, at) : null;
58
+ };
59
+
60
+ /**
61
+ * A block's id as its document authored it — the expansion prefix dropped. A setting that names a
62
+ * sibling block (a dock's panel naming its child) was written against these ids, so a block
63
+ * resolving such a setting compares local ids: it works the same in a page and inside a prefab.
64
+ */
65
+ export const localIdOf = (id: string): string => id.slice(id.lastIndexOf(UNIT_ID_SEPARATOR) + 1);
66
+
67
+ /** an instance's values of the open fields: block id → setting → value */
68
+ export type TFieldValues = Readonly<Record<string, Readonly<Record<string, unknown>>>>;
69
+
70
+ /** the settings key an instance keeps its open fields' values under */
71
+ export const FIELDS_SETTING = 'fields';
72
+
73
+ export interface IExpandedUnit extends IUnitRef {
74
+ readonly rev: number;
75
+ }
76
+
77
+ export interface IUnitSource {
78
+ readonly root: IBlueprintNodeDoc;
79
+ /** the prefab's revision (a content prefab) or content digest (a distribution's) — keys the render cache */
80
+ readonly rev: number;
81
+ readonly fields?: readonly IPrefabField[];
82
+ }
83
+
84
+ export type TUnitLoader = (unit: IUnitRef) => Promise<IUnitSource | null>;
85
+
86
+ const fieldValuesOf = (node: IBlueprintNodeDoc): TFieldValues => {
87
+ const raw: unknown = node.settings?.[FIELDS_SETTING];
88
+ return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw as TFieldValues : {};
89
+ };
90
+
91
+ /** the prefab's blocks, ids prefixed, the instance's values in the open fields — no other setting */
92
+ function instantiate(node: IBlueprintNodeDoc, prefix: string, open: ReadonlyMap<string, readonly string[]>, values: TFieldValues): IBlueprintNodeDoc {
93
+ const settable = open.get(node.id) ?? [];
94
+ const own = values[node.id] ?? {};
95
+ const given = settable.filter((s) => own[s] !== undefined);
96
+ const settings = given.length ? { ...(node.settings ?? {}), ...Object.fromEntries(given.map((s) => [s, own[s]])) } : node.settings;
97
+ return {
98
+ ...node,
99
+ ...(settings ? { settings } : {}),
100
+ id: `${prefix}${UNIT_ID_SEPARATOR}${node.id}`,
101
+ children: (node.children ?? []).map((c) => instantiate(c, prefix, open, values)),
102
+ };
103
+ }
104
+
105
+ const openFields = (fields: readonly IPrefabField[] | undefined): Map<string, string[]> => {
106
+ const open = new Map<string, string[]>();
107
+ for (const f of fields ?? []) open.set(f.node, [...(open.get(f.node) ?? []), f.setting]);
108
+ return open;
109
+ };
110
+
111
+ /** the outlet's children become the page's blocks; the first outlet in document order wins */
112
+ export function fillOutlet(nodes: IBlueprintNodeDoc[], body: IBlueprintNodeDoc[]): { nodes: IBlueprintNodeDoc[]; filled: boolean } {
113
+ let filled = false;
114
+ const walk = (node: IBlueprintNodeDoc): IBlueprintNodeDoc => {
115
+ if (filled) return node;
116
+ if (isOutletReference(node.reference)) {
117
+ filled = true;
118
+ return { ...node, children: body };
119
+ }
120
+ return { ...node, children: (node.children ?? []).map(walk) };
121
+ };
122
+ return { nodes: nodes.map(walk), filled };
123
+ }
124
+
125
+ /** the node's slot travels to what replaces it: a frame in a `footer` slot stays in the footer */
126
+ function withSlot(nodes: IBlueprintNodeDoc[], slot: string | undefined): IBlueprintNodeDoc[] {
127
+ return nodes.map((node) => {
128
+ const { slot: own, ...rest } = node;
129
+ void own;
130
+ return slot === undefined ? rest : { ...rest, slot };
131
+ });
132
+ }
133
+
134
+ /** blocks as rendered hold the PREVIOUS body inside their outlet — emptied before refilling */
135
+ function stripBody(node: IBlueprintNodeDoc): IBlueprintNodeDoc {
136
+ if (isOutletReference(node.reference)) return { ...node, children: [] };
137
+ return { ...node, children: (node.children ?? []).map(stripBody) };
138
+ }
139
+
140
+ /** the instance's current field values put onto blocks already expanded (ids carry the prefix) */
141
+ function refill(node: IBlueprintNodeDoc, values: TFieldValues): IBlueprintNodeDoc {
142
+ const own = values[localIdOf(node.id)];
143
+ const settings = own ? { ...(node.settings ?? {}), ...own } : node.settings;
144
+ return { ...node, ...(settings ? { settings } : {}), children: (node.children ?? []).map((c) => refill(c, values)) };
145
+ }
146
+
147
+ /**
148
+ * A prefab node expanded from blocks ALREADY RENDERED: the editor's live tree updates carry the
149
+ * raw document, and the preview realm re-expands it from what it shows right now — no server
150
+ * round trip while the author types (an instance's field included). Unknown blocks (a prefab
151
+ * inserted a moment ago) stay a raw node until the next server render.
152
+ */
153
+ export function expandUnitsFrom(current: IBlueprintNodeDoc, next: IBlueprintNodeDoc): IBlueprintNodeDoc {
154
+ const chrome = new Map<string, IBlueprintNodeDoc[]>();
155
+ // a prefab's top blocks are kept under its node's id; the descent goes on below them — another
156
+ // prefab's blocks may sit in its outlet (a page's frame inside the site's theme)
157
+ const collect = (nodes: readonly IBlueprintNodeDoc[], within: string | null) => {
158
+ for (const node of nodes) {
159
+ const owner = unitOwnerOf(node.id);
160
+ if (owner !== null && owner !== within) {
161
+ chrome.set(owner, [...(chrome.get(owner) ?? []), node]);
162
+ collect(node.children ?? [], owner);
163
+ continue;
164
+ }
165
+ collect(node.children ?? [], within);
166
+ }
167
+ };
168
+ collect(current.children ?? [], null);
169
+ const expand = (nodes: readonly IBlueprintNodeDoc[]): IBlueprintNodeDoc[] => nodes.flatMap((node) => {
170
+ const body = expand(node.children ?? []);
171
+ if (unitOf(node.reference) === null) return [{ ...node, children: body }];
172
+ const known = chrome.get(node.id);
173
+ if (!known) return [{ ...node, children: body }];
174
+ // the outlet is refilled with the body as it is now; the fields with the instance's values
175
+ const values = fieldValuesOf(node);
176
+ const { nodes: filled, filled: ok } = fillOutlet(known.map((n) => refill(stripBody(n), values)), body);
177
+ return withSlot(ok ? filled : [...filled, ...body], node.slot);
178
+ });
179
+ return { ...next, children: expand(next.children ?? []) };
180
+ }
181
+
182
+ const cacheName = (u: IUnitRef): string => `${u.namespace}.${u.name}`;
183
+
184
+ /**
185
+ * Every prefab node in the page replaced by its prefab's blocks, the node's children inside the
186
+ * outlet, the instance's open fields filled. A prefab that cannot be loaded (unpublished,
187
+ * deleted) leaves the node's children in place — the article still renders, only the frame is
188
+ * missing. Returns the expanded document and the prefabs it used with their revisions: they
189
+ * belong in the render's cache key.
190
+ */
191
+ export async function expandUnits(doc: IBlueprintNodeDoc, load: TUnitLoader): Promise<{ doc: IBlueprintNodeDoc; units: IExpandedUnit[] }> {
192
+ const units: IExpandedUnit[] = [];
193
+ const loaded = new Map<string, Promise<IUnitSource | null>>();
194
+ const source = (unit: IUnitRef) => {
195
+ const key = `${unit.namespace}:${unit.name}`;
196
+ let pending = loaded.get(key);
197
+ if (!pending) {
198
+ pending = load(unit);
199
+ loaded.set(key, pending);
200
+ }
201
+ return pending;
202
+ };
203
+ // every prefab the tree places is asked for at once — a prefab inside another one's place would
204
+ // otherwise wait for the outer one's read
205
+ const prefetch = (node: IBlueprintNodeDoc) => {
206
+ const ref = unitOf(node.reference);
207
+ if (ref) void source(ref).catch(() => null);
208
+ for (const child of node.children ?? []) prefetch(child);
209
+ };
210
+ prefetch(doc);
211
+ const expandChildren = async (children: readonly IBlueprintNodeDoc[]): Promise<IBlueprintNodeDoc[]> => {
212
+ const out: IBlueprintNodeDoc[] = [];
213
+ for (const child of children) {
214
+ const ref = unitOf(child.reference);
215
+ if (ref === null) {
216
+ out.push({ ...child, children: await expandChildren(child.children ?? []) });
217
+ continue;
218
+ }
219
+ const body = await expandChildren(child.children ?? []);
220
+ const unit = await source(ref);
221
+ if (!unit) {
222
+ out.push(...withSlot(body, child.slot));
223
+ continue;
224
+ }
225
+ if (!units.some((u) => u.namespace === ref.namespace && u.name === ref.name)) units.push({ ...ref, rev: unit.rev });
226
+ const open = openFields(unit.fields);
227
+ const values = fieldValuesOf(child);
228
+ const chrome = (unit.root.children ?? []).map((n) => instantiate(n, child.id, open, values));
229
+ const { nodes, filled } = fillOutlet(chrome, body);
230
+ out.push(...withSlot(filled ? nodes : [...nodes, ...body], child.slot));
231
+ }
232
+ return out;
233
+ };
234
+ return { doc: { ...doc, children: await expandChildren(doc.children ?? []) }, units: units.sort((a, b) => cacheName(a).localeCompare(cacheName(b))) };
235
+ }
236
+
237
+ /** the cache-key suffix naming the prefabs a render depends on: `+acme.header@3+mosetta-editor.workbench@2711` */
238
+ export const unitsCacheSuffix = (units: readonly IExpandedUnit[]): string => units.map((u) => `+${cacheName(u)}@${u.rev}`).join('');
239
+
240
+ /** a small, stable number for a text — a distribution's prefab has no revision counter, its digest is its revision */
241
+ export function textDigest(text: string): number {
242
+ let hash = 0x811c9dc5;
243
+ for (let i = 0; i < text.length; i++) {
244
+ hash ^= text.charCodeAt(i);
245
+ hash = Math.imul(hash, 0x01000193);
246
+ }
247
+ return hash >>> 0;
248
+ }
249
+
250
+ /**
251
+ * The loader of a distribution's prefabs: `<publicBase>prefabs/<namespace>/<name>.json`,
252
+ * revalidated by ETag on every call (the deploy uploads prefabs uncached, with the catalog), its
253
+ * revision the digest of what was read. Unreachable or absent → null, and the node renders
254
+ * without it.
255
+ */
256
+ export function prefabLoader(publicBase: string | ((namespace: string) => string), fetchImpl: typeof fetch = fetch): (unit: IUnitRef) => Promise<IUnitSource | null> {
257
+ // a library's prefabs lie with its code: one base, or a base per namespace (its code source)
258
+ const baseOf = typeof publicBase === 'function' ? publicBase : () => publicBase;
259
+ const known = new Map<string, { etag: string | null; unit: IUnitSource }>();
260
+ return async (ref) => {
261
+ const url = `${baseOf(ref.namespace)}${prefabPath(ref.namespace, ref.name)}`;
262
+ const cached = known.get(url);
263
+ let res: Response;
264
+ try {
265
+ res = await fetchImpl(url, { headers: cached?.etag ? { 'If-None-Match': cached.etag } : {} });
266
+ } catch {
267
+ return cached?.unit ?? null;
268
+ }
269
+ if (cached && res.status === 304) return cached.unit;
270
+ if (!res.ok) return null;
271
+ const text = await res.text();
272
+ let parsed: { root?: unknown; fields?: unknown };
273
+ try {
274
+ parsed = JSON.parse(text) as { root?: unknown; fields?: unknown };
275
+ } catch {
276
+ return null;
277
+ }
278
+ if (!parsed.root || typeof parsed.root !== 'object') return null;
279
+ const unit: IUnitSource = {
280
+ root: parsed.root as IBlueprintNodeDoc,
281
+ rev: textDigest(text),
282
+ ...(Array.isArray(parsed.fields) ? { fields: parsed.fields as IPrefabField[] } : {}),
283
+ };
284
+ known.set(url, { etag: res.headers.get('ETag'), unit });
285
+ return unit;
286
+ };
287
+ }
288
+
289
+ /** the project's own namespace, as its content config names it (`config.json`: `namespace`) — or null */
290
+ export function projectNamespaceOf(config: unknown): string | null {
291
+ const ns = (config as { namespace?: unknown } | null)?.namespace;
292
+ return typeof ns === 'string' && /^[a-z0-9][a-z0-9-]*$/.test(ns) ? ns : null;
293
+ }
294
+
295
+ /**
296
+ * The loader of the project's own prefabs, over a content store's read: the prefab's live
297
+ * revision (`prefabs/<name>/prefab.json` → `versions/<live>.json`). A prefab document is a tree like
298
+ * a page's — its root's children are what an instance expands into — with its open fields beside.
299
+ */
300
+ export function contentPrefabLoader(get: (key: string) => Promise<Response>): (name: string) => Promise<IUnitSource | null> {
301
+ return async (name) => {
302
+ const metaRes = await get(`prefabs/${name}/prefab.json`);
303
+ if (!metaRes.ok) return null;
304
+ const meta = (await metaRes.json()) as { live?: unknown };
305
+ if (typeof meta.live !== 'number') return null;
306
+ const docRes = await get(`prefabs/${name}/versions/${meta.live}.json`);
307
+ if (!docRes.ok) return null;
308
+ const doc = (await docRes.json()) as IBlueprintNodeDoc & { root?: IBlueprintNodeDoc; fields?: unknown };
309
+ return {
310
+ root: doc.root ?? doc,
311
+ rev: meta.live,
312
+ ...(Array.isArray(doc.fields) ? { fields: doc.fields as IPrefabField[] } : {}),
313
+ };
314
+ };
315
+ }
316
+
317
+ // ---- a prefab in the editor ----
318
+ //
319
+ // The editor edits every tree in a PAGE's shape — one session, one canvas, one tree panel for
320
+ // both. A prefab opens as a page whose root is the prefab's root, its open fields and its draft's
321
+ // working fields along; the pattern is the site's root, where the canvas previews it. What is
322
+ // stored stays a prefab: the shape is converted back on every write.
323
+
324
+ /** the path the canvas previews a prefab under */
325
+ export const PREFAB_CANVAS_PATTERN = '/';
326
+
327
+ /** a prefab (a draft or a revision) as the editor edits it */
328
+ export function prefabAsPage(prefab: IPrefabDoc): IPageDoc {
329
+ const { root, fields, devOnly, history, redo, seq, createdAt, baseRev, title } = prefab;
330
+ return {
331
+ ...root,
332
+ pattern: PREFAB_CANVAS_PATTERN,
333
+ ...(fields ? { fields } : {}),
334
+ ...(devOnly ? { devOnly } : {}),
335
+ ...(seq !== undefined ? { seq } : {}),
336
+ ...(createdAt !== undefined ? { createdAt } : {}),
337
+ ...(baseRev !== undefined ? { baseRev } : {}),
338
+ ...(title !== undefined ? { title } : {}),
339
+ ...(history ? { history } : {}),
340
+ ...(redo ? { redo } : {}),
341
+ };
342
+ }
343
+
344
+ /** what the editor wrote, stored as the prefab it is: the page's own things (route, meta) dropped */
345
+ export function pageAsPrefab(page: IPageDoc): IPrefabDoc {
346
+ const { id, reference, children, slot, settings, ssr, fields, devOnly, history, redo, seq, createdAt, baseRev, title } = page;
347
+ const root: IBlueprintNodeDoc = {
348
+ id,
349
+ reference,
350
+ children,
351
+ ...(slot !== undefined ? { slot } : {}),
352
+ ...(settings !== undefined ? { settings } : {}),
353
+ ...(ssr !== undefined ? { ssr } : {}),
354
+ };
355
+ return {
356
+ root,
357
+ ...(fields ? { fields } : {}),
358
+ ...(devOnly ? { devOnly } : {}),
359
+ ...(seq !== undefined ? { seq } : {}),
360
+ ...(createdAt !== undefined ? { createdAt } : {}),
361
+ ...(baseRev !== undefined ? { baseRev } : {}),
362
+ ...(title !== undefined ? { title } : {}),
363
+ ...(history ? { history } : {}),
364
+ ...(redo ? { redo } : {}),
365
+ };
366
+ }
367
+
368
+ /** the namespace a module or prefab reference is authored under (`_ns_<namespace>_t_…`), or null */
369
+ export const namespaceOfReference = (reference: string): string | null => /^_ns_(.+?)_t_/.exec(reference)?.[1] ?? null;
370
+
371
+ /**
372
+ * A document without the blocks of DISCONNECTED distributions (ADR-0059): every node whose module
373
+ * (or prefab) is authored under one of `namespaces` leaves, with what it holds. The site renders
374
+ * what is left, on the server and in the browser alike — the browser gets this very document, so
375
+ * it never asks for a disconnected block. Untouched branches keep their identity.
376
+ *
377
+ * With `standIn`, such a node is not dropped but REPLACED by what it returns — the editor's preview
378
+ * shows a placeholder where the block stood, so the author sees it and can take it out.
379
+ */
380
+ export function withoutDistributions<T extends IBlueprintNodeDoc>(
381
+ doc: T,
382
+ namespaces: readonly string[],
383
+ standIn?: (node: IBlueprintNodeDoc) => IBlueprintNodeDoc,
384
+ ): T {
385
+ if (!namespaces.length) return doc;
386
+ const off = new Set(namespaces);
387
+ const isOff = (node: IBlueprintNodeDoc) => off.has(namespaceOfReference(node.reference) ?? '');
388
+ const prune = <N extends IBlueprintNodeDoc>(node: N): N => {
389
+ const children = node.children ?? [];
390
+ const kept = standIn
391
+ ? children.map((child) => (isOff(child) ? standIn(child) : prune(child)))
392
+ : children.filter((child) => !isOff(child)).map(prune);
393
+ return kept.length === children.length && kept.every((child, i) => child === children[i]) ? node : { ...node, children: kept };
394
+ };
395
+ return prune(doc);
396
+ }
@@ -0,0 +1,40 @@
1
+ // VERSIONS of distributions (ADR-0061). A module's full id carries its distribution's version in
2
+ // the encoded form (`…_v_1_2_0`), the catalog names it dotted (`1.2.0`); a deployed version never
3
+ // changes, and versions compare as the numbers they are — `0.0.10` is after `0.0.9`.
4
+
5
+ /** the version a reference names, dotted (`1.2.0`), or null for one that names none (`_v_live`) */
6
+ export function versionOfReference(reference: string): string | null {
7
+ const encoded = /_v_(\d+(?:_\d+)*)$/.exec(reference)?.[1];
8
+ return encoded ? encoded.replace(/_/g, '.') : null;
9
+ }
10
+
11
+ /** a version as a full id spells it */
12
+ export const encodeVersion = (version: string): string => version.trim().replace(/[^a-zA-Z0-9]/g, '_');
13
+
14
+ /** the same module at another version */
15
+ export function withVersion(reference: string, version: string): string {
16
+ return reference.replace(/_v_[A-Za-z0-9_]+$/, `_v_${encodeVersion(version)}`);
17
+ }
18
+
19
+ /** the numbers of a version, in either spelling (`1.2.0`, `1_2_0`) */
20
+ const partsOf = (version: string): number[] => version.split(/[._]/).map((part) => Number.parseInt(part, 10) || 0);
21
+
22
+ /** < 0 when `a` comes before `b`, > 0 after, 0 for the same version — in either spelling */
23
+ export function compareVersions(a: string, b: string): number {
24
+ const left = partsOf(a);
25
+ const right = partsOf(b);
26
+ for (let i = 0; i < Math.max(left.length, right.length); i++) {
27
+ const delta = (left[i] ?? 0) - (right[i] ?? 0);
28
+ if (delta) return delta;
29
+ }
30
+ return 0;
31
+ }
32
+
33
+ /** the latest of some versions, or undefined for none */
34
+ export function latestVersion(versions: Iterable<string>): string | undefined {
35
+ let latest: string | undefined;
36
+ for (const version of versions) {
37
+ if (latest === undefined || compareVersions(version, latest) > 0) latest = version;
38
+ }
39
+ return latest;
40
+ }