From d8ce889d8d5b66de649295bc59233a627c050345 Mon Sep 17 00:00:00 2001 From: Nilambar Sharma Date: Sat, 8 Aug 2026 23:06:28 +0545 Subject: [PATCH] Add SVN checker --- assets/css/plugin-check-svn-checker.css | 91 +++++ assets/js/plugin-check-svn-checker.js | 171 ++++++++++ includes/Admin/SVN_Checker_Page.php | 197 +++++++++++ includes/Plugin_Main.php | 5 + includes/SVN/Checker.php | 426 ++++++++++++++++++++++++ includes/SVN/Fetcher.php | 199 +++++++++++ includes/SVN/Section.php | 103 ++++++ 7 files changed, 1192 insertions(+) create mode 100644 assets/css/plugin-check-svn-checker.css create mode 100644 assets/js/plugin-check-svn-checker.js create mode 100644 includes/Admin/SVN_Checker_Page.php create mode 100644 includes/SVN/Checker.php create mode 100644 includes/SVN/Fetcher.php create mode 100644 includes/SVN/Section.php diff --git a/assets/css/plugin-check-svn-checker.css b/assets/css/plugin-check-svn-checker.css new file mode 100644 index 000000000..3186ca480 --- /dev/null +++ b/assets/css/plugin-check-svn-checker.css @@ -0,0 +1,91 @@ +.plugin-check-svn-checker-wrap { + max-width: 1200px; +} +.plugin-check-svn-checker-wrap .plugin-check-svn-checker-inner { + margin-top: 10px; +} + +/* ---------- Search form ---------- */ +.plugin-check-svn-checker-form { + margin-bottom: 20px; +} +.plugin-check-svn-checker-search-row { + display: flex; + gap: 8px; + align-items: center; +} +.plugin-check-svn-checker-search-row .spinner { + float: none; + margin: 0; +} +.plugin-check-svn-checker-slug-input { + flex: 1; + max-width: 400px; +} + +/* ---------- Plugin name ---------- */ +.plugin-check-svn-checker-plugin-name { + font-size: 16px; + font-weight: 600; + margin-bottom: 16px; +} + +/* ---------- SVN external link in section heading ---------- */ +.plugin-check-svn-checker-svn-link { + vertical-align: middle; + text-decoration: none; +} +.plugin-check-svn-checker-svn-link .dashicons { + font-size: 20px; + width: 20px; + height: 20px; +} + +/* ---------- Inline checks (root section) ---------- */ +.plugin-check-svn-checker-inline-checks { + display: flex; + flex-wrap: wrap; + gap: 16px; + margin-bottom: 20px; +} + +/* ---------- Checks table ---------- */ +.plugin-check-svn-checker-col-status { + width: 90px; +} +.plugin-check-svn-checker-col-check { + width: 40%; +} + +.plugin-check-svn-checker-row-warn td { + background: #fffcf0 !important; +} +.plugin-check-svn-checker-row-fail td { + background: #fff5f5 !important; +} + +/* ---------- Status badge ---------- */ +.plugin-check-svn-checker-badge-pass { + color: #00a32a; +} +.plugin-check-svn-checker-badge-warn { + color: #c07a00; +} +.plugin-check-svn-checker-badge-fail { + color: #d63638; +} +.plugin-check-svn-checker-badge-info { + color: #2271b1; +} + +/* ---------- Detail text ---------- */ +.plugin-check-svn-checker-detail-text { + font-size: 13px; + font-family: monospace; +} + +@media (max-width: 782px) { + .plugin-check-svn-checker-col-detail { + display: none; + } +} diff --git a/assets/js/plugin-check-svn-checker.js b/assets/js/plugin-check-svn-checker.js new file mode 100644 index 000000000..84e429259 --- /dev/null +++ b/assets/js/plugin-check-svn-checker.js @@ -0,0 +1,171 @@ +/* global pluginCheckSvnChecker */ +( function () { + 'use strict'; + + const form = document.getElementById( 'plugin-check-svn-checker-form' ); + const result = document.getElementById( 'plugin-check-svn-checker-result' ); + + if ( ! form || ! result ) { + return; + } + + const spinner = document.getElementById( + 'plugin-check-svn-checker-spinner' + ); + const { i18n } = pluginCheckSvnChecker; + + form.addEventListener( 'submit', async ( e ) => { + e.preventDefault(); + + const slug = document + .getElementById( 'plugin-check-svn-checker-slug-input' ) + .value.trim(); + + result.innerHTML = ''; + spinner.classList.add( 'is-active' ); + + try { + const params = new URLSearchParams(); + params.set( 'action', pluginCheckSvnChecker.action ); + params.set( 'nonce', pluginCheckSvnChecker.nonce ); + params.set( 'slug', slug ); + + const res = await fetch( pluginCheckSvnChecker.ajaxUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: params, + } ); + + const { success, data } = await res.json(); + + if ( ! success ) { + result.innerHTML = `

${ esc( + ( data && data.message ) || i18n.error + ) }

`; + return; + } + + result.innerHTML = renderReport( data ); + } catch { + result.innerHTML = `

${ esc( + i18n.requestFailed + ) }

`; + } finally { + spinner.classList.remove( 'is-active' ); + } + } ); + + function renderReport( data ) { + const { meta, sections } = data; + let html = ''; + + if ( meta.plugin_name ) { + html += `

${ esc( + meta.plugin_name + ) }


`; + } + + // Sections. + for ( const section of sections ) { + if ( ! section.checks.length ) { + continue; + } + + const svnLink = + section.id === 'root' && meta.svn_url + ? ` ` + : ''; + html += `

${ esc( section.label ) }${ svnLink }

`; + + if ( section.id === 'root' ) { + html += '
'; + for ( const check of section.checks ) { + html += `${ badge( check.status ) } ${ esc( + check.label + ) }`; + } + html += '
'; + } else { + html += + '' + + '' + + `` + + `` + + `` + + ''; + + for ( const check of section.checks ) { + html += ``; + html += ``; + html += ``; + html += ``; + html += ''; + } + + html += '
${ esc( + i18n.colStatus + ) }${ esc( + i18n.colCheck + ) }${ esc( + i18n.colDetail + ) }
${ badge( + check.status + ) }${ esc( + check.label + ) }${ esc( + check.detail + ) }
'; + } + } + + return html; + } + + function badge( status ) { + const map = { + pass: [ + 'dashicons-yes-alt', + i18n.pass, + 'plugin-check-svn-checker-badge-pass', + ], + warn: [ + 'dashicons-info', + i18n.warning, + 'plugin-check-svn-checker-badge-warn', + ], + fail: [ + 'dashicons-dismiss', + i18n.fail, + 'plugin-check-svn-checker-badge-fail', + ], + info: [ + 'dashicons-info', + i18n.info, + 'plugin-check-svn-checker-badge-info', + ], + }; + const [ icon, label, cls ] = map[ status ] || map.warn; + return ` ${ esc( + label + ) }`; + } + + function esc( str ) { + return String( str ?? '' ) + .replace( /&/g, '&' ) + .replace( //g, '>' ) + .replace( /"/g, '"' ) + .replace( /'/g, ''' ); + } +} )(); diff --git a/includes/Admin/SVN_Checker_Page.php b/includes/Admin/SVN_Checker_Page.php new file mode 100644 index 000000000..1012b4e06 --- /dev/null +++ b/includes/Admin/SVN_Checker_Page.php @@ -0,0 +1,197 @@ +hook_suffix = (string) $hook; + } + + /** + * Enqueue assets only on this plugin's page. + * + * @since 2.1.0 + * + * @param string $hook_suffix Current admin page hook suffix. + */ + public function enqueue_scripts( string $hook_suffix ): void { + if ( '' === $this->hook_suffix || $this->hook_suffix !== $hook_suffix ) { + return; + } + + wp_enqueue_style( + 'plugin-check-svn-checker', + WP_PLUGIN_CHECK_PLUGIN_DIR_URL . 'assets/css/plugin-check-svn-checker.css', + array(), + WP_PLUGIN_CHECK_VERSION + ); + + wp_enqueue_script( + 'plugin-check-svn-checker', + WP_PLUGIN_CHECK_PLUGIN_DIR_URL . 'assets/js/plugin-check-svn-checker.js', + array(), + WP_PLUGIN_CHECK_VERSION, + true + ); + + wp_localize_script( + 'plugin-check-svn-checker', + 'pluginCheckSvnChecker', + array( + 'ajaxUrl' => admin_url( 'admin-ajax.php' ), + 'action' => self::ACTION_CHECK, + 'nonce' => wp_create_nonce( self::ACTION_CHECK ), + 'i18n' => array( + 'error' => __( 'Error', 'plugin-check' ), + 'enterSlug' => __( 'Enter plugin slug', 'plugin-check' ), + 'requestFailed' => __( 'Request failed.', 'plugin-check' ), + 'viewInSvn' => __( 'View in SVN', 'plugin-check' ), + 'colStatus' => __( 'Status', 'plugin-check' ), + 'colCheck' => __( 'Check', 'plugin-check' ), + 'colDetail' => __( 'Detail', 'plugin-check' ), + 'pass' => __( 'Pass', 'plugin-check' ), + 'warning' => __( 'Warning', 'plugin-check' ), + 'fail' => __( 'Fail', 'plugin-check' ), + 'info' => __( 'Info', 'plugin-check' ), + ), + ) + ); + } + + /** + * Render the page. + * + * @since 2.1.0 + */ + public function render_page(): void { + $slug = isset( $_GET['plugin_slug'] ) ? sanitize_title( wp_unslash( $_GET['plugin_slug'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + ?> +
+ +

+ +
+ +
+
+ + + +
+
+ +
+ +
+ +
+ __( 'Insufficient permissions.', 'plugin-check' ) ), 403 ); + } + + $slug = isset( $_POST['slug'] ) ? sanitize_title( wp_unslash( $_POST['slug'] ) ) : ''; + + if ( empty( $slug ) ) { + wp_send_json_error( array( 'message' => __( 'Plugin slug is required.', 'plugin-check' ) ), 400 ); + } + + $checker = new Checker( $slug ); + $report = $checker->run(); + + if ( ! empty( $report['meta']['error'] ) ) { + wp_send_json_error( + array( + /* translators: %s: plugin slug. */ + 'message' => sprintf( __( 'Plugin "%s" was not found in the WordPress.org SVN repository.', 'plugin-check' ), $slug ), + ), + 404 + ); + } + + wp_send_json_success( $report ); + } +} diff --git a/includes/Plugin_Main.php b/includes/Plugin_Main.php index 3aa0a4343..210034611 100644 --- a/includes/Plugin_Main.php +++ b/includes/Plugin_Main.php @@ -10,6 +10,7 @@ use WordPress\Plugin_Check\Admin\Admin_AJAX; use WordPress\Plugin_Check\Admin\Admin_Page; use WordPress\Plugin_Check\Admin\Settings_Page; +use WordPress\Plugin_Check\Admin\SVN_Checker_Page; /** * Main class for the plugin. @@ -82,5 +83,9 @@ public function add_hooks() { $namer_page_class = '\\WordPress\\Plugin_Check\\Admin\\Namer_Page'; $namer_page = new $namer_page_class(); $namer_page->add_hooks(); + + // Create the SVN Checker page. + $svn_checker_page = new SVN_Checker_Page(); + $svn_checker_page->add_hooks(); } } diff --git a/includes/SVN/Checker.php b/includes/SVN/Checker.php new file mode 100644 index 000000000..94a122a77 --- /dev/null +++ b/includes/SVN/Checker.php @@ -0,0 +1,426 @@ +slug = sanitize_title( trim( $input ) ); + $this->base_url = self::WP_SVN_BASE . $this->slug . '/'; + $this->fetcher = new Fetcher( $this->base_url ); + } + + /** + * Run all checks and return the report data. + * + * Each remote directory is fetched exactly once; results are reused + * for both existence checks and content analysis. + * + * @since 2.1.0 + * + * @return array{slug: string, meta: array, sections: Section[]} + */ + public function run(): array { + // Attempt trunk/readme.txt, then README.txt, readme.md, README.md. + $readme_resp = $this->fetcher->fetch_raw( 'trunk/readme.txt' ); + if ( 200 !== $readme_resp['code'] ) { + $readme_resp = $this->fetcher->fetch_raw( 'trunk/README.txt' ); + } + if ( 200 !== $readme_resp['code'] ) { + $readme_resp = $this->fetcher->fetch_raw( 'trunk/readme.md' ); + } + if ( 200 !== $readme_resp['code'] ) { + $readme_resp = $this->fetcher->fetch_raw( 'trunk/README.md' ); + } + $readme_ok = 200 === $readme_resp['code']; + $readme_data = $readme_ok ? $this->fetcher->parse_readme( $readme_resp['body'] ) : array(); + + // Fetch trunk/ listing for existence check and PHP file discovery (one request). + $trunk_dir = $this->fetcher->fetch_directory( 'trunk/' ); + $trunk_exists = $trunk_dir['exists'] || $readme_ok; + + // Bail early if the slug doesn't resolve to a valid SVN repo. + if ( ! $trunk_exists ) { + return array( + 'slug' => $this->slug, + 'meta' => array( + 'error' => 'not_found', + 'svn_url' => $this->base_url, + ), + 'sections' => array(), + ); + } + + // Find main PHP file from the listing; returns content to avoid a second fetch. + [ 'file' => $plugin_file, 'content' => $php_content ] = + $this->find_main_plugin_file_with_content( $trunk_dir['items'] ); + $php_data = ! empty( $php_content ) + ? $this->fetcher->parse_plugin_headers( $php_content ) + : array(); + + // Check tags/ existence only — no need to parse the full listing. + $tags_exists = 200 === $this->fetcher->fetch_raw( 'tags/' )['code']; + + // Fetch assets/ listing for existence check and asset file scan (one request). + $assets_dir = $this->fetcher->fetch_directory( 'assets/' ); + $assets_exists = $assets_dir['exists']; + + $stable_tag = $readme_data['stable_tag'] ?? null; + $trunk_version = $php_data['Version'] ?? null; + + $meta = array( + 'plugin_name' => $readme_data['name'] ?? null, + 'plugin_file' => $plugin_file, + 'stable_tag' => $stable_tag, + 'trunk_version' => $trunk_version, + 'requires_php' => $readme_data['requires_php'] ?? null, + 'tested_up_to' => $readme_data['tested_up_to'] ?? null, + 'svn_url' => $this->base_url, + ); + + $sections = array(); + + // Section: root (no additional requests). + $root_section = new Section( 'root', __( 'Main SVN Folder', 'plugin-check' ) ); + $root_section->add_check( 'root_trunk_exists', __( 'trunk/ exists', 'plugin-check' ), 'pass', __( 'Found', 'plugin-check' ) ); + $root_section->add_check( 'root_tags_exists', __( 'tags/ exists', 'plugin-check' ), $tags_exists ? 'pass' : 'fail', $tags_exists ? __( 'Found', 'plugin-check' ) : __( 'Missing', 'plugin-check' ) ); + $root_section->add_check( 'root_assets_exists', __( 'assets/ exists', 'plugin-check' ), $assets_exists ? 'pass' : 'warn', $assets_exists ? __( 'Found', 'plugin-check' ) : __( 'Missing — optional but recommended', 'plugin-check' ) ); + $sections[] = $root_section; + + // Section: trunk (no additional requests). + $trunk_section = new Section( 'trunk', __( 'Trunk', 'plugin-check' ) ); + + $trunk_section->add_check( + 'trunk_readme_found', + __( 'readme.txt found', 'plugin-check' ), + $readme_ok ? 'pass' : 'fail', + $readme_ok + ? 'trunk/readme.txt' + /* translators: %s: file path. */ + : sprintf( __( '%s is missing', 'plugin-check' ), 'trunk/readme.txt' ) + ); + + $trunk_section->add_check( + 'trunk_stable_tag_declared', + __( 'Stable tag declared', 'plugin-check' ), + ! empty( $stable_tag ) ? 'pass' : 'fail', + ! empty( $stable_tag ) + ? $stable_tag + /* translators: %s: file path. */ + : sprintf( __( '"Stable tag:" not found in %s', 'plugin-check' ), 'trunk/readme.txt' ) + ); + + $trunk_section->add_check( + 'trunk_main_php_file_found', + __( 'Main plugin PHP file found', 'plugin-check' ), + ! empty( $plugin_file ) ? 'pass' : 'warn', + ! empty( $plugin_file ) ? "trunk/{$plugin_file}" : __( 'No PHP file with "Plugin Name:" header found in trunk/', 'plugin-check' ) + ); + + $trunk_section->add_check( + 'trunk_version_declared', + __( 'Version declared in PHP header', 'plugin-check' ), + ! empty( $trunk_version ) ? 'pass' : ( $plugin_file ? 'fail' : 'warn' ), + ! empty( $trunk_version ) + ? $trunk_version + : ( + $plugin_file + /* translators: %s: file name. */ + ? sprintf( __( 'No "Version:" header in trunk/%s', 'plugin-check' ), $plugin_file ) + : __( 'Skipped — plugin PHP file not found', 'plugin-check' ) + ) + ); + + if ( ! empty( $stable_tag ) && ! empty( $trunk_version ) ) { + $match = ( $stable_tag === $trunk_version ); + $trunk_section->add_check( + 'trunk_stable_tag_matches_version', + __( 'Stable tag matches PHP version', 'plugin-check' ), + $match ? 'pass' : 'fail', + $match ? "{$stable_tag} === {$trunk_version}" : "readme={$stable_tag}, php={$trunk_version}" + ); + } else { + $trunk_section->add_check( 'trunk_stable_tag_matches_version', __( 'Stable tag matches PHP version', 'plugin-check' ), 'warn', __( 'Cannot compare — one or both values missing', 'plugin-check' ) ); + } + + $sections[] = $trunk_section; + + // Section: stable_tag. + if ( $stable_tag ) { + $stable_tag_section = new Section( 'stable_tag', "tags/{$stable_tag}/" ); + + $tag_exists = 200 === $this->fetcher->fetch_raw( "tags/{$stable_tag}/" )['code']; + $stable_tag_section->add_check( + 'stable_tag_dir_exists', + /* translators: %s: SVN tag directory path. */ + sprintf( __( '%s exists', 'plugin-check' ), "tags/{$stable_tag}/" ), + $tag_exists ? 'pass' : 'fail', + $tag_exists + ? __( 'Found', 'plugin-check' ) + /* translators: %s: SVN tag directory path. */ + : sprintf( __( '%s not found', 'plugin-check' ), "tags/{$stable_tag}/" ) + ); + + if ( $tag_exists && $plugin_file ) { + $this->add_stable_tag_checks( $stable_tag_section, $stable_tag, $plugin_file, $stable_tag, $trunk_version ); + } + + $sections[] = $stable_tag_section; + } + + // Section: assets (pass already-fetched listing). + $assets_section = new Section( 'assets', __( 'Assets', 'plugin-check' ) ); + $this->add_assets_checks( $assets_section, $assets_dir['items'] ); + $sections[] = $assets_section; + + return array( + 'slug' => $this->slug, + 'meta' => $meta, + 'sections' => $sections, + ); + } + + /** + * Add checks for the tags/{stable}/ folder. + * + * @since 2.1.0 + * + * @param Section $section Section to add checks to. + * @param string $tag Stable tag version string. + * @param string $plugin_file Main plugin PHP filename. + * @param string|null $trunk_stable Stable tag from trunk/readme.txt. + * @param string|null $trunk_version Version from trunk PHP header. + */ + private function add_stable_tag_checks( + Section $section, + string $tag, + string $plugin_file, + ?string $trunk_stable, + ?string $trunk_version + ): void { + // Attempt readme.txt, then README.txt, readme.md, README.md in tag. + $readme_resp = $this->fetcher->fetch_raw( "tags/{$tag}/readme.txt" ); + if ( 200 !== $readme_resp['code'] ) { + $readme_resp = $this->fetcher->fetch_raw( "tags/{$tag}/README.txt" ); + } + if ( 200 !== $readme_resp['code'] ) { + $readme_resp = $this->fetcher->fetch_raw( "tags/{$tag}/readme.md" ); + } + if ( 200 !== $readme_resp['code'] ) { + $readme_resp = $this->fetcher->fetch_raw( "tags/{$tag}/README.md" ); + } + $readme_ok = 200 === $readme_resp['code']; + + $section->add_check( + 'tag_readme_found', + __( 'readme.txt found', 'plugin-check' ), + $readme_ok ? 'pass' : 'fail', + $readme_ok + ? "tags/{$tag}/readme.txt" + /* translators: %s: file path. */ + : sprintf( __( '%s is missing', 'plugin-check' ), "tags/{$tag}/readme.txt" ) + ); + + if ( $readme_ok ) { + $readme_data = $this->fetcher->parse_readme( $readme_resp['body'] ); + $tag_stable = $readme_data['stable_tag'] ?? null; + + $section->add_check( + 'tag_stable_tag_declared', + __( 'Stable tag declared', 'plugin-check' ), + ! empty( $tag_stable ) ? 'pass' : 'fail', + ! empty( $tag_stable ) ? $tag_stable : __( '"Stable tag:" not found', 'plugin-check' ) + ); + + if ( $trunk_stable && $tag_stable ) { + $match = ( $tag_stable === $trunk_stable ); + $section->add_check( + 'tag_readme_stable_tag_matches_trunk', + __( 'readme stable tag matches trunk', 'plugin-check' ), + $match ? 'pass' : 'fail', + "{$tag_stable} === {$trunk_stable}" + ); + } + } + + // Plugin PHP file in tag. + $php_resp = $this->fetcher->fetch_raw( "tags/{$tag}/{$plugin_file}" ); + $php_ok = 200 === $php_resp['code']; + + $section->add_check( + 'tag_main_php_file_found', + __( 'Main PHP file found', 'plugin-check' ), + $php_ok ? 'pass' : 'fail', + $php_ok + ? "tags/{$tag}/{$plugin_file}" + /* translators: %s: file path. */ + : sprintf( __( '%s is missing', 'plugin-check' ), "tags/{$tag}/{$plugin_file}" ) + ); + + if ( $php_ok ) { + $php_data = $this->fetcher->parse_plugin_headers( $php_resp['body'] ); + $php_version = $php_data['Version'] ?? null; + + $section->add_check( + 'tag_version_declared', + __( 'Version declared', 'plugin-check' ), + ! empty( $php_version ) ? 'pass' : 'fail', + ! empty( $php_version ) ? $php_version : __( '"Version:" header not found', 'plugin-check' ) + ); + + if ( $trunk_version && $php_version ) { + $match = ( $php_version === $trunk_version ); + $section->add_check( + 'tag_php_version_matches_trunk', + __( 'PHP version matches trunk', 'plugin-check' ), + $match ? 'pass' : 'fail', + "{$php_version} === {$trunk_version}" + ); + } + } + } + + /** + * Add asset presence checks (banner, icon). + * + * @since 2.1.0 + * + * @param Section $section Section to add checks to. + * @param array $items Pre-fetched assets/ listing. + */ + private function add_assets_checks( Section $section, array $items ): void { + $filenames = array_column( $items, 'name' ); + + $banner_file = null; + $icon_file = null; + + foreach ( $filenames as $name ) { + if ( ! $banner_file && preg_match( '/^banner-\d+x\d+\.(png|jpg)$/i', $name ) ) { + $banner_file = $name; + } + if ( ! $icon_file && preg_match( '/^icon(-\d+x\d+\.(png|jpg)|\.svg)$/i', $name ) ) { + $icon_file = $name; + } + } + + $section->add_check( + 'assets_banner_present', + __( 'Banner image present', 'plugin-check' ), + $banner_file ? 'pass' : 'info', + /* translators: %s: file name pattern. */ + $banner_file ?? sprintf( __( '%s not found — optional', 'plugin-check' ), 'banner-772x250.(png|jpg)' ) + ); + + $section->add_check( + 'assets_icon_present', + __( 'Icon image present', 'plugin-check' ), + $icon_file ? 'pass' : 'info', + /* translators: %s: file name pattern. */ + $icon_file ?? sprintf( __( '%s not found — optional', 'plugin-check' ), 'icon-128x128.(png|jpg) or icon.svg' ) + ); + } + + /** + * Find the main plugin PHP file from a pre-fetched trunk/ listing. + * + * Uses the directory listing so no extra HTTP request is needed. + * Prioritises {slug}.php (WP.org convention) but works for any filename. + * Returns both the filename and its content so the caller never re-fetches. + * + * @since 2.1.0 + * + * @param array $trunk_items Pre-fetched trunk/ items. + * @return array{file: string|null, content: string} + */ + private function find_main_plugin_file_with_content( array $trunk_items ): array { + $skip = array( 'uninstall.php' ); + $slug_php = $this->slug . '.php'; + $first = array(); + $rest = array(); + + foreach ( $trunk_items as $item ) { + if ( $item['is_dir'] ) { + continue; + } + + $name = $item['name']; + + if ( 'php' !== strtolower( pathinfo( $name, PATHINFO_EXTENSION ) ) ) { + continue; + } + + if ( in_array( $name, $skip, true ) ) { + continue; + } + + // Keep slug.php as first candidate (convention), everything else after. + if ( $name === $slug_php ) { + $first[] = $name; + } else { + $rest[] = $name; + } + } + + foreach ( array_merge( $first, $rest ) as $name ) { + $r = $this->fetcher->fetch_raw( "trunk/{$name}" ); + if ( 200 === $r['code'] && false !== strpos( $r['body'], 'Plugin Name:' ) ) { + return array( + 'file' => $name, + 'content' => $r['body'], + ); + } + } + + return array( + 'file' => null, + 'content' => '', + ); + } +} diff --git a/includes/SVN/Fetcher.php b/includes/SVN/Fetcher.php new file mode 100644 index 000000000..782b24783 --- /dev/null +++ b/includes/SVN/Fetcher.php @@ -0,0 +1,199 @@ +base_url = $base_url; + } + + /** + * Fetch a file by relative path. Returns HTTP code and body. + * + * @since 2.1.0 + * + * @param string $relative_path Path relative to base URL. + * @return array{code: int, body: string} + */ + public function fetch_raw( string $relative_path ): array { + $url = $this->base_url . ltrim( $relative_path, '/' ); + $response = wp_remote_get( $url, array( 'timeout' => 20 ) ); + + if ( is_wp_error( $response ) ) { + return array( + 'code' => 0, + 'body' => '', + ); + } + + return array( + 'code' => (int) wp_remote_retrieve_response_code( $response ), + 'body' => wp_remote_retrieve_body( $response ), + ); + } + + /** + * Fetch a directory and return both existence flag and parsed items. + * + * @since 2.1.0 + * + * @param string $relative_path Directory path relative to base URL. + * @return array{exists: bool, items: array} + */ + public function fetch_directory( string $relative_path ): array { + $r = $this->fetch_raw( $relative_path ); + + return array( + 'exists' => 200 === $r['code'], + 'items' => 200 === $r['code'] ? $this->parse_html_links( $r['body'] ) : array(), + ); + } + + /** + * Fetch and parse an SVN HTTP directory listing. + * + * @since 2.1.0 + * + * @param string $relative_path Directory path relative to base URL. + * @return array + */ + public function fetch_directory_items( string $relative_path ): array { + return $this->fetch_directory( $relative_path )['items']; + } + + /** + * Parse links from an SVN HTML directory listing. + * + * @since 2.1.0 + * + * @param string $html Raw HTML body. + * @return array + */ + private function parse_html_links( string $html ): array { + libxml_use_internal_errors( true ); + $doc = new DOMDocument(); + $doc->loadHTML( '' . $html ); + libxml_clear_errors(); + + $items = array(); + $seen = array(); + + foreach ( $doc->getElementsByTagName( 'a' ) as $link ) { + $href = $link->getAttribute( 'href' ); + $text = trim( $link->textContent ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + + if ( + empty( $href ) + || '../' === $href + || '#' === $href[0] + || '?' === $href[0] + || false !== strpos( $href, '://' ) + || isset( $seen[ $href ] ) + ) { + continue; + } + + $seen[ $href ] = true; + $items[] = array( + 'name' => $text ? $text : rtrim( $href, '/' ), + 'href' => $href, + 'is_dir' => '/' === substr( $href, -1 ), + ); + } + + return $items; + } + + /** + * Parse readme.txt content into a metadata array. + * + * @since 2.1.0 + * + * @param string $content Raw readme.txt content. + * @return array + */ + public function parse_readme( string $content ): array { + $data = array(); + $fields = array( + 'stable_tag' => '/^Stable tag:\s*(.+)$/im', + 'requires_at_least' => '/^Requires at least:\s*(.+)$/im', + 'tested_up_to' => '/^Tested up to:\s*(.+)$/im', + 'requires_php' => '/^Requires PHP:\s*(.+)$/im', + ); + + foreach ( $fields as $key => $pattern ) { + if ( preg_match( $pattern, $content, $m ) ) { + $data[ $key ] = trim( $m[1] ); + } + } + + if ( preg_match( '/^===\s*(.+?)\s*===/m', $content, $m ) ) { + $data['name'] = trim( $m[1] ); + } elseif ( preg_match( '/^#\s+(.+)$/m', $content, $m ) ) { + $data['name'] = trim( $m[1] ); + } + + return $data; + } + + /** + * Parse WordPress plugin PHP header fields. + * + * @since 2.1.0 + * + * @param string $content Raw PHP file content. + * @return array + */ + public function parse_plugin_headers( string $content ): array { + $data = array(); + $fields = array( + 'Plugin Name' => '/^\s*[\/*#]*\s*Plugin Name:\s*(.+)$/im', + 'Version' => '/^\s*[\/*#]*\s*Version:\s*(.+)$/im', + 'Requires at least' => '/^\s*[\/*#]*\s*Requires at least:\s*(.+)$/im', + 'Tested up to' => '/^\s*[\/*#]*\s*Tested up to:\s*(.+)$/im', + 'Requires PHP' => '/^\s*[\/*#]*\s*Requires PHP:\s*(.+)$/im', + 'Author' => '/^\s*[\/*#]*\s*Author:\s*(.+)$/im', + ); + + foreach ( $fields as $key => $pattern ) { + if ( preg_match( $pattern, $content, $m ) ) { + $data[ $key ] = trim( $m[1] ); + } + } + + return $data; + } +} diff --git a/includes/SVN/Section.php b/includes/SVN/Section.php new file mode 100644 index 000000000..1cda519df --- /dev/null +++ b/includes/SVN/Section.php @@ -0,0 +1,103 @@ + + */ + private array $checks = array(); + + /** + * Constructor. + * + * @since 2.1.0 + * + * @param string $id Section ID. + * @param string $label Section label. + */ + public function __construct( string $id, string $label ) { + $this->id = $id; + $this->label = $label; + } + + /** + * Add a check. + * + * @since 2.1.0 + * + * @param string $key Machine-readable, untranslated check identifier. + * @param string $label Translated check label. + * @param string $status pass|warn|fail|info. + * @param string $detail Translated detail text. + */ + public function add_check( string $key, string $label, string $status, string $detail = '' ): void { + $this->checks[] = array( + 'key' => $key, + 'label' => $label, + 'status' => $status, + 'detail' => $detail, + ); + } + + /** + * Return checks in this section. + * + * @since 2.1.0 + * + * @return array + */ + public function get_checks(): array { + return $this->checks; + } + + /** + * Serialize to JSON. + * + * @since 2.1.0 + * + * @return array + */ + public function jsonSerialize(): array { + return array( + 'id' => $this->id, + 'label' => $this->label, + 'checks' => $this->checks, + ); + } +}