@sanity/context 0.0.3 → 0.0.5

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,111 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Preuninstall script for @sanity/context
4
+ * Removes skill files from consuming project's AI assistant skill directories
5
+ *
6
+ * Fails silently to never break npm uninstall
7
+ */
8
+
9
+ import {existsSync, readdirSync, rmSync} from 'node:fs'
10
+ import {dirname, join, resolve} from 'node:path'
11
+ import {fileURLToPath} from 'node:url'
12
+
13
+ const __dirname = dirname(fileURLToPath(import.meta.url))
14
+ const SKILL_FOLDER_NAME = 'sanity-context-mcp'
15
+
16
+ const SKILL_DIRS = ['.claude/skills', '.cursor/skills', '.windsurf/skills']
17
+
18
+ /**
19
+ * Find the consuming project's root directory
20
+ */
21
+ function findProjectRoot() {
22
+ let current = resolve(__dirname, '..')
23
+
24
+ while (current !== dirname(current)) {
25
+ current = dirname(current)
26
+
27
+ if (current.includes('node_modules')) {
28
+ continue
29
+ }
30
+
31
+ if (existsSync(join(current, 'package.json'))) {
32
+ return current
33
+ }
34
+ }
35
+
36
+ return null
37
+ }
38
+
39
+ /**
40
+ * Check if we're running in development mode (not as a dependency)
41
+ */
42
+ function isDevMode() {
43
+ if (!__dirname.includes('node_modules')) {
44
+ return true
45
+ }
46
+
47
+ const initCwd = process.env.INIT_CWD
48
+ const packageDir = resolve(__dirname, '..')
49
+
50
+ if (initCwd && resolve(initCwd) === packageDir) {
51
+ return true
52
+ }
53
+
54
+ return false
55
+ }
56
+
57
+ /**
58
+ * Remove directory if empty
59
+ */
60
+ function removeIfEmpty(dirPath) {
61
+ try {
62
+ if (existsSync(dirPath)) {
63
+ const contents = readdirSync(dirPath)
64
+ if (contents.length === 0) {
65
+ rmSync(dirPath, {recursive: true})
66
+ }
67
+ }
68
+ } catch {
69
+ // Ignore errors
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Main uninstall logic
75
+ */
76
+ function main() {
77
+ try {
78
+ if (isDevMode()) {
79
+ return
80
+ }
81
+
82
+ const projectRoot = findProjectRoot()
83
+ if (!projectRoot) {
84
+ return
85
+ }
86
+
87
+ for (const skillDir of SKILL_DIRS) {
88
+ const targetDir = join(projectRoot, skillDir, SKILL_FOLDER_NAME)
89
+
90
+ try {
91
+ if (existsSync(targetDir)) {
92
+ // Remove the entire skill folder
93
+ rmSync(targetDir, {recursive: true, force: true})
94
+
95
+ // Clean up empty parent directories
96
+ const parentDir = join(projectRoot, skillDir)
97
+ removeIfEmpty(parentDir)
98
+
99
+ const grandparentDir = dirname(parentDir)
100
+ removeIfEmpty(grandparentDir)
101
+ }
102
+ } catch {
103
+ continue
104
+ }
105
+ }
106
+ } catch {
107
+ // Fail silently - never break npm uninstall
108
+ }
109
+ }
110
+
111
+ main()
@@ -0,0 +1,73 @@
1
+ ---
2
+ name: sanity-context-mcp
3
+ description: Install and configure the Sanity Context MCP plugin in a Sanity Studio and create AgentContext documents.
4
+ ---
5
+
6
+ # Sanity Context MCP Setup
7
+
8
+ Help developers install the Context MCP plugin and configure it in their Sanity Studio.
9
+
10
+ ## What is Context MCP?
11
+
12
+ An MCP server that gives AI agents structured access to Sanity content. Preserves schema structure instead of flattening to embeddings — agents can filter by real field values, follow references, and combine structural queries with semantic search.
13
+
14
+ ## Setup tasks
15
+
16
+ ### 1. Install the Studio plugin
17
+
18
+ Install `@sanity/context` and add it to your Sanity Studio configuration:
19
+
20
+ ```ts
21
+ // sanity.config.ts
22
+ import {contextPlugin} from '@sanity/context/studio'
23
+
24
+ export default defineConfig({
25
+ // ...
26
+ plugins: [contextPlugin()],
27
+ })
28
+ ```
29
+
30
+ This adds the `sanity.agentContext` document type to the Studio.
31
+
32
+ ### 2. Create an AgentContext document
33
+
34
+ Create a `sanity.agentContext` document in the Studio with:
35
+
36
+ - A `name` for display
37
+ - A `slug` (auto-generated from name) that identifies the context
38
+ - A `groqFilter` to scope what content the agent can access
39
+
40
+ The slug becomes the MCP URL: `https://context-mcp.sanity.io/mcp/:projectId/:dataset/:slug`
41
+
42
+ ## Available MCP tools
43
+
44
+ | Tool | Purpose |
45
+ | ----------------- | ------------------------------------------------------ |
46
+ | `initial_context` | Get compressed schema overview (types, fields, counts) |
47
+ | `groq_query` | Execute GROQ queries with optional semantic search |
48
+ | `schema_explorer` | Get detailed schema for a specific type |
49
+
50
+ ## GROQ with semantic search
51
+
52
+ Context MCP supports `text::embedding()` for semantic ranking inside `score()`:
53
+
54
+ - **Basic filtering** — Standard GROQ filters on type, fields, references
55
+ - **Semantic ranking** — Use `score(text::embedding("search terms"))` to rank by meaning
56
+ - **Hybrid search** — Combine structural filters with `score(text::embedding(...))` for hybrid queries
57
+
58
+ Always use `order(_score desc)` when using `score()` to get best matches first.
59
+
60
+ Example hybrid query:
61
+
62
+ ```groq
63
+ *[_type == "product" && category == "sweaters" && price < 100]
64
+ | score(text::embedding("cozy warm comfortable"))
65
+ | order(_score desc)
66
+ { _id, title, price }[0...10]
67
+ ```
68
+
69
+ ## Guidelines
70
+
71
+ - Create AgentContext documents before connecting
72
+ - Start with `initial_context` to understand available content types
73
+ - Combine structural filters with semantic search for best results
@@ -2,14 +2,14 @@ import {CopyIcon} from '@sanity/icons'
2
2
  import {Box, Button, Card, Flex, Stack, Text, useToast} from '@sanity/ui'
3
3
  import {getValueAtPath, type InputProps, useDataset, useProjectId} from 'sanity'
4
4
 
5
- export function AgentDocumentInput(props: InputProps) {
5
+ export function AgentContextDocumentInput(props: InputProps) {
6
6
  const dataset = useDataset()
7
7
  const projectId = useProjectId()
8
8
  const toast = useToast()
9
9
 
10
10
  const slug = getValueAtPath(props.value, ['slug'])
11
11
  const currentSlug = slug && typeof slug === 'object' && 'current' in slug ? slug.current : ''
12
- const MCP_URL = `https://context-mcp.sanity.io/${projectId}/${dataset}/${currentSlug}`
12
+ const MCP_URL = `https://context-mcp.sanity.io/mcp/${projectId}/${dataset}/${currentSlug}`
13
13
 
14
14
  const handleCopy = () => {
15
15
  try {
@@ -1,8 +1,12 @@
1
1
  import {DatabaseIcon} from '@sanity/icons'
2
2
  import {defineField, defineType} from 'sanity'
3
3
 
4
+ import {AgentContextDocumentInput} from './AgentContextDocumentInput'
5
+ import {GroqFilterInput} from './groq-filter-input/GroqFilterInput'
6
+
4
7
  /**
5
8
  * The name of the agent context schema type.
9
+ * @beta
6
10
  */
7
11
  export const AGENT_CONTEXT_SCHEMA_TYPE_NAME = 'sanity.agentContext'
8
12
 
@@ -20,11 +24,15 @@ export const agentContextSchema = defineType({
20
24
  title: AGENT_CONTEXT_SCHEMA_TITLE,
21
25
  type: 'document',
22
26
  icon: DatabaseIcon,
27
+ components: {
28
+ input: AgentContextDocumentInput,
29
+ },
23
30
  fields: [
24
31
  defineField({
25
32
  name: 'name',
26
33
  title: 'Name',
27
34
  type: 'string',
35
+ placeholder: 'My Agent Context',
28
36
  }),
29
37
  defineField({
30
38
  name: 'slug',
@@ -34,25 +42,15 @@ export const agentContextSchema = defineType({
34
42
  source: 'name',
35
43
  },
36
44
  }),
37
- defineField({
38
- name: 'organizationId',
39
- title: 'Organization ID',
40
- type: 'string',
41
- }),
42
- defineField({
43
- name: 'projectId',
44
- title: 'Project ID',
45
- type: 'string',
46
- }),
47
- defineField({
48
- name: 'dataset',
49
- title: 'Dataset',
50
- type: 'string',
51
- }),
52
45
  defineField({
53
46
  name: 'groqFilter',
54
- title: 'GROQ filter',
55
- type: 'text',
47
+ title: 'Content filter',
48
+ description:
49
+ 'Define which content AI agents can access. Pick types which will generate the filter, or manually enter the filter in the GROQ tab.',
50
+ type: 'string',
51
+ components: {
52
+ input: GroqFilterInput,
53
+ },
56
54
  }),
57
55
  ],
58
56
  })
@@ -0,0 +1,369 @@
1
+ import {
2
+ CheckmarkIcon,
3
+ ChevronDownIcon,
4
+ CloseIcon,
5
+ ErrorOutlineIcon,
6
+ GroqIcon,
7
+ ListIcon,
8
+ } from '@sanity/icons'
9
+ import {
10
+ Box,
11
+ Button,
12
+ Card,
13
+ Flex,
14
+ Popover,
15
+ Stack,
16
+ Tab,
17
+ TabList,
18
+ TabPanel,
19
+ Text,
20
+ TextArea,
21
+ TextInput,
22
+ Tooltip,
23
+ useClickOutsideEvent,
24
+ } from '@sanity/ui'
25
+ import {useCallback, useMemo, useRef, useState} from 'react'
26
+ import {
27
+ CommandList,
28
+ SanityDefaultPreview,
29
+ set,
30
+ type StringInputProps,
31
+ unset,
32
+ useSchema,
33
+ } from 'sanity'
34
+
35
+ import {isSimpleTypeQuery, listToQuery, queryToList, validateGroq} from './groqUtils'
36
+ import {useComposedRefs} from './useComposedRefs'
37
+
38
+ const TAB_IDS = {
39
+ TYPES_TAB: 'types-tab',
40
+ TYPES_PANEL: 'types-panel',
41
+ GROQ_TAB: 'groq-tab',
42
+ GROQ_PANEL: 'groq-panel',
43
+ } as const
44
+
45
+ const ITEM_HEIGHT = 43
46
+
47
+ export function GroqFilterInput(props: StringInputProps) {
48
+ const {value, onChange, elementProps} = props
49
+ const {ref: refProp, ...restElementProps} = elementProps || {}
50
+
51
+ const [open, setOpen] = useState<boolean>(false)
52
+ const [inputElement, setInputElement] = useState<HTMLInputElement | null>(null)
53
+ const inputRef = useRef<HTMLInputElement>(null)
54
+ const popoverRef = useRef<HTMLDivElement>(null)
55
+ const openListButtonRef = useRef<HTMLButtonElement>(null)
56
+ const [searchQuery, setSearchQuery] = useState<string | null>(null)
57
+
58
+ const schema = useSchema()
59
+
60
+ // Compose the input ref with the ref prop
61
+ const setInputRef = useCallback(
62
+ (node: HTMLInputElement | null) => {
63
+ inputRef.current = node
64
+ setInputElement(node)
65
+ },
66
+ [inputRef],
67
+ )
68
+ const composedRef = useComposedRefs(setInputRef, refProp)
69
+
70
+ // Check if the current query is simple enough to edit via Types UI
71
+ const isSimple = useMemo(() => isSimpleTypeQuery(value), [value])
72
+
73
+ // Validate GROQ syntax
74
+ const validation = useMemo(() => validateGroq(value), [value])
75
+
76
+ // Initialize view based on whether the current query is simple or complex
77
+ const [panel, setPanel] = useState<'types' | 'groq'>(() =>
78
+ isSimpleTypeQuery(value) ? 'types' : 'groq',
79
+ )
80
+
81
+ const selectedTypes = useMemo(() => {
82
+ if (!value) return []
83
+ return queryToList(value)
84
+ }, [value])
85
+
86
+ // Filter the type names based on the search query
87
+ const filteredTypeNames = useMemo(() => {
88
+ const types = schema._original?.types || []
89
+ const typeNames = types.map((type) => type.name).filter((name) => !name.startsWith('sanity.'))
90
+ if (!searchQuery) return typeNames
91
+
92
+ return typeNames.filter((name) => name.toLowerCase().includes(searchQuery.toLowerCase()))
93
+ }, [searchQuery, schema._original?.types])
94
+
95
+ // Handle document type item click.
96
+ // 1. If the item is already selected, remove it from the selected types.
97
+ // 2. If the item is not selected, add it to the selected types.
98
+ // 3. Transform the updated selected types into a GROQ query and set it as the new value.
99
+ const handleDocumentTypeItemClick = (item: string) => {
100
+ const nextValue = selectedTypes.includes(item)
101
+ ? selectedTypes.filter((t) => t !== item)
102
+ : [...selectedTypes, item]
103
+
104
+ onChange(nextValue.length > 0 ? set(listToQuery(nextValue)) : unset())
105
+ }
106
+
107
+ const closeList = useCallback(() => {
108
+ setOpen(false)
109
+ setSearchQuery(null)
110
+ }, [])
111
+
112
+ const handleToggleList = useCallback(() => {
113
+ if (open) {
114
+ closeList()
115
+ } else {
116
+ setOpen(true)
117
+ inputRef.current?.focus()
118
+ }
119
+ }, [open, closeList])
120
+
121
+ const handleTextInputKeyDown = useCallback(
122
+ (event: React.KeyboardEvent<HTMLInputElement>) => {
123
+ if (event.key === 'Escape') {
124
+ closeList()
125
+ }
126
+ },
127
+ [closeList],
128
+ )
129
+
130
+ const getItemSelected = useCallback(
131
+ (index: number) => {
132
+ const item = filteredTypeNames[index]
133
+ return item ? selectedTypes.includes(item) : false
134
+ },
135
+ [filteredTypeNames, selectedTypes],
136
+ )
137
+
138
+ useClickOutsideEvent(
139
+ () => {
140
+ closeList()
141
+ },
142
+ () => [popoverRef.current, inputRef.current, openListButtonRef.current],
143
+ )
144
+
145
+ const isListOpen = open || searchQuery !== null
146
+
147
+ // If query is complex or invalid, force GROQ panel regardless of selection
148
+ const effectivePanel = !isSimple || !validation.valid ? 'groq' : panel
149
+
150
+ return (
151
+ <Stack space={2}>
152
+ <TabList space={1}>
153
+ <Tooltip
154
+ animate
155
+ disabled={isSimple && validation.valid}
156
+ content={
157
+ <Box padding={1} style={{maxWidth: '200px'}}>
158
+ <Text size={1}>
159
+ {!validation.valid
160
+ ? 'The current filter has a syntax error that needs to be fixed in the GROQ tab.'
161
+ : 'The current filter is too complex to edit here. Use the GROQ tab to edit it.'}
162
+ </Text>
163
+ </Box>
164
+ }
165
+ delay={{open: 200, close: 0}}
166
+ placement="bottom"
167
+ portal
168
+ >
169
+ <div>
170
+ <Tab
171
+ aria-controls={TAB_IDS.TYPES_PANEL}
172
+ disabled={!isSimple || !validation.valid}
173
+ icon={ListIcon}
174
+ id={TAB_IDS.TYPES_TAB}
175
+ label="Types"
176
+ onClick={() => isSimple && validation.valid && setPanel('types')}
177
+ padding={3}
178
+ selected={effectivePanel === 'types'}
179
+ />
180
+ </div>
181
+ </Tooltip>
182
+
183
+ <Tab
184
+ aria-controls={TAB_IDS.GROQ_PANEL}
185
+ icon={GroqIcon}
186
+ id={TAB_IDS.GROQ_TAB}
187
+ label="GROQ"
188
+ onClick={() => setPanel('groq')}
189
+ padding={3}
190
+ selected={effectivePanel === 'groq'}
191
+ />
192
+ </TabList>
193
+
194
+ {/* ----- Types panel ----- */}
195
+ <TabPanel
196
+ aria-labelledby={TAB_IDS.TYPES_TAB}
197
+ hidden={effectivePanel !== 'types'}
198
+ id={TAB_IDS.TYPES_PANEL}
199
+ tabIndex={-1}
200
+ >
201
+ <Stack space={3}>
202
+ <Popover
203
+ animate
204
+ constrainSize
205
+ fallbackPlacements={['bottom', 'top']}
206
+ matchReferenceWidth
207
+ open={isListOpen}
208
+ placement="bottom"
209
+ portal
210
+ ref={popoverRef}
211
+ content={
212
+ <Flex direction="column" height="fill">
213
+ {filteredTypeNames.length === 0 && (
214
+ <Flex direction="column" overflow="hidden" flex={1} padding={5}>
215
+ <Text align="center" size={1}>
216
+ No document types found matching <b>{`"${searchQuery}"`}</b>
217
+ </Text>
218
+ </Flex>
219
+ )}
220
+
221
+ {filteredTypeNames.length > 0 && (
222
+ <Flex direction="column" overflow="hidden" flex={1}>
223
+ <CommandList
224
+ activeItemDataAttr="data-hovered"
225
+ ariaLabel="Document Types"
226
+ ariaMultiselectable
227
+ fixedHeight
228
+ getItemKey={(item) => item}
229
+ getItemSelected={getItemSelected}
230
+ inputElement={inputElement}
231
+ itemHeight={ITEM_HEIGHT}
232
+ padding={1}
233
+ items={filteredTypeNames}
234
+ renderItem={(documentTypeName) => {
235
+ const isSelected = selectedTypes.includes(documentTypeName)
236
+ const schemaType = schema.get(documentTypeName)
237
+
238
+ return (
239
+ <Stack key={documentTypeName} padding={1}>
240
+ <Button
241
+ aria-label={`${isSelected ? 'Remove' : 'Add'} ${documentTypeName} to filter`}
242
+ aria-selected={isSelected}
243
+ mode="bleed"
244
+ onClick={() => handleDocumentTypeItemClick(documentTypeName)}
245
+ padding={0}
246
+ >
247
+ <Flex align="center" gap={2}>
248
+ <Box flex={1}>
249
+ <SanityDefaultPreview
250
+ layout="compact"
251
+ title={schemaType?.title || documentTypeName}
252
+ schemaType={schemaType}
253
+ icon={schemaType?.icon}
254
+ />
255
+ </Box>
256
+
257
+ {isSelected && (
258
+ <Box paddingX={3}>
259
+ <Text size={1}>
260
+ <CheckmarkIcon />
261
+ </Text>
262
+ </Box>
263
+ )}
264
+ </Flex>
265
+ </Button>
266
+ </Stack>
267
+ )
268
+ }}
269
+ />
270
+ </Flex>
271
+ )}
272
+ </Flex>
273
+ }
274
+ >
275
+ <Card display="flex" border radius={2} overflow="hidden">
276
+ <Card flex={1} borderRight>
277
+ <TextInput
278
+ {...restElementProps}
279
+ autoComplete="off"
280
+ border={false}
281
+ onChange={(event) => setSearchQuery(event.currentTarget.value)}
282
+ onKeyDown={handleTextInputKeyDown}
283
+ placeholder="Search for document types"
284
+ radius={0}
285
+ ref={composedRef}
286
+ value={searchQuery || ''}
287
+ />
288
+ </Card>
289
+
290
+ <Flex align="center" justify="center" sizing="border" padding={1} height="fill">
291
+ <Button
292
+ aria-label="Open document types list"
293
+ disabled={isListOpen}
294
+ icon={ChevronDownIcon}
295
+ mode="bleed"
296
+ onClick={handleToggleList}
297
+ padding={2}
298
+ ref={openListButtonRef}
299
+ />
300
+ </Flex>
301
+ </Card>
302
+ </Popover>
303
+
304
+ <Flex wrap="wrap" gap={2}>
305
+ {selectedTypes.map((type) => {
306
+ const title = schema.get(type)?.title || type
307
+
308
+ return (
309
+ <Card key={type} padding={1} radius={3} border tone="transparent" paddingLeft={2}>
310
+ <Flex align="center" gap={1} overflow="hidden">
311
+ <Box flex={1}>
312
+ <Text size={1} weight="medium" textOverflow="ellipsis">
313
+ {title}
314
+ </Text>
315
+ </Box>
316
+
317
+ <Button
318
+ aria-label="Remove {type} from filter"
319
+ fontSize={0}
320
+ icon={CloseIcon}
321
+ mode="bleed"
322
+ onClick={() => handleDocumentTypeItemClick(type)}
323
+ padding={2}
324
+ />
325
+ </Flex>
326
+ </Card>
327
+ )
328
+ })}
329
+ </Flex>
330
+ </Stack>
331
+ </TabPanel>
332
+
333
+ {/* ----- GROQ panel ----- */}
334
+ <TabPanel
335
+ aria-labelledby={TAB_IDS.GROQ_TAB}
336
+ hidden={effectivePanel !== 'groq'}
337
+ id={TAB_IDS.GROQ_PANEL}
338
+ tabIndex={-1}
339
+ >
340
+ <Stack space={3}>
341
+ <TextArea
342
+ {...restElementProps}
343
+ onChange={(event) =>
344
+ onChange(event.currentTarget.value ? set(event.currentTarget.value) : unset())
345
+ }
346
+ placeholder='_type in ["author", "post"]'
347
+ value={value || ''}
348
+ style={{fontFamily: 'monospace'}}
349
+ padding={4}
350
+ />
351
+ </Stack>
352
+ </TabPanel>
353
+
354
+ {/* ----- Result and validation errors ----- */}
355
+
356
+ {!validation.valid && (
357
+ <Card padding={3} radius={2} tone="critical" border>
358
+ <Flex align="flex-start" gap={2}>
359
+ <Text size={1}>
360
+ <ErrorOutlineIcon />
361
+ </Text>
362
+
363
+ <Text size={1}>{validation.error}</Text>
364
+ </Flex>
365
+ </Card>
366
+ )}
367
+ </Stack>
368
+ )
369
+ }
@@ -0,0 +1,69 @@
1
+ import {describe, expect, it} from 'vitest'
2
+
3
+ import {isSimpleTypeQuery, listToQuery, queryToList, validateGroq} from './groqUtils'
4
+
5
+ describe('groqUtils', () => {
6
+ describe('listToQuery', () => {
7
+ it('should convert a list of types to a GROQ query', () => {
8
+ expect(listToQuery(['author'])).toBe('_type in ["author"]')
9
+ expect(listToQuery(['author', 'book'])).toBe('_type in ["author", "book"]')
10
+ })
11
+
12
+ it('should return empty brackets for empty list', () => {
13
+ expect(listToQuery([])).toBe('_type in []')
14
+ })
15
+ })
16
+
17
+ describe('queryToList', () => {
18
+ it('should parse types from a GROQ query', () => {
19
+ expect(queryToList('_type in ["author"]')).toEqual(['author'])
20
+ expect(queryToList('_type in ["author", "book"]')).toEqual(['author', 'book'])
21
+ })
22
+
23
+ it('should handle queries with extra whitespace', () => {
24
+ expect(queryToList('_type in ["author" , "book"]')).toEqual(['author', 'book'])
25
+ })
26
+
27
+ it('should return empty array for non-matching queries', () => {
28
+ expect(queryToList('_type == "author"')).toEqual([])
29
+ expect(queryToList('')).toEqual([])
30
+ })
31
+ })
32
+
33
+ describe('isSimpleTypeQuery', () => {
34
+ it('should return true for simple _type in [...] queries', () => {
35
+ expect(isSimpleTypeQuery('_type in ["author"]')).toBe(true)
36
+ expect(isSimpleTypeQuery('_type in ["author", "book"]')).toBe(true)
37
+ expect(isSimpleTypeQuery('_type in []')).toBe(true)
38
+ })
39
+
40
+ it('should return true for undefined/empty queries', () => {
41
+ expect(isSimpleTypeQuery(undefined)).toBe(true)
42
+ expect(isSimpleTypeQuery('')).toBe(true)
43
+ })
44
+
45
+ it('should return false for complex queries', () => {
46
+ expect(isSimpleTypeQuery('_type in ["author"] && published')).toBe(false)
47
+ expect(isSimpleTypeQuery('_type == "author"')).toBe(false)
48
+ expect(isSimpleTypeQuery('*[_type in ["author"]]')).toBe(false)
49
+ })
50
+ })
51
+
52
+ describe('validateGroq', () => {
53
+ it('should return valid for correct GROQ syntax', () => {
54
+ expect(validateGroq('_type in ["author"]')).toEqual({valid: true})
55
+ expect(validateGroq('_type == "author" && published')).toEqual({valid: true})
56
+ })
57
+
58
+ it('should return valid for undefined/empty queries', () => {
59
+ expect(validateGroq(undefined)).toEqual({valid: true})
60
+ expect(validateGroq('')).toEqual({valid: true})
61
+ })
62
+
63
+ it('should return invalid with error for bad GROQ syntax', () => {
64
+ const result = validateGroq('_type in ["author"')
65
+ expect(result.valid).toBe(false)
66
+ expect(result.error).toBeDefined()
67
+ })
68
+ })
69
+ })