Robert Birming

Writing stats

  1. Bear Blog library
  2. Add-ons

A small widget that shows a snapshot of your recent writing: words, longest and shortest posts, and your posting rhythm.

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 wherever you want the widget to appear, then add the script to your footer (Dashboard → Settings → Header and footer directives) and the styles to your theme. Change data-title to rename the heading.

Stats reflect whatever your feed includes, typically your latest 10 posts.

Markup

<div class="ws-widget" data-title="Recent writing"></div>

Script

<script>
/* Writing stats | robertbirming.com */
document.addEventListener('DOMContentLoaded', () => {
  const box = document.querySelector('.ws-widget')
  if (!box || box.dataset.ready) return
  box.dataset.ready = 'true'

  const FEED_SIZE = 10
  const DAY = 864e5
  const lang = document.documentElement.lang || undefined
  const fmt = (n) => n.toLocaleString(lang)
  const segmenter = 'Segmenter' in Intl ? new Intl.Segmenter(lang, { granularity: 'word' }) : null

  const el = (tag, className, text) => {
    const node = document.createElement(tag)
    if (className) node.className = className
    if (text != null) node.textContent = text
    return node
  }

  // Count only the words you wrote: code, captions, and scripts are left out
  const countWords = (html) => {
    const doc = new DOMParser().parseFromString(html, 'text/html')
    doc.body.querySelectorAll('pre, code, figure, figcaption, script, style').forEach((n) => n.remove())
    doc.body.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, blockquote, br, td, th')
      .forEach((n) => { n.before(' '); n.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
  }

  // "Longest →" links to the post, with its title as a tooltip
  const postLink = (label, post) => {
    const a = el('a', 'ws-link', `${label} →`)
    a.href = /^https?:\/\//i.test(post.url) ? post.url : '#'
    a.title = post.title
    return a
  }

  const section = (title, rows) => {
    const wrap = el('div', 'ws-section')
    wrap.append(el('p', 'ws-subtitle', title))
    const list = el('dl', 'ws-list')
    for (const [label, value] of rows) {
      const dt = el('dt')
      dt.append(label)
      list.append(dt, el('dd', null, value))
    }
    wrap.append(list)
    return wrap
  }

  const timeAgo = (d) => {
    const days = Math.round((Date.now() - d) / DAY)
    const text = new Intl.RelativeTimeFormat(lang, { numeric: 'auto' }).format(-days, 'day')
    return text.charAt(0).toLocaleUpperCase(lang) + text.slice(1)
  }

  // "June – October 2026", in the page's language
  const span = (a, b) => {
    const f = new Intl.DateTimeFormat(lang, { month: 'long', year: 'numeric' })
    try { return f.formatRange(a, b) } catch { return `${f.format(a)} – ${f.format(b)}` }
  }

  // [item, count] for the most frequent item
  const mostCommon = (items) => {
    const counts = new Map()
    for (const item of items) counts.set(item, (counts.get(item) || 0) + 1)
    return [...counts].sort((a, b) => b[1] - a[1])[0]
  }

  const readFeed = async () => {
    const res = await fetch('/feed/', { cache: 'no-store' })
    if (!res.ok) throw new Error(res.status)
    const xml = new DOMParser().parseFromString(await res.text(), 'application/xml')
    if (xml.querySelector('parsererror')) throw new Error('Invalid XML')

    const isAtom = xml.querySelector('entry') !== null
    return [...xml.querySelectorAll(isAtom ? 'entry' : 'item')].map((item) => ({
      title: item.querySelector('title')?.textContent.trim() || 'Untitled',
      url: isAtom
        ? (item.querySelector("link[rel='alternate']") || item.querySelector('link'))?.getAttribute('href') || ''
        : item.querySelector('link')?.textContent || '',
      date: new Date(
        (isAtom
          ? item.querySelector('published')?.textContent || item.querySelector('updated')?.textContent
          : item.querySelector('pubDate')?.textContent) || ''
      ),
      words: countWords(
        (isAtom
          ? item.querySelector('content')?.textContent
          : item.getElementsByTagNameNS('http://purl.org/rss/1.0/modules/content/', 'encoded')[0]?.textContent
            || item.querySelector('description')?.textContent) || ''
      ),
    })).filter((p) => !Number.isNaN(p.date.getTime()))
  }

  const wordsSection = (posts) => {
    const wordy = posts.filter((p) => p.words > 0)
    if (!wordy.length) return null
    const total = wordy.reduce((sum, p) => sum + p.words, 0)
    const longest = wordy.reduce((a, b) => (b.words > a.words ? b : a))
    const shortest = wordy.reduce((a, b) => (b.words < a.words ? b : a))
    return section('Words', [
      ['Total words', fmt(total)],
      ['Avg per post', fmt(Math.round(total / wordy.length))],
      [postLink('Longest', longest), `${fmt(longest.words)} words`],
      [postLink('Shortest', shortest), `${fmt(shortest.words)} words`]
    ])
  }

  const rhythmSection = (posts, first, latest) => {
    const rows = [['Latest post', timeAgo(latest)]]

    if (posts.length > 1) {
      const gap = (latest - first) / DAY / (posts.length - 1)
      rows.push(['Posting rhythm',
        gap < 0.5 ? 'Several a day'
        : gap < 1.5 ? 'About daily'
        : `Every ${fmt(Math.round(gap))} days`])
    }

    // 7 Jan 2024 was a Sunday, so day 0–6 maps to a real date for naming
    const [day, dayCount] = mostCommon(posts.map((p) => p.date.getDay()))
    const dayName = new Date(2024, 0, 7 + day).toLocaleDateString(lang, { weekday: 'long' })
    rows.push(['Busiest weekday', `${dayName} (${fmt(dayCount)})`])

    // The longest gap between two posts in a row
    if (posts.length > 1) {
      const sorted = posts.map((p) => p.date.getTime()).sort((a, b) => a - b)
      const gaps = sorted.slice(1).map((t, i) => (t - sorted[i]) / DAY)
      const longest = Math.round(Math.max(...gaps))
      rows.push(['Longest break', longest < 1 ? 'Under a day' : `${fmt(longest)} ${longest === 1 ? 'day' : 'days'}`])
    }

    return section('Rhythm', rows)
  }

  const run = async () => {
    const status = el('p', 'ws-note', 'Counting words…')
    box.replaceChildren(el('p', 'ws-title', box.dataset.title || 'Recent writing'), status)

    let posts
    try {
      posts = await readFeed()
    } catch {
      status.textContent = 'Couldn’t read the feed right now.'
      return
    }

    if (!posts.length) {
      status.textContent = 'No posts to count yet.'
      return
    }

    const times = posts.map((p) => p.date.getTime())
    const first = new Date(Math.min(...times))
    const latest = new Date(Math.max(...times))
    const n = posts.length
    const scope = n === 1 ? '1 post' : n >= FEED_SIZE ? `Latest ${fmt(n)} posts` : `All ${fmt(n)} posts`

    const wrap = el('div', 'ws-sections')
    wrap.append(...[wordsSection(posts), rhythmSection(posts, first, latest)].filter(Boolean))
    status.replaceWith(el('p', 'ws-meta', `${scope} · ${span(first, latest)}`), wrap)
  }

  run()
})
</script>

Styles

/* Writing stats | robertbirming.com */

.ws-widget {
  margin-block: 1.5rem;
  padding: 1.25rem 1.5rem;
  background-color: var(--code-background-color);
  border: 1px solid color-mix(in srgb, var(--text-color), transparent 85%);
  border-radius: 6px;
}

.ws-widget:empty {
  display: none;
}

.ws-title {
  margin-block: 0 0.25rem;
  font-weight: 700;
  color: var(--heading-color, var(--text-color));
}

.ws-meta,
.ws-note {
  margin-block: 0 1rem;
  font-size: calc(var(--font-scale) * 0.85);
  color: color-mix(in srgb, var(--text-color), transparent 40%);
}

/* Words and Rhythm sit side by side when there's room */
.ws-sections {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(14rem, 100%), 1fr));
  gap: 1.5rem 2rem;
}

