Skip to content

Commit

Permalink
initial public release
Browse files Browse the repository at this point in the history
  • Loading branch information
infinitnet committed Oct 20, 2023
1 parent 21de280 commit 1ccffcd
Show file tree
Hide file tree
Showing 3 changed files with 363 additions and 0 deletions.
25 changes: 25 additions & 0 deletions editor.css
Original file line number Diff line number Diff line change
@@ -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;
}
308 changes: 308 additions & 0 deletions editor.js
Original file line number Diff line number Diff line change
@@ -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 = '<div class="relevant-density">Relevant Density in Headings: ' + headingDensity.toFixed(2) + '%</div>' + '<div class="relevant-density">Relevant Density Overall: ' + density.toFixed(2) + '%</div>';

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 = `<div class="term-frequency" style="background-color: ${detail.count > 0 ? 'lightgreen' : 'lightred'}" data-term="${detail.term}" onclick="copyToClipboard(event)">${detail.term} <sup>${detail.count}</sup></div>`;
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);
30 changes: 30 additions & 0 deletions relevant-density-optimizer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php
/**
* Plugin Name: Relevant Density Optimizer
* Description: Highlight relevant terms in Gutenberg editor and optimize density.
* Author: Infinitnet
* Version: 1.6
*/

function rdo_enqueue_block_editor_assets() {
if (!wp_script_is('rdo-editor-js', 'enqueued')) {
wp_enqueue_script('rdo-editor-js', plugin_dir_url(__FILE__) . 'editor.js', array('wp-plugins', 'wp-edit-post', 'wp-element', 'wp-data', 'wp-compose', 'wp-components'), '1.1', true);
}

wp_enqueue_style('rdo-editor-css', plugin_dir_url(__FILE__) . 'editor.css', array(), '1.1');
}

add_action('enqueue_block_editor_assets', 'rdo_enqueue_block_editor_assets');

function rdo_register_meta() {
register_meta('post', '_important_terms', array(
'show_in_rest' => true,
'single' => true,
'type' => 'string',
'auth_callback' => function() {
return current_user_can('edit_posts');
}
));
}

add_action('init', 'rdo_register_meta');

0 comments on commit 1ccffcd

Please sign in to comment.