diff --git a/editor.css b/editor.css new file mode 100644 index 0000000..83d2218 --- /dev/null +++ b/editor.css @@ -0,0 +1,25 @@ +.highlight-term { + background-color: lightgreen; +} + +.relevant-density { + margin-top: 10px; + font-weight: bold; +} + +.term-frequency { + margin-top: 5px; + padding: 3px; + display: inline-block; + border-radius: 4px; + margin-right: 2px; + margin-bottom: 2px; +} + +.term-frequency[style*="lightgreen"] { + background-color: lightgreen; +} + +.term-frequency[style*="lightred"] { + background-color: lightcoral; +} diff --git a/editor.js b/editor.js new file mode 100644 index 0000000..9bab9ba --- /dev/null +++ b/editor.js @@ -0,0 +1,308 @@ +const { PluginSidebar } = wp.editPost; +const { TextareaControl, Button, ToggleControl } = wp.components; +const { withSelect, withDispatch, subscribe } = wp.data; +const selectData = wp.data.select; +const { createElement: termsHighlighterEl, useState, useEffect } = wp.element; +const { compose } = wp.compose; + +const TERMS_SPLIT_REGEX = /\s*,\s*|\s*\n\s*/; + +const computeRelevantDensity = (content, termsArray) => { + const contentWords = content.split(/\s+/); + const totalWords = contentWords.length; + let termCount = 0; + + termsArray.forEach(term => { + const regex = new RegExp("\\b" + term + "\\b", "gi"); + const matches = content.match(regex); + termCount += (matches ? matches.length : 0); + }); + + return (termCount / totalWords) * 100; +}; + +const computeRelevantDensityForHeadings = (blocks, termsArray) => { + let contentWords = []; + + blocks.forEach(block => { + if (block.name === 'core/heading') { + contentWords = contentWords.concat(block.attributes.content.split(/\s+/)); + } + }); + + if (!contentWords.length) return 0; + + const totalWords = contentWords.length; + let termCount = 0; + + termsArray.forEach(term => { + const regex = new RegExp("\\b" + term + "\\b", "gi"); + termCount += contentWords.join(' ').match(regex)?.length || 0; + }); + + return (termCount / totalWords) * 100; +}; + +const displayRelevantDetails = (content, terms, sortType, showUnusedOnly) => { + if (!terms) return; + + const searchTermInput = document.querySelector('.searchTermInput'); + const currentSearchTerm = searchTermInput ? searchTermInput.value : ""; + + const termsArray = terms.split(TERMS_SPLIT_REGEX) + .map(term => term.toLowerCase().trim()) + .filter(term => term !== "") + .filter((term, index, self) => self.indexOf(term) === index); + + const density = computeRelevantDensity(content, termsArray); + const blocks = selectData('core/block-editor').getBlocks(); + const headingDensity = computeRelevantDensityForHeadings(blocks, termsArray); + + let detailsHTML = '
Relevant Density in Headings: ' + headingDensity.toFixed(2) + '%
' + '
Relevant Density Overall: ' + density.toFixed(2) + '%
'; + + const termDetails = termsArray.map(term => { + const regex = new RegExp("\\b" + term + "\\b", "gi"); + const matches = content.match(regex); + const count = (matches ? matches.length : 0); + return { term, count }; + }); + + if (sortType === 'Count ascending') { + termDetails.sort((a, b) => a.count - b.count); + } else if (sortType === 'Alphabetically') { + termDetails.sort((a, b) => a.term.localeCompare(b.term)); + } else { + termDetails.sort((a, b) => b.count - a.count); + } + + const filteredDetails = showUnusedOnly ? termDetails.filter(detail => detail.count === 0) : termDetails; + + filteredDetails.filter(detail => detail.term.toLowerCase().includes(currentSearchTerm.toLowerCase())).forEach(detail => { + const termElement = `
${detail.term} ${detail.count}
`; + detailsHTML += termElement; + }); + + const sidebarElement = document.querySelector('.relevant-density-optimizer .relevant-details'); + if (sidebarElement) { + sidebarElement.innerHTML = detailsHTML; + } +}; + +const debounce = (func, wait) => { + let timeout; + return (...args) => { + clearTimeout(timeout); + timeout = setTimeout(() => func.apply(this, args), wait); + }; +}; + +const debouncedDisplayRelevantDetails = debounce(displayRelevantDetails, 1000); + +const removeHighlighting = () => { + document.querySelectorAll(".editor-styles-wrapper .highlight-term").forEach(span => { + const parent = span.parentElement; + while (span.firstChild) { + parent.insertBefore(span.firstChild, span); + } + parent.removeChild(span); + }); +}; + +const removeHighlightingFromContent = (content) => { + const tempDiv = document.createElement("div"); + tempDiv.innerHTML = content; + tempDiv.querySelectorAll(".highlight-term").forEach(span => { + const parent = span.parentElement; + while (span.firstChild) { + parent.insertBefore(span.firstChild, span); + } + parent.removeChild(span); + }); + return tempDiv.innerHTML; +}; + +const createHighlightPattern = (termsArray) => { + const escapedTerms = termsArray.map(term => term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); + return new RegExp("\\b(" + escapedTerms.join('|') + ")\\b", "gi"); +}; + +const highlightText = (node, pattern) => { + if (!node || !pattern) return; + + if (node.nodeType === 3) { + let match; + let lastIndex = 0; + let fragment = document.createDocumentFragment(); + + while ((match = pattern.exec(node.nodeValue)) !== null) { + const precedingText = document.createTextNode(node.nodeValue.slice(lastIndex, match.index)); + fragment.appendChild(precedingText); + + const highlightSpan = document.createElement('span'); + highlightSpan.className = 'highlight-term'; + highlightSpan.textContent = match[0]; + fragment.appendChild(highlightSpan); + + lastIndex = pattern.lastIndex; + } + + if (lastIndex < node.nodeValue.length) { + const remainingText = document.createTextNode(node.nodeValue.slice(lastIndex)); + fragment.appendChild(remainingText); + } + + if (fragment.childNodes.length > 0) { + node.parentNode.replaceChild(fragment, node); + } + + } else if (node.nodeType === 1 && node.childNodes && !/(script|style)/i.test(node.tagName)) { + Array.from(node.childNodes).forEach(childNode => highlightText(childNode, pattern)); + } +}; + +const highlightTerms = (termsArray, blocks = document.querySelectorAll(".editor-styles-wrapper .block-editor-block-list__block")) => { + const pattern = createHighlightPattern(termsArray); + requestAnimationFrame(() => { + removeHighlighting(); + blocks.forEach(block => highlightText(block, pattern)); + }); +}; + +function copyToClipboard(event) { + const term = event.currentTarget.getAttribute('data-term'); + navigator.clipboard.writeText(term); +} + +const ImportantTermsComponent = compose([ + withSelect(selectFunc => ({ + metaFieldValue: selectFunc('core/editor').getEditedPostAttribute('meta')['_important_terms'], + content: selectFunc('core/editor').getEditedPostContent(), + })), + withDispatch(dispatch => ({ + setMetaFieldValue: value => { + const editor = dispatch('core/editor'); + let content = selectData('core/editor').getEditedPostContent(); + content = removeHighlightingFromContent(content); + editor.editPost({ content: content }); + editor.editPost({ meta: { _important_terms: value } }); + } + })) +])((props) => { + const [localTerms, setLocalTerms] = useState(props.metaFieldValue || ""); + const [searchTerm, setSearchTerm] = useState(""); + const [isHighlightingEnabled, toggleHighlighting] = useState(false); + const [sortType, setSortType] = useState("Count descending"); + const [showUnusedOnly, setShowUnusedOnly] = useState(false); + + const handleToggle = () => { + toggleHighlighting(!isHighlightingEnabled); + globalHighlightingState = !isHighlightingEnabled; + const terms = localTerms.split(TERMS_SPLIT_REGEX); + const sortedTerms = terms.sort((a, b) => b.length - a.length); + if (globalHighlightingState) { + highlightTerms(sortedTerms); + } else { + removeHighlighting(); + } + }; + + useEffect(() => { + debouncedDisplayRelevantDetails(props.content, localTerms, sortType, showUnusedOnly); +}, [props.content, searchTerm, localTerms, sortType, showUnusedOnly]); + + useEffect(() => { + return () => { + debouncedDisplayRelevantDetails.cancel(); + } +}, []); + + const saveTerms = () => { + let terms = localTerms.split(TERMS_SPLIT_REGEX); + terms = terms.map(term => term.toLowerCase().trim()); + terms = terms.filter(term => term !== ""); + terms = terms.filter(term => !term.includes('==')); + terms = [...new Set(terms)]; + const cleanedTerms = terms.join('\n'); + props.setMetaFieldValue(cleanedTerms); + setLocalTerms(cleanedTerms); + }; + + return termsHighlighterEl( + 'div', + {}, + termsHighlighterEl(TextareaControl, { + label: "Relevant Terms", + value: localTerms, + onChange: setLocalTerms + }), + termsHighlighterEl(ToggleControl, { + label: 'Highlight', + checked: isHighlightingEnabled, + onChange: handleToggle + }), + termsHighlighterEl(Button, { + isPrimary: true, + onClick: saveTerms + }, 'Update'), + termsHighlighterEl('br'), + termsHighlighterEl('br'), + termsHighlighterEl('select', { + value: sortType, + onChange: event => setSortType(event.target.value) + }, + termsHighlighterEl('option', { value: 'Count descending' }, 'Count descending'), + termsHighlighterEl('option', { value: 'Count ascending' }, 'Count ascending'), + termsHighlighterEl('option', { value: 'Alphabetically' }, 'Alphabetically') + ), + termsHighlighterEl('br'), + termsHighlighterEl('br'), + termsHighlighterEl(ToggleControl, { + label: 'Unused only', + checked: showUnusedOnly, + onChange: () => setShowUnusedOnly(!showUnusedOnly) + }), + termsHighlighterEl('br'), + termsHighlighterEl('input', { + type: 'text', + placeholder: 'Search...', + value: searchTerm, + onChange: event => setSearchTerm(event.target.value), + className: 'searchTermInput' + }), + termsHighlighterEl('div', { className: 'relevant-density-optimizer' }, + termsHighlighterEl('div', { className: 'relevant-details' }) + ) + ); +}); + +wp.plugins.registerPlugin('relevant-density-optimizer', { + icon: 'awards', + render: () => termsHighlighterEl(PluginSidebar, { + name: "relevant-density-optimizer", + title: "Relevant Density Optimizer" + }, termsHighlighterEl(ImportantTermsComponent)) +}); + +let globalHighlightingState = false; + +const handleEditorChange = () => { + const newContent = selectData('core/editor').getEditedPostContent(); + const postMeta = selectData('core/editor').getEditedPostAttribute('meta') || {}; + const terms = postMeta['_important_terms'] ? postMeta['_important_terms'].split('\n') : []; + + if (newContent !== lastComputedContent || terms.join(',') !== lastComputedTerms) { + displayRelevantDetails(newContent, postMeta['_important_terms']); + + if (globalHighlightingState) { + const sortedTerms = terms.sort((a, b) => b.length - a.length); + highlightTerms(sortedTerms); + } + + lastComputedContent = newContent; + lastComputedTerms = terms.join(','); + } +}; + +const debouncedHandleEditorChange = debounce(handleEditorChange, 3000); + +subscribe(debouncedHandleEditorChange); diff --git a/relevant-density-optimizer.php b/relevant-density-optimizer.php new file mode 100644 index 0000000..59bbbef --- /dev/null +++ b/relevant-density-optimizer.php @@ -0,0 +1,30 @@ + true, + 'single' => true, + 'type' => 'string', + 'auth_callback' => function() { + return current_user_can('edit_posts'); + } + )); +} + +add_action('init', 'rdo_register_meta');