@sanity-labs/groq-content-mapper 0.0.0 → 0.1.1

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/README.md CHANGED
@@ -1,2 +1,228 @@
1
- # groq-content-mapper
2
- Import .groq files as typed query strings, powered by a TypeScript 7.1 content mapper
1
+ # @sanity-labs/groq-content-mapper
2
+
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
+ ## Requirements
6
+
7
+ - **TypeScript 7.1 or later.** Content mappers do not exist in earlier versions. 7.1 is currently a beta: `npm install -D typescript@beta`.
8
+ - **Node.js 22.12 or later.** On Node 26.5 and later, run `.groq` imports with `--experimental-import-text`, or with the preload below. On earlier Node, run `node --import @sanity-labs/groq-content-mapper/register`.
9
+ - **Type-check with `tsc --runExternalCode`.** Without that flag the mapper does not run, and `.groq` imports are not typed.
10
+
11
+ ```jsonc
12
+ // package.json
13
+ {
14
+ "scripts": {
15
+ "typecheck": "tsc --noEmit --runExternalCode",
16
+ },
17
+ }
18
+ ```
19
+
20
+ ## Usage
21
+
22
+ ```groq
23
+ // postsBySearch.groq
24
+ *[_type == "post" && title match $searchQuery] | order(_createdAt desc) [$start...$end] {
25
+ _id,
26
+ title,
27
+ slug
28
+ }
29
+ ```
30
+
31
+ ```ts
32
+ import postsBySearch, {type QueryResponse} from './postsBySearch.groq' with {type: 'text'}
33
+
34
+ const posts = await client.fetch(postsBySearch, {searchQuery: 'foo', start: 0, end: 10})
35
+ // ^? QueryResponse, with the params checked
36
+ ```
37
+
38
+ Types come from the file as it is. Nothing is written to disk, and nothing is added to the global scope.
39
+
40
+ ## What an import gives you
41
+
42
+ Each `.groq` file has three exports:
43
+
44
+ - The default export is the query string, typed as its own text, and still assignable to `string`. The type includes the file's trailing newline, because that is what the import evaluates to.
45
+ - `QueryResponse` is the result type. `[0]` is `T | null`.
46
+ - `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>`.
47
+
48
+ ```ts
49
+ export type QueryResponse = Array<{_id: string; title: string | null; slug: Slug | null}>
50
+ export type QueryParams = {searchQuery: string; start: number; end: number}
51
+ declare const query: '*[_type == "post" && title match $searchQuery] …\n' & {
52
+ readonly __groqResult?: QueryResponse
53
+ readonly __groqParams?: QueryParams
54
+ }
55
+ export default query
56
+ ```
57
+
58
+ `client.fetch(query, params)` infers both types once `@sanity/client` includes [sanity-io/client#1373](https://github.com/sanity-io/client/pull/1373). With an older client, pass the type yourself: `client.fetch<QueryResponse>(query, params)`. `GroqQuery<Query, Result, Params>`, `GroqResult<Q>` and `GroqParams<Q>` are exported for the same shape.
59
+
60
+ ### Imports
61
+
62
+ A value import needs `with {type: 'text'}`, the [standard text import](https://github.com/tc39/proposal-import-text). At run time the default export is the file's content.
63
+
64
+ A type-only import needs no attribute. It is erased, so the query string stays out of the bundle:
65
+
66
+ ```ts
67
+ import type {QueryResponse} from './postsBySearch.groq'
68
+
69
+ export function PostList(props: {posts: QueryResponse}) {}
70
+ ```
71
+
72
+ When a file needs both the query and its types, write one statement:
73
+
74
+ ```ts
75
+ import postsBySearch, {type QueryResponse} from './postsBySearch.groq' with {type: 'text'}
76
+ ```
77
+
78
+ A separate `import type` from the same file gets merged by ESLint's `import-x/no-duplicates` autofix, which drops or mangles the attribute.
79
+
80
+ ## Setup
81
+
82
+ ```sh
83
+ npm install -D @sanity-labs/groq-content-mapper typescript@beta
84
+ ```
85
+
86
+ If the app runs `.groq` imports on Node through the preload (rather than as a bundle), install the package as a regular dependency instead.
87
+
88
+ ```sh
89
+ npx sanity schema extract
90
+ ```
91
+
92
+ `schema` is required, and the tsconfig is the only configuration. Paths are relative to it:
93
+
94
+ ```jsonc
95
+ // tsconfig.json
96
+ {
97
+ "compilerOptions": {/* ... */},
98
+ "contentMappers": [
99
+ {
100
+ "package": "@sanity-labs/groq-content-mapper",
101
+ "extensions": [".groq"],
102
+ "options": {"schema": "./schema.json"},
103
+ },
104
+ ],
105
+ }
106
+ ```
107
+
108
+ Type-check with the `tsc --noEmit --runExternalCode` script above. The schema file is watched, so extracting it again retypes the imports. A query that does not parse, or a missing schema, still resolves: the unknown parts are `unknown`, and the diagnostic says why. A GROQ syntax error is reported on the `.groq` file as `error groq2`.
109
+
110
+ ### Editor
111
+
112
+ In VS Code or Cursor, install the TypeScript 7 extension (`TypeScriptTeam.native-preview`) and point it at the project's TypeScript. The extension bundles 7.0:
113
+
114
+ ```jsonc
115
+ // .vscode/settings.json
116
+ {
117
+ "js/ts.experimental.useTsgo": true,
118
+ "js/ts.tsdk.path": "./node_modules/typescript",
119
+ }
120
+ ```
121
+
122
+ Trust the workspace, and allow the extension to use the workspace's TypeScript when it asks.
123
+
124
+ ## One schema per folder
125
+
126
+ A codebase that queries more than one project or dataset has one schema per folder. TypeScript allows one mapper entry per extension, so list the folders inside that entry with `schemas`:
127
+
128
+ ```jsonc
129
+ {
130
+ "contentMappers": [
131
+ {
132
+ "package": "@sanity-labs/groq-content-mapper",
133
+ "extensions": [".groq"],
134
+ "options": {
135
+ "schemas": [
136
+ {"include": "src/queries/blog", "schema": "schemas/blog.json"},
137
+ {"include": ["src/queries/shop", "src/queries/checkout"], "schema": "schemas/shop.json"},
138
+ ],
139
+ },
140
+ },
141
+ ],
142
+ }
143
+ ```
144
+
145
+ A file uses the schema of the deepest folder that contains it, and `schema` when no folder does. Without a `schema`, a file outside every folder gets a diagnostic.
146
+
147
+ A `package.json` `imports` alias resolves to the file, so the folder's schema still applies. Node, Bun, Deno and the bundlers resolve `imports` themselves:
148
+
149
+ ```jsonc
150
+ // package.json
151
+ {
152
+ "imports": {
153
+ "#queries/*": "./src/queries/*",
154
+ },
155
+ }
156
+ ```
157
+
158
+ ```ts
159
+ import posts from '#queries/blog/posts.groq' with {type: 'text'}
160
+ import products from '#queries/shop/products.groq' with {type: 'text'}
161
+ ```
162
+
163
+ ## Running the imports
164
+
165
+ `with {type: 'text'}` makes the runtime or bundler deliver the file as a string. Where one does not, one line fills in.
166
+
167
+ | Runtime or bundler | Setup |
168
+ | --------------------------------------------- | ----------------------------------------------------------------------- |
169
+ | Bun, Deno, esbuild, `Bun.build` | nothing |
170
+ | webpack 5.102 and later, Rspack 1.7 and later | nothing |
171
+ | webpack before 5.102 | `{test: /\.groq$/, type: 'asset/source'}` in `module.rules` |
172
+ | Rolldown | `moduleTypes: {'.groq': 'text'}` |
173
+ | Node 26.5 and later | `--experimental-import-text`, or the preload below |
174
+ | Node 22.12 and later | `node --import @sanity-labs/groq-content-mapper/register main.ts` |
175
+ | Vite, Vitest, Rollup | the Vite plugin |
176
+ | Sanity Studio, App SDK apps | the Vite plugin, in `vite: {plugins: [groqFiles()]}` of `sanity.cli.ts` |
177
+ | Next.js 16.3 and later | `withGroqFiles` |
178
+ | Jest | the Jest transformer, with tests compiled to CommonJS |
179
+
180
+ ### Node
181
+
182
+ ```sh
183
+ node --import @sanity-labs/groq-content-mapper/register main.ts
184
+ ```
185
+
186
+ ### Vite, Vitest, Rollup
187
+
188
+ ```ts
189
+ // vite.config.ts
190
+ import groqFiles from '@sanity-labs/groq-content-mapper/vite'
191
+ import {defineConfig} from 'vite'
192
+
193
+ export default defineConfig({plugins: [groqFiles()]})
194
+ ```
195
+
196
+ Vite's dev server does not handle the attribute, so the plugin is needed there even when a production build would work without it.
197
+
198
+ ### Next.js
199
+
200
+ ```ts
201
+ // next.config.ts
202
+ import {withGroqFiles} from '@sanity-labs/groq-content-mapper/next'
203
+
204
+ export default withGroqFiles(nextConfig)
205
+ ```
206
+
207
+ `withGroqFiles` covers both Turbopack and the webpack Next bundles. It also replaces the type check `next build` runs, which does not pass `--runExternalCode` and so fails on every `.groq` import. The same check runs with the flag after the compile. If the config already sets `typescript.ignoreBuildErrors`, no check is run.
208
+
209
+ ### Jest
210
+
211
+ ```js
212
+ // jest.config.js
213
+ export default {
214
+ transform: {'\\.groq$': '@sanity-labs/groq-content-mapper/jest'},
215
+ }
216
+ ```
217
+
218
+ Jest rejects `type: 'text'` in its ES module loader, so this is for tests compiled to CommonJS, where the attribute is dropped.
219
+
220
+ ## Limitations
221
+
222
+ - A comparison against `^` types its placeholder as `unknown`.
223
+ - `score()` is not typed yet, so `_score` is `null`, and a `[_score > 0]` filter empties the result type.
224
+ - `include` takes folders, not globs.
225
+
226
+ ## License
227
+
228
+ 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"}