.ws-subtitle {
  margin-block: 0 0.75rem;
  padding-block-end: 0.5rem;
  font-size: calc(var(--font-scale) * 0.75);
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: color-mix(in srgb, var(--text-color), transparent 40%);
  border-block-end: 1px solid color-mix(in srgb, var(--text-color), transparent 85%);
}

.ws-list {
  display: grid;
  grid-template-columns: auto 1fr;
  align-items: baseline;
  gap: 0.5rem 1rem;
  margin: 0;
  font-size: calc(var(--font-scale) * 0.9);
  line-height: 1.3;
}

.ws-list dt,
.ws-list dd {
  margin: 0;
}

.ws-list dt {
  white-space: nowrap;
  color: color-mix(in srgb, var(--text-color), transparent 40%);
}

.ws-list dd {
  min-width: 0;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  text-align: end;
  text-wrap: pretty;
}

.ws-list a.ws-link,
.ws-list a.ws-link:visited {
  color: inherit;
  text-decoration: none;
}

@media (hover: hover) {
  .ws-list a.ws-link:hover {
    color: var(--link-color);
    text-decoration: underline;
    text-underline-offset: 0.2em;
  }
}

Happy blogging, word by word.

Want more? Check out the full Bear Blog library.

  1. Requires JavaScript, available on Bear Blog's premium plan.↩