*/ private array $scorers; /** * @var AnchorCandidateBuilder */ private AnchorCandidateBuilder $anchor_builder; /** * @var SuggestionRanker */ private SuggestionRanker $ranker; /** * @var SuggestionCache */ private SuggestionCache $cache; /** * @var array */ private array $enhancers; /** * @var array */ private array $pool_enhancers; /** * @var CandidateInjectorInterface|null */ private ?CandidateInjectorInterface $candidate_injector; /** * Normalised ignore-phrase set for fast lookup. * * Keys are lower-cased, trimmed phrases; values are true. * * @var array */ private array $ignore_phrases; /** * Whether to deduplicate ranked suggestions by anchor string. * * @var bool */ private bool $prevent_cannibalization; /** * Number of leading paragraphs to exclude from suggestion generation. * * @var int */ private int $skip_paragraphs; /** * Persisted corpus stem document-frequency source. Null means "always * derive per request" (e.g. in tests that construct the engine * directly with a fixture CandidateProvider). * * @var StemFrequencyRepositoryInterface|null */ private ?StemFrequencyRepositoryInterface $stem_frequency_repository; /** * @since 1.0.0 * @since 1.7.0 Added the $candidate_injector parameter. * @since 1.8.0 Added the $prevent_cannibalization parameter. * @since 1.8.0 Added the $skip_paragraphs parameter. * @since 1.10.0 Added the $stem_frequency_repository parameter. * @since 1.18.0 $candidate_injector accepts any CandidateInjectorInterface. * * @param PhraseTokenizer $phrases Sentence/phrase splitter. * @param WordTokenizer $tokenizer Word tokenizer & stemmer. * @param CandidateProvider $candidates Source of corpus candidates. * @param array $scorers Scoring strategies. Order * does not matter. * @param AnchorCandidateBuilder $anchor_builder Anchor selection helper. * @param SuggestionRanker $ranker Final ranking helper. * @param SuggestionCache $cache Per-post result cache. * @param array $enhancers Optional. Post-ranking * enhancers. Default []. * @param array $ignore_phrases Optional. Anchor phrases * that must never be suggested. * Default []. * @param array $pool_enhancers Optional. Enhancers * applied to the full * candidate pool before * anchor dedup. Default []. * @param CandidateInjectorInterface|null $candidate_injector Optional. Adds a bounded * set of candidates the lexical * gate rejected. Default null * (no injection, unchanged * behavior). * @param bool $prevent_cannibalization Optional. When true, ranked * suggestions are deduplicated so * each anchor string appears only * once across all targets. Default * true (matches the setting's * default and preserves the * engine's original unconditional * behavior). * @param int $skip_paragraphs Optional. Number of leading paragraphs to * exclude from suggestion generation. * Default 0 (no paragraphs skipped, * unchanged behavior). * @param StemFrequencyRepositoryInterface|null $stem_frequency_repository * Optional. Source of the persisted corpus * stem document-frequency map. When null, * or when its document count is zero (not * yet built), the engine falls back to * deriving the map from $candidates on * every request, exactly as it always has. * Default null. */ public function __construct( PhraseTokenizer $phrases, WordTokenizer $tokenizer, CandidateProvider $candidates, array $scorers, AnchorCandidateBuilder $anchor_builder, SuggestionRanker $ranker, SuggestionCache $cache, array $enhancers = array(), array $ignore_phrases = array(), array $pool_enhancers = array(), ?CandidateInjectorInterface $candidate_injector = null, bool $prevent_cannibalization = true, int $skip_paragraphs = 0, ?StemFrequencyRepositoryInterface $stem_frequency_repository = null ) { $this->phrases = $phrases; $this->tokenizer = $tokenizer; $this->candidates = $candidates; $this->scorers = array_values( array_filter( $scorers, static fn( $s ) => $s instanceof ScoringStrategy ) ); $this->anchor_builder = $anchor_builder; $this->ranker = $ranker; $this->cache = $cache; $this->enhancers = array_values( array_filter( $enhancers, static fn( $e ) => $e instanceof EnhancerInterface ) ); $this->pool_enhancers = array_values( array_filter( $pool_enhancers, static fn( $e ) => $e instanceof CandidatePoolEnhancerInterface ) ); $this->candidate_injector = $candidate_injector; $this->prevent_cannibalization = $prevent_cannibalization; $this->skip_paragraphs = max( 0, $skip_paragraphs ); $this->stem_frequency_repository = $stem_frequency_repository; // Normalise ignore phrases for O(1) lookup. $this->ignore_phrases = array(); foreach ( $ignore_phrases as $phrase ) { if ( is_string( $phrase ) && '' !== trim( $phrase ) ) { $this->ignore_phrases[ self::ignore_key( $phrase ) ] = true; } } } /** * Returns ranked suggestions for the given source post. * * @since 1.0.0 * @since 1.7.0 Merges in candidates recovered by the optional * candidate injector before ranking, when one is configured. * @since 1.10.0 Candidate loading is two-phase: the corpus is gated on * title/keyword data only, then only the surviving ids are * hydrated with content. Behaviour is unchanged for callers -- * this only changes when and how much content gets read. * * @param PostModel $source The post or term being analysed. * @param string $content The source's content (post_content or * term description). The engine does not * load this from WordPress — callers * supply it so the same engine can serve * both posts and terms. * @param int $limit Optional. Maximum number of results. * Default 10. Clamped to {@see MAX_LIMIT}. * * @return array Ranked suggestion list. */ public function get_suggestions( PostModel $source, string $content, int $limit = SuggestionRanker::DEFAULT_LIMIT ): array { $this->last_skip_reason = ''; if ( $source->id <= 0 ) { return array(); } if ( $limit <= 0 ) { return array(); } if ( $limit > self::MAX_LIMIT ) { $limit = self::MAX_LIMIT; } // Cache short-circuit. The cache is keyed by source id, type, // and limit so different callers don't share storage. $cached = $this->cache->get( $source->id, $limit, $source->type ); if ( null !== $cached ) { return $cached; } [ $phrases, $unlinked_sentences, $linked_ranges ] = $this->build_source_phrases( $content ); if ( array() === $phrases ) { return array(); } // Phase 1 of two-phase candidate loading: every candidate's identity // and title, cheap enough to run over the whole corpus with no cap. $light_candidates = $this->candidates->get_light_candidates_for( $source ); if ( array() === $light_candidates ) { return array(); } // The title gate below reads the whole sentence, not just the phrase // fragment, so two title words split across clauses still count // together. Sentences are shared by several phrases and the map would // otherwise be rebuilt once per phrase x candidate, so it is computed // once per distinct sentence here. // // The words of links already in the sentence are left out: they // never counted towards a match, and keeping them visible in the // sentence must not start letting them. $sentence_words = array(); foreach ( $phrases as $phrase ) { if ( ! isset( $sentence_words[ $phrase->sentence ] ) ) { $sentence_words[ $phrase->sentence ] = $this->tokenizer->unique_stems( $unlinked_sentences[ $phrase->sentence ] ?? $phrase->sentence ); } } // Title stems only depend on the candidate's title, which is // identical on the light and hydrated representations of the same // post, so this is computed once here (keyed by post id, which // survives the light-to-hydrated transition, unlike an object // identity would) and reused by both the gate below and the // phrase x candidate scoring loop further down. $title_stems_by_id = array(); foreach ( $light_candidates as $light_candidate ) { $title_stems_by_id[ $light_candidate->post->id ] = $this->tokenizer->unique_stems( $light_candidate->post->title ); } // Phase 2: gates on the light candidates (title/keyword only, no // content needed) to decide which ids are worth hydrating, then // hydrates exactly that survivor set in one batched call -- or, // when no persisted corpus frequency map is available yet, // hydrates the whole corpus up front, matching this engine's // pre-two-phase behaviour exactly. See the method docblock for why // the fallback branch does not get the same savings. [ $doc_count, $doc_freq, $candidates ] = $this->resolve_candidates_and_frequency( $light_candidates, $phrases, $sentence_words, $title_stems_by_id ); if ( array() === $candidates ) { return array(); } // Build all phrase × candidate combinations, keeping every valid // suggestion. The ranker will deduplicate by target; we then do a // second pass to deduplicate by anchor string. $suggestions = array(); foreach ( $phrases as $phrase ) { foreach ( $candidates as $candidate ) { $title_stems = $title_stems_by_id[ $candidate->post->id ] ?? array(); $matched_keywords = self::matching_keywords( $phrase, $candidate->target_keywords ); // Gate on the whole sentence so two title words split across // clauses still count together. The anchor is still built // from the phrase below, so a candidate admitted here yields // nothing unless the fragment itself contains a title word // to link. $gate_words = $sentence_words[ $phrase->sentence ] ?? $phrase->unique_words; $has_title_gate = array() !== $title_stems && $this->has_title_overlap( $gate_words, $title_stems, $doc_freq, $doc_count ); // Hard gate: skip unless the candidate's title overlaps this // phrase, OR the owner declared a target keyword that fully // matches it. A target keyword is an explicit owner signal — // it should not need to also share title words to surface. if ( ! $has_title_gate && array() === $matched_keywords ) { continue; } $context = array( KeywordScorer::CONTEXT_KEY_TARGET_WORDS => $candidate->word_counts, TfIdfScorer::CONTEXT_KEY_TARGET_WORD_TOTAL => $candidate->word_total, TfIdfScorer::CONTEXT_KEY_CORPUS_DOC_COUNT => $doc_count, TfIdfScorer::CONTEXT_KEY_CORPUS_DOC_FREQ => $doc_freq, TargetKeywordScorer::CONTEXT_KEY_TARGET_KEYWORDS => $candidate->target_keywords, TitleOverlapScorer::CONTEXT_KEY_TITLE_STEMS => $title_stems, ); $post_score = $this->score_post( $phrase, $candidate->post, $context ); if ( $post_score <= 0.0 ) { continue; } // A keyword-only match (no title overlap) has no title stems // to anchor against, so it takes the keyword-anchor path // instead of the title-span builder. $anchor = $has_title_gate ? $this->anchor_builder->build( $phrase, $candidate->word_counts, $title_stems ) : $this->anchor_builder->build_from_keywords( $phrase, $matched_keywords ); if ( null === $anchor ) { continue; } // Drop suggestions whose anchor matches an ignored phrase. if ( array() !== $this->ignore_phrases ) { if ( isset( $this->ignore_phrases[ self::ignore_key( $anchor->anchor ) ] ) ) { continue; } } $suggestions[] = new SuggestionModel( $source, $candidate->post, $anchor->anchor, '', $phrase->sentence, $post_score, $anchor->score, 0.0, $anchor->words, array(), array() !== $matched_keywords ); } } // Let a configured injector (supplied by an add-on) recover a // bounded set of candidates the lexical gate above rejected. // Injected entries are merged into $suggestions here -- before // ranking, pool enhancement, and anchor dedup -- so they flow // through the exact same downstream pipeline as every // lexically-matched suggestion, with no special casing below. // // The injector receives the full light corpus (not $candidates, // which may now be gate-survivors only -- see // resolve_candidates_and_frequency()) plus a hydration callback, // because injection specifically searches among candidates the // lexical gate REJECTED. It hydrates content itself, but only for // the handful of candidates its own similarity scoring narrows // down to -- never the whole corpus. if ( null !== $this->candidate_injector ) { $injected = $this->candidate_injector->inject_candidates( $phrases, $suggestions, $light_candidates, $source, fn( array $post_ids ): array => $this->candidates->hydrate( $post_ids ) ); if ( array() !== $injected ) { $suggestions = array_merge( $suggestions, $injected ); } } // First pass: deduplicate by target (keep best score per target). // Ask for more than $limit so the anchor-dedup pass has candidates // to draw from when it displaces duplicates and so pool enhancers // (semantic reranker) can work on an over-fetched candidate set. $over_fetch = min( $limit * 5, self::MAX_LIMIT ); $ranked = $this->ranker->rank( $suggestions, $over_fetch ); // Run pool enhancers on the full over-fetched list before anchor // dedup truncates it. The reranker needs more candidates than the // final limit to meaningfully reorder. foreach ( $this->pool_enhancers as $enhancer ) { $ranked = $enhancer->enhance_pool( $ranked, $source ); } // Second pass: deduplicate by anchor string. $ranked = $this->deduplicate_by_anchor( $ranked, $limit, $linked_ranges ); // Run final-list enhancers on the already-deduplicated, capped list. // Running them here (rather than on the over-fetched pool) means // every unit of enhancer work — e.g. an LLM call — is spent on a // suggestion the caller will actually see. foreach ( $this->enhancers as $enhancer ) { $ranked = $enhancer->enhance( $ranked, $source ); } // An enhancer that rewrites an anchor post-dedup could coincidentally // pick the same anchor text as another suggestion already in the // list. Re-run the (idempotent) anchor dedup to guard against that; // when nothing changed this is a cheap no-op pass over ≤ $limit items. $ranked = $this->deduplicate_by_anchor( $ranked, $limit, $linked_ranges ); $this->cache->set( $source->id, $limit, $ranked, $source->type ); return $ranked; } /** * Why the last {@see get_suggestions()} call returned nothing. * * Empty string when the run completed normally. `indexing_required` * means the corpus is too large to score without an index that does not * exist yet — callers should tell the user indexing is in progress * rather than "no suggestions found", which would be misleading. * * @since 1.14.0 * * @return string */ public function last_skip_reason(): string { return $this->last_skip_reason; } /** * Returns the target keywords that are fully contained in the phrase. * * A keyword phrase is pre-stemmed and space-joined (e.g. `"pet insur"`). * It is a hit when every one of its stems appears in the phrase's * `unique_words` map. This mirrors {@see TargetKeywordScorer::score()}'s * hit rule exactly — the scorer stays a standalone, pure strategy, so * the ~15-line rule is deliberately duplicated here rather than shared. * * @since 1.5.0 * * @param PhraseModel $phrase Source phrase. * @param array $target_keywords Candidate's pre-stemmed * target keyword phrases. * * @return array Matching stemmed keyword phrases, in the * order they appeared in $target_keywords. */ private static function matching_keywords( PhraseModel $phrase, array $target_keywords ): array { $phrase_words = $phrase->unique_words; if ( array() === $phrase_words || array() === $target_keywords ) { return array(); } $matched = array(); foreach ( $target_keywords as $keyword ) { if ( ! is_string( $keyword ) || '' === trim( $keyword ) ) { continue; } $keyword_stems = preg_split( '/\s+/u', trim( $keyword ) ); if ( false === $keyword_stems || array() === $keyword_stems ) { continue; } $all_present = true; foreach ( $keyword_stems as $stem ) { if ( '' === $stem ) { continue; } if ( ! isset( $phrase_words[ $stem ] ) ) { $all_present = false; break; } } if ( $all_present ) { $matched[] = $keyword; } } return $matched; } /** * Returns whether a word set's overlap with a candidate's title is * strong enough to count as a match. * * Used as a hard gate before scoring — candidates that fail this * check are skipped entirely, regardless of what other scorers would * return. The rule, applied via {@see TitleMatchGate::evaluate()}: at * least 2 title stems appear in the word set, or exactly 1 does and * that stem alone accounts for at least * {@see TitleMatchGate::MIN_SINGLE_MATCH_IDF_SHARE} of the title's * total IDF. This lets one genuinely distinctive word ("phobia", * "cynophobia") carry a suggestion while one generic word ("term", * "world", "keep") still cannot. * * @since 1.0.0 * @since 1.8.0 Accepts the corpus document-frequency map and document * count and delegates to {@see TitleMatchGate::evaluate()}, so * a single matched stem also passes when its IDF share of the * title meets {@see TitleMatchGate::MIN_SINGLE_MATCH_IDF_SHARE} * — not only when the title is a single word. * @since 1.9.0 Takes the word set directly instead of a PhraseModel, so * the caller can gate on the surrounding sentence while the * anchor is still built from the phrase. * * @param array $words Word set to test (phrase or sentence). * @param array $title_stems Candidate title stems. * @param array $doc_freq Corpus stem document-frequency map. * @param int $doc_count Total corpus document count. * * @return bool */ private function has_title_overlap( array $words, array $title_stems, array $doc_freq, int $doc_count ): bool { return TitleMatchGate::evaluate( $title_stems, $words, $doc_freq, $doc_count )['passes']; } /** * Deduplicates a ranked suggestion list by anchor collision. * * Walks the list in score order (highest first) and applies two * independent checks, both keyed on the normalised (lower-cased, * whitespace-collapsed) anchor string: * * 1. Unconditional: an anchor already claimed by a higher-scoring * suggestion FROM THE SAME SENTENCE is dropped. This is not an * SEO-strategy choice — {@see \Postlinkly\Link\LinkInserter * ::insert_anchor_in_context()} links only the first safe * occurrence of the anchor text within the sentence it was * generated from, so a second suggestion pointing at the identical * span can never actually be inserted alongside the first. Offering * it anyway means one of the two silently fails at insert time, or * worse, lands on some unrelated occurrence of the same text * elsewhere in the content. * 2. Gated by {@see $prevent_cannibalization}: an anchor already * claimed by a higher-scoring suggestion for a DIFFERENT target, * regardless of sentence, is also dropped when the setting is on — * this is the SEO-strategy question of whether the same phrase * should ever point at more than one target post anywhere in the * content. * * The result is capped at $limit either way — the cap is a separate * result-size concern from anchor dedup. * * @since 1.0.0 * @since 1.8.0 Skips the cross-target anchor-collision removal (but * keeps the $limit cap) when $prevent_cannibalization is false. * @since 1.11.0 The same-sentence check above is now unconditional, * independent of $prevent_cannibalization — it was previously * skipped along with everything else when the setting was off, * which let two suggestions for the identical anchor in the * identical sentence both be offered even though only one could * ever be inserted. * @since 1.18.3 The same-sentence check also drops an anchor whose words * overlap a higher-scoring anchor in that sentence ("depression" * after "depression and anxiety"). Once the first is linked, the * shared words are inside that link, so selecting both always * left one uninserted. Added the $linked parameter: the words of * existing links count as taken from the start, so an anchor first * found inside one is dropped rather than highlighted where it * cannot be linked. * * @param array $ranked Already ranked suggestions. * @param int $limit Maximum results to return. * @param array> $linked Optional. Byte ranges of existing * link text, keyed by sentence, * from build_source_phrases(). * Default empty. * * @return array */ private function deduplicate_by_anchor( array $ranked, int $limit, array $linked = array() ): array { $used_anchors = array(); $used_spans = array(); $claimed = $linked; $out = array(); foreach ( $ranked as $suggestion ) { if ( count( $out ) >= $limit ) { break; } // Normalise the anchor for comparison: lower-case, collapsed whitespace. $anchor_key = mb_strtolower( trim( (string) preg_replace( '/\s+/', ' ', $suggestion->anchor ) ), 'UTF-8' ); // Same anchor, same source sentence: a physical insertion // conflict. Always checked, regardless of $prevent_cannibalization. $sentence_key = mb_strtolower( trim( (string) preg_replace( '/\s+/', ' ', $suggestion->original_sentence ) ), 'UTF-8' ); $span_key = $sentence_key . "\x00" . $anchor_key; if ( isset( $used_spans[ $span_key ] ) ) { continue; } // Overlapping anchors in the same sentence: also a physical // conflict, also unconditional. Ranges are measured on the // sentence exactly as stored, because that is the string the // linked ranges were recorded against. An anchor that cannot be // found in its sentence has no range and is left to the // exact-match check above. $sentence = $suggestion->original_sentence; $range = self::anchor_range_in_sentence( $suggestion->anchor, $sentence ); if ( null !== $range && self::overlaps_any( $range, $claimed[ $sentence ] ?? array() ) ) { continue; } if ( $this->prevent_cannibalization && isset( $used_anchors[ $anchor_key ] ) ) { // This anchor is already used by a higher-scoring suggestion // for a different target. Drop this one — the user would // see the same anchor phrase competing across targets. continue; } $used_spans[ $span_key ] = true; $used_anchors[ $anchor_key ] = true; if ( null !== $range ) { $claimed[ $sentence ][] = $range; } $out[] = $suggestion; } return $out; } /** * Normalises a phrase for comparison against the ignored-phrases list. * * Anchors keep the curly apostrophes the post uses ("doctor’s office"), * while owners type the list with a keyboard apostrophe. Both become * straight here so an ignored phrase still matches. * * @since 1.18.3 * * @param string $phrase Anchor or ignored phrase. * * @return string Lower-cased, trimmed phrase with straight apostrophes. */ private static function ignore_key( string $phrase ): string { return mb_strtolower( trim( str_replace( array( "\u{2018}", "\u{2019}" ), "'", $phrase ) ), 'UTF-8' ); } /** * Finds where an anchor sits in its sentence, as the editor and inserter see it. * * Returns the first whole-word occurrence. That is the occurrence the * editor highlights and {@see \Postlinkly\Link\LinkInserter * ::insert_anchor_in_context()} links, so two anchors conflict exactly * when these ranges overlap. Word edges use the same Unicode lookarounds * as the inserter, so "cat" is not found inside "category". Matching * ignores case and accepts any run of whitespace between words, so the * anchor and sentence can be passed exactly as stored. * * @since 1.18.3 * * @param string $anchor Anchor text. * @param string $sentence Sentence the anchor was taken from. * * @return array{0: int, 1: int}|null Byte start and exclusive end within * $sentence, or null when the anchor is * not in the sentence as whole words. */ private static function anchor_range_in_sentence( string $anchor, string $sentence ): ?array { if ( '' === $anchor || '' === $sentence ) { return null; } $words = preg_split( '/\s+/u', $anchor, -1, PREG_SPLIT_NO_EMPTY ); if ( false === $words || array() === $words ) { return null; } $pattern = '/(? preg_quote( $w, '/' ), $words ) ) . '(?![\p{L}\p{N}])/iu'; if ( 1 !== preg_match( $pattern, $sentence, $m, PREG_OFFSET_CAPTURE ) ) { return null; } return array( $m[0][1], $m[0][1] + strlen( $m[0][0] ) ); } /** * Whether a range shares at least one byte with any range in a list. * * Ranges are half-open, so two anchors that merely touch ("a" ending * where "b" begins) do not overlap. * * @since 1.18.3 * * @param array{0: int, 1: int} $range Range to test. * @param array $claimed Ranges already taken. * * @return bool */ private static function overlaps_any( array $range, array $claimed ): bool { foreach ( $claimed as $taken ) { if ( $range[0] < $taken[1] && $taken[0] < $range[1] ) { return true; } } return false; } /** * Drops the first $skip_count paragraphs from raw (still-tagged) content. * * Must run before {@see strip_markup()}, which removes the `

` tags * this method relies on to find paragraph boundaries — once markup is * stripped, paragraph structure cannot be reconstructed. Both * Gutenberg's core Paragraph block and classic-editor content (via * `wpautop()`) wrap paragraph text in `

...

`, so splitting on * `

` (case-insensitive) reliably marks paragraph ends for both. * * Content with no `

` tags at all (a single unbroken block of text) * is treated as one paragraph, so a skip count still has something * well-defined to count against instead of silently doing nothing. * * @since 1.8.0 * * @param string $raw_content Raw, unstripped post content. * @param int $skip_count Number of leading paragraphs to drop. * * @return string Remaining raw content, still tagged, ready for * {@see strip_markup()}. Empty string when the content * has no more paragraphs than $skip_count. */ private static function count_leading_paragraphs_to_skip( string $raw_content, int $skip_count ): string { if ( $skip_count <= 0 ) { return $raw_content; } // Splitting with delimiter capture keeps each

tag attached to // the text before it, so paragraph 1 is $parts[0] . $parts[1], and // so on. $parts = preg_split( '/(<\/p>)/i', $raw_content, -1, PREG_SPLIT_DELIM_CAPTURE ); if ( false === $parts ) { return $raw_content; } // Recombine each [text, "

"] pair into one paragraph chunk. A // final fragment with no closing tag (malformed markup, or content // with no

tags at all) is counted as one last paragraph too; // whitespace-only chunks (e.g. the empty tail after a final

) // are dropped rather than counted as an extra empty paragraph. $paragraphs = array(); $part_count = count( $parts ); for ( $i = 0; $i < $part_count; $i += 2 ) { $chunk = $parts[ $i ] . ( $parts[ $i + 1 ] ?? '' ); if ( '' !== trim( $chunk ) ) { $paragraphs[] = $chunk; } } return implode( '', array_slice( $paragraphs, $skip_count ) ); } /** * Strips HTML tags, Gutenberg block delimiters, and shortcodes from content. * * Deliberately stricter than the candidate word-map builder's equivalent: * this one fences the text of `…` elements and removes `

…

` * elements, because neither may become an anchor — the former is already * linked text, the latter is a document landmark. The builder keeps both, * since candidate scoring should see every word the post contains. * * @since 1.0.0 * @since 1.12.0 Removes heading elements and their text. * @since 1.18.3 Keeps link text between {@see LINK_OPEN} and * {@see LINK_CLOSE} instead of removing it, and ends a line * at every paragraph, list item, table cell and line break. * * @param string $content Raw post content. * * @return string Plain text suitable for tokenisation, with link text * fenced by the link markers. */ private static function strip_markup( string $content ): string { if ( '' === $content ) { return ''; } // Existing links are already occupied anchor text, but their words // are still part of the sentence: removing them left a gap in the // context the reader is shown, and a sentence the inserter could no // longer find in the post, so the link went to the first matching // words anywhere instead. The text is kept and fenced; // build_source_phrases() keeps it out of every phrase. $content = preg_replace( '/]*>([\s\S]*?)<\/a>/iu', self::LINK_OPEN . '$1' . self::LINK_CLOSE, $content ); if ( null === $content ) { return ''; } // A full stop closing the link text ("see this guide. Next") // would otherwise sit before the fence rather than before the space, // and PhraseTokenizer::split_sentences() only ends a sentence at // punctuation directly followed by whitespace. Moving it outside the // fence changes nothing a reader sees. $content = preg_replace( '/([.!?;]+)' . self::LINK_CLOSE . '/u', self::LINK_CLOSE . '$1', $content ); if ( null === $content ) { return ''; } // Headings name a section; they are not prose, and a link inside one // is never the intent. Nothing downstream would catch it either -- // HtmlSafetyGuard only rejects offsets inside existing anchors, tag // interiors, attributes, and block JSON -- so the text is removed // here, before sentence splitting can ever offer it as an anchor. // // The replacement is a newline, not a space: a heading usually sits // between two paragraphs, and a heading without terminal punctuation // would otherwise run them together into a single sentence that does // not exist in the post. Such a sentence can never be located // verbatim in the raw content at insert time, which sends // LinkInserter::insert_anchor_in_context() down its whole-content // fallback and links the wrong occurrence. // // The `\1` backreference keeps the closing tag at the level of the // opening one, so an unbalanced `

…

` is left alone rather // than swallowing content up to the next unrelated heading close. $content = preg_replace( '/]*>[\s\S]*?<\/h\1>/iu', "\n", $content ); if ( null === $content ) { return ''; } // Remove Gutenberg block delimiters. $content = preg_replace( '//u', ' ', $content ); if ( null === $content ) { return ''; } // Drop shortcode brackets but keep inner text. $content = preg_replace( '/\[\/?[A-Za-z0-9_-]+(?:\s+[^\]]*)?\]/u', ' ', $content ); if ( null === $content ) { return ''; } // A paragraph, list item, table cell or line break ends a run of text // even where no punctuation does. Joined with a space, "terror //
  • Panic" became one sentence that exists nowhere in the post, so // link text built across the two could never be inserted. A newline // ends the sentence, as it does for headings above. $content = preg_replace( '/<\/(?:p|li|td|th|dt|dd|div|blockquote|figcaption|pre|caption|summary)\s*>|/iu', "\n", $content ); if ( null === $content ) { return ''; } // Block tags with no whitespace between them (e.g. `

    `, as // Gutenberg serialises adjacent blocks) would otherwise strip to // zero characters and glue the two sides together with no space — // corrupting sentence boundaries and anchor-in-sentence matching // downstream. A harmless extra space is preferable to a missing one. $content = str_replace( '><', '> <', $content ); if ( function_exists( 'wp_strip_all_tags' ) ) { return (string) wp_strip_all_tags( $content ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.strip_tags_strip_tags return (string) strip_tags( $content ); } /** * Builds the source post's phrase list with stemmed-word maps. * * Each phrase's sentence is the sentence as the post reads, including * the words of any link it already contains. The phrases themselves * come only from the text outside those links, so no anchor is built * from, or across, an existing link. * * @since 1.0.0 * @since 1.8.0 Drops the configured number of leading paragraphs * before tokenising, when $skip_paragraphs is set. * @since 1.18.3 Sentences keep the words of existing links. Also returns, * per sentence, its text without them (what the title gate * reads) and where they sit (what anchor dedup must avoid). * * @param string $content Source content. * * @return array{0: array, 1: array, 2: array>} * Phrases; each sentence's text without linked words, keyed by * sentence; and each sentence's linked byte ranges, keyed by * sentence (only sentences that contain a link). */ private function build_source_phrases( string $content ): array { $none = array( array(), array(), array() ); if ( '' === trim( $content ) ) { return $none; } // Must run on the still-tagged raw content -- paragraph boundaries // are gone once strip_markup() removes

    tags below. No-ops when // $skip_paragraphs is 0 (the default). $content = self::count_leading_paragraphs_to_skip( $content, $this->skip_paragraphs ); if ( '' === trim( $content ) ) { return $none; } // Strip HTML/markup before tokenising so the phrase splitter and // word tokenizer never see raw tags, URLs, or block delimiters. $content = self::strip_markup( $content ); if ( '' === trim( $content ) ) { return $none; } $phrases = array(); $unlinked = array(); $linked = array(); $index = 0; $in_link = false; $sentences = $this->phrases->split_sentences( $content ); foreach ( $sentences as $marked ) { [ $sentence, $pieces, $ranges ] = self::separate_links( $marked, $in_link ); if ( '' === $sentence ) { continue; } // The same sentence can occur twice in a post. Keeping every // linked range from both copies errs towards offering less, // never towards an anchor that lands inside a link. $unlinked[ $sentence ] ??= implode( ' ', $pieces ); if ( array() !== $ranges ) { $linked[ $sentence ] = array_merge( $linked[ $sentence ] ?? array(), $ranges ); } foreach ( $pieces as $piece ) { foreach ( $this->phrases->split_phrases( $piece ) as $phrase_text ) { $unique_words = $this->tokenizer->unique_stems( $phrase_text ); if ( array() === $unique_words ) { continue; } $phrases[] = new PhraseModel( $phrase_text, $sentence, $index, $unique_words ); ++$index; } } } return array( $phrases, $unlinked, $linked ); } /** * Splits a marked sentence into what the reader sees and what may be linked. * * Removes the link markers from the sentence, keeping the linked words, * and records where those words sit. The text outside them is returned * as separate pieces: words either side of a link are not next to each * other in the post, so no phrase may join them. * * A link can outlast its sentence, for instance when its text ends in a * full stop followed by more text, so whether the text is inside a link * is carried from one sentence to the next through $in_link. * * @since 1.18.3 * * @param string $marked Sentence from strip_markup() output. * @param bool $in_link Whether the sentence starts inside a link. Updated * to whether the next sentence does. * * @return array{0: string, 1: array, 2: array} * The sentence without markers; the pieces outside links; and * the linked byte ranges within the returned sentence. */ private static function separate_links( string $marked, bool &$in_link ): array { $parts = preg_split( '/(' . self::LINK_OPEN . '|' . self::LINK_CLOSE . ')/u', $marked, -1, PREG_SPLIT_DELIM_CAPTURE ); if ( false === $parts ) { $parts = array( $marked ); } $sentence = ''; $pieces = array(); $ranges = array(); $start = $in_link ? 0 : null; foreach ( $parts as $part ) { if ( self::LINK_OPEN === $part ) { $in_link = true; $start = strlen( $sentence ); continue; } if ( self::LINK_CLOSE === $part ) { if ( null !== $start ) { $ranges[] = array( $start, strlen( $sentence ) ); } $in_link = false; $start = null; continue; } $sentence .= $part; if ( ! $in_link ) { $pieces[] = $part; } } // The link runs on into the next sentence. if ( null !== $start ) { $ranges[] = array( $start, strlen( $sentence ) ); } // Trim as split_sentences() does. The markers could have shielded // whitespace or punctuation at either end from its own trim; trimming // the start shifts every range left by the same number of bytes. $trim_chars = " \t\r\n.!?;"; $trimmed = ltrim( $sentence, $trim_chars ); $shift = strlen( $sentence ) - strlen( $trimmed ); $sentence = rtrim( $trimmed, $trim_chars ); $length = strlen( $sentence ); $kept = array(); foreach ( $ranges as [ $from, $to ] ) { $from = max( 0, $from - $shift ); $to = min( $length, $to - $shift ); if ( $from < $to ) { $kept[] = array( $from, $to ); } } return array( $sentence, $pieces, $kept ); } /** * Resolves the corpus stem document-frequency map AND the hydrated * candidate set for this request in one pass, using whichever of two * strategies fits the data actually available. * * Fast path -- a persisted stem-frequency map with data: reads a * batched frequency lookup for only the stems this request needs * (title stems, sentence words, phrase words) without touching any * candidate's content, gates every light candidate against every * phrase using that map (identical gate logic to the phrase x * candidate loop in {@see get_suggestions()} -- this is a pre-pass * over the same rule, not a different one), then hydrates exactly the * surviving ids in one batched {@see CandidateProvider::hydrate()} * call. This is the two-phase saving this plan exists for: content is * read only for candidates that already proved relevant by title or * keyword. A configured {@see CandidateInjectorInterface} no longer * forces this method off the fast path -- as of 1.11.0 it hydrates its * own small survivor set independently (see its own class docblock), * so it needs nothing extra from this method. * * Fallback path -- no persisted map yet (fresh install, rebuild never * run): hydrates the ENTIRE light corpus up front, exactly reproducing * this engine's pre-two-phase behaviour. Without a persisted map, * doc-frequency can only be derived from real content word counts * ({@see build_doc_frequency()}), which needs every candidate hydrated * regardless of gate outcome -- there is no cheaper way to compute it * per request. * * @since 1.10.0 * @since 1.11.0 No longer falls back to full hydration merely because * a candidate injector is configured -- see above. * * @param array $light_candidates Phase 1 corpus. * @param array $phrases Source phrases. * @param array> $sentence_words Sentence -> stem map, keyed by sentence text. * @param array> $title_stems_by_id Candidate post id -> title stem map. * * @return array{0: int, 1: array, 2: array} [doc_count, doc_freq, candidates]. */ private function resolve_candidates_and_frequency( array $light_candidates, array $phrases, array $sentence_words, array $title_stems_by_id ): array { $has_persisted_map = null !== $this->stem_frequency_repository && $this->stem_frequency_repository->get_document_count() > 0; if ( ! $has_persisted_map ) { /** * Filters the largest corpus for which document frequency may be * computed live, without a persisted index. * * @since 1.14.0 * * @param int $max Maximum candidate count. */ $max = (int) apply_filters( 'postlinkly_live_frequency_max_corpus', self::LIVE_FREQUENCY_MAX_CORPUS ); // Above this, the hydrate() below reads every candidate's content // in one query and holds it all at once, which is what made // suggestions fail outright on a large unindexed site. Report // that indexing is needed instead of attempting it. if ( count( $light_candidates ) > $max ) { $this->last_skip_reason = 'indexing_required'; return array( 0, array(), array() ); } $all_ids = array_map( static fn( LightCandidate $c ): int => $c->post->id, $light_candidates ); $hydrated = $this->candidates->hydrate( $all_ids ); return array( count( $hydrated ), $this->build_doc_frequency( $hydrated ), $hydrated ); } $doc_count = $this->stem_frequency_repository->get_document_count(); $needed_stems = array(); foreach ( $title_stems_by_id as $stems ) { foreach ( $stems as $stem => $_count ) { $needed_stems[ $stem ] = true; } } foreach ( $sentence_words as $words ) { foreach ( $words as $stem => $_count ) { $needed_stems[ $stem ] = true; } } foreach ( $phrases as $phrase ) { foreach ( $phrase->unique_words as $stem => $_count ) { $needed_stems[ $stem ] = true; } } $doc_freq = $this->stem_frequency_repository->get_many( array_keys( $needed_stems ) ); // Gate every light candidate against every phrase -- identical rule // to the phrase x candidate loop below, run early so hydrate() is // only ever asked for ids that already proved relevant. $survivor_ids = array(); foreach ( $phrases as $phrase ) { $gate_words = $sentence_words[ $phrase->sentence ] ?? $phrase->unique_words; foreach ( $light_candidates as $light_candidate ) { $id = $light_candidate->post->id; if ( isset( $survivor_ids[ $id ] ) ) { continue; // Already confirmed a survivor by an earlier phrase. } $title_stems = $title_stems_by_id[ $id ]; $has_title_gate = array() !== $title_stems && $this->has_title_overlap( $gate_words, $title_stems, $doc_freq, $doc_count ); if ( $has_title_gate || array() !== self::matching_keywords( $phrase, $light_candidate->target_keywords ) ) { $survivor_ids[ $id ] = true; } } } $hydrated = array() !== $survivor_ids ? $this->candidates->hydrate( array_keys( $survivor_ids ) ) : array(); return array( $doc_count, $doc_freq, $hydrated ); } /** * Builds the corpus-wide document-frequency map by deriving it from * this request's own candidate set. * * Walks every candidate and tallies, for each stem appearing in its * word counts, +1 to the doc-frequency. The result is the input the * TF-IDF scorer needs to compute IDF. * * This is {@see resolve_candidates_and_frequency()}'s permanent * fallback, not a migration shim to be removed once the persisted map * exists — it is what keeps a fresh install (and the test suite, which * constructs the engine with no repository at all) producing * suggestions before any corpus rebuild has ever run. * * @since 1.0.0 * * @param array $candidates Candidate corpus. * * @return array Stem -> doc-count map. */ private function build_doc_frequency( array $candidates ): array { $out = array(); foreach ( $candidates as $candidate ) { foreach ( $candidate->word_counts as $stem => $_count ) { if ( ! is_string( $stem ) || '' === $stem ) { continue; } $out[ $stem ] = ( $out[ $stem ] ?? 0 ) + 1; } } return $out; } /** * Returns the post-level score: weighted sum of every scorer's output. * * The scorer interface defines per-strategy weight; the engine does a * weighted sum here. We do not normalise by the sum of weights — * absolute magnitudes are meaningful within a single ranking pass and * the ranker takes care of dropping zero-scoring entries. * * @since 1.0.0 * * @param PhraseModel $phrase Phrase from the source post. * @param PostModel $candidate Candidate target. * @param array $context Engine-supplied context. * * @return float */ private function score_post( PhraseModel $phrase, PostModel $candidate, array $context ): float { $total = 0.0; foreach ( $this->scorers as $scorer ) { $weight = $scorer->get_weight(); if ( $weight <= 0.0 ) { continue; } $total += $scorer->score( $phrase, $candidate, $context ) * $weight; } return $total; } }