@mosetta/react 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/src/react.ts ADDED
@@ -0,0 +1,34 @@
1
+ // @mosetta/react — the whole react binding in ONE entry. The former /context, /hooks, /tree
2
+ // and /di subpaths were one organism pretending to be four: everything depends on the same two
3
+ // context instances, a version mismatch kills all of them at once, and every application uses
4
+ // them together. One entry → ONE dependency artifact → the singleton discipline holds by
5
+ // construction. The subpaths that remain (`/start`, `/ssr`, `/server-action`) stay out for
6
+ // closure reasons — react-dom and the action client must not ride into every block — and they
7
+ // link THIS artifact externally.
8
+ export * from './utils/definers';
9
+ export * from './hooks/di';
10
+ export * from './utils/ProvidedContext';
11
+ export * from './hooks/NodeContext';
12
+ export * from './utils/tileReference';
13
+ export * from './utils/previewSlots';
14
+ export * from './hooks/clientState';
15
+ export * from './hooks/usePage';
16
+ export * from './hooks/useRoute';
17
+ export * from './hooks/usePreviewType';
18
+ export * from './hooks/useSettingsDraft';
19
+ export * from './hooks/useEditorContracts';
20
+ export * from './hooks/useMediaUpload';
21
+ export * from './hooks/useTile';
22
+ export * from './hooks/useAddToHead';
23
+ export * from './hooks/useShippedSettings';
24
+ export * from './hooks/useStyler';
25
+ export * from './hooks/useTheme';
26
+ export * from './hooks/useHref';
27
+ export * from './hooks/useRouter';
28
+ export * from './hooks/useResolvedSettings';
29
+ export * from './hooks/useLocalStorageKey';
30
+ export { BlueprintRoot, BlueprintNodeRenderer, NODE_ID_ATTRIBUTE } from './components/BlueprintNodeRenderer';
31
+ export { DefaultRootComponent } from './components/DefaultRootComponent';
32
+ export { Link } from './components/Link';
33
+ export type { ILinkProps } from './components/Link';
34
+ export { ComponentErrorHandler } from './components/ComponentErrorHandler';
@@ -0,0 +1,163 @@
1
+ // useServerAction v2. ONE hook, the `start` axis:
2
+ // START_FROM_CLIENT (default) — query: auto-starts, cached by <full-id>/<action>#<stable-input>
3
+ // START_MANUAL — mutation: no cache, input arrives at data.start(input)
4
+ // START_FROM_SERVER — the SSR axis; in the browser behaves as a query (a hydration
5
+ // seed from the head cache script covers the precompute)
6
+ //
7
+ // SERVER EXECUTION: when the render carries the ssr channel (→ SsrActionRecorder
8
+ // in DI), START_FROM_SERVER queries run for real through the shared manager — the hook throws
9
+ // the waiter UNCONDITIONALLY (the server render must wait; Suspense-throw is the legacy protocol
10
+ // both runtimes support) and drives the head channel's counter by its rules: begin on the render
11
+ // that ensured the query, complete on the RETRY render that consumed the settled data.
12
+ // The start axis decides WHO pays for the data: START_FROM_CLIENT ships a Loading stub from the
13
+ // server (the client mirrors it and starts the fetch after hydration — parity by stub), manual
14
+ // ships Idle. Channel-less server renders keep the v1 per-hook stubs for everything.
15
+ //
16
+ // DUAL-RUNTIME CONTRACT: this file must run under React (browser) AND preact/compat (the future SSR
17
+ // renderer in the worker — the default React server renderer would tank the isolate's RPS). Only
18
+ // cross-safe primitives: useSyncExternalStore + basic hooks. NO `use()` (absent in preact), no
19
+ // conditional hooks (branches are value-level), no module-level shared state. Opt-in suspense throws
20
+ // the waiter promise — the legacy protocol both runtimes (and preact's renderToStringAsync) support.
21
+ import { useCallback, useState, useSyncExternalStore } from 'react';
22
+ import { useComponent } from '@mosetta/react';
23
+ import { useDi } from '@mosetta/react';
24
+ import {
25
+ EServerActionStart,
26
+ EXECUTION_ENV_TOKEN,
27
+ fullServerTileId,
28
+ ServerActionToken,
29
+ } from '@mosetta/server-action/core';
30
+ import { DI_CONTAINER_TOKEN } from '@mosetta/di';
31
+ import {
32
+ ensureServerActionClient,
33
+ IdleServerData,
34
+ IServerDataHandle,
35
+ LoadingServerData,
36
+ SERVER_DATA_MANAGER,
37
+ ServerData,
38
+ SSR_ACTION_RECORDER_TOKEN,
39
+ stableStringify,
40
+ } from '@mosetta/server-action/client';
41
+
42
+ export interface IUseServerActionOptions<TIn extends {}, TOut> {
43
+ readonly token: ServerActionToken<TIn, TOut>;
44
+ /** query input; MANUAL mode ignores it (the input arrives at data.start(input)) */
45
+ readonly input?: TIn;
46
+ /** fixed at mount (changing it later is a programming error, like changing a hook's order) */
47
+ readonly start?: EServerActionStart;
48
+ /** opt-in suspense: throw the waiter while Loading (browser only; ignored on the server) */
49
+ readonly suspense?: boolean;
50
+ /** merged with token.tags / token.invalidates */
51
+ readonly tags?: readonly string[];
52
+ readonly invalidates?: readonly string[];
53
+ }
54
+
55
+ const RESOLVED = Promise.resolve();
56
+
57
+ function mergeTags(a?: readonly string[], b?: readonly string[]): readonly string[] {
58
+ if (!a?.length) return b ?? [];
59
+ if (!b?.length) return a;
60
+ return [...new Set([...a, ...b])];
61
+ }
62
+
63
+ // v1 server behavior (SSR precompute deferred): fresh PER-HOOK stubs — never module-level singletons
64
+ // (a shared subscriber set across concurrent isolate renders was a legacy leak).
65
+ function createServerHandle<TIn, TOut>(isManual: boolean): IServerDataHandle<TOut, TIn> {
66
+ const snapshot: ServerData<TOut, TIn> = isManual
67
+ ? new IdleServerData<TOut, TIn>(() => {
68
+ throw new Error('cannot start a manual server action during server render');
69
+ })
70
+ : new LoadingServerData<TOut, TIn>(RESOLVED);
71
+ return { subscribe: () => () => {}, snapshot: () => snapshot };
72
+ }
73
+
74
+ export function useServerAction<TIn extends {}, TOut>(
75
+ options: IUseServerActionOptions<TIn, TOut>,
76
+ ): ServerData<TOut, TIn> {
77
+ const { token } = options;
78
+ // "carry your own" wiring: the hook lazily wires its own client slice into the top
79
+ // scope of the inherited per-request container — no root registration, no wrapper component;
80
+ // the slice code enters a page's closure ONLY through modules that actually call this hook.
81
+ const di = useDi(DI_CONTAINER_TOKEN);
82
+ ensureServerActionClient(di);
83
+ const manager = useDi(SERVER_DATA_MANAGER);
84
+ const { reference } = useComponent();
85
+
86
+ const env = useDi(EXECUTION_ENV_TOKEN);
87
+ const recorder = useDi(SSR_ACTION_RECORDER_TOKEN);
88
+
89
+ // start + env are captured once — the mode is fixed for the hook's lifetime
90
+ const [mode] = useState(() => {
91
+ const start = options.start ?? EServerActionStart.START_FROM_CLIENT;
92
+ const isManual = start === EServerActionStart.START_MANUAL;
93
+ const isServer = env === 'server';
94
+ // real server execution: ONLY the START_FROM_SERVER axis, and it needs the render
95
+ // channel's recorder; everything else on the server renders its v1 stub
96
+ return {
97
+ isManual,
98
+ isServer,
99
+ serverExecutes: isServer && start === EServerActionStart.START_FROM_SERVER && recorder !== null,
100
+ };
101
+ });
102
+
103
+ const fullId = fullServerTileId({
104
+ namespace: reference.namespace,
105
+ version: reference.version,
106
+ name: token.serverModuleName ?? reference.name,
107
+ });
108
+
109
+ // per-hook handle for manual/stub-server modes; queries go through the shared manager cache
110
+ // (on an executing server render too — dedup and the recorder's per-key idempotence line up)
111
+ const [localHandle] = useState<IServerDataHandle<TOut, TIn> | null>(() => {
112
+ if (mode.isServer && !mode.serverExecutes) return createServerHandle<TIn, TOut>(mode.isManual);
113
+ if (mode.isManual) {
114
+ return manager.createManual<TIn, TOut>({
115
+ fullId,
116
+ actionName: token.actionName,
117
+ invalidates: mergeTags(token.invalidates, options.invalidates),
118
+ customSerializer: token.customSerializer,
119
+ });
120
+ }
121
+ return null;
122
+ });
123
+
124
+ const key = localHandle ? null : `${fullId}/${token.actionName}#${stableStringify(options.input)}`;
125
+ if (key !== null) {
126
+ // render-phase, but idempotent by key (start-on-first-read); a new input → a new key → a new query
127
+ manager.ensureQuery({
128
+ key,
129
+ tags: mergeTags(token.tags, options.tags),
130
+ request: { fullId, actionName: token.actionName, input: options.input },
131
+ customSerializer: token.customSerializer,
132
+ });
133
+ // the head channel's counter waits for this query from the SAME render that started it
134
+ if (mode.serverExecutes) recorder!.onQueryStart(key);
135
+ }
136
+
137
+ const subscribe = useCallback(
138
+ (listener: () => void) => (localHandle ? localHandle.subscribe(listener) : manager.subscribe(key!, listener)),
139
+ [manager, localHandle, key],
140
+ );
141
+ const getSnapshot = useCallback(
142
+ () => (localHandle ? localHandle.snapshot() : manager.snapshot<TOut>(key!) as ServerData<TOut, TIn>),
143
+ [manager, localHandle, key],
144
+ );
145
+
146
+ const data = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
147
+
148
+ if (mode.serverExecutes && key !== null) {
149
+ // UNCONDITIONAL suspense on an executing server render — the page waits for its data;
150
+ // the waiter never rejects (failures fold into an Error snapshot)
151
+ // eslint-disable-next-line @typescript-eslint/only-throw-error -- Suspense protocol
152
+ if (data.isLoading()) throw data.waiter;
153
+ // the retry render consumed the settled data → complete the counter + record for the cache
154
+ recorder!.onQuerySettled(key, data);
155
+ }
156
+
157
+ // opt-in suspense; never on a stub server render (its snapshot is a resolved-waiter Loading
158
+ // stub — throwing it would loop an async server renderer forever)
159
+ // eslint-disable-next-line @typescript-eslint/only-throw-error -- Suspense protocol (opt-in)
160
+ if (options.suspense && !mode.isServer && data.isLoading()) throw data.waiter;
161
+
162
+ return data;
163
+ }
package/src/ssr.tsx ADDED
@@ -0,0 +1,124 @@
1
+ // @mosetta/react/ssr — the REACT half of a server render: `export default defineSsrSide()`.
2
+ //
3
+ // Everything dialect-specific lives here, and nothing else does. The engine (@mosetta/server/ssr)
4
+ // knows which artifacts enter the isolate and how they link, how the head channel counts, what a
5
+ // preview token means and what a shell must carry; it hands all of that over as DATA. This file
6
+ // knows how to make an element, how to hold the head open until the body settles, and how to turn
7
+ // the result into a stream — which is react's business and no one else's.
8
+ //
9
+ // A page on another binding writes the same two members over the same engine. Nothing here is ever
10
+ // imported by browser code: an ssr facet is built into the isolate grain, which is the only reason
11
+ // react-dom/server may be named at all.
12
+ //
13
+ // NO JSX on purpose: the shell is built with createElement so this file reads the same way the
14
+ // engine's contract does — a tree of data, not markup.
15
+ import { createElement, Suspense, type FC } from 'react';
16
+ import * as react from 'react';
17
+ import * as jsxRuntime from 'react/jsx-runtime';
18
+ import * as reactDom from 'react-dom/server.edge';
19
+ import { renderToReadableStream } from 'react-dom/server.edge';
20
+ import { createRootRenderer } from '@mosetta/server/ssr';
21
+ import { FULL_APP_PREVIEW } from '@mosetta/runtime/client';
22
+ import type { IShellRequest, IRootRenderer } from '@mosetta/server/ssr';
23
+ import { isRightToLeft } from '@mosetta/core';
24
+
25
+ const raw = (html: string) => ({ dangerouslySetInnerHTML: { __html: html } });
26
+
27
+ /**
28
+ * The tags the head channel collected, held behind a boundary until the body settles: an action
29
+ * started deep in the tree may still add a title. Suspense-throw is the protocol both react and
30
+ * preact/compat understand — which is why the wait is expressed this way and not with an await.
31
+ */
32
+ function headTagsElement(request: IShellRequest) {
33
+ let settled = false;
34
+ const waiting = request.waitHead().then(() => {
35
+ settled = true;
36
+ });
37
+ const HeadTags = () => {
38
+ // eslint-disable-next-line @typescript-eslint/only-throw-error -- Suspense protocol
39
+ if (!settled) throw waiting;
40
+ // the key attribute lets the browser twin (DomHeadWriter) UPSERT over these tags
41
+ return request.headTags().map(([key, entry]) => createElement(entry.tag, {
42
+ key,
43
+ 'data-mosetta-head-key': key,
44
+ ...entry.attributes,
45
+ ...(entry.html !== undefined ? raw(entry.html) : {}),
46
+ }));
47
+ };
48
+ return createElement(Suspense, { fallback: null }, createElement(HeadTags, null));
49
+ }
50
+
51
+ /** the `<html>` shell: boot data in, an element tree out */
52
+ function shellElement(request: IShellRequest) {
53
+ const { envelope, boot, preview } = request;
54
+ const base = envelope.hostBase;
55
+ // a link of another code source is absolute already (ADR-0070); the host's are under its base
56
+ const bootUrlOf = (at: string, path: string): string => (/^[a-z][a-z0-9+.-]*:/i.test(path) ? path : `${at}${path}`);
57
+ // the page's own words in the head (react escapes them): the title a tab and a search result
58
+ // show, the description a result shows under it; the rest of the head is the SEO feature's
59
+ const meta = (envelope as { meta?: { title?: unknown; description?: unknown } }).meta;
60
+ const title = typeof meta?.title === 'string' && meta.title ? meta.title : 'mosetta';
61
+ const description = typeof meta?.description === 'string' && meta.description ? meta.description : null;
62
+ // #pageContent and the boot scripts are NOT here: the engine appends them at the end of <body>
63
+ // once the render finished, so the envelope carries what every node declared it ships
64
+ // (useShippedSettings) — see the engine's withBootTail
65
+ // the root gets the envelope as its tree and the channel in its props — head writes and action
66
+ // counting from anywhere below reach the engine's per-request channel that way
67
+ // a PREVIEW is a preview on the server too: the realm mark below reaches only the browser's
68
+ // container, so the root's prop says it here — blocks that branch on previewness (a hint for
69
+ // an empty list, a placeholder for an analytics script) render the same html the client
70
+ // hydrates against
71
+ const Root = request.app?.Component as FC<{ moduleTreeNode: unknown; ssrChannel: unknown; preview?: { type: string } }> | undefined;
72
+ const appElement = Root
73
+ ? createElement(Root, { moduleTreeNode: envelope, ssrChannel: request.channel, ...(preview ? { preview: { type: FULL_APP_PREVIEW } } : {}) })
74
+ : null;
75
+ const lang = (envelope as { route?: { lang?: unknown } }).route?.lang;
76
+ // a right-to-left language lays the whole page out mirrored
77
+ return createElement(
78
+ 'html',
79
+ typeof lang === 'string' && lang ? { lang, ...(isRightToLeft(lang) ? { dir: 'rtl' } : {}) } : null,
80
+ createElement(
81
+ 'head',
82
+ null,
83
+ createElement('meta', { charSet: 'utf-8' }),
84
+ createElement('meta', { name: 'viewport', content: 'width=device-width, initial-scale=1' }),
85
+ createElement('title', null, title),
86
+ ...(description ? [createElement('meta', { name: 'description', content: description })] : []),
87
+ createElement('style', raw('html,body,#root{margin:0;padding:0}')),
88
+ // the declared browser closure: warm the whole artifact wave during parsing (systemjs loads
89
+ // via script injection → classic-script destination, `as="script"`)…
90
+ // SRI: cross-origin integrity requires CORS → crossorigin=anonymous
91
+ ...boot.preloadPaths.map((l) => createElement('link', { rel: 'preload', as: 'script', href: bootUrlOf(base, l.path), ...(l.integrity ? { integrity: l.integrity, crossOrigin: 'anonymous' } : {}) })),
92
+ // …and the css of EVERY styled non-lazy tree module — client-rendered parts included; fetched
93
+ // with CORS, hash or not, so the copy the browser keeps carries the CDN's CORS headers and a
94
+ // later CORS read of the same file is answered from it
95
+ ...boot.stylePaths.map((l) => createElement('link', { rel: 'stylesheet', href: bootUrlOf(base, l.path), crossOrigin: 'anonymous', ...(l.integrity ? { integrity: l.integrity } : {}) })),
96
+ headTagsElement(request),
97
+ // the flag must exist BEFORE any boot script runs — BlueprintRoot reads it to wrap the tree
98
+ // in ProvidePreview (the whole iframe realm is a preview)
99
+ preview ? createElement('script', raw('window.__mosettaPreview=true')) : null,
100
+ ),
101
+ createElement('body', null, createElement('div', { id: 'root' }, appElement)),
102
+ );
103
+ }
104
+
105
+ export function defineSsrSide(): IRootRenderer {
106
+ return createRootRenderer({
107
+ // what client artifacts link against instead of their own react family: every composed
108
+ // version collapses onto these. react-dom/client is named because client artifacts import
109
+ // hydrateRoot at the top level and only CALL it when mounting — never on the server.
110
+ substitutions: {
111
+ react: react,
112
+ 'react/jsx-runtime': jsxRuntime,
113
+ 'react-dom': reactDom,
114
+ 'react-dom/client': reactDom,
115
+ },
116
+ async renderShell(request: IShellRequest): Promise<ReadableStream> {
117
+ const stream = await renderToReadableStream(shellElement(request), { signal: request.signal });
118
+ // buffered mode: await allReady before a byte is read — a late-resolving head boundary
119
+ // would otherwise ship as a fallback plus a patch script
120
+ await stream.allReady;
121
+ return stream;
122
+ },
123
+ });
124
+ }
package/src/start.ts ADDED
@@ -0,0 +1,79 @@
1
+ // @mosetta/runtime/react — the react-specific start surface: how a tree gets PUT somewhere.
2
+ //
3
+ // Two callers, one implementation. A page boots through its script facet, which is a one-liner
4
+ // over `startAndMount()`; a HOST (the editor showing a block, a panel showing a form) has an
5
+ // element and a tree and asks the root module to mount it there. Rendering a distribution must
6
+ // not require being the page — see ADR-0034.
7
+ //
8
+ // react lives HERE so the `/bootstrap` core stays package-agnostic; as a script facet dependency
9
+ // these imports are externalized like any module's — the SHARED react instance, never a bundled
10
+ // second copy (the hooks dispatcher of a bundled react-dom would not be the one the tree's
11
+ // modules read).
12
+ import { createElement, type FC } from 'react';
13
+ import { hydrateRoot } from 'react-dom/client';
14
+ import { start } from '@mosetta/runtime/bootstrap';
15
+ import type { IBlueprintNode } from '@mosetta/core';
16
+ import type { IMountHandle, IMountOptions, IRootComponentSide, IRootComponentProps } from '@mosetta/react';
17
+ import { DefaultRootComponent } from '@mosetta/react';
18
+
19
+ const MOUNTED = '__mosettaMounted__';
20
+
21
+ /**
22
+ * HYDRATION, always — including into an element with nothing in it.
23
+ *
24
+ * An empty container is not a special case: the first client render is the MIRROR PASS, where a
25
+ * node the server did not render returns a hole. A tree handed over by a host carries no
26
+ * server-render stamp at all, so every node is a hole, so the first pass produces exactly the
27
+ * nothing that is already there — and the holes fill in after mount. One code path for a page
28
+ * that arrived with server HTML and for a panel that arrived empty.
29
+ */
30
+ export function reactRoot(Component: FC<IRootComponentProps>): IRootComponentSide {
31
+ return {
32
+ Component,
33
+ mount(element: HTMLElement, options: IMountOptions): IMountHandle {
34
+ const app = (tree: IBlueprintNode) => createElement(Component, { moduleTreeNode: tree, parentDi: options.parentDi, preview: options.preview });
35
+ const root = hydrateRoot(element, app(options.tree));
36
+ return {
37
+ // the new tree travels as a prop; the root passes it to its page manager, which is what
38
+ // everything under it actually reads (ADR-0033 §7)
39
+ render: (tree) => root.render(app(tree)),
40
+ unmount: () => root.unmount(),
41
+ };
42
+ },
43
+ };
44
+ }
45
+
46
+ /** the root a page gets without writing one: DI birth + children, and the mounting to match */
47
+ export const defaultReactRoot = reactRoot(DefaultRootComponent);
48
+
49
+ /**
50
+ * `start()` + mount of the page's own root module. Idempotent: two mounting script facets on one
51
+ * page is an authoring error — the second call warns and skips.
52
+ *
53
+ * The root mounts ITSELF: `defineRootComponentSide(defaultReactRoot)` is what an app's root
54
+ * module says, and the same member serves a host that wants this tree somewhere else. react-dom
55
+ * riding into the root's closure costs the server nothing — the isolate substitutes the react
56
+ * family at link time and no longer composes its artifacts (server closure.ts).
57
+ */
58
+ export async function startAndMount(): Promise<void> {
59
+ const g = globalThis as Record<string, unknown>;
60
+ if (g[MOUNTED]) {
61
+ console.warn('[mosetta] startAndMount: page already mounted — skipped (one mounting script facet per page)');
62
+ return;
63
+ }
64
+ g[MOUNTED] = true;
65
+
66
+ const boot = await start();
67
+ if (!boot) return; // start() already reported the fatal
68
+ const root = boot.tree.reference ? boot.holder.getModule(boot.tree.reference) : undefined;
69
+ const module = root?.module as IRootComponentSide | undefined;
70
+ if (module?.Component == null) {
71
+ console.error('[mosetta] startAndMount: root module has no Component export');
72
+ boot.rootEl.textContent = '[mosetta] runtime failed: root module has no Component export';
73
+ return;
74
+ }
75
+ const mount = module.mount ?? reactRoot(module.Component).mount;
76
+ // the boot envelope is a page tree with extras (catalog, host base, ssr stamp) — a superset of
77
+ // what a mount needs
78
+ mount(boot.rootEl, { tree: boot.tree as unknown as IBlueprintNode });
79
+ }
@@ -0,0 +1,54 @@
1
+ // THE context wrapper — the one implementation of the Provide/use pair. The binding keeps
2
+ // exactly TWO contexts (the DI container and the node pointer); anything that ever needs another
3
+ // goes through this class, so every context has a NAME and a hard edge: reading it outside a
4
+ // provider is a named error at the read site, never an `undefined` that crashes three calls
5
+ // later. No more hand-rolled createContext pairs, each fragile in its own way.
6
+ import { createContext, ReactNode, useContext, useMemo } from 'react';
7
+ import type { FC } from 'react';
8
+
9
+ interface IBox<T> {
10
+ readonly value: T;
11
+ }
12
+
13
+ export class ProvidedContext<T> {
14
+ /** the provider component; carries the context's name as its displayName (devtools show it) */
15
+ readonly Provide: FC<{ value: T; children?: ReactNode }>;
16
+
17
+ /** the reader; throws a NAMED error when no provider is above */
18
+ readonly use: () => T;
19
+
20
+ /** the tolerant reader — null when no provider is above, for contexts with a sane fallback */
21
+ readonly useOptional: () => T | null;
22
+
23
+ constructor(name: string) {
24
+ // the BOX keeps `undefined` and `null` legal values — only a missing provider reads as null
25
+ const context = createContext<IBox<T> | null>(null);
26
+ context.displayName = name;
27
+
28
+ function Provide({ value, children }: { value: T; children?: ReactNode }) {
29
+ const box = useMemo<IBox<T>>(() => ({ value }), [value]);
30
+ return <context.Provider value={box}>{children}</context.Provider>;
31
+ }
32
+
33
+ // synthetic on purpose (one Provide per instance), so the name is stated explicitly —
34
+ // the convention for every component a factory builds
35
+ Provide.displayName = `${name}.Context`;
36
+
37
+ function use(): T {
38
+ const box = useContext(context);
39
+ if (box === null) {
40
+ throw new Error(`context "${name}" is not provided here — the tree above is missing its Provide`);
41
+ }
42
+ return box.value;
43
+ }
44
+
45
+ function useOptional(): T | null {
46
+ const box = useContext(context);
47
+ return box === null ? null : box.value;
48
+ }
49
+
50
+ this.Provide = Provide;
51
+ this.use = use;
52
+ this.useOptional = useOptional;
53
+ }
54
+ }
@@ -0,0 +1,190 @@
1
+ import type { IBlueprintNode } from '@mosetta/core';
2
+ import type { IMountPreview, IRefProviderDeclaration, ISsrRenderChannel, TBindableType } from '@mosetta/runtime/client';
3
+ import type { Constructable, DiContainer } from '@mosetta/di';
4
+ import type { FC } from 'react';
5
+
6
+ export interface IWithComponent<T = {}> {
7
+ readonly Component: FC<T>;
8
+ }
9
+
10
+ export interface IComponentSide<TSettings> extends IWithComponent {
11
+ readonly defaultSettings: () => TSettings;
12
+ /**
13
+ * This block HANDS OUT values other blocks' settings may reference (`{ "$ref": "<name>/<key>" }`):
14
+ * a name and a pure adapter over this block's own settings. The page resolves references with
15
+ * it while rendering, the editor reads and writes through it.
16
+ */
17
+ readonly provides?: IRefProviderDeclaration<TSettings>;
18
+ /**
19
+ * The settings of this block that MAY hold a reference instead of a literal — the visible
20
+ * contract a provider's editor relies on when it binds a page's fields — each with the TYPE of
21
+ * what it holds: `{ text: 'text', href: 'url' }`. A provider of that type is offered for the
22
+ * field and takes it under its wing in bulk or automatically (i18n links the texts, never an
23
+ * address). The list form (`['text']`) still works: settings of no declared type, bindable by
24
+ * hand only.
25
+ */
26
+ readonly bindable?: readonly (keyof TSettings & string)[] | Readonly<Partial<Record<keyof TSettings & string, TBindableType>>>;
27
+ /**
28
+ * This block's editor facet has a part for the PAGE (`<PageInspector>` — i18n's language, a
29
+ * theme's pick for the canvas): the editor mounts it among the canvas tools, with the node's
30
+ * settings as `subject`. A block that `provides` references has one implied.
31
+ */
32
+ readonly editorChrome?: boolean;
33
+ }
34
+
35
+ /**
36
+ * How a block asks to be SHOWN before it is placed (the palette's hover preview) — as DATA, so
37
+ * that showing it never requires being written in the host's framework.
38
+ *
39
+ * - `'self'` — "me, with my own default settings"; the common answer, and one word long.
40
+ * - a tree — anything richer: the block with children, inside a wrapper, or under a root module
41
+ * of its own (`reference`) which is free to register whatever services the specimen needs.
42
+ *
43
+ * No declaration, no preview: a block rendered alone out of any composition is a guess, and a
44
+ * guess shown as "this is what you are about to place" is worse than no answer.
45
+ */
46
+ export type TPreviewSpec = 'self' | IPreviewTree;
47
+
48
+ export interface IPreviewTree {
49
+ /** root module to mount the specimen with; omitted → the host's own */
50
+ readonly reference?: string;
51
+ readonly children: readonly IBlueprintNode[];
52
+ }
53
+
54
+ /**
55
+ * One named state of a block worth seeing — "Primary", "Without a description", "Three columns".
56
+ * The workbench shows every variant of a block side by side, live, on the block's own page, and
57
+ * opens any of them in its playground (the block's real settings form, nothing saved).
58
+ */
59
+ export interface IBlockVariant<TSettings = Record<string, unknown>> {
60
+ /** what the variant is called, short */
61
+ readonly name: string;
62
+ /** a sentence on what it is for */
63
+ readonly description?: string;
64
+ /** settings over the block's `defaultSettings()` */
65
+ readonly settings?: Partial<TSettings>;
66
+ /**
67
+ * What the block holds — a container's blocks, each in its slot. Left out: what the Default
68
+ * variant holds (the block as `preview` declares it, or nothing).
69
+ */
70
+ readonly children?: readonly IBlueprintNode[];
71
+ }
72
+
73
+ export interface IEditorSide<TSettings = Record<string, unknown>> extends IWithComponent {
74
+ readonly preview?: TPreviewSpec;
75
+ /**
76
+ * DI services that arrange the SPECIMEN's environment so the LIVE component can render — a
77
+ * seeded editing session, a canned data source. Registered into the preview application's
78
+ * container before anything renders. A preview is the real component in an arranged world:
79
+ * these mock its surroundings, never its markup.
80
+ */
81
+ readonly previewServices?: readonly Constructable[];
82
+ /**
83
+ * The block's VARIANTS — its states worth seeing side by side, as data. "Default" (the block as
84
+ * it is placed) needs no entry: it always comes first.
85
+ */
86
+ readonly variants?: readonly IBlockVariant<TSettings>[];
87
+ /**
88
+ * Settings written for an EARLIER version of the block (`from`, `1.2.0`), as this version reads
89
+ * them — called when an author updates a block on a page. Without it, the settings move as they
90
+ * are, and what this version added comes from its `defaultSettings()`.
91
+ */
92
+ readonly upgrade?: (settings: Record<string, unknown>, from: string) => Partial<TSettings>;
93
+ }
94
+
95
+ export interface IFallbackSide<TSettings> extends IComponentSide<TSettings> {
96
+ }
97
+
98
+ // NO di prop (SSR epic): the container is born INSIDE the Component —
99
+ // `useState(() => createAppContainer(...))` — so the same code path serves browser and server,
100
+ // and a cached server isolate gets a fresh container per request for free.
101
+ export interface IRootComponentProps {
102
+ moduleTreeNode: IBlueprintNode;
103
+ /** the mounting application's container (see IMountOptions.parentDi); absent for a page's own boot */
104
+ readonly parentDi?: DiContainer;
105
+ /** the host's word that this application is a specimen (see IMountOptions.preview) */
106
+ readonly preview?: IMountPreview;
107
+ // stamped by the ssr module during a server render, absent in the browser;
108
+ // the root passes the WHOLE props object into createAppContainer so the channel rides along
109
+ readonly ssrChannel?: ISsrRenderChannel;
110
+ }
111
+
112
+ /** what a host hands over when it asks for a tree to be put somewhere */
113
+ export interface IMountOptions {
114
+ /** an ordinary page document: the root's chrome plus its children */
115
+ readonly tree: IBlueprintNode;
116
+ /**
117
+ * The mounting application's own container — the Unity-editor door (PARENT_DI_TOKEN in the
118
+ * mounted app). A mount is same-realm by construction (it takes a live element) and tokens
119
+ * are string-keyed, so the door survives foreign package copies and foreign bindings; what a
120
+ * host means to EXPOSE it registers under a named token in this container (the settings
121
+ * draft), and everything else is the parent environment, deliberately open at editor time.
122
+ */
123
+ readonly parentDi?: DiContainer;
124
+ /**
125
+ * The host's word that this application is a SPECIMEN — addressed to the mounted CONTAINER
126
+ * (the type feeds PREVIEW_TYPE_TOKEN; the services are DI classes arranging the specimen's
127
+ * environment).
128
+ */
129
+ readonly preview?: IMountPreview;
130
+ }
131
+
132
+ export interface IMountHandle {
133
+ /** put a different tree in the same application, without building it again */
134
+ render(tree: IBlueprintNode): void;
135
+ unmount(): void;
136
+ }
137
+
138
+ // The ROOT component module: an ordinary component facet whose Component is the page
139
+ // tree's root (chrome around the tree; DI is born inside it) — and, optionally, the way to PUT
140
+ // that tree somewhere.
141
+ //
142
+ // `mount` exists because rendering a distribution must not require being the page: a host (the
143
+ // editor previewing a block, a panel showing a settings form) has an element and a tree, and asks
144
+ // the root — the only thing that knows how to render its own tree — to do it. The page's own boot
145
+ // goes through the very same call, from the script facet.
146
+ //
147
+ // It is NOT filled in by the definer: this file is imported by every component facet, and a react
148
+ // implementation would drag react-dom into the closure of every block. The react binding hands out
149
+ // a ready pair instead — `defineRootComponentSide(defaultReactRoot)`, or `reactRoot(MyChrome)`
150
+ // for a root with its own chrome.
151
+ //
152
+ // Required, not optional: a half-contract ("some roots can be hosted") would leave every host
153
+ // guessing. The server render pays nothing for it — the isolate substitutes the react family at
154
+ // link time and no longer composes its artifacts at all (server closure.ts).
155
+ export interface IRootComponentSide {
156
+ Component: FC<IRootComponentProps>;
157
+ mount: (element: HTMLElement, options: IMountOptions) => IMountHandle;
158
+ }
159
+
160
+ export function defineRootComponentSide(module: IRootComponentSide) {
161
+ return module;
162
+ }
163
+
164
+ export function defineComponentSide<TSettings>(module: IComponentSide<TSettings>) {
165
+ return module;
166
+ }
167
+
168
+ export function defineEditorSide<TSettings = Record<string, unknown>>(module: IEditorSide<TSettings>) {
169
+ return module;
170
+ }
171
+
172
+ export function defineFallbackSide<TSettings>(module: IFallbackSide<TSettings>) {
173
+ return module;
174
+ }
175
+
176
+ /**
177
+ * Marks a module INTERNAL: a block of the tooling (an editor panel, the workbench shell, a preview
178
+ * agent, the editor's own fallback) that the platform's pages place but no author should be
179
+ * offered — the block picker leaves it out. Wraps any definer's result, so a component and a
180
+ * fallback are marked the same way:
181
+ *
182
+ * export default markInternalTile(defineComponentSide({ ... }));
183
+ *
184
+ * Merely importing the marker is the declaration: the build stamps the module `internal` by the
185
+ * import the facet made, the way it stamps a page root. The module itself is untouched.
186
+ */
187
+ export function markInternalTile<TModule>(module: TModule): TModule {
188
+ return module;
189
+ }
190
+