$data Keys `contact` (list of URIs or bare email addresses), * `expires`, `canonical`, `languages` (list of tags), * `policy`, `acknowledgments` and `encryption`. * @return string File body, `\n` separated, with a trailing newline. * * @throws InvalidArgumentException When no contact is given, or no expiry date. */ public function generate( array $data ): string { $contacts = $this->contacts( $data ); $expires = $this->line( $data['expires'] ?? '' ); if ( [] === $contacts ) { throw new InvalidArgumentException( 'security.txt needs at least one Contact.' ); } if ( '' === $expires ) { throw new InvalidArgumentException( 'security.txt needs an Expires date.' ); } $lines = [ self::MARKER ]; foreach ( $contacts as $contact ) { $lines[] = 'Contact: ' . $contact; } $lines[] = 'Expires: ' . $expires; foreach ( [ 'Encryption' => $data['encryption'] ?? '', 'Acknowledgments' => $data['acknowledgments'] ?? '', 'Preferred-Languages' => implode( ', ', $this->items( $data, 'languages' ) ), 'Canonical' => $data['canonical'] ?? '', 'Policy' => $data['policy'] ?? '', ] as $field => $value ) { $value = $this->line( $value ); if ( '' !== $value ) { $lines[] = $field . ': ' . $value; } } return implode( "\n", $lines ) . "\n"; } /** * The contact URIs, every one of them a URI. * * RFC 9116 requires each `Contact` value to be a URI, so a value that is not * one yet is made into one: a bare address becomes `mailto:`, and anything * else becomes `https:`, which is the only scheme the RFC allows for a web * contact page. A value that already carries a scheme — `mailto:`, `https:`, * `tel:` — is kept exactly as it is, because the RFC accepts any URI and not * only those three. * * @param array $data Input array. * @return array Contact URIs, in the given order. */ private function contacts( array $data ): array { $contacts = []; foreach ( $this->items( $data, 'contact' ) as $contact ) { if ( 1 === preg_match( '/^[a-z][a-z0-9+.\-]*:/i', $contact ) ) { $contacts[] = $contact; continue; } $scheme = 1 === preg_match( '/^[^\s@]+@[^\s@]+\.[^\s@]+$/', $contact ) ? 'mailto:' : 'https://'; $contacts[] = $scheme . $contact; } return $contacts; } /** * Trimmed, non-empty single line items of one input key. * * @param array $data Input array. * @param string $key Key holding a list. * @return array Items. */ private function items( array $data, string $key ): array { $list = is_array( $data[ $key ] ?? null ) ? $data[ $key ] : []; $items = []; foreach ( $list as $item ) { $item = $this->line( $item ); if ( '' !== $item ) { $items[] = $item; } } return $items; } /** * A value as a single trimmed line. * * Field values are one line by definition: a newline inside one would end * the field and let the rest of the value be read as another one. * * @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 ) ); } }