Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 154 additions & 1 deletion src/wp-admin/includes/plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This odd behavior that prevents custom keys stems from https://core.trac.wordpress.org/ticket/8964 — no valid reason was given.

Can we rectify it? It would allow us to quickly deprecate and assign alternative names without changing keys.

}

// 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.
*
Expand Down Expand Up @@ -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'] ) {
Expand Down
182 changes: 160 additions & 22 deletions src/wp-includes/class-wp-theme.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -297,37 +321,63 @@ 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. */
__( 'The theme directory "%s" does not exist.' ),
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', __( '<strong>Error:</strong> 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', __( '<strong>Error:</strong> 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.' ) );
Expand All @@ -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.
Expand Down Expand Up @@ -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.
*
Expand Down
Loading
Loading