Skip to content

Commit 693b1a7

Browse files
committed
Add JSON as an alternative to PHP/CSS file headers for plugin and theme metadata
1 parent 64086b2 commit 693b1a7

20 files changed

Lines changed: 893 additions & 25 deletions

File tree

src/wp-admin/includes/plugin.php

Lines changed: 146 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,147 @@
66
* @subpackage Administration
77
*/
88

9+
/**
10+
* Reads plugin metadata from a plugin.json file.
11+
*
12+
* Looks for a plugin.json file in the same directory as the given plugin file.
13+
* Only applies when the plugin file is inside WP_PLUGIN_DIR (not MU-plugins or
14+
* drop-ins), and only for the file designated as the main plugin file.
15+
*
16+
* The main plugin file is determined by the `mainFile` property in plugin.json,
17+
* falling back to a PHP file matching the directory name (e.g. `my-plugin/my-plugin.php`).
18+
*
19+
* @since 7.0.0
20+
* @access private
21+
*
22+
* @param string $plugin_file Absolute path to a plugin PHP file.
23+
* @return array|false Array of plugin data keyed by header name, or false
24+
* if plugin.json does not exist, does not apply to this file,
25+
* or has no name property.
26+
*/
27+
function _get_plugin_json_data( $plugin_file ) {
28+
$plugin_dir = dirname( $plugin_file );
29+
$json_file = $plugin_dir . '/plugin.json';
30+
31+
/*
32+
* Only apply plugin.json for plugins that are direct subdirectories of WP_PLUGIN_DIR.
33+
* This excludes single-file plugins in the root, MU-plugins, drop-ins,
34+
* and any files outside the standard plugins directory.
35+
*/
36+
$plugin_dir_real = realpath( $plugin_dir );
37+
$plugin_root_real = defined( 'WP_PLUGIN_DIR' ) ? realpath( WP_PLUGIN_DIR ) : false;
38+
39+
if ( false === $plugin_dir_real || false === $plugin_root_real ) {
40+
return false;
41+
}
42+
43+
if ( dirname( $plugin_dir_real ) !== $plugin_root_real ) {
44+
return false;
45+
}
46+
47+
if ( ! is_readable( $json_file ) ) {
48+
return false;
49+
}
50+
51+
/*
52+
* Read and decode JSON directly rather than using wp_json_file_decode().
53+
*
54+
* This function is called during plugin discovery for every PHP file in
55+
* directories that contain a plugin.json. Using wp_json_file_decode()
56+
* would trigger wp_trigger_error() for invalid JSON, which is undesirable
57+
* since the function should fail silently and fall back to PHP file headers.
58+
*/
59+
$contents = @file_get_contents( $json_file );
60+
61+
if ( false === $contents ) {
62+
return false;
63+
}
64+
65+
$json_data = json_decode( $contents, true );
66+
67+
if ( ! is_array( $json_data ) || empty( $json_data['name'] ) ) {
68+
return false;
69+
}
70+
71+
/*
72+
* Only apply plugin.json metadata to the main plugin file.
73+
* This prevents every PHP file in the directory from appearing as a separate plugin.
74+
*
75+
* The main file is determined by:
76+
* 1. The `mainFile` property in plugin.json (e.g. "mainFile": "my-plugin.php").
77+
* 2. A PHP file matching the directory name (e.g. my-plugin/my-plugin.php).
78+
*/
79+
$dir_name = basename( $plugin_dir );
80+
$file_name = basename( $plugin_file );
81+
$is_main_file = false;
82+
83+
if ( ! empty( $json_data['mainFile'] ) ) {
84+
$is_main_file = ( $file_name === $json_data['mainFile'] );
85+
} else {
86+
$is_main_file = ( $file_name === $dir_name . '.php' );
87+
}
88+
89+
if ( ! $is_main_file ) {
90+
return false;
91+
}
92+
93+
$key_map = array(
94+
'name' => 'Name',
95+
'uri' => 'PluginURI',
96+
'version' => 'Version',
97+
'description' => 'Description',
98+
'author' => 'Author',
99+
'authorUri' => 'AuthorURI',
100+
'textDomain' => 'TextDomain',
101+
'domainPath' => 'DomainPath',
102+
'network' => 'Network',
103+
'updateUri' => 'UpdateURI',
104+
);
105+
106+
// Initialize all headers to empty strings (matching get_file_data() behavior).
107+
$plugin_data = array(
108+
'Name' => '',
109+
'PluginURI' => '',
110+
'Version' => '',
111+
'Description' => '',
112+
'Author' => '',
113+
'AuthorURI' => '',
114+
'TextDomain' => '',
115+
'DomainPath' => '',
116+
'Network' => '',
117+
'RequiresWP' => '',
118+
'RequiresPHP' => '',
119+
'UpdateURI' => '',
120+
'RequiresPlugins' => '',
121+
'_sitewide' => '',
122+
);
123+
124+
foreach ( $key_map as $json_key => $header_key ) {
125+
if ( isset( $json_data[ $json_key ] ) ) {
126+
if ( 'network' === $json_key ) {
127+
$plugin_data[ $header_key ] = ( true === $json_data[ $json_key ] ) ? 'true' : '';
128+
} else {
129+
$plugin_data[ $header_key ] = (string) $json_data[ $json_key ];
130+
}
131+
}
132+
}
133+
134+
// Map nested requires object.
135+
if ( isset( $json_data['requires'] ) && is_array( $json_data['requires'] ) ) {
136+
if ( isset( $json_data['requires']['wordpress'] ) ) {
137+
$plugin_data['RequiresWP'] = (string) $json_data['requires']['wordpress'];
138+
}
139+
if ( isset( $json_data['requires']['php'] ) ) {
140+
$plugin_data['RequiresPHP'] = (string) $json_data['requires']['php'];
141+
}
142+
if ( isset( $json_data['requires']['plugins'] ) && is_array( $json_data['requires']['plugins'] ) ) {
143+
$plugin_data['RequiresPlugins'] = implode( ', ', $json_data['requires']['plugins'] );
144+
}
145+
}
146+
147+
return $plugin_data;
148+
}
149+
9150
/**
10151
* Parses the plugin contents to retrieve plugin's metadata.
11152
*
@@ -91,7 +232,11 @@ function get_plugin_data( $plugin_file, $markup = true, $translate = true ) {
91232
'_sitewide' => 'Site Wide Only',
92233
);
93234

94-
$plugin_data = get_file_data( $plugin_file, $default_headers, 'plugin' );
235+
$plugin_data = _get_plugin_json_data( $plugin_file );
236+
237+
if ( ! $plugin_data ) {
238+
$plugin_data = get_file_data( $plugin_file, $default_headers, 'plugin' );
239+
}
95240

96241
// Site Wide Only is the old header for Network.
97242
if ( ! $plugin_data['Network'] && $plugin_data['_sitewide'] ) {

src/wp-includes/class-wp-theme.php

Lines changed: 153 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,30 @@ final class WP_Theme implements ArrayAccess {
1919
*/
2020
public $update = false;
2121

