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

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

Note that stats reflect whatever your feed includes — typically the latest 10–20 posts, depending on your feed settings.

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 createStatRow(label, value) {
    const row = document.createElement("div");
    row.className = "bl-row";

    const labelEl = document.createElement("span");
    labelEl.className = "bl-stat-label";
    labelEl.textContent = label;

    const valueEl = document.createElement("span");
    valueEl.className = "bl-stat-value";
    valueEl.textContent = value;

    row.append(labelEl, valueEl);
    return row;
  }

  function createLinkedStatRow(label, post) {
    const row = document.createElement("div");
    row.className = "bl-row";

    const link = document.createElement("a");
    link.className = "bl-stat-label bl-stat-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 = "bl-stat-value";
    valueEl.textContent = `${fmt(post.words)} words`;

    row.append(link, valueEl);
    return row;
  }

  /* --------------------------------
     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.replaceChildren();
        const empty = document.createElement("p");
        empty.className = "bl-empty";
        empty.textContent = "No posts found.";
        grid.appendChild(empty);
        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 avgGap = posts.length > 1
        ? Math.round((sorted[sorted.length - 1] - sorted[0]) / 86400000 / (posts.length - 1))
        : 0;

      const section1 = document.createElement("div");
      section1.className = "bl-section";
      section1.append(
        createStatRow("Total words", fmt(totalWords)),
        createStatRow("Avg per post", fmt(avgWords))
      );

      const divider1 = document.createElement("div");
      divider1.className = "bl-divider";

      const section2 = document.createElement("div");
      section2.className = "bl-section";
      section2.append(
        createLinkedStatRow("Longest", longest),
        createLinkedStatRow("Shortest", shortest)
      );

      const divider2 = document.createElement("div");
      divider2.className = "bl-divider";

      const section3 = document.createElement("div");
      section3.className = "bl-section";
      section3.append(
        createStatRow("Avg gap", avgGap + (avgGap === 1 ? " day between posts" : " days between posts"))
      );

      grid.replaceChildren(section1, divider1, section2, divider2, section3);

    } catch (err) {
      grid.replaceChildren();
      const error = document.createElement("p");
      error.className = "bl-error";
      error.textContent = "Couldn't load feed.";
      grid.appendChild(error);
    }
  }

  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;
}

Happy blogging, and happy writing.

Want more? Check out all available Bearming add-ons.

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

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