PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.10
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.10
2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 2.6.4 2.6.3 All 139 releases
metasync / admin / js / metasync-seo-sidebar.js

metasync-seo-sidebar.js in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.6.10, at admin/js/metasync-seo-sidebar.js

2,034 lines 82.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * MetaSync SEO Sidebar for Gutenberg Block Editor
3 *
4 * Provides SEO Title and Meta Description inputs directly in the post editor sidebar.
5 *
6 * @package MetaSync
7 * @since 2.7.0
8 */
9
10 (function(wp) {
11 'use strict';
12
13 const { registerPlugin } = wp.plugins;
14 const { PluginSidebar, PluginSidebarMoreMenuItem } = wp.editPost;
15 const { PanelBody, TextControl, TextareaControl, Button, ButtonGroup, SelectControl, CheckboxControl, Spinner, Notice } = wp.components;
16 const { useSelect, useDispatch, select: wpSelect, dispatch: wpDispatch } = wp.data;
17 const { useState, useEffect, useCallback, createElement: el } = wp.element;
18 const { __ } = wp.i18n;
19 const apiFetch = wp.apiFetch;
20
21 // Get configuration from PHP
22 const config = window.metasyncSeoSidebar || {
23 iconUrl: '',
24 otherSeoPrimary: {
25 yoastActive: false,
26 rankMathActive: false,
27 aioseoActive: false,
28 },
29 metaKeys: {
30 seoTitle: '_metasync_seo_title',
31 metaDescription: '_metasync_seo_desc',
32 // OTTO keys for fallback (read-only, used to prefill if manual fields are empty)
33 ottoTitle: '_metasync_otto_title',
34 ottoDescription: '_metasync_otto_description',
35 // OTTO disabled per-post flag
36 ottoDisabled: '_metasync_otto_disabled',
37 // Breadcrumb title override
38 breadcrumbTitle: '_metasync_breadcrumb_title',
39 // Primary category
40 primaryCategory: '_metasync_primary_category',
41 primaryProductCat: '_metasync_primary_product_cat',
42 // Language alternates (hreflang) — JSON-encoded array
43 hreflang: '_metasync_hreflang',
44 },
45 hasMetaKeys: {
46 // Whether the manual meta keys exist in database (from PHP check)
47 seoTitle: false,
48 metaDescription: false,
49 },
50 wpmlEntries: [],
51 otto: {
52 globalEnabled: false,
53 name: 'OTTO',
54 },
55 limits: {
56 seoTitle: { min: 50, max: 60, absolute: 70 },
57 metaDescription: { min: 120, max: 160, absolute: 200 },
58 },
59 i18n: {
60 panelTitle: 'MetaSync SEO',
61 seoTitleLabel: 'SEO Title',
62 seoTitleHelp: 'The title that appears in search engine results. Optimal length: 50-60 characters.',
63 metaDescriptionLabel: 'Meta Description',
64 metaDescriptionHelp: 'A brief description for search engine results. Optimal length: 120-160 characters.',
65 urlSlugLabel: 'URL Slug',
66 urlSlugHelp: 'The URL-friendly version of the post name. Use lowercase letters, numbers, and hyphens only.',
67 serpPreviewTitle: 'SERP Preview',
68 serpPreviewHelp: 'Preview how your page will appear in Google search results.',
69 serpDesktop: 'Desktop',
70 serpMobile: 'Mobile',
71 characters: 'characters',
72 primaryCategoryLabel: 'Primary Category',
73 primaryCategoryHelp: 'Used in breadcrumbs and canonical URL when multiple categories are assigned.',
74 primaryCategoryNote: 'Assign 2+ categories to enable this option.',
75 breadcrumbTitleLabel: 'Breadcrumb Title Override',
76 breadcrumbTitleHelp: 'Custom label for this page in breadcrumb trails. Leave empty to use the post title.',
77 ottoPrefillHelp: 'Pre-filled from OTTO. Edit to customize.',
78 ottoOverrideNotice: 'OTTO is enabled. Any SEO title and description changes from OTTO will be overwritten by your custom values entered here.',
79 // Language Alternates (hreflang) panel strings
80 languageAlternatesTitle: 'Language Alternates',
81 addAlternate: 'Add alternate',
82 langLabel: 'Language',
83 regionLabel: 'Region',
84 urlLabel: 'URL',
85 editManually: 'Edit Manually',
86 wpmlAutoPopulated: 'Auto-populated from WPML. Click Edit Manually to override.',
87 },
88 };
89
90 // Check if another SEO plugin already provides a primary category selector.
91 const otherSeo = config.otherSeoPrimary || {};
92 const hasOtherSeoPrimary = otherSeo.yoastActive || otherSeo.rankMathActive || otherSeo.aioseoActive;
93
94 /**
95 * Character Counter Component
96 * Displays character count with color-coded indicator
97 */
98 const CharacterCounter = ({ count, min, max, absolute }) => {
99 let status = 'optimal';
100 let statusColor = '#00a32a'; // Green
101
102 if (count === 0) {
103 status = 'empty';
104 statusColor = '#757575'; // Gray
105 } else if (count < min) {
106 status = 'short';
107 statusColor = '#dba617'; // Yellow/Orange
108 } else if (count > absolute) {
109 status = 'too-long';
110 statusColor = '#d63638'; // Red
111 } else if (count > max) {
112 status = 'long';
113 statusColor = '#dba617'; // Yellow/Orange
114 }
115
116 const progressWidth = Math.min((count / absolute) * 100, 100);
117
118 return el('div', { className: 'metasync-char-counter' },
119 el('div', { className: 'metasync-char-counter-bar' },
120 el('div', {
121 className: 'metasync-char-counter-progress',
122 style: {
123 width: progressWidth + '%',
124 backgroundColor: statusColor,
125 },
126 })
127 ),
128 el('span', {
129 className: 'metasync-char-counter-text',
130 style: { color: statusColor },
131 }, count + ' ' + config.i18n.characters)
132 );
133 };
134
135 /**
136 * OTTO Override Notice Component
137 * Shows an informational notice when OTTO is enabled (globally + per-post) and user has custom values
138 * Informs user that their custom values will take priority over OTTO
139 */
140 const OttoOverrideNotice = () => {
141 // Check if OTTO is globally enabled
142 const isOttoGloballyEnabled = config.otto.globalEnabled;
143
144 // Get custom values and per-post OTTO disabled status
145 const { hasCustomTitle, hasCustomDescription, isOttoDisabledForPost } = useSelect((select) => {
146 const meta = select('core/editor').getEditedPostAttribute('meta') || {};
147 const ottoDisabledValue = meta[config.metaKeys.ottoDisabled] || '';
148 return {
149 hasCustomTitle: !!(meta[config.metaKeys.seoTitle] || '').trim(),
150 hasCustomDescription: !!(meta[config.metaKeys.metaDescription] || '').trim(),
151 isOttoDisabledForPost: ottoDisabledValue === '1' || ottoDisabledValue === 'true',
152 };
153 }, []);
154
155 // OTTO is active for this post if: globally enabled AND not disabled per-post
156 const isOttoActiveForPost = isOttoGloballyEnabled && !isOttoDisabledForPost;
157
158 // Only show notice if OTTO is active for this post AND user has custom values
159 if (!isOttoActiveForPost || (!hasCustomTitle && !hasCustomDescription)) {
160 return null;
161 }
162
163 return el('div', { className: 'metasync-otto-override-notice' },
164 el('div', { className: 'metasync-otto-notice-icon' },
165 el('svg', {
166 width: 20,
167 height: 20,
168 viewBox: '0 0 24 24',
169 fill: 'none',
170 xmlns: 'http://www.w3.org/2000/svg',
171 },
172 el('path', {
173 d: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z',
174 fill: 'currentColor',
175 })
176 )
177 ),
178 el('div', { className: 'metasync-otto-notice-content' },
179 el('strong', null, __('Custom Values Active', 'metasync')),
180 el('p', null, config.i18n.ottoOverrideNotice)
181 )
182 );
183 };
184
185 /**
186 * SEO Title Input Component
187 * Falls back to OTTO title only if manual field has never been set
188 */
189 const SeoTitleInput = () => {
190 const metaKey = config.metaKeys.seoTitle;
191 const ottoKey = config.metaKeys.ottoTitle;
192 const limits = config.limits.seoTitle;
193
194 // Get both manual and OTTO values
195 // Use PHP-provided hasMetaKeys to check if meta key exists in database
196 const { manualValue, ottoValue } = useSelect((select) => {
197 const meta = select('core/editor').getEditedPostAttribute('meta') || {};
198 return {
199 manualValue: meta[metaKey] || '',
200 ottoValue: meta[ottoKey] || '',
201 };
202 }, [metaKey, ottoKey]);
203
204 const { editPost } = useDispatch('core/editor');
205
206 // Track if user has edited this field during this session
207 const [hasBeenEdited, setHasBeenEdited] = useState(false);
208
209 // Check if meta key exists in database (from PHP check)
210 const hasMetaKeyInDb = config.hasMetaKeys.seoTitle;
211
212 // Display value logic:
213 // - If user has edited during this session, show manual value (even if empty)
214 // - If manual value exists in database (even empty), show it
215 // - Otherwise show OTTO as prefill
216 const shouldShowOttoFallback = !hasBeenEdited && !hasMetaKeyInDb && ottoValue;
217 const displayValue = shouldShowOttoFallback ? ottoValue : (manualValue || '');
218
219 // Track if showing OTTO value (for visual indicator)
220 const isOttoValue = shouldShowOttoFallback;
221
222 const handleChange = (value) => {
223 // Mark as edited so we don't fallback to OTTO anymore
224 setHasBeenEdited(true);
225 // Always save to manual field
226 editPost({ meta: { [metaKey]: value } });
227 };
228
229 return el('div', { className: 'metasync-seo-field' },
230 el(TextControl, {
231 label: config.i18n.seoTitleLabel,
232 value: displayValue,
233 onChange: handleChange,
234 help: isOttoValue
235 ? config.i18n.ottoPrefillHelp
236 : config.i18n.seoTitleHelp,
237 placeholder: __('Enter SEO title...', 'metasync'),
238 className: isOttoValue ? 'metasync-prefilled-otto' : '',
239 }),
240 el(CharacterCounter, {
241 count: displayValue.length,
242 min: limits.min,
243 max: limits.max,
244 absolute: limits.absolute,
245 })
246 );
247 };
248
249 /**
250 * Meta Description Input Component
251 * Falls back to OTTO description only if manual field has never been set
252 */
253 const MetaDescriptionInput = () => {
254 const metaKey = config.metaKeys.metaDescription;
255 const ottoKey = config.metaKeys.ottoDescription;
256 const limits = config.limits.metaDescription;
257
258 // Get both manual and OTTO values
259 // Use PHP-provided hasMetaKeys to check if meta key exists in database
260 const { manualValue, ottoValue } = useSelect((select) => {
261 const meta = select('core/editor').getEditedPostAttribute('meta') || {};
262 return {
263 manualValue: meta[metaKey] || '',
264 ottoValue: meta[ottoKey] || '',
265 };
266 }, [metaKey, ottoKey]);
267
268 const { editPost } = useDispatch('core/editor');
269
270 // Track if user has edited this field during this session
271 const [hasBeenEdited, setHasBeenEdited] = useState(false);
272
273 // Check if meta key exists in database (from PHP check)
274 const hasMetaKeyInDb = config.hasMetaKeys.metaDescription;
275
276 // Display value logic:
277 // - If user has edited during this session, show manual value (even if empty)
278 // - If manual value exists in database (even empty), show it
279 // - Otherwise show OTTO as prefill
280 const shouldShowOttoFallback = !hasBeenEdited && !hasMetaKeyInDb && ottoValue;
281 const displayValue = shouldShowOttoFallback ? ottoValue : (manualValue || '');
282
283 // Track if showing OTTO value (for visual indicator)
284 const isOttoValue = shouldShowOttoFallback;
285
286 const handleChange = (value) => {
287 // Mark as edited so we don't fallback to OTTO anymore
288 setHasBeenEdited(true);
289 // Always save to manual field
290 editPost({ meta: { [metaKey]: value } });
291 };
292
293 return el('div', { className: 'metasync-seo-field' },
294 el(TextareaControl, {
295 label: config.i18n.metaDescriptionLabel,
296 value: displayValue,
297 onChange: handleChange,
298 help: isOttoValue
299 ? config.i18n.ottoPrefillHelp
300 : config.i18n.metaDescriptionHelp,
301 placeholder: __('Enter meta description...', 'metasync'),
302 rows: 4,
303 className: isOttoValue ? 'metasync-prefilled-otto' : '',
304 }),
305 el(CharacterCounter, {
306 count: displayValue.length,
307 min: limits.min,
308 max: limits.max,
309 absolute: limits.absolute,
310 })
311 );
312 };
313
314 /**
315 * URL Slug Input Component
316 * Syncs with WordPress native post slug (permalink)
317 */
318 const UrlSlugInput = () => {
319 // Get the current post slug and permalink
320 const { slug, link, postId } = useSelect((select) => {
321 const editor = select('core/editor');
322 return {
323 slug: editor.getEditedPostAttribute('slug') || '',
324 link: editor.getPermalink() || '',
325 postId: editor.getCurrentPostId(),
326 };
327 }, []);
328
329 const { editPost } = useDispatch('core/editor');
330
331 /**
332 * Sanitize slug to match WordPress permalink standards
333 * - Convert to lowercase
334 * - Replace spaces with hyphens
335 * - Remove special characters except hyphens
336 * - Remove multiple consecutive hyphens
337 */
338 const sanitizeSlug = (value) => {
339 return value
340 .toLowerCase()
341 .replace(/\s+/g, '-') // Replace spaces with hyphens
342 .replace(/[^a-z0-9-]/g, '') // Remove special characters
343 .replace(/-+/g, '-') // Replace multiple hyphens with single
344 .replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
345 };
346
347 const handleChange = (value) => {
348 const sanitized = sanitizeSlug(value);
349 editPost({ slug: sanitized });
350 };
351
352 // Extract base URL for preview (remove the slug part)
353 const baseUrl = link ? link.replace(/[^/]+\/?$/, '') : '';
354
355 return el('div', { className: 'metasync-seo-field metasync-url-slug-field' },
356 el(TextControl, {
357 label: config.i18n.urlSlugLabel,
358 value: slug,
359 onChange: handleChange,
360 help: config.i18n.urlSlugHelp,
361 placeholder: __('enter-url-slug', 'metasync'),
362 }),
363 // Show permalink preview
364 link && el('div', { className: 'metasync-permalink-preview' },
365 el('span', { className: 'metasync-permalink-label' }, __('Permalink:', 'metasync') + ' '),
366 el('a', {
367 href: link,
368 target: '_blank',
369 rel: 'noopener noreferrer',
370 className: 'metasync-permalink-link',
371 },
372 el('span', { className: 'metasync-permalink-base' }, baseUrl),
373 el('strong', { className: 'metasync-permalink-slug' }, slug || __('(auto-generated)', 'metasync'))
374 )
375 )
376 );
377 };
378
379 /**
380 * SERP Preview Component
381 * Shows a real-time preview of how the page will appear in Google search results
382 */
383 const SerpPreview = () => {
384 const [viewMode, setViewMode] = useState('desktop'); // 'desktop' or 'mobile'
385
386 // Get all the data needed for the preview (with OTTO fallbacks)
387 const { seoTitle, metaDescription, postTitle, permalink, excerpt } = useSelect((select) => {
388 const editor = select('core/editor');
389 const meta = editor.getEditedPostAttribute('meta') || {};
390
391 // Get manual values first, then fall back to OTTO values
392 const manualTitle = meta[config.metaKeys.seoTitle] || '';
393 const ottoTitle = meta[config.metaKeys.ottoTitle] || '';
394 const manualDesc = meta[config.metaKeys.metaDescription] || '';
395 const ottoDesc = meta[config.metaKeys.ottoDescription] || '';
396
397 return {
398 seoTitle: manualTitle || ottoTitle, // Manual > OTTO
399 metaDescription: manualDesc || ottoDesc, // Manual > OTTO
400 postTitle: editor.getEditedPostAttribute('title') || '',
401 permalink: editor.getPermalink() || '',
402 excerpt: editor.getEditedPostAttribute('excerpt') || '',
403 };
404 }, []);
405
406 // Determine display values with fallbacks
407 const displayTitle = seoTitle || postTitle || __('Page Title', 'metasync');
408 const displayDescription = metaDescription || excerpt || __('Add a meta description to control how your page appears in search results.', 'metasync');
409
410 // Format URL for display (remove protocol, truncate if needed)
411 const formatUrl = (url) => {
412 if (!url) return 'example.com';
413 try {
414 const urlObj = new URL(url);
415 let displayUrl = urlObj.hostname + urlObj.pathname;
416 // Remove trailing slash
417 displayUrl = displayUrl.replace(/\/$/, '');
418 return displayUrl;
419 } catch (e) {
420 return url;
421 }
422 };
423
424 // Truncate text with ellipsis
425 const truncate = (text, maxLength) => {
426 if (!text) return '';
427 if (text.length <= maxLength) return text;
428 return text.substring(0, maxLength).trim() + '...';
429 };
430
431 // Get display limits based on view mode
432 const titleLimit = viewMode === 'mobile' ? 55 : 60;
433 const descLimit = viewMode === 'mobile' ? 120 : 160;
434
435 const displayUrl = formatUrl(permalink);
436 const truncatedTitle = truncate(displayTitle, titleLimit);
437 const truncatedDescription = truncate(displayDescription, descLimit);
438
439 // Generate breadcrumb from URL
440 const getBreadcrumbs = (url) => {
441 if (!url) return [];
442 try {
443 const urlObj = new URL(url);
444 const pathParts = urlObj.pathname.split('/').filter(p => p);
445 if (pathParts.length === 0) return [urlObj.hostname];
446 return [urlObj.hostname, ...pathParts.slice(0, -1)];
447 } catch (e) {
448 return [];
449 }
450 };
451
452 const breadcrumbs = getBreadcrumbs(permalink);
453
454 return el('div', { className: 'metasync-serp-preview' },
455 // Header with toggle
456 el('div', { className: 'metasync-serp-header' },
457 el('span', { className: 'metasync-serp-label' }, config.i18n.serpPreviewTitle),
458 el(ButtonGroup, { className: 'metasync-serp-toggle' },
459 el(Button, {
460 isPrimary: viewMode === 'desktop',
461 isSecondary: viewMode !== 'desktop',
462 isSmall: true,
463 onClick: () => setViewMode('desktop'),
464 'aria-label': config.i18n.serpDesktop,
465 },
466 el('span', { className: 'dashicons dashicons-desktop' }),
467 ' ',
468 config.i18n.serpDesktop
469 ),
470 el(Button, {
471 isPrimary: viewMode === 'mobile',
472 isSecondary: viewMode !== 'mobile',
473 isSmall: true,
474 onClick: () => setViewMode('mobile'),
475 'aria-label': config.i18n.serpMobile,
476 },
477 el('span', { className: 'dashicons dashicons-smartphone' }),
478 ' ',
479 config.i18n.serpMobile
480 )
481 )
482 ),
483
484 // Google-style preview
485 el('div', {
486 className: 'metasync-serp-result ' + (viewMode === 'mobile' ? 'metasync-serp-mobile' : 'metasync-serp-desktop')
487 },
488 // Favicon and URL
489 el('div', { className: 'metasync-serp-url-row' },
490 el('div', { className: 'metasync-serp-favicon' },
491 el('div', { className: 'metasync-serp-favicon-placeholder' })
492 ),
493 el('div', { className: 'metasync-serp-url-info' },
494 el('span', { className: 'metasync-serp-site-name' },
495 breadcrumbs[0] || 'example.com'
496 ),
497 el('span', { className: 'metasync-serp-breadcrumb' },
498 breadcrumbs.length > 1 ? ' › ' + breadcrumbs.slice(1).join(' › ') : ''
499 )
500 )
501 ),
502
503 // Title
504 el('div', { className: 'metasync-serp-title' }, truncatedTitle),
505
506 // Description
507 el('div', { className: 'metasync-serp-description' }, truncatedDescription)
508 ),
509
510 // Help text
511 el('p', { className: 'metasync-serp-help' }, config.i18n.serpPreviewHelp)
512 );
513 };
514
515 /**
516 * Internal Link Suggestions Panel Component
517 * Fetches and displays internal link suggestions from the REST API.
518 * Uses Gutenberg block APIs to insert links safely within individual blocks.
519 */
520 const LinkSuggestionsPanel = () => {
521 const [suggestions, setSuggestions] = useState([]);
522 const [isLoading, setIsLoading] = useState(false);
523 const [error, setError] = useState(null);
524 const [insertStatus, setInsertStatus] = useState(null);
525
526 const postId = useSelect((select) => {
527 return select('core/editor').getCurrentPostId();
528 }, []);
529
530 const blocks = useSelect((select) => {
531 return select('core/block-editor').getBlocks();
532 }, []);
533
534 const lsConfig = config.linkSuggestions || {};
535 const lsI18n = lsConfig.i18n || {};
536
537 const escapeRegex = (str) => {
538 return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
539 };
540
541 const fetchSuggestions = () => {
542 if (!postId || !lsConfig.restUrl) return;
543 setIsLoading(true);
544 setError(null);
545 setInsertStatus(null);
546
547 apiFetch({
548 url: lsConfig.restUrl + '?post_id=' + postId + '&limit=10',
549 }).then((response) => {
550 setSuggestions(response.suggestions || []);
551 setIsLoading(false);
552 }).catch((err) => {
553 setError(err.message || 'Failed to fetch suggestions');
554 setIsLoading(false);
555 });
556 };
557
558 useEffect(() => {
559 fetchSuggestions();
560 }, [postId]);
561
562 /**
563 * Get the plain text content of a block (strips HTML tags)
564 */
565 const getBlockText = (html) => {
566 if (!html) return '';
567 var doc = new DOMParser().parseFromString(html, 'text/html');
568 return doc.body.textContent || '';
569 };
570
571 /**
572 * Get the text content attribute from a block (handles different block types)
573 */
574 const getBlockContentHtml = (block) => {
575 if (!block || !block.attributes) return '';
576 // core/paragraph, core/heading, core/preformatted, core/verse use 'content'
577 // core/list (older) uses 'values'
578 // core/list-item uses 'content'
579 return block.attributes.content || block.attributes.values || '';
580 };
581
582 /**
583 * Flatten all blocks including nested innerBlocks into a single list
584 */
585 const flattenBlocks = (blockList) => {
586 var result = [];
587 if (!blockList) return result;
588 for (var i = 0; i < blockList.length; i++) {
589 result.push(blockList[i]);
590 if (blockList[i].innerBlocks && blockList[i].innerBlocks.length > 0) {
591 result = result.concat(flattenBlocks(blockList[i].innerBlocks));
592 }
593 }
594 return result;
595 };
596
597 /**
598 * Check if a position in HTML is inside an HTML tag (i.e., between < and >)
599 * This prevents matching text inside attributes like alt="...", title="...", etc.
600 */
601 const isInsideHtmlTag = (html, index) => {
602 // Look backwards from the match position for < or >
603 for (var i = index - 1; i >= 0; i--) {
604 if (html[i] === '>') return false; // Closed tag before us — we're outside
605 if (html[i] === '<') return true; // Open tag before us — we're inside a tag
606 }
607 return false;
608 };
609
610 /**
611 * Check if a position in HTML is inside an <a>...</a> element
612 */
613 const isInsideAnchorTag = (html, index) => {
614 var before = html.substring(0, index);
615 var openA = (before.match(/<a\s/gi) || []).length;
616 var closeA = (before.match(/<\/a>/gi) || []).length;
617 return openA > closeA;
618 };
619
620 /**
621 * Check if phrase exists in any block and is not already linked
622 */
623 const isPhraseUnlinked = useCallback((phrase) => {
624 if (!phrase || !blocks || blocks.length === 0) return false;
625
626 var escaped = escapeRegex(phrase);
627 var regex = new RegExp(escaped, 'gi');
628 var allBlocks = flattenBlocks(blocks);
629
630 for (var i = 0; i < allBlocks.length; i++) {
631 var block = allBlocks[i];
632 var html = getBlockContentHtml(block);
633 if (!html) continue;
634
635 var text = getBlockText(html);
636 if (!(new RegExp(escaped, 'i')).test(text)) continue;
637
638 // Search in raw HTML for an occurrence that is:
639 // 1. NOT inside an HTML tag attribute
640 // 2. NOT inside an <a> tag
641 var match;
642 regex.lastIndex = 0;
643 while ((match = regex.exec(html)) !== null) {
644 if (!isInsideHtmlTag(html, match.index) && !isInsideAnchorTag(html, match.index)) {
645 return true;
646 }
647 }
648 regex.lastIndex = 0;
649 }
650 return false;
651 }, [blocks]);
652
653 /**
654 * Insert a link using Gutenberg's block API
655 * Finds the specific block containing the phrase and updates only that block
656 */
657 const insertLink = (suggestion) => {
658 var phrase = suggestion.matched_phrase;
659 var url = suggestion.url;
660 if (!phrase || !url) return;
661
662 var escaped = escapeRegex(phrase);
663 var searchRegex = new RegExp(escaped, 'gi');
664
665 // Sanitize URL for safe HTML insertion
666 var safeUrl = url.replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
667
668 // Find the block that contains this phrase (unlinked), including nested blocks
669 var currentBlocks = flattenBlocks(wpSelect('core/block-editor').getBlocks());
670
671 for (var i = 0; i < currentBlocks.length; i++) {
672 var block = currentBlocks[i];
673 var html = getBlockContentHtml(block);
674 if (!html) continue;
675
676 // Check if phrase exists in this block's text
677 var text = getBlockText(html);
678 if (!searchRegex.test(text)) {
679 searchRegex.lastIndex = 0;
680 continue;
681 }
682 searchRegex.lastIndex = 0;
683
684 // Find the first unlinked occurrence in the HTML
685 var match;
686 var newHtml = html;
687 var replaced = false;
688
689 while ((match = searchRegex.exec(html)) !== null) {
690 // Skip matches inside HTML tag attributes (alt, title, src, etc.)
691 if (isInsideHtmlTag(html, match.index)) continue;
692 // Skip matches inside existing <a> tags
693 if (isInsideAnchorTag(html, match.index)) continue;
694
695 // This occurrence is safe to wrap
696 var originalPhrase = html.substring(match.index, match.index + match[0].length);
697 newHtml = html.substring(0, match.index) +
698 '<a href="' + safeUrl + '" target="_blank" rel="noopener noreferrer">' + originalPhrase + '</a>' +
699 html.substring(match.index + match[0].length);
700 replaced = true;
701 break;
702 }
703 searchRegex.lastIndex = 0;
704
705 if (replaced) {
706 // Update ONLY this specific block, not the entire post content
707 // Use the correct attribute key (content vs values for list blocks)
708 var attrKey = (block.attributes && block.attributes.values !== undefined && !block.attributes.content) ? 'values' : 'content';
709 var updateAttrs = {};
710 updateAttrs[attrKey] = newHtml;
711 wpDispatch('core/block-editor').updateBlockAttributes(block.clientId, updateAttrs);
712
713 // Remove the inserted suggestion from the list
714 setSuggestions(function(prev) {
715 return prev.filter(function(s) {
716 return s.post_id !== suggestion.post_id;
717 });
718 });
719
720 setInsertStatus('Linked: "' + phrase + '"');
721 setTimeout(function() { setInsertStatus(null); }, 3000);
722 return;
723 }
724 }
725
726 // If we got here, couldn't find a suitable block
727 setInsertStatus('Could not find the phrase in any content block.');
728 setTimeout(function() { setInsertStatus(null); }, 3000);
729 };
730
731 const truncateUrl = (url, maxLen) => {
732 if (!url) return '';
733 if (url.length <= maxLen) return url;
734 return url.substring(0, maxLen) + '\u2026';
735 };
736
737 // Build panel children
738 var children = [];
739
740 // Header with Refresh button
741 children.push(
742 el('div', { className: 'metasync-link-suggestions-header', key: 'header' },
743 el('span', null, ''),
744 el(Button, {
745 isSmall: true,
746 isSecondary: true,
747 onClick: fetchSuggestions,
748 disabled: isLoading,
749 }, lsI18n.refreshButton || 'Refresh')
750 )
751 );
752
753 // Cross-plugin notices
754 if (lsConfig.yoastPremiumActive) {
755 children.push(
756 el('div', { className: 'metasync-link-suggestions-notice', key: 'yoast-notice' },
757 lsI18n.yoastNotice || ''
758 )
759 );
760 }
761 if (lsConfig.rankMathActive) {
762 children.push(
763 el('div', { className: 'metasync-link-suggestions-notice', key: 'rankmath-notice' },
764 lsI18n.rankMathNotice || ''
765 )
766 );
767 }
768
769 // Insert status feedback
770 if (insertStatus) {
771 children.push(
772 el('div', {
773 className: 'metasync-link-suggestions-notice',
774 key: 'insert-status',
775 style: { color: '#00a32a', fontWeight: 600 },
776 }, insertStatus)
777 );
778 }
779
780 if (isLoading) {
781 children.push(el(Spinner, { key: 'spinner' }));
782 } else if (error) {
783 children.push(
784 el('div', { className: 'metasync-link-suggestions-empty', key: 'error' }, error)
785 );
786 } else if (suggestions.length === 0) {
787 children.push(
788 el('div', { className: 'metasync-link-suggestions-empty', key: 'empty' },
789 lsI18n.noSuggestions || 'No suggestions found.'
790 )
791 );
792 } else {
793 var items = suggestions.map(function(suggestion) {
794 var canInsert = isPhraseUnlinked(suggestion.matched_phrase);
795 return el('li', {
796 className: 'metasync-link-suggestion-item',
797 key: suggestion.post_id,
798 },
799 el('span', { className: 'metasync-suggestion-title' }, suggestion.title),
800 el('span', { className: 'metasync-suggestion-url' }, truncateUrl(suggestion.url, 40)),
801 el('span', { className: 'metasync-suggestion-phrase' },
802 (lsI18n.matchedPhrase || 'Matched phrase:') + ' ',
803 el('strong', null, suggestion.matched_phrase)
804 ),
805 suggestion.relevance_score ? el('span', {
806 className: 'metasync-suggestion-score',
807 style: { fontSize: '11px', color: '#646970', display: 'block', marginBottom: '4px' },
808 }, 'Relevance: ' + Math.round(suggestion.relevance_score * 100) + '%') : null,
809 el(Button, {
810 isSmall: true,
811 isPrimary: true,
812 onClick: function() { insertLink(suggestion); },
813 disabled: !canInsert,
814 }, lsI18n.insertButton || 'Insert')
815 );
816 });
817 children.push(
818 el('ul', { className: 'metasync-link-suggestions-list', key: 'list' }, items)
819 );
820 }
821
822 return el('div', null, children);
823 };
824
825 // =========================================================================
826 // Schema Content Panel Components
827 // =========================================================================
828
829 const schemaConfig = config.schemaContent || {};
830 const schemaI18n = schemaConfig.i18n || {};
831
832 /**
833 * FAQ Panel - manages Q&A items
834 */
835 const FAQPanel = ({ fields, onChange }) => {
836 const items = (fields && fields.faq_items) || [];
837
838 const updateItem = (index, key, value) => {
839 const next = items.slice();
840 next[index] = Object.assign({}, next[index], { [key]: value });
841 onChange(Object.assign({}, fields, { faq_items: next }));
842 };
843
844 const addItem = () => {
845 const next = items.slice();
846 next.push({ question: '', answer: '' });
847 onChange(Object.assign({}, fields, { faq_items: next }));
848 };
849
850 const removeItem = (index) => {
851 const next = items.slice();
852 next.splice(index, 1);
853 onChange(Object.assign({}, fields, { faq_items: next }));
854 };
855
856 return el('div', { className: 'metasync-schema-faq-panel' },
857 items.map((item, i) =>
858 el('div', { key: 'faq-' + i, className: 'metasync-schema-repeater-row', style: { marginBottom: '12px', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' } },
859 el(TextControl, {
860 label: __('Question', 'metasync') + ' ' + (i + 1),
861 value: item.question || '',
862 onChange: (v) => updateItem(i, 'question', v),
863 }),
864 el(TextareaControl, {
865 label: __('Answer', 'metasync'),
866 value: item.answer || '',
867 onChange: (v) => updateItem(i, 'answer', v),
868 rows: 3,
869 }),
870 el(Button, {
871 isDestructive: true,
872 isSmall: true,
873 isLink: true,
874 onClick: () => removeItem(i),
875 }, schemaI18n.removeQuestion || 'Remove')
876 )
877 ),
878 el(Button, {
879 isSecondary: true,
880 isSmall: true,
881 onClick: addItem,
882 }, schemaI18n.addQuestion || 'Add Question')
883 );
884 };
885
886 /**
887 * HowTo Panel - manages steps list
888 */
889 const HowToPanel = ({ fields, onChange }) => {
890 const steps = (fields && fields.steps) || [];
891
892 const updateStep = (index, key, value) => {
893 const next = steps.slice();
894 next[index] = Object.assign({}, next[index], { [key]: value });
895 onChange(Object.assign({}, fields, { steps: next }));
896 };
897
898 const addStep = () => {
899 const next = steps.slice();
900 next.push({ instructions: '', image: '' });
901 onChange(Object.assign({}, fields, { steps: next }));
902 };
903
904 const removeStep = (index) => {
905 const next = steps.slice();
906 next.splice(index, 1);
907 onChange(Object.assign({}, fields, { steps: next }));
908 };
909
910 return el('div', { className: 'metasync-schema-howto-panel' },
911 el(TextControl, {
912 label: __('Total Time (minutes)', 'metasync'),
913 value: (fields && fields.total_time) || '',
914 onChange: (v) => onChange(Object.assign({}, fields, { total_time: v })),
915 type: 'number',
916 }),
917 steps.map((step, i) =>
918 el('div', { key: 'step-' + i, className: 'metasync-schema-repeater-row', style: { marginBottom: '12px', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' } },
919 el(TextareaControl, {
920 label: __('Step', 'metasync') + ' ' + (i + 1) + ' — ' + __('Instructions', 'metasync'),
921 value: step.instructions || '',
922 onChange: (v) => updateStep(i, 'instructions', v),
923 rows: 2,
924 }),
925 el(TextControl, {
926 label: __('Image URL (optional)', 'metasync'),
927 value: step.image || '',
928 onChange: (v) => updateStep(i, 'image', v),
929 }),
930 el(Button, {
931 isDestructive: true,
932 isSmall: true,
933 isLink: true,
934 onClick: () => removeStep(i),
935 }, schemaI18n.removeStep || 'Remove')
936 )
937 ),
938 el(Button, {
939 isSecondary: true,
940 isSmall: true,
941 onClick: addStep,
942 }, schemaI18n.addStep || 'Add Step')
943 );
944 };
945
946 /**
947 * Product Panel - price, currency, availability, condition, SKU, brand
948 */
949 const ProductPanel = ({ fields, onChange, woocommerceActive, woocommerceData }) => {
950 const f = fields || {};
951
952 const update = (key, value) => {
953 onChange(Object.assign({}, f, { [key]: value }));
954 };
955
956 const autoPopulateWC = () => {
957 if (woocommerceData) {
958 onChange(Object.assign({}, f, {
959 price: woocommerceData.price || f.price || '',
960 currency: woocommerceData.currency || f.currency || 'USD',
961 availability: woocommerceData.availability || f.availability || 'InStock',
962 sku: woocommerceData.sku || f.sku || '',
963 }));
964 }
965 };
966
967 return el('div', { className: 'metasync-schema-product-panel' },
968 woocommerceActive && woocommerceData && el(Button, {
969 isSecondary: true,
970 isSmall: true,
971 onClick: autoPopulateWC,
972 style: { marginBottom: '12px' },
973 }, schemaI18n.autoPopulateWC || 'Auto-populate from WooCommerce'),
974 el(TextControl, {
975 label: __('Price', 'metasync'),
976 value: f.price || '',
977 onChange: (v) => update('price', v),
978 type: 'number',
979 }),
980 el(SelectControl, {
981 label: __('Currency', 'metasync'),
982 value: f.currency || 'USD',
983 options: [
984 { label: 'USD', value: 'USD' },
985 { label: 'EUR', value: 'EUR' },
986 { label: 'GBP', value: 'GBP' },
987 { label: 'CAD', value: 'CAD' },
988 { label: 'AUD', value: 'AUD' },
989 ],
990 onChange: (v) => update('currency', v),
991 }),
992 el(SelectControl, {
993 label: __('Availability', 'metasync'),
994 value: f.availability || 'InStock',
995 options: [
996 { label: 'In Stock', value: 'InStock' },
997 { label: 'Out of Stock', value: 'OutOfStock' },
998 { label: 'Pre-Order', value: 'PreOrder' },
999 ],
1000 onChange: (v) => update('availability', v),
1001 }),
1002 el(SelectControl, {
1003 label: __('Condition', 'metasync'),
1004 value: f.condition || 'NewCondition',
1005 options: [
1006 { label: 'New', value: 'NewCondition' },
1007 { label: 'Used', value: 'UsedCondition' },
1008 { label: 'Refurbished', value: 'RefurbishedCondition' },
1009 ],
1010 onChange: (v) => update('condition', v),
1011 }),
1012 el(TextControl, {
1013 label: __('SKU', 'metasync'),
1014 value: f.sku || '',
1015 onChange: (v) => update('sku', v),
1016 }),
1017 el(TextControl, {
1018 label: __('Brand', 'metasync'),
1019 value: f.brand || '',
1020 onChange: (v) => update('brand', v),
1021 })
1022 );
1023 };
1024
1025 /**
1026 * Recipe Panel - yield, times, calories, ingredients, instructions
1027 */
1028 const RecipePanel = ({ fields, onChange }) => {
1029 const f = fields || {};
1030 const ingredients = f.ingredients || [];
1031 const instructions = f.instructions || [];
1032
1033 const update = (key, value) => {
1034 onChange(Object.assign({}, f, { [key]: value }));
1035 };
1036
1037 const updateIngredient = (index, value) => {
1038 const next = ingredients.slice();
1039 next[index] = value;
1040 update('ingredients', next);
1041 };
1042
1043 const addIngredient = () => {
1044 const next = ingredients.slice();
1045 next.push('');
1046 update('ingredients', next);
1047 };
1048
1049 const removeIngredient = (index) => {
1050 const next = ingredients.slice();
1051 next.splice(index, 1);
1052 update('ingredients', next);
1053 };
1054
1055 const updateInstruction = (index, value) => {
1056 const next = instructions.slice();
1057 next[index] = { text: value };
1058 update('instructions', next);
1059 };
1060
1061 const addInstruction = () => {
1062 const next = instructions.slice();
1063 next.push({ text: '' });
1064 update('instructions', next);
1065 };
1066
1067 const removeInstruction = (index) => {
1068 const next = instructions.slice();
1069 next.splice(index, 1);
1070 update('instructions', next);
1071 };
1072
1073 return el('div', { className: 'metasync-schema-recipe-panel' },
1074 el(TextControl, {
1075 label: __('Yield (servings)', 'metasync'),
1076 value: f.yield || '',
1077 onChange: (v) => update('yield', v),
1078 }),
1079 el(TextControl, {
1080 label: __('Prep Time (minutes)', 'metasync'),
1081 value: f.prep_time || '',
1082 onChange: (v) => update('prep_time', v),
1083 type: 'number',
1084 }),
1085 el(TextControl, {
1086 label: __('Cook Time (minutes)', 'metasync'),
1087 value: f.cook_time || '',
1088 onChange: (v) => update('cook_time', v),
1089 type: 'number',
1090 }),
1091 el(TextControl, {
1092 label: __('Total Time (minutes)', 'metasync'),
1093 value: f.total_time || '',
1094 onChange: (v) => update('total_time', v),
1095 type: 'number',
1096 }),
1097 el(TextControl, {
1098 label: __('Calories', 'metasync'),
1099 value: f.calories || '',
1100 onChange: (v) => update('calories', v),
1101 type: 'number',
1102 }),
1103 el('h4', { style: { marginTop: '12px', marginBottom: '4px' } }, __('Ingredients', 'metasync')),
1104 ingredients.map((ing, i) =>
1105 el('div', { key: 'ing-' + i, style: { display: 'flex', alignItems: 'center', gap: '4px', marginBottom: '4px' } },
1106 el(TextControl, {
1107 value: typeof ing === 'string' ? ing : (ing || ''),
1108 onChange: (v) => updateIngredient(i, v),
1109 placeholder: __('Ingredient', 'metasync'),
1110 style: { flex: 1 },
1111 }),
1112 el(Button, {
1113 isDestructive: true,
1114 isSmall: true,
1115 isLink: true,
1116 onClick: () => removeIngredient(i),
1117 }, schemaI18n.removeIngredient || 'Remove')
1118 )
1119 ),
1120 el(Button, {
1121 isSecondary: true,
1122 isSmall: true,
1123 onClick: addIngredient,
1124 style: { marginBottom: '12px' },
1125 }, schemaI18n.addIngredient || 'Add Ingredient'),
1126 el('h4', { style: { marginTop: '12px', marginBottom: '4px' } }, __('Instructions', 'metasync')),
1127 instructions.map((inst, i) => {
1128 var instText = typeof inst === 'string' ? inst : ((inst && inst.text) || '');
1129 return el('div', { key: 'inst-' + i, style: { display: 'flex', alignItems: 'flex-start', gap: '4px', marginBottom: '4px' } },
1130 el(TextareaControl, {
1131 value: instText,
1132 onChange: (v) => updateInstruction(i, v),
1133 placeholder: __('Step', 'metasync') + ' ' + (i + 1),
1134 rows: 2,
1135 style: { flex: 1 },
1136 }),
1137 el(Button, {
1138 isDestructive: true,
1139 isSmall: true,
1140 isLink: true,
1141 onClick: () => removeInstruction(i),
1142 }, schemaI18n.removeInstruction || 'Remove')
1143 );
1144 }),
1145 el(Button, {
1146 isSecondary: true,
1147 isSmall: true,
1148 onClick: addInstruction,
1149 }, schemaI18n.addInstruction || 'Add Instruction')
1150 );
1151 };
1152
1153 /**
1154 * Schema Content Panel
1155 * Main panel that fetches schema data and renders type-specific sub-panels
1156 */
1157 const SchemaContentPanel = () => {
1158 const [schemaData, setSchemaData] = useState(null);
1159 const [isLoading, setIsLoading] = useState(false);
1160 const [isSavingSchema, setIsSavingSchema] = useState(false);
1161 const [saveNotice, setSaveNotice] = useState(null);
1162 const [validationWarnings, setValidationWarnings] = useState([]);
1163 const [pendingChanges, setPendingChanges] = useState({});
1164 // Only true after the user has actually edited a field; prevents firing
1165 // extra REST calls on every post save when nothing has changed.
1166 const [isDirty, setIsDirty] = useState(false);
1167
1168 const postId = useSelect((select) => {
1169 return select('core/editor').getCurrentPostId();
1170 }, []);
1171
1172 const isSavingPost = useSelect((select) => {
1173 return select('core/editor').isSavingPost();
1174 }, []);
1175
1176 const restUrl = schemaConfig.restUrl || '';
1177
1178 // Fetch schema content on mount
1179 useEffect(() => {
1180 if (!postId || !restUrl) return;
1181 setIsLoading(true);
1182 apiFetch({
1183 url: restUrl + '/' + postId,
1184 }).then((response) => {
1185 setSchemaData(response);
1186 // Initialize pending changes from fetched data
1187 var initial = {};
1188 if (response && response.types) {
1189 response.types.forEach(function(t) {
1190 initial[t.type] = t.fields || {};
1191 });
1192 }
1193 setPendingChanges(initial);
1194 setIsLoading(false);
1195 }).catch(() => {
1196 setIsLoading(false);
1197 });
1198 }, [postId]);
1199
1200 // Save schema content — serialises requests to avoid lost-update race condition
1201 const saveSchemaContent = useCallback(() => {
1202 if (!postId || !restUrl) return;
1203 var types = Object.keys(pendingChanges);
1204 if (types.length === 0) return;
1205
1206 setIsSavingSchema(true);
1207 setSaveNotice(null);
1208 setValidationWarnings([]);
1209
1210 var allWarnings = [];
1211 // Chain requests sequentially so each POST reads the latest DB state
1212 var chain = types.reduce(function(promise, schemaType) {
1213 return promise.then(function() {
1214 return apiFetch({
1215 url: restUrl + '/' + postId,
1216 method: 'POST',
1217 data: {
1218 schema_type: schemaType,
1219 fields: pendingChanges[schemaType],
1220 },
1221 }).then(function(resp) {
1222 if (resp.validation_warnings && resp.validation_warnings.length > 0) {
1223 allWarnings = allWarnings.concat(resp.validation_warnings);
1224 }
1225 });
1226 });
1227 }, Promise.resolve());
1228
1229 chain.then(function() {
1230 setIsSavingSchema(false);
1231 setIsDirty(false);
1232 setValidationWarnings(allWarnings);
1233 setSaveNotice({ type: 'success', message: schemaI18n.saved || 'Schema content saved.' });
1234 setTimeout(function() { setSaveNotice(null); }, 4000);
1235 }).catch(function() {
1236 setIsSavingSchema(false);
1237 setSaveNotice({ type: 'error', message: schemaI18n.saveError || 'Failed to save schema content.' });
1238 setTimeout(function() { setSaveNotice(null); }, 4000);
1239 });
1240 }, [postId, restUrl, pendingChanges]);
1241
1242 // Auto-save on post save — only when the user has edited schema fields
1243 const [wasSaving, setWasSaving] = useState(false);
1244 useEffect(() => {
1245 if (isSavingPost && !wasSaving) {
1246 setWasSaving(true);
1247 }
1248 if (!isSavingPost && wasSaving) {
1249 setWasSaving(false);
1250 if (isDirty) {
1251 saveSchemaContent();
1252 }
1253 }
1254 }, [isSavingPost, wasSaving, isDirty, saveSchemaContent]);
1255
1256 const updateTypeFields = (schemaType, newFields) => {
1257 setIsDirty(true);
1258 setPendingChanges(function(prev) {
1259 var next = Object.assign({}, prev);
1260 next[schemaType] = newFields;
1261 return next;
1262 });
1263 };
1264
1265 // Build panel content
1266 var children = [];
1267
1268 if (isLoading) {
1269 children.push(el(Spinner, { key: 'loading' }));
1270 } else if (!schemaData || !schemaData.types || schemaData.types.length === 0) {
1271 children.push(
1272 el('p', { key: 'no-types', style: { color: '#757575', fontStyle: 'italic' } },
1273 schemaI18n.noSchemaTypes || 'No schema types configured.'
1274 )
1275 );
1276 } else {
1277 schemaData.types.forEach(function(typeData) {
1278 var schemaType = typeData.type;
1279 var currentFields = pendingChanges[schemaType] || typeData.fields || {};
1280 var onChangeFields = function(newFields) {
1281 updateTypeFields(schemaType, newFields);
1282 };
1283
1284 var subPanel = null;
1285 switch (schemaType) {
1286 case 'FAQPage':
1287 subPanel = el(FAQPanel, { key: 'faq', fields: currentFields, onChange: onChangeFields });
1288 break;
1289 case 'HowTo':
1290 subPanel = el(HowToPanel, { key: 'howto', fields: currentFields, onChange: onChangeFields });
1291 break;
1292 case 'product':
1293 subPanel = el(ProductPanel, {
1294 key: 'product',
1295 fields: currentFields,
1296 onChange: onChangeFields,
1297 woocommerceActive: schemaConfig.woocommerceActive || false,
1298 woocommerceData: schemaConfig.woocommerceData || null,
1299 });
1300 break;
1301 case 'recipe':
1302 subPanel = el(RecipePanel, { key: 'recipe', fields: currentFields, onChange: onChangeFields });
1303 break;
1304 default:
1305 subPanel = el('p', { key: 'unsupported-' + schemaType, style: { color: '#757575' } },
1306 __('Content editing for', 'metasync') + ' ' + schemaType + ' ' + __('is available in the classic editor.', 'metasync')
1307 );
1308 break;
1309 }
1310
1311 children.push(
1312 el(PanelBody, {
1313 key: 'schema-type-' + schemaType,
1314 title: schemaType,
1315 initialOpen: true,
1316 }, subPanel)
1317 );
1318 });
1319
1320 // Validation warnings
1321 if (validationWarnings.length > 0) {
1322 children.push(
1323 el(Notice, {
1324 key: 'validation-warnings',
1325 status: 'warning',
1326 isDismissible: false,
1327 style: { marginTop: '8px' },
1328 },
1329 el('ul', { style: { margin: 0, paddingLeft: '16px' } },
1330 validationWarnings.map(function(w, i) {
1331 var msg = (typeof w === 'string') ? w : (w.message || w.error || JSON.stringify(w));
1332 return el('li', { key: 'warn-' + i }, msg);
1333 })
1334 )
1335 )
1336 );
1337 }
1338
1339 // Save notice
1340 if (saveNotice) {
1341 children.push(
1342 el(Notice, {
1343 key: 'save-notice',
1344 status: saveNotice.type === 'success' ? 'success' : 'error',
1345 isDismissible: false,
1346 style: { marginTop: '8px' },
1347 }, saveNotice.message)
1348 );
1349 }
1350
1351 // Save button
1352 children.push(
1353 el(Button, {
1354 key: 'save-btn',
1355 isPrimary: true,
1356 onClick: saveSchemaContent,
1357 disabled: isSavingSchema,
1358 style: { marginTop: '12px' },
1359 }, isSavingSchema ? (schemaI18n.saving || 'Saving...') : (schemaI18n.saveButton || 'Save Schema Content'))
1360 );
1361 }
1362
1363 return el('div', { className: 'metasync-schema-content-panel' }, children);
1364 };
1365
1366 /**
1367 * MetaSync SEO Sidebar Icon
1368 * Uses external SVG from admin/images/icon-256x256.svg
1369 */
1370 const MetaSyncIcon = config.iconUrl
1371 ? el('img', {
1372 src: config.iconUrl,
1373 alt: 'MetaSync SEO',
1374 width: 20,
1375 height: 20,
1376 style: {
1377 display: 'block',
1378 objectFit: 'contain',
1379 },
1380 })
1381 : el('svg', {
1382 width: 20,
1383 height: 20,
1384 viewBox: '0 0 24 24',
1385 fill: 'none',
1386 xmlns: 'http://www.w3.org/2000/svg',
1387 },
1388 el('path', {
1389 d: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z',
1390 fill: 'currentColor',
1391 })
1392 );
1393
1394 /**
1395 * Breadcrumb Title Override Component
1396 * Now rendered inside the MetaSync SEO sidebar panel.
1397 */
1398 const BreadcrumbTitleInput = () => {
1399 const metaKey = config.metaKeys.breadcrumbTitle || '_metasync_breadcrumb_title';
1400
1401 const { value } = useSelect((select) => {
1402 const meta = select('core/editor').getEditedPostAttribute('meta') || {};
1403 return { value: meta[metaKey] || '' };
1404 }, [metaKey]);
1405
1406 const { editPost } = useDispatch('core/editor');
1407
1408 const handleChange = (newValue) => {
1409 editPost({ meta: { [metaKey]: newValue } });
1410 };
1411
1412 return el(TextControl, {
1413 label: config.i18n.breadcrumbTitleLabel || 'Breadcrumb Title Override',
1414 value: value,
1415 onChange: handleChange,
1416 help: config.i18n.breadcrumbTitleHelp || 'Custom label for this page in breadcrumb trails.',
1417 placeholder: __('Leave empty to use post title', 'metasync'),
1418 });
1419 };
1420
1421 /**
1422 * Primary Category Selector Component
1423 *
1424 * Rendered in two places:
1425 * 1. Inside the MetaSync SEO sidebar panel
1426 * 2. Injected below the WordPress Categories checklist (via editor.PostTaxonomyType filter)
1427 *
1428 * Hidden from the Categories panel when Yoast, Rank Math, or AIOSEO is active
1429 * (those plugins provide their own selector), but always available in the SEO sidebar.
1430 */
1431 const PrimaryCategoryInjectPanel = () => {
1432 const metaKey = config.metaKeys.primaryCategory || '_metasync_primary_category';
1433
1434 const { primaryCategoryId, postCategories, allCategories } = useSelect((select) => {
1435 const editor = select('core/editor');
1436 const meta = editor.getEditedPostAttribute('meta') || {};
1437 const assignedCatIds = editor.getEditedPostAttribute('categories') || [];
1438
1439 let cats = [];
1440 if (assignedCatIds.length > 0) {
1441 const allCats = select('core').getEntityRecords('taxonomy', 'category', {
1442 include: assignedCatIds,
1443 per_page: 100,
1444 });
1445 if (allCats) {
1446 cats = allCats;
1447 }
1448 }
1449
1450 return {
1451 primaryCategoryId: meta[metaKey] || 0,
1452 postCategories: assignedCatIds,
1453 allCategories: cats,
1454 };
1455 }, [metaKey]);
1456
1457 const { editPost } = useDispatch('core/editor');
1458
1459 if (postCategories.length < 2) {
1460 return null;
1461 }
1462
1463 // Wait for categories to resolve from the store.
1464 if (!allCategories || allCategories.length < 2) {
1465 return null;
1466 }
1467
1468 const options = [
1469 { label: __('— Auto (first category) —', 'metasync'), value: 0 },
1470 ];
1471 allCategories.forEach(function(cat) {
1472 options.push({ label: cat.name, value: cat.id });
1473 });
1474
1475 return el('div', { className: 'metasync-primary-category-panel' },
1476 el(SelectControl, {
1477 label: config.i18n.primaryCategoryLabel || 'Primary Category',
1478 value: primaryCategoryId,
1479 options: options,
1480 onChange: function(value) {
1481 editPost({ meta: { [metaKey]: parseInt(value, 10) || 0 } });
1482 },
1483 help: config.i18n.primaryCategoryHelp || 'Used in breadcrumbs and canonical URL when multiple categories are assigned.',
1484 })
1485 );
1486 };
1487
1488 /**
1489 * Language Alternates (hreflang) Panel Component
1490 *
1491 * Renders the list of hreflang entries (read from the _metasync_hreflang
1492 * post meta as a JSON array of {lang, region, url} objects).
1493 *
1494 * When WPML auto-populated entries are available and the user has not
1495 * overridden them, renders them as read-only rows with an "Edit Manually"
1496 * button that switches to manual-edit mode. In manual-edit mode (and
1497 * when WPML is not active), renders editable rows plus an "Add alternate"
1498 * button.
1499 */
1500 const LanguageAlternatesPanel = () => {
1501 const metaKey = config.metaKeys.hreflang || '_metasync_hreflang';
1502 const wpmlEntries = Array.isArray(config.wpmlEntries) ? config.wpmlEntries : [];
1503
1504 const { rawValue } = useSelect((select) => {
1505 const meta = select('core/editor').getEditedPostAttribute('meta') || {};
1506 return { rawValue: meta[metaKey] || '' };
1507 }, [metaKey]);
1508
1509 const { editPost } = useDispatch('core/editor');
1510
1511 let rows = [];
1512 if (rawValue) {
1513 try {
1514 const parsed = JSON.parse(rawValue);
1515 if (Array.isArray(parsed)) {
1516 rows = parsed;
1517 }
1518 } catch (e) {
1519 rows = [];
1520 }
1521 }
1522
1523 // Manual mode is active when the user explicitly overrode WPML
1524 // (tracked in state for the current session) or when manual rows
1525 // already exist in the stored meta.
1526 const [manualMode, setManualMode] = useState(rows.length > 0);
1527
1528 const saveRows = (nextRows) => {
1529 editPost({ meta: { [metaKey]: JSON.stringify(nextRows) } });
1530 };
1531
1532 const updateRow = (index, field, value) => {
1533 const next = rows.slice();
1534 next[index] = Object.assign({}, next[index] || { lang: '', region: '', url: '' });
1535 next[index][field] = value;
1536 saveRows(next);
1537 };
1538
1539 const addRow = () => {
1540 const next = rows.slice();
1541 next.push({ lang: '', region: '', url: '' });
1542 saveRows(next);
1543 };
1544
1545 const removeRow = (index) => {
1546 const next = rows.slice();
1547 next.splice(index, 1);
1548 saveRows(next);
1549 };
1550
1551 const startManualEdit = () => {
1552 // Seed manual rows from the WPML entries so the user can tweak
1553 // rather than re-enter everything.
1554 if (rows.length === 0 && wpmlEntries.length > 0) {
1555 const seeded = wpmlEntries.map(function(e) {
1556 return { lang: e.lang || '', region: '', url: e.url || '' };
1557 });
1558 saveRows(seeded);
1559 }
1560 setManualMode(true);
1561 };
1562
1563 // WPML auto-populated, read-only view
1564 if (!manualMode && wpmlEntries.length > 0) {
1565 return el('div', { className: 'metasync-language-alternates' },
1566 el('p', { className: 'metasync-language-alternates-help' },
1567 config.i18n.wpmlAutoPopulated || 'Auto-populated from WPML. Click Edit Manually to override.'
1568 ),
1569 el('table', { className: 'metasync-language-alternates-table' },
1570 el('thead', null,
1571 el('tr', null,
1572 el('th', null, config.i18n.langLabel || 'Language'),
1573 el('th', null, config.i18n.urlLabel || 'URL')
1574 )
1575 ),
1576 el('tbody', null,
1577 wpmlEntries.map(function(entry, i) {
1578 return el('tr', { key: 'wpml-' + i },
1579 el('td', null, entry.lang || ''),
1580 el('td', null,
1581 el('a', {
1582 href: entry.url,
1583 target: '_blank',
1584 rel: 'noopener noreferrer',
1585 }, entry.url || '')
1586 )
1587 );
1588 })
1589 )
1590 ),
1591 el(Button, {
1592 isSecondary: true,
1593 isSmall: true,
1594 onClick: startManualEdit,
1595 }, config.i18n.editManually || 'Edit Manually')
1596 );
1597 }
1598
1599 // Manual-edit view
1600 const editableRows = rows.length > 0 ? rows : [];
1601
1602 return el('div', { className: 'metasync-language-alternates' },
1603 editableRows.length === 0
1604 ? el('p', { className: 'metasync-language-alternates-help' },
1605 __('No language alternates yet. Use "Add alternate" to create one.', 'metasync'))
1606 : null,
1607 editableRows.map(function(row, index) {
1608 return el('div', { className: 'metasync-language-alternate-row', key: 'row-' + index },
1609 el(TextControl, {
1610 label: config.i18n.langLabel || 'Language',
1611 value: (row && row.lang) || '',
1612 onChange: function(value) { updateRow(index, 'lang', value); },
1613 placeholder: 'en',
1614 }),
1615 el(TextControl, {
1616 label: config.i18n.regionLabel || 'Region',
1617 value: (row && row.region) || '',
1618 onChange: function(value) { updateRow(index, 'region', value); },
1619 placeholder: 'us',
1620 }),
1621 el(TextControl, {
1622 label: config.i18n.urlLabel || 'URL',
1623 value: (row && row.url) || '',
1624 onChange: function(value) { updateRow(index, 'url', value); },
1625 placeholder: 'https://example.com/page/',
1626 }),
1627 el(Button, {
1628 isLink: true,
1629 isDestructive: true,
1630 isSmall: true,
1631 onClick: function() { removeRow(index); },
1632 }, __('Remove', 'metasync'))
1633 );
1634 }),
1635 el(Button, {
1636 isSecondary: true,
1637 isSmall: true,
1638 onClick: addRow,
1639 }, config.i18n.addAlternate || 'Add alternate')
1640 );
1641 };
1642
1643 /**
1644 * Advanced Robots Directives Panel Component (WP-197)
1645 * Allows per-post control of robots directives like nofollow, noarchive, etc.
1646 */
1647 const RobotsAdvancedPanel = () => {
1648 const metaKey = config.metaKeys.robotsAdvanced || '_metasync_robots_advanced';
1649 const i18nR = config.i18n || {};
1650
1651 const postId = useSelect(function(select) {
1652 return select('core/editor').getCurrentPostId();
1653 }, []);
1654
1655 // Local state for the form — initialized from DB on mount
1656 const [localState, setLocalState] = useState(null);
1657
1658 // Load initial value from DB on mount
1659 useEffect(function() {
1660 if (localState === null && postId) {
1661 apiFetch({ path: '/wp/v2/posts/' + postId + '?context=edit&_fields=meta' }).then(function(post) {
1662 var raw = (post.meta && post.meta[metaKey]) || '';
1663 var initial = {
1664 nofollow: false, noarchive: false, nosnippet: false, noimageindex: false,
1665 max_snippet: -1, max_image_preview: 'large',
1666 };
1667 if (raw) {
1668 try {
1669 var parsed = JSON.parse(raw);
1670 if (parsed && typeof parsed === 'object') {
1671 if (parsed.nofollow !== undefined) initial.nofollow = !!parsed.nofollow;
1672 if (parsed.noarchive !== undefined) initial.noarchive = !!parsed.noarchive;
1673 if (parsed.nosnippet !== undefined) initial.nosnippet = !!parsed.nosnippet;
1674 if (parsed.noimageindex !== undefined) initial.noimageindex = !!parsed.noimageindex;
1675 if (parsed.max_snippet !== undefined) initial.max_snippet = parsed.max_snippet;
1676 if (parsed.max_image_preview !== undefined) initial.max_image_preview = parsed.max_image_preview;
1677 }
1678 } catch (e) {}
1679 }
1680 setLocalState(initial);
1681 });
1682 }
1683 }, [postId, localState, metaKey]);
1684
1685 // Listen for legacy meta box changes → sync to sidebar
1686 useEffect(function() {
1687 var legacyIds = {
1688 'robots_common3': 'nofollow',
1689 'robots_common4': 'noarchive',
1690 'robots_common5': 'noimageindex',
1691 'robots_common6': 'nosnippet',
1692 };
1693 var handlers = [];
1694 Object.keys(legacyIds).forEach(function(elId) {
1695 var checkbox = document.getElementById(elId);
1696 if (checkbox) {
1697 var handler = function() {
1698 updateField(legacyIds[elId], checkbox.checked);
1699 };
1700 checkbox.addEventListener('change', handler);
1701 handlers.push({ el: checkbox, handler: handler });
1702 }
1703 });
1704 // Advanced fields
1705 var snippetVal = document.getElementById('advanced_robots_snippet_value');
1706 if (snippetVal) {
1707 var h = function() { updateField('max_snippet', parseInt(snippetVal.value, 10) || -1); };
1708 snippetVal.addEventListener('change', h);
1709 handlers.push({ el: snippetVal, handler: h });
1710 }
1711 var imageVal = document.getElementById('advanced_robots_image_value');
1712 if (imageVal) {
1713 var h2 = function() { updateField('max_image_preview', imageVal.value); };
1714 imageVal.addEventListener('change', h2);
1715 handlers.push({ el: imageVal, handler: h2 });
1716 }
1717 return function() {
1718 handlers.forEach(function(item) {
1719 item.el.removeEventListener('change', item.handler);
1720 });
1721 };
1722 }, []);
1723
1724 // Per-field save status tracking (hooks must be before any early return)
1725 const [savingField, setSavingField] = useState('');
1726 const [savedField, setSavedField] = useState('');
1727
1728 if (localState === null) {
1729 return el('div', { style: { padding: '12px 0', color: '#757575' } }, 'Loading...');
1730 }
1731
1732 var updateField = function(key, value) {
1733 var updated = Object.assign({}, localState);
1734 updated[key] = value;
1735 setLocalState(updated);
1736 syncToLegacyDOM(updated);
1737 setSavingField(key);
1738 setSavedField('');
1739 if (!postId) return;
1740 apiFetch({
1741 path: '/wp/v2/posts/' + postId,
1742 method: 'POST',
1743 data: { meta: { [metaKey]: JSON.stringify(updated) } },
1744 }).then(function() {
1745 setSavingField('');
1746 setSavedField(key);
1747 setTimeout(function() { setSavedField(''); }, 1500);
1748 }).catch(function() {
1749 setSavingField('');
1750 });
1751 };
1752
1753 // Sync sidebar state to legacy meta box DOM elements in real-time
1754 var syncToLegacyDOM = function(state) {
1755 // Common robots checkboxes (by ID)
1756 var checkboxMap = {
1757 nofollow: 'robots_common3',
1758 noarchive: 'robots_common4',
1759 noimageindex: 'robots_common5',
1760 nosnippet: 'robots_common6',
1761 };
1762 Object.keys(checkboxMap).forEach(function(key) {
1763 var el = document.getElementById(checkboxMap[key]);
1764 if (el) el.checked = !!state[key];
1765 });
1766
1767 // Advanced robots: max-snippet
1768 var snippetEnable = document.getElementById('advanced_robots_snippet');
1769 var snippetValue = document.getElementById('advanced_robots_snippet_value');
1770 if (snippetEnable && snippetValue) {
1771 var hasSnippet = state.max_snippet !== undefined && state.max_snippet !== null && state.max_snippet !== -1;
1772 snippetEnable.checked = hasSnippet;
1773 snippetValue.value = state.max_snippet !== undefined ? state.max_snippet : -1;
1774 }
1775
1776 // Advanced robots: max-image-preview
1777 var imageEnable = document.getElementById('advanced_robots_image');
1778 var imageValue = document.getElementById('advanced_robots_image_value');
1779 if (imageEnable && imageValue) {
1780 var hasImage = state.max_image_preview && state.max_image_preview !== 'large';
1781 imageEnable.checked = !!hasImage;
1782 imageValue.value = state.max_image_preview || 'large';
1783 }
1784 };
1785
1786 // Inline status badge for a field
1787 var fieldStatus = function(key) {
1788 if (savingField === key) {
1789 return el('span', { style: { fontSize: '11px', color: '#dba617', marginLeft: '8px' } }, 'Saving...');
1790 }
1791 if (savedField === key) {
1792 return el('span', { style: { fontSize: '11px', color: '#00a32a', marginLeft: '8px' } }, 'Saved');
1793 }
1794 return null;
1795 };
1796
1797 return el('div', { className: 'metasync-robots-advanced' },
1798 el('div', null,
1799 el(CheckboxControl, {
1800 label: el(wp.element.Fragment, null, (i18nR.nofollowLabel || 'Nofollow'), fieldStatus('nofollow')),
1801 help: i18nR.nofollowHelp || 'Prevent search engines from following links on this page.',
1802 checked: localState.nofollow,
1803 onChange: function(val) { updateField('nofollow', val); },
1804 })
1805 ),
1806 el('div', null,
1807 el(CheckboxControl, {
1808 label: el(wp.element.Fragment, null, (i18nR.noarchiveLabel || 'Noarchive'), fieldStatus('noarchive')),
1809 help: i18nR.noarchiveHelp || 'Prevent search engines from showing cached versions.',
1810 checked: localState.noarchive,
1811 onChange: function(val) { updateField('noarchive', val); },
1812 })
1813 ),
1814 el('div', null,
1815 el(CheckboxControl, {
1816 label: el(wp.element.Fragment, null, (i18nR.nosnippetLabel || 'Nosnippet'), fieldStatus('nosnippet')),
1817 help: i18nR.nosnippetHelp || 'Prevent search engines from showing text snippets.',
1818 checked: localState.nosnippet,
1819 onChange: function(val) { updateField('nosnippet', val); },
1820 })
1821 ),
1822 el('div', null,
1823 el(CheckboxControl, {
1824 label: el(wp.element.Fragment, null, (i18nR.noimageindexLabel || 'No Image Index'), fieldStatus('noimageindex')),
1825 help: i18nR.noimageindexHelp || 'Prevent this page from appearing as image search referrer.',
1826 checked: localState.noimageindex,
1827 onChange: function(val) { updateField('noimageindex', val); },
1828 })
1829 ),
1830 el('div', null,
1831 el('div', { style: { display: 'flex', alignItems: 'center', marginBottom: '4px' } },
1832 el('span', { style: { fontWeight: 500, fontSize: '11px', textTransform: 'uppercase' } }, i18nR.maxSnippetLabel || 'Max Snippet Length'),
1833 fieldStatus('max_snippet')
1834 ),
1835 el(TextControl, {
1836 help: i18nR.maxSnippetHelp || 'Maximum character length for text snippets. -1 for unlimited.',
1837 type: 'number',
1838 min: '-1',
1839 value: String(localState.max_snippet),
1840 onChange: function(val) { updateField('max_snippet', parseInt(val, 10) || -1); },
1841 })
1842 ),
1843 el('div', null,
1844 el('div', { style: { display: 'flex', alignItems: 'center', marginBottom: '4px' } },
1845 el('span', { style: { fontWeight: 500, fontSize: '11px', textTransform: 'uppercase' } }, i18nR.maxImagePreviewLabel || 'Max Image Preview'),
1846 fieldStatus('max_image_preview')
1847 ),
1848 el(SelectControl, {
1849 help: i18nR.maxImagePreviewHelp || 'Maximum size of image preview in search results.',
1850 value: localState.max_image_preview,
1851 options: [
1852 { label: 'Large', value: 'large' },
1853 { label: 'Standard', value: 'standard' },
1854 { label: 'None', value: 'none' },
1855 ],
1856 onChange: function(val) { updateField('max_image_preview', val); },
1857 })
1858 )
1859 );
1860 };
1861
1862 /**
1863 * Main Sidebar Component
1864 */
1865 /**
1866 * Plugin Sync Status Panel Component (WP-196)
1867 * Reads _metasync_plugin_sync_ts from post meta and displays sync status.
1868 */
1869 const PluginSyncStatusPanel = () => {
1870 const syncTsRaw = useSelect(function(select) {
1871 var metaKey = config.metaKeys.pluginSyncTs || '_metasync_plugin_sync_ts';
1872 return select('core/editor').getEditedPostAttribute('meta')[metaKey] || '';
1873 }, []);
1874
1875 var activeSeoPlugins = config.activeSeoPlugins || {};
1876 var syncI18n = config.i18n || {};
1877
1878 // No active SEO plugins detected -- nothing to show
1879 if (!activeSeoPlugins.yoast && !activeSeoPlugins.rankmath && !activeSeoPlugins.aioseo) {
1880 return null;
1881 }
1882
1883 var syncData = {};
1884 if (syncTsRaw) {
1885 try {
1886 syncData = JSON.parse(syncTsRaw);
1887 } catch (e) {
1888 syncData = {};
1889 }
1890 }
1891
1892 var formatRelativeTime = function(isoString) {
1893 if (!isoString) return syncI18n.syncNever || 'Never synced';
1894 var diff = Math.floor((Date.now() - new Date(isoString).getTime()) / 1000);
1895 if (diff < 60) return diff + 's ' + (syncI18n.syncAgo || 'ago');
1896 if (diff < 3600) return Math.floor(diff / 60) + 'm ' + (syncI18n.syncAgo || 'ago');
1897 if (diff < 86400) return Math.floor(diff / 3600) + 'h ' + (syncI18n.syncAgo || 'ago');
1898 return Math.floor(diff / 86400) + 'd ' + (syncI18n.syncAgo || 'ago');
1899 };
1900
1901 var pluginLabels = { yoast: 'Yoast SEO', rankmath: 'Rank Math', aioseo: 'AIOSEO' };
1902 var items = [];
1903 ['yoast', 'rankmath', 'aioseo'].forEach(function(slug) {
1904 if (!activeSeoPlugins[slug]) return;
1905 var ts = syncData[slug] || '';
1906 items.push(
1907 el('div', { key: slug, style: { display: 'flex', justifyContent: 'space-between', padding: '4px 0' } },
1908 el('span', { style: { fontWeight: 500 } }, pluginLabels[slug]),
1909 el('span', { style: { color: ts ? '#00a32a' : '#757575' } }, formatRelativeTime(ts))
1910 )
1911 );
1912 });
1913
1914 if (items.length === 0) return null;
1915
1916 return el('div', { className: 'metasync-plugin-sync-status' },
1917 el('p', { style: { fontWeight: 600, marginBottom: '8px' } }, syncI18n.syncedTo || 'Synced to:'),
1918 items
1919 );
1920 };
1921
1922 const MetaSyncSeoSidebar = () => {
1923 return el(PluginSidebar, {
1924 name: 'metasync-seo-sidebar',
1925 title: config.i18n.panelTitle,
1926 icon: MetaSyncIcon,
1927 },
1928 el('div', { className: 'metasync-seo-sidebar-content' },
1929 // Show notice when user has custom values and OTTO is enabled
1930 el(OttoOverrideNotice, null),
1931 el(PanelBody, {
1932 title: config.i18n.panelTitle,
1933 initialOpen: true,
1934 },
1935 el(SeoTitleInput, null),
1936 el(MetaDescriptionInput, null),
1937 el(UrlSlugInput, null),
1938 el(BreadcrumbTitleInput, null),
1939 el(PrimaryCategoryInjectPanel, null)
1940 ),
1941 el(PanelBody, {
1942 title: config.i18n.serpPreviewTitle,
1943 initialOpen: true,
1944 },
1945 el(SerpPreview, null)
1946 ),
1947 el(PanelBody, {
1948 title: schemaI18n.panelTitle || 'Schema Markup Content',
1949 initialOpen: false,
1950 },
1951 el(SchemaContentPanel, null)
1952 ),
1953 el(PanelBody, {
1954 title: config.i18n.languageAlternatesTitle || 'Language Alternates',
1955 initialOpen: false,
1956 },
1957 el(LanguageAlternatesPanel, null)
1958 ),
1959 el(PanelBody, {
1960 title: (config.linkSuggestions && config.linkSuggestions.i18n && config.linkSuggestions.i18n.panelTitle) || 'Internal Link Suggestions',
1961 initialOpen: false,
1962 },
1963 el(LinkSuggestionsPanel, null)
1964 ),
1965 el(PanelBody, {
1966 title: config.i18n.robotsAdvancedTitle || 'Advanced Robots Directives',
1967 initialOpen: false,
1968 },
1969 el(RobotsAdvancedPanel, null)
1970 ),
1971 el(PanelBody, {
1972 title: config.i18n.syncStatusTitle || 'Plugin Sync Status',
1973 initialOpen: true,
1974 },
1975 el(PluginSyncStatusPanel, null)
1976 )
1977 )
1978 );
1979 };
1980
1981 /**
1982 * Sidebar Menu Item Component
1983 */
1984 const MetaSyncSeoMenuItem = () => {
1985 return el(PluginSidebarMoreMenuItem, {
1986 target: 'metasync-seo-sidebar',
1987 icon: MetaSyncIcon,
1988 }, config.i18n.panelTitle);
1989 };
1990
1991 /**
1992 * Combined Plugin Component
1993 */
1994 const MetaSyncSeoPlugin = () => {
1995 return el(wp.element.Fragment, null,
1996 el(MetaSyncSeoSidebar, null),
1997 el(MetaSyncSeoMenuItem, null)
1998 );
1999 };
2000
2001 /**
2002 * Hook into the WordPress Categories taxonomy panel.
2003 * Wraps the original component and appends our Primary Category selector
2004 * directly below the category checkboxes — same UX as AIOSEO.
2005 */
2006 if (!hasOtherSeoPrimary) {
2007 wp.hooks.addFilter(
2008 'editor.PostTaxonomyType',
2009 'metasync/primary-category',
2010 function(OriginalComponent) {
2011 return function(props) {
2012 // Only inject into the 'category' taxonomy panel.
2013 if (props.slug !== 'category') {
2014 return el(OriginalComponent, props);
2015 }
2016
2017 return el(wp.element.Fragment, null,
2018 el(OriginalComponent, props),
2019 el(PrimaryCategoryInjectPanel, null)
2020 );
2021 };
2022 }
2023 );
2024 }
2025
2026 // Register the SEO sidebar plugin.
2027 registerPlugin('metasync-seo', {
2028 render: MetaSyncSeoPlugin,
2029 icon: MetaSyncIcon,
2030 });
2031
2032 })(window.wp);
2033
2034