) => {
20 | let newValue: number | string | boolean = e.currentTarget.value
21 |
22 | if (e.currentTarget.type === 'checkbox' && 'checked' in e.currentTarget) {
23 | newValue = e.currentTarget.checked
24 | } else if (e.currentTarget.type === 'number') {
25 | newValue = Number(value)
26 | }
27 |
28 | updateData({
29 | ...data,
30 | [name]: newValue,
31 | })
32 | setValue(newValue || '')
33 | },
34 | [data, name, updateData, value],
35 | )
36 |
37 | if (!type || !name || !updateData) {
38 | return null
39 | }
40 |
41 | const label = title || name
42 |
43 | if (UNSUPORTED_TYPES.includes(type)) {
44 | return (
45 |
46 |
47 |
48 | {label}
49 |
50 |
51 | {unsupportedError || 'Close this dialog and edit the document to change this field.'}
52 |
53 |
54 |
55 | )
56 | }
57 |
58 | if (type === 'object' && field.fields?.length) {
59 | return (
60 |
61 | {field.fields.map((fld) => (
62 |
69 | ))}
70 |
71 | )
72 | }
73 |
74 | if (!['boolean', 'number', 'text', 'string'].includes(type)) {
75 | console.error('Asset-source OG Image: wrong field type received')
76 | return null
77 | }
78 |
79 | const commonProps = {
80 | onChange,
81 | value,
82 | disabled,
83 | }
84 |
85 | return (
86 |
87 |
94 |
95 | )
96 | }
97 |
98 | export default EditorField
99 |
--------------------------------------------------------------------------------
/src/Image.tsx:
--------------------------------------------------------------------------------
1 | import {SanityImage} from './types'
2 | import * as React from 'react'
3 | import imageBuilder from './imageBuilder'
4 |
5 | interface Props {
6 | image?: SanityImage
7 | width?: number
8 | }
9 |
10 | const Image: React.FC = (props) => {
11 | const {image, width} = props
12 | if (!image?.asset?._ref) {
13 | return null
14 | }
15 | const src = imageBuilder
16 | .image(image)
17 | .width(width || 500)
18 | .url()
19 |
20 | if (!src) {
21 | return null
22 | }
23 |
24 | return
25 | }
26 |
27 | export default Image
28 |
--------------------------------------------------------------------------------
/src/LayoutsPicker.tsx:
--------------------------------------------------------------------------------
1 | import {Box, Button, Inline, Text} from '@sanity/ui'
2 | import {EditorLayout} from './types'
3 | import * as React from 'react'
4 |
5 | interface LayoutsPickerProps {
6 | layouts?: EditorLayout[]
7 | activeLayout?: EditorLayout
8 | disabled: boolean
9 | setActiveLayout?: (layout: EditorLayout) => void
10 | }
11 |
12 | const LayoutsPicker: React.FC = (props) => {
13 | const {layouts, activeLayout, disabled, setActiveLayout} = props
14 | if (
15 | !props.layouts?.length ||
16 | props.layouts.length < 2 ||
17 | !props.activeLayout ||
18 | !props.setActiveLayout
19 | ) {
20 | return null
21 | }
22 | return (
23 | <>
24 |
25 | Choose layout
26 |
27 |
28 | {layouts?.map((layout, i) => (
29 |
39 | >
40 | )
41 | }
42 |
43 | export default LayoutsPicker
44 |
--------------------------------------------------------------------------------
/src/app.tsx:
--------------------------------------------------------------------------------
1 | import {Box, Portal, ThemeProvider, studioTheme, useGlobalKeyDown, usePrefersDark} from '@sanity/ui'
2 | import {SanityDocument, EditorLayout, DialogLabels} from './types'
3 | import Editor from './Editor'
4 | import React, {useCallback} from 'react'
5 | import isHotkey from 'is-hotkey'
6 | import defaultLayout from './defaultLayout'
7 |
8 | interface SelectedAsset {
9 | [key: string]: any
10 | }
11 |
12 | type ToolType = {props?: {layouts?: EditorLayout[]}} | string
13 |
14 | type Props = {
15 | // User-provided. See README for how they set it up
16 | layouts: EditorLayout[]
17 | dialog?: DialogLabels
18 | // The props below are provided by Sanity
19 | /**
20 | * Exclusive to asset source dialogs.
21 | */
22 | onClose?: () => void
23 | /**
24 | * Exclusive to asset source dialogs.
25 | */
26 | onSelect?: () => void
27 | /**
28 | * Exclusive to studio tools.
29 | */
30 | tool?: ToolType
31 | document?: SanityDocument
32 | selectedAssets?: SelectedAsset[]
33 | selectionType: 'single'
34 | darkMode?: boolean
35 | }
36 | const MediaEditor: React.FC = (props) => {
37 | const {tool, onClose, dialog, onSelect} = props
38 |
39 | const prefersDark = usePrefersDark()
40 | const scheme = prefersDark ? 'dark' : 'light'
41 |
42 | const handleGlobalKeyDown = useCallback((event: KeyboardEvent) => {
43 | if (isHotkey('esc', event) && onClose) {
44 | onClose()
45 | }
46 | }, [])
47 | useGlobalKeyDown(handleGlobalKeyDown)
48 |
49 | let layouts = (typeof tool === 'object' && tool.props?.layouts) || props.layouts
50 | layouts = layouts?.filter((layout) => layout.prepare && layout.component)
51 |
52 | if (!layouts?.length) {
53 | layouts = [defaultLayout]
54 | }
55 | if (!layouts?.length) {
56 | if (onClose) {
57 | onClose()
58 | }
59 | return null
60 | }
61 |
62 | const document: SanityDocument = props.document || {_id: 'unknown'}
63 |
64 | const editorProps = {
65 | document,
66 | layouts,
67 | onSelect,
68 | onClose,
69 | dialog,
70 | }
71 | return (
72 |
73 | {tool ? (
74 |
80 |
81 |
82 | ) : (
83 |
84 |
95 |
96 |
97 |
98 | )}
99 |
100 | )
101 | }
102 |
103 | export default MediaEditor
104 |
--------------------------------------------------------------------------------
/src/defaultLayout.tsx:
--------------------------------------------------------------------------------
1 | import {Card, Container, Stack, usePrefersDark} from '@sanity/ui'
2 | import {EditorLayout, LayoutData, PrepareFunction} from './types'
3 | import * as React from 'react'
4 |
5 | export const DefaultLayoutComponent: React.FC = ({title, subtitle, logo}) => {
6 | const prefersDark = usePrefersDark()
7 | const scheme = prefersDark ? 'dark' : 'light'
8 | return (
9 |
23 |
24 |
25 | {title && {title}
}
26 | {subtitle && {subtitle}
}
27 | {logo &&
}
28 |
29 |
30 |
31 | )
32 | }
33 |
34 | // Ideally, users will provide their own prepare function, this is an unlikely fallback
35 | export const defaultPrepare: PrepareFunction = (document) => {
36 | return {
37 | // Possible common values for title & image
38 | title: document.title || document.seoTitle || document.seo?.title || document.hero?.title,
39 | logo: document.ogImage || document.image || document.hero?.image || document.logo,
40 | includeBorder: false,
41 | }
42 | }
43 |
44 | const defaultLayout: EditorLayout = {
45 | name: 'default',
46 | title: 'Default layout',
47 | component: DefaultLayoutComponent,
48 | prepare: defaultPrepare,
49 | fields: [
50 | {
51 | title: 'Title',
52 | name: 'title',
53 | type: 'string',
54 | },
55 | {
56 | title: 'Subtitle',
57 | name: 'subtitle',
58 | type: 'text',
59 | },
60 | {
61 | title: 'Logo / image',
62 | name: 'logo',
63 | type: 'image',
64 | },
65 | ],
66 | }
67 |
68 | export default defaultLayout
69 |
--------------------------------------------------------------------------------
/src/imageBuilder.ts:
--------------------------------------------------------------------------------
1 | import imageUrlBuilder from '@sanity/image-url'
2 | import {useClient} from 'sanity'
3 |
4 | const imageBuilder = imageUrlBuilder(useClient({apiVersion: '2021-06-07'}))
5 |
6 | export default imageBuilder
7 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import {definePlugin} from 'sanity'
2 | import MediaEditor from './app'
3 | import {EditorLayout} from './types'
4 |
5 | interface GenerateOGImageConfig {
6 | layouts?: EditorLayout[]
7 | }
8 |
9 | /**
10 | * Usage in `sanity.config.ts` (or .js)
11 | *
12 | * ```ts
13 | * import {defineConfig} from 'sanity'
14 | * import {generateOGImage} from 'sanity-plugin-generate-ogimage'
15 | *
16 | * export default defineConfig({
17 | * // ...
18 | * plugins: [generateOGImage()],
19 | * })
20 | * ```
21 | *
22 | * Or to use custom layouts:
23 | * ```ts
24 | * import {defineConfig} from 'sanity'
25 | * import {generateOGImage} from 'sanity-plugin-generate-ogimage'
26 | * import CustomLayout from './CustomLayout'
27 | *
28 | * export default defineConfig({
29 | * // ...
30 | * plugins: [generateOGImage({layouts: [CustomLayout]})],
31 | * })
32 | * ```
33 | */
34 | export const generateOGImage = definePlugin((config = {}) => {
35 | // eslint-disable-next-line no-console
36 | console.log('hello there from sanity-plugin-generate-ogimage', config.layouts)
37 |
38 | return {
39 | name: 'sanity-plugin-generate-ogimage',
40 | tools: (prev, context) => {
41 | return [
42 | ...prev, // remember to include previous values
43 | {
44 | name: 'asset-source-ogimage',
45 | title: 'Generate image',
46 | component: MediaEditor,
47 | props: {
48 | layouts: config.layouts,
49 | },
50 | },
51 | ]
52 | },
53 | }
54 | })
55 |
56 | export {MediaEditor}
57 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | export interface SanityDocument {
4 | _id: string
5 | [key: string]: any
6 | }
7 |
8 | export interface SanityImage {
9 | _type?: 'image'
10 | asset: {
11 | _ref: string
12 | _type: 'reference'
13 | }
14 | }
15 |
16 | export interface DialogLabels {
17 | /**
18 | * Title above the dialog.
19 | */
20 | title?: string
21 | /**
22 | * Text of the generation button.
23 | */
24 | finishCta?: string
25 | /**
26 | * The a11y title for the close button in the dialog.
27 | */
28 | ariaClose?: string
29 | }
30 |
31 | export interface LayoutData {
32 | [key: string]: any
33 | }
34 |
35 | export type PrepareFunction = (document: SanityDocument) => Data
36 |
37 | export type LayoutFieldTypes =
38 | | 'string'
39 | | 'text'
40 | | 'number'
41 | | 'image'
42 | | 'object'
43 | | 'boolean'
44 | | 'array'
45 | | 'date'
46 | | 'datetime'
47 | | 'reference'
48 |
49 | export interface LayoutField {
50 | /**
51 | * Labels for editors changing the value of the property live.
52 | */
53 | title: string
54 | description?: string
55 | /**
56 | * Equivalent to the property name in prepare's resulting LayoutData object.
57 | */
58 | name: string
59 | /**
60 | * Array, date, datetime, reference and image aren't supported (yet?)
61 | */
62 | type: LayoutFieldTypes
63 | /**
64 | * Exclusive to objects
65 | */
66 | fields?: LayoutField[]
67 | /**
68 | * Helpful error message for editors when they can't edit that given field in the Editor dialog.
69 | * Exclusive to non-supported types
70 | */
71 | unsupportedError?: string
72 | }
73 |
74 | export type EditorLayout = {
75 | /**
76 | * Needs to be unique to identify this layout among others.
77 | */
78 | name: string
79 | /**
80 | * Visible label to users. Only shows when we have 2 or more layouts.
81 | */
82 | title?: string
83 | /**
84 | * React component which renders
85 | */
86 | component?: React.FC
87 | /**
88 | * Function which gets the current document.
89 | * Is irrelevant in the context of studio tools as the layout won't receive a document, so if you're only using it there you can ignore this.
90 | */
91 | prepare?: PrepareFunction
92 | /**
93 | * Fields editable by users to change the component data and see changes in the layout live.
94 | */
95 | fields?: LayoutField[]
96 | /**
97 | * Common examples include:
98 | * 1200x630 - Twitter, LinkedIn & Facebook
99 | * 256x256 - WhatsApp
100 | * 1080x1080 - Instagram square
101 | */
102 | dimensions?: {
103 | width: number
104 | height: number
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/src/useEditorLogic.ts:
--------------------------------------------------------------------------------
1 | import {EditorLayout, LayoutData} from './types'
2 | import download from 'downloadjs'
3 | import {toPng} from 'html-to-image'
4 | import React, {useState, useRef, useCallback} from 'react'
5 |
6 | import defaultLayout from './defaultLayout'
7 | import {EditorProps} from './Editor'
8 |
9 | function useEditorLogic({document, layouts, onSelect}: EditorProps) {
10 | const captureRef = useRef(null)
11 | const [status, setStatus] = useState<'idle' | 'error' | 'loading' | 'success'>('idle')
12 | const disabled = status === 'loading'
13 | const layoutsExist = layouts && layouts[0]?.component
14 |
15 | const [activeLayout, setActiveLayout] = useState(
16 | layoutsExist ? layouts[0] : defaultLayout,
17 | )
18 |
19 | const prepare = activeLayout.prepare ? activeLayout.prepare(document) : undefined
20 | const [data, setData] = useState(onSelect && prepare ? prepare : {})
21 |
22 | const generateImage = useCallback(
23 | async (e: React.FormEvent) => {
24 | e.preventDefault()
25 | if (!captureRef?.current) {
26 | console.error('Capture reference is missing.')
27 | return
28 | }
29 | try {
30 | setStatus('loading')
31 | const imgBase64 = await toPng(captureRef.current, {
32 | quality: 1,
33 | pixelRatio: 1,
34 | })
35 | setStatus('success')
36 | if (onSelect) {
37 | onSelect([
38 | {
39 | kind: 'base64',
40 | value: imgBase64,
41 | assetDocumentProps: {
42 | originalFilename: `OG Image - ${new Date(Date.now()).toISOString()}`,
43 | source: {
44 | name: 'asset-source-ogimage',
45 | id: 'asset-source-ogimage',
46 | },
47 | },
48 | },
49 | ])
50 | }
51 | } catch (error) {
52 | setStatus('error')
53 | console.error('Error generating image:', error)
54 | }
55 | },
56 | [onSelect],
57 | )
58 |
59 | const downloadImage = useCallback(async (e: React.FormEvent) => {
60 | e.preventDefault()
61 | if (!captureRef?.current) {
62 | console.error('Capture reference is missing.')
63 | return
64 | }
65 | try {
66 | setStatus('loading')
67 | const imgBase64 = await toPng(captureRef.current, {
68 | quality: 1,
69 | pixelRatio: 1,
70 | })
71 | setStatus('success')
72 | download(imgBase64, `OG Image - ${new Date(Date.now()).toISOString()}.png`)
73 | } catch (error) {
74 | setStatus('error')
75 | console.error('Error downloading image:', error)
76 | }
77 | }, [])
78 |
79 | return {
80 | activeLayout,
81 | setActiveLayout,
82 | disabled,
83 | generateImage,
84 | downloadImage,
85 | captureRef,
86 | data,
87 | setData,
88 | }
89 | }
90 |
91 | export default useEditorLogic
92 |
--------------------------------------------------------------------------------
/tsconfig.dist.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": "./dist",
13 | "jsx": "react-jsx",
14 | "emitDeclarationOnly": true
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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: undefined,
9 | },
10 | sanityExchangeUrl,
11 | })
12 |
--------------------------------------------------------------------------------