30% of its word characters are Arabic codepoints.
*/
private static function is_arabic( $text ) {
preg_match_all( '/\p{L}/u', $text, $all );
$total = count( $all[0] );
if ( $total === 0 ) return false;
preg_match_all( self::ARABIC_REGEX, $text, $ar );
return ( count( $ar[0] ) / $total ) > 0.30;
}
/**
* Unify Arabic letter forms that are orthographically distinct but
* treated as interchangeable by real searchers and writers: hamza-seated
* alef forms all collapse to bare alef, alef maqsura collapses to yaa,
* and taa marbuta collapses to haa. Diacritics and the tatweel
* elongation character are stripped entirely. Non-Arabic characters
* pass through untouched, so this is safe to run on any string.
*
* Without this, a focus keyword spelled "مدرسة" would not match content
* spelled "مدرسه" (or vice versa) even though any Arabic reader treats
* them as the same word - this was the plugin's biggest Arabic
* keyword-matching gap.
*/
private static function normalize_arabic( $text ) {
if ( '' === $text ) return $text;
$text = preg_replace( self::ARABIC_DIACRITICS_REGEX, '', $text );
$text = str_replace( [ 'أ', 'إ', 'آ', 'ٱ' ], 'ا', $text );
$text = str_replace( 'ى', 'ي', $text );
$text = str_replace( 'ة', 'ه', $text );
return $text;
}
/**
* The single function every keyword-vs-content comparison in analyze()
* should go through: lowercases (a no-op for Arabic, correct for
* Latin/mixed strings) and normalizes Arabic letter forms. Safe to call
* on any string regardless of language.
*/
private static function normalize_for_compare( $text ) {
return self::normalize_arabic( mb_strtolower( (string) $text ) );
}
/**
* Light Arabic stemmer (Larkey-style): strips one leading proclitic
* (و/ف/ب/ك/ل, the definite article ال, and its fused forms like وال/بال)
* and one trailing suffix (pronoun, plural, or feminine ending) so
* inflected forms of the same word - "المدرستين", "مدرستها", "مدارسنا" -
* reduce to a comparable stem. Deliberately shallow: it trades some
* linguistic precision for match recall, and callers only use it as a
* fallback once an exact (normalized) match has already failed, never
* to override a real match. Words under 4 characters are returned
* unchanged - too short to safely strip anything from.
*/
private static function light_stem_ar( $word ) {
$word = trim( (string) $word );
if ( mb_strlen( $word ) < 4 ) return $word;
$prefixes = [ 'وال', 'فال', 'بال', 'كال', 'ولل', 'فلل', 'ال', 'و', 'ف', 'ب', 'ك', 'ل' ];
foreach ( $prefixes as $p ) {
$plen = mb_strlen( $p );
if ( mb_substr( $word, 0, $plen ) === $p && ( mb_strlen( $word ) - $plen ) >= 3 ) {
$word = mb_substr( $word, $plen );
break;
}
}
$suffixes = [ 'هما', 'كما', 'تين', 'يه', 'ات', 'ون', 'ين', 'ان', 'ها', 'هم', 'هن', 'كم', 'كن', 'نا' ];
foreach ( $suffixes as $s ) {
$slen = mb_strlen( $s );
if ( mb_substr( $word, -$slen ) === $s && ( mb_strlen( $word ) - $slen ) >= 3 ) {
$word = mb_substr( $word, 0, mb_strlen( $word ) - $slen );
break;
}
}
return $word;
}
/**
* light_stem_ar() alone under-matches one very common pattern: a
* feminine noun's taa marbuta normalizes to haa when the word appears
* bare ("مدرسة" -> "مدرسه"), but standard Arabic orthography swaps it
* for a plain taa when a pronoun suffix is attached before that suffix
* is stripped ("مدرسة" + "ها" is written "مدرستها", never "مدرسةها").
* A single stem comparison misses that pair entirely. This returns the
* stem plus, where relevant, one extra candidate covering that
* alternation, so callers can match if *any* candidate coincides.
*/
private static function ar_stem_candidates( $word ) {
$stem = self::light_stem_ar( $word );
$candidates = [ $stem ];
if ( mb_substr( $stem, -1 ) === 'ه' && mb_strlen( $stem ) >= 3 ) {
$candidates[] = mb_substr( $stem, 0, mb_strlen( $stem ) - 1 );
}
if ( mb_substr( $stem, -1 ) === 'ت' && mb_strlen( $stem ) >= 3 ) {
$candidates[] = mb_substr( $stem, 0, mb_strlen( $stem ) - 1 ) . 'ه';
}
return $candidates;
}
/**
* Count words in a string - works for both English and Arabic.
*
* PHP's str_word_count() uses locale-aware byte sequences and returns 0
* for Arabic text. Instead we split on whitespace/punctuation and count
* non-empty tokens, which is universally correct.
*/
/**
* Unicode-aware word count (Arabic, Latin, etc.) - deliberately public
* so other classes (e.g. the Pro AI Answer Simulator) don't reach for
* PHP's str_word_count(), which only recognizes ASCII letters and
* silently returns 0 (or near it) for Arabic content.
*/
public static function count_words( $text ) {
// Match sequences of Unicode letters/digits (handles Arabic, Latin, etc.)
preg_match_all( '/[\p{L}\p{N}]+/u', $text, $m );
return count( $m[0] );
}
/**
* Count occurrences of $keyword in $content for Arabic morphology.
*
* For Arabic, exact-phrase matching misses inflected/root forms, so we
* also count word-by-word: a match is counted if every word of the keyword
* appears within a 10-word window in the content. For English or short
* single-word keywords the simple substr_count() path is used.
*/
private static function count_keyword_occurrences( $content_lower, $kw_lower, $is_arabic ) {
$exact = substr_count( $content_lower, $kw_lower );
if ( ! $is_arabic || $exact > 0 ) {
return $exact;
}
// Arabic morphological approximation: tokenise and look for all
// keyword words appearing in a sliding window.
$kw_words = preg_split( '/\s+/u', trim( $kw_lower ), -1, PREG_SPLIT_NO_EMPTY );
$content_words = preg_split( '/\s+/u', trim( $content_lower ), -1, PREG_SPLIT_NO_EMPTY );
if ( count( $kw_words ) < 2 ) {
// Single-word keyword: count content words whose stem
// candidates overlap the keyword's stem candidates.
$root_candidates = self::ar_stem_candidates( $kw_words[0] );
$count = 0;
foreach ( $content_words as $w ) {
if ( array_intersect( self::ar_stem_candidates( $w ), $root_candidates ) ) $count++;
}
return $count;
}
// Multi-word keyword: sliding window
$window = count( $kw_words ) + 4;
$count = 0;
$n = count( $content_words );
for ( $i = 0; $i <= $n - count( $kw_words ); $i++ ) {
$slice = array_slice( $content_words, $i, min( $window, $n - $i ) );
$all_found = true;
foreach ( $kw_words as $kw ) {
$root_candidates = self::ar_stem_candidates( $kw );
$found = false;
foreach ( $slice as $w ) {
if ( array_intersect( self::ar_stem_candidates( $w ), $root_candidates ) ) { $found = true; break; }
}
if ( ! $found ) { $all_found = false; break; }
}
if ( $all_found ) { $count++; $i += count( $kw_words ) - 1; }
}
return $count;
}
/**
* True if $haystack_lower contains the primary focus keyword or, when
* set, its Arabizi/transliterated variant. Lets code-switched content -
* an Arabic dialect written in Latin letters, or an article that mixes
* Arabic script with an English/Latin rendering of the same term - pass
* the same keyword checks a single-script page would, instead of only
* ever checking the Arabic-script form.
*/
private static function matches_kw( $haystack_lower, $kw_lower, $arabizi_lower ) {
if ( false !== mb_strpos( $haystack_lower, $kw_lower ) ) {
return true;
}
return '' !== $arabizi_lower && false !== mb_strpos( $haystack_lower, $arabizi_lower );
}
/**
* @param int $post_id
* @param array $overrides Optional live values from the React meta box:
* [ 'focus_keyword' => '', 'title' => '', 'description' => '' ]
* Any key present here takes precedence over the DB value.
*/
public static function analyze( $post_id, array $overrides = [] ) {
$post = get_post( $post_id );
$keyword = isset( $overrides['focus_keyword'] ) ? $overrides['focus_keyword'] : AWBSEO_Meta_Manager::get( $post_id, 'focus_keyword' );
// Arabizi/transliterated variant of the focus keyword, captured via the
// bolt-on field next to it in the meta box - optional, only ever used
// as an alternative match alongside $keyword, never in place of it.
$arabizi = isset( $overrides['focus_keyword_arabizi'] ) ? $overrides['focus_keyword_arabizi'] : AWBSEO_Meta_Manager::get( $post_id, 'focus_keyword_arabizi' );
$title = ( isset( $overrides['title'] ) && $overrides['title'] !== '' ) ? $overrides['title'] : AWBSEO_Meta_Manager::get_title( $post_id );
$desc = ( isset( $overrides['description'] ) && $overrides['description'] !== '' ) ? $overrides['description'] : AWBSEO_Meta_Manager::get_description( $post_id );
// Rendered HTML (shortcodes/blocks processed) - the structural checks
// below (images, alt text, links, headings) scan this instead of raw
// post_content, so page builders that store shortcodes rather than
// literal
// tags (WPBakery, Elementor in shortcode mode,
// etc.) are still detected correctly. get_rendered_content() also
// strips any shortcode tag the builder plugin couldn't expand (e.g.
// if it's deactivated), so leftover bracket syntax/base64 payloads
// never get scanned as if they were real content.
$rendered = AWBSEO_Core::get_rendered_content( $post );
$content = wp_strip_all_tags( $rendered );
// Fallback 1: extract text directly from raw post_content shortcodes
// (WPBakery stores content as shortcodes; callbacks don't fire in admin).
if ( $post
&& ! empty( $post->post_content )
&& self::count_words( $content ) < 100 ) {
$raw_text = AWBSEO_Core::extract_raw_text( $post->post_content );
if ( self::count_words( $raw_text ) > self::count_words( $content ) ) {
$content = $raw_text;
}
}
// Fallback 2: HTTP fetch of the public URL.
// When page builder callbacks produce empty HTML in admin AJAX context
// (WPBakery, Elementor, Divi, etc.) and the raw shortcode extraction
// also fails, fetching the rendered frontend page is the only reliable
// way to get the actual word count. This is a manual trigger (user
// clicks Analyze), so the extra round-trip is acceptable.
if ( $post && self::count_words( $content ) < 100 ) {
$permalink = get_permalink( $post->ID );
if ( $permalink ) {
$http = wp_remote_get( $permalink, [
'timeout' => 5,
'sslverify' => false,
] );
if ( ! is_wp_error( $http )
&& 200 === (int) wp_remote_retrieve_response_code( $http ) ) {
$fetched = wp_strip_all_tags( wp_remote_retrieve_body( $http ) );
if ( self::count_words( $fetched ) > self::count_words( $content ) ) {
$content = $fetched;
}
}
}
}
$content = apply_filters( 'awbseo_analyzed_content', $content, $post_id );
$url = get_permalink( $post_id );
$slug = $post->post_name;
$checks = [];
// 1. Focus keyword set.
$checks[] = $keyword
? self::pass( 'focus_keyword', 'Focus keyword is set.' )
: self::fail( 'focus_keyword', 'No focus keyword set. Add a focus keyword so AWBSEO can check your content.' );
// Detect content language once - reused by density and word-count checks.
$is_arabic = self::is_arabic( $content );
if ( $keyword ) {
$kw_lower = self::normalize_for_compare( $keyword );
$content_lower = self::normalize_for_compare( $content );
$title_lower = self::normalize_for_compare( $title );
$desc_lower = self::normalize_for_compare( $desc );
$slug_lower = mb_strtolower( $slug ); // slugs are handled separately via sanitize_title() below
$arabizi_lower = $arabizi ? self::normalize_for_compare( $arabizi ) : '';
// 2. Keyword in title.
$checks[] = self::matches_kw( $title_lower, $kw_lower, $arabizi_lower )
? self::pass( 'kw_in_title', 'Focus keyword found in SEO title.' )
: self::fail( 'kw_in_title', "Your focus keyword '{$keyword}' is not in the SEO title. Add it - ideally near the beginning." );
// 3. Keyword in meta description.
$checks[] = self::matches_kw( $desc_lower, $kw_lower, $arabizi_lower )
? self::pass( 'kw_in_desc', 'Focus keyword found in meta description.' )
: self::warn( 'kw_in_desc', "Your focus keyword '{$keyword}' is missing from the meta description. Include it naturally." );
// 4. Keyword in URL slug.
// Slugs are hyphenated ("growth-foundation"), but keywords are
// plain text with spaces ("growth foundation") - a literal
// substring match against $slug_lower never matches, so we
// normalize the keyword the same way WordPress builds the slug
// before comparing.
$kw_slug = sanitize_title( $keyword );
$kw_slug_arabizi = $arabizi ? sanitize_title( $arabizi ) : '';
$slug_check = ( '' !== $kw_slug && false !== strpos( $slug_lower, $kw_slug ) )
|| ( '' !== $kw_slug_arabizi && false !== strpos( $slug_lower, $kw_slug_arabizi ) );
// Prefer the Arabizi form in the suggestion when one is set - it's
// plain ASCII and won't come back as a percent-encoded slug the
// way a raw Arabic keyword does.
$slug_suggestion = $kw_slug_arabizi ?: urldecode( $kw_slug );
$checks[] = $slug_check
? self::pass( 'kw_in_slug', 'Focus keyword found in the URL slug.' )
: self::warn( 'kw_in_slug', "The URL slug doesn't contain your focus keyword. Consider using a slug like '/" . $slug_suggestion . "'." ); // $kw_slug itself (used in $slug_check above) is untouched, so the original pass/fail comparison is unchanged when no Arabizi variant is set.
// 5. Keyword density (0.5% – 3%).
// Use Unicode-safe word counter so Arabic text is counted correctly.
$word_count = self::count_words( $content );
$kw_count = $word_count > 0
? self::count_keyword_occurrences( $content_lower, $kw_lower, $is_arabic )
: 0;
// Code-switched content often repeats the keyword in its Arabizi
// form rather than (or alongside) Arabic script - count both so
// density reflects total keyword usage, not only Arabic-script hits.
if ( $word_count > 0 && '' !== $arabizi_lower ) {
$kw_count += self::count_keyword_occurrences( $content_lower, $arabizi_lower, false );
}
$density = $word_count > 0 ? round( ( $kw_count / $word_count ) * 100, 2 ) : 0;
if ( $density >= 0.5 && $density <= 3 ) {
$checks[] = self::pass( 'kw_density', "Keyword density is {$density}% - good." );
} elseif ( $density < 0.5 ) {
$checks[] = self::warn( 'kw_density', "Keyword density is {$density}% (too low). Use your focus keyword more naturally in the content." );
} else {
$checks[] = self::warn( 'kw_density', "Keyword density is {$density}% (too high - may look spammy). Reduce keyword repetition." );
}
// 6. Keyword in first paragraph.
$paragraphs = preg_split( '/\n\n+/', $content );
$first_para = self::normalize_for_compare( $paragraphs[0] ?? '' );
$checks[] = self::matches_kw( $first_para, $kw_lower, $arabizi_lower )
? self::pass( 'kw_in_intro', 'Focus keyword appears in the first paragraph.' )
: self::warn( 'kw_in_intro', 'Move your focus keyword to the first paragraph - Google weighs early content more heavily.' );
// 7. Keyword in at least one H2/H3.
preg_match_all( '/]*>(.*?)<\/h[23]>/is', $rendered, $headings );
$heading_text = self::normalize_for_compare( implode( ' ', $headings[1] ?? [] ) );
$checks[] = self::matches_kw( $heading_text, $kw_lower, $arabizi_lower )
? self::pass( 'kw_in_headings', 'Focus keyword found in a subheading (H2/H3).' )
: self::warn( 'kw_in_headings', 'Add your focus keyword to at least one H2 or H3 subheading.' );
}
// 8. SEO title length (50–60 chars).
$title_len = mb_strlen( $title );
if ( $title_len >= 50 && $title_len <= 60 ) {
$checks[] = self::pass( 'title_length', "SEO title is {$title_len} chars - ideal." );
} elseif ( $title_len < 50 ) {
$checks[] = self::warn( 'title_length', "SEO title is {$title_len} chars (too short). Aim for 50–60 to use the full SERP display." );
} else {
$checks[] = self::warn( 'title_length', "SEO title is {$title_len} chars (too long). Google will truncate above ~60 chars." );
}
// 9. Meta description length (120–160 chars).
$desc_len = mb_strlen( $desc );
if ( $desc_len >= 120 && $desc_len <= 160 ) {
$checks[] = self::pass( 'desc_length', "Meta description is {$desc_len} chars - ideal." );
} elseif ( $desc_len === 0 ) {
$checks[] = self::fail( 'desc_length', 'No meta description set. Write one - it appears as the snippet in search results.' );
} elseif ( $desc_len < 120 ) {
$checks[] = self::warn( 'desc_length', "Meta description is {$desc_len} chars (too short). Expand to 120–160 chars." );
} else {
$checks[] = self::warn( 'desc_length', "Meta description is {$desc_len} chars (too long - may be cut off). Keep it under 160." );
}
// 10. Content length (>300 words for basic, >1000 for good).
// Unicode-safe count handles Arabic and other non-Latin scripts.
$wc = self::count_words( $content );
if ( $wc >= 1000 ) {
$checks[] = self::pass( 'content_length', "Content is {$wc} words - substantial." );
} elseif ( $wc >= 300 ) {
$checks[] = self::warn( 'content_length', "Content is {$wc} words. Google favors in-depth content - aim for 1,000+ words for competitive keywords." );
} else {
$checks[] = self::fail( 'content_length', "Content is only {$wc} words. Thin content ranks poorly. Add more depth (minimum 300 words, ideally 1,000+)." );
}
// 11. Images present.
preg_match_all( '/
]+>/i', $rendered, $imgs );
$img_count = count( $imgs[0] );
$checks[] = $img_count > 0
? self::pass( 'has_images', "Post contains {$img_count} image(s)." )
: self::warn( 'has_images', 'No images found. Adding relevant images improves engagement and can rank in Google Images.' );
// 12. Alt text on images.
if ( $img_count > 0 ) {
preg_match_all( '/
]+alt=["\']([^"\']*)["\'][^>]*>/i', $rendered, $alts );
$no_alt = $img_count - count( array_filter( $alts[1] ) );
$checks[] = $no_alt === 0
? self::pass( 'img_alt', 'All images have alt text.' )
: self::warn( 'img_alt', "{$no_alt} image(s) are missing alt text. Alt text helps accessibility and image SEO." );
}
// 13. Internal links.
preg_match_all( '/]+href=["\']' . preg_quote( home_url(), '/' ) . '[^"\']*["\'][^>]*>/i', $rendered, $int_links );
$int_count = count( $int_links[0] );
$checks[] = $int_count >= 2
? self::pass( 'internal_links', "Post has {$int_count} internal link(s) - good for link equity." )
: self::warn( 'internal_links', "Only {$int_count} internal link(s) found. Add 2–5 links to related content on your site." );
// 14. External links.
preg_match_all( '/]+href=["\']https?:\/\/(?!' . preg_quote( parse_url( home_url(), PHP_URL_HOST ), '/' ) . ')[^"\']+["\'][^>]*>/i', $rendered, $ext_links );
$ext_count = count( $ext_links[0] );
$checks[] = $ext_count > 0
? self::pass( 'external_links', "Post links out to {$ext_count} external source(s) - good for trust signals." )
: self::warn( 'external_links', 'No external links found. Linking to authoritative sources can boost credibility.' );
// 15. Has H1 (via post title - WordPress always outputs the post title as H1 in well-coded themes).
$checks[] = self::pass( 'has_h1', 'Post title is used as the H1 heading.' );
// 16. Permalink readable (no query strings, no numbers-only slugs).
$is_clean = ! preg_match( '/[?&=]/', $url ) && ! preg_match( '/\/\?p=\d+/', $url );
$checks[] = $is_clean
? self::pass( 'clean_url', 'URL is clean and readable.' )
: self::warn( 'clean_url', 'URL contains query strings or looks like a default WordPress permalink. Use readable slugs (Settings → Permalinks).' );
// Score.
$total = count( $checks );
$passed = count( array_filter( $checks, fn( $c ) => $c['status'] === 'pass' ) );
$score = $total > 0 ? round( ( $passed / $total ) * 100 ) : 0;
return [
'score' => $score,
'checks' => $checks,
];
}
private static function pass( $id, $msg ) {
return [ 'id' => $id, 'status' => 'pass', 'message' => $msg ];
}
private static function warn( $id, $msg ) {
return [ 'id' => $id, 'status' => 'warn', 'message' => $msg ];
}
private static function fail( $id, $msg ) {
return [ 'id' => $id, 'status' => 'fail', 'message' => $msg ];
}
}