Bearlytics writing stats
Bearlytics shows a snapshot of your recent writing: words, averages, longest and shortest posts, and posting rhythm. All pulled from your blog feed.1 2
Preview
How to use
Add the markup below wherever you want the widget to appear, then add the script and styles to your theme. In the script, replace https://yourblog.com/feed/ with your own feed URL. For Bear Blog, that's your blog address followed by /feed/.
Markup
<div class="bl-widget">
<p class="bl-label">Last 10 posts</p>
<div class="bl-grid" id="bl-grid">
<div class="bl-loading">Fetching stats…</div>
</div>
</div>
Script
<script>
/* Bearlytics | robertbirming.com */
(function () {
/* --------------------------------
Config – swap in your feed URL
-------------------------------- */
const FEED_URL = "https://yourblog.com/feed/";
/* --------------------------------
Helpers
-------------------------------- */
function countWords(html) {
const stripped = html
.replace(/<pre[\s\S]*?<\/pre>/gi, " ")
.replace(/<code[\s\S]*?<\/code>/gi, " ");
const text = stripped.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
return text ? text.split(" ").length : 0;
}
function fmt(n) {
return n.toLocaleString();
}
function stat(label, value) {
return `
<div class="bl-row">
<span class="bl-stat-label">${label}</span>
<span class="bl-stat-value">${value}</span>
</div>`;
}
function linkedStat(label, post) {
return `
<div class="bl-row">
<a class="bl-stat-label bl-stat-link" href="${post.url}" title="${post.title}">${label} ↗</a>
<span class="bl-stat-value">${fmt(post.words)} words</span>
</div>`;
}
/* --------------------------------
Fetch & render
-------------------------------- */
async function run() {
const grid = document.getElementById("bl-grid");
if (!grid) return;
try {
const res = await fetch(FEED_URL, { cache: "no-store" });
if (!res.ok) throw new Error("Network response was not ok");
const text = await res.text();
const xml = new DOMParser().parseFromString(text, "text/xml");
if (xml.querySelector("parsererror")) throw new Error("Invalid XML");
const isAtom = xml.querySelector("entry") !== null;
const entries = Array.from(xml.querySelectorAll(isAtom ? "entry" : "item"));
let totalWords = 0;
let shortest = null;
let longest = null;
const timestamps = [];
const posts = entries.map(el => {
const content = isAtom
? (el.querySelector("content")?.textContent || "")
: (el.getElementsByTagNameNS("http://purl.org/rss/1.0/modules/content/", "encoded")[0]?.textContent
|| el.querySelector("description")?.textContent || "");
const dateStr = isAtom
? el.querySelector("published")?.textContent
: el.querySelector("pubDate")?.textContent;
const ts = dateStr ? new Date(dateStr).getTime() : NaN;
return {
title: el.querySelector("title")?.textContent || "Untitled",
url: isAtom
? el.querySelector("link[rel='alternate']")?.getAttribute("href") || ""
: el.querySelector("link")?.textContent || "",
words: countWords(content),
ts,
};
}).filter(p => !isNaN(p.ts));
if (!posts.length) {
grid.innerHTML = `<p class="bl-empty">No posts found.</p>`;
return;
}
for (const p of posts) {
totalWords += p.words;
timestamps.push(p.ts);
if (!shortest || p.words < shortest.words) shortest = p;
if (!longest || p.words > longest.words) longest = p;
}
const avgWords = Math.round(totalWords / posts.length);
const sorted = [...timestamps].sort((a, b) => a - b);
const span = Math.round((sorted[sorted.length - 1] - sorted[0]) / 86400000);
const avgGap = posts.length > 1 ? Math.round(span / (posts.length - 1)) : 0;
grid.innerHTML = `
<div class="bl-section">
${stat("Total words", fmt(totalWords))}
${stat("Avg per post", fmt(avgWords))}
</div>
<div class="bl-divider"></div>
<div class="bl-section">
${linkedStat("Longest", longest)}
${linkedStat("Shortest", shortest)}
</div>
<div class="bl-divider"></div>
<div class="bl-section">
${stat("Span", span + (span === 1 ? " day" : " days"))}
${stat("Avg gap", avgGap + (avgGap === 1 ? " day between posts" : " days between posts"))}
</div>
`;
} catch (err) {
grid.innerHTML = `<p class="bl-error">Couldn't load feed.</p>`;
}
}
run();
})();
</script>
Styles
/* Bearlytics | robertbirming.com */
.bl-widget {
max-width: 28rem;
margin-block: var(--space-block);
padding-block: 1.25rem 1rem;
padding-inline: 1.5rem;
color: var(--text);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.bl-label {
margin-block: 0 0.75rem;
font-size: var(--font-small);
font-weight: 500;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text);
}
.bl-grid {
display: flex;
flex-direction: column;
}
.bl-section {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding-block: 0.25rem;
}
.bl-divider {
margin-block: 0.6rem;
border-block-start: 1px solid var(--border);
}
.bl-row {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.75rem;
align-items: baseline;
}
.bl-stat-label,
.bl-stat-value {
font-size: var(--font-small);
}
.bl-stat-label {
color: var(--muted);
white-space: nowrap;
}
.bl-stat-value {
font-variant-numeric: tabular-nums;
text-align: end;
}
.bl-widget a.bl-stat-link,
.bl-widget a.bl-stat-link:visited {
color: var(--muted);
text-decoration: none;
}
.bl-loading,
.bl-empty,
.bl-error {
font-size: var(--font-small);
color: var(--muted);
margin: 0;
}
Browse more Bearming add-ons.
Happy blogging, and may the words keep coming.
Requires JavaScript, available on Bear Blog's premium plan. Works with any Atom or RSS feed, not just Bear Blog.↩
Built for the Bearming theme. Using a different theme? Add the Bearming tokens to make them work with your setup.↩