@farm.js/sanity 0.1.0-beta.104
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 +22 -0
- package/README.md +158 -0
- package/dist/client.d.ts +9 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +21 -0
- package/dist/config.d.ts +56 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +44 -0
- package/dist/content.d.ts +46 -0
- package/dist/content.d.ts.map +1 -0
- package/dist/content.js +129 -0
- package/dist/image.d.ts +15 -0
- package/dist/image.d.ts.map +1 -0
- package/dist/image.js +37 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +31 -0
- package/dist/webhook.d.ts +14 -0
- package/dist/webhook.d.ts.map +1 -0
- package/dist/webhook.js +55 -0
- package/package.json +54 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Farm.js Team
|
|
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.
|
|
22
|
+
|
package/README.md
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# @farm.js/sanity
|
|
2
|
+
|
|
3
|
+
Sanity CMS integration for Farm.js applications. It resolves the Sanity client configuration,
|
|
4
|
+
validates it at startup, and receives Sanity webhooks so cached content is invalidated the moment
|
|
5
|
+
an editor publishes.
|
|
6
|
+
|
|
7
|
+
Farm.js is currently in beta.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pnpm add @farm.js/sanity @sanity/client
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`@sanity/client` is a peer dependency so the application and the integration share one client.
|
|
16
|
+
|
|
17
|
+
## Configure
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { defineConfig } from "@farm.js/core";
|
|
21
|
+
import { sanity } from "@farm.js/sanity";
|
|
22
|
+
|
|
23
|
+
export default defineConfig({
|
|
24
|
+
integrations: {
|
|
25
|
+
cms: sanity(),
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
With no options, values come from the environment:
|
|
31
|
+
|
|
32
|
+
| Variable | Purpose |
|
|
33
|
+
| ----------------------- | ------------------------------------------------------------------------------ |
|
|
34
|
+
| `SANITY_PROJECT_ID` | Project id. `SANITY_STUDIO_PROJECT_ID` is also read. |
|
|
35
|
+
| `SANITY_DATASET` | Dataset. `SANITY_STUDIO_DATASET` is also read. |
|
|
36
|
+
| `SANITY_API_VERSION` | Optional. `SANITY_STUDIO_API_VERSION` is also read. Defaults to a pinned date. |
|
|
37
|
+
| `SANITY_API_READ_TOKEN` | Optional. A Viewer token, needed for private datasets and drafts. |
|
|
38
|
+
| `SANITY_WEBHOOK_SECRET` | Required once a webhook is configured. |
|
|
39
|
+
|
|
40
|
+
A missing project id or dataset fails at startup rather than at the first request.
|
|
41
|
+
|
|
42
|
+
Note that a dataset which needs a token returns an empty result to an unauthenticated query,
|
|
43
|
+
with no error. If content is missing, check the token before anything else.
|
|
44
|
+
|
|
45
|
+
## Read content
|
|
46
|
+
|
|
47
|
+
The application owns the client. Build it once, use it directly, and hand the same object to the
|
|
48
|
+
integration so Farm does not construct a second one.
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
// src/lib/cms.server.ts
|
|
52
|
+
import { createSanityClient, resolveSanityConfig } from "@farm.js/sanity";
|
|
53
|
+
|
|
54
|
+
export const cms = createSanityClient(resolveSanityConfig({ useCdn: false }));
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
// farm.config.ts
|
|
59
|
+
integrations: {
|
|
60
|
+
cms: sanity({ instance: cms }),
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Wrap queries in `createServerQuery` so results are cached and can be invalidated by key.
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { createServerQuery } from "@farm.js/core";
|
|
68
|
+
import { cms } from "./cms.server";
|
|
69
|
+
|
|
70
|
+
export const postsQuery = createServerQuery({
|
|
71
|
+
key: () => ["sanity", "post", "list"],
|
|
72
|
+
staleTime: "5m",
|
|
73
|
+
handler: () =>
|
|
74
|
+
cms.fetch(`*[_type == "post"] | order(publishedAt desc) { title, "slug": slug.current }`),
|
|
75
|
+
});
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Invalidate on publish
|
|
79
|
+
|
|
80
|
+
Sanity can call your application when a document changes. The integration verifies the request
|
|
81
|
+
came from Sanity, then asks the application which cache entries the change affects.
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
sanity({
|
|
85
|
+
instance: cms,
|
|
86
|
+
webhook: {
|
|
87
|
+
onChange(payload) {
|
|
88
|
+
if (payload._type !== "post") return;
|
|
89
|
+
const slug = payload.slug as string;
|
|
90
|
+
return {
|
|
91
|
+
keys: [
|
|
92
|
+
["sanity", "post", "list"],
|
|
93
|
+
["sanity", "post", slug],
|
|
94
|
+
],
|
|
95
|
+
paths: ["/posts", `/posts/${slug}`],
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`keys` are server query keys. `paths` are route paths whose rendered output should be
|
|
103
|
+
revalidated as well. Return nothing for changes that affect no cached content.
|
|
104
|
+
|
|
105
|
+
In Sanity, create a webhook under **API > Webhooks**:
|
|
106
|
+
|
|
107
|
+
- URL: `https://your-app.example/api/sanity/webhook`
|
|
108
|
+
- Trigger on create, update and delete
|
|
109
|
+
- Projection: `{ _id, _type, "slug": slug.current }`
|
|
110
|
+
- Secret: the value of `SANITY_WEBHOOK_SECRET`
|
|
111
|
+
|
|
112
|
+
The payload passed to `onChange` is whatever the projection returns, so include the fields the
|
|
113
|
+
mapping needs. A document referenced by others can list them, for example
|
|
114
|
+
`"slugs": *[_type == "post" && references(^._id)].slug.current`.
|
|
115
|
+
|
|
116
|
+
Responses follow what Sanity does next: `401` for a bad signature and `400` for a malformed body
|
|
117
|
+
are not retried, and a `500` from a failing `onChange` is retried.
|
|
118
|
+
|
|
119
|
+
### Read through the API, not the CDN
|
|
120
|
+
|
|
121
|
+
Set `useCdn: false` on the client when the webhook drives invalidation. The webhook fires before
|
|
122
|
+
Sanity's CDN has updated, so a refetch through the CDN can return the previous content and cache
|
|
123
|
+
it again for the full `staleTime`. Farm's cache is the cache in this setup; a second one in front
|
|
124
|
+
of it only adds that race.
|
|
125
|
+
|
|
126
|
+
Leave the CDN on for applications that rely on `staleTime` alone, or for browser side reads.
|
|
127
|
+
|
|
128
|
+
### Multiple server instances
|
|
129
|
+
|
|
130
|
+
Farm's data cache lives in memory per process unless a distributed adapter is configured. On a
|
|
131
|
+
serverless platform the webhook reaches one instance and clears only that instance's memory. Use
|
|
132
|
+
an adapter such as `@farm.js/cache-redis` so an invalidation reaches every instance.
|
|
133
|
+
|
|
134
|
+
## Options
|
|
135
|
+
|
|
136
|
+
| Option | Default | Notes |
|
|
137
|
+
| ------------------ | --------------------- | ----------------------------------------------------- |
|
|
138
|
+
| `projectId` | env | |
|
|
139
|
+
| `dataset` | env | |
|
|
140
|
+
| `apiVersion` | pinned date | Exported as `DEFAULT_SANITY_API_VERSION`. |
|
|
141
|
+
| `token` | env | Server only. Never passed to the browser. |
|
|
142
|
+
| `useCdn` | `true` | Set `false` with webhook invalidation. |
|
|
143
|
+
| `instance` | | Existing `SanityClient`. Skips credential validation. |
|
|
144
|
+
| `webhook.secret` | env | |
|
|
145
|
+
| `webhook.path` | `/api/sanity/webhook` | |
|
|
146
|
+
| `webhook.onChange` | | Maps a payload to `{ keys?, paths? }`. |
|
|
147
|
+
| `log` | | Integration lifecycle logger. |
|
|
148
|
+
|
|
149
|
+
## Bring your own client
|
|
150
|
+
|
|
151
|
+
Pass a configured client through `instance` for options the integration does not expose. The
|
|
152
|
+
integration uses that object as is.
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
import { createClient } from "@sanity/client";
|
|
156
|
+
|
|
157
|
+
sanity({ instance: createClient({ projectId, dataset, apiVersion, perspective: "published" }) });
|
|
158
|
+
```
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ResolvedSanityConfig } from "./config.js";
|
|
2
|
+
import { type SanityClient } from "@sanity/client";
|
|
3
|
+
export declare function createSanityClient(config: ResolvedSanityConfig, instance?: SanityClient): SanityClient;
|
|
4
|
+
/**
|
|
5
|
+
* Webhooks fire before Sanity's CDN updates, so a revalidation fetch through
|
|
6
|
+
* the CDN re-caches the content it was told to replace.
|
|
7
|
+
*/
|
|
8
|
+
export declare function createFreshSanityClient(client: SanityClient): SanityClient;
|
|
9
|
+
//# sourceMappingURL=client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AACxD,OAAO,EAAgB,KAAK,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAEjE,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,oBAAoB,EAC5B,QAAQ,CAAC,EAAE,YAAY,GACtB,YAAY,CAYd;AAED;;;GAGG;AACH,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,YAAY,GAAG,YAAY,CAE1E"}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createClient } from "@sanity/client";
|
|
2
|
+
export function createSanityClient(config, instance) {
|
|
3
|
+
if (instance)
|
|
4
|
+
return instance;
|
|
5
|
+
// Mapped field by field. Spreading config would forward the webhook secret
|
|
6
|
+
// straight into the Sanity client.
|
|
7
|
+
return createClient({
|
|
8
|
+
projectId: config.projectId,
|
|
9
|
+
dataset: config.dataset,
|
|
10
|
+
apiVersion: config.apiVersion,
|
|
11
|
+
useCdn: config.useCdn,
|
|
12
|
+
...(config.token ? { token: config.token } : {}),
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Webhooks fire before Sanity's CDN updates, so a revalidation fetch through
|
|
17
|
+
* the CDN re-caches the content it was told to replace.
|
|
18
|
+
*/
|
|
19
|
+
export function createFreshSanityClient(client) {
|
|
20
|
+
return client.withConfig({ useCdn: false });
|
|
21
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { FarmIntegrationLogger, RouteDataCacheKey } from "@farm.js/core";
|
|
2
|
+
import type { SanityClient } from "@sanity/client";
|
|
3
|
+
/** Pinned so query behaviour never changes without a version bump. */
|
|
4
|
+
export declare const DEFAULT_SANITY_API_VERSION = "2026-03-01";
|
|
5
|
+
export interface SanityWebhookChange {
|
|
6
|
+
/** Server query keys to invalidate, as passed to `createServerQuery`. */
|
|
7
|
+
keys?: readonly RouteDataCacheKey[];
|
|
8
|
+
/** Route paths whose rendered output should be revalidated. */
|
|
9
|
+
paths?: readonly string[];
|
|
10
|
+
}
|
|
11
|
+
export interface SanityWebhookOptions {
|
|
12
|
+
/** Defaults to `SANITY_WEBHOOK_SECRET`. */
|
|
13
|
+
secret?: string;
|
|
14
|
+
/** Defaults to `/api/sanity/webhook`. */
|
|
15
|
+
path?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Maps a changed document to the cache entries it affects. The payload is
|
|
18
|
+
* whatever projection the webhook is configured with in Sanity.
|
|
19
|
+
*/
|
|
20
|
+
onChange(payload: Record<string, unknown>): SanityWebhookChange | void | Promise<SanityWebhookChange | void>;
|
|
21
|
+
}
|
|
22
|
+
export interface SanityIntegrationInput {
|
|
23
|
+
projectId?: string;
|
|
24
|
+
dataset?: string;
|
|
25
|
+
apiVersion?: string;
|
|
26
|
+
token?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Defaults to true. Set false when the webhook drives invalidation: the
|
|
29
|
+
* webhook fires before Sanity's CDN updates, so a CDN read after
|
|
30
|
+
* invalidation can re-cache the content it was told to replace.
|
|
31
|
+
*/
|
|
32
|
+
useCdn?: boolean;
|
|
33
|
+
/** Existing Sanity client. When provided, Farm does not construct its own. */
|
|
34
|
+
instance?: SanityClient;
|
|
35
|
+
webhook?: SanityWebhookOptions;
|
|
36
|
+
log?: FarmIntegrationLogger;
|
|
37
|
+
}
|
|
38
|
+
export interface ResolvedSanityConfig {
|
|
39
|
+
projectId: string;
|
|
40
|
+
dataset: string;
|
|
41
|
+
apiVersion: string;
|
|
42
|
+
useCdn: boolean;
|
|
43
|
+
token?: string;
|
|
44
|
+
webhookSecret?: string;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Farm copies .env into process.env before evaluating the config file, so the
|
|
48
|
+
* integration factory can resolve everything it needs up front.
|
|
49
|
+
*/
|
|
50
|
+
export declare function resolveSanityConfig(input: SanityIntegrationInput): ResolvedSanityConfig;
|
|
51
|
+
/**
|
|
52
|
+
* Validation for Farm. Values are already resolved, so this only reports what
|
|
53
|
+
* is missing and turns it into a startup error rather than a runtime one.
|
|
54
|
+
*/
|
|
55
|
+
export declare function sanityIntegrationConfig(resolved: ResolvedSanityConfig, input: SanityIntegrationInput): import("@farm.js/core").FarmIntegrationConfigDefinition<ResolvedSanityConfig, import("@farm.js/core").FarmSchema | undefined>;
|
|
56
|
+
//# sourceMappingURL=config.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qBAAqB,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAE9E,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAEnD,sEAAsE;AACtE,eAAO,MAAM,0BAA0B,eAAe,CAAC;AAEvD,MAAM,WAAW,mBAAmB;IAClC,yEAAyE;IACzE,IAAI,CAAC,EAAE,SAAS,iBAAiB,EAAE,CAAC;IACpC,+DAA+D;IAC/D,KAAK,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC3B;AAED,MAAM,WAAW,oBAAoB;IACnC,2CAA2C;IAC3C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,yCAAyC;IACzC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;OAGG;IACH,QAAQ,CACN,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC/B,mBAAmB,GAAG,IAAI,GAAG,OAAO,CAAC,mBAAmB,GAAG,IAAI,CAAC,CAAC;CACrE;AAED,MAAM,WAAW,sBAAsB;IACrC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;OAIG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,YAAY,CAAC;IACxB,OAAO,CAAC,EAAE,oBAAoB,CAAC;IAC/B,GAAG,CAAC,EAAE,qBAAqB,CAAC;CAC7B;AAED,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,OAAO,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAWD;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,sBAAsB,GAAG,oBAAoB,CAYvF;AAED;;;GAGG;AACH,wBAAgB,uBAAuB,CACrC,QAAQ,EAAE,oBAAoB,EAC9B,KAAK,EAAE,sBAAsB,iIAY9B"}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { integrationConfig } from "@farm.js/integration-utils";
|
|
2
|
+
/** Pinned so query behaviour never changes without a version bump. */
|
|
3
|
+
export const DEFAULT_SANITY_API_VERSION = "2026-03-01";
|
|
4
|
+
/** First non-empty value among the given variables. */
|
|
5
|
+
function readEnv(...names) {
|
|
6
|
+
for (const name of names) {
|
|
7
|
+
const value = process.env[name];
|
|
8
|
+
if (value)
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Farm copies .env into process.env before evaluating the config file, so the
|
|
15
|
+
* integration factory can resolve everything it needs up front.
|
|
16
|
+
*/
|
|
17
|
+
export function resolveSanityConfig(input) {
|
|
18
|
+
return {
|
|
19
|
+
projectId: input.projectId ?? readEnv("SANITY_PROJECT_ID", "SANITY_STUDIO_PROJECT_ID") ?? "",
|
|
20
|
+
dataset: input.dataset ?? readEnv("SANITY_DATASET", "SANITY_STUDIO_DATASET") ?? "",
|
|
21
|
+
apiVersion: input.apiVersion ??
|
|
22
|
+
readEnv("SANITY_API_VERSION", "SANITY_STUDIO_API_VERSION") ??
|
|
23
|
+
DEFAULT_SANITY_API_VERSION,
|
|
24
|
+
useCdn: input.useCdn ?? true,
|
|
25
|
+
token: input.token ?? readEnv("SANITY_API_READ_TOKEN"),
|
|
26
|
+
webhookSecret: input.webhook?.secret ?? readEnv("SANITY_WEBHOOK_SECRET"),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Validation for Farm. Values are already resolved, so this only reports what
|
|
31
|
+
* is missing and turns it into a startup error rather than a runtime one.
|
|
32
|
+
*/
|
|
33
|
+
export function sanityIntegrationConfig(resolved, input) {
|
|
34
|
+
const required = input.instance
|
|
35
|
+
? []
|
|
36
|
+
: ["projectId", "dataset"];
|
|
37
|
+
if (input.webhook)
|
|
38
|
+
required.push("webhookSecret");
|
|
39
|
+
return integrationConfig({
|
|
40
|
+
label: "Sanity integration",
|
|
41
|
+
input: resolved,
|
|
42
|
+
required,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ContentRemoteSource } from "@farm.js/content";
|
|
2
|
+
import type { SanityClient } from "@sanity/client";
|
|
3
|
+
export interface SanityContentSourceOptions {
|
|
4
|
+
/** GROQ query resolving to an array of documents. */
|
|
5
|
+
query: string;
|
|
6
|
+
params?: Record<string, unknown>;
|
|
7
|
+
/** Existing Sanity client. When provided, Farm does not construct its own. */
|
|
8
|
+
client?: SanityClient;
|
|
9
|
+
projectId?: string;
|
|
10
|
+
dataset?: string;
|
|
11
|
+
apiVersion?: string;
|
|
12
|
+
token?: string;
|
|
13
|
+
/**
|
|
14
|
+
* Defaults to false here, unlike the integration: content is fetched while
|
|
15
|
+
* the configuration loads and bundled, so a build wants the freshest
|
|
16
|
+
* documents rather than the CDN's cached copy.
|
|
17
|
+
*/
|
|
18
|
+
useCdn?: boolean;
|
|
19
|
+
/** Names the source in error messages. Defaults to "sanity". */
|
|
20
|
+
name?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Entry identifier per document. Defaults to `slug.current` when present,
|
|
23
|
+
* falling back to `_id`.
|
|
24
|
+
*/
|
|
25
|
+
id?: (document: Record<string, unknown>) => string;
|
|
26
|
+
/** Optional Markdown body per document, for `entry.body` and word counts. */
|
|
27
|
+
body?: (document: Record<string, unknown>) => string | undefined;
|
|
28
|
+
/** Development re-fetch cadence in milliseconds. */
|
|
29
|
+
refreshInterval?: number;
|
|
30
|
+
/**
|
|
31
|
+
* Enables `collections.<name>.create/update/delete`. Defaults to
|
|
32
|
+
* `SANITY_API_WRITE_TOKEN`; without it the source stays read-only.
|
|
33
|
+
* Keep write tokens server-side only.
|
|
34
|
+
*/
|
|
35
|
+
writeToken?: string;
|
|
36
|
+
/** Sanity `_type` for documents made through `create`. Required to create. */
|
|
37
|
+
createType?: string;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* A `@farm.js/content` source that loads documents from Sanity, so a CMS
|
|
41
|
+
* collection gets the same schema validation, transforms, and generated
|
|
42
|
+
* server types as local files. Content is a build-time snapshot; configure a
|
|
43
|
+
* Sanity webhook against a deploy hook to rebuild on publish.
|
|
44
|
+
*/
|
|
45
|
+
export declare function sanitySource(options: SanityContentSourceOptions): ContentRemoteSource;
|
|
46
|
+
//# sourceMappingURL=content.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"content.d.ts","sourceRoot":"","sources":["../src/content.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAyB,mBAAmB,EAAE,MAAM,kBAAkB,CAAC;AACnF,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAInD,MAAM,WAAW,0BAA0B;IACzC,qDAAqD;IACrD,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC,8EAA8E;IAC9E,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;OAIG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,gEAAgE;IAChE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;OAGG;IACH,EAAE,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,MAAM,CAAC;IACnD,6EAA6E;IAC7E,IAAI,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,MAAM,GAAG,SAAS,CAAC;IACjE,oDAAoD;IACpD,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,8EAA8E;IAC9E,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,0BAA0B,GAAG,mBAAmB,CA+HrF"}
|
package/dist/content.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { createSanityClient } from "./client.js";
|
|
2
|
+
import { resolveSanityConfig } from "./config.js";
|
|
3
|
+
/**
|
|
4
|
+
* A `@farm.js/content` source that loads documents from Sanity, so a CMS
|
|
5
|
+
* collection gets the same schema validation, transforms, and generated
|
|
6
|
+
* server types as local files. Content is a build-time snapshot; configure a
|
|
7
|
+
* Sanity webhook against a deploy hook to rebuild on publish.
|
|
8
|
+
*/
|
|
9
|
+
export function sanitySource(options) {
|
|
10
|
+
if (typeof options?.query !== "string" || !options.query.trim()) {
|
|
11
|
+
throw new TypeError("sanitySource() requires a GROQ `query` string");
|
|
12
|
+
}
|
|
13
|
+
const name = options.name ?? "sanity";
|
|
14
|
+
const resolveId = options.id ?? defaultDocumentId;
|
|
15
|
+
// Resolved lazily so importing farm.config.ts without the env set only
|
|
16
|
+
// fails when the collection actually loads, with an actionable message.
|
|
17
|
+
let client = options.client;
|
|
18
|
+
const resolveClient = () => {
|
|
19
|
+
if (client)
|
|
20
|
+
return client;
|
|
21
|
+
const config = resolveSanityConfig({
|
|
22
|
+
projectId: options.projectId,
|
|
23
|
+
dataset: options.dataset,
|
|
24
|
+
apiVersion: options.apiVersion,
|
|
25
|
+
token: options.token,
|
|
26
|
+
useCdn: options.useCdn ?? false,
|
|
27
|
+
});
|
|
28
|
+
if (!config.projectId || !config.dataset) {
|
|
29
|
+
throw new Error(`sanitySource(${JSON.stringify(name)}) requires a project id and dataset. Set ` +
|
|
30
|
+
"SANITY_PROJECT_ID and SANITY_DATASET, pass them to sanitySource(), or supply " +
|
|
31
|
+
"an existing client through `client`.");
|
|
32
|
+
}
|
|
33
|
+
client = createSanityClient(config);
|
|
34
|
+
return client;
|
|
35
|
+
};
|
|
36
|
+
const writeToken = options.writeToken ?? process.env.SANITY_API_WRITE_TOKEN;
|
|
37
|
+
let writeClient;
|
|
38
|
+
const resolveWriteClient = () => {
|
|
39
|
+
if (writeClient)
|
|
40
|
+
return writeClient;
|
|
41
|
+
const base = resolveClient();
|
|
42
|
+
// The write path never reads through the CDN and may need a stronger token.
|
|
43
|
+
writeClient = base.withConfig({ useCdn: false, ...(writeToken ? { token: writeToken } : {}) });
|
|
44
|
+
return writeClient;
|
|
45
|
+
};
|
|
46
|
+
// Entry ids are slugs by default, but Sanity mutations address `_id`.
|
|
47
|
+
// Remember the mapping from the last fetch and fall back to a lookup.
|
|
48
|
+
const documentIds = new Map();
|
|
49
|
+
const resolveDocumentId = async (id) => {
|
|
50
|
+
const known = documentIds.get(id);
|
|
51
|
+
if (known)
|
|
52
|
+
return known;
|
|
53
|
+
const bySlug = await resolveWriteClient().fetch("*[slug.current == $id][0]._id", { id });
|
|
54
|
+
return bySlug ?? id;
|
|
55
|
+
};
|
|
56
|
+
const toDocument = (record) => {
|
|
57
|
+
const id = resolveId(record) || record._id;
|
|
58
|
+
if (typeof record._id === "string")
|
|
59
|
+
documentIds.set(id, record._id);
|
|
60
|
+
const body = options.body?.(record);
|
|
61
|
+
return { id, data: record, ...(typeof body === "string" ? { body } : {}) };
|
|
62
|
+
};
|
|
63
|
+
const writes = writeToken
|
|
64
|
+
? {
|
|
65
|
+
create: async (input) => {
|
|
66
|
+
if (!options.createType) {
|
|
67
|
+
throw new Error(`sanitySource(${JSON.stringify(name)}) needs \`createType\` (the Sanity _type) to create documents`);
|
|
68
|
+
}
|
|
69
|
+
const created = await resolveWriteClient().create({
|
|
70
|
+
_type: options.createType,
|
|
71
|
+
...input.data,
|
|
72
|
+
});
|
|
73
|
+
return toDocument(created);
|
|
74
|
+
},
|
|
75
|
+
update: async (id, patch) => {
|
|
76
|
+
const documentId = await resolveDocumentId(id);
|
|
77
|
+
const updated = await resolveWriteClient()
|
|
78
|
+
.patch(documentId)
|
|
79
|
+
.set(patch.data ?? {})
|
|
80
|
+
.commit();
|
|
81
|
+
return toDocument(updated);
|
|
82
|
+
},
|
|
83
|
+
delete: async (id) => {
|
|
84
|
+
await resolveWriteClient().delete(await resolveDocumentId(id));
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
: {};
|
|
88
|
+
return {
|
|
89
|
+
kind: "remote",
|
|
90
|
+
name,
|
|
91
|
+
...(options.refreshInterval !== undefined ? { refreshInterval: options.refreshInterval } : {}),
|
|
92
|
+
...writes,
|
|
93
|
+
async fetch() {
|
|
94
|
+
const result = await resolveClient().fetch(options.query, options.params ?? {});
|
|
95
|
+
if (!Array.isArray(result)) {
|
|
96
|
+
throw new Error(`sanitySource(${JSON.stringify(name)}) query must resolve to an array of documents; ` +
|
|
97
|
+
"wrap single documents in [] in the GROQ projection");
|
|
98
|
+
}
|
|
99
|
+
return result.map((document, index) => {
|
|
100
|
+
if (!document || typeof document !== "object" || Array.isArray(document)) {
|
|
101
|
+
throw new Error(`sanitySource(${JSON.stringify(name)}) document at index ${index} is not an object`);
|
|
102
|
+
}
|
|
103
|
+
const record = document;
|
|
104
|
+
const id = resolveId(record);
|
|
105
|
+
if (typeof id !== "string" || !id) {
|
|
106
|
+
throw new Error(`sanitySource(${JSON.stringify(name)}) could not derive an id for the document at ` +
|
|
107
|
+
`index ${index}; give documents a slug or pass an \`id\` function`);
|
|
108
|
+
}
|
|
109
|
+
if (typeof record._id === "string")
|
|
110
|
+
documentIds.set(id, record._id);
|
|
111
|
+
const body = options.body?.(record);
|
|
112
|
+
return {
|
|
113
|
+
id,
|
|
114
|
+
data: record,
|
|
115
|
+
...(typeof body === "string" ? { body } : {}),
|
|
116
|
+
};
|
|
117
|
+
});
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function defaultDocumentId(document) {
|
|
122
|
+
const slug = document.slug;
|
|
123
|
+
if (slug &&
|
|
124
|
+
typeof slug === "object" &&
|
|
125
|
+
typeof slug.current === "string") {
|
|
126
|
+
return slug.current;
|
|
127
|
+
}
|
|
128
|
+
return typeof document._id === "string" ? document._id : "";
|
|
129
|
+
}
|
package/dist/image.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { FarmImageLoader } from "@farm.js/core/image";
|
|
2
|
+
export interface SanityImageLoaderOptions {
|
|
3
|
+
projectId: string;
|
|
4
|
+
dataset: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* A loader for `@farm.js/core/image` that resolves images on the Sanity CDN.
|
|
8
|
+
*
|
|
9
|
+
* `src` is either an asset id such as `image-abc-2000x3000-jpg` or a CDN URL
|
|
10
|
+
* that already carries crop and hotspot parameters. Crop and hotspot live on
|
|
11
|
+
* the image object and cannot pass through a string, so an app that needs them
|
|
12
|
+
* builds the base URL with `@sanity/image-url` and hands that in as `src`.
|
|
13
|
+
*/
|
|
14
|
+
export declare function createSanityImageLoader(options: SanityImageLoaderOptions): FarmImageLoader;
|
|
15
|
+
//# sourceMappingURL=image.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"image.d.ts","sourceRoot":"","sources":["../src/image.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAG3D,MAAM,WAAW,wBAAwB;IACvC,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,wBAAwB,GAAG,eAAe,CA+B1F"}
|
package/dist/image.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createImageUrlBuilder } from "@sanity/image-url";
|
|
2
|
+
/**
|
|
3
|
+
* A loader for `@farm.js/core/image` that resolves images on the Sanity CDN.
|
|
4
|
+
*
|
|
5
|
+
* `src` is either an asset id such as `image-abc-2000x3000-jpg` or a CDN URL
|
|
6
|
+
* that already carries crop and hotspot parameters. Crop and hotspot live on
|
|
7
|
+
* the image object and cannot pass through a string, so an app that needs them
|
|
8
|
+
* builds the base URL with `@sanity/image-url` and hands that in as `src`.
|
|
9
|
+
*/
|
|
10
|
+
export function createSanityImageLoader(options) {
|
|
11
|
+
const builder = createImageUrlBuilder(options);
|
|
12
|
+
return ({ src, width, quality }) => {
|
|
13
|
+
const w = String(Math.round(width));
|
|
14
|
+
const q = String(Math.round(quality));
|
|
15
|
+
// A prebuilt CDN URL already carries crop and hotspot. The builder only
|
|
16
|
+
// parses asset references, so append the responsive parameters directly.
|
|
17
|
+
if (/^https?:\/\//.test(src)) {
|
|
18
|
+
const url = new URL(src);
|
|
19
|
+
url.searchParams.set("w", w);
|
|
20
|
+
url.searchParams.set("q", q);
|
|
21
|
+
url.searchParams.set("auto", "format");
|
|
22
|
+
// An art-directed URL may already carry fit=crop; only default it.
|
|
23
|
+
if (!url.searchParams.has("fit"))
|
|
24
|
+
url.searchParams.set("fit", "max");
|
|
25
|
+
return url.toString();
|
|
26
|
+
}
|
|
27
|
+
return (builder
|
|
28
|
+
.image(src)
|
|
29
|
+
.width(Math.round(width))
|
|
30
|
+
.quality(Math.round(quality))
|
|
31
|
+
// Let the CDN negotiate WebP or AVIF from the Accept header.
|
|
32
|
+
.auto("format")
|
|
33
|
+
// Serve the original when it is smaller than requested instead of upscaling.
|
|
34
|
+
.fit("max")
|
|
35
|
+
.url());
|
|
36
|
+
};
|
|
37
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type SanityIntegrationInput } from "./config.js";
|
|
2
|
+
export declare function sanity(input?: SanityIntegrationInput): import("@farm.js/core").DefinedIntegration<{
|
|
3
|
+
category: "cms";
|
|
4
|
+
type: string;
|
|
5
|
+
instance: import("@sanity/client").SanityClient;
|
|
6
|
+
config: import("@farm.js/core").FarmIntegrationConfigDefinition<import("./config.js").ResolvedSanityConfig, import("@farm.js/core").FarmSchema | undefined>;
|
|
7
|
+
log: import("@farm.js/core").FarmIntegrationLogger | undefined;
|
|
8
|
+
routes: import("@farm.js/core").FarmIntegrationRoute<unknown, unknown, import("@farm.js/core").FarmSchema | undefined>[];
|
|
9
|
+
}, import("@farm.js/core").FarmSchema | undefined>;
|
|
10
|
+
export { createFreshSanityClient, createSanityClient } from "./client.js";
|
|
11
|
+
export { DEFAULT_SANITY_API_VERSION, resolveSanityConfig } from "./config.js";
|
|
12
|
+
export type { ResolvedSanityConfig, SanityIntegrationInput, SanityWebhookChange, SanityWebhookOptions, } from "./config.js";
|
|
13
|
+
export { createSanityImageLoader, type SanityImageLoaderOptions } from "./image.js";
|
|
14
|
+
export { createSanityWebhookRoute, DEFAULT_SANITY_WEBHOOK_PATH } from "./webhook.js";
|
|
15
|
+
export { sanitySource } from "./content.js";
|
|
16
|
+
export type { SanityContentSourceOptions } from "./content.js";
|
|
17
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAGL,KAAK,sBAAsB,EAC5B,MAAM,aAAa,CAAC;AAGrB,wBAAgB,MAAM,CAAC,KAAK,GAAE,sBAA2B;;;;;;;mDA2BxD;AAED,OAAO,EAAE,uBAAuB,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAC1E,OAAO,EAAE,0BAA0B,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAC9E,YAAY,EACV,oBAAoB,EACpB,sBAAsB,EACtB,mBAAmB,EACnB,oBAAoB,GACrB,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,uBAAuB,EAAE,KAAK,wBAAwB,EAAE,MAAM,YAAY,CAAC;AACpF,OAAO,EAAE,wBAAwB,EAAE,2BAA2B,EAAE,MAAM,cAAc,CAAC;AACrF,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAC5C,YAAY,EAAE,0BAA0B,EAAE,MAAM,cAAc,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { defineIntegration } from "@farm.js/core";
|
|
2
|
+
import { createSanityClient } from "./client.js";
|
|
3
|
+
import { resolveSanityConfig, sanityIntegrationConfig, } from "./config.js";
|
|
4
|
+
import { createSanityWebhookRoute } from "./webhook.js";
|
|
5
|
+
export function sanity(input = {}) {
|
|
6
|
+
const config = resolveSanityConfig(input);
|
|
7
|
+
// The factory runs while farm.config.ts is evaluated, before Farm validates
|
|
8
|
+
// integration config, and the client cannot be constructed without these.
|
|
9
|
+
if (!input.instance && (!config.projectId || !config.dataset)) {
|
|
10
|
+
throw new Error("Sanity integration requires a project id and dataset. Set SANITY_PROJECT_ID and " +
|
|
11
|
+
"SANITY_DATASET, pass them to sanity(), or supply an existing client through `instance`.");
|
|
12
|
+
}
|
|
13
|
+
// A configured webhook without a secret is reported by `config` at startup,
|
|
14
|
+
// so the route is only registered once both are present.
|
|
15
|
+
const routes = input.webhook && config.webhookSecret
|
|
16
|
+
? [createSanityWebhookRoute({ ...input.webhook, secret: config.webhookSecret })]
|
|
17
|
+
: [];
|
|
18
|
+
return defineIntegration({
|
|
19
|
+
category: "cms",
|
|
20
|
+
type: "sanity",
|
|
21
|
+
instance: createSanityClient(config, input.instance),
|
|
22
|
+
config: sanityIntegrationConfig(config, input),
|
|
23
|
+
log: input.log,
|
|
24
|
+
routes,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
export { createFreshSanityClient, createSanityClient } from "./client.js";
|
|
28
|
+
export { DEFAULT_SANITY_API_VERSION, resolveSanityConfig } from "./config.js";
|
|
29
|
+
export { createSanityImageLoader } from "./image.js";
|
|
30
|
+
export { createSanityWebhookRoute, DEFAULT_SANITY_WEBHOOK_PATH } from "./webhook.js";
|
|
31
|
+
export { sanitySource } from "./content.js";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type FarmIntegrationRoute, type RouteDataCacheKey } from "@farm.js/core";
|
|
2
|
+
import type { SanityWebhookChange, SanityWebhookOptions } from "./config.js";
|
|
3
|
+
export declare const DEFAULT_SANITY_WEBHOOK_PATH = "/api/sanity/webhook";
|
|
4
|
+
/** The cache operations the route performs. Injected so tests need no cache. */
|
|
5
|
+
export interface SanityWebhookInvalidation {
|
|
6
|
+
invalidate(key: RouteDataCacheKey): void | Promise<void>;
|
|
7
|
+
revalidatePath(path: string): void | Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
export interface SanityWebhookRouteOptions extends Omit<SanityWebhookOptions, "secret"> {
|
|
10
|
+
secret: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function applySanityWebhookChange(change: SanityWebhookChange | void, invalidation?: SanityWebhookInvalidation): Promise<number>;
|
|
13
|
+
export declare function createSanityWebhookRoute(options: SanityWebhookRouteOptions, invalidation?: SanityWebhookInvalidation): FarmIntegrationRoute;
|
|
14
|
+
//# sourceMappingURL=webhook.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webhook.d.ts","sourceRoot":"","sources":["../src/webhook.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,oBAAoB,EACzB,KAAK,iBAAiB,EACvB,MAAM,eAAe,CAAC;AAEvB,OAAO,KAAK,EAAE,mBAAmB,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AAE7E,eAAO,MAAM,2BAA2B,wBAAwB,CAAC;AAEjE,gFAAgF;AAChF,MAAM,WAAW,yBAAyB;IACxC,UAAU,CAAC,GAAG,EAAE,iBAAiB,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACzD,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACpD;AAID,MAAM,WAAW,yBAA0B,SAAQ,IAAI,CAAC,oBAAoB,EAAE,QAAQ,CAAC;IACrF,MAAM,EAAE,MAAM,CAAC;CAChB;AASD,wBAAsB,wBAAwB,CAC5C,MAAM,EAAE,mBAAmB,GAAG,IAAI,EAClC,YAAY,GAAE,yBAA4C,GACzD,OAAO,CAAC,MAAM,CAAC,CAWjB;AAED,wBAAgB,wBAAwB,CACtC,OAAO,EAAE,yBAAyB,EAClC,YAAY,GAAE,yBAA4C,GACzD,oBAAoB,CAgCtB"}
|
package/dist/webhook.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { integrationRoute, invalidate, revalidatePath, } from "@farm.js/core";
|
|
2
|
+
import { isValidSignature, SIGNATURE_HEADER_NAME } from "@sanity/webhook";
|
|
3
|
+
export const DEFAULT_SANITY_WEBHOOK_PATH = "/api/sanity/webhook";
|
|
4
|
+
const farmInvalidation = { invalidate, revalidatePath };
|
|
5
|
+
function json(status, body) {
|
|
6
|
+
return new Response(JSON.stringify(body), {
|
|
7
|
+
status,
|
|
8
|
+
headers: { "content-type": "application/json" },
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
export async function applySanityWebhookChange(change, invalidation = farmInvalidation) {
|
|
12
|
+
if (!change)
|
|
13
|
+
return 0;
|
|
14
|
+
// The cache reports nothing back, so the count is of distinct targets asked
|
|
15
|
+
// for, not entries removed. Deduplicate so a repeated key is only requested once.
|
|
16
|
+
const keys = [...new Map((change.keys ?? []).map((key) => [JSON.stringify(key), key])).values()];
|
|
17
|
+
const paths = [...new Set(change.paths ?? [])];
|
|
18
|
+
await Promise.all([
|
|
19
|
+
...keys.map((key) => invalidation.invalidate(key)),
|
|
20
|
+
...paths.map((path) => invalidation.revalidatePath(path)),
|
|
21
|
+
]);
|
|
22
|
+
return keys.length + paths.length;
|
|
23
|
+
}
|
|
24
|
+
export function createSanityWebhookRoute(options, invalidation = farmInvalidation) {
|
|
25
|
+
return integrationRoute.post(options.path ?? DEFAULT_SANITY_WEBHOOK_PATH, {
|
|
26
|
+
// Sanity signs the exact bytes it sent. Parsing first and re-serialising
|
|
27
|
+
// changes them, so the body has to be read as text and verified before
|
|
28
|
+
// anything looks at it.
|
|
29
|
+
rawBody: true,
|
|
30
|
+
async handler(request) {
|
|
31
|
+
const rawBody = await request.text();
|
|
32
|
+
const signature = request.headers.get(SIGNATURE_HEADER_NAME);
|
|
33
|
+
if (!signature || !(await isValidSignature(rawBody, signature, options.secret))) {
|
|
34
|
+
return json(401, { error: "Invalid signature" });
|
|
35
|
+
}
|
|
36
|
+
let payload;
|
|
37
|
+
try {
|
|
38
|
+
payload = JSON.parse(rawBody);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return json(400, { error: "Body is not JSON" });
|
|
42
|
+
}
|
|
43
|
+
// Sanity retries on 5xx and gives up on 4xx. A failing mapping is the
|
|
44
|
+
// app's problem, not the payload's, so it should be retried.
|
|
45
|
+
try {
|
|
46
|
+
const change = await options.onChange(payload);
|
|
47
|
+
const invalidated = await applySanityWebhookChange(change, invalidation);
|
|
48
|
+
return json(200, { targets: invalidated });
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return json(500, { error: "Failed to apply change" });
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@farm.js/sanity",
|
|
3
|
+
"version": "0.1.0-beta.104",
|
|
4
|
+
"description": "Sanity CMS integration for Farm.js",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/farming-labs/farm.js",
|
|
9
|
+
"directory": "packages/farm-sanity"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist"
|
|
13
|
+
],
|
|
14
|
+
"type": "module",
|
|
15
|
+
"main": "./dist/index.js",
|
|
16
|
+
"module": "./dist/index.js",
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"import": "./dist/index.js"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@sanity/image-url": "^2.1.1",
|
|
29
|
+
"@sanity/webhook": "^4.0.4",
|
|
30
|
+
"@farm.js/core": "0.1.0-beta.104",
|
|
31
|
+
"@farm.js/integration-utils": "0.1.0-beta.104"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@sanity/client": "^8.4.0",
|
|
35
|
+
"typescript": "^5.3.3",
|
|
36
|
+
"vitest": "^3.2.7",
|
|
37
|
+
"@farm.js/content": "0.1.0-beta.0"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"@sanity/client": "^7.0.0 || ^8.0.0",
|
|
41
|
+
"@farm.js/content": "^0.1.0-beta.0"
|
|
42
|
+
},
|
|
43
|
+
"peerDependenciesMeta": {
|
|
44
|
+
"@farm.js/content": {
|
|
45
|
+
"optional": true
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc",
|
|
50
|
+
"dev": "tsc --watch",
|
|
51
|
+
"type-check": "tsc --noEmit -p tsconfig.test.json",
|
|
52
|
+
"test": "vitest run"
|
|
53
|
+
}
|
|
54
|
+
}
|