├── .babelrc ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── .stylelintrc ├── .travis.yml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── assets └── dev │ ├── application.js │ ├── licenses.json │ ├── scripts │ ├── index.js │ ├── theme-sniffer.js │ └── utils │ │ ├── ajax.js │ │ └── sniff-js.js │ └── styles │ ├── admin.scss │ ├── application.scss │ ├── components │ └── _admin-screen.scss │ └── utils │ └── _colors.scss ├── composer.json ├── composer.lock ├── package-lock.json ├── package.json ├── phpcs.xml.dist ├── readme.txt ├── screenshot.png ├── src ├── CompiledContainer.php ├── admin-menus │ ├── class-base-admin-menu.php │ └── class-sniff-page.php ├── callback │ ├── class-base-ajax-callback.php │ ├── class-run-sniffer-callback.php │ └── invokable-interface.php ├── class-di-container.php ├── class-plugin-factory.php ├── class-plugin.php ├── enqueue │ ├── assets-interface.php │ └── class-enqueue-resources.php ├── exceptions │ ├── class-api-response-error.php │ ├── class-failed-to-load-view.php │ ├── class-invalid-service.php │ ├── class-invalid-uri.php │ ├── class-missing-manifest.php │ ├── class-no-themes-present.php │ ├── class-plugin-activation-failure.php │ └── general-exception-interface.php ├── has-activation-interface.php ├── has-deactivation-interface.php ├── helpers │ ├── file-helpers-trait.php │ ├── readme-helpers-trait.php │ └── sniffer-helpers-trait.php ├── i18n │ └── class-internationalization.php ├── registerable-interface.php ├── renderable-interface.php ├── service-interface.php ├── sniffs │ ├── class-result.php │ ├── class-validate-file.php │ ├── class-validate.php │ ├── has-results-interface.php │ ├── readme │ │ ├── class-contributors.php │ │ ├── class-license-uri.php │ │ ├── class-license.php │ │ ├── class-parser.php │ │ └── class-validator.php │ ├── screenshot │ │ └── class-validator.php │ └── validatable-interface.php └── view │ ├── class-base-view.php │ ├── class-escaped-view.php │ ├── class-post-escaped-view.php │ ├── class-templated-view.php │ └── class-view.php ├── theme-sniffer.php ├── views ├── partials │ └── report-notice.php └── theme-sniffer-page.php └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "browsers": [ 8 | "last 2 versions", 9 | "not ie < 11", 10 | "android >= 4.2" 11 | ], 12 | "node": "current" 13 | } 14 | } 15 | ] 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # This file is for unifying the coding style for different editors and IDEs 2 | # editorconfig.org 3 | 4 | # WordPress Coding Standards 5 | # https://make.wordpress.org/core/handbook/coding-standards/ 6 | 7 | root = true 8 | 9 | [*] 10 | charset = utf-8 11 | end_of_line = lf 12 | insert_final_newline = true 13 | trim_trailing_whitespace = true 14 | indent_style = tab 15 | indent_size = 4 16 | 17 | [*.{json,yaml}] 18 | indent_style = space 19 | indent_size = 2 20 | 21 | [*.md] 22 | trim_trailing_whitespace = false 23 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /vendor/* 2 | /node_modules/* 3 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "globals": { 4 | "__VERSION__": true 5 | }, 6 | "env": { 7 | "browser": true, 8 | "node": true, 9 | "commonjs": true, 10 | "es6": true, 11 | "jquery": true 12 | }, 13 | "extends": ["eslint:recommended", "wordpress"], 14 | "rules": { 15 | "indent": ["error", "tab"], 16 | "linebreak-style": ["error", "unix"], 17 | "quotes": ["error", "single"], 18 | "semi": ["error", "always"], 19 | "yoda": ["error", "never"] 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[Bug]" 5 | labels: 'Type: Bug' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Environment (please complete the following information):** 27 | - OS: [e.g. iOS, Windows, Linux] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 1.0.0] 30 | - PHP Version [e.g. 7.0, 7.1] 31 | - WP Version [e.g. 4.9, 5.2] 32 | 33 | **Additional context** 34 | Add any other context about the problem here. 35 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[New Feature]" 5 | labels: 'Type: Enhancement' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Node and composer folders 2 | node_modules/ 3 | vendor/ 4 | 5 | # Project build folder 6 | /build/ 7 | /assets/build/ 8 | 9 | # Mac OS custom attribute store and thumbnails 10 | *.DS_Store 11 | ._* 12 | 13 | # Exclude final build zip file and temp folder 14 | /theme-sniffer 15 | .theme-sniffer.zip 16 | 17 | # Windows thumbnail cache files 18 | Thumbs.db 19 | ehthumbs.db 20 | ehthumbs_vista.db 21 | 22 | # IDE specific 23 | theme-sniffer.sublime-project 24 | theme-sniffer.sublime-workspace 25 | -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-wordpress" 3 | } 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: trusty 2 | 3 | cache: 4 | directories: 5 | # Cache directory for older Composer versions. 6 | - $HOME/.composer/cache/files 7 | # Cache directory for more recent Composer versions. 8 | - $HOME/.cache/composer/files 9 | 10 | language: php 11 | 12 | php: 13 | - 7.0 14 | - 7.1 15 | - 7.2 16 | - "7.4snapshot" 17 | 18 | notifications: 19 | email: 20 | on_success: never 21 | on_failure: change 22 | 23 | branches: 24 | only: 25 | - master 26 | - development 27 | 28 | matrix: 29 | fast_finish: true 30 | include: 31 | - php: 7.3 32 | env: PHPCS=1 33 | 34 | allow_failures: 35 | # Allow failures for unstable builds. 36 | - php: "7.4snapshot" 37 | 38 | before_install: 39 | - phpenv config-rm xdebug.ini 40 | - composer install 41 | 42 | script: 43 | # Validate the composer.json file. 44 | # @link https://getcomposer.org/doc/03-cli.md#validate 45 | - composer validate --no-check-all --strict 46 | 47 | # Lint the PHP files against parse errors. 48 | - composer lint 49 | 50 | # Check the codebase for any PHPCS errors. 51 | - if [[ "$PHPCS" == 1 ]]; then composer checkcs; fi 52 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | # WordPress Etiquette 4 | 5 | This project comes under the WordPress [Etiquette](https://wordpress.org/about/etiquette/): 6 | 7 | In the WordPress open source project, we realize that our biggest asset is the community that we foster. The project, as a whole, follows these basic philosophical principles from The Cathedral and The Bazaar. 8 | 9 | - Contributions to the WordPress open source project are for the benefit of the WordPress community as a whole, not specific businesses or individuals. All actions taken as a contributor should be made with the best interests of the community in mind. 10 | - Participation in the WordPress open source project is open to all who wish to join, regardless of ability, skill, financial status, or any other criteria. 11 | - The WordPress open source project is a volunteer-run community. Even in cases where contributors are sponsored by companies, that time is donated for the benefit of the entire open source community. 12 | - Any member of the community can donate their time and contribute to the project in any form including design, code, documentation, community building, etc. For more information, go to make.wordpress.org. 13 | - The WordPress open source community cares about diversity. We strive to maintain a welcoming environment where everyone can feel included, by keeping communication free of discrimination, incitement to violence, promotion of hate, and unwelcoming behavior. 14 | 15 | The team involved will block any user who causes any breach in this. 16 | 17 | ## Our Pledge 18 | 19 | In the interest of fostering an open and welcoming environment, we as 20 | contributors and maintainers pledge to making participation in our project and 21 | our community a harassment-free experience for everyone, regardless of age, body 22 | size, disability, ethnicity, sex characteristics, gender identity and expression, 23 | level of experience, education, socio-economic status, nationality, personal 24 | appearance, race, religion, or sexual identity and orientation. 25 | 26 | ## Our Standards 27 | 28 | Examples of behavior that contributes to creating a positive environment 29 | include: 30 | 31 | * Using welcoming and inclusive language 32 | * Being respectful of differing viewpoints and experiences 33 | * Gracefully accepting constructive criticism 34 | * Focusing on what is best for the community 35 | * Showing empathy towards other community members 36 | 37 | Examples of unacceptable behavior by participants include: 38 | 39 | * The use of sexualized language or imagery and unwelcome sexual attention or 40 | advances 41 | * Trolling, insulting/derogatory comments, and personal or political attacks 42 | * Public or private harassment 43 | * Publishing others' private information, such as a physical or electronic 44 | address, without explicit permission 45 | * Other conduct which could reasonably be considered inappropriate in a 46 | professional setting 47 | 48 | ## Our Responsibilities 49 | 50 | Project maintainers are responsible for clarifying the standards of acceptable 51 | behavior and are expected to take appropriate and fair corrective action in 52 | response to any instances of unacceptable behavior. 53 | 54 | Project maintainers have the right and responsibility to remove, edit, or 55 | reject comments, commits, code, wiki edits, issues, and other contributions 56 | that are not aligned to this Code of Conduct, or to ban temporarily or 57 | permanently any contributor for other behaviors that they deem inappropriate, 58 | threatening, offensive, or harmful. 59 | 60 | ## Scope 61 | 62 | This Code of Conduct applies both within project spaces and in public spaces 63 | when an individual is representing the project or its community. Examples of 64 | representing a project or community include using an official project e-mail 65 | address, posting via an official social media account, or acting as an appointed 66 | representative at an online or offline event. Representation of a project may be 67 | further defined and clarified by project maintainers. 68 | 69 | ## Enforcement 70 | 71 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 72 | reported by contacting the project team at themes@wordpress.org. All 73 | complaints will be reviewed and investigated and will result in a response that 74 | is deemed necessary and appropriate to the circumstances. The project team is 75 | obligated to maintain confidentiality with regard to the reporter of an incident. 76 | Further details of specific enforcement policies may be posted separately. 77 | 78 | Project maintainers who do not follow or enforce the Code of Conduct in good 79 | faith may face temporary or permanent repercussions as determined by other 80 | members of the project's leadership. 81 | 82 | ## Attribution 83 | 84 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 85 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 86 | 87 | [homepage]: https://www.contributor-covenant.org 88 | 89 | For answers to common questions about this code of conduct, see 90 | https://www.contributor-covenant.org/faq 91 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Hi, thank you for your interest in contributing to the Theme Sniffer plugin! We look forward to working with you. 2 | 3 | # Reporting Bugs 4 | 5 | Please search the repo to see if your issue has been reported already and if so, comment in that issue instead of opening a new one. 6 | 7 | Include as much detail as possible when describing the bug. This helps us solve your problem more quickly. Also, be sure to fill in the details in the issue template. 8 | 9 | # Contributing patches and new features 10 | 11 | ## Branches 12 | 13 | Ongoing development will be done in the `development` branch with merges done into `master` once considered stable. 14 | 15 | To contribute an improvement to this project, fork the repo and open a pull request to the `development` branch. Alternatively, if you have push access to this repo, create a feature branch prefixed by `feature/` and then open an intra-repo PR from that branch to `development`. 16 | 17 | Once a commit is made to `development`, a PR should be opened from `development` into `master` and named "Next release - X.X.X". This PR will provide collaborators with a forum to discuss the upcoming stable release. 18 | 19 | ## Code Standards for this project 20 | 21 | The code must follow the standards defined in the `phpcs.xml.dist` file in the root folder of the repo. Every pull request will go through the automatic check done by TravisCI. 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 WPTRT 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Travis](https://img.shields.io/travis/WPTRT/theme-sniffer.svg?style=for-the-badge)](https://travis-ci.org/WPTRT/theme-sniffer.svg?branch=master) 2 | [![License: MIT](https://img.shields.io/github/license/WPTRT/theme-sniffer.svg?style=for-the-badge)](https://github.com/WPTRT/theme-sniffer/blob/master/LICENSE) 3 | [![GitHub All Releases](https://img.shields.io/github/downloads/WPTRT/theme-sniffer/total.svg?style=for-the-badge)](https://github.com/WPTRT/theme-sniffer/releases/) 4 | 5 | [![Minimum PHP Version](https://img.shields.io/packagist/php-v/wptrt/theme-sniffer.svg?style=for-the-badge&maxAge=3600)](https://packagist.org/packages/wptrt/theme-sniffer) 6 | [![Tested on PHP 7.0 to nightly](https://img.shields.io/badge/tested%20on-PHP%207.0%20|%207.1%20|%207.2%20|%207.3|%20nightly-green.svg?style=for-the-badge&maxAge=2419200)](https://travis-ci.org/WPTRT/theme-sniffer) 7 | [![Number of Contributors](https://img.shields.io/github/contributors/WPTRT/theme-sniffer.svg?maxAge=3600&style=for-the-badge)](https://github.com/WPTRT/theme-sniffer/graphs/contributors) 8 | 9 | # Theme Sniffer 10 | 11 | * [Description](#description) 12 | * [Requirements](#requirements) 13 | * [Installation](#installation) 14 | * [Contributing](#contributing) 15 | * [License](#license) 16 | 17 | ## Description 18 | 19 | Theme Sniffer is a plugin utilizing custom sniffs for [PHP_CodeSniffer](https://github.com/squizlabs/PHP_CodeSniffer) that statically analyzes your theme and ensures that it adheres to WordPress coding conventions, as well as checking your code against PHP version compatibility. 20 | 21 | ## Requirements 22 | 23 | The Theme Sniffer requires: 24 | 25 | * PHP 7.0 or higher. 26 | * WordPress 4.7 or higher. 27 | 28 | ## Installation - development 29 | 30 | ### For themes development and checking 31 | 32 | * Download [zip file](https://github.com/WPTT/theme-sniffer/releases/download/1.1.2/theme-sniffer.zip). **Note**: Please use this distribution plugin zip. GitHub provided zip will not work. 33 | * Install this as you normally install a WordPress plugin 34 | * Activate plugin 35 | 36 | ### For Theme Sniffer development 37 | 38 | * Clone this repository under `wp-content/plugins/` 39 | * Run `composer install` 40 | * Run `npm install` 41 | * Run `npm run build` 42 | * Activate plugin 43 | 44 | #### On Windows 45 | 46 | * When developing on Windows machine you might run into various issues with CLI when not installed globally. Installing following items globally might help, but use it with caution: 47 | 48 | * Run `npm install --global eslint-cli` 49 | * Run `npm install --global stylelint` 50 | * Run `composer global require "squizlabs/php_codesniffer=*"` & make sure that you have installed [WordPress Coding Standards](https://github.com/WordPress/WordPress-Coding-Standards) and [PHPCompatibility](https://github.com/PHPCompatibility/PHPCompatibility) by running `phpcs -i`. When PHPCS installed globally, you might need clone both repositories and add them to PHPCS with `phpcs --config-set installed_paths /path/to/PHPCompatibility,/path/to/wpcs` 51 | 52 | **Note**: If you build the plugin this way you'll have extra `node_modules/` folders which are not required for the plugin to run, and just take up space. They are to be used for the development purposes mainly. Some of the `vendor/` folders are necessary for Theme Sniffer to run 53 | 54 | ![Screenshot](screenshot.png?raw=true) 55 | 56 | ## Usage 57 | 58 | * Go to `Theme Sniffer` 59 | * Select theme from the dropdown 60 | * Select options 61 | * Click `GO` 62 | 63 | ### Options 64 | 65 | * `Select Standard` - Select the standard with which you would like to sniff the theme 66 | * `Hide Warning` - Enable this to hide warnings 67 | * `Raw Output` - Enable this to display sniff report in plain text format. Suitable to copy/paste report to trac ticket 68 | * `Ignore annotations` - Ignores any comment annotation that might disable the sniff 69 | * `Check only PHP files` - Only checks PHP files, not CSS and JS - use this to prevent any possible memory leaks 70 | * `Minimum PHP version` - Select the minimum PHP Version to check if your theme will work with that version 71 | 72 | ## Development 73 | 74 | Development prerequisites: 75 | 76 | * Installed [Node.js](https://nodejs.org/en/) 77 | * Installed [Composer](https://getcomposer.org/) 78 | * Test environment - [Local by Flywheel](https://local.getflywheel.com/), [VVV](https://varyingvagrantvagrants.org/), [Docker](https://www.docker.com/), [MAMP](https://www.mamp.info/en/), [XAMPP](https://www.apachefriends.org/index.html), [WAMP](http://www.wampserver.com/en/), [DevKinsta](https://kinsta.com/devkinsta/) (whatever works for you) 79 | 80 | All of the development asset files are located in the `assets/dev/` folder. We have refactored the plugin to use the latest JavaScript development methods. This is why we are using [webpack](https://webpack.js.org/) to bundle our assets. 81 | 82 | When wanting to add a new feature fork the plugin. If you are a maintainer create a `feature/*` branch. 83 | 84 | To start developing, first clone this repo under `wp-content/plugins/`. Then run in the terminal: 85 | 86 | `composer install` 87 | `npm install` 88 | 89 | Then you can run: 90 | 91 | `npm run start` 92 | 93 | This will run webpack in the watch mode, so your changes will be saved in the build folder on the fly. After you're done making changes, run: 94 | 95 | `npm run build` 96 | 97 | This will create the `assets/build/` folder with js and css files that the plugin will use and a zip file for installation. 98 | 99 | If you want to skip creating the zip file, you can use 100 | 101 | `npm run dev` 102 | 103 | This command behaves like the build one, but it skips creation of the zip file. 104 | 105 | When developing JavaScript code keep in mind the separation of concerns principle - data access and business logic should be separate from the presentation. If you 'sniff' (no pun intended) through the js code, you'll see that `index.js` holds all event triggers and calls the method for sniff start that is located in the separate `ThemeSniffer` class. Business logic modules should contain plain JavaScript (no framework), which makes it reusable. Of course, there is still room for improvement, so if you notice something that could be improved we encourage you to make a PR. 106 | 107 | The same is valid for PHP code. The business logic is stored in the `src/` folder, the JS and CSS are located in `assets/` folder and the views are located in the `views/` folder. 108 | 109 | 110 | -------------------------------------------------------------------------------- /assets/dev/application.js: -------------------------------------------------------------------------------- 1 | // Load Styles. 2 | import './styles/application.scss'; 3 | 4 | // Load Scripts. 5 | import './scripts/index'; 6 | -------------------------------------------------------------------------------- /assets/dev/scripts/index.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery'; 2 | import ThemeSniffer from './theme-sniffer'; 3 | 4 | $( 5 | function() { 6 | const options = { 7 | sniffReport: $( '.js-sniff-report' ), 8 | snifferInfo: $( '.js-sniffer-info' ), 9 | checkNotice: $( '.js-check-done' ), 10 | startNotice: $( '.js-start-notice' ), 11 | reportItem: $( '.js-report-item' ), 12 | loader: $( '.js-loader' ), 13 | startButton: '.js-start-check', 14 | stopButton: '.js-stop-check', 15 | reportItemHeading: '.js-report-item-heading', 16 | reportReportTable: '.js-report-table', 17 | reportNoticeType: '.js-report-notice-type', 18 | reportNoticeSource: '.js-report-notice-source', 19 | reportItemBtn: '.js-annotation-button', 20 | reportItemSource: '.js-annotation-source', 21 | reportItemLine: '.js-report-item-line', 22 | reportItemType: '.js-report-item-type', 23 | reportItemMessage: '.js-report-item-message', 24 | runAction: 'run_sniffer', 25 | nonce: $( '#theme-sniffer_nonce' ).val() 26 | }; 27 | 28 | const themeSniffer = new ThemeSniffer( options ); 29 | 30 | $( options.startButton ).on( 31 | 'click', () => { 32 | const theme = $( 'select[name=themename]' ).val(); 33 | const warningHide = $( 'input[name=hide_warning]' ).is( ':checked' ); 34 | const outputRaw = $( 'input[name=raw_output]' ).is( ':checked' ); 35 | const ignoreAnnotations = $( 'input[name=ignore_annotations]' ).is( ':checked' ); 36 | const minPHPVersion = $( 'select[name=minimum_php_version]' ).val(); 37 | const themePrefixes = $( 'input[name=theme_prefixes]' ).val(); 38 | 39 | const selectedRulesets = $( 'input[name="selected_ruleset[]"]:checked' ).map( ( ind, el ) => el.value ).toArray(); 40 | 41 | themeSniffer.enableAjax(); 42 | themeSniffer.themeCheckRunPHPCS( theme, warningHide, outputRaw, ignoreAnnotations, minPHPVersion, selectedRulesets, themePrefixes ); 43 | } 44 | ); 45 | 46 | $( options.stopButton ).on( 47 | 'click', () => themeSniffer.preventAjax() 48 | ); 49 | 50 | $( 'select[name="themename"]' ).on( 51 | 'change', () => { 52 | if ( options.startNotice.html().length ) { 53 | themeSniffer.preventAjax(); 54 | 55 | if ( options.sniffReport.length ) { 56 | options.sniffReport.empty(); 57 | } 58 | } 59 | 60 | } 61 | ); 62 | } 63 | ); 64 | -------------------------------------------------------------------------------- /assets/dev/scripts/theme-sniffer.js: -------------------------------------------------------------------------------- 1 | /* global ajaxurl, themeSnifferLocalization */ 2 | 3 | import $ from 'jquery'; 4 | import {ajax} from './utils/ajax'; 5 | 6 | import Clipboard from 'clipboard'; 7 | 8 | export default class ThemeSniffer { 9 | constructor( options ) { 10 | this.SHOW_CLASS = 'is-shown'; 11 | this.ERROR_CLASS = 'is-error'; 12 | this.WARNING_CLASS = 'is-warning'; 13 | this.DISABLED_CLASS = 'is-disabled'; 14 | this.IS_RAW_CLASS = 'is-raw'; 15 | 16 | this.reportItemHeading = options.reportItemHeading; 17 | this.reportReportTable = options.reportReportTable; 18 | this.reportNoticeType = options.reportNoticeType; 19 | this.reportNoticeSource = options.reportNoticeSource; 20 | this.reportItemLine = options.reportItemLine; 21 | this.reportItemType = options.reportItemType; 22 | this.reportItemMessage = options.reportItemMessage; 23 | this.reportItemBtn = options.reportItemBtn; 24 | this.reportItemSource = options.reportItemSource; 25 | 26 | this.$sniffReport = options.sniffReport; 27 | this.$snifferInfo = options.snifferInfo; 28 | this.$checkNotice = options.checkNotice; 29 | this.$startNotice = options.startNotice; 30 | this.$reportItem = options.reportItem; 31 | this.$loader = options.loader; 32 | 33 | this.clipboardInstance = null; 34 | 35 | this.$startButton = $( options.startButton ); 36 | this.$stopButton = $( options.stopButton ); 37 | 38 | this.nonce = options.nonce; 39 | this.runAction = options.runAction; 40 | 41 | this.ajaxRequest = []; 42 | this.ajaxAllow = true; 43 | } 44 | 45 | enableAjax() { 46 | this.ajaxAllow = true; 47 | this.$snifferInfo.removeClass( this.SHOW_CLASS ); 48 | } 49 | 50 | preventAjax() { 51 | this.ajaxAllow = false; 52 | 53 | this.$startButton.removeClass( this.DISABLED_CLASS ); 54 | this.$stopButton.addClass( this.DISABLED_CLASS ); 55 | this.$loader.removeClass( this.SHOW_CLASS ); 56 | 57 | this.$startNotice.html( themeSnifferLocalization.ajaxAborted ).addClass( this.SHOW_CLASS ); 58 | 59 | // This will trigger error in console, but it's not an error per se. 60 | // It's expected behavior. 61 | $.each( this.ajaxRequest, ( idx, jqXHR ) => { 62 | jqXHR.abort([ themeSnifferLocalization.ajaxAborted ]); 63 | }); 64 | } 65 | 66 | renderRaw( data, element ) { 67 | element.append( data ); 68 | } 69 | 70 | renderJSON( json ) { 71 | if ( this.clipboardInstance ) { 72 | this.clipboardInstance.destroy(); // Kill existing instance. 73 | } 74 | 75 | let report; 76 | 77 | report = this.$reportItem.clone().addClass( this.SHOW_CLASS ); 78 | 79 | const $reportItemHeading = report.find( this.reportItemHeading ); 80 | const $reportReportTable = report.find( this.reportReportTable ); 81 | const $reportNoticeType = report.find( this.reportNoticeType ); 82 | const $reportNoticeSource = report.find( this.reportNoticeSource ); 83 | $reportItemHeading.text( json.filePath.split( '/themes/' )[1]); 84 | 85 | $.each( 86 | json.messages, ( index, value ) => { 87 | 88 | const line = value.line || 0; 89 | const message = value.message; 90 | const type = value.type; 91 | const source = value.source; 92 | const $singleItem = $reportNoticeType.clone().addClass( type.toLowerCase() ); 93 | const $msgSource = $reportNoticeSource.clone(); 94 | 95 | $singleItem.find( this.reportItemLine ).text( line ); 96 | $singleItem.find( this.reportItemType ).text( type ); 97 | if ( value.source && ! value.source.includes( 'ThemeSniffer' ) ) { 98 | $singleItem.find( this.reportItemMessage ).text( message ); 99 | } else { 100 | let decoded = $( '

