diff --git a/phpunit.xml b/phpunit.xml
index 557c77f2..10e90ab1 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -10,4 +10,7 @@
tests/Alley/WP/
+
+
+
diff --git a/src/alley/wp/alleyvate/class-feature.php b/src/alley/wp/alleyvate/class-feature.php
index 01031862..ab3d3f00 100644
--- a/src/alley/wp/alleyvate/class-feature.php
+++ b/src/alley/wp/alleyvate/class-feature.php
@@ -62,6 +62,18 @@ public function filtered_boot(): void {
*/
$load = apply_filters( "alleyvate_load_{$this->handle}", $load );
+ /**
+ * Filters whether to load the given Alleyvate feature.
+ *
+ * The dynamic portion of the hook name, `$this->$this->handle`, refers to the
+ * machine name for the feature. Filtering in this instance is based on the
+ * secondary parameter, environment,which should be used to determine whether
+ * to load or not.
+ *
+ * @param bool $load Whether to load the feature. Default true.
+ */
+ $load = apply_filters( "alleyvate_load_{$this->handle}_in_environment", $load, wp_get_environment_type() );
+
if ( $load ) {
$this->booted = true;
$this->origin->boot();
diff --git a/src/alley/wp/alleyvate/features/class-disable-alley-authors.php b/src/alley/wp/alleyvate/features/class-disable-alley-authors.php
new file mode 100644
index 00000000..e9c2f8d0
--- /dev/null
+++ b/src/alley/wp/alleyvate/features/class-disable-alley-authors.php
@@ -0,0 +1,173 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ *
+ * @package wp-alleyvate
+ */
+
+namespace Alley\WP\Alleyvate\Features;
+
+use Alley\WP\Types\Feature;
+
+/**
+ * Removes the impact of Alley accounts on the frontend of client websites by:
+ * - Ensuring Alley users do not have author archive pages.
+ * - Ensuring Byline Manager and Co-Authors Plus profiles linked to Alley users do not have author archive pages.
+ * - Filtering Alley account usernames to display as "Staff" on the frontend.
+ */
+final class Disable_Alley_Authors implements Feature {
+
+ /**
+ * Add an early hook to decide if this feature should load or not, based on the environment.
+ */
+ public function __construct() {
+ add_filter( 'alleyvate_load_disable_alley_authors_in_environment', [ self::class, 'restrict_to_environment' ], 999, 2 );
+ }
+
+ /**
+ * Accepts whether or not the feature should load, as well as the current environment,
+ * to allow for disabling this feature on certain environments.
+ *
+ * @param bool $load Whether or not to load the feature.
+ * @param string $environment The loaded environment.
+ * @return bool
+ */
+ public static function restrict_to_environment( $load, $environment ): bool {
+ if ( ! $load ) {
+ return $load;
+ }
+
+ $allowed_environments = apply_filters( 'alleyvate_disable_alley_authors_environments', [ 'production' ] );
+
+ return in_array( $environment, $allowed_environments, true );
+ }
+
+ /**
+ * Boot the feature.
+ */
+ public function boot(): void {
+ add_action( 'template_include', [ self::class, 'disable_staff_archives' ], 999 );
+ add_filter( 'get_the_author_display_name', [ self::class, 'filter__get_the_author_display_name' ], 999, 2 );
+ add_filter( 'author_link', [ self::class, 'filter__author_link' ], 999, 2 );
+ }
+
+ /**
+ * Filters the author archive URL.
+ *
+ * @param string $link The author link.
+ * @param int $author_id The author ID.
+ * @return string
+ */
+ public static function filter__author_link( $link, $author_id ): string {
+ if ( ! is_singular() ) {
+ return $link;
+ }
+
+ $author = get_user_by( 'ID', $author_id );
+ if ( ! self::is_staff_author( $author->user_email ) ) {
+ return $link;
+ }
+
+ return get_home_url();
+ }
+
+ /**
+ * Action fired once the post data has been set up. Passes the post and query by
+ * reference, so we can filter the objects directly.
+ *
+ * @param string $display_name The current post we are filtering.
+ * @param int $author_id The author ID.
+ * @return string
+ */
+ public static function filter__get_the_author_display_name( $display_name, $author_id ): string {
+ $author = get_user_by( 'ID', $author_id );
+
+ if ( ! self::is_staff_author( $author->user_email ) ) {
+ return $display_name;
+ }
+
+ return __( 'Staff', 'alley' );
+ }
+
+ /**
+ * Disable author archives for Alley--and other "general staff"--accounts.
+ *
+ * @param string $template The template currently being included.
+ * @return string The template to ultimately include.
+ */
+ public static function disable_staff_archives( string $template ): string {
+ global $wp_query;
+ // If this isn't an author archive, skip it.
+ if ( ! is_author() ) {
+ return $template;
+ }
+
+ $author = $wp_query->get_queried_object();
+
+ if ( ! self::is_staff_author( $author->user_email ) ) {
+ return $template;
+ }
+ $wp_query->set_404();
+ status_header( 404 );
+ nocache_headers();
+ return get_404_template();
+ }
+
+ /**
+ * Generate an array of authors in the database that are to be defined as "Staff"
+ * authors and not attributable authors.
+ *
+ * @param string $email The email address to compare against.
+ * @return int[]
+ */
+ public static function is_staff_author( string $email ): bool {
+ /**
+ * Filters which domains to use for defining staff users in the user database.
+ *
+ * @param string[] $domains The array of domains. Defaults to alley domains.
+ */
+ $domains = apply_filters(
+ 'alleyvate_staff_author_domains',
+ [
+ 'alley.com',
+ 'alley.co',
+ 'local.test',
+ ]
+ );
+
+ $domains = array_map(
+ /**
+ * Force domains to take the format of an email domain, to avoid
+ * false positives like `alley.com@example.com` which is a valid
+ * email.
+ *
+ * @param string $domain The domain to filter.
+ * @return string The filtered domain value.
+ */
+ function ( $domain ) {
+ if ( trim( $domain, '@' ) !== $domain ) {
+ return $domain;
+ }
+
+ return '@' . $domain;
+ },
+ $domains
+ );
+
+ return (bool) array_reduce(
+ $domains,
+ function ( $carry, $domain ) use ( $email ) {
+ if ( ! $carry ) {
+ $carry = str_contains( $email, $domain );
+ }
+ return $carry;
+ },
+ false
+ );
+ }
+}
diff --git a/src/alley/wp/alleyvate/load.php b/src/alley/wp/alleyvate/load.php
index 84ee4da0..8ed38246 100644
--- a/src/alley/wp/alleyvate/load.php
+++ b/src/alley/wp/alleyvate/load.php
@@ -109,6 +109,10 @@ function load(): void {
'disable_block_editor_rest_api_preload_paths',
new Features\Disable_Block_Editor_Rest_Api_Preload_Paths(),
),
+ new Feature(
+ 'disable_alley_authors',
+ new Features\Disable_Alley_Authors(),
+ ),
);
$plugin->boot();
diff --git a/tests/Alley/WP/Alleyvate/Features/DisableAlleyAuthorsTest.php b/tests/Alley/WP/Alleyvate/Features/DisableAlleyAuthorsTest.php
new file mode 100644
index 00000000..5dc5cc83
--- /dev/null
+++ b/tests/Alley/WP/Alleyvate/Features/DisableAlleyAuthorsTest.php
@@ -0,0 +1,342 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ *
+ * @package wp-alleyvate
+ */
+
+declare( strict_types=1 );
+
+namespace Alley\WP\Alleyvate\Features;
+
+use Alley\WP\Alleyvate\Feature;
+
+use Mantle\Database\Model\User;
+use Mantle\Testkit\Test_Case;
+use Mantle\Testing\Concerns\Refresh_Database;
+
+/**
+ * Tests for confirming Alley usernames authors do not appear on the frontend as authors.
+ */
+final class DisableAlleyAuthorsTest extends Test_Case {
+ use Refresh_Database;
+
+ /**
+ * Feature instance.
+ *
+ * @var Disable_Alley_Authors
+ */
+ private Disable_Alley_Authors $feature;
+
+ /**
+ * Alley User test account.
+ *
+ * @var Mantle\Database\Model\User
+ */
+ private User $alley_account;
+
+ /**
+ * Non-Alley User test account.
+ *
+ * @var Mantle\Database\Model\User
+ */
+ private User $non_alley_account;
+
+ /**
+ * Determine if Byline Manager is installed and available.
+ *
+ * @var bool
+ */
+ private bool $byline_manager_installed;
+
+ /**
+ * Determine if Co-Authors Plus is installed and available.
+ *
+ * @var bool
+ */
+ private bool $co_authors_plus_installed;
+
+ /**
+ * Set up the test.
+ */
+ protected function setUp(): void {
+ parent::setUp();
+
+ $this->feature = new Disable_Alley_Authors();
+
+ $this->alley_account = $this->factory()->user->as_models()->create_and_get(
+ [
+ 'user_email' => 'user@alley.com',
+ 'role' => 'administrator',
+ ]
+ );
+
+ $this->non_alley_account = $this->factory()->user->as_models()->create_and_get(
+ [
+ 'user_email' => 'user@example.com',
+ 'role' => 'editor',
+ ]
+ );
+
+ $this->byline_manager_installed = class_exists( 'Byline_Manager\Core_Author_Block' );
+ $this->co_authors_plus_installed = class_exists( 'CoAuthors_Plus' );
+ }
+
+ /**
+ * Ensure Alley users (as identified by an email address at one of Alley's domains) do
+ * not have author archive pages (they should 404)
+ *
+ * @test
+ */
+ public function test_ensure_alley_users_do_not_have_author_archive_pages() {
+ $this->feature->boot();
+
+ $this->factory()->post->create( [ 'post_author' => $this->alley_account->ID ] );
+ $this->factory()->post->create( [ 'post_author' => $this->non_alley_account->ID ] );
+
+ $this->get( $this->alley_account->permalink() )
+ ->assertStatus( 404 );
+
+ $this->get( $this->non_alley_account->permalink() )
+ ->assertOk();
+ }
+
+ /**
+ * Ensure Co-Authors Plus profiles linked to Alley users do not have author archives
+ */
+ public function test_ensure_co_authors_plus_profiles_linked_to_alley_users_do_not_have_author_archive() {
+ $this->markTestIncomplete();
+ }
+
+ /**
+ * Ensure Byline Manager profiles linked to Alley users do not have author archives
+ */
+ public function test_ensure_byline_manager_profiles_linked_to_alley_users_do_not_have_author_archive() {
+ $this->markTestIncomplete();
+ }
+
+ /**
+ * Filter author names for traditional authors data so filtered users don't appear as
+ * their actual names, but rather a generic "Staff" name.
+ *
+ * @test
+ */
+ public function test_alley_author_names_appear_as_generic_staff_name() {
+ $this->feature->boot();
+
+ $post = $this->factory()->post
+ ->as_models()
+ ->create_and_get( [ 'post_author' => $this->alley_account->ID ] );
+
+ $this->get( $post->permalink() )
+ ->assertOk()
+ ->assertDontSee( '>' . $this->alley_account->name . '<' )
+ ->assertSee( '>Staff<' );
+ }
+
+ /**
+ * Filter author URLs for traditional authors data so filtered users don't get author
+ * links.
+ *
+ * @test
+ */
+ public function test_alley_author_urls_do_not_render() {
+ $this->feature->boot();
+
+ $post = $this->factory()->post
+ ->as_models()
+ ->create_and_get( [ 'post_author' => $this->alley_account->ID ] );
+
+ $this->get( $post->permalink() )
+ ->assertOk()
+ ->assertDontSee( '/author/' . $this->alley_account->slug );
+ }
+
+ /**
+ * Filter author names for Co-Authors Plus so filtered users appear as "Staff" instead of
+ * their display name.
+ */
+ public function test_alley_author_names_appear_as_generic_staff_name_in_co_authors_plus() {
+ $this->markTestIncomplete();
+ }
+
+ /**
+ * Filter author names for Byline Manager so filtered users appear as "Staff" instead of
+ * their display name.
+ */
+ public function test_alley_author_names_appear_as_generic_staff_name_in_byline_manager() {
+ $this->markTestIncomplete();
+ }
+
+ /**
+ * Data Provider for testing emails.
+ *
+ * @return array;
+ */
+ public static function emailProvider(): array {
+ return [
+ [ 'user1@alley.com', true ],
+ [ 'user1@alley.co', true ],
+ [ 'user1@example.com', false ],
+ [ 'user1@example.co', false ],
+ [ 'alley.com@example.co', false ],
+ [ 'alley.co@example.co', false ],
+ ];
+ }
+
+ /**
+ * Generate a list of user accounts by email domain, defaulting to include Alley domains.
+ *
+ * @param string $email The email address to test.
+ * @param bool $expected_result Whether or not the comparison should work.
+ * @test
+ * @dataProvider emailProvider
+ */
+ public function test_user_array_generated_by_email_domain( string $email, bool $expected_result ) {
+ $this->assertSame(
+ $expected_result,
+ Disable_Alley_Authors::is_staff_author( $email ),
+ sprintf(
+ 'Email %s was expected to be %s but returned %s.',
+ $email,
+ ( $expected_result ) ? 'true' : 'false',
+ ( ! $expected_result ) ? 'true' : 'false',
+ ),
+ );
+ }
+
+ /**
+ * Allow the list of domains for this feature to be filtered. To test this, we take our
+ * list of test emails and invert the expected results.
+ *
+ * @param string $email The email address to test.
+ * @param bool $expected_result Whether or not the comparison should work.
+ * @test
+ * @dataProvider emailProvider
+ */
+ public function test_email_domains_is_filterable( string $email, bool $expected_result ) {
+ // Define the set of domains to be non-alley domains.
+ $filter = fn() => [ 'example.com', 'example.co' ];
+ add_filter( 'alleyvate_staff_author_domains', $filter );
+
+ /*
+ * The filter above should provide us with the exact opposite results as defined
+ * in the dataProvider so we invert that expectation here.
+ */
+ $expected_result = ! $expected_result;
+
+ // Now that we've inverted expectations, compare against reality.
+ $this->assertSame(
+ $expected_result,
+ Disable_Alley_Authors::is_staff_author( $email ),
+ sprintf(
+ 'Email %s was expected to be %s but returned %s.',
+ $email,
+ ( $expected_result ) ? 'true' : 'false',
+ ( ! $expected_result ) ? 'true' : 'false',
+ ),
+ );
+
+ remove_filter( 'alleyvate_staff_author_domains', $filter );
+ }
+
+ /**
+ * The environment list should be filterable, with production being the default.
+ */
+ public function test_environment_list_is_filterable() {
+ // Temporarily enable feature loading, but disable for all environments.
+ remove_filter( 'alleyvate_load_feature', '__return_false' );
+
+ // Allow this feature for our test environment.
+ $filter = fn() => [ 'development' ];
+ add_filter( 'alleyvate_disable_alley_authors_environments', $filter );
+
+ $feature = new Feature( 'disable_alley_authors', $this->feature );
+ $feature->filtered_boot();
+
+ $debug_information = $feature->add_debug_information( [] );
+
+ /*
+ * We can use the debug information to confirm that our feature
+ * was actually enabled during this process.
+ */
+ $this->assertSame( 'Enabled', $debug_information['wp-alleyvate']['fields'][0]['value'] );
+
+ // Clean up.
+ remove_filter( 'alleyvate_disable_alley_authors_environments', $filter );
+ add_filter( 'alleyvate_load_feature', '__return_false' );
+ }
+
+ /**
+ * Add a filter to conditionally enable/disable features by environment, which passes the
+ * feature and the environment name, using defaults from the feature (with the typical case
+ * of a feature being enabled on all environments) so this can be filtered a high level.
+ */
+ public function test_high_level_enable_disable_filter_exists_to_allow_enabling_feature_by_environment() {
+
+ // Temporarily enable feature loading, but disable for all environments.
+ remove_filter( 'alleyvate_load_feature', '__return_false' );
+
+ $filter = function ( $load, $environment ): bool {
+ if ( 'production' === $environment ) {
+ return true;
+ }
+
+ return false;
+ };
+
+ add_filter( 'alleyvate_load_example_feature_in_environment', $filter, 10, 2 );
+
+ /**
+ * A test dummy feature that increments a counter whenever booted.
+ */
+ $dummy = new class() implements \Alley\WP\Types\Feature {
+ /**
+ * Counter to count how many times the boot method is run.
+ *
+ * @var int
+ */
+ public static $counter = 0;
+
+ /**
+ * Boot of test dummy feature.
+ */
+ public function boot(): void {
+ self::$counter++;
+ }
+
+ /**
+ * Get the counter.
+ *
+ * @return int
+ */
+ public function getCounter(): int {
+ return self::$counter;
+ }
+ };
+
+ $feature = new Feature( 'example_feature', $dummy );
+ $feature->filtered_boot();
+
+ $this->assertSame( 0, $dummy->getCounter() );
+
+ // Remove test filter.
+ remove_filter( 'alleyvate_load_example_feature_in_environment', $filter );
+
+ // Force feature to load.
+ add_filter( 'alleyvate_load_example_feature_in_environment', '__return_true' );
+
+ $feature->filtered_boot();
+
+ $this->assertSame( 1, $dummy->getCounter() );
+
+ // Remove customizations of filters.
+ remove_filter( 'alleyvate_load_example_feature_in_environment', '__return_true' );
+ add_filter( 'alleyvate_load_feature', '__return_false' );
+ }
+}