@sanity/workbench 0.1.0-alpha.1 → 0.1.0-alpha.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,91 @@
1
+ import type { CanvasResource as ProtocolCanvasResource } from "@sanity/message-protocol";
2
+ import { z } from "zod";
3
+
4
+ import { AbstractApplication } from "./applications/application";
5
+ import { OrganizationId } from "./organizations";
6
+
7
+ /**
8
+ * Canvas ID schema, branded for type safety.
9
+ * @public
10
+ */
11
+ const CanvasId = z.string().nonempty().brand("CanvasId");
12
+
13
+ /**
14
+ * Canvas ID type, branded for type safety.
15
+ * @public
16
+ */
17
+ export type CanvasId = z.output<typeof CanvasId>;
18
+
19
+ /**
20
+ * Validates and brands a string as a CanvasId.
21
+ * @public
22
+ */
23
+ export function brandCanvasId(id: string): CanvasId {
24
+ return CanvasId.parse(id);
25
+ }
26
+
27
+ const Canvas = z.object({
28
+ id: CanvasId,
29
+ organizationId: OrganizationId,
30
+ status: z.enum(["active", "provisioning"]),
31
+ });
32
+
33
+ /**
34
+ * Represents a Canvas resource as returned from the API.
35
+ * @public
36
+ */
37
+ export type Canvas = z.output<typeof Canvas>;
38
+
39
+ /**
40
+ * Validates and parses a raw API response into a branded
41
+ * Canvas.
42
+ * @public
43
+ */
44
+ export function parseCanvas(data: unknown): Canvas {
45
+ return Canvas.parse(data);
46
+ }
47
+
48
+ /**
49
+ * Whilst the constructor takes an organization's canvas resource the existance
50
+ * therefore implies the organization has access to the canvas application which
51
+ * is what workbench most importantly wants to know.
52
+ * @public
53
+ */
54
+ export class CanvasApplication extends AbstractApplication<"canvas"> {
55
+ readonly canvas: Canvas;
56
+
57
+ constructor(canvas: Canvas) {
58
+ super("canvas");
59
+
60
+ this.canvas = canvas;
61
+ }
62
+
63
+ get id(): CanvasId {
64
+ return this.canvas.id;
65
+ }
66
+
67
+ get href(): string {
68
+ return "canvas";
69
+ }
70
+
71
+ get title(): string {
72
+ return "Canvas";
73
+ }
74
+
75
+ get url(): URL {
76
+ return new URL(
77
+ `https://canvas.${import.meta.env.VITE_SANITY_DOMAIN}/o/${this.canvas.organizationId}`,
78
+ );
79
+ }
80
+
81
+ get isFederated(): boolean {
82
+ return false;
83
+ }
84
+
85
+ toProtocolResource(): ProtocolCanvasResource {
86
+ return {
87
+ type: "canvas",
88
+ id: this.id,
89
+ } satisfies ProtocolCanvasResource;
90
+ }
91
+ }
@@ -0,0 +1,34 @@
1
+ import type { OrganizationId } from "./organizations";
2
+
3
+ /**
4
+ * Workbench configuration.
5
+ *
6
+ * @public
7
+ */
8
+ interface Config {
9
+ /**
10
+ * The organization ID to use when rendering the workbench.
11
+ */
12
+ organizationId?: string;
13
+ }
14
+
15
+ /**
16
+ * The resolved configuration after processing
17
+ * and validating the provided config.
18
+ *
19
+ * @public
20
+ */
21
+ type ResolvedConfig = Omit<Config, "organizationId"> & {
22
+ organizationId: OrganizationId;
23
+ };
24
+
25
+ /**
26
+ * Options for rendering a remote module, such as a user application.
27
+ *
28
+ * @public
29
+ */
30
+ interface RemoteModuleRenderOptions {
31
+ reactStrictMode?: boolean;
32
+ }
33
+
34
+ export type { Config, ResolvedConfig, RemoteModuleRenderOptions };
@@ -0,0 +1,12 @@
1
+ export * from "./applications/application";
2
+ export * from "./applications/application-list";
3
+ export * from "./canvases";
4
+ export * from "./config";
5
+ export * from "./log";
6
+ export * from "./media-libraries";
7
+ export * from "./organizations";
8
+ export * from "./projects";
9
+ export { joinUrlPaths } from "./shared/urls";
10
+ export * from "./user-applications/core-app";
11
+ export * from "./user-applications/studios";
12
+ export * from "./user-applications/user-application";
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Log levels in order of verbosity (least to most)
3
+ * - none: Silent
4
+ * - error: Critical failures that prevent operation
5
+ * - warn: Issues that may cause problems but don't stop execution
6
+ * - info: High-level informational messages (default)
7
+ * - debug: Detailed debugging information (maintainer level)
8
+ * - trace: Very detailed tracing — sets `internal: true` on context
9
+ * @public
10
+ */
11
+ export type LogLevel = "none" | "error" | "warn" | "info" | "debug";
12
+
13
+ /**
14
+ * Namespaces organize logs by functional domain.
15
+ * @internal
16
+ */
17
+ export type LogNamespace = string;
18
+
19
+ type LogContext = { [key: string]: unknown };
20
+
21
+ /**
22
+ * @public
23
+ */
24
+ export interface Logger {
25
+ error: (message: string, context?: LogContext) => void;
26
+ warn: (message: string, context?: LogContext) => void;
27
+ info: (message: string, context?: LogContext) => void;
28
+ debug: (message: string, context?: LogContext) => void;
29
+ }
30
+
31
+ const LEVELS: readonly LogLevel[] = ["none", "error", "warn", "info", "debug"];
32
+
33
+ interface LoggerOptions {
34
+ namespace?: LogNamespace;
35
+ context?: LogContext;
36
+ logLevel?: LogLevel;
37
+ }
38
+
39
+ /**
40
+ * @public
41
+ */
42
+ export function createLogger({
43
+ namespace,
44
+ context: baseContext,
45
+ logLevel = "info",
46
+ }: LoggerOptions = {}): Logger {
47
+ function isLevelEnabled(level: LogLevel): boolean {
48
+ return LEVELS.indexOf(level) <= LEVELS.indexOf(logLevel);
49
+ }
50
+
51
+ function logAtLevel(
52
+ level: LogLevel,
53
+ message: string,
54
+ context?: LogContext,
55
+ ): void {
56
+ if (!isLevelEnabled(level)) return;
57
+
58
+ const merged =
59
+ (baseContext ?? context) ? { ...baseContext, ...context } : undefined;
60
+ const args: unknown[] = [
61
+ ...(namespace ? [`[${namespace}]`] : []),
62
+ message,
63
+ ...(merged ? [merged] : []),
64
+ ];
65
+
66
+ if (level === "error") console.error(...args);
67
+ else if (level === "warn") console.warn(...args);
68
+ // oxlint-disable-next-line no-console
69
+ else if (level === "info") console.info(...args);
70
+ // oxlint-disable-next-line no-console
71
+ else console.debug(...args);
72
+ }
73
+
74
+ return {
75
+ error: (message, context) => logAtLevel("error", message, context),
76
+ warn: (message, context) => logAtLevel("warn", message, context),
77
+ info: (message, context) => logAtLevel("info", message, context),
78
+ debug: (message, context) => logAtLevel("debug", message, context),
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Shared workbench logger instance. Use this from both the workbench host
84
+ * and its remotes so lifecycle and diagnostic logs appear under a single
85
+ * namespace.
86
+ *
87
+ * @public
88
+ */
89
+ export const logger: Logger = createLogger({
90
+ namespace: "sanity-workbench",
91
+ logLevel: "debug",
92
+ });
@@ -0,0 +1,92 @@
1
+ import type { MediaResource as ProtocolMediaResource } from "@sanity/message-protocol";
2
+ import { z } from "zod";
3
+
4
+ import { AbstractApplication } from "./applications/application";
5
+ import { OrganizationId } from "./organizations";
6
+
7
+ /**
8
+ * Canvas ID schema, branded for type safety.
9
+ * @public
10
+ */
11
+ const MediaLibraryId = z.string().nonempty().brand("MediaLibraryId");
12
+
13
+ /**
14
+ * MediaLibrary ID type, branded for type safety.
15
+ * @public
16
+ */
17
+ export type MediaLibraryId = z.output<typeof MediaLibraryId>;
18
+
19
+ /**
20
+ * Validates and brands a string as a MediaLibraryId.
21
+ * @public
22
+ */
23
+ export function brandMediaLibraryId(id: string): MediaLibraryId {
24
+ return MediaLibraryId.parse(id);
25
+ }
26
+
27
+ const MediaLibrary = z.object({
28
+ id: MediaLibraryId,
29
+ organizationId: OrganizationId,
30
+ status: z.enum(["active", "provisioning"]),
31
+ aclMode: z.enum(["private", "public"]),
32
+ });
33
+
34
+ /**
35
+ * Represents a MediaLibrary resource as returned from the API.
36
+ * @public
37
+ */
38
+ export type MediaLibrary = z.output<typeof MediaLibrary>;
39
+
40
+ /**
41
+ * Validates and parses a raw API response into a branded
42
+ * MediaLibrary.
43
+ * @public
44
+ */
45
+ export function parseMediaLibrary(data: unknown): MediaLibrary {
46
+ return MediaLibrary.parse(data);
47
+ }
48
+
49
+ /**
50
+ * Whilst the constructor takes an organization's media library resource the existance
51
+ * therefore implies the organization has access to the media library application which
52
+ * is what workbench most importantly wants to know.
53
+ * @public
54
+ */
55
+ export class MediaLibraryApplication extends AbstractApplication<"media-library"> {
56
+ readonly library: MediaLibrary;
57
+
58
+ constructor(library: MediaLibrary) {
59
+ super("media-library");
60
+
61
+ this.library = library;
62
+ }
63
+
64
+ get id(): string {
65
+ return this.library.id;
66
+ }
67
+
68
+ get href(): string {
69
+ return "media";
70
+ }
71
+
72
+ get title(): string {
73
+ return "Media Library";
74
+ }
75
+
76
+ get isFederated(): boolean {
77
+ return false;
78
+ }
79
+
80
+ get url(): URL {
81
+ return new URL(
82
+ `https://media.${import.meta.env.VITE_SANITY_DOMAIN}/${this.library.organizationId}`,
83
+ );
84
+ }
85
+
86
+ toProtocolResource(): ProtocolMediaResource {
87
+ return {
88
+ type: "media-library",
89
+ id: this.id,
90
+ } satisfies ProtocolMediaResource;
91
+ }
92
+ }
@@ -0,0 +1,115 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * Organization ID schema, branded for type safety.
5
+ * @public
6
+ */
7
+ export const OrganizationId = z.string().nonempty().brand("OrganizationId");
8
+
9
+ /**
10
+ * Organization ID type, branded for type safety.
11
+ * @public
12
+ */
13
+ export type OrganizationId = z.output<typeof OrganizationId>;
14
+
15
+ /**
16
+ * Validates and brands a string as an OrganizationId.
17
+ * @public
18
+ */
19
+ export function brandOrganizationId(id: string): OrganizationId {
20
+ return OrganizationId.parse(id);
21
+ }
22
+
23
+ const OrganizationMember = z.object({
24
+ sanityUserId: z.string(),
25
+ isCurrentUser: z.boolean(),
26
+ user: z.object({
27
+ id: z.string(),
28
+ displayName: z.string(),
29
+ familyName: z.string(),
30
+ givenName: z.string(),
31
+ middleName: z.string().nullable(),
32
+ imageUrl: z.string().nullable(),
33
+ email: z.string(),
34
+ loginProvider: z.string(),
35
+ }),
36
+ roles: z.array(
37
+ z.object({
38
+ name: z.string(),
39
+ title: z.string(),
40
+ description: z.string().optional(),
41
+ }),
42
+ ),
43
+ });
44
+
45
+ /**
46
+ * @public
47
+ */
48
+ export type OrganizationMember = z.output<typeof OrganizationMember>;
49
+
50
+ /**
51
+ * Organization schema — validates and brands API responses
52
+ * from the `/organizations/:id` endpoint.
53
+ * @public
54
+ */
55
+ export const Organization = z.object({
56
+ id: OrganizationId,
57
+ name: z.string(),
58
+ slug: z.string().nullable(),
59
+ createdAt: z.string(),
60
+ updatedAt: z.string(),
61
+ dashboardStatus: z.enum(["enabled", "disabled"]),
62
+ aiFeaturesStatus: z.enum(["enabled", "disabled"]),
63
+ defaultRoleName: z.string(),
64
+ });
65
+
66
+ /**
67
+ * Represents an organization with optional members and
68
+ * features arrays depending on the generic parameters.
69
+ * - `Organization` — base fields only (default)
70
+ * - `Organization<true>` — includes `members`
71
+ * - `Organization<true, true>` — includes both
72
+ * @public
73
+ */
74
+ export type Organization<
75
+ IncludeMembers extends boolean = true,
76
+ IncludeFeatures extends boolean = true,
77
+ > = z.output<typeof Organization> &
78
+ (IncludeMembers extends true ? { members: OrganizationMember[] } : unknown) &
79
+ (IncludeFeatures extends true ? { features: string[] } : unknown);
80
+
81
+ /**
82
+ * Validates and parses a raw API response into a branded
83
+ * Organization. The options control which schema is used —
84
+ * matching what the API returns based on query params.
85
+ * @public
86
+ */
87
+ export function parseOrganization<
88
+ IncludeMembers extends boolean = true,
89
+ IncludeFeatures extends boolean = true,
90
+ >(
91
+ data: unknown,
92
+ options?: {
93
+ includeMembers?: IncludeMembers;
94
+ includeFeatures?: IncludeFeatures;
95
+ },
96
+ ): Organization<IncludeMembers, IncludeFeatures> {
97
+ const includeMembers = options?.includeMembers ?? true;
98
+ const includeFeatures = options?.includeFeatures ?? true;
99
+
100
+ const extensions = {
101
+ ...(includeMembers && {
102
+ members: z.array(OrganizationMember),
103
+ }),
104
+ ...(includeFeatures && {
105
+ features: z.array(z.string()),
106
+ }),
107
+ };
108
+
109
+ const schema =
110
+ Object.keys(extensions).length > 0
111
+ ? Organization.extend(extensions)
112
+ : Organization;
113
+
114
+ return schema.parse(data) as Organization<IncludeMembers, IncludeFeatures>;
115
+ }
@@ -0,0 +1,114 @@
1
+ import { z } from "zod";
2
+
3
+ import { OrganizationId } from "./organizations";
4
+
5
+ /**
6
+ * Project ID schema, branded for type safety.
7
+ * @public
8
+ */
9
+ export const ProjectId = z.string().nonempty().brand("ProjectId");
10
+
11
+ /**
12
+ * Project ID type, branded for type safety.
13
+ * @public
14
+ */
15
+ export type ProjectId = z.output<typeof ProjectId>;
16
+
17
+ /**
18
+ * Validates and brands a string as a ProjectId.
19
+ * @public
20
+ */
21
+ export function brandProjectId(id: string): ProjectId {
22
+ return ProjectId.parse(id);
23
+ }
24
+
25
+ const ProjectMember = z.object({
26
+ id: z.string(),
27
+ createdAt: z.string(),
28
+ updatedAt: z.string(),
29
+ isCurrentUser: z.boolean(),
30
+ isRobot: z.boolean(),
31
+ roles: z.array(
32
+ z.object({
33
+ name: z.string(),
34
+ title: z.string(),
35
+ description: z.string(),
36
+ }),
37
+ ),
38
+ });
39
+
40
+ /**
41
+ * @public
42
+ */
43
+ export type ProjectMember = z.output<typeof ProjectMember>;
44
+
45
+ /**
46
+ * Project schema — validates and brands API responses
47
+ * from the `/projects/:id` endpoint.
48
+ * @public
49
+ */
50
+ export const Project = z.object({
51
+ id: ProjectId,
52
+ displayName: z.string(),
53
+ studioHost: z.string().nullable(),
54
+ organizationId: OrganizationId,
55
+ metadata: z.object({
56
+ color: z.string().optional(),
57
+ externalStudioHost: z.string().optional(),
58
+ initialTemplate: z.string().optional(),
59
+ cliInitializedAt: z.string().optional(),
60
+ integration: z.literal(["manage", "cli"]),
61
+ }),
62
+ isBlocked: z.boolean(),
63
+ isDisabled: z.boolean(),
64
+ isDisabledByUser: z.boolean(),
65
+ activityFeedEnabled: z.boolean(),
66
+ createdAt: z.string(),
67
+ updatedAt: z.string(),
68
+ });
69
+
70
+ /**
71
+ * Represents a Sanity project with optional members and
72
+ * features arrays depending on the generic parameters.
73
+ * By default, neither members nor features are included.
74
+ * - `Project` — base fields only (default)
75
+ * - `Project<true>` — includes `members`
76
+ * - `Project<true, true>` — includes both
77
+ * @public
78
+ */
79
+ export type Project<
80
+ IncludeMembers extends boolean = true,
81
+ IncludeFeatures extends boolean = true,
82
+ > = z.output<typeof Project> &
83
+ (IncludeMembers extends true ? { members: ProjectMember[] } : unknown) &
84
+ (IncludeFeatures extends true ? { features: string[] } : unknown);
85
+
86
+ /**
87
+ * Validates and parses a raw API response into a branded
88
+ * Project. The options control which schema is used —
89
+ * matching what the API returns based on query params.
90
+ * @public
91
+ */
92
+ export function parseProject<
93
+ IncludeMembers extends boolean = true,
94
+ IncludeFeatures extends boolean = true,
95
+ >(
96
+ data: unknown,
97
+ options?: {
98
+ includeMembers?: IncludeMembers;
99
+ includeFeatures?: IncludeFeatures;
100
+ },
101
+ ): Project<IncludeMembers, IncludeFeatures> {
102
+ const includeMembers = options?.includeMembers ?? true;
103
+ const includeFeatures = options?.includeFeatures ?? true;
104
+
105
+ const extensions = {
106
+ ...(includeMembers && { members: z.array(ProjectMember) }),
107
+ ...(includeFeatures && { features: z.array(z.string()) }),
108
+ };
109
+
110
+ const schema =
111
+ Object.keys(extensions).length > 0 ? Project.extend(extensions) : Project;
112
+
113
+ return schema.parse(data) as Project<IncludeMembers, IncludeFeatures>;
114
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Joins multiple path segments into a single path string.
3
+ * Handles null, undefined, and URL objects gracefully.
4
+ *
5
+ * @public
6
+ * @param paths - An array of path segments to join.
7
+ * @returns A single joined path string.
8
+ */
9
+ export const joinUrlPaths = (
10
+ ...paths: Array<string | URL | null | undefined>
11
+ ): string => {
12
+ let nextPath = null;
13
+
14
+ const safeJoinSegments = (
15
+ segment1: string | null | undefined,
16
+ segment2: string | null | undefined,
17
+ ): string => {
18
+ if (!segment1) {
19
+ if (!segment2) {
20
+ return "";
21
+ }
22
+
23
+ return segment2;
24
+ }
25
+
26
+ if (!segment2) {
27
+ return segment1;
28
+ }
29
+
30
+ if (segment1.endsWith("/") && segment2.startsWith("/")) {
31
+ return segment1 + segment2.slice(1);
32
+ }
33
+
34
+ if (segment1.endsWith("/") || segment2.startsWith("/")) {
35
+ return segment1 + segment2;
36
+ }
37
+
38
+ return `${segment1}/${segment2}`;
39
+ };
40
+
41
+ const validPaths = paths.filter(
42
+ (path) => path !== null && path !== undefined,
43
+ );
44
+
45
+ for (const path of validPaths) {
46
+ nextPath = safeJoinSegments(
47
+ nextPath,
48
+ path instanceof URL ? path.pathname : path,
49
+ );
50
+ }
51
+
52
+ return nextPath ?? "";
53
+ };
54
+
55
+ /**
56
+ * Returns a normalized path by ensuring it starts with a single leading slash
57
+ * and does not end with a trailing slash (unless it's the root path).
58
+ */
59
+ export function normalizePath(pathname: string): string {
60
+ if (!pathname.startsWith("/")) {
61
+ pathname = `/${pathname}`;
62
+ }
63
+
64
+ if (pathname !== "/" && pathname.endsWith("/")) {
65
+ pathname = pathname.slice(0, -1);
66
+ }
67
+
68
+ return pathname;
69
+ }
70
+
71
+ /**
72
+ * The pathname, search, and hash values of a URL.
73
+ */
74
+ interface Path {
75
+ /**
76
+ * A URL pathname, beginning with a /.
77
+ */
78
+ pathname: string;
79
+ /**
80
+ * A URL search string, beginning with a ?.
81
+ */
82
+ search: string;
83
+ /**
84
+ * A URL fragment identifier, beginning with a #.
85
+ */
86
+ hash: string;
87
+ }
88
+
89
+ /**
90
+ * Parses a string URL path into its separate pathname, search, and hash components.
91
+ */
92
+ export function parsePath(path: string): Partial<Path> {
93
+ let parsedPath: Partial<Path> = {};
94
+
95
+ if (path) {
96
+ let hashIndex = path.indexOf("#");
97
+ if (hashIndex >= 0) {
98
+ parsedPath.hash = path.substring(hashIndex);
99
+ path = path.substring(0, hashIndex);
100
+ }
101
+
102
+ let searchIndex = path.indexOf("?");
103
+ if (searchIndex >= 0) {
104
+ parsedPath.search = path.substring(searchIndex);
105
+ path = path.substring(0, searchIndex);
106
+ }
107
+
108
+ if (path) {
109
+ parsedPath.pathname = path;
110
+ }
111
+ }
112
+
113
+ return parsedPath;
114
+ }
115
+
116
+ /**
117
+ * Creates a string URL path from the given pathname, search, and hash components.
118
+ */
119
+ export function createPath({
120
+ pathname = "/",
121
+ search = "",
122
+ hash = "",
123
+ }: Partial<Path>) {
124
+ if (search && search !== "?")
125
+ pathname += search.charAt(0) === "?" ? search : `?${search}`;
126
+ if (hash && hash !== "#")
127
+ pathname += hash.charAt(0) === "#" ? hash : `#${hash}`;
128
+ return pathname;
129
+ }