├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .github ├── scripts │ └── build-plugin.js └── workflows │ ├── playwright.yml │ ├── release-plugin.yml │ ├── static-checks.yml │ └── unit-test.yml ├── .gitignore ├── .nvmrc ├── .wp-env.json ├── Readme.md ├── composer.json ├── e2e ├── html │ ├── csn-page-1.html │ ├── csn-page-2.html │ ├── csn-page-3.html │ ├── directive-bind.html │ ├── directive-priorities.html │ ├── directives-class.html │ ├── directives-context.html │ ├── directives-effect.html │ ├── directives-show.html │ ├── directives-text.html │ ├── negation-operator.html │ ├── store-tag-corrupted-json.html │ ├── store-tag-invalid-state.html │ ├── store-tag-missing.html │ ├── store-tag-ok.html │ ├── tovdom-full-next.html │ ├── tovdom-full.html │ ├── tovdom-islands.html │ └── tovdom.html ├── js │ ├── directive-bind.js │ ├── directive-effect.js │ ├── directive-priorities.js │ └── negation-operator.js ├── page-1 │ ├── index.js │ ├── store.js │ └── style.css ├── page-2 │ ├── index.js │ ├── store.js │ └── style.css ├── specs │ ├── csn.spec.ts │ ├── directive-bind.spec.ts │ ├── directive-effect.spec.ts │ ├── directive-priorities.spec.ts │ ├── directives-class.spec.ts │ ├── directives-context.spec.ts │ ├── directives-show.spec.ts │ ├── directives-text.spec.ts │ ├── negation-operator.spec.ts │ ├── store-tag.spec.ts │ ├── tovdom-full.spec.ts │ ├── tovdom-islands.spec.ts │ └── tovdom.spec.ts └── tests.ts ├── jest.babel.config.js ├── jest.config.js ├── package-lock.json ├── package.json ├── phpcs.xml.dist ├── phpunit.xml.dist ├── phpunit ├── bootstrap.php └── directives │ ├── attributes │ ├── wp-bind.php │ ├── wp-class.php │ ├── wp-context.php │ ├── wp-html.php │ ├── wp-style.php │ └── wp-text.php │ ├── utils │ └── evaluate.php │ ├── wp-directive-processor.php │ ├── wp-directive-store.php │ └── wp-process-directives.php ├── playwright.config.ts ├── src ├── admin │ └── admin-page.php ├── directives │ ├── attributes │ │ ├── wp-bind.php │ │ ├── wp-class.php │ │ ├── wp-context.php │ │ ├── wp-html.php │ │ ├── wp-style.php │ │ └── wp-text.php │ ├── class-wp-directive-context.php │ ├── class-wp-directive-processor.php │ ├── class-wp-directive-store.php │ ├── utils.php │ ├── wp-html.php │ └── wp-process-directives.php └── runtime │ ├── constants.js │ ├── directives.js │ ├── hooks.js │ ├── index.js │ ├── router.js │ ├── store.js │ ├── utils.js │ └── vdom.js ├── webpack.config.js └── wp-directives.php /.editorconfig: -------------------------------------------------------------------------------- 1 | # This file is for unifying the coding style for different editors and IDEs 2 | # editorconfig.org 3 | 4 | # WordPress Coding Standards 5 | # https://make.wordpress.org/core/handbook/coding-standards/ 6 | 7 | root = true 8 | 9 | [*] 10 | charset = utf-8 11 | end_of_line = lf 12 | insert_final_newline = true 13 | trim_trailing_whitespace = true 14 | indent_style = tab 15 | 16 | [*.{yml,yaml}] 17 | indent_style = space 18 | indent_size = 2 19 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | build 2 | node_modules 3 | vendor 4 | 5 | # In case the wp-env directories were symlinked into the project for IDE indexing. 6 | 7 | gutenberg 8 | wp 9 | wp-tests 10 | e2e 11 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ['plugin:@wordpress/eslint-plugin/recommended'], 4 | env: { 5 | browser: true, 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /.github/scripts/build-plugin.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const archiver = require('archiver'); 3 | 4 | // Create a new zip file 5 | const output = fs.createWriteStream('block-interactivity-experiments.zip'); 6 | const archive = archiver('zip', { zlib: { level: 9 } }); 7 | 8 | // Add files and directories to the zip file 9 | archive.directory('src/', 'src'); 10 | archive.directory('build/', 'build'); 11 | archive.file('Readme.md', { name: 'Readme.md' }); 12 | archive.file('wp-directives.php', { name: 'wp-directives.php' }); 13 | 14 | // Finalize the zip file 15 | archive.pipe(output); 16 | archive.finalize(); 17 | -------------------------------------------------------------------------------- /.github/workflows/playwright.yml: -------------------------------------------------------------------------------- 1 | name: Playwright Tests 2 | on: pull_request 3 | jobs: 4 | test: 5 | timeout-minutes: 60 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v3 9 | - uses: actions/setup-node@v3 10 | with: 11 | node-version-file: '.nvmrc' 12 | cache: npm 13 | - name: Install dependencies 14 | run: npm ci 15 | - name: Install Playwright Browsers 16 | run: npx playwright install --with-deps 17 | - name: Build 18 | run: npm run build 19 | - name: Run Playwright tests 20 | run: npx playwright test 21 | - uses: actions/upload-artifact@v3 22 | if: always() 23 | with: 24 | name: playwright-report 25 | path: playwright-report/ 26 | retention-days: 30 27 | -------------------------------------------------------------------------------- /.github/workflows/release-plugin.yml: -------------------------------------------------------------------------------- 1 | name: Build Plugin ZIP 2 | 3 | on: 4 | push: 5 | branches: 6 | - main-wp-directives-plugin 7 | 8 | jobs: 9 | bump-version: 10 | runs-on: ubuntu-latest 11 | outputs: 12 | old_version: ${{ steps.get_version.outputs.old_version }} 13 | new_version: ${{ steps.get_version.outputs.new_version }} 14 | 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v2 18 | 19 | - name: Compute old and new version 20 | id: get_version 21 | run: | 22 | OLD_VERSION=$(jq --raw-output '.version' package.json) 23 | echo "old_version=${OLD_VERSION}" >> $GITHUB_OUTPUT 24 | NEW_VERSION=$(npx semver $OLD_VERSION -i patch) 25 | echo "new_version=${NEW_VERSION}" >> $GITHUB_OUTPUT 26 | 27 | - name: Configure git user name and email 28 | run: | 29 | git config user.name github-actions[bot] 30 | git config user.email 41898282+github-actions[bot]@users.noreply.github.com 31 | 32 | - name: Update plugin version 33 | env: 34 | VERSION: ${{ steps.get_version.outputs.new_version }} 35 | run: | 36 | cat <<< $(jq --tab --arg version "${VERSION}" '.version = $version' package.json) > package.json 37 | cat <<< $(jq --tab --arg version "${VERSION}" '.version = $version' package-lock.json) > package-lock.json 38 | sed -i "s/${{ steps.get_version.outputs.old_version }}/${VERSION}/g" wp-directives.php 39 | 40 | - name: Commit the version bump 41 | id: commit_version_bump 42 | run: | 43 | git add wp-directives.php package.json package-lock.json 44 | git commit -m "Bump plugin version to ${{ steps.get_version.outputs.new_version }}" 45 | git push --set-upstream origin main-wp-directives-plugin 46 | echo "version_bump_commit=$(git rev-parse --verify --short HEAD)" >> $GITHUB_OUTPUT 47 | 48 | build: 49 | runs-on: ubuntu-latest 50 | needs: bump-version 51 | 52 | steps: 53 | - name: Checkout code 54 | uses: actions/checkout@v2 55 | 56 | - name: Install Composer dependencies 57 | run: composer install --no-dev 58 | 59 | - name: Install npm dependencies 60 | run: npm ci 61 | 62 | - name: Build plugin 63 | run: npm run build 64 | 65 | - name: Create plugin ZIP file 66 | run: npm run plugin-zip 67 | 68 | - name: Release 69 | uses: softprops/action-gh-release@v1 70 | with: 71 | tag_name: ${{ needs.bump-version.outputs.new_version }} 72 | files: | 73 | block-interactivity-experiments.zip 74 | -------------------------------------------------------------------------------- /.github/workflows/static-checks.yml: -------------------------------------------------------------------------------- 1 | name: Static Analysis 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - 'main*' 8 | 9 | # Cancels all previous workflow runs for pull requests that have not completed. 10 | concurrency: 11 | # The concurrency group contains the workflow name and the branch name for pull requests 12 | # or the commit hash for any other events. 13 | group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.head_ref || github.sha }} 14 | cancel-in-progress: true 15 | 16 | jobs: 17 | check: 18 | name: All 19 | runs-on: ubuntu-latest 20 | if: ${{ github.repository == 'WordPress/block-interactivity-experiments' || github.event_name == 'pull_request' }} 21 | 22 | steps: 23 | - uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0 24 | 25 | - name: Use desired version of NodeJS 26 | uses: actions/setup-node@8c91899e586c5b171469028077307d293428b516 # v3.5.1 27 | with: 28 | node-version-file: '.nvmrc' 29 | cache: npm 30 | 31 | - name: Install dependencies 32 | run: npm ci 33 | 34 | - name: Setup PHP 35 | uses: shivammathur/setup-php@v2 36 | with: 37 | php-version: '8.0' 38 | coverage: none 39 | 40 | - name: Get Composer Cache Directory 41 | id: composer-cache 42 | run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 43 | 44 | - name: Configure Composer cache 45 | uses: actions/cache@v3 46 | with: 47 | path: ${{ steps.composer-cache.outputs.dir }} 48 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} 49 | restore-keys: ${{ runner.os }}-composer- 50 | 51 | - name: Install Composer dependencies 52 | run: composer install --prefer-dist --optimize-autoloader --no-progress --no-interaction 53 | 54 | - name: Check PHP coding standards (PHPCS) 55 | run: composer run-script lint 56 | 57 | - name: Check JS coding standards (eslint) 58 | run: npm run lint:js 59 | -------------------------------------------------------------------------------- /.github/workflows/unit-test.yml: -------------------------------------------------------------------------------- 1 | name: Unit Tests 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - 'main*' 8 | 9 | # Cancels all previous workflow runs for pull requests that have not completed. 10 | concurrency: 11 | # The concurrency group contains the workflow name and the branch name for pull requests 12 | # or the commit hash for any other events. 13 | group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.head_ref || github.sha }} 14 | cancel-in-progress: true 15 | 16 | jobs: 17 | unit-php: 18 | name: PHP 19 | runs-on: ubuntu-latest 20 | if: ${{ github.repository == 'WordPress/block-interactivity-experiments' || github.event_name == 'pull_request' }} 21 | 22 | steps: 23 | - uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # v3.1.0 24 | 25 | - name: Install Composer dependencies 26 | run: | 27 | composer install 28 | 29 | - name: Use desired version of NodeJS 30 | uses: actions/setup-node@8c91899e586c5b171469028077307d293428b516 # v3.5.1 31 | with: 32 | node-version-file: '.nvmrc' 33 | cache: npm 34 | 35 | - name: Npm install and build 36 | run: | 37 | npm ci 38 | npm run build 39 | 40 | - name: Run WordPress 41 | run: | 42 | npm run wp-env start 43 | 44 | - name: Running single site unit tests 45 | run: npm run test:unit:php 46 | if: ${{ success() || failure() }} 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | build 4 | vendor 5 | composer.lock 6 | /test-results/ 7 | /playwright-report/ 8 | /playwright/.cache/ 9 | .phpunit.result.cache 10 | 11 | # In case the wp-env directories were symlinked into the project for IDE indexing. 12 | gutenberg 13 | wp 14 | wp-tests 15 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 14 2 | -------------------------------------------------------------------------------- /.wp-env.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "https://downloads.wordpress.org/plugin/gutenberg.latest-stable.zip", 4 | "." 5 | ], 6 | "config": { 7 | "SCRIPT_DEBUG": true 8 | }, 9 | "env": { 10 | "tests": { 11 | "mappings": { 12 | "wp-content/plugins/block-interactivity-experiments": "." 13 | } 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | > **WARNING:** This repository is no longer active. The development and discussions have been moved to the Gutenberg repository. 2 | 3 | We have transitioned ongoing work on the Interactivity API to the [Gutenberg repository](https://github.com/WordPress/gutenberg). 4 | 5 | - Conversations and collaboration are now taking place within the [Interactivity API category](https://github.com/WordPress/gutenberg/discussions/categories/interactivity-api). 6 | 7 | - Development of the Interactivity API is now happening directly in the Gutenberg repository using the label [\[Feature\] Interactivity API](https://github.com/WordPress/gutenberg/issues?q=label%3A%22%5BFeature%5D+Interactivity+API%22). 8 | 9 | We welcome you to join us in Gutenberg! Your feedback, ideas, and contributions regarding the Interactivity API are greatly appreciated. By centralizing the development process in one location, we aim to streamline collaboration and keep everyone up to date. 10 | 11 | Please go to the Gutenberg repository to find the latest developments, discuss use cases, ask questions, and be part of shaping this exciting API. We look forward to continuing the conversation on this project in its new home. 12 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wordpress/block-interactivity-experiments", 3 | "type": "wordpress-plugin", 4 | "license": "GPL-2.0-or-later", 5 | "description": "Experiments to add frontend interactivity to blocks", 6 | "homepage": "https://wordpress.github.io/gutenberg/", 7 | "keywords": [ 8 | "block-interactivity-experiments", 9 | "wordpress", 10 | "wp", 11 | "react", 12 | "javascript" 13 | ], 14 | "support": { 15 | "issues": "https://github.com/WordPress/block-interactivity-experiments/issues" 16 | }, 17 | "config": { 18 | "process-timeout": 0, 19 | "platform": { 20 | "php": "7.4" 21 | }, 22 | "allow-plugins": { 23 | "dealerdirect/phpcodesniffer-composer-installer": true, 24 | "composer/installers": true 25 | } 26 | }, 27 | "require-dev": { 28 | "dealerdirect/phpcodesniffer-composer-installer": "^0.7", 29 | "squizlabs/php_codesniffer": "^3.5", 30 | "phpcompatibility/phpcompatibility-wp": "^2.1.3", 31 | "wp-coding-standards/wpcs": "^2.2", 32 | "sirbrillig/phpcs-variable-analysis": "^2.8", 33 | "phpunit/phpunit": "^9.5", 34 | "yoast/phpunit-polyfills": "^1.0" 35 | }, 36 | "require": { 37 | "composer/installers": "~1.0" 38 | }, 39 | "scripts": { 40 | "format": "phpcbf --standard=phpcs.xml.dist --report-summary --report-source", 41 | "lint": "phpcs --standard=phpcs.xml.dist" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /e2e/html/csn-page-1.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | CSN - Page 1 5 | 6 | 11 | 14 | 15 | 16 | 17 |

Client-side navigation Page 1

18 |

Subheading

19 | Next Page 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /e2e/html/csn-page-2.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | CSN - Page 2 5 | 6 | 11 | 12 | 13 | 14 |

Client-side navigation Page 2

15 |

Subheading

16 | 22 | 23 |
28 |

Show when true

29 |
30 | 31 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /e2e/html/csn-page-3.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | CSN - Page 3 5 | 6 | 7 | 8 | 9 |

Client-side navigation Page 3

10 |

Subheading

11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /e2e/html/directive-bind.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Directives -- data-wp-bind 5 | 6 | 7 | 8 | 12 | 13 | 18 | 19 | 24 | 25 | 31 | 32 | 37 | 42 | 43 | 44 | 47 | 48 |

55 | Some Text 56 |

57 | 58 | 59 | 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /e2e/html/directive-priorities.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Directive priorities 5 | 6 | 7 | 8 |

 9 | 
10 | 		
11 | 		
18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /e2e/html/directives-class.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Directives -- data-wp-class 5 | 6 | 7 | 8 | 14 | 15 | 21 | 22 |
27 | 28 |
33 | 34 |
41 | 42 |
48 | 49 |
54 | 55 |
59 | 60 |
61 |
66 | 72 |
73 | 74 | 75 | 76 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /e2e/html/directives-context.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Directives -- data-wp-context 5 | 6 | 7 | 8 | 9 |
12 |
 16 |                 
 17 |             
18 | 26 | 34 | 42 | 50 |
53 |
 57 |                     
 58 |                 
59 | 67 | 75 | 83 | 91 | 99 | 107 |
108 |
109 | 110 | 119 |
120 | 121 | 122 | 123 | 124 | 125 | 126 | -------------------------------------------------------------------------------- /e2e/html/directives-effect.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Directives -- data-wp-effect 5 | 6 | 7 | 8 | 9 |
10 | 14 |
15 | 16 |
20 | 21 |
22 | 23 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /e2e/html/directives-show.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Directives -- data-wp-show 5 | 6 | 7 | 8 | 14 | 15 | 21 | 22 |
27 |

trueValue children

28 |
29 | 30 |
34 |

falseValue children

35 |
36 | 37 |
38 |
42 | falseValue 43 |
44 | 50 |
51 | 52 | 53 | 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /e2e/html/directives-text.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Directives -- data-wp-text 5 | 6 | 7 | 8 |
9 | 13 | 19 |
20 | 21 |
22 | 26 | 32 |
33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /e2e/html/negation-operator.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Negation Operator 5 | 6 | 7 | 8 | 14 | 15 |
19 | 20 |
24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /e2e/html/store-tag-corrupted-json.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Store -- hydration 5 | 6 | 7 | 8 | 9 |
10 | Counter: 11 | 3 16 |
17 | Double: 18 | 6 23 |
24 | 30 | 0 35 | clicks 36 |
37 | 40 | 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /e2e/html/store-tag-invalid-state.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Store -- hydration 5 | 6 | 7 | 8 | 9 |
10 | Counter: 11 | 3 16 |
17 | Double: 18 | 6 23 |
24 | 30 | 0 35 | clicks 36 |
37 | 40 | 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /e2e/html/store-tag-missing.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Store -- hydration 5 | 6 | 7 | 8 | 9 |
10 | Counter: 11 | 3 16 |
17 | Double: 18 | 6 23 |
24 | 30 | 0 35 | clicks 36 |
37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /e2e/html/store-tag-ok.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Store -- hydration 5 | 6 | 7 | 8 | 9 |
10 | Counter: 11 | 3 16 |
17 | Double: 18 | 6 23 |
24 | 30 | 0 35 | clicks 36 |
37 | 40 | 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /e2e/html/tovdom-full-next.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | toVdom - full (next) 5 | 6 | 7 | 8 |
9 | 10 | New content. 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /e2e/html/tovdom-full.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | toVdom - full 5 | 6 | 7 | 8 |
9 |
10 | 11 | This should not be shown because we are in full mode. 12 | 13 |
14 |
15 | next 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /e2e/html/tovdom-islands.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | toVdom - islands 5 | 6 | 7 |
8 |
9 | 10 | This should be shown because it is inside an island. 11 | 12 |
13 | 14 |
15 |
16 | 17 | This should not be shown because it is inside an island. 18 | 19 |
20 |
21 | 22 |
23 |
24 |
25 | 28 | This should be shown because it is inside an inner 29 | block of an isolated island. 30 | 31 |
32 |
33 |
34 | 35 |
36 |
37 |
41 | 42 | This should not have two template wrappers because 43 | that means we hydrated twice. 44 | 45 |
46 |
47 |
48 | 49 |
50 |
51 |
52 |
53 | 56 | This should not be shown because even though it 57 | is inside an inner block of an isolated island, 58 | it's inside an new island. 59 | 60 |
61 |
62 |
63 |
64 |
65 | 66 | 67 | 68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /e2e/html/tovdom.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | toVdom 5 | 6 | 7 | 8 |
9 | 10 |
11 | Comments inner node 12 | 13 |
14 |
15 | 16 |
17 |
18 |
19 | 20 | 38 | 39 |
40 |
41 |
42 | 43 | 60 | 61 | 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /e2e/js/directive-bind.js: -------------------------------------------------------------------------------- 1 | import { store } from '../../src/runtime/store'; 2 | 3 | store({ 4 | state: { 5 | url: '/some-url', 6 | checked: true, 7 | show: false, 8 | width: 1, 9 | }, 10 | foo: { 11 | bar: 1, 12 | }, 13 | actions: { 14 | toggle: ({ state, foo }) => { 15 | state.url = '/some-other-url'; 16 | state.checked = !state.checked; 17 | state.show = !state.show; 18 | state.width += foo.bar; 19 | }, 20 | }, 21 | }); 22 | -------------------------------------------------------------------------------- /e2e/js/directive-effect.js: -------------------------------------------------------------------------------- 1 | import { store } from '../../src/runtime/store'; 2 | import { directive } from '../../src/runtime/hooks'; 3 | import { useContext, useMemo } from 'preact/hooks'; 4 | 5 | // Fake `data-wp-fakeshow` directive to test when things are removed from the DOM. 6 | // Replace with `data-wp-show` when it's ready. 7 | directive( 8 | 'fakeshow', 9 | ({ 10 | directives: { 11 | fakeshow: { default: fakeshow }, 12 | }, 13 | element, 14 | evaluate, 15 | context, 16 | }) => { 17 | const contextValue = useContext(context); 18 | const children = useMemo( 19 | () => 20 | element.type === 'template' 21 | ? element.props.templateChildren 22 | : element, 23 | [] 24 | ); 25 | if (!evaluate(fakeshow, { context: contextValue })) return null; 26 | return children; 27 | } 28 | ); 29 | 30 | store({ 31 | state: { 32 | isOpen: true, 33 | isElementInTheDOM: false, 34 | }, 35 | selectors: { 36 | elementInTheDOM: ({ state }) => 37 | state.isElementInTheDOM 38 | ? 'element is in the DOM' 39 | : 'element is not in the DOM', 40 | }, 41 | actions: { 42 | toggle({ state }) { 43 | state.isOpen = !state.isOpen; 44 | }, 45 | }, 46 | effects: { 47 | elementAddedToTheDOM: ({ state }) => { 48 | state.isElementInTheDOM = true; 49 | 50 | return () => { 51 | state.isElementInTheDOM = false; 52 | }; 53 | }, 54 | changeFocus: ({ state }) => { 55 | if (state.isOpen) { 56 | document.querySelector("[data-testid='input']").focus(); 57 | } 58 | }, 59 | }, 60 | }); 61 | -------------------------------------------------------------------------------- /e2e/js/directive-priorities.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useContext } from 'preact/hooks'; 2 | import { deepSignal } from 'deepsignal'; 3 | 4 | import { store } from '../../src/runtime/store'; 5 | import { directive } from '../../src/runtime/hooks'; 6 | 7 | /** 8 | * Util to check that render calls happen in order. 9 | */ 10 | const executionProof = (n) => { 11 | const el = document.querySelector('[data-testid="execution order"]'); 12 | if (!el.textContent) el.textContent = n; 13 | else el.textContent += `, ${n}`; 14 | }; 15 | 16 | /** 17 | * Simple context directive, just for testing purposes. It provides a deep 18 | * signal with these two properties: 19 | * - attribute: 'from context' 20 | * - text: 'from context' 21 | */ 22 | directive( 23 | 'test-context', 24 | ({ context: { Provider }, props: { children } }) => { 25 | executionProof('context'); 26 | const value = deepSignal({ 27 | attribute: 'from context', 28 | text: 'from context', 29 | }); 30 | return {children}; 31 | }, 32 | { priority: 8 } 33 | ); 34 | 35 | /** 36 | * Simple attribute directive, for testing purposes. It reads the value of 37 | * `attribute` from context and populates `data-attribute` with it. 38 | */ 39 | directive('test-attribute', ({ context, evaluate, element }) => { 40 | executionProof('attribute'); 41 | const contextValue = useContext(context); 42 | const attributeValue = evaluate('context.attribute', { 43 | context: contextValue, 44 | }); 45 | useEffect(() => { 46 | element.ref.current.setAttribute('data-attribute', attributeValue); 47 | }, []); 48 | element.props['data-attribute'] = attributeValue; 49 | }); 50 | 51 | /** 52 | * Simple text directive, for testing purposes. It reads the value of 53 | * `text` from context and populates `children` with it. 54 | */ 55 | directive( 56 | 'test-text', 57 | ({ context, evaluate, element }) => { 58 | executionProof('text'); 59 | const contextValue = useContext(context); 60 | const textValue = evaluate('context.text', { 61 | context: contextValue, 62 | }); 63 | element.props.children =

{textValue}

; 64 | }, 65 | { priority: 12 } 66 | ); 67 | 68 | /** 69 | * Children directive, for testing purposes. It adds a wrapper around 70 | * `children`, including two buttons to modify `text` and `attribute` values 71 | * from the received context. 72 | */ 73 | directive( 74 | 'test-children', 75 | ({ context, evaluate, element }) => { 76 | executionProof('children'); 77 | const contextValue = useContext(context); 78 | const updateAttribute = () => { 79 | evaluate('actions.updateAttribute', { context: contextValue }); 80 | }; 81 | const updateText = () => { 82 | evaluate('actions.updateText', { context: contextValue }); 83 | }; 84 | element.props.children = ( 85 |
86 | {element.props.children} 87 | 88 | 89 |
90 | ); 91 | }, 92 | { priority: 14 } 93 | ); 94 | 95 | store({ 96 | actions: { 97 | updateText({ context }) { 98 | context.text = 'updated'; 99 | }, 100 | updateAttribute({ context }) { 101 | context.attribute = 'updated'; 102 | }, 103 | }, 104 | }); 105 | -------------------------------------------------------------------------------- /e2e/js/negation-operator.js: -------------------------------------------------------------------------------- 1 | import { store } from '../../src/runtime/store'; 2 | 3 | store({ 4 | selectors: { 5 | active: ({ state }) => { 6 | return state.active; 7 | }, 8 | }, 9 | state: { 10 | active: false, 11 | }, 12 | actions: { 13 | toggle: ({ state }) => { 14 | state.active = !state.active; 15 | }, 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /e2e/page-1/index.js: -------------------------------------------------------------------------------- 1 | import './store.js'; 2 | import './style.css'; 3 | -------------------------------------------------------------------------------- /e2e/page-1/store.js: -------------------------------------------------------------------------------- 1 | import { store } from '../../src/runtime/store'; 2 | 3 | store({ 4 | state: { 5 | trueValue: true, 6 | falseValue: false, 7 | text: 'Text 1', 8 | }, 9 | derived: { 10 | renderContext: ({ context }) => { 11 | return JSON.stringify(context, undefined, 2); 12 | }, 13 | }, 14 | actions: { 15 | toggleTrueValue: ({ state }) => { 16 | state.trueValue = !state.trueValue; 17 | }, 18 | toggleFalseValue: ({ state }) => { 19 | state.falseValue = !state.falseValue; 20 | }, 21 | toggleContextFalseValue: ({ context }) => { 22 | context.falseValue = !context.falseValue; 23 | }, 24 | updateContext: ({ context, event }) => { 25 | const { name, value } = event.target; 26 | const [key, ...path] = name.split('.').reverse(); 27 | const obj = path.reduceRight((o, k) => o[k], context); 28 | obj[key] = value; 29 | }, 30 | toggleStateText: ({ state }) => { 31 | state.text = state.text === 'Text 1' ? 'Text 2' : 'Text 1'; 32 | }, 33 | toggleContextText: ({ context }) => { 34 | context.text = context.text === 'Text 1' ? 'Text 2' : 'Text 1'; 35 | }, 36 | }, 37 | }); 38 | 39 | // State for the store hydration tests. 40 | store({ 41 | state: { 42 | counter: { 43 | // TODO: replace this with a getter. 44 | // `value` is defined in the server. 45 | double: ({ state }) => state.counter.value * 2, 46 | clicks: 0, 47 | }, 48 | }, 49 | actions: { 50 | counter: { 51 | increment: ({ state }) => { 52 | state.counter.value += 1; 53 | state.counter.clicks += 1; 54 | }, 55 | }, 56 | }, 57 | }); 58 | -------------------------------------------------------------------------------- /e2e/page-1/style.css: -------------------------------------------------------------------------------- 1 | h1 { 2 | color: rgb(255, 0, 0); 3 | } 4 | 5 | h2 { 6 | color: rgb(0, 255, 0); 7 | } 8 | -------------------------------------------------------------------------------- /e2e/page-2/index.js: -------------------------------------------------------------------------------- 1 | import './store.js'; 2 | import './style.css'; 3 | -------------------------------------------------------------------------------- /e2e/page-2/store.js: -------------------------------------------------------------------------------- 1 | import { store } from '../../src/runtime/store'; 2 | import { navigate } from '../../src/runtime/router'; 3 | 4 | store({ 5 | state: { 6 | newValue: true, 7 | }, 8 | actions: { 9 | toggleNewValue: ({ state }) => { 10 | state.newValue = !state.newValue; 11 | }, 12 | replaceWithPage3: () => { 13 | navigate('/csn-page-3.html', { replace: true }); 14 | }, 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /e2e/page-2/style.css: -------------------------------------------------------------------------------- 1 | h1 { 2 | color: rgb(0, 0, 255); 3 | } 4 | -------------------------------------------------------------------------------- /e2e/specs/csn.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '../tests'; 2 | 3 | test.describe('toVdom - full', () => { 4 | test.beforeEach(async ({ goToFile }) => { 5 | await goToFile('csn-page-1.html'); 6 | }); 7 | 8 | test('it should navigate in the client', async ({ page }) => { 9 | const csnPage1 = await page.evaluate('window.csn'); 10 | expect(csnPage1).toBeTruthy(); 11 | await page.getByTestId('csn-next-page').click(); 12 | const csnPage2 = await page.evaluate('window.csn'); 13 | expect(csnPage2).toBeTruthy(); 14 | }); 15 | 16 | test('it should load update content after navigation', async ({ page }) => { 17 | const title = page.getByTestId('csn-heading-page-1'); 18 | await expect(title).toHaveText(/Client-side navigation Page 1/); 19 | await page.getByTestId('csn-next-page').click(); 20 | const newTitle = page.getByTestId('csn-heading-page-2'); 21 | await expect(newTitle).toHaveText(/Client-side navigation Page 2/); 22 | }); 23 | 24 | test('it should remove old content after navigation', async ({ page }) => { 25 | const button = page.getByTestId('csn-next-page'); 26 | await expect(button).toBeVisible(); 27 | await button.click(); 28 | await expect(button).not.toBeVisible(); 29 | }); 30 | 31 | test('it should apply new styles after navigation', async ({ page }) => { 32 | const title = page.getByTestId('csn-heading-page-1'); 33 | await expect(title).toHaveCSS('color', 'rgb(255, 0, 0)'); 34 | await page.getByTestId('csn-next-page').click(); 35 | const newTitle = page.getByTestId('csn-heading-page-2'); 36 | await expect(newTitle).toHaveCSS('color', 'rgb(0, 0, 255)'); 37 | }); 38 | 39 | test('it should remove old styles after navigation', async ({ page }) => { 40 | const subheading = page.getByTestId('csn-subheading'); 41 | await expect(subheading).toHaveCSS('color', 'rgb(0, 255, 0)'); 42 | await page.getByTestId('csn-next-page').click(); 43 | await expect(subheading).not.toHaveCSS('color', 'rgb(0, 255, 0)'); 44 | }); 45 | 46 | test('it should apply new scripts after navigation', async ({ page }) => { 47 | await page.getByTestId('csn-next-page').click(); 48 | const el = page.getByTestId('show when newValue is true'); 49 | await expect(el).toBeVisible(); 50 | await page.getByTestId('toggle newValue').click(); 51 | await expect(el).toBeHidden(); 52 | }); 53 | 54 | test('it should replace current page in session history when using `replace` option', async ({ 55 | page, 56 | }) => { 57 | // We start on page 1 and navigate using `push` to page 2. 58 | await page.getByTestId('csn-next-page').click(); 59 | // Once we are in page 2, we navigate using `replace` to page 3. 60 | await page.getByTestId('replace with page 3').click(); 61 | const newTitle = page.getByTestId('csn-heading-page-3'); 62 | await expect(newTitle).toHaveText(/Client-side navigation Page 3/); 63 | // If we go back, we should go back to page 1 and not 2. 64 | await page.goBack(); 65 | const prevTitle = page.getByTestId('csn-heading-page-1'); 66 | await expect(prevTitle).toHaveText(/Client-side navigation Page 1/); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /e2e/specs/directive-bind.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '../tests'; 2 | 3 | test.describe('data-wp-bind', () => { 4 | test.beforeEach(async ({ goToFile }) => { 5 | await goToFile('directive-bind.html'); 6 | }); 7 | 8 | test('add missing href at hydration', async ({ page }) => { 9 | const el = page.getByTestId('add missing href at hydration'); 10 | await expect(el).toHaveAttribute('href', '/some-url'); 11 | }); 12 | 13 | test('change href at hydration', async ({ page }) => { 14 | const el = page.getByTestId('change href at hydration'); 15 | await expect(el).toHaveAttribute('href', '/some-url'); 16 | }); 17 | 18 | test('update missing href at hydration', async ({ page }) => { 19 | const el = page.getByTestId('add missing href at hydration'); 20 | await expect(el).toHaveAttribute('href', '/some-url'); 21 | page.getByTestId('toggle').click(); 22 | await expect(el).toHaveAttribute('href', '/some-other-url'); 23 | }); 24 | 25 | test('add missing checked at hydration', async ({ page }) => { 26 | const el = page.getByTestId('add missing checked at hydration'); 27 | await expect(el).toHaveAttribute('checked', ''); 28 | }); 29 | 30 | test('remove existing checked at hydration', async ({ page }) => { 31 | const el = page.getByTestId('remove existing checked at hydration'); 32 | await expect(el).not.toHaveAttribute('checked', ''); 33 | }); 34 | 35 | test('update existing checked', async ({ page }) => { 36 | const el = page.getByTestId('add missing checked at hydration'); 37 | const el2 = page.getByTestId('remove existing checked at hydration'); 38 | let checked = await el.evaluate( 39 | (element: HTMLInputElement) => element.checked 40 | ); 41 | let checked2 = await el2.evaluate( 42 | (element: HTMLInputElement) => element.checked 43 | ); 44 | expect(checked).toBe(true); 45 | expect(checked2).toBe(false); 46 | await page.getByTestId('toggle').click(); 47 | checked = await el.evaluate( 48 | (element: HTMLInputElement) => element.checked 49 | ); 50 | checked2 = await el2.evaluate( 51 | (element: HTMLInputElement) => element.checked 52 | ); 53 | expect(checked).toBe(false); 54 | expect(checked2).toBe(true); 55 | }); 56 | 57 | test('nested binds', async ({ page }) => { 58 | const el = page.getByTestId('nested binds - 1'); 59 | await expect(el).toHaveAttribute('href', '/some-url'); 60 | const el2 = page.getByTestId('nested binds - 2'); 61 | await expect(el2).toHaveAttribute('width', '1'); 62 | await page.getByTestId('toggle').click(); 63 | await expect(el).toHaveAttribute('href', '/some-other-url'); 64 | await expect(el2).toHaveAttribute('width', '2'); 65 | }); 66 | 67 | test('check enumerated attributes with true/false values', async ({ 68 | page, 69 | }) => { 70 | const el = page.getByTestId( 71 | 'check enumerated attributes with true/false exist and have a string value' 72 | ); 73 | await expect(el).toHaveAttribute('hidden', ''); 74 | await expect(el).toHaveAttribute('aria-hidden', 'true'); 75 | await expect(el).toHaveAttribute('aria-expanded', 'false'); 76 | await expect(el).toHaveAttribute('data-some-value', 'false'); 77 | await page.getByTestId('toggle').click(); 78 | await expect(el).not.toHaveAttribute('hidden', ''); 79 | await expect(el).toHaveAttribute('aria-hidden', 'false'); 80 | await expect(el).toHaveAttribute('aria-expanded', 'true'); 81 | await expect(el).toHaveAttribute('data-some-value', 'true'); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /e2e/specs/directive-effect.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '../tests'; 2 | 3 | test.describe('data-wp-effect', () => { 4 | test.beforeEach(async ({ goToFile }) => { 5 | await goToFile('directives-effect.html'); 6 | }); 7 | 8 | test('check that effect runs when it is added', async ({ page }) => { 9 | const el = page.getByTestId('element in the DOM'); 10 | await expect(el).toContainText('element is in the DOM'); 11 | }); 12 | 13 | test('check that effect runs when it is removed', async ({ page }) => { 14 | await page.getByTestId('toggle').click(); 15 | const el = page.getByTestId('element in the DOM'); 16 | await expect(el).toContainText('element is not in the DOM'); 17 | }); 18 | 19 | test('change focus after DOM changes', async ({ page }) => { 20 | const el = page.getByTestId('input'); 21 | await expect(el).toBeFocused(); 22 | await page.getByTestId('toggle').click(); 23 | await page.getByTestId('toggle').click(); 24 | await expect(el).toBeFocused(); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /e2e/specs/directive-priorities.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '../tests'; 2 | 3 | test.describe('Directives (w/ priority)', () => { 4 | test.beforeEach(async ({ goToFile }) => { 5 | await goToFile('directive-priorities.html'); 6 | }); 7 | 8 | test('should run in priority order', async ({ page }) => { 9 | const executionOrder = page.getByTestId('execution order'); 10 | await expect(executionOrder).toHaveText( 11 | 'context, attribute, text, children' 12 | ); 13 | }); 14 | 15 | test('should wrap those with less priority', async ({ page }) => { 16 | // Check that attribute value is correctly received from Provider. 17 | const element = page.getByTestId('test directives'); 18 | await expect(element).toHaveAttribute('data-attribute', 'from context'); 19 | 20 | // Check that text value is correctly received from Provider, and text 21 | // wrapped with an element with `data-testid=text`. 22 | const text = element.getByTestId('text'); 23 | await expect(text).toHaveText('from context'); 24 | }); 25 | 26 | test('should propagate element modifications top-down', async ({ 27 | page, 28 | }) => { 29 | const executionOrder = page.getByTestId('execution order'); 30 | const element = page.getByTestId('test directives'); 31 | const text = element.getByTestId('text'); 32 | 33 | // Get buttons. 34 | const updateAttribute = element.getByRole('button', { 35 | name: 'Update attribute', 36 | }); 37 | const updateText = element.getByRole('button', { 38 | name: 'Update text', 39 | }); 40 | 41 | // Modify `attribute` inside context. This triggers a re-render for the 42 | // component that wraps the `attribute` directive, evaluating it again. 43 | // Nested components are re-rendered as well, so their directives are 44 | // also re-evaluated (note how `text` and `children` have run). 45 | await updateAttribute.click(); 46 | await expect(element).toHaveAttribute('data-attribute', 'updated'); 47 | await expect(executionOrder).toHaveText( 48 | [ 49 | 'context, attribute, text, children', 50 | 'attribute, text, children', 51 | ].join(', ') 52 | ); 53 | 54 | // Modify `text` inside context. This triggers a re-render of the 55 | // component that wraps the `text` directive. In this case, only 56 | // `children` run as well, right after `text`. 57 | await updateText.click(); 58 | await expect(element).toHaveAttribute('data-attribute', 'updated'); 59 | await expect(text).toHaveText('updated'); 60 | await expect(executionOrder).toHaveText( 61 | [ 62 | 'context, attribute, text, children', 63 | 'attribute, text, children', 64 | 'text, children', 65 | ].join(', ') 66 | ); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /e2e/specs/directives-class.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '../tests'; 2 | 3 | test.describe('data-wp-class', () => { 4 | test.beforeEach(async ({ goToFile }) => { 5 | await goToFile('directives-class.html'); 6 | }); 7 | 8 | test('remove class if callback returns falsy value', async ({ page }) => { 9 | const el = page.getByTestId( 10 | 'remove class if callback returns falsy value' 11 | ); 12 | await expect(el).toHaveClass('bar'); 13 | page.getByTestId('toggle falseValue').click(); 14 | await expect(el).toHaveClass('foo bar'); 15 | page.getByTestId('toggle falseValue').click(); 16 | await expect(el).toHaveClass('bar'); 17 | }); 18 | 19 | test('add class if callback returns truthy value', async ({ page }) => { 20 | const el = page.getByTestId( 21 | 'add class if callback returns truthy value' 22 | ); 23 | await expect(el).toHaveClass('foo bar'); 24 | page.getByTestId('toggle trueValue').click(); 25 | await expect(el).toHaveClass('foo'); 26 | page.getByTestId('toggle trueValue').click(); 27 | await expect(el).toHaveClass('foo bar'); 28 | }); 29 | 30 | test('handles multiple classes and callbacks', async ({ page }) => { 31 | const el = page.getByTestId('handles multiple classes and callbacks'); 32 | await expect(el).toHaveClass('bar baz'); 33 | page.getByTestId('toggle trueValue').click(); 34 | await expect(el).toHaveClass(''); 35 | page.getByTestId('toggle trueValue').click(); 36 | await expect(el).toHaveClass('bar baz'); 37 | page.getByTestId('toggle falseValue').click(); 38 | await expect(el).toHaveClass('foo bar baz'); 39 | page.getByTestId('toggle trueValue').click(); 40 | await expect(el).toHaveClass('foo'); 41 | }); 42 | 43 | test('handles class names that are contained inside other class names', async ({ 44 | page, 45 | }) => { 46 | const el = page.getByTestId( 47 | 'handles class names that are contained inside other class names' 48 | ); 49 | await expect(el).toHaveClass('foo-bar'); 50 | page.getByTestId('toggle falseValue').click(); 51 | await expect(el).toHaveClass('foo foo-bar'); 52 | page.getByTestId('toggle trueValue').click(); 53 | await expect(el).toHaveClass('foo'); 54 | }); 55 | 56 | test('can toggle class in the middle', async ({ page }) => { 57 | const el = page.getByTestId('can toggle class in the middle'); 58 | await expect(el).toHaveClass('foo bar baz'); 59 | page.getByTestId('toggle trueValue').click(); 60 | await expect(el).toHaveClass('foo baz'); 61 | page.getByTestId('toggle trueValue').click(); 62 | await expect(el).toHaveClass('foo bar baz'); 63 | }); 64 | 65 | test('can toggle class when class attribute is missing', async ({ 66 | page, 67 | }) => { 68 | const el = page.getByTestId( 69 | 'can toggle class when class attribute is missing' 70 | ); 71 | await expect(el).toHaveClass(''); 72 | page.getByTestId('toggle falseValue').click(); 73 | await expect(el).toHaveClass('foo'); 74 | page.getByTestId('toggle falseValue').click(); 75 | await expect(el).toHaveClass(''); 76 | }); 77 | 78 | test('can use context values', async ({ page }) => { 79 | const el = page.getByTestId('can use context values'); 80 | await expect(el).toHaveClass(''); 81 | page.getByTestId('toggle context false value').click(); 82 | await expect(el).toHaveClass('foo'); 83 | page.getByTestId('toggle context false value').click(); 84 | await expect(el).toHaveClass(''); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /e2e/specs/directives-context.spec.ts: -------------------------------------------------------------------------------- 1 | import { Locator } from '@playwright/test'; 2 | import { test, expect } from '../tests'; 3 | 4 | const parseContent = async (loc: Locator) => 5 | JSON.parse((await loc.textContent()) || ''); 6 | 7 | test.describe('data-wp-context', () => { 8 | test.beforeEach(async ({ goToFile }) => { 9 | await goToFile('directives-context.html'); 10 | }); 11 | 12 | test('is correctly initialized', async ({ page }) => { 13 | const parentContext = await parseContent( 14 | page.getByTestId('parent context') 15 | ); 16 | 17 | expect(parentContext).toMatchObject({ 18 | prop1: 'parent', 19 | prop2: 'parent', 20 | obj: { prop4: 'parent', prop5: 'parent' }, 21 | array: [1, 2, 3], 22 | }); 23 | }); 24 | 25 | test('is correctly extended', async ({ page }) => { 26 | const childContext = await parseContent( 27 | page.getByTestId('child context') 28 | ); 29 | 30 | expect(childContext).toMatchObject({ 31 | prop1: 'parent', 32 | prop2: 'child', 33 | prop3: 'child', 34 | obj: { prop4: 'parent', prop5: 'child', prop6: 'child' }, 35 | array: [4, 5, 6], 36 | }); 37 | }); 38 | 39 | test('changes in inherited properties are reflected (child)', async ({ 40 | page, 41 | }) => { 42 | await page.getByTestId('child prop1').click(); 43 | await page.getByTestId('child obj.prop4').click(); 44 | 45 | const childContext = await parseContent( 46 | page.getByTestId('child context') 47 | ); 48 | 49 | expect(childContext.prop1).toBe('modifiedFromChild'); 50 | expect(childContext.obj.prop4).toBe('modifiedFromChild'); 51 | 52 | const parentContext = await parseContent( 53 | page.getByTestId('parent context') 54 | ); 55 | 56 | expect(parentContext.prop1).toBe('modifiedFromChild'); 57 | expect(parentContext.obj.prop4).toBe('modifiedFromChild'); 58 | }); 59 | 60 | test('changes in inherited properties are reflected (parent)', async ({ 61 | page, 62 | }) => { 63 | await page.getByTestId('parent prop1').click(); 64 | await page.getByTestId('parent obj.prop4').click(); 65 | 66 | const childContext = await parseContent( 67 | page.getByTestId('child context') 68 | ); 69 | 70 | expect(childContext.prop1).toBe('modifiedFromParent'); 71 | expect(childContext.obj.prop4).toBe('modifiedFromParent'); 72 | 73 | const parentContext = await parseContent( 74 | page.getByTestId('parent context') 75 | ); 76 | 77 | expect(parentContext.prop1).toBe('modifiedFromParent'); 78 | expect(parentContext.obj.prop4).toBe('modifiedFromParent'); 79 | }); 80 | 81 | test('changes in shadowed properties do not leak (child)', async ({ 82 | page, 83 | }) => { 84 | await page.getByTestId('child prop2').click(); 85 | await page.getByTestId('child obj.prop5').click(); 86 | 87 | const childContext = await parseContent( 88 | page.getByTestId('child context') 89 | ); 90 | 91 | expect(childContext.prop2).toBe('modifiedFromChild'); 92 | expect(childContext.obj.prop5).toBe('modifiedFromChild'); 93 | 94 | const parentContext = await parseContent( 95 | page.getByTestId('parent context') 96 | ); 97 | 98 | expect(parentContext.prop2).toBe('parent'); 99 | expect(parentContext.obj.prop5).toBe('parent'); 100 | }); 101 | 102 | test('changes in shadowed properties do not leak (parent)', async ({ 103 | page, 104 | }) => { 105 | await page.getByTestId('parent prop2').click(); 106 | await page.getByTestId('parent obj.prop5').click(); 107 | 108 | const childContext = await parseContent( 109 | page.getByTestId('child context') 110 | ); 111 | 112 | expect(childContext.prop2).toBe('child'); 113 | expect(childContext.obj.prop5).toBe('child'); 114 | 115 | const parentContext = await parseContent( 116 | page.getByTestId('parent context') 117 | ); 118 | 119 | expect(parentContext.prop2).toBe('modifiedFromParent'); 120 | expect(parentContext.obj.prop5).toBe('modifiedFromParent'); 121 | }); 122 | 123 | test('Array properties are shadowed', async ({ page }) => { 124 | const parentContext = await parseContent( 125 | page.getByTestId('parent context') 126 | ); 127 | 128 | const childContext = await parseContent( 129 | page.getByTestId('child context') 130 | ); 131 | 132 | expect(parentContext.array).toMatchObject([1, 2, 3]); 133 | expect(childContext.array).toMatchObject([4, 5, 6]); 134 | }); 135 | 136 | test('can be accessed in other directives on the same element', async ({ 137 | page, 138 | }) => { 139 | await page.pause(); 140 | const element = page.getByTestId('context & other directives'); 141 | await expect(element).toHaveText('Text 1'); 142 | await expect(element).toHaveAttribute('value', 'Text 1'); 143 | await element.click(); 144 | await expect(element).toHaveText('Text 2'); 145 | await expect(element).toHaveAttribute('value', 'Text 2'); 146 | await element.click(); 147 | await expect(element).toHaveText('Text 1'); 148 | await expect(element).toHaveAttribute('value', 'Text 1'); 149 | }); 150 | }); 151 | -------------------------------------------------------------------------------- /e2e/specs/directives-show.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '../tests'; 2 | 3 | test.describe('data-wp-show', () => { 4 | test.beforeEach(async ({ goToFile }) => { 5 | await goToFile('directives-show.html'); 6 | }); 7 | 8 | test('show if callback returns truthy value', async ({ page }) => { 9 | const el = page.getByTestId('show if callback returns truthy value'); 10 | await expect(el).toBeVisible(); 11 | }); 12 | 13 | test('do not show if callback returns falsy value', async ({ page }) => { 14 | const el = page.getByTestId( 15 | 'do not show if callback returns false value' 16 | ); 17 | await expect(el).toBeHidden(); 18 | }); 19 | 20 | test('hide when toggling truthy value to falsy', async ({ page }) => { 21 | const el = page.getByTestId('show if callback returns truthy value'); 22 | await expect(el).toBeVisible(); 23 | page.getByTestId('toggle trueValue').click(); 24 | await expect(el).toBeHidden(); 25 | page.getByTestId('toggle trueValue').click(); 26 | await expect(el).toBeVisible(); 27 | }); 28 | 29 | test('show when toggling false value to truthy', async ({ page }) => { 30 | const el = page.getByTestId( 31 | 'do not show if callback returns false value' 32 | ); 33 | await expect(el).toBeHidden(); 34 | page.getByTestId('toggle falseValue').click(); 35 | await expect(el).toBeVisible(); 36 | page.getByTestId('toggle falseValue').click(); 37 | await expect(el).toBeHidden(); 38 | }); 39 | 40 | test('can use context values', async ({ page }) => { 41 | const el = page.getByTestId('can use context values'); 42 | await expect(el).toBeHidden(); 43 | page.getByTestId('toggle context false value').click(); 44 | await expect(el).toBeVisible(); 45 | page.getByTestId('toggle context false value').click(); 46 | await expect(el).toBeHidden(); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /e2e/specs/directives-text.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '../tests'; 2 | 3 | test.describe('data-wp-text', () => { 4 | test.beforeEach(async ({ goToFile }) => { 5 | await goToFile('directives-text.html'); 6 | }); 7 | 8 | test('show proper text reading from state', async ({ page }) => { 9 | await page.pause(); 10 | const el = page.getByTestId('show state text'); 11 | await expect(el).toHaveText('Text 1'); 12 | page.getByTestId('toggle state text').click(); 13 | await expect(el).toHaveText('Text 2'); 14 | page.getByTestId('toggle state text').click(); 15 | await expect(el).toHaveText('Text 1'); 16 | }); 17 | 18 | test('show proper text reading from context', async ({ page }) => { 19 | await page.pause(); 20 | const el = page.getByTestId('show context text'); 21 | await expect(el).toHaveText('Text 1'); 22 | page.getByTestId('toggle context text').click(); 23 | await expect(el).toHaveText('Text 2'); 24 | page.getByTestId('toggle context text').click(); 25 | await expect(el).toHaveText('Text 1'); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /e2e/specs/negation-operator.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '../tests'; 2 | 3 | test.describe('negation-operator', () => { 4 | test.beforeEach(async ({ goToFile }) => { 5 | await goToFile('negation-operator.html'); 6 | }); 7 | 8 | test('add hidden attribute when !state.active', async ({ page }) => { 9 | const el = page.getByTestId( 10 | 'add hidden attribute if state is not active' 11 | ); 12 | 13 | await expect(el).toHaveAttribute('hidden', ''); 14 | page.getByTestId('toggle active value').click(); 15 | await expect(el).not.toHaveAttribute('hidden', ''); 16 | page.getByTestId('toggle active value').click(); 17 | await expect(el).toHaveAttribute('hidden', ''); 18 | }); 19 | 20 | test('add hidden attribute when !selectors.active', async ({ page }) => { 21 | const el = page.getByTestId( 22 | 'add hidden attribute if selector is not active' 23 | ); 24 | 25 | await expect(el).toHaveAttribute('hidden', ''); 26 | page.getByTestId('toggle active value').click(); 27 | await expect(el).not.toHaveAttribute('hidden', ''); 28 | page.getByTestId('toggle active value').click(); 29 | await expect(el).toHaveAttribute('hidden', ''); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /e2e/specs/store-tag.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '../tests'; 2 | 3 | test.describe('store tag', () => { 4 | test('hydrates when it is well defined', async ({ goToFile, page }) => { 5 | await goToFile('store-tag-ok.html'); 6 | 7 | const value = page.getByTestId('counter value'); 8 | const double = page.getByTestId('counter double'); 9 | const clicks = page.getByTestId('counter clicks'); 10 | 11 | await expect(value).toHaveText('3'); 12 | await expect(double).toHaveText('6'); 13 | await expect(clicks).toHaveText('0'); 14 | 15 | page.getByTestId('counter button').click(); 16 | 17 | await expect(value).toHaveText('4'); 18 | await expect(double).toHaveText('8'); 19 | await expect(clicks).toHaveText('1'); 20 | }); 21 | 22 | test('does not break the page when missing', async ({ goToFile, page }) => { 23 | await goToFile('store-tag-missing.html'); 24 | 25 | const clicks = page.getByTestId('counter clicks'); 26 | await expect(clicks).toHaveText('0'); 27 | page.getByTestId('counter button').click(); 28 | await expect(clicks).toHaveText('1'); 29 | }); 30 | 31 | test('does not break the page when corrupted', async ({ 32 | goToFile, 33 | page, 34 | }) => { 35 | await goToFile('store-tag-corrupted-json.html'); 36 | 37 | const clicks = page.getByTestId('counter clicks'); 38 | await expect(clicks).toHaveText('0'); 39 | page.getByTestId('counter button').click(); 40 | await expect(clicks).toHaveText('1'); 41 | }); 42 | 43 | test('does not break the page when it contains an invalid state', async ({ 44 | goToFile, 45 | page, 46 | }) => { 47 | await goToFile('store-tag-invalid-state.html'); 48 | 49 | const clicks = page.getByTestId('counter clicks'); 50 | await expect(clicks).toHaveText('0'); 51 | page.getByTestId('counter button').click(); 52 | await expect(clicks).toHaveText('1'); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /e2e/specs/tovdom-full.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '../tests'; 2 | 3 | test.describe('toVdom - full', () => { 4 | test.beforeEach(async ({ goToFile }) => { 5 | await goToFile('tovdom-full.html'); 6 | }); 7 | 8 | test('it should stop when it founds data-wp-ignore', async ({ page }) => { 9 | const el = page.getByTestId('inside data-wp-ignore'); 10 | await expect(el).toBeVisible(); 11 | }); 12 | 13 | test('it should not change data-wp-ignore content after navigation', async ({ 14 | page, 15 | }) => { 16 | // Next HTML purposely changes content inside `data-wp-ignore`. 17 | await page.getByTestId('next').click(); 18 | 19 | const oldContent = page.getByTestId('inside data-wp-ignore'); 20 | await expect(oldContent).toBeVisible(); 21 | 22 | const newContent = page.getByTestId( 23 | 'new content inside data-wp-ignore' 24 | ); 25 | await expect(newContent).not.toBeVisible(); 26 | 27 | const link = page.getByTestId('next'); 28 | await expect(link).not.toBeVisible(); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /e2e/specs/tovdom-islands.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '../tests'; 2 | 3 | test.describe('toVdom - isands', () => { 4 | test.beforeEach(async ({ goToFile }) => { 5 | await goToFile('tovdom-islands.html'); 6 | }); 7 | 8 | test('directives that are not inside islands should not be hydrated', async ({ 9 | page, 10 | }) => { 11 | const el = page.getByTestId('not inside an island'); 12 | await expect(el).toBeVisible(); 13 | }); 14 | 15 | test('directives that are inside islands should be hydrated', async ({ 16 | page, 17 | }) => { 18 | const el = page.getByTestId('inside an island'); 19 | await expect(el).toBeHidden(); 20 | }); 21 | 22 | test('directives that are inside inner blocks of isolated islands should not be hydrated', async ({ 23 | page, 24 | }) => { 25 | const el = page.getByTestId( 26 | 'inside an inner block of an isolated island' 27 | ); 28 | await expect(el).toBeVisible(); 29 | }); 30 | 31 | test('directives inside islands should not be hydrated twice', async ({ 32 | page, 33 | }) => { 34 | const el = page.getByTestId('island inside another island'); 35 | const templates = el.locator('template'); 36 | expect(await templates.count()).toEqual(1); 37 | }); 38 | 39 | test('islands inside inner blocks of isolated islands should be hydrated', async ({ 40 | page, 41 | }) => { 42 | const el = page.getByTestId( 43 | 'island inside inner block of isolated island' 44 | ); 45 | await expect(el).toBeHidden(); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /e2e/specs/tovdom.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '../tests'; 2 | 3 | test.describe('toVdom', () => { 4 | test.beforeEach(async ({ goToFile }) => { 5 | await goToFile('tovdom.html'); 6 | }); 7 | 8 | test('it should delete comments', async ({ page }) => { 9 | const el = page.getByTestId('it should delete comments'); 10 | const c = await el.innerHTML(); 11 | expect(c).not.toContain('##1##'); 12 | expect(c).not.toContain('##2##'); 13 | const el2 = page.getByTestId( 14 | 'it should keep this node between comments' 15 | ); 16 | await expect(el2).toBeVisible(); 17 | }); 18 | 19 | test('it should delete processing instructions', async ({ page }) => { 20 | const el = page.getByTestId('it should delete processing instructions'); 21 | const c = await el.innerHTML(); 22 | expect(c).not.toContain('##1##'); 23 | expect(c).not.toContain('##2##'); 24 | const el2 = page.getByTestId( 25 | 'it should keep this node between processing instructions' 26 | ); 27 | await expect(el2).toBeVisible(); 28 | }); 29 | 30 | test('it should replace CDATA with text nodes', async ({ page }) => { 31 | const el = page.getByTestId('it should replace CDATA with text nodes'); 32 | const c = await el.innerHTML(); 33 | expect(c).toContain('##1##'); 34 | expect(c).toContain('##2##'); 35 | const el2 = page.getByTestId('it should keep this node between CDATA'); 36 | await expect(el2).toBeVisible(); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /e2e/tests.ts: -------------------------------------------------------------------------------- 1 | import { test as base, type Page } from '@playwright/test'; 2 | import { join } from 'path'; 3 | 4 | type Fixtures = { 5 | /** 6 | * Allow visiting local HTML files as if they were under a real domain, 7 | * mainly to avoid errors from `fetch` calls 8 | * 9 | * It looks for HTML files inside the `/e2e/html` folder, and uses 10 | * `Page.goto` under the hood. 11 | * 12 | * @example 13 | * ```ts 14 | * test.beforeEach(async ({ goToFile }) => { 15 | * await goToFile('directives-context.html'); 16 | * }); 17 | * ``` 18 | * 19 | * @param filename The name of the HTML file to visit. 20 | * @param options Same options object accepted by `page.goto`. 21 | * 22 | * @return Promise. 23 | */ 24 | goToFile: (...params: Parameters) => ReturnType; 25 | }; 26 | 27 | export const test = base.extend({ 28 | goToFile: async ({ page }, use) => { 29 | await page.route('**/*.html', async (route, req) => { 30 | const { pathname } = new URL(req.url()); 31 | route.fulfill({ path: join(__dirname, './html', pathname) }); 32 | }); 33 | await page.route('**/*.{js,css}', async (route, req) => { 34 | const { pathname } = new URL(req.url()); 35 | route.fulfill({ path: join(__dirname, '..', pathname) }); 36 | }); 37 | 38 | await use(async (filename, options) => 39 | page.goto(join('http://a.b', filename), options) 40 | ); 41 | }, 42 | }); 43 | 44 | export { expect } from '@playwright/test'; 45 | -------------------------------------------------------------------------------- /jest.babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [['@babel/preset-env', { targets: { node: 'current' } }]], 3 | }; 4 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | '^.+\\.jsx?$': ['babel-jest', { configFile: './jest.babel.config.js' }], 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "block-interactivity-experiments", 3 | "version": "0.1.27", 4 | "author": "The WordPress Contributors", 5 | "license": "GPL-2.0-or-later", 6 | "main": "build/index.js", 7 | "scripts": { 8 | "build": "webpack --mode=production", 9 | "start": "webpack --mode=development --watch", 10 | "dev": "npm start", 11 | "format:php": "wp-env run composer run-script format", 12 | "lint:php": "wp-env run composer run-script lint", 13 | "lint:js": "wp-scripts lint-js", 14 | "lint:js:fix": "npm run lint:js -- --fix", 15 | "test": "jest", 16 | "pretest:unit:php": "wp-env start", 17 | "test:unit:php": "wp-env run tests-wordpress /var/www/html/wp-content/plugins/block-interactivity-experiments/vendor/bin/phpunit -c /var/www/html/wp-content/plugins/block-interactivity-experiments/phpunit.xml.dist --verbose", 18 | "test:watch": "jest --watch", 19 | "plugin-zip": "node .github/scripts/build-plugin.js", 20 | "wp-env": "wp-env" 21 | }, 22 | "prettier": { 23 | "useTabs": true, 24 | "tabWidth": 4, 25 | "printWidth": 80, 26 | "singleQuote": true, 27 | "trailingComma": "es5", 28 | "bracketSameLine": false, 29 | "bracketSpacing": true, 30 | "semi": true, 31 | "arrowParens": "always", 32 | "phpVersion": "5.6" 33 | }, 34 | "devDependencies": { 35 | "@babel/core": "^7.17.10", 36 | "@babel/preset-env": "^7.17.10", 37 | "@playwright/test": "^1.29.0", 38 | "@types/jest": "^27.5.1", 39 | "@wordpress/env": "^5.8.0", 40 | "@wordpress/scripts": "^24.3.0", 41 | "archiver": "^5.3.1", 42 | "babel-jest": "^28.1.0", 43 | "css-loader": "^6.7.3", 44 | "jest": "^28.1.0", 45 | "mini-css-extract-plugin": "^2.7.5", 46 | "prettier": "^2.7.1", 47 | "style-loader": "^3.3.2" 48 | }, 49 | "dependencies": { 50 | "@preact/signals": "^1.1.2", 51 | "deepsignal": "^1.2.1", 52 | "hpq": "^1.3.0", 53 | "preact": "^10.10.6" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /phpcs.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | Sniffs for WordPress plugins, with minor modifications for Gutenberg 4 | 5 | 6 | 7 | *\.php$ 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | phpunit/* 31 | 32 | 33 | phpunit/* 34 | 35 | 36 | phpunit/* 37 | 38 | 39 | phpunit/* 40 | 41 | 42 | phpunit/* 43 | 44 | 45 | phpunit/* 46 | 47 | 48 | phpunit/* 49 | 50 | 51 | 52 | 53 | 54 | ./wp-directives.php 55 | ./src 56 | ./phpunit 57 | 58 | 59 | ./build 60 | 61 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | 13 | 14 | 15 | ./phpunit/directives/ 16 | 17 | 18 | 19 | 20 | ms-required 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /phpunit/bootstrap.php: -------------------------------------------------------------------------------- 1 | get_error_message(); 71 | } 72 | 73 | throw new Exception( 'WordPress died: ' . $message ); 74 | } 75 | tests_add_filter( 'wp_die_handler', 'fail_if_died' ); 76 | 77 | $GLOBALS['wp_tests_options'] = array( 78 | 'gutenberg-experiments' => array( 79 | 'gutenberg-widget-experiments' => '1', 80 | 'gutenberg-full-site-editing' => 1, 81 | ), 82 | ); 83 | 84 | // Enable the widget block editor. 85 | tests_add_filter( 'gutenberg_use_widgets_block_editor', '__return_true' ); 86 | 87 | /** 88 | * Register test block prior to theme.json generating metadata. 89 | * 90 | * This new block is used to test experimental selectors. It is registered 91 | * via `tests_add_filter()` here during bootstrapping so that it occurs prior 92 | * to theme.json generating block metadata. Once a core block, such as Image, 93 | * uses feature level selectors we could remove this in favour of testing via 94 | * the core block. 95 | */ 96 | function gutenberg_register_test_block_for_feature_selectors() { 97 | WP_Block_Type_Registry::get_instance()->register( 98 | 'test/test', 99 | array( 100 | 'api_version' => 2, 101 | 'attributes' => array( 102 | 'textColor' => array( 103 | 'type' => 'string', 104 | ), 105 | 'style' => array( 106 | 'type' => 'object', 107 | ), 108 | ), 109 | 'supports' => array( 110 | '__experimentalBorder' => array( 111 | 'radius' => true, 112 | '__experimentalSelector' => '.inner', 113 | ), 114 | 'color' => array( 115 | 'text' => true, 116 | ), 117 | 'spacing' => array( 118 | 'padding' => true, 119 | '__experimentalSelector' => '.inner', 120 | ), 121 | 'typography' => array( 122 | 'fontSize' => true, 123 | '__experimentalSelector' => '.sub-heading', 124 | ), 125 | '__experimentalSelector' => '.wp-block-test, .wp-block-test__wrapper', 126 | ), 127 | ) 128 | ); 129 | } 130 | tests_add_filter( 'init', 'gutenberg_register_test_block_for_feature_selectors' ); 131 | 132 | // Start up the WP testing environment. 133 | require $_tests_dir . '/includes/bootstrap.php'; 134 | 135 | // Use existing behavior for wp_die during actual test execution. 136 | remove_filter( 'wp_die_handler', 'fail_if_died' ); 137 | 138 | -------------------------------------------------------------------------------- /phpunit/directives/attributes/wp-bind.php: -------------------------------------------------------------------------------- 1 | '; 21 | $tags = new WP_HTML_Tag_Processor( $markup ); 22 | $tags->next_tag(); 23 | 24 | $context_before = new WP_Directive_Context( array( 'myblock' => array( 'imageSource' => './wordpress.png' ) ) ); 25 | $context = $context_before; 26 | process_wp_bind( $tags, $context ); 27 | 28 | $this->assertSame( 29 | '', 30 | $tags->get_updated_html() 31 | ); 32 | $this->assertSame( './wordpress.png', $tags->get_attribute( 'src' ) ); 33 | $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-bind directive changed context' ); 34 | } 35 | 36 | public function test_directive_ignores_empty_bound_attribute() { 37 | $markup = ''; 38 | $tags = new WP_HTML_Tag_Processor( $markup ); 39 | $tags->next_tag(); 40 | 41 | $context_before = new WP_Directive_Context( array( 'myblock' => array( 'imageSource' => './wordpress.png' ) ) ); 42 | $context = $context_before; 43 | process_wp_bind( $tags, $context ); 44 | 45 | $this->assertSame( $markup, $tags->get_updated_html() ); 46 | $this->assertNull( $tags->get_attribute( 'src' ) ); 47 | $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-bind directive changed context' ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /phpunit/directives/attributes/wp-class.php: -------------------------------------------------------------------------------- 1 | Test'; 21 | $tags = new WP_HTML_Tag_Processor( $markup ); 22 | $tags->next_tag(); 23 | 24 | $context_before = new WP_Directive_Context( array( 'myblock' => array( 'isRed' => true ) ) ); 25 | $context = $context_before; 26 | process_wp_class( $tags, $context ); 27 | 28 | $this->assertSame( 29 | '
Test
', 30 | $tags->get_updated_html() 31 | ); 32 | $this->assertStringContainsString( 'red', $tags->get_attribute( 'class' ) ); 33 | $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-class directive changed context' ); 34 | } 35 | 36 | public function test_directive_removes_class() { 37 | $markup = '
Test
'; 38 | $tags = new WP_HTML_Tag_Processor( $markup ); 39 | $tags->next_tag(); 40 | 41 | $context_before = new WP_Directive_Context( array( 'myblock' => array( 'isBlue' => false ) ) ); 42 | $context = $context_before; 43 | process_wp_class( $tags, $context ); 44 | 45 | $this->assertSame( 46 | '
Test
', 47 | $tags->get_updated_html() 48 | ); 49 | $this->assertStringNotContainsString( 'blue', $tags->get_attribute( 'class' ) ); 50 | $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-class directive changed context' ); 51 | } 52 | 53 | public function test_directive_removes_empty_class_attribute() { 54 | $markup = '
Test
'; 55 | $tags = new WP_HTML_Tag_Processor( $markup ); 56 | $tags->next_tag(); 57 | 58 | $context_before = new WP_Directive_Context( array( 'myblock' => array( 'isBlue' => false ) ) ); 59 | $context = $context_before; 60 | process_wp_class( $tags, $context ); 61 | 62 | $this->assertSame( 63 | // WP_HTML_Tag_Processor has a TODO note to prune whitespace after classname removal. 64 | '
Test
', 65 | $tags->get_updated_html() 66 | ); 67 | $this->assertNull( $tags->get_attribute( 'class' ) ); 68 | $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-class directive changed context' ); 69 | } 70 | 71 | public function test_directive_does_not_remove_non_existant_class() { 72 | $markup = '
Test
'; 73 | $tags = new WP_HTML_Tag_Processor( $markup ); 74 | $tags->next_tag(); 75 | 76 | $context_before = new WP_Directive_Context( array( 'myblock' => array( 'isBlue' => false ) ) ); 77 | $context = $context_before; 78 | process_wp_class( $tags, $context ); 79 | 80 | $this->assertSame( 81 | '
Test
', 82 | $tags->get_updated_html() 83 | ); 84 | $this->assertSame( 'green red', $tags->get_attribute( 'class' ) ); 85 | $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-class directive changed context' ); 86 | } 87 | 88 | public function test_directive_ignores_empty_class_name() { 89 | $markup = '
Test
'; 90 | $tags = new WP_HTML_Tag_Processor( $markup ); 91 | $tags->next_tag(); 92 | 93 | $context_before = new WP_Directive_Context( array( 'myblock' => array( 'isRed' => true ) ) ); 94 | $context = $context_before; 95 | process_wp_class( $tags, $context ); 96 | 97 | $this->assertSame( $markup, $tags->get_updated_html() ); 98 | $this->assertStringNotContainsString( 'red', $tags->get_attribute( 'class' ) ); 99 | $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-class directive changed context' ); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /phpunit/directives/attributes/wp-context.php: -------------------------------------------------------------------------------- 1 | array( 'open' => false ), 23 | 'otherblock' => array( 'somekey' => 'somevalue' ), 24 | ) 25 | ); 26 | 27 | $markup = '
'; 28 | $tags = new WP_HTML_Tag_Processor( $markup ); 29 | $tags->next_tag(); 30 | 31 | process_wp_context( $tags, $context ); 32 | 33 | $this->assertSame( 34 | array( 35 | 'myblock' => array( 'open' => true ), 36 | 'otherblock' => array( 'somekey' => 'somevalue' ), 37 | ), 38 | $context->get_context() 39 | ); 40 | } 41 | 42 | public function test_directive_resets_context_correctly_upon_closing_tag() { 43 | $context = new WP_Directive_Context( 44 | array( 'my-key' => 'original-value' ) 45 | ); 46 | 47 | $context->set_context( 48 | array( 'my-key' => 'new-value' ) 49 | ); 50 | 51 | $markup = '
'; 52 | $tags = new WP_HTML_Tag_Processor( $markup ); 53 | $tags->next_tag( array( 'tag_closers' => 'visit' ) ); 54 | 55 | process_wp_context( $tags, $context ); 56 | 57 | $this->assertSame( 58 | array( 'my-key' => 'original-value' ), 59 | $context->get_context() 60 | ); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /phpunit/directives/attributes/wp-html.php: -------------------------------------------------------------------------------- 1 | '; 22 | 23 | $tags = new WP_Directive_Processor( $markup ); 24 | $tags->next_tag(); 25 | 26 | $context_before = new WP_Directive_Context( array( 'myblock' => array( 'someHtml' => 'Lorem ipsum dolor sit.' ) ) ); 27 | $context = clone $context_before; 28 | process_wp_html( $tags, $context ); 29 | 30 | $expected_markup = '
Lorem ipsum dolor sit.
'; 31 | $this->assertSame( $expected_markup, $tags->get_updated_html() ); 32 | $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-html directive changed context' ); 33 | } 34 | 35 | public function test_directive_overwrites_inner_html_based_on_attribute_value() { 36 | $markup = '
Lorem ipsum dolor sit.
'; 37 | 38 | $tags = new WP_Directive_Processor( $markup ); 39 | $tags->next_tag(); 40 | 41 | $context_before = new WP_Directive_Context( array( 'myblock' => array( 'someHtml' => 'Honi soit qui mal y pense.' ) ) ); 42 | $context = clone $context_before; 43 | process_wp_html( $tags, $context ); 44 | 45 | $expected_markup = '
Honi soit qui mal y pense.
'; 46 | $this->assertSame( $expected_markup, $tags->get_updated_html() ); 47 | $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-html directive changed context' ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /phpunit/directives/attributes/wp-style.php: -------------------------------------------------------------------------------- 1 | Test'; 21 | $tags = new WP_HTML_Tag_Processor( $markup ); 22 | $tags->next_tag(); 23 | 24 | $context_before = new WP_Directive_Context( array( 'myblock' => array( 'color' => 'green' ) ) ); 25 | $context = $context_before; 26 | process_wp_style( $tags, $context ); 27 | 28 | $this->assertSame( 29 | '
Test
', 30 | $tags->get_updated_html() 31 | ); 32 | $this->assertStringContainsString( 'color: green;', $tags->get_attribute( 'style' ) ); 33 | $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-style directive changed context' ); 34 | } 35 | 36 | public function test_directive_ignores_empty_style() { 37 | $markup = '
Test
'; 38 | $tags = new WP_HTML_Tag_Processor( $markup ); 39 | $tags->next_tag(); 40 | 41 | $context_before = new WP_Directive_Context( array( 'myblock' => array( 'color' => 'green' ) ) ); 42 | $context = $context_before; 43 | process_wp_style( $tags, $context ); 44 | 45 | $this->assertSame( $markup, $tags->get_updated_html() ); 46 | $this->assertStringNotContainsString( 'color: green;', $tags->get_attribute( 'style' ) ); 47 | $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-style directive changed context' ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /phpunit/directives/attributes/wp-text.php: -------------------------------------------------------------------------------- 1 | '; 22 | 23 | $tags = new WP_Directive_Processor( $markup ); 24 | $tags->next_tag(); 25 | 26 | $context_before = new WP_Directive_Context( array( 'myblock' => array( 'someText' => 'The HTML tag
produces a line break.' ) ) ); 27 | $context = clone $context_before; 28 | process_wp_text( $tags, $context ); 29 | 30 | $expected_markup = '
The HTML tag <br> produces a line break.
'; 31 | $this->assertSame( $expected_markup, $tags->get_updated_html() ); 32 | $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-text directive changed context' ); 33 | } 34 | 35 | public function test_directive_overwrites_inner_html_based_on_attribute_value() { 36 | $markup = '
Lorem ipsum dolor sit.
'; 37 | 38 | $tags = new WP_Directive_Processor( $markup ); 39 | $tags->next_tag(); 40 | 41 | $context_before = new WP_Directive_Context( array( 'myblock' => array( 'someText' => 'Honi soit qui mal y pense.' ) ) ); 42 | $context = clone $context_before; 43 | process_wp_text( $tags, $context ); 44 | 45 | $expected_markup = '
Honi soit qui mal y pense.
'; 46 | $this->assertSame( $expected_markup, $tags->get_updated_html() ); 47 | $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-text directive changed context' ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /phpunit/directives/utils/evaluate.php: -------------------------------------------------------------------------------- 1 | array( 20 | 'core' => array( 21 | 'number' => 1, 22 | 'bool' => true, 23 | 'nested' => array( 24 | 'string' => 'hi', 25 | ), 26 | ), 27 | ), 28 | ) 29 | ); 30 | $this->assertSame( 1, evaluate( 'state.core.number' ) ); 31 | $this->assertTrue( evaluate( 'state.core.bool' ) ); 32 | $this->assertSame( 'hi', evaluate( 'state.core.nested.string' ) ); 33 | $this->assertFalse( evaluate( '!state.core.bool' ) ); 34 | } 35 | 36 | public function test_evaluate_function_should_access_passed_context() { 37 | $context = array( 38 | 'local' => array( 39 | 'number' => 2, 40 | 'bool' => false, 41 | 'nested' => array( 42 | 'string' => 'bye', 43 | ), 44 | ), 45 | ); 46 | $this->assertSame( 2, evaluate( 'context.local.number', $context ) ); 47 | $this->assertFalse( evaluate( 'context.local.bool', $context ) ); 48 | $this->assertTrue( evaluate( '!context.local.bool', $context ) ); 49 | $this->assertSame( 'bye', evaluate( 'context.local.nested.string', $context ) ); 50 | // Previously defined state is also accessible. 51 | $this->assertSame( 1, evaluate( 'state.core.number' ) ); 52 | $this->assertTrue( evaluate( 'state.core.bool' ) ); 53 | $this->assertSame( 'hi', evaluate( 'state.core.nested.string' ) ); 54 | } 55 | 56 | public function test_evaluate_function_should_return_null_for_unresolved_paths() { 57 | $this->assertNull( evaluate( 'this.property.doesnt.exist' ) ); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /phpunit/directives/wp-directive-processor.php: -------------------------------------------------------------------------------- 1 | outside
inside
'; 14 | 15 | public function test_next_balanced_closer_stays_on_void_tag() { 16 | $tags = new WP_Directive_Processor( self::HTML ); 17 | 18 | $tags->next_tag( 'img' ); 19 | $result = $tags->next_balanced_closer(); 20 | $this->assertSame( 'IMG', $tags->get_tag() ); 21 | $this->assertFalse( $result ); 22 | } 23 | 24 | public function test_next_balanced_closer_proceeds_to_correct_tag() { 25 | $tags = new WP_Directive_Processor( self::HTML ); 26 | 27 | $tags->next_tag( 'section' ); 28 | $tags->next_balanced_closer(); 29 | $this->assertSame( 'SECTION', $tags->get_tag() ); 30 | $this->assertTrue( $tags->is_tag_closer() ); 31 | } 32 | 33 | public function test_next_balanced_closer_proceeds_to_correct_tag_for_nested_tag() { 34 | $tags = new WP_Directive_Processor( self::HTML ); 35 | 36 | $tags->next_tag( 'div' ); 37 | $tags->next_tag( 'div' ); 38 | $tags->next_balanced_closer(); 39 | $this->assertSame( 'DIV', $tags->get_tag() ); 40 | $this->assertTrue( $tags->is_tag_closer() ); 41 | } 42 | 43 | public function test_get_inner_html_returns_correct_result() { 44 | $tags = new WP_Directive_Processor( self::HTML ); 45 | 46 | $tags->next_tag( 'section' ); 47 | $this->assertSame( '
inside
', $tags->get_inner_html() ); 48 | } 49 | 50 | public function test_set_inner_html_on_void_element_has_no_effect() { 51 | $tags = new WP_Directive_Processor( self::HTML ); 52 | 53 | $tags->next_tag( 'img' ); 54 | $content = $tags->set_inner_html( 'This is the new img content' ); 55 | $this->assertFalse( $content ); 56 | $this->assertSame( self::HTML, $tags->get_updated_html() ); 57 | } 58 | 59 | public function test_set_inner_html_sets_content_correctly() { 60 | $tags = new WP_Directive_Processor( self::HTML ); 61 | 62 | $tags->next_tag( 'section' ); 63 | $tags->set_inner_html( 'This is the new section content.' ); 64 | $this->assertSame( '
outside
This is the new section content.
', $tags->get_updated_html() ); 65 | } 66 | 67 | public function test_set_inner_html_updates_bookmarks_correctly() { 68 | $tags = new WP_Directive_Processor( self::HTML ); 69 | 70 | $tags->next_tag( 'div' ); 71 | $tags->set_bookmark( 'start' ); 72 | $tags->next_tag( 'img' ); 73 | $this->assertSame( 'IMG', $tags->get_tag() ); 74 | $tags->set_bookmark( 'after' ); 75 | $tags->seek( 'start' ); 76 | 77 | $tags->set_inner_html( 'This is the new div content.' ); 78 | $this->assertSame( '
This is the new div content.
inside
', $tags->get_updated_html() ); 79 | $tags->seek( 'after' ); 80 | $this->assertSame( 'IMG', $tags->get_tag() ); 81 | } 82 | 83 | public function test_set_inner_html_subsequent_updates_on_the_same_tag_work() { 84 | $tags = new WP_Directive_Processor( self::HTML ); 85 | 86 | $tags->next_tag( 'section' ); 87 | $tags->set_inner_html( 'This is the new section content.' ); 88 | $tags->set_inner_html( 'This is the even newer section content.' ); 89 | $this->assertSame( '
outside
This is the even newer section content.
', $tags->get_updated_html() ); 90 | } 91 | 92 | public function test_set_inner_html_followed_by_set_attribute_works() { 93 | $tags = new WP_Directive_Processor( self::HTML ); 94 | 95 | $tags->next_tag( 'section' ); 96 | $tags->set_inner_html( 'This is the new section content.' ); 97 | $tags->set_attribute( 'id', 'thesection' ); 98 | $this->assertSame( '
outside
This is the new section content.
', $tags->get_updated_html() ); 99 | } 100 | 101 | public function test_set_inner_html_preceded_by_set_attribute_works() { 102 | $tags = new WP_Directive_Processor( self::HTML ); 103 | 104 | $tags->next_tag( 'section' ); 105 | $tags->set_attribute( 'id', 'thesection' ); 106 | $tags->set_inner_html( 'This is the new section content.' ); 107 | $this->assertSame( '
outside
This is the new section content.
', $tags->get_updated_html() ); 108 | } 109 | 110 | public function test_set_inner_html_invalidates_bookmarks_that_point_to_replaced_content() { 111 | $this->markTestSkipped( "This requires on bookmark invalidation, which is only in GB's WP 6.3 compat layer." ); 112 | 113 | $tags = new WP_Directive_Processor( self::HTML ); 114 | 115 | $tags->next_tag( 'section' ); 116 | $tags->set_bookmark( 'start' ); 117 | $tags->next_tag( 'img' ); 118 | $tags->set_bookmark( 'replaced' ); 119 | $tags->seek( 'start' ); 120 | 121 | $tags->set_inner_html( 'This is the new section content.' ); 122 | $this->assertSame( '
outside
This is the new section content.
', $tags->get_updated_html() ); 123 | 124 | $this->expectExceptionMessage( 'Invalid bookmark name' ); 125 | $successful_seek = $tags->seek( 'replaced' ); 126 | $this->assertFalse( $successful_seek ); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /phpunit/directives/wp-directive-store.php: -------------------------------------------------------------------------------- 1 | assertEmpty( WP_Directive_Store::get_data() ); 23 | } 24 | 25 | public function test_store_can_be_merged() { 26 | $data = array( 27 | 'state' => array( 28 | 'core' => array( 29 | 'a' => 1, 30 | 'b' => 2, 31 | 'nested' => array( 32 | 'c' => 3, 33 | ), 34 | ), 35 | ), 36 | ); 37 | WP_Directive_Store::merge_data( $data ); 38 | $this->assertSame( $data, WP_Directive_Store::get_data() ); 39 | } 40 | 41 | public function test_store_can_be_extended() { 42 | WP_Directive_Store::merge_data( 43 | array( 44 | 'state' => array( 45 | 'core' => array( 46 | 'a' => 1, 47 | ), 48 | ), 49 | ) 50 | ); 51 | WP_Directive_Store::merge_data( 52 | array( 53 | 'state' => array( 54 | 'core' => array( 55 | 'b' => 2, 56 | ), 57 | 'custom' => array( 58 | 'c' => 3, 59 | ), 60 | ), 61 | ) 62 | ); 63 | $this->assertSame( 64 | array( 65 | 'state' => array( 66 | 'core' => array( 67 | 'a' => 1, 68 | 'b' => 2, 69 | ), 70 | 'custom' => array( 71 | 'c' => 3, 72 | ), 73 | ), 74 | ), 75 | WP_Directive_Store::get_data() 76 | ); 77 | } 78 | 79 | public function test_store_existing_props_should_be_overwritten() { 80 | WP_Directive_Store::merge_data( 81 | array( 82 | 'state' => array( 83 | 'core' => array( 84 | 'a' => 1, 85 | ), 86 | ), 87 | ) 88 | ); 89 | WP_Directive_Store::merge_data( 90 | array( 91 | 'state' => array( 92 | 'core' => array( 93 | 'a' => 'overwritten', 94 | ), 95 | ), 96 | ) 97 | ); 98 | $this->assertSame( 99 | array( 100 | 'state' => array( 101 | 'core' => array( 102 | 'a' => 'overwritten', 103 | ), 104 | ), 105 | ), 106 | WP_Directive_Store::get_data() 107 | ); 108 | } 109 | 110 | public function test_store_should_be_correctly_rendered() { 111 | WP_Directive_Store::merge_data( 112 | array( 113 | 'state' => array( 114 | 'core' => array( 115 | 'a' => 1, 116 | ), 117 | ), 118 | ) 119 | ); 120 | WP_Directive_Store::merge_data( 121 | array( 122 | 'state' => array( 123 | 'core' => array( 124 | 'b' => 2, 125 | ), 126 | ), 127 | ) 128 | ); 129 | ob_start(); 130 | WP_Directive_Store::render(); 131 | $rendered = ob_get_clean(); 132 | $this->assertSame( 133 | '', 134 | $rendered 135 | ); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /phpunit/directives/wp-process-directives.php: -------------------------------------------------------------------------------- 1 | createMock( Helper_Class::class ); 26 | 27 | $test_helper->expects( $this->exactly( 2 ) ) 28 | ->method( 'process_foo_test' ) 29 | ->with( 30 | $this->callback( 31 | function( $p ) { 32 | return 'DIV' === $p->get_tag() && ( 33 | // Either this is a closing tag... 34 | $p->is_tag_closer() || 35 | // ...or it is an open tag, and has the directive attribute set. 36 | ( ! $p->is_tag_closer() && 'abc' === $p->get_attribute( 'foo-test' ) ) 37 | ); 38 | } 39 | ) 40 | ); 41 | 42 | $directives = array( 43 | 'foo-test' => array( $test_helper, 'process_foo_test' ), 44 | ); 45 | 46 | $markup = '
Example:
This is a test>
Here is a nested div
'; 47 | $tags = new WP_HTML_Tag_Processor( $markup ); 48 | wp_process_directives( $tags, 'foo-', $directives ); 49 | } 50 | 51 | public function test_directives_with_double_hyphen_processed_correctly() { 52 | $test_helper = $this->createMock( Helper_Class::class ); 53 | $test_helper->expects( $this->atLeastOnce() ) 54 | ->method( 'process_foo_test' ); 55 | 56 | $directives = array( 57 | 'foo-test' => array( $test_helper, 'process_foo_test' ), 58 | ); 59 | 60 | $markup = '
'; 61 | $tags = new WP_HTML_Tag_Processor( $markup ); 62 | wp_process_directives( $tags, 'foo-', $directives ); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | import { devices, type PlaywrightTestConfig } from '@playwright/test'; 3 | 4 | /** 5 | * Read environment variables from file. 6 | * https://github.com/motdotla/dotenv 7 | */ 8 | // require('dotenv').config(); 9 | 10 | /** 11 | * See https://playwright.dev/docs/test-configuration. 12 | */ 13 | const config: PlaywrightTestConfig = { 14 | testDir: './e2e', 15 | /* Maximum time one test can run for. */ 16 | timeout: 30 * 1000, 17 | expect: { 18 | /** 19 | * Maximum time expect() should wait for the condition to be met. 20 | * For example in `await expect(locator).toHaveText();` 21 | */ 22 | timeout: 5000, 23 | }, 24 | /* Run tests in files in parallel */ 25 | fullyParallel: true, 26 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 27 | forbidOnly: !!process.env.CI, 28 | /* Retry on CI only */ 29 | retries: process.env.CI ? 2 : 0, 30 | /* Opt out of parallel tests on CI. */ 31 | workers: process.env.CI ? 1 : undefined, 32 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 33 | reporter: 'html', 34 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 35 | use: { 36 | /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ 37 | actionTimeout: 0, 38 | /* Base URL to use in actions like `await page.goto('/')`. */ 39 | // baseURL: 'http://localhost:3000', 40 | 41 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 42 | trace: 'on-first-retry', 43 | }, 44 | 45 | /* Configure projects for major browsers */ 46 | projects: [ 47 | { 48 | name: 'chromium', 49 | use: { 50 | ...devices['Desktop Chrome'], 51 | }, 52 | }, 53 | 54 | { 55 | name: 'firefox', 56 | use: { 57 | ...devices['Desktop Firefox'], 58 | }, 59 | }, 60 | 61 | { 62 | name: 'webkit', 63 | use: { 64 | ...devices['Desktop Safari'], 65 | }, 66 | }, 67 | 68 | /* Test against mobile viewports. */ 69 | // { 70 | // name: 'Mobile Chrome', 71 | // use: { 72 | // ...devices['Pixel 5'], 73 | // }, 74 | // }, 75 | // { 76 | // name: 'Mobile Safari', 77 | // use: { 78 | // ...devices['iPhone 12'], 79 | // }, 80 | // }, 81 | 82 | /* Test against branded browsers. */ 83 | // { 84 | // name: 'Microsoft Edge', 85 | // use: { 86 | // channel: 'msedge', 87 | // }, 88 | // }, 89 | // { 90 | // name: 'Google Chrome', 91 | // use: { 92 | // channel: 'chrome', 93 | // }, 94 | // }, 95 | ], 96 | 97 | /* Folder for test artifacts such as screenshots, videos, traces, etc. */ 98 | // outputDir: 'test-results/', 99 | 100 | /* Run your local dev server before starting the tests */ 101 | // webServer: { 102 | // command: 'npm run start', 103 | // port: 3000, 104 | // }, 105 | }; 106 | 107 | export default config; 108 | -------------------------------------------------------------------------------- /src/admin/admin-page.php: -------------------------------------------------------------------------------- 1 | 27 |
28 |

WP Directives

29 |
30 | 34 | 35 |
36 |
37 | 'object', 49 | 'default' => array( 50 | 'client_side_navigation' => false, 51 | ), 52 | 'sanitize_callback' => 'wp_directives_validate_settings', 53 | ) 54 | ); 55 | 56 | add_settings_section( 57 | 'wp_directives_plugin_section', 58 | '', 59 | null, // TODO: This is supposed to be a callable. 60 | 'wp_directives_plugin_page' 61 | ); 62 | 63 | add_settings_field( 64 | 'client_side_navigation', 65 | __( 'Client Side Navigation', 'wp-directives' ), 66 | 'wp_directives_client_side_navigation_input', 67 | 'wp_directives_plugin_page', 68 | 'wp_directives_plugin_section' 69 | ); 70 | } 71 | add_action( 'admin_init', 'wp_directives_register_settings' ); 72 | 73 | /** 74 | * Validate settings. 75 | * 76 | * @param array $input Unvalidated setting. 77 | * @return array Validated setting. 78 | */ 79 | function wp_directives_validate_settings( $input ) { 80 | $output = get_option( 'wp_directives_plugin_settings' ); 81 | $output['client_side_navigation'] = ! empty( $input['client_side_navigation'] ); 82 | return $output; 83 | } 84 | 85 | /** 86 | * Render field for client-side navigation. 87 | */ 88 | function wp_directives_client_side_navigation_input() { 89 | $options = get_option( 'wp_directives_plugin_settings' ); 90 | ?> 91 | 92 | 95 | > 96 | 97 | is_tag_closer() ) { 19 | return; 20 | } 21 | 22 | $prefixed_attributes = $tags->get_attribute_names_with_prefix( 'data-wp-bind--' ); 23 | 24 | foreach ( $prefixed_attributes as $attr ) { 25 | list( , $bound_attr ) = explode( '--', $attr ); 26 | if ( empty( $bound_attr ) ) { 27 | continue; 28 | } 29 | 30 | $expr = $tags->get_attribute( $attr ); 31 | $value = evaluate( $expr, $context->get_context() ); 32 | $tags->set_attribute( $bound_attr, $value ); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/directives/attributes/wp-class.php: -------------------------------------------------------------------------------- 1 | is_tag_closer() ) { 19 | return; 20 | } 21 | 22 | $prefixed_attributes = $tags->get_attribute_names_with_prefix( 'data-wp-class--' ); 23 | 24 | foreach ( $prefixed_attributes as $attr ) { 25 | list( , $class_name ) = explode( '--', $attr ); 26 | if ( empty( $class_name ) ) { 27 | continue; 28 | } 29 | 30 | $expr = $tags->get_attribute( $attr ); 31 | $add_class = evaluate( $expr, $context->get_context() ); 32 | if ( $add_class ) { 33 | $tags->add_class( $class_name ); 34 | } else { 35 | $tags->remove_class( $class_name ); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/directives/attributes/wp-context.php: -------------------------------------------------------------------------------- 1 | is_tag_closer() ) { 16 | $context->rewind_context(); 17 | return; 18 | } 19 | 20 | $value = $tags->get_attribute( 'data-wp-context' ); 21 | if ( null === $value ) { 22 | // No data-wp-context directive. 23 | return; 24 | } 25 | 26 | $new_context = json_decode( $value, true ); 27 | // TODO: Error handling. 28 | 29 | $context->set_context( $new_context ); 30 | } 31 | -------------------------------------------------------------------------------- /src/directives/attributes/wp-html.php: -------------------------------------------------------------------------------- 1 | is_tag_closer() ) { 19 | return; 20 | } 21 | 22 | $value = $tags->get_attribute( 'data-wp-html' ); 23 | if ( null === $value ) { 24 | return; 25 | } 26 | 27 | $text = evaluate( $value, $context->get_context() ); 28 | $tags->set_inner_html( $text ); 29 | } 30 | -------------------------------------------------------------------------------- /src/directives/attributes/wp-style.php: -------------------------------------------------------------------------------- 1 | is_tag_closer() ) { 19 | return; 20 | } 21 | 22 | $prefixed_attributes = $tags->get_attribute_names_with_prefix( 'data-wp-style--' ); 23 | 24 | foreach ( $prefixed_attributes as $attr ) { 25 | list( , $style_name ) = explode( '--', $attr ); 26 | if ( empty( $style_name ) ) { 27 | continue; 28 | } 29 | 30 | $expr = $tags->get_attribute( $attr ); 31 | $style_value = evaluate( $expr, $context->get_context() ); 32 | if ( $style_value ) { 33 | $style_attr = $tags->get_attribute( 'style' ); 34 | $style_attr = set_style( $style_attr, $style_name, $style_value ); 35 | $tags->set_attribute( 'style', $style_attr ); 36 | } else { 37 | // TODO: Do we want to unset styles if they're null? 38 | } 39 | } 40 | } 41 | 42 | -------------------------------------------------------------------------------- /src/directives/attributes/wp-text.php: -------------------------------------------------------------------------------- 1 | is_tag_closer() ) { 19 | return; 20 | } 21 | 22 | $value = $tags->get_attribute( 'data-wp-text' ); 23 | if ( null === $value ) { 24 | return; 25 | } 26 | 27 | $text = evaluate( $value, $context->get_context() ); 28 | $tags->set_inner_html( esc_html( $text ) ); 29 | } 30 | -------------------------------------------------------------------------------- /src/directives/class-wp-directive-context.php: -------------------------------------------------------------------------------- 1 | 20 | * 21 | *
22 | * 23 | *
24 | * 25 | * 26 | */ 27 | class WP_Directive_Context { 28 | /** 29 | * The stack used to store contexts internally. 30 | * 31 | * @var array An array of contexts. 32 | */ 33 | protected $stack = array( array() ); 34 | 35 | /** 36 | * Constructor. 37 | * 38 | * Accepts a context as an argument to initialize this with. 39 | * 40 | * @param array $context A context. 41 | */ 42 | function __construct( $context = array() ) { 43 | $this->set_context( $context ); 44 | } 45 | 46 | /** 47 | * Return the current context. 48 | * 49 | * @return array The current context. 50 | */ 51 | public function get_context() { 52 | return end( $this->stack ); 53 | } 54 | 55 | /** 56 | * Set the current context. 57 | * 58 | * @param array $context The context to be set. 59 | * @return void 60 | */ 61 | public function set_context( $context ) { 62 | array_push( $this->stack, array_replace_recursive( $this->get_context(), $context ) ); 63 | } 64 | 65 | /** 66 | * Reset the context to its previous state. 67 | * 68 | * @return void 69 | */ 70 | public function rewind_context() { 71 | array_pop( $this->stack ); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/directives/class-wp-directive-processor.php: -------------------------------------------------------------------------------- 1 | get_tag(); 26 | 27 | if ( self::is_html_void_element( $tag_name ) ) { 28 | return false; 29 | } 30 | 31 | while ( $this->next_tag( 32 | array( 33 | 'tag_name' => $tag_name, 34 | 'tag_closers' => 'visit', 35 | ) 36 | ) ) { 37 | if ( ! $this->is_tag_closer() ) { 38 | $depth++; 39 | continue; 40 | } 41 | 42 | if ( 0 === $depth ) { 43 | return true; 44 | } 45 | 46 | $depth--; 47 | } 48 | 49 | return false; 50 | } 51 | 52 | /** 53 | * Return the content between two balanced tags. 54 | * 55 | * When called on an opening tag, return the HTML content found between 56 | * that opening tag and its matching closing tag. 57 | * 58 | * @return string The content between the current opening and its matching closing tag. 59 | */ 60 | public function get_inner_html() { 61 | $bookmarks = $this->get_balanced_tag_bookmarks(); 62 | if ( ! $bookmarks ) { 63 | return false; 64 | } 65 | list( $start_name, $end_name ) = $bookmarks; 66 | 67 | $start = $this->bookmarks[ $start_name ]->end + 1; 68 | $end = $this->bookmarks[ $end_name ]->start; 69 | 70 | $this->seek( $start_name ); // Return to original position. 71 | $this->release_bookmark( $start_name ); 72 | $this->release_bookmark( $end_name ); 73 | 74 | return substr( $this->html, $start, $end - $start ); 75 | } 76 | 77 | /** 78 | * Set the content between two balanced tags. 79 | * 80 | * When called on an opening tag, set the HTML content found between 81 | * that opening tag and its matching closing tag. 82 | * 83 | * @param string $new_html The string to replace the content between the matching tags with. 84 | * @return bool Whether the content was successfully replaced. 85 | */ 86 | public function set_inner_html( $new_html ) { 87 | $this->get_updated_html(); // Apply potential previous updates. 88 | 89 | $bookmarks = $this->get_balanced_tag_bookmarks(); 90 | if ( ! $bookmarks ) { 91 | return false; 92 | } 93 | list( $start_name, $end_name ) = $bookmarks; 94 | 95 | $start = $this->bookmarks[ $start_name ]->end + 1; 96 | $end = $this->bookmarks[ $end_name ]->start; 97 | 98 | $this->seek( $start_name ); // Return to original position. 99 | $this->release_bookmark( $start_name ); 100 | $this->release_bookmark( $end_name ); 101 | 102 | $this->lexical_updates[] = new WP_HTML_Text_Replacement( $start, $end, $new_html ); 103 | return true; 104 | } 105 | 106 | /** 107 | * Return a pair of bookmarks for the current opening tag and the matching closing tag. 108 | * 109 | * @return array|false A pair of bookmarks, or false if there's no matching closing tag. 110 | */ 111 | public function get_balanced_tag_bookmarks() { 112 | $i = 0; 113 | while ( array_key_exists( 'start' . $i, $this->bookmarks ) ) { 114 | ++$i; 115 | } 116 | $start_name = 'start' . $i; 117 | 118 | $this->set_bookmark( $start_name ); 119 | if ( ! $this->next_balanced_closer() ) { 120 | $this->release_bookmark( $start_name ); 121 | return false; 122 | } 123 | 124 | $i = 0; 125 | while ( array_key_exists( 'end' . $i, $this->bookmarks ) ) { 126 | ++$i; 127 | } 128 | $end_name = 'end' . $i; 129 | $this->set_bookmark( $end_name ); 130 | 131 | return array( $start_name, $end_name ); 132 | } 133 | 134 | /** 135 | * Whether a given HTML element is void (e.g.
). 136 | * 137 | * @param string $tag_name The element in question. 138 | * @return bool True if the element is void. 139 | * 140 | * @see https://html.spec.whatwg.org/#elements-2 141 | */ 142 | public static function is_html_void_element( $tag_name ) { 143 | switch ( $tag_name ) { 144 | case 'AREA': 145 | case 'BASE': 146 | case 'BR': 147 | case 'COL': 148 | case 'EMBED': 149 | case 'HR': 150 | case 'IMG': 151 | case 'INPUT': 152 | case 'LINK': 153 | case 'META': 154 | case 'SOURCE': 155 | case 'TRACK': 156 | case 'WBR': 157 | return true; 158 | 159 | default: 160 | return false; 161 | } 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/directives/class-wp-directive-store.php: -------------------------------------------------------------------------------- 1 | $store"; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/directives/utils.php: -------------------------------------------------------------------------------- 1 | $context ) 33 | ); 34 | 35 | if ( strpos( $path, '!' ) === 0 ) { 36 | $path = substr( $path, 1 ); 37 | $has_negation_operator = true; 38 | } 39 | 40 | $array = explode( '.', $path ); 41 | 42 | foreach ( $array as $p ) { 43 | if ( isset( $current[ $p ] ) ) { 44 | $current = $current[ $p ]; 45 | } else { 46 | return null; 47 | } 48 | } 49 | 50 | return isset( $has_negation_operator ) ? ! $current : $current; 51 | } 52 | 53 | 54 | /** 55 | * Set style. 56 | * 57 | * @param string $style Existing style to amend. 58 | * @param string $name Style property name. 59 | * @param string $value Style property value. 60 | * @return string Amended styles. 61 | */ 62 | function set_style( $style, $name, $value ) { 63 | $style_assignments = explode( ';', $style ); 64 | $modified = false; 65 | foreach ( $style_assignments as $style_assignment ) { 66 | list( $style_name ) = explode( ':', $style_assignment ); 67 | if ( trim( $style_name ) === $name ) { 68 | // TODO: Retain surrounding whitespace from $style_value, if any. 69 | $style_assignment = $style_name . ': ' . $value; 70 | $modified = true; 71 | break; 72 | } 73 | } 74 | 75 | if ( ! $modified ) { 76 | $new_style_assignment = $name . ': ' . $value; 77 | // If the last element is empty or whitespace-only, we insert 78 | // the new "key: value" pair before it. 79 | if ( empty( trim( end( $style_assignments ) ) ) ) { 80 | array_splice( $style_assignments, - 1, 0, $new_style_assignment ); 81 | } else { 82 | array_push( $style_assignments, $new_style_assignment ); 83 | } 84 | } 85 | return implode( ';', $style_assignments ); 86 | } 87 | -------------------------------------------------------------------------------- /src/directives/wp-html.php: -------------------------------------------------------------------------------- 1 | next_tag( array( 'tag_closers' => 'visit' ) ) ) { 27 | $tag_name = strtolower( $tags->get_tag() ); 28 | 29 | // Is this a tag that closes the latest opening tag? 30 | if ( $tags->is_tag_closer() ) { 31 | if ( 0 === count( $tag_stack ) ) { 32 | continue; 33 | } 34 | 35 | list( $latest_opening_tag_name, $attributes ) = end( $tag_stack ); 36 | if ( $latest_opening_tag_name === $tag_name ) { 37 | array_pop( $tag_stack ); 38 | 39 | // If the matching opening tag didn't have any attribute directives, 40 | // we move on. 41 | if ( 0 === count( $attributes ) ) { 42 | continue; 43 | } 44 | } 45 | } else { 46 | // Helper that removes the part after the double hyphen before looking for 47 | // the directive processor inside `$attribute_directives`. 48 | $get_directive_type = function ( $attr ) { 49 | return explode( '--', $attr )[0]; 50 | }; 51 | 52 | $attributes = $tags->get_attribute_names_with_prefix( $prefix ); 53 | $attributes = array_map( $get_directive_type, $attributes ); 54 | $attributes = array_intersect( $attributes, array_keys( $directives ) ); 55 | 56 | // If this is an open tag, and if it either has attribute directives, 57 | // or if we're inside a tag that does, take note of this tag and its attribute 58 | // directives so we can call its directive processor once we encounter the 59 | // matching closing tag. 60 | if ( 61 | ! WP_Directive_Processor::is_html_void_element( $tags->get_tag() ) && 62 | ( 0 !== count( $attributes ) || 0 !== count( $tag_stack ) ) 63 | ) { 64 | $tag_stack[] = array( $tag_name, $attributes ); 65 | } 66 | } 67 | 68 | foreach ( $attributes as $attribute ) { 69 | call_user_func( $directives[ $attribute ], $tags, $context ); 70 | } 71 | } 72 | 73 | return $tags; 74 | } 75 | 76 | -------------------------------------------------------------------------------- /src/runtime/constants.js: -------------------------------------------------------------------------------- 1 | export const csnMetaTagItemprop = 'wp-client-side-navigation'; 2 | export const directivePrefix = 'wp'; 3 | -------------------------------------------------------------------------------- /src/runtime/directives.js: -------------------------------------------------------------------------------- 1 | import { useContext, useMemo, useEffect } from 'preact/hooks'; 2 | import { deepSignal, peek } from 'deepsignal'; 3 | import { useSignalEffect } from './utils'; 4 | import { directive } from './hooks'; 5 | import { prefetch, navigate, canDoClientSideNavigation } from './router'; 6 | 7 | // Check if current page can do client-side navigation. 8 | const clientSideNavigation = canDoClientSideNavigation(document.head); 9 | 10 | const isObject = (item) => 11 | item && typeof item === 'object' && !Array.isArray(item); 12 | 13 | const mergeDeepSignals = (target, source) => { 14 | for (const k in source) { 15 | if (typeof peek(target, k) === 'undefined') { 16 | target[`$${k}`] = source[`$${k}`]; 17 | } else if (isObject(peek(target, k)) && isObject(peek(source, k))) { 18 | mergeDeepSignals(target[`$${k}`].peek(), source[`$${k}`].peek()); 19 | } 20 | } 21 | }; 22 | 23 | export default () => { 24 | // data-wp-context 25 | directive( 26 | 'context', 27 | ({ 28 | directives: { 29 | context: { default: context }, 30 | }, 31 | props: { children }, 32 | context: inherited, 33 | }) => { 34 | const { Provider } = inherited; 35 | const inheritedValue = useContext(inherited); 36 | const value = useMemo(() => { 37 | const localValue = deepSignal(context); 38 | mergeDeepSignals(localValue, inheritedValue); 39 | return localValue; 40 | }, [context, inheritedValue]); 41 | 42 | return {children}; 43 | }, 44 | { priority: 5 } 45 | ); 46 | 47 | // data-wp-effect--[name] 48 | directive('effect', ({ directives: { effect }, context, evaluate }) => { 49 | const contextValue = useContext(context); 50 | Object.values(effect).forEach((path) => { 51 | useSignalEffect(() => { 52 | return evaluate(path, { context: contextValue }); 53 | }); 54 | }); 55 | }); 56 | 57 | // data-wp-on--[event] 58 | directive('on', ({ directives: { on }, element, evaluate, context }) => { 59 | const contextValue = useContext(context); 60 | Object.entries(on).forEach(([name, path]) => { 61 | element.props[`on${name}`] = (event) => { 62 | evaluate(path, { event, context: contextValue }); 63 | }; 64 | }); 65 | }); 66 | 67 | // data-wp-class--[classname] 68 | directive( 69 | 'class', 70 | ({ directives: { class: className }, element, evaluate, context }) => { 71 | const contextValue = useContext(context); 72 | Object.keys(className) 73 | .filter((n) => n !== 'default') 74 | .forEach((name) => { 75 | const result = evaluate(className[name], { 76 | className: name, 77 | context: contextValue, 78 | }); 79 | const currentClass = element.props.class || ''; 80 | const classFinder = new RegExp( 81 | `(^|\\s)${name}(\\s|$)`, 82 | 'g' 83 | ); 84 | if (!result) 85 | element.props.class = currentClass 86 | .replace(classFinder, ' ') 87 | .trim(); 88 | else if (!classFinder.test(currentClass)) 89 | element.props.class = currentClass 90 | ? `${currentClass} ${name}` 91 | : name; 92 | 93 | useEffect(() => { 94 | // This seems necessary because Preact doesn't change the class names 95 | // on the hydration, so we have to do it manually. It doesn't need 96 | // deps because it only needs to do it the first time. 97 | if (!result) { 98 | element.ref.current.classList.remove(name); 99 | } else { 100 | element.ref.current.classList.add(name); 101 | } 102 | }, []); 103 | }); 104 | } 105 | ); 106 | 107 | // data-wp-bind--[attribute] 108 | directive( 109 | 'bind', 110 | ({ directives: { bind }, element, context, evaluate }) => { 111 | const contextValue = useContext(context); 112 | Object.entries(bind) 113 | .filter((n) => n !== 'default') 114 | .forEach(([attribute, path]) => { 115 | const result = evaluate(path, { 116 | context: contextValue, 117 | }); 118 | element.props[attribute] = result; 119 | 120 | // This seems necessary because Preact doesn't change the attributes 121 | // on the hydration, so we have to do it manually. It doesn't need 122 | // deps because it only needs to do it the first time. 123 | useEffect(() => { 124 | // aria- and data- attributes have no boolean representation. 125 | // A `false` value is different from the attribute not being 126 | // present, so we can't remove it. 127 | // We follow Preact's logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L131C24-L136 128 | if (result === false && attribute[4] !== '-') { 129 | element.ref.current.removeAttribute(attribute); 130 | } else { 131 | element.ref.current.setAttribute( 132 | attribute, 133 | result === true && attribute[4] !== '-' 134 | ? '' 135 | : result 136 | ); 137 | } 138 | }, []); 139 | }); 140 | } 141 | ); 142 | 143 | // data-wp-link 144 | directive( 145 | 'link', 146 | ({ 147 | directives: { 148 | link: { default: link }, 149 | }, 150 | props: { href }, 151 | element, 152 | }) => { 153 | useEffect(() => { 154 | // Prefetch the page if it is in the directive options. 155 | if (clientSideNavigation && link?.prefetch) { 156 | prefetch(href); 157 | } 158 | }); 159 | 160 | // Don't do anything if it's falsy. 161 | if (clientSideNavigation && link !== false) { 162 | element.props.onclick = async (event) => { 163 | event.preventDefault(); 164 | 165 | // Fetch the page (or return it from cache). 166 | await navigate(href); 167 | 168 | // Update the scroll, depending on the option. True by default. 169 | if (link?.scroll === 'smooth') { 170 | window.scrollTo({ 171 | top: 0, 172 | left: 0, 173 | behavior: 'smooth', 174 | }); 175 | } else if (link?.scroll !== false) { 176 | window.scrollTo(0, 0); 177 | } 178 | }; 179 | } 180 | } 181 | ); 182 | 183 | // data-wp-show 184 | directive( 185 | 'show', 186 | ({ 187 | directives: { 188 | show: { default: show }, 189 | }, 190 | element, 191 | evaluate, 192 | context, 193 | }) => { 194 | const contextValue = useContext(context); 195 | 196 | if (!evaluate(show, { context: contextValue })) 197 | element.props.children = ( 198 | 199 | ); 200 | } 201 | ); 202 | 203 | // data-wp-ignore 204 | directive( 205 | 'ignore', 206 | ({ 207 | element: { 208 | type: Type, 209 | props: { innerHTML, ...rest }, 210 | }, 211 | }) => { 212 | // Preserve the initial inner HTML. 213 | const cached = useMemo(() => innerHTML, []); 214 | return ( 215 | 216 | ); 217 | } 218 | ); 219 | 220 | // data-wp-text 221 | directive( 222 | 'text', 223 | ({ 224 | directives: { 225 | text: { default: text }, 226 | }, 227 | element, 228 | evaluate, 229 | context, 230 | }) => { 231 | const contextValue = useContext(context); 232 | element.props.children = evaluate(text, { context: contextValue }); 233 | } 234 | ); 235 | }; 236 | -------------------------------------------------------------------------------- /src/runtime/hooks.js: -------------------------------------------------------------------------------- 1 | import { h, options, createContext, cloneElement } from 'preact'; 2 | import { useRef, useMemo } from 'preact/hooks'; 3 | import { rawStore as store } from './store'; 4 | 5 | // Main context. 6 | const context = createContext({}); 7 | 8 | // WordPress Directives. 9 | const directiveMap = {}; 10 | const directivePriorities = {}; 11 | export const directive = (name, cb, { priority = 10 } = {}) => { 12 | directiveMap[name] = cb; 13 | directivePriorities[name] = priority; 14 | }; 15 | 16 | // Resolve the path to some property of the store object. 17 | const resolve = (path, ctx) => { 18 | let current = { ...store, context: ctx }; 19 | path.split('.').forEach((p) => (current = current[p])); 20 | return current; 21 | }; 22 | 23 | // Generate the evaluate function. 24 | const getEvaluate = 25 | ({ ref } = {}) => 26 | (path, extraArgs = {}) => { 27 | // If path starts with !, remove it and save a flag. 28 | const hasNegationOperator = path[0] === '!' && !!(path = path.slice(1)); 29 | const value = resolve(path, extraArgs.context); 30 | const returnValue = 31 | typeof value === 'function' 32 | ? value({ 33 | ref: ref.current, 34 | ...store, 35 | ...extraArgs, 36 | }) 37 | : value; 38 | return hasNegationOperator ? !returnValue : returnValue; 39 | }; 40 | 41 | // Separate directives by priority. The resulting array contains objects 42 | // of directives grouped by same priority, and sorted in ascending order. 43 | const usePriorityLevels = (directives) => 44 | useMemo(() => { 45 | const byPriority = Object.entries(directives).reduce( 46 | (acc, [name, values]) => { 47 | const priority = directivePriorities[name]; 48 | if (!acc[priority]) acc[priority] = {}; 49 | acc[priority][name] = values; 50 | 51 | return acc; 52 | }, 53 | {} 54 | ); 55 | 56 | return Object.entries(byPriority) 57 | .sort(([p1], [p2]) => p1 - p2) 58 | .map(([, obj]) => obj); 59 | }, [directives]); 60 | 61 | // Directive wrapper. 62 | const Directive = ({ type, directives, props: originalProps }) => { 63 | const ref = useRef(null); 64 | const element = h(type, { ...originalProps, ref }); 65 | const evaluate = useMemo(() => getEvaluate({ ref }), []); 66 | 67 | // Add wrappers recursively for each priority level. 68 | const byPriorityLevel = usePriorityLevels(directives); 69 | return ( 70 | 76 | ); 77 | }; 78 | 79 | // Priority level wrapper. 80 | const RecursivePriorityLevel = ({ 81 | directives: [directives, ...rest], 82 | element, 83 | evaluate, 84 | originalProps, 85 | }) => { 86 | // This element needs to be a fresh copy so we are not modifying an already 87 | // rendered element with Preact's internal properties initialized. This 88 | // prevents an error with changes in `element.props.children` not being 89 | // reflected in `element.__k`. 90 | element = cloneElement(element); 91 | 92 | // Recursively render the wrapper for the next priority level. 93 | // 94 | // Note that, even though we're instantiating a vnode with a 95 | // `RecursivePriorityLevel` here, its render function will not be executed 96 | // just yet. Actually, it will be delayed until the current render function 97 | // has finished. That ensures directives in the current priorty level have 98 | // run (and thus modified the passed `element`) before the next level. 99 | const children = 100 | rest.length > 0 ? ( 101 | 107 | ) : ( 108 | element 109 | ); 110 | 111 | const props = { ...originalProps, children }; 112 | const directiveArgs = { directives, props, element, context, evaluate }; 113 | 114 | for (const d in directives) { 115 | const wrapper = directiveMap[d]?.(directiveArgs); 116 | if (wrapper !== undefined) props.children = wrapper; 117 | } 118 | 119 | return props.children; 120 | }; 121 | 122 | // Preact Options Hook called each time a vnode is created. 123 | const old = options.vnode; 124 | options.vnode = (vnode) => { 125 | if (vnode.props.__directives) { 126 | const props = vnode.props; 127 | const directives = props.__directives; 128 | delete props.__directives; 129 | vnode.props = { 130 | type: vnode.type, 131 | directives, 132 | props, 133 | }; 134 | vnode.type = Directive; 135 | } 136 | 137 | if (old) old(vnode); 138 | }; 139 | -------------------------------------------------------------------------------- /src/runtime/index.js: -------------------------------------------------------------------------------- 1 | import registerDirectives from './directives'; 2 | import { init } from './router'; 3 | export { store } from './store'; 4 | export { navigate } from './router'; 5 | 6 | /** 7 | * Initialize the Interactivity API. 8 | */ 9 | document.addEventListener('DOMContentLoaded', async () => { 10 | registerDirectives(); 11 | await init(); 12 | // eslint-disable-next-line no-console 13 | console.log('Interactivity API started'); 14 | }); 15 | -------------------------------------------------------------------------------- /src/runtime/router.js: -------------------------------------------------------------------------------- 1 | import { hydrate, render } from 'preact'; 2 | import { toVdom, hydratedIslands } from './vdom'; 3 | import { createRootFragment } from './utils'; 4 | import { csnMetaTagItemprop, directivePrefix } from './constants'; 5 | 6 | // The root to render the vdom (document.body). 7 | let rootFragment; 8 | 9 | // The cache of visited and prefetched pages, stylesheets and scripts. 10 | const pages = new Map(); 11 | const stylesheets = new Map(); 12 | const scripts = new Map(); 13 | 14 | // Helper to remove domain and hash from the URL. We are only interesting in 15 | // caching the path and the query. 16 | const cleanUrl = (url) => { 17 | const u = new URL(url, window.location); 18 | return u.pathname + u.search; 19 | }; 20 | 21 | // Helper to check if a page can do client-side navigation. 22 | export const canDoClientSideNavigation = (dom) => 23 | dom 24 | .querySelector(`meta[itemprop='${csnMetaTagItemprop}']`) 25 | ?.getAttribute('content') === 'active'; 26 | 27 | /** 28 | * Finds the elements in the document that match the selector and fetch them. 29 | * For each element found, fetch the content and store it in the cache. 30 | * Returns an array of elements to add to the document. 31 | * 32 | * @param {Document} document 33 | * @param {string} selector - CSS selector used to find the elements. 34 | * @param {'href'|'src'} attribute - Attribute that determines where to fetch 35 | * the styles or scripts from. Also used as the key for the cache. 36 | * @param {Map} cache - Cache to use for the elements. Can be `stylesheets` or `scripts`. 37 | * @param {'style'|'script'} elementToCreate - Element to create for each fetched 38 | * item. Can be 'style' or 'script'. 39 | * @return {Promise>} - Array of elements to add to the document. 40 | */ 41 | const fetchScriptOrStyle = async ( 42 | document, 43 | selector, 44 | attribute, 45 | cache, 46 | elementToCreate 47 | ) => { 48 | const fetchedItems = await Promise.all( 49 | [].map.call(document.querySelectorAll(selector), (el) => { 50 | const attributeValue = el.getAttribute(attribute); 51 | if (!cache.has(attributeValue)) 52 | cache.set( 53 | attributeValue, 54 | fetch(attributeValue).then((r) => r.text()) 55 | ); 56 | return cache.get(attributeValue); 57 | }) 58 | ); 59 | 60 | return fetchedItems.map((item) => { 61 | const element = document.createElement(elementToCreate); 62 | element.textContent = item; 63 | return element; 64 | }); 65 | }; 66 | 67 | // Fetch styles of a new page. 68 | const fetchAssets = async (document) => { 69 | const stylesFromSheets = await fetchScriptOrStyle( 70 | document, 71 | 'link[rel=stylesheet]', 72 | 'href', 73 | stylesheets, 74 | 'style' 75 | ); 76 | const scriptTags = await fetchScriptOrStyle( 77 | document, 78 | 'script[src]', 79 | 'src', 80 | scripts, 81 | 'script' 82 | ); 83 | const moduleScripts = await fetchScriptOrStyle( 84 | document, 85 | 'script[type=module]', 86 | 'src', 87 | scripts, 88 | 'script' 89 | ); 90 | moduleScripts.forEach((script) => script.setAttribute('type', 'module')); 91 | 92 | return [ 93 | ...scriptTags, 94 | document.querySelector('title'), 95 | ...document.querySelectorAll('style'), 96 | ...stylesFromSheets, 97 | ]; 98 | }; 99 | 100 | // Fetch a new page and convert it to a static virtual DOM. 101 | const fetchPage = async (url) => { 102 | const html = await window.fetch(url).then((r) => r.text()); 103 | const dom = new window.DOMParser().parseFromString(html, 'text/html'); 104 | if (!canDoClientSideNavigation(dom.head)) return false; 105 | const head = await fetchAssets(dom); 106 | return { head, body: toVdom(dom.body) }; 107 | }; 108 | 109 | // Prefetch a page. We store the promise to avoid triggering a second fetch for 110 | // a page if a fetching has already started. 111 | export const prefetch = (url) => { 112 | url = cleanUrl(url); 113 | if (!pages.has(url)) { 114 | pages.set(url, fetchPage(url)); 115 | } 116 | }; 117 | 118 | // Navigate to a new page. 119 | export const navigate = async (href, { replace = false } = {}) => { 120 | const url = cleanUrl(href); 121 | prefetch(url); 122 | const page = await pages.get(url); 123 | if (page) { 124 | document.head.replaceChildren(...page.head); 125 | render(page.body, rootFragment); 126 | window.history[replace ? 'replaceState' : 'pushState']({}, '', href); 127 | } else { 128 | window.location.assign(href); 129 | } 130 | }; 131 | 132 | // Listen to the back and forward buttons and restore the page if it's in the 133 | // cache. 134 | window.addEventListener('popstate', async () => { 135 | const url = cleanUrl(window.location); // Remove hash. 136 | const page = pages.has(url) && (await pages.get(url)); 137 | if (page) { 138 | document.head.replaceChildren(...page.head); 139 | render(page.body, rootFragment); 140 | } else { 141 | window.location.reload(); 142 | } 143 | }); 144 | 145 | // Initialize the router with the initial DOM. 146 | export const init = async () => { 147 | if (canDoClientSideNavigation(document.head)) { 148 | // Create the root fragment to hydrate everything. 149 | rootFragment = createRootFragment( 150 | document.documentElement, 151 | document.body 152 | ); 153 | const body = toVdom(document.body); 154 | hydrate(body, rootFragment); 155 | 156 | // Cache the scripts. Has to be called before fetching the assets. 157 | [].map.call(document.querySelectorAll('script[src]'), (script) => { 158 | scripts.set(script.getAttribute('src'), script.textContent); 159 | }); 160 | 161 | const head = await fetchAssets(document); 162 | pages.set(cleanUrl(window.location), Promise.resolve({ body, head })); 163 | } else { 164 | document 165 | .querySelectorAll(`[data-${directivePrefix}-interactive]`) 166 | .forEach((node) => { 167 | if (!hydratedIslands.has(node)) { 168 | const fragment = createRootFragment(node.parentNode, node); 169 | const vdom = toVdom(node); 170 | hydrate(vdom, fragment); 171 | } 172 | }); 173 | } 174 | }; 175 | -------------------------------------------------------------------------------- /src/runtime/store.js: -------------------------------------------------------------------------------- 1 | import { deepSignal } from 'deepsignal'; 2 | 3 | const isObject = (item) => 4 | item && typeof item === 'object' && !Array.isArray(item); 5 | 6 | export const deepMerge = (target, source) => { 7 | if (isObject(target) && isObject(source)) { 8 | for (const key in source) { 9 | if (isObject(source[key])) { 10 | if (!target[key]) Object.assign(target, { [key]: {} }); 11 | deepMerge(target[key], source[key]); 12 | } else { 13 | Object.assign(target, { [key]: source[key] }); 14 | } 15 | } 16 | } 17 | }; 18 | 19 | const getSerializedState = () => { 20 | // TODO: change the store tag ID for a better one. 21 | const storeTag = document.querySelector( 22 | `script[type="application/json"]#store` 23 | ); 24 | if (!storeTag) return {}; 25 | try { 26 | const { state } = JSON.parse(storeTag.textContent); 27 | if (isObject(state)) return state; 28 | throw Error('Parsed state is not an object'); 29 | } catch (e) { 30 | // eslint-disable-next-line no-console 31 | console.log(e); 32 | } 33 | return {}; 34 | }; 35 | 36 | const rawState = getSerializedState(); 37 | export const rawStore = { state: deepSignal(rawState) }; 38 | 39 | if (typeof window !== 'undefined') window.store = rawStore; 40 | 41 | export const store = ({ state, ...block }) => { 42 | deepMerge(rawStore, block); 43 | deepMerge(rawState, state); 44 | }; 45 | -------------------------------------------------------------------------------- /src/runtime/utils.js: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect } from 'preact/hooks'; 2 | import { effect } from '@preact/signals'; 3 | 4 | function afterNextFrame(callback) { 5 | const done = () => { 6 | cancelAnimationFrame(raf); 7 | setTimeout(callback); 8 | }; 9 | const raf = requestAnimationFrame(done); 10 | } 11 | 12 | // Using the mangled properties: 13 | // this.c: this._callback 14 | // this.x: this._compute 15 | // https://github.com/preactjs/signals/blob/main/mangle.json 16 | function createFlusher(compute, notify) { 17 | let flush; 18 | const dispose = effect(function () { 19 | flush = this.c.bind(this); 20 | this.x = compute; 21 | this.c = notify; 22 | return compute(); 23 | }); 24 | return { flush, dispose }; 25 | } 26 | 27 | // Version of `useSignalEffect` with a `useEffect`-like execution. This hook 28 | // implementation comes from this PR: 29 | // https://github.com/preactjs/signals/pull/290. 30 | // 31 | // We need to include it here in this repo until the mentioned PR is merged. 32 | export function useSignalEffect(cb) { 33 | const callback = useRef(cb); 34 | callback.current = cb; 35 | 36 | useEffect(() => { 37 | const execute = () => callback.current(); 38 | const notify = () => afterNextFrame(eff.flush); 39 | const eff = createFlusher(execute, notify); 40 | return eff.dispose; 41 | }, []); 42 | } 43 | 44 | // For wrapperless hydration. 45 | // See https://gist.github.com/developit/f4c67a2ede71dc2fab7f357f39cff28c 46 | export const createRootFragment = (parent, replaceNode) => { 47 | replaceNode = [].concat(replaceNode); 48 | const s = replaceNode[replaceNode.length - 1].nextSibling; 49 | function insert(c, r) { 50 | parent.insertBefore(c, r || s); 51 | } 52 | return (parent.__k = { 53 | nodeType: 1, 54 | parentNode: parent, 55 | firstChild: replaceNode[0], 56 | childNodes: replaceNode, 57 | insertBefore: insert, 58 | appendChild: insert, 59 | removeChild(c) { 60 | parent.removeChild(c); 61 | }, 62 | }); 63 | }; 64 | -------------------------------------------------------------------------------- /src/runtime/vdom.js: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { directivePrefix as p } from './constants'; 3 | 4 | const ignoreAttr = `data-${p}-ignore`; 5 | const islandAttr = `data-${p}-interactive`; 6 | const fullPrefix = `data-${p}-`; 7 | 8 | // Regular expression for directive parsing. 9 | const directiveParser = new RegExp( 10 | `^data-${p}-` + // ${p} must be a prefix string, like 'wp'. 11 | // Match alphanumeric characters including hyphen-separated 12 | // segments. It excludes underscore intentionally to prevent confusion. 13 | // E.g., "custom-directive". 14 | '([a-z0-9]+(?:-[a-z0-9]+)*)' + 15 | // (Optional) Match '--' followed by any alphanumeric charachters. It 16 | // excludes underscore intentionally to prevent confusion, but it can 17 | // contain multiple hyphens. E.g., "--custom-prefix--with-more-info". 18 | '(?:--([a-z0-9][a-z0-9-]+))?$', 19 | 'i' // Case insensitive. 20 | ); 21 | 22 | export const hydratedIslands = new WeakSet(); 23 | 24 | // Recursive function that transforms a DOM tree into vDOM. 25 | export function toVdom(root) { 26 | const treeWalker = document.createTreeWalker( 27 | root, 28 | 205 // ELEMENT + TEXT + COMMENT + CDATA_SECTION + PROCESSING_INSTRUCTION 29 | ); 30 | 31 | function walk(node) { 32 | const { attributes, nodeType } = node; 33 | 34 | if (nodeType === 3) return [node.data]; 35 | if (nodeType === 4) { 36 | const next = treeWalker.nextSibling(); 37 | node.replaceWith(new Text(node.nodeValue)); 38 | return [node.nodeValue, next]; 39 | } 40 | if (nodeType === 8 || nodeType === 7) { 41 | const next = treeWalker.nextSibling(); 42 | node.remove(); 43 | return [null, next]; 44 | } 45 | 46 | const props = {}; 47 | const children = []; 48 | const directives = {}; 49 | let hasDirectives = false; 50 | let ignore = false; 51 | let island = false; 52 | 53 | for (let i = 0; i < attributes.length; i++) { 54 | const n = attributes[i].name; 55 | if ( 56 | n[fullPrefix.length] && 57 | n.slice(0, fullPrefix.length) === fullPrefix 58 | ) { 59 | if (n === ignoreAttr) { 60 | ignore = true; 61 | } else if (n === islandAttr) { 62 | island = true; 63 | } else { 64 | hasDirectives = true; 65 | let val = attributes[i].value; 66 | try { 67 | val = JSON.parse(val); 68 | } catch (e) {} 69 | const [, prefix, suffix] = directiveParser.exec(n); 70 | directives[prefix] = directives[prefix] || {}; 71 | directives[prefix][suffix || 'default'] = val; 72 | } 73 | } else if (n === 'ref') { 74 | continue; 75 | } 76 | props[n] = attributes[i].value; 77 | } 78 | 79 | if (ignore && !island) 80 | return [ 81 | h(node.localName, { 82 | ...props, 83 | innerHTML: node.innerHTML, 84 | __directives: { ignore: true }, 85 | }), 86 | ]; 87 | if (island) hydratedIslands.add(node); 88 | 89 | if (hasDirectives) props.__directives = directives; 90 | 91 | let child = treeWalker.firstChild(); 92 | if (child) { 93 | while (child) { 94 | const [vnode, nextChild] = walk(child); 95 | if (vnode) children.push(vnode); 96 | child = nextChild || treeWalker.nextSibling(); 97 | } 98 | treeWalker.parentNode(); 99 | } 100 | 101 | return [h(node.localName, props, children)]; 102 | } 103 | 104 | return walk(treeWalker.currentNode); 105 | } 106 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const defaultConfig = require('@wordpress/scripts/config/webpack.config'); 2 | const { resolve } = require('path'); 3 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 4 | 5 | module.exports = [ 6 | defaultConfig, 7 | { 8 | ...defaultConfig, 9 | entry: { 10 | runtime: './src/runtime', 11 | 'e2e/page-1': './e2e/page-1', 12 | 'e2e/page-2': './e2e/page-2', 13 | 'e2e/directive-priorities': './e2e/js/directive-priorities', 14 | 'e2e/directive-bind': './e2e/js/directive-bind', 15 | 'e2e/directive-effect': './e2e/js/directive-effect', 16 | 'e2e/negation-operator': './e2e/js/negation-operator', 17 | }, 18 | output: { 19 | filename: '[name].js', 20 | path: resolve(process.cwd(), 'build'), 21 | library: { 22 | name: '__experimentalInteractivity', 23 | type: 'window', 24 | }, 25 | }, 26 | optimization: { 27 | runtimeChunk: { 28 | name: 'vendors', 29 | }, 30 | splitChunks: { 31 | cacheGroups: { 32 | vendors: { 33 | test: /[\\/]node_modules[\\/]/, 34 | name: 'vendors', 35 | minSize: 0, 36 | chunks: 'all', 37 | }, 38 | }, 39 | }, 40 | }, 41 | module: { 42 | rules: [ 43 | { 44 | test: /\.(j|t)sx?$/, 45 | exclude: /node_modules/, 46 | use: [ 47 | { 48 | loader: require.resolve('babel-loader'), 49 | options: { 50 | cacheDirectory: 51 | process.env.BABEL_CACHE_DIRECTORY || true, 52 | babelrc: false, 53 | configFile: false, 54 | presets: [ 55 | [ 56 | '@babel/preset-react', 57 | { 58 | runtime: 'automatic', 59 | importSource: 'preact', 60 | }, 61 | ], 62 | ], 63 | }, 64 | }, 65 | ], 66 | }, 67 | { 68 | test: /\.css$/i, 69 | use: [MiniCssExtractPlugin.loader, 'css-loader'], 70 | }, 71 | ], 72 | }, 73 | plugins: [new MiniCssExtractPlugin()], 74 | }, 75 | ]; 76 | -------------------------------------------------------------------------------- /wp-directives.php: -------------------------------------------------------------------------------- 1 |

%s

', 27 | __( 28 | 'This plugin requires the Gutenberg plugin to be installed and activated.', 29 | 'wp-directives' 30 | ) 31 | ); 32 | } 33 | ); 34 | 35 | // Deactivate the plugin. 36 | deactivate_plugins( plugin_basename( __FILE__ ) ); 37 | return; 38 | } 39 | 40 | require_once __DIR__ . '/src/directives/wp-html.php'; 41 | 42 | require_once __DIR__ . '/src/directives/class-wp-directive-context.php'; 43 | require_once __DIR__ . '/src/directives/class-wp-directive-store.php'; 44 | require_once __DIR__ . '/src/directives/wp-process-directives.php'; 45 | 46 | require_once __DIR__ . '/src/directives/attributes/wp-bind.php'; 47 | require_once __DIR__ . '/src/directives/attributes/wp-context.php'; 48 | require_once __DIR__ . '/src/directives/attributes/wp-class.php'; 49 | require_once __DIR__ . '/src/directives/attributes/wp-html.php'; 50 | require_once __DIR__ . '/src/directives/attributes/wp-style.php'; 51 | require_once __DIR__ . '/src/directives/attributes/wp-text.php'; 52 | 53 | /** 54 | * Load includes. 55 | */ 56 | function wp_directives_loader() { 57 | // Load the Admin page. 58 | require_once plugin_dir_path( __FILE__ ) . '/src/admin/admin-page.php'; 59 | } 60 | add_action( 'plugins_loaded', 'wp_directives_loader' ); 61 | 62 | /** 63 | * Add default settings upon activation. 64 | */ 65 | function wp_directives_activate() { 66 | add_option( 67 | 'wp_directives_plugin_settings', 68 | array( 69 | 'client_side_navigation' => false, 70 | ) 71 | ); 72 | } 73 | register_activation_hook( __FILE__, 'wp_directives_activate' ); 74 | 75 | /** 76 | * Delete settings on uninstall. 77 | */ 78 | function wp_directives_uninstall() { 79 | delete_option( 'wp_directives_plugin_settings' ); 80 | } 81 | register_uninstall_hook( __FILE__, 'wp_directives_uninstall' ); 82 | 83 | /** 84 | * Register the scripts 85 | */ 86 | function wp_directives_register_scripts() { 87 | wp_register_script( 88 | 'wp-directive-vendors', 89 | plugins_url( 'build/vendors.js', __FILE__ ), 90 | array(), 91 | '1.0.0', 92 | true 93 | ); 94 | wp_register_script( 95 | 'wp-directive-runtime', 96 | plugins_url( 'build/runtime.js', __FILE__ ), 97 | array( 'wp-directive-vendors' ), 98 | '1.0.0', 99 | true 100 | ); 101 | 102 | // For now we can always enqueue the runtime. We'll figure out how to 103 | // conditionally enqueue directives later. 104 | wp_enqueue_script( 'wp-directive-runtime' ); 105 | } 106 | add_action( 'wp_enqueue_scripts', 'wp_directives_register_scripts' ); 107 | 108 | /** 109 | * Add data-wp-link attribute. 110 | * 111 | * @param string $block_content Block content. 112 | * @return string Filtered block content. 113 | */ 114 | function wp_directives_add_wp_link_attribute( $block_content ) { 115 | $site_url = parse_url( get_site_url() ); 116 | $w = new WP_HTML_Tag_Processor( $block_content ); 117 | while ( $w->next_tag( 'a' ) ) { 118 | if ( $w->get_attribute( 'target' ) === '_blank' ) { 119 | break; 120 | } 121 | 122 | $link = parse_url( $w->get_attribute( 'href' ) ); 123 | if ( ! isset( $link['host'] ) || $link['host'] === $site_url['host'] ) { 124 | $classes = $w->get_attribute( 'class' ); 125 | if ( 126 | str_contains( $classes, 'query-pagination' ) || 127 | str_contains( $classes, 'page-numbers' ) 128 | ) { 129 | $w->set_attribute( 130 | 'data-wp-link', 131 | '{ "prefetch": true, "scroll": false }' 132 | ); 133 | } else { 134 | $w->set_attribute( 'data-wp-link', '{ "prefetch": true }' ); 135 | } 136 | } 137 | } 138 | return (string) $w; 139 | } 140 | // We go only through the Query Loops and the template parts until we find a better solution. 141 | add_filter( 142 | 'render_block_core/query', 143 | 'wp_directives_add_wp_link_attribute', 144 | 10, 145 | 1 146 | ); 147 | add_filter( 148 | 'render_block_core/template-part', 149 | 'wp_directives_add_wp_link_attribute', 150 | 10, 151 | 1 152 | ); 153 | 154 | /** 155 | * Check whether client-side navigation has been opted into. 156 | * 157 | * @return bool Whether client-side navigation is enabled. 158 | */ 159 | function wp_directives_get_client_side_navigation() { 160 | static $client_side_navigation = null; 161 | if ( is_null( $client_side_navigation ) ) { 162 | $client_side_navigation = (bool) apply_filters( 'client_side_navigation', false ); 163 | } 164 | return $client_side_navigation; 165 | } 166 | 167 | /** 168 | * Print client-side navigation meta tag if enabled. 169 | */ 170 | function wp_directives_add_client_side_navigation_meta_tag() { 171 | if ( wp_directives_get_client_side_navigation() ) { 172 | echo ''; 173 | } 174 | } 175 | add_action( 'wp_head', 'wp_directives_add_client_side_navigation_meta_tag' ); 176 | 177 | /** 178 | * Obtain client-side navigation option. 179 | * 180 | * @return bool Whether client-side navigation is enabled. 181 | */ 182 | function wp_directives_client_site_navigation_option() { 183 | $options = get_option( 'wp_directives_plugin_settings' ); 184 | return (bool) $options['client_side_navigation']; 185 | } 186 | add_filter( 187 | 'client_side_navigation', 188 | 'wp_directives_client_site_navigation_option', 189 | 9 190 | ); 191 | 192 | /** 193 | * Mark interactive blocks if client-side navigation is enabled. 194 | * 195 | * @param string $block_content Block content. 196 | * @param array $block Block. 197 | * @param WP_Block $instance Block instance. 198 | * @return string Filtered block. 199 | */ 200 | function wp_directives_mark_interactive_blocks( $block_content, $block, $instance ) { 201 | if ( wp_directives_get_client_side_navigation() ) { 202 | return $block_content; 203 | } 204 | 205 | // Append the `data-wp-ignore` attribute for inner blocks of interactive blocks. 206 | if ( isset( $instance->parsed_block['isolated'] ) ) { 207 | $w = new WP_HTML_Tag_Processor( $block_content ); 208 | $w->next_tag(); 209 | $w->set_attribute( 'data-wp-ignore', '' ); 210 | $block_content = (string) $w; 211 | } 212 | 213 | // Return if it's not interactive. 214 | if ( ! block_has_support( $instance->block_type, array( 'interactivity' ) ) ) { 215 | return $block_content; 216 | } 217 | 218 | // Add the `data-wp-interactive` attribute if it's interactive. 219 | $w = new WP_HTML_Tag_Processor( $block_content ); 220 | $w->next_tag(); 221 | $w->set_attribute( 'data-wp-interactive', '' ); 222 | 223 | return (string) $w; 224 | } 225 | add_filter( 'render_block', 'wp_directives_mark_interactive_blocks', 10, 3 ); 226 | 227 | /** 228 | * Add a flag to mark inner blocks of isolated interactive blocks. 229 | * 230 | * @param array $parsed_block Parsed block. 231 | * @param array $source_block Source block. 232 | * @param WP_Block|null $parent_block Parent block. 233 | */ 234 | function wp_directives_inner_blocks( $parsed_block, $source_block, $parent_block ) { 235 | if ( 236 | isset( $parent_block ) && 237 | block_has_support( 238 | $parent_block->block_type, 239 | array( 240 | 'interactivity', 241 | 'isolated', 242 | ) 243 | ) 244 | ) { 245 | $parsed_block['isolated'] = true; 246 | } 247 | return $parsed_block; 248 | } 249 | add_filter( 'render_block_data', 'wp_directives_inner_blocks', 10, 3 ); 250 | 251 | /** 252 | * Process directives in block. 253 | * 254 | * @param string $block_content Block content. 255 | * @return string Filtered block content. 256 | */ 257 | function process_directives_in_block( $block_content ) { 258 | // TODO: Add some directive/components registration mechanism. 259 | $directives = array( 260 | 'data-wp-context' => 'process_wp_context', 261 | 'data-wp-bind' => 'process_wp_bind', 262 | 'data-wp-class' => 'process_wp_class', 263 | 'data-wp-html' => 'process_wp_html', 264 | 'data-wp-style' => 'process_wp_style', 265 | 'data-wp-text' => 'process_wp_text', 266 | ); 267 | 268 | $tags = new WP_Directive_Processor( $block_content ); 269 | $tags = wp_process_directives( $tags, 'data-wp-', $directives ); 270 | return $tags->get_updated_html(); 271 | } 272 | add_filter( 273 | 'render_block', 274 | 'process_directives_in_block', 275 | 10, 276 | 1 277 | ); 278 | 279 | // TODO: check if priority 9 is enough. 280 | // TODO: check if `wp_footer` is the most appropriate hook. 281 | add_action( 'wp_footer', array( 'WP_Directive_Store', 'render' ), 9 ); 282 | --------------------------------------------------------------------------------