*/ private const STANDARDS = [ 'Schema.org JSON-LD', 'llms.txt', 'humans.txt', 'security.txt (RFC 9116)', 'Content-Signal', 'IndexNow', ]; /** * Machine name, matching the settings section the module reads. * * @return string Module slug. */ public function slug(): string { return 'humans'; } /** * Human readable, translated module name. * * @return string Module title. */ public function title(): string { return __( 'humans.txt', 'acid-aeo' ); } /** * Hooks the module into WordPress. * * @return void */ public function register(): void { Router::add_file( 'humans', 'humans\.txt', 'text/plain', [ $this, 'render' ], [ 'path' => 'humans.txt' ] ); add_action( 'acid_aeo_settings_updated', [ $this, 'on_settings_updated' ] ); add_action( 'save_post', [ $this, 'invalidate_post' ], 10, 2 ); add_action( 'profile_update', [ $this, 'invalidate' ] ); } /** * Renders `/humans.txt`. * * @return string File body. */ public function render(): string { return ( new HumansTxtGenerator() )->generate( $this->data() ); } /** * Audit checks contributed by this module. * * @return array Checks. */ public function checks(): array { return [ Checks::reachable( 'humans_reachable', $this->slug(), __( 'humans.txt is reachable', 'acid-aeo' ), Router::url( 'humans' ), [ 'type' => 'text/plain', 'weight' => Check::WEIGHT_IMPORTANT, 'fix' => 'flush_rewrite', ] ), Checks::physical_conflict( 'humans_physical_conflict', $this->slug(), 'humans.txt', 'humans.txt' ), ]; } /** * Drops every cached file after a change that could alter them. * * @return void */ public function invalidate(): void { Cache::invalidate_all(); } /** * Drops the cache after a post changed, ignoring the noise around a save. * * The `Last update` line follows the newest post modification date, so a * published post moves it; an autosave, a revision and a post type nobody * can read do not, and each of them would cost a cache version bump on * every keystroke the editor sends. * * @param mixed $post_id Post ID. * @param mixed $post Post object, as `save_post` passes it. * @return void */ public function invalidate_post( $post_id, $post = null ): void { if ( $this->is_listed_post( (int) $post_id, $post ) ) { $this->invalidate(); } } /** * The colophon the file is rendered from. * * @return array Input for `Generators\HumansTxtGenerator`. */ public function data(): array { $identity = SiteData::identity(); $data = [ 'team' => $this->team(), 'thanks' => $this->option_list( 'humans.thanks' ), 'site' => [ 'last_update' => ContentData::last_modified_gmt(), 'language' => $this->language( $identity ), 'doctype' => 'HTML5', 'standards' => self::STANDARDS, 'components' => $this->option_list( 'humans.components' ), 'software' => $this->software(), ], 'credit' => $this->credit(), ]; /** * Filters the colophon `humans.txt` is built from. * * @param array $data Colophon, as described by `Modules\HumansTxt::data()`. */ $data = apply_filters( 'acid_aeo_humans_data', $data ); return is_array( $data ) ? $data : []; } /** * The team, with every blank field filled in from the linked WordPress user. * * @return array> Rows for the generator. */ private function team(): array { $rows = Options::get( 'humans.team', [] ); $team = []; foreach ( is_array( $rows ) ? $rows : [] as $row ) { if ( is_array( $row ) ) { $team[] = $this->member( $row ); } } return $team; } /** * One team member. * * What the site owner typed wins; the user account only fills the gaps, so * a member can be credited under a name that is not their login name. * * @param array $row Stored row. * @return array Row of `name`, `role`, `contact`, `url` and `social`. */ private function member( array $row ): array { $user = $this->user( (int) ( $row['user_id'] ?? 0 ) ); return [ 'name' => $this->first_non_empty( $row['name'] ?? '', null !== $user ? (string) $user->display_name : '' ), 'role' => $this->first_non_empty( $row['role'] ?? '', $this->role( $user ) ), 'contact' => (string) ( $row['contact'] ?? '' ), 'url' => $this->first_non_empty( $row['url'] ?? '', null !== $user ? (string) $user->user_url : '' ), 'social' => is_array( $row['social'] ?? null ) ? $row['social'] : [], ]; } /** * One WordPress user. * * @param int $user_id User ID from the settings. * @return WP_User|null The user, or null when the row names none. */ private function user( int $user_id ): ?WP_User { if ( $user_id <= 0 ) { return null; } $user = get_userdata( $user_id ); return $user instanceof WP_User ? $user : null; } /** * What a user does here. * * The job title from their profile first, because "Founder" says more than * "Administrator"; the WordPress role is the fallback. * * @param WP_User|null $user The user. * @return string Role, empty when there is no user or no role. */ private function role( ?WP_User $user ): string { if ( null === $user ) { return ''; } $title = trim( (string) get_user_meta( $user->ID, UserFields::JOB_TITLE, true ) ); if ( '' !== $title ) { return $title; } $roles = is_array( $user->roles ) ? array_values( $user->roles ) : []; if ( [] === $roles ) { return ''; } $names = wp_roles()->get_names(); $name = (string) ( $names[ $roles[0] ] ?? '' ); return '' !== $name ? translate_user_role( $name ) : ''; } /** * The languages the site is published in, as one `Language` line. * * @param array $identity Identity from `Data\SiteData`. * @return string E.g. `ro / en`. */ private function language( array $identity ): string { $languages = $this->strings( $identity['languages'] ?? [] ); return [] !== $languages ? implode( ' / ', $languages ) : (string) ( $identity['language'] ?? '' ); } /** * What the site runs on: WordPress, its theme, this plugin, then whatever was added. * * @return array Software names. */ private function software(): array { $software = [ 'WordPress ' . (string) get_bloginfo( 'version' ) ]; $theme = trim( (string) wp_get_theme()->get( 'Name' ) ); if ( '' !== $theme ) { $software[] = $theme; } $software[] = 'Acid AEO ' . ACID_AEO_VERSION; return array_merge( $software, $this->option_list( 'humans.software' ) ); } /** * The credit line, when the site owner leaves it on. * * @return array|null Keys `name` and `url`, or null when switched off. */ private function credit(): ?array { if ( ! (bool) Options::get( 'general.agency_credit', false ) ) { return null; } return [ 'name' => 'Acid AEO by Acid Studios', 'url' => 'https://acidstudios.ro', ]; } /** * One settings list, as strings. * * @param string $path Dot path of the setting. * @return array Items. */ private function option_list( string $path ): array { return $this->strings( Options::get( $path, [] ) ); } /** * A value as a list of non-empty strings. * * @param mixed $value Raw value. * @return array Items. */ private function strings( $value ): array { $items = []; foreach ( is_array( $value ) ? $value : [] as $item ) { $item = is_scalar( $item ) ? trim( (string) $item ) : ''; if ( '' !== $item ) { $items[] = $item; } } return $items; } /** * The first of two values that is not an empty string. * * @param mixed $value Preferred value, from the settings. * @param string $fallback Fallback value, from the user account. * @return string The chosen value. */ private function first_non_empty( $value, string $fallback ): string { $value = is_scalar( $value ) ? trim( (string) $value ) : ''; return '' !== $value ? $value : $fallback; } }