Robert Birming

Bearlytics writing stats

Bearlytics shows a snapshot of your recent writing: words, averages, longest and shortest posts, and posting rhythm. All pulled from your blog feed.1 2

Have a look below or see it in action on my stats page.

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 to your theme. In the script, replace https://yourblog.com/feed/ with your own feed URL. For Bear Blog, that's your blog address followed by /feed/.

Markup

<div class="bl-widget">
  <p class="bl-label">Last 10 posts</p>
  <div class="bl-grid" id="bl-grid">
    <div class="bl-loading">Fetching stats…</div>
  </div>
</div>

Script

<script>
/* Bearlytics | robertbirming.com */
(function () {

  /* --------------------------------
     Config – swap in your feed URL
  -------------------------------- */
  const FEED_URL = "https://yourblog.com/feed/";

  /* --------------------------------
     Helpers
  -------------------------------- */
  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 stat(label, value) {
    return `
      <div class="bl-row">
        <span class="bl-stat-label">${label}</span>
        <span class="bl-stat-value">${value}</span>
      </div>`;
  }

  function linkedStat(label, post) {
    return `
      <div class="bl-row">
        <a class="bl-stat-label bl-stat-link" href="${post.url}" title="${post.title}">${label} ↗</a>
        <span class="bl-stat-value">${fmt(post.words)} words</span>
      </div>`;
  }

  /* --------------------------------
     Fetch & render
  -------------------------------- */
  async function run() {
    const grid = document.getElementById("bl-grid");
    if (!grid) return;

    try {
      const res = await fetch(FEED_URL, { cache: "no-store" });
      if (!res.ok) throw new Error("Network response was not ok");

      const text = await res.text();
      const xml  = new DOMParser().parseFromString(text, "text/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"));

      let totalWords = 0;
      let shortest   = null;
      let longest    = null;
      const timestamps = [];

      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 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")?.textContent || "",
          words: countWords(content),
          ts,
        };
      }).filter(p => !isNaN(p.ts));

      if (!posts.length) {
        grid.innerHTML = `<p class="bl-empty">No posts found.</p>`;
        return;
      }

      for (const p of posts) {
        totalWords += p.words;
        timestamps.push(p.ts);
        if (!shortest || p.words < shortest.words) shortest = p;
        if (!longest  || p.words > longest.words)  longest  = p;
      }

      const avgWords = Math.round(totalWords / posts.length);

      const sorted = [...timestamps].sort((a, b) => a - b);
      const span   = Math.round((sorted[sorted.length - 1] - sorted[0]) / 86400000);
      const avgGap = posts.length > 1 ? Math.round(span / (posts.length - 1)) : 0;

      grid.innerHTML = `
        <div class="bl-section">
          ${stat("Total words", fmt(totalWords))}
          ${stat("Avg per post", fmt(avgWords))}
        </div>
        <div class="bl-divider"></div>
        <div class="bl-section">
          ${linkedStat("Longest", longest)}
          ${linkedStat("Shortest", shortest)}
        </div>
        <div class="bl-divider"></div>
        <div class="bl-section">
          ${stat("Span",    span   + (span   === 1 ? " day"  : " days"))}
          ${stat("Avg gap", avgGap + (avgGap === 1 ? " day between posts" : " days between posts"))}
        </div>
      `;

    } catch (err) {
      grid.innerHTML = `<p class="bl-error">Couldn't load feed.</p>`;
    }
  }

  run();

})();
</script>

Styles

/* Bearlytics | robertbirming.com */
.bl-widget {
  max-width: 28rem;
  margin-block: var(--space-block);
  padding-block: 1.25rem 1rem;
  padding-inline: 1.5rem;
  color: var(--text);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}

.bl-label {
  margin-block: 0 0.75rem;
  font-size: var(--font-small);
  font-weight: 500;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text);
}

.bl-grid {
  display: flex;
  flex-direction: column;
}

.bl-section {
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
  padding-block: 0.25rem;
}

.bl-divider {
  margin-block: 0.6rem;
  border-block-start: 1px solid var(--border);
}

.bl-row {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 0.75rem;
  align-items: baseline;
}

.bl-stat-label,
.bl-stat-value {
  font-size: var(--font-small);
}

.bl-stat-label {
  color: var(--muted);
  white-space: nowrap;
}

.bl-stat-value {
  font-variant-numeric: tabular-nums;
  text-align: end;
}

.bl-widget a.bl-stat-link,
.bl-widget a.bl-stat-link:visited {
  color: var(--muted);
  text-decoration: none;
}

.bl-loading,
.bl-empty,
.bl-error {
  font-size: var(--font-small);
  color: var(--muted);
  margin: 0;
}

Browse more Bearming add-ons.

Happy blogging, and may the words keep coming.

  1. Requires JavaScript, available on Bear Blog's premium plan. Works with any Atom or RSS feed, not just Bear Blog.

  2. Built for the Bearming theme. Using a different theme? Add the Bearming tokens to make them work with your setup.