import { useCallback, useEffect } from 'react'; import apiFetch from '@wordpress/api-fetch'; import { dispatch, select, subscribe } from '@wordpress/data'; import * as editor from '@wordpress/editor'; import { SearchCheckIcon } from 'lucide-react'; import { TooltipProvider } from '../components/ui/tooltip'; import { LiveScore } from './components/LiveScore'; import { PrePublishSummary } from './components/PrePublishSummary'; import { ScoreIcon } from './components/ScoreBadge'; import { seoEditorEnv, type SeoEditorEnv } from './env'; import { useLiveAnalysis } from './hooks/use-live-analysis'; import { useEditedPost, usePostMeta } from './hooks/use-seo-meta'; import { blocksFromHtml } from './lib/blocks'; import { getLiveScore, useLiveScore } from './lib/live-score'; import { asNumber, asString } from './lib/meta-values'; import { Panel, type AppliedPost } from './Panel'; const SIDEBAR = 'recomaze-seo'; type SidebarSlots = Pick< typeof editor, 'PluginSidebar' | 'PluginSidebarMoreMenuItem' | 'PluginPrePublishPanel' >; /** * The editor slots: the editor's package has them from WordPress 6.6, the * post editor's before it (and it is always loaded where this runs). * * @returns {Partial} The slots, when found. */ const sidebarSlots = (): Partial => { const older = ( window as unknown as { wp?: { editPost?: Partial } } ).wp?.editPost; return { PluginSidebar: editor.PluginSidebar ?? older?.PluginSidebar, PluginSidebarMoreMenuItem: editor.PluginSidebarMoreMenuItem ?? older?.PluginSidebarMoreMenuItem, PluginPrePublishPanel: editor.PluginPrePublishPanel ?? older?.PluginPrePublishPanel, }; }; /** * Leave the publish check for the SEO sidebar. */ const openSeoSidebar = (): void => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const store = (name: string): any => dispatch(name); store('core/editor')?.closePublishSidebar?.(); const target = `recomaze-seo/${SIDEBAR}`; const editPost = store('core/edit-post'); if (editPost?.openGeneralSidebar) { editPost.openGeneralSidebar(target); } else { store('core/interface')?.enableComplementaryArea?.('core', target); } }; /** The SEO summary in the publish check, worked out when it opens. */ const PrePublish = ({ env }: { env: SeoEditorEnv }): JSX.Element | null => { const { analysis, keyword } = useLiveAnalysis(env); if (!analysis) { return null; } return (
); }; const pathOf = (url: string): string => { try { return new URL(url).pathname; } catch { return url; } }; /** * Say that a published post's new address left a redirect behind, the way * the classic screens say it after a save, with the way to the redirect. * * @param {string} from - The old address. * @param {string} to - The new address. * @param {string} appUrl - The plugin's admin page. */ const movedNotice = (from: string, to: string, appUrl: string): void => { const old = pathOf(from); // eslint-disable-next-line @typescript-eslint/no-explicit-any (dispatch('core/notices') as any).createNotice( 'info', `SEO: the address changed from ${old} to ${pathOf(to)}, so a 301 redirect now sends visitors and search engines from the old one to the new one.`, { id: 'recomaze-seo-moved', isDismissible: true, // Only for a user the plugin's page lets in. actions: appUrl === '' ? [] : [ { label: 'See the redirect', url: `${appUrl}#/seo/redirects?search=${encodeURIComponent(old.replace(/^\/+|\/+$/g, ''))}`, }, ], } ); }; /** * What follows a save: the score goes to the post through its own request, * and a published post's new address says it left a redirect behind. * Watched from the plugin itself, so the sidebar need not be open: with * the Post panel in front, a changed slug went by without the notice. * * @param {SeoEditorEnv|undefined} env - The editor's environment. */ const useSaveWatcher = (env: SeoEditorEnv | undefined): void => { const autoRedirects = Boolean(env?.autoRedirects); const appUrl = env?.appUrl ?? ''; useEffect(() => { let wasSaving = false; let before: { link: string; status: string } | null = null; return subscribe(() => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const editor = select('core/editor') as any; const saving = editor.isSavingPost() && !editor.isAutosavingPost(); if (saving && !wasSaving) { const saved = editor.getCurrentPost(); before = { link: saved.link ?? '', status: saved.status ?? '' }; } const saved = wasSaving && !saving && editor.didPostSaveRequestSucceed(); // Settled before acting: the notice below changes a store, which runs // this listener again, and a save still unseen there looped until the // stack overflowed. wasSaving = saving; const moved = before; if (!saved) { return; } before = null; const score = getLiveScore(); if (score !== null) { apiFetch({ path: '/recomaze/v1/seo/score', method: 'POST', data: { post_id: editor.getCurrentPostId(), score }, }).catch(() => undefined); } if (moved && autoRedirects) { const after = editor.getCurrentPost(); if ( moved.status === 'publish' && after.status === 'publish' && moved.link !== '' && after.link && moved.link !== after.link ) { movedNotice(moved.link, after.link, appUrl); } } }); }, [autoRedirects, appUrl]); }; /** The panel fed by the block editor's own store. */ const BlockPanel = ({ env }: { env: SeoEditorEnv }): JSX.Element => { const { meta, update } = usePostMeta(); const post = useEditedPost(); // LiveScore beside the sidebar keeps the score, open or closed, and it // goes to the post once the post is saved, through its own request: as // an edit of the post it made every opened post "unsaved" and took the // undo history for itself. const storeScore = useCallback((): void => undefined, []); // What a fix wrote goes into the editor's edits: the title, excerpt and // slug as attributes, the tags as term ids, the content as blocks made // from its HTML, so Update saves the same values. const applyPost = useCallback( (changes: AppliedPost): void => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const editor = dispatch('core/editor') as any; const edits: Record = {}; if (changes.title !== undefined) edits.title = changes.title; if (changes.excerpt !== undefined) edits.excerpt = changes.excerpt; if (changes.slug !== undefined) edits.slug = changes.slug; if (changes.tags) { const taxonomy = env.taxonomies.find( entry => entry.slug === 'product_tag' ); if (taxonomy) { edits[taxonomy.restBase] = changes.tags.map(tag => tag.id); } } if (Object.keys(edits).length > 0) { editor.editPost(edits); } if (changes.content !== undefined) { editor.resetEditorBlocks(blocksFromHtml(changes.content)); } }, [env.taxonomies] ); return ( ); }; /** The sidebar in the block editor, with the score on its toolbar button. */ export const SeoSidebar = (): JSX.Element | null => { const env = seoEditorEnv; const { meta } = usePostMeta(); const live = useLiveScore(); useSaveWatcher(env); const { PluginSidebar, PluginSidebarMoreMenuItem, PluginPrePublishPanel } = sidebarSlots(); if (!env || !PluginSidebar || !PluginSidebarMoreMenuItem) { return null; } // Until LiveScore has worked it out: the stored score, none for a page // without a keyword or never scored (the field reads 0 then). const keyword = asString(meta[env.keys.focusKeywords]).trim(); const stored = asNumber(meta[env.keys.score]); const score = live ?? (keyword === '' || stored === 0 ? null : stored); return ( <> }> Recomaze SEO } className="recomaze-seo-sidebar" >
{PluginPrePublishPanel && ( )} ); };