import { __ } from '@wordpress/i18n'; import apiFetch from '@wordpress/api-fetch'; import { Button, Notice, Popover, Spinner } from '@wordpress/components'; import { useSelect } from '@wordpress/data'; import { useCallback, useEffect, useMemo, useRef, useState } from '@wordpress/element'; import { getEditorConfig } from '../config'; import { measureSerpDescription, measureSerpTitle } from '../utils/textMetrics'; import usePostDataField from '../hooks/usePostDataField'; import type { ScoreApiConfig, ScoreResponse } from '../types'; import { buildScoreRuleGuide } from '../../shared/scoreRuleGuide'; import { clearScoreCache, loadScoreCache, saveScoreCache } from '../../shared/scoreCache'; type ScoreState = | { status: 'idle' | 'loading'; data: null; error: null } | { status: 'ready'; data: ScoreResponse; error: null } | { status: 'error'; data: null; error: string }; type RuleResult = { id: string; label: string; score: number; weight: number; status: string; value?: unknown; }; const fetchScore = async ( api: ScoreApiConfig, postId: number, metaTitlePx: number, metaDescriptionPx: number, ): Promise< ScoreResponse > => { const query = new URLSearchParams( { post: String( postId ), meta_title_length_px: String( Math.max( 0, Math.round( metaTitlePx ) ) ), meta_description_length_px: String( Math.max( 0, Math.round( metaDescriptionPx ) ) ), } ); return apiFetch< ScoreResponse >( { url: `${ api.root }?${ query.toString() }`, method: api.method ?? 'GET', headers: { 'X-WP-Nonce': api.nonce, }, } ); }; const truncate = ( value: number | string ): number => { const numeric = typeof value === 'string' ? parseFloat( value ) : value; return Math.floor( numeric ); }; const clampScore = ( score: number ): number => { if ( ! Number.isFinite( score ) ) { return 0; } return Math.max( 0, Math.min( 100, score ) ); }; const scoreTone = ( score: number ): { background: string; border: string; text: string } => { if ( ! Number.isFinite( score ) || score < 60 ) { return { background: '#feefed', border: '#f35d4a', text: '#f35d4a' }; } if ( score < 80 ) { return { background: '#fef6eb', border: '#f8a738', text: '#f8a738' }; } return { background: '#eefaf1', border: '#51c975', text: '#51c975' }; }; const normalizeRules = ( rules: unknown ): RuleResult[] => { if ( ! Array.isArray( rules ) ) { return []; } return rules .map( ( rule ) => ( rule && typeof rule === 'object' ? ( rule as Record< string, unknown > ) : null ) ) .filter( ( rule ): rule is Record< string, unknown > => !! rule && 'id' in rule && 'label' in rule ) .map( ( rule ) => ( { id: String( rule.id ?? '' ), label: String( rule.label ?? '' ), score: Number( rule.score ?? 0 ), weight: Number( rule.weight ?? 0 ), status: String( rule.status ?? '' ), value: rule.value, } ) ) .filter( ( rule ) => rule.status !== 'na' ); }; const ScorePanel = () => { const apiConfig = getEditorConfig().scoreApi; const [ metaTitle ] = usePostDataField( 'title' ); const [ metaDescription ] = usePostDataField( 'description' ); const postId = useSelect( ( select ) => { const editor = select( 'core/editor' ) as { getCurrentPostId?: () => number | undefined }; return editor.getCurrentPostId ? editor.getCurrentPostId() : undefined; }, [], ); const postExcerpt = useSelect( ( select ) => { const editor = select( 'core/editor' ) as { getEditedPostAttribute?: ( key: string ) => unknown; }; return ( editor.getEditedPostAttribute?.( 'excerpt' ) as string ) || ''; }, [], ); const postTitle = useSelect( ( select ) => { const editor = select( 'core/editor' ) as { getEditedPostAttribute?: ( key: string ) => unknown; }; return ( editor.getEditedPostAttribute?.( 'title' ) as string ) || ''; }, [], ); const saveState = useSelect( ( select ) => { const editor = select( 'core/editor' ) as { isSavingPost?: () => boolean; isAutosavingPost?: () => boolean; didPostSaveRequestSucceed?: () => boolean; }; return { isSaving: editor.isSavingPost ? editor.isSavingPost() : false, isAutosaving: editor.isAutosavingPost ? editor.isAutosavingPost() : false, didSaveSucceed: editor.didPostSaveRequestSucceed ? editor.didPostSaveRequestSucceed() : false, }; }, [], ); const [ state, setState ] = useState< ScoreState >( { status: 'idle', data: null, error: null, } ); const [ openRuleId, setOpenRuleId ] = useState< string | null >( null ); const [ suggestionsExpanded, setSuggestionsExpanded ] = useState( true ); const [ showTips, setShowTips ] = useState( false ); const prevSaveRef = useRef( false ); const prevDidSaveRef = useRef( false ); const hasInitializedSaveRefs = useRef( false ); const currentBlogId = getEditorConfig().currentBlogId ?? 1; const requestScore = useCallback( async ( options?: { forceRefresh?: boolean } ) => { if ( ! apiConfig?.root || ! postId ) { return; } if ( options?.forceRefresh ) { clearScoreCache( postId, currentBlogId ); } const descriptionText = metaDescription?.trim() ? metaDescription : postExcerpt; const titleText = metaTitle?.trim() ? metaTitle : postTitle; const titlePx = measureSerpTitle( titleText ?? '' ); const descriptionPx = measureSerpDescription( descriptionText ?? '' ); setState( { status: 'loading', data: null, error: null } ); try { const data = await fetchScore( apiConfig, postId, titlePx, descriptionPx ); saveScoreCache( postId, data, currentBlogId ); setState( { status: 'ready', data, error: null } ); } catch ( error ) { const message = error && typeof error === 'object' && 'message' in error ? String( ( error as Error ).message ) : __( 'Unable to fetch SEO score.', 'airygen-seo' ); setState( { status: 'error', data: null, error: message } ); } }, [ apiConfig, postId, metaTitle, postTitle, metaDescription, postExcerpt, currentBlogId ] ); useEffect( () => { if ( ! postId || ! apiConfig?.root ) { return; } const cached = loadScoreCache< ScoreResponse >( postId, currentBlogId ); if ( cached ) { setState( { status: 'ready', data: cached, error: null } ); return; } const persisted = getEditorConfig().scoreCalculator?.scoreCache; if ( persisted && persisted.post_id === postId ) { setState( { status: 'ready', data: persisted, error: null } ); return; } void requestScore(); }, [ postId, apiConfig, currentBlogId, requestScore ] ); useEffect( () => { if ( ! postId || ! apiConfig?.root ) { return; } if ( ! hasInitializedSaveRefs.current ) { prevSaveRef.current = saveState.isSaving && ! saveState.isAutosaving; prevDidSaveRef.current = saveState.didSaveSucceed; hasInitializedSaveRefs.current = true; return; } const wasSaving = prevSaveRef.current; const isSavingNow = saveState.isSaving && ! saveState.isAutosaving; if ( wasSaving && ! isSavingNow ) { void requestScore(); } prevSaveRef.current = isSavingNow; }, [ saveState.isSaving, saveState.isAutosaving, saveState.didSaveSucceed, postId, apiConfig, requestScore ] ); useEffect( () => { if ( ! postId || ! apiConfig?.root ) { return; } if ( ! hasInitializedSaveRefs.current ) { prevDidSaveRef.current = saveState.didSaveSucceed; return; } if ( saveState.didSaveSucceed && ! prevDidSaveRef.current ) { void requestScore(); } prevDidSaveRef.current = saveState.didSaveSucceed; }, [ saveState.didSaveSucceed, postId, apiConfig, requestScore ] ); const totalScoreValue = state.data ? Number( state.data.total.score ) : Number.NaN; const totalScore = state.data ? truncate( state.data.total.score ) : '--'; const totalMaxValue = state.data ? Number( state.data.total.max ) : Number.NaN; const tone = scoreTone( totalScoreValue ); const viewWidth = 233; const viewHeight = 64; const strokeWidth = 4; const rectWidth = viewWidth - strokeWidth; const rectHeight = viewHeight - strokeWidth; const perimeter = 2 * ( rectWidth + rectHeight ); const startOffset = ( rectWidth / 2 / perimeter ) * 100; const progress = clampScore( Number.isFinite( totalScoreValue ) && Number.isFinite( totalMaxValue ) && totalMaxValue > 0 ? Math.round( ( totalScoreValue / totalMaxValue ) * 100 ) : 0, ); const rules = useMemo( () => { if ( ! state.data ) { return []; } const baseRules = normalizeRules( state.data.base?.rules ).map( ( rule, index ) => ( { ...rule, _sortIndex: index, } ) ); const statusOrder: Record = { fail: 0, warn: 1, pass: 2, }; return baseRules .slice() .sort( ( a, b ) => { const orderA = statusOrder[ a.status ] ?? 1; const orderB = statusOrder[ b.status ] ?? 1; if ( orderA !== orderB ) { return orderA - orderB; } return a._sortIndex - b._sortIndex; } ) .map( ( rule ) => { const { _sortIndex, ...rest } = rule; return rest; } ); }, [ state.data ] ); const failingCount = useMemo( () => rules.filter( ( rule ) => rule.status && rule.status !== 'pass' ).length, [ rules ], ); const OverallIcon = () => ( ); const RecalculateIcon = () => (