├── .editorconfig ├── .eslintrc ├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ ├── release-assets.yml │ └── release.yml ├── .gitignore ├── .nvmrc ├── .vscode ├── extensions.json └── settings.json ├── .wp-env.json ├── .wporg ├── banner-1544x500.png ├── banner-772x250.png ├── blueprints │ └── blueprint.json ├── icon-128x128.png ├── icon-256x256.png ├── screenshot-1.png ├── screenshot-2.png ├── screenshot-3.png ├── screenshot-4.png ├── screenshot-5.png └── screenshot-6.png ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── app ├── CommonScripts.php ├── Concerns │ └── DontInstantiate.php ├── Contracts │ └── Enabler.php ├── Features │ ├── AdminBar │ │ ├── AdminBar.php │ │ └── RegisteredMenu.php │ ├── Attachment.php │ ├── Comments.php │ ├── DashboardWidgets.php │ ├── Embeds.php │ ├── Feeds.php │ ├── Gutenberg.php │ ├── Heartbeat │ │ ├── Heartbeat.php │ │ ├── ManageAdmin.php │ │ └── ManagePostEditor.php │ ├── LoginIdentifier.php │ ├── MaintenanceMode.php │ ├── MediaViewMode.php │ ├── ObfuscateUsernames.php │ ├── PrivateMode.php │ └── Updates │ │ ├── Helpers │ │ ├── AutoUpdate.php │ │ ├── AutoUpdateComponents.php │ │ ├── Updates.php │ │ └── UpdatesComponents.php │ │ ├── ManageCore.php │ │ ├── ManagePlugins.php │ │ ├── ManageThemes.php │ │ └── Updates.php ├── Helpers │ ├── Admin.php │ ├── Assets.php │ ├── Option.php │ ├── Str.php │ └── URL.php ├── InlineData.php ├── Modules │ ├── Admin.php │ ├── Advanced.php │ ├── General.php │ ├── Mail.php │ ├── Media.php │ ├── Modules.php │ ├── Security.php │ └── Site.php ├── Plugin.php └── SettingPage.php ├── composer.json ├── composer.lock ├── inc ├── bootstrap │ ├── app.php │ ├── dev.php │ └── providers.php ├── config │ └── app.php ├── languages │ └── syntatis-feature-flipper.pot ├── settings │ └── all.php └── views │ ├── maintenance-mode.php │ └── settings-page.php ├── package-lock.json ├── package.json ├── phpcs.xml.dist ├── phpstan.neon.dist ├── phpunit.xml.dist ├── readme.txt ├── scoper.inc.php ├── src ├── admin-bar │ ├── EnvironmentType │ │ ├── EnvironmentType.jsx │ │ ├── EnvironmentType.module.scss │ │ └── index.js │ └── index.js ├── comments │ └── index.js ├── embeds │ └── index.js └── setting-page │ ├── Page.jsx │ ├── Page.scss │ ├── components │ ├── Details │ │ ├── Details.jsx │ │ ├── Details.module.scss │ │ └── index.js │ ├── HelpContent │ │ ├── HelpContent.jsx │ │ ├── HelpContent.module.scss │ │ └── index.js │ ├── HelpTip │ │ ├── HelpTip.jsx │ │ ├── HelpTip.module.scss │ │ └── index.js │ └── index.js │ ├── fieldset │ ├── AdminBarFieldset.jsx │ ├── CommentsFieldset.jsx │ ├── CommentsFieldset.module.scss │ ├── DashboardWidgetsFieldset.jsx │ ├── GutenbergFieldset.jsx │ ├── HeartbeatFieldset.jsx │ ├── HeartbeatFieldset.module.scss │ ├── ImageQualityFieldset.jsx │ ├── RadioGroupFieldset.jsx │ ├── RevisionsFieldset.jsx │ ├── SiteAccessFieldset.jsx │ ├── SiteAccessFieldset.module.scss │ ├── SwitchFieldset.jsx │ ├── TextFieldset.jsx │ ├── UpdatesFieldset.jsx │ ├── UpdatesFieldset.module.scss │ ├── index.js │ └── styles.module.scss │ ├── form │ ├── Fieldset.jsx │ ├── Fieldset.module.scss │ ├── Form.jsx │ ├── Form.module.scss │ ├── FormNotice.jsx │ ├── FormNotice.module.scss │ ├── SettingsProvider.jsx │ ├── SubmitButton.jsx │ ├── index.js │ ├── useSettings.js │ └── useSettingsContext.js │ ├── index.js │ └── tabs │ ├── AdminTab.jsx │ ├── AdvancedTab.jsx │ ├── AdvancedTab.module.scss │ ├── GeneralTab.jsx │ ├── MailTab.jsx │ ├── MediaTab.jsx │ ├── SecurityTab.jsx │ ├── SiteTab.jsx │ └── index.js ├── syntatis-feature-flipper.php ├── tests ├── phpstan │ └── woocommerce-stubs.php └── phpunit │ ├── WPTestCase.php │ ├── WithAdminBar.php │ ├── app │ ├── Features │ │ ├── AdminBarTest.php │ │ ├── AttachmentTest.php │ │ ├── CommentsTest.php │ │ ├── DashboardWidgetsTest.php │ │ ├── GutenbergTest.php │ │ ├── Heartbeat │ │ │ ├── HeartbeatTest.php │ │ │ ├── ManageAdminTest.php │ │ │ └── ManagePostEditorTest.php │ │ ├── LoginIdentifierTest.php │ │ ├── MaintenanceModeTest.php │ │ ├── ObfuscateUsernamesTest.php │ │ ├── PrivateModeTest.php │ │ ├── Updates │ │ │ ├── Helpers │ │ │ │ ├── AutoUpdateTest.php │ │ │ │ └── UpdatesTest.php │ │ │ ├── ManageCoreTest.php │ │ │ ├── ManagePluginsTest.php │ │ │ └── ManageThemesTest.php │ │ └── UpdatesTest.php │ ├── Helpers │ │ ├── AdminTest.php │ │ ├── OptionTest.php │ │ └── StrTest.php │ ├── InlineDataTest.php │ └── Modules │ │ ├── GeneralTest.php │ │ └── MailTest.php │ └── bootstrap.php ├── uninstall.php └── webpack.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | indent_style = tab 9 | indent_size = 4 10 | 11 | [*.json] 12 | indent_style = space 13 | 14 | [*.{yml,yaml,neon,neon.dist}] 15 | indent_style = space 16 | indent_size = 2 17 | 18 | [*.md] 19 | trim_trailing_whitespace = false 20 | 21 | [{*.txt,wp-config-sample.php}] 22 | end_of_line = crlf 23 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ "plugin:@wordpress/eslint-plugin/recommended" ] 3 | } 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | labels: 8 | - "dependencies" 9 | - "ci-cd" 10 | 11 | - package-ecosystem: "composer" 12 | versioning-strategy: increase 13 | open-pull-requests-limit: 5 14 | directory: "/" 15 | schedule: 16 | interval: "weekly" 17 | day: "friday" 18 | labels: 19 | - "dependencies" 20 | - "composer" 21 | ignore: 22 | - dependency-name: "symfony/*" 23 | versions: [">=6.0"] 24 | groups: 25 | composer-require: 26 | dependency-type: "production" 27 | update-types: 28 | - "minor" 29 | - "patch" 30 | composer-require-dev: 31 | dependency-type: "development" 32 | update-types: 33 | - "minor" 34 | - "patch" 35 | 36 | - package-ecosystem: "npm" 37 | versioning-strategy: increase 38 | open-pull-requests-limit: 5 39 | directory: "/" 40 | schedule: 41 | interval: "weekly" 42 | day: "sunday" 43 | labels: 44 | - "dependencies" 45 | - "npm" 46 | allow: 47 | - dependency-type: "direct" 48 | groups: 49 | npm-dependencies: 50 | dependency-type: "production" 51 | update-types: 52 | - "minor" 53 | - "patch" 54 | npm-dev-dependencies: 55 | dependency-type: "development" 56 | update-types: 57 | - "minor" 58 | - "patch" 59 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | branches: 7 | - main 8 | paths: 9 | - ".github/workflows/ci.yml" 10 | - "**.php" 11 | - "composer.json" 12 | - "phpcs.xml.dist" 13 | - "phpstan.neon.dist" 14 | - "phpunit.xml.dist" 15 | 16 | push: 17 | branches: 18 | - main 19 | paths: 20 | - ".github/workflows/ci.yml" 21 | - "**.php" 22 | - "composer.json" 23 | - "phpcs.xml.dist" 24 | - "phpstan.neon.dist" 25 | - "phpunit.xml.dist" 26 | 27 | jobs: 28 | checks: 29 | runs-on: ubuntu-latest 30 | steps: 31 | - name: Checkout code 32 | uses: actions/checkout@v4 33 | 34 | - name: Setup PHP 35 | uses: shivammathur/setup-php@v2 36 | with: 37 | php-version: "7.4" 38 | 39 | - name: Install PHP dependencies 40 | uses: ramsey/composer-install@v3 41 | 42 | - name: Run PHPCS 43 | run: composer run phpcs 44 | 45 | - name: Run PHPStan 46 | run: composer run phpstan 47 | 48 | tests: 49 | runs-on: ubuntu-latest 50 | strategy: 51 | fail-fast: true 52 | max-parallel: 2 53 | matrix: 54 | php-version: ["7.4", "8.0", "8.1", "8.2", "8.3", "8.4"] 55 | wp-core-version: ["WordPress/WordPress", "WordPress/WordPress#6.0"] 56 | 57 | steps: 58 | - name: Checkout code 59 | uses: actions/checkout@v4 60 | 61 | - name: Setup Node.js 62 | uses: actions/setup-node@v4 63 | with: 64 | cache: "npm" 65 | node-version: "20.x" 66 | 67 | - name: Setup PHP 68 | uses: shivammathur/setup-php@v2 69 | with: 70 | php-version: "7.4" 71 | tools: composer:v2 72 | 73 | - name: Get Docker version 74 | run: docker -v 75 | 76 | - name: Install NodeJS dependencies 77 | run: npm ci 78 | 79 | - name: Install PHP dependencies 80 | uses: ramsey/composer-install@v3 81 | 82 | - name: Start wp-env 83 | run: | 84 | jq -n \ 85 | --arg phpVersion "${{ matrix.php-version }}" \ 86 | --arg wpCoreVersion "${{ matrix.wp-core-version }}" \ 87 | '{"phpVersion":$phpVersion, "core":$wpCoreVersion}' \ 88 | > .wp-env.override.json 89 | npm run wp-env:start 90 | npm run wp-env:tests-wordpress php -- -v 91 | 92 | - name: Run PHPUnit 93 | run: composer run phpunit 94 | 95 | typos: 96 | runs-on: ubuntu-latest 97 | steps: 98 | - name: Checkout code 99 | uses: actions/checkout@v4 100 | 101 | - name: Check spelling 102 | uses: crate-ci/typos@v1.32.0 103 | -------------------------------------------------------------------------------- /.github/workflows/release-assets.yml: -------------------------------------------------------------------------------- 1 | name: release-assets 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | release: 8 | runs-on: ubuntu-22.04 9 | steps: 10 | - name: Checkout code 11 | uses: actions/checkout@v4 12 | 13 | - name: Push Assets to WordPress.org 14 | uses: 10up/action-wordpress-plugin-asset-update@stable 15 | env: 16 | SLUG: syntatis-feature-flipper 17 | ASSETS_DIR: .wporg 18 | IGNORE_OTHER_FILES: true 19 | SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }} 20 | SVN_USERNAME: ${{ secrets.SVN_USERNAME }} 21 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | workflow_dispatch: 5 | release: 6 | types: [released] 7 | 8 | env: 9 | PLUGIN_SLUG: syntatis-feature-flipper 10 | 11 | jobs: 12 | release: 13 | runs-on: ubuntu-22.04 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v4 17 | 18 | - name: Setup PHP 19 | uses: shivammathur/setup-php@v2 20 | with: 21 | php-version: "7.4" 22 | tools: composer:v2 23 | 24 | - name: Install dependencies 25 | run: | 26 | npm install 27 | composer install --prefer-dist --no-interaction --no-progress --optimize-autoloader 28 | 29 | - name: Build plugin 30 | run: | 31 | npm run build 32 | composer run build 33 | composer run plugin:zip -- --file=build 34 | unzip build.zip -d build 35 | 36 | - name: "Check latest tagged version" 37 | if: ${{ github.event_name == 'release' }} 38 | run: | 39 | LATEST_TAG="${{ github.ref_name }}"; 40 | HEADER_VERSION="v$(sed -n -e '0,/^\s*\* Version:\s\+\([0-9].\+\)$/ s##\1#p' syntatis-feature-flipper.php)"; 41 | PLUGIN_VERSION="v$(sed -n -e "0,/^const PLUGIN_VERSION = '\\([0-9].\\+\\)';$/ s##\\1#p" syntatis-feature-flipper.php)"; 42 | README_VERSION="v$(sed -n -e '0,/^Stable tag: \([0-9].\+\)$/ s##\1#p' readme.txt | tr -d '[:space:]')"; 43 | if [[ "${LATEST_TAG}" != "${HEADER_VERSION}" || "${LATEST_TAG}" != "${PLUGIN_VERSION}" || "${LATEST_TAG}" != "${README_VERSION}" ]]; 44 | then 45 | echo "::error::Latest tag differs from current version" 46 | exit 10 47 | fi 48 | 49 | - name: Deploy to WordPress.org 50 | uses: 10up/action-wordpress-plugin-deploy@stable 51 | with: 52 | dry-run: ${{ github.event_name != 'release' }} 53 | env: 54 | ASSETS_DIR: .wporg 55 | BUILD_DIR: build 56 | SLUG: ${{ env.PLUGIN_SLUG }} 57 | SVN_USERNAME: ${{ secrets.SVN_USERNAME }} 58 | SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }} 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OS generated files # 2 | ###################### 3 | .DS_Store 4 | Thumbs.db 5 | 6 | # Logs and databases # 7 | ###################### 8 | *.sql 9 | *.log 10 | 11 | # Compiled source # 12 | ################### 13 | *.tar.gz 14 | *.zip 15 | dist* 16 | build* 17 | @* 18 | 19 | # Dependencies # 20 | ########################## 21 | node_modules 22 | vendor* 23 | 24 | # Temporary files # 25 | ############# 26 | tmp 27 | .phpunit* 28 | *.cache 29 | 30 | # wp-env # 31 | ##################### 32 | .wp-env.override* 33 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 2 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "bmewburn.vscode-intelephense-client", 4 | "christian-kohler.path-intellisense", 5 | "dotjoshjohnson.xml", 6 | "editorconfig.editorconfig", 7 | "kasik96.latte", 8 | "mikestead.dotenv", 9 | "ms-vscode-remote.remote-containers", 10 | "neilbrayfield.php-docblocker", 11 | "sanderronde.phpstan-vscode", 12 | "wmaurer.change-case", 13 | "valeryanm.vscode-phpsab", 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "intelephense.stubs": [ 3 | "apache", 4 | "bcmath", 5 | "bz2", 6 | "calendar", 7 | "com_dotnet", 8 | "Core", 9 | "ctype", 10 | "curl", 11 | "date", 12 | "dba", 13 | "dom", 14 | "enchant", 15 | "exif", 16 | "FFI", 17 | "fileinfo", 18 | "filter", 19 | "fpm", 20 | "ftp", 21 | "gd", 22 | "gettext", 23 | "gmp", 24 | "hash", 25 | "iconv", 26 | "imap", 27 | "intl", 28 | "json", 29 | "ldap", 30 | "libxml", 31 | "mbstring", 32 | "meta", 33 | "mysqli", 34 | "oci8", 35 | "odbc", 36 | "openssl", 37 | "pcntl", 38 | "pcre", 39 | "PDO", 40 | "pgsql", 41 | "Phar", 42 | "posix", 43 | "pspell", 44 | "readline", 45 | "redis", 46 | "Reflection", 47 | "session", 48 | "shmop", 49 | "SimpleXML", 50 | "snmp", 51 | "soap", 52 | "sockets", 53 | "sodium", 54 | "SPL", 55 | "sqlite3", 56 | "standard", 57 | "superglobals", 58 | "sysvmsg", 59 | "sysvsem", 60 | "sysvshm", 61 | "tidy", 62 | "tokenizer", 63 | "wordpress", 64 | "xml", 65 | "xmlreader", 66 | "xmlrpc", 67 | "xmlwriter", 68 | "xsl", 69 | "Zend OPcache", 70 | "zip", 71 | "zlib" 72 | ], 73 | "[json]": { 74 | "editor.defaultFormatter": "vscode.json-language-features" 75 | }, 76 | "[php]": { 77 | "editor.defaultFormatter": "wongjn.php-sniffer" 78 | }, 79 | "files.exclude": { 80 | "**/.git": true, 81 | "**/.svn": true, 82 | "**/.hg": true, 83 | "**/.DS_Store": true, 84 | "**/Thumbs.db": true 85 | }, 86 | "search.exclude": { 87 | "**/dist": true 88 | }, 89 | "intelephense.files.exclude": [ 90 | "**/.git/**", 91 | "**/.svn/**", 92 | "**/.hg/**", 93 | "**/.DS_Store/**", 94 | "**/.history/**", 95 | "**/CVS/**" 96 | ], 97 | "phpSniffer.autoDetect": true, 98 | "phpSniffer.run": "onSave" 99 | } 100 | -------------------------------------------------------------------------------- /.wp-env.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schemas.wp.org/trunk/wp-env.json", 3 | "phpVersion": "7.4", 4 | "core": "https://wordpress.org/latest.zip", 5 | "plugins": [ 6 | "." 7 | ], 8 | "port": 8801, 9 | "config": { 10 | "WP_DEBUG_LOG": true 11 | }, 12 | "env": { 13 | "tests": { 14 | "port": 8901 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.wporg/banner-1544x500.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syntatis/wp-feature-flipper/b3f304cfcc0c554dff673f42b9e3b43ea0407b56/.wporg/banner-1544x500.png -------------------------------------------------------------------------------- /.wporg/banner-772x250.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syntatis/wp-feature-flipper/b3f304cfcc0c554dff673f42b9e3b43ea0407b56/.wporg/banner-772x250.png -------------------------------------------------------------------------------- /.wporg/blueprints/blueprint.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://playground.wordpress.net/blueprint-schema.json", 3 | "preferredVersions": { 4 | "php": "7.4", 5 | "wp": "latest" 6 | }, 7 | "landingPage": "/wp-admin/options-general.php?page=syntatis-feature-flipper", 8 | "steps": [ 9 | { 10 | "step": "login" 11 | }, 12 | { 13 | "step": "installPlugin", 14 | "pluginData": { 15 | "resource": "wordpress.org/plugins", 16 | "slug": "syntatis-feature-flipper" 17 | }, 18 | "options": { 19 | "activate": true 20 | } 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /.wporg/icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syntatis/wp-feature-flipper/b3f304cfcc0c554dff673f42b9e3b43ea0407b56/.wporg/icon-128x128.png -------------------------------------------------------------------------------- /.wporg/icon-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syntatis/wp-feature-flipper/b3f304cfcc0c554dff673f42b9e3b43ea0407b56/.wporg/icon-256x256.png -------------------------------------------------------------------------------- /.wporg/screenshot-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syntatis/wp-feature-flipper/b3f304cfcc0c554dff673f42b9e3b43ea0407b56/.wporg/screenshot-1.png -------------------------------------------------------------------------------- /.wporg/screenshot-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syntatis/wp-feature-flipper/b3f304cfcc0c554dff673f42b9e3b43ea0407b56/.wporg/screenshot-2.png -------------------------------------------------------------------------------- /.wporg/screenshot-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syntatis/wp-feature-flipper/b3f304cfcc0c554dff673f42b9e3b43ea0407b56/.wporg/screenshot-3.png -------------------------------------------------------------------------------- /.wporg/screenshot-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syntatis/wp-feature-flipper/b3f304cfcc0c554dff673f42b9e3b43ea0407b56/.wporg/screenshot-4.png -------------------------------------------------------------------------------- /.wporg/screenshot-5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syntatis/wp-feature-flipper/b3f304cfcc0c554dff673f42b9e3b43ea0407b56/.wporg/screenshot-5.png -------------------------------------------------------------------------------- /.wporg/screenshot-6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syntatis/wp-feature-flipper/b3f304cfcc0c554dff673f42b9e3b43ea0407b56/.wporg/screenshot-6.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Banner](.wporg/banner-1544x500.png) 2 | 3 |
4 | 5 | [![CI](https://github.com/syntatis/wp-feature-flipper/actions/workflows/ci.yml/badge.svg)](https://github.com/syntatis/wp-feature-flipper/actions/workflows/ci.yml) 6 | [![WordPress Plugin Required PHP Version](https://img.shields.io/wordpress/plugin/required-php/syntatis-feature-flipper?label=php&color=7a86b8)](https://wordpress.org/plugins/syntatis-feature-flipper/) 7 | [![WordPress Plugin Required WP Version](https://img.shields.io/wordpress/plugin/wp-version/syntatis-feature-flipper?logo=wordpress&label=min&color=4f94d4)](https://wordpress.org/plugins/syntatis-feature-flipper/) 8 | [![WordPress Plugin Tested WP Version](https://img.shields.io/wordpress/plugin/tested/syntatis-feature-flipper?logo=wordpress&label=up-to&color=4f94d4)](https://wordpress.org/plugins/syntatis-feature-flipper/) 9 | [![WordPress Plugin Version](https://img.shields.io/wordpress/plugin/v/syntatis-feature-flipper?logo=wordpress&logoColor=fff&label=playground&labelColor=3858e9&color=3858e9)](https://playground.wordpress.net/?blueprint-url=https://raw.githubusercontent.com/syntatis/wp-feature-flipper/main/.wporg/blueprints/blueprint.json) 10 | 11 |
12 | 13 | --- 14 | 15 | > [!NOTE] 16 | > This plugin is built to serve as a showcase for [Howdy](https://github.com/syntatis/howdy), a starter kit for developing WordPress® plugins. 17 | 18 | This plugin gives you the ability to manage core WordPress features like Comments, the Block Editor (Gutenberg), Emojis, XML-RPC, Feeds, Updates, Automatic Updates, Cron, Heartbeat, and more. If you don't need certain features, you can easily toggle them off or customize their behavior. 19 | 20 | It also includes additional utility features, like showing your site's environment type in the admin bar, enabling maintenance or private mode, or using random URLs for media pages, which you can enable or disable as needed. 21 | 22 | [Read more on WordPress.org](https://wordpress.org/plugins/syntatis-feature-flipper/) 23 | 24 | ## Contributing 25 | 26 | Found a bug? [Open an issue](https://github.com/syntatis/wp-feature-flipper/issues/new) or [submit a pull request](https://github.com/syntatis/wp-feature-flipper/compare). Have an idea, or suggestion? Let's discuss it in the [Discussions](https://github.com/syntatis/wp-feature-flipper/discussions) tab. 27 | 28 | ## Security Vulnerabilities 29 | 30 | If you discover a security issue, please report it through the **Patchstack Vulnerability Disclosure Program (VDP)**. The Patchstack team will validate, triage, and assist in resolving any reported vulnerabilities to keep the plugin secure. 31 | 32 | [Report a security vulnerability](https://patchstack.com/database/wordpress/plugin/syntatis-feature-flipper/vdp) 33 | -------------------------------------------------------------------------------- /app/CommonScripts.php: -------------------------------------------------------------------------------- 1 | addAction('admin_enqueue_scripts', [$this, 'enqueueScripts']); 16 | $hook->addAction('wp_enqueue_scripts', [$this, 'enqueueScripts']); 17 | } 18 | 19 | public function enqueueScripts(): void 20 | { 21 | if (! is_user_logged_in()) { 22 | return; 23 | } 24 | 25 | wp_enqueue_style(App::name() . '-common', App::url('dist/assets/index.css')); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/Concerns/DontInstantiate.php: -------------------------------------------------------------------------------- 1 | [ 38 | 'id' => 'customize', 39 | 'parent' => false, 40 | ], 41 | 'edit' => [ 42 | 'id' => 'edit', 43 | 'parent' => false, 44 | ], 45 | 'search' => [ 46 | 'id' => 'search', 47 | 'parent' => false, 48 | ], 49 | ]; 50 | 51 | private ?WP_Admin_Bar $wpAdminBar; 52 | 53 | /** @phpstan-var array */ 54 | private array $registeredMenu; 55 | 56 | private function __construct() 57 | { 58 | /** @var WP_Admin_Bar|null $wpAdminBar */ 59 | $wpAdminBar = $GLOBALS['wp_admin_bar'] ?? null; 60 | 61 | $this->wpAdminBar = $wpAdminBar; 62 | $this->registeredMenu = $this->getRegisteredMenu(); 63 | } 64 | 65 | /** 66 | * @phpstan-param "top"|null $level The level of the menu to retrieve. 67 | * 68 | * @phpstan-return array 69 | */ 70 | public static function all(?string $level = null): array 71 | { 72 | $instance = new self(); 73 | 74 | switch ($level) { 75 | case 'top': 76 | return $instance->getTopItems(); 77 | 78 | default: 79 | return $instance->registeredMenu; 80 | } 81 | } 82 | 83 | /** 84 | * Retrieve top-level menu of the admin bar. 85 | * 86 | * @phpstan-return array 87 | */ 88 | private function getTopItems(): array 89 | { 90 | $topItems = []; 91 | 92 | foreach ($this->registeredMenu as $key => $menu) { 93 | $menuParent = $menu['parent'] ?? null; 94 | 95 | /** 96 | * If the node is not a top-level node or a node within the `top-secondary`, 97 | * skip it. The `top-secondary` section may contain some menus items such 98 | * as the "environment type" and "site status" menu. Even though these 99 | * menu are technically not top-level menus, they are considered as 100 | * such since users can see them on the top-level and might want 101 | * to toggle them on or off. 102 | * 103 | * @see Syntatis\FeatureFlipper\Features\AdminBar::addEnvironmentTypeNode() 104 | * @see Syntatis\FeatureFlipper\Features\MaintenanceMode 105 | * @see Syntatis\FeatureFlipper\Features\PrivateMode 106 | */ 107 | if ($menuParent !== false && $menuParent !== 'top-secondary') { 108 | continue; 109 | } 110 | 111 | if (in_array($key, self::getExcludedMenu(), true)) { 112 | continue; 113 | } 114 | 115 | $topItems[$key] = $menu; 116 | } 117 | 118 | return $topItems; 119 | } 120 | 121 | /** @phpstan-return array */ 122 | private function getRegisteredMenu(): array 123 | { 124 | if (! $this->wpAdminBar instanceof WP_Admin_Bar) { 125 | return []; 126 | } 127 | 128 | /** @phpstan-var array|null $nodes */ 129 | $nodes = $this->wpAdminBar->get_nodes(); 130 | 131 | if (! is_array($nodes)) { 132 | return []; 133 | } 134 | 135 | $items = []; 136 | 137 | foreach ($nodes as $id => $node) { 138 | $id = property_exists($node, 'id') && is_string($node->id) ? 139 | $node->id : 140 | null; 141 | 142 | if ($id === '' || $id === null) { 143 | continue; 144 | } 145 | 146 | $parent = property_exists($node, 'parent') && is_string($node->parent) ? 147 | $node->parent : 148 | false; 149 | 150 | $items[$id] = [ 151 | 'id' => $id, 152 | 'parent' => $parent, 153 | ]; 154 | } 155 | 156 | return array_merge($items, self::INCLUDE_CORE_MENU_ITEMS); 157 | } 158 | 159 | /** @phpstan-return list */ 160 | private static function getExcludedMenu(): array 161 | { 162 | $excludes = self::EXCLUDE_MENU_ITEMS; 163 | 164 | if (! Option::isOn('comments')) { 165 | $excludes = [ 166 | ...$excludes, 167 | 'comments', 168 | ]; 169 | } 170 | 171 | return $excludes; 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /app/Features/Attachment.php: -------------------------------------------------------------------------------- 1 | addFilter(Option::hook('default:attachment_page'), static function (): bool { 21 | /** 22 | * In WordPress 6.4, A new option, `wp_attachment_pages_enabled` is introduced 23 | * to control the attachment page behavior. 24 | * 25 | * - For new sites, the option will be set to `0` by default, and the attachment 26 | * page will be disabled. 27 | * - For existing sites, the option will be set to `1` after the upgrade and 28 | * the attachment page will 29 | * remain enabled. 30 | * 31 | * By default, this plugin will follow this default behavior. 32 | * 33 | * @see https://make.wordpress.org/core/2023/10/16/changes-to-attachment-pages/ 34 | */ 35 | $enabled = get_option('wp_attachment_pages_enabled', null); 36 | 37 | return $enabled === '1' || $enabled === null; 38 | }); 39 | $hook->addAction( 40 | Option::hook('add:attachment_page'), 41 | static function ($option, $value): void { 42 | update_option('wp_attachment_pages_enabled', (bool) $value ? '1' : '0'); 43 | }, 44 | 10, 45 | 2, 46 | ); 47 | $hook->addAction( 48 | Option::hook('update:attachment_page'), 49 | static function ($oldValue, $newValue): void { 50 | update_option('wp_attachment_pages_enabled', (bool) $newValue ? '1' : '0'); 51 | }, 52 | 10, 53 | 2, 54 | ); 55 | 56 | // 1. Attachment Page. 57 | if (! Option::isOn('attachment_page')) { 58 | $hook->addAction('template_redirect', function (): void { 59 | if (! is_attachment()) { 60 | return; 61 | } 62 | 63 | $this->toNotFound(); 64 | }); 65 | 66 | $hook->addFilter( 67 | 'redirect_canonical', 68 | /* @phpstan-ignore shipmonk.missingNativeReturnTypehint */ 69 | function (string $url) { 70 | if (! is_attachment()) { 71 | return $url; 72 | } 73 | 74 | $this->toNotFound(); 75 | }, 76 | ); 77 | 78 | /** 79 | * Replace the link to "View Attachment Page" with the actual attachment URL. 80 | */ 81 | $hook->addFilter('attachment_link', static function (string $url, int $id): string { 82 | $attachmentUrl = wp_get_attachment_url($id); 83 | 84 | if (is_string($attachmentUrl)) { 85 | return $attachmentUrl; 86 | } 87 | 88 | return $url; 89 | }, 99, 2); 90 | } 91 | 92 | if (Option::isOn('attachment_slug')) { 93 | return; 94 | } 95 | 96 | $hook->addFilter( 97 | 'wp_unique_post_slug', 98 | static function (string $slug, string $id, string $status, string $type): string { 99 | if ($type !== 'attachment' || Uuid::isValid($slug)) { 100 | return $slug; 101 | } 102 | 103 | try { 104 | return (string) Uuid::v5(Uuid::fromString(Uuid::NAMESPACE_URL), $slug); 105 | } catch (Throwable $th) { 106 | return $slug; 107 | } 108 | }, 109 | 99, 110 | 4, 111 | ); 112 | } 113 | 114 | private function toNotFound(): void 115 | { 116 | /** @var WP_Query|null $wpQuery */ 117 | $wpQuery = $GLOBALS['wp_query'] ?? null; 118 | 119 | if ($wpQuery === null) { 120 | return; 121 | } 122 | 123 | $wpQuery->set_404(); 124 | status_header(404); 125 | nocache_headers(); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /app/Features/Embeds.php: -------------------------------------------------------------------------------- 1 | addAction('init', fn () => $this->disables($hook), PHP_INT_MAX); 31 | } 32 | 33 | private function disables(Hook $hook): void 34 | { 35 | // phpcs:disable 36 | /** @var WP $wp */ 37 | $wp = $GLOBALS['wp']; 38 | $wp->public_query_vars = array_diff($wp->public_query_vars, ['embed']); 39 | // phpcs:enable 40 | 41 | $hook->addAction(Option::hook('update:cron'), [$this, 'flushPermalinks']); 42 | $hook->addAction('enqueue_block_editor_assets', [$this, 'disableOnBlockEditor']); 43 | $hook->addAction('wp_default_scripts', [$this, 'disableScriptDependencies']); 44 | $hook->addFilter('embed_oembed_discover', '__return_false'); 45 | $hook->addFilter('oembed_response_data', [$this, 'disableResponseData']); 46 | $hook->addFilter('rest_endpoints', [$this, 'disableEndpoint']); 47 | $hook->addFilter('rewrite_rules_array', [$this, 'disableRewrite']); 48 | $hook->addFilter('tiny_mce_plugins', [$this, 'disableOnTinyEditor']); 49 | 50 | $hook->removeAction('wp_head', 'wp_oembed_add_discovery_links'); 51 | $hook->removeAction('wp_head', 'wp_oembed_add_host_js'); 52 | $hook->removeFilter('oembed_dataparse', 'wp_filter_oembed_result', 10); 53 | $hook->removeFilter('pre_oembed_result', 'wp_filter_pre_oembed_result', 10); 54 | } 55 | 56 | public function flushPermalinks(): void 57 | { 58 | flush_rewrite_rules(false); 59 | } 60 | 61 | /** 62 | * @param array $data 63 | * 64 | * @return array|false 65 | */ 66 | public function disableResponseData(array $data) 67 | { 68 | if (defined('REST_REQUEST') && REST_REQUEST) { 69 | return false; 70 | } 71 | 72 | return $data; 73 | } 74 | 75 | /** 76 | * @param array $endpoints 77 | * 78 | * @return array 79 | */ 80 | public function disableEndpoint(array $endpoints): array 81 | { 82 | if (isset($endpoints['/oembed/1.0/embed'])) { 83 | unset($endpoints['/oembed/1.0/embed']); 84 | } 85 | 86 | return $endpoints; 87 | } 88 | 89 | /** 90 | * @param array $rules 91 | * 92 | * @return array 93 | */ 94 | public function disableRewrite(array $rules): array 95 | { 96 | foreach ($rules as $rule => $rewrite) { 97 | if (is_string($rewrite) && strpos($rewrite, 'embed=true') === false) { 98 | continue; 99 | } 100 | 101 | unset($rules[$rule]); 102 | } 103 | 104 | return $rules; 105 | } 106 | 107 | /** 108 | * @param array $plugins 109 | * 110 | * @return array 111 | */ 112 | public function disableOnTinyEditor(array $plugins): array 113 | { 114 | return array_diff($plugins, ['wpembed']); 115 | } 116 | 117 | public function disableOnBlockEditor(): void 118 | { 119 | $assetFile = App::dir('dist/assets/embeds/index.asset.php'); 120 | 121 | /** @phpstan-var array{dependencies?:array,version?:string} $asset */ 122 | $asset = is_readable($assetFile) ? require $assetFile : []; 123 | $asset['dependencies'] ??= []; 124 | $asset['version'] ??= null; 125 | 126 | wp_enqueue_script( 127 | App::name() . '-embeds', 128 | App::url('dist/assets/embeds/index.js'), 129 | $asset['dependencies'], 130 | $asset['version'], 131 | true, 132 | ); 133 | } 134 | 135 | public function disableScriptDependencies(WP_Scripts $scripts): void 136 | { 137 | if (! isset($scripts->registered['wp-edit-post'])) { 138 | return; 139 | } 140 | 141 | $scripts->registered['wp-edit-post']->deps = array_diff( 142 | $scripts->registered['wp-edit-post']->deps, 143 | ['wp-embed'], 144 | ); 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /app/Features/Feeds.php: -------------------------------------------------------------------------------- 1 | addAction('do_feed', [$this, 'toHomepage'], PHP_INT_MIN); 23 | $hook->addAction('do_feed_rdf', [$this, 'toHomepage'], PHP_INT_MIN); 24 | $hook->addAction('do_feed_rss', [$this, 'toHomepage'], PHP_INT_MIN); 25 | $hook->addAction('do_feed_rss2', [$this, 'toHomepage'], PHP_INT_MIN); 26 | $hook->addAction('do_feed_atom', [$this, 'toHomepage'], PHP_INT_MIN); 27 | 28 | // Disable comments feeds. 29 | $hook->addAction('do_feed_rss2_comments', [$this, 'toHomepage'], PHP_INT_MIN); 30 | $hook->addAction('do_feed_atom_comments', [$this, 'toHomepage'], PHP_INT_MIN); 31 | 32 | // Remove RSS feed links. 33 | $hook->addFilter('feed_links_show_posts_feed', '__return_false', 99); 34 | 35 | // Remove all extra RSS feed links. 36 | $hook->addFilter('feed_links_show_comments_feed', '__return_false', 99); 37 | } 38 | 39 | public function toHomepage(): void 40 | { 41 | wp_redirect(home_url()); 42 | exit; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /app/Features/Gutenberg.php: -------------------------------------------------------------------------------- 1 | addAction('syntatis/feature_flipper/updated_options', [$this, 'stashOptions']); 25 | $hook->addFilter('use_block_editor_for_post', [$this, 'filterUseBlockEditorForPost'], PHP_INT_MAX, 2); 26 | $hook->addFilter( 27 | Option::hook('default:gutenberg_post_types'), 28 | static fn () => self::getPostTypes(), 29 | PHP_INT_MAX, 30 | ); 31 | $hook->addFilter( 32 | Option::hook('gutenberg_post_types'), 33 | static fn ($value) => Option::patch( 34 | 'gutenberg_post_types', 35 | is_array($value) ? $value : [], 36 | self::getPostTypes(), 37 | ), 38 | PHP_INT_MAX, 39 | ); 40 | } 41 | 42 | /** @param array $options List of option names that have been updated. */ 43 | public function stashOptions(array $options): void 44 | { 45 | if (! in_array(Option::name('gutenberg_post_types'), $options, true)) { 46 | return; 47 | } 48 | 49 | Option::stash('gutenberg_post_types', self::getPostTypes()); 50 | } 51 | 52 | /** 53 | * Filter the value to determine whether to use the block editor for a post. 54 | * 55 | * @see https://developer.wordpress.org/reference/hooks/use_block_editor_for_post/ 56 | * 57 | * @param int|WP_Post $post 58 | */ 59 | public function filterUseBlockEditorForPost(bool $value, $post): bool 60 | { 61 | // If the Gutenberg feature is disabled, force the classic editor. 62 | if (! Option::isOn('gutenberg')) { 63 | return false; 64 | } 65 | 66 | $wpPost = is_int($post) ? get_post($post) : $post; 67 | 68 | if (! $wpPost instanceof WP_Post) { 69 | return $value; 70 | } 71 | 72 | $postTypes = Option::get('gutenberg_post_types'); 73 | $postTypes = is_array($postTypes) ? $postTypes : []; 74 | 75 | // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps -- WordPress convention. 76 | return in_array($wpPost->post_type, $postTypes, true); 77 | } 78 | 79 | /** @phpstan-return list */ 80 | private static function getPostTypes(): array 81 | { 82 | return array_values(array_filter( 83 | get_post_types(['public' => true]), 84 | static fn ($key): bool => use_block_editor_for_post_type($key), 85 | )); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /app/Features/Heartbeat/Heartbeat.php: -------------------------------------------------------------------------------- 1 | addAction('init', [$this, 'deregisterScripts'], PHP_INT_MAX); 33 | } 34 | 35 | public function deregisterScripts(): void 36 | { 37 | if (Option::isOn('heartbeat')) { 38 | return; 39 | } 40 | 41 | /** 42 | * If the feature is disabled, deregister the Heartbeat API script, which 43 | * effectively stopping all the Heartbeat API requests on all pages. 44 | */ 45 | wp_deregister_script('heartbeat'); 46 | } 47 | 48 | /** @return iterable */ 49 | public function getInstances(ContainerInterface $container): iterable 50 | { 51 | yield new ManageAdmin(); 52 | yield new ManagePostEditor(); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /app/Features/Heartbeat/ManageAdmin.php: -------------------------------------------------------------------------------- 1 | addAction('admin_init', [$this, 'deregisterScripts'], PHP_INT_MAX); 21 | $hook->addFilter('heartbeat_settings', [$this, 'filterSettings'], PHP_INT_MAX); 22 | 23 | /** 24 | * Filter the Heartbeat settings for the admin area. 25 | * 26 | * When Heartbeat is disabled from the global option, it should also disable 27 | * the "heartbeat_admin" option. 28 | */ 29 | $hook->addFilter( 30 | Option::hook('heartbeat_admin'), 31 | static fn ($value) => Option::isOn('heartbeat') ? $value : false, 32 | ); 33 | $hook->addFilter( 34 | Option::hook('default:heartbeat_admin'), 35 | static fn ($value) => Option::isOn('heartbeat') ? $value : false, 36 | ); 37 | } 38 | 39 | /** 40 | * Deregister the Heartbeat script in the admin area. 41 | */ 42 | public function deregisterScripts(): void 43 | { 44 | if ( 45 | ! is_admin() || 46 | self::isPostEditor() || 47 | Option::isOn('heartbeat_admin') 48 | ) { 49 | return; 50 | } 51 | 52 | wp_deregister_script('heartbeat'); 53 | } 54 | 55 | /** 56 | * Filter the Heartbeat settings for the admin area. 57 | * 58 | * @param array $settings 59 | * 60 | * @return array 61 | */ 62 | public function filterSettings(array $settings = []): array 63 | { 64 | /** 65 | * If it's not admin, return the settings as is. 66 | * 67 | * In the post editor, even though that it is on the admin area, settings 68 | * should also return as is as well, since the settings for post editor 69 | * would be applied from a separate class. 70 | * 71 | * Don't modify the interval when the feature is off. When is is off, the 72 | * heartbeat script is deregistered, so modifying the interval is not 73 | * required. 74 | * 75 | * @see \Syntatis\FeatureFlipper\Features\Heartbeat\ManagePostEditor 76 | */ 77 | if ( 78 | ! is_admin() || 79 | ! Option::isOn('heartbeat_admin') || 80 | self::isPostEditor() 81 | ) { 82 | return $settings; 83 | } 84 | 85 | $interval = Option::get('heartbeat_admin_interval'); 86 | 87 | if (is_numeric($interval)) { 88 | $interval = absint($interval); 89 | 90 | $settings['interval'] = $interval; 91 | $settings['minimalInterval'] = $interval; 92 | } 93 | 94 | return $settings; 95 | } 96 | 97 | private static function isPostEditor(): bool 98 | { 99 | return Admin::isScreen('post.php') || Admin::isScreen('post-new.php'); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /app/Features/Heartbeat/ManagePostEditor.php: -------------------------------------------------------------------------------- 1 | addAction('admin_init', [$this, 'deregisterScripts'], PHP_INT_MAX); 21 | $hook->addFilter('heartbeat_settings', [$this, 'filterSettings'], PHP_INT_MAX); 22 | 23 | /** 24 | * Filter the Heartbeat settings for the post editor screen. 25 | * 26 | * When Heartbeat is disabled from the global option, it should also disable 27 | * the "heartbeat_post_editor" option. 28 | */ 29 | $hook->addFilter( 30 | Option::hook('heartbeat_post_editor'), 31 | static fn ($value) => Option::isOn('heartbeat') ? $value : false, 32 | ); 33 | $hook->addFilter( 34 | Option::hook('default:heartbeat_post_editor'), 35 | static fn ($value) => Option::isOn('heartbeat') ? $value : false, 36 | ); 37 | } 38 | 39 | /** 40 | * Deregister the Heartbeat script in the post editor screen. 41 | */ 42 | public function deregisterScripts(): void 43 | { 44 | if (! self::isPostEditor() || Option::isOn('heartbeat_post_editor')) { 45 | return; 46 | } 47 | 48 | wp_deregister_script('heartbeat'); 49 | } 50 | 51 | /** 52 | * Filter the Heartbeat settings for the post editor area. 53 | * 54 | * @param array $settings 55 | * 56 | * @return array 57 | */ 58 | public function filterSettings(array $settings): array 59 | { 60 | if ( 61 | ! self::isPostEditor() || 62 | ! Option::isOn('heartbeat_post_editor') 63 | ) { 64 | return $settings; 65 | } 66 | 67 | $interval = Option::get('heartbeat_post_editor_interval'); 68 | 69 | if (is_numeric($interval)) { 70 | $interval = absint($interval); 71 | 72 | $settings['interval'] = $interval; 73 | $settings['minimalInterval'] = $interval; 74 | } 75 | 76 | return $settings; 77 | } 78 | 79 | private static function isPostEditor(): bool 80 | { 81 | return Admin::isScreen('post.php') || Admin::isScreen('post-new.php'); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /app/Features/LoginIdentifier.php: -------------------------------------------------------------------------------- 1 | identifier = is_string($identifier) ? $identifier : null; 24 | } 25 | 26 | public function hook(Hook $hook): void 27 | { 28 | if ($this->identifier === 'both') { 29 | return; 30 | } 31 | 32 | $hook->addFilter('gettext', [$this, 'filterGetText'], PHP_INT_MAX, 3); 33 | 34 | switch ($this->identifier) { 35 | case 'email': 36 | $hook->removeAction('authenticate', 'wp_authenticate_username_password', 20); 37 | break; 38 | 39 | case 'username': 40 | $hook->removeAction('authenticate', 'wp_authenticate_email_password', 20); 41 | break; 42 | } 43 | } 44 | 45 | public function filterGetText(string $translation, string $text, string $domain): string 46 | { 47 | if (! URL::isLogin() || $domain !== 'default') { 48 | return $translation; 49 | } 50 | 51 | if ($text === 'Username or Email Address') { 52 | $identifier = $this->identifier; 53 | 54 | switch ($identifier) { 55 | case 'username': 56 | // phpcs:ignore WordPress.WP.I18n.MissingArgDomain -- Translation will be handled by Core 57 | return __('Username'); 58 | 59 | case 'email': 60 | // phpcs:ignore WordPress.WP.I18n.MissingArgDomain -- Translation will be handled by Core 61 | return __('Email'); 62 | } 63 | } 64 | 65 | return $translation; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /app/Features/MaintenanceMode.php: -------------------------------------------------------------------------------- 1 | addFilter(Option::hook('sanitize:site_maintenance_args'), [$this, 'sanitizeArgsOption'], PHP_INT_MAX); 28 | $hook->addFilter(Option::hook('default:site_maintenance_args'), static function (): array { 29 | return [ 30 | 'headline' => __( 31 | 'Under Maintenance 🚧', 32 | 'syntatis-feature-flipper', 33 | ), 34 | 'message' => __( 35 | 'We are currently performing some scheduled maintenance. We will be back as soon as possible.', 36 | 'syntatis-feature-flipper', 37 | ), 38 | ]; 39 | }, PHP_INT_MAX); 40 | 41 | if (Option::get('site_access') !== 'maintenance') { 42 | return; 43 | } 44 | 45 | $hook->addAction('admin_bar_menu', [$this, 'adminBarMenu'], PHP_INT_MIN); 46 | $hook->addAction('rightnow_end', [$this, 'showRightNowStatus'], PHP_INT_MIN); 47 | $hook->addAction('template_redirect', [$this, 'templateRedirect'], PHP_INT_MIN); 48 | $hook->addFilter('wp_title', [$this, 'filterPageTitle'], PHP_INT_MAX, 2); 49 | } 50 | 51 | public function templateRedirect(): void 52 | { 53 | if (is_user_logged_in()) { 54 | return; 55 | } 56 | 57 | status_header(503); 58 | header('Retry-After: ' . 3600); 59 | 60 | $args = Option::get('site_maintenance_args'); 61 | $args = is_array($args) ? $args : []; 62 | $args = [ 63 | 'headline' => $args['headline'] ?? '', 64 | 'message' => $args['message'] ?? '', 65 | ]; 66 | 67 | include App::dir('inc/views/maintenance-mode.php'); 68 | 69 | exit; 70 | } 71 | 72 | public function filterPageTitle(string $title, string $separator): string 73 | { 74 | return sprintf( 75 | '%s %s %s', 76 | _x('Under Maintenance', 'Maintenance page title', 'syntatis-feature-flipper'), 77 | $separator, 78 | get_bloginfo('name'), 79 | ); 80 | } 81 | 82 | /** 83 | * Show the "Maintenance" status on the admin bar menu. 84 | */ 85 | public function adminBarMenu(WP_Admin_Bar $wpAdminBar): void 86 | { 87 | if (! is_admin()) { 88 | return; 89 | } 90 | 91 | $node = [ 92 | 'id' => App::name() . '-site-access', 93 | 'title' => sprintf( 94 | <<<'HTML' 95 |
96 | %s 97 |
98 | HTML, 99 | _x('Maintenance', 'Site access mode', 'syntatis-feature-flipper'), 100 | ), 101 | 'parent' => 'top-secondary', 102 | ]; 103 | 104 | if (current_user_can('manage_options') && ! Admin::isScreen(App::name())) { 105 | $node['href'] = Admin::url(App::name(), ['tab' => 'site']); 106 | } 107 | 108 | $wpAdminBar->add_node($node); 109 | } 110 | 111 | /** 112 | * Show the "Private" status at the "At a glance" dashboard widget. 113 | */ 114 | public function showRightNowStatus(): void 115 | { 116 | $message = __('The site is currently in Maintenance mode', 'syntatis-feature-flipper'); 117 | 118 | if (current_user_can('manage_options')) { 119 | $message = sprintf( 120 | '%s', 121 | Admin::url(App::name(), ['tab' => 'site']), 122 | $message, 123 | ); 124 | } 125 | 126 | printf( 127 | <<<'HTML' 128 |
129 | 130 | %s 131 |
132 | HTML, 133 | wp_kses($message, ['a' => ['href' => true]]), 134 | ); 135 | } 136 | 137 | /** 138 | * @param mixed $value The value of the "site_maintenance_args" option. 139 | * 140 | * @return array 141 | */ 142 | public function sanitizeArgsOption($value): array 143 | { 144 | $value = is_array($value) ? $value : []; 145 | $headline = $value['headline'] ?? ''; 146 | $message = $value['message'] ?? ''; 147 | 148 | return [ 149 | 'headline' => wp_strip_all_tags(is_string($headline) ? $headline : ''), 150 | 'message' => wp_strip_all_tags(is_string($message) ? $message : ''), 151 | ]; 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /app/Features/MediaViewMode.php: -------------------------------------------------------------------------------- 1 | addAction('admin_enqueue_scripts', [$this, 'addInlineScript']); 27 | } 28 | 29 | public function addInlineScript(): void 30 | { 31 | $screen = get_current_screen(); 32 | 33 | if (! $screen instanceof WP_Screen || $screen->id !== 'upload' || ! is_admin()) { 34 | return; 35 | } 36 | 37 | wp_add_inline_style( 38 | 'media', 39 | <<<'CSS' 40 | .wp-filter .view-switch, 41 | .media-toolbar .view-switch { 42 | display: none !important; 43 | } 44 | 45 | .wp-filter .filter-items { 46 | padding: 12px 0 !important; 47 | } 48 | 49 | .media-toolbar-secondary { 50 | padding: 12px 0 !important; 51 | margin: 0 8px 0 2px !important; 52 | } 53 | CSS, 54 | ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /app/Features/PrivateMode.php: -------------------------------------------------------------------------------- 1 | addFilter(Option::hook('default:site_access'), $optionCallback, PHP_INT_MAX); 58 | $hook->addFilter(Option::hook('site_access'), $optionCallback, PHP_INT_MAX); 59 | 60 | /** 61 | * Remove the `site_private` option, after the `site_access` option has been 62 | * updated, since the `site_private` option is now deprecated. 63 | */ 64 | $updateOptionCallback = static function (): void { 65 | $private = Option::get('site_private'); 66 | 67 | if ($private !== '1' && $private !== '') { 68 | return; 69 | } 70 | 71 | Option::delete('site_private'); 72 | }; 73 | $hook->addAction(Option::hook('add:site_access'), $updateOptionCallback, PHP_INT_MAX); 74 | $hook->addAction(Option::hook('update:site_access'), $updateOptionCallback, PHP_INT_MAX); 75 | 76 | if (Option::get('site_access') !== 'private') { 77 | return; 78 | } 79 | 80 | $hook->addAction('admin_bar_menu', [$this, 'adminBarMenu'], PHP_INT_MIN); 81 | $hook->addAction('rightnow_end', [$this, 'showRightNowStatus'], PHP_INT_MIN); 82 | $hook->addAction('template_redirect', [$this, 'forceLogin'], PHP_INT_MIN); 83 | $hook->addFilter('login_site_html_link', '__return_empty_string', PHP_INT_MAX); 84 | } 85 | 86 | public function forceLogin(): void 87 | { 88 | if ( 89 | URL::isLogin() || 90 | is_user_logged_in() || 91 | wp_doing_ajax() || 92 | wp_doing_cron() || 93 | ( defined('WP_CLI') && WP_CLI ) 94 | ) { 95 | return; 96 | } 97 | 98 | nocache_headers(); 99 | wp_safe_redirect(wp_login_url(URL::current()), 302); 100 | exit; 101 | } 102 | 103 | /** 104 | * Show the "Maintenance" status on the admin bar menu. 105 | */ 106 | public function adminBarMenu(WP_Admin_Bar $wpAdminBar): void 107 | { 108 | if (! is_admin()) { 109 | return; 110 | } 111 | 112 | $node = [ 113 | 'id' => App::name() . '-site-access', 114 | 'title' => sprintf( 115 | <<<'HTML' 116 |
117 | 118 | %s 119 |
120 | HTML, 121 | _x('Private', 'Site access mode', 'syntatis-feature-flipper'), 122 | ), 123 | 'parent' => 'top-secondary', 124 | ]; 125 | 126 | if (current_user_can('manage_options') && ! Admin::isScreen(App::name())) { 127 | $node['href'] = Admin::url(App::name(), ['tab' => 'site']); 128 | } 129 | 130 | $wpAdminBar->add_node($node); 131 | } 132 | 133 | /** 134 | * Show the "Private" status at the "At a glance" dashboard widget. 135 | */ 136 | public function showRightNowStatus(): void 137 | { 138 | $message = __('The site is currently in Private mode', 'syntatis-feature-flipper'); 139 | 140 | if (current_user_can('manage_options')) { 141 | $message = sprintf( 142 | '%s', 143 | Admin::url(App::name(), ['tab' => 'site']), 144 | $message, 145 | ); 146 | } 147 | 148 | printf( 149 | <<<'HTML' 150 |
151 | 152 | %s 153 |
154 | HTML, 155 | wp_kses($message, ['a' => ['href' => true]]), 156 | ); 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /app/Features/Updates/Helpers/AutoUpdate.php: -------------------------------------------------------------------------------- 1 | value = $value; 22 | } 23 | 24 | public static function global(bool $value): Enabler 25 | { 26 | return new self($value); 27 | } 28 | 29 | public static function components(bool $value): Enabler 30 | { 31 | return new AutoUpdateComponents($value); 32 | } 33 | 34 | public function isEnabled(): bool 35 | { 36 | if (! Option::isOn('updates')) { 37 | return false; 38 | } 39 | 40 | return $this->value; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /app/Features/Updates/Helpers/AutoUpdateComponents.php: -------------------------------------------------------------------------------- 1 | value = $value; 18 | } 19 | 20 | public function isEnabled(): bool 21 | { 22 | if (! Option::isOn('updates')) { 23 | return false; 24 | } 25 | 26 | if (! Option::isOn('auto_updates')) { 27 | return false; 28 | } 29 | 30 | return $this->value; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/Features/Updates/Helpers/Updates.php: -------------------------------------------------------------------------------- 1 | value = $value; 22 | } 23 | 24 | public static function global(bool $value): Enabler 25 | { 26 | return new self($value); 27 | } 28 | 29 | public static function components(bool $value): Enabler 30 | { 31 | return new UpdatesComponents($value); 32 | } 33 | 34 | public function isEnabled(): bool 35 | { 36 | return $this->value; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /app/Features/Updates/Helpers/UpdatesComponents.php: -------------------------------------------------------------------------------- 1 | value = $value; 18 | } 19 | 20 | public function isEnabled(): bool 21 | { 22 | if (! Option::isOn('updates')) { 23 | return false; 24 | } 25 | 26 | return $this->value; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/Features/Updates/ManageCore.php: -------------------------------------------------------------------------------- 1 | Updates::components((bool) $value)->isEnabled(); 26 | $autoUpdateFn = static fn ($value): bool => Option::isOn('update_core') ? 27 | AutoUpdate::components((bool) $value)->isEnabled() : 28 | false; 29 | 30 | $hook->addFilter(Option::hook('default:auto_update_core'), $autoUpdateFn); 31 | $hook->addFilter(Option::hook('default:update_core'), $updatesFn); 32 | $hook->addFilter(Option::hook('auto_update_core'), $autoUpdateFn); 33 | $hook->addFilter(Option::hook('update_core'), $updatesFn); 34 | 35 | if (! Option::isOn('update_core')) { 36 | $hook->addFilter('schedule_event', [$this, 'filterScheduleEvent']); 37 | $hook->addFilter('send_core_update_notification_email', '__return_false'); 38 | $hook->addFilter('site_transient_update_core', [$this, 'filterSiteTransientUpdate']); 39 | $hook->addFilter('site_status_tests', [$this, 'filterSiteStatusTests']); 40 | $hook->removeAction('admin_init', '_maybe_update_core'); 41 | $hook->removeAction('wp_maybe_auto_update', 'wp_maybe_auto_update'); 42 | $hook->removeAction('wp_version_check', 'wp_version_check'); 43 | } 44 | 45 | if (Option::isOn('auto_update_core')) { 46 | return; 47 | } 48 | 49 | if (! defined('WP_AUTO_UPDATE_CORE')) { 50 | define('WP_AUTO_UPDATE_CORE', false); 51 | } 52 | 53 | $hook->addFilter('allow_dev_auto_core_updates', '__return_false'); 54 | $hook->addFilter('allow_major_auto_core_updates', '__return_false'); 55 | $hook->addFilter('allow_minor_auto_core_updates', '__return_false'); 56 | $hook->addFilter('auto_core_update_send_email', '__return_false'); 57 | $hook->addFilter('auto_update_core', '__return_false'); 58 | $hook->addFilter('automatic_updates_is_vcs_checkout', '__return_false', 1); 59 | } 60 | 61 | /** 62 | * Prune the transient the Core update information. 63 | * 64 | * This will effectively also remove the Update notification in the admin 65 | * area, in case the update information was already fetched before the 66 | * Core update feature is disabled. 67 | * 68 | * @see https://github.com/WordPress/WordPress/blob/master/wp-admin/includes/update.php#L54 69 | * 70 | * @param mixed $cache The WordPress Core update information cache. 71 | */ 72 | public function filterSiteTransientUpdate($cache = null): object 73 | { 74 | return (object) [ 75 | 'updates' => [], 76 | 'translations' => [], 77 | 'version_checked' => $GLOBALS['wp_version'] ?? '', 78 | 'last_checked' => time(), 79 | ]; 80 | } 81 | 82 | /** 83 | * Prevent the Core update check from being scheduled. 84 | * 85 | * @return object|false 86 | */ 87 | public function filterScheduleEvent(object $event) 88 | { 89 | if (property_exists($event, 'hook') && $event->hook === 'wp_version_check') { 90 | return false; 91 | } 92 | 93 | return $event; 94 | } 95 | 96 | /** 97 | * Remove the Core update from the "Site Health" tests and report. 98 | * 99 | * WordPress will check for the Core update status in and will report it as 100 | * in the "Site Health" status. This filter will exclude these tests and 101 | * will remove them from the report since the Core update is disabled 102 | * intentionally. 103 | * 104 | * @param array> $tests 105 | * 106 | * @return array> 107 | */ 108 | public function filterSiteStatusTests(array $tests): array 109 | { 110 | unset($tests['async']['background_updates']); 111 | unset($tests['direct']['plugin_theme_auto_updates']); 112 | 113 | return $tests; 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /app/Features/Updates/ManagePlugins.php: -------------------------------------------------------------------------------- 1 | Updates::components((bool) $value)->isEnabled(); 23 | $autoUpdateFn = static fn ($value): bool => Option::isOn('update_plugins') ? 24 | AutoUpdate::components((bool) $value)->isEnabled() : 25 | false; 26 | 27 | $hook->addFilter(Option::hook('default:auto_update_plugins'), $autoUpdateFn); 28 | $hook->addFilter(Option::hook('default:update_plugins'), $updatesFn); 29 | $hook->addFilter(Option::hook('auto_update_plugins'), $autoUpdateFn); 30 | $hook->addFilter(Option::hook('update_plugins'), $updatesFn); 31 | 32 | if (! Option::isOn('update_plugins')) { 33 | $hook->removeAction('admin_init', '_maybe_update_plugins'); 34 | $hook->removeAction('load-plugins.php', 'wp_plugin_update_rows', 20); 35 | $hook->removeAction('load-plugins.php', 'wp_update_plugins'); 36 | $hook->removeAction('load-update-core.php', 'wp_update_plugins'); 37 | $hook->removeAction('load-update.php', 'wp_update_plugins'); 38 | $hook->removeAction('wp_update_plugins', 'wp_update_plugins'); 39 | $hook->addFilter('site_transient_update_plugins', [$this, 'filterSiteTransientUpdate']); 40 | } 41 | 42 | if (Option::isOn('auto_update_plugins')) { 43 | return; 44 | } 45 | 46 | $hook->addFilter('auto_update_plugin', '__return_false'); 47 | } 48 | 49 | /** 50 | * Prune the Themes update information cache fetched from WordPress.org 51 | * 52 | * This will effectively also remove the Update notification in the admin 53 | * area, in case the update information was already fetched before the 54 | * Plugins update feature is disabled. 55 | * 56 | * @see https://github.com/WordPress/WordPress/blob/master/wp-admin/includes/update.php#L409 57 | * 58 | * @param object|bool $cache 59 | */ 60 | public function filterSiteTransientUpdate($cache): object 61 | { 62 | return (object) [ 63 | 'response' => [], 64 | 'translations' => [], 65 | 'last_checked' => time(), 66 | ]; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /app/Features/Updates/ManageThemes.php: -------------------------------------------------------------------------------- 1 | Updates::components((bool) $value)->isEnabled(); 23 | $autoUpdateFn = static fn ($value): bool => Option::isOn('update_themes') ? 24 | AutoUpdate::components((bool) $value)->isEnabled() : 25 | false; 26 | 27 | $hook->addFilter(Option::hook('default:auto_update_themes'), $autoUpdateFn); 28 | $hook->addFilter(Option::hook('default:update_themes'), $updatesFn); 29 | $hook->addFilter(Option::hook('auto_update_themes'), $autoUpdateFn); 30 | $hook->addFilter(Option::hook('update_themes'), $updatesFn); 31 | 32 | if (! Option::isOn('update_themes')) { 33 | $hook->addFilter('site_transient_update_themes', [$this, 'filterSiteTransientUpdate']); 34 | $hook->removeAction('admin_init', '_maybe_update_themes'); 35 | $hook->removeAction('load-themes.php', 'wp_theme_update_rows', 20); 36 | $hook->removeAction('load-themes.php', 'wp_update_themes'); 37 | $hook->removeAction('load-update-core.php', 'wp_update_themes'); 38 | $hook->removeAction('load-update.php', 'wp_update_themes'); 39 | $hook->removeAction('wp_update_themes', 'wp_update_themes'); 40 | } 41 | 42 | if (Option::isOn('auto_update_themes')) { 43 | return; 44 | } 45 | 46 | $hook->addFilter('auto_update_theme', '__return_false'); 47 | } 48 | 49 | /** 50 | * Prune the Plugins update information cache fetched from WordPress.org 51 | * 52 | * This will effectively also remove the Update notification in the admin 53 | * area, in case the update information was already fetched before the 54 | * Themes update feature is disabled. 55 | * 56 | * @param object|bool $cache The Plugins update information cache. 57 | */ 58 | public function filterSiteTransientUpdate($cache): object 59 | { 60 | return (object) [ 61 | 'response' => [], 62 | 'translations' => [], 63 | 'last_checked' => time(), 64 | ]; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /app/Features/Updates/Updates.php: -------------------------------------------------------------------------------- 1 | addFilter( 23 | Option::hook('updates'), 24 | static fn ($value) => Helpers\Updates::global((bool) $value)->isEnabled(), 25 | ); 26 | $hook->addFilter( 27 | Option::hook('default:auto_updates'), 28 | static fn ($value) => Helpers\AutoUpdate::global((bool) $value)->isEnabled(), 29 | ); 30 | $hook->addFilter( 31 | Option::hook('auto_updates'), 32 | static fn ($value) => Helpers\AutoUpdate::global((bool) $value)->isEnabled(), 33 | ); 34 | 35 | if (! Option::isOn('updates')) { 36 | $hook->addAction('admin_menu', [$this, 'removeMenu'], PHP_INT_MAX); 37 | $hook->removeAction('init', 'wp_schedule_update_checks'); 38 | } 39 | 40 | if (Option::isOn('auto_updates')) { 41 | return; 42 | } 43 | 44 | if (! defined('AUTOMATIC_UPDATER_DISABLED')) { 45 | define('AUTOMATIC_UPDATER_DISABLED', false); 46 | } 47 | 48 | $hook->addFilter('auto_update_translation', '__return_false'); 49 | $hook->addFilter('automatic_updater_disabled', '__return_false'); 50 | $hook->removeAction('wp_maybe_auto_update', 'wp_maybe_auto_update'); 51 | } 52 | 53 | /** 54 | * Remove the "Updates" menu from the Menu in the admin area. 55 | */ 56 | public function removeMenu(): void 57 | { 58 | remove_submenu_page('index.php', 'update-core.php'); 59 | } 60 | 61 | /** @return iterable */ 62 | public function getInstances(ContainerInterface $container): iterable 63 | { 64 | yield new ManageCore(); 65 | yield new ManagePlugins(); 66 | yield new ManageThemes(); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /app/Helpers/Admin.php: -------------------------------------------------------------------------------- 1 | $args Additional query arguments to append to the URL. 27 | * @phpstan-param non-empty-string $address 28 | */ 29 | public static function url(string $address, array $args = []): string 30 | { 31 | switch ($address) { 32 | case App::name(): 33 | return add_query_arg( 34 | array_merge( 35 | $args, 36 | ['page' => $address], 37 | ), 38 | admin_url('options-general.php'), 39 | ); 40 | 41 | default: 42 | return add_query_arg($args, admin_url($address)); 43 | } 44 | } 45 | 46 | /** @phpstan-param non-empty-string $address */ 47 | public static function isScreen(string $address): bool 48 | { 49 | if (! is_admin()) { 50 | return false; 51 | } 52 | 53 | switch ($address) { 54 | case App::name(): 55 | return self::isPluginSettingPage(); 56 | 57 | default: 58 | if (Str::endsWith($address, '.php')) { 59 | $pagenow = $GLOBALS['pagenow'] ?? ''; 60 | 61 | if (is_string($pagenow) && $pagenow === $address) { 62 | return true; 63 | } 64 | } 65 | 66 | if (! function_exists('get_current_screen')) { 67 | return false; 68 | } 69 | 70 | $screen = get_current_screen(); 71 | 72 | if ($screen instanceof WP_Screen) { 73 | return $screen->id === $address; 74 | } 75 | 76 | return false; 77 | } 78 | } 79 | 80 | /** 81 | * Whether the current view is the plugin setting page. 82 | */ 83 | private static function isPluginSettingPage(): bool 84 | { 85 | $screenId = 'settings_page_' . App::name(); 86 | $pageNow = $GLOBALS['pagenow'] ?? null; 87 | $pageHook = $GLOBALS['page_hook'] ?? null; 88 | 89 | if ($pageNow === 'options-general.php' && $pageHook === $screenId) { 90 | return true; 91 | } 92 | 93 | if (! function_exists('get_current_screen')) { 94 | return false; 95 | } 96 | 97 | $screen = get_current_screen(); 98 | 99 | return $screen instanceof WP_Screen ? 100 | $screen->id === $screenId : 101 | false; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /app/Helpers/Assets.php: -------------------------------------------------------------------------------- 1 | } 27 | */ 28 | public static function manifest(string $path): array 29 | { 30 | $path = App::dir($path); 31 | $assets = str_ends_with($path, '.php') && is_readable($path) ? require $path : []; 32 | $assets = is_array($assets) ? $assets : []; 33 | 34 | $version = isset($assets['version']) && is_string($assets['version']) ? sanitize_key($assets['version']) : null; 35 | $dependencies = isset($assets['dependencies']) && is_array($assets['dependencies']) ? 36 | self::sanitizeDependencies($assets['dependencies']) : 37 | []; 38 | 39 | return [ 40 | 'version' => $version, 41 | 'dependencies' => $dependencies, 42 | ]; 43 | } 44 | 45 | /** 46 | * @param array $dependencies Unsanitized dependencies, retrieved from built file. 47 | * 48 | * @phpstan-return list 49 | */ 50 | private static function sanitizeDependencies(array $dependencies): array 51 | { 52 | $filtered = array_filter($dependencies, static fn ($dep) => is_string($dep) && $dep !== ''); 53 | 54 | return array_values(array_map('sanitize_key', $filtered)); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /app/Helpers/Str.php: -------------------------------------------------------------------------------- 1 | */ 23 | final class InlineData implements ArrayAccess, JsonSerializable 24 | { 25 | /** @var array */ 26 | private array $data; 27 | 28 | public function __construct() 29 | { 30 | $this->data = [ 31 | '$wp' => [ 32 | 'siteUrl' => get_site_url(), 33 | 'permalinkStructure' => (bool) get_option('permalink_structure'), 34 | 'postTypes' => self::getPostTypes(), 35 | 'themeSupport' => [ 36 | 'widgetsBlockEditor' => get_theme_support('widgets-block-editor'), 37 | ], 38 | ], 39 | 'settingPage' => esc_url(Admin::url(App::name())), 40 | 'settingPageTab' => sanitize_key(isset($_GET['tab']) && is_string($_GET['tab']) ? $_GET['tab'] : ''), 41 | ]; 42 | } 43 | 44 | /** @param mixed $offset */ 45 | public function offsetExists($offset): bool 46 | { 47 | if (is_string($offset)) { 48 | return isset($this->data[$offset]); 49 | } 50 | 51 | return false; 52 | } 53 | 54 | /** 55 | * @param mixed $offset 56 | * 57 | * @return mixed 58 | */ 59 | #[ReturnTypeWillChange] 60 | public function offsetGet($offset) 61 | { 62 | if (is_string($offset)) { 63 | return $this->data[$offset] ?? null; 64 | } 65 | 66 | return null; 67 | } 68 | 69 | /** 70 | * @param mixed $offset 71 | * @param mixed $value 72 | */ 73 | public function offsetSet($offset, $value): void 74 | { 75 | if (! is_string($offset) || $value === null) { 76 | return; 77 | } 78 | 79 | $this->data[$offset] = $value; 80 | } 81 | 82 | /** @param mixed $offset */ 83 | public function offsetUnset($offset): void 84 | { 85 | throw new BadMethodCallException('Cannot unset data'); 86 | } 87 | 88 | /** @return array */ 89 | public function jsonSerialize(): array 90 | { 91 | /** 92 | * For internal use. Subject to change. External plugin should not rely on this hook. 93 | * 94 | * @var self $instance 95 | */ 96 | $instance = apply_filters('syntatis/feature_flipper/inline_data', $this); 97 | 98 | return $instance->data; 99 | } 100 | 101 | /** 102 | * Retrieve the list of registered post types on the site. 103 | * 104 | * @see register_post_type() for accepted arguments. 105 | * 106 | * @return array> 107 | */ 108 | private static function getPostTypes(): array 109 | { 110 | $postTypes = array_filter( 111 | get_post_types(['public' => true], 'objects'), 112 | static fn (WP_Post_Type $postTypeObject, string $postType) => ! in_array($postTypeObject->name, ['attachment'], true), 113 | ARRAY_FILTER_USE_BOTH, 114 | ); 115 | 116 | return array_map( 117 | static fn (WP_Post_Type $postTypeObject): array => [ 118 | 'name' => $postTypeObject->name, 119 | 'label' => $postTypeObject->label, 120 | 'supports' => get_all_post_type_supports($postTypeObject->name), 121 | ], 122 | $postTypes, 123 | ); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /app/Modules/Admin.php: -------------------------------------------------------------------------------- 1 | addFilter('admin_footer_text', '__return_empty_string', 99); 21 | $hook->addFilter('update_footer', '__return_empty_string', 99); 22 | } 23 | 24 | if (Option::isOn('update_nags')) { 25 | return; 26 | } 27 | 28 | $hook->addAction('admin_init', static function () use ($hook): void { 29 | $hook->removeAction('admin_notices', 'update_nag', 3); 30 | $hook->removeAction('network_admin_notices', 'update_nag', 3); 31 | }, 99); 32 | } 33 | 34 | /** @return iterable */ 35 | public function getInstances(ContainerInterface $container): iterable 36 | { 37 | yield new DashboardWidgets(); 38 | yield new AdminBar(); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /app/Modules/Advanced.php: -------------------------------------------------------------------------------- 1 | */ 30 | public function getInstances(ContainerInterface $container): iterable 31 | { 32 | yield new Heartbeat(); 33 | yield new Updates(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /app/Modules/General.php: -------------------------------------------------------------------------------- 1 | addFilter('use_widgets_block_editor', [$this, 'filterUseWidgetsBlockEditor'], PHP_INT_MAX); 35 | $hook->addFilter( 36 | Option::hook('default:block_based_widgets'), 37 | static fn () => get_theme_support('widgets-block-editor'), 38 | PHP_INT_MAX, 39 | ); 40 | 41 | if (! Option::isOn('self_ping')) { 42 | $hook->addFilter('pre_ping', static function (&$links): void { 43 | if (! is_array($links)) { 44 | return; 45 | } 46 | 47 | $links = array_filter( 48 | $links, 49 | static fn ($link) => is_string($link) && ! str_starts_with($link, home_url()), 50 | ); 51 | }, 99); 52 | } 53 | 54 | $hook->addFilter('preprocess_comment', [$this, 'filterPreprocessComment'], PHP_INT_MIN); 55 | 56 | /** 57 | * When revisions are disabled, force the maximum revisions to 0 to prevent 58 | * WordPress from creating any revisions. 59 | */ 60 | if (! Option::isOn('revisions') && ! defined('WP_POST_REVISIONS')) { 61 | define('WP_POST_REVISIONS', 0); 62 | } 63 | 64 | $hook->addFilter( 65 | 'wp_revisions_to_keep', 66 | static function ($num) { 67 | if (! Option::isOn('revisions')) { 68 | return 0; 69 | } 70 | 71 | if (! Option::isOn('revisions_max_enabled')) { 72 | return $num; 73 | } 74 | 75 | $max = Option::get('revisions_max'); 76 | 77 | return is_numeric($max) ? (int) $max : $num; 78 | }, 79 | PHP_INT_MAX, 80 | ); 81 | } 82 | 83 | /** 84 | * Filter the value to determine whether to use the block editor for widgets. 85 | * 86 | * @see https://developer.wordpress.org/reference/hooks/use_widgets_block_editor/ 87 | */ 88 | public function filterUseWidgetsBlockEditor(bool $value): bool 89 | { 90 | $option = Option::get('block_based_widgets'); 91 | 92 | if ($option === null) { 93 | return $value; 94 | } 95 | 96 | return (bool) $option === true; 97 | } 98 | 99 | /** 100 | * Filter the comment content before it is processed. 101 | * 102 | * @param array{comment_content?:string|null} $commentData The comment data. {@see https://developer.wordpress.org/reference/hooks/preprocess_comment/}. 103 | * 104 | * @return array{comment_content?:string|null} The filtered comment data. 105 | */ 106 | public function filterPreprocessComment(array $commentData = []): array 107 | { 108 | if (! isset($commentData['comment_content'])) { 109 | return $commentData; 110 | } 111 | 112 | $length = Str::length( 113 | strip_tags($commentData['comment_content']), 114 | get_bloginfo('charset'), 115 | ); 116 | 117 | if ($length === false) { 118 | return $commentData; 119 | } 120 | 121 | if (Option::isOn('comment_min_length_enabled')) { 122 | $minLength = Option::get('comment_min_length'); 123 | $minLength = is_numeric($minLength) ? absint($minLength) : null; 124 | 125 | if ($minLength !== null && $length < $minLength) { 126 | wp_die( 127 | esc_html(__('Comment\'s too short. Please write something more helpful.', 'syntatis-feature-flipper')), 128 | esc_html(__('Comment Error', 'syntatis-feature-flipper')), 129 | [ 130 | 'response' => 400, 131 | 'back_link' => true, 132 | ], 133 | ); 134 | } 135 | } 136 | 137 | if (Option::isOn('comment_max_length_enabled')) { 138 | $maxLength = Option::get('comment_max_length'); 139 | $maxLength = is_numeric($maxLength) ? absint($maxLength) : null; 140 | 141 | if ($maxLength !== null && $length > $maxLength) { 142 | wp_die( 143 | esc_html(__('Comment\'s too long. Please write more concisely.', 'syntatis-feature-flipper')), 144 | esc_html(__('Comment Error', 'syntatis-feature-flipper')), 145 | [ 146 | 'response' => 400, 147 | 'back_link' => true, 148 | ], 149 | ); 150 | } 151 | } 152 | 153 | return $commentData; 154 | } 155 | 156 | /** 157 | * Proivide other features to load managed within the General section. 158 | * 159 | * @return iterable 160 | */ 161 | public function getInstances(ContainerInterface $container): iterable 162 | { 163 | yield new Comments(); 164 | yield new Embeds(); 165 | yield new Feeds(); 166 | yield new Gutenberg(); 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /app/Modules/Mail.php: -------------------------------------------------------------------------------- 1 | addFilter( 21 | 'wp_mail_from', 22 | static function (string $value): string { 23 | $address = Option::get('mail_from_address'); 24 | 25 | if (is_string($address) && (bool) is_email($address)) { 26 | return $address; 27 | } 28 | 29 | return $value; 30 | }, 31 | PHP_INT_MAX, 32 | ); 33 | 34 | $hook->addFilter( 35 | 'wp_mail_from_name', 36 | static function (string $value): string { 37 | $name = Option::get('mail_from_name'); 38 | 39 | if (is_string($name) && trim($name) !== '') { 40 | return $name; 41 | } 42 | 43 | return $value; 44 | }, 45 | PHP_INT_MAX, 46 | ); 47 | 48 | $hook->addFilter( 49 | 'pre_wp_mail', 50 | static function ($value) { 51 | return (bool) Option::get('mail_sending') ? $value : false; 52 | }, 53 | PHP_INT_MAX, 54 | ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /app/Modules/Media.php: -------------------------------------------------------------------------------- 1 | addFilter( 20 | 'media_library_infinite_scrolling', 21 | static fn (): bool => Option::isOn('media_infinite_scroll'), 22 | ); 23 | 24 | $hook->addFilter( 25 | 'jpeg_quality', 26 | static function ($quality) { 27 | if (! Option::isOn('jpeg_compression')) { 28 | return 100; 29 | } 30 | 31 | return Option::get('jpeg_compression_quality'); 32 | }, 33 | 99, 34 | ); 35 | } 36 | 37 | /** @return iterable */ 38 | public function getInstances(ContainerInterface $container): iterable 39 | { 40 | yield new Attachment(); 41 | yield new MediaViewMode(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /app/Modules/Modules.php: -------------------------------------------------------------------------------- 1 | */ 15 | final class Modules implements IteratorAggregate 16 | { 17 | private ContainerInterface $container; 18 | 19 | public function __construct(ContainerInterface $container) 20 | { 21 | $this->container = $container; 22 | } 23 | 24 | /** @return Traversable */ 25 | public function getIterator(): Traversable 26 | { 27 | yield from $this->iterate($this->getModules()); 28 | } 29 | 30 | /** @return iterable */ 31 | private function getModules(): iterable 32 | { 33 | yield new Admin(); 34 | yield new Advanced(); 35 | yield new General(); 36 | yield new Media(); 37 | yield new Security(); 38 | yield new Site(); 39 | yield new Mail(); 40 | } 41 | 42 | /** 43 | * @param iterable $values The value to iterate. 44 | * 45 | * @return iterable 46 | */ 47 | private function iterate(iterable $values): iterable 48 | { 49 | foreach ($values as $value) { 50 | if (! is_object($value)) { 51 | continue; 52 | } 53 | 54 | yield $value; 55 | 56 | if (! ($value instanceof Extendable)) { 57 | continue; 58 | } 59 | 60 | yield from $this->iterate($value->getInstances($this->container)); 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /app/Modules/Security.php: -------------------------------------------------------------------------------- 1 | addFilter('pings_open', '__return_false'); 29 | $hook->addFilter('xmlrpc_enabled', '__return_false'); 30 | $hook->addFilter('xmlrpc_methods', '__return_empty_array'); 31 | $hook->removeAction('wp_head', 'rsd_link'); 32 | } 33 | 34 | if (! Option::isOn('file_edit') && ! defined('DISALLOW_FILE_EDIT')) { 35 | define('DISALLOW_FILE_EDIT', true); 36 | } 37 | 38 | if (! Option::isOn('application_passwords')) { 39 | $hook->addFilter('wp_is_application_passwords_available', '__return_false'); 40 | } 41 | 42 | if (Option::isOn('obfuscate_login_error')) { 43 | $hook->addFilter('login_errors', [$this, 'filterLoginErrorMessage']); 44 | } 45 | 46 | if (Option::isOn('login_block_bots')) { 47 | $hook->addAction('wp', [$this, 'blockBots'], PHP_INT_MIN); 48 | } 49 | 50 | if (! Option::isOn('authenticated_rest_api')) { 51 | return; 52 | } 53 | 54 | $hook->addFilter('rest_authentication_errors', [$this, 'apiForceAuthentication']); 55 | } 56 | 57 | /** 58 | * @param WP_Error|true|null $access 59 | * 60 | * @return WP_Error|true|null 61 | */ 62 | public function apiForceAuthentication($access) 63 | { 64 | return is_user_logged_in() 65 | ? $access 66 | : new WP_Error( 67 | 'rest_login_required', 68 | __('Unauthorized API access.', 'syntatis-feature-flipper'), 69 | [ 70 | 'status' => rest_authorization_required_code(), 71 | ], 72 | ); 73 | } 74 | 75 | public function filterLoginErrorMessage(): string 76 | { 77 | return __( 78 | 'Error: Login failed. Please ensure your credentials are correct.', 79 | 'syntatis-feature-flipper', 80 | ); 81 | } 82 | 83 | public function blockBots(): void 84 | { 85 | if (! URL::isLogin()) { 86 | return; 87 | } 88 | 89 | $crawlerDetect = new CrawlerDetect(); 90 | 91 | if (! $crawlerDetect->isCrawler()) { 92 | return; 93 | } 94 | 95 | wp_die( 96 | esc_html(__('You are not allowed to access this page.', 'syntatis-feature-flipper')), 97 | esc_html(__('Forbidden', 'syntatis-feature-flipper')), 98 | 403, 99 | ); 100 | } 101 | 102 | /** @return iterable */ 103 | public function getInstances(ContainerInterface $container): iterable 104 | { 105 | yield new LoginIdentifier(); 106 | yield new ObfuscateUsernames(); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /app/Modules/Site.php: -------------------------------------------------------------------------------- 1 | removeAction('admin_print_scripts', 'print_emoji_detection_script'); 31 | $hook->removeAction('admin_print_styles', 'print_emoji_styles'); 32 | $hook->removeAction('wp_head', 'print_emoji_detection_script', 7); 33 | $hook->removeAction('wp_print_styles', 'print_emoji_styles'); 34 | $hook->removeFilter('comment_text_rss', 'wp_staticize_emoji'); 35 | $hook->removeFilter('the_content_feed', 'wp_staticize_emoji'); 36 | $hook->removeFilter('wp_mail', 'wp_staticize_emoji_for_email'); 37 | } 38 | 39 | if (! Option::isOn('scripts_version')) { 40 | $callback = static fn (string $src): string => remove_query_arg('ver', $src); 41 | 42 | $hook->addFilter('script_loader_src', $callback); 43 | $hook->addFilter('style_loader_src', $callback); 44 | } 45 | 46 | if (! Option::isOn('jquery_migrate')) { 47 | $hook->addAction('wp_default_scripts', static function (WP_Scripts $scripts): void { 48 | $jquery = $scripts->query('jquery', 'registered'); 49 | 50 | if (! $jquery instanceof _WP_Dependency) { 51 | return; 52 | } 53 | 54 | $jquery->deps = array_diff($jquery->deps, ['jquery-migrate']); 55 | }); 56 | } 57 | 58 | if (! Option::isOn('rsd_link')) { 59 | $hook->removeAction('wp_head', 'rsd_link'); 60 | } 61 | 62 | if (! Option::isOn('generator_tag')) { 63 | $hook->removeAction('wp_head', 'wp_generator'); 64 | } 65 | 66 | if (Option::isOn('shortlink')) { 67 | return; 68 | } 69 | 70 | $hook->removeAction('wp_head', 'wp_shortlink_wp_head'); 71 | $hook->removeAction('wp_head', 'wp_shortlink_header'); 72 | } 73 | 74 | /** @return iterable */ 75 | public function getInstances(ContainerInterface $container): iterable 76 | { 77 | yield new MaintenanceMode(); 78 | yield new PrivateMode(); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /app/Plugin.php: -------------------------------------------------------------------------------- 1 | */ 15 | public function getInstances(ContainerInterface $container): iterable 16 | { 17 | yield new CommonScripts(); 18 | yield new SettingPage($container->get(Settings::class)); 19 | yield from new Modules($container); 20 | 21 | do_action('syntatis/feature_flipper/init', $container); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /inc/bootstrap/app.php: -------------------------------------------------------------------------------- 1 | setPluginFilePath(PLUGIN_FILE) 38 | ->addServices(include PLUGIN_DIR . '/inc/bootstrap/providers.php') 39 | ->boot(); 40 | -------------------------------------------------------------------------------- /inc/bootstrap/dev.php: -------------------------------------------------------------------------------- 1 | register(); 20 | } else { 21 | $whoops = new Run(); 22 | $whoops->pushHandler(new PrettyPageHandler()); 23 | $whoops->register(); 24 | } 25 | -------------------------------------------------------------------------------- /inc/bootstrap/providers.php: -------------------------------------------------------------------------------- 1 | 'syntatis-feature-flipper', 13 | 14 | /** 15 | * The plugin text domain. 16 | * 17 | * The text domain will be used to translate the plugin's strings. It has 18 | * to match with the text domain used in the plugin header. 19 | */ 20 | 'text_domain' => 'syntatis-feature-flipper', 21 | 22 | /** 23 | * The option name prefix. 24 | * 25 | * Prefixing the option name helps to avoid conflicts with other plugins, 26 | * so make sure that it's very unique to the plugin. 27 | */ 28 | 'option_prefix' => 'syntatis_feature_flipper_', 29 | 30 | /** 31 | * The plugin blocks directory. 32 | * 33 | * The value defines the path to the directory where the blocks are added 34 | * with each sub-directory representing a block. Each block should have 35 | * a `block.json` file that defines the block's metadata, and their 36 | * respective CSS, and JavaScript files already compiled. 37 | * 38 | * The value should be a relative path to the plugin's root directory. 39 | * 40 | * @see https://developer.wordpress.org/block-editor/getting-started/fundamentals/ 41 | */ 42 | 'blocks_path' => 'dist/assets', 43 | ]; 44 | -------------------------------------------------------------------------------- /inc/views/maintenance-mode.php: -------------------------------------------------------------------------------- 1 | 6 | 7 | > 8 | 9 | 10 | <?php wp_title(); ?> 11 | 12 | 25 | 26 | 27 |
28 |

29 | 30 |
31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /inc/views/settings-page.php: -------------------------------------------------------------------------------- 1 | 17 |
18 |

19 |
'> 23 |
24 | 29 |
30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "syntatis-feature-flipper", 3 | "version": "1.9.5", 4 | "description": "Disable Comments, Gutenberg, Emojis, and other features you don't need in WordPress®", 5 | "author": { 6 | "name": "Thoriq Firdaus", 7 | "url": "https://github.com/tfirdaus" 8 | }, 9 | "private": true, 10 | "license": "GPL-3.0", 11 | "keywords": [ 12 | "wordpress", 13 | "plugin", 14 | "flipper", 15 | "feature", 16 | "rss", 17 | "gutenberg", 18 | "emojis", 19 | "xmlrpc" 20 | ], 21 | "engines": { 22 | "node": ">=20", 23 | "npm": ">=10" 24 | }, 25 | "dependencies": { 26 | "@syntatis/kubrick": "^0.1.0", 27 | "@wordpress/api-fetch": "^7.24.0", 28 | "@wordpress/dom-ready": "^4.24.0", 29 | "@wordpress/element": "^6.24.0", 30 | "@wordpress/i18n": "^5.22.0", 31 | "@wordpress/icons": "^10.24.0", 32 | "clsx": "^2.1.1", 33 | "lodash-es": "^4.17.21", 34 | "use-session-storage-state": "^19.0.1" 35 | }, 36 | "devDependencies": { 37 | "@wordpress/env": "^10.24.0", 38 | "@wordpress/scripts": "^30.17.0", 39 | "copy-webpack-plugin": "^13.0.0", 40 | "cross-env": "^7.0.3" 41 | }, 42 | "scripts": { 43 | "start": "wp-scripts start --output-path=dist/assets", 44 | "build": "cross-env NODE_ENV=production wp-scripts build --output-path=dist/assets", 45 | "format": "wp-scripts format", 46 | "lint:css": "wp-scripts lint-style src", 47 | "lint:js": "wp-scripts lint-js src", 48 | "wp-env": "wp-env", 49 | "wp-env:destroy": "wp-env destroy", 50 | "wp-env:start": "wp-env start", 51 | "wp-env:tests-wordpress": "wp-env run tests-wordpress --env-cwd=/var/www/html/wp-content/plugins/wp-feature-flipper", 52 | "packages-update": "wp-scripts packages-update" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /phpcs.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 7 | PHP Coding Standards for modern WordPress plugin 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | scoper.inc.php 18 | uninstall.php 19 | syntatis-feature-flipper.php 20 | ./app 21 | ./inc 22 | ./tests 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | /inc/views/*.php 68 | 69 | 70 | /inc/bootstrap/providers.php 71 | /inc/config/*.php 72 | 73 | 74 | /inc/bootstrap/providers.php 75 | 76 | 77 | 78 | /tests/phpunit/bootstrap.php 79 | /tests/phpstan/woocommerce-stubs.php 80 | 81 | -------------------------------------------------------------------------------- /phpstan.neon.dist: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 10 3 | bootstrapFiles: 4 | - dist/autoload/vendor/autoload.php 5 | - tests/phpstan/woocommerce-stubs.php 6 | paths: 7 | - app 8 | 9 | ignoreErrors: 10 | - 11 | message: '#^Unused Syntatis\\FeatureFlipper\\Helpers\\Option\:\:.*$#' 12 | path: app/Helpers/Option.php 13 | - 14 | identifier: shipmonk.checkedExceptionInYieldingMethod 15 | - 16 | identifier: shipmonk.forbiddenCast 17 | 18 | ## syntatis/phpstan-psr-11 19 | syntatis: 20 | psr-11: SSFV\Psr\Container\ContainerInterface 21 | 22 | ## johnbillion/wp-compat 23 | WPCompat: 24 | requiresAtLeast: '6.0' 25 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | 15 | 16 | 17 | tests/phpunit/app/ 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /scoper.inc.php: -------------------------------------------------------------------------------- 1 | get(); 13 | -------------------------------------------------------------------------------- /src/admin-bar/EnvironmentType/EnvironmentType.jsx: -------------------------------------------------------------------------------- 1 | import { Button, Tooltip } from '@syntatis/kubrick'; 2 | import { __ } from '@wordpress/i18n'; 3 | import styles from './EnvironmentType.module.scss'; 4 | 5 | const ENVIRONMENT_TYPES_LABELS = { 6 | local: __( 'Local', 'syntatis-feature-flipper' ), 7 | development: __( 'Development', 'syntatis-feature-flipper' ), 8 | staging: __( 'Staging', 'syntatis-feature-flipper' ), 9 | production: __( 'Production', 'syntatis-feature-flipper' ), 10 | }; 11 | 12 | const ENVIRONMENT_TYPES_DESCRIPTIONS = { 13 | local: __( 14 | 'You\'re currently working on the "Local" environment. It typically runs on a local computer or local server for development, testing, and debugging purposes.', 15 | 'syntatis-feature-flipper' 16 | ), 17 | development: __( 18 | 'You\'re currently working on the "Development" environment. It typically used for developing, testing, and debugging before it is launched on the staging platform. This environment is usually remotely accessed using SSH or SFTP.', 19 | 'syntatis-feature-flipper' 20 | ), 21 | staging: __( 22 | 'You\'re currently working on the "Staging" environment. It typically closely mimics the production environment, used for validating any modifications or upgrades before they are applied to the live site.', 23 | 'syntatis-feature-flipper' 24 | ), 25 | production: __( 26 | 'You\'re currently working on the "Production" site. This environment is where the public users can access the site. Maintaining its stability and security is critical. Please be cautious when making changes on the site.', 27 | 'syntatis-feature-flipper' 28 | ), 29 | }; 30 | 31 | export const EnvironmentType = ( { environmentType } ) => { 32 | if ( ! environmentType ) { 33 | return null; 34 | } 35 | return ( 36 | { ENVIRONMENT_TYPES_DESCRIPTIONS[ environmentType ] }

40 | } 41 | placement="bottom" 42 | > 43 | 51 |
52 | ); 53 | }; 54 | -------------------------------------------------------------------------------- /src/admin-bar/EnvironmentType/EnvironmentType.module.scss: -------------------------------------------------------------------------------- 1 | :global(#wpadminbar) { 2 | :global(#wp-admin-bar-syntatis-feature-flipper-environment-type) { 3 | > [role="menuitem"] { 4 | padding: 0; 5 | } 6 | } 7 | 8 | :global(#syntatis-feature-flipper-environment-type) { 9 | .trigger { 10 | background-color: transparent; 11 | border: 0; 12 | display: flex; 13 | align-items: center; 14 | justify-content: center; 15 | gap: 0.25rem; 16 | padding: 0 0.5rem; 17 | cursor: help; 18 | color: inherit; 19 | 20 | &:focus { 21 | box-shadow: none; 22 | outline: 2px solid var(--kubrick-accent-color); 23 | outline-offset: -2px; 24 | } 25 | } 26 | 27 | :global(.dashicons) { 28 | margin-right: 0; 29 | } 30 | } 31 | } 32 | 33 | // Force the tooltip styles, which may be overridden by theme styles. 34 | .tooltip { 35 | color: var(--kubrick-gray-700) !important; 36 | font-size: 13px !important; 37 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif !important; 38 | line-height: 1.4em !important; 39 | 40 | p:only-child { 41 | margin: 0; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/admin-bar/EnvironmentType/index.js: -------------------------------------------------------------------------------- 1 | export { EnvironmentType } from './EnvironmentType'; 2 | -------------------------------------------------------------------------------- /src/admin-bar/index.js: -------------------------------------------------------------------------------- 1 | import domReady from '@wordpress/dom-ready'; 2 | import { createRoot } from '@wordpress/element'; 3 | import { EnvironmentType } from './EnvironmentType/EnvironmentType'; 4 | 5 | domReady( () => { 6 | const container = document.querySelector( 7 | '#syntatis-feature-flipper-environment-type' 8 | ); 9 | if ( container ) { 10 | const data = JSON.parse( container.dataset.inline ); 11 | 12 | createRoot( container ).render( 13 | 14 | ); 15 | } 16 | } ); 17 | -------------------------------------------------------------------------------- /src/comments/index.js: -------------------------------------------------------------------------------- 1 | import domReady from '@wordpress/dom-ready'; 2 | import { getBlockType, unregisterBlockType } from '@wordpress/blocks'; 3 | 4 | domReady( () => { 5 | const blocks = [ 6 | 'core/comment-author-name', 7 | 'core/comment-content', 8 | 'core/comment-date', 9 | 'core/comment-edit-link', 10 | 'core/comment-reply-link', 11 | 'core/comment-template', 12 | 'core/comments', 13 | 'core/comments-pagination', 14 | 'core/comments-pagination-next', 15 | 'core/comments-pagination-numbers', 16 | 'core/comments-pagination-previous', 17 | 'core/comments-title', 18 | 'core/latest-comments', 19 | 'core/post-comments-form', 20 | 'core/post-comments', 21 | ]; 22 | 23 | blocks.forEach( ( block ) => { 24 | if ( undefined !== getBlockType( block ) ) { 25 | unregisterBlockType( block ); 26 | } 27 | } ); 28 | } ); 29 | -------------------------------------------------------------------------------- /src/embeds/index.js: -------------------------------------------------------------------------------- 1 | import { 2 | unregisterBlockType, 3 | getBlockVariations, 4 | unregisterBlockVariation, 5 | getBlockType, 6 | } from '@wordpress/blocks'; 7 | 8 | import domReady from '@wordpress/dom-ready'; 9 | 10 | domReady( () => { 11 | if ( getBlockVariations && getBlockVariations( 'core/embed' ) ) { 12 | unregisterBlockVariation( 'core/embed', 'wordpress' ); 13 | } else if ( getBlockType( 'core-embed/wordpress' ) ) { 14 | unregisterBlockType( 'core-embed/wordpress' ); 15 | } 16 | } ); 17 | -------------------------------------------------------------------------------- /src/setting-page/Page.jsx: -------------------------------------------------------------------------------- 1 | import { __ } from '@wordpress/i18n'; 2 | import { Tab, Tabs, TabsProvider } from '@syntatis/kubrick'; 3 | import { 4 | AdminTab, 5 | AdvancedTab, 6 | GeneralTab, 7 | MailTab, 8 | MediaTab, 9 | SecurityTab, 10 | SiteTab, 11 | } from './tabs'; 12 | import { useSettingsContext } from './form'; 13 | import './Page.scss'; 14 | 15 | export const Page = () => { 16 | const { inlineData } = useSettingsContext(); 17 | 18 | return ( 19 | 20 | 21 | 25 | 26 | 27 | 31 | 32 | 33 | 37 | 38 | 39 | 43 | 44 | 45 | 49 | 50 | 51 | 55 | 56 | 57 | 61 | 62 | 63 | 64 | 65 | ); 66 | }; 67 | -------------------------------------------------------------------------------- /src/setting-page/Page.scss: -------------------------------------------------------------------------------- 1 | .wrap #syntatis-feature-flipper-settings { 2 | details summary { 3 | font-weight: 600; 4 | cursor: pointer; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/setting-page/components/Details/Details.jsx: -------------------------------------------------------------------------------- 1 | import styles from './Details.module.scss'; 2 | 3 | export const Details = ( { summary, children, className = styles.root } ) => { 4 | return ( 5 |
6 | { summary } 7 |
{ children }
8 |
9 | ); 10 | }; 11 | -------------------------------------------------------------------------------- /src/setting-page/components/Details/Details.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | margin-top: 0.5rem 3 | } 4 | 5 | .content { 6 | padding-top: 1rem; 7 | } 8 | -------------------------------------------------------------------------------- /src/setting-page/components/Details/index.js: -------------------------------------------------------------------------------- 1 | export { Details } from './Details'; 2 | -------------------------------------------------------------------------------- /src/setting-page/components/HelpContent/HelpContent.jsx: -------------------------------------------------------------------------------- 1 | import { __ } from '@wordpress/i18n'; 2 | import styles from './HelpContent.module.scss'; 3 | import { Link } from '@syntatis/kubrick'; 4 | import { external, Icon } from '@wordpress/icons'; 5 | 6 | export const HelpContent = ( { children, readmore } ) => { 7 | return ( 8 |
9 | { children } 10 | { readmore && ( 11 |

12 | } 17 | > 18 | { __( 'Read more', 'syntatis-feature-flipper' ) } 19 | 20 |

21 | ) } 22 |
23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /src/setting-page/components/HelpContent/HelpContent.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | p:first-child { 3 | margin-top: 0; 4 | } 5 | 6 | p:last-child { 7 | margin-bottom: 0; 8 | } 9 | } 10 | 11 | .readmore { 12 | display: flex; 13 | align-items: center; 14 | gap: 0.05rem; 15 | } 16 | -------------------------------------------------------------------------------- /src/setting-page/components/HelpContent/index.js: -------------------------------------------------------------------------------- 1 | export { HelpContent } from './HelpContent'; 2 | -------------------------------------------------------------------------------- /src/setting-page/components/HelpTip/HelpTip.jsx: -------------------------------------------------------------------------------- 1 | import { IconButton, Tooltip } from '@syntatis/kubrick'; 2 | import { __ } from '@wordpress/i18n'; 3 | import { helpFilled, Icon } from '@wordpress/icons'; 4 | import styles from './HelpTip.module.scss'; 5 | 6 | export const HelpTip = ( { children } ) => { 7 | return ( 8 | 9 | 16 | 17 | 18 | 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /src/setting-page/components/HelpTip/HelpTip.module.scss: -------------------------------------------------------------------------------- 1 | :global(.wp-core-ui #syntatis-feature-flipper-settings) { 2 | .icon { 3 | cursor: help; 4 | background-color: transparent; 5 | color: var(--kubrick-gray-500); 6 | max-height: max-content; 7 | min-height: max-content; 8 | border: 0; 9 | padding: 0; 10 | border-radius: 100px; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/setting-page/components/HelpTip/index.js: -------------------------------------------------------------------------------- 1 | export { HelpTip } from './HelpTip'; 2 | -------------------------------------------------------------------------------- /src/setting-page/components/index.js: -------------------------------------------------------------------------------- 1 | export { Details } from './Details'; 2 | export { HelpContent } from './HelpContent'; 3 | export { HelpTip } from './HelpTip'; 4 | -------------------------------------------------------------------------------- /src/setting-page/fieldset/AdminBarFieldset.jsx: -------------------------------------------------------------------------------- 1 | import { __ } from '@wordpress/i18n'; 2 | import { SwitchFieldset } from './SwitchFieldset'; 3 | import { Checkbox, CheckboxGroup } from '@syntatis/kubrick'; 4 | import { useSettingsContext } from '../form'; 5 | import { Details, HelpContent } from '../components'; 6 | 7 | export const AdminBarFieldset = () => { 8 | const { getOption, inputProps, inlineData } = useSettingsContext(); 9 | const menu = inlineData.$wp.adminBarMenu || []; 10 | 11 | return ( 12 | 26 |

27 | { __( 28 | 'When disabling the Admin Bar through this option, it will hide the Admin Bar on the front end only. The Admin Bar will still be visible in the admin area, and you will still be able to selectively hide which items are displayed on the Admin Bar.', 29 | 'syntatis-feature-flipper' 30 | ) } 31 |

32 | 33 | } 34 | > 35 |
36 | 45 | { menu.map( ( id ) => ( 46 | { id } } 50 | /> 51 | ) ) } 52 | 53 |
54 |
55 | ); 56 | }; 57 | -------------------------------------------------------------------------------- /src/setting-page/fieldset/CommentsFieldset.jsx: -------------------------------------------------------------------------------- 1 | import { __ } from '@wordpress/i18n'; 2 | import { SwitchFieldset } from './SwitchFieldset'; 3 | import { useState } from '@wordpress/element'; 4 | import { Checkbox, TextField } from '@syntatis/kubrick'; 5 | import { useSettingsContext } from '../form'; 6 | import styles from './CommentsFieldset.module.scss'; 7 | 8 | export const CommentsFieldset = () => { 9 | const { getOption, getOptionName } = useSettingsContext(); 10 | const [ isEnabled, setEnabled ] = useState( getOption( 'comments' ) ); 11 | const [ isMinEnabled, setMinEnabled ] = useState( 12 | getOption( 'comment_min_length_enabled' ) 13 | ); 14 | const [ isMaxEnabled, setMaxEnabled ] = useState( 15 | getOption( 'comment_max_length_enabled' ) 16 | ); 17 | const minChars = getOption( 'comment_min_length' ); 18 | const maxChars = getOption( 'comment_max_length' ); 19 | 20 | return ( 21 | 32 | { isEnabled && ( 33 |
34 | 59 | } 60 | /> 61 | 86 | } 87 | /> 88 |
89 | ) } 90 |
91 | ); 92 | }; 93 | -------------------------------------------------------------------------------- /src/setting-page/fieldset/CommentsFieldset.module.scss: -------------------------------------------------------------------------------- 1 | .details { 2 | margin-top: 1rem; 3 | display: flex; 4 | flex-direction: column; 5 | gap: 1rem; 6 | } 7 | 8 | .inputNumber input { 9 | max-width: 4rem; 10 | } 11 | -------------------------------------------------------------------------------- /src/setting-page/fieldset/DashboardWidgetsFieldset.jsx: -------------------------------------------------------------------------------- 1 | import { __ } from '@wordpress/i18n'; 2 | import { SwitchFieldset } from './SwitchFieldset'; 3 | import { Checkbox, CheckboxGroup } from '@syntatis/kubrick'; 4 | import { useSettingsContext } from '../form'; 5 | import { useId, useState } from '@wordpress/element'; 6 | import { Details } from '../components'; 7 | 8 | export const DashboardWidgetsFieldset = () => { 9 | const { getOption, inputProps, inlineData } = useSettingsContext(); 10 | const [ isEnabled, setEnabled ] = useState( 11 | getOption( 'dashboard_widgets' ) 12 | ); 13 | const labelId = useId(); 14 | const registeredWidgets = inlineData.$wp.dashboardWidgets || []; 15 | 16 | return ( 17 | 31 | { isEnabled && ( 32 |
35 | { __( 'Settings', 'syntatis-feature-flipper' ) } 36 | 37 | } 38 | > 39 | 50 | { Object.keys( registeredWidgets ).map( ( id ) => { 51 | return ( 52 | 57 | ); 58 | } ) } 59 | 60 |
61 | ) } 62 |
63 | ); 64 | }; 65 | -------------------------------------------------------------------------------- /src/setting-page/fieldset/GutenbergFieldset.jsx: -------------------------------------------------------------------------------- 1 | import { __ } from '@wordpress/i18n'; 2 | import { SwitchFieldset } from './SwitchFieldset'; 3 | import { useState } from '@wordpress/element'; 4 | import { Checkbox, CheckboxGroup } from '@syntatis/kubrick'; 5 | import { Details } from '../components'; 6 | import { useSettingsContext } from '../form'; 7 | 8 | const PostTypesInputs = () => { 9 | const { getOption, inlineData, inputProps } = useSettingsContext(); 10 | const postTypes = inlineData.$wp.postTypes; 11 | 12 | if ( ! postTypes ) { 13 | return null; 14 | } 15 | 16 | for ( const postTypeKey in postTypes ) { 17 | if ( ! postTypes[ postTypeKey ].supports?.editor ) { 18 | delete postTypes[ postTypeKey ]; 19 | continue; 20 | } 21 | } 22 | 23 | return ( 24 | 33 | { Object.keys( postTypes ).map( ( postTypeKey ) => { 34 | const postType = postTypes[ postTypeKey ]; 35 | 36 | return ( 37 | 42 | { postType.label } { postTypeKey } 43 | 44 | } 45 | /> 46 | ); 47 | } ) } 48 | 49 | ); 50 | }; 51 | 52 | export const GutenbergFieldset = () => { 53 | const { getOption } = useSettingsContext(); 54 | const [ value, setValue ] = useState( getOption( 'gutenberg' ) ); 55 | 56 | return ( 57 | 71 | { value && ( 72 |
75 | 76 |
77 | ) } 78 |
79 | ); 80 | }; 81 | -------------------------------------------------------------------------------- /src/setting-page/fieldset/HeartbeatFieldset.module.scss: -------------------------------------------------------------------------------- 1 | .group { 2 | margin-top: 1rem; 3 | display: flex; 4 | flex-direction: column; 5 | gap: 1rem; 6 | } 7 | 8 | .groupInputs 9 | { 10 | display: flex; 11 | flex-direction: column; 12 | gap: 0.5rem; 13 | } 14 | -------------------------------------------------------------------------------- /src/setting-page/fieldset/ImageQualityFieldset.jsx: -------------------------------------------------------------------------------- 1 | import { __ } from '@wordpress/i18n'; 2 | import { SwitchFieldset } from './SwitchFieldset'; 3 | import { TextField } from '@syntatis/kubrick'; 4 | import { Fieldset, useSettingsContext } from '../form'; 5 | import { useState } from '@wordpress/element'; 6 | 7 | export const ImageQualityFieldset = () => { 8 | const { getOption } = useSettingsContext(); 9 | const [ values, setValues ] = useState( { 10 | jpegCompression: getOption( 'jpeg_compression' ), 11 | } ); 12 | 13 | return ( 14 |
21 | { 34 | setValues( ( currentValues ) => { 35 | return { 36 | ...currentValues, 37 | jpegCompression: value, 38 | }; 39 | } ); 40 | } } 41 | > 42 | { values.jpegCompression && ( 43 |
44 | 55 | { __( 56 | 'Quality', 57 | 'syntatis-feature-flipper' 58 | ) } 59 | 60 | } 61 | aria-label={ __( 62 | 'Quality', 63 | 'syntatis-feature-flipper' 64 | ) } 65 | suffix="%" 66 | /> 67 |
68 | ) } 69 |
70 |
71 | ); 72 | }; 73 | -------------------------------------------------------------------------------- /src/setting-page/fieldset/RadioGroupFieldset.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable jsx-a11y/label-has-associated-control -- Handled by the `labelProps` */ 2 | import { Radio, RadioGroup } from '@syntatis/kubrick'; 3 | import { useSettingsContext } from '../form'; 4 | import { HelpTip } from '../components'; 5 | import styles from './styles.module.scss'; 6 | 7 | export const RadioGroupFieldset = ( { 8 | description, 9 | id, 10 | name, 11 | onChange, 12 | title, 13 | children, 14 | isDisabled, 15 | isSelected, 16 | help, 17 | options, 18 | orientation, 19 | } ) => { 20 | const { labelProps, inputProps, getOption } = useSettingsContext(); 21 | 22 | return ( 23 | 24 | 25 | 26 | 27 | { help && { help } } 28 | 29 | 30 | 31 | { 35 | if ( onChange !== undefined ) { 36 | onChange( checked ); 37 | } 38 | } } 39 | defaultValue={ getOption( name ) } 40 | description={ description } 41 | isDisabled={ isDisabled } 42 | isSelected={ isSelected } 43 | orientation={ orientation } 44 | > 45 | { options.map( ( { label, value, ...props } ) => ( 46 | 47 | { label } 48 | 49 | ) ) } 50 | 51 | { children } 52 | 53 | 54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /src/setting-page/fieldset/RevisionsFieldset.jsx: -------------------------------------------------------------------------------- 1 | import { __ } from '@wordpress/i18n'; 2 | import { useState } from '@wordpress/element'; 3 | import { Checkbox, TextField } from '@syntatis/kubrick'; 4 | import { SwitchFieldset } from './SwitchFieldset'; 5 | import { useSettingsContext } from '../form'; 6 | import { HelpContent } from '../components'; 7 | 8 | export const RevisionsFieldset = () => { 9 | const { getOption, getOptionName } = useSettingsContext(); 10 | const [ isEnabled, setEnabled ] = useState( getOption( 'revisions' ) ); 11 | const [ isMaxEnabled, setMaxEnabled ] = useState( 12 | getOption( 'revisions_max_enabled' ) 13 | ); 14 | const revisionMax = getOption( 'revisions_max' ); 15 | 16 | return ( 17 | 28 |

29 | { __( 30 | 'While the revision feature is helpful for recovering content, storing too many revisions can clutter the database, slow down performance, and use up storage space.', 31 | 'syntatis-feature-flipper' 32 | ) } 33 |

34 |

35 | { __( 36 | 'Limiting or disabling revisions can help to improve your site database more efficient, especially for multi-author blogs or sites with limited hosting resources.', 37 | 'syntatis-feature-flipper' 38 | ) } 39 |

40 | 41 | } 42 | onChange={ setEnabled } 43 | > 44 | { isEnabled && ( 45 |
46 | 73 | } 74 | description={ __( 75 | 'Apply maximum number of revisions to keep.', 76 | 'syntatis-feature-flipper' 77 | ) } 78 | /> 79 |
80 | ) } 81 |
82 | ); 83 | }; 84 | -------------------------------------------------------------------------------- /src/setting-page/fieldset/SiteAccessFieldset.jsx: -------------------------------------------------------------------------------- 1 | import { __ } from '@wordpress/i18n'; 2 | import { useState } from '@wordpress/element'; 3 | import { TextArea, TextField } from '@syntatis/kubrick'; 4 | import { RadioGroupFieldset } from './RadioGroupFieldset'; 5 | import { Fieldset, useSettingsContext } from '../form'; 6 | import styles from './SiteAccessFieldset.module.scss'; 7 | 8 | export const SiteAccessFieldset = () => { 9 | const { getOption, optionPrefix } = useSettingsContext(); 10 | const [ siteAccess, setSiteAccess ] = useState( 11 | getOption( 'site_access' ) 12 | ); 13 | 14 | return ( 15 |
16 | 41 | { siteAccess === 'maintenance' && ( 42 |
47 | { 63 | if ( ! value.trim() ) { 64 | return __( 65 | 'Please provide a headline for the maintenance page.', 66 | 'syntatis-feature-flipper' 67 | ); 68 | } 69 | } } 70 | validationBehavior="native" 71 | /> 72 |