-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
infinitnet
committed
Oct 20, 2023
1 parent
21de280
commit 1ccffcd
Showing
3 changed files
with
363 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'); |