Skip to content

Commit c1e4f2a

Browse files
authoredMar 12, 2024
feat: add cmd to generate a JSON representation of schema (#5919)
* feat: add cmd to generate a JSON representation of schema * feat: refactor to use compiled schema * fix: append _type to objects * chore(schema): clean up and describe execution * chore(schema): lock groq-js to minor canary
1 parent bf22266 commit c1e4f2a

23 files changed

+6444
-0
lines changed
 

‎packages/@sanity/schema/package.json

+2
Original file line numberDiff line numberDiff line change
@@ -77,13 +77,15 @@
7777
"@sanity/generate-help-url": "^3.0.0",
7878
"@sanity/types": "3.32.0",
7979
"arrify": "^1.0.1",
80+
"groq-js": "^1.5.0-canary.1",
8081
"humanize-list": "^1.0.1",
8182
"leven": "^3.1.0",
8283
"lodash": "^4.17.21",
8384
"object-inspect": "^1.6.0"
8485
},
8586
"devDependencies": {
8687
"@jest/globals": "^29.7.0",
88+
"@sanity/icons": "^2.8.0",
8789
"rimraf": "^3.0.2"
8890
}
8991
}

‎packages/@sanity/schema/src/_exports/_internal.ts

+1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export {
44
resolveSearchConfig,
55
resolveSearchConfigForBaseFieldPaths,
66
} from '../legacy/searchConfig/resolve'
7+
export {extractSchema} from '../sanity/extractSchema'
78
export {groupProblems} from '../sanity/groupProblems'
89
export {
910
type _FIXME_ as FIXME,

‎packages/@sanity/schema/src/sanity/extractSchema.ts

+534
Large diffs are not rendered by default.

‎packages/@sanity/schema/test/extractSchema/__snapshots__/extractSchema.test.ts.snap

+4,475
Large diffs are not rendered by default.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,379 @@
1+
import assert from 'node:assert'
2+
3+
import {describe, expect, test} from '@jest/globals'
4+
import {defineType} from '@sanity/types'
5+
6+
import {Schema} from '../../src/legacy/Schema'
7+
import {extractSchema} from '../../src/sanity/extractSchema'
8+
import {groupProblems} from '../../src/sanity/groupProblems'
9+
import {validateSchema} from '../../src/sanity/validateSchema'
10+
import schemaFixtures from '../legacy/fixtures/schemas'
11+
// built-in types
12+
import assetSourceData from './fixtures/assetSourceData'
13+
import Block from './fixtures/block'
14+
import fileAsset from './fixtures/fileAsset'
15+
import geopoint from './fixtures/geopoint'
16+
import imageAsset from './fixtures/imageAsset'
17+
import imageCrop from './fixtures/imageCrop'
18+
import imageDimensions from './fixtures/imageDimensions'
19+
import imageHotspot from './fixtures/imageHotspot'
20+
import imageMetadata from './fixtures/imageMetadata'
21+
import imagePalette from './fixtures/imagePalette'
22+
import imagePaletteSwatch from './fixtures/imagePaletteSwatch'
23+
import slug from './fixtures/slug'
24+
25+
const builtinTypes = [
26+
assetSourceData,
27+
slug,
28+
geopoint,
29+
imageAsset,
30+
fileAsset,
31+
imageCrop,
32+
imageHotspot,
33+
imageMetadata,
34+
imageDimensions,
35+
imagePalette,
36+
imagePaletteSwatch,
37+
]
38+
39+
// taken from sanity/src/core/schema/createSchema.ts
40+
function createSchema(schemaDef: {name: string; types: any[]}) {
41+
const validated = validateSchema(schemaDef.types).getTypes()
42+
const validation = groupProblems(validated)
43+
const hasErrors = validation.some((group) =>
44+
group.problems.some((problem) => problem.severity === 'error'),
45+
)
46+
47+
return Schema.compile({
48+
name: 'test',
49+
types: hasErrors ? [] : [...schemaDef.types, ...builtinTypes].filter(Boolean),
50+
})
51+
}
52+
53+
describe('Extract schema test', () => {
54+
test('Extracts schema general', () => {
55+
const schema = createSchema({
56+
name: 'test',
57+
types: [
58+
defineType({
59+
title: 'Valid document',
60+
name: 'validDocument',
61+
type: 'document',
62+
fields: [
63+
{
64+
title: 'Title',
65+
name: 'title',
66+
type: 'string',
67+
},
68+
{
69+
title: 'List',
70+
name: 'list',
71+
type: 'string',
72+
options: {
73+
list: ['a', 'b', 'c'],
74+
},
75+
validation: (Rule) => Rule.required(),
76+
},
77+
{
78+
title: 'Number',
79+
name: 'number',
80+
type: 'number',
81+
},
82+
{
83+
title: 'some other object',
84+
name: 'someInlinedObject',
85+
type: 'obj',
86+
},
87+
{
88+
title: 'Manuscript',
89+
name: 'manuscript',
90+
type: 'manuscript',
91+
},
92+
{
93+
title: 'customStringType',
94+
name: 'customStringType',
95+
type: 'customStringType',
96+
},
97+
{
98+
title: 'Blocks',
99+
name: 'blocks',
100+
type: 'array',
101+
of: [{type: 'block'}],
102+
},
103+
{
104+
type: 'reference',
105+
name: 'other',
106+
to: {
107+
type: 'otherValidDocument',
108+
},
109+
},
110+
{
111+
type: 'reference',
112+
name: 'others',
113+
to: [
114+
{
115+
type: 'otherValidDocument',
116+
},
117+
],
118+
},
119+
],
120+
}),
121+
{
122+
title: 'Author',
123+
name: 'author',
124+
type: 'document',
125+
fields: [
126+
{
127+
title: 'Name',
128+
name: 'name',
129+
type: 'string',
130+
},
131+
{
132+
title: 'Profile picture',
133+
name: 'profilePicture',
134+
type: 'image',
135+
options: {
136+
hotspot: true,
137+
},
138+
fields: [
139+
{
140+
name: 'caption',
141+
type: 'string',
142+
title: 'Caption',
143+
},
144+
{
145+
name: 'attribution',
146+
type: 'string',
147+
title: 'Attribution',
148+
},
149+
],
150+
},
151+
],
152+
},
153+
{
154+
title: 'Book',
155+
name: 'book',
156+
type: 'document',
157+
fields: [
158+
{
159+
title: 'Name',
160+
name: 'name',
161+
type: 'string',
162+
},
163+
],
164+
},
165+
Block,
166+
{
167+
title: 'Other valid document',
168+
name: 'otherValidDocument',
169+
type: 'document',
170+
fields: [
171+
{
172+
title: 'Title',
173+
name: 'title',
174+
type: 'string',
175+
},
176+
],
177+
},
178+
{
179+
type: 'object',
180+
name: 'obj',
181+
fields: [
182+
{
183+
title: 'Field #1',
184+
name: 'field1',
185+
type: 'string',
186+
},
187+
{
188+
title: 'Field #2',
189+
name: 'field2',
190+
type: 'number',
191+
},
192+
],
193+
},
194+
defineType({
195+
name: 'customStringType',
196+
title: 'My custom string type',
197+
type: 'string',
198+
}),
199+
{
200+
type: 'object',
201+
name: 'code',
202+
fields: [
203+
{
204+
title: 'The Code!',
205+
name: 'thecode',
206+
type: 'string',
207+
},
208+
],
209+
},
210+
{
211+
title: 'Manuscript',
212+
name: 'manuscript',
213+
type: 'file',
214+
fields: [
215+
{
216+
name: 'description',
217+
type: 'string',
218+
title: 'Description',
219+
},
220+
{
221+
name: 'author',
222+
type: 'reference',
223+
title: 'Author',
224+
to: {type: 'author'},
225+
},
226+
],
227+
},
228+
],
229+
})
230+
231+
const extracted = extractSchema(schema)
232+
expect(extracted.map((v) => v.name)).toStrictEqual([
233+
'sanity.imagePaletteSwatch',
234+
'sanity.imagePalette',
235+
'sanity.imageDimensions',
236+
'geopoint',
237+
'slug',
238+
'sanity.fileAsset',
239+
'code',
240+
'customStringType',
241+
'blocksTest',
242+
'book',
243+
'author',
244+
'sanity.imageCrop',
245+
'sanity.imageHotspot',
246+
'sanity.imageAsset',
247+
'sanity.assetSourceData',
248+
'sanity.imageMetadata',
249+
'validDocument',
250+
'otherValidDocument',
251+
'manuscript',
252+
'obj',
253+
])
254+
const validDocument = extracted.find((type) => type.name === 'validDocument')
255+
expect(validDocument).toBeDefined()
256+
assert(validDocument !== undefined) // this is a workaround for TS, but leave the expect above for clarity in case of failure
257+
258+
expect(validDocument.name).toEqual('validDocument')
259+
expect(validDocument.type).toEqual('document')
260+
assert(validDocument.type === 'document') // this is a workaround for TS https://github.com/DefinitelyTyped/DefinitelyTyped/issues/41179
261+
expect(Object.keys(validDocument.attributes)).toStrictEqual([
262+
'_id',
263+
'_type',
264+
'_createdAt',
265+
'_updatedAt',
266+
'_rev',
267+
'title',
268+
'list',
269+
'number',
270+
'someInlinedObject',
271+
'manuscript',
272+
'customStringType',
273+
'blocks',
274+
'other',
275+
'others',
276+
])
277+
278+
// Check that the block type is extracted correctly, as an array
279+
expect(validDocument.attributes.blocks.type).toEqual('objectAttribute')
280+
expect(validDocument.attributes.blocks.value.type).toEqual('array')
281+
assert(validDocument.attributes.blocks.value.type === 'array') // this is a workaround for TS
282+
expect(validDocument.attributes.blocks.value.of.type).toEqual('object')
283+
assert(validDocument.attributes.blocks.value.of.type === 'object') // this is a workaround for TS
284+
expect(Object.keys(validDocument.attributes.blocks.value.of.attributes)).toStrictEqual([
285+
'children',
286+
'style',
287+
'listItem',
288+
'markDefs',
289+
'level',
290+
'_type',
291+
])
292+
293+
expect(validDocument.attributes.blocks.value.of.attributes.children.value.type).toEqual('array')
294+
assert(validDocument.attributes.blocks.value.of.attributes.children.value.type === 'array') // this is a workaround for TS
295+
expect(validDocument.attributes.blocks.value.of.attributes.children.value.of.type).toEqual(
296+
'object',
297+
)
298+
assert(validDocument.attributes.blocks.value.of.attributes.children.value.of.type === 'object') // this is a workaround for TS
299+
expect(
300+
Object.keys(validDocument.attributes.blocks.value.of.attributes.children.value.of.attributes),
301+
).toStrictEqual(['marks', 'text', '_type'])
302+
303+
expect(extracted).toMatchSnapshot()
304+
})
305+
306+
test('order of types does not matter', () => {
307+
const schema1 = createSchema({
308+
name: 'test',
309+
types: [
310+
{
311+
title: 'Author',
312+
name: 'author',
313+
type: 'object',
314+
fields: [
315+
{
316+
title: 'Name',
317+
name: 'name',
318+
type: 'string',
319+
},
320+
],
321+
},
322+
{
323+
title: 'Book',
324+
name: 'book',
325+
type: 'document',
326+
fields: [
327+
{
328+
title: 'Name',
329+
name: 'name',
330+
type: 'string',
331+
},
332+
{
333+
title: 'Author',
334+
name: 'author',
335+
type: 'author',
336+
},
337+
],
338+
},
339+
],
340+
})
341+
342+
expect(extractSchema(schema1).map((v) => v.name)).toStrictEqual([
343+
'sanity.imagePaletteSwatch',
344+
'sanity.imagePalette',
345+
'sanity.imageDimensions',
346+
'sanity.imageHotspot',
347+
'sanity.imageCrop',
348+
'sanity.fileAsset',
349+
'sanity.imageAsset',
350+
'sanity.imageMetadata',
351+
'geopoint',
352+
'slug',
353+
'sanity.assetSourceData',
354+
'book',
355+
'author',
356+
])
357+
})
358+
359+
describe('Can extract sample fixtures', () => {
360+
const cases = Object.keys(schemaFixtures).map((schemaName) => {
361+
const schema = createSchema(schemaFixtures[schemaName])
362+
if (schema._original.types.length === 0) {
363+
return {schemaName, schema: null}
364+
}
365+
return {schemaName, schema}
366+
})
367+
const passes = cases.filter((v): v is {schemaName: string; schema: Schema} => v.schema !== null)
368+
369+
test.each(passes)('extracts schema $schemaName', ({schema}) => {
370+
const extracted = extractSchema(schema)
371+
expect(extracted.length).toBeGreaterThan(0) // we don't really care about the exact number, just that it passes :+1:
372+
})
373+
374+
const skips = cases.filter((v): v is {schemaName: string; schema: null} => v.schema === null)
375+
test.skip.each(skips)('extracts schema $schemaName', () => {
376+
// Add a test for the skipped cases so we can track them in the test report
377+
})
378+
})
379+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
export default {
2+
name: 'sanity.assetSourceData',
3+
title: 'Asset Source Data',
4+
type: 'object',
5+
fields: [
6+
{
7+
name: 'name',
8+
title: 'Source name',
9+
description: 'A canonical name for the source this asset is originating from',
10+
type: 'string',
11+
},
12+
{
13+
name: 'id',
14+
title: 'Asset Source ID',
15+
description:
16+
'The unique ID for the asset within the originating source so you can programatically find back to it',
17+
type: 'string',
18+
},
19+
{
20+
name: 'url',
21+
title: 'Asset information URL',
22+
description: 'A URL to find more information about this asset in the originating source',
23+
type: 'string',
24+
},
25+
],
26+
}

‎packages/@sanity/schema/test/extractSchema/fixtures/block.ts

+432
Large diffs are not rendered by default.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
export default {
2+
name: 'sanity.fileAsset',
3+
title: 'File',
4+
type: 'document',
5+
fieldsets: [
6+
{
7+
name: 'system',
8+
title: 'System fields',
9+
description: 'These fields are managed by the system and not editable',
10+
},
11+
],
12+
fields: [
13+
{
14+
name: 'originalFilename',
15+
type: 'string',
16+
title: 'Original file name',
17+
readOnly: true,
18+
},
19+
{
20+
name: 'label',
21+
type: 'string',
22+
title: 'Label',
23+
},
24+
{
25+
name: 'title',
26+
type: 'string',
27+
title: 'Title',
28+
},
29+
{
30+
name: 'description',
31+
type: 'string',
32+
title: 'Description',
33+
},
34+
{
35+
name: 'altText',
36+
type: 'string',
37+
title: 'Alternative text',
38+
},
39+
{
40+
name: 'sha1hash',
41+
type: 'string',
42+
title: 'SHA1 hash',
43+
readOnly: true,
44+
fieldset: 'system',
45+
},
46+
{
47+
name: 'extension',
48+
type: 'string',
49+
title: 'File extension',
50+
readOnly: true,
51+
fieldset: 'system',
52+
},
53+
{
54+
name: 'mimeType',
55+
type: 'string',
56+
title: 'Mime type',
57+
readOnly: true,
58+
fieldset: 'system',
59+
},
60+
{
61+
name: 'size',
62+
type: 'number',
63+
title: 'File size in bytes',
64+
readOnly: true,
65+
fieldset: 'system',
66+
},
67+
{
68+
name: 'assetId',
69+
type: 'string',
70+
title: 'Asset ID',
71+
readOnly: true,
72+
fieldset: 'system',
73+
},
74+
{
75+
name: 'uploadId',
76+
type: 'string',
77+
readOnly: true,
78+
hidden: true,
79+
fieldset: 'system',
80+
},
81+
{
82+
name: 'path',
83+
type: 'string',
84+
title: 'Path',
85+
readOnly: true,
86+
fieldset: 'system',
87+
},
88+
{
89+
name: 'url',
90+
type: 'string',
91+
title: 'Url',
92+
readOnly: true,
93+
fieldset: 'system',
94+
},
95+
{
96+
name: 'source',
97+
type: 'sanity.assetSourceData',
98+
title: 'Source',
99+
readOnly: true,
100+
fieldset: 'system',
101+
},
102+
],
103+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export default {
2+
title: 'Geographical Point',
3+
name: 'geopoint',
4+
type: 'object',
5+
fields: [
6+
{
7+
name: 'lat',
8+
type: 'number',
9+
title: 'Latitude',
10+
},
11+
{
12+
name: 'lng',
13+
type: 'number',
14+
title: 'Longitude',
15+
},
16+
{
17+
name: 'alt',
18+
type: 'number',
19+
title: 'Altitude',
20+
},
21+
],
22+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
export default {
2+
name: 'sanity.imageAsset',
3+
title: 'Image',
4+
type: 'document',
5+
fieldsets: [
6+
{
7+
name: 'system',
8+
title: 'System fields',
9+
description: 'These fields are managed by the system and not editable',
10+
},
11+
],
12+
fields: [
13+
{
14+
name: 'originalFilename',
15+
type: 'string',
16+
title: 'Original file name',
17+
readOnly: true,
18+
},
19+
{
20+
name: 'label',
21+
type: 'string',
22+
title: 'Label',
23+
},
24+
{
25+
name: 'title',
26+
type: 'string',
27+
title: 'Title',
28+
},
29+
{
30+
name: 'description',
31+
type: 'string',
32+
title: 'Description',
33+
},
34+
{
35+
name: 'altText',
36+
type: 'string',
37+
title: 'Alternative text',
38+
},
39+
{
40+
name: 'sha1hash',
41+
type: 'string',
42+
title: 'SHA1 hash',
43+
readOnly: true,
44+
fieldset: 'system',
45+
},
46+
{
47+
name: 'extension',
48+
type: 'string',
49+
readOnly: true,
50+
title: 'File extension',
51+
fieldset: 'system',
52+
},
53+
{
54+
name: 'mimeType',
55+
type: 'string',
56+
readOnly: true,
57+
title: 'Mime type',
58+
fieldset: 'system',
59+
},
60+
{
61+
name: 'size',
62+
type: 'number',
63+
title: 'File size in bytes',
64+
readOnly: true,
65+
fieldset: 'system',
66+
},
67+
{
68+
name: 'assetId',
69+
type: 'string',
70+
title: 'Asset ID',
71+
readOnly: true,
72+
fieldset: 'system',
73+
},
74+
{
75+
name: 'uploadId',
76+
type: 'string',
77+
readOnly: true,
78+
hidden: true,
79+
fieldset: 'system',
80+
},
81+
{
82+
name: 'path',
83+
type: 'string',
84+
title: 'Path',
85+
readOnly: true,
86+
fieldset: 'system',
87+
},
88+
{
89+
name: 'url',
90+
type: 'string',
91+
title: 'Url',
92+
readOnly: true,
93+
fieldset: 'system',
94+
},
95+
{
96+
name: 'metadata',
97+
type: 'sanity.imageMetadata',
98+
title: 'Metadata',
99+
},
100+
{
101+
name: 'source',
102+
type: 'sanity.assetSourceData',
103+
title: 'Source',
104+
readOnly: true,
105+
fieldset: 'system',
106+
},
107+
],
108+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
export default {
2+
name: 'sanity.imageCrop',
3+
title: 'Image crop',
4+
type: 'object',
5+
fields: [
6+
{
7+
name: 'top',
8+
type: 'number',
9+
},
10+
{
11+
name: 'bottom',
12+
type: 'number',
13+
},
14+
{
15+
name: 'left',
16+
type: 'number',
17+
},
18+
{
19+
name: 'right',
20+
type: 'number',
21+
},
22+
],
23+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export default {
2+
name: 'sanity.imageDimensions',
3+
type: 'object',
4+
title: 'Image dimensions',
5+
fields: [
6+
{name: 'height', type: 'number', title: 'Height', readOnly: true},
7+
{name: 'width', type: 'number', title: 'Width', readOnly: true},
8+
{name: 'aspectRatio', type: 'number', title: 'Aspect ratio', readOnly: true},
9+
],
10+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
export default {
2+
name: 'sanity.imageHotspot',
3+
title: 'Image hotspot',
4+
type: 'object',
5+
fields: [
6+
{
7+
name: 'x',
8+
type: 'number',
9+
},
10+
{
11+
name: 'y',
12+
type: 'number',
13+
},
14+
{
15+
name: 'height',
16+
type: 'number',
17+
},
18+
{
19+
name: 'width',
20+
type: 'number',
21+
},
22+
],
23+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
export default {
2+
name: 'sanity.imageMetadata',
3+
title: 'Image metadata',
4+
type: 'object',
5+
fieldsets: [
6+
{
7+
name: 'extra',
8+
title: 'Extra metadata…',
9+
options: {
10+
collapsable: true,
11+
},
12+
},
13+
],
14+
fields: [
15+
{
16+
name: 'location',
17+
type: 'geopoint',
18+
},
19+
{
20+
name: 'dimensions',
21+
title: 'Dimensions',
22+
type: 'sanity.imageDimensions',
23+
fieldset: 'extra',
24+
},
25+
{
26+
name: 'palette',
27+
type: 'sanity.imagePalette',
28+
title: 'Palette',
29+
fieldset: 'extra',
30+
},
31+
{
32+
name: 'lqip',
33+
title: 'LQIP (Low-Quality Image Placeholder)',
34+
type: 'string',
35+
readOnly: true,
36+
},
37+
{
38+
name: 'blurHash',
39+
title: 'BlurHash',
40+
type: 'string',
41+
readOnly: true,
42+
},
43+
{
44+
name: 'hasAlpha',
45+
title: 'Has alpha channel',
46+
type: 'boolean',
47+
readOnly: true,
48+
},
49+
{
50+
name: 'isOpaque',
51+
title: 'Is opaque',
52+
type: 'boolean',
53+
readOnly: true,
54+
},
55+
],
56+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export default {
2+
name: 'sanity.imagePalette',
3+
title: 'Image palette',
4+
type: 'object',
5+
fields: [
6+
{name: 'darkMuted', type: 'sanity.imagePaletteSwatch', title: 'Dark Muted'},
7+
{name: 'lightVibrant', type: 'sanity.imagePaletteSwatch', title: 'Light Vibrant'},
8+
{name: 'darkVibrant', type: 'sanity.imagePaletteSwatch', title: 'Dark Vibrant'},
9+
{name: 'vibrant', type: 'sanity.imagePaletteSwatch', title: 'Vibrant'},
10+
{name: 'dominant', type: 'sanity.imagePaletteSwatch', title: 'Dominant'},
11+
{name: 'lightMuted', type: 'sanity.imagePaletteSwatch', title: 'Light Muted'},
12+
{name: 'muted', type: 'sanity.imagePaletteSwatch', title: 'Muted'},
13+
],
14+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export default {
2+
name: 'sanity.imagePaletteSwatch',
3+
title: 'Image palette swatch',
4+
type: 'object',
5+
fields: [
6+
{name: 'background', type: 'string', title: 'Background', readOnly: true},
7+
{name: 'foreground', type: 'string', title: 'Foreground', readOnly: true},
8+
{name: 'population', type: 'number', title: 'Population', readOnly: true},
9+
{name: 'title', type: 'string', title: 'String', readOnly: true},
10+
],
11+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
export default {
2+
title: 'Slug',
3+
name: 'slug',
4+
type: 'object',
5+
fields: [
6+
{
7+
name: 'current',
8+
title: 'Current slug',
9+
type: 'string',
10+
},
11+
{
12+
// The source field is deprecated/unused, but leaving it included and hidden
13+
// to prevent rendering "Unknown field" warnings on legacy data
14+
name: 'source',
15+
title: 'Source field',
16+
type: 'string',
17+
hidden: true,
18+
},
19+
],
20+
}

‎packages/sanity/package.config.ts

+5
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ export default defineConfig({
3939
require: './lib/_internal/cli/threads/validateSchema.js',
4040
default: './lib/_internal/cli/threads/validateSchema.js',
4141
},
42+
'./_internal/cli/threads/extractSchema': {
43+
source: './src/_internal/cli/threads/extractSchema.ts',
44+
require: './lib/_internal/cli/threads/extractSchema.js',
45+
default: './lib/_internal/cli/threads/extractSchema.js',
46+
},
4247
}),
4348

4449
extract: {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import {writeFile} from 'node:fs/promises'
2+
import {dirname, join} from 'node:path'
3+
import {Worker} from 'node:worker_threads'
4+
5+
import {type CliCommandArguments, type CliCommandContext} from '@sanity/cli'
6+
import readPkgUp from 'read-pkg-up'
7+
8+
import {
9+
type ExtractSchemaWorkerData,
10+
type ExtractSchemaWorkerResult,
11+
} from '../../threads/extractSchema'
12+
13+
interface ExtractFlags {
14+
workspace?: string
15+
path?: string
16+
'enforce-required-fields'?: boolean
17+
format?: 'groq-type-nodes' | string
18+
}
19+
20+
export type SchemaValidationFormatter = (result: ExtractSchemaWorkerResult) => string
21+
22+
export default async function extractAction(
23+
args: CliCommandArguments<ExtractFlags>,
24+
{workDir, output}: CliCommandContext,
25+
): Promise<void> {
26+
const flags = args.extOptions
27+
const formatFlat = flags.format || 'groq-type-nodes'
28+
29+
const rootPkgPath = readPkgUp.sync({cwd: __dirname})?.path
30+
if (!rootPkgPath) {
31+
throw new Error('Could not find root directory for `sanity` package')
32+
}
33+
34+
const workerPath = join(
35+
dirname(rootPkgPath),
36+
'lib',
37+
'_internal',
38+
'cli',
39+
'threads',
40+
'extractSchema.js',
41+
)
42+
43+
const spinner = output
44+
.spinner({})
45+
.start(
46+
flags['enforce-required-fields']
47+
? 'Extracting schema, with enforced required fields'
48+
: 'Extracting schema',
49+
)
50+
51+
const worker = new Worker(workerPath, {
52+
workerData: {
53+
workDir,
54+
workspaceName: flags.workspace,
55+
enforceRequiredFields: flags['enforce-required-fields'],
56+
format: formatFlat,
57+
} satisfies ExtractSchemaWorkerData,
58+
// eslint-disable-next-line no-process-env
59+
env: process.env,
60+
})
61+
62+
const {schema} = await new Promise<ExtractSchemaWorkerResult>((resolve, reject) => {
63+
worker.addListener('message', resolve)
64+
worker.addListener('error', reject)
65+
})
66+
67+
const path = flags.path || join(process.cwd(), 'schema.json')
68+
69+
spinner.text = `Writing schema to ${path}`
70+
71+
await writeFile(path, JSON.stringify(schema, null, 2))
72+
73+
spinner.succeed('Extracted schema')
74+
}

‎packages/sanity/src/_internal/cli/commands/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import listMigrationsCommand from './migration/listMigrationsCommand'
4646
import migrationGroup from './migration/migrationGroup'
4747
import runMigrationCommand from './migration/runMigrationCommand'
4848
import previewCommand from './preview/previewCommand'
49+
import extractSchemaCommand from './schema/extractSchemaCommand'
4950
import schemaGroup from './schema/schemaGroup'
5051
import validateSchemaCommand from './schema/validateSchemaCommand'
5152
import startCommand from './start/startCommand'
@@ -105,6 +106,7 @@ const commands: (CliCommandDefinition | CliCommandGroupDefinition)[] = [
105106
startCommand,
106107
schemaGroup,
107108
validateSchemaCommand,
109+
extractSchemaCommand,
108110
previewCommand,
109111
uninstallCommand,
110112
execCommand,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import {type CliCommandDefinition} from '@sanity/cli'
2+
3+
const description = 'Extracts a JSON representation of a Sanity schema within a Studio context.'
4+
5+
const helpText = `
6+
**Note**: This command is experimental and subject to change.
7+
8+
Options
9+
--workspace <name> The name of the workspace to generate a schema for
10+
--path Optional path to specify destination of the schema file
11+
--enforce-required-fields Makes the schema generated treat fields marked as required as non-optional. Defaults to false.
12+
--format=[groq-type-nodes] Format the schema as GROQ type nodes. Only available format at the moment.
13+
14+
Examples
15+
# Extracts schema types in a Sanity project with more than one workspace
16+
sanity schema extract --workspace default
17+
`
18+
19+
const extractSchemaCommand: CliCommandDefinition = {
20+
name: 'extract',
21+
group: 'schema',
22+
signature: '',
23+
description,
24+
helpText,
25+
hideFromHelp: true,
26+
action: async (args, context) => {
27+
const mod = await import('../../actions/schema/extractAction')
28+
29+
return mod.default(args, context)
30+
},
31+
} satisfies CliCommandDefinition
32+
33+
export default extractSchemaCommand
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import {isMainThread, parentPort, workerData as _workerData} from 'node:worker_threads'
2+
3+
import {extractSchema} from '@sanity/schema/_internal'
4+
import {type Workspace} from 'sanity'
5+
6+
import {getStudioWorkspaces} from '../util/getStudioWorkspaces'
7+
import {mockBrowserEnvironment} from '../util/mockBrowserEnvironment'
8+
9+
export interface ExtractSchemaWorkerData {
10+
workDir: string
11+
workspaceName?: string
12+
enforceRequiredFields?: boolean
13+
format: 'groq-type-nodes' | string
14+
}
15+
16+
export interface ExtractSchemaWorkerResult {
17+
schema: ReturnType<typeof extractSchema>
18+
}
19+
20+
if (isMainThread || !parentPort) {
21+
throw new Error('This module must be run as a worker thread')
22+
}
23+
24+
const opts = _workerData as ExtractSchemaWorkerData
25+
const cleanup = mockBrowserEnvironment(opts.workDir)
26+
27+
async function main() {
28+
try {
29+
if (opts.format !== 'groq-type-nodes') {
30+
throw new Error(`Unsupported format: "${opts.format}"`)
31+
}
32+
33+
const workspaces = await getStudioWorkspaces({basePath: opts.workDir})
34+
35+
const workspace = getWorkspace({workspaces, workspaceName: opts.workspaceName})
36+
37+
const schema = extractSchema(workspace.schema, {
38+
enforceRequiredFields: opts.enforceRequiredFields,
39+
})
40+
41+
parentPort?.postMessage({
42+
schema,
43+
} satisfies ExtractSchemaWorkerResult)
44+
} finally {
45+
cleanup()
46+
}
47+
}
48+
49+
main()
50+
51+
function getWorkspace({
52+
workspaces,
53+
workspaceName,
54+
}: {
55+
workspaces: Workspace[]
56+
workspaceName?: string
57+
}): Workspace {
58+
if (workspaces.length === 0) {
59+
throw new Error('No studio configuration found')
60+
}
61+
62+
if (workspaces.length === 1) {
63+
return workspaces[0]
64+
}
65+
66+
if (workspaceName === undefined) {
67+
throw new Error(
68+
`Multiple workspaces found. Please specify which workspace to use with '--workspace'.`,
69+
)
70+
}
71+
const workspace = workspaces.find((w) => w.name === workspaceName)
72+
if (!workspace) {
73+
throw new Error(`Could not find workspace "${workspaceName}"`)
74+
}
75+
return workspace
76+
}

‎pnpm-lock.yaml

+15
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)
Please sign in to comment.