Skip to content

Commit 032c88f

Browse files
authored
feat: add Studio resource (#128)
1 parent d6b1ce2 commit 032c88f

6 files changed

Lines changed: 329 additions & 0 deletions

File tree

src/definers/studios.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import {validateStudio} from '../index.js'
2+
import type {BlueprintStudioConfig, BlueprintStudioResource} from '../types/studios.js'
3+
import {runValidation} from '../utils/validation.js'
4+
5+
/**
6+
* Defines a studio.
7+
*
8+
* ```ts
9+
* defineStudio({
10+
* name: 'my-studio',
11+
* src: 'studios/my-studio',
12+
* autoUpdates: {
13+
* enabled: true
14+
* }
15+
* })
16+
* ```
17+
* @param parameters The studio configuration
18+
* @public
19+
* @beta Deploying Studios via Blueprints is experimental. This may be subject to breaking changes.
20+
* @category Definers
21+
* @expandType BlueprintStudioConfig
22+
* @returns The studio resource
23+
* @hidden
24+
*/
25+
export function defineStudio(config: BlueprintStudioConfig): BlueprintStudioResource {
26+
const studioResource: BlueprintStudioResource = {
27+
...config,
28+
type: 'sanity.studio',
29+
}
30+
31+
runValidation(() => validateStudio(studioResource))
32+
33+
return studioResource
34+
}

src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export * from './definers/resources.js'
1616
export * from './definers/robots.js'
1717
export * from './definers/robotTokens.js'
1818
export * from './definers/roles.js'
19+
export * from './definers/studios.js'
1920
export * from './definers/webhooks.js'
2021

2122
// TYPES - base types for all resources
@@ -37,6 +38,7 @@ export * from './types/resources.js'
3738
export * from './types/robots.js'
3839
export * from './types/robotTokens.js'
3940
export * from './types/roles.js'
41+
export * from './types/studios.js'
4042
export * from './types/webhooks.js'
4143

4244
// VALIDATION - validation for all resources
@@ -53,6 +55,7 @@ export * from './validation/resources.js'
5355
export * from './validation/robots.js'
5456
export * from './validation/robotTokens.js'
5557
export * from './validation/roles.js'
58+
export * from './validation/studios.js'
5659
export * from './validation/webhooks.js'
5760

5861
// Public BLUEPRINTS INTERNALS - published bucket of misc Blueprint types

src/types/studios.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import type {BlueprintProjectResourceLifecycle, BlueprintResource} from '../index.js'
2+
3+
/**
4+
* Represents a Studio resource.
5+
* @see https://www.sanity.io/docs/studio
6+
* @beta This feature is subject to breaking changes.
7+
* @category Resource Types
8+
*/
9+
export interface BlueprintStudioResource extends BlueprintResource<BlueprintProjectResourceLifecycle> {
10+
type: 'sanity.studio'
11+
12+
/** The relative location of the studio source code. */
13+
src: string
14+
15+
/**
16+
* Auto update settings for the studio.
17+
*/
18+
autoUpdates: {
19+
/** Whether auto updates are enabled for the studio */
20+
enabled: boolean
21+
22+
/** What "version"/"channel" to use for auto updates */
23+
version?: string // 'next', 'stable', 'latest' or a semantic version (e.g., "1.2.3", "2.0.0-beta.1")
24+
}
25+
26+
/** The base path of the studio URL. Defaults to '/' */
27+
basePath?: string
28+
29+
/** Whether or not to minify the source code. Defaults to true. */
30+
minify?: boolean
31+
32+
/** Whether or not to configure the reactCompiler. Defaults to false. */
33+
reactCompiler?: boolean
34+
35+
/** Whether or not to generate source maps. Defaults to false. */
36+
sourceMap?: boolean
37+
38+
/**
39+
* The project ID of the project that contains your Studio.
40+
*
41+
* The `project` attribute must be defined if your blueprint is scoped to an organization.
42+
*/
43+
project?: string
44+
}
45+
46+
export interface BlueprintStudioConfig extends Omit<BlueprintStudioResource, 'type'> {}

src/validation/studios.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import type {BlueprintError} from '../types/errors.js'
2+
import {validateResource} from './resources.js'
3+
4+
/**
5+
* Validates that the given resource is a valid Studio.
6+
* @param resource The Studio resource
7+
* @hidden
8+
* @category Validation
9+
* @returns A list of validation errors
10+
*/
11+
export function validateStudio(resource: unknown): BlueprintError[] {
12+
if (!resource) return [{type: 'invalid_value', message: 'Studio config must be provided'}]
13+
if (typeof resource !== 'object') return [{type: 'invalid_type', message: 'Studio config must be an object'}]
14+
15+
const errors: BlueprintError[] = validateResource(resource, {projectContained: true})
16+
17+
if ('type' in resource && resource.type !== 'sanity.studio') {
18+
errors.push({type: 'invalid_value', message: 'Studio type must be `sanity.studio`'})
19+
}
20+
21+
if (!('src' in resource) || !resource.src) {
22+
errors.push({type: 'missing_parameter', message: 'Studio src is required'})
23+
} else if (typeof resource.src !== 'string') {
24+
errors.push({type: 'invalid_type', message: 'Studio src must be a string'})
25+
}
26+
27+
if (!('autoUpdates' in resource) || !resource.autoUpdates) {
28+
errors.push({type: 'missing_parameter', message: 'Studio autoUpdates is required'})
29+
} else if (typeof resource.autoUpdates !== 'object') {
30+
errors.push({type: 'invalid_type', message: 'Studio autoUpdates must be an object'})
31+
} else {
32+
if (!('enabled' in resource.autoUpdates)) {
33+
errors.push({type: 'missing_parameter', message: 'Studio autoUpdates.enabled is required'})
34+
} else if (typeof resource.autoUpdates.enabled !== 'boolean') {
35+
errors.push({type: 'invalid_type', message: 'Studio autoUpdates.enabled must be a boolean'})
36+
}
37+
38+
if ('version' in resource.autoUpdates) {
39+
if (typeof resource.autoUpdates.version !== 'string') {
40+
errors.push({type: 'invalid_type', message: 'Studio autoUpdates.version must be a string'})
41+
}
42+
}
43+
}
44+
45+
if ('basePath' in resource) {
46+
if (typeof resource.basePath !== 'string') {
47+
errors.push({type: 'invalid_type', message: 'Studio basePath must be a string'})
48+
}
49+
}
50+
51+
if ('minify' in resource) {
52+
if (typeof resource.minify !== 'boolean') {
53+
errors.push({type: 'invalid_type', message: 'Studio minify must be a boolean'})
54+
}
55+
}
56+
57+
if ('reactCompiler' in resource) {
58+
if (typeof resource.reactCompiler !== 'boolean') {
59+
errors.push({type: 'invalid_type', message: 'Studio reactCompiler must be a boolean'})
60+
}
61+
}
62+
63+
if ('sourceMap' in resource) {
64+
if (typeof resource.sourceMap !== 'boolean') {
65+
errors.push({type: 'invalid_type', message: 'Studio sourceMap must be a boolean'})
66+
}
67+
}
68+
69+
if ('project' in resource) {
70+
if (typeof resource.project !== 'string') {
71+
errors.push({type: 'invalid_type', message: 'Studio project must be a string'})
72+
}
73+
}
74+
75+
return errors
76+
}

test/unit/definers/studios.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import {afterEach, describe, expect, test, vi} from 'vitest'
2+
import * as studios from '../../../src/definers/studios.js'
3+
import * as index from '../../../src/index.js'
4+
import {defineBlueprintForResource} from '../../helpers/index.js'
5+
6+
vi.mock(import('../../../src/index.js'), async (importOriginal) => {
7+
const originalModule = await importOriginal()
8+
return {
9+
...originalModule,
10+
validateBlueprint: vi.fn(() => []),
11+
}
12+
})
13+
14+
describe('defineStudio', () => {
15+
afterEach(() => {
16+
vi.resetAllMocks()
17+
})
18+
19+
test('should throw an error if validateStudio returns an error', () => {
20+
const spy = vi.spyOn(index, 'validateStudio').mockImplementation(() => [{type: 'test', message: 'this is a test'}])
21+
expect(() =>
22+
defineBlueprintForResource(
23+
studios.defineStudio({
24+
name: 'studio-name',
25+
src: 'studios/my-studio',
26+
autoUpdates: {
27+
enabled: true,
28+
},
29+
}),
30+
),
31+
).toThrow(/this is a test/)
32+
33+
expect(spy).toHaveBeenCalledOnce()
34+
})
35+
36+
test('should accept a valid configuration and set the type', () => {
37+
const studioResource = studios.defineStudio({
38+
name: 'studio-name',
39+
src: 'studios/my-studio',
40+
autoUpdates: {
41+
enabled: true,
42+
},
43+
})
44+
45+
expect(studioResource.type).toStrictEqual('sanity.studio')
46+
})
47+
48+
test('should accept a valid configuration with a lifecycle', () => {
49+
const studioResource = studios.defineStudio({
50+
name: 'studio-name',
51+
src: 'studios/my-studio',
52+
autoUpdates: {
53+
enabled: true,
54+
},
55+
56+
lifecycle: {
57+
deletionPolicy: 'allow',
58+
59+
ownershipAction: {
60+
type: 'attach',
61+
projectId: 'a1b2c3',
62+
id: 'abc123',
63+
},
64+
},
65+
})
66+
67+
expect(studioResource.lifecycle?.deletionPolicy).toStrictEqual('allow')
68+
})
69+
})
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import {describe, expect, test} from 'vitest'
2+
import {validateStudio} from '../../../src/index.js'
3+
4+
const validStudio = {
5+
name: 'my-studio',
6+
type: 'sanity.studio',
7+
src: './studio',
8+
autoUpdates: {enabled: true},
9+
}
10+
11+
describe('validateStudio', () => {
12+
test('should return an error if config is falsey', () => {
13+
const errors = validateStudio(undefined)
14+
expect(errors).toContainEqual({type: 'invalid_value', message: 'Studio config must be provided'})
15+
})
16+
17+
test('should return an error if config is not an object', () => {
18+
const errors = validateStudio(1)
19+
expect(errors).toContainEqual({type: 'invalid_type', message: 'Studio config must be an object'})
20+
})
21+
22+
test('should return an error if type is not sanity.studio', () => {
23+
const errors = validateStudio({...validStudio, type: 'invalid'})
24+
expect(errors).toContainEqual({type: 'invalid_value', message: 'Studio type must be `sanity.studio`'})
25+
})
26+
27+
test('should return an error if src is not provided', () => {
28+
const {src: _src, ...noSrc} = validStudio
29+
const errors = validateStudio(noSrc)
30+
expect(errors).toContainEqual({type: 'missing_parameter', message: 'Studio src is required'})
31+
})
32+
33+
test('should return an error if src is not a string', () => {
34+
const errors = validateStudio({...validStudio, src: 1})
35+
expect(errors).toContainEqual({type: 'invalid_type', message: 'Studio src must be a string'})
36+
})
37+
38+
test('should return an error if autoUpdates is not provided', () => {
39+
const {autoUpdates: _au, ...noAutoUpdates} = validStudio
40+
const errors = validateStudio(noAutoUpdates)
41+
expect(errors).toContainEqual({type: 'missing_parameter', message: 'Studio autoUpdates is required'})
42+
})
43+
44+
test('should return an error if autoUpdates is not an object', () => {
45+
const errors = validateStudio({...validStudio, autoUpdates: 'yes'})
46+
expect(errors).toContainEqual({type: 'invalid_type', message: 'Studio autoUpdates must be an object'})
47+
})
48+
49+
test('should return an error if autoUpdates.enabled is not provided', () => {
50+
const errors = validateStudio({...validStudio, autoUpdates: {}})
51+
expect(errors).toContainEqual({type: 'missing_parameter', message: 'Studio autoUpdates.enabled is required'})
52+
})
53+
54+
test('should return an error if autoUpdates.enabled is not a boolean', () => {
55+
const errors = validateStudio({...validStudio, autoUpdates: {enabled: 'yes'}})
56+
expect(errors).toContainEqual({type: 'invalid_type', message: 'Studio autoUpdates.enabled must be a boolean'})
57+
})
58+
59+
test('should return an error if autoUpdates.version is not a string', () => {
60+
const errors = validateStudio({...validStudio, autoUpdates: {enabled: true, version: 1}})
61+
expect(errors).toContainEqual({type: 'invalid_type', message: 'Studio autoUpdates.version must be a string'})
62+
})
63+
64+
test('should return an error if basePath is not a string', () => {
65+
const errors = validateStudio({...validStudio, basePath: 1})
66+
expect(errors).toContainEqual({type: 'invalid_type', message: 'Studio basePath must be a string'})
67+
})
68+
69+
test('should return an error if minify is not a boolean', () => {
70+
const errors = validateStudio({...validStudio, minify: 'yes'})
71+
expect(errors).toContainEqual({type: 'invalid_type', message: 'Studio minify must be a boolean'})
72+
})
73+
74+
test('should return an error if reactCompiler is not a boolean', () => {
75+
const errors = validateStudio({...validStudio, reactCompiler: 'yes'})
76+
expect(errors).toContainEqual({type: 'invalid_type', message: 'Studio reactCompiler must be a boolean'})
77+
})
78+
79+
test('should return an error if sourceMap is not a boolean', () => {
80+
const errors = validateStudio({...validStudio, sourceMap: 'yes'})
81+
expect(errors).toContainEqual({type: 'invalid_type', message: 'Studio sourceMap must be a boolean'})
82+
})
83+
84+
test('should return an error if project is not a string', () => {
85+
const errors = validateStudio({...validStudio, project: 1})
86+
expect(errors).toContainEqual({type: 'invalid_type', message: 'Studio project must be a string'})
87+
})
88+
89+
test('should accept a valid configuration', () => {
90+
const errors = validateStudio({
91+
...validStudio,
92+
autoUpdates: {enabled: true, version: '^3.0.0'},
93+
basePath: '/studio',
94+
minify: true,
95+
reactCompiler: false,
96+
sourceMap: true,
97+
project: 'abcdefg',
98+
})
99+
expect(errors, JSON.stringify(errors)).toHaveLength(0)
100+
})
101+
})

0 commit comments

Comments
 (0)