├── .distignore ├── .github └── workflows │ └── build-and-release.yml ├── .gitignore ├── .nvmrc ├── .phpcs.xml ├── LICENSE ├── README.md ├── assets └── blueprints │ └── blueprint.json ├── bin └── install-wp-tests.sh ├── classact.php ├── composer.json ├── composer.lock ├── includes ├── Plugin.php └── Updater.php ├── package-lock.json ├── package.json ├── phpunit.xml ├── src └── editor │ ├── classact │ ├── accessibility.js │ ├── components.js │ ├── context.js │ ├── core.js │ ├── hooks.js │ ├── index.js │ ├── styles.scss │ └── utils.js │ └── index.js ├── tests ├── .gitignore └── phpunit │ ├── bootstrap.php │ └── includes │ ├── class-test-helpers.php │ ├── test-autoloader.php │ ├── test-plugin.php │ └── test-updater.php └── webpack.config.js /.distignore: -------------------------------------------------------------------------------- 1 | # Directories 2 | .git 3 | .github 4 | node_modules 5 | src 6 | bin 7 | 8 | # Files 9 | .distignore 10 | .gitignore 11 | .editorconfig 12 | 13 | # File Types 14 | *.json 15 | *.lock 16 | *.log 17 | *.dist 18 | *.zip -------------------------------------------------------------------------------- /.github/workflows/build-and-release.yml: -------------------------------------------------------------------------------- 1 | name: Build, Attest and Release 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | permissions: 8 | contents: write 9 | id-token: write # Required for attestation 10 | attestations: write # Required for attestation 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v4 18 | 19 | - name: Setup Node.js 20 | uses: actions/setup-node@v4 21 | with: 22 | cache: 'npm' 23 | 24 | - name: Install Dependencies 25 | run: npm install 26 | 27 | - name: Build Plugin 28 | run: npm run build 29 | 30 | - name: Build Plugin Zip 31 | run: npm run plugin-zip 32 | id: build-zip 33 | 34 | - name: Upload Release Asset 35 | uses: actions/upload-release-asset@v1 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | with: 39 | upload_url: ${{ github.event.release.upload_url }} 40 | asset_path: ./classact.zip 41 | asset_name: classact.zip 42 | asset_content_type: application/zip 43 | 44 | - name: Generate build provenance attestation 45 | uses: johnbillion/action-wordpress-plugin-attestation@0.7.0 46 | id: attestation 47 | with: 48 | zip-path: ./classact.zip 49 | plugin: classact 50 | version: ${{ github.event.release.tag_name }} 51 | zip-url: 'https://updates.wpadmin.app/download/plugin/classact/${{ github.event.release.tag_name }}' 52 | 53 | - name: Log Attestation Information 54 | run: | 55 | echo "Attestation ID: ${{ steps.attestation.outputs.attestation-id }}" 56 | echo "Attestation URL: ${{ steps.attestation.outputs.attestation-url }}" 57 | echo "Bundle Path: ${{ steps.attestation.outputs.bundle-path }}" 58 | echo "ZIP URL: ${{ steps.attestation.outputs.zip-url }}" 59 | 60 | - name: Notify Update Server 61 | run: | 62 | curl -X POST https://updates.wpadmin.app/invalidate \ 63 | -H "Content-Type: application/json" \ 64 | -d '{"type": "plugin", "slug": "classact", "secret": "${{ secrets.PRESSFLARE_WEBHOOK_KEY }}"}' 65 | 66 | - name: Verify Update Cache Invalidation 67 | run: | 68 | curl -I https://updates.wpadmin.app/download/plugin/classact/${{ github.event.release.tag_name }} 69 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | build 3 | node_modules 4 | vendor 5 | 6 | *.log 7 | 8 | .DS_Store 9 | Thumbs.db 10 | 11 | phpcs.xml -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 -------------------------------------------------------------------------------- /.phpcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | ClassAct WordPress plugin coding standards 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | . 25 | 26 | 27 | /vendor/* 28 | /node_modules/* 29 | /build/* 30 | /tests/* 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ClassAct 2 | 3 | A lightweight WordPress plugin for visually managing CSS classes in the Block Editor. 4 | 5 | ![ClassAct Demo](https://github.com/user-attachments/assets/9a4b0713-4c6a-464d-b455-00f1120334a2) 6 | 7 | ## 📦 [Download](https://github.com/0aveRyan/classact/releases/latest/download/classact.zip) 8 | 9 | ## ✨ Features 10 | 11 | - **🔖 Visual Token Management** - Manipulate CSS classes as visual tokens 12 | - **⚡️ Quick Actions** - Copy, sort, and remove classes with one click 13 | - **🔍 Enhanced Modal** - Full-featured management with advanced sorting options 14 | - **⌨️ Keyboard Shortcut** - Press `alt + c` (or `option + c` on Mac) to quickly access 15 | - **🙈 Core CSS Field Hidden** - Replaces the default field in the Advanced panel 16 | 17 | ## 🚀 Getting Started 18 | 19 | 1. Upload and activate the plugin 20 | 2. Select any block in the editor 21 | 3. Find the token field in the Advanced panel 22 | 4. Add, remove, and manage CSS classes visually 23 | 24 | ## 💡 Key Features 25 | 26 | ### Inspector Panel 27 | - Add classes by typing and pressing Enter/Space 28 | - Remove classes by clicking × on any token 29 | - Use quick actions: Copy, Sort, Clear, Manage 30 | 31 | ### Modal Interface (`alt+c` / `option+c`) 32 | - **Token Management** - Add/remove with visual tokens 33 | - **Text Editing** - Edit classes directly in text area 34 | - **Sorting Options**: 35 | - Auto Sort (intelligent best practices) 36 | - Alphabetical 37 | - Length 38 | - Move block styles to end 39 | - **Cleanup**: 40 | - Clear custom classes 41 | - Clear all classes 42 | 43 | ## 🛠️ Developer Notes 44 | 45 | - Class names are validated using regex for proper CSS naming conventions 46 | - WordPress block style classes (`is-style-*`) receive special handling 47 | - Changes sync with the core WordPress class input field 48 | 49 | ## 🧪 Testing 50 | 51 | ClassAct uses PHPUnit for testing critical plugin functionality. The test suite covers: 52 | 53 | - Plugin initialization and assets loading 54 | - Automatic update system 55 | - PSR-4 autoloader functionality 56 | 57 | ### Running Tests 58 | 59 | 1. Install development dependencies: 60 | ```bash 61 | composer install 62 | ``` 63 | 64 | 2. Set up the WordPress test environment (requires MySQL): 65 | ```bash 66 | composer run setup-local-tests 67 | ``` 68 | 69 | 3. Run the tests: 70 | ```bash 71 | composer test 72 | ``` 73 | 74 | 4. Generate code coverage report: 75 | ```bash 76 | composer run test:coverage 77 | ``` 78 | 79 | ## 🔧 Customization 80 | 81 | ### `classact_keyboard_shortcut` Filter 82 | 83 | Customize the keyboard shortcut: 84 | 85 | ```php 86 | add_filter('classact_keyboard_shortcut', function($shortcut) { 87 | return array( 88 | 'modifier' => 'ctrl', 89 | 'character' => 'c', 90 | ); 91 | }); 92 | ``` 93 | 94 | Valid modifiers: `alt`, `ctrl`, `shift`, `meta` (Command on Mac) 95 | -------------------------------------------------------------------------------- /assets/blueprints/blueprint.json: -------------------------------------------------------------------------------- 1 | { 2 | "landingPage": "/wp-admin/post-new.php?post_type=page", 3 | "preferredVersions": { 4 | "php": "8.2", 5 | "wp": "latest" 6 | }, 7 | "steps": [ 8 | { 9 | "step": "login", 10 | "username": "admin", 11 | "password": "password" 12 | }, 13 | { 14 | "step": "installPlugin", 15 | "pluginZipFile": { 16 | "resource": "url", 17 | "url": "https://github.com/0aveRyan/classact/releases/latest/download/classact.zip" 18 | } 19 | }, 20 | { 21 | "step": "activatePlugin", 22 | "pluginName": "ClassAct" 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /bin/install-wp-tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ $# -lt 3 ]; then 4 | echo "usage: $0 [db-host] [wp-version] [skip-database-creation]" 5 | exit 1 6 | fi 7 | 8 | DB_NAME=$1 9 | DB_USER=$2 10 | DB_PASS=$3 11 | DB_HOST=${4-localhost} 12 | WP_VERSION=${5-latest} 13 | SKIP_DB_CREATE=${6-false} 14 | 15 | TMPDIR=${TMPDIR-/tmp} 16 | TMPDIR=$(echo $TMPDIR | sed -e "s/\/$//") 17 | WP_TESTS_DIR=${WP_TESTS_DIR-$TMPDIR/wordpress-tests-lib} 18 | WP_CORE_DIR=${WP_CORE_DIR-$TMPDIR/wordpress/} 19 | 20 | download() { 21 | if [ `which curl` ]; then 22 | curl -s "$1" > "$2"; 23 | elif [ `which wget` ]; then 24 | wget -nv -O "$2" "$1" 25 | fi 26 | } 27 | 28 | if [[ $WP_VERSION =~ ^[0-9]+\.[0-9]+$ ]]; then 29 | WP_TESTS_TAG="branches/$WP_VERSION" 30 | elif [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0-9]+ ]]; then 31 | if [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0] ]]; then 32 | # version x.x.0 means the first release of the major version, so strip off the .0 and download version x.x 33 | WP_TESTS_TAG="tags/${WP_VERSION%??}" 34 | else 35 | WP_TESTS_TAG="tags/$WP_VERSION" 36 | fi 37 | elif [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then 38 | WP_TESTS_TAG="trunk" 39 | else 40 | # http serves a single offer, whereas https serves multiple. we only want one 41 | download http://api.wordpress.org/core/version-check/1.7/ /tmp/wp-latest.json 42 | grep '[0-9]+\.[0-9]+(\.[0-9]+)?' /tmp/wp-latest.json 43 | LATEST_VERSION=$(grep -o '"version":"[^"]*' /tmp/wp-latest.json | sed 's/"version":"//') 44 | if [[ -z "$LATEST_VERSION" ]]; then 45 | echo "Latest WordPress version could not be found" 46 | exit 1 47 | fi 48 | WP_TESTS_TAG="tags/$LATEST_VERSION" 49 | fi 50 | 51 | set -ex 52 | 53 | install_wp() { 54 | 55 | if [ -d $WP_CORE_DIR ]; then 56 | return; 57 | fi 58 | 59 | mkdir -p $WP_CORE_DIR 60 | 61 | if [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then 62 | mkdir -p $TMPDIR/wordpress-nightly 63 | download https://wordpress.org/nightly-builds/wordpress-latest.zip $TMPDIR/wordpress-nightly/wordpress-nightly.zip 64 | unzip -q $TMPDIR/wordpress-nightly/wordpress-nightly.zip -d $TMPDIR/wordpress-nightly/ 65 | mv $TMPDIR/wordpress-nightly/wordpress/* $WP_CORE_DIR 66 | else 67 | if [ $WP_VERSION == 'latest' ]; then 68 | local ARCHIVE_NAME='latest' 69 | elif [[ $WP_VERSION =~ [0-9]+\.[0-9]+ ]]; then 70 | # https serves multiple offers, whereas http serves single. 71 | download https://api.wordpress.org/core/version-check/1.7/ $TMPDIR/wp-latest.json 72 | if [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0] ]]; then 73 | # version x.x.0 means the first release of the major version, so strip off the .0 and download version x.x 74 | LATEST_VERSION=${WP_VERSION%??} 75 | else 76 | # otherwise, scan the releases and get the most up to date minor version of the major release 77 | local VERSION_ESCAPED=`echo $WP_VERSION | sed 's/\./\\\\./g'` 78 | LATEST_VERSION=$(grep -o '"version":"'$VERSION_ESCAPED'[^"]*' $TMPDIR/wp-latest.json | sed 's/"version":"//' | head -1) 79 | fi 80 | if [[ -z "$LATEST_VERSION" ]]; then 81 | local ARCHIVE_NAME="wordpress-$WP_VERSION" 82 | else 83 | local ARCHIVE_NAME="wordpress-$LATEST_VERSION" 84 | fi 85 | else 86 | local ARCHIVE_NAME="wordpress-$WP_VERSION" 87 | fi 88 | download https://wordpress.org/${ARCHIVE_NAME}.tar.gz $TMPDIR/wordpress.tar.gz 89 | tar --strip-components=1 -zxmf $TMPDIR/wordpress.tar.gz -C $WP_CORE_DIR 90 | fi 91 | 92 | download https://raw.github.com/markoheijnen/wp-mysqli/master/db.php $WP_CORE_DIR/wp-content/db.php 93 | } 94 | 95 | install_test_suite() { 96 | # portable in-place argument for both GNU sed and Mac OSX sed 97 | if [[ $(uname -s) == 'Darwin' ]]; then 98 | local ioption='-i.bak' 99 | else 100 | local ioption='-i' 101 | fi 102 | 103 | # set up testing suite if it doesn't yet exist 104 | if [ ! -d $WP_TESTS_DIR ]; then 105 | # set up testing suite 106 | mkdir -p $WP_TESTS_DIR 107 | svn co --quiet https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/includes/ $WP_TESTS_DIR/includes 108 | svn co --quiet https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/data/ $WP_TESTS_DIR/data 109 | fi 110 | 111 | if [ ! -f wp-tests-config.php ]; then 112 | download https://develop.svn.wordpress.org/${WP_TESTS_TAG}/wp-tests-config-sample.php "$WP_TESTS_DIR"/wp-tests-config.php 113 | # remove all forward slashes in the end 114 | WP_CORE_DIR=$(echo $WP_CORE_DIR | sed "s:/\+$::") 115 | sed $ioption "s:dirname( __FILE__ ) . '/src/':'$WP_CORE_DIR/':" "$WP_TESTS_DIR"/wp-tests-config.php 116 | sed $ioption "s/youremptytestdbnamehere/$DB_NAME/" "$WP_TESTS_DIR"/wp-tests-config.php 117 | sed $ioption "s/yourusernamehere/$DB_USER/" "$WP_TESTS_DIR"/wp-tests-config.php 118 | sed $ioption "s/yourpasswordhere/$DB_PASS/" "$WP_TESTS_DIR"/wp-tests-config.php 119 | sed $ioption "s|localhost|${DB_HOST}|" "$WP_TESTS_DIR"/wp-tests-config.php 120 | fi 121 | 122 | } 123 | 124 | install_db() { 125 | 126 | if [ ${SKIP_DB_CREATE} = "true" ]; then 127 | return 0 128 | fi 129 | 130 | # parse DB_HOST for port or socket references 131 | local PARTS=(${DB_HOST//\:/ }) 132 | local DB_HOSTNAME=${PARTS[0]}; 133 | local DB_SOCK_OR_PORT=${PARTS[1]}; 134 | local EXTRA="" 135 | 136 | if ! [ -z $DB_HOSTNAME ] ; then 137 | if [ $(echo $DB_SOCK_OR_PORT | grep -e '^[0-9]\{1,\}$') ]; then 138 | EXTRA=" --host=$DB_HOSTNAME --port=$DB_SOCK_OR_PORT --protocol=tcp" 139 | elif ! [ -z $DB_SOCK_OR_PORT ] ; then 140 | EXTRA=" --socket=$DB_SOCK_OR_PORT" 141 | elif ! [ -z $DB_HOSTNAME ] ; then 142 | EXTRA=" --host=$DB_HOSTNAME --protocol=tcp" 143 | fi 144 | fi 145 | 146 | # create database 147 | mysqladmin create $DB_NAME --user="$DB_USER" --password="$DB_PASS"$EXTRA 148 | } 149 | 150 | install_wp 151 | install_test_suite 152 | install_db -------------------------------------------------------------------------------- /classact.php: -------------------------------------------------------------------------------- 1 | =7.4" 14 | }, 15 | "require-dev": { 16 | "phpunit/phpunit": "^12.1.4", 17 | "yoast/phpunit-polyfills": "^4.0.0", 18 | "brain/monkey": "^2.6.2", 19 | "wp-coding-standards/wpcs": "^3.1.0", 20 | "dealerdirect/phpcodesniffer-composer-installer": "^v1.0.0", 21 | "phpcompatibility/phpcompatibility-wp": "^2.1.6", 22 | "php-parallel-lint/php-parallel-lint": "^v1.4.0" 23 | }, 24 | "scripts": { 25 | "test": "phpunit", 26 | "test:coverage": "phpunit --coverage-clover=coverage.xml", 27 | "setup-local-tests": "bash bin/install-wp-tests.sh wordpress_test root root localhost latest", 28 | "lint": "parallel-lint --exclude vendor .", 29 | "phpcs": "phpcs", 30 | "phpcs:fix": "phpcbf", 31 | "phpcompat": "phpcs -p --standard=PHPCompatibilityWP --runtime-set testVersion 7.4- ." 32 | }, 33 | "config": { 34 | "allow-plugins": { 35 | "dealerdirect/phpcodesniffer-composer-installer": true 36 | } 37 | }, 38 | "autoload-dev": { 39 | "psr-4": { 40 | "ClassAct\\Tests\\": "tests/phpunit/" 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /includes/Plugin.php: -------------------------------------------------------------------------------- 1 | init_hooks(); 66 | $this->init_updater(); 67 | } 68 | 69 | /** 70 | * Initialize WordPress hooks 71 | * 72 | * Sets up all the necessary WordPress action and filter hooks 73 | * that the plugin needs to function properly. 74 | * 75 | * @since 1.0.0 76 | * @access private 77 | */ 78 | private function init_hooks() { 79 | // Load translations when WordPress initializes. 80 | add_action( 'init', array( $this, 'load_textdomain' ) ); 81 | 82 | // Load the editor assets only when the block editor is active. 83 | add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_editor_assets' ) ); 84 | } 85 | 86 | /** 87 | * Initialize the automatic update system 88 | * 89 | * Sets up the custom updater for the plugin when appropriate. 90 | * Only runs in the admin area and when auto-updates aren't disabled. 91 | * 92 | * @since 1.0.0 93 | * @access private 94 | */ 95 | private function init_updater() { 96 | // Only initialize the updater in the admin area. 97 | if ( is_admin() && ! wp_doing_ajax() && ! CLASSACT_DISABLE_AUTOUPDATE ) { 98 | new Updater( 99 | CLASSACT_FILE, 100 | 'classact', 101 | CLASSACT_VERSION 102 | ); 103 | } 104 | } 105 | 106 | /** 107 | * Load plugin translations 108 | * 109 | * Loads the text domain for translations from the languages directory. 110 | * Used for internationalizing the plugin. 111 | * 112 | * @since 1.0.0 113 | * @access public 114 | */ 115 | public function load_textdomain() { 116 | load_plugin_textdomain( 'classact', false, dirname( plugin_basename( CLASSACT_FILE ) ) . '/languages' ); 117 | } 118 | 119 | /** 120 | * Enqueue block editor assets 121 | * 122 | * Loads the JavaScript and CSS assets for the block editor integration. 123 | * This includes the main functionality for the ClassAct plugin. 124 | * 125 | * @since 1.0.0 126 | * @access public 127 | */ 128 | public function enqueue_editor_assets() { 129 | // Get the asset information (version and dependencies). 130 | $asset_file = include CLASSACT_BUILD_DIR . '/editor.asset.php'; 131 | 132 | // Enqueue the main editor script. 133 | wp_enqueue_script( 134 | 'classact-editor', 135 | CLASSACT_BUILD_URL . '/editor.js', 136 | $asset_file['dependencies'], 137 | $asset_file['version'], 138 | true 139 | ); 140 | 141 | // Enqueue the editor styles. 142 | wp_enqueue_style( 143 | 'classact-editor', 144 | CLASSACT_BUILD_URL . '/editor.css', 145 | array(), 146 | $asset_file['version'] 147 | ); 148 | 149 | // Set up translations for the editor script. 150 | wp_set_script_translations( 'classact-editor', 'classact' ); 151 | 152 | // Define default keyboard shortcut. 153 | $keyboard_shortcut = array( 154 | 'modifier' => 'alt', 155 | 'character' => 'c', 156 | ); 157 | 158 | // Allow filtering the keyboard shortcut. 159 | $keyboard_shortcut = apply_filters( 'classact_keyboard_shortcut', $keyboard_shortcut ); 160 | 161 | // Localize the script with the keyboard shortcut. 162 | wp_localize_script( 163 | 'classact-editor', 164 | 'classactConfig', 165 | array( 166 | 'keyboardShortcut' => $keyboard_shortcut, 167 | ) 168 | ); 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /includes/Updater.php: -------------------------------------------------------------------------------- 1 | plugin_basename = plugin_basename( $plugin_file ); 119 | $this->plugin_slug = sanitize_key( $plugin_slug ); 120 | $this->version = sanitize_text_field( $version ); 121 | $this->cache_key = 'classact_' . $this->plugin_slug . '_update_data'; 122 | 123 | // Hook into WordPress update system. 124 | add_filter( 'pre_set_site_transient_update_plugins', array( $this, 'check_for_update' ) ); 125 | 126 | // Filter the plugin API response for detailed information. 127 | add_filter( 'plugins_api', array( $this, 'plugins_api_filter' ), 10, 3 ); 128 | 129 | // Clean up after ourselves by removing the filter when the plugin is updated. 130 | add_action( 'upgrader_process_complete', array( $this, 'clear_update_cache' ), 10, 2 ); 131 | 132 | // Add our self-hosted plugin updater message. 133 | add_filter( 'plugin_row_meta', array( $this, 'add_plugin_update_info' ), 10, 2 ); 134 | } 135 | 136 | 137 | /** 138 | * Add information about the update server to the plugin row 139 | * 140 | * @param array $plugin_meta The array of metadata. 141 | * @param string $plugin_file The plugin filename. 142 | * @return array Modified plugin metadata array. 143 | */ 144 | public function add_plugin_update_info( $plugin_meta, $plugin_file ) { 145 | if ( $plugin_file === $this->plugin_basename ) { 146 | $plugin_meta[] = sprintf( 147 | '%s', 148 | esc_html__( 'Updates via Cloudflare & GitHub', 'classact' ) 149 | ); 150 | } 151 | return $plugin_meta; 152 | } 153 | 154 | /** 155 | * Check for updates when WordPress checks for updates 156 | * 157 | * @param object $transient The update_plugins transient object. 158 | * @return object Modified transient object. 159 | */ 160 | public function check_for_update( $transient ) { 161 | if ( empty( $transient->checked ) ) { 162 | return $transient; 163 | } 164 | 165 | // Get update data. 166 | $update_data = $this->get_update_data(); 167 | 168 | if ( false === $update_data ) { 169 | return $transient; 170 | } 171 | 172 | // If there's a newer version, add it to the transient. 173 | if ( isset( $update_data->version ) && version_compare( $this->version, $update_data->version, '<' ) ) { 174 | $plugin_info = new \stdClass(); 175 | $plugin_info->slug = $this->plugin_slug; 176 | $plugin_info->plugin = $this->plugin_basename; 177 | $plugin_info->new_version = $update_data->version; 178 | $plugin_info->url = $update_data->homepage ?? ''; 179 | $plugin_info->package = $update_data->download_url ?? ''; 180 | 181 | // Include icons if available. 182 | if ( isset( $update_data->icons ) && is_object( $update_data->icons ) ) { 183 | $plugin_info->icons = (array) $update_data->icons; 184 | } 185 | 186 | // Include banners if available. 187 | if ( isset( $update_data->banners ) && is_object( $update_data->banners ) ) { 188 | $plugin_info->banners = (array) $update_data->banners; 189 | } 190 | 191 | // Add tested up to if available. 192 | if ( isset( $update_data->tested ) ) { 193 | $plugin_info->tested = $update_data->tested; 194 | } 195 | 196 | // Add requires PHP if available. 197 | if ( isset( $update_data->requires_php ) ) { 198 | $plugin_info->requires_php = $update_data->requires_php; 199 | } 200 | 201 | $transient->response[ $this->plugin_basename ] = $plugin_info; 202 | } 203 | 204 | return $transient; 205 | } 206 | 207 | /** 208 | * Provide plugin information for the WordPress plugin API 209 | * 210 | * @param false|object|array $result The result object or array. 211 | * @param string $action The API action being performed. 212 | * @param object $args Arguments for the API request. 213 | * @return false|object The API response or unchanged result. 214 | */ 215 | public function plugins_api_filter( $result, $action, $args ) { 216 | // Only filter for plugin information API requests for our plugin. 217 | if ( 'plugin_information' !== $action || ! isset( $args->slug ) || $args->slug !== $this->plugin_slug ) { 218 | return $result; 219 | } 220 | 221 | $update_data = $this->get_update_data(); 222 | 223 | if ( false === $update_data ) { 224 | return $result; 225 | } 226 | 227 | // Convert to the format WordPress expects. 228 | $api_response = new \stdClass(); 229 | $api_response->name = $update_data->name ?? $this->plugin_slug; 230 | $api_response->slug = $this->plugin_slug; 231 | $api_response->version = $update_data->version; 232 | $api_response->author = $update_data->author ?? ''; 233 | $api_response->homepage = $update_data->homepage ?? ''; 234 | $api_response->requires = $update_data->requires ?? ''; 235 | $api_response->tested = $update_data->tested ?? ''; 236 | $api_response->requires_php = $update_data->requires_php ?? ''; 237 | $api_response->downloaded = 0; 238 | $api_response->download_link = $update_data->download_url ?? ''; 239 | 240 | // Add banners if available. 241 | if ( isset( $update_data->banners ) && is_object( $update_data->banners ) ) { 242 | $api_response->banners = (array) $update_data->banners; 243 | } 244 | 245 | // Add icons if available. 246 | if ( isset( $update_data->icons ) && is_object( $update_data->icons ) ) { 247 | $api_response->icons = (array) $update_data->icons; 248 | } 249 | 250 | // Add sections if available (description, changelog, etc.). 251 | if ( isset( $update_data->sections ) && is_object( $update_data->sections ) ) { 252 | $api_response->sections = (array) $update_data->sections; 253 | } else { 254 | $api_response->sections = array( 255 | 'description' => isset( $update_data->description ) ? $update_data->description : '', 256 | 'changelog' => isset( $update_data->changelog ) ? $update_data->changelog : '', 257 | ); 258 | } 259 | 260 | return $api_response; 261 | } 262 | 263 | /** 264 | * Get plugin update data from the API 265 | * 266 | * @return false|object Update data or false on error 267 | */ 268 | private function get_update_data() { 269 | // Check cache first. 270 | $cached_data = get_transient( $this->cache_key ); 271 | if ( false !== $cached_data ) { 272 | return json_decode( $cached_data ); 273 | } 274 | 275 | // Build the request URL. 276 | $request_params = array( 277 | 'plugin_slug' => $this->plugin_slug, 278 | 'version' => $this->version, 279 | ); 280 | 281 | $request_url = add_query_arg( $request_params, $this->api_url ); 282 | 283 | // Make the request with proper timeout and security. 284 | $response = wp_remote_get( 285 | $request_url, 286 | array( 287 | 'timeout' => 10, 288 | 'sslverify' => true, 289 | 'headers' => array( 290 | 'Accept' => 'application/json', 291 | ), 292 | ) 293 | ); 294 | 295 | // Handle errors. 296 | if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { 297 | return false; 298 | } 299 | 300 | // Parse the response. 301 | $response_body = wp_remote_retrieve_body( $response ); 302 | $update_data = json_decode( $response_body ); 303 | 304 | // Validate the response. 305 | if ( ! is_object( $update_data ) || empty( $update_data->version ) ) { 306 | return false; 307 | } 308 | 309 | // Cache the result. 310 | set_transient( $this->cache_key, $response_body, $this->cache_expiration * HOUR_IN_SECONDS ); 311 | 312 | return $update_data; 313 | } 314 | 315 | /** 316 | * Clear update cache when plugin is updated 317 | * 318 | * @param \WP_Upgrader $upgrader WP_Upgrader instance. 319 | * @param array $options Array of bulk item update data. 320 | */ 321 | public function clear_update_cache( $upgrader, $options ) { 322 | if ( 'update' === $options['action'] && 'plugin' === $options['type'] ) { 323 | // Check if our plugin was updated. 324 | if ( isset( $options['plugins'] ) && is_array( $options['plugins'] ) ) { 325 | if ( in_array( $this->plugin_basename, $options['plugins'], true ) ) { 326 | // Clear our transient. 327 | delete_transient( $this->cache_key ); 328 | } 329 | } 330 | } 331 | } 332 | } 333 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "classact", 3 | "version": "2.1.0", 4 | "main": "index.js", 5 | "scripts": { 6 | "start": "wp-scripts start", 7 | "build": "wp-scripts build", 8 | "format": "wp-scripts format", 9 | "plugin-zip": "wp-scripts plugin-zip" 10 | }, 11 | "author": "Dave Ryan", 12 | "license": "GPL-2.0-or-later", 13 | "description": "", 14 | "devDependencies": { 15 | "@wordpress/base-styles": "^5.22.0", 16 | "@wordpress/block-editor": "^14.17.0", 17 | "@wordpress/components": "^29.8.0", 18 | "@wordpress/element": "^6.22.0", 19 | "@wordpress/scripts": "^30.15.0", 20 | "webpack-merge": "^6.0.1" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | ./tests/phpunit/includes 14 | 15 | 16 | 17 | 18 | ./includes 19 | ./classact.php 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/editor/classact/accessibility.js: -------------------------------------------------------------------------------- 1 | /** 2 | * ClassAct - Accessibility Utilities 3 | * 4 | * This module provides accessibility-focused components and hooks for the ClassAct plugin. 5 | * It includes ARIA live regions, feedback management, and accessible button components. 6 | * These utilities help ensure the plugin meets WCAG accessibility guidelines. 7 | * 8 | * @module ClassAct/Accessibility 9 | * @since 2.0.0 10 | */ 11 | 12 | /** 13 | * WordPress dependencies 14 | */ 15 | import { Button } from '@wordpress/components'; 16 | import { useState, useEffect, forwardRef } from '@wordpress/element'; 17 | import { __ } from '@wordpress/i18n'; 18 | 19 | /** 20 | * Internal dependencies 21 | */ 22 | import { ARIA_MESSAGES, TIMING } from './utils'; 23 | 24 | /** 25 | * ARIA Live Region Announcement Component 26 | * 27 | * A centralized component for announcing status messages to screen readers. 28 | * This component creates a visually hidden region that screen readers will 29 | * announce when its content changes. It manages the announcement lifecycle, 30 | * including clearing the announcement after a specified duration. 31 | * 32 | * @since 2.0.0 33 | * 34 | * @param {Object} props Component props 35 | * @param {string} props.message The message to announce to screen readers 36 | * @param {number} props.duration How long the message persists (ms), defaults to 2000ms 37 | * @returns {JSX.Element} The ARIA live region announcement component 38 | * 39 | * @example 40 | * ```jsx 41 | * 42 | * ``` 43 | */ 44 | export const AriaLiveAnnouncement = ( { 45 | message, 46 | duration = TIMING.ANNOUNCEMENT_DURATION, 47 | } ) => { 48 | // Store the current announcement message 49 | const [ statusMessage, setStatusMessage ] = useState( message || '' ); 50 | 51 | // Update the announcement when the message changes 52 | useEffect( () => { 53 | // Don't do anything if there's no message to announce 54 | if ( ! message ) return; 55 | 56 | // Set the message to be announced 57 | setStatusMessage( message ); 58 | 59 | // Clear the message after the specified duration 60 | const timer = setTimeout( () => { 61 | setStatusMessage( '' ); 62 | }, duration ); 63 | 64 | // Clean up the timer if the component unmounts 65 | return () => clearTimeout( timer ); 66 | }, [ message, duration ] ); 67 | 68 | return ( 69 | 76 | ); 77 | }; 78 | 79 | /** 80 | * Feedback Management Hook 81 | * 82 | * Custom hook for managing temporary feedback states and accessibility announcements. 83 | * Provides utilities to display temporary feedback messages to users and 84 | * announce changes to screen readers. 85 | * 86 | * @since 2.0.0 87 | * 88 | * @param {Object} options Hook options 89 | * @param {string} options.successMessage The message to announce on success 90 | * @param {number} options.duration How long the feedback should persist (ms) 91 | * @returns {Object} Object containing state and methods for managing feedback 92 | * 93 | * @example 94 | * ```jsx 95 | * const feedback = useFeedback({ 96 | * successMessage: 'Item copied to clipboard', 97 | * duration: 3000 98 | * }); 99 | * 100 | * // Later in your code: 101 | * feedback.activate(); // Shows feedback and announces message 102 | * ``` 103 | */ 104 | export const useFeedback = ( { 105 | successMessage, 106 | duration = TIMING.FEEDBACK_DURATION, 107 | } ) => { 108 | // Track if feedback is currently being shown 109 | const [ isActive, setIsActive ] = useState( false ); 110 | 111 | // Store the current announcement message 112 | const [ message, setMessage ] = useState( '' ); 113 | 114 | /** 115 | * Activate the feedback state 116 | * 117 | * @param {string} [customMessage] Optional custom message to override the default 118 | */ 119 | const activate = ( customMessage ) => { 120 | // Show the feedback UI 121 | setIsActive( true ); 122 | 123 | // Set the message for screen readers 124 | setMessage( customMessage || successMessage ); 125 | 126 | // Automatically clear the feedback after the duration 127 | setTimeout( () => { 128 | setIsActive( false ); 129 | setMessage( '' ); 130 | }, duration ); 131 | }; 132 | 133 | /** 134 | * Immediately clear the feedback state 135 | */ 136 | const clearFeedback = () => { 137 | setIsActive( false ); 138 | setMessage( '' ); 139 | }; 140 | 141 | return { 142 | isActive, 143 | message, 144 | activate, 145 | clearFeedback, 146 | }; 147 | }; 148 | 149 | /** 150 | * Accessible Button with Visual Feedback 151 | * 152 | * A reusable component for buttons that provide both visual feedback 153 | * and screen reader announcements when activated. Combines the WordPress 154 | * Button component with accessibility features. 155 | * 156 | * @since 2.0.0 157 | * 158 | * @param {Object} props Component props 159 | * @param {ReactNode} props.children Button label content when not in feedback state 160 | * @param {string} props.feedbackText Text to show when feedback is active 161 | * @param {string} props.ariaMessage Screen reader announcement when button is activated 162 | * @param {Function} props.onClick Additional click handler function 163 | * @param {string} props.ariaLabel Accessible description of the button's action 164 | * @param {Object} props.icon Icon component to display in the button 165 | * @param {Object} props.buttonProps Additional props passed to the Button component 166 | * @param {Object} ref Forwarded ref to the button element 167 | * @returns {JSX.Element} Accessible button with feedback capability 168 | * 169 | * @example 170 | * ```jsx 171 | * copyToClipboard(text)} 176 | * ariaLabel="Copy text to clipboard" 177 | * > 178 | * Copy 179 | * 180 | * ``` 181 | */ 182 | export const FeedbackButton = forwardRef( 183 | ( 184 | { 185 | children, 186 | feedbackText, 187 | ariaMessage, 188 | onClick, 189 | ariaLabel, 190 | icon, 191 | ...buttonProps 192 | }, 193 | ref 194 | ) => { 195 | // Set up feedback management 196 | const feedback = useFeedback( { 197 | successMessage: ariaMessage, 198 | } ); 199 | 200 | /** 201 | * Handle button click with feedback 202 | * 203 | * @param {Event} event Click event 204 | */ 205 | const handleClick = ( event ) => { 206 | // Activate feedback 207 | feedback.activate(); 208 | 209 | // Call the provided onClick handler if it exists 210 | if ( onClick ) { 211 | onClick( event ); 212 | } 213 | }; 214 | 215 | return ( 216 | <> 217 | 226 | 227 | 228 | ); 229 | } 230 | ); 231 | -------------------------------------------------------------------------------- /src/editor/classact/components.js: -------------------------------------------------------------------------------- 1 | /** 2 | * ClassAct - Component Definitions 3 | */ 4 | 5 | /** 6 | * WordPress dependencies 7 | */ 8 | import { store as blockEditorStore } from '@wordpress/block-editor'; 9 | import { 10 | Button, 11 | FormTokenField, 12 | Icon, 13 | Modal, 14 | Notice, 15 | TextareaControl, 16 | } from '@wordpress/components'; 17 | import { useCopyToClipboard } from '@wordpress/compose'; 18 | import { useSelect } from '@wordpress/data'; 19 | import { createPortal, useEffect } from '@wordpress/element'; 20 | import { __ } from '@wordpress/i18n'; 21 | import { 22 | check, 23 | copySmall, 24 | formatLowercase, 25 | formatOutdentRTL, 26 | listView, 27 | moveTo, 28 | styles, 29 | tableRowDelete, 30 | trash, 31 | } from '@wordpress/icons'; 32 | 33 | /** 34 | * Internal dependencies 35 | */ 36 | import { 37 | AriaLiveAnnouncement, 38 | FeedbackButton, 39 | useFeedback, 40 | } from './accessibility'; 41 | import { useModalContext } from './context'; 42 | import { 43 | getGroupVariationType, 44 | useBlockAttributes, 45 | useBlockTitle, 46 | useClassManagement, 47 | } from './hooks'; 48 | import { 49 | ARIA_MESSAGES, 50 | autoSortClasses, 51 | clearExceptStyleClasses, 52 | moveStyleClassToEnd, 53 | parseClassNames, 54 | sortClassesAlphabetically, 55 | sortClassesByLength, 56 | TIMING, 57 | } from './utils'; 58 | 59 | /** 60 | * BlockTitleDisplay Component 61 | * Displays a block title with icon in WordPress native style 62 | * Enhanced to properly handle group block variations 63 | */ 64 | export const BlockTitleDisplay = ( { clientId, title } ) => { 65 | const blockInfo = useSelect( 66 | ( select ) => { 67 | if ( ! clientId ) return { icon: null, displayTitle: title || '' }; 68 | 69 | const editor = select( blockEditorStore ); 70 | const blockName = editor.getBlockName( clientId ); 71 | const blockAttributes = editor.getBlockAttributes( clientId ); 72 | const blockRegistry = select( 'core/blocks' ); 73 | const blockType = blockRegistry.getBlockType( blockName ); 74 | 75 | let displayTitle = title || ''; 76 | let blockIcon = blockType?.icon?.src || null; 77 | 78 | // If it's a core/group block and we don't have a custom title yet, 79 | // explicitly set the title based on its variation type 80 | if ( blockName === 'core/group' && ! title ) { 81 | const variationType = getGroupVariationType( blockAttributes ); 82 | displayTitle = variationType; 83 | } 84 | 85 | return { 86 | icon: blockIcon, 87 | displayTitle: displayTitle, 88 | }; 89 | }, 90 | [ clientId, title ] 91 | ); 92 | 93 | return ( 94 |
95 |
96 | { blockInfo.icon && ( 97 | 98 | 99 | 100 | ) } 101 | 102 | { blockInfo.displayTitle } 103 | 104 |
105 |
106 | ); 107 | }; 108 | 109 | /** 110 | * CopyButton component for copying class names to clipboard 111 | */ 112 | export const CopyButton = ( { 113 | className, 114 | variant = 'secondary', 115 | size = 'small', 116 | } ) => { 117 | const copyRef = useCopyToClipboard( 118 | () => className || '', 119 | () => {} 120 | ); 121 | 122 | return ( 123 | 132 | { __( 'Copy' ) } 133 | 134 | ); 135 | }; 136 | 137 | /** 138 | * SortButton component for sorting class names with temporary feedback 139 | */ 140 | export const SortButton = ( { 141 | classesArray, 142 | handleClassChange, 143 | variant = 'secondary', 144 | size = 'small', 145 | } ) => { 146 | const handleSort = () => { 147 | handleClassChange( autoSortClasses( classesArray ) ); 148 | }; 149 | 150 | return ( 151 | 160 | { __( 'Sort' ) } 161 | 162 | ); 163 | }; 164 | 165 | /** 166 | * ClearButton component for clearing class names with temporary feedback 167 | */ 168 | export const ClearButton = ( { 169 | setAttributes, 170 | variant = 'secondary', 171 | size = 'small', 172 | } ) => { 173 | const handleClear = () => { 174 | setAttributes( { className: '' } ); 175 | }; 176 | 177 | return ( 178 | 188 | { __( 'Clear' ) } 189 | 190 | ); 191 | }; 192 | 193 | /** 194 | * AutoSortButton component for the modal 195 | */ 196 | export const AutoSortButton = ( { classesArray, handleClassChange } ) => { 197 | const handleAutoSort = () => { 198 | handleClassChange( autoSortClasses( classesArray ) ); 199 | }; 200 | 201 | return ( 202 | 211 | { __( 'Auto Sort' ) } 212 | 213 | ); 214 | }; 215 | 216 | /** 217 | * AlphaSortButton component for the modal 218 | */ 219 | export const AlphaSortButton = ( { classesArray, handleClassChange } ) => { 220 | const handleAlphaSort = () => { 221 | handleClassChange( sortClassesAlphabetically( classesArray ) ); 222 | }; 223 | 224 | return ( 225 | 234 | { __( 'Alpha Sort' ) } 235 | 236 | ); 237 | }; 238 | 239 | /** 240 | * LengthSortButton component for the modal 241 | */ 242 | export const LengthSortButton = ( { classesArray, handleClassChange } ) => { 243 | const handleLengthSort = () => { 244 | handleClassChange( sortClassesByLength( classesArray ) ); 245 | }; 246 | 247 | return ( 248 | 257 | { __( 'Length Sort' ) } 258 | 259 | ); 260 | }; 261 | 262 | /** 263 | * StyleToEndButton component for the modal 264 | */ 265 | export const StyleToEndButton = ( { classesArray, handleClassChange } ) => { 266 | const handleMoveToEnd = () => { 267 | handleClassChange( moveStyleClassToEnd( classesArray ) ); 268 | }; 269 | 270 | return ( 271 | 280 | { __( 'Block Style to End' ) } 281 | 282 | ); 283 | }; 284 | 285 | /** 286 | * ClearCustomButton component for the modal 287 | */ 288 | export const ClearCustomButton = ( { classesArray, handleClassChange } ) => { 289 | const handleClearCustom = () => { 290 | handleClassChange( clearExceptStyleClasses( classesArray ) ); 291 | }; 292 | 293 | return ( 294 | 306 | { __( 'Clear Custom' ) } 307 | 308 | ); 309 | }; 310 | 311 | /** 312 | * ClearAllButton component for the modal 313 | */ 314 | export const ClearAllButton = ( { handleTextAreaChange } ) => { 315 | const handleClearAll = () => { 316 | handleTextAreaChange( '' ); 317 | }; 318 | 319 | return ( 320 | 330 | { __( 'Clear All' ) } 331 | 332 | ); 333 | }; 334 | 335 | /** 336 | * Global modal container that listens for open requests 337 | */ 338 | export const ClassActModal = () => { 339 | const { isOpen, blockClientId, closeModal } = useModalContext(); 340 | 341 | // Return early if modal is not open or no block is selected 342 | if ( ! isOpen || ! blockClientId ) { 343 | return null; 344 | } 345 | 346 | // Use createPortal to render the modal in the #classact-modal-root 347 | return createPortal( 348 | , 352 | document.getElementById( 'classact-modal-root' ) 353 | ); 354 | }; 355 | 356 | /** 357 | * Core class management modal content component 358 | * Displays the main modal UI without any data handling logic 359 | */ 360 | export const ClassManagementModalContent = ( { 361 | classesArray, 362 | classManagement, 363 | initialClasses, 364 | blockDisplayTitle, 365 | onRequestClose, 366 | clientId, 367 | } ) => { 368 | return ( 369 | 382 | } 383 | > 384 | { classManagement.errorMessage && ( 385 | classManagement.setErrorMessage( '' ) } 389 | className="classact-error-message" 390 | politeness="assertive" 391 | > 392 | 393 | { classManagement.errorMessage } 394 | 395 | 396 | ) } 397 | 398 |
399 | 403 | 404 |
405 | { Array.isArray(classesArray) && classesArray ? classesArray.length : 0 } 406 |
407 |
408 | 409 | 415 | classManagement.isValidClass( token ) 416 | } 417 | __experimentalShowHowTo={ false } 418 | tokenizeOnSpace 419 | tokenizeOnBlur 420 | __next40pxDefaultSize 421 | __nextHasNoMarginBottom 422 | help={ __( 423 | 'Type class names and press Enter or Space to add. Press backspace to remove.' 424 | ) } 425 | aria-describedby={ 426 | classManagement.errorMessage 427 | ? 'classact-error-message' 428 | : undefined 429 | } 430 | aria-label={ __( 'CSS classes for' ) + ' ' + blockDisplayTitle } 431 | maxSuggestions={ 100 } 432 | /> 433 | 434 |
435 | 436 | 451 | 452 |
453 | 457 | 461 | 465 | 469 | 473 | 476 |
477 | 478 | ); 479 | }; 480 | 481 | /** 482 | * Connected version of the modal that gets data from block context 483 | */ 484 | export const ConnectedClassActModal = ( { clientId, onRequestClose } ) => { 485 | // Get block data through the block attributes hook 486 | const blockAttrs = useBlockAttributes( { clientId } ); 487 | 488 | // Get the initial class string from the block attributes 489 | const initialClasses = blockAttrs?.attributes?.className; 490 | 491 | // Use the class management hook 492 | const classManagement = useClassManagement( { 493 | initialClasses, 494 | onChange: ( newClassName ) => { 495 | if ( blockAttrs ) { 496 | blockAttrs.updateClassName( newClassName ); 497 | } 498 | }, 499 | onTextChange: ( value ) => { 500 | if ( blockAttrs ) { 501 | blockAttrs.updateClassName( value ); 502 | } 503 | }, 504 | } ); 505 | 506 | // Initialize textarea value when initial classes change 507 | useEffect( () => { 508 | if ( initialClasses !== undefined ) { 509 | classManagement.handleTextChange( initialClasses || '' ); 510 | } 511 | }, [ initialClasses ] ); 512 | 513 | // Get the block's display title 514 | const blockDisplayTitle = useBlockTitle( { 515 | clientId, 516 | fallbackName: blockAttrs?.blockName, 517 | } ); 518 | 519 | // If no block data yet, return null 520 | if ( ! blockAttrs || ! classManagement || ! Array.isArray(classManagement.classesArray) ) { 521 | return null; 522 | } 523 | 524 | return ( 525 | 533 | ); 534 | }; 535 | 536 | /** 537 | * Controlled version of the modal for direct state management 538 | */ 539 | export const ControlledClassActModal = ( { 540 | onRequestClose, 541 | classes, 542 | name, 543 | array, 544 | setAttributes, 545 | updateArray, 546 | } ) => { 547 | // Use the class management hook 548 | const classManagement = useClassManagement( { 549 | initialClasses: classes, 550 | onChange: ( newClassName ) => { 551 | if ( updateArray ) { 552 | updateArray( parseClassNames( newClassName || '' ) ); 553 | } else if ( setAttributes ) { 554 | setAttributes( { 555 | className: newClassName, 556 | } ); 557 | } 558 | }, 559 | onTextChange: ( value ) => { 560 | if ( setAttributes ) { 561 | setAttributes( { 562 | className: value, 563 | } ); 564 | } 565 | }, 566 | } ); 567 | 568 | // Use given array or parse from class string 569 | const classesArray = array || classManagement.classesArray; 570 | 571 | // Initialize textarea value when classes change 572 | useEffect( () => { 573 | if ( classes !== undefined ) { 574 | classManagement.handleTextChange( classes || '' ); 575 | } 576 | }, [ classes ] ); 577 | 578 | return ( 579 | 587 | ); 588 | }; 589 | 590 | /** 591 | * Backward compatibility wrapper for the modal component 592 | * Determines which implementation to use based on props 593 | */ 594 | export const ClassActManagementModal = ( { 595 | clientId, 596 | onRequestClose, 597 | isKeyboardShortcutTriggered = false, 598 | classes, 599 | name, 600 | array, 601 | setAttributes, 602 | updateArray, 603 | } ) => { 604 | // If using the keyboard shortcut or clientId, use the connected version 605 | if ( isKeyboardShortcutTriggered || ( clientId && ! classes && ! array ) ) { 606 | return ( 607 | 611 | ); 612 | } 613 | 614 | // Otherwise use the controlled version 615 | return ( 616 | 624 | ); 625 | }; 626 | -------------------------------------------------------------------------------- /src/editor/classact/context.js: -------------------------------------------------------------------------------- 1 | /** 2 | * ClassAct - Improved Modal State Management 3 | * 4 | * This implementation simplifies the modal state approach by: 5 | * 1. Using a cleaner context pattern with proper defaults 6 | * 2. Removing the separate singleton state object and unifying state management 7 | * 3. Adding TypeScript-like documentation for better code understanding 8 | * 4. Providing a more straightforward API for consumers 9 | */ 10 | /** 11 | * WordPress dependencies 12 | */ 13 | import { 14 | createContext, 15 | useCallback, 16 | useContext, 17 | useEffect, 18 | useState, 19 | } from '@wordpress/element'; 20 | import { __ } from '@wordpress/i18n'; 21 | 22 | /** 23 | * Modal Context type definition 24 | * @typedef {Object} ModalContextType 25 | * @property {boolean} isOpen - Whether the modal is currently open 26 | * @property {string|null} blockClientId - The client ID of the current block, if any 27 | * @property {string|null} blockTitle - Optional display title for the block 28 | * @property {Function} openModal - Function to open the modal 29 | * @property {Function} closeModal - Function to close the modal 30 | */ 31 | 32 | /** 33 | * Default context value 34 | * @type {ModalContextType} 35 | */ 36 | const defaultContextValue = { 37 | isOpen: false, 38 | blockClientId: null, 39 | blockTitle: null, 40 | openModal: () => {}, 41 | closeModal: () => {}, 42 | }; 43 | 44 | // Create a single event emitter for global access 45 | const modalEvents = { 46 | listeners: new Set(), 47 | emit: ( data ) => { 48 | modalEvents.listeners.forEach( ( listener ) => listener( data ) ); 49 | }, 50 | subscribe: ( listener ) => { 51 | modalEvents.listeners.add( listener ); 52 | return () => { 53 | modalEvents.listeners.delete( listener ); 54 | }; 55 | }, 56 | }; 57 | 58 | // Create the context with default implementation 59 | const ModalContext = createContext( defaultContextValue ); 60 | 61 | /** 62 | * Modal Provider component 63 | * 64 | * Provides modal state management to all child components 65 | * 66 | * @param {Object} props Component props 67 | * @param {React.ReactNode} props.children Child components 68 | * @returns {JSX.Element} Provider component 69 | */ 70 | export const ModalProvider = ( { children } ) => { 71 | const [ state, setState ] = useState( { 72 | isOpen: false, 73 | blockClientId: null, 74 | blockTitle: null, 75 | } ); 76 | 77 | // Subscribe to global events (useful for keyboard shortcuts) 78 | useEffect( () => { 79 | // Handle external open/close requests 80 | const handleExternalRequest = ( data ) => { 81 | setState( ( prevState ) => ( { 82 | ...prevState, 83 | ...data, 84 | } ) ); 85 | }; 86 | 87 | // Subscribe and return unsubscribe function 88 | return modalEvents.subscribe( handleExternalRequest ); 89 | }, [] ); 90 | 91 | // Open modal function 92 | const openModal = useCallback( ( clientId, title = null ) => { 93 | const newState = { 94 | isOpen: true, 95 | blockClientId: clientId, 96 | blockTitle: title, 97 | }; 98 | 99 | setState( newState ); 100 | modalEvents.emit( newState ); 101 | }, [] ); 102 | 103 | // Close modal function 104 | const closeModal = useCallback( () => { 105 | const newState = { 106 | isOpen: false, 107 | blockClientId: null, 108 | blockTitle: null, 109 | }; 110 | 111 | setState( newState ); 112 | modalEvents.emit( newState ); 113 | }, [] ); 114 | 115 | // Combine state and functions into context value 116 | const value = { 117 | ...state, 118 | openModal, 119 | closeModal, 120 | }; 121 | 122 | return ( 123 | 124 | { children } 125 | 126 | ); 127 | }; 128 | 129 | /** 130 | * Custom hook to access the modal context 131 | * 132 | * This hook provides access to the modal state and actions. 133 | * Will work both inside and outside the provider by using the event system as fallback. 134 | * 135 | * @returns {ModalContextType} Modal context with state and actions 136 | */ 137 | export const useModalContext = () => { 138 | // Try to get context from provider 139 | const context = useContext( ModalContext ); 140 | 141 | // If we have a valid context, return it 142 | if ( context !== defaultContextValue ) { 143 | return context; 144 | } 145 | 146 | // Create a standalone implementation for components outside the provider 147 | const [ state, setState ] = useState( { 148 | isOpen: false, 149 | blockClientId: null, 150 | blockTitle: null, 151 | } ); 152 | 153 | // Subscribe to global events on first render 154 | useEffect( () => { 155 | return modalEvents.subscribe( setState ); 156 | }, [] ); 157 | 158 | // Provide fallback implementation 159 | return { 160 | ...state, 161 | openModal: ( clientId, title = null ) => { 162 | const newState = { 163 | isOpen: true, 164 | blockClientId: clientId, 165 | blockTitle: title, 166 | }; 167 | modalEvents.emit( newState ); 168 | }, 169 | closeModal: () => { 170 | const newState = { 171 | isOpen: false, 172 | blockClientId: null, 173 | blockTitle: null, 174 | }; 175 | modalEvents.emit( newState ); 176 | }, 177 | }; 178 | }; 179 | -------------------------------------------------------------------------------- /src/editor/classact/core.js: -------------------------------------------------------------------------------- 1 | /** 2 | * ClassAct - Core Functionality 3 | */ 4 | 5 | /** 6 | * WordPress dependencies 7 | */ 8 | import { 9 | InspectorAdvancedControls, 10 | store as blockEditorStore, 11 | } from '@wordpress/block-editor'; 12 | import { hasBlockSupport } from '@wordpress/blocks'; 13 | import { Button, FormTokenField, Notice } from '@wordpress/components'; 14 | import { createHigherOrderComponent } from '@wordpress/compose'; 15 | import { dispatch, useDispatch, useSelect } from '@wordpress/data'; 16 | import domReady from '@wordpress/dom-ready'; 17 | import { createRoot, Fragment } from '@wordpress/element'; 18 | import { addFilter } from '@wordpress/hooks'; 19 | import { __ } from '@wordpress/i18n'; 20 | import { copySmall, listView, tool, trash } from '@wordpress/icons'; 21 | import { 22 | store as keyboardShortcutsStore, 23 | useShortcut, 24 | } from '@wordpress/keyboard-shortcuts'; 25 | import { registerPlugin } from '@wordpress/plugins'; 26 | 27 | /** 28 | * Internal dependencies 29 | */ 30 | import { 31 | ClassActModal, 32 | ClearButton, 33 | CopyButton, 34 | SortButton, 35 | } from './components'; 36 | import { ModalProvider, useModalContext } from './context'; 37 | import { 38 | useBlockAttributes, 39 | useBlockTitle, 40 | useClassManagement, 41 | } from './hooks'; 42 | 43 | /** 44 | * Constants 45 | */ 46 | const FILTER_NAME = 'classact/inspector-controls'; 47 | const SHORTCUT_NAME = 'classact/open-css-modal'; 48 | 49 | /** 50 | * Register the keyboard shortcut at initialization time 51 | * Use the filtered keyboard shortcut from PHP or fall back to default 52 | */ 53 | const keyboardShortcut = window.classactConfig?.keyboardShortcut || { 54 | modifier: 'alt', 55 | character: 'c', 56 | }; 57 | 58 | dispatch( keyboardShortcutsStore ).registerShortcut( { 59 | name: SHORTCUT_NAME, 60 | category: 'block', 61 | description: __( 'Open CSS class management modal' ), 62 | keyCombination: keyboardShortcut, 63 | } ); 64 | 65 | /** 66 | * Global shortcut handler plugin 67 | */ 68 | registerPlugin( 'classact-global-shortcut', { 69 | render: () => { 70 | const { getSelectedBlockClientId, getBlockName } = useSelect( 71 | ( select ) => ( { 72 | getSelectedBlockClientId: 73 | select( blockEditorStore ).getSelectedBlockClientId, 74 | getBlockName: select( blockEditorStore ).getBlockName, 75 | } ), 76 | [] 77 | ); 78 | 79 | // Use the context hook which falls back to the singleton 80 | const { openModal } = useModalContext(); 81 | 82 | useShortcut( 83 | SHORTCUT_NAME, 84 | ( event ) => { 85 | event.preventDefault(); 86 | 87 | const selectedBlockClientId = getSelectedBlockClientId(); 88 | if ( ! selectedBlockClientId ) { 89 | return; 90 | } 91 | 92 | const blockName = getBlockName( selectedBlockClientId ); 93 | if ( ! hasBlockSupport( blockName, 'customClassName', true ) ) { 94 | return; 95 | } 96 | 97 | // This will work regardless of context availability 98 | openModal( selectedBlockClientId ); 99 | }, 100 | { 101 | bindGlobal: true, 102 | } 103 | ); 104 | 105 | return null; 106 | }, 107 | } ); 108 | 109 | /** 110 | * Higher-order component that adds CSS class management to block inspector controls 111 | * Now using the new useClassManagement and useBlockAttributes hooks 112 | */ 113 | const withClassActInspectorControls = createHigherOrderComponent( 114 | ( BlockEdit ) => { 115 | return ( props ) => { 116 | const { name, clientId } = props; 117 | 118 | // Skip if block doesn't support custom class names 119 | if ( ! hasBlockSupport( name, 'customClassName', true ) ) { 120 | return ; 121 | } 122 | 123 | // Get block attributes and update functions using our custom hook 124 | const blockAttrs = useBlockAttributes( { clientId } ); 125 | 126 | // Get a user-friendly block title using our custom hook 127 | const blockTitle = useBlockTitle( { 128 | clientId, 129 | fallbackName: name, 130 | } ); 131 | 132 | // Use our class management hook 133 | const classManagement = useClassManagement( { 134 | initialClasses: blockAttrs?.attributes?.className, 135 | onChange: ( newClassName ) => { 136 | if ( blockAttrs ) { 137 | blockAttrs.updateClassName( newClassName ); 138 | } 139 | }, 140 | onTextChange: ( value ) => { 141 | if ( blockAttrs ) { 142 | blockAttrs.updateClassName( value ); 143 | } 144 | }, 145 | } ); 146 | 147 | // Use the modal context for opening the modal 148 | const { openModal } = useModalContext(); 149 | 150 | return ( 151 | 152 | 153 | 154 | { classManagement.errorMessage && ( 155 | 159 | classManagement.setErrorMessage( '' ) 160 | } 161 | className="classact-error-message" 162 | > 163 | 164 | { classManagement.errorMessage } 165 | 166 | 167 | ) } 168 | 169 | 177 | classManagement.isValidClass( token ) 178 | } 179 | __experimentalShowHowTo={ false } 180 | tokenizeOnSpace 181 | tokenizeOnBlur 182 | __next40pxDefaultSize 183 | __nextHasNoMarginBottom 184 | help={ __( 185 | 'Type class names and press Enter or Space to add. Press backspace to remove.' 186 | ) } 187 | aria-describedby={ 188 | classManagement.errorMessage 189 | ? 'classact-inspector-error' 190 | : undefined 191 | } 192 | /> 193 | 194 |
195 | 196 |
197 | 200 | 206 | 209 |
210 | 211 | 225 | 226 | 227 | ); 228 | }; 229 | }, 230 | 'withClassActInspectorControls' 231 | ); 232 | 233 | /** 234 | * Initialize the modal container 235 | */ 236 | domReady( () => { 237 | const modalRoot = document.createElement( 'div' ); 238 | modalRoot.id = 'classact-modal-root'; 239 | document.body.appendChild( modalRoot ); 240 | 241 | const root = createRoot( modalRoot ); 242 | root.render( 243 | 244 | 245 | 246 | ); 247 | } ); 248 | 249 | // Register the inspector controls 250 | addFilter( 'editor.BlockEdit', FILTER_NAME, withClassActInspectorControls ); 251 | -------------------------------------------------------------------------------- /src/editor/classact/hooks.js: -------------------------------------------------------------------------------- 1 | /** 2 | * WordPress dependencies 3 | */ 4 | import { store as blockEditorStore } from '@wordpress/block-editor'; 5 | import { useDispatch, useSelect } from '@wordpress/data'; 6 | import { useCallback, useMemo, useState } from '@wordpress/element'; 7 | import { __ } from '@wordpress/i18n'; 8 | 9 | /** 10 | * Internal dependencies 11 | */ 12 | import { 13 | autoSortClasses, 14 | clearExceptStyleClasses, 15 | ERROR_MESSAGES, 16 | moveStyleClassToEnd, 17 | parseClassNames, 18 | sanitizeAndValidateClasses, 19 | sortClassesAlphabetically, 20 | sortClassesByLength, 21 | validClassNameRegex, 22 | } from './utils'; 23 | 24 | /** 25 | * Get the specific variation type for a core/group block 26 | * 27 | * @param {Object} attributes - Block attributes 28 | * @returns {string} The specific variation type (Group, Row, Stack, Grid) 29 | */ 30 | export const getGroupVariationType = ( attributes ) => { 31 | // Default to Group if no layout type 32 | if ( ! attributes?.layout?.type ) { 33 | return 'Group'; 34 | } 35 | 36 | const { layout } = attributes; 37 | 38 | // Handle Grid type immediately 39 | if ( layout.type === 'grid' ) { 40 | return 'Grid'; 41 | } 42 | 43 | // Handle non-flex layouts 44 | if ( layout.type !== 'flex' ) { 45 | return 'Group'; 46 | } 47 | 48 | // For flex layouts, determine the subtype 49 | 50 | // Explicit horizontal orientation = Row 51 | if ( layout.orientation === 'horizontal' ) { 52 | return 'Row'; 53 | } 54 | 55 | // Explicit vertical orientation = Stack 56 | if ( layout.orientation === 'vertical' ) { 57 | return 'Stack'; 58 | } 59 | 60 | // Check for Row-like properties when orientation isn't specified 61 | const hasRowProperties = 62 | layout.contentSize || 63 | layout.justifyContent === 'space-between' || 64 | layout.flexWrap === 'nowrap'; 65 | 66 | // Default to Row for flex layouts, as this is the most common use case in WordPress 67 | return hasRowProperties ? 'Row' : 'Row'; // Both cases return Row as it's the most common default 68 | }; 69 | 70 | /** 71 | * Enhanced hook to get a block's display title with fallbacks 72 | * Combines and improves the functionality of previous useBlockTitle and useSafeBlockDisplayTitle hooks 73 | * 74 | * @param {Object} options - Hook options 75 | * @param {string} options.clientId - The block's client ID 76 | * @param {string} options.fallbackName - Optional fallback name to use if clientId is not provided 77 | * @param {boolean} options.safeMode - If true, returns null on error instead of fallback (default: false) 78 | * @param {boolean} options.fullCustomName - If true, checks all possible custom name locations (default: true) 79 | * @returns {string|null} The display title for the block, or null if in safe mode and an error occurs 80 | */ 81 | export const useBlockTitle = ( { 82 | clientId, 83 | fallbackName = '', 84 | safeMode = false, 85 | fullCustomName = true, 86 | } ) => { 87 | // Format block name - moved outside conditional to maintain hook order 88 | const formatBlockName = useMemo(() => { 89 | return (blockName) => { 90 | if (!blockName || !blockName.includes('/')) return ''; 91 | const nameParts = blockName.split('/'); 92 | return nameParts[1] 93 | .split('-') 94 | .map( 95 | (part) => 96 | part.charAt(0).toUpperCase() + 97 | part.slice(1) 98 | ) 99 | .join(' '); 100 | }; 101 | }, []); 102 | 103 | // Process heading content - moved outside conditional to maintain hook order 104 | const formatHeadingContent = useMemo(() => { 105 | return (content) => { 106 | if (!content) return ''; 107 | const textContent = content.replace(/<[^>]*>/g, ''); 108 | if (textContent.length > 0) { 109 | return textContent.length > 30 110 | ? textContent.substring(0, 27) + '...' 111 | : textContent; 112 | } 113 | return ''; 114 | }; 115 | }, []); 116 | return useSelect( 117 | ( select ) => { 118 | // If no clientId and no fallback, return default or null based on mode 119 | if ( ! clientId && ! fallbackName ) { 120 | return safeMode ? null : __( 'Block' ); 121 | } 122 | 123 | // Try to use the clientId to get block information 124 | if ( clientId ) { 125 | try { 126 | const editor = select( blockEditorStore ); 127 | if (!editor) { 128 | return fallbackName || (safeMode ? null : __( 'Block' )); 129 | } 130 | const blockName = editor.getBlockName( clientId ); 131 | const blockAttributes = 132 | editor.getBlockAttributes( clientId ); 133 | const blockRegistry = select( 'core/blocks' ); 134 | if (!blockRegistry) { 135 | return blockName || fallbackName || (safeMode ? null : __( 'Block' )); 136 | } 137 | let blockTypeName = ''; 138 | let customName = ''; 139 | 140 | // Get the standard block type name 141 | if ( blockName ) { 142 | const blockType = 143 | blockRegistry.getBlockType( blockName ); 144 | 145 | // Special handling for core/group blocks to show their specific variation 146 | if ( blockName === 'core/group' ) { 147 | blockTypeName = 148 | getGroupVariationType( blockAttributes ); 149 | 150 | // Additional safety check for row blocks that might be misidentified 151 | if ( 152 | blockAttributes?.layout?.orientation === 153 | 'horizontal' && 154 | blockTypeName !== 'Row' 155 | ) { 156 | blockTypeName = 'Row'; 157 | } 158 | } else if ( blockType?.title ) { 159 | blockTypeName = blockType.title; 160 | } else if ( blockName.includes( '/' ) ) { 161 | blockTypeName = formatBlockName(blockName); 162 | } else { 163 | blockTypeName = blockName; 164 | } 165 | } 166 | 167 | // Check for custom name - level of checking based on fullCustomName flag 168 | if ( blockAttributes?.metadata?.name ) { 169 | customName = blockAttributes.metadata.name; 170 | } else if ( fullCustomName && blockAttributes?.name ) { 171 | customName = blockAttributes.name; 172 | } else if ( fullCustomName && blockAttributes?.title ) { 173 | customName = blockAttributes.title; 174 | } else if ( 175 | fullCustomName && 176 | blockName === 'core/heading' && 177 | blockAttributes?.content 178 | ) { 179 | // For headings, use content as name 180 | // Use formatHeadingContent function defined at top level 181 | customName = formatHeadingContent(blockAttributes.content); 182 | } 183 | 184 | // Format according to WordPress convention 185 | if ( customName && blockTypeName ) { 186 | return `${ customName } (${ blockTypeName })`; 187 | } else if ( blockTypeName ) { 188 | return blockTypeName; 189 | } else if ( customName ) { 190 | return customName; 191 | } 192 | 193 | // Last resort fallback if we somehow got here 194 | return ( 195 | blockName || 196 | fallbackName || 197 | ( safeMode ? null : __( 'Block' ) ) 198 | ); 199 | } catch ( e ) { 200 | // Return null in safe mode, otherwise continue to fallback 201 | if ( safeMode ) { 202 | return null; 203 | } 204 | } 205 | } 206 | 207 | // Final fallback 208 | return safeMode ? null : fallbackName || __( 'Block' ); 209 | }, 210 | [ clientId || '', fallbackName || '', safeMode, fullCustomName ] 211 | ); 212 | }; 213 | 214 | /** 215 | * Custom hook for managing block attributes 216 | * Provides simplified access to common block editor operations 217 | * 218 | * @param {Object} options - Hook options 219 | * @param {string} options.clientId - The block's client ID 220 | * @returns {Object} Object containing block data and updater functions 221 | */ 222 | export const useBlockAttributes = ( { clientId } ) => { 223 | const { updateBlockAttributes } = useDispatch( blockEditorStore ); 224 | 225 | // Get block data from the editor store 226 | const blockData = useSelect( 227 | ( select ) => { 228 | if ( ! clientId ) return null; 229 | 230 | const editor = select( blockEditorStore ); 231 | if (!editor) return null; 232 | 233 | const blockName = editor.getBlockName( clientId ); 234 | const blockRegistry = select( 'core/blocks' ); 235 | 236 | return { 237 | blockName: blockName, 238 | attributes: editor.getBlockAttributes( clientId ), 239 | blockType: blockRegistry ? blockRegistry.getBlockType(blockName) : null, 240 | }; 241 | }, 242 | [ clientId || '' ] 243 | ); 244 | 245 | // Update className attribute specifically 246 | const updateClassName = useCallback( 247 | ( newClassName ) => { 248 | if ( ! clientId ) return; 249 | 250 | updateBlockAttributes( clientId, { 251 | className: 252 | newClassName && newClassName.trim() 253 | ? newClassName 254 | : undefined, 255 | } ); 256 | }, 257 | [ clientId, updateBlockAttributes ] 258 | ); 259 | 260 | return { 261 | ...blockData, 262 | updateClassName, 263 | updateAttributes: ( attributes ) => 264 | updateBlockAttributes( clientId, attributes ), 265 | }; 266 | }; 267 | 268 | /** 269 | * Custom hook for managing CSS classes 270 | * Provides functions for parsing, validating, and manipulating classes 271 | * 272 | * @param {Object} options - Hook options 273 | * @param {string} options.initialClasses - Initial CSS class string 274 | * @param {Function} options.onChange - Callback function when classes change 275 | * @param {Function} options.onTextChange - Callback for direct text changes 276 | * @returns {Object} Object containing class data and manipulation functions 277 | */ 278 | export const useClassManagement = ( { 279 | initialClasses = '', 280 | onChange, 281 | onTextChange, 282 | } ) => { 283 | const [ errorMessage, setErrorMessage ] = useState( '' ); 284 | const [ textValue, setTextValue ] = useState( initialClasses || '' ); 285 | 286 | // Parse classes into array form with additional safety 287 | const classesArray = useMemo(() => { 288 | return parseClassNames(initialClasses) || []; 289 | }, [initialClasses || '']); 290 | 291 | // Handle validated class change 292 | const handleClassChange = useCallback( 293 | ( newClasses ) => { 294 | const { cleanedClasses, invalidClasses, isValid } = 295 | sanitizeAndValidateClasses( newClasses ); 296 | 297 | if ( ! isValid ) { 298 | setErrorMessage( 299 | `${ ERROR_MESSAGES.INVALID_CLASS } ${ invalidClasses.join( 300 | ', ' 301 | ) }. ${ ERROR_MESSAGES.CLASS_FORMAT }` 302 | ); 303 | return; 304 | } 305 | 306 | setErrorMessage( '' ); 307 | 308 | // Update internal state 309 | setTextValue( cleanedClasses.join( ' ' ) ); 310 | 311 | // Call the onChange callback if provided 312 | if ( onChange ) { 313 | onChange( 314 | cleanedClasses.length 315 | ? cleanedClasses.join( ' ' ) 316 | : undefined 317 | ); 318 | } 319 | }, 320 | [ onChange ] 321 | ); 322 | 323 | // Handle direct text area changes 324 | const handleTextChange = useCallback( 325 | ( value ) => { 326 | // Update internal state 327 | setTextValue( value ); 328 | 329 | // Call the onTextChange callback if provided 330 | if ( onTextChange ) { 331 | onTextChange( value ); 332 | } 333 | }, 334 | [ onTextChange ] 335 | ); 336 | 337 | // Handle blur event for text input - validates and cleans classes 338 | const handleTextBlur = useCallback( () => { 339 | if ( ! textValue.trim() ) { 340 | setTextValue( '' ); 341 | if ( onChange ) { 342 | onChange( undefined ); 343 | } 344 | return; 345 | } 346 | 347 | // Parse and validate classes 348 | const classes = parseClassNames( textValue ); 349 | const { cleanedClasses, invalidClasses, isValid } = 350 | sanitizeAndValidateClasses( classes ); 351 | 352 | if ( ! isValid ) { 353 | setErrorMessage( 354 | `${ ERROR_MESSAGES.INVALID_CLASS } ${ invalidClasses.join( 355 | ', ' 356 | ) }. ${ ERROR_MESSAGES.CLASS_FORMAT }` 357 | ); 358 | } else { 359 | setErrorMessage( '' ); 360 | } 361 | 362 | // Update with cleaned classes 363 | const newValue = cleanedClasses.join( ' ' ); 364 | setTextValue( newValue ); 365 | 366 | if ( onChange ) { 367 | onChange( cleanedClasses.length ? newValue : undefined ); 368 | } 369 | }, [ textValue, onChange ] ); 370 | 371 | // Sorting and manipulation functions 372 | const sortAlphabetically = useCallback( () => { 373 | handleClassChange( sortClassesAlphabetically( classesArray ) ); 374 | }, [ classesArray, handleClassChange ] ); 375 | 376 | const sortByLength = useCallback( () => { 377 | handleClassChange( sortClassesByLength( classesArray ) ); 378 | }, [ classesArray, handleClassChange ] ); 379 | 380 | const moveStyleToEnd = useCallback( () => { 381 | handleClassChange( moveStyleClassToEnd( classesArray ) ); 382 | }, [ classesArray, handleClassChange ] ); 383 | 384 | const autoSort = useCallback( () => { 385 | handleClassChange( autoSortClasses( classesArray ) ); 386 | }, [ classesArray, handleClassChange ] ); 387 | 388 | const clearCustomClasses = useCallback( () => { 389 | handleClassChange( clearExceptStyleClasses( classesArray ) ); 390 | }, [ classesArray, handleClassChange ] ); 391 | 392 | const clearAllClasses = useCallback( () => { 393 | handleTextChange( '' ); 394 | }, [ handleTextChange ] ); 395 | 396 | return { 397 | classesArray, 398 | textValue, 399 | errorMessage, 400 | setErrorMessage, 401 | handleClassChange, 402 | handleTextChange, 403 | handleTextBlur, 404 | sortAlphabetically, 405 | sortByLength, 406 | moveStyleToEnd, 407 | autoSort, 408 | clearCustomClasses, 409 | clearAllClasses, 410 | isValidClass: ( token ) => validClassNameRegex.test( token.trim() ), 411 | }; 412 | }; 413 | -------------------------------------------------------------------------------- /src/editor/classact/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * ClassAct - CSS Class Management for WordPress Block Editor 3 | * 4 | * Main module for the ClassAct plugin's editor integration. 5 | * This file serves as the entry point for the ClassAct functionality, 6 | * importing styles and the core logic that powers the editor enhancements. 7 | * 8 | * @module ClassAct/Editor 9 | * @since 2.0.0 10 | */ 11 | 12 | /** 13 | * Import core functionality 14 | * The core.js file contains all the plugin's integration with the block editor, 15 | * including filters, hooks, and component registrations 16 | */ 17 | import './core'; 18 | 19 | /** 20 | * Import stylesheets for the editor interface 21 | * These styles customize the appearance of ClassAct components 22 | */ 23 | import './styles.scss'; 24 | -------------------------------------------------------------------------------- /src/editor/classact/styles.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * ClassAct - CSS Class Management Styles 3 | */ 4 | 5 | // Import WordPress Base Styles - using @import for now 6 | // TODO: These will need to be updated to @use when Dart Sass 3.0 is released 7 | // See migration guide: https://sass-lang.com/d/import 8 | // The current setup uses the deprecated @import syntax but works with WordPress dependencies 9 | @import "@wordpress/base-styles/variables"; 10 | @import "@wordpress/base-styles/colors"; 11 | @import "@wordpress/base-styles/breakpoints"; 12 | @import "@wordpress/base-styles/mixins"; 13 | @import "@wordpress/base-styles/z-index"; 14 | 15 | // Block Inspector Styles 16 | .block-editor-block-inspector__advanced { 17 | // When an HTML anchor is present, hide the immediate .components-base-control 18 | .html-anchor-control + .components-base-control { 19 | display: none !important; 20 | visibility: hidden; 21 | } 22 | 23 | // If no .html-anchor-control exists, hide the first .components-base-control 24 | &:not(:has(.html-anchor-control)) { 25 | .components-base-control:first-of-type { 26 | display: none !important; 27 | visibility: hidden; 28 | } 29 | } 30 | } 31 | 32 | /* Styles for Block Title Display Component */ 33 | .classact-block-title { 34 | margin-bottom: $grid-unit-20; // 16px 35 | 36 | &__container { 37 | display: flex; 38 | align-items: center; 39 | font-family: $default-font; 40 | } 41 | 42 | &__icon { 43 | display: flex; 44 | align-items: center; 45 | justify-content: center; 46 | margin-right: $grid-unit-10; // 8px 47 | color: $gray-900; // WordPress gray 48 | 49 | .components-icon { 50 | width: $grid-unit-30; // 24px 51 | height: $grid-unit-30; // 24px 52 | } 53 | } 54 | 55 | &__name { 56 | font-size: $default-font-size; // 13px 57 | font-weight: 500; 58 | color: $gray-900; 59 | } 60 | 61 | &__count { 62 | display: inline-flex; 63 | align-items: center; 64 | justify-content: center; 65 | background-color: var(--wp-admin-theme-color); 66 | color: $white; 67 | font-size: 11px; 68 | padding: 0 6px; // Explicit value 69 | border-radius: 2px; // Explicit value 70 | margin-left: $grid-unit-10; // 8px 71 | height: $grid-unit-20 + 4px; // 20px 72 | font-weight: 500; 73 | } 74 | } 75 | 76 | /* Modal header styles */ 77 | .classact-modal-header { 78 | display: flex; 79 | align-items: center; 80 | margin-bottom: $grid-unit-20; // 16px + padding 81 | padding-bottom: $grid-unit-15; // 12px 82 | border-bottom: 1px solid $gray-200; 83 | 84 | .classact-block-title { 85 | margin-bottom: 0; 86 | flex-grow: 1; 87 | } 88 | 89 | // Better handle responsive layout 90 | @media (max-width: $break-small) { 91 | flex-direction: column; 92 | align-items: flex-start; 93 | 94 | .classact-block-title__count { 95 | margin-left: 0; 96 | margin-top: $grid-unit-05; // 4px 97 | } 98 | } 99 | } 100 | 101 | /* WordPress admin UI styling for the modal */ 102 | .components-modal__content { 103 | .classact-modal-header { 104 | padding-top: $grid-unit-10; // 8px 105 | } 106 | 107 | .components-form-token-field__label, 108 | .components-textarea-control__label { 109 | font-size: $default-font-size; 110 | font-weight: 500; 111 | } 112 | 113 | /* Accessibility focus styles for form fields */ 114 | .components-form-token-field__input-container:focus-within { 115 | box-shadow: 0 0 0 2px var(--wp-admin-theme-color); 116 | outline: 2px solid transparent; 117 | } 118 | 119 | .components-textarea-control__input:focus { 120 | box-shadow: 0 0 0 2px var(--wp-admin-theme-color); 121 | outline: 2px solid transparent; 122 | } 123 | 124 | /* Help text styling */ 125 | .components-form-token-field__help, 126 | .components-textarea-control__help { 127 | margin-top: $grid-unit-05; // 4px 128 | margin-bottom: $grid-unit-15; // 12px 129 | font-size: 12px; 130 | color: $gray-700; 131 | } 132 | 133 | /* Token field focus improvements */ 134 | .components-form-token-field__token { 135 | // Make tokens easier to spot 136 | background-color: rgba(var(--wp-admin-theme-color-rgb), 0.1); 137 | 138 | &:focus-within { 139 | box-shadow: 0 0 0 1px var(--wp-admin-theme-color); 140 | } 141 | } 142 | 143 | /* Spacing for notice components inside the modal */ 144 | .components-notice { 145 | margin: 0 0 $grid-unit-20 0; // 16px bottom margin 146 | } 147 | } 148 | 149 | /* Utility classes to replace inline styles */ 150 | .classact-spacer { 151 | height: $grid-unit-10; // 8px 152 | } 153 | 154 | .classact-button-group { 155 | display: flex; 156 | gap: $grid-unit-10; // 8px 157 | justify-content: space-between; 158 | margin-bottom: $grid-unit-10; // 8px 159 | } 160 | 161 | .classact-full-width-button { 162 | width: 100%; 163 | justify-content: center !important; 164 | margin-bottom: $grid-unit-10; // 8px 165 | } 166 | 167 | .classact-modal-button-group { 168 | margin-top: $grid-unit-10; // 8px 169 | display: flex; 170 | gap: $grid-unit-05; // 4px 171 | flex-wrap: wrap; 172 | 173 | @media (min-width: $break-small) { 174 | gap: $grid-unit-10; // Increase gap at larger screen sizes (8px) 175 | } 176 | } 177 | 178 | /* Visually hidden for screen readers */ 179 | .classact-visually-hidden { 180 | position: absolute; 181 | width: 1px; 182 | height: 1px; 183 | margin: -1px; 184 | padding: 0; 185 | overflow: hidden; 186 | clip: rect(0, 0, 0, 0); 187 | border: 0; 188 | } 189 | 190 | /* Error message styling */ 191 | .classact-error-message { 192 | margin-bottom: $grid-unit-15; // 12px 193 | } 194 | 195 | /* Enhance the Notice component styling for our use case */ 196 | .components-notice.classact-error-message { 197 | margin-bottom: $grid-unit-15; // 12px 198 | 199 | // Ensure the notice is properly contained 200 | .components-notice__content { 201 | margin: 0; 202 | } 203 | } -------------------------------------------------------------------------------- /src/editor/classact/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * ClassAct - CSS Class Management Utilities 3 | * 4 | * This file contains utilities organized into logical groups: 5 | * 1. Constants - Regex patterns and error messages 6 | * 2. Parsing Utilities - Functions for parsing and validating class names 7 | * 3. Sorting Utilities - Functions for various sorting operations 8 | * 4. Manipulation Utilities - Functions for transforming class collections 9 | */ 10 | 11 | /** 12 | * WordPress dependencies 13 | */ 14 | import { __ } from '@wordpress/i18n'; 15 | 16 | /** 17 | * ========================================================================= 18 | * 1. CONSTANTS 19 | * ========================================================================= 20 | */ 21 | 22 | /** 23 | * Regular Expression for Valid CSS Class Names 24 | */ 25 | export const validClassNameRegex = 26 | /^[a-zA-Z_-][a-zA-Z0-9_-]*|\[[^\s.<>#{}]+\]$/; 27 | 28 | /** 29 | * Error messages 30 | */ 31 | export const ERROR_MESSAGES = { 32 | INVALID_CLASS: __( 'The following CSS class names are invalid:' ), 33 | CLASS_FORMAT: __( 34 | 'CSS class names must start with a letter, underscore, or hyphen, followed by alphanumeric characters.' 35 | ), 36 | EMPTY_CLASS: __( 'Please enter at least one valid CSS class name.' ), 37 | DUPLICATE_CLASS: __( 'Duplicate class names were removed:' ), 38 | }; 39 | 40 | /** 41 | * ARIA Live region messages 42 | */ 43 | export const ARIA_MESSAGES = { 44 | COPIED: __( 'CSS classes copied to clipboard' ), 45 | SORTED_ALPHA: __( 'CSS classes sorted alphabetically' ), 46 | SORTED_LENGTH: __( 'Classes sorted by length' ), 47 | SORTED_AUTO: __( 'Classes automatically sorted' ), 48 | CLEARED_ALL: __( 'All CSS classes cleared' ), 49 | CLEARED_CUSTOM: __( 'Custom classes cleared, style classes kept' ), 50 | STYLE_MOVED: __( 'Style classes moved to the end' ), 51 | }; 52 | 53 | /** 54 | * Timing Constants 55 | */ 56 | export const TIMING = { 57 | FEEDBACK_DURATION: 2000, // Default duration for feedback messages in milliseconds 58 | ANNOUNCEMENT_DURATION: 2000, // Default duration for screen reader announcements 59 | }; 60 | 61 | /** 62 | * ========================================================================= 63 | * 2. PARSING UTILITIES 64 | * ========================================================================= 65 | */ 66 | 67 | /** 68 | * Convert a class string to an array of unique class names. 69 | * 70 | * @since 2.0.0 71 | * @param {string} classString - String of CSS class names separated by spaces. 72 | * @returns {string[]} Array of unique CSS class names. 73 | */ 74 | export const parseClassNames = ( classString ) => { 75 | // Ensure classString is a valid string, with stronger validation to prevent errors 76 | if (classString === undefined || classString === null || typeof classString !== 'string') { 77 | return []; 78 | } 79 | 80 | try { 81 | return [ 82 | ...new Set( classString.split( /\s+/ ).filter( Boolean ) ), 83 | ]; 84 | } catch (e) { 85 | // Return empty array if any operations fail 86 | return []; 87 | } 88 | }; 89 | 90 | /** 91 | * Validate and clean class names. 92 | * @param {string[]} newClasses - Array of CSS class names to validate and clean. 93 | * @returns {Object} Object containing cleaned classes, invalid classes, and validation status. 94 | */ 95 | export const sanitizeAndValidateClasses = ( newClasses ) => { 96 | const cleanedClasses = [ 97 | ...new Set( 98 | newClasses 99 | .map( ( cls ) => ( typeof cls === 'string' ? cls.trim() : '' ) ) 100 | .filter( Boolean ) 101 | ), 102 | ]; 103 | 104 | const invalidClasses = cleanedClasses.filter( 105 | ( c ) => ! validClassNameRegex.test( c ) 106 | ); 107 | 108 | return { 109 | cleanedClasses, 110 | invalidClasses, 111 | isValid: invalidClasses.length === 0, 112 | }; 113 | }; 114 | 115 | /** 116 | * ========================================================================= 117 | * 3. SORTING UTILITIES 118 | * ========================================================================= 119 | */ 120 | 121 | /** 122 | * Sort CSS class names alphabetically, accounting for numeric prefixes. 123 | * @param {string[]} classesArray - Array of CSS class names to sort. 124 | * @returns {string[]} Sorted array of CSS class names. 125 | */ 126 | export const sortClassesAlphabetically = ( classesArray ) => { 127 | if (!Array.isArray(classesArray) || !classesArray.length) { 128 | return []; 129 | } 130 | return classesArray.slice().sort( ( a, b ) => { 131 | const strA = String( a ); 132 | const strB = String( b ); 133 | 134 | const startsWithDigitA = /^\d/.test( strA ); 135 | const startsWithDigitB = /^\d/.test( strB ); 136 | 137 | if ( ! startsWithDigitA && ! startsWithDigitB ) { 138 | return strA.localeCompare( strB ); 139 | } 140 | 141 | if ( ! startsWithDigitA && startsWithDigitB ) return -1; 142 | if ( startsWithDigitA && ! startsWithDigitB ) return 1; 143 | 144 | const numA = parseInt( strA.match( /^(\d+)/ )[ 0 ], 10 ); 145 | const numB = parseInt( strB.match( /^(\d+)/ )[ 0 ], 10 ); 146 | 147 | if ( numA !== numB ) { 148 | return numA - numB; 149 | } 150 | 151 | const restA = strA.replace( /^\d+[-]?/, '' ); 152 | const restB = strB.replace( /^\d+[-]?/, '' ); 153 | return restA.localeCompare( restB ); 154 | } ); 155 | }; 156 | 157 | /** 158 | * Sort CSS class names by length, shortest to longest. 159 | * @param {string[]} classesArray - Array of CSS class names to sort. 160 | * @returns {string[]} Sorted array of CSS class names. 161 | */ 162 | export const sortClassesByLength = ( classesArray ) => { 163 | if (!Array.isArray(classesArray) || !classesArray.length) { 164 | return []; 165 | } 166 | return classesArray.slice().sort( ( a, b ) => a.length - b.length ); 167 | }; 168 | 169 | /** 170 | * Check if a class name is a WordPress block style class 171 | * @param {string} className - CSS class name to check 172 | * @returns {boolean} True if it's a style class 173 | */ 174 | export const isStyleClass = ( className ) => { 175 | return className.startsWith( 'is-style-' ); 176 | }; 177 | 178 | /** 179 | * Move .is-style classes to the end of the array. 180 | * @param {string[]} classes - Array of CSS class names. 181 | * @returns {string[]} Array with the style class moved to the end. 182 | */ 183 | export const moveStyleClassToEnd = ( classes ) => { 184 | if (!Array.isArray(classes) || !classes.length) { 185 | return []; 186 | } 187 | const result = [ ...classes ]; // Create a copy to avoid mutating the original 188 | const index = result.findIndex( ( cls ) => cls && typeof cls === 'string' && cls.startsWith( 'is-style-' ) ); 189 | if ( index !== -1 ) { 190 | const [ styleClass ] = result.splice( index, 1 ); 191 | result.push( styleClass ); 192 | } 193 | return result; 194 | }; 195 | 196 | /** 197 | * The "auto sort" function - combines alphabetical sorting with style classes at the end 198 | * 199 | * @since 2.0.0 200 | * @param {string[]} classesArray - Array of CSS class names to sort. 201 | * @returns {string[]} Sorted array with style classes at the end. 202 | */ 203 | export const autoSortClasses = ( classesArray ) => { 204 | if (!Array.isArray(classesArray) || !classesArray.length) { 205 | return []; 206 | } 207 | return moveStyleClassToEnd( sortClassesAlphabetically( classesArray ) ); 208 | }; 209 | 210 | /** 211 | * ========================================================================= 212 | * 4. MANIPULATION UTILITIES 213 | * ========================================================================= 214 | */ 215 | 216 | /** 217 | * Clear all classes except .is-style classes. 218 | * @param {string[]} classes - Array of CSS class names. 219 | * @returns {string[]} Array with all non-style classes removed. 220 | */ 221 | export const clearExceptStyleClasses = ( classes ) => { 222 | if (!Array.isArray(classes) || !classes.length) { 223 | return []; 224 | } 225 | return classes.filter( isStyleClass ); 226 | }; 227 | 228 | /** 229 | * Convert array of class names to a space-separated string 230 | * @param {string[]} classes - Array of CSS class names 231 | * @returns {string} Space-separated class names string 232 | */ 233 | export const classArrayToString = ( classes ) => { 234 | return classes?.length ? classes.join( ' ' ) : ''; 235 | }; 236 | 237 | /** 238 | * Gets class count statistics 239 | * 240 | * @since 2.0.0 241 | * @param {string[]} classes - Array of CSS class names 242 | * @returns {Object} Statistics about the classes with count of total, style, and custom classes 243 | */ 244 | export const getClassStats = ( classes ) => { 245 | if (!Array.isArray(classes) || !classes.length) { 246 | return { 247 | total: 0, 248 | styleClasses: 0, 249 | customClasses: 0, 250 | }; 251 | } 252 | 253 | const styleClasses = classes.filter( isStyleClass ); 254 | 255 | return { 256 | total: classes.length, 257 | styleClasses: styleClasses.length, 258 | customClasses: classes.length - styleClasses.length, 259 | }; 260 | }; 261 | -------------------------------------------------------------------------------- /src/editor/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * ClassAct - Main Editor Entry Point 3 | * 4 | * This is the primary entry point for the ClassAct editor integration. 5 | * It imports the main module which contains all the functionality. 6 | * 7 | * @since 2.0.0 8 | */ 9 | 10 | /** 11 | * Internal dependencies 12 | */ 13 | import './classact'; 14 | -------------------------------------------------------------------------------- /tests/.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore coverage reports 2 | coverage/ 3 | coverage.xml 4 | 5 | # Ignore PHPUnit cache 6 | .phpunit.result.cache -------------------------------------------------------------------------------- /tests/phpunit/bootstrap.php: -------------------------------------------------------------------------------- 1 | setMethods( $methods ) 28 | ->getMock(); 29 | } 30 | 31 | /** 32 | * Creates a mock HTTP response for testing API calls. 33 | * 34 | * @param int $response_code The HTTP response code. 35 | * @param string $body The response body. 36 | * @param array $headers Response headers. 37 | * @return array A WordPress HTTP API response array. 38 | */ 39 | public static function create_mock_http_response( $response_code = 200, $body = '', $headers = array() ) { 40 | return array( 41 | 'headers' => $headers, 42 | 'body' => $body, 43 | 'response' => array( 44 | 'code' => $response_code, 45 | 'message' => get_status_header_desc( $response_code ), 46 | ), 47 | 'cookies' => array(), 48 | 'http_response' => null, 49 | ); 50 | } 51 | 52 | /** 53 | * Helper to set a private/protected property of an object. 54 | * 55 | * @param object $object The object containing the property. 56 | * @param string $property The name of the private property. 57 | * @param mixed $value The value to set. 58 | */ 59 | public static function set_private_property( $object, $property, $value ) { 60 | $reflection = new ReflectionClass( get_class( $object ) ); 61 | $property = $reflection->getProperty( $property ); 62 | $property->setAccessible( true ); 63 | $property->setValue( $object, $value ); 64 | } 65 | 66 | /** 67 | * Helper to access a private/protected property of an object. 68 | * 69 | * @param object $object The object containing the property. 70 | * @param string $property The name of the private property. 71 | * 72 | * @return mixed The property value. 73 | */ 74 | public static function get_private_property( $object, $property ) { 75 | $reflection = new ReflectionClass( get_class( $object ) ); 76 | $property = $reflection->getProperty( $property ); 77 | $property->setAccessible( true ); 78 | return $property->getValue( $object ); 79 | } 80 | 81 | /** 82 | * Helper to call a private/protected method of an object. 83 | * 84 | * @param object $object The object containing the method. 85 | * @param string $method The name of the private method. 86 | * @param array $args Arguments to pass to the method. 87 | * 88 | * @return mixed The method return value. 89 | */ 90 | public static function call_private_method( $object, $method, array $args = array() ) { 91 | $reflection = new ReflectionClass( get_class( $object ) ); 92 | $method = $reflection->getMethod( $method ); 93 | $method->setAccessible( true ); 94 | return $method->invokeArgs( $object, $args ); 95 | } 96 | } -------------------------------------------------------------------------------- /tests/phpunit/includes/test-autoloader.php: -------------------------------------------------------------------------------- 1 | test_class_file = CLASSACT_PLUGIN_DIR . '/includes/TestAutoload.php'; 29 | file_put_contents( $this->test_class_file, 'assertEquals( 'Autoload successful', $test_class->test_method() ); 47 | } 48 | 49 | /** 50 | * Test that the autoloader doesn't attempt to load non-ClassAct classes. 51 | */ 52 | public function test_autoloader_ignores_non_classact_classes() { 53 | // Create a flag to check if the autoloader is triggered 54 | $autoloader_triggered = false; 55 | 56 | // Create a fake autoloader function to check if it gets called 57 | $test_autoloader = function( $class ) use ( &$autoloader_triggered ) { 58 | // Check if it's a non-ClassAct class 59 | if ( strpos( $class, 'ClassAct\\' ) !== 0 && strpos( $class, 'NonClassAct\\' ) === 0 ) { 60 | $autoloader_triggered = true; 61 | } 62 | }; 63 | 64 | // Register our test autoloader 65 | spl_autoload_register( $test_autoloader ); 66 | 67 | // Try to load a non-existent non-ClassAct class 68 | // This should not be handled by the ClassAct autoloader 69 | @class_exists( 'NonClassAct\\TestClass' ); 70 | 71 | // Unregister our test autoloader 72 | spl_autoload_unregister( $test_autoloader ); 73 | 74 | // The ClassAct autoloader should not have tried to handle this 75 | $this->assertFalse( $autoloader_triggered ); 76 | } 77 | 78 | /** 79 | * Test that the autoloader correctly maps namespace structure to file paths. 80 | */ 81 | public function test_autoloader_maps_namespaces_to_paths() { 82 | // Create a nested namespace test class 83 | $nested_dir = CLASSACT_PLUGIN_DIR . '/includes/Nested'; 84 | $nested_file = $nested_dir . '/TestClass.php'; 85 | 86 | // Create directory 87 | if ( ! file_exists( $nested_dir ) ) { 88 | mkdir( $nested_dir, 0777, true ); 89 | } 90 | 91 | // Create test file 92 | file_put_contents( $nested_file, 'assertEquals( 'Nested autoload successful', $test_class->test_method() ); 105 | 106 | // Clean up 107 | unlink( $nested_file ); 108 | rmdir( $nested_dir ); 109 | } 110 | 111 | /** 112 | * Test that the autoloader correctly handles non-existent files. 113 | */ 114 | public function test_autoloader_handles_nonexistent_files() { 115 | // Try to load a non-existent ClassAct class 116 | // This shouldn't cause any errors, just fail to load 117 | $class_exists = @class_exists( 'ClassAct\\NonExistentClass' ); 118 | 119 | // The class should not exist 120 | $this->assertFalse( $class_exists ); 121 | } 122 | 123 | /** 124 | * Clean up after the test. 125 | */ 126 | public function tearDown() : void { 127 | // Remove the temporary test class file 128 | if ( file_exists( $this->test_class_file ) ) { 129 | unlink( $this->test_class_file ); 130 | } 131 | 132 | parent::tearDown(); 133 | } 134 | } -------------------------------------------------------------------------------- /tests/phpunit/includes/test-plugin.php: -------------------------------------------------------------------------------- 1 | plugin = Plugin::instance(); 31 | } 32 | 33 | /** 34 | * Test the singleton instance method. 35 | */ 36 | public function test_instance() { 37 | // Get the singleton instance 38 | $instance1 = Plugin::instance(); 39 | $instance2 = Plugin::instance(); 40 | 41 | // Test that the instances are the same 42 | $this->assertSame( $instance1, $instance2 ); 43 | 44 | // Test that the instance is of the correct class 45 | $this->assertInstanceOf( Plugin::class, $instance1 ); 46 | } 47 | 48 | /** 49 | * Test that the load_textdomain method works correctly. 50 | */ 51 | public function test_load_textdomain() { 52 | // Mock the load_plugin_textdomain function 53 | $mock_function_called = false; 54 | $plugin_basename = plugin_basename( CLASSACT_FILE ); 55 | 56 | // Add a filter to intercept the load_plugin_textdomain call 57 | add_filter( 'load_textdomain_mofile', function( $mofile, $domain ) use ( &$mock_function_called ) { 58 | if ( 'classact' === $domain ) { 59 | $mock_function_called = true; 60 | // Return a non-existent file to prevent actually loading anything 61 | return '/non/existent/file.mo'; 62 | } 63 | return $mofile; 64 | }, 10, 2 ); 65 | 66 | // Call the method 67 | $this->plugin->load_textdomain(); 68 | 69 | // Check that the function was called 70 | $this->assertTrue( $mock_function_called ); 71 | 72 | // Remove the filter 73 | remove_all_filters( 'load_textdomain_mofile' ); 74 | } 75 | 76 | /** 77 | * Test that the enqueue_editor_assets method properly registers scripts and styles. 78 | */ 79 | public function test_enqueue_editor_assets() { 80 | // Define test asset data 81 | $test_version = '1.2.3'; 82 | $test_deps = array( 'wp-blocks', 'wp-editor' ); 83 | 84 | // Create a mock asset file 85 | $mock_asset_data = array( 86 | 'dependencies' => $test_deps, 87 | 'version' => $test_version, 88 | ); 89 | 90 | // Add a filter to intercept the include call 91 | add_filter( 'pre_option_classact_asset_include', function() use ( $mock_asset_data ) { 92 | return $mock_asset_data; 93 | } ); 94 | 95 | // Mock the WordPress functions 96 | $wp_enqueue_script_args = null; 97 | $wp_enqueue_style_args = null; 98 | $wp_set_script_translations_args = null; 99 | $wp_localize_script_args = null; 100 | 101 | // Replace wp_enqueue_script 102 | $this->_replace_wp_function( 'wp_enqueue_script', function() use ( &$wp_enqueue_script_args ) { 103 | $wp_enqueue_script_args = func_get_args(); 104 | return true; 105 | } ); 106 | 107 | // Replace wp_enqueue_style 108 | $this->_replace_wp_function( 'wp_enqueue_style', function() use ( &$wp_enqueue_style_args ) { 109 | $wp_enqueue_style_args = func_get_args(); 110 | return true; 111 | } ); 112 | 113 | // Replace wp_set_script_translations 114 | $this->_replace_wp_function( 'wp_set_script_translations', function() use ( &$wp_set_script_translations_args ) { 115 | $wp_set_script_translations_args = func_get_args(); 116 | return true; 117 | } ); 118 | 119 | // Replace wp_localize_script 120 | $this->_replace_wp_function( 'wp_localize_script', function() use ( &$wp_localize_script_args ) { 121 | $wp_localize_script_args = func_get_args(); 122 | return true; 123 | } ); 124 | 125 | // Mock the include function to return our mock asset data 126 | $this->_replace_wp_function( 'include', function( $file ) use ( $mock_asset_data ) { 127 | if ( strpos( $file, 'editor.asset.php' ) !== false ) { 128 | return $mock_asset_data; 129 | } 130 | return include( $file ); 131 | } ); 132 | 133 | // Call the method 134 | $this->plugin->enqueue_editor_assets(); 135 | 136 | // Check that wp_enqueue_script was called with the right arguments 137 | $this->assertNotNull( $wp_enqueue_script_args ); 138 | $this->assertEquals( 'classact-editor', $wp_enqueue_script_args[0] ); 139 | $this->assertContains( 'editor.js', $wp_enqueue_script_args[1] ); 140 | $this->assertEquals( $test_deps, $wp_enqueue_script_args[2] ); 141 | $this->assertEquals( $test_version, $wp_enqueue_script_args[3] ); 142 | 143 | // Check that wp_enqueue_style was called with the right arguments 144 | $this->assertNotNull( $wp_enqueue_style_args ); 145 | $this->assertEquals( 'classact-editor', $wp_enqueue_style_args[0] ); 146 | $this->assertContains( 'editor.css', $wp_enqueue_style_args[1] ); 147 | $this->assertEquals( array(), $wp_enqueue_style_args[2] ); 148 | $this->assertEquals( $test_version, $wp_enqueue_style_args[3] ); 149 | 150 | // Check that wp_set_script_translations was called 151 | $this->assertNotNull( $wp_set_script_translations_args ); 152 | $this->assertEquals( 'classact-editor', $wp_set_script_translations_args[0] ); 153 | $this->assertEquals( 'classact', $wp_set_script_translations_args[1] ); 154 | 155 | // Check that wp_localize_script was called with keyboard shortcut 156 | $this->assertNotNull( $wp_localize_script_args ); 157 | $this->assertEquals( 'classact-editor', $wp_localize_script_args[0] ); 158 | $this->assertEquals( 'classactConfig', $wp_localize_script_args[1] ); 159 | $this->assertArrayHasKey( 'keyboardShortcut', $wp_localize_script_args[2] ); 160 | $this->assertArrayHasKey( 'modifier', $wp_localize_script_args[2]['keyboardShortcut'] ); 161 | $this->assertArrayHasKey( 'character', $wp_localize_script_args[2]['keyboardShortcut'] ); 162 | } 163 | 164 | /** 165 | * Test that the keyboard shortcut filter works. 166 | */ 167 | public function test_keyboard_shortcut_filter() { 168 | // Define a custom keyboard shortcut 169 | $custom_shortcut = array( 170 | 'modifier' => 'ctrl', 171 | 'character' => 'x', 172 | ); 173 | 174 | // Add a filter to modify the keyboard shortcut 175 | add_filter( 'classact_keyboard_shortcut', function() use ( $custom_shortcut ) { 176 | return $custom_shortcut; 177 | } ); 178 | 179 | // Mock the wp_localize_script function 180 | $wp_localize_script_args = null; 181 | $this->_replace_wp_function( 'wp_localize_script', function() use ( &$wp_localize_script_args ) { 182 | $wp_localize_script_args = func_get_args(); 183 | return true; 184 | } ); 185 | 186 | // Call the method 187 | $this->plugin->enqueue_editor_assets(); 188 | 189 | // Check that wp_localize_script was called with our custom shortcut 190 | $this->assertNotNull( $wp_localize_script_args ); 191 | $this->assertEquals( 'classactConfig', $wp_localize_script_args[1] ); 192 | $this->assertEquals( $custom_shortcut, $wp_localize_script_args[2]['keyboardShortcut'] ); 193 | 194 | // Remove the filter 195 | remove_all_filters( 'classact_keyboard_shortcut' ); 196 | } 197 | 198 | /** 199 | * Helper method to replace WordPress functions for testing. 200 | * 201 | * @param string $function_name The name of the function to replace. 202 | * @param callable $replacement The replacement function. 203 | */ 204 | private function _replace_wp_function( $function_name, $replacement ) { 205 | global $wp_filter; 206 | 207 | if ( ! function_exists( 'runkit_function_redefine' ) ) { 208 | $this->markTestSkipped( 'This test requires the runkit extension.' ); 209 | return; 210 | } 211 | 212 | if ( function_exists( $function_name ) ) { 213 | runkit_function_redefine( $function_name, '', $replacement ); 214 | } 215 | } 216 | 217 | /** 218 | * Clean up after the test. 219 | */ 220 | public function tearDown() : void { 221 | // Remove any filters we added 222 | remove_all_filters( 'load_textdomain_mofile' ); 223 | remove_all_filters( 'pre_option_classact_asset_include' ); 224 | remove_all_filters( 'classact_keyboard_shortcut' ); 225 | 226 | parent::tearDown(); 227 | } 228 | } -------------------------------------------------------------------------------- /tests/phpunit/includes/test-updater.php: -------------------------------------------------------------------------------- 1 | plugin_file = CLASSACT_PLUGIN_DIR . '/classact.php'; 51 | $this->plugin_slug = 'classact'; 52 | $this->plugin_version = '2.0.0'; 53 | 54 | // Create an Updater instance 55 | $this->updater = new Updater( 56 | $this->plugin_file, 57 | $this->plugin_slug, 58 | $this->plugin_version 59 | ); 60 | 61 | // Clear any existing transients 62 | delete_transient( 'classact_classact_update_data' ); 63 | } 64 | 65 | /** 66 | * Test that the Updater initializes with correct properties. 67 | */ 68 | public function test_updater_initialization() { 69 | // Check the plugin basename is set correctly 70 | $plugin_basename = plugin_basename( $this->plugin_file ); 71 | $actual_basename = ClassAct_Test_Helpers::get_private_property( $this->updater, 'plugin_basename' ); 72 | $this->assertEquals( $plugin_basename, $actual_basename ); 73 | 74 | // Check the plugin slug is sanitized 75 | $actual_slug = ClassAct_Test_Helpers::get_private_property( $this->updater, 'plugin_slug' ); 76 | $this->assertEquals( sanitize_key( $this->plugin_slug ), $actual_slug ); 77 | 78 | // Check the version is sanitized 79 | $actual_version = ClassAct_Test_Helpers::get_private_property( $this->updater, 'version' ); 80 | $this->assertEquals( sanitize_text_field( $this->plugin_version ), $actual_version ); 81 | 82 | // Check that the cache key is set correctly 83 | $expected_cache_key = 'classact_' . $this->plugin_slug . '_update_data'; 84 | $actual_cache_key = ClassAct_Test_Helpers::get_private_property( $this->updater, 'cache_key' ); 85 | $this->assertEquals( $expected_cache_key, $actual_cache_key ); 86 | } 87 | 88 | /** 89 | * Test that get_update_data returns cached data when available. 90 | */ 91 | public function test_get_update_data_uses_cache() { 92 | // Create mock update data 93 | $mock_data = (object) array( 94 | 'version' => '2.1.0', 95 | 'download_url' => 'https://example.com/download', 96 | 'homepage' => 'https://example.com', 97 | ); 98 | 99 | // Set the cache 100 | $cache_key = ClassAct_Test_Helpers::get_private_property( $this->updater, 'cache_key' ); 101 | set_transient( $cache_key, json_encode( $mock_data ), 10 ); 102 | 103 | // Call the method and check it returns the cached data 104 | $result = ClassAct_Test_Helpers::call_private_method( $this->updater, 'get_update_data' ); 105 | 106 | $this->assertIsObject( $result ); 107 | $this->assertEquals( '2.1.0', $result->version ); 108 | $this->assertEquals( 'https://example.com/download', $result->download_url ); 109 | } 110 | 111 | /** 112 | * Test that the updater properly checks for updates and adds to the transient. 113 | */ 114 | public function test_check_for_update() { 115 | // Create a transient object for testing 116 | $transient = new stdClass(); 117 | $transient->checked = array( 118 | plugin_basename( $this->plugin_file ) => $this->plugin_version, 119 | ); 120 | $transient->response = array(); 121 | 122 | // Mock the update data 123 | $mock_data = (object) array( 124 | 'version' => '2.1.0', // Higher version than current 125 | 'download_url' => 'https://example.com/download', 126 | 'homepage' => 'https://example.com', 127 | 'tested' => '6.1', 128 | 'requires_php' => '7.4', 129 | 'icons' => (object) array( 130 | '1x' => 'https://example.com/icon.png', 131 | ), 132 | 'banners' => (object) array( 133 | 'low' => 'https://example.com/banner.png', 134 | ), 135 | ); 136 | 137 | // Create a partial mock of the Updater class 138 | $updater_mock = $this->getMockBuilder( Updater::class ) 139 | ->setConstructorArgs( array( $this->plugin_file, $this->plugin_slug, $this->plugin_version ) ) 140 | ->setMethods( array( 'get_update_data' ) ) 141 | ->getMock(); 142 | 143 | // Set up the mock to return our mock data 144 | $updater_mock->expects( $this->once() ) 145 | ->method( 'get_update_data' ) 146 | ->willReturn( $mock_data ); 147 | 148 | // Run the function under test 149 | $result = $updater_mock->check_for_update( $transient ); 150 | 151 | // Verify that it added the update info to the transient 152 | $this->assertArrayHasKey( plugin_basename( $this->plugin_file ), $result->response ); 153 | $this->assertEquals( '2.1.0', $result->response[plugin_basename( $this->plugin_file )]->new_version ); 154 | } 155 | 156 | /** 157 | * Test that no update is added when version is the same or lower. 158 | */ 159 | public function test_no_update_when_version_is_same_or_lower() { 160 | // Create a transient object for testing 161 | $transient = new stdClass(); 162 | $transient->checked = array( 163 | plugin_basename( $this->plugin_file ) => $this->plugin_version, 164 | ); 165 | $transient->response = array(); 166 | 167 | // Mock the update data with same version 168 | $mock_data = (object) array( 169 | 'version' => $this->plugin_version, // Same version 170 | 'download_url' => 'https://example.com/download', 171 | ); 172 | 173 | // Create a partial mock of the Updater class 174 | $updater_mock = $this->getMockBuilder( Updater::class ) 175 | ->setConstructorArgs( array( $this->plugin_file, $this->plugin_slug, $this->plugin_version ) ) 176 | ->setMethods( array( 'get_update_data' ) ) 177 | ->getMock(); 178 | 179 | // Set up the mock to return our mock data 180 | $updater_mock->expects( $this->once() ) 181 | ->method( 'get_update_data' ) 182 | ->willReturn( $mock_data ); 183 | 184 | // Run the function under test 185 | $result = $updater_mock->check_for_update( $transient ); 186 | 187 | // Verify that no update was added 188 | $this->assertEmpty( $result->response ); 189 | } 190 | 191 | /** 192 | * Test the plugins_api_filter method. 193 | */ 194 | public function test_plugins_api_filter() { 195 | // Create mock update data 196 | $mock_data = (object) array( 197 | 'name' => 'ClassAct', 198 | 'version' => '2.1.0', 199 | 'author' => 'Dave Ryan', 200 | 'homepage' => 'https://example.com', 201 | 'requires' => '5.8', 202 | 'tested' => '6.1', 203 | 'requires_php' => '7.4', 204 | 'download_url' => 'https://example.com/download', 205 | 'sections' => (object) array( 206 | 'description' => 'A test description', 207 | 'changelog' => 'Test changelog', 208 | ), 209 | 'banners' => (object) array( 210 | 'low' => 'https://example.com/banner.png', 211 | ), 212 | 'icons' => (object) array( 213 | '1x' => 'https://example.com/icon.png', 214 | ), 215 | ); 216 | 217 | // Create a partial mock of the Updater class 218 | $updater_mock = $this->getMockBuilder( Updater::class ) 219 | ->setConstructorArgs( array( $this->plugin_file, $this->plugin_slug, $this->plugin_version ) ) 220 | ->setMethods( array( 'get_update_data' ) ) 221 | ->getMock(); 222 | 223 | // Set up the mock to return our mock data 224 | $updater_mock->expects( $this->once() ) 225 | ->method( 'get_update_data' ) 226 | ->willReturn( $mock_data ); 227 | 228 | // Set up the args for our plugin 229 | $args = new stdClass(); 230 | $args->slug = $this->plugin_slug; 231 | 232 | // Run the function under test 233 | $result = $updater_mock->plugins_api_filter( false, 'plugin_information', $args ); 234 | 235 | // Verify the response data 236 | $this->assertEquals( 'ClassAct', $result->name ); 237 | $this->assertEquals( $this->plugin_slug, $result->slug ); 238 | $this->assertEquals( '2.1.0', $result->version ); 239 | $this->assertEquals( 'https://example.com/download', $result->download_link ); 240 | $this->assertIsArray( $result->sections ); 241 | $this->assertArrayHasKey( 'description', $result->sections ); 242 | $this->assertArrayHasKey( 'changelog', $result->sections ); 243 | } 244 | 245 | /** 246 | * Test that the updater properly clears the update cache. 247 | */ 248 | public function test_clear_update_cache() { 249 | // Set a transient 250 | $cache_key = ClassAct_Test_Helpers::get_private_property( $this->updater, 'cache_key' ); 251 | set_transient( $cache_key, 'test_data', 10 ); 252 | 253 | // Check it exists 254 | $this->assertEquals( 'test_data', get_transient( $cache_key ) ); 255 | 256 | // Create a mock upgrader and options 257 | $upgrader = new stdClass(); 258 | $options = array( 259 | 'action' => 'update', 260 | 'type' => 'plugin', 261 | 'plugins' => array( plugin_basename( $this->plugin_file ) ), 262 | ); 263 | 264 | // Call the method 265 | $this->updater->clear_update_cache( $upgrader, $options ); 266 | 267 | // Verify the transient was deleted 268 | $this->assertFalse( get_transient( $cache_key ) ); 269 | } 270 | 271 | /** 272 | * Test that HTTP API errors are handled properly. 273 | */ 274 | public function test_get_update_data_handles_http_errors() { 275 | // Create a partial mock of the Updater class to intercept the wp_remote_get call 276 | $updater_mock = $this->getMockBuilder( Updater::class ) 277 | ->setConstructorArgs( array( $this->plugin_file, $this->plugin_slug, $this->plugin_version ) ) 278 | ->setMethods( array( 'get_update_data' ) ) 279 | ->getMock(); 280 | 281 | // Replace the original get_update_data method with a version for testing 282 | $test_method = function() { 283 | // Set properties for testing 284 | $api_url = ClassAct_Test_Helpers::get_private_property( $this, 'api_url' ); 285 | $plugin_slug = ClassAct_Test_Helpers::get_private_property( $this, 'plugin_slug' ); 286 | $version = ClassAct_Test_Helpers::get_private_property( $this, 'version' ); 287 | 288 | // Force a WP_Error response for testing 289 | add_filter( 'pre_http_request', function() { 290 | return new WP_Error( 'http_request_failed', 'Connection error' ); 291 | } ); 292 | 293 | // Build the request URL 294 | $request_params = array( 295 | 'plugin_slug' => $plugin_slug, 296 | 'version' => $version, 297 | ); 298 | 299 | $request_url = add_query_arg( $request_params, $api_url ); 300 | 301 | // Make the request that will now return an error 302 | $response = wp_remote_get( 303 | $request_url, 304 | array( 305 | 'timeout' => 10, 306 | 'sslverify' => true, 307 | 'headers' => array( 308 | 'Accept' => 'application/json', 309 | ), 310 | ) 311 | ); 312 | 313 | // Remove our filter to avoid affecting other tests 314 | remove_filter( 'pre_http_request', function() { 315 | return new WP_Error( 'http_request_failed', 'Connection error' ); 316 | } ); 317 | 318 | // Handle errors 319 | if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { 320 | return false; 321 | } 322 | 323 | return true; 324 | }; 325 | 326 | // Replace the method with our test version 327 | ClassAct_Test_Helpers::set_private_property( $updater_mock, 'get_update_data', $test_method ); 328 | 329 | // Verify the method handles WP_Error correctly 330 | $this->assertFalse( ClassAct_Test_Helpers::call_private_method( $updater_mock, 'get_update_data' ) ); 331 | } 332 | 333 | /** 334 | * Clean up after the test. 335 | */ 336 | public function tearDown() : void { 337 | // Clear any transients created during testing 338 | delete_transient( 'classact_classact_update_data' ); 339 | 340 | parent::tearDown(); 341 | } 342 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require( 'path' ); 2 | const { merge } = require( 'webpack-merge' ); 3 | const version = require( './package.json' ).version; 4 | 5 | const classactConfig = { 6 | resolve: { 7 | alias: { 8 | '@classact': path.resolve( __dirname, 'src' ), 9 | }, 10 | }, 11 | entry: { 12 | editor: path.resolve( __dirname, 'src/editor/index.js' ), 13 | }, 14 | output: { 15 | path: path.resolve( __dirname, 'build/' + version ), 16 | }, 17 | stats: { 18 | warnings: false, // Suppress all webpack warnings 19 | }, 20 | }; 21 | 22 | module.exports = merge( 23 | require( '@wordpress/scripts/config/webpack.config' ), 24 | classactConfig 25 | ); 26 | --------------------------------------------------------------------------------