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');