├── .eslintrc.yml ├── .github └── workflows │ ├── publish-latest-plugin-zip.yml │ └── release.yml ├── .gitignore ├── .prettierrc.js ├── LICENSE.md ├── README.md ├── blueprint.json ├── create-content-model.php ├── dev.json ├── get-started.md ├── includes ├── exporter │ ├── 0-load.php │ ├── class-content-model-exporter.php │ └── template │ │ ├── README.md │ │ └── includes │ │ └── json-initializer │ │ ├── 0-load.php │ │ └── class-content-model-json-initializer.php ├── manager │ ├── 0-load.php │ ├── class-content-model-loader.php │ ├── manager.js │ └── src │ │ ├── components │ │ ├── attribute-binder-panel.js │ │ ├── cpt-settings-panel.js │ │ ├── edit-block.js │ │ ├── edit-field.js │ │ ├── fields-ui.js │ │ └── manage-bindings.js │ │ ├── constants.js │ │ ├── hooks │ │ ├── use-content-model-name-validation.js │ │ └── use-default-value-placeholder-changer.js │ │ ├── register-attribute-binder.js │ │ ├── register-content-model-name-validation.js │ │ ├── register-cpt-settings-panel.js │ │ ├── register-default-value-placeholder-changer.js │ │ └── register-fields-ui.js └── runtime │ ├── 0-load.php │ ├── class-content-model-block.php │ ├── class-content-model-data-hydrator.php │ ├── class-content-model-html-manipulator.php │ ├── class-content-model-manager.php │ ├── class-content-model.php │ ├── data-entry.js │ ├── helpers.php │ ├── src │ ├── components │ │ ├── block-variation-updater.js │ │ └── fields-ui.js │ ├── constants.js │ ├── hooks │ │ ├── use-bound-group-extractor.js │ │ ├── use-content-locking.js │ │ └── use-fallback-value-clearer.js │ ├── register-block-variation-updater.js │ ├── register-bound-group-extractor.js │ ├── register-content-locking.js │ ├── register-fallback-value-clearer.js │ └── register-fields-ui.js │ └── templating.js ├── package-lock.json ├── package.json ├── phpcs.xml └── webpack.config.js /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | root: true 2 | env: 3 | es6: true 4 | browser: true 5 | jest: true 6 | extends: 7 | - plugin:@wordpress/eslint-plugin/recommended 8 | rules: 9 | '@wordpress/no-unsafe-wp-apis': off 10 | 'import/no-extraneous-dependencies': off 11 | -------------------------------------------------------------------------------- /.github/workflows/publish-latest-plugin-zip.yml: -------------------------------------------------------------------------------- 1 | name: Publish plugin to Automattic/create-content-model-releases latest on trunk merge 2 | 3 | on: 4 | push: 5 | branches: 6 | - trunk 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Get most recent tag 19 | id: get_tag 20 | uses: WyriHaximus/github-action-get-previous-tag@v1 21 | with: 22 | fallback: 1.0.0 23 | 24 | - name: Update version to latest trunk commit 25 | run: "find . -type f -exec sed -i 's/0.0.0-placeholder/${{ steps.get_tag.outputs.tag }}-dev-${{ github.sha }}/g' {} +" 26 | 27 | - name: Setup Node.js 28 | uses: actions/setup-node@v4 29 | with: 30 | node-version: "20" 31 | 32 | - name: Install dependencies 33 | run: npm ci 34 | 35 | - name: Build and create plugin zip 36 | run: npm run plugin-zip 37 | 38 | - name: Create output directory 39 | run: mkdir -p output 40 | 41 | - name: Move plugin zip to output/ 42 | run: mv create-content-model.zip output/ 43 | 44 | - name: Get user email 45 | id: get_email 46 | run: | 47 | EMAIL=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ 48 | https://api.github.com/users/${{ github.actor }} \ 49 | | jq -r '.email // empty') 50 | if [ -z "$EMAIL" ]; then 51 | EMAIL="${{ github.actor }}@users.noreply.github.com" 52 | fi 53 | echo "email=$EMAIL" >> $GITHUB_OUTPUT 54 | 55 | - name: Push zip to another repository 56 | uses: cpina/github-action-push-to-another-repository@main 57 | env: 58 | SSH_DEPLOY_KEY: ${{ secrets.SSH_DEPLOY_KEY }} 59 | with: 60 | source-directory: "output" 61 | destination-github-username: "automattic" 62 | destination-repository-name: "create-content-model-releases" 63 | user-name: ${{ github.actor }} 64 | user-email: ${{ steps.get_email.outputs.email }} 65 | target-branch: latest 66 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: "tagged-release" 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | jobs: 9 | tagged-release: 10 | name: "Tagged Release" 11 | runs-on: "ubuntu-latest" 12 | outputs: 13 | tag_name: ${{ steps.get_tag.outputs.tag_name }} 14 | 15 | steps: 16 | - uses: actions/checkout@master 17 | - name: Get tag name 18 | id: get_tag 19 | run: echo "tag_name=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT 20 | - name: Fetch Tags 21 | run: git fetch --tags --force 22 | - id: tag-message 23 | run: git tag -l --sort=-taggerdate --format='%(contents)' $(git describe --tags $(git branch --show-current)) 24 | - uses: actions/setup-node@v4 25 | with: 26 | node-version: 20 27 | cache: "npm" 28 | - run: npm install 29 | - run: "find . -type f -exec sed -i 's/0.0.0-placeholder/${{ steps.get_tag.outputs.tag_name }}/g' {} +" 30 | - run: npm run plugin-zip 31 | - uses: softprops/action-gh-release@v2 32 | with: 33 | body: ${{ steps.tag-message.outputs.stdout }} 34 | files: create-content-model.zip 35 | make_latest: true 36 | 37 | - name: Upload plugin zip 38 | uses: actions/upload-artifact@v4 39 | with: 40 | name: plugin-zip 41 | path: create-content-model.zip 42 | 43 | publish-to-releases-repo: 44 | name: "Publish to Releases Repository" 45 | needs: tagged-release 46 | runs-on: "ubuntu-latest" 47 | 48 | steps: 49 | # Download the artifact from the previous job 50 | - name: Download plugin zip 51 | uses: actions/download-artifact@v4 52 | with: 53 | name: plugin-zip 54 | 55 | - name: Create output directory 56 | run: mkdir -p output 57 | 58 | - name: Move plugin zip to output/ 59 | run: mv create-content-model.zip output/ 60 | 61 | - name: Get user email 62 | id: get_email 63 | run: | 64 | EMAIL=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ 65 | https://api.github.com/users/${{ github.actor }} \ 66 | | jq -r '.email // empty') 67 | if [ -z "$EMAIL" ]; then 68 | EMAIL="${{ github.actor }}@users.noreply.github.com" 69 | fi 70 | echo "email=$EMAIL" >> $GITHUB_OUTPUT 71 | 72 | - name: Push zip to create-content-model-releases repository 73 | uses: cpina/github-action-push-to-another-repository@main 74 | env: 75 | SSH_DEPLOY_KEY: ${{ secrets.SSH_DEPLOY_KEY }} 76 | with: 77 | source-directory: "output" 78 | destination-github-username: "automattic" 79 | destination-repository-name: "create-content-model-releases" 80 | user-name: ${{ github.actor }} 81 | user-email: ${{ steps.get_email.outputs.email }} 82 | target-branch: releases 83 | commit-message: "${{ needs.tagged-release.outputs.tag_name }} from ORIGIN_COMMIT" 84 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | debug.log 3 | includes/*/dist 4 | create-content-model.zip 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = require( '@wordpress/prettier-config' ); 2 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The GNU General Public License, Version 2, June 1991 (GPLv2) 2 | 3 | > Copyright (C) 1989, 1991 Free Software Foundation, Inc. 4 | > 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA 5 | 6 | Everyone is permitted to copy and distribute verbatim copies of this license 7 | document, but changing it is not allowed. 8 | 9 | ## Preamble 10 | 11 | The licenses for most software are designed to take away your freedom to share 12 | and change it. By contrast, the GNU General Public License is intended to 13 | guarantee your freedom to share and change free software--to make sure the 14 | software is free for all its users. This General Public License applies to most 15 | of the Free Software Foundation's software and to any other program whose 16 | authors commit to using it. (Some other Free Software Foundation software is 17 | covered by the GNU Library General Public License instead.) You can apply it to 18 | your programs, too. 19 | 20 | When we speak of free software, we are referring to freedom, not price. Our 21 | General Public Licenses are designed to make sure that you have the freedom to 22 | distribute copies of free software (and charge for this service if you wish), 23 | that you receive source code or can get it if you want it, that you can change 24 | the software or use pieces of it in new free programs; and that you know you can 25 | do these things. 26 | 27 | To protect your rights, we need to make restrictions that forbid anyone to deny 28 | you these rights or to ask you to surrender the rights. These restrictions 29 | translate to certain responsibilities for you if you distribute copies of the 30 | software, or if you modify it. 31 | 32 | For example, if you distribute copies of such a program, whether gratis or for a 33 | fee, you must give the recipients all the rights that you have. You must make 34 | sure that they, too, receive or can get the source code. And you must show them 35 | these terms so they know their rights. 36 | 37 | We protect your rights with two steps: (1) copyright the software, and (2) offer 38 | you this license which gives you legal permission to copy, distribute and/or 39 | modify the software. 40 | 41 | Also, for each author's protection and ours, we want to make certain that 42 | everyone understands that there is no warranty for this free software. If the 43 | software is modified by someone else and passed on, we want its recipients to 44 | know that what they have is not the original, so that any problems introduced by 45 | others will not reflect on the original authors' reputations. 46 | 47 | Finally, any free program is threatened constantly by software patents. We wish 48 | to avoid the danger that redistributors of a free program will individually 49 | obtain patent licenses, in effect making the program proprietary. To prevent 50 | this, we have made it clear that any patent must be licensed for everyone's free 51 | use or not licensed at all. 52 | 53 | The precise terms and conditions for copying, distribution and modification 54 | follow. 55 | 56 | ## Terms And Conditions For Copying, Distribution And Modification 57 | 58 | **0.** This License applies to any program or other work which contains a notice 59 | placed by the copyright holder saying it may be distributed under the terms of 60 | this General Public License. The "Program", below, refers to any such program or 61 | work, and a "work based on the Program" means either the Program or any 62 | derivative work under copyright law: that is to say, a work containing the 63 | Program or a portion of it, either verbatim or with modifications and/or 64 | translated into another language. (Hereinafter, translation is included without 65 | limitation in the term "modification".) Each licensee is addressed as "you". 66 | 67 | Activities other than copying, distribution and modification are not covered by 68 | this License; they are outside its scope. The act of running the Program is not 69 | restricted, and the output from the Program is covered only if its contents 70 | constitute a work based on the Program (independent of having been made by 71 | running the Program). Whether that is true depends on what the Program does. 72 | 73 | **1.** You may copy and distribute verbatim copies of the Program's source code 74 | as you receive it, in any medium, provided that you conspicuously and 75 | appropriately publish on each copy an appropriate copyright notice and 76 | disclaimer of warranty; keep intact all the notices that refer to this License 77 | and to the absence of any warranty; and give any other recipients of the Program 78 | a copy of this License along with the Program. 79 | 80 | You may charge a fee for the physical act of transferring a copy, and you may at 81 | your option offer warranty protection in exchange for a fee. 82 | 83 | **2.** You may modify your copy or copies of the Program or any portion of it, 84 | thus forming a work based on the Program, and copy and distribute such 85 | modifications or work under the terms of Section 1 above, provided that you also 86 | meet all of these conditions: 87 | 88 | - **a)** You must cause the modified files to carry prominent notices stating 89 | that you changed the files and the date of any change. 90 | 91 | - **b)** You must cause any work that you distribute or publish, that in whole 92 | or in part contains or is derived from the Program or any part thereof, to 93 | be licensed as a whole at no charge to all third parties under the terms of 94 | this License. 95 | 96 | - **c)** If the modified program normally reads commands interactively when 97 | run, you must cause it, when started running for such interactive use in the 98 | most ordinary way, to print or display an announcement including an 99 | appropriate copyright notice and a notice that there is no warranty (or 100 | else, saying that you provide a warranty) and that users may redistribute 101 | the program under these conditions, and telling the user how to view a copy 102 | of this License. (Exception: if the Program itself is interactive but does 103 | not normally print such an announcement, your work based on the Program is 104 | not required to print an announcement.) 105 | 106 | These requirements apply to the modified work as a whole. If identifiable 107 | sections of that work are not derived from the Program, and can be reasonably 108 | considered independent and separate works in themselves, then this License, and 109 | its terms, do not apply to those sections when you distribute them as separate 110 | works. But when you distribute the same sections as part of a whole which is a 111 | work based on the Program, the distribution of the whole must be on the terms of 112 | this License, whose permissions for other licensees extend to the entire whole, 113 | and thus to each and every part regardless of who wrote it. 114 | 115 | Thus, it is not the intent of this section to claim rights or contest your 116 | rights to work written entirely by you; rather, the intent is to exercise the 117 | right to control the distribution of derivative or collective works based on the 118 | Program. 119 | 120 | In addition, mere aggregation of another work not based on the Program with the 121 | Program (or with a work based on the Program) on a volume of a storage or 122 | distribution medium does not bring the other work under the scope of this 123 | License. 124 | 125 | **3.** You may copy and distribute the Program (or a work based on it, under 126 | Section 2) in object code or executable form under the terms of Sections 1 and 2 127 | above provided that you also do one of the following: 128 | 129 | - **a)** Accompany it with the complete corresponding machine-readable source 130 | code, which must be distributed under the terms of Sections 1 and 2 above on 131 | a medium customarily used for software interchange; or, 132 | 133 | - **b)** Accompany it with a written offer, valid for at least three years, to 134 | give any third party, for a charge no more than your cost of physically 135 | performing source distribution, a complete machine-readable copy of the 136 | corresponding source code, to be distributed under the terms of Sections 1 137 | and 2 above on a medium customarily used for software interchange; or, 138 | 139 | - **c)** Accompany it with the information you received as to the offer to 140 | distribute corresponding source code. (This alternative is allowed only for 141 | noncommercial distribution and only if you received the program in object 142 | code or executable form with such an offer, in accord with Subsection b 143 | above.) 144 | 145 | The source code for a work means the preferred form of the work for making 146 | modifications to it. For an executable work, complete source code means all the 147 | source code for all modules it contains, plus any associated interface 148 | definition files, plus the scripts used to control compilation and installation 149 | of the executable. However, as a special exception, the source code distributed 150 | need not include anything that is normally distributed (in either source or 151 | binary form) with the major components (compiler, kernel, and so on) of the 152 | operating system on which the executable runs, unless that component itself 153 | accompanies the executable. 154 | 155 | If distribution of executable or object code is made by offering access to copy 156 | from a designated place, then offering equivalent access to copy the source code 157 | from the same place counts as distribution of the source code, even though third 158 | parties are not compelled to copy the source along with the object code. 159 | 160 | **4.** You may not copy, modify, sublicense, or distribute the Program except as 161 | expressly provided under this License. Any attempt otherwise to copy, modify, 162 | sublicense or distribute the Program is void, and will automatically terminate 163 | your rights under this License. However, parties who have received copies, or 164 | rights, from you under this License will not have their licenses terminated so 165 | long as such parties remain in full compliance. 166 | 167 | **5.** You are not required to accept this License, since you have not signed 168 | it. However, nothing else grants you permission to modify or distribute the 169 | Program or its derivative works. These actions are prohibited by law if you do 170 | not accept this License. Therefore, by modifying or distributing the Program (or 171 | any work based on the Program), you indicate your acceptance of this License to 172 | do so, and all its terms and conditions for copying, distributing or modifying 173 | the Program or works based on it. 174 | 175 | **6.** Each time you redistribute the Program (or any work based on the 176 | Program), the recipient automatically receives a license from the original 177 | licensor to copy, distribute or modify the Program subject to these terms and 178 | conditions. You may not impose any further restrictions on the recipients' 179 | exercise of the rights granted herein. You are not responsible for enforcing 180 | compliance by third parties to this License. 181 | 182 | **7.** If, as a consequence of a court judgment or allegation of patent 183 | infringement or for any other reason (not limited to patent issues), conditions 184 | are imposed on you (whether by court order, agreement or otherwise) that 185 | contradict the conditions of this License, they do not excuse you from the 186 | conditions of this License. If you cannot distribute so as to satisfy 187 | simultaneously your obligations under this License and any other pertinent 188 | obligations, then as a consequence you may not distribute the Program at all. 189 | For example, if a patent license would not permit royalty-free redistribution of 190 | the Program by all those who receive copies directly or indirectly through you, 191 | then the only way you could satisfy both it and this License would be to refrain 192 | entirely from distribution of the Program. 193 | 194 | If any portion of this section is held invalid or unenforceable under any 195 | particular circumstance, the balance of the section is intended to apply and the 196 | section as a whole is intended to apply in other circumstances. 197 | 198 | It is not the purpose of this section to induce you to infringe any patents or 199 | other property right claims or to contest validity of any such claims; this 200 | section has the sole purpose of protecting the integrity of the free software 201 | distribution system, which is implemented by public license practices. Many 202 | people have made generous contributions to the wide range of software 203 | distributed through that system in reliance on consistent application of that 204 | system; it is up to the author/donor to decide if he or she is willing to 205 | distribute software through any other system and a licensee cannot impose that 206 | choice. 207 | 208 | This section is intended to make thoroughly clear what is believed to be a 209 | consequence of the rest of this License. 210 | 211 | **8.** If the distribution and/or use of the Program is restricted in certain 212 | countries either by patents or by copyrighted interfaces, the original copyright 213 | holder who places the Program under this License may add an explicit 214 | geographical distribution limitation excluding those countries, so that 215 | distribution is permitted only in or among countries not thus excluded. In such 216 | case, this License incorporates the limitation as if written in the body of this 217 | License. 218 | 219 | **9.** The Free Software Foundation may publish revised and/or new versions of 220 | the General Public License from time to time. Such new versions will be similar 221 | in spirit to the present version, but may differ in detail to address new 222 | problems or concerns. 223 | 224 | Each version is given a distinguishing version number. If the Program specifies 225 | a version number of this License which applies to it and "any later version", 226 | you have the option of following the terms and conditions either of that version 227 | or of any later version published by the Free Software Foundation. If the 228 | Program does not specify a version number of this License, you may choose any 229 | version ever published by the Free Software Foundation. 230 | 231 | **10.** If you wish to incorporate parts of the Program into other free programs 232 | whose distribution conditions are different, write to the author to ask for 233 | permission. For software which is copyrighted by the Free Software Foundation, 234 | write to the Free Software Foundation; we sometimes make exceptions for this. 235 | Our decision will be guided by the two goals of preserving the free status of 236 | all derivatives of our free software and of promoting the sharing and reuse of 237 | software generally. 238 | 239 | ## No Warranty 240 | 241 | **11.** BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR 242 | THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE 243 | STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM 244 | "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, 245 | BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A 246 | PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE 247 | PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 248 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 249 | 250 | **12.** IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 251 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE 252 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 253 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR 254 | INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA 255 | BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A 256 | FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER 257 | OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Create Content Model 2 | 3 | _Define custom post types & fields in the Block Editor._ 4 | 5 | WordPress.com’s experimental Create Content Model plugin transforms the way custom post types and custom fields are created and managed in WordPress by making use of the latest core features to bring content modeling into the Block Editor. Additionally, the created data model and data entry UI can be exported as a standalone, maintenance-free plugin. 6 | 7 | [![Try in WordPress Playground](https://img.shields.io/badge/Try%20in%20WordPress%20Playground-blue?style=for-the-badge)](https://playground.wordpress.net/?blueprint-url=https://raw.githubusercontent.com/Automattic/create-content-model/trunk/blueprint.json) 8 | 9 | You can also test out the plugin locally with [Studio](https://developer.wordpress.com/studio/?utm_source=github&utm_medium=readme&utm_campaign=create-content-model)! Check out the [Get Started](/get-started.md#test-locally-with-studio) guide for details. 10 | 11 | https://github.com/user-attachments/assets/723a973a-eb92-4b71-9f64-ac269d0f9861 12 | 13 | For a more thorough introduction: 14 | 15 | - Check out Brian Coord's [Custom fields and post types inside the block editor livestream](https://www.youtube.com/watch?v=VLB3OkgNOTs). 16 | - [Watch our talk at WordCamp Asia 2025](https://www.youtube.com/live/nKntUgxnZuY?feature=shared&t=3409), and this is the [demo video](https://youtu.be/67CHMveu38Y) shown at the end of the talk. 17 | 18 | ## Getting Started 19 | 20 | Find detailed instructions on creating your content model using this plugin in the [Get Started](/get-started.md) guide. 21 | 22 | [![Download Latest Release](https://img.shields.io/badge/Download%20Latest%20Release-blue?style=for-the-badge)](https://github.com/Automattic/create-content-model/releases/latest/download/create-content-model.zip) 23 | 24 | ## About 25 | 26 | Our team at WordPress.com is excited to share our recent prototyping efforts on game changing approaches to custom content creation. 27 | 28 | The Create Content Model plugin builds upon our custom post types project at the [CloudFest Hackathon in 2024](https://wordpress.com/blog/2024/04/15/custom-post-types-wordpress-admin/?utm_source=github&utm_medium=readme&utm_campaign=create-content-model). We’ve leveraged core functionality, like [block bindings](https://make.wordpress.org/core/2024/03/06/new-feature-the-block-bindings-api/) and [block variations](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-variations/), to create a new paradigm for creating and managing custom post types and custom fields in WordPress. 29 | 30 | Unlike existing custom post type and custom field plugins, our plugin takes a native approach by putting the creation and management of these elements directly in the WordPress Block Editor. Using the Block Bindings API, `post_meta` fields are bound to block attributes. Block variations are created from each bound block for use in front-end template layouts. The result is an extremely intuitive workflow for both the setup of custom post types and fields and their usage in front-end templating. 31 | 32 | A key feature of the Create Content Model plugin is the export of a locked custom data model and a data entry UI. Developers can generate and reuse the same content models on multiple sites without ongoing plugin maintenance or costs. They can hand off fully functional sites with locked custom post types and fields, ready for clients to populate the content. 33 | 34 | ## Development 35 | 36 | * Run `npm install` to install the dependencies 37 | * Run `npm run dev-server` to start the local WordPress server 38 | * In a new terminal window, run `npm start` to start the JavaScript bundler watcher 39 | 40 | ### Bundling 41 | 42 | Run `npm run plugin-zip` to create a zip file of the plugin. This will automatically bundle the JavaScript files. 43 | 44 | ### Creating a new release 45 | 46 | Create a new release by filling the form on [this page](https://github.com/Automattic/create-content-model/releases/new). 47 | 48 | The release title and tag ("Choose a tag" selectbox, above the title) should be in the Semver format (`major.minor.patch`). 49 | 50 | The release description should be a list of bullet points of the most meaningful changes. You can copy the commit title from the merged PRs. 51 | 52 | After clicking "Publish release," a [GitHub workflow](https://github.com/Automattic/create-content-model/blob/trunk/.github/workflows/release.yml) will bundle the plugin and export the release artifact. 53 | 54 | ## Contribute & Contact 55 | 56 | Want to help us move this concept forward? 57 | 58 | Feel free to open an issue in the repo to discuss your proposed improvement. Pull requests are welcome for bug fixes and enhancements. 59 | 60 | We built this as a prototype and may invest into it further based on level of interest. Our near term vision is outlined in this [roadmap issue](https://github.com/Automattic/create-content-model/issues/77). 61 | 62 | ## Licensing 63 | [GNU General Public License](/LICENSE.md) 64 | 65 | ## Credits & Acknowledgements 66 | We’d like to thank the team at WordPress.com who made this project possible: [Luis Felipe Zaguini](https://github.com/zaguiini), [Candy Tsai](https://github.com/candy02058912), [Autumn Fjeld](https://github.com/autumnfjeld), [Brian Coords](https://github.com/bacoords), [Daniel Bachhuber](https://github.com/danielbachhuber). 67 | 68 | ## Stay in the Loop with WordPress.com 69 | Follow us: 70 | 71 | [![LinkedIn](https://img.shields.io/badge/LinkedIn-0077B5?style=for-the-badge&logo=linkedin&logoColor=white)](https://www.linkedin.com/showcase/wordpress.com) 72 | 73 | [![X](https://img.shields.io/badge/X-000000?style=for-the-badge&logo=x&logoColor=white)](https://x.com/wordpressdotcom) 74 | 75 | [![image](https://img.shields.io/badge/Instagram-E4405F?style=for-the-badge&logo=instagram&logoColor=white)](https://www.instagram.com/wordpressdotcom) 76 | 77 | 78 | 79 | And while you’re at it, check out our [WordPress hosting solution for developers](https://wordpress.com/hosting?utm_source=github&utm_medium=readme&utm_campaign=create-content-model) and [our agency program](https://wordpress.com/for-agencies/?utm_source=github&utm_medium=readme&utm_campaign=create-content-model). 80 | -------------------------------------------------------------------------------- /blueprint.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://playground.wordpress.net/blueprint-schema.json", 3 | "meta": { 4 | "title": "Create Content Model latest", 5 | "description": "Installs the latest version of create-content-model plugin to WordPress Playground", 6 | "author": "Automattic", 7 | "categories": [ "Content", "CPT" ] 8 | }, 9 | "landingPage": "/wp-admin/", 10 | "steps": [ 11 | { 12 | "step": "login" 13 | }, 14 | { 15 | "step": "installPlugin", 16 | "pluginZipFile": { 17 | "resource": "url", 18 | "url": "https://raw.githubusercontent.com/Automattic/create-content-model-releases/releases/create-content-model.zip" 19 | } 20 | }, 21 | { 22 | "step": "activatePlugin", 23 | "pluginPath": "create-content-model/create-content-model.php" 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /create-content-model.php: -------------------------------------------------------------------------------- 1 | esc_html__( 'Return to Plugins Page' ), 18 | 'link_url' => esc_url( admin_url( 'plugins.php' ) ), 19 | ) 20 | ); 21 | } 22 | 23 | define( 'CONTENT_MODEL_PLUGIN_FILE', __FILE__ ); 24 | define( 'CONTENT_MODEL_PLUGIN_PATH', plugin_dir_path( __FILE__ ) ); 25 | define( 'CONTENT_MODEL_PLUGIN_URL', plugin_dir_url( __FILE__ ) ); 26 | 27 | if ( ! function_exists( 'content_model_require_if_exists' ) ) { 28 | /** 29 | * Requires a file if it exists. 30 | * 31 | * @param string $file The file to require. 32 | */ 33 | function content_model_require_if_exists( string $file ) { 34 | if ( file_exists( $file ) ) { 35 | require_once $file; 36 | } 37 | } 38 | } 39 | 40 | content_model_require_if_exists( __DIR__ . '/includes/json-initializer/0-load.php' ); 41 | content_model_require_if_exists( __DIR__ . '/includes/runtime/0-load.php' ); 42 | content_model_require_if_exists( __DIR__ . '/includes/manager/0-load.php' ); 43 | content_model_require_if_exists( __DIR__ . '/includes/exporter/0-load.php' ); 44 | -------------------------------------------------------------------------------- /dev.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://playground.wordpress.net/blueprint-schema.json", 3 | "steps": [ 4 | { 5 | "step": "defineWpConfigConsts", 6 | "consts": { 7 | "WP_DEBUG": true, 8 | "WP_DEBUG_LOG": "/var/www/html/wp-content/plugins/create-content-model/debug.log" 9 | } 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /get-started.md: -------------------------------------------------------------------------------- 1 | 2 | # Get Started 3 | Start leveraging the latest WordPress core features with WordPress.com’s experimental Create Content Model plugin. 4 | 5 | Create custom post types and custom fields directly in the Block Editor, and then export your data model and data entry UI as a standalone, maintenance-free plugin. Everything you build is using core WordPress functionality, so you can run the future-proof plugin or you can use filters and hooks to extend and adapt your data model. 6 | 7 | To get started with the Create Content Model plugin, [download the latest release](https://github.com/Automattic/create-content-model/releases/latest/download/create-content-model.zip), [launch our WordPress Playground Blueprint](https://playground.wordpress.net/?blueprint-url=https://raw.githubusercontent.com/Automattic/create-content-model/trunk/blueprint.json), or [test locally with Studio](#test-locally-with-studio). 8 | 9 | https://github.com/user-attachments/assets/09b449f0-4398-4037-ba07-820c76407d7d 10 | 11 | ## Creating a content model 12 | 13 | A content model is a custom post type that you can register and build directly in WordPress core. As you design it, you select which blocks are editable and can add custom fields. 14 | 15 | Creating a new content model includes: 16 | 17 | 1. Registering a new post type. 18 | 2. Designing the frontend template for your new post type and selecting which blocks in the template are editable by adding new “Bindings.” 19 | 3. Adding custom fields to your post to collect additional data. 20 | 21 | *Note that custom taxonomy support is on the [roadmap](https://github.com/Automattic/create-content-model/issues/77).* 22 | 23 | ### Register the post type 24 | 25 | From the Content Models page: 26 | 27 | 1. Click **Add New Model**. 28 | 2. Name your content model. 29 | 3. Manage the post type settings using the sidebar panel: Singular Label, Plural Label, Icon Name 30 | 31 | ![data-model-post-type-labels](https://github.com/user-attachments/assets/9369283f-d8d9-4040-8ec3-722ef8b9d0ff) 32 | 33 | ### Design the frontend template for your new post type 34 | Once you’ve created the post type, you can start designing the template. 35 | 36 | As you add blocks to the template, they will not be editable by default. You’ll need to select which blocks include dynamic (aka editable) data by selecting **Add Binding** in the block sidebar controls. 37 | 38 | Once a block is “bound,” it will be editable in the future, and any data entered will be automatically stored as postmeta, meaning you can use that data in different templates or query loops, view it in the REST API, bulk manage it via the database, and much more. 39 | 40 | ![data-model-add-binding](https://github.com/user-attachments/assets/7a93ce88-f241-4017-bc01-1ecb472164b1) 41 | 42 | If you have the [Gutenberg plugin](https://wordpress.org/plugins/gutenberg/) and enable the "Block Binding UI" experiment enabled, you can view and use your custom fields registered as postmeta when you manually bind an attribute. 43 | 44 | Since we’re using core WordPress’ [Block Bindings API](https://make.wordpress.org/core/2024/03/06/new-feature-the-block-bindings-api/), the only blocks that are currently supported are the [Paragraph](https://wordpress.org/documentation/article/paragraph-block/), [Heading](https://wordpress.org/documentation/article/heading-block/), [Image](https://wordpress.org/documentation/article/image-block/), and [Buttons](https://wordpress.org/documentation/article/buttons-block/) blocks. 45 | 46 | ### Rich content areas: the Group block 47 | This tool also allows you to bind the [Group](https://wordpress.org/documentation/article/group-block/) block to a post meta field, which creates a “rich text” or WYSIWYG area in your template where multiple blocks can be used. A bound group block will store its contents in a post meta field, or you can map it directly to the `post_content` attribute. 48 | 49 | ![data-model-attribute-bindings](https://github.com/user-attachments/assets/6dfd750a-315b-46cd-ac73-4426b8e7a54f) 50 | 51 | ## Your data model and custom fields 52 | All of your bound blocks will save their content to post meta fields, so you can redesign, remix, and filter your content model in the future and without losing the integrity of your data. Open the Post Meta sidebar panel to view. 53 | 54 | ![data-model-post-meta](https://github.com/user-attachments/assets/7232d9b7-8ac3-4159-ba4a-6e94d37ada58) 55 | 56 | Click the Manage Post Meta button to browse all of your block bindings and create your own custom fields that are available in the post editing screen. 57 | 58 | ![data-model-post-meta-custom-fields](https://github.com/user-attachments/assets/f7ee2af7-1753-41ec-885d-4cf9b3669a93) 59 | 60 | Click **Publish** to see your new content model on your website. 61 | 62 | ## Adding and managing content 63 | Once your data model is published, it will show up as an additional post type beneath Posts and Pages in your WordPress dashboard. 64 | 65 | Add new content just like you would add any other post content. You’ll notice that only the blocks you bound are editable, and the rest of the data model’s template is safe from being changed or edited. 66 | 67 | ![data-model-content-group](https://github.com/user-attachments/assets/eac3b513-175b-480b-9777-94fa6cc340b1) 68 | 69 | Any custom fields that you’ve added will also be available in the post sidebar: 70 | 71 | ![data-model-custom-fields](https://github.com/user-attachments/assets/39b485a1-cf3a-492a-a497-969d1ca14040) 72 | 73 | Click the **Publish** button to publish your post and view it on the front end. 74 | 75 | ## Updating the front-end layout 76 | Create Content Model is designed to work with the block editor and block-based themes. If you’d like to make changes to the design of the single or archive templates for your custom post type, you can do that inside of the Site Editor, just like you would for any other post type. 77 | 78 | ### Single post template 79 | You can set up the single post layout in your custom post type template. Alternatively, you can create a new `single-CPTNAME.html` template and add block variations from the block inserter. 80 | 81 | ### Archives and the Query Loop block 82 | Create a custom [Query Loop](https://wordpress.org/documentation/article/query-loop-block/) on a page, `archive-CPTNAME.html` template, or any other template (e.g. the theme’s home template). Pull in the posts from your data model, sort and filter them, and use the new block variations in the block inserter to pull individual pieces of data into your template. 83 | 84 | ![data-model-query-loop](https://github.com/user-attachments/assets/a5023781-4ce8-426f-9e9d-46eb7ce35795) 85 | 86 | ## Plugin export workflow 87 | Create Content Model is a development tool, but it’s not required to run on your site. If you’re done building your content model, you can “export” it as a standalone plugin: 88 | 89 | 1. Click Content Models → **Export**. 90 | 2. Export the data model plugin by clicking the **Download ZIP file** button. 91 | 3. Install your data model plugin on a new site (or deactivate the main plugin on the current site and import the new plugin). Remember to upload it as the .zip file. 92 | 4. Optionally, add version control for your own content model plugin to track changes and deploy your plugin across multiple sites. 93 | 94 | ## Test locally with Studio 95 | Studio is WordPress.com's free, open-source local development environment. 96 | 97 | 1. Download [Studio](https://developer.wordpress.com/studio/?utm_source=github&utm_medium=get-started&utm_campaign=create-content-model). 98 | 2. Add a site. 99 | 3. Open WP Admin. 100 | 4. Download the [latest plugin release](https://github.com/Automattic/create-content-model/releases/latest/download/create-content-model.zip). 101 | 5. Install and activate the plugin. 102 | -------------------------------------------------------------------------------- /includes/exporter/0-load.php: -------------------------------------------------------------------------------- 1 | generate_all_models_json(); 69 | $has_models = ! empty( $all_models_json ); 70 | $show_error = ! $has_models; 71 | if ( isset( $_GET['error'] ) && 'no_models' === $_GET['error'] && check_admin_referer( 'export_error_nonce', 'export_error_nonce' ) ) { 72 | $show_error = true; 73 | } 74 | ?> 75 |
76 |

