├── .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 | [](https://travis-ci.org/WPTRT/theme-sniffer.svg?branch=master) 2 | [](https://github.com/WPTRT/theme-sniffer/blob/master/LICENSE) 3 | [](https://github.com/WPTRT/theme-sniffer/releases/) 4 | 5 | [](https://packagist.org/packages/wptrt/theme-sniffer) 6 | [](https://travis-ci.org/WPTRT/theme-sniffer) 7 | [](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 |  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 |%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
` 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 |
30 |
31 |
34 |
35 |
36 |
37 |
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 |
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 |
--------------------------------------------------------------------------------