Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions modules/custom/az_accordion/az_accordion.services.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
services:
az_accordion.faq_schema_aggregator_subscriber:
class: Drupal\az_accordion\EventSubscriber\AZFaqAggregatorSubscriber
tags:
- { name: event_subscriber }
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<?php

declare(strict_types=1);

namespace Drupal\az_accordion\EventSubscriber;

use Drupal\Core\Render\HtmlResponse;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;

/**
* Aggregates per-accordion FAQ question entries into a single FAQPage block.
*
* Each accordion formatter attaches its questions as a separate html_head
* entry with a key prefixed by 'faq_questions_'. This subscriber runs before
* HtmlResponseSubscriber (which processes attachments into HTML), finds all
* such entries, merges the questions, and replaces them with a single
* 'faq_schema' entry containing one FAQPage JSON-LD block.
*/
class AZFaqAggregatorSubscriber implements EventSubscriberInterface {

/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
// Run before HtmlResponseSubscriber (priority 0) so we can modify
// the raw attachments before they are processed into HTML.
$events[KernelEvents::RESPONSE][] = ['onRespond', 10];
return $events;
}

/**
* Aggregates FAQ question html_head entries into a single FAQPage block.
*
* @param \Symfony\Component\HttpKernel\Event\ResponseEvent $event
* The response event.
*/
public function onRespond(ResponseEvent $event): void {
$response = $event->getResponse();
if (!$response instanceof HtmlResponse) {
return;
}

$attachments = $response->getAttachments();
if (empty($attachments['html_head'])) {
return;
}

// Collect questions grouped by the accordion entity ID encoded in the
// attachment key. Grouping lets us sort whole accordions by their
// position on the page.
$groups = [];
$remaining_head = [];

foreach ($attachments['html_head'] as $item) {
[$element, $key] = $item;
if (str_starts_with($key, 'faq_questions_')) {
$entity_id = substr($key, strlen('faq_questions_'));
$data = json_decode($element['#value'], TRUE);
if (is_array($data) && !empty($data['questions'])) {
$groups[$entity_id] = $data['questions'];
}
}
else {
$remaining_head[] = $item;
}
}

if (empty($groups)) {
return;
}

// Sort groups by the DOM order of each accordion wrapper's id attribute.
// Non-FAQ accordions that match this pattern are ignored since uksort
// only consults IDs that keyed an FAQ accordion group.
preg_match_all('/id="accordion-(\d+)/', $response->getContent(), $matches);
$order = array_flip($matches[1]);

uksort($groups, function ($a, $b) use ($order): int {
$pa = $order[$a] ?? PHP_INT_MAX;
$pb = $order[$b] ?? PHP_INT_MAX;
return $pa <=> $pb;
});

$all_questions = [];
foreach ($groups as $questions) {
foreach ($questions as $question) {
$all_questions[] = $question;
}
}

// Build the single merged FAQPage schema.
$faq_schema = [
'@context' => 'https://schema.org',
'@type' => 'FAQPage',
'mainEntity' => $all_questions,
];

// Add the merged schema as a single html_head entry.
$remaining_head[] = [
[
'#type' => 'html_tag',
'#tag' => 'script',
'#attributes' => ['type' => 'application/ld+json'],
'#value' => json_encode($faq_schema, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
],
'faq_schema',
];

$attachments['html_head'] = $remaining_head;
$response->setAttachments($attachments);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Drupal\az_accordion\Plugin\Field\FieldFormatter;

use Drupal\Component\Utility\Html;
use Drupal\Component\Utility\Xss;
use Drupal\Core\Field\Attribute\FieldFormatter;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Field\FormatterBase;
Expand Down Expand Up @@ -63,6 +64,7 @@ public function viewElements(FieldItemListInterface $items, $langcode) {

$entity = $items->getEntity();
$accordion_container_id = HTML::getUniqueId('accordion-' . $entity->id());
$faq_schema_enabled = FALSE;

foreach ($items as $delta => $item) {
assert($item instanceof AZAccordionItem);
Expand All @@ -71,6 +73,17 @@ public function viewElements(FieldItemListInterface $items, $langcode) {

$column_classes = [];
$column_classes[] = 'col-md-4 col-lg-4';
$parent = $item->getEntity();

if ($parent instanceof ParagraphInterface) {
// Get the behavior settings for the parent.
$parent_config = $parent->getAllBehaviorSettings();

// Check if FAQ schema markup is enabled.
if (!empty($parent_config['az_accordion_paragraph_behavior']['faq_schema'])) {
$faq_schema_enabled = TRUE;
}
}

// Handle class keys that contained multiple classes.
$column_classes = implode(' ', $column_classes);
Expand Down Expand Up @@ -123,7 +136,71 @@ public function viewElements(FieldItemListInterface $items, $langcode) {
$element['#accordion_container_id'] = $accordion_container_id;
}

// Attach FAQ schema markup if enabled.
if ($faq_schema_enabled && !empty($element)) {
$this->attachFaqSchema($element, $items);
}

return $element;
}

/**
* Attaches FAQ question data to the render array for later aggregation.
*
* Each FAQ-enabled accordion attaches its questions as a separate html_head
* entry with a unique key (faq_questions_ENTITY_ID). The
* AZFaqAggregatorSubscriber merges all such entries into a single
* FAQPage JSON-LD block before the response is sent. This approach is
* compatible with Drupal's render caching because #attached metadata
* survives caching.
*
* @param array &$element
* The render array to attach the schema to.
* @param \Drupal\Core\Field\FieldItemListInterface $items
* The accordion field items.
*/
protected function attachFaqSchema(array &$element, FieldItemListInterface $items) {
$questions = [];

foreach ($items as $item) {
$title = $item->title ?? '';
$body = $item->body ?? '';

if (empty($title) || empty($body)) {
continue;
}

// Keep only the HTML tags that Google displays in FAQ rich results.
// @see https://developers.google.com/search/docs/appearance/structured-data/faqpage
$allowed_tags = [
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'br', 'ol', 'ul', 'li', 'a', 'p', 'div',
'b', 'strong', 'i', 'em',
];
$clean_body = Xss::filter($body, $allowed_tags);

$questions[] = [
'@type' => 'Question',
'name' => strip_tags($title),
'acceptedAnswer' => [
'@type' => 'Answer',
'text' => $clean_body,
],
];
}

if (!empty($questions)) {
$data = ['questions' => $questions];
$element['#attached']['html_head'][] = [
[
'#type' => 'html_tag',
'#tag' => 'script',
'#attributes' => ['type' => 'application/ld+json'],
'#value' => json_encode($data),
],
'faq_questions_' . $items->getEntity()->id(),
];
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ public function buildBehaviorForm(ParagraphInterface $paragraph, array &$form, F
'#description' => $this->t('Display an "Expand/Collapse All" button above this accordion.'),
];

$form['faq_schema'] = [
'#type' => 'checkbox',
'#title' => $this->t('This is an FAQ'),
'#default_value' => $config['faq_schema'] ?? FALSE,
'#description' => $this->t('Enable FAQ (FAQPage) schema markup for this accordion. When checked, structured data will be added to the page so search engines can display these items as rich FAQ results. Only use this for content that is genuinely a list of frequently asked questions.'),
];

parent::buildBehaviorForm($paragraph, $form, $form_state);

// This places the form fields on the content tab rather than behavior tab.
Expand Down
Loading