diff --git a/bin/class-scf-since-resolver.php b/bin/class-scf-since-resolver.php new file mode 100644 index 00000000..d3b740a0 --- /dev/null +++ b/bin/class-scf-since-resolver.php @@ -0,0 +1,111 @@ +isFile() || ! $this->is_supported_file( $file->getFilename() ) ) { + continue; + } + + if ( $this->resolve_file( $file->getPathname(), $version ) ) { + $changed[] = $file->getPathname(); + } + } + } + + return $changed; + } + + /** + * Resolve placeholders in one file. + * + * Only the placeholder sitting in an `@since` annotation is replaced, so + * unrelated text is never touched. + * + * @param string $path File path. + * @param string $version Release version. + * @return int Number of replacements. + * @throws \RuntimeException When the file cannot be written. + */ + private function resolve_file( $path, $version ) { + $content = file_get_contents( $path ); + if ( false === $content || false === strpos( $content, self::PLACEHOLDER ) ) { + return 0; + } + + $updated = preg_replace_callback( + '/@since\s+(?:SCF\s+)?' . preg_quote( self::PLACEHOLDER, '/' ) . '/', + function () use ( $version ) { + return '@since SCF ' . $version; + }, + $content, + -1, + $count + ); + + if ( null === $updated || 0 === $count ) { + return 0; + } + + if ( false === file_put_contents( $path, $updated ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Release tooling runs outside WordPress. + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- CLI error output, not rendered HTML. + throw new \RuntimeException( 'Could not write resolved @since annotations to ' . $path ); + } + + return $count; + } + + /** + * Check whether a file can contain a source docblock. + * + * @param string $filename File name. + * @return bool Whether the file is supported. + */ + private function is_supported_file( $filename ) { + return (bool) preg_match( '/\.(php|js|jsx|ts|tsx)$/', $filename ); + } +} diff --git a/bin/prepare-release.php b/bin/prepare-release.php index e0432383..62b17a86 100644 --- a/bin/prepare-release.php +++ b/bin/prepare-release.php @@ -14,6 +14,7 @@ // Ensure we're in the right directory. chdir( dirname( __DIR__ ) ); +require_once __DIR__ . '/class-scf-since-resolver.php'; // Check if required PHP functions are available. $required_functions = array( 'exec', 'passthru' ); @@ -75,6 +76,12 @@ public function run() { exit( 1 ); } + $resolved_sources = $this->resolve_since_annotations( $new_version ); + if ( $resolved_sources ) { + $this->generate_docs(); + $this->commit_since_annotations( $new_version, $resolved_sources ); + } + $this->update_version( $new_version ); $this->commit_version( $new_version ); @@ -260,6 +267,47 @@ private function get_changelog( $version ) { return ''; } + /** + * Resolve release-time @since placeholders. + * + * @param string $version Release version. + * @return string[] Paths of files that were changed. + */ + private function resolve_since_annotations( $version ) { + $resolver = new Since_Resolver(); + $changed = $resolver->resolve( $version, dirname( __DIR__ ) ); + + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo 'Resolved @since placeholders in ' . count( $changed ) . " file(s) to {$version}.\n"; + + return $changed; + } + + /** + * Commit resolved @since annotations and their regenerated documentation. + * + * @param string $version Version the annotations were resolved to. + * @param string[] $sources Source files that were changed. + */ + private function commit_since_annotations( $version, $sources ) { + $paths = array_merge( $sources, array( 'docs/code-reference', 'docs/bin/manifest.json' ) ); + passthru( 'git add ' . implode( ' ', array_map( 'escapeshellarg', $paths ) ), $return ); + if ( 0 !== $return ) { + exit( $return ); + } + + exec( 'git diff --cached --quiet', $output, $status ); + if ( 0 === $status ) { + return; + } + + echo "Committing resolved @since annotations...\n"; + passthru( "git commit -m 'Resolve @since placeholders for {$version}'", $return ); + if ( 0 !== $return ) { + exit( $return ); + } + } + /** * Update version in plugin file * diff --git a/docs/contributing/index.md b/docs/contributing/index.md index 22858fb2..13cd16da 100644 --- a/docs/contributing/index.md +++ b/docs/contributing/index.md @@ -72,3 +72,4 @@ Releases are **not** deployed automatically — there is no GitHub Action that p - Write unit tests for new features - Document all changes - Keep pull requests focused +- For new SCF APIs, use `@since SCF {NEXT_MAJOR_VERSION}`. The release preparation script replaces this placeholder with the release version. diff --git a/docs/contributing/releases.md b/docs/contributing/releases.md index f1ce2696..e23114c8 100644 --- a/docs/contributing/releases.md +++ b/docs/contributing/releases.md @@ -59,6 +59,7 @@ This will run an interactive script that will: - Generate documentation, verify generated schemas, and update translations - Commit these changes to the current branch - Prompt the user for the new version number and update it accordingly in `secure-custom-fields.php` +- Replace `@since SCF {NEXT_MAJOR_VERSION}` placeholders with the release version and regenerate the reference documentation - Offer the user to update the stable tag in `readme.txt` - Offer the user to create a PR (so the changes can be merged back to `trunk`) diff --git a/tests/e2e/fixtures.js b/tests/e2e/fixtures.js index 59d8b016..c8fee97e 100644 --- a/tests/e2e/fixtures.js +++ b/tests/e2e/fixtures.js @@ -144,22 +144,25 @@ const test = wpTest.extend( { 'role=button[name="View"i]' ); - if ( + const hasViewButton = ( await viewButton.count() ) > 0 && - ( await viewButton.first().isVisible() ) - ) { - return editor.openPreviewPage(); - } + ( await viewButton.first().isVisible() ); - await editorTopBar - .locator( 'role=button[name="Preview"i]' ) - .click(); + await ( hasViewButton + ? viewButton.first() + : editorTopBar.locator( 'role=button[name="Preview"i]' ) + ).click(); + // WordPress trunk renamed the menu item to + // "Preview (opens in a new tab)", so match on the shared + // "Preview ... new tab" text instead of the exact label. const [ previewPage ] = await Promise.all( [ context.waitForEvent( 'page' ), - page.click( - 'role=menuitem[name=/Preview in new tab/i]' - ), + page + .getByRole( 'menuitem', { + name: /Preview.*new tab/i, + } ) + .click(), ] ); return previewPage; diff --git a/tests/php/docs/SCFSinceResolverTest.php b/tests/php/docs/SCFSinceResolverTest.php new file mode 100644 index 00000000..5b67a028 --- /dev/null +++ b/tests/php/docs/SCFSinceResolverTest.php @@ -0,0 +1,130 @@ +source_dir && is_dir( $this->source_dir ) ) { + $this->remove_directory( $this->source_dir ); + } + + parent::tear_down(); + } + + /** + * Resolves SCF annotations and leaves legacy and resolved tags untouched. + */ + public function test_resolves_placeholders_without_changing_existing_tags() { + $this->source_dir = sys_get_temp_dir() . '/scf-since-' . uniqid(); + mkdir( $this->source_dir . '/includes', 0777, true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir -- Test fixture setup. + file_put_contents( // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture setup. + $this->source_dir . '/includes/example.php', + "resolve( '7.0.0', $this->source_dir ); + $content = file_get_contents( $this->source_dir . '/includes/example.php' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Test fixture assertion. + + $this->assertCount( 1, $changed ); + $this->assertStringContainsString( '@since SCF 7.0.0', $content ); + $this->assertStringContainsString( '@since ACF 6.1', $content ); + $this->assertStringContainsString( '@since SCF 6.9.5', $content ); + } + + /** + * Handles the documented placeholder form without the SCF prefix. + */ + public function test_resolves_placeholder_without_scf_prefix() { + $this->source_dir = sys_get_temp_dir() . '/scf-since-' . uniqid(); + mkdir( $this->source_dir . '/src', 0777, true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir -- Test fixture setup. + file_put_contents( $this->source_dir . '/src/example.js', '/** @since {NEXT_MAJOR_VERSION} */' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture setup. + + $changed = ( new Since_Resolver() )->resolve( '7.0.0', $this->source_dir ); + $content = file_get_contents( $this->source_dir . '/src/example.js' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Test fixture assertion. + + $this->assertCount( 1, $changed ); + $this->assertStringContainsString( '@since SCF 7.0.0', $content ); + } + + /** + * Leaves placeholder text that is not an annotation alone. + */ + public function test_ignores_placeholder_outside_annotations() { + $this->source_dir = sys_get_temp_dir() . '/scf-since-' . uniqid(); + mkdir( $this->source_dir . '/includes', 0777, true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir -- Test fixture setup. + file_put_contents( // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture setup. + $this->source_dir . '/includes/example.php', + "resolve( '7.0.0', $this->source_dir ); + $content = file_get_contents( $this->source_dir . '/includes/example.php' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Test fixture assertion. + + $this->assertSame( array(), $changed ); + $this->assertStringContainsString( "'SCF {NEXT_MAJOR_VERSION}'", $content ); + } + + /** + * Ignores unsupported file types. + */ + public function test_ignores_unsupported_files() { + $this->source_dir = sys_get_temp_dir() . '/scf-since-' . uniqid(); + mkdir( $this->source_dir . '/includes', 0777, true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir -- Test fixture setup. + file_put_contents( $this->source_dir . '/includes/example.txt', '@since SCF {NEXT_MAJOR_VERSION}' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture setup. + + $this->assertSame( array(), ( new Since_Resolver() )->resolve( '7.0.0', $this->source_dir ) ); + } + + /** + * Returns no changed files when nothing needs resolving. + */ + public function test_reports_no_changes_without_placeholders() { + $this->source_dir = sys_get_temp_dir() . '/scf-since-' . uniqid(); + mkdir( $this->source_dir . '/src', 0777, true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir -- Test fixture setup. + file_put_contents( $this->source_dir . '/src/example.js', '/** @since SCF 6.9.5 */' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture setup. + + $this->assertSame( array(), ( new Since_Resolver() )->resolve( '7.0.0', $this->source_dir ) ); + } + + /** + * Recursively remove a temporary directory. + * + * @param string $dir Directory path. + */ + private function remove_directory( $dir ) { + $files = array_diff( scandir( $dir ), array( '.', '..' ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_scandir -- Test fixture cleanup. + + foreach ( $files as $file ) { + $path = $dir . '/' . $file; + + if ( is_dir( $path ) ) { + $this->remove_directory( $path ); + } else { + unlink( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Test fixture cleanup. + } + } + + rmdir( $dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Test fixture cleanup. + } +}