diff --git a/.github/workflows/scheduled-ci.yml b/.github/workflows/scheduled-ci.yml index c6718fd..129802b 100644 --- a/.github/workflows/scheduled-ci.yml +++ b/.github/workflows/scheduled-ci.yml @@ -8,4 +8,5 @@ on: jobs: scheduled-ci: - uses: builtnorth/.github/.github/workflows/composer-package-ci.yml@main \ No newline at end of file + uses: builtnorth/.github/.github/workflows/composer-package-ci.yml@main + secrets: inherit \ No newline at end of file diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 77bb23e..cedd6bf 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,3 +8,4 @@ on: jobs: ci: uses: builtnorth/.github/.github/workflows/composer-package-ci.yml@main + secrets: inherit diff --git a/composer.json b/composer.json index 12ad3f1..f04bedf 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,7 @@ } ], "require": { - "php": ">=8.1" + "php": ">=8.0" }, "autoload": { "psr-4": { diff --git a/docs/hooks-reference.md b/docs/hooks-reference.md index ad09165..24a23c3 100644 --- a/docs/hooks-reference.md +++ b/docs/hooks-reference.md @@ -82,7 +82,7 @@ add_filter('wp_schema_framework_output_enabled', function($enabled) { Override the detected page context. **Parameters:** -- `$context` (string) - Detected context ('home', 'singular', 'archive', 'search', 'error') +- `$context` (string) - Detected context ('home', 'singular', 'attachment', 'archive', 'search', '404', 'unknown') **Usage:** ```php @@ -167,7 +167,8 @@ Modify a specific schema piece by ID. **Usage:** ```php -add_filter('wp_schema_framework_piece_id_#organization', function($piece, $context) { +// Note: '#' is stripped from IDs when building the hook name, so '#organization' becomes 'organization' +add_filter('wp_schema_framework_piece_id_organization', function($piece, $context) { $piece->set('telephone', '+1234567890'); return $piece; }, 10, 2); diff --git a/inc/App.php b/inc/App.php index 82fd10f..9c30d29 100644 --- a/inc/App.php +++ b/inc/App.php @@ -4,6 +4,7 @@ namespace BuiltNorth\WPSchema; +use BuiltNorth\WPSchema\Contracts\SchemaProviderInterface; use BuiltNorth\WPSchema\Services\ProviderRegistry; use BuiltNorth\WPSchema\Services\GraphBuilder; use BuiltNorth\WPSchema\Services\OutputService; @@ -60,12 +61,14 @@ public function init(): void // Initialize output hooks $this->output_service->init(); - + + // Mark initialized before firing the action so register_provider() works + // during wp_schema_framework_register_providers callbacks. + $this->initialized = true; + // Allow plugins to register providers do_action('wp_schema_framework_register_providers', $this); - $this->initialized = true; - // Framework is ready do_action('wp_schema_framework_ready', $this); @@ -120,16 +123,25 @@ private function register_core_providers(): void public static function register_provider(string $name, string $class_name): bool { $instance = self::instance(); - + + // Registry is only available after init(); return false instead of crashing. + if (!$instance->initialized) { + return false; + } + if (!class_exists($class_name)) { return false; } - + + if (!is_a($class_name, SchemaProviderInterface::class, true)) { + return false; + } + try { $provider = new $class_name(); $instance->registry->register($name, $provider); return true; - } catch (\Exception $e) { + } catch (\Throwable $e) { return false; } } diff --git a/inc/Graph/SchemaGraph.php b/inc/Graph/SchemaGraph.php index 53fef0e..05c21fa 100644 --- a/inc/Graph/SchemaGraph.php +++ b/inc/Graph/SchemaGraph.php @@ -169,7 +169,7 @@ public function to_json(): string return json_encode( $schema_array, - JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT ) ?: ''; } diff --git a/inc/Providers/ArticleProvider.php b/inc/Providers/ArticleProvider.php index 5a829b4..1f73480 100644 --- a/inc/Providers/ArticleProvider.php +++ b/inc/Providers/ArticleProvider.php @@ -113,42 +113,72 @@ public function get_pieces(string $context): array if (!$post) { return []; } - + // Get schema type $default_type = $this->get_default_schema_type($post->post_type); $schema_type = apply_filters('wp_schema_framework_post_type_override', $default_type, $post->ID, $post->post_type, $post); - - // Create article piece + $article = new SchemaPiece('#article', $schema_type); - + $article ->set('headline', $post->post_title) ->set('name', $post->post_title) ->set('url', get_permalink($post->ID)) ->set('datePublished', get_the_date('c', $post->ID)) ->set('dateModified', get_the_modified_date('c', $post->ID)) + ->set('mainEntityOfPage', ['@type' => 'WebPage', '@id' => get_permalink($post->ID)]) + ->set('inLanguage', get_bloginfo('language')) ->add_reference('author', '#author') - ->add_reference('publisher', '#organization'); - - // Add description from filter or excerpt + ->add_reference('publisher', '#organization') + ->add_reference('isPartOf', '#website'); + + // Description from filter or excerpt $description = apply_filters('wp_schema_framework_post_description', '', $post->ID, $post); if ($description) { $article->set('description', $description); } elseif ($post->post_excerpt) { $article->set('description', wp_strip_all_tags($post->post_excerpt)); } - - // Add featured image - if (has_post_thumbnail($post->ID)) { - $image_url = get_the_post_thumbnail_url($post->ID, 'full'); + + // Featured image with dimensions + $thumb_id = get_post_thumbnail_id($post->ID); + if ($thumb_id) { + $image_url = wp_get_attachment_image_url($thumb_id, 'full'); if ($image_url) { - $article->set('image', [ - '@type' => 'ImageObject', - 'url' => $image_url, - ]); + $image_data = ['@type' => 'ImageObject', 'url' => $image_url]; + $metadata = wp_get_attachment_metadata($thumb_id); + if (!empty($metadata['width'])) { + $image_data['width'] = $metadata['width']; + } + if (!empty($metadata['height'])) { + $image_data['height'] = $metadata['height']; + } + $article->set('image', $image_data); } } - + + // Keywords from post tags + $tags = get_the_tags($post->ID); + if ($tags) { + $article->set('keywords', implode(', ', array_map(fn($tag) => $tag->name, $tags))); + } + + // Article section from primary category + $categories = get_the_category($post->ID); + if ($categories) { + $article->set('articleSection', $categories[0]->name); + } + + // Word count + $word_count = str_word_count(wp_strip_all_tags($post->post_content)); + if ($word_count > 0) { + $article->set('wordCount', $word_count); + } + + // Allow filtering — also gives polaris-seo a chance to add image fallback etc. + $data = apply_filters('wp_schema_framework_article_data', $article->to_array(), $post->ID, $post); + $article->from_array($data); + return [$article]; } diff --git a/inc/Providers/MediaProvider.php b/inc/Providers/MediaProvider.php index eb7a952..fe53084 100644 --- a/inc/Providers/MediaProvider.php +++ b/inc/Providers/MediaProvider.php @@ -19,8 +19,7 @@ class MediaProvider implements SchemaProviderInterface { public function can_provide(string $context): bool { - // Only provide for singular attachment pages - return $context === 'singular' && is_attachment(); + return $context === 'attachment'; } public function get_pieces(string $context): array @@ -309,8 +308,8 @@ private function format_duration(int $seconds): string { $hours = floor($seconds / 3600); $minutes = floor(($seconds % 3600) / 60); - $seconds = $seconds % 60; - + $secs = $seconds % 60; + $duration = 'PT'; if ($hours > 0) { $duration .= $hours . 'H'; @@ -318,10 +317,10 @@ private function format_duration(int $seconds): string if ($minutes > 0) { $duration .= $minutes . 'M'; } - if ($seconds > 0) { - $duration .= $seconds . 'S'; + if ($secs > 0 || $duration === 'PT') { + $duration .= $secs . 'S'; } - + return $duration; } } \ No newline at end of file diff --git a/inc/Providers/WebPageProvider.php b/inc/Providers/WebPageProvider.php index 3a4c6ff..680b140 100644 --- a/inc/Providers/WebPageProvider.php +++ b/inc/Providers/WebPageProvider.php @@ -53,15 +53,16 @@ public function get_pieces(string $context): array ->set('name', get_bloginfo('name')) ->set('headline', get_bloginfo('name')) ->set('url', home_url()) + ->set('inLanguage', get_bloginfo('language')) ->add_reference('publisher', '#organization') ->add_reference('isPartOf', '#website'); - + // Add description $description = get_bloginfo('description'); if ($description) { $webpage->set('description', $description); } - + // If front page is a static page if (get_option('show_on_front') === 'page') { $page_id = get_option('page_on_front'); @@ -69,31 +70,39 @@ public function get_pieces(string $context): array $post = get_post($page_id); if ($post) { $webpage->set('headline', $post->post_title); - + if ($post->post_excerpt) { $webpage->set('description', wp_strip_all_tags($post->post_excerpt)); } - + $webpage ->set('datePublished', get_the_date('c', $page_id)) ->set('dateModified', get_the_modified_date('c', $page_id)); - - if (has_post_thumbnail($page_id)) { - $image_url = get_the_post_thumbnail_url($page_id, 'full'); + + $thumb_id = get_post_thumbnail_id($page_id); + if ($thumb_id) { + $image_url = wp_get_attachment_image_url($thumb_id, 'full'); if ($image_url) { - $webpage->set('image', [ - '@type' => 'ImageObject', - 'url' => $image_url, - ]); + $image_data = ['@type' => 'ImageObject', 'url' => $image_url]; + $metadata = wp_get_attachment_metadata($thumb_id); + if (!empty($metadata['width'])) { + $image_data['width'] = $metadata['width']; + } + if (!empty($metadata['height'])) { + $image_data['height'] = $metadata['height']; + } + $webpage->set('image', $image_data); } } } } } - // Add breadcrumb reference - $webpage->add_reference('breadcrumb', '#breadcrumb'); - + // Only add breadcrumb reference if a BreadcrumbList will be in the graph + if (apply_filters('wp_schema_framework_has_breadcrumb', false)) { + $webpage->add_reference('breadcrumb', '#breadcrumb'); + } + // Allow filtering of homepage data $data = apply_filters('wp_schema_framework_homepage_data', $webpage->to_array()); $data = apply_filters('wp_schema_framework_webpage_data', $data, 0, null); @@ -121,10 +130,11 @@ public function get_pieces(string $context): array ->set('url', get_permalink($post->ID)) ->set('datePublished', get_the_date('c', $post->ID)) ->set('dateModified', get_the_modified_date('c', $post->ID)) + ->set('inLanguage', get_bloginfo('language')) ->add_reference('author', '#author') ->add_reference('publisher', '#organization') ->add_reference('isPartOf', '#website'); - + // Add description from filter or excerpt $description = apply_filters('wp_schema_framework_post_description', '', $post->ID, $post); if ($description) { @@ -132,21 +142,29 @@ public function get_pieces(string $context): array } elseif ($post->post_excerpt) { $webpage->set('description', wp_strip_all_tags($post->post_excerpt)); } - - // Add featured image - if (has_post_thumbnail($post->ID)) { - $image_url = get_the_post_thumbnail_url($post->ID, 'full'); + + // Featured image with dimensions + $thumb_id = get_post_thumbnail_id($post->ID); + if ($thumb_id) { + $image_url = wp_get_attachment_image_url($thumb_id, 'full'); if ($image_url) { - $webpage->set('image', [ - '@type' => 'ImageObject', - 'url' => $image_url, - ]); + $image_data = ['@type' => 'ImageObject', 'url' => $image_url]; + $metadata = wp_get_attachment_metadata($thumb_id); + if (!empty($metadata['width'])) { + $image_data['width'] = $metadata['width']; + } + if (!empty($metadata['height'])) { + $image_data['height'] = $metadata['height']; + } + $webpage->set('image', $image_data); } } - // Add breadcrumb reference - $webpage->add_reference('breadcrumb', '#breadcrumb'); - + // Only add breadcrumb reference if a BreadcrumbList will be in the graph + if (apply_filters('wp_schema_framework_has_breadcrumb', false)) { + $webpage->add_reference('breadcrumb', '#breadcrumb'); + } + // Allow filtering of webpage data $data = apply_filters('wp_schema_framework_webpage_data', $webpage->to_array(), $post->ID, $post); $webpage->from_array($data); diff --git a/inc/Providers/WebsiteProvider.php b/inc/Providers/WebsiteProvider.php index b39a62e..095717c 100644 --- a/inc/Providers/WebsiteProvider.php +++ b/inc/Providers/WebsiteProvider.php @@ -46,12 +46,18 @@ public function get_pieces(string $context): array ->set('name', get_bloginfo('name')) ->set('url', home_url('/')) ->add_reference('publisher', '#organization'); - + // Add description if available $description = get_bloginfo('description'); if ($description) { $website->set('description', $description); } + + // Language + $language = get_bloginfo('language'); + if ($language) { + $website->set('inLanguage', $language); + } // Add search action on all pages for Google Sitelinks Searchbox $search_url = home_url('/?s={search_term_string}'); diff --git a/inc/Services/ContextDetector.php b/inc/Services/ContextDetector.php index 6abcf88..19ff9d5 100644 --- a/inc/Services/ContextDetector.php +++ b/inc/Services/ContextDetector.php @@ -19,26 +19,22 @@ class ContextDetector public function get_current_context(): string { if (is_front_page()) { - return 'home'; + $context = 'home'; + } elseif (is_attachment()) { + $context = 'attachment'; + } elseif (is_singular()) { + $context = 'singular'; + } elseif (is_archive() || is_home()) { + $context = 'archive'; + } elseif (is_search()) { + $context = 'search'; + } elseif (is_404()) { + $context = '404'; + } else { + $context = 'unknown'; } - - if (is_singular()) { - return 'singular'; - } - - if (is_archive() || is_home()) { - return 'archive'; - } - - if (is_search()) { - return 'search'; - } - - if (is_404()) { - return '404'; - } - - return 'unknown'; + + return (string) apply_filters('wp_schema_framework_context', $context); } /** diff --git a/inc/Services/GraphBuilder.php b/inc/Services/GraphBuilder.php index 3d8c1d2..9a6fad8 100644 --- a/inc/Services/GraphBuilder.php +++ b/inc/Services/GraphBuilder.php @@ -40,7 +40,15 @@ public function build_for_context(string $context): SchemaGraph // Apply filters for extensibility $graph->apply_filters($context); - + + // Surface dangling references as error_log entries during development + if (defined('WP_DEBUG') && WP_DEBUG) { + foreach ($graph->validate_references() as $error) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log('[WP Schema] ' . $error); + } + } + return $graph; } } \ No newline at end of file diff --git a/inc/Services/OutputService.php b/inc/Services/OutputService.php index 19fc639..c1da60c 100644 --- a/inc/Services/OutputService.php +++ b/inc/Services/OutputService.php @@ -83,7 +83,8 @@ private function output_graph($graph): void // Allow filtering of complete graph before output $graph_data = apply_filters('wp_schema_framework_graph', $graph_data); - $json = json_encode($graph_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + // JSON_HEX_TAG prevents breakout inside sequences (JSON_HEX_TAG) + */ + public function test_json_output_escapes_script_closing_tag(): void + { + $piece = new SchemaPiece('#test', 'Article'); + $piece->set('name', 'Title with tag'); + + $graph = Mockery::mock(SchemaGraph::class); + $graph->shouldReceive('is_empty')->andReturn(false); + $graph->shouldReceive('get_pieces')->andReturn([$piece]); + + $graph_builder = Mockery::mock(GraphBuilder::class); + $graph_builder->shouldReceive('build_for_context')->andReturn($graph); + + $context_detector = Mockery::mock(ContextDetector::class); + $context_detector->shouldReceive('get_current_context')->andReturn('singular'); + $context_detector->shouldReceive('should_generate_schema')->with('singular')->andReturn(true); + + WP_Mock::userFunction('do_action')->andReturn(null); + WP_Mock::userFunction('apply_filters') + ->andReturnUsing(function ($hook, $value) { + return $value; + }); + + $service = new OutputService($graph_builder, $context_detector); + + ob_start(); + $service->output_schema(); + $output = ob_get_clean(); + + // Extract just the JSON content (between the script tags, before the closing ) + preg_match('/ sequence must not appear unescaped inside the JSON content + $this->assertStringNotContainsString('', $json_content, 'Raw must be escaped inside JSON-LD content'); + // JSON_HEX_TAG encodes < as < and > as > + $this->assertStringContainsString('\u003C', $json_content, 'JSON_HEX_TAG should encode < as \u003C'); + } + + /** + * Test JSON output escapes ampersands (JSON_HEX_AMP) + */ + public function test_json_output_escapes_ampersands(): void + { + $piece = new SchemaPiece('#test', 'Organization'); + $piece->set('name', 'Foo & Bar'); + + $graph = Mockery::mock(SchemaGraph::class); + $graph->shouldReceive('is_empty')->andReturn(false); + $graph->shouldReceive('get_pieces')->andReturn([$piece]); + + $graph_builder = Mockery::mock(GraphBuilder::class); + $graph_builder->shouldReceive('build_for_context')->andReturn($graph); + + $context_detector = Mockery::mock(ContextDetector::class); + $context_detector->shouldReceive('get_current_context')->andReturn('home'); + $context_detector->shouldReceive('should_generate_schema')->with('home')->andReturn(true); + + WP_Mock::userFunction('do_action')->andReturn(null); + WP_Mock::userFunction('apply_filters') + ->andReturnUsing(function ($hook, $value) { + return $value; + }); + + $service = new OutputService($graph_builder, $context_detector); + + ob_start(); + $service->output_schema(); + $output = ob_get_clean(); + + $this->assertStringContainsString('\u0026', $output, 'JSON_HEX_AMP should encode & as \u0026'); + } + + /** + * Test output is wrapped in correct script tag + */ + public function test_output_wrapped_in_ld_json_script_tag(): void + { + $piece = new SchemaPiece('#test', 'WebSite'); + $piece->set('name', 'Test'); + + $graph = Mockery::mock(SchemaGraph::class); + $graph->shouldReceive('is_empty')->andReturn(false); + $graph->shouldReceive('get_pieces')->andReturn([$piece]); + + $graph_builder = Mockery::mock(GraphBuilder::class); + $graph_builder->shouldReceive('build_for_context')->andReturn($graph); + + $context_detector = Mockery::mock(ContextDetector::class); + $context_detector->shouldReceive('get_current_context')->andReturn('home'); + $context_detector->shouldReceive('should_generate_schema')->with('home')->andReturn(true); + + WP_Mock::userFunction('do_action')->andReturn(null); + WP_Mock::userFunction('apply_filters') + ->andReturnUsing(function ($hook, $value) { + return $value; + }); + + $service = new OutputService($graph_builder, $context_detector); + + ob_start(); + $service->output_schema(); + $output = ob_get_clean(); + + $this->assertStringContainsString('', $output); + } + + /** + * Test nothing is output when graph is empty + */ + public function test_no_output_when_graph_empty(): void + { + $graph = Mockery::mock(SchemaGraph::class); + $graph->shouldReceive('is_empty')->andReturn(true); + + $graph_builder = Mockery::mock(GraphBuilder::class); + $graph_builder->shouldReceive('build_for_context')->andReturn($graph); + + $context_detector = Mockery::mock(ContextDetector::class); + $context_detector->shouldReceive('get_current_context')->andReturn('home'); + $context_detector->shouldReceive('should_generate_schema')->with('home')->andReturn(true); + + WP_Mock::userFunction('do_action')->andReturn(null); + WP_Mock::userFunction('apply_filters')->andReturn(true); + + $service = new OutputService($graph_builder, $context_detector); + + ob_start(); + $service->output_schema(); + $output = ob_get_clean(); + + $this->assertEmpty($output); + } +}