Robert Birming

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

Last 10 posts

Fetching stats…

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.

  1. Requires JavaScript, available with Bear Blog subscription.