├── .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 |
20 | Toggle trueValue
21 |
22 |
23 |
30 |
31 |
35 | Replace with page 3
36 |
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 |
45 | Update
46 |
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 |
12 | Toggle trueValue
13 |
14 |
15 |
19 | Toggle falseValue
20 |
21 |
22 |
27 |
28 |
33 |
34 |
41 |
42 |
48 |
49 |
54 |
55 |
59 |
60 |
61 |
66 |
70 | Toggle context falseValue
71 |
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 |
24 | prop1
25 |
26 |
32 | prop2
33 |
34 |
40 | obj.prop4
41 |
42 |
48 | obj.prop5
49 |
50 |
53 |
57 |
58 |
59 |
65 | prop1
66 |
67 |
73 | prop2
74 |
75 |
81 | prop3
82 |
83 |
89 | obj.prop4
90 |
91 |
97 | obj.prop5
98 |
99 |
105 | obj.prop6
106 |
107 |
108 |
109 |
110 |
117 | Toggle Context Text
118 |
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 |
24 | Update
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/e2e/html/directives-show.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Directives -- data-wp-show
5 |
6 |
7 |
8 |
12 | Toggle trueValue
13 |
14 |
15 |
19 | Toggle falseValue
20 |
21 |
22 |
27 |
trueValue children
28 |
29 |
30 |
34 |
falseValue children
35 |
36 |
37 |
38 |
42 | falseValue
43 |
44 |
48 | Toggle context falseValue
49 |
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 |
17 | Toggle State Text
18 |
19 |
20 |
21 |
22 |
26 |
30 | Toggle Context Text
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/e2e/html/negation-operator.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Negation Operator
5 |
6 |
7 |
8 |
12 | Toggle Active Value
13 |
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 |
28 | +1
29 |
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 |
28 | +1
29 |
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 |
28 | +1
29 |
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 |
28 | +1
29 |
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 |
19 |
20 |
38 |
39 |
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 | Update attribute
88 | Update text
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 | outsideinside
';
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 |
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 | {element.props.children}
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 |
--------------------------------------------------------------------------------