diff --git a/.github/workflows/install-testing.yml b/.github/workflows/install-testing.yml index c9de4a73abf25..ca03ebd518a00 100644 --- a/.github/workflows/install-testing.yml +++ b/.github/workflows/install-testing.yml @@ -11,6 +11,7 @@ on: paths: - '.github/workflows/install-testing.yml' - '.version-support-*.json' + - '.github/workflows/reusable-support-json-reader-v1.yml' pull_request: # Always test the workflow when changes are suggested. paths: diff --git a/.github/workflows/local-docker-environment.yml b/.github/workflows/local-docker-environment.yml new file mode 100644 index 0000000000000..1e25c82ef1287 --- /dev/null +++ b/.github/workflows/local-docker-environment.yml @@ -0,0 +1,154 @@ +name: Local Docker Environment + +on: + push: + branches: + - trunk + - '6.[8-9]' + - '[7-9].[0-9]' + paths: + # Any changes to Docker related files. + - '.env.example' + - 'docker-compose.yml' + # Any changes to local environment related files + - 'tools/local-env/**' + # These files manage packages used by the local environment. + - 'package*.json' + # These files configure Composer. Changes could affect the local environment. + - 'composer.*' + # These files define the versions to test. + - '.version-support-*.json' + # Changes to this and related workflow files should always be verified. + - '.github/workflows/local-docker-environment.yml' + - '.github/workflows/reusable-support-json-reader-v1.yml' + - '.github/workflows/reusable-test-docker-environment-v1.yml' + pull_request: + branches: + - trunk + - '6.[8-9]' + - '[7-9].[0-9]' + paths: + # Any changes to Docker related files. + - '.env.example' + - 'docker-compose.yml' + # Any changes to local environment related files + - 'tools/local-env/**' + # These files manage packages used by the local environment. + - 'package*.json' + # These files configure Composer. Changes could affect the local environment. + - 'composer.*' + # These files define the versions to test. + - '.version-support-*.json' + # Changes to this and related workflow files should always be verified. + - '.github/workflows/local-docker-environment.yml' + - '.github/workflows/reusable-support-json-reader-v1.yml' + - '.github/workflows/reusable-test-docker-environment-v1.yml' + workflow_dispatch: + +# Cancels all previous workflow runs for pull requests that have not completed. +concurrency: + # The concurrency group contains the workflow name and the branch name for pull requests + # or the commit hash for any other events. + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.head_ref || github.sha }} + cancel-in-progress: true + +# Disable permissions for all available scopes by default. +# Any needed permissions should be configured at the job level. +permissions: {} + +jobs: + # + # Determines the appropriate supported values for PHP and database versions based on the WordPress + # version being tested. + # + build-test-matrix: + name: Build Test Matrix + uses: WordPress/wordpress-develop/.github/workflows/reusable-support-json-reader-v1.yml@trunk + permissions: + contents: read + secrets: inherit + if: ${{ github.repository == 'WordPress/wordpress-develop' || github.event_name == 'pull_request' }} + with: + wp-version: ${{ github.event_name == 'pull_request' && github.base_ref || github.ref_name }} + + # Tests the local Docker environment. + environment-tests-mysql: + name: PHP ${{ matrix.php }} + uses: WordPress/wordpress-develop/.github/workflows/reusable-test-local-docker-environment-v1.yml@trunk + permissions: + contents: read + if: ${{ github.repository == 'WordPress/wordpress-develop' || github.event_name == 'pull_request' }} + needs: [ build-test-matrix ] + strategy: + fail-fast: false + matrix: + os: [ ubuntu-latest ] + memcached: [ false, true ] + php: ${{ fromJSON( needs.build-test-matrix.outputs.php-versions ) }} + db-version: ${{ fromJSON( needs.build-test-matrix.outputs.mysql-versions ) }} + + exclude: + # The MySQL 5.5 containers will not start. + - db-version: '5.5' + # MySQL 9.0+ will not work on PHP 7.2 & 7.3 + - php: '7.2' + db-version: '9.0' + - php: '7.3' + db-version: '9.0' + + with: + os: ${{ matrix.os }} + php: ${{ matrix.php }} + db-type: 'mysql' + db-version: ${{ matrix.db-version }} + memcached: ${{ matrix.memcached }} + tests-domain: ${{ matrix.tests-domain }} + + slack-notifications: + name: Slack Notifications + uses: WordPress/wordpress-develop/.github/workflows/slack-notifications.yml@trunk + permissions: + actions: read + contents: read + needs: [ build-test-matrix, environment-tests-mysql ] + if: ${{ github.repository == 'WordPress/wordpress-develop' && github.event_name != 'pull_request' && always() }} + with: + calling_status: ${{ contains( needs.*.result, 'cancelled' ) && 'cancelled' || contains( needs.*.result, 'failure' ) && 'failure' || 'success' }} + secrets: + SLACK_GHA_SUCCESS_WEBHOOK: ${{ secrets.SLACK_GHA_SUCCESS_WEBHOOK }} + SLACK_GHA_CANCELLED_WEBHOOK: ${{ secrets.SLACK_GHA_CANCELLED_WEBHOOK }} + SLACK_GHA_FIXED_WEBHOOK: ${{ secrets.SLACK_GHA_FIXED_WEBHOOK }} + SLACK_GHA_FAILURE_WEBHOOK: ${{ secrets.SLACK_GHA_FAILURE_WEBHOOK }} + + failed-workflow: + name: Failed workflow tasks + runs-on: ubuntu-latest + permissions: + actions: write + needs: [ build-test-matrix, environment-tests-mysql, slack-notifications ] + if: | + always() && + github.repository == 'WordPress/wordpress-develop' && + github.event_name != 'pull_request' && + github.run_attempt < 2 && + ( + contains( needs.*.result, 'cancelled' ) || + contains( needs.*.result, 'failure' ) + ) + + steps: + - name: Dispatch workflow run + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + retries: 2 + retry-exempt-status-codes: 418 + script: | + github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'failed-workflow.yml', + ref: 'trunk', + inputs: { + run_id: '${{ github.run_id }}' + } + }); diff --git a/.github/workflows/phpunit-tests.yml b/.github/workflows/phpunit-tests.yml index 949e08d5339ca..62a7aa50f7718 100644 --- a/.github/workflows/phpunit-tests.yml +++ b/.github/workflows/phpunit-tests.yml @@ -53,40 +53,40 @@ jobs: memcached: [ false ] include: - # Include jobs for PHP 7.4 with memcached. + # Include jobs that test with memcached. - os: ubuntu-latest - php: '7.4' + php: '8.3' db-type: 'mysql' - db-version: '5.7' + db-version: '8.4' tests-domain: 'example.org' multisite: false memcached: true - os: ubuntu-latest - php: '7.4' + php: '8.3' db-type: 'mysql' - db-version: '5.7' + db-version: '8.4' tests-domain: 'example.org' multisite: true memcached: true # Include jobs with a port on the test domain for both single and multisite. - os: ubuntu-latest - php: '7.4' + php: '8.4' db-type: 'mysql' - db-version: '5.7' + db-version: '8.4' tests-domain: 'example.org:8889' multisite: false memcached: false - os: ubuntu-latest - php: '7.4' + php: '8.4' db-type: 'mysql' - db-version: '5.7' + db-version: '8.4' tests-domain: 'example.org:8889' multisite: true memcached: false # Report test results to the Host Test Results. - os: ubuntu-latest db-type: 'mysql' - db-version: '8.0' + db-version: '8.4' tests-domain: 'example.org' multisite: false memcached: false @@ -131,15 +131,15 @@ jobs: memcached: [ false ] include: - # Include jobs for PHP 7.4 with memcached. + # Include jobs that test with memcached. - os: ubuntu-latest - php: '7.4' + php: '8.3' db-type: 'mariadb' db-version: '11.2' multisite: false memcached: true - os: ubuntu-latest - php: '7.4' + php: '8.3' db-type: 'mariadb' db-version: '11.2' multisite: true diff --git a/.github/workflows/reusable-phpunit-tests-v3.yml b/.github/workflows/reusable-phpunit-tests-v3.yml index 56e40e762cd27..fd21b3a9b48f5 100644 --- a/.github/workflows/reusable-phpunit-tests-v3.yml +++ b/.github/workflows/reusable-phpunit-tests-v3.yml @@ -171,7 +171,7 @@ jobs: - name: WordPress Docker container debug information run: | - docker compose run --rm mysql ${{ env.LOCAL_DB_TYPE }} --version + docker compose run --rm mysql ${{ env.LOCAL_DB_TYPE == 'mariadb' && contains( fromJSON('["5.5", "10.0", "10.1", "10.2", "10.3"]'), env.LOCAL_DB_VERSION ) && 'mysql' || env.LOCAL_DB_TYPE }} --version docker compose run --rm php php --version docker compose run --rm php php -m docker compose run --rm php php -i diff --git a/.github/workflows/reusable-support-json-reader-v1.yml b/.github/workflows/reusable-support-json-reader-v1.yml index 2adc15cebdee1..ce574e4b2c3b7 100644 --- a/.github/workflows/reusable-support-json-reader-v1.yml +++ b/.github/workflows/reusable-support-json-reader-v1.yml @@ -11,6 +11,10 @@ on: description: 'The WordPress version to test . Accepts major and minor versions, "latest", or "nightly". Major releases must not end with ".0".' type: string default: 'nightly' + repository: + description: 'The repository to read support JSON files from.' + type: string + default: 'WordPress/wordpress-develop' outputs: major-wp-version: description: "The major WordPress version based on the version provided in wp-version" @@ -42,14 +46,15 @@ jobs: - name: Checkout repository uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: + repository: ${{ inputs.repository }} show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} - name: Determine the major WordPress version id: major-wp-version run: | - if [ "${{ inputs.wp-version }}" ] && [ "${{ inputs.wp-version }}" != "nightly" ] && [ "${{ inputs.wp-version }}" != "latest" ]; then + if [ "${{ inputs.wp-version }}" ] && [ "${{ inputs.wp-version }}" != "nightly" ] && [ "${{ inputs.wp-version }}" != "latest" ] && [ "${{ inputs.wp-version }}" != "trunk" ]; then echo "version=$(echo "${{ inputs.wp-version }}" | tr '.' '-' | cut -d '-' -f1-2)" >> $GITHUB_OUTPUT - elif [ "${{ inputs.wp-version }}" ]; then + elif [ "${{ inputs.wp-version }}" ] && [ "${{ inputs.wp-version }}" != "trunk" ]; then echo "version=$(echo "${{ inputs.wp-version }}")" >> $GITHUB_OUTPUT else echo "version=nightly" >> $GITHUB_OUTPUT @@ -74,6 +79,7 @@ jobs: - name: Checkout repository uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: + repository: ${{ inputs.repository }} show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} # Look up the major version's specific PHP support policy when a version is provided. @@ -106,6 +112,7 @@ jobs: - name: Checkout repository uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: + repository: ${{ inputs.repository }} show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} # Look up the major version's specific MySQL support policy when a version is provided. diff --git a/.github/workflows/reusable-test-local-docker-environment-v1.yml b/.github/workflows/reusable-test-local-docker-environment-v1.yml new file mode 100644 index 0000000000000..4dccd7ef3dccd --- /dev/null +++ b/.github/workflows/reusable-test-local-docker-environment-v1.yml @@ -0,0 +1,160 @@ +## +# A reusable workflow that ensures the local Docker environment is working properly. +# +# This workflow is used by `trunk` and branches >= 6.8. +## +name: Test local Docker environment + +on: + workflow_call: + inputs: + os: + description: 'Operating system to test' + required: false + type: 'string' + default: 'ubuntu-latest' + php: + description: 'The version of PHP to use, in the format of X.Y' + required: false + type: 'string' + default: 'latest' + db-type: + description: 'Database type. Valid types are mysql and mariadb' + required: false + type: 'string' + default: 'mysql' + db-version: + description: 'Database version' + required: false + type: 'string' + default: '8.0' + memcached: + description: 'Whether to enable memcached' + required: false + type: 'boolean' + default: false + tests-domain: + description: 'The domain to use for the tests' + required: false + type: 'string' + default: 'example.org' + +env: + LOCAL_PHP: ${{ inputs.php == 'latest' && 'latest' || format( '{0}-fpm', inputs.php ) }} + LOCAL_DB_TYPE: ${{ inputs.db-type }} + LOCAL_DB_VERSION: ${{ inputs.db-version }} + LOCAL_PHP_MEMCACHED: ${{ inputs.memcached }} + LOCAL_WP_TESTS_DOMAIN: ${{ inputs.tests-domain }} + PUPPETEER_SKIP_DOWNLOAD: ${{ true }} + +jobs: + # Tests the local Docker environment. + # + # Performs the following steps: + # - Sets environment variables. + # - Checks out the repository. + # - Sets up Node.js. + # - Sets up PHP. + # - Installs Composer dependencies. + # - Installs npm dependencies + # - Logs general debug information about the runner. + # - Logs Docker debug information (about the Docker installation within the runner). + # - Starts the WordPress Docker container. + # - Logs the running Docker containers. + # - Logs debug information about what's installed within the WordPress Docker containers. + # - Install WordPress within the Docker container. + # - Restarts the Docker environment. + # - Runs a WP CLI command. + # - Tests the logs command. + # - Tests the reset command. + # - Ensures version-controlled files are not modified or deleted. + local-docker-environment-tests: + name: PHP ${{ inputs.php }} / ${{ 'mariadb' == inputs.db-type && 'MariaDB' || 'MySQL' }} ${{ inputs.db-version }}${{ inputs.memcached && ' with memcached' || '' }}${{ 'example.org' != inputs.tests-domain && format( ' {0}', inputs.tests-domain ) || '' }} + runs-on: ${{ inputs.os }} + timeout-minutes: 20 + + steps: + - name: Configure environment variables + run: | + echo "PHP_FPM_UID=$(id -u)" >> $GITHUB_ENV + echo "PHP_FPM_GID=$(id -g)" >> $GITHUB_ENV + + - name: Checkout repository + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + with: + show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} + + - name: Set up Node.js + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 + with: + node-version-file: '.nvmrc' + cache: npm + + ## + # This allows Composer dependencies to be installed using a single step. + # + # Since tests are currently run within the Docker containers where the PHP version varies, + # the same PHP version needs to be configured for the action runner machine so that the correct + # dependency versions are installed and cached. + ## + - name: Set up PHP + uses: shivammathur/setup-php@c541c155eee45413f5b09a52248675b1a2575231 # v2.31.1 + with: + php-version: '${{ inputs.php }}' + coverage: none + + # Since Composer dependencies are installed using `composer update` and no lock file is in version control, + # passing a custom cache suffix ensures that the cache is flushed at least once per week. + - name: Install Composer dependencies + uses: ramsey/composer-install@57532f8be5bda426838819c5ee9afb8af389d51a # v3.0.0 + with: + custom-cache-suffix: $(/bin/date -u --date='last Mon' "+%F") + + - name: Install npm dependencies + run: npm ci + + - name: General debug information + run: | + npm --version + node --version + curl --version + git --version + composer --version + locale -a + + - name: Docker debug information + run: | + docker -v + + - name: Start Docker environment + run: | + npm run env:start + + - name: Log running Docker containers + run: docker ps -a + + - name: WordPress Docker container debug information + run: | + docker compose run --rm mysql ${{ env.LOCAL_DB_TYPE }} --version + docker compose run --rm php php --version + docker compose run --rm php php -m + docker compose run --rm php php -i + docker compose run --rm php locale -a + + - name: Install WordPress + run: npm run env:install + + - name: Restart Docker environment + run: npm run env:restart + + - name: Test a CLI command + run: npm run env:cli wp option get siteurl + + - name: Test logs command + run: npm run env:logs + + - name: Reset the Docker environment + run: npm run env:reset + + - name: Ensure version-controlled files are not modified or deleted + run: git diff --exit-code diff --git a/README.md b/README.md index adb4fc946d9a8..fc8c00f6821af 100644 --- a/README.md +++ b/README.md @@ -139,11 +139,14 @@ The development environment can be reset. This will destroy the database and att npm run env:reset ``` -### Apple Silicon machines and old MySQL versions +### Apple Silicon machines and old MySQL/MariaDB versions -The MySQL Docker images do not support Apple Silicon processors (M1, M2, etc.) for MySQL versions 5.7 and earlier. +Older MySQL and MariaDB Docker images do not support Apple Silicon processors (M1, M2, etc.). This is true for: -When using MySQL <= 5.7 on an Apple Silicon machine, you must create a `docker-compose.override.yml` file with the following contents: +- MySQL versions 5.7 and earlier +- MariaDB 5.5 + +When using these versions on an Apple Silicon machine, you must create a `docker-compose.override.yml` file with the following contents: ``` services: diff --git a/composer.json b/composer.json index eb78d144e590c..16e0097ba01fd 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,8 @@ "squizlabs/php_codesniffer": "3.10.3", "wp-coding-standards/wpcs": "~3.1.0", "phpcompatibility/phpcompatibility-wp": "~2.1.3", - "yoast/phpunit-polyfills": "^1.1.0" + "yoast/phpunit-polyfills": "^1.1.0", + "phpstan/phpstan": "^2.0" }, "config": { "allow-plugins": { diff --git a/docker-compose.yml b/docker-compose.yml index 8b90b678a00a2..ec462c8a24c5c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -82,7 +82,7 @@ services: command: ${LOCAL_DB_AUTH_OPTION-} healthcheck: - test: [ "CMD-SHELL", "if [ \"$LOCAL_DB_TYPE\" = \"mariadb\" ]; then mariadb-admin ping -h localhost; else mysqladmin ping -h localhost; fi" ] + test: [ "CMD-SHELL", "if [ \"$LOCAL_DB_TYPE\" = \"mariadb\" ]; then case \"$LOCAL_DB_VERSION\" in 5.5|10.0|10.1|10.2|10.3) mysqladmin ping -h localhost || exit $$?;; *) mariadb-admin ping -h localhost || exit $$?;; esac; else mysqladmin ping -h localhost || exit $$?; fi" ] timeout: 5s interval: 5s retries: 10 diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000000000..dcfe612ddb003 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,7 @@ +parameters: + level: max + phpVersion: 70224 + paths: + - src/wp-includes/html-api/class-wp-css-selectors.php + scanDirectories: + - src/wp-includes/html-api diff --git a/src/wp-admin/customize.php b/src/wp-admin/customize.php index 40857031a7ef8..b27292d9eb949 100644 --- a/src/wp-admin/customize.php +++ b/src/wp-admin/customize.php @@ -100,6 +100,12 @@ $wp_customize->set_autofocus( $autofocus ); } +// Let's roll. +header( 'Content-Type: ' . get_option( 'html_type' ) . '; charset=' . get_option( 'blog_charset' ) ); + +wp_user_settings(); +_wp_admin_html_begin(); + $registered = $wp_scripts->registered; $wp_scripts = new WP_Scripts(); $wp_scripts->registered = $registered; @@ -126,12 +132,6 @@ */ do_action( 'customize_controls_enqueue_scripts' ); -// Let's roll. -header( 'Content-Type: ' . get_option( 'html_type' ) . '; charset=' . get_option( 'blog_charset' ) ); - -wp_user_settings(); -_wp_admin_html_begin(); - $body_class = 'wp-core-ui wp-customizer js'; if ( wp_is_mobile() ) : diff --git a/src/wp-admin/includes/plugin.php b/src/wp-admin/includes/plugin.php index 0969f956577ed..de1468352b3d9 100644 --- a/src/wp-admin/includes/plugin.php +++ b/src/wp-admin/includes/plugin.php @@ -6,6 +6,221 @@ * @subpackage Administration */ +/** + * Parses the plugin contents to retrieve plugin's metadata. + * + * All plugin headers must be on their own line. Plugin description must not have + * any newlines, otherwise only parts of the description will be displayed. + * The below is formatted for printing. + * + * /* + * Plugin Name: Name of the plugin. + * Plugin URI: The home page of the plugin. + * Description: Plugin description. + * Author: Plugin author's name. + * Author URI: Link to the author's website. + * Version: Plugin version. + * Text Domain: Optional. Unique identifier, should be same as the one used in + * load_plugin_textdomain(). + * Domain Path: Optional. Only useful if the translations are located in a + * folder above the plugin's base path. For example, if .mo files are + * located in the locale folder then Domain Path will be "/locale/" and + * must have the first slash. Defaults to the base folder the plugin is + * located in. + * Network: Optional. Specify "Network: true" to require that a plugin is activated + * across all sites in an installation. This will prevent a plugin from being + * activated on a single site when Multisite is enabled. + * Requires at least: Optional. Specify the minimum required WordPress version. + * Requires PHP: Optional. Specify the minimum required PHP version. + * * / # Remove the space to close comment. + * + * The first 8 KB of the file will be pulled in and if the plugin data is not + * within that first 8 KB, then the plugin author should correct their plugin + * and move the plugin data headers to the top. + * + * The plugin file is assumed to have permissions to allow for scripts to read + * the file. This is not checked however and the file is only opened for + * reading. + * + * @since 1.5.0 + * @since 5.3.0 Added support for `Requires at least` and `Requires PHP` headers. + * @since 5.8.0 Added support for `Update URI` header. + * @since 6.5.0 Added support for `Requires Plugins` header. + * + * @param string $plugin_file Absolute path to the main plugin file. + * @param bool $markup Optional. If the returned data should have HTML markup applied. + * Default true. + * @param bool $translate Optional. If the returned data should be translated. Default true. + * @return array { + * Plugin data. Values will be empty if not supplied by the plugin. + * + * @type string $Name Name of the plugin. Should be unique. + * @type string $PluginURI Plugin URI. + * @type string $Version Plugin version. + * @type string $Description Plugin description. + * @type string $Author Plugin author's name. + * @type string $AuthorURI Plugin author's website address (if set). + * @type string $TextDomain Plugin textdomain. + * @type string $DomainPath Plugin's relative directory path to .mo files. + * @type bool $Network Whether the plugin can only be activated network-wide. + * @type string $RequiresWP Minimum required version of WordPress. + * @type string $RequiresPHP Minimum required version of PHP. + * @type string $UpdateURI ID of the plugin for update purposes, should be a URI. + * @type string $RequiresPlugins Comma separated list of dot org plugin slugs. + * @type string $Title Title of the plugin and link to the plugin's site (if set). + * @type string $AuthorName Plugin author's name. + * } + */ +function get_plugin_data( $plugin_file, $markup = true, $translate = true ) { + + $default_headers = array( + 'Name' => 'Plugin Name', + 'PluginURI' => 'Plugin URI', + 'Version' => 'Version', + 'Description' => 'Description', + 'Author' => 'Author', + 'AuthorURI' => 'Author URI', + 'TextDomain' => 'Text Domain', + 'DomainPath' => 'Domain Path', + 'Network' => 'Network', + 'RequiresWP' => 'Requires at least', + 'RequiresPHP' => 'Requires PHP', + 'UpdateURI' => 'Update URI', + 'RequiresPlugins' => 'Requires Plugins', + // Site Wide Only is deprecated in favor of Network. + '_sitewide' => 'Site Wide Only', + ); + + $plugin_data = get_file_data( $plugin_file, $default_headers, 'plugin' ); + + // Site Wide Only is the old header for Network. + if ( ! $plugin_data['Network'] && $plugin_data['_sitewide'] ) { + /* translators: 1: Site Wide Only: true, 2: Network: true */ + _deprecated_argument( __FUNCTION__, '3.0.0', sprintf( __( 'The %1$s plugin header is deprecated. Use %2$s instead.' ), 'Site Wide Only: true', 'Network: true' ) ); + $plugin_data['Network'] = $plugin_data['_sitewide']; + } + $plugin_data['Network'] = ( 'true' === strtolower( $plugin_data['Network'] ) ); + unset( $plugin_data['_sitewide'] ); + + // If no text domain is defined fall back to the plugin slug. + if ( ! $plugin_data['TextDomain'] ) { + $plugin_slug = dirname( plugin_basename( $plugin_file ) ); + if ( '.' !== $plugin_slug && ! str_contains( $plugin_slug, '/' ) ) { + $plugin_data['TextDomain'] = $plugin_slug; + } + } + + if ( $markup || $translate ) { + $plugin_data = _get_plugin_data_markup_translate( $plugin_file, $plugin_data, $markup, $translate ); + } else { + $plugin_data['Title'] = $plugin_data['Name']; + $plugin_data['AuthorName'] = $plugin_data['Author']; + } + + return $plugin_data; +} + +/** + * Sanitizes plugin data, optionally adds markup, optionally translates. + * + * @since 2.7.0 + * + * @see get_plugin_data() + * + * @access private + * + * @param string $plugin_file Path to the main plugin file. + * @param array $plugin_data An array of plugin data. See get_plugin_data(). + * @param bool $markup Optional. If the returned data should have HTML markup applied. + * Default true. + * @param bool $translate Optional. If the returned data should be translated. Default true. + * @return array Plugin data. Values will be empty if not supplied by the plugin. + * See get_plugin_data() for the list of possible values. + */ +function _get_plugin_data_markup_translate( $plugin_file, $plugin_data, $markup = true, $translate = true ) { + + // Sanitize the plugin filename to a WP_PLUGIN_DIR relative path. + $plugin_file = plugin_basename( $plugin_file ); + + // Translate fields. + if ( $translate ) { + $textdomain = $plugin_data['TextDomain']; + if ( $textdomain ) { + if ( ! is_textdomain_loaded( $textdomain ) ) { + if ( $plugin_data['DomainPath'] ) { + load_plugin_textdomain( $textdomain, false, dirname( $plugin_file ) . $plugin_data['DomainPath'] ); + } else { + load_plugin_textdomain( $textdomain, false, dirname( $plugin_file ) ); + } + } + } elseif ( 'hello.php' === basename( $plugin_file ) ) { + $textdomain = 'default'; + } + if ( $textdomain ) { + foreach ( array( 'Name', 'PluginURI', 'Description', 'Author', 'AuthorURI', 'Version' ) as $field ) { + if ( ! empty( $plugin_data[ $field ] ) ) { + // phpcs:ignore WordPress.WP.I18n.LowLevelTranslationFunction,WordPress.WP.I18n.NonSingularStringLiteralText,WordPress.WP.I18n.NonSingularStringLiteralDomain + $plugin_data[ $field ] = translate( $plugin_data[ $field ], $textdomain ); + } + } + } + } + + // Sanitize fields. + $allowed_tags_in_links = array( + 'abbr' => array( 'title' => true ), + 'acronym' => array( 'title' => true ), + 'code' => true, + 'em' => true, + 'strong' => true, + ); + + $allowed_tags = $allowed_tags_in_links; + $allowed_tags['a'] = array( + 'href' => true, + 'title' => true, + ); + + /* + * Name is marked up inside tags. Don't allow these. + * Author is too, but some plugins have used here (omitting Author URI). + */ + $plugin_data['Name'] = wp_kses( $plugin_data['Name'], $allowed_tags_in_links ); + $plugin_data['Author'] = wp_kses( $plugin_data['Author'], $allowed_tags ); + + $plugin_data['Description'] = wp_kses( $plugin_data['Description'], $allowed_tags ); + $plugin_data['Version'] = wp_kses( $plugin_data['Version'], $allowed_tags ); + + $plugin_data['PluginURI'] = esc_url( $plugin_data['PluginURI'] ); + $plugin_data['AuthorURI'] = esc_url( $plugin_data['AuthorURI'] ); + + $plugin_data['Title'] = $plugin_data['Name']; + $plugin_data['AuthorName'] = $plugin_data['Author']; + + // Apply markup. + if ( $markup ) { + if ( $plugin_data['PluginURI'] && $plugin_data['Name'] ) { + $plugin_data['Title'] = '' . $plugin_data['Name'] . ''; + } + + if ( $plugin_data['AuthorURI'] && $plugin_data['Author'] ) { + $plugin_data['Author'] = '' . $plugin_data['Author'] . ''; + } + + $plugin_data['Description'] = wptexturize( $plugin_data['Description'] ); + + if ( $plugin_data['Author'] ) { + $plugin_data['Description'] .= sprintf( + /* translators: %s: Plugin author. */ + ' ' . __( 'By %s.' ) . '', + $plugin_data['Author'] + ); + } + } + + return $plugin_data; +} + /** * Gets a list of a plugin's files. * diff --git a/src/wp-includes/PHPMailer/PHPMailer.php b/src/wp-includes/PHPMailer/PHPMailer.php index 0bc29b7832e6e..31731594f12ec 100644 --- a/src/wp-includes/PHPMailer/PHPMailer.php +++ b/src/wp-includes/PHPMailer/PHPMailer.php @@ -253,7 +253,7 @@ class PHPMailer * You can set your own, but it must be in the format "", * as defined in RFC5322 section 3.6.4 or it will be ignored. * - * @see https://tools.ietf.org/html/rfc5322#section-3.6.4 + * @see https://www.rfc-editor.org/rfc/rfc5322#section-3.6.4 * * @var string */ @@ -387,7 +387,7 @@ class PHPMailer * 'DELAY' will notify you if there is an unusual delay in delivery, but the actual * delivery's outcome (success or failure) is not yet decided. * - * @see https://tools.ietf.org/html/rfc3461 See section 4.1 for more information about NOTIFY + * @see https://www.rfc-editor.org/rfc/rfc3461.html#section-4.1 for more information about NOTIFY */ public $dsn = ''; @@ -756,7 +756,7 @@ class PHPMailer * * @var string */ - const VERSION = '6.9.2'; + const VERSION = '6.9.3'; /** * Error severity: message only, continue processing. @@ -1873,7 +1873,7 @@ protected static function isShellSafe($string) */ protected static function isPermittedPath($path) { - //Matches scheme definition from https://tools.ietf.org/html/rfc3986#section-3.1 + //Matches scheme definition from https://www.rfc-editor.org/rfc/rfc3986#section-3.1 return !preg_match('#^[a-z][a-z\d+.-]*://#i', $path); } @@ -2707,7 +2707,7 @@ public function createHeader() } //Only allow a custom message ID if it conforms to RFC 5322 section 3.6.4 - //https://tools.ietf.org/html/rfc5322#section-3.6.4 + //https://www.rfc-editor.org/rfc/rfc5322#section-3.6.4 if ( '' !== $this->MessageID && preg_match( @@ -4912,7 +4912,7 @@ public function DKIM_Sign($signHeader) * Uses the 'relaxed' algorithm from RFC6376 section 3.4.2. * Canonicalized headers should *always* use CRLF, regardless of mailer setting. * - * @see https://tools.ietf.org/html/rfc6376#section-3.4.2 + * @see https://www.rfc-editor.org/rfc/rfc6376#section-3.4.2 * * @param string $signHeader Header * @@ -4924,7 +4924,7 @@ public function DKIM_HeaderC($signHeader) $signHeader = static::normalizeBreaks($signHeader, self::CRLF); //Unfold header lines //Note PCRE \s is too broad a definition of whitespace; RFC5322 defines it as `[ \t]` - //@see https://tools.ietf.org/html/rfc5322#section-2.2 + //@see https://www.rfc-editor.org/rfc/rfc5322#section-2.2 //That means this may break if you do something daft like put vertical tabs in your headers. $signHeader = preg_replace('/\r\n[ \t]+/', ' ', $signHeader); //Break headers out into an array @@ -4956,7 +4956,7 @@ public function DKIM_HeaderC($signHeader) * Uses the 'simple' algorithm from RFC6376 section 3.4.3. * Canonicalized bodies should *always* use CRLF, regardless of mailer setting. * - * @see https://tools.ietf.org/html/rfc6376#section-3.4.3 + * @see https://www.rfc-editor.org/rfc/rfc6376#section-3.4.3 * * @param string $body Message Body * @@ -4992,7 +4992,7 @@ public function DKIM_Add($headers_line, $subject, $body) $DKIMquery = 'dns/txt'; //Query method $DKIMtime = time(); //Always sign these headers without being asked - //Recommended list from https://tools.ietf.org/html/rfc6376#section-5.4.1 + //Recommended list from https://www.rfc-editor.org/rfc/rfc6376#section-5.4.1 $autoSignHeaders = [ 'from', 'to', @@ -5098,7 +5098,7 @@ public function DKIM_Add($headers_line, $subject, $body) } //The DKIM-Signature header is included in the signature *except for* the value of the `b` tag //which is appended after calculating the signature - //https://tools.ietf.org/html/rfc6376#section-3.5 + //https://www.rfc-editor.org/rfc/rfc6376#section-3.5 $dkimSignatureHeader = 'DKIM-Signature: v=1;' . ' d=' . $this->DKIM_domain . ';' . ' s=' . $this->DKIM_selector . ';' . static::$LE . diff --git a/src/wp-includes/PHPMailer/SMTP.php b/src/wp-includes/PHPMailer/SMTP.php index 5b238b5279758..b4eff40424ffc 100644 --- a/src/wp-includes/PHPMailer/SMTP.php +++ b/src/wp-includes/PHPMailer/SMTP.php @@ -35,7 +35,7 @@ class SMTP * * @var string */ - const VERSION = '6.9.2'; + const VERSION = '6.9.3'; /** * SMTP line break constant. @@ -62,7 +62,7 @@ class SMTP * The maximum line length allowed by RFC 5321 section 4.5.3.1.6, * *excluding* a trailing CRLF break. * - * @see https://tools.ietf.org/html/rfc5321#section-4.5.3.1.6 + * @see https://www.rfc-editor.org/rfc/rfc5321#section-4.5.3.1.6 * * @var int */ @@ -72,7 +72,7 @@ class SMTP * The maximum line length allowed for replies in RFC 5321 section 4.5.3.1.5, * *including* a trailing CRLF line break. * - * @see https://tools.ietf.org/html/rfc5321#section-4.5.3.1.5 + * @see https://www.rfc-editor.org/rfc/rfc5321#section-4.5.3.1.5 * * @var int */ @@ -373,7 +373,7 @@ public function connect($host, $port = null, $timeout = 30, $options = []) } //Anything other than a 220 response means something went wrong //RFC 5321 says the server will wait for us to send a QUIT in response to a 554 error - //https://tools.ietf.org/html/rfc5321#section-3.1 + //https://www.rfc-editor.org/rfc/rfc5321#section-3.1 if ($responseCode === 554) { $this->quit(); } @@ -582,7 +582,7 @@ public function authenticate( } //Send encoded username and password if ( - //Format from https://tools.ietf.org/html/rfc4616#section-2 + //Format from https://www.rfc-editor.org/rfc/rfc4616#section-2 //We skip the first field (it's forgery), so the string starts with a null byte !$this->sendCommand( 'User & Password', @@ -795,7 +795,7 @@ public function data($msg_data) //Send the lines to the server foreach ($lines_out as $line_out) { //Dot-stuffing as per RFC5321 section 4.5.2 - //https://tools.ietf.org/html/rfc5321#section-4.5.2 + //https://www.rfc-editor.org/rfc/rfc5321#section-4.5.2 if (!empty($line_out) && $line_out[0] === '.') { $line_out = '.' . $line_out; } diff --git a/src/wp-includes/blocks.php b/src/wp-includes/blocks.php index 9d8f4bfaf9845..94c4dfa43afb6 100644 --- a/src/wp-includes/blocks.php +++ b/src/wp-includes/blocks.php @@ -1217,6 +1217,7 @@ function update_ignored_hooked_blocks_postmeta( $post ) { $existing_post = get_post( $post->ID ); // Merge the existing post object with the updated post object to pass to the block hooks algorithm for context. $context = (object) array_merge( (array) $existing_post, (array) $post ); + $context = new WP_Post( $context ); // Convert to WP_Post object. $serialized_block = apply_block_hooks_to_content( $markup, $context, 'set_ignored_hooked_blocks_metadata' ); $root_block = parse_blocks( $serialized_block )[0]; diff --git a/src/wp-includes/class-wp-locale.php b/src/wp-includes/class-wp-locale.php index 87af36a2a42e5..a78617b5e3332 100644 --- a/src/wp-includes/class-wp-locale.php +++ b/src/wp-includes/class-wp-locale.php @@ -336,6 +336,26 @@ public function get_month_abbrev( $month_name ) { return $this->month_abbrev[ $month_name ]; } + /** + * Retrieves translated version of month genitive string. + * + * The $month_number parameter has to be a string + * because it must have the '0' in front of any number + * that is less than 10. Starts from '01' and ends at + * '12'. + * + * You can use an integer instead and it will add the + * '0' before the numbers less than 10 for you. + * + * @since 6.8.0 + * + * @param string|int $month_number '01' through '12'. + * @return string Translated genitive month name. + */ + public function get_month_genitive( $month_number ) { + return $this->month_genitive[ zeroise( $month_number, 2 ) ]; + } + /** * Retrieves translated version of meridiem string. * diff --git a/src/wp-includes/embed.php b/src/wp-includes/embed.php index c7dfd38761cb4..f7fe10e48239d 100644 --- a/src/wp-includes/embed.php +++ b/src/wp-includes/embed.php @@ -721,7 +721,7 @@ function get_oembed_response_data_rich( $data, $post, $width, $height ) { } if ( $thumbnail_id ) { - list( $thumbnail_url, $thumbnail_width, $thumbnail_height ) = wp_get_attachment_image_src( $thumbnail_id, array( $width, 99999 ) ); + list( $thumbnail_url, $thumbnail_width, $thumbnail_height ) = wp_get_attachment_image_src( $thumbnail_id, array( $width, 0 ) ); $data['thumbnail_url'] = $thumbnail_url; $data['thumbnail_width'] = $thumbnail_width; $data['thumbnail_height'] = $thumbnail_height; diff --git a/src/wp-includes/functions.php b/src/wp-includes/functions.php index dd0066cf3fb63..7e46dd53cab2d 100644 --- a/src/wp-includes/functions.php +++ b/src/wp-includes/functions.php @@ -6930,221 +6930,6 @@ function get_file_data( $file, $default_headers, $context = '' ) { return $all_headers; } -/** - * Parses the plugin contents to retrieve plugin's metadata. - * - * All plugin headers must be on their own line. Plugin description must not have - * any newlines, otherwise only parts of the description will be displayed. - * The below is formatted for printing. - * - * /* - * Plugin Name: Name of the plugin. - * Plugin URI: The home page of the plugin. - * Description: Plugin description. - * Author: Plugin author's name. - * Author URI: Link to the author's website. - * Version: Plugin version. - * Text Domain: Optional. Unique identifier, should be same as the one used in - * load_plugin_textdomain(). - * Domain Path: Optional. Only useful if the translations are located in a - * folder above the plugin's base path. For example, if .mo files are - * located in the locale folder then Domain Path will be "/locale/" and - * must have the first slash. Defaults to the base folder the plugin is - * located in. - * Network: Optional. Specify "Network: true" to require that a plugin is activated - * across all sites in an installation. This will prevent a plugin from being - * activated on a single site when Multisite is enabled. - * Requires at least: Optional. Specify the minimum required WordPress version. - * Requires PHP: Optional. Specify the minimum required PHP version. - * * / # Remove the space to close comment. - * - * The first 8 KB of the file will be pulled in and if the plugin data is not - * within that first 8 KB, then the plugin author should correct their plugin - * and move the plugin data headers to the top. - * - * The plugin file is assumed to have permissions to allow for scripts to read - * the file. This is not checked however and the file is only opened for - * reading. - * - * @since 1.5.0 - * @since 5.3.0 Added support for `Requires at least` and `Requires PHP` headers. - * @since 5.8.0 Added support for `Update URI` header. - * @since 6.5.0 Added support for `Requires Plugins` header. - * - * @param string $plugin_file Absolute path to the main plugin file. - * @param bool $markup Optional. If the returned data should have HTML markup applied. - * Default true. - * @param bool $translate Optional. If the returned data should be translated. Default true. - * @return array { - * Plugin data. Values will be empty if not supplied by the plugin. - * - * @type string $Name Name of the plugin. Should be unique. - * @type string $PluginURI Plugin URI. - * @type string $Version Plugin version. - * @type string $Description Plugin description. - * @type string $Author Plugin author's name. - * @type string $AuthorURI Plugin author's website address (if set). - * @type string $TextDomain Plugin textdomain. - * @type string $DomainPath Plugin's relative directory path to .mo files. - * @type bool $Network Whether the plugin can only be activated network-wide. - * @type string $RequiresWP Minimum required version of WordPress. - * @type string $RequiresPHP Minimum required version of PHP. - * @type string $UpdateURI ID of the plugin for update purposes, should be a URI. - * @type string $RequiresPlugins Comma separated list of dot org plugin slugs. - * @type string $Title Title of the plugin and link to the plugin's site (if set). - * @type string $AuthorName Plugin author's name. - * } - */ -function get_plugin_data( $plugin_file, $markup = true, $translate = true ) { - - $default_headers = array( - 'Name' => 'Plugin Name', - 'PluginURI' => 'Plugin URI', - 'Version' => 'Version', - 'Description' => 'Description', - 'Author' => 'Author', - 'AuthorURI' => 'Author URI', - 'TextDomain' => 'Text Domain', - 'DomainPath' => 'Domain Path', - 'Network' => 'Network', - 'RequiresWP' => 'Requires at least', - 'RequiresPHP' => 'Requires PHP', - 'UpdateURI' => 'Update URI', - 'RequiresPlugins' => 'Requires Plugins', - // Site Wide Only is deprecated in favor of Network. - '_sitewide' => 'Site Wide Only', - ); - - $plugin_data = get_file_data( $plugin_file, $default_headers, 'plugin' ); - - // Site Wide Only is the old header for Network. - if ( ! $plugin_data['Network'] && $plugin_data['_sitewide'] ) { - /* translators: 1: Site Wide Only: true, 2: Network: true */ - _deprecated_argument( __FUNCTION__, '3.0.0', sprintf( __( 'The %1$s plugin header is deprecated. Use %2$s instead.' ), 'Site Wide Only: true', 'Network: true' ) ); - $plugin_data['Network'] = $plugin_data['_sitewide']; - } - $plugin_data['Network'] = ( 'true' === strtolower( $plugin_data['Network'] ) ); - unset( $plugin_data['_sitewide'] ); - - // If no text domain is defined fall back to the plugin slug. - if ( ! $plugin_data['TextDomain'] ) { - $plugin_slug = dirname( plugin_basename( $plugin_file ) ); - if ( '.' !== $plugin_slug && ! str_contains( $plugin_slug, '/' ) ) { - $plugin_data['TextDomain'] = $plugin_slug; - } - } - - if ( $markup || $translate ) { - $plugin_data = _get_plugin_data_markup_translate( $plugin_file, $plugin_data, $markup, $translate ); - } else { - $plugin_data['Title'] = $plugin_data['Name']; - $plugin_data['AuthorName'] = $plugin_data['Author']; - } - - return $plugin_data; -} - -/** - * Sanitizes plugin data, optionally adds markup, optionally translates. - * - * @since 2.7.0 - * - * @see get_plugin_data() - * - * @access private - * - * @param string $plugin_file Path to the main plugin file. - * @param array $plugin_data An array of plugin data. See get_plugin_data(). - * @param bool $markup Optional. If the returned data should have HTML markup applied. - * Default true. - * @param bool $translate Optional. If the returned data should be translated. Default true. - * @return array Plugin data. Values will be empty if not supplied by the plugin. - * See get_plugin_data() for the list of possible values. - */ -function _get_plugin_data_markup_translate( $plugin_file, $plugin_data, $markup = true, $translate = true ) { - - // Sanitize the plugin filename to a WP_PLUGIN_DIR relative path. - $plugin_file = plugin_basename( $plugin_file ); - - // Translate fields. - if ( $translate ) { - $textdomain = $plugin_data['TextDomain']; - if ( $textdomain ) { - if ( ! is_textdomain_loaded( $textdomain ) ) { - if ( $plugin_data['DomainPath'] ) { - load_plugin_textdomain( $textdomain, false, dirname( $plugin_file ) . $plugin_data['DomainPath'] ); - } else { - load_plugin_textdomain( $textdomain, false, dirname( $plugin_file ) ); - } - } - } elseif ( 'hello.php' === basename( $plugin_file ) ) { - $textdomain = 'default'; - } - if ( $textdomain ) { - foreach ( array( 'Name', 'PluginURI', 'Description', 'Author', 'AuthorURI', 'Version' ) as $field ) { - if ( ! empty( $plugin_data[ $field ] ) ) { - // phpcs:ignore WordPress.WP.I18n.LowLevelTranslationFunction,WordPress.WP.I18n.NonSingularStringLiteralText,WordPress.WP.I18n.NonSingularStringLiteralDomain - $plugin_data[ $field ] = translate( $plugin_data[ $field ], $textdomain ); - } - } - } - } - - // Sanitize fields. - $allowed_tags_in_links = array( - 'abbr' => array( 'title' => true ), - 'acronym' => array( 'title' => true ), - 'code' => true, - 'em' => true, - 'strong' => true, - ); - - $allowed_tags = $allowed_tags_in_links; - $allowed_tags['a'] = array( - 'href' => true, - 'title' => true, - ); - - /* - * Name is marked up inside tags. Don't allow these. - * Author is too, but some plugins have used here (omitting Author URI). - */ - $plugin_data['Name'] = wp_kses( $plugin_data['Name'], $allowed_tags_in_links ); - $plugin_data['Author'] = wp_kses( $plugin_data['Author'], $allowed_tags ); - - $plugin_data['Description'] = wp_kses( $plugin_data['Description'], $allowed_tags ); - $plugin_data['Version'] = wp_kses( $plugin_data['Version'], $allowed_tags ); - - $plugin_data['PluginURI'] = esc_url( $plugin_data['PluginURI'] ); - $plugin_data['AuthorURI'] = esc_url( $plugin_data['AuthorURI'] ); - - $plugin_data['Title'] = $plugin_data['Name']; - $plugin_data['AuthorName'] = $plugin_data['Author']; - - // Apply markup. - if ( $markup ) { - if ( $plugin_data['PluginURI'] && $plugin_data['Name'] ) { - $plugin_data['Title'] = '' . $plugin_data['Name'] . ''; - } - - if ( $plugin_data['AuthorURI'] && $plugin_data['Author'] ) { - $plugin_data['Author'] = '' . $plugin_data['Author'] . ''; - } - - $plugin_data['Description'] = wptexturize( $plugin_data['Description'] ); - - if ( $plugin_data['Author'] ) { - $plugin_data['Description'] .= sprintf( - /* translators: %s: Plugin author. */ - ' ' . __( 'By %s.' ) . '', - $plugin_data['Author'] - ); - } - } - - return $plugin_data; -} - /** * Returns true. * diff --git a/src/wp-includes/html-api/class-wp-css-attribute-selector.php b/src/wp-includes/html-api/class-wp-css-attribute-selector.php new file mode 100644 index 0000000000000..7036dd3775cc1 --- /dev/null +++ b/src/wp-includes/html-api/class-wp-css-attribute-selector.php @@ -0,0 +1,219 @@ +name = $name; + $this->matcher = $matcher; + $this->value = $value; + $this->modifier = $modifier; + } + + /** + * Determines if the processor's current position matches the selector. + * + * @param WP_HTML_Tag_Processor $processor + * @return bool True if the processor's current position matches the selector. + */ + public function matches( WP_HTML_Tag_Processor $processor ): bool { + $att_value = $processor->get_attribute( $this->name ); + if ( null === $att_value ) { + return false; + } + + if ( null === $this->value ) { + return true; + } + + if ( true === $att_value ) { + $att_value = ''; + } + + $case_insensitive = self::MODIFIER_CASE_INSENSITIVE === $this->modifier; + + switch ( $this->matcher ) { + case self::MATCH_EXACT: + return $case_insensitive + ? 0 === strcasecmp( $att_value, $this->value ) + : $att_value === $this->value; + + case self::MATCH_ONE_OF_EXACT: + foreach ( $this->whitespace_delimited_list( $att_value ) as $val ) { + if ( + $case_insensitive + ? 0 === strcasecmp( $val, $this->value ) + : $val === $this->value + ) { + return true; + } + } + return false; + + case self::MATCH_EXACT_OR_EXACT_WITH_HYPHEN: + // Attempt the full match first + if ( + $case_insensitive + ? 0 === strcasecmp( $att_value, $this->value ) + : $att_value === $this->value + ) { + return true; + } + + // Partial match + if ( strlen( $att_value ) < strlen( $this->value ) + 1 ) { + return false; + } + + $starts_with = "{$this->value}-"; + return 0 === substr_compare( $att_value, $starts_with, 0, strlen( $starts_with ), $case_insensitive ); + + case self::MATCH_PREFIXED_BY: + return 0 === substr_compare( $att_value, $this->value, 0, strlen( $this->value ), $case_insensitive ); + + case self::MATCH_SUFFIXED_BY: + return 0 === substr_compare( $att_value, $this->value, -strlen( $this->value ), null, $case_insensitive ); + + case self::MATCH_CONTAINS: + return false !== ( + $case_insensitive + ? stripos( $att_value, $this->value ) + : strpos( $att_value, $this->value ) + ); + } + } + + /** + * Splits a string into a list of whitespace delimited values. + * + * This is useful for the {@see WP_CSS_Attribute_Selector::MATCH_ONE_OF_EXACT} matcher. + * + * @param string $input + * + * @return Generator + */ + private function whitespace_delimited_list( string $input ): Generator { + // Start by skipping whitespace. + $offset = strspn( $input, " \t\r\n\f" ); + + while ( $offset < strlen( $input ) ) { + // Find the byte length until the next boundary. + $length = strcspn( $input, " \t\r\n\f", $offset ); + $value = substr( $input, $offset, $length ); + + // Move past trailing whitespace. + $offset += $length + strspn( $input, " \t\r\n\f", $offset + $length ); + + yield $value; + } + } +} diff --git a/src/wp-includes/html-api/class-wp-css-class-selector.php b/src/wp-includes/html-api/class-wp-css-class-selector.php new file mode 100644 index 0000000000000..c3e7ced008a6e --- /dev/null +++ b/src/wp-includes/html-api/class-wp-css-class-selector.php @@ -0,0 +1,14 @@ +has_class( $this->ident ); + } + + /** @var string */ + public $ident; + + public function __construct( string $ident ) { + $this->ident = $ident; + } +} diff --git a/src/wp-includes/html-api/class-wp-css-complex-selector-list.php b/src/wp-includes/html-api/class-wp-css-complex-selector-list.php new file mode 100644 index 0000000000000..0413b8dea426a --- /dev/null +++ b/src/wp-includes/html-api/class-wp-css-complex-selector-list.php @@ -0,0 +1,155 @@ + in the grammar. See {@see WP_CSS_Compound_Selector_List} for more details on the grammar. + * + * This class supports the same selector syntax as {@see WP_CSS_Compound_Selector_List} as well as: + * - The following combinators: + * - Next sibling (`el + el`) + * - Subsequent sibling (`el ~ el`) + * + * @since TBD + * + * @access private + */ +class WP_CSS_Complex_Selector_List extends WP_CSS_Compound_Selector_List implements WP_CSS_HTML_Processor_Matcher { + /** + * Takes a CSS selector string and returns an instance of itself or `null` if the selector + * string is invalid or unsupported. + * + * @since TBD + * + * @param string $input CSS selectors. + * @return static|null + */ + public static function from_selectors( string $input ) { + $input = self::normalize_selector_input( $input ); + + if ( '' === $input ) { + return null; + } + + $offset = 0; + + $selector = self::parse_complex_selector( $input, $offset ); + if ( null === $selector ) { + return null; + } + self::parse_whitespace( $input, $offset ); + + $selectors = array( $selector ); + while ( $offset < strlen( $input ) ) { + // Each loop should stop on a `,` selector list delimiter. + if ( ',' !== $input[ $offset ] ) { + return null; + } + ++$offset; + self::parse_whitespace( $input, $offset ); + $selector = self::parse_complex_selector( $input, $offset ); + if ( null === $selector ) { + return null; + } + $selectors[] = $selector; + self::parse_whitespace( $input, $offset ); + } + + return new self( $selectors ); + } + + /* + * ------------------------------ + * Selector parsing functionality + * ------------------------------ + */ + + /** + * Parses a complex selector. + * + * > = [ ? ]* + * + * @return WP_CSS_Complex_Selector|null + */ + final protected static function parse_complex_selector( string $input, int &$offset ): ?WP_CSS_Complex_Selector { + if ( $offset >= strlen( $input ) ) { + return null; + } + + $updated_offset = $offset; + $selector = self::parse_compound_selector( $input, $updated_offset ); + if ( null === $selector ) { + return null; + } + + $selectors = array( $selector ); + $has_preceding_subclass_selector = null !== $selector->subclass_selectors; + + $found_whitespace = self::parse_whitespace( $input, $updated_offset ); + while ( $updated_offset < strlen( $input ) ) { + if ( + WP_CSS_Complex_Selector::COMBINATOR_CHILD === $input[ $updated_offset ] || + WP_CSS_Complex_Selector::COMBINATOR_NEXT_SIBLING === $input[ $updated_offset ] || + WP_CSS_Complex_Selector::COMBINATOR_SUBSEQUENT_SIBLING === $input[ $updated_offset ] + ) { + $combinator = $input[ $updated_offset ]; + ++$updated_offset; + self::parse_whitespace( $input, $updated_offset ); + + // Failure to find a selector here is a parse error + $selector = self::parse_compound_selector( $input, $updated_offset ); + } elseif ( $found_whitespace ) { + /* + * Whitespace is ambiguous, it could be a descendant combinator or + * insignificant whitespace. + */ + $selector = self::parse_compound_selector( $input, $updated_offset ); + if ( null === $selector ) { + break; + } + $combinator = WP_CSS_Complex_Selector::COMBINATOR_DESCENDANT; + } else { + break; + } + + if ( null === $selector ) { + return null; + } + + /* + * Subclass selectors in non-final position is not supported: + * - `div > .className` is valid + * - `.className > div` is not + */ + if ( $has_preceding_subclass_selector ) { + return null; + } + $has_preceding_subclass_selector = null !== $selector->subclass_selectors; + + $selectors[] = $combinator; + $selectors[] = $selector; + + $found_whitespace = self::parse_whitespace( $input, $updated_offset ); + } + $offset = $updated_offset; + return new WP_CSS_Complex_Selector( $selectors ); + } +} diff --git a/src/wp-includes/html-api/class-wp-css-complex-selector.php b/src/wp-includes/html-api/class-wp-css-complex-selector.php new file mode 100644 index 0000000000000..a4cfd46622560 --- /dev/null +++ b/src/wp-includes/html-api/class-wp-css-complex-selector.php @@ -0,0 +1,88 @@ + in the grammar. + * + * > = [ ? ] * + */ +final class WP_CSS_Complex_Selector implements WP_CSS_HTML_Processor_Matcher { + public function matches( WP_HTML_Processor $processor ): bool { + // First selector must match this location. + if ( ! $this->selectors[0]->matches( $processor ) ) { + return false; + } + + if ( count( $this->selectors ) === 1 ) { + return true; + } + + /** @var string[] */ + $breadcrumbs = array_slice( array_reverse( $processor->get_breadcrumbs() ), 1 ); + $selectors = array_slice( $this->selectors, 1 ); + return $this->explore_matches( $selectors, $breadcrumbs ); + } + + /** + * This only looks at breadcrumbs and can therefore only support type selectors. + * + * @param array $selectors + * @param string[] $breadcrumbs + */ + private function explore_matches( array $selectors, array $breadcrumbs ): bool { + if ( array() === $selectors ) { + return true; + } + if ( array() === $breadcrumbs ) { + return false; + } + + /** @var self::COMBINATOR_* */ + $combinator = $selectors[0]; + /** @var WP_CSS_Compound_Selector */ + $selector = $selectors[1]; + + switch ( $combinator ) { + case self::COMBINATOR_CHILD: + if ( '*' === $selector->type_selector->ident || strcasecmp( $breadcrumbs[0], $selector->type_selector->ident ) === 0 ) { + return $this->explore_matches( array_slice( $selectors, 2 ), array_slice( $breadcrumbs, 1 ) ); + } + return false; + + case self::COMBINATOR_DESCENDANT: + // Find _all_ the breadcrumbs that match and recurse from each of them. + for ( $i = 0; $i < count( $breadcrumbs ); $i++ ) { + if ( '*' === $selector->type_selector->ident || strcasecmp( $breadcrumbs[ $i ], $selector->type_selector->ident ) === 0 ) { + $next_crumbs = array_slice( $breadcrumbs, $i + 1 ); + if ( $this->explore_matches( array_slice( $selectors, 2 ), $next_crumbs ) ) { + return true; + } + } + } + return false; + + default: + throw new Exception( "Combinator '{$combinator}' is not supported yet." ); + } + } + + const COMBINATOR_CHILD = '>'; + const COMBINATOR_DESCENDANT = ' '; + const COMBINATOR_NEXT_SIBLING = '+'; + const COMBINATOR_SUBSEQUENT_SIBLING = '~'; + + /** + * even indexes are WP_CSS_Compound_Selector, odd indexes are string combinators. + * In reverse order to match the current element and then work up the tree. + * Any non-final selector is a type selector. + * + * @var array + */ + public $selectors = array(); + + /** + * @param array $selectors + */ + public function __construct( array $selectors ) { + $this->selectors = array_reverse( $selectors ); + } +} diff --git a/src/wp-includes/html-api/class-wp-css-compound-selector-list.php b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php new file mode 100644 index 0000000000000..8cca2e27c9ec3 --- /dev/null +++ b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php @@ -0,0 +1,867 @@ + in the grammar. The supported grammar is: + * + * = + * = # + * = # + * = [ ? ]* + * = [ ? * ]! + * = '>' | [ '|' '|' ] + * = | '*' + * = | | + * = + * = '.' + * = '[' ']' | + * '[' [ | ] ? ']' + * = [ '~' | '|' | '^' | '$' | '*' ]? '=' + * = i | I | s | S + * + * @link https://www.w3.org/TR/selectors/#grammar Refer to the grammar for more details. + * + * This class of selectors does not support "complex" selectors. That is any selector with a + * combinator such as descendent (`.ancestor .descendant`) or child (`.parent > .child`). + * See {@see WP_CSS_Complex_Selector_List} for support of some combinators. + * + * Note that this grammar has been adapted and does not support the full CSS selector grammar. + * Supported selector syntax: + * - Type selectors (tag names, e.g. `div`) + * - Class selectors (e.g. `.class-name`) + * - ID selectors (e.g. `#unique-id`) + * - Attribute selectors (e.g. `[attribute-name]` or `[attribute-name="value"]`) + * - Comma-separated selector lists (e.g. `.selector-1, .selector-2`) + * - The following combinators. Only type (element) selectors are allowed in non-final position: + * - descendant (e.g. `el .descendant`) + * - child (`el > .child`) + * + * Unsupported selector syntax: + * - Pseudo-element selectors (`::before`) + * - Pseudo-class selectors (`:hover` or `:nth-child(2)`) + * - Namespace prefixes (`svg|title` or `[xlink|href]`) + * - No combinators are supported (descendant, child, next sibling, subsequent sibling) + * + * Future ideas: + * - Namespace type selectors could be implemented with select namespaces in order to + * select elements from a namespace, for example: + * - `svg|*` to select all SVG elements + * - `html|title` to select only HTML TITLE elements. + * + * @since TBD + * + * @access private + * + * @link https://www.w3.org/TR/css-syntax-3/ + * @link https://www.w3.org/tr/selectors/ + * @link https://www.w3.org/TR/selectors-api2/ + * @link https://www.w3.org/TR/selectors-4/ + */ +class WP_CSS_Compound_Selector_List implements WP_CSS_HTML_Tag_Processor_Matcher { + /** + * @param WP_HTML_Tag_Processor $processor + * @return bool + */ + public function matches( $processor ): bool { + if ( $processor->get_token_type() !== '#tag' ) { + return false; + } + + foreach ( $this->selectors as $selector ) { + if ( $selector->matches( $processor ) ) { + return true; + } + } + return false; + } + + /** + * Array of selectors. + * + * @var array + */ + private $selectors; + + /** + * Constructor. + * + * @param array $selectors Array of selectors. + */ + protected function __construct( array $selectors ) { + $this->selectors = $selectors; + } + + /** + * Takes a CSS selector string and returns an instance of itself or `null` if the selector + * string is invalid or unsupported. + * + * @since TBD + * + * @param string $input CSS selectors. + * @return static|null + */ + public static function from_selectors( string $input ) { + $input = self::normalize_selector_input( $input ); + + if ( '' === $input ) { + return null; + } + + $offset = 0; + + $selector = self::parse_compound_selector( $input, $offset ); + if ( null === $selector ) { + return null; + } + self::parse_whitespace( $input, $offset ); + + $selectors = array( $selector ); + while ( $offset < strlen( $input ) ) { + // Each loop should stop on a `,` selector list delimiter. + if ( ',' !== $input[ $offset ] ) { + return null; + } + ++$offset; + self::parse_whitespace( $input, $offset ); + $selector = self::parse_compound_selector( $input, $offset ); + if ( null === $selector ) { + return null; + } + $selectors[] = $selector; + self::parse_whitespace( $input, $offset ); + } + + return new self( $selectors ); + } + + /* + * ------------------------------ + * Selector parsing functionality + * ------------------------------ + */ + + /** + * Parse an ID selector + * + * > = + * + * https://www.w3.org/TR/selectors/#grammar + * + * @return WP_CSS_ID_Selector|null + */ + final protected static function parse_id_selector( string $input, int &$offset ): ?WP_CSS_ID_Selector { + $ident = self::parse_hash_token( $input, $offset ); + if ( null === $ident ) { + return null; + } + return new WP_CSS_ID_Selector( $ident ); + } + + /** + * Parse a class selector + * + * > = '.' + * + * https://www.w3.org/TR/selectors/#grammar + * + * @return WP_CSS_Class_Selector|null + */ + final protected static function parse_class_selector( string $input, int &$offset ): ?WP_CSS_Class_Selector { + if ( $offset + 1 >= strlen( $input ) || '.' !== $input[ $offset ] ) { + return null; + } + + $updated_offset = $offset + 1; + $result = self::parse_ident( $input, $updated_offset ); + + if ( null === $result ) { + return null; + } + + $offset = $updated_offset; + return new WP_CSS_Class_Selector( $result ); + } + + /** + * Parse a type selector + * + * > = | ? '*' + * > = [ | '*' ]? '|' + * > = ? + * + * Namespaces (e.g. |div, *|div, or namespace|div) are not supported, + * so this selector effectively matches * or ident. + * + * https://www.w3.org/TR/selectors/#grammar + * + * @return WP_CSS_Type_Selector|null + */ + final protected static function parse_type_selector( string $input, int &$offset ): ?WP_CSS_Type_Selector { + if ( $offset >= strlen( $input ) ) { + return null; + } + + if ( '*' === $input[ $offset ] ) { + ++$offset; + return new WP_CSS_Type_Selector( '*' ); + } + + $result = self::parse_ident( $input, $offset ); + if ( null === $result ) { + return null; + } + + return new WP_CSS_Type_Selector( $result ); + } + + /** + * Parse an attribute selector + * + * > = '[' ']' | + * > '[' [ | ] ? ']' + * > = [ '~' | '|' | '^' | '$' | '*' ]? '=' + * > = i | s + * > = ? + * + * Namespaces are not supported, so attribute names are effectively identifiers. + * + * https://www.w3.org/TR/selectors/#grammar + * + * @return WP_CSS_Attribute_Selector|null + */ + final protected static function parse_attribute_selector( string $input, int &$offset ): ?WP_CSS_Attribute_Selector { + // Need at least 3 bytes [x] + if ( $offset + 2 >= strlen( $input ) ) { + return null; + } + + $updated_offset = $offset; + + if ( '[' !== $input[ $updated_offset ] ) { + return null; + } + ++$updated_offset; + + self::parse_whitespace( $input, $updated_offset ); + $attr_name = self::parse_ident( $input, $updated_offset ); + if ( null === $attr_name ) { + return null; + } + self::parse_whitespace( $input, $updated_offset ); + + if ( $updated_offset >= strlen( $input ) ) { + return null; + } + + if ( ']' === $input[ $updated_offset ] ) { + $offset = $updated_offset + 1; + return new WP_CSS_Attribute_Selector( $attr_name ); + } + + // need to match at least `=x]` at this point + if ( $updated_offset + 3 >= strlen( $input ) ) { + return null; + } + + if ( '=' === $input[ $updated_offset ] ) { + ++$updated_offset; + $attr_matcher = WP_CSS_Attribute_Selector::MATCH_EXACT; + } elseif ( '=' === $input[ $updated_offset + 1 ] ) { + switch ( $input[ $updated_offset ] ) { + case '~': + $attr_matcher = WP_CSS_Attribute_Selector::MATCH_ONE_OF_EXACT; + $updated_offset += 2; + break; + case '|': + $attr_matcher = WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN; + $updated_offset += 2; + break; + case '^': + $attr_matcher = WP_CSS_Attribute_Selector::MATCH_PREFIXED_BY; + $updated_offset += 2; + break; + case '$': + $attr_matcher = WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY; + $updated_offset += 2; + break; + case '*': + $attr_matcher = WP_CSS_Attribute_Selector::MATCH_CONTAINS; + $updated_offset += 2; + break; + default: + return null; + } + } else { + return null; + } + + self::parse_whitespace( $input, $updated_offset ); + $attr_val = + self::parse_string( $input, $updated_offset ) ?? + self::parse_ident( $input, $updated_offset ); + + if ( null === $attr_val ) { + return null; + } + + self::parse_whitespace( $input, $updated_offset ); + if ( $updated_offset >= strlen( $input ) ) { + return null; + } + + $attr_modifier = null; + switch ( $input[ $updated_offset ] ) { + case 'i': + case 'I': + $attr_modifier = WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE; + ++$updated_offset; + break; + + case 's': + case 'S': + $attr_modifier = WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE; + ++$updated_offset; + break; + } + + if ( null !== $attr_modifier ) { + self::parse_whitespace( $input, $updated_offset ); + if ( $updated_offset >= strlen( $input ) ) { + return null; + } + } + + if ( ']' === $input[ $updated_offset ] ) { + $offset = $updated_offset + 1; + return new WP_CSS_Attribute_Selector( $attr_name, $attr_matcher, $attr_val, $attr_modifier ); + } + + return null; + } + + /** + * Parses a compound selector. + * + * > = [ ? * ]! + * + * @return WP_CSS_Compound_Selector|null + */ + final protected static function parse_compound_selector( string $input, int &$offset ): ?WP_CSS_Compound_Selector { + if ( $offset >= strlen( $input ) ) { + return null; + } + + $updated_offset = $offset; + $type_selector = self::parse_type_selector( $input, $updated_offset ); + + $subclass_selectors = array(); + $last_parsed_subclass_selector = self::parse_subclass_selector( $input, $updated_offset ); + while ( null !== $last_parsed_subclass_selector ) { + $subclass_selectors[] = $last_parsed_subclass_selector; + $last_parsed_subclass_selector = self::parse_subclass_selector( $input, $updated_offset ); + } + + if ( null !== $type_selector || array() !== $subclass_selectors ) { + $offset = $updated_offset; + return new WP_CSS_Compound_Selector( $type_selector, $subclass_selectors ); + } + return null; + } + + /** + * Parses a subclass selector. + * + * > = | | + * + * @return WP_CSS_ID_Selector|WP_CSS_Class_Selector|WP_CSS_Attribute_Selector|null + */ + private static function parse_subclass_selector( string $input, int &$offset ) { + if ( $offset >= strlen( $input ) ) { + return null; + } + + $next_char = $input[ $offset ]; + return '.' === $next_char + ? self::parse_class_selector( $input, $offset ) + : ( + '#' === $next_char + ? self::parse_id_selector( $input, $offset ) + : ( '[' === $next_char + ? self::parse_attribute_selector( $input, $offset ) + : null + ) + ); + } + + + /* + * ------------------------ + * Selector partial parsing + * ------------------------ + * + * These functions consume parts of a selector string input when successful + * and return meaningful values to be used by selectors. + */ + + const UTF8_MAX_CODEPOINT_VALUE = 0x10FFFF; + const WHITESPACE_CHARACTERS = " \t\r\n\f"; + + final public static function parse_whitespace( string $input, int &$offset ): bool { + $length = strspn( $input, self::WHITESPACE_CHARACTERS, $offset ); + $advanced = $length > 0; + $offset += $length; + return $advanced; + } + + /** + * Tokenization of hash tokens + * + * > U+0023 NUMBER SIGN (#) + * > If the next input code point is an ident code point or the next two input code points are a valid escape, then: + * > 1. Create a . + * > 2. If the next 3 input code points would start an ident sequence, set the + * > ’s type flag to "id". + * > 3. Consume an ident sequence, and set the ’s value to the + * > returned string. + * > 4. Return the . + * > Otherwise, return a with its value set to the current input code point. + * + * This implementation is not interested in the , a '#' delim token is not relevant for selectors. + */ + final protected static function parse_hash_token( string $input, int &$offset ): ?string { + if ( $offset + 1 >= strlen( $input ) || '#' !== $input[ $offset ] ) { + return null; + } + + $updated_offset = $offset + 1; + $result = self::parse_ident( $input, $updated_offset ); + + if ( null === $result ) { + return null; + } + + $offset = $updated_offset; + return $result; + } + + /** + * Parse an ident token + * + * CAUTION: This method is _not_ for parsing and ID selector! + * + * > 4.3.11. Consume an ident sequence + * > This section describes how to consume an ident sequence from a stream of code points. It returns a string containing the largest name that can be formed from adjacent code points in the stream, starting from the first. + * > + * > Note: This algorithm does not do the verification of the first few code points that are necessary to ensure the returned code points would constitute an . If that is the intended use, ensure that the stream starts with an ident sequence before calling this algorithm. + * > + * > Let result initially be an empty string. + * > + * > Repeatedly consume the next input code point from the stream: + * > + * > ident code point + * > Append the code point to result. + * > the stream starts with a valid escape + * > Consume an escaped code point. Append the returned code point to result. + * > anything else + * > Reconsume the current input code point. Return result. + * + * https://www.w3.org/TR/css-syntax-3/#consume-name + * + * @return string|null + */ + final protected static function parse_ident( string $input, int &$offset ): ?string { + if ( ! self::check_if_three_code_points_would_start_an_ident_sequence( $input, $offset ) ) { + return null; + } + + $ident = ''; + + while ( $offset < strlen( $input ) ) { + if ( self::next_two_are_valid_escape( $input, $offset ) ) { + // Move past the `\` character. + ++$offset; + $ident .= self::consume_escaped_codepoint( $input, $offset ); + continue; + } elseif ( self::is_ident_codepoint( $input, $offset ) ) { + // @todo this should append and advance the correct number of bytes. + $ident .= $input[ $offset ]; + ++$offset; + continue; + } + break; + } + + return $ident; + } + + /** + * Parse a string token + * + * > 4.3.5. Consume a string token + * > This section describes how to consume a string token from a stream of code points. It returns either a or . + * > + * > This algorithm may be called with an ending code point, which denotes the code point that ends the string. If an ending code point is not specified, the current input code point is used. + * > + * > Initially create a with its value set to the empty string. + * > + * > Repeatedly consume the next input code point from the stream: + * > + * > ending code point + * > Return the . + * > EOF + * > This is a parse error. Return the . + * > newline + * > This is a parse error. Reconsume the current input code point, create a , and return it. + * > U+005C REVERSE SOLIDUS (\) + * > If the next input code point is EOF, do nothing. + * > Otherwise, if the next input code point is a newline, consume it. + * > Otherwise, (the stream starts with a valid escape) consume an escaped code point and append the returned code point to the ’s value. + * > + * > anything else + * > Append the current input code point to the ’s value. + * + * https://www.w3.org/TR/css-syntax-3/#consume-string-token + * + * This implementation will never return a because + * the is not a part of the selector grammar. That + * case is treated as failure to parse and null is returned. + * + * @return string|null + */ + final protected static function parse_string( string $input, int &$offset ): ?string { + if ( $offset >= strlen( $input ) ) { + return null; + } + + $ending_code_point = $input[ $offset ]; + if ( '"' !== $ending_code_point && "'" !== $ending_code_point ) { + return null; + } + + $string_token = ''; + + $updated_offset = $offset + 1; + $anything_else_mask = "\\\n{$ending_code_point}"; + while ( $updated_offset < strlen( $input ) ) { + $anything_else_length = strcspn( $input, $anything_else_mask, $updated_offset ); + if ( $anything_else_length > 0 ) { + $string_token .= substr( $input, $updated_offset, $anything_else_length ); + $updated_offset += $anything_else_length; + + if ( $updated_offset >= strlen( $input ) ) { + break; + } + } + + switch ( $input[ $updated_offset ] ) { + case '\\': + ++$updated_offset; + if ( $updated_offset >= strlen( $input ) ) { + break; + } + if ( "\n" === $input[ $updated_offset ] ) { + ++$updated_offset; + break; + } else { + $string_token .= self::consume_escaped_codepoint( $input, $updated_offset ); + } + break; + + /* + * This case would return a . + * The is not a part of the selector grammar + * so we do not return it and instead treat this as a + * failure to parse a string token. + */ + case "\n": + return null; + + case $ending_code_point: + ++$updated_offset; + break 2; + } + } + + $offset = $updated_offset; + return $string_token; + } + + /** + * Consume an escaped code point. + * + * > 4.3.7. Consume an escaped code point + * > This section describes how to consume an escaped code point. It assumes that the U+005C + * > REVERSE SOLIDUS (\) has already been consumed and that the next input code point has + * > already been verified to be part of a valid escape. It will return a code point. + * > + * > Consume the next input code point. + * > + * > hex digit + * > Consume as many hex digits as possible, but no more than 5. Note that this means 1-6 + * > hex digits have been consumed in total. If the next input code point is whitespace, + * > consume it as well. Interpret the hex digits as a hexadecimal number. If this number is + * > zero, or is for a surrogate, or is greater than the maximum allowed code point, return + * > U+FFFD REPLACEMENT CHARACTER (�). Otherwise, return the code point with that value. + * > EOF + * > This is a parse error. Return U+FFFD REPLACEMENT CHARACTER (�). + * > anything else + * > Return the current input code point. + * + * @param string $input + * @param int $offset + * @return string + */ + final protected static function consume_escaped_codepoint( $input, &$offset ): string { + $hex_length = strspn( $input, '0123456789abcdefABCDEF', $offset, 6 ); + if ( $hex_length > 0 ) { + /** + * The 6-character hex string has a maximum value of 0xFFFFFF. + * It is likely to fit in an int value and not be a float. + * + * @var int + */ + $codepoint_value = hexdec( substr( $input, $offset, $hex_length ) ); + + /* + * > A surrogate is a leading surrogate or a trailing surrogate. + * > A leading surrogate is a code point that is in the range U+D800 to U+DBFF, inclusive. + * > A trailing surrogate is a code point that is in the range U+DC00 to U+DFFF, inclusive. + * + * The surrogate ranges are adjacent, so the complete range is 0xD800 to 0xDFFF, inclusive. + */ + $codepoint_char = ( + 0 === $codepoint_value || + $codepoint_value > self::UTF8_MAX_CODEPOINT_VALUE || + ( 0xD800 <= $codepoint_value && $codepoint_value <= 0xDFFF ) + ) + ? "\u{FFFD}" + : mb_chr( $codepoint_value, 'UTF-8' ); + + $offset += $hex_length; + + // If the next input code point is whitespace, consume it as well. + if ( + strlen( $input ) > $offset && + ( + "\n" === $input[ $offset ] || + "\t" === $input[ $offset ] || + ' ' === $input[ $offset ] + ) + ) { + ++$offset; + } + return $codepoint_char; + } + + $codepoint_char = mb_substr( $input, $offset, 1, 'UTF-8' ); + $offset += strlen( $codepoint_char ); + return $codepoint_char; + } + + /* + * --------------------------- + * Selector parsing utiltities + * --------------------------- + * + * The following functions are used for parsing but do not consume any input. + */ + + /** + * Checks for two valid escape codepoints. + * + * > 4.3.8. Check if two code points are a valid escape + * > This section describes how to check if two code points are a valid escape. The algorithm described here can be called explicitly with two code points, or can be called with the input stream itself. In the latter case, the two code points in question are the current input code point and the next input code point, in that order. + * > + * > Note: This algorithm will not consume any additional code point. + * > + * > If the first code point is not U+005C REVERSE SOLIDUS (\), return false. + * > + * > Otherwise, if the second code point is a newline, return false. + * > + * > Otherwise, return true. + * + * https://www.w3.org/TR/css-syntax-3/#starts-with-a-valid-escape + * + * @todo this does not check whether the second codepoint is valid. + * + * @param string $input The input string. + * @param int $offset The byte offset in the string. + * @return bool True if the next two codepoints are a valid escape, otherwise false. + */ + final protected static function next_two_are_valid_escape( string $input, int $offset ): bool { + if ( $offset + 1 >= strlen( $input ) ) { + return false; + } + return '\\' === $input[ $offset ] && "\n" !== $input[ $offset + 1 ]; + } + + /** + * Checks if the next code point is an "ident start code point". + * + * Caution! This method does not do any bounds checking, it should not be passed + * a string with an offset that is out of bounds. + * + * > ident-start code point + * > A letter, a non-ASCII code point, or U+005F LOW LINE (_). + * > uppercase letter + * > A code point between U+0041 LATIN CAPITAL LETTER A (A) and U+005A LATIN CAPITAL LETTER Z (Z) inclusive. + * > lowercase letter + * > A code point between U+0061 LATIN SMALL LETTER A (a) and U+007A LATIN SMALL LETTER Z (z) inclusive. + * > letter + * > An uppercase letter or a lowercase letter. + * > non-ASCII code point + * > A code point with a value equal to or greater than U+0080 . + * + * @link https://www.w3.org/TR/css-syntax-3/#ident-start-code-point + * + * @param string $input The input string. + * @param int $offset The byte offset in the string. + * @return bool True if the next codepoint is an ident start code point, otherwise false. + */ + final protected static function is_ident_start_codepoint( string $input, int $offset ): bool { + return ( + '_' === $input[ $offset ] || + ( 'a' <= $input[ $offset ] && $input[ $offset ] <= 'z' ) || + ( 'A' <= $input[ $offset ] && $input[ $offset ] <= 'Z' ) || + ord( $input[ $offset ] ) > 0x7F + ); + } + + /** + * Checks if the next code point is an "ident code point". + * + * Caution! This method does not do any bounds checking, it should not be passed + * a string with an offset that is out of bounds. + * + * > ident code point + * > An ident-start code point, a digit, or U+002D HYPHEN-MINUS (-). + * > digit + * > A code point between U+0030 DIGIT ZERO (0) and U+0039 DIGIT NINE (9) inclusive. + * + * @link https://www.w3.org/TR/css-syntax-3/#ident-code-point + * + * @param string $input The input string. + * @param int $offset The byte offset in the string. + * @return bool True if the next codepoint is an ident code point, otherwise false. + */ + final protected static function is_ident_codepoint( string $input, int $offset ): bool { + return '-' === $input[ $offset ] || + ( '0' <= $input[ $offset ] && $input[ $offset ] <= '9' ) || + self::is_ident_start_codepoint( $input, $offset ); + } + + /** + * Checks if three code points would start an ident sequence. + * + * > 4.3.9. Check if three code points would start an ident sequence + * > This section describes how to check if three code points would start an ident sequence. The algorithm described here can be called explicitly with three code points, or can be called with the input stream itself. In the latter case, the three code points in question are the current input code point and the next two input code points, in that order. + * > + * > Note: This algorithm will not consume any additional code points. + * > + * > Look at the first code point: + * > + * > U+002D HYPHEN-MINUS + * > If the second code point is an ident-start code point or a U+002D HYPHEN-MINUS, or the second and third code points are a valid escape, return true. Otherwise, return false. + * > ident-start code point + * > Return true. + * > U+005C REVERSE SOLIDUS (\) + * > If the first and second code points are a valid escape, return true. Otherwise, return false. + * > anything else + * > Return false. + * + * @link https://www.w3.org/TR/css-syntax-3/#would-start-an-identifier + * + * @param string $input The input string. + * @param int $offset The byte offset in the string. + * @return bool True if the next three codepoints would start an ident sequence, otherwise false. + */ + final protected static function check_if_three_code_points_would_start_an_ident_sequence( string $input, int $offset ): bool { + if ( $offset >= strlen( $input ) ) { + return false; + } + + // > U+005C REVERSE SOLIDUS (\) + if ( '\\' === $input[ $offset ] ) { + return self::next_two_are_valid_escape( $input, $offset ); + } + + // > U+002D HYPHEN-MINUS + if ( '-' === $input[ $offset ] ) { + $after_initial_hyphen_minus_offset = $offset + 1; + if ( $after_initial_hyphen_minus_offset >= strlen( $input ) ) { + return false; + } + + // > If the second code point is… U+002D HYPHEN-MINUS… return true + if ( '-' === $input[ $after_initial_hyphen_minus_offset ] ) { + return true; + } + + // > If the second and third code points are a valid escape… return true. + if ( self::next_two_are_valid_escape( $input, $after_initial_hyphen_minus_offset ) ) { + return true; + } + + // > If the second code point is an ident-start code point… return true. + if ( self::is_ident_start_codepoint( $input, $after_initial_hyphen_minus_offset ) ) { + return true; + } + + // > Otherwise, return false. + return false; + } + + // > ident-start code point + // > Return true. + // > anything else + // > Return false. + return self::is_ident_start_codepoint( $input, $offset ); + } + + /** + * @todo doc… + */ + final protected static function normalize_selector_input( string $input ): string { + /* + * > A selector string is a list of one or more complex selectors ([SELECTORS4], section 3.1) that may be surrounded by whitespace… + * + * This list includes \f. + * A later step would normalize it to a known whitespace character, but it can be trimmed here as well. + */ + $input = trim( $input, " \t\r\n\r\f" ); + + /* + * > The input stream consists of the filtered code points pushed into it as the input byte stream is decoded. + * > + * > To filter code points from a stream of (unfiltered) code points input: + * > Replace any U+000D CARRIAGE RETURN (CR) code points, U+000C FORM FEED (FF) code points, or pairs of U+000D CARRIAGE RETURN (CR) followed by U+000A LINE FEED (LF) in input by a single U+000A LINE FEED (LF) code point. + * > Replace any U+0000 NULL or surrogate code points in input with U+FFFD REPLACEMENT CHARACTER (�). + * + * https://www.w3.org/TR/css-syntax-3/#input-preprocessing + */ + $input = str_replace( array( "\r\n" ), "\n", $input ); + $input = str_replace( array( "\r", "\f" ), "\n", $input ); + $input = str_replace( "\0", "\u{FFFD}", $input ); + + return $input; + } +} diff --git a/src/wp-includes/html-api/class-wp-css-compound-selector.php b/src/wp-includes/html-api/class-wp-css-compound-selector.php new file mode 100644 index 0000000000000..e64695abe9ab3 --- /dev/null +++ b/src/wp-includes/html-api/class-wp-css-compound-selector.php @@ -0,0 +1,39 @@ + in the grammar. + * + * > = [ ? * ]! + */ +final class WP_CSS_Compound_Selector implements WP_CSS_HTML_Tag_Processor_Matcher { + public function matches( WP_HTML_Tag_Processor $processor ): bool { + if ( $this->type_selector ) { + if ( ! $this->type_selector->matches( $processor ) ) { + return false; + } + } + if ( null !== $this->subclass_selectors ) { + foreach ( $this->subclass_selectors as $subclass_selector ) { + if ( ! $subclass_selector->matches( $processor ) ) { + return false; + } + } + } + return true; + } + + /** @var WP_CSS_Type_Selector|null */ + public $type_selector; + + /** @var array|null */ + public $subclass_selectors; + + /** + * @param WP_CSS_Type_Selector|null $type_selector + * @param array $subclass_selectors + */ + public function __construct( ?WP_CSS_Type_Selector $type_selector, array $subclass_selectors ) { + $this->type_selector = $type_selector; + $this->subclass_selectors = array() === $subclass_selectors ? null : $subclass_selectors; + } +} diff --git a/src/wp-includes/html-api/class-wp-css-id-selector.php b/src/wp-includes/html-api/class-wp-css-id-selector.php new file mode 100644 index 0000000000000..83339ff839317 --- /dev/null +++ b/src/wp-includes/html-api/class-wp-css-id-selector.php @@ -0,0 +1,23 @@ +ident = $ident; + } + + public function matches( WP_HTML_Tag_Processor $processor ): bool { + $id = $processor->get_attribute( 'id' ); + if ( ! is_string( $id ) ) { + return false; + } + + $case_insensitive = $processor->is_quirks_mode(); + + return $case_insensitive + ? 0 === strcasecmp( $id, $this->ident ) + : $processor->get_attribute( 'id' ) === $this->ident; + } +} diff --git a/src/wp-includes/html-api/class-wp-css-type-selector.php b/src/wp-includes/html-api/class-wp-css-type-selector.php new file mode 100644 index 0000000000000..c65adce14047d --- /dev/null +++ b/src/wp-includes/html-api/class-wp-css-type-selector.php @@ -0,0 +1,25 @@ +get_tag(); + if ( null === $tag_name ) { + return false; + } + if ( '*' === $this->ident ) { + return true; + } + return 0 === strcasecmp( $tag_name, $this->ident ); + } + + /** + * @var string + * + * The type identifier string or '*'. + */ + public $ident; + + public function __construct( string $ident ) { + $this->ident = $ident; + } +} diff --git a/src/wp-includes/html-api/class-wp-html-active-formatting-elements.php b/src/wp-includes/html-api/class-wp-html-active-formatting-elements.php index 2f51482eee052..b545e5a861e72 100644 --- a/src/wp-includes/html-api/class-wp-html-active-formatting-elements.php +++ b/src/wp-includes/html-api/class-wp-html-active-formatting-elements.php @@ -29,6 +29,7 @@ * @since 6.4.0 * * @access private + * @codeCoverageIgnore * * @see https://html.spec.whatwg.org/#list-of-active-formatting-elements * @see WP_HTML_Processor diff --git a/src/wp-includes/html-api/class-wp-html-attribute-token.php b/src/wp-includes/html-api/class-wp-html-attribute-token.php index 74d41320b1c79..a93115c8fb77d 100644 --- a/src/wp-includes/html-api/class-wp-html-attribute-token.php +++ b/src/wp-includes/html-api/class-wp-html-attribute-token.php @@ -16,6 +16,7 @@ * @access private * @since 6.2.0 * @since 6.5.0 Replaced `end` with `length` to more closely match `substr()`. + * @codeCoverageIgnore * * @see WP_HTML_Tag_Processor */ diff --git a/src/wp-includes/html-api/class-wp-html-decoder.php b/src/wp-includes/html-api/class-wp-html-decoder.php index 6c1404beddcf1..ff0c9438f6bec 100644 --- a/src/wp-includes/html-api/class-wp-html-decoder.php +++ b/src/wp-includes/html-api/class-wp-html-decoder.php @@ -8,6 +8,7 @@ * @package WordPress * @subpackage HTML-API * @since 6.6.0 + * @codeCoverageIgnore */ class WP_HTML_Decoder { /** 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 index e0396f7d7d603..2087f689c0b26 100644 --- a/src/wp-includes/html-api/class-wp-html-doctype-info.php +++ b/src/wp-includes/html-api/class-wp-html-doctype-info.php @@ -49,6 +49,7 @@ * @see https://www.iso.org/standard/16387.html * * @since 6.7.0 + * @codeCoverageIgnore * * @see WP_HTML_Processor */ 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 210492ab9af08..eff82fecead25 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 @@ -21,6 +21,7 @@ * > for misnested tags). * * @since 6.4.0 + * @codeCoverageIgnore * * @access private * 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 b257aa809da75..75a4a996251f1 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 @@ -16,6 +16,7 @@ * @since 6.4.0 * * @access private + * @codeCoverageIgnore * * @see WP_HTML_Processor */ 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 e88757ec7b4c2..0ad41d932add0 100644 --- a/src/wp-includes/html-api/class-wp-html-processor.php +++ b/src/wp-includes/html-api/class-wp-html-processor.php @@ -136,6 +136,7 @@ * these situations and will bail. * * @since 6.4.0 + * @codeCoverageIgnore * * @see WP_HTML_Tag_Processor * @see https://html.spec.whatwg.org/ @@ -635,6 +636,75 @@ public function get_unsupported_exception() { return $this->unsupported_exception; } + /** + * Progress through a document pausing on tags matching the provided CSS selector string. + * + * @example + * + * $processor = WP_HTML_Processor::create_fragment( + * 'Example' + * ); + * foreach ( $processor->select_all( 'meta[property^="og:" i]' ) as $_ ) { + * // Loop is entered twice. + * var_dump( + * $processor->get_tag(), // string(4) "META" + * $processor->get_attribute( 'property' ), // string(7) "og:type" / string(14) "og:description" + * $processor->get_attribute( 'content' ), // string(7) "website" / string(11) "An example." + * ); + * } + * + * @since TBD + * + * @param string $selector_string Selector string. + * @return Generator A generator pausing on each tag matching the selector. + */ + public function select_all( $selector_string ): Generator { + $selector = WP_CSS_Complex_Selector_List::from_selectors( $selector_string ); + if ( null === $selector ) { + _doing_it_wrong( + __METHOD__, + sprintf( 'Received unsupported or invalid selector "%s".', $selector_string ), + '6.8' + ); + return; + } + + while ( $this->next_tag() ) { + if ( $selector->matches( $this ) ) { + yield; + } + } + } + + /** + * Move to the next tag matching the provided CSS selector string. + * + * This method will stop at the next match. To progress through all matches, use + * the {@see WP_HTML_Processor::select_all()} method. + * + * @example + * + * $processor = WP_HTML_Processor::create_fragment( + * 'Example' + * ); + * $processor->select( 'meta[charset]' ); + * var_dump( + * $processor->get_tag(), // string(4) "META" + * $processor->get_attribute( 'charset' ), // string(5) "utf-8" + * ); + * + * @since TBD + * + * @param string $selector_string + * @return bool True if a matching tag was found, otherwise false. + */ + public function select( string $selector_string ): bool { + foreach ( $this->select_all( $selector_string ) as $_ ) { + return true; + } + return false; + } + /** * Finds the next tag matching the $query. * diff --git a/src/wp-includes/html-api/class-wp-html-span.php b/src/wp-includes/html-api/class-wp-html-span.php index 04a1d5258c904..b76d3708a8884 100644 --- a/src/wp-includes/html-api/class-wp-html-span.php +++ b/src/wp-includes/html-api/class-wp-html-span.php @@ -20,6 +20,8 @@ * @since 6.2.0 * @since 6.5.0 Replaced `end` with `length` to more closely align with `substr()`. * + * @codeCoverageIgnore + * * @see WP_HTML_Tag_Processor */ class WP_HTML_Span { diff --git a/src/wp-includes/html-api/class-wp-html-stack-event.php b/src/wp-includes/html-api/class-wp-html-stack-event.php index dcb3c79ef1003..69a23f747fdef 100644 --- a/src/wp-includes/html-api/class-wp-html-stack-event.php +++ b/src/wp-includes/html-api/class-wp-html-stack-event.php @@ -14,6 +14,7 @@ * * @access private * @since 6.6.0 + * @codeCoverageIgnore * * @see WP_HTML_Processor */ 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 39390621e86a6..9b541ebc946ff 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 @@ -407,6 +407,7 @@ * @since 6.5.0 Pauses processor when input ends in an incomplete syntax token. * Introduces "special" elements which act like void elements, e.g. TITLE, STYLE. * Allows scanning through all tokens and processing modifiable text, where applicable. + * @codeCoverageIgnore */ class WP_HTML_Tag_Processor { /** @@ -537,6 +538,10 @@ class WP_HTML_Tag_Processor { */ protected $compat_mode = self::NO_QUIRKS_MODE; + public function is_quirks_mode() { + return self::QUIRKS_MODE === $this->compat_mode; + } + /** * Indicates whether the parser is inside foreign content, * e.g. inside an SVG or MathML element. @@ -856,6 +861,75 @@ public function change_parsing_namespace( string $new_namespace ): bool { return true; } + /** + * Progress through a document pausing on tags matching the provided CSS selector string. + * + * @example + * + * $processor = new WP_HTML_Tag_Processor( + * 'Example' + * ); + * foreach ( $processor->select_all( 'meta[property^="og:" i]' ) as $_ ) { + * // Loop is entered twice. + * var_dump( + * $processor->get_tag(), // string(4) "META" + * $processor->get_attribute( 'property' ), // string(7) "og:type" / string(14) "og:description" + * $processor->get_attribute( 'content' ), // string(7) "website" / string(11) "An example." + * ); + * } + * + * @since TBD + * + * @param string $selector_string Selector string. + * @return Generator A generator pausing on each tag matching the selector. + */ + public function select_all( $selector_string ): Generator { + $selector = WP_CSS_Compound_Selector_List::from_selectors( $selector_string ); + if ( null === $selector ) { + _doing_it_wrong( + __METHOD__, + sprintf( 'Received unsupported or invalid selector "%s".', $selector_string ), + '6.8' + ); + return; + } + + while ( $this->next_tag() ) { + if ( $selector->matches( $this ) ) { + yield; + } + } + } + + /** + * Move to the next tag matching the provided CSS selector string. + * + * This method will stop at the next match. To progress through all matches, use + * the {@see WP_HTML_Tag_Processor::select_all()} method. + * + * @example + * + * $processor = new WP_HTML_Tag_Processor( + * 'Example' + * ); + * $processor->select( 'meta[charset]' ); + * var_dump( + * $processor->get_tag(), // string(4) "META" + * $processor->get_attribute( 'charset' ), // string(5) "utf-8" + * ); + * + * @since TBD + * + * @param string $selector_string + * @return bool True if a matching tag was found, otherwise false. + */ + public function select( string $selector_string ): bool { + foreach ( $this->select_all( $selector_string ) as $_ ) { + return true; + } + return false; + } + /** * Finds the next tag matching the $query. * diff --git a/src/wp-includes/html-api/class-wp-html-text-replacement.php b/src/wp-includes/html-api/class-wp-html-text-replacement.php index 65e17d48fdb4a..797c101aead2d 100644 --- a/src/wp-includes/html-api/class-wp-html-text-replacement.php +++ b/src/wp-includes/html-api/class-wp-html-text-replacement.php @@ -18,6 +18,7 @@ * @since 6.5.0 Replace `end` with `length` to more closely match `substr()`. * * @see WP_HTML_Tag_Processor + * @codeCoverageIgnore */ class WP_HTML_Text_Replacement { /** 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 d5e51ac29007f..55645644c7ba3 100644 --- a/src/wp-includes/html-api/class-wp-html-token.php +++ b/src/wp-includes/html-api/class-wp-html-token.php @@ -17,6 +17,8 @@ * * @access private * + * @codeCoverageIgnore + * * @see WP_HTML_Processor */ class WP_HTML_Token { diff --git a/src/wp-includes/html-api/class-wp-html-unsupported-exception.php b/src/wp-includes/html-api/class-wp-html-unsupported-exception.php index 7b244a5e8a8dd..ce254225ed967 100644 --- a/src/wp-includes/html-api/class-wp-html-unsupported-exception.php +++ b/src/wp-includes/html-api/class-wp-html-unsupported-exception.php @@ -25,6 +25,7 @@ * * @access private * + * @codeCoverageIgnore * @see WP_HTML_Processor */ class WP_HTML_Unsupported_Exception extends Exception { diff --git a/src/wp-includes/html-api/html5-named-character-references.php b/src/wp-includes/html-api/html5-named-character-references.php index 9466f0a06b8cb..1094a52ead1d3 100644 --- a/src/wp-includes/html-api/html5-named-character-references.php +++ b/src/wp-includes/html-api/html5-named-character-references.php @@ -14,6 +14,7 @@ * * @package WordPress * @since 6.6.0 + * @codeCoverageIgnore */ // phpcs:disable diff --git a/src/wp-includes/html-api/interface-wp-css-html-processor-matcher.php b/src/wp-includes/html-api/interface-wp-css-html-processor-matcher.php new file mode 100644 index 0000000000000..2ae29413b35d2 --- /dev/null +++ b/src/wp-includes/html-api/interface-wp-css-html-processor-matcher.php @@ -0,0 +1,8 @@ +initialize(); } +// To make get_plugin_data() available in a way that's compatible with plugins also loading this file, see #62244. +require_once ABSPATH . 'wp-admin/includes/plugin.php'; + // Load active plugins. foreach ( wp_get_active_and_valid_plugins() as $plugin ) { wp_register_plugin_realpath( $plugin ); diff --git a/tests/phpunit/tests/blocks/updateIgnoredHookedBlocksPostMeta.php b/tests/phpunit/tests/blocks/updateIgnoredHookedBlocksPostMeta.php index 7b0a830dd52dd..18b5eff08ba79 100644 --- a/tests/phpunit/tests/blocks/updateIgnoredHookedBlocksPostMeta.php +++ b/tests/phpunit/tests/blocks/updateIgnoredHookedBlocksPostMeta.php @@ -193,4 +193,31 @@ public function test_update_ignored_hooked_blocks_postmeta_dont_modify_if_no_pos 'Post content did not match the original markup.' ); } + + /** + * @ticket 62639 + */ + public function test_update_ignored_hooked_blocks_postmeta_sets_correct_context_type() { + $action = new MockAction(); + add_filter( 'hooked_block_types', array( $action, 'filter' ), 10, 4 ); + + $original_markup = ''; + $post = new stdClass(); + $post->ID = self::$navigation_post->ID; + $post->post_content = $original_markup; + $post->post_type = 'wp_navigation'; + + $post = update_ignored_hooked_blocks_postmeta( $post ); + + $args = $action->get_args(); + $contexts = array_column( $args, 3 ); + + foreach ( $contexts as $context ) { + $this->assertInstanceOf( + WP_Post::class, + $context, + 'The context passed to the hooked_block_types filter is not a WP_Post instance.' + ); + } + } } diff --git a/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php b/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php new file mode 100644 index 0000000000000..0b17e57847662 --- /dev/null +++ b/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php @@ -0,0 +1,117 @@ +test_class = new class() extends WP_CSS_Complex_Selector_List { + public function __construct() { + parent::__construct( array() ); + } + + public static function test_parse_complex_selector( string $input, int &$offset ) { + return self::parse_complex_selector( $input, $offset ); + } + }; + } + + /** + * @ticket 62653 + */ + public function test_parse_complex_selector() { + $input = 'el1 > .child#bar[baz=quux] , rest'; + $offset = 0; + $sel = $this->test_class::test_parse_complex_selector( $input, $offset ); + + $this->assertSame( 3, count( $sel->selectors ) ); + + $this->assertSame( 'el1', $sel->selectors[2]->type_selector->ident ); + $this->assertNull( $sel->selectors[2]->subclass_selectors ); + + $this->assertSame( WP_CSS_Complex_Selector::COMBINATOR_CHILD, $sel->selectors[1] ); + + $this->assertSame( 3, count( $sel->selectors[0]->subclass_selectors ) ); + $this->assertNull( $sel->selectors[0]->type_selector ); + $this->assertSame( 3, count( $sel->selectors[0]->subclass_selectors ) ); + $this->assertSame( 'child', $sel->selectors[0]->subclass_selectors[0]->ident ); + + $this->assertSame( ', rest', substr( $input, $offset ) ); + } + + /** + * @ticket 62653 + */ + public function test_parse_invalid_complex_selector() { + $input = 'el.foo#bar[baz=quux] > , rest'; + $offset = 0; + $result = $this->test_class::test_parse_complex_selector( $input, $offset ); + $this->assertNull( $result ); + } + + /** + * @ticket 62653 + */ + public function test_parse_invalid_complex_selector_nonfinal_subclass() { + $input = 'el.foo#bar[baz=quux] > final, rest'; + $offset = 0; + $result = $this->test_class::test_parse_complex_selector( $input, $offset ); + $this->assertNull( $result ); + } + + /** + * @ticket 62653 + */ + public function test_parse_empty_complex_selector() { + $input = ''; + $offset = 0; + $result = $this->test_class::test_parse_complex_selector( $input, $offset ); + $this->assertNull( $result ); + } + + /** + * @ticket 62653 + */ + public function test_parse_complex_selector_list() { + $input = 'el1 el2 el.foo#bar[baz=quux], second > selector'; + $result = WP_CSS_Complex_Selector_List::from_selectors( $input ); + $this->assertNotNull( $result ); + } + + /** + * @ticket 62653 + */ + public function test_parse_invalid_selector_list() { + $input = 'el,,'; + $result = WP_CSS_Complex_Selector_List::from_selectors( $input ); + $this->assertNull( $result ); + } + + /** + * @ticket 62653 + */ + public function test_parse_invalid_selector_list2() { + $input = 'el!'; + $result = WP_CSS_Complex_Selector_List::from_selectors( $input ); + $this->assertNull( $result ); + } + + /** + * @ticket 62653 + */ + public function test_parse_empty_selector_list() { + $input = " \t \t\n\r\f"; + $result = WP_CSS_Complex_Selector_List::from_selectors( $input ); + $this->assertNull( $result ); + } +} diff --git a/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php b/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php new file mode 100644 index 0000000000000..715e0e26bc9cd --- /dev/null +++ b/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php @@ -0,0 +1,440 @@ +test_class = new class() extends WP_CSS_Compound_Selector_List { + public function __construct() { + parent::__construct( array() ); + } + + /* + * Parsing + */ + public static function test_parse_ident( string $input, int &$offset ) { + return self::parse_ident( $input, $offset ); + } + + public static function test_parse_string( string $input, int &$offset ) { + return self::parse_string( $input, $offset ); + } + + public static function test_parse_type_selector( string $input, int &$offset ) { + return self::parse_type_selector( $input, $offset ); + } + + public static function test_parse_id_selector( string $input, int &$offset ) { + return self::parse_id_selector( $input, $offset ); + } + + public static function test_parse_class_selector( string $input, int &$offset ) { + return self::parse_class_selector( $input, $offset ); + } + + public static function test_parse_attribute_selector( string $input, int &$offset ) { + return self::parse_attribute_selector( $input, $offset ); + } + + public static function test_parse_compound_selector( string $input, int &$offset ) { + return self::parse_compound_selector( $input, $offset ); + } + + /* + * Utilities + */ + public static function test_is_ident_codepoint( string $input, int $offset ) { + return self::is_ident_codepoint( $input, $offset ); + } + + public static function test_is_ident_start_codepoint( string $input, int $offset ) { + return self::is_ident_start_codepoint( $input, $offset ); + } + }; + } + + /** + * Data provider. + * + * @return array + */ + public static function data_idents(): array { + return array( + 'trailing #' => array( '_-foo123#xyz', '_-foo123', '#xyz' ), + 'trailing .' => array( '😍foo123.xyz', '😍foo123', '.xyz' ), + 'trailing " "' => array( '😍foo123 more', '😍foo123', ' more' ), + 'escaped ASCII character' => array( '\\xyz', 'xyz', '' ), + 'escaped space' => array( '\\ x', ' x', '' ), + 'escaped emoji' => array( '\\😍', '😍', '' ), + 'hex unicode codepoint' => array( '\\1f0a1', '🂡', '' ), + 'HEX UNICODE CODEPOINT' => array( '\\1D4B2', '𝒲', '' ), + + 'hex tab-suffixed 1' => array( "\\31\t23", '123', '' ), + 'hex newline-suffixed 1' => array( "\\31\n23", '123', '' ), + 'hex space-suffixed 1' => array( "\\31 23", '123', '' ), + 'hex tab' => array( '\\9', "\t", '' ), + 'hex a' => array( '\\61 bc', 'abc', '' ), + 'hex a max escape length' => array( '\\000061bc', 'abc', '' ), + + 'out of range replacement min' => array( '\\110000 ', "\u{fffd}", '' ), + 'out of range replacement max' => array( '\\ffffff ', "\u{fffd}", '' ), + 'leading surrogate min replacement' => array( '\\d800 ', "\u{fffd}", '' ), + 'leading surrogate max replacement' => array( '\\dbff ', "\u{fffd}", '' ), + 'trailing surrogate min replacement' => array( '\\dc00 ', "\u{fffd}", '' ), + 'trailing surrogate max replacement' => array( '\\dfff ', "\u{fffd}", '' ), + 'can start with -ident' => array( '-ident', '-ident', '' ), + 'can start with --anything' => array( '--anything', '--anything', '' ), + 'can start with ---anything' => array( '--_anything', '--_anything', '' ), + 'can start with --1anything' => array( '--1anything', '--1anything', '' ), + 'can start with -\31 23' => array( '-\31 23', '-123', '' ), + 'can start with --\31 23' => array( '--\31 23', '--123', '' ), + 'ident ends before ]' => array( 'ident]', 'ident', ']' ), + + // Invalid + 'Invalid: (empty string)' => array( '' ), + 'Invalid: bad start >' => array( '>ident' ), + 'Invalid: bad start [' => array( '[ident' ), + 'Invalid: bad start #' => array( '#ident' ), + 'Invalid: bad start " "' => array( ' ident' ), + 'Invalid: bad start 1' => array( '1ident' ), + 'Invalid: bad start -1' => array( '-1ident' ), + 'Invalid: bad start -' => array( '-' ), + ); + } + + /** + * @ticket 62653 + */ + public function test_is_ident_and_is_ident_start() { + $this->assertFalse( $this->test_class::test_is_ident_codepoint( '[', 0 ) ); + $this->assertFalse( $this->test_class::test_is_ident_codepoint( ']', 0 ) ); + $this->assertFalse( $this->test_class::test_is_ident_start_codepoint( '[', 0 ) ); + $this->assertFalse( $this->test_class::test_is_ident_start_codepoint( ']', 0 ) ); + } + + /** + * @ticket 62653 + * + * @dataProvider data_idents + */ + public function test_parse_ident( string $input, ?string $expected = null, ?string $rest = null ) { + + $offset = 0; + $result = $this->test_class::test_parse_ident( $input, $offset ); + if ( null === $expected ) { + $this->assertNull( $result ); + } else { + $this->assertSame( $expected, $result, 'Ident did not match.' ); + $this->assertSame( $rest, substr( $input, $offset ), 'Offset was not updated correctly.' ); + } + } + + /** + * @ticket 62653 + * + * @dataProvider data_strings + */ + public function test_parse_string( string $input, ?string $expected = null, ?string $rest = null ) { + $offset = 0; + $result = $this->test_class::test_parse_string( $input, $offset ); + if ( null === $expected ) { + $this->assertNull( $result ); + } else { + $this->assertSame( $expected, $result, 'String did not match.' ); + $this->assertSame( $rest, substr( $input, $offset ), 'Offset was not updated correctly.' ); + } + } + + /** + * Data provider. + * + * @return array + */ + public static function data_strings(): array { + return array( + '"foo"' => array( '"foo"', 'foo', '' ), + '"foo"after' => array( '"foo"after', 'foo', 'after' ), + '"foo""two"' => array( '"foo""two"', 'foo', '"two"' ), + '"foo"\'two\'' => array( '"foo"\'two\'', 'foo', "'two'" ), + + "'foo'" => array( "'foo'", 'foo', '' ), + "'foo'after" => array( "'foo'after", 'foo', 'after' ), + "'foo'\"two\"" => array( "'foo'\"two\"", 'foo', '"two"' ), + "'foo''two'" => array( "'foo''two'", 'foo', "'two'" ), + + "'foo\\nbar'" => array( "'foo\\\nbar'", 'foobar', '' ), + "'foo\\31 23'" => array( "'foo\\31 23'", 'foo123', '' ), + "'foo\\31\\n23'" => array( "'foo\\31\n23'", 'foo123', '' ), + "'foo\\31\\t23'" => array( "'foo\\31\t23'", 'foo123', '' ), + "'foo\\00003123'" => array( "'foo\\00003123'", 'foo123', '' ), + + "'foo\\" => array( "'foo\\", 'foo', '' ), + + '"' => array( '"', '', '' ), + '"\\"' => array( '"\\"', '"', '' ), + '"missing close' => array( '"missing close', 'missing close', '' ), + + // Invalid + 'Invalid: (empty string)' => array( '' ), + 'Invalid: .foo' => array( '.foo' ), + 'Invalid: #foo' => array( '#foo' ), + "Invalid: 'newline\\n'" => array( "'newline\n'" ), + 'Invalid: foo' => array( 'foo' ), + ); + } + + /** + * @ticket 62653 + * + * @dataProvider data_id_selectors + */ + public function test_parse_id( string $input, ?string $expected = null, ?string $rest = null ) { + $offset = 0; + $result = $this->test_class::test_parse_id_selector( $input, $offset ); + if ( null === $expected ) { + $this->assertNull( $result ); + } else { + $this->assertSame( $expected, $result->ident ); + $this->assertSame( $rest, substr( $input, $offset ) ); + } + } + + /** + * Data provider. + * + * @return array + */ + public static function data_id_selectors(): array { + return array( + 'valid #_-foo123' => array( '#_-foo123', '_-foo123', '' ), + 'valid #foo#bar' => array( '#foo#bar', 'foo', '#bar' ), + 'escaped #\31 23' => array( '#\\31 23', '123', '' ), + 'with descendant #\31 23 div' => array( '#\\31 23 div', '123', ' div' ), + + 'not ID foo' => array( 'foo' ), + 'not ID .bar' => array( '.bar' ), + 'not valid #1foo' => array( '#1foo' ), + ); + } + + /** + * @ticket 62653 + * + * @dataProvider data_class_selectors + */ + public function test_parse_class( string $input, ?string $expected = null, ?string $rest = null ) { + $offset = 0; + $result = $this->test_class::test_parse_class_selector( $input, $offset ); + if ( null === $expected ) { + $this->assertNull( $result ); + } else { + $this->assertSame( $expected, $result->ident ); + $this->assertSame( $rest, substr( $input, $offset ) ); + } + } + + /** + * Data provider. + * + * @return array + */ + public static function data_class_selectors(): array { + return array( + 'valid ._-foo123' => array( '._-foo123', '_-foo123', '' ), + 'valid .foo.bar' => array( '.foo.bar', 'foo', '.bar' ), + 'escaped .\31 23' => array( '.\\31 23', '123', '' ), + 'with descendant .\31 23 div' => array( '.\\31 23 div', '123', ' div' ), + + 'not class foo' => array( 'foo' ), + 'not class #bar' => array( '#bar' ), + 'not valid .1foo' => array( '.1foo' ), + ); + } + + /** + * @ticket 62653 + * + * @dataProvider data_type_selectors + */ + public function test_parse_type( string $input, ?string $expected = null, ?string $rest = null ) { + $offset = 0; + $result = $this->test_class::test_parse_type_selector( $input, $offset ); + if ( null === $expected ) { + $this->assertNull( $result ); + } else { + $this->assertSame( $expected, $result->ident ); + $this->assertSame( $rest, substr( $input, $offset ) ); + } + } + + /** + * Data provider. + * + * @return array + */ + public static function data_type_selectors(): array { + return array( + 'any *' => array( '* .class', '*', ' .class' ), + 'a' => array( 'a', 'a', '' ), + 'div.class' => array( 'div.class', 'div', '.class' ), + 'custom-type#id' => array( 'custom-type#id', 'custom-type', '#id' ), + + // Invalid + 'Invalid: (empty string)' => array( '' ), + 'Invalid: #id' => array( '#id' ), + 'Invalid: .class' => array( '.class' ), + 'Invalid: [attr]' => array( '[attr]' ), + ); + } + + /** + * @ticket 62653 + * + * @dataProvider data_attribute_selectors + */ + public function test_parse_attribute( + string $input, + ?string $expected_name = null, + ?string $expected_matcher = null, + ?string $expected_value = null, + ?string $expected_modifier = null, + ?string $rest = null + ) { + $offset = 0; + $result = $this->test_class::test_parse_attribute_selector( $input, $offset ); + if ( null === $expected_name ) { + $this->assertNull( $result ); + } else { + $this->assertSame( $expected_name, $result->name ); + $this->assertSame( $expected_matcher, $result->matcher ); + $this->assertSame( $expected_value, $result->value ); + $this->assertSame( $expected_modifier, $result->modifier ); + $this->assertSame( $rest, substr( $input, $offset ) ); + } + } + + /** + * Data provider. + * + * @return array + */ + public static function data_attribute_selectors(): array { + return array( + '[href]' => array( '[href]', 'href', null, null, null, '' ), + '[href] type' => array( '[href] type', 'href', null, null, null, ' type' ), + '[href]#id' => array( '[href]#id', 'href', null, null, null, '#id' ), + '[href].class' => array( '[href].class', 'href', null, null, null, '.class' ), + '[href][href2]' => array( '[href][href2]', 'href', null, null, null, '[href2]' ), + '[\n href\t\r]' => array( "[\n href\t\r]", 'href', null, null, null, '' ), + '[href=foo]' => array( '[href=foo]', 'href', WP_CSS_Attribute_Selector::MATCH_EXACT, 'foo', null, '' ), + '[href \n = bar ]' => array( "[href \n = bar ]", 'href', WP_CSS_Attribute_Selector::MATCH_EXACT, 'bar', null, '' ), + '[href \n ^= baz ]' => array( "[href \n ^= baz ]", 'href', WP_CSS_Attribute_Selector::MATCH_PREFIXED_BY, 'baz', null, '' ), + + '[match $= insensitive i]' => array( '[match $= insensitive i]', 'match', WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY, 'insensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), + '[match|=sensitive s]' => array( '[match|=sensitive s]', 'match', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'sensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), + '[att=val I]' => array( '[att=val I]', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'val', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), + '[att=val S]' => array( '[att=val S]', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'val', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), + + '[match~="quoted[][]"]' => array( '[match~="quoted[][]"]', 'match', WP_CSS_Attribute_Selector::MATCH_ONE_OF_EXACT, 'quoted[][]', null, '' ), + "[match$='quoted!{}']" => array( "[match$='quoted!{}']", 'match', WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY, 'quoted!{}', null, '' ), + "[match*='quoted's]" => array( "[match*='quoted's]", 'match', WP_CSS_Attribute_Selector::MATCH_CONTAINS, 'quoted', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), + + '[escape-nl="foo\\nbar"]' => array( "[escape-nl='foo\\\nbar']", 'escape-nl', WP_CSS_Attribute_Selector::MATCH_EXACT, 'foobar', null, '' ), + '[escape-seq="\\31 23"]' => array( "[escape-seq='\\31 23']", 'escape-seq', WP_CSS_Attribute_Selector::MATCH_EXACT, '123', null, '' ), + + // Invalid + 'Invalid: (empty string)' => array( '' ), + 'Invalid: foo' => array( 'foo' ), + 'Invalid: [foo' => array( '[foo' ), + 'Invalid: [#foo]' => array( '[#foo]' ), + 'Invalid: [*|*]' => array( '[*|*]' ), + 'Invalid: [ns|*]' => array( '[ns|*]' ), + 'Invalid: [* |att]' => array( '[* |att]' ), + 'Invalid: [*| att]' => array( '[*| att]' ), + 'Invalid: [att * =]' => array( '[att * =]' ), + 'Invalid: [att+=val]' => array( '[att+=val]' ), + 'Invalid: [att=val ' => array( '[att=val ' ), + 'Invalid: [att i]' => array( '[att i]' ), + 'Invalid: [att s]' => array( '[att s]' ), + "Invalid: [att='val\\n']" => array( "[att='val\n']" ), + 'Invalid: [att=val i ' => array( '[att=val i ' ), + 'Invalid: [att="val"ix' => array( '[att="val"ix' ), + ); + } + + /** + * @ticket 62653 + */ + public function test_parse_selector() { + $input = 'el.foo#bar[baz=quux] > .child'; + $offset = 0; + $sel = $this->test_class::test_parse_compound_selector( $input, $offset ); + + $this->assertSame( 'el', $sel->type_selector->ident ); + $this->assertSame( 3, count( $sel->subclass_selectors ) ); + $this->assertSame( 'foo', $sel->subclass_selectors[0]->ident, 'foo' ); + $this->assertSame( 'bar', $sel->subclass_selectors[1]->ident, 'bar' ); + $this->assertSame( 'baz', $sel->subclass_selectors[2]->name, 'baz' ); + $this->assertSame( WP_CSS_Attribute_Selector::MATCH_EXACT, $sel->subclass_selectors[2]->matcher ); + $this->assertSame( 'quux', $sel->subclass_selectors[2]->value ); + $this->assertSame( ' > .child', substr( $input, $offset ) ); + } + + /** + * @ticket 62653 + */ + public function test_parse_empty_selector() { + $input = ''; + $offset = 0; + $result = $this->test_class::test_parse_compound_selector( $input, $offset ); + $this->assertNull( $result ); + $this->assertSame( 0, $offset ); + } + + /** + * @ticket 62653 + */ + public function test_parse_selector_list() { + $input = 'el1, el2, el.foo#bar[baz=quux]'; + $result = WP_CSS_Compound_Selector_List::from_selectors( $input ); + $this->assertNotNull( $result ); + } + + /** + * @ticket 62653 + */ + public function test_parse_invalid_selector_list() { + $input = 'el,,'; + $result = WP_CSS_Compound_Selector_List::from_selectors( $input ); + $this->assertNull( $result ); + } + + /** + * @ticket 62653 + */ + public function test_parse_invalid_selector_list2() { + $input = 'el!'; + $result = WP_CSS_Compound_Selector_List::from_selectors( $input ); + $this->assertNull( $result ); + } + + /** + * @ticket 62653 + */ + public function test_parse_empty_selector_list() { + $input = " \t \t\n\r\f"; + $result = WP_CSS_Compound_Selector_List::from_selectors( $input ); + $this->assertNull( $result ); + } +} diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php b/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php new file mode 100644 index 0000000000000..d94190ff91077 --- /dev/null +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php @@ -0,0 +1,87 @@ +' ); + $this->assertFalse( $processor->select( 'div' ) ); + } + + /** + * @ticket 62653 + * + * @dataProvider data_selectors + */ + public function test_select_all( string $html, string $selector, int $match_count ) { + $processor = WP_HTML_Processor::create_full_parser( $html ); + $count = 0; + foreach ( $processor->select_all( $selector ) as $_ ) { + $breadcrumb_string = implode( ', ', $processor->get_breadcrumbs() ); + $this->assertTrue( + $processor->get_attribute( 'match' ), + "Matched unexpected tag {$processor->get_tag()} @ {$breadcrumb_string}" + ); + ++$count; + } + $this->assertSame( $match_count, $count, 'Did not match expected number of tags.' ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_selectors(): array { + return array( + 'any' => array( '

