diff --git a/src/wp-admin/includes/plugin.php b/src/wp-admin/includes/plugin.php
index fae10f1a679a4..782d22af73173 100644
--- a/src/wp-admin/includes/plugin.php
+++ b/src/wp-admin/includes/plugin.php
@@ -6,6 +6,155 @@
* @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',
+ );
+
+ $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' => '',
+ 'PluginURI' => '',
+ 'Version' => '',
+ 'Description' => '',
+ 'Author' => '',
+ 'AuthorURI' => '',
+ 'TextDomain' => '',
+ 'DomainPath' => '',
+ 'Network' => '',
+ 'RequiresWP' => '',
+ 'RequiresPHP' => '',
+ 'UpdateURI' => '',
+ '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 ] ) ) {
+ 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 +240,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..0ee355c8116b3 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,89 @@ 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'];
+
+ $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_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 ( $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 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.
+ *
+ * @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..296d8fcbb98de
--- /dev/null
+++ b/tests/phpunit/tests/theme/wpThemeJsonMetadata.php
@@ -0,0 +1,309 @@
+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;
+ }
+
+ /**
+ * 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.
+ *
+ * @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 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.
+ *
+ * @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 );
+ }
+ }
+ }
+}