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>
/* 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.

  1. Requires JavaScript, available with Bear Blog subscription.↩