├── .npmrc
├── .prettierignore
├── .husky
├── pre-commit
└── commit-msg
├── commitlint.config.js
├── .eslintignore
├── .prettierrc
├── lint-staged.config.js
├── .npmignore
├── sanity.json
├── .releaserc.json
├── .github
├── renovate.json
└── workflows
│ └── main.yml
├── tsconfig.json
├── .editorconfig
├── .eslintrc
├── v2-incompatible.js
├── src
├── schema
│ ├── cloudinaryAssetContext.ts
│ ├── cloudinaryAssetContextCustom.ts
│ ├── cloudinaryAssetDerived.ts
│ └── cloudinaryAsset.ts
├── components
│ ├── VideoPlayer.tsx
│ ├── SecretsConfigView.tsx
│ ├── AssetDiff.tsx
│ ├── AssetPreview.tsx
│ ├── WidgetInput.tsx
│ ├── AssetListFunctions.tsx
│ ├── CloudinaryInput.tsx
│ └── asset-source
│ │ ├── CloudinaryAssetSource.tsx
│ │ └── Icon.tsx
├── types.ts
├── index.ts
└── utils.ts
├── tsconfig.lib.json
├── tsconfig.settings.json
├── package.config.ts
├── .gitignore
├── LICENSE
├── package.json
├── README.md
└── CHANGELOG.md
/.npmrc:
--------------------------------------------------------------------------------
1 | legacy-peer-deps=true
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | lib
2 | pnpm-lock.yaml
3 | yarn.lock
4 | package-lock.json
5 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | . "$(dirname "$0")/_/husky.sh"
3 |
4 | npx lint-staged
5 |
--------------------------------------------------------------------------------
/commitlint.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: ['@commitlint/config-conventional'],
3 | }
4 |
--------------------------------------------------------------------------------
/.husky/commit-msg:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | . "$(dirname "$0")/_/husky.sh"
3 |
4 | npx --no -- commitlint --edit ""
5 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | *.js
2 | .eslintrc.js
3 | commitlint.config.js
4 | lib
5 | lint-staged.config.js
6 | package.config.ts
7 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": false,
3 | "printWidth": 100,
4 | "bracketSpacing": false,
5 | "singleQuote": true
6 | }
7 |
--------------------------------------------------------------------------------
/lint-staged.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | '**/*.{js,jsx}': ['eslint'],
3 | '**/*.{ts,tsx}': ['eslint', () => 'tsc --noEmit'],
4 | }
5 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | /test
2 | /coverage
3 | .editorconfig
4 | .eslintrc
5 | .gitignore
6 | .github
7 | .prettierrc
8 | .travis.yml
9 | .nyc_output
10 |
--------------------------------------------------------------------------------
/sanity.json:
--------------------------------------------------------------------------------
1 | {
2 | "parts": [
3 | {
4 | "implements": "part:@sanity/base/sanity-root",
5 | "path": "./v2-incompatible.js"
6 | }
7 | ]
8 | }
9 |
--------------------------------------------------------------------------------
/.releaserc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@sanity/semantic-release-preset",
3 | "branches": ["main", {"name": "studio-v2", "channel": "studio-v2", "range": "0.x.x"}]
4 | }
5 |
--------------------------------------------------------------------------------
/.github/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3 | "extends": [
4 | "github>sanity-io/renovate-config",
5 | "github>sanity-io/renovate-config:studio-v3"
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.settings",
3 | "include": ["./src", "./package.config.ts"],
4 | "compilerOptions": {
5 | "rootDir": ".",
6 | "jsx": "react-jsx",
7 | "noEmit": true
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | ; editorconfig.org
2 | root = true
3 | charset= utf8
4 |
5 | [*]
6 | end_of_line = lf
7 | insert_final_newline = true
8 | trim_trailing_whitespace = true
9 | indent_style = space
10 | indent_size = 2
11 |
12 | [*.md]
13 | trim_trailing_whitespace = false
14 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "env": {
4 | "node": true,
5 | "browser": true
6 | },
7 | "extends": [
8 | "sanity",
9 | "sanity/typescript",
10 | "sanity/react",
11 | "plugin:react-hooks/recommended",
12 | "plugin:prettier/recommended"
13 | ]
14 | }
--------------------------------------------------------------------------------
/v2-incompatible.js:
--------------------------------------------------------------------------------
1 | const {showIncompatiblePluginDialog} = require('@sanity/incompatible-plugin')
2 | const {name, version, sanityExchangeUrl} = require('./package.json')
3 |
4 | export default showIncompatiblePluginDialog({
5 | name: name,
6 | versions: {
7 | v3: version,
8 | v2: '^0.2.2',
9 | },
10 | sanityExchangeUrl,
11 | })
12 |
--------------------------------------------------------------------------------
/src/schema/cloudinaryAssetContext.ts:
--------------------------------------------------------------------------------
1 | import {defineType} from 'sanity'
2 |
3 | export interface CloudinaryAssetContext {
4 | custom: object
5 | }
6 |
7 | export const cloudinaryAssetContext = defineType({
8 | type: 'object',
9 | name: 'cloudinary.assetContext',
10 | fields: [
11 | {
12 | type: 'cloudinary.assetContextCustom',
13 | name: 'custom',
14 | },
15 | ],
16 | })
17 |
--------------------------------------------------------------------------------
/tsconfig.lib.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.settings",
3 | "include": ["./src"],
4 | "exclude": [
5 | "./src/**/__fixtures__",
6 | "./src/**/__mocks__",
7 | "./src/**/*.test.ts",
8 | "./src/**/*.test.tsx"
9 | ],
10 | "compilerOptions": {
11 | "rootDir": ".",
12 | "outDir": "./lib",
13 | "jsx": "react-jsx",
14 | "emitDeclarationOnly": true
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/tsconfig.settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "moduleResolution": "node",
4 | "target": "esnext",
5 | "module": "esnext",
6 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
7 | "esModuleInterop": true,
8 | "strict": true,
9 | "downlevelIteration": true,
10 | "declaration": true,
11 | "allowSyntheticDefaultImports": true,
12 | "skipLibCheck": true,
13 | "isolatedModules": true
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/src/schema/cloudinaryAssetContextCustom.ts:
--------------------------------------------------------------------------------
1 | import {defineType} from 'sanity'
2 |
3 | export type CloudinaryAssetContextCustom = {
4 | alt: string
5 | caption: string
6 | }
7 |
8 | export const cloudinaryAssetContextCustom = defineType({
9 | type: 'object',
10 | name: 'cloudinary.assetContextCustom',
11 | fields: [
12 | {
13 | type: 'string',
14 | name: 'alt',
15 | },
16 | {
17 | type: 'string',
18 | name: 'caption',
19 | },
20 | ],
21 | })
22 |
--------------------------------------------------------------------------------
/package.config.ts:
--------------------------------------------------------------------------------
1 | import {defineConfig} from '@sanity/pkg-utils'
2 |
3 | export default defineConfig({
4 | legacyExports: true,
5 | dist: 'lib',
6 | tsconfig: 'tsconfig.lib.json',
7 |
8 | // Remove this block to enable strict export validation
9 | extract: {
10 | rules: {
11 | 'ae-forgotten-export': 'off',
12 | 'ae-incompatible-release-tags': 'off',
13 | 'ae-internal-missing-underscore': 'off',
14 | 'ae-missing-release-tag': 'off',
15 | },
16 | },
17 | })
18 |
--------------------------------------------------------------------------------
/src/schema/cloudinaryAssetDerived.ts:
--------------------------------------------------------------------------------
1 | import {defineType} from 'sanity'
2 |
3 | export type CloudinaryAssetDerived = {
4 | raw_transformation: string
5 | url: string
6 | secure_url: string
7 | }
8 |
9 | export const cloudinaryAssetDerivedSchema = defineType({
10 | type: 'object',
11 | name: 'cloudinary.assetDerived',
12 | fields: [
13 | {
14 | type: 'string',
15 | name: 'raw_transformation',
16 | },
17 | {
18 | type: 'url',
19 | name: 'url',
20 | },
21 | {
22 | type: 'url',
23 | name: 'secure_url',
24 | },
25 | ],
26 | })
27 |
--------------------------------------------------------------------------------
/src/components/VideoPlayer.tsx:
--------------------------------------------------------------------------------
1 | import React, {CSSProperties} from 'react'
2 |
3 | type PlayerKind = 'player' | 'diff'
4 |
5 | export type VideoPlayerProps = {
6 | src: string
7 | // eslint-disable-next-line react/no-unused-prop-types
8 | kind: PlayerKind
9 | }
10 |
11 | export default function VideoPlayer(props: VideoPlayerProps) {
12 | const {src} = props
13 |
14 | const style: CSSProperties = {
15 | width: '100%',
16 | height: 'auto',
17 | }
18 |
19 | return (
20 |
23 | )
24 | }
25 |
--------------------------------------------------------------------------------
/src/components/SecretsConfigView.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import {SettingsView} from '@sanity/studio-secrets'
3 |
4 | export type Secrets = {
5 | cloudName: string
6 | apiKey: string
7 | }
8 |
9 | const pluginConfigKeys = [
10 | {
11 | key: 'cloudName',
12 | title: 'Cloud name',
13 | description: '',
14 | },
15 | {
16 | key: 'apiKey',
17 | title: 'API key',
18 | description: '',
19 | },
20 | ]
21 |
22 | export const namespace = 'cloudinary'
23 |
24 | type Props = {
25 | onClose: () => void
26 | }
27 |
28 | const SecretsConfigView = (props: Props) => {
29 | return (
30 |
36 | )
37 | }
38 |
39 | export default SecretsConfigView
40 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 |
6 | # Runtime data
7 | pids
8 | *.pid
9 | *.seed
10 |
11 | # Directory for instrumented libs generated by jscoverage/JSCover
12 | lib-cov
13 |
14 | # Coverage directory used by tools like istanbul
15 | coverage
16 |
17 | # nyc test coverage
18 | .nyc_output
19 |
20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
21 | .grunt
22 |
23 | # node-waf configuration
24 | .lock-wscript
25 |
26 | # Compiled binary addons (http://nodejs.org/api/addons.html)
27 | build/Release
28 |
29 | # Dependency directories
30 | node_modules
31 | jspm_packages
32 |
33 | # Optional npm cache directory
34 | .npm
35 |
36 | # Optional REPL history
37 | .node_repl_history
38 |
39 | # macOS finder cache file
40 | .DS_Store
41 |
42 | # VS Code settings
43 | .vscode
44 |
45 | # IntelliJ
46 | .idea
47 | *.iml
48 |
49 | # Cache
50 | .cache
51 |
52 | # Yalc
53 | .yalc
54 | yalc.lock
55 |
56 | ##npm package zips
57 | *.tgz
58 |
59 | # Compiled plugin
60 | lib
61 | dist
62 |
63 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 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.
22 |
--------------------------------------------------------------------------------
/src/components/AssetDiff.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import {DiffFromTo} from 'sanity'
3 | import VideoPlayer from './VideoPlayer'
4 | import {assetUrl} from '../utils'
5 | import {CloudinaryAsset} from '../types'
6 |
7 | type Props = {
8 | value: CloudinaryAsset | undefined
9 | }
10 |
11 | const CloudinaryDiffPreview = ({value}: Props) => {
12 | if (!value) {
13 | return null
14 | }
15 |
16 | const url = assetUrl(value)
17 |
18 | if (value.resource_type === 'video' && url) {
19 | return (
20 |
29 | )
30 | }
31 |
32 | return
33 | }
34 |
35 | type DiffProps = {
36 | diff: any
37 | schemaType: any
38 | }
39 |
40 | const AssetDiff = ({diff, schemaType}: DiffProps) => {
41 | return
42 | }
43 |
44 | export default AssetDiff
45 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | import {CloudinaryAssetDerived} from './schema/cloudinaryAssetDerived'
2 |
3 | export type CloudinaryDerivative = {
4 | url: string
5 | secure_url: string
6 | raw_transformation: string
7 | }
8 |
9 | export type CloudinaryAssetResponse = {
10 | public_id: string
11 | resource_type: string
12 | type: string
13 | url: string
14 | tags: string[]
15 | secure_url: string
16 | format: string
17 | width: number
18 | height: number
19 | bytes: number
20 | context?: {
21 | custom: Record
22 | }
23 | derived?: CloudinaryDerivative[]
24 | }
25 |
26 | export type InsertHandlerParams = {
27 | assets: CloudinaryAssetResponse[]
28 | }
29 |
30 | export interface CloudinaryMediaLibrary {
31 | show: (config?: {asset: any; folder: any}) => void
32 | hide: () => void
33 | }
34 |
35 | export type CloudinaryAsset = {
36 | _type: string
37 | _key?: string
38 | _version: number
39 | public_id: string
40 | resource_type: string
41 | type: string
42 | format: string
43 | version: number
44 | url: string
45 | secure_url: string
46 | derived?: CloudinaryAssetDerived[]
47 | display_name?: string
48 | }
49 |
50 | export type AssetDocument = {
51 | _id: string
52 | label?: string
53 | title?: string
54 | description?: string
55 | source?: {
56 | id: string
57 | name: string
58 | url?: string
59 | }
60 | creditLine?: string
61 | originalFilename?: string
62 | }
63 |
64 | declare global {
65 | // eslint-disable-next-line no-unused-vars
66 | interface Window {
67 | cloudinary: {
68 | openMediaLibrary: (config: any, callbacks: any) => void
69 | createMediaLibrary: (config: any, callbacks?: any) => CloudinaryMediaLibrary
70 | }
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/src/components/AssetPreview.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import VideoPlayer from './VideoPlayer'
3 | import {assetUrl} from '../utils'
4 | import {Flex, Text} from '@sanity/ui'
5 | import {CloudinaryAsset} from '../types'
6 | import {DocumentIcon} from '@sanity/icons'
7 |
8 | interface ComponentProps {
9 | layout?: 'default' | 'block'
10 | value: CloudinaryAsset | undefined
11 | }
12 |
13 | const AssetPreview = ({value, layout}: ComponentProps) => {
14 | const url = value && assetUrl(value)
15 | if (!value || !url) {
16 | return null
17 | }
18 |
19 | switch (value.resource_type) {
20 | case 'video':
21 | return (
22 |
28 |
29 |
30 | )
31 | case 'raw':
32 | return (
33 |
34 |
35 |
36 | {value.display_name ?? 'Raw file'}
37 |
38 |
39 | )
40 | default:
41 | return (
42 |
43 |
61 |
62 | )
63 | }
64 | }
65 |
66 | export default AssetPreview
67 |
--------------------------------------------------------------------------------
/src/components/WidgetInput.tsx:
--------------------------------------------------------------------------------
1 | import React, {useCallback} from 'react'
2 | import {ObjectInputProps, PatchEvent, unset} from 'sanity'
3 | import {Button, Flex, Grid, Stack} from '@sanity/ui'
4 | import {PlugIcon} from '@sanity/icons'
5 | import {styled} from 'styled-components'
6 | import AssetPreview from './AssetPreview'
7 | import {CloudinaryAsset} from '../types'
8 |
9 | const SetupButtonContainer = styled.div`
10 | position: relative;
11 | display: block;
12 | font-size: 0.8em;
13 | transform: translate(0%, -10%);
14 | `
15 |
16 | type WidgetInputProps = ObjectInputProps & {openMediaSelector: () => void; onSetup: () => void}
17 |
18 | const WidgetInput = (props: WidgetInputProps) => {
19 | const {onChange, readOnly, value, openMediaSelector} = props
20 |
21 | const removeValue = useCallback(() => {
22 | onChange(PatchEvent.from([unset()]))
23 | }, [onChange])
24 |
25 | return (
26 |
27 |
28 |
29 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
53 |
61 |
62 |
63 | )
64 | }
65 |
66 | export default WidgetInput
67 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import {cloudinaryAssetSchema} from './schema/cloudinaryAsset'
2 | import {cloudinaryAssetDerivedSchema} from './schema/cloudinaryAssetDerived'
3 | import {
4 | definePlugin,
5 | AssetSource,
6 | ArrayOfObjectsInputProps,
7 | isArrayOfObjectsSchemaType,
8 | } from 'sanity'
9 | import {CloudinaryIcon} from './components/asset-source/Icon'
10 | import {CloudinaryAssetSource} from './components/asset-source/CloudinaryAssetSource'
11 | import {cloudinaryAssetContext} from './schema/cloudinaryAssetContext'
12 | import {cloudinaryAssetContextCustom} from './schema/cloudinaryAssetContextCustom'
13 | import {AssetListFunctions} from './components/AssetListFunctions'
14 |
15 | export {type CloudinaryAssetContext} from './schema/cloudinaryAssetContext'
16 | export {type CloudinaryAssetDerived} from './schema/cloudinaryAssetDerived'
17 | export {type CloudinaryAssetContextCustom} from './schema/cloudinaryAssetContextCustom'
18 |
19 | export type {AssetDocument, CloudinaryAsset} from './types'
20 |
21 | export {
22 | cloudinaryAssetSchema,
23 | cloudinaryAssetDerivedSchema,
24 | cloudinaryAssetContext,
25 | cloudinaryAssetContextCustom,
26 | }
27 |
28 | export const cloudinarySchemaPlugin = definePlugin({
29 | name: 'cloudinary-schema',
30 | form: {
31 | components: {
32 | input: (props) => {
33 | const {schemaType} = props
34 | if (isArrayOfObjectsSchemaType(schemaType)) {
35 | const arrayProps = props as ArrayOfObjectsInputProps
36 | const cloudinaryType = arrayProps.schemaType.of.find(
37 | (t: {name: string}) => t.name === cloudinaryAssetSchema.name
38 | )
39 | if (cloudinaryType) {
40 | return arrayProps.renderDefault({...arrayProps, arrayFunctions: AssetListFunctions})
41 | }
42 | }
43 | return props.renderDefault(props)
44 | },
45 | },
46 | },
47 | schema: {
48 | types: [
49 | cloudinaryAssetSchema,
50 | cloudinaryAssetDerivedSchema,
51 | cloudinaryAssetContext,
52 | cloudinaryAssetContextCustom,
53 | ],
54 | },
55 | })
56 |
57 | export const cloudinaryImageSource: AssetSource = {
58 | name: 'cloudinary-image',
59 | title: 'Cloudinary',
60 | icon: CloudinaryIcon,
61 | component: CloudinaryAssetSource,
62 | }
63 |
64 | export const cloudinaryAssetSourcePlugin = definePlugin({
65 | name: 'cloudinart-asset-source',
66 | form: {
67 | image: {
68 | assetSources: [cloudinaryImageSource],
69 | },
70 | },
71 | })
72 |
--------------------------------------------------------------------------------
/src/schema/cloudinaryAsset.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | import CloudinaryInput from '../components/CloudinaryInput'
3 | import AssetDiff from '../components/AssetDiff'
4 | import AssetPreview from '../components/AssetPreview'
5 | import {defineType} from 'sanity'
6 |
7 | export const cloudinaryAssetSchema = defineType({
8 | type: 'object',
9 | name: 'cloudinary.asset',
10 | fields: [
11 | {
12 | type: 'string',
13 | name: 'public_id',
14 | },
15 | {
16 | type: 'string',
17 | name: 'resource_type',
18 | // "image", "?"
19 | },
20 | {
21 | type: 'string',
22 | name: 'type',
23 | // "upload", "?"
24 | },
25 | {
26 | type: 'string',
27 | name: 'format',
28 | // "jpg"
29 | },
30 | {
31 | type: 'number',
32 | name: 'version',
33 | },
34 | {
35 | type: 'url',
36 | name: 'url',
37 | },
38 | {
39 | type: 'url',
40 | name: 'secure_url',
41 | },
42 | {
43 | type: 'number',
44 | name: 'width',
45 | },
46 | {
47 | type: 'number',
48 | name: 'height',
49 | },
50 | {
51 | type: 'number',
52 | name: 'bytes',
53 | },
54 | {
55 | type: 'number',
56 | name: 'duration',
57 | // can be null
58 | },
59 | {
60 | type: 'array',
61 | name: 'tags',
62 | of: [{type: 'string'}],
63 | },
64 | {
65 | type: 'datetime',
66 | name: 'created_at',
67 | },
68 | {
69 | type: 'array',
70 | name: 'derived',
71 | of: [{type: 'cloudinary.assetDerived', name: 'derived'}],
72 | },
73 | {
74 | type: 'string',
75 | name: 'access_mode',
76 | },
77 | {
78 | type: 'cloudinary.assetContext',
79 | name: 'context',
80 | },
81 | // metadata array of unknown content
82 | ],
83 | ...({
84 | components: {
85 | input: CloudinaryInput,
86 | diff: AssetDiff,
87 | preview: AssetPreview,
88 | },
89 | } as {}), //TODO revert this change when rc.1 is released
90 | preview: {
91 | select: {
92 | url: 'url',
93 | resource_type: 'resource_type',
94 | derived: 'derived.0.url',
95 | },
96 | prepare({url, derived, resource_type}) {
97 | return {
98 | title: url,
99 | value: {
100 | title: url,
101 | resource_type,
102 | url: derived || url,
103 | },
104 | }
105 | },
106 | },
107 | })
108 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "sanity-plugin-cloudinary",
3 | "version": "1.4.1",
4 | "description": "Cloudinary integration for Sanity Studio V3.",
5 | "keywords": [
6 | "sanity",
7 | "sanity-plugin"
8 | ],
9 | "homepage": "https://github.com/sanity-io/sanity-plugin-cloudinary#readme",
10 | "bugs": {
11 | "url": "https://github.com/sanity-io/sanity-plugin-cloudinary/issues"
12 | },
13 | "repository": {
14 | "type": "git",
15 | "url": "git@github.com:sanity-io/sanity-plugin-cloudinary.git"
16 | },
17 | "license": "MIT",
18 | "author": "Sanity.io ",
19 | "exports": {
20 | ".": {
21 | "types": "./lib/index.d.ts",
22 | "source": "./src/index.ts",
23 | "require": "./lib/index.js",
24 | "import": "./lib/index.esm.js",
25 | "default": "./lib/index.esm.js"
26 | },
27 | "./package.json": "./package.json"
28 | },
29 | "main": "./lib/index.js",
30 | "module": "./lib/index.esm.js",
31 | "source": "./src/index.ts",
32 | "types": "./lib/index.d.ts",
33 | "files": [
34 | "lib",
35 | "sanity.json",
36 | "src",
37 | "v2-incompatible.js"
38 | ],
39 | "scripts": {
40 | "build": "run-s clean && plugin-kit verify-package --silent && pkg-utils build --strict && pkg-utils --strict",
41 | "clean": "rimraf lib",
42 | "link-watch": "plugin-kit link-watch",
43 | "lint": "eslint .",
44 | "prepare": "husky install",
45 | "prepublishOnly": "run-s build",
46 | "watch": "pkg-utils watch --strict",
47 | "format": "prettier --write --cache --ignore-unknown ."
48 | },
49 | "dependencies": {
50 | "@sanity/icons": "^3.7.0",
51 | "@sanity/incompatible-plugin": "^1.0.5",
52 | "@sanity/studio-secrets": "^3.0.3",
53 | "@sanity/ui": "^2.15.2",
54 | "nanoid": "^4.0.0"
55 | },
56 | "devDependencies": {
57 | "@commitlint/cli": "^17.8.1",
58 | "@commitlint/config-conventional": "^17.8.1",
59 | "@sanity/pkg-utils": "^2.4.10",
60 | "@sanity/plugin-kit": "^3.1.10",
61 | "@sanity/semantic-release-preset": "^4.1.6",
62 | "@types/react": "^19.0.10",
63 | "@typescript-eslint/eslint-plugin": "^5.62.0",
64 | "@typescript-eslint/parser": "^5.62.0",
65 | "eslint": "^8.52.0",
66 | "eslint-config-prettier": "^8.10.0",
67 | "eslint-config-sanity": "^6.0.0",
68 | "eslint-plugin-prettier": "^4.2.1",
69 | "eslint-plugin-react": "^7.33.2",
70 | "eslint-plugin-react-hooks": "^4.6.0",
71 | "husky": "^8.0.1",
72 | "lint-staged": "^13.0.3",
73 | "npm-run-all": "^4.1.5",
74 | "postcss": "^8.0.0",
75 | "prettier": "^2.8.8",
76 | "prettier-plugin-packagejson": "^2.4.6",
77 | "react": "^19.0.0",
78 | "react-dom": "^19.0.0",
79 | "react-is": "^19.0.0",
80 | "rimraf": "^5.0.0",
81 | "sanity": "^3.78.1",
82 | "semantic-release": "^22.0.12",
83 | "styled-components": "^6.1.15",
84 | "typescript": "^5.2.2"
85 | },
86 | "peerDependencies": {
87 | "react": "^18.3 || ^19",
88 | "react-dom": "^18.3 || ^19",
89 | "sanity": "^3 || ^4.0.0-0 || ^5.0.0",
90 | "styled-components": "^6.0"
91 | },
92 | "engines": {
93 | "node": ">=14"
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/src/components/AssetListFunctions.tsx:
--------------------------------------------------------------------------------
1 | import React, {useCallback} from 'react'
2 | import {Box, Button, Flex} from '@sanity/ui'
3 | import {
4 | ArrayInputFunctionsProps,
5 | ArrayOfObjectsFunctions,
6 | ArraySchemaType,
7 | insert,
8 | ObjectSchemaType,
9 | PatchEvent,
10 | setIfMissing,
11 | } from 'sanity'
12 |
13 | import {useSecrets} from '@sanity/studio-secrets'
14 | import SecretsConfigView, {namespace} from './SecretsConfigView'
15 | import {cloudinaryAssetSchema} from '../schema/cloudinaryAsset'
16 | import {openMediaSelector} from '../utils'
17 | import {InsertHandlerParams} from '../types'
18 | import {PlugIcon} from '@sanity/icons'
19 |
20 | interface ApiConfig {
21 | cloudName: string
22 | apiKey: string
23 | }
24 |
25 | export const AssetListFunctions = (
26 | props: ArrayInputFunctionsProps<{_key: string}, ArraySchemaType>
27 | ) => {
28 | const {onValueCreate, onChange} = props
29 |
30 | const {secrets, loading} = useSecrets(namespace)
31 | const [showSettings, setShowSettings] = React.useState(false)
32 |
33 | const show = useCallback(() => setShowSettings(true), [setShowSettings])
34 | const hide = useCallback(() => setShowSettings(false), [setShowSettings])
35 |
36 | const cloudinaryType = props.schemaType.of.find(
37 | (t: {name: string}) => t.name === cloudinaryAssetSchema.name
38 | ) as ObjectSchemaType | undefined
39 |
40 | if (!cloudinaryType) {
41 | throw new Error(`AssetListFunctions can only be used in array.of ${
42 | cloudinaryAssetSchema.name
43 | }, but it was array.of
44 | ${props.schemaType.of.map((t) => t.name)}`)
45 | }
46 |
47 | const handleSelect = useCallback(
48 | (selected: InsertHandlerParams) => {
49 | const items = selected.assets.map((asset) =>
50 | Object.assign(
51 | {},
52 | asset,
53 | {
54 | // Schema version. In case we ever change our schema.
55 | _version: 1,
56 | },
57 | onValueCreate(cloudinaryType as any) // onValueCreate is mistyped
58 | )
59 | )
60 | onChange(PatchEvent.from([setIfMissing([]), insert(items, 'after', [-1])]))
61 | },
62 | [onValueCreate, onChange, cloudinaryType]
63 | )
64 |
65 | const handleOpenSelector = useCallback(
66 | () =>
67 | secrets &&
68 | openMediaSelector(
69 | secrets.cloudName,
70 | secrets.apiKey,
71 | true, // multi-selection
72 | handleSelect
73 | ),
74 | [secrets, handleSelect]
75 | )
76 | return (
77 |
78 | {showSettings && }
79 |
80 |
81 |
82 | {cloudinaryType && (
83 | <>
84 |
85 |
92 |
93 |
94 |
95 |
96 | >
97 | )}
98 |
99 | )
100 | }
101 |
--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable camelcase,@typescript-eslint/explicit-module-boundary-types */
2 | import {
3 | CloudinaryAsset,
4 | CloudinaryAssetResponse,
5 | CloudinaryMediaLibrary,
6 | InsertHandlerParams,
7 | } from './types'
8 |
9 | const widgetSrc = 'https://media-library.cloudinary.com/global/all.js'
10 |
11 | export function assetUrl(asset: Partial>) {
12 | if (asset.derived && asset.derived.length > 0) {
13 | const [derived] = asset.derived
14 | if (derived.secure_url) {
15 | return derived.secure_url
16 | }
17 | return derived.url
18 | }
19 | if (asset.secure_url) {
20 | return asset.secure_url
21 | }
22 | return asset.url
23 | }
24 |
25 | export const openMediaSelector = (
26 | cloudName: string,
27 | apiKey: string,
28 | multiple: boolean,
29 | insertHandler: (params: InsertHandlerParams) => void,
30 | selectedAsset?: CloudinaryAsset
31 | ) => {
32 | loadJS(widgetSrc, () => {
33 | const options: Record = {
34 | cloud_name: cloudName,
35 | api_key: apiKey,
36 | insert_caption: 'Select',
37 | multiple,
38 | }
39 |
40 | if (selectedAsset) {
41 | options.asset = {
42 | public_id: selectedAsset.public_id,
43 | type: selectedAsset.type,
44 | resource_type: selectedAsset.resource_type,
45 | }
46 | }
47 |
48 | window.cloudinary.openMediaLibrary(options, {insertHandler})
49 | })
50 | }
51 |
52 | export const createMediaLibrary = ({
53 | cloudName,
54 | apiKey,
55 | inlineContainer,
56 | libraryCreated,
57 | insertHandler,
58 | }: {
59 | cloudName: string
60 | apiKey: string
61 | inlineContainer: string
62 | libraryCreated: (library: CloudinaryMediaLibrary) => void
63 | insertHandler: (params: InsertHandlerParams) => void
64 | }) => {
65 | loadJS(widgetSrc, () => {
66 | const options: Record = {
67 | cloud_name: cloudName,
68 | api_key: apiKey,
69 | insert_caption: 'Select',
70 | inline_container: inlineContainer,
71 | remove_header: true,
72 | }
73 |
74 | libraryCreated(window.cloudinary.createMediaLibrary(options, {insertHandler}))
75 | })
76 | }
77 |
78 | export function loadJS(url: string, callback: () => void) {
79 | const existingScript = document.getElementById('damWidget')
80 | if (!existingScript) {
81 | const script = document.createElement('script')
82 | script.src = url
83 | script.id = 'damWidget'
84 | document.body.appendChild(script)
85 | script.onload = () => {
86 | if (callback) {
87 | return callback()
88 | }
89 | return true
90 | }
91 | }
92 | if (existingScript && callback) {
93 | return callback()
94 | }
95 | return true
96 | }
97 |
98 | export function encodeSourceId(asset: CloudinaryAssetResponse): string {
99 | const {resource_type, public_id, type} = asset
100 | return btoa(JSON.stringify({public_id, resource_type, type})) // Sort keys alphabetically!
101 | }
102 |
103 | export function encodeFilename(asset: CloudinaryAssetResponse) {
104 | return `${asset.public_id.split('/').slice(-1)[0]}.${asset.format}`
105 | }
106 |
107 | export function decodeSourceId(sourceId: string): CloudinaryAssetResponse | undefined {
108 | let sourceIdDecoded: any
109 | try {
110 | sourceIdDecoded = JSON.parse(atob(sourceId))
111 | } catch (err) {
112 | // Do nothing
113 | }
114 | return sourceIdDecoded
115 | }
116 |
--------------------------------------------------------------------------------
/src/components/CloudinaryInput.tsx:
--------------------------------------------------------------------------------
1 | import React, {useCallback, useState} from 'react'
2 | import WidgetInput from './WidgetInput'
3 | import {nanoid} from 'nanoid'
4 | import {ObjectInputProps, PatchEvent, set} from 'sanity'
5 | import {CloudinaryAsset, CloudinaryAssetResponse} from '../types'
6 | import {useSecrets} from '@sanity/studio-secrets'
7 | import {InsertHandlerParams} from '../types'
8 | import {openMediaSelector} from '../utils'
9 | import SecretsConfigView, {namespace, Secrets} from './SecretsConfigView'
10 |
11 | const CloudinaryInput = (props: ObjectInputProps) => {
12 | const [showSettings, setShowSettings] = useState(false)
13 | const {secrets} = useSecrets(namespace)
14 | const {onChange, schemaType: type} = props
15 | const value = (props.value as CloudinaryAsset) || undefined
16 |
17 | /* eslint-disable camelcase */
18 | const handleSelect = useCallback(
19 | (payload: InsertHandlerParams) => {
20 | const [asset] = payload.assets
21 |
22 | if (!asset) {
23 | return
24 | }
25 |
26 | let updatedAsset = asset
27 |
28 | // Update the asset with the new custom values
29 | const assetWithoutNulls = Object.fromEntries(
30 | Object.entries(asset).filter(([_, assetValue]) => assetValue !== null)
31 | ) as CloudinaryAssetResponse
32 |
33 | // Ensure we preserve the required fields from the original asset
34 | const requiredFields = {
35 | public_id: asset.public_id,
36 | resource_type: asset.resource_type,
37 | type: asset.type,
38 | url: asset.url,
39 | secure_url: asset.secure_url,
40 | format: asset.format,
41 | width: asset.width,
42 | height: asset.height,
43 | bytes: asset.bytes,
44 | tags: asset.tags,
45 | }
46 | updatedAsset = {
47 | ...assetWithoutNulls,
48 | ...requiredFields,
49 | }
50 |
51 | //The metadata in Sanity Studio cannot contain special characters,
52 | //hence the cloudinary metadata (context) needs to be transformed to valid object keys
53 | if (asset.context) {
54 | const objectWithRenamedKeys = Object.fromEntries(
55 | Object.entries(asset.context.custom).map(([contextKey, contextValue]) => {
56 | return [contextKey.replace(/[^a-zA-Z0-9_]|-/g, '_'), contextValue]
57 | })
58 | )
59 |
60 | // Update the asset with the new custom values
61 | updatedAsset = {
62 | ...updatedAsset,
63 | context: {
64 | ...asset.context,
65 | custom: objectWithRenamedKeys,
66 | },
67 | }
68 | }
69 |
70 | // Handle derived field - only include if not null
71 | if (asset.derived) {
72 | const derivedWithType = asset.derived.map((derivedItem) => ({
73 | ...derivedItem,
74 | _type: 'derived',
75 | }))
76 |
77 | updatedAsset = {
78 | ...updatedAsset,
79 | derived: derivedWithType,
80 | }
81 | }
82 |
83 | onChange(
84 | PatchEvent.from([
85 | set(
86 | Object.assign(
87 | {
88 | _type: type.name,
89 | _version: 1,
90 | ...(value?._key ? {_key: value._key} : {_key: nanoid()}),
91 | },
92 | updatedAsset
93 | )
94 | ),
95 | ])
96 | )
97 | },
98 | [onChange, type, value?._key]
99 | )
100 |
101 | const action = secrets
102 | ? () =>
103 | openMediaSelector(
104 | secrets.cloudName,
105 | secrets.apiKey,
106 | false, // single selection
107 | handleSelect,
108 | value
109 | )
110 | : () => setShowSettings(true)
111 |
112 | return (
113 | <>
114 | {showSettings && setShowSettings(false)} />}
115 | setShowSettings(true)} openMediaSelector={action} {...props} />
116 | >
117 | )
118 | /* eslint-enable camelcase */
119 | }
120 |
121 | export default CloudinaryInput
122 |
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | ---
2 | name: CI & Release
3 |
4 | # Workflow name based on selected inputs. Fallback to default Github naming when expression evaluates to empty string
5 | run-name: >-
6 | ${{
7 | inputs.release && inputs.test && 'Build ➤ Test ➤ Publish to NPM' ||
8 | inputs.release && !inputs.test && 'Build ➤ Skip Tests ➤ Publish to NPM' ||
9 | github.event_name == 'workflow_dispatch' && inputs.test && 'Build ➤ Test' ||
10 | github.event_name == 'workflow_dispatch' && !inputs.test && 'Build ➤ Skip Tests' ||
11 | ''
12 | }}
13 |
14 | on:
15 | # Build on pushes branches that have a PR (including drafts)
16 | pull_request:
17 | # Build on commits pushed to branches without a PR if it's in the allowlist
18 | push:
19 | branches: [main,studio-v2]
20 | # https://docs.github.com/en/actions/managing-workflow-runs/manually-running-a-workflow
21 | workflow_dispatch:
22 | inputs:
23 | test:
24 | description: Run tests
25 | required: true
26 | default: true
27 | type: boolean
28 | release:
29 | description: Release new version
30 | required: true
31 | default: false
32 | type: boolean
33 |
34 | concurrency:
35 | # On PRs builds will cancel if new pushes happen before the CI completes, as it defines `github.head_ref` and gives it the name of the branch the PR wants to merge into
36 | # Otherwise `github.run_id` ensures that you can quickly merge a queue of PRs without causing tests to auto cancel on any of the commits pushed to main.
37 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
38 | cancel-in-progress: true
39 |
40 | jobs:
41 | log-the-inputs:
42 | name: Log inputs
43 | runs-on: ubuntu-latest
44 | steps:
45 | - run: |
46 | echo "Inputs: $INPUTS"
47 | env:
48 | INPUTS: ${{ toJSON(inputs) }}
49 |
50 | build:
51 | runs-on: ubuntu-latest
52 | name: Lint & Build
53 | steps:
54 | - uses: actions/checkout@v4
55 | - uses: actions/setup-node@v4
56 | with:
57 | cache: npm
58 | node-version: lts/*
59 | - run: npm ci
60 | # Linting can be skipped
61 | - run: npm run lint --if-present
62 | if: github.event.inputs.test != 'false'
63 | # But not the build script, as semantic-release will crash if this command fails so it makes sense to test it early
64 | - run: npm run prepublishOnly --if-present
65 |
66 | test:
67 | needs: build
68 | # The test matrix can be skipped, in case a new release needs to be fast-tracked and tests are already passing on main
69 | if: github.event.inputs.test != 'false'
70 | runs-on: ${{ matrix.os }}
71 | name: Node.js ${{ matrix.node }} / ${{ matrix.os }}
72 | strategy:
73 | # A test failing on windows doesn't mean it'll fail on macos. It's useful to let all tests run to its completion to get the full picture
74 | fail-fast: false
75 | matrix:
76 | # Run the testing suite on each major OS with the latest LTS release of Node.js
77 | os: [macos-latest, ubuntu-latest, windows-latest]
78 | node: [lts/*]
79 | # It makes sense to also test the oldest, and latest, versions of Node.js, on ubuntu-only since it's the fastest CI runner
80 | include:
81 | - os: ubuntu-latest
82 | # Test the oldest LTS release of Node that's still receiving bugfixes and security patches, versions older than that have reached End-of-Life
83 | node: lts/-2
84 | - os: ubuntu-latest
85 | # Test the actively developed version that will become the latest LTS release next October
86 | node: current
87 | steps:
88 | # It's only necessary to do this for windows, as mac and ubuntu are sane OS's that already use LF
89 | - name: Set git to use LF
90 | if: matrix.os == 'windows-latest'
91 | run: |
92 | git config --global core.autocrlf false
93 | git config --global core.eol lf
94 | - uses: actions/checkout@v4
95 | - uses: actions/setup-node@v4
96 | with:
97 | cache: npm
98 | node-version: ${{ matrix.node }}
99 | - run: npm i
100 | - run: npm test --if-present
101 |
102 | release:
103 | needs: [build, test]
104 | # only run if opt-in during workflow_dispatch
105 | if: always() && github.event.inputs.release == 'true' && needs.build.result != 'failure' && needs.test.result != 'failure' && needs.test.result != 'cancelled'
106 | runs-on: ubuntu-latest
107 | name: Semantic release
108 | steps:
109 | - uses: actions/create-github-app-token@v2
110 | id: app-token
111 | with:
112 | app-id: ${{ secrets.ECOSPARK_APP_ID }}
113 | private-key: ${{ secrets.ECOSPARK_APP_PRIVATE_KEY }}
114 | - uses: actions/checkout@v4
115 | with:
116 | # Need to fetch entire commit history to
117 | # analyze every commit since last release
118 | fetch-depth: 0
119 | # Uses generated token to allow pushing commits back
120 | token: ${{ steps.app-token.outputs.token }}
121 | # Make sure the value of GITHUB_TOKEN will not be persisted in repo's config
122 | persist-credentials: false
123 | - uses: actions/setup-node@v4
124 | with:
125 | cache: npm
126 | node-version: lts/*
127 | - run: npm ci
128 | # Branches that will release new versions are defined in .releaserc.json
129 | - run: npx semantic-release
130 | env:
131 | GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
132 | NPM_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
133 |
--------------------------------------------------------------------------------
/src/components/asset-source/CloudinaryAssetSource.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable camelcase */
2 | import React, {useCallback, useEffect, useRef, useState} from 'react'
3 | import {Box, Button, Dialog, Flex, Spinner, Stack, Text} from '@sanity/ui'
4 | import {CloudinaryMediaLibrary, InsertHandlerParams} from '../../types'
5 | import {createMediaLibrary, decodeSourceId, encodeFilename, encodeSourceId} from '../../utils'
6 | import {styled} from 'styled-components'
7 | import {useSecrets} from '@sanity/studio-secrets'
8 | import SecretsConfigView, {namespace, Secrets} from '../SecretsConfigView'
9 | import {AssetSourceComponentProps, ImageAsset} from 'sanity'
10 | import {PlugIcon} from '@sanity/icons'
11 |
12 | export const Widget = styled.div`
13 | height: 70vh;
14 | `
15 |
16 | export function CloudinaryAssetSource(props: AssetSourceComponentProps) {
17 | const {onClose, dialogHeaderTitle} = props
18 |
19 | const [loadingMessage, setLoadingMessage] = useState(
20 | 'Loading Cloudinary Media Libary'
21 | )
22 | const library = useRef(undefined)
23 | const contentRef = useRef(null)
24 | const {secrets} = useSecrets(namespace)
25 | const cloudName = secrets?.cloudName
26 | const apiKey = secrets?.apiKey
27 | const [widgetId] = useState(() => `cloundinaryWidget-${Date.now()}`)
28 | const [showSettings, setShowSettings] = useState(false)
29 |
30 | const propsRef = useRef(props)
31 |
32 | useEffect(() => {
33 | // because we have to access props after loading js in a callback,
34 | // we cannot pass props as dependecnies as that will cause infinite updates
35 | // this takes a snapshot of props, so we can access them later
36 | propsRef.current = props
37 | }, [props])
38 |
39 | const handleClose = useCallback(() => {
40 | if (library.current) {
41 | library.current.hide()
42 | }
43 | onClose()
44 | }, [onClose, library])
45 |
46 | useEffect(() => {
47 | if (!cloudName || !apiKey) {
48 | return
49 | }
50 |
51 | createMediaLibrary({
52 | cloudName,
53 | apiKey,
54 | inlineContainer: `#${widgetId}`,
55 | libraryCreated: (lib: CloudinaryMediaLibrary) => {
56 | library.current = lib
57 | const selectedAssets = propsRef.current.selectedAssets
58 | const firstSelectedAsset = selectedAssets ? selectedAssets[0] : null
59 |
60 | // eslint-disable-next-line no-undef
61 | const iframe: ChildNode | null | undefined =
62 | contentRef.current && contentRef.current.firstChild
63 | if (iframe && iframe instanceof HTMLIFrameElement) {
64 | setLoadingMessage(undefined)
65 | let asset
66 | if (
67 | propsRef.current.selectionType === 'single' &&
68 | firstSelectedAsset &&
69 | firstSelectedAsset.source &&
70 | firstSelectedAsset.source.id
71 | ) {
72 | asset = decodeSourceId(firstSelectedAsset.source.id)
73 | }
74 | const folder = asset
75 | ? {
76 | path: asset.public_id.split('/').slice(0, -1).join('/'),
77 | resource_type: 'image',
78 | }
79 | : {path: '', resource_type: 'image'}
80 | if (lib && contentRef.current) {
81 | lib.show({folder, asset})
82 | contentRef.current.style.visibility = 'visible'
83 | }
84 | }
85 | },
86 | insertHandler: ({assets}: InsertHandlerParams) => {
87 | if (!library.current) {
88 | return
89 | }
90 | const imageAssets = assets.filter((asset) => asset.resource_type === 'image')
91 | if (imageAssets.length === 0) {
92 | throw new Error('The selection did not contain any images.')
93 | }
94 | library.current.hide()
95 | propsRef.current.onSelect(
96 | imageAssets.map((asset) => {
97 | const url =
98 | asset.derived && asset.derived[0] ? asset.derived[0].secure_url : asset.secure_url
99 | return {
100 | kind: 'url',
101 | value: url,
102 | assetDocumentProps: {
103 | _type: 'sanity.imageAsset',
104 | originalFilename: encodeFilename(asset),
105 | source: {
106 | id: encodeSourceId(asset),
107 | name: `cloudinary:${cloudName}`,
108 | },
109 | } as ImageAsset,
110 | }
111 | })
112 | )
113 | },
114 | })
115 | }, [cloudName, apiKey, widgetId])
116 |
117 | const hasConfig = apiKey && cloudName
118 | return (
119 |
154 | )
155 | }
156 |
--------------------------------------------------------------------------------
/src/components/asset-source/Icon.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | export function CloudinaryIcon() {
4 | return (
5 |
92 | )
93 | }
94 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # sanity-plugin-cloudinary
2 |
3 |
4 | ## Installation
5 |
6 | ```
7 | pnpm add sanity-plugin-cloudinary
8 | ```
9 |
10 | or
11 |
12 | ```
13 | npm install --save sanity-plugin-cloudinary
14 | ```
15 |
16 | or
17 |
18 | ```
19 | yarn add sanity-plugin-cloudinary
20 | ```
21 |
22 | ## Usage
23 |
24 | There are two plugins in this package:
25 |
26 | - `cloudinaryAssetSourcePlugin` - use this if you intend to serve Cloudinary images from the Sanity CDN
27 | - `cloudinarySchemaPlugin` - use this if you intend to serve Cloudinary images from the Cloudinary CDN
28 |
29 | Also see notes below on how Cloudinary config should be provided.
30 |
31 | ## Cloudinary as a Sanity asset
32 |
33 | ### Add Cloudinary as an asset source to all images
34 |
35 | ```js
36 | import {defineConfg} from 'sanity'
37 | import {cloudinaryAssetSourcePlugin} from 'sanity-plugin-cloudinary'
38 |
39 | export default defineConfg({
40 | /*...*/
41 | plugins: [cloudinaryAssetSourcePlugin()],
42 | })
43 | ```
44 |
45 | ### Fine tune image sources
46 |
47 | ```js
48 | import {defineConfg} from 'sanity'
49 | import {cloudinaryImageSource} from 'sanity-plugin-cloudinary'
50 |
51 | export default defineConfg({
52 | /*...*/
53 | form: {
54 | image: {
55 | assetSources: (previousAssetSources, context) => {
56 | if (context.currentUser?.roles.includes('cloudinaryAccess')) {
57 | // appends cloudinary as an asset source
58 | return [...previousAssetSources, cloudinaryImageSource]
59 | }
60 | if (context.currentUser?.roles.includes('onlyCloudinaryAccess')) {
61 | // only use clooudinary as an asset source
62 | return [cloudinaryImageSource]
63 | }
64 | // dont add cloudnary as an asset sources
65 | return previousAssetSources
66 | },
67 | },
68 | },
69 | })
70 | ```
71 |
72 | ## Cloudinary assets
73 |
74 | ```js
75 | import {defineConfg} from 'sanity'
76 | import {cloudinarySchemaPlugin} from 'sanity-plugin-cloudinary'
77 |
78 | export default defineConfg({
79 | /*...*/
80 | plugins: [cloudinarySchemaPlugin()],
81 | })
82 | ```
83 |
84 | Now you can declare a field to be `cloudinary.asset` in your schema
85 |
86 | ```javascript
87 | {
88 | type: "cloudinary.asset",
89 | name: "image",
90 | description: "This asset is served from Cloudinary",
91 | }
92 | ```
93 |
94 | ## Config
95 |
96 | Includes easy configuration of your cloudname and api key, stored safely in your dataset as a private document.
97 |
98 |
99 | Uses Cloudinary media library for selecting assets and transformations
100 |
101 |
102 |
103 |
104 |
105 | ## In arrays
106 |
107 | If you use this type in an array, you will have additional array functions for adding multiple assets at once, and for configuring the connection to Cloudinary.
108 |
109 | ```javascript
110 | {
111 | type: "array",
112 | name: "cloudinaryList",
113 | description: "This asset is served from Cloudinary",
114 | of: [{ type: "cloudinary.asset" }]
115 | }
116 | ```
117 |
118 |
119 |
120 | ## Content
121 |
122 | Here is an example of which data is stored on your document after selecting an asset.
123 |
124 | ```json
125 | {
126 | "public_id": "29b4a88182b4cb50330011d23a29bcb371bd5886-2400x1344_lzcx7x",
127 | "resource_type": "image",
128 | "type": "upload",
129 | "format": "jpg",
130 | "version": 1616474653,
131 | "url": "http://res.cloudinary.com/dzwiku20l/image/upload/v1616474653/29b4a88182b4cb50330011d23a29bcb371bd5886-2400x1344_lzcx7x.jpg",
132 | "secure_url": "https://res.cloudinary.com/dzwiku20l/image/upload/v1616474653/29b4a88182b4cb50330011d23a29bcb371bd5886-2400x1344_lzcx7x.jpg",
133 | "width": 2400,
134 | "height": 1344,
135 | "bytes": 547710,
136 | "duration": null,
137 | "tags": [],
138 | "context": {
139 | "custom": {
140 | "alt": "alternative text for image"
141 | }
142 | },
143 | "created_at": "2021-03-23T04:44:13Z",
144 | "access_mode": "public",
145 | "_version": 1,
146 | "_type": "cloudinary.asset"
147 | }
148 | ```
149 |
150 | Note: The `_version` in the data here refers to the schema version of this plugin, should the way it stores the data from Cloudinary change in the future.
151 |
152 | ## Transformations
153 |
154 | You can create a transformation when selecting the asset, and this information is previewed and stored
155 |
156 |
157 |
158 | ```json
159 | {
160 | "public_id": "29b4a88182b4cb50330011d23a29bcb371bd5886-2400x1344_lzcx7x",
161 | "resource_type": "image",
162 | "type": "upload",
163 | "format": "jpg",
164 | "version": 1616474653,
165 | "url": "http://res.cloudinary.com/dzwiku20l/image/upload/v1616474653/29b4a88182b4cb50330011d23a29bcb371bd5886-2400x1344_lzcx7x.jpg",
166 | "secure_url": "https://res.cloudinary.com/dzwiku20l/image/upload/v1616474653/29b4a88182b4cb50330011d23a29bcb371bd5886-2400x1344_lzcx7x.jpg",
167 | "width": 2400,
168 | "height": 1344,
169 | "bytes": 547710,
170 | "duration": null,
171 | "tags": null,
172 | "context": {
173 | "custom": {
174 | "alt": "alternative text for image"
175 | }
176 | },
177 | "created_at": "2021-03-23T04:44:13Z",
178 | "derived": [
179 | {
180 | "url": "http://res.cloudinary.com/dzwiku20l/image/upload/a_45/v1616474653/29b4a88182b4cb50330011d23a29bcb371bd5886-2400x1344_lzcx7x.jpg",
181 | "secure_url": "https://res.cloudinary.com/dzwiku20l/image/upload/a_45/v1616474653/29b4a88182b4cb50330011d23a29bcb371bd5886-2400x1344_lzcx7x.jpg",
182 | "raw_transformation": "a_45"
183 | }
184 | ],
185 | "access_mode": "public",
186 | "_version": 1,
187 | "_type": "cloudinary.asset"
188 | }
189 | ```
190 |
191 | ## Video
192 |
193 | Video assets gets a video player preview in the Studio
194 |
195 |
196 |
197 | ```json
198 | {
199 | "public_id": "Make_it_happen_together.-WWa8qtgD0f0_nucpr9",
200 | "resource_type": "video",
201 | "type": "upload",
202 | "format": "mp4",
203 | "version": 1616474928,
204 | "url": "http://res.cloudinary.com/dzwiku20l/video/upload/v1616474928/Make_it_happen_together.-WWa8qtgD0f0_nucpr9.mp4",
205 | "secure_url": "https://res.cloudinary.com/dzwiku20l/video/upload/v1616474928/Make_it_happen_together.-WWa8qtgD0f0_nucpr9.mp4",
206 | "width": 1920,
207 | "height": 1080,
208 | "bytes": 3937717,
209 | "duration": 24.1,
210 | "tags": [],
211 | "metadata": [],
212 | "created_at": "2021-03-23T04:48:48Z",
213 | "access_mode": "public",
214 | "_version": 1,
215 | "_type": "cloudinary.asset"
216 | }
217 | ```
218 |
219 | ## License
220 |
221 | MIT-licensed. See LICENSE.
222 |
223 | ## Develop & test
224 |
225 | This plugin uses [@sanity/plugin-kit](https://github.com/sanity-io/plugin-kit)
226 | with default configuration for build & watch scripts.
227 |
228 | See [Testing a plugin in Sanity Studio](https://github.com/sanity-io/plugin-kit#testing-a-plugin-in-sanity-studio)
229 | on how to run this plugin with hotreload in the studio.
230 |
231 | ### Release new version
232 |
233 | Run ["CI & Release" workflow](https://github.com/sanity-io/sanity-plugin-cloudinary/actions/workflows/main.yml).
234 | Make sure to select the main branch and check "Release new version".
235 |
236 | Semantic release will only release on configured branches, so it is safe to run release on any branch.
237 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | # 📓 Changelog
4 |
5 | All notable changes to this project will be documented in this file. See
6 | [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
7 |
8 | ## [1.4.1](https://github.com/sanity-io/sanity-plugin-cloudinary/compare/v1.4.0...v1.4.1) (2025-12-18)
9 |
10 | ### Bug Fixes
11 |
12 | - **deps:** make peer dependencies include sanity 5.x ([#94](https://github.com/sanity-io/sanity-plugin-cloudinary/issues/94)) ([d4a7765](https://github.com/sanity-io/sanity-plugin-cloudinary/commit/d4a77654514ef14ea920b23e6ead9a8e59efc23c))
13 |
14 | ## [1.4.0](https://github.com/sanity-io/sanity-plugin-cloudinary/compare/v1.3.1...v1.4.0) (2025-09-03)
15 |
16 | ### Features
17 |
18 | - Handle Null Values and Anon Objects for Copy/Paste ([0e8759e](https://github.com/sanity-io/sanity-plugin-cloudinary/commit/0e8759e9eeac634b5e251ee06b7ce690d0e91598))
19 |
20 | ### Bug Fixes
21 |
22 | - update readme ([ac4fd8f](https://github.com/sanity-io/sanity-plugin-cloudinary/commit/ac4fd8f54c9d3d6e0737b6897d49c4142a5028b8))
23 |
24 | ## [1.3.1](https://github.com/sanity-io/sanity-plugin-cloudinary/compare/v1.3.0...v1.3.1) (2025-07-10)
25 |
26 | ### Bug Fixes
27 |
28 | - **deps:** allow studio v4 peer dep ranges ([7586517](https://github.com/sanity-io/sanity-plugin-cloudinary/commit/7586517ee6d5c9e5a736c4dec5d43f2361512377))
29 |
30 | ## [1.3.0](https://github.com/sanity-io/sanity-plugin-cloudinary/compare/v1.2.0...v1.3.0) (2025-04-16)
31 |
32 | ### Features
33 |
34 | - pdf and raw resource_type preview ([339bc56](https://github.com/sanity-io/sanity-plugin-cloudinary/commit/339bc563f1d51c66b3a6e42fd60bc4d69d95f301))
35 |
36 | ## [1.2.0](https://github.com/sanity-io/sanity-plugin-cloudinary/compare/v1.1.5...v1.2.0) (2025-03-07)
37 |
38 | ### Features
39 |
40 | - add react 19 support ([8e01cdd](https://github.com/sanity-io/sanity-plugin-cloudinary/commit/8e01cddba570bb5b67f4e7f6e8b221ce40bc76bd))
41 |
42 | ## [1.1.5](https://github.com/sanity-io/sanity-plugin-cloudinary/compare/v1.1.4...v1.1.5) (2024-11-26)
43 |
44 | ### Bug Fixes
45 |
46 | - [#74](https://github.com/sanity-io/sanity-plugin-cloudinary/issues/74) upgrade '@sanity/studio-secrets' and '@sanity/ui' to latest versions ([#78](https://github.com/sanity-io/sanity-plugin-cloudinary/issues/78)) ([c71435a](https://github.com/sanity-io/sanity-plugin-cloudinary/commit/c71435a544bd9d4019544c283c7c874b0b7bb3d1))
47 |
48 | ## [1.1.4](https://github.com/sanity-io/sanity-plugin-cloudinary/compare/v1.1.3...v1.1.4) (2024-10-16)
49 |
50 | ### Bug Fixes
51 |
52 | - use 'dialogHeaderTitle' prop ([#77](https://github.com/sanity-io/sanity-plugin-cloudinary/issues/77)) ([b770cb2](https://github.com/sanity-io/sanity-plugin-cloudinary/commit/b770cb2777244f9cb68d02127446093b43ee414e))
53 |
54 | ## [1.1.3](https://github.com/sanity-io/sanity-plugin-cloudinary/compare/v1.1.2...v1.1.3) (2024-05-30)
55 |
56 | ### Bug Fixes
57 |
58 | - transform cloudinary metadata to no special characters ([#73](https://github.com/sanity-io/sanity-plugin-cloudinary/issues/73)) ([f75e729](https://github.com/sanity-io/sanity-plugin-cloudinary/commit/f75e72973cf4f7e3a0379fb7825dc39282394dc8))
59 |
60 | ## [1.1.2](https://github.com/sanity-io/sanity-plugin-cloudinary/compare/v1.1.1...v1.1.2) (2024-02-05)
61 |
62 | ### Bug Fixes
63 |
64 | - **deps:** update dependencies (non-major) ([#25](https://github.com/sanity-io/sanity-plugin-cloudinary/issues/25)) ([3a30053](https://github.com/sanity-io/sanity-plugin-cloudinary/commit/3a30053d5fa999c85aaa95861e4e3213567341ac))
65 | - **deps:** update dependency nanoid to v4 ([#26](https://github.com/sanity-io/sanity-plugin-cloudinary/issues/26)) ([91aaf21](https://github.com/sanity-io/sanity-plugin-cloudinary/commit/91aaf21c77b6fbb0494b353daa985da2a80d4312))
66 | - fixes styled-components dependency causing plugin to crash ([#67](https://github.com/sanity-io/sanity-plugin-cloudinary/issues/67)) ([f4c245b](https://github.com/sanity-io/sanity-plugin-cloudinary/commit/f4c245b2135530b5978b38865654b90d2eb82e3d))
67 |
68 | ## [1.1.1](https://github.com/sanity-io/sanity-plugin-cloudinary/compare/v1.1.0...v1.1.1) (2023-03-08)
69 |
70 | ### Bug Fixes
71 |
72 | - restored "add multiple" support for arrays with cloudinary.assets ([91cf56a](https://github.com/sanity-io/sanity-plugin-cloudinary/commit/91cf56a30e9c02560ee2d86eabe0aba8454aa829))
73 |
74 | ## [1.1.0](https://github.com/sanity-io/sanity-plugin-cloudinary/compare/v1.0.2...v1.1.0) (2023-03-01)
75 |
76 | ### Features
77 |
78 | - context is now part of the schema (but not exposed in ui) ([31bf342](https://github.com/sanity-io/sanity-plugin-cloudinary/commit/31bf3421308cbddd0bec523d02262f1ef1ee2b71))
79 |
80 | ## [1.0.2](https://github.com/sanity-io/sanity-plugin-cloudinary/compare/v1.0.1...v1.0.2) (2023-02-20)
81 |
82 | ### Bug Fixes
83 |
84 | - fix video player and preview ([98357a2](https://github.com/sanity-io/sanity-plugin-cloudinary/commit/98357a2630d0a33da28edc842118ab4aed247509))
85 |
86 | ## [1.0.1](https://github.com/sanity-io/sanity-plugin-cloudinary/compare/v1.0.0...v1.0.1) (2022-11-25)
87 |
88 | ### Bug Fixes
89 |
90 | - **deps:** sanity ^3.0.0 (rc.3 compatible) ([0ff26b3](https://github.com/sanity-io/sanity-plugin-cloudinary/commit/0ff26b3a600527668cf95079e18c9d264f7ad3c5))
91 |
92 | ## 1.0.0 (2022-11-15)
93 |
94 | ### ⚠ BREAKING CHANGES
95 |
96 | - this version does not work in Sanity Studio V2
97 |
98 | ### Features
99 |
100 | - initial sanity V3 release ([43d5f7a](https://github.com/sanity-io/sanity-plugin-cloudinary/commit/43d5f7ac33f2a668aa4ad56594f047f1caebcee8))
101 | - initial v3 version ([8e45096](https://github.com/sanity-io/sanity-plugin-cloudinary/commit/8e45096cc039b3a36269ce193c18027f7b45cd0e))
102 |
103 | ### Bug Fixes
104 |
105 | - callbacks in CloudinaryAssetSource should no longer be stale ([40e671e](https://github.com/sanity-io/sanity-plugin-cloudinary/commit/40e671e6beb3ab98cf7622c1eeaf722d092eea2c))
106 | - compiled for sanity 3.0.0-rc.0 ([45fcfc6](https://github.com/sanity-io/sanity-plugin-cloudinary/commit/45fcfc675836e732caca58b9c03c29c5cba447f7))
107 | - create array if not already existing, before adding items. Fixes [#1](https://github.com/sanity-io/sanity-plugin-cloudinary/issues/1) ([93c0874](https://github.com/sanity-io/sanity-plugin-cloudinary/commit/93c08747a356e094bdd75ab15b22877a0bbdfecc))
108 | - **deps:** @sanity/secrets -> @sanity/studio-secrets ([3f4b105](https://github.com/sanity-io/sanity-plugin-cloudinary/commit/3f4b105ae18d1e4741309e4740c3fc2e3e26646e))
109 | - **deps:** pkg-utils & @sanity/plugin-kit ([9671271](https://github.com/sanity-io/sanity-plugin-cloudinary/commit/96712711d406fe5a245cee572e2b499e40c6ac17))
110 | - make sure we keep \_key properties ([c5454ec](https://github.com/sanity-io/sanity-plugin-cloudinary/commit/c5454ece76716a8d3745f7472866e40575aab3ff))
111 | - **release:** initial v3 npm version ([f565046](https://github.com/sanity-io/sanity-plugin-cloudinary/commit/f5650464194f303f3a5e65276b14e6b7f99560e1))
112 | - remove styling ([f409c59](https://github.com/sanity-io/sanity-plugin-cloudinary/commit/f409c59c1177e77dc641562c374121681a49f930))
113 |
114 | ## [1.0.0-v3-studio.4](https://github.com/sanity-io/sanity-plugin-cloudinary/compare/v1.0.0-v3-studio.3...v1.0.0-v3-studio.4) (2022-11-04)
115 |
116 | ### Bug Fixes
117 |
118 | - **deps:** pkg-utils & @sanity/plugin-kit ([9671271](https://github.com/sanity-io/sanity-plugin-cloudinary/commit/96712711d406fe5a245cee572e2b499e40c6ac17))
119 |
120 | ## [1.0.0-v3-studio.3](https://github.com/sanity-io/sanity-plugin-cloudinary/compare/v1.0.0-v3-studio.2...v1.0.0-v3-studio.3) (2022-11-03)
121 |
122 | ### Bug Fixes
123 |
124 | - compiled for sanity 3.0.0-rc.0 ([45fcfc6](https://github.com/sanity-io/sanity-plugin-cloudinary/commit/45fcfc675836e732caca58b9c03c29c5cba447f7))
125 |
126 | ## [1.0.0-v3-studio.2](https://github.com/sanity-io/sanity-plugin-cloudinary/compare/v1.0.0-v3-studio.1...v1.0.0-v3-studio.2) (2022-10-31)
127 |
128 | ### Bug Fixes
129 |
130 | - **release:** initial v3 npm version ([f565046](https://github.com/sanity-io/sanity-plugin-cloudinary/commit/f5650464194f303f3a5e65276b14e6b7f99560e1))
131 |
132 | ## 1.0.0-v3-studio.1 (2022-10-31)
133 |
134 | ### Features
135 |
136 | - initial v3 version ([8e45096](https://github.com/sanity-io/sanity-plugin-cloudinary/commit/8e45096cc039b3a36269ce193c18027f7b45cd0e))
137 |
138 | ### Bug Fixes
139 |
140 | - callbacks in CloudinaryAssetSource should no longer be stale ([40e671e](https://github.com/sanity-io/sanity-plugin-cloudinary/commit/40e671e6beb3ab98cf7622c1eeaf722d092eea2c))
141 | - create array if not already existing, before adding items. Fixes [#1](https://github.com/sanity-io/sanity-plugin-cloudinary/issues/1) ([93c0874](https://github.com/sanity-io/sanity-plugin-cloudinary/commit/93c08747a356e094bdd75ab15b22877a0bbdfecc))
142 | - **deps:** @sanity/secrets -> @sanity/studio-secrets ([3f4b105](https://github.com/sanity-io/sanity-plugin-cloudinary/commit/3f4b105ae18d1e4741309e4740c3fc2e3e26646e))
143 | - make sure we keep \_key properties ([c5454ec](https://github.com/sanity-io/sanity-plugin-cloudinary/commit/c5454ece76716a8d3745f7472866e40575aab3ff))
144 | - remove styling ([f409c59](https://github.com/sanity-io/sanity-plugin-cloudinary/commit/f409c59c1177e77dc641562c374121681a49f930))
145 |
--------------------------------------------------------------------------------