From 51a228e5caa5442d646dc87a326be2c5a7219f62 Mon Sep 17 00:00:00 2001 From: Ren Ventura Date: Fri, 24 May 2024 03:19:41 -0400 Subject: [PATCH 1/7] Added composer require snippet --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index a862e62..876925d 100755 --- a/README.md +++ b/README.md @@ -12,6 +12,10 @@ Installation ----------- It is recommended to [install the composer package](https://packagist.org/packages/renventura/wp-package-parser). +```sh +composer require renventura/wp-package-parser +``` + Basic usage ----------- ### Extract plugin metadata: From 3f2992a92c170d997748c421b6b46bdc07a72f20 Mon Sep 17 00:00:00 2001 From: Ren Ventura Date: Fri, 24 May 2024 04:11:13 -0400 Subject: [PATCH 2/7] Cleanup --- .github/workflows/ci.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1783348..fabff9b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -# This is the main Continuous Integration (CI) pipeline for the the stellarwp/plugin-framework package. +# Continuous Integration (CI) pipeline. # # Any time code is pushed to one of the main branches or a PR is opened, this pipeline should be # run to ensure everything still works as designed and meets our coding standards. @@ -21,14 +21,12 @@ jobs: # Execute all PHPUnit tests. phpunit: - name: PHPUnit (PHP ${{ matrix.php-versions }}, WP ${{ matrix.wp-versions }}) + name: PHPUnit (PHP ${{ matrix.php-versions }}) runs-on: ubuntu-latest strategy: fail-fast: false matrix: - # Run on all versions of PHP supported by WordPress. php-versions: ['8.0', '8.1', '8.2', '8.3'] - wp-versions: ['latest'] services: mysql: From e8259d512b1dca07db168ea74cc80ba99224bfce Mon Sep 17 00:00:00 2001 From: Ren Ventura Date: Sat, 25 May 2024 16:36:42 -0400 Subject: [PATCH 3/7] Added PHP Static Analysis to dev workflow (#1) Added PHP Static Analysis to dev workflow --- .github/workflows/ci.yml | 19 +++++++++++++++++++ .gitignore | 1 + composer.json | 7 ++++++- phpstan.neon.dist | 11 +++++++++++ src/Parsers/Parser.php | 4 ++-- src/Parsers/PluginParser.php | 8 ++++---- src/Parsers/ThemeParser.php | 6 +++--- src/WPPackage.php | 6 +++--- tests/Unit/Parsers/PluginParserTest.php | 6 ++++++ tests/Unit/Parsers/ThemeParserTest.php | 6 ++++++ tests/bootstrap-phpstan.php | 10 ++++++++++ tests/bootstrap.php | 3 ++- 12 files changed, 73 insertions(+), 14 deletions(-) create mode 100644 phpstan.neon.dist create mode 100644 tests/bootstrap-phpstan.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fabff9b..aee5de2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,25 @@ concurrency: jobs: + # Static Code Analysis (PHPStan) + static-code-analysis: + name: PHPStan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: Configure PHP environment + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: mbstring, intl + coverage: none + + - uses: ramsey/composer-install@v2 + + - name: Run PHPStan + run: composer test:analysis + # Execute all PHPUnit tests. phpunit: name: PHPUnit (PHP ${{ matrix.php-versions }}) diff --git a/.gitignore b/.gitignore index e6c97b0..f1a2791 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ composer.lock .php-cs-fixer.php phpcs.xml phpunit.xml +phpstan.neon # Cache files. .php_cs.cache diff --git a/composer.json b/composer.json index 5bedc49..4bde70b 100755 --- a/composer.json +++ b/composer.json @@ -12,7 +12,8 @@ "erusev/parsedown": "^1.7" }, "require-dev": { - "phpunit/phpunit": "^9.6" + "phpunit/phpunit": "^9.6", + "phpstan/phpstan": "^1.11" }, "autoload": { "psr-4": { @@ -29,8 +30,12 @@ "@test:all" ], "test:all": [ + "@test:analysis", "@test:unit" ], + "test:analysis": [ + "./vendor/bin/phpstan analyse -c phpstan.neon.dist --memory-limit=768M" + ], "test:unit": [ "./vendor/bin/phpunit --testdox --verbose --color=always" ] diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..d6663b8 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,11 @@ +# Configuration for PHPStan +# https://phpstan.org/config-reference + +parameters: + level: 6 + bootstrapFiles: + - tests/bootstrap-phpstan.php + paths: + - src + - tests + diff --git a/src/Parsers/Parser.php b/src/Parsers/Parser.php index 878670d..2985403 100755 --- a/src/Parsers/Parser.php +++ b/src/Parsers/Parser.php @@ -10,7 +10,7 @@ abstract class Parser { /** * Header map. * - * @var array + * @var array */ protected $headerMap = array(); @@ -23,7 +23,7 @@ abstract class Parser { * * @param string $fileContents File contents. Can be safely truncated to 8kiB as that's all WP itself scans. * - * @return array + * @return array */ protected function parseHeaders( string $fileContents ) : array { $headers = array(); diff --git a/src/Parsers/PluginParser.php b/src/Parsers/PluginParser.php index f886b7a..407b7f7 100755 --- a/src/Parsers/PluginParser.php +++ b/src/Parsers/PluginParser.php @@ -9,7 +9,7 @@ class PluginParser extends Parser { /** * Header map. * - * @var array + * @var array */ protected $headerMap = array( 'name' => 'Plugin Name', @@ -28,9 +28,9 @@ class PluginParser extends Parser { * * @param string $content Readme file content. * - * @return array + * @return null|array */ - public function parseReadme( string $content ) : array { + public function parseReadme( string $content ) : null|array { $readmeTxtContents = trim( $content, " \t\n\r" ); $readme = array( 'name' => '', @@ -149,7 +149,7 @@ public function parseReadme( string $content ) : array { * * @param string $fileContents Contents of the plugin file * - * @return array|null See above for description. + * @return null|array See above for description. */ public function parsePlugin( string $fileContents ) : array|null { diff --git a/src/Parsers/ThemeParser.php b/src/Parsers/ThemeParser.php index 1207ce3..2038c26 100755 --- a/src/Parsers/ThemeParser.php +++ b/src/Parsers/ThemeParser.php @@ -9,7 +9,7 @@ class ThemeParser extends Parser { /** * Header map. * - * @var array + * @var array */ protected $headerMap = array( 'name' => 'Theme Name', @@ -30,9 +30,9 @@ class ThemeParser extends Parser { * * @param string $fileContents Contents of style.css file. * - * @return array|null + * @return null|array */ - public function parseStyle( string $fileContents ) : array|null { + public function parseStyle( string $fileContents ) : null|array { $headers = $this->parseHeaders( $fileContents ); $headers['tags'] = array_filter( array_map( 'trim', explode( ',', strip_tags( $headers['tags'] ) ) ) ); diff --git a/src/WPPackage.php b/src/WPPackage.php index 59c352e..ebaf3c5 100755 --- a/src/WPPackage.php +++ b/src/WPPackage.php @@ -11,7 +11,7 @@ class WPPackage { /** * Metadata. * - * @var array + * @var array */ protected $metadata = array(); @@ -57,7 +57,7 @@ public function getSlug() : string|null { /** * Get metadata. * - * @return array + * @return array */ public function getMetaData() : array { return $this->metadata; @@ -150,7 +150,7 @@ public function getType() : string|null { * * @param string $file_name File name. * - * @return bool|array + * @return bool|array */ private function exploreFile( string $file_name ) : bool|array { $data = pathinfo( $file_name ); diff --git a/tests/Unit/Parsers/PluginParserTest.php b/tests/Unit/Parsers/PluginParserTest.php index 68f2d50..de18b58 100755 --- a/tests/Unit/Parsers/PluginParserTest.php +++ b/tests/Unit/Parsers/PluginParserTest.php @@ -7,6 +7,8 @@ class PluginParserTest extends TestCase { /** * Package not found. + * + * @return void */ public function test_no_info_when_package_not_found() { $package = new WPPackage( '/path/wrong/abc.zip' ); @@ -17,6 +19,8 @@ public function test_no_info_when_package_not_found() { /** * Correctly parses a valid plugin. + * + * @return void */ public function test_parses_valid_plugin() { $package = new WPPackage( TESTS_DIR . '/packages/hello-dolly.1.6.zip' ); @@ -26,6 +30,8 @@ public function test_parses_valid_plugin() { /** * getMetaData() should return correct data about the package. + * + * @return void */ public function test_getMetaData_should_return_correct_data_for_plugin() { $package = new WPPackage( TESTS_DIR . '/packages/hello-dolly.1.6.zip' ); diff --git a/tests/Unit/Parsers/ThemeParserTest.php b/tests/Unit/Parsers/ThemeParserTest.php index fef7d59..0014172 100755 --- a/tests/Unit/Parsers/ThemeParserTest.php +++ b/tests/Unit/Parsers/ThemeParserTest.php @@ -7,6 +7,8 @@ class ThemeParserTest extends TestCase { /** * Package not found. + * + * @return void */ public function test_no_info_when_package_not_found() { $package = new WPPackage( 'path/wrong/test.zip' ); @@ -17,6 +19,8 @@ public function test_no_info_when_package_not_found() { /** * Correctly parses a valid package. + * + * @return void */ public function test_parses_valid_theme() { $package = new WPPackage( TESTS_DIR . '/packages/twentyseventeen.1.3.zip' ); @@ -26,6 +30,8 @@ public function test_parses_valid_theme() { /** * getMetaData() should return correct data about the package. + * + * @return void */ public function test_getMetaData_should_return_correct_data_for_theme() { $package = new WPPackage( TESTS_DIR . '/packages/twentysixteen.1.3.zip' ); diff --git a/tests/bootstrap-phpstan.php b/tests/bootstrap-phpstan.php new file mode 100644 index 0000000..5df7f1e --- /dev/null +++ b/tests/bootstrap-phpstan.php @@ -0,0 +1,10 @@ + Date: Sat, 25 May 2024 22:45:38 -0400 Subject: [PATCH 4/7] Add PHP_CodeSniffer (#2) Added PHPCS --- .github/workflows/ci.yml | 23 +- .phpcs.xml.dist | 29 ++ composer.json | 12 +- src/Parsers/Parser.php | 106 ++++--- src/Parsers/PluginParser.php | 318 +++++++++---------- src/Parsers/ThemeParser.php | 74 ++--- src/WPPackage.php | 406 ++++++++++++------------ tests/Unit/Parsers/PluginParserTest.php | 63 ++-- tests/Unit/Parsers/ThemeParserTest.php | 57 ++-- tests/bootstrap-phpstan.php | 4 +- tests/bootstrap.php | 8 +- 11 files changed, 592 insertions(+), 508 deletions(-) create mode 100644 .phpcs.xml.dist diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aee5de2..1a4ccec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,12 +19,31 @@ concurrency: jobs: + # Check coding standards (PHP_CodeSniffer, PHP-CS-Fixer, Shellcheck) + coding-standards: + name: PHPCS + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Configure PHP environment + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: mbstring + coverage: none + + - uses: ramsey/composer-install@v2 + + - name: Run PHPCS + run: composer test:standards + # Static Code Analysis (PHPStan) static-code-analysis: name: PHPStan runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Configure PHP environment uses: shivammathur/setup-php@v2 @@ -57,7 +76,7 @@ jobs: options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=10s --health-retries=10 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Configure PHP environment uses: shivammathur/setup-php@v2 diff --git a/.phpcs.xml.dist b/.phpcs.xml.dist new file mode 100644 index 0000000..eacdb67 --- /dev/null +++ b/.phpcs.xml.dist @@ -0,0 +1,29 @@ + + + + + + ./src + ./tests + + + + + + + + + + + + + + + + + tests/bootstrap.php + + diff --git a/composer.json b/composer.json index 4bde70b..dfa47ec 100755 --- a/composer.json +++ b/composer.json @@ -13,7 +13,8 @@ }, "require-dev": { "phpunit/phpunit": "^9.6", - "phpstan/phpstan": "^1.11" + "phpstan/phpstan": "^1.11", + "squizlabs/php_codesniffer": "^3.10" }, "autoload": { "psr-4": { @@ -26,22 +27,31 @@ } }, "scripts": { + "fix:standards": [ + "./vendor/bin/phpcbf ./src ./tests" + ], "test": [ "@test:all" ], "test:all": [ "@test:analysis", + "@test:standards", "@test:unit" ], "test:analysis": [ "./vendor/bin/phpstan analyse -c phpstan.neon.dist --memory-limit=768M" ], + "test:standards": [ + "./vendor/bin/phpcs ./src ./tests" + ], "test:unit": [ "./vendor/bin/phpunit --testdox --verbose --color=always" ] }, "scripts-descriptions": { "test:all": "Run all automated tests.", + "test:analysis": "Perform static code analysis.", + "test:standards": "Check coding standards.", "test:unit": "Run all of the PHPUnit test suites." } } diff --git a/src/Parsers/Parser.php b/src/Parsers/Parser.php index 2985403..962a6a0 100755 --- a/src/Parsers/Parser.php +++ b/src/Parsers/Parser.php @@ -1,65 +1,69 @@ - */ - protected $headerMap = array(); +abstract class Parser +{ + /** + * Header map. + * + * @var array + */ + protected $headerMap = array(); - /** - * Parse the file contents to retrieve its metadata. - * - * Searches for metadata for a file, such as a plugin or theme. Each piece of - * metadata must be on its own line. For a field spanning multiple lines, it - * must not have any newlines or only parts of it will be displayed. - * - * @param string $fileContents File contents. Can be safely truncated to 8kiB as that's all WP itself scans. - * - * @return array - */ - protected function parseHeaders( string $fileContents ) : array { - $headers = array(); - $headerMap = $this->headerMap; + /** + * Parse the file contents to retrieve its metadata. + * + * Searches for metadata for a file, such as a plugin or theme. Each piece of + * metadata must be on its own line. For a field spanning multiple lines, it + * must not have any newlines or only parts of it will be displayed. + * + * @param string $fileContents File contents. Can be safely truncated to 8kiB as that's all WP itself scans. + * + * @return array + */ + protected function parseHeaders(string $fileContents): array + { + $headers = array(); + $headerMap = $this->headerMap; - // Support systems that use CR as a line ending. - $fileContents = str_replace( "\r", "\n", $fileContents ); + // Support systems that use CR as a line ending. + $fileContents = str_replace("\r", "\n", $fileContents); - foreach ( $headerMap as $field => $prettyName ) { - $found = preg_match( '/^[ \t\/*#@]*' . preg_quote( $prettyName, '/' ) . ':(.*)$/mi', $fileContents, $matches ); - if ( ( $found > 0 ) && ! empty( $matches[1] ) ) { - // Strip comment markers and closing PHP tags. - $value = trim( preg_replace( "/\s*(?:\*\/|\?>).*/", '', $matches[1] ) ); - $headers[ $field ] = $value; - } else { - $headers[ $field ] = ''; - } - } + foreach ($headerMap as $field => $prettyName) { + $found = preg_match('/^[ \t\/*#@]*' . preg_quote($prettyName, '/') . ':(.*)$/mi', $fileContents, $matches); + if (( $found > 0 ) && ! empty($matches[1])) { + // Strip comment markers and closing PHP tags. + $value = trim(preg_replace("/\s*(?:\*\/|\?>).*/", '', $matches[1])); + $headers[ $field ] = $value; + } else { + $headers[ $field ] = ''; + } + } - return $headers; - } + return $headers; + } - /** - * Transform Markdown markup to HTML. - * - * Tries (in vain) to emulate the transformation that WordPress.org applies to readme.txt files. - * - * @param string $text - * - * @return string - */ - protected function applyMarkdown( string $text ) : string { - // The WP standard for readme files uses some custom markup, like "= H4 headers =" - $text = preg_replace( '@^\s*=\s*(.+?)\s*=\s*$@m', "

$1

\n", $text ); - $markdown = new Parsedown(); - return $markdown->parse( $text ); - } + /** + * Transform Markdown markup to HTML. + * + * Tries (in vain) to emulate the transformation that WordPress.org applies to readme.txt files. + * + * @param string $text + * + * @return string + */ + protected function applyMarkdown(string $text): string + { + // The WP standard for readme files uses some custom markup, like "= H4 headers =" + $text = preg_replace('@^\s*=\s*(.+?)\s*=\s*$@m', "

$1

\n", $text); + $markdown = new Parsedown(); + return $markdown->parse($text); + } } diff --git a/src/Parsers/PluginParser.php b/src/Parsers/PluginParser.php index 407b7f7..eb928c7 100755 --- a/src/Parsers/PluginParser.php +++ b/src/Parsers/PluginParser.php @@ -1,167 +1,167 @@ - */ - protected $headerMap = array( - 'name' => 'Plugin Name', - 'plugin_uri' => 'Plugin URI', - 'version' => 'Version', - 'description' => 'Description', - 'author' => 'Author', - 'author_profile' => 'Author URI', - 'text_domain' => 'Text Domain', - 'domain_path' => 'Domain Path', - 'network' => 'Network', - ); - - /** - * Parse file readme.txt - * - * @param string $content Readme file content. - * - * @return null|array - */ - public function parseReadme( string $content ) : null|array { - $readmeTxtContents = trim( $content, " \t\n\r" ); - $readme = array( - 'name' => '', - 'contributors' => array(), - 'donate' => '', - 'tags' => array(), - 'requires' => '', - 'tested' => '', - 'stable' => '', - 'short_description' => '', - 'sections' => array(), - ); - - // The readme.txt header has a fairly fixed structure, so we can parse it line-by-line - $lines = explode( "\n", $readmeTxtContents ); - - // Plugin name is at the very top, e.g. === My Plugin === - if ( preg_match( '@===\s*(.+?)\s*===@', array_shift( $lines ), $matches ) ) { - $readme['name'] = $matches[1]; - } else { - return null; - } - - // Then there's a bunch of meta fields formatted as "Field: value" - $headers = array(); - $headerMap = array( - 'Contributors' => 'contributors', - 'Donate link' => 'donate', - 'Tags' => 'tags', - 'Requires at least' => 'requires', - 'Tested up to' => 'tested', - 'Stable tag' => 'stable', - ); - do { // Parse each readme.txt header - $pieces = explode( ':', array_shift( $lines ), 2 ); - if ( array_key_exists( $pieces[0], $headerMap ) ) { - if ( isset( $pieces[1] ) ) { - $headers[ $headerMap[ $pieces[0] ] ] = trim( $pieces[1] ); - } else { - $headers[ $headerMap[ $pieces[0] ] ] = ''; - } - } - } while ( trim( $pieces[0] ) != '' ); // Until an empty line is encountered - - // "Contributors" is a comma-separated list. Convert it to an array. - if ( ! empty( $headers['contributors'] ) ) { - $headers['contributors'] = array_map( 'trim', explode( ',', $headers['contributors'] ) ); - } - - // Likewise for "Tags" - if ( ! empty( $headers['tags'] ) ) { - $headers['tags'] = array_map( 'trim', explode( ',', $headers['tags'] ) ); - } - - $readme = array_merge( $readme, $headers ); - - // After the headers comes the short description - $readme['short_description'] = array_shift( $lines ); - - // Finally, a valid readme.txt also contains one or more "sections" identified by "== Section Name ==" - $sections = array(); - $contentBuffer = array(); - $currentSection = ''; - foreach ( $lines as $line ) { - // Is this a section header? - if ( preg_match( '@^\s*==\s+(.+?)\s+==\s*$@m', $line, $matches ) ) { - +class PluginParser extends Parser +{ + /** + * Header map. + * + * @var array + */ + protected $headerMap = array( + 'name' => 'Plugin Name', + 'plugin_uri' => 'Plugin URI', + 'version' => 'Version', + 'description' => 'Description', + 'author' => 'Author', + 'author_profile' => 'Author URI', + 'text_domain' => 'Text Domain', + 'domain_path' => 'Domain Path', + 'network' => 'Network', + ); + + /** + * Parse file readme.txt + * + * @param string $content Readme file content. + * + * @return null|array + */ + public function parseReadme(string $content): null|array + { + $readmeTxtContents = trim($content, " \t\n\r"); + $readme = array( + 'name' => '', + 'contributors' => array(), + 'donate' => '', + 'tags' => array(), + 'requires' => '', + 'tested' => '', + 'stable' => '', + 'short_description' => '', + 'sections' => array(), + ); + + // The readme.txt header has a fairly fixed structure, so we can parse it line-by-line + $lines = explode("\n", $readmeTxtContents); + + // Plugin name is at the very top, e.g. === My Plugin === + if (preg_match('@===\s*(.+?)\s*===@', array_shift($lines), $matches)) { + $readme['name'] = $matches[1]; + } else { + return null; + } + + // Then there's a bunch of meta fields formatted as "Field: value" + $headers = array(); + $headerMap = array( + 'Contributors' => 'contributors', + 'Donate link' => 'donate', + 'Tags' => 'tags', + 'Requires at least' => 'requires', + 'Tested up to' => 'tested', + 'Stable tag' => 'stable', + ); + do { // Parse each readme.txt header + $pieces = explode(':', array_shift($lines), 2); + if (array_key_exists($pieces[0], $headerMap)) { + if (isset($pieces[1])) { + $headers[ $headerMap[ $pieces[0] ] ] = trim($pieces[1]); + } else { + $headers[ $headerMap[ $pieces[0] ] ] = ''; + } + } + } while (trim($pieces[0]) != ''); // Until an empty line is encountered + + // "Contributors" is a comma-separated list. Convert it to an array. + if (! empty($headers['contributors'])) { + $headers['contributors'] = array_map('trim', explode(',', $headers['contributors'])); + } + + // Likewise for "Tags" + if (! empty($headers['tags'])) { + $headers['tags'] = array_map('trim', explode(',', $headers['tags'])); + } + + $readme = array_merge($readme, $headers); + + // After the headers comes the short description + $readme['short_description'] = array_shift($lines); + + // Finally, a valid readme.txt also contains one or more "sections" identified by "== Section Name ==" + $sections = array(); + $contentBuffer = array(); + $currentSection = ''; + foreach ($lines as $line) { + // Is this a section header? + if (preg_match('@^\s*==\s+(.+?)\s+==\s*$@m', $line, $matches)) { // Flush the content buffer for the previous section, if any - if ( ! empty( $currentSection ) ) { - $sectionContent = trim( implode( "\n", $contentBuffer ) ); - $sections[ $currentSection ] = $sectionContent; - } - - // Start reading a new section - $currentSection = $matches[1]; - $currentSection = strtolower($currentSection); - $contentBuffer = array(); - - } else { - - // Buffer all section content - $contentBuffer[] = $line; - } - } - - // Flush the buffer for the last section - if ( ! empty( $currentSection ) ) { - $sections[ $currentSection ] = trim( implode( "\n", $contentBuffer ) ); - } - - // Apply Markdown to sections - $sections = array_map( array( $this, 'applyMarkdown' ), $sections ); - $readme['sections'] = $sections; - - return $readme; - } - - /** - * Parse the plugin contents to retrieve plugin's metadata headers. - * - * Adapted from the get_plugin_data() function used by WordPress. - * Returns an array that contains the following: - * 'Name' - Name of the plugin. - * 'Title' - Title of the plugin and the link to the plugin's web site. - * 'Description' - Description of what the plugin does and/or notes from the author. - * 'Author' - The author's name. - * 'AuthorURI' - The author's web site address. - * 'Version' - The plugin version number. - * 'PluginURI' - Plugin web site address. - * 'TextDomain' - Plugin's text domain for localization. - * 'DomainPath' - Plugin's relative directory path to .mo files. - * 'Network' - Boolean. Whether the plugin can only be activated network wide. - * - * If the input string doesn't appear to contain a valid plugin header, the function - * will return NULL. - * - * @param string $fileContents Contents of the plugin file - * - * @return null|array See above for description. - */ - public function parsePlugin( string $fileContents ) : array|null { - - $headers = $this->parseHeaders( $fileContents ); - - $headers['network'] = ( strtolower( $headers['network'] ) === 'true' ); - - // If it doesn't have a name, it's probably not a plugin. - if ( empty( $headers['name'] ) ) { - return null; - } - - return $headers; - } + if (! empty($currentSection)) { + $sectionContent = trim(implode("\n", $contentBuffer)); + $sections[ $currentSection ] = $sectionContent; + } + + // Start reading a new section + $currentSection = $matches[1]; + $currentSection = strtolower($currentSection); + $contentBuffer = array(); + } else { + // Buffer all section content + $contentBuffer[] = $line; + } + } + + // Flush the buffer for the last section + if (! empty($currentSection)) { + $sections[ $currentSection ] = trim(implode("\n", $contentBuffer)); + } + + // Apply Markdown to sections + $sections = array_map(array( $this, 'applyMarkdown' ), $sections); + $readme['sections'] = $sections; + + return $readme; + } + + /** + * Parse the plugin contents to retrieve plugin's metadata headers. + * + * Adapted from the get_plugin_data() function used by WordPress. + * Returns an array that contains the following: + * 'Name' - Name of the plugin. + * 'Title' - Title of the plugin and the link to the plugin's web site. + * 'Description' - Description of what the plugin does and/or notes from the author. + * 'Author' - The author's name. + * 'AuthorURI' - The author's web site address. + * 'Version' - The plugin version number. + * 'PluginURI' - Plugin web site address. + * 'TextDomain' - Plugin's text domain for localization. + * 'DomainPath' - Plugin's relative directory path to .mo files. + * 'Network' - Boolean. Whether the plugin can only be activated network wide. + * + * If the input string doesn't appear to contain a valid plugin header, the function + * will return NULL. + * + * @param string $fileContents Contents of the plugin file + * + * @return null|array See above for description. + */ + public function parsePlugin(string $fileContents): array|null + { + + $headers = $this->parseHeaders($fileContents); + + $headers['network'] = ( strtolower($headers['network']) === 'true' ); + + // If it doesn't have a name, it's probably not a plugin. + if (empty($headers['name'])) { + return null; + } + + return $headers; + } } diff --git a/src/Parsers/ThemeParser.php b/src/Parsers/ThemeParser.php index 2038c26..5f3a919 100755 --- a/src/Parsers/ThemeParser.php +++ b/src/Parsers/ThemeParser.php @@ -1,47 +1,49 @@ - */ - protected $headerMap = array( - 'name' => 'Theme Name', - 'theme_uri' => 'Theme URI', - 'description' => 'Description', - 'author' => 'Author', - 'author_uri' => 'Author URI', - 'version' => 'Version', - 'template' => 'Template', - 'status' => 'Status', - 'tags' => 'Tags', - 'text_domain' => 'Text Domain', - 'domain_path' => 'Domain Path', - ); +class ThemeParser extends Parser +{ + /** + * Header map. + * + * @var array + */ + protected $headerMap = array( + 'name' => 'Theme Name', + 'theme_uri' => 'Theme URI', + 'description' => 'Description', + 'author' => 'Author', + 'author_uri' => 'Author URI', + 'version' => 'Version', + 'template' => 'Template', + 'status' => 'Status', + 'tags' => 'Tags', + 'text_domain' => 'Text Domain', + 'domain_path' => 'Domain Path', + ); - /** - * Parse style.css file. - * - * @param string $fileContents Contents of style.css file. - * - * @return null|array - */ - public function parseStyle( string $fileContents ) : null|array { + /** + * Parse style.css file. + * + * @param string $fileContents Contents of style.css file. + * + * @return null|array + */ + public function parseStyle(string $fileContents): null|array + { - $headers = $this->parseHeaders( $fileContents ); - $headers['tags'] = array_filter( array_map( 'trim', explode( ',', strip_tags( $headers['tags'] ) ) ) ); + $headers = $this->parseHeaders($fileContents); + $headers['tags'] = array_filter(array_map('trim', explode(',', strip_tags($headers['tags'])))); - // If it doesn't have a name, it's probably not a valid theme. - if ( empty( $headers['name'] ) ) { - return null; - } + // If it doesn't have a name, it's probably not a valid theme. + if (empty($headers['name'])) { + return null; + } - return $headers; - } + return $headers; + } } diff --git a/src/WPPackage.php b/src/WPPackage.php index ebaf3c5..343ed44 100755 --- a/src/WPPackage.php +++ b/src/WPPackage.php @@ -1,207 +1,217 @@ - */ - protected $metadata = array(); - - /** - * Package file path. - * - * @var string - */ - private $package_path; - - /** - * Package type. - * - * @var string - */ - private $type = null; - - /** - * Construct a package instance and parse the provided zip file. - * - * @param $package_path - */ - public function __construct( string $package_path ) { - $this->package_path = $package_path; - $this->parse(); - } - - /** - * Get slug. - * - * @return string|null - */ - public function getSlug() : string|null { - $metadata = $this->getMetaData(); - - if ( ! isset( $metadata['slug'] ) ) { - return null; - } - - return $metadata['slug']; - } - - /** - * Get metadata. - * - * @return array - */ - public function getMetaData() : array { - return $this->metadata; - } - - /** - * Parse package. - * - * @return bool - */ - private function parse() : bool { - if ( ! $this->validateFile() ) { - return false; - } - - $plugin_parser = new Parsers\PluginParser(); - $theme_parser = new Parsers\ThemeParser(); - - $slug = null; - $zip = $this->openPackage(); - $files = $zip->numFiles; - - for ( $index = 0; $index < $files; $index ++ ) { - $info = $zip->statIndex( $index ); - - $file = $this->exploreFile( $info['name'] ); - if ( ! $file ) { - continue; - } - - $slug = $file['dirname']; - $file_name = $file['name'] . '.' . $file['extension']; - $content = $zip->getFromIndex( $index ); - - if ( $file['extension'] === 'php' ) { - $headers = $plugin_parser->parsePlugin( $content ); - - if ( $headers ) { - //Add plugin file - $plugin_file = $slug . '/' . $file_name; - $headers['plugin'] = $plugin_file; - - $this->type = 'plugin'; - $this->metadata = array_merge( $this->metadata, $headers ); - } - - continue; - } - - if ( $file_name === 'readme.txt' ) { - $data = $plugin_parser->parseReadme( $content ); - unset( $data['name'] ); - $data['readme'] = true; - $this->metadata = array_merge( $data, $this->metadata ); - - continue; - } - - if ( $file_name === 'style.css' ) { - $headers = $theme_parser->parseStyle( $content ); - if ( $headers ) { - $this->type = 'theme'; - $this->metadata = $headers; - } - } - } - - if ( empty( $this->type ) ) { - $this->metadata = array(); - - return false; - } - - $this->metadata['slug'] = $slug; - - return true; - } - - /** - * Get package type. - * - * @return string|null - */ - public function getType() : string|null { - return $this->type; - } - - /** - * Explore file. - * - * @param string $file_name File name. - * - * @return bool|array - */ - private function exploreFile( string $file_name ) : bool|array { - $data = pathinfo( $file_name ); - $dirname = $data['dirname']; - $depth = substr_count( $dirname, '/' ); - $extension = ! empty( $data['extension'] ) ? $data['extension'] : false; - - //Skip directories and everything that's more than 1 sub-directory deep. - if ( $depth > 0 || ! $extension ) { - return false; - } - - return array( - 'dirname' => $dirname, - 'name' => $data['filename'], - 'extension' => $data['extension'] - ); - } - - /** - * Validate package file. - * - * @return bool - */ - private function validateFile() { - $file = $this->package_path; - - if ( ! file_exists( $file ) || ! is_readable( $file ) ) { - return false; - } - - if ( 'zip' !== pathinfo( $file, PATHINFO_EXTENSION ) ) { - return false; - } - - return true; - } - - /** - * Open package file. - * - * @return false|ZipArchive - */ - private function openPackage() : bool|ZipArchive { - $file = $this->package_path; - - $zip = new ZipArchive(); - if ( $zip->open( $file ) !== true ) { - return false; - } - - return $zip; - } +class WPPackage +{ + /** + * Metadata. + * + * @var array + */ + protected $metadata = array(); + + /** + * Package file path. + * + * @var string + */ + private $package_path; + + /** + * Package type. + * + * @var string + */ + private $type = null; + + /** + * Construct a package instance and parse the provided zip file. + * + * @param $package_path + */ + public function __construct(string $package_path) + { + $this->package_path = $package_path; + $this->parse(); + } + + /** + * Get slug. + * + * @return string|null + */ + public function getSlug(): string|null + { + $metadata = $this->getMetaData(); + + if (! isset($metadata['slug'])) { + return null; + } + + return $metadata['slug']; + } + + /** + * Get metadata. + * + * @return array + */ + public function getMetaData(): array + { + return $this->metadata; + } + + /** + * Parse package. + * + * @return bool + */ + private function parse(): bool + { + if (! $this->validateFile()) { + return false; + } + + $plugin_parser = new Parsers\PluginParser(); + $theme_parser = new Parsers\ThemeParser(); + + $slug = null; + $zip = $this->openPackage(); + $files = $zip->numFiles; + + for ($index = 0; $index < $files; $index++) { + $info = $zip->statIndex($index); + + $file = $this->exploreFile($info['name']); + if (! $file) { + continue; + } + + $slug = $file['dirname']; + $file_name = $file['name'] . '.' . $file['extension']; + $content = $zip->getFromIndex($index); + + if ($file['extension'] === 'php') { + $headers = $plugin_parser->parsePlugin($content); + + if ($headers) { + //Add plugin file + $plugin_file = $slug . '/' . $file_name; + $headers['plugin'] = $plugin_file; + + $this->type = 'plugin'; + $this->metadata = array_merge($this->metadata, $headers); + } + + continue; + } + + if ($file_name === 'readme.txt') { + $data = $plugin_parser->parseReadme($content); + unset($data['name']); + $data['readme'] = true; + $this->metadata = array_merge($data, $this->metadata); + + continue; + } + + if ($file_name === 'style.css') { + $headers = $theme_parser->parseStyle($content); + if ($headers) { + $this->type = 'theme'; + $this->metadata = $headers; + } + } + } + + if (empty($this->type)) { + $this->metadata = array(); + + return false; + } + + $this->metadata['slug'] = $slug; + + return true; + } + + /** + * Get package type. + * + * @return string|null + */ + public function getType(): string|null + { + return $this->type; + } + + /** + * Explore file. + * + * @param string $file_name File name. + * + * @return bool|array + */ + private function exploreFile(string $file_name): bool|array + { + $data = pathinfo($file_name); + $dirname = $data['dirname']; + $depth = substr_count($dirname, '/'); + $extension = ! empty($data['extension']) ? $data['extension'] : false; + + //Skip directories and everything that's more than 1 sub-directory deep. + if ($depth > 0 || ! $extension) { + return false; + } + + return array( + 'dirname' => $dirname, + 'name' => $data['filename'], + 'extension' => $data['extension'] + ); + } + + /** + * Validate package file. + * + * @return bool + */ + private function validateFile() + { + $file = $this->package_path; + + if (! file_exists($file) || ! is_readable($file)) { + return false; + } + + if ('zip' !== pathinfo($file, PATHINFO_EXTENSION)) { + return false; + } + + return true; + } + + /** + * Open package file. + * + * @return false|ZipArchive + */ + private function openPackage(): bool|ZipArchive + { + $file = $this->package_path; + + $zip = new ZipArchive(); + if ($zip->open($file) !== true) { + return false; + } + + return $zip; + } } diff --git a/tests/Unit/Parsers/PluginParserTest.php b/tests/Unit/Parsers/PluginParserTest.php index de18b58..b118409 100755 --- a/tests/Unit/Parsers/PluginParserTest.php +++ b/tests/Unit/Parsers/PluginParserTest.php @@ -1,47 +1,52 @@ assertEquals( null, $package->getType() ); - $this->assertEquals( null, $package->getSlug() ); - $this->assertEquals( array(), $package->getMetaData() ); - } + */ + public function testNoInfoWhenPackageNotFound() + { + $package = new WPPackage('/path/wrong/abc.zip'); + $this->assertEquals(null, $package->getType()); + $this->assertEquals(null, $package->getSlug()); + $this->assertEquals(array(), $package->getMetaData()); + } - /** + /** * Correctly parses a valid plugin. * * @return void - */ - public function test_parses_valid_plugin() { - $package = new WPPackage( TESTS_DIR . '/packages/hello-dolly.1.6.zip' ); - $this->assertEquals( 'plugin', $package->getType() ); - $this->assertEquals( 'hello-dolly', $package->getSlug() ); - } + */ + public function testParsesValidPlugin() + { + $package = new WPPackage(TESTS_DIR . '/packages/hello-dolly.1.6.zip'); + $this->assertEquals('plugin', $package->getType()); + $this->assertEquals('hello-dolly', $package->getSlug()); + } - /** + /** * getMetaData() should return correct data about the package. * * @return void - */ - public function test_getMetaData_should_return_correct_data_for_plugin() { - $package = new WPPackage( TESTS_DIR . '/packages/hello-dolly.1.6.zip' ); + */ + public function testGetmetadataShouldReturnCorrectDataForPlugin() + { + $package = new WPPackage(TESTS_DIR . '/packages/hello-dolly.1.6.zip'); - $metadata = $package->getMetaData(); - $this->assertEquals( 'Hello Dolly', $metadata['name'] ); - $this->assertEquals( 'hello-dolly/hello.php', $metadata['plugin'] ); - $this->assertEquals( '4.6', $metadata['requires'] ); - $this->assertEquals( '4.7', $metadata['tested'] ); - $this->assertEquals( '1.6', $metadata['version'] ); - $this->assertEquals( 'hello-dolly', $metadata['slug'] ); - } + $metadata = $package->getMetaData(); + $this->assertEquals('Hello Dolly', $metadata['name']); + $this->assertEquals('hello-dolly/hello.php', $metadata['plugin']); + $this->assertEquals('4.6', $metadata['requires']); + $this->assertEquals('4.7', $metadata['tested']); + $this->assertEquals('1.6', $metadata['version']); + $this->assertEquals('hello-dolly', $metadata['slug']); + } } diff --git a/tests/Unit/Parsers/ThemeParserTest.php b/tests/Unit/Parsers/ThemeParserTest.php index 0014172..6b5f29b 100755 --- a/tests/Unit/Parsers/ThemeParserTest.php +++ b/tests/Unit/Parsers/ThemeParserTest.php @@ -1,47 +1,52 @@ assertEquals( null, $package->getType() ); - $this->assertEquals( array(), $package->getMetaData() ); - $this->assertEquals( null, $package->getSlug() ); - } + */ + public function testNoInfoWhenPackageNotFound() + { + $package = new WPPackage('path/wrong/test.zip'); + $this->assertEquals(null, $package->getType()); + $this->assertEquals(array(), $package->getMetaData()); + $this->assertEquals(null, $package->getSlug()); + } /** * Correctly parses a valid package. * * @return void - */ - public function test_parses_valid_theme() { - $package = new WPPackage( TESTS_DIR . '/packages/twentyseventeen.1.3.zip' ); - $this->assertEquals( 'theme', $package->getType() ); - $this->assertEquals( 'twentyseventeen', $package->getSlug() ); + */ + public function testParsesValidTheme() + { + $package = new WPPackage(TESTS_DIR . '/packages/twentyseventeen.1.3.zip'); + $this->assertEquals('theme', $package->getType()); + $this->assertEquals('twentyseventeen', $package->getSlug()); } - /** + /** * getMetaData() should return correct data about the package. * * @return void */ - public function test_getMetaData_should_return_correct_data_for_theme() { - $package = new WPPackage( TESTS_DIR . '/packages/twentysixteen.1.3.zip' ); - $this->assertEquals( 'theme', $package->getType() ); - $this->assertEquals( 'twentysixteen', $package->getSlug() ); + public function testGetmetadataShouldReturnCorrectDataForTheme() + { + $package = new WPPackage(TESTS_DIR . '/packages/twentysixteen.1.3.zip'); + $this->assertEquals('theme', $package->getType()); + $this->assertEquals('twentysixteen', $package->getSlug()); - $metadata = $package->getMetaData(); - $this->assertEquals( '1.3', $metadata['version'] ); - $this->assertEquals( 'Twenty Sixteen', $metadata['name'] ); - $this->assertEquals( 'twentysixteen', $metadata['text_domain'] ); - $this->assertEquals( 'twentysixteen', $metadata['slug'] ); - } + $metadata = $package->getMetaData(); + $this->assertEquals('1.3', $metadata['version']); + $this->assertEquals('Twenty Sixteen', $metadata['name']); + $this->assertEquals('twentysixteen', $metadata['text_domain']); + $this->assertEquals('twentysixteen', $metadata['slug']); + } } diff --git a/tests/bootstrap-phpstan.php b/tests/bootstrap-phpstan.php index 5df7f1e..95157a5 100644 --- a/tests/bootstrap-phpstan.php +++ b/tests/bootstrap-phpstan.php @@ -6,5 +6,5 @@ * @link https://phpstan.org/config-reference#bootstrap */ -define( 'PROJECT_ROOT', dirname( __DIR__ ) ); -define( 'TESTS_DIR', dirname( __FILE__ ) ); +define('PROJECT_ROOT', dirname(__DIR__)); +define('TESTS_DIR', dirname(__FILE__)); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 541b250..14d6d7a 100755 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,7 +1,7 @@ Date: Sat, 25 May 2024 23:07:51 -0400 Subject: [PATCH 5/7] Added release workflow (#3) --- .github/workflows/release.yml | 49 +++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8a81afe --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,49 @@ +# This workflow automates the release process: when a branch matching "release/vX.Y.Z" is merged +# into "main", automatically create a new **draft** release with information about the release. +name: Release + +# Only execute when a release branch has been merged into "main". +on: + pull_request: + types: + - closed + branches: + - main + +jobs: + + # Automatically prepare a release following a successful merge into "main". + publish-release: + if: github.event.pull_request.merged == true + name: Release to Packagist + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Determine release version + id: parse-version + env: + # Parse the version number from a branch name based on semver. + # Reference and examples of matched patterns: https://regexr.com/6jfqu + pattern: '(?:^|\/)v?\.?\K(\d+\.\d+\.\d+(-[0-9A-Za-z-]+(?:\.\d+)?)?(\+(?:\.?[0-9A-Za-z-]+)+)?)$' + run: | + version=$(grep -oP "${{ env.pattern }}" <<< "${{ github.event.pull_request.head.ref }}") + echo "::set-output name=version::$version" + echo "Parsed version: '${version}'" + + - name: Create draft release + id: publish + uses: ncipollo/release-action@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + tag: "v${{ steps.parse-version.outputs.version }}" + commit: main + name: ${{ github.event.pull_request.title }} + body: ${{ github.event.pull_request.body }} + draft: true + prerelease: ${{ contains(steps.publish.outputs.version, '-') }} + + - name: Release details + run: | + echo "Draft release created: ${{ steps.publish.outputs.html_url }}" From d1d345c4b2fe4f3cad79a063a5ac001ece7bd284 Mon Sep 17 00:00:00 2001 From: Ren Ventura Date: Wed, 24 Jul 2024 13:17:46 -0400 Subject: [PATCH 6/7] Corrected return type (#4) --- src/Parsers/PluginParser.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Parsers/PluginParser.php b/src/Parsers/PluginParser.php index eb928c7..7f8bc2d 100755 --- a/src/Parsers/PluginParser.php +++ b/src/Parsers/PluginParser.php @@ -53,7 +53,7 @@ public function parseReadme(string $content): null|array if (preg_match('@===\s*(.+?)\s*===@', array_shift($lines), $matches)) { $readme['name'] = $matches[1]; } else { - return null; + return []; } // Then there's a bunch of meta fields formatted as "Field: value" From 37370ab504f80aa8f0b55f4c3bee650dd3cbbe5a Mon Sep 17 00:00:00 2001 From: Andrew Dawes <53574062+AndrewJDawes@users.noreply.github.com> Date: Tue, 29 Jul 2025 07:33:59 -0400 Subject: [PATCH 7/7] Update to include newly-added Plugin Header Requirements https://developer.wordpress.org/plugins/plugin-basics/header-requirements/ --- src/Parsers/PluginParser.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Parsers/PluginParser.php b/src/Parsers/PluginParser.php index 7f8bc2d..0c7c44d 100755 --- a/src/Parsers/PluginParser.php +++ b/src/Parsers/PluginParser.php @@ -15,13 +15,19 @@ class PluginParser extends Parser protected $headerMap = array( 'name' => 'Plugin Name', 'plugin_uri' => 'Plugin URI', - 'version' => 'Version', 'description' => 'Description', + 'version' => 'Version', + 'requires_at_least' => 'Requires at least', + 'requires_php' => 'Requires PHP', 'author' => 'Author', 'author_profile' => 'Author URI', + 'license' => 'License', + 'license_uri' => 'License URI', 'text_domain' => 'Text Domain', 'domain_path' => 'Domain Path', 'network' => 'Network', + 'update_uri' => 'Update URI', + 'requires_plugins' => 'Requires Plugins', ); /**