$data Colophon: `team` (rows of `name`, `role`, `contact`, * `url` and `social`), `thanks` (names), `site` * (`last_update`, `language`, `doctype`, `standards`, * `components`, `software`) and `credit` * (`name` and `url`, or null to leave the section out). * @return string File body, `\n` separated, with a trailing newline. */ public function generate( array $data ): string { $blocks = [ $this->team_section( $this->sub_list( $data, 'team' ) ), $this->thanks_section( $this->sub_list( $data, 'thanks' ) ), $this->site_section( $this->sub_array( $data, 'site' ) ), $this->credit_section( is_array( $data['credit'] ?? null ) ? $data['credit'] : [] ), self::MARKER, ]; return implode( "\n\n", array_filter( $blocks, static fn( string $block ): bool => '' !== $block ) ) . "\n"; } /** * The people behind the site, one blank line between members. * * @param array $team Team rows. * @return string Section, or an empty string when nobody is named. */ private function team_section( array $team ): string { $members = []; foreach ( $team as $member ) { $lines = is_array( $member ) ? $this->member_lines( $member ) : []; if ( [] !== $lines ) { $members[] = implode( "\n", $lines ); } } return [] === $members ? '' : '/* TEAM */' . "\n" . implode( "\n\n", $members ); } /** * The field lines of one team member. * * A member without a name is nobody, so the whole entry is dropped. * * @param array $member Row of `name`, `role`, `contact`, `url` and `social`. * @return array Field lines, empty when the row names nobody. */ private function member_lines( array $member ): array { $name = $this->line( $member['name'] ?? '' ); if ( '' === $name ) { return []; } return $this->fields( [ 'Name' => $name, 'Role' => $member['role'] ?? '', 'Contact' => $this->obfuscate( $this->line( $member['contact'] ?? '' ) ), 'Site' => $member['url'] ?? '', 'Social' => implode( ' , ', $this->items( $this->sub_list( $member, 'social' ) ) ), ] ); } /** * The people the site owner thanks, one `Name` line each. * * @param array $thanks Names. * @return string Section, or an empty string when nobody is thanked. */ private function thanks_section( array $thanks ): string { $lines = []; foreach ( $this->items( $thanks ) as $name ) { $lines[] = self::INDENT . 'Name: ' . $name; } return [] === $lines ? '' : '/* THANKS */' . "\n" . implode( "\n", $lines ); } /** * What the site is made of. * * Kept even when every line is empty: a humans.txt without a `SITE` section * is not a humans.txt, and the marker below it needs something to close. * * @param array $site Keys `last_update`, `language`, `doctype`, * `standards`, `components` and `software`. * @return string Section. */ private function site_section( array $site ): string { $lines = $this->fields( [ 'Last update' => $this->date( $this->line( $site['last_update'] ?? '' ) ), 'Language' => $site['language'] ?? '', 'Doctype' => $site['doctype'] ?? '', 'Standards' => implode( ', ', $this->items( $this->sub_list( $site, 'standards' ) ) ), 'Components' => implode( ', ', $this->items( $this->sub_list( $site, 'components' ) ) ), 'Software' => implode( ', ', $this->items( $this->sub_list( $site, 'software' ) ) ), ] ); return rtrim( '/* SITE */' . "\n" . implode( "\n", $lines ), "\n" ); } /** * The one line crediting the plugin, when the site owner leaves it on. * * @param array $credit Keys `name` and `url`. * @return string Section, or an empty string when the credit is switched off. */ private function credit_section( array $credit ): string { $name = $this->line( $credit['name'] ?? '' ); $url = $this->line( $credit['url'] ?? '' ); if ( '' === $name ) { return ''; } $line = 'Built with ' . $name; if ( '' !== $url ) { $line .= ' — ' . $url; } return '/* CREDIT */' . "\n" . self::INDENT . $line; } /** * Turns a map of field names into indented `Name: value` lines, dropping the empty ones. * * @param array $fields Field name => value. * @return array Lines, in the given order. */ private function fields( array $fields ): array { $lines = []; foreach ( $fields as $name => $value ) { $value = $this->line( $value ); if ( '' !== $value ) { $lines[] = self::INDENT . $name . ': ' . $value; } } return $lines; } /** * Hides an email address from the harvesters that read this file. * * The humanstxt.org convention: `name@example.com` becomes * `name [at] example.com`. Anything that is not an address is left alone. * * @param string $contact Contact value. * @return string Obfuscated contact. */ private function obfuscate( string $contact ): string { if ( 1 !== preg_match( '/^[^\s@]+@[^\s@]+$/', $contact ) ) { return $contact; } return str_replace( '@', ' [at] ', $contact ); } /** * A date in the format humans.txt uses. * * @param string $date Date as `Y-m-d`. * @return string Date as `Y/m/d`, or the input when it is not a date. */ private function date( string $date ): string { if ( 1 !== preg_match( '/^(\d{4})-(\d{2})-(\d{2})$/', $date, $parts ) ) { return $date; } return $parts[1] . '/' . $parts[2] . '/' . $parts[3]; } /** * Trimmed, non-empty single line items of a list. * * @param array $values Raw list. * @return array Items. */ private function items( array $values ): array { $items = []; foreach ( $values as $item ) { $item = $this->line( $item ); if ( '' !== $item ) { $items[] = $item; } } return $items; } /** * A sub array of the input, whatever the caller passed. * * @param array $data Colophon. * @param string $key Key to read. * @return array The value when it is an array, else an empty array. */ private function sub_array( array $data, string $key ): array { return is_array( $data[ $key ] ?? null ) ? $data[ $key ] : []; } /** * A sub list of the input, re-indexed. * * @param array $data Colophon. * @param string $key Key to read. * @return array The value as a list, else an empty array. */ private function sub_list( array $data, string $key ): array { return array_values( $this->sub_array( $data, $key ) ); } /** * A value as a single trimmed line. * * @param mixed $value Raw value. * @return string Line, empty when the value is not text. */ private function line( $value ): string { if ( ! is_scalar( $value ) ) { return ''; } return trim( (string) preg_replace( '/\s+/', ' ', (string) $value ) ); } }