Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP - Allow plugins to extend core embed blocks #14050

Closed
wants to merge 7 commits into from

Conversation

notnownikki
Copy link
Member

Description

This is Work In Progress, needing feedback from plugin developers and core Gutenberg team members.

Some plugins (e.g. Jetpack) have shortcodes that handle embedding certain content, for example, YouTube. These shortcodes have extra options that aren't available through oEmbed, because the provider's oEmbed API does not support the extra options.

This PR adds new options to the embed block settings to allow plugins to customise core embed blocks and add extra options, modifying the output from oEmbed if needed, or allowing the plugin to override what the embed block saves with a shortcode.

The new options are:

preview: function that returns an enhanced preview. Takes the preview oEmbed generates as an argument.
save: function that allows the plugin to override the block's save.
fetching: function that returns a boolean to show if the preview is being fetched. This allows custom preview endpoints to be implemented.
inspector: component to be rendered as part of the block inspector, to provide controls for any new options the plugin provides.

This PR also includes enhancements for the YouTube block, allowing us to set relative video options, start time, and autoplay. This might not be suitable for inclusion in core (more suited to a plugin?) but it's here as an example of what's possible with the new options.

To demonstrate how a plugin can provide a custom save and preview, the packages/e2e-tests/plugins/extend-embeds/plugin shows the Reddit block being backed by the plugin's own preview endpoint.

How has this been tested?

There's a new e2e test that has a plugin that demonstrates all the new features are working.

Types of changes

New feature, completely backwards compatible.

Checklist:

  • My code is tested.
  • My code follows the WordPress code style.
  • My code follows the accessibility standards.
  • My code has proper inline documentation.
  • I've included developer documentation if appropriate.

@gziolo gziolo added [Status] In Progress Tracking issues with work in progress [Feature] Extensibility The ability to extend blocks or the editing experience [Block] Embed Affects the Embed Block labels Feb 22, 2019
@notnownikki
Copy link
Member Author

In an ironic twist, it's Jetpack that makes the embed-extend plugin not work and fail the e2e test, so I'm looking at that, because it would be Jetpack that takes advantage of these features first, I would guess!

@gwwar
Copy link
Contributor

gwwar commented Mar 9, 2019

@notnownikki it may help to squash commits to proposed changes, then the youtube demo enhancement or pull this out to two PRs with the latter based on this branch. What type of feedback are you interested in?

@gwwar
Copy link
Contributor

gwwar commented Mar 9, 2019

Would it be possible to rebase? Running this locally I'm seeing, when clicking on a paragraph

