From e4e1fec6472f7ad7f6886f2fa4d6989d05a82b34 Mon Sep 17 00:00:00 2001 From: Faisal Ahammad Date: Wed, 23 Sep 2026 02:33:58 +0600 Subject: [PATCH 1/2] feat(release): resolve @since version placeholders at release time New SCF APIs can now use @since SCF {NEXT_MAJOR_VERSION} instead of guessing the next version. The placeholder is replaced with the actual release version by composer prepare-release, after the maintainer enters and confirms the version, so annotations stay correct even when a release slips. Since_Resolver only rewrites the token inside an @since annotation and normalises it to @since SCF . Legacy ACF tags and versions that are already resolved are left alone. The release script then regenerates the docs and commits just the resolved sources plus the generated reference files. Closes #151 --- bin/class-scf-since-resolver.php | 111 ++++++++++++++++++++ bin/prepare-release.php | 48 +++++++++ docs/contributing/index.md | 1 + docs/contributing/releases.md | 1 + tests/php/docs/SCFSinceResolverTest.php | 130 ++++++++++++++++++++++++ 5 files changed, 291 insertions(+) create mode 100644 bin/class-scf-since-resolver.php create mode 100644 tests/php/docs/SCFSinceResolverTest.php 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/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. + } +} From d66824fe189207e04e326e8c93bf00cc818db309 Mon Sep 17 00:00:00 2001 From: Faisal Ahammad Date: Wed, 23 Sep 2026 19:25:20 +0600 Subject: [PATCH 2/2] fix(tests): match WP trunk preview menu label Match the editor preview menu item with a tolerant pattern because WP trunk renamed it to Preview (opens in a new tab). Refs #547 --- tests/e2e/fixtures.js | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) 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;