diff --git a/packages/vitec/Classes/Seo/EmbeddedJsonLd.php b/packages/vitec/Classes/Seo/EmbeddedJsonLd.php new file mode 100644 index 0000000..17597cd --- /dev/null +++ b/packages/vitec/Classes/Seo/EmbeddedJsonLd.php @@ -0,0 +1,56 @@ +…

of their bodytext (found 2026-11-10: 17 records). Left + * in place, the frontend renders the JSON as visible text while Google + * never sees it as structured data. This helper removes such paragraphs + * from the HTML and returns the decoded nodes, ready to join the page's + * JSON-LD @graph (their own @context is dropped - the graph carries one). + * + * Fail-safe: a paragraph that merely looks like JSON but does not decode + * to a schema.org node stays in the HTML untouched. + */ +final class EmbeddedJsonLd +{ + /** + * @return array{html: string, nodes: list>} + */ + public static function extract(string $html): array + { + if ($html === '' || !str_contains($html, 'schema.org')) { + return ['html' => $html, 'nodes' => []]; + } + + $nodes = []; + $clean = preg_replace_callback( + '#]*>\s*(\{.+?\})\s*

#s', + static function (array $match) use (&$nodes): string { + $candidate = $match[1]; + if (!str_contains($candidate, 'schema.org')) { + return $match[0]; + } + $decoded = json_decode($candidate, true); + if (!is_array($decoded)) { + // RTE processing may have entity-encoded the quotes. + $decoded = json_decode(html_entity_decode($candidate, ENT_QUOTES | ENT_HTML5), true); + } + if (!is_array($decoded) || empty($decoded['@type'])) { + return $match[0]; + } + unset($decoded['@context']); + $nodes[] = $decoded; + return ''; + }, + $html + ) ?? $html; + + return ['html' => trim($clean), 'nodes' => $nodes]; + } +} diff --git a/packages/vitec/Classes/Seo/VitecMetaHandler.php b/packages/vitec/Classes/Seo/VitecMetaHandler.php index 1d6b9ca..beb1413 100644 --- a/packages/vitec/Classes/Seo/VitecMetaHandler.php +++ b/packages/vitec/Classes/Seo/VitecMetaHandler.php @@ -85,7 +85,9 @@ class VitecMetaHandler extends MetaHandler } $description = $this->plainText((string)($news['teaser'] ?? '')); if ($description === '') { - $description = $this->plainText((string)($news['bodytext'] ?? '')); + $description = $this->plainText( + EmbeddedJsonLd::extract((string)($news['bodytext'] ?? ''))['html'] + ); } $image = $this->firstImage('tx_news_domain_model_news', 'fal_media', $newsUid); } diff --git a/packages/vitec/Classes/UserFunc/NewsJsonRenderer.php b/packages/vitec/Classes/UserFunc/NewsJsonRenderer.php index 5f3dbe6..4c41edf 100644 --- a/packages/vitec/Classes/UserFunc/NewsJsonRenderer.php +++ b/packages/vitec/Classes/UserFunc/NewsJsonRenderer.php @@ -375,9 +375,12 @@ final class NewsJsonRenderer // SSR HTML to 2.8 MB). Cards render from `teaser`; only records // WITHOUT a teaser get a plain-text excerpt in `bodytext` so no // card goes blank. The detail mode is unchanged. + // Editor-pasted schema.org blocks (FAQPage JSON in a trailing + // paragraph) are split out of the body - they reach Google via + // the page-level jsonLd @graph instead (PageJsonLdRenderer). 'bodytext' => $mode === 'list' ? (trim((string)($news['teaser'] ?? '')) !== '' ? '' : $this->listExcerpt((string)($news['bodytext'] ?? ''))) - : RteResolver::html($news['bodytext'] ?? ''), + : \Evomedien\Vitec\Seo\EmbeddedJsonLd::extract(RteResolver::html($news['bodytext'] ?? ''))['html'], 'datetime' => (int)($news['datetime'] ?? 0), 'archive' => (int)($news['archive'] ?? 0), 'istopnews' => (bool)($news['istopnews'] ?? false), @@ -579,6 +582,7 @@ final class NewsJsonRenderer */ private function listExcerpt(string $html): string { + $html = \Evomedien\Vitec\Seo\EmbeddedJsonLd::extract($html)['html']; $text = trim((string)preg_replace('/\s+/u', ' ', strip_tags($html))); if (mb_strlen($text) <= 300) { return $text; diff --git a/packages/vitec/Classes/UserFunc/PageJsonLdRenderer.php b/packages/vitec/Classes/UserFunc/PageJsonLdRenderer.php index e9dfec5..433ec79 100755 --- a/packages/vitec/Classes/UserFunc/PageJsonLdRenderer.php +++ b/packages/vitec/Classes/UserFunc/PageJsonLdRenderer.php @@ -156,6 +156,13 @@ final class PageJsonLdRenderer 'author' => (string)($newsRow['author'] ?? ''), 'author_email' => (string)($newsRow['author_email'] ?? ''), ], $base, $this->recordImageUrls('tx_news_domain_model_news', 'fal_media', $newsUid, $base, $service), $hasOrganization); + + // Editor-pasted schema blocks (e.g. FAQPage JSON in a + // trailing bodytext paragraph) join the @graph here; + // NewsJsonRenderer strips them out of the rendered body. + foreach (\Evomedien\Vitec\Seo\EmbeddedJsonLd::extract((string)($newsRow['bodytext'] ?? ''))['nodes'] as $embeddedNode) { + $nodes[] = $embeddedNode; + } } }