@fourtwelvelabs/fetch-contentful 0.1.0 → 0.3.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 +102 -0
- package/README.md +124 -7
- package/dist/cli/index.mjs +949 -0
- package/dist/cli/index.mjs.map +1 -0
- package/dist/index.cjs +23 -27
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +98 -12
- package/dist/index.d.ts +98 -12
- package/dist/index.mjs +23 -27
- package/dist/index.mjs.map +1 -1
- package/dist/tada/index.cjs +4 -0
- package/dist/tada/index.cjs.map +1 -0
- package/dist/tada/index.d.cts +106 -0
- package/dist/tada/index.d.ts +106 -0
- package/dist/tada/index.mjs +3 -0
- package/dist/tada/index.mjs.map +1 -0
- package/docs/tada.md +327 -0
- package/package.json +28 -2
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# @fourtwelvelabs/fetch-contentful
|
|
2
|
+
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 6013f57: `locale` now defaults to `"en-US"`.
|
|
8
|
+
|
|
9
|
+
Previously, omitting `locale` meant no `locale` argument was sent at all, and
|
|
10
|
+
Contentful served whichever locale the space itself defaults to. Now `en-US`
|
|
11
|
+
is requested explicitly unless you say otherwise.
|
|
12
|
+
|
|
13
|
+
**This changes which content comes back for a space whose default locale is
|
|
14
|
+
not `en-US`.** If that describes your space, either set the locale once for
|
|
15
|
+
the project:
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
export const fetchContentful = createFetchContentful({ locale: "de-DE" });
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
or restore the previous behavior — no `locale` argument, space decides — with
|
|
22
|
+
the new `null` opt-out:
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
await fetchContentful(QUERY, { locale: null });
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`locale: null` is accepted anywhere `locale` is, including as a
|
|
29
|
+
`createFetchContentful` default and as a per-call override of one. A space
|
|
30
|
+
that has no `en-US` locale configured will now reject with a `LOCALE` error
|
|
31
|
+
under `validateLocale: true`, rather than silently returning the space
|
|
32
|
+
default.
|
|
33
|
+
|
|
34
|
+
## 0.2.1
|
|
35
|
+
|
|
36
|
+
### Patch Changes
|
|
37
|
+
|
|
38
|
+
- 641367c: `@split` on a **root field** now throws instead of being silently ignored.
|
|
39
|
+
|
|
40
|
+
Root fields have no parent entry to stitch a result back onto, so they have
|
|
41
|
+
never been splittable — but the directive was quietly stripped before the
|
|
42
|
+
request went out, leaving the query just as expensive as before with nothing
|
|
43
|
+
to explain why. It now raises a `CONFIG` `FetchContentfulError` naming the
|
|
44
|
+
field.
|
|
45
|
+
|
|
46
|
+
**This can surface in existing queries.** If a query annotates a root field,
|
|
47
|
+
that call will now reject where it previously succeeded (having ignored the
|
|
48
|
+
directive). The fix is to remove the directive — it was never doing anything
|
|
49
|
+
— and page through the field with its own `limit` / `skip` arguments instead.
|
|
50
|
+
Nested `@split` annotations are unaffected.
|
|
51
|
+
|
|
52
|
+
Also clarifies and pins the relationship between `@split` and
|
|
53
|
+
`autoSplitNestedCollections`: the option gates **only** the automatic
|
|
54
|
+
detection of nested `*Collection` fields, while `@split` is an independent
|
|
55
|
+
explicit trigger that splits its field — collection or one-to-one reference —
|
|
56
|
+
regardless of the option. This enables a "manual mode"
|
|
57
|
+
(`autoSplitNestedCollections: false` plus targeted annotations) where only
|
|
58
|
+
the fields that actually exceed Contentful's complexity limit are split. That
|
|
59
|
+
behavior was already correct; it is now documented and covered by tests.
|
|
60
|
+
|
|
61
|
+
## 0.2.0
|
|
62
|
+
|
|
63
|
+
### Minor Changes
|
|
64
|
+
|
|
65
|
+
- 9c9eef3: Add a gql.tada companion.
|
|
66
|
+
|
|
67
|
+
- **`@fourtwelvelabs/fetch-contentful/tada`** — a new type-only subpath export
|
|
68
|
+
providing `ContentfulScalars`, the scalar map for Contentful's GraphQL
|
|
69
|
+
Content API, for `initGraphQLTada<{ scalars: ContentfulScalars }>`. Nothing
|
|
70
|
+
in it imports gql.tada at runtime.
|
|
71
|
+
- **`tada-init` and `tada-refresh` CLI commands**, via a new
|
|
72
|
+
`fetch-contentful` binary. `tada-init` downloads the space's schema as SDL,
|
|
73
|
+
registers `@0no-co/graphqlsp` in the project's `tsconfig.json` (preserving
|
|
74
|
+
comments and formatting, and updating an existing entry rather than
|
|
75
|
+
duplicating it) and writes a `graphql.ts` bound to the schema and the scalar
|
|
76
|
+
map. `tada-refresh` re-downloads the schema only, leaving the file untouched
|
|
77
|
+
when nothing changed. Both support `--dry-run`, resolve credentials through
|
|
78
|
+
the same environment variables the library reads, and never print the access
|
|
79
|
+
token.
|
|
80
|
+
- **Typed document support in `fetchContentful`** — passing a
|
|
81
|
+
`TypedDocumentNode` (gql.tada, or graphql-codegen's client preset) infers
|
|
82
|
+
both the result and the variable types, with response shaping and single-root
|
|
83
|
+
unwrapping applied to the inferred type. `variables` is required exactly when
|
|
84
|
+
the document declares one the library does not inject itself, and unknown
|
|
85
|
+
variable names are rejected. Existing string and `DocumentNode` usage is
|
|
86
|
+
unchanged.
|
|
87
|
+
|
|
88
|
+
`gql.tada` and `@0no-co/graphqlsp` are optional peer dependencies, needed only
|
|
89
|
+
at author time.
|
|
90
|
+
|
|
91
|
+
## 0.1.0
|
|
92
|
+
|
|
93
|
+
### Minor Changes
|
|
94
|
+
|
|
95
|
+
- b6e2577: Initial release. A type-safe GraphQL fetching utility for Contentful:
|
|
96
|
+
|
|
97
|
+
- Automatic query splitting for nested reference collections (and `@split`-annotated one-to-one references), fetched in id batches and stitched back into the original response shape.
|
|
98
|
+
- Automatic retries with exponential backoff and jitter, honoring `Retry-After`.
|
|
99
|
+
- Response shaping (`fooCollection.items` → `foo`) and single-root unwrapping, at runtime and in the return type.
|
|
100
|
+
- Automatic `preview` / `locale` argument injection, with optional locale validation against the space.
|
|
101
|
+
- Typed `FetchContentfulError` carrying an error code, HTTP status, and GraphQL errors.
|
|
102
|
+
- Next.js App Router support via `next.revalidate` / `next.tags` passthrough.
|
package/README.md
CHANGED
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
A foolproof, type-safe GraphQL fetching utility for Contentful. One function in, shaped data out:
|
|
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
|
-
- **Automatic retries** — transient failures (network errors, 408/429/5xx) retry with exponential backoff + jitter,
|
|
6
|
+
- **Automatic retries** — transient failures (network errors, 408/429/5xx) retry with exponential backoff + jitter, honoring `Retry-After`. Configurable, default 5.
|
|
7
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
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.
|
|
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
10
|
- **All-or-nothing promise** — resolves only when every request in the tree succeeded; rejects with a typed `FetchContentfulError` if anything fails.
|
|
11
11
|
- **Locale awareness** — locales are fetched from Contentful once and cached in-module, so validation costs nothing per fetch.
|
|
12
|
+
- **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).
|
|
12
13
|
- **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
|
|
|
14
15
|
## Install
|
|
@@ -36,6 +37,8 @@ If `space` or the appropriate token can't be resolved, the promise rejects with
|
|
|
36
37
|
| Delivery token | `CONTENTFUL_ACCESS_TOKEN` | `NEXT_PUBLIC_CONTENTFUL_ACCESS_TOKEN` |
|
|
37
38
|
| Preview token | `CONTENTFUL_PREVIEW_ACCESS_TOKEN` | `NEXT_PUBLIC_CONTENTFUL_PREVIEW_ACCESS_TOKEN` |
|
|
38
39
|
|
|
40
|
+
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
|
+
|
|
39
42
|
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
43
|
|
|
41
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 *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.
|
|
@@ -50,7 +53,7 @@ import { createFetchContentful } from '@fourtwelvelabs/fetch-contentful';
|
|
|
50
53
|
|
|
51
54
|
export const fetchContentful = createFetchContentful({
|
|
52
55
|
space: 'abc123', // or leave unset to use env vars
|
|
53
|
-
locale: 'en-US'
|
|
56
|
+
locale: 'de-DE', // override the 'en-US' default for the whole project
|
|
54
57
|
retries: 3,
|
|
55
58
|
});
|
|
56
59
|
```
|
|
@@ -101,6 +104,63 @@ const data = await fetchContentful<PagesQuery>(
|
|
|
101
104
|
|
|
102
105
|
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
106
|
|
|
107
|
+
`locale` is optional here — it defaults to `en-US`, so the same argument is injected whether or not you pass it. Pass a different code to override it, or `locale: null` to send no `locale` argument at all and let the space serve its own default locale.
|
|
108
|
+
|
|
109
|
+
## Typed queries with gql.tada
|
|
110
|
+
|
|
111
|
+
Everything above types the response by hand (`fetchContentful<PagesQuery>`). You can instead have the types come from the query itself. Install the author-time peers, then run the setup command:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
yarn add -D gql.tada @0no-co/graphqlsp
|
|
115
|
+
npx @fourtwelvelabs/fetch-contentful tada-init
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
It reads the same environment variables the library does, is safe to re-run, and takes `--dry-run`. Three things happen: your space's schema is downloaded as SDL, `@0no-co/graphqlsp` is registered in your `tsconfig.json`, and **`src/graphql.ts`** is written — a small file of your own that binds gql.tada to your schema:
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
// src/graphql.ts — generated for you. Commit it; edit it freely.
|
|
122
|
+
import { initGraphQLTada } from 'gql.tada';
|
|
123
|
+
import type { ContentfulScalars } from '@fourtwelvelabs/fetch-contentful/tada';
|
|
124
|
+
import type { introspection } from './contentful-env.d.ts';
|
|
125
|
+
|
|
126
|
+
export const graphql = initGraphQLTada<{
|
|
127
|
+
introspection: introspection; // your space's content model, as types
|
|
128
|
+
scalars: ContentfulScalars; // DateTime → string, JSON → JsonValue, …
|
|
129
|
+
}>();
|
|
130
|
+
|
|
131
|
+
export { readFragment } from 'gql.tada';
|
|
132
|
+
export type { FragmentOf, ResultOf, VariablesOf } from 'gql.tada';
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
`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.
|
|
136
|
+
|
|
137
|
+
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:
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
// src/lib/pages.ts
|
|
141
|
+
import { fetchContentful } from '@fourtwelvelabs/fetch-contentful';
|
|
142
|
+
import { graphql } from '../graphql';
|
|
143
|
+
|
|
144
|
+
const PageQuery = graphql(`
|
|
145
|
+
query Page($slug: String!) {
|
|
146
|
+
pageCollection(where: { slug: $slug }, limit: 1) {
|
|
147
|
+
items {
|
|
148
|
+
title
|
|
149
|
+
sectionsCollection { items { heading } }
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
`);
|
|
154
|
+
|
|
155
|
+
const pages = await fetchContentful(PageQuery, { variables: { slug: 'home' } });
|
|
156
|
+
// ^? Array<{ title: string | null;
|
|
157
|
+
// sections: Array<{ heading: string | null }> }> | null
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
No type argument, and `variables` is required exactly when the document declares one the library doesn't inject itself (`$preview` and `$locale` are filled in automatically). Response shaping and single-root unwrapping are applied to the inferred type, so what you see is what you get. After a content-model change, run `tada-refresh` to re-download the schema.
|
|
161
|
+
|
|
162
|
+
**[Full gql.tada guide →](./docs/tada.md)** — the scalar map, manual setup without the CLI, the CLI reference, and a CI recipe that opens a PR when your content model changes.
|
|
163
|
+
|
|
104
164
|
## Next.js
|
|
105
165
|
|
|
106
166
|
### App Router (recommended)
|
|
@@ -147,11 +207,11 @@ fetchContentful(query, {
|
|
|
147
207
|
space: 'abc123', // default: NEXT_PUBLIC_CONTENTFUL_SPACE
|
|
148
208
|
environment: 'master', // default: NEXT_PUBLIC_CONTENTFUL_ENVIRONMENT ?? 'master'
|
|
149
209
|
preview: false, // default: false
|
|
150
|
-
locale: '
|
|
210
|
+
locale: 'en-US', // default: 'en-US'. null sends no locale at all
|
|
151
211
|
validateLocale: true, // reject with LOCALE if locale isn't configured
|
|
152
212
|
token: '...', // override the env-derived token
|
|
153
213
|
|
|
154
|
-
// Request
|
|
214
|
+
// Request behavior
|
|
155
215
|
variables: { slug: 'home' },
|
|
156
216
|
retries: 5, // retry count after the initial attempt
|
|
157
217
|
retryDelayMs: 250, // base backoff delay
|
|
@@ -167,7 +227,8 @@ fetchContentful(query, {
|
|
|
167
227
|
|
|
168
228
|
// Query rewriting
|
|
169
229
|
autoInjectArgs: true, // inject preview/locale args on root fields
|
|
170
|
-
autoSplitNestedCollections: true, //
|
|
230
|
+
autoSplitNestedCollections: true, // auto-detect non-root *Collection fields
|
|
231
|
+
// (@split works regardless — see below)
|
|
171
232
|
splitBatchSize: 50, // parent ids per subquery
|
|
172
233
|
});
|
|
173
234
|
```
|
|
@@ -226,7 +287,7 @@ The standalone `shapeData` / `ShapeCollections` exports exist only for advanced
|
|
|
226
287
|
|
|
227
288
|
Contentful rejects queries whose complexity exceeds its limit — usually caused by nested reference fields multiplying. `fetch-contentful` avoids this transparently:
|
|
228
289
|
|
|
229
|
-
1. Named fragments are inlined and the query is scanned. Every **nested** `*Collection` field (auto) and every field annotated **`@split`**
|
|
290
|
+
1. Named fragments are inlined and the query is scanned. Every **nested** `*Collection` field (auto) and every field annotated **`@split`** is removed from the outer query and recorded as a plan.
|
|
230
291
|
2. The outer query runs with tiny markers (`sys { id }`, `__typename`) injected where fields were removed.
|
|
231
292
|
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
293
|
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.
|
|
@@ -249,6 +310,59 @@ query {
|
|
|
249
310
|
|
|
250
311
|
The `@split` directive is stripped before anything is sent to Contentful.
|
|
251
312
|
|
|
313
|
+
### `@split` and `autoSplitNestedCollections` are independent
|
|
314
|
+
|
|
315
|
+
`autoSplitNestedCollections` controls **only automatic detection** — whether nested `*Collection` fields are split without being asked. `@split` is an explicit instruction that always applies to the field it annotates, whatever the option is set to, and works on **nested collections just as well as one-to-one references**. An annotated collection takes exactly the same path as an auto-detected one: its arguments (`limit`, `where`, `order`, …) and selection set are re-selected verbatim in the subquery.
|
|
316
|
+
|
|
317
|
+
The two triggers never double up — a field caught by both produces one subquery, not two.
|
|
318
|
+
|
|
319
|
+
### Manual mode
|
|
320
|
+
|
|
321
|
+
Turn auto-splitting off and annotate only the fields that actually blow the complexity limit. Everything else then runs as a single round trip, which is usually faster and always easier to reason about:
|
|
322
|
+
|
|
323
|
+
```ts
|
|
324
|
+
const data = await fetchContentful(QUERY, {
|
|
325
|
+
autoSplitNestedCollections: false,
|
|
326
|
+
});
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
```graphql
|
|
330
|
+
query {
|
|
331
|
+
pageCollection(limit: 10) {
|
|
332
|
+
items {
|
|
333
|
+
title
|
|
334
|
+
# Small and cheap: fetched inline, in the same request.
|
|
335
|
+
seo { description }
|
|
336
|
+
tagsCollection(limit: 5) { items { name } }
|
|
337
|
+
|
|
338
|
+
# The expensive one: fetched separately, batched by parent id.
|
|
339
|
+
sectionsCollection(limit: 50) @split {
|
|
340
|
+
items {
|
|
341
|
+
heading
|
|
342
|
+
mediaCollection(limit: 10) { items { url } }
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
That sends two requests instead of the four auto mode would. Splits still nest recursively in manual mode: a `@split` inside a subquery is planned when that subquery runs, so you can annotate as deeply as you need.
|
|
351
|
+
|
|
352
|
+
### Root fields cannot be split
|
|
353
|
+
|
|
354
|
+
`@split` on a **root** field raises a `CONFIG` `FetchContentfulError` rather than being quietly ignored:
|
|
355
|
+
|
|
356
|
+
```graphql
|
|
357
|
+
query {
|
|
358
|
+
pageCollection @split { # ✗ CONFIG error
|
|
359
|
+
items { title }
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
A root field has no parent entry to stitch its result back onto, so there is nothing for the directive to do. Reach for `limit` and `skip` on the field itself instead. (Before 0.2.1 the directive was silently stripped here — if an existing query relies on that, remove the directive.)
|
|
365
|
+
|
|
252
366
|
## Error handling
|
|
253
367
|
|
|
254
368
|
Every rejection is a `FetchContentfulError`:
|
|
@@ -280,6 +394,9 @@ import {
|
|
|
280
394
|
injectRootArgs, // the preview/locale argument injector (advanced AST use)
|
|
281
395
|
} from '@fourtwelvelabs/fetch-contentful';
|
|
282
396
|
import type { ShapeCollections, FetchContentfulOptions } from '@fourtwelvelabs/fetch-contentful';
|
|
397
|
+
|
|
398
|
+
// Type-only subpath, for wiring gql.tada up by hand (see docs/tada.md).
|
|
399
|
+
import type { ContentfulScalars, JsonValue } from '@fourtwelvelabs/fetch-contentful/tada';
|
|
283
400
|
```
|
|
284
401
|
|
|
285
402
|
`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.
|