77 | render_error_message( $show_error ); ?> 78 | render_export_form( $has_models ); ?> 79 | render_content_models_preview( $all_models_json ); ?> 80 |
81 | 93 |
94 |

95 |
96 | 108 |
109 | 110 | 111 |

112 | > 113 |
114 | 126 |

127 |
128 | $model_data ) : ?> 129 |
130 |

131 | 132 | 133 |

134 | 140 |
141 | 142 |
143 | enqueue_content_models_preview_assets(); ?> 144 | 155 | 192 | 220 | 'postType', 232 | 'postType' => $model->slug, 233 | 'slug' => $model->slug, 234 | 'label' => $model->title, 235 | 'pluralLabel' => $model->get_plural_label(), 236 | 'icon' => $model->get_icon(), 237 | 'template' => $model->template, 238 | 'fields' => $this->format_fields_for_export( $model->get_meta_fields() ), 239 | ); 240 | } 241 | 242 | 243 | /** 244 | * Formats the fields for export. 245 | * 246 | * @param array $fields The raw fields data. 247 | * @return array The formatted fields for export. 248 | */ 249 | private function format_fields_for_export( $fields ) { 250 | $formatted_fields = array(); 251 | foreach ( $fields as $field ) { 252 | $formatted_fields[] = array( 253 | 'slug' => $field['slug'], 254 | 'type' => $field['type'], 255 | 'label' => $field['slug'], 256 | 'description' => $field['description'], 257 | ); 258 | } 259 | return $formatted_fields; 260 | } 261 | 262 | 263 | /** 264 | * Generates JSON data for all registered content models. 265 | * 266 | * @return array An associative array of content models' JSON data. 267 | */ 268 | private function generate_all_models_json() { 269 | $content_models = Content_Model_Manager::get_instance()->get_content_models(); 270 | $all_models_json = array(); 271 | 272 | foreach ( $content_models as $model ) { 273 | $json_data = $this->generate_json_for_model( $model ); 274 | $all_models_json[ $model->slug ] = $json_data; 275 | } 276 | 277 | return $all_models_json; 278 | } 279 | 280 | 281 | /** 282 | * Handles the ZIP file download process. 283 | * 284 | * @return void 285 | */ 286 | public function handle_zip_download() { 287 | if ( ! current_user_can( 'manage_options' ) ) { 288 | wp_die( esc_html__( 'You do not have sufficient permissions to access this page.' ) ); 289 | } 290 | 291 | check_admin_referer( 'download_content_models_zip', 'download_zip_nonce' ); 292 | 293 | $all_models_json = $this->generate_all_models_json(); 294 | 295 | if ( empty( $all_models_json ) ) { 296 | $redirect_url = add_query_arg( 297 | array( 298 | 'error' => 'no_models', 299 | 'export_error_nonce' => wp_create_nonce( 'export_error_nonce' ), 300 | ), 301 | wp_get_referer() 302 | ); 303 | wp_safe_redirect( $redirect_url ); 304 | exit; 305 | } 306 | 307 | $zip_file = $this->create_zip_file( $all_models_json ); 308 | 309 | if ( $zip_file ) { 310 | $zip_url = wp_get_attachment_url( $zip_file ); 311 | wp_safe_redirect( $zip_url ); 312 | exit; 313 | } else { 314 | wp_die( esc_html__( 'Failed to create ZIP file' ) ); 315 | } 316 | } 317 | 318 | 319 | /** 320 | * Includes nested files in the ZIP archive. 321 | * 322 | * @param ZipArchive $zip The ZIP archive object. 323 | * @param string $base_path The base path of the files. 324 | * @param string $folder The folder to include. 325 | * @return void 326 | */ 327 | private function include_nested_files( $zip, $base_path, $folder ) { 328 | $files = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $base_path . $folder ), RecursiveIteratorIterator::SELF_FIRST ); 329 | 330 | foreach ( $files as $file ) { 331 | $path = str_replace( $base_path, '', $file->getPathname() ); 332 | 333 | if ( is_dir( $file ) ) { 334 | $zip->addEmptyDir( $path ); 335 | } else { 336 | $is_js_src = str_ends_with( $path, '.js' ) && ! str_contains( $path, 'dist' ); 337 | 338 | if ( $is_js_src ) { 339 | continue; 340 | } 341 | 342 | $zip->addFile( $file->getPathname(), $path ); 343 | } 344 | } 345 | } 346 | 347 | 348 | /** 349 | * Updates the version and name in the main plugin file. 350 | * 351 | * @return string The updated plugin file content. 352 | */ 353 | private function replace_content_model_plugin_file_version() { 354 | $plugin_data = get_plugin_data( CONTENT_MODEL_PLUGIN_FILE, false, false ); 355 | $plugin_file = file_get_contents( CONTENT_MODEL_PLUGIN_PATH . 'create-content-model.php' ); 356 | 357 | $original_version = $plugin_data['Version']; 358 | $updated_version = $plugin_data['Version'] . '-' . time(); 359 | 360 | $plugin_file = str_replace( "Version: $original_version", "Version: $updated_version", $plugin_file ); 361 | 362 | $original_plugin_name = $plugin_data['Name']; 363 | $updated_plugin_name = 'Content Models'; 364 | 365 | $plugin_file = str_replace( "Plugin Name: $original_plugin_name", "Plugin Name: $updated_plugin_name", $plugin_file ); 366 | 367 | return $plugin_file; 368 | } 369 | 370 | 371 | /** 372 | * Creates a ZIP file containing the content models and necessary plugin files. 373 | * This does not include content models that are not published. 374 | * 375 | * @param array $all_models_json The JSON data for the content models. 376 | * @return int|false The attachment ID of the created ZIP file, or false on failure. 377 | */ 378 | private function create_zip_file( $all_models_json ) { 379 | $upload_dir = wp_upload_dir(); 380 | $zip_filename = 'content_models_' . gmdate( 'Y-m-d_H-i-s' ) . '.zip'; 381 | $zip_filepath = $upload_dir['path'] . '/' . $zip_filename; 382 | 383 | $zip = new ZipArchive(); 384 | if ( true !== $zip->open( $zip_filepath, ZipArchive::CREATE ) ) { 385 | return false; 386 | } 387 | 388 | $zip->addFromString( 'create-content-model.php', $this->replace_content_model_plugin_file_version() ); 389 | 390 | $this->include_nested_files( $zip, CONTENT_MODEL_PLUGIN_PATH, 'includes/runtime' ); 391 | $this->include_nested_files( $zip, CONTENT_MODEL_PLUGIN_PATH . 'includes/exporter/template/', '' ); 392 | 393 | foreach ( $all_models_json as $model_slug => $model_json ) { 394 | $zip->addFromString( 'post-types/' . $model_slug . '.json', wp_json_encode( $model_json, JSON_UNESCAPED_UNICODE ) ); 395 | } 396 | 397 | $zip->close(); 398 | 399 | $filetype = wp_check_filetype( $zip_filename, null ); 400 | $attachment = array( 401 | 'post_mime_type' => $filetype['type'], 402 | 'post_title' => sanitize_file_name( $zip_filename ), 403 | 'post_content' => '', 404 | 'post_status' => 'inherit', 405 | ); 406 | 407 | $attach_id = wp_insert_attachment( $attachment, $zip_filepath ); 408 | if ( 0 === $attach_id ) { 409 | return false; 410 | } 411 | 412 | return $attach_id; 413 | } 414 | } 415 | -------------------------------------------------------------------------------- /includes/exporter/template/README.md: -------------------------------------------------------------------------------- 1 | # Create Content Model Plugin (Exported version) 2 | 3 | ## Overview 4 | 5 | This is an exported version of the Create Content Model plugin, which contains the defined Content Models and template. 6 | 7 | ## Reminders 8 | 9 | - Cannot be activated with the main plugin simultaneously. 10 | - If you'd like to change any of the properties in the Content Model, please re-export it from the main plugin. Please don't edit this plugin directly. 11 | 12 | ## Installation 13 | 14 | Plugins > Add New Plugin > Upload Plugin Button at the top 15 | 16 | Please refer to the Main README file for the usage guide. 17 | -------------------------------------------------------------------------------- /includes/exporter/template/includes/json-initializer/0-load.php: -------------------------------------------------------------------------------- 1 | json_decode( file_get_contents( $file ), true ), 43 | $post_types 44 | ); 45 | 46 | self::register_content_models_from_json( $post_types ); 47 | self::delete_dangling_content_models( $post_types ); 48 | $option['version'] = $plugin_data['Version']; 49 | update_option( self::CREATE_CONTENT_MODEL_OPTION, $option ); 50 | } 51 | 52 | /** 53 | * Register the content models from JSON files. 54 | * 55 | * @param array $post_types The post types from the JSON files. 56 | */ 57 | private static function register_content_models_from_json( $post_types ) { 58 | $content_models = self::group_content_models_by_slug(); 59 | 60 | foreach ( $post_types as $post_type ) { 61 | $content_model_post = array( 62 | 'post_name' => $post_type['slug'], 63 | 'post_title' => $post_type['label'], 64 | 'post_status' => 'publish', 65 | 'post_type' => Content_Model_Manager::POST_TYPE_NAME, 66 | 'post_content' => serialize_blocks( $post_type['template'] ), 67 | ); 68 | 69 | $existing_content_model = $content_models[ $post_type['slug'] ] ?? null; 70 | 71 | if ( $existing_content_model ) { 72 | $content_model_post['ID'] = $existing_content_model->ID; 73 | } 74 | 75 | $post_id = wp_insert_post( $content_model_post ); 76 | 77 | update_post_meta( $post_id, 'plural_label', $post_type['pluralLabel'] ); 78 | update_post_meta( $post_id, 'icon', $post_type['icon'] ); 79 | 80 | update_post_meta( $post_id, 'fields', wp_json_encode( $post_type['fields'] ) ); 81 | } 82 | } 83 | 84 | /** 85 | * Deletes content models not included in the JSON files. 86 | * 87 | * @param array $post_types The post types from the JSON files. 88 | * 89 | * @return void 90 | */ 91 | private static function delete_dangling_content_models( $post_types ) { 92 | $content_models = self::group_content_models_by_slug(); 93 | $post_types = self::group_post_types_by_slug( $post_types ); 94 | 95 | foreach ( $content_models as $model_slug => $model ) { 96 | if ( ! isset( $post_types[ $model_slug ] ) ) { 97 | wp_delete_post( $model->ID, false ); 98 | } 99 | } 100 | } 101 | 102 | /** 103 | * Groups existing content models by their slug. 104 | * 105 | * @return array An array of content models, keyed by their slug. 106 | */ 107 | private static function group_content_models_by_slug() { 108 | $models = Content_Model_Manager::get_content_models_from_database(); 109 | 110 | return array_reduce( 111 | $models, 112 | function ( $carry, $model ) { 113 | $carry[ $model->post_name ] = $model; 114 | return $carry; 115 | }, 116 | array() 117 | ); 118 | } 119 | 120 | /** 121 | * Groups existing post types by their slug. 122 | * 123 | * @param array $post_types The post types from the JSON files. 124 | * 125 | * @return array An array of post types, keyed by their slug. 126 | */ 127 | private static function group_post_types_by_slug( $post_types ) { 128 | return array_reduce( 129 | $post_types, 130 | function ( $carry, $post_type ) { 131 | $carry[ $post_type['slug'] ] = $post_type; 132 | return $carry; 133 | }, 134 | array() 135 | ); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /includes/manager/0-load.php: -------------------------------------------------------------------------------- 1 | register_post_type(); 51 | 52 | add_action( 'enqueue_block_editor_assets', array( $this, 'maybe_enqueue_scripts' ) ); 53 | 54 | add_action( 'save_post', array( $this, 'map_template_to_bindings_api_signature' ), 99, 2 ); 55 | 56 | add_action( 'save_post', array( $this, 'flush_rewrite_rules_on_slug_change' ), 99, 2 ); 57 | 58 | /** 59 | * We need two different hooks here because the Editor and the front-end read from different sources. 60 | * 61 | * The Editor reads the whole post, while the front-end reads only the post content. 62 | */ 63 | add_action( 'the_post', array( $this, 'map_template_to_content_model_editor_signature' ) ); 64 | 65 | /** 66 | * Update title placeholder to be more suitable for creating a new model. 67 | */ 68 | add_filter( 'enter_title_here', array( $this, 'set_title_placeholder' ), 10, 2 ); 69 | } 70 | 71 | /** 72 | * Registers the post type for the content models. 73 | * 74 | * @return void 75 | */ 76 | private function register_post_type() { 77 | register_post_type( 78 | Content_Model_Manager::POST_TYPE_NAME, 79 | array( 80 | 'labels' => array( 81 | 'name' => __( 'Content Models' ), 82 | 'singular_name' => __( 'Content Model' ), 83 | 'menu_name' => __( 'Content Models' ), 84 | 'all_items' => __( 'All Content Models' ), 85 | 'add_new' => __( 'Add New Model' ), 86 | 'add_new_item' => __( 'Add New Content Model' ), 87 | 'edit_item' => __( 'Edit Content Model' ), 88 | 'new_item' => __( 'New Content Model' ), 89 | 'view_item' => __( 'View Content Model' ), 90 | 'search_items' => __( 'Search Content Models' ), 91 | 'not_found' => __( 'No content models found' ), 92 | 'not_found_in_trash' => __( 'No content models found in trash' ), 93 | 'parent_item_colon' => __( 'Parent Content Model:' ), 94 | 'featured_image' => __( 'Featured Image' ), 95 | 'set_featured_image' => __( 'Set featured image' ), 96 | 'remove_featured_image' => __( 'Remove featured image' ), 97 | 'use_featured_image' => __( 'Use as featured image' ), 98 | 'archives' => __( 'Content Model archives' ), 99 | 'insert_into_item' => __( 'Insert into content model' ), 100 | 'uploaded_to_this_item' => __( 'Uploaded to this content model' ), 101 | 'filter_items_list' => __( 'Filter content models list' ), 102 | 'items_list_navigation' => __( 'Content models list navigation' ), 103 | 'items_list' => __( 'Content models list' ), 104 | 'attributes' => __( 'Content Model Attributes' ), 105 | ), 106 | 'public' => true, 107 | 'publicly_queryable' => false, 108 | 'menu_position' => 60, 109 | 'menu_icon' => 'dashicons-edit', 110 | 'show_in_menu' => true, 111 | 'show_in_rest' => true, 112 | 'supports' => array( 'title', 'editor', 'custom-fields' ), 113 | 'template' => array( 114 | array( 115 | 'core/paragraph', 116 | array( 'placeholder' => __( 'Start building your model' ) ), 117 | ), 118 | ), 119 | ) 120 | ); 121 | 122 | register_post_meta( 123 | Content_Model_Manager::POST_TYPE_NAME, 124 | 'fields', 125 | array( 126 | 'type' => 'string', 127 | 'single' => true, 128 | 'sanitize_callback' => '', 129 | 'default' => '[]', 130 | 'show_in_rest' => array( 131 | 'schema ' => array( 132 | 'type' => 'string', 133 | ), 134 | ), 135 | ) 136 | ); 137 | 138 | register_post_meta( 139 | Content_Model_Manager::POST_TYPE_NAME, 140 | 'blocks', 141 | array( 142 | 'type' => 'string', 143 | 'single' => true, 144 | 'sanitize_callback' => '', 145 | 'default' => '[]', 146 | 'show_in_rest' => array( 147 | 'schema ' => array( 148 | 'type' => 'string', 149 | ), 150 | ), 151 | ) 152 | ); 153 | 154 | $cpt_fields = array( 155 | 'plural_label' => array( 156 | 'type' => 'string', 157 | 'single' => true, 158 | 'show_in_rest' => true, 159 | ), 160 | 'icon' => array( 161 | 'type' => 'string', 162 | 'single' => true, 163 | 'show_in_rest' => true, 164 | 'default' => 'admin-post', 165 | ), 166 | ); 167 | 168 | foreach ( $cpt_fields as $field_name => $field_args ) { 169 | register_post_meta( 170 | Content_Model_Manager::POST_TYPE_NAME, 171 | $field_name, 172 | $field_args 173 | ); 174 | } 175 | } 176 | 177 | /** 178 | * Enqueue the helper scripts if opening the content model manager. 179 | * 180 | * @return void 181 | */ 182 | public function maybe_enqueue_scripts() { 183 | global $post; 184 | 185 | if ( ! $post || Content_Model_Manager::POST_TYPE_NAME !== $post->post_type ) { 186 | return; 187 | } 188 | 189 | $asset_file = include CONTENT_MODEL_PLUGIN_PATH . '/includes/manager/dist/manager.asset.php'; 190 | 191 | wp_enqueue_script( 192 | 'content-model/manager', 193 | CONTENT_MODEL_PLUGIN_URL . '/includes/manager/dist/manager.js', 194 | $asset_file['dependencies'], 195 | $asset_file['version'], 196 | true 197 | ); 198 | 199 | wp_localize_script( 200 | 'content-model/manager', 201 | 'contentModelData', 202 | array( 203 | 'BINDINGS_KEY' => self::BINDINGS_KEY, 204 | 'BLOCK_VARIATION_NAME_ATTR' => Content_Model_Block::BLOCK_VARIATION_NAME_ATTR, 205 | 'POST_TYPE_NAME' => Content_Model_Manager::POST_TYPE_NAME, 206 | ) 207 | ); 208 | } 209 | 210 | /** 211 | * Maps our bindings to the bindings API signature. 212 | * 213 | * @param int $post_id The post ID. 214 | * @param WP_Post $post The post. 215 | */ 216 | public function map_template_to_bindings_api_signature( $post_id, $post ) { 217 | if ( Content_Model_Manager::POST_TYPE_NAME !== $post->post_type || 'publish' !== $post->post_status ) { 218 | return; 219 | } 220 | 221 | remove_action( 'save_post', array( $this, 'map_template_to_bindings_api_signature' ), 99 ); 222 | 223 | $blocks = parse_blocks( wp_unslash( $post->post_content ) ); 224 | $blocks = content_model_block_walker( $blocks, array( $this, 'map_block_to_bindings_api_signature' ) ); 225 | $blocks = serialize_blocks( $blocks ); 226 | 227 | wp_update_post( 228 | array( 229 | 'ID' => $post_id, 230 | 'post_content' => $blocks, 231 | ) 232 | ); 233 | 234 | add_action( 'save_post', array( $this, 'map_template_to_bindings_api_signature' ), 99, 2 ); 235 | } 236 | 237 | /** 238 | * Maps bindings from our signature to a language the bindings API can understand. This is necessary because in 239 | * content editing mode, you should be able to override the bound attribute's values. 240 | * 241 | * @param array $block The blocks from the template. 242 | * 243 | * @return array $block The blocks from the template. 244 | */ 245 | public static function map_block_to_bindings_api_signature( $block ) { 246 | $existing_bindings = $block['attrs']['metadata'][ self::BINDINGS_KEY ] ?? array(); 247 | 248 | if ( empty( $existing_bindings ) ) { 249 | return $block; 250 | } 251 | 252 | $block['attrs']['metadata']['bindings'] = array(); 253 | 254 | foreach ( $existing_bindings as $attribute => $field ) { 255 | $block['attrs']['metadata']['bindings'][ $attribute ] = array( 256 | 'source' => 'post_content' === $field ? 'core/post-content' : 'core/post-meta', 257 | 'args' => array( 'key' => $field ), 258 | ); 259 | } 260 | 261 | unset( $block['attrs']['metadata'][ self::BINDINGS_KEY ] ); 262 | 263 | return $block; 264 | } 265 | 266 | /** 267 | * In the editor, display the template and fill it with the data. 268 | * 269 | * @param WP_Post $post The current post. 270 | */ 271 | public function map_template_to_content_model_editor_signature( $post ) { 272 | if ( Content_Model_Manager::POST_TYPE_NAME !== $post->post_type ) { 273 | return; 274 | } 275 | 276 | $blocks = parse_blocks( wp_unslash( $post->post_content ) ); 277 | $blocks = content_model_block_walker( $blocks, array( $this, 'map_block_to_content_model_editor_signature' ) ); 278 | $blocks = serialize_blocks( $blocks ); 279 | 280 | $post->post_content = $blocks; 281 | } 282 | 283 | /** 284 | * Maps bindings from the bindings API signature to ours. This is necessary because in 285 | * content editing mode, you should be able to override the bound attribute's values. 286 | * 287 | * @param array $block The block from the template. 288 | * 289 | * @return array $block The block from the template. 290 | */ 291 | public static function map_block_to_content_model_editor_signature( $block ) { 292 | $existing_bindings = $block['attrs']['metadata']['bindings'] ?? array(); 293 | 294 | if ( empty( $existing_bindings ) ) { 295 | return $block; 296 | } 297 | 298 | $block['attrs']['metadata'][ self::BINDINGS_KEY ] = array(); 299 | 300 | foreach ( $existing_bindings as $attribute => $binding ) { 301 | $block['attrs']['metadata'][ self::BINDINGS_KEY ][ $attribute ] = $binding['args']['key']; 302 | } 303 | 304 | unset( $block['attrs']['metadata']['bindings'] ); 305 | 306 | return $block; 307 | } 308 | 309 | 310 | /** 311 | * Flushes the rewrite rules when the slug of a content model changes. 312 | * 313 | * @param int $post_id The post ID. 314 | * @param WP_Post $post The post. 315 | */ 316 | public function flush_rewrite_rules_on_slug_change( $post_id, $post ) { 317 | if ( Content_Model_Manager::POST_TYPE_NAME !== $post->post_type ) { 318 | return; 319 | } 320 | 321 | flush_rewrite_rules(); 322 | } 323 | 324 | /** 325 | * Sets the title placeholder for the Content Model post type. 326 | * 327 | * @param string $title The default title placeholder. 328 | * @param WP_Post $post The current post object. 329 | * @return string The modified title placeholder. 330 | */ 331 | public function set_title_placeholder( $title, $post ) { 332 | if ( Content_Model_Manager::POST_TYPE_NAME === $post->post_type ) { 333 | return __( 'Add model name' ); 334 | } 335 | return $title; 336 | } 337 | } 338 | -------------------------------------------------------------------------------- /includes/manager/manager.js: -------------------------------------------------------------------------------- 1 | import { registerAttributeBinder } from './src/register-attribute-binder'; 2 | import { registerContentModelNameValidation } from './src/register-content-model-name-validation'; 3 | import { registerCPTSettingsPanel } from './src/register-cpt-settings-panel'; 4 | import { registerDefaultValuePlaceholderChanger } from './src/register-default-value-placeholder-changer'; 5 | import { registerFieldsUI } from './src/register-fields-ui'; 6 | 7 | registerAttributeBinder(); 8 | registerCPTSettingsPanel(); 9 | registerFieldsUI(); 10 | registerContentModelNameValidation(); 11 | registerDefaultValuePlaceholderChanger(); 12 | -------------------------------------------------------------------------------- /includes/manager/src/components/attribute-binder-panel.js: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo, useState } from '@wordpress/element'; 2 | import { __ } from '@wordpress/i18n'; 3 | import { InspectorControls } from '@wordpress/block-editor'; 4 | import { 5 | PanelBody, 6 | PanelRow, 7 | Button, 8 | ButtonGroup, 9 | __experimentalItemGroup as ItemGroup, 10 | __experimentalItem as Item, 11 | Flex, 12 | FlexBlock, 13 | FlexItem, 14 | } from '@wordpress/components'; 15 | import { useEntityProp } from '@wordpress/core-data'; 16 | 17 | import ManageBindings from './manage-bindings'; 18 | import { 19 | SUPPORTED_BLOCK_ATTRIBUTES, 20 | BINDINGS_KEY, 21 | POST_TYPE_NAME, 22 | BLOCK_VARIATION_NAME_ATTR, 23 | } from '../constants'; 24 | 25 | export const AttributeBinderPanel = ( { attributes, setAttributes, name } ) => { 26 | const supportedAttributes = SUPPORTED_BLOCK_ATTRIBUTES[ name ]; 27 | const bindings = attributes?.metadata?.[ BINDINGS_KEY ]; 28 | 29 | const [ editingBoundAttribute, setEditingBoundAttribute ] = 30 | useState( null ); 31 | 32 | const [ meta, setMeta ] = useEntityProp( 33 | 'postType', 34 | POST_TYPE_NAME, 35 | 'meta' 36 | ); 37 | 38 | const blocks = useMemo( () => { 39 | return meta?.blocks ? JSON.parse( meta.blocks ) : []; 40 | }, [ meta.blocks ] ); 41 | 42 | const boundField = blocks.find( 43 | ( block ) => block.slug === attributes.metadata?.slug 44 | ); 45 | 46 | const removeBindings = useCallback( () => { 47 | const newAttributes = { 48 | metadata: { 49 | ...( attributes.metadata ?? {} ), 50 | }, 51 | }; 52 | 53 | delete newAttributes.metadata[ BINDINGS_KEY ]; 54 | delete newAttributes.metadata[ BLOCK_VARIATION_NAME_ATTR ]; 55 | delete newAttributes.metadata.slug; 56 | 57 | const newBlocks = blocks.filter( 58 | ( block ) => block.slug !== attributes.metadata.slug 59 | ); 60 | 61 | setMeta( { 62 | blocks: JSON.stringify( newBlocks ), 63 | } ); 64 | 65 | setAttributes( newAttributes ); 66 | }, [ attributes.metadata, setAttributes, blocks, setMeta ] ); 67 | 68 | const setBinding = useCallback( 69 | ( block ) => { 70 | const newBindings = supportedAttributes.reduce( 71 | ( acc, attribute ) => { 72 | acc[ attribute ] = 73 | 'post_content' === block.slug 74 | ? block.slug 75 | : `${ block.slug }__${ attribute }`; 76 | 77 | return acc; 78 | }, 79 | {} 80 | ); 81 | 82 | const newAttributes = { 83 | metadata: { 84 | ...( attributes.metadata ?? {} ), 85 | [ BLOCK_VARIATION_NAME_ATTR ]: block.label, 86 | slug: block.slug, 87 | [ BINDINGS_KEY ]: newBindings, 88 | }, 89 | }; 90 | 91 | setAttributes( newAttributes ); 92 | }, 93 | [ attributes.metadata, setAttributes, supportedAttributes ] 94 | ); 95 | 96 | return ( 97 | 98 | 99 | { ! editingBoundAttribute && bindings && ( 100 | 101 | { supportedAttributes.map( ( attribute ) => { 102 | return ( 103 | 104 | 105 | { attribute } 106 | { bindings[ attribute ] && ( 107 | 108 | 109 | 110 | { 111 | bindings[ 112 | attribute 113 | ] 114 | } 115 | 116 | 117 | 118 | ) } 119 | 120 | 121 | ); 122 | } ) } 123 | 124 | ) } 125 | { ! editingBoundAttribute && ( 126 | 127 | 128 | 140 | { bindings && ( 141 | 147 | ) } 148 | 149 | 150 | ) } 151 | { editingBoundAttribute && ( 152 | 153 | { 155 | setBinding( formData ); 156 | setEditingBoundAttribute( null ); 157 | } } 158 | defaultFormData={ { 159 | label: 160 | attributes?.metadata?.[ 161 | BLOCK_VARIATION_NAME_ATTR 162 | ] ?? '', 163 | slug: attributes?.metadata?.slug ?? '', 164 | uuid: boundField?.uuid ?? crypto.randomUUID(), 165 | description: '', 166 | type: name, 167 | visible: false, 168 | } } 169 | typeIsDisabled={ true } 170 | /> 171 | 172 | ) } 173 | 174 | 175 | ); 176 | }; 177 | -------------------------------------------------------------------------------- /includes/manager/src/components/cpt-settings-panel.js: -------------------------------------------------------------------------------- 1 | import { PluginDocumentSettingPanel } from '@wordpress/editor'; 2 | import { TextControl, Dashicon } from '@wordpress/components'; 3 | import { __ } from '@wordpress/i18n'; 4 | import { 5 | useLayoutEffect, 6 | useRef, 7 | createInterpolateElement, 8 | } from '@wordpress/element'; 9 | import { useEntityProp } from '@wordpress/core-data'; 10 | import { POST_TYPE_NAME } from '../constants'; 11 | 12 | function getPlural( singular ) { 13 | if ( singular.endsWith( 'y' ) ) { 14 | return `${ singular.slice( 0, -1 ) }ies`; 15 | } 16 | if ( singular.endsWith( 's' ) || singular.endsWith( 'ch' ) ) { 17 | return `${ singular }es`; 18 | } 19 | return `${ singular }s`; 20 | } 21 | 22 | export const CPTSettingsPanel = function () { 23 | const [ meta, setMeta ] = useEntityProp( 24 | 'postType', 25 | POST_TYPE_NAME, 26 | 'meta' 27 | ); 28 | 29 | const [ title, setTitle ] = useEntityProp( 30 | 'postType', 31 | POST_TYPE_NAME, 32 | 'title' 33 | ); 34 | 35 | const lastTitle = useRef( title ); 36 | 37 | useLayoutEffect( () => { 38 | if ( title !== lastTitle.current ) { 39 | lastTitle.current = title; 40 | setMeta( { ...meta, plural_label: getPlural( title ) } ); 41 | } 42 | }, [ title, meta, setMeta ] ); 43 | 44 | const dashicon = meta.icon.replace( 'dashicons-', '' ); 45 | 46 | return ( 47 | <> 48 | 53 | 59 | 63 | setMeta( { ...meta, plural_label: value } ) 64 | } 65 | help={ __( 66 | 'This is the label that will be used for the plural form of the post type' 67 | ) } 68 | /> 69 |
70 | setMeta( { ...meta, icon } ) } 74 | help={ createInterpolateElement( 75 | __( 76 | 'The icon for the post type. See reference' 77 | ), 78 | { 79 | a: ( 80 | // eslint-disable-next-line jsx-a11y/anchor-has-content 81 | 86 | ), 87 | } 88 | ) } 89 | /> 90 | { dashicon && ( 91 |
101 | { ' ' } 102 | { ' ' } 103 |
104 | ) } 105 |
106 |
107 | 108 | ); 109 | }; 110 | -------------------------------------------------------------------------------- /includes/manager/src/components/edit-block.js: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | ButtonGroup, 4 | TextControl, 5 | __experimentalGrid as Grid, 6 | CardBody, 7 | Card, 8 | __experimentalItemGroup as ItemGroup, 9 | __experimentalItem as Item, 10 | Flex, 11 | FlexBlock, 12 | FlexItem, 13 | } from '@wordpress/components'; 14 | import { __ } from '@wordpress/i18n'; 15 | import { useState, useEffect } from '@wordpress/element'; 16 | import { 17 | blockDefault, 18 | paragraph, 19 | image, 20 | heading, 21 | group, 22 | button, 23 | } from '@wordpress/icons'; 24 | 25 | import { SUPPORTED_BLOCK_ATTRIBUTES } from '../constants'; 26 | 27 | const EditBlockForm = ( { 28 | block = { 29 | label: '', 30 | slug: '', 31 | description: '', 32 | type: 'text', 33 | visible: false, 34 | }, 35 | onChange = () => {}, 36 | } ) => { 37 | const [ formData, setFormData ] = useState( block ); 38 | 39 | useEffect( () => { 40 | onChange( formData ); 41 | }, [ formData ] ); 42 | 43 | return ( 44 | <> 45 | 46 | 47 | 52 | 58 | 59 | 60 | 61 | 66 | 71 | 75 | setFormData( { 76 | ...formData, 77 | description: value, 78 | } ) 79 | } 80 | /> 81 | 82 | 83 | 87 | 88 | 89 | 90 | ); 91 | }; 92 | 93 | const BlockAttributes = ( { slug, type } ) => { 94 | const supportedAttributes = SUPPORTED_BLOCK_ATTRIBUTES[ type ]; 95 | return ( 96 | 97 | { supportedAttributes.map( ( attribute ) => ( 98 | 99 | 100 | { attribute } 101 | 102 | 103 | { 'post_content' === slug ? ( 104 | { slug } 105 | ) : ( 106 | { `${ slug }__${ attribute }` } 107 | ) } 108 | 109 | 110 | 111 | 112 | ) ) } 113 | 114 | ); 115 | }; 116 | 117 | const blockIcon = ( type ) => { 118 | switch ( type ) { 119 | case 'core/paragraph': 120 | return paragraph; 121 | case 'core/image': 122 | return image; 123 | case 'core/heading': 124 | return heading; 125 | case 'core/group': 126 | return group; 127 | case 'core/button': 128 | return button; 129 | default: 130 | return blockDefault; 131 | } 132 | }; 133 | 134 | export default EditBlockForm; 135 | -------------------------------------------------------------------------------- /includes/manager/src/components/edit-field.js: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | ButtonGroup, 4 | TextControl, 5 | SelectControl, 6 | __experimentalGrid as Grid, 7 | CardBody, 8 | Card, 9 | __experimentalItemGroup as ItemGroup, 10 | __experimentalItem as Item, 11 | Flex, 12 | FlexBlock, 13 | FlexItem, 14 | } from '@wordpress/components'; 15 | import { __ } from '@wordpress/i18n'; 16 | import { useState, useEffect } from '@wordpress/element'; 17 | import { trash, chevronUp, chevronDown, post } from '@wordpress/icons'; 18 | import { cleanForSlug } from '@wordpress/url'; 19 | 20 | const EditFieldForm = ( { 21 | field = { 22 | label: '', 23 | slug: '', 24 | description: '', 25 | type: 'text', 26 | visible: false, 27 | }, 28 | onChange = () => {}, 29 | onDelete = () => {}, 30 | onMoveUp = () => {}, 31 | onMoveDown = () => {}, 32 | index, 33 | total, 34 | } ) => { 35 | const [ formData, setFormData ] = useState( field ); 36 | const [ slugWasTouched, setSlugWasTouched ] = useState( 37 | formData.slug !== '' 38 | ); 39 | 40 | useEffect( () => { 41 | if ( ! slugWasTouched ) { 42 | setFormData( { 43 | ...formData, 44 | slug: cleanForSlug( formData.label ).replace( /-/g, '_' ), 45 | } ); 46 | } 47 | onChange( formData ); 48 | }, [ formData ] ); 49 | 50 | return ( 51 | <> 52 | 53 | 54 | 59 | 62 | 63 | { index > 0 && ( 64 | 89 | 90 | 91 | { isFieldsOpen && ( 92 | setFieldsOpen( false ) } 96 | > 97 | , 103 | }, 104 | { 105 | name: 'blocks', 106 | title: __( 'Block Bindings' ), 107 | content: , 108 | }, 109 | ] } 110 | > 111 | { ( tab ) => <>{ tab.content } } 112 | 113 | 114 | ) } 115 | 116 | 117 | ); 118 | }; 119 | 120 | const FieldsList = () => { 121 | const [ meta, setMeta ] = useEntityProp( 122 | 'postType', 123 | POST_TYPE_NAME, 124 | 'meta' 125 | ); 126 | 127 | const fields = meta?.fields ? JSON.parse( meta.fields ) : []; 128 | 129 | // Save the fields back to the meta. 130 | const setFields = ( newFields ) => { 131 | setMeta( { fields: JSON.stringify( newFields ) } ); 132 | }; 133 | 134 | const deleteField = ( field ) => { 135 | const newFields = fields.filter( ( f ) => f.uuid !== field.uuid ); 136 | setFields( newFields ); 137 | }; 138 | 139 | const editField = ( field ) => { 140 | const newFields = fields.map( ( f ) => 141 | f.uuid === field.uuid ? field : f 142 | ); 143 | setFields( newFields ); 144 | }; 145 | 146 | return ( 147 | <> 148 |

{ __( 'Custom fields show up in the post sidebar.' ) }

149 | 150 | { fields.map( ( field ) => ( 151 | f.uuid === field.uuid 159 | ) } 160 | onMoveUp={ ( movedField ) => { 161 | const index = fields.findIndex( 162 | ( f ) => f.uuid === movedField.uuid 163 | ); 164 | const newFields = [ ...fields ]; 165 | newFields.splice( index, 1 ); 166 | newFields.splice( index - 1, 0, movedField ); 167 | setFields( newFields ); 168 | } } 169 | onMoveDown={ ( movedField ) => { 170 | const index = fields.findIndex( 171 | ( f ) => f.uuid === movedField.uuid 172 | ); 173 | const newFields = [ ...fields ]; 174 | newFields.splice( index, 1 ); 175 | newFields.splice( index + 1, 0, movedField ); 176 | setFields( newFields ); 177 | } } 178 | /> 179 | ) ) } 180 | 181 | 200 | 201 | 202 | ); 203 | }; 204 | 205 | const BlocksList = () => { 206 | const [ meta ] = useEntityProp( 'postType', POST_TYPE_NAME, 'meta' ); 207 | 208 | const blocks = meta?.blocks ? JSON.parse( meta.blocks ) : []; 209 | 210 | return ( 211 | <> 212 | 213 | { blocks.map( ( block ) => ( 214 | 215 | ) ) } 216 | 217 | 218 | ); 219 | }; 220 | const blockIcon = ( type ) => { 221 | switch ( type ) { 222 | case 'core/paragraph': 223 | return paragraph; 224 | case 'core/image': 225 | return image; 226 | case 'core/heading': 227 | return heading; 228 | case 'core/group': 229 | return group; 230 | case 'core/button': 231 | return button; 232 | default: 233 | return blockDefault; 234 | } 235 | }; 236 | -------------------------------------------------------------------------------- /includes/manager/src/components/manage-bindings.js: -------------------------------------------------------------------------------- 1 | import { Button, TextControl } from '@wordpress/components'; 2 | import { __ } from '@wordpress/i18n'; 3 | import { useEntityProp } from '@wordpress/core-data'; 4 | import { useState, useEffect } from '@wordpress/element'; 5 | import { POST_TYPE_NAME } from '../constants'; 6 | import { cleanForSlug } from '@wordpress/url'; 7 | 8 | const ManageBindings = ( { 9 | defaultFormData = { 10 | label: '', 11 | slug: '', 12 | description: '', 13 | type: 'text', 14 | visible: false, 15 | uuid: crypto.randomUUID(), 16 | }, 17 | onSave = () => {}, 18 | } ) => { 19 | const [ formData, setFormData ] = useState( defaultFormData ); 20 | const [ isValid, setIsValid ] = useState( false ); 21 | 22 | const [ meta, setMeta ] = useEntityProp( 23 | 'postType', 24 | POST_TYPE_NAME, 25 | 'meta' 26 | ); 27 | 28 | const blocks = meta?.blocks ? JSON.parse( meta.blocks ) : []; 29 | 30 | const saveForm = ( e ) => { 31 | e.preventDefault(); 32 | let newBlocks = blocks; 33 | 34 | if ( formData.slug === '' ) { 35 | const slug = cleanForSlug( formData.label ).replace( /-/g, '_' ); 36 | formData.slug = slug; 37 | } 38 | 39 | if ( blocks.find( ( block ) => block.uuid === formData.uuid ) ) { 40 | // If the slug is the same and it exists, update the block. 41 | newBlocks = newBlocks.map( ( block ) => { 42 | if ( block.uuid === formData.uuid ) { 43 | block = formData; 44 | } 45 | return block; 46 | } ); 47 | setMeta( { 48 | blocks: JSON.stringify( newBlocks ), 49 | } ); 50 | } else { 51 | setMeta( { 52 | blocks: JSON.stringify( [ ...newBlocks, formData ] ), 53 | } ); 54 | } 55 | onSave( formData ); 56 | }; 57 | 58 | useEffect( () => { 59 | if ( formData.label === '' ) { 60 | setIsValid( false ); 61 | } else { 62 | setIsValid( true ); 63 | } 64 | }, [ formData ] ); 65 | 66 | return ( 67 | <> 68 |
69 | 73 | setFormData( { ...formData, label: value } ) 74 | } 75 | /> 76 | 83 | 84 | 85 | ); 86 | }; 87 | 88 | export default ManageBindings; 89 | -------------------------------------------------------------------------------- /includes/manager/src/constants.js: -------------------------------------------------------------------------------- 1 | // https://github.com/WordPress/WordPress/blob/master/wp-includes/class-wp-block.php#L246-L251 2 | export const SUPPORTED_BLOCK_ATTRIBUTES = { 3 | 'core/group': [ 'content' ], 4 | 'core/paragraph': [ 'content' ], 5 | 'core/heading': [ 'content' ], 6 | 'core/image': [ 'id', 'url', 'title', 'alt' ], 7 | 'core/button': [ 'url', 'text', 'linkTarget', 'rel' ], 8 | }; 9 | 10 | export const { BINDINGS_KEY, BLOCK_VARIATION_NAME_ATTR, POST_TYPE_NAME } = 11 | window.contentModelData; 12 | -------------------------------------------------------------------------------- /includes/manager/src/hooks/use-content-model-name-validation.js: -------------------------------------------------------------------------------- 1 | import { __ } from '@wordpress/i18n'; 2 | import { useEffect } from '@wordpress/element'; 3 | import { useSelect, useDispatch } from '@wordpress/data'; 4 | import { store as editorStore } from '@wordpress/editor'; 5 | import { store as noticesStore } from '@wordpress/notices'; 6 | 7 | export const useContentModelNameValidation = () => { 8 | const { editPost, lockPostSaving, unlockPostSaving } = 9 | useDispatch( editorStore ); 10 | const { createNotice, removeNotice } = useDispatch( noticesStore ); 11 | 12 | const { title, isPublishSidebarOpened, isPublishingPost } = useSelect( 13 | ( select ) => ( { 14 | title: select( editorStore ).getEditedPostAttribute( 'title' ), 15 | isPublishSidebarOpened: 16 | select( editorStore ).isPublishSidebarOpened(), 17 | isPublishingPost: select( editorStore ).isPublishingPost(), 18 | } ) 19 | ); 20 | 21 | useEffect( () => { 22 | const trimmedTitle = title?.trim() ?? ''; 23 | 24 | if ( trimmedTitle.length === 0 ) { 25 | lockPostSaving( 'title-empty-lock' ); 26 | } else { 27 | unlockPostSaving( 'title-empty-lock' ); 28 | removeNotice( 'title-empty-notice' ); 29 | 30 | editPost( { title: trimmedTitle.substring( 0, 20 ) } ); 31 | 32 | if ( trimmedTitle.length > 20 ) { 33 | createNotice( 34 | 'warning', 35 | __( 'Model name is limited to 20 characters.' ), 36 | { 37 | id: 'title-length-notice', 38 | type: 'snackbar', 39 | isDismissible: true, 40 | } 41 | ); 42 | } 43 | } 44 | 45 | if ( 46 | ( isPublishSidebarOpened || isPublishingPost ) && 47 | trimmedTitle.length === 0 48 | ) { 49 | createNotice( 50 | 'warning', 51 | __( 'Please enter a model name in order to publish.' ), 52 | { 53 | id: 'title-empty-notice', 54 | isDismissible: true, 55 | } 56 | ); 57 | } 58 | }, [ 59 | title, 60 | isPublishSidebarOpened, 61 | isPublishingPost, 62 | lockPostSaving, 63 | unlockPostSaving, 64 | editPost, 65 | createNotice, 66 | removeNotice, 67 | ] ); 68 | }; 69 | -------------------------------------------------------------------------------- /includes/manager/src/hooks/use-default-value-placeholder-changer.js: -------------------------------------------------------------------------------- 1 | import { __, sprintf } from '@wordpress/i18n'; 2 | import { useEffect } from '@wordpress/element'; 3 | import { useSelect, useDispatch } from '@wordpress/data'; 4 | import { store as blockEditorStore } from '@wordpress/block-editor'; 5 | 6 | export const useDefaultValuePlaceholderChanger = () => { 7 | const boundBlocks = useSelect( ( select ) => { 8 | const blocks = select( blockEditorStore ).getBlocks(); 9 | const map = {}; 10 | 11 | const processBlock = ( block ) => { 12 | const name = block.attributes?.metadata?.name; 13 | 14 | if ( name ) { 15 | map[ block.clientId ] = block.attributes.metadata.name; 16 | } 17 | 18 | // Process inner blocks if they exist, like core/button is inside core/buttons. 19 | if ( block.innerBlocks && block.innerBlocks.length > 0 ) { 20 | block.innerBlocks.forEach( processBlock ); 21 | } 22 | }; 23 | 24 | blocks.forEach( processBlock ); 25 | 26 | return map; 27 | }, [] ); 28 | 29 | const { updateBlockAttributes } = useDispatch( blockEditorStore ); 30 | 31 | useEffect( () => { 32 | Object.entries( boundBlocks ).forEach( ( [ blockId, blockName ] ) => { 33 | updateBlockAttributes( blockId, { 34 | placeholder: sprintf( 35 | // translators: %s is the block binding's name. 36 | __( 'Add placeholder value for %s' ), 37 | blockName 38 | ), 39 | } ); 40 | } ); 41 | }, [ boundBlocks, updateBlockAttributes ] ); 42 | }; 43 | -------------------------------------------------------------------------------- /includes/manager/src/register-attribute-binder.js: -------------------------------------------------------------------------------- 1 | import { addFilter } from '@wordpress/hooks'; 2 | import { useMemo } from '@wordpress/element'; 3 | import { createHigherOrderComponent } from '@wordpress/compose'; 4 | import { useSelect } from '@wordpress/data'; 5 | import { store as blockEditorStore } from '@wordpress/block-editor'; 6 | 7 | import { SUPPORTED_BLOCK_ATTRIBUTES, BINDINGS_KEY } from './constants'; 8 | import { AttributeBinderPanel } from './components/attribute-binder-panel'; 9 | 10 | const withAttributeBinder = createHigherOrderComponent( ( BlockEdit ) => { 11 | return ( props ) => { 12 | const { getBlockParentsByBlockName, getBlocksByClientId } = 13 | useSelect( blockEditorStore ); 14 | 15 | const blockParentsByBlockName = getBlockParentsByBlockName( 16 | props.clientId, 17 | [ 'core/group' ] 18 | ); 19 | 20 | const parentHasBindings = useMemo( () => { 21 | return ( 22 | getBlocksByClientId( blockParentsByBlockName ).filter( 23 | ( block ) => 24 | Object.keys( 25 | block?.attributes?.metadata?.[ BINDINGS_KEY ] || {} 26 | ).length > 0 27 | ).length > 0 28 | ); 29 | }, [ blockParentsByBlockName, getBlocksByClientId ] ); 30 | 31 | const shouldDisplayAttributeBinderPanel = 32 | SUPPORTED_BLOCK_ATTRIBUTES[ props.name ] && ! parentHasBindings; 33 | 34 | return ( 35 | <> 36 | { shouldDisplayAttributeBinderPanel && ( 37 | 38 | ) } 39 | 40 | 41 | ); 42 | }; 43 | }, 'withAttributeBinder' ); 44 | 45 | export const registerAttributeBinder = () => { 46 | addFilter( 47 | 'editor.BlockEdit', 48 | 'content-model/attribute-binder', 49 | withAttributeBinder 50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /includes/manager/src/register-content-model-name-validation.js: -------------------------------------------------------------------------------- 1 | import { registerPlugin } from '@wordpress/plugins'; 2 | import { useContentModelNameValidation } from './hooks/use-content-model-name-validation'; 3 | 4 | export const registerContentModelNameValidation = () => { 5 | registerPlugin( 'content-model-name-validation', { 6 | render: () => { 7 | // eslint-disable-next-line react-hooks/rules-of-hooks 8 | useContentModelNameValidation(); 9 | }, 10 | } ); 11 | }; 12 | -------------------------------------------------------------------------------- /includes/manager/src/register-cpt-settings-panel.js: -------------------------------------------------------------------------------- 1 | import { registerPlugin } from '@wordpress/plugins'; 2 | import { CPTSettingsPanel } from './components/cpt-settings-panel'; 3 | 4 | export const registerCPTSettingsPanel = () => { 5 | registerPlugin( 'create-content-model-cpt-settings-panel', { 6 | render: CPTSettingsPanel, 7 | } ); 8 | }; 9 | -------------------------------------------------------------------------------- /includes/manager/src/register-default-value-placeholder-changer.js: -------------------------------------------------------------------------------- 1 | import { registerPlugin } from '@wordpress/plugins'; 2 | import { useDefaultValuePlaceholderChanger } from './hooks/use-default-value-placeholder-changer'; 3 | 4 | export const registerDefaultValuePlaceholderChanger = () => { 5 | registerPlugin( 'content-model-default-value-placeholder-changer', { 6 | render: () => { 7 | // eslint-disable-next-line react-hooks/rules-of-hooks 8 | useDefaultValuePlaceholderChanger(); 9 | }, 10 | } ); 11 | }; 12 | -------------------------------------------------------------------------------- /includes/manager/src/register-fields-ui.js: -------------------------------------------------------------------------------- 1 | import { registerPlugin } from '@wordpress/plugins'; 2 | import { FieldsUI } from './components/fields-ui'; 3 | 4 | export const registerFieldsUI = () => { 5 | registerPlugin( 'create-content-model-fields-ui', { 6 | render: FieldsUI, 7 | } ); 8 | }; 9 | -------------------------------------------------------------------------------- /includes/runtime/0-load.php: -------------------------------------------------------------------------------- 1 | block_name = $block['blockName']; 67 | $this->content_model = $content_model; 68 | $this->raw_block = $block; 69 | 70 | $metadata = $block['attrs']['metadata'] ?? array(); 71 | 72 | if ( empty( $metadata[ self::BLOCK_VARIATION_NAME_ATTR ] ) ) { 73 | return; 74 | } 75 | 76 | $this->block_variation_name = $metadata[ self::BLOCK_VARIATION_NAME_ATTR ]; 77 | $this->block_variation_slug = sanitize_title_with_dashes( $this->block_variation_name ); 78 | 79 | if ( ! $this->content_model && ! empty( $metadata[ self::CONTENT_MODEL_SLUG_ATTR ] ) ) { 80 | $content_model_slug = $metadata[ self::CONTENT_MODEL_SLUG_ATTR ]; 81 | 82 | $this->content_model = Content_Model_Manager::get_instance()->get_content_model_by_slug( $content_model_slug ); 83 | } 84 | 85 | $this->bindings = $metadata['bindings'] ?? array(); 86 | 87 | /** 88 | * If not instantiated directly by a content model, do not register hooks. 89 | */ 90 | if ( ! $content_model ) { 91 | return; 92 | } 93 | 94 | add_filter( 'get_block_type_variations', array( $this, 'register_block_variation' ), 10, 2 ); 95 | 96 | add_filter( 'pre_render_block', array( $this, 'render_group_variation' ), 99, 2 ); 97 | } 98 | 99 | /** 100 | * Retrieves the block variation name. 101 | * 102 | * @return string The block name. 103 | */ 104 | public function get_block_variation_name() { 105 | return $this->block_variation_name; 106 | } 107 | 108 | /** 109 | * Retrieves the content model instance associated with this block. 110 | * 111 | * @return Content_Model The content model instance. 112 | */ 113 | public function get_content_model() { 114 | return $this->content_model; 115 | } 116 | 117 | /** 118 | * Retrieves the underlying block name. 119 | * 120 | * @return string The block name. 121 | */ 122 | public function get_block_name() { 123 | return $this->block_name; 124 | } 125 | 126 | /** 127 | * Retrieves the bindings for the Content_Model_Block instance. 128 | * 129 | * @return array The bindings for the block. 130 | */ 131 | public function get_bindings() { 132 | return $this->bindings; 133 | } 134 | 135 | /** 136 | * Retrieves the value of a specific binding for the Content_Model_Block instance. 137 | * 138 | * @param string $binding_key The key of the binding to retrieve. 139 | * @return mixed The value of the binding, or null if not found. 140 | */ 141 | public function get_binding( $binding_key ) { 142 | return $this->bindings[ $binding_key ] ?? null; 143 | } 144 | 145 | /** 146 | * Register a block variations for this block. 147 | * 148 | * @param array $variations The existing block variations. 149 | * @param WP_Block_Type $block_type The block type. 150 | */ 151 | public function register_block_variation( $variations, $block_type ) { 152 | if ( $block_type->name !== $this->block_name || empty( $this->get_bindings() ) ) { 153 | return $variations; 154 | } 155 | 156 | $variation = array( 157 | 'name' => sanitize_title( $this->block_variation_name ), 158 | 'title' => $this->block_variation_name, 159 | 'category' => $this->content_model->slug . '-blocks', 160 | 'attributes' => array_merge( 161 | $this->raw_block['attrs'], 162 | array( 163 | 'metadata' => array( 164 | self::BLOCK_VARIATION_NAME_ATTR => $this->block_variation_name, 165 | self::CONTENT_MODEL_SLUG_ATTR => $this->content_model->slug, 166 | ), 167 | ), 168 | ), 169 | ); 170 | 171 | if ( 'core/group' === $this->block_name ) { 172 | $content_binding = $this->get_binding( 'content' ); 173 | 174 | if ( ! $content_binding ) { 175 | return $variations; 176 | } 177 | 178 | $variation['innerBlocks'] = array( 179 | array( 180 | 'core/paragraph', 181 | array( 182 | 'content' => $content_binding['args']['key'], 183 | ), 184 | ), 185 | ); 186 | } 187 | 188 | $variation['attributes']['metadata']['bindings'] = $this->get_bindings(); 189 | 190 | $variations[] = $variation; 191 | 192 | return $variations; 193 | } 194 | 195 | /** 196 | * Since there are no official bindings for group variations and its inner blocks, 197 | * we need to inject the inner blocks and HTML ourselves. 198 | * 199 | * @param string|null $pre_render The pre-rendered content. 200 | * @param array $parsed_block The current parsed block. 201 | * 202 | * @return string|null The rendered block. 203 | */ 204 | public function render_group_variation( $pre_render, $parsed_block ) { 205 | $tentative_block = new Content_Model_Block( $parsed_block ); 206 | 207 | if ( $tentative_block->get_block_variation_name() !== $this->block_variation_name ) { 208 | return $pre_render; 209 | } 210 | 211 | if ( $tentative_block->get_block_name() !== 'core/group' ) { 212 | return $pre_render; 213 | } 214 | 215 | $hydrator = new Content_Model_Data_Hydrator( array( $parsed_block ), true ); 216 | 217 | remove_filter( 'pre_render_block', array( $this, 'render_group_variation' ), 99 ); 218 | 219 | // Accessing index zero because we've passed an array with one element above. 220 | $result = render_block( $hydrator->hydrate()[0] ); 221 | 222 | add_filter( 'pre_render_block', array( $this, 'render_group_variation' ), 99, 2 ); 223 | 224 | return $result; 225 | } 226 | 227 | /** 228 | * Returns the metadata for an attribute. 229 | * 230 | * @param string $attribute_name The attribute. 231 | * 232 | * @return array The metadata. 233 | */ 234 | public function get_attribute_metadata( $attribute_name ) { 235 | $block_metadata = WP_Block_Type_Registry::get_instance()->get_registered( $this->get_block_name() ); 236 | $block_attributes = $block_metadata->get_attributes(); 237 | 238 | if ( isset( $block_attributes[ $attribute_name ] ) ) { 239 | return $block_attributes[ $attribute_name ]; 240 | } 241 | 242 | if ( 'core/group' === $this->get_block_name() && 'content' === $attribute_name ) { 243 | return array( 244 | 'source' => 'rich-text', 245 | 'selector' => 'div', 246 | ); 247 | } 248 | } 249 | 250 | /** 251 | * Get the type of an attribute, according to our case handling. 252 | * 253 | * @param string $attribute_name The name of the attribute. 254 | * 255 | * @return string The type of the attribute. 256 | */ 257 | public function get_attribute_type( $attribute_name ) { 258 | $block_attribute = $this->get_attribute_metadata( $attribute_name ); 259 | 260 | $block_attribute_type = $block_attribute['type'] ?? 'string'; 261 | 262 | if ( ! in_array( $block_attribute_type, array( 'integer', 'number', 'boolean' ), true ) ) { 263 | $block_attribute_type = 'string'; 264 | } 265 | 266 | return $block_attribute_type; 267 | } 268 | 269 | /** 270 | * Get the default value from an attribute in the template. 271 | * 272 | * @param string $attribute_name The attribute name. 273 | * 274 | * @return mixed The default value. 275 | */ 276 | public function get_default_value_for_attribute( $attribute_name ) { 277 | $block_attribute = $this->raw_block['attrs'][ $attribute_name ] ?? null; 278 | 279 | if ( $block_attribute ) { 280 | return $block_attribute; 281 | } 282 | 283 | if ( 'content' === $attribute_name && 'core/group' === $this->block_name ) { 284 | return serialize_blocks( $this->raw_block['innerBlocks'] ); 285 | } 286 | 287 | $attribute_metadata = $this->get_attribute_metadata( $attribute_name ); 288 | 289 | if ( isset( $attribute_metadata['source'] ) ) { 290 | $html_manipulator = new Content_Model_Html_Manipulator( $this->raw_block['innerHTML'] ); 291 | 292 | $attribute_value = $html_manipulator->extract_attribute( $attribute_metadata ); 293 | 294 | if ( $attribute_value ) { 295 | return $attribute_value; 296 | } 297 | } 298 | } 299 | } 300 | -------------------------------------------------------------------------------- /includes/runtime/class-content-model-data-hydrator.php: -------------------------------------------------------------------------------- 1 | blocks = $blocks; 37 | 38 | $this->strip_attribute_metadata = $strip_attribute_metadata; 39 | } 40 | 41 | /** 42 | * Fills the bound attributes with content from the post. 43 | * 44 | * @return array The template blocks, filled with data. 45 | */ 46 | public function hydrate() { 47 | return content_model_block_walker( 48 | $this->blocks, 49 | array( $this, 'hydrate_block' ) 50 | ); 51 | } 52 | 53 | /** 54 | * Hydrates a block with information from the post. 55 | * 56 | * @param array $block The block. 57 | * 58 | * @return array The hydrated block. 59 | */ 60 | public function hydrate_block( $block ) { 61 | $block['attrs']['lock'] = array( 62 | 'move' => true, 63 | 'remove' => true, 64 | ); 65 | 66 | $content_model_block = new Content_Model_Block( $block ); 67 | 68 | if ( 'core/group' !== $content_model_block->get_block_name() ) { 69 | return $block; 70 | } 71 | 72 | $binding = $content_model_block->get_binding( 'content' ); 73 | 74 | if ( ! $binding ) { 75 | return $block; 76 | } 77 | 78 | $field = $binding['args']['key']; 79 | 80 | if ( 'post_content' === $field ) { 81 | $content = get_the_content(); 82 | } else { 83 | $content = get_post_meta( get_the_ID(), $field, true ); 84 | } 85 | 86 | // If can't find the content, do not try to inject it. 87 | if ( ! $content ) { 88 | return $block; 89 | } 90 | 91 | if ( $this->strip_attribute_metadata ) { 92 | $content = implode( 93 | '', 94 | array_map( fn( $block ) => render_block( $block ), parse_blocks( $content ) ) 95 | ); 96 | } 97 | 98 | $html_handler = new Content_Model_Html_Manipulator( $block['innerHTML'] ); 99 | 100 | $block_attribute = $content_model_block->get_attribute_metadata( 'content' ); 101 | 102 | $block['innerHTML'] = $html_handler->replace_attribute( $block_attribute, $content ); 103 | $block['innerContent'] = array( $block['innerHTML'] ); 104 | 105 | return $block; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /includes/runtime/class-content-model-html-manipulator.php: -------------------------------------------------------------------------------- 1 | loadXML( $markup, LIBXML_NOXMLDECL ); 29 | 30 | $this->dom = $dom; 31 | } 32 | 33 | /** 34 | * Extract attribute value from the markup. 35 | * 36 | * @param array $attribute_metadata The attribute metadata from the block.json file. 37 | * 38 | * @return mixed|null The attribute value. 39 | */ 40 | public function extract_attribute( $attribute_metadata ) { 41 | $matches = $this->get_matches( $attribute_metadata['selector'] ); 42 | 43 | if ( ! $matches ) { 44 | return null; 45 | } 46 | 47 | foreach ( $matches as $match ) { 48 | if ( $match instanceof \DOMElement ) { 49 | if ( 'attribute' === $attribute_metadata['source'] ) { 50 | return $match->getAttribute( $attribute_metadata['attribute'] ); 51 | } 52 | 53 | return implode( 54 | '', 55 | array_map( 56 | // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase 57 | fn( $node ) => $node->ownerDocument->saveXML( $node ), 58 | // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase 59 | iterator_to_array( $match->childNodes ), 60 | ) 61 | ); 62 | } 63 | } 64 | 65 | return null; 66 | } 67 | 68 | /** 69 | * Replace attribute value in the markup. 70 | * 71 | * @param array $attribute_metadata The attribute metadata from the block.json file. 72 | * @param mixed $attribute_value The attribute value. 73 | * 74 | * @return string The updated markup. 75 | */ 76 | public function replace_attribute( $attribute_metadata, $attribute_value ) { 77 | $matches = $this->get_matches( $attribute_metadata['selector'] ); 78 | 79 | foreach ( $matches as $match ) { 80 | if ( $match instanceof \DOMElement ) { 81 | if ( 'attribute' === $attribute_metadata['source'] ) { 82 | $attribute = $attribute_metadata['attribute']; 83 | $value = $match->getAttribute( $attribute ); 84 | 85 | if ( 'class' === $attribute ) { 86 | $value .= ' ' . $attribute_value; 87 | } else { 88 | $value = $attribute_value; 89 | } 90 | 91 | $match->setAttribute( $attribute, $value ); 92 | } else { 93 | self::swap_element_inner_html( $match, $attribute_value ); 94 | } 95 | } 96 | } 97 | 98 | return $this->get_markup(); 99 | } 100 | 101 | /** 102 | * Returns matches for a given selector. 103 | * 104 | * @param string $selector The selector. 105 | * 106 | * @return DOMNodeList|false 107 | */ 108 | private function get_matches( $selector ) { 109 | $xpath = new DOMXPath( $this->dom ); 110 | 111 | $query = '(//*'; 112 | 113 | foreach ( explode( ',', $selector ) as $possible_selector ) { 114 | $query .= ' | ' . $possible_selector; 115 | } 116 | 117 | $query .= ')[last()]'; 118 | 119 | return $xpath->query( $query ); 120 | } 121 | 122 | /** 123 | * Returns the markup associated with the DOMDocument instance. 124 | * 125 | * @return string 126 | */ 127 | private function get_markup() { 128 | return $this->dom->saveXML( $this->dom->documentElement, LIBXML_NOXMLDECL ); 129 | } 130 | 131 | /** 132 | * Swap the node's innerHTML with the given HTML. 133 | * 134 | * @param \DOMElement $node The HTML node. 135 | * @param string $html The desired inner HTML. 136 | * 137 | * @return void 138 | */ 139 | private static function swap_element_inner_html( $node, $html ) { 140 | // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase 141 | $fragment = $node->ownerDocument->createDocumentFragment(); 142 | $fragment->appendXML( $html ); 143 | 144 | while ( $node->hasChildNodes() ) { 145 | // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase 146 | $node->removeChild( $node->firstChild ); 147 | } 148 | 149 | $node->appendChild( $fragment ); 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /includes/runtime/class-content-model-manager.php: -------------------------------------------------------------------------------- 1 | register_content_models(); 51 | } 52 | 53 | /** 54 | * Retrieves the registered content models. 55 | * 56 | * @return Content_Model[] An array of registered content models. 57 | */ 58 | public function get_content_models() { 59 | return $this->content_models; 60 | } 61 | 62 | /** 63 | * Retrieves a content model by its slug. 64 | * 65 | * @param string $slug The slug of the content model to retrieve. 66 | * @return Content_Model|null The content model with the matching slug, or null if not found. 67 | */ 68 | public function get_content_model_by_slug( $slug ) { 69 | foreach ( $this->content_models as $content_model ) { 70 | if ( $slug === $content_model->slug ) { 71 | return $content_model; 72 | } 73 | } 74 | 75 | return null; 76 | } 77 | 78 | /** 79 | * Registers all content models. 80 | * 81 | * @return void 82 | */ 83 | private function register_content_models() { 84 | $content_models = self::get_content_models_from_database(); 85 | 86 | foreach ( $content_models as $content_model ) { 87 | $this->content_models[] = new Content_Model( $content_model ); 88 | } 89 | } 90 | 91 | /** 92 | * Retrieves the list of registered content models. 93 | * 94 | * @return WP_Post[] An array of WP_Post objects representing the registered content models. 95 | */ 96 | public static function get_content_models_from_database() { 97 | return get_posts( array( 'post_type' => self::POST_TYPE_NAME ) ); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /includes/runtime/class-content-model.php: -------------------------------------------------------------------------------- 1 | slug = $content_model_post->post_name; 74 | $this->title = $content_model_post->post_title; 75 | $this->template = parse_blocks( $content_model_post->post_content ); 76 | $this->post_id = $content_model_post->ID; 77 | 78 | $this->register_post_type(); 79 | 80 | // TODO: Not load this eagerly. 81 | $this->blocks = $this->inflate_template_blocks( $this->template ); 82 | $this->fields = $this->parse_fields(); 83 | $this->register_meta_fields(); 84 | 85 | add_action( 'enqueue_block_editor_assets', array( $this, 'maybe_enqueue_templating_scripts' ) ); 86 | add_action( 'enqueue_block_editor_assets', array( $this, 'maybe_enqueue_data_entry_scripts' ) ); 87 | 88 | add_filter( 'block_categories_all', array( $this, 'register_block_category' ) ); 89 | 90 | add_filter( 'rest_request_before_callbacks', array( $this, 'remove_default_meta_keys_on_save' ), 10, 3 ); 91 | add_filter( 'rest_post_dispatch', array( $this, 'fill_empty_meta_keys_with_default_values' ), 10, 3 ); 92 | 93 | add_action( 'rest_after_insert_' . $this->slug, array( $this, 'extract_post_content_from_blocks' ), 99, 1 ); 94 | 95 | /** 96 | * We need two different hooks here because the Editor and the front-end read from different sources. 97 | * 98 | * The Editor reads the whole post, while the front-end reads only the post content. 99 | */ 100 | add_action( 'the_post', array( $this, 'hydrate_bound_groups' ) ); 101 | add_filter( 'the_content', array( $this, 'swap_post_content_with_hydrated_template' ) ); 102 | 103 | add_filter( 'get_post_metadata', array( $this, 'cast_meta_field_types' ), 10, 3 ); 104 | } 105 | 106 | /** 107 | * Retrieves the plural label for the content model. 108 | * 109 | * If the plural label is not set, it will default to the singular label with an 's' appended. 110 | * 111 | * @return string The plural label. 112 | */ 113 | public function get_plural_label() { 114 | return $this->get_model_meta( 'plural_label' ) ?? $this->get_plural( $this->title ); 115 | } 116 | 117 | /** 118 | * Retrieves the icon for the content model. 119 | * 120 | * If the icon is not set, it will default to 'admin-post'. 121 | * 122 | * @return string The icon slug. 123 | */ 124 | public function get_icon() { 125 | $icon = $this->get_model_meta( 'icon' ) ?? 'admin-post'; 126 | 127 | return str_replace( 'dashicons-', '', $icon ); 128 | } 129 | 130 | /** 131 | * Retrieves a meta value for the content model. 132 | * 133 | * @param string $key The meta key to retrieve. 134 | * @return mixed The value of the meta field, or null if it does not exist. 135 | */ 136 | private function get_model_meta( $key ) { 137 | $meta = get_post_meta( $this->post_id, $key, true ); 138 | 139 | if ( ! empty( $meta ) ) { 140 | return $meta; 141 | } 142 | 143 | return null; 144 | } 145 | 146 | /** 147 | * Attempts to get the plural label based on the given singular label. 148 | * 149 | * @param string $singular The singular label. 150 | * @return string The plural label (best guess based on common English grammar rules). 151 | */ 152 | private function get_plural( $singular ) { 153 | if ( str_ends_with( $singular, 'y' ) ) { 154 | return substr( $singular, 0, -1 ) . 'ies'; 155 | } 156 | if ( str_ends_with( $singular, 's' ) || str_ends_with( $singular, 'ch' ) ) { 157 | return "{$singular}es"; 158 | } 159 | return "{$singular}s"; 160 | } 161 | 162 | /** 163 | * Registers the custom post type for the content model. 164 | * 165 | * @return void 166 | */ 167 | private function register_post_type() { 168 | $singular_name = $this->title; 169 | $plural_name = $this->get_plural_label(); 170 | 171 | $labels = array( 172 | 'name' => $plural_name, 173 | 'singular_name' => $singular_name, 174 | 'menu_name' => $plural_name, 175 | // translators: %s is the plural name of the post type. 176 | 'all_items' => sprintf( __( 'All %s' ), $plural_name ), 177 | // translators: %s is the singular name of the post type. 178 | 'add_new' => sprintf( __( 'Add New %s' ), $singular_name ), 179 | // translators: %s is the singular name of the post type. 180 | 'add_new_item' => sprintf( __( 'Add New %s' ), $singular_name ), 181 | // translators: %s is the singular name of the post type. 182 | 'edit_item' => sprintf( __( 'Edit %s' ), $singular_name ), 183 | // translators: %s is the singular name of the post type. 184 | 'new_item' => sprintf( __( 'New %s' ), $singular_name ), 185 | // translators: %s is the singular name of the post type. 186 | 'view_item' => sprintf( __( 'View %s' ), $singular_name ), 187 | // translators: %s is the plural name of the post type. 188 | 'search_items' => sprintf( __( 'Search %s' ), $plural_name ), 189 | // translators: %s is the plural name of the post type. 190 | 'not_found' => sprintf( __( 'No %s found' ), $plural_name ), 191 | // translators: %s is the plural name of the post type. 192 | 'not_found_in_trash' => sprintf( __( 'No %s found in trash' ), $plural_name ), 193 | ); 194 | 195 | register_post_type( 196 | $this->slug, 197 | array( 198 | 'labels' => $labels, 199 | 'public' => true, 200 | 'show_in_menu' => true, 201 | 'has_archive' => true, 202 | 'show_in_rest' => true, 203 | 'menu_icon' => "dashicons-{$this->get_icon()}", 204 | 'supports' => array( 'title', 'editor', 'custom-fields' ), 205 | ) 206 | ); 207 | } 208 | 209 | /** 210 | * Parses the fields associated with the Content Model. 211 | * 212 | * @return array Fields associated with the Content Model. 213 | */ 214 | private function parse_fields() { 215 | $fields = get_post_meta( $this->post_id, 'fields', true ); 216 | 217 | if ( ! $fields ) { 218 | return array(); 219 | } 220 | 221 | $decoded_files = json_decode( $fields, true ); 222 | 223 | if ( is_array( $decoded_files ) ) { 224 | return $decoded_files; 225 | } 226 | 227 | return array(); 228 | } 229 | 230 | 231 | /** 232 | * Recursively inflates (i.e., maps the block into Content_Model_Block) the blocks. 233 | * 234 | * @param array $blocks The template blocks to inflate. 235 | * @return Content_Model_Block[] The Content_Model_Block instances. 236 | */ 237 | private function inflate_template_blocks( $blocks ) { 238 | $acc = array(); 239 | 240 | content_model_block_walker( 241 | $blocks, 242 | function ( $block ) use ( &$acc ) { 243 | $content_model_block = new Content_Model_Block( $block, $this ); 244 | 245 | if ( empty( $content_model_block->get_bindings() ) ) { 246 | return $block; 247 | } 248 | 249 | $acc[ $content_model_block->get_block_variation_name() ] = $content_model_block; 250 | 251 | return $block; 252 | } 253 | ); 254 | 255 | return $acc; 256 | } 257 | 258 | /** 259 | * Registers meta fields for the content model. 260 | * 261 | * @return void 262 | */ 263 | private function register_meta_fields() { 264 | 265 | if ( ! empty( $this->fields ) ) { 266 | foreach ( $this->fields as $field ) { 267 | if ( strpos( $field['type'], 'core' ) !== false ) { 268 | continue; 269 | } 270 | register_post_meta( 271 | $this->slug, 272 | $field['slug'], 273 | array( 274 | 'description' => $field['description'], 275 | 'show_in_rest' => true, 276 | 'single' => true, 277 | 'type' => 'string', // todo: support other types. 278 | ) 279 | ); 280 | } 281 | } 282 | 283 | foreach ( $this->blocks as $block ) { 284 | foreach ( $block->get_bindings() as $attribute_name => $binding ) { 285 | $field = $binding['args']['key']; 286 | 287 | if ( 'post_content' === $field ) { 288 | continue; 289 | } 290 | 291 | $this->bound_meta_keys[ $field ] = (object) array( 292 | 'block' => $block, 293 | 'attribute_name' => $attribute_name, 294 | ); 295 | 296 | $args = array( 297 | 'show_in_rest' => true, 298 | 'single' => true, 299 | 'type' => $block->get_attribute_type( $attribute_name ), 300 | ); 301 | 302 | $default_value = $block->get_default_value_for_attribute( $attribute_name ); 303 | 304 | if ( ! empty( $default_value ) ) { 305 | $args['default'] = $default_value; 306 | } 307 | 308 | register_post_meta( 309 | $this->slug, 310 | $field, 311 | $args 312 | ); 313 | } 314 | } 315 | } 316 | 317 | /** 318 | * Retrieves a list of meta fields registered for the content model. 319 | * 320 | * @return array An array of registered meta field keys. 321 | */ 322 | public function get_meta_fields() { 323 | $registered_meta_fields = get_registered_meta_keys( 'post', $this->slug ); 324 | 325 | $result = array(); 326 | 327 | foreach ( $registered_meta_fields as $meta_field => $meta_field_data ) { 328 | $result[] = array( 329 | 'slug' => $meta_field, 330 | 'type' => $meta_field_data['type'], 331 | 'description' => $meta_field_data['description'], 332 | ); 333 | } 334 | 335 | return $result; 336 | } 337 | 338 | /** 339 | * Casts meta field types for the content model. The values are saved as strings, 340 | * so we need to cast them back to their original type. Not sure why WordPress doesn't 341 | * do that already. 342 | * 343 | * @param mixed $value The value coming from the database. 344 | * @param int $object_id The post ID. 345 | * @param string $meta_key The meta key. 346 | * 347 | * @return mixed The casted value, if applicable. The original value otherwise. 348 | */ 349 | public function cast_meta_field_types( $value, $object_id, $meta_key ) { 350 | $meta_field_type = get_registered_meta_keys( 'post', $this->slug )[ $meta_key ]['type'] ?? null; 351 | 352 | if ( ! $meta_field_type ) { 353 | return $value; 354 | } 355 | 356 | if ( 'integer' === $meta_field_type ) { 357 | return (int) $value; 358 | } 359 | 360 | if ( 'number' === $meta_field_type ) { 361 | return (float) $value; 362 | } 363 | 364 | if ( 'boolean' === $meta_field_type ) { 365 | return (bool) $value; 366 | } 367 | 368 | return $value; 369 | } 370 | 371 | /** 372 | * Register the block category, which will be used by Content_Model_Block to group block variations. 373 | * 374 | * @param array $categories The existing block categories. 375 | */ 376 | public function register_block_category( $categories ) { 377 | $categories[] = array( 378 | 'slug' => $this->slug . '-blocks', 379 | // translators: %s is content model name. 380 | 'title' => sprintf( __( '%s blocks' ), ucwords( $this->title ) ), 381 | ); 382 | 383 | return $categories; 384 | } 385 | 386 | /** 387 | * Finds the post_content content area within blocks. 388 | * 389 | * @param WP_Post $post The post. 390 | */ 391 | public function extract_post_content_from_blocks( $post ) { 392 | if ( 'publish' !== $post->post_status ) { 393 | return; 394 | } 395 | 396 | $blocks = parse_blocks( wp_unslash( $post->post_content ) ); 397 | 398 | wp_update_post( 399 | array( 400 | 'ID' => $post->ID, 401 | 'post_content' => self::get_post_content( $blocks ) ?? '', 402 | ) 403 | ); 404 | } 405 | 406 | /** 407 | * Intercepts the saving request and removes the meta keys with default values. 408 | * 409 | * @param WP_HTTP_Response|null $response The response. 410 | * @param WP_REST_Server $server Route handler used for the request. 411 | * @param WP_REST_Request $request The request. 412 | * 413 | * @return WP_REST_Response The response. 414 | */ 415 | public function remove_default_meta_keys_on_save( $response, $server, $request ) { 416 | $is_upserting = in_array( $request->get_method(), array( 'POST', 'PUT' ), true ); 417 | $is_touching_post_type = str_starts_with( $request->get_route(), '/wp/v2/' . $this->slug ); 418 | 419 | if ( $is_upserting && $is_touching_post_type ) { 420 | $meta = $request->get_param( 'meta' ) ?? array(); 421 | 422 | foreach ( $meta as $key => $value ) { 423 | if ( '' === $value ) { 424 | unset( $meta[ $key ] ); 425 | delete_post_meta( $request->get_param( 'id' ), $key ); 426 | } 427 | } 428 | 429 | $request->set_param( 'meta', $meta ); 430 | } 431 | 432 | return $response; 433 | } 434 | 435 | /** 436 | * Intercepts the response and fills the empty meta keys with default values. 437 | * 438 | * @param WP_HTTP_Response $result The response. 439 | * @param WP_REST_Server $server The server. 440 | * @param WP_REST_Request $request The request. 441 | * 442 | * @return WP_REST_Response The response. 443 | */ 444 | public function fill_empty_meta_keys_with_default_values( $result, $server, $request ) { 445 | $is_allowed_method = in_array( $request->get_method(), array( 'GET', 'POST', 'PUT' ), true ); 446 | $is_touching_post_type = str_starts_with( $request->get_route(), '/wp/v2/' . $this->slug ); 447 | 448 | if ( $is_allowed_method && $is_touching_post_type ) { 449 | $data = $result->get_data(); 450 | 451 | $data['meta'] ??= array(); 452 | 453 | foreach ( $data['meta'] as $key => $value ) { 454 | $bound_meta_key = $this->bound_meta_keys[ $key ] ?? null; 455 | 456 | if ( empty( $value ) && $bound_meta_key ) { 457 | // TODO: Switch to empty string when Gutenberg 19.2 gets released. 458 | $data['meta'][ $key ] = self::FALLBACK_VALUE_PLACEHOLDER; 459 | } 460 | } 461 | 462 | $result->set_data( $data ); 463 | } 464 | 465 | return $result; 466 | } 467 | /** 468 | * Extracts the post content from the blocks. 469 | * 470 | * @param array $blocks The blocks. 471 | * 472 | * @return string The post content. 473 | */ 474 | private static function get_post_content( $blocks ) { 475 | $post_content = content_model_block_walker( 476 | $blocks, 477 | function ( $block ) { 478 | if ( 'core/group' !== $block['blockName'] ) { 479 | return $block; 480 | } 481 | 482 | $content_model_block = new Content_Model_Block( $block ); 483 | $content_binding = $content_model_block->get_binding( 'content' ); 484 | 485 | if ( $content_binding && 'post_content' === $content_binding['args']['key'] ) { 486 | return serialize_blocks( $block['innerBlocks'] ); 487 | } 488 | 489 | return $block; 490 | }, 491 | false // Breadth-first because it's more likely that post content will be at the top level. 492 | ); 493 | 494 | if ( ! is_string( $post_content ) ) { 495 | return null; 496 | } 497 | 498 | return $post_content; 499 | } 500 | 501 | /** 502 | * In the editor, display the template and fill bound Groups with data. 503 | * Blocks using the supported Bindings API attributes will be filled automatically. 504 | * 505 | * @param WP_Post $post The current post. 506 | */ 507 | public function hydrate_bound_groups( $post ) { 508 | if ( $this->slug !== $post->post_type ) { 509 | return; 510 | } 511 | 512 | $editor_blocks = $this->template; 513 | $editor_blocks = ( new Content_Model_Data_Hydrator( $editor_blocks, false ) )->hydrate(); 514 | $editor_blocks = content_model_block_walker( $editor_blocks, array( $this, 'add_fallback_value_placeholder' ) ); 515 | 516 | $post->post_content = serialize_blocks( $editor_blocks ); 517 | } 518 | 519 | /** 520 | * If a block has bindings modify the placeholder text. 521 | * 522 | * @param array $block The original block. 523 | * 524 | * @return array The modified block. 525 | */ 526 | public function add_fallback_value_placeholder( $block ) { 527 | $tentative_block = new Content_Model_Block( $block ); 528 | 529 | if ( ! empty( $tentative_block->get_bindings() ) ) { 530 | // translators: %s is the block variation name. 531 | $block['attrs']['placeholder'] = sprintf( __( 'Enter a value for %s' ), $tentative_block->get_block_variation_name() ); 532 | 533 | return $block; 534 | } 535 | 536 | return $block; 537 | } 538 | 539 | /** 540 | * In the front-end, swap the post_content with the hydrated template. 541 | * 542 | * @param string $post_content The current post content. 543 | */ 544 | public function swap_post_content_with_hydrated_template( $post_content ) { 545 | global $post; 546 | 547 | if ( $this->slug !== $post->post_type ) { 548 | return $post_content; 549 | } 550 | 551 | return implode( '', array_map( fn( $block ) => render_block( $block ), $this->template ) ); 552 | } 553 | 554 | /** 555 | * Enqueue the data entry helper scripts. 556 | * 557 | * @return void 558 | */ 559 | public function maybe_enqueue_data_entry_scripts() { 560 | global $post; 561 | 562 | if ( ! $post || $this->slug !== $post->post_type ) { 563 | return; 564 | } 565 | 566 | $asset_file = include CONTENT_MODEL_PLUGIN_PATH . 'includes/runtime/dist/data-entry.asset.php'; 567 | 568 | wp_enqueue_script( 569 | 'content-model/data-entry', 570 | CONTENT_MODEL_PLUGIN_URL . '/includes/runtime/dist/data-entry.js', 571 | $asset_file['dependencies'], 572 | $asset_file['version'], 573 | true 574 | ); 575 | 576 | wp_localize_script( 577 | 'content-model/data-entry', 578 | 'contentModelData', 579 | array( 580 | 'POST_TYPE' => $this->slug, 581 | 'FIELDS' => $this->fields, 582 | 'FALLBACK_VALUE_PLACEHOLDER' => self::FALLBACK_VALUE_PLACEHOLDER, 583 | ) 584 | ); 585 | } 586 | 587 | /** 588 | * Enqueue the templating helper scripts. 589 | * 590 | * @return void 591 | */ 592 | public function maybe_enqueue_templating_scripts() { 593 | $current_screen = get_current_screen(); 594 | 595 | if ( 'site-editor' !== $current_screen->id ) { 596 | return; 597 | } 598 | 599 | $asset_file = include CONTENT_MODEL_PLUGIN_PATH . 'includes/runtime/dist/templating.asset.php'; 600 | 601 | wp_enqueue_script( 602 | 'content-model/templating', 603 | CONTENT_MODEL_PLUGIN_URL . '/includes/runtime/dist/templating.js', 604 | $asset_file['dependencies'], 605 | $asset_file['version'], 606 | true 607 | ); 608 | } 609 | } 610 | -------------------------------------------------------------------------------- /includes/runtime/data-entry.js: -------------------------------------------------------------------------------- 1 | import { registerBoundGroupExtractor } from './src/register-bound-group-extractor'; 2 | import { registerContentLocking } from './src/register-content-locking'; 3 | import { registerFallbackValueClearer } from './src/register-fallback-value-clearer'; 4 | import { registerFieldsUI } from './src/register-fields-ui'; 5 | 6 | registerBoundGroupExtractor(); 7 | registerFieldsUI(); 8 | registerContentLocking(); 9 | registerFallbackValueClearer(); 10 | -------------------------------------------------------------------------------- /includes/runtime/helpers.php: -------------------------------------------------------------------------------- 1 | { 8 | return ( 9 | a.metadata && 10 | b.metadata && 11 | a.metadata.name === b.metadata.name && 12 | a.metadata.content_model_slug === b.metadata.content_model_slug 13 | ); 14 | }; 15 | 16 | export const BlockVariationUpdater = ( { 17 | name, 18 | attributes, 19 | setAttributes, 20 | } ) => { 21 | const variation = useMemo( () => { 22 | const variations = getBlockVariations( name ); 23 | 24 | return variations.find( ( tentativeVariation ) => 25 | matchesMetadata( tentativeVariation.attributes, attributes ) 26 | ); 27 | }, [ name, attributes ] ); 28 | 29 | return ( 30 | 31 | 32 | 39 | 40 | { __( 'Sync variation' ) } 41 | 42 | 53 | 54 | 55 | 56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /includes/runtime/src/components/fields-ui.js: -------------------------------------------------------------------------------- 1 | import { PluginDocumentSettingPanel } from '@wordpress/editor'; 2 | import { 3 | Button, 4 | Modal, 5 | TextControl, 6 | TextareaControl, 7 | __experimentalVStack as VStack, 8 | Card, 9 | CardBody, 10 | CardFooter, 11 | } from '@wordpress/components'; 12 | import { MediaPlaceholder } from '@wordpress/block-editor'; 13 | import { __ } from '@wordpress/i18n'; 14 | import { useEntityProp } from '@wordpress/core-data'; 15 | import { useState } from '@wordpress/element'; 16 | import { FIELDS, POST_TYPE } from '../constants'; 17 | 18 | export const FieldsUI = function () { 19 | const [ isFieldsOpen, setFieldsOpen ] = useState( false ); 20 | 21 | if ( FIELDS.filter( ( field ) => field.visible ).length === 0 ) { 22 | return null; 23 | } 24 | 25 | return ( 26 | 31 | 32 | 33 | 34 | 40 | 41 | { isFieldsOpen && ( 42 | setFieldsOpen( false ) } 46 | > 47 | 48 | 49 | ) } 50 | 51 | ); 52 | }; 53 | 54 | const FieldsList = ( { fields } ) => { 55 | return ( 56 | 57 | { fields 58 | .filter( ( field ) => field.visible ) 59 | .map( ( field ) => ( 60 | 61 | ) ) } 62 | 63 | ); 64 | }; 65 | 66 | const FieldRow = ( { field } ) => { 67 | const [ meta, setMeta ] = useEntityProp( 'postType', POST_TYPE, 'meta' ); 68 | 69 | const value = meta[ field.slug ] ?? ''; 70 | 71 | return ( 72 |
73 | { 77 | setMeta( { 78 | [ slug ]: newValue, 79 | } ); 80 | } } 81 | /> 82 | 83 | { field.description } 84 | 85 |
86 | ); 87 | }; 88 | 89 | const FieldInput = ( { field, isDisabled = false, value, saveChanges } ) => { 90 | if ( 'image' === field.type ) { 91 | return ( 92 | <> 93 | 101 | { field.label } 102 | 103 | { ! value && ( 104 | 109 | saveChanges( field.slug, newValue.url ) 110 | } 111 | /> 112 | ) } 113 | { value && ( 114 | 115 | 116 | { 121 | 122 | 123 | 129 | 130 | 131 | ) } 132 | 133 | ); 134 | } 135 | 136 | if ( 'textarea' === field.type ) { 137 | return ( 138 | saveChanges( field.slug, newValue ) } 143 | /> 144 | ); 145 | } 146 | 147 | return ( 148 | saveChanges( field.slug, newValue ) } 154 | /> 155 | ); 156 | }; 157 | -------------------------------------------------------------------------------- /includes/runtime/src/constants.js: -------------------------------------------------------------------------------- 1 | // https://github.com/WordPress/WordPress/blob/master/wp-includes/class-wp-block.php#L246-L251 2 | export const SUPPORTED_BLOCK_ATTRIBUTES = { 3 | 'core/group': [ 'content' ], 4 | 'core/paragraph': [ 'content' ], 5 | 'core/heading': [ 'content' ], 6 | 'core/image': [ 'id', 'url', 'title', 'alt' ], 7 | 'core/button': [ 'url', 'text', 'linkTarget', 'rel' ], 8 | }; 9 | 10 | export const { POST_TYPE, FIELDS, FALLBACK_VALUE_PLACEHOLDER } = 11 | window.contentModelData; 12 | -------------------------------------------------------------------------------- /includes/runtime/src/hooks/use-bound-group-extractor.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from '@wordpress/element'; 2 | import { useEntityProp } from '@wordpress/core-data'; 3 | import { useSelect } from '@wordpress/data'; 4 | import { store as editorStore } from '@wordpress/block-editor'; 5 | import { serialize } from '@wordpress/blocks'; 6 | import { POST_TYPE } from '../constants'; 7 | 8 | export const useBoundGroupExtractor = () => { 9 | const blocks = useSelect( ( select ) => select( editorStore ).getBlocks() ); 10 | 11 | const [ , setMeta ] = useEntityProp( 'postType', POST_TYPE, 'meta' ); 12 | 13 | useEffect( () => { 14 | const boundBlocks = findBoundGroupBlocks( blocks ); 15 | 16 | if ( Object.keys( boundBlocks ).length === 0 ) { 17 | return; 18 | } 19 | 20 | setMeta( boundBlocks ); 21 | }, [ blocks, setMeta ] ); 22 | 23 | return null; 24 | }; 25 | 26 | const findBoundGroupBlocks = ( blocks, acc = {} ) => { 27 | for ( const block of blocks ) { 28 | if ( 'core/group' !== block.name ) { 29 | continue; 30 | } 31 | 32 | const binding = block.attributes.metadata?.bindings?.content?.args?.key; 33 | 34 | if ( binding && binding !== 'post_content' ) { 35 | acc[ binding ] = serialize( block.innerBlocks ); 36 | } 37 | 38 | if ( block.innerBlocks.length > 0 ) { 39 | acc = findBoundGroupBlocks( block.innerBlocks, acc ); 40 | } 41 | } 42 | 43 | return acc; 44 | }; 45 | -------------------------------------------------------------------------------- /includes/runtime/src/hooks/use-content-locking.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from '@wordpress/element'; 2 | import { dispatch, useDispatch } from '@wordpress/data'; 3 | import { store as blockEditorStore } from '@wordpress/block-editor'; 4 | import { SUPPORTED_BLOCK_ATTRIBUTES } from '../constants'; 5 | 6 | const SUPPORTED_BLOCKS = Object.keys( SUPPORTED_BLOCK_ATTRIBUTES ); 7 | 8 | export const useContentLocking = function () { 9 | const blocks = wp.data.select( 'core/block-editor' ).getBlocks(); 10 | 11 | const currentBlock = wp.data 12 | .select( 'core/block-editor' ) 13 | .getSelectedBlock(); 14 | 15 | const { setBlockEditingMode } = useDispatch( blockEditorStore ); 16 | 17 | useEffect( () => { 18 | if ( blocks.length > 0 ) { 19 | parseBlocks( blocks, setBlockEditingMode ); 20 | } 21 | }, [ blocks, setBlockEditingMode, currentBlock ] ); 22 | }; 23 | 24 | const parseBlocks = ( blocks, setEditMode, forceEnabled = false ) => { 25 | blocks.forEach( ( block ) => { 26 | if ( 27 | block.innerBlocks.length > 0 && 28 | block.attributes.metadata?.bindings 29 | ) { 30 | // This is a group block with bindings, probably content. 31 | setEditMode( block.clientId, 'default' ); 32 | 33 | if ( block.innerBlocks ) { 34 | parseBlocks( block.innerBlocks, setEditMode, true ); 35 | } 36 | } else if ( block.innerBlocks.length > 0 ) { 37 | // This is a group block with no bindings, probably layout. 38 | 39 | // First check this container has a bound group block is inside. 40 | const boundGroup = findBoundGroup( block.innerBlocks ); 41 | 42 | if ( ! boundGroup ) { 43 | // Then, lock the block. 44 | dispatch( 'core/block-editor' ).updateBlock( block.clientId, { 45 | ...block, 46 | attributes: { 47 | ...block.attributes, 48 | templateLock: 'contentOnly', 49 | }, 50 | } ); 51 | setEditMode( block.clientId, 'disabled' ); 52 | } 53 | if ( block.innerBlocks ) { 54 | parseBlocks( block.innerBlocks, setEditMode ); 55 | } 56 | } else if ( 57 | ( SUPPORTED_BLOCKS.includes( block.name ) && 58 | block.attributes.metadata?.bindings ) || 59 | forceEnabled 60 | ) { 61 | setEditMode( block.clientId, '' ); 62 | } else { 63 | setEditMode( block.clientId, 'disabled' ); 64 | } 65 | } ); 66 | }; 67 | 68 | const findBoundGroup = ( blocks ) => { 69 | for ( const block of blocks ) { 70 | if ( 71 | block.name === 'core/group' && 72 | block.attributes.metadata?.bindings 73 | ) { 74 | return block; 75 | } 76 | if ( block.innerBlocks.length > 0 ) { 77 | const boundGroup = findBoundGroup( block.innerBlocks ); 78 | if ( boundGroup ) { 79 | return boundGroup; 80 | } 81 | } 82 | } 83 | return null; 84 | }; 85 | -------------------------------------------------------------------------------- /includes/runtime/src/hooks/use-fallback-value-clearer.js: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect } from '@wordpress/element'; 2 | import { useEntityProp } from '@wordpress/core-data'; 3 | import { store as blockEditorStore } from '@wordpress/block-editor'; 4 | import { useSelect } from '@wordpress/data'; 5 | import { FALLBACK_VALUE_PLACEHOLDER, POST_TYPE } from '../constants'; 6 | 7 | /** 8 | * This allows the user to edit values that are bound to an attribute. 9 | * There is a bug in the Bindings API preventing this from working, 10 | * so here's our workaround. 11 | * 12 | * TODO Remove when Gutenberg 19.2 gets released. 13 | * 14 | * See https://github.com/Automattic/create-content-model/issues/63 for the problem. 15 | */ 16 | export const useFallbackValueClearer = () => { 17 | const [ meta, setMeta ] = useEntityProp( 'postType', POST_TYPE, 'meta' ); 18 | 19 | const blockToMetaMap = useSelect( ( select ) => { 20 | const blocks = select( blockEditorStore ).getBlocks(); 21 | const map = {}; 22 | 23 | const processBlock = ( block ) => { 24 | const bindings = block.attributes?.metadata?.bindings || {}; 25 | Object.entries( bindings ).forEach( ( [ , binding ] ) => { 26 | if ( binding.source === 'core/post-meta' ) { 27 | if ( ! map[ block.clientId ] ) { 28 | map[ block.clientId ] = []; 29 | } 30 | map[ block.clientId ].push( { 31 | metaKey: binding.args.key, 32 | blockName: block.attributes.metadata.name, 33 | } ); 34 | } 35 | } ); 36 | 37 | // Process inner blocks if they exist, like core/button is inside core/buttons. 38 | if ( block.innerBlocks && block.innerBlocks.length > 0 ) { 39 | block.innerBlocks.forEach( processBlock ); 40 | } 41 | }; 42 | 43 | blocks.forEach( processBlock ); 44 | 45 | return map; 46 | }, [] ); 47 | 48 | useLayoutEffect( () => { 49 | Object.entries( blockToMetaMap ).forEach( ( [ , metaInfos ] ) => { 50 | metaInfos.forEach( ( { metaKey } ) => { 51 | const value = meta[ metaKey ]; 52 | 53 | if ( value === FALLBACK_VALUE_PLACEHOLDER ) { 54 | setMeta( { [ metaKey ]: '' } ); 55 | } 56 | } ); 57 | } ); 58 | }, [ meta, setMeta, blockToMetaMap ] ); 59 | 60 | return null; 61 | }; 62 | -------------------------------------------------------------------------------- /includes/runtime/src/register-block-variation-updater.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | import { addFilter } from '@wordpress/hooks'; 3 | import { createHigherOrderComponent } from '@wordpress/compose'; 4 | import { BlockVariationUpdater } from './components/block-variation-updater'; 5 | 6 | const withBlockVariationUpdater = createHigherOrderComponent( ( BlockEdit ) => { 7 | return ( props ) => { 8 | const { name, content_model_slug } = props.attributes.metadata ?? {}; 9 | 10 | const shouldDisplayVariationUpdater = name && content_model_slug; 11 | 12 | return ( 13 | <> 14 | { shouldDisplayVariationUpdater && ( 15 | 16 | ) } 17 | 18 | 19 | ); 20 | }; 21 | }, 'withBlockVariationUpdater' ); 22 | 23 | export const registerBlockVariationUpdater = () => { 24 | addFilter( 25 | 'editor.BlockEdit', 26 | 'content-model/block-variation-updater', 27 | withBlockVariationUpdater 28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /includes/runtime/src/register-bound-group-extractor.js: -------------------------------------------------------------------------------- 1 | import { registerPlugin } from '@wordpress/plugins'; 2 | import { useBoundGroupExtractor } from './hooks/use-bound-group-extractor'; 3 | 4 | export const registerBoundGroupExtractor = () => { 5 | registerPlugin( 'create-content-model-bound-group-extractor', { 6 | render: () => { 7 | // eslint-disable-next-line react-hooks/rules-of-hooks 8 | useBoundGroupExtractor(); 9 | }, 10 | } ); 11 | }; 12 | -------------------------------------------------------------------------------- /includes/runtime/src/register-content-locking.js: -------------------------------------------------------------------------------- 1 | import { registerPlugin } from '@wordpress/plugins'; 2 | import { useContentLocking } from './hooks/use-content-locking'; 3 | 4 | export const registerContentLocking = () => { 5 | registerPlugin( 'create-content-model-content-locking', { 6 | render: () => { 7 | // eslint-disable-next-line react-hooks/rules-of-hooks 8 | useContentLocking(); 9 | }, 10 | } ); 11 | }; 12 | -------------------------------------------------------------------------------- /includes/runtime/src/register-fallback-value-clearer.js: -------------------------------------------------------------------------------- 1 | import { registerPlugin } from '@wordpress/plugins'; 2 | import { useFallbackValueClearer } from './hooks/use-fallback-value-clearer'; 3 | 4 | export const registerFallbackValueClearer = () => { 5 | registerPlugin( 'create-content-model-fallback-value-clearer', { 6 | render: () => { 7 | // eslint-disable-next-line react-hooks/rules-of-hooks 8 | useFallbackValueClearer(); 9 | }, 10 | } ); 11 | }; 12 | -------------------------------------------------------------------------------- /includes/runtime/src/register-fields-ui.js: -------------------------------------------------------------------------------- 1 | import { registerPlugin } from '@wordpress/plugins'; 2 | import { FieldsUI } from './components/fields-ui'; 3 | 4 | export const registerFieldsUI = () => { 5 | // Register the plugin. 6 | registerPlugin( 'create-content-model-fields-ui', { 7 | render: FieldsUI, 8 | } ); 9 | }; 10 | -------------------------------------------------------------------------------- /includes/runtime/templating.js: -------------------------------------------------------------------------------- 1 | import { registerBlockVariationUpdater } from './src/register-block-variation-updater'; 2 | 3 | registerBlockVariationUpdater(); 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create-content-model", 3 | "version": "0.0.0-placeholder", 4 | "scripts": { 5 | "prepare": "husky", 6 | "dev-server": "npx @wp-now/wp-now start --wp=nightly --skip-browser --blueprint=dev.json", 7 | "format": "wp-scripts format", 8 | "lint:css": "wp-scripts lint-style", 9 | "lint:js": "wp-scripts lint-js", 10 | "start": "wp-scripts start", 11 | "build": "wp-scripts build", 12 | "preplugin-zip": "npm run build", 13 | "plugin-zip": "wp-scripts plugin-zip" 14 | }, 15 | "files": [ 16 | "create-content-model.php", 17 | "includes/**/*.php", 18 | "includes/**/dist" 19 | ], 20 | "keywords": [], 21 | "author": "", 22 | "license": "ISC", 23 | "description": "", 24 | "dependencies": { 25 | "@wordpress/block-editor": "^14.0.0", 26 | "@wordpress/blocks": "^13.5.0", 27 | "@wordpress/components": "^28.5.0", 28 | "@wordpress/compose": "^7.5.0", 29 | "@wordpress/data": "^10.5.0", 30 | "@wordpress/hooks": "^4.5.0", 31 | "@wordpress/i18n": "^5.5.0", 32 | "@wordpress/icons": "^10.5.0", 33 | "@wordpress/server-side-render": "^5.5.0" 34 | }, 35 | "devDependencies": { 36 | "@wordpress/eslint-plugin": "^20.0.0", 37 | "@wordpress/prettier-config": "^4.3.0", 38 | "@wordpress/scripts": "^27.9.0", 39 | "glob": "^11.0.0", 40 | "husky": "^9.1.4", 41 | "prettier": "npm:wp-prettier@^3.0.3" 42 | }, 43 | "husky": { 44 | "hooks": { 45 | "pre-commit": "lint-staged" 46 | } 47 | }, 48 | "lint-staged": { 49 | "*.{js,json,ts,tsx,yml,yaml}": [ 50 | "npm run format" 51 | ], 52 | "*.{js,ts,tsx}": [ 53 | "npm run lint:js" 54 | ], 55 | "*.scss": [ 56 | "npm run lint:css" 57 | ] 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /phpcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | The phpcs standard for wpcom code. 4 | 5 | 6 | 7 | 8 | 9 | error 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | error 27 | 28 | 29 | 30 | 0 31 | 32 | 33 | 0 34 | 35 | 36 | 0 37 | 38 | 39 | 0 40 | 41 | 42 | 0 43 | 44 | 45 | 0 46 | 47 | 48 | 0 49 | 50 | 51 | warning 52 | 53 | 54 | **/*.asset.php 55 | 56 | 57 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require( 'webpack' ); 2 | const glob = require( 'glob' ); 3 | const defaultConfig = require( '@wordpress/scripts/config/webpack.config' ); 4 | const path = require( 'path' ); 5 | const { CleanWebpackPlugin } = require( 'clean-webpack-plugin' ); 6 | 7 | const includesDir = path.resolve( process.cwd(), 'includes' ); 8 | 9 | const entries = glob 10 | .sync( '*/*.js', { cwd: includesDir } ) 11 | .reduce( ( acc, entry ) => { 12 | const [ folder, name ] = entry.split( '/' ); 13 | 14 | acc[ 15 | `${ folder }/${ path.parse( name ).name }` 16 | ] = `./includes/${ entry }`; 17 | 18 | return acc; 19 | }, {} ); 20 | 21 | /** @type {webpack.Configuration} */ 22 | const config = { 23 | ...defaultConfig, 24 | entry: entries, 25 | output: { 26 | path: includesDir, 27 | filename: ( pathData ) => { 28 | const [ folder, name ] = pathData.chunk.name.split( '/' ); 29 | 30 | return `${ folder }/dist/${ name }.js`; 31 | }, 32 | clean: { 33 | keep: ( asset ) => { 34 | return ! asset.includes( 'dist' ); 35 | }, 36 | }, 37 | }, 38 | plugins: defaultConfig.plugins.filter( 39 | ( plugin ) => ! ( plugin instanceof CleanWebpackPlugin ) 40 | ), 41 | }; 42 | 43 | // Return Configuration 44 | module.exports = config; 45 | --------------------------------------------------------------------------------