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>
/* Writing stats | robertbirming.com */
document.addEventListener('DOMContentLoaded', () => {
const grid = document.getElementById('ws-grid')
if (!grid) return
const nf = new Intl.NumberFormat('en')
const segmenter = 'Segmenter' in Intl
? new Intl.Segmenter('en', { granularity: 'word' })
: null
const countWords = (html) => {
const doc = new DOMParser().parseFromString(html, 'text/html')
doc.querySelectorAll('pre, code, script, style').forEach((el) => {
el.replaceWith(' ')
})
doc.body.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, blockquote, br, hr, td, th')
.forEach((el) => {
el.before(' ')
el.after(' ')
})
const text = doc.body.textContent.trim()
if (!text) return 0
if (!segmenter) return text.split(/\s+/).length
let n = 0
for (const s of segmenter.segment(text)) if (s.isWordLike) n++
return n
}
const message = (className, text) => {
const p = document.createElement('p')
p.className = className
p.textContent = text
grid.replaceChildren(p)
}
const row = (label, ...content) => {
const dt = document.createElement('dt')
dt.textContent = label
const dd = document.createElement('dd')
dd.append(...content)
return [dt, dd]
}
const postRow = (label, post) => {
const link = document.createElement('a')
link.href = /^https?:\/\//i.test(post.url) ? post.url : '#'
link.textContent = post.title
return row(label, link, ` · ${nf.format(post.words)} words`)
}
fetch('/feed/', { cache: 'no-store' })
.then((res) => {
if (!res.ok) throw new Error(res.status)
return res.text()
})
.then((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 = [...xml.querySelectorAll(isAtom ? 'entry' : 'item')]
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 url = isAtom
? el.querySelector("link[rel='alternate']")?.getAttribute('href')
|| el.querySelector('link')?.getAttribute('href')
: el.querySelector('link')?.textContent
return {
title: el.querySelector('title')?.textContent || 'Untitled',
url: url || '',
words: countWords(content || ''),
}
}).filter((p) => p.words > 0)
if (!posts.length) return message('ws-empty', 'No posts found.')
const total = posts.reduce((sum, p) => sum + p.words, 0)
const sorted = [...posts].sort((a, b) => a.words - b.words)
const list = document.createElement('dl')
list.className = 'ws-list'
list.append(
...row('Total words', nf.format(total)),
...row('Average per post', nf.format(Math.round(total / posts.length))),
...postRow('Longest post', sorted.at(-1)),
...postRow('Shortest post', sorted[0])
)
grid.replaceChildren(list)
})
.catch(() => message('ws-error', "Couldn't load feed."))
})
</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.↩