import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import apiFetch from '@wordpress/api-fetch'; import { Callout } from '../components/ui/callout'; import { Input } from '../components/ui/input'; import { Label } from '../components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '../components/ui/select'; import { Tabs, TabsContent, TabsList, TabsTrigger, } from '../components/ui/tabs'; import { classNames } from '../lib/utils'; import { X_HANDLE_ERROR, xHandle } from '../lib/seo/x-handle'; import TagListField, { type TagChipExtras, } from '../components/ui/tag-list-field'; import { useTagList, type TagList } from '../lib/hooks/use-tag-list'; import { KEYWORD_TESTS, analyze, scoreTone, type Analysis, type AnalysisResult, } from './analysis/analyzer'; import { AnalysisList, type TestAction } from './components/AnalysisList'; import { withToc } from './lib/blocks'; import { CheckboxField, FieldHeader, ImageField, TemplateField, TextField, ToggleField, } from './components/fields'; import { FaqGenerator } from './components/FaqGenerator'; import { LinkSuggestions } from './components/LinkSuggestions'; import { PostRedirect } from './components/PostRedirect'; import { KeywordIdeas } from './components/KeywordIdeas'; import { PostFixDialog, type PostFixValues } from './components/PostFix'; import { ProductFixButton, ProductFixDialog, type ProductFixResult, type ProductFixTag, } from './components/ProductFix'; import { isToDo, isToFix } from './components/result-tone'; import { ScoreBadge, TONE_CLASSES } from './components/ScoreBadge'; import { SnippetPreview } from './components/SnippetPreview'; import { Toast } from './components/Toast'; import type { SeoEditorEnv } from './env'; import { useAttachmentUrl, type EditedPost, type SeoMeta, } from './hooks/use-seo-meta'; import { useAiGate } from './hooks/use-ai-gate'; import { useAnalysisInput } from './hooks/use-analysis-input'; import { isValidGtin } from './lib/gtin'; import { KEYWORDS_MAX, leadKeyword, parseKeywords, promoteKeyword, } from './lib/keywords'; import { asList, asNumber, asRecord, asString } from './lib/meta-values'; import { renderTemplate } from './lib/render-template'; import { SERP_FONTS, SERP_WIDTHS } from './lib/text-width'; import { SchemaTab } from './schema/SchemaTab'; /** The share image a large card wants at least. */ const LARGE_CARD = { width: 1200, height: 630 }; /** The badges the shared image can carry, the empty one meaning the default. */ const OVERLAY_ITEMS = [ { value: '', label: 'Follow Titles & meta' }, { value: 'none', label: 'No badge' }, { value: 'play', label: 'Play button' }, { value: 'gif', label: 'GIF label' }, ]; /** What a fix wrote on the post, for the editor to show without a reload. */ export interface AppliedPost { title?: string; content?: string; excerpt?: string; slug?: string; tags?: ProductFixTag[]; } /** Where the panel reads and writes: the block editor's entity, or a classic form. */ export interface PanelData { meta: SeoMeta; update: (changes: SeoMeta) => void; post: EditedPost; /** Puts what a fix wrote into the editor's own fields, so a later save keeps it. */ applyPost?: (changes: AppliedPost) => void; /** * Keeps the score the analysis gives. The block editor writes it only * into a post that already has changes, outside the undo history, so * opening the panel never marks a post as edited; without it the score * goes through update like any field. */ storeScore?: (score: number | null) => void; } const ROBOTS_COPY: Record = { noindex: { label: 'No index', tip: 'Keep this page out of search results. It stays online and linkable.', }, nofollow: { label: 'No follow', tip: 'Ask search engines not to follow the links on this page.', }, noarchive: { label: 'No archive', tip: 'No cached copy of this page in the results.', }, nosnippet: { label: 'No snippet', tip: 'No text or video preview under the result.', }, noimageindex: { label: 'No image index', tip: 'Images on this page are not indexed on their own.', }, }; const CONDITIONS = [ { value: 'new', label: 'New' }, { value: 'refurbished', label: 'Refurbished' }, { value: 'used', label: 'Used' }, ]; const AVAILABILITIES = [ { value: 'stock', label: 'From the stock status' }, { value: 'instock', label: 'In stock' }, { value: 'outofstock', label: 'Out of stock' }, { value: 'preorder', label: 'Pre-order' }, { value: 'backorder', label: 'On backorder' }, ]; const DESCRIPTION_TIPS = { post: 'The snippet under the title in search results. Empty, the excerpt or the first sentences of the content are used.', term: 'The snippet under the title in search results. Empty, the term description is used.', user: 'The snippet under the title in search results. Empty, the template from Titles & meta applies.', }; /** * Keeps the keyword chips and the comma-separated meta field in step: the * chips follow the stored value, and only what the user adds or removes is * written back, so a saved value never bounces between the two. */ /** Tests the snippet fixes: the title, the description, the address. */ const SNIPPET_TESTS = new Set([ 'keywordInTitle', 'keywordInMetaDescription', 'keywordInPermalink', 'lengthPermalink', 'titleStartWithKeyword', ]); /** Tests the content rewrite fixes. */ const CONTENT_TESTS = new Set([ 'keywordIn10Percent', 'keywordInContent', 'lengthContent', 'keywordInSubheadings', 'keywordInImageAlt', 'keywordDensity', 'contentHasShortParagraphs', 'contentHasAssets', 'aeoAnswerFirst', 'aeoHeadings', 'aeoKeyTakeaways', 'aeoTable', 'aeoNoDashes', 'aeoNoBannedPhrases', 'aeoNoPricesOrStock', ]); const BLOCK_MARKUP = /