@amazeelabs/silverback-gutenberg 2.6.3 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,17 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # [2.7.0](https://github.com/amazeeio-solutions/silverback-template/compare/@amazeelabs/silverback-gutenberg@2.6.3...@amazeelabs/silverback-gutenberg@2.7.0) (2025-12-09)
7
+
8
+
9
+ ### Features
10
+
11
+ * **SLB-468:** cardinality validator improvement ([a9b3e39](https://github.com/amazeeio-solutions/silverback-template/commit/a9b3e394e4567e2c721c8a65b69e11a2c544040a))
12
+
13
+
14
+
15
+
16
+
6
17
  ## [2.6.3](https://github.com/AmazeeLabs/silverback-template/compare/@amazeelabs/silverback-gutenberg@2.6.2...@amazeelabs/silverback-gutenberg@2.6.3) (2025-09-26)
7
18
 
8
19
  **Note:** Version bump only for package @amazeelabs/silverback-gutenberg
@@ -2,6 +2,7 @@
2
2
 
3
3
  namespace Drupal\silverback_gutenberg\GutenbergValidation;
4
4
 
5
+ use Drupal\Component\Utility\Html;
5
6
  use Drupal\Core\StringTranslation\TranslatableMarkup;
6
7
 
7
8
  /**
@@ -73,17 +74,26 @@ trait GutenbergCardinalityValidatorTrait {
73
74
  return $this->validateEmptyInnerBlocks($expected_children);
74
75
  }
75
76
 
76
- // Count blocks, then check if the quantity for each is correct.
77
+ // Count blocks and keep references for additional validations.
77
78
  $countInnerBlockInstances = [];
79
+ $innerBlocksByName = [];
78
80
  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;
81
84
  }
82
- $countInnerBlockInstances[$innerBlock['blockName']]++;
85
+ if (!isset($countInnerBlockInstances[$blockName])) {
86
+ $countInnerBlockInstances[$blockName] = 0;
87
+ }
88
+ $countInnerBlockInstances[$blockName]++;
89
+ $innerBlocksByName[$blockName][] = $innerBlock;
83
90
  }
84
91
 
85
92
  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) {
87
97
  $message = $this->getExpectedQuantityErrorMessage($child);
88
98
  return [
89
99
  'is_valid' => FALSE,
@@ -91,13 +101,12 @@ trait GutenbergCardinalityValidatorTrait {
91
101
  ];
92
102
  }
93
103
  // 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;
99
106
  }
100
- if ($countInnerBlockInstances[$child['blockName']] < $child['min']) {
107
+
108
+ $blockCount = $countInnerBlockInstances[$blockName] ?? 0;
109
+ if ($blockCount < $child['min']) {
101
110
  return [
102
111
  'is_valid' => FALSE,
103
112
  'message' => \Drupal::translation()->formatPlural($child['min'],
@@ -109,7 +118,7 @@ trait GutenbergCardinalityValidatorTrait {
109
118
  ]),
110
119
  ];
111
120
  }
112
- if ($child['max'] !== GutenbergCardinalityValidatorInterface::CARDINALITY_UNLIMITED && $countInnerBlockInstances[$child['blockName']] > $child['max']) {
121
+ if ($child['max'] !== GutenbergCardinalityValidatorInterface::CARDINALITY_UNLIMITED && $blockCount > $child['max']) {
113
122
  return [
114
123
  'is_valid' => FALSE,
115
124
  'message' => \Drupal::translation()->formatPlural($child['max'],
@@ -121,6 +130,13 @@ trait GutenbergCardinalityValidatorTrait {
121
130
  ]),
122
131
  ];
123
132
  }
133
+
134
+ if (!empty($childBlocks) && !$this->hasPopulatedBlock($childBlocks)) {
135
+ return [
136
+ 'is_valid' => FALSE,
137
+ 'message' => $this->getMissingContentErrorMessage($child),
138
+ ];
139
+ }
124
140
  }
125
141
 
126
142
  return [
@@ -172,7 +188,18 @@ trait GutenbergCardinalityValidatorTrait {
172
188
  private function validateAnyInnerBlocks(array $inner_blocks, array $expected_children): array {
173
189
  $min = $expected_children['min'];
174
190
  $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
+ }
176
203
  if ($count < $min) {
177
204
  return [
178
205
  'is_valid' => FALSE,
@@ -218,4 +245,132 @@ trait GutenbergCardinalityValidatorTrait {
218
245
  return $result;
219
246
  }
220
247
 
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
+
221
376
  }
@@ -1534,4 +1534,289 @@ class BlockValidatorCardinalityTest extends UnitTestCase {
1534
1534
  );
1535
1535
  }
1536
1536
 
1537
+ public function testRequirePopulatedBlocksWithHtml() {
1538
+ $expectedChildren = [
1539
+ [
1540
+ 'blockName' => 'core/paragraph',
1541
+ 'blockLabel' => t('Paragraph'),
1542
+ 'min' => 1,
1543
+ 'max' => GutenbergCardinalityValidatorInterface::CARDINALITY_UNLIMITED,
1544
+ ],
1545
+ ];
1546
+
1547
+ $validBlock = [
1548
+ 'blockName' => 'core/column',
1549
+ 'innerBlocks' => [
1550
+ [
1551
+ 'blockName' => 'core/paragraph',
1552
+ 'innerHTML' => '<p>Content</p>',
1553
+ 'innerContent' => ['<p>Content</p>'],
1554
+ ],
1555
+ ],
1556
+ ];
1557
+ $invalidBlock = [
1558
+ 'blockName' => 'core/column',
1559
+ 'innerBlocks' => [
1560
+ [
1561
+ 'blockName' => 'core/paragraph',
1562
+ 'innerHTML' => '<p></p>',
1563
+ 'innerContent' => ['<p></p>'],
1564
+ ],
1565
+ ],
1566
+ ];
1567
+
1568
+ $this->assertEquals(
1569
+ [
1570
+ 'is_valid' => TRUE,
1571
+ 'message' => '',
1572
+ ],
1573
+ $this->validateCardinality($validBlock, $expectedChildren),
1574
+ );
1575
+ $this->assertEquals(
1576
+ [
1577
+ 'is_valid' => FALSE,
1578
+ 'message' => '<em class="placeholder">Paragraph</em>: block must contain content or attributes.',
1579
+ ],
1580
+ $this->validateCardinality($invalidBlock, $expectedChildren),
1581
+ );
1582
+ }
1583
+
1584
+ public function testMixedContentBlocksAreValid() {
1585
+ $expectedChildren = [
1586
+ [
1587
+ 'blockName' => 'core/paragraph',
1588
+ 'blockLabel' => t('Paragraph'),
1589
+ 'min' => 1,
1590
+ 'max' => 3,
1591
+ ],
1592
+ ];
1593
+
1594
+ $block = [
1595
+ 'blockName' => 'core/column',
1596
+ 'innerBlocks' => [
1597
+ [
1598
+ 'blockName' => 'core/paragraph',
1599
+ 'innerHTML' => '<p></p>',
1600
+ 'innerContent' => ['<p></p>'],
1601
+ ],
1602
+ [
1603
+ 'blockName' => 'core/paragraph',
1604
+ 'innerHTML' => '<p>Content</p>',
1605
+ 'innerContent' => ['<p>Content</p>'],
1606
+ ],
1607
+ ],
1608
+ ];
1609
+
1610
+ $this->assertEquals(
1611
+ [
1612
+ 'is_valid' => TRUE,
1613
+ 'message' => '',
1614
+ ],
1615
+ $this->validateCardinality($block, $expectedChildren),
1616
+ );
1617
+ }
1618
+
1619
+ public function testRequirePopulatedBlocksWithAttributes() {
1620
+ $expectedChildren = [
1621
+ [
1622
+ 'blockName' => 'core/embed',
1623
+ 'blockLabel' => t('Embed'),
1624
+ 'min' => 1,
1625
+ 'max' => 1,
1626
+ ],
1627
+ ];
1628
+
1629
+ $validBlock = [
1630
+ 'blockName' => 'core/column',
1631
+ 'innerBlocks' => [
1632
+ [
1633
+ 'blockName' => 'core/embed',
1634
+ 'innerHTML' => '',
1635
+ 'innerContent' => [],
1636
+ 'attrs' => [
1637
+ 'url' => 'https://example.com/video',
1638
+ ],
1639
+ ],
1640
+ ],
1641
+ ];
1642
+ $invalidBlock = [
1643
+ 'blockName' => 'core/column',
1644
+ 'innerBlocks' => [
1645
+ [
1646
+ 'blockName' => 'core/embed',
1647
+ 'innerHTML' => '',
1648
+ 'innerContent' => [],
1649
+ 'attrs' => [],
1650
+ ],
1651
+ ],
1652
+ ];
1653
+
1654
+ $this->assertEquals(
1655
+ [
1656
+ 'is_valid' => TRUE,
1657
+ 'message' => '',
1658
+ ],
1659
+ $this->validateCardinality($validBlock, $expectedChildren),
1660
+ );
1661
+ $this->assertEquals(
1662
+ [
1663
+ 'is_valid' => FALSE,
1664
+ 'message' => '<em class="placeholder">Embed</em>: block must contain content or attributes.',
1665
+ ],
1666
+ $this->validateCardinality($invalidBlock, $expectedChildren),
1667
+ );
1668
+ }
1669
+
1670
+ public function testAnyBlockTypeRequiresContent() {
1671
+ $expectedChildren = [
1672
+ 'validationType' => GutenbergCardinalityValidatorInterface::CARDINALITY_ANY,
1673
+ 'min' => 1,
1674
+ 'max' => 2,
1675
+ ];
1676
+
1677
+ $validBlock = [
1678
+ 'blockName' => 'core/group',
1679
+ 'innerBlocks' => [
1680
+ [
1681
+ 'blockName' => 'core/paragraph',
1682
+ 'innerHTML' => '<p>Content</p>',
1683
+ 'innerContent' => ['<p>Content</p>'],
1684
+ ],
1685
+ ],
1686
+ ];
1687
+ $invalidBlock = [
1688
+ 'blockName' => 'core/group',
1689
+ 'innerBlocks' => [
1690
+ [
1691
+ 'blockName' => 'core/paragraph',
1692
+ 'innerHTML' => '<p></p>',
1693
+ 'innerContent' => ['<p></p>'],
1694
+ ],
1695
+ ],
1696
+ ];
1697
+
1698
+ $this->assertEquals(
1699
+ [
1700
+ 'is_valid' => TRUE,
1701
+ 'message' => '',
1702
+ ],
1703
+ $this->validateCardinality($validBlock, $expectedChildren),
1704
+ );
1705
+ $this->assertEquals(
1706
+ [
1707
+ 'is_valid' => FALSE,
1708
+ 'message' => 'Block must contain content or attributes.',
1709
+ ],
1710
+ $this->validateCardinality($invalidBlock, $expectedChildren),
1711
+ );
1712
+ $this->assertEquals(
1713
+ [
1714
+ 'is_valid' => FALSE,
1715
+ 'message' => 'At least 1 block is required.',
1716
+ ],
1717
+ $this->validateCardinality([
1718
+ 'blockName' => 'core/group',
1719
+ 'innerBlocks' => [],
1720
+ ], $expectedChildren),
1721
+ );
1722
+ }
1723
+
1724
+ public function testAnyBlockTypeAcceptsNestedContent() {
1725
+ $expectedChildren = [
1726
+ 'validationType' => GutenbergCardinalityValidatorInterface::CARDINALITY_ANY,
1727
+ 'min' => 1,
1728
+ 'max' => 2,
1729
+ ];
1730
+
1731
+ $block = [
1732
+ 'blockName' => 'core/group',
1733
+ 'innerBlocks' => [
1734
+ [
1735
+ 'blockName' => 'custom/accordion-item-text',
1736
+ 'innerHTML' => '',
1737
+ 'innerContent' => [],
1738
+ 'innerBlocks' => [
1739
+ [
1740
+ 'blockName' => 'core/paragraph',
1741
+ 'innerHTML' => '<p>Content</p>',
1742
+ 'innerContent' => ['<p>Content</p>'],
1743
+ ],
1744
+ ],
1745
+ ],
1746
+ ],
1747
+ ];
1748
+
1749
+ $this->assertEquals(
1750
+ [
1751
+ 'is_valid' => TRUE,
1752
+ 'message' => '',
1753
+ ],
1754
+ $this->validateCardinality($block, $expectedChildren),
1755
+ );
1756
+ }
1757
+
1758
+ public function testBlockWithOnlyNestedContentSucceedsWhenDescendantHasContent() {
1759
+ $expectedChildren = [
1760
+ [
1761
+ 'blockName' => 'custom/accordion-item-text',
1762
+ 'blockLabel' => t('Accordion item'),
1763
+ 'min' => 1,
1764
+ 'max' => 1,
1765
+ ],
1766
+ ];
1767
+
1768
+ $validBlock = [
1769
+ 'blockName' => 'custom/accordion',
1770
+ 'innerBlocks' => [
1771
+ [
1772
+ 'blockName' => 'custom/accordion-item-text',
1773
+ 'innerHTML' => '',
1774
+ 'innerContent' => [],
1775
+ 'attrs' => [],
1776
+ 'innerBlocks' => [
1777
+ [
1778
+ 'blockName' => 'core/paragraph',
1779
+ 'innerHTML' => '<p>Some content</p>',
1780
+ 'innerContent' => ['<p>Some content</p>'],
1781
+ ],
1782
+ ],
1783
+ ],
1784
+ ],
1785
+ ];
1786
+
1787
+ $invalidBlock = [
1788
+ 'blockName' => 'custom/accordion',
1789
+ 'innerBlocks' => [
1790
+ [
1791
+ 'blockName' => 'custom/accordion-item-text',
1792
+ 'innerHTML' => '',
1793
+ 'innerContent' => [],
1794
+ 'attrs' => [],
1795
+ 'innerBlocks' => [
1796
+ [
1797
+ 'blockName' => 'core/paragraph',
1798
+ 'innerHTML' => '<p></p>',
1799
+ 'innerContent' => ['<p></p>'],
1800
+ ],
1801
+ ],
1802
+ ],
1803
+ ],
1804
+ ];
1805
+
1806
+ $this->assertEquals(
1807
+ [
1808
+ 'is_valid' => TRUE,
1809
+ 'message' => '',
1810
+ ],
1811
+ $this->validateCardinality($validBlock, $expectedChildren),
1812
+ );
1813
+ $this->assertEquals(
1814
+ [
1815
+ 'is_valid' => FALSE,
1816
+ 'message' => '<em class="placeholder">Accordion item</em>: block must contain content or attributes.',
1817
+ ],
1818
+ $this->validateCardinality($invalidBlock, $expectedChildren),
1819
+ );
1820
+ }
1821
+
1537
1822
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amazeelabs/silverback-gutenberg",
3
- "version": "2.6.3",
3
+ "version": "2.7.0",
4
4
  "scripts": {
5
5
  "precommit": "pnpm precommit:fix && pnpm precommit:check && pnpm test:unit",
6
6
  "precommit:fix": "pnpm --filter @custom/cms precommit:fix packages/@amazeelabs/silverback-gutenberg/drupal",
@@ -12,5 +12,5 @@
12
12
  "publishConfig": {
13
13
  "access": "public"
14
14
  },
15
- "gitHead": "00e375dc86982224214745341087636b28d971af"
15
+ "gitHead": "178ee4efaab57bf5d31c7d497908839489394b3e"
16
16
  }