', '*', 5 ), + 'quirks mode ID' => array( '

In quirks mode, ID matching is case-insensitive.', '#id', 2 ), + 'quirks mode class' => array( '

In quirks mode, class matching is case-insensitive.', '.c', 2 ), + 'no-quirks mode ID' => array( '

In no-quirks mode, ID matching is case-sensitive.', '#id', 1 ), + 'no-quirks mode class' => array( '

In no-quirks mode, class matching is case-sensitive.', '.c', 1 ), + 'any descendant' => array( '

', 'section *', 4 ), + 'any child 1' => array( '

', 'section > *', 2 ), + 'any child 2' => array( '

', 'div > *', 1 ), + ); + } + + /** + * @ticket 62653 + * + * @expectedIncorrectUsage WP_HTML_Processor::select_all + * + * @dataProvider data_invalid_selectors + */ + public function test_invalid_selector( string $selector ) { + $processor = WP_HTML_Processor::create_fragment( 'irrelevant' ); + $this->assertFalse( $processor->select( $selector ) ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_invalid_selectors(): array { + return array( + 'invalid selector' => array( '[invalid!selector]' ), + + // The class selectors below are not allowed in non-final position. + 'unsupported child selector' => array( '.parent > .child' ), + 'unsupported descendant selector' => array( '.ancestor .descendant' ), + ); + } +} diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php new file mode 100644 index 0000000000000..586e38b4bafb2 --- /dev/null +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php @@ -0,0 +1,111 @@ +' ); + $this->assertFalse( $processor->select( 'div' ) ); + } + + /** + * @ticket 62653 + * + * @dataProvider data_selectors + */ + public function test_select( string $html, string $selector, int $match_count ) { + $processor = new WP_HTML_Tag_Processor( $html ); + $count = 0; + foreach ( $processor->select_all( $selector ) as $_ ) { + $this->assertTrue( + $processor->get_attribute( 'match' ), + "Matched unexpected tag {$processor->get_tag()}" + ); + ++$count; + } + $this->assertSame( $match_count, $count, 'Did not match expected number of tags.' ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_selectors(): array { + return array( + 'simple type' => array( '
', 'div', 2 ), + 'any type' => array( '
', '*', 2 ), + 'simple class' => array( '
', '.x', 2 ), + 'simple id' => array( '
', '#x', 2 ), + + 'attribute presence' => array( '
', '[att]', 2 ), + 'attribute empty string match' => array( '
', '[att=""]', 2 ), + 'attribute value' => array( '

', '[att=val]', 2 ), + 'attribute quoted value' => array( '

', '[att="::"]', 2 ), + 'attribute case insensitive' => array( '

', '[att="VAL"i]', 2 ), + 'attribute case sensitive mod' => array( '

', '[att="val"s]', 2 ), + + 'attribute one of' => array( '

', '[att~="b"]', 3 ), + 'attribute one of insensitive' => array( '

', '[att~="b"i]', 1 ), + 'attribute one of mod sensitive' => array( '
', '[att~="b"s]', 1 ), + 'attribute one of whitespace cases' => array( "
", '[att~="b"]', 1 ), + + 'attribute with-hyphen' => array( '

', '[att|="special"]', 2 ), + 'attribute with-hyphen insensitive' => array( '

', '[att|="special" i]', 2 ), + 'attribute with-hyphen sensitive mod' => array( '

', '[att|="special"s]', 1 ), + + 'attribute prefixed' => array( '

', '[att^="p"]', 2 ), + 'attribute prefixed insensitive' => array( '

', '[att^="p"i]', 1 ), + 'attribute prefixed sensitive mod' => array( '

', '[att^="p"s]', 1 ), + + 'attribute suffixed' => array( '

', '[att$="x"]', 2 ), + 'attribute suffixed insensitive' => array( '

', '[att$="x"i]', 1 ), + 'attribute suffixed sensitive mod' => array( '

', '[att$="x"s]', 1 ), + + 'attribute contains' => array( '

', '[att*="x"]', 2 ), + 'attribute contains insensitive' => array( '

', '[att*="x"i]', 1 ), + 'attribute contains sensitive mod' => array( '

', '[att*="x"s]', 1 ), + + 'list' => array( '

', 'a, p, .class, #id, [att]', 2 ), + 'compound' => array( '

', 'custom-el[att="bar"][ fruit ~= "banana" i]', 1 ), + ); + } + + /** + * @ticket 62653 + * + * @expectedIncorrectUsage WP_HTML_Tag_Processor::select_all + * + * @dataProvider data_invalid_selectors + */ + public function test_invalid_selector( string $selector ) { + $processor = new WP_HTML_Tag_Processor( 'irrelevant' ); + $this->assertFalse( $processor->select( $selector ) ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_invalid_selectors(): array { + return array( + 'complex descendant' => array( 'div *' ), + 'complex child' => array( 'div > *' ), + 'invalid selector' => array( '[invalid!selector]' ), + ); + } +} diff --git a/tests/phpunit/tests/oembed/getResponseData.php b/tests/phpunit/tests/oembed/getResponseData.php index 7cd5b1cc66ddf..09a0f3142b319 100644 --- a/tests/phpunit/tests/oembed/getResponseData.php +++ b/tests/phpunit/tests/oembed/getResponseData.php @@ -251,6 +251,26 @@ public function test_get_oembed_response_data_with_thumbnail() { $this->assertLessThanOrEqual( 400, $data['thumbnail_width'] ); } + /** + * @ticket 62094 + */ + public function test_get_oembed_response_data_has_correct_thumbnail_size() { + $post = self::factory()->post->create_and_get(); + + /* Use a large image as post thumbnail */ + $attachment_id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/33772.jpg' ); + set_post_thumbnail( $post, $attachment_id ); + + /* Get the image, sized for 400x??? pixels display */ + $image = wp_get_attachment_image_src( $attachment_id, array( 400, 0 ) ); + + /* Get the oembed data array for a 400 pixels wide embed */ + $data = get_oembed_response_data( $post, 400 ); + + /* Make sure the embed references the small image, not the full-size one. */ + $this->assertSame( $image[0], $data['thumbnail_url'] ); + } + public function test_get_oembed_response_data_for_attachment() { $parent = self::factory()->post->create(); $file = DIR_TESTDATA . '/images/canola.jpg';