I’ll build this as a standalone advanced reading-time chunk for RX Theme: safe DOM-ready loading, WordPress-friendly selectors, Bengali/English text support, progress bar, analytics-ready events, and reusable API methods.
Friend, create this file:
assets/static-js/static-chunks-js/chunk-034-reading-time.js
Then paste this full advanced code:
/*!
* RX Theme - Advanced Reading Time Chunk
* File: assets/static-js/static-chunks-js/chunk-034-reading-time.js
* Purpose: Reading time, word count, progress bar, scroll progress, completion tracking
* Author: RX Theme
*/
(function RXReadingTimeChunk(window, document) {
'use strict';
if (!window || !document) {
return;
}
var RX_READING_TIME_VERSION = '1.0.0';
var DEFAULTS = {
contentSelectors: [
'.rx-single-content',
'.entry-content',
'.post-content',
'.article-content',
'article .content',
'article',
'main'
],
titleSelectors: [
'.entry-title',
'.post-title',
'h1'
],
outputSelectors: [
'[data-rx-reading-time]',
'.rx-reading-time',
'.reading-time'
],
excludeSelectors: [
'script',
'style',
'noscript',
'iframe',
'svg',
'canvas',
'form',
'nav',
'aside',
'.rx-no-reading-time',
'.no-reading-time',
'.wp-block-embed',
'.sharedaddy',
'.post-navigation',
'.comments-area',
'.comment-respond',
'.related-posts',
'.rx-related-posts',
'.rx-ad',
'.ads',
'.advertisement'
],
wordsPerMinute: 220,
bengaliWordsPerMinute: 180,
codeWordsPerMinute: 120,
imageSeconds: 8,
tableSeconds: 12,
videoSeconds: 20,
audioSeconds: 15,
embedSeconds: 18,
minimumMinute: 1,
roundMode: 'ceil',
showWordCount: true,
showCharacterCount: false,
showProgressBar: true,
showFloatingProgress: false,
showCompletionMessage: false,
autoInject: true,
injectPosition: 'before-content',
progressBarId: 'rx-reading-progress-bar',
progressBarClass: 'rx-reading-progress-bar',
floatingProgressId: 'rx-floating-reading-progress',
floatingProgressClass: 'rx-floating-reading-progress',
completionThreshold: 90,
saveProgress: true,
storagePrefix: 'rx_reading_progress_',
enableMutationObserver: true,
mutationDebounce: 600,
enableEvents: true,
eventPrefix: 'rxReadingTime',
labels: {
minute: 'min read',
minutes: 'min read',
lessThanMinute: 'Less than 1 min read',
word: 'word',
words: 'words',
character: 'character',
characters: 'characters',
completed: 'Reading completed',
progress: 'Reading progress'
},
banglaLabels: {
minute: 'মিনিট পড়া',
minutes: 'মিনিট পড়া',
lessThanMinute: '১ মিনিটের কম',
word: 'শব্দ',
words: 'শব্দ',
character: 'অক্ষর',
characters: 'অক্ষর',
completed: 'পড়া সম্পন্ন',
progress: 'পড়ার অগ্রগতি'
},
debug: false
};
var state = {
initialized: false,
contentElement: null,
titleElement: null,
outputElements: [],
progressBar: null,
floatingProgress: null,
mutationObserver: null,
scrollTicking: false,
mutationTimer: null,
completed: false,
data: {
words: 0,
bengaliWords: 0,
englishWords: 0,
characters: 0,
images: 0,
tables: 0,
videos: 0,
audios: 0,
embeds: 0,
codeBlocks: 0,
seconds: 0,
minutes: 1,
progress: 0,
language: 'en'
}
};
function log() {
if (!DEFAULTS.debug || !window.console) {
return;
}
var args = Array.prototype.slice.call(arguments);
args.unshift('[RX Reading Time]');
window.console.log.apply(window.console, args);
}
function extend(target) {
var sources = Array.prototype.slice.call(arguments, 1);
sources.forEach(function mergeSource(source) {
if (!source) {
return;
}
Object.keys(source).forEach(function copyKey(key) {
if (
Object.prototype.toString.call(source[key]) === '[object Object]' &&
Object.prototype.toString.call(target[key]) === '[object Object]'
) {
target[key] = extend({}, target[key], source[key]);
} else {
target[key] = source[key];
}
});
});
return target;
}
function ready(callback) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', callback, { once: true });
} else {
callback();
}
}
function queryFirst(selectors, root) {
root = root || document;
for (var i = 0; i < selectors.length; i += 1) {
var element = root.querySelector(selectors[i]);
if (element) {
return element;
}
}
return null;
}
function queryAll(selectors, root) {
root = root || document;
var results = [];
selectors.forEach(function eachSelector(selector) {
var nodes = root.querySelectorAll(selector);
Array.prototype.forEach.call(nodes, function eachNode(node) {
if (results.indexOf(node) === -1) {
results.push(node);
}
});
});
return results;
}
function getConfigFromWindow() {
var config = {};
if (window.rxReadingTimeConfig && typeof window.rxReadingTimeConfig === 'object') {
config = window.rxReadingTimeConfig;
}
return config;
}
function getConfigFromDataset(element) {
if (!element || !element.dataset) {
return {};
}
var config = {};
if (element.dataset.rxWordsPerMinute) {
config.wordsPerMinute = parseInt(element.dataset.rxWordsPerMinute, 10);
}
if (element.dataset.rxReadingAutoInject) {
config.autoInject = element.dataset.rxReadingAutoInject !== 'false';
}
if (element.dataset.rxReadingProgressBar) {
config.showProgressBar = element.dataset.rxReadingProgressBar !== 'false';
}
if (element.dataset.rxReadingWordCount) {
config.showWordCount = element.dataset.rxReadingWordCount !== 'false';
}
if (element.dataset.rxReadingPosition) {
config.injectPosition = element.dataset.rxReadingPosition;
}
return config;
}
function normalizeWhitespace(text) {
return String(text || '')
.replace(/\u00a0/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function cloneContentForText(element, config) {
var clone = element.cloneNode(true);
config.excludeSelectors.forEach(function removeSelector(selector) {
var nodes = clone.querySelectorAll(selector);
Array.prototype.forEach.call(nodes, function removeNode(node) {
if (node && node.parentNode) {
node.parentNode.removeChild(node);
}
});
});
return clone;
}
function isBengaliText(text) {
return /[\u0980-\u09FF]/.test(text || '');
}
function countBengaliWords(text) {
var matches = String(text || '').match(/[\u0980-\u09FF]+/g);
return matches ? matches.length : 0;
}
function countEnglishWords(text) {
var cleaned = String(text || '')
.replace(/[\u0980-\u09FF]+/g, ' ')
.replace(/[^\w\s'-]/g, ' ');
var matches = cleaned.match(/[A-Za-z0-9]+(?:['-][A-Za-z0-9]+)?/g);
return matches ? matches.length : 0;
}
function countCharacters(text) {
return normalizeWhitespace(text).replace(/\s/g, '').length;
}
function countElements(element, selector) {
return element.querySelectorAll(selector).length;
}
function countCodeWords(element) {
var codeText = '';
Array.prototype.forEach.call(element.querySelectorAll('pre, code'), function eachCode(node) {
codeText += ' ' + node.textContent;
});
return normalizeWhitespace(codeText).split(/\s+/).filter(Boolean).length;
}
function calculateReadingData(element, config) {
var clone = cloneContentForText(element, config);
var text = normalizeWhitespace(clone.textContent || '');
var bengaliWords = countBengaliWords(text);
var englishWords = countEnglishWords(text);
var totalWords = bengaliWords + englishWords;
var characters = countCharacters(text);
var images = countElements(element, 'img, picture, figure');
var tables = countElements(element, 'table');
var videos = countElements(element, 'video');
var audios = countElements(element, 'audio');
var embeds = countElements(element, 'iframe, embed, object');
var codeBlocks = countElements(element, 'pre, code');
var codeWords = countCodeWords(element);
var normalWords = Math.max(totalWords - codeWords, 0);
var normalSeconds = 0;
if (bengaliWords > englishWords) {
normalSeconds = (normalWords / config.bengaliWordsPerMinute) * 60;
} else {
normalSeconds = (normalWords / config.wordsPerMinute) * 60;
}
var codeSeconds = (codeWords / config.codeWordsPerMinute) * 60;
var mediaSeconds =
images * config.imageSeconds +
tables * config.tableSeconds +
videos * config.videoSeconds +
audios * config.audioSeconds +
embeds * config.embedSeconds;
var totalSeconds = normalSeconds + codeSeconds + mediaSeconds;
var minutes;
if (config.roundMode === 'floor') {
minutes = Math.floor(totalSeconds / 60);
} else if (config.roundMode === 'round') {
minutes = Math.round(totalSeconds / 60);
} else {
minutes = Math.ceil(totalSeconds / 60);
}
minutes = Math.max(minutes, config.minimumMinute);
return {
words: totalWords,
bengaliWords: bengaliWords,
englishWords: englishWords,
characters: characters,
images: images,
tables: tables,
videos: videos,
audios: audios,
embeds: embeds,
codeBlocks: codeBlocks,
codeWords: codeWords,
seconds: Math.round(totalSeconds),
minutes: minutes,
progress: state.data.progress || 0,
language: isBengaliText(text) && bengaliWords > englishWords ? 'bn' : 'en'
};
}
function getLabels(data, config) {
return data.language === 'bn' ? config.banglaLabels : config.labels;
}
function formatReadingTime(data, config) {
var labels = getLabels(data, config);
if (data.seconds < 60) {
return labels.lessThanMinute;
}
var label = data.minutes === 1 ? labels.minute : labels.minutes;
return data.minutes + ' ' + label;
}
function formatWordCount(data, config) {
var labels = getLabels(data, config);
var label = data.words === 1 ? labels.word : labels.words;
return data.words.toLocaleString() + ' ' + label;
}
function formatCharacterCount(data, config) {
var labels = getLabels(data, config);
var label = data.characters === 1 ? labels.character : labels.characters;
return data.characters.toLocaleString() + ' ' + label;
}
function buildOutputText(data, config) {
var parts = [formatReadingTime(data, config)];
if (config.showWordCount) {
parts.push(formatWordCount(data, config));
}
if (config.showCharacterCount) {
parts.push(formatCharacterCount(data, config));
}
return parts.join(' · ');
}
function buildOutputHTML(data, config) {
var readingTime = formatReadingTime(data, config);
var html = '';
html += '<span class="rx-reading-time__time" aria-label="Estimated reading time">';
html += escapeHTML(readingTime);
html += '</span>';
if (config.showWordCount) {
html += '<span class="rx-reading-time__separator" aria-hidden="true"> · </span>';
html += '<span class="rx-reading-time__words">';
html += escapeHTML(formatWordCount(data, config));
html += '</span>';
}
if (config.showCharacterCount) {
html += '<span class="rx-reading-time__separator" aria-hidden="true"> · </span>';
html += '<span class="rx-reading-time__characters">';
html += escapeHTML(formatCharacterCount(data, config));
html += '</span>';
}
return html;
}
function escapeHTML(value) {
return String(value)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function createInjectedOutput(contentElement) {
var wrapper = document.createElement('div');
wrapper.className = 'rx-reading-time rx-reading-time--auto';
wrapper.setAttribute('data-rx-reading-time', '');
wrapper.setAttribute('role', 'note');
wrapper.setAttribute('aria-live', 'polite');
return wrapper;
}
function injectOutputElement(contentElement, config) {
if (!config.autoInject) {
return null;
}
var existing = queryFirst(config.outputSelectors, document);
if (existing) {
return existing;
}
var output = createInjectedOutput(contentElement);
if (config.injectPosition === 'after-title' && state.titleElement && state.titleElement.parentNode) {
state.titleElement.parentNode.insertBefore(output, state.titleElement.nextSibling);
return output;
}
if (config.injectPosition === 'after-content' && contentElement.parentNode) {
contentElement.parentNode.insertBefore(output, contentElement.nextSibling);
return output;
}
if (contentElement.parentNode) {
contentElement.parentNode.insertBefore(output, contentElement);
return output;
}
return null;
}
function renderOutputs(data, config) {
state.outputElements.forEach(function renderOne(output) {
if (!output) {
return;
}
output.innerHTML = buildOutputHTML(data, config);
output.setAttribute('data-rx-reading-minutes', String(data.minutes));
output.setAttribute('data-rx-reading-seconds', String(data.seconds));
output.setAttribute('data-rx-reading-words', String(data.words));
output.setAttribute('data-rx-reading-characters', String(data.characters));
output.setAttribute('data-rx-reading-language', data.language);
output.setAttribute('title', buildOutputText(data, config));
});
}
function createProgressBar(config) {
if (!config.showProgressBar) {
return null;
}
var existing = document.getElementById(config.progressBarId);
if (existing) {
return existing;
}
var bar = document.createElement('div');
var inner = document.createElement('div');
bar.id = config.progressBarId;
bar.className = config.progressBarClass;
bar.setAttribute('aria-hidden', 'true');
inner.className = config.progressBarClass + '__inner';
inner.style.width = '0%';
bar.appendChild(inner);
document.body.appendChild(bar);
return bar;
}
function createFloatingProgress(config) {
if (!config.showFloatingProgress) {
return null;
}
var existing = document.getElementById(config.floatingProgressId);
if (existing) {
return existing;
}
var box = document.createElement('div');
box.id = config.floatingProgressId;
box.className = config.floatingProgressClass;
box.setAttribute('role', 'status');
box.setAttribute('aria-live', 'polite');
box.textContent = '0%';
document.body.appendChild(box);
return box;
}
function getContentProgress(contentElement) {
if (!contentElement) {
return 0;
}
var rect = contentElement.getBoundingClientRect();
var viewportHeight = window.innerHeight || document.documentElement.clientHeight;
var contentHeight = contentElement.offsetHeight || rect.height;
if (contentHeight <= 0) {
return 0;
}
var contentTop = rect.top + window.pageYOffset;
var contentBottom = contentTop + contentHeight;
var scrollTop = window.pageYOffset || document.documentElement.scrollTop;
var start = contentTop - viewportHeight * 0.15;
var end = contentBottom - viewportHeight * 0.85;
if (scrollTop <= start) {
return 0;
}
if (scrollTop >= end) {
return 100;
}
var progress = ((scrollTop - start) / (end - start)) * 100;
return Math.max(0, Math.min(100, progress));
}
function updateProgressUI(progress, config) {
progress = Math.round(progress);
if (state.progressBar) {
var inner = state.progressBar.querySelector('.' + config.progressBarClass + '__inner');
if (inner) {
inner.style.width = progress + '%';
}
state.progressBar.setAttribute('data-progress', String(progress));
}
if (state.floatingProgress) {
var labels = getLabels(state.data, config);
state.floatingProgress.textContent = labels.progress + ': ' + progress + '%';
state.floatingProgress.setAttribute('data-progress', String(progress));
}
}
function getStorageKey() {
var path = window.location.pathname || 'home';
return DEFAULTS.storagePrefix + path.replace(/[^\w-]/g, '_');
}
function saveProgress(progress, config) {
if (!config.saveProgress || !window.localStorage) {
return;
}
try {
window.localStorage.setItem(getStorageKey(), JSON.stringify({
progress: progress,
updatedAt: Date.now(),
url: window.location.href
}));
} catch (error) {
log('Local storage save failed', error);
}
}
function readSavedProgress(config) {
if (!config.saveProgress || !window.localStorage) {
return null;
}
try {
var value = window.localStorage.getItem(getStorageKey());
if (!value) {
return null;
}
return JSON.parse(value);
} catch (error) {
log('Local storage read failed', error);
return null;
}
}
function dispatchEvent(name, detail, config) {
if (!config.enableEvents) {
return;
}
var eventName = config.eventPrefix + ':' + name;
try {
var event = new CustomEvent(eventName, {
bubbles: true,
cancelable: false,
detail: detail
});
document.dispatchEvent(event);
} catch (error) {
log('Event dispatch failed', error);
}
}
function onScroll(config) {
if (state.scrollTicking) {
return;
}
state.scrollTicking = true;
window.requestAnimationFrame(function scrollFrame() {
var progress = getContentProgress(state.contentElement);
state.data.progress = progress;
updateProgressUI(progress, config);
if (progress > 0) {
saveProgress(progress, config);
}
if (!state.completed && progress >= config.completionThreshold) {
state.completed = true;
dispatchEvent('completed', {
version: RX_READING_TIME_VERSION,
data: state.data,
progress: progress
}, config);
if (config.showCompletionMessage) {
showCompletionMessage(config);
}
}
dispatchEvent('progress', {
version: RX_READING_TIME_VERSION,
progress: progress,
data: state.data
}, config);
state.scrollTicking = false;
});
}
function showCompletionMessage(config) {
var labels = getLabels(state.data, config);
var message = document.createElement('div');
message.className = 'rx-reading-completed-message';
message.setAttribute('role', 'status');
message.setAttribute('aria-live', 'polite');
message.textContent = labels.completed;
document.body.appendChild(message);
window.setTimeout(function removeMessage() {
if (message && message.parentNode) {
message.parentNode.removeChild(message);
}
}, 3000);
}
function setupMutationObserver(config) {
if (!config.enableMutationObserver || !window.MutationObserver || !state.contentElement) {
return;
}
if (state.mutationObserver) {
state.mutationObserver.disconnect();
}
state.mutationObserver = new MutationObserver(function onMutate() {
window.clearTimeout(state.mutationTimer);
state.mutationTimer = window.setTimeout(function recalcAfterMutation() {
recalculate(config);
}, config.mutationDebounce);
});
state.mutationObserver.observe(state.contentElement, {
childList: true,
subtree: true,
characterData: true
});
}
function addBaseStyles(config) {
if (document.getElementById('rx-reading-time-style')) {
return;
}
var style = document.createElement('style');
style.id = 'rx-reading-time-style';
style.textContent =
'.rx-reading-time{' +
'display:flex;' +
'align-items:center;' +
'gap:.25rem;' +
'font-size:.9375rem;' +
'line-height:1.5;' +
'color:var(--rx-color-muted,#64748b);' +
'margin:0 0 1rem;' +
'}' +
'.rx-reading-time__time{' +
'font-weight:600;' +
'color:var(--rx-color-text,#1e293b);' +
'}' +
'.rx-reading-time__separator{' +
'color:var(--rx-color-border,#94a3b8);' +
'}' +
'.rx-reading-progress-bar{' +
'position:fixed;' +
'top:0;' +
'left:0;' +
'right:0;' +
'z-index:99999;' +
'height:3px;' +
'background:transparent;' +
'pointer-events:none;' +
'}' +
'.rx-reading-progress-bar__inner{' +
'display:block;' +
'height:100%;' +
'width:0%;' +
'background:var(--rx-primary,#0ea5e9);' +
'transition:width .12s linear;' +
'}' +
'.rx-floating-reading-progress{' +
'position:fixed;' +
'right:1rem;' +
'bottom:1rem;' +
'z-index:99998;' +
'padding:.5rem .75rem;' +
'border-radius:999px;' +
'font-size:.8125rem;' +
'line-height:1;' +
'color:var(--rx-floating-progress-color,#fff);' +
'background:var(--rx-floating-progress-bg,rgba(15,23,42,.88));' +
'box-shadow:0 10px 25px rgba(15,23,42,.15);' +
'}' +
'.rx-reading-completed-message{' +
'position:fixed;' +
'left:50%;' +
'bottom:2rem;' +
'z-index:99999;' +
'transform:translateX(-50%);' +
'padding:.75rem 1rem;' +
'border-radius:.75rem;' +
'font-size:.9375rem;' +
'color:#fff;' +
'background:rgba(15,23,42,.92);' +
'box-shadow:0 20px 40px rgba(15,23,42,.25);' +
'}' +
'@media (prefers-reduced-motion:reduce){' +
'.rx-reading-progress-bar__inner{transition:none;}' +
'}';
document.head.appendChild(style);
}
function recalculate(config) {
if (!state.contentElement) {
return null;
}
state.data = calculateReadingData(state.contentElement, config);
renderOutputs(state.data, config);
onScroll(config);
dispatchEvent('updated', {
version: RX_READING_TIME_VERSION,
data: state.data
}, config);
return state.data;
}
function destroy() {
window.removeEventListener('scroll', state._scrollHandler);
window.removeEventListener('resize', state._resizeHandler);
if (state.mutationObserver) {
state.mutationObserver.disconnect();
state.mutationObserver = null;
}
state.initialized = false;
}
function init(userConfig) {
if (state.initialized) {
return state.data;
}
var windowConfig = getConfigFromWindow();
var contentElement = queryFirst(
userConfig && userConfig.contentSelectors
? userConfig.contentSelectors
: windowConfig.contentSelectors || DEFAULTS.contentSelectors,
document
);
var datasetConfig = getConfigFromDataset(contentElement);
var config = extend({}, DEFAULTS, windowConfig, datasetConfig, userConfig || {});
DEFAULTS.debug = config.debug;
DEFAULTS.storagePrefix = config.storagePrefix;
state.contentElement = contentElement;
state.titleElement = queryFirst(config.titleSelectors, document);
if (!state.contentElement) {
log('No content element found.');
return null;
}
addBaseStyles(config);
var outputElements = queryAll(config.outputSelectors, document);
var injected = injectOutputElement(state.contentElement, config);
if (injected && outputElements.indexOf(injected) === -1) {
outputElements.push(injected);
}
state.outputElements = outputElements;
state.progressBar = createProgressBar(config);
state.floatingProgress = createFloatingProgress(config);
var saved = readSavedProgress(config);
if (saved && typeof saved.progress === 'number') {
state.data.progress = saved.progress;
}
recalculate(config);
state._scrollHandler = function scrollHandler() {
onScroll(config);
};
state._resizeHandler = function resizeHandler() {
onScroll(config);
};
window.addEventListener('scroll', state._scrollHandler, { passive: true });
window.addEventListener('resize', state._resizeHandler, { passive: true });
setupMutationObserver(config);
state.initialized = true;
dispatchEvent('ready', {
version: RX_READING_TIME_VERSION,
data: state.data
}, config);
return state.data;
}
window.RXReadingTime = {
version: RX_READING_TIME_VERSION,
init: init,
recalculate: function publicRecalculate(customConfig) {
var config = extend({}, DEFAULTS, getConfigFromWindow(), customConfig || {});
return recalculate(config);
},
destroy: destroy,
getData: function getData() {
return extend({}, state.data);
},
getProgress: function getProgress() {
return state.data.progress || 0;
},
formatReadingTime: function publicFormatReadingTime(data, customConfig) {
var config = extend({}, DEFAULTS, getConfigFromWindow(), customConfig || {});
return formatReadingTime(data || state.data, config);
}
};
ready(function autoStart() {
init();
});
})(window, document);
Optional HTML output position
Add this inside your single post template where you want reading time to show:
<div class="rx-reading-time" data-rx-reading-time></div>
Optional global setting before loading this chunk
Add before enqueueing this JS, or localize it from WordPress:
<script>
window.rxReadingTimeConfig = {
wordsPerMinute: 220,
bengaliWordsPerMinute: 180,
showWordCount: true,
showCharacterCount: false,
showProgressBar: true,
showFloatingProgress: false,
autoInject: true,
injectPosition: 'after-title'
};
</script>
WordPress enqueue example
wp_enqueue_script(
'rx-reading-time',
get_template_directory_uri() . '/assets/static-js/static-chunks-js/chunk-034-reading-time.js',
array(),
RX_THEME_VERSION,
true
);
This file is safe as a static chunk because it has no jQuery dependency, loads after DOM ready, auto-detects article content, supports Bengali and English reading speed, creates a top progress bar, saves reading progress, and exposes window.RXReadingTime for future RX Theme advanced features.