Subject: [PATCH] Frontend-rendered layout-builder previews
---
Index: modules/lupus_decoupled_layout_builder/lupus_decoupled_layout_builder.services.yml
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/modules/lupus_decoupled_layout_builder/lupus_decoupled_layout_builder.services.yml b/modules/lupus_decoupled_layout_builder/lupus_decoupled_layout_builder.services.yml
--- a/modules/lupus_decoupled_layout_builder/lupus_decoupled_layout_builder.services.yml	(revision 5d718c2794021a3ece415a83bc5aaf15511580ac)
+++ b/modules/lupus_decoupled_layout_builder/lupus_decoupled_layout_builder.services.yml	(date 1729713662334)
@@ -1,4 +1,9 @@
 services:
+  access_check.entity.lupus_decoupled_layout_builder_access:
+    class: Drupal\lupus_decoupled_layout_builder\Access\LupusDecoupledLayoutBuilderAccessCheck
+    tags:
+      - { name: access_check, applies_to: _lupus_decoupled_layout_builder_access }
+    arguments: ['@plugin.manager.layout_builder.section_storage']
   lupus_decoupled_layout_builder.route_subscriber:
     class: Drupal\lupus_decoupled_layout_builder\EventSubscriber\LupusDecoupledLayoutBuilderRouteSubscriber
     tags:
Index: modules/lupus_decoupled_layout_builder/lupus_decoupled_layout_builder.routing.yml
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/modules/lupus_decoupled_layout_builder/lupus_decoupled_layout_builder.routing.yml b/modules/lupus_decoupled_layout_builder/lupus_decoupled_layout_builder.routing.yml
new file mode 100644
--- /dev/null	(date 1729713662334)
+++ b/modules/lupus_decoupled_layout_builder/lupus_decoupled_layout_builder.routing.yml	(date 1729713662334)
@@ -0,0 +1,12 @@
+## A URL to return data for a draft layout builder block.
+lupus_decoupled_layout_builder.preview_layout_builder_block:
+  path: '/layout-preview/node/{node}/block/{block_data}/{view_mode}'
+  defaults:
+    _controller: '\Drupal\lupus_decoupled_layout_builder\Controller\LupusDecoupledLayoutBuilderController::previewBlock'
+  requirements:
+    _lupus_decoupled_layout_builder_access: 'view'
+  options:
+    _admin_route: TRUE
+    parameters:
+      node:
+        type: entity:node
Index: modules/lupus_decoupled_layout_builder/lupus_decoupled_layout_builder.libraries.yml
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/modules/lupus_decoupled_layout_builder/lupus_decoupled_layout_builder.libraries.yml b/modules/lupus_decoupled_layout_builder/lupus_decoupled_layout_builder.libraries.yml
new file mode 100644
--- /dev/null	(date 1729713662334)
+++ b/modules/lupus_decoupled_layout_builder/lupus_decoupled_layout_builder.libraries.yml	(date 1729713662334)
@@ -0,0 +1,10 @@
+iframe_resizer:
+  remote: https://github.com/davidjbradshaw/iframe-resizer/
+  version: 4.3.3
+  header: true
+  license:
+    name: MIT
+    url: https://github.com/davidjbradshaw/iframe-resizer/blob/master/LICENSE
+    gpl-compatible: true
+  js:
+    https://cdn.jsdelivr.net/npm/iframe-resizer@4.3.6/js/iframeResizer.js: { type: external, minified: true }
Index: modules/lupus_decoupled_layout_builder/examples/vue3/pages/layout-preview/node/[node]/block/[uuid]/[ViewMode].vue
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/modules/lupus_decoupled_layout_builder/examples/vue3/pages/layout-preview/node/[node]/block/[uuid]/[ViewMode].vue b/modules/lupus_decoupled_layout_builder/examples/vue3/pages/layout-preview/node/[node]/block/[uuid]/[ViewMode].vue
new file mode 100644
--- /dev/null	(date 1729713662334)
+++ b/modules/lupus_decoupled_layout_builder/examples/vue3/pages/layout-preview/node/[node]/block/[uuid]/[ViewMode].vue	(date 1729713662334)
@@ -0,0 +1,14 @@
+<template>
+  <component :is="renderCustomElements(page.content)" />
+</template>
+
+<script lang="ts" setup>
+const { fetchPage, renderCustomElements } = useDrupalCe()
+const page = await fetchPage(useRoute().path, { query: useRoute().query })
+
+useHead({
+  script: [
+    { src: "https://cdn.jsdelivr.net/npm/iframe-resizer@4.3.6/js/iframeResizer.contentWindow.js" },
+  ]
+});
+</script>
Index: modules/lupus_decoupled_layout_builder/lupus_decoupled_layout_builder.module
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/modules/lupus_decoupled_layout_builder/lupus_decoupled_layout_builder.module b/modules/lupus_decoupled_layout_builder/lupus_decoupled_layout_builder.module
--- a/modules/lupus_decoupled_layout_builder/lupus_decoupled_layout_builder.module	(revision 5d718c2794021a3ece415a83bc5aaf15511580ac)
+++ b/modules/lupus_decoupled_layout_builder/lupus_decoupled_layout_builder.module	(date 1729713662334)
@@ -30,3 +30,70 @@
     $implementations['lupus_decoupled_layout_builder'] = $group;
   }
 }