22+
/**
23+
* Mapping of theme.json metadata keys to internal header keys.
24+
*
25+
* Used when reading theme metadata from the `metadata` property in theme.json
26+
* as an alternative to style.css file headers.
27+
*
28+
* @since 7.0.0
29+
* @var string[]
30+
*/
31+
private static $json_metadata_keys = array(
32+
'name' => 'Name',
33+
'uri' => 'ThemeURI',
34+
'description' => 'Description',
35+
'author' => 'Author',
36+
'authorUri' => 'AuthorURI',
37+
'version' => 'Version',
38+
'template' => 'Template',
39+
'status' => 'Status',
40+
'tags' => 'Tags',
41+
'textDomain' => 'TextDomain',
42+
'domainPath' => 'DomainPath',
43+
'updateUri' => 'UpdateURI',
44+
);
45+
2246
/**
2347
* Headers for style.css files.
2448
*
@@ -297,37 +321,63 @@ public function __construct( $theme_dir, $theme_root, $_child = null ) {
297321
$theme_root_template = $cache['theme_root_template'];
298322
}
299323
} elseif ( ! file_exists( $this->theme_root . '/' . $theme_file ) ) {
300-
$this->headers['Name'] = $this->stylesheet;
301324
if ( ! file_exists( $this->theme_root . '/' . $this->stylesheet ) ) {
302-
$this->errors = new WP_Error(
325+
$this->headers['Name'] = $this->stylesheet;
326+
$this->errors = new WP_Error(
303327
'theme_not_found',
304328
sprintf(
305329
/* translators: %s: Theme directory name. */
306330
__( 'The theme directory "%s" does not exist.' ),
307331
esc_html( $this->stylesheet )
308332
)
309333
);
310-
} else {
311-
$this->errors = new WP_Error( 'theme_no_stylesheet', __( 'Stylesheet is missing.' ) );
334+
$this->template = $this->stylesheet;
335+
$this->block_theme = false;
336+
$this->block_template_folders = $this->default_template_folders;
337+
$this->cache_add(
338+
'theme',
339+
array(
340+
'block_template_folders' => $this->block_template_folders,
341+
'block_theme' => $this->block_theme,
342+
'headers' => $this->headers,
343+
'errors' => $this->errors,
344+
'stylesheet' => $this->stylesheet,
345+
'template' => $this->template,
346+
)
347+
);
348+
if ( ! file_exists( $this->theme_root ) ) { // Don't cache this one.
349+
$this->errors->add( 'theme_root_missing', __( '<strong>Error:</strong> The themes directory is either empty or does not exist. Please check your installation.' ) );
350+
}
351+
return;
312352
}
313-
$this->template = $this->stylesheet;
314-
$this->block_theme = false;
315-
$this->block_template_folders = $this->default_template_folders;
316-
$this->cache_add(
317-
'theme',
318-
array(
319-
'block_template_folders' => $this->block_template_folders,
320-
'block_theme' => $this->block_theme,
321-
'headers' => $this->headers,
322-
'errors' => $this->errors,
323-
'stylesheet' => $this->stylesheet,
324-
'template' => $this->template,
325-
)
326-
);
327-
if ( ! file_exists( $this->theme_root ) ) { // Don't cache this one.
328-
$this->errors->add( 'theme_root_missing', __( '<strong>Error:</strong> The themes directory is either empty or does not exist. Please check your installation.' ) );
353+
354+
/*
355+
* The theme directory exists but style.css is missing.
356+
* Try reading metadata from theme.json before treating this as an error.
357+
*/
358+
$json_headers = $this->read_json_metadata();
359+
360+
if ( ! $json_headers ) {
361+
$this->headers['Name'] = $this->stylesheet;
362+
$this->errors = new WP_Error( 'theme_no_stylesheet', __( 'Stylesheet is missing.' ) );
363+
$this->template = $this->stylesheet;
364+
$this->block_theme = false;
365+
$this->block_template_folders = $this->default_template_folders;
366+
$this->cache_add(
367+
'theme',
368+
array(
369+
'block_template_folders' => $this->block_template_folders,
370+
'block_theme' => $this->block_theme,
371+
'headers' => $this->headers,
372+
'errors' => $this->errors,
373+
'stylesheet' => $this->stylesheet,
374+
'template' => $this->template,
375+
)
376+
);
377+
return;
329378
}
330-
return;
379+
380+
$this->headers = $json_headers;
331381
} elseif ( ! is_readable( $this->theme_root . '/' . $theme_file ) ) {
332382
$this->headers['Name'] = $this->stylesheet;
333383
$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 ) {
347397
);
348398
return;
349399
} else {
350-
$this->headers = get_file_data( $this->theme_root . '/' . $theme_file, self::$file_headers, 'theme' );
400+
$this->headers = $this->read_json_metadata();
401+
402+
if ( ! $this->headers ) {
403+
$this->headers = get_file_data( $this->theme_root . '/' . $theme_file, self::$file_headers, 'theme' );
404+
}
405+
351406
/*
352407
* Default themes always trump their pretenders.
353408
* Properly identify default themes that are inside a directory within wp-content/themes.
@@ -799,6 +854,82 @@ public function __wakeup() {
799854
$this->headers_sanitized = array();
800855
}
801856

857+
/**
858+
* Reads theme metadata from the `metadata` property in theme.json.
859+
*
860+
* Provides a structured JSON alternative to parsing CSS comment headers
861+
* in style.css. When a theme.json file contains a `metadata` property,
862+
* its values are mapped to the internal header format used by WP_Theme.
863+
*
864+
* @since 7.0.0
865+
*
866+
* @return array|false Array of theme headers keyed by header name, or false
867+
* if theme.json does not exist or has no metadata property.
868+
*/
869+
private function read_json_metadata() {
870+
$theme_json_file = $this->theme_root . '/' . $this->stylesheet . '/theme.json';
871+
872+
if ( ! is_readable( $theme_json_file ) ) {
873+
return false;
874+
}
875+
876+
/*
877+
* Read and decode JSON directly rather than using wp_json_file_decode().
878+
*
879+
* This method is called during theme enumeration for every theme that has
880+
* a theme.json file, including themes without a `metadata` property and
881+
* themes with malformed JSON. Using wp_json_file_decode() would trigger
882+
* wp_trigger_error() for invalid JSON, which is undesirable here since
883+
* the existing WP_Theme_JSON_Resolver handles JSON errors when it needs
884+
* to parse theme settings. This method should fail silently and fall back
885+
* to style.css headers.
886+
*/
887+
$contents = @file_get_contents( $theme_json_file );
888+
889+
if ( false === $contents ) {
890+
return false;
891+
}
892+
893+
$theme_json_data = json_decode( $contents, true );
894+
895+
if ( ! is_array( $theme_json_data ) || ! isset( $theme_json_data['metadata'] ) || ! is_array( $theme_json_data['metadata'] ) ) {
896+
return false;
897+
}
898+
899+
$metadata = $theme_json_data['metadata'];
900+
901+
// Initialize all headers to empty strings (matching get_file_data() behavior).
902+
$headers = array_fill_keys( array_keys( self::$file_headers ), '' );
903+
904+
// Map JSON metadata keys to internal header keys.
905+
foreach ( self::$json_metadata_keys as $json_key => $header_key ) {
906+
if ( isset( $metadata[ $json_key ] ) ) {
907+
if ( 'tags' === $json_key && is_array( $metadata[ $json_key ] ) ) {
908+
$headers[ $header_key ] = implode( ', ', $metadata[ $json_key ] );
909+
} else {
910+
$headers[ $header_key ] = (string) $metadata[ $json_key ];
911+
}
912+
}
913+
}
914+
915+
// Map nested requires object.
916+
if ( isset( $metadata['requires'] ) && is_array( $metadata['requires'] ) ) {
917+
if ( isset( $metadata['requires']['wordpress'] ) ) {
918+
$headers['RequiresWP'] = (string) $metadata['requires']['wordpress'];
919+
}
920+
if ( isset( $metadata['requires']['php'] ) ) {
921+
$headers['RequiresPHP'] = (string) $metadata['requires']['php'];
922+
}
923+
}
924+
925+
// Only return headers if at least a name was provided.
926+
if ( empty( $headers['Name'] ) ) {
927+
return false;
928+
}
929+
930+
return $headers;
931+
}
932+
802933
/**
803934
* Adds theme data to cache.
804935
*

0 commit comments

Comments
 (0)