@fourtwelvelabs/fetch-contentful 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 +21 -0
- package/README.md +301 -0
- package/dist/index.cjs +977 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +324 -0
- package/dist/index.d.ts +324 -0
- package/dist/index.mjs +961 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +76 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Fourtwelve 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
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
# @fourtwelvelabs/fetch-contentful
|
|
2
|
+
|
|
3
|
+
A foolproof, type-safe GraphQL fetching utility for Contentful. One function in, shaped data out:
|
|
4
|
+
|
|
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
|
+
- **Automatic retries** — transient failures (network errors, 408/429/5xx) retry with exponential backoff + jitter, honouring `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 *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
|
+
- **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.
|
|
10
|
+
- **All-or-nothing promise** — resolves only when every request in the tree succeeded; rejects with a typed `FetchContentfulError` if anything fails.
|
|
11
|
+
- **Locale awareness** — locales are fetched from Contentful once and cached in-module, so validation costs nothing per fetch.
|
|
12
|
+
- **ESM + CJS, Next.js-ready** — first-class App Router support (`next.revalidate` / `next.tags` passthrough), works in Pages Router and any Node ≥ 18 runtime.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
yarn add @fourtwelvelabs/fetch-contentful graphql
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Configuration
|
|
21
|
+
|
|
22
|
+
Settings resolve in this order — first hit wins, per setting:
|
|
23
|
+
|
|
24
|
+
1. **Per-call options** — `fetchContentful(query, { space, token, ... })`
|
|
25
|
+
2. **Factory defaults** — set once with `createFetchContentful` (below)
|
|
26
|
+
3. **Environment variables** — framework-neutral names first, then `NEXT_PUBLIC_`-prefixed equivalents
|
|
27
|
+
|
|
28
|
+
If `space` or the appropriate token can't be resolved, the promise rejects with a single `CONFIG` error listing **every** missing setting and the exact option/env names that would satisfy it.
|
|
29
|
+
|
|
30
|
+
### Environment variables
|
|
31
|
+
|
|
32
|
+
| Setting | Neutral name | Next.js client-safe name |
|
|
33
|
+
| --- | --- | --- |
|
|
34
|
+
| `space` | `CONTENTFUL_SPACE_ID` | `NEXT_PUBLIC_CONTENTFUL_SPACE_ID` |
|
|
35
|
+
| `environment` | `CONTENTFUL_ENVIRONMENT` | `NEXT_PUBLIC_CONTENTFUL_ENVIRONMENT` |
|
|
36
|
+
| Delivery token | `CONTENTFUL_ACCESS_TOKEN` | `NEXT_PUBLIC_CONTENTFUL_ACCESS_TOKEN` |
|
|
37
|
+
| Preview token | `CONTENTFUL_PREVIEW_ACCESS_TOKEN` | `NEXT_PUBLIC_CONTENTFUL_PREVIEW_ACCESS_TOKEN` |
|
|
38
|
+
|
|
39
|
+
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.
|
|
40
|
+
|
|
41
|
+
**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. Think twice before exposing tokens to the browser at all: Contentful delivery tokens are read-only and commonly made public, but preview tokens should stay server-side.
|
|
42
|
+
|
|
43
|
+
### Project-level defaults with `createFetchContentful`
|
|
44
|
+
|
|
45
|
+
For utility-specific configuration, create a configured instance once and import it everywhere — this is the config file, just one you import instead of one the library discovers at runtime (which would break bundlers and edge runtimes, and invite committed secrets):
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
// lib/contentful.ts
|
|
49
|
+
import { createFetchContentful } from '@fourtwelvelabs/fetch-contentful';
|
|
50
|
+
|
|
51
|
+
export const fetchContentful = createFetchContentful({
|
|
52
|
+
space: 'abc123', // or leave unset to use env vars
|
|
53
|
+
locale: 'en-US',
|
|
54
|
+
retries: 3,
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
// anywhere else
|
|
60
|
+
import { fetchContentful } from '@/lib/contentful';
|
|
61
|
+
const data = await fetchContentful<PagesQuery>(QUERY);
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Per-call options override factory defaults (top-level shallow merge). One caveat: `shapeResponseData: false` and `unwrapRootField: false` change the return *type* only when written on the call itself, so set those per call rather than as factory defaults.
|
|
65
|
+
|
|
66
|
+
## Quick start
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import { fetchContentful } from '@fourtwelvelabs/fetch-contentful';
|
|
70
|
+
|
|
71
|
+
interface PagesQuery {
|
|
72
|
+
pageCollection: {
|
|
73
|
+
items: Array<{
|
|
74
|
+
title: string;
|
|
75
|
+
sectionsCollection: { items: Array<{ heading: string }> };
|
|
76
|
+
}>;
|
|
77
|
+
} | null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const data = await fetchContentful<PagesQuery>(
|
|
81
|
+
/* GraphQL */ `
|
|
82
|
+
query Pages($limit: Int) {
|
|
83
|
+
pageCollection(limit: $limit) {
|
|
84
|
+
items {
|
|
85
|
+
title
|
|
86
|
+
sectionsCollection(limit: 10) {
|
|
87
|
+
items {
|
|
88
|
+
heading
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
`,
|
|
95
|
+
{ variables: { limit: 10 }, preview: true, locale: 'en-US' },
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
// Shaped and unwrapped: the single root field's contents come back directly.
|
|
99
|
+
// data: Array<{ title: string; sections: Array<{ heading: string }> }>
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Notice there is no `$preview` or `$locale` anywhere in the query: the options are injected as `preview: true` / `locale: "en-US"` arguments on the root field before the query is sent, and Contentful cascades both to every nested field. Arguments you write explicitly are never overridden, so per-field overrides like `title(locale: "de-DE")` keep working. Set `autoInjectArgs: false` to opt out. (The older style still works too — `$preview: Boolean` / `$locale: String` variables are auto-filled whenever a query declares them.)
|
|
103
|
+
|
|
104
|
+
## Next.js
|
|
105
|
+
|
|
106
|
+
### App Router (recommended)
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
// app/pages/[slug]/page.tsx
|
|
110
|
+
import { fetchContentful } from '@fourtwelvelabs/fetch-contentful';
|
|
111
|
+
|
|
112
|
+
export default async function Page({ params }: { params: { slug: string } }) {
|
|
113
|
+
const data = await fetchContentful<PageQuery>(PAGE_QUERY, {
|
|
114
|
+
variables: { slug: params.slug },
|
|
115
|
+
next: { revalidate: 300, tags: ['contentful'] }, // forwarded to fetch()
|
|
116
|
+
});
|
|
117
|
+
return <Article page={data.page[0]} />;
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Draft Mode preview:
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
import { draftMode } from 'next/headers';
|
|
125
|
+
|
|
126
|
+
const { isEnabled } = await draftMode();
|
|
127
|
+
const data = await fetchContentful<PageQuery>(PAGE_QUERY, {
|
|
128
|
+
preview: isEnabled,
|
|
129
|
+
cache: isEnabled ? 'no-store' : undefined,
|
|
130
|
+
});
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Pages Router
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
export const getStaticProps: GetStaticProps = async ({ preview = false }) => {
|
|
137
|
+
const data = await fetchContentful<PageQuery>(PAGE_QUERY, { preview });
|
|
138
|
+
return { props: { data }, revalidate: 300 };
|
|
139
|
+
};
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Options
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
fetchContentful(query, {
|
|
146
|
+
// Contentful targeting
|
|
147
|
+
space: 'abc123', // default: NEXT_PUBLIC_CONTENTFUL_SPACE
|
|
148
|
+
environment: 'master', // default: NEXT_PUBLIC_CONTENTFUL_ENVIRONMENT ?? 'master'
|
|
149
|
+
preview: false, // default: false
|
|
150
|
+
locale: 'de-DE', // injected as a root-field argument (and $locale)
|
|
151
|
+
validateLocale: true, // reject with LOCALE if locale isn't configured
|
|
152
|
+
token: '...', // override the env-derived token
|
|
153
|
+
|
|
154
|
+
// Request behaviour
|
|
155
|
+
variables: { slug: 'home' },
|
|
156
|
+
retries: 5, // retry count after the initial attempt
|
|
157
|
+
retryDelayMs: 250, // base backoff delay
|
|
158
|
+
maxRetryDelayMs: 8000, // backoff ceiling
|
|
159
|
+
signal: controller.signal, // AbortSignal, forwarded to fetch
|
|
160
|
+
cache: 'no-store', // RequestCache, forwarded to fetch
|
|
161
|
+
next: { revalidate: 60 }, // Next.js App Router fetch extensions
|
|
162
|
+
fetch: customFetch, // injectable fetch (testing / polyfills)
|
|
163
|
+
|
|
164
|
+
// Response handling
|
|
165
|
+
shapeResponseData: true, // false = skip collection shaping
|
|
166
|
+
unwrapRootField: true, // false = keep single-root responses wrapped
|
|
167
|
+
|
|
168
|
+
// Query rewriting
|
|
169
|
+
autoInjectArgs: true, // inject preview/locale args on root fields
|
|
170
|
+
autoSplitNestedCollections: true, // split every non-root *Collection field
|
|
171
|
+
splitBatchSize: 50, // parent ids per subquery
|
|
172
|
+
});
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Response shaping
|
|
176
|
+
|
|
177
|
+
Shaping happens **inside `fetchContentful` — you never call anything yourself**. The data the promise resolves with is already shaped, and the return type (`ShapeCollections<TData>`) matches it, so autocomplete and the runtime always agree. If you'd rather receive Contentful's raw wire shape, pass `shapeResponseData: false` — the return type follows along and becomes plain `TData`:
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
const raw = await fetchContentful<WireQuery>(QUERY, { shapeResponseData: false });
|
|
181
|
+
// raw.pageCollection.items — untouched wire shape, pagination metadata intact
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Splitting, stitching, retries, and argument injection all still apply in raw mode; only the final rename/hoist step is skipped.
|
|
185
|
+
|
|
186
|
+
The transform: every key ending in `Collection` whose value has an `items` array is renamed to drop the suffix, and `items` is hoisted up to become the value — recursively, at every depth. Everything else passes through untouched.
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
// What Contentful returns on the wire // What fetchContentful resolves with
|
|
190
|
+
{ [
|
|
191
|
+
pageCollection: { {
|
|
192
|
+
items: [ title: 'Home',
|
|
193
|
+
{ sections: [{ heading: 'Hero' }],
|
|
194
|
+
title: 'Home', },
|
|
195
|
+
sectionsCollection: { ]
|
|
196
|
+
items: [{ heading: 'Hero' }],
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
],
|
|
200
|
+
},
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Two transforms produce that result, both reflected in the return type:
|
|
205
|
+
|
|
206
|
+
1. **Collection shaping** (`shapeResponseData`, default `true`) — the rename/hoist described above.
|
|
207
|
+
2. **Single-root unwrapping** (`unwrapRootField`, default `true`) — when the (shaped) response has exactly one root key, the promise resolves with that key's value directly, so you skip the `data.page` hop. Applies to collections and one-to-one roots alike (`{ siteSettings: {...} }` resolves as the settings entry itself). Queries with **multiple root fields always stay wrapped** — unwrapping would be ambiguous — so adding a second root field changes the result shape from `T` back to `{ a: ..., b: ... }`; the types track this, so TypeScript flags every affected call site.
|
|
208
|
+
|
|
209
|
+
Note that pagination metadata (`total`, `skip`, `limit`) selected alongside `items` is dropped by the hoist. If you need it for a particular collection, alias that field to a name that doesn't end in `Collection` and it will pass through completely untouched:
|
|
210
|
+
|
|
211
|
+
```graphql
|
|
212
|
+
query {
|
|
213
|
+
pagedResults: pageCollection(limit: 10, skip: 20) {
|
|
214
|
+
total
|
|
215
|
+
items { title }
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
# resolves as data.pagedResults: { total: number; items: [...] }
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
The generic you pass (`fetchContentful<PagesQuery>`) should always describe the **wire shape** (with `fooCollection.items`) — the library derives the shaped type from it for you, and the aliasing trick above is reflected there too, since the type transform keys off the same `Collection` suffix.
|
|
222
|
+
|
|
223
|
+
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`.
|
|
224
|
+
|
|
225
|
+
## How splitting works
|
|
226
|
+
|
|
227
|
+
Contentful rejects queries whose complexity exceeds its limit — usually caused by nested reference fields multiplying. `fetch-contentful` avoids this transparently:
|
|
228
|
+
|
|
229
|
+
1. Named fragments are inlined and the query is scanned. Every **nested** `*Collection` field (auto) and every field annotated **`@split`** (one-to-one references) is removed from the outer query and recorded as a plan.
|
|
230
|
+
2. The outer query runs with tiny markers (`sys { id }`, `__typename`) injected where fields were removed.
|
|
231
|
+
3. Parents are grouped by their concrete `__typename`, and each split field is re-fetched via `"{typeName}Collection"(where: { sys: { id_in: [...] } })` in batches of `splitBatchSize`, in parallel. Subqueries go through the same pipeline, so splits nest recursively to any depth.
|
|
232
|
+
4. Results are stitched back onto their parents by `sys.id`. If any entry can't be resolved, the whole promise rejects with a `STITCH` error — you never get silently incomplete data.
|
|
233
|
+
|
|
234
|
+
Marking a one-to-one reference:
|
|
235
|
+
|
|
236
|
+
```graphql
|
|
237
|
+
query {
|
|
238
|
+
pageCollection {
|
|
239
|
+
items {
|
|
240
|
+
title
|
|
241
|
+
hero @split { # fetched in a second, cheaper query
|
|
242
|
+
headline
|
|
243
|
+
image { url }
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
The `@split` directive is stripped before anything is sent to Contentful.
|
|
251
|
+
|
|
252
|
+
## Error handling
|
|
253
|
+
|
|
254
|
+
Every rejection is a `FetchContentfulError`:
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
import { fetchContentful, isFetchContentfulError } from '@fourtwelvelabs/fetch-contentful';
|
|
258
|
+
|
|
259
|
+
try {
|
|
260
|
+
await fetchContentful(QUERY);
|
|
261
|
+
} catch (error) {
|
|
262
|
+
if (isFetchContentfulError(error)) {
|
|
263
|
+
error.code; // 'CONFIG' | 'NETWORK' | 'HTTP' | 'GRAPHQL' | 'STITCH' | 'LOCALE'
|
|
264
|
+
error.status; // HTTP status, when applicable
|
|
265
|
+
error.errors; // GraphQL errors, when applicable
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
## Other exports
|
|
271
|
+
|
|
272
|
+
```ts
|
|
273
|
+
import {
|
|
274
|
+
createFetchContentful, // project-level defaults (see Configuration)
|
|
275
|
+
getLocales, // cached locale lookup (space/env/preview keyed)
|
|
276
|
+
clearLocaleCache, // reset the cache (tests, revalidation hooks)
|
|
277
|
+
shapeData, // the runtime collection shaper (already applied by fetchContentful)
|
|
278
|
+
unwrapSingleRoot, // the runtime single-root unwrapper (already applied too)
|
|
279
|
+
inlineFragments, // fragment inliner (advanced AST use)
|
|
280
|
+
injectRootArgs, // the preview/locale argument injector (advanced AST use)
|
|
281
|
+
} from '@fourtwelvelabs/fetch-contentful';
|
|
282
|
+
import type { ShapeCollections, FetchContentfulOptions } from '@fourtwelvelabs/fetch-contentful';
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
`ShapeCollections<T>` and `UnwrapSingleRoot<T>` are the type-level twins of `shapeData` and `unwrapSingleRoot`. Neither is needed alongside `fetchContentful` (shaping is built into it) — they're for shaping Contentful-shaped data that arrived some other way.
|
|
286
|
+
|
|
287
|
+
## Limitations
|
|
288
|
+
|
|
289
|
+
- One `query` operation per document (no mutations/subscriptions, no multi-operation documents).
|
|
290
|
+
- Splitting relies on Contentful's naming conventions: a parent of type `BlogPost` must be re-queryable via `blogPostCollection`. Custom schemas that break this convention will fail with a `STITCH` error rather than return wrong data.
|
|
291
|
+
- Root-level collections are never split (there is no parent to stitch onto); only nested ones are.
|
|
292
|
+
- `@split`/auto-split fields inside `... on Entry { ... }` fragments are treated as unconditional, since `Entry` is an interface and never matches a concrete `__typename`.
|
|
293
|
+
|
|
294
|
+
## Development
|
|
295
|
+
|
|
296
|
+
```bash
|
|
297
|
+
npm test # vitest
|
|
298
|
+
npm run test:coverage # enforces 100% statements/branches/functions/lines
|
|
299
|
+
npm run typecheck
|
|
300
|
+
npm run build # emits ESM + d.ts to dist/
|
|
301
|
+
```
|