diff --git a/.typos.toml b/.typos.toml
index 64ba3ac11..54fa41a65 100644
--- a/.typos.toml
+++ b/.typos.toml
@@ -1,15 +1,38 @@
[default]
extend-ignore-re = [
- "avail_post_stati",
- "ba",
- "MAGPIE_INITALIZED",
- "setted_site_transient",
- "setted_transient",
- "stati",
- "wheres",
+ # Ignore TLD names file - contains legitimate domain extensions that look like typos
]
[files]
+# Ignore spell checking in TLD names file as it contains legitimate domain extensions
extend-exclude = [
- "includes/Lib/Readme/*.php"
+ "includes/Traits/TLD_Names.php"
]
+
+[default.extend-words]
+# Brand names and legitimate TLDs
+chanel = "chanel"
+marshalls = "marshalls"
+immobilien = "immobilien"
+lolipop = "lolipop"
+onl = "onl"
+passagens = "passagens"
+
+# WordPress core function names (intentional naming)
+stati = "stati" # WordPress function: get_post_stati
+wheres = "wheres" # WordPress variable: $wheres
+
+# WordPress core action/hook names (must match exactly)
+setted = "setted" # WordPress hooks: setted_site_transient, setted_transient
+
+# WordPress core constants (legacy naming)
+INITALIZED = "INITALIZED" # WordPress constant: MAGPIE_INITALIZED
+
+# Third-party library names
+ba = "ba" # jQuery plugin: jquery.ba-serializeobject
+
+# Creative Commons license abbreviations
+ND = "ND" # CC BY-ND (No Derivatives)
+
+# Parser checks for invalid licenses (intentionally includes common misspellings)
+Proprietery = "Proprietery" # Intentional check in Parser.php for common license typo
diff --git a/composer.json b/composer.json
index 4b4e78d96..c14311be8 100644
--- a/composer.json
+++ b/composer.json
@@ -56,7 +56,8 @@
"composer/installers": true,
"cweagans/composer-patches": false,
"dealerdirect/phpcodesniffer-composer-installer": true,
- "phpstan/extension-installer": true
+ "phpstan/extension-installer": true,
+ "php-http/discovery": true
},
"platform": {
"php": "7.4"
diff --git a/includes/Checker/Checks/Plugin_Repo/Plugin_Readme_Check.php b/includes/Checker/Checks/Plugin_Repo/Plugin_Readme_Check.php
index 39f179e1f..55c3f6a20 100644
--- a/includes/Checker/Checks/Plugin_Repo/Plugin_Readme_Check.php
+++ b/includes/Checker/Checks/Plugin_Repo/Plugin_Readme_Check.php
@@ -12,10 +12,12 @@
use WordPress\Plugin_Check\Checker\Checks\Abstract_File_Check;
use WordPress\Plugin_Check\Lib\Readme\Parser as PCPParser;
use WordPress\Plugin_Check\Traits\Amend_Check_Result;
+use WordPress\Plugin_Check\Traits\External_Utils;
use WordPress\Plugin_Check\Traits\Find_Readme;
use WordPress\Plugin_Check\Traits\Language_Utils;
use WordPress\Plugin_Check\Traits\License_Utils;
use WordPress\Plugin_Check\Traits\Stable_Check;
+use WordPress\Plugin_Check\Traits\TLD_Names;
use WordPress\Plugin_Check\Traits\URL_Utils;
use WordPress\Plugin_Check\Traits\Version_Utils;
use WordPressdotorg\Plugin_Directory\Readme\Parser as DotorgParser;
@@ -32,6 +34,8 @@ class Plugin_Readme_Check extends Abstract_File_Check {
use Amend_Check_Result;
use Find_Readme;
+ use TLD_Names;
+ use External_Utils;
use Stable_Check;
use License_Utils;
use URL_Utils;
@@ -118,6 +122,9 @@ protected function check_files( Check_Result $result, array $files ) {
// Check the readme file for contributors.
$this->check_for_contributors( $result, $readme_file );
+ // Check for third parties privacy notes.
+ $this->check_for_privacy_notes( $result, $readme_file, $parser, $files );
+
// Check the readme file for requires headers.
$this->check_requires_headers( $result, $readme_file, $parser );
@@ -849,6 +856,87 @@ function ( $value ) {
}
/**
+ * Checks the readme file for external privacy notes.
+ *
+ * @since 1.4.0
+ *
+ * @param Check_Result $result The Check Result to amend.
+ * @param string $readme_file Readme file.
+ * @param DotorgParser|PCPParser $parser The Parser object.
+ * @param array $files Array of plugin files.
+ */
+ private function check_for_privacy_notes( Check_Result $result, string $readme_file, $parser, array $files ) {
+ $existing_tld_names = $this->get_tld_names();
+
+ // Load domains mentioned in readme.
+ $this->domains_mentioned_readme = $this->load_domains_mentioned_in_readme( $readme_file, $existing_tld_names );
+
+ // Filter files to check (PHP, JS, CSS).
+ $files_ext = self::filter_files_by_extensions( $files, array( 'php', 'css', 'js' ) );
+
+ // Collect all external domains found in the plugin files.
+ $found_domains = array();
+ foreach ( $files_ext as $file ) {
+ $domains_in_file = $this->find_external_domains_in_file( $file );
+ if ( ! empty( $domains_in_file ) ) {
+ foreach ( $domains_in_file as $domain ) {
+ $found_domains[ $domain ] = $file;
+ }
+ }
+ }
+
+ // Skip if no external domains found.
+ if ( empty( $found_domains ) ) {
+ return;
+ }
+
+ // Check each found domain.
+ foreach ( $found_domains as $domain => $file ) {
+ // Extract base domain using TLD list.
+ $base_domain = $this->extract_domain_from_host( $domain, $existing_tld_names );
+
+ // Check if domain is mentioned in readme.
+ if ( ! $this->is_domain_mentioned_in_readme( $base_domain ) ) {
+ $this->add_result_error_for_file(
+ $result,
+ sprintf(
+ /* translators: 1: domain, 2: file where found */
+ __( 'Undocumented use of a 3rd party or external service.
We permit plugins to require the use of 3rd party (external) services, provided they are properly documented in a clear manner. Please update your readme with documentation for the external service "%1$s" found in file %2$s. In order to do so, you must update your readme to: clearly explain that your plugin is relying on a 3rd party as a service and under what circumstances, provide a link to the service, and provide a link to the service\'s terms of use and/or privacy policies.', 'plugin-check' ),
+ esc_html( $domain ),
+ esc_html( basename( $file ) )
+ ),
+ 'undocumented_third_party_service',
+ $readme_file,
+ 0,
+ 0,
+ 'https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/#8-plugins-may-not-send-executable-code-via-third-party-systems',
+ 7
+ );
+ continue;
+ }
+
+ // Domain is mentioned, check if it's documented with privacy/terms.
+ if ( ! $this->is_domain_documented_readme( $base_domain ) ) {
+ $this->add_result_warning_for_file(
+ $result,
+ sprintf(
+ /* translators: %s: domain */
+ __( 'Third-party service may not be properly documented.
The external service "%s" is mentioned in your readme, but we could not find clear links to privacy policy or terms of service. Please ensure you provide links to the service\'s terms of use and/or privacy policies.', 'plugin-check' ),
+ esc_html( $domain )
+ ),
+ 'incomplete_third_party_service_documentation',
+ $readme_file,
+ 0,
+ 0,
+ 'https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/#8-plugins-may-not-send-executable-code-via-third-party-systems',
+ 6
+ );
+ }
+ }
+ }
+
+ /**
+ * Returns current major WordPress version.
* Checks the readme file for requires headers.
*
* @since 1.5.0
diff --git a/includes/Lib/Readme/Parser.php b/includes/Lib/Readme/Parser.php
index b81e583d7..091662ab5 100644
--- a/includes/Lib/Readme/Parser.php
+++ b/includes/Lib/Readme/Parser.php
@@ -88,7 +88,7 @@ class Parser {
public $faq = array();
/**
- * Warning flags which indicate specific parsing failures have occured.
+ * Warning flags which indicate specific parsing failures have occurred.
*
* @var array
*/
@@ -830,7 +830,7 @@ protected function sanitize_requires_version( $version ) {
if (
// x.y or x.y.z
! preg_match( '!^\d+\.\d(\.\d+)?$!', $version ) ||
- // Allow plugins to mark themselves as requireing Stable+0.1 (trunk/master) but not higher
+ // Allow plugins to mark themselves as requiring Stable+0.1 (trunk/master) but not higher
defined( 'WP_CORE_STABLE_BRANCH' ) && ( (float) $version > (float) WP_CORE_STABLE_BRANCH + 0.1 )
) {
$this->warnings['requires_header_ignored'] = true;
@@ -945,7 +945,7 @@ public function validate_license( $license ) {
$probably_compatible = array(
'GPL',
'General Public License',
- // 'GNU 2', 'GNU Public', 'GNU Version 2' explicitely not included, as it's not a specific license.
+ // 'GNU 2', 'GNU Public', 'GNU Version 2' explicitly not included, as it's not a specific license.
'MIT',
'ISC',
'Expat',
diff --git a/includes/Traits/External_Utils.php b/includes/Traits/External_Utils.php
new file mode 100644
index 000000000..1e236aebf
--- /dev/null
+++ b/includes/Traits/External_Utils.php
@@ -0,0 +1,559 @@
+extract_urls_from_readme_lines( $lines );
+ if ( empty( $urls ) ) {
+ return $domains_mentioned;
+ }
+
+ $domains_mentioned = $this->process_urls_for_domains( $urls, $existing_tld_names );
+ $domains_mentioned = $this->cleanup_domain_urls( $domains_mentioned );
+
+ return $domains_mentioned;
+ }
+
+ /**
+ * Extract URLs from readme file lines.
+ *
+ * @since 1.8.0
+ *
+ * @param array $lines Lines from readme file.
+ * @return array Array of unique URLs.
+ */
+ private function extract_urls_from_readme_lines( $lines ) {
+ $urls = array();
+ foreach ( $lines as $line ) {
+ preg_match_all( '/@?(https?:\/\/)?(www\.)?[-a-zA-Z0-9:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9(:%_\+~#?&\/=]*)/', $line, $result );
+ foreach ( $result[0] as $url ) {
+ $url = strtolower( $url );
+ // Remove domains in email addresses.
+ if ( ! str_starts_with( $url, '@' ) ) {
+ // Add protocol if domain taken without protocol.
+ if ( ! str_starts_with( $url, 'http' ) ) {
+ $url = 'http://' . $url;
+ }
+ $urls[] = $url;
+ }
+ }
+ }
+ return array_unique( $urls );
+ }
+
+ /**
+ * Process URLs to extract domains.
+ *
+ * @since 1.8.0
+ *
+ * @param array $urls Array of URLs.
+ * @param array $existing_tld_names Array of existing TLD names.
+ * @return array Array of domain information.
+ */
+ private function process_urls_for_domains( $urls, $existing_tld_names ) {
+ $domains_mentioned = array();
+
+ foreach ( $urls as $url ) {
+ $parsed_url = parse_url( $url );
+ if ( false === $parsed_url || empty( $parsed_url['host'] ) ) {
+ continue;
+ }
+
+ $path = ! empty( $parsed_url['path'] ) ? $parsed_url['path'] : '';
+ $this->extract_domain_from_url( $url, $parsed_url['host'], $path, $existing_tld_names, $domains_mentioned );
+ }
+
+ return $domains_mentioned;
+ }
+
+ /**
+ * Extract domain from URL and add to domains array.
+ *
+ * @since 1.8.0
+ *
+ * @param string $url The URL.
+ * @param string $host The host from parsed URL.
+ * @param string $path The path from parsed URL.
+ * @param array $existing_tld_names Array of existing TLD names.
+ * @param array &$domains_mentioned Reference to domains array to update.
+ */
+ private function extract_domain_from_url( $url, $host, $path, $existing_tld_names, &$domains_mentioned ) {
+ preg_match_all( '/(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]/', $url, $result );
+
+ foreach ( $result[0] as $domain ) {
+ $domain = strtolower( $domain );
+
+ if ( $this->is_invalid_domain( $domain ) ) {
+ continue;
+ }
+
+ $domain_tld = $this->get_longest_matching_tld( $host, $existing_tld_names );
+ if ( empty( $domain_tld ) ) {
+ continue;
+ }
+
+ $domain = $this->extract_root_domain( $host, $domain_tld );
+ $this->add_or_update_domain( $domain, $url, $path, $domains_mentioned );
+ }
+ }
+
+ /**
+ * Check if domain is invalid (numeric TLD or file extension).
+ *
+ * @since 1.8.0
+ *
+ * @param string $domain Domain to check.
+ * @return bool True if invalid, false otherwise.
+ */
+ private function is_invalid_domain( $domain ) {
+ $typical_off_loading_extensions = array( 'css', 'svg', 'jpg', 'jpeg', 'gif', 'png', 'webm', 'mp4', 'mpg', 'mpeg', 'mp3' );
+ $domain_elements = explode( '.', $domain );
+ $tld = end( $domain_elements );
+
+ // Invalid TLD, numeric, looks like detected a version.
+ if ( $tld === (int) $tld ) {
+ return true;
+ }
+
+ // Invalid, looks like detected a file.
+ return in_array(
+ $tld,
+ array_merge( $typical_off_loading_extensions, array( 'php', 'html', 'zip' ) ),
+ true
+ );
+ }
+
+ /**
+ * Get the longest matching TLD from host.
+ *
+ * @since 1.8.0
+ *
+ * @param string $host The host.
+ * @param array $existing_tld_names Array of existing TLD names.
+ * @return string The longest matching TLD or empty string.
+ */
+ private function get_longest_matching_tld( $host, $existing_tld_names ) {
+ $domain_tld = '';
+ foreach ( $existing_tld_names as $tld ) {
+ if ( str_ends_with( $host, $tld ) && strlen( $tld ) > strlen( $domain_tld ) ) {
+ $domain_tld = $tld;
+ }
+ }
+ return $domain_tld;
+ }
+
+ /**
+ * Extract root domain from host and TLD.
+ *
+ * @since 1.8.0
+ *
+ * @param string $host The host.
+ * @param string $domain_tld The TLD.
+ * @return string The root domain.
+ */
+ private function extract_root_domain( $host, $domain_tld ) {
+ $domain = str_replace( '.' . $domain_tld, '', $host );
+ $parts = explode( '.', $domain );
+ return end( $parts ) . '.' . $domain_tld;
+ }
+
+ /**
+ * Add or update domain in domains array.
+ *
+ * @since 1.8.0
+ *
+ * @param string $domain The domain.
+ * @param string $url The URL.
+ * @param string $path The path.
+ * @param array &$domains_mentioned Reference to domains array to update.
+ */
+ private function add_or_update_domain( $domain, $url, $path, &$domains_mentioned ) {
+ $key = $this->get_key_domain_mentioned_in_readme( $domain );
+
+ if ( false !== $key ) {
+ // Domain exists, add URL and path.
+ $domains_mentioned[ $key ]['urls'][] = $url;
+ if ( ! empty( $path ) ) {
+ $domains_mentioned[ $key ]['paths'][] = $path;
+ }
+ } else {
+ // Create new domain entry.
+ $domain_mentioned = array(
+ 'domains' => $this->add_domains_of_same_service( $domain ),
+ 'urls' => array( $url ),
+ 'paths' => ! empty( $path ) ? array( $path ) : array(),
+ );
+ $domains_mentioned[] = $domain_mentioned;
+ }
+ }
+
+ /**
+ * Cleanup domain URLs by removing duplicates.
+ *
+ * @since 1.8.0
+ *
+ * @param array $domains_mentioned Array of domain information.
+ * @return array Cleaned array.
+ */
+ private function cleanup_domain_urls( $domains_mentioned ) {
+ if ( empty( $domains_mentioned ) ) {
+ return $domains_mentioned;
+ }
+
+ return array_map(
+ function ( $domain ) {
+ $domain['urls'] = array_unique( $domain['urls'] );
+ return $domain;
+ },
+ $domains_mentioned
+ );
+ }
+
+ /**
+ * Get key domain mentioned in readme file.
+ *
+ * @since 1.4.0
+ *
+ * @param string $domain_search Domain search string.
+ * @return string|int|bool Key of domain mentioned in readme file, or false if not found.
+ */
+ protected function get_key_domain_mentioned_in_readme( $domain_search ) {
+ if ( ! empty( $this->domains_mentioned_readme ) ) {
+ foreach ( $this->domains_mentioned_readme as $key => $domains ) {
+ if ( ! empty( $domains['domains'] ) ) {
+ foreach ( $domains['domains'] as $domain ) {
+ if ( str_contains( $domain_search, $domain ) || str_contains( $domain, $domain_search ) ) {
+ return $key;
+ }
+ }
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Add domains of the same service.
+ *
+ * @since 1.4.0
+ *
+ * @param string $domain Domain.
+ * @return array An array containing domains of the same service.
+ */
+ protected function add_domains_of_same_service( $domain ) {
+ $domains = array( $domain );
+ $domains_of_the_same_service = array(
+ 'paypal.com' => array( 'paypal.com', 'paypalobjects.com' ),
+ 'google.com' => array( 'google.com', 'googleapis.com', 'googletagmanager.com' ),
+ 'microsoft.com' => array( 'microsoft.com', 'outlook.com', 'live.com' ),
+ 'atlassian.net' => array( 'atlassian.com', 'trello.com' ),
+ 'dropbox.com' => array( 'dropbox.com', 'dropboxapi.com' ),
+ 'tiktok.com' => array( 'tiktok.com', 'tiktokapis.com' ),
+ 'zendesk.com' => array( 'zendesk.com', 'zdassets.com' ),
+ );
+ foreach ( $domains_of_the_same_service as $key => $service ) {
+ foreach ( $service as $service_domain ) {
+ if ( $service_domain === $domain ) {
+ $domains = array_merge( $domains, $domains_of_the_same_service[ $key ] );
+ $domains = array_unique( $domains );
+ }
+ }
+ }
+
+ return $domains;
+ }
+
+ /**
+ * Check if domain is mentioned in readme file.
+ *
+ * @since 1.4.0
+ *
+ * @param string $domain Domain.
+ * @return bool True if domain is mentioned in readme file, false otherwise.
+ */
+ protected function is_domain_mentioned_in_readme( $domain ) {
+ $key = $this->get_key_domain_mentioned_in_readme( $domain );
+ if ( false !== $key ) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Check if domain is documented in readme file.
+ *
+ * @since 1.4.0
+ *
+ * @param string $domain Domain.
+ * @return bool True if domain is documented in readme file, false otherwise.
+ */
+ protected function is_domain_documented_readme( $domain ) {
+ $key = $this->get_key_domain_mentioned_in_readme( $domain );
+ $privacy = false;
+ $terms = false;
+
+ if ( ! empty( $this->domains_mentioned_readme[ $key ]['paths'] ) ) {
+ foreach ( $this->domains_mentioned_readme[ $key ]['paths'] as $path ) {
+ foreach ( $this->privacy_common_uris_paths as $privacy_str ) {
+ if ( str_contains( $path, $privacy_str ) ) {
+ $privacy = $path;
+ break;
+ }
+ }
+ foreach ( $this->terms_common_uris_paths as $terms_str ) {
+ if ( str_contains( $path, $terms_str ) ) {
+ $terms = $path;
+ break;
+ }
+ }
+ }
+ }
+
+ if ( $privacy || $terms ) { // To lower down false positives while keeping the check we are ok to have just one of them.
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Common privacy URI paths.
+ *
+ * @since 1.4.0
+ *
+ * @var array
+ */
+ private $privacy_common_uris_paths = array( 'privacy', 'legal' );
+
+ /**
+ * Common terms URI paths.
+ *
+ * @since 1.4.0
+ *
+ * @var array
+ */
+ private $terms_common_uris_paths = array( 'terms', 'tos', 'conditions', 'legal' );
+
+ /**
+ * Domains mentioned in readme.
+ *
+ * @since 1.4.0
+ *
+ * @var array
+ */
+ private $domains_mentioned_readme = array();
+
+ /**
+ * Find external domains in a file.
+ *
+ * @since 1.4.0
+ *
+ * @param string $file File path.
+ * @return array Array of domains found in the file.
+ */
+ protected function find_external_domains_in_file( $file ) {
+ $domains = array();
+
+ // Skip if file doesn't exist or is not readable.
+ if ( ! file_exists( $file ) || ! is_readable( $file ) ) {
+ return $domains;
+ }
+
+ $content = file_get_contents( $file );
+ if ( false === $content ) {
+ return $domains;
+ }
+
+ // Skip plugin header section in PHP files to avoid flagging metadata URLs.
+ $extension = pathinfo( $file, PATHINFO_EXTENSION );
+ if ( 'php' === $extension ) {
+ // Remove the plugin header block (first DocBlock in the file).
+ $content = preg_replace( '#^<\?php\s*/\*\*.*?\*/\s*#s', '', $content, 1 );
+ }
+
+ // Pattern to match URLs in function calls that indicate actual service usage.
+ $service_patterns = array(
+ // Remote HTTP functions.
+ '#(?:wp_remote_get|wp_remote_post|wp_remote_request|wp_safe_remote_get|wp_safe_remote_post|wp_safe_remote_request|file_get_contents|fopen|curl_init)\s*\(\s*["\'](?:https?:)?//([a-zA-Z0-9][-a-zA-Z0-9]*(?:\.[a-zA-Z0-9][-a-zA-Z0-9]*)+)[^"\']*["\']#i',
+ // Enqueue functions.
+ '#(?:wp_enqueue_script|wp_register_script|wp_enqueue_style|wp_register_style)\s*\([^,]+,\s*["\'](?:https?:)?//([a-zA-Z0-9][-a-zA-Z0-9]*(?:\.[a-zA-Z0-9][-a-zA-Z0-9]*)+)[^"\']*["\']#i',
+ // JavaScript fetch.
+ '#fetch\s*\(\s*["\'](?:https?:)?//([a-zA-Z0-9][-a-zA-Z0-9]*(?:\.[a-zA-Z0-9][-a-zA-Z0-9]*)+)[^"\']*["\']#i',
+ );
+
+ foreach ( $service_patterns as $pattern ) {
+ if ( preg_match_all( $pattern, $content, $matches ) ) {
+ foreach ( $matches[1] as $domain ) {
+ $domain = strtolower( trim( $domain ) );
+
+ // Skip common WordPress and localhost domains.
+ if ( $this->is_common_wordpress_domain( $domain ) ) {
+ continue;
+ }
+
+ if ( $this->is_localhost_domain( $domain ) ) {
+ continue;
+ }
+
+ if ( $this->is_known_safe_domain( $domain ) ) {
+ continue;
+ }
+
+ $domains[] = $domain;
+ }
+ }
+ }
+
+ return array_unique( $domains );
+ }
+
+ /**
+ * Check if domain is a common WordPress domain.
+ *
+ * @since 1.4.0
+ *
+ * @param string $domain Domain to check.
+ * @return bool True if it's a common WordPress domain, false otherwise.
+ */
+ private function is_common_wordpress_domain( $domain ) {
+ $wordpress_domains = array(
+ 'wordpress.org',
+ 'w.org',
+ 'wordpress.com',
+ 'gravatar.com',
+ 'wp.com',
+ );
+
+ foreach ( $wordpress_domains as $wp_domain ) {
+ if ( str_contains( $domain, $wp_domain ) ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Check if domain is a common/known safe domain that should be ignored.
+ *
+ * @since 1.4.0
+ *
+ * @param string $domain Domain to check.
+ * @return bool True if it's a known safe domain, false otherwise.
+ */
+ private function is_known_safe_domain( $domain ) {
+ $safe_domains = array(
+ 'github.com',
+ 'gitlab.com',
+ 'bitbucket.org',
+ 'gnu.org', // GNU licenses.
+ 'opensource.org', // OSI licenses.
+ 'creativecommons.org',
+ 'fsf.org', // Free Software Foundation.
+ );
+
+ foreach ( $safe_domains as $safe_domain ) {
+ if ( str_contains( $domain, $safe_domain ) ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Check if domain is a localhost/staging domain.
+ *
+ * @since 1.4.0
+ *
+ * @param string $domain Domain to check.
+ * @return bool True if it's a localhost domain, false otherwise.
+ */
+ private function is_localhost_domain( $domain ) {
+ $patterns = array(
+ 'localhost',
+ '127.0.0.1',
+ 'example.com',
+ 'example.org',
+ '.local',
+ '.test',
+ '.localhost',
+ );
+
+ foreach ( $patterns as $pattern ) {
+ if ( str_contains( $domain, $pattern ) ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Extract domain from URL or hostname.
+ *
+ * @since 1.4.0
+ *
+ * @param string $host Hostname to extract domain from.
+ * @param array $existing_tld_names Array of TLD names.
+ * @return string Extracted domain.
+ */
+ private function extract_domain_from_host( $host, $existing_tld_names ) {
+ $host = strtolower( $host );
+ $domain_tld = '';
+
+ // Get domain biggest TLD.
+ foreach ( $existing_tld_names as $tld ) {
+ if ( str_ends_with( $host, $tld ) ) {
+ if ( strlen( $tld ) > strlen( $domain_tld ) ) {
+ $domain_tld = $tld;
+ }
+ }
+ }
+
+ if ( empty( $domain_tld ) ) {
+ // Fallback: assume last two parts are the domain.
+ $parts = explode( '.', $host );
+ if ( count( $parts ) >= 2 ) {
+ return $parts[ count( $parts ) - 2 ] . '.' . $parts[ count( $parts ) - 1 ];
+ }
+ return $host;
+ }
+
+ // Get domain from host and TLD.
+ $domain = str_replace( '.' . $domain_tld, '', $host );
+ $parts = explode( '.', $domain );
+ $domain = end( $parts ) . '.' . $domain_tld;
+
+ return $domain;
+ }
+}
diff --git a/includes/Traits/TLD_Names.php b/includes/Traits/TLD_Names.php
new file mode 100644
index 000000000..25ad0d7ee
--- /dev/null
+++ b/includes/Traits/TLD_Names.php
@@ -0,0 +1,9202 @@
+assertCount( 1, wp_list_filter( $errors['readme.txt'][0][0], array( 'code' => 'readme_invalid_donate_link_domain' ) ) );
}
+ public function test_run_with_undocumented_third_party_services() {
+ $check = new Plugin_Readme_Check();
+ $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-third-party-undocumented/load.php' );
+ $check_result = new Check_Result( $check_context );
+
+ $check->run( $check_result );
+
+ $errors = $check_result->get_errors();
+
+ $this->assertNotEmpty( $errors );
+ $this->assertArrayHasKey( 'readme.txt', $errors );
+
+ // Check for undocumented third-party service errors.
+ $undocumented_errors = wp_list_filter( $errors['readme.txt'][0][0], array( 'code' => 'undocumented_third_party_service' ) );
+ $this->assertNotEmpty( $undocumented_errors, 'Should detect undocumented third-party services' );
+ }
+
+ public function test_run_with_documented_third_party_services() {
+ $check = new Plugin_Readme_Check();
+ $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-third-party-documented/load.php' );
+ $check_result = new Check_Result( $check_context );
+
+ $check->run( $check_result );
+
+ $errors = $check_result->get_errors();
+
+ // Check that short description error exists.
+ $this->assertNotEmpty( $errors );
+ $this->assertArrayHasKey( 'readme.txt', $errors );
+ $this->assertCount( 1, wp_list_filter( $errors['readme.txt'][0][0], array( 'code' => 'readme_short_description_non_official_language' ) ) );
+
+ // Check that description error exists.
+ $this->assertCount( 1, wp_list_filter( $errors['readme.txt'][0][0], array( 'code' => 'readme_description_non_official_language' ) ) );
+ }
+
public function test_run_language_detection_with_non_english_content() {
$check = new Plugin_Readme_Check();
$check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-plugin-readme-errors-language/load.php' );