*/
private const BLOCK_ASSETS = [
self::FAQ_BLOCK => 'faq-block',
self::HOWTO_BLOCK => 'howto-block',
self::TOC_BLOCK => 'toc-block',
];
/**
* Wire up hooks.
*
* @return void
*/
public function init(): void {
add_action('enqueue_block_editor_assets', [$this, 'enqueue_editor_assets']);
add_action('enqueue_block_assets', [$this, 'enqueue_block_styles']);
add_filter('render_block', [$this, 'inject_block_schema'], 10, 2);
}
/**
* Enqueue each block's editor script.
*
* @return void
*/
public function enqueue_editor_assets(): void {
foreach (self::BLOCK_ASSETS as $handle) {
$asset_path = THINKRANK_PLUGIN_DIR . "assets/{$handle}.asset.php";
$asset = file_exists($asset_path)
? include $asset_path
: ['dependencies' => ['wp-blocks', 'wp-element', 'wp-block-editor', 'wp-components', 'wp-i18n'], 'version' => THINKRANK_VERSION];
wp_enqueue_script(
"thinkrank-{$handle}",
THINKRANK_PLUGIN_URL . "assets/{$handle}.js",
$asset['dependencies'] ?? [],
$asset['version'] ?? THINKRANK_VERSION,
true
);
wp_set_script_translations("thinkrank-{$handle}", 'thinkrank');
}
}
/**
* Enqueue block stylesheets where blocks render.
*
* Hooked to enqueue_block_assets — not enqueue_block_editor_assets — so
* core mirrors them into the iframed editor canvas instead of only the
* editor's outer document. On the front end each loads only when its
* block is present.
*
* @return void
*/
public function enqueue_block_styles(): void {
foreach (self::BLOCK_ASSETS as $block_name => $handle) {
if (!is_admin() && (!function_exists('has_block') || !has_block($block_name))) {
continue;
}
$css = THINKRANK_PLUGIN_DIR . "assets/{$handle}.css";
if (!file_exists($css)) {
continue;
}
$asset_path = THINKRANK_PLUGIN_DIR . "assets/{$handle}.asset.php";
$asset = file_exists($asset_path) ? include $asset_path : [];
wp_enqueue_style(
"thinkrank-{$handle}",
THINKRANK_PLUGIN_URL . "assets/{$handle}.css",
[],
$asset['version'] ?? THINKRANK_VERSION
);
}
}
/**
* Append block-level JSON-LD after a ThinkRank block's rendered output.
*
* Done server-side (not in the blocks' save output) so the schema is not
* stripped by KSES for users without unfiltered_html.
*
* @param string $block_content Rendered block HTML.
* @param array $block Parsed block (name + attrs).
* @return string
*/
public function inject_block_schema(string $block_content, array $block): string {
$name = $block['blockName'] ?? '';
$attrs = $block['attrs'] ?? [];
if (!isset(self::BLOCK_ASSETS[$name])) {
return $block_content;
}
// Schema output is on by default; only skip when explicitly disabled.
if (array_key_exists('outputSchema', $attrs) && false === $attrs['outputSchema']) {
return $block_content;
}
switch ($name) {
case self::FAQ_BLOCK:
// Only the post being viewed may claim to be an FAQPage. On an
// archive or the blog home the graph's collection pass skips
// (it is not is_singular()), so absorption never happens and
// every listed post carrying an FAQ block used to emit its own
// standalone FAQPage beside a head that already declares
// CollectionPage — N FAQPage scripts on one URL.
if (!$this->is_faq_schema_context()) {
return $block_content;
}
// The request's schema graph already merged this block's questions
// into its single FAQPage, so emitting here would recreate the
// duplicate FAQPage the graph exists to prevent (#355).
if ($this->faq_absorbed_by_graph()) {
return $block_content;
}
$schema = $this->build_faq_schema($attrs);
break;
case self::HOWTO_BLOCK:
$schema = $this->build_howto_schema($attrs);
break;
case self::TOC_BLOCK:
$schema = $this->build_toc_schema($attrs);
break;
default:
$schema = null;
}
if (null === $schema) {
return $block_content;
}
$json = wp_json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
if (false === $json) {
return $block_content;
}
return $block_content . "\n" . '';
}
/**
* Whether this render may emit a page-level FAQPage.
*
* True only while rendering the singular post that is actually being
* viewed. A listing (archive, blog home, search) renders many posts under
* one URL, and an FAQPage there would describe a document that does not
* exist. Outside a front-end query — the editor, a REST render — there is no
* page to describe either.
*
* @since 2.0.1
* @return bool
*/
private function is_faq_schema_context(): bool {
if (!function_exists('is_singular') || !is_singular()) {
return false;
}
$queried_id = (int) get_queried_object_id();
$current_id = (int) get_the_ID();
// A secondary loop inside a singular template can render other posts;
// their FAQ content is not this URL's FAQ content.
return $queried_id > 0 && $queried_id === $current_id;
}
/**
* Whether the schema graph already absorbed this page's FAQ content.
*
* Falls back to false whenever the graph never ran, so the block keeps its
* original standalone behaviour outside a normal front-end render.
*
* @since 1.32.0
* @return bool
*/
private function faq_absorbed_by_graph(): bool {
if (!class_exists('ThinkRank\\Frontend\\Schema_Graph')) {
return false;
}
return \ThinkRank\Frontend\Schema_Graph::instance()->absorbed_content_faq();
}
/**
* FAQPage schema from FAQ block attributes.
*
* @param array $attrs Block attributes.
* @return array|null Schema array, or null when there is nothing to emit.
*/
private function build_faq_schema(array $attrs): ?array {
$faqs = $attrs['faqs'] ?? [];
if (!is_array($faqs) || empty($faqs)) {
return null;
}
$entities = [];
foreach ($faqs as $faq) {
$question = isset($faq['question']) ? trim(wp_strip_all_tags((string) $faq['question'])) : '';
$answer = isset($faq['answer']) ? trim((string) $faq['answer']) : '';
if ($question === '' || $answer === '') {
continue;
}
$text = wp_kses_post($answer);
// Yoast-style: a per-item image travels inside the answer HTML, so
// rich results can surface it without a separate ImageObject node.
$image_url = isset($faq['imageUrl']) ? esc_url((string) $faq['imageUrl']) : '';
if ($image_url !== '') {
$image_alt = isset($faq['imageAlt']) ? esc_attr((string) $faq['imageAlt']) : '';
$text .= '
';
}
$entities[] = [
'@type' => 'Question',
'name' => $question,
'acceptedAnswer' => [
'@type' => 'Answer',
'text' => $text,
],
];
}
if (empty($entities)) {
return null;
}
return [
'@context' => 'https://schema.org',
'@type' => 'FAQPage',
'mainEntity' => $entities,
];
}
/**
* HowTo schema from HowTo block attributes.
*
* Property shape follows Google's HowTo guidelines (and matches what
* RankMath emits): name, description, totalTime as ISO 8601, and
* HowToStep entries with name/text/image.
*
* @param array $attrs Block attributes.
* @return array|null Schema array, or null when there is nothing to emit.
*/
private function build_howto_schema(array $attrs): ?array {
$steps = $attrs['steps'] ?? [];
if (!is_array($steps) || empty($steps)) {
return null;
}
$step_entities = [];
foreach ($steps as $step) {
$title = isset($step['title']) ? trim(wp_strip_all_tags((string) $step['title'])) : '';
$text = isset($step['text']) ? trim(wp_strip_all_tags((string) $step['text'])) : '';
if ($title === '' && $text === '') {
continue;
}
$entity = ['@type' => 'HowToStep'];
if ($title !== '' && $text !== '') {
$entity['name'] = $title;
$entity['text'] = $text;
} else {
// Google requires text; fall back to whichever field is set.
$entity['text'] = $text !== '' ? $text : $title;
}
$image_url = isset($step['imageUrl']) ? esc_url_raw((string) $step['imageUrl']) : '';
if ($image_url !== '') {
$entity['image'] = [
'@type' => 'ImageObject',
'url' => $image_url,
];
}
$step_entities[] = $entity;
}
if (empty($step_entities)) {
return null;
}
$heading = isset($attrs['heading']) ? trim(wp_strip_all_tags((string) $attrs['heading'])) : '';
$name = $heading !== '' ? $heading : get_the_title();
$schema = [
'@context' => 'https://schema.org',
'@type' => 'HowTo',
'name' => $name,
'step' => $step_entities,
];
$description = isset($attrs['description']) ? trim(wp_strip_all_tags((string) $attrs['description'])) : '';
if ($description !== '') {
$schema['description'] = $description;
}
// ISO 8601 duration, e.g. P1DT2H30M — only when a duration was set.
$days = max(0, (int) ($attrs['totalDays'] ?? 0));
$hours = max(0, (int) ($attrs['totalHours'] ?? 0));
$minutes = max(0, (int) ($attrs['totalMinutes'] ?? 0));
if ($days + $hours + $minutes > 0) {
$schema['totalTime'] = sprintf('P%dDT%dH%dM', $days, $hours, $minutes);
}
return $schema;
}
/**
* SiteNavigationElement schema from TOC block attributes (one element per
* listed section — the shape RankMath's TOC block emits).
*
* @param array $attrs Block attributes.
* @return array|null Schema array, or null when there is nothing to emit.
*/
private function build_toc_schema(array $attrs): ?array {
$headings = $attrs['headings'] ?? [];
if (!is_array($headings) || empty($headings)) {
return null;
}
$permalink = get_permalink();
if (!is_string($permalink)) {
$permalink = '';
}
$elements = [];
foreach ($headings as $item) {
$content = isset($item['content']) ? trim(wp_strip_all_tags((string) $item['content'])) : '';
$anchor = isset($item['anchor']) ? trim((string) $item['anchor']) : '';
if ($content === '' || $anchor === '') {
continue;
}
$elements[] = [
'@type' => 'SiteNavigationElement',
'name' => $content,
'url' => $permalink . '#' . $anchor,
];
}
if (empty($elements)) {
return null;
}
return [
'@context' => 'https://schema.org',
'@graph' => $elements,
];
}
}