@fourtwelvelabs/fetch-contentful 0.4.1 → 1.0.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/CHANGELOG.md +153 -0
- package/README.md +192 -57
- package/dist/cli/index.mjs +21 -72
- package/dist/cli/index.mjs.map +1 -1
- package/dist/index.cjs +309 -139
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +254 -26
- package/dist/index.d.ts +254 -26
- package/dist/index.mjs +305 -140
- package/dist/index.mjs.map +1 -1
- package/docs/tada.md +31 -29
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,158 @@
|
|
|
1
1
|
# @fourtwelvelabs/fetch-contentful
|
|
2
2
|
|
|
3
|
+
## 1.0.0
|
|
4
|
+
|
|
5
|
+
### Major Changes
|
|
6
|
+
|
|
7
|
+
- **1.0.0 — the API is stable.**
|
|
8
|
+
|
|
9
|
+
Everything the README documents is now covered by semver: `fetchContentful`,
|
|
10
|
+
`createFetchContentful`, every option on `FetchContentfulOptions`, the shape
|
|
11
|
+
of `FetchContentfulError`, and the exported helpers and types. Breaking
|
|
12
|
+
changes to any of them require a major release from here on.
|
|
13
|
+
|
|
14
|
+
Two things are deliberately *not* part of that promise:
|
|
15
|
+
|
|
16
|
+
- The `@split` directive's internal marker aliases (`_splitSysId`,
|
|
17
|
+
`_splitTypename`) and the generated subquery text. These are implementation
|
|
18
|
+
detail of query splitting, visible only if you inspect requests on the wire.
|
|
19
|
+
- The exact wording of error messages. `error.code`, `error.status`,
|
|
20
|
+
`error.errors` and `error.query` are stable; the prose is not.
|
|
21
|
+
|
|
22
|
+
This release also clears out the last deprecation — see the `token` removal
|
|
23
|
+
above — so 1.0 starts with no options that are documented one way and behave
|
|
24
|
+
another.
|
|
25
|
+
- **Breaking:** the deprecated `token` option is removed. Use `deliveryToken`
|
|
26
|
+
and `previewToken`.
|
|
27
|
+
|
|
28
|
+
A single `token` could not serve both modes. It was applied to whichever mode
|
|
29
|
+
a call happened to be in, so a factory configured with one sent a *delivery*
|
|
30
|
+
token to any call that passed `preview: true` — authenticating against the
|
|
31
|
+
Preview API with a credential that cannot read drafts. The two-token form has
|
|
32
|
+
been the documented way to configure this since 0.4.0; this release stops
|
|
33
|
+
carrying the version that could silently authenticate against the wrong API.
|
|
34
|
+
|
|
35
|
+
```diff
|
|
36
|
+
export const fetchContentful = createFetchContentful({
|
|
37
|
+
space: 'abc123',
|
|
38
|
+
- token: process.env.CONTENTFUL_ACCESS_TOKEN,
|
|
39
|
+
+ deliveryToken: process.env.CONTENTFUL_ACCESS_TOKEN,
|
|
40
|
+
+ previewToken: process.env.CONTENTFUL_PREVIEW_ACCESS_TOKEN,
|
|
41
|
+
});
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
If you only ever fetch published content, `deliveryToken` alone is enough —
|
|
45
|
+
`previewToken` is required only for calls that pass `preview: true`, and the
|
|
46
|
+
`CONFIG` error names exactly which one is missing.
|
|
47
|
+
|
|
48
|
+
Passing `token` is now an unknown option: it is not silently ignored in a way
|
|
49
|
+
that sends an unauthenticated request, it simply is not a token, so the call
|
|
50
|
+
fails the same way passing none does.
|
|
51
|
+
|
|
52
|
+
`EnvSettings.token`, the matching deprecated alias on `readEnvSettings()`, is
|
|
53
|
+
removed with it. Read `deliveryToken` instead — same value, and it says which
|
|
54
|
+
of the two tokens it is.
|
|
55
|
+
- ffb17c0: Unpublished and deleted references no longer reject the call, and no longer
|
|
56
|
+
leave `null` holes in your collections.
|
|
57
|
+
|
|
58
|
+
Contentful's GraphQL API does not omit a link it cannot resolve — a reference
|
|
59
|
+
whose target was deleted, or (on the Delivery API) never published. It returns
|
|
60
|
+
`null` **in that position**, on an HTTP 200, with an `UNRESOLVABLE_LINK` object
|
|
61
|
+
in the `errors` array:
|
|
62
|
+
|
|
63
|
+
```jsonc
|
|
64
|
+
{
|
|
65
|
+
"data": { "page": { "sectionsCollection": { "items": [{ "heading": "One" }, null] } } },
|
|
66
|
+
"errors": [{ "extensions": { "contentful": { "code": "UNRESOLVABLE_LINK", … } } }]
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
This library rejected on any non-empty `errors` array, so a single entry
|
|
71
|
+
unpublished by an editor failed the *entire* fetch — discarding a response that
|
|
72
|
+
was otherwise complete and correct. That is now the one GraphQL error that does
|
|
73
|
+
not reject on its own. Any other error still does, and one riding along with an
|
|
74
|
+
unresolvable link takes the whole list with it into `error.errors`.
|
|
75
|
+
|
|
76
|
+
Left alone, the response then hands you `(Section | null)[]`, where the obvious
|
|
77
|
+
`sections.map((s) => s.heading)` throws — not in development, where everything
|
|
78
|
+
is published, but on the afternoon someone unpublishes one thing. So the null
|
|
79
|
+
positions are now **dropped by default**, in the data and in the return type:
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
const page = await fetchContentful<PageQuery>(QUERY);
|
|
83
|
+
page.sections.map((section) => section.heading); // Section[], no guard needed
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Change it with the new `unresolvableLinks` option:
|
|
87
|
+
|
|
88
|
+
| Value | Data | `items` type |
|
|
89
|
+
| --- | --- | --- |
|
|
90
|
+
| `'omit'` *(default)* | Null positions dropped | `Section[]` |
|
|
91
|
+
| `'null'` | Contentful's response, untouched | `(Section \| null)[]` |
|
|
92
|
+
| `'error'` | Rejects with a `GRAPHQL` error | — |
|
|
93
|
+
|
|
94
|
+
`'error'` restores the pre-1.0 behavior exactly.
|
|
95
|
+
|
|
96
|
+
Because dropping a null silently hides a real editorial mistake, `onUnresolvableLink`
|
|
97
|
+
is called once per request that carried one, with the errors Contentful sent —
|
|
98
|
+
each holding the `linkId`, `field` and `type` of the missing reference. Anything
|
|
99
|
+
it throws is swallowed: it runs inside the retry loop, so a failing logger would
|
|
100
|
+
otherwise look like a failed request and send the query again.
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
export const fetchContentful = createFetchContentful({
|
|
104
|
+
onUnresolvableLink: (errors) => logger.warn({ errors }, 'broken Contentful reference'),
|
|
105
|
+
});
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Two limits worth knowing. A *one-to-one* reference has no position to drop, so
|
|
109
|
+
an unresolvable one stays `null` on its field in every mode but `'error'`. And
|
|
110
|
+
the Preview API resolves drafts, so the same query can legitimately return
|
|
111
|
+
different array lengths in preview and production — usually the explanation when
|
|
112
|
+
a page looks right in Draft Mode and short in production.
|
|
113
|
+
|
|
114
|
+
Also exported: `omitUnresolvedLinks`, `isUnresolvableLinkError`,
|
|
115
|
+
`partitionUnresolvableLinks`, and the type-level `OmitUnresolvedLinks<T>` /
|
|
116
|
+
`UnresolvableLinkMode`.
|
|
117
|
+
|
|
118
|
+
### Minor Changes
|
|
119
|
+
|
|
120
|
+
- ffb17c0: Errors from Contentful now show the query, with a caret under the line the
|
|
121
|
+
error points at.
|
|
122
|
+
|
|
123
|
+
Contentful reports `line` and `column` for most rejected queries, but those
|
|
124
|
+
coordinates are into the query **as sent** — after fragments are inlined,
|
|
125
|
+
`preview`/`locale` arguments injected, and split fields hoisted into
|
|
126
|
+
generated subqueries. Line 6 of a document nobody has ever seen is not a
|
|
127
|
+
clue, so the query now comes along with the message:
|
|
128
|
+
|
|
129
|
+
```
|
|
130
|
+
Contentful returned GraphQL errors: Cannot query field "titel" on type "Page".
|
|
131
|
+
|
|
132
|
+
2 | pageCollection(where: {slug: $slug}, limit: 1) {
|
|
133
|
+
3 | items {
|
|
134
|
+
> 4 | titel
|
|
135
|
+
| ^ Cannot query field "titel" on type "Page".
|
|
136
|
+
5 | }
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The marked line is colored when stderr is a terminal, and left as plain text
|
|
140
|
+
when it is not, so log pipelines still get something they can store.
|
|
141
|
+
`NO_COLOR` and `FORCE_COLOR` are honored. No dependency was added.
|
|
142
|
+
|
|
143
|
+
Two related changes come with it:
|
|
144
|
+
|
|
145
|
+
- A non-2xx response is no longer reported by status alone. Contentful
|
|
146
|
+
answers an invalid query with HTTP 400 and the same `errors` array a 200
|
|
147
|
+
would have carried, so those messages (and their locations) now reach the
|
|
148
|
+
error instead of being dropped on the floor. 5xx bodies are still ignored —
|
|
149
|
+
they explain nothing and the request is on its way to a retry.
|
|
150
|
+
- `FetchContentfulError` carries the `query` that failed.
|
|
151
|
+
|
|
152
|
+
Set `annotateQueryOnError: false` to keep messages to a single line. The
|
|
153
|
+
formatter is exported as `annotateQuery(query, errors, options)` for custom
|
|
154
|
+
reporting.
|
|
155
|
+
|
|
3
156
|
## 0.4.1
|
|
4
157
|
|
|
5
158
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -4,9 +4,10 @@ A foolproof, type-safe GraphQL fetching utility for Contentful. One function in,
|
|
|
4
4
|
|
|
5
5
|
- **Automatic query splitting** — nested reference collections (and `@split`-annotated one-to-one references) are broken into separate subqueries, fetched in id batches, and stitched back into the original response shape. Fully recursive, so deeply nested queries never hit Contentful's query complexity limit.
|
|
6
6
|
- **Automatic retries** — transient failures (network errors, 408/429/5xx) retry with exponential backoff + jitter, honoring `Retry-After`. Configurable, default 5.
|
|
7
|
-
- **Response shaping (built in)** — before the promise resolves, every `fooCollection.items` in the response becomes `foo` (a plain array), at runtime
|
|
8
|
-
- **Single-root unwrapping (built in)** — when a query has one root field, the promise resolves with that field's contents directly (`data`
|
|
7
|
+
- **Response shaping (built in)** — before the promise resolves, every `fooCollection.items` in the response becomes `foo` (a plain array), at runtime _and_ in the return type. No separate call needed.
|
|
8
|
+
- **Single-root unwrapping (built in)** — when a query has one root field, the promise resolves with that field's contents directly (`data` _is_ the array/entry, not `{ siteSettings: ... }`). Multi-root queries stay wrapped.
|
|
9
9
|
- **No preview/locale boilerplate** — `preview` and `locale` are injected as arguments onto your query's root fields automatically (Contentful cascades them to every nested field), so queries never need to declare or thread them. The locale defaults to `en-US`.
|
|
10
|
+
- **No null holes in collections (built in)** — an unpublished or deleted reference comes back from Contentful as a `null` _inside_ `items`. Those positions are dropped before the promise resolves, at runtime and in the return type, so `items.map(...)` is safe. Configurable via `unresolvableLinks`.
|
|
10
11
|
- **All-or-nothing promise** — resolves only when every request in the tree succeeded; rejects with a typed `FetchContentfulError` if anything fails.
|
|
11
12
|
- **Locale awareness** — locales are fetched from Contentful once and cached in-module, so validation costs nothing per fetch.
|
|
12
13
|
- **Inferred types, optionally** — pass a [gql.tada](https://gql-tada.0no.co) or graphql-codegen document and the result and variable types come from the query itself. One command (`tada-init`) sets gql.tada up against your space. See [Typed queries with gql.tada](#typed-queries-with-gqltada).
|
|
@@ -30,24 +31,24 @@ If `space` or the appropriate token can't be resolved, the promise rejects with
|
|
|
30
31
|
|
|
31
32
|
### Environment variables
|
|
32
33
|
|
|
33
|
-
| Setting
|
|
34
|
-
|
|
|
35
|
-
| `space`
|
|
36
|
-
| `environment`
|
|
37
|
-
| `deliveryToken` | `CONTENTFUL_ACCESS_TOKEN`
|
|
38
|
-
| `previewToken`
|
|
34
|
+
| Setting | Neutral name | Next.js client-safe name |
|
|
35
|
+
| --------------- | --------------------------------- | ------------------------------------- |
|
|
36
|
+
| `space` | `CONTENTFUL_SPACE_ID` | `NEXT_PUBLIC_CONTENTFUL_SPACE_ID` |
|
|
37
|
+
| `environment` | `CONTENTFUL_ENVIRONMENT` | `NEXT_PUBLIC_CONTENTFUL_ENVIRONMENT` |
|
|
38
|
+
| `deliveryToken` | `CONTENTFUL_ACCESS_TOKEN` | `NEXT_PUBLIC_CONTENTFUL_ACCESS_TOKEN` |
|
|
39
|
+
| `previewToken` | `CONTENTFUL_PREVIEW_ACCESS_TOKEN` | **none, by design** |
|
|
39
40
|
|
|
40
41
|
There is no environment variable for the locale: it defaults to **`en-US`**, and a project that wants a different one sets it once with `createFetchContentful` (below).
|
|
41
42
|
|
|
42
43
|
The library reads the already-populated `process.env`; loading `.env` / `.env.local` files from disk is your platform's job (Next.js, Vite, dotenv all do this), so precedence between those files always matches your framework's rules.
|
|
43
44
|
|
|
44
|
-
**In a Next.js project you don't need both names.** Set only the `NEXT_PUBLIC_` variant and it works everywhere: the server reads it like any other env var (the prefix only controls
|
|
45
|
+
**In a Next.js project you don't need both names.** Set only the `NEXT_PUBLIC_` variant and it works everywhere: the server reads it like any other env var (the prefix only controls _client_ exposure), and client bundles get it because the library reads these variables with literal `process.env.NEXT_PUBLIC_...` accesses — the exact pattern Next's build-time inliner string-replaces. Server-only projects (or any other framework) should use the neutral names. This applies to the space, the environment and the delivery token; the preview token has no client-safe name, for the reason below.
|
|
45
46
|
|
|
46
47
|
### The preview token is never read from a `NEXT_PUBLIC_` variable
|
|
47
48
|
|
|
48
49
|
Next.js exposes `NEXT_PUBLIC_` variables to the browser by string-replacing literal `process.env.NEXT_PUBLIC_…` accesses at build time. This library contains such accesses, so if it read the preview token from a prefixed name, setting that variable would bake the token into your public JavaScript — whether or not any client code ever asked for preview content. Importing the library would be enough.
|
|
49
50
|
|
|
50
|
-
A delivery token is read-only and commonly public. A **preview token reads unpublished content**, so it is resolved only from the unprefixed `CONTENTFUL_PREVIEW_ACCESS_TOKEN`. In Next.js that name is still readable on the server — the prefix only controls
|
|
51
|
+
A delivery token is read-only and commonly public. A **preview token reads unpublished content**, so it is resolved only from the unprefixed `CONTENTFUL_PREVIEW_ACCESS_TOKEN`. In Next.js that name is still readable on the server — the prefix only controls _client_ exposure — so server-side preview (Draft Mode, route handlers, server components) works unchanged.
|
|
51
52
|
|
|
52
53
|
If you have decided that your app genuinely needs preview in the browser, pass `previewToken` explicitly. Shipping it is then your deliberate choice rather than something the library did on your behalf.
|
|
53
54
|
|
|
@@ -60,9 +61,9 @@ For utility-specific configuration, create a configured instance once and import
|
|
|
60
61
|
import { createFetchContentful } from '@fourtwelvelabs/fetch-contentful';
|
|
61
62
|
|
|
62
63
|
export const fetchContentful = createFetchContentful({
|
|
63
|
-
space: 'abc123',
|
|
64
|
-
locale: 'de-DE',
|
|
65
|
-
retries: 3
|
|
64
|
+
space: 'abc123', // or leave unset to use env vars
|
|
65
|
+
locale: 'de-DE', // override the 'en-US' default for the whole project
|
|
66
|
+
retries: 3
|
|
66
67
|
});
|
|
67
68
|
|
|
68
69
|
// Configure both tokens once, and every call picks the right one:
|
|
@@ -73,10 +74,11 @@ export const fetchContentful = createFetchContentful({
|
|
|
73
74
|
```ts
|
|
74
75
|
// anywhere else
|
|
75
76
|
import { fetchContentful } from '@/lib/contentful';
|
|
77
|
+
|
|
76
78
|
const data = await fetchContentful<PagesQuery>(QUERY);
|
|
77
79
|
```
|
|
78
80
|
|
|
79
|
-
Per-call options override factory defaults (top-level shallow merge). One caveat: `shapeResponseData: false` and `
|
|
81
|
+
Per-call options override factory defaults (top-level shallow merge). One caveat: `shapeResponseData: false`, `unwrapRootField: false` and `unresolvableLinks: 'null'` change the return _type_ only when written on the call itself, so set those per call rather than as factory defaults. Set on the factory they still take effect at runtime — the type just assumes the default.
|
|
80
82
|
|
|
81
83
|
## Quick start
|
|
82
84
|
|
|
@@ -107,7 +109,7 @@ const data = await fetchContentful<PagesQuery>(
|
|
|
107
109
|
}
|
|
108
110
|
}
|
|
109
111
|
`,
|
|
110
|
-
{ variables: { limit: 10 }, preview: true, locale: 'en-US' }
|
|
112
|
+
{ variables: { limit: 10 }, preview: true, locale: 'en-US' }
|
|
111
113
|
);
|
|
112
114
|
|
|
113
115
|
// Shaped and unwrapped: the single root field's contents come back directly.
|
|
@@ -137,14 +139,14 @@ import type { introspection } from './contentful-env.d.ts';
|
|
|
137
139
|
|
|
138
140
|
export const graphql = initGraphQLTada<{
|
|
139
141
|
introspection: introspection; // your space's content model, as types
|
|
140
|
-
scalars: ContentfulScalars;
|
|
142
|
+
scalars: ContentfulScalars; // DateTime → string, JSON → JsonValue, …
|
|
141
143
|
}>();
|
|
142
144
|
|
|
143
145
|
export { readFragment } from 'gql.tada';
|
|
144
146
|
export type { FragmentOf, ResultOf, VariablesOf } from 'gql.tada';
|
|
145
147
|
```
|
|
146
148
|
|
|
147
|
-
`graphql` is a tagged template function doing two jobs at once: at runtime it parses your query into a GraphQL document, and at compile time it infers that query's result and variable types from the schema. It can't be an export of this package — it has to be bound to
|
|
149
|
+
`graphql` is a tagged template function doing two jobs at once: at runtime it parses your query into a GraphQL document, and at compile time it infers that query's result and variable types from the schema. It can't be an export of this package — it has to be bound to _your_ space — which is why the command generates it.
|
|
148
150
|
|
|
149
151
|
Restart the TypeScript server so `@0no-co/graphqlsp` loads (it's what gives you autocomplete and field validation inside the backticks). Then import `graphql` from wherever `tada-init` put it, and queries type themselves:
|
|
150
152
|
|
|
@@ -158,7 +160,11 @@ const PageQuery = graphql(`
|
|
|
158
160
|
pageCollection(where: { slug: $slug }, limit: 1) {
|
|
159
161
|
items {
|
|
160
162
|
title
|
|
161
|
-
sectionsCollection {
|
|
163
|
+
sectionsCollection {
|
|
164
|
+
items {
|
|
165
|
+
heading
|
|
166
|
+
}
|
|
167
|
+
}
|
|
162
168
|
}
|
|
163
169
|
}
|
|
164
170
|
}
|
|
@@ -198,7 +204,7 @@ import { draftMode } from 'next/headers';
|
|
|
198
204
|
const { isEnabled } = await draftMode();
|
|
199
205
|
const data = await fetchContentful<PageQuery>(PAGE_QUERY, {
|
|
200
206
|
preview: isEnabled,
|
|
201
|
-
cache: isEnabled ? 'no-store' : undefined
|
|
207
|
+
cache: isEnabled ? 'no-store' : undefined
|
|
202
208
|
});
|
|
203
209
|
```
|
|
204
210
|
|
|
@@ -216,33 +222,38 @@ export const getStaticProps: GetStaticProps = async ({ preview = false }) => {
|
|
|
216
222
|
```ts
|
|
217
223
|
fetchContentful(query, {
|
|
218
224
|
// Contentful targeting
|
|
219
|
-
space: 'abc123',
|
|
220
|
-
environment: 'master',
|
|
221
|
-
preview: false,
|
|
222
|
-
locale: 'en-US',
|
|
223
|
-
validateLocale: true,
|
|
224
|
-
deliveryToken: '...',
|
|
225
|
-
previewToken: '...',
|
|
225
|
+
space: 'abc123', // default: NEXT_PUBLIC_CONTENTFUL_SPACE
|
|
226
|
+
environment: 'master', // default: NEXT_PUBLIC_CONTENTFUL_ENVIRONMENT ?? 'master'
|
|
227
|
+
preview: false, // default: false
|
|
228
|
+
locale: 'en-US', // default: 'en-US'. null sends no locale at all
|
|
229
|
+
validateLocale: true, // reject with LOCALE if locale isn't configured
|
|
230
|
+
deliveryToken: '...', // CDA token, used when preview is false
|
|
231
|
+
previewToken: '...', // CPA token, used when preview is true
|
|
226
232
|
|
|
227
233
|
// Request behavior
|
|
228
234
|
variables: { slug: 'home' },
|
|
229
|
-
retries: 5,
|
|
230
|
-
retryDelayMs: 250,
|
|
231
|
-
maxRetryDelayMs: 8000,
|
|
232
|
-
signal: controller.signal,
|
|
233
|
-
cache: 'no-store',
|
|
234
|
-
next: { revalidate: 60 },
|
|
235
|
-
fetch: customFetch,
|
|
235
|
+
retries: 5, // retry count after the initial attempt
|
|
236
|
+
retryDelayMs: 250, // base backoff delay
|
|
237
|
+
maxRetryDelayMs: 8000, // backoff ceiling
|
|
238
|
+
signal: controller.signal, // AbortSignal, forwarded to fetch
|
|
239
|
+
cache: 'no-store', // RequestCache, forwarded to fetch
|
|
240
|
+
next: { revalidate: 60 }, // Next.js App Router fetch extensions
|
|
241
|
+
fetch: customFetch, // injectable fetch (testing / polyfills)
|
|
236
242
|
|
|
237
243
|
// Response handling
|
|
238
|
-
shapeResponseData: true,
|
|
239
|
-
unwrapRootField: true,
|
|
244
|
+
shapeResponseData: true, // false = skip collection shaping
|
|
245
|
+
unwrapRootField: true, // false = keep single-root responses wrapped
|
|
246
|
+
unresolvableLinks: 'omit', // 'omit' | 'null' | 'error' — broken references
|
|
247
|
+
|
|
248
|
+
// Diagnostics
|
|
249
|
+
annotateQueryOnError: true, // false = keep error messages to one line
|
|
250
|
+
onUnresolvableLink: (errors) => {}, // notified when a broken reference is tolerated
|
|
240
251
|
|
|
241
252
|
// Query rewriting
|
|
242
|
-
autoInjectArgs: true,
|
|
253
|
+
autoInjectArgs: true, // inject preview/locale args on root fields
|
|
243
254
|
autoSplitNestedCollections: true, // auto-detect non-root *Collection fields
|
|
244
|
-
|
|
245
|
-
splitBatchSize: 50
|
|
255
|
+
// (@split works regardless — see below)
|
|
256
|
+
splitBatchSize: 50 // parent ids per subquery
|
|
246
257
|
});
|
|
247
258
|
```
|
|
248
259
|
|
|
@@ -286,7 +297,9 @@ Note that pagination metadata (`total`, `skip`, `limit`) selected alongside `ite
|
|
|
286
297
|
query {
|
|
287
298
|
pagedResults: pageCollection(limit: 10, skip: 20) {
|
|
288
299
|
total
|
|
289
|
-
items {
|
|
300
|
+
items {
|
|
301
|
+
title
|
|
302
|
+
}
|
|
290
303
|
}
|
|
291
304
|
}
|
|
292
305
|
# resolves as data.pagedResults: { total: number; items: [...] }
|
|
@@ -296,6 +309,63 @@ The generic you pass (`fetchContentful<PagesQuery>`) should always describe the
|
|
|
296
309
|
|
|
297
310
|
The standalone `shapeData` / `ShapeCollections` exports exist only for advanced cases where you're transforming Contentful data that came from somewhere else; you never need them when using `fetchContentful`.
|
|
298
311
|
|
|
312
|
+
## Unpublished and deleted references
|
|
313
|
+
|
|
314
|
+
Contentful's GraphQL API does not omit a reference it cannot resolve — it returns `null` **in that position**:
|
|
315
|
+
|
|
316
|
+
```jsonc
|
|
317
|
+
// what Contentful sends when sections[1] was unpublished
|
|
318
|
+
{
|
|
319
|
+
"data": { "page": { "sectionsCollection": { "items": [{ "heading": "One" }, null] } } },
|
|
320
|
+
"errors": [{ "message": "Query execution error. Link to entry 'xyz' … cannot be resolved",
|
|
321
|
+
"extensions": { "contentful": { "code": "UNRESOLVABLE_LINK", … } } }]
|
|
322
|
+
}
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
Note the HTTP status is **200** and the rest of the data is exactly what you asked for. Only the missing entry is missing.
|
|
326
|
+
|
|
327
|
+
That leaves `items` typed `(Section | null)[]`, and the natural `sections.map((s) => s.heading)` throws — not in development, where everything is published, but on the afternoon an editor unpublishes one thing. So by default this library **drops those positions** and resolves with the entries that exist:
|
|
328
|
+
|
|
329
|
+
```ts
|
|
330
|
+
const page = await fetchContentful<PageQuery>(QUERY);
|
|
331
|
+
page.sections.map((section) => section.heading); // Section[], no null check needed
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
The return type is narrowed to match, so TypeScript agrees with the runtime rather than forcing a guard that can never fire.
|
|
335
|
+
|
|
336
|
+
### Choosing a different behavior
|
|
337
|
+
|
|
338
|
+
| `unresolvableLinks` | Data | `items` type |
|
|
339
|
+
| -------------------- | -------------------------------- | --------------------- |
|
|
340
|
+
| `'omit'` _(default)_ | Null positions dropped | `Section[]` |
|
|
341
|
+
| `'null'` | Contentful's response, untouched | `(Section \| null)[]` |
|
|
342
|
+
| `'error'` | Rejects with a `GRAPHQL` error | — |
|
|
343
|
+
|
|
344
|
+
`'error'` is what this library did before 1.0. It is the strictest option and the one to reach for if a broken reference should stop a build rather than quietly shorten a list.
|
|
345
|
+
|
|
346
|
+
### Keep the broken references visible
|
|
347
|
+
|
|
348
|
+
Dropping a null silently hides a real editorial mistake, so pair any non-`'error'` mode with a reporter:
|
|
349
|
+
|
|
350
|
+
```ts
|
|
351
|
+
export const fetchContentful = createFetchContentful({
|
|
352
|
+
onUnresolvableLink: (errors) => {
|
|
353
|
+
for (const error of errors) {
|
|
354
|
+
const { linkId, field, type } = error.extensions.contentful.details;
|
|
355
|
+
logger.warn({ linkId, field, type }, 'Contentful reference could not be resolved');
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
It is called once per request that carried a broken link, never in `'error'` mode (the call rejects with those same errors instead), and anything it throws is swallowed — a logger must not be able to fail, or silently retry, a fetch that otherwise succeeded.
|
|
362
|
+
|
|
363
|
+
### Two things this does not do
|
|
364
|
+
|
|
365
|
+
**A one-to-one reference stays `null`.** `page.hero` has no position to drop, only a field with no value, so it remains `null` in every mode but `'error'` and still needs a check.
|
|
366
|
+
|
|
367
|
+
**Preview and production legitimately disagree.** The Preview API resolves drafts, so a link that is a hole in production is a real entry in preview. The same query can return different array lengths in the two modes — which is usually the explanation when a page looks right in Draft Mode and short in production.
|
|
368
|
+
|
|
299
369
|
## How splitting works
|
|
300
370
|
|
|
301
371
|
Contentful rejects queries whose complexity exceeds its limit — usually caused by nested reference fields multiplying. `fetch-contentful` avoids this transparently:
|
|
@@ -312,9 +382,12 @@ query {
|
|
|
312
382
|
pageCollection {
|
|
313
383
|
items {
|
|
314
384
|
title
|
|
315
|
-
hero @split {
|
|
385
|
+
hero @split {
|
|
386
|
+
# fetched in a second, cheaper query
|
|
316
387
|
headline
|
|
317
|
-
image {
|
|
388
|
+
image {
|
|
389
|
+
url
|
|
390
|
+
}
|
|
318
391
|
}
|
|
319
392
|
}
|
|
320
393
|
}
|
|
@@ -335,7 +408,7 @@ Turn auto-splitting off and annotate only the fields that actually blow the comp
|
|
|
335
408
|
|
|
336
409
|
```ts
|
|
337
410
|
const data = await fetchContentful(QUERY, {
|
|
338
|
-
autoSplitNestedCollections: false
|
|
411
|
+
autoSplitNestedCollections: false
|
|
339
412
|
});
|
|
340
413
|
```
|
|
341
414
|
|
|
@@ -345,14 +418,24 @@ query {
|
|
|
345
418
|
items {
|
|
346
419
|
title
|
|
347
420
|
# Small and cheap: fetched inline, in the same request.
|
|
348
|
-
seo {
|
|
349
|
-
|
|
421
|
+
seo {
|
|
422
|
+
description
|
|
423
|
+
}
|
|
424
|
+
tagsCollection(limit: 5) {
|
|
425
|
+
items {
|
|
426
|
+
name
|
|
427
|
+
}
|
|
428
|
+
}
|
|
350
429
|
|
|
351
430
|
# The expensive one: fetched separately, batched by parent id.
|
|
352
431
|
sectionsCollection(limit: 50) @split {
|
|
353
432
|
items {
|
|
354
433
|
heading
|
|
355
|
-
mediaCollection(limit: 10) {
|
|
434
|
+
mediaCollection(limit: 10) {
|
|
435
|
+
items {
|
|
436
|
+
url
|
|
437
|
+
}
|
|
438
|
+
}
|
|
356
439
|
}
|
|
357
440
|
}
|
|
358
441
|
}
|
|
@@ -368,8 +451,11 @@ That sends two requests instead of the four auto mode would. Splits still nest r
|
|
|
368
451
|
|
|
369
452
|
```graphql
|
|
370
453
|
query {
|
|
371
|
-
pageCollection @split {
|
|
372
|
-
|
|
454
|
+
pageCollection @split {
|
|
455
|
+
# ✗ CONFIG error
|
|
456
|
+
items {
|
|
457
|
+
title
|
|
458
|
+
}
|
|
373
459
|
}
|
|
374
460
|
}
|
|
375
461
|
```
|
|
@@ -387,32 +473,81 @@ try {
|
|
|
387
473
|
await fetchContentful(QUERY);
|
|
388
474
|
} catch (error) {
|
|
389
475
|
if (isFetchContentfulError(error)) {
|
|
390
|
-
error.code;
|
|
476
|
+
error.code; // 'CONFIG' | 'NETWORK' | 'HTTP' | 'GRAPHQL' | 'STITCH' | 'LOCALE'
|
|
391
477
|
error.status; // HTTP status, when applicable
|
|
392
478
|
error.errors; // GraphQL errors, when applicable
|
|
479
|
+
error.query; // the query as sent, when a request was made
|
|
393
480
|
}
|
|
394
481
|
}
|
|
395
482
|
```
|
|
396
483
|
|
|
484
|
+
A GraphQL response that reports **only** unresolvable links is the one thing that does not reject — see [Unpublished and deleted references](#unpublished-and-deleted-references). Any other error in the `errors` array still does, and if one rides along with an unresolvable link the whole list reaches `error.errors`.
|
|
485
|
+
|
|
486
|
+
### Rejected queries are printed back with the bad line marked
|
|
487
|
+
|
|
488
|
+
Contentful reports a `line` and `column` for most queries it rejects — into
|
|
489
|
+
the query **as sent**, which is not the query you wrote: fragments have been
|
|
490
|
+
inlined, `preview`/`locale` arguments injected, and `@split` fields hoisted
|
|
491
|
+
into generated subqueries. So the query comes along with the message:
|
|
492
|
+
|
|
493
|
+
```text
|
|
494
|
+
Contentful returned GraphQL errors: Cannot query field "titel" on type "Page".
|
|
495
|
+
|
|
496
|
+
2 | pageCollection(where: {slug: $slug}, limit: 1) {
|
|
497
|
+
3 | items {
|
|
498
|
+
> 4 | titel
|
|
499
|
+
| ^ Cannot query field "titel" on type "Page".
|
|
500
|
+
5 | }
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
The marked line is colored when stderr is a terminal and plain when it is
|
|
504
|
+
not, so a log pipeline still receives text it can store. `NO_COLOR` and
|
|
505
|
+
`FORCE_COLOR` are honored.
|
|
506
|
+
|
|
507
|
+
This also applies to the HTTP 400 Contentful returns for a query it cannot
|
|
508
|
+
validate: the `errors` in that response body reach `error.errors` and the
|
|
509
|
+
message, rather than being reported as a bare status.
|
|
510
|
+
|
|
511
|
+
Pass `annotateQueryOnError: false` to keep messages to a single line. The
|
|
512
|
+
query is still on the error either way, and the formatter is exported if you
|
|
513
|
+
would rather render it yourself:
|
|
514
|
+
|
|
515
|
+
```ts
|
|
516
|
+
import { annotateQuery, isFetchContentfulError } from '@fourtwelvelabs/fetch-contentful';
|
|
517
|
+
|
|
518
|
+
if (isFetchContentfulError(error) && error.query && error.errors) {
|
|
519
|
+
logger.debug(annotateQuery(error.query, error.errors, { contextLines: 5 }));
|
|
520
|
+
}
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
`contextLines` defaults to `3`; pass `Infinity` to print the whole query, and
|
|
524
|
+
`color: true | false` to override the terminal detection.
|
|
525
|
+
|
|
397
526
|
## Other exports
|
|
398
527
|
|
|
399
528
|
```ts
|
|
400
529
|
import {
|
|
530
|
+
annotateQuery, // render a query with a caret under a reported error
|
|
531
|
+
clearLocaleCache, // reset the cache (tests, revalidation hooks)
|
|
401
532
|
createFetchContentful, // project-level defaults (see Configuration)
|
|
402
|
-
getLocales,
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
533
|
+
getLocales, // cached locale lookup (space/env/preview keyed)
|
|
534
|
+
injectRootArgs, // the preview/locale argument injector (advanced AST use)
|
|
535
|
+
inlineFragments, // fragment inliner (advanced AST use)
|
|
536
|
+
isUnresolvableLinkError, // classify one GraphQL error as a broken link
|
|
537
|
+
omitUnresolvedLinks, // the runtime null-position dropper (applied too)
|
|
538
|
+
partitionUnresolvableLinks, // split an errors array into broken links vs the rest
|
|
539
|
+
shapeData, // the runtime collection shaper (already applied by fetchContentful)
|
|
540
|
+
unwrapSingleRoot, // the runtime single-root unwrapper (already applied too)
|
|
541
|
+
type FetchContentfulOptions,
|
|
542
|
+
type OmitUnresolvedLinks,
|
|
543
|
+
type ShapeCollections,
|
|
544
|
+
type UnresolvableLinkMode
|
|
408
545
|
} from '@fourtwelvelabs/fetch-contentful';
|
|
409
|
-
import type { ShapeCollections, FetchContentfulOptions } from '@fourtwelvelabs/fetch-contentful';
|
|
410
|
-
|
|
411
546
|
// Type-only subpath, for wiring gql.tada up by hand (see docs/tada.md).
|
|
412
547
|
import type { ContentfulScalars, JsonValue } from '@fourtwelvelabs/fetch-contentful/tada';
|
|
413
548
|
```
|
|
414
549
|
|
|
415
|
-
`ShapeCollections<T>` and `
|
|
550
|
+
`ShapeCollections<T>`, `UnwrapSingleRoot<T>` and `OmitUnresolvedLinks<T>` are the type-level twins of `shapeData`, `unwrapSingleRoot` and `omitUnresolvedLinks`. Neither is needed alongside `fetchContentful` (shaping is built into it) — they're for shaping Contentful-shaped data that arrived some other way.
|
|
416
551
|
|
|
417
552
|
## Limitations
|
|
418
553
|
|