PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.17
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.17
2.7.1 2.7.2 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 All 141 releases
metasync / admin / js / metasync-seo-sidebar.js

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

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