@sanity-labs/groq-content-mapper 0.0.0-stage → 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sanity Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,229 @@
1
- # Temporary Holding Version
1
+ # @sanity-labs/groq-content-mapper
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Typed `.groq` file imports for TypeScript 7.1 and later. A query kept in its own file is imported as a string that carries its result and params types, inferred from your Sanity schema:
4
+
5
+ ```groq
6
+ // postsBySearch.groq
7
+ *[_type == "post" && title match $searchQuery] | order(_createdAt desc) [$start...$end] {
8
+ _id,
9
+ title,
10
+ slug
11
+ }
12
+ ```
13
+
14
+ ```ts
15
+ import postsBySearch, {type QueryResponse} from './postsBySearch.groq' with {type: 'text'}
16
+
17
+ const posts = await client.fetch(postsBySearch, {searchQuery: 'foo', start: 0, end: 10})
18
+ // ^? QueryResponse, with the params checked
19
+ ```
20
+
21
+ This is experimental. It is a TypeScript 7.1 [content mapper](https://devblogs.microsoft.com/typescript/announcing-typescript-7-1-beta/): there is no generated declaration file, and nothing for earlier TypeScript releases. The type generation itself comes from [`@sanity/codegen`](https://github.com/sanity-io/codegen), which this package depends on, so you do not install it yourself.
22
+
23
+ ## What an import gives you
24
+
25
+ Each `.groq` file is a module with three exports:
26
+
27
+ - The default export is the query string, typed as its own literal with the result and params types attached as optional phantom properties. It is still assignable to `string`. The literal is the file's exact content, trailing newline included, because that is what a text import evaluates to.
28
+ - `QueryResponse` is the result type, evaluated the way `defineQuery` results are, so `[0]` is `T | null`.
29
+ - `QueryParams` is the object the `$` placeholders expect. A placeholder compared with a field takes the field's type, `match` takes a string, slice bounds are numbers, and anything the schema says nothing about is `unknown`. A query without placeholders has `QueryParams = Record<string, never>`.
30
+
31
+ ```ts
32
+ // What './postsBySearch.groq' resolves to
33
+ export type QueryResponse = Array<{_id: string; title: string | null; slug: Slug | null}>
34
+ export type QueryParams = {searchQuery: string; start: number; end: number}
35
+ declare const query: '*[_type == "post" && title match $searchQuery] …\n' & {
36
+ readonly __groqResult?: QueryResponse
37
+ readonly __groqParams?: QueryParams
38
+ }
39
+ export default query
40
+ ```
41
+
42
+ Nothing is declared globally and nothing is imported. `@sanity/client` reads the brand structurally once [sanity-io/client#1373](https://github.com/sanity-io/client/pull/1373) is released, so `client.fetch(query, params)` infers both types. With an older client, pass the type yourself: `client.fetch<QueryResponse>(query, params)`. The brand is also exported as `GroqQuery<Query, Result, Params>`, with `GroqResult<Q>` and `GroqParams<Q>` to read it back.
43
+
44
+ ### Imports
45
+
46
+ A value import needs the `with {type: 'text'}` attribute, which is the standard text import ([TC39 import text](https://github.com/tc39/proposal-import-text)): at run time the default export is the file's content. TypeScript ignores the attribute and asks the content mapper for the type.
47
+
48
+ A type-only import needs no attribute, and since it is erased it keeps the query string out of the bundle. Use it in code that only needs the types, such as components rendering a result:
49
+
50
+ ```ts
51
+ import type {QueryResponse} from './postsBySearch.groq'
52
+
53
+ export function PostList(props: {posts: QueryResponse}) {}
54
+ ```
55
+
56
+ When a file needs both the query and its types, write one statement with inline type specifiers:
57
+
58
+ ```ts
59
+ import postsBySearch, {type QueryResponse} from './postsBySearch.groq' with {type: 'text'}
60
+ ```
61
+
62
+ rather than a value import plus a separate `import type` from the same file. ESLint's `import-x/no-duplicates` autofix merges the two and drops or mangles the attribute in the process.
63
+
64
+ ## Setup
65
+
66
+ You need TypeScript 7.1 or later and Node 22.12 or later.
67
+
68
+ ```sh
69
+ npm install -D @sanity-labs/groq-content-mapper typescript@beta
70
+ ```
71
+
72
+ If your app runs `.groq` imports on Node through the `register` preload below (rather than as a bundle), install the package as a regular dependency instead.
73
+
74
+ Write the schema file with the Sanity CLI:
75
+
76
+ ```sh
77
+ npx sanity schema extract
78
+ ```
79
+
80
+ Register the mapper in `tsconfig.json`, next to `compilerOptions`. The tsconfig is the only place it is configured, and `schema` is required:
81
+
82
+ ```jsonc
83
+ // tsconfig.json
84
+ {
85
+ "compilerOptions": {/* ... */},
86
+ "contentMappers": [
87
+ {
88
+ "package": "@sanity-labs/groq-content-mapper",
89
+ "extensions": [".groq"],
90
+ "options": {"schema": "./schema.json"},
91
+ },
92
+ ],
93
+ }
94
+ ```
95
+
96
+ Paths are relative to the tsconfig. Type-check with `--runExternalCode`, the flag that lets `tsc` start content mappers:
97
+
98
+ ```jsonc
99
+ // package.json
100
+ {
101
+ "scripts": {
102
+ "typecheck": "tsc --noEmit --runExternalCode",
103
+ },
104
+ }
105
+ ```
106
+
107
+ The schema file is watched, so extracting it again retypes the imports. A file that cannot be typed, because the query does not parse or the schema is missing, still resolves, with `unknown` for what could not be inferred and a diagnostic saying why. GROQ syntax errors are reported at the `.groq` file as `error groq2`.
108
+
109
+ ### Editor
110
+
111
+ In VS Code or Cursor, install the TypeScript 7 extension (`TypeScriptTeam.native-preview`) and point it at the project's TypeScript, since the extension bundles 7.0:
112
+
113
+ ```jsonc
114
+ // .vscode/settings.json
115
+ {
116
+ "js/ts.experimental.useTsgo": true,
117
+ "js/ts.tsdk.path": "./node_modules/typescript",
118
+ }
119
+ ```
120
+
121
+ Trust the workspace, and allow the extension to use the workspace's TypeScript when it asks.
122
+
123
+ ## One schema per folder
124
+
125
+ A codebase that queries more than one project or dataset has one schema per folder. TypeScript registers a content mapper by file extension only, and rejects two entries for `.groq`, so the folders are listed inside the one entry with `schemas`:
126
+
127
+ ```jsonc
128
+ {
129
+ "contentMappers": [
130
+ {
131
+ "package": "@sanity-labs/groq-content-mapper",
132
+ "extensions": [".groq"],
133
+ "options": {
134
+ "schemas": [
135
+ {"include": "src/queries/blog", "schema": "schemas/blog.json"},
136
+ {"include": ["src/queries/shop", "src/queries/checkout"], "schema": "schemas/shop.json"},
137
+ ],
138
+ },
139
+ },
140
+ ],
141
+ }
142
+ ```
143
+
144
+ A file is typed against the schema of the deepest folder that contains it, and against `schema` when no folder does. Without a `schema`, a file outside every folder gets a diagnostic.
145
+
146
+ Folders work well with a `package.json` `imports` alias. TypeScript resolves the alias to the file before asking the mapper, so each folder's schema still applies, and Node, Bun, Deno and the bundlers resolve `imports` themselves:
147
+
148
+ ```jsonc
149
+ // package.json
150
+ {
151
+ "imports": {
152
+ "#queries/*": "./src/queries/*",
153
+ },
154
+ }
155
+ ```
156
+
157
+ ```ts
158
+ import posts from '#queries/blog/posts.groq' with {type: 'text'}
159
+ import products from '#queries/shop/products.groq' with {type: 'text'}
160
+ ```
161
+
162
+ ## Running the imports
163
+
164
+ At run time `with {type: 'text'}` makes the runtime or bundler deliver the file as a string. Many do this on their own; for the rest, this package has one entry each. None of them runs type generation.
165
+
166
+ | Runtime or bundler | Setup |
167
+ | --------------------------------------------- | ----------------------------------------------------------------------- |
168
+ | Bun, Deno, esbuild, `Bun.build` | nothing |
169
+ | webpack 5.102 and later, Rspack 1.7 and later | nothing |
170
+ | webpack before 5.102 | `{test: /\.groq$/, type: 'asset/source'}` in `module.rules` |
171
+ | Rolldown | `moduleTypes: {'.groq': 'text'}` |
172
+ | Node 26.5 and later | `--experimental-import-text`, or the preload below |
173
+ | Node 22.12 and later | `node --import @sanity-labs/groq-content-mapper/register main.ts` |
174
+ | Vite, Vitest, Rollup | the Vite plugin |
175
+ | Sanity Studio, App SDK apps | the Vite plugin, in `vite: {plugins: [groqFiles()]}` of `sanity.cli.ts` |
176
+ | Next.js 16.3 and later | `withGroqFiles` |
177
+ | Jest | the Jest transformer, with tests compiled to CommonJS |
178
+
179
+ ### Node
180
+
181
+ ```sh
182
+ node --import @sanity-labs/groq-content-mapper/register main.ts
183
+ ```
184
+
185
+ The preload uses `module.registerHooks` where Node has it (22.15 and later) and `module.register` before that.
186
+
187
+ ### Vite, Vitest, Rollup
188
+
189
+ ```ts
190
+ // vite.config.ts
191
+ import groqFiles from '@sanity-labs/groq-content-mapper/vite'
192
+ import {defineConfig} from 'vite'
193
+
194
+ export default defineConfig({plugins: [groqFiles()]})
195
+ ```
196
+
197
+ Vite's dev server does not handle the attribute itself, so the plugin is needed there even where the build would work without it.
198
+
199
+ ### Next.js
200
+
201
+ ```ts
202
+ // next.config.ts
203
+ import {withGroqFiles} from '@sanity-labs/groq-content-mapper/next'
204
+
205
+ export default withGroqFiles(nextConfig)
206
+ ```
207
+
208
+ Turbopack handles the attribute on its own. The wrapper adds an `asset/source` rule for the webpack that Next bundles, which predates webpack's built-in support. It also takes over the type check `next build` runs: Next runs `tsc` without `--runExternalCode`, so its check fails on every `.groq` import. The wrapper turns Next's check off and runs the same check with the flag after the compile. If your config already sets `typescript.ignoreBuildErrors`, no check is run.
209
+
210
+ ### Jest
211
+
212
+ ```js
213
+ // jest.config.js
214
+ export default {
215
+ transform: {'\\.groq$': '@sanity-labs/groq-content-mapper/jest'},
216
+ }
217
+ ```
218
+
219
+ Jest's ES module loader rejects `type: 'text'`, so this works for tests compiled to CommonJS, where Babel drops the attribute.
220
+
221
+ ## Limitations
222
+
223
+ - A comparison against `^` (the parent scope) types its placeholder as `unknown`.
224
+ - groq-js does not model `score()` yet, so `_score` is typed as `null`, and a `[_score > 0]` filter empties the result type.
225
+ - `include` takes folders, not globs.
226
+
227
+ ## License
228
+
229
+ MIT
@@ -0,0 +1 @@
1
+ export {}
@@ -0,0 +1,11 @@
1
+ import { t as ContentMapper } from "../contentMapper-BMNwbuMF.js";
2
+ try {
3
+ await new ContentMapper().serve({
4
+ input: process.stdin,
5
+ output: process.stdout
6
+ });
7
+ } catch (error) {
8
+ process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`), process.exitCode = 1;
9
+ }
10
+
11
+ //# sourceMappingURL=contentMapper.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"contentMapper.js","names":[],"sources":["../../src/bin/contentMapper.ts"],"sourcesContent":["// The command in package.json `typescript.contentMapper.exec`. TypeScript 7.1 starts it with\n// stdio pipes, speaks the content mapper protocol over them, and closes stdin to stop it.\nimport {ContentMapper} from '../contentMapper.js'\n\ntry {\n await new ContentMapper().serve({input: process.stdin, output: process.stdout})\n} catch (error) {\n process.stderr.write(\n `${error instanceof Error ? (error.stack ?? error.message) : String(error)}\\n`,\n )\n process.exitCode = 1\n}\n"],"mappings":";AAIA,IAAI;CACF,MAAM,IAAI,cAAc,CAAC,CAAC,MAAM;EAAC,OAAO,QAAQ;EAAO,QAAQ,QAAQ;CAAM,CAAC;AAChF,SAAS,OAAO;CAId,AAHA,QAAQ,OAAO,MACb,GAAG,iBAAiB,QAAS,MAAM,SAAS,MAAM,UAAW,OAAO,KAAK,EAAE,GAC7E,GACA,QAAQ,WAAW;AACrB"}