@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/delivery.d.ts ADDED
@@ -0,0 +1,47 @@
1
+ import { type ContentContract } from "./delivery-contract.js";
2
+ export interface ContentStorage {
3
+ getItem(key: string): Promise<string | null>;
4
+ setItem(key: string, value: string): Promise<void>;
5
+ }
6
+ export interface DeliveryState<D> {
7
+ phase?: "hydrating" | "fetching" | "preparing" | "idle";
8
+ channel?: "draft" | "published" | "unknown";
9
+ checkedAt?: number | null;
10
+ document: D | null;
11
+ ready: boolean;
12
+ source: "none" | "bundled" | "cache" | "remote" | "development" | "published";
13
+ revision: string | null;
14
+ hydrated: boolean;
15
+ refreshing: boolean;
16
+ error: string | null;
17
+ }
18
+ export interface DeliveryOptions<D> extends ContentContract<D> {
19
+ endpoint?: string;
20
+ initialContent?: D;
21
+ mode?: "development" | "production";
22
+ storage: ContentStorage;
23
+ allowHttp?: boolean;
24
+ timeoutMs?: number;
25
+ maxCharacters?: number;
26
+ fetch?: typeof fetch;
27
+ /** Resolve/cache required media before a newly downloaded revision is activated. */
28
+ prepare?: (document: D, signal: AbortSignal) => Promise<void>;
29
+ }
30
+ export declare function createContentClient<D>(options: DeliveryOptions<D>): {
31
+ cacheKey: string;
32
+ getSnapshot: () => DeliveryState<D>;
33
+ subscribe(listener: () => void): () => void;
34
+ start(): Promise<void>;
35
+ refresh(): Promise<void>;
36
+ stop(): void;
37
+ };
38
+
39
+ export type ContentClient<D> = ReturnType<typeof createContentClient<D>>;
40
+
41
+ export { validateRevision } from "./delivery-contract.js";
42
+ export type { ContentContract, ContentRevision } from "./delivery-contract.js";
43
+
44
+ export function studioDevelopmentEndpoint(
45
+ link: string | null,
46
+ development: boolean,
47
+ ): string | null | undefined;
package/delivery.js ADDED
@@ -0,0 +1,256 @@
1
+ import { validateRevision } from "./delivery-contract.js";
2
+
3
+ export function createContentClient(options) {
4
+ if (
5
+ !/^[a-z0-9][a-z0-9-]*$/.test(options.projectId) ||
6
+ !Number.isSafeInteger(options.schemaVersion) ||
7
+ options.schemaVersion < 1 ||
8
+ ((options.initialContent ?? options.defaults) !== undefined &&
9
+ options.validate(options.initialContent ?? options.defaults).length)
10
+ )
11
+ throw new Error("Invalid content delivery configuration.");
12
+ if (
13
+ options.contractId !== undefined &&
14
+ !/^[a-f0-9]{64}$/.test(options.contractId)
15
+ )
16
+ throw new Error("Invalid Studio contract identity.");
17
+ const initial = options.initialContent ?? options.defaults;
18
+ const endpoint = options.endpoint ? new URL(options.endpoint) : null;
19
+ if (
20
+ endpoint &&
21
+ (endpoint.username ||
22
+ endpoint.password ||
23
+ endpoint.hash ||
24
+ (endpoint.protocol !== "https:" &&
25
+ !(options.allowHttp && endpoint.protocol === "http:")))
26
+ )
27
+ throw new Error(
28
+ "Content delivery requires a credential-free HTTPS endpoint.",
29
+ );
30
+ if (endpoint) {
31
+ endpoint.searchParams.set("projectId", options.projectId);
32
+ if (options.contractId)
33
+ endpoint.searchParams.set("contractId", options.contractId);
34
+ }
35
+ const cacheKey = `studio:content:${options.projectId}:${options.schemaVersion}:${options.contractId ?? "legacy"}:${options.mode ?? "remote"}:${endpoint?.href ?? "bundled"}`;
36
+ const max = options.maxCharacters ?? 4_000_000;
37
+ let state = {
38
+ phase: endpoint ? "hydrating" : "idle",
39
+ channel: options.mode === "development" ? "draft" : options.mode === "production" ? "published" : "unknown",
40
+ checkedAt: null,
41
+ document: initial ?? null,
42
+ ready: initial !== undefined,
43
+ source: initial === undefined ? "none" : "bundled",
44
+ revision: null,
45
+ hydrated: !endpoint,
46
+ refreshing: false,
47
+ error:
48
+ !endpoint && initial === undefined
49
+ ? "No Studio content endpoint configured."
50
+ : null,
51
+ };
52
+ const listeners = new Set();
53
+ let generation = 0,
54
+ active = null;
55
+ let starting = null,
56
+ refreshing = null;
57
+ const emit = (patch) => {
58
+ state = { ...state, ...patch };
59
+ for (const listener of listeners) listener();
60
+ };
61
+ const decode = (raw) => {
62
+ if (raw.length > max)
63
+ throw new Error("Content revision exceeds the size limit.");
64
+ return validateRevision(JSON.parse(raw), options);
65
+ };
66
+ const bounded = (task, signal) =>
67
+ new Promise((resolve, reject) => {
68
+ const abort = () =>
69
+ reject(new Error("Content request timed out or was cancelled."));
70
+ if (signal.aborted) {
71
+ abort();
72
+ return;
73
+ }
74
+ signal.addEventListener("abort", abort, { once: true });
75
+ task
76
+ .then(resolve, reject)
77
+ .finally(() => signal.removeEventListener("abort", abort));
78
+ });
79
+ async function request(epoch) {
80
+ if (!endpoint || epoch !== generation) return;
81
+ const controller = new AbortController();
82
+ active = controller;
83
+ const timer = setTimeout(
84
+ () => controller.abort(),
85
+ options.timeoutMs ?? 8000,
86
+ );
87
+ emit({ refreshing: true, phase: "fetching", error: null });
88
+ try {
89
+ const response = await bounded(
90
+ (options.fetch ?? fetch)(endpoint.href, {
91
+ signal: controller.signal,
92
+ headers: state.revision
93
+ ? { "If-None-Match": `"${state.revision}"` }
94
+ : {},
95
+ cache: "no-cache",
96
+ }),
97
+ controller.signal,
98
+ );
99
+ if (response.status === 304) {
100
+ if (!state.revision)
101
+ throw new Error(
102
+ "Server returned unchanged content without a local revision.",
103
+ );
104
+ return;
105
+ }
106
+ if (!response.ok)
107
+ throw new Error(`Content service returned ${response.status}.`);
108
+ const raw = await bounded(response.text(), controller.signal);
109
+ const revision = decode(raw);
110
+ // Never compare revision ordering: selecting an older immutable revision is a valid rollback.
111
+ if (revision.revision === state.revision) return;
112
+ if (options.prepare) {
113
+ emit({ phase: "preparing" });
114
+ await bounded(
115
+ options.prepare(revision.document, controller.signal),
116
+ controller.signal,
117
+ );
118
+ }
119
+ if (controller.signal.aborted || epoch !== generation) return;
120
+ let storageError = null;
121
+ try {
122
+ await bounded(
123
+ options.storage.setItem(cacheKey, JSON.stringify(revision)),
124
+ controller.signal,
125
+ );
126
+ } catch {
127
+ storageError = "Content updated, but offline storage is unavailable.";
128
+ }
129
+ if (controller.signal.aborted || epoch !== generation) return;
130
+ emit({
131
+ document: revision.document,
132
+ ready: true,
133
+ revision: revision.revision,
134
+ source:
135
+ options.mode === "development"
136
+ ? "development"
137
+ : options.mode === "production"
138
+ ? "published"
139
+ : "remote",
140
+ error: storageError,
141
+ });
142
+ } catch (error) {
143
+ if (epoch === generation)
144
+ emit({
145
+ error:
146
+ error instanceof Error ? error.message : "Content refresh failed.",
147
+ });
148
+ } finally {
149
+ clearTimeout(timer);
150
+ if (active === controller) active = null;
151
+ if (epoch === generation) emit({ hydrated: true, refreshing: false, phase: "idle", checkedAt: Date.now() });
152
+ }
153
+ }
154
+ async function hydrate(epoch) {
155
+ if (!endpoint) return;
156
+ try {
157
+ // Slow/broken storage must not prevent the network request or bundled fallback.
158
+ const controller = new AbortController();
159
+ const timer = setTimeout(
160
+ () => controller.abort(),
161
+ options.timeoutMs ?? 8000,
162
+ );
163
+ let raw;
164
+ try {
165
+ raw = await bounded(
166
+ options.storage.getItem(cacheKey),
167
+ controller.signal,
168
+ );
169
+ } finally {
170
+ clearTimeout(timer);
171
+ }
172
+ if (raw) {
173
+ const revision = decode(raw);
174
+ if (epoch === generation)
175
+ emit({
176
+ document: revision.document,
177
+ ready: true,
178
+ source: "cache",
179
+ revision: revision.revision,
180
+ hydrated: true,
181
+ });
182
+ }
183
+ } catch {
184
+ /* A corrupt/foreign/unavailable cache never replaces bundled defaults. */
185
+ }
186
+ if (epoch === generation) await request(epoch);
187
+ }
188
+ return {
189
+ cacheKey,
190
+ getSnapshot: () => state,
191
+ subscribe(listener) {
192
+ listeners.add(listener);
193
+ return () => {
194
+ listeners.delete(listener);
195
+ };
196
+ },
197
+ start() {
198
+ if (!starting) {
199
+ const epoch = generation;
200
+ starting = hydrate(epoch);
201
+ }
202
+ return starting;
203
+ },
204
+ async refresh() {
205
+ if (!endpoint) return;
206
+ const epoch = generation;
207
+ if (!starting) {
208
+ starting = hydrate(epoch);
209
+ await starting;
210
+ return;
211
+ }
212
+ if (!state.hydrated || (state.refreshing && !refreshing)) {
213
+ await starting;
214
+ return;
215
+ }
216
+ if (epoch !== generation) return;
217
+ if (!refreshing)
218
+ refreshing = request(epoch).finally(() => {
219
+ if (epoch === generation) refreshing = null;
220
+ });
221
+ await refreshing;
222
+ },
223
+ stop() {
224
+ generation++;
225
+ active?.abort();
226
+ active = null;
227
+ starting = null;
228
+ refreshing = null;
229
+ emit({ refreshing: false, phase: "idle" });
230
+ },
231
+ };
232
+ }
233
+
234
+ export { validateRevision } from "./delivery-contract.js";
235
+
236
+ export function studioDevelopmentEndpoint(link, development) {
237
+ if (!development || !link) return;
238
+ try {
239
+ const endpoint = new URL(link).searchParams.get("studioDev");
240
+ if (endpoint === "off") return null;
241
+ if (!endpoint || endpoint.length > 1500) return;
242
+ const url = new URL(endpoint);
243
+ if (
244
+ !["http:", "https:"].includes(url.protocol) ||
245
+ url.username ||
246
+ url.password ||
247
+ url.hash ||
248
+ url.search ||
249
+ !/^\/__studio\/dev\/[a-f0-9]{48}\/content\.json$/.test(url.pathname)
250
+ )
251
+ return;
252
+ return url.href;
253
+ } catch {
254
+ return;
255
+ }
256
+ }
package/index.d.ts ADDED
@@ -0,0 +1,102 @@
1
+ export interface StudioField {
2
+ path: string;
3
+ type: "text" | "image" | "color" | "number" | "boolean" | "enum" | "array";
4
+ optional?: boolean;
5
+ conditions?: Array<Record<string,string>>;
6
+ itemType?: "string" | "number" | "boolean";
7
+ min?: number;
8
+ max?: number;
9
+ maxLength?: number;
10
+ values?: (string | number)[];
11
+ }
12
+ export interface StudioContract {
13
+ contractId?: string;
14
+ fields: StudioField[];
15
+ collections?: Record<
16
+ string,
17
+ {
18
+ template?: { id: string; [key: string]: unknown };
19
+ minItems?: number;
20
+ maxItems?: number;
21
+ direction?: "vertical" | "horizontal" | "grid";
22
+ conditions?: Array<Record<string,string>>;
23
+ cases?: Array<{types:string[];conditions?:Array<Record<string,string>>}>;
24
+ ownedCollections?: string[];
25
+ variants?: Record<string, { label?: string; template: { id: string; type: string; [key: string]: unknown } }>;
26
+ }
27
+ >;
28
+ }
29
+ export function defineStudioDefaults<T>(value: T): T;
30
+ export function getStudioValue(value: unknown, path: string): unknown;
31
+ export function validateStudioState(
32
+ value: unknown,
33
+ contract: StudioContract,
34
+ ): string[];
35
+
36
+ export function studioItemSegment(id: string): string;
37
+ export function expandStudioPath(document: unknown, pattern: string): string[];
38
+
39
+ export function duplicateStudioItem(
40
+ document: unknown,
41
+ path: string,
42
+ id: string,
43
+ options?: {
44
+ position?: "before" | "after";
45
+ maxItems?: number;
46
+ ownedCollections?: string[];
47
+ createId?: () => string;
48
+ },
49
+ ): string;
50
+
51
+ export function createStudioItem<T extends { id: string }>(
52
+ template: T,
53
+ existing?: unknown[],
54
+ options?: { ownedCollections?: string[]; createId?: () => string },
55
+ ): T;
56
+ export function mergeStudioDefaults<T>(base: T, authored: unknown): T;
57
+
58
+ export function subscribeStudioReveal(
59
+ listener: (target: { path: string; id: string }) => void,
60
+ ): () => boolean;
61
+ export function requestStudioItemReveal(path: string, id: string): void;
62
+
63
+ export function moveStudioItem(document: unknown, path: string, id: string, offset: -1 | 1): boolean;
64
+ export function removeStudioItem(document: unknown, path: string, id: string, options?: { minItems?: number }): void;
65
+
66
+ export interface StudioBlockCollection {
67
+ conditions?: Array<Record<string,string>>;
68
+ cases?: Array<{types:string[];conditions?:Array<Record<string,string>>}>;
69
+ variants?: Record<string, { label?: string; template: { id: string; type: string; [key: string]: unknown } }>;
70
+ maxItems?: number;
71
+ ownedCollections?: string[];
72
+ }
73
+ export function insertStudioBlock(document: unknown, path: string, type: string, definition: StudioBlockCollection, options?: {
74
+ relativeTo?: string;
75
+ position?: "before" | "after" | "replace";
76
+ createId?: () => string;
77
+ }): string;
78
+
79
+ export function matchesStudioConditions(document: unknown, concretePath: string, conditions?: Array<Record<string,string>>): boolean;
80
+ export function studioCollectionPattern(path: string): string;
81
+ export function studioBindingAncestors(path: string): Array<{path:string;id:string}>;
82
+
83
+ export function studioCollectionDefinition(document: unknown, definitions: StudioContract["collections"], path: string): NonNullable<StudioContract["collections"]>[string] | undefined;
84
+
85
+ export interface StudioPreset {
86
+ protocolVersion: 1;
87
+ id: string;
88
+ projectId: string;
89
+ contractId: string;
90
+ name: string;
91
+ createdAt: string;
92
+ sourcePath: string;
93
+ item: { id: string; [key: string]: unknown };
94
+ }
95
+ export function createStudioPreset(document: unknown, path: string, id: string, options: {projectId:string;contract:StudioContract;name:string}): StudioPreset;
96
+ export function insertStudioPreset(document: unknown,path: string,preset: StudioPreset,options:{projectId:string;contract:StudioContract;relativeTo?:string;position?:"before"|"after"|"replace";createId?:()=>string}): string;
97
+
98
+ export interface StudioMigrationMapping {from:string;to:string;conversion?:"copy"|"text"|"number"|"boolean";}
99
+ export interface StudioContractChange {scope:"field"|"collection";key:string;path:string;kind:"added"|"removed"|"changed";before?:unknown;after?:unknown;}
100
+ export function studioMigrationFieldKey(field:Pick<StudioField,"path"|"conditions">):string;
101
+ export function compareStudioContracts(previous:StudioContract,next:StudioContract):StudioContractChange[];
102
+ export function previewStudioMigration(source:unknown,document:unknown,previous:StudioContract,next:StudioContract,mappings?:StudioMigrationMapping[]):{document:unknown;changes:Array<{from:string;to:string;before:unknown;after:unknown}>;errors:string[];valid:boolean;};