@mosetta/react 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.
@@ -0,0 +1,174 @@
1
+ // THE node pointer — the second of the two contexts (with DI) that everything module-facing
2
+ // derives from. One provider per rendered node instead of a layer cake of six — and the pointer
3
+ // carries ONLY what never changes for a living node: its id (plus, under a fallback, the error).
4
+ //
5
+ // The node's DATA does not ride the context, and the reason is react's own mechanics: context
6
+ // propagation does not stop at a nested provider of the same context, so a pointer that carried
7
+ // the node document would re-render EVERY descendant module whenever an ancestor's document
8
+ // changed — an edited node's untouched sibling included, which is exactly what the renderer's
9
+ // memo economy exists to prevent. Per-node data lives in a DI-keyed store (nodeStatesOf) that
10
+ // the renderer writes as it walks: a component re-reads it exactly when the renderer re-rendered
11
+ // it — the memo path IS the subscription. The context never propagates at all.
12
+ import { ReactNode, useMemo, useSyncExternalStore } from 'react';
13
+ import type { ITileReference, IBlueprintNode } from '@mosetta/core';
14
+ import { DI_CONTAINER_TOKEN, DiContainer } from '@mosetta/di';
15
+ import { useDi } from './di';
16
+ import { ProvidedContext } from '../utils/ProvidedContext';
17
+ import { parseReference } from '../utils/tileReference';
18
+ import { previewSlots, slotKey, SlotMarker } from '../utils/previewSlots';
19
+ import { useResolvedSettings } from './useResolvedSettings';
20
+
21
+ export interface IProvidedChild {
22
+ readonly slot: string;
23
+ readonly child: ReactNode;
24
+ }
25
+
26
+ export const DEFAULT_SLOT = 'default';
27
+
28
+ export interface IWithError {
29
+ readonly error: Error;
30
+ }
31
+
32
+ /**
33
+ * A place for children, as the module DECLARES it.
34
+ *
35
+ * Slots are not declared anywhere else on purpose: a slot exists exactly when the module renders
36
+ * it, and not a moment earlier — that is what lets the editor offer an insertion point only where
37
+ * one truly is. Capacity, though, the editor cannot observe: whether a place takes one child or
38
+ * any number is knowledge only the module's author has. Hence the argument is REQUIRED.
39
+ */
40
+ export interface IChildrenSlot {
41
+ /** slot name; omitted → the default slot */
42
+ readonly slot?: string;
43
+ /**
44
+ * How many children this place accepts — a number, or 'many' when unbounded. The editor offers
45
+ * an insertion point while there is room. Spelled 'many' rather than Infinity because the value
46
+ * travels to the editor as JSON, where Infinity does not survive.
47
+ */
48
+ readonly max: number | 'many';
49
+ /** how many the module needs to look right; below it publishing warns. Default 0. */
50
+ readonly min?: number;
51
+ /**
52
+ * How tall the editor's insertion marker should be here, in pixels. A hint, not a rule: it only
53
+ * affects what the EDITOR sees (markers exist under preview alone), and it is worth setting when
54
+ * the default block of a few pixels looks wrong in this particular place.
55
+ */
56
+ readonly markerHeight?: number;
57
+ }
58
+
59
+ export interface INodePointer {
60
+ readonly id: string;
61
+ /** present only under a fallback: what this node's own render died of */
62
+ readonly error?: Error;
63
+ }
64
+
65
+ /** what the renderer knows about a node while it is on screen */
66
+ export interface INodeState {
67
+ readonly node: IBlueprintNode;
68
+ /** effective server-renderedness of this branch (SSR epic stage 4) */
69
+ readonly ssrRendered: boolean;
70
+ /** what the renderer rendered for this node's slots */
71
+ readonly children: readonly IProvidedChild[];
72
+ }
73
+
74
+ // Keyed BY THE CONTAINER — one store per application, found without registering anything during
75
+ // render (a lazy WeakMap get is idempotent; a DI registration is not). The container is the
76
+ // application's identity everywhere else too.
77
+ const nodeStates = new WeakMap<DiContainer, Map<string, INodeState>>();
78
+
79
+ export function nodeStatesOf(di: DiContainer): Map<string, INodeState> {
80
+ let states = nodeStates.get(di);
81
+ if (!states) {
82
+ states = new Map();
83
+ nodeStates.set(di, states);
84
+ }
85
+ return states;
86
+ }
87
+
88
+ const NodeContext = new ProvidedContext<INodePointer>('node');
89
+
90
+ export const ProvideNode = NodeContext.Provide;
91
+
92
+ export const useNode = NodeContext.use;
93
+
94
+ function useNodeState(): INodeState {
95
+ const { id } = useNode();
96
+ const di = useDi(DI_CONTAINER_TOKEN);
97
+ const state = nodeStatesOf(di).get(id);
98
+ if (!state) throw new Error(`node "${id}" has a pointer but no state — was it rendered outside the tree renderer?`);
99
+ return state;
100
+ }
101
+
102
+ export function useComponent(): { reference: ITileReference } {
103
+ const reference = useNodeState().node.reference;
104
+ return useMemo(() => ({ reference: parseReference(reference) }), [reference]);
105
+ }
106
+
107
+ // Node settings. NO merging with defaults here:
108
+ // defaultSettings() is an EDITOR-side seed copied into node.settings at insertion; what the tree
109
+ // carries is complete by contract. Absent settings (a node never touched by the editor) read as {}.
110
+ const NO_SETTINGS: Record<string, unknown> = Object.freeze({});
111
+
112
+ /**
113
+ * The node's settings as the block uses them: a setting that REFERENCES a value another block
114
+ * hands out (`{ "$ref": "i18n/k3" }`) arrives as that value — a block never sees the reference.
115
+ */
116
+ export function useSettings<TSettings>(): TSettings {
117
+ const { node } = useNodeState();
118
+ return useResolvedSettings(node.id, (node.settings ?? NO_SETTINGS)) as TSettings;
119
+ }
120
+
121
+ /** the node's settings exactly as the document holds them — references unresolved */
122
+ export function useRawSettings<TSettings>(): TSettings {
123
+ return (useNodeState().node.settings ?? NO_SETTINGS) as TSettings;
124
+ }
125
+
126
+ /** the error a fallback renders for; undefined everywhere outside a fallback */
127
+ export function useError(): Error | undefined {
128
+ return useNode().error;
129
+ }
130
+
131
+ /**
132
+ * The children placed into this slot. Whatever the document holds is rendered as is: `max` and
133
+ * `min` are a declaration for the editor, never a filter — content is never silently dropped
134
+ * because the code changed its mind about capacity.
135
+ *
136
+ * Under an armed preview the returned list ends with an invisible MARKER, which is how the editor
137
+ * learns that this slot is being rendered at all (previewSlots). Everywhere else the list holds
138
+ * the children and nothing more.
139
+ */
140
+ /** a child as a HOST sees it: the node document beside the element the renderer made of it */
141
+ export interface IChildEntry {
142
+ readonly node: IBlueprintNode;
143
+ readonly child: ReactNode;
144
+ }
145
+
146
+ /**
147
+ * EVERY child with its document — for hosts that manage content rather than lay it out (a dock
148
+ * arranging tabs needs each child's identity and root reference, not a slot's worth of elements).
149
+ * The arrays pair 1:1 by construction: the renderer builds the elements by mapping the docs.
150
+ */
151
+ export function useChildEntries(): IChildEntry[] {
152
+ const state = useNodeState();
153
+ const docs = state.node.children ?? [];
154
+ return state.children.map((provided, index) => ({ node: docs[index], child: provided.child }));
155
+ }
156
+
157
+ export function useChildren(place: IChildrenSlot): ReactNode[] {
158
+ const slot = place.slot ?? DEFAULT_SLOT;
159
+ const { id } = useNode();
160
+ const provided = useNodeState().children;
161
+ const armed = useSyncExternalStore(previewSlots.subscribeArmed, previewSlots.isArmed, previewSlots.neverArmed);
162
+ const children = provided.filter((c) => c.slot === slot).map((c) => c.child);
163
+ if (!armed) return children;
164
+ return [
165
+ ...children,
166
+ <SlotMarker key={`__slot__${slotKey(id, slot)}`}
167
+ ownerId={id}
168
+ slot={slot}
169
+ max={place.max}
170
+ min={place.min ?? 0}
171
+ filled={children.length}
172
+ height={place.markerHeight} />,
173
+ ];
174
+ }
@@ -0,0 +1,31 @@
1
+ // Client/hydration state WITHOUT a context: none of it is tree-scoped data.
2
+ import { useCallback, useEffect, useRef, useSyncExternalStore } from 'react';
3
+ import { getIsClient } from '@mosetta/runtime/client';
4
+
5
+ /** a realm fact, not a render fact — constant for the life of the session */
6
+ export function useIsClient(): boolean {
7
+ return getIsClient();
8
+ }
9
+
10
+ /** true until the CALLER's own mount effect has fired */
11
+ export function useIsFirstRender(): () => boolean {
12
+ const first = useRef(true);
13
+ useEffect(() => {
14
+ first.current = false;
15
+ }, []);
16
+ return useCallback(() => first.current, []);
17
+ }
18
+
19
+ const subscribeNever = () => () => {};
20
+ const yes = () => true;
21
+ const no = () => false;
22
+
23
+ /**
24
+ * The hydration gate: false on the server and during the hydration MIRROR PASS, true after the
25
+ * component mounted. The canonical useSyncExternalStore shape — react itself re-checks the
26
+ * snapshot after hydration and re-renders the subscriber, which is exactly what lets a HOLE (a
27
+ * node the server did not render) fill in even under an unchanged memoized parent.
28
+ */
29
+ export function useHydrated(): boolean {
30
+ return useSyncExternalStore(subscribeNever, yes, no);
31
+ }
@@ -0,0 +1,65 @@
1
+ // The react adapter of the DI container: the `di` context instance — one of the binding's two
2
+ // contexts (ProvidedContext), living in the root entry with everything that depends on it.
3
+ // Beside it, the SCOPE context: every rendered block provides its own scope (a child of the
4
+ // parent block's — see BlueprintNodeRenderer), and the hooks resolve tokens FROM that scope,
5
+ // so a facility registered for a subtree (a dock panel's tab handle) is found by walking the
6
+ // scope chain instead of by wrapping containers.
7
+ import { DiContainer, GlobalScope, IWithDi, MultiDiToken, Scope, SingleDiToken } from '@mosetta/di';
8
+ import { ReactNode, useCallback, useSyncExternalStore } from 'react';
9
+ import { ProvidedContext } from '../utils/ProvidedContext';
10
+
11
+ const DiContext = new ProvidedContext<DiContainer>('di');
12
+
13
+ const DiScopeContext = new ProvidedContext<Scope>('diScope');
14
+
15
+ export function ProvideDi({ di, children }: { children: ReactNode } & IWithDi) {
16
+ return <DiContext.Provide value={di}>{children}</DiContext.Provide>;
17
+ }
18
+
19
+ export function ProvideDiScope({ scope, children }: { scope: Scope; children?: ReactNode }) {
20
+ return <DiScopeContext.Provide value={scope}>{children}</DiScopeContext.Provide>;
21
+ }
22
+
23
+ /** the scope this place of the tree resolves from; outside any provider — the global root */
24
+ export function useDiScope(): Scope {
25
+ return DiScopeContext.useOptional() ?? GlobalScope.singleton();
26
+ }
27
+
28
+ export function useDi<T>(token: SingleDiToken<T>, scope?: Scope): T;
29
+ export function useDi<T>(token: MultiDiToken<T>, scope?: Scope): T[];
30
+ export function useDi<T>(token: SingleDiToken<T> | MultiDiToken<T>, scope?: Scope): T | T[] {
31
+ const di = DiContext.use();
32
+ const contextScope = useDiScope();
33
+ const from = scope ?? contextScope;
34
+
35
+ if (SingleDiToken.isSingleToken(token)) {
36
+ return di.getByToken(token, from);
37
+ }
38
+
39
+ return di.getManyByToken(token, from);
40
+ }
41
+
42
+ /**
43
+ * Resolve a token that MAY be absent — the cooperation hook: a module that can use a facility of
44
+ * its host (a dock's layout manager, a tab handle) pulls it optionally and simply does nothing
45
+ * when the host provides none. `useDi` throws for a missing token because a required dependency
46
+ * missing is a composition error; an optional integration is not.
47
+ *
48
+ * REACTIVE by construction: the hook subscribes to the container's implementation changes, so a
49
+ * facility registered (or disposed) after the first render reaches the component as a re-render —
50
+ * no mount-order luck involved.
51
+ */
52
+ export function useDiOptional<T>(token: SingleDiToken<T>, scope?: Scope): T | null {
53
+ const di = DiContext.use();
54
+ const contextScope = useDiScope();
55
+ const from = scope ?? contextScope;
56
+ const subscribe = useCallback(
57
+ (onChange: () => void) => di.onTokenImplementationChange(token, from, onChange),
58
+ [di, token, from],
59
+ );
60
+ const read = useCallback(
61
+ () => (di.hasByToken(token, from) ? di.getByToken(token, from) : null),
62
+ [di, token, from],
63
+ );
64
+ return useSyncExternalStore(subscribe, read, read);
65
+ }
@@ -0,0 +1,27 @@
1
+ import { useMemo } from 'react';
2
+ import { useDi } from './di';
3
+ import { HEAD_WRITER_TOKEN, IHeadWriter, RESERVED_HEAD_KEYS } from '@mosetta/runtime/client';
4
+
5
+ // Modules write head tags DURING RENDER — on the server the tag must land in the per-request
6
+ // collector before the body settles (an effect would fire after the page shipped). The write is
7
+ // therefore idempotent by key on both sides (last-write-wins in the collector, upsert in the DOM
8
+ // twin) — StrictMode's double render is harmless.
9
+ // FRAMEWORK keys are reserved: a write under one is ignored with a warning — the hook is the
10
+ // module-facing surface, the framework's own writers (the ssr recorder) bypass it.
11
+ export function useAddToHead(): IHeadWriter['addToHead'] {
12
+ const writer = useDi(HEAD_WRITER_TOKEN);
13
+ return useMemo(() => {
14
+ const write = writer.addToHead.bind(writer);
15
+ const warned = new Set<string>();
16
+ return (key, tag, attributes, html) => {
17
+ if (RESERVED_HEAD_KEYS.has(key)) {
18
+ if (!warned.has(key)) {
19
+ warned.add(key);
20
+ console.warn(`[mosetta] head key "${key}" is reserved for the framework — write ignored`);
21
+ }
22
+ return;
23
+ }
24
+ write(key, tag, attributes, html);
25
+ };
26
+ }, [writer]);
27
+ }
@@ -0,0 +1,217 @@
1
+ import { createElement, Fragment, useCallback, useRef, useSyncExternalStore } from 'react';
2
+ import type { ReactNode } from 'react';
3
+ import { SingleDiToken } from '@mosetta/di';
4
+ import type { IStyleVariable } from '@mosetta/core';
5
+ import { PARENT_DI_TOKEN } from '@mosetta/runtime/client';
6
+ import { useDi } from './di';
7
+ import { useSettings } from './NodeContext';
8
+ import { SETTINGS_DRAFT_TOKEN } from './useSettingsDraft';
9
+ import type { IFieldBinding, IPageMetaShape, IPageMetaWordsShape, IScopeEntry, ISettingsDraftContract } from './useSettingsDraft';
10
+
11
+ // What a workbench exposes to the editor facets it mounts, beside the settings draft — each a
12
+ // named token in the workbench's own container, read through the parent door, each optional:
13
+ // a facet shown by a host that offers none simply goes without.
14
+
15
+ /** a field's binding, and the two things a form may do about it */
16
+ export interface ISettingsBinding extends IFieldBinding {
17
+ bind(provider: string): void;
18
+ unbind(): void;
19
+ }
20
+
21
+ const NO_BINDING: IFieldBinding = { provider: null, providers: [] };
22
+
23
+ function useDraftContract(): ISettingsDraftContract | null {
24
+ const parent = useDi(PARENT_DI_TOKEN);
25
+ return parent?.hasByToken(SETTINGS_DRAFT_TOKEN) ? parent.getByToken(SETTINGS_DRAFT_TOKEN) : null;
26
+ }
27
+
28
+ /**
29
+ * How `field` of the edited node stands against references — for a form that wants to show it
30
+ * (a "linked to i18n" mark) or change it. Null when the host knows no references.
31
+ */
32
+ export function useSettingsBinding(field: string): ISettingsBinding | null {
33
+ const contract = useDraftContract();
34
+ const last = useRef<IFieldBinding>(NO_BINDING);
35
+ const snapshot = useCallback(() => {
36
+ const next = contract?.binding?.(field) ?? NO_BINDING;
37
+ const prev = last.current;
38
+ // the store contract wants a stable object while nothing changed
39
+ if (prev.provider === next.provider && prev.providers.join('|') === next.providers.join('|')) return prev;
40
+ last.current = next;
41
+ return next;
42
+ }, [contract, field]);
43
+ const subscribe = useCallback((listener: () => void) => contract?.subscribe(listener) ?? (() => {}), [contract]);
44
+ const binding = useSyncExternalStore(subscribe, snapshot, snapshot);
45
+ if (!contract?.binding) return null;
46
+ return {
47
+ ...binding,
48
+ bind: (provider) => contract.bind?.(field, provider),
49
+ unbind: () => contract.unbind?.(field),
50
+ };
51
+ }
52
+
53
+ /** a PROVIDER form's reach over its scope; null when the host offers none */
54
+ export interface IProviderScope {
55
+ bindScope(): Promise<number>;
56
+ usedKeys(): readonly string[];
57
+ /** the provider's settings as they are NOW — after an await, the render's copy is stale */
58
+ settings(): Record<string, unknown>;
59
+ /** the node the draft is about now; a long operation stops when the author picked another */
60
+ node(): string | null;
61
+ /** the referenced keys in the page's order, each with what reads it; empty when the host tells nothing */
62
+ outline(): readonly IScopeEntry[];
63
+ /** the PAGE around the provider: its meta and the way to write its words in other languages; null — no page (a prefab) */
64
+ page(): { meta: IPageMetaShape; setWords(byLang: Readonly<Record<string, IPageMetaWordsShape>>): void } | null;
65
+ /** told whenever the document changes — a block placed, a text edited anywhere in the scope */
66
+ subscribe(listener: () => void): () => void;
67
+ /** light a node up in the editor (a list pointing at what reads a value); null — none */
68
+ hover(nodeId: string | null): void;
69
+ }
70
+
71
+ /**
72
+ * A PROVIDER's reach over its scope: in its own settings form, the provider the form is about;
73
+ * with `providerId`, that provider node — a provider's chrome among the canvas tools, standing
74
+ * beside whatever is selected. Null when the host offers none.
75
+ */
76
+ export function useProviderScope(providerId?: string): IProviderScope | null {
77
+ const contract = useDraftContract();
78
+ if (!contract?.bindScope || !contract.usedKeys || !contract.node) return null;
79
+ const { pageMeta, setPageMetaWords } = contract;
80
+ const usedKeys = contract.usedKeys;
81
+ return {
82
+ bindScope: contract.bindScope,
83
+ usedKeys: () => usedKeys(providerId),
84
+ settings: contract.snapshot,
85
+ node: contract.node,
86
+ outline: () => contract.outline?.(providerId) ?? [],
87
+ page: () => {
88
+ const meta = pageMeta?.(providerId) ?? null;
89
+ return meta && setPageMetaWords ? { meta, setWords: setPageMetaWords } : null;
90
+ },
91
+ subscribe: contract.subscribe,
92
+ hover: (nodeId) => contract.hover?.(nodeId),
93
+ };
94
+ }
95
+
96
+ /**
97
+ * The views the editor chose for providers, by provider name — i18n: which language is being
98
+ * edited. Editor state, never document: the canvas previews it, forms read and write through it.
99
+ */
100
+ export interface IEditorViewsContract {
101
+ snapshot(): Readonly<Record<string, unknown>>;
102
+ subscribe(listener: () => void): () => void;
103
+ set(name: string, view: unknown): void;
104
+ }
105
+
106
+ export const EDITOR_VIEWS_TOKEN = new SingleDiToken<IEditorViewsContract>('EDITOR_VIEWS_TOKEN');
107
+
108
+ const NO_VIEWS: Readonly<Record<string, unknown>> = Object.freeze({});
109
+ const NO_SUBSCRIPTION = () => () => {};
110
+
111
+ /** one provider's view and its setter; null when the host keeps no views */
112
+ export function useEditorView<TView>(name: string): { view: TView | null; set(view: TView | null): void } | null {
113
+ const parent = useDi(PARENT_DI_TOKEN);
114
+ const contract = parent?.hasByToken(EDITOR_VIEWS_TOKEN) ? parent.getByToken(EDITOR_VIEWS_TOKEN) : null;
115
+ const snapshot = useCallback(() => contract?.snapshot() ?? NO_VIEWS, [contract]);
116
+ const views = useSyncExternalStore(contract?.subscribe ?? NO_SUBSCRIPTION, snapshot, snapshot);
117
+ if (!contract) return null;
118
+ return { view: (views[name] as TView | undefined) ?? null, set: (view) => contract.set(name, view) };
119
+ }
120
+
121
+ /**
122
+ * The project's content config (`config.json`) as the host read it — which languages its
123
+ * addresses serve, and whatever else a project-wide block wants to know. Null until read, or
124
+ * when the host offers none.
125
+ */
126
+ export interface IProjectConfigContract {
127
+ snapshot(): Readonly<Record<string, unknown>> | null;
128
+ subscribe(listener: () => void): () => void;
129
+ }
130
+
131
+ export const EDITOR_PROJECT_CONFIG_TOKEN = new SingleDiToken<IProjectConfigContract>('EDITOR_PROJECT_CONFIG_TOKEN');
132
+
133
+ export function useProjectConfig(): Readonly<Record<string, unknown>> | null {
134
+ const parent = useDi(PARENT_DI_TOKEN);
135
+ const contract = parent?.hasByToken(EDITOR_PROJECT_CONFIG_TOKEN) ? parent.getByToken(EDITOR_PROJECT_CONFIG_TOKEN) : null;
136
+ const snapshot = useCallback(() => contract?.snapshot() ?? null, [contract]);
137
+ return useSyncExternalStore(contract?.subscribe ?? NO_SUBSCRIPTION, snapshot, snapshot);
138
+ }
139
+
140
+ /**
141
+ * The variables of the themes AROUND the edited node — what its styles may name instead of a
142
+ * literal (`var(--theme-accent)`). Inner themes override outer ones key by key. Empty where no
143
+ * theme is around, or when the host offers none.
144
+ */
145
+ export interface IThemeVariablesContract {
146
+ snapshot(): readonly IStyleVariable[];
147
+ subscribe(listener: () => void): () => void;
148
+ }
149
+
150
+ export const EDITOR_THEME_VARIABLES_TOKEN = new SingleDiToken<IThemeVariablesContract>('EDITOR_THEME_VARIABLES_TOKEN');
151
+
152
+ const NO_VARIABLES: readonly IStyleVariable[] = Object.freeze([]);
153
+
154
+ export function useThemeVariables(): readonly IStyleVariable[] {
155
+ const parent = useDi(PARENT_DI_TOKEN);
156
+ const contract = parent?.hasByToken(EDITOR_THEME_VARIABLES_TOKEN) ? parent.getByToken(EDITOR_THEME_VARIABLES_TOKEN) : null;
157
+ const snapshot = useCallback(() => contract?.snapshot() ?? NO_VARIABLES, [contract]);
158
+ return useSyncExternalStore(contract?.subscribe ?? NO_SUBSCRIPTION, snapshot, snapshot);
159
+ }
160
+
161
+ /** what a `chrome` mount is about: the settings of the node whose control it is */
162
+ export function useEditorSubject<TSettings>(): TSettings | null {
163
+ return useSettings<{ subject?: TSettings }>().subject ?? null;
164
+ }
165
+
166
+ /** …and that node's id — what a chrome asks its provider scope about (useProviderScope(id)) */
167
+ export function useEditorSubjectId(): string | null {
168
+ return useSettings<{ subjectId?: string }>().subjectId ?? null;
169
+ }
170
+
171
+ /**
172
+ * Where the host mounted this editor facet: `settings` — the node's settings form; `chrome` — a
173
+ * provider's standing control in the corner of the canvas (i18n's language switch). The host
174
+ * says it in the facet node's own settings.
175
+ */
176
+ export type TEditorPlacement = 'settings' | 'chrome';
177
+
178
+ export function useEditorPlacement(): TEditorPlacement {
179
+ return useSettings<{ placement?: TEditorPlacement }>().placement ?? 'settings';
180
+ }
181
+
182
+ /**
183
+ * The TAB a section of a block's settings belongs to. The editor keeps the catalog — `main` (what
184
+ * the block is: its content, its options), `styles` (how it looks), `other` (everything else) —
185
+ * and shows one tab at a time; a name it does not know lands under `other`. Unnamed content is
186
+ * `main`. Read by the host off the DOM, as this attribute.
187
+ */
188
+ export type TInspectorTab = 'main' | 'styles' | 'other' | (string & {});
189
+
190
+ export const INSPECTOR_TAB_ATTRIBUTE = 'data-inspector-tab';
191
+
192
+ /**
193
+ * WHERE an editor facet's parts go, said by wrapping them — the facet renders all of them, the
194
+ * host shows each where it belongs:
195
+ *
196
+ * return <>
197
+ * <BlockInspector><I18nForm /></BlockInspector> // the selected block's settings
198
+ * <BlockInspector tab="other"><Advanced /></BlockInspector> // …under a tab of their own
199
+ * <PageInspector><LanguageSwitch /></PageInspector> // the canvas tools, over the page
200
+ * </>;
201
+ *
202
+ * Unwrapped content is the block's settings, as it always was. The host mounts the facet in each
203
+ * place on its own — a block's form exists while the block is selected, the page's controls for as
204
+ * long as the canvas tools are out — and asks for the page's part only of a module that declares
205
+ * `editorChrome`. A `tab` puts the section under that tab of the settings (no box of its own: the
206
+ * wrapper is `display: contents`).
207
+ */
208
+ export function BlockInspector({ tab, children }: { tab?: TInspectorTab; children?: ReactNode }) {
209
+ if (useEditorPlacement() !== 'settings') return null;
210
+ return tab
211
+ ? createElement('div', { [INSPECTOR_TAB_ATTRIBUTE]: tab, style: { display: 'contents' } }, children)
212
+ : createElement(Fragment, null, children);
213
+ }
214
+
215
+ export function PageInspector({ children }: { children?: ReactNode }) {
216
+ return useEditorPlacement() === 'chrome' ? createElement(Fragment, null, children) : null;
217
+ }
@@ -0,0 +1,29 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ const forceRerenderCallbacks: Array<(path: string) => void> = [];
4
+
5
+ export function useForceRerender(path: string) {
6
+ const [, setRenderCounter] = useState(0);
7
+
8
+ useEffect(() => {
9
+ function rerender(currentPath: string) {
10
+ if (path === currentPath) {
11
+ setRenderCounter(r => r + 1);
12
+ }
13
+ }
14
+
15
+ forceRerenderCallbacks.push(rerender);
16
+
17
+ return () => {
18
+ forceRerenderCallbacks.splice(forceRerenderCallbacks.indexOf(rerender), 1);
19
+ };
20
+ }, []);
21
+
22
+ return {
23
+ rerender(path: string) {
24
+ for (let setRender of forceRerenderCallbacks) {
25
+ setRender(path);
26
+ }
27
+ },
28
+ };
29
+ }
@@ -0,0 +1,67 @@
1
+ import { useMemo } from 'react';
2
+ import { isCurrentPath, resolveHref } from '@mosetta/core';
3
+ import type { IHrefContext, IHrefOptions } from '@mosetta/core';
4
+ import { useRoute } from './useRoute';
5
+ import { useRouter } from './useRouter';
6
+
7
+ // ADDRESSES WITHOUT LANGUAGE: a block names a path of the site as its author wrote it — `/blog`,
8
+ // `blog/x`, or nothing for the page itself — and gets the address in the reader's language, or in
9
+ // the one it asks for. Where the language goes in an address is the platform's business (the
10
+ // scheme the server stamps into the route); no block writes it.
11
+ //
12
+ // const href = useHref('/blog'); // /ru/blog for a Russian reader
13
+ // const german = useHref(undefined, { lang: 'de' }); // this page in German, its query kept
14
+ // const narrowed = useHref(undefined, { query: { category: 'ai' } });
15
+
16
+ export type { IHrefOptions } from '@mosetta/core';
17
+
18
+ function useHrefContext(): IHrefContext {
19
+ const route = useRoute();
20
+ return useMemo(() => ({
21
+ path: route?.path ?? '/',
22
+ search: route?.search ?? {},
23
+ lang: route?.lang,
24
+ addressing: route?.addressing,
25
+ }), [route]);
26
+ }
27
+
28
+ /** the address of a path of this site (or of this page), in the reader's language or the one asked for */
29
+ export function useHref(target?: string, options?: IHrefOptions): string {
30
+ return resolveHref(useHrefContext(), target, options);
31
+ }
32
+
33
+ /** the same, as a function — for a block that makes many addresses (a list, a language switcher) */
34
+ export function useHrefBuilder(): (target?: string, options?: IHrefOptions) => string {
35
+ const context = useHrefContext();
36
+ return useMemo(() => (target?: string, options?: IHrefOptions) => resolveHref(context, target, options), [context]);
37
+ }
38
+
39
+ /**
40
+ * Go to a path of this site in the reader's language — in place through the client router
41
+ * (ADR-0051), or by a whole document load where the router is inert or does not take the address.
42
+ */
43
+ export function useNavigate(): (target?: string, options?: IHrefOptions) => void {
44
+ const build = useHrefBuilder();
45
+ const router = useRouter();
46
+ return useMemo(() => (target?: string, options?: IHrefOptions) => {
47
+ const href = build(target, options);
48
+ void router.navigate(href).then((taken) => {
49
+ if (!taken && typeof window !== 'undefined') window.location.assign(href);
50
+ });
51
+ }, [build, router]);
52
+ }
53
+
54
+ /** whether a path of this site is where the reader is — exactly, or (`under`) anywhere beneath it */
55
+ export function useIsCurrent(target: string, options: { under?: boolean } = {}): boolean {
56
+ return isCurrentPath(useHrefContext(), target, options.under === true);
57
+ }
58
+
59
+ /** the languages of the project and the one being read — a language switcher's business only */
60
+ export function useLanguages(): { readonly current: string; readonly all: readonly string[] } {
61
+ const route = useRoute();
62
+ const addressing = route?.addressing;
63
+ return useMemo(() => ({
64
+ current: route?.lang ?? addressing?.defaultLanguage ?? 'en',
65
+ all: addressing?.languages ?? [route?.lang ?? 'en'],
66
+ }), [route, addressing]);
67
+ }
@@ -0,0 +1,57 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { useForceRerender } from './useForceRerender';
3
+
4
+ /**
5
+ * A value kept in the browser's local storage — an arrangement or a preference, never content.
6
+ *
7
+ * The store is taken from the WINDOW this component renders in, not from the bare global: a host
8
+ * may run a tree in another realm (the editor previews a block; a test drives a jsdom window it
9
+ * did not publish globally), and a bare `localStorage` reference is then a ReferenceError instead
10
+ * of a preference. Where there is no window at all — a server render — the initial value stands
11
+ * and nothing is written; the first client render agrees with it, so hydration is unaffected.
12
+ */
13
+ function storage(): Storage | null {
14
+ try {
15
+ return typeof window === 'undefined' ? null : window.localStorage;
16
+ } catch {
17
+ return null; // a document that denies storage (sandboxed iframe, blocked cookies)
18
+ }
19
+ }
20
+
21
+ export function useLocalStorageKey<T>(key: string, initialValue: T) {
22
+ const [firstRender, setFirstRender] = useState(true);
23
+ const { rerender } = useForceRerender(key);
24
+
25
+ function setValueToStorage(value: T) {
26
+ const serialized = JSON.stringify(value);
27
+
28
+ storage()?.setItem(key, serialized);
29
+
30
+ return serialized;
31
+ }
32
+
33
+ function getValueFromStorage(): T {
34
+ let rawValue = storage()?.getItem(key);
35
+
36
+ if (!rawValue) {
37
+ rawValue = setValueToStorage(initialValue);
38
+ }
39
+
40
+ return JSON.parse(rawValue) as T;
41
+ }
42
+
43
+ useEffect(() => {
44
+ setFirstRender(false);
45
+ rerender(key);
46
+ }, []);
47
+
48
+ return {
49
+ value: firstRender
50
+ ? initialValue
51
+ : getValueFromStorage(),
52
+ setValue: (value: T) => {
53
+ setValueToStorage(value);
54
+ rerender(key);
55
+ },
56
+ };
57
+ }