@byamoah/studio-state 0.0.0-stage → 0.1.0-beta.0

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/react.d.ts ADDED
@@ -0,0 +1,152 @@
1
+ import type { StudioContract } from "./index.js";
2
+ import type { ReactNode, ComponentType } from "react";
3
+ /** Augmented by the project's generated declaration file. */
4
+ export interface StudioState {}
5
+ export interface StudioBlockTypes {}
6
+ export function StudioProvider<T extends object>(props: {
7
+ value: T | null;
8
+ status?: StudioStatus;
9
+ contract?: StudioContract;
10
+ children?: ReactNode;
11
+ }): ReactNode;
12
+ export function useStudioContext(): Readonly<StudioState>;
13
+
14
+ type CollectionPaths<T, P extends string = ""> = {
15
+ [K in keyof T & string]: T[K] extends readonly { id: string }[]
16
+ ? `${P}${K}`
17
+ : T[K] extends object
18
+ ? CollectionPaths<T[K], `${P}${K}.`>
19
+ : never;
20
+ }[keyof T & string];
21
+ type PathValue<T, P extends string> = P extends `${infer K}.${infer R}`
22
+ ? K extends keyof T
23
+ ? PathValue<T[K], R>
24
+ : never
25
+ : P extends keyof T
26
+ ? T[P]
27
+ : never;
28
+ export function useStudioListItem<P extends CollectionPaths<StudioState>>(
29
+ path: P,
30
+ id: string,
31
+ options?: { reveal?: () => void },
32
+ ): PathValue<StudioState, P> extends readonly (infer Item)[]
33
+ ? Readonly<Item>
34
+ : never;
35
+
36
+ export function useStudioCollection<P extends CollectionPaths<StudioState>>(
37
+ path: P,
38
+ options?: { reveal?: (id: string) => void },
39
+ ): Readonly<PathValue<StudioState, P>>;
40
+
41
+ export interface RepeatItemProps {
42
+ id: string;
43
+ index: number;
44
+ instanceId: string;
45
+ }
46
+ /** Headless: place inside your row, column, grid or native list container. */
47
+ export function Repeat<
48
+ P extends CollectionPaths<StudioState>,
49
+ Extra extends object = Record<never, never>,
50
+ >(props: {
51
+ path: P;
52
+ template: ComponentType<RepeatItemProps & Extra>;
53
+ direction?: "vertical" | "horizontal" | "grid";
54
+ itemProps?: Extra;
55
+ reveal?: (id: string) => void;
56
+ }): ReactNode;
57
+ export function useStudioRepeat<
58
+ P extends CollectionPaths<StudioState>,
59
+ Extra extends object = Record<never, never>,
60
+ >(props: {
61
+ path: P;
62
+ template: ComponentType<RepeatItemProps & Extra>;
63
+ direction?: "vertical" | "horizontal" | "grid";
64
+ itemProps?: Extra;
65
+ reveal?: (id: string) => void;
66
+ }): ReactNode[];
67
+ export function useStudioInstanceId(suffix?: string): string;
68
+
69
+ export interface StudioStatus {
70
+ phase?: "hydrating" | "fetching" | "preparing" | "idle";
71
+ channel?: "draft" | "published" | "unknown";
72
+ checkedAt?: number | null;
73
+ contractId?: string;
74
+ ready: boolean;
75
+ refreshing: boolean;
76
+ hydrated: boolean;
77
+ source: "none" | "bundled" | "cache" | "remote" | "development" | "published";
78
+ revision: string | null;
79
+ error: string | null;
80
+ refresh(): Promise<void>;
81
+ }
82
+ export function useStudioStatus(): Readonly<StudioStatus>;
83
+ export function StudioDeliveryProvider<D extends object>(props: {
84
+ client: import("./delivery.js").ContentClient<D>;
85
+ contract?: StudioContract;
86
+ children?: ReactNode;
87
+ }): ReactNode;
88
+
89
+ /** Compiler-supplied inspection metadata; absent outside an instrumented Repeat. */
90
+ export interface StudioRepeatMetadata {
91
+ collectionPath?: string;
92
+ candidates?: StudioRepeatMetadata[];
93
+ id: string;
94
+ identity: "registered" | "ambiguous";
95
+ kind: "text" | "image" | "input" | "surface" | "container" | "component" | "unknown";
96
+ tag: string;
97
+ source: { file: string; line: number; column?: number; component: string };
98
+ fields: Array<{
99
+ property: string;
100
+ kind: "text" | "image" | "color" | "number" | "boolean" | "enum" | "array" | "unknown";
101
+ status: "bound" | "inspect-only";
102
+ provenance: "editorial" | "literal" | "expression";
103
+ reason: string;
104
+ binding?: string;
105
+ bindingSource?: string;
106
+ reference?: { path: string; pattern?: string; source: string; dynamic: boolean; unsafe?: boolean; conditions?: Array<Record<string,string>>; collection?: { path: string; key: string; contextual?: boolean } };
107
+ }>;
108
+ }
109
+ export function useStudioRepeatMetadata(): StudioRepeatMetadata | undefined;
110
+
111
+ export function defineBlocks<T extends Record<string, import("react").ComponentType<never>>>(blocks: T): Readonly<T>;
112
+ export function Layout<P extends CollectionPaths<StudioState>, Extra extends object = Record<never, never>>(props: {
113
+ path: P;
114
+ blocks: Record<string, ComponentType<RepeatItemProps & Extra>>;
115
+ itemProps?: Extra;
116
+ direction?: "vertical" | "horizontal" | "grid";
117
+ reveal?: (id: string) => void;
118
+ }): ReactNode;
119
+
120
+ /** Pass the actual std collection; no separately declared path is required. */
121
+ export function useStudioListItem<T extends { id: string }>(items: readonly T[], id: string, options?: { reveal?: () => void }): Readonly<T>;
122
+ export function useStudioCollection<T extends { id: string }>(items: readonly T[], options?: { reveal?: (id: string) => void }): readonly T[];
123
+ export function Repeat<Extra extends object = Record<never, never>>(props: {
124
+ items: readonly { id: string }[];
125
+ template: ComponentType<RepeatItemProps & Extra>;
126
+ direction?: "vertical" | "horizontal" | "grid";
127
+ itemProps?: Extra;
128
+ reveal?: (id: string) => void;
129
+ }): ReactNode;
130
+ export function useStudioRepeat<Extra extends object = Record<never, never>>(props: {
131
+ items: readonly { id: string }[];
132
+ template: ComponentType<RepeatItemProps & Extra>;
133
+ direction?: "vertical" | "horizontal" | "grid";
134
+ itemProps?: Extra;
135
+ reveal?: (id: string) => void;
136
+ }): ReactNode[];
137
+ export function Layout<Extra extends object = Record<never, never>>(props: {
138
+ items: readonly { id: string; type: string }[];
139
+ blocks: Record<string, ComponentType<RepeatItemProps & Extra>>;
140
+ itemProps?: Extra;
141
+ direction?: "vertical" | "horizontal" | "grid";
142
+ reveal?: (id: string) => void;
143
+ }): ReactNode;
144
+
145
+ export function useStudioBlock<K extends keyof StudioBlockTypes>(type: K): Readonly<StudioBlockTypes[K]>;
146
+ export function useStudioBoundMetadata<T extends StudioRepeatMetadata | undefined>(metadata: T): T;
147
+ export function Slot<Extra extends object = Record<never, never>>(props: {
148
+ name: string;
149
+ blocks: Record<string, ComponentType<RepeatItemProps & Extra>>;
150
+ itemProps?: Extra;
151
+ direction?: "vertical" | "horizontal" | "grid";
152
+ }): ReactNode;
package/react.js ADDED
@@ -0,0 +1,258 @@
1
+ import {
2
+ getStudioValue,
3
+ studioItemSegment,
4
+ validateStudioState,
5
+ subscribeStudioReveal,
6
+ matchesStudioConditions,
7
+ } from "./index.js";
8
+ import {
9
+ createContext,
10
+ createElement,
11
+ useContext,
12
+ useEffect,
13
+ useRef,
14
+ useMemo,
15
+ useId,
16
+ useSyncExternalStore,
17
+ } from "react";
18
+ const Context = createContext(undefined);
19
+ const StatusContext = createContext(undefined);
20
+ const noopRefresh = async () => {};
21
+ export function StudioProvider({ value, children, contract, status }) {
22
+ if (contract && value != null) {
23
+ const errors = validateStudioState(value, contract);
24
+ if (errors.length) throw new Error(errors.slice(0, 3).join(" "));
25
+ }
26
+ const current = {
27
+ ready: value != null,
28
+ refreshing: false,
29
+ hydrated: true,
30
+ source: "bundled",
31
+ revision: null,
32
+ error: null,
33
+ refresh: noopRefresh,
34
+ contractId: contract?.contractId,
35
+ ...status,
36
+ };
37
+ current.ready = current.ready && value != null;
38
+ return createElement(
39
+ StatusContext.Provider,
40
+ { value: current },
41
+ createElement(Context.Provider, { value }, children),
42
+ );
43
+ }
44
+ export function useStudioContext() {
45
+ const value = useContext(Context);
46
+ if (value == null)
47
+ throw new Error("useStudioContext must be used inside StudioProvider.");
48
+ return value;
49
+ }
50
+
51
+ export function useStudioStatus() {
52
+ const status = useContext(StatusContext);
53
+ if (!status)
54
+ throw new Error("useStudioStatus must be used inside StudioProvider.");
55
+ return status;
56
+ }
57
+ export function StudioDeliveryProvider({ client, contract, children }) {
58
+ const state = useSyncExternalStore(
59
+ client.subscribe,
60
+ client.getSnapshot,
61
+ client.getSnapshot,
62
+ );
63
+ useEffect(() => {
64
+ void client.start();
65
+ return () => client.stop();
66
+ }, [client]);
67
+ return createElement(
68
+ StudioProvider,
69
+ {
70
+ value: state.document,
71
+ contract,
72
+ status: { ...state, refresh: client.refresh },
73
+ },
74
+ children,
75
+ );
76
+ }
77
+
78
+ /** Explicit collection binding. Call in an item component, never inside a map callback. */
79
+ export function useStudioListItem(path, id, options) {
80
+ const instance = useContext(InstanceContext);
81
+ const revealPath = Array.isArray(path) ? (instance?.items === path ? instance.path : undefined) : path;
82
+ const state = useStudioContext();
83
+ const callback = useRef(options?.reveal);
84
+ callback.current = options?.reveal;
85
+ useEffect(() => {
86
+ const reveal = (event) => {
87
+ if (event.detail?.path === revealPath && event.detail?.id === id)
88
+ callback.current?.();
89
+ };
90
+ const unsubscribe = subscribeStudioReveal((detail) => reveal({ detail }));
91
+ const browser =
92
+ typeof window !== "undefined" &&
93
+ typeof window.addEventListener === "function";
94
+ if (browser) window.addEventListener("studio:reveal-item", reveal);
95
+ return () => {
96
+ unsubscribe();
97
+ if (browser) window.removeEventListener("studio:reveal-item", reveal);
98
+ };
99
+ }, [revealPath, id]);
100
+ const items = Array.isArray(path) ? path : getStudioValue(state, path);
101
+ studioItemSegment(id);
102
+ const matches = Array.isArray(items)
103
+ ? items.filter((item) => item?.id === id)
104
+ : [];
105
+ if (matches.length !== 1)
106
+ throw new Error(`Studio item ${path}/${id} must exist exactly once.`);
107
+ return matches[0];
108
+ }
109
+
110
+ /** Parent-level reveal support also works before a virtualized item is mounted. */
111
+ export function useStudioCollection(path, options) {
112
+ const state = useStudioContext();
113
+ const callback = useRef(options?.reveal);
114
+ callback.current = options?.reveal;
115
+ useEffect(() => {
116
+ const reveal = (event) => {
117
+ if (event.detail?.path === (options?.path ?? path) && typeof event.detail?.id === "string")
118
+ callback.current?.(event.detail.id);
119
+ };
120
+ const unsubscribe = subscribeStudioReveal((detail) => reveal({ detail }));
121
+ const browser =
122
+ typeof window !== "undefined" &&
123
+ typeof window.addEventListener === "function";
124
+ if (browser) window.addEventListener("studio:reveal-item", reveal);
125
+ return () => {
126
+ unsubscribe();
127
+ if (browser) window.removeEventListener("studio:reveal-item", reveal);
128
+ };
129
+ }, [path, options?.path]);
130
+ const items = Array.isArray(path) ? path : getStudioValue(state, path);
131
+ if (!Array.isArray(items))
132
+ throw new Error(`Studio collection ${path} must be an array.`);
133
+ return items;
134
+ }
135
+
136
+ const InstanceContext = createContext(undefined);
137
+ /** Headless region: the parent owns native/web layout; direction declares insertion semantics. */
138
+ export function useStudioRepeat({
139
+ path: explicitPath,
140
+ items: providedItems,
141
+ __studioPath,
142
+ template,
143
+ direction = "vertical",
144
+ itemProps,
145
+ reveal,
146
+ __studioRepeat,
147
+ }) {
148
+ const parent = useContext(InstanceContext);
149
+ const document = useContext(Context);
150
+ const path = explicitPath ?? __studioPath ?? __studioRepeat?.collectionPath;
151
+ const items = useStudioCollection(providedItems ?? path, { reveal, path });
152
+ const regionId = useId();
153
+ const metadataCache = useRef({ source: __studioRepeat, items: new Map() });
154
+ if (metadataCache.current.source !== __studioRepeat)
155
+ metadataCache.current = { source: __studioRepeat, items: new Map() };
156
+ const live = new Set(items.map(item => studioItemSegment(item.id)));
157
+ for (const key of metadataCache.current.items.keys()) if (!live.has(key)) metadataCache.current.items.delete(key);
158
+ if (!["vertical", "horizontal", "grid"].includes(direction))
159
+ throw new Error("Invalid Studio Repeat direction.");
160
+ const seen = new Set();
161
+ return items.map((item, index) => {
162
+ const segment = studioItemSegment(item.id);
163
+ if (seen.has(item.id))
164
+ throw new Error(`Duplicate Studio item id in ${path}.`);
165
+ seen.add(item.id);
166
+ const instanceId = `studio:${path}:${segment}:${regionId}`;
167
+ const signature = JSON.stringify([...(parent?.chain ?? []), [path, item.id, item.type]]);
168
+ const scope = { path, id: item.id, item, items, signature, chain: [...(parent?.chain ?? []), [path, item.id, item.type]] };
169
+ const cached = metadataCache.current.items.get(segment);
170
+ let metadata = cached?.signature === signature ? cached.metadata : undefined;
171
+ if (__studioRepeat && !metadata) {
172
+ metadata = bindMetadata(__studioRepeat, scope, document);
173
+ metadataCache.current.items.set(segment, { signature, metadata });
174
+ }
175
+ return createElement(
176
+ InstanceContext.Provider,
177
+ { key: segment, value: { ...scope, instanceId, metadata } },
178
+ createElement(template, { ...itemProps, id: item.id, index, instanceId }),
179
+ );
180
+ });
181
+ }
182
+ export function useStudioInstanceId(suffix = "root") {
183
+ const instance = useContext(InstanceContext);
184
+ if (!instance)
185
+ throw new Error("useStudioInstanceId requires a Studio Repeat item.");
186
+ return `${instance.instanceId}:${studioItemSegment(suffix)}`;
187
+ }
188
+
189
+ /** Component form for containers that do not inspect their direct React children. */
190
+ export function Repeat(props) {
191
+ return useStudioRepeat(props);
192
+ }
193
+
194
+ /** Host adapters consume this to register the current Repeat item's bounds. */
195
+ export function useStudioRepeatMetadata() {
196
+ return useContext(InstanceContext)?.metadata;
197
+ }
198
+
199
+ /** Registration stays in application code; content can choose only these types. */
200
+ export function defineBlocks(blocks) {
201
+ if (!blocks || typeof blocks !== "object" || !Object.keys(blocks).length)
202
+ throw new Error("Register at least one layout block.");
203
+ return Object.freeze({ ...blocks });
204
+ }
205
+ function LayoutItem({ __layout, ...props }) {
206
+ const item = useStudioListItem(__layout.items ?? __layout.path, props.id);
207
+ const Template = Object.hasOwn(__layout.blocks, item.type) && __layout.blocks[item.type];
208
+ if (!Template) throw new Error(`Unsupported Studio block type: ${item.type}`);
209
+ return createElement(Template, props);
210
+ }
211
+ /** Ordered heterogeneous blocks with Repeat's identity and inspection semantics. */
212
+ export function Layout({ blocks, itemProps, ...props }) {
213
+ return useStudioRepeat({ ...props, template: LayoutItem,
214
+ itemProps: { ...itemProps, __layout: { path: props.path, items: props.items, blocks } },
215
+ });
216
+ }
217
+
218
+ /** A type-scoped item reader; the compiler supplies its collection and schema. */
219
+ export function useStudioBlock(type) {
220
+ const scope = useContext(InstanceContext);
221
+ if (!scope || scope.item?.type !== type) throw new Error(`Studio block ${type} requires its matching Layout item.`);
222
+ return scope.item;
223
+ }
224
+ function bindMetadata(metadata, scope, document) {
225
+ if (!metadata || !scope?.path) return metadata;
226
+ if (metadata.candidates) {
227
+ for (const candidate of metadata.candidates) {
228
+ const bound = bindMetadata(candidate, scope, document);
229
+ if (bound.fields.some(field => field.reference?.collection && field.status === "bound")) return bound;
230
+ }
231
+ }
232
+ return { ...metadata, fields: metadata.fields.flatMap(field => {
233
+ const pattern = scope.path.split(".").map(part => part.startsWith("@") ? "*" : part).join(".");
234
+ const collection = field.reference?.collection ?? (field.reference?.path.startsWith(pattern + ".*.") ? {path:pattern} : undefined);
235
+ if (!collection) return [field];
236
+ const schemaPath = field.reference.pattern ?? field.reference.path;
237
+ const prefix = schemaPath.startsWith(collection.path + ".") ? collection.path : scope.path;
238
+ const suffix = schemaPath.slice(prefix.length + 1).split(".").slice(1).join(".");
239
+ const binding = `${scope.path}.${studioItemSegment(scope.id)}.${suffix}`;
240
+ // Only resources belonging to this rendered instance can bind here.
241
+ if (pattern !== collection.path || !matchesStudioConditions(document, binding, field.reference.conditions)) return [];
242
+ return [{ ...field, binding, reference: { ...field.reference, path: binding, pattern: schemaPath } }];
243
+ }) };
244
+ }
245
+ /** Shared host adapters use this for nested/contextual compiler metadata. */
246
+ export function useStudioBoundMetadata(metadata) {
247
+ const scope = useContext(InstanceContext);
248
+ const document = useContext(Context);
249
+ return useMemo(() => bindMetadata(metadata, scope, document), [metadata, scope?.signature]);
250
+ }
251
+ /** A named, typed region owned by the current layout item. */
252
+ export function Slot({ name, blocks, itemProps, ...props }) {
253
+ const owner = useContext(InstanceContext);
254
+ if (!owner || !/^[A-Za-z_$][\w$]*$/.test(name) || ["__proto__", "constructor", "prototype"].includes(name))
255
+ throw new Error("Studio Slot requires a Layout item and a safe name.");
256
+ const path = `${owner.path ?? "items"}.${studioItemSegment(owner.id)}.slots.${name}`;
257
+ return createElement(Layout, { ...props, path, items: owner.item.slots?.[name] ?? [], blocks, itemProps });
258
+ }