$data Site summary: `name`, `tagline`, `about`, `sections`, * `offerings`, `faq`, `contact`, `policy`, `optional`, * `labels`, `generated` and `generator`. * @return string File body, `\n` separated, with a trailing newline. */ public function generate( array $data ): string { $labels = $this->sub_array( $data, 'labels' ); $blocks = $this->header_blocks( $data ); foreach ( $this->sub_array( $data, 'sections' ) as $section ) { $blocks[] = $this->link_section( $this->line( is_array( $section ) ? ( $section['title'] ?? '' ) : '' ), is_array( $section ) && is_array( $section['items'] ?? null ) ? $section['items'] : [] ); } $blocks[] = $this->offerings_section( $this->sub_array( $data, 'offerings' ), $this->label( $labels, 'offerings' ) ); $blocks[] = $this->faq_section( $this->sub_array( $data, 'faq' ), $this->label( $labels, 'faq' ) ); $blocks[] = $this->contact_section( $this->sub_array( $data, 'contact' ), $this->label( $labels, 'contact' ) ); $blocks[] = $this->policy_section( $this->sub_array( $data, 'policy' ), $this->label( $labels, 'policy' ) ); $blocks[] = $this->link_section( self::OPTIONAL_TITLE, $this->sub_array( $data, 'optional' ) ); $blocks[] = $this->marker( $data ); return implode( "\n\n", array_filter( $blocks, static fn( string $block ): bool => '' !== $block ) ) . "\n"; } /** * Renders the full text file: the index header, then one section per document. * * Documents are added while they fit: the first one that would push the file * past `$max_bytes` ends the run, and a comment says how many were left out. * The budget is counted in bytes because it exists to keep the response, and * the model context that consumes it, within a size a host can serve; the * marker that closes the file is reserved out of it, never dropped from it. * * @param array $data Site summary, as `generate()` takes it. * @param array $documents Rows of `title`, `url`, `date`, `modified`, `author` * and `markdown`, in the order to render. * @param int $max_bytes Maximum size of the body; 0 or less means no limit. * @return string File body, `\n` separated, with a trailing newline. */ public function generate_full( array $data, array $documents, int $max_bytes ): string { $marker = $this->marker( $data ); $out = implode( "\n\n", $this->header_blocks( $data ) ) . "\n"; $limit = $max_bytes > 0 ? $max_bytes : PHP_INT_MAX; $used = strlen( $out ) + strlen( $marker ) + 2; $total = count( $documents ); $done = 0; foreach ( array_values( $documents ) as $document ) { $block = is_array( $document ) ? $this->document_block( $document ) : ''; if ( '' === $block ) { ++$done; continue; } if ( $used + strlen( $block ) > $limit ) { $out .= "\n" . '' . "\n"; break; } $out .= $block; $used += strlen( $block ); ++$done; } return rtrim( $out, "\n" ) . "\n\n" . $marker . "\n"; } /** * The blocks both files open with: name, tagline and description. * * @param array $data Site summary. * @return array Non empty blocks, in order. */ private function header_blocks( array $data ): array { $name = $this->line( $data['name'] ?? '' ); $tagline = $this->line( $data['tagline'] ?? '' ); $about = $this->paragraphs( $data['about'] ?? '' ); $blocks = [ '# ' . ( '' !== $name ? $name : 'Site' ) ]; if ( '' !== $tagline ) { $blocks[] = '> ' . $tagline; } if ( '' !== $about ) { $blocks[] = $about; } return $blocks; } /** * Renders a section of links. * * @param string $title Section title. * @param array $items Rows of `title`, `url` and `summary`. * @return string Section, or an empty string when it has no usable item. */ private function link_section( string $title, array $items ): string { $lines = []; foreach ( $items as $item ) { if ( ! is_array( $item ) ) { continue; } $line = $this->link_line( $item ); if ( '' !== $line ) { $lines[] = $line; } } if ( '' === $title || [] === $lines ) { return ''; } return '## ' . $title . "\n" . implode( "\n", $lines ); } /** * Renders one link item. * * @param array $item Row of `title`, `url` and `summary`. * @return string Item line, or an empty string when the row names nothing. */ private function link_line( array $item ): string { $title = $this->line( $item['title'] ?? '' ); $url = $this->line( $item['url'] ?? '' ); $summary = $this->summary( $item['summary'] ?? '' ); if ( '' === $title ) { $title = $url; } if ( '' === $title ) { return ''; } $line = '- ' . ( '' !== $url ? '[' . $title . '](' . $url . ')' : $title ); return '' !== $summary ? $line . ': ' . $summary : $line; } /** * Renders the offerings section. * * @param array $offerings Rows of `name`, `url`, `summary`, `price_from`, * `currency` and `delivery`. * @param string $title Translated section title. * @return string Section, or an empty string when there is nothing to offer. */ private function offerings_section( array $offerings, string $title ): string { $lines = []; foreach ( $offerings as $offering ) { if ( ! is_array( $offering ) ) { continue; } $line = $this->link_line( [ 'title' => $offering['name'] ?? '', 'url' => $offering['url'] ?? '', 'summary' => $offering['summary'] ?? '', ] ); if ( '' === $line ) { continue; } $terms = $this->offering_terms( $offering ); $lines[] = '' !== $terms ? $line . ' (' . $terms . ')' : $line; } if ( '' === $title || [] === $lines ) { return ''; } return '## ' . $title . "\n" . implode( "\n", $lines ); } /** * The commercial terms of one offering, as the parenthesis that closes its line. * * @param array $offering Offering row. * @return string E.g. `from 2000 EUR, 4–6 weeks`, empty when neither is set. */ private function offering_terms( array $offering ): string { $price = (float) ( $offering['price_from'] ?? 0 ); $currency = $this->line( $offering['currency'] ?? '' ); $delivery = $this->line( $offering['delivery'] ?? '' ); $terms = []; if ( $price > 0 ) { $terms[] = trim( 'from ' . $this->amount( $price ) . ' ' . $currency ); } if ( '' !== $delivery ) { $terms[] = $delivery; } return implode( ', ', $terms ); } /** * Formats a price without trailing zeros. * * @param float $price Amount. * @return string E.g. `2000` or `1499.5`. */ private function amount( float $price ): string { return rtrim( rtrim( number_format( $price, 2, '.', '' ), '0' ), '.' ); } /** * Renders the FAQ section, one `###` heading per question. * * @param array $faq Rows of `q` and `a`. * @param string $title Translated section title. * @return string Section, or an empty string when nothing is answered. */ private function faq_section( array $faq, string $title ): string { $entries = []; foreach ( $faq as $row ) { if ( ! is_array( $row ) ) { continue; } $question = $this->line( $row['q'] ?? '' ); $answer = $this->line( $row['a'] ?? '' ); if ( '' !== $question && '' !== $answer ) { $entries[] = '### ' . $question . "\n" . $answer; } } if ( '' === $title || [] === $entries ) { return ''; } return '## ' . $title . "\n\n" . implode( "\n\n", $entries ); } /** * Renders the contact section. * * @param array $contact Keys `email`, `phone`, `address` and `hours`. * @param string $title Translated section title. * @return string Section, or an empty string when no way to get in touch is known. */ private function contact_section( array $contact, string $title ): string { $lines = $this->labelled_lines( [ 'Email' => $contact['email'] ?? '', 'Phone' => $contact['phone'] ?? '', 'Address' => $contact['address'] ?? '', 'Hours' => $contact['hours'] ?? '', ] ); if ( '' === $title || [] === $lines ) { return ''; } return '## ' . $title . "\n" . implode( "\n", $lines ); } /** * Renders the AI usage policy section. * * @param array $policy Keys `content_signal`, `license_label`, `ai_txt_url` * and `permissions_contact`. * @param string $title Translated section title. * @return string Section, or an empty string when no policy is set. */ private function policy_section( array $policy, string $title ): string { $lines = $this->labelled_lines( [ 'Content-Signal' => $policy['content_signal'] ?? '', 'License' => $policy['license_label'] ?? '', 'Full policy' => $policy['ai_txt_url'] ?? '', 'Permissions' => $policy['permissions_contact'] ?? '', ] ); if ( '' === $title || [] === $lines ) { return ''; } return '## ' . $title . "\n" . implode( "\n", $lines ); } /** * Turns a map of field names into `- Name: value` lines, dropping the empty ones. * * The field names are part of the file format rather than prose, so they stay * in English whatever the site's language is. * * @param array $fields Field name => value. * @return array Lines, in the given order. */ private function labelled_lines( array $fields ): array { $lines = []; foreach ( $fields as $name => $value ) { $value = $this->line( $value ); if ( '' !== $value ) { $lines[] = '- ' . $name . ': ' . $value; } } return $lines; } /** * Renders one document of the full text file. * * @param array $document Row of `title`, `url`, `date`, `modified`, * `author` and `markdown`. * @return string Document block, or an empty string when it carries no text. */ private function document_block( array $document ): string { $title = $this->line( $document['title'] ?? '' ); $url = $this->line( $document['url'] ?? '' ); $markdown = trim( (string) ( $document['markdown'] ?? '' ) ); if ( '' === $title || '' === $markdown ) { return ''; } $block = "\n---\n\n" . '# ' . $title . "\n\n"; $meta = []; if ( '' !== $url ) { $meta[] = 'URL: ' . $url; } $provenance = $this->document_meta( $document ); if ( '' !== $provenance ) { $meta[] = $provenance; } if ( [] !== $meta ) { $block .= implode( "\n", $meta ) . "\n\n"; } return $block . $markdown . "\n"; } /** * The provenance line of one document. * * @param array $document Document row. * @return string E.g. `Published: 2026-01-01 · Updated: 2026-01-02 · Author: X`. */ private function document_meta( array $document ): string { $parts = []; foreach ( [ 'Published' => $document['date'] ?? '', 'Updated' => $document['modified'] ?? '', 'Author' => $document['author'] ?? '', ] as $name => $value ) { $value = $this->line( $value ); if ( '' !== $value ) { $parts[] = $name . ': ' . $value; } } return implode( ' · ', $parts ); } /** * The comment that closes both files. * * Says who wrote the file: the plugin recognizes its own output by this line * before it overwrites a file on disk, so it is emitted whatever the caller * left out, and the date is dropped rather than left blank when unknown. * * @param array $data Site summary. * @return string Marker comment. */ private function marker( array $data ): string { $generator = $this->line( $data['generator'] ?? '' ); $generated = $this->line( $data['generated'] ?? '' ); $marker = ''; } /** * One translated section title. * * @param array $labels Translated labels. * @param string $key Label key. * @return string Title, empty when the caller did not supply one. */ private function label( array $labels, string $key ): string { return $this->line( $labels[ $key ] ?? '' ); } /** * A sub array of the input, whatever the caller passed. * * @param array $data Site summary. * @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 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 ) ); } /** * A value as text whose paragraphs survive. * * @param mixed $value Raw value. * @return string Text with at most one blank line between paragraphs. */ private function paragraphs( $value ): string { if ( ! is_scalar( $value ) ) { return ''; } $text = str_replace( [ "\r\n", "\r" ], "\n", (string) $value ); $text = (string) preg_replace( '/[^\S\n]+/', ' ', $text ); $text = (string) preg_replace( '/ *\n */', "\n", $text ); return trim( (string) preg_replace( "/\n{3,}/", "\n\n", $text ) ); } /** * A summary as one line, shortened to fit an index. * * @param mixed $value Raw summary. * @return string Summary of at most 300 characters. */ private function summary( $value ): string { $summary = $this->line( $value ); $length = function_exists( 'mb_strlen' ) ? mb_strlen( $summary ) : strlen( $summary ); if ( $length <= self::SUMMARY_LENGTH ) { return $summary; } $cut = self::SUMMARY_LENGTH - 1; $cut = function_exists( 'mb_substr' ) ? mb_substr( $summary, 0, $cut ) : substr( $summary, 0, $cut ); return rtrim( $cut ) . '…'; } }