' ).html( message ).text(); 101 | let msg = new DOMParser().parseFromString( decoded, 'text/html' ).body.childNodes; 102 | $singleItem.find( this.reportItemMessage ).append( $( msg ) ); 103 | } 104 | 105 | $singleItem.appendTo( $reportReportTable ); 106 | 107 | if ( source ) { 108 | $msgSource.find( this.reportItemSource ) 109 | .text( `// phpcs:ignore ${ source }` ); 110 | 111 | $msgSource.appendTo( $reportReportTable ); 112 | } 113 | } 114 | ); 115 | 116 | $reportNoticeType.remove(); 117 | $reportNoticeSource.remove(); 118 | 119 | // Setup Clipboards. 120 | this.setupClipboards(); 121 | 122 | return report; 123 | } 124 | 125 | setupClipboards() { 126 | let clipboards = document.querySelectorAll( this.reportItemBtn ); 127 | 128 | // Create clipboard instance. 129 | this.clipboardInstance = new Clipboard( clipboards, { 130 | target: trigger => { 131 | return trigger.lastElementChild; 132 | } 133 | }); 134 | 135 | // Clear selection after copy. 136 | this.clipboardInstance.on( 'success', event => { 137 | 138 | // Store current label. 139 | let currentLabel = event.trigger.parentElement.getAttribute( 'aria-label' ); 140 | 141 | // Set copy success message. 142 | event.trigger.parentElement.setAttribute( 'aria-label', themeSnifferLocalization.copySuccess ); 143 | 144 | // Restore label. 145 | $( event.trigger.parentElement ).mouseleave( () => { 146 | event.trigger.parentElement.setAttribute( 'aria-label', currentLabel ); 147 | }); 148 | 149 | // Clear selection text. 150 | event.clearSelection(); 151 | }); 152 | } 153 | 154 | showNotices( message ) { 155 | this.$startNotice.html( message ).addClass( this.SHOW_CLASS ); 156 | this.$checkNotice.removeClass( this.SHOW_CLASS ); 157 | this.$loader.addClass( this.SHOW_CLASS ); 158 | this.$startButton.addClass( this.DISABLED_CLASS ); 159 | this.$stopButton.removeClass( this.DISABLED_CLASS ); 160 | } 161 | 162 | hideNotices( message, showNotice ) { 163 | this.$startNotice.html( message ).addClass( this.SHOW_CLASS ); 164 | this.$loader.removeClass( this.SHOW_CLASS ); 165 | this.$stopButton.addClass( this.DISABLED_CLASS ); 166 | this.$startButton.removeClass( this.DISABLED_CLASS ); 167 | if ( showNotice ) { 168 | this.$checkNotice.addClass( this.SHOW_CLASS ); 169 | } 170 | } 171 | 172 | themeCheckRunPHPCS( theme, warningHide, outputRaw, ignoreAnnotations, minPHPVersion, selectedRulesets, themePrefixes ) { 173 | 174 | const snifferRunData = { 175 | themeName: theme, 176 | hideWarning: warningHide, 177 | rawOutput: outputRaw, 178 | ignoreAnnotations: ignoreAnnotations, 179 | minimumPHPVersion: minPHPVersion, 180 | wpRulesets: selectedRulesets, 181 | themePrefixes: themePrefixes, 182 | action: this.runAction, 183 | nonce: this.nonce 184 | }; 185 | 186 | if ( ! this.ajaxAllow ) { 187 | return false; 188 | } 189 | 190 | return ajax( 191 | { 192 | type: 'POST', 193 | url: ajaxurl, 194 | data: snifferRunData, 195 | beforeSend: ( jqXHR ) => { 196 | this.showNotices( themeSnifferLocalization.checkInProgress ); 197 | if ( ! outputRaw ) { 198 | this.$sniffReport.removeClass( this.IS_RAW_CLASS ); 199 | } 200 | this.$sniffReport.empty(); 201 | this.$snifferInfo.empty(); 202 | this.ajaxRequest.push( jqXHR ); 203 | } 204 | } 205 | ).then( ( response ) => { 206 | if ( response.success === true ) { 207 | this.$startNotice.removeClass( this.SHOW_CLASS ); 208 | 209 | if ( outputRaw ) { 210 | this.hideNotices( themeSnifferLocalization.checkCompleted, true ); 211 | const report = this.$sniffReport.addClass( this.IS_RAW_CLASS ); 212 | this.renderRaw( response.data, report ); 213 | return; 214 | } 215 | 216 | for ( let file of response.files ) { 217 | ( async() => { 218 | this.$sniffReport.append( this.renderJSON( file ) ); 219 | })( file ); 220 | } 221 | 222 | this.hideNotices( themeSnifferLocalization.checkCompleted, true ); 223 | } else { 224 | this.hideNotices( themeSnifferLocalization.errorReport, false ); 225 | this.$snifferInfo.addClass( this.SHOW_CLASS ).addClass( this.ERROR_CLASS ).text( response.data[0].message ); 226 | } 227 | }, ( xhr, textStatus, errorThrown ) => { 228 | throw new Error( `Error: ${errorThrown}: ${xhr} ${textStatus}` ); 229 | } 230 | ); 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /assets/dev/scripts/utils/ajax.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery'; 2 | 3 | export const ajax = ( options, resolve, reject ) => $.ajax( options ) 4 | .done( resolve ) 5 | .fail( reject ); 6 | -------------------------------------------------------------------------------- /assets/dev/scripts/utils/sniff-js.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Sniff a JavaScript file. 3 | * 4 | * @since 1.1.0 5 | * 6 | * @prop {string} path - File's Path relative to WordPress' ABSPATH. 7 | * @prop {object} fileObject - ThemeSniffer file sniff object. 8 | * @prop {object} fileObject.filePath - Absolute path to file to sniff. 9 | * @prop {object} fileObject.errors - Total errors on file to sniff. 10 | * @prop {array} fileObject.messages - ThemeSniffer Error message objects for ouput. 11 | * 12 | * @type {SniffJs} 13 | */ 14 | export class SniffJs { 15 | 16 | /** 17 | * Set object properties and instantiate. 18 | * 19 | * @param {Object} fileObject ThemeSniffer file sniff object. 20 | * 21 | * @since 1.1.0 22 | */ 23 | constructor( fileObject ) { 24 | this.fileObject = fileObject; 25 | // Seems like syntax errors or empty results cause phpcs to provide a non-number for error count. 26 | this.fileObject.errors = isNaN( this.fileObject.errors ) ? 0 : parseInt( this.fileObject.errors, 10 ); 27 | this.path = this.getPath(); 28 | } 29 | 30 | /** 31 | * Get relative path to WordPress' ABSPATH from absolute path. 32 | * 33 | * @since 1.1.0 34 | * 35 | * @return {string} Relative path from ABSPATH. 36 | */ 37 | getPath() { 38 | let jsFile = this.fileObject.filePath.split( /((?:[^/]*\/)*)(.*)\/themes\//gmi ), 39 | fp = jsFile.pop(), // Filepath relative to wp-content/themes/. 40 | wpContent = jsFile.pop(); // Name of wpContent folder. 41 | return `/${ wpContent }/themes/${ fp }`; 42 | } 43 | 44 | /** 45 | * Format Espirma errors for ThemeSniffer consumption. 46 | * 47 | * @param {Object} err An Espirma error. 48 | * 49 | * @since 1.1.0 50 | */ 51 | format( err ) { 52 | this.fileObject.errors++; 53 | this.fileObject.messages.push( 54 | { 55 | line: err.lineNumber, 56 | column: err.column, 57 | message: err.description, 58 | severity: 5, 59 | type: 'ERROR', 60 | fixable: false 61 | } 62 | ); 63 | } 64 | 65 | /** 66 | * Processes the fileObject class property. 67 | * 68 | * This will get the file contents of the file requested for sniff, and 69 | * then will do syntax checks using Espirma. The tolerant mode for 70 | * Espirma is on to allow multiple errors to come through if Esprima can 71 | * continue syntax checking. It's not perfect, but it helps! Loc is on 72 | * so we can include the col/line nums in our reporter, which are passed 73 | * back to the fileObject. 74 | * 75 | * @since 1.1.0 76 | * 77 | * @return {Promise|fileObject} A promise for the fileObject passed in the constructor. 78 | */ 79 | process() { 80 | return new Promise( ( resolve, reject ) => { 81 | fetch( this.path ) 82 | .then( response => response.text() ) 83 | .then( data => { 84 | const errors = esprima.parse( data, 85 | { 86 | tolerant: true, 87 | loc: true 88 | } 89 | ).errors; 90 | 91 | for ( let error of errors ) { 92 | this.format( error ); 93 | } 94 | }) 95 | .catch( error => this.format( error ) ) 96 | .finally( () => this.fileObject.errors && resolve( this.fileObject ) ); 97 | }); 98 | } 99 | } 100 | 101 | export default SniffJs; 102 | -------------------------------------------------------------------------------- /assets/dev/styles/admin.scss: -------------------------------------------------------------------------------- 1 | .wrap { 2 | 3 | &.theme-sniffer { 4 | background-color: $white-color; 5 | margin: 20px 20px 0 0; 6 | padding: 50px; 7 | 8 | h1 { 9 | font-weight: 500; 10 | } 11 | } 12 | } 13 | 14 | .button { 15 | 16 | &.is-disabled { 17 | pointer-events: none; 18 | color: $gray-color; 19 | } 20 | } 21 | 22 | .report-file-item { 23 | margin-top: 10px; 24 | padding-bottom: 10px; 25 | margin-bottom: 20px; 26 | display: none; 27 | 28 | &.is-shown { 29 | display: block; 30 | } 31 | } 32 | 33 | .report-file-heading { 34 | font-weight: 700; 35 | background-color: $darkGray-color; 36 | color: $white-color; 37 | padding: 8px 10px; 38 | border-top-left-radius: 5px; 39 | border-top-right-radius: 5px; 40 | } 41 | 42 | .report-summary { 43 | 44 | tr { 45 | background-color: $dirtyWhite-color; 46 | 47 | &.heading { 48 | background-color: $dark-color; 49 | color: $white-color; 50 | } 51 | 52 | &.field { 53 | background-color: $gray-color; 54 | } 55 | } 56 | 57 | td, 58 | th { 59 | border: 1px $grayWhite-color solid; 60 | padding: 5px; 61 | } 62 | } 63 | 64 | .report-table { 65 | color: $dark-color; 66 | width: 100%; 67 | border-collapse: collapse; 68 | 69 | td { 70 | 71 | &.td-type { 72 | text-transform: uppercase; 73 | width: 75px; 74 | } 75 | } 76 | 77 | tr { 78 | display: block; 79 | padding: 5px 15px; 80 | 81 | td { 82 | padding: 8px; 83 | font-weight: 500; 84 | 85 | &.td-line { 86 | width: 55px; 87 | } 88 | } 89 | 90 | &:last-of-type { 91 | 92 | td { 93 | 94 | &.td-line { 95 | border-bottom-left-radius: 5px; 96 | } 97 | 98 | &.td-message { 99 | border-bottom-right-radius: 5px; 100 | } 101 | } 102 | } 103 | } 104 | 105 | 106 | .item-type { 107 | 108 | &.error { 109 | background-color: $dirtyWhite-color; 110 | 111 | td { 112 | color: $red-color; 113 | } 114 | } 115 | } 116 | } 117 | 118 | .theme-sniffer-info { 119 | display: none; 120 | 121 | &.is-shown { 122 | display: block; 123 | } 124 | } 125 | 126 | .check-done { 127 | display: none; 128 | z-index: 3; 129 | 130 | &.is-shown { 131 | display: block; 132 | } 133 | } 134 | 135 | .start-notice { 136 | display: none; 137 | font-weight: 700; 138 | padding: 20px 0 15px; 139 | 140 | &.is-shown { 141 | display: block; 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /assets/dev/styles/application.scss: -------------------------------------------------------------------------------- 1 | // Utils 2 | @import "utils/colors"; 3 | 4 | // Components 5 | @import "components/admin-screen"; 6 | -------------------------------------------------------------------------------- /assets/dev/styles/components/_admin-screen.scss: -------------------------------------------------------------------------------- 1 | @import "primer-tooltips/index.scss"; 2 | 3 | .theme-sniffer { 4 | background-color: $white-color; 5 | margin: 20px 20px 0 0; 6 | padding: 50px; 7 | 8 | &__title { 9 | font-weight: 500; 10 | } 11 | 12 | &__form { 13 | 14 | &-label { 15 | display: block; 16 | vertical-align: top; 17 | margin-right: 10px; 18 | } 19 | 20 | &-select { 21 | height: 34px !important; 22 | padding: 0; 23 | margin: 0; 24 | margin-right: 10px; 25 | display: inline-block; 26 | vertical-align: top; 27 | border: 1px solid $graywhite-color; 28 | border-radius: 5px; 29 | 30 | &--spaced { 31 | margin-right: 5px; 32 | } 33 | } 34 | 35 | &-input { 36 | height: 34px; 37 | padding: 10px; 38 | margin: 0; 39 | border: 1px solid $graywhite-color; 40 | border-radius: 5px; 41 | min-width: 200px; 42 | } 43 | 44 | &-checkbox { 45 | height: 28px !important; 46 | width: 28px !important; 47 | border: 1px solid $graywhite-color !important; 48 | border-radius: 5px; 49 | margin-right: 10px !important; 50 | margin-bottom: 10px !important; 51 | position: relative; 52 | top: 4px; 53 | 54 | &::before { 55 | font-size: 32px !important; 56 | margin: 0.2125rem 0 0 0.05rem !important; 57 | } 58 | } 59 | 60 | &-button { 61 | padding: 8px 18px; 62 | display: inline-block; 63 | vertical-align: top; 64 | text-transform: uppercase; 65 | color: $white-color; 66 | border-radius: 5px; 67 | transition: background-color 300ms ease-in-out; 68 | cursor: pointer; 69 | margin-right: 5px; 70 | 71 | &--primary { 72 | background-color: $green-color; 73 | 74 | &:hover { 75 | background-color: darken($green-color, 20%); 76 | } 77 | } 78 | 79 | &--secondary { 80 | background-color: $red-color; 81 | 82 | &:hover { 83 | background-color: darken($red-color, 20%); 84 | } 85 | } 86 | 87 | &:focus, 88 | &:active { 89 | border-color: $blue-color; 90 | box-shadow: 0 0 2px rgba($darkblue-color, 0.8); 91 | outline: 0; 92 | } 93 | 94 | &.is-disabled { 95 | pointer-events: none; 96 | color: $gray-color; 97 | } 98 | } 99 | 100 | &-description { 101 | margin-top: 10px; 102 | } 103 | } 104 | 105 | &__start-notice { 106 | display: none; 107 | font-weight: 700; 108 | padding: 20px 0 15px; 109 | 110 | &.is-shown { 111 | display: block; 112 | } 113 | } 114 | 115 | &__report { 116 | $report: &; 117 | 118 | &-item { 119 | margin-top: 10px; 120 | padding-bottom: 10px; 121 | margin-bottom: 20px; 122 | display: none; 123 | 124 | &.is-shown { 125 | display: block; 126 | } 127 | } 128 | 129 | &-heading { 130 | font-weight: 700; 131 | background-color: $darkgray-color; 132 | color: $white-color; 133 | padding: 8px 10px; 134 | border-top-left-radius: 5px; 135 | border-top-right-radius: 5px; 136 | } 137 | 138 | &-table { 139 | color: $dark-color; 140 | width: 100%; 141 | border-collapse: collapse; 142 | } 143 | 144 | &-table-row { 145 | display: block; 146 | padding: 10px 23px 0 23px; 147 | background-color: $dirtywhite-color; 148 | position: relative; 149 | 150 | &.js-report-notice-source { 151 | display: grid; 152 | grid-template-columns: 125px auto; // result of calc(55px + 75px + 2px - 7px) table-line + table-type + 2px table border - 7px button padding. 153 | padding-top: 4px; 154 | padding-bottom: 11px; 155 | 156 | &::after { 157 | content: ""; 158 | background-color: $graywhite-color; 159 | width: 90%; 160 | height: 1px; 161 | position: absolute; 162 | bottom: 0; 163 | left: 5%; 164 | } 165 | } 166 | 167 | &.error { 168 | 169 | td { 170 | color: $red-color; 171 | } 172 | } 173 | 174 | &:last-of-type { 175 | border-bottom-left-radius: 5px; 176 | border-bottom-right-radius: 5px; 177 | padding-bottom: 11px; 178 | 179 | &::after { 180 | display: none; 181 | } 182 | } 183 | 184 | &.heading { 185 | background-color: $dark-color; 186 | color: $white-color; 187 | } 188 | 189 | &.field { 190 | background-color: $gray-color; 191 | } 192 | } 193 | 194 | &-table-line { 195 | font-weight: 500; 196 | width: 55px; 197 | } 198 | 199 | &-table-type { 200 | text-transform: uppercase; 201 | width: 75px; 202 | } 203 | 204 | &-table-message { 205 | line-height: 1.6; 206 | } 207 | 208 | &.is-raw { 209 | white-space: pre-wrap; 210 | } 211 | 212 | &-copy-annotation { 213 | 214 | &-source { 215 | color: #5c5c5c; 216 | font-size: 0.9em; 217 | font-style: italic; 218 | font-weight: 400; 219 | } 220 | 221 | &-btn { 222 | background: transparent; 223 | border: none; 224 | cursor: copy; // Set cursor to copy for cross-OS 225 | display: inline-flex; 226 | padding: 7px; 227 | width: 100%; 228 | 229 | .dashicons-clipboard { 230 | width: 16px; 231 | height: 16px; 232 | font-size: 16px; 233 | padding-right: 7px; 234 | } 235 | } 236 | } 237 | } 238 | 239 | &__info { 240 | display: none; 241 | 242 | &.is-shown { 243 | display: block; 244 | } 245 | 246 | &.is-error { 247 | color: $red-color; 248 | } 249 | } 250 | 251 | &__check-done-notice { 252 | display: none; 253 | position: absolute; 254 | z-index: 3; 255 | 256 | &.is-shown { 257 | display: block; 258 | } 259 | } 260 | // Loader taken from: http://tobiasahlin.com/spinkit/ 261 | &__loader { 262 | 263 | &.is-shown { 264 | display: block; 265 | } 266 | 267 | display: none; 268 | width: 40px; 269 | height: 40px; 270 | background-color: $blue-color; 271 | 272 | margin: 100px auto; 273 | -webkit-animation: flip-spinner 1.2s infinite ease-in-out; 274 | animation: flip-spinner 1.2s infinite ease-in-out; 275 | } 276 | } 277 | 278 | @-webkit-keyframes flip-spinner { 279 | 280 | 0% { 281 | -webkit-transform: perspective(120px); 282 | } 283 | 284 | 50% { 285 | -webkit-transform: perspective(120px) rotateY(180deg); 286 | } 287 | 288 | 100% { 289 | -webkit-transform: perspective(120px) rotateY(180deg) rotateX(180deg); 290 | } 291 | } 292 | 293 | @keyframes flip-spinner { 294 | 295 | 0% { 296 | transform: perspective(120px) rotateX(0deg) rotateY(0deg); 297 | -webkit-transform: perspective(120px) rotateX(0deg) rotateY(0deg); 298 | } 299 | 300 | 50% { 301 | transform: perspective(120px) rotateX(-180.1deg) rotateY(0deg); 302 | -webkit-transform: perspective(120px) rotateX(-180.1deg) rotateY(0deg); 303 | } 304 | 305 | 100% { 306 | transform: perspective(120px) rotateX(-180deg) rotateY(-179.9deg); 307 | -webkit-transform: perspective(120px) rotateX(-180deg) rotateY(-179.9deg); 308 | } 309 | } 310 | -------------------------------------------------------------------------------- /assets/dev/styles/utils/_colors.scss: -------------------------------------------------------------------------------- 1 | $white-color: #fff; 2 | $dirtywhite-color: #f3f3f3; 3 | $graywhite-color: #ddd; 4 | $gray-color: #ccc; 5 | $darkgray-color: #737373; 6 | $dark-color: #444; 7 | $red-color: #ef5d5d; 8 | $green-color: #58bf66; 9 | $blue-color: #5b9dd9; 10 | $darkblue-color: #1e8cbe; 11 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wptrt/theme-sniffer", 3 | "type": "wordpress-plugin", 4 | "keywords": ["plugin", "phpcs", "standards", "WordPress"], 5 | "description": "Theme Sniffer plugin which uses PHP_CodeSniffer for automatic theme checking.", 6 | "license": "MIT", 7 | "authors": [{ 8 | "name" : "Contributors", 9 | "homepage": "https://github.com/WPTRT/theme-sniffer/graphs/contributors" 10 | }, { 11 | "name": "Denis Žoljom", 12 | "homepage": "https://github.com/dingo-d", 13 | "role": "Lead developer" 14 | }], 15 | "require": { 16 | "php": ">=7.0", 17 | "dealerdirect/phpcodesniffer-composer-installer": "^0.5.0", 18 | "michelf/php-markdown": "^1.8", 19 | "phpcompatibility/php-compatibility": "^9.0", 20 | "jakub-onderka/php-parallel-lint": "^1.0", 21 | "php-di/php-di": "^6.0", 22 | "squizlabs/php_codesniffer": "^3.3.0", 23 | "wptrt/wpthemereview": "^0.2.0" 24 | }, 25 | "require-dev": { 26 | "roave/security-advisories" : "dev-master" 27 | }, 28 | "autoload": { 29 | "classmap": [ 30 | "src/", 31 | "views/" 32 | ] 33 | }, 34 | "scripts": { 35 | "checkcs": "@php ./vendor/squizlabs/php_codesniffer/bin/phpcs", 36 | "fixcs": "@php ./vendor/squizlabs/php_codesniffer/bin/phpcbf", 37 | "lint": "@php ./vendor/bin/parallel-lint --exclude .git --exclude vendor ." 38 | }, 39 | "config": { 40 | "sort-packages": true, 41 | "optimize-autoloader": true 42 | }, 43 | "support": { 44 | "issues": "https://github.com/WPTRT/theme-sniffer/issues", 45 | "source": "https://github.com/WPTRT/theme-sniffer" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "theme-sniffer", 3 | "version": "1.1.2", 4 | "description": "Theme Sniffer plugin using sniffs. `WordPress-Theme` standard is used from WPTRT/WordPress-Coding-Standards.", 5 | "main": "", 6 | "dependencies": { 7 | "@babel/polyfill": "^7.6.0", 8 | "autoprefixer": "^9.6.1", 9 | "clipboard": "^2.0.4", 10 | "cssnano": "^4.1.10", 11 | "jquery": "^3.5.0", 12 | "primer-tooltips": "^2.0.0", 13 | "wp-license-compatibility": "^1.0.0" 14 | }, 15 | "devDependencies": { 16 | "@babel/core": "^7.6.0", 17 | "@babel/preset-env": "^7.6.0", 18 | "archiver": "^3.1.1", 19 | "babel-eslint": "^10.0.3", 20 | "babel-loader": "^8.0.6", 21 | "clean-webpack-plugin": "^3.0.0", 22 | "create-file-webpack": "^1.0.2", 23 | "cross-env": "^6.0.0", 24 | "css-loader": "^3.2.0", 25 | "eslint": "^6.4.0", 26 | "eslint-cli": "^1.1.1", 27 | "eslint-config-wordpress": "^2.0.0", 28 | "filemanager-webpack-plugin": "^2.0.5", 29 | "fs-extra": "^8.1.0", 30 | "husky": "^3.0.5", 31 | "mini-css-extract-plugin": "^0.8.0", 32 | "node-sass": "^4.14.1", 33 | "rimraf": "^3.0.0", 34 | "sass-loader": "^8.0.0", 35 | "style-loader": "^1.0.0", 36 | "stylelint": "^11.0.0", 37 | "stylelint-config-wordpress": "^14.0.0", 38 | "terser-webpack-plugin": "^2.1.0", 39 | "webpack": "^4.40.2", 40 | "webpack-cli": "^3.3.9", 41 | "webpack-manifest-plugin": "^2.0.4" 42 | }, 43 | "scripts": { 44 | "start": "cross-env NODE_ENV=development && webpack --progress --watch --display-error-details --display-modules --display-reasons --mode development", 45 | "build": "composer dump-autoload && cross-env NODE_ENV=production webpack -p --progress --mode production", 46 | "dev": "composer dump-autoload && cross-env NODE_ENV=development webpack -p --progress --mode development", 47 | "precommit": "eslint assets/dev/scripts/*.js --fix && stylelint \"assets/dev/styles/**/*.scss\" --syntax scss && ./vendor/bin/phpcs --ignore=*/vendor/*,*/node_modules/* ." 48 | }, 49 | "repository": { 50 | "type": "git", 51 | "url": "git+https://github.com/WPTRT/theme-sniffer.git" 52 | }, 53 | "author": "WordPress Theme Review Team", 54 | "contributors": [ 55 | { 56 | "name": "Denis Žoljom" 57 | }, 58 | { 59 | "name": "Nilambar Sharma" 60 | }, 61 | { 62 | "name": "Ulrich Pogson" 63 | }, 64 | { 65 | "name": "Tim Elsass" 66 | } 67 | ], 68 | "license": "GPL-2.0", 69 | "bugs": { 70 | "url": "https://github.com/WPTRT/theme-sniffer/issues" 71 | }, 72 | "homepage": "https://github.com/WPTRT/theme-sniffer#readme", 73 | "browserslist": [ 74 | "android >= 4.2", 75 | "last 2 versions", 76 | "Safari >= 8", 77 | "not ie < 11" 78 | ], 79 | "husky": { 80 | "hooks": { 81 | "pre-commit": "npm run precommit" 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /phpcs.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | Coding standard for the WordPress Theme Sniffer plugin. 4 | 5 | 6 | . 7 | 8 | 9 | */node_modules/* 10 | */vendor/* 11 | */bin/* 12 | */assets/* 13 | /src/CompiledContainer.php 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /readme.txt: -------------------------------------------------------------------------------- 1 | === Theme Sniffer === 2 | Contributors: dingo_d, rabmalin, grapplerulrich, timph, vyskoczilova, abdullahramzan, williampatton, passoniate 3 | Tags: check, checker, coding standards, theme, tool 4 | Requires at least: 5.0 5 | Tested up to: 5.3.2 6 | Requires PHP: 7.0 7 | Stable tag: 1.1.2 8 | License: MIT 9 | License URI: https://opensource.org/licenses/MIT 10 | 11 | Theme Sniffer will help you analyze your theme code, ensuring the PHP and WordPress coding standards compatibility. 12 | 13 | == Description == 14 | 15 | Theme Sniffer is a plugin utilizing custom sniffs for [PHP_CodeSniffer](https://github.com/squizlabs/PHP_CodeSniffer) that statically analyzes your theme and ensures that it adheres to WordPress coding conventions, as well as checking your code against PHP version compatibility. 16 | 17 | == Installation == 18 | 19 | = Installing from WordPress repository: = 20 | 21 | 1. From the dashboard of your site, navigate to Plugins –> Add New. 22 | 2. In the Search type Theme Sniffer 23 | 3. Click Install Now 24 | 4. When it’s finished, activate the plugin via the prompt. A message will show confirming activation was successful. 25 | 26 | Make sure that your server has php version greater or equal to 7.0, otherwise, the plugin won't activate. 27 | 28 | = Uploading the .zip file: = 29 | 30 | 1. From the dashboard of your site, navigate to Plugins –> Add New. 31 | 2. Select the Upload option and hit "Choose File." 32 | 3. When the pop-up appears select the theme-sniffer.x.x.zip file from your desktop. (The ‘x.x’ will change depending on the current version number). 33 | 4. Follow the on-screen instructions and wait as the upload completes. 34 | When it’s finished, activate the plugin via the prompt. A message will show confirming activation was successful. 35 | 36 | == Screenshots == 37 | 38 | 1. Theme Sniffer main screen 39 | 40 | == Frequently Asked Questions == 41 | 42 | = How to use the plugin? = 43 | 44 | * Go to `Theme Sniffer` menu 45 | * Select theme from the drop-down 46 | * Select the desired options 47 | * Click `GO` to start the Theme Sniffer 48 | 49 | = What options are there? = 50 | 51 | * `Select Standard` - Select the standard with which you would like to sniff the theme 52 | * `Hide Warning` - Enable this to hide warnings 53 | * `Raw Output` - Enable this to display sniff report in plain text format. Suitable to copy/paste report to trac ticket 54 | * `Ignore annotations` - Ignores any comment annotation that might disable the sniff 55 | * `Check only PHP files` - Only checks PHP files, not CSS and JS - use this to prevent any possible memory leaks 56 | * `Minimum PHP version` - Select the minimum PHP Version to check if your theme will work with that version 57 | 58 | = How can I help with the development of the plugin? = 59 | 60 | Go to the official repo on Github (https://github.com/WPTRT/theme-sniffer), fork the plugin, read the readme and go through the issues. Any kind of help is appreciated. Either manually testing or writing code is invaluable for the open source project such as this. 61 | 62 | = Contributors and testers thanks = 63 | 64 | Thanks to Danny Cooper, Liton Arefin and metallicarosetail (slack) for testing the plugin and finding bugs in the development stage. 65 | 66 | Thanks to Abdullah Ramzan and Arslan Ahmed for fixing minor typos, William Patton for help with the required files checks. Thanks to Karolína Vyskočilová for finding out the issue with cross-env issue. 67 | 68 | Thanks to the TRT for the support. 69 | 70 | 71 | == Upgrade Notice == 72 | 73 | The latest upgrade mostly with development changes and some minor improvements in sniff handling. 74 | 75 | == Changelog == 76 | 77 | = 1.1.2 = 78 | * Added a way to remove node_modules, vendor, and test folders in the theme prior to sniffing 79 | * This fixes out of memory issues when large files are found 80 | * Fixed minor styling issue 81 | * Removed option to check JS files 82 | 83 | = 1.1.1 = 84 | * Fixed bug in the screenshot ratio calculation 85 | 86 | = 1.1.0 = 87 | * Added sniff codes that can be copied for easier whitelisting of the false issues 88 | * Added readme validator 89 | * Added Screenshot validator 90 | * Added required files checks 91 | * Added checks for core minimum PHP version 92 | * Added a license validator 93 | * Updated WPThemeReview coding standards to the 0.2.0 version 94 | * Moved JS checking to esprima 95 | * Moved installation error to admin notice 96 | * Validation improvements 97 | * Fixed annotation issue - the ingore annotation checkbox worked counter to what it should 98 | * Fixed cross-env issue for development on Windows machines 99 | * Minor fixes 100 | 101 | = 1.0.0 = 102 | * Added the WPThemeReview standard 103 | * Added the theme prefix checks 104 | * Added `Check only PHP files`option 105 | * Added additional functionality 106 | * Updated the PHPCS version to the latest one, as well as WPCS version 107 | * Refactored the code structure to more modern workflow 108 | * Theme tags are pulled from the API 109 | 110 | = 0.1.5 = 111 | * Change the development process 112 | * Modern JS development workflow 113 | 114 | = 0.1.4 = 115 | * Using REST instead of admin-ajax for checks 116 | * Code optimization 117 | 118 | = 0.1.3 = 119 | * Update zip link in the readme file 120 | 121 | = 0.1.2 = 122 | * Add option to display report in HTML or raw format 123 | * Update to latest sniffs 124 | 125 | = 0.1.1 = 126 | * Fix sniffer issues in admin files 127 | 128 | = 0.1.0 = 129 | * Initial pre-release 130 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WPTT/theme-sniffer/ce1fcf16c9c7ce80b65fcbc6790c6d49553193b9/screenshot.png -------------------------------------------------------------------------------- /src/CompiledContainer.php: -------------------------------------------------------------------------------- 1 | 'get5d873921285f3695255794', 8 | 'Theme_Sniffer\\Callback\\Run_Sniffer_Callback' => 'get5d8739212b488781548526', 9 | 'Theme_Sniffer\\Enqueue\\Enqueue_Resources' => 'get5d8739212b6ef442585473', 10 | 'Theme_Sniffer\\i18n\\Internationalization' => 'get5d8739212b7c5480981663', 11 | ); 12 | 13 | protected function get5d873921285f3695255794() 14 | { 15 | $object = new Theme_Sniffer\Admin_Menus\Sniff_Page(); 16 | return $object; 17 | } 18 | 19 | protected function get5d8739212b488781548526() 20 | { 21 | $object = new Theme_Sniffer\Callback\Run_Sniffer_Callback(); 22 | return $object; 23 | } 24 | 25 | protected function get5d8739212b6ef442585473() 26 | { 27 | $object = new Theme_Sniffer\Enqueue\Enqueue_Resources(); 28 | return $object; 29 | } 30 | 31 | protected function get5d8739212b7c5480981663() 32 | { 33 | $object = new Theme_Sniffer\i18n\Internationalization(); 34 | return $object; 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /src/admin-menus/class-base-admin-menu.php: -------------------------------------------------------------------------------- 1 | get_title(), 33 | $this->get_menu_title(), 34 | $this->get_capability(), 35 | $this->get_menu_slug(), 36 | array( $this, 'process_admin_menu' ), 37 | $this->get_icon(), 38 | $this->get_position() 39 | ); 40 | } 41 | ); 42 | } 43 | 44 | /** 45 | * Process the admin menu attributes and prepare rendering. 46 | * 47 | * The echo doesn't need to be escaped since it's escaped 48 | * in the render method. 49 | * 50 | * @param array|string $atts Attributes as passed to the admin menu. 51 | * 52 | * @return void The rendered content needs to be echoed. 53 | */ 54 | public function process_admin_menu( $atts ) { 55 | $atts = $this->process_attributes( $atts ); 56 | $atts['admin_menu_id'] = $this->get_menu_slug(); 57 | $atts['nonce_field'] = $this->render_nonce(); 58 | 59 | echo $this->render( (array) $atts ); // phpcs:ignore 60 | } 61 | 62 | /** 63 | * Render the current Renderable. 64 | * 65 | * @param array $context Context in which to render. 66 | * 67 | * @return string Rendered HTML. 68 | */ 69 | public function render( array $context = array() ) : string { 70 | try { 71 | $view = new Escaped_View( 72 | new Templated_View( $this->get_view_uri() ) 73 | ); 74 | 75 | return $view->render( $context ); 76 | } catch ( \Exception $exception ) { 77 | // Don't let exceptions bubble up. Just render the exception message into the admin menu. 78 | return sprintf( 79 | '

%s
', 80 | $exception->getMessage() 81 | ); 82 | } 83 | } 84 | 85 | /** 86 | * Render the nonce. 87 | * 88 | * @return string Hidden field with a nonce. 89 | */ 90 | protected function render_nonce() : string { 91 | ob_start(); 92 | 93 | wp_nonce_field( 94 | $this->get_nonce_action(), 95 | $this->get_nonce_name() 96 | ); 97 | 98 | return ob_get_clean(); 99 | } 100 | 101 | /** 102 | * Verify the nonce and return the result. 103 | * 104 | * @return bool Whether the nonce could be successfully verified. 105 | */ 106 | protected function verify_nonce() : bool { 107 | $nonce_name = $this->get_nonce_name(); 108 | 109 | if ( ! isset( $_POST[ $nonce_name ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.NoNonceVerification 110 | return false; 111 | } 112 | 113 | $nonce = sanitize_text_field( wp_unslash( $_POST[ $nonce_name ] ) ); // phpcs:ignore WordPress.Security.NonceVerification.NoNonceVerification 114 | 115 | $result = wp_verify_nonce( 116 | $nonce, 117 | $this->get_nonce_action() 118 | ); 119 | 120 | return false !== $result; 121 | } 122 | 123 | /** 124 | * Get the action of the nonce to use. 125 | * 126 | * @return string Action of the nonce. 127 | */ 128 | protected function get_nonce_action() : string { 129 | return "{$this->get_menu_slug()}_action"; 130 | } 131 | 132 | /** 133 | * Get the name of the nonce to use. 134 | * 135 | * @return string Name of the nonce. 136 | */ 137 | protected function get_nonce_name() : string { 138 | return "{$this->get_menu_slug()}_nonce"; 139 | } 140 | 141 | /** 142 | * Get the title to use for the admin page. 143 | * 144 | * @return string The text to be displayed in the title tags of the page when the menu is selected. 145 | */ 146 | abstract protected function get_title() : string; 147 | 148 | /** 149 | * Get the menu title to use for the admin menu. 150 | * 151 | * @return string The text to be used for the menu. 152 | */ 153 | abstract protected function get_menu_title() : string; 154 | 155 | /** 156 | * Get the capability required for this menu to be displayed. 157 | * 158 | * @return string The capability required for this menu to be displayed to the user. 159 | */ 160 | abstract protected function get_capability() : string; 161 | 162 | /** 163 | * Get the menu slug. 164 | * 165 | * @return string The slug name to refer to this menu by. Should be unique for this menu page and only include lowercase alphanumeric, dashes, and underscores characters to be compatible with sanitize_key(). 166 | */ 167 | abstract protected function get_menu_slug() : string; 168 | 169 | /** 170 | * Get the URL to the icon to be used for this menu 171 | * 172 | * @return string The URL to the icon to be used for this menu. 173 | * * Pass a base64-encoded SVG using a data URI, which will be colored to match 174 | * the color scheme. This should begin with 'data:image/svg+xml;base64,'. 175 | * * Pass the name of a Dashicons helper class to use a font icon, 176 | * e.g. 'dashicons-chart-pie'. 177 | * * Pass 'none' to leave div.wp-menu-image empty so an icon can be added via CSS. 178 | */ 179 | protected function get_icon() : string { 180 | return 'none'; 181 | } 182 | 183 | /** 184 | * Get the position of the menu. 185 | * 186 | * @return int Number that indicates the position of the menu. 187 | * 5 - below Posts 188 | * 10 - below Media 189 | * 15 - below Links 190 | * 20 - below Pages 191 | * 25 - below comments 192 | * 60 - below first separator 193 | * 65 - below Plugins 194 | * 70 - below Users 195 | * 75 - below Tools 196 | * 80 - below Settings 197 | * 100 - below second separator 198 | */ 199 | protected function get_position() : int { 200 | return 100; 201 | } 202 | 203 | /** 204 | * Get the View URI to use for rendering the admin menu. 205 | * 206 | * @return string View URI. 207 | */ 208 | abstract protected function get_view_uri() : string; 209 | 210 | /** 211 | * Process the admin menu attributes. 212 | * 213 | * @param array|string $atts Raw admin menu attributes passed into the 214 | * admin menu function. 215 | * 216 | * @return array Processed admin menu attributes. 217 | */ 218 | abstract protected function process_attributes( $atts ) : array; 219 | } 220 | -------------------------------------------------------------------------------- /src/admin-menus/class-sniff-page.php: -------------------------------------------------------------------------------- 1 | get_active_themes(); 98 | } catch ( No_Themes_Present $e ) { 99 | $atts['error'] = esc_html( $e->getMessage() ); 100 | } 101 | 102 | $atts['standards'] = $this->get_wpcs_standards(); 103 | $atts['php_versions'] = $this->get_php_versions(); 104 | $atts['minimum_php_version'] = $this->get_minimum_php_version(); 105 | $atts['current_theme'] = \get_stylesheet(); 106 | $atts['standard_status'] = wp_list_pluck( $this->get_wpcs_standards(), 'default' ); 107 | 108 | return $atts; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/callback/class-base-ajax-callback.php: -------------------------------------------------------------------------------- 1 | get_action_name(); 29 | 30 | add_action( "wp_ajax_{$callback_name}", array( $this, 'callback' ) ); 31 | 32 | // Add nopriv action. 33 | if ( $this->is_public() ) { 34 | add_action( "wp_ajax_nopriv_{$callback_name}", array( $this, 'callback' ) ); 35 | } 36 | } 37 | 38 | /** 39 | * Callback method for the specific class. 40 | */ 41 | abstract public function callback(); 42 | 43 | /** 44 | * Returns true if the callback should be public 45 | * 46 | * @return boolean true if callback is public. 47 | */ 48 | abstract protected function is_public() : bool; 49 | 50 | /** 51 | * Get name of the callback action 52 | * 53 | * @return string Name of the callback action. 54 | */ 55 | abstract protected function get_action_name() : string; 56 | } 57 | -------------------------------------------------------------------------------- /src/callback/class-run-sniffer-callback.php: -------------------------------------------------------------------------------- 1 | get_wpcs_standards(); 352 | 353 | $selected_standards = array_map( 354 | 'sanitize_text_field', 355 | wp_unslash( $_POST[ self::WP_RULESETS ] ) 356 | ); 357 | 358 | $standards_array = array_map( 359 | function( $standard ) use ( $standards ) { 360 | if ( ! empty( $standards[ $standard ] ) ) { 361 | return $standards[ $standard ]['label']; 362 | } 363 | }, 364 | $selected_standards 365 | ); 366 | 367 | $args = array(); 368 | 369 | // Current theme text domain. 370 | $args[ self::TEXT_DOMAINS ] = array( self::$theme_slug ); 371 | 372 | $all_files = array( 'php' ); 373 | 374 | $theme = wp_get_theme( self::$theme_slug ); 375 | $all_files = $theme->get_files( $all_files, -1, false ); 376 | 377 | // Disallowed folders. 378 | $re = '/(\/node_modules)|(\/vendor)|(\/test)|(\/tests)/'; 379 | 380 | $files_to_sniff = array(); 381 | 382 | foreach ( $all_files as $file_name => $file_path ) { 383 | 384 | // Check for Frameworks. 385 | $allowed_frameworks = array( 386 | 'kirki' => 'kirki.php', 387 | 'hybrid-core' => 'hybrid.php', 388 | ); 389 | 390 | foreach ( $allowed_frameworks as $framework_textdomain => $identifier ) { 391 | if ( strrpos( $file_name, $identifier ) !== false && ! in_array( $framework_textdomain, $args[ self::TEXT_DOMAINS ], true ) ) { 392 | $args[ self::TEXT_DOMAINS ][] = $framework_textdomain; 393 | } 394 | } 395 | 396 | // Check if files are in disallowed folders (defined by the regex) and remove them. 397 | preg_match_all( $re, $file_path, $matches, PREG_SET_ORDER, 0 ); 398 | 399 | if ( empty( $matches ) ) { 400 | $files_to_sniff[ $file_name ] = $file_path; 401 | } 402 | } 403 | 404 | // Safeguard, but don't seems to be working correctly. 405 | $ignored = '.*/node_modules/.*,.*/vendor/.*,.*/assets/build/.*,.*/build/.*,.*/bin/.*,.*/tests/.*,.*/test/.*'; 406 | 407 | $results_arguments = array( 408 | 'extensions' => 'php', 409 | 'show_warnings' => $show_warnings, 410 | 'minimum_php_version' => $minimum_php_version, 411 | 'args' => $args, 412 | 'theme_prefixes' => $theme_prefixes, 413 | 'all_files' => $files_to_sniff, 414 | 'standards_array' => $standards_array, 415 | 'ignore_annotations' => $ignore_annotations, 416 | 'ignored' => $ignored, 417 | 'raw_output' => $raw_output, 418 | ); 419 | 420 | $sniff_results = $this->get_sniff_results( $results_arguments ); 421 | 422 | if ( $raw_output ) { 423 | $results_raw = array( 424 | self::SUCCESS => true, 425 | self::DATA => $sniff_results, 426 | ); 427 | 428 | \wp_send_json( $results_raw, 200 ); 429 | } 430 | 431 | $sniffer_results = json_decode( $sniff_results, true ); 432 | 433 | $total_errors = $sniffer_results[ self::TOTALS ][ self::ERRORS ]; 434 | $total_warning = $sniffer_results[ self::TOTALS ][ self::WARNINGS ]; 435 | $total_fixable = $sniffer_results[ self::TOTALS ][ self::FIXABLE ]; 436 | $total_files = $sniffer_results[ self::FILES ]; 437 | 438 | // Check theme headers. 439 | $theme_header_checks = $this->style_headers_check( self::$theme_slug, $theme, $show_warnings ); 440 | $screenshot_checks = $this->screenshot_check(); 441 | $readme_checks = $this->readme_check(); 442 | 443 | foreach ( $screenshot_checks as $file ) { 444 | $total_errors += $file[ self::ERRORS ]; 445 | $total_warning += $file[ self::WARNINGS ]; 446 | } 447 | 448 | $total_files += $screenshot_checks; 449 | 450 | foreach ( $readme_checks as $file ) { 451 | $total_errors += $file[ self::ERRORS ]; 452 | $total_warning += $file[ self::WARNINGS ]; 453 | } 454 | 455 | $total_files += $readme_checks; 456 | 457 | $total_errors += $theme_header_checks[ self::TOTALS ][ self::ERRORS ]; 458 | $total_warning += $theme_header_checks[ self::TOTALS ][ self::WARNINGS ]; 459 | $total_fixable += $theme_header_checks[ self::TOTALS ][ self::FIXABLE ]; 460 | $total_files += $theme_header_checks[ self::FILES ]; 461 | 462 | // Filtering the files for easier JS handling. 463 | $file_i = 0; 464 | $files = array(); 465 | 466 | foreach ( $total_files as $file_path => $file_sniff_results ) { 467 | 468 | // Allow the file list to pass any .js through for further handling, and remove all others with no errors or warnings. 469 | if ( substr( $file_path, -3 ) !== '.js' && ( $file_sniff_results[ self::ERRORS ] === 0 && $file_sniff_results[ self::WARNINGS ] === 0 ) ) { 470 | continue; 471 | } 472 | 473 | $files[ $file_i ][ self::FILE_PATH ] = $file_path; 474 | $files[ $file_i ][ self::ERRORS ] = $file_sniff_results[ self::ERRORS ]; 475 | $files[ $file_i ][ self::WARNINGS ] = $file_sniff_results[ self::WARNINGS ]; 476 | $files[ $file_i ][ self::MESSAGES ] = $file_sniff_results[ self::MESSAGES ]; 477 | $file_i++; 478 | } 479 | 480 | $results = array( 481 | self::SUCCESS => true, 482 | self::TOTALS => array( 483 | self::ERRORS => $total_errors, 484 | self::WARNINGS => $total_warning, 485 | self::FIXABLE => $total_fixable, 486 | ), 487 | self::FILES => $files, 488 | ); 489 | 490 | \wp_send_json( $results, 200 ); 491 | } 492 | 493 | /** 494 | * Method that returns the results based on a custom PHPCS Runner 495 | * 496 | * @param array ...$arguments Array of passed arguments. 497 | * @return string Sniff results string. 498 | * 499 | * @throws \PHP_CodeSniffer\Exceptions\DeepExitException Sniffer exception. 500 | */ 501 | protected function get_sniff_results( ...$arguments ) { 502 | // Unpack the arguments. 503 | $show_warnings = $arguments[0]['show_warnings']; 504 | $minimum_php_version = $arguments[0]['minimum_php_version']; 505 | $args = $arguments[0]['args']; 506 | $theme_prefixes = $arguments[0]['theme_prefixes']; 507 | $extensions = $arguments[0]['extensions']; 508 | $all_files = $arguments[0]['all_files']; 509 | $standards_array = $arguments[0]['standards_array']; 510 | $ignore_annotations = $arguments[0]['ignore_annotations']; 511 | $ignored = $arguments[0]['ignored']; 512 | $raw_output = $arguments[0]['raw_output']; 513 | 514 | // Create a custom runner. 515 | $runner = new Runner(); 516 | 517 | $config_args = array( '-s', '-p' ); 518 | 519 | if ( $show_warnings === '0' ) { 520 | $config_args[] = '-n'; 521 | } 522 | 523 | $runner->config = new Config( $config_args ); 524 | 525 | $all_files = array_values( $all_files ); 526 | 527 | if ( $extensions ) { 528 | $runner->config->extensions = array( 529 | 'php' => 'PHP', 530 | 'inc' => 'PHP', 531 | ); 532 | } 533 | 534 | $runner->config->standards = $standards_array; 535 | $runner->config->files = $all_files; 536 | $runner->config->annotations = $ignore_annotations; 537 | $runner->config->parallel = 8; 538 | $runner->config->colors = false; 539 | $runner->config->tabWidth = 0; 540 | $runner->config->reportWidth = 110; 541 | $runner->config->interactive = false; 542 | $runner->config->cache = false; 543 | $runner->config->ignored = $ignored; 544 | 545 | if ( ! $raw_output ) { 546 | $runner->config->reports = array( 'json' => null ); 547 | } 548 | 549 | $runner->init(); 550 | 551 | // Set default standard. 552 | PHPCSHelper::set_config_data( self::DEFAULT_STANDARD, $this->get_default_standard(), true ); 553 | 554 | // Ignoring warnings when generating the exit code. 555 | PHPCSHelper::set_config_data( self::IGNORE_WARNINGS_ON_EXIT, true, true ); 556 | 557 | // Set minimum supported PHP version. 558 | PHPCSHelper::set_config_data( self::TEST_VERSION, $minimum_php_version . '-', true ); 559 | 560 | // Set text domains. 561 | PHPCSHelper::set_config_data( self::TEXT_DOMAIN, implode( ',', $args[ self::TEXT_DOMAINS ] ), true ); 562 | 563 | if ( $theme_prefixes !== '' ) { 564 | // Set prefix. 565 | PHPCSHelper::set_config_data( self::PREFIXES, $theme_prefixes, true ); 566 | } 567 | 568 | $runner->reporter = new Reporter( $runner->config ); 569 | 570 | foreach ( $all_files as $file_path ) { 571 | $file = new DummyFile( file_get_contents( $file_path ), $runner->ruleset, $runner->config ); 572 | $file->path = $file_path; 573 | 574 | $runner->processFile( $file ); 575 | } 576 | 577 | ob_start(); 578 | $runner->reporter->printReports(); 579 | $report = ob_get_clean(); 580 | 581 | return $report; 582 | } 583 | 584 | /** 585 | * Perform style.css header check. 586 | * 587 | * @since 0.3.0 588 | * 589 | * @param string $theme_slug Theme slug. 590 | * @param \WP_Theme $theme WP_Theme Theme object. 591 | * @param bool $show_warnings Show warnings. 592 | * 593 | * @return bool 594 | */ 595 | protected function style_headers_check( $theme_slug, \WP_Theme $theme, $show_warnings ) { 596 | $required_headers = $this->get_required_headers(); 597 | 598 | $notices = array(); 599 | 600 | foreach ( $required_headers as $header ) { 601 | if ( $theme->get( $header ) ) { 602 | continue; 603 | } 604 | 605 | $notices[] = array( 606 | self::MESSAGE => sprintf( 607 | /* translators: 1: comment header line */ 608 | esc_html__( 'The %1$s is not defined in the style.css header.', 'theme-sniffer' ), 609 | $header 610 | ), 611 | self::SEVERITY => self::ERROR, 612 | ); 613 | } 614 | 615 | if ( strpos( $theme_slug, 'wordpress' ) || strpos( $theme_slug, 'theme' ) ) { // phpcs:ignore 616 | $notices[] = array( 617 | self::MESSAGE => esc_html__( 'The theme name cannot contain WordPress or Theme as a part of its name.', 'theme-sniffer' ), 618 | self::SEVERITY => self::ERROR, 619 | ); 620 | } 621 | 622 | if ( preg_match( '|[^\d\.]|', $theme->get( 'Version' ) ) ) { 623 | $notices[] = array( 624 | self::MESSAGE => esc_html__( 'Version strings can only contain numeric and period characters (e.g. 1.2).', 'theme-sniffer' ), 625 | self::SEVERITY => self::ERROR, 626 | ); 627 | } 628 | 629 | // Prevent duplicate URLs. 630 | $themeuri = trim( $theme->get( 'ThemeURI' ), '/\\' ); 631 | $authoruri = trim( $theme->get( 'AuthorURI' ), '/\\' ); 632 | 633 | if ( ( $themeuri === $authoruri ) && ( ! empty( $themeuri ) || ! empty( $authoruri ) ) ) { 634 | $notices[] = array( 635 | self::MESSAGE => esc_html__( 'Duplicate theme and author URLs. A theme URL is a page/site that provides details about this specific theme. An author URL is a page/site that provides information about the author of the theme. The theme and author URL are optional.', 'theme-sniffer' ), 636 | self::SEVERITY => self::ERROR, 637 | ); 638 | } 639 | 640 | if ( $theme_slug === $theme->get( 'Text Domain' ) ) { 641 | $notices[] = array( 642 | self::MESSAGE => sprintf( 643 | /* translators: %1$s: Text Domain, %2$s: Theme Slug */ 644 | esc_html__( 'The text domain "%1$s" must match the theme slug "%2$s".', 'theme-sniffer' ), 645 | $theme->get( 'TextDomain' ), 646 | $theme_slug 647 | ), 648 | self::SEVERITY => self::ERROR, 649 | ); 650 | } 651 | 652 | $registered_tags = $this->get_theme_tags(); 653 | $tags = array_map( 'strtolower', $theme->get( 'Tags' ) ); 654 | $tags_count = array_count_values( $tags ); 655 | $subject_tags_names = array(); 656 | 657 | $subject_tags = array_flip( $registered_tags['subject_tags'] ); 658 | $allowed_tags = array_flip( $registered_tags['allowed_tags'] ); 659 | 660 | foreach ( $tags as $tag ) { 661 | if ( $tags_count[ $tag ] > 1 ) { 662 | $notices[] = array( 663 | self::MESSAGE => sprintf( 664 | /* translators: %s: Theme tag */ 665 | esc_html__( 'The tag "%s" is being used more than once, please remove the duplicate.', 'theme-sniffer' ), 666 | $tag 667 | ), 668 | self::SEVERITY => self::ERROR, 669 | ); 670 | } 671 | 672 | if ( isset( $subject_tags[ $tag ] ) ) { 673 | $subject_tags_names[] = $tag; 674 | continue; 675 | } 676 | 677 | if ( ! isset( $allowed_tags[ $tag ] ) ) { 678 | $notices[] = array( 679 | self::MESSAGE => sprintf( 680 | /* translators: %s: Theme tag */ 681 | wp_kses_post( __( 'Please remove "%s" as it is not a standard tag.', 'theme-sniffer' ) ), 682 | $tag 683 | ), 684 | self::SEVERITY => self::ERROR, 685 | ); 686 | continue; 687 | } 688 | 689 | if ( 'accessibility-ready' === $tag && $show_warnings !== '0' ) { 690 | $notices[] = array( 691 | self::MESSAGE => wp_kses_post( __( 'Themes that use the "accessibility-ready" tag will need to undergo an accessibility review.', 'theme-sniffer' ) ), 692 | self::SEVERITY => self::WARNING, 693 | ); 694 | } 695 | } 696 | 697 | $subject_tags_count = count( $subject_tags_names ); 698 | 699 | if ( $subject_tags_count > 3 ) { 700 | $notices[] = array( 701 | self::MESSAGE => sprintf( 702 | /* translators: 1: Subject theme tag, 2: Tags list */ 703 | esc_html__( 'A maximum of 3 subject tags are allowed. The theme has %1$d subjects tags [%2$s]. Please remove the subject tags, which do not directly apply to the theme.', 'theme-sniffer' ), 704 | $subject_tags_count, 705 | implode( ',', $subject_tags_names ) 706 | ), 707 | self::SEVERITY => self::ERROR, 708 | ); 709 | } 710 | 711 | $error_count = 0; 712 | $warning_count = 0; 713 | $messages = array(); 714 | 715 | foreach ( $notices as $notice ) { 716 | $severity = $notice[ self::SEVERITY ]; 717 | 718 | if ( $severity === self::ERROR ) { 719 | $error_count++; 720 | } else { 721 | $warning_count++; 722 | } 723 | 724 | $messages[] = array( 725 | self::MESSAGE => $notice[ self::MESSAGE ], 726 | self::SEVERITY => $severity, 727 | self::FIXABLE => false, 728 | self::TYPE => strtoupper( $severity ), 729 | ); 730 | } 731 | 732 | $header_results = array( 733 | self::TOTALS => array( 734 | self::ERRORS => $error_count, 735 | self::WARNINGS => $warning_count, 736 | self::FIXABLE => $error_count + $warning_count, 737 | ), 738 | self::FILES => array( 739 | self::$theme_root . "/{$theme_slug}/style.css" => array( 740 | self::ERRORS => $error_count, 741 | self::WARNINGS => $warning_count, 742 | self::MESSAGES => $messages, 743 | ), 744 | ), 745 | ); 746 | 747 | return $header_results; 748 | } 749 | 750 | /** 751 | * Performs readme.txt sniffs. 752 | * 753 | * @since 1.1.0 754 | * 755 | * @return array $check Sniffer file report. 756 | */ 757 | protected function readme_check() { 758 | $validator = new Readme( self::$theme_slug ); 759 | return $validator->get_results(); 760 | } 761 | 762 | /** 763 | * Perform screenshot sniffs. 764 | * 765 | * @since 1.0.0 766 | */ 767 | protected function screenshot_check() { 768 | $validator = new Screenshot( self::$theme_slug ); 769 | return $validator->get_results(); 770 | } 771 | 772 | /** 773 | * Returns true if the callback should be public 774 | * 775 | * @return boolean true if callback is public. 776 | */ 777 | protected function is_public() : bool { 778 | return self::CB_PUBLIC; 779 | } 780 | 781 | /** 782 | * Get name of the callback action 783 | * 784 | * @return string Name of the callback action. 785 | */ 786 | protected function get_action_name() : string { 787 | return self::CALLBACK_ACTION; 788 | } 789 | } 790 | -------------------------------------------------------------------------------- /src/callback/invokable-interface.php: -------------------------------------------------------------------------------- 1 | get_prepared_service_array( $services ); 38 | $container = $this->get_di_container( $di_services ); 39 | 40 | return array_map( 41 | static function( $class ) use ( $container ) { 42 | return $container->get( $class ); 43 | }, 44 | array_keys( $di_services ) 45 | ); 46 | } 47 | 48 | /** 49 | * Return a DI container 50 | * 51 | * Build and return a DI container. 52 | * Wire all the dependencies automatically, based on the provided array of 53 | * class => dependencies from the get_di_services(). 54 | * 55 | * @param array $services Array of service. 56 | * 57 | * @return Container 58 | * 59 | * @throws \Exception Throws exception if container cannot be created. 60 | * @since 1.2.0 61 | */ 62 | private function get_di_container( array $services ) { 63 | $builder = new ContainerBuilder(); 64 | 65 | $builder->enableCompilation( __DIR__ ); 66 | 67 | $definitions = array(); 68 | 69 | foreach ( $services as $service_name => $service_dependencies ) { 70 | $definitions[ $service_name ] = \DI\create()->constructor( ...$this->get_di_dependencies( $service_dependencies ) ); 71 | } 72 | 73 | return $builder->addDefinitions( $definitions )->build(); 74 | } 75 | 76 | /** 77 | * Get dependencies from PHP-DI 78 | * 79 | * Return prepared Dependency Injection objects. 80 | * If you pass a class use PHP-DI to prepare if not just output it. 81 | * 82 | * @since 1.2.0 83 | * 84 | * @param array $dependencies Array of classes/parameters to inject in constructor. 85 | * 86 | * @return array 87 | */ 88 | private function get_di_dependencies( array $dependencies ) : array { 89 | return array_map( 90 | static function( $dependency ) { 91 | if ( class_exists( $dependency ) ) { 92 | return \DI\get( $dependency ); 93 | } 94 | return $dependency; 95 | }, 96 | $dependencies 97 | ); 98 | } 99 | 100 | /** 101 | * Prepare services array 102 | * 103 | * Takes an argument of services, which is a multidimensional array, 104 | * that has a class name for a key, and a list of dependencies as a value, or no value at all. 105 | * It then loops though this array, and if the dependencies are an array it will just add this to 106 | * the value of the $prepared_services array, and the key will be the class name. 107 | * In case that there is no dependency. 108 | * 109 | * @since 1.2.0 110 | * 111 | * @param array $services A list of classes with optional dependencies. 112 | * 113 | * @return array 114 | */ 115 | private function get_prepared_service_array( array $services ) : array { 116 | $prepared_services = array(); 117 | 118 | foreach ( $services as $class => $dependencies ) { 119 | if ( ! is_array( $dependencies ) ) { 120 | $prepared_services[ $dependencies ] = array(); 121 | } else { 122 | $prepared_services[ $class ] = $dependencies; 123 | } 124 | } 125 | 126 | return $prepared_services; 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/class-plugin-factory.php: -------------------------------------------------------------------------------- 1 | register_services(); 60 | 61 | // Activate that which can be activated. 62 | foreach ( $this->services as $service ) { 63 | if ( $service instanceof Has_Activation ) { 64 | $service->activate(); 65 | } 66 | } 67 | 68 | \flush_rewrite_rules(); 69 | } 70 | 71 | /** 72 | * Deactivate the plugin. 73 | */ 74 | public function deactivate() { 75 | $this->register_services(); 76 | 77 | // Deactivate that which can be deactivated. 78 | foreach ( $this->services as $service ) { 79 | if ( $service instanceof Has_Deactivation ) { 80 | $service->deactivate(); 81 | } 82 | } 83 | 84 | \flush_rewrite_rules(); 85 | } 86 | 87 | /** 88 | * Register the plugin with the WordPress system. 89 | * 90 | * @throws Exception\Invalid_Service If a service is not valid. 91 | */ 92 | public function register() { 93 | $this->register_assets_manifest_data(); 94 | 95 | add_action( 'plugins_loaded', array( $this, 'register_services' ) ); 96 | add_action( 'plugin_action_links_' . PLUGIN_BASENAME, array( $this, 'plugin_settings_link' ) ); 97 | add_filter( 'extra_theme_headers', array( $this, 'add_headers' ) ); 98 | } 99 | 100 | /** 101 | * Register bundled asset manifest 102 | * 103 | * @throws Exception\Missing_Manifest Throws error if manifest is missing. 104 | * @return void 105 | */ 106 | public function register_assets_manifest_data() { 107 | 108 | $response = file_get_contents( 109 | rtrim( plugin_dir_path( __DIR__ ), '/' ) . '/assets/build/manifest.json' 110 | ); 111 | 112 | if ( ! $response ) { 113 | $error_message = esc_html__( 'manifest.json is missing. Bundle the plugin before using it.', 'developer-portal' ); 114 | throw Exception\Missing_Manifest::message( $error_message ); 115 | } 116 | 117 | define( 'ASSETS_MANIFEST', (string) $response ); 118 | } 119 | 120 | /** 121 | * Register the individual services of this plugin. 122 | * 123 | * @throws Exception\Invalid_Service If a service is not valid. 124 | */ 125 | public function register_services() { 126 | // Bail early so we don't instantiate services twice. 127 | if ( ! empty( $this->services ) ) { 128 | return; 129 | } 130 | 131 | $container = new Di_Container(); 132 | 133 | $this->services = $container->get_di_services( $this->get_service_classes() ); 134 | 135 | array_walk( 136 | $this->services, 137 | static function( $class ) { 138 | if ( ! $class instanceof Registerable ) { 139 | return; 140 | } 141 | 142 | $class->register(); 143 | } 144 | ); 145 | } 146 | 147 | /** 148 | * Add go to theme check page link on plugin page. 149 | * 150 | * @since 1.0.0 Moved to main plugin class file. 151 | * @since 0.1.3 152 | * 153 | * @param array $links Array of plugin action links. 154 | * @return array Modified array of plugin action links. 155 | */ 156 | public function plugin_settings_link( array $links ) : array { 157 | $settings_page_link = '' . esc_attr__( 'Theme Sniffer Page', 'theme-sniffer' ) . ''; 158 | array_unshift( $links, $settings_page_link ); 159 | 160 | return $links; 161 | } 162 | 163 | /** 164 | * Allow fetching custom headers. 165 | * 166 | * @since 0.1.3 167 | * 168 | * @param array $extra_headers List of extra headers. 169 | * 170 | * @return array List of extra headers. 171 | */ 172 | public static function add_headers( array $extra_headers ) : array { 173 | $extra_headers[] = 'License'; 174 | $extra_headers[] = 'License URI'; 175 | $extra_headers[] = 'Template Version'; 176 | 177 | return $extra_headers; 178 | } 179 | 180 | /** 181 | * Get the list of services to register. 182 | * 183 | * @return array Array of fully qualified class names. 184 | */ 185 | private function get_service_classes() : array { 186 | return array( 187 | Admin_Menus\Sniff_Page::class, 188 | Callback\Run_Sniffer_Callback::class, 189 | Enqueue\Enqueue_Resources::class, 190 | i18n\Internationalization::class, 191 | ); 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/enqueue/assets-interface.php: -------------------------------------------------------------------------------- 1 | get_manifest_assets_data( self::CSS_URI ), 67 | array(), 68 | self::VERSION, 69 | self::MEDIA_ALL 70 | ); 71 | 72 | wp_enqueue_style( self::CSS_HANDLE ); 73 | } 74 | 75 | /** 76 | * Register admin area scripts 77 | * 78 | * @since 1.2.0 79 | * 80 | * @param string $hook Hook suffix for the current admin page. 81 | */ 82 | public function enqueue_scripts( $hook ) { 83 | if ( $hook !== 'toplevel_page_theme-sniffer' ) { 84 | return; 85 | } 86 | 87 | wp_register_script( 88 | self::JS_HANDLE, 89 | $this->get_manifest_assets_data( self::JS_URI ), 90 | $this->get_js_dependencies(), 91 | self::VERSION, 92 | self::IN_FOOTER 93 | ); 94 | 95 | wp_enqueue_script( self::JS_HANDLE ); 96 | 97 | foreach ( $this->get_localizations() as $localization_name => $localization_data ) { 98 | wp_localize_script( self::JS_HANDLE, $localization_name, $localization_data ); 99 | } 100 | } 101 | 102 | /** 103 | * Get script dependencies 104 | * 105 | * @link https://developer.wordpress.org/reference/functions/wp_enqueue_script/#default-scripts-included-and-registered-by-wordpress 106 | * 107 | * @return array List of all the script dependencies 108 | */ 109 | protected function get_js_dependencies() : array { 110 | return array( 111 | 'jquery', 112 | 'esprima', 113 | ); 114 | } 115 | 116 | /** 117 | * Get script localizations 118 | * 119 | * @return array Key value pair of different localizations 120 | */ 121 | protected function get_localizations() : array { 122 | return array( 123 | self::LOCALIZATION_HANDLE => array( 124 | 'sniffError' => esc_html__( 'The check has failed. This could happen due to running out of memory. Either reduce the file length or increase PHP memory.', 'theme-sniffer' ), 125 | 'checkCompleted' => esc_html__( 'Check is completed. The results are below.', 'theme-sniffer' ), 126 | 'checkInProgress' => esc_html__( 'Check in progress', 'theme-sniffer' ), 127 | 'errorReport' => esc_html__( 'Error', 'theme-sniffer' ), 128 | 'ajaxAborted' => esc_html__( 'Checking stopped', 'theme-sniffer' ), 129 | 'copySuccess' => esc_attr__( 'Copied!', 'theme-sniffer' ), 130 | ), 131 | ); 132 | } 133 | 134 | /** 135 | * Return full path for specific asset from manifest.json 136 | * This is used for cache busting assets. 137 | * 138 | * @param string $key File name key you want to get from manifest. 139 | * @return string Full path to asset. 140 | */ 141 | private function get_manifest_assets_data( string $key = null ) : string { 142 | $data = ASSETS_MANIFEST; 143 | 144 | if ( ! $key || $data === null ) { 145 | return ''; 146 | } 147 | 148 | $data = json_decode( $data, true ); 149 | 150 | if ( empty( $data ) ) { 151 | return ''; 152 | } 153 | 154 | $asset = $data[ $key ] ?? ''; 155 | 156 | return plugin_dir_url( dirname( __DIR__ ) ) . '/assets/build/' . $asset; 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/exceptions/class-api-response-error.php: -------------------------------------------------------------------------------- 1 | getMessage() 34 | ); 35 | 36 | return new static( $message, $exception->getCode(), $exception ); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/exceptions/class-invalid-service.php: -------------------------------------------------------------------------------- 1 | license_data = $this->set_license_data(); 39 | } 40 | 41 | /** 42 | * Gets license validation data. 43 | * 44 | * @since 1.1.0 45 | * 46 | * @return array License validation data. 47 | */ 48 | public function set_license_data() : array { 49 | $licenses_path = WP_PLUGIN_DIR . plugin_dir_path( '/theme-sniffer/assets/build/licenses.json' ); 50 | $licenses_file = file_get_contents( $licenses_path . 'licenses.json' ); 51 | 52 | return json_decode( $licenses_file, true ); 53 | } 54 | 55 | /** 56 | * Find license matches. 57 | * 58 | * @since 1.1.0 59 | * 60 | * @param string $id License identifier. 61 | * 62 | * @return object $reponse Object containing license criteria and match information. 63 | */ 64 | public function find_license( $id ) { 65 | $response = (object) array(); 66 | $response->provided = $id; 67 | $response->deprecated = array(); 68 | $response->live = array(); 69 | 70 | // SPDX ID exact match, so skip loop. 71 | $found = $this->license_data[ $response->provided ] ?? array(); 72 | 73 | // Check if license is deprecated. Set data. 74 | if ( $found ) { 75 | $response->id = $found['licenseId']; 76 | if ( $found['isDeprecatedLicenseId'] ) { 77 | $response->deprecated[ $response->provided ] = $found; 78 | } else { 79 | $response->live[ $response->provided ] = $found; 80 | } 81 | } else { 82 | foreach ( $this->license_data as $license => $details ) { 83 | if ( in_array( $response->provided, $details['names'], true ) || in_array( $response->provided, $details['ids'], true ) ) { 84 | if ( $this->license_data[ $license ]['isDeprecatedLicenseId'] ) { 85 | $response->deprecated[ $license ] = $details; 86 | } else { 87 | $response->live[ $license ] = $details; 88 | } 89 | } 90 | } 91 | } 92 | 93 | return $this->get_response_message( $response ); 94 | } 95 | 96 | /** 97 | * Get license match messages. 98 | * 99 | * @since 1.1.0 100 | * 101 | * @param Object $response Object containing license criteria and match information. 102 | * 103 | * @return Object $response Object containing license criteria and match information. 104 | */ 105 | public function get_response_message( $response ) { 106 | 107 | // Non-deprecated license matches found - check here. Names can match deprecated licenses as well. 108 | if ( ! empty( $response->live ) ) { 109 | 110 | // Single live license match. 111 | if ( count( $response->live ) === 1 ) { 112 | $details = current( $response->live ); 113 | 114 | // Set the ID for easier lookup in other checks. 115 | $response->id = $details['licenseId']; 116 | 117 | // Provided a SPDX name that matched. 118 | if ( $response->provided === $details['name'] ) { 119 | $response->status = 'warning'; 120 | /* translators: 1: a SPDX license name. 2: the recommended SPDX ID to use instead. */ 121 | $response->message = sprintf( esc_html__( 'Found a valid SPDX name, %1$s, but it is better to use the SPDX ID: %2$s', 'theme-sniffer' ), $response->provided, $details['licenseId'] ); 122 | } elseif ( $response->provided === $details['licenseId'] ) { // A Valid SPDX ID was found, no message required. 123 | $response->status = 'success'; 124 | $response->message = null; 125 | } else { // A single match was found for FSF criteria. 126 | $response->status = 'warning'; 127 | /* translators: 1: a SPDX license name. 2: the recommended SPDX ID to use instead. */ 128 | $response->message = sprintf( esc_html__( 'Found valid license information based on FSF naming: %1$s, but it is better to use the SPDX ID: %2$s', 'theme-sniffer' ), $response->provided, $details['licenseId'] ); 129 | } 130 | } else { // Multiple matches returned, so it's FSF provided criteria. 131 | $matches = array_keys( $response->live ); 132 | $response->status = 'warning'; 133 | /* translators: %s: listing of license IDs matched. */ 134 | $response->message = sprintf( esc_html__( 'Found multiple records matching these licenses: %s, it\'s required to use a single SPDX Idenitfier!', 'theme-sniffer' ), implode( ', ', $matches ) ); 135 | } 136 | } elseif ( ! empty( $response->deprecated ) ) { // Deprecated match found. 137 | $response->status = 'warning'; 138 | /* translators: %s: User provided license identifier. */ 139 | $response->message = sprintf( esc_html__( 'The license identification provided, %s, indicates a deprecated license! Please use a valid SPDX Identifier!', 'theme-sniffer' ), $response->provided ); 140 | } else { // No matches found. 141 | $response->status = 'warning'; 142 | /* translators: %s: unrecognized user provided license identifier */ 143 | $response->message = sprintf( esc_html__( 'No matching license criteria could be determined from: %s!', 'theme-sniffer' ), $response->provided ); 144 | } 145 | 146 | return $response; 147 | } 148 | 149 | /** 150 | * Check if a license criteria object is gpl compatible. 151 | * 152 | * @since 1.1.0 153 | * 154 | * @param Object $license A license criteria object. 155 | * 156 | * @return bool License criteria is GPLv2 compatible. 157 | */ 158 | public function is_gpl2_or_later_compatible( $license ) { 159 | $gpl = false; 160 | 161 | if ( ! empty( $license->id ) ) { 162 | 163 | // Check if license is flagged as GPLv2.0 Compatible. 164 | $gpl = $this->license_data[ $license->id ]['isGpl2Compatible'] || $this->license_data[ $license->id ]['isGpl3Compatible']; 165 | } 166 | 167 | return $gpl; 168 | } 169 | 170 | /** 171 | * Get blacklisted resources. 172 | * 173 | * @since 1.1.0 174 | * 175 | * @return array Blacklisted resource urls. 176 | */ 177 | public function get_blacklist() : array { 178 | return array( 179 | 'unsplash', 180 | 'sxc.hu', 181 | 'photopin.com', 182 | 'publicdomainpictures.net', 183 | 'splitshire.com', 184 | 'pixabay.com', 185 | ); 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /src/helpers/sniffer-helpers-trait.php: -------------------------------------------------------------------------------- 1 | array( 36 | 'label' => 'WPThemeReview', 37 | 'description' => esc_html__( 'Ruleset for WordPress theme review requirements (Required)', 'theme-sniffer' ), 38 | 'default' => 1, 39 | ), 40 | 'wordpress-core' => array( 41 | 'label' => 'WordPress-Core', 42 | 'description' => esc_html__( 'Main ruleset for WordPress core coding standards (Optional)', 'theme-sniffer' ), 43 | 'default' => 0, 44 | ), 45 | 'wordpress-extra' => array( 46 | 'label' => 'WordPress-Extra', 47 | 'description' => esc_html__( 'Extended ruleset for recommended best practices (Optional)', 'theme-sniffer' ), 48 | 'default' => 0, 49 | ), 50 | 'wordpress-docs' => array( 51 | 'label' => 'WordPress-Docs', 52 | 'description' => esc_html__( 'Additional ruleset for WordPress inline documentation standards (Optional)', 'theme-sniffer' ), 53 | 'default' => 0, 54 | ), 55 | 'wordpress-vip' => array( 56 | 'label' => 'WordPress-VIP', 57 | 'description' => esc_html__( 'Extended ruleset for WordPress VIP coding requirements (Optional)', 'theme-sniffer' ), 58 | 'default' => 0, 59 | ), 60 | ); 61 | 62 | if ( has_filter( 'theme_sniffer_add_standards' ) ) { 63 | $standards = apply_filters( 'theme_sniffer_add_standards', $standards ); 64 | } 65 | 66 | return $standards; 67 | } 68 | 69 | /** 70 | * Return all the active themes 71 | * 72 | * @since 1.0.0 Moved to a trait. 73 | * 74 | * @throws No_Themes_Present If there are no themes in the themes directory. 75 | * @return array Array of active themes. 76 | */ 77 | public function get_active_themes() : array { 78 | $all_themes = wp_get_themes(); 79 | $active_theme = ( wp_get_theme() )->get( 'Name' ); 80 | 81 | if ( empty( $all_themes ) ) { 82 | throw No_Themes_Present::message( 83 | esc_html__( 'No themes present in the themes directory.', 'theme-sniffer' ) 84 | ); 85 | } 86 | 87 | $themes = array(); 88 | foreach ( $all_themes as $theme_slug => $theme ) { 89 | $theme_name = $theme->get( 'Name' ); 90 | $theme_version = $theme->get( 'Version' ); 91 | 92 | if ( $theme_name === $active_theme ) { 93 | $theme_name = "(Active) $theme_name"; 94 | } 95 | 96 | $themes[ $theme_slug ] = "$theme_name - v$theme_version"; 97 | 98 | } 99 | 100 | return $themes; 101 | } 102 | 103 | /** 104 | * Returns PHP versions. 105 | * 106 | * @since 1.2.0 Removed versions lower than 5.6, as this is the minimum required version. 107 | * @since 1.0.0 Added PHP 7.x versions. Moved to a trait. 108 | * @since 0.1.3 109 | * 110 | * @return array PHP versions. 111 | */ 112 | public function get_php_versions() : array { 113 | return array( 114 | '5.6', 115 | '7.0', 116 | '7.1', 117 | '7.2', 118 | '7.3', 119 | ); 120 | } 121 | 122 | /** 123 | * Returns theme tags. 124 | * 125 | * @since 1.2.0 Removed the API calls as they are prone to failure. 126 | * @since 1.0.0 Moved to a trait and refactored to use tags from API. 127 | * @since 0.1.3 128 | * 129 | * @return array Theme tags array. 130 | */ 131 | public function get_theme_tags() : array { 132 | return array( 133 | 'subject_tags' => array( 134 | 'blog', 135 | 'e-commerce', 136 | 'education', 137 | 'entertainment', 138 | 'food-and-drink', 139 | 'holiday', 140 | 'news', 141 | 'photography', 142 | 'portfolio', 143 | ), 144 | 'allowed_tags' => array( 145 | 'grid-layout', 146 | 'one-column', 147 | 'two-columns', 148 | 'three-columns', 149 | 'four-columns', 150 | 'left-sidebar', 151 | 'right-sidebar', 152 | 'wide-blocks', 153 | 'accessibility-ready', 154 | 'block-styles', 155 | 'buddypress', 156 | 'custom-background', 157 | 'custom-colors', 158 | 'custom-header', 159 | 'custom-logo', 160 | 'custom-menu', 161 | 'editor-style', 162 | 'featured-image-header', 163 | 'featured-images', 164 | 'flexible-header', 165 | 'footer-widgets', 166 | 'front-page-post-form', 167 | 'full-width-template', 168 | 'microformats', 169 | 'post-formats', 170 | 'rtl-language-support', 171 | 'sticky-post', 172 | 'theme-options', 173 | 'threaded-comments', 174 | 'translation-ready', 175 | ), 176 | ); 177 | } 178 | 179 | /** 180 | * Helper method that returns the default standard 181 | * 182 | * @since 1.0.0 183 | * @return string Name of the default standard. 184 | */ 185 | public function get_default_standard() : string { 186 | return 'WPThemeReview'; 187 | } 188 | 189 | /** 190 | * Helper method to get a list of required headers 191 | * 192 | * @since 1.0.0 193 | * @return array List of required headers. 194 | */ 195 | public function get_required_headers() { 196 | return array( 197 | 'Name', 198 | 'Description', 199 | 'Author', 200 | 'Version', 201 | 'License', 202 | 'License URI', 203 | 'TextDomain', 204 | ); 205 | } 206 | 207 | /** 208 | * Check WP Core's Required PHP Version 209 | * 210 | * The functionality to check WP core wasn't added until 5.1.0, so this will 211 | * address users who are on older WP versions and fetch from the API. The 212 | * code is copied from the core function wp_check_php_version. 213 | * 214 | * @link https://developer.wordpress.org/reference/functions/wp_check_php_version/ 215 | * 216 | * @since 1.1.0 217 | * 218 | * @return string|false $response String containing minimum PHP version required for user's install of WP. False on failure. 219 | */ 220 | public function get_wp_minimum_php_version() { 221 | if ( function_exists( 'wp_check_php_version' ) ) { 222 | $response = wp_check_php_version(); 223 | } else { 224 | $version = phpversion(); 225 | $key = md5( $version ); 226 | 227 | $response = get_site_transient( 'php_check_' . $key ); 228 | if ( false === $response ) { 229 | $url = 'http://api.wordpress.org/core/serve-happy/1.0/'; 230 | if ( wp_http_supports( array( 'ssl' ) ) ) { 231 | $url = set_url_scheme( $url, 'https' ); 232 | } 233 | 234 | $url = add_query_arg( 'php_version', $version, $url ); 235 | 236 | $response = wp_remote_get( $url ); 237 | 238 | if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { 239 | return false; 240 | } 241 | 242 | /** 243 | * Response should be an array with: 244 | * 'recommended_version' - string - The PHP version recommended by WordPress. 245 | * 'is_supported' - boolean - Whether the PHP version is actively supported. 246 | * 'is_secure' - boolean - Whether the PHP version receives security updates. 247 | * 'is_acceptable' - boolean - Whether the PHP version is still acceptable for WordPress. 248 | */ 249 | $response = json_decode( wp_remote_retrieve_body( $response ), true ); 250 | 251 | if ( ! is_array( $response ) ) { 252 | return false; 253 | } 254 | 255 | set_site_transient( 'php_check_' . $key, $response, WEEK_IN_SECONDS ); 256 | } 257 | 258 | if ( isset( $response['is_acceptable'] ) && $response['is_acceptable'] ) { 259 | /** 260 | * Filters whether the active PHP version is considered acceptable by WordPress. 261 | * 262 | * Returning false will trigger a PHP version warning to show up in the admin dashboard to administrators. 263 | * 264 | * This filter is only run if the wordpress.org Serve Happy API considers the PHP version acceptable, ensuring 265 | * that this filter can only make this check stricter, but not loosen it. 266 | * 267 | * @since 5.1.1 268 | * 269 | * @param bool $is_acceptable Whether the PHP version is considered acceptable. Default true. 270 | * @param string $version PHP version checked. 271 | */ 272 | $response['is_acceptable'] = (bool) apply_filters( 'wp_is_php_version_acceptable', true, $version ); 273 | } 274 | } 275 | 276 | if ( ! isset( $response['minimum_version'] ) ) { 277 | return false; 278 | } 279 | 280 | return $response['minimum_version']; 281 | } 282 | 283 | /** 284 | * Helper method to get the minimum PHP version supplied by theme 285 | * or the WP core default. 286 | * 287 | * @since 1.1.0 288 | * 289 | * @return string Minimum PHP Version String. 290 | */ 291 | public function get_minimum_php_version() { 292 | 293 | // WP Core minimum PHP version - only used for fallback if API fails, and no transient stored. 294 | $minimum_php_version = '5.2'; 295 | 296 | // Check API for minimum WP core version supported. 297 | $php_check = $this->get_wp_minimum_php_version(); 298 | 299 | // Checks response success or transient data. 300 | if ( $php_check !== false ) { 301 | $minimum_php_version = $php_check; 302 | } 303 | 304 | $readme = wp_normalize_path( get_template_directory() . '/readme.txt' ); 305 | 306 | if ( file_exists( $readme ) ) { 307 | 308 | // Check if theme has set minimum PHP version in it's readme.txt file. 309 | $theme_php_version = get_file_data( $readme, array( 'minimum_php_version' => 'Requires PHP' ) ); 310 | 311 | // Theme has provided an override to minimum PHP version defined by WP Core. 312 | if ( ! empty( $theme_php_version['minimum_php_version'] ) ) { 313 | $minimum_php_version = $theme_php_version['minimum_php_version']; 314 | } 315 | } 316 | 317 | // Theme Sniffer's supported PHP version strings are X.X format. 318 | $minimum_php_version = substr( $minimum_php_version, 0, 3 ); 319 | 320 | // Check Theme Sniffer's supported PHP Versions and find closest PHP version. 321 | $supported_php_version = null; 322 | $theme_sniffer_versions = $this->get_php_versions(); 323 | 324 | foreach ( $theme_sniffer_versions as $php_version ) { 325 | if ( $supported_php_version === null || abs( $minimum_php_version - $supported_php_version ) > abs( $php_version - $minimum_php_version ) ) { 326 | $supported_php_version = $php_version; 327 | } 328 | } 329 | 330 | // Ensure a supported version was found or just use the minimum PHP version determined appropriate. 331 | if ( $supported_php_version !== null ) { 332 | $minimum_php_version = $supported_php_version; 333 | } 334 | 335 | return $minimum_php_version; 336 | } 337 | } 338 | -------------------------------------------------------------------------------- /src/i18n/class-internationalization.php: -------------------------------------------------------------------------------- 1 | file = $file; 131 | $this->results = $this->set_format(); 132 | $this->results = $this->set_results( $results ); 133 | } 134 | 135 | /** 136 | * Set results class property. 137 | * 138 | * @since 1.1.0 139 | * 140 | * @param string $results Results of sniffs. 141 | * 142 | * @return string $results Formatted results of sniffs. 143 | */ 144 | private function set_results( $results ) { 145 | if ( $results ) { 146 | 147 | // Loop results and assign. 148 | foreach ( $results as $result ) { 149 | if ( $result[ self::SEVERITY ] === self::ERROR ) { 150 | $this->results[ $this->file ][ self::ERRORS ]++; 151 | } 152 | 153 | if ( $result[ self::SEVERITY ] === self::WARNING ) { 154 | $this->results[ $this->file ][ self::WARNINGS ]++; 155 | } 156 | 157 | $this->results[ $this->file ][ self::MESSAGES ][] = array( 158 | self::MESSAGE => esc_html( $result[ self::MESSAGE ] ?? '' ), 159 | self::SOURCE => $result[ self::SOURCE ] ?? null, 160 | self::SEVERITY => $result[ self::SEVERITY ] ?? null, 161 | self::FIXABLE => $result[ self::FIXABLE ] ?? false, 162 | self::TYPE => strtoupper( $result[ self::SEVERITY ] ?? '' ), 163 | ); 164 | } 165 | } 166 | 167 | return $this->results; 168 | } 169 | 170 | /** 171 | * Set format for results class property. 172 | * 173 | * @since 1.1.0 174 | * 175 | * @return array Formatted result. 176 | */ 177 | private function set_format() { 178 | return array( 179 | $this->file => array( 180 | self::ERRORS => 0, 181 | self::WARNINGS => 0, 182 | self::MESSAGES => array(), 183 | ), 184 | ); 185 | } 186 | 187 | /** 188 | * Return results from all validator parts ran. 189 | * 190 | * @since 1.1.0 191 | * 192 | * @return array $results Validator warnings/messages. 193 | */ 194 | public function get_results() { 195 | $result = empty( $this->results[ $this->file ][ self::MESSAGES ] ) ? false : $this->results; 196 | return $this->results; 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /src/sniffs/class-validate-file.php: -------------------------------------------------------------------------------- 1 | theme_slug = $slug; 72 | $this->theme_root = get_theme_root( $this->theme_slug ); 73 | $this->file = $this->set_file(); 74 | $this->validate( $this->file ); 75 | } 76 | 77 | /** 78 | * Set file class property. 79 | * 80 | * @since 1.1.0 81 | * 82 | * @return string $file Found file. 83 | */ 84 | public function set_file() { 85 | $file = false; 86 | foreach ( $this->extensions as $extenstion ) { 87 | $file = implode( '/', array( $this->theme_root, $this->theme_slug, $this->filename . '.' . $extenstion ) ); 88 | $file = $this->file_exists( $file ); 89 | if ( $file !== false ) { 90 | break; 91 | } 92 | } 93 | 94 | return $file; 95 | } 96 | 97 | /** 98 | * Runs validation. 99 | * 100 | * @since 1.1.0 101 | * 102 | * @param string $file File to validate. 103 | */ 104 | public function validate( $file ) { 105 | 106 | // No file. 107 | if ( $file === false ) { 108 | 109 | // Set file class property for result output. 110 | $this->file = implode( '/', array( $this->theme_root, $this->theme_slug, $this->filename . '.' . $this->extensions[0] ) ); 111 | 112 | $this->results[] = array( 113 | 'severity' => 'error', 114 | 'message' => sprintf( 115 | /* translators: 1: the file required including name and extension. */ 116 | esc_html__( 'Themes are required to provide %1$s', 'theme-sniffer' ), 117 | $this->filename . '.' . $this->extensions[0] 118 | ), 119 | ); 120 | 121 | return; 122 | } 123 | 124 | // Valid file but not recommended extension. 125 | $pathinfo = pathinfo( $file ); 126 | 127 | if ( $pathinfo['extension'] !== $this->extensions[0] ) { 128 | $this->results[] = array( 129 | 'severity' => 'warning', 130 | 'message' => sprintf( 131 | /* translators: 1: filename being validated 2: file extension found 3: recommended file extension to use */ 132 | esc_html__( '%1$s.%2$s is valid, but %1$s.%3$s is recommended.', 'theme-sniffer' ), 133 | $this->filename, 134 | $pathinfo['extension'], 135 | $this->extensions[0] 136 | ), 137 | ); 138 | 139 | return; 140 | } 141 | } 142 | 143 | /** 144 | * Return results from all validator parts ran. 145 | * 146 | * @since 1.1.0 147 | * 148 | * @return array $results Validator errors/warnings/messages. 149 | */ 150 | public function get_results() { 151 | $result = new Result( $this->file, $this->results ); 152 | return $result->get_results(); 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/sniffs/class-validate.php: -------------------------------------------------------------------------------- 1 | args = $args; 35 | $this->init(); 36 | $this->check(); 37 | } 38 | 39 | /** 40 | * Additional initialization logic before check. 41 | * 42 | * @since 1.1.0 43 | * 44 | * @return void 45 | */ 46 | public function init() {} 47 | 48 | /** 49 | * Classes should check to perform validation in check(). 50 | * 51 | * @since 1.1.0 52 | * 53 | * @return void 54 | */ 55 | abstract public function check(); 56 | 57 | /** 58 | * Retrieve the results from the validate check method. 59 | * 60 | * @since 1.1.0 61 | * 62 | * @return array $results Results from validation check. 63 | */ 64 | public function get_results() { 65 | return $this->results; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/sniffs/has-results-interface.php: -------------------------------------------------------------------------------- 1 | args ) { 34 | return; 35 | } 36 | 37 | // Check each user's profile in list. 38 | foreach ( $this->args as $contributor ) { 39 | $profile = "https://profiles.wordpress.org/{$contributor}/"; 40 | $response = wp_remote_head( $profile, array( 'timeout' => 20 ) ); 41 | 42 | // Error with remote request. 43 | if ( is_wp_error( $response ) ) { 44 | $this->results[] = array( 45 | 'severity' => 'warning', 46 | 'message' => esc_html__( 'Something went wrong when remotely reaching out to WordPress.org to valid the contributors in readme.txt' ), 47 | ); 48 | 49 | continue; 50 | } 51 | 52 | $status = wp_remote_retrieve_response_code( $response ); 53 | 54 | // Successful validatation. 55 | if ( $status === 200 ) { 56 | continue; 57 | } 58 | 59 | // Profile page redirect. 60 | if ( $status === 302 ) { 61 | $this->results[] = array( 62 | 'severity' => 'error', 63 | /* translators: %s: a contributor's username for WordPress.org that wasn't found. */ 64 | 'message' => sprintf( esc_html__( 'The user %s, is not a valid WordPress.org username!', 'theme-sniffer' ), $contributor ), 65 | ); 66 | 67 | continue; 68 | } 69 | 70 | // Catch all error if something beyond this.. 71 | $this->results[] = array( 72 | 'severity' => 'warning', 73 | 'message' => esc_html__( 'Something went wrong when validating readme.txt\'s contributors list!', 'theme-sniffer' ), 74 | ); 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/sniffs/readme/class-license-uri.php: -------------------------------------------------------------------------------- 1 | license_uri_helpers(); 37 | } 38 | 39 | /** 40 | * Check License URI from readme.txt 41 | * 42 | * @since 1.1.0 43 | */ 44 | public function check() { 45 | $license = $this->find_license( $this->args->primary ); 46 | 47 | // Still report errors when license status is warning (or success of course). 48 | if ( $license->status !== 'warning' ) { 49 | $uris = $this->license_data[ $license->id ]['uris']; 50 | 51 | // Missing License URI field warning. 52 | if ( empty( $this->args->uri ) ) { 53 | $this->results[] = array( 54 | 'severity' => 'warning', 55 | 'message' => esc_html__( 'All themes are required to provide a License URI in their readme.txt!', 'theme-sniffer' ), 56 | ); 57 | } 58 | 59 | // URI field is invalid. 60 | if ( empty( preg_grep( '/^' . preg_quote( $this->args->uri, '/' ) . '$/i', $uris ) ) ) { 61 | $this->results[] = array( 62 | 'severity' => 'warning', 63 | 'message' => wp_kses( 64 | sprintf( 65 | /* translators: 1: the user provided License URI in readme.txt 2: the license comparing against in readme.txt 3: a list of suitable license URIs that could be used */ 66 | __( 'The License URI provided: %1$s, is not a known URI reference for the license %2$s. These are recognized URIs for the license provided:
%3$s', 'theme-sniffer' ), 67 | $this->args->uri, 68 | $this->args->primary, 69 | implode( '
', $uris ) 70 | ), 71 | array( 72 | 'br' => array(), 73 | ) 74 | ), 75 | ); 76 | } 77 | } else { 78 | // Unable to determine License URI without valid License. 79 | $this->results[] = array( 80 | 'severity' => 'warning', 81 | 'message' => esc_html__( 'Unable to determine License URI with an invalid License supplied in readme.txt!', 'theme-sniffer' ), 82 | ); 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/sniffs/readme/class-license.php: -------------------------------------------------------------------------------- 1 | license_helpers(); 37 | } 38 | 39 | /** 40 | * Check license from readme.txt 41 | * 42 | * @since 1.1.0 43 | */ 44 | public function check() { 45 | $license_data = $this->find_license( $this->args ); 46 | 47 | // Only report errors. 48 | if ( $license_data->status !== 'success' ) { 49 | $this->results[] = array( 50 | 'severity' => $license_data->status, 51 | 'message' => $license_data->message, 52 | ); 53 | } 54 | 55 | // Check if GPLv2 compatible if no errors found with License Identifier so far. 56 | if ( $license_data->status !== 'warning' && ! $this->is_gpl2_or_later_compatible( $license_data ) ) { 57 | $this->results[] = array( 58 | 'severity' => 'warning', 59 | 'message' => sprintf( 60 | /* translators: %s: the license specified in readme.txt */ 61 | esc_html__( 'The license specified, %s is not compatible with WordPress\' license of GPL-2.0-or-later. All themes must meet this requirement!' ), 62 | $this->args 63 | ), 64 | ); 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/sniffs/readme/class-parser.php: -------------------------------------------------------------------------------- 1 | to 135 | * 136 | * @var array 137 | */ 138 | private $alias_sections = [ 139 | 'frequently_asked_questions' => 'faq', 140 | 'change_log' => 'changelog', 141 | ]; 142 | 143 | /** 144 | * These are the valid header mappings for the header. 145 | * 146 | * @var array 147 | */ 148 | private $valid_headers = [ 149 | 'tested' => 'tested', 150 | 'tested up to' => 'tested', 151 | 'requires' => 'requires', 152 | 'requires at least' => 'requires', 153 | 'requires php' => 'requires_php', 154 | 'tags' => 'tags', 155 | 'contributors' => 'contributors', 156 | 'donate link' => 'donate_link', 157 | 'stable tag' => 'stable_tag', 158 | 'license' => 'license', 159 | 'license uri' => 'license_uri', 160 | 'resources' => 'resources', 161 | ]; 162 | 163 | /** 164 | * These plugin tags are ignored. 165 | * 166 | * @var array 167 | */ 168 | private $ignore_tags = []; 169 | 170 | /** 171 | * Parser constructor. 172 | * 173 | * @param string $file 174 | */ 175 | public function __construct( $file ) { 176 | if ( $file ) { 177 | $this->parse_readme( $file ); 178 | } 179 | } 180 | 181 | /** 182 | * @param string $file 183 | * @return bool 184 | */ 185 | protected function parse_readme( $file ) { 186 | $contents = file_get_contents( $file ); 187 | if ( preg_match( '!!u', $contents ) ) { 188 | $contents = preg_split( '!\R!u', $contents ); 189 | } else { 190 | $contents = preg_split( '!\R!', $contents ); // regex failed due to invalid UTF8 in $contents, see #2298 191 | } 192 | $contents = array_map( [ $this, 'strip_newlines' ], $contents ); 193 | 194 | // Strip UTF8 BOM if present. 195 | if ( 0 === strpos( $contents[0], "\xEF\xBB\xBF" ) ) { 196 | $contents[0] = substr( $contents[0], 3 ); 197 | } 198 | 199 | // Convert UTF-16 files. 200 | if ( 0 === strpos( $contents[0], "\xFF\xFE" ) ) { 201 | foreach ( $contents as $i => $line ) { 202 | $contents[ $i ] = mb_convert_encoding( $line, 'UTF-8', 'UTF-16' ); 203 | } 204 | } 205 | 206 | $line = $this->get_first_nonwhitespace( $contents ); 207 | $this->name = $this->sanitize_text( trim( $line, "#= \t\0\x0B" ) ); 208 | 209 | // Strip GitHub style header\n==== underlines. 210 | if ( ! empty( $contents ) && '' === trim( $contents[0], '=-' ) ) { 211 | array_shift( $contents ); 212 | } 213 | 214 | // Handle readme's which do `=== Plugin Name ===\nMy SuperAwesomePlugin Name\n...`. 215 | if ( 'plugin name' == strtolower( $this->name ) ) { 216 | $this->name = $line = $this->get_first_nonwhitespace( $contents ); 217 | 218 | // Ensure that the line read wasn't an actual header or description. 219 | if ( strlen( $line ) > 50 || preg_match( '~^(' . implode( '|', array_keys( $this->valid_headers ) ) . ')\s*:~i', $line ) ) { 220 | $this->name = false; 221 | array_unshift( $contents, $line ); 222 | } 223 | } 224 | 225 | // Parse headers. 226 | $headers = []; 227 | 228 | $line = $this->get_first_nonwhitespace( $contents ); 229 | do { 230 | $value = null; 231 | if ( false === strpos( $line, ':' ) ) { 232 | 233 | // Some plugins have line-breaks within the headers. 234 | if ( empty( $line ) ) { 235 | break; 236 | } else { 237 | continue; 238 | } 239 | } 240 | 241 | $bits = explode( ':', trim( $line ), 2 ); 242 | list( $key, $value ) = $bits; 243 | $key = strtolower( trim( $key, " \t*-\r\n" ) ); 244 | if ( isset( $this->valid_headers[ $key ] ) ) { 245 | $headers[ $this->valid_headers[ $key ] ] = trim( $value ); 246 | } 247 | } while ( ( $line = array_shift( $contents ) ) !== null ); 248 | array_unshift( $contents, $line ); 249 | 250 | if ( ! empty( $headers['tags'] ) ) { 251 | $this->tags = explode( ',', $headers['tags'] ); 252 | $this->tags = array_map( 'trim', $this->tags ); 253 | $this->tags = array_filter( $this->tags ); 254 | $this->tags = array_diff( $this->tags, $this->ignore_tags ); 255 | } 256 | 257 | if ( ! empty( $headers['requires'] ) ) { 258 | $this->requires = $this->sanitize_requires_version( $headers['requires'] ); 259 | } 260 | 261 | if ( ! empty( $headers['tested'] ) ) { 262 | $this->tested = $this->sanitize_tested_version( $headers['tested'] ); 263 | } 264 | 265 | if ( ! empty( $headers['requires_php'] ) ) { 266 | $this->requires_php = $this->sanitize_requires_php( $headers['requires_php'] ); 267 | } 268 | 269 | if ( ! empty( $headers['contributors'] ) ) { 270 | $this->contributors = explode( ',', $headers['contributors'] ); 271 | $this->contributors = array_map( 'trim', $this->contributors ); 272 | } 273 | 274 | if ( ! empty( $headers['stable_tag'] ) ) { 275 | $this->stable_tag = $this->sanitize_stable_tag( $headers['stable_tag'] ); 276 | } 277 | 278 | if ( ! empty( $headers['donate_link'] ) ) { 279 | $this->donate_link = $headers['donate_link']; 280 | } 281 | 282 | if ( ! empty( $headers['license'] ) ) { 283 | 284 | // Handle the many cases of "License: GPLv2 - http://...". 285 | if ( empty( $headers['license_uri'] ) && preg_match( '!(https?://\S+)!i', $headers['license'], $url ) ) { 286 | $headers['license_uri'] = $url[1]; 287 | $headers['license'] = trim( str_replace( $url[1], '', $headers['license'] ), " -*\t\n\r\n" ); 288 | } 289 | 290 | $this->license = $headers['license']; 291 | } 292 | 293 | if ( ! empty( $headers['license_uri'] ) ) { 294 | $this->license_uri = $headers['license_uri']; 295 | } 296 | 297 | // Parse the short description. 298 | while ( ( $line = array_shift( $contents ) ) !== null ) { 299 | $trimmed = trim( $line ); 300 | 301 | if ( empty( $trimmed ) ) { 302 | $this->short_description .= "\n"; 303 | continue; 304 | } 305 | 306 | if ( ( '=' === $trimmed[0] && isset( $trimmed[1] ) && '=' === $trimmed[1] ) || 307 | ( '#' === $trimmed[0] && isset( $trimmed[1] ) && '#' === $trimmed[1] ) 308 | ) { 309 | 310 | // Stop after any Markdown heading. 311 | array_unshift( $contents, $line ); 312 | break; 313 | } 314 | 315 | $this->short_description .= $line . "\n"; 316 | } 317 | $this->short_description = trim( $this->short_description ); 318 | 319 | /* 320 | * Parse the rest of the body. 321 | * Pre-fill the sections, we'll filter out empty sections later. 322 | */ 323 | $this->sections = array_fill_keys( $this->expected_sections, '' ); 324 | $current = $section_name = $section_title = ''; 325 | 326 | while ( ( $line = array_shift( $contents ) ) !== null ) { 327 | $trimmed = trim( $line ); 328 | if ( empty( $trimmed ) ) { 329 | $current .= "\n"; 330 | continue; 331 | } 332 | 333 | // Stop only after a ## Markdown header, not a ###. 334 | if ( ( '=' === $trimmed[0] && isset( $trimmed[1] ) && '=' === $trimmed[1] ) || 335 | ( '#' === $trimmed[0] && isset( $trimmed[1] ) && '#' === $trimmed[1] && isset( $trimmed[2] ) && '#' !== $trimmed[2] ) 336 | ) { 337 | 338 | if ( ! empty( $section_name ) ) { 339 | $this->sections[ $section_name ] .= trim( $current ); 340 | } 341 | 342 | $current = ''; 343 | $section_title = trim( $line, "#= \t" ); 344 | $section_name = strtolower( str_replace( ' ', '_', $section_title ) ); 345 | 346 | if ( isset( $this->alias_sections[ $section_name ] ) ) { 347 | $section_name = $this->alias_sections[ $section_name ]; 348 | } 349 | 350 | // If we encounter an unknown section header, include the provided Title, we'll filter it to other_notes later. 351 | if ( ! in_array( $section_name, $this->expected_sections ) ) { 352 | $current .= '

' . $section_title . '

'; 353 | $section_name = 'other_notes'; 354 | } 355 | 356 | continue; 357 | } 358 | 359 | $current .= $line . "\n"; 360 | } 361 | 362 | if ( ! empty( $section_name ) ) { 363 | $this->sections[ $section_name ] .= trim( $current ); 364 | } 365 | 366 | // Filter out any empty sections. 367 | $this->sections = array_filter( $this->sections ); 368 | 369 | // Use the short description for the description section if not provided. 370 | if ( empty( $this->sections['description'] ) ) { 371 | $this->sections['description'] = $this->short_description; 372 | } 373 | 374 | // Suffix the Other Notes section to the description. 375 | if ( ! empty( $this->sections['other_notes'] ) ) { 376 | $this->sections['description'] .= "\n" . $this->sections['other_notes']; 377 | unset( $this->sections['other_notes'] ); 378 | } 379 | 380 | // Parse out the Upgrade Notice section into it's own data. 381 | if ( isset( $this->sections['upgrade_notice'] ) ) { 382 | $this->upgrade_notice = $this->parse_section( $this->sections['upgrade_notice'] ); 383 | $this->upgrade_notice = array_map( array( $this, 'sanitize_text' ), $this->upgrade_notice ); 384 | unset( $this->sections['upgrade_notice'] ); 385 | } 386 | 387 | // Display FAQs as a definition list. 388 | if ( isset( $this->sections['faq'] ) ) { 389 | $this->faq = $this->parse_section( $this->sections['faq'] ); 390 | $this->sections['faq'] = ''; 391 | } 392 | 393 | // Markdownify! 394 | $this->sections = array_map( array( $this, 'parse_markdown' ), $this->sections ); 395 | $this->upgrade_notice = array_map( array( $this, 'parse_markdown' ), $this->upgrade_notice ); 396 | $this->faq = array_map( array( $this, 'parse_markdown' ), $this->faq ); 397 | 398 | // Use the first line of the description for the short description if not provided. 399 | if ( ! $this->short_description && ! empty( $this->sections['description'] ) ) { 400 | $this->short_description = array_filter( explode( "\n", $this->sections['description'] ) )[0]; 401 | } 402 | 403 | // Sanitize and trim the short_description to match requirements. 404 | $this->short_description = $this->sanitize_text( $this->short_description ); 405 | $this->short_description = $this->parse_markdown( $this->short_description ); 406 | $this->short_description = wp_strip_all_tags( $this->short_description ); 407 | $this->short_description = $this->trim_length( $this->short_description, 150 ); 408 | 409 | if ( ! empty( $this->faq ) ) { 410 | // If the FAQ contained data we couldn't parse, we'll treat it as freeform and display it before any questions which are found. 411 | if ( isset( $this->faq[''] ) ) { 412 | $this->sections['faq'] .= $this->faq['']; 413 | unset( $this->faq[''] ); 414 | } 415 | 416 | if ( $this->faq ) { 417 | $this->sections['faq'] .= "\n
\n"; 418 | foreach ( $this->faq as $question => $answer ) { 419 | $question_slug = sanitize_title_with_dashes( $question ); 420 | $this->sections['faq'] .= "
{$question}
\n
{$answer}
\n"; 421 | } 422 | $this->sections['faq'] .= "\n
\n"; 423 | } 424 | } 425 | 426 | // Filter the HTML. 427 | $this->sections = array_map( array( $this, 'filter_text' ), $this->sections ); 428 | 429 | return true; 430 | } 431 | 432 | /** 433 | * @access protected 434 | * 435 | * @param string $contents 436 | * @return string 437 | */ 438 | protected function get_first_nonwhitespace( &$contents ) { 439 | while ( ( $line = array_shift( $contents ) ) !== null ) { 440 | $trimmed = trim( $line ); 441 | if ( ! empty( $trimmed ) ) { 442 | break; 443 | } 444 | } 445 | 446 | return $line; 447 | } 448 | 449 | /** 450 | * @access protected 451 | * 452 | * @param string $line 453 | * @return string 454 | */ 455 | protected function strip_newlines( $line ) { 456 | return rtrim( $line, "\r\n" ); 457 | } 458 | 459 | /** 460 | * @access protected 461 | * 462 | * @param string $desc 463 | * @param int $length 464 | * @return string 465 | */ 466 | protected function trim_length( $desc, $length = 150 ) { 467 | if ( mb_strlen( $desc ) > $length ) { 468 | $desc = mb_substr( $desc, 0, $length ) . ' …'; 469 | 470 | // If not a full sentence, and one ends within 20% of the end, trim it to that. 471 | if ( '.' !== mb_substr( $desc, -1 ) && ( $pos = mb_strrpos( $desc, '.' ) ) > ( 0.8 * $length ) ) { 472 | $desc = mb_substr( $desc, 0, $pos + 1 ); 473 | } 474 | } 475 | 476 | return trim( $desc ); 477 | } 478 | 479 | /** 480 | * Filters text passed in through wp_kses, and force balances 481 | * HTML tags that aren't properly closed. 482 | * 483 | * @access protected 484 | * 485 | * @param string $text Text to filter. 486 | * 487 | * @return string $text The filtered text. 488 | */ 489 | protected function filter_text( $text ) { 490 | $text = trim( $text ); 491 | 492 | $allowed = [ 493 | 'a' => [ 494 | 'href' => true, 495 | 'title' => true, 496 | 'rel' => true, 497 | ], 498 | 'blockquote' => [ 499 | 'cite' => true, 500 | ], 501 | 'br' => [], 502 | 'p' => [], 503 | 'code' => [], 504 | 'pre' => [], 505 | 'em' => [], 506 | 'strong' => [], 507 | 'ul' => [], 508 | 'ol' => [], 509 | 'dl' => [], 510 | 'dt' => [], 511 | 'dd' => [], 512 | 'li' => [], 513 | 'h3' => [], 514 | 'h4' => [], 515 | ]; 516 | 517 | $text = force_balance_tags( $text ); 518 | 519 | $text = wp_kses( $text, $allowed ); 520 | 521 | // wpautop() will eventually replace all \n's with
s, and that isn't what we want (The text may be line-wrapped in the readme, we don't want that, we want paragraph-wrapped text). 522 | // TODO: This incorrectly also applies within `` tags which we don't want either: $text = preg_replace( "/(? ])\n/", ' ', $text );. 523 | $text = trim( $text ); 524 | 525 | return $text; 526 | } 527 | 528 | /** 529 | * Sanitize text. 530 | * 531 | * @access protected 532 | * 533 | * @param string $text Text to sanitize. 534 | * 535 | * @return string $text Cleaned text. 536 | */ 537 | protected function sanitize_text( $text ) { 538 | // not fancy. 539 | $text = wp_strip_all_tags( $text ); 540 | $text = esc_html( $text ); 541 | $text = trim( $text ); 542 | 543 | return $text; 544 | } 545 | 546 | /** 547 | * Sanitize the provided stable tag to something we expect. 548 | * 549 | * @param string $stable_tag the raw Stable Tag line from the readme. 550 | * 551 | * @return string $stable_tag The sanitized stable tag. 552 | */ 553 | protected function sanitize_stable_tag( $stable_tag ) { 554 | $stable_tag = trim( $stable_tag ); 555 | $stable_tag = trim( $stable_tag, '"\'' ); 556 | $stable_tag = preg_replace( '!^/?tags/!i', '', $stable_tag ); // Matches for: "tags/1.2.3". 557 | $stable_tag = preg_replace( '![^a-z0-9_.-]!i', '', $stable_tag ); 558 | 559 | // If the stable_tag begins with a ., we treat it as 0.blah. 560 | if ( '.' === substr( $stable_tag, 0, 1 ) ) { 561 | $stable_tag = "0{$stable_tag}"; 562 | } 563 | 564 | return $stable_tag; 565 | } 566 | 567 | /** 568 | * Sanitizes the Requires PHP header to ensure that it's a valid version header. 569 | * 570 | * @param string $version The version number passed in the header. 571 | * 572 | * @return string $version The sanitized version number. 573 | */ 574 | protected function sanitize_requires_php( $version ) { 575 | $version = trim( $version ); 576 | 577 | // x.y or x.y.z version number. 578 | if ( $version && ! preg_match( '!^\d+(\.\d+){1,2}$!', $version ) ) { 579 | $this->warnings['requires_php_header_ignored'] = true; 580 | 581 | // Ignore the readme value. 582 | $version = ''; 583 | } 584 | 585 | return $version; 586 | } 587 | 588 | /** 589 | * Sanitizes the Tested header to ensure that it's a valid version header. 590 | * 591 | * @param string $version The version number from header. 592 | * 593 | * @return string $version The sanitized version number. 594 | */ 595 | protected function sanitize_tested_version( $version ) { 596 | $version = trim( $version ); 597 | 598 | if ( $version ) { 599 | 600 | // Handle the edge-case of 'WordPress 5.0' and 'WP 5.0' for historical purposes. 601 | $strip_phrases = [ 602 | 'WordPress', 603 | 'WP', 604 | ]; 605 | 606 | $version = trim( str_ireplace( $strip_phrases, '', $version ) ); 607 | 608 | // Strip off any -alpha, -RC, -beta suffixes, as these complicate comparisons and are rarely used. 609 | list( $version, ) = explode( '-', $version ); 610 | 611 | if ( 612 | 613 | // x.y or x.y.z version number. 614 | ! preg_match( '!^\d+\.\d(\.\d+)?$!', $version ) || 615 | 616 | // Allow plugins to mark themselves as compatible with Stable+0.1 (trunk/master) but not higher. 617 | defined( 'WP_CORE_STABLE_BRANCH' ) && ( (float) $version > (float) WP_CORE_STABLE_BRANCH + 0.1 ) 618 | ) { 619 | $this->warnings['tested_header_ignored'] = true; 620 | 621 | // Ignore the readme value. 622 | $version = ''; 623 | } 624 | } 625 | 626 | return $version; 627 | } 628 | 629 | /** 630 | * Sanitizes the Requires at least header to ensure that it's a valid version header. 631 | * 632 | * @param string $version The version number from header. 633 | * 634 | * @return string $version The sanitized version number. 635 | */ 636 | protected function sanitize_requires_version( $version ) { 637 | $version = trim( $version ); 638 | 639 | if ( $version ) { 640 | 641 | // Handle the edge-case of 'WordPress 5.0' and 'WP 5.0' for historical purposes. 642 | $strip_phrases = [ 643 | 'WordPress', 644 | 'WP', 645 | 'or higher', 646 | 'and above', 647 | '+', 648 | ]; 649 | 650 | $version = trim( str_ireplace( $strip_phrases, '', $version ) ); 651 | 652 | // Strip off any -alpha, -RC, -beta suffixes, as these complicate comparisons and are rarely used. 653 | list( $version, ) = explode( '-', $version ); 654 | 655 | if ( 656 | 657 | // x.y or x.y.z version number. 658 | ! preg_match( '!^\d+\.\d(\.\d+)?$!', $version ) || 659 | 660 | // Allow plugins to mark themselves as requiring Stable+0.1 (trunk/master) but not higher. 661 | defined( 'WP_CORE_STABLE_BRANCH' ) && ( (float) $version > (float) WP_CORE_STABLE_BRANCH + 0.1 ) 662 | ) { 663 | $this->warnings['requires_header_ignored'] = true; 664 | 665 | // Ignore the readme value. 666 | $version = ''; 667 | } 668 | } 669 | 670 | return $version; 671 | } 672 | 673 | /** 674 | * Parses a slice of lines from the file into an array of Heading => Content. 675 | * 676 | * We assume that every heading encountered is a new item, and not a sub heading. 677 | * We support headings which are either `= Heading`, `# Heading` or `** Heading`. 678 | * 679 | * @param string|array $lines The lines of the section to parse. 680 | * 681 | * @return array 682 | */ 683 | protected function parse_section( $lines ) { 684 | $return = []; 685 | $key = $value = ''; 686 | 687 | if ( ! is_array( $lines ) ) { 688 | $lines = explode( "\n", $lines ); 689 | } 690 | 691 | $trimmed_lines = array_map( 'trim', $lines ); 692 | 693 | /* 694 | * The heading style being matched in the block. Can be 'heading' or 'bold'. 695 | * Standard Markdown headings (## .. and == ... ==) are used, but if none are present. 696 | * full line bolding will be used as a heading style. 697 | */ 698 | $heading_style = 'bold'; 699 | foreach ( $trimmed_lines as $trimmed ) { 700 | if ( $trimmed && ( $trimmed[0] === '#' || $trimmed[0] === '=' ) ) { 701 | $heading_style = 'heading'; 702 | break; 703 | } 704 | } 705 | 706 | $line_count = count( $lines ); 707 | for ( $i = 0; $i < $line_count; $i++ ) { 708 | $line = &$lines[ $i ]; 709 | $trimmed = &$trimmed_lines[ $i ]; 710 | if ( ! $trimmed ) { 711 | $value .= "\n"; 712 | continue; 713 | } 714 | 715 | $is_heading = false; 716 | if ( 'heading' === $heading_style && ( $trimmed[0] === '#' || $trimmed[0] === '=' ) ) { 717 | $is_heading = true; 718 | } elseif ( 'bold' === $heading_style && ( substr( $trimmed, 0, 2 ) === '**' && substr( $trimmed, -2 ) === '**' ) ) { 719 | $is_heading = true; 720 | } 721 | 722 | if ( $is_heading ) { 723 | if ( $value ) { 724 | $return[ $key ] = trim( $value ); 725 | } 726 | 727 | $value = ''; 728 | 729 | // Trim off the first character of the line, as we know that's the heading style we're expecting to remove. 730 | $key = trim( $line, $trimmed[0] . " \t" ); 731 | continue; 732 | } 733 | 734 | $value .= $line . "\n"; 735 | } 736 | 737 | if ( $key || $value ) { 738 | $return[ $key ] = trim( $value ); 739 | } 740 | 741 | return $return; 742 | } 743 | 744 | /** 745 | * Parse markdown from sections. 746 | * 747 | * This isn't required as we are just wanting data, so eventually 748 | * can be removed along with Markdown dep. 749 | * 750 | * @param string $text Text to apply transformation to. 751 | * 752 | * @return string Transformed text to HTML. 753 | */ 754 | protected function parse_markdown( $text ) { 755 | static $markdown = null; 756 | 757 | if ( is_null( $markdown ) ) { 758 | $markdown = new MarkdownExtra(); 759 | } 760 | 761 | return $markdown->transform( $text ); 762 | } 763 | 764 | /** 765 | * Determine if the readme contains unique installation instructions. 766 | * 767 | * When phrases are added here, the affected plugins will need to be reparsed to pick it up. 768 | * 769 | * @return bool Whether the instructions differ from default instructions. 770 | */ 771 | protected function has_unique_installation_instructions() { 772 | if ( ! isset( $this->sections['installation'] ) ) { 773 | return false; 774 | } 775 | 776 | // If the plugin installation section contains any of these phrases, skip it as it's not useful. 777 | $common_phrases = array( 778 | 'This section describes how to install the plugin and get it working.', // Default readme.txt content. 779 | ); 780 | 781 | foreach ( $common_phrases as $phrase ) { 782 | if ( false !== stripos( $this->sections['installation'], $phrase ) ) { 783 | return false; 784 | } 785 | } 786 | 787 | return true; 788 | } 789 | } 790 | -------------------------------------------------------------------------------- /src/sniffs/readme/class-validator.php: -------------------------------------------------------------------------------- 1 | license ) && ! empty( $parser->license_uri ) ) { 61 | $parser->license_uri = (object) array( 62 | 'primary' => $parser->license, 63 | 'uri' => $parser->license_uri, 64 | ); 65 | } 66 | 67 | return $parser; 68 | } 69 | 70 | /** 71 | * Runs any existing validators set on parser. 72 | * 73 | * @since 1.1.0 74 | * 75 | * @param string $file file to validate. 76 | */ 77 | public function validate( $file ) { 78 | 79 | // Validate file. 80 | parent::validate( $file ); 81 | 82 | // No need to continue if file validation contains error for file not existing. 83 | if ( ! empty( $this->results ) && in_array( 'error', array_column( $this->results, 'severity' ), true ) ) { 84 | return; 85 | } 86 | 87 | $parser = new Parser( $this->file ); 88 | $this->parser = $this->set_defaults( $parser ); 89 | 90 | foreach ( $this->parser as $name => $args ) { 91 | $class = __NAMESPACE__ . '\\' . ucwords( $name, '_' ); 92 | 93 | if ( class_exists( $class ) ) { 94 | $validator = new $class( $args ); 95 | $results = $validator->get_results(); 96 | 97 | if ( is_array( $results ) ) { 98 | $this->results = array_merge( $this->results, $results ); 99 | } 100 | } 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/sniffs/screenshot/class-validator.php: -------------------------------------------------------------------------------- 1 | results ) && in_array( 'error', array_column( $this->results, 'severity' ), true ) ) { 58 | return; 59 | } 60 | 61 | // Image mime. 62 | $mime_type = wp_get_image_mime( $file ); 63 | 64 | // Missing mime type. 65 | if ( ! $mime_type ) { 66 | $this->results[] = array( 67 | 'severity' => 'error', 68 | 'message' => sprintf( 69 | esc_html__( 'Screenshot mime type could not be determined, screenshots must have a mime type of "img/png" or "img/jpg".', 'theme-sniffer' ), 70 | $mime_type 71 | ), 72 | ); 73 | 74 | return; 75 | } 76 | 77 | // Valid mime type returned, but not a png. 78 | if ( $mime_type !== 'image/png' ) { 79 | $this->results[] = array( 80 | 'severity' => 'warning', 81 | 'message' => sprintf( 82 | /* translators: 1: screenshot mime type found */ 83 | esc_html__( 'Screenshot has mime type of "%1$s", but a mimetype of "img/png" is recommended.', 'theme-sniffer' ), 84 | $mime_type 85 | ), 86 | ); 87 | 88 | return; 89 | } 90 | 91 | // Screenshot validated at this point, so check dimensions - no need for fileinfo. 92 | // props @Otto42(WP.org, GitHub) for aspect ratio logic from Theme Check: https://github.com/WordPress/theme-check/blob/master/checks/screenshot.php. 93 | list( $width, $height ) = getimagesize( $file ); 94 | 95 | // Screenshot too big. 96 | if ( $width > 1200 || $height > 900 ) { 97 | $this->results[] = array( 98 | 'severity' => 'error', 99 | 'message' => sprintf( 100 | /* translators: 1: screenshot width 2: screenshot height */ 101 | esc_html__( 'The size of your screenshot should not exceed 1200x900, but screenshot.png is currently %1$dx%2$d.', 'theme-sniffer' ), 102 | $width, 103 | $height 104 | ), 105 | ); 106 | 107 | return; 108 | } 109 | 110 | // Aspect Ratio. 111 | if ( $height / $width !== 0.75 ) { 112 | $this->results[] = array( 113 | 'severity' => 'error', 114 | 'message' => esc_html__( 'Screenshot aspect ratio must be 4:3!', 'theme-sniffer' ), 115 | ); 116 | 117 | return; 118 | } 119 | 120 | // Recommended size. 121 | if ( $width !== 1200 || $height !== 900 ) { 122 | $this->results[] = array( 123 | 'severity' => 'warning', 124 | 'message' => esc_html__( 'Screenshot size of 1200x900 is recommended to account for HiDPI displays.', 'theme-sniffer' ), 125 | ); 126 | 127 | return; 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/sniffs/validatable-interface.php: -------------------------------------------------------------------------------- 1 | uri = $this->validate( $uri ); 57 | } 58 | 59 | /** 60 | * Render a given URI. 61 | * 62 | * @param array $context Context in which to render. 63 | * 64 | * @return string Rendered HTML. 65 | * @throws Failed_To_Load_View If the View URI could not be loaded. 66 | */ 67 | public function render( array $context = array() ) : string { 68 | $context = array_filter( $context ); 69 | 70 | // Add context to the current instance to make it available within the rendered view. 71 | foreach ( $context as $key => $value ) { 72 | $this->$key = $value; 73 | } 74 | 75 | // Add entire context as array to the current instance to pass onto partial views. 76 | $this->internal_context = $context; 77 | 78 | // Save current buffering level so we can backtrack in case of an error. 79 | // This is needed because the view itself might also add an unknown. 80 | // number of output buffering levels. 81 | $buffer_level = ob_get_level(); 82 | ob_start(); 83 | 84 | try { 85 | include $this->uri; 86 | } catch ( \Exception $exception ) { 87 | // Remove whatever levels were added up until now. 88 | while ( ob_get_level() > $buffer_level ) { 89 | ob_end_clean(); 90 | } 91 | throw Failed_To_Load_View::view_exception( 92 | $this->uri, 93 | $exception 94 | ); 95 | } 96 | 97 | return ob_get_clean(); 98 | } 99 | 100 | /** 101 | * Render a partial view. 102 | * 103 | * This can be used from within a currently rendered view, to include 104 | * nested partials. 105 | * 106 | * The passed-in context is optional, and will fall back to the parent's 107 | * context if omitted. 108 | * 109 | * @param string $uri URI of the partial to render. 110 | * @param array|null $context Context in which to render the partial. 111 | * 112 | * @return string Rendered HTML. 113 | * @throws Invalid_URI If the provided URI was not valid. 114 | * @throws Failed_To_Load_View If the view could not be loaded. 115 | */ 116 | public function render_partial( $uri, array $context = null ) : string { 117 | $view = new static( $uri ); 118 | 119 | return $view->render( $context ? $context : $this->internal_context ); 120 | } 121 | 122 | /** 123 | * Validate an URI. 124 | * 125 | * @param string $uri URI to validate. 126 | * 127 | * @return string Validated URI. 128 | * @throws Invalid_URI If an invalid URI was passed into the View. 129 | */ 130 | protected function validate( $uri ) : string { 131 | $uri = $this->check_extension( $uri, static::VIEW_EXTENSION ); 132 | $uri = trailingslashit( dirname( __DIR__, 2 ) ) . $uri; 133 | 134 | if ( ! is_readable( $uri ) ) { 135 | throw Invalid_URI::from_uri( $uri ); 136 | } 137 | 138 | return $uri; 139 | } 140 | 141 | /** 142 | * Check that the URI has the correct extension. 143 | * 144 | * Optionally adds the extension if none was detected. 145 | * 146 | * @param string $uri URI to check the extension of. 147 | * @param string $extension Extension to use. 148 | * 149 | * @return string URI with correct extension. 150 | */ 151 | protected function check_extension( $uri, $extension ) : string { 152 | $detected_extension = pathinfo( $uri, PATHINFO_EXTENSION ); 153 | 154 | if ( $extension !== $detected_extension ) { 155 | $uri .= '.' . $extension; 156 | } 157 | 158 | return $uri; 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/view/class-escaped-view.php: -------------------------------------------------------------------------------- 1 | array( 29 | 'id' => true, 30 | 'class' => true, 31 | 'action' => true, 32 | 'method' => true, 33 | 'tabindex' => true, 34 | ), 35 | 'input' => array( 36 | 'id' => true, 37 | 'class' => true, 38 | 'type' => true, 39 | 'name' => true, 40 | 'value' => true, 41 | 'tabindex' => true, 42 | 'checked' => true, 43 | ), 44 | 'select' => array( 45 | 'id' => true, 46 | 'class' => true, 47 | 'type' => true, 48 | 'name' => true, 49 | 'value' => true, 50 | 'tabindex' => true, 51 | ), 52 | 'option' => array( 53 | 'id' => true, 54 | 'class' => true, 55 | 'type' => true, 56 | 'name' => true, 57 | 'value' => true, 58 | 'selected' => true, 59 | 'tabindex' => true, 60 | ), 61 | 'label' => array( 62 | 'for' => true, 63 | ), 64 | 'div' => array( 65 | 'class' => true, 66 | ), 67 | 'svg' => array( 68 | 'class' => true, 69 | 'style' => true, 70 | 'width' => true, 71 | 'height' => true, 72 | 'viewbox' => true, 73 | 'xmlns' => true, 74 | ), 75 | 'g' => array( 76 | 'fill' => true, 77 | 'fill-rule' => true, 78 | 'transform' => true, 79 | ), 80 | 'path' => array( 81 | 'd' => true, 82 | 'id' => true, 83 | 'fill' => true, 84 | 'style' => true, 85 | 'stroke' => true, 86 | 'stroke-width' => true, 87 | ), 88 | 'mask' => array( 89 | 'id' => true, 90 | 'fill' => true, 91 | ), 92 | 'rect' => array( 93 | 'transform' => true, 94 | 'fill' => true, 95 | 'width' => true, 96 | 'height' => true, 97 | 'rx' => true, 98 | 'ry' => true, 99 | 'x' => true, 100 | 'y' => true, 101 | ), 102 | 'xmlns' => array( 103 | 'xlink' => true, 104 | ), 105 | 'defs' => array(), 106 | ); 107 | 108 | /** 109 | * View instance to decorate. 110 | * 111 | * @var View 112 | */ 113 | private $view; 114 | 115 | /** 116 | * Tags that are allowed to pass through the escaping function. 117 | * 118 | * @var array 119 | */ 120 | private $allowed_tags = array(); 121 | 122 | /** 123 | * Instantiate a Escaped_View object. 124 | * 125 | * @param View $view View instance to decorate. 126 | * @param array|null $allowed_tags Optional. Array of allowed tags to let 127 | * through escaping functions. Set to sane 128 | * defaults if none provided. 129 | */ 130 | public function __construct( View $view, $allowed_tags = null ) { 131 | $this->view = $view; 132 | $this->allowed_tags = null === $allowed_tags ? 133 | $this->prepare_allowed_tags( wp_kses_allowed_html( 'post' ) ) : 134 | $allowed_tags; 135 | } 136 | 137 | /** 138 | * Render a given URI. 139 | * 140 | * @param array $context Context in which to render. 141 | * 142 | * @return string Rendered HTML. 143 | * @throws Failed_To_Load_View If the View URI could not be loaded. 144 | */ 145 | public function render( array $context = array() ) : string { 146 | return wp_kses( $this->view->render( $context ), $this->allowed_tags ); 147 | } 148 | 149 | /** 150 | * Render a partial view. 151 | * 152 | * This can be used from within a currently rendered view, to include 153 | * nested partials. 154 | * 155 | * The passed-in context is optional, and will fall back to the parent's 156 | * context if omitted. 157 | * 158 | * @param string $uri URI of the partial to render. 159 | * @param array|null $context Context in which to render the partial. 160 | * 161 | * @return string Rendered HTML. 162 | * @throws Invalid_URI If the provided URI was not valid. 163 | * @throws Failed_To_Load_View If the view could not be loaded. 164 | */ 165 | public function render_partial( $uri, array $context = null ) : string { 166 | return wp_kses( 167 | $this->view->render_partial( $uri, $context ), 168 | $this->allowed_tags 169 | ); 170 | } 171 | 172 | /** 173 | * Prepare an array of allowed tags by adding form elements to the existing 174 | * array. 175 | * 176 | * This makes sure that the basic form elements always pass through the 177 | * escaping functions. 178 | * 179 | * @param array $allowed_tags Allowed tags as fetched from the WordPress 180 | * defaults. 181 | * 182 | * @return array Modified tags array. 183 | */ 184 | private function prepare_allowed_tags( $allowed_tags ) : array { 185 | return array_replace_recursive( $allowed_tags, self::ALLOWED_TAGS ); 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /src/view/class-post-escaped-view.php: -------------------------------------------------------------------------------- 1 | view = $view; 35 | } 36 | /** 37 | * Render a given URI. 38 | * 39 | * @param array $context Context in which to render. 40 | * 41 | * @return string Rendered HTML. 42 | * @throws Failed_To_Load_View If the View URI could not be loaded. 43 | */ 44 | public function render( array $context = array() ) : string { 45 | return wp_kses_post( $this->view->render( $context ) ); 46 | } 47 | /** 48 | * Render a partial view. 49 | * 50 | * This can be used from within a currently rendered view, to include 51 | * nested partials. 52 | * 53 | * The passed-in context is optional, and will fall back to the parent's 54 | * context if omitted. 55 | * 56 | * @param string $uri URI of the partial to render. 57 | * @param array|null $context Context in which to render the partial. 58 | * 59 | * @return string Rendered HTML. 60 | * @throws Invalid_URI If the provided URI was not valid. 61 | * @throws Failed_To_Load_View If the view could not be loaded. 62 | */ 63 | public function render_partial( $uri, array $context = null ) : string { 64 | return wp_kses_post( $this->view->render_partial( $uri, $context ) ); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/view/class-templated-view.php: -------------------------------------------------------------------------------- 1 | check_extension( $uri, static::VIEW_EXTENSION ); 33 | 34 | foreach ( $this->get_locations( $uri ) as $location ) { 35 | if ( is_readable( $location ) ) { 36 | return $location; 37 | } 38 | } 39 | 40 | if ( ! is_readable( $uri ) ) { 41 | throw Invalid_URI::from_uri( $uri ); 42 | } 43 | 44 | return $uri; 45 | } 46 | 47 | /** 48 | * Get the possible locations for the view. 49 | * 50 | * @param string $uri URI of the view to get the locations for. 51 | * 52 | * @return array Array of possible locations. 53 | */ 54 | protected function get_locations( $uri ) : array { 55 | return array( 56 | trailingslashit( \get_stylesheet_directory() ) . $uri, 57 | trailingslashit( \get_template_directory() ) . $uri, 58 | trailingslashit( dirname( __DIR__, 2 ) ) . $uri, 59 | ); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/view/class-view.php: -------------------------------------------------------------------------------- 1 | activate(); 42 | } 43 | ); 44 | 45 | /** 46 | * The code that runs during plugin deactivation. 47 | * 48 | * @since 1.0.0 49 | */ 50 | register_deactivation_hook( 51 | __FILE__, 52 | function() { 53 | Plugin_Factory::create()->deactivate(); 54 | } 55 | ); 56 | 57 | 58 | Plugin_Factory::create()->register(); 59 | -------------------------------------------------------------------------------- /views/partials/report-notice.php: -------------------------------------------------------------------------------- 1 | 16 | 17 |
18 |
19 |
20 |
21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 36 | 37 |
30 | 31 | 34 | 35 |
38 |
39 |
40 | -------------------------------------------------------------------------------- /views/theme-sniffer-page.php: -------------------------------------------------------------------------------- 1 | error ) ) { 16 | ?> 17 |
18 |

error ); ?>

