@usegraft/sdk-react 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Anderson Joseph
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,108 @@
1
+ # @usegraft/sdk-react
2
+
3
+ > The browser client: typed content reads and hooks over Graft's read-only content API.
4
+
5
+ Part of [Graft](https://github.com/AndersonDesign1/graft), a CMS built so an AI agent is the primary operator.
6
+
7
+ Every other Graft SDK reads on a server. This one reads in the browser, for the places a loader cannot help: a search box, a client-side editor preview, a widget on a page the server rendered an hour ago.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm i @usegraft/sdk-react
13
+ ```
14
+
15
+ `react` is a peer dependency, 18 or 19.
16
+
17
+ ## Types do not cross the wire
18
+
19
+ Your app imports its own `collections` from `graft.config.ts` at compile time, exactly as a server adapter does. The endpoint supplies data at runtime. Nothing is generated, nothing is fetched to be typed, and `getContent("docs", slug)` returns the document type your schema declares.
20
+
21
+ ```ts
22
+ // src/graft.ts
23
+ import { createGraft, createGraftHooks } from "@usegraft/sdk-react";
24
+ import { collections } from "../graft.config";
25
+
26
+ export const graft = createGraft({
27
+ endpoint: "https://cms.example.com/api/content/v1",
28
+ collections,
29
+ });
30
+
31
+ export const { GraftProvider, useGraft, useContent, useContentList, useContentSearch } =
32
+ createGraftHooks(graft);
33
+ ```
34
+
35
+ The hooks come out of a factory rather than being importable directly, and that is what keeps the reads typed. A hook reading an untyped context could only promise `Document<AnyCollection>` — `data.title` would be `unknown` and a misspelled collection name would be a runtime surprise instead of a compile error. Binding the factory to your collections once buys typed reads everywhere.
36
+
37
+ ## Serve the endpoint
38
+
39
+ `endpoint` points at a mount of Graft's read-only content API — what `graft serve` exposes at `/api/content/v1`, or your own mount of `createContentApiHandler` from [`@usegraft/content-api`](https://www.npmjs.com/package/@usegraft/content-api). It answers document reads and search, nothing else: no functions, no writes, no schema.
40
+
41
+ An endpoint is pinned to one branch on the server side, which is why there is no `branch` option to pass with `endpoint`. Point at the preview deployment's endpoint to read a preview branch.
42
+
43
+ ## Read
44
+
45
+ ```tsx
46
+ import { useContent, useContentSearch } from "../graft";
47
+
48
+ function Doc({ slug }: { slug: string }) {
49
+ const { data, error, loading } = useContent("docs", slug);
50
+
51
+ if (loading) return <Spinner />;
52
+ if (error) return <Problem error={error} />;
53
+ if (!data) return <NotFound />;
54
+ return <article>{data.data.title}</article>;
55
+ }
56
+ ```
57
+
58
+ Each hook reports `{ data, error, loading, refresh }`. `data` answers the current arguments or it is `undefined`: changing the slug clears it rather than showing the previous document while the next one loads.
59
+
60
+ `useContentList(collection, options?)` and `useContentSearch(collection, query, options?)` are the same shape over `listContent` and `searchContent`.
61
+
62
+ ## What this is not
63
+
64
+ It is not a cache. There is no deduplication, no retry, no background refresh, no stale-while-revalidate — a read starts when its arguments change and reports one answer. Your app almost certainly has TanStack Query or SWR already, both of which do that properly, and `graft.getContent` is a plain async function that composes with either:
65
+
66
+ ```ts
67
+ useQuery({
68
+ queryKey: ["docs", slug],
69
+ queryFn: () => graft.getContent("docs", slug),
70
+ });
71
+ ```
72
+
73
+ Shipping a worse copy of a query client inside an SDK is the wrong trade, so the hooks stay a binding and the caching stays yours.
74
+
75
+ It is also not a server renderer. `useEffect` does not run during server rendering, so on the server these hooks report their loading state and nothing else. Content that has to be in the HTML belongs in a loader or a server adapter: [`@usegraft/sdk-next`](https://www.npmjs.com/package/@usegraft/sdk-next), [`sdk-astro`](https://www.npmjs.com/package/@usegraft/sdk-astro), [`sdk-sveltekit`](https://www.npmjs.com/package/@usegraft/sdk-sveltekit), [`sdk-react-router`](https://www.npmjs.com/package/@usegraft/sdk-react-router), [`sdk-tanstack-start`](https://www.npmjs.com/package/@usegraft/sdk-tanstack-start).
76
+
77
+ ## Point a subtree somewhere else
78
+
79
+ `GraftProvider` is optional — the hooks fall back to the handle the factory was built with. Mount one to override it for a subtree: a preview branch's endpoint, or a fake in tests.
80
+
81
+ ```tsx
82
+ <GraftProvider graft={previewGraft}>
83
+ <Preview />
84
+ </GraftProvider>
85
+ ```
86
+
87
+ ## Without hooks
88
+
89
+ `createGraft` is useful on its own. It returns `getContent` / `listContent` / `searchContent` and the underlying `client`, all typed the same way, for event handlers, a query client, or anywhere React is not involved.
90
+
91
+ ## Bring your own transport
92
+
93
+ Pass `index` instead of `endpoint` to read through any `ContentIndexReader` — a configured `createContentApiReader`, a service-worker-backed reader, or a fake in a test.
94
+
95
+ ```ts
96
+ import { createContentApiReader } from "@usegraft/sdk-react";
97
+
98
+ export const graft = createGraft({
99
+ index: createContentApiReader({ endpoint, headers: { Authorization: `Bearer ${token}` } }),
100
+ collections,
101
+ });
102
+ ```
103
+
104
+ There is no `db` option, and no `graftRoute`. A database handle in a browser bundle is a database URL in a browser bundle, and mounting a request handler is a server's job.
105
+
106
+ ---
107
+
108
+ MIT. [Repository](https://github.com/AndersonDesign1/graft) · [Changelog](https://github.com/AndersonDesign1/graft/blob/main/packages/sdk-react/CHANGELOG.md)
@@ -0,0 +1,101 @@
1
+ import { AnyCollection, ReadOptions, Document, ListOptions, SearchOptions, SearchHit, GraftClient, ClientOptions } from '@usegraft/sdk-core';
2
+ export { AnyCollection, ChangeSet, ClientOptions, Document, GraftClient, ListOptions, ReadOptions, SearchHit, SearchOptions, TAG_NAMESPACE, collectionTag, createClient, documentTag, tagsFor, tagsForChanges, toDocument } from '@usegraft/sdk-core';
3
+ import { ReactNode, ReactElement } from 'react';
4
+ export { ContentApiReaderOptions, createContentApiReader } from '@usegraft/content-api';
5
+
6
+ interface GraftOptions<TCollections extends Record<string, AnyCollection>> extends Omit<ClientOptions<TCollections>, "db"> {
7
+ /** Content API base URL, e.g. `https://cms.example.com/api/content/v1`. */
8
+ endpoint?: string | URL;
9
+ /** Static headers sent with every read, such as `Authorization`. */
10
+ headers?: Record<string, string>;
11
+ /** Fetch implementation, for tests, instrumentation, or a non-browser runtime. */
12
+ fetch?: typeof globalThis.fetch;
13
+ }
14
+ interface Graft<TCollections extends Record<string, AnyCollection>> {
15
+ /** Typed getDocument. */
16
+ getContent<K extends keyof TCollections & string>(collection: K, slug: string, options?: ReadOptions): Promise<Document<TCollections[K]> | null>;
17
+ /** Typed listDocuments. */
18
+ listContent<K extends keyof TCollections & string>(collection: K, options?: ListOptions): Promise<Document<TCollections[K]>[]>;
19
+ /** Typed searchDocuments (full-text, best-ranked first). */
20
+ searchContent<K extends keyof TCollections & string>(collection: K, query: string, options?: SearchOptions): Promise<SearchHit<TCollections[K]>[]>;
21
+ /** The underlying sdk-core client, for anything the helpers don't cover. */
22
+ client: GraftClient<TCollections>;
23
+ }
24
+ /**
25
+ * Build the read handle. Give it your `collections` and the endpoint your
26
+ * content API is mounted at; reads are typed from the schema, not from the
27
+ * response.
28
+ */
29
+ declare function createGraft<TCollections extends Record<string, AnyCollection>>(options: GraftOptions<TCollections>): Graft<TCollections>;
30
+
31
+ /**
32
+ * React ergonomics over the read handle: a provider and three hooks that run
33
+ * the async reads and report their state.
34
+ *
35
+ * Deliberately not a cache. Every re-read starts from nothing and reports
36
+ * exactly one answer for the current arguments, because an SDK that invented
37
+ * its own stale-while-revalidate would be a worse copy of the query client the
38
+ * app already has. Wrap `graft.getContent` in TanStack Query or SWR when you
39
+ * want caching, retries, deduplication or background refresh — the handle is a
40
+ * plain async function and composes with either of them.
41
+ *
42
+ * Browser-side. `useEffect` does not run during server rendering, so on the
43
+ * server these hooks render their loading state and nothing else. Data that
44
+ * has to be in the HTML belongs in a loader or a server adapter
45
+ * (@usegraft/sdk-react-router, @usegraft/sdk-tanstack-start, and the rest).
46
+ *
47
+ * The hooks come out of a factory rather than being importable directly. That
48
+ * is what keeps the no-codegen contract: a hook reading an untyped context
49
+ * could only return `Document<AnyCollection>`, so `data.title` would be
50
+ * `unknown` and an unknown collection name would be a runtime surprise instead
51
+ * of a compile error. Binding the factory to your `collections` once is what
52
+ * buys typed reads everywhere the hooks are used.
53
+ */
54
+
55
+ /** What a hook reports about the read it is running. */
56
+ interface AsyncState<TData> {
57
+ /** The answer for the current arguments, or undefined until one arrives. */
58
+ data: TData | undefined;
59
+ /** Why the read failed, usually a GraftError. */
60
+ error: Error | undefined;
61
+ /** True while a read is in flight, the first one included. */
62
+ loading: boolean;
63
+ /** Run the read again. */
64
+ refresh: () => void;
65
+ }
66
+ interface GraftHooks<TCollections extends Record<string, AnyCollection>> {
67
+ /**
68
+ * Supplies the handle the hooks read. Optional — they fall back to the one
69
+ * the factory was built with. Use it to point a subtree somewhere else: a
70
+ * preview branch's endpoint, or a fake in tests.
71
+ */
72
+ GraftProvider: (props: {
73
+ graft: Graft<TCollections>;
74
+ children: ReactNode;
75
+ }) => ReactElement;
76
+ /** The handle in scope, for reads the hooks don't cover. */
77
+ useGraft: () => Graft<TCollections>;
78
+ /** One document, or null when the collection has no such slug. */
79
+ useContent: <K extends keyof TCollections & string>(collection: K, slug: string, options?: ReadOptions) => AsyncState<Document<TCollections[K]> | null>;
80
+ /** A collection, in index order. */
81
+ useContentList: <K extends keyof TCollections & string>(collection: K, options?: ListOptions) => AsyncState<Document<TCollections[K]>[]>;
82
+ /** Full-text search within one collection, best-ranked first. */
83
+ useContentSearch: <K extends keyof TCollections & string>(collection: K, query: string, options?: SearchOptions) => AsyncState<SearchHit<TCollections[K]>[]>;
84
+ }
85
+ /**
86
+ * Bind the provider and hooks to one handle, and to the collections that
87
+ * handle was built with.
88
+ *
89
+ * ```ts
90
+ * // src/graft.ts
91
+ * import { createGraft, createGraftHooks } from "@usegraft/sdk-react";
92
+ * import { collections } from "../graft.config";
93
+ *
94
+ * export const graft = createGraft({ endpoint: "/api/content/v1", collections });
95
+ * export const { GraftProvider, useContent, useContentList, useContentSearch } =
96
+ * createGraftHooks(graft);
97
+ * ```
98
+ */
99
+ declare function createGraftHooks<TCollections extends Record<string, AnyCollection>>(graft: Graft<TCollections>): GraftHooks<TCollections>;
100
+
101
+ export { type AsyncState, type Graft, type GraftHooks, type GraftOptions, createGraft, createGraftHooks };
package/dist/index.js ADDED
@@ -0,0 +1,147 @@
1
+ // src/graft.ts
2
+ import { createContentApiReader } from "@usegraft/content-api";
3
+ import { GraftError } from "@usegraft/contracts";
4
+ import {
5
+ createClient
6
+ } from "@usegraft/sdk-core";
7
+ function readerFor(options) {
8
+ if (options.endpoint === void 0) {
9
+ if (options.index === void 0) {
10
+ throw new GraftError({
11
+ code: "CONFIG_INVALID",
12
+ message: "createGraft needs somewhere to read from: pass `endpoint` or `index`.",
13
+ fix: "Pass `endpoint` (the content API a `graft serve` mounts, e.g. https://cms.example.com/api/content/v1) or `index` (your own ContentIndexReader). There is no `db` option in the browser."
14
+ });
15
+ }
16
+ return options.index;
17
+ }
18
+ if (options.branch !== void 0) {
19
+ throw new GraftError({
20
+ code: "CONFIG_INVALID",
21
+ message: "`branch` cannot be combined with `endpoint`.",
22
+ fix: "Each content API endpoint serves exactly one branch, fixed by the server. Point `endpoint` at the branch's own deployment instead of asking for one here.",
23
+ details: { branch: options.branch, endpoint: options.endpoint.toString() }
24
+ });
25
+ }
26
+ return createContentApiReader({
27
+ endpoint: options.endpoint,
28
+ headers: options.headers,
29
+ fetch: options.fetch
30
+ });
31
+ }
32
+ function createGraft(options) {
33
+ const client = createClient({
34
+ index: readerFor(options),
35
+ collections: options.collections,
36
+ branch: options.branch
37
+ });
38
+ return {
39
+ client,
40
+ getContent: (collection, slug, opts) => client.getDocument(collection, slug, opts),
41
+ listContent: (collection, opts) => client.listDocuments(collection, opts),
42
+ searchContent: (collection, query, opts) => client.searchDocuments(collection, query, opts)
43
+ };
44
+ }
45
+
46
+ // src/hooks.tsx
47
+ import {
48
+ createContext,
49
+ useCallback,
50
+ useContext,
51
+ useEffect,
52
+ useState
53
+ } from "react";
54
+ import { jsx } from "react/jsx-runtime";
55
+ var PENDING = { data: void 0, error: void 0, loading: true };
56
+ function asError(cause) {
57
+ return cause instanceof Error ? cause : new Error(String(cause));
58
+ }
59
+ function useRead(read) {
60
+ const [attempt, setAttempt] = useState(0);
61
+ const [state, setState] = useState(PENDING);
62
+ useEffect(() => {
63
+ let live = true;
64
+ setState(PENDING);
65
+ read().then(
66
+ (data) => {
67
+ if (live) setState({ data, error: void 0, loading: false });
68
+ },
69
+ (cause) => {
70
+ if (live) setState({ data: void 0, error: asError(cause), loading: false });
71
+ }
72
+ );
73
+ return () => {
74
+ live = false;
75
+ };
76
+ }, [read, attempt]);
77
+ const refresh = useCallback(() => setAttempt((previous) => previous + 1), []);
78
+ return { ...state, refresh };
79
+ }
80
+ function createGraftHooks(graft) {
81
+ const GraftContext = createContext(graft);
82
+ function GraftProvider({
83
+ graft: value,
84
+ children
85
+ }) {
86
+ return /* @__PURE__ */ jsx(GraftContext.Provider, { value, children });
87
+ }
88
+ const useGraft = () => useContext(GraftContext);
89
+ return {
90
+ GraftProvider,
91
+ useGraft,
92
+ useContent(collection, slug, options) {
93
+ const handle = useGraft();
94
+ const branch = options?.branch;
95
+ const read = useCallback(
96
+ () => handle.getContent(collection, slug, { branch }),
97
+ [handle, collection, slug, branch]
98
+ );
99
+ return useRead(read);
100
+ },
101
+ useContentList(collection, options) {
102
+ const handle = useGraft();
103
+ const branch = options?.branch;
104
+ const limit = options?.limit;
105
+ const offset = options?.offset;
106
+ const read = useCallback(
107
+ () => handle.listContent(collection, { branch, limit, offset }),
108
+ [handle, collection, branch, limit, offset]
109
+ );
110
+ return useRead(read);
111
+ },
112
+ useContentSearch(collection, query, options) {
113
+ const handle = useGraft();
114
+ const branch = options?.branch;
115
+ const limit = options?.limit;
116
+ const read = useCallback(
117
+ () => handle.searchContent(collection, query, { branch, limit }),
118
+ [handle, collection, query, branch, limit]
119
+ );
120
+ return useRead(read);
121
+ }
122
+ };
123
+ }
124
+
125
+ // src/index.ts
126
+ import {
127
+ createClient as createClient2,
128
+ toDocument,
129
+ collectionTag,
130
+ documentTag,
131
+ tagsFor,
132
+ tagsForChanges,
133
+ TAG_NAMESPACE
134
+ } from "@usegraft/sdk-core";
135
+ import { createContentApiReader as createContentApiReader2 } from "@usegraft/content-api";
136
+ export {
137
+ TAG_NAMESPACE,
138
+ collectionTag,
139
+ createClient2 as createClient,
140
+ createContentApiReader2 as createContentApiReader,
141
+ createGraft,
142
+ createGraftHooks,
143
+ documentTag,
144
+ tagsFor,
145
+ tagsForChanges,
146
+ toDocument
147
+ };
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@usegraft/sdk-react",
3
+ "version": "0.2.0",
4
+ "description": "React browser client: typed content reads and hooks over Graft's read-only content API.",
5
+ "keywords": [
6
+ "agent",
7
+ "ai",
8
+ "cms",
9
+ "content-api",
10
+ "graft",
11
+ "headless-cms",
12
+ "hooks",
13
+ "react",
14
+ "spa",
15
+ "typescript"
16
+ ],
17
+ "homepage": "https://github.com/AndersonDesign1/graft#readme",
18
+ "license": "MIT",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/AndersonDesign1/graft.git",
22
+ "directory": "packages/sdk-react"
23
+ },
24
+ "files": [
25
+ "dist"
26
+ ],
27
+ "type": "module",
28
+ "main": "./dist/index.js",
29
+ "module": "./dist/index.js",
30
+ "types": "./dist/index.d.ts",
31
+ "exports": {
32
+ ".": {
33
+ "types": "./dist/index.d.ts",
34
+ "import": "./dist/index.js"
35
+ }
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "dependencies": {
41
+ "@usegraft/content-api": "0.2.0",
42
+ "@usegraft/contracts": "0.2.0",
43
+ "@usegraft/sdk-core": "0.2.0"
44
+ },
45
+ "devDependencies": {
46
+ "@testing-library/react": "^16.3.0",
47
+ "@types/react": "^19.0.0",
48
+ "@types/react-dom": "^19.0.0",
49
+ "@usegraft/core": "0.2.0",
50
+ "jsdom": "^25.0.1",
51
+ "react": "^19.0.0",
52
+ "react-dom": "^19.0.0"
53
+ },
54
+ "peerDependencies": {
55
+ "react": ">=18.0.0"
56
+ },
57
+ "engines": {
58
+ "node": ">=22.16"
59
+ },
60
+ "scripts": {
61
+ "build": "tsup src/index.ts --format esm --dts --clean --external react --external react/jsx-runtime",
62
+ "dev": "tsup src/index.ts --format esm --watch --external react --external react/jsx-runtime",
63
+ "typecheck": "tsc --noEmit",
64
+ "test": "vitest run --passWithNoTests"
65
+ }
66
+ }