diff --git a/checks/class-file-check.php b/checks/class-file-check.php
index 90607752..f0fce6c1 100644
--- a/checks/class-file-check.php
+++ b/checks/class-file-check.php
@@ -66,7 +66,7 @@ public function check( $php_files, $css_files, $other_files ) {
'favicon\.ico' => __( 'Favicon', 'theme-check' ),
);
- $musthave = array( 'index.php', 'style.css', 'readme.txt' );
+ $musthave = array( 'index.php', 'style.css' );
checkcount();
diff --git a/checks/class-readme-check.php b/checks/class-readme-check.php
new file mode 100644
index 00000000..99bf0a43
--- /dev/null
+++ b/checks/class-readme-check.php
@@ -0,0 +1,285 @@
+theme = $data['theme'];
+ }
+ if ( isset( $data['slug'] ) ) {
+ $this->slug = $data['slug'];
+ }
+ }
+
+ /**
+ * Check that return true for good/okay/acceptable, false for bad/not-okay/unacceptable.
+ *
+ * @param array $php_files File paths and content for PHP files.
+ * @param array $css_files File paths and content for CSS files.
+ * @param array $other_files Folder names, file paths and content for other files.
+ */
+ public function check( $php_files, $css_files, $other_files ) {
+
+ /**
+ * Latest WordPress version
+ *
+ * @var string $latest_wordpress_version
+ */
+ if ( defined( 'WP_CORE_LATEST_RELEASE' ) ) {
+ // When running on WordPress.org, this constant defines the latest WordPress release.
+ $latest_wordpress_version = WP_CORE_LATEST_RELEASE;
+ } else {
+ // Assume that the local environment being tested in is up to date.
+ $latest_wordpress_version = $GLOBALS['wp_version'];
+ }
+
+ checkcount();
+
+ // Get a list of file names and check for the readme.
+ $readme = '';
+
+ // Get the contents of themeslug/filename:
+ foreach ( $other_files as $path => $contents ) {
+ if ( stripos( $path, $this->slug . '/readme.txt' ) || stripos( $path, $this->slug . '/readme.md' ) !== false ) {
+ $readme .= $contents;
+ }
+ }
+
+ // Publish an error if there is no readme file.
+ if ( empty( $readme ) ) {
+ $this->error[] = sprintf(
+ '%s: %s',
+ __( 'REQUIRED', 'theme-check' ),
+ __( 'The readme file is missing.', 'theme-check' )
+ );
+ $ret = false;
+ } else {
+ // Parse the content of the readme.
+ $readme = new Readme_Parser( $readme );
+
+ // Error if the theme name is missing in the readme.
+ if ( empty( $readme->name ) ) {
+ $this->error[] = sprintf(
+ '%s: %s',
+ __( 'README ERROR', 'theme-check' ),
+ /* translators: 1: 'Theme Name' section title, 2: 'Theme Name' */
+ sprintf(
+ __( 'Could not find a theme name in the readme. Theme name format looks like: %1$s. Please change %2$s to reflect the actual name of your theme.', 'theme-check' ),
+ '=== Theme Name ===',
+ 'Theme Name'
+ )
+ );
+ $ret = false;
+ } elseif ( $readme->name != $this->theme ) {
+ $this->error[] = sprintf(
+ '%s: %s',
+ __( 'README ERROR', 'theme-check' ),
+ /* translators: 1: actual theme name, 2: theme name in readme, 3: 'Theme Name' section title, 4: 'Theme Name' */
+ sprintf(
+ __( 'The theme name in the readme %1$s does not match the name of your theme %2$s. Theme name format looks like: %3$s. Please change %4$s to reflect the actual name of your theme.', 'theme-check' ),
+ '' . esc_html( $readme->name ) . '',
+ '' . esc_html( $this->theme ) . '',
+ '=== Theme Name ===',
+ 'Theme Name'
+ )
+ );
+ $ret = false;
+ }
+
+ // Warnings.
+ if ( isset( $readme->warnings['requires_header_ignored'] ) ) {
+ $this->error[] = sprintf(
+ '%s: %s',
+ __( 'README WARNING', 'theme-check' ),
+ /* translators: 1: theme header tag; 2: Example version 5.0. 3: Example version 4.9. */
+ sprintf(
+ __( 'The %1$s field in the readme was ignored. This field should only contain a valid WordPress version such as %2$s or %3$s.', 'theme-check' ),
+ 'Requires at least',
+ '' . number_format( $latest_wordpress_version, 1 ) . '',
+ '' . number_format( $latest_wordpress_version - 0.1, 1 ) . ''
+ )
+ );
+ } elseif ( empty( $readme->requires ) ) {
+ $this->error[] = sprintf(
+ '%s: %s',
+ __( 'README WARNING', 'theme-check' ),
+ sprintf(
+ /* translators: %s: theme header tag */
+ __( 'The %s field is missing from the readme.', 'theme-check' ),
+ 'Requires at least'
+ )
+ );
+ }
+
+ if ( isset( $readme->warnings['tested_header_ignored'] ) ) {
+ $this->error[] = sprintf(
+ '%s: %s',
+ __( 'README WARNING', 'theme-check' ),
+ sprintf(
+ /* translators: 1: theme header tag; 2: Example version 5.0. 3: Example version 5.1. */
+ __( 'The %1$s field in the readme was ignored. This field should only contain a valid WordPress version such as %2$s or %3$s.', 'theme-check' ),
+ 'Tested up to',
+ '' . number_format( $latest_wordpress_version, 1 ) . '',
+ '' . number_format( $latest_wordpress_version + 0.1, 1 ) . ''
+ )
+ );
+ } elseif ( empty( $readme->tested ) ) {
+ $this->error[] = sprintf(
+ '%s: %s',
+ __( 'README WARNING', 'theme-check' ),
+ sprintf(
+ /* translators: %s: plugin header tag */
+ __( 'The %s field is missing from the readme.', 'theme-check' ),
+ 'Tested up to'
+ )
+ );
+ }
+
+ if ( isset( $readme->warnings['requires_php_header_ignored'] ) ) {
+ $this->error[] = sprintf(
+ '%s: %s',
+ __( 'README WARNING', 'theme-check' ),
+ sprintf(
+ /* translators: 1: plugin header tag; 2: Example version 5.2.4. 3: Example version 7.0. */
+ __( 'The %1$s field in the readme was ignored. This field should only contain a PHP version such as %2$s or %3$s.', 'theme-check' ),
+ 'Requires PHP',
+ '5.2.4',
+ '7.0'
+ )
+ );
+ } elseif ( empty( $readme->requires_php ) ) {
+ $this->error[] = sprintf(
+ '%s: %s',
+ __( 'README WARNING', 'theme-check' ),
+ sprintf(
+ /* translators: %s: plugin header tag */
+ __( 'The %s field is missing from the readme.', 'theme-check' ),
+ 'Requires PHP'
+ )
+ );
+ }
+
+ if ( 2 <= count( $readme->contributors ) ) {
+ $this->error[] = sprintf(
+ '%s: %s',
+ __( 'README WARNING', 'theme-check' ),
+ sprintf(
+ /* translators: %s: theme header tag */
+ __( 'The %s field should only contain one WordPress.org username. Remember that usernames are case-sensitive.', 'theme-check' ),
+ 'Contributors'
+ )
+ );
+ } elseif ( ! count( $readme->contributors ) ) {
+ $this->error[] = sprintf(
+ '%s: %s',
+ __( 'README WARNING', 'theme-check' ),
+ sprintf(
+ /* translators: %s: theme header tag */
+ __( 'The %s field is missing from the readme or is empty.', 'theme-check' ),
+ 'Contributors'
+ )
+ );
+ }
+
+ if ( empty( $readme->license ) ) {
+ $this->error[] = sprintf(
+ '%s: %s',
+ __( 'README WARNING', 'theme-check' ),
+ sprintf(
+ /* translators: %s: theme header tag */
+ __( 'The %s field is missing from the readme.', 'theme-check' ),
+ 'License'
+ )
+ );
+ }
+
+ if ( empty( $readme->license_uri ) ) {
+ $this->error[] = sprintf(
+ '%s: %s',
+ __( 'README WARNING', 'theme-check' ),
+ sprintf(
+ /* translators: %s: theme header tag */
+ __( 'The %s field is missing from the readme.', 'theme-check' ),
+ 'License URI'
+ )
+ );
+ }
+
+ // Info.
+ if ( empty( $readme->sections['description'] ) ) {
+ $this->error[] = sprintf(
+ '%s: %s',
+ __( 'README INFO', 'theme-check' ),
+ sprintf(
+ /* translators: %s: section title */
+ __( 'No %s section was found in the readme.', 'theme-check' ),
+ '== Description =='
+ )
+ );
+ }
+
+ if ( empty( $readme->sections['faq'] ) ) {
+ $this->error[] = sprintf(
+ '%s: %s',
+ __( 'README INFO', 'theme-check' ),
+ sprintf(
+ /* translators: %s: section title */
+ __( 'No %s section was found in the readme.', 'theme-check' ),
+ '== Frequently Asked Questions =='
+ )
+ );
+ }
+
+ if ( empty( $readme->sections['changelog'] ) ) {
+ $this->error[] = sprintf(
+ '%s: %s',
+ __( 'README INFO', 'theme-check' ),
+ sprintf(
+ /* translators: %s: section title */
+ __( 'No %s section was found in the readme.', 'theme-check' ),
+ '== Changelog =='
+ )
+ );
+ }
+ }
+ }
+
+ /**
+ * Get error messages from the checks.
+ *
+ * @return array Error message.
+ */
+ public function getError() {
+ return $this->error;
+ }
+}
+
+$themechecks[] = new Readme_Check();
diff --git a/checks/class-readme-parser.php b/checks/class-readme-parser.php
new file mode 100644
index 00000000..e10b3c23
--- /dev/null
+++ b/checks/class-readme-parser.php
@@ -0,0 +1,408 @@
+ to
+ *
+ * @var array
+ */
+ private $alias_sections = array(
+ 'frequently_asked_questions' => 'faq',
+ 'change_log' => 'changelog',
+ );
+
+ /**
+ * These are the valid header mappings for the header.
+ *
+ * @var array
+ */
+ private $valid_headers = array(
+ 'tested' => 'tested',
+ 'tested up to' => 'tested',
+ 'requires' => 'requires',
+ 'requires at least' => 'requires',
+ 'requires php' => 'requires_php',
+ 'tags' => 'tags',
+ 'contributors' => 'contributors',
+ 'donate link' => 'donate_link',
+ 'license' => 'license',
+ 'license uri' => 'license_uri',
+ );
+
+ /**
+ * Parser constructor.
+ *
+ * @param string $string Contents of a readme to parse.
+ */
+ public function __construct( $string ) {
+ $this->parse_readme_contents( $string );
+ }
+
+ /**
+ * Parse and sanitize the readme
+ *
+ * @param string $contents The contents of the readme to parse.
+ * @return bool
+ */
+ protected function parse_readme_contents( $contents ) {
+ if ( preg_match( '!!u', $contents ) ) {
+ $contents = preg_split( '!\R!u', $contents );
+ } else {
+ $contents = preg_split( '!\R!', $contents ); // regex failed due to invalid UTF8 in $contents, see #2298
+ }
+ // Remove empty lines.
+ $contents = array_map( array( $this, 'strip_newlines' ), $contents );
+
+ // Strip UTF8 BOM if present.
+ if ( 0 === strpos( $contents[0], "\xEF\xBB\xBF" ) ) {
+ $contents[0] = substr( $contents[0], 3 );
+ }
+
+ // Convert UTF-16 files.
+ if ( 0 === strpos( $contents[0], "\xFF\xFE" ) ) {
+ foreach ( $contents as $i => $line ) {
+ $contents[ $i ] = mb_convert_encoding( $line, 'UTF-8', 'UTF-16' );
+ }
+ }
+
+ $contents = array_filter( $contents, 'strlen' );
+
+ $line = $this->get_first_nonwhitespace( $contents );
+ $this->name = $this->sanitize_text( trim( $line, "#= \t\0\x0B" ) );
+
+ // Strip Github style header\n==== underlines.
+ if ( ! empty( $contents ) && '' === trim( $contents[0], '=-' ) ) {
+ array_shift( $contents );
+ }
+
+ // Handle readme's which do `=== Theme Name ===\nMy SuperAwesome Name\n...`
+ if ( 'theme name' == strtolower( $this->name ) ) {
+
+ $line = $this->get_first_nonwhitespace( $contents );
+ $this->name = $line;
+
+ // Ensure that the line read wasn't an actual header or description.
+ if ( strlen( $line ) > 50 || preg_match( '~^(' . implode( '|', array_keys( $this->valid_headers ) ) . ')\s*:~i', $line ) ) {
+ $this->name = false;
+ array_unshift( $contents, $line );
+ }
+ }
+
+ // Parse headers.
+ $headers = array();
+
+ $line = $this->get_first_nonwhitespace( $contents );
+ do {
+ $value = null;
+ if ( false === strpos( $line, ':' ) ) {
+
+ // Some themes have line-breaks within the headers.
+ if ( empty( $line ) ) {
+ break;
+ } else {
+ continue;
+ }
+ }
+
+ $bits = explode( ':', trim( $line ), 2 );
+ list( $key, $value ) = $bits;
+ $key = strtolower( trim( $key, " \t*-\r\n" ) );
+ if ( isset( $this->valid_headers[ $key ] ) ) {
+ $headers[ $this->valid_headers[ $key ] ] = trim( $value );
+ }
+ } while ( ( $line = array_shift( $contents ) ) !== null );
+ array_unshift( $contents, $line );
+
+ if ( ! empty( $headers['requires'] ) ) {
+ $this->requires = $this->sanitize_requires_version( $headers['requires'] );
+ }
+ if ( ! empty( $headers['tested'] ) ) {
+ $this->tested = $this->sanitize_tested_version( $headers['tested'] );
+ }
+ if ( ! empty( $headers['requires_php'] ) ) {
+ $this->requires_php = $this->sanitize_requires_php( $headers['requires_php'] );
+ }
+ if ( ! empty( $headers['contributors'] ) ) {
+ $this->contributors = explode( ',', $headers['contributors'] );
+ $this->contributors = array_map( 'trim', $this->contributors );
+ }
+ if ( ! empty( $headers['license'] ) ) {
+ // Handle the many cases of "License: GPLv2 - http://..."
+ if ( empty( $headers['license_uri'] ) && preg_match( '!(https?://\S+)!i', $headers['license'], $url ) ) {
+ $headers['license_uri'] = $url[1];
+ $headers['license'] = trim( str_replace( $url[1], '', $headers['license'] ), " -*\t\n\r\n" );
+ }
+ $this->license = $headers['license'];
+ }
+ if ( ! empty( $headers['license_uri'] ) ) {
+ $this->license_uri = $headers['license_uri'];
+ }
+
+ return true;
+ }
+
+ /**
+ * Get the first non white space in the readme file content
+ *
+ * @access protected
+ *
+ * @param string $contents Readme file content.
+ * @return string
+ */
+ protected function get_first_nonwhitespace( &$contents ) {
+ while ( ( $line = array_shift( $contents ) ) !== null ) {
+ $trimmed = trim( $line );
+ if ( ! empty( $trimmed ) ) {
+ break;
+ }
+ }
+
+ return $line;
+ }
+
+ /**
+ * Strip new lines
+ *
+ * @access protected
+ *
+ * @param string $line The line to remove the line endings from.
+ * @return string
+ */
+ protected function strip_newlines( $line ) {
+ return rtrim( $line, "\r\n" );
+ }
+
+ /**
+ * Sanitize and remove characters from the theme name
+ *
+ * @access protected
+ *
+ * @param string $text Text to sanitize.
+ * @return string
+ */
+ protected function sanitize_text( $text ) {
+ $text = wp_strip_all_tags( $text );
+ $text = esc_html( $text );
+ $text = trim( $text );
+
+ return $text;
+ }
+
+ /**
+ * Sanitizes the Requires PHP header to ensure that it's a valid version header
+ *
+ * @param string $version Minimum required PHP version.
+ * @return string The sanitized $version
+ */
+ protected function sanitize_requires_php( $version ) {
+ $version = trim( $version );
+
+ // x.y or x.y.z
+ if ( $version && ! preg_match( '!^\d+(\.\d+){1,2}$!', $version ) ) {
+ $this->warnings['requires_php_header_ignored'] = true;
+ // Ignore the readme value.
+ $version = '';
+ }
+
+ return $version;
+ }
+
+ /**
+ * Sanitizes the Tested header to ensure that it's a valid version header
+ *
+ * @param string $version WordPress version that the theme is tested with.
+ * @return string The sanitized $version
+ */
+ protected function sanitize_tested_version( $version ) {
+ $version = trim( $version );
+
+ /**
+ * Latest WordPress version
+ *
+ * @var string $latest_wordpress_version
+ */
+ if ( defined( 'WP_CORE_LATEST_RELEASE' ) ) {
+ // When running on WordPress.org, this constant defines the latest WordPress release.
+ $latest_wordpress_version = WP_CORE_LATEST_RELEASE;
+ } else {
+ // Assume that the local environment being tested in is up to date.
+ $latest_wordpress_version = $GLOBALS['wp_version'];
+ }
+
+ if ( $version ) {
+ // Handle the edge-case of 'WordPress 5.0' and 'WP 5.0' for historical purposes.
+ $strip_phrases = array( 'WordPress', 'WP' );
+ $version = trim( str_ireplace( $strip_phrases, '', $version ) );
+
+ // Strip off any -alpha, -RC, -beta suffixes, as these complicate comparisons and are rarely used.
+ list( $version, ) = explode( '-', $version );
+
+ if (
+ // x.y or x.y.z
+ ! preg_match( '!^\d+\.\d(\.\d+)?$!', $version ) ||
+ // Allow themes to mark themselves as compatible with Stable+0.1 (trunk/master) but not higher
+ (
+ $latest_wordpress_version &&
+ version_compare( (float) $version, (float) $latest_wordpress_version + 0.1, '>' )
+ )
+ ) {
+ $this->warnings['tested_header_ignored'] = true;
+ // Ignore the readme value.
+ $version = '';
+ }
+ }
+
+ return $version;
+ }
+
+ /**
+ * Sanitizes the Requires at least header to ensure that it's a valid version header
+ *
+ * @param string $version The minim required WordPress version.
+ * @return string The sanitized $version
+ */
+ protected function sanitize_requires_version( $version ) {
+ $version = trim( $version );
+ $latest_wordpress_version = '5.9';
+
+ if ( $version ) {
+ // Handle the edge-case of 'WordPress 5.0' and 'WP 5.0' for historical purposes.
+ $strip_phrases = array( 'WordPress', 'WP', 'or higher', 'and above', '+' );
+ $version = trim( str_ireplace( $strip_phrases, '', $version ) );
+
+ // Strip off any -alpha, -RC, -beta suffixes, as these complicate comparisons and are rarely used.
+ list( $version, ) = explode( '-', $version );
+
+ if (
+ // x.y or x.y.z
+ ! preg_match( '!^\d+\.\d(\.\d+)?$!', $version ) ||
+ // Allow themes to mark themselves as requireing Stable+0.1 (trunk/master) but not higher
+ $latest_wordpress_version && ( (float) $version > (float) $latest_wordpress_version + 0.1 )
+ ) {
+ $this->warnings['requires_header_ignored'] = true;
+ // Ignore the readme value.
+ $version = '';
+ }
+ }
+
+ return $version;
+ }
+
+}