19 |
20 | themes; 26 | 27 | if ( empty( $themes ) ) { 28 | return; 29 | } 30 | 31 | $standards = $this->standards; 32 | $php_versions = $this->php_versions; 33 | $nonce = $this->nonce_field; 34 | $current_theme = $this->current_theme; 35 | 36 | // Predefined values. 37 | $minimum_php_version = $this->minimum_php_version; 38 | $standard_status = $this->standard_status; 39 | 40 | // Defaults. 41 | $hide_warning = 0; 42 | $raw_output = 0; 43 | $ignore_annotations = 0; 44 | $check_php_only = 0; 45 | ?> 46 | 47 |
48 |

49 |
50 |
51 |
52 | 55 | 60 | 61 | 62 |
63 |
64 | 67 | 68 |
69 |
70 |
71 |

72 | $standard ) : ?> 73 |
77 | 78 |
79 |
80 |

81 |    82 |    83 |    84 | 92 |
93 |
94 | render_partial( 'views/partials/report-notice' ); // phpcs:ignore ?> 95 |
96 |
97 |
98 | 99 |
100 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require( 'path' ); 2 | 3 | const webpack = require( 'webpack' ); 4 | const { CleanWebpackPlugin } = require( 'clean-webpack-plugin' ); 5 | const MiniCssExtractPlugin = require( 'mini-css-extract-plugin' ); 6 | const TerserPlugin = require( 'terser-webpack-plugin' ); 7 | const ManifestPlugin = require( 'webpack-manifest-plugin' ); 8 | const FileManagerPlugin = require( 'filemanager-webpack-plugin' ); 9 | const CreateFileWebpack = require( 'create-file-webpack' ); 10 | const Licenses = require( 'wp-license-compatibility' ); 11 | 12 | const DEV = process.env.NODE_ENV !== 'production'; 13 | 14 | const appPath = __dirname; 15 | 16 | // Entry. 17 | const pluginPath = '/assets'; 18 | const pluginFullPath = `${appPath}${pluginPath}`; 19 | const pluginEntry = `${pluginFullPath}/dev/application.js`; 20 | const pluginPublicPath = `${pluginFullPath}/build`; 21 | 22 | // Outputs. 23 | const outputJs = 'scripts/[name]-[hash].js'; 24 | const outputCss = 'styles/[name]-[hash].css'; 25 | 26 | const allModules = { 27 | rules: [ 28 | { 29 | test: /\.(js|jsx)$/, 30 | exclude: /node_modules/, 31 | use: 'babel-loader' 32 | }, 33 | { 34 | test: /\.json$/, 35 | use: 'json-loader' 36 | }, 37 | { 38 | test: /\.scss$/, 39 | use: [ 40 | MiniCssExtractPlugin.loader, 41 | 'css-loader', 'sass-loader' 42 | ] 43 | } 44 | ] 45 | }; 46 | 47 | const allPlugins = [ 48 | new CleanWebpackPlugin(), 49 | new MiniCssExtractPlugin( 50 | { 51 | filename: outputCss 52 | } 53 | ), 54 | new webpack.ProvidePlugin( 55 | { 56 | $: 'jquery', 57 | jQuery: 'jquery' 58 | } 59 | ), 60 | new webpack.DefinePlugin( 61 | { 62 | 'process.env': { 63 | NODE_ENV: JSON.stringify( process.env.NODE_ENV || 'development' ) 64 | } 65 | } 66 | ), 67 | new ManifestPlugin() 68 | ]; 69 | 70 | const allOptimizations = { 71 | runtimeChunk: false, 72 | splitChunks: { 73 | cacheGroups: { 74 | commons: { 75 | test: /[\\/]node_modules[\\/]/, 76 | name: 'vendors', 77 | chunks: 'all' 78 | } 79 | } 80 | } 81 | }; 82 | 83 | // Use only for production build. 84 | if ( ! DEV ) { 85 | allOptimizations.minimizer = [ 86 | new TerserPlugin({ 87 | cache: true, 88 | parallel: true, 89 | sourceMap: true 90 | }) 91 | ]; 92 | 93 | allPlugins.push( 94 | new CreateFileWebpack({ 95 | path: './assets/build/', 96 | fileName: 'licenses.json', 97 | content: JSON.stringify( Licenses, null, 2 ) 98 | }), 99 | 100 | new FileManagerPlugin({ 101 | onEnd: [ 102 | { 103 | copy: [ 104 | { 105 | source: './', 106 | destination: './theme-sniffer' 107 | } 108 | ] 109 | }, 110 | { 111 | delete: [ 112 | './theme-sniffer/assets/dev', 113 | './theme-sniffer/node_modules', 114 | './theme-sniffer/composer.json', 115 | './theme-sniffer/composer.lock', 116 | './theme-sniffer/package.json', 117 | './theme-sniffer/package-lock.json', 118 | './theme-sniffer/phpcs.xml.dist', 119 | './theme-sniffer/webpack.config.js', 120 | './theme-sniffer/CODE_OF_CONDUCT.md', 121 | './theme-sniffer/CONTRIBUTING.md' 122 | ] 123 | }, 124 | { 125 | archive: [ 126 | { 127 | source: './theme-sniffer', 128 | destination: './theme-sniffer.zip', 129 | options: { 130 | gzip: true, 131 | gzipOptions: { level: 1 }, 132 | globOptions: { nomount: true } 133 | } 134 | } 135 | ] 136 | }, 137 | { 138 | delete: [ 139 | './theme-sniffer' 140 | ] 141 | } 142 | ] 143 | }) 144 | ); 145 | 146 | } 147 | 148 | module.exports = [ 149 | { 150 | context: path.join( appPath ), 151 | 152 | entry: { 153 | themeSniffer: [ pluginEntry ] 154 | }, 155 | 156 | output: { 157 | path: pluginPublicPath, 158 | publicPath: '', 159 | filename: outputJs 160 | }, 161 | 162 | externals: { 163 | jquery: 'jQuery', 164 | esprima: 'esprima' 165 | }, 166 | 167 | optimization: allOptimizations, 168 | 169 | module: allModules, 170 | 171 | plugins: allPlugins 172 | } 173 | ]; 174 | --------------------------------------------------------------------------------