From 84b82eabf440cf40e15880f9448222e99e2d1d9e Mon Sep 17 00:00:00 2001 From: Ben Word Date: Sun, 8 Mar 2026 01:02:36 -0600 Subject: [PATCH 1/2] Add JSON as an alternative to PHP/CSS file headers for plugin and theme metadata --- src/wp-admin/includes/plugin.php | 147 +++++++++- src/wp-includes/class-wp-theme.php | 175 ++++++++++-- src/wp-includes/theme.php | 23 +- tests/phpunit/data/plugins/create_plugin.php | 4 + .../json-mainfile-plugin/bootstrap.php | 2 + .../json-mainfile-plugin.php | 2 + .../plugins/json-mainfile-plugin/plugin.json | 5 + .../json-malformed-plugin.php | 6 + .../plugins/json-malformed-plugin/plugin.json | 1 + .../plugins/json-metadata-plugin/helper.php | 2 + .../json-metadata-plugin.php | 6 + .../plugins/json-metadata-plugin/plugin.json | 14 + .../json-metadata-no-stylesheet/index.php | 2 + .../json-metadata-no-stylesheet/theme.json | 15 + .../themedir1/json-metadata-theme/index.php | 2 + .../themedir1/json-metadata-theme/style.css | 5 + .../themedir1/json-metadata-theme/theme.json | 18 ++ .../admin/includesPluginJsonMetadata.php | 265 ++++++++++++++++++ tests/phpunit/tests/theme/themeDir.php | 2 + .../tests/theme/wpThemeJsonMetadata.php | 247 ++++++++++++++++ 20 files changed, 918 insertions(+), 25 deletions(-) create mode 100644 tests/phpunit/data/plugins/create_plugin.php create mode 100644 tests/phpunit/data/plugins/json-mainfile-plugin/bootstrap.php create mode 100644 tests/phpunit/data/plugins/json-mainfile-plugin/json-mainfile-plugin.php create mode 100644 tests/phpunit/data/plugins/json-mainfile-plugin/plugin.json create mode 100644 tests/phpunit/data/plugins/json-malformed-plugin/json-malformed-plugin.php create mode 100644 tests/phpunit/data/plugins/json-malformed-plugin/plugin.json create mode 100644 tests/phpunit/data/plugins/json-metadata-plugin/helper.php create mode 100644 tests/phpunit/data/plugins/json-metadata-plugin/json-metadata-plugin.php create mode 100644 tests/phpunit/data/plugins/json-metadata-plugin/plugin.json create mode 100644 tests/phpunit/data/themedir1/json-metadata-no-stylesheet/index.php create mode 100644 tests/phpunit/data/themedir1/json-metadata-no-stylesheet/theme.json create mode 100644 tests/phpunit/data/themedir1/json-metadata-theme/index.php create mode 100644 tests/phpunit/data/themedir1/json-metadata-theme/style.css create mode 100644 tests/phpunit/data/themedir1/json-metadata-theme/theme.json create mode 100644 tests/phpunit/tests/admin/includesPluginJsonMetadata.php create mode 100644 tests/phpunit/tests/theme/wpThemeJsonMetadata.php diff --git a/src/wp-admin/includes/plugin.php b/src/wp-admin/includes/plugin.php index fae10f1a679a4..3db0db5c823fc 100644 --- a/src/wp-admin/includes/plugin.php +++ b/src/wp-admin/includes/plugin.php @@ -6,6 +6,147 @@ * @subpackage Administration */ +/** + * Reads plugin metadata from a plugin.json file. + * + * Looks for a plugin.json file in the same directory as the given plugin file. + * Only applies when the plugin file is inside WP_PLUGIN_DIR (not MU-plugins or + * drop-ins), and only for the file designated as the main plugin file. + * + * The main plugin file is determined by the `mainFile` property in plugin.json, + * falling back to a PHP file matching the directory name (e.g. `my-plugin/my-plugin.php`). + * + * @since 7.0.0 + * @access private + * + * @param string $plugin_file Absolute path to a plugin PHP file. + * @return array|false Array of plugin data keyed by header name, or false + * if plugin.json does not exist, does not apply to this file, + * or has no name property. + */ +function _get_plugin_json_data( $plugin_file ) { + $plugin_dir = dirname( $plugin_file ); + $json_file = $plugin_dir . '/plugin.json'; + + /* + * Only apply plugin.json for plugins that are direct subdirectories of WP_PLUGIN_DIR. + * This excludes single-file plugins in the root, MU-plugins, drop-ins, + * and any files outside the standard plugins directory. + */ + $plugin_dir_real = realpath( $plugin_dir ); + $plugin_root_real = defined( 'WP_PLUGIN_DIR' ) ? realpath( WP_PLUGIN_DIR ) : false; + + if ( false === $plugin_dir_real || false === $plugin_root_real ) { + return false; + } + + if ( dirname( $plugin_dir_real ) !== $plugin_root_real ) { + return false; + } + + if ( ! is_readable( $json_file ) ) { + return false; + } + + /* + * Read and decode JSON directly rather than using wp_json_file_decode(). + * + * This function is called during plugin discovery for every PHP file in + * directories that contain a plugin.json. Using wp_json_file_decode() + * would trigger wp_trigger_error() for invalid JSON, which is undesirable + * since the function should fail silently and fall back to PHP file headers. + */ + $contents = @file_get_contents( $json_file ); + + if ( false === $contents ) { + return false; + } + + $json_data = json_decode( $contents, true ); + + if ( ! is_array( $json_data ) || empty( $json_data['name'] ) ) { + return false; + } + + /* + * Only apply plugin.json metadata to the main plugin file. + * This prevents every PHP file in the directory from appearing as a separate plugin. + * + * The main file is determined by: + * 1. The `mainFile` property in plugin.json (e.g. "mainFile": "my-plugin.php"). + * 2. A PHP file matching the directory name (e.g. my-plugin/my-plugin.php). + */ + $dir_name = basename( $plugin_dir ); + $file_name = basename( $plugin_file ); + $is_main_file = false; + + if ( ! empty( $json_data['mainFile'] ) ) { + $is_main_file = ( $file_name === $json_data['mainFile'] ); + } else { + $is_main_file = ( $file_name === $dir_name . '.php' ); + } + + if ( ! $is_main_file ) { + return false; + } + + $key_map = array( + 'name' => 'Name', + 'uri' => 'PluginURI', + 'version' => 'Version', + 'description' => 'Description', + 'author' => 'Author', + 'authorUri' => 'AuthorURI', + 'textDomain' => 'TextDomain', + 'domainPath' => 'DomainPath', + 'network' => 'Network', + 'updateUri' => 'UpdateURI', + ); + + // Initialize all headers to empty strings (matching get_file_data() behavior). + $plugin_data = array( + 'Name' => '', + 'PluginURI' => '', + 'Version' => '', + 'Description' => '', + 'Author' => '', + 'AuthorURI' => '', + 'TextDomain' => '', + 'DomainPath' => '', + 'Network' => '', + 'RequiresWP' => '', + 'RequiresPHP' => '', + 'UpdateURI' => '', + 'RequiresPlugins' => '', + '_sitewide' => '', + ); + + foreach ( $key_map as $json_key => $header_key ) { + if ( isset( $json_data[ $json_key ] ) ) { + if ( 'network' === $json_key ) { + $plugin_data[ $header_key ] = ( true === $json_data[ $json_key ] ) ? 'true' : ''; + } else { + $plugin_data[ $header_key ] = (string) $json_data[ $json_key ]; + } + } + } + + // Map nested requires object. + if ( isset( $json_data['requires'] ) && is_array( $json_data['requires'] ) ) { + if ( isset( $json_data['requires']['wordpress'] ) ) { + $plugin_data['RequiresWP'] = (string) $json_data['requires']['wordpress']; + } + if ( isset( $json_data['requires']['php'] ) ) { + $plugin_data['RequiresPHP'] = (string) $json_data['requires']['php']; + } + if ( isset( $json_data['requires']['plugins'] ) && is_array( $json_data['requires']['plugins'] ) ) { + $plugin_data['RequiresPlugins'] = implode( ', ', $json_data['requires']['plugins'] ); + } + } + + return $plugin_data; +} + /** * Parses the plugin contents to retrieve plugin's metadata. * @@ -91,7 +232,11 @@ function get_plugin_data( $plugin_file, $markup = true, $translate = true ) { '_sitewide' => 'Site Wide Only', ); - $plugin_data = get_file_data( $plugin_file, $default_headers, 'plugin' ); + $plugin_data = _get_plugin_json_data( $plugin_file ); + + if ( ! $plugin_data ) { + $plugin_data = get_file_data( $plugin_file, $default_headers, 'plugin' ); + } // Site Wide Only is the old header for Network. if ( ! $plugin_data['Network'] && $plugin_data['_sitewide'] ) { diff --git a/src/wp-includes/class-wp-theme.php b/src/wp-includes/class-wp-theme.php index a5f2459d490c7..fe54f50db7c3b 100644 --- a/src/wp-includes/class-wp-theme.php +++ b/src/wp-includes/class-wp-theme.php @@ -19,6 +19,30 @@ final class WP_Theme implements ArrayAccess { */ public $update = false; + /** + * Mapping of theme.json metadata keys to internal header keys. + * + * Used when reading theme metadata from the `metadata` property in theme.json + * as an alternative to style.css file headers. + * + * @since 7.0.0 + * @var string[] + */ + private static $json_metadata_keys = array( + 'name' => 'Name', + 'uri' => 'ThemeURI', + 'description' => 'Description', + 'author' => 'Author', + 'authorUri' => 'AuthorURI', + 'version' => 'Version', + 'template' => 'Template', + 'status' => 'Status', + 'tags' => 'Tags', + 'textDomain' => 'TextDomain', + 'domainPath' => 'DomainPath', + 'updateUri' => 'UpdateURI', + ); + /** * Headers for style.css files. * @@ -297,9 +321,9 @@ public function __construct( $theme_dir, $theme_root, $_child = null ) { $theme_root_template = $cache['theme_root_template']; } } elseif ( ! file_exists( $this->theme_root . '/' . $theme_file ) ) { - $this->headers['Name'] = $this->stylesheet; if ( ! file_exists( $this->theme_root . '/' . $this->stylesheet ) ) { - $this->errors = new WP_Error( + $this->headers['Name'] = $this->stylesheet; + $this->errors = new WP_Error( 'theme_not_found', sprintf( /* translators: %s: Theme directory name. */ @@ -307,27 +331,53 @@ public function __construct( $theme_dir, $theme_root, $_child = null ) { esc_html( $this->stylesheet ) ) ); - } else { - $this->errors = new WP_Error( 'theme_no_stylesheet', __( 'Stylesheet is missing.' ) ); + $this->template = $this->stylesheet; + $this->block_theme = false; + $this->block_template_folders = $this->default_template_folders; + $this->cache_add( + 'theme', + array( + 'block_template_folders' => $this->block_template_folders, + 'block_theme' => $this->block_theme, + 'headers' => $this->headers, + 'errors' => $this->errors, + 'stylesheet' => $this->stylesheet, + 'template' => $this->template, + ) + ); + if ( ! file_exists( $this->theme_root ) ) { // Don't cache this one. + $this->errors->add( 'theme_root_missing', __( 'Error: The themes directory is either empty or does not exist. Please check your installation.' ) ); + } + return; } - $this->template = $this->stylesheet; - $this->block_theme = false; - $this->block_template_folders = $this->default_template_folders; - $this->cache_add( - 'theme', - array( - 'block_template_folders' => $this->block_template_folders, - 'block_theme' => $this->block_theme, - 'headers' => $this->headers, - 'errors' => $this->errors, - 'stylesheet' => $this->stylesheet, - 'template' => $this->template, - ) - ); - if ( ! file_exists( $this->theme_root ) ) { // Don't cache this one. - $this->errors->add( 'theme_root_missing', __( 'Error: The themes directory is either empty or does not exist. Please check your installation.' ) ); + + /* + * The theme directory exists but style.css is missing. + * Try reading metadata from theme.json before treating this as an error. + */ + $json_headers = $this->read_json_metadata(); + + if ( ! $json_headers ) { + $this->headers['Name'] = $this->stylesheet; + $this->errors = new WP_Error( 'theme_no_stylesheet', __( 'Stylesheet is missing.' ) ); + $this->template = $this->stylesheet; + $this->block_theme = false; + $this->block_template_folders = $this->default_template_folders; + $this->cache_add( + 'theme', + array( + 'block_template_folders' => $this->block_template_folders, + 'block_theme' => $this->block_theme, + 'headers' => $this->headers, + 'errors' => $this->errors, + 'stylesheet' => $this->stylesheet, + 'template' => $this->template, + ) + ); + return; } - return; + + $this->headers = $json_headers; } elseif ( ! is_readable( $this->theme_root . '/' . $theme_file ) ) { $this->headers['Name'] = $this->stylesheet; $this->errors = new WP_Error( 'theme_stylesheet_not_readable', __( 'Stylesheet is not readable.' ) ); @@ -347,7 +397,12 @@ public function __construct( $theme_dir, $theme_root, $_child = null ) { ); return; } else { - $this->headers = get_file_data( $this->theme_root . '/' . $theme_file, self::$file_headers, 'theme' ); + $this->headers = $this->read_json_metadata(); + + if ( ! $this->headers ) { + $this->headers = get_file_data( $this->theme_root . '/' . $theme_file, self::$file_headers, 'theme' ); + } + /* * Default themes always trump their pretenders. * Properly identify default themes that are inside a directory within wp-content/themes. @@ -799,6 +854,82 @@ public function __wakeup() { $this->headers_sanitized = array(); } + /** + * Reads theme metadata from the `metadata` property in theme.json. + * + * Provides a structured JSON alternative to parsing CSS comment headers + * in style.css. When a theme.json file contains a `metadata` property, + * its values are mapped to the internal header format used by WP_Theme. + * + * @since 7.0.0 + * + * @return array|false Array of theme headers keyed by header name, or false + * if theme.json does not exist or has no metadata property. + */ + private function read_json_metadata() { + $theme_json_file = $this->theme_root . '/' . $this->stylesheet . '/theme.json'; + + if ( ! is_readable( $theme_json_file ) ) { + return false; + } + + /* + * Read and decode JSON directly rather than using wp_json_file_decode(). + * + * This method is called during theme enumeration for every theme that has + * a theme.json file, including themes without a `metadata` property and + * themes with malformed JSON. Using wp_json_file_decode() would trigger + * wp_trigger_error() for invalid JSON, which is undesirable here since + * the existing WP_Theme_JSON_Resolver handles JSON errors when it needs + * to parse theme settings. This method should fail silently and fall back + * to style.css headers. + */ + $contents = @file_get_contents( $theme_json_file ); + + if ( false === $contents ) { + return false; + } + + $theme_json_data = json_decode( $contents, true ); + + if ( ! is_array( $theme_json_data ) || ! isset( $theme_json_data['metadata'] ) || ! is_array( $theme_json_data['metadata'] ) ) { + return false; + } + + $metadata = $theme_json_data['metadata']; + + // Initialize all headers to empty strings (matching get_file_data() behavior). + $headers = array_fill_keys( array_keys( self::$file_headers ), '' ); + + // Map JSON metadata keys to internal header keys. + foreach ( self::$json_metadata_keys as $json_key => $header_key ) { + if ( isset( $metadata[ $json_key ] ) ) { + if ( 'tags' === $json_key && is_array( $metadata[ $json_key ] ) ) { + $headers[ $header_key ] = implode( ', ', $metadata[ $json_key ] ); + } else { + $headers[ $header_key ] = (string) $metadata[ $json_key ]; + } + } + } + + // Map nested requires object. + if ( isset( $metadata['requires'] ) && is_array( $metadata['requires'] ) ) { + if ( isset( $metadata['requires']['wordpress'] ) ) { + $headers['RequiresWP'] = (string) $metadata['requires']['wordpress']; + } + if ( isset( $metadata['requires']['php'] ) ) { + $headers['RequiresPHP'] = (string) $metadata['requires']['php']; + } + } + + // Only return headers if at least a name was provided. + if ( empty( $headers['Name'] ) ) { + return false; + } + + return $headers; + } + /** * Adds theme data to cache. * diff --git a/src/wp-includes/theme.php b/src/wp-includes/theme.php index cd83d15d1b914..89e8866d0f637 100644 --- a/src/wp-includes/theme.php +++ b/src/wp-includes/theme.php @@ -896,6 +896,25 @@ function switch_theme( $stylesheet ) { * @return bool */ function validate_current_theme() { + $has_json_metadata_name = static function ( $theme_directory ) { + $theme_json_file = $theme_directory . '/theme.json'; + + if ( ! is_readable( $theme_json_file ) ) { + return false; + } + + $contents = @file_get_contents( $theme_json_file ); + if ( false === $contents ) { + return false; + } + + $theme_json_data = json_decode( $contents, true ); + return is_array( $theme_json_data ) + && isset( $theme_json_data['metadata'] ) + && is_array( $theme_json_data['metadata'] ) + && ! empty( $theme_json_data['metadata']['name'] ); + }; + /** * Filters whether to validate the active theme. * @@ -913,9 +932,9 @@ function validate_current_theme() { && ! file_exists( get_template_directory() . '/index.php' ) ) { // Invalid. - } elseif ( ! file_exists( get_template_directory() . '/style.css' ) ) { + } elseif ( ! file_exists( get_template_directory() . '/style.css' ) && ! $has_json_metadata_name( get_template_directory() ) ) { // Invalid. - } elseif ( is_child_theme() && ! file_exists( get_stylesheet_directory() . '/style.css' ) ) { + } elseif ( is_child_theme() && ! file_exists( get_stylesheet_directory() . '/style.css' ) && ! $has_json_metadata_name( get_stylesheet_directory() ) ) { // Invalid. } else { // Valid. diff --git a/tests/phpunit/data/plugins/create_plugin.php b/tests/phpunit/data/plugins/create_plugin.php new file mode 100644 index 0000000000000..0f35d111136f8 --- /dev/null +++ b/tests/phpunit/data/plugins/create_plugin.php @@ -0,0 +1,4 @@ +assertIsArray( $plugin_data ); + $this->assertSame( 'JSON Metadata Plugin', $plugin_data['Name'] ); + $this->assertSame( 'https://example.com/json-metadata-plugin', $plugin_data['PluginURI'] ); + $this->assertSame( 'A plugin using JSON metadata', $plugin_data['Description'] ); + $this->assertSame( 'Plugin Author', $plugin_data['Author'] ); + $this->assertSame( 'https://example.com/author', $plugin_data['AuthorURI'] ); + $this->assertSame( '1.0.0', $plugin_data['Version'] ); + $this->assertSame( 'json-metadata-plugin', $plugin_data['TextDomain'] ); + $this->assertSame( '6.0', $plugin_data['RequiresWP'] ); + $this->assertSame( '8.0', $plugin_data['RequiresPHP'] ); + } + + /** + * Tests that plugin dependencies are parsed from JSON array. + * + * Creates a temporary plugin inside WP_PLUGIN_DIR to exercise the real + * _get_plugin_json_data() code path, then cleans up immediately. + * + * @ticket 24152 + */ + public function test_plugin_json_requires_plugins_is_comma_separated() { + $plugin_dir = WP_PLUGIN_DIR . '/json-deps-test-' . uniqid(); + $dir_name = basename( $plugin_dir ); + $plugin_file = $plugin_dir . '/' . $dir_name . '.php'; + + mkdir( $plugin_dir ); + file_put_contents( + $plugin_dir . '/plugin.json', + wp_json_encode( + array( + 'name' => 'Deps Test Plugin', + 'version' => '1.0.0', + 'requires' => array( + 'plugins' => array( 'woocommerce', 'jetpack' ), + ), + ) + ) + ); + file_put_contents( $plugin_file, 'assertIsArray( $plugin_data ); + $this->assertSame( 'woocommerce, jetpack', $plugin_data['RequiresPlugins'] ); + } + + /** + * Tests that plugin.json takes priority over PHP file headers. + * + * @ticket 24152 + */ + public function test_plugin_json_takes_priority_over_file_headers() { + $plugin_file = DIR_TESTDATA . '/plugins/json-metadata-plugin/json-metadata-plugin.php'; + $plugin_data = get_plugin_data( $plugin_file, false, false ); + + $this->assertSame( 'JSON Metadata Plugin', $plugin_data['Name'] ); + $this->assertSame( '1.0.0', $plugin_data['Version'] ); + } + + /** + * Tests that single-file plugins in the root directory return false. + * + * @ticket 24152 + */ + public function test_single_file_plugin_returns_false() { + $plugin_file = WP_PLUGIN_DIR . '/hello.php'; + $plugin_data = _get_plugin_json_data( $plugin_file ); + + $this->assertFalse( $plugin_data ); + } + + /** + * Tests fallback to PHP file headers when no plugin.json exists. + * + * @ticket 24152 + */ + public function test_falls_back_to_file_headers_without_plugin_json() { + $plugin_file = DIR_TESTDATA . '/plugins/hello.php'; + $plugin_data = _get_plugin_json_data( $plugin_file ); + + $this->assertFalse( $plugin_data ); + } + + /** + * Tests that non-main PHP files in a directory with plugin.json are not affected. + * + * Only the main plugin file (matching directory name or mainFile property) + * should receive JSON metadata. + * + * @ticket 24152 + */ + public function test_non_main_php_file_returns_false() { + $plugin_file = DIR_TESTDATA . '/plugins/json-metadata-plugin/helper.php'; + $plugin_data = _get_plugin_json_data( $plugin_file ); + + $this->assertFalse( $plugin_data ); + } + + /** + * Tests that malformed plugin.json falls back to PHP file headers. + * + * @ticket 24152 + */ + public function test_malformed_plugin_json_falls_back_to_file_headers() { + $plugin_file = DIR_TESTDATA . '/plugins/json-malformed-plugin/json-malformed-plugin.php'; + + $plugin_data = get_plugin_data( $plugin_file, false, false ); + + $this->assertSame( 'Malformed JSON Fallback Plugin', $plugin_data['Name'] ); + $this->assertSame( '3.0.0', $plugin_data['Version'] ); + } + + /** + * Tests that the mainFile property in plugin.json designates the main plugin file. + * + * @ticket 24152 + */ + public function test_main_file_property_designates_main_plugin_file() { + $main_file = DIR_TESTDATA . '/plugins/json-mainfile-plugin/bootstrap.php'; + $other_file = DIR_TESTDATA . '/plugins/json-mainfile-plugin/json-mainfile-plugin.php'; + + $main_data = _get_plugin_json_data( $main_file ); + $other_data = _get_plugin_json_data( $other_file ); + + $this->assertIsArray( $main_data ); + $this->assertSame( 'Main File Plugin', $main_data['Name'] ); + $this->assertFalse( $other_data ); + } + + /** + * Tests that the network flag only accepts a strict boolean true. + * + * @ticket 24152 + */ + public function test_plugin_json_network_requires_strict_boolean_true() { + $plugin_dir = WP_PLUGIN_DIR . '/json-network-test-' . uniqid(); + $dir_name = basename( $plugin_dir ); + + mkdir( $plugin_dir ); + + file_put_contents( + $plugin_dir . '/plugin.json', + wp_json_encode( + array( + 'name' => 'Network True Plugin', + 'version' => '1.0.0', + 'network' => true, + ) + ) + ); + file_put_contents( $plugin_dir . '/' . $dir_name . '.php', ' 'Network String False Plugin', + 'version' => '1.0.0', + 'network' => 'false', + ) + ) + ); + $string_false_data = _get_plugin_json_data( $plugin_dir . '/' . $dir_name . '.php' ); + + file_put_contents( + $plugin_dir . '/plugin.json', + wp_json_encode( + array( + 'name' => 'Network Int One Plugin', + 'version' => '1.0.0', + 'network' => 1, + ) + ) + ); + $int_one_data = _get_plugin_json_data( $plugin_dir . '/' . $dir_name . '.php' ); + + unlink( $plugin_dir . '/plugin.json' ); + unlink( $plugin_dir . '/' . $dir_name . '.php' ); + rmdir( $plugin_dir ); + wp_clean_plugins_cache(); + + $this->assertSame( 'true', $true_data['Network'] ); + $this->assertSame( '', $string_false_data['Network'] ); + $this->assertSame( '', $int_one_data['Network'] ); + } + + /** + * Tests that plugin.json does not affect MU-plugins. + * + * @ticket 24152 + */ + public function test_plugin_json_does_not_affect_mu_plugins() { + // A file outside WP_PLUGIN_DIR should not be affected by plugin.json. + $mu_plugin_file = WPMU_PLUGIN_DIR . '/some-mu-plugin.php'; + $plugin_data = _get_plugin_json_data( $mu_plugin_file ); + + $this->assertFalse( $plugin_data ); + } + + /** + * Tests that plugin.json does not affect files in MU-plugin subdirectories. + * + * @ticket 24152 + */ + public function test_plugin_json_does_not_affect_mu_plugin_subdirectories() { + if ( ! is_dir( WPMU_PLUGIN_DIR ) ) { + mkdir( WPMU_PLUGIN_DIR ); + } + + $mu_subdir = WPMU_PLUGIN_DIR . '/json-mu-subdir-test-' . uniqid(); + $dir_name = basename( $mu_subdir ); + $mu_file = $mu_subdir . '/' . $dir_name . '.php'; + + mkdir( $mu_subdir ); + file_put_contents( + $mu_subdir . '/plugin.json', + wp_json_encode( + array( + 'name' => 'MU Subdir JSON Plugin', + 'version' => '1.0.0', + ) + ) + ); + file_put_contents( $mu_file, 'assertFalse( $plugin_data ); + } +} diff --git a/tests/phpunit/tests/theme/themeDir.php b/tests/phpunit/tests/theme/themeDir.php index a953a04bc5533..5519c895edd61 100644 --- a/tests/phpunit/tests/theme/themeDir.php +++ b/tests/phpunit/tests/theme/themeDir.php @@ -192,6 +192,8 @@ public function test_theme_list() { 'Block Theme with Hooked Blocks', 'Empty `fontFace` in theme.json - no webfonts defined', 'A theme with the Update URI header', + 'JSON Metadata Theme', + 'JSON Only Theme', ); $this->assertSameSets( $expected, $theme_names ); diff --git a/tests/phpunit/tests/theme/wpThemeJsonMetadata.php b/tests/phpunit/tests/theme/wpThemeJsonMetadata.php new file mode 100644 index 0000000000000..a685f16a53afa --- /dev/null +++ b/tests/phpunit/tests/theme/wpThemeJsonMetadata.php @@ -0,0 +1,247 @@ +theme_root = realpath( DIR_TESTDATA . '/themedir1' ); + + $this->orig_theme_dir = $GLOBALS['wp_theme_directories']; + $GLOBALS['wp_theme_directories'] = array( $this->theme_root ); + $this->orig_template = get_option( 'template' ); + $this->orig_stylesheet = get_option( 'stylesheet' ); + + add_filter( 'theme_root', array( $this, '_theme_root' ) ); + add_filter( 'stylesheet_root', array( $this, '_theme_root' ) ); + add_filter( 'template_root', array( $this, '_theme_root' ) ); + // Clear caches. + wp_clean_themes_cache(); + unset( $GLOBALS['wp_themes'] ); + } + + public function tear_down() { + update_option( 'template', $this->orig_template ); + update_option( 'stylesheet', $this->orig_stylesheet ); + $GLOBALS['wp_theme_directories'] = $this->orig_theme_dir; + wp_clean_themes_cache(); + unset( $GLOBALS['wp_themes'] ); + parent::tear_down(); + } + + public function _theme_root( $dir ) { + return $this->theme_root; + } + + /** + * Tests that theme.json metadata takes priority over style.css headers. + * + * @ticket 24152 + */ + public function test_theme_json_metadata_takes_priority_over_stylesheet_headers() { + $theme = new WP_Theme( 'json-metadata-theme', $this->theme_root ); + + $this->assertSame( 'JSON Metadata Theme', $theme->get( 'Name' ) ); + $this->assertSame( 'https://example.com/json-metadata-theme', $theme->get( 'ThemeURI' ) ); + $this->assertSame( 'A theme using JSON metadata in theme.json', $theme->get( 'Description' ) ); + $this->assertSame( 'Theme Author', $theme->get( 'Author' ) ); + $this->assertSame( 'https://example.com/author', $theme->get( 'AuthorURI' ) ); + $this->assertSame( '1.0.0', $theme->get( 'Version' ) ); + $this->assertSame( 'json-metadata-theme', $theme->get( 'TextDomain' ) ); + $this->assertSame( '6.0', $theme->get( 'RequiresWP' ) ); + $this->assertSame( '8.0', $theme->get( 'RequiresPHP' ) ); + } + + /** + * Tests that tags are properly converted from JSON array to comma-separated string. + * + * @ticket 24152 + */ + public function test_theme_json_metadata_tags_are_parsed() { + $theme = new WP_Theme( 'json-metadata-theme', $this->theme_root ); + + $this->assertSame( array( 'blog', 'custom-colors' ), $theme->get( 'Tags' ) ); + } + + /** + * Tests that a theme with theme.json metadata but no style.css is valid. + * + * @ticket 24152 + */ + public function test_theme_json_metadata_without_stylesheet() { + $theme = new WP_Theme( 'json-metadata-no-stylesheet', $this->theme_root ); + + $this->assertSame( 'JSON Only Theme', $theme->get( 'Name' ) ); + $this->assertSame( '2.0.0', $theme->get( 'Version' ) ); + $this->assertSame( '7.0', $theme->get( 'RequiresWP' ) ); + $this->assertSame( '8.2', $theme->get( 'RequiresPHP' ) ); + $this->assertFalse( $theme->errors() ); + } + + /** + * Tests that style.css headers are used when theme.json has no metadata property. + * + * @ticket 24152 + */ + public function test_falls_back_to_stylesheet_headers_without_json_metadata() { + $theme = new WP_Theme( 'default', $this->theme_root ); + + $this->assertSame( 'WordPress Default', $theme->get( 'Name' ) ); + $this->assertFalse( $theme->errors() ); + } + + /** + * Tests that malformed theme.json silently falls back to style.css headers. + * + * @ticket 24152 + */ + public function test_malformed_theme_json_falls_back_to_stylesheet_headers() { + $theme = new WP_Theme( 'block-theme-non-latin', $this->theme_root ); + + // Should fall back to style.css headers without errors from JSON parsing. + $this->assertNotEmpty( $theme->get( 'Name' ) ); + } + + /** + * Tests that a block theme with theme.json but no metadata property uses style.css. + * + * @ticket 24152 + */ + public function test_block_theme_without_metadata_property_uses_stylesheet() { + $theme = new WP_Theme( 'block-theme', $this->theme_root ); + + // block-theme has theme.json for settings but no metadata property. + $this->assertSame( 'Block Theme', $theme->get( 'Name' ) ); + $this->assertFalse( $theme->errors() ); + } + + /** + * Tests that active theme validation allows a JSON-metadata theme without style.css. + * + * @ticket 24152 + */ + public function test_validate_current_theme_allows_json_metadata_without_stylesheet() { + update_option( 'template', 'json-metadata-no-stylesheet' ); + update_option( 'stylesheet', 'json-metadata-no-stylesheet' ); + wp_clean_themes_cache(); + unset( $GLOBALS['wp_themes'] ); + + $this->assertTrue( validate_current_theme() ); + } + + /** + * Tests that active theme validation allows a child theme without style.css when JSON metadata is present. + * + * @ticket 24152 + */ + public function test_validate_current_theme_allows_child_json_metadata_without_stylesheet() { + $child_slug = 'json-metadata-child-no-stylesheet-' . wp_generate_password( 8, false ); + $child_dir = $this->theme_root . '/' . $child_slug; + + mkdir( $child_dir ); + try { + file_put_contents( + $child_dir . '/theme.json', + wp_json_encode( + array( + 'version' => 3, + 'metadata' => array( + 'name' => 'Temporary JSON Child Theme', + 'template' => 'json-metadata-theme', + ), + ) + ) + ); + file_put_contents( $child_dir . '/index.php', "assertTrue( validate_current_theme() ); + } finally { + if ( file_exists( $child_dir . '/theme.json' ) ) { + unlink( $child_dir . '/theme.json' ); + } + if ( file_exists( $child_dir . '/index.php' ) ) { + unlink( $child_dir . '/index.php' ); + } + if ( is_dir( $child_dir ) ) { + rmdir( $child_dir ); + } + } + } + + /** + * Tests that active theme validation rejects malformed theme.json when style.css is missing. + * + * @ticket 24152 + */ + public function test_validate_current_theme_rejects_malformed_json_without_stylesheet() { + $theme_slug = 'json-metadata-malformed-no-stylesheet-' . wp_generate_password( 8, false ); + $theme_dir = $this->theme_root . '/' . $theme_slug; + + mkdir( $theme_dir ); + try { + file_put_contents( $theme_dir . '/theme.json', '{ this is not valid json }' ); + file_put_contents( $theme_dir . '/index.php', "assertFalse( validate_current_theme() ); + $this->assertNotSame( $theme_slug, get_option( 'template' ) ); + $this->assertNotSame( $theme_slug, get_option( 'stylesheet' ) ); + } finally { + if ( file_exists( $theme_dir . '/theme.json' ) ) { + unlink( $theme_dir . '/theme.json' ); + } + if ( file_exists( $theme_dir . '/index.php' ) ) { + unlink( $theme_dir . '/index.php' ); + } + if ( is_dir( $theme_dir ) ) { + rmdir( $theme_dir ); + } + } + } +} From 579784fe8f9a34b70e04e6aff70c22528bb55699 Mon Sep 17 00:00:00 2001 From: Ben Word Date: Sun, 8 Mar 2026 01:55:14 -0600 Subject: [PATCH 2/2] Support extra header filters in JSON metadata parsing --- src/wp-admin/includes/plugin.php | 8 +++ src/wp-includes/class-wp-theme.php | 11 +++- .../admin/includesPluginJsonMetadata.php | 53 ++++++++++++++++ .../tests/theme/wpThemeJsonMetadata.php | 62 +++++++++++++++++++ 4 files changed, 132 insertions(+), 2 deletions(-) diff --git a/src/wp-admin/includes/plugin.php b/src/wp-admin/includes/plugin.php index 3db0db5c823fc..782d22af73173 100644 --- a/src/wp-admin/includes/plugin.php +++ b/src/wp-admin/includes/plugin.php @@ -103,6 +103,11 @@ function _get_plugin_json_data( $plugin_file ) { 'updateUri' => 'UpdateURI', ); + $extra_plugin_headers = (array) apply_filters( 'extra_plugin_headers', array() ); + foreach ( $extra_plugin_headers as $extra_header ) { + $key_map[ $extra_header ] = $extra_header; + } + // Initialize all headers to empty strings (matching get_file_data() behavior). $plugin_data = array( 'Name' => '', @@ -120,6 +125,9 @@ function _get_plugin_json_data( $plugin_file ) { 'RequiresPlugins' => '', '_sitewide' => '', ); + foreach ( $extra_plugin_headers as $extra_header ) { + $plugin_data[ $extra_header ] = ''; + } foreach ( $key_map as $json_key => $header_key ) { if ( isset( $json_data[ $json_key ] ) ) { diff --git a/src/wp-includes/class-wp-theme.php b/src/wp-includes/class-wp-theme.php index fe54f50db7c3b..0ee355c8116b3 100644 --- a/src/wp-includes/class-wp-theme.php +++ b/src/wp-includes/class-wp-theme.php @@ -898,11 +898,18 @@ private function read_json_metadata() { $metadata = $theme_json_data['metadata']; + $extra_theme_headers = (array) apply_filters( 'extra_theme_headers', array() ); + // Initialize all headers to empty strings (matching get_file_data() behavior). - $headers = array_fill_keys( array_keys( self::$file_headers ), '' ); + $headers = array_fill_keys( array_merge( array_keys( self::$file_headers ), $extra_theme_headers ), '' ); + + $json_metadata_keys = self::$json_metadata_keys; + foreach ( $extra_theme_headers as $extra_header ) { + $json_metadata_keys[ $extra_header ] = $extra_header; + } // Map JSON metadata keys to internal header keys. - foreach ( self::$json_metadata_keys as $json_key => $header_key ) { + foreach ( $json_metadata_keys as $json_key => $header_key ) { if ( isset( $metadata[ $json_key ] ) ) { if ( 'tags' === $json_key && is_array( $metadata[ $json_key ] ) ) { $headers[ $header_key ] = implode( ', ', $metadata[ $json_key ] ); diff --git a/tests/phpunit/tests/admin/includesPluginJsonMetadata.php b/tests/phpunit/tests/admin/includesPluginJsonMetadata.php index f7c64904b684c..8f6a8ae55150a 100644 --- a/tests/phpunit/tests/admin/includesPluginJsonMetadata.php +++ b/tests/phpunit/tests/admin/includesPluginJsonMetadata.php @@ -12,6 +12,17 @@ */ class Tests_Admin_IncludesPluginJsonMetadata extends WP_UnitTestCase { + /** + * Registers an extra plugin header for testing. + * + * @param string[] $headers Existing extra headers. + * @return string[] Filtered extra headers. + */ + public function filter_extra_plugin_headers( $headers ) { + $headers[] = 'Custom Header'; + return $headers; + } + /** * Tests that plugin.json metadata is read correctly. * @@ -214,6 +225,48 @@ public function test_plugin_json_network_requires_strict_boolean_true() { $this->assertSame( '', $int_one_data['Network'] ); } + /** + * Tests that custom plugin headers registered via extra_plugin_headers are read from plugin.json. + * + * @ticket 24152 + */ + public function test_plugin_json_supports_extra_plugin_headers() { + $plugin_dir = WP_PLUGIN_DIR . '/json-custom-header-test-' . uniqid(); + $dir_name = basename( $plugin_dir ); + $plugin_file = $plugin_dir . '/' . $dir_name . '.php'; + + add_filter( 'extra_plugin_headers', array( $this, 'filter_extra_plugin_headers' ) ); + mkdir( $plugin_dir ); + try { + file_put_contents( + $plugin_dir . '/plugin.json', + wp_json_encode( + array( + 'name' => 'Custom Header Plugin', + 'version' => '1.0.0', + 'Custom Header' => 'Custom Header Value', + ) + ) + ); + file_put_contents( $plugin_file, 'assertSame( 'Custom Header Value', $plugin_data['Custom Header'] ); + } finally { + remove_filter( 'extra_plugin_headers', array( $this, 'filter_extra_plugin_headers' ) ); + if ( file_exists( $plugin_dir . '/plugin.json' ) ) { + unlink( $plugin_dir . '/plugin.json' ); + } + if ( file_exists( $plugin_file ) ) { + unlink( $plugin_file ); + } + if ( is_dir( $plugin_dir ) ) { + rmdir( $plugin_dir ); + } + wp_clean_plugins_cache(); + } + } + /** * Tests that plugin.json does not affect MU-plugins. * diff --git a/tests/phpunit/tests/theme/wpThemeJsonMetadata.php b/tests/phpunit/tests/theme/wpThemeJsonMetadata.php index a685f16a53afa..296d8fcbb98de 100644 --- a/tests/phpunit/tests/theme/wpThemeJsonMetadata.php +++ b/tests/phpunit/tests/theme/wpThemeJsonMetadata.php @@ -70,6 +70,17 @@ public function _theme_root( $dir ) { return $this->theme_root; } + /** + * Registers an extra theme header for testing. + * + * @param string[] $headers Existing extra headers. + * @return string[] Filtered extra headers. + */ + public function filter_extra_theme_headers( $headers ) { + $headers[] = 'Custom Header'; + return $headers; + } + /** * Tests that theme.json metadata takes priority over style.css headers. * @@ -152,6 +163,57 @@ public function test_block_theme_without_metadata_property_uses_stylesheet() { $this->assertFalse( $theme->errors() ); } + /** + * Tests that custom theme headers registered via extra_theme_headers are read from theme.json metadata. + * + * @ticket 24152 + */ + public function test_theme_json_supports_extra_theme_headers() { + $theme_slug = 'json-custom-header-theme-' . wp_generate_password( 8, false ); + $theme_dir = $this->theme_root . '/' . $theme_slug; + + add_filter( 'extra_theme_headers', array( $this, 'filter_extra_theme_headers' ) ); + mkdir( $theme_dir ); + try { + file_put_contents( + $theme_dir . '/theme.json', + wp_json_encode( + array( + 'version' => 3, + 'metadata' => array( + 'name' => 'JSON Custom Header Theme', + 'Custom Header' => 'Custom Theme Header Value', + ), + ) + ) + ); + file_put_contents( $theme_dir . '/style.css', "/*\nTheme Name: CSS Fallback\n*/\n" ); + file_put_contents( $theme_dir . '/index.php', "theme_root ); + $this->assertSame( 'Custom Theme Header Value', $theme->get( 'Custom Header' ) ); + } finally { + remove_filter( 'extra_theme_headers', array( $this, 'filter_extra_theme_headers' ) ); + if ( file_exists( $theme_dir . '/theme.json' ) ) { + unlink( $theme_dir . '/theme.json' ); + } + if ( file_exists( $theme_dir . '/style.css' ) ) { + unlink( $theme_dir . '/style.css' ); + } + if ( file_exists( $theme_dir . '/index.php' ) ) { + unlink( $theme_dir . '/index.php' ); + } + if ( is_dir( $theme_dir ) ) { + rmdir( $theme_dir ); + } + wp_clean_themes_cache(); + unset( $GLOBALS['wp_themes'] ); + } + } + /** * Tests that active theme validation allows a JSON-metadata theme without style.css. *