Skip to content

Commit a9b3e39

Browse files
committed
feat(SLB-468): cardinality validator improvement
Extend the cardinality validator to ensure content of children blocks is checked, not just the count
1 parent cc98ded commit a9b3e39

File tree

3 files changed

+495
-13
lines changed

3 files changed

+495
-13
lines changed

packages/@amazeelabs/silverback-gutenberg/drupal/silverback_gutenberg/src/GutenbergValidation/GutenbergCardinalityValidatorTrait.php

Lines changed: 168 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace Drupal\silverback_gutenberg\GutenbergValidation;
44

5+
use Drupal\Component\Utility\Html;
56
use Drupal\Core\StringTranslation\TranslatableMarkup;
67

78
/**
@@ -73,31 +74,39 @@ public function validateCardinality(array $block, array $expected_children): arr
7374
return $this->validateEmptyInnerBlocks($expected_children);
7475
}
7576

76-
// Count blocks, then check if the quantity for each is correct.
77+
// Count blocks and keep references for additional validations.
7778
$countInnerBlockInstances = [];
79+
$innerBlocksByName = [];
7880
foreach ($block['innerBlocks'] as $innerBlock) {
79-
if (!isset($countInnerBlockInstances[$innerBlock['blockName']])) {
80-
$countInnerBlockInstances[$innerBlock['blockName']] = 0;
81+
$blockName = $innerBlock['blockName'] ?? NULL;
82+
if ($blockName === NULL) {
83+
continue;
8184
}
82-
$countInnerBlockInstances[$innerBlock['blockName']]++;
85+
if (!isset($countInnerBlockInstances[$blockName])) {
86+
$countInnerBlockInstances[$blockName] = 0;
87+
}
88+
$countInnerBlockInstances[$blockName]++;
89+
$innerBlocksByName[$blockName][] = $innerBlock;
8390
}
8491

8592
foreach ($expected_children as $child) {
86-
if (!isset($countInnerBlockInstances[$child['blockName']]) && $child['min'] > 0) {
93+
$blockName = $child['blockName'];
94+
$childBlocks = $innerBlocksByName[$blockName] ?? [];
95+
96+
if (!isset($countInnerBlockInstances[$blockName]) && $child['min'] > 0) {
8797
$message = $this->getExpectedQuantityErrorMessage($child);
8898
return [
8999
'is_valid' => FALSE,
90100
'message' => $message,
91101
];
92102
}
93103
// Minimum is set to 0, so we don't care if the block is not present.
94-
if (!isset($countInnerBlockInstances[$child['blockName']]) && $child['min'] === 0) {
95-
return [
96-
'is_valid' => TRUE,
97-
'message' => '',
98-
];
104+
if (!isset($countInnerBlockInstances[$blockName]) && $child['min'] === 0) {
105+
continue;
99106
}
100-
if ($countInnerBlockInstances[$child['blockName']] < $child['min']) {
107+
108+
$blockCount = $countInnerBlockInstances[$blockName] ?? 0;
109+
if ($blockCount < $child['min']) {
101110
return [
102111
'is_valid' => FALSE,
103112
'message' => \Drupal::translation()->formatPlural($child['min'],
@@ -109,7 +118,7 @@ public function validateCardinality(array $block, array $expected_children): arr
109118
]),
110119
];
111120
}
112-
if ($child['max'] !== GutenbergCardinalityValidatorInterface::CARDINALITY_UNLIMITED && $countInnerBlockInstances[$child['blockName']] > $child['max']) {
121+
if ($child['max'] !== GutenbergCardinalityValidatorInterface::CARDINALITY_UNLIMITED && $blockCount > $child['max']) {
113122
return [
114123
'is_valid' => FALSE,
115124
'message' => \Drupal::translation()->formatPlural($child['max'],
@@ -121,6 +130,13 @@ public function validateCardinality(array $block, array $expected_children): arr
121130
]),
122131
];
123132
}
133+
134+
if (!empty($childBlocks) && !$this->hasPopulatedBlock($childBlocks)) {
135+
return [
136+
'is_valid' => FALSE,
137+
'message' => $this->getMissingContentErrorMessage($child),
138+
];
139+
}
124140
}
125141

126142
return [
@@ -172,7 +188,18 @@ private function validateEmptyInnerBlocks (array $expected_children): array {
172188
private function validateAnyInnerBlocks(array $inner_blocks, array $expected_children): array {
173189
$min = $expected_children['min'];
174190
$max = $expected_children['max'];
175-
$count = count($inner_blocks['innerBlocks']);
191+
$innerBlockList = $inner_blocks['innerBlocks'] ?? [];
192+
$count = count($innerBlockList);
193+
if (
194+
$count > 0 &&
195+
!$this->hasPopulatedBlock($innerBlockList) &&
196+
!$this->isBlockPopulated($inner_blocks)
197+
) {
198+
return [
199+
'is_valid' => FALSE,
200+
'message' => $this->getMissingContentErrorMessage(NULL),
201+
];
202+
}
176203
if ($count < $min) {
177204
return [
178205
'is_valid' => FALSE,
@@ -218,4 +245,132 @@ private function getExpectedQuantityErrorMessage(array $child_block): string|Tra
218245
return $result;
219246
}
220247

248+
private function blockHasMeaningfulHtml(array $block): bool {
249+
$innerHTML = $block['innerHTML'] ?? '';
250+
if (is_string($innerHTML) && $this->stringContainsContent($innerHTML)) {
251+
return TRUE;
252+
}
253+
254+
if (!empty($block['innerContent']) && is_array($block['innerContent'])) {
255+
foreach ($block['innerContent'] as $chunk) {
256+
if (is_string($chunk) && $this->stringContainsContent($chunk)) {
257+
return TRUE;
258+
}
259+
}
260+
}
261+
262+
return FALSE;
263+
}
264+
265+
private function stringContainsContent(string $value): bool {
266+
$decoded = Html::decodeEntities($value);
267+
$stripped = trim(strip_tags($decoded));
268+
if ($stripped !== '') {
269+
return TRUE;
270+
}
271+
272+
return (bool) preg_match('/<(img|video|audio|iframe|svg|figure|source|embed|object|picture)\b/i', $value);
273+
}
274+
275+
private function blockHasMeaningfulAttributes(array $block): bool {
276+
$attrs = $block['attrs'] ?? [];
277+
if (empty($attrs)) {
278+
return FALSE;
279+
}
280+
281+
foreach ($attrs as $value) {
282+
if ($this->isMeaningfulValue($value)) {
283+
return TRUE;
284+
}
285+
}
286+
287+
return FALSE;
288+
}
289+
290+
private function isMeaningfulValue(mixed $value): bool {
291+
if ($value === NULL) {
292+
return FALSE;
293+
}
294+
if (is_string($value)) {
295+
return trim($value) !== '';
296+
}
297+
if (is_bool($value)) {
298+
return $value;
299+
}
300+
if (is_numeric($value)) {
301+
return TRUE;
302+
}
303+
if (is_array($value)) {
304+
foreach ($value as $item) {
305+
if ($this->isMeaningfulValue($item)) {
306+
return TRUE;
307+
}
308+
}
309+
return FALSE;
310+
}
311+
312+
return TRUE;
313+
}
314+
315+
/**
316+
* Checks if any block in the supplied list is populated.
317+
*/
318+
private function hasPopulatedBlock(array $blocks): bool {
319+
foreach ($blocks as $block) {
320+
if (is_array($block) && $this->isBlockPopulated($block)) {
321+
return TRUE;
322+
}
323+
}
324+
return FALSE;
325+
}
326+
327+
private function isBlockPopulated(array $block): bool {
328+
$evaluated = FALSE;
329+
330+
if (array_key_exists('innerHTML', $block) || array_key_exists('innerContent', $block)) {
331+
$evaluated = TRUE;
332+
if ($this->blockHasMeaningfulHtml($block)) {
333+
return TRUE;
334+
}
335+
}
336+
337+
if (array_key_exists('attrs', $block)) {
338+
$evaluated = TRUE;
339+
if ($this->blockHasMeaningfulAttributes($block)) {
340+
return TRUE;
341+
}
342+
}
343+
344+
if (!empty($block['innerBlocks']) && is_array($block['innerBlocks'])) {
345+
$evaluated = TRUE;
346+
foreach ($block['innerBlocks'] as $innerBlock) {
347+
if (is_array($innerBlock) && $this->isBlockPopulated($innerBlock)) {
348+
return TRUE;
349+
}
350+
}
351+
}
352+
353+
if (!$evaluated) {
354+
return TRUE;
355+
}
356+
357+
return FALSE;
358+
}
359+
360+
private function getMissingContentErrorMessage(?array $child_block): string|TranslatableMarkup {
361+
$messageSuffix = t('content or attributes.');
362+
363+
if (!empty($child_block)) {
364+
$messageParams = [
365+
'%label' => $child_block['blockLabel'],
366+
'@message_suffix' => $messageSuffix,
367+
];
368+
return t('%label: block must contain @message_suffix', $messageParams);
369+
}
370+
371+
return t('Block must contain @message_suffix', [
372+
'@message_suffix' => $messageSuffix,
373+
]);
374+
}
375+
221376
}

0 commit comments

Comments
 (0)