*/
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;
}
}