PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.0
2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 All 49 releases
← All changes | includes/ai/class-metadata-generator.php +73 -17 1.0.22.7.0 View file →
@@ -49,9 +49,9 @@
49 49 * @param Settings|null $settings Settings instance
50 50 */
51 51 public function __construct(?Manager $ai_manager = null, ?Settings $settings = null) {
52 52 $this->ai_manager = $ai_manager ?? new Manager();
53 - $this->settings = $settings ?? new Settings();
53 + $this->settings = $settings ?? Settings::instance();
54 54 }
55 55
56 56 /**
57 57 * Generate metadata for post content
@@ -202,11 +202,12 @@
202 202 // Trim and limit length for AI processing
203 203 $content = trim($content);
204 204 $max_length = 4000; // Reasonable limit for AI processing
205 205
206 - if (strlen($content) > $max_length) {
207 - $content = substr($content, 0, $max_length) . '...';
208 - }
206 + // strlen()/substr() count BYTES: on Thai or CJK this handed the model a
207 + // third of the intended content, and cut the last character in half so
208 + // the payload carried an invalid UTF-8 sequence (#687).
209 + $content = \ThinkRank\Core\Seo_Text::trim_to_length($content, $max_length);
209 210
210 211 return $content;
211 212 }
212 213
@@ -252,31 +253,64 @@
252 253 * @param array $options Generation options
253 254 * @return array Enhanced metadata
254 255 */
255 256 private function validate_and_enhance_metadata(array $metadata, array $options): array {
257 + $max_title_length = max(1, (int) $options['max_title_length']);
258 + $max_description_length = max(1, (int) $options['max_description_length']);
259 +
260 + $metadata['title'] = trim((string) ($metadata['title'] ?? ''));
261 + $metadata['description'] = trim((string) ($metadata['description'] ?? ''));
262 +
256 263 // Validate title length
257 - if (strlen($metadata['title']) > $options['max_title_length']) {
258 - $metadata['title'] = substr($metadata['title'], 0, $options['max_title_length'] - 3) . '...';
264 + if (mb_strlen($metadata['title']) > $max_title_length) {
265 + $metadata['title'] = $this->truncate_text($metadata['title'], $max_title_length);
259 266 $metadata['title_truncated'] = true;
260 267 }
261 -
268 +
262 269 // Validate description length
263 - if (strlen($metadata['description']) > $options['max_description_length']) {
264 - $metadata['description'] = substr($metadata['description'], 0, $options['max_description_length'] - 3) . '...';
270 + if (mb_strlen($metadata['description']) > $max_description_length) {
271 + $metadata['description'] = $this->truncate_text($metadata['description'], $max_description_length);
265 272 $metadata['description_truncated'] = true;
266 273 }
267 -
274 +
268 275 // Add character counts
269 - $metadata['title_length'] = strlen($metadata['title']);
270 - $metadata['description_length'] = strlen($metadata['description']);
271 -
276 + $metadata['title_length'] = mb_strlen($metadata['title']);
277 + $metadata['description_length'] = mb_strlen($metadata['description']);
278 +
272 279 // Calculate optimization score
273 280 $metadata['optimization_score'] = $this->calculate_optimization_score($metadata, $options);
274 -
281 +
275 282 return $metadata;
276 283 }
277 -
284 +
278 285 /**
286 + * Truncate text to a hard character limit, preferring a word boundary
287 + *
288 + * The ellipsis counts towards the limit, so the result is never longer
289 + * than $max_length characters.
290 + *
291 + * @param string $text Text to truncate
292 + * @param int $max_length Maximum length in characters
293 + * @return string Truncated text
294 + */
295 + private function truncate_text(string $text, int $max_length): string {
296 + if (mb_strlen($text) <= $max_length) {
297 + return $text;
298 + }
299 +
300 + // Reserve one character for the ellipsis.
301 + $truncated = mb_substr($text, 0, $max_length - 1);
302 +
303 + // Cut back to the last word boundary, unless that throws away too much.
304 + $last_space = mb_strrpos($truncated, ' ');
305 + if ($last_space !== false && $last_space > (int) ($max_length * 0.6)) {
306 + $truncated = mb_substr($truncated, 0, $last_space);
307 + }
308 +
309 + return rtrim($truncated, " \t\n\r\0\x0B,;:-") . '…';
310 + }
311 +
312 + /**
279 313 * Save metadata to post meta
280 314 *
281 315 * @param int $post_id Post ID
282 316 * @param array $metadata Metadata to save
@@ -284,9 +318,13 @@
284 318 */
285 319 private function save_post_metadata(int $post_id, array $metadata): void {
286 320 update_post_meta($post_id, '_thinkrank_ai_title', $metadata['title']);
287 321 update_post_meta($post_id, '_thinkrank_ai_description', $metadata['description']);
288 - update_post_meta($post_id, '_thinkrank_focus_keyword', $metadata['focus_keyword']);
322 + // Persist via Focus_Keywords so the array + legacy meta stay in sync.
323 + // Existing keywords are preserved (the AI value seeds only when unset).
324 + if (empty(\ThinkRank\SEO\Focus_Keywords::get($post_id))) {
325 + \ThinkRank\SEO\Focus_Keywords::save($post_id, $metadata['focus_keyword']);
326 + }
289 327 update_post_meta($post_id, '_thinkrank_generated_at', $metadata['generated_at']);
290 328 update_post_meta($post_id, '_thinkrank_metadata_full', $metadata);
291 329 }
292 330
@@ -321,9 +359,27 @@
321 359 $words = str_word_count($text, 1);
322 360 $syllables = 0;
323 361
324 362 foreach ($words as $word) {
325 - $syllables += max(1, preg_match_all('/[aeiouy]+/i', $word));
363 + $word = preg_replace('/[^a-z]/', '', strtolower($word));
364 + if ($word === '') {
365 + continue;
366 + }
367 +
368 + $groups = preg_match_all('/[aeiouy]+/', $word);
369 +
370 + // Standard Flesch heuristic: a trailing silent e does not form a
371 + // syllable ("make", "time", "these") — but only when a consonant
372 + // precedes it (a vowel+e ending like "movie" already shares its
373 + // group) and never for consonant-le ("table"), which does count.
374 + // Without this the counter inflated syllables/word by ~0.2-0.3 on
375 + // ordinary prose, driving raw Flesch negative and the UI to a
376 + // clamped "Very Difficult (0)" (#407).
377 + if ($groups > 1 && preg_match('/[^aeiouy]e$/', $word) && !str_ends_with($word, 'le')) {
378 + $groups--;
379 + }
380 +
381 + $syllables += max(1, $groups);
326 382 }
327 383
328 384 return $syllables;
329 385 }