Helps integrating Drupal's Gutenberg module into Silverback projects.
This module provides a set of GraphQL directives that are picked up by the
amazeelabs/graphql_directives
module. This allows to easily expose Gutenberg
blocks through a GraphQL schema.
Parse the raw output of a field at a given path and expose its content as
structured block data. Allows to define aggregated
and ignored
blocks:
aggregated
: All subsequent blocks of these types will be merged into onecore/paragraph
block. In Gutenberg, standard HTML elements like lists, headings or tables are represented as separate blocks. This directive allows to merge them into one and simplify handling in the frontend.ignored
: Blocks of these types will be ignored. This is useful for blocks that are not relevant for the frontend, like thecore/group
block. The block will simply not part of the result and any children are spread where the block was.
type Page {
title: String! @resolveProperty(path: "title.value")
content: [Blocks!]!
@resolveEditorBlocks(
path: "body.value"
aggregated: ["core/paragraph", "core/list"]
ignored: ["core/group"]
)
}
Retrieve the type of gutenberg block. Useful for resolving types of a block union.
union Blocks @resolveEditorBlockType = Paragraph | Heading | List
Extract inner markup of a block that was provided by the user via rich HTML.
type Text @type(id: "core/paragraph") {
content: String @resolveEditorBlockMarkup
}
Retrieve a specific attribute, stored in a block.
type Figure @type(id: "custom/figure") {
caption: String @resolveEditorBlockAttribute(key: "caption")
}
Resolve a media entity referenced in a block.
type Figure @type(id: "custom/figure") {
image: Image @resolveEditorBlockMedia
}
Extract all child blocks of a given block.
type Columns @type(id: "custom/columns") {
columns: [ColumnBlocks!]! @resolveEditorBlockChildren
}
The main idea is that all links added to a Gutenberg page are
- kept in internal format (e.g.
/node/123
) when saved to Drupal database - processed to language-prefixed aliased form (e.g.
/en/my-page
) when- they are displayed in Gutenberg editor
- they are sent out via GraphQL
This helps to
- always display fresh path aliases
- be sure that the language prefix is correct
- update link URLs when translating content (e.g.
/en/my-page
will become/fr/ma-page
automatically because it's/node/123
under the hood) - keep track of entity usage (TBD)
The module does most of the things automatically. Yet there are few things developers should take care of.
First, custom Gutenberg blocks which store links in block attributes should
implement hook_silverback_gutenberg_link_processor_block_attrs_alter
. See
silverback_gutenberg.api.php
for an example.
Next, GraphQL resolvers which parse Gutenberg code should call
LinkProcessor::processLinks
before parsing the blocks. See
DataProducer/Gutenberg.php
for an example.
Custom validator plugins can be created in
src/Plugin/Validation/GutenbergValidator
Example: to validate that an email is valid and required.
- the block name is
custom/my-block
- the field attribute is
email
and the labelEmail
<?php
namespace Drupal\custom_gutenberg\Plugin\Validation\GutenbergValidator;
use Drupal\silverback_gutenberg\GutenbergValidation\GutenbergValidatorBase;
use Drupal\Core\StringTranslation\StringTranslationTrait;
/**
* @GutenbergValidator(
* id="my_block_validator",
* label = @Translation("My block validator")
* )
*/
class MyBlockValidator extends GutenbergValidatorBase {
use StringTranslationTrait;
/**
* {@inheritDoc}
*/
public function applies(array $block) {
return $block['blockName'] === 'custom/my-block';
}
/**
* {@inheritDoc}
*/
public function validatedFields(array $block = []) {
return [
'email' => [
'field_label' => $this->t('Email'),
'rules' => ['required', 'email'],
],
];
}
}
Perform custom block validation logic then return the result.
public function validateContent(array $block) {
$isValid = TRUE;
// Custom validation logic.
// (...)
if (!$isValid) {
return [
'is_valid' => FALSE,
'message' => 'Message',
];
}
// Passes validation.
return [
'is_valid' => TRUE,
'message' => '',
];
}
Uses the validateContent()
method as a wrapper, with the cardinality validator
trait.
use GutenbergCardinalityValidatorTrait;
Validate a given block type for inner blocks.
public function validateContent(array $block) {
$expectedChildren = [
[
'blockName' => 'custom/teaser',
'blockLabel' => $this->t('Teaser'),
'min' => 1,
'max' => 2,
],
];
return $this->validateCardinality($block, $expectedChildren);
}
Validate any kind of block type for inner blocks.
public function validateContent(array $block) {
$expectedChildren = [
'validationType' => GutenbergCardinalityValidatorInterface::CARDINALITY_ANY,
'min' => 0,
'max' => 1,
];
return $this->validateCardinality($block, $expectedChildren);
}
Validate a minimum with no maximum.
public function validateContent(array $block) {
$expectedChildren = [
[
'blockName' => 'custom/teaser',
'blockLabel' => $this->t('Teaser'),
'min' => 1,
'max' => GutenbergCardinalityValidatorInterface::CARDINALITY_UNLIMITED,
],
];
return $this->validateCardinality($block, $expectedChildren);
}
Client side cardinality validation can also be done in custom blocks with this pattern.
- use
getBlockCount
- remove the
InnerBlocks
appender when the limit is reached
/* global Drupal */
import { registerBlockType } from 'wordpress__blocks';
import { InnerBlocks } from 'wordpress__block-editor';
import { useSelect } from 'wordpress__data';
// @ts-ignore
const __ = Drupal.t;
const MAX_BLOCKS: number = 1;
registerBlockType('custom/my-block', {
title: __('My Block'),
icon: 'location',
category: 'layout',
attributes: {},
edit: (props) => {
const { blockCount } = useSelect((select) => ({
blockCount: select('core/block-editor').getBlockCount(props.clientId),
}));
return (
<div>
<InnerBlocks
templateLock={false}
renderAppender={() => {
if (blockCount >= MAX_BLOCKS) {
return null;
} else {
return <InnerBlocks.ButtonBlockAppender />;
}
}}
allowedBlocks={['core/block']}
template={[]}
/>
</div>
);
},
save: () => {
return <InnerBlocks.Content />;
},
});
To enable the integration:
-
Enable the linkit module and create a linkit profile with
gutenberg
machine nameThis brings
- Basic linkit integration
- Improved suggestion labels (e.g.
Content: Page
,Media: PDF
instead ofpage
,pdf
)
-
Add
Silverback:
prefixed matchers to the profileHow they differ from the default linkit matchers
-
Suggestions order is done by the position of the search string in the label. For example, if you search for "best", the order will be:
- Best in class
- The best choice
- Always choose best
-
Improved display of translated content. By default, linkit searches through all content translations but displays suggestions in the current language. Which can be confusing. The Silverback matchers changes this a bit. If the displayed item does not contain the prompt, a translation containing the prompt will be added in the brackets. For example, if you search for "gift" with the English UI, the suggestions will look like this:
- Gift for a friend
- Poison for an enemy (Gift für einen Feind)
-
-
To use a different profile when using the LinkControl component, add the machine name of the profile to the
subtype
query parameter in the component propsuggestionsQuery
like below, where the custom linkit profile is calledcustomer
.
<DrupalLinkControl
searchInputPlaceholder={__('Target page')}
value={{
url: props.attributes.linkUrl,
}}
settings={[]}
suggestionsQuery={{
// Use the custom linkit profile called customer.
subtype: 'customer',
}}
onChange={(link) => {
props.setAttributes({
linkUrl: link.url,
});
}}
/>