@sanity/workbench 0.1.0-alpha.6 → 0.1.0-alpha.8
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/dist/{log.js → _chunks-es/index.js} +7 -2
- package/dist/_chunks-es/index.js.map +1 -0
- package/dist/_internal.d.ts +13 -6
- package/dist/_internal.js +20 -9
- package/dist/_internal.js.map +1 -1
- package/dist/core.d.ts +1464 -0
- package/dist/core.js +744 -0
- package/dist/core.js.map +1 -0
- package/package.json +12 -4
- package/src/_exports/core.ts +1 -0
- package/src/_internal/index.ts +2 -1
- package/src/_internal/render.test.ts +91 -4
- package/src/_internal/render.ts +53 -33
- package/src/core/__tests__/__fixtures__.ts +245 -0
- package/src/core/applications/application-list.test.ts +222 -0
- package/src/core/applications/application-list.ts +103 -0
- package/src/core/applications/application.ts +78 -0
- package/src/core/applications/local-application.test.ts +93 -0
- package/src/core/applications/local-application.ts +56 -0
- package/src/core/canvases.test.ts +38 -0
- package/src/core/canvases.ts +81 -0
- package/src/core/config.ts +34 -0
- package/src/core/index.ts +12 -0
- package/src/{log → core/log}/index.ts +12 -0
- package/src/core/media-libraries.test.ts +38 -0
- package/src/core/media-libraries.ts +83 -0
- package/src/core/organizations.test.ts +134 -0
- package/src/core/organizations.ts +115 -0
- package/src/core/projects.test.ts +248 -0
- package/src/core/projects.ts +114 -0
- package/src/core/shared/urls.test.ts +182 -0
- package/src/core/shared/urls.ts +128 -0
- package/src/core/user-applications/core-app.test.ts +236 -0
- package/src/core/user-applications/core-app.ts +113 -0
- package/src/core/user-applications/studios/index.ts +3 -0
- package/src/core/user-applications/studios/schemas.test.ts +113 -0
- package/src/core/user-applications/studios/schemas.ts +106 -0
- package/src/core/user-applications/studios/studio.test.ts +997 -0
- package/src/core/user-applications/studios/studio.ts +498 -0
- package/src/core/user-applications/studios/workspace.ts +143 -0
- package/src/core/user-applications/user-application.test.ts +125 -0
- package/src/core/user-applications/user-application.ts +107 -0
- package/src/vite-env.d.ts +8 -0
- package/dist/log.d.ts +0 -48
- package/dist/log.js.map +0 -1
- package/src/_exports/log.ts +0 -1
- /package/src/{log → core/log}/index.test.ts +0 -0
|
@@ -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,248 @@
|
|
|
1
|
+
import { describe, expect, expectTypeOf, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { brandOrganizationId } from "./organizations";
|
|
4
|
+
import type { OrganizationId } from "./organizations";
|
|
5
|
+
import type { Project, ProjectId, ProjectMember } from "./projects";
|
|
6
|
+
import { brandProjectId, parseProject } from "./projects";
|
|
7
|
+
|
|
8
|
+
describe("Project type", () => {
|
|
9
|
+
it("includes members and features by default", () => {
|
|
10
|
+
expectTypeOf<Project>().toHaveProperty("members");
|
|
11
|
+
expectTypeOf<Project>().toHaveProperty("features");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("excludes members when IncludeMembers is false", () => {
|
|
15
|
+
expectTypeOf<Project<false>>().toHaveProperty("features");
|
|
16
|
+
expectTypeOf<
|
|
17
|
+
"members" extends keyof Project<false> ? true : false
|
|
18
|
+
>().toEqualTypeOf<false>();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("excludes features when IncludeFeatures is false", () => {
|
|
22
|
+
expectTypeOf<Project<true, false>>().toHaveProperty("members");
|
|
23
|
+
expectTypeOf<
|
|
24
|
+
"features" extends keyof Project<true, false> ? true : false
|
|
25
|
+
>().toEqualTypeOf<false>();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("excludes both when both are false", () => {
|
|
29
|
+
expectTypeOf<
|
|
30
|
+
"members" extends keyof Project<false, false> ? true : false
|
|
31
|
+
>().toEqualTypeOf<false>();
|
|
32
|
+
expectTypeOf<
|
|
33
|
+
"features" extends keyof Project<false, false> ? true : false
|
|
34
|
+
>().toEqualTypeOf<false>();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("always includes base properties", () => {
|
|
38
|
+
expectTypeOf<Project>().toHaveProperty("id");
|
|
39
|
+
expectTypeOf<Project>().toHaveProperty("displayName");
|
|
40
|
+
expectTypeOf<Project>().toHaveProperty("organizationId");
|
|
41
|
+
|
|
42
|
+
expectTypeOf<Project<false, false>>().toHaveProperty("id");
|
|
43
|
+
expectTypeOf<Project<false, false>>().toHaveProperty("displayName");
|
|
44
|
+
expectTypeOf<Project<false, false>>().toHaveProperty("organizationId");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("has branded id and organizationId types", () => {
|
|
48
|
+
expectTypeOf<Project["id"]>().toEqualTypeOf<ProjectId>();
|
|
49
|
+
expectTypeOf<Project["organizationId"]>().toEqualTypeOf<OrganizationId>();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("brandProjectId", () => {
|
|
54
|
+
it("brands a valid project id", () => {
|
|
55
|
+
const id = brandProjectId("abc123");
|
|
56
|
+
expect(id).toBe("abc123");
|
|
57
|
+
expectTypeOf(id).toEqualTypeOf<ProjectId>();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("rejects an empty string", () => {
|
|
61
|
+
expect(() => brandProjectId("")).toThrow();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("is not assignable from a plain string", () => {
|
|
65
|
+
expectTypeOf<ProjectId>().branded.toEqualTypeOf<ProjectId>();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("is not assignable from OrganizationId", () => {
|
|
69
|
+
expectTypeOf<
|
|
70
|
+
OrganizationId extends ProjectId ? true : false
|
|
71
|
+
>().toEqualTypeOf<false>();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("parseProject", () => {
|
|
76
|
+
const VALID_PROJECT = {
|
|
77
|
+
id: "proj123",
|
|
78
|
+
displayName: "My Project",
|
|
79
|
+
studioHost: "my-project",
|
|
80
|
+
organizationId: brandOrganizationId("org123"),
|
|
81
|
+
metadata: {
|
|
82
|
+
integration: "cli",
|
|
83
|
+
color: "#ff0000",
|
|
84
|
+
initialTemplate: "blog",
|
|
85
|
+
cliInitializedAt: "2024-01-01T00:00:00Z",
|
|
86
|
+
},
|
|
87
|
+
isBlocked: false,
|
|
88
|
+
isDisabled: false,
|
|
89
|
+
isDisabledByUser: false,
|
|
90
|
+
activityFeedEnabled: true,
|
|
91
|
+
createdAt: "2024-01-01T00:00:00Z",
|
|
92
|
+
updatedAt: "2024-01-01T00:00:00Z",
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const VALID_MEMBER = {
|
|
96
|
+
id: "member1",
|
|
97
|
+
createdAt: "2024-01-01T00:00:00Z",
|
|
98
|
+
updatedAt: "2024-01-01T00:00:00Z",
|
|
99
|
+
isCurrentUser: true,
|
|
100
|
+
isRobot: false,
|
|
101
|
+
roles: [{ name: "admin", title: "Admin", description: "Full access" }],
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const VALID_PROJECT_FULL = {
|
|
105
|
+
...VALID_PROJECT,
|
|
106
|
+
members: [VALID_MEMBER],
|
|
107
|
+
features: ["customDomain"],
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
it("parses a valid project with members and features by default", () => {
|
|
111
|
+
const project = parseProject(VALID_PROJECT_FULL);
|
|
112
|
+
expect(project.displayName).toBe("My Project");
|
|
113
|
+
expect(project.members).toHaveLength(1);
|
|
114
|
+
expect(project.features).toEqual(["customDomain"]);
|
|
115
|
+
expectTypeOf(project).toEqualTypeOf<Project>();
|
|
116
|
+
expectTypeOf(project.id).toEqualTypeOf<ProjectId>();
|
|
117
|
+
expectTypeOf(project.organizationId).toEqualTypeOf<OrganizationId>();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("rejects missing required fields", () => {
|
|
121
|
+
expect(() => parseProject({ id: "proj123" })).toThrow();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("rejects missing members by default", () => {
|
|
125
|
+
expect(() => parseProject(VALID_PROJECT)).toThrow();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("strips unknown properties", () => {
|
|
129
|
+
const project = parseProject({
|
|
130
|
+
...VALID_PROJECT_FULL,
|
|
131
|
+
unknownField: "should be stripped",
|
|
132
|
+
});
|
|
133
|
+
expect(project).not.toHaveProperty("unknownField");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("parses without members when excluded", () => {
|
|
137
|
+
const project = parseProject(
|
|
138
|
+
{ ...VALID_PROJECT, features: ["customDomain"] },
|
|
139
|
+
{ includeMembers: false },
|
|
140
|
+
);
|
|
141
|
+
expect(project).not.toHaveProperty("members");
|
|
142
|
+
expect(project.features).toEqual(["customDomain"]);
|
|
143
|
+
expectTypeOf(project).toEqualTypeOf<Project<false>>();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("parses without features when excluded", () => {
|
|
147
|
+
const project = parseProject(
|
|
148
|
+
{ ...VALID_PROJECT, members: [VALID_MEMBER] },
|
|
149
|
+
{ includeFeatures: false },
|
|
150
|
+
);
|
|
151
|
+
expect(project).not.toHaveProperty("features");
|
|
152
|
+
expect(project.members).toHaveLength(1);
|
|
153
|
+
expectTypeOf(project).toEqualTypeOf<Project<true, false>>();
|
|
154
|
+
expectTypeOf(project.members).toEqualTypeOf<ProjectMember[]>();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("parses base only when both excluded", () => {
|
|
158
|
+
const project = parseProject(VALID_PROJECT, {
|
|
159
|
+
includeMembers: false,
|
|
160
|
+
includeFeatures: false,
|
|
161
|
+
});
|
|
162
|
+
expect(project).not.toHaveProperty("members");
|
|
163
|
+
expect(project).not.toHaveProperty("features");
|
|
164
|
+
expectTypeOf(project).toEqualTypeOf<Project<false, false>>();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("parses with both members and features", () => {
|
|
168
|
+
const project = parseProject(VALID_PROJECT_FULL, {
|
|
169
|
+
includeMembers: true,
|
|
170
|
+
includeFeatures: true,
|
|
171
|
+
});
|
|
172
|
+
expect(project.members).toHaveLength(1);
|
|
173
|
+
expect(project.features).toEqual(["customDomain"]);
|
|
174
|
+
expectTypeOf(project).toEqualTypeOf<Project<true, true>>();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("metadata fields are optional", () => {
|
|
178
|
+
const BASE = {
|
|
179
|
+
...VALID_PROJECT,
|
|
180
|
+
members: [VALID_MEMBER],
|
|
181
|
+
features: ["customDomain"],
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
it("parses with only the integration field", () => {
|
|
185
|
+
const project = parseProject({
|
|
186
|
+
...BASE,
|
|
187
|
+
metadata: { integration: "cli" },
|
|
188
|
+
});
|
|
189
|
+
expect(project.metadata).toEqual({ integration: "cli" });
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("metadata.integration", () => {
|
|
193
|
+
it('accepts "cli"', () => {
|
|
194
|
+
const project = parseProject({
|
|
195
|
+
...BASE,
|
|
196
|
+
metadata: { integration: "cli" },
|
|
197
|
+
});
|
|
198
|
+
expect(project.metadata.integration).toBe("cli");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('accepts "manage"', () => {
|
|
202
|
+
const project = parseProject({
|
|
203
|
+
...BASE,
|
|
204
|
+
metadata: { integration: "manage" },
|
|
205
|
+
});
|
|
206
|
+
expect(project.metadata.integration).toBe("manage");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("rejects an unknown value", () => {
|
|
210
|
+
expect(() =>
|
|
211
|
+
parseProject({
|
|
212
|
+
...BASE,
|
|
213
|
+
metadata: { integration: "unknown" },
|
|
214
|
+
}),
|
|
215
|
+
).toThrow();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("rejects a missing integration field", () => {
|
|
219
|
+
expect(() =>
|
|
220
|
+
parseProject({ ...BASE, metadata: { color: "#ff0000" } }),
|
|
221
|
+
).toThrow();
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("parses without metadata.color", () => {
|
|
226
|
+
const { color: _color, ...rest } = BASE.metadata;
|
|
227
|
+
const project = parseProject({ ...BASE, metadata: rest });
|
|
228
|
+
expect(project.metadata).not.toHaveProperty("color");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("parses without metadata.externalStudioHost", () => {
|
|
232
|
+
const project = parseProject({ ...BASE, metadata: BASE.metadata });
|
|
233
|
+
expect(project.metadata).not.toHaveProperty("externalStudioHost");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("parses without metadata.initialTemplate", () => {
|
|
237
|
+
const { initialTemplate: _initialTemplate, ...rest } = BASE.metadata;
|
|
238
|
+
const project = parseProject({ ...BASE, metadata: rest });
|
|
239
|
+
expect(project.metadata).not.toHaveProperty("initialTemplate");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("parses without metadata.cliInitializedAt", () => {
|
|
243
|
+
const { cliInitializedAt: _cliInitializedAt, ...rest } = BASE.metadata;
|
|
244
|
+
const project = parseProject({ ...BASE, metadata: rest });
|
|
245
|
+
expect(project.metadata).not.toHaveProperty("cliInitializedAt");
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
});
|
|
@@ -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,182 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { createPath, joinUrlPaths, normalizePath, parsePath } from "./urls";
|
|
4
|
+
|
|
5
|
+
describe("joinUrlPaths", () => {
|
|
6
|
+
it("joins two simple segments", () => {
|
|
7
|
+
expect(joinUrlPaths("foo", "bar")).toBe("foo/bar");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("handles trailing slash on first segment", () => {
|
|
11
|
+
expect(joinUrlPaths("foo/", "bar")).toBe("foo/bar");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("handles leading slash on second segment", () => {
|
|
15
|
+
expect(joinUrlPaths("foo", "/bar")).toBe("foo/bar");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("collapses double slashes at the join", () => {
|
|
19
|
+
expect(joinUrlPaths("foo/", "/bar")).toBe("foo/bar");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("joins multiple segments", () => {
|
|
23
|
+
expect(joinUrlPaths("a", "b", "c")).toBe("a/b/c");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("skips null and undefined values", () => {
|
|
27
|
+
expect(joinUrlPaths("a", null, undefined, "b")).toBe("a/b");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("returns empty string for all nullish inputs", () => {
|
|
31
|
+
expect(joinUrlPaths(null, undefined)).toBe("");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("returns empty string with no arguments", () => {
|
|
35
|
+
expect(joinUrlPaths()).toBe("");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("returns single segment unchanged", () => {
|
|
39
|
+
expect(joinUrlPaths("foo")).toBe("foo");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("extracts pathname from URL objects", () => {
|
|
43
|
+
const url = new URL("https://example.com/api");
|
|
44
|
+
expect(joinUrlPaths(url, "users")).toBe("/api/users");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("handles URL object with trailing slash", () => {
|
|
48
|
+
const url = new URL("https://example.com/api/");
|
|
49
|
+
expect(joinUrlPaths(url, "users")).toBe("/api/users");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("joins two URL objects", () => {
|
|
53
|
+
const a = new URL("https://example.com/api");
|
|
54
|
+
const b = new URL("https://example.com/v2");
|
|
55
|
+
expect(joinUrlPaths(a, b)).toBe("/api/v2");
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("normalizePath", () => {
|
|
60
|
+
it("adds leading slash when missing", () => {
|
|
61
|
+
expect(normalizePath("foo")).toBe("/foo");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("preserves existing leading slash", () => {
|
|
65
|
+
expect(normalizePath("/foo")).toBe("/foo");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("removes trailing slash", () => {
|
|
69
|
+
expect(normalizePath("/foo/")).toBe("/foo");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("preserves root path", () => {
|
|
73
|
+
expect(normalizePath("/")).toBe("/");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("handles bare segment with trailing slash", () => {
|
|
77
|
+
expect(normalizePath("foo/")).toBe("/foo");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("handles deeper paths", () => {
|
|
81
|
+
expect(normalizePath("/a/b/c/")).toBe("/a/b/c");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("parsePath", () => {
|
|
86
|
+
it("parses pathname only", () => {
|
|
87
|
+
expect(parsePath("/foo")).toEqual({ pathname: "/foo" });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("parses pathname with search", () => {
|
|
91
|
+
expect(parsePath("/foo?bar=1")).toEqual({
|
|
92
|
+
pathname: "/foo",
|
|
93
|
+
search: "?bar=1",
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("parses pathname with hash", () => {
|
|
98
|
+
expect(parsePath("/foo#heading")).toEqual({
|
|
99
|
+
pathname: "/foo",
|
|
100
|
+
hash: "#heading",
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("parses pathname with search and hash", () => {
|
|
105
|
+
expect(parsePath("/foo?bar=1#heading")).toEqual({
|
|
106
|
+
pathname: "/foo",
|
|
107
|
+
search: "?bar=1",
|
|
108
|
+
hash: "#heading",
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("parses hash that appears before search in the string", () => {
|
|
113
|
+
expect(parsePath("/foo#heading?notSearch")).toEqual({
|
|
114
|
+
pathname: "/foo",
|
|
115
|
+
hash: "#heading?notSearch",
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("returns empty object for empty string", () => {
|
|
120
|
+
expect(parsePath("")).toEqual({});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("parses search-only path", () => {
|
|
124
|
+
expect(parsePath("?q=1")).toEqual({ search: "?q=1" });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("parses hash-only path", () => {
|
|
128
|
+
expect(parsePath("#top")).toEqual({ hash: "#top" });
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("createPath", () => {
|
|
133
|
+
it("returns pathname only", () => {
|
|
134
|
+
expect(createPath({ pathname: "/foo" })).toBe("/foo");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("appends search string", () => {
|
|
138
|
+
expect(createPath({ pathname: "/foo", search: "?bar=1" })).toBe(
|
|
139
|
+
"/foo?bar=1",
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("appends hash string", () => {
|
|
144
|
+
expect(createPath({ pathname: "/foo", hash: "#heading" })).toBe(
|
|
145
|
+
"/foo#heading",
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("appends both search and hash", () => {
|
|
150
|
+
expect(
|
|
151
|
+
createPath({
|
|
152
|
+
pathname: "/foo",
|
|
153
|
+
search: "?bar=1",
|
|
154
|
+
hash: "#heading",
|
|
155
|
+
}),
|
|
156
|
+
).toBe("/foo?bar=1#heading");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("adds ? prefix when search is missing it", () => {
|
|
160
|
+
expect(createPath({ pathname: "/foo", search: "bar=1" })).toBe(
|
|
161
|
+
"/foo?bar=1",
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("adds # prefix when hash is missing it", () => {
|
|
166
|
+
expect(createPath({ pathname: "/foo", hash: "heading" })).toBe(
|
|
167
|
+
"/foo#heading",
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("defaults pathname to /", () => {
|
|
172
|
+
expect(createPath({})).toBe("/");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("ignores bare ? search", () => {
|
|
176
|
+
expect(createPath({ pathname: "/foo", search: "?" })).toBe("/foo");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("ignores bare # hash", () => {
|
|
180
|
+
expect(createPath({ pathname: "/foo", hash: "#" })).toBe("/foo");
|
|
181
|
+
});
|
|
182
|
+
});
|