@portabletext/plugin-sdk-value 1.0.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) 2016 - 2025 Sanity.io
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,58 @@
1
+ # SDK Value Plugin
2
+
3
+ > 🔗 Connects a Portable Text Editor with a Sanity document using the SDK
4
+
5
+ The SDK Value plugin provides seamless two-way synchronization between a Portable Text Editor instance and a specific field in a Sanity document. This enables real-time collaboration and ensures that changes made through the editor are immediately reflected in the document, and vice versa.
6
+
7
+ ## Features
8
+
9
+ - **Two-way synchronization**: Changes in the editor update the document, and document changes update the editor
10
+ - **Real-time updates**: Automatically handles patches from external sources (other users, mutations, etc.)
11
+ - **Optimistic updates**: Provides smooth user experience with immediate local updates
12
+
13
+ ## Usage
14
+
15
+ Import the `SDKValuePlugin` React component and place it inside the `EditorProvider`. The plugin requires document handle properties and a path to specify which field to synchronize:
16
+
17
+ ```tsx
18
+ import {
19
+ defineSchema,
20
+ EditorProvider,
21
+ PortableTextEditable,
22
+ } from '@portabletext/editor'
23
+ import {SDKValuePlugin} from '@portabletext/plugin-sdk-value'
24
+
25
+ function MyEditor() {
26
+ return (
27
+ <EditorProvider initialConfig={{schemaDefinition: defineSchema({})}}>
28
+ <PortableTextEditable />
29
+ <SDKValuePlugin
30
+ documentId="my-document-id"
31
+ documentType="myDocumentType"
32
+ path="content"
33
+ />
34
+ </EditorProvider>
35
+ )
36
+ }
37
+ ```
38
+
39
+ ## Props
40
+
41
+ The `SDKValuePlugin` component accepts a [Document Handle](https://www.sanity.io/docs/app-sdk/document-handles) plus an additional `path` parameter:
42
+
43
+ | Prop | Type | Description |
44
+ | -------------- | ------------------- | ------------------------------------------------------------------ |
45
+ | `documentId` | `string` | The document ID |
46
+ | `documentType` | `string` | The document type |
47
+ | `path` | `string` | [JSONMatch][json-match] path expression to the Portable Text field |
48
+ | `dataset` | `string` (optional) | Dataset name (if different from configured default) |
49
+ | `projectId` | `string` (optional) | Project ID (if different from configured default) |
50
+
51
+ [json-match]: https://www.sanity.io/docs/content-lake/json-match
52
+
53
+ ## Requirements
54
+
55
+ This plugin requires:
56
+
57
+ - `@sanity/sdk-react` for document state management
58
+ - The document must exist in the Sanity dataset
package/dist/index.cjs ADDED
@@ -0,0 +1,97 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: !0 });
3
+ var compilerRuntime = require("react/compiler-runtime"), editor = require("@portabletext/editor"), diffPatch = require("@sanity/diff-patch"), jsonMatch = require("@sanity/json-match"), sdkReact = require("@sanity/sdk-react"), react = require("react");
4
+ const ARRAYIFY_ERROR_MESSAGE = "Unexpected path format from diffValue output. Please report this issue.";
5
+ function* getSegments(node) {
6
+ node.base && (yield* getSegments(node.base)), node.segment.type !== "This" && (yield node.segment);
7
+ }
8
+ function isKeyPath(node) {
9
+ return node.type !== "Path" || node.base || node.recursive || node.segment.type !== "Identifier" ? !1 : node.segment.name === "_key";
10
+ }
11
+ function arrayifyPath(pathExpr) {
12
+ const node = jsonMatch.parsePath(pathExpr);
13
+ if (!node) return [];
14
+ if (node.type !== "Path") throw new Error(ARRAYIFY_ERROR_MESSAGE);
15
+ return Array.from(getSegments(node)).map((segment) => {
16
+ if (segment.type === "Identifier") return segment.name;
17
+ if (segment.type !== "Subscript") throw new Error(ARRAYIFY_ERROR_MESSAGE);
18
+ if (segment.elements.length !== 1) throw new Error(ARRAYIFY_ERROR_MESSAGE);
19
+ const [element] = segment.elements;
20
+ if (element.type === "Number") return element.value;
21
+ if (element.type !== "Comparison") throw new Error(ARRAYIFY_ERROR_MESSAGE);
22
+ if (element.operator !== "==") throw new Error(ARRAYIFY_ERROR_MESSAGE);
23
+ const keyPathNode = [element.left, element.right].find(isKeyPath);
24
+ if (!keyPathNode) throw new Error(ARRAYIFY_ERROR_MESSAGE);
25
+ const other = element.left === keyPathNode ? element.right : element.left;
26
+ if (other.type !== "String") throw new Error(ARRAYIFY_ERROR_MESSAGE);
27
+ return {
28
+ _key: other.value
29
+ };
30
+ });
31
+ }
32
+ function convertPatches(patches) {
33
+ return patches.flatMap((p) => Object.entries(p).flatMap(([type, values]) => {
34
+ const origin = "remote";
35
+ switch (type) {
36
+ case "set":
37
+ case "setIfMissing":
38
+ case "diffMatchPatch":
39
+ case "inc":
40
+ case "dec":
41
+ return Object.entries(values).map(([pathExpr, value]) => ({
42
+ type,
43
+ value,
44
+ origin,
45
+ path: arrayifyPath(pathExpr)
46
+ }));
47
+ case "unset":
48
+ return Array.isArray(values) ? values.map(arrayifyPath).map((path) => ({
49
+ type,
50
+ origin,
51
+ path
52
+ })) : [];
53
+ case "insert": {
54
+ const {
55
+ items,
56
+ ...rest
57
+ } = values, position = Object.keys(rest).at(0);
58
+ if (!position) return [];
59
+ const pathExpr = rest[position];
60
+ return [{
61
+ type,
62
+ origin,
63
+ position,
64
+ path: arrayifyPath(pathExpr),
65
+ items
66
+ }];
67
+ }
68
+ default:
69
+ return [];
70
+ }
71
+ }));
72
+ }
73
+ function SDKValuePlugin(props) {
74
+ const $ = compilerRuntime.c(6), setSdkValue = sdkReact.useEditDocument(props), instance = sdkReact.useSanityInstance(props), editor$1 = editor.useEditor();
75
+ let t0, t1;
76
+ return $[0] !== editor$1 || $[1] !== instance || $[2] !== props || $[3] !== setSdkValue ? (t0 = () => {
77
+ const getEditorValue = () => editor$1.getSnapshot().context.value, {
78
+ getCurrent: getSdkValue,
79
+ subscribe: onSdkValueChange
80
+ } = sdkReact.getDocumentState(instance, props), editorSubscription = editor$1.on("patch", () => setSdkValue(getEditorValue())), unsubscribeToEditorChanges = () => editorSubscription.unsubscribe(), unsubscribeToSdkChanges = onSdkValueChange(() => {
81
+ const snapshot = getEditorValue(), patches = convertPatches(diffPatch.diffValue(snapshot, getSdkValue()));
82
+ patches.length && editor$1.send({
83
+ type: "patches",
84
+ patches,
85
+ snapshot
86
+ });
87
+ });
88
+ return editor$1.send({
89
+ type: "update value",
90
+ value: getSdkValue() ?? []
91
+ }), () => {
92
+ unsubscribeToEditorChanges(), unsubscribeToSdkChanges();
93
+ };
94
+ }, t1 = [setSdkValue, editor$1, instance, props], $[0] = editor$1, $[1] = instance, $[2] = props, $[3] = setSdkValue, $[4] = t0, $[5] = t1) : (t0 = $[4], t1 = $[5]), react.useEffect(t0, t1), null;
95
+ }
96
+ exports.SDKValuePlugin = SDKValuePlugin;
97
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.cjs","sources":["../src/plugin.sdk-value.tsx"],"sourcesContent":["import {\n useEditor,\n type PortableTextBlock,\n type Patch as PtePatch,\n} from '@portabletext/editor'\nimport type {\n JSONValue,\n Path,\n PathSegment,\n InsertPatch as PteInsertPatch,\n} from '@portabletext/patches'\nimport {diffValue, type SanityPatchOperations} from '@sanity/diff-patch'\nimport {\n parsePath,\n type ExprNode,\n type PathNode,\n type SegmentNode,\n type ThisNode,\n} from '@sanity/json-match'\nimport {\n getDocumentState,\n useEditDocument,\n useSanityInstance,\n type DocumentHandle,\n} from '@sanity/sdk-react'\nimport {useEffect} from 'react'\n\ninterface SDKValuePluginProps extends DocumentHandle {\n path: string\n}\n\ntype InsertPatch = Required<Pick<SanityPatchOperations, 'insert'>>\n\nconst ARRAYIFY_ERROR_MESSAGE =\n 'Unexpected path format from diffValue output. Please report this issue.'\n\nfunction* getSegments(\n node: PathNode,\n): Generator<Exclude<SegmentNode, ThisNode>> {\n if (node.base) yield* getSegments(node.base)\n if (node.segment.type !== 'This') yield node.segment\n}\n\nfunction isKeyPath(node: ExprNode): node is PathNode {\n if (node.type !== 'Path') return false\n if (node.base) return false\n if (node.recursive) return false\n if (node.segment.type !== 'Identifier') return false\n return node.segment.name === '_key'\n}\n\nfunction arrayifyPath(pathExpr: string): Path {\n const node = parsePath(pathExpr)\n if (!node) return []\n if (node.type !== 'Path') throw new Error(ARRAYIFY_ERROR_MESSAGE)\n\n return Array.from(getSegments(node)).map((segment): PathSegment => {\n if (segment.type === 'Identifier') return segment.name\n if (segment.type !== 'Subscript') throw new Error(ARRAYIFY_ERROR_MESSAGE)\n if (segment.elements.length !== 1) throw new Error(ARRAYIFY_ERROR_MESSAGE)\n\n const [element] = segment.elements\n if (element.type === 'Number') return element.value\n\n if (element.type !== 'Comparison') throw new Error(ARRAYIFY_ERROR_MESSAGE)\n if (element.operator !== '==') throw new Error(ARRAYIFY_ERROR_MESSAGE)\n const keyPathNode = [element.left, element.right].find(isKeyPath)\n if (!keyPathNode) throw new Error(ARRAYIFY_ERROR_MESSAGE)\n const other = element.left === keyPathNode ? element.right : element.left\n if (other.type !== 'String') throw new Error(ARRAYIFY_ERROR_MESSAGE)\n return {_key: other.value}\n })\n}\n\nfunction convertPatches(patches: SanityPatchOperations[]): PtePatch[] {\n return patches.flatMap((p) => {\n return Object.entries(p).flatMap(([type, values]): PtePatch[] => {\n const origin = 'remote'\n\n switch (type) {\n case 'set':\n case 'setIfMissing':\n case 'diffMatchPatch':\n case 'inc':\n case 'dec': {\n return Object.entries(values).map(\n ([pathExpr, value]) =>\n ({type, value, origin, path: arrayifyPath(pathExpr)}) as PtePatch,\n )\n }\n case 'unset': {\n if (!Array.isArray(values)) return []\n return values.map(arrayifyPath).map((path) => ({type, origin, path}))\n }\n case 'insert': {\n const {items, ...rest} = values as InsertPatch['insert']\n type InsertPosition = PteInsertPatch['position']\n const position = Object.keys(rest).at(0) as InsertPosition | undefined\n\n if (!position) return []\n const pathExpr = (rest as {[K in InsertPosition]: string})[position]\n const insertPatch: PteInsertPatch = {\n type,\n origin,\n position,\n path: arrayifyPath(pathExpr),\n items: items as JSONValue[],\n }\n\n return [insertPatch]\n }\n\n default: {\n return []\n }\n }\n })\n })\n}\n/**\n * @public\n */\nexport function SDKValuePlugin(props: SDKValuePluginProps) {\n // NOTE: the real `useEditDocument` suspends until the document is loaded into the SDK store\n const setSdkValue = useEditDocument(props)\n const instance = useSanityInstance(props)\n const editor = useEditor()\n\n useEffect(() => {\n const getEditorValue = () => editor.getSnapshot().context.value\n const {getCurrent: getSdkValue, subscribe: onSdkValueChange} =\n getDocumentState<PortableTextBlock[]>(instance, props)\n\n const editorSubscription = editor.on('patch', () =>\n setSdkValue(getEditorValue()),\n )\n const unsubscribeToEditorChanges = () => editorSubscription.unsubscribe()\n const unsubscribeToSdkChanges = onSdkValueChange(() => {\n const snapshot = getEditorValue()\n const patches = convertPatches(diffValue(snapshot, getSdkValue()))\n\n if (patches.length) {\n editor.send({type: 'patches', patches, snapshot})\n }\n })\n\n // update initial value\n editor.send({type: 'update value', value: getSdkValue() ?? []})\n\n return () => {\n unsubscribeToEditorChanges()\n unsubscribeToSdkChanges()\n }\n }, [setSdkValue, editor, instance, props])\n\n return null\n}\n"],"names":["ARRAYIFY_ERROR_MESSAGE","getSegments","node","base","segment","type","isKeyPath","recursive","name","arrayifyPath","pathExpr","parsePath","Error","Array","from","map","elements","length","element","value","operator","keyPathNode","left","right","find","other","_key","convertPatches","patches","flatMap","p","Object","entries","values","origin","path","isArray","items","rest","position","keys","at","SDKValuePlugin","props","$","_c","setSdkValue","useEditDocument","instance","useSanityInstance","editor","useEditor","t0","t1","getEditorValue","getSnapshot","context","getCurrent","getSdkValue","subscribe","onSdkValueChange","getDocumentState","editorSubscription","on","unsubscribeToEditorChanges","unsubscribe","unsubscribeToSdkChanges","snapshot","diffValue","send","useEffect"],"mappings":"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"}
@@ -0,0 +1,12 @@
1
+ import {DocumentHandle} from '@sanity/sdk-react'
2
+
3
+ /**
4
+ * @public
5
+ */
6
+ export declare function SDKValuePlugin(props: SDKValuePluginProps): null
7
+
8
+ declare interface SDKValuePluginProps extends DocumentHandle {
9
+ path: string
10
+ }
11
+
12
+ export {}
@@ -0,0 +1,12 @@
1
+ import {DocumentHandle} from '@sanity/sdk-react'
2
+
3
+ /**
4
+ * @public
5
+ */
6
+ export declare function SDKValuePlugin(props: SDKValuePluginProps): null
7
+
8
+ declare interface SDKValuePluginProps extends DocumentHandle {
9
+ path: string
10
+ }
11
+
12
+ export {}
package/dist/index.js ADDED
@@ -0,0 +1,102 @@
1
+ import { c } from "react/compiler-runtime";
2
+ import { useEditor } from "@portabletext/editor";
3
+ import { diffValue } from "@sanity/diff-patch";
4
+ import { parsePath } from "@sanity/json-match";
5
+ import { useEditDocument, useSanityInstance, getDocumentState } from "@sanity/sdk-react";
6
+ import { useEffect } from "react";
7
+ const ARRAYIFY_ERROR_MESSAGE = "Unexpected path format from diffValue output. Please report this issue.";
8
+ function* getSegments(node) {
9
+ node.base && (yield* getSegments(node.base)), node.segment.type !== "This" && (yield node.segment);
10
+ }
11
+ function isKeyPath(node) {
12
+ return node.type !== "Path" || node.base || node.recursive || node.segment.type !== "Identifier" ? !1 : node.segment.name === "_key";
13
+ }
14
+ function arrayifyPath(pathExpr) {
15
+ const node = parsePath(pathExpr);
16
+ if (!node) return [];
17
+ if (node.type !== "Path") throw new Error(ARRAYIFY_ERROR_MESSAGE);
18
+ return Array.from(getSegments(node)).map((segment) => {
19
+ if (segment.type === "Identifier") return segment.name;
20
+ if (segment.type !== "Subscript") throw new Error(ARRAYIFY_ERROR_MESSAGE);
21
+ if (segment.elements.length !== 1) throw new Error(ARRAYIFY_ERROR_MESSAGE);
22
+ const [element] = segment.elements;
23
+ if (element.type === "Number") return element.value;
24
+ if (element.type !== "Comparison") throw new Error(ARRAYIFY_ERROR_MESSAGE);
25
+ if (element.operator !== "==") throw new Error(ARRAYIFY_ERROR_MESSAGE);
26
+ const keyPathNode = [element.left, element.right].find(isKeyPath);
27
+ if (!keyPathNode) throw new Error(ARRAYIFY_ERROR_MESSAGE);
28
+ const other = element.left === keyPathNode ? element.right : element.left;
29
+ if (other.type !== "String") throw new Error(ARRAYIFY_ERROR_MESSAGE);
30
+ return {
31
+ _key: other.value
32
+ };
33
+ });
34
+ }
35
+ function convertPatches(patches) {
36
+ return patches.flatMap((p) => Object.entries(p).flatMap(([type, values]) => {
37
+ const origin = "remote";
38
+ switch (type) {
39
+ case "set":
40
+ case "setIfMissing":
41
+ case "diffMatchPatch":
42
+ case "inc":
43
+ case "dec":
44
+ return Object.entries(values).map(([pathExpr, value]) => ({
45
+ type,
46
+ value,
47
+ origin,
48
+ path: arrayifyPath(pathExpr)
49
+ }));
50
+ case "unset":
51
+ return Array.isArray(values) ? values.map(arrayifyPath).map((path) => ({
52
+ type,
53
+ origin,
54
+ path
55
+ })) : [];
56
+ case "insert": {
57
+ const {
58
+ items,
59
+ ...rest
60
+ } = values, position = Object.keys(rest).at(0);
61
+ if (!position) return [];
62
+ const pathExpr = rest[position];
63
+ return [{
64
+ type,
65
+ origin,
66
+ position,
67
+ path: arrayifyPath(pathExpr),
68
+ items
69
+ }];
70
+ }
71
+ default:
72
+ return [];
73
+ }
74
+ }));
75
+ }
76
+ function SDKValuePlugin(props) {
77
+ const $ = c(6), setSdkValue = useEditDocument(props), instance = useSanityInstance(props), editor = useEditor();
78
+ let t0, t1;
79
+ return $[0] !== editor || $[1] !== instance || $[2] !== props || $[3] !== setSdkValue ? (t0 = () => {
80
+ const getEditorValue = () => editor.getSnapshot().context.value, {
81
+ getCurrent: getSdkValue,
82
+ subscribe: onSdkValueChange
83
+ } = getDocumentState(instance, props), editorSubscription = editor.on("patch", () => setSdkValue(getEditorValue())), unsubscribeToEditorChanges = () => editorSubscription.unsubscribe(), unsubscribeToSdkChanges = onSdkValueChange(() => {
84
+ const snapshot = getEditorValue(), patches = convertPatches(diffValue(snapshot, getSdkValue()));
85
+ patches.length && editor.send({
86
+ type: "patches",
87
+ patches,
88
+ snapshot
89
+ });
90
+ });
91
+ return editor.send({
92
+ type: "update value",
93
+ value: getSdkValue() ?? []
94
+ }), () => {
95
+ unsubscribeToEditorChanges(), unsubscribeToSdkChanges();
96
+ };
97
+ }, t1 = [setSdkValue, editor, instance, props], $[0] = editor, $[1] = instance, $[2] = props, $[3] = setSdkValue, $[4] = t0, $[5] = t1) : (t0 = $[4], t1 = $[5]), useEffect(t0, t1), null;
98
+ }
99
+ export {
100
+ SDKValuePlugin
101
+ };
102
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sources":["../src/plugin.sdk-value.tsx"],"sourcesContent":["import {\n useEditor,\n type PortableTextBlock,\n type Patch as PtePatch,\n} from '@portabletext/editor'\nimport type {\n JSONValue,\n Path,\n PathSegment,\n InsertPatch as PteInsertPatch,\n} from '@portabletext/patches'\nimport {diffValue, type SanityPatchOperations} from '@sanity/diff-patch'\nimport {\n parsePath,\n type ExprNode,\n type PathNode,\n type SegmentNode,\n type ThisNode,\n} from '@sanity/json-match'\nimport {\n getDocumentState,\n useEditDocument,\n useSanityInstance,\n type DocumentHandle,\n} from '@sanity/sdk-react'\nimport {useEffect} from 'react'\n\ninterface SDKValuePluginProps extends DocumentHandle {\n path: string\n}\n\ntype InsertPatch = Required<Pick<SanityPatchOperations, 'insert'>>\n\nconst ARRAYIFY_ERROR_MESSAGE =\n 'Unexpected path format from diffValue output. Please report this issue.'\n\nfunction* getSegments(\n node: PathNode,\n): Generator<Exclude<SegmentNode, ThisNode>> {\n if (node.base) yield* getSegments(node.base)\n if (node.segment.type !== 'This') yield node.segment\n}\n\nfunction isKeyPath(node: ExprNode): node is PathNode {\n if (node.type !== 'Path') return false\n if (node.base) return false\n if (node.recursive) return false\n if (node.segment.type !== 'Identifier') return false\n return node.segment.name === '_key'\n}\n\nfunction arrayifyPath(pathExpr: string): Path {\n const node = parsePath(pathExpr)\n if (!node) return []\n if (node.type !== 'Path') throw new Error(ARRAYIFY_ERROR_MESSAGE)\n\n return Array.from(getSegments(node)).map((segment): PathSegment => {\n if (segment.type === 'Identifier') return segment.name\n if (segment.type !== 'Subscript') throw new Error(ARRAYIFY_ERROR_MESSAGE)\n if (segment.elements.length !== 1) throw new Error(ARRAYIFY_ERROR_MESSAGE)\n\n const [element] = segment.elements\n if (element.type === 'Number') return element.value\n\n if (element.type !== 'Comparison') throw new Error(ARRAYIFY_ERROR_MESSAGE)\n if (element.operator !== '==') throw new Error(ARRAYIFY_ERROR_MESSAGE)\n const keyPathNode = [element.left, element.right].find(isKeyPath)\n if (!keyPathNode) throw new Error(ARRAYIFY_ERROR_MESSAGE)\n const other = element.left === keyPathNode ? element.right : element.left\n if (other.type !== 'String') throw new Error(ARRAYIFY_ERROR_MESSAGE)\n return {_key: other.value}\n })\n}\n\nfunction convertPatches(patches: SanityPatchOperations[]): PtePatch[] {\n return patches.flatMap((p) => {\n return Object.entries(p).flatMap(([type, values]): PtePatch[] => {\n const origin = 'remote'\n\n switch (type) {\n case 'set':\n case 'setIfMissing':\n case 'diffMatchPatch':\n case 'inc':\n case 'dec': {\n return Object.entries(values).map(\n ([pathExpr, value]) =>\n ({type, value, origin, path: arrayifyPath(pathExpr)}) as PtePatch,\n )\n }\n case 'unset': {\n if (!Array.isArray(values)) return []\n return values.map(arrayifyPath).map((path) => ({type, origin, path}))\n }\n case 'insert': {\n const {items, ...rest} = values as InsertPatch['insert']\n type InsertPosition = PteInsertPatch['position']\n const position = Object.keys(rest).at(0) as InsertPosition | undefined\n\n if (!position) return []\n const pathExpr = (rest as {[K in InsertPosition]: string})[position]\n const insertPatch: PteInsertPatch = {\n type,\n origin,\n position,\n path: arrayifyPath(pathExpr),\n items: items as JSONValue[],\n }\n\n return [insertPatch]\n }\n\n default: {\n return []\n }\n }\n })\n })\n}\n/**\n * @public\n */\nexport function SDKValuePlugin(props: SDKValuePluginProps) {\n // NOTE: the real `useEditDocument` suspends until the document is loaded into the SDK store\n const setSdkValue = useEditDocument(props)\n const instance = useSanityInstance(props)\n const editor = useEditor()\n\n useEffect(() => {\n const getEditorValue = () => editor.getSnapshot().context.value\n const {getCurrent: getSdkValue, subscribe: onSdkValueChange} =\n getDocumentState<PortableTextBlock[]>(instance, props)\n\n const editorSubscription = editor.on('patch', () =>\n setSdkValue(getEditorValue()),\n )\n const unsubscribeToEditorChanges = () => editorSubscription.unsubscribe()\n const unsubscribeToSdkChanges = onSdkValueChange(() => {\n const snapshot = getEditorValue()\n const patches = convertPatches(diffValue(snapshot, getSdkValue()))\n\n if (patches.length) {\n editor.send({type: 'patches', patches, snapshot})\n }\n })\n\n // update initial value\n editor.send({type: 'update value', value: getSdkValue() ?? []})\n\n return () => {\n unsubscribeToEditorChanges()\n unsubscribeToSdkChanges()\n }\n }, [setSdkValue, editor, instance, props])\n\n return null\n}\n"],"names":["ARRAYIFY_ERROR_MESSAGE","getSegments","node","base","segment","type","isKeyPath","recursive","name","arrayifyPath","pathExpr","parsePath","Error","Array","from","map","elements","length","element","value","operator","keyPathNode","left","right","find","other","_key","convertPatches","patches","flatMap","p","Object","entries","values","origin","path","isArray","items","rest","position","keys","at","SDKValuePlugin","props","$","_c","setSdkValue","useEditDocument","instance","useSanityInstance","editor","useEditor","t0","t1","getEditorValue","getSnapshot","context","getCurrent","getSdkValue","subscribe","onSdkValueChange","getDocumentState","editorSubscription","on","unsubscribeToEditorChanges","unsubscribe","unsubscribeToSdkChanges","snapshot","diffValue","send","useEffect"],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,81 @@
1
+ {
2
+ "name": "@portabletext/plugin-sdk-value",
3
+ "version": "1.0.0",
4
+ "description": "Synchronizes the Portable Text Editor value with the Sanity SDK, allowing for two-way editing.",
5
+ "keywords": [
6
+ "portabletext",
7
+ "plugin",
8
+ "sdk",
9
+ "sync",
10
+ "value",
11
+ "@sanity/sdk",
12
+ "@sanity/sdk-react"
13
+ ],
14
+ "homepage": "https://portabletext.org",
15
+ "bugs": {
16
+ "url": "https://github.com/portabletext/plugins/issues"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "https://github.com/portabletext/plugins.git",
21
+ "directory": "plugins/sdk-value"
22
+ },
23
+ "license": "MIT",
24
+ "author": "Sanity.io <hello@sanity.io>",
25
+ "sideEffects": false,
26
+ "type": "module",
27
+ "exports": {
28
+ ".": {
29
+ "source": "./src/index.ts",
30
+ "import": "./dist/index.js",
31
+ "require": "./dist/index.cjs",
32
+ "default": "./dist/index.js"
33
+ },
34
+ "./package.json": "./package.json"
35
+ },
36
+ "main": "./dist/index.cjs",
37
+ "module": "./dist/index.js",
38
+ "types": "./dist/index.d.ts",
39
+ "files": [
40
+ "src",
41
+ "dist"
42
+ ],
43
+ "devDependencies": {
44
+ "@portabletext/editor": "^1.55.12",
45
+ "@portabletext/patches": "^1.1.0",
46
+ "@sanity/schema": "^3.96.0",
47
+ "@sanity/sdk-react": "^2.1.0",
48
+ "@sanity/types": "^3.96.0",
49
+ "@testing-library/react": "^16.3.0",
50
+ "@testing-library/user-event": "^14.6.1",
51
+ "@types/react": "^19.1.2",
52
+ "@vitejs/plugin-react": "^4.4.1",
53
+ "babel-plugin-react-compiler": "19.0.0-beta-e993439-20250328",
54
+ "playwright": "^1.52.0",
55
+ "react": "^19.1.0",
56
+ "react-dom": "^19.1.0",
57
+ "react-error-boundary": "^6.0.0"
58
+ },
59
+ "peerDependencies": {
60
+ "@portabletext/editor": "^1.55.12",
61
+ "@sanity/sdk-react": "^2.1.0",
62
+ "react": "^19.1.0",
63
+ "react-dom": "^19.1.0"
64
+ },
65
+ "dependencies": {
66
+ "@sanity/diff-patch": "^6.0.0",
67
+ "@sanity/json-match": "^1.0.5",
68
+ "react-compiler-runtime": "19.1.0-rc.1"
69
+ },
70
+ "scripts": {
71
+ "build": "pkg-utils build --strict --check --clean",
72
+ "check:lint": "biome lint .",
73
+ "check:react-compiler": "eslint --cache --no-inline-config --no-eslintrc --ignore-pattern '**/__tests__/**' --ext .cjs,.mjs,.js,.jsx,.ts,.tsx --parser @typescript-eslint/parser --plugin react-compiler --plugin react-hooks --rule 'react-compiler/react-compiler: [warn]' --rule 'react-hooks/rules-of-hooks: [error]' --rule 'react-hooks/exhaustive-deps: [error]' src",
74
+ "check:types": "tsc",
75
+ "check:types:watch": "tsc --watch",
76
+ "clean": "del .turbo && del dist && del node_modules",
77
+ "dev": "pkg-utils watch",
78
+ "lint:fix": "biome lint --write .",
79
+ "test": "vitest --run"
80
+ }
81
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from './plugin.sdk-value'
@@ -0,0 +1,183 @@
1
+ import {
2
+ defineSchema,
3
+ EditorProvider,
4
+ PortableTextEditable,
5
+ useEditor,
6
+ type Editor,
7
+ type PortableTextBlock,
8
+ } from '@portabletext/editor'
9
+ import {
10
+ createDocumentHandle,
11
+ getDocumentState,
12
+ ResourceProvider,
13
+ useEditDocument,
14
+ type ResourceProviderProps,
15
+ type SanityConfig,
16
+ type SanityInstance,
17
+ type StateSource,
18
+ } from '@sanity/sdk-react'
19
+ import {render, waitFor} from '@testing-library/react'
20
+ import userEvent, {type UserEvent} from '@testing-library/user-event'
21
+ import {useEffect} from 'react'
22
+ import {ErrorBoundary} from 'react-error-boundary'
23
+ import {
24
+ afterEach,
25
+ beforeEach,
26
+ describe,
27
+ expect,
28
+ it,
29
+ vi,
30
+ type Mock,
31
+ } from 'vitest'
32
+ import {SDKValuePlugin} from './plugin.sdk-value'
33
+
34
+ vi.mock('@sanity/sdk-react', async () => {
35
+ const {createContext, createRef, Suspense, useContext} = await import('react')
36
+ const Context = createContext<SanityConfig | null>(null)
37
+ const eventTarget = new EventTarget()
38
+ const valueRef = createRef<PortableTextBlock[]>()
39
+
40
+ return {
41
+ createDocumentHandle: (value: unknown) => value,
42
+ useEditDocument: () => {
43
+ if (!valueRef.current) {
44
+ // suspend at least once
45
+ throw new Promise((resolve) => {
46
+ valueRef.current = []
47
+ setTimeout(resolve, 0)
48
+ })
49
+ }
50
+
51
+ return (value: PortableTextBlock[]) => {
52
+ valueRef.current = value
53
+ eventTarget.dispatchEvent(new CustomEvent('change'))
54
+ }
55
+ },
56
+ getDocumentState: (): StateSource<PortableTextBlock[] | null> => {
57
+ return {
58
+ getCurrent: () => valueRef.current,
59
+ subscribe: (fn) => {
60
+ const listener = () => fn?.()
61
+ eventTarget.addEventListener('change', listener)
62
+ return () => eventTarget.removeEventListener('change', listener)
63
+ },
64
+ get observable(): StateSource<
65
+ PortableTextBlock[] | null
66
+ >['observable'] {
67
+ throw new Error('Not implemented')
68
+ },
69
+ }
70
+ },
71
+ useSanityInstance: () => useContext(Context)!,
72
+ ResourceProvider: ({
73
+ children,
74
+ fallback,
75
+ ...config
76
+ }: ResourceProviderProps) => (
77
+ <Suspense fallback={fallback}>
78
+ <Context.Provider value={config}>{children}</Context.Provider>
79
+ </Suspense>
80
+ ),
81
+ }
82
+ })
83
+
84
+ const schemaDefinition = defineSchema({})
85
+
86
+ describe(SDKValuePlugin.name, () => {
87
+ let user: UserEvent
88
+ let setSdkValue: (value: PortableTextBlock[] | null | undefined) => void
89
+ let getSdkValue: () => PortableTextBlock[] | null | undefined
90
+ let getEditorValue: () => PortableTextBlock[] | null | undefined
91
+ let portableTextEditable: HTMLElement
92
+ let unmount: () => void
93
+ let errorHandler: Mock
94
+
95
+ beforeEach(async () => {
96
+ user = userEvent.setup()
97
+ errorHandler = vi.fn()
98
+
99
+ const testId = 'portable-text-editable'
100
+ const {resolve: resolveEditor, promise: editorPromise} =
101
+ Promise.withResolvers<Editor>()
102
+
103
+ const doc = createDocumentHandle({
104
+ documentId: 'example-document-id',
105
+ documentType: 'example-document-type',
106
+ })
107
+ const path = 'example-portable-text-field'
108
+
109
+ function CaptureEditorPlugin() {
110
+ const editor = useEditor()
111
+
112
+ useEffect(() => {
113
+ resolveEditor(editor)
114
+ }, [editor])
115
+
116
+ return null
117
+ }
118
+
119
+ const result = render(
120
+ <ErrorBoundary fallback={null} onError={errorHandler}>
121
+ <ResourceProvider
122
+ projectId="example-project"
123
+ dataset="example-dataset"
124
+ fallback={<>Loading…</>}
125
+ >
126
+ <EditorProvider initialConfig={{schemaDefinition}}>
127
+ <SDKValuePlugin {...doc} path={path} />
128
+ <CaptureEditorPlugin />
129
+ <PortableTextEditable data-testid={testId} />
130
+ </EditorProvider>
131
+ </ResourceProvider>
132
+ </ErrorBoundary>,
133
+ )
134
+ portableTextEditable = await waitFor(() => result.getByTestId(testId))
135
+ unmount = result.unmount
136
+
137
+ const editor = await editorPromise
138
+ setSdkValue = useEditDocument<PortableTextBlock[] | null | undefined>({
139
+ ...doc,
140
+ path,
141
+ })
142
+
143
+ getSdkValue = getDocumentState(null as unknown as SanityInstance, {
144
+ ...doc,
145
+ path,
146
+ }).getCurrent
147
+
148
+ getEditorValue = () => editor.getSnapshot().context.value
149
+ })
150
+
151
+ afterEach(async () => {
152
+ // wait one frame before unmounting in the event of errors
153
+ await new Promise((resolve) => setTimeout(resolve, 0))
154
+ expect(errorHandler).not.toHaveBeenCalled()
155
+ unmount?.()
156
+ })
157
+
158
+ it('syncs editor changes to the SDK', async () => {
159
+ await user.click(portableTextEditable)
160
+ await user.type(portableTextEditable, 'Hello world!')
161
+
162
+ expect(getSdkValue()).toEqual(getEditorValue())
163
+ expect(getSdkValue()).toMatchObject([{children: [{text: 'Hello world!'}]}])
164
+ })
165
+
166
+ it('syncs SDK changes to the editor', () => {
167
+ const testValue: PortableTextBlock[] = [
168
+ {
169
+ _type: 'block',
170
+ _key: 'test-key',
171
+ children: [
172
+ {_type: 'span', _key: 'span-key', text: 'SDK content', marks: []},
173
+ ],
174
+ markDefs: [],
175
+ style: 'normal',
176
+ },
177
+ ]
178
+
179
+ setSdkValue(testValue)
180
+ expect(getEditorValue()).toEqual(getSdkValue())
181
+ expect(getEditorValue()).toEqual(testValue)
182
+ })
183
+ })
@@ -0,0 +1,157 @@
1
+ import {
2
+ useEditor,
3
+ type PortableTextBlock,
4
+ type Patch as PtePatch,
5
+ } from '@portabletext/editor'
6
+ import type {
7
+ JSONValue,
8
+ Path,
9
+ PathSegment,
10
+ InsertPatch as PteInsertPatch,
11
+ } from '@portabletext/patches'
12
+ import {diffValue, type SanityPatchOperations} from '@sanity/diff-patch'
13
+ import {
14
+ parsePath,
15
+ type ExprNode,
16
+ type PathNode,
17
+ type SegmentNode,
18
+ type ThisNode,
19
+ } from '@sanity/json-match'
20
+ import {
21
+ getDocumentState,
22
+ useEditDocument,
23
+ useSanityInstance,
24
+ type DocumentHandle,
25
+ } from '@sanity/sdk-react'
26
+ import {useEffect} from 'react'
27
+
28
+ interface SDKValuePluginProps extends DocumentHandle {
29
+ path: string
30
+ }
31
+
32
+ type InsertPatch = Required<Pick<SanityPatchOperations, 'insert'>>
33
+
34
+ const ARRAYIFY_ERROR_MESSAGE =
35
+ 'Unexpected path format from diffValue output. Please report this issue.'
36
+
37
+ function* getSegments(
38
+ node: PathNode,
39
+ ): Generator<Exclude<SegmentNode, ThisNode>> {
40
+ if (node.base) yield* getSegments(node.base)
41
+ if (node.segment.type !== 'This') yield node.segment
42
+ }
43
+
44
+ function isKeyPath(node: ExprNode): node is PathNode {
45
+ if (node.type !== 'Path') return false
46
+ if (node.base) return false
47
+ if (node.recursive) return false
48
+ if (node.segment.type !== 'Identifier') return false
49
+ return node.segment.name === '_key'
50
+ }
51
+
52
+ function arrayifyPath(pathExpr: string): Path {
53
+ const node = parsePath(pathExpr)
54
+ if (!node) return []
55
+ if (node.type !== 'Path') throw new Error(ARRAYIFY_ERROR_MESSAGE)
56
+
57
+ return Array.from(getSegments(node)).map((segment): PathSegment => {
58
+ if (segment.type === 'Identifier') return segment.name
59
+ if (segment.type !== 'Subscript') throw new Error(ARRAYIFY_ERROR_MESSAGE)
60
+ if (segment.elements.length !== 1) throw new Error(ARRAYIFY_ERROR_MESSAGE)
61
+
62
+ const [element] = segment.elements
63
+ if (element.type === 'Number') return element.value
64
+
65
+ if (element.type !== 'Comparison') throw new Error(ARRAYIFY_ERROR_MESSAGE)
66
+ if (element.operator !== '==') throw new Error(ARRAYIFY_ERROR_MESSAGE)
67
+ const keyPathNode = [element.left, element.right].find(isKeyPath)
68
+ if (!keyPathNode) throw new Error(ARRAYIFY_ERROR_MESSAGE)
69
+ const other = element.left === keyPathNode ? element.right : element.left
70
+ if (other.type !== 'String') throw new Error(ARRAYIFY_ERROR_MESSAGE)
71
+ return {_key: other.value}
72
+ })
73
+ }
74
+
75
+ function convertPatches(patches: SanityPatchOperations[]): PtePatch[] {
76
+ return patches.flatMap((p) => {
77
+ return Object.entries(p).flatMap(([type, values]): PtePatch[] => {
78
+ const origin = 'remote'
79
+
80
+ switch (type) {
81
+ case 'set':
82
+ case 'setIfMissing':
83
+ case 'diffMatchPatch':
84
+ case 'inc':
85
+ case 'dec': {
86
+ return Object.entries(values).map(
87
+ ([pathExpr, value]) =>
88
+ ({type, value, origin, path: arrayifyPath(pathExpr)}) as PtePatch,
89
+ )
90
+ }
91
+ case 'unset': {
92
+ if (!Array.isArray(values)) return []
93
+ return values.map(arrayifyPath).map((path) => ({type, origin, path}))
94
+ }
95
+ case 'insert': {
96
+ const {items, ...rest} = values as InsertPatch['insert']
97
+ type InsertPosition = PteInsertPatch['position']
98
+ const position = Object.keys(rest).at(0) as InsertPosition | undefined
99
+
100
+ if (!position) return []
101
+ const pathExpr = (rest as {[K in InsertPosition]: string})[position]
102
+ const insertPatch: PteInsertPatch = {
103
+ type,
104
+ origin,
105
+ position,
106
+ path: arrayifyPath(pathExpr),
107
+ items: items as JSONValue[],
108
+ }
109
+
110
+ return [insertPatch]
111
+ }
112
+
113
+ default: {
114
+ return []
115
+ }
116
+ }
117
+ })
118
+ })
119
+ }
120
+ /**
121
+ * @public
122
+ */
123
+ export function SDKValuePlugin(props: SDKValuePluginProps) {
124
+ // NOTE: the real `useEditDocument` suspends until the document is loaded into the SDK store
125
+ const setSdkValue = useEditDocument(props)
126
+ const instance = useSanityInstance(props)
127
+ const editor = useEditor()
128
+
129
+ useEffect(() => {
130
+ const getEditorValue = () => editor.getSnapshot().context.value
131
+ const {getCurrent: getSdkValue, subscribe: onSdkValueChange} =
132
+ getDocumentState<PortableTextBlock[]>(instance, props)
133
+
134
+ const editorSubscription = editor.on('patch', () =>
135
+ setSdkValue(getEditorValue()),
136
+ )
137
+ const unsubscribeToEditorChanges = () => editorSubscription.unsubscribe()
138
+ const unsubscribeToSdkChanges = onSdkValueChange(() => {
139
+ const snapshot = getEditorValue()
140
+ const patches = convertPatches(diffValue(snapshot, getSdkValue()))
141
+
142
+ if (patches.length) {
143
+ editor.send({type: 'patches', patches, snapshot})
144
+ }
145
+ })
146
+
147
+ // update initial value
148
+ editor.send({type: 'update value', value: getSdkValue() ?? []})
149
+
150
+ return () => {
151
+ unsubscribeToEditorChanges()
152
+ unsubscribeToSdkChanges()
153
+ }
154
+ }, [setSdkValue, editor, instance, props])
155
+
156
+ return null
157
+ }