title( $post ); $parts = array( $title ); $body = $this->rendered_content( $post ); if ( '' !== $body ) { $parts[] = $body; } foreach ( $this->terms( $post ) as $line ) { // Term names keep their taxonomy's label: on their own they read as a // list of words, and the label is what makes them a sentence. $parts[] = $line; } $content = implode( "\n\n", $parts ); $truncated = false; if ( strlen( $content ) > self::MAX_BYTES ) { // mb_strcut, not mb_substr: cutting on a byte count without splitting // a multi-byte character is exactly what it is for. $content = mb_strcut( $content, 0, self::MAX_BYTES ); $truncated = true; } $document = array( 'title' => $title, 'content' => $content, 'url' => $this->url( $post ), 'language' => $this->language(), 'truncated' => $truncated, ); /** * Filters the document before it is sent. * * Whatever is added here is text a visitor may read back: the platform * stores and chunks it verbatim and shows it in snippets. * * @param array $document The document. * @param \WP_Post $post The post it came from. */ return (array) apply_filters( 'sharelyai_document', $document, $post ); } /** * A content hash covering everything that changes the document. * * What it covers decides what a re-sync notices. The settings are in it * because changing which taxonomies are sent changes the text without * touching the post. * * @param array $document The document. * @param string[] $roles The roles it is tagged with. */ public function hash( array $document, array $roles = array() ): string { // Sorted, because a role set is a set: the same roles in a different // order are the same access, and a hash that disagreed would re-send // every entry whenever WordPress happened to list roles differently. $roles = array_map( 'strval', $roles ); sort( $roles ); return hash( 'sha256', implode( '|', array( (string) $document['content'], (string) $document['title'], (string) $document['url'], (string) $document['language'], implode( ',', $roles ), ) ) ); } /** * The post's title, or something better than an empty string. * * An untitled post is not an error — WordPress allows it — but a result with * no label is useless to click, so it gets the same fallback the admin list * shows. * * @param \WP_Post $post The post. */ private function title( \WP_Post $post ): string { // WordPress decorates a private post's title — `Private: Board // minutes` — and a password-protected one's. That prefix is a label // for the editor's own screen; sent to Sharely it becomes part of the // text a visitor searches and reads, and it announces the status of a // page to anybody who finds it. The other `the_title` filters stay, // because a multilingual plugin's translation is a real title. $plain = static fn() => '%s'; add_filter( 'private_title_format', $plain, 99 ); add_filter( 'protected_title_format', $plain, 99 ); $title = trim( wp_strip_all_tags( get_the_title( $post ) ) ); remove_filter( 'private_title_format', $plain, 99 ); remove_filter( 'protected_title_format', $plain, 99 ); return '' !== $title ? $title : sprintf( /* translators: %d: the post's numeric id. */ __( 'Untitled #%d', 'sharelyai' ), $post->ID ); } /** * The post's body as a visitor would read it, as plain text. * * @param \WP_Post $post The post. */ private function rendered_content( \WP_Post $post ): string { $raw = (string) $post->post_content; if ( '' === trim( $raw ) ) { return ''; } // `the_content` is not applied: it is the one filter every plugin on the // site hangs related-posts blocks, share buttons and advertising off, and // all of that would become searchable text attributed to this page. $rendered = has_blocks( $raw ) ? do_blocks( $raw ) : $raw; $rendered = do_shortcode( $rendered ); return self::to_text( $rendered ); } /** * Terms, per taxonomy, as "Label: a, b, c" lines. * * @param \WP_Post $post The post. * * @return string[] */ private function terms( \WP_Post $post ): array { $lines = array(); foreach ( get_object_taxonomies( $post->post_type, 'objects' ) as $taxonomy ) { if ( ! $taxonomy->public ) { continue; } $terms = get_the_terms( $post, $taxonomy->name ); if ( ! is_array( $terms ) || array() === $terms ) { continue; } // The taxonomy's default term is dropped. WordPress puts every post // in "Uncategorized" unless the author chooses otherwise, so sending // it would append the same meaningless line to almost every document // on the site — and the platform reads these lines back to visitors // in snippets. $default = (int) get_option( 'default_' . $taxonomy->name, 0 ); $terms = array_filter( $terms, static fn( \WP_Term $term ): bool => $term->term_id !== $default ); if ( array() === $terms ) { continue; } $names = array_map( static fn( \WP_Term $term ): string => $term->name, $terms ); $lines[] = sprintf( '%s: %s', (string) ( $taxonomy->labels->name ?? $taxonomy->name ), implode( ', ', $names ) ); } return $lines; } /** * Where a visitor reads this post. * * The permalink as WordPress gives it. Recorded with the document because a * search result links to the site, not to Sharely, and because reconcile * uses it to recognise this site's own items in the workspace. * * @param \WP_Post $post The post. */ private function url( \WP_Post $post ): string { $permalink = get_permalink( $post ); return is_string( $permalink ) && '' !== $permalink ? $permalink : home_url( '/?p=' . $post->ID ); } /** * The language to tell the platform, or none when it would not accept it. * * The contract takes a short list; a locale outside it is better sent as * nothing than as a value the schema refuses. */ private function language(): string { $accepted = array( 'en', 'es', 'pt', 'fr', 'de', 'it', 'ja', 'ko', 'ru', 'zh' ); $locale = strtolower( (string) get_locale() ); $short = substr( $locale, 0, 2 ); return in_array( $short, $accepted, true ) ? $short : 'en'; } /** * HTML to readable plain text. * * Block and paragraph boundaries become spaces rather than disappearing: * without that, "…end of one paragraph" and "Start of the next…" run into one * word, and the platform indexes a word nobody wrote. * * @param string $html Rendered HTML. */ public static function to_text( string $html ): string { // Script and style content is not prose and would be indexed as if it were. $html = preg_replace( '~<(script|style)\b[^>]*>.*?~is', ' ', $html ) ?? $html; $html = preg_replace( '~<(br|p|div|li|tr|h[1-6]|section|article)\b[^>]*>~i', ' ', $html ) ?? $html; $html = preg_replace( '~~i', ' ', $html ) ?? $html; $text = wp_strip_all_tags( $html ); $text = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); // Non-breaking spaces come out of the editor constantly and are not // whitespace to preg_replace. $text = str_replace( array( "\xc2\xa0", "\u{200b}" ), ' ', $text ); return trim( (string) preg_replace( '/\s+/u', ' ', $text ) ); } }