Skip to content

Commit 1ccffcd

Browse files
author
infinitnet
committed
initial public release
1 parent 21de280 commit 1ccffcd

File tree

3 files changed

+363
-0
lines changed

3 files changed

+363
-0
lines changed

editor.css

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
.highlight-term {
2+
background-color: lightgreen;
3+
}
4+
5+
.relevant-density {
6+
margin-top: 10px;
7+
font-weight: bold;
8+
}
9+
10+
.term-frequency {
11+
margin-top: 5px;
12+
padding: 3px;
13+
display: inline-block;
14+
border-radius: 4px;
15+
margin-right: 2px;
16+
margin-bottom: 2px;
17+
}
18+
19+
.term-frequency[style*="lightgreen"] {
20+
background-color: lightgreen;
21+
}
22+
23+
.term-frequency[style*="lightred"] {
24+
background-color: lightcoral;
25+
}

editor.js

Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
const { PluginSidebar } = wp.editPost;
2+
const { TextareaControl, Button, ToggleControl } = wp.components;
3+
const { withSelect, withDispatch, subscribe } = wp.data;
4+
const selectData = wp.data.select;
5+
const { createElement: termsHighlighterEl, useState, useEffect } = wp.element;
6+
const { compose } = wp.compose;
7+
8+
const TERMS_SPLIT_REGEX = /\s*,\s*|\s*\n\s*/;
9+
10+
const computeRelevantDensity = (content, termsArray) => {
11+
const contentWords = content.split(/\s+/);
12+
const totalWords = contentWords.length;
13+
let termCount = 0;
14+
15+
termsArray.forEach(term => {
16+
const regex = new RegExp("\\b" + term + "\\b", "gi");
17+
const matches = content.match(regex);
18+
termCount += (matches ? matches.length : 0);
19+
});
20+
21+
return (termCount / totalWords) * 100;
22+
};
23+
24+
const computeRelevantDensityForHeadings = (blocks, termsArray) => {
25+
let contentWords = [];
26+
27+
blocks.forEach(block => {
28+
if (block.name === 'core/heading') {
29+
contentWords = contentWords.concat(block.attributes.content.split(/\s+/));
30+
}
31+
});
32+
33+
if (!contentWords.length) return 0;
34+
35+
const totalWords = contentWords.length;
36+
let termCount = 0;
37+
38+
termsArray.forEach(term => {
39+
const regex = new RegExp("\\b" + term + "\\b", "gi");
40+
termCount += contentWords.join(' ').match(regex)?.length || 0;
41+
});
42+
43+
return (termCount / totalWords) * 100;
44+
};
45+
46+
const displayRelevantDetails = (content, terms, sortType, showUnusedOnly) => {
47+
if (!terms) return;
48+
49+
const searchTermInput = document.querySelector('.searchTermInput');
50+
const currentSearchTerm = searchTermInput ? searchTermInput.value : "";
51+
52+
const termsArray = terms.split(TERMS_SPLIT_REGEX)
53+
.map(term => term.toLowerCase().trim())
54+
.filter(term => term !== "")
55+
.filter((term, index, self) => self.indexOf(term) === index);
56+
57+
const density = computeRelevantDensity(content, termsArray);
58+
const blocks = selectData('core/block-editor').getBlocks();
59+
const headingDensity = computeRelevantDensityForHeadings(blocks, termsArray);
60+
61+
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>';
62+
63+
const termDetails = termsArray.map(term => {
64+
const regex = new RegExp("\\b" + term + "\\b", "gi");
65+
const matches = content.match(regex);
66+
const count = (matches ? matches.length : 0);
67+
return { term, count };
68+
});
69+
70+
if (sortType === 'Count ascending') {
71+
termDetails.sort((a, b) => a.count - b.count);
72+
} else if (sortType === 'Alphabetically') {
73+
termDetails.sort((a, b) => a.term.localeCompare(b.term));
74+
} else {
75+
termDetails.sort((a, b) => b.count - a.count);
76+
}
77+
78+
const filteredDetails = showUnusedOnly ? termDetails.filter(detail => detail.count === 0) : termDetails;
79+
80+
filteredDetails.filter(detail => detail.term.toLowerCase().includes(currentSearchTerm.toLowerCase())).forEach(detail => {
81+
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>`;
82+
detailsHTML += termElement;
83+
});
84+
85+
const sidebarElement = document.querySelector('.relevant-density-optimizer .relevant-details');
86+
if (sidebarElement) {
87+
sidebarElement.innerHTML = detailsHTML;
88+
}
89+
};
90+
91+
const debounce = (func, wait) => {
92+
let timeout;
93+
return (...args) => {
94+
clearTimeout(timeout);
95+
timeout = setTimeout(() => func.apply(this, args), wait);
96+
};
97+
};
98+
99+
const debouncedDisplayRelevantDetails = debounce(displayRelevantDetails, 1000);
100+
101+
const removeHighlighting = () => {
102+
document.querySelectorAll(".editor-styles-wrapper .highlight-term").forEach(span => {
103+
const parent = span.parentElement;
104+
while (span.firstChild) {
105+
parent.insertBefore(span.firstChild, span);
106+
}
107+
parent.removeChild(span);
108+
});
109+
};
110+
111+
const removeHighlightingFromContent = (content) => {
112+
const tempDiv = document.createElement("div");
113+
tempDiv.innerHTML = content;
114+
tempDiv.querySelectorAll(".highlight-term").forEach(span => {
115+
const parent = span.parentElement;
116+
while (span.firstChild) {
117+
parent.insertBefore(span.firstChild, span);
118+
}
119+
parent.removeChild(span);
120+
});
121+
return tempDiv.innerHTML;
122+
};
123+
124+
const createHighlightPattern = (termsArray) => {
125+
const escapedTerms = termsArray.map(term => term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
126+
return new RegExp("\\b(" + escapedTerms.join('|') + ")\\b", "gi");
127+
};
128+
129+
const highlightText = (node, pattern) => {
130+
if (!node || !pattern) return;
131+
132+
if (node.nodeType === 3) {
133+
let match;
134+
let lastIndex = 0;
135+
let fragment = document.createDocumentFragment();
136+
137+
while ((match = pattern.exec(node.nodeValue)) !== null) {
138+
const precedingText = document.createTextNode(node.nodeValue.slice(lastIndex, match.index));
139+
fragment.appendChild(precedingText);
140+
141+
const highlightSpan = document.createElement('span');
142+
highlightSpan.className = 'highlight-term';
143+
highlightSpan.textContent = match[0];
144+
fragment.appendChild(highlightSpan);
145+
146+
lastIndex = pattern.lastIndex;
147+
}
148+
149+
if (lastIndex < node.nodeValue.length) {
150+
const remainingText = document.createTextNode(node.nodeValue.slice(lastIndex));
151+
fragment.appendChild(remainingText);
152+
}
153+
154+
if (fragment.childNodes.length > 0) {
155+
node.parentNode.replaceChild(fragment, node);
156+
}
157+
158+
} else if (node.nodeType === 1 && node.childNodes && !/(script|style)/i.test(node.tagName)) {
159+
Array.from(node.childNodes).forEach(childNode => highlightText(childNode, pattern));
160+
}
161+
};
162+
163+
const highlightTerms = (termsArray, blocks = document.querySelectorAll(".editor-styles-wrapper .block-editor-block-list__block")) => {
164+
const pattern = createHighlightPattern(termsArray);
165+
requestAnimationFrame(() => {
166+
removeHighlighting();
167+
blocks.forEach(block => highlightText(block, pattern));
168+
});
169+
};
170+
171+
function copyToClipboard(event) {
172+
const term = event.currentTarget.getAttribute('data-term');
173+
navigator.clipboard.writeText(term);
174+
}
175+
176+
const ImportantTermsComponent = compose([
177+
withSelect(selectFunc => ({
178+
metaFieldValue: selectFunc('core/editor').getEditedPostAttribute('meta')['_important_terms'],
179+
content: selectFunc('core/editor').getEditedPostContent(),
180+
})),
181+
withDispatch(dispatch => ({
182+
setMetaFieldValue: value => {
183+
const editor = dispatch('core/editor');
184+
let content = selectData('core/editor').getEditedPostContent();
185+
content = removeHighlightingFromContent(content);
186+
editor.editPost({ content: content });
187+
editor.editPost({ meta: { _important_terms: value } });
188+
}
189+
}))
190+
])((props) => {
191+
const [localTerms, setLocalTerms] = useState(props.metaFieldValue || "");
192+
const [searchTerm, setSearchTerm] = useState("");
193+
const [isHighlightingEnabled, toggleHighlighting] = useState(false);
194+
const [sortType, setSortType] = useState("Count descending");
195+
const [showUnusedOnly, setShowUnusedOnly] = useState(false);
196+
197+
const handleToggle = () => {
198+
toggleHighlighting(!isHighlightingEnabled);
199+
globalHighlightingState = !isHighlightingEnabled;
200+
const terms = localTerms.split(TERMS_SPLIT_REGEX);
201+
const sortedTerms = terms.sort((a, b) => b.length - a.length);
202+
if (globalHighlightingState) {
203+
highlightTerms(sortedTerms);
204+
} else {
205+
removeHighlighting();
206+
}
207+
};
208+
209+
useEffect(() => {
210+
debouncedDisplayRelevantDetails(props.content, localTerms, sortType, showUnusedOnly);
211+
}, [props.content, searchTerm, localTerms, sortType, showUnusedOnly]);
212+
213+
useEffect(() => {
214+
return () => {
215+
debouncedDisplayRelevantDetails.cancel();
216+
}
217+
}, []);
218+
219+
const saveTerms = () => {
220+
let terms = localTerms.split(TERMS_SPLIT_REGEX);
221+
terms = terms.map(term => term.toLowerCase().trim());
222+
terms = terms.filter(term => term !== "");
223+
terms = terms.filter(term => !term.includes('=='));
224+
terms = [...new Set(terms)];
225+
const cleanedTerms = terms.join('\n');
226+
props.setMetaFieldValue(cleanedTerms);
227+
setLocalTerms(cleanedTerms);
228+
};
229+
230+
return termsHighlighterEl(
231+
'div',
232+
{},
233+
termsHighlighterEl(TextareaControl, {
234+
label: "Relevant Terms",
235+
value: localTerms,
236+
onChange: setLocalTerms
237+
}),
238+
termsHighlighterEl(ToggleControl, {
239+
label: 'Highlight',
240+
checked: isHighlightingEnabled,
241+
onChange: handleToggle
242+
}),
243+
termsHighlighterEl(Button, {
244+
isPrimary: true,
245+
onClick: saveTerms
246+
}, 'Update'),
247+
termsHighlighterEl('br'),
248+
termsHighlighterEl('br'),
249+
termsHighlighterEl('select', {
250+
value: sortType,
251+
onChange: event => setSortType(event.target.value)
252+
},
253+
termsHighlighterEl('option', { value: 'Count descending' }, 'Count descending'),
254+
termsHighlighterEl('option', { value: 'Count ascending' }, 'Count ascending'),
255+
termsHighlighterEl('option', { value: 'Alphabetically' }, 'Alphabetically')
256+
),
257+
termsHighlighterEl('br'),
258+
termsHighlighterEl('br'),
259+
termsHighlighterEl(ToggleControl, {
260+
label: 'Unused only',
261+
checked: showUnusedOnly,
262+
onChange: () => setShowUnusedOnly(!showUnusedOnly)
263+
}),
264+
termsHighlighterEl('br'),
265+
termsHighlighterEl('input', {
266+
type: 'text',
267+
placeholder: 'Search...',
268+
value: searchTerm,
269+
onChange: event => setSearchTerm(event.target.value),
270+
className: 'searchTermInput'
271+
}),
272+
termsHighlighterEl('div', { className: 'relevant-density-optimizer' },
273+
termsHighlighterEl('div', { className: 'relevant-details' })
274+
)
275+
);
276+
});
277+
278+
wp.plugins.registerPlugin('relevant-density-optimizer', {
279+
icon: 'awards',
280+
render: () => termsHighlighterEl(PluginSidebar, {
281+
name: "relevant-density-optimizer",
282+
title: "Relevant Density Optimizer"
283+
}, termsHighlighterEl(ImportantTermsComponent))
284+
});
285+
286+
let globalHighlightingState = false;
287+
288+
const handleEditorChange = () => {
289+
const newContent = selectData('core/editor').getEditedPostContent();
290+
const postMeta = selectData('core/editor').getEditedPostAttribute('meta') || {};
291+
const terms = postMeta['_important_terms'] ? postMeta['_important_terms'].split('\n') : [];
292+
293+
if (newContent !== lastComputedContent || terms.join(',') !== lastComputedTerms) {
294+
displayRelevantDetails(newContent, postMeta['_important_terms']);
295+
296+
if (globalHighlightingState) {
297+
const sortedTerms = terms.sort((a, b) => b.length - a.length);
298+
highlightTerms(sortedTerms);
299+
}
300+
301+
lastComputedContent = newContent;
302+
lastComputedTerms = terms.join(',');
303+
}
304+
};
305+
306+
const debouncedHandleEditorChange = debounce(handleEditorChange, 3000);
307+
308+
subscribe(debouncedHandleEditorChange);

relevant-density-optimizer.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
/**
3+
* Plugin Name: Relevant Density Optimizer
4+
* Description: Highlight relevant terms in Gutenberg editor and optimize density.
5+
* Author: Infinitnet
6+
* Version: 1.6
7+
*/
8+
9+
function rdo_enqueue_block_editor_assets() {
10+
if (!wp_script_is('rdo-editor-js', 'enqueued')) {
11+
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);
12+
}
13+
14+
wp_enqueue_style('rdo-editor-css', plugin_dir_url(__FILE__) . 'editor.css', array(), '1.1');
15+
}
16+
17+
add_action('enqueue_block_editor_assets', 'rdo_enqueue_block_editor_assets');
18+
19+
function rdo_register_meta() {
20+
register_meta('post', '_important_terms', array(
21+
'show_in_rest' => true,
22+
'single' => true,
23+
'type' => 'string',
24+
'auth_callback' => function() {
25+
return current_user_can('edit_posts');
26+
}
27+
));
28+
}
29+
30+
add_action('init', 'rdo_register_meta');

0 commit comments

Comments
 (0)