TypeError: Cannot read property 'core/paragraph' of undefined
    at getInsertUsage (http://localhost:8888/wp-content/plugins/gutenberg/build/editor/index.js?ver=1552158196:38342:39)
    at buildBlockTypeInserterItem (http://localhost:8888/wp-content/plugins/gutenberg/build/editor/index.js?ver=1552158196:38491:16)
    at Array.map (<anonymous>)
    at http://localhost:8888/wp-content/plugins/gutenberg/build/editor/index.js?ver=1552158196:38541:6
    at callSelector (http://localhost:8888/wp-content/plugins/gutenberg/build/editor/index.js?ver=1552158196:5996:18)
    at runSelector (http://localhost:8888/wp-content/plugins/gutenberg/build/data/index.js?ver=1552158196:2420:23)
    at http://localhost:8888/wp-content/plugins/gutenberg/build/editor/index.js?ver=1552158196:14480:20
    at getNextMergeProps (http://localhost:8888/wp-content/plugins/gutenberg/build/data/index.js?ver=1552158196:1983:14)
    at new ComponentWithSelect (http://localhost:8888/wp-content/plugins/gutenberg/build/data/index.js?ver=1552158196:2001:28)
    at zf (http://localhost:8888/wp-content/plugins/gutenberg/vendor/react-dom.min.713f0afa.js:69:258)

@notnownikki notnownikki force-pushed the update/embed-block-extra-options branch from d45385c to 694336a Compare March 11, 2019 10:05
@notnownikki notnownikki force-pushed the update/embed-block-extra-options branch from 694336a to 34b62fc Compare March 11, 2019 10:15
@notnownikki
Copy link
Member Author

@gwwar I rebased this and now there are two commits, one that adds the extra settings, and one that extends the YouTube block.

I'm not sure where that paragraph error is coming from, I don't see it locally, and the e2e tests that run in CI don't see it either. It looks like a usage tracking thing unrelated to this PR, is it appearing in master for you too?

Anyway, this should be a lot easier to review now :)

I'm looking for a code review, and feedback on the approach and how well it will allow Jetpack to extend core embed blocks to let them be backed by shortcodes and implement all the nice options that the shortcodes have that oEmbed doesn't.

I also really need help figuring out why enabling Jetpack changes the order scripts are loaded in and prevents the test plugin extend-embeds loading in time to use the block filters. I opened an issue at Automattic/jetpack#11464 and it's this issue that is causing CI to fail.

};
const onChangeStart = ( value ) => {
this.setAttributes( { start: value } );
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor note but we are creating new functions on each render call. The value is being passed from the event, so we can pull this out of render. Were you having problems with this?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ugh, no real reason other than took the shortest route to making it work so I could get feedback on the approach :) definitely should be moved out

constructor() {
super( ...arguments );
const { extraOptions = {} } = this.props.attributes;
this.setAttributes = this.setAttributes.bind( this );
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe name this something else, it's a bit confusing to read vs props.setAttributes

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

// because we don't want videos autoplaying in the editor.
const { start, relatedOnlyFromChannel } = attributes.extraOptions;
if ( undefined !== start && parseInt( start ) > 0 ) {
extraQueryParams = extraQueryParams + '&start=' + parseInt( start );
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if we have a polyfill for this, but if so, https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams would be useful to build a query string

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/**
* External dependencies
*/
import classnames from 'classnames/dedupe';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting, not a typo :D https://github.com/JedWatson/classnames#alternate-dedupe-version

"This version is slower (about 5x) so it is offered as an opt-in." Both appear to be in use in Gutenberg.

} else {
fetching = isRequestingEmbedPreview( url );
}
}
Copy link
Contributor

@gwwar gwwar Mar 15, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another minor note here, but may be worth pulling out to helper functions, so it's easier to reason about the control flow:

getPreview = ( ownProps ) => {
    const { url } = ownProps;
    if ( ! url ) {
        return false;
    }
    if ( options.preview ) {
        options.preview( getEmbedPreview( url ), ownProps.attributes );
    }
    return getEmbedPreview( url );
}

componentDidUpdate( prevProps ) {
if ( prevProps.html !== this.props.html ) {
// Allows the new html to go into the sandbox.
this.iframe.current.contentDocument.body.removeAttribute( 'data-resizable-iframe-connected' );
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For folks reading there's an early exit in sandbox otherwise

if ( null !== body.getAttribute( 'data-resizable-iframe-connected' ) ) {

@gwwar
Copy link
Contributor

gwwar commented Mar 16, 2019

Thanks @notnownikki, I think this is a pretty interesting and valid use case. I left a few minor notes/thoughts, but overall I'll defer to @WordPress/gutenberg-core for technical guidance here.

Some bigger questions:

  • If a plugin enhances a core embed, we publish a post with the new options, disable the plugin and then edit again, does the core block still work?
  • If we expect ^^ to break, should we be providing a better hint about what enhanced the core embed? For folks troubleshooting, this will be a bit confusing since it should be a core block but has extra values. (As an aside I'd like for the error case to show a view only mode, so it's a bit less scary for most people).

how well it will allow Jetpack to extend core embed blocks to let them be backed by shortcodes

I suppose if we wanted to add a shortcode fallback in html content, we'd update serialized output. We'd need to make sure that core html attribute parsing is a bit more forgiving, as we'll look for an exact match. Maybe adding a function to the blocks.getBlockAttributes filter?

For example:

<RawHTML>{ `[myembed id="${ id }"]` }</RawHTML>

cc @mmtr in case you're interested in taking a look as well.

@notnownikki
Copy link
Member Author

@gwwar

If a plugin enhances a core embed, we publish a post with the new options, disable the plugin and then edit again, does the core block still work?

"It depends" - every engineer ever ;)

If the YouTube extension was removed, the blocks would continue to work, because we're modifying the output of oEmbed with a server side dynamic block and not altering what gets saved. So editing those would load as expected, because oEmbed would still run, but the extended behaviour wouldn't render any more so you'd just get the normal version.

If a plugin was removed that had replaced save with something completely different, then the editor would treat it as an unknown block, because it wouldn't match what the un-enhanced embed block expected. If the plugin had replaced save with some arbitrary HTML, then converting it to HTML would be fine. If the plugin had used a shortcode, then it's the same situation as any other scenario where you install a plugin that provides a shortcode and then remove the plugin.

@aduth
Copy link
Member

aduth commented Mar 18, 2019

If a plugin was removed that had replaced save with something completely different, then the editor would treat it as an unknown block, because it wouldn't match what the un-enhanced embed block expected.

As I understand its history, this is the crux of why extending blocks hadn't previously been implemented, since the story here is not ideal; neither for the user whose blocks are now suddenly invalid, nor for the plugin author who probably won't consider the reality that their plugin may not always be activated.

Some options to consider, then:

  • Should we push the idea of reusing pieces of existing blocks in one's own custom blocks? This is possible to a degree with existing components, though certainly leaves much to be desired. A next step I'd like to see here is some idea of "abstract blocks" or mixin-like behavior, using existing features like alignment as a starting point (ties in with Block API: Server-side awareness of block types #2751 (comment) and the "Block RFC" of Add the block registration RFC #13693 insofar as attributes like alignment need to be made known in non-browser contexts).
  • If we go the path of direct extension, finding ways for the extender to communicate how a downgrade should happen if their plugin was to be disabled in the future. Is part of this making the "Convert to Blocks" function more featureful to the point that it could happen transparently (i.e. a user will never see the "Invalid block" prompt if a viable conversion option exists, as it will occur automatically)?

@notnownikki
Copy link
Member Author

@aduth thank you! I'll take another look at how we could do this without extending blocks while still allowing the shortcode-backed block to pick up URL pastes at a higher priority than core blocks that handle the same URLs, as that seems to require the fewest changes and be in line with other efforts.

@notnownikki
Copy link
Member Author

This shouldn't be too difficult to change round so that plugins can register embed blocks and have them used in the URL paste and embed block resolution.

I still need help with figuring out why Jetpack changes the script load order though, it would mean that when Jetpack is active, other plugins would not be able to register their embed blocks because of the reasons in #14050 (comment)

@notnownikki
Copy link
Member Author

Ok, this has been changed around so that plugins can supply a full set of embed block settings into the common or other embed blocks, using a filter. This lets the plugin provide a fully featured embed block without changing the existing one, and the plugin can decide whether the block takes priority over the existing block when it comes to URL pasting or not, by either putting it at the top or bottom of the list of block settings.

In the example e2e plugin, there's an enhanced Reddit block, and both this block and the existing core block are available to the user. The plugin's block takes priority when resolving a URL to an embed block.

@mapk
Copy link
Contributor

mapk commented Mar 19, 2019

This is really cool, @notnownikki! I just wanted to share what the extra settings look like.

settings

I wonder if the number field might look better as a shorter input.

Screen Shot 2019-03-19 at 2 27 33 PM

@notnownikki
Copy link
Member Author

Thanks 😁 this has had very little visual polish, and I've held off on documentation until we've got the settings approach nailed down and the jetpack conflict sorted, just so you know 🙂

@notnownikki
Copy link
Member Author

Some updates here. It seems like #9757 has been open for a while and is a symptom of much the same problem - the registration code runs inline and if something changes round the load order of scripts, then plugins can miss filters and there's nothing they can do about it.

I'm looking at other implementations to allow the enhanced blocks to hook into the Pasted URL -> Embed Block resolution code, so we can work around that issue.

@notnownikki
Copy link
Member Author

I've opened a new issue at #14578 to get more feedback on the underlying problem.

@notnownikki
Copy link
Member Author

It's working with Jetpack!

@aduth your eyes would be very much appreciated on this again :)

Things I know are not there yet:

  • Documentation for the new registerEmbedBlock API
  • Developer documentation for embed block settings
  • Proper code comments
  • Tests for the new YouTube options

It's been changed so that the filter on the list of blocks used to resolve a URL to an embed block happens during the resolution process, because we can't guarantee that a plugin's code will load before wp-block-library or wp-blocks. The registerEmbedBlock function is exposed through wp.blockLibrary to give plugin developers an easy way to register a new embed block, passing it the same settings data structure as the embed blocks use.

@notnownikki notnownikki added the Needs Technical Feedback Needs testing from a developer perspective. label Apr 15, 2019
@aduth
Copy link
Member

aduth commented Apr 15, 2019

There's a lot to take in here, so forgive me if points of my comment have already been addressed or considered.

This pull request seems to add quite a few things around providing a packaged option for extending blocks behavior, but it also seems relatively constrained in what's actually possible (extending only embeds block). The included example of extending the YouTube block appears to include some specific server behaviors which either a plugin couldn't imitate, or they could only imitate by completely replacing the default server render behavior of the block (i.e. not interoperable with other plugins or core behavior which seeks to do the same).

I'm a bit wary also of:

  • Introducing specialized variants of a block to the base blocks module, e.g. renderEmbedBlock as not inherent to the concept of a block but rather relevant only in its specific applications.
  • Introducing new filters. Or at least, it's not clear to me how some of these like blockLibrary.Embed.commonSettings are to be used. I'm struggling to find the agenda recap, but it was discussed in a JavaScript chat a few months back and there seemed to be broad reluctance to continue pushing the actions/filters pattern in JavaScript except where demonstrably beneficial / without alternative.
  • Does it actually prevent an invalidation from occurring when the plugin is deactivated? I can't see from the current code with allowing plugin authors to change the behavior of save that this wouldn't still occur.

Considering the original goal, it seems there's a lot which already exists to be able to extend the default behavior. Complemented with parts introduced here (like extraOptions), I could imagine a full story for block extensions.

One thing which hadn't occurred to me previously, but which extraOptions hinted at: A block can be extended with an attribute, and as long as that attribute has no impact on the markup (i.e. exists only within the serialized comment demarcations), its removal will never result in an invalidation. I wonder if this could be leveraged by a plugin to extend a block with its own attributes to be considered entirely server-side. If the plugin is ever deactivated, the block won't become invalidated, but it will "forget" those attribute values on subsequent saves (this could be argued to be a bad thing in the same vein as why block invalidation exists in the first place, but provides some option of a fallback path).

Considering the given YouTube options example, I might wonder then if it's a matter of:

  1. Override the attributes: The plugin uses the blocks.registerBlockType filter to add a new autoplay attribute (optionally "namespaced" to the plugin under some plugin-specific object attribute jetpackOptions).
wp.hooks.addFilter( 'blocks.registerBlockType', 'jetpack/youtube-autoplay', ( settings, name ) => {
	if ( name !== 'core-embed/youtube' ) {
		return settings;
	}

	return {
		...settings,
		attributes: {
			...settings.attributes,
			autoplay: {
				type: 'boolean',
				default: false,
			},
		},
	};
} );
  1. Override the edit implementation: The plugin uses the editor.BlockEdit filter to add additional inspector controls:
wp.hooks.addFilter(
	'editor.BlockEdit',
	'jetpack/autoplay-inspector-controls',
	( BlockEdit ) => ( props ) => (
		<Fragment>
			<BlockEdit { ...props } />
			<InspectorControls>
				{ /* Autoplay Controls */ }
			</InspectorControls>
		</Fragment>
	)
);
  1. Override the rendered output: The plugin uses the PHP render_block filter (or pre_render_block) to replace the output. Notably, you might reference Try: Provider-specific embed block settings (Tweet theme option) #3032 in considering to provide arguments through an [embed] shortcode to be processed/filtered on the actual oEmbed fetch (source).
function jetpack_add_youtube_autoplay( $content, $block ) {
	if ( 'core-embed/youtube' !== $block['blockName'] ) {
		return $content;
	}

	return sprintf(
		'[embed args="%s"]%s[/embed]',
		http_build_query( array( 'autoplay' => $block['attributes']['autoplay'] ) ),
		$content
	);
}
add_filter( 'render_block', 'jetpack_add_youtube_autoplay', 10, 2 );

function jetpack_youtube_block_add_oembed_parameters( $provider, $url, $args ) {
	if ( ! empty( $args['args'] ) ) {
		wp_parse_str( $args['args'], $extra_args );
		$provider = add_query_arg( $extra_args, $provider );
	}
	return $provider;
}
add_filter( 'oembed_fetch_url', 'jetpack_youtube_block_add_oembed_parameters', 10, 3 );

The trickiest part of all of this seems to be managing the oEmbed processing, both as it impacts preview in the editor, and in considering additional arguments for the fetch. The snippet above with the oembed_fetch_url filter seems like something which ought to be considered a general enhancement for core (i.e. including some additional arguments to the fetched URL from an embed, even if necessary to use the shortcode).

The editor preview is a bit more challenging mostly in how the proxy endpoint currently only supports to receive the URL, and isn't aware of whether that request is coming from the block editor, or the specific context of the block for which it is being requested (including the block's attributes, where autoplay would exist). I might consider one of two enhancements:

  • The block editor sends with its preview request all the attributes of the block (as query arguments?). The plugin could then consider these query arguments in some existing filter run during the oEmbed processing (oembed_result, oembed_fetch_url).
  • The oEmbed proxy endpoint could optionally accept the [embed] shortcode. Then, perhaps there's a way to reuse logic like in the snippet above jetpack_add_youtube_autoplay to generate an embed shortcode (including the autoplay arguments) to be considered by the proxy endpoint. I wonder if it's the sort of thing where the embed block in the editor uses ServerSideRender to generate its preview, then the above extension example would apply automatically.

The key with all of this is that with these extensions, nothing about the generated markup of the block changes (not considering the comment attributes, since these are ignored for validation). This is critical to ensure that the content is future-proof against plugin deactivations.

@notnownikki
Copy link
Member Author

@aduth I agree with a lot of that, but unfortunately it can't be implemented with how the block registration filters get run.

There's an old issue #9757 which highlights that the block registration filters sometimes get run too early for plugins to use them. If we look at how assets are loaded when Jetpack is active (issue raised here in Jetpack but closed because Jetpack only reveals the symptom, it's not the cause Automattic/jetpack#11464 ), plugins that rely on those block registration filters to modify settings or inject their own sometimes don't get chance to run, because Jetpack requires wp-blocks, which loads, runs the filters, and subsequent plugins that load are too late to use them. That's also the reason this PR has been open so long, the e2e test that tested the extend-embeds plugin was failing when Jetpack was active, because the filters had been run by the time the plugin loaded. That issue isn't specific to Jetpack - any plugin that depends on code that runs filters is going to make those filters unavailable for plugins that load later.

The other side of things is that for previewing YouTube, oEmbed does not support the options that this PR enables. Start time, related videos, etc. are not supported by YouTube embed, so we can't pass those options to any of the oembed_ calls because YouTube does nothing with them. We have to either modify the HTML returned by oEmbed, or implement it ourselves.

All of this becomes easy to manage, and a large part of this PR's code disappears, if we can rely on filters running.

@notnownikki notnownikki added the [Status] Blocked Used to indicate that a current effort isn't able to move forward label Apr 17, 2019
@notnownikki
Copy link
Member Author

Closing this one out as it would require a large reworking if/when filters are replaced with something else.

@youknowriad youknowriad deleted the update/embed-block-extra-options branch May 27, 2020 17:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
[Block] Embed Affects the Embed Block [Feature] Extensibility The ability to extend blocks or the editing experience Needs Technical Feedback Needs testing from a developer perspective. [Status] Blocked Used to indicate that a current effort isn't able to move forward [Status] In Progress Tracking issues with work in progress
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants