Bear Blog writing stats
A small widget that shows a snapshot of your recent writing: total words, average length, and your longest and shortest posts.
It reads straight from your blog feed, so there's nothing to update by hand, the numbers stay current automatically as you publish.1
Preview
How to use
Add the markup below wherever you want the widget to appear, then add the script and styles. The script fetches your blog's feed and fills in the numbers automatically.
Stats reflect whatever your feed includes, typically the latest 10 posts, since that's what Bear Blog's feed returns.
Markup
<div class="ws-widget">
<p class="ws-label-title">Last 10 posts</p>
<div class="ws-grid" id="ws-grid">
<p class="ws-loading">Fetching stats…</p>
</div>
</div>
Script
<script>
document.addEventListener('DOMContentLoaded', function () {
const grid = document.getElementById('ws-grid')
if (!grid) return
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 createRow(label, value) {
const row = document.createElement('div')
row.className = 'ws-row'
const labelEl = document.createElement('span')
labelEl.className = 'ws-label'
labelEl.textContent = label
const valueEl = document.createElement('span')
valueEl.className = 'ws-value'
valueEl.textContent = value
row.append(labelEl, valueEl)
return row
}
function createLinkedRow(label, post) {
const row = document.createElement('div')
row.className = 'ws-row'
const link = document.createElement('a')
link.className = 'ws-label ws-link'
link.textContent = `${label} →`
link.title = post.title
link.rel = 'noopener noreferrer'
try {
const url = new URL(post.url)
link.href = (url.protocol === 'https:' || url.protocol === 'http:') ? url.href : '#'
} catch {
link.href = '#'
}
const valueEl = document.createElement('span')
valueEl.className = 'ws-value'
valueEl.textContent = `${fmt(post.words)} words`
row.append(link, valueEl)
return row
}
fetch('/feed/', { cache: 'no-store' })
.then(function (res) { return res.text() })
.then(function (text) {
const xml = new DOMParser().parseFromString(text, 'application/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'))
const posts = entries.map(function (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')?.getAttribute('href') || '')
: (el.querySelector('link')?.textContent || ''),
words: countWords(content),
ts,
}
}).filter(function (p) { return !isNaN(p.ts) })
if (!posts.length) {
grid.replaceChildren()
const empty = document.createElement('p')
empty.className = 'ws-empty'
empty.textContent = 'No posts found.'
grid.appendChild(empty)
return
}
let totalWords = 0
let shortest = null
let longest = null
for (const p of posts) {
totalWords += p.words
if (!shortest || p.words < shortest.words) shortest = p
if (!longest || p.words > longest.words) longest = p
}
const avgWords = Math.round(totalWords / posts.length)
grid.replaceChildren(
createRow('Total words', fmt(totalWords)),
createRow('Avg per post', fmt(avgWords)),
createLinkedRow('Longest', longest),
createLinkedRow('Shortest', shortest)
)
})
.catch(function () {
grid.replaceChildren()
const error = document.createElement('p')
error.className = 'ws-error'
error.textContent = "Couldn't load feed."
grid.appendChild(error)
})
})
</script>
Styles
.ws-widget {
max-width: 24rem;
margin-block: 1.5rem;
padding-block: 1rem;
padding-inline: 1.25rem;
background-color: var(--code-background-color);
border: 1px solid color-mix(in srgb, var(--text-color), transparent 85%);
border-radius: 6px;
}
.ws-label-title {
margin-block: 0 0.75rem;
font-size: calc(var(--font-scale) * 0.85);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-color);
}
.ws-grid {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.ws-row {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.75rem;
align-items: baseline;
padding-block: 0.15rem;
}
.ws-label,
.ws-value {
font-size: calc(var(--font-scale) * 0.9);
}
.ws-label {
color: color-mix(in srgb, var(--text-color), transparent 40%);
white-space: nowrap;
}
.ws-value {
font-variant-numeric: tabular-nums;
text-align: end;
}
.ws-widget a.ws-link,
.ws-widget a.ws-link:visited {
color: color-mix(in srgb, var(--text-color), transparent 40%);
text-decoration: none;
}
.ws-widget a.ws-link:hover {
text-decoration: underline;
text-underline-offset: 0.2em;
}
.ws-loading,
.ws-empty,
.ws-error {
margin: 0;
font-size: calc(var(--font-scale) * 0.9);
color: color-mix(in srgb, var(--text-color), transparent 40%);
}
Want more? Check out the full Bear Blog library.
Requires JavaScript, available with Bear Blog subscription.↩