How to transform a shortcode into a block

I’m sharing my solution here in case it can help someone, as I believe this type of shortcode transform is a common use case.

As fetching data inside a transform is not possible right now, I’m creating the block inside the transform by passing just the carousel id attribute from the carousel shortcode. I then fill the rest of the attributes afterwards inside the edit function by querying the WP data store there. Finally, I set the temporary id block attribute to undefined so it’s not stored in the block markup as this attribute is useless after the transform.

I know this is a bit convoluted and cumbersome, but it seems to be the only way to do this right now. If anyone finds a better way to do this, I’m all ears.

Thanks to Tom J Nowell for the help.

block.json:

{
    "$schema": "https://schemas.wp.org/trunk/block.json",
    "apiVersion": 3,
    "name": "my/rather-simple-carousel",
    "title": "Rather Simple Carousel",
    "description": "Display a carousel.",
    "textdomain": "rather-simple-carousel",
    "category": "media",
    "attributes": {
        "id": {
            "type": "integer"
        },
        "images": {
            "type": "array",
            "items": {
                "type": "integer"
            },
            "default": []
        },
        "caption": {
            "type": "string",
            "default": ""
        },
        "max_height": {
            "type": "string",
            "default": "300px"
        }
    },
    "supports": {
        "spacing": {
            "margin": true,
            "padding": true
        }
    },
    "editorScript": "file:./index.js",
    "viewScript": "file:./view.js",
    "style": "file:./style-index.css",
    "editorStyle": "file:./index.css"
}

index.js:

/**
 * WordPress dependencies
 */
import {
    G,
    Path,
    SVG,
} from '@wordpress/components';
import {
    registerBlockType
} from '@wordpress/blocks';

/**
 * Internal dependencies
 */
import metadata from './block.json';
import Edit from './edit';
import transforms from './transforms';

import './editor.scss';
import './style.scss';

const { name } = metadata;

export const settings = {
    icon: {
        src: <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
            <Path fill="none" d="M0 0h24v24H0V0z" />
            <G><Path d="M20 4v12H8V4h12m0-2H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8.5 9.67l1.69 2.26 2.48-3.1L19 15H9zM2 6v14c0 1.1.9 2 2 2h14v-2H4V6H2z" /></G>
        </SVG>,
        foreground: '#ff8a00'
    },

    edit: Edit,
    transforms,
};

registerBlockType(name, settings);

edit.js:

/**
 * WordPress dependencies
 */
import { __ } from '@wordpress/i18n';
import {
    ToolbarGroup,
    ToolbarButton,
    PanelBody,
    __experimentalUnitControl as UnitControl,
    __experimentalUseCustomUnits as useCustomUnits,
} from '@wordpress/components';
import {
    InspectorControls,
    BlockControls,
    MediaUploadCheck,
    MediaUpload,
    MediaPlaceholder,
    RichText,
    useBlockProps,
    useSettings
} from '@wordpress/block-editor';
import { useEffect } from '@wordpress/element';
import { useRefEffect } from '@wordpress/compose';
import { useSelect } from '@wordpress/data';

/**
 * Internal dependencies
 */
import { Carousel } from './carousel.js';

const Edit = (props) => {

    const blockProps = useBlockProps();
    const {
        attributes: { id, images, caption, max_height },
        setAttributes,
    } = props;

    const units = useCustomUnits({
        availableUnits: useSettings('spacing.units') || [
            'px',
            'em',
            'rem',
            'vw',
            'vh',
        ],
        defaultValues: { px: 100, em: 10, rem: 10, vw: 10, vh: 25 },
    });

    // Get the carousel post meta data using the temporary id attribute.
    const { post, hasPostResolved } = useSelect(select => {
        return {
            post: select('core').getEntityRecord('postType', 'carousel', id, {
                context: 'edit',
                _fields: ['id', 'meta']
            }),
            hasPostResolved: select('core').hasFinishedResolution('getEntityRecord', ['postType', 'carousel', id, {
                context: 'edit',
                _fields: ['id', 'meta']
            }]),
        }
    }, [id]);

    // Fill the missing attributes after a shortcode-to-block transform.
    useEffect(() => {
        if (hasPostResolved && post) {
            if (images.length === 0) {
                const postImages = post.meta._rsc_carousel_items.split(',');
                setAttributes({ images: postImages });
            }
            if (!caption) {
                const postCaption = post.meta._rsc_carousel_caption;
                setAttributes({ caption: postCaption });
            }
            if (max_height === '300px') {
                const postMaxHeight = post.meta._rsc_carousel_max_height;
                setAttributes({ max_height: postMaxHeight + 'px' });
            }
            // Unset the id attribute as it's no longer necessary.
            setAttributes({ id: undefined });
        }
    }, [hasPostResolved, post]);

    const { attachments } = useSelect(select => {
        const query = {
            include: images.join(','),
            per_page: images.length,
            orderby: 'include'
        };
        return {
            attachments: select('core').getMediaItems(query, { context: 'view' }),
        }
    }, [images]);

    const displayImages = (images) => {
        return (
            images.map((image, index) => {
                return (
                    <div className="carousel-item" key={index}>
                        <figure>
                            <img src={image.source_url} style={{ maxHeight: max_height }} alt={image.alt_text} key={image.id} />
                            {image.caption && (
                                <figcaption>{image.caption.rendered}</figcaption>
                            )}
                        </figure>
                    </div>
                )
            })
        )
    };

    function setImages(media) {
        const imageIDs = media.map(image => image.id);
        setAttributes({ images: imageIDs })
    }

    function setCaption(value) {
        setAttributes({ caption: value })
    }

    function setMaxHeight(value) {
        setAttributes({ max_height: value })
    }

    const ref = useRefEffect((element) => {
        Carousel(element, { speed: 10 });
    }, []);

    return (
        <>
            <InspectorControls>
                <PanelBody
                    title={__('Settings', 'rather-simple-carousel')}
                >
                    <UnitControl
                        label={__('Max height of elements', 'rather-simple-carousel')}
                        min="1"
                        onChange={setMaxHeight}
                        value={max_height}
                        units={units}
                    />
                </PanelBody>
            </InspectorControls>
            <BlockControls>
                {images.length > 0 && (
                    <ToolbarGroup>
                        <MediaUploadCheck>
                            <MediaUpload
                                allowedTypes={['image']}
                                multiple={true}
                                gallery={true}
                                value={images}
                                onSelect={setImages}
                                render={({ open }) => (
                                    <ToolbarButton onClick={open}>
                                        {__('Edit images', 'rather-simple-carousel')}
                                    </ToolbarButton>)}
                            />
                        </MediaUploadCheck>
                    </ToolbarGroup>
                )}
            </BlockControls>
            <MediaUploadCheck>
                {attachments && attachments.length > 0 ?
                    <figure {...blockProps}>
                        <div className="carousel-wrapper" ref={ref}>
                            <div className="carousel-frame">
                                <div className="carousel-items">
                                    {
                                        displayImages(attachments)
                                    }
                                </div>
                            </div>
                            <div className="carousel-arrow left"><span className="icon"><svg version="1.1" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m17.5 5v14l-11-7z" /></svg></span></div>
                            <div className="carousel-arrow right"><span className="icon"><svg version="1.1" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m6.5 5v14l11-7z" /></svg></span></div>
                        </div>
                        <RichText
                            className="carousel-caption"
                            tagName="figcaption"
                            placeholder={__('Enter a caption', 'rather-simple-carousel')}
                            value={caption}
                            onChange={setCaption}
                        />
                    </figure>
                    :
                    <MediaPlaceholder
                        accept="image/*"
                        allowedTypes={['image']}
                        onSelect={setImages}
                        multiple={true}
                        gallery={true}
                        addToGallery={true}
                        handleUpload={true}
                        labels={
                            { title: __('Rather Simple Carousel', 'rather-simple-carousel') }
                        }
                    />
                }
            </MediaUploadCheck>
        </>
    );

}

export default Edit;

transforms.js

/**
 * WordPress dependencies
 */
import { createBlock } from '@wordpress/blocks';

const transforms = {
    from: [
        {
            type: 'shortcode',
            tag: 'carousel',
            transform({ named: { id } }) {
                return createBlock('my/rather-simple-carousel', {
                    id: id
                });
            }
        },
    ],
}

export default transforms;

Hata!: SQLSTATE[HY000] [1045] Access denied for user 'divattrend_liink'@'localhost' (using password: YES)