+
+/**
+ * Implements hook_preprocess_HOOK() for block content previews.
+ */
+function lupus_decoupled_layout_builder_preprocess_block(&$variables) {
+  if ($variables['in_preview']) {
+    if (!empty($variables['attributes']['data-layout-block-uuid'])) {
+      $storage = \Drupal::request()->attributes->get('section_storage');
+      $storage_contexts = $storage->getContexts();
+      // Bail if there's no node context (e.g. LB default template).
+      if (empty($storage_contexts['entity'])) {
+        return;
+      }
+      $node = $storage->getContextValue('entity');
+
+      if ($node instanceof \Drupal\node\NodeInterface) {
+        $frontend_base_url = \Drupal::service('lupus_decoupled_ce_api.base_url_provider')
+          ->getFrontendBaseUrl();
+
+        // Use the blocks view mode if it has one.
+        $view_mode = isset($variables['content']['#view_mode']) ? $variables['content']['#view_mode'] : 'full';
+
+        // We want the frontend to render the title, hide the one by Drupal.
+        if ($variables['configuration']['label_display'] == 'visible') {
+          $variables['configuration']['label_display'] = 'hidden';
+          $variables['label'] = '';
+        }
+
+        // Set the data property.
+        if ($variables['base_plugin_id'] == 'inline_block') {
+          $data = 'inline_block:' . $variables['attributes']['data-layout-block-uuid'];
+        }
+        else if ($variables['base_plugin_id'] == 'field_block') {
+          if (!empty($variables['content']['#field_name'])) {
+            $data = 'field_block:' . $variables['content']['#field_name'];
+          }
+          else {
+            $field_name = explode(':', $variables['plugin_id']);
+            $data = 'field_block:' . end($field_name);
+          }
+        }
+        else {
+          $data = $variables['plugin_id'] . ':' . $variables['attributes']['data-layout-block-uuid'];
+        }
+
+        $markup = [
+          'iframe' => [
+            '#type' => 'html_tag',
+            '#tag' => 'iframe',
+            '#attributes' => [
+              'src' => $frontend_base_url . '/layout-preview/node/' . $node->id() . '/block/' . $data . '/' . $view_mode,
+              'style' => 'border:none;width:100%;height:0;overflow:hidden;',
+              'id' => 'iframe-' . $variables['attributes']['data-layout-block-uuid'],
+            ],
+          ],
+          'script' => [
+            '#type' => 'html_tag',
+            '#tag' => 'script',
+            '#value' => '<script>iFrameResize({ heightCalculationMethod:"bodyScroll" }, "#iframe-' . $variables['attributes']['data-layout-block-uuid'] . '");</script>',
+          ],
+        ];
+        $variables['content'] = ['#markup' => \Drupal::service('renderer')->render($markup)];
+        $variables['#attached']['library'][] = 'lupus_decoupled_layout_builder/iframe_resizer';
+      }
+    }
+  }
+}
\ No newline at end of file
Index: modules/lupus_decoupled_layout_builder/src/Access/LupusDecoupledLayoutBuilderAccessCheck.php
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/modules/lupus_decoupled_layout_builder/src/Access/LupusDecoupledLayoutBuilderAccessCheck.php b/modules/lupus_decoupled_layout_builder/src/Access/LupusDecoupledLayoutBuilderAccessCheck.php
new file mode 100644
--- /dev/null	(date 1729713662337)
+++ b/modules/lupus_decoupled_layout_builder/src/Access/LupusDecoupledLayoutBuilderAccessCheck.php	(date 1729713662337)
@@ -0,0 +1,78 @@
+<?php
+
+namespace Drupal\lupus_decoupled_layout_builder\Access;
+
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Cache\RefinableCacheableDependencyInterface;
+use Drupal\Core\Plugin\Context\Context;
+use Drupal\Core\Plugin\Context\ContextDefinition;
+use Drupal\Core\Plugin\Context\EntityContext;
+use Drupal\Core\Routing\Access\AccessInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\layout_builder\SectionStorage\SectionStorageManager;
+use Drupal\node\NodeInterface;
+use Symfony\Component\Routing\Route;
+
+/**
+ * Provides an access check for the Layout Builder defaults.
+ *
+ * @ingroup layout_builder_access
+ *
+ * @internal
+ *   Tagged services are internal.
+ */
+class LupusDecoupledLayoutBuilderAccessCheck implements AccessInterface {
+
+  /**
+   * The section storage.
+   *
+   * @var \Drupal\layout_builder\SectionStorage\SectionStorageManager
+   */
+  protected $sectionStorage;
+
+  /**
+   * LayoutBuilderController constructor.
+   *
+   * @param \Drupal\layout_builder\LayoutTempstoreRepository $section_storage
+   *   The section storage manager.
+   */
+  public function __construct(SectionStorageManager $section_storage) {
+    $this->sectionStorage = $section_storage;
+  }
+
+  /**
+   * Checks routing access to the layout.
+   *
+   * @param \Drupal\node\NodeInterface $node
+   *   The node the block belongs to.
+   * @param \Drupal\Core\Session\AccountInterface $account
+   *   The current user.
+   * @param \Symfony\Component\Routing\Route $route
+   *   The route to check against.
+   *
+   * @return \Drupal\Core\Access\AccessResultInterface
+   *   The access result.
+   */
+  public function access(NodeInterface $node, AccountInterface $account, Route $route) {
+    $operation = $route->getRequirement('_lupus_decoupled_layout_builder_access');
+    $section_storage = $this->sectionStorage
+      ->load('overrides', [
+        'entity' => EntityContext::fromEntity($node),
+        'view_mode' => new Context(new ContextDefinition('string'), 'full')
+      ]);
+
+    $access = $section_storage->access($operation, $account, TRUE);
+
+    // Check for the global permission unless the section storage checks
+    // permissions itself.
+    if (!$section_storage->getPluginDefinition()->get('handles_permission_check')) {
+      $access = $access->andIf(AccessResult::allowedIfHasPermission($account, 'configure any layout'));
+    }
+
+    if ($access instanceof RefinableCacheableDependencyInterface) {
+      $access->addCacheableDependency($section_storage);
+    }
+    return $access;
+  }
+
+}
Index: modules/lupus_decoupled_layout_builder/src/Controller/LupusDecoupledLayoutBuilderController.php
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/modules/lupus_decoupled_layout_builder/src/Controller/LupusDecoupledLayoutBuilderController.php b/modules/lupus_decoupled_layout_builder/src/Controller/LupusDecoupledLayoutBuilderController.php
new file mode 100644
--- /dev/null	(date 1729713662337)
+++ b/modules/lupus_decoupled_layout_builder/src/Controller/LupusDecoupledLayoutBuilderController.php	(date 1729713662337)
@@ -0,0 +1,178 @@
+<?php
+
+namespace Drupal\lupus_decoupled_layout_builder\Controller;
+
+use Drupal\Core\Block\BlockManager;
+use Drupal\layout_builder\LayoutTempstoreRepository;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Drupal\Core\Controller\ControllerBase;
+use Drupal\Core\Plugin\Context\Context;
+use Drupal\Core\Plugin\Context\ContextDefinition;
+use Drupal\Core\Plugin\Context\EntityContext;
+use Drupal\custom_elements\CustomElement;
+use Drupal\custom_elements\CustomElementGeneratorTrait;
+use Drupal\layout_builder\SectionStorage\SectionStorageManager;
+use Drupal\node\NodeInterface;
+
+/**
+ * Controller for Lupus Decoupled Layout Builder.
+ */
+class LupusDecoupledLayoutBuilderController extends ControllerBase {
+
+  use CustomElementGeneratorTrait;
+
+  /**
+   * The section storage.
+   *
+   * @var \Drupal\layout_builder\SectionStorage\SectionStorageManager
+   */
+  protected $sectionStorage;
+
+
+  /**
+   * The layout builder tempstore.
+   *
+   * @var \Drupal\layout_builder\LayoutTempstoreRepository
+   */
+  protected $tempstore;
+
+  /**
+   * The Drupal block manager.
+   *
+   * @var \Drupal\Core\Block\BlockManager
+   */
+  protected $blockManager;
+
+  /**
+   * LayoutBuilderController constructor.
+   *
+   * @param \Drupal\layout_builder\LayoutTempstoreRepository $section_storage
+   *   The section storage manager.
+   * @param \Drupal\layout_builder\LayoutTempstoreRepository $tempstore
+   *   The layout builder tempstore.
+   * @param \Drupal\Core\Block\BlockManager $block_manager
+   *   The Drupal block manager.
+   */
+  public function __construct(SectionStorageManager $section_storage, LayoutTempstoreRepository $tempstore, BlockManager $block_manager) {
+    $this->sectionStorage = $section_storage;
+    $this->tempstore = $tempstore;
+    $this->blockManager = $block_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   *
+   * @param \Symfony\Component\DependencyInjection\ContainerInterface $container
+   *   The Drupal service container.
+   *
+   * @return static
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('plugin.manager.layout_builder.section_storage'),
+      $container->get('layout_builder.tempstore_repository'),
+      $container->get('plugin.manager.block')
+    );
+  }
+
+  /**
+   * Preview a layout builder block.
+   *
+   * @param \Drupal\node\NodeInterface $node
+   *   The node the block belongs to.
+   * @param string $block_data_id
+   *   The block version UUID to preview.
+   * @param string $view_mode
+   *   (optional) The view mode to use. Defaults to 'full'.
+   *
+   * @return \Drupal\custom_elements\CustomElement
+   *   Returns CustomElement object.
+   */
+  public function previewBlock(NodeInterface $node, $block_data, $view_mode = 'full') {
+    // Load the Layout Builder section storage.
+    $overrides = $this->sectionStorage
+      ->load('overrides', [
+        'entity' => EntityContext::fromEntity($node),
+        'view_mode' => new Context(new ContextDefinition('string'), $view_mode)
+      ]);
+    $block_data = explode(':', $block_data);
+
+    switch($block_data[0]) {
+      case 'field_block':
+        if (!empty($block_data[1]) && $node->hasField($block_data[1])) {
+          $value = $node->get($block_data[1])->value;
+          if (empty($value)) {
+            $value = t('There is no content for the @field field.', ['@field' => $block_data[1]]);
+          }
+        }
+        else {
+          $value = t('Content block');
+        }
+
+        $custom_element = new CustomElement();
+        $custom_element->setAttribute('element', 'drupal-markup');
+        $custom_element->setAttribute('content', $value);
+        break;
+
+      case 'inline_block':
+        $preview_section_storage = $this->tempstore->get($overrides);
+
+        // Loop through all the sections and components until we find the one being previewed.
+        foreach ($preview_section_storage->getSections() as $section) {
+          foreach ($section->getComponents() as $uuid => $component) {
+            if ($uuid == $block_data[1]) {
+              // Get the data of the "draft" block.
+              $configuration = $component->toArray()['configuration'];
+              $id_data = explode(':', $configuration['id']);
+
+              // Handle content blocks.
+              if (isset($configuration['block_revision_id'])) {
+                $block_content = \Drupal::entityTypeManager()
+                  ->getStorage('block_content')
+                  ->loadRevision($configuration['block_revision_id']);
+
+                // Check if the block has draft data.
+                if (!empty($configuration['block_serialized'])) {
+                  $block_override_values = unserialize($configuration['block_serialized']);
+
+                  // Apply the temporary draft data to the block object so that it's present for rendering.
+                  foreach ($block_override_values->toArray() as $key => $value) {
+                    $block_content->set($key, $value);
+                  }
+                }
+
+                $custom_element = $this->getCustomElementGenerator()->generate($block_content, $view_mode);
+              } // Handle field blocks.
+              else if ($id_data[0] == 'field_block') {
+                $custom_element = new CustomElement();
+                $custom_element->setAttribute('element', 'drupal-' . implode('-', str_replace('_', '-', $id_data)));
+                $custom_element->setAttribute('content', $node->get($id_data[3])->value);
+              } // Handle non-content blocks.
+              else {
+                $block_content = $this->blockManager->createInstance($configuration['id'], $configuration);
+                $render = $block_content->build();
+                $custom_element = CustomElement::createFromRenderArray($render);
+              }
+
+              // Disable caching for previews.
+              $custom_element->mergeCacheMaxAge(0);
+            }
+          }
+        }
+        break;
+
+      default:
+        if ($plugin_block = $this->blockManager->createInstance($block_data[0], ['uuid' => $block_data[1]])) {
+          $render = $plugin_block->build();
+          $custom_element = CustomElement::createFromRenderArray($render);
+        }
+        else {
+          $custom_element = new CustomElement();
+          $custom_element->setAttribute('element', 'drupal-markup');
+          $custom_element->setAttribute('content', t('Content not found.'));
+        }
+    }
+
+    return $custom_element;
+  }
+}
