/**
* MetaSync SEO Sidebar for Gutenberg Block Editor
*
* Provides SEO Title and Meta Description inputs directly in the post editor sidebar.
*
* @package MetaSync
* @since 2.7.0
*/
(function(wp) {
'use strict';
const { registerPlugin } = wp.plugins;
const { PluginSidebar, PluginSidebarMoreMenuItem } = wp.editPost;
const { PanelBody, TextControl, TextareaControl, Button, ButtonGroup, SelectControl, CheckboxControl, Spinner, Notice } = wp.components;
const { useSelect, useDispatch, select: wpSelect, dispatch: wpDispatch } = wp.data;
const { useState, useEffect, useCallback, createElement: el } = wp.element;
const { __ } = wp.i18n;
const apiFetch = wp.apiFetch;
// Get configuration from PHP
const config = window.metasyncSeoSidebar || {
iconUrl: '',
otherSeoPrimary: {
yoastActive: false,
rankMathActive: false,
aioseoActive: false,
},
metaKeys: {
seoTitle: '_metasync_seo_title',
metaDescription: '_metasync_seo_desc',
// OTTO keys for fallback (read-only, used to prefill if manual fields are empty)
ottoTitle: '_metasync_otto_title',
ottoDescription: '_metasync_otto_description',
// OTTO disabled per-post flag
ottoDisabled: '_metasync_otto_disabled',
// Breadcrumb title override
breadcrumbTitle: '_metasync_breadcrumb_title',
// Primary category
primaryCategory: '_metasync_primary_category',
primaryProductCat: '_metasync_primary_product_cat',
// Language alternates (hreflang) — JSON-encoded array
hreflang: '_metasync_hreflang',
},
hasMetaKeys: {
// Whether the manual meta keys exist in database (from PHP check)
seoTitle: false,
metaDescription: false,
},
wpmlEntries: [],
otto: {
globalEnabled: false,
name: 'OTTO',
},
limits: {
seoTitle: { min: 50, max: 60, absolute: 70 },
metaDescription: { min: 120, max: 160, absolute: 200 },
},
i18n: {
panelTitle: 'MetaSync SEO',
seoTitleLabel: 'SEO Title',
seoTitleHelp: 'The title that appears in search engine results. Optimal length: 50-60 characters.',
metaDescriptionLabel: 'Meta Description',
metaDescriptionHelp: 'A brief description for search engine results. Optimal length: 120-160 characters.',
urlSlugLabel: 'URL Slug',
urlSlugHelp: 'The URL-friendly version of the post name. Use lowercase letters, numbers, and hyphens only.',
serpPreviewTitle: 'SERP Preview',
serpPreviewHelp: 'Preview how your page will appear in Google search results.',
serpDesktop: 'Desktop',
serpMobile: 'Mobile',
characters: 'characters',
primaryCategoryLabel: 'Primary Category',
primaryCategoryHelp: 'Used in breadcrumbs and canonical URL when multiple categories are assigned.',
primaryCategoryNote: 'Assign 2+ categories to enable this option.',
breadcrumbTitleLabel: 'Breadcrumb Title Override',
breadcrumbTitleHelp: 'Custom label for this page in breadcrumb trails. Leave empty to use the post title.',
ottoPrefillHelp: 'Pre-filled from OTTO. Edit to customize.',
ottoOverrideNotice: 'OTTO is enabled. Any SEO title and description changes from OTTO will be overwritten by your custom values entered here.',
// Language Alternates (hreflang) panel strings
languageAlternatesTitle: 'Language Alternates',
addAlternate: 'Add alternate',
langLabel: 'Language',
regionLabel: 'Region',
urlLabel: 'URL',
editManually: 'Edit Manually',
wpmlAutoPopulated: 'Auto-populated from WPML. Click Edit Manually to override.',
},
};
// Check if another SEO plugin already provides a primary category selector.
const otherSeo = config.otherSeoPrimary || {};
const hasOtherSeoPrimary = otherSeo.yoastActive || otherSeo.rankMathActive || otherSeo.aioseoActive;
/**
* Character Counter Component
* Displays character count with color-coded indicator
*/
const CharacterCounter = ({ count, min, max, absolute }) => {
let status = 'optimal';
let statusColor = '#00a32a'; // Green
if (count === 0) {
status = 'empty';
statusColor = '#757575'; // Gray
} else if (count < min) {
status = 'short';
statusColor = '#dba617'; // Yellow/Orange
} else if (count > absolute) {
status = 'too-long';
statusColor = '#d63638'; // Red
} else if (count > max) {
status = 'long';
statusColor = '#dba617'; // Yellow/Orange
}
const progressWidth = Math.min((count / absolute) * 100, 100);
return el('div', { className: 'metasync-char-counter' },
el('div', { className: 'metasync-char-counter-bar' },
el('div', {
className: 'metasync-char-counter-progress',
style: {
width: progressWidth + '%',
backgroundColor: statusColor,
},
})
),
el('span', {
className: 'metasync-char-counter-text',
style: { color: statusColor },
}, count + ' ' + config.i18n.characters)
);
};
/**
* OTTO Override Notice Component
* Shows an informational notice when OTTO is enabled (globally + per-post) and user has custom values
* Informs user that their custom values will take priority over OTTO
*/
const OttoOverrideNotice = () => {
// Check if OTTO is globally enabled
const isOttoGloballyEnabled = config.otto.globalEnabled;
// Get custom values and per-post OTTO disabled status
const { hasCustomTitle, hasCustomDescription, isOttoDisabledForPost } = useSelect((select) => {
const meta = select('core/editor').getEditedPostAttribute('meta') || {};
const ottoDisabledValue = meta[config.metaKeys.ottoDisabled] || '';
return {
hasCustomTitle: !!(meta[config.metaKeys.seoTitle] || '').trim(),
hasCustomDescription: !!(meta[config.metaKeys.metaDescription] || '').trim(),
isOttoDisabledForPost: ottoDisabledValue === '1' || ottoDisabledValue === 'true',
};
}, []);
// OTTO is active for this post if: globally enabled AND not disabled per-post
const isOttoActiveForPost = isOttoGloballyEnabled && !isOttoDisabledForPost;
// Only show notice if OTTO is active for this post AND user has custom values
if (!isOttoActiveForPost || (!hasCustomTitle && !hasCustomDescription)) {
return null;
}
return el('div', { className: 'metasync-otto-override-notice' },
el('div', { className: 'metasync-otto-notice-icon' },
el('svg', {
width: 20,
height: 20,
viewBox: '0 0 24 24',
fill: 'none',
xmlns: 'http://www.w3.org/2000/svg',
},
el('path', {
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',
fill: 'currentColor',
})
)
),
el('div', { className: 'metasync-otto-notice-content' },
el('strong', null, __('Custom Values Active', 'metasync')),
el('p', null, config.i18n.ottoOverrideNotice)
)
);
};
/**
* SEO Title Input Component
* Falls back to OTTO title only if manual field has never been set
*/
const SeoTitleInput = () => {
const metaKey = config.metaKeys.seoTitle;
const ottoKey = config.metaKeys.ottoTitle;
const limits = config.limits.seoTitle;
// Get both manual and OTTO values
// Use PHP-provided hasMetaKeys to check if meta key exists in database
const { manualValue, ottoValue } = useSelect((select) => {
const meta = select('core/editor').getEditedPostAttribute('meta') || {};
return {
manualValue: meta[metaKey] || '',
ottoValue: meta[ottoKey] || '',
};
}, [metaKey, ottoKey]);
const { editPost } = useDispatch('core/editor');
// Track if user has edited this field during this session
const [hasBeenEdited, setHasBeenEdited] = useState(false);
// Check if meta key exists in database (from PHP check)
const hasMetaKeyInDb = config.hasMetaKeys.seoTitle;
// Display value logic:
// - If user has edited during this session, show manual value (even if empty)
// - If manual value exists in database (even empty), show it
// - Otherwise show OTTO as prefill
const shouldShowOttoFallback = !hasBeenEdited && !hasMetaKeyInDb && ottoValue;
const displayValue = shouldShowOttoFallback ? ottoValue : (manualValue || '');
// Track if showing OTTO value (for visual indicator)
const isOttoValue = shouldShowOttoFallback;
const handleChange = (value) => {
// Mark as edited so we don't fallback to OTTO anymore
setHasBeenEdited(true);
// Always save to manual field
editPost({ meta: { [metaKey]: value } });
};
return el('div', { className: 'metasync-seo-field' },
el(TextControl, {
label: config.i18n.seoTitleLabel,
value: displayValue,
onChange: handleChange,
help: isOttoValue
? config.i18n.ottoPrefillHelp
: config.i18n.seoTitleHelp,
placeholder: __('Enter SEO title...', 'metasync'),
className: isOttoValue ? 'metasync-prefilled-otto' : '',
}),
el(CharacterCounter, {
count: displayValue.length,
min: limits.min,
max: limits.max,
absolute: limits.absolute,
})
);
};
/**
* Meta Description Input Component
* Falls back to OTTO description only if manual field has never been set
*/
const MetaDescriptionInput = () => {
const metaKey = config.metaKeys.metaDescription;
const ottoKey = config.metaKeys.ottoDescription;
const limits = config.limits.metaDescription;
// Get both manual and OTTO values
// Use PHP-provided hasMetaKeys to check if meta key exists in database
const { manualValue, ottoValue } = useSelect((select) => {
const meta = select('core/editor').getEditedPostAttribute('meta') || {};
return {
manualValue: meta[metaKey] || '',
ottoValue: meta[ottoKey] || '',
};
}, [metaKey, ottoKey]);
const { editPost } = useDispatch('core/editor');
// Track if user has edited this field during this session
const [hasBeenEdited, setHasBeenEdited] = useState(false);
// Check if meta key exists in database (from PHP check)
const hasMetaKeyInDb = config.hasMetaKeys.metaDescription;
// Display value logic:
// - If user has edited during this session, show manual value (even if empty)
// - If manual value exists in database (even empty), show it
// - Otherwise show OTTO as prefill
const shouldShowOttoFallback = !hasBeenEdited && !hasMetaKeyInDb && ottoValue;
const displayValue = shouldShowOttoFallback ? ottoValue : (manualValue || '');
// Track if showing OTTO value (for visual indicator)
const isOttoValue = shouldShowOttoFallback;
const handleChange = (value) => {
// Mark as edited so we don't fallback to OTTO anymore
setHasBeenEdited(true);
// Always save to manual field
editPost({ meta: { [metaKey]: value } });
};
return el('div', { className: 'metasync-seo-field' },
el(TextareaControl, {
label: config.i18n.metaDescriptionLabel,
value: displayValue,
onChange: handleChange,
help: isOttoValue
? config.i18n.ottoPrefillHelp
: config.i18n.metaDescriptionHelp,
placeholder: __('Enter meta description...', 'metasync'),
rows: 4,
className: isOttoValue ? 'metasync-prefilled-otto' : '',
}),
el(CharacterCounter, {
count: displayValue.length,
min: limits.min,
max: limits.max,
absolute: limits.absolute,
})
);
};
/**
* URL Slug Input Component
* Syncs with WordPress native post slug (permalink)
*/
const UrlSlugInput = () => {
// Get the current post slug and permalink
const { slug, link, postId } = useSelect((select) => {
const editor = select('core/editor');
return {
slug: editor.getEditedPostAttribute('slug') || '',
link: editor.getPermalink() || '',
postId: editor.getCurrentPostId(),
};
}, []);
const { editPost } = useDispatch('core/editor');
/**
* Sanitize slug to match WordPress permalink standards
* - Convert to lowercase
* - Replace spaces with hyphens
* - Remove special characters except hyphens
* - Remove multiple consecutive hyphens
*/
const sanitizeSlug = (value) => {
return value
.toLowerCase()
.replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/[^a-z0-9-]/g, '') // Remove special characters
.replace(/-+/g, '-') // Replace multiple hyphens with single
.replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
};
const handleChange = (value) => {
const sanitized = sanitizeSlug(value);
editPost({ slug: sanitized });
};
// Extract base URL for preview (remove the slug part)
const baseUrl = link ? link.replace(/[^/]+\/?$/, '') : '';
return el('div', { className: 'metasync-seo-field metasync-url-slug-field' },
el(TextControl, {
label: config.i18n.urlSlugLabel,
value: slug,
onChange: handleChange,
help: config.i18n.urlSlugHelp,
placeholder: __('enter-url-slug', 'metasync'),
}),
// Show permalink preview
link && el('div', { className: 'metasync-permalink-preview' },
el('span', { className: 'metasync-permalink-label' }, __('Permalink:', 'metasync') + ' '),
el('a', {
href: link,
target: '_blank',
rel: 'noopener noreferrer',
className: 'metasync-permalink-link',
},
el('span', { className: 'metasync-permalink-base' }, baseUrl),
el('strong', { className: 'metasync-permalink-slug' }, slug || __('(auto-generated)', 'metasync'))
)
)
);
};
/**
* SERP Preview Component
* Shows a real-time preview of how the page will appear in Google search results
*/
const SerpPreview = () => {
const [viewMode, setViewMode] = useState('desktop'); // 'desktop' or 'mobile'
// Get all the data needed for the preview (with OTTO fallbacks)
const { seoTitle, metaDescription, postTitle, permalink, excerpt } = useSelect((select) => {
const editor = select('core/editor');
const meta = editor.getEditedPostAttribute('meta') || {};
// Get manual values first, then fall back to OTTO values
const manualTitle = meta[config.metaKeys.seoTitle] || '';
const ottoTitle = meta[config.metaKeys.ottoTitle] || '';
const manualDesc = meta[config.metaKeys.metaDescription] || '';
const ottoDesc = meta[config.metaKeys.ottoDescription] || '';
return {
seoTitle: manualTitle || ottoTitle, // Manual > OTTO
metaDescription: manualDesc || ottoDesc, // Manual > OTTO
postTitle: editor.getEditedPostAttribute('title') || '',
permalink: editor.getPermalink() || '',
excerpt: editor.getEditedPostAttribute('excerpt') || '',
};
}, []);
// Determine display values with fallbacks
const displayTitle = seoTitle || postTitle || __('Page Title', 'metasync');
const displayDescription = metaDescription || excerpt || __('Add a meta description to control how your page appears in search results.', 'metasync');
// Format URL for display (remove protocol, truncate if needed)
const formatUrl = (url) => {
if (!url) return 'example.com';
try {
const urlObj = new URL(url);
let displayUrl = urlObj.hostname + urlObj.pathname;
// Remove trailing slash
displayUrl = displayUrl.replace(/\/$/, '');
return displayUrl;
} catch (e) {
return url;
}
};
// Truncate text with ellipsis
const truncate = (text, maxLength) => {
if (!text) return '';
if (text.length <= maxLength) return text;
return text.substring(0, maxLength).trim() + '...';
};
// Get display limits based on view mode
const titleLimit = viewMode === 'mobile' ? 55 : 60;
const descLimit = viewMode === 'mobile' ? 120 : 160;
const displayUrl = formatUrl(permalink);
const truncatedTitle = truncate(displayTitle, titleLimit);
const truncatedDescription = truncate(displayDescription, descLimit);
// Generate breadcrumb from URL
const getBreadcrumbs = (url) => {
if (!url) return [];
try {
const urlObj = new URL(url);
const pathParts = urlObj.pathname.split('/').filter(p => p);
if (pathParts.length === 0) return [urlObj.hostname];
return [urlObj.hostname, ...pathParts.slice(0, -1)];
} catch (e) {
return [];
}
};
const breadcrumbs = getBreadcrumbs(permalink);
return el('div', { className: 'metasync-serp-preview' },
// Header with toggle
el('div', { className: 'metasync-serp-header' },
el('span', { className: 'metasync-serp-label' }, config.i18n.serpPreviewTitle),
el(ButtonGroup, { className: 'metasync-serp-toggle' },
el(Button, {
isPrimary: viewMode === 'desktop',
isSecondary: viewMode !== 'desktop',
isSmall: true,
onClick: () => setViewMode('desktop'),
'aria-label': config.i18n.serpDesktop,
},
el('span', { className: 'dashicons dashicons-desktop' }),
' ',
config.i18n.serpDesktop
),
el(Button, {
isPrimary: viewMode === 'mobile',
isSecondary: viewMode !== 'mobile',
isSmall: true,
onClick: () => setViewMode('mobile'),
'aria-label': config.i18n.serpMobile,
},
el('span', { className: 'dashicons dashicons-smartphone' }),
' ',
config.i18n.serpMobile
)
)
),
// Google-style preview
el('div', {
className: 'metasync-serp-result ' + (viewMode === 'mobile' ? 'metasync-serp-mobile' : 'metasync-serp-desktop')
},
// Favicon and URL
el('div', { className: 'metasync-serp-url-row' },
el('div', { className: 'metasync-serp-favicon' },
el('div', { className: 'metasync-serp-favicon-placeholder' })
),
el('div', { className: 'metasync-serp-url-info' },
el('span', { className: 'metasync-serp-site-name' },
breadcrumbs[0] || 'example.com'
),
el('span', { className: 'metasync-serp-breadcrumb' },
breadcrumbs.length > 1 ? ' › ' + breadcrumbs.slice(1).join(' › ') : ''
)
)
),
// Title
el('div', { className: 'metasync-serp-title' }, truncatedTitle),
// Description
el('div', { className: 'metasync-serp-description' }, truncatedDescription)
),
// Help text
el('p', { className: 'metasync-serp-help' }, config.i18n.serpPreviewHelp)
);
};
/**
* Internal Link Suggestions Panel Component
* Fetches and displays internal link suggestions from the REST API.
* Uses Gutenberg block APIs to insert links safely within individual blocks.
*/
const LinkSuggestionsPanel = () => {
const [suggestions, setSuggestions] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [insertStatus, setInsertStatus] = useState(null);
const postId = useSelect((select) => {
return select('core/editor').getCurrentPostId();
}, []);
const blocks = useSelect((select) => {
return select('core/block-editor').getBlocks();
}, []);
const lsConfig = config.linkSuggestions || {};
const lsI18n = lsConfig.i18n || {};
const escapeRegex = (str) => {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
};
const fetchSuggestions = () => {
if (!postId || !lsConfig.restUrl) return;
setIsLoading(true);
setError(null);
setInsertStatus(null);
apiFetch({
url: lsConfig.restUrl + '?post_id=' + postId + '&limit=10',
}).then((response) => {
setSuggestions(response.suggestions || []);
setIsLoading(false);
}).catch((err) => {
setError(err.message || 'Failed to fetch suggestions');
setIsLoading(false);
});
};
useEffect(() => {
fetchSuggestions();
}, [postId]);
/**
* Get the plain text content of a block (strips HTML tags)
*/
const getBlockText = (html) => {
if (!html) return '';
var doc = new DOMParser().parseFromString(html, 'text/html');
return doc.body.textContent || '';
};
/**
* Get the text content attribute from a block (handles different block types)
*/
const getBlockContentHtml = (block) => {
if (!block || !block.attributes) return '';
// core/paragraph, core/heading, core/preformatted, core/verse use 'content'
// core/list (older) uses 'values'
// core/list-item uses 'content'
return block.attributes.content || block.attributes.values || '';
};
/**
* Flatten all blocks including nested innerBlocks into a single list
*/
const flattenBlocks = (blockList) => {
var result = [];
if (!blockList) return result;
for (var i = 0; i < blockList.length; i++) {
result.push(blockList[i]);
if (blockList[i].innerBlocks && blockList[i].innerBlocks.length > 0) {
result = result.concat(flattenBlocks(blockList[i].innerBlocks));
}
}
return result;
};
/**
* Check if a position in HTML is inside an HTML tag (i.e., between < and >)
* This prevents matching text inside attributes like alt="...", title="...", etc.
*/
const isInsideHtmlTag = (html, index) => {
// Look backwards from the match position for < or >
for (var i = index - 1; i >= 0; i--) {
if (html[i] === '>') return false; // Closed tag before us — we're outside
if (html[i] === '<') return true; // Open tag before us — we're inside a tag
}
return false;
};
/**
* Check if a position in HTML is inside an ... element
*/
const isInsideAnchorTag = (html, index) => {
var before = html.substring(0, index);
var openA = (before.match(//gi) || []).length;
return openA > closeA;
};
/**
* Check if phrase exists in any block and is not already linked
*/
const isPhraseUnlinked = useCallback((phrase) => {
if (!phrase || !blocks || blocks.length === 0) return false;
var escaped = escapeRegex(phrase);
var regex = new RegExp(escaped, 'gi');
var allBlocks = flattenBlocks(blocks);
for (var i = 0; i < allBlocks.length; i++) {
var block = allBlocks[i];
var html = getBlockContentHtml(block);
if (!html) continue;
var text = getBlockText(html);
if (!(new RegExp(escaped, 'i')).test(text)) continue;
// Search in raw HTML for an occurrence that is:
// 1. NOT inside an HTML tag attribute
// 2. NOT inside an tag
var match;
regex.lastIndex = 0;
while ((match = regex.exec(html)) !== null) {
if (!isInsideHtmlTag(html, match.index) && !isInsideAnchorTag(html, match.index)) {
return true;
}
}
regex.lastIndex = 0;
}
return false;
}, [blocks]);
/**
* Insert a link using Gutenberg's block API
* Finds the specific block containing the phrase and updates only that block
*/
const insertLink = (suggestion) => {
var phrase = suggestion.matched_phrase;
var url = suggestion.url;
if (!phrase || !url) return;
var escaped = escapeRegex(phrase);
var searchRegex = new RegExp(escaped, 'gi');
// Sanitize URL for safe HTML insertion
var safeUrl = url.replace(/"/g, '"').replace(//g, '>');
// Find the block that contains this phrase (unlinked), including nested blocks
var currentBlocks = flattenBlocks(wpSelect('core/block-editor').getBlocks());
for (var i = 0; i < currentBlocks.length; i++) {
var block = currentBlocks[i];
var html = getBlockContentHtml(block);
if (!html) continue;
// Check if phrase exists in this block's text
var text = getBlockText(html);
if (!searchRegex.test(text)) {
searchRegex.lastIndex = 0;
continue;
}
searchRegex.lastIndex = 0;
// Find the first unlinked occurrence in the HTML
var match;
var newHtml = html;
var replaced = false;
while ((match = searchRegex.exec(html)) !== null) {
// Skip matches inside HTML tag attributes (alt, title, src, etc.)
if (isInsideHtmlTag(html, match.index)) continue;
// Skip matches inside existing tags
if (isInsideAnchorTag(html, match.index)) continue;
// This occurrence is safe to wrap
var originalPhrase = html.substring(match.index, match.index + match[0].length);
newHtml = html.substring(0, match.index) +
'' + originalPhrase + '' +
html.substring(match.index + match[0].length);
replaced = true;
break;
}
searchRegex.lastIndex = 0;
if (replaced) {
// Update ONLY this specific block, not the entire post content
// Use the correct attribute key (content vs values for list blocks)
var attrKey = (block.attributes && block.attributes.values !== undefined && !block.attributes.content) ? 'values' : 'content';
var updateAttrs = {};
updateAttrs[attrKey] = newHtml;
wpDispatch('core/block-editor').updateBlockAttributes(block.clientId, updateAttrs);
// Remove the inserted suggestion from the list
setSuggestions(function(prev) {
return prev.filter(function(s) {
return s.post_id !== suggestion.post_id;
});
});
setInsertStatus('Linked: "' + phrase + '"');
setTimeout(function() { setInsertStatus(null); }, 3000);
return;
}
}
// If we got here, couldn't find a suitable block
setInsertStatus('Could not find the phrase in any content block.');
setTimeout(function() { setInsertStatus(null); }, 3000);
};
const truncateUrl = (url, maxLen) => {
if (!url) return '';
if (url.length <= maxLen) return url;
return url.substring(0, maxLen) + '\u2026';
};
// Build panel children
var children = [];
// Header with Refresh button
children.push(
el('div', { className: 'metasync-link-suggestions-header', key: 'header' },
el('span', null, ''),
el(Button, {
isSmall: true,
isSecondary: true,
onClick: fetchSuggestions,
disabled: isLoading,
}, lsI18n.refreshButton || 'Refresh')
)
);
// Cross-plugin notices
if (lsConfig.yoastPremiumActive) {
children.push(
el('div', { className: 'metasync-link-suggestions-notice', key: 'yoast-notice' },
lsI18n.yoastNotice || ''
)
);
}
if (lsConfig.rankMathActive) {
children.push(
el('div', { className: 'metasync-link-suggestions-notice', key: 'rankmath-notice' },
lsI18n.rankMathNotice || ''
)
);
}
// Insert status feedback
if (insertStatus) {
children.push(
el('div', {
className: 'metasync-link-suggestions-notice',
key: 'insert-status',
style: { color: '#00a32a', fontWeight: 600 },
}, insertStatus)
);
}
if (isLoading) {
children.push(el(Spinner, { key: 'spinner' }));
} else if (error) {
children.push(
el('div', { className: 'metasync-link-suggestions-empty', key: 'error' }, error)
);
} else if (suggestions.length === 0) {
children.push(
el('div', { className: 'metasync-link-suggestions-empty', key: 'empty' },
lsI18n.noSuggestions || 'No suggestions found.'
)
);
} else {
var items = suggestions.map(function(suggestion) {
var canInsert = isPhraseUnlinked(suggestion.matched_phrase);
return el('li', {
className: 'metasync-link-suggestion-item',
key: suggestion.post_id,
},
el('span', { className: 'metasync-suggestion-title' }, suggestion.title),
el('span', { className: 'metasync-suggestion-url' }, truncateUrl(suggestion.url, 40)),
el('span', { className: 'metasync-suggestion-phrase' },
(lsI18n.matchedPhrase || 'Matched phrase:') + ' ',
el('strong', null, suggestion.matched_phrase)
),
suggestion.relevance_score ? el('span', {
className: 'metasync-suggestion-score',
style: { fontSize: '11px', color: '#646970', display: 'block', marginBottom: '4px' },
}, 'Relevance: ' + Math.round(suggestion.relevance_score * 100) + '%') : null,
el(Button, {
isSmall: true,
isPrimary: true,
onClick: function() { insertLink(suggestion); },
disabled: !canInsert,
}, lsI18n.insertButton || 'Insert')
);
});
children.push(
el('ul', { className: 'metasync-link-suggestions-list', key: 'list' }, items)
);
}
return el('div', null, children);
};
// =========================================================================
// Schema Content Panel Components
// =========================================================================
const schemaConfig = config.schemaContent || {};
const schemaI18n = schemaConfig.i18n || {};
/**
* FAQ Panel - manages Q&A items
*/
const FAQPanel = ({ fields, onChange }) => {
const items = (fields && fields.faq_items) || [];
const updateItem = (index, key, value) => {
const next = items.slice();
next[index] = Object.assign({}, next[index], { [key]: value });
onChange(Object.assign({}, fields, { faq_items: next }));
};
const addItem = () => {
const next = items.slice();
next.push({ question: '', answer: '' });
onChange(Object.assign({}, fields, { faq_items: next }));
};
const removeItem = (index) => {
const next = items.slice();
next.splice(index, 1);
onChange(Object.assign({}, fields, { faq_items: next }));
};
return el('div', { className: 'metasync-schema-faq-panel' },
items.map((item, i) =>
el('div', { key: 'faq-' + i, className: 'metasync-schema-repeater-row', style: { marginBottom: '12px', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' } },
el(TextControl, {
label: __('Question', 'metasync') + ' ' + (i + 1),
value: item.question || '',
onChange: (v) => updateItem(i, 'question', v),
}),
el(TextareaControl, {
label: __('Answer', 'metasync'),
value: item.answer || '',
onChange: (v) => updateItem(i, 'answer', v),
rows: 3,
}),
el(Button, {
isDestructive: true,
isSmall: true,
isLink: true,
onClick: () => removeItem(i),
}, schemaI18n.removeQuestion || 'Remove')
)
),
el(Button, {
isSecondary: true,
isSmall: true,
onClick: addItem,
}, schemaI18n.addQuestion || 'Add Question')
);
};
/**
* HowTo Panel - manages steps list
*/
const HowToPanel = ({ fields, onChange }) => {
const steps = (fields && fields.steps) || [];
const updateStep = (index, key, value) => {
const next = steps.slice();
next[index] = Object.assign({}, next[index], { [key]: value });
onChange(Object.assign({}, fields, { steps: next }));
};
const addStep = () => {
const next = steps.slice();
next.push({ instructions: '', image: '' });
onChange(Object.assign({}, fields, { steps: next }));
};
const removeStep = (index) => {
const next = steps.slice();
next.splice(index, 1);
onChange(Object.assign({}, fields, { steps: next }));
};
return el('div', { className: 'metasync-schema-howto-panel' },
el(TextControl, {
label: __('Total Time (minutes)', 'metasync'),
value: (fields && fields.total_time) || '',
onChange: (v) => onChange(Object.assign({}, fields, { total_time: v })),
type: 'number',
}),
steps.map((step, i) =>
el('div', { key: 'step-' + i, className: 'metasync-schema-repeater-row', style: { marginBottom: '12px', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' } },
el(TextareaControl, {
label: __('Step', 'metasync') + ' ' + (i + 1) + ' — ' + __('Instructions', 'metasync'),
value: step.instructions || '',
onChange: (v) => updateStep(i, 'instructions', v),
rows: 2,
}),
el(TextControl, {
label: __('Image URL (optional)', 'metasync'),
value: step.image || '',
onChange: (v) => updateStep(i, 'image', v),
}),
el(Button, {
isDestructive: true,
isSmall: true,
isLink: true,
onClick: () => removeStep(i),
}, schemaI18n.removeStep || 'Remove')
)
),
el(Button, {
isSecondary: true,
isSmall: true,
onClick: addStep,
}, schemaI18n.addStep || 'Add Step')
);
};
/**
* Product Panel - price, currency, availability, condition, SKU, brand
*/
const ProductPanel = ({ fields, onChange, woocommerceActive, woocommerceData }) => {
const f = fields || {};
const update = (key, value) => {
onChange(Object.assign({}, f, { [key]: value }));
};
const autoPopulateWC = () => {
if (woocommerceData) {
onChange(Object.assign({}, f, {
price: woocommerceData.price || f.price || '',
currency: woocommerceData.currency || f.currency || 'USD',
availability: woocommerceData.availability || f.availability || 'InStock',
sku: woocommerceData.sku || f.sku || '',
}));
}
};
return el('div', { className: 'metasync-schema-product-panel' },
woocommerceActive && woocommerceData && el(Button, {
isSecondary: true,
isSmall: true,
onClick: autoPopulateWC,
style: { marginBottom: '12px' },
}, schemaI18n.autoPopulateWC || 'Auto-populate from WooCommerce'),
el(TextControl, {
label: __('Price', 'metasync'),
value: f.price || '',
onChange: (v) => update('price', v),
type: 'number',
}),
el(SelectControl, {
label: __('Currency', 'metasync'),
value: f.currency || 'USD',
options: [
{ label: 'USD', value: 'USD' },
{ label: 'EUR', value: 'EUR' },
{ label: 'GBP', value: 'GBP' },
{ label: 'CAD', value: 'CAD' },
{ label: 'AUD', value: 'AUD' },
],
onChange: (v) => update('currency', v),
}),
el(SelectControl, {
label: __('Availability', 'metasync'),
value: f.availability || 'InStock',
options: [
{ label: 'In Stock', value: 'InStock' },
{ label: 'Out of Stock', value: 'OutOfStock' },
{ label: 'Pre-Order', value: 'PreOrder' },
],
onChange: (v) => update('availability', v),
}),
el(SelectControl, {
label: __('Condition', 'metasync'),
value: f.condition || 'NewCondition',
options: [
{ label: 'New', value: 'NewCondition' },
{ label: 'Used', value: 'UsedCondition' },
{ label: 'Refurbished', value: 'RefurbishedCondition' },
],
onChange: (v) => update('condition', v),
}),
el(TextControl, {
label: __('SKU', 'metasync'),
value: f.sku || '',
onChange: (v) => update('sku', v),
}),
el(TextControl, {
label: __('Brand', 'metasync'),
value: f.brand || '',
onChange: (v) => update('brand', v),
})
);
};
/**
* Recipe Panel - yield, times, calories, ingredients, instructions
*/
const RecipePanel = ({ fields, onChange }) => {
const f = fields || {};
const ingredients = f.ingredients || [];
const instructions = f.instructions || [];
const update = (key, value) => {
onChange(Object.assign({}, f, { [key]: value }));
};
const updateIngredient = (index, value) => {
const next = ingredients.slice();
next[index] = value;
update('ingredients', next);
};
const addIngredient = () => {
const next = ingredients.slice();
next.push('');
update('ingredients', next);
};
const removeIngredient = (index) => {
const next = ingredients.slice();
next.splice(index, 1);
update('ingredients', next);
};
const updateInstruction = (index, value) => {
const next = instructions.slice();
next[index] = { text: value };
update('instructions', next);
};
const addInstruction = () => {
const next = instructions.slice();
next.push({ text: '' });
update('instructions', next);
};
const removeInstruction = (index) => {
const next = instructions.slice();
next.splice(index, 1);
update('instructions', next);
};
return el('div', { className: 'metasync-schema-recipe-panel' },
el(TextControl, {
label: __('Yield (servings)', 'metasync'),
value: f.yield || '',
onChange: (v) => update('yield', v),
}),
el(TextControl, {
label: __('Prep Time (minutes)', 'metasync'),
value: f.prep_time || '',
onChange: (v) => update('prep_time', v),
type: 'number',
}),
el(TextControl, {
label: __('Cook Time (minutes)', 'metasync'),
value: f.cook_time || '',
onChange: (v) => update('cook_time', v),
type: 'number',
}),
el(TextControl, {
label: __('Total Time (minutes)', 'metasync'),
value: f.total_time || '',
onChange: (v) => update('total_time', v),
type: 'number',
}),
el(TextControl, {
label: __('Calories', 'metasync'),
value: f.calories || '',
onChange: (v) => update('calories', v),
type: 'number',
}),
el('h4', { style: { marginTop: '12px', marginBottom: '4px' } }, __('Ingredients', 'metasync')),
ingredients.map((ing, i) =>
el('div', { key: 'ing-' + i, style: { display: 'flex', alignItems: 'center', gap: '4px', marginBottom: '4px' } },
el(TextControl, {
value: typeof ing === 'string' ? ing : (ing || ''),
onChange: (v) => updateIngredient(i, v),
placeholder: __('Ingredient', 'metasync'),
style: { flex: 1 },
}),
el(Button, {
isDestructive: true,
isSmall: true,
isLink: true,
onClick: () => removeIngredient(i),
}, schemaI18n.removeIngredient || 'Remove')
)
),
el(Button, {
isSecondary: true,
isSmall: true,
onClick: addIngredient,
style: { marginBottom: '12px' },
}, schemaI18n.addIngredient || 'Add Ingredient'),
el('h4', { style: { marginTop: '12px', marginBottom: '4px' } }, __('Instructions', 'metasync')),
instructions.map((inst, i) => {
var instText = typeof inst === 'string' ? inst : ((inst && inst.text) || '');
return el('div', { key: 'inst-' + i, style: { display: 'flex', alignItems: 'flex-start', gap: '4px', marginBottom: '4px' } },
el(TextareaControl, {
value: instText,
onChange: (v) => updateInstruction(i, v),
placeholder: __('Step', 'metasync') + ' ' + (i + 1),
rows: 2,
style: { flex: 1 },
}),
el(Button, {
isDestructive: true,
isSmall: true,
isLink: true,
onClick: () => removeInstruction(i),
}, schemaI18n.removeInstruction || 'Remove')
);
}),
el(Button, {
isSecondary: true,
isSmall: true,
onClick: addInstruction,
}, schemaI18n.addInstruction || 'Add Instruction')
);
};
/**
* Schema Content Panel
* Main panel that fetches schema data and renders type-specific sub-panels
*/
const SchemaContentPanel = () => {
const [schemaData, setSchemaData] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [isSavingSchema, setIsSavingSchema] = useState(false);
const [saveNotice, setSaveNotice] = useState(null);
const [validationWarnings, setValidationWarnings] = useState([]);
const [pendingChanges, setPendingChanges] = useState({});
// Only true after the user has actually edited a field; prevents firing
// extra REST calls on every post save when nothing has changed.
const [isDirty, setIsDirty] = useState(false);
const postId = useSelect((select) => {
return select('core/editor').getCurrentPostId();
}, []);
const isSavingPost = useSelect((select) => {
return select('core/editor').isSavingPost();
}, []);
const restUrl = schemaConfig.restUrl || '';
// Fetch schema content on mount
useEffect(() => {
if (!postId || !restUrl) return;
setIsLoading(true);
apiFetch({
url: restUrl + '/' + postId,
}).then((response) => {
setSchemaData(response);
// Initialize pending changes from fetched data
var initial = {};
if (response && response.types) {
response.types.forEach(function(t) {
initial[t.type] = t.fields || {};
});
}
setPendingChanges(initial);
setIsLoading(false);
}).catch(() => {
setIsLoading(false);
});
}, [postId]);
// Save schema content — serialises requests to avoid lost-update race condition
const saveSchemaContent = useCallback(() => {
if (!postId || !restUrl) return;
var types = Object.keys(pendingChanges);
if (types.length === 0) return;
setIsSavingSchema(true);
setSaveNotice(null);
setValidationWarnings([]);
var allWarnings = [];
// Chain requests sequentially so each POST reads the latest DB state
var chain = types.reduce(function(promise, schemaType) {
return promise.then(function() {
return apiFetch({
url: restUrl + '/' + postId,
method: 'POST',
data: {
schema_type: schemaType,
fields: pendingChanges[schemaType],
},
}).then(function(resp) {
if (resp.validation_warnings && resp.validation_warnings.length > 0) {
allWarnings = allWarnings.concat(resp.validation_warnings);
}
});
});
}, Promise.resolve());
chain.then(function() {
setIsSavingSchema(false);
setIsDirty(false);
setValidationWarnings(allWarnings);
setSaveNotice({ type: 'success', message: schemaI18n.saved || 'Schema content saved.' });
setTimeout(function() { setSaveNotice(null); }, 4000);
}).catch(function() {
setIsSavingSchema(false);
setSaveNotice({ type: 'error', message: schemaI18n.saveError || 'Failed to save schema content.' });
setTimeout(function() { setSaveNotice(null); }, 4000);
});
}, [postId, restUrl, pendingChanges]);
// Auto-save on post save — only when the user has edited schema fields
const [wasSaving, setWasSaving] = useState(false);
useEffect(() => {
if (isSavingPost && !wasSaving) {
setWasSaving(true);
}
if (!isSavingPost && wasSaving) {
setWasSaving(false);
if (isDirty) {
saveSchemaContent();
}
}
}, [isSavingPost, wasSaving, isDirty, saveSchemaContent]);
const updateTypeFields = (schemaType, newFields) => {
setIsDirty(true);
setPendingChanges(function(prev) {
var next = Object.assign({}, prev);
next[schemaType] = newFields;
return next;
});
};
// Build panel content
var children = [];
if (isLoading) {
children.push(el(Spinner, { key: 'loading' }));
} else if (!schemaData || !schemaData.types || schemaData.types.length === 0) {
children.push(
el('p', { key: 'no-types', style: { color: '#757575', fontStyle: 'italic' } },
schemaI18n.noSchemaTypes || 'No schema types configured.'
)
);
} else {
schemaData.types.forEach(function(typeData) {
var schemaType = typeData.type;
var currentFields = pendingChanges[schemaType] || typeData.fields || {};
var onChangeFields = function(newFields) {
updateTypeFields(schemaType, newFields);
};
var subPanel = null;
switch (schemaType) {
case 'FAQPage':
subPanel = el(FAQPanel, { key: 'faq', fields: currentFields, onChange: onChangeFields });
break;
case 'HowTo':
subPanel = el(HowToPanel, { key: 'howto', fields: currentFields, onChange: onChangeFields });
break;
case 'product':
subPanel = el(ProductPanel, {
key: 'product',
fields: currentFields,
onChange: onChangeFields,
woocommerceActive: schemaConfig.woocommerceActive || false,
woocommerceData: schemaConfig.woocommerceData || null,
});
break;
case 'recipe':
subPanel = el(RecipePanel, { key: 'recipe', fields: currentFields, onChange: onChangeFields });
break;
default:
subPanel = el('p', { key: 'unsupported-' + schemaType, style: { color: '#757575' } },
__('Content editing for', 'metasync') + ' ' + schemaType + ' ' + __('is available in the classic editor.', 'metasync')
);
break;
}
children.push(
el(PanelBody, {
key: 'schema-type-' + schemaType,
title: schemaType,
initialOpen: true,
}, subPanel)
);
});
// Validation warnings
if (validationWarnings.length > 0) {
children.push(
el(Notice, {
key: 'validation-warnings',
status: 'warning',
isDismissible: false,
style: { marginTop: '8px' },
},
el('ul', { style: { margin: 0, paddingLeft: '16px' } },
validationWarnings.map(function(w, i) {
var msg = (typeof w === 'string') ? w : (w.message || w.error || JSON.stringify(w));
return el('li', { key: 'warn-' + i }, msg);
})
)
)
);
}
// Save notice
if (saveNotice) {
children.push(
el(Notice, {
key: 'save-notice',
status: saveNotice.type === 'success' ? 'success' : 'error',
isDismissible: false,
style: { marginTop: '8px' },
}, saveNotice.message)
);
}
// Save button
children.push(
el(Button, {
key: 'save-btn',
isPrimary: true,
onClick: saveSchemaContent,
disabled: isSavingSchema,
style: { marginTop: '12px' },
}, isSavingSchema ? (schemaI18n.saving || 'Saving...') : (schemaI18n.saveButton || 'Save Schema Content'))
);
}
return el('div', { className: 'metasync-schema-content-panel' }, children);
};
/**
* MetaSync SEO Sidebar Icon
* Uses external SVG from admin/images/icon-256x256.svg
*/
const MetaSyncIcon = config.iconUrl
? el('img', {
src: config.iconUrl,
alt: 'MetaSync SEO',
width: 20,
height: 20,
style: {
display: 'block',
objectFit: 'contain',
},
})
: el('svg', {
width: 20,
height: 20,
viewBox: '0 0 24 24',
fill: 'none',
xmlns: 'http://www.w3.org/2000/svg',
},
el('path', {
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',
fill: 'currentColor',
})
);
/**
* Breadcrumb Title Override Component
* Now rendered inside the MetaSync SEO sidebar panel.
*/
const BreadcrumbTitleInput = () => {
const metaKey = config.metaKeys.breadcrumbTitle || '_metasync_breadcrumb_title';
const { value } = useSelect((select) => {
const meta = select('core/editor').getEditedPostAttribute('meta') || {};
return { value: meta[metaKey] || '' };
}, [metaKey]);
const { editPost } = useDispatch('core/editor');
const handleChange = (newValue) => {
editPost({ meta: { [metaKey]: newValue } });
};
return el(TextControl, {
label: config.i18n.breadcrumbTitleLabel || 'Breadcrumb Title Override',
value: value,
onChange: handleChange,
help: config.i18n.breadcrumbTitleHelp || 'Custom label for this page in breadcrumb trails.',
placeholder: __('Leave empty to use post title', 'metasync'),
});
};
/**
* Primary Category Selector Component
*
* Rendered in two places:
* 1. Inside the MetaSync SEO sidebar panel
* 2. Injected below the WordPress Categories checklist (via editor.PostTaxonomyType filter)
*
* Hidden from the Categories panel when Yoast, Rank Math, or AIOSEO is active
* (those plugins provide their own selector), but always available in the SEO sidebar.
*/
const PrimaryCategoryInjectPanel = () => {
const metaKey = config.metaKeys.primaryCategory || '_metasync_primary_category';
const { primaryCategoryId, postCategories, allCategories } = useSelect((select) => {
const editor = select('core/editor');
const meta = editor.getEditedPostAttribute('meta') || {};
const assignedCatIds = editor.getEditedPostAttribute('categories') || [];
let cats = [];
if (assignedCatIds.length > 0) {
const allCats = select('core').getEntityRecords('taxonomy', 'category', {
include: assignedCatIds,
per_page: 100,
});
if (allCats) {
cats = allCats;
}
}
return {
primaryCategoryId: meta[metaKey] || 0,
postCategories: assignedCatIds,
allCategories: cats,
};
}, [metaKey]);
const { editPost } = useDispatch('core/editor');
if (postCategories.length < 2) {
return null;
}
// Wait for categories to resolve from the store.
if (!allCategories || allCategories.length < 2) {
return null;
}
const options = [
{ label: __('— Auto (first category) —', 'metasync'), value: 0 },
];
allCategories.forEach(function(cat) {
options.push({ label: cat.name, value: cat.id });
});
return el('div', { className: 'metasync-primary-category-panel' },
el(SelectControl, {
label: config.i18n.primaryCategoryLabel || 'Primary Category',
value: primaryCategoryId,
options: options,
onChange: function(value) {
editPost({ meta: { [metaKey]: parseInt(value, 10) || 0 } });
},
help: config.i18n.primaryCategoryHelp || 'Used in breadcrumbs and canonical URL when multiple categories are assigned.',
})
);
};
/**
* Language Alternates (hreflang) Panel Component
*
* Renders the list of hreflang entries (read from the _metasync_hreflang
* post meta as a JSON array of {lang, region, url} objects).
*
* When WPML auto-populated entries are available and the user has not
* overridden them, renders them as read-only rows with an "Edit Manually"
* button that switches to manual-edit mode. In manual-edit mode (and
* when WPML is not active), renders editable rows plus an "Add alternate"
* button.
*/
const LanguageAlternatesPanel = () => {
const metaKey = config.metaKeys.hreflang || '_metasync_hreflang';
const wpmlEntries = Array.isArray(config.wpmlEntries) ? config.wpmlEntries : [];
const { rawValue } = useSelect((select) => {
const meta = select('core/editor').getEditedPostAttribute('meta') || {};
return { rawValue: meta[metaKey] || '' };
}, [metaKey]);
const { editPost } = useDispatch('core/editor');
let rows = [];
if (rawValue) {
try {
const parsed = JSON.parse(rawValue);
if (Array.isArray(parsed)) {
rows = parsed;
}
} catch (e) {
rows = [];
}
}
// Manual mode is active when the user explicitly overrode WPML
// (tracked in state for the current session) or when manual rows
// already exist in the stored meta.
const [manualMode, setManualMode] = useState(rows.length > 0);
const saveRows = (nextRows) => {
editPost({ meta: { [metaKey]: JSON.stringify(nextRows) } });
};
const updateRow = (index, field, value) => {
const next = rows.slice();
next[index] = Object.assign({}, next[index] || { lang: '', region: '', url: '' });
next[index][field] = value;
saveRows(next);
};
const addRow = () => {
const next = rows.slice();
next.push({ lang: '', region: '', url: '' });
saveRows(next);
};
const removeRow = (index) => {
const next = rows.slice();
next.splice(index, 1);
saveRows(next);
};
const startManualEdit = () => {
// Seed manual rows from the WPML entries so the user can tweak
// rather than re-enter everything.
if (rows.length === 0 && wpmlEntries.length > 0) {
const seeded = wpmlEntries.map(function(e) {
return { lang: e.lang || '', region: '', url: e.url || '' };
});
saveRows(seeded);
}
setManualMode(true);
};
// WPML auto-populated, read-only view
if (!manualMode && wpmlEntries.length > 0) {
return el('div', { className: 'metasync-language-alternates' },
el('p', { className: 'metasync-language-alternates-help' },
config.i18n.wpmlAutoPopulated || 'Auto-populated from WPML. Click Edit Manually to override.'
),
el('table', { className: 'metasync-language-alternates-table' },
el('thead', null,
el('tr', null,
el('th', null, config.i18n.langLabel || 'Language'),
el('th', null, config.i18n.urlLabel || 'URL')
)
),
el('tbody', null,
wpmlEntries.map(function(entry, i) {
return el('tr', { key: 'wpml-' + i },
el('td', null, entry.lang || ''),
el('td', null,
el('a', {
href: entry.url,
target: '_blank',
rel: 'noopener noreferrer',
}, entry.url || '')
)
);
})
)
),
el(Button, {
isSecondary: true,
isSmall: true,
onClick: startManualEdit,
}, config.i18n.editManually || 'Edit Manually')
);
}
// Manual-edit view
const editableRows = rows.length > 0 ? rows : [];
return el('div', { className: 'metasync-language-alternates' },
editableRows.length === 0
? el('p', { className: 'metasync-language-alternates-help' },
__('No language alternates yet. Use "Add alternate" to create one.', 'metasync'))
: null,
editableRows.map(function(row, index) {
return el('div', { className: 'metasync-language-alternate-row', key: 'row-' + index },
el(TextControl, {
label: config.i18n.langLabel || 'Language',
value: (row && row.lang) || '',
onChange: function(value) { updateRow(index, 'lang', value); },
placeholder: 'en',
}),
el(TextControl, {
label: config.i18n.regionLabel || 'Region',
value: (row && row.region) || '',
onChange: function(value) { updateRow(index, 'region', value); },
placeholder: 'us',
}),
el(TextControl, {
label: config.i18n.urlLabel || 'URL',
value: (row && row.url) || '',
onChange: function(value) { updateRow(index, 'url', value); },
placeholder: 'https://example.com/page/',
}),
el(Button, {
isLink: true,
isDestructive: true,
isSmall: true,
onClick: function() { removeRow(index); },
}, __('Remove', 'metasync'))
);
}),
el(Button, {
isSecondary: true,
isSmall: true,
onClick: addRow,
}, config.i18n.addAlternate || 'Add alternate')
);
};
/**
* Advanced Robots Directives Panel Component (WP-197)
* Allows per-post control of robots directives like nofollow, noarchive, etc.
*/
const RobotsAdvancedPanel = () => {
const metaKey = config.metaKeys.robotsAdvanced || '_metasync_robots_advanced';
const i18nR = config.i18n || {};
const postId = useSelect(function(select) {
return select('core/editor').getCurrentPostId();
}, []);
// Local state for the form — initialized from DB on mount
const [localState, setLocalState] = useState(null);
// Load initial value from DB on mount
useEffect(function() {
if (localState === null && postId) {
apiFetch({ path: '/wp/v2/posts/' + postId + '?context=edit&_fields=meta' }).then(function(post) {
var raw = (post.meta && post.meta[metaKey]) || '';
var initial = {
nofollow: false, noarchive: false, nosnippet: false, noimageindex: false,
max_snippet: -1, max_image_preview: 'large',
};
if (raw) {
try {
var parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
if (parsed.nofollow !== undefined) initial.nofollow = !!parsed.nofollow;
if (parsed.noarchive !== undefined) initial.noarchive = !!parsed.noarchive;
if (parsed.nosnippet !== undefined) initial.nosnippet = !!parsed.nosnippet;
if (parsed.noimageindex !== undefined) initial.noimageindex = !!parsed.noimageindex;
if (parsed.max_snippet !== undefined) initial.max_snippet = parsed.max_snippet;
if (parsed.max_image_preview !== undefined) initial.max_image_preview = parsed.max_image_preview;
}
} catch (e) {}
}
setLocalState(initial);
});
}
}, [postId, localState, metaKey]);
// Listen for legacy meta box changes → sync to sidebar
useEffect(function() {
var legacyIds = {
'robots_common3': 'nofollow',
'robots_common4': 'noarchive',
'robots_common5': 'noimageindex',
'robots_common6': 'nosnippet',
};
var handlers = [];
Object.keys(legacyIds).forEach(function(elId) {
var checkbox = document.getElementById(elId);
if (checkbox) {
var handler = function() {
updateField(legacyIds[elId], checkbox.checked);
};
checkbox.addEventListener('change', handler);
handlers.push({ el: checkbox, handler: handler });
}
});
// Advanced fields
var snippetVal = document.getElementById('advanced_robots_snippet_value');
if (snippetVal) {
var h = function() { updateField('max_snippet', parseInt(snippetVal.value, 10) || -1); };
snippetVal.addEventListener('change', h);
handlers.push({ el: snippetVal, handler: h });
}
var imageVal = document.getElementById('advanced_robots_image_value');
if (imageVal) {
var h2 = function() { updateField('max_image_preview', imageVal.value); };
imageVal.addEventListener('change', h2);
handlers.push({ el: imageVal, handler: h2 });
}
return function() {
handlers.forEach(function(item) {
item.el.removeEventListener('change', item.handler);
});
};
}, []);
// Per-field save status tracking (hooks must be before any early return)
const [savingField, setSavingField] = useState('');
const [savedField, setSavedField] = useState('');
if (localState === null) {
return el('div', { style: { padding: '12px 0', color: '#757575' } }, 'Loading...');
}
var updateField = function(key, value) {
var updated = Object.assign({}, localState);
updated[key] = value;
setLocalState(updated);
syncToLegacyDOM(updated);
setSavingField(key);
setSavedField('');
if (!postId) return;
apiFetch({
path: '/wp/v2/posts/' + postId,
method: 'POST',
data: { meta: { [metaKey]: JSON.stringify(updated) } },
}).then(function() {
setSavingField('');
setSavedField(key);
setTimeout(function() { setSavedField(''); }, 1500);
}).catch(function() {
setSavingField('');
});
};
// Sync sidebar state to legacy meta box DOM elements in real-time
var syncToLegacyDOM = function(state) {
// Common robots checkboxes (by ID)
var checkboxMap = {
nofollow: 'robots_common3',
noarchive: 'robots_common4',
noimageindex: 'robots_common5',
nosnippet: 'robots_common6',
};
Object.keys(checkboxMap).forEach(function(key) {
var el = document.getElementById(checkboxMap[key]);
if (el) el.checked = !!state[key];
});
// Advanced robots: max-snippet
var snippetEnable = document.getElementById('advanced_robots_snippet');
var snippetValue = document.getElementById('advanced_robots_snippet_value');
if (snippetEnable && snippetValue) {
var hasSnippet = state.max_snippet !== undefined && state.max_snippet !== null && state.max_snippet !== -1;
snippetEnable.checked = hasSnippet;
snippetValue.value = state.max_snippet !== undefined ? state.max_snippet : -1;
}
// Advanced robots: max-image-preview
var imageEnable = document.getElementById('advanced_robots_image');
var imageValue = document.getElementById('advanced_robots_image_value');
if (imageEnable && imageValue) {
var hasImage = state.max_image_preview && state.max_image_preview !== 'large';
imageEnable.checked = !!hasImage;
imageValue.value = state.max_image_preview || 'large';
}
};
// Inline status badge for a field
var fieldStatus = function(key) {
if (savingField === key) {
return el('span', { style: { fontSize: '11px', color: '#dba617', marginLeft: '8px' } }, 'Saving...');
}
if (savedField === key) {
return el('span', { style: { fontSize: '11px', color: '#00a32a', marginLeft: '8px' } }, 'Saved');
}
return null;
};
return el('div', { className: 'metasync-robots-advanced' },
el('div', null,
el(CheckboxControl, {
label: el(wp.element.Fragment, null, (i18nR.nofollowLabel || 'Nofollow'), fieldStatus('nofollow')),
help: i18nR.nofollowHelp || 'Prevent search engines from following links on this page.',
checked: localState.nofollow,
onChange: function(val) { updateField('nofollow', val); },
})
),
el('div', null,
el(CheckboxControl, {
label: el(wp.element.Fragment, null, (i18nR.noarchiveLabel || 'Noarchive'), fieldStatus('noarchive')),
help: i18nR.noarchiveHelp || 'Prevent search engines from showing cached versions.',
checked: localState.noarchive,
onChange: function(val) { updateField('noarchive', val); },
})
),
el('div', null,
el(CheckboxControl, {
label: el(wp.element.Fragment, null, (i18nR.nosnippetLabel || 'Nosnippet'), fieldStatus('nosnippet')),
help: i18nR.nosnippetHelp || 'Prevent search engines from showing text snippets.',
checked: localState.nosnippet,
onChange: function(val) { updateField('nosnippet', val); },
})
),
el('div', null,
el(CheckboxControl, {
label: el(wp.element.Fragment, null, (i18nR.noimageindexLabel || 'No Image Index'), fieldStatus('noimageindex')),
help: i18nR.noimageindexHelp || 'Prevent this page from appearing as image search referrer.',
checked: localState.noimageindex,
onChange: function(val) { updateField('noimageindex', val); },
})
),
el('div', null,
el('div', { style: { display: 'flex', alignItems: 'center', marginBottom: '4px' } },
el('span', { style: { fontWeight: 500, fontSize: '11px', textTransform: 'uppercase' } }, i18nR.maxSnippetLabel || 'Max Snippet Length'),
fieldStatus('max_snippet')
),
el(TextControl, {
help: i18nR.maxSnippetHelp || 'Maximum character length for text snippets. -1 for unlimited.',
type: 'number',
min: '-1',
value: String(localState.max_snippet),
onChange: function(val) { updateField('max_snippet', parseInt(val, 10) || -1); },
})
),
el('div', null,
el('div', { style: { display: 'flex', alignItems: 'center', marginBottom: '4px' } },
el('span', { style: { fontWeight: 500, fontSize: '11px', textTransform: 'uppercase' } }, i18nR.maxImagePreviewLabel || 'Max Image Preview'),
fieldStatus('max_image_preview')
),
el(SelectControl, {
help: i18nR.maxImagePreviewHelp || 'Maximum size of image preview in search results.',
value: localState.max_image_preview,
options: [
{ label: 'Large', value: 'large' },
{ label: 'Standard', value: 'standard' },
{ label: 'None', value: 'none' },
],
onChange: function(val) { updateField('max_image_preview', val); },
})
)
);
};
/**
* Main Sidebar Component
*/
/**
* Plugin Sync Status Panel Component (WP-196)
* Reads _metasync_plugin_sync_ts from post meta and displays sync status.
*/
const PluginSyncStatusPanel = () => {
const syncTsRaw = useSelect(function(select) {
var metaKey = config.metaKeys.pluginSyncTs || '_metasync_plugin_sync_ts';
return select('core/editor').getEditedPostAttribute('meta')[metaKey] || '';
}, []);
var activeSeoPlugins = config.activeSeoPlugins || {};
var syncI18n = config.i18n || {};
// No active SEO plugins detected -- nothing to show
if (!activeSeoPlugins.yoast && !activeSeoPlugins.rankmath && !activeSeoPlugins.aioseo) {
return null;
}
var syncData = {};
if (syncTsRaw) {
try {
syncData = JSON.parse(syncTsRaw);
} catch (e) {
syncData = {};
}
}
var formatRelativeTime = function(isoString) {
if (!isoString) return syncI18n.syncNever || 'Never synced';
var diff = Math.floor((Date.now() - new Date(isoString).getTime()) / 1000);
if (diff < 60) return diff + 's ' + (syncI18n.syncAgo || 'ago');
if (diff < 3600) return Math.floor(diff / 60) + 'm ' + (syncI18n.syncAgo || 'ago');
if (diff < 86400) return Math.floor(diff / 3600) + 'h ' + (syncI18n.syncAgo || 'ago');
return Math.floor(diff / 86400) + 'd ' + (syncI18n.syncAgo || 'ago');
};
var pluginLabels = { yoast: 'Yoast SEO', rankmath: 'Rank Math', aioseo: 'AIOSEO' };
var items = [];
['yoast', 'rankmath', 'aioseo'].forEach(function(slug) {
if (!activeSeoPlugins[slug]) return;
var ts = syncData[slug] || '';
items.push(
el('div', { key: slug, style: { display: 'flex', justifyContent: 'space-between', padding: '4px 0' } },
el('span', { style: { fontWeight: 500 } }, pluginLabels[slug]),
el('span', { style: { color: ts ? '#00a32a' : '#757575' } }, formatRelativeTime(ts))
)
);
});
if (items.length === 0) return null;
return el('div', { className: 'metasync-plugin-sync-status' },
el('p', { style: { fontWeight: 600, marginBottom: '8px' } }, syncI18n.syncedTo || 'Synced to:'),
items
);
};
const MetaSyncSeoSidebar = () => {
return el(PluginSidebar, {
name: 'metasync-seo-sidebar',
title: config.i18n.panelTitle,
icon: MetaSyncIcon,
},
el('div', { className: 'metasync-seo-sidebar-content' },
// Show notice when user has custom values and OTTO is enabled
el(OttoOverrideNotice, null),
el(PanelBody, {
title: config.i18n.panelTitle,
initialOpen: true,
},
el(SeoTitleInput, null),
el(MetaDescriptionInput, null),
el(UrlSlugInput, null),
el(BreadcrumbTitleInput, null),
el(PrimaryCategoryInjectPanel, null)
),
el(PanelBody, {
title: config.i18n.serpPreviewTitle,
initialOpen: true,
},
el(SerpPreview, null)
),
el(PanelBody, {
title: schemaI18n.panelTitle || 'Schema Markup Content',
initialOpen: false,
},
el(SchemaContentPanel, null)
),
el(PanelBody, {
title: config.i18n.languageAlternatesTitle || 'Language Alternates',
initialOpen: false,
},
el(LanguageAlternatesPanel, null)
),
el(PanelBody, {
title: (config.linkSuggestions && config.linkSuggestions.i18n && config.linkSuggestions.i18n.panelTitle) || 'Internal Link Suggestions',
initialOpen: false,
},
el(LinkSuggestionsPanel, null)
),
el(PanelBody, {
title: config.i18n.robotsAdvancedTitle || 'Advanced Robots Directives',
initialOpen: false,
},
el(RobotsAdvancedPanel, null)
),
el(PanelBody, {
title: config.i18n.syncStatusTitle || 'Plugin Sync Status',
initialOpen: true,
},
el(PluginSyncStatusPanel, null)
)
)
);
};
/**
* Sidebar Menu Item Component
*/
const MetaSyncSeoMenuItem = () => {
return el(PluginSidebarMoreMenuItem, {
target: 'metasync-seo-sidebar',
icon: MetaSyncIcon,
}, config.i18n.panelTitle);
};
/**
* Combined Plugin Component
*/
const MetaSyncSeoPlugin = () => {
return el(wp.element.Fragment, null,
el(MetaSyncSeoSidebar, null),
el(MetaSyncSeoMenuItem, null)
);
};
/**
* Hook into the WordPress Categories taxonomy panel.
* Wraps the original component and appends our Primary Category selector
* directly below the category checkboxes — same UX as AIOSEO.
*/
if (!hasOtherSeoPrimary) {
wp.hooks.addFilter(
'editor.PostTaxonomyType',
'metasync/primary-category',
function(OriginalComponent) {
return function(props) {
// Only inject into the 'category' taxonomy panel.
if (props.slug !== 'category') {
return el(OriginalComponent, props);
}
return el(wp.element.Fragment, null,
el(OriginalComponent, props),
el(PrimaryCategoryInjectPanel, null)
);
};
}
);
}
// Register the SEO sidebar plugin.
registerPlugin('metasync-seo', {
render: MetaSyncSeoPlugin,
icon: MetaSyncIcon,
});
})(window.wp);