diff --git a/.github/workflows/install-testing.yml b/.github/workflows/install-testing.yml index f820c214df16d..d35b25c08bd8b 100644 --- a/.github/workflows/install-testing.yml +++ b/.github/workflows/install-testing.yml @@ -141,7 +141,7 @@ jobs: steps: - name: Set up PHP ${{ matrix.php }} - uses: shivammathur/setup-php@a4e22b60bbb9c1021113f2860347b0759f66fe5d # v2.30.0 + uses: shivammathur/setup-php@c541c155eee45413f5b09a52248675b1a2575231 # v2.31.1 with: php-version: '${{ matrix.php }}' coverage: none diff --git a/.github/workflows/props-bot.yml b/.github/workflows/props-bot.yml index 5b0ba5c3c4515..548f9023a92e2 100644 --- a/.github/workflows/props-bot.yml +++ b/.github/workflows/props-bot.yml @@ -18,7 +18,7 @@ on: # You cannot filter this event for PR comments only. # However, the logic below does short-circuit the workflow for issues. issue_comment: - type: + types: - created # This event will run everytime a new PR review is initially submitted. pull_request_review: diff --git a/.github/workflows/pull-request-comments.yml b/.github/workflows/pull-request-comments.yml index 189eb1c1737d0..59c73db4e4131 100644 --- a/.github/workflows/pull-request-comments.yml +++ b/.github/workflows/pull-request-comments.yml @@ -195,7 +195,7 @@ jobs: const prBody = pr.body ?? ''; const prTitle = pr.title ?? ''; - const tracTicketRegex = new RegExp( 'https?://core.trac.wordpress.org/ticket/([0-9]+)', 'g' ); + const tracTicketRegex = new RegExp( '(https?://core.trac.wordpress.org/ticket/|Core-|ticket:)([0-9]+)', 'g' ); const tracTicketMatches = prBody.match( tracTicketRegex ) || prTitle.match( tracTicketRegex ); if ( ! tracTicketMatches ) { diff --git a/.github/workflows/reusable-coding-standards-javascript.yml b/.github/workflows/reusable-coding-standards-javascript.yml index a424f80630887..e36e2f592081c 100644 --- a/.github/workflows/reusable-coding-standards-javascript.yml +++ b/.github/workflows/reusable-coding-standards-javascript.yml @@ -35,7 +35,7 @@ jobs: show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} - name: Set up Node.js - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 with: node-version-file: '.nvmrc' cache: npm diff --git a/.github/workflows/reusable-coding-standards-php.yml b/.github/workflows/reusable-coding-standards-php.yml index 296b61bcbd499..5aea737c5eb83 100644 --- a/.github/workflows/reusable-coding-standards-php.yml +++ b/.github/workflows/reusable-coding-standards-php.yml @@ -47,7 +47,7 @@ jobs: show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} - name: Set up PHP - uses: shivammathur/setup-php@a4e22b60bbb9c1021113f2860347b0759f66fe5d # v2.30.0 + uses: shivammathur/setup-php@c541c155eee45413f5b09a52248675b1a2575231 # v2.31.1 with: php-version: ${{ inputs.php-version }} coverage: none @@ -60,7 +60,7 @@ jobs: run: echo "date=$(/bin/date -u --date='last Mon' "+%F")" >> $GITHUB_OUTPUT - name: Cache PHPCS scan cache - uses: actions/cache@ab5e6d0c87105b4c9c2047343972218f562e4319 # v4.0.1 + uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2 with: path: | .cache/phpcs-src.json diff --git a/.github/workflows/reusable-end-to-end-tests.yml b/.github/workflows/reusable-end-to-end-tests.yml index 768f31ef97d00..1d980551b48c6 100644 --- a/.github/workflows/reusable-end-to-end-tests.yml +++ b/.github/workflows/reusable-end-to-end-tests.yml @@ -68,7 +68,7 @@ jobs: show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} - name: Set up Node.js - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 with: node-version-file: '.nvmrc' cache: npm @@ -125,7 +125,7 @@ jobs: run: npm run test:e2e - name: Archive debug artifacts (screenshots, HTML snapshots) - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@0b2256b8c012f0828dc542b3febcab082c67f72b # v4.3.4 if: always() with: name: failures-artifacts${{ inputs.LOCAL_SCRIPT_DEBUG && '-SCRIPT_DEBUG' || '' }}-${{ github.run_id }} diff --git a/.github/workflows/reusable-javascript-tests.yml b/.github/workflows/reusable-javascript-tests.yml index 0d7b674fb932a..7adee1161c840 100644 --- a/.github/workflows/reusable-javascript-tests.yml +++ b/.github/workflows/reusable-javascript-tests.yml @@ -30,7 +30,7 @@ jobs: show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} - name: Set up Node.js - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 with: node-version-file: '.nvmrc' cache: npm diff --git a/.github/workflows/reusable-performance.yml b/.github/workflows/reusable-performance.yml index a97aceb7e72a9..bf6b31fc17a90 100644 --- a/.github/workflows/reusable-performance.yml +++ b/.github/workflows/reusable-performance.yml @@ -118,7 +118,7 @@ jobs: run: echo "TARGET_SHA=$(git rev-parse HEAD^1)" >> $GITHUB_ENV - name: Set up Node.js - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 with: node-version-file: '.nvmrc' cache: npm @@ -281,7 +281,7 @@ jobs: run: npm run test:performance - name: Archive artifacts - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@0b2256b8c012f0828dc542b3febcab082c67f72b # v4.3.4 if: always() with: name: performance-artifacts${{ inputs.memcached && '-memcached' || '' }}-${{ github.run_id }} diff --git a/.github/workflows/reusable-php-compatibility.yml b/.github/workflows/reusable-php-compatibility.yml index 1a6a6b128747b..6171c3467e8e4 100644 --- a/.github/workflows/reusable-php-compatibility.yml +++ b/.github/workflows/reusable-php-compatibility.yml @@ -41,7 +41,7 @@ jobs: show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} - name: Set up PHP - uses: shivammathur/setup-php@a4e22b60bbb9c1021113f2860347b0759f66fe5d # v2.30.0 + uses: shivammathur/setup-php@c541c155eee45413f5b09a52248675b1a2575231 # v2.31.1 with: php-version: ${{ inputs.php-version }} coverage: none @@ -58,7 +58,7 @@ jobs: run: echo "date=$(/bin/date -u --date='last Mon' "+%F")" >> $GITHUB_OUTPUT - name: Cache PHP compatibility scan cache - uses: actions/cache@ab5e6d0c87105b4c9c2047343972218f562e4319 # v4.0.1 + uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2 with: path: .cache/phpcompat.json key: ${{ runner.os }}-date-${{ steps.get-date.outputs.date }}-php-${{ inputs.php-version }}-phpcompat-cache-${{ hashFiles('**/composer.json', 'phpcompat.xml.dist') }} diff --git a/.github/workflows/reusable-phpunit-tests-v1.yml b/.github/workflows/reusable-phpunit-tests-v1.yml index 8331d863f8258..621e9eb045b74 100644 --- a/.github/workflows/reusable-phpunit-tests-v1.yml +++ b/.github/workflows/reusable-phpunit-tests-v1.yml @@ -95,7 +95,7 @@ jobs: show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} - name: Set up Node.js - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 with: node-version-file: '.nvmrc' cache: npm diff --git a/.github/workflows/reusable-phpunit-tests-v2.yml b/.github/workflows/reusable-phpunit-tests-v2.yml index e9539d377ea03..a9cb221664c14 100644 --- a/.github/workflows/reusable-phpunit-tests-v2.yml +++ b/.github/workflows/reusable-phpunit-tests-v2.yml @@ -98,7 +98,7 @@ jobs: show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} - name: Install Node.js - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 with: node-version-file: '.nvmrc' cache: npm diff --git a/.github/workflows/reusable-phpunit-tests-v3.yml b/.github/workflows/reusable-phpunit-tests-v3.yml index ed0e8f7b13cba..c9387d06a8451 100644 --- a/.github/workflows/reusable-phpunit-tests-v3.yml +++ b/.github/workflows/reusable-phpunit-tests-v3.yml @@ -103,7 +103,7 @@ jobs: show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} - name: Set up Node.js - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 with: node-version-file: '.nvmrc' cache: npm @@ -116,7 +116,7 @@ jobs: # dependency versions are installed and cached. ## - name: Set up PHP - uses: shivammathur/setup-php@a4e22b60bbb9c1021113f2860347b0759f66fe5d # v2.30.0 + uses: shivammathur/setup-php@c541c155eee45413f5b09a52248675b1a2575231 # v2.31.1 with: php-version: '${{ inputs.php }}' coverage: none diff --git a/.github/workflows/reusable-test-core-build-process.yml b/.github/workflows/reusable-test-core-build-process.yml index 0df177b7d786f..6d1c39ce37584 100644 --- a/.github/workflows/reusable-test-core-build-process.yml +++ b/.github/workflows/reusable-test-core-build-process.yml @@ -63,7 +63,7 @@ jobs: show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} - name: Set up Node.js - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 with: node-version-file: '.nvmrc' cache: npm @@ -101,7 +101,7 @@ jobs: run: git diff --exit-code - name: Upload ZIP as a GitHub Actions artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@0b2256b8c012f0828dc542b3febcab082c67f72b # v4.3.4 if: ${{ inputs.save-build || inputs.prepare-playground }} with: name: wordpress-build-${{ github.event_name == 'pull_request' && github.event.number || github.sha }} @@ -117,7 +117,7 @@ jobs: # Uploads the PR number as an artifact for the Pull Request Commenting workflow to download and then # leave a comment detailing how to test the PR within WordPress Playground. - name: Upload PR number as artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@0b2256b8c012f0828dc542b3febcab082c67f72b # v4.3.4 if: ${{ inputs.prepare-playground && github.repository == 'WordPress/wordpress-develop' && github.event_name == 'pull_request' }} with: name: pr-number diff --git a/.github/workflows/reusable-test-gutenberg-build-process.yml b/.github/workflows/reusable-test-gutenberg-build-process.yml index a72e616bc9446..7765453f6157e 100644 --- a/.github/workflows/reusable-test-gutenberg-build-process.yml +++ b/.github/workflows/reusable-test-gutenberg-build-process.yml @@ -55,7 +55,7 @@ jobs: show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} - name: Set up Node.js - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 with: node-version-file: '.nvmrc' cache: npm diff --git a/.github/workflows/reusable-upgrade-testing.yml b/.github/workflows/reusable-upgrade-testing.yml index 8e467c20ccbc9..6b285d190c6a3 100644 --- a/.github/workflows/reusable-upgrade-testing.yml +++ b/.github/workflows/reusable-upgrade-testing.yml @@ -62,7 +62,7 @@ jobs: steps: - name: Set up PHP ${{ inputs.php }} - uses: shivammathur/setup-php@a4e22b60bbb9c1021113f2860347b0759f66fe5d # v2.30.0 + uses: shivammathur/setup-php@c541c155eee45413f5b09a52248675b1a2575231 # v2.31.1 with: php-version: '${{ inputs.php }}' coverage: none diff --git a/.github/workflows/slack-notifications.yml b/.github/workflows/slack-notifications.yml index aab3a85147bc0..4ae4e52df569d 100644 --- a/.github/workflows/slack-notifications.yml +++ b/.github/workflows/slack-notifications.yml @@ -167,7 +167,7 @@ jobs: steps: - name: Post failure notifications to Slack - uses: slackapi/slack-github-action@6c661ce58804a1a20f6dc5fbee7f0381b469e001 # v1.25.0 + uses: slackapi/slack-github-action@70cd7be8e40a46e8b0eced40b0de447bdb42f68e # v1.26.0 with: payload: ${{ needs.prepare.outputs.payload }} env: @@ -183,7 +183,7 @@ jobs: steps: - name: Post failure notifications to Slack - uses: slackapi/slack-github-action@6c661ce58804a1a20f6dc5fbee7f0381b469e001 # v1.25.0 + uses: slackapi/slack-github-action@70cd7be8e40a46e8b0eced40b0de447bdb42f68e # v1.26.0 with: payload: ${{ needs.prepare.outputs.payload }} env: @@ -199,7 +199,7 @@ jobs: steps: - name: Post success notifications to Slack - uses: slackapi/slack-github-action@6c661ce58804a1a20f6dc5fbee7f0381b469e001 # v1.25.0 + uses: slackapi/slack-github-action@70cd7be8e40a46e8b0eced40b0de447bdb42f68e # v1.26.0 with: payload: ${{ needs.prepare.outputs.payload }} env: @@ -215,7 +215,7 @@ jobs: steps: - name: Post cancelled notifications to Slack - uses: slackapi/slack-github-action@6c661ce58804a1a20f6dc5fbee7f0381b469e001 # v1.25.0 + uses: slackapi/slack-github-action@70cd7be8e40a46e8b0eced40b0de447bdb42f68e # v1.26.0 with: payload: ${{ needs.prepare.outputs.payload }} env: diff --git a/.github/workflows/test-and-zip-default-themes.yml b/.github/workflows/test-and-zip-default-themes.yml index 31e4800cfa44d..74800a4ace8bf 100644 --- a/.github/workflows/test-and-zip-default-themes.yml +++ b/.github/workflows/test-and-zip-default-themes.yml @@ -131,7 +131,7 @@ jobs: show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} - name: Set up Node.js - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 with: node-version-file: '.nvmrc' cache: npm @@ -187,7 +187,7 @@ jobs: show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} - name: Upload theme ZIP as an artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@0b2256b8c012f0828dc542b3febcab082c67f72b # v4.3.4 with: if-no-files-found: error name: ${{ matrix.theme }} diff --git a/.github/workflows/test-coverage.yml b/.github/workflows/test-coverage.yml index 042ab8d6065df..8c8655b9a9fc1 100644 --- a/.github/workflows/test-coverage.yml +++ b/.github/workflows/test-coverage.yml @@ -81,7 +81,7 @@ jobs: show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} - name: Set up Node.js - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 with: node-version-file: '.nvmrc' cache: npm @@ -94,7 +94,7 @@ jobs: # dependency versions are installed and cached. ## - name: Set up PHP - uses: shivammathur/setup-php@a4e22b60bbb9c1021113f2860347b0759f66fe5d # v2.30.0 + uses: shivammathur/setup-php@c541c155eee45413f5b09a52248675b1a2575231 # v2.31.1 with: php-version: '7.4' coverage: none @@ -151,7 +151,7 @@ jobs: - name: Upload single site report to Codecov if: ${{ ! matrix.multisite && matrix.format == 'clover' && github.event_name != 'pull_request' }} - uses: codecov/codecov-action@54bcd8715eee62d40e33596ef5e8f0f48dbbccab # v4.1.0 + uses: codecov/codecov-action@e28ff129e5465c2c0dcc6f003fc735cb6ae0c673 # v4.5.0 with: token: ${{ secrets.CODECOV_TOKEN }} file: wp-code-coverage-single-${{ github.sha }}${{ 'clover' == matrix.format && '.xml' || '' }} @@ -160,7 +160,7 @@ jobs: - name: Upload single site HTML report as artifact if: ${{ ! matrix.multisite && matrix.format == 'html' }} - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@0b2256b8c012f0828dc542b3febcab082c67f72b # v4.3.4 with: name: wp-code-coverage-single-${{ github.sha }} path: wp-code-coverage-single-${{ github.sha }} @@ -175,7 +175,7 @@ jobs: - name: Upload multisite report to Codecov if: ${{ matrix.multisite && matrix.format == 'clover' && github.event_name != 'pull_request' }} - uses: codecov/codecov-action@54bcd8715eee62d40e33596ef5e8f0f48dbbccab # v4.1.0 + uses: codecov/codecov-action@e28ff129e5465c2c0dcc6f003fc735cb6ae0c673 # v4.5.0 with: token: ${{ secrets.CODECOV_TOKEN }} file: wp-code-coverage-multisite-${{ github.sha }}${{ 'clover' == matrix.format && '.xml' || '' }} @@ -184,7 +184,7 @@ jobs: - name: Upload multisite HTML report as artifact if: ${{ matrix.multisite && matrix.format == 'html' }} - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@0b2256b8c012f0828dc542b3febcab082c67f72b # v4.3.4 with: name: wp-code-coverage-multisite-${{ github.sha }} path: wp-code-coverage-multisite-${{ github.sha }} diff --git a/.mailmap b/.mailmap new file mode 100644 index 0000000000000..bbcf17b3ec9d4 --- /dev/null +++ b/.mailmap @@ -0,0 +1,129 @@ +# Aliases names and emails inside of commits. +# See https://git-scm.com/docs/gitmailmap +# +# Some entries appear as duplicates, but are both required to correct +# identities with just the wrong name as well as those with the wrong +# name and also the wrong email address. + +# Accounts with a display name. +Aaron D. Campbell +Aaron Jorbin +Adam Silverstein +Adam Zieliński +Adam Zieliński +Alex King +Alex Shiels +André +André +Andrea Fercia +Andrew Duthie +Andrew Nacin +Andrew Ozz +Anthony Burchell +Anton Timmermans +Bernie Reiter +Bernie Reiter +Boone Gorges +Carlos Bravo +Colin Stewart +Daniel Bachhuber +Daniel Richards +Daryl Koopersmith +David A. Kennedy +David Baumwald +Dennis Snell +Dion Hulse +Dominik Schilling +Dominik Schilling +Donncha O Caoimh +Dougal Campbell +Drew Jaynes +Drew Jaynes +Drew Jaynes +Ella +Ella +Ella +Ella +Eric Andrew Lewis +Felix Arntz +Gary Pendergast +Greg Ziółkowski +Greg Ziółkowski +Helen Hou-Sandi +Ian Belanger +Ian Dunn +Ian Stewart +Isabel Brison +Jake Spurlock +James Nylen +Jb Audras +Jeff Ong +Jeremy Felt +Joe Dolson +Joe Hoyle +Joe McGill +John Blackbourn +John James Jacoby +Jon Cave +Jonathan Desrosiers +Jonny Harris +Jorge Costa +Joseph Scott +Juliette Reinders Folmer +Juliette Reinders Folmer +K. Adam White +Kelly Choyce-Dwan +Kelly Choyce-Dwan +Kira Schroder +Kira Schroder +Konstantin Kovshenin +Konstantin Obenland +Konstantin Obenland +Lance Willett +Marius L. J +Mark Jaquith +Matias Ventura +Matt Mullenweg +Matt Thomas +Mel Choyce +Michael Adams +Michael Adams +Michael Arestad +Michal Czaplinski +Miguel Fonseca +Mike Little +Nikolay Bachiyski +Omar Reiss +Pascal Birchler +Pete Mall +Peter Westwood +Peter Wilson +Rachel Baker +Riad Benguella +Robert Anderson +Ron Rennick +Ryan Boren +Ryan McCue +Scott Taylor +Sergey Biryukov +Sergey Biryukov +Tammie Lister +Tammie Lister +Timothy Jacobs +Timothy Jacobs +Tonya Mork +Tonya Mork +Weston Ruter + +# Accounts without a corresponding display name. +allancole +bumpbot +jverber +laurelfulford +luisherranz +michelvaldrighi +potbot +ramonopoly +rob1n +scribu +zieladam diff --git a/package-lock.json b/package-lock.json index 17e20b756c8c2..f392e9064e327 100644 --- a/package-lock.json +++ b/package-lock.json @@ -75,7 +75,7 @@ "@wordpress/warning": "3.0.1", "@wordpress/widgets": "4.0.6", "@wordpress/wordcount": "4.0.1", - "backbone": "1.5.0", + "backbone": "1.6.0", "clipboard": "2.0.11", "core-js-url-browser": "3.6.4", "element-closest": "^3.0.2", @@ -96,9 +96,9 @@ "react": "18.3.1", "react-dom": "18.3.1", "react-is": "18.3.1", - "regenerator-runtime": "0.14.0", - "underscore": "1.13.6", - "whatwg-fetch": "3.6.17", + "regenerator-runtime": "0.14.1", + "underscore": "1.13.7", + "whatwg-fetch": "3.6.20", "wicg-inert": "3.1.2" }, "devDependencies": { @@ -10067,9 +10067,9 @@ "dev": true }, "node_modules/backbone": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/backbone/-/backbone-1.5.0.tgz", - "integrity": "sha512-RPKlstw5NW+rD2X4PnEnvgLhslRnXOugXw2iBloHkPMgOxvakP1/A+tZIGM3qCm8uvZeEf8zMm0uvcK1JwL+IA==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/backbone/-/backbone-1.6.0.tgz", + "integrity": "sha512-13PUjmsgw/49EowNcQvfG4gmczz1ximTMhUktj0Jfrjth0MVaTxehpU+qYYX4MxnuIuhmvBLC6/ayxuAGnOhbA==", "dependencies": { "underscore": ">=1.8.3" } @@ -28768,9 +28768,9 @@ } }, "node_modules/regenerator-runtime": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz", - "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==" + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" }, "node_modules/regenerator-transform": { "version": "0.15.1", @@ -32728,9 +32728,9 @@ } }, "node_modules/underscore": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.6.tgz", - "integrity": "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==" + "version": "1.13.7", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz", + "integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==" }, "node_modules/underscore.string": { "version": "3.3.5", @@ -33887,9 +33887,9 @@ } }, "node_modules/whatwg-fetch": { - "version": "3.6.17", - "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.17.tgz", - "integrity": "sha512-c4ghIvG6th0eudYwKZY5keb81wtFz9/WeAHAoy8+r18kcWlitUIrmGFQ2rWEl4UCKUilD3zCLHOIPheHx5ypRQ==" + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==" }, "node_modules/whatwg-mimetype": { "version": "3.0.0", @@ -41569,9 +41569,9 @@ } }, "backbone": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/backbone/-/backbone-1.5.0.tgz", - "integrity": "sha512-RPKlstw5NW+rD2X4PnEnvgLhslRnXOugXw2iBloHkPMgOxvakP1/A+tZIGM3qCm8uvZeEf8zMm0uvcK1JwL+IA==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/backbone/-/backbone-1.6.0.tgz", + "integrity": "sha512-13PUjmsgw/49EowNcQvfG4gmczz1ximTMhUktj0Jfrjth0MVaTxehpU+qYYX4MxnuIuhmvBLC6/ayxuAGnOhbA==", "requires": { "underscore": ">=1.8.3" } @@ -55632,9 +55632,9 @@ } }, "regenerator-runtime": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz", - "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==" + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" }, "regenerator-transform": { "version": "0.15.1", @@ -58688,9 +58688,9 @@ "dev": true }, "underscore": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.6.tgz", - "integrity": "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==" + "version": "1.13.7", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz", + "integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==" }, "underscore.string": { "version": "3.3.5", @@ -59511,9 +59511,9 @@ } }, "whatwg-fetch": { - "version": "3.6.17", - "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.17.tgz", - "integrity": "sha512-c4ghIvG6th0eudYwKZY5keb81wtFz9/WeAHAoy8+r18kcWlitUIrmGFQ2rWEl4UCKUilD3zCLHOIPheHx5ypRQ==" + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==" }, "whatwg-mimetype": { "version": "3.0.0", diff --git a/package.json b/package.json index 6f3fce17b19f0..49d03a9ed3be8 100644 --- a/package.json +++ b/package.json @@ -144,7 +144,7 @@ "@wordpress/warning": "3.0.1", "@wordpress/widgets": "4.0.6", "@wordpress/wordcount": "4.0.1", - "backbone": "1.5.0", + "backbone": "1.6.0", "clipboard": "2.0.11", "core-js-url-browser": "3.6.4", "element-closest": "^3.0.2", @@ -165,9 +165,9 @@ "react": "18.3.1", "react-dom": "18.3.1", "react-is": "18.3.1", - "regenerator-runtime": "0.14.0", - "underscore": "1.13.6", - "whatwg-fetch": "3.6.17", + "regenerator-runtime": "0.14.1", + "underscore": "1.13.7", + "whatwg-fetch": "3.6.20", "wicg-inert": "3.1.2" }, "scripts": { diff --git a/phpcs.xml.dist b/phpcs.xml.dist index ece1c8238613d..636580a4040d9 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -262,6 +262,7 @@ in the parsing, and distance the code from its standard. --> /wp-includes/html-api/class-wp-html-processor\.php + /wp-includes/html-api/class-wp-html-doctype-info\.php \n"; + $ie_conditional_prefix = "\n"; } $before_script = $this->get_inline_script_tag( $handle, 'before' ); $after_script = $this->get_inline_script_tag( $handle, 'after' ); if ( $before_script || $after_script ) { - $inline_script_tag = $cond_before . $before_script . $after_script . $cond_after; + $inline_script_tag = $ie_conditional_prefix . $before_script . $after_script . $ie_conditional_suffix; } else { $inline_script_tag = ''; } @@ -353,10 +353,10 @@ public function do_item( $handle, $group = false ) { * @param string $src Script loader source path. * @param string $handle Script handle. */ - $srce = apply_filters( 'script_loader_src', $src, $handle ); + $filtered_src = apply_filters( 'script_loader_src', $src, $handle ); if ( - $this->in_default_dir( $srce ) + $this->in_default_dir( $filtered_src ) && ( $before_script || $after_script || $translations_stop_concat || $this->is_delayed_strategy( $strategy ) ) ) { $this->do_concat = false; @@ -364,7 +364,7 @@ public function do_item( $handle, $group = false ) { // Have to print the so-far concatenated scripts right away to maintain the right order. _print_scripts(); $this->reset(); - } elseif ( $this->in_default_dir( $srce ) && ! $conditional ) { + } elseif ( $this->in_default_dir( $filtered_src ) && ! $conditional ) { $this->print_code .= $this->print_extra_script( $handle, false ); $this->concat .= "$handle,"; $this->concat_version .= "$handle$ver"; @@ -378,13 +378,13 @@ public function do_item( $handle, $group = false ) { $has_conditional_data = $conditional && $this->get_data( $handle, 'data' ); if ( $has_conditional_data ) { - echo $cond_before; + echo $ie_conditional_prefix; } $this->print_extra_script( $handle ); if ( $has_conditional_data ) { - echo $cond_after; + echo $ie_conditional_suffix; } // A single item may alias a set of items, by having dependencies, but no source. @@ -425,9 +425,9 @@ public function do_item( $handle, $group = false ) { if ( $intended_strategy ) { $attr['data-wp-strategy'] = $intended_strategy; } - $tag = $translations . $cond_before . $before_script; + $tag = $translations . $ie_conditional_prefix . $before_script; $tag .= wp_get_script_tag( $attr ); - $tag .= $after_script . $cond_after; + $tag .= $after_script . $ie_conditional_suffix; /** * Filters the HTML script tag of an enqueued script. @@ -626,16 +626,16 @@ public function localize( $handle, $object_name, $l10n ) { */ public function set_group( $handle, $recursion, $group = false ) { if ( isset( $this->registered[ $handle ]->args ) && 1 === $this->registered[ $handle ]->args ) { - $grp = 1; + $calculated_group = 1; } else { - $grp = (int) $this->get_data( $handle, 'group' ); + $calculated_group = (int) $this->get_data( $handle, 'group' ); } - if ( false !== $group && $grp > $group ) { - $grp = $group; + if ( false !== $group && $calculated_group > $group ) { + $calculated_group = $group; } - return parent::set_group( $handle, $recursion, $grp ); + return parent::set_group( $handle, $recursion, $calculated_group ); } /** @@ -723,7 +723,7 @@ public function print_translations( $handle, $display = true ) { * @return bool True on success, false on failure. */ public function all_deps( $handles, $recursion = false, $group = false ) { - $r = parent::all_deps( $handles, $recursion, $group ); + $result = parent::all_deps( $handles, $recursion, $group ); if ( ! $recursion ) { /** * Filters the list of script dependencies left to print. @@ -734,7 +734,7 @@ public function all_deps( $handles, $recursion = false, $group = false ) { */ $this->to_do = apply_filters( 'print_scripts_array', $this->to_do ); } - return $r; + return $result; } /** @@ -889,10 +889,10 @@ private function is_delayed_strategy( $strategy ) { * @return string The best eligible loading strategy. */ private function get_eligible_loading_strategy( $handle ) { - $intended = (string) $this->get_data( $handle, 'strategy' ); + $intended_strategy = (string) $this->get_data( $handle, 'strategy' ); // Bail early if there is no intended strategy. - if ( ! $intended ) { + if ( ! $intended_strategy ) { return ''; } @@ -900,16 +900,16 @@ private function get_eligible_loading_strategy( $handle ) { * If the intended strategy is 'defer', limit the initial list of eligible * strategies, since 'async' can fallback to 'defer', but not vice-versa. */ - $initial = ( 'defer' === $intended ) ? array( 'defer' ) : null; + $initial_strategy = ( 'defer' === $intended_strategy ) ? array( 'defer' ) : null; - $eligible = $this->filter_eligible_strategies( $handle, $initial ); + $eligible_strategies = $this->filter_eligible_strategies( $handle, $initial_strategy ); // Return early once we know the eligible strategy is blocking. - if ( empty( $eligible ) ) { + if ( empty( $eligible_strategies ) ) { return ''; } - return in_array( 'async', $eligible, true ) ? 'async' : 'defer'; + return in_array( 'async', $eligible_strategies, true ) ? 'async' : 'defer'; } /** @@ -917,20 +917,20 @@ private function get_eligible_loading_strategy( $handle ) { * * @since 6.3.0 * - * @param string $handle The script handle. - * @param string[]|null $eligible Optional. The list of strategies to filter. Default null. - * @param array $checked Optional. An array of already checked script handles, used to avoid recursive loops. + * @param string $handle The script handle. + * @param string[]|null $eligible_strategies Optional. The list of strategies to filter. Default null. + * @param array $checked Optional. An array of already checked script handles, used to avoid recursive loops. * @return string[] A list of eligible loading strategies that could be used. */ - private function filter_eligible_strategies( $handle, $eligible = null, $checked = array() ) { + private function filter_eligible_strategies( $handle, $eligible_strategies = null, $checked = array() ) { // If no strategies are being passed, all strategies are eligible. - if ( null === $eligible ) { - $eligible = $this->delayed_strategies; + if ( null === $eligible_strategies ) { + $eligible_strategies = $this->delayed_strategies; } // If this handle was already checked, return early. if ( isset( $checked[ $handle ] ) ) { - return $eligible; + return $eligible_strategies; } // Mark this handle as checked. @@ -938,12 +938,12 @@ private function filter_eligible_strategies( $handle, $eligible = null, $checked // If this handle isn't registered, don't filter anything and return. if ( ! isset( $this->registered[ $handle ] ) ) { - return $eligible; + return $eligible_strategies; } // If the handle is not enqueued, don't filter anything and return. if ( ! $this->query( $handle, 'enqueued' ) ) { - return $eligible; + return $eligible_strategies; } $is_alias = (bool) ! $this->registered[ $handle ]->src; @@ -961,7 +961,7 @@ private function filter_eligible_strategies( $handle, $eligible = null, $checked // If the intended strategy is 'defer', filter out 'async'. if ( 'defer' === $intended_strategy ) { - $eligible = array( 'defer' ); + $eligible_strategies = array( 'defer' ); } $dependents = $this->get_dependents( $handle ); @@ -969,14 +969,14 @@ private function filter_eligible_strategies( $handle, $eligible = null, $checked // Recursively filter eligible strategies for dependents. foreach ( $dependents as $dependent ) { // Bail early once we know the eligible strategy is blocking. - if ( empty( $eligible ) ) { + if ( empty( $eligible_strategies ) ) { return array(); } - $eligible = $this->filter_eligible_strategies( $dependent, $eligible, $checked ); + $eligible_strategies = $this->filter_eligible_strategies( $dependent, $eligible_strategies, $checked ); } - return $eligible; + return $eligible_strategies; } /** diff --git a/src/wp-includes/class-wp-styles.php b/src/wp-includes/class-wp-styles.php index 76883b54ca98a..e64378be5fc8d 100644 --- a/src/wp-includes/class-wp-styles.php +++ b/src/wp-includes/class-wp-styles.php @@ -165,14 +165,14 @@ public function do_item( $handle, $group = false ) { $ver = $ver ? $ver . '&' . $this->args[ $handle ] : $this->args[ $handle ]; } - $src = $obj->src; - $cond_before = ''; - $cond_after = ''; - $conditional = isset( $obj->extra['conditional'] ) ? $obj->extra['conditional'] : ''; + $src = $obj->src; + $ie_conditional_prefix = ''; + $ie_conditional_suffix = ''; + $conditional = isset( $obj->extra['conditional'] ) ? $obj->extra['conditional'] : ''; if ( $conditional ) { - $cond_before = "\n"; + $ie_conditional_prefix = "\n"; } $inline_style = $this->print_inline_style( $handle, false ); @@ -279,17 +279,17 @@ public function do_item( $handle, $group = false ) { } if ( $this->do_concat ) { - $this->print_html .= $cond_before; + $this->print_html .= $ie_conditional_prefix; $this->print_html .= $tag; if ( $inline_style_tag ) { $this->print_html .= $inline_style_tag; } - $this->print_html .= $cond_after; + $this->print_html .= $ie_conditional_suffix; } else { - echo $cond_before; + echo $ie_conditional_prefix; echo $tag; $this->print_inline_style( $handle ); - echo $cond_after; + echo $ie_conditional_suffix; } return true; @@ -368,7 +368,7 @@ public function print_inline_style( $handle, $display = true ) { * @return bool True on success, false on failure. */ public function all_deps( $handles, $recursion = false, $group = false ) { - $r = parent::all_deps( $handles, $recursion, $group ); + $result = parent::all_deps( $handles, $recursion, $group ); if ( ! $recursion ) { /** * Filters the array of enqueued styles before processing for output. @@ -379,7 +379,7 @@ public function all_deps( $handles, $recursion = false, $group = false ) { */ $this->to_do = apply_filters( 'print_styles_array', $this->to_do ); } - return $r; + return $result; } /** diff --git a/src/wp-includes/class-wp-theme-json-resolver.php b/src/wp-includes/class-wp-theme-json-resolver.php index 5caaf0bb7e1c4..d63a84353cd46 100644 --- a/src/wp-includes/class-wp-theme-json-resolver.php +++ b/src/wp-includes/class-wp-theme-json-resolver.php @@ -848,6 +848,7 @@ public static function get_style_variations( $scope = 'theme' ) { * as the value of `_link` object in REST API responses. * * @since 6.6.0 + * @since 6.7.0 Resolve relative paths in block styles. * * @param WP_Theme_JSON $theme_json A theme json instance. * @return array An array of resolved paths. @@ -860,15 +861,14 @@ public static function get_resolved_theme_uris( $theme_json ) { } $theme_json_data = $theme_json->get_raw_data(); - - // Top level styles. - $background_image_url = isset( $theme_json_data['styles']['background']['backgroundImage']['url'] ) ? $theme_json_data['styles']['background']['backgroundImage']['url'] : null; - /* * The same file convention when registering web fonts. * See: WP_Font_Face_Resolver::to_theme_file_uri. */ $placeholder = 'file:./'; + + // Top level styles. + $background_image_url = $theme_json_data['styles']['background']['backgroundImage']['url'] ?? null; if ( isset( $background_image_url ) && is_string( $background_image_url ) && @@ -888,6 +888,33 @@ public static function get_resolved_theme_uris( $theme_json ) { $resolved_theme_uris[] = $resolved_theme_uri; } + // Block styles. + if ( ! empty( $theme_json_data['styles']['blocks'] ) ) { + foreach ( $theme_json_data['styles']['blocks'] as $block_name => $block_styles ) { + if ( ! isset( $block_styles['background']['backgroundImage']['url'] ) ) { + continue; + } + $background_image_url = $block_styles['background']['backgroundImage']['url']; + if ( + is_string( $background_image_url ) && + // Skip if the src doesn't start with the placeholder, as there's nothing to replace. + str_starts_with( $background_image_url, $placeholder ) + ) { + $file_type = wp_check_filetype( $background_image_url ); + $src_url = str_replace( $placeholder, '', $background_image_url ); + $resolved_theme_uri = array( + 'name' => $background_image_url, + 'href' => sanitize_url( get_theme_file_uri( $src_url ) ), + 'target' => "styles.blocks.{$block_name}.background.backgroundImage.url", + ); + if ( isset( $file_type['type'] ) ) { + $resolved_theme_uri['type'] = $file_type['type']; + } + $resolved_theme_uris[] = $resolved_theme_uri; + } + } + } + return $resolved_theme_uris; } diff --git a/src/wp-includes/class-wp-theme-json.php b/src/wp-includes/class-wp-theme-json.php index 512df0fbf8c0f..cbe266bfad0cc 100644 --- a/src/wp-includes/class-wp-theme-json.php +++ b/src/wp-includes/class-wp-theme-json.php @@ -226,6 +226,7 @@ class WP_Theme_JSON { * @since 6.4.0 Added `writing-mode` property. * @since 6.5.0 Added `aspect-ratio` property. * @since 6.6.0 Added `background-[image|position|repeat|size]` properties. + * @since 6.7.0 Added `background-attachment` property. * * @var array */ @@ -237,6 +238,7 @@ class WP_Theme_JSON { 'background-position' => array( 'background', 'backgroundPosition' ), 'background-repeat' => array( 'background', 'backgroundRepeat' ), 'background-size' => array( 'background', 'backgroundSize' ), + 'background-attachment' => array( 'background', 'backgroundAttachment' ), 'border-radius' => array( 'border', 'radius' ), 'border-top-left-radius' => array( 'border', 'radius', 'topLeft' ), 'border-top-right-radius' => array( 'border', 'radius', 'topRight' ), @@ -520,10 +522,11 @@ class WP_Theme_JSON { */ const VALID_STYLES = array( 'background' => array( - 'backgroundImage' => 'top', - 'backgroundPosition' => 'top', - 'backgroundRepeat' => 'top', - 'backgroundSize' => 'top', + 'backgroundImage' => null, + 'backgroundPosition' => null, + 'backgroundRepeat' => null, + 'backgroundSize' => null, + 'backgroundAttachment' => null, ), 'border' => array( 'color' => null, @@ -1445,9 +1448,16 @@ public function get_stylesheet( $types = array( 'variables', 'styles', 'presets' protected function process_blocks_custom_css( $css, $selector ) { $processed_css = ''; + if ( empty( $css ) ) { + return $processed_css; + } + // Split CSS nested rules. $parts = explode( '&', $css ); foreach ( $parts as $part ) { + if ( empty( $part ) ) { + continue; + } $is_root_css = ( ! str_contains( $part, '{' ) ); if ( $is_root_css ) { // If the part doesn't contain braces, it applies to the root level. @@ -1460,11 +1470,25 @@ protected function process_blocks_custom_css( $css, $selector ) { } $nested_selector = $part[0]; $css_value = $part[1]; - $part_selector = str_starts_with( $nested_selector, ' ' ) + + /* + * Handle pseudo elements such as ::before, ::after etc. Regex will also + * capture any leading combinator such as >, +, or ~, as well as spaces. + * This allows pseudo elements as descendants e.g. `.parent ::before`. + */ + $matches = array(); + $has_pseudo_element = preg_match( '/([>+~\s]*::[a-zA-Z-]+)/', $nested_selector, $matches ); + $pseudo_part = $has_pseudo_element ? $matches[1] : ''; + $nested_selector = $has_pseudo_element ? str_replace( $pseudo_part, '', $nested_selector ) : $nested_selector; + + // Finalize selector and re-append pseudo element if required. + $part_selector = str_starts_with( $nested_selector, ' ' ) ? static::scope_selector( $selector, $nested_selector ) : static::append_to_selector( $selector, $nested_selector ); - $final_selector = ":root :where($part_selector)"; - $processed_css .= $final_selector . '{' . trim( $css_value ) . '}';} + $final_selector = ":root :where($part_selector)$pseudo_part"; + + $processed_css .= $final_selector . '{' . trim( $css_value ) . '}'; + } } return $processed_css; } @@ -1689,7 +1713,7 @@ protected function get_layout_styles( $block_metadata, $types = array() ) { $spacing_rule['selector'] ); } else { - $format = static::ROOT_BLOCK_SELECTOR === $selector ? '.%2$s %3$s' : '%1$s-%2$s %3$s'; + $format = static::ROOT_BLOCK_SELECTOR === $selector ? ':root :where(.%2$s)%3$s' : ':root :where(%1$s-%2$s)%3$s'; $layout_selector = sprintf( $format, $selector, @@ -2271,7 +2295,7 @@ protected static function flatten_tree( $tree, $prefix = '', $token = '--' ) { * * array( * 'name' => 'property_name', - * 'value' => 'property_value, + * 'value' => 'property_value', * ) * * @since 5.8.0 @@ -2279,6 +2303,7 @@ protected static function flatten_tree( $tree, $prefix = '', $token = '--' ) { * @since 6.1.0 Added `$theme_json`, `$selector`, and `$use_root_padding` parameters. * @since 6.5.0 Output a `min-height: unset` rule when `aspect-ratio` is set. * @since 6.6.0 Pass current theme JSON settings to wp_get_typography_font_size_value(), and process background properties. + * @since 6.7.0 `ref` resolution of background properties, and assigning custom default values. * * @param array $styles Styles to process. * @param array $settings Theme settings. @@ -2332,10 +2357,27 @@ protected static function compute_style_properties( $styles, $settings = array() } } - // Processes background styles. - if ( 'background' === $value_path[0] && isset( $styles['background'] ) ) { - $background_styles = wp_style_engine_get_styles( array( 'background' => $styles['background'] ) ); - $value = isset( $background_styles['declarations'][ $css_property ] ) ? $background_styles['declarations'][ $css_property ] : $value; + /* + * Processes background image styles. + * If the value is a URL, it will be converted to a CSS `url()` value. + * For uploaded image (images with a database ID), apply size and position defaults, + * equal to those applied in block supports in lib/background.php. + */ + if ( 'background-image' === $css_property && ! empty( $value ) ) { + $background_styles = wp_style_engine_get_styles( + array( 'background' => array( 'backgroundImage' => $value ) ) + ); + $value = $background_styles['declarations'][ $css_property ]; + } + if ( empty( $value ) && static::ROOT_BLOCK_SELECTOR !== $selector && ! empty( $styles['background']['backgroundImage']['id'] ) ) { + if ( 'background-size' === $css_property ) { + $value = 'cover'; + } + // If the background size is set to `contain` and no position is set, set the position to `center`. + if ( 'background-position' === $css_property ) { + $background_size = $styles['background']['backgroundSize'] ?? null; + $value = 'contain' === $background_size ? '50% 50%' : null; + } } // Skip if empty and not "0" or value represents array of longhand values. @@ -2397,6 +2439,7 @@ protected static function compute_style_properties( $styles, $settings = array() * to the standard form "--wp--preset--color--secondary". * This is already done by the sanitize method, * so every property will be in the standard form. + * @since 6.7.0 Added support for background image refs. * * @param array $styles Styles subtree. * @param array $path Which property to process. @@ -2413,14 +2456,18 @@ protected static function get_property_value( $styles, $path, $theme_json = null /* * This converts references to a path to the value at that path - * where the values is an array with a "ref" key, pointing to a path. + * where the value is an array with a "ref" key, pointing to a path. * For example: { "ref": "style.color.background" } => "#fff". + * In the case of backgroundImage, if both a ref and a URL are present in the value, + * the URL takes precedence and the ref is ignored. */ if ( is_array( $value ) && isset( $value['ref'] ) ) { $value_path = explode( '.', $value['ref'] ); $ref_value = _wp_array_get( $theme_json, $value_path ); + // Background Image refs can refer to a string or an array containing a URL string. + $ref_value_url = $ref_value['url'] ?? null; // Only use the ref value if we find anything. - if ( ! empty( $ref_value ) && is_string( $ref_value ) ) { + if ( ! empty( $ref_value ) && ( is_string( $ref_value ) || is_string( $ref_value_url ) ) ) { $value = $ref_value; } @@ -2889,6 +2936,9 @@ static function ( $pseudo_selector ) use ( $selector ) { } /* + * Root selector (body) styles should not be wrapped in `:root where()` to keep + * specificity at (0,0,1) and maintain backwards compatibility. + * * Top-level element styles using element-only specificity selectors should * not get wrapped in `:root :where()` to maintain backwards compatibility. * @@ -2896,11 +2946,13 @@ static function ( $pseudo_selector ) use ( $selector ) { * still need to be wrapped in `:root :where` to cap specificity for nested * variations etc. Pseudo selectors won't match the ELEMENTS selector exactly. */ - $element_only_selector = $current_element && + $element_only_selector = $is_root_selector || ( + $current_element && isset( static::ELEMENTS[ $current_element ] ) && // buttons, captions etc. still need `:root :where()` as they are class based selectors. ! isset( static::__EXPERIMENTAL_ELEMENT_CLASS_NAMES[ $current_element ] ) && - static::ELEMENTS[ $current_element ] === $selector; + static::ELEMENTS[ $current_element ] === $selector + ); // 2. Generate and append the rules that use the general selector. $general_selector = $element_only_selector ? $selector : ":root :where($selector)"; @@ -3054,6 +3106,7 @@ protected static function get_metadata_boolean( $data, $path, $default_value = f * * @since 5.8.0 * @since 5.9.0 Duotone preset also has origins. + * @since 6.7.0 Replace background image objects during merge. * * @param WP_Theme_JSON $incoming Data to merge. */ @@ -3177,6 +3230,25 @@ public function merge( $incoming ) { } } } + + /* + * Style values are merged at the leaf level, however + * some values provide exceptions, namely style values that are + * objects and represent unique definitions for the style. + */ + $style_nodes = static::get_styles_block_nodes(); + foreach ( $style_nodes as $style_node ) { + $path = $style_node['path']; + /* + * Background image styles should be replaced, not merged, + * as they themselves are specific object definitions for the style. + */ + $background_image_path = array_merge( $path, static::PROPERTIES_METADATA['background-image'] ); + $content = _wp_array_get( $incoming_data, $background_image_path, null ); + if ( isset( $content ) ) { + _wp_array_set( $this->theme_json, $background_image_path, $content ); + } + } } /** diff --git a/src/wp-includes/class-wp-walker.php b/src/wp-includes/class-wp-walker.php index ff5eac2f50040..df67921c2746c 100644 --- a/src/wp-includes/class-wp-walker.php +++ b/src/wp-includes/class-wp-walker.php @@ -135,6 +135,9 @@ public function display_element( $element, &$children_elements, $max_depth, $dep return; } + $max_depth = (int) $max_depth; + $depth = (int) $depth; + $id_field = $this->db_fields['id']; $id = $element->$id_field; @@ -191,6 +194,8 @@ public function display_element( $element, &$children_elements, $max_depth, $dep public function walk( $elements, $max_depth, ...$args ) { $output = ''; + $max_depth = (int) $max_depth; + // Invalid parameter or nothing to walk. if ( $max_depth < -1 || empty( $elements ) ) { return $output; @@ -285,12 +290,14 @@ public function walk( $elements, $max_depth, ...$args ) { * @return string XHTML of the specified page of elements. */ public function paged_walk( $elements, $max_depth, $page_num, $per_page, ...$args ) { + $output = ''; + + $max_depth = (int) $max_depth; + if ( empty( $elements ) || $max_depth < -1 ) { - return ''; + return $output; } - $output = ''; - $parent_field = $this->db_fields['parent']; $count = -1; diff --git a/src/wp-includes/comment-template.php b/src/wp-includes/comment-template.php index fed6568af33d5..ccc57ed8ee085 100644 --- a/src/wp-includes/comment-template.php +++ b/src/wp-includes/comment-template.php @@ -29,7 +29,7 @@ function get_comment_author( $comment_id = 0 ) { } elseif ( is_scalar( $comment_id ) ) { $comment_id = (string) $comment_id; } else { - $comment_id = ''; + $comment_id = '0'; } if ( empty( $comment->comment_author ) ) { @@ -233,7 +233,13 @@ function get_comment_author_email_link( $link_text = '', $before = '', $after = function get_comment_author_link( $comment_id = 0 ) { $comment = get_comment( $comment_id ); - $comment_id = ! empty( $comment->comment_ID ) ? $comment->comment_ID : (string) $comment_id; + if ( ! empty( $comment->comment_ID ) ) { + $comment_id = $comment->comment_ID; + } elseif ( is_scalar( $comment_id ) ) { + $comment_id = (string) $comment_id; + } else { + $comment_id = '0'; + } $comment_author_url = get_comment_author_url( $comment ); $comment_author = get_comment_author( $comment ); diff --git a/src/wp-includes/compat.php b/src/wp-includes/compat.php index 900a7994a1eae..030a288d823ca 100644 --- a/src/wp-includes/compat.php +++ b/src/wp-includes/compat.php @@ -549,3 +549,8 @@ function str_ends_with( $haystack, $needle ) { if ( ! defined( 'IMG_AVIF' ) ) { define( 'IMG_AVIF', IMAGETYPE_AVIF ); } + +// IMAGETYPE_HEIC constant is not yet defined in PHP as of PHP 8.3. +if ( ! defined( 'IMAGETYPE_HEIC' ) ) { + define( 'IMAGETYPE_HEIC', 99 ); +} diff --git a/src/wp-includes/css/media-views.css b/src/wp-includes/css/media-views.css index 44bda26f1f162..81f6611e7f2ee 100644 --- a/src/wp-includes/css/media-views.css +++ b/src/wp-includes/css/media-views.css @@ -709,6 +709,7 @@ color: #043959; /* Only visible in Windows High Contrast mode */ outline: 2px solid transparent; + z-index: 1; } .media-router .active, @@ -2531,7 +2532,8 @@ width: 230px; } - .options-general-php .crop-content.site-icon { + .options-general-php .crop-content.site-icon, + .wp-customizer:not(.mobile) .media-frame-content .crop-content.site-icon { margin-right: 262px; } @@ -2831,7 +2833,8 @@ position: fixed; } - .options-general-php .crop-content.site-icon { + .options-general-php .crop-content.site-icon, + .wp-customizer:not(.mobile) .media-frame-content .crop-content.site-icon { margin-right: 0; } diff --git a/src/wp-includes/customize/class-wp-customize-site-icon-control.php b/src/wp-includes/customize/class-wp-customize-site-icon-control.php index 33694b2a60600..fe05678321cc2 100644 --- a/src/wp-includes/customize/class-wp-customize-site-icon-control.php +++ b/src/wp-includes/customize/class-wp-customize-site-icon-control.php @@ -54,59 +54,66 @@ public function content_template() { <# if ( data.label ) { #> {{ data.label }} <# } #> - <# if ( data.description ) { #> - {{{ data.description }}} - <# } #> <# if ( data.attachment && data.attachment.id ) { #>
<# if ( data.attachment.sizes ) { #> -
-
- - -
- {{
-									data.attachment.alt ?
-										wp.i18n.sprintf(
-											<?php
-											/* translators: %s: The selected image alt text. */
-											echo wp_json_encode( __( 'Browser icon preview: Current image: %s' ) );
-											?>
-											,
-											data.attachment.alt
-										) :
-										wp.i18n.sprintf(
-											<?php
-											/* translators: %s: The selected image filename. */
-											echo wp_json_encode( __( 'Browser icon preview: The current image has no alternative text. The file name is: %s' ) );
-											?>
-											,
-											data.attachment.filename
-										)
-								}} + +
+
+ {{
+								data.attachment.alt ?
+									wp.i18n.sprintf(
+										<?php
+										/* translators: %s: The selected image alt text. */
+										echo wp_json_encode( __( 'App icon preview: Current image: %s' ) )
+										?>
+										,
+										data.attachment.alt
+									) :
+									wp.i18n.sprintf(
+										<?php
+										/* translators: %s: The selected image filename. */
+										echo wp_json_encode( __( 'App icon preview: The current image has no alternative text. The file name is: %s' ) );
+										?>
+										,
+										data.attachment.filename
+									)
+							}} +
+ +
+ {{
+										data.attachment.alt ?
+											wp.i18n.sprintf(
+												<?php
+												/* translators: %s: The selected image alt text. */
+												echo wp_json_encode( __( 'Browser icon preview: Current image: %s' ) );
+												?>
+												,
+												data.attachment.alt
+											) :
+											wp.i18n.sprintf(
+												<?php
+												/* translators: %s: The selected image filename. */
+												echo wp_json_encode( __( 'Browser icon preview: The current image has no alternative text. The file name is: %s' ) );
+												?>
+												,
+												data.attachment.filename
+											)
+									}} + + +
+
-
- {{
-							data.attachment.alt ?
-								wp.i18n.sprintf(
-									<?php
-									/* translators: %s: The selected image alt text. */
-									echo wp_json_encode( __( 'App icon preview: Current image: %s' ) )
-									?>
-									,
-									data.attachment.alt
-								) :
-								wp.i18n.sprintf(
-									<?php
-									/* translators: %s: The selected image filename. */
-									echo wp_json_encode( __( 'App icon preview: The current image has no alternative text. The file name is: %s' ) );
-									?>
-									,
-									data.attachment.filename
-								)
-						}}
<# } #>
@@ -128,6 +135,9 @@ public function content_template() {
<# } #> + <# if ( data.description ) { #> + {{{ data.description }}} + <# } #> 268435456 /* = 256M */ ) { + } elseif ( -1 === $current_limit_int || $current_limit_int > 256 * MB_IN_BYTES ) { define( 'WP_MAX_MEMORY_LIMIT', $current_limit ); + } elseif ( wp_convert_hr_to_bytes( WP_MEMORY_LIMIT ) > 256 * MB_IN_BYTES ) { + define( 'WP_MAX_MEMORY_LIMIT', WP_MEMORY_LIMIT ); } else { define( 'WP_MAX_MEMORY_LIMIT', '256M' ); } diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 6fba4441d50bd..dfa8cab48cc6f 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -3,6 +3,12 @@ * Sets up the default filters and actions for most * of the WordPress hooks. * + * This file is loaded very early in the bootstrap which + * means many functions are not yet available and site + * information such as if this is multisite is unknown. + * Before using functions besides `add_filter` and + * `add_action`, verify things will work as expected. + * * If you need to remove a default hook, this file will * give you the priority to use for removing the hook. * diff --git a/src/wp-includes/formatting.php b/src/wp-includes/formatting.php index cd1ee2689489e..e618536f41a20 100644 --- a/src/wp-includes/formatting.php +++ b/src/wp-includes/formatting.php @@ -762,8 +762,8 @@ function wp_replace_in_html_tags( $haystack, $replace_pairs ) { // Optimize when searching for one item. if ( 1 === count( $replace_pairs ) ) { // Extract $needle and $replace. - foreach ( $replace_pairs as $needle => $replace ) { - } + $needle = array_key_first( $replace_pairs ); + $replace = $replace_pairs[ $needle ]; // Loop through delimiters (elements) only. for ( $i = 1, $c = count( $textarr ); $i < $c; $i += 2 ) { @@ -5499,11 +5499,11 @@ function normalize_whitespace( $str ) { } /** - * Properly strips all HTML tags including script and style + * Properly strips all HTML tags including 'script' and 'style'. * * This differs from strip_tags() because it removes the contents of * the `' )` - * will return 'something'. wp_strip_all_tags will return '' + * will return 'something'. wp_strip_all_tags() will return an empty string. * * @since 2.9.0 * diff --git a/src/wp-includes/functions.php b/src/wp-includes/functions.php index e821f6f2b08be..6ddd11f0715a1 100644 --- a/src/wp-includes/functions.php +++ b/src/wp-includes/functions.php @@ -73,7 +73,7 @@ function mysql2date( $format, $date, $translate = true ) { function current_time( $type, $gmt = 0 ) { // Don't use non-GMT timestamp, unless you know the difference and really need to. if ( 'timestamp' === $type || 'U' === $type ) { - return $gmt ? time() : time() + (int) ( get_option( 'gmt_offset' ) * HOUR_IN_SECONDS ); + return $gmt ? time() : time() + (int) ( (float) get_option( 'gmt_offset' ) * HOUR_IN_SECONDS ); } if ( 'mysql' === $type ) { @@ -2706,8 +2706,7 @@ function wp_unique_filename( $dir, $filename, $unique_filename_callback = null ) * when regenerated. If yes, ensure the new file name will be unique and will produce unique sub-sizes. */ if ( $is_image ) { - /** This filter is documented in wp-includes/class-wp-image-editor.php */ - $output_formats = apply_filters( 'image_editor_output_format', array(), $_dir . $filename, $mime_type ); + $output_formats = wp_get_image_editor_output_format( $_dir . $filename, $mime_type ); $alt_types = array(); if ( ! empty( $output_formats[ $mime_type ] ) ) { @@ -3120,6 +3119,7 @@ function wp_check_filetype_and_ext( $file, $filename, $mimes = null ) { 'image/tiff' => 'tif', 'image/webp' => 'webp', 'image/avif' => 'avif', + 'image/heic' => 'heic', ) ); @@ -3299,6 +3299,7 @@ function wp_check_filetype_and_ext( $file, $filename, $mimes = null ) { * @since 4.7.1 * @since 5.8.0 Added support for WebP images. * @since 6.5.0 Added support for AVIF images. + * @since 6.7.0 Added support for HEIC images. * * @param string $file Full path to the file. * @return string|false The actual mime type or false if the type cannot be determined. @@ -3372,6 +3373,15 @@ function wp_get_image_mime( $file ) { ) { $mime = 'image/avif'; } + + if ( + isset( $magic[1] ) && + isset( $magic[2] ) && + 'ftyp' === hex2bin( $magic[1] ) && + ( 'heic' === hex2bin( $magic[2] ) || 'heif' === hex2bin( $magic[2] ) ) + ) { + $mime = 'image/heic'; + } } catch ( Exception $e ) { $mime = false; } @@ -8807,18 +8817,43 @@ function clean_dirsize_cache( $path ) { set_transient( 'dirsize_cache', $directory_cache, $expiration ); } +/** + * Returns the current WordPress version. + * + * Returns an unmodified value of `$wp_version`. Some plugins modify the global + * in an attempt to improve security through obscurity. This practice can cause + * errors in WordPress, so the ability to get an unmodified version is needed. + * + * @since 6.7.0 + * + * @return string The current WordPress version. + */ +function wp_get_wp_version() { + require ABSPATH . WPINC . '/version.php'; + + return $wp_version; +} + /** * Checks compatibility with the current WordPress version. * * @since 5.2.0 * - * @global string $wp_version The WordPress version string. + * @global string $_wp_tests_wp_version The WordPress version string. Used only in Core tests. * * @param string $required Minimum required WordPress version. * @return bool True if required version is compatible or empty, false if not. */ function is_wp_version_compatible( $required ) { - global $wp_version; + if ( + defined( 'WP_RUN_CORE_TESTS' ) + && WP_RUN_CORE_TESTS + && isset( $GLOBALS['_wp_tests_wp_version'] ) + ) { + $wp_version = $GLOBALS['_wp_tests_wp_version']; + } else { + $wp_version = wp_get_wp_version(); + } // Strip off any -alpha, -RC, -beta, -src suffixes. list( $version ) = explode( '-', $wp_version ); diff --git a/src/wp-includes/general-template.php b/src/wp-includes/general-template.php index bf70defd38701..f199000b5cd53 100644 --- a/src/wp-includes/general-template.php +++ b/src/wp-includes/general-template.php @@ -3120,6 +3120,15 @@ function feed_links( $args = array() ) { $args = wp_parse_args( $args, $defaults ); + /** + * Filters the feed links arguments. + * + * @since 6.7.0 + * + * @param array $args An array of feed links arguments. + */ + $args = apply_filters( 'feed_links_args', $args ); + /** * Filters whether to display the posts feed link. * @@ -3182,6 +3191,15 @@ function feed_links_extra( $args = array() ) { $args = wp_parse_args( $args, $defaults ); + /** + * Filters the extra feed links arguments. + * + * @since 6.7.0 + * + * @param array $args An array of extra feed links arguments. + */ + $args = apply_filters( 'feed_links_extra_args', $args ); + if ( is_singular() ) { $id = 0; $post = get_post( $id ); diff --git a/src/wp-includes/global-styles-and-settings.php b/src/wp-includes/global-styles-and-settings.php index b79eac58450e2..f40d4a5e9863e 100644 --- a/src/wp-includes/global-styles-and-settings.php +++ b/src/wp-includes/global-styles-and-settings.php @@ -247,6 +247,7 @@ function wp_get_global_stylesheet( $types = array() ) { * Adds global style rules to the inline style for each block. * * @since 6.1.0 + * @since 6.7.0 Resolve relative paths in block styles. * * @global WP_Styles $wp_styles */ @@ -254,6 +255,7 @@ function wp_add_global_styles_for_blocks() { global $wp_styles; $tree = WP_Theme_JSON_Resolver::get_merged_data(); + $tree = WP_Theme_JSON_Resolver::resolve_theme_file_uris( $tree ); $block_nodes = $tree->get_styles_block_nodes(); foreach ( $block_nodes as $metadata ) { $block_css = $tree->get_styles_for_block( $metadata ); diff --git a/src/wp-includes/html-api/class-wp-html-doctype-info.php b/src/wp-includes/html-api/class-wp-html-doctype-info.php new file mode 100644 index 0000000000000..e0396f7d7d603 --- /dev/null +++ b/src/wp-includes/html-api/class-wp-html-doctype-info.php @@ -0,0 +1,616 @@ +`. + * + * > DOCTYPEs are required for legacy reasons. When omitted, browsers tend to use a different + * > rendering mode that is incompatible with some specifications. Including the DOCTYPE in a + * > document ensures that the browser makes a best-effort attempt at following the + * > relevant specifications. + * + * @see https://html.spec.whatwg.org/#the-doctype + * + * DOCTYPE declarations comprise four properties: a name, public identifier, system identifier, + * and an indication of which document compatability mode they would imply if an HTML parser + * hadn't already determined it from other information. + * + * @see https://html.spec.whatwg.org/#the-initial-insertion-mode + * + * Historically, the DOCTYPE declaration was used in SGML documents to instruct a parser how + * to interpret the various tags and entities within a document. Its role in HTML diverged + * from how it was used in SGML and no meaning should be back-read into HTML based on how it + * is used in SGML, XML, or XHTML documents. + * + * @see https://www.iso.org/standard/16387.html + * + * @since 6.7.0 + * + * @see WP_HTML_Processor + */ +class WP_HTML_Doctype_Info { + /** + * Name of the DOCTYPE: should be "html" for HTML documents. + * + * This value should be considered "read only" and not modified. + * + * Historically the DOCTYPE name indicates name of the document's root element. + * + * + * ╰──┴── name is "html". + * + * @see https://html.spec.whatwg.org/#tokenization + * + * @since 6.7.0 + * + * @var string|null + */ + public $name = null; + + /** + * Public identifier of the DOCTYPE. + * + * This value should be considered "read only" and not modified. + * + * The public identifier is optional and should not appear in HTML documents. + * A `null` value indicates that no public identifier was present in the DOCTYPE. + * + * Historically the presence of the public identifier indicated that a document + * was meant to be shared between computer systems and the value indicated to a + * knowledgeable parser how to find the relevant document type definition (DTD). + * + * + * │ │ ╰─── public identifier ─────╯ + * ╰──┴── name is "html". + * + * @see https://html.spec.whatwg.org/#tokenization + * + * @since 6.7.0 + * + * @var string|null + */ + public $public_identifier = null; + + /** + * System identifier of the DOCTYPE. + * + * This value should be considered "read only" and not modified. + * + * The system identifier is optional and should not appear in HTML documents. + * A `null` value indicates that no system identifier was present in the DOCTYPE. + * + * Historically the system identifier specified where a relevant document type + * declaration for the given document is stored and may be retrieved. + * + * + * │ │ ╰──── system identifier ────╯ + * ╰──┴── name is "html". + * + * If a public identifier were provided it would indicate to a knowledgeable + * parser how to interpret the system identifier. + * + * + * │ │ ╰─── public identifier ─────╯ ╰──── system identifier ────╯ + * ╰──┴── name is "html". + * + * @see https://html.spec.whatwg.org/#tokenization + * + * @since 6.7.0 + * + * @var string|null + */ + public $system_identifier = null; + + /** + * Which document compatability mode this DOCTYPE declaration indicates. + * + * This value should be considered "read only" and not modified. + * + * When an HTML parser has not already set the document compatability mode, + * (e.g. "quirks" or "no-quirks" mode), it will infer if from the properties + * of the appropriate DOCTYPE declaration, if one exists. The DOCTYPE can + * indicate one of three possible document compatability modes: + * + * - "no-quirks" and "limited-quirks" modes (also called "standards" mode). + * - "quirks" mode (also called `CSS1Compat` mode). + * + * An appropriate DOCTYPE is one encountered in the "initial" insertion mode, + * before the HTML element has been opened and before finding any other + * DOCTYPE declaration tokens. + * + * @see https://html.spec.whatwg.org/#the-initial-insertion-mode + * + * @since 6.7.0 + * + * @var string One of "no-quirks", "limited-quirks", or "quirks". + */ + public $indicated_compatability_mode; + + /** + * Constructor. + * + * This class should not be instantiated directly. + * Use the static {@see self::from_doctype_token} method instead. + * + * The arguments to this constructor correspond to the "DOCTYPE token" + * as defined in the HTML specification. + * + * > DOCTYPE tokens have a name, a public identifier, a system identifier, + * > and a force-quirks flag. When a DOCTYPE token is created, its name, public identifier, + * > and system identifier must be marked as missing (which is a distinct state from the + * > empty string), and the force-quirks flag must be set to off (its other state is on). + * + * @see https://html.spec.whatwg.org/multipage/parsing.html#tokenization + * + * @since 6.7.0 + * + * @param string|null $name Name of the DOCTYPE. + * @param string|null $public_identifier Public identifier of the DOCTYPE. + * @param string|null $system_identifier System identifier of the DOCTYPE. + * @param bool $force_quirks_flag Whether the force-quirks flag is set for the token. + */ + private function __construct( + ?string $name, + ?string $public_identifier, + ?string $system_identifier, + bool $force_quirks_flag + ) { + $this->name = $name; + $this->public_identifier = $public_identifier; + $this->system_identifier = $system_identifier; + + /* + * > If the DOCTYPE token matches one of the conditions in the following list, + * > then set the Document to quirks mode: + */ + + /* + * > The force-quirks flag is set to on. + */ + if ( $force_quirks_flag ) { + $this->indicated_compatability_mode = 'quirks'; + return; + } + + /* + * Normative documents will contain the literal `` with no + * public or system identifiers; short-circuit to avoid extra parsing. + */ + if ( 'html' === $name && null === $public_identifier && null === $system_identifier ) { + $this->indicated_compatability_mode = 'no-quirks'; + return; + } + + /* + * > The name is not "html". + * + * The tokenizer must report the name in lower case even if provided in + * the document in upper case; thus no conversion is required here. + */ + if ( 'html' !== $name ) { + $this->indicated_compatability_mode = 'quirks'; + return; + } + + /* + * Set up some variables to handle the rest of the conditions. + * + * > set...the public identifier...to...the empty string if the public identifier was missing. + * > set...the system identifier...to...the empty string if the system identifier was missing. + * > + * > The system identifier and public identifier strings must be compared... + * > in an ASCII case-insensitive manner. + * > + * > A system identifier whose value is the empty string is not considered missing + * > for the purposes of the conditions above. + */ + $system_identifier_is_missing = null === $system_identifier; + $public_identifier = null === $public_identifier ? '' : strtolower( $public_identifier ); + $system_identifier = null === $system_identifier ? '' : strtolower( $system_identifier ); + + /* + * > The public identifier is set to… + */ + if ( + '-//w3o//dtd w3 html strict 3.0//en//' === $public_identifier || + '-/w3c/dtd html 4.0 transitional/en' === $public_identifier || + 'html' === $public_identifier + ) { + $this->indicated_compatability_mode = 'quirks'; + return; + } + + /* + * > The system identifier is set to… + */ + if ( 'http://www.ibm.com/data/dtd/v11/ibmxhtml1-transitional.dtd' === $system_identifier ) { + $this->indicated_compatability_mode = 'quirks'; + return; + } + + /* + * All of the following conditions depend on matching the public identifier. + * If the public identifier is empty, none of the following conditions will match. + */ + if ( '' === $public_identifier ) { + $this->indicated_compatability_mode = 'no-quirks'; + return; + } + + /* + * > The public identifier starts with… + * + * @todo Optimize this matching. It shouldn't be a large overall performance issue, + * however, as only a single DOCTYPE declaration token should ever be parsed, + * and normative documents will have exited before reaching this condition. + */ + if ( + str_starts_with( $public_identifier, '+//silmaril//dtd html pro v0r11 19970101//' ) || + str_starts_with( $public_identifier, '-//as//dtd html 3.0 aswedit + extensions//' ) || + str_starts_with( $public_identifier, '-//advasoft ltd//dtd html 3.0 aswedit + extensions//' ) || + str_starts_with( $public_identifier, '-//ietf//dtd html 2.0 level 1//' ) || + str_starts_with( $public_identifier, '-//ietf//dtd html 2.0 level 2//' ) || + str_starts_with( $public_identifier, '-//ietf//dtd html 2.0 strict level 1//' ) || + str_starts_with( $public_identifier, '-//ietf//dtd html 2.0 strict level 2//' ) || + str_starts_with( $public_identifier, '-//ietf//dtd html 2.0 strict//' ) || + str_starts_with( $public_identifier, '-//ietf//dtd html 2.0//' ) || + str_starts_with( $public_identifier, '-//ietf//dtd html 2.1e//' ) || + str_starts_with( $public_identifier, '-//ietf//dtd html 3.0//' ) || + str_starts_with( $public_identifier, '-//ietf//dtd html 3.2 final//' ) || + str_starts_with( $public_identifier, '-//ietf//dtd html 3.2//' ) || + str_starts_with( $public_identifier, '-//ietf//dtd html 3//' ) || + str_starts_with( $public_identifier, '-//ietf//dtd html level 0//' ) || + str_starts_with( $public_identifier, '-//ietf//dtd html level 1//' ) || + str_starts_with( $public_identifier, '-//ietf//dtd html level 2//' ) || + str_starts_with( $public_identifier, '-//ietf//dtd html level 3//' ) || + str_starts_with( $public_identifier, '-//ietf//dtd html strict level 0//' ) || + str_starts_with( $public_identifier, '-//ietf//dtd html strict level 1//' ) || + str_starts_with( $public_identifier, '-//ietf//dtd html strict level 2//' ) || + str_starts_with( $public_identifier, '-//ietf//dtd html strict level 3//' ) || + str_starts_with( $public_identifier, '-//ietf//dtd html strict//' ) || + str_starts_with( $public_identifier, '-//ietf//dtd html//' ) || + str_starts_with( $public_identifier, '-//metrius//dtd metrius presentational//' ) || + str_starts_with( $public_identifier, '-//microsoft//dtd internet explorer 2.0 html strict//' ) || + str_starts_with( $public_identifier, '-//microsoft//dtd internet explorer 2.0 html//' ) || + str_starts_with( $public_identifier, '-//microsoft//dtd internet explorer 2.0 tables//' ) || + str_starts_with( $public_identifier, '-//microsoft//dtd internet explorer 3.0 html strict//' ) || + str_starts_with( $public_identifier, '-//microsoft//dtd internet explorer 3.0 html//' ) || + str_starts_with( $public_identifier, '-//microsoft//dtd internet explorer 3.0 tables//' ) || + str_starts_with( $public_identifier, '-//netscape comm. corp.//dtd html//' ) || + str_starts_with( $public_identifier, '-//netscape comm. corp.//dtd strict html//' ) || + str_starts_with( $public_identifier, "-//o'reilly and associates//dtd html 2.0//" ) || + str_starts_with( $public_identifier, "-//o'reilly and associates//dtd html extended 1.0//" ) || + str_starts_with( $public_identifier, "-//o'reilly and associates//dtd html extended relaxed 1.0//" ) || + str_starts_with( $public_identifier, '-//sq//dtd html 2.0 hotmetal + extensions//' ) || + str_starts_with( $public_identifier, '-//softquad software//dtd hotmetal pro 6.0::19990601::extensions to html 4.0//' ) || + str_starts_with( $public_identifier, '-//softquad//dtd hotmetal pro 4.0::19971010::extensions to html 4.0//' ) || + str_starts_with( $public_identifier, '-//spyglass//dtd html 2.0 extended//' ) || + str_starts_with( $public_identifier, '-//sun microsystems corp.//dtd hotjava html//' ) || + str_starts_with( $public_identifier, '-//sun microsystems corp.//dtd hotjava strict html//' ) || + str_starts_with( $public_identifier, '-//w3c//dtd html 3 1995-03-24//' ) || + str_starts_with( $public_identifier, '-//w3c//dtd html 3.2 draft//' ) || + str_starts_with( $public_identifier, '-//w3c//dtd html 3.2 final//' ) || + str_starts_with( $public_identifier, '-//w3c//dtd html 3.2//' ) || + str_starts_with( $public_identifier, '-//w3c//dtd html 3.2s draft//' ) || + str_starts_with( $public_identifier, '-//w3c//dtd html 4.0 frameset//' ) || + str_starts_with( $public_identifier, '-//w3c//dtd html 4.0 transitional//' ) || + str_starts_with( $public_identifier, '-//w3c//dtd html experimental 19960712//' ) || + str_starts_with( $public_identifier, '-//w3c//dtd html experimental 970421//' ) || + str_starts_with( $public_identifier, '-//w3c//dtd w3 html//' ) || + str_starts_with( $public_identifier, '-//w3o//dtd w3 html 3.0//' ) || + str_starts_with( $public_identifier, '-//webtechs//dtd mozilla html 2.0//' ) || + str_starts_with( $public_identifier, '-//webtechs//dtd mozilla html//' ) + ) { + $this->indicated_compatability_mode = 'quirks'; + return; + } + + /* + * > The system identifier is missing and the public identifier starts with… + */ + if ( + $system_identifier_is_missing && ( + str_starts_with( $public_identifier, '-//w3c//dtd html 4.01 frameset//' ) || + str_starts_with( $public_identifier, '-//w3c//dtd html 4.01 transitional//' ) + ) + ) { + $this->indicated_compatability_mode = 'quirks'; + return; + } + + /* + * > Otherwise, if the DOCTYPE token matches one of the conditions in + * > the following list, then set the Document to limited-quirks mode. + */ + + /* + * > The public identifier starts with… + */ + if ( + str_starts_with( $public_identifier, '-//w3c//dtd xhtml 1.0 frameset//' ) || + str_starts_with( $public_identifier, '-//w3c//dtd xhtml 1.0 transitional//' ) + ) { + $this->indicated_compatability_mode = 'limited-quirks'; + return; + } + + /* + * > The system identifier is not missing and the public identifier starts with… + */ + if ( + ! $system_identifier_is_missing && ( + str_starts_with( $public_identifier, '-//w3c//dtd html 4.01 frameset//' ) || + str_starts_with( $public_identifier, '-//w3c//dtd html 4.01 transitional//' ) + ) + ) { + $this->indicated_compatability_mode = 'limited-quirks'; + return; + } + + $this->indicated_compatability_mode = 'no-quirks'; + } + + /** + * Creates a WP_HTML_Doctype_Info instance by parsing a raw DOCTYPE declaration token. + * + * Use this method to parse a DOCTYPE declaration token and get access to its properties + * via the returned WP_HTML_Doctype_Info class instance. The provided input must parse + * properly as a DOCTYPE declaration, though it must not represent a valid DOCTYPE. + * + * Example: + * + * // Normative HTML DOCTYPE declaration. + * $doctype = WP_HTML_Doctype_Info::from_doctype_token( '' ); + * 'no-quirks' === $doctype->indicated_compatability_mode; + * + * // A nonsensical DOCTYPE is still valid, and will indicate "quirks" mode. + * $doctype = WP_HTML_Doctype_Info::from_doctype_token( '' ); + * 'quirks' === $doctype->indicated_compatability_mode; + * + * // Textual quirks present in raw HTML are handled appropriately. + * $doctype = WP_HTML_Doctype_Info::from_doctype_token( "" ); + * 'no-quirks' === $doctype->indicated_compatability_mode; + * + * // Anything other than a proper DOCTYPE declaration token fails to parse. + * null === WP_HTML_Doctype_Info::from_doctype_token( ' ' ); + * null === WP_HTML_Doctype_Info::from_doctype_token( '

' ); + * null === WP_HTML_Doctype_Info::from_doctype_token( '' ); + * null === WP_HTML_Doctype_Info::from_doctype_token( 'html' ); + * null === WP_HTML_Doctype_Info::from_doctype_token( '' ); + * + * @since 6.7.0 + * + * @param string $doctype_html The complete raw DOCTYPE HTML string, e.g. ``. + * + * @return WP_HTML_Doctype_Info|null A WP_HTML_Doctype_Info instance will be returned if the + * provided DOCTYPE HTML is a valid DOCTYPE. Otherwise, null. + */ + public static function from_doctype_token( string $doctype_html ): ?self { + $doctype_name = null; + $doctype_public_id = null; + $doctype_system_id = null; + + $end = strlen( $doctype_html ) - 1; + + /* + * This parser combines the rules for parsing DOCTYPE tokens found in the HTML + * specification for the DOCTYPE related tokenizer states. + * + * @see https://html.spec.whatwg.org/#doctype-state + */ + + /* + * - Valid DOCTYPE HTML token must be at least `` assuming a complete token not + * ending in end-of-file. + * - It must start with an ASCII case-insensitive match for `` must be the final byte in the HTML string. + */ + if ( + $end < 9 || + 0 !== substr_compare( $doctype_html, '`? + if ( '>' !== $doctype_html[ $end ] || ( strcspn( $doctype_html, '>', $at ) + $at ) < $end ) { + return null; + } + + /* + * Perform newline normalization and ensure the $end value is correct after normalization. + * + * @see https://html.spec.whatwg.org/#preprocessing-the-input-stream + * @see https://infra.spec.whatwg.org/#normalize-newlines + */ + $doctype_html = str_replace( "\r\n", "\n", $doctype_html ); + $doctype_html = str_replace( "\r", "\n", $doctype_html ); + $end = strlen( $doctype_html ) - 1; + + /* + * In this state, the doctype token has been found and its "content" optionally including the + * name, public identifier, and system identifier is between the current position and the end. + * + * "" + * ╰─ $at ╰─ $end + * + * It's also possible that the declaration part is empty. + * + * ╭─ $at + * "" + * ╰─ $end + * + * Rules for parsing ">" which terminates the DOCTYPE do not need to be considered as they + * have been handled above in the condition that the provided DOCTYPE HTML must contain + * exactly one ">" character in the final position. + */ + + /* + * + * Parsing effectively begins in "Before DOCTYPE name state". Ignore whitespace and + * proceed to the next state. + * + * @see https://html.spec.whatwg.org/#before-doctype-name-state + */ + $at += strspn( $doctype_html, " \t\n\f\r", $at ); + + if ( $at >= $end ) { + return new self( $doctype_name, $doctype_public_id, $doctype_system_id, true ); + } + + $name_length = strcspn( $doctype_html, " \t\n\f\r", $at, $end - $at ); + $doctype_name = str_replace( "\0", "\u{FFFD}", strtolower( substr( $doctype_html, $at, $name_length ) ) ); + + $at += $name_length; + $at += strspn( $doctype_html, " \t\n\f\r", $at, $end - $at ); + if ( $at >= $end ) { + return new self( $doctype_name, $doctype_public_id, $doctype_system_id, false ); + } + + /* + * "After DOCTYPE name state" + * + * Find a case-insensitive match for "PUBLIC" or "SYSTEM" at this point. + * Otherwise, set force-quirks and enter bogus DOCTYPE state (skip the rest of the doctype). + * + * @see https://html.spec.whatwg.org/#after-doctype-name-state + */ + if ( $at + 6 >= $end ) { + return new self( $doctype_name, $doctype_public_id, $doctype_system_id, true ); + } + + /* + * > If the six characters starting from the current input character are an ASCII + * > case-insensitive match for the word "PUBLIC", then consume those characters + * > and switch to the after DOCTYPE public keyword state. + */ + if ( 0 === substr_compare( $doctype_html, 'PUBLIC', $at, 6, true ) ) { + $at += 6; + $at += strspn( $doctype_html, " \t\n\f\r", $at, $end - $at ); + if ( $at >= $end ) { + return new self( $doctype_name, $doctype_public_id, $doctype_system_id, true ); + } + goto parse_doctype_public_identifier; + } + + /* + * > Otherwise, if the six characters starting from the current input character are an ASCII + * > case-insensitive match for the word "SYSTEM", then consume those characters and switch + * > to the after DOCTYPE system keyword state. + */ + if ( 0 === substr_compare( $doctype_html, 'SYSTEM', $at, 6, true ) ) { + $at += 6; + $at += strspn( $doctype_html, " \t\n\f\r", $at, $end - $at ); + if ( $at >= $end ) { + return new self( $doctype_name, $doctype_public_id, $doctype_system_id, true ); + } + goto parse_doctype_system_identifier; + } + + /* + * > Otherwise, this is an invalid-character-sequence-after-doctype-name parse error. + * > Set the current DOCTYPE token's force-quirks flag to on. Reconsume in the bogus + * > DOCTYPE state. + */ + return new self( $doctype_name, $doctype_public_id, $doctype_system_id, true ); + + parse_doctype_public_identifier: + /* + * The parser should enter "DOCTYPE public identifier (double-quoted) state" or + * "DOCTYPE public identifier (single-quoted) state" by finding one of the valid quotes. + * Anything else forces quirks mode and ignores the rest of the contents. + * + * @see https://html.spec.whatwg.org/#doctype-public-identifier-(double-quoted)-state + * @see https://html.spec.whatwg.org/#doctype-public-identifier-(single-quoted)-state + */ + $closer_quote = $doctype_html[ $at ]; + + /* + * > This is a missing-quote-before-doctype-public-identifier parse error. Set the + * > current DOCTYPE token's force-quirks flag to on. Reconsume in the bogus DOCTYPE state. + */ + if ( '"' !== $closer_quote && "'" !== $closer_quote ) { + return new self( $doctype_name, $doctype_public_id, $doctype_system_id, true ); + } + + ++$at; + + $identifier_length = strcspn( $doctype_html, $closer_quote, $at, $end - $at ); + $doctype_public_id = str_replace( "\0", "\u{FFFD}", substr( $doctype_html, $at, $identifier_length ) ); + + $at += $identifier_length; + if ( $at >= $end || $closer_quote !== $doctype_html[ $at ] ) { + return new self( $doctype_name, $doctype_public_id, $doctype_system_id, true ); + } + + ++$at; + + /* + * "Between DOCTYPE public and system identifiers state" + * + * Advance through whitespace between public and system identifiers. + * + * @see https://html.spec.whatwg.org/#between-doctype-public-and-system-identifiers-state + */ + $at += strspn( $doctype_html, " \t\n\f\r", $at, $end - $at ); + if ( $at >= $end ) { + return new self( $doctype_name, $doctype_public_id, $doctype_system_id, false ); + } + + parse_doctype_system_identifier: + /* + * The parser should enter "DOCTYPE system identifier (double-quoted) state" or + * "DOCTYPE system identifier (single-quoted) state" by finding one of the valid quotes. + * Anything else forces quirks mode and ignores the rest of the contents. + * + * @see https://html.spec.whatwg.org/#doctype-system-identifier-(double-quoted)-state + * @see https://html.spec.whatwg.org/#doctype-system-identifier-(single-quoted)-state + */ + $closer_quote = $doctype_html[ $at ]; + + /* + * > This is a missing-quote-before-doctype-system-identifier parse error. Set the + * > current DOCTYPE token's force-quirks flag to on. Reconsume in the bogus DOCTYPE state. + */ + if ( '"' !== $closer_quote && "'" !== $closer_quote ) { + return new self( $doctype_name, $doctype_public_id, $doctype_system_id, true ); + } + + ++$at; + + $identifier_length = strcspn( $doctype_html, $closer_quote, $at, $end - $at ); + $doctype_system_id = str_replace( "\0", "\u{FFFD}", substr( $doctype_html, $at, $identifier_length ) ); + + $at += $identifier_length; + if ( $at >= $end || $closer_quote !== $doctype_html[ $at ] ) { + return new self( $doctype_name, $doctype_public_id, $doctype_system_id, true ); + } + + return new self( $doctype_name, $doctype_public_id, $doctype_system_id, false ); + } +} diff --git a/src/wp-includes/html-api/class-wp-html-open-elements.php b/src/wp-includes/html-api/class-wp-html-open-elements.php index d59bd32140582..5ce1f8feb552c 100644 --- a/src/wp-includes/html-api/class-wp-html-open-elements.php +++ b/src/wp-includes/html-api/class-wp-html-open-elements.php @@ -113,13 +113,13 @@ public function set_push_handler( Closure $handler ): void { * * @param int $nth Retrieve the nth item on the stack, with 1 being * the top element, 2 being the second, etc... - * @return string|null Name of the node on the stack at the given location, - * or `null` if the location isn't on the stack. + * @return WP_HTML_Token|null Name of the node on the stack at the given location, + * or `null` if the location isn't on the stack. */ - public function at( int $nth ): ?string { + public function at( int $nth ): ?WP_HTML_Token { foreach ( $this->walk_down() as $item ) { if ( 0 === --$nth ) { - return $item->node_name; + return $item; } } @@ -242,18 +242,22 @@ public function current_node_is( string $identity ): bool { */ public function has_element_in_specific_scope( string $tag_name, $termination_list ): bool { foreach ( $this->walk_up() as $node ) { - if ( $node->node_name === $tag_name ) { + $namespaced_name = 'html' === $node->namespace + ? $node->node_name + : "{$node->namespace} {$node->node_name}"; + + if ( $namespaced_name === $tag_name ) { return true; } if ( '(internal: H1 through H6 - do not use)' === $tag_name && - in_array( $node->node_name, array( 'H1', 'H2', 'H3', 'H4', 'H5', 'H6' ), true ) + in_array( $namespaced_name, array( 'H1', 'H2', 'H3', 'H4', 'H5', 'H6' ), true ) ) { return true; } - if ( in_array( $node->node_name, $termination_list, true ) ) { + if ( in_array( $namespaced_name, $termination_list, true ) ) { return false; } } @@ -288,7 +292,7 @@ public function has_element_in_specific_scope( string $tag_name, $termination_li * > - SVG title * * @since 6.4.0 - * @since 6.7.0 Supports all required HTML elements. + * @since 6.7.0 Full support. * * @see https://html.spec.whatwg.org/#has-an-element-in-scope * @@ -308,7 +312,17 @@ public function has_element_in_scope( string $tag_name ): bool { 'MARQUEE', 'OBJECT', 'TEMPLATE', - // @todo: Support SVG and MathML nodes when support for foreign content is added. + + 'math MI', + 'math MO', + 'math MN', + 'math MS', + 'math MTEXT', + 'math ANNOTATION-XML', + + 'svg FOREIGNOBJECT', + 'svg DESC', + 'svg TITLE', ) ); } @@ -349,7 +363,17 @@ public function has_element_in_list_item_scope( string $tag_name ): bool { 'OL', 'TEMPLATE', 'UL', - // @todo: Support SVG and MathML nodes when support for foreign content is added. + + 'math MI', + 'math MO', + 'math MN', + 'math MS', + 'math MTEXT', + 'math ANNOTATION-XML', + + 'svg FOREIGNOBJECT', + 'svg DESC', + 'svg TITLE', ) ); } @@ -386,7 +410,17 @@ public function has_element_in_button_scope( string $tag_name ): bool { 'MARQUEE', 'OBJECT', 'TEMPLATE', - // @todo: Support SVG and MathML nodes when support for foreign content is added. + + 'math MI', + 'math MO', + 'math MN', + 'math MS', + 'math MTEXT', + 'math ANNOTATION-XML', + + 'svg FOREIGNOBJECT', + 'svg DESC', + 'svg TITLE', ) ); } @@ -653,11 +687,15 @@ public function walk_up( ?WP_HTML_Token $above_this_node = null ) { * @param WP_HTML_Token $item Element that was added to the stack of open elements. */ public function after_element_push( WP_HTML_Token $item ): void { + $namespaced_name = 'html' === $item->namespace + ? $item->node_name + : "{$item->namespace} {$item->node_name}"; + /* * When adding support for new elements, expand this switch to trap * cases where the precalculated value needs to change. */ - switch ( $item->node_name ) { + switch ( $namespaced_name ) { case 'APPLET': case 'BUTTON': case 'CAPTION': @@ -668,6 +706,15 @@ public function after_element_push( WP_HTML_Token $item ): void { case 'MARQUEE': case 'OBJECT': case 'TEMPLATE': + case 'math MI': + case 'math MO': + case 'math MN': + case 'math MS': + case 'math MTEXT': + case 'math ANNOTATION-XML': + case 'svg FOREIGNOBJECT': + case 'svg DESC': + case 'svg TITLE': $this->has_p_in_button_scope = false; break; @@ -711,6 +758,15 @@ public function after_element_pop( WP_HTML_Token $item ): void { case 'MARQUEE': case 'OBJECT': case 'TEMPLATE': + case 'math MI': + case 'math MO': + case 'math MN': + case 'math MS': + case 'math MTEXT': + case 'math ANNOTATION-XML': + case 'svg FOREIGNOBJECT': + case 'svg DESC': + case 'svg TITLE': $this->has_p_in_button_scope = $this->has_element_in_button_scope( 'P' ); break; } @@ -720,6 +776,80 @@ public function after_element_pop( WP_HTML_Token $item ): void { } } + /** + * Clear the stack back to a table context. + * + * > When the steps above require the UA to clear the stack back to a table context, it means + * > that the UA must, while the current node is not a table, template, or html element, pop + * > elements from the stack of open elements. + * + * @see https://html.spec.whatwg.org/multipage/parsing.html#clear-the-stack-back-to-a-table-context + * + * @since 6.7.0 + */ + public function clear_to_table_context(): void { + foreach ( $this->walk_up() as $item ) { + if ( + 'TABLE' === $item->node_name || + 'TEMPLATE' === $item->node_name || + 'HTML' === $item->node_name + ) { + break; + } + $this->pop(); + } + } + + /** + * Clear the stack back to a table body context. + * + * > When the steps above require the UA to clear the stack back to a table body context, it + * > means that the UA must, while the current node is not a tbody, tfoot, thead, template, or + * > html element, pop elements from the stack of open elements. + * + * @see https://html.spec.whatwg.org/multipage/parsing.html#clear-the-stack-back-to-a-table-body-context + * + * @since 6.7.0 + */ + public function clear_to_table_body_context(): void { + foreach ( $this->walk_up() as $item ) { + if ( + 'TBODY' === $item->node_name || + 'TFOOT' === $item->node_name || + 'THEAD' === $item->node_name || + 'TEMPLATE' === $item->node_name || + 'HTML' === $item->node_name + ) { + break; + } + $this->pop(); + } + } + + /** + * Clear the stack back to a table row context. + * + * > When the steps above require the UA to clear the stack back to a table row context, it + * > means that the UA must, while the current node is not a tr, template, or html element, pop + * > elements from the stack of open elements. + * + * @see https://html.spec.whatwg.org/multipage/parsing.html#clear-the-stack-back-to-a-table-row-context + * + * @since 6.7.0 + */ + public function clear_to_table_row_context(): void { + foreach ( $this->walk_up() as $item ) { + if ( + 'TR' === $item->node_name || + 'TEMPLATE' === $item->node_name || + 'HTML' === $item->node_name + ) { + break; + } + $this->pop(); + } + } + /** * Wakeup magic method. * diff --git a/src/wp-includes/html-api/class-wp-html-processor-state.php b/src/wp-includes/html-api/class-wp-html-processor-state.php index e0469bea020e5..16875c4ac1b2b 100644 --- a/src/wp-includes/html-api/class-wp-html-processor-state.php +++ b/src/wp-includes/html-api/class-wp-html-processor-state.php @@ -299,18 +299,6 @@ class WP_HTML_Processor_State { */ const INSERTION_MODE_AFTER_AFTER_FRAMESET = 'insertion-mode-after-after-frameset'; - /** - * In foreign content insertion mode for full HTML parser. - * - * @since 6.7.0 - * - * @see https://html.spec.whatwg.org/#parsing-main-inforeign - * @see WP_HTML_Processor_State::$insertion_mode - * - * @var string - */ - const INSERTION_MODE_IN_FOREIGN_CONTENT = 'insertion-mode-in-foreign-content'; - /** * No-quirks mode document compatability mode. * @@ -428,6 +416,38 @@ class WP_HTML_Processor_State { */ public $context_node = null; + /** + * The recognized encoding of the input byte stream. + * + * > The stream of code points that comprises the input to the tokenization + * > stage will be initially seen by the user agent as a stream of bytes + * > (typically coming over the network or from the local file system). + * > The bytes encode the actual characters according to a particular character + * > encoding, which the user agent uses to decode the bytes into characters. + * + * @since 6.7.0 + * + * @var string|null + */ + public $encoding = null; + + /** + * The parser's confidence in the input encoding. + * + * > When the HTML parser is decoding an input byte stream, it uses a character + * > encoding and a confidence. The confidence is either tentative, certain, or + * > irrelevant. The encoding used, and whether the confidence in that encoding + * > is tentative or certain, is used during the parsing to determine whether to + * > change the encoding. If no encoding is necessary, e.g. because the parser is + * > operating on a Unicode stream and doesn't have to use a character encoding + * > at all, then the confidence is irrelevant. + * + * @since 6.7.0 + * + * @var string + */ + public $encoding_confidence = 'tentative'; + /** * HEAD element pointer. * diff --git a/src/wp-includes/html-api/class-wp-html-processor.php b/src/wp-includes/html-api/class-wp-html-processor.php index f9073492d86ac..d6df280f35dca 100644 --- a/src/wp-includes/html-api/class-wp-html-processor.php +++ b/src/wp-includes/html-api/class-wp-html-processor.php @@ -256,21 +256,6 @@ class WP_HTML_Processor extends WP_HTML_Tag_Processor { */ private $context_node = null; - /** - * Whether the parser has yet processed the context node, - * if created as a fragment parser. - * - * The context node will be initially pushed onto the stack of open elements, - * but when created as a fragment parser, this context element (and the implicit - * HTML document node above it) should not be exposed as a matched token or node. - * - * This boolean indicates whether the processor should skip over the current - * node in its initial search for the first node created from the input HTML. - * - * @var bool - */ - private $has_seen_context_node = false; - /* * Public Interface Functions */ @@ -312,22 +297,24 @@ public static function create_fragment( $html, $context = '', $encoding = return null; } - $processor = new static( $html, self::CONSTRUCTOR_UNLOCK_CODE ); - $processor->state->context_node = array( 'BODY', array() ); - $processor->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_IN_BODY; + $processor = new static( $html, self::CONSTRUCTOR_UNLOCK_CODE ); + $processor->state->context_node = array( 'BODY', array() ); + $processor->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_IN_BODY; + $processor->state->encoding = $encoding; + $processor->state->encoding_confidence = 'certain'; // @todo Create "fake" bookmarks for non-existent but implied nodes. $processor->bookmarks['root-node'] = new WP_HTML_Span( 0, 0 ); $processor->bookmarks['context-node'] = new WP_HTML_Span( 0, 0 ); - $processor->state->stack_of_open_elements->push( - new WP_HTML_Token( - 'root-node', - 'HTML', - false - ) + $root_node = new WP_HTML_Token( + 'root-node', + 'HTML', + false ); + $processor->state->stack_of_open_elements->push( $root_node ); + $context_node = new WP_HTML_Token( 'context-node', $processor->state->context_node[0], @@ -340,6 +327,34 @@ public static function create_fragment( $html, $context = '', $encoding = return $processor; } + /** + * Creates an HTML processor in the full parsing mode. + * + * It's likely that a fragment parser is more appropriate, unless sending an + * entire HTML document from start to finish. Consider a fragment parser with + * a context node of ``. + * + * Since UTF-8 is the only currently-accepted charset, if working with a + * document that isn't UTF-8, it's important to convert the document before + * creating the processor: pass in the converted HTML. + * + * @param string $html Input HTML document to process. + * @param string|null $known_definite_encoding Optional. If provided, specifies the charset used + * in the input byte stream. Currently must be UTF-8. + * @return static|null The created processor if successful, otherwise null. + */ + public static function create_full_parser( $html, $known_definite_encoding = 'UTF-8' ) { + if ( 'UTF-8' !== $known_definite_encoding ) { + return null; + } + + $processor = new static( $html, self::CONSTRUCTOR_UNLOCK_CODE ); + $processor->state->encoding = $known_definite_encoding; + $processor->state->encoding_confidence = 'certain'; + + return $processor; + } + /** * Constructor. * @@ -377,6 +392,8 @@ function ( WP_HTML_Token $token ): void { $same_node = isset( $this->state->current_token ) && $token->node_name === $this->state->current_token->node_name; $provenance = ( ! $same_node || $is_virtual ) ? 'virtual' : 'real'; $this->element_queue[] = new WP_HTML_Stack_Event( $token, WP_HTML_Stack_Event::PUSH, $provenance ); + + $this->change_parsing_namespace( $token->namespace ); } ); @@ -386,6 +403,12 @@ function ( WP_HTML_Token $token ): void { $same_node = isset( $this->state->current_token ) && $token->node_name === $this->state->current_token->node_name; $provenance = ( ! $same_node || $is_virtual ) ? 'virtual' : 'real'; $this->element_queue[] = new WP_HTML_Stack_Event( $token, WP_HTML_Stack_Event::POP, $provenance ); + $adjusted_current_node = $this->get_adjusted_current_node(); + $this->change_parsing_namespace( + $adjusted_current_node + ? $adjusted_current_node->namespace + : 'html' + ); } ); @@ -609,25 +632,30 @@ public function next_token(): bool { * until there are events or until there are no more * tokens works in the meantime and isn't obviously wrong. */ - while ( empty( $this->element_queue ) && $this->step() ) { - continue; + if ( empty( $this->element_queue ) && $this->step() ) { + return $this->next_token(); } // Process the next event on the queue. $this->current_element = array_shift( $this->element_queue ); if ( ! isset( $this->current_element ) ) { - return false; + // There are no tokens left, so close all remaining open elements. + while ( $this->state->stack_of_open_elements->pop() ) { + continue; + } + + return empty( $this->element_queue ) ? false : $this->next_token(); } $is_pop = WP_HTML_Stack_Event::POP === $this->current_element->operation; /* * The root node only exists in the fragment parser, and closing it - * indicates that the parse is complete. Stop before popping if from + * indicates that the parse is complete. Stop before popping it from * the breadcrumbs. */ if ( 'root-node' === $this->current_element->token->bookmark_name ) { - return ! $is_pop && $this->next_token(); + return $this->next_token(); } // Adjust the breadcrumbs for this event. @@ -638,7 +666,7 @@ public function next_token(): bool { } // Avoid sending close events for elements which don't expect a closing. - if ( $is_pop && ! static::expects_closer( $this->current_element->token ) ) { + if ( $is_pop && ! $this->expects_closer( $this->current_element->token ) ) { return $this->next_token(); } @@ -749,21 +777,21 @@ public function matches_breadcrumbs( $breadcrumbs ): bool { * * @since 6.6.0 * - * @todo When adding support for foreign content, ensure that - * this returns false for self-closing elements in the - * SVG and MathML namespace. - * * @param WP_HTML_Token|null $node Optional. Node to examine, if provided. * Default is to examine current node. * @return bool|null Whether to expect a closer for the currently-matched node, * or `null` if not matched on any token. */ - public function expects_closer( $node = null ): ?bool { + public function expects_closer( WP_HTML_Token $node = null ): ?bool { $token_name = $node->node_name ?? $this->get_token_name(); + if ( ! isset( $token_name ) ) { return null; } + $token_namespace = $node->namespace ?? $this->get_namespace(); + $token_has_self_closing = $node->has_self_closing_flag ?? $this->has_self_closing_flag(); + return ! ( // Comments, text nodes, and other atomic tokens. '#' === $token_name[0] || @@ -772,7 +800,9 @@ public function expects_closer( $node = null ): ?bool { // Void elements. self::is_void( $token_name ) || // Special atomic elements. - in_array( $token_name, array( 'IFRAME', 'NOEMBED', 'NOFRAMES', 'SCRIPT', 'STYLE', 'TEXTAREA', 'TITLE', 'XMP' ), true ) + ( 'html' === $token_namespace && in_array( $token_name, array( 'IFRAME', 'NOEMBED', 'NOFRAMES', 'SCRIPT', 'STYLE', 'TEXTAREA', 'TITLE', 'XMP' ), true ) ) || + // Self-closing elements in foreign content. + ( 'html' !== $token_namespace && $token_has_self_closing ) ); } @@ -804,14 +834,9 @@ public function step( $node_to_process = self::PROCESS_NEXT_NODE ): bool { * * When moving on to the next node, therefore, if the bottom-most element * on the stack is a void element, it must be closed. - * - * @todo Once self-closing foreign elements and BGSOUND are supported, - * they must also be implicitly closed here too. BGSOUND is - * special since it's only self-closing if the self-closing flag - * is provided in the opening tag, otherwise it expects a tag closer. */ $top_node = $this->state->stack_of_open_elements->current_node(); - if ( isset( $top_node ) && ! static::expects_closer( $top_node ) ) { + if ( isset( $top_node ) && ! $this->expects_closer( $top_node ) ) { $this->state->stack_of_open_elements->pop(); } } @@ -828,14 +853,46 @@ public function step( $node_to_process = self::PROCESS_NEXT_NODE ): bool { return false; } - $this->state->current_token = new WP_HTML_Token( - $this->bookmark_token(), - $this->get_token_name(), - $this->has_self_closing_flag(), - $this->release_internal_bookmark_on_destruct + $adjusted_current_node = $this->get_adjusted_current_node(); + $is_closer = $this->is_tag_closer(); + $is_start_tag = WP_HTML_Tag_Processor::STATE_MATCHED_TAG === $this->parser_state && ! $is_closer; + $token_name = $this->get_token_name(); + + if ( self::REPROCESS_CURRENT_NODE !== $node_to_process ) { + $this->state->current_token = new WP_HTML_Token( + $this->bookmark_token(), + $token_name, + $this->has_self_closing_flag(), + $this->release_internal_bookmark_on_destruct + ); + } + + $parse_in_current_insertion_mode = ( + 0 === $this->state->stack_of_open_elements->count() || + 'html' === $adjusted_current_node->namespace || + ( + 'math' === $adjusted_current_node->integration_node_type && + ( + ( $is_start_tag && ! in_array( $token_name, array( 'MGLYPH', 'MALIGNMARK' ), true ) ) || + '#text' === $token_name + ) + ) || + ( + 'math' === $adjusted_current_node->namespace && + 'ANNOTATION-XML' === $adjusted_current_node->node_name && + $is_start_tag && 'SVG' === $token_name + ) || + ( + 'html' === $adjusted_current_node->integration_node_type && + ( $is_start_tag || '#text' === $token_name ) + ) ); try { + if ( ! $parse_in_current_insertion_mode ) { + return $this->step_in_foreign_content(); + } + switch ( $this->state->insertion_mode ) { case WP_HTML_Processor_State::INSERTION_MODE_INITIAL: return $this->step_initial(); @@ -903,9 +960,6 @@ public function step( $node_to_process = self::PROCESS_NEXT_NODE ): bool { case WP_HTML_Processor_State::INSERTION_MODE_AFTER_AFTER_FRAMESET: return $this->step_after_after_frameset(); - case WP_HTML_Processor_State::INSERTION_MODE_IN_FOREIGN_CONTENT: - return $this->step_in_foreign_content(); - // This should be unreachable but PHP doesn't have total type checking on switch. default: $this->bail( "Unaware of the requested parsing mode: '{$this->state->insertion_mode}'." ); @@ -988,7 +1042,60 @@ public function get_current_depth(): int { * @return bool Whether an element was found. */ private function step_initial(): bool { - $this->bail( 'No support for parsing in the ' . WP_HTML_Processor_State::INSERTION_MODE_INITIAL . ' state.' ); + $token_name = $this->get_token_name(); + $token_type = $this->get_token_type(); + $op_sigil = '#tag' === $token_type ? ( parent::is_tag_closer() ? '-' : '+' ) : ''; + $op = "{$op_sigil}{$token_name}"; + + switch ( $op ) { + /* + * > A character token that is one of U+0009 CHARACTER TABULATION, + * > U+000A LINE FEED (LF), U+000C FORM FEED (FF), + * > U+000D CARRIAGE RETURN (CR), or U+0020 SPACE + * + * Parse error: ignore the token. + */ + case '#text': + $text = $this->get_modifiable_text(); + if ( strlen( $text ) === strspn( $text, " \t\n\f\r" ) ) { + return $this->step(); + } + goto initial_anything_else; + break; + + /* + * > A comment token + */ + case '#comment': + case '#funky-comment': + case '#presumptuous-tag': + $this->insert_html_element( $this->state->current_token ); + return true; + + /* + * > A DOCTYPE token + */ + case 'html': + $doctype = $this->get_doctype_info(); + if ( null !== $doctype && 'quirks' === $doctype->indicated_compatability_mode ) { + $this->state->document_mode = WP_HTML_Processor_State::QUIRKS_MODE; + } + + /* + * > Then, switch the insertion mode to "before html". + */ + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_BEFORE_HTML; + $this->insert_html_element( $this->state->current_token ); + return true; + } + + /* + * > Anything else + */ + initial_anything_else: + $this->state->document_mode = WP_HTML_Processor_State::QUIRKS_MODE; + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_BEFORE_HTML; + return $this->step( self::REPROCESS_CURRENT_NODE ); } /** @@ -997,7 +1104,7 @@ private function step_initial(): bool { * This internal function performs the 'before html' insertion mode * logic for the generalized WP_HTML_Processor::step() function. * - * @since 6.7.0 Stub implementation. + * @since 6.7.0 * * @throws WP_HTML_Unsupported_Exception When encountering unsupported HTML input. * @@ -1007,7 +1114,86 @@ private function step_initial(): bool { * @return bool Whether an element was found. */ private function step_before_html(): bool { - $this->bail( 'No support for parsing in the ' . WP_HTML_Processor_State::INSERTION_MODE_BEFORE_HTML . ' state.' ); + $token_name = $this->get_token_name(); + $token_type = $this->get_token_type(); + $is_closer = parent::is_tag_closer(); + $op_sigil = '#tag' === $token_type ? ( $is_closer ? '-' : '+' ) : ''; + $op = "{$op_sigil}{$token_name}"; + + switch ( $op ) { + /* + * > A DOCTYPE token + */ + case 'html': + // Parse error: ignore the token. + return $this->step(); + + /* + * > A comment token + */ + case '#comment': + case '#funky-comment': + case '#presumptuous-tag': + $this->insert_html_element( $this->state->current_token ); + return true; + + /* + * > A character token that is one of U+0009 CHARACTER TABULATION, + * > U+000A LINE FEED (LF), U+000C FORM FEED (FF), + * > U+000D CARRIAGE RETURN (CR), or U+0020 SPACE + * + * Parse error: ignore the token. + */ + case '#text': + $text = $this->get_modifiable_text(); + if ( strlen( $text ) === strspn( $text, " \t\n\f\r" ) ) { + return $this->step(); + } + goto before_html_anything_else; + break; + + /* + * > A start tag whose tag name is "html" + */ + case '+HTML': + $this->insert_html_element( $this->state->current_token ); + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_BEFORE_HEAD; + return true; + + /* + * > An end tag whose tag name is one of: "head", "body", "html", "br" + * + * Closing BR tags are always reported by the Tag Processor as opening tags. + */ + case '-HEAD': + case '-BODY': + case '-HTML': + /* + * > Act as described in the "anything else" entry below. + */ + goto before_html_anything_else; + break; + } + + /* + * > Any other end tag + */ + if ( $is_closer ) { + // Parse error: ignore the token. + return $this->step(); + } + + /* + * > Anything else. + * + * > Create an html element whose node document is the Document object. + * > Append it to the Document object. Put this element in the stack of open elements. + * > Switch the insertion mode to "before head", then reprocess the token. + */ + before_html_anything_else: + $this->insert_virtual_node( 'HTML' ); + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_BEFORE_HEAD; + return $this->step( self::REPROCESS_CURRENT_NODE ); } /** @@ -1026,124 +1212,31 @@ private function step_before_html(): bool { * @return bool Whether an element was found. */ private function step_before_head(): bool { - $this->bail( 'No support for parsing in the ' . WP_HTML_Processor_State::INSERTION_MODE_BEFORE_HEAD . ' state.' ); - } - - /** - * Parses next element in the 'in head' insertion mode. - * - * This internal function performs the 'in head' insertion mode - * logic for the generalized WP_HTML_Processor::step() function. - * - * @since 6.7.0 Stub implementation. - * - * @throws WP_HTML_Unsupported_Exception When encountering unsupported HTML input. - * - * @see https://html.spec.whatwg.org/multipage/parsing.html#parsing-main-inhead - * @see WP_HTML_Processor::step - * - * @return bool Whether an element was found. - */ - private function step_in_head(): bool { - $this->bail( 'No support for parsing in the ' . WP_HTML_Processor_State::INSERTION_MODE_IN_HEAD . ' state.' ); - } - - /** - * Parses next element in the 'in head noscript' insertion mode. - * - * This internal function performs the 'in head noscript' insertion mode - * logic for the generalized WP_HTML_Processor::step() function. - * - * @since 6.7.0 Stub implementation. - * - * @throws WP_HTML_Unsupported_Exception When encountering unsupported HTML input. - * - * @see https://html.spec.whatwg.org/#parsing-main-inheadnoscript - * @see WP_HTML_Processor::step - * - * @return bool Whether an element was found. - */ - private function step_in_head_noscript(): bool { - $this->bail( 'No support for parsing in the ' . WP_HTML_Processor_State::INSERTION_MODE_IN_HEAD_NOSCRIPT . ' state.' ); - } - - /** - * Parses next element in the 'after head' insertion mode. - * - * This internal function performs the 'after head' insertion mode - * logic for the generalized WP_HTML_Processor::step() function. - * - * @since 6.7.0 Stub implementation. - * - * @throws WP_HTML_Unsupported_Exception When encountering unsupported HTML input. - * - * @see https://html.spec.whatwg.org/#the-after-head-insertion-mode - * @see WP_HTML_Processor::step - * - * @return bool Whether an element was found. - */ - private function step_after_head(): bool { - $this->bail( 'No support for parsing in the ' . WP_HTML_Processor_State::INSERTION_MODE_AFTER_HEAD . ' state.' ); - } - - /** - * Parses next element in the 'in body' insertion mode. - * - * This internal function performs the 'in body' insertion mode - * logic for the generalized WP_HTML_Processor::step() function. - * - * @since 6.4.0 - * - * @throws WP_HTML_Unsupported_Exception When encountering unsupported HTML input. - * - * @see https://html.spec.whatwg.org/#parsing-main-inbody - * @see WP_HTML_Processor::step - * - * @return bool Whether an element was found. - */ - private function step_in_body(): bool { $token_name = $this->get_token_name(); $token_type = $this->get_token_type(); - $op_sigil = '#tag' === $token_type ? ( parent::is_tag_closer() ? '-' : '+' ) : ''; + $is_closer = parent::is_tag_closer(); + $op_sigil = '#tag' === $token_type ? ( $is_closer ? '-' : '+' ) : ''; $op = "{$op_sigil}{$token_name}"; switch ( $op ) { + /* + * > A character token that is one of U+0009 CHARACTER TABULATION, + * > U+000A LINE FEED (LF), U+000C FORM FEED (FF), + * > U+000D CARRIAGE RETURN (CR), or U+0020 SPACE + * + * Parse error: ignore the token. + */ case '#text': - $current_token = $this->bookmarks[ $this->state->current_token->bookmark_name ]; - - /* - * > A character token that is U+0000 NULL - * - * Any successive sequence of NULL bytes is ignored and won't - * trigger active format reconstruction. Therefore, if the text - * only comprises NULL bytes then the token should be ignored - * here, but if there are any other characters in the stream - * the active formats should be reconstructed. - */ - if ( - 1 <= $current_token->length && - "\x00" === $this->html[ $current_token->start ] && - strspn( $this->html, "\x00", $current_token->start, $current_token->length ) === $current_token->length - ) { - // Parse error: ignore the token. - return $this->step(); - } - - $this->reconstruct_active_formatting_elements(); - - /* - * Whitespace-only text does not affect the frameset-ok flag. - * It is probably inter-element whitespace, but it may also - * contain character references which decode only to whitespace. - */ $text = $this->get_modifiable_text(); - if ( strlen( $text ) !== strspn( $text, " \t\n\f\r" ) ) { - $this->state->frameset_ok = false; + if ( strlen( $text ) === strspn( $text, " \t\n\f\r" ) ) { + return $this->step(); } + goto before_head_anything_else; + break; - $this->insert_html_element( $this->state->current_token ); - return true; - + /* + * > A comment token + */ case '#comment': case '#funky-comment': case '#presumptuous-tag': @@ -1152,1151 +1245,3200 @@ private function step_in_body(): bool { /* * > A DOCTYPE token - * > Parse error. Ignore the token. */ case 'html': + // Parse error: ignore the token. return $this->step(); /* * > A start tag whose tag name is "html" */ case '+HTML': - if ( ! $this->state->stack_of_open_elements->contains( 'TEMPLATE' ) ) { - /* - * > Otherwise, for each attribute on the token, check to see if the attribute - * > is already present on the top element of the stack of open elements. If - * > it is not, add the attribute and its corresponding value to that element. - * - * This parser does not currently support this behavior: ignore the token. - */ - } - - // Ignore the token. - return $this->step(); + return $this->step_in_body(); /* - * > A start tag whose tag name is one of: "base", "basefont", "bgsound", "link", - * > "meta", "noframes", "script", "style", "template", "title" - * > - * > An end tag whose tag name is "template" + * > A start tag whose tag name is "head" */ - case '+BASE': - case '+BASEFONT': - case '+BGSOUND': - case '+LINK': - case '+META': - case '+NOFRAMES': - case '+SCRIPT': - case '+STYLE': - case '+TEMPLATE': - case '+TITLE': - case '-TEMPLATE': - return $this->step_in_head(); + case '+HEAD': + $this->insert_html_element( $this->state->current_token ); + $this->state->head_element = $this->state->current_token; + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_IN_HEAD; + return true; /* - * > A start tag whose tag name is "body" + * > An end tag whose tag name is one of: "head", "body", "html", "br" + * > Act as described in the "anything else" entry below. * - * This tag in the IN BODY insertion mode is a parse error. + * Closing BR tags are always reported by the Tag Processor as opening tags. */ - case '+BODY': - if ( - 1 === $this->state->stack_of_open_elements->count() || - 'BODY' !== $this->state->stack_of_open_elements->at( 2 ) || - $this->state->stack_of_open_elements->contains( 'TEMPLATE' ) - ) { - // Ignore the token. - return $this->step(); - } + case '-HEAD': + case '-BODY': + case '-HTML': + goto before_head_anything_else; + break; + } + + if ( $is_closer ) { + // Parse error: ignore the token. + return $this->step(); + } + + /* + * > Anything else + * + * > Insert an HTML element for a "head" start tag token with no attributes. + */ + before_head_anything_else: + $this->state->head_element = $this->insert_virtual_node( 'HEAD' ); + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_IN_HEAD; + return $this->step( self::REPROCESS_CURRENT_NODE ); + } + + /** + * Parses next element in the 'in head' insertion mode. + * + * This internal function performs the 'in head' insertion mode + * logic for the generalized WP_HTML_Processor::step() function. + * + * @since 6.7.0 + * + * @throws WP_HTML_Unsupported_Exception When encountering unsupported HTML input. + * + * @see https://html.spec.whatwg.org/multipage/parsing.html#parsing-main-inhead + * @see WP_HTML_Processor::step + * + * @return bool Whether an element was found. + */ + private function step_in_head(): bool { + $token_name = $this->get_token_name(); + $token_type = $this->get_token_type(); + $is_closer = parent::is_tag_closer(); + $op_sigil = '#tag' === $token_type ? ( $is_closer ? '-' : '+' ) : ''; + $op = "{$op_sigil}{$token_name}"; + switch ( $op ) { + case '#text': /* - * > Otherwise, set the frameset-ok flag to "not ok"; then, for each attribute - * > on the token, check to see if the attribute is already present on the body - * > element (the second element) on the stack of open elements, and if it is - * > not, add the attribute and its corresponding value to that element. - * - * This parser does not currently support this behavior: ignore the token. + * > A character token that is one of U+0009 CHARACTER TABULATION, + * > U+000A LINE FEED (LF), U+000C FORM FEED (FF), + * > U+000D CARRIAGE RETURN (CR), or U+0020 SPACE */ - $this->state->frameset_ok = false; - return $this->step(); - - /* - * > A start tag whose tag name is "frameset" - * - * This tag in the IN BODY insertion mode is a parse error. - */ - case '+FRAMESET': - if ( - 1 === $this->state->stack_of_open_elements->count() || - 'BODY' !== $this->state->stack_of_open_elements->at( 2 ) || - false === $this->state->frameset_ok - ) { - // Ignore the token. + $text = $this->get_modifiable_text(); + if ( '' === $text ) { + /* + * If the text is empty after processing HTML entities and stripping + * U+0000 NULL bytes then ignore the token. + */ return $this->step(); } - /* - * > Otherwise, run the following steps: - */ - $this->bail( 'Cannot process non-ignored FRAMESET tags.' ); + if ( strlen( $text ) === strspn( $text, " \t\n\f\r" ) ) { + // Insert the character. + $this->insert_html_element( $this->state->current_token ); + return true; + } + + goto in_head_anything_else; break; /* - * > An end tag whose tag name is "body" + * > A comment token */ - case '-BODY': - if ( ! $this->state->stack_of_open_elements->has_element_in_scope( 'BODY' ) ) { - // Parse error: ignore the token. - return $this->step(); - } - - /* - * > Otherwise, if there is a node in the stack of open elements that is not either a - * > dd element, a dt element, an li element, an optgroup element, an option element, - * > a p element, an rb element, an rp element, an rt element, an rtc element, a tbody - * > element, a td element, a tfoot element, a th element, a thread element, a tr - * > element, the body element, or the html element, then this is a parse error. - * - * There is nothing to do for this parse error, so don't check for it. - */ - - $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_AFTER_BODY; + case '#comment': + case '#funky-comment': + case '#presumptuous-tag': + $this->insert_html_element( $this->state->current_token ); return true; /* - * > An end tag whose tag name is "html" + * > A DOCTYPE token */ - case '-HTML': - if ( ! $this->state->stack_of_open_elements->has_element_in_scope( 'BODY' ) ) { - // Parse error: ignore the token. - return $this->step(); - } - - /* - * > Otherwise, if there is a node in the stack of open elements that is not either a - * > dd element, a dt element, an li element, an optgroup element, an option element, - * > a p element, an rb element, an rp element, an rt element, an rtc element, a tbody - * > element, a td element, a tfoot element, a th element, a thread element, a tr - * > element, the body element, or the html element, then this is a parse error. - * - * There is nothing to do for this parse error, so don't check for it. - */ - - $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_AFTER_BODY; - return $this->step( self::REPROCESS_CURRENT_NODE ); + case 'html': + // Parse error: ignore the token. + return $this->step(); /* - * > A start tag whose tag name is one of: "address", "article", "aside", - * > "blockquote", "center", "details", "dialog", "dir", "div", "dl", - * > "fieldset", "figcaption", "figure", "footer", "header", "hgroup", - * > "main", "menu", "nav", "ol", "p", "search", "section", "summary", "ul" + * > A start tag whose tag name is "html" */ - case '+ADDRESS': - case '+ARTICLE': - case '+ASIDE': - case '+BLOCKQUOTE': - case '+CENTER': - case '+DETAILS': - case '+DIALOG': - case '+DIR': - case '+DIV': - case '+DL': - case '+FIELDSET': - case '+FIGCAPTION': - case '+FIGURE': - case '+FOOTER': - case '+HEADER': - case '+HGROUP': - case '+MAIN': - case '+MENU': - case '+NAV': - case '+OL': - case '+P': - case '+SEARCH': - case '+SECTION': - case '+SUMMARY': - case '+UL': - if ( $this->state->stack_of_open_elements->has_p_in_button_scope() ) { - $this->close_a_p_element(); - } + case '+HTML': + return $this->step_in_body(); + /* + * > A start tag whose tag name is one of: "base", "basefont", "bgsound", "link" + */ + case '+BASE': + case '+BASEFONT': + case '+BGSOUND': + case '+LINK': $this->insert_html_element( $this->state->current_token ); return true; /* - * > A start tag whose tag name is one of: "h1", "h2", "h3", "h4", "h5", "h6" + * > A start tag whose tag name is "meta" */ - case '+H1': - case '+H2': - case '+H3': - case '+H4': - case '+H5': - case '+H6': - if ( $this->state->stack_of_open_elements->has_p_in_button_scope() ) { - $this->close_a_p_element(); + case '+META': + $this->insert_html_element( $this->state->current_token ); + + /* + * > If the active speculative HTML parser is null, then: + * > - If the element has a charset attribute, and getting an encoding from + * > its value results in an encoding, and the confidence is currently + * > tentative, then change the encoding to the resulting encoding. + */ + $charset = $this->get_attribute( 'charset' ); + if ( is_string( $charset ) && 'tentative' === $this->state->encoding_confidence ) { + $this->bail( 'Cannot yet process META tags with charset to determine encoding.' ); } + /* + * > - Otherwise, if the element has an http-equiv attribute whose value is + * > an ASCII case-insensitive match for the string "Content-Type", and + * > the element has a content attribute, and applying the algorithm for + * > extracting a character encoding from a meta element to that attribute's + * > value returns an encoding, and the confidence is currently tentative, + * > then change the encoding to the extracted encoding. + */ + $http_equiv = $this->get_attribute( 'http-equiv' ); + $content = $this->get_attribute( 'content' ); if ( - in_array( - $this->state->stack_of_open_elements->current_node()->node_name, - array( 'H1', 'H2', 'H3', 'H4', 'H5', 'H6' ), - true - ) + is_string( $http_equiv ) && + is_string( $content ) && + 0 === strcasecmp( $http_equiv, 'Content-Type' ) && + 'tentative' === $this->state->encoding_confidence ) { - // @todo Indicate a parse error once it's possible. - $this->state->stack_of_open_elements->pop(); + $this->bail( 'Cannot yet process META tags with http-equiv Content-Type to determine encoding.' ); } - $this->insert_html_element( $this->state->current_token ); return true; /* - * > A start tag whose tag name is one of: "pre", "listing" + * > A start tag whose tag name is "title" */ - case '+PRE': - case '+LISTING': - if ( $this->state->stack_of_open_elements->has_p_in_button_scope() ) { - $this->close_a_p_element(); - } - - /* - * > If the next token is a U+000A LINE FEED (LF) character token, - * > then ignore that token and move on to the next one. (Newlines - * > at the start of pre blocks are ignored as an authoring convenience.) - * - * This is handled in `get_modifiable_text()`. - */ - + case '+TITLE': $this->insert_html_element( $this->state->current_token ); - $this->state->frameset_ok = false; return true; /* - * > A start tag whose tag name is "form" + * > A start tag whose tag name is "noscript", if the scripting flag is enabled + * > A start tag whose tag name is one of: "noframes", "style" + * + * The scripting flag is never enabled in this parser. */ - case '+FORM': - $stack_contains_template = $this->state->stack_of_open_elements->contains( 'TEMPLATE' ); - - if ( isset( $this->state->form_element ) && ! $stack_contains_template ) { - // Parse error: ignore the token. - return $this->step(); - } - - if ( $this->state->stack_of_open_elements->has_p_in_button_scope() ) { - $this->close_a_p_element(); - } - + case '+NOFRAMES': + case '+STYLE': $this->insert_html_element( $this->state->current_token ); - if ( ! $stack_contains_template ) { - $this->state->form_element = $this->state->current_token; - } - return true; /* - * > A start tag whose tag name is "li" - * > A start tag whose tag name is one of: "dd", "dt" + * > A start tag whose tag name is "noscript", if the scripting flag is disabled */ - case '+DD': + case '+NOSCRIPT': + $this->insert_html_element( $this->state->current_token ); + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_IN_HEAD_NOSCRIPT; + return true; + + /* + * > A start tag whose tag name is "script" + * + * @todo Could the adjusted insertion location be anything other than the current location? + */ + case '+SCRIPT': + $this->insert_html_element( $this->state->current_token ); + return true; + + /* + * > An end tag whose tag name is "head" + */ + case '-HEAD': + $this->state->stack_of_open_elements->pop(); + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_AFTER_HEAD; + return true; + + /* + * > An end tag whose tag name is one of: "body", "html", "br" + * + * BR tags are always reported by the Tag Processor as opening tags. + */ + case '-BODY': + case '-HTML': + /* + * > Act as described in the "anything else" entry below. + */ + goto in_head_anything_else; + break; + + /* + * > A start tag whose tag name is "template" + * + * @todo Could the adjusted insertion location be anything other than the current location? + */ + case '+TEMPLATE': + $this->state->active_formatting_elements->insert_marker(); + $this->state->frameset_ok = false; + + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_IN_TEMPLATE; + $this->state->stack_of_template_insertion_modes[] = WP_HTML_Processor_State::INSERTION_MODE_IN_TEMPLATE; + + $this->insert_html_element( $this->state->current_token ); + return true; + + /* + * > An end tag whose tag name is "template" + */ + case '-TEMPLATE': + if ( ! $this->state->stack_of_open_elements->contains( 'TEMPLATE' ) ) { + // @todo Indicate a parse error once it's possible. + return $this->step(); + } + + $this->generate_implied_end_tags_thoroughly(); + if ( ! $this->state->stack_of_open_elements->current_node_is( 'TEMPLATE' ) ) { + // @todo Indicate a parse error once it's possible. + } + + $this->state->stack_of_open_elements->pop_until( 'TEMPLATE' ); + $this->state->active_formatting_elements->clear_up_to_last_marker(); + array_pop( $this->state->stack_of_template_insertion_modes ); + $this->reset_insertion_mode_appropriately(); + return true; + } + + /* + * > A start tag whose tag name is "head" + * > Any other end tag + */ + if ( '+HEAD' === $op || $is_closer ) { + // Parse error: ignore the token. + return $this->step(); + } + + /* + * > Anything else + */ + in_head_anything_else: + $this->state->stack_of_open_elements->pop(); + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_AFTER_HEAD; + return $this->step( self::REPROCESS_CURRENT_NODE ); + } + + /** + * Parses next element in the 'in head noscript' insertion mode. + * + * This internal function performs the 'in head noscript' insertion mode + * logic for the generalized WP_HTML_Processor::step() function. + * + * @since 6.7.0 Stub implementation. + * + * @throws WP_HTML_Unsupported_Exception When encountering unsupported HTML input. + * + * @see https://html.spec.whatwg.org/#parsing-main-inheadnoscript + * @see WP_HTML_Processor::step + * + * @return bool Whether an element was found. + */ + private function step_in_head_noscript(): bool { + $token_name = $this->get_token_name(); + $token_type = $this->get_token_type(); + $is_closer = parent::is_tag_closer(); + $op_sigil = '#tag' === $token_type ? ( $is_closer ? '-' : '+' ) : ''; + $op = "{$op_sigil}{$token_name}"; + + switch ( $op ) { + /* + * > A character token that is one of U+0009 CHARACTER TABULATION, + * > U+000A LINE FEED (LF), U+000C FORM FEED (FF), + * > U+000D CARRIAGE RETURN (CR), or U+0020 SPACE + * + * Parse error: ignore the token. + */ + case '#text': + $text = $this->get_modifiable_text(); + if ( strlen( $text ) === strspn( $text, " \t\n\f\r" ) ) { + return $this->step_in_head(); + } + + goto in_head_noscript_anything_else; + break; + + /* + * > A DOCTYPE token + */ + case 'html': + // Parse error: ignore the token. + return $this->step(); + + /* + * > A start tag whose tag name is "html" + */ + case '+HTML': + return $this->step_in_body(); + + /* + * > An end tag whose tag name is "noscript" + */ + case '-NOSCRIPT': + $this->state->stack_of_open_elements->pop(); + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_IN_HEAD; + return true; + + /* + * > A comment token + * > + * > A start tag whose tag name is one of: "basefont", "bgsound", + * > "link", "meta", "noframes", "style" + */ + case '#comment': + case '#funky-comment': + case '#presumptuous-tag': + case '+BASEFONT': + case '+BGSOUND': + case '+LINK': + case '+META': + case '+NOFRAMES': + case '+STYLE': + return $this->step_in_head(); + + /* + * > An end tag whose tag name is "br" + * + * This should never happen, as the Tag Processor prevents showing a BR closing tag. + */ + } + + /* + * > A start tag whose tag name is one of: "head", "noscript" + * > Any other end tag + */ + if ( '+HEAD' === $op || '+NOSCRIPT' === $op || $is_closer ) { + // Parse error: ignore the token. + return $this->step(); + } + + /* + * > Anything else + * + * Anything here is a parse error. + */ + in_head_noscript_anything_else: + $this->state->stack_of_open_elements->pop(); + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_IN_HEAD; + return $this->step( self::REPROCESS_CURRENT_NODE ); + } + + /** + * Parses next element in the 'after head' insertion mode. + * + * This internal function performs the 'after head' insertion mode + * logic for the generalized WP_HTML_Processor::step() function. + * + * @since 6.7.0 Stub implementation. + * + * @throws WP_HTML_Unsupported_Exception When encountering unsupported HTML input. + * + * @see https://html.spec.whatwg.org/#the-after-head-insertion-mode + * @see WP_HTML_Processor::step + * + * @return bool Whether an element was found. + */ + private function step_after_head(): bool { + $token_name = $this->get_token_name(); + $token_type = $this->get_token_type(); + $is_closer = parent::is_tag_closer(); + $op_sigil = '#tag' === $token_type ? ( $is_closer ? '-' : '+' ) : ''; + $op = "{$op_sigil}{$token_name}"; + + switch ( $op ) { + /* + * > A character token that is one of U+0009 CHARACTER TABULATION, + * > U+000A LINE FEED (LF), U+000C FORM FEED (FF), + * > U+000D CARRIAGE RETURN (CR), or U+0020 SPACE + */ + case '#text': + $text = $this->get_modifiable_text(); + if ( strlen( $text ) === strspn( $text, " \t\n\f\r" ) ) { + // Insert the character. + $this->insert_html_element( $this->state->current_token ); + return true; + } + goto after_head_anything_else; + break; + + /* + * > A comment token + */ + case '#comment': + case '#funky-comment': + case '#presumptuous-tag': + $this->insert_html_element( $this->state->current_token ); + return true; + + /* + * > A DOCTYPE token + */ + case 'html': + // Parse error: ignore the token. + return $this->step(); + + /* + * > A start tag whose tag name is "html" + */ + case '+HTML': + return $this->step_in_body(); + + /* + * > A start tag whose tag name is "body" + */ + case '+BODY': + $this->insert_html_element( $this->state->current_token ); + $this->state->frameset_ok = false; + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_IN_BODY; + return true; + + /* + * > A start tag whose tag name is "frameset" + */ + case '+FRAMESET': + $this->insert_html_element( $this->state->current_token ); + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_IN_FRAMESET; + return true; + + /* + * > A start tag whose tag name is one of: "base", "basefont", "bgsound", + * > "link", "meta", "noframes", "script", "style", "template", "title" + * + * Anything here is a parse error. + */ + case '+BASE': + case '+BASEFONT': + case '+BGSOUND': + case '+LINK': + case '+META': + case '+NOFRAMES': + case '+SCRIPT': + case '+STYLE': + case '+TEMPLATE': + case '+TITLE': + /* + * > Push the node pointed to by the head element pointer onto the stack of open elements. + * > Process the token using the rules for the "in head" insertion mode. + * > Remove the node pointed to by the head element pointer from the stack of open elements. (It might not be the current node at this point.) + */ + $this->bail( 'Cannot process elements after HEAD which reopen the HEAD element.' ); + /* + * Do not leave this break in when adding support; it's here to prevent + * WPCS from getting confused at the switch structure without a return, + * because it doesn't know that `bail()` always throws. + */ + break; + + /* + * > An end tag whose tag name is "template" + */ + case '-TEMPLATE': + return $this->step_in_head(); + + /* + * > An end tag whose tag name is one of: "body", "html", "br" + * + * Closing BR tags are always reported by the Tag Processor as opening tags. + */ + case '-BODY': + case '-HTML': + /* + * > Act as described in the "anything else" entry below. + */ + goto after_head_anything_else; + break; + } + + /* + * > A start tag whose tag name is "head" + * > Any other end tag + */ + if ( '+HEAD' === $op || $is_closer ) { + // Parse error: ignore the token. + return $this->step(); + } + + /* + * > Anything else + * > Insert an HTML element for a "body" start tag token with no attributes. + */ + after_head_anything_else: + $this->insert_virtual_node( 'BODY' ); + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_IN_BODY; + return $this->step( self::REPROCESS_CURRENT_NODE ); + } + + /** + * Parses next element in the 'in body' insertion mode. + * + * This internal function performs the 'in body' insertion mode + * logic for the generalized WP_HTML_Processor::step() function. + * + * @since 6.4.0 + * + * @throws WP_HTML_Unsupported_Exception When encountering unsupported HTML input. + * + * @see https://html.spec.whatwg.org/#parsing-main-inbody + * @see WP_HTML_Processor::step + * + * @return bool Whether an element was found. + */ + private function step_in_body(): bool { + $token_name = $this->get_token_name(); + $token_type = $this->get_token_type(); + $op_sigil = '#tag' === $token_type ? ( parent::is_tag_closer() ? '-' : '+' ) : ''; + $op = "{$op_sigil}{$token_name}"; + + switch ( $op ) { + case '#text': + $current_token = $this->bookmarks[ $this->state->current_token->bookmark_name ]; + + /* + * > A character token that is U+0000 NULL + * + * Any successive sequence of NULL bytes is ignored and won't + * trigger active format reconstruction. Therefore, if the text + * only comprises NULL bytes then the token should be ignored + * here, but if there are any other characters in the stream + * the active formats should be reconstructed. + */ + if ( + 1 <= $current_token->length && + "\x00" === $this->html[ $current_token->start ] && + strspn( $this->html, "\x00", $current_token->start, $current_token->length ) === $current_token->length + ) { + // Parse error: ignore the token. + return $this->step(); + } + + $this->reconstruct_active_formatting_elements(); + + /* + * Whitespace-only text does not affect the frameset-ok flag. + * It is probably inter-element whitespace, but it may also + * contain character references which decode only to whitespace. + */ + $text = $this->get_modifiable_text(); + if ( strlen( $text ) !== strspn( $text, " \t\n\f\r" ) ) { + $this->state->frameset_ok = false; + } + + $this->insert_html_element( $this->state->current_token ); + return true; + + case '#comment': + case '#funky-comment': + case '#presumptuous-tag': + $this->insert_html_element( $this->state->current_token ); + return true; + + /* + * > A DOCTYPE token + * > Parse error. Ignore the token. + */ + case 'html': + return $this->step(); + + /* + * > A start tag whose tag name is "html" + */ + case '+HTML': + if ( ! $this->state->stack_of_open_elements->contains( 'TEMPLATE' ) ) { + /* + * > Otherwise, for each attribute on the token, check to see if the attribute + * > is already present on the top element of the stack of open elements. If + * > it is not, add the attribute and its corresponding value to that element. + * + * This parser does not currently support this behavior: ignore the token. + */ + } + + // Ignore the token. + return $this->step(); + + /* + * > A start tag whose tag name is one of: "base", "basefont", "bgsound", "link", + * > "meta", "noframes", "script", "style", "template", "title" + * > + * > An end tag whose tag name is "template" + */ + case '+BASE': + case '+BASEFONT': + case '+BGSOUND': + case '+LINK': + case '+META': + case '+NOFRAMES': + case '+SCRIPT': + case '+STYLE': + case '+TEMPLATE': + case '+TITLE': + case '-TEMPLATE': + return $this->step_in_head(); + + /* + * > A start tag whose tag name is "body" + * + * This tag in the IN BODY insertion mode is a parse error. + */ + case '+BODY': + if ( + 1 === $this->state->stack_of_open_elements->count() || + 'BODY' !== ( $this->state->stack_of_open_elements->at( 2 )->node_name ?? null ) || + $this->state->stack_of_open_elements->contains( 'TEMPLATE' ) + ) { + // Ignore the token. + return $this->step(); + } + + /* + * > Otherwise, set the frameset-ok flag to "not ok"; then, for each attribute + * > on the token, check to see if the attribute is already present on the body + * > element (the second element) on the stack of open elements, and if it is + * > not, add the attribute and its corresponding value to that element. + * + * This parser does not currently support this behavior: ignore the token. + */ + $this->state->frameset_ok = false; + return $this->step(); + + /* + * > A start tag whose tag name is "frameset" + * + * This tag in the IN BODY insertion mode is a parse error. + */ + case '+FRAMESET': + if ( + 1 === $this->state->stack_of_open_elements->count() || + 'BODY' !== ( $this->state->stack_of_open_elements->at( 2 )->node_name ?? null ) || + false === $this->state->frameset_ok + ) { + // Ignore the token. + return $this->step(); + } + + /* + * > Otherwise, run the following steps: + */ + $this->bail( 'Cannot process non-ignored FRAMESET tags.' ); + break; + + /* + * > An end tag whose tag name is "body" + */ + case '-BODY': + if ( ! $this->state->stack_of_open_elements->has_element_in_scope( 'BODY' ) ) { + // Parse error: ignore the token. + return $this->step(); + } + + /* + * > Otherwise, if there is a node in the stack of open elements that is not either a + * > dd element, a dt element, an li element, an optgroup element, an option element, + * > a p element, an rb element, an rp element, an rt element, an rtc element, a tbody + * > element, a td element, a tfoot element, a th element, a thread element, a tr + * > element, the body element, or the html element, then this is a parse error. + * + * There is nothing to do for this parse error, so don't check for it. + */ + + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_AFTER_BODY; + return true; + + /* + * > An end tag whose tag name is "html" + */ + case '-HTML': + if ( ! $this->state->stack_of_open_elements->has_element_in_scope( 'BODY' ) ) { + // Parse error: ignore the token. + return $this->step(); + } + + /* + * > Otherwise, if there is a node in the stack of open elements that is not either a + * > dd element, a dt element, an li element, an optgroup element, an option element, + * > a p element, an rb element, an rp element, an rt element, an rtc element, a tbody + * > element, a td element, a tfoot element, a th element, a thread element, a tr + * > element, the body element, or the html element, then this is a parse error. + * + * There is nothing to do for this parse error, so don't check for it. + */ + + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_AFTER_BODY; + return $this->step( self::REPROCESS_CURRENT_NODE ); + + /* + * > A start tag whose tag name is one of: "address", "article", "aside", + * > "blockquote", "center", "details", "dialog", "dir", "div", "dl", + * > "fieldset", "figcaption", "figure", "footer", "header", "hgroup", + * > "main", "menu", "nav", "ol", "p", "search", "section", "summary", "ul" + */ + case '+ADDRESS': + case '+ARTICLE': + case '+ASIDE': + case '+BLOCKQUOTE': + case '+CENTER': + case '+DETAILS': + case '+DIALOG': + case '+DIR': + case '+DIV': + case '+DL': + case '+FIELDSET': + case '+FIGCAPTION': + case '+FIGURE': + case '+FOOTER': + case '+HEADER': + case '+HGROUP': + case '+MAIN': + case '+MENU': + case '+NAV': + case '+OL': + case '+P': + case '+SEARCH': + case '+SECTION': + case '+SUMMARY': + case '+UL': + if ( $this->state->stack_of_open_elements->has_p_in_button_scope() ) { + $this->close_a_p_element(); + } + + $this->insert_html_element( $this->state->current_token ); + return true; + + /* + * > A start tag whose tag name is one of: "h1", "h2", "h3", "h4", "h5", "h6" + */ + case '+H1': + case '+H2': + case '+H3': + case '+H4': + case '+H5': + case '+H6': + if ( $this->state->stack_of_open_elements->has_p_in_button_scope() ) { + $this->close_a_p_element(); + } + + if ( + in_array( + $this->state->stack_of_open_elements->current_node()->node_name, + array( 'H1', 'H2', 'H3', 'H4', 'H5', 'H6' ), + true + ) + ) { + // @todo Indicate a parse error once it's possible. + $this->state->stack_of_open_elements->pop(); + } + + $this->insert_html_element( $this->state->current_token ); + return true; + + /* + * > A start tag whose tag name is one of: "pre", "listing" + */ + case '+PRE': + case '+LISTING': + if ( $this->state->stack_of_open_elements->has_p_in_button_scope() ) { + $this->close_a_p_element(); + } + + /* + * > If the next token is a U+000A LINE FEED (LF) character token, + * > then ignore that token and move on to the next one. (Newlines + * > at the start of pre blocks are ignored as an authoring convenience.) + * + * This is handled in `get_modifiable_text()`. + */ + + $this->insert_html_element( $this->state->current_token ); + $this->state->frameset_ok = false; + return true; + + /* + * > A start tag whose tag name is "form" + */ + case '+FORM': + $stack_contains_template = $this->state->stack_of_open_elements->contains( 'TEMPLATE' ); + + if ( isset( $this->state->form_element ) && ! $stack_contains_template ) { + // Parse error: ignore the token. + return $this->step(); + } + + if ( $this->state->stack_of_open_elements->has_p_in_button_scope() ) { + $this->close_a_p_element(); + } + + $this->insert_html_element( $this->state->current_token ); + if ( ! $stack_contains_template ) { + $this->state->form_element = $this->state->current_token; + } + + return true; + + /* + * > A start tag whose tag name is "li" + * > A start tag whose tag name is one of: "dd", "dt" + */ + case '+DD': case '+DT': case '+LI': $this->state->frameset_ok = false; - $node = $this->state->stack_of_open_elements->current_node(); - $is_li = 'LI' === $token_name; + $node = $this->state->stack_of_open_elements->current_node(); + $is_li = 'LI' === $token_name; + + in_body_list_loop: + /* + * The logic for LI and DT/DD is the same except for one point: LI elements _only_ + * close other LI elements, but a DT or DD element closes _any_ open DT or DD element. + */ + if ( $is_li ? 'LI' === $node->node_name : ( 'DD' === $node->node_name || 'DT' === $node->node_name ) ) { + $node_name = $is_li ? 'LI' : $node->node_name; + $this->generate_implied_end_tags( $node_name ); + if ( ! $this->state->stack_of_open_elements->current_node_is( $node_name ) ) { + // @todo Indicate a parse error once it's possible. This error does not impact the logic here. + } + + $this->state->stack_of_open_elements->pop_until( $node_name ); + goto in_body_list_done; + } + + if ( + 'ADDRESS' !== $node->node_name && + 'DIV' !== $node->node_name && + 'P' !== $node->node_name && + self::is_special( $node ) + ) { + /* + * > If node is in the special category, but is not an address, div, + * > or p element, then jump to the step labeled done below. + */ + goto in_body_list_done; + } else { + /* + * > Otherwise, set node to the previous entry in the stack of open elements + * > and return to the step labeled loop. + */ + foreach ( $this->state->stack_of_open_elements->walk_up( $node ) as $item ) { + $node = $item; + break; + } + goto in_body_list_loop; + } + + in_body_list_done: + if ( $this->state->stack_of_open_elements->has_p_in_button_scope() ) { + $this->close_a_p_element(); + } + + $this->insert_html_element( $this->state->current_token ); + return true; + + case '+PLAINTEXT': + if ( $this->state->stack_of_open_elements->has_p_in_button_scope() ) { + $this->close_a_p_element(); + } + + /* + * @todo This may need to be handled in the Tag Processor and turn into + * a single self-contained tag like TEXTAREA, whose modifiable text + * is the rest of the input document as plaintext. + */ + $this->bail( 'Cannot process PLAINTEXT elements.' ); + break; + + /* + * > A start tag whose tag name is "button" + */ + case '+BUTTON': + if ( $this->state->stack_of_open_elements->has_element_in_scope( 'BUTTON' ) ) { + // @todo Indicate a parse error once it's possible. This error does not impact the logic here. + $this->generate_implied_end_tags(); + $this->state->stack_of_open_elements->pop_until( 'BUTTON' ); + } + + $this->reconstruct_active_formatting_elements(); + $this->insert_html_element( $this->state->current_token ); + $this->state->frameset_ok = false; + + return true; + + /* + * > An end tag whose tag name is one of: "address", "article", "aside", "blockquote", + * > "button", "center", "details", "dialog", "dir", "div", "dl", "fieldset", + * > "figcaption", "figure", "footer", "header", "hgroup", "listing", "main", + * > "menu", "nav", "ol", "pre", "search", "section", "summary", "ul" + */ + case '-ADDRESS': + case '-ARTICLE': + case '-ASIDE': + case '-BLOCKQUOTE': + case '-BUTTON': + case '-CENTER': + case '-DETAILS': + case '-DIALOG': + case '-DIR': + case '-DIV': + case '-DL': + case '-FIELDSET': + case '-FIGCAPTION': + case '-FIGURE': + case '-FOOTER': + case '-HEADER': + case '-HGROUP': + case '-LISTING': + case '-MAIN': + case '-MENU': + case '-NAV': + case '-OL': + case '-PRE': + case '-SEARCH': + case '-SECTION': + case '-SUMMARY': + case '-UL': + if ( ! $this->state->stack_of_open_elements->has_element_in_scope( $token_name ) ) { + // @todo Report parse error. + // Ignore the token. + return $this->step(); + } + + $this->generate_implied_end_tags(); + if ( ! $this->state->stack_of_open_elements->current_node_is( $token_name ) ) { + // @todo Record parse error: this error doesn't impact parsing. + } + $this->state->stack_of_open_elements->pop_until( $token_name ); + return true; + + /* + * > An end tag whose tag name is "form" + */ + case '-FORM': + if ( ! $this->state->stack_of_open_elements->contains( 'TEMPLATE' ) ) { + $node = $this->state->form_element; + $this->state->form_element = null; + + /* + * > If node is null or if the stack of open elements does not have node + * > in scope, then this is a parse error; return and ignore the token. + * + * @todo It's necessary to check if the form token itself is in scope, not + * simply whether any FORM is in scope. + */ + if ( + null === $node || + ! $this->state->stack_of_open_elements->has_element_in_scope( 'FORM' ) + ) { + // Parse error: ignore the token. + return $this->step(); + } + + $this->generate_implied_end_tags(); + if ( $node !== $this->state->stack_of_open_elements->current_node() ) { + // @todo Indicate a parse error once it's possible. This error does not impact the logic here. + $this->bail( 'Cannot close a FORM when other elements remain open as this would throw off the breadcrumbs for the following tokens.' ); + } + + $this->state->stack_of_open_elements->remove_node( $node ); + } else { + /* + * > If the stack of open elements does not have a form element in scope, + * > then this is a parse error; return and ignore the token. + * + * Note that unlike in the clause above, this is checking for any FORM in scope. + */ + if ( ! $this->state->stack_of_open_elements->has_element_in_scope( 'FORM' ) ) { + // Parse error: ignore the token. + return $this->step(); + } + + $this->generate_implied_end_tags(); + + if ( ! $this->state->stack_of_open_elements->current_node_is( 'FORM' ) ) { + // @todo Indicate a parse error once it's possible. This error does not impact the logic here. + } + + $this->state->stack_of_open_elements->pop_until( 'FORM' ); + return true; + } + break; + + /* + * > An end tag whose tag name is "p" + */ + case '-P': + if ( ! $this->state->stack_of_open_elements->has_p_in_button_scope() ) { + $this->insert_html_element( $this->state->current_token ); + } + + $this->close_a_p_element(); + return true; + + /* + * > An end tag whose tag name is "li" + * > An end tag whose tag name is one of: "dd", "dt" + */ + case '-DD': + case '-DT': + case '-LI': + if ( + /* + * An end tag whose tag name is "li": + * If the stack of open elements does not have an li element in list item scope, + * then this is a parse error; ignore the token. + */ + ( + 'LI' === $token_name && + ! $this->state->stack_of_open_elements->has_element_in_list_item_scope( 'LI' ) + ) || + /* + * An end tag whose tag name is one of: "dd", "dt": + * If the stack of open elements does not have an element in scope that is an + * HTML element with the same tag name as that of the token, then this is a + * parse error; ignore the token. + */ + ( + 'LI' !== $token_name && + ! $this->state->stack_of_open_elements->has_element_in_scope( $token_name ) + ) + ) { + /* + * This is a parse error, ignore the token. + * + * @todo Indicate a parse error once it's possible. + */ + return $this->step(); + } + + $this->generate_implied_end_tags( $token_name ); + + if ( ! $this->state->stack_of_open_elements->current_node_is( $token_name ) ) { + // @todo Indicate a parse error once it's possible. This error does not impact the logic here. + } + + $this->state->stack_of_open_elements->pop_until( $token_name ); + return true; + + /* + * > An end tag whose tag name is one of: "h1", "h2", "h3", "h4", "h5", "h6" + */ + case '-H1': + case '-H2': + case '-H3': + case '-H4': + case '-H5': + case '-H6': + if ( ! $this->state->stack_of_open_elements->has_element_in_scope( '(internal: H1 through H6 - do not use)' ) ) { + /* + * This is a parse error; ignore the token. + * + * @todo Indicate a parse error once it's possible. + */ + return $this->step(); + } + + $this->generate_implied_end_tags(); + + if ( ! $this->state->stack_of_open_elements->current_node_is( $token_name ) ) { + // @todo Record parse error: this error doesn't impact parsing. + } + + $this->state->stack_of_open_elements->pop_until( '(internal: H1 through H6 - do not use)' ); + return true; + + /* + * > A start tag whose tag name is "a" + */ + case '+A': + foreach ( $this->state->active_formatting_elements->walk_up() as $item ) { + switch ( $item->node_name ) { + case 'marker': + break; + + case 'A': + $this->run_adoption_agency_algorithm(); + $this->state->active_formatting_elements->remove_node( $item ); + $this->state->stack_of_open_elements->remove_node( $item ); + break; + } + } + + $this->reconstruct_active_formatting_elements(); + $this->insert_html_element( $this->state->current_token ); + $this->state->active_formatting_elements->push( $this->state->current_token ); + return true; + + /* + * > A start tag whose tag name is one of: "b", "big", "code", "em", "font", "i", + * > "s", "small", "strike", "strong", "tt", "u" + */ + case '+B': + case '+BIG': + case '+CODE': + case '+EM': + case '+FONT': + case '+I': + case '+S': + case '+SMALL': + case '+STRIKE': + case '+STRONG': + case '+TT': + case '+U': + $this->reconstruct_active_formatting_elements(); + $this->insert_html_element( $this->state->current_token ); + $this->state->active_formatting_elements->push( $this->state->current_token ); + return true; + + /* + * > A start tag whose tag name is "nobr" + */ + case '+NOBR': + $this->reconstruct_active_formatting_elements(); + + if ( $this->state->stack_of_open_elements->has_element_in_scope( 'NOBR' ) ) { + // Parse error. + $this->run_adoption_agency_algorithm(); + $this->reconstruct_active_formatting_elements(); + } + + $this->insert_html_element( $this->state->current_token ); + $this->state->active_formatting_elements->push( $this->state->current_token ); + return true; + + /* + * > An end tag whose tag name is one of: "a", "b", "big", "code", "em", "font", "i", + * > "nobr", "s", "small", "strike", "strong", "tt", "u" + */ + case '-A': + case '-B': + case '-BIG': + case '-CODE': + case '-EM': + case '-FONT': + case '-I': + case '-S': + case '-SMALL': + case '-STRIKE': + case '-STRONG': + case '-TT': + case '-U': + $this->run_adoption_agency_algorithm(); + return true; + + /* + * > A start tag whose tag name is one of: "applet", "marquee", "object" + */ + case '+APPLET': + case '+MARQUEE': + case '+OBJECT': + $this->reconstruct_active_formatting_elements(); + $this->insert_html_element( $this->state->current_token ); + $this->state->active_formatting_elements->insert_marker(); + $this->state->frameset_ok = false; + return true; + + /* + * > A end tag token whose tag name is one of: "applet", "marquee", "object" + */ + case '-APPLET': + case '-MARQUEE': + case '-OBJECT': + if ( ! $this->state->stack_of_open_elements->has_element_in_scope( $token_name ) ) { + // Parse error: ignore the token. + return $this->step(); + } + + $this->generate_implied_end_tags(); + if ( ! $this->state->stack_of_open_elements->current_node_is( $token_name ) ) { + // This is a parse error. + } + + $this->state->stack_of_open_elements->pop_until( $token_name ); + $this->state->active_formatting_elements->clear_up_to_last_marker(); + return true; + + /* + * > A start tag whose tag name is "table" + */ + case '+TABLE': + /* + * > If the Document is not set to quirks mode, and the stack of open elements + * > has a p element in button scope, then close a p element. + */ + if ( + WP_HTML_Processor_State::QUIRKS_MODE !== $this->state->document_mode && + $this->state->stack_of_open_elements->has_p_in_button_scope() + ) { + $this->close_a_p_element(); + } + + $this->insert_html_element( $this->state->current_token ); + $this->state->frameset_ok = false; + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_IN_TABLE; + return true; + + /* + * > An end tag whose tag name is "br" + * + * This is prevented from happening because the Tag Processor + * reports all closing BR tags as if they were opening tags. + */ + + /* + * > A start tag whose tag name is one of: "area", "br", "embed", "img", "keygen", "wbr" + */ + case '+AREA': + case '+BR': + case '+EMBED': + case '+IMG': + case '+KEYGEN': + case '+WBR': + $this->reconstruct_active_formatting_elements(); + $this->insert_html_element( $this->state->current_token ); + $this->state->frameset_ok = false; + return true; + + /* + * > A start tag whose tag name is "input" + */ + case '+INPUT': + $this->reconstruct_active_formatting_elements(); + $this->insert_html_element( $this->state->current_token ); + + /* + * > If the token does not have an attribute with the name "type", or if it does, + * > but that attribute's value is not an ASCII case-insensitive match for the + * > string "hidden", then: set the frameset-ok flag to "not ok". + */ + $type_attribute = $this->get_attribute( 'type' ); + if ( ! is_string( $type_attribute ) || 'hidden' !== strtolower( $type_attribute ) ) { + $this->state->frameset_ok = false; + } + + return true; + + /* + * > A start tag whose tag name is one of: "param", "source", "track" + */ + case '+PARAM': + case '+SOURCE': + case '+TRACK': + $this->insert_html_element( $this->state->current_token ); + return true; + + /* + * > A start tag whose tag name is "hr" + */ + case '+HR': + if ( $this->state->stack_of_open_elements->has_p_in_button_scope() ) { + $this->close_a_p_element(); + } + $this->insert_html_element( $this->state->current_token ); + $this->state->frameset_ok = false; + return true; + + /* + * > A start tag whose tag name is "image" + */ + case '+IMAGE': + /* + * > Parse error. Change the token's tag name to "img" and reprocess it. (Don't ask.) + * + * Note that this is handled elsewhere, so it should not be possible to reach this code. + */ + $this->bail( "Cannot process an IMAGE tag. (Don't ask.)" ); + break; + + /* + * > A start tag whose tag name is "textarea" + */ + case '+TEXTAREA': + $this->insert_html_element( $this->state->current_token ); + + /* + * > If the next token is a U+000A LINE FEED (LF) character token, then ignore + * > that token and move on to the next one. (Newlines at the start of + * > textarea elements are ignored as an authoring convenience.) + * + * This is handled in `get_modifiable_text()`. + */ + + $this->state->frameset_ok = false; + + /* + * > Switch the insertion mode to "text". + * + * As a self-contained node, this behavior is handled in the Tag Processor. + */ + return true; + + /* + * > A start tag whose tag name is "xmp" + */ + case '+XMP': + if ( $this->state->stack_of_open_elements->has_p_in_button_scope() ) { + $this->close_a_p_element(); + } + + $this->reconstruct_active_formatting_elements(); + $this->state->frameset_ok = false; + + /* + * > Follow the generic raw text element parsing algorithm. + * + * As a self-contained node, this behavior is handled in the Tag Processor. + */ + $this->insert_html_element( $this->state->current_token ); + return true; + + /* + * A start tag whose tag name is "iframe" + */ + case '+IFRAME': + $this->state->frameset_ok = false; + + /* + * > Follow the generic raw text element parsing algorithm. + * + * As a self-contained node, this behavior is handled in the Tag Processor. + */ + $this->insert_html_element( $this->state->current_token ); + return true; + + /* + * > A start tag whose tag name is "noembed" + * > A start tag whose tag name is "noscript", if the scripting flag is enabled + * + * The scripting flag is never enabled in this parser. + */ + case '+NOEMBED': + $this->insert_html_element( $this->state->current_token ); + return true; + + /* + * > A start tag whose tag name is "select" + */ + case '+SELECT': + $this->reconstruct_active_formatting_elements(); + $this->insert_html_element( $this->state->current_token ); + $this->state->frameset_ok = false; + + switch ( $this->state->insertion_mode ) { + /* + * > If the insertion mode is one of "in table", "in caption", "in table body", "in row", + * > or "in cell", then switch the insertion mode to "in select in table". + */ + case WP_HTML_Processor_State::INSERTION_MODE_IN_TABLE: + case WP_HTML_Processor_State::INSERTION_MODE_IN_CAPTION: + case WP_HTML_Processor_State::INSERTION_MODE_IN_TABLE_BODY: + case WP_HTML_Processor_State::INSERTION_MODE_IN_ROW: + case WP_HTML_Processor_State::INSERTION_MODE_IN_CELL: + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_IN_SELECT_IN_TABLE; + break; + + /* + * > Otherwise, switch the insertion mode to "in select". + */ + default: + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_IN_SELECT; + break; + } + return true; + + /* + * > A start tag whose tag name is one of: "optgroup", "option" + */ + case '+OPTGROUP': + case '+OPTION': + if ( $this->state->stack_of_open_elements->current_node_is( 'OPTION' ) ) { + $this->state->stack_of_open_elements->pop(); + } + $this->reconstruct_active_formatting_elements(); + $this->insert_html_element( $this->state->current_token ); + return true; + + /* + * > A start tag whose tag name is one of: "rb", "rtc" + */ + case '+RB': + case '+RTC': + if ( $this->state->stack_of_open_elements->has_element_in_scope( 'RUBY' ) ) { + $this->generate_implied_end_tags(); + + if ( $this->state->stack_of_open_elements->current_node_is( 'RUBY' ) ) { + // @todo Indicate a parse error once it's possible. + } + } + + $this->insert_html_element( $this->state->current_token ); + return true; + + /* + * > A start tag whose tag name is one of: "rp", "rt" + */ + case '+RP': + case '+RT': + if ( $this->state->stack_of_open_elements->has_element_in_scope( 'RUBY' ) ) { + $this->generate_implied_end_tags( 'RTC' ); + + $current_node_name = $this->state->stack_of_open_elements->current_node()->node_name; + if ( 'RTC' === $current_node_name || 'RUBY' === $current_node_name ) { + // @todo Indicate a parse error once it's possible. + } + } + + $this->insert_html_element( $this->state->current_token ); + return true; + + /* + * > A start tag whose tag name is "math" + */ + case '+MATH': + $this->reconstruct_active_formatting_elements(); + + /* + * @todo Adjust MathML attributes for the token. (This fixes the case of MathML attributes that are not all lowercase.) + * @todo Adjust foreign attributes for the token. (This fixes the use of namespaced attributes, in particular XLink.) + * + * These ought to be handled in the attribute methods. + */ + $this->state->current_token->namespace = 'math'; + $this->insert_html_element( $this->state->current_token ); + if ( $this->state->current_token->has_self_closing_flag ) { + $this->state->stack_of_open_elements->pop(); + } + return true; + + /* + * > A start tag whose tag name is "svg" + */ + case '+SVG': + $this->reconstruct_active_formatting_elements(); - in_body_list_loop: /* - * The logic for LI and DT/DD is the same except for one point: LI elements _only_ - * close other LI elements, but a DT or DD element closes _any_ open DT or DD element. + * @todo Adjust SVG attributes for the token. (This fixes the case of SVG attributes that are not all lowercase.) + * @todo Adjust foreign attributes for the token. (This fixes the use of namespaced attributes, in particular XLink in SVG.) + * + * These ought to be handled in the attribute methods. */ - if ( $is_li ? 'LI' === $node->node_name : ( 'DD' === $node->node_name || 'DT' === $node->node_name ) ) { - $node_name = $is_li ? 'LI' : $node->node_name; - $this->generate_implied_end_tags( $node_name ); - if ( ! $this->state->stack_of_open_elements->current_node_is( $node_name ) ) { - // @todo Indicate a parse error once it's possible. This error does not impact the logic here. - } + $this->state->current_token->namespace = 'svg'; + $this->insert_html_element( $this->state->current_token ); + if ( $this->state->current_token->has_self_closing_flag ) { + $this->state->stack_of_open_elements->pop(); + } + return true; - $this->state->stack_of_open_elements->pop_until( $node_name ); - goto in_body_list_done; + /* + * > A start tag whose tag name is one of: "caption", "col", "colgroup", + * > "frame", "head", "tbody", "td", "tfoot", "th", "thead", "tr" + */ + case '+CAPTION': + case '+COL': + case '+COLGROUP': + case '+FRAME': + case '+HEAD': + case '+TBODY': + case '+TD': + case '+TFOOT': + case '+TH': + case '+THEAD': + case '+TR': + // Parse error. Ignore the token. + return $this->step(); + } + + if ( ! parent::is_tag_closer() ) { + /* + * > Any other start tag + */ + $this->reconstruct_active_formatting_elements(); + $this->insert_html_element( $this->state->current_token ); + return true; + } else { + /* + * > Any other end tag + */ + + /* + * Find the corresponding tag opener in the stack of open elements, if + * it exists before reaching a special element, which provides a kind + * of boundary in the stack. For example, a `` should not + * close anything beyond its containing `P` or `DIV` element. + */ + foreach ( $this->state->stack_of_open_elements->walk_up() as $node ) { + if ( 'html' === $node->namespace && $token_name === $node->node_name ) { + break; + } + + if ( self::is_special( $node ) ) { + // This is a parse error, ignore the token. + return $this->step(); + } + } + + $this->generate_implied_end_tags( $token_name ); + if ( $node !== $this->state->stack_of_open_elements->current_node() ) { + // @todo Record parse error: this error doesn't impact parsing. + } + + foreach ( $this->state->stack_of_open_elements->walk_up() as $item ) { + $this->state->stack_of_open_elements->pop(); + if ( $node === $item ) { + return true; } + } + } + } + + /** + * Parses next element in the 'in table' insertion mode. + * + * This internal function performs the 'in table' insertion mode + * logic for the generalized WP_HTML_Processor::step() function. + * + * @since 6.7.0 + * + * @throws WP_HTML_Unsupported_Exception When encountering unsupported HTML input. + * + * @see https://html.spec.whatwg.org/#parsing-main-intable + * @see WP_HTML_Processor::step + * + * @return bool Whether an element was found. + */ + private function step_in_table(): bool { + $token_name = $this->get_token_name(); + $token_type = $this->get_token_type(); + $op_sigil = '#tag' === $token_type ? ( parent::is_tag_closer() ? '-' : '+' ) : ''; + $op = "{$op_sigil}{$token_name}"; + switch ( $op ) { + /* + * > A character token, if the current node is table, + * > tbody, template, tfoot, thead, or tr element + */ + case '#text': + $current_node = $this->state->stack_of_open_elements->current_node(); + $current_node_name = $current_node ? $current_node->node_name : null; if ( - 'ADDRESS' !== $node->node_name && - 'DIV' !== $node->node_name && - 'P' !== $node->node_name && - $this->is_special( $node->node_name ) + $current_node_name && ( + 'TABLE' === $current_node_name || + 'TBODY' === $current_node_name || + 'TEMPLATE' === $current_node_name || + 'TFOOT' === $current_node_name || + 'THEAD' === $current_node_name || + 'TR' === $current_node_name + ) ) { + $text = $this->get_modifiable_text(); /* - * > If node is in the special category, but is not an address, div, - * > or p element, then jump to the step labeled done below. + * If the text is empty after processing HTML entities and stripping + * U+0000 NULL bytes then ignore the token. */ - goto in_body_list_done; - } else { + if ( '' === $text ) { + return $this->step(); + } + /* - * > Otherwise, set node to the previous entry in the stack of open elements - * > and return to the step labeled loop. + * This follows the rules for "in table text" insertion mode. + * + * Whitespace-only text nodes are inserted in-place. Otherwise + * foster parenting is enabled and the nodes would be + * inserted out-of-place. + * + * > If any of the tokens in the pending table character tokens + * > list are character tokens that are not ASCII whitespace, + * > then this is a parse error: reprocess the character tokens + * > in the pending table character tokens list using the rules + * > given in the "anything else" entry in the "in table" + * > insertion mode. + * > + * > Otherwise, insert the characters given by the pending table + * > character tokens list. + * + * @see https://html.spec.whatwg.org/#parsing-main-intabletext */ - foreach ( $this->state->stack_of_open_elements->walk_up( $node ) as $item ) { - $node = $item; - break; + if ( strlen( $text ) === strspn( $text, " \t\f\r\n" ) ) { + $this->insert_html_element( $this->state->current_token ); + return true; } - goto in_body_list_loop; - } - in_body_list_done: - if ( $this->state->stack_of_open_elements->has_p_in_button_scope() ) { - $this->close_a_p_element(); + // Non-whitespace would trigger fostering, unsupported at this time. + $this->bail( 'Foster parenting is not supported.' ); + break; } + break; + /* + * > A comment token + */ + case '#comment': + case '#funky-comment': + case '#presumptuous-tag': $this->insert_html_element( $this->state->current_token ); return true; - case '+PLAINTEXT': - if ( $this->state->stack_of_open_elements->has_p_in_button_scope() ) { - $this->close_a_p_element(); - } - - /* - * @todo This may need to be handled in the Tag Processor and turn into - * a single self-contained tag like TEXTAREA, whose modifiable text - * is the rest of the input document as plaintext. - */ - $this->bail( 'Cannot process PLAINTEXT elements.' ); - break; - /* - * > A start tag whose tag name is "button" + * > A DOCTYPE token */ - case '+BUTTON': - if ( $this->state->stack_of_open_elements->has_element_in_scope( 'BUTTON' ) ) { - // @todo Indicate a parse error once it's possible. This error does not impact the logic here. - $this->generate_implied_end_tags(); - $this->state->stack_of_open_elements->pop_until( 'BUTTON' ); - } + case 'html': + // Parse error: ignore the token. + return $this->step(); - $this->reconstruct_active_formatting_elements(); + /* + * > A start tag whose tag name is "caption" + */ + case '+CAPTION': + $this->state->stack_of_open_elements->clear_to_table_context(); + $this->state->active_formatting_elements->insert_marker(); $this->insert_html_element( $this->state->current_token ); - $this->state->frameset_ok = false; + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_IN_CAPTION; + return true; + /* + * > A start tag whose tag name is "colgroup" + */ + case '+COLGROUP': + $this->state->stack_of_open_elements->clear_to_table_context(); + $this->insert_html_element( $this->state->current_token ); + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_IN_COLUMN_GROUP; return true; /* - * > An end tag whose tag name is one of: "address", "article", "aside", "blockquote", - * > "button", "center", "details", "dialog", "dir", "div", "dl", "fieldset", - * > "figcaption", "figure", "footer", "header", "hgroup", "listing", "main", - * > "menu", "nav", "ol", "pre", "search", "section", "summary", "ul" - * - * @todo This needs to check if the element in scope is an HTML element, meaning that - * when SVG and MathML support is added, this needs to differentiate between an - * HTML element of the given name, such as `

`, and a foreign element of - * the same given name. + * > A start tag whose tag name is "col" */ - case '-ADDRESS': - case '-ARTICLE': - case '-ASIDE': - case '-BLOCKQUOTE': - case '-BUTTON': - case '-CENTER': - case '-DETAILS': - case '-DIALOG': - case '-DIR': - case '-DIV': - case '-DL': - case '-FIELDSET': - case '-FIGCAPTION': - case '-FIGURE': - case '-FOOTER': - case '-HEADER': - case '-HGROUP': - case '-LISTING': - case '-MAIN': - case '-MENU': - case '-NAV': - case '-OL': - case '-PRE': - case '-SEARCH': - case '-SECTION': - case '-SUMMARY': - case '-UL': - if ( ! $this->state->stack_of_open_elements->has_element_in_scope( $token_name ) ) { - // @todo Report parse error. - // Ignore the token. - return $this->step(); - } + case '+COL': + $this->state->stack_of_open_elements->clear_to_table_context(); - $this->generate_implied_end_tags(); - if ( ! $this->state->stack_of_open_elements->current_node_is( $token_name ) ) { - // @todo Record parse error: this error doesn't impact parsing. - } - $this->state->stack_of_open_elements->pop_until( $token_name ); + /* + * > Insert an HTML element for a "colgroup" start tag token with no attributes, + * > then switch the insertion mode to "in column group". + */ + $this->insert_virtual_node( 'COLGROUP' ); + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_IN_COLUMN_GROUP; + return $this->step( self::REPROCESS_CURRENT_NODE ); + + /* + * > A start tag whose tag name is one of: "tbody", "tfoot", "thead" + */ + case '+TBODY': + case '+TFOOT': + case '+THEAD': + $this->state->stack_of_open_elements->clear_to_table_context(); + $this->insert_html_element( $this->state->current_token ); + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_IN_TABLE_BODY; return true; /* - * > An end tag whose tag name is "form" + * > A start tag whose tag name is one of: "td", "th", "tr" */ - case '-FORM': - if ( ! $this->state->stack_of_open_elements->contains( 'TEMPLATE' ) ) { - $node = $this->state->form_element; - $this->state->form_element = null; + case '+TD': + case '+TH': + case '+TR': + $this->state->stack_of_open_elements->clear_to_table_context(); + /* + * > Insert an HTML element for a "tbody" start tag token with no attributes, + * > then switch the insertion mode to "in table body". + */ + $this->insert_virtual_node( 'TBODY' ); + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_IN_TABLE_BODY; + return $this->step( self::REPROCESS_CURRENT_NODE ); - /* - * > If node is null or if the stack of open elements does not have node - * > in scope, then this is a parse error; return and ignore the token. - * - * @todo It's necessary to check if the form token itself is in scope, not - * simply whether any FORM is in scope. - */ - if ( - null === $node || - ! $this->state->stack_of_open_elements->has_element_in_scope( 'FORM' ) - ) { - // Parse error: ignore the token. - return $this->step(); - } + /* + * > A start tag whose tag name is "table" + * + * This tag in the IN TABLE insertion mode is a parse error. + */ + case '+TABLE': + if ( ! $this->state->stack_of_open_elements->has_element_in_table_scope( 'TABLE' ) ) { + return $this->step(); + } - $this->generate_implied_end_tags(); - if ( $node !== $this->state->stack_of_open_elements->current_node() ) { - // @todo Indicate a parse error once it's possible. This error does not impact the logic here. - $this->bail( 'Cannot close a FORM when other elements remain open as this would throw off the breadcrumbs for the following tokens.' ); - } + $this->state->stack_of_open_elements->pop_until( 'TABLE' ); + $this->reset_insertion_mode_appropriately(); + return $this->step( self::REPROCESS_CURRENT_NODE ); - $this->state->stack_of_open_elements->remove_node( $node ); - } else { - /* - * > If the stack of open elements does not have a form element in scope, - * > then this is a parse error; return and ignore the token. - * - * Note that unlike in the clause above, this is checking for any FORM in scope. - */ - if ( ! $this->state->stack_of_open_elements->has_element_in_scope( 'FORM' ) ) { - // Parse error: ignore the token. - return $this->step(); - } + /* + * > An end tag whose tag name is "table" + */ + case '-TABLE': + if ( ! $this->state->stack_of_open_elements->has_element_in_table_scope( 'TABLE' ) ) { + // @todo Indicate a parse error once it's possible. + return $this->step(); + } - $this->generate_implied_end_tags(); + $this->state->stack_of_open_elements->pop_until( 'TABLE' ); + $this->reset_insertion_mode_appropriately(); + return true; - if ( ! $this->state->stack_of_open_elements->current_node_is( 'FORM' ) ) { - // @todo Indicate a parse error once it's possible. This error does not impact the logic here. - } + /* + * > An end tag whose tag name is one of: "body", "caption", "col", "colgroup", "html", "tbody", "td", "tfoot", "th", "thead", "tr" + */ + case '-BODY': + case '-CAPTION': + case '-COL': + case '-COLGROUP': + case '-HTML': + case '-TBODY': + case '-TD': + case '-TFOOT': + case '-TH': + case '-THEAD': + case '-TR': + // Parse error: ignore the token. + return $this->step(); - $this->state->stack_of_open_elements->pop_until( 'FORM' ); - return true; - } - break; + /* + * > A start tag whose tag name is one of: "style", "script", "template" + * > An end tag whose tag name is "template" + */ + case '+STYLE': + case '+SCRIPT': + case '+TEMPLATE': + case '-TEMPLATE': + /* + * > Process the token using the rules for the "in head" insertion mode. + */ + return $this->step_in_head(); /* - * > An end tag whose tag name is "p" + * > A start tag whose tag name is "input" + * + * > If the token does not have an attribute with the name "type", or if it does, but + * > that attribute's value is not an ASCII case-insensitive match for the string + * > "hidden", then: act as described in the "anything else" entry below. */ - case '-P': - if ( ! $this->state->stack_of_open_elements->has_p_in_button_scope() ) { - $this->insert_html_element( $this->state->current_token ); + case '+INPUT': + $type_attribute = $this->get_attribute( 'type' ); + if ( ! is_string( $type_attribute ) || 'hidden' !== strtolower( $type_attribute ) ) { + goto anything_else; } - - $this->close_a_p_element(); + // @todo Indicate a parse error once it's possible. + $this->insert_html_element( $this->state->current_token ); return true; /* - * > An end tag whose tag name is "li" - * > An end tag whose tag name is one of: "dd", "dt" + * > A start tag whose tag name is "form" + * + * This tag in the IN TABLE insertion mode is a parse error. */ - case '-DD': - case '-DT': - case '-LI': + case '+FORM': if ( - /* - * An end tag whose tag name is "li": - * If the stack of open elements does not have an li element in list item scope, - * then this is a parse error; ignore the token. - */ - ( - 'LI' === $token_name && - ! $this->state->stack_of_open_elements->has_element_in_list_item_scope( 'LI' ) - ) || - /* - * An end tag whose tag name is one of: "dd", "dt": - * If the stack of open elements does not have an element in scope that is an - * HTML element with the same tag name as that of the token, then this is a - * parse error; ignore the token. - */ - ( - 'LI' !== $token_name && - ! $this->state->stack_of_open_elements->has_element_in_scope( $token_name ) - ) + $this->state->stack_of_open_elements->has_element_in_scope( 'TEMPLATE' ) || + isset( $this->state->form_element ) ) { - /* - * This is a parse error, ignore the token. - * - * @todo Indicate a parse error once it's possible. - */ return $this->step(); } - $this->generate_implied_end_tags( $token_name ); + // This FORM is special because it immediately closes and cannot have other children. + $this->insert_html_element( $this->state->current_token ); + $this->state->form_element = $this->state->current_token; + $this->state->stack_of_open_elements->pop(); + return true; + } - if ( ! $this->state->stack_of_open_elements->current_node_is( $token_name ) ) { - // @todo Indicate a parse error once it's possible. This error does not impact the logic here. + /* + * > Anything else + * > Parse error. Enable foster parenting, process the token using the rules for the + * > "in body" insertion mode, and then disable foster parenting. + * + * @todo Indicate a parse error once it's possible. + */ + anything_else: + $this->bail( 'Foster parenting is not supported.' ); + } + + /** + * Parses next element in the 'in table text' insertion mode. + * + * This internal function performs the 'in table text' insertion mode + * logic for the generalized WP_HTML_Processor::step() function. + * + * @since 6.7.0 Stub implementation. + * + * @throws WP_HTML_Unsupported_Exception When encountering unsupported HTML input. + * + * @see https://html.spec.whatwg.org/#parsing-main-intabletext + * @see WP_HTML_Processor::step + * + * @return bool Whether an element was found. + */ + private function step_in_table_text(): bool { + $this->bail( 'No support for parsing in the ' . WP_HTML_Processor_State::INSERTION_MODE_IN_TABLE_TEXT . ' state.' ); + } + + /** + * Parses next element in the 'in caption' insertion mode. + * + * This internal function performs the 'in caption' insertion mode + * logic for the generalized WP_HTML_Processor::step() function. + * + * @since 6.7.0 + * + * @throws WP_HTML_Unsupported_Exception When encountering unsupported HTML input. + * + * @see https://html.spec.whatwg.org/#parsing-main-incaption + * @see WP_HTML_Processor::step + * + * @return bool Whether an element was found. + */ + private function step_in_caption(): bool { + $tag_name = $this->get_tag(); + $op_sigil = $this->is_tag_closer() ? '-' : '+'; + $op = "{$op_sigil}{$tag_name}"; + + switch ( $op ) { + /* + * > An end tag whose tag name is "caption" + * > A start tag whose tag name is one of: "caption", "col", "colgroup", "tbody", "td", "tfoot", "th", "thead", "tr" + * > An end tag whose tag name is "table" + * + * These tag handling rules are identical except for the final instruction. + * Handle them in a single block. + */ + case '-CAPTION': + case '+CAPTION': + case '+COL': + case '+COLGROUP': + case '+TBODY': + case '+TD': + case '+TFOOT': + case '+TH': + case '+THEAD': + case '+TR': + case '-TABLE': + if ( ! $this->state->stack_of_open_elements->has_element_in_table_scope( 'CAPTION' ) ) { + // Parse error: ignore the token. + return $this->step(); } - $this->state->stack_of_open_elements->pop_until( $token_name ); - return true; + $this->generate_implied_end_tags(); + if ( ! $this->state->stack_of_open_elements->current_node_is( 'CAPTION' ) ) { + // @todo Indicate a parse error once it's possible. + } + + $this->state->stack_of_open_elements->pop_until( 'CAPTION' ); + $this->state->active_formatting_elements->clear_up_to_last_marker(); + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_IN_TABLE; + + // If this is not a CAPTION end tag, the token should be reprocessed. + if ( '-CAPTION' === $op ) { + return true; + } + return $this->step( self::REPROCESS_CURRENT_NODE ); + + /** + * > An end tag whose tag name is one of: "body", "col", "colgroup", "html", "tbody", "td", "tfoot", "th", "thead", "tr" + */ + case '-BODY': + case '-COL': + case '-COLGROUP': + case '-HTML': + case '-TBODY': + case '-TD': + case '-TFOOT': + case '-TH': + case '-THEAD': + case '-TR': + // Parse error: ignore the token. + return $this->step(); + } + + /** + * > Anything else + * > Process the token using the rules for the "in body" insertion mode. + */ + return $this->step_in_body(); + } + + /** + * Parses next element in the 'in column group' insertion mode. + * + * This internal function performs the 'in column group' insertion mode + * logic for the generalized WP_HTML_Processor::step() function. + * + * @since 6.7.0 + * + * @throws WP_HTML_Unsupported_Exception When encountering unsupported HTML input. + * + * @see https://html.spec.whatwg.org/#parsing-main-incolgroup + * @see WP_HTML_Processor::step + * + * @return bool Whether an element was found. + */ + private function step_in_column_group(): bool { + $token_name = $this->get_token_name(); + $token_type = $this->get_token_type(); + $op_sigil = '#tag' === $token_type ? ( parent::is_tag_closer() ? '-' : '+' ) : ''; + $op = "{$op_sigil}{$token_name}"; + switch ( $op ) { /* - * > An end tag whose tag name is one of: "h1", "h2", "h3", "h4", "h5", "h6" + * > A character token that is one of U+0009 CHARACTER TABULATION, U+000A LINE FEED (LF), + * > U+000C FORM FEED (FF), U+000D CARRIAGE RETURN (CR), or U+0020 SPACE */ - case '-H1': - case '-H2': - case '-H3': - case '-H4': - case '-H5': - case '-H6': - if ( ! $this->state->stack_of_open_elements->has_element_in_scope( '(internal: H1 through H6 - do not use)' ) ) { + case '#text': + $text = $this->get_modifiable_text(); + if ( '' === $text ) { /* - * This is a parse error; ignore the token. - * - * @todo Indicate a parse error once it's possible. + * If the text is empty after processing HTML entities and stripping + * U+0000 NULL bytes then ignore the token. */ return $this->step(); } - $this->generate_implied_end_tags(); - - if ( ! $this->state->stack_of_open_elements->current_node_is( $token_name ) ) { - // @todo Record parse error: this error doesn't impact parsing. + if ( strlen( $text ) === strspn( $text, " \t\n\f\r" ) ) { + // Insert the character. + $this->insert_html_element( $this->state->current_token ); + return true; } - $this->state->stack_of_open_elements->pop_until( '(internal: H1 through H6 - do not use)' ); + goto in_column_group_anything_else; + break; + + /* + * > A comment token + */ + case '#comment': + case '#funky-comment': + case '#presumptuous-tag': + $this->insert_html_element( $this->state->current_token ); return true; /* - * > A start tag whose tag name is "a" + * > A DOCTYPE token */ - case '+A': - foreach ( $this->state->active_formatting_elements->walk_up() as $item ) { - switch ( $item->node_name ) { - case 'marker': - break; + case 'html': + // @todo Indicate a parse error once it's possible. + return $this->step(); - case 'A': - $this->run_adoption_agency_algorithm(); - $this->state->active_formatting_elements->remove_node( $item ); - $this->state->stack_of_open_elements->remove_node( $item ); - break; - } - } + /* + * > A start tag whose tag name is "html" + */ + case '+HTML': + return $this->step_in_body(); - $this->reconstruct_active_formatting_elements(); + /* + * > A start tag whose tag name is "col" + */ + case '+COL': $this->insert_html_element( $this->state->current_token ); - $this->state->active_formatting_elements->push( $this->state->current_token ); + $this->state->stack_of_open_elements->pop(); return true; /* - * > A start tag whose tag name is one of: "b", "big", "code", "em", "font", "i", - * > "s", "small", "strike", "strong", "tt", "u" + * > An end tag whose tag name is "colgroup" */ - case '+B': - case '+BIG': - case '+CODE': - case '+EM': - case '+FONT': - case '+I': - case '+S': - case '+SMALL': - case '+STRIKE': - case '+STRONG': - case '+TT': - case '+U': - $this->reconstruct_active_formatting_elements(); - $this->insert_html_element( $this->state->current_token ); - $this->state->active_formatting_elements->push( $this->state->current_token ); + case '-COLGROUP': + if ( ! $this->state->stack_of_open_elements->current_node_is( 'COLGROUP' ) ) { + // @todo Indicate a parse error once it's possible. + return $this->step(); + } + $this->state->stack_of_open_elements->pop(); + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_IN_TABLE; return true; /* - * > A start tag whose tag name is "nobr" + * > An end tag whose tag name is "col" */ - case '+NOBR': - $this->reconstruct_active_formatting_elements(); + case '-COL': + // Parse error: ignore the token. + return $this->step(); - if ( $this->state->stack_of_open_elements->has_element_in_scope( 'NOBR' ) ) { - // Parse error. - $this->run_adoption_agency_algorithm(); - $this->reconstruct_active_formatting_elements(); - } + /* + * > A start tag whose tag name is "template" + * > An end tag whose tag name is "template" + */ + case '+TEMPLATE': + case '-TEMPLATE': + return $this->step_in_head(); + } - $this->insert_html_element( $this->state->current_token ); - $this->state->active_formatting_elements->push( $this->state->current_token ); - return true; + in_column_group_anything_else: + /* + * > Anything else + */ + if ( ! $this->state->stack_of_open_elements->current_node_is( 'COLGROUP' ) ) { + // @todo Indicate a parse error once it's possible. + return $this->step(); + } + $this->state->stack_of_open_elements->pop(); + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_IN_TABLE; + return $this->step( self::REPROCESS_CURRENT_NODE ); + } + + /** + * Parses next element in the 'in table body' insertion mode. + * + * This internal function performs the 'in table body' insertion mode + * logic for the generalized WP_HTML_Processor::step() function. + * + * @since 6.7.0 + * + * @throws WP_HTML_Unsupported_Exception When encountering unsupported HTML input. + * + * @see https://html.spec.whatwg.org/#parsing-main-intbody + * @see WP_HTML_Processor::step + * + * @return bool Whether an element was found. + */ + private function step_in_table_body(): bool { + $tag_name = $this->get_tag(); + $op_sigil = $this->is_tag_closer() ? '-' : '+'; + $op = "{$op_sigil}{$tag_name}"; + switch ( $op ) { /* - * > An end tag whose tag name is one of: "a", "b", "big", "code", "em", "font", "i", - * > "nobr", "s", "small", "strike", "strong", "tt", "u" + * > A start tag whose tag name is "tr" */ - case '-A': - case '-B': - case '-BIG': - case '-CODE': - case '-EM': - case '-FONT': - case '-I': - case '-S': - case '-SMALL': - case '-STRIKE': - case '-STRONG': - case '-TT': - case '-U': - $this->run_adoption_agency_algorithm(); + case '+TR': + $this->state->stack_of_open_elements->clear_to_table_body_context(); + $this->insert_html_element( $this->state->current_token ); + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_IN_ROW; return true; /* - * > A start tag whose tag name is one of: "applet", "marquee", "object" + * > A start tag whose tag name is one of: "th", "td" */ - case '+APPLET': - case '+MARQUEE': - case '+OBJECT': - $this->reconstruct_active_formatting_elements(); - $this->insert_html_element( $this->state->current_token ); - $this->state->active_formatting_elements->insert_marker(); - $this->state->frameset_ok = false; - return true; + case '+TH': + case '+TD': + // @todo Indicate a parse error once it's possible. + $this->state->stack_of_open_elements->clear_to_table_body_context(); + $this->insert_virtual_node( 'TR' ); + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_IN_ROW; + return $this->step( self::REPROCESS_CURRENT_NODE ); /* - * > A end tag token whose tag name is one of: "applet", "marquee", "object" - * - * @todo This needs to check if the element in scope is an HTML element, meaning that - * when SVG and MathML support is added, this needs to differentiate between an - * HTML element of the given name, such as ``, and a foreign element of - * the same given name. + * > An end tag whose tag name is one of: "tbody", "tfoot", "thead" */ - case '-APPLET': - case '-MARQUEE': - case '-OBJECT': - if ( ! $this->state->stack_of_open_elements->has_element_in_scope( $token_name ) ) { + case '-TBODY': + case '-TFOOT': + case '-THEAD': + if ( ! $this->state->stack_of_open_elements->has_element_in_table_scope( $tag_name ) ) { // Parse error: ignore the token. return $this->step(); } - $this->generate_implied_end_tags(); - if ( ! $this->state->stack_of_open_elements->current_node_is( $token_name ) ) { - // This is a parse error. - } - - $this->state->stack_of_open_elements->pop_until( $token_name ); - $this->state->active_formatting_elements->clear_up_to_last_marker(); + $this->state->stack_of_open_elements->clear_to_table_body_context(); + $this->state->stack_of_open_elements->pop(); + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_IN_TABLE; return true; /* - * > A start tag whose tag name is "table" + * > A start tag whose tag name is one of: "caption", "col", "colgroup", "tbody", "tfoot", "thead" + * > An end tag whose tag name is "table" */ - case '+TABLE': + case '+CAPTION': + case '+COL': + case '+COLGROUP': + case '+TBODY': + case '+TFOOT': + case '+THEAD': + case '-TABLE': if ( - WP_HTML_Processor_State::QUIRKS_MODE !== $this->state->document_mode && - $this->state->stack_of_open_elements->has_p_in_button_scope() + ! $this->state->stack_of_open_elements->has_element_in_table_scope( 'TBODY' ) && + ! $this->state->stack_of_open_elements->has_element_in_table_scope( 'THEAD' ) && + ! $this->state->stack_of_open_elements->has_element_in_table_scope( 'TFOOT' ) ) { - $this->close_a_p_element(); + // Parse error: ignore the token. + return $this->step(); } - - $this->insert_html_element( $this->state->current_token ); - $this->state->frameset_ok = false; + $this->state->stack_of_open_elements->clear_to_table_body_context(); + $this->state->stack_of_open_elements->pop(); $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_IN_TABLE; - return true; + return $this->step( self::REPROCESS_CURRENT_NODE ); /* - * > An end tag whose tag name is "br" - * - * This is prevented from happening because the Tag Processor - * reports all closing BR tags as if they were opening tags. + * > An end tag whose tag name is one of: "body", "caption", "col", "colgroup", "html", "td", "th", "tr" */ + case '-BODY': + case '-CAPTION': + case '-COL': + case '-COLGROUP': + case '-HTML': + case '-TD': + case '-TH': + case '-TR': + // Parse error: ignore the token. + return $this->step(); + } + + /* + * > Anything else + * > Process the token using the rules for the "in table" insertion mode. + */ + return $this->step_in_table(); + } + + /** + * Parses next element in the 'in row' insertion mode. + * + * This internal function performs the 'in row' insertion mode + * logic for the generalized WP_HTML_Processor::step() function. + * + * @since 6.7.0 + * + * @throws WP_HTML_Unsupported_Exception When encountering unsupported HTML input. + * + * @see https://html.spec.whatwg.org/#parsing-main-intr + * @see WP_HTML_Processor::step + * + * @return bool Whether an element was found. + */ + private function step_in_row(): bool { + $tag_name = $this->get_tag(); + $op_sigil = $this->is_tag_closer() ? '-' : '+'; + $op = "{$op_sigil}{$tag_name}"; + switch ( $op ) { /* - * > A start tag whose tag name is one of: "area", "br", "embed", "img", "keygen", "wbr" + * > A start tag whose tag name is one of: "th", "td" */ - case '+AREA': - case '+BR': - case '+EMBED': - case '+IMG': - case '+KEYGEN': - case '+WBR': - $this->reconstruct_active_formatting_elements(); + case '+TH': + case '+TD': + $this->state->stack_of_open_elements->clear_to_table_row_context(); $this->insert_html_element( $this->state->current_token ); - $this->state->frameset_ok = false; + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_IN_CELL; + $this->state->active_formatting_elements->insert_marker(); return true; /* - * > A start tag whose tag name is "input" + * > An end tag whose tag name is "tr" */ - case '+INPUT': - $this->reconstruct_active_formatting_elements(); - $this->insert_html_element( $this->state->current_token ); - - /* - * > If the token does not have an attribute with the name "type", or if it does, - * > but that attribute's value is not an ASCII case-insensitive match for the - * > string "hidden", then: set the frameset-ok flag to "not ok". - */ - $type_attribute = $this->get_attribute( 'type' ); - if ( ! is_string( $type_attribute ) || 'hidden' !== strtolower( $type_attribute ) ) { - $this->state->frameset_ok = false; + case '-TR': + if ( ! $this->state->stack_of_open_elements->has_element_in_table_scope( 'TR' ) ) { + // Parse error: ignore the token. + return $this->step(); } + $this->state->stack_of_open_elements->clear_to_table_row_context(); + $this->state->stack_of_open_elements->pop(); + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_IN_TABLE_BODY; return true; /* - * > A start tag whose tag name is one of: "param", "source", "track" + * > A start tag whose tag name is one of: "caption", "col", "colgroup", "tbody", "tfoot", "thead", "tr" + * > An end tag whose tag name is "table" */ - case '+PARAM': - case '+SOURCE': - case '+TRACK': - $this->insert_html_element( $this->state->current_token ); - return true; + case '+CAPTION': + case '+COL': + case '+COLGROUP': + case '+TBODY': + case '+TFOOT': + case '+THEAD': + case '+TR': + case '-TABLE': + if ( ! $this->state->stack_of_open_elements->has_element_in_table_scope( 'TR' ) ) { + // Parse error: ignore the token. + return $this->step(); + } + + $this->state->stack_of_open_elements->clear_to_table_row_context(); + $this->state->stack_of_open_elements->pop(); + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_IN_TABLE_BODY; + return $this->step( self::REPROCESS_CURRENT_NODE ); /* - * > A start tag whose tag name is "hr" + * > An end tag whose tag name is one of: "tbody", "tfoot", "thead" */ - case '+HR': - if ( $this->state->stack_of_open_elements->has_p_in_button_scope() ) { - $this->close_a_p_element(); + case '-TBODY': + case '-TFOOT': + case '-THEAD': + if ( ! $this->state->stack_of_open_elements->has_element_in_table_scope( $tag_name ) ) { + // Parse error: ignore the token. + return $this->step(); } - $this->insert_html_element( $this->state->current_token ); - $this->state->frameset_ok = false; - return true; + if ( ! $this->state->stack_of_open_elements->has_element_in_table_scope( 'TR' ) ) { + // Ignore the token. + return $this->step(); + } + + $this->state->stack_of_open_elements->clear_to_table_row_context(); + $this->state->stack_of_open_elements->pop(); + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_IN_TABLE_BODY; + return $this->step( self::REPROCESS_CURRENT_NODE ); + + /* + * > An end tag whose tag name is one of: "body", "caption", "col", "colgroup", "html", "td", "th" + */ + case '-BODY': + case '-CAPTION': + case '-COL': + case '-COLGROUP': + case '-HTML': + case '-TD': + case '-TH': + // Parse error: ignore the token. + return $this->step(); + } + + /* + * > Anything else + * > Process the token using the rules for the "in table" insertion mode. + */ + return $this->step_in_table(); + } + + /** + * Parses next element in the 'in cell' insertion mode. + * + * This internal function performs the 'in cell' insertion mode + * logic for the generalized WP_HTML_Processor::step() function. + * + * @since 6.7.0 + * + * @throws WP_HTML_Unsupported_Exception When encountering unsupported HTML input. + * + * @see https://html.spec.whatwg.org/#parsing-main-intd + * @see WP_HTML_Processor::step + * + * @return bool Whether an element was found. + */ + private function step_in_cell(): bool { + $tag_name = $this->get_tag(); + $op_sigil = $this->is_tag_closer() ? '-' : '+'; + $op = "{$op_sigil}{$tag_name}"; + + switch ( $op ) { /* - * > A start tag whose tag name is "image" + * > An end tag whose tag name is one of: "td", "th" */ - case '+IMAGE': + case '-TD': + case '-TH': + if ( ! $this->state->stack_of_open_elements->has_element_in_table_scope( $tag_name ) ) { + // Parse error: ignore the token. + return $this->step(); + } + + $this->generate_implied_end_tags(); + /* - * > Parse error. Change the token's tag name to "img" and reprocess it. (Don't ask.) - * - * Note that this is handled elsewhere, so it should not be possible to reach this code. + * @todo This needs to check if the current node is an HTML element, meaning that + * when SVG and MathML support is added, this needs to differentiate between an + * HTML element of the given name, such as `
`, and a foreign element of + * the same given name. */ - $this->bail( "Cannot process an IMAGE tag. (Don't ask.)" ); - break; + if ( ! $this->state->stack_of_open_elements->current_node_is( $tag_name ) ) { + // @todo Indicate a parse error once it's possible. + } + + $this->state->stack_of_open_elements->pop_until( $tag_name ); + $this->state->active_formatting_elements->clear_up_to_last_marker(); + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_IN_ROW; + return true; /* - * > A start tag whose tag name is "textarea" + * > A start tag whose tag name is one of: "caption", "col", "colgroup", "tbody", "td", + * > "tfoot", "th", "thead", "tr" */ - case '+TEXTAREA': - $this->insert_html_element( $this->state->current_token ); - + case '+CAPTION': + case '+COL': + case '+COLGROUP': + case '+TBODY': + case '+TD': + case '+TFOOT': + case '+TH': + case '+THEAD': + case '+TR': /* - * > If the next token is a U+000A LINE FEED (LF) character token, then ignore - * > that token and move on to the next one. (Newlines at the start of - * > textarea elements are ignored as an authoring convenience.) + * > Assert: The stack of open elements has a td or th element in table scope. * - * This is handled in `get_modifiable_text()`. + * Nothing to do here, except to verify in tests that this never appears. */ - $this->state->frameset_ok = false; + $this->close_cell(); + return $this->step( self::REPROCESS_CURRENT_NODE ); - /* - * > Switch the insertion mode to "text". - * - * As a self-contained node, this behavior is handled in the Tag Processor. - */ - return true; + /* + * > An end tag whose tag name is one of: "body", "caption", "col", "colgroup", "html" + */ + case '-BODY': + case '-CAPTION': + case '-COL': + case '-COLGROUP': + case '-HTML': + // Parse error: ignore the token. + return $this->step(); /* - * > A start tag whose tag name is "xmp" + * > An end tag whose tag name is one of: "table", "tbody", "tfoot", "thead", "tr" */ - case '+XMP': - if ( $this->state->stack_of_open_elements->has_p_in_button_scope() ) { - $this->close_a_p_element(); + case '-TABLE': + case '-TBODY': + case '-TFOOT': + case '-THEAD': + case '-TR': + if ( ! $this->state->stack_of_open_elements->has_element_in_table_scope( $tag_name ) ) { + // Parse error: ignore the token. + return $this->step(); } + $this->close_cell(); + return $this->step( self::REPROCESS_CURRENT_NODE ); + } - $this->reconstruct_active_formatting_elements(); - $this->state->frameset_ok = false; + /* + * > Anything else + * > Process the token using the rules for the "in body" insertion mode. + */ + return $this->step_in_body(); + } - /* - * > Follow the generic raw text element parsing algorithm. - * - * As a self-contained node, this behavior is handled in the Tag Processor. - */ - $this->insert_html_element( $this->state->current_token ); - return true; + /** + * Parses next element in the 'in select' insertion mode. + * + * This internal function performs the 'in select' insertion mode + * logic for the generalized WP_HTML_Processor::step() function. + * + * @since 6.7.0 + * + * @throws WP_HTML_Unsupported_Exception When encountering unsupported HTML input. + * + * @see https://html.spec.whatwg.org/multipage/parsing.html#parsing-main-inselect + * @see WP_HTML_Processor::step + * + * @return bool Whether an element was found. + */ + private function step_in_select(): bool { + $token_name = $this->get_token_name(); + $token_type = $this->get_token_type(); + $op_sigil = '#tag' === $token_type ? ( parent::is_tag_closer() ? '-' : '+' ) : ''; + $op = "{$op_sigil}{$token_name}"; + switch ( $op ) { /* - * A start tag whose tag name is "iframe" + * > Any other character token */ - case '+IFRAME': - $this->state->frameset_ok = false; + case '#text': + $current_token = $this->bookmarks[ $this->state->current_token->bookmark_name ]; /* - * > Follow the generic raw text element parsing algorithm. + * > A character token that is U+0000 NULL * - * As a self-contained node, this behavior is handled in the Tag Processor. + * If a text node only comprises null bytes then it should be + * entirely ignored and should not return to calling code. */ + if ( + 1 <= $current_token->length && + "\x00" === $this->html[ $current_token->start ] && + strspn( $this->html, "\x00", $current_token->start, $current_token->length ) === $current_token->length + ) { + // Parse error: ignore the token. + return $this->step(); + } + $this->insert_html_element( $this->state->current_token ); return true; /* - * > A start tag whose tag name is "noembed" - * > A start tag whose tag name is "noscript", if the scripting flag is enabled - * - * The scripting flag is never enabled in this parser. + * > A comment token */ - case '+NOEMBED': + case '#comment': + case '#funky-comment': + case '#presumptuous-tag': $this->insert_html_element( $this->state->current_token ); return true; /* - * > A start tag whose tag name is "select" + * > A DOCTYPE token */ - case '+SELECT': - $this->reconstruct_active_formatting_elements(); - $this->insert_html_element( $this->state->current_token ); - $this->state->frameset_ok = false; - - switch ( $this->state->insertion_mode ) { - /* - * > If the insertion mode is one of "in table", "in caption", "in table body", "in row", - * > or "in cell", then switch the insertion mode to "in select in table". - */ - case WP_HTML_Processor_State::INSERTION_MODE_IN_TABLE: - case WP_HTML_Processor_State::INSERTION_MODE_IN_CAPTION: - case WP_HTML_Processor_State::INSERTION_MODE_IN_TABLE_BODY: - case WP_HTML_Processor_State::INSERTION_MODE_IN_ROW: - case WP_HTML_Processor_State::INSERTION_MODE_IN_CELL: - $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_IN_SELECT_IN_TABLE; - break; + case 'html': + // Parse error: ignore the token. + return $this->step(); - /* - * > Otherwise, switch the insertion mode to "in select". - */ - default: - $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_IN_SELECT; - break; - } - return true; + /* + * > A start tag whose tag name is "html" + */ + case '+HTML': + return $this->step_in_body(); /* - * > A start tag whose tag name is one of: "optgroup", "option" + * > A start tag whose tag name is "option" */ - case '+OPTGROUP': case '+OPTION': if ( $this->state->stack_of_open_elements->current_node_is( 'OPTION' ) ) { $this->state->stack_of_open_elements->pop(); } - $this->reconstruct_active_formatting_elements(); $this->insert_html_element( $this->state->current_token ); return true; /* - * > A start tag whose tag name is one of: "rb", "rtc" + * > A start tag whose tag name is "optgroup" + * > A start tag whose tag name is "hr" + * + * These rules are identical except for the treatment of the self-closing flag and + * the subsequent pop of the HR void element, all of which is handled elsewhere in the processor. */ - case '+RB': - case '+RTC': - if ( $this->state->stack_of_open_elements->has_element_in_scope( 'RUBY' ) ) { - $this->generate_implied_end_tags(); + case '+OPTGROUP': + case '+HR': + if ( $this->state->stack_of_open_elements->current_node_is( 'OPTION' ) ) { + $this->state->stack_of_open_elements->pop(); + } - if ( $this->state->stack_of_open_elements->current_node_is( 'RUBY' ) ) { - // @todo Indicate a parse error once it's possible. - } + if ( $this->state->stack_of_open_elements->current_node_is( 'OPTGROUP' ) ) { + $this->state->stack_of_open_elements->pop(); } $this->insert_html_element( $this->state->current_token ); return true; /* - * > A start tag whose tag name is one of: "rp", "rt" + * > An end tag whose tag name is "optgroup" */ - case '+RP': - case '+RT': - if ( $this->state->stack_of_open_elements->has_element_in_scope( 'RUBY' ) ) { - $this->generate_implied_end_tags( 'RTC' ); - - $current_node_name = $this->state->stack_of_open_elements->current_node()->node_name; - if ( 'RTC' === $current_node_name || 'RUBY' === $current_node_name ) { - // @todo Indicate a parse error once it's possible. + case '-OPTGROUP': + $current_node = $this->state->stack_of_open_elements->current_node(); + if ( $current_node && 'OPTION' === $current_node->node_name ) { + foreach ( $this->state->stack_of_open_elements->walk_up( $current_node ) as $parent ) { + break; + } + if ( $parent && 'OPTGROUP' === $parent->node_name ) { + $this->state->stack_of_open_elements->pop(); } } - $this->insert_html_element( $this->state->current_token ); - return true; - - /* - * > A start tag whose tag name is "math" - */ - case '+MATH': - $this->reconstruct_active_formatting_elements(); - - /* - * @todo Adjust MathML attributes for the token. (This fixes the case of MathML attributes that are not all lowercase.) - * @todo Adjust foreign attributes for the token. (This fixes the use of namespaced attributes, in particular XLink.) - * - * These ought to be handled in the attribute methods. - */ - - $this->bail( 'Cannot process MATH element, opening foreign content.' ); - break; - - /* - * > A start tag whose tag name is "svg" - */ - case '+SVG': - $this->reconstruct_active_formatting_elements(); - - /* - * @todo Adjust SVG attributes for the token. (This fixes the case of SVG attributes that are not all lowercase.) - * @todo Adjust foreign attributes for the token. (This fixes the use of namespaced attributes, in particular XLink in SVG.) - * - * These ought to be handled in the attribute methods. - */ - - $this->bail( 'Cannot process SVG element, opening foreign content.' ); - break; + if ( $this->state->stack_of_open_elements->current_node_is( 'OPTGROUP' ) ) { + $this->state->stack_of_open_elements->pop(); + return true; + } - /* - * > A start tag whose tag name is one of: "caption", "col", "colgroup", - * > "frame", "head", "tbody", "td", "tfoot", "th", "thead", "tr" - */ - case '+CAPTION': - case '+COL': - case '+COLGROUP': - case '+FRAME': - case '+HEAD': - case '+TBODY': - case '+TD': - case '+TFOOT': - case '+TH': - case '+THEAD': - case '+TR': - // Parse error. Ignore the token. + // Parse error: ignore the token. return $this->step(); - } - if ( ! parent::is_tag_closer() ) { - /* - * > Any other start tag - */ - $this->reconstruct_active_formatting_elements(); - $this->insert_html_element( $this->state->current_token ); - return true; - } else { /* - * > Any other end tag + * > An end tag whose tag name is "option" */ + case '-OPTION': + if ( $this->state->stack_of_open_elements->current_node_is( 'OPTION' ) ) { + $this->state->stack_of_open_elements->pop(); + return true; + } + + // Parse error: ignore the token. + return $this->step(); /* - * Find the corresponding tag opener in the stack of open elements, if - * it exists before reaching a special element, which provides a kind - * of boundary in the stack. For example, a `` should not - * close anything beyond its containing `P` or `DIV` element. + * > An end tag whose tag name is "select" + * > A start tag whose tag name is "select" + * + * > It just gets treated like an end tag. */ - foreach ( $this->state->stack_of_open_elements->walk_up() as $node ) { - /* - * @todo This needs to check if the element in scope is an HTML element, meaning that - * when SVG and MathML support is added, this needs to differentiate between an - * HTML element of the given name, such as ``, and a foreign element of - * the same given name. - */ - if ( $token_name === $node->node_name ) { - break; + case '-SELECT': + case '+SELECT': + if ( ! $this->state->stack_of_open_elements->has_element_in_select_scope( 'SELECT' ) ) { + // Parse error: ignore the token. + return $this->step(); } + $this->state->stack_of_open_elements->pop_until( 'SELECT' ); + $this->reset_insertion_mode_appropriately(); + return true; - if ( self::is_special( $node->node_name ) ) { - // This is a parse error, ignore the token. + /* + * > A start tag whose tag name is one of: "input", "keygen", "textarea" + * + * All three of these tags are considered a parse error when found in this insertion mode. + */ + case '+INPUT': + case '+KEYGEN': + case '+TEXTAREA': + if ( ! $this->state->stack_of_open_elements->has_element_in_select_scope( 'SELECT' ) ) { + // Ignore the token. return $this->step(); } - } - - $this->generate_implied_end_tags( $token_name ); - if ( $node !== $this->state->stack_of_open_elements->current_node() ) { - // @todo Record parse error: this error doesn't impact parsing. - } + $this->state->stack_of_open_elements->pop_until( 'SELECT' ); + $this->reset_insertion_mode_appropriately(); + return $this->step( self::REPROCESS_CURRENT_NODE ); - foreach ( $this->state->stack_of_open_elements->walk_up() as $item ) { - $this->state->stack_of_open_elements->pop(); - if ( $node === $item ) { - return true; - } - } + /* + * > A start tag whose tag name is one of: "script", "template" + * > An end tag whose tag name is "template" + */ + case '+SCRIPT': + case '+TEMPLATE': + case '-TEMPLATE': + return $this->step_in_head(); } + + /* + * > Anything else + * > Parse error: ignore the token. + */ + return $this->step(); } /** - * Parses next element in the 'in table' insertion mode. + * Parses next element in the 'in select in table' insertion mode. * - * This internal function performs the 'in table' insertion mode + * This internal function performs the 'in select in table' insertion mode * logic for the generalized WP_HTML_Processor::step() function. * - * @since 6.7.0 Stub implementation. + * @since 6.7.0 * * @throws WP_HTML_Unsupported_Exception When encountering unsupported HTML input. * - * @see https://html.spec.whatwg.org/#parsing-main-intable + * @see https://html.spec.whatwg.org/#parsing-main-inselectintable * @see WP_HTML_Processor::step * * @return bool Whether an element was found. */ - private function step_in_table(): bool { - $this->bail( 'No support for parsing in the ' . WP_HTML_Processor_State::INSERTION_MODE_IN_TABLE . ' state.' ); + private function step_in_select_in_table(): bool { + $token_name = $this->get_token_name(); + $token_type = $this->get_token_type(); + $op_sigil = '#tag' === $token_type ? ( parent::is_tag_closer() ? '-' : '+' ) : ''; + $op = "{$op_sigil}{$token_name}"; + + switch ( $op ) { + /* + * > A start tag whose tag name is one of: "caption", "table", "tbody", "tfoot", "thead", "tr", "td", "th" + */ + case '+CAPTION': + case '+TABLE': + case '+TBODY': + case '+TFOOT': + case '+THEAD': + case '+TR': + case '+TD': + case '+TH': + // @todo Indicate a parse error once it's possible. + $this->state->stack_of_open_elements->pop_until( 'SELECT' ); + $this->reset_insertion_mode_appropriately(); + return $this->step( self::REPROCESS_CURRENT_NODE ); + + /* + * > An end tag whose tag name is one of: "caption", "table", "tbody", "tfoot", "thead", "tr", "td", "th" + */ + case '-CAPTION': + case '-TABLE': + case '-TBODY': + case '-TFOOT': + case '-THEAD': + case '-TR': + case '-TD': + case '-TH': + // @todo Indicate a parse error once it's possible. + if ( ! $this->state->stack_of_open_elements->has_element_in_table_scope( $token_name ) ) { + return $this->step(); + } + $this->state->stack_of_open_elements->pop_until( 'SELECT' ); + $this->reset_insertion_mode_appropriately(); + return $this->step( self::REPROCESS_CURRENT_NODE ); + } + + /* + * > Anything else + */ + return $this->step_in_select(); } /** - * Parses next element in the 'in table text' insertion mode. + * Parses next element in the 'in template' insertion mode. * - * This internal function performs the 'in table text' insertion mode + * This internal function performs the 'in template' insertion mode * logic for the generalized WP_HTML_Processor::step() function. * * @since 6.7.0 Stub implementation. * * @throws WP_HTML_Unsupported_Exception When encountering unsupported HTML input. * - * @see https://html.spec.whatwg.org/#parsing-main-intabletext + * @see https://html.spec.whatwg.org/#parsing-main-intemplate * @see WP_HTML_Processor::step * * @return bool Whether an element was found. */ - private function step_in_table_text(): bool { - $this->bail( 'No support for parsing in the ' . WP_HTML_Processor_State::INSERTION_MODE_IN_TABLE_TEXT . ' state.' ); + private function step_in_template(): bool { + $token_name = $this->get_token_name(); + $token_type = $this->get_token_type(); + $is_closer = $this->is_tag_closer(); + $op_sigil = '#tag' === $token_type ? ( $is_closer ? '-' : '+' ) : ''; + $op = "{$op_sigil}{$token_name}"; + + switch ( $op ) { + /* + * > A character token + * > A comment token + * > A DOCTYPE token + */ + case '#text': + case '#comment': + case '#funky-comment': + case '#presumptuous-tag': + case 'html': + return $this->step_in_body(); + + /* + * > A start tag whose tag name is one of: "base", "basefont", "bgsound", "link", + * > "meta", "noframes", "script", "style", "template", "title" + * > An end tag whose tag name is "template" + */ + case '+BASE': + case '+BASEFONT': + case '+BGSOUND': + case '+LINK': + case '+META': + case '+NOFRAMES': + case '+SCRIPT': + case '+STYLE': + case '+TEMPLATE': + case '+TITLE': + case '-TEMPLATE': + return $this->step_in_head(); + + /* + * > A start tag whose tag name is one of: "caption", "colgroup", "tbody", "tfoot", "thead" + */ + case '+CAPTION': + case '+COLGROUP': + case '+TBODY': + case '+TFOOT': + case '+THEAD': + array_pop( $this->state->stack_of_template_insertion_modes ); + $this->state->stack_of_template_insertion_modes[] = WP_HTML_Processor_State::INSERTION_MODE_IN_TABLE; + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_IN_TABLE; + return $this->step( self::REPROCESS_CURRENT_NODE ); + + /* + * > A start tag whose tag name is "col" + */ + case '+COL': + array_pop( $this->state->stack_of_template_insertion_modes ); + $this->state->stack_of_template_insertion_modes[] = WP_HTML_Processor_State::INSERTION_MODE_IN_COLUMN_GROUP; + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_IN_COLUMN_GROUP; + return $this->step( self::REPROCESS_CURRENT_NODE ); + + /* + * > A start tag whose tag name is "tr" + */ + case '+TR': + array_pop( $this->state->stack_of_template_insertion_modes ); + $this->state->stack_of_template_insertion_modes[] = WP_HTML_Processor_State::INSERTION_MODE_IN_TABLE_BODY; + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_IN_TABLE_BODY; + return $this->step( self::REPROCESS_CURRENT_NODE ); + + /* + * > A start tag whose tag name is one of: "td", "th" + */ + case '+TD': + case '+TH': + array_pop( $this->state->stack_of_template_insertion_modes ); + $this->state->stack_of_template_insertion_modes[] = WP_HTML_Processor_State::INSERTION_MODE_IN_ROW; + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_IN_ROW; + return $this->step( self::REPROCESS_CURRENT_NODE ); + } + + /* + * > Any other start tag + */ + if ( ! $is_closer ) { + array_pop( $this->state->stack_of_template_insertion_modes ); + $this->state->stack_of_template_insertion_modes[] = WP_HTML_Processor_State::INSERTION_MODE_IN_BODY; + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_IN_BODY; + return $this->step( self::REPROCESS_CURRENT_NODE ); + } + + /* + * > Any other end tag + */ + if ( $is_closer ) { + // Parse error: ignore the token. + return $this->step(); + } + + /* + * > An end-of-file token + */ + if ( ! $this->state->stack_of_open_elements->contains( 'TEMPLATE' ) ) { + // Stop parsing. + return false; + } + + // @todo Indicate a parse error once it's possible. + $this->state->stack_of_open_elements->pop_until( 'TEMPLATE' ); + $this->state->active_formatting_elements->clear_up_to_last_marker(); + array_pop( $this->state->stack_of_template_insertion_modes ); + $this->reset_insertion_mode_appropriately(); + return $this->step( self::REPROCESS_CURRENT_NODE ); } /** - * Parses next element in the 'in caption' insertion mode. + * Parses next element in the 'after body' insertion mode. * - * This internal function performs the 'in caption' insertion mode + * This internal function performs the 'after body' insertion mode * logic for the generalized WP_HTML_Processor::step() function. * * @since 6.7.0 Stub implementation. * * @throws WP_HTML_Unsupported_Exception When encountering unsupported HTML input. * - * @see https://html.spec.whatwg.org/#parsing-main-incaption + * @see https://html.spec.whatwg.org/#parsing-main-afterbody * @see WP_HTML_Processor::step * * @return bool Whether an element was found. */ - private function step_in_caption(): bool { - $this->bail( 'No support for parsing in the ' . WP_HTML_Processor_State::INSERTION_MODE_IN_CAPTION . ' state.' ); + private function step_after_body(): bool { + $tag_name = $this->get_token_name(); + $token_type = $this->get_token_type(); + $op_sigil = '#tag' === $token_type ? ( $this->is_tag_closer() ? '-' : '+' ) : ''; + $op = "{$op_sigil}{$tag_name}"; + + switch ( $op ) { + /* + * > A character token that is one of U+0009 CHARACTER TABULATION, U+000A LINE FEED (LF), + * > U+000C FORM FEED (FF), U+000D CARRIAGE RETURN (CR), or U+0020 SPACE + * + * > Process the token using the rules for the "in body" insertion mode. + */ + case '#text': + $text = $this->get_modifiable_text(); + if ( strlen( $text ) === strspn( $text, " \t\n\f\r" ) ) { + return $this->step_in_body(); + } + goto after_body_anything_else; + break; + + /* + * > A comment token + */ + case '#comment': + case '#funky-comment': + case '#presumptuous-tag': + $this->bail( 'Content outside of BODY is unsupported.' ); + break; + + /* + * > A DOCTYPE token + */ + case 'html': + // Parse error: ignore the token. + return $this->step(); + + /* + * > A start tag whose tag name is "html" + */ + case '+HTML': + return $this->step_in_body(); + + /* + * > An end tag whose tag name is "html" + * + * > If the parser was created as part of the HTML fragment parsing algorithm, + * > this is a parse error; ignore the token. (fragment case) + * > + * > Otherwise, switch the insertion mode to "after after body". + */ + case '-HTML': + if ( isset( $this->context_node ) ) { + return $this->step(); + } + + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_AFTER_AFTER_BODY; + return true; + } + + /* + * > Parse error. Switch the insertion mode to "in body" and reprocess the token. + */ + after_body_anything_else: + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_IN_BODY; + return $this->step( self::REPROCESS_CURRENT_NODE ); } /** - * Parses next element in the 'in column group' insertion mode. + * Parses next element in the 'in frameset' insertion mode. * - * This internal function performs the 'in column group' insertion mode + * This internal function performs the 'in frameset' insertion mode * logic for the generalized WP_HTML_Processor::step() function. * * @since 6.7.0 Stub implementation. * * @throws WP_HTML_Unsupported_Exception When encountering unsupported HTML input. * - * @see https://html.spec.whatwg.org/#parsing-main-incolgroup + * @see https://html.spec.whatwg.org/#parsing-main-inframeset * @see WP_HTML_Processor::step * * @return bool Whether an element was found. */ - private function step_in_column_group(): bool { - $this->bail( 'No support for parsing in the ' . WP_HTML_Processor_State::INSERTION_MODE_IN_COLUMN_GROUP . ' state.' ); + private function step_in_frameset(): bool { + $tag_name = $this->get_token_name(); + $token_type = $this->get_token_type(); + $op_sigil = '#tag' === $token_type ? ( $this->is_tag_closer() ? '-' : '+' ) : ''; + $op = "{$op_sigil}{$tag_name}"; + + switch ( $op ) { + /* + * > A character token that is one of U+0009 CHARACTER TABULATION, U+000A LINE FEED (LF), + * > U+000C FORM FEED (FF), U+000D CARRIAGE RETURN (CR), or U+0020 SPACE + * > + * > Insert the character. + * + * This algorithm effectively strips non-whitespace characters from text and inserts + * them under HTML. This is not supported at this time. + */ + case '#text': + $text = $this->get_modifiable_text(); + $text = $this->get_modifiable_text(); + if ( strlen( $text ) === strspn( $text, " \t\n\f\r" ) ) { + return $this->step_in_body(); + } + $this->bail( 'Non-whitespace characters cannot be handled in frameset.' ); + break; + + /* + * > A comment token + */ + case '#comment': + case '#funky-comment': + case '#presumptuous-tag': + $this->insert_html_element( $this->state->current_token ); + return true; + + /* + * > A DOCTYPE token + */ + case 'html': + // Parse error: ignore the token. + return $this->step(); + + /* + * > A start tag whose tag name is "html" + */ + case '+HTML': + return $this->step_in_body(); + + /* + * > A start tag whose tag name is "frameset" + */ + case '+FRAMESET': + $this->insert_html_element( $this->state->current_token ); + return true; + + /* + * > An end tag whose tag name is "frameset" + */ + case '-FRAMESET': + /* + * > If the current node is the root html element, then this is a parse error; + * > ignore the token. (fragment case) + */ + if ( $this->state->stack_of_open_elements->current_node_is( 'HTML' ) ) { + return $this->step(); + } + + /* + * > Otherwise, pop the current node from the stack of open elements. + */ + $this->state->stack_of_open_elements->pop(); + + /* + * > If the parser was not created as part of the HTML fragment parsing algorithm + * > (fragment case), and the current node is no longer a frameset element, then + * > switch the insertion mode to "after frameset". + */ + if ( ! isset( $this->context_node ) && ! $this->state->stack_of_open_elements->current_node_is( 'FRAMESET' ) ) { + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_AFTER_FRAMESET; + } + + return true; + + /* + * > A start tag whose tag name is "frame" + * + * > Insert an HTML element for the token. Immediately pop the + * > current node off the stack of open elements. + * > + * > Acknowledge the token's self-closing flag, if it is set. + */ + case '+FRAME': + $this->insert_html_element( $this->state->current_token ); + $this->state->stack_of_open_elements->pop(); + return true; + + /* + * > A start tag whose tag name is "noframes" + */ + case '+NOFRAMES': + return $this->step_in_head(); + } + + // Parse error: ignore the token. + return $this->step(); } /** - * Parses next element in the 'in table body' insertion mode. + * Parses next element in the 'after frameset' insertion mode. * - * This internal function performs the 'in table body' insertion mode + * This internal function performs the 'after frameset' insertion mode * logic for the generalized WP_HTML_Processor::step() function. * * @since 6.7.0 Stub implementation. * * @throws WP_HTML_Unsupported_Exception When encountering unsupported HTML input. * - * @see https://html.spec.whatwg.org/#parsing-main-intbody + * @see https://html.spec.whatwg.org/#parsing-main-afterframeset * @see WP_HTML_Processor::step * * @return bool Whether an element was found. */ - private function step_in_table_body(): bool { - $this->bail( 'No support for parsing in the ' . WP_HTML_Processor_State::INSERTION_MODE_IN_TABLE_BODY . ' state.' ); + private function step_after_frameset(): bool { + $tag_name = $this->get_token_name(); + $token_type = $this->get_token_type(); + $op_sigil = '#tag' === $token_type ? ( $this->is_tag_closer() ? '-' : '+' ) : ''; + $op = "{$op_sigil}{$tag_name}"; + + switch ( $op ) { + /* + * > A character token that is one of U+0009 CHARACTER TABULATION, U+000A LINE FEED (LF), + * > U+000C FORM FEED (FF), U+000D CARRIAGE RETURN (CR), or U+0020 SPACE + * > + * > Insert the character. + * + * This algorithm effectively strips non-whitespace characters from text and inserts + * them under HTML. This is not supported at this time. + */ + case '#text': + $text = $this->get_modifiable_text(); + if ( strlen( $text ) === strspn( $text, " \t\n\f\r" ) ) { + return $this->step_in_body(); + } + $this->bail( 'Non-whitespace characters cannot be handled in after frameset' ); + break; + + /* + * > A comment token + */ + case '#comment': + case '#funky-comment': + case '#presumptuous-tag': + $this->insert_html_element( $this->state->current_token ); + return true; + + /* + * > A DOCTYPE token + */ + case 'html': + // Parse error: ignore the token. + return $this->step(); + + /* + * > A start tag whose tag name is "html" + */ + case '+HTML': + return $this->step_in_body(); + + /* + * > An end tag whose tag name is "html" + */ + case '-HTML': + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_AFTER_AFTER_FRAMESET; + return true; + + /* + * > A start tag whose tag name is "noframes" + */ + case '+NOFRAMES': + return $this->step_in_head(); + } + + // Parse error: ignore the token. + return $this->step(); } /** - * Parses next element in the 'in row' insertion mode. + * Parses next element in the 'after after body' insertion mode. * - * This internal function performs the 'in row' insertion mode + * This internal function performs the 'after after body' insertion mode * logic for the generalized WP_HTML_Processor::step() function. * * @since 6.7.0 Stub implementation. * * @throws WP_HTML_Unsupported_Exception When encountering unsupported HTML input. * - * @see https://html.spec.whatwg.org/#parsing-main-intr + * @see https://html.spec.whatwg.org/#the-after-after-body-insertion-mode * @see WP_HTML_Processor::step * * @return bool Whether an element was found. */ - private function step_in_row(): bool { - $this->bail( 'No support for parsing in the ' . WP_HTML_Processor_State::INSERTION_MODE_IN_ROW . ' state.' ); + private function step_after_after_body(): bool { + $tag_name = $this->get_token_name(); + $token_type = $this->get_token_type(); + $op_sigil = '#tag' === $token_type ? ( $this->is_tag_closer() ? '-' : '+' ) : ''; + $op = "{$op_sigil}{$tag_name}"; + + switch ( $op ) { + /* + * > A comment token + */ + case '#comment': + case '#funky-comment': + case '#presumptuous-tag': + $this->bail( 'Content outside of HTML is unsupported.' ); + break; + + /* + * > A DOCTYPE token + * > A start tag whose tag name is "html" + * + * > Process the token using the rules for the "in body" insertion mode. + */ + case 'html': + case '+HTML': + return $this->step_in_body(); + + /* + * > A character token that is one of U+0009 CHARACTER TABULATION, U+000A LINE FEED (LF), + * > U+000C FORM FEED (FF), U+000D CARRIAGE RETURN (CR), or U+0020 SPACE + * > + * > Process the token using the rules for the "in body" insertion mode. + */ + case '#text': + $text = $this->get_modifiable_text(); + if ( strlen( $text ) === strspn( $text, " \t\n\f\r" ) ) { + return $this->step_in_body(); + } + goto after_after_body_anything_else; + break; + } + + /* + * > Parse error. Switch the insertion mode to "in body" and reprocess the token. + */ + after_after_body_anything_else: + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_IN_BODY; + return $this->step( self::REPROCESS_CURRENT_NODE ); } /** - * Parses next element in the 'in cell' insertion mode. + * Parses next element in the 'after after frameset' insertion mode. * - * This internal function performs the 'in cell' insertion mode + * This internal function performs the 'after after frameset' insertion mode * logic for the generalized WP_HTML_Processor::step() function. * * @since 6.7.0 Stub implementation. * * @throws WP_HTML_Unsupported_Exception When encountering unsupported HTML input. * - * @see https://html.spec.whatwg.org/#parsing-main-intd + * @see https://html.spec.whatwg.org/#the-after-after-frameset-insertion-mode * @see WP_HTML_Processor::step * * @return bool Whether an element was found. */ - private function step_in_cell(): bool { - $this->bail( 'No support for parsing in the ' . WP_HTML_Processor_State::INSERTION_MODE_IN_CELL . ' state.' ); + private function step_after_after_frameset(): bool { + $tag_name = $this->get_token_name(); + $token_type = $this->get_token_type(); + $op_sigil = '#tag' === $token_type ? ( $this->is_tag_closer() ? '-' : '+' ) : ''; + $op = "{$op_sigil}{$tag_name}"; + + switch ( $op ) { + /* + * > A comment token + */ + case '#comment': + case '#funky-comment': + case '#presumptuous-tag': + $this->bail( 'Content outside of HTML is unsupported.' ); + break; + + /* + * > A DOCTYPE token + * > A start tag whose tag name is "html" + * + * > Process the token using the rules for the "in body" insertion mode. + */ + case 'html': + case '+HTML': + return $this->step_in_body(); + + /* + * > A character token that is one of U+0009 CHARACTER TABULATION, U+000A LINE FEED (LF), + * > U+000C FORM FEED (FF), U+000D CARRIAGE RETURN (CR), or U+0020 SPACE + * > + * > Process the token using the rules for the "in body" insertion mode. + * + * This algorithm effectively strips non-whitespace characters from text and inserts + * them under HTML. This is not supported at this time. + */ + case '#text': + $text = $this->get_modifiable_text(); + if ( strlen( $text ) === strspn( $text, " \t\n\f\r" ) ) { + return $this->step_in_body(); + } + $this->bail( 'Non-whitespace characters cannot be handled in after after frameset.' ); + break; + + /* + * > A start tag whose tag name is "noframes" + */ + case '+NOFRAMES': + return $this->step_in_head(); + } + + // Parse error: ignore the token. + return $this->step(); } /** - * Parses next element in the 'in select' insertion mode. + * Parses next element in the 'in foreign content' insertion mode. * - * This internal function performs the 'in select' insertion mode + * This internal function performs the 'in foreign content' insertion mode * logic for the generalized WP_HTML_Processor::step() function. * - * @since 6.7.0 + * @since 6.7.0 Stub implementation. * * @throws WP_HTML_Unsupported_Exception When encountering unsupported HTML input. * - * @see https://html.spec.whatwg.org/multipage/parsing.html#parsing-main-inselect + * @see https://html.spec.whatwg.org/#parsing-main-inforeign * @see WP_HTML_Processor::step * * @return bool Whether an element was found. */ - private function step_in_select(): bool { - $token_name = $this->get_token_name(); + private function step_in_foreign_content(): bool { + $tag_name = $this->get_token_name(); $token_type = $this->get_token_type(); - $op_sigil = '#tag' === $token_type ? ( parent::is_tag_closer() ? '-' : '+' ) : ''; - $op = "{$op_sigil}{$token_name}"; + $op_sigil = '#tag' === $token_type ? ( $this->is_tag_closer() ? '-' : '+' ) : ''; + $op = "{$op_sigil}{$tag_name}"; - switch ( $op ) { - /* - * > Any other character token - */ - case '#text': - $current_token = $this->bookmarks[ $this->state->current_token->bookmark_name ]; + /* + * > A start tag whose name is "font", if the token has any attributes named "color", "face", or "size" + * + * This section drawn out above the switch to more easily incorporate + * the additional rules based on the presence of the attributes. + */ + if ( + '+FONT' === $op && + ( + null !== $this->get_attribute( 'color' ) || + null !== $this->get_attribute( 'face' ) || + null !== $this->get_attribute( 'size' ) + ) + ) { + $op = '+FONT with attributes'; + } + switch ( $op ) { + case '#text': /* * > A character token that is U+0000 NULL * - * If a text node only comprises null bytes then it should be - * entirely ignored and should not return to calling code. + * This is handled by `get_modifiable_text()`. */ - if ( - 1 <= $current_token->length && - "\x00" === $this->html[ $current_token->start ] && - strspn( $this->html, "\x00", $current_token->start, $current_token->length ) === $current_token->length - ) { - // Parse error: ignore the token. - return $this->step(); + + /* + * Whitespace-only text does not affect the frameset-ok flag. + * It is probably inter-element whitespace, but it may also + * contain character references which decode only to whitespace. + */ + $text = $this->get_modifiable_text(); + if ( strlen( $text ) !== strspn( $text, " \t\n\f\r" ) ) { + $this->state->frameset_ok = false; } - $this->insert_html_element( $this->state->current_token ); + $this->insert_foreign_element( $this->state->current_token, false ); return true; /* * > A comment token */ + case '#cdata-section': case '#comment': case '#funky-comment': case '#presumptuous-tag': - $this->insert_html_element( $this->state->current_token ); + $this->insert_foreign_element( $this->state->current_token, false ); return true; /* @@ -2307,274 +4449,225 @@ private function step_in_select(): bool { return $this->step(); /* - * > A start tag whose tag name is "html" + * > A start tag whose tag name is "b", "big", "blockquote", "body", "br", "center", + * > "code", "dd", "div", "dl", "dt", "em", "embed", "h1", "h2", "h3", "h4", "h5", + * > "h6", "head", "hr", "i", "img", "li", "listing", "menu", "meta", "nobr", "ol", + * > "p", "pre", "ruby", "s", "small", "span", "strong", "strike", "sub", "sup", + * > "table", "tt", "u", "ul", "var" + * + * > A start tag whose name is "font", if the token has any attributes named "color", "face", or "size" + * + * > An end tag whose tag name is "br", "p" + * + * Closing BR tags are always reported by the Tag Processor as opening tags. */ - case '+HTML': - return $this->step_in_body(); + case '+B': + case '+BIG': + case '+BLOCKQUOTE': + case '+BODY': + case '+BR': + case '+CENTER': + case '+CODE': + case '+DD': + case '+DIV': + case '+DL': + case '+DT': + case '+EM': + case '+EMBED': + case '+H1': + case '+H2': + case '+H3': + case '+H4': + case '+H5': + case '+H6': + case '+HEAD': + case '+HR': + case '+I': + case '+IMG': + case '+LI': + case '+LISTING': + case '+MENU': + case '+META': + case '+NOBR': + case '+OL': + case '+P': + case '+PRE': + case '+RUBY': + case '+S': + case '+SMALL': + case '+SPAN': + case '+STRONG': + case '+STRIKE': + case '+SUB': + case '+SUP': + case '+TABLE': + case '+TT': + case '+U': + case '+UL': + case '+VAR': + case '+FONT with attributes': + case '-BR': + case '-P': + // @todo Indicate a parse error once it's possible. + foreach ( $this->state->stack_of_open_elements->walk_up() as $current_node ) { + if ( + 'math' === $current_node->integration_node_type || + 'html' === $current_node->integration_node_type || + 'html' === $current_node->namespace + ) { + break; + } - /* - * > A start tag whose tag name is "option" - */ - case '+OPTION': - if ( $this->state->stack_of_open_elements->current_node_is( 'OPTION' ) ) { $this->state->stack_of_open_elements->pop(); } - $this->insert_html_element( $this->state->current_token ); - return true; + return $this->step( self::REPROCESS_CURRENT_NODE ); + } + + /* + * > Any other start tag + */ + if ( ! $this->is_tag_closer() ) { + $this->insert_foreign_element( $this->state->current_token, false ); /* - * > A start tag whose tag name is "optgroup" - * > A start tag whose tag name is "hr" + * > If the token has its self-closing flag set, then run + * > the appropriate steps from the following list: + * > + * > ↪ the token's tag name is "script", and the new current node is in the SVG namespace + * > Acknowledge the token's self-closing flag, and then act as + * > described in the steps for a "script" end tag below. + * > + * > ↪ Otherwise + * > Pop the current node off the stack of open elements and + * > acknowledge the token's self-closing flag. * - * These rules are identical except for the treatment of the self-closing flag and - * the subsequent pop of the HR void element, all of which is handled elsewhere in the processor. + * Since the rules for SCRIPT below indicate to pop the element off of the stack of + * open elements, which is the same for the Otherwise condition, there's no need to + * separate these checks. The difference comes when a parser operates with the scripting + * flag enabled, and executes the script, which this parser does not support. */ - case '+OPTGROUP': - case '+HR': - if ( $this->state->stack_of_open_elements->current_node_is( 'OPTION' ) ) { - $this->state->stack_of_open_elements->pop(); - } + if ( $this->state->current_token->has_self_closing_flag ) { + $this->state->stack_of_open_elements->pop(); + } + return true; + } - if ( $this->state->stack_of_open_elements->current_node_is( 'OPTGROUP' ) ) { - $this->state->stack_of_open_elements->pop(); - } + /* + * > An end tag whose name is "script", if the current node is an SVG script element. + */ + if ( $this->is_tag_closer() && 'SCRIPT' === $this->state->current_token->node_name && 'svg' === $this->state->current_token->namespace ) { + $this->state->stack_of_open_elements->pop(); + return true; + } - $this->insert_html_element( $this->state->current_token ); + /* + * > Any other end tag + */ + if ( $this->is_tag_closer() ) { + $node = $this->state->stack_of_open_elements->current_node(); + if ( $tag_name !== $node->node_name ) { + // @todo Indicate a parse error once it's possible. + } + in_foreign_content_end_tag_loop: + if ( $node === $this->state->stack_of_open_elements->at( 1 ) ) { return true; + } /* - * > An end tag whose tag name is "optgroup" + * > If node's tag name, converted to ASCII lowercase, is the same as the tag name + * > of the token, pop elements from the stack of open elements until node has + * > been popped from the stack, and then return. */ - case '-OPTGROUP': - $current_node = $this->state->stack_of_open_elements->current_node(); - if ( $current_node && 'OPTION' === $current_node->node_name ) { - foreach ( $this->state->stack_of_open_elements->walk_up( $current_node ) as $parent ) { - break; - } - if ( $parent && 'OPTGROUP' === $parent->node_name ) { - $this->state->stack_of_open_elements->pop(); + if ( 0 === strcasecmp( $node->node_name, $tag_name ) ) { + foreach ( $this->state->stack_of_open_elements->walk_up() as $item ) { + $this->state->stack_of_open_elements->pop(); + if ( $node === $item ) { + return true; } } + } - if ( $this->state->stack_of_open_elements->current_node_is( 'OPTGROUP' ) ) { - $this->state->stack_of_open_elements->pop(); - return true; - } + foreach ( $this->state->stack_of_open_elements->walk_up( $node ) as $item ) { + $node = $item; + break; + } - // Parse error: ignore the token. - return $this->step(); + if ( 'html' !== $node->namespace ) { + goto in_foreign_content_end_tag_loop; + } - /* - * > An end tag whose tag name is "option" - */ - case '-OPTION': - if ( $this->state->stack_of_open_elements->current_node_is( 'OPTION' ) ) { - $this->state->stack_of_open_elements->pop(); - return true; - } + switch ( $this->state->insertion_mode ) { + case WP_HTML_Processor_State::INSERTION_MODE_INITIAL: + return $this->step_initial(); - // Parse error: ignore the token. - return $this->step(); + case WP_HTML_Processor_State::INSERTION_MODE_BEFORE_HTML: + return $this->step_before_html(); - /* - * > An end tag whose tag name is "select" - * > A start tag whose tag name is "select" - * - * > It just gets treated like an end tag. - */ - case '-SELECT': - case '+SELECT': - if ( ! $this->state->stack_of_open_elements->has_element_in_select_scope( 'SELECT' ) ) { - // Parse error: ignore the token. - return $this->step(); - } - $this->state->stack_of_open_elements->pop_until( 'SELECT' ); - $this->reset_insertion_mode(); - return true; + case WP_HTML_Processor_State::INSERTION_MODE_BEFORE_HEAD: + return $this->step_before_head(); - /* - * > A start tag whose tag name is one of: "input", "keygen", "textarea" - * - * All three of these tags are considered a parse error when found in this insertion mode. - */ - case '+INPUT': - case '+KEYGEN': - case '+TEXTAREA': - if ( ! $this->state->stack_of_open_elements->has_element_in_select_scope( 'SELECT' ) ) { - // Ignore the token. - return $this->step(); - } - $this->state->stack_of_open_elements->pop_until( 'SELECT' ); - $this->reset_insertion_mode(); - return $this->step( self::REPROCESS_CURRENT_NODE ); + case WP_HTML_Processor_State::INSERTION_MODE_IN_HEAD: + return $this->step_in_head(); - /* - * > A start tag whose tag name is one of: "script", "template" - * > An end tag whose tag name is "template" - */ - case '+SCRIPT': - case '+TEMPLATE': - case '-TEMPLATE': - return $this->step_in_head(); - } + case WP_HTML_Processor_State::INSERTION_MODE_IN_HEAD_NOSCRIPT: + return $this->step_in_head_noscript(); - /* - * > Anything else - * > Parse error: ignore the token. - */ - return $this->step(); - } + case WP_HTML_Processor_State::INSERTION_MODE_AFTER_HEAD: + return $this->step_after_head(); - /** - * Parses next element in the 'in select in table' insertion mode. - * - * This internal function performs the 'in select in table' insertion mode - * logic for the generalized WP_HTML_Processor::step() function. - * - * @since 6.7.0 Stub implementation. - * - * @throws WP_HTML_Unsupported_Exception When encountering unsupported HTML input. - * - * @see https://html.spec.whatwg.org/#parsing-main-inselectintable - * @see WP_HTML_Processor::step - * - * @return bool Whether an element was found. - */ - private function step_in_select_in_table(): bool { - $this->bail( 'No support for parsing in the ' . WP_HTML_Processor_State::INSERTION_MODE_IN_SELECT_IN_TABLE . ' state.' ); - } + case WP_HTML_Processor_State::INSERTION_MODE_IN_BODY: + return $this->step_in_body(); - /** - * Parses next element in the 'in template' insertion mode. - * - * This internal function performs the 'in template' insertion mode - * logic for the generalized WP_HTML_Processor::step() function. - * - * @since 6.7.0 Stub implementation. - * - * @throws WP_HTML_Unsupported_Exception When encountering unsupported HTML input. - * - * @see https://html.spec.whatwg.org/#parsing-main-intemplate - * @see WP_HTML_Processor::step - * - * @return bool Whether an element was found. - */ - private function step_in_template(): bool { - $this->bail( 'No support for parsing in the ' . WP_HTML_Processor_State::INSERTION_MODE_IN_TEMPLATE . ' state.' ); - } + case WP_HTML_Processor_State::INSERTION_MODE_IN_TABLE: + return $this->step_in_table(); + + case WP_HTML_Processor_State::INSERTION_MODE_IN_TABLE_TEXT: + return $this->step_in_table_text(); + + case WP_HTML_Processor_State::INSERTION_MODE_IN_CAPTION: + return $this->step_in_caption(); + + case WP_HTML_Processor_State::INSERTION_MODE_IN_COLUMN_GROUP: + return $this->step_in_column_group(); + + case WP_HTML_Processor_State::INSERTION_MODE_IN_TABLE_BODY: + return $this->step_in_table_body(); + + case WP_HTML_Processor_State::INSERTION_MODE_IN_ROW: + return $this->step_in_row(); + + case WP_HTML_Processor_State::INSERTION_MODE_IN_CELL: + return $this->step_in_cell(); + + case WP_HTML_Processor_State::INSERTION_MODE_IN_SELECT: + return $this->step_in_select(); - /** - * Parses next element in the 'after body' insertion mode. - * - * This internal function performs the 'after body' insertion mode - * logic for the generalized WP_HTML_Processor::step() function. - * - * @since 6.7.0 Stub implementation. - * - * @throws WP_HTML_Unsupported_Exception When encountering unsupported HTML input. - * - * @see https://html.spec.whatwg.org/#parsing-main-afterbody - * @see WP_HTML_Processor::step - * - * @return bool Whether an element was found. - */ - private function step_after_body(): bool { - $this->bail( 'No support for parsing in the ' . WP_HTML_Processor_State::INSERTION_MODE_AFTER_BODY . ' state.' ); - } + case WP_HTML_Processor_State::INSERTION_MODE_IN_SELECT_IN_TABLE: + return $this->step_in_select_in_table(); - /** - * Parses next element in the 'in frameset' insertion mode. - * - * This internal function performs the 'in frameset' insertion mode - * logic for the generalized WP_HTML_Processor::step() function. - * - * @since 6.7.0 Stub implementation. - * - * @throws WP_HTML_Unsupported_Exception When encountering unsupported HTML input. - * - * @see https://html.spec.whatwg.org/#parsing-main-inframeset - * @see WP_HTML_Processor::step - * - * @return bool Whether an element was found. - */ - private function step_in_frameset(): bool { - $this->bail( 'No support for parsing in the ' . WP_HTML_Processor_State::INSERTION_MODE_IN_FRAMESET . ' state.' ); - } + case WP_HTML_Processor_State::INSERTION_MODE_IN_TEMPLATE: + return $this->step_in_template(); - /** - * Parses next element in the 'after frameset' insertion mode. - * - * This internal function performs the 'after frameset' insertion mode - * logic for the generalized WP_HTML_Processor::step() function. - * - * @since 6.7.0 Stub implementation. - * - * @throws WP_HTML_Unsupported_Exception When encountering unsupported HTML input. - * - * @see https://html.spec.whatwg.org/#parsing-main-afterframeset - * @see WP_HTML_Processor::step - * - * @return bool Whether an element was found. - */ - private function step_after_frameset(): bool { - $this->bail( 'No support for parsing in the ' . WP_HTML_Processor_State::INSERTION_MODE_AFTER_FRAMESET . ' state.' ); - } + case WP_HTML_Processor_State::INSERTION_MODE_AFTER_BODY: + return $this->step_after_body(); - /** - * Parses next element in the 'after after body' insertion mode. - * - * This internal function performs the 'after after body' insertion mode - * logic for the generalized WP_HTML_Processor::step() function. - * - * @since 6.7.0 Stub implementation. - * - * @throws WP_HTML_Unsupported_Exception When encountering unsupported HTML input. - * - * @see https://html.spec.whatwg.org/#the-after-after-body-insertion-mode - * @see WP_HTML_Processor::step - * - * @return bool Whether an element was found. - */ - private function step_after_after_body(): bool { - $this->bail( 'No support for parsing in the ' . WP_HTML_Processor_State::INSERTION_MODE_AFTER_AFTER_BODY . ' state.' ); - } + case WP_HTML_Processor_State::INSERTION_MODE_IN_FRAMESET: + return $this->step_in_frameset(); - /** - * Parses next element in the 'after after frameset' insertion mode. - * - * This internal function performs the 'after after frameset' insertion mode - * logic for the generalized WP_HTML_Processor::step() function. - * - * @since 6.7.0 Stub implementation. - * - * @throws WP_HTML_Unsupported_Exception When encountering unsupported HTML input. - * - * @see https://html.spec.whatwg.org/#the-after-after-frameset-insertion-mode - * @see WP_HTML_Processor::step - * - * @return bool Whether an element was found. - */ - private function step_after_after_frameset(): bool { - $this->bail( 'No support for parsing in the ' . WP_HTML_Processor_State::INSERTION_MODE_AFTER_AFTER_FRAMESET . ' state.' ); - } + case WP_HTML_Processor_State::INSERTION_MODE_AFTER_FRAMESET: + return $this->step_after_frameset(); - /** - * Parses next element in the 'in foreign content' insertion mode. - * - * This internal function performs the 'in foreign content' insertion mode - * logic for the generalized WP_HTML_Processor::step() function. - * - * @since 6.7.0 Stub implementation. - * - * @throws WP_HTML_Unsupported_Exception When encountering unsupported HTML input. - * - * @see https://html.spec.whatwg.org/#parsing-main-inforeign - * @see WP_HTML_Processor::step - * - * @return bool Whether an element was found. - */ - private function step_in_foreign_content(): bool { - $this->bail( 'No support for parsing in the ' . WP_HTML_Processor_State::INSERTION_MODE_IN_FOREIGN_CONTENT . ' state.' ); + case WP_HTML_Processor_State::INSERTION_MODE_AFTER_AFTER_BODY: + return $this->step_after_after_body(); + + case WP_HTML_Processor_State::INSERTION_MODE_AFTER_AFTER_FRAMESET: + return $this->step_after_after_frameset(); + + // This should be unreachable but PHP doesn't have total type checking on switch. + default: + $this->bail( "Unaware of the requested parsing mode: '{$this->state->insertion_mode}'." ); + } + } } /* @@ -2604,6 +4697,19 @@ private function bookmark_token() { * HTML semantic overrides for Tag Processor */ + /** + * Indicates the namespace of the current token, or "html" if there is none. + * + * @return string One of "html", "math", or "svg". + */ + public function get_namespace(): string { + if ( ! isset( $this->current_element ) ) { + return parent::get_namespace(); + } + + return $this->current_element->token->namespace; + } + /** * Returns the uppercase name of the matched tag. * @@ -3239,6 +5345,28 @@ private function generate_implied_end_tags_thoroughly(): void { } } + /** + * Returns the adjusted current node. + * + * > The adjusted current node is the context element if the parser was created as + * > part of the HTML fragment parsing algorithm and the stack of open elements + * > has only one element in it (fragment case); otherwise, the adjusted current + * > node is the current node. + * + * @see https://html.spec.whatwg.org/#adjusted-current-node + * + * @since 6.7.0 + * + * @return WP_HTML_Token|null The adjusted current node. + */ + private function get_adjusted_current_node(): ?WP_HTML_Token { + if ( isset( $this->context_node ) && 1 === $this->state->stack_of_open_elements->count() ) { + return $this->context_node; + } + + return $this->state->stack_of_open_elements->current_node(); + } + /** * Reconstructs the active formatting elements. * @@ -3292,7 +5420,7 @@ private function reconstruct_active_formatting_elements(): bool { * * @see https://html.spec.whatwg.org/multipage/parsing.html#reset-the-insertion-mode-appropriately */ - public function reset_insertion_mode(): void { + private function reset_insertion_mode_appropriately(): void { // Set the first node. $first_node = null; foreach ( $this->state->stack_of_open_elements->walk_down() as $first_node ) { @@ -3548,7 +5676,7 @@ private function run_adoption_agency_algorithm(): void { continue; } - if ( self::is_special( $item->node_name ) ) { + if ( self::is_special( $item ) ) { $furthest_block = $item; break; } @@ -3576,6 +5704,33 @@ private function run_adoption_agency_algorithm(): void { $this->bail( 'Cannot run adoption agency when looping required.' ); } + /** + * Runs the "close the cell" algorithm. + * + * > Where the steps above say to close the cell, they mean to run the following algorithm: + * > 1. Generate implied end tags. + * > 2. If the current node is not now a td element or a th element, then this is a parse error. + * > 3. Pop elements from the stack of open elements stack until a td element or a th element has been popped from the stack. + * > 4. Clear the list of active formatting elements up to the last marker. + * > 5. Switch the insertion mode to "in row". + * + * @see https://html.spec.whatwg.org/multipage/parsing.html#close-the-cell + * + * @since 6.7.0 + */ + private function close_cell(): void { + $this->generate_implied_end_tags(); + // @todo Parse error if the current node is a "td" or "th" element. + foreach ( $this->state->stack_of_open_elements->walk_up() as $element ) { + $this->state->stack_of_open_elements->pop(); + if ( 'TD' === $element->node_name || 'TH' === $element->node_name ) { + break; + } + } + $this->state->active_formatting_elements->clear_up_to_last_marker(); + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_IN_ROW; + } + /** * Inserts an HTML element on the stack of open elements. * @@ -3589,10 +5744,152 @@ private function insert_html_element( WP_HTML_Token $token ): void { $this->state->stack_of_open_elements->push( $token ); } + /** + * Inserts a foreign element on to the stack of open elements. + * + * @since 6.7.0 + * + * @see https://html.spec.whatwg.org/#insert-a-foreign-element + * + * @param WP_HTML_Token $token Insert this token. The token's namespace and + * insertion point will be updated correctly. + * @param bool $only_add_to_element_stack Whether to skip the "insert an element at the adjusted + * insertion location" algorithm when adding this element. + */ + private function insert_foreign_element( WP_HTML_Token $token, bool $only_add_to_element_stack ): void { + $adjusted_current_node = $this->get_adjusted_current_node(); + + $token->namespace = $adjusted_current_node ? $adjusted_current_node->namespace : 'html'; + + if ( $this->is_mathml_integration_point() ) { + $token->integration_node_type = 'math'; + } elseif ( $this->is_html_integration_point() ) { + $token->integration_node_type = 'html'; + } + + if ( false === $only_add_to_element_stack ) { + /* + * @todo Implement the "appropriate place for inserting a node" and the + * "insert an element at the adjusted insertion location" algorithms. + * + * These algorithms mostly impacts DOM tree construction and not the HTML API. + * Here, there's no DOM node onto which the element will be appended, so the + * parser will skip this step. + * + * @see https://html.spec.whatwg.org/#insert-an-element-at-the-adjusted-insertion-location + */ + } + + $this->insert_html_element( $token ); + } + + /** + * Inserts a virtual element on the stack of open elements. + * + * @since 6.7.0 + * + * @param string $token_name Name of token to create and insert into the stack of open elements. + * @param string|null $bookmark_name Optional. Name to give bookmark for created virtual node. + * Defaults to auto-creating a bookmark name. + * @return WP_HTML_Token Newly-created virtual token. + */ + private function insert_virtual_node( $token_name, $bookmark_name = null ): WP_HTML_Token { + $here = $this->bookmarks[ $this->state->current_token->bookmark_name ]; + $name = $bookmark_name ?? $this->bookmark_token(); + + $this->bookmarks[ $name ] = new WP_HTML_Span( $here->start, 0 ); + + $token = new WP_HTML_Token( $name, $token_name, false ); + $this->insert_html_element( $token ); + return $token; + } + /* * HTML Specification Helpers */ + /** + * Indicates if the current token is a MathML integration point. + * + * @since 6.7.0 + * + * @see https://html.spec.whatwg.org/#mathml-text-integration-point + * + * @return bool Whether the current token is a MathML integration point. + */ + private function is_mathml_integration_point(): bool { + $current_token = $this->state->current_token; + if ( ! isset( $current_token ) ) { + return false; + } + + if ( 'math' !== $current_token->namespace || 'M' !== $current_token->node_name[0] ) { + return false; + } + + $tag_name = $current_token->node_name; + + return ( + 'MI' === $tag_name || + 'MO' === $tag_name || + 'MN' === $tag_name || + 'MS' === $tag_name || + 'MTEXT' === $tag_name + ); + } + + /** + * Indicates if the current token is an HTML integration point. + * + * Note that this method must be an instance method with access + * to the current token, since it needs to examine the attributes + * of the currently-matched tag, if it's in the MathML namespace. + * Otherwise it would be required to scan the HTML and ensure that + * no other accounting is overlooked. + * + * @since 6.7.0 + * + * @see https://html.spec.whatwg.org/#html-integration-point + * + * @return bool Whether the current token is an HTML integration point. + */ + private function is_html_integration_point(): bool { + $current_token = $this->state->current_token; + if ( ! isset( $current_token ) ) { + return false; + } + + if ( 'html' === $current_token->namespace ) { + return false; + } + + $tag_name = $current_token->node_name; + + if ( 'svg' === $current_token->namespace ) { + return ( + 'DESC' === $tag_name || + 'FOREIGNOBJECT' === $tag_name || + 'TITLE' === $tag_name + ); + } + + if ( 'math' === $current_token->namespace ) { + if ( 'ANNOTATION-XML' !== $tag_name ) { + return false; + } + + $encoding = $this->get_attribute( 'encoding' ); + + return ( + is_string( $encoding ) && + ( + 0 === strcasecmp( $encoding, 'application/xhtml+xml' ) || + 0 === strcasecmp( $encoding, 'text/html' ) + ) + ); + } + } + /** * Returns whether an element of a given name is in the HTML special category. * @@ -3600,11 +5897,17 @@ private function insert_html_element( WP_HTML_Token $token ): void { * * @see https://html.spec.whatwg.org/#special * - * @param string $tag_name Name of element to check. + * @param WP_HTML_Token|string $tag_name Node to check, or only its name if in the HTML namespace. * @return bool Whether the element of the given name is in the special category. */ public static function is_special( $tag_name ): bool { - $tag_name = strtoupper( $tag_name ); + if ( is_string( $tag_name ) ) { + $tag_name = strtoupper( $tag_name ); + } else { + $tag_name = 'html' === $tag_name->namespace + ? strtoupper( $tag_name->node_name ) + : "{$tag_name->namespace} {$tag_name->node_name}"; + } return ( 'ADDRESS' === $tag_name || @@ -3692,17 +5995,17 @@ public static function is_special( $tag_name ): bool { 'XMP' === $tag_name || // MathML. - 'MI' === $tag_name || - 'MO' === $tag_name || - 'MN' === $tag_name || - 'MS' === $tag_name || - 'MTEXT' === $tag_name || - 'ANNOTATION-XML' === $tag_name || + 'math MI' === $tag_name || + 'math MO' === $tag_name || + 'math MN' === $tag_name || + 'math MS' === $tag_name || + 'math MTEXT' === $tag_name || + 'math ANNOTATION-XML' === $tag_name || // SVG. - 'FOREIGNOBJECT' === $tag_name || - 'DESC' === $tag_name || - 'TITLE' === $tag_name + 'svg DESC' === $tag_name || + 'svg FOREIGNOBJECT' === $tag_name || + 'svg TITLE' === $tag_name ); } @@ -3743,6 +6046,53 @@ public static function is_void( $tag_name ): bool { ); } + /** + * Gets an encoding from a given string. + * + * This is an algorithm defined in the WHAT-WG specification. + * + * Example: + * + * 'UTF-8' === self::get_encoding( 'utf8' ); + * 'UTF-8' === self::get_encoding( " \tUTF-8 " ); + * null === self::get_encoding( 'UTF-7' ); + * null === self::get_encoding( 'utf8; charset=' ); + * + * @see https://encoding.spec.whatwg.org/#concept-encoding-get + * + * @todo As this parser only supports UTF-8, only the UTF-8 + * encodings are detected. Add more as desired, but the + * parser will bail on non-UTF-8 encodings. + * + * @since 6.7.0 + * + * @param string $label A string which may specify a known encoding. + * @return string|null Known encoding if matched, otherwise null. + */ + protected static function get_encoding( string $label ): ?string { + /* + * > Remove any leading and trailing ASCII whitespace from label. + */ + $label = trim( $label, " \t\f\r\n" ); + + /* + * > If label is an ASCII case-insensitive match for any of the labels listed in the + * > table below, then return the corresponding encoding; otherwise return failure. + */ + switch ( strtolower( $label ) ) { + case 'unicode-1-1-utf-8': + case 'unicode11utf8': + case 'unicode20utf8': + case 'utf-8': + case 'utf8': + case 'x-unicode20utf8': + return 'UTF-8'; + + default: + return null; + } + } + /* * Constants that would pollute the top of the class if they were found there. */ diff --git a/src/wp-includes/html-api/class-wp-html-tag-processor.php b/src/wp-includes/html-api/class-wp-html-tag-processor.php index 7d04fd31d80d2..72307cb3920b6 100644 --- a/src/wp-includes/html-api/class-wp-html-tag-processor.php +++ b/src/wp-includes/html-api/class-wp-html-tag-processor.php @@ -511,6 +511,23 @@ class WP_HTML_Tag_Processor { */ protected $parser_state = self::STATE_READY; + /** + * Indicates whether the parser is inside foreign content, + * e.g. inside an SVG or MathML element. + * + * One of 'html', 'svg', or 'math'. + * + * Several parsing rules change based on whether the parser + * is inside foreign content, including whether CDATA sections + * are allowed and whether a self-closing flag indicates that + * an element has no content. + * + * @since 6.7.0 + * + * @var string + */ + private $parsing_namespace = 'html'; + /** * What kind of syntax token became an HTML comment. * @@ -614,7 +631,7 @@ class WP_HTML_Tag_Processor { * * @since 6.5.0 * - * @var string + * @var int */ private $text_length; @@ -780,6 +797,25 @@ public function __construct( $html ) { $this->html = $html; } + /** + * Switches parsing mode into a new namespace, such as when + * encountering an SVG tag and entering foreign content. + * + * @since 6.7.0 + * + * @param string $new_namespace One of 'html', 'svg', or 'math' indicating into what + * namespace the next tokens will be processed. + * @return bool Whether the namespace was valid and changed. + */ + public function change_parsing_namespace( string $new_namespace ): bool { + if ( ! in_array( $new_namespace, array( 'html', 'math', 'svg' ), true ) ) { + return false; + } + + $this->parsing_namespace = $new_namespace; + return true; + } + /** * Finds the next tag matching the $query. * @@ -843,6 +879,7 @@ public function next_tag( $query = null ): bool { * The Tag Processor currently only supports the tag token. * * @since 6.5.0 + * @since 6.7.0 Recognizes CDATA sections within foreign content. * * @return bool Whether a token was parsed. */ @@ -956,6 +993,7 @@ private function base_class_next_token(): bool { */ if ( $this->is_closing_tag || + 'html' !== $this->parsing_namespace || 1 !== strspn( $this->html, 'iIlLnNpPsStTxX', $this->tag_name_starts_at, 1 ) ) { return true; @@ -996,7 +1034,6 @@ private function base_class_next_token(): bool { $duplicate_attributes = $this->duplicate_attributes; // Find the closing tag if necessary. - $found_closer = false; switch ( $tag_name ) { case 'SCRIPT': $found_closer = $this->skip_script_data(); @@ -1431,8 +1468,15 @@ private function skip_script_data(): bool { continue; } - // Everything of interest past here starts with "<". - if ( $at + 1 >= $doc_length || '<' !== $html[ $at++ ] ) { + if ( $at + 1 >= $doc_length ) { + return false; + } + + /* + * Everything of interest past here starts with "<". + * Check this character and advance position regardless. + */ + if ( '<' !== $html[ $at++ ] ) { continue; } @@ -1752,6 +1796,32 @@ private function parse_next_tag(): bool { return true; } + if ( + 'html' !== $this->parsing_namespace && + strlen( $html ) > $at + 8 && + '[' === $html[ $at + 2 ] && + 'C' === $html[ $at + 3 ] && + 'D' === $html[ $at + 4 ] && + 'A' === $html[ $at + 5 ] && + 'T' === $html[ $at + 6 ] && + 'A' === $html[ $at + 7 ] && + '[' === $html[ $at + 8 ] + ) { + $closer_at = strpos( $html, ']]>', $at + 9 ); + if ( false === $closer_at ) { + $this->parser_state = self::STATE_INCOMPLETE_INPUT; + + return false; + } + + $this->parser_state = self::STATE_CDATA_NODE; + $this->text_starts_at = $at + 9; + $this->text_length = $closer_at - $this->text_starts_at; + $this->token_length = $closer_at + 3 - $this->token_starts_at; + $this->bytes_already_parsed = $closer_at + 3; + return true; + } + /* * Anything else here is an incorrectly-opened comment and transitions * to the bogus comment state - skip to the nearest >. If no closer is @@ -1902,6 +1972,8 @@ private function parse_next_tag(): bool { if ( $this->is_closing_tag ) { // No chance of finding a closer. if ( $at + 3 > $doc_length ) { + $this->parser_state = self::STATE_INCOMPLETE_INPUT; + return false; } @@ -2644,6 +2716,17 @@ public function get_attribute_names_with_prefix( $prefix ): ?array { return $matches; } + /** + * Returns the namespace of the matched token. + * + * @since 6.7.0 + * + * @return string One of 'html', 'math', or 'svg'. + */ + public function get_namespace(): string { + return $this->parsing_namespace; + } + /** * Returns the uppercase name of the matched tag. * @@ -2681,6 +2764,388 @@ public function get_tag(): ?string { return null; } + /** + * Returns the adjusted tag name for a given token, taking into + * account the current parsing context, whether HTML, SVG, or MathML. + * + * @since 6.7.0 + * + * @return string|null Name of current tag name. + */ + public function get_qualified_tag_name(): ?string { + $tag_name = $this->get_tag(); + if ( null === $tag_name ) { + return null; + } + + if ( 'html' === $this->get_namespace() ) { + return $tag_name; + } + + $lower_tag_name = strtolower( $tag_name ); + if ( 'math' === $this->get_namespace() ) { + return $lower_tag_name; + } + + if ( 'svg' === $this->get_namespace() ) { + switch ( $lower_tag_name ) { + case 'altglyph': + return 'altGlyph'; + + case 'altglyphdef': + return 'altGlyphDef'; + + case 'altglyphitem': + return 'altGlyphItem'; + + case 'animatecolor': + return 'animateColor'; + + case 'animatemotion': + return 'animateMotion'; + + case 'animatetransform': + return 'animateTransform'; + + case 'clippath': + return 'clipPath'; + + case 'feblend': + return 'feBlend'; + + case 'fecolormatrix': + return 'feColorMatrix'; + + case 'fecomponenttransfer': + return 'feComponentTransfer'; + + case 'fecomposite': + return 'feComposite'; + + case 'feconvolvematrix': + return 'feConvolveMatrix'; + + case 'fediffuselighting': + return 'feDiffuseLighting'; + + case 'fedisplacementmap': + return 'feDisplacementMap'; + + case 'fedistantlight': + return 'feDistantLight'; + + case 'fedropshadow': + return 'feDropShadow'; + + case 'feflood': + return 'feFlood'; + + case 'fefunca': + return 'feFuncA'; + + case 'fefuncb': + return 'feFuncB'; + + case 'fefuncg': + return 'feFuncG'; + + case 'fefuncr': + return 'feFuncR'; + + case 'fegaussianblur': + return 'feGaussianBlur'; + + case 'feimage': + return 'feImage'; + + case 'femerge': + return 'feMerge'; + + case 'femergenode': + return 'feMergeNode'; + + case 'femorphology': + return 'feMorphology'; + + case 'feoffset': + return 'feOffset'; + + case 'fepointlight': + return 'fePointLight'; + + case 'fespecularlighting': + return 'feSpecularLighting'; + + case 'fespotlight': + return 'feSpotLight'; + + case 'fetile': + return 'feTile'; + + case 'feturbulence': + return 'feTurbulence'; + + case 'foreignobject': + return 'foreignObject'; + + case 'glyphref': + return 'glyphRef'; + + case 'lineargradient': + return 'linearGradient'; + + case 'radialgradient': + return 'radialGradient'; + + case 'textpath': + return 'textPath'; + + default: + return $lower_tag_name; + } + } + } + + /** + * Returns the adjusted attribute name for a given attribute, taking into + * account the current parsing context, whether HTML, SVG, or MathML. + * + * @since 6.7.0 + * + * @param string $attribute_name Which attribute to adjust. + * + * @return string|null + */ + public function get_qualified_attribute_name( $attribute_name ): ?string { + if ( self::STATE_MATCHED_TAG !== $this->parser_state ) { + return null; + } + + $namespace = $this->get_namespace(); + $lower_name = strtolower( $attribute_name ); + + if ( 'math' === $namespace && 'definitionurl' === $lower_name ) { + return 'definitionURL'; + } + + if ( 'svg' === $this->get_namespace() ) { + switch ( $lower_name ) { + case 'attributename': + return 'attributeName'; + + case 'attributetype': + return 'attributeType'; + + case 'basefrequency': + return 'baseFrequency'; + + case 'baseprofile': + return 'baseProfile'; + + case 'calcmode': + return 'calcMode'; + + case 'clippathunits': + return 'clipPathUnits'; + + case 'diffuseconstant': + return 'diffuseConstant'; + + case 'edgemode': + return 'edgeMode'; + + case 'filterunits': + return 'filterUnits'; + + case 'glyphref': + return 'glyphRef'; + + case 'gradienttransform': + return 'gradientTransform'; + + case 'gradientunits': + return 'gradientUnits'; + + case 'kernelmatrix': + return 'kernelMatrix'; + + case 'kernelunitlength': + return 'kernelUnitLength'; + + case 'keypoints': + return 'keyPoints'; + + case 'keysplines': + return 'keySplines'; + + case 'keytimes': + return 'keyTimes'; + + case 'lengthadjust': + return 'lengthAdjust'; + + case 'limitingconeangle': + return 'limitingConeAngle'; + + case 'markerheight': + return 'markerHeight'; + + case 'markerunits': + return 'markerUnits'; + + case 'markerwidth': + return 'markerWidth'; + + case 'maskcontentunits': + return 'maskContentUnits'; + + case 'maskunits': + return 'maskUnits'; + + case 'numoctaves': + return 'numOctaves'; + + case 'pathlength': + return 'pathLength'; + + case 'patterncontentunits': + return 'patternContentUnits'; + + case 'patterntransform': + return 'patternTransform'; + + case 'patternunits': + return 'patternUnits'; + + case 'pointsatx': + return 'pointsAtX'; + + case 'pointsaty': + return 'pointsAtY'; + + case 'pointsatz': + return 'pointsAtZ'; + + case 'preservealpha': + return 'preserveAlpha'; + + case 'preserveaspectratio': + return 'preserveAspectRatio'; + + case 'primitiveunits': + return 'primitiveUnits'; + + case 'refx': + return 'refX'; + + case 'refy': + return 'refY'; + + case 'repeatcount': + return 'repeatCount'; + + case 'repeatdur': + return 'repeatDur'; + + case 'requiredextensions': + return 'requiredExtensions'; + + case 'requiredfeatures': + return 'requiredFeatures'; + + case 'specularconstant': + return 'specularConstant'; + + case 'specularexponent': + return 'specularExponent'; + + case 'spreadmethod': + return 'spreadMethod'; + + case 'startoffset': + return 'startOffset'; + + case 'stddeviation': + return 'stdDeviation'; + + case 'stitchtiles': + return 'stitchTiles'; + + case 'surfacescale': + return 'surfaceScale'; + + case 'systemlanguage': + return 'systemLanguage'; + + case 'tablevalues': + return 'tableValues'; + + case 'targetx': + return 'targetX'; + + case 'targety': + return 'targetY'; + + case 'textlength': + return 'textLength'; + + case 'viewbox': + return 'viewBox'; + + case 'viewtarget': + return 'viewTarget'; + + case 'xchannelselector': + return 'xChannelSelector'; + + case 'ychannelselector': + return 'yChannelSelector'; + + case 'zoomandpan': + return 'zoomAndPan'; + } + } + + if ( 'html' !== $namespace ) { + switch ( $lower_name ) { + case 'xlink:actuate': + return 'xlink actuate'; + + case 'xlink:arcrole': + return 'xlink arcrole'; + + case 'xlink:href': + return 'xlink href'; + + case 'xlink:role': + return 'xlink role'; + + case 'xlink:show': + return 'xlink show'; + + case 'xlink:title': + return 'xlink title'; + + case 'xlink:type': + return 'xlink type'; + + case 'xml:lang': + return 'xml lang'; + + case 'xml:space': + return 'xml space'; + + case 'xmlns': + return 'xmlns'; + + case 'xmlns:xlink': + return 'xmlns xlink'; + } + } + + return $attribute_name; + } + /** * Indicates if the currently matched tag contains the self-closing flag. * @@ -2885,11 +3350,15 @@ public function get_comment_type(): ?string { * @return string */ public function get_modifiable_text(): string { - if ( null === $this->text_starts_at || 0 === $this->text_length ) { + $has_enqueued_update = isset( $this->lexical_updates['modifiable text'] ); + + if ( ! $has_enqueued_update && ( null === $this->text_starts_at || 0 === $this->text_length ) ) { return ''; } - $text = substr( $this->html, $this->text_starts_at, $this->text_length ); + $text = $has_enqueued_update + ? $this->lexical_updates['modifiable text']->text + : substr( $this->html, $this->text_starts_at, $this->text_length ); /* * Pre-processing the input stream would normally happen before @@ -2950,12 +3419,167 @@ public function get_modifiable_text(): string { * In all other contexts it's replaced by the replacement character (U+FFFD) * for security reasons (to avoid joining together strings that were safe * when separated, but not when joined). + * + * @todo Inside HTML integration points and MathML integration points, the + * text is processed according to the insertion mode, not according + * to the foreign content rules. This should strip the NULL bytes. */ - return '#text' === $tag_name + return ( '#text' === $tag_name && 'html' === $this->get_namespace() ) ? str_replace( "\x00", '', $decoded ) : str_replace( "\x00", "\u{FFFD}", $decoded ); } + /** + * Sets the modifiable text for the matched token, if matched. + * + * Modifiable text is text content that may be read and changed without + * changing the HTML structure of the document around it. This includes + * the contents of `#text` nodes in the HTML as well as the inner + * contents of HTML comments, Processing Instructions, and others, even + * though these nodes aren't part of a parsed DOM tree. They also contain + * the contents of SCRIPT and STYLE tags, of TEXTAREA tags, and of any + * other section in an HTML document which cannot contain HTML markup (DATA). + * + * Not all modifiable text may be set by this method, and not all content + * may be set as modifiable text. In the case that this fails it will return + * `false` indicating as much. For instance, it will not allow inserting the + * string `next_tag( 'STYLE' ) ) { + * $style = $processor->get_modifiable_text(); + * $processor->set_modifiable_text( "// Made with love on the World Wide Web\n{$style}" ); + * } + * + * // Replace smiley text with Emoji smilies. + * while ( $processor->next_token() ) { + * if ( '#text' !== $processor->get_token_name() ) { + * continue; + * } + * + * $chunk = $processor->get_modifiable_text(); + * if ( ! str_contains( $chunk, ':)' ) ) { + * continue; + * } + * + * $processor->set_modifiable_text( str_replace( ':)', '🙂', $chunk ) ); + * } + * + * @since 6.7.0 + * + * @param string $plaintext_content New text content to represent in the matched token. + * + * @return bool Whether the text was able to update. + */ + public function set_modifiable_text( string $plaintext_content ): bool { + if ( self::STATE_TEXT_NODE === $this->parser_state ) { + $this->lexical_updates['modifiable text'] = new WP_HTML_Text_Replacement( + $this->text_starts_at, + $this->text_length, + htmlspecialchars( $plaintext_content, ENT_QUOTES | ENT_HTML5 ) + ); + + return true; + } + + // Comment data is not encoded. + if ( + self::STATE_COMMENT === $this->parser_state && + self::COMMENT_AS_HTML_COMMENT === $this->comment_type + ) { + // Check if the text could close the comment. + if ( 1 === preg_match( '/--!?>/', $plaintext_content ) ) { + return false; + } + + $this->lexical_updates['modifiable text'] = new WP_HTML_Text_Replacement( + $this->text_starts_at, + $this->text_length, + $plaintext_content + ); + + return true; + } + + if ( self::STATE_MATCHED_TAG !== $this->parser_state ) { + return false; + } + + switch ( $this->get_tag() ) { + case 'SCRIPT': + /* + * This is over-protective, but ensures the update doesn't break + * out of the SCRIPT element. A more thorough check would need to + * ensure that the script closing tag doesn't exist, and isn't + * also "hidden" inside the script double-escaped state. + * + * It may seem like replacing `lexical_updates['modifiable text'] = new WP_HTML_Text_Replacement( + $this->text_starts_at, + $this->text_length, + $plaintext_content + ); + + return true; + + case 'STYLE': + $plaintext_content = preg_replace_callback( + '~style)~i', + static function ( $tag_match ) { + return "\\3c\\2f{$tag_match['TAG_NAME']}"; + }, + $plaintext_content + ); + + $this->lexical_updates['modifiable text'] = new WP_HTML_Text_Replacement( + $this->text_starts_at, + $this->text_length, + $plaintext_content + ); + + return true; + + case 'TEXTAREA': + case 'TITLE': + $plaintext_content = preg_replace_callback( + "~{$this->get_tag()})~i", + static function ( $tag_match ) { + return "</{$tag_match['TAG_NAME']}"; + }, + $plaintext_content + ); + + /* + * These don't _need_ to be escaped, but since they are decoded it's + * safe to leave them escaped and this can prevent other code from + * naively detecting tags within the contents. + * + * @todo It would be useful to prefix a multiline replacement text + * with a newline, but not necessary. This is for aesthetics. + */ + $this->lexical_updates['modifiable text'] = new WP_HTML_Text_Replacement( + $this->text_starts_at, + $this->text_length, + $plaintext_content + ); + + return true; + } + + return false; + } + /** * Updates or creates a new attribute on the currently matched tag with the passed value. * @@ -3043,7 +3667,13 @@ public function set_attribute( $name, $value ): bool { * * @see https://html.spec.whatwg.org/#attributes-3 */ - $escaped_new_value = in_array( $comparable_name, wp_kses_uri_attributes() ) ? esc_url( $value ) : esc_attr( $value ); + $escaped_new_value = in_array( $comparable_name, wp_kses_uri_attributes(), true ) ? esc_url( $value ) : esc_attr( $value ); + + // If the escaping functions wiped out the update, reject it and indicate it was rejected. + if ( '' === $escaped_new_value && '' !== $value ) { + return false; + } + $updated_attribute = "{$name}=\"{$escaped_new_value}\""; } @@ -3379,7 +4009,13 @@ private function matches(): bool { } // Does the tag name match the requested tag name in a case-insensitive manner? - if ( isset( $this->sought_tag_name ) && 0 !== substr_compare( $this->html, $this->sought_tag_name, $this->tag_name_starts_at, $this->tag_name_length, true ) ) { + if ( + isset( $this->sought_tag_name ) && + ( + strlen( $this->sought_tag_name ) !== $this->tag_name_length || + 0 !== substr_compare( $this->html, $this->sought_tag_name, $this->tag_name_starts_at, $this->tag_name_length, true ) + ) + ) { return false; } @@ -3390,6 +4026,27 @@ private function matches(): bool { return true; } + /** + * Gets DOCTYPE declaration info from a DOCTYPE token. + * + * DOCTYPE tokens may appear in many places in an HTML document. In most places, they are + * simply ignored. The main parsing functions find the basic shape of DOCTYPE tokens but + * do not perform detailed parsing. + * + * This method can be called to perform a full parse of the DOCTYPE token and retrieve + * its information. + * + * @return WP_HTML_Doctype_Info|null The DOCTYPE declaration information or `null` if not + * currently at a DOCTYPE node. + */ + public function get_doctype_info(): ?WP_HTML_Doctype_Info { + if ( self::STATE_DOCTYPE !== $this->parser_state ) { + return null; + } + + return WP_HTML_Doctype_Info::from_doctype_token( substr( $this->html, $this->token_starts_at, $this->token_length ) ); + } + /** * Parser Ready State. * @@ -3481,7 +4138,7 @@ private function matches(): bool { /** * Indicates that the parser has found a DOCTYPE node and it's - * possible to read and modify its modifiable text. + * possible to read its DOCTYPE information via `get_doctype_info()`. * * @since 6.5.0 * diff --git a/src/wp-includes/html-api/class-wp-html-token.php b/src/wp-includes/html-api/class-wp-html-token.php index 948fe343dfbaa..d5e51ac29007f 100644 --- a/src/wp-includes/html-api/class-wp-html-token.php +++ b/src/wp-includes/html-api/class-wp-html-token.php @@ -60,6 +60,24 @@ class WP_HTML_Token { */ public $has_self_closing_flag = false; + /** + * Indicates if the element is an HTML element or if it's inside foreign content. + * + * @since 6.7.0 + * + * @var string 'html', 'svg', or 'math'. + */ + public $namespace = 'html'; + + /** + * Indicates which kind of integration point the element is, if any. + * + * @since 6.7.0 + * + * @var string|null 'math', 'html', or null if not an integration point. + */ + public $integration_node_type = null; + /** * Called when token is garbage-collected or otherwise destroyed. * @@ -80,6 +98,7 @@ class WP_HTML_Token { */ public function __construct( ?string $bookmark_name, string $node_name, bool $has_self_closing_flag, ?callable $on_destroy = null ) { $this->bookmark_name = $bookmark_name; + $this->namespace = 'html'; $this->node_name = $node_name; $this->has_self_closing_flag = $has_self_closing_flag; $this->on_destroy = $on_destroy; diff --git a/src/wp-includes/http.php b/src/wp-includes/http.php index 26a0fc3a7e5a3..240fd0379500a 100644 --- a/src/wp-includes/http.php +++ b/src/wp-includes/http.php @@ -389,8 +389,6 @@ function wp_remote_retrieve_cookie_value( $response, $name ) { * @return bool */ function wp_http_supports( $capabilities = array(), $url = null ) { - $http = _wp_http_get_object(); - $capabilities = wp_parse_args( $capabilities ); $count = count( $capabilities ); @@ -407,7 +405,7 @@ function wp_http_supports( $capabilities = array(), $url = null ) { } } - return (bool) $http->_get_first_available_transport( $capabilities ); + return WpOrg\Requests\Requests::has_capabilities( $capabilities ); } /** diff --git a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php index 50fb4d6ac216f..b552d07938e07 100644 --- a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php +++ b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php @@ -494,6 +494,7 @@ private function _process_directives( string $html ) { * @since 6.5.0 * @since 6.6.0 The function now adds a warning when the namespace is null, falsy, or the directive value is empty. * @since 6.6.0 Removed `default_namespace` and `context` arguments. + * @since 6.6.0 Add support for derived state. * * @param string|true $directive_value The directive attribute value string or `true` when it's a boolean attribute. * @return mixed|null The result of the evaluation. Null if the reference path doesn't exist or the namespace is falsy. @@ -530,32 +531,32 @@ private function evaluate( $directive_value ) { } else { return null; } - } - if ( $current instanceof Closure ) { - /* - * This state getter's namespace is added to the stack so that - * `state()` or `get_config()` read that namespace when called - * without specifying one. - */ - array_push( $this->namespace_stack, $ns ); - try { - $current = $current(); - } catch ( Throwable $e ) { - _doing_it_wrong( - __METHOD__, - sprintf( - /* translators: 1: Path pointing to an Interactivity API state property, 2: Namespace for an Interactivity API store. */ - __( 'Uncaught error executing a derived state callback with path "%1$s" and namespace "%2$s".' ), - $path, - $ns - ), - '6.6.0' - ); - return null; - } finally { - // Remove the property's namespace from the stack. - array_pop( $this->namespace_stack ); + if ( $current instanceof Closure ) { + /* + * This state getter's namespace is added to the stack so that + * `state()` or `get_config()` read that namespace when called + * without specifying one. + */ + array_push( $this->namespace_stack, $ns ); + try { + $current = $current(); + } catch ( Throwable $e ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: 1: Path pointing to an Interactivity API state property, 2: Namespace for an Interactivity API store. */ + __( 'Uncaught error executing a derived state callback with path "%1$s" and namespace "%2$s".' ), + $path, + $ns + ), + '6.6.0' + ); + return null; + } finally { + // Remove the property's namespace from the stack. + array_pop( $this->namespace_stack ); + } } } diff --git a/src/wp-includes/l10n/class-wp-translation-file-mo.php b/src/wp-includes/l10n/class-wp-translation-file-mo.php index 3f5e72597ca5f..f50c0ec1cf2d1 100644 --- a/src/wp-includes/l10n/class-wp-translation-file-mo.php +++ b/src/wp-includes/l10n/class-wp-translation-file-mo.php @@ -154,7 +154,7 @@ protected function parse_file(): bool { // Metadata about the MO file is stored in the first translation entry. if ( '' === $original ) { foreach ( explode( "\n", $translation ) as $meta_line ) { - if ( '' === $meta_line ) { + if ( '' === $meta_line || ! str_contains( $meta_line, ':' ) ) { continue; } diff --git a/src/wp-includes/link-template.php b/src/wp-includes/link-template.php index dde12c16168be..4cfc47f83ec05 100644 --- a/src/wp-includes/link-template.php +++ b/src/wp-includes/link-template.php @@ -1092,7 +1092,7 @@ function get_edit_term_link( $term, $taxonomy = '', $object_type = '' ) { } $args = array( - 'taxonomy' => $taxonomy, + 'taxonomy' => $tax->name, 'tag_ID' => $term_id, ); @@ -1595,27 +1595,39 @@ function get_delete_post_link( $post = 0, $deprecated = '', $force_delete = fals * Retrieves the edit comment link. * * @since 2.3.0 + * @since 6.7.0 The $context parameter was added. * * @param int|WP_Comment $comment_id Optional. Comment ID or WP_Comment object. - * @return string|void The edit comment link URL for the given comment. + * @param string $context Optional. Context in which the URL should be used. Either 'display', + * to include HTML entities, or 'url'. Default 'display'. + * @return string|void The edit comment link URL for the given comment, or void if the comment id does not exist or + * the current user is not allowed to edit it. */ -function get_edit_comment_link( $comment_id = 0 ) { +function get_edit_comment_link( $comment_id = 0, $context = 'display' ) { $comment = get_comment( $comment_id ); - if ( ! current_user_can( 'edit_comment', $comment->comment_ID ) ) { + if ( ! is_object( $comment ) || ! current_user_can( 'edit_comment', $comment->comment_ID ) ) { return; } - $location = admin_url( 'comment.php?action=editcomment&c=' ) . $comment->comment_ID; + if ( 'display' === $context ) { + $action = 'comment.php?action=editcomment&c='; + } else { + $action = 'comment.php?action=editcomment&c='; + } + + $location = admin_url( $action ) . $comment->comment_ID; /** * Filters the comment edit link. * - * @since 2.3.0 + * @since 6.7.0 The $comment_id and $context parameters are now being passed to the filter. * * @param string $location The edit link. + * @param int $comment_id Optional. Unique ID of the comment to generate an edit link. + * @param int $context Optional. Context to include HTML entities in link. Default 'display'. */ - return apply_filters( 'get_edit_comment_link', $location ); + return apply_filters( 'get_edit_comment_link', $location, $comment_id, $context ); } /** @@ -4328,6 +4340,7 @@ function is_avatar_comment_type( $comment_type ) { * Retrieves default data about the avatar. * * @since 4.2.0 + * @since 6.7.0 Gravatar URLs always use HTTPS. * * @param mixed $id_or_email The avatar to retrieve. Accepts a user ID, Gravatar MD5 hash, * user email, WP_User object, WP_Post object, or WP_Comment object. @@ -4358,6 +4371,9 @@ function is_avatar_comment_type( $comment_type ) { * - 'X' (even more mature than above) * Default is the value of the 'avatar_rating' option. * @type string $scheme URL scheme to use. See set_url_scheme() for accepted values. + * For Gravatars this setting is ignored and HTTPS is used to avoid + * unnecessary redirects. The setting is retained for systems using + * the {@see 'pre_get_avatar_data'} filter to customize avatars. * Default null. * @type array $processed_args When the function returns, the value will be the processed/sanitized $args * plus a "found_avatar" guess. Pass as a reference. Default null. @@ -4508,9 +4524,6 @@ function get_avatar_data( $id_or_email, $args = null ) { if ( $email_hash ) { $args['found_avatar'] = true; - $gravatar_server = hexdec( $email_hash[0] ) % 3; - } else { - $gravatar_server = rand( 0, 2 ); } $url_args = array( @@ -4520,15 +4533,17 @@ function get_avatar_data( $id_or_email, $args = null ) { 'r' => $args['rating'], ); - if ( is_ssl() ) { - $url = 'https://secure.gravatar.com/avatar/' . $email_hash; - } else { - $url = sprintf( 'http://%d.gravatar.com/avatar/%s', $gravatar_server, $email_hash ); - } + /* + * Gravatars are always served over HTTPS. + * + * The Gravatar website redirects HTTP requests to HTTPS URLs so always + * use the HTTPS scheme to avoid unnecessary redirects. + */ + $url = 'https://secure.gravatar.com/avatar/' . $email_hash; $url = add_query_arg( rawurlencode_deep( array_filter( $url_args ) ), - set_url_scheme( $url, $args['scheme'] ) + $url ); /** diff --git a/src/wp-includes/media-template.php b/src/wp-includes/media-template.php index d0ae16b3fd7b7..3a9bd723ba78f 100644 --- a/src/wp-includes/media-template.php +++ b/src/wp-includes/media-template.php @@ -605,8 +605,12 @@ function wp_print_media_templates() {
<# if ( data.image && data.image.src && data.image.src !== data.icon ) { #> - <# } else if ( data.sizes && data.sizes.medium ) { #> - + <# } else if ( data.sizes ) { + if ( data.sizes.medium ) { #> + + <# } else { #> + + <# } #> <# } else { #> <# } #> @@ -1542,21 +1546,31 @@ function wp_print_media_templates() { - diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php index f79ce679f6770..5c93aee2256b4 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -4064,8 +4064,7 @@ function wp_get_image_editor( $path, $args = array() ) { // Check and set the output mime type mapped to the input type. if ( isset( $args['mime_type'] ) ) { - /** This filter is documented in wp-includes/class-wp-image-editor.php */ - $output_format = apply_filters( 'image_editor_output_format', array(), $path, $args['mime_type'] ); + $output_format = wp_get_image_editor_output_format( $path, $args['mime_type'] ); if ( isset( $output_format[ $args['mime_type'] ] ) ) { $args['output_mime_type'] = $output_format[ $args['mime_type'] ]; } @@ -4224,6 +4223,11 @@ function wp_plupload_default_settings() { $defaults['avif_upload_error'] = true; } + // Check if HEIC images can be edited. + if ( ! wp_image_editor_supports( array( 'mime_type' => 'image/heic' ) ) ) { + $defaults['heic_upload_error'] = true; + } + /** * Filters the Plupload default settings. * @@ -5483,12 +5487,17 @@ function _wp_add_additional_image_sizes() { * Callback to enable showing of the user error when uploading .heic images. * * @since 5.5.0 + * @since 6.7.0 The default behavior is to enable heic uploads as long as the server + * supports the format. The uploads are converted to JPEG's by default. * * @param array[] $plupload_settings The settings for Plupload.js. * @return array[] Modified settings for Plupload.js. */ function wp_show_heic_upload_error( $plupload_settings ) { - $plupload_settings['heic_upload_error'] = true; + // Check if HEIC images can be edited. + if ( ! wp_image_editor_supports( array( 'mime_type' => 'image/heic' ) ) ) { + $plupload_init['heic_upload_error'] = true; + } return $plupload_settings; } @@ -5586,6 +5595,29 @@ function wp_getimagesize( $filename, ?array &$image_info = null ) { } } + // For PHP versions that don't support HEIC images, extract the size info using Imagick when available. + if ( 'image/heic' === wp_get_image_mime( $filename ) ) { + $editor = wp_get_image_editor( $filename ); + if ( is_wp_error( $editor ) ) { + return false; + } + // If the editor for HEICs is Imagick, use it to get the image size. + if ( $editor instanceof WP_Image_Editor_Imagick ) { + $size = $editor->get_size(); + return array( + $size['width'], + $size['height'], + IMAGETYPE_HEIC, + sprintf( + 'width="%d" height="%d"', + $size['width'], + $size['height'] + ), + 'mime' => 'image/heic', + ); + } + } + // The image could not be parsed. return false; } @@ -6069,3 +6101,37 @@ function wp_high_priority_element_flag( $value = null ) { return $high_priority_element; } + +/** + * Determines the output format for the image editor. + * + * @since 6.7.0 + * @access private + * + * @param string $filename Path to the image. + * @param string $mime_type The source image mime type. + * @return string[] An array of mime type mappings. + */ +function wp_get_image_editor_output_format( $filename, $mime_type ) { + /** + * Filters the image editor output format mapping. + * + * Enables filtering the mime type used to save images. By default, + * the mapping array is empty, so the mime type matches the source image. + * + * @see WP_Image_Editor::get_output_format() + * + * @since 5.8.0 + * @since 6.7.0 The default was changed from array() to array( 'image/heic' => 'image/jpeg' ). + * + * @param string[] $output_format { + * An array of mime type mappings. Maps a source mime type to a new + * destination mime type. Default maps uploaded HEIC images to JPEG output. + * + * @type string ...$0 The new mime type. + * } + * @param string $filename Path to the image. + * @param string $mime_type The source image mime type. + */ + return apply_filters( 'image_editor_output_format', array( 'image/heic' => 'image/jpeg' ), $filename, $mime_type ); +} diff --git a/src/wp-includes/nav-menu.php b/src/wp-includes/nav-menu.php index a063835fda341..d808c4e212d39 100644 --- a/src/wp-includes/nav-menu.php +++ b/src/wp-includes/nav-menu.php @@ -491,17 +491,25 @@ function wp_update_nav_menu_item( $menu_id = 0, $menu_item_db_id = 0, $menu_item $args['menu-item-url'] = ''; $original_title = ''; + if ( 'taxonomy' === $args['menu-item-type'] ) { - $original_parent = get_term_field( 'parent', $args['menu-item-object-id'], $args['menu-item-object'], 'raw' ); - $original_title = get_term_field( 'name', $args['menu-item-object-id'], $args['menu-item-object'], 'raw' ); - } elseif ( 'post_type' === $args['menu-item-type'] ) { + $original_object = get_term( $args['menu-item-object-id'], $args['menu-item-object'] ); + if ( $original_object instanceof WP_Term ) { + $original_parent = get_term_field( 'parent', $args['menu-item-object-id'], $args['menu-item-object'], 'raw' ); + $original_title = get_term_field( 'name', $args['menu-item-object-id'], $args['menu-item-object'], 'raw' ); + } + } elseif ( 'post_type' === $args['menu-item-type'] ) { $original_object = get_post( $args['menu-item-object-id'] ); - $original_parent = (int) $original_object->post_parent; - $original_title = $original_object->post_title; + + if ( $original_object instanceof WP_Post ) { + $original_parent = (int) $original_object->post_parent; + $original_title = $original_object->post_title; + } } elseif ( 'post_type_archive' === $args['menu-item-type'] ) { $original_object = get_post_type_object( $args['menu-item-object'] ); - if ( $original_object ) { + + if ( $original_object instanceof WP_Post_Type ) { $original_title = $original_object->labels->archives; } } diff --git a/src/wp-includes/option.php b/src/wp-includes/option.php index 125f25d40d157..537f7aafd0aba 100644 --- a/src/wp-includes/option.php +++ b/src/wp-includes/option.php @@ -2264,6 +2264,17 @@ function delete_network_option( $network_id, $option ) { 'site_id' => $network_id, ) ); + + if ( $result ) { + $notoptions_key = "$network_id:notoptions"; + $notoptions = wp_cache_get( $notoptions_key, 'site-options' ); + + if ( ! is_array( $notoptions ) ) { + $notoptions = array(); + } + $notoptions[ $option ] = true; + wp_cache_set( $notoptions_key, $notoptions, 'site-options' ); + } } if ( $result ) { @@ -2293,15 +2304,6 @@ function delete_network_option( $network_id, $option ) { */ do_action( 'delete_site_option', $option, $network_id ); - $notoptions_key = "$network_id:notoptions"; - $notoptions = wp_cache_get( $notoptions_key, 'site-options' ); - - if ( ! is_array( $notoptions ) ) { - $notoptions = array(); - } - $notoptions[ $option ] = true; - wp_cache_set( $notoptions_key, $notoptions, 'site-options' ); - return true; } diff --git a/src/wp-includes/pluggable.php b/src/wp-includes/pluggable.php index 0e9e0d4579c19..8380a597249ba 100644 --- a/src/wp-includes/pluggable.php +++ b/src/wp-includes/pluggable.php @@ -2468,6 +2468,41 @@ function wp_salt( $scheme = 'auth' ) { } } + /* + * Determine which options to prime. + * + * If the salt keys are undefined, use a duplicate value or the + * default `put your unique phrase here` value the salt will be + * generated via `wp_generate_password()` and stored as a site + * option. These options will be primed to avoid repeated + * database requests for undefined salts. + */ + $options_to_prime = array(); + foreach ( array( 'auth', 'secure_auth', 'logged_in', 'nonce' ) as $key ) { + foreach ( array( 'key', 'salt' ) as $second ) { + $const = strtoupper( "{$key}_{$second}" ); + if ( ! defined( $const ) || true === $duplicated_keys[ constant( $const ) ] ) { + $options_to_prime[] = "{$key}_{$second}"; + } + } + } + + if ( ! empty( $options_to_prime ) ) { + /* + * Also prime `secret_key` used for undefined salting schemes. + * + * If the scheme is unknown, the default value for `secret_key` will be + * used too for the salt. This should rarely happen, so the option is only + * primed if other salts are undefined. + * + * At this point of execution it is known that a database call will be made + * to prime salts, so the `secret_key` option can be primed regardless of the + * constants status. + */ + $options_to_prime[] = 'secret_key'; + wp_prime_site_option_caches( $options_to_prime ); + } + $values = array( 'key' => '', 'salt' => '', diff --git a/src/wp-includes/post-template.php b/src/wp-includes/post-template.php index 7b12bf6c4c602..4f3bfbef0cd7e 100644 --- a/src/wp-includes/post-template.php +++ b/src/wp-includes/post-template.php @@ -1777,9 +1777,10 @@ function get_the_password_form( $post = 0 ) { /** * Filters the HTML output for the protected post password form. * - * If modifying the password field, please note that the core database schema - * limits the password field to 20 characters regardless of the value of the - * size attribute in the form input. + * If modifying the password field, please note that the WordPress database schema + * limits the password field to 255 characters regardless of the value of the + * `minlength` or `maxlength` attributes or other validation that may be added to + * the input. * * @since 2.7.0 * @since 5.8.0 Added the `$post` parameter. diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 993056ff2c032..69f2afcb8955b 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -6829,7 +6829,7 @@ function wp_attachment_is( $type, $post = null ) { switch ( $type ) { case 'image': - $image_exts = array( 'jpg', 'jpeg', 'jpe', 'gif', 'png', 'webp', 'avif' ); + $image_exts = array( 'jpg', 'jpeg', 'jpe', 'gif', 'png', 'webp', 'avif', 'heic' ); return in_array( $ext, $image_exts, true ); case 'audio': diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 7aa2534520621..9b023e97ece1b 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -38,26 +38,55 @@ function register_rest_route( $route_namespace, $route, $args = array(), $overri * and namespace indexes. If you really need to register a * non-namespaced route, call `WP_REST_Server::register_route` directly. */ - _doing_it_wrong( 'register_rest_route', __( 'Routes must be namespaced with plugin or theme name and version.' ), '4.4.0' ); + _doing_it_wrong( + __FUNCTION__, + sprintf( + /* translators: 1: string value of the namespace, 2: string value of the route. */ + __( 'Routes must be namespaced with plugin or theme name and version. Instead there seems to be an empty namespace \'%1$s\' for route \'%2$s\'.' ), + '' . $route_namespace . '', + '' . $route . '' + ), + '4.4.0' + ); return false; } elseif ( empty( $route ) ) { - _doing_it_wrong( 'register_rest_route', __( 'Route must be specified.' ), '4.4.0' ); + _doing_it_wrong( + __FUNCTION__, + sprintf( + /* translators: 1: string value of the namespace, 2: string value of the route. */ + __( 'Route must be specified. Instead within the namespace \'%1$s\', there seems to be an empty route \'%2$s\'.' ), + '' . $route_namespace . '', + '' . $route . '' + ), + '4.4.0' + ); return false; } $clean_namespace = trim( $route_namespace, '/' ); if ( $clean_namespace !== $route_namespace ) { - _doing_it_wrong( __FUNCTION__, __( 'Namespace must not start or end with a slash.' ), '5.4.2' ); + _doing_it_wrong( + __FUNCTION__, + sprintf( + /* translators: 1: string value of the namespace, 2: string value of the route. */ + __( 'Namespace must not start or end with a slash. Instead namespace \'%1$s\' for route \'%2$s\' seems to contain a slash.' ), + '' . $route_namespace . '', + '' . $route . '' + ), + '5.4.2' + ); } if ( ! did_action( 'rest_api_init' ) ) { _doing_it_wrong( - 'register_rest_route', + __FUNCTION__, sprintf( - /* translators: %s: rest_api_init */ - __( 'REST API routes must be registered on the %s action.' ), - 'rest_api_init' + /* translators: 1: rest_api_init, 2: string value of the route, 3: string value of the namespace. */ + __( 'REST API routes must be registered on the %1$s action. Instead route \'%2$s\' with namespace \'%3$s\' was not registered on this action.' ), + 'rest_api_init', + '' . $route . '', + '' . $route_namespace . '' ), '5.1.0' ); diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php index b3004adb5e59a..0c98a729e43f9 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php @@ -339,8 +339,7 @@ protected function insert_attachment( $request ) { * * @since 4.7.0 * - * @param WP_Post $attachment Inserted or updated attachment - * object. + * @param WP_Post $attachment Inserted or updated attachment object. * @param WP_REST_Request $request The request sent to the API. * @param bool $creating True when creating an attachment, false when updating. */ @@ -531,7 +530,7 @@ public function edit_media_item( $request ) { ); } - $supported_types = array( 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif' ); + $supported_types = array( 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/heic' ); $mime_type = get_post_mime_type( $attachment_id ); if ( ! in_array( $mime_type, $supported_types, true ) ) { return new WP_Error( diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-pattern-directory-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-pattern-directory-controller.php index 41f37c9f1db8d..3345ea0aa18a7 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-pattern-directory-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-pattern-directory-controller.php @@ -87,13 +87,6 @@ public function get_items_permissions_check( $request ) { * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ public function get_items( $request ) { - /* - * Include an unmodified `$wp_version`, so the API can craft a response that's tailored to - * it. Some plugins modify the version in a misguided attempt to improve security by - * obscuring the version, which can cause invalid requests. - */ - require ABSPATH . WPINC . '/version.php'; - $valid_query_args = array( 'offset' => true, 'order' => true, @@ -106,7 +99,7 @@ public function get_items( $request ) { $query_args = array_intersect_key( $request->get_params(), $valid_query_args ); $query_args['locale'] = get_user_locale(); - $query_args['wp-version'] = $wp_version; + $query_args['wp-version'] = wp_get_wp_version(); $query_args['pattern-categories'] = isset( $request['category'] ) ? $request['category'] : false; $query_args['pattern-keywords'] = isset( $request['keyword'] ) ? $request['keyword'] : false; diff --git a/src/wp-includes/rest-api/fields/class-wp-rest-meta-fields.php b/src/wp-includes/rest-api/fields/class-wp-rest-meta-fields.php index 7b46905a7aa4f..5f3b55843e23a 100644 --- a/src/wp-includes/rest-api/fields/class-wp-rest-meta-fields.php +++ b/src/wp-includes/rest-api/fields/class-wp-rest-meta-fields.php @@ -268,6 +268,7 @@ protected function delete_meta_value( $object_id, $meta_key, $name ) { * Alters the list of values in the database to match the list of provided values. * * @since 4.7.0 + * @since 6.7.0 Stores values into DB even if provided registered default value. * * @param int $object_id Object ID to update. * @param string $meta_key Key for the custom field. @@ -290,7 +291,7 @@ protected function update_multi_meta_value( $object_id, $meta_key, $name, $value ); } - $current_values = get_metadata( $meta_type, $object_id, $meta_key, false ); + $current_values = get_metadata_raw( $meta_type, $object_id, $meta_key, false ); $subtype = get_object_subtype( $meta_type, $object_id ); if ( ! is_array( $current_values ) ) { @@ -367,6 +368,7 @@ function ( $stored_value ) use ( $meta_key, $subtype, $value ) { * Updates a meta value for an object. * * @since 4.7.0 + * @since 6.7.0 Stores values into DB even if provided registered default value. * * @param int $object_id Object ID to update. * @param string $meta_key Key for the custom field. @@ -378,7 +380,7 @@ protected function update_meta_value( $object_id, $meta_key, $name, $value ) { $meta_type = $this->get_meta_type(); // Do the exact same check for a duplicate value as in update_metadata() to avoid update_metadata() returning false. - $old_value = get_metadata( $meta_type, $object_id, $meta_key ); + $old_value = get_metadata_raw( $meta_type, $object_id, $meta_key ); $subtype = get_object_subtype( $meta_type, $object_id ); if ( is_array( $old_value ) && 1 === count( $old_value ) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index b7da065ad1796..0c71a1b3b0b1c 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -54,11 +54,10 @@ function wp_register_tinymce_scripts( $scripts, $force_uncompressed = false ) { script_concat_settings(); - $compressed = $compress_scripts && $concatenate_scripts && isset( $_SERVER['HTTP_ACCEPT_ENCODING'] ) - && false !== stripos( $_SERVER['HTTP_ACCEPT_ENCODING'], 'gzip' ) && ! $force_uncompressed; + $compressed = $compress_scripts && $concatenate_scripts && ! $force_uncompressed; /* - * Load tinymce.js when running from /src, otherwise load wp-tinymce.js.gz (in production) + * Load tinymce.js when running from /src, otherwise load wp-tinymce.js (in production) * or tinymce.min.js (when SCRIPT_DEBUG is true). */ if ( $compressed ) { @@ -111,10 +110,10 @@ function wp_default_packages_vendor( $scripts ) { 'react' => '18.3.1', 'react-dom' => '18.3.1', 'react-jsx-runtime' => '18.3.1', - 'regenerator-runtime' => '0.14.0', + 'regenerator-runtime' => '0.14.1', 'moment' => '2.29.4', 'lodash' => '4.17.21', - 'wp-polyfill-fetch' => '3.6.17', + 'wp-polyfill-fetch' => '3.6.20', 'wp-polyfill-formdata' => '4.0.10', 'wp-polyfill-node-contains' => '4.8.0', 'wp-polyfill-url' => '3.6.4', @@ -684,7 +683,13 @@ function wp_scripts_get_suffix( $type = '' ) { static $suffixes; if ( null === $suffixes ) { - // Include an unmodified $wp_version. + /* + * Include an unmodified $wp_version. + * + * Note: wp_get_wp_version() is not used here, as this file can be included + * via wp-admin/load-scripts.php or wp-admin/load-styles.php, in which case + * wp-includes/functions.php is not loaded. + */ require ABSPATH . WPINC . '/version.php'; /* @@ -1033,8 +1038,8 @@ function wp_default_scripts( $scripts ) { $scripts->add( 'json2', "/wp-includes/js/json2$suffix.js", array(), '2015-05-03' ); did_action( 'init' ) && $scripts->add_data( 'json2', 'conditional', 'lt IE 8' ); - $scripts->add( 'underscore', "/wp-includes/js/underscore$dev_suffix.js", array(), '1.13.4', 1 ); - $scripts->add( 'backbone', "/wp-includes/js/backbone$dev_suffix.js", array( 'underscore', 'jquery' ), '1.5.0', 1 ); + $scripts->add( 'underscore', "/wp-includes/js/underscore$dev_suffix.js", array(), '1.13.7', 1 ); + $scripts->add( 'backbone', "/wp-includes/js/backbone$dev_suffix.js", array( 'underscore', 'jquery' ), '1.6.0', 1 ); $scripts->add( 'wp-util', "/wp-includes/js/wp-util$suffix.js", array( 'underscore', 'jquery' ), false, 1 ); did_action( 'init' ) && $scripts->localize( @@ -1392,7 +1397,7 @@ function wp_default_scripts( $scripts ) { $scripts->add( 'admin-tags', "/wp-admin/js/tags$suffix.js", array( 'jquery', 'wp-ajax-response' ), false, 1 ); $scripts->set_translations( 'admin-tags' ); - $scripts->add( 'admin-comments', "/wp-admin/js/edit-comments$suffix.js", array( 'wp-lists', 'quicktags', 'jquery-query' ), false, 1 ); + $scripts->add( 'admin-comments', "/wp-admin/js/edit-comments$suffix.js", array( 'wp-lists', 'quicktags', 'jquery-query', 'wp-a11y' ), false, 1 ); $scripts->set_translations( 'admin-comments' ); did_action( 'init' ) && $scripts->localize( 'admin-comments', @@ -1522,7 +1527,13 @@ function wp_default_scripts( $scripts ) { function wp_default_styles( $styles ) { global $editor_styles; - // Include an unmodified $wp_version. + /* + * Include an unmodified $wp_version. + * + * Note: wp_get_wp_version() is not used here, as this file can be included + * via wp-admin/load-scripts.php or wp-admin/load-styles.php, in which case + * wp-includes/functions.php is not loaded. + */ require ABSPATH . WPINC . '/version.php'; if ( ! defined( 'SCRIPT_DEBUG' ) ) { @@ -3045,7 +3056,7 @@ static function ( $matches ) use ( $stylesheet_url ) { if ( str_starts_with( $url, 'http:' ) || str_starts_with( $url, 'https:' ) || - str_starts_with( $url, '//' ) || + str_starts_with( $url, '/' ) || str_starts_with( $url, '#' ) || str_starts_with( $url, 'data:' ) ) { diff --git a/src/wp-includes/style-engine/class-wp-style-engine.php b/src/wp-includes/style-engine/class-wp-style-engine.php index 1ba813ed65b14..3012ca3eefd30 100644 --- a/src/wp-includes/style-engine/class-wp-style-engine.php +++ b/src/wp-includes/style-engine/class-wp-style-engine.php @@ -50,31 +50,37 @@ final class WP_Style_Engine { */ const BLOCK_STYLE_DEFINITIONS_METADATA = array( 'background' => array( - 'backgroundImage' => array( + 'backgroundImage' => array( 'property_keys' => array( 'default' => 'background-image', ), 'value_func' => array( self::class, 'get_url_or_value_css_declaration' ), 'path' => array( 'background', 'backgroundImage' ), ), - 'backgroundPosition' => array( + 'backgroundPosition' => array( 'property_keys' => array( 'default' => 'background-position', ), 'path' => array( 'background', 'backgroundPosition' ), ), - 'backgroundRepeat' => array( + 'backgroundRepeat' => array( 'property_keys' => array( 'default' => 'background-repeat', ), 'path' => array( 'background', 'backgroundRepeat' ), ), - 'backgroundSize' => array( + 'backgroundSize' => array( 'property_keys' => array( 'default' => 'background-size', ), 'path' => array( 'background', 'backgroundSize' ), ), + 'backgroundAttachment' => array( + 'property_keys' => array( + 'default' => 'background-attachment', + ), + 'path' => array( 'background', 'backgroundAttachment' ), + ), ), 'color' => array( 'text' => array( diff --git a/src/wp-includes/update.php b/src/wp-includes/update.php index d521913bb93e4..c623b248b9308 100644 --- a/src/wp-includes/update.php +++ b/src/wp-includes/update.php @@ -31,22 +31,20 @@ function wp_version_check( $extra_stats = array(), $force_check = false ) { return; } - // Include an unmodified $wp_version. - require ABSPATH . WPINC . '/version.php'; $php_version = PHP_VERSION; $current = get_site_transient( 'update_core' ); $translations = wp_get_installed_translations( 'core' ); // Invalidate the transient when $wp_version changes. - if ( is_object( $current ) && $wp_version !== $current->version_checked ) { + if ( is_object( $current ) && wp_get_wp_version() !== $current->version_checked ) { $current = false; } if ( ! is_object( $current ) ) { $current = new stdClass(); $current->updates = array(); - $current->version_checked = $wp_version; + $current->version_checked = wp_get_wp_version(); } if ( ! empty( $extra_stats ) ) { @@ -95,7 +93,7 @@ function wp_version_check( $extra_stats = array(), $force_check = false ) { $extensions = get_loaded_extensions(); sort( $extensions, SORT_STRING | SORT_FLAG_CASE ); $query = array( - 'version' => $wp_version, + 'version' => wp_get_wp_version(), 'php' => $php_version, 'locale' => $locale, 'mysql' => $mysql_version, @@ -191,7 +189,7 @@ function wp_version_check( $extra_stats = array(), $force_check = false ) { $options = array( 'timeout' => $doing_cron ? 30 : 3, - 'user-agent' => 'WordPress/' . $wp_version . '; ' . home_url( '/' ), + 'user-agent' => 'WordPress/' . wp_get_wp_version() . '; ' . home_url( '/' ), 'headers' => array( 'wp_install' => $wp_install, 'wp_blog' => home_url( '/' ), @@ -266,7 +264,7 @@ function wp_version_check( $extra_stats = array(), $force_check = false ) { $updates = new stdClass(); $updates->updates = $offers; $updates->last_checked = time(); - $updates->version_checked = $wp_version; + $updates->version_checked = wp_get_wp_version(); if ( isset( $body['translations'] ) ) { $updates->translations = $body['translations']; @@ -315,9 +313,6 @@ function wp_update_plugins( $extra_stats = array() ) { return; } - // Include an unmodified $wp_version. - require ABSPATH . WPINC . '/version.php'; - // If running blog-side, bail unless we've not checked in the last 12 hours. if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; @@ -423,7 +418,7 @@ function wp_update_plugins( $extra_stats = array() ) { 'locale' => wp_json_encode( $locales ), 'all' => wp_json_encode( true ), ), - 'user-agent' => 'WordPress/' . $wp_version . '; ' . home_url( '/' ), + 'user-agent' => 'WordPress/' . wp_get_wp_version() . '; ' . home_url( '/' ), ); if ( $extra_stats ) { @@ -590,9 +585,6 @@ function wp_update_themes( $extra_stats = array() ) { return; } - // Include an unmodified $wp_version. - require ABSPATH . WPINC . '/version.php'; - $installed_themes = wp_get_themes(); $translations = wp_get_installed_translations( 'themes' ); @@ -705,7 +697,7 @@ function wp_update_themes( $extra_stats = array() ) { 'translations' => wp_json_encode( $translations ), 'locale' => wp_json_encode( $locales ), ), - 'user-agent' => 'WordPress/' . $wp_version . '; ' . home_url( '/' ), + 'user-agent' => 'WordPress/' . wp_get_wp_version() . '; ' . home_url( '/' ), ); if ( $extra_stats ) { @@ -989,14 +981,11 @@ function wp_get_update_data() { * @global string $wp_version The WordPress version string. */ function _maybe_update_core() { - // Include an unmodified $wp_version. - require ABSPATH . WPINC . '/version.php'; - $current = get_site_transient( 'update_core' ); if ( isset( $current->last_checked, $current->version_checked ) && 12 * HOUR_IN_SECONDS > ( time() - $current->last_checked ) - && $current->version_checked === $wp_version + && wp_get_wp_version() === $current->version_checked ) { return; } @@ -1110,8 +1099,6 @@ function wp_delete_all_temp_backups() { * @access private * * @global WP_Filesystem_Base $wp_filesystem WordPress filesystem subclass. - * - * @return void|WP_Error Void on success, or a WP_Error object on failure. */ function _wp_delete_all_temp_backups() { global $wp_filesystem; @@ -1125,15 +1112,17 @@ function _wp_delete_all_temp_backups() { ob_end_clean(); if ( false === $credentials || ! WP_Filesystem( $credentials ) ) { - return new WP_Error( 'fs_unavailable', __( 'Could not access filesystem.' ) ); + wp_trigger_error( __FUNCTION__, __( 'Could not access filesystem.' ) ); + return; } if ( ! $wp_filesystem->wp_content_dir() ) { - return new WP_Error( - 'fs_no_content_dir', + wp_trigger_error( + __FUNCTION__, /* translators: %s: Directory name. */ sprintf( __( 'Unable to locate WordPress content directory (%s).' ), 'wp-content' ) ); + return; } $temp_backup_dir = $wp_filesystem->wp_content_dir() . 'upgrade-temp-backup/'; diff --git a/src/wp-settings.php b/src/wp-settings.php index 369493f3fcec0..d3dfe5776ee88 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -252,6 +252,7 @@ require ABSPATH . WPINC . '/html-api/html5-named-character-references.php'; require ABSPATH . WPINC . '/html-api/class-wp-html-attribute-token.php'; require ABSPATH . WPINC . '/html-api/class-wp-html-span.php'; +require ABSPATH . WPINC . '/html-api/class-wp-html-doctype-info.php'; require ABSPATH . WPINC . '/html-api/class-wp-html-text-replacement.php'; require ABSPATH . WPINC . '/html-api/class-wp-html-decoder.php'; require ABSPATH . WPINC . '/html-api/class-wp-html-tag-processor.php'; diff --git a/tests/phpunit/data/blocks/notice/variations.php b/tests/phpunit/data/blocks/notice/variations.php new file mode 100644 index 0000000000000..bed66d9544176 --- /dev/null +++ b/tests/phpunit/data/blocks/notice/variations.php @@ -0,0 +1,10 @@ + 'warning', + 'title' => 'warning', + 'description' => 'Shows warning.', + 'keywords' => array( 'warning' ), + ), +); diff --git a/tests/phpunit/data/images/test-image.heic b/tests/phpunit/data/images/test-image.heic new file mode 100644 index 0000000000000..cb104e1d53e42 Binary files /dev/null and b/tests/phpunit/data/images/test-image.heic differ diff --git a/tests/phpunit/tests/admin/includesPlugin.php b/tests/phpunit/tests/admin/includesPlugin.php index 0d29b81d028e0..e95697810d43f 100644 --- a/tests/phpunit/tests/admin/includesPlugin.php +++ b/tests/phpunit/tests/admin/includesPlugin.php @@ -96,7 +96,7 @@ public function test_submenu_position( $position, $expected_position ) { wp_set_current_user( $current_user ); // Clean up the temporary user. - wp_delete_user( $admin_user ); + self::delete_user( $admin_user ); // Verify the menu was inserted at the expected position. $this->assertSame( 'custom-position', $submenu[ $parent ][ $expected_position ][2] ); @@ -204,7 +204,7 @@ public function test_submenu_helpers_position( $position, $expected_position ) { } // Clean up the temporary user. - wp_delete_user( $admin_user ); + self::delete_user( $admin_user ); foreach ( $actual_positions as $test => $actual_position ) { // Verify the menu was inserted at the expected position. @@ -295,7 +295,7 @@ public function test_position_when_parent_slug_child_slug_are_the_same() { // Clean up the temporary user. wp_set_current_user( $current_user ); - wp_delete_user( $admin_user ); + self::delete_user( $admin_user ); // Verify the menu was inserted at the expected position. $this->assertSame( 'main_slug', $submenu['main_slug'][0][2] ); @@ -326,7 +326,7 @@ public function test_passing_string_as_position_fires_doing_it_wrong_submenu() { // Clean up the temporary user. wp_set_current_user( $current_user ); - wp_delete_user( $admin_user ); + self::delete_user( $admin_user ); // Verify the menu was inserted at the expected position. $this->assertSame( 'submenu_page_1', $submenu['main_slug'][1][2] ); @@ -355,7 +355,7 @@ public function test_passing_float_as_position_does_not_override_int() { // Clean up the temporary user. wp_set_current_user( $current_user ); - wp_delete_user( $admin_user ); + self::delete_user( $admin_user ); // Verify the menus were inserted. $this->assertSame( 'main_slug_1', $menu[1][2] ); diff --git a/tests/phpunit/tests/admin/includesSchema.php b/tests/phpunit/tests/admin/includesSchema.php index 77b3e06a53ed6..9f2d19f0c16aa 100644 --- a/tests/phpunit/tests/admin/includesSchema.php +++ b/tests/phpunit/tests/admin/includesSchema.php @@ -123,7 +123,7 @@ public function data_populate_options() { 'rss_use_excerpt' => '0', 'mailserver_url' => 'mail.example.com', 'mailserver_login' => 'login@example.com', - 'mailserver_pass' => 'password', + 'mailserver_pass' => '', ), ), array( @@ -137,7 +137,7 @@ public function data_populate_options() { 'rss_use_excerpt' => '1', 'mailserver_url' => 'mail.example.com', 'mailserver_login' => 'login@example.com', - 'mailserver_pass' => 'password', + 'mailserver_pass' => '', ), ), array( @@ -151,7 +151,7 @@ public function data_populate_options() { 'rss_use_excerpt' => '0', 'mailserver_url' => 'mail.example.com', 'mailserver_login' => 'login@example.com', - 'mailserver_pass' => 'password', + 'mailserver_pass' => '', ), ), array( diff --git a/tests/phpunit/tests/avatar.php b/tests/phpunit/tests/avatar.php index 32ff9ac648f8d..97f24841e372b 100644 --- a/tests/phpunit/tests/avatar.php +++ b/tests/phpunit/tests/avatar.php @@ -11,7 +11,7 @@ class Tests_Avatar extends WP_UnitTestCase { */ public function test_get_avatar_url_gravatar_url() { $url = get_avatar_url( 1 ); - $this->assertSame( preg_match( '|^http?://[0-9]+.gravatar.com/avatar/[0-9a-f]{32}\?|', $url ), 1 ); + $this->assertSame( preg_match( '|^https?://secure.gravatar.com/avatar/[0-9a-f]{32}\?|', $url ), 1 ); } /** @@ -56,19 +56,29 @@ public function test_get_avatar_url_rating() { } /** + * Ensures the get_avatar_url always returns an HTTPS scheme for gravatars. + * * @ticket 21195 + * @ticket 37454 + * + * @covers ::get_avatar_url */ public function test_get_avatar_url_scheme() { $url = get_avatar_url( 1 ); - $this->assertSame( preg_match( '|^http://|', $url ), 1 ); + $this->assertSame( preg_match( '|^https://|', $url ), 1, 'Avatars should default to the HTTPS scheme' ); $args = array( 'scheme' => 'https' ); $url = get_avatar_url( 1, $args ); - $this->assertSame( preg_match( '|^https://|', $url ), 1 ); + $this->assertSame( preg_match( '|^https://|', $url ), 1, 'Requesting the HTTPS scheme should be respected' ); + + $args = array( 'scheme' => 'http' ); + $url = get_avatar_url( 1, $args ); + $this->assertSame( preg_match( '|^https://|', $url ), 1, 'Requesting the HTTP scheme should return an HTTPS URL to avoid redirects' ); $args = array( 'scheme' => 'lolcat' ); $url = get_avatar_url( 1, $args ); - $this->assertSame( preg_match( '|^lolcat://|', $url ), 0 ); + $this->assertSame( preg_match( '|^lolcat://|', $url ), 0, 'Unrecognized schemes should be ignored' ); + $this->assertSame( preg_match( '|^https://|', $url ), 1, 'Unrecognized schemes should return an HTTPS URL' ); } /** @@ -257,7 +267,7 @@ public function test_get_avatar_data_should_return_gravatar_url_when_input_avata $actual_data = get_avatar_data( $comment ); $this->assertTrue( is_avatar_comment_type( $comment_type ) ); - $this->assertMatchesRegularExpression( '|^http?://[0-9]+.gravatar.com/avatar/[0-9a-f]{32}\?|', $actual_data['url'] ); + $this->assertMatchesRegularExpression( '|^https?://secure.gravatar.com/avatar/[0-9a-f]{32}\?|', $actual_data['url'] ); } /** diff --git a/tests/phpunit/tests/block-bindings/wpBlockBindingsRegistry.php b/tests/phpunit/tests/block-bindings/wpBlockBindingsRegistry.php index fc5b91a9d702a..e4aa415e9af96 100644 --- a/tests/phpunit/tests/block-bindings/wpBlockBindingsRegistry.php +++ b/tests/phpunit/tests/block-bindings/wpBlockBindingsRegistry.php @@ -380,6 +380,8 @@ public function test_merging_uses_context_from_multiple_sources() { ); $new_uses_context = $block_registry->get_registered( 'core/paragraph' )->uses_context; + unregister_block_bindings_source( 'test/source-one' ); + unregister_block_bindings_source( 'test/source-two' ); // Checks that the resulting `uses_context` contains the values from both sources. $this->assertContains( 'commonContext', $new_uses_context ); $this->assertContains( 'sourceOneContext', $new_uses_context ); diff --git a/tests/phpunit/tests/block-supports/wpRenderBackgroundSupport.php b/tests/phpunit/tests/block-supports/wpRenderBackgroundSupport.php index 3fa7da28908a3..4de405eec883d 100644 --- a/tests/phpunit/tests/block-supports/wpRenderBackgroundSupport.php +++ b/tests/phpunit/tests/block-supports/wpRenderBackgroundSupport.php @@ -69,6 +69,8 @@ public function filter_set_theme_root() { * @ticket 59357 * @ticket 60175 * @ticket 61123 + * @ticket 61720 + * @ticket 61858 * * @covers ::wp_render_background_support * @@ -139,20 +141,21 @@ public function data_background_block_support() { 'expected_wrapper' => '
Content
', 'wrapper' => '
Content
', ), - 'background image style with contain, position, and repeat is applied' => array( + 'background image style with contain, position, attachment, and repeat is applied' => array( 'theme_name' => 'block-theme-child-with-fluid-typography', 'block_name' => 'test/background-rules-are-output', 'background_settings' => array( 'backgroundImage' => true, ), 'background_style' => array( - 'backgroundImage' => array( + 'backgroundImage' => array( 'url' => 'https://example.com/image.jpg', ), - 'backgroundRepeat' => 'no-repeat', - 'backgroundSize' => 'contain', + 'backgroundRepeat' => 'no-repeat', + 'backgroundSize' => 'contain', + 'backgroundAttachment' => 'fixed', ), - 'expected_wrapper' => '
Content
', + 'expected_wrapper' => '
Content
', 'wrapper' => '
Content
', ), 'background image style is appended if a style attribute already exists' => array( diff --git a/tests/phpunit/tests/blocks/editor.php b/tests/phpunit/tests/blocks/editor.php index 0682839605da9..b10dbb93dae01 100644 --- a/tests/phpunit/tests/blocks/editor.php +++ b/tests/phpunit/tests/blocks/editor.php @@ -720,4 +720,38 @@ public function data_block_editor_rest_api_preload_adds_missing_leading_slash() ), ); } + + /** + * @ticket 61641 + */ + public function test_get_block_editor_settings_block_bindings_sources() { + $block_editor_context = new WP_Block_Editor_Context(); + register_block_bindings_source( + 'test/source-one', + array( + 'label' => 'Source One', + 'get_value_callback' => function () {}, + 'uses_context' => array( 'postId' ), + ) + ); + register_block_bindings_source( + 'test/source-two', + array( + 'label' => 'Source Two', + 'get_value_callback' => function () {}, + ) + ); + $settings = get_block_editor_settings( array(), $block_editor_context ); + $exposed_sources = $settings['blockBindingsSources']; + unregister_block_bindings_source( 'test/source-one' ); + unregister_block_bindings_source( 'test/source-two' ); + // It is expected to have 4 sources: the 2 registered sources in the test, and the 2 core sources. + $this->assertCount( 4, $exposed_sources ); + $source_one = $exposed_sources['test/source-one']; + $this->assertSame( 'Source One', $source_one['label'] ); + $this->assertSameSets( array( 'postId' ), $source_one['usesContext'] ); + $source_two = $exposed_sources['test/source-two']; + $this->assertSame( 'Source Two', $source_two['label'] ); + $this->assertArrayNotHasKey( 'usesContext', $source_two ); + } } diff --git a/tests/phpunit/tests/blocks/register.php b/tests/phpunit/tests/blocks/register.php index 1dbc688bb16cf..7e0c391e1f226 100644 --- a/tests/phpunit/tests/blocks/register.php +++ b/tests/phpunit/tests/blocks/register.php @@ -957,6 +957,37 @@ public function data_register_block_registers_with_args_override_returns_false_w ); } + /** + * Tests registering a block with variations from a PHP file. + * + * @ticket 61280 + * + * @covers ::register_block_type_from_metadata + */ + public function test_register_block_type_from_metadata_with_variations_php_file() { + $filter_metadata_registration = static function ( $metadata ) { + $metadata['variations'] = 'file:./variations.php'; + return $metadata; + }; + + add_filter( 'block_type_metadata', $filter_metadata_registration, 10, 2 ); + $result = register_block_type_from_metadata( + DIR_TESTDATA . '/blocks/notice' + ); + remove_filter( 'block_type_metadata', $filter_metadata_registration ); + + $this->assertInstanceOf( 'WP_Block_Type', $result, 'The block was not registered' ); + + $this->assertIsCallable( $result->variation_callback, 'The variation callback hasn\'t been set' ); + $expected_variations = require DIR_TESTDATA . '/blocks/notice/variations.php'; + $this->assertSame( + $expected_variations, + call_user_func( $result->variation_callback ), + 'The variation callback hasn\'t been set correctly' + ); + $this->assertSame( $expected_variations, $result->variations, 'The block variations are incorrect' ); + } + /** * Tests that the function returns the registered block when the `block.json` * is found in the fixtures directory. diff --git a/tests/phpunit/tests/comment/getCommentAuthor.php b/tests/phpunit/tests/comment/getCommentAuthor.php index 93570b51d16dd..61e8c213b54b9 100644 --- a/tests/phpunit/tests/comment/getCommentAuthor.php +++ b/tests/phpunit/tests/comment/getCommentAuthor.php @@ -22,7 +22,7 @@ public static function set_up_before_class() { public function get_comment_author_filter( $comment_author, $comment_id, $comment ) { $this->assertSame( $comment_id, self::$comment->comment_ID, 'Comment IDs do not match.' ); - $this->assertTrue( is_string( $comment_id ), '$comment_id parameter is not a string.' ); + $this->assertIsString( $comment_id, '$comment_id parameter is not a string.' ); return $comment_author; } @@ -41,7 +41,7 @@ public function test_comment_author_passes_correct_comment_id_for_int() { public function get_comment_author_filter_non_existent_id( $comment_author, $comment_id, $comment ) { $this->assertSame( $comment_id, (string) self::$non_existent_comment_id, 'Comment IDs do not match.' ); - $this->assertTrue( is_string( $comment_id ), '$comment_id parameter is not a string.' ); + $this->assertIsString( $comment_id, '$comment_id parameter is not a string.' ); return $comment_author; } @@ -71,7 +71,9 @@ public function test_should_return_author_when_given_object_without_comment_id( $user = self::factory()->user->create_and_get( $user_data ); $comment_props->user_id = $user->ID; } + $comment = new WP_Comment( $comment_props ); + $this->assertSame( $expected, get_comment_author( $comment ) ); } diff --git a/tests/phpunit/tests/comment/getCommentAuthorLink.php b/tests/phpunit/tests/comment/getCommentAuthorLink.php new file mode 100644 index 0000000000000..c3d1033d837b5 --- /dev/null +++ b/tests/phpunit/tests/comment/getCommentAuthorLink.php @@ -0,0 +1,116 @@ +comment->create_and_get( + array( + 'comment_post_ID' => 0, + ) + ); + } + + public function get_comment_author_link_filter( $comment_author_link, $comment_author, $comment_id ) { + $this->assertSame( $comment_id, self::$comment->comment_ID, 'Comment IDs do not match.' ); + $this->assertIsString( $comment_id, '$comment_id parameter is not a string.' ); + + return $comment_author_link; + } + + public function test_comment_author_link_passes_correct_comment_id_for_comment_object() { + add_filter( 'get_comment_author_link', array( $this, 'get_comment_author_link_filter' ), 99, 3 ); + + get_comment_author_link( self::$comment ); + } + + public function test_comment_author_link_passes_correct_comment_id_for_int() { + add_filter( 'get_comment_author_link', array( $this, 'get_comment_author_link_filter' ), 99, 3 ); + + get_comment_author_link( (int) self::$comment->comment_ID ); + } + + public function get_comment_author_link_filter_non_existent_id( $comment_author_link, $comment_author, $comment_id ) { + $this->assertSame( $comment_id, (string) self::$non_existent_comment_id, 'Comment IDs do not match.' ); + $this->assertIsString( $comment_id, '$comment_id parameter is not a string.' ); + + return $comment_author_link; + } + + /** + * @ticket 60475 + */ + public function test_comment_author_link_passes_correct_comment_id_for_non_existent_comment() { + add_filter( 'get_comment_author_link', array( $this, 'get_comment_author_link_filter_non_existent_id' ), 99, 3 ); + + self::$non_existent_comment_id = self::$comment->comment_ID + 1; + + get_comment_author_link( self::$non_existent_comment_id ); // Non-existent comment ID. + } + + /** + * @ticket 61681 + * @ticket 61715 + * + * @dataProvider data_should_return_author_when_given_object_without_comment_id + * + * @param stdClass $comment_props Comment properties test data. + * @param string $expected The expected result. + * @param array $user_data Optional. User data for creating an author. Default empty array. + */ + public function test_should_return_author_when_given_object_without_comment_id( $comment_props, $expected, $user_data = array() ) { + if ( ! empty( $comment_props->user_id ) ) { + $user = self::factory()->user->create_and_get( $user_data ); + $comment_props->user_id = $user->ID; + } + + $comment = new WP_Comment( $comment_props ); + + $this->assertSame( $expected, get_comment_author_link( $comment ) ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_should_return_author_when_given_object_without_comment_id() { + return array( + 'with no author' => array( + 'comment_props' => new stdClass(), + 'expected' => 'Anonymous', + ), + 'with author name' => array( + 'comment_props' => (object) array( + 'comment_author' => 'tester1', + ), + 'expected' => 'tester1', + ), + 'with author name, empty ID' => array( + 'comment_props' => (object) array( + 'comment_author' => 'tester2', + 'comment_ID' => '', + ), + 'expected' => 'tester2', + ), + 'with author ID' => array( + 'comment_props' => (object) array( + 'user_id' => 1, // Populates in the test with an actual user ID. + ), + 'expected' => 'Tester3', + 'user_data' => array( + 'display_name' => 'Tester3', + ), + ), + ); + } +} diff --git a/tests/phpunit/tests/date/currentTime.php b/tests/phpunit/tests/date/currentTime.php index b308f4918ff5b..a41ea258dbe37 100644 --- a/tests/phpunit/tests/date/currentTime.php +++ b/tests/phpunit/tests/date/currentTime.php @@ -91,9 +91,14 @@ public function test_should_work_with_changed_timezone() { /** * @ticket 40653 + * @ticket 57998 + * + * @dataProvider data_timezones + * + * @param string $timezone The timezone to test. */ - public function test_should_return_wp_timestamp() { - update_option( 'timezone_string', 'Europe/Helsinki' ); + public function test_should_return_wp_timestamp( $timezone ) { + update_option( 'timezone_string', $timezone ); $timestamp = time(); $datetime = new DateTime( '@' . $timestamp ); @@ -101,24 +106,29 @@ public function test_should_return_wp_timestamp() { $wp_timestamp = $timestamp + $datetime->getOffset(); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.RequestedUTC - $this->assertEqualsWithDelta( $timestamp, current_time( 'timestamp', true ), 2, 'The dates should be equal' ); + $this->assertEqualsWithDelta( $timestamp, current_time( 'timestamp', true ), 2, 'When passing "timestamp", the date should be equal to time()' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.RequestedUTC - $this->assertEqualsWithDelta( $timestamp, current_time( 'U', true ), 2, 'The dates should be equal' ); + $this->assertEqualsWithDelta( $timestamp, current_time( 'U', true ), 2, 'When passing "U", the date should be equal to time()' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested - $this->assertEqualsWithDelta( $wp_timestamp, current_time( 'timestamp' ), 2, 'The dates should be equal' ); + $this->assertEqualsWithDelta( $wp_timestamp, current_time( 'timestamp' ), 2, 'When passing "timestamp", the date should be equal to calculated timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested - $this->assertEqualsWithDelta( $wp_timestamp, current_time( 'U' ), 2, 'The dates should be equal' ); + $this->assertEqualsWithDelta( $wp_timestamp, current_time( 'U' ), 2, 'When passing "U", the date should be equal to calculated timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested - $this->assertIsInt( current_time( 'timestamp' ) ); + $this->assertIsInt( current_time( 'timestamp' ), 'The returned timestamp should be an integer' ); } /** * @ticket 40653 + * @ticket 57998 + * + * @dataProvider data_timezones + * + * @param string $timezone The timezone to test. */ - public function test_should_return_correct_local_time() { - update_option( 'timezone_string', 'Europe/Helsinki' ); + public function test_should_return_correct_local_time( $timezone ) { + update_option( 'timezone_string', $timezone ); $timestamp = time(); $datetime_local = new DateTime( '@' . $timestamp ); @@ -127,7 +137,20 @@ public function test_should_return_correct_local_time() { $datetime_utc->setTimezone( new DateTimeZone( 'UTC' ) ); $this->assertEqualsWithDelta( strtotime( $datetime_local->format( DATE_W3C ) ), strtotime( current_time( DATE_W3C ) ), 2, 'The dates should be equal' ); - $this->assertEqualsWithDelta( strtotime( $datetime_utc->format( DATE_W3C ) ), strtotime( current_time( DATE_W3C, true ) ), 2, 'The dates should be equal' ); + $this->assertEqualsWithDelta( strtotime( $datetime_utc->format( DATE_W3C ) ), strtotime( current_time( DATE_W3C, true ) ), 2, 'When passing "timestamp", the dates should be equal' ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_timezones() { + return array( + array( 'Europe/Helsinki' ), + array( 'Indian/Antananarivo' ), + array( 'Australia/Adelaide' ), + ); } /** @@ -158,4 +181,88 @@ public function test_should_work_with_deprecated_timezone() { $this->assertSame( gmdate( $format ), $current_time_gmt, 'The dates should be equal [3]' ); $this->assertSame( $datetime->format( $format ), $current_time, 'The dates should be equal [4]' ); } + + /** + * Ensures an empty offset does not cause a type error. + * + * @ticket 57998 + */ + public function test_empty_offset_does_not_cause_a_type_error() { + // Ensure `wp_timezone_override_offset()` doesn't override offset. + update_option( 'timezone_string', '' ); + update_option( 'gmt_offset', '' ); + + $expected = time(); + + // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested + $this->assertEqualsWithDelta( $expected, current_time( 'timestamp' ), 2, 'The timestamps should be equal' ); + } + + /** + * Ensures the offset applied in current_time() is correct. + * + * @ticket 57998 + * + * @dataProvider data_partial_hour_timezones_with_timestamp + * + * @param float $partial_hour Partial hour GMT offset to test. + */ + public function test_partial_hour_timezones_with_timestamp( $partial_hour ) { + // Ensure `wp_timezone_override_offset()` doesn't override offset. + update_option( 'timezone_string', '' ); + update_option( 'gmt_offset', $partial_hour ); + + $expected = time() + (int) ( $partial_hour * HOUR_IN_SECONDS ); + + // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested + $this->assertEqualsWithDelta( $expected, current_time( 'timestamp' ), 2, 'The timestamps should be equal' ); + } + + /** + * Tests the tests. + * + * Ensures the offsets match the stated timezones in the data provider. + * + * @ticket 57998 + * + * @dataProvider data_partial_hour_timezones_with_timestamp + * + * @param float $partial_hour Partial hour GMT offset to test. + * @param string $timezone_string Timezone string to test. + */ + public function test_partial_hour_timezones_match_datetime_offset( $partial_hour, $timezone_string ) { + $timezone = new DateTimeZone( $timezone_string ); + $datetime = new DateTime( 'now', $timezone ); + $dst_offset = (int) $datetime->format( 'I' ); + + // Timezone offset in hours. + $offset = $timezone->getOffset( $datetime ) / HOUR_IN_SECONDS; + + /* + * Adjust for daylight saving time. + * + * DST adds an hour to the offset, the partial hour offset + * is set the the standard time offset so this removes the + * DST offset to avoid false negatives. + */ + $offset -= $dst_offset; + + $this->assertSame( $partial_hour, $offset, 'The offset should match to timezone.' ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_partial_hour_timezones_with_timestamp() { + return array( + '+12:45' => array( 12.75, 'Pacific/Chatham' ), // New Zealand, Chatham Islands. + '+9:30' => array( 9.5, 'Australia/Darwin' ), // Australian Northern Territory. + '+05:30' => array( 5.5, 'Asia/Kolkata' ), // India and Sri Lanka. + '+05:45' => array( 5.75, 'Asia/Kathmandu' ), // Nepal. + '-03:30' => array( -3.50, 'Canada/Newfoundland' ), // Canada, Newfoundland. + '-09:30' => array( -9.50, 'Pacific/Marquesas' ), // French Polynesia, Marquesas Islands. + ); + } } diff --git a/tests/phpunit/tests/dependencies.php b/tests/phpunit/tests/dependencies.php index 39a2860fc365e..dfcd253239583 100644 --- a/tests/phpunit/tests/dependencies.php +++ b/tests/phpunit/tests/dependencies.php @@ -148,4 +148,94 @@ public function test_enqueue_before_register() { $this->assertContains( 'one', $dep->queue ); } + + /** + * Data provider for test_get_etag. + * + * @return array[] + */ + public function data_provider_get_etag() { + return array( + 'should accept one dependency' => array( + 'load' => array( + 'abcd' => '1.0.2', + ), + 'hash_source_string' => 'WP:6.7;abcd:1.0.2;', + 'expected' => 'W/"8145d7e3c41d5a9cc2bccba4afa861fc"', + ), + 'should accept empty array of dependencies' => array( + 'load' => array(), + 'hash_source_string' => 'WP:6.7;', + 'expected' => 'W/"7ee896c19250a3d174f11469a4ad0b1e"', + ), + ); + } + + /** + * Tests get_etag method for WP_Scripts. + * + * @ticket 58433 + * @ticket 61485 + * + * @covers WP_Dependencies::get_etag + * + * @dataProvider data_provider_get_etag + * + * @param array $load List of scripts to load. + * @param string $hash_source_string Hash source string. + * @param string $expected Expected etag. + */ + public function test_get_etag_scripts( $load, $hash_source_string, $expected ) { + global $wp_version; + // Modify global to avoid tests needing to change with each new version of WordPress. + $original_wp_version = $wp_version; + $wp_version = '6.7'; + $instance = wp_scripts(); + + foreach ( $load as $handle => $ver ) { + // The src should not be empty. + wp_enqueue_script( $handle, 'https://example.org', array(), $ver ); + } + + $result = $instance->get_etag( array_keys( $load ) ); + + // Restore global prior to making assertions. + $wp_version = $original_wp_version; + + $this->assertSame( $expected, $result, "Expected MD hash: $expected for $hash_source_string, but got: $result." ); + } + + /** + * Tests get_etag method for WP_Styles. + * + * @ticket 58433 + * @ticket 61485 + * + * @covers WP_Dependencies::get_etag + * + * @dataProvider data_provider_get_etag + * + * @param array $load List of styles to load. + * @param string $hash_source_string Hash source string. + * @param string $expected Expected etag. + */ + public function test_get_etag_styles( $load, $hash_source_string, $expected ) { + global $wp_version; + // Modify global to avoid tests needing to change with each new version of WordPress. + $original_wp_version = $wp_version; + $wp_version = '6.7'; + $instance = wp_scripts(); + + foreach ( $load as $handle => $ver ) { + // The src should not be empty. + wp_enqueue_style( $handle, 'https://example.cdn', array(), $ver ); + } + + $result = $instance->get_etag( array_keys( $load ) ); + + // Restore global prior to making assertions. + $wp_version = $original_wp_version; + + $this->assertSame( $expected, $result, "Expected MD hash: $expected for $hash_source_string, but got: $result." ); + } } diff --git a/tests/phpunit/tests/dependencies/styles.php b/tests/phpunit/tests/dependencies/styles.php index 8746c095338c2..9cb6283c488d5 100644 --- a/tests/phpunit/tests/dependencies/styles.php +++ b/tests/phpunit/tests/dependencies/styles.php @@ -228,6 +228,10 @@ public function data_normalize_relative_css_links() { 'css' => 'p {background-image: url(\'../image1.jpg\');}', 'expected' => 'p {background-image: url(\'/wp-content/themes/test/../image1.jpg\');}', ), + 'URLs with absolute path, shouldn\'t change' => array( + 'css' => 'p {background:url( "/image0.svg" );}', + 'expected' => 'p {background:url( "/image0.svg" );}', + ), 'External URLs, shouldn\'t change' => array( 'css' => 'p {background-image: url(\'http://foo.com/image2.png\');}', 'expected' => 'p {background-image: url(\'http://foo.com/image2.png\');}', diff --git a/tests/phpunit/tests/functions.php b/tests/phpunit/tests/functions.php index 5572c215a34bb..9d3260abd0957 100644 --- a/tests/phpunit/tests/functions.php +++ b/tests/phpunit/tests/functions.php @@ -1355,6 +1355,11 @@ public function data_wp_get_image_mime() { DIR_TESTDATA . '/images/avif-transparent.avif', 'image/avif', ), + // HEIC. + array( + DIR_TESTDATA . '/images/test-image.heic', + 'image/heic', + ), ); return $data; @@ -1384,7 +1389,7 @@ public function test_wp_getimagesize( $file, $expected ) { } /** - * Data profider for test_wp_getimagesize(). + * Data provider for test_wp_getimagesize(). */ public function data_wp_getimagesize() { $data = array( @@ -1541,6 +1546,35 @@ public function data_wp_getimagesize() { return $data; } + /** + * Tests that wp_getimagesize() correctly handles HEIC image files. + * + * @ticket 53645 + */ + public function test_wp_getimagesize_heic() { + if ( ! is_callable( 'exif_imagetype' ) && ! function_exists( 'getimagesize' ) ) { + $this->markTestSkipped( 'The exif PHP extension is not loaded.' ); + } + + $file = DIR_TESTDATA . '/images/test-image.heic'; + + $editor = wp_get_image_editor( $file ); + if ( is_wp_error( $editor ) || ! $editor->supports_mime_type( 'image/heic' ) ) { + $this->markTestSkipped( 'No HEIC support in the editor engine on this system.' ); + } + + $expected = array( + 50, + 50, + IMAGETYPE_HEIC, + 'width="50" height="50"', + 'mime' => 'image/heic', + ); + $result = wp_getimagesize( $file ); + $this->assertSame( $expected, $result ); + } + + /** * @ticket 39550 * @dataProvider data_wp_check_filetype_and_ext diff --git a/tests/phpunit/tests/functions/isWpVersionCompatible.php b/tests/phpunit/tests/functions/isWpVersionCompatible.php index 599f3b29f0005..fba8af3e14fdd 100644 --- a/tests/phpunit/tests/functions/isWpVersionCompatible.php +++ b/tests/phpunit/tests/functions/isWpVersionCompatible.php @@ -8,12 +8,45 @@ * @covers ::is_wp_version_compatible */ class Tests_Functions_IsWpVersionCompatible extends WP_UnitTestCase { + /** + * The current WordPress version. + * + * @var string + */ + private static $wp_version; + + /** + * Sets the test WordPress version property and global before any tests run. + */ + public static function set_up_before_class() { + parent::set_up_before_class(); + self::$wp_version = wp_get_wp_version(); + $GLOBALS['_wp_tests_wp_version'] = self::$wp_version; + } + + /** + * Resets the test WordPress version global after each test runs. + */ + public function tear_down() { + $GLOBALS['_wp_tests_wp_version'] = self::$wp_version; + parent::tear_down(); + } + + /** + * Unsets the test WordPress version global after all tests run. + */ + public static function tear_down_after_class() { + unset( $GLOBALS['_wp_tests_wp_version'] ); + parent::tear_down_after_class(); + } + /** * Tests is_wp_version_compatible(). * * @dataProvider data_is_wp_version_compatible * * @ticket 54257 + * @ticket 61781 * * @param mixed $required The minimum required WordPress version. * @param bool $expected The expected result. @@ -28,8 +61,7 @@ public function test_is_wp_version_compatible( $required, $expected ) { * @return array[] */ public function data_is_wp_version_compatible() { - global $wp_version; - + $wp_version = wp_get_wp_version(); $version_parts = explode( '.', $wp_version ); $lower_version = $version_parts; $higher_version = $version_parts; @@ -104,22 +136,15 @@ public function data_is_wp_version_compatible() { * @dataProvider data_is_wp_version_compatible_should_gracefully_handle_trailing_point_zero_version_numbers * * @ticket 59448 + * @ticket 61781 * * @param mixed $required The minimum required WordPress version. * @param string $wp The value for the $wp_version global variable. * @param bool $expected The expected result. */ public function test_is_wp_version_compatible_should_gracefully_handle_trailing_point_zero_version_numbers( $required, $wp, $expected ) { - global $wp_version; - $original_version = $wp_version; - $wp_version = $wp; - - $actual = is_wp_version_compatible( $required ); - - // Reset the version before the assertion in case of failure. - $wp_version = $original_version; - - $this->assertSame( $expected, $actual, 'The expected result was not returned.' ); + $GLOBALS['_wp_tests_wp_version'] = $wp; + $this->assertSame( $expected, is_wp_version_compatible( $required ), 'The expected result was not returned.' ); } /** @@ -183,22 +208,15 @@ public function data_is_wp_version_compatible_should_gracefully_handle_trailing_ * @dataProvider data_is_wp_version_compatible_with_development_versions * * @ticket 54257 + * @ticket 61781 * * @param string $required The minimum required WordPress version. * @param string $wp The value for the $wp_version global variable. * @param bool $expected The expected result. */ public function test_is_wp_version_compatible_with_development_versions( $required, $wp, $expected ) { - global $wp_version; - - $original_version = $wp_version; - $wp_version = $wp; - $actual = is_wp_version_compatible( $required ); - - // Reset the version before the assertion in case of failure. - $wp_version = $original_version; - - $this->assertSame( $expected, $actual ); + $GLOBALS['_wp_tests_wp_version'] = $wp; + $this->assertSame( $expected, is_wp_version_compatible( $required ) ); } /** @@ -207,10 +225,8 @@ public function test_is_wp_version_compatible_with_development_versions( $requir * @return array[] */ public function data_is_wp_version_compatible_with_development_versions() { - global $wp_version; - // For consistent results, remove possible suffixes. - list( $version ) = explode( '-', $wp_version ); + list( $version ) = explode( '-', wp_get_wp_version() ); $version_parts = explode( '.', $version ); $lower_version = $version_parts; diff --git a/tests/phpunit/tests/functions/wpGetWpVersion.php b/tests/phpunit/tests/functions/wpGetWpVersion.php new file mode 100644 index 0000000000000..d10d946e3baea --- /dev/null +++ b/tests/phpunit/tests/functions/wpGetWpVersion.php @@ -0,0 +1,34 @@ +assertSame( $GLOBALS['wp_version'], wp_get_wp_version() ); + } + + /** + * Tests that changes to the `$wp_version` global are ignored. + * + * @ticket 61627 + */ + public function test_should_ignore_changes_to_wp_version_global() { + $original_wp_version = $GLOBALS['wp_version']; + $GLOBALS['wp_version'] = 'modified_wp_version'; + $actual = wp_get_wp_version(); + $GLOBALS['wp_version'] = $original_wp_version; + + $this->assertSame( $original_wp_version, $actual ); + } +} diff --git a/tests/phpunit/tests/html-api/wpHtmlDoctypeInfo.php b/tests/phpunit/tests/html-api/wpHtmlDoctypeInfo.php new file mode 100644 index 0000000000000..d8e30d5028a86 --- /dev/null +++ b/tests/phpunit/tests/html-api/wpHtmlDoctypeInfo.php @@ -0,0 +1,118 @@ +assertNotNull( + $doctype, + "Should have parsed the following doctype declaration: {$html}" + ); + + $this->assertSame( + $expected_compat_mode, + $doctype->indicated_compatability_mode, + 'Failed to infer the expected document compatability mode.' + ); + + $this->assertSame( + $expected_name, + $doctype->name, + 'Failed to parse the expected DOCTYPE name.' + ); + + $this->assertSame( + $expected_public_id, + $doctype->public_identifier, + 'Failed to parse the expected DOCTYPE public identifier.' + ); + + $this->assertSame( + $expected_system_id, + $doctype->system_identifier, + 'Failed to parse the expected DOCTYPE system identifier.' + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_parseable_raw_doctypes(): array { + return array( + 'Missing doctype name' => array( '', 'quirks' ), + 'HTML5 doctype' => array( '', 'no-quirks', 'html' ), + 'HTML5 doctype no whitespace before name' => array( '', 'no-quirks', 'html' ), + 'XHTML doctype' => array( '', 'no-quirks', 'html', '-//W3C//DTD HTML 4.01//EN', 'http://www.w3.org/TR/html4/strict.dtd' ), + 'SVG doctype' => array( '', 'quirks', 'svg', '-//W3C//DTD SVG 1.1//EN', 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd' ), + 'MathML doctype' => array( '', 'quirks', 'math', '-//W3C//DTD MathML 2.0//EN', 'http://www.w3.org/Math/DTD/mathml2/mathml2.dtd' ), + 'Doctype with null byte replacement' => array( "", 'quirks', "null-\u{FFFD}", "\u{FFFD}", "\u{FFFD}\u{FFFD}" ), + 'Uppercase doctype' => array( '', 'quirks', 'uppercase' ), + 'Lowercase doctype' => array( '', 'quirks', 'lowercase' ), + 'Doctype with whitespace' => array( "", 'no-quirks', 'html', '', '' ), + 'Doctype trailing characters' => array( "", 'no-quirks', 'html', '', '' ), + 'An ugly no-quirks doctype' => array( "", 'no-quirks', 'html', 'pub-id', 'sysid' ), + 'Missing public ID' => array( '', 'quirks', 'html' ), + 'Missing system ID' => array( '', 'quirks', 'html' ), + 'Missing close quote public ID' => array( "", 'quirks', 'html', 'xyz' ), + 'Missing close quote system ID' => array( "", 'quirks', 'html', null, 'xyz' ), + 'Missing close quote system ID with public' => array( "", 'quirks', 'html', 'abc', 'xyz' ), + 'Bogus characters instead of system/public' => array( '', 'quirks', 'html' ), + 'Bogus characters instead of PUBLIC quote' => array( "", 'quirks', 'html' ), + 'Bogus characters instead of SYSTEM quote ' => array( "", 'quirks', 'html' ), + 'Emoji' => array( '', 'quirks', "\u{1F3F4}\u{E0067}\u{E0062}\u{E0065}\u{E006E}\u{E0067}\u{E007F}", '🔥', '😈' ), + 'Bogus characters instead of SYSTEM quote after public' => array( "", 'quirks', 'html', '' ), + 'Special quirks mode if system unset' => array( '', 'quirks', 'html', '-//W3C//DTD HTML 4.01 Frameset//' ), + 'Special limited-quirks mode if system set' => array( '', 'limited-quirks', 'html', '-//W3C//DTD HTML 4.01 Frameset//', '' ), + ); + } + + /** + * @dataProvider invalid_inputs + * + * @ticket 61576 + */ + public function test_invalid_inputs_return_null( string $html ) { + $this->assertNull( WP_HTML_Doctype_Info::from_doctype_token( $html ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function invalid_inputs(): array { + return array( + 'Empty string' => array( '' ), + 'Other HTML' => array( '
' ), + 'DOCTYPE after HTML' => array( 'x' ), + 'DOCTYPE before HTML' => array( 'x' ), + 'Incomplete DOCTYPE' => array( '"' => array( '">' ), + ); + } +} diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor.php b/tests/phpunit/tests/html-api/wpHtmlProcessor.php index 12f36ca742989..ebc41aef9b5ef 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor.php @@ -155,10 +155,6 @@ public function test_cannot_nest_void_tags( $tag_name ) { $found_tag = $processor->next_tag(); - if ( WP_HTML_Processor::ERROR_UNSUPPORTED === $processor->get_last_error() ) { - $this->markTestSkipped( "Tag {$tag_name} is not supported." ); - } - $this->assertTrue( $found_tag, "Could not find first {$tag_name}." @@ -220,10 +216,6 @@ public function test_expects_closer_expects_no_closer_for_self_contained_tokens( $processor = WP_HTML_Processor::create_fragment( $self_contained_token ); $found_token = $processor->next_token(); - if ( WP_HTML_Processor::ERROR_UNSUPPORTED === $processor->get_last_error() ) { - $this->markTestSkipped( "HTML '{$self_contained_token}' is not supported." ); - } - $this->assertTrue( $found_token, "Failed to find any tokens in '{$self_contained_token}': check test data provider." @@ -305,10 +297,6 @@ public function test_cannot_nest_void_tags_next_token( $tag_name ) { $found_tag = $processor->next_token(); - if ( WP_HTML_Processor::ERROR_UNSUPPORTED === $processor->get_last_error() ) { - $this->markTestSkipped( "Tag {$tag_name} is not supported." ); - } - $this->assertTrue( $found_tag, "Could not find first {$tag_name}." @@ -358,37 +346,6 @@ public static function data_void_tags_not_ignored_in_body() { return $all_void_tags; } - /** - * Ensures that special handling of unsupported tags is cleaned up - * as handling is implemented. Otherwise there's risk of leaving special - * handling (that is never reached) when tag handling is implemented. - * - * @ticket 60092 - * - * @dataProvider data_unsupported_special_in_body_tags - * - * @covers WP_HTML_Processor::step_in_body - * - * @param string $tag_name Name of the tag to test. - */ - public function test_step_in_body_fails_on_unsupported_tags( $tag_name ) { - $fragment = WP_HTML_Processor::create_fragment( '<' . $tag_name . '>' ); - $this->assertFalse( $fragment->next_tag(), 'Should fail to find tag: ' . $tag_name . '.' ); - $this->assertEquals( $fragment->get_last_error(), WP_HTML_Processor::ERROR_UNSUPPORTED, 'Should have unsupported last error.' ); - } - - /** - * Data provider. - * - * @return array[] - */ - public static function data_unsupported_special_in_body_tags() { - return array( - 'MATH' => array( 'MATH' ), - 'SVG' => array( 'SVG' ), - ); - } - /** * Ensures that the HTML Processor properly reports the depth of a given element. * @@ -475,6 +432,47 @@ public static function data_html_with_target_element_and_depth_of_next_node_in_b ); } + /** + * Ensures that elements which are unopened at the end of a document are implicitly closed. + * + * @ticket 61576 + */ + public function test_closes_unclosed_elements() { + $processor = WP_HTML_Processor::create_fragment( '

' ); + + $this->assertTrue( + $processor->next_tag( 'SPAN' ), + 'Could not find SPAN element: check test setup.' + ); + + // This is the end of the document, but there should be three closing events. + $processor->next_token(); + $this->assertSame( + 'SPAN', + $processor->get_tag(), + 'Should have found implicit SPAN closing tag.' + ); + + $processor->next_token(); + $this->assertSame( + 'P', + $processor->get_tag(), + 'Should have found implicit P closing tag.' + ); + + $processor->next_token(); + $this->assertSame( + 'DIV', + $processor->get_tag(), + 'Should have found implicit DIV closing tag.' + ); + + $this->assertFalse( + $processor->next_token(), + "Should have failed to find any more tokens but found a '{$processor->get_token_name()}'" + ); + } + /** * Ensures that subclasses can be created from ::create_fragment method. * @@ -493,4 +491,32 @@ public function __construct( $html ) { $subclass_processor = call_user_func( array( get_class( $subclass_instance ), 'create_fragment' ), '' ); $this->assertInstanceOf( get_class( $subclass_instance ), $subclass_processor, '::create_fragment did not return subclass instance.' ); } + + /** + * Ensures that self-closing elements in foreign content properly report + * that they expect no closer. + * + * @ticket 61576 + */ + public function test_expects_closer_foreign_content_self_closing() { + $processor = WP_HTML_Processor::create_fragment( '' ); + + $this->assertTrue( $processor->next_tag() ); + $this->assertSame( 'SVG', $processor->get_tag() ); + $this->assertFalse( $processor->expects_closer() ); + + $this->assertTrue( $processor->next_tag() ); + $this->assertSame( 'MATH', $processor->get_tag() ); + $this->assertTrue( $processor->expects_closer() ); + } + + /** + * Ensures that self-closing foreign SCRIPT elements are properly found. + * + * @ticket 61576 + */ + public function test_foreign_content_script_self_closing() { + $processor = WP_HTML_Processor::create_fragment( '' ); + $this->assertTrue( $processor->next_tag( 'script' ) ); + } } diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessorBreadcrumbs.php b/tests/phpunit/tests/html-api/wpHtmlProcessorBreadcrumbs.php index e9c7362c179a2..911fa8b910b37 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessorBreadcrumbs.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessorBreadcrumbs.php @@ -25,7 +25,7 @@ class Tests_HtmlApi_WpHtmlProcessorBreadcrumbs extends WP_UnitTestCase { public function test_navigates_into_normative_html_for_supported_elements( $html, $tag_name ) { $processor = WP_HTML_Processor::create_fragment( $html ); - $this->assertTrue( $processor->step(), "Failed to step into supported {$tag_name} element." ); + $this->assertTrue( $processor->next_token(), "Failed to step into supported {$tag_name} element." ); $this->assertSame( $tag_name, $processor->get_tag(), "Misread {$tag_name} as a {$processor->get_tag()} element." ); } @@ -46,8 +46,10 @@ public static function data_single_tag_of_supported_elements() { 'ASIDE', 'AUDIO', 'B', + 'BASE', 'BDI', 'BDO', + 'BGSOUND', // Deprectated. 'BIG', 'BLINK', // Deprecated. 'BR', @@ -88,22 +90,27 @@ public static function data_single_tag_of_supported_elements() { 'IMG', 'INS', 'LI', + 'LINK', 'ISINDEX', // Deprecated. 'KBD', 'KEYGEN', // Deprecated. 'LABEL', 'LEGEND', + 'LINK', 'LISTING', // Deprecated. 'MAIN', 'MAP', 'MARK', 'MARQUEE', // Deprecated. 'MENU', + 'META', 'METER', 'MULTICOL', // Deprecated. 'NAV', 'NEXTID', // Deprecated. 'NOBR', // Neutralized. + 'NOEMBED', // Neutralized. + 'NOFRAMES', // Neutralized. 'NOSCRIPT', 'OBJECT', 'OL', @@ -118,6 +125,7 @@ public static function data_single_tag_of_supported_elements() { 'RTC', // Neutralized. 'RUBY', 'SAMP', + 'SCRIPT', 'SEARCH', 'SECTION', 'SLOT', @@ -126,21 +134,29 @@ public static function data_single_tag_of_supported_elements() { 'SPAN', 'STRIKE', 'STRONG', + 'STYLE', 'SUB', 'SUMMARY', 'SUP', 'TABLE', + 'TEXTAREA', 'TIME', + 'TITLE', 'TT', 'U', 'UL', 'VAR', 'VIDEO', + 'XMP', // Deprecated, use PRE instead. ); $data = array(); foreach ( $supported_elements as $tag_name ) { - $data[ $tag_name ] = array( "<{$tag_name}>", $tag_name ); + $closer = in_array( $tag_name, array( 'NOEMBED', 'NOFRAMES', 'SCRIPT', 'STYLE', 'TEXTAREA', 'TITLE', 'XMP' ), true ) + ? "" + : ''; + + $data[ $tag_name ] = array( "<{$tag_name}>{$closer}", $tag_name ); } $data['IMAGE (treated as an IMG)'] = array( '', 'IMG' ); @@ -148,76 +164,6 @@ public static function data_single_tag_of_supported_elements() { return $data; } - /** - * Ensures that no new HTML elements are accidentally partially-supported. - * - * When introducing support for new HTML elements, there are multiple places - * in the HTML Processor that need to be updated, until the time that the class - * has full HTML5 support. Because of this, these tests lock down the interface - * to ensure that support isn't accidentally updated in one place for a new - * element while overlooked in another. - * - * @ticket 58517 - * - * @covers WP_HTML_Processor::step - * - * @dataProvider data_unsupported_elements - * - * @param string $html HTML string containing unsupported elements. - */ - public function test_fails_when_encountering_unsupported_tag( $html ) { - $processor = WP_HTML_Processor::create_fragment( $html ); - - $this->assertFalse( $processor->step(), "Should not have stepped into unsupported {$processor->get_tag()} element." ); - } - - /** - * Data provider. - * - * @return array[] - */ - public static function data_unsupported_elements() { - $unsupported_elements = array( - 'BASE', - 'BGSOUND', // Deprecated; self-closing if self-closing flag provided, otherwise normal. - 'BODY', - 'CAPTION', - 'COL', - 'COLGROUP', - 'FRAME', - 'FRAMESET', - 'HEAD', - 'HTML', - 'IFRAME', - 'LINK', - 'MATH', - 'META', - 'NOEMBED', // Neutralized. - 'NOFRAMES', // Neutralized. - 'PLAINTEXT', // Neutralized. - 'SCRIPT', - 'STYLE', - 'SVG', - 'TBODY', - 'TD', - 'TEMPLATE', - 'TEXTAREA', - 'TFOOT', - 'TH', - 'THEAD', - 'TITLE', - 'TR', - 'XMP', // Deprecated, use PRE instead. - ); - - $data = array(); - foreach ( $unsupported_elements as $tag_name ) { - $data[ $tag_name ] = array( "<{$tag_name}>" ); - } - - return $data; - } - /** * @ticket 58517 * diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessorHtml5lib.php b/tests/phpunit/tests/html-api/wpHtmlProcessorHtml5lib.php index 8487df26c99dc..15d50d1934116 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessorHtml5lib.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessorHtml5lib.php @@ -21,38 +21,30 @@ * @group html-api-html5lib-tests */ class Tests_HtmlApi_Html5lib extends WP_UnitTestCase { - /** - * The HTML Processor only accepts HTML in document . - * Do not run tests that look for anything in document . - */ - const SKIP_HEAD_TESTS = true; - /** * Skip specific tests that may not be supported or have known issues. */ const SKIP_TESTS = array( - 'adoption01/line0046' => 'Unimplemented: Reconstruction of active formatting elements.', - 'adoption01/line0159' => 'Unimplemented: Reconstruction of active formatting elements.', - 'adoption01/line0318' => 'Unimplemented: Reconstruction of active formatting elements.', - 'tests1/line0720' => 'Unimplemented: Reconstruction of active formatting elements.', - 'tests15/line0001' => 'Unimplemented: Reconstruction of active formatting elements.', - 'tests15/line0022' => 'Unimplemented: Reconstruction of active formatting elements.', - 'tests15/line0068' => 'Unimplemented: no support outside of IN BODY yet.', - 'tests2/line0650' => 'Whitespace only test never enters "in body" parsing mode.', - 'tests19/line0965' => 'Unimplemented: no support outside of IN BODY yet.', - 'tests23/line0001' => 'Unimplemented: Reconstruction of active formatting elements.', - 'tests23/line0041' => 'Unimplemented: Reconstruction of active formatting elements.', - 'tests23/line0069' => 'Unimplemented: Reconstruction of active formatting elements.', - 'tests23/line0101' => 'Unimplemented: Reconstruction of active formatting elements.', - 'tests26/line0263' => 'Bug: An active formatting element should be created for a trailing text node.', - 'webkit01/line0231' => 'Unimplemented: This parser does not add missing attributes to existing HTML or BODY tags.', - 'webkit02/line0013' => "Asserting behavior with scripting flag enabled, which this parser doesn't support.", - 'webkit01/line0300' => 'Unimplemented: no support outside of IN BODY yet.', - 'webkit01/line0310' => 'Unimplemented: no support outside of IN BODY yet.', - 'webkit01/line0336' => 'Unimplemented: no support outside of IN BODY yet.', - 'webkit01/line0349' => 'Unimplemented: no support outside of IN BODY yet.', - 'webkit01/line0362' => 'Unimplemented: no support outside of IN BODY yet.', - 'webkit01/line0375' => 'Unimplemented: no support outside of IN BODY yet.', + 'comments01/line0155' => 'Unimplemented: Need to access raw comment text on non-normative comments.', + 'comments01/line0169' => 'Unimplemented: Need to access raw comment text on non-normative comments.', + 'doctype01/line0380' => 'Bug: Mixed whitespace, non-whitespace text in head not split correctly', + 'html5test-com/line0129' => 'Unimplemented: Need to access raw comment text on non-normative comments.', + 'noscript01/line0014' => 'Unimplemented: This parser does not add missing attributes to existing HTML or BODY tags.', + 'tests1/line0692' => 'Bug: Mixed whitespace, non-whitespace text in head not split correctly', + 'tests14/line0022' => 'Unimplemented: This parser does not add missing attributes to existing HTML or BODY tags.', + 'tests14/line0055' => 'Unimplemented: This parser does not add missing attributes to existing HTML or BODY tags.', + 'tests19/line0488' => 'Unimplemented: This parser does not add missing attributes to existing HTML or BODY tags.', + 'tests19/line0500' => 'Unimplemented: This parser does not add missing attributes to existing HTML or BODY tags.', + 'tests19/line0965' => 'Bug: Mixed whitespace, non-whitespace text in head not split correctly.', + 'tests19/line1079' => 'Unimplemented: This parser does not add missing attributes to existing HTML or BODY tags.', + 'tests2/line0207' => 'Unimplemented: This parser does not add missing attributes to existing HTML or BODY tags.', + 'tests2/line0686' => 'Unimplemented: This parser does not add missing attributes to existing HTML or BODY tags.', + 'tests2/line0697' => 'Unimplemented: This parser does not add missing attributes to existing HTML or BODY tags.', + 'tests2/line0709' => 'Unimplemented: This parser does not add missing attributes to existing HTML or BODY tags.', + 'tests5/line0013' => 'Bug: Mixed whitespace, non-whitespace text in head not split correctly.', + 'tests5/line0077' => 'Bug: Mixed whitespace, non-whitespace text in head not split correctly.', + 'tests5/line0091' => 'Bug: Mixed whitespace, non-whitespace text in head not split correctly', + 'webkit01/line0231' => 'Unimplemented: This parser does not add missing attributes to existing HTML or BODY tags.', ); /** @@ -67,14 +59,40 @@ class Tests_HtmlApi_Html5lib extends WP_UnitTestCase { * @param string $html Given test HTML. * @param string $expected_tree Tree structure of parsed HTML. */ - public function test_parse( $fragment_context, $html, $expected_tree ) { + public function test_parse( ?string $fragment_context, string $html, string $expected_tree ) { $processed_tree = self::build_tree_representation( $fragment_context, $html ); if ( null === $processed_tree ) { $this->markTestSkipped( 'Test includes unsupported markup.' ); } + $fragment_detail = $fragment_context ? " in context <{$fragment_context}>" : ''; - $this->assertSame( $expected_tree, $processed_tree, "HTML was not processed correctly:\n{$html}" ); + /* + * The HTML processor does not produce html, head, body tags if the processor does not reach them. + * HTML tree construction will always produce these tags, the HTML API does not at this time. + */ + $auto_generated_html_head_body = "\n \n \n\n"; + $auto_generated_head_body = " \n \n\n"; + $auto_generated_body = " \n\n"; + if ( str_ends_with( $expected_tree, $auto_generated_html_head_body ) && ! str_ends_with( $processed_tree, $auto_generated_html_head_body ) ) { + if ( str_ends_with( $processed_tree, "\n \n\n" ) ) { + $processed_tree = substr_replace( $processed_tree, " \n\n", -1 ); + } elseif ( str_ends_with( $processed_tree, "\n\n" ) ) { + $processed_tree = substr_replace( $processed_tree, " \n \n\n", -1 ); + } else { + $processed_tree = substr_replace( $processed_tree, $auto_generated_html_head_body, -1 ); + } + } elseif ( str_ends_with( $expected_tree, $auto_generated_head_body ) && ! str_ends_with( $processed_tree, $auto_generated_head_body ) ) { + if ( str_ends_with( $processed_tree, "\n\n" ) ) { + $processed_tree = substr_replace( $processed_tree, " \n\n", -1 ); + } else { + $processed_tree = substr_replace( $processed_tree, $auto_generated_head_body, -1 ); + } + } elseif ( str_ends_with( $expected_tree, $auto_generated_body ) && ! str_ends_with( $processed_tree, $auto_generated_body ) ) { + $processed_tree = substr_replace( $processed_tree, $auto_generated_body, -1 ); + } + + $this->assertSame( $expected_tree, $processed_tree, "HTML was not processed correctly{$fragment_detail}:\n{$html}" ); } /** @@ -99,7 +117,9 @@ public function data_external_html5lib_tests() { $line = str_pad( strval( $test[0] ), 4, '0', STR_PAD_LEFT ); $test_name = "{$test_suite}/line{$line}"; - if ( self::should_skip_test( $test_name, $test[3] ) ) { + $test_context_element = $test[1]; + + if ( self::should_skip_test( $test_context_element, $test_name ) ) { continue; } @@ -117,15 +137,9 @@ public function data_external_html5lib_tests() { * * @return bool True if the test case should be skipped. False otherwise. */ - private static function should_skip_test( $test_name, $expected_tree ): bool { - if ( self::SKIP_HEAD_TESTS ) { - $html_start = "\n \n \n"; - if ( - strlen( $expected_tree ) < strlen( $html_start ) || - substr( $expected_tree, 0, strlen( $html_start ) ) !== $html_start - ) { - return true; - } + private static function should_skip_test( ?string $test_context_element, string $test_name ): bool { + if ( null !== $test_context_element && 'body' !== $test_context_element ) { + return true; } if ( array_key_exists( $test_name, self::SKIP_TESTS ) ) { @@ -145,15 +159,18 @@ private static function should_skip_test( $test_name, $expected_tree ): bool { private static function build_tree_representation( ?string $fragment_context, string $html ) { $processor = $fragment_context ? WP_HTML_Processor::create_fragment( $html, "<{$fragment_context}>" ) - : WP_HTML_Processor::create_fragment( $html ); + : WP_HTML_Processor::create_full_parser( $html ); if ( null === $processor ) { return null; } - $output = "\n \n \n"; - - // Initially, assume we're 2 levels deep at: html > body > [position] - $indent_level = 2; + /* + * The fragment parser will start in 2 levels deep at: html > body > [position] + * and requires adjustment to initial parameters. + * The full parser will not. + */ + $output = $fragment_context ? "\n \n \n" : ''; + $indent_level = $fragment_context ? 2 : 0; $indent = ' '; $was_text = null; $text_node = ''; @@ -163,34 +180,95 @@ private static function build_tree_representation( ?string $fragment_context, st return null; } - if ( $was_text && '#text' !== $processor->get_token_name() ) { - $output .= "{$text_node}\"\n"; + $token_name = $processor->get_token_name(); + $token_type = $processor->get_token_type(); + $is_closer = $processor->is_tag_closer(); + + if ( $was_text && '#text' !== $token_name ) { + if ( '' !== $text_node ) { + $output .= "{$text_node}\"\n"; + } $was_text = false; $text_node = ''; } - switch ( $processor->get_token_type() ) { + switch ( $token_type ) { + case '#doctype': + $doctype = $processor->get_doctype_info(); + $output .= "name}"; + if ( null !== $doctype->public_identifier || null !== $doctype->system_identifier ) { + $output .= " \"{$doctype->public_identifier}\" \"{$doctype->system_identifier}\""; + } + $output .= ">\n"; + break; + case '#tag': - $tag_name = strtolower( $processor->get_tag() ); + $namespace = $processor->get_namespace(); + $tag_name = 'html' === $namespace + ? strtolower( $processor->get_tag() ) + : "{$namespace} {$processor->get_qualified_tag_name()}"; - if ( $processor->is_tag_closer() ) { + if ( $is_closer ) { --$indent_level; + + if ( 'html' === $namespace && 'TEMPLATE' === $token_name ) { + --$indent_level; + } + break; } - $tag_indent = count( $processor->get_breadcrumbs() ) - 1; + $tag_indent = $indent_level; - if ( ! WP_HTML_Processor::is_void( $tag_name ) ) { - $indent_level = $tag_indent + 1; + if ( $processor->expects_closer() ) { + ++$indent_level; } $output .= str_repeat( $indent, $tag_indent ) . "<{$tag_name}>\n"; $attribute_names = $processor->get_attribute_names_with_prefix( '' ); if ( $attribute_names ) { - sort( $attribute_names, SORT_STRING ); - + $sorted_attributes = array(); foreach ( $attribute_names as $attribute_name ) { + $sorted_attributes[ $attribute_name ] = $processor->get_qualified_attribute_name( $attribute_name ); + } + + /* + * Sorts attributes to match html5lib sort order. + * + * - First comes normal HTML attributes. + * - Then come adjusted foreign attributes; these have spaces in their names. + * - Finally come non-adjusted foreign attributes; these have a colon in their names. + * + * Example: + * + * From: + * Sorted: 'definitionURL', 'xlink show', 'xlink title', 'xlink:author' + */ + uasort( + $sorted_attributes, + static function ( $a, $b ) { + $a_has_ns = str_contains( $a, ':' ); + $b_has_ns = str_contains( $b, ':' ); + + // Attributes with `:` should follow all other attributes. + if ( $a_has_ns !== $b_has_ns ) { + return $a_has_ns ? 1 : -1; + } + + $a_has_sp = str_contains( $a, ' ' ); + $b_has_sp = str_contains( $b, ' ' ); + + // Attributes with a namespace ' ' should come after those without. + if ( $a_has_sp !== $b_has_sp ) { + return $a_has_sp ? 1 : -1; + } + + return $a <=> $b; + } + ); + + foreach ( $sorted_attributes as $attribute_name => $display_name ) { $val = $processor->get_attribute( $attribute_name ); /* * Attributes with no value are `true` with the HTML API, @@ -199,28 +277,39 @@ private static function build_tree_representation( ?string $fragment_context, st if ( true === $val ) { $val = ''; } - $output .= str_repeat( $indent, $tag_indent + 1 ) . "{$attribute_name}=\"{$val}\"\n"; + $output .= str_repeat( $indent, $tag_indent + 1 ) . "{$display_name}=\"{$val}\"\n"; } } // Self-contained tags contain their inner contents as modifiable text. $modifiable_text = $processor->get_modifiable_text(); if ( '' !== $modifiable_text ) { - $output .= str_repeat( $indent, $indent_level ) . "\"{$modifiable_text}\"\n"; + $output .= str_repeat( $indent, $tag_indent + 1 ) . "\"{$modifiable_text}\"\n"; } - if ( ! $processor->is_void( $tag_name ) && ! $processor->expects_closer() ) { - --$indent_level; + if ( 'html' === $namespace && 'TEMPLATE' === $token_name ) { + $output .= str_repeat( $indent, $indent_level ) . "content\n"; + ++$indent_level; } break; + case '#cdata-section': case '#text': + $text_content = $processor->get_modifiable_text(); + if ( '' === $text_content ) { + break; + } $was_text = true; if ( '' === $text_node ) { $text_node .= str_repeat( $indent, $indent_level ) . '"'; } - $text_node .= $processor->get_modifiable_text(); + $text_node .= $text_content; + break; + + case '#funky-comment': + // Comments must be "<" then "!-- " then the data then " -->". + $output .= str_repeat( $indent, $indent_level ) . "\n"; break; case '#comment': @@ -235,6 +324,10 @@ private static function build_tree_representation( ?string $fragment_context, st $comment_text_content = "[CDATA[{$processor->get_modifiable_text()}]]"; break; + case WP_HTML_Processor::COMMENT_AS_PI_NODE_LOOKALIKE: + $comment_text_content = "?{$processor->get_tag()}{$processor->get_modifiable_text()}?"; + break; + default: throw new Error( "Unhandled comment type for tree construction: {$processor->get_comment_type()}" ); } @@ -286,6 +379,7 @@ public static function parse_html5_dat_testfile( $filename ) { $test_html = ''; $test_dom = ''; $test_context_element = null; + $test_script_flag = false; $test_line_number = 0; while ( false !== ( $line = fgets( $handle ) ) ) { @@ -294,8 +388,12 @@ public static function parse_html5_dat_testfile( $filename ) { if ( '#' === $line[0] ) { // Finish section. if ( "#data\n" === $line ) { - // Yield when switching from a previous state. - if ( $state ) { + /* + * Yield when switching from a previous state. + * Do not yield tests with the scripting flag enabled. The scripting flag + * is always disabled in the HTML API. + */ + if ( $state && ! $test_script_flag ) { yield array( $test_line_number, $test_context_element, @@ -310,6 +408,10 @@ public static function parse_html5_dat_testfile( $filename ) { $test_html = ''; $test_dom = ''; $test_context_element = null; + $test_script_flag = false; + } + if ( "#script-on\n" === $line ) { + $test_script_flag = true; } $state = trim( substr( $line, 1 ) ); diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessorSemanticRules.php b/tests/phpunit/tests/html-api/wpHtmlProcessorSemanticRules.php index a64872bed2f1d..ffc99ad58fd8e 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessorSemanticRules.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessorSemanticRules.php @@ -424,4 +424,28 @@ public function test_br_end_tag_unsupported() { $this->assertFalse( $processor->is_tag_closer(), 'Should have treated the tag as an opening tag.' ); $this->assertNull( $processor->get_attribute_names_with_prefix( '' ), 'Should have ignored any attributes on the tag.' ); } + + /******************************************************************* + * RULES FOR "IN TABLE" MODE + *******************************************************************/ + + /** + * Ensure that form elements in tables (but not cells) are immediately popped off the stack. + * + * @ticket 61576 + */ + public function test_table_form_element_immediately_popped() { + $processor = WP_HTML_Processor::create_fragment( '' ); + + // There should be a FORM opener and a (virtual) FORM closer. + $this->assertTrue( $processor->next_tag( 'FORM' ) ); + $this->assertTrue( $processor->next_token() ); + $this->assertSame( 'FORM', $processor->get_token_name() ); + $this->assertTrue( $processor->is_tag_closer() ); + + // Followed by the comment token. + $this->assertTrue( $processor->next_token() ); + $this->assertSame( '#comment', $processor->get_token_name() ); + $this->assertsame( array( 'HTML', 'BODY', 'TABLE', '#comment' ), $processor->get_breadcrumbs() ); + } } diff --git a/tests/phpunit/tests/html-api/wpHtmlSupportRequiredOpenElements.php b/tests/phpunit/tests/html-api/wpHtmlSupportRequiredOpenElements.php deleted file mode 100644 index d2b24cd8bbcbc..0000000000000 --- a/tests/phpunit/tests/html-api/wpHtmlSupportRequiredOpenElements.php +++ /dev/null @@ -1,219 +0,0 @@ -" ); - - $this->assertFalse( $processor->step(), "Must support terminating elements in specific scope check before adding support for the {$tag_name} element." ); - } - - /** - * The check for whether an element is in a scope depends on - * looking for a number of terminating elements in the stack of open - * elements. Until the listed elements are supported in the HTML - * processor, there are no terminating elements and there's no - * point in taking the time to look for them. - * - * @since 6.4.0 - * - * @ticket 58517 - */ - public function test_has_element_in_scope_needs_support() { - // MathML Elements: MI, MO, MN, MS, MTEXT, ANNOTATION-XML. - $this->ensure_support_is_added_everywhere( 'MATH' ); - - /* - * SVG elements: note that TITLE is both an HTML element and an SVG element - * so care must be taken when adding support for either one. - * - * FOREIGNOBJECT, DESC, TITLE. - */ - $this->ensure_support_is_added_everywhere( 'SVG' ); - } - - /** - * The check for whether an element is in list item scope depends on - * the elements for any scope, plus UL and OL. - * - * The method for asserting list item scope doesn't currently exist - * because the LI element isn't yet supported and the LI element is - * the only element that needs to know about list item scope. - * - * @since 6.4.0 - * - * @ticket 58517 - * - * @covers WP_HTML_Open_Elements::has_element_in_list_item_scope - */ - public function test_has_element_in_list_item_scope_needs_support() { - // MathML Elements: MI, MO, MN, MS, MTEXT, ANNOTATION-XML. - $this->ensure_support_is_added_everywhere( 'MATH' ); - - /* - * SVG elements: note that TITLE is both an HTML element and an SVG element - * so care must be taken when adding support for either one. - * - * FOREIGNOBJECT, DESC, TITLE. - */ - $this->ensure_support_is_added_everywhere( 'SVG' ); - } - - /** - * The check for whether an element is in BUTTON scope depends on - * the elements for any scope, plus BUTTON. - * - * @since 6.4.0 - * - * @ticket 58517 - * - * @covers WP_HTML_Open_Elements::has_element_in_button_scope - */ - public function test_has_element_in_button_scope_needs_support() { - // MathML Elements: MI, MO, MN, MS, MTEXT, ANNOTATION-XML. - $this->ensure_support_is_added_everywhere( 'MATH' ); - - /* - * SVG elements: note that TITLE is both an HTML element and an SVG element - * so care must be taken when adding support for either one. - * - * FOREIGNOBJECT, DESC, TITLE. - */ - $this->ensure_support_is_added_everywhere( 'SVG' ); - } - - /** - * The optimization maintaining a flag for "P is in BUTTON scope" requires - * updating that flag every time an element is popped from the stack of - * open elements. - * - * @since 6.4.0 - * - * @ticket 58517 - * - * @covers WP_HTML_Open_Elements::after_element_pop - */ - public function test_after_element_pop_must_maintain_p_in_button_scope_flag() { - // MathML Elements: MI, MO, MN, MS, MTEXT, ANNOTATION-XML. - $this->ensure_support_is_added_everywhere( 'MATH' ); - - /* - * SVG elements: note that TITLE is both an HTML element and an SVG element - * so care must be taken when adding support for either one. - * - * FOREIGNOBJECT, DESC, TITLE. - */ - $this->ensure_support_is_added_everywhere( 'SVG' ); - } - - /** - * The optimization maintaining a flag for "P is in BUTTON scope" requires - * updating that flag every time an element is pushed onto the stack of - * open elements. - * - * @since 6.4.0 - * - * @ticket 58517 - * - * @covers WP_HTML_Open_Elements::after_element_push - */ - public function test_after_element_push_must_maintain_p_in_button_scope_flag() { - // MathML Elements: MI, MO, MN, MS, MTEXT, ANNOTATION-XML. - $this->ensure_support_is_added_everywhere( 'MATH' ); - - /* - * SVG elements: note that TITLE is both an HTML element and an SVG element - * so care must be taken when adding support for either one. - * - * FOREIGNOBJECT, DESC, TITLE. - */ - $this->ensure_support_is_added_everywhere( 'SVG' ); - } - - /** - * The check for whether an element is in TABLE scope depends on - * the HTML, TABLE, and TEMPLATE elements. - * - * @since 6.4.0 - * - * @ticket 58517 - * - * @covers WP_HTML_Open_Elements::has_element_in_table_scope - */ - public function test_has_element_in_table_scope_needs_support() { - // MathML Elements: MI, MO, MN, MS, MTEXT, ANNOTATION-XML. - $this->ensure_support_is_added_everywhere( 'MATH' ); - - /* - * SVG elements: note that TITLE is both an HTML element and an SVG element - * so care must be taken when adding support for either one. - * - * FOREIGNOBJECT, DESC, TITLE. - */ - $this->ensure_support_is_added_everywhere( 'SVG' ); - } - - /** - * The check for whether an element is in SELECT scope depends on - * the OPTGROUP and OPTION elements. - * - * @since 6.4.0 - * - * @ticket 58517 - * - * @covers WP_HTML_Open_Elements::has_element_in_select_scope - */ - public function test_has_element_in_select_scope_needs_support() { - // MathML Elements: MI, MO, MN, MS, MTEXT, ANNOTATION-XML. - $this->ensure_support_is_added_everywhere( 'MATH' ); - - /* - * SVG elements: note that TITLE is both an HTML element and an SVG element - * so care must be taken when adding support for either one. - * - * FOREIGNOBJECT, DESC, TITLE. - */ - $this->ensure_support_is_added_everywhere( 'SVG' ); - } -} diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor-token-scanning.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-token-scanning.php index fbb2521233679..e8195dcfa28c6 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor-token-scanning.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-token-scanning.php @@ -512,6 +512,67 @@ public function test_basic_assertion_abruptly_closed_cdata_section() { ); } + /** + * Ensures that basic CDATA sections inside foreign content are detected. + * + * @ticket 61576 + */ + public function test_basic_cdata_in_foreign_content() { + $processor = new WP_HTML_Tag_Processor( 'this is >&gt; real CDATA' ); + $processor->next_token(); + + // Artificially change namespace; this should be done in the HTML Processor. + $processor->change_parsing_namespace( 'svg' ); + $processor->next_token(); + + $this->assertSame( + '#cdata-section', + $processor->get_token_name(), + "Should have found a CDATA section but found {$processor->get_token_name()} instead." + ); + + $this->assertNull( + $processor->get_tag(), + 'Should not have been able to query tag name on non-element token.' + ); + + $this->assertNull( + $processor->get_attribute( 'type' ), + 'Should not have been able to query attributes on non-element token.' + ); + + $this->assertSame( + 'this is >> real CDATA', + $processor->get_modifiable_text(), + 'Found incorrect modifiable text.' + ); + } + + /** + * Ensures that empty CDATA sections inside foreign content are detected. + * + * @ticket 61576 + */ + public function test_empty_cdata_in_foreign_content() { + $processor = new WP_HTML_Tag_Processor( '' ); + $processor->next_token(); + + // Artificially change namespace; this should be done in the HTML Processor. + $processor->change_parsing_namespace( 'svg' ); + $processor->next_token(); + + $this->assertSame( + '#cdata-section', + $processor->get_token_name(), + "Should have found a CDATA section but found {$processor->get_token_name()} instead." + ); + + $this->assertEmpty( + $processor->get_modifiable_text(), + 'Found non-empty modifiable text.' + ); + } + /** * Ensures that normative Processing Instruction nodes are properly parsed. * diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php index fceaaddb04af6..fed0b9d050e1a 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php @@ -601,6 +601,31 @@ public function test_next_tag_should_return_false_for_a_non_existing_tag() { $this->assertFalse( $processor->next_tag( 'p' ), 'Querying a non-existing tag did not return false' ); } + /** + * @ticket 61545 + */ + public function test_next_tag_should_not_match_on_substrings_of_a_requested_tag() { + $processor = new WP_HTML_Tag_Processor( '

' ); + + $this->assertTrue( + $processor->next_tag( 'PICTURE' ), + 'Failed to find a tag when requested: check test setup.' + ); + + $this->assertSame( + 'PICTURE', + $processor->get_tag(), + 'Should have skipped past substring tag matches, directly finding the PICTURE element.' + ); + + $processor = new WP_HTML_Tag_Processor( '

' ); + + $this->assertFalse( + $processor->next_tag( 'PICTURE' ), + "Should not have found any PICTURE element, but found '{$processor->get_token_name()}' instead." + ); + } + /** * @ticket 59209 * @@ -2875,4 +2900,59 @@ public function insert_after( $new_html ) { 'Should have properly applied the update from in front of the cursor.' ); } + + /** + * Test an infinite loop bugfix in incomplete script tag parsing. + * + * @small + * + * @ticket 61810 + */ + public function test_script_tag_processing_no_infinite_loop_final_dash() { + $processor = new WP_HTML_Tag_Processor( '' ); + $processor->next_token(); + + $this->assertSame( + 'SCRIPT', + $processor->get_token_name(), + "Should have found text node but found '{$processor->get_token_name()}' instead: check test setup." + ); + + $this->assertSame( + '', + $processor->get_modifiable_text(), + 'Should have found initial test text: check test setup.' + ); + + $processor->set_modifiable_text( $after ); + $this->assertSame( + $after, + $processor->get_modifiable_text(), + 'Should have found enqueued updated text.' + ); + + $processor->get_updated_html(); + $this->assertSame( + $after, + $processor->get_modifiable_text(), + 'Should have found updated text.' + ); + } + + /** + * Ensures that updates to modifiable text that are shorter than the + * original text do not cause the parser to lose its orientation. + * + * @ticket 61617 + */ + public function test_setting_shorter_modifiable_text() { + $processor = new WP_HTML_Tag_Processor( '

' ); + + // Find the test node in the middle. + while ( 'TEXTAREA' !== $processor->get_token_name() && $processor->next_token() ) { + continue; + } + + $this->assertSame( + 'TEXTAREA', + $processor->get_token_name(), + 'Failed to find the test TEXTAREA node; check the test setup.' + ); + + $processor->set_modifiable_text( 'short' ); + $processor->get_updated_html(); + $this->assertSame( + 'short', + $processor->get_modifiable_text(), + 'Should have updated modifiable text to something shorter than the original.' + ); + + $this->assertTrue( + $processor->next_token(), + 'Should have advanced to the last token in the input.' + ); + + $this->assertSame( + 'DIV', + $processor->get_token_name(), + 'Should have recognized the final DIV in the input.' + ); + + $this->assertSame( + 'not a ', + $processor->get_attribute( 'id' ), + 'Should have read in the id from the last DIV as "not a "' + ); + } + + /** + * Ensures that reads to modifiable text after setting it reads the updated + * enqueued values, and not the original value. + * + * @ticket 61617 + */ + public function test_modifiable_text_reads_updates_after_setting() { + $processor = new WP_HTML_Tag_Processor( 'This is text' ); + + $processor->next_token(); + $this->assertSame( + '#text', + $processor->get_token_name(), + 'Failed to find first text node: check test setup.' + ); + + $update = 'This is new text'; + $processor->set_modifiable_text( $update ); + $this->assertSame( + $update, + $processor->get_modifiable_text(), + 'Failed to read updated enqueued value of text node.' + ); + + $processor->next_token(); + $this->assertSame( + '#comment', + $processor->get_token_name(), + 'Failed to advance to comment: check test setup.' + ); + + $this->assertSame( + ' this is not ', + $processor->get_modifiable_text(), + 'Failed to read modifiable text for next token; did it read the old enqueued value from the previous token?' + ); + } + /** * Ensures that when ignoring a newline after LISTING and PRE tags, that this * happens appropriately after seeking. @@ -108,4 +269,155 @@ public function test_get_modifiable_text_ignores_newlines_after_seeking() { 'Should not have removed the leading newline from the last DIV on its second traversal.' ); } + + /** + * Ensures that modifiable text updates are not applied where they aren't supported. + * + * @ticket 61617 + * + * @dataProvider data_tokens_not_supporting_modifiable_text_updates + * + * @param string $html Contains HTML with a token not supporting modifiable text updates. + * @param int $advance_n_tokens Count of times to run `next_token()` before reaching target node. + */ + public function test_rejects_updates_on_unsupported_match_locations( string $html, int $advance_n_tokens ) { + $processor = new WP_HTML_Tag_Processor( $html ); + while ( --$advance_n_tokens >= 0 ) { + $processor->next_token(); + } + + $this->assertFalse( + $processor->set_modifiable_text( 'Bazinga!' ), + 'Should have prevented modifying the text at the target node.' + ); + + $this->assertSame( + $html, + $processor->get_updated_html(), + 'Should not have modified the input document in any way.' + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_tokens_not_supporting_modifiable_text_updates() { + return array( + 'Before parsing' => array( 'nothing to see here', 0 ), + 'After parsing' => array( 'nothing here either', 2 ), + 'Incomplete document' => array( ' array( 'Text', 1, 'Blubber', 'Blubber' ), + 'Text node (middle)' => array( 'Bold move', 2, 'yo', 'yo' ), + 'Text node (end)' => array( 'of a dog', 2, 'of a cat', 'of a cat' ), + 'Encoded text node' => array( '
birds and dogs
', 2, ' & ', '
<birds> & <dogs>
' ), + 'SCRIPT tag' => array( 'beforeafter', 2, 'const img = " &
";', 'beforeafter' ), + 'STYLE tag' => array( '', 1, 'p::before { content: " & "; }', '' ), + 'TEXTAREA tag' => array( 'ab', 2, "so it ", "ab" ), + 'TEXTAREA (escape)' => array( 'ab', 2, 'but it does for ', 'ab' ), + 'TEXTAREA (escape+attrs)' => array( 'ab', 2, 'but it does for ', 'ab' ), + 'TITLE tag' => array( 'ahas no need to escapeb', 2, "so it ", "aso it <doesn't>b" ), + 'TITLE (escape)' => array( 'ahas no need to escapeb', 2, 'but it does for ', 'abut it does for </title>b' ), + 'TITLE (escape+attrs)' => array( 'ahas no need to escapeb', 2, 'but it does for ', 'abut it does for </title not an="attribute">b' ), + ); + } + + /** + * Ensures that updates with potentially-compromising values aren't accepted. + * + * For example, a modifiable text update should be allowed which would break + * the structure of the containing element, such as in a script or comment. + * + * @ticket 61617 + * + * @dataProvider data_unallowed_modifiable_text_updates + * + * @param string $html_with_nonempty_modifiable_text Will be used to find the test element. + * @param string $invalid_update Update containing possibly-compromising text. + */ + public function test_rejects_updates_with_unallowed_substrings( string $html_with_nonempty_modifiable_text, string $invalid_update ) { + $processor = new WP_HTML_Tag_Processor( $html_with_nonempty_modifiable_text ); + + while ( '' === $processor->get_modifiable_text() && $processor->next_token() ) { + continue; + } + + $original_text = $processor->get_modifiable_text(); + $this->assertNotEmpty( $original_text, 'Should have found non-empty text: check test setup.' ); + + $this->assertFalse( + $processor->set_modifiable_text( $invalid_update ), + 'Should have reject possibly-compromising modifiable text update.' + ); + + // Flush updates. + $processor->get_updated_html(); + + $this->assertSame( + $original_text, + $processor->get_modifiable_text(), + 'Should have preserved the original modifiable text before the rejected update.' + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_unallowed_modifiable_text_updates() { + return array( + 'Comment with -->' => array( '', 'Comments end in -->' ), + 'Comment with --!>' => array( '', 'Invalid but legitimate comments end in --!>' ), + 'SCRIPT with ' => array( '', 'Just a ' ), + 'SCRIPT with ' => array( '', 'beforeafter' ), + ); + } } diff --git a/tests/phpunit/tests/image/functions.php b/tests/phpunit/tests/image/functions.php index e70d19c0327ba..e895ec9dc8f7d 100644 --- a/tests/phpunit/tests/image/functions.php +++ b/tests/phpunit/tests/image/functions.php @@ -237,6 +237,7 @@ public function data_file_is_displayable_image_negative() { 'test-image.jp2', 'test-image.psd', 'test-image-zip.tiff', + 'test-image.heic', ); return $this->text_array_to_dataprovider( $files ); diff --git a/tests/phpunit/tests/image/resize.php b/tests/phpunit/tests/image/resize.php index e82dd3d2e6b2a..f4fe8632a0580 100644 --- a/tests/phpunit/tests/image/resize.php +++ b/tests/phpunit/tests/image/resize.php @@ -114,6 +114,32 @@ public function test_resize_avif() { $this->assertSame( IMAGETYPE_AVIF, $type ); } + /** + * Test resizing HEIC image. + * + * @ticket 53645 + */ + public function test_resize_heic() { + $file = DIR_TESTDATA . '/images/test-image.heic'; + $editor = wp_get_image_editor( $file ); + + // Check if the editor supports the HEIC mime type. + if ( is_wp_error( $editor ) || ! $editor->supports_mime_type( 'image/heic' ) ) { + $this->markTestSkipped( 'No HEIC support in the editor engine on this system.' ); + } + + $image = $this->resize_helper( $file, 25, 25 ); + + list( $w, $h, $type ) = wp_getimagesize( $image ); + + unlink( $image ); + + $this->assertSame( 'test-image-25x25.jpg', wp_basename( $image ) ); + $this->assertSame( 25, $w ); + $this->assertSame( 25, $h ); + $this->assertSame( IMAGETYPE_JPEG, $type ); + } + public function test_resize_larger() { // image_resize() should refuse to make an image larger. $image = $this->resize_helper( DIR_TESTDATA . '/images/test-image.jpg', 100, 100 ); @@ -235,6 +261,6 @@ protected function resize_helper( $file, $width, $height, $crop = false ) { return $saved; } - return $dest_file; + return $saved['path']; } } diff --git a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI.php b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI.php index b9cc2c0b83fac..e9349190ebdb2 100644 --- a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI.php +++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI.php @@ -1297,7 +1297,6 @@ public function test_evaluate_derived_state_defined_in_different_namespace() { $this->assertSame( "Derived state: otherPlugin-state\nDerived context: otherPlugin-context", $result ); } - /** * Tests the `evaluate` method for derived state functions that throw. * @@ -1322,6 +1321,29 @@ public function test_evaluate_derived_state_that_throws() { $this->assertNull( $result ); } + /** + * Tests the `evaluate` method for derived state intermediate values. + * + * @ticket 61741 + * + * @covers ::evaluate + */ + public function test_evaluate_derived_state_intermediate() { + $this->interactivity->state( + 'myPlugin', + array( + 'derivedState' => function () { + return array( 'property' => 'value' ); + }, + ) + ); + $this->set_internal_context_stack(); + $this->set_internal_namespace_stack( 'myPlugin' ); + + $result = $this->evaluate( 'state.derivedState.property' ); + $this->assertSame( 'value', $result ); + } + /** * Tests the `kebab_to_camel_case` method. * diff --git a/tests/phpunit/tests/link/getDashboardUrl.php b/tests/phpunit/tests/link/getDashboardUrl.php index dafaa7b62e96a..2864db235ffb9 100644 --- a/tests/phpunit/tests/link/getDashboardUrl.php +++ b/tests/phpunit/tests/link/getDashboardUrl.php @@ -12,11 +12,7 @@ public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { } public static function wpTearDownAfterClass() { - if ( is_multisite() ) { - wpmu_delete_user( self::$user_id ); - } else { - wp_delete_user( self::$user_id ); - } + self::delete_user( self::$user_id ); } /** diff --git a/tests/phpunit/tests/link/getEditCommentLink.php b/tests/phpunit/tests/link/getEditCommentLink.php new file mode 100644 index 0000000000000..1d574d40286d1 --- /dev/null +++ b/tests/phpunit/tests/link/getEditCommentLink.php @@ -0,0 +1,130 @@ +comment->create( array( 'comment_content' => 'Test comment' ) ); + + self::$user_ids = array( + 'admin' => $factory->user->create( array( 'role' => 'administrator' ) ), + 'subscriber' => $factory->user->create( array( 'role' => 'subscriber' ) ), + ); + } + + public static function wpTearDownAfterClass() { + // Delete the test comment. + wp_delete_comment( self::$comment_id, true ); + + // Delete the test users. + foreach ( self::$user_ids as $user_id ) { + self::delete_user( $user_id ); + } + } + + public function set_up() { + parent::set_up(); + wp_set_current_user( self::$user_ids['admin'] ); + } + + /** + * Tests that get_edit_comment_link() returns the correct URL by default. + */ + public function test_get_edit_comment_link_default() { + $comment_id = self::$comment_id; + $expected_url = admin_url( 'comment.php?action=editcomment&c=' . $comment_id ); + $actual_url = get_edit_comment_link( $comment_id ); + + $this->assertSame( $expected_url, $actual_url ); + } + + /** + * Tests that get_edit_comment_link() returns the correct URL with a context of 'display'. + * + * The expected result should include HTML entities. + * + * @ticket 61727 + */ + public function test_get_edit_comment_link_display_context() { + $comment_id = self::$comment_id; + $expected_url = admin_url( 'comment.php?action=editcomment&c=' . $comment_id ); + $actual_url = get_edit_comment_link( $comment_id, 'display' ); + + $this->assertSame( $expected_url, $actual_url ); + } + + /** + * Tests that get_edit_comment_link() returns the correct URL with a context of 'url'. + * + * The expected result should not include HTML entities. + * + * @ticket 61727 + */ + public function test_get_edit_comment_link_url_context() { + $comment_id = self::$comment_id; + $expected_url = admin_url( 'comment.php?action=editcomment&c=' . $comment_id ); + $actual_url = get_edit_comment_link( $comment_id, 'url' ); + + $this->assertSame( $expected_url, $actual_url ); + } + + /** + * Tests that get_edit_comment_link() returns nothing if the comment ID is invalid. + * + * @ticket 61727 + */ + public function test_get_edit_comment_link_invalid_comment() { + $comment_id = 12345; + $actual_url_display = get_edit_comment_link( $comment_id, 'display' ); + $actual_url = get_edit_comment_link( $comment_id, 'url' ); + + $this->assertNull( $actual_url_display ); + $this->assertNull( $actual_url ); + } + + /** + * Tests that get_edit_comment_link() returns nothing if the current user cannot edit it. + */ + public function test_get_edit_comment_link_user_cannot_edit() { + wp_set_current_user( self::$user_ids['subscriber'] ); + $comment_id = self::$comment_id; + $actual_url_display = get_edit_comment_link( $comment_id, 'display' ); + $actual_url = get_edit_comment_link( $comment_id, 'url' ); + + $this->assertNull( $actual_url_display ); + $this->assertNull( $actual_url ); + } + + /** + * Tests that the 'get_edit_comment_link' filter works as expected, including the additional parameters. + * + * @ticket 61727 + */ + public function test_get_edit_comment_link_filter() { + $comment_id = self::$comment_id; + $expected_url_display = admin_url( 'comment-test.php?context=display' ); + $expected_url = admin_url( 'comment-test.php?context=url' ); + + add_filter( + 'get_edit_comment_link', + function ( $location, $comment_id, $context ) { + return admin_url( 'comment-test.php?context=' . $context ); + }, + 10, + 3 + ); + + $actual_url_display = get_edit_comment_link( $comment_id, 'display' ); + $actual_url = get_edit_comment_link( $comment_id, 'url' ); + + // Assert the final URLs are as expected + $this->assertSame( $expected_url_display, $actual_url_display ); + $this->assertSame( $expected_url, $actual_url ); + } +} diff --git a/tests/phpunit/tests/link/getEditTermLink.php b/tests/phpunit/tests/link/getEditTermLink.php index d0303f1b1a403..e86cd78449f3d 100644 --- a/tests/phpunit/tests/link/getEditTermLink.php +++ b/tests/phpunit/tests/link/getEditTermLink.php @@ -236,4 +236,17 @@ public function data_get_edit_term_link() { ), ); } + + /** + * Checks that `get_edit_term_link()` produces the correct URL when called without taxonomy. + * + * @ticket 61726 + */ + public function test_get_edit_term_link_without_taxonomy() { + $term = $this->get_term( 'wptests_tax', true ); + + $actual = get_edit_term_link( $term ); + $expected = sprintf( admin_url( 'term.php?taxonomy=wptests_tax&tag_ID=%d&post_type=post' ), $term ); + $this->assertSame( $expected, $actual ); + } } diff --git a/tests/phpunit/tests/option/networkOption.php b/tests/phpunit/tests/option/networkOption.php index 3c89d5d0b408a..a4247d4926cec 100644 --- a/tests/phpunit/tests/option/networkOption.php +++ b/tests/phpunit/tests/option/networkOption.php @@ -60,6 +60,7 @@ public function test_delete_network_option_on_only_one_network() { * Tests that calling delete_network_option() updates nooptions when option deleted. * * @ticket 61484 + * @ticket 61730 * * @covers ::delete_network_option */ @@ -73,6 +74,11 @@ public function test_check_delete_network_option_updates_notoptions() { $this->assertIsArray( $notoptions, 'The notoptions cache is expected to be an array.' ); $this->assertTrue( $notoptions['foo'], 'The deleted options is expected to be in notoptions.' ); + if ( ! is_multisite() ) { + $network_notoptions = wp_cache_get( '1:notoptions', 'site-options' ); + $this->assertTrue( empty( $network_notoptions['foo'] ), 'The deleted option is not expected to be in network notoptions on a non-multisite.' ); + } + $before = get_num_queries(); get_network_option( 1, 'foo' ); $queries = get_num_queries() - $before; @@ -302,4 +308,108 @@ public function test_add_network_option_clears_the_notoptions_cache() { $updated_notoptions = wp_cache_get( $cache_key, $cache_group ); $this->assertArrayNotHasKey( $option_name, $updated_notoptions, 'The "foobar" option should not be in the notoptions cache after updating it.' ); } + + /** + * Test adding a previously known notoption returns the correct value. + * + * @ticket 61730 + * + * @covers ::add_network_option + * @covers ::delete_network_option + */ + public function test_adding_previous_notoption_returns_correct_value() { + $option_name = 'ticket_61730_option_to_be_created'; + + add_network_option( 1, $option_name, 'baz' ); + delete_network_option( 1, $option_name ); + + $this->assertFalse( get_network_option( 1, $option_name ), 'The option should not be found.' ); + + add_network_option( 1, $option_name, 'foo' ); + $this->assertSame( 'foo', get_network_option( 1, $option_name ), 'The option should return the newly set value.' ); + } + + /** + * Test `get_network_option()` does not use network notoptions cache for single sites. + * + * @ticket 61730 + * + * @group ms-excluded + * + * @covers ::get_network_option + */ + public function test_get_network_option_does_not_use_network_notoptions_cache_for_single_sites() { + get_network_option( 1, 'ticket_61730_notoption' ); + + $network_notoptions_cache = wp_cache_get( '1:notoptions', 'site-options' ); + $single_site_notoptions_cache = wp_cache_get( 'notoptions', 'options' ); + + $this->assertEmpty( $network_notoptions_cache, 'Network notoptions cache should not be set for single site installs.' ); + $this->assertIsArray( $single_site_notoptions_cache, 'Single site notoptions cache should be set.' ); + $this->assertArrayHasKey( 'ticket_61730_notoption', $single_site_notoptions_cache, 'The option should be in the notoptions cache.' ); + } + + /** + * Test `delete_network_option()` does not use network notoptions cache for single sites. + * + * @ticket 61730 + * @ticket 61484 + * + * @group ms-excluded + * + * @covers ::delete_network_option + */ + public function test_delete_network_option_does_not_use_network_notoptions_cache_for_single_sites() { + add_network_option( 1, 'ticket_61730_notoption', 'value' ); + delete_network_option( 1, 'ticket_61730_notoption' ); + + $network_notoptions_cache = wp_cache_get( '1:notoptions', 'site-options' ); + $single_site_notoptions_cache = wp_cache_get( 'notoptions', 'options' ); + + $this->assertEmpty( $network_notoptions_cache, 'Network notoptions cache should not be set for single site installs.' ); + $this->assertIsArray( $single_site_notoptions_cache, 'Single site notoptions cache should be set.' ); + $this->assertArrayHasKey( 'ticket_61730_notoption', $single_site_notoptions_cache, 'The option should be in the notoptions cache.' ); + } + + /** + * Test `get_network_option()` does not use single site notoptions cache for networks. + * + * @ticket 61730 + * + * @group ms-required + * + * @covers ::get_network_option + */ + public function test_get_network_option_does_not_use_single_site_notoptions_cache_for_networks() { + get_network_option( 1, 'ticket_61730_notoption' ); + + $network_notoptions_cache = wp_cache_get( '1:notoptions', 'site-options' ); + $single_site_notoptions_cache = wp_cache_get( 'notoptions', 'options' ); + + $this->assertEmpty( $single_site_notoptions_cache, 'Single site notoptions cache should not be set for multisite installs.' ); + $this->assertIsArray( $network_notoptions_cache, 'Multisite notoptions cache should be set.' ); + $this->assertArrayHasKey( 'ticket_61730_notoption', $network_notoptions_cache, 'The option should be in the notoptions cache.' ); + } + + /** + * Test `delete_network_option()` does not use single site notoptions cache for networks. + * + * @ticket 61730 + * @ticket 61484 + * + * @group ms-required + * + * @covers ::delete_network_option + */ + public function test_delete_network_option_does_not_use_single_site_notoptions_cache_for_networks() { + add_network_option( 1, 'ticket_61730_notoption', 'value' ); + delete_network_option( 1, 'ticket_61730_notoption' ); + + $network_notoptions_cache = wp_cache_get( '1:notoptions', 'site-options' ); + $single_site_notoptions_cache = wp_cache_get( 'notoptions', 'options' ); + + $this->assertEmpty( $single_site_notoptions_cache, 'Single site notoptions cache should not be set for multisite installs.' ); + $this->assertIsArray( $network_notoptions_cache, 'Multisite notoptions cache should be set.' ); + $this->assertArrayHasKey( 'ticket_61730_notoption', $network_notoptions_cache, 'The option should be in the notoptions cache.' ); + } } diff --git a/tests/phpunit/tests/option/option.php b/tests/phpunit/tests/option/option.php index a9db23df19bf2..36a40d9a2f495 100644 --- a/tests/phpunit/tests/option/option.php +++ b/tests/phpunit/tests/option/option.php @@ -143,7 +143,7 @@ public function test_get_option_notoptions_set_cache() { * @covers ::get_option */ public function test_get_option_notoptions_do_not_load_cache() { - add_option( 'foo', 'bar', '', 'no' ); + add_option( 'foo', 'bar', '', false ); wp_cache_delete( 'notoptions', 'options' ); $before = get_num_queries(); @@ -360,12 +360,14 @@ public function test_option_autoloading( $name, $autoload_value, $expected ) { public function data_option_autoloading() { return array( // Supported values. - array( 'autoload_yes', 'yes', 'on' ), array( 'autoload_true', true, 'on' ), - array( 'autoload_no', 'no', 'off' ), array( 'autoload_false', false, 'off' ), array( 'autoload_null', null, 'auto' ), + // Values supported for backward compatibility. + array( 'autoload_yes', 'yes', 'on' ), + array( 'autoload_no', 'no', 'off' ), + // Technically unsupported values. array( 'autoload_string', 'foo', 'auto' ), array( 'autoload_int', 123456, 'auto' ), @@ -457,8 +459,8 @@ public function test_update_option_autoloading_small_option_auto() { * @covers ::update_option */ public function test_update_option_with_autoload_change_no_to_yes() { - add_option( 'foo', 'value1', '', 'no' ); - update_option( 'foo', 'value2', 'yes' ); + add_option( 'foo', 'value1', '', false ); + update_option( 'foo', 'value2', true ); delete_option( 'foo' ); $this->assertFalse( get_option( 'foo' ) ); } @@ -473,8 +475,8 @@ public function test_update_option_with_autoload_change_no_to_yes() { * @covers ::update_option */ public function test_update_option_with_autoload_change_yes_to_no() { - add_option( 'foo', 'value1', '', 'yes' ); - update_option( 'foo', 'value2', 'no' ); + add_option( 'foo', 'value1', '', true ); + update_option( 'foo', 'value2', false ); delete_option( 'foo' ); $this->assertFalse( get_option( 'foo' ) ); } diff --git a/tests/phpunit/tests/option/updateOption.php b/tests/phpunit/tests/option/updateOption.php index 1be8aa0cf8c5a..c33f91ef73ebb 100644 --- a/tests/phpunit/tests/option/updateOption.php +++ b/tests/phpunit/tests/option/updateOption.php @@ -52,7 +52,7 @@ public function test_should_set_autoload_yes_for_nonexistent_option_when_autoloa */ public function test_should_set_autoload_yes_for_nonexistent_option_when_autoload_param_is_yes() { $this->flush_cache(); - update_option( 'test_update_option_default', 'value', 'yes' ); + update_option( 'test_update_option_default', 'value', true ); $this->flush_cache(); // Populate the alloptions cache, which includes autoload=yes options. @@ -75,7 +75,7 @@ public function test_should_set_autoload_yes_for_nonexistent_option_when_autoloa */ public function test_should_set_autoload_no_for_nonexistent_option_when_autoload_param_is_no() { $this->flush_cache(); - update_option( 'test_update_option_default', 'value', 'no' ); + update_option( 'test_update_option_default', 'value', false ); $this->flush_cache(); // Populate the alloptions cache, which does not include autoload=no options. @@ -122,7 +122,7 @@ public function test_should_set_autoload_no_for_nonexistent_option_when_autoload * @covers ::get_option */ public function test_autoload_should_be_updated_for_existing_option_when_value_is_changed() { - add_option( 'foo', 'bar', '', 'no' ); + add_option( 'foo', 'bar', '', false ); $updated = update_option( 'foo', 'bar2', true ); $this->assertTrue( $updated ); @@ -146,7 +146,7 @@ public function test_autoload_should_be_updated_for_existing_option_when_value_i * @covers ::get_option */ public function test_autoload_should_not_be_updated_for_existing_option_when_value_is_unchanged() { - add_option( 'foo', 'bar', '', 'yes' ); + add_option( 'foo', 'bar', '', true ); $updated = update_option( 'foo', 'bar', false ); $this->assertFalse( $updated ); @@ -171,7 +171,7 @@ public function test_autoload_should_not_be_updated_for_existing_option_when_val * @covers ::get_option */ public function test_autoload_should_not_be_updated_for_existing_option_when_value_is_changed_but_no_value_of_autoload_is_provided() { - add_option( 'foo', 'bar', '', 'yes' ); + add_option( 'foo', 'bar', '', true ); // Don't pass a value for `$autoload`. $updated = update_option( 'foo', 'bar2' ); diff --git a/tests/phpunit/tests/option/wpLoadAlloptions.php b/tests/phpunit/tests/option/wpLoadAlloptions.php index 34b8ebac9012d..166979a3772d7 100644 --- a/tests/phpunit/tests/option/wpLoadAlloptions.php +++ b/tests/phpunit/tests/option/wpLoadAlloptions.php @@ -26,7 +26,7 @@ public function test_if_alloptions_is_cached() { */ public function test_default_and_yes() { add_option( 'foo', 'bar' ); - add_option( 'bar', 'foo', '', 'yes' ); + add_option( 'bar', 'foo', '', true ); $alloptions = wp_load_alloptions(); $this->assertArrayHasKey( 'foo', $alloptions ); $this->assertArrayHasKey( 'bar', $alloptions ); @@ -39,7 +39,7 @@ public function test_default_and_yes() { */ public function test_default_and_no() { add_option( 'foo', 'bar' ); - add_option( 'bar', 'foo', '', 'no' ); + add_option( 'bar', 'foo', '', false ); $alloptions = wp_load_alloptions(); $this->assertArrayHasKey( 'foo', $alloptions ); $this->assertArrayNotHasKey( 'bar', $alloptions ); diff --git a/tests/phpunit/tests/option/wpSetOptionAutoload.php b/tests/phpunit/tests/option/wpSetOptionAutoload.php index 226ad4b155d1f..b6eeb4026374e 100644 --- a/tests/phpunit/tests/option/wpSetOptionAutoload.php +++ b/tests/phpunit/tests/option/wpSetOptionAutoload.php @@ -11,6 +11,8 @@ class Tests_Option_WpSetOptionAutoload extends WP_UnitTestCase { /** * Tests that setting an option's autoload value to 'yes' works as expected. * + * The values 'yes' and 'no' are only supported for backward compatibility. + * * @ticket 58964 */ public function test_wp_set_option_autoload_yes() { @@ -30,6 +32,8 @@ public function test_wp_set_option_autoload_yes() { /** * Tests that setting an option's autoload value to 'no' works as expected. * + * The values 'yes' and 'no' are only supported for backward compatibility. + * * @ticket 58964 */ public function test_wp_set_option_autoload_no() { @@ -56,9 +60,9 @@ public function test_wp_set_option_autoload_same() { $option = 'test_option'; $value = 'value'; - add_option( $option, $value, '', 'yes' ); + add_option( $option, $value, '', true ); - $this->assertFalse( wp_set_option_autoload( $option, 'yes' ), 'Function did unexpectedly succeed' ); + $this->assertFalse( wp_set_option_autoload( $option, true ), 'Function did unexpectedly succeed' ); $this->assertSame( 'on', $wpdb->get_var( $wpdb->prepare( "SELECT autoload FROM $wpdb->options WHERE option_name = %s", $option ) ), 'Option autoload value unexpectedly updated in database' ); } @@ -72,7 +76,7 @@ public function test_wp_set_option_autoload_missing() { $option = 'test_option'; - $this->assertFalse( wp_set_option_autoload( $option, 'yes' ), 'Function did unexpectedly succeed' ); + $this->assertFalse( wp_set_option_autoload( $option, true ), 'Function did unexpectedly succeed' ); $this->assertNull( $wpdb->get_var( $wpdb->prepare( "SELECT autoload FROM $wpdb->options WHERE option_name = %s", $option ) ), 'Missing option autoload value was set in database' ); $this->assertArrayNotHasKey( $option, wp_cache_get( 'alloptions', 'options' ), 'Missing option found in alloptions cache' ); $this->assertFalse( wp_cache_get( $option, 'options' ), 'Missing option found in individual cache' ); diff --git a/tests/phpunit/tests/option/wpSetOptionAutoloadValues.php b/tests/phpunit/tests/option/wpSetOptionAutoloadValues.php index 7f7e9a15b8d28..7214beedfe8e9 100644 --- a/tests/phpunit/tests/option/wpSetOptionAutoloadValues.php +++ b/tests/phpunit/tests/option/wpSetOptionAutoloadValues.php @@ -11,6 +11,8 @@ class Tests_Option_WpSetOptionAutoloadValues extends WP_UnitTestCase { /** * Tests setting options' autoload to 'yes' where for some options this is already the case. * + * The values 'yes' and 'no' are only supported for backward compatibility. + * * @ticket 58964 */ public function test_wp_set_option_autoload_values_all_yes_partial_update() { @@ -20,8 +22,8 @@ public function test_wp_set_option_autoload_values_all_yes_partial_update() { 'test_option1' => 'yes', 'test_option2' => 'yes', ); - add_option( 'test_option1', 'value1', '', 'yes' ); - add_option( 'test_option2', 'value2', '', 'no' ); + add_option( 'test_option1', 'value1', '', true ); + add_option( 'test_option2', 'value2', '', false ); $expected = array( 'test_option1' => false, 'test_option2' => true, @@ -42,6 +44,8 @@ public function test_wp_set_option_autoload_values_all_yes_partial_update() { * * In this case, the 'alloptions' cache should not be cleared, but only its options set to 'no' should be deleted. * + * The values 'yes' and 'no' are only supported for backward compatibility. + * * @ticket 58964 */ public function test_wp_set_option_autoload_values_all_no_partial_update() { @@ -51,8 +55,8 @@ public function test_wp_set_option_autoload_values_all_no_partial_update() { 'test_option1' => 'no', 'test_option2' => 'no', ); - add_option( 'test_option1', 'value1', '', 'yes' ); - add_option( 'test_option2', 'value2', '', 'no' ); + add_option( 'test_option1', 'value1', '', true ); + add_option( 'test_option2', 'value2', '', false ); $expected = array( 'test_option1' => true, 'test_option2' => false, @@ -70,6 +74,8 @@ public function test_wp_set_option_autoload_values_all_no_partial_update() { /** * Tests setting options' autoload to 'yes' where for all of them this is already the case. * + * The values 'yes' and 'no' are only supported for backward compatibility. + * * @ticket 58964 */ public function test_wp_set_option_autoload_values_all_yes_no_update() { @@ -79,8 +85,8 @@ public function test_wp_set_option_autoload_values_all_yes_no_update() { 'test_option1' => 'yes', 'test_option2' => 'yes', ); - add_option( 'test_option1', 'value1', '', 'yes' ); - add_option( 'test_option2', 'value2', '', 'yes' ); + add_option( 'test_option1', 'value1', '', true ); + add_option( 'test_option2', 'value2', '', true ); $expected = array( 'test_option1' => false, 'test_option2' => false, @@ -96,7 +102,7 @@ public function test_wp_set_option_autoload_values_all_yes_no_update() { } /** - * Tests setting options' autoload to either 'yes' or 'no' where for some options this is already the case. + * Tests setting options' autoload to either true or false where for some options this is already the case. * * The test also covers one option that is entirely missing. * @@ -106,14 +112,14 @@ public function test_wp_set_option_autoload_values_mixed_partial_update() { global $wpdb; $options = array( - 'test_option1' => 'yes', - 'test_option2' => 'no', - 'test_option3' => 'yes', - 'missing_opt' => 'yes', + 'test_option1' => true, + 'test_option2' => false, + 'test_option3' => true, + 'missing_opt' => true, ); - add_option( 'test_option1', 'value1', '', 'no' ); - add_option( 'test_option2', 'value2', '', 'yes' ); - add_option( 'test_option3', 'value3', '', 'yes' ); + add_option( 'test_option1', 'value1', '', false ); + add_option( 'test_option2', 'value2', '', true ); + add_option( 'test_option3', 'value3', '', true ); $expected = array( 'test_option1' => true, 'test_option2' => true, @@ -132,7 +138,7 @@ public function test_wp_set_option_autoload_values_mixed_partial_update() { } /** - * Tests setting options' autoload to either 'yes' or 'no' while only the 'no' options actually need to be updated. + * Tests setting options' autoload to either true or false while only the false options actually need to be updated. * * In this case, the 'alloptions' cache should not be cleared, but only its options set to 'no' should be deleted. * @@ -142,13 +148,13 @@ public function test_wp_set_option_autoload_values_mixed_only_update_no() { global $wpdb; $options = array( - 'test_option1' => 'yes', - 'test_option2' => 'no', - 'test_option3' => 'yes', + 'test_option1' => true, + 'test_option2' => false, + 'test_option3' => true, ); - add_option( 'test_option1', 'value1', '', 'yes' ); - add_option( 'test_option2', 'value2', '', 'yes' ); - add_option( 'test_option3', 'value3', '', 'yes' ); + add_option( 'test_option1', 'value1', '', true ); + add_option( 'test_option2', 'value2', '', true ); + add_option( 'test_option3', 'value3', '', true ); $expected = array( 'test_option1' => false, 'test_option2' => true, @@ -160,7 +166,7 @@ public function test_wp_set_option_autoload_values_mixed_only_update_no() { $this->assertSame( $num_queries + 2, get_num_queries(), 'Function made unexpected amount of database queries' ); $this->assertSameSets( array( 'on', 'off', 'on' ), $wpdb->get_col( $wpdb->prepare( "SELECT autoload FROM $wpdb->options WHERE option_name IN (" . implode( ',', array_fill( 0, count( $options ), '%s' ) ) . ')', ...array_keys( $options ) ) ), 'Option autoload values not updated in database' ); foreach ( $options as $option => $autoload ) { - if ( 'no' === $autoload ) { + if ( false === $autoload ) { $this->assertArrayNotHasKey( $option, wp_cache_get( 'alloptions', 'options' ), sprintf( 'Option %s not deleted from alloptions cache', $option ) ); } else { $this->assertArrayHasKey( $option, wp_cache_get( 'alloptions', 'options' ), sprintf( 'Option %s unexpectedly deleted from alloptions cache', $option ) ); @@ -177,11 +183,11 @@ public function test_wp_set_option_autoload_values_with_sql_query_failure() { global $wpdb; $options = array( - 'test_option1' => 'yes', - 'test_option2' => 'yes', + 'test_option1' => true, + 'test_option2' => true, ); - add_option( 'test_option1', 'value1', '', 'no' ); - add_option( 'test_option2', 'value2', '', 'no' ); + add_option( 'test_option1', 'value1', '', false ); + add_option( 'test_option2', 'value2', '', false ); // Force UPDATE queries to fail, leading to no autoload values being updated. add_filter( @@ -203,7 +209,7 @@ static function ( $query ) { } /** - * Tests setting options' autoload with boolean values. + * Tests setting options' autoload with now encouraged boolean values. * * @ticket 58964 */ diff --git a/tests/phpunit/tests/option/wpSetOptionsAutoload.php b/tests/phpunit/tests/option/wpSetOptionsAutoload.php index 68a1191df18d4..eb8358f533527 100644 --- a/tests/phpunit/tests/option/wpSetOptionsAutoload.php +++ b/tests/phpunit/tests/option/wpSetOptionsAutoload.php @@ -11,6 +11,8 @@ class Tests_Option_WpSetOptionsAutoload extends WP_UnitTestCase { /** * Tests that setting options' autoload value to 'yes' works as expected. * + * The values 'yes' and 'no' are only supported for backward compatibility. + * * @ticket 58964 */ public function test_wp_set_options_autoload_yes() { @@ -23,7 +25,7 @@ public function test_wp_set_options_autoload_yes() { $expected = array(); foreach ( $options as $option => $value ) { - add_option( $option, $value, '', 'no' ); + add_option( $option, $value, '', false ); $expected[ $option ] = true; } @@ -40,6 +42,8 @@ public function test_wp_set_options_autoload_yes() { /** * Tests that setting options' autoload value to 'no' works as expected. * + * The values 'yes' and 'no' are only supported for backward compatibility. + * * @ticket 58964 */ public function test_wp_set_options_autoload_no() { @@ -52,7 +56,7 @@ public function test_wp_set_options_autoload_no() { $expected = array(); foreach ( $options as $option => $value ) { - add_option( $option, $value, '', 'yes' ); + add_option( $option, $value, '', true ); $expected[ $option ] = true; } @@ -80,12 +84,12 @@ public function test_wp_set_options_autoload_same() { $expected = array(); foreach ( $options as $option => $value ) { - add_option( $option, $value, '', 'yes' ); + add_option( $option, $value, '', true ); $expected[ $option ] = false; } $num_queries = get_num_queries(); - $this->assertSame( $expected, wp_set_options_autoload( array_keys( $options ), 'yes' ), 'Function did unexpectedly succeed' ); + $this->assertSame( $expected, wp_set_options_autoload( array_keys( $options ), true ), 'Function did unexpectedly succeed' ); $this->assertSame( $num_queries + 1, get_num_queries(), 'Function attempted to update options autoload value in database' ); $this->assertSame( array( 'on', 'on' ), $wpdb->get_col( $wpdb->prepare( "SELECT autoload FROM $wpdb->options WHERE option_name IN (" . implode( ',', array_fill( 0, count( $options ), '%s' ) ) . ')', ...array_keys( $options ) ) ), 'Options autoload value unexpectedly updated in database' ); } @@ -108,7 +112,7 @@ public function test_wp_set_options_autoload_missing() { $expected[ $option ] = false; } - $this->assertSame( $expected, wp_set_options_autoload( $options, 'yes' ), 'Function did unexpectedly succeed' ); + $this->assertSame( $expected, wp_set_options_autoload( $options, true ), 'Function did unexpectedly succeed' ); $this->assertSame( array(), $wpdb->get_col( $wpdb->prepare( "SELECT autoload FROM $wpdb->options WHERE option_name IN (" . implode( ',', array_fill( 0, count( $options ), '%s' ) ) . ')', ...array_keys( $options ) ) ), 'Missing options autoload value was set in database' ); } @@ -125,14 +129,14 @@ public function test_wp_set_options_autoload_mixed() { 'test_option2' => 'value2', ); - add_option( 'test_option1', $options['test_option1'], '', 'yes' ); - add_option( 'test_option2', $options['test_option2'], '', 'no' ); + add_option( 'test_option1', $options['test_option1'], '', true ); + add_option( 'test_option2', $options['test_option2'], '', false ); $expected = array( 'test_option1' => false, 'test_option2' => true, ); - $this->assertSame( $expected, wp_set_options_autoload( array_keys( $options ), 'yes' ), 'Function produced unexpected result' ); + $this->assertSame( $expected, wp_set_options_autoload( array_keys( $options ), true ), 'Function produced unexpected result' ); $this->assertSame( array( 'on', 'on' ), $wpdb->get_col( $wpdb->prepare( "SELECT autoload FROM $wpdb->options WHERE option_name IN (" . implode( ',', array_fill( 0, count( $options ), '%s' ) ) . ')', ...array_keys( $options ) ) ), 'Option autoload values not updated in database' ); foreach ( $options as $option => $value ) { $this->assertFalse( wp_cache_get( $option, 'options' ), sprintf( 'Option %s not deleted from individual cache', $option ) ); diff --git a/tests/phpunit/tests/post/nav-menu.php b/tests/phpunit/tests/post/nav-menu.php index 7a320f9f8d628..d4ece1ff1776c 100644 --- a/tests/phpunit/tests/post/nav-menu.php +++ b/tests/phpunit/tests/post/nav-menu.php @@ -1208,6 +1208,35 @@ public function test_wp_update_nav_menu_item_with_special_characters_in_category $this->assertEmpty( $category_item->post_title ); } + /** + * Tests `wp_update_nav_menu_item()` with a non-existing taxonomy. + * + * When inserting a term from a non-existing taxonomy as a nav item, + * the `post_title` property should be empty, and the function + * should not throw a fatal error for `wp_specialchars_decode()`. + * + * @ticket 61799 + */ + public function test_wp_update_nav_menu_item_with_invalid_taxonomy() { + register_taxonomy( 'invalid', 'post' ); + $term = self::factory()->term->create_and_get( array( 'taxonomy' => 'invalid' ) ); + unregister_taxonomy( 'invalid' ); + + $menu_item_id = wp_update_nav_menu_item( + $this->menu_id, + 0, + array( + 'menu-item-type' => 'taxonomy', + 'menu-item-object' => 'invalid', + 'menu-item-object-id' => $term->term_id, + 'menu-item-status' => 'publish', + ) + ); + + $menu_item = get_post( $menu_item_id ); + $this->assertEmpty( $menu_item->post_title ); + } + /** * Test passed post_date/post_date_gmt. * diff --git a/tests/phpunit/tests/post/wpListPages.php b/tests/phpunit/tests/post/wpListPages.php index 38ba729cee20b..f059591219b1a 100644 --- a/tests/phpunit/tests/post/wpListPages.php +++ b/tests/phpunit/tests/post/wpListPages.php @@ -160,6 +160,42 @@ public function test_wp_list_pages_depth() { $this->assertSameIgnoreEOL( $expected, wp_list_pages( $args ) ); } + /** + * @ticket 61749 + */ + public function test_wp_list_pages_depth_equals_zero() { + $expected = ''; + + // Execute wp_list_pages() with a string to force calling wp_parse_args(). + ob_start(); + wp_list_pages( 'depth=0' ); + $output = ob_get_clean(); + + // If depth equals 0, all levels should be displayed. + $this->assertSameIgnoreEOL( $expected, $output ); + } + public function test_wp_list_pages_show_date() { $args = array( 'echo' => false, diff --git a/tests/phpunit/tests/privacy/wpPrivacySendErasureFulfillmentNotification.php b/tests/phpunit/tests/privacy/wpPrivacySendErasureFulfillmentNotification.php index 401528a158da4..bb6195912e233 100644 --- a/tests/phpunit/tests/privacy/wpPrivacySendErasureFulfillmentNotification.php +++ b/tests/phpunit/tests/privacy/wpPrivacySendErasureFulfillmentNotification.php @@ -98,6 +98,58 @@ public function tear_down() { parent::tear_down(); } + /** + * The function should not send an email when the request ID does not exist. + * + * @ticket 44234 + */ + public function test_should_not_send_email_when_not_a_valid_request_id() { + _wp_privacy_send_erasure_fulfillment_notification( 1234567890 ); + + $mailer = tests_retrieve_phpmailer_instance(); + + $this->assertEmpty( $mailer->mock_sent ); + } + + /** + * The function should not send an email when the ID passed does not correspond to a user request. + * + * @ticket 44234 + */ + public function test_should_not_send_email_when_not_a_user_request() { + $post_id = self::factory()->post->create( + array( + 'post_type' => 'post', // Should be 'user_request'. + ) + ); + + _wp_privacy_send_erasure_fulfillment_notification( $post_id ); + $mailer = tests_retrieve_phpmailer_instance(); + + $this->assertEmpty( $mailer->mock_sent ); + } + + /** + * The function should not send an email when the request is not completed. + * + * @ticket 44234 + */ + public function test_should_not_send_email_when_request_not_completed() { + wp_update_post( + array( + 'ID' => self::$request_id, + 'post_status' => 'request-confirmed', // Should be 'request-completed'. + ) + ); + + _wp_privacy_send_erasure_fulfillment_notification( self::$request_id ); + + $mailer = tests_retrieve_phpmailer_instance(); + + $this->assertEmpty( $mailer->mock_sent ); + $this->assertFalse( metadata_exists( 'post', self::$request_id, '_wp_user_notified' ) ); + } + /** * The function should send an email when a valid request ID is passed. * @@ -282,58 +334,6 @@ public function modify_email_headers( $headers ) { return $headers; } - /** - * The function should not send an email when the request ID does not exist. - * - * @ticket 44234 - */ - public function test_should_not_send_email_when_passed_invalid_request_id() { - _wp_privacy_send_erasure_fulfillment_notification( 1234567890 ); - - $mailer = tests_retrieve_phpmailer_instance(); - - $this->assertEmpty( $mailer->mock_sent ); - } - - /** - * The function should not send an email when the ID passed does not correspond to a user request. - * - * @ticket 44234 - */ - public function test_should_not_send_email_when_not_user_request() { - $post_id = self::factory()->post->create( - array( - 'post_type' => 'post', // Should be 'user_request'. - ) - ); - - _wp_privacy_send_erasure_fulfillment_notification( $post_id ); - $mailer = tests_retrieve_phpmailer_instance(); - - $this->assertEmpty( $mailer->mock_sent ); - } - - /** - * The function should not send an email when the request is not completed. - * - * @ticket 44234 - */ - public function test_should_not_send_email_when_request_not_completed() { - wp_update_post( - array( - 'ID' => self::$request_id, - 'post_status' => 'request-confirmed', // Should be 'request-completed'. - ) - ); - - _wp_privacy_send_erasure_fulfillment_notification( self::$request_id ); - - $mailer = tests_retrieve_phpmailer_instance(); - - $this->assertEmpty( $mailer->mock_sent ); - $this->assertFalse( metadata_exists( 'post', self::$request_id, '_wp_user_notified' ) ); - } - /** * The function should respect the user locale settings when the site uses the default locale. * diff --git a/tests/phpunit/tests/privacy/wpPrivacySendPersonalDataExportEmail.php b/tests/phpunit/tests/privacy/wpPrivacySendPersonalDataExportEmail.php index a0f4c96d298b2..37e2e4c282bd5 100644 --- a/tests/phpunit/tests/privacy/wpPrivacySendPersonalDataExportEmail.php +++ b/tests/phpunit/tests/privacy/wpPrivacySendPersonalDataExportEmail.php @@ -46,27 +46,6 @@ class Tests_Privacy_wpPrivacySendPersonalDataExportEmail extends WP_UnitTestCase */ protected static $admin_user; - /** - * Reset the mocked phpmailer instance before each test method. - * - * @since 4.9.6 - */ - public function set_up() { - parent::set_up(); - reset_phpmailer_instance(); - } - - /** - * Reset the mocked phpmailer instance after each test method. - * - * @since 4.9.6 - */ - public function tear_down() { - reset_phpmailer_instance(); - restore_previous_locale(); - parent::tear_down(); - } - /** * Create user request fixtures shared by test methods. * @@ -95,31 +74,32 @@ public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { } /** - * The function should send an export link to the requester when the user request is confirmed. + * Reset the mocked phpmailer instance before each test method. + * + * @since 4.9.6 */ - public function test_function_should_send_export_link_to_requester() { - $exports_url = wp_privacy_exports_url(); - $export_file_name = 'wp-personal-data-file-Wv0RfMnGIkl4CFEDEEkSeIdfLmaUrLsl.zip'; - $export_file_url = $exports_url . $export_file_name; - update_post_meta( self::$request_id, '_export_file_name', $export_file_name ); - - $email_sent = wp_privacy_send_personal_data_export_email( self::$request_id ); - $mailer = tests_retrieve_phpmailer_instance(); + public function set_up() { + parent::set_up(); + reset_phpmailer_instance(); + } - $this->assertSame( 'request-confirmed', get_post_status( self::$request_id ) ); - $this->assertSame( self::$requester_email, $mailer->get_recipient( 'to' )->address ); - $this->assertStringContainsString( 'Personal Data Export', $mailer->get_sent()->subject ); - $this->assertStringContainsString( $export_file_url, $mailer->get_sent()->body ); - $this->assertStringContainsString( 'please download it', $mailer->get_sent()->body ); - $this->assertTrue( $email_sent ); + /** + * Reset the mocked phpmailer instance after each test method. + * + * @since 4.9.6 + */ + public function tear_down() { + reset_phpmailer_instance(); + restore_previous_locale(); + parent::tear_down(); } /** - * The function should error when the request ID is invalid. + * The function should error when the request ID does not exist. * * @since 4.9.6 */ - public function test_function_should_error_when_request_id_invalid() { + public function test_should_return_wp_error_when_not_a_valid_request_id() { $request_id = 0; $email_sent = wp_privacy_send_personal_data_export_email( $request_id ); $this->assertWPError( $email_sent ); @@ -131,12 +111,30 @@ public function test_function_should_error_when_request_id_invalid() { $this->assertSame( 'invalid_request', $email_sent->get_error_code() ); } + /** + * The function should error when the ID passed does not correspond to a user request. + * + * @since 6.7.0 + * @ticket 46560 + */ + public function test_should_return_wp_error_when_not_a_user_request() { + $post_id = self::factory()->post->create( + array( + 'post_type' => 'post', // Should be 'user_request'. + ) + ); + + $email_sent = wp_privacy_send_personal_data_export_email( $post_id ); + $this->assertWPError( $email_sent ); + $this->assertSame( 'invalid_request', $email_sent->get_error_code() ); + } + /** * The function should error when the email was not sent. * * @since 4.9.6 */ - public function test_return_wp_error_when_send_fails() { + public function test_should_return_wp_error_when_sending_fails() { add_filter( 'wp_mail_from', '__return_empty_string' ); // Cause `wp_mail()` to return false. $email_sent = wp_privacy_send_personal_data_export_email( self::$request_id ); @@ -144,6 +142,26 @@ public function test_return_wp_error_when_send_fails() { $this->assertSame( 'privacy_email_error', $email_sent->get_error_code() ); } + /** + * The function should send an export link to the requester when the user request is confirmed. + */ + public function test_should_send_export_link_to_requester() { + $exports_url = wp_privacy_exports_url(); + $export_file_name = 'wp-personal-data-file-Wv0RfMnGIkl4CFEDEEkSeIdfLmaUrLsl.zip'; + $export_file_url = $exports_url . $export_file_name; + update_post_meta( self::$request_id, '_export_file_name', $export_file_name ); + + $email_sent = wp_privacy_send_personal_data_export_email( self::$request_id ); + $mailer = tests_retrieve_phpmailer_instance(); + + $this->assertSame( 'request-confirmed', get_post_status( self::$request_id ) ); + $this->assertSame( self::$requester_email, $mailer->get_recipient( 'to' )->address ); + $this->assertStringContainsString( 'Personal Data Export', $mailer->get_sent()->subject ); + $this->assertStringContainsString( $export_file_url, $mailer->get_sent()->body ); + $this->assertStringContainsString( 'please download it', $mailer->get_sent()->body ); + $this->assertTrue( $email_sent ); + } + /** * The export expiration should be filterable. * diff --git a/tests/phpunit/tests/privacy/wpPrivacySendRequestConfirmationNotification.php b/tests/phpunit/tests/privacy/wpPrivacySendRequestConfirmationNotification.php index 951039cb086d1..59d56c0642f35 100644 --- a/tests/phpunit/tests/privacy/wpPrivacySendRequestConfirmationNotification.php +++ b/tests/phpunit/tests/privacy/wpPrivacySendRequestConfirmationNotification.php @@ -32,11 +32,11 @@ public function tear_down() { } /** - * The function should not send emails when the request ID does not exist. + * The function should not send an email when the request ID does not exist. * * @ticket 43967 */ - public function test_function_should_not_send_email_when_not_a_valid_request_id() { + public function test_should_not_send_email_when_not_a_valid_request_id() { _wp_privacy_send_request_confirmation_notification( 1234567890 ); $mailer = tests_retrieve_phpmailer_instance(); @@ -44,14 +44,14 @@ public function test_function_should_not_send_email_when_not_a_valid_request_id( } /** - * The function should not send emails when the ID passed is not a WP_User_Request. + * The function should not send an email when the ID passed does not correspond to a user request. * * @ticket 43967 */ - public function test_function_should_not_send_email_when_not_a_wp_user_request() { + public function test_should_not_send_email_when_not_a_user_request() { $post_id = self::factory()->post->create( array( - 'post_type' => 'post', + 'post_type' => 'post', // Should be 'user_request'. ) ); @@ -66,7 +66,7 @@ public function test_function_should_not_send_email_when_not_a_wp_user_request() * * @ticket 43967 */ - public function test_function_should_send_email_to_site_admin_when_user_request_confirmed() { + public function test_should_send_email_to_site_admin_when_user_request_confirmed() { $email = 'export.request.from.unregistered.user@example.com'; $request_id = wp_create_user_request( $email, 'export_personal_data' ); @@ -89,7 +89,7 @@ public function test_function_should_send_email_to_site_admin_when_user_request_ * * @ticket 43967 */ - public function test_function_should_only_send_email_to_site_admin_when_user_request_is_confirmed() { + public function test_should_only_send_email_to_site_admin_when_user_request_is_confirmed() { $email = 'export.request.from.unregistered.user@example.com'; $request_id = wp_create_user_request( $email, 'export_personal_data' ); @@ -109,7 +109,7 @@ public function test_function_should_only_send_email_to_site_admin_when_user_req * * @ticket 43967 */ - public function test_function_should_only_send_email_once_to_admin_when_user_request_is_confirmed() { + public function test_should_only_send_email_once_to_admin_when_user_request_is_confirmed() { $email = 'export.request.from.unregistered.user@example.com'; $request_id = wp_create_user_request( $email, 'export_personal_data' ); diff --git a/tests/phpunit/tests/rest-api/rest-post-meta-fields.php b/tests/phpunit/tests/rest-api/rest-post-meta-fields.php index 1418db19dbce2..786396df2e215 100644 --- a/tests/phpunit/tests/rest-api/rest-post-meta-fields.php +++ b/tests/phpunit/tests/rest-api/rest-post-meta-fields.php @@ -3095,6 +3095,464 @@ public function test_default_is_added_to_schema() { $this->assertSame( 'Goodnight Moon', $schema['default'] ); } + /** + * Ensures that REST API calls with post meta containing the default value for the + * registered meta field stores the default value into the database. + * + * When the default value isn't persisted in the database, a read of the post meta + * at some point in the future might return a different value if the code setting the + * default changed. This ensures that once a value is intentionally saved into the + * database that it will remain durably in future reads. + * + * @ticket 55600 + * + * @dataProvider data_scalar_default_values + * + * @param string $type Scalar type of default value: one of `boolean`, `integer`, `number`, or `string`. + * @param mixed $default_value Appropriate default value for given type. + * @param mixed $alternative_value Ignored in this test. + */ + public function test_scalar_singular_default_is_saved_to_db( $type, $default_value, $alternative_value ) { + $this->grant_write_permission(); + + $meta_key_single = "with_{$type}_default"; + + register_post_meta( + 'post', + $meta_key_single, + array( + 'type' => $type, + 'single' => true, + 'show_in_rest' => true, + 'default' => $default_value, + ) + ); + + $request = new WP_REST_Request( 'POST', sprintf( '/wp/v2/posts/%d', self::$post_id ) ); + $request->set_body_params( + array( + 'meta' => array( + $meta_key_single => $default_value, + ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( + 200, + $response->get_status(), + "API call should have returned successfully but didn't: check test setup." + ); + + $this->assertSame( + array( (string) $default_value ), + get_metadata_raw( 'post', self::$post_id, $meta_key_single, false ), + 'Should have stored a single meta value with string-cast version of default value.' + ); + } + + /** + * Ensures that REST API calls with multi post meta values (containing the default) + * for the registered meta field stores the default value into the database. + * + * When the default value isn't persisted in the database, a read of the post meta + * at some point in the future might return a different value if the code setting the + * default changed. This ensures that once a value is intentionally saved into the + * database that it will remain durably in future reads. + * + * Further, the total count of stored values may be wrong if the default value + * is culled from the results of a "multi" read. + * + * @ticket 55600 + * + * @dataProvider data_scalar_default_values + * + * @param string $type Scalar type of default value: one of `boolean`, `integer`, `number`, or `string`. + * @param mixed $default_value Appropriate default value for given type. + * @param mixed $alternative_value Appropriate value for given type that doesn't match the default value. + */ + public function test_scalar_multi_default_is_saved_to_db( $type, $default_value, $alternative_value ) { + $this->grant_write_permission(); + + $meta_key_multiple = "with_multi_{$type}_default"; + + // Register non-singular post meta for type. + register_post_meta( + 'post', + $meta_key_multiple, + array( + 'type' => $type, + 'single' => false, + 'show_in_rest' => true, + 'default' => $default_value, + ) + ); + + // Write the default value as the sole value. + $request = new WP_REST_Request( 'POST', sprintf( '/wp/v2/posts/%d', self::$post_id ) ); + $request->set_body_params( + array( + 'meta' => array( + $meta_key_multiple => array( $default_value ), + ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( + 200, + $response->get_status(), + "API call should have returned successfully but didn't: check test setup." + ); + + $this->assertSame( + array( (string) $default_value ), + get_metadata_raw( 'post', self::$post_id, $meta_key_multiple, false ), + 'Should have stored a single meta value with string-cast version of default value.' + ); + + // Write multiple values, including the default, to ensure it remains. + $request = new WP_REST_Request( 'POST', sprintf( '/wp/v2/posts/%d', self::$post_id ) ); + $request->set_body_params( + array( + 'meta' => array( + $meta_key_multiple => array( + $default_value, + $alternative_value, + ), + ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( + 200, + $response->get_status(), + "API call should have returned successfully but didn't: check test setup." + ); + + $this->assertSame( + array( (string) $default_value, (string) $alternative_value ), + get_metadata_raw( 'post', self::$post_id, $meta_key_multiple, false ), + 'Should have stored both the default and non-default string-cast values.' + ); + } + + /** + * Ensures that REST API calls with post meta containing an object as the default + * value for the registered meta field stores the default value into the database. + * + * When the default value isn't persisted in the database, a read of the post meta + * at some point in the future might return a different value if the code setting the + * default changed. This ensures that once a value is intentionally saved into the + * database that it will remain durably in future reads. + * + * @ticket 55600 + * + * @dataProvider data_scalar_default_values + * + * @param string $type Scalar type of default value: one of `boolean`, `integer`, `number`, or `string`. + * @param mixed $default_value Appropriate default value for given type. + * @param mixed $alternative_value Ignored in this test. + */ + public function test_object_singular_default_is_saved_to_db( $type, $default_value, $alternative_value ) { + $this->grant_write_permission(); + + $meta_key_single = "with_{$type}_default"; + + // Register singular post meta for type. + register_post_meta( + 'post', + $meta_key_single, + array( + 'type' => 'object', + 'single' => true, + 'show_in_rest' => array( + 'schema' => array( + 'type' => 'object', + 'properties' => array( + $type => array( 'type' => $type ), + ), + ), + ), + 'default' => (object) array( $type => $default_value ), + ) + ); + + $request = new WP_REST_Request( 'POST', sprintf( '/wp/v2/posts/%d', self::$post_id ) ); + $request->set_body_params( + array( + 'meta' => array( + $meta_key_single => (object) array( $type => $default_value ), + ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( + 200, + $response->get_status(), + "API call should have returned successfully but didn't: check test setup." + ); + + // Objects stored into the database are read back as arrays. + $this->assertSame( + array( array( $type => $default_value ) ), + get_metadata_raw( 'post', self::$post_id, $meta_key_single, false ), + 'Should have stored a single meta value with an object representing the default value.' + ); + } + + /** + * Ensures that REST API calls with multi post meta values (containing an object as + * the default) for the registered meta field stores the default value into the database. + * + * When the default value isn't persisted in the database, a read of the post meta + * at some point in the future might return a different value if the code setting the + * default changed. This ensures that once a value is intentionally saved into the + * database that it will remain durably in future reads. + * + * Further, the total count of stored values may be wrong if the default value + * is culled from the results of a "multi" read. + * + * @ticket 55600 + * + * @dataProvider data_scalar_default_values + * + * @param string $type Scalar type of default value: one of `boolean`, `integer`, `number`, or `string`. + * @param mixed $default_value Appropriate default value for given type. + * @param mixed $alternative_value Appropriate value for given type that doesn't match the default value. + */ + public function test_object_multi_default_is_saved_to_db( $type, $default_value, $alternative_value ) { + $this->grant_write_permission(); + + $meta_key_multiple = "with_multi_{$type}_default"; + + // Register non-singular post meta for type. + register_post_meta( + 'post', + $meta_key_multiple, + array( + 'type' => 'object', + 'single' => false, + 'show_in_rest' => array( + 'schema' => array( + 'type' => 'object', + 'properties' => array( + $type => array( 'type' => $type ), + ), + ), + ), + 'default' => (object) array( $type => $default_value ), + ) + ); + + $request = new WP_REST_Request( 'POST', sprintf( '/wp/v2/posts/%d', self::$post_id ) ); + $request->set_body_params( + array( + 'meta' => array( + $meta_key_multiple => array( (object) array( $type => $default_value ) ), + ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( + 200, + $response->get_status(), + "API call should have returned successfully but didn't: check test setup." + ); + + // Objects stored into the database are read back as arrays. + $this->assertSame( + array( array( $type => $default_value ) ), + get_metadata_raw( 'post', self::$post_id, $meta_key_multiple, false ), + 'Should have stored a single meta value with an object representing the default value.' + ); + + $request = new WP_REST_Request( 'POST', sprintf( '/wp/v2/posts/%d', self::$post_id ) ); + $request->set_body_params( + array( + 'meta' => array( + $meta_key_multiple => array( + (object) array( $type => $default_value ), + (object) array( $type => $alternative_value ), + ), + ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( + 200, + $response->get_status(), + "API call should have returned successfully but didn't: check test setup." + ); + + // Objects stored into the database are read back as arrays. + $this->assertSame( + array( array( $type => $default_value ), array( $type => $alternative_value ) ), + get_metadata_raw( 'post', self::$post_id, $meta_key_multiple, false ), + 'Should have stored a single meta value with an object representing the default value.' + ); + } + + /** + * Ensures that REST API calls with post meta containing a list array as the default + * value for the registered meta field stores the default value into the database. + * + * When the default value isn't persisted in the database, a read of the post meta + * at some point in the future might return a different value if the code setting the + * default changed. This ensures that once a value is intentionally saved into the + * database that it will remain durably in future reads. + * + * @ticket 55600 + * + * @dataProvider data_scalar_default_values + * + * @param string $type Scalar type of default value: one of `boolean`, `integer`, `number`, or `string`. + * @param mixed $default_value Appropriate default value for given type. + * @param mixed $alternative_value Ignored in this test. + */ + public function test_array_singular_default_is_saved_to_db( $type, $default_value, $alternative_value ) { + $this->grant_write_permission(); + + $meta_key_single = "with_{$type}_default"; + + // Register singular post meta for type. + register_post_meta( + 'post', + $meta_key_single, + array( + 'type' => 'array', + 'single' => true, + 'show_in_rest' => array( + 'schema' => array( + 'type' => 'array', + 'items' => array( + 'type' => $type, + ), + ), + ), + 'default' => $default_value, + ) + ); + + $request = new WP_REST_Request( 'POST', sprintf( '/wp/v2/posts/%d', self::$post_id ) ); + $request->set_body_params( + array( + 'meta' => array( + $meta_key_single => array( $default_value ), + ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( + 200, + $response->get_status(), + "API call should have returned successfully but didn't: check test setup." + ); + + $this->assertSame( + array( array( $default_value ) ), + get_metadata_raw( 'post', self::$post_id, $meta_key_single, false ), + 'Should have stored a single meta value with an array containing only the default value.' + ); + } + + /** + * Ensures that REST API calls with multi post meta values (containing a list array as + * the default) for the registered meta field stores the default value into the database. + * + * When the default value isn't persisted in the database, a read of the post meta + * at some point in the future might return a different value if the code setting the + * default changed. This ensures that once a value is intentionally saved into the + * database that it will remain durably in future reads. + * + * Further, the total count of stored values may be wrong if the default value + * is culled from the results of a "multi" read. + * + * @ticket 55600 + * + * @dataProvider data_scalar_default_values + * + * @param string $type Scalar type of default value: one of `boolean`, `integer`, `number`, or `string`. + * @param mixed $default_value Appropriate default value for given type. + * @param mixed $alternative_value Appropriate value for given type that doesn't match the default value. + */ + public function test_array_multi_default_is_saved_to_db( $type, $default_value, $alternative_value ) { + $this->grant_write_permission(); + + $meta_key_multiple = "with_multi_{$type}_default"; + + // Register non-singular post meta for type. + register_post_meta( + 'post', + $meta_key_multiple, + array( + 'type' => 'array', + 'single' => false, + 'show_in_rest' => array( + 'schema' => array( + 'type' => 'array', + 'items' => array( + 'type' => $type, + ), + ), + ), + 'default' => $default_value, + ) + ); + + $request = new WP_REST_Request( 'POST', sprintf( '/wp/v2/posts/%d', self::$post_id ) ); + $request->set_body_params( + array( + 'meta' => array( + $meta_key_multiple => array( array( $default_value ) ), + ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( + 200, + $response->get_status(), + "API call should have returned successfully but didn't: check test setup." + ); + + $this->assertSame( + array( array( $default_value ) ), + get_metadata_raw( 'post', self::$post_id, $meta_key_multiple, false ), + 'Should have stored a single meta value with an object representing the default value.' + ); + + $request = new WP_REST_Request( 'POST', sprintf( '/wp/v2/posts/%d', self::$post_id ) ); + $request->set_body_params( + array( + 'meta' => array( + $meta_key_multiple => array( + array( $default_value ), + array( $alternative_value ), + ), + ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( + 200, + $response->get_status(), + "API call should have returned successfully but didn't: check test setup." + ); + + $this->assertSame( + array( array( $default_value ), array( $alternative_value ) ), + get_metadata_raw( 'post', self::$post_id, $meta_key_multiple, false ), + 'Should have stored a single meta value with an object representing the default value.' + ); + } + /** * @ticket 48823 */ @@ -3516,4 +3974,21 @@ public function data_revisioned_single_post_meta_with_posts_endpoint_page_and_cp ), ); } + + /** + * Data provider. + * + * Provides example default values of scalar types; + * in contrast to arrays, objects, etc... + * + * @return array[] + */ + public static function data_scalar_default_values() { + return array( + 'boolean default' => array( 'boolean', true, false ), + 'integer default' => array( 'integer', 42, 43 ), + 'number default' => array( 'number', 42.99, 43.99 ), + 'string default' => array( 'string', 'string', 'string2' ), + ); + } } diff --git a/tests/phpunit/tests/rest-api/rest-schema-setup.php b/tests/phpunit/tests/rest-api/rest-schema-setup.php index 3c5b8a1966b7f..4d5c269357a68 100644 --- a/tests/phpunit/tests/rest-api/rest-schema-setup.php +++ b/tests/phpunit/tests/rest-api/rest-schema-setup.php @@ -729,9 +729,9 @@ public function test_build_wp_api_client_fixtures() { 'TagModel.meta.test_multi' => array(), 'TagModel.meta.test_tag_meta' => '', 'UsersCollection.0.link' => 'http://example.org/?author=1', - 'UsersCollection.0.avatar_urls.24' => 'http://0.gravatar.com/avatar/96614ec98aa0c0d2ee75796dced6df54?s=24&d=mm&r=g', - 'UsersCollection.0.avatar_urls.48' => 'http://0.gravatar.com/avatar/96614ec98aa0c0d2ee75796dced6df54?s=48&d=mm&r=g', - 'UsersCollection.0.avatar_urls.96' => 'http://0.gravatar.com/avatar/96614ec98aa0c0d2ee75796dced6df54?s=96&d=mm&r=g', + 'UsersCollection.0.avatar_urls.24' => 'https://secure.gravatar.com/avatar/96614ec98aa0c0d2ee75796dced6df54?s=24&d=mm&r=g', + 'UsersCollection.0.avatar_urls.48' => 'https://secure.gravatar.com/avatar/96614ec98aa0c0d2ee75796dced6df54?s=48&d=mm&r=g', + 'UsersCollection.0.avatar_urls.96' => 'https://secure.gravatar.com/avatar/96614ec98aa0c0d2ee75796dced6df54?s=96&d=mm&r=g', 'UsersCollection.0._links.self.0.href' => 'http://example.org/index.php?rest_route=/wp/v2/users/1', 'UsersCollection.0._links.collection.0.href' => 'http://example.org/index.php?rest_route=/wp/v2/users', 'UsersCollection.1.id' => 2, diff --git a/tests/phpunit/tests/rest-api/rest-sidebars-controller.php b/tests/phpunit/tests/rest-api/rest-sidebars-controller.php index 0bddf12df92b9..67a49770dbc86 100644 --- a/tests/phpunit/tests/rest-api/rest-sidebars-controller.php +++ b/tests/phpunit/tests/rest-api/rest-sidebars-controller.php @@ -43,8 +43,8 @@ public static function wpSetUpBeforeClass( $factory ) { } public static function wpTearDownAfterClass() { - wp_delete_user( self::$admin_id ); - wp_delete_user( self::$author_id ); + self::delete_user( self::$admin_id ); + self::delete_user( self::$author_id ); } public function set_up() { diff --git a/tests/phpunit/tests/style-engine/styleEngine.php b/tests/phpunit/tests/style-engine/styleEngine.php index 9092ce5b6df03..686865c6803ab 100644 --- a/tests/phpunit/tests/style-engine/styleEngine.php +++ b/tests/phpunit/tests/style-engine/styleEngine.php @@ -28,6 +28,7 @@ public function tear_down() { * @ticket 58549 * @ticket 58590 * @ticket 60175 + * @ticket 61720 * * @covers ::wp_style_engine_get_styles * @@ -539,22 +540,24 @@ public function data_wp_style_engine_get_styles() { 'inline_background_image_url_with_background_size' => array( 'block_styles' => array( 'background' => array( - 'backgroundImage' => array( + 'backgroundImage' => array( 'url' => 'https://example.com/image.jpg', ), - 'backgroundPosition' => 'center', - 'backgroundRepeat' => 'no-repeat', - 'backgroundSize' => 'cover', + 'backgroundPosition' => 'center', + 'backgroundRepeat' => 'no-repeat', + 'backgroundSize' => 'cover', + 'backgroundAttachment' => 'fixed', ), ), 'options' => array(), 'expected_output' => array( - 'css' => "background-image:url('https://example.com/image.jpg');background-position:center;background-repeat:no-repeat;background-size:cover;", + 'css' => "background-image:url('https://example.com/image.jpg');background-position:center;background-repeat:no-repeat;background-size:cover;background-attachment:fixed;", 'declarations' => array( - 'background-image' => "url('https://example.com/image.jpg')", - 'background-position' => 'center', - 'background-repeat' => 'no-repeat', - 'background-size' => 'cover', + 'background-image' => "url('https://example.com/image.jpg')", + 'background-position' => 'center', + 'background-repeat' => 'no-repeat', + 'background-size' => 'cover', + 'background-attachment' => 'fixed', ), ), ), diff --git a/tests/phpunit/tests/term/cache.php b/tests/phpunit/tests/term/cache.php index 0651551f941e9..f299bf9bed643 100644 --- a/tests/phpunit/tests/term/cache.php +++ b/tests/phpunit/tests/term/cache.php @@ -116,9 +116,6 @@ public function test_get_term_should_update_term_cache_when_passed_an_object() { $num_queries = get_num_queries(); - // get_term() will only be update the cache if the 'filter' prop is unset. - unset( $term_object->filter ); - $term_object_2 = get_term( $term_object, 'wptests_tax' ); // No new queries should have fired. diff --git a/tests/phpunit/tests/term/getTerm.php b/tests/phpunit/tests/term/getTerm.php index ed6acab691fc5..a72ebca40ad40 100644 --- a/tests/phpunit/tests/term/getTerm.php +++ b/tests/phpunit/tests/term/getTerm.php @@ -98,7 +98,6 @@ public function test_passing_term_object_should_skip_database_query_when_filter_ $num_queries = get_num_queries(); - unset( $term->filter ); $term_a = get_term( $term, 'wptests_tax' ); $this->assertSame( $num_queries, get_num_queries() ); diff --git a/tests/phpunit/tests/term/wpInsertTerm.php b/tests/phpunit/tests/term/wpInsertTerm.php index 798db9233bfc8..0bf95b9a10279 100644 --- a/tests/phpunit/tests/term/wpInsertTerm.php +++ b/tests/phpunit/tests/term/wpInsertTerm.php @@ -186,7 +186,6 @@ public function test_wp_insert_term_slug_0() { public function test_wp_insert_term_duplicate_name() { $term = self::factory()->tag->create_and_get( array( 'name' => 'Bozo' ) ); $this->assertNotWPError( $term ); - $this->assertEmpty( $term->errors ); // Test existing term name with unique slug. $term1 = self::factory()->tag->create( diff --git a/tests/phpunit/tests/theme/wpThemeJson.php b/tests/phpunit/tests/theme/wpThemeJson.php index 68b5cd9aaf7d9..2de327dd7c47e 100644 --- a/tests/phpunit/tests/theme/wpThemeJson.php +++ b/tests/phpunit/tests/theme/wpThemeJson.php @@ -396,6 +396,7 @@ public function test_get_settings_appearance_false_does_not_opt_in() { * @ticket 60936 * @ticket 61165 * @ticket 61630 + * @ticket 61704 */ public function test_get_stylesheet() { $theme_json = new WP_Theme_JSON( @@ -566,7 +567,7 @@ public function test_get_stylesheet() { ); $variables = ':root{--wp--preset--color--grey: grey;--wp--preset--gradient--custom-gradient: linear-gradient(135deg,rgba(0,0,0) 0%,rgb(0,0,0) 100%);--wp--preset--font-size--small: 14px;--wp--preset--font-size--big: 41px;--wp--preset--font-family--arial: Arial, serif;}.wp-block-group{--wp--custom--base-font: 16;--wp--custom--line-height--small: 1.2;--wp--custom--line-height--medium: 1.4;--wp--custom--line-height--large: 1.8;}'; - $styles = ':where(body) { margin: 0; }.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }:where(.is-layout-flex){gap: 0.5em;}:where(.is-layout-grid){gap: 0.5em;}.is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}.is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}.is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}.is-layout-constrained > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}.is-layout-constrained > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}.is-layout-constrained > .aligncenter{margin-left: auto !important;margin-right: auto !important;}.is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)){margin-left: auto !important;margin-right: auto !important;}body .is-layout-flex{display: flex;}.is-layout-flex{flex-wrap: wrap;align-items: center;}.is-layout-flex > :is(*, div){margin: 0;}body .is-layout-grid{display: grid;}.is-layout-grid > :is(*, div){margin: 0;}:root :where(body){color: var(--wp--preset--color--grey);}a:where(:not(.wp-element-button)){background-color: #333;color: #111;}:root :where(.wp-element-button, .wp-block-button__link){box-shadow: 10px 10px 5px 0px rgba(0,0,0,0.66);}:root :where(.wp-block-cover){min-height: unset;aspect-ratio: 16/9;}:root :where(.wp-block-group){background: var(--wp--preset--gradient--custom-gradient);border-radius: 10px;min-height: 50vh;padding: 24px;}:root :where(.wp-block-group a:where(:not(.wp-element-button))){color: #111;}:root :where(.wp-block-heading){color: #123456;}:root :where(.wp-block-heading a:where(:not(.wp-element-button))){background-color: #333;color: #111;font-size: 60px;}:root :where(.wp-block-media-text){text-align: center;}:root :where(.wp-block-post-date){color: #123456;}:root :where(.wp-block-post-date a:where(:not(.wp-element-button))){background-color: #777;color: #555;}:root :where(.wp-block-post-excerpt){column-count: 2;}:root :where(.wp-block-image){margin-bottom: 30px;}:root :where(.wp-block-image img, .wp-block-image .wp-block-image__crop-area, .wp-block-image .components-placeholder){border-top-left-radius: 10px;border-bottom-right-radius: 1em;}:root :where(.wp-block-image img, .wp-block-image .components-placeholder){filter: var(--wp--preset--duotone--custom-duotone);}'; + $styles = ':where(body) { margin: 0; }.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }:where(.is-layout-flex){gap: 0.5em;}:where(.is-layout-grid){gap: 0.5em;}.is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}.is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}.is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}.is-layout-constrained > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}.is-layout-constrained > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}.is-layout-constrained > .aligncenter{margin-left: auto !important;margin-right: auto !important;}.is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)){margin-left: auto !important;margin-right: auto !important;}body .is-layout-flex{display: flex;}.is-layout-flex{flex-wrap: wrap;align-items: center;}.is-layout-flex > :is(*, div){margin: 0;}body .is-layout-grid{display: grid;}.is-layout-grid > :is(*, div){margin: 0;}body{color: var(--wp--preset--color--grey);}a:where(:not(.wp-element-button)){background-color: #333;color: #111;}:root :where(.wp-element-button, .wp-block-button__link){box-shadow: 10px 10px 5px 0px rgba(0,0,0,0.66);}:root :where(.wp-block-cover){min-height: unset;aspect-ratio: 16/9;}:root :where(.wp-block-group){background: var(--wp--preset--gradient--custom-gradient);border-radius: 10px;min-height: 50vh;padding: 24px;}:root :where(.wp-block-group a:where(:not(.wp-element-button))){color: #111;}:root :where(.wp-block-heading){color: #123456;}:root :where(.wp-block-heading a:where(:not(.wp-element-button))){background-color: #333;color: #111;font-size: 60px;}:root :where(.wp-block-media-text){text-align: center;}:root :where(.wp-block-post-date){color: #123456;}:root :where(.wp-block-post-date a:where(:not(.wp-element-button))){background-color: #777;color: #555;}:root :where(.wp-block-post-excerpt){column-count: 2;}:root :where(.wp-block-image){margin-bottom: 30px;}:root :where(.wp-block-image img, .wp-block-image .wp-block-image__crop-area, .wp-block-image .components-placeholder){border-top-left-radius: 10px;border-bottom-right-radius: 1em;}:root :where(.wp-block-image img, .wp-block-image .components-placeholder){filter: var(--wp--preset--duotone--custom-duotone);}'; $presets = '.has-grey-color{color: var(--wp--preset--color--grey) !important;}.has-grey-background-color{background-color: var(--wp--preset--color--grey) !important;}.has-grey-border-color{border-color: var(--wp--preset--color--grey) !important;}.has-custom-gradient-gradient-background{background: var(--wp--preset--gradient--custom-gradient) !important;}.has-small-font-size{font-size: var(--wp--preset--font-size--small) !important;}.has-big-font-size{font-size: var(--wp--preset--font-size--big) !important;}.has-arial-font-family{font-family: var(--wp--preset--font-family--arial) !important;}'; $all = $variables . $styles . $presets; @@ -683,6 +684,7 @@ public function test_get_stylesheet_skips_disabled_protected_properties() { * @ticket 58550 * @ticket 60936 * @ticket 61165 + * @ticket 61829 */ public function test_get_stylesheet_renders_enabled_protected_properties() { $theme_json = new WP_Theme_JSON( @@ -701,7 +703,7 @@ public function test_get_stylesheet_renders_enabled_protected_properties() { ) ); - $expected = ':where(body) { margin: 0; }.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }:where(.wp-site-blocks) > * { margin-block-start: 1em; margin-block-end: 0; }:where(.wp-site-blocks) > :first-child { margin-block-start: 0; }:where(.wp-site-blocks) > :last-child { margin-block-end: 0; }:root { --wp--style--block-gap: 1em; }.is-layout-flow > :first-child{margin-block-start: 0;}.is-layout-flow > :last-child{margin-block-end: 0;}.is-layout-flow > *{margin-block-start: 1em;margin-block-end: 0;}.is-layout-constrained > :first-child{margin-block-start: 0;}.is-layout-constrained > :last-child{margin-block-end: 0;}.is-layout-constrained > *{margin-block-start: 1em;margin-block-end: 0;}.is-layout-flex {gap: 1em;}.is-layout-grid {gap: 1em;}.is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}.is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}.is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}.is-layout-constrained > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}.is-layout-constrained > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}.is-layout-constrained > .aligncenter{margin-left: auto !important;margin-right: auto !important;}.is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)){margin-left: auto !important;margin-right: auto !important;}body .is-layout-flex{display: flex;}.is-layout-flex{flex-wrap: wrap;align-items: center;}.is-layout-flex > :is(*, div){margin: 0;}body .is-layout-grid{display: grid;}.is-layout-grid > :is(*, div){margin: 0;}'; + $expected = ':where(body) { margin: 0; }.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }:where(.wp-site-blocks) > * { margin-block-start: 1em; margin-block-end: 0; }:where(.wp-site-blocks) > :first-child { margin-block-start: 0; }:where(.wp-site-blocks) > :last-child { margin-block-end: 0; }:root { --wp--style--block-gap: 1em; }:root :where(.is-layout-flow) > :first-child{margin-block-start: 0;}:root :where(.is-layout-flow) > :last-child{margin-block-end: 0;}:root :where(.is-layout-flow) > *{margin-block-start: 1em;margin-block-end: 0;}:root :where(.is-layout-constrained) > :first-child{margin-block-start: 0;}:root :where(.is-layout-constrained) > :last-child{margin-block-end: 0;}:root :where(.is-layout-constrained) > *{margin-block-start: 1em;margin-block-end: 0;}:root :where(.is-layout-flex){gap: 1em;}:root :where(.is-layout-grid){gap: 1em;}.is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}.is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}.is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}.is-layout-constrained > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}.is-layout-constrained > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}.is-layout-constrained > .aligncenter{margin-left: auto !important;margin-right: auto !important;}.is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)){margin-left: auto !important;margin-right: auto !important;}body .is-layout-flex{display: flex;}.is-layout-flex{flex-wrap: wrap;align-items: center;}.is-layout-flex > :is(*, div){margin: 0;}body .is-layout-grid{display: grid;}.is-layout-grid > :is(*, div){margin: 0;}'; $this->assertSame( $expected, $theme_json->get_stylesheet() ); $this->assertSame( $expected, $theme_json->get_stylesheet( array( 'styles' ) ) ); } @@ -1076,6 +1078,7 @@ public function test_get_stylesheet_handles_priority_of_elements_vs_block_elemen * @ticket 58550 * @ticket 60936 * @ticket 61165 + * @ticket 61829 */ public function test_get_stylesheet_generates_layout_styles() { $theme_json = new WP_Theme_JSON( @@ -1101,7 +1104,7 @@ public function test_get_stylesheet_generates_layout_styles() { // Results also include root site blocks styles. $this->assertSame( - ':root { --wp--style--global--content-size: 640px;--wp--style--global--wide-size: 1200px; }:where(body) { margin: 0; }.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }:where(.wp-site-blocks) > * { margin-block-start: 1em; margin-block-end: 0; }:where(.wp-site-blocks) > :first-child { margin-block-start: 0; }:where(.wp-site-blocks) > :last-child { margin-block-end: 0; }:root { --wp--style--block-gap: 1em; }.is-layout-flow > :first-child{margin-block-start: 0;}.is-layout-flow > :last-child{margin-block-end: 0;}.is-layout-flow > *{margin-block-start: 1em;margin-block-end: 0;}.is-layout-constrained > :first-child{margin-block-start: 0;}.is-layout-constrained > :last-child{margin-block-end: 0;}.is-layout-constrained > *{margin-block-start: 1em;margin-block-end: 0;}.is-layout-flex {gap: 1em;}.is-layout-grid {gap: 1em;}.is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}.is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}.is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}.is-layout-constrained > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}.is-layout-constrained > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}.is-layout-constrained > .aligncenter{margin-left: auto !important;margin-right: auto !important;}.is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)){max-width: var(--wp--style--global--content-size);margin-left: auto !important;margin-right: auto !important;}.is-layout-constrained > .alignwide{max-width: var(--wp--style--global--wide-size);}body .is-layout-flex{display: flex;}.is-layout-flex{flex-wrap: wrap;align-items: center;}.is-layout-flex > :is(*, div){margin: 0;}body .is-layout-grid{display: grid;}.is-layout-grid > :is(*, div){margin: 0;}', + ':root { --wp--style--global--content-size: 640px;--wp--style--global--wide-size: 1200px; }:where(body) { margin: 0; }.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }:where(.wp-site-blocks) > * { margin-block-start: 1em; margin-block-end: 0; }:where(.wp-site-blocks) > :first-child { margin-block-start: 0; }:where(.wp-site-blocks) > :last-child { margin-block-end: 0; }:root { --wp--style--block-gap: 1em; }:root :where(.is-layout-flow) > :first-child{margin-block-start: 0;}:root :where(.is-layout-flow) > :last-child{margin-block-end: 0;}:root :where(.is-layout-flow) > *{margin-block-start: 1em;margin-block-end: 0;}:root :where(.is-layout-constrained) > :first-child{margin-block-start: 0;}:root :where(.is-layout-constrained) > :last-child{margin-block-end: 0;}:root :where(.is-layout-constrained) > *{margin-block-start: 1em;margin-block-end: 0;}:root :where(.is-layout-flex){gap: 1em;}:root :where(.is-layout-grid){gap: 1em;}.is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}.is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}.is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}.is-layout-constrained > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}.is-layout-constrained > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}.is-layout-constrained > .aligncenter{margin-left: auto !important;margin-right: auto !important;}.is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)){max-width: var(--wp--style--global--content-size);margin-left: auto !important;margin-right: auto !important;}.is-layout-constrained > .alignwide{max-width: var(--wp--style--global--wide-size);}body .is-layout-flex{display: flex;}.is-layout-flex{flex-wrap: wrap;align-items: center;}.is-layout-flex > :is(*, div){margin: 0;}body .is-layout-grid{display: grid;}.is-layout-grid > :is(*, div){margin: 0;}', $theme_json->get_stylesheet( array( 'styles' ) ) ); } @@ -1112,6 +1115,7 @@ public function test_get_stylesheet_generates_layout_styles() { * @ticket 58550 * @ticket 60936 * @ticket 61165 + * @ticket 61829 */ public function test_get_stylesheet_generates_layout_styles_with_spacing_presets() { $theme_json = new WP_Theme_JSON( @@ -1137,7 +1141,7 @@ public function test_get_stylesheet_generates_layout_styles_with_spacing_presets // Results also include root site blocks styles. $this->assertSame( - ':root { --wp--style--global--content-size: 640px;--wp--style--global--wide-size: 1200px; }:where(body) { margin: 0; }.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }:where(.wp-site-blocks) > * { margin-block-start: var(--wp--preset--spacing--60); margin-block-end: 0; }:where(.wp-site-blocks) > :first-child { margin-block-start: 0; }:where(.wp-site-blocks) > :last-child { margin-block-end: 0; }:root { --wp--style--block-gap: var(--wp--preset--spacing--60); }.is-layout-flow > :first-child{margin-block-start: 0;}.is-layout-flow > :last-child{margin-block-end: 0;}.is-layout-flow > *{margin-block-start: var(--wp--preset--spacing--60);margin-block-end: 0;}.is-layout-constrained > :first-child{margin-block-start: 0;}.is-layout-constrained > :last-child{margin-block-end: 0;}.is-layout-constrained > *{margin-block-start: var(--wp--preset--spacing--60);margin-block-end: 0;}.is-layout-flex {gap: var(--wp--preset--spacing--60);}.is-layout-grid {gap: var(--wp--preset--spacing--60);}.is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}.is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}.is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}.is-layout-constrained > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}.is-layout-constrained > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}.is-layout-constrained > .aligncenter{margin-left: auto !important;margin-right: auto !important;}.is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)){max-width: var(--wp--style--global--content-size);margin-left: auto !important;margin-right: auto !important;}.is-layout-constrained > .alignwide{max-width: var(--wp--style--global--wide-size);}body .is-layout-flex{display: flex;}.is-layout-flex{flex-wrap: wrap;align-items: center;}.is-layout-flex > :is(*, div){margin: 0;}body .is-layout-grid{display: grid;}.is-layout-grid > :is(*, div){margin: 0;}', + ':root { --wp--style--global--content-size: 640px;--wp--style--global--wide-size: 1200px; }:where(body) { margin: 0; }.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }:where(.wp-site-blocks) > * { margin-block-start: var(--wp--preset--spacing--60); margin-block-end: 0; }:where(.wp-site-blocks) > :first-child { margin-block-start: 0; }:where(.wp-site-blocks) > :last-child { margin-block-end: 0; }:root { --wp--style--block-gap: var(--wp--preset--spacing--60); }:root :where(.is-layout-flow) > :first-child{margin-block-start: 0;}:root :where(.is-layout-flow) > :last-child{margin-block-end: 0;}:root :where(.is-layout-flow) > *{margin-block-start: var(--wp--preset--spacing--60);margin-block-end: 0;}:root :where(.is-layout-constrained) > :first-child{margin-block-start: 0;}:root :where(.is-layout-constrained) > :last-child{margin-block-end: 0;}:root :where(.is-layout-constrained) > *{margin-block-start: var(--wp--preset--spacing--60);margin-block-end: 0;}:root :where(.is-layout-flex){gap: var(--wp--preset--spacing--60);}:root :where(.is-layout-grid){gap: var(--wp--preset--spacing--60);}.is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}.is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}.is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}.is-layout-constrained > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}.is-layout-constrained > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}.is-layout-constrained > .aligncenter{margin-left: auto !important;margin-right: auto !important;}.is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)){max-width: var(--wp--style--global--content-size);margin-left: auto !important;margin-right: auto !important;}.is-layout-constrained > .alignwide{max-width: var(--wp--style--global--wide-size);}body .is-layout-flex{display: flex;}.is-layout-flex{flex-wrap: wrap;align-items: center;}.is-layout-flex > :is(*, div){margin: 0;}body .is-layout-grid{display: grid;}.is-layout-grid > :is(*, div){margin: 0;}', $theme_json->get_stylesheet( array( 'styles' ) ) ); } @@ -1238,6 +1242,7 @@ public function test_get_stylesheet_skips_layout_styles() { * @ticket 58550 * @ticket 60936 * @ticket 61165 + * @ticket 61829 */ public function test_get_stylesheet_generates_valid_block_gap_values_and_skips_null_or_false_values() { $theme_json = new WP_Theme_JSON( @@ -1284,7 +1289,7 @@ public function test_get_stylesheet_generates_valid_block_gap_values_and_skips_n ); $this->assertSame( - ':root { --wp--style--global--content-size: 640px;--wp--style--global--wide-size: 1200px; }:where(body) { margin: 0; }.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }:where(.wp-site-blocks) > * { margin-block-start: 1rem; margin-block-end: 0; }:where(.wp-site-blocks) > :first-child { margin-block-start: 0; }:where(.wp-site-blocks) > :last-child { margin-block-end: 0; }:root { --wp--style--block-gap: 1rem; }.is-layout-flow > :first-child{margin-block-start: 0;}.is-layout-flow > :last-child{margin-block-end: 0;}.is-layout-flow > *{margin-block-start: 1rem;margin-block-end: 0;}.is-layout-constrained > :first-child{margin-block-start: 0;}.is-layout-constrained > :last-child{margin-block-end: 0;}.is-layout-constrained > *{margin-block-start: 1rem;margin-block-end: 0;}.is-layout-flex {gap: 1rem;}.is-layout-grid {gap: 1rem;}.is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}.is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}.is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}.is-layout-constrained > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}.is-layout-constrained > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}.is-layout-constrained > .aligncenter{margin-left: auto !important;margin-right: auto !important;}.is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)){max-width: var(--wp--style--global--content-size);margin-left: auto !important;margin-right: auto !important;}.is-layout-constrained > .alignwide{max-width: var(--wp--style--global--wide-size);}body .is-layout-flex{display: flex;}.is-layout-flex{flex-wrap: wrap;align-items: center;}.is-layout-flex > :is(*, div){margin: 0;}body .is-layout-grid{display: grid;}.is-layout-grid > :is(*, div){margin: 0;}:root :where(.wp-block-post-content){color: gray;}.wp-block-social-links-is-layout-flow > :first-child{margin-block-start: 0;}.wp-block-social-links-is-layout-flow > :last-child{margin-block-end: 0;}.wp-block-social-links-is-layout-flow > *{margin-block-start: 0;margin-block-end: 0;}.wp-block-social-links-is-layout-constrained > :first-child{margin-block-start: 0;}.wp-block-social-links-is-layout-constrained > :last-child{margin-block-end: 0;}.wp-block-social-links-is-layout-constrained > *{margin-block-start: 0;margin-block-end: 0;}.wp-block-social-links-is-layout-flex {gap: 0;}.wp-block-social-links-is-layout-grid {gap: 0;}.wp-block-buttons-is-layout-flow > :first-child{margin-block-start: 0;}.wp-block-buttons-is-layout-flow > :last-child{margin-block-end: 0;}.wp-block-buttons-is-layout-flow > *{margin-block-start: 0;margin-block-end: 0;}.wp-block-buttons-is-layout-constrained > :first-child{margin-block-start: 0;}.wp-block-buttons-is-layout-constrained > :last-child{margin-block-end: 0;}.wp-block-buttons-is-layout-constrained > *{margin-block-start: 0;margin-block-end: 0;}.wp-block-buttons-is-layout-flex {gap: 0;}.wp-block-buttons-is-layout-grid {gap: 0;}', + ':root { --wp--style--global--content-size: 640px;--wp--style--global--wide-size: 1200px; }:where(body) { margin: 0; }.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }:where(.wp-site-blocks) > * { margin-block-start: 1rem; margin-block-end: 0; }:where(.wp-site-blocks) > :first-child { margin-block-start: 0; }:where(.wp-site-blocks) > :last-child { margin-block-end: 0; }:root { --wp--style--block-gap: 1rem; }:root :where(.is-layout-flow) > :first-child{margin-block-start: 0;}:root :where(.is-layout-flow) > :last-child{margin-block-end: 0;}:root :where(.is-layout-flow) > *{margin-block-start: 1rem;margin-block-end: 0;}:root :where(.is-layout-constrained) > :first-child{margin-block-start: 0;}:root :where(.is-layout-constrained) > :last-child{margin-block-end: 0;}:root :where(.is-layout-constrained) > *{margin-block-start: 1rem;margin-block-end: 0;}:root :where(.is-layout-flex){gap: 1rem;}:root :where(.is-layout-grid){gap: 1rem;}.is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}.is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}.is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}.is-layout-constrained > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}.is-layout-constrained > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}.is-layout-constrained > .aligncenter{margin-left: auto !important;margin-right: auto !important;}.is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)){max-width: var(--wp--style--global--content-size);margin-left: auto !important;margin-right: auto !important;}.is-layout-constrained > .alignwide{max-width: var(--wp--style--global--wide-size);}body .is-layout-flex{display: flex;}.is-layout-flex{flex-wrap: wrap;align-items: center;}.is-layout-flex > :is(*, div){margin: 0;}body .is-layout-grid{display: grid;}.is-layout-grid > :is(*, div){margin: 0;}:root :where(.wp-block-post-content){color: gray;}:root :where(.wp-block-social-links-is-layout-flow) > :first-child{margin-block-start: 0;}:root :where(.wp-block-social-links-is-layout-flow) > :last-child{margin-block-end: 0;}:root :where(.wp-block-social-links-is-layout-flow) > *{margin-block-start: 0;margin-block-end: 0;}:root :where(.wp-block-social-links-is-layout-constrained) > :first-child{margin-block-start: 0;}:root :where(.wp-block-social-links-is-layout-constrained) > :last-child{margin-block-end: 0;}:root :where(.wp-block-social-links-is-layout-constrained) > *{margin-block-start: 0;margin-block-end: 0;}:root :where(.wp-block-social-links-is-layout-flex){gap: 0;}:root :where(.wp-block-social-links-is-layout-grid){gap: 0;}:root :where(.wp-block-buttons-is-layout-flow) > :first-child{margin-block-start: 0;}:root :where(.wp-block-buttons-is-layout-flow) > :last-child{margin-block-end: 0;}:root :where(.wp-block-buttons-is-layout-flow) > *{margin-block-start: 0;margin-block-end: 0;}:root :where(.wp-block-buttons-is-layout-constrained) > :first-child{margin-block-start: 0;}:root :where(.wp-block-buttons-is-layout-constrained) > :last-child{margin-block-end: 0;}:root :where(.wp-block-buttons-is-layout-constrained) > *{margin-block-start: 0;margin-block-end: 0;}:root :where(.wp-block-buttons-is-layout-flex){gap: 0;}:root :where(.wp-block-buttons-is-layout-grid){gap: 0;}', $theme_json->get_stylesheet() ); } @@ -1365,6 +1370,7 @@ public function test_get_stylesheet_custom_root_selector() { * @ticket 61118 * @ticket 61165 * @ticket 61630 + * @ticket 61704 */ public function test_get_stylesheet_generates_fluid_typography_values() { register_block_type( @@ -1419,7 +1425,7 @@ public function test_get_stylesheet_generates_fluid_typography_values() { unregister_block_type( 'test/clamp-me' ); $this->assertSame( - ':root{--wp--preset--font-size--pickles: clamp(14px, 0.875rem + ((1vw - 3.2px) * 0.156), 16px);--wp--preset--font-size--toast: clamp(14.642px, 0.915rem + ((1vw - 3.2px) * 0.575), 22px);}:root :where(body){font-size: clamp(0.875em, 0.875rem + ((1vw - 0.2em) * 0.156), 1em);}h1{font-size: clamp(50.171px, 3.136rem + ((1vw - 3.2px) * 3.893), 100px);}:root :where(.wp-block-test-clamp-me){font-size: clamp(27.894px, 1.743rem + ((1vw - 3.2px) * 1.571), 48px);}.has-pickles-font-size{font-size: var(--wp--preset--font-size--pickles) !important;}.has-toast-font-size{font-size: var(--wp--preset--font-size--toast) !important;}', + ':root{--wp--preset--font-size--pickles: clamp(14px, 0.875rem + ((1vw - 3.2px) * 0.156), 16px);--wp--preset--font-size--toast: clamp(14.642px, 0.915rem + ((1vw - 3.2px) * 0.575), 22px);}body{font-size: clamp(0.875em, 0.875rem + ((1vw - 0.2em) * 0.156), 1em);}h1{font-size: clamp(50.171px, 3.136rem + ((1vw - 3.2px) * 3.893), 100px);}:root :where(.wp-block-test-clamp-me){font-size: clamp(27.894px, 1.743rem + ((1vw - 3.2px) * 1.571), 48px);}.has-pickles-font-size{font-size: var(--wp--preset--font-size--pickles) !important;}.has-toast-font-size{font-size: var(--wp--preset--font-size--toast) !important;}', $theme_json->get_stylesheet( array( 'styles', 'variables', 'presets' ), null, array( 'skip_root_layout_styles' => true ) ) ); } @@ -2328,6 +2334,115 @@ public function test_merge_incoming_data_presets_use_default_names() { $this->assertSameSetsWithIndex( $expected, $actual ); } + /** + * @ticket 61858 + */ + public function test_merge_incoming_background_styles() { + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'background' => array( + 'backgroundImage' => array( + 'url' => 'http://example.org/quote.png', + ), + 'backgroundSize' => 'cover', + ), + 'blocks' => array( + 'core/group' => array( + 'background' => array( + 'backgroundImage' => array( + 'ref' => 'styles.blocks.core/verse.background.backgroundImage', + ), + 'backgroundAttachment' => 'fixed', + ), + ), + 'core/quote' => array( + 'background' => array( + 'backgroundImage' => array( + 'url' => 'http://example.org/quote.png', + ), + 'backgroundAttachment' => array( + 'ref' => 'styles.blocks.core/group.background.backgroundAttachment', + ), + ), + ), + ), + ), + ) + ); + + $update_background_image_styles = array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'background' => array( + 'backgroundSize' => 'contain', + ), + 'blocks' => array( + 'core/group' => array( + 'background' => array( + 'backgroundImage' => array( + 'url' => 'http://example.org/group.png', + ), + ), + ), + 'core/quote' => array( + 'background' => array( + 'backgroundAttachment' => 'fixed', + ), + ), + 'core/verse' => array( + 'background' => array( + 'backgroundImage' => array( + 'ref' => 'styles.blocks.core/group.background.backgroundImage', + ), + ), + ), + ), + ), + ); + $expected = array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'background' => array( + 'backgroundImage' => array( + 'url' => 'http://example.org/quote.png', + ), + 'backgroundSize' => 'contain', + ), + 'blocks' => array( + 'core/group' => array( + 'background' => array( + 'backgroundImage' => array( + 'url' => 'http://example.org/group.png', + ), + 'backgroundAttachment' => 'fixed', + ), + ), + 'core/quote' => array( + 'background' => array( + 'backgroundImage' => array( + 'url' => 'http://example.org/quote.png', + ), + 'backgroundAttachment' => 'fixed', + ), + ), + 'core/verse' => array( + 'background' => array( + 'backgroundImage' => array( + 'ref' => 'styles.blocks.core/group.background.backgroundImage', + ), + ), + ), + ), + ), + ); + $theme_json->merge( new WP_Theme_JSON( $update_background_image_styles ) ); + $actual = $theme_json->get_raw_data(); + + $this->assertEqualSetsWithIndex( $expected, $actual ); + } + /** * @ticket 54336 */ @@ -3548,6 +3663,7 @@ public function test_get_element_class_name_invalid() { * @ticket 58550 * @ticket 60936 * @ticket 61165 + * @ticket 61704 */ public function test_get_property_value_valid() { $theme_json = new WP_Theme_JSON( @@ -3570,7 +3686,7 @@ public function test_get_property_value_valid() { ) ); - $expected = ':root :where(body){background-color: #ffffff;color: #000000;}:root :where(.wp-element-button, .wp-block-button__link){background-color: #000000;color: #ffffff;}'; + $expected = 'body{background-color: #ffffff;color: #000000;}:root :where(.wp-element-button, .wp-block-button__link){background-color: #000000;color: #ffffff;}'; $this->assertSame( $expected, $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) ) ); } @@ -3630,6 +3746,7 @@ public function data_get_property_value_should_return_string_for_invalid_paths_o * @ticket 58550 * @ticket 60936 * @ticket 61165 + * @ticket 61704 * @expectedIncorrectUsage get_property_value */ public function test_get_property_value_loop() { @@ -3653,7 +3770,7 @@ public function test_get_property_value_loop() { ) ); - $expected = ':root :where(body){background-color: #ffffff;}:root :where(.wp-element-button, .wp-block-button__link){color: #ffffff;}'; + $expected = 'body{background-color: #ffffff;}:root :where(.wp-element-button, .wp-block-button__link){color: #ffffff;}'; $this->assertSame( $expected, $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) ) ); } @@ -3666,6 +3783,7 @@ public function test_get_property_value_loop() { * @ticket 58550 * @ticket 60936 * @ticket 61165 + * @ticket 61704 * @expectedIncorrectUsage get_property_value */ public function test_get_property_value_recursion() { @@ -3689,7 +3807,7 @@ public function test_get_property_value_recursion() { ) ); - $expected = ':root :where(body){background-color: #ffffff;color: #ffffff;}:root :where(.wp-element-button, .wp-block-button__link){color: #ffffff;}'; + $expected = 'body{background-color: #ffffff;color: #ffffff;}:root :where(.wp-element-button, .wp-block-button__link){color: #ffffff;}'; $this->assertSame( $expected, $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) ) ); } @@ -3701,6 +3819,7 @@ public function test_get_property_value_recursion() { * @ticket 58550 * @ticket 60936 * @ticket 61165 + * @ticket 61704 * @expectedIncorrectUsage get_property_value */ public function test_get_property_value_self() { @@ -3716,7 +3835,7 @@ public function test_get_property_value_self() { ) ); - $expected = ':root :where(body){background-color: #ffffff;}'; + $expected = 'body{background-color: #ffffff;}'; $this->assertSame( $expected, $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) ) ); } @@ -3726,6 +3845,7 @@ public function test_get_property_value_self() { * @ticket 60936 * @ticket 61304 * @ticket 61165 + * @ticket 61704 */ public function test_get_styles_for_block_with_padding_aware_alignments() { $theme_json = new WP_Theme_JSON( @@ -3752,7 +3872,7 @@ public function test_get_styles_for_block_with_padding_aware_alignments() { 'selector' => 'body', ); - $expected = ':where(body) { margin: 0; }.wp-site-blocks { padding-top: var(--wp--style--root--padding-top); padding-bottom: var(--wp--style--root--padding-bottom); }.has-global-padding { padding-right: var(--wp--style--root--padding-right); padding-left: var(--wp--style--root--padding-left); }.has-global-padding > .alignfull { margin-right: calc(var(--wp--style--root--padding-right) * -1); margin-left: calc(var(--wp--style--root--padding-left) * -1); }.has-global-padding :where(:not(.alignfull.is-layout-flow) > .has-global-padding:not(.wp-block-block, .alignfull)) { padding-right: 0; padding-left: 0; }.has-global-padding :where(:not(.alignfull.is-layout-flow) > .has-global-padding:not(.wp-block-block, .alignfull)) > .alignfull { margin-left: 0; margin-right: 0; }.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }:where(.is-layout-flex){gap: 0.5em;}:where(.is-layout-grid){gap: 0.5em;}.is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}.is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}.is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}.is-layout-constrained > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}.is-layout-constrained > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}.is-layout-constrained > .aligncenter{margin-left: auto !important;margin-right: auto !important;}.is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)){margin-left: auto !important;margin-right: auto !important;}body .is-layout-flex{display: flex;}.is-layout-flex{flex-wrap: wrap;align-items: center;}.is-layout-flex > :is(*, div){margin: 0;}body .is-layout-grid{display: grid;}.is-layout-grid > :is(*, div){margin: 0;}:root :where(body){--wp--style--root--padding-top: 10px;--wp--style--root--padding-right: 12px;--wp--style--root--padding-bottom: 10px;--wp--style--root--padding-left: 12px;}'; + $expected = ':where(body) { margin: 0; }.wp-site-blocks { padding-top: var(--wp--style--root--padding-top); padding-bottom: var(--wp--style--root--padding-bottom); }.has-global-padding { padding-right: var(--wp--style--root--padding-right); padding-left: var(--wp--style--root--padding-left); }.has-global-padding > .alignfull { margin-right: calc(var(--wp--style--root--padding-right) * -1); margin-left: calc(var(--wp--style--root--padding-left) * -1); }.has-global-padding :where(:not(.alignfull.is-layout-flow) > .has-global-padding:not(.wp-block-block, .alignfull)) { padding-right: 0; padding-left: 0; }.has-global-padding :where(:not(.alignfull.is-layout-flow) > .has-global-padding:not(.wp-block-block, .alignfull)) > .alignfull { margin-left: 0; margin-right: 0; }.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }:where(.is-layout-flex){gap: 0.5em;}:where(.is-layout-grid){gap: 0.5em;}.is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}.is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}.is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}.is-layout-constrained > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}.is-layout-constrained > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}.is-layout-constrained > .aligncenter{margin-left: auto !important;margin-right: auto !important;}.is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)){margin-left: auto !important;margin-right: auto !important;}body .is-layout-flex{display: flex;}.is-layout-flex{flex-wrap: wrap;align-items: center;}.is-layout-flex > :is(*, div){margin: 0;}body .is-layout-grid{display: grid;}.is-layout-grid > :is(*, div){margin: 0;}body{--wp--style--root--padding-top: 10px;--wp--style--root--padding-right: 12px;--wp--style--root--padding-bottom: 10px;--wp--style--root--padding-left: 12px;}'; $root_rules = $theme_json->get_root_layout_rules( WP_Theme_JSON::ROOT_BLOCK_SELECTOR, $metadata ); $style_rules = $theme_json->get_styles_for_block( $metadata ); $this->assertSame( $expected, $root_rules . $style_rules ); @@ -3763,6 +3883,7 @@ public function test_get_styles_for_block_with_padding_aware_alignments() { * @ticket 58550 * @ticket 60936 * @ticket 61165 + * @ticket 61704 */ public function test_get_styles_for_block_without_padding_aware_alignments() { $theme_json = new WP_Theme_JSON( @@ -3786,7 +3907,7 @@ public function test_get_styles_for_block_without_padding_aware_alignments() { 'selector' => 'body', ); - $expected = ':where(body) { margin: 0; }.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }:where(.is-layout-flex){gap: 0.5em;}:where(.is-layout-grid){gap: 0.5em;}.is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}.is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}.is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}.is-layout-constrained > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}.is-layout-constrained > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}.is-layout-constrained > .aligncenter{margin-left: auto !important;margin-right: auto !important;}.is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)){margin-left: auto !important;margin-right: auto !important;}body .is-layout-flex{display: flex;}.is-layout-flex{flex-wrap: wrap;align-items: center;}.is-layout-flex > :is(*, div){margin: 0;}body .is-layout-grid{display: grid;}.is-layout-grid > :is(*, div){margin: 0;}:root :where(body){padding-top: 10px;padding-right: 12px;padding-bottom: 10px;padding-left: 12px;}'; + $expected = ':where(body) { margin: 0; }.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }:where(.is-layout-flex){gap: 0.5em;}:where(.is-layout-grid){gap: 0.5em;}.is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}.is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}.is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}.is-layout-constrained > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}.is-layout-constrained > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}.is-layout-constrained > .aligncenter{margin-left: auto !important;margin-right: auto !important;}.is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)){margin-left: auto !important;margin-right: auto !important;}body .is-layout-flex{display: flex;}.is-layout-flex{flex-wrap: wrap;align-items: center;}.is-layout-flex > :is(*, div){margin: 0;}body .is-layout-grid{display: grid;}.is-layout-grid > :is(*, div){margin: 0;}body{padding-top: 10px;padding-right: 12px;padding-bottom: 10px;padding-left: 12px;}'; $root_rules = $theme_json->get_root_layout_rules( WP_Theme_JSON::ROOT_BLOCK_SELECTOR, $metadata ); $style_rules = $theme_json->get_styles_for_block( $metadata ); $this->assertSame( $expected, $root_rules . $style_rules ); @@ -3825,6 +3946,7 @@ public function test_get_styles_with_content_width() { * @ticket 58550 * @ticket 60936 * @ticket 61165 + * @ticket 61829 */ public function test_get_styles_with_appearance_tools() { $theme_json = new WP_Theme_JSON( @@ -3841,7 +3963,7 @@ public function test_get_styles_with_appearance_tools() { 'selector' => 'body', ); - $expected = ':where(body) { margin: 0; }.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }:where(.wp-site-blocks) > * { margin-block-start: ; margin-block-end: 0; }:where(.wp-site-blocks) > :first-child { margin-block-start: 0; }:where(.wp-site-blocks) > :last-child { margin-block-end: 0; }:root { --wp--style--block-gap: ; }.is-layout-flow > :first-child{margin-block-start: 0;}.is-layout-flow > :last-child{margin-block-end: 0;}.is-layout-flow > *{margin-block-start: 1;margin-block-end: 0;}.is-layout-constrained > :first-child{margin-block-start: 0;}.is-layout-constrained > :last-child{margin-block-end: 0;}.is-layout-constrained > *{margin-block-start: 1;margin-block-end: 0;}.is-layout-flex {gap: 1;}.is-layout-grid {gap: 1;}.is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}.is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}.is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}.is-layout-constrained > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}.is-layout-constrained > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}.is-layout-constrained > .aligncenter{margin-left: auto !important;margin-right: auto !important;}.is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)){margin-left: auto !important;margin-right: auto !important;}body .is-layout-flex{display: flex;}.is-layout-flex{flex-wrap: wrap;align-items: center;}.is-layout-flex > :is(*, div){margin: 0;}body .is-layout-grid{display: grid;}.is-layout-grid > :is(*, div){margin: 0;}'; + $expected = ':where(body) { margin: 0; }.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }:where(.wp-site-blocks) > * { margin-block-start: ; margin-block-end: 0; }:where(.wp-site-blocks) > :first-child { margin-block-start: 0; }:where(.wp-site-blocks) > :last-child { margin-block-end: 0; }:root { --wp--style--block-gap: ; }:root :where(.is-layout-flow) > :first-child{margin-block-start: 0;}:root :where(.is-layout-flow) > :last-child{margin-block-end: 0;}:root :where(.is-layout-flow) > *{margin-block-start: 1;margin-block-end: 0;}:root :where(.is-layout-constrained) > :first-child{margin-block-start: 0;}:root :where(.is-layout-constrained) > :last-child{margin-block-end: 0;}:root :where(.is-layout-constrained) > *{margin-block-start: 1;margin-block-end: 0;}:root :where(.is-layout-flex){gap: 1;}:root :where(.is-layout-grid){gap: 1;}.is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}.is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}.is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}.is-layout-constrained > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}.is-layout-constrained > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}.is-layout-constrained > .aligncenter{margin-left: auto !important;margin-right: auto !important;}.is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)){margin-left: auto !important;margin-right: auto !important;}body .is-layout-flex{display: flex;}.is-layout-flex{flex-wrap: wrap;align-items: center;}.is-layout-flex > :is(*, div){margin: 0;}body .is-layout-grid{display: grid;}.is-layout-grid > :is(*, div){margin: 0;}'; $this->assertSame( $expected, $theme_json->get_root_layout_rules( WP_Theme_JSON::ROOT_BLOCK_SELECTOR, $metadata ) ); } @@ -4993,10 +5115,15 @@ public function test_get_shadow_styles_for_blocks() { } /** - * Tests that theme background image styles are correctly generated. + * Tests that theme background image styles are correctly generated, + * and that default background size of "cover" isn't + * applied (it's only applied to blocks). * * @ticket 61123 * @ticket 61165 + * @ticket 61720 + * @ticket 61704 + * @ticket 61858 */ public function test_get_top_level_background_image_styles() { $theme_json = new WP_Theme_JSON( @@ -5004,12 +5131,12 @@ public function test_get_top_level_background_image_styles() { 'version' => WP_Theme_JSON::LATEST_SCHEMA, 'styles' => array( 'background' => array( - 'backgroundImage' => array( + 'backgroundImage' => array( 'url' => 'http://example.org/image.png', ), - 'backgroundSize' => 'contain', - 'backgroundRepeat' => 'no-repeat', - 'backgroundPosition' => 'center center', + 'backgroundRepeat' => 'no-repeat', + 'backgroundPosition' => 'center center', + 'backgroundAttachment' => 'fixed', ), ), ) @@ -5020,25 +5147,162 @@ public function test_get_top_level_background_image_styles() { 'selector' => 'body', ); - $expected_styles = "html{min-height: calc(100% - var(--wp-admin--admin-bar--height, 0px));}:root :where(body){background-image: url('http://example.org/image.png');background-position: center center;background-repeat: no-repeat;background-size: contain;}"; - $this->assertSame( $expected_styles, $theme_json->get_styles_for_block( $body_node ), 'Styles returned from "::get_stylesheet()" with top-level background styles type does not match expectations' ); + $expected_styles = "html{min-height: calc(100% - var(--wp-admin--admin-bar--height, 0px));}body{background-image: url('http://example.org/image.png');background-position: center center;background-repeat: no-repeat;background-attachment: fixed;}"; + $this->assertSame( $expected_styles, $theme_json->get_styles_for_block( $body_node ), 'Styles returned from "::get_stylesheet()" with top-level background styles type do not match expectations' ); + + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'background' => array( + 'backgroundImage' => "url('http://example.org/image.png')", + 'backgroundSize' => 'contain', + 'backgroundRepeat' => 'no-repeat', + 'backgroundPosition' => 'center center', + 'backgroundAttachment' => 'fixed', + ), + ), + ) + ); + + $expected_styles = "html{min-height: calc(100% - var(--wp-admin--admin-bar--height, 0px));}body{background-image: url('http://example.org/image.png');background-position: center center;background-repeat: no-repeat;background-size: contain;background-attachment: fixed;}"; + $this->assertSame( $expected_styles, $theme_json->get_styles_for_block( $body_node ), 'Styles returned from "::get_stylesheet()" with top-level background image as string type do not match expectations' ); + } + + /** + * Block-level global background image styles. + * + * @ticket 61588 + * @ticket 61720 + * @ticket 61858 + */ + public function test_get_block_background_image_styles() { + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'core/group' => array( + 'background' => array( + 'backgroundImage' => "url('http://example.org/group.png')", + 'backgroundRepeat' => 'no-repeat', + 'backgroundPosition' => 'center center', + 'backgroundAttachment' => 'fixed', + ), + ), + 'core/quote' => array( + 'background' => array( + 'backgroundImage' => array( + 'url' => 'http://example.org/quote.png', + 'id' => 321, + ), + 'backgroundSize' => 'contain', + ), + ), + 'core/verse' => array( + 'background' => array( + 'backgroundImage' => array( + 'url' => 'http://example.org/verse.png', + 'id' => 123, + ), + ), + ), + ), + ), + ) + ); + + $group_node = array( + 'name' => 'core/group', + 'path' => array( 'styles', 'blocks', 'core/group' ), + 'selector' => '.wp-block-group', + 'selectors' => array( + 'root' => '.wp-block-group', + ), + ); + + $group_styles = ":root :where(.wp-block-group){background-image: url('http://example.org/group.png');background-position: center center;background-repeat: no-repeat;background-attachment: fixed;}"; + $this->assertSame( $group_styles, $theme_json->get_styles_for_block( $group_node ), 'Styles returned from "::get_styles_for_block()" with core/group background styles as string type do not match expectations.' ); + + $quote_node = array( + 'name' => 'core/quote', + 'path' => array( 'styles', 'blocks', 'core/quote' ), + 'selector' => '.wp-block-quote', + 'selectors' => array( + 'root' => '.wp-block-quote', + ), + ); + + $quote_styles = ":root :where(.wp-block-quote){background-image: url('http://example.org/quote.png');background-position: 50% 50%;background-size: contain;}"; + $this->assertSame( $quote_styles, $theme_json->get_styles_for_block( $quote_node ), 'Styles returned from "::get_styles_for_block()" with core/quote default background styles do not match expectations.' ); + + $verse_node = array( + 'name' => 'core/verse', + 'path' => array( 'styles', 'blocks', 'core/verse' ), + 'selector' => '.wp-block-verse', + 'selectors' => array( + 'root' => '.wp-block-verse', + ), + ); + + $verse_styles = ":root :where(.wp-block-verse){background-image: url('http://example.org/verse.png');background-size: cover;}"; + $this->assertSame( $verse_styles, $theme_json->get_styles_for_block( $verse_node ), 'Styles returned from "::get_styles_for_block()" with default core/verse background styles as string type do not match expectations.' ); + } + /** + * Testing background dynamic properties in theme.json. + * + * @ticket 61858 + */ + public function test_get_resolved_background_image_styles() { $theme_json = new WP_Theme_JSON( array( 'version' => WP_Theme_JSON::LATEST_SCHEMA, 'styles' => array( 'background' => array( - 'backgroundImage' => "url('http://example.org/image.png')", - 'backgroundSize' => 'contain', - 'backgroundRepeat' => 'no-repeat', - 'backgroundPosition' => 'center center', + 'backgroundImage' => array( + 'url' => 'http://example.org/top.png', + ), + 'backgroundSize' => 'contain', + 'backgroundRepeat' => 'repeat', + 'backgroundPosition' => '10% 20%', + 'backgroundAttachment' => 'scroll', + ), + 'blocks' => array( + 'core/group' => array( + 'background' => array( + 'backgroundImage' => array( + 'id' => 123, + 'url' => 'http://example.org/group.png', + ), + ), + ), + 'core/post-content' => array( + 'background' => array( + 'backgroundImage' => array( + 'ref' => 'styles.background.backgroundImage', + ), + 'backgroundSize' => array( + 'ref' => 'styles.background.backgroundSize', + ), + 'backgroundRepeat' => array( + 'ref' => 'styles.background.backgroundRepeat', + ), + 'backgroundPosition' => array( + 'ref' => 'styles.background.backgroundPosition', + ), + 'backgroundAttachment' => array( + 'ref' => 'styles.background.backgroundAttachment', + ), + ), + ), ), ), ) ); - $expected_styles = "html{min-height: calc(100% - var(--wp-admin--admin-bar--height, 0px));}:root :where(body){background-image: url('http://example.org/image.png');background-position: center center;background-repeat: no-repeat;background-size: contain;}"; - $this->assertSame( $expected_styles, $theme_json->get_styles_for_block( $body_node ), 'Styles returned from "::get_stylesheet()" with top-level background image as string type does not match expectations' ); + $expected = "html{min-height: calc(100% - var(--wp-admin--admin-bar--height, 0px));}body{background-image: url('http://example.org/top.png');background-position: 10% 20%;background-repeat: repeat;background-size: contain;background-attachment: scroll;}:root :where(.wp-block-group){background-image: url('http://example.org/group.png');background-size: cover;}:root :where(.wp-block-post-content){background-image: url('http://example.org/top.png');background-position: 10% 20%;background-repeat: repeat;background-size: contain;background-attachment: scroll;}"; + $this->assertSame( $expected, $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) ) ); } /** @@ -5167,6 +5431,7 @@ public function data_custom_css_for_user_caps() { /** * @ticket 61165 + * @ticket 61769 * * @dataProvider data_process_blocks_custom_css * @@ -5194,6 +5459,13 @@ public function test_process_blocks_custom_css( $input, $expected ) { public function data_process_blocks_custom_css() { return array( // Simple CSS without any nested selectors. + 'empty css' => array( + 'input' => array( + 'selector' => '.foo', + 'css' => '', + ), + 'expected' => '', + ), 'no nested selectors' => array( 'input' => array( 'selector' => '.foo', @@ -5209,13 +5481,20 @@ public function data_process_blocks_custom_css() { ), 'expected' => ':root :where(.foo){color: red; margin: auto;}:root :where(.foo.one){color: blue;}:root :where(.foo .two){color: green;}', ), + 'no root styles' => array( + 'input' => array( + 'selector' => '.foo', + 'css' => '&::before{color: red;}', + ), + 'expected' => ':root :where(.foo)::before{color: red;}', + ), // CSS with pseudo elements. 'with pseudo elements' => array( 'input' => array( 'selector' => '.foo', 'css' => 'color: red; margin: auto; &::before{color: blue;} & ::before{color: green;} &.one::before{color: yellow;} & .two::before{color: purple;}', ), - 'expected' => ':root :where(.foo){color: red; margin: auto;}:root :where(.foo::before){color: blue;}:root :where(.foo ::before){color: green;}:root :where(.foo.one::before){color: yellow;}:root :where(.foo .two::before){color: purple;}', + 'expected' => ':root :where(.foo){color: red; margin: auto;}:root :where(.foo)::before{color: blue;}:root :where(.foo) ::before{color: green;}:root :where(.foo.one)::before{color: yellow;}:root :where(.foo .two)::before{color: purple;}', ), // CSS with multiple root selectors. 'with multiple root selectors' => array( @@ -5223,7 +5502,7 @@ public function data_process_blocks_custom_css() { 'selector' => '.foo, .bar', 'css' => 'color: red; margin: auto; &.one{color: blue;} & .two{color: green;} &::before{color: yellow;} & ::before{color: purple;} &.three::before{color: orange;} & .four::before{color: skyblue;}', ), - 'expected' => ':root :where(.foo, .bar){color: red; margin: auto;}:root :where(.foo.one, .bar.one){color: blue;}:root :where(.foo .two, .bar .two){color: green;}:root :where(.foo::before, .bar::before){color: yellow;}:root :where(.foo ::before, .bar ::before){color: purple;}:root :where(.foo.three::before, .bar.three::before){color: orange;}:root :where(.foo .four::before, .bar .four::before){color: skyblue;}', + 'expected' => ':root :where(.foo, .bar){color: red; margin: auto;}:root :where(.foo.one, .bar.one){color: blue;}:root :where(.foo .two, .bar .two){color: green;}:root :where(.foo, .bar)::before{color: yellow;}:root :where(.foo, .bar) ::before{color: purple;}:root :where(.foo.three, .bar.three)::before{color: orange;}:root :where(.foo .four, .bar .four)::before{color: skyblue;}', ), ); } diff --git a/tests/phpunit/tests/theme/wpThemeJsonResolver.php b/tests/phpunit/tests/theme/wpThemeJsonResolver.php index 5ad65e8862218..4fb3784f1a41b 100644 --- a/tests/phpunit/tests/theme/wpThemeJsonResolver.php +++ b/tests/phpunit/tests/theme/wpThemeJsonResolver.php @@ -1257,6 +1257,7 @@ public function test_shadow_default_presets_value_for_block_and_classic_themes() * * @covers WP_Theme_JSON_Resolver::resolve_theme_file_uris * @ticket 61273 + * @ticket 61588 */ public function test_resolve_theme_file_uris() { $theme_json = new WP_Theme_JSON( @@ -1268,6 +1269,22 @@ public function test_resolve_theme_file_uris() { 'url' => 'file:./assets/image.png', ), ), + 'blocks' => array( + 'core/quote' => array( + 'background' => array( + 'backgroundImage' => array( + 'url' => 'file:./assets/quote.png', + ), + ), + ), + 'core/verse' => array( + 'background' => array( + 'backgroundImage' => array( + 'url' => 'file:./assets/verse.png', + ), + ), + ), + ), ), ) ); @@ -1280,6 +1297,22 @@ public function test_resolve_theme_file_uris() { 'url' => 'https://example.org/wp-content/themes/example-theme/assets/image.png', ), ), + 'blocks' => array( + 'core/quote' => array( + 'background' => array( + 'backgroundImage' => array( + 'url' => 'https://example.org/wp-content/themes/example-theme/assets/quote.png', + ), + ), + ), + 'core/verse' => array( + 'background' => array( + 'backgroundImage' => array( + 'url' => 'https://example.org/wp-content/themes/example-theme/assets/verse.png', + ), + ), + ), + ), ), ); @@ -1293,6 +1326,7 @@ public function test_resolve_theme_file_uris() { * * @covers WP_Theme_JSON_Resolver::get_resolved_theme_uris * @ticket 61273 + * @ticket 61588 */ public function test_get_resolved_theme_uris() { $theme_json = new WP_Theme_JSON( @@ -1304,6 +1338,22 @@ public function test_get_resolved_theme_uris() { 'url' => 'file:./assets/image.png', ), ), + 'blocks' => array( + 'core/quote' => array( + 'background' => array( + 'backgroundImage' => array( + 'url' => 'file:./assets/quote.jpg', + ), + ), + ), + 'core/verse' => array( + 'background' => array( + 'backgroundImage' => array( + 'url' => 'file:./assets/verse.gif', + ), + ), + ), + ), ), ) ); @@ -1315,6 +1365,18 @@ public function test_get_resolved_theme_uris() { 'target' => 'styles.background.backgroundImage.url', 'type' => 'image/png', ), + array( + 'name' => 'file:./assets/quote.jpg', + 'href' => 'https://example.org/wp-content/themes/example-theme/assets/quote.jpg', + 'target' => 'styles.blocks.core/quote.background.backgroundImage.url', + 'type' => 'image/jpeg', + ), + array( + 'name' => 'file:./assets/verse.gif', + 'href' => 'https://example.org/wp-content/themes/example-theme/assets/verse.gif', + 'target' => 'styles.blocks.core/verse.background.backgroundImage.url', + 'type' => 'image/gif', + ), ); $actual = WP_Theme_JSON_Resolver::get_resolved_theme_uris( $theme_json ); diff --git a/tests/phpunit/tests/user/queryCache.php b/tests/phpunit/tests/user/queryCache.php index 985c6d7da9f6d..0e708a55c4de8 100644 --- a/tests/phpunit/tests/user/queryCache.php +++ b/tests/phpunit/tests/user/queryCache.php @@ -388,7 +388,7 @@ public function test_query_cache_delete_user() { $this->assertSameSets( $expected, $found, 'Find author in returned values' ); - wp_delete_user( $user_id ); + self::delete_user( $user_id ); $q2 = new WP_User_Query( array( diff --git a/tests/phpunit/tests/xmlrpc/wp/getUser.php b/tests/phpunit/tests/xmlrpc/wp/getUser.php index dfeb1c8e2e226..391ec7157cee9 100644 --- a/tests/phpunit/tests/xmlrpc/wp/getUser.php +++ b/tests/phpunit/tests/xmlrpc/wp/getUser.php @@ -98,7 +98,7 @@ public function test_valid_user() { $this->assertSame( $user_data['user_login'], $result['username'] ); $this->assertContains( $user_data['role'], $result['roles'] ); - wp_delete_user( $user_id ); + self::delete_user( $user_id ); } public function test_no_fields() { diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index a5a2d3622eb0f..7e97f19b18588 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -13748,9 +13748,9 @@ mockedApiResponse.UsersCollection = [ "link": "http://example.org/?author=1", "slug": "admin", "avatar_urls": { - "24": "http://0.gravatar.com/avatar/96614ec98aa0c0d2ee75796dced6df54?s=24&d=mm&r=g", - "48": "http://0.gravatar.com/avatar/96614ec98aa0c0d2ee75796dced6df54?s=48&d=mm&r=g", - "96": "http://0.gravatar.com/avatar/96614ec98aa0c0d2ee75796dced6df54?s=96&d=mm&r=g" + "24": "https://secure.gravatar.com/avatar/96614ec98aa0c0d2ee75796dced6df54?s=24&d=mm&r=g", + "48": "https://secure.gravatar.com/avatar/96614ec98aa0c0d2ee75796dced6df54?s=48&d=mm&r=g", + "96": "https://secure.gravatar.com/avatar/96614ec98aa0c0d2ee75796dced6df54?s=96&d=mm&r=g" }, "meta": { "meta_key": "meta_value" @@ -13776,9 +13776,9 @@ mockedApiResponse.UsersCollection = [ "link": "http://example.org/?author=2", "slug": "restapiclientfixtureuser", "avatar_urls": { - "24": "http://2.gravatar.com/avatar/57cbd982c963c7eb2294e2eee1b4448e?s=24&d=mm&r=g", - "48": "http://2.gravatar.com/avatar/57cbd982c963c7eb2294e2eee1b4448e?s=48&d=mm&r=g", - "96": "http://2.gravatar.com/avatar/57cbd982c963c7eb2294e2eee1b4448e?s=96&d=mm&r=g" + "24": "https://secure.gravatar.com/avatar/57cbd982c963c7eb2294e2eee1b4448e?s=24&d=mm&r=g", + "48": "https://secure.gravatar.com/avatar/57cbd982c963c7eb2294e2eee1b4448e?s=48&d=mm&r=g", + "96": "https://secure.gravatar.com/avatar/57cbd982c963c7eb2294e2eee1b4448e?s=96&d=mm&r=g" }, "meta": { "meta_key": "" @@ -13806,9 +13806,9 @@ mockedApiResponse.UserModel = { "link": "http://example.org/?author=2", "slug": "restapiclientfixtureuser", "avatar_urls": { - "24": "http://2.gravatar.com/avatar/57cbd982c963c7eb2294e2eee1b4448e?s=24&d=mm&r=g", - "48": "http://2.gravatar.com/avatar/57cbd982c963c7eb2294e2eee1b4448e?s=48&d=mm&r=g", - "96": "http://2.gravatar.com/avatar/57cbd982c963c7eb2294e2eee1b4448e?s=96&d=mm&r=g" + "24": "https://secure.gravatar.com/avatar/57cbd982c963c7eb2294e2eee1b4448e?s=24&d=mm&r=g", + "48": "https://secure.gravatar.com/avatar/57cbd982c963c7eb2294e2eee1b4448e?s=48&d=mm&r=g", + "96": "https://secure.gravatar.com/avatar/57cbd982c963c7eb2294e2eee1b4448e?s=96&d=mm&r=g" }, "meta": { "meta_key": "" @@ -13823,9 +13823,9 @@ mockedApiResponse.me = { "link": "http://example.org/?author=2", "slug": "restapiclientfixtureuser", "avatar_urls": { - "24": "http://2.gravatar.com/avatar/57cbd982c963c7eb2294e2eee1b4448e?s=24&d=mm&r=g", - "48": "http://2.gravatar.com/avatar/57cbd982c963c7eb2294e2eee1b4448e?s=48&d=mm&r=g", - "96": "http://2.gravatar.com/avatar/57cbd982c963c7eb2294e2eee1b4448e?s=96&d=mm&r=g" + "24": "https://secure.gravatar.com/avatar/57cbd982c963c7eb2294e2eee1b4448e?s=24&d=mm&r=g", + "48": "https://secure.gravatar.com/avatar/57cbd982c963c7eb2294e2eee1b4448e?s=48&d=mm&r=g", + "96": "https://secure.gravatar.com/avatar/57cbd982c963c7eb2294e2eee1b4448e?s=96&d=mm&r=g" }, "meta": { "meta_key": "" @@ -13849,9 +13849,9 @@ mockedApiResponse.CommentsCollection = [ "status": "approved", "type": "comment", "author_avatar_urls": { - "24": "http://2.gravatar.com/avatar/bd7c2b505bcf39cc71cfee564c614956?s=24&d=mm&r=g", - "48": "http://2.gravatar.com/avatar/bd7c2b505bcf39cc71cfee564c614956?s=48&d=mm&r=g", - "96": "http://2.gravatar.com/avatar/bd7c2b505bcf39cc71cfee564c614956?s=96&d=mm&r=g" + "24": "https://secure.gravatar.com/avatar/bd7c2b505bcf39cc71cfee564c614956?s=24&d=mm&r=g", + "48": "https://secure.gravatar.com/avatar/bd7c2b505bcf39cc71cfee564c614956?s=48&d=mm&r=g", + "96": "https://secure.gravatar.com/avatar/bd7c2b505bcf39cc71cfee564c614956?s=96&d=mm&r=g" }, "meta": { "meta_key": "meta_value" @@ -13894,9 +13894,9 @@ mockedApiResponse.CommentModel = { "status": "approved", "type": "comment", "author_avatar_urls": { - "24": "http://2.gravatar.com/avatar/bd7c2b505bcf39cc71cfee564c614956?s=24&d=mm&r=g", - "48": "http://2.gravatar.com/avatar/bd7c2b505bcf39cc71cfee564c614956?s=48&d=mm&r=g", - "96": "http://2.gravatar.com/avatar/bd7c2b505bcf39cc71cfee564c614956?s=96&d=mm&r=g" + "24": "https://secure.gravatar.com/avatar/bd7c2b505bcf39cc71cfee564c614956?s=24&d=mm&r=g", + "48": "https://secure.gravatar.com/avatar/bd7c2b505bcf39cc71cfee564c614956?s=48&d=mm&r=g", + "96": "https://secure.gravatar.com/avatar/bd7c2b505bcf39cc71cfee564c614956?s=96&d=mm&r=g" }, "meta": { "meta_key": "meta_value"