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);
+ }
+}