> $blocks Parsed Gutenberg blocks. */ public static function slots_from_blocks( array $blocks ): SlotBag { $flat = self::flatten_blocks( $blocks ); $data = [ 'goal' => '', 'context' => '', 'issues' => '', 'completion' => '', 'steps' => [], 'code' => [], ]; foreach ( $flat as $block ) { $name = (string) ( $block['blockName'] ?? '' ); $html = (string) ( $block['innerHTML'] ?? '' ); switch ( $name ) { case TechArticleWrappers::GOAL_BLOCK: $data['goal'] = self::plain( $html ); break; case TechArticleWrappers::CONTEXT_BLOCK: $data['context'] = self::plain( $html ); break; case TechArticleWrappers::ISSUES_BLOCK: $data['issues'] = self::plain( $html ); break; case TechArticleWrappers::COMPLETION_BLOCK: $data['completion'] = self::plain( $html ); break; case TechArticleWrappers::STEPS_BLOCK: if ( ! empty( $block['attrs']['steps'] ) && is_array( $block['attrs']['steps'] ) ) { foreach ( $block['attrs']['steps'] as $step ) { $text = is_array( $step ) ? (string) ( $step['text'] ?? '' ) : ''; $text = trim( wp_strip_all_tags( $text ) ); if ( '' !== $text ) { $data['steps'][] = [ 'text' => $text ]; } } } break; case TechArticleWrappers::STEP_BLOCK: case TechArticle::STEP_BLOCK: $text = self::plain_from_inner( $block['innerBlocks'] ?? [] ); if ( '' !== $text ) { $data['steps'][] = [ 'text' => $text ]; } break; case 'core/code': case 'forwp-advanced-code/code': $code = self::plain( $html ); if ( '' !== $code ) { $lang = (string) ( $block['attrs']['language'] ?? 'php' ); $data['code'][] = [ 'text' => $code, 'language' => $lang, ]; } break; } } // Code inside step wrappers still counts. if ( empty( $data['code'] ) ) { foreach ( $flat as $block ) { if ( ( $block['blockName'] ?? '' ) !== 'core/code' ) { continue; } $code = self::plain( (string) ( $block['innerHTML'] ?? '' ) ); if ( '' !== $code ) { $data['code'][] = [ 'text' => $code, 'language' => (string) ( $block['attrs']['language'] ?? 'php' ), ]; } } } return SlotBag::from_array( $data ); } /** * Extract slots from HTML that uses TechArticle CSS class wrappers. */ public static function slots_from_html( string $html ): SlotBag { $html = trim( $html ); if ( '' === $html ) { return new SlotBag(); } $previous = libxml_use_internal_errors( true ); $dom = new \DOMDocument(); $wrapped = '
inside any techarticle section if CLASS_CODE missing.
if ( empty( $data['code'] ) ) {
foreach ( [ self::CLASS_STEPS, self::CLASS_GOAL, self::CLASS_CONTEXT ] as $section_class ) {
$root = self::first_class_node( $xpath, $section_class );
if ( ! $root ) {
continue;
}
foreach ( $xpath->query( './/pre//code', $root ) as $code_node ) {
$text = self::node_text( $code_node );
if ( '' !== $text ) {
$data['code'][] = [ 'text' => $text, 'language' => 'php' ];
}
}
}
}
return SlotBag::from_array( $data );
}
/**
* @param array $data Slot array.
*/
public static function slots_from_array( array $data ): SlotBag {
return SlotBag::from_array( $data );
}
/**
* Build TechArticle JSON-LD from page context + slots.
*
* @param array $context headline, url, dates, author, image, about, @id.
* @return array
*/
public static function to_schema( array $context, SlotBag $slots ): array {
if ( ! $slots->has_required() ) {
return [];
}
$has_part = [];
foreach ( $slots->code as $sample ) {
$has_part[] = [
'@type' => 'SoftwareSourceCode',
'codeSampleType' => 'full',
'programmingLanguage' => $sample['language'] ?? 'php',
'text' => $sample['text'],
];
}
$howto_steps = [];
foreach ( $slots->steps as $step ) {
$entry = [
'@type' => 'HowToStep',
'text' => $step['text'],
];
if ( ! empty( $step['name'] ) ) {
$entry['name'] = $step['name'];
}
$howto_steps[] = $entry;
}
$howto = [
'@type' => 'HowTo',
'step' => $howto_steps,
];
if ( '' !== $slots->goal ) {
$howto['name'] = $slots->goal;
}
$has_part[] = $howto;
$schema = [
'@context' => 'https://schema.org',
'@type' => 'TechArticle',
'headline' => (string) ( $context['headline'] ?? '' ),
'url' => (string) ( $context['url'] ?? '' ),
'hasPart' => $has_part,
];
if ( ! empty( $context['@id'] ) ) {
$schema['@id'] = (string) $context['@id'];
}
if ( ! empty( $context['datePublished'] ) ) {
$schema['datePublished'] = (string) $context['datePublished'];
}
if ( ! empty( $context['dateModified'] ) ) {
$schema['dateModified'] = (string) $context['dateModified'];
}
if ( ! empty( $context['author'] ) ) {
$schema['author'] = $context['author'];
}
if ( ! empty( $context['image'] ) ) {
$schema['image'] = $context['image'];
}
if ( ! empty( $context['about'] ) ) {
$schema['about'] = $context['about'];
}
if ( '' !== $slots->goal ) {
$schema['description'] = $slots->goal;
} elseif ( '' !== $slots->context ) {
$schema['description'] = $slots->context;
}
if ( '' !== $slots->issues ) {
$schema['additionalProperty'] = [
[
'@type' => 'PropertyValue',
'name' => 'commonMistakes',
'value' => $slots->issues,
],
];
}
/**
* Filter assembled TechArticle schema (mapper path).
*
* @param array $schema Schema.
* @param array $context Context.
* @param SlotBag $slots Slots.
*/
return apply_filters( 'forwp_seo_techarticle_mapper_schema', $schema, $context, $slots );
}
/**
* @param array> $blocks Blocks.
* @return array>
*/
private static function flatten_blocks( array $blocks ): array {
$flat = [];
foreach ( $blocks as $block ) {
$flat[] = $block;
if ( ! empty( $block['innerBlocks'] ) ) {
$flat = array_merge( $flat, self::flatten_blocks( $block['innerBlocks'] ) );
}
}
return $flat;
}
private static function plain( string $html ): string {
return trim( wp_strip_all_tags( $html ) );
}
/**
* @param array> $blocks Inner blocks.
*/
private static function plain_from_inner( array $blocks ): string {
$parts = [];
foreach ( $blocks as $block ) {
$name = $block['blockName'] ?? '';
$html = $block['innerHTML'] ?? '';
if ( in_array( $name, [ 'core/heading', 'core/paragraph', 'core/list', 'core/code' ], true ) ) {
$text = self::plain( (string) $html );
if ( '' !== $text ) {
$parts[] = $text;
}
}
if ( ! empty( $block['innerBlocks'] ) ) {
$child = self::plain_from_inner( $block['innerBlocks'] );
if ( '' !== $child ) {
$parts[] = $child;
}
}
}
return trim( implode( "\n", $parts ) );
}
private static function first_class_text( \DOMXPath $xpath, string $class ): string {
$node = self::first_class_node( $xpath, $class );
if ( ! $node ) {
return '';
}
$parts = [];
foreach ( $xpath->query( './p|./ul/li|./ol/li|.//p[not(ancestor::*[contains(@class,"forwp-seo-techarticle-step")])]', $node ) as $child ) {
$text = self::node_text( $child );
if ( '' !== $text ) {
$parts[] = $text;
}
}
if ( ! empty( $parts ) ) {
return trim( implode( ' ', $parts ) );
}
// Clone without headings / nested code for a clean fallback.
$clone = $node->cloneNode( true );
if ( $clone instanceof \DOMElement ) {
$headings = [];
foreach ( $clone->getElementsByTagName( '*' ) as $el ) {
$tag = strtolower( $el->tagName );
if ( in_array( $tag, [ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'pre', 'code' ], true ) ) {
$headings[] = $el;
}
}
foreach ( $headings as $el ) {
if ( $el->parentNode ) {
$el->parentNode->removeChild( $el );
}
}
return self::node_text( $clone );
}
return self::node_text( $node );
}
private static function first_class_node( \DOMXPath $xpath, string $class ): ?\DOMNode {
$nodes = self::nodes_by_class( $xpath, $class );
return $nodes[0] ?? null;
}
/**
* @return array
*/
private static function nodes_by_class( \DOMXPath $xpath, string $class ): array {
$safe = addcslashes( $class, '"\\' );
$list = $xpath->query(
'//*[@id="forwp-seo-root"]//*[contains(concat(" ", normalize-space(@class), " "), " ' . $safe . ' ")]'
);
if ( ! $list ) {
return [];
}
$out = [];
foreach ( $list as $node ) {
$out[] = $node;
}
return $out;
}
private static function node_text( \DOMNode $node ): string {
return trim( preg_replace( '/\s+/u', ' ', $node->textContent ?? '' ) ?? '' );
}
/**
* Step text without nested code samples.
*/
private static function step_text( \DOMXPath $xpath, \DOMNode $node ): string {
$parts = [];
foreach ( $xpath->query( './/h3|.//h4|.//p[not(ancestor::pre)]', $node ) as $child ) {
// Skip if inside a code wrapper.
$parent = $child->parentNode;
$skip = false;
while ( $parent instanceof \DOMElement ) {
$class = $parent->getAttribute( 'class' );
if ( false !== strpos( ' ' . $class . ' ', ' ' . self::CLASS_CODE . ' ' ) ) {
$skip = true;
break;
}
$parent = $parent->parentNode;
}
if ( $skip ) {
continue;
}
$text = self::node_text( $child );
if ( '' !== $text ) {
$parts[] = $text;
}
}
if ( ! empty( $parts ) ) {
return trim( implode( ': ', array_unique( $parts ) ) );
}
$clone = $node->cloneNode( true );
if ( $clone instanceof \DOMElement ) {
$remove = [];
foreach ( $clone->getElementsByTagName( '*' ) as $el ) {
$class = $el instanceof \DOMElement ? $el->getAttribute( 'class' ) : '';
$tag = strtolower( $el->tagName );
if ( 'pre' === $tag || 'code' === $tag || false !== strpos( ' ' . $class . ' ', ' ' . self::CLASS_CODE . ' ' ) ) {
$remove[] = $el;
}
}
foreach ( $remove as $el ) {
if ( $el->parentNode ) {
$el->parentNode->removeChild( $el );
}
}
return self::node_text( $clone );
}
return self::node_text( $node );
}
/**
* Prefer / text when CLASS_CODE is on a section wrapper.
*/
private static function code_text( \DOMXPath $xpath, \DOMNode $node ): string {
foreach ( $xpath->query( './/pre//code|.//code', $node ) as $code_node ) {
$text = self::node_text( $code_node );
if ( '' !== $text ) {
return $text;
}
}
return self::node_text( $node );
}
}