@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 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
+ ```
@@ -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
+ }
@@ -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"}
@@ -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
+ }
@@ -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
+ }
@@ -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"}
@@ -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
+ }