true, 'include_case_variations' => true, 'include_partial_matches' => false, 'min_word_length' => 3, 'max_variations' => 20 ); /** * Current configuration * @var array */ private $config; /** * Common English stop words to ignore in partial matching * @var array */ private $stop_words = array( 'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by', 'from', 'as', 'is', 'was', 'are', 'were', 'been', 'be', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', 'should', 'may', 'might', 'must', 'shall', 'can' ); /** * Constructor */ public function __construct() { $this->load_configuration(); } /** * Generate title variations (the link matcher matches them exactly) * * @param string $title Original title * @return array Array of variations including original */ public function generate_variations($title) { if (empty($title)) { return array(); } $variations = array($title); // ⚠ There is no prefix/suffix stripping and no hidden override for // one (#140 DD11, DEPRECATION_NOTICES). // Add plural/singular variations if ($this->config['include_plurals']) { $variations = array_merge($variations, $this->generate_plural_variations($title)); } // Add case variations if ($this->config['include_case_variations']) { $variations = array_merge($variations, $this->generate_case_variations($variations)); } // Add partial matches if enabled if ($this->config['include_partial_matches']) { $variations = array_merge($variations, $this->generate_partial_matches($title)); } // Clean and deduplicate $variations = $this->clean_variations($variations); // Limit number of variations if (count($variations) > $this->config['max_variations']) { $variations = array_slice($variations, 0, $this->config['max_variations']); } return $variations; } /** * Load configuration from options */ private function load_configuration() { // answerrails_variation_config is a documented HIDDEN OVERRIDE with // no UI; the defaults above are load-bearing (plurals + case // variations ON), and migration 1.5.0 deletes stored values so // those defaults resume. The prefix/suffix keys are NOT overridable // — no machinery reads them (#140 DD11, DEPRECATION_NOTICES). The // remaining keys (plurals, case, partial matches, limits) are. // NOTE: variations are computed at knowledge INDEX time — an // override only affects items (re)indexed afterwards. $saved_config = get_option('answerrails_variation_config', array()); $this->config = wp_parse_args($saved_config, $this->default_config); } /** * Generate plural and singular variations * * @param string $title Original title * @return array Plural/singular variations */ private function generate_plural_variations($title) { $variations = array(); // Simple English pluralization rules $last_word = $this->get_last_significant_word($title); if (empty($last_word)) { return $variations; } // Check if already plural (ends in 's') if (substr($last_word, -1) === 's') { // Try singular $singular = substr($last_word, 0, -1); $variations[] = str_replace($last_word, $singular, $title); // Handle 'ies' -> 'y' if (substr($last_word, -3) === 'ies') { $singular = substr($last_word, 0, -3) . 'y'; $variations[] = str_replace($last_word, $singular, $title); } // Handle 'es' -> '' only after a sibilant stem whose plural // really is +es (classes, dishes, churches, boxes, buzzes). #373: // a bare 'es' strip turned "Services" into the non-word "Servic"; // the plain 's' strip above already yields an 'e'-stem singular. // 'ch' is included although an e-stem word (Niches, Caches) then // also yields a non-word stem: matching is exact, so an extra // variation nobody writes links nothing, while a missing one // ("Church") loses a real link (Codex P2 on PR #375). if (preg_match('/(ss|sh|ch|x|zz)es$/i', $last_word)) { $singular = substr($last_word, 0, -2); $variations[] = str_replace($last_word, $singular, $title); } } else { // Try plural $plural = $last_word . 's'; $variations[] = str_replace($last_word, $plural, $title); // Handle 'y' -> 'ies' if (substr($last_word, -1) === 'y' && !in_array(substr($last_word, -2, 1), array('a', 'e', 'i', 'o', 'u'))) { $plural = substr($last_word, 0, -1) . 'ies'; $variations[] = str_replace($last_word, $plural, $title); } // Handle words ending in 's', 'x', 'z', 'ch', 'sh' if (preg_match('/(s|x|z|ch|sh)$/', $last_word)) { $plural = $last_word . 'es'; $variations[] = str_replace($last_word, $plural, $title); } } return $variations; } /** * Generate case variations * * @param array $variations Existing variations * @return array Additional case variations */ private function generate_case_variations($variations) { $case_variations = array(); foreach ($variations as $variation) { // Lowercase $case_variations[] = strtolower($variation); // Uppercase $case_variations[] = strtoupper($variation); // Title case $case_variations[] = ucwords(strtolower($variation)); // First letter uppercase $case_variations[] = ucfirst(strtolower($variation)); } return $case_variations; } /** * Generate partial matches from significant words * * @param string $title Original title * @return array Partial match variations */ private function generate_partial_matches($title) { $variations = array(); $words = $this->extract_significant_words($title); // Add individual significant words foreach ($words as $word) { if (strlen($word) >= $this->config['min_word_length']) { $variations[] = $word; } } // Add combinations of 2-3 consecutive words $all_words = explode(' ', $title); $word_count = count($all_words); for ($length = 2; $length <= min(3, $word_count - 1); $length++) { for ($start = 0; $start <= $word_count - $length; $start++) { $phrase = implode(' ', array_slice($all_words, $start, $length)); if ($this->is_significant_phrase($phrase)) { $variations[] = $phrase; } } } return $variations; } /** * Extract significant words from text * * @param string $text Input text * @return array Significant words */ private function extract_significant_words($text) { $words = preg_split('/\s+/', strtolower($text)); $significant = array(); foreach ($words as $word) { // Remove punctuation $word = preg_replace('/[^\w\-]/', '', $word); // Check if significant if (strlen($word) >= $this->config['min_word_length'] && !in_array($word, $this->stop_words)) { $significant[] = $word; } } return $significant; } /** * Get the last significant word in a title * * @param string $title Title text * @return string|null Last significant word */ private function get_last_significant_word($title) { $words = explode(' ', $title); for ($i = count($words) - 1; $i >= 0; $i--) { $word = preg_replace('/[^\w]/', '', $words[$i]); if (strlen($word) >= $this->config['min_word_length'] && !in_array(strtolower($word), $this->stop_words)) { return $word; } } return null; } /** * Check if a phrase is significant * * @param string $phrase Phrase to check * @return bool True if significant */ private function is_significant_phrase($phrase) { $words = explode(' ', strtolower($phrase)); $significant_count = 0; foreach ($words as $word) { if (!in_array($word, $this->stop_words)) { $significant_count++; } } // At least half the words should be significant return $significant_count >= count($words) / 2; } /** * Clean and deduplicate variations * * @param array $variations Raw variations * @return array Cleaned variations */ private function clean_variations($variations) { $cleaned = array(); foreach ($variations as $variation) { // Trim whitespace $variation = trim($variation); // Skip empty if (empty($variation)) { continue; } // Normalize spaces $variation = preg_replace('/\s+/', ' ', $variation); // Add to cleaned array (using lowercase as key for deduplication) $key = strtolower($variation); if (!isset($cleaned[$key])) { $cleaned[$key] = $variation; } } // Return values only (not keys) return array_values($cleaned); } // ⚠ This class exposes no configuration accessor and no writer: // answerrails_variation_config is a read-only hidden override, and // $this->config is read directly by its owner (#139). }