Robert Birming

Blog archive

  1. Bear Blog library
  2. Add-ons

Groups your blog posts by month, with year filtering, live search, and pagination.

Plain JavaScript, no dependencies, works on Bear's default markup. Drop it in once and it rebuilds your existing post list into a searchable, filterable archive. It's also available in Herman's Bear plugins collection.1 2

Tags are picked up automatically from Bear's tag list, if present, and shown below the search controls.

Preview

Blog archive add-on showing year dropdown, search field, and posts grouped by month

How to use

  1. Copy the script below and paste it into your blog's footer directive.
  2. Copy the styles into your theme. They use Bear's default token names, so they should work with most themes without changes.
  3. Enjoy and keep blogging.

Note: the add-on only runs on pages where <body> has the blog class, matching Bear's own plugin convention. If your archive doesn't activate, add class_name: blog to the page's settings.

Script

<script>
/*
 Plugin name: Blog archive
 Description: Groups blog posts by month with year filtering, search, and pagination.
 Author: Robert Birming
 Author URI: https://robertbirming.com
*/
(function () {

  "use strict";

  const PARAM_YEAR = "y";
  const PARAM_SEARCH = "s";
  const PARAM_PAGE = "p";

  const POSTS_PER_PAGE = 25;
  const SEARCH_DEBOUNCE_MS = 140;

  const ID_WRAPPER = "blog-archive";
  const ID_YEAR = "blog-archive-year";
  const ID_SEARCH = "blog-archive-search";
  const ID_PAGINATION = "blog-archive-pagination";
  const ID_PREV = "blog-archive-prev";
  const ID_NEXT = "blog-archive-next";
  const ID_INFO = "blog-archive-info";
  const ID_EMPTY = "blog-archive-empty";

  const showEl = (el) => { el.hidden = false; el.style.display = ""; };
  const hideEl = (el) => { el.hidden = true; el.style.display = "none"; };

  const normalizeText = (s) => (s || "").normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase();

  function init() {
    if (!document.body.classList.contains("blog")) return;

    const main = document.querySelector("main");
    if (!main) return;

    if (main.querySelector("#" + ID_WRAPPER)) return;

    const sourceList =
      main.querySelector("ul.embedded.blog-posts") ||
      main.querySelector("ul.blog-posts");
    if (!sourceList) return;

    const rawItems = Array.from(sourceList.querySelectorAll("li"));
    if (!rawItems.length) return;

    const legacySearch = main.querySelector("#searchInput");
    if (legacySearch) legacySearch.remove();

    const tagsBlock = main.querySelector(".tags")?.closest("small");

    const clamp = (n, min, max) => Math.min(max, Math.max(min, n));

    const parseDatetime = (dt) => {
      const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dt);
      return m ? new Date(Date.UTC(+m[1], +m[2] - 1, +m[3])) : new Date(dt);
    };

    const readParams = () => new URLSearchParams(location.search);

    // Uses replaceState (not pushState) so filtering/searching doesn't clutter browser history
    const writeParams = (p) => {
      const url = new URL(location.href);
      url.pathname = url.pathname.endsWith("/") ? url.pathname : url.pathname + "/";
      url.search = p.toString();
      history.replaceState(null, "", url.toString());
    };

    const setDisabled = (btn, disabled) => {
      btn.disabled = !!disabled;
    };

    const groups = Object.create(null);
    const years = Object.create(null);
    const allItems = [];

    for (let i = 0; i < rawItems.length; i++) {
      const li = rawItems[i];

      li.style.display = "";

      const time = li.querySelector("time[datetime]");
      if (!time) continue;

      const dt = time.getAttribute("datetime");
      if (!dt) continue;

      const date = parseDatetime(dt);
      if (Number.isNaN(date.getTime())) continue;

      const year = String(date.getUTCFullYear());
      const month = String(date.getUTCMonth() + 1).padStart(2, "0");
      const monthKey = year + "-" + month;

      const label = date.toLocaleDateString("en-US", {
        month: "long",
        year: "numeric",
        timeZone: "UTC",
      });

      const anchor = li.querySelector("a");
      li.dataset.blogArchiveYear = year;
      li.dataset.blogArchiveText = normalizeText((anchor?.textContent || "").trim());

      years[year] = (years[year] || 0) + 1;

      if (!groups[monthKey]) groups[monthKey] = { label, date, items: [] };
      groups[monthKey].items.push(li);

      allItems.push(li);
    }

    if (!allItems.length) return;

    const sortedMonths = Object.keys(groups).sort((a, b) => groups[b].date - groups[a].date);

    sourceList.remove();

    const wrapper = document.createElement("div");
    wrapper.id = ID_WRAPPER;
    main.appendChild(wrapper);

    const controls = document.createElement("p");

    const yearSelect = document.createElement("select");
    yearSelect.id = ID_YEAR;
    yearSelect.setAttribute("aria-label", "Filter posts by year");
    yearSelect.setAttribute("aria-controls", wrapper.id);

    const optAll = document.createElement("option");
    optAll.value = "";
    optAll.textContent = "All posts";
    yearSelect.appendChild(optAll);

    Object.keys(years)
      .sort((a, b) => Number(b) - Number(a))
      .forEach((y) => {
        const opt = document.createElement("option");
        opt.value = y;
        opt.textContent = y + " (" + years[y] + ")";
        yearSelect.appendChild(opt);
      });

    controls.appendChild(yearSelect);

    const searchInput = document.createElement("input");
    searchInput.type = "search";
    searchInput.id = ID_SEARCH;
    searchInput.placeholder = "Search\u2026";
    searchInput.autocomplete = "off";
    searchInput.spellcheck = false;
    searchInput.setAttribute("aria-label", "Search posts");
    searchInput.setAttribute("aria-controls", wrapper.id);

    controls.appendChild(searchInput);

    wrapper.appendChild(controls);

    if (tagsBlock) wrapper.appendChild(tagsBlock);

    const emptyMsg = document.createElement("p");
    emptyMsg.id = ID_EMPTY;
    hideEl(emptyMsg);
    wrapper.appendChild(emptyMsg);

    const months = [];

    for (let mi = 0; mi < sortedMonths.length; mi++) {
      const key = sortedMonths[mi];
      const g = groups[key];

      const h3 = document.createElement("h3");
      h3.textContent = g.label;

      const ul = document.createElement("ul");
      ul.className = "blog-posts";

      for (let j = 0; j < g.items.length; j++) {
        showEl(g.items[j]);
        ul.appendChild(g.items[j]);
      }

      wrapper.appendChild(h3);
      wrapper.appendChild(ul);

      months.push({ h3, ul });
    }

    const pagination = document.createElement("p");
    pagination.id = ID_PAGINATION;

    const prevBtn = document.createElement("button");
    prevBtn.type = "button";
    prevBtn.id = ID_PREV;
    prevBtn.textContent = "Previous";
    prevBtn.setAttribute("aria-controls", wrapper.id);

    const info = document.createElement("span");
    info.id = ID_INFO;
    info.setAttribute("aria-live", "polite");

    const nextBtn = document.createElement("button");
    nextBtn.type = "button";
    nextBtn.id = ID_NEXT;
    nextBtn.textContent = "Next";
    nextBtn.setAttribute("aria-controls", wrapper.id);

    pagination.appendChild(prevBtn);
    pagination.appendChild(info);
    pagination.appendChild(nextBtn);
    wrapper.appendChild(pagination);

    let currentPage = 1;
    let debounceId = null;

    const clearPendingSearch = () => {
      if (debounceId) {
        window.clearTimeout(debounceId);
        debounceId = null;
      }
    };

    const getFiltered = () => {
      const yr = yearSelect.value;
      const term = normalizeText(searchInput.value.trim());

      if (!yr && !term) return allItems;

      return allItems.filter((item) => {
        if (yr && item.dataset.blogArchiveYear !== yr) return false;
        if (term && !(item.dataset.blogArchiveText || "").includes(term)) return false;
        return true;
      });
    };

    const updateEmptyMessage = () => {
      const yr = yearSelect.value;
      const term = searchInput.value.trim();
      const parts = [];
      if (yr) parts.push(yr);
      if (term) parts.push("\u201C" + term + "\u201D");
      emptyMsg.textContent = "No posts found" + (parts.length ? " for " + parts.join(", ") : "") + ".";
    };

    const renderPageItems = (pageItems, hasAnyResults) => {
      allItems.forEach(hideEl);
      pageItems.forEach(showEl);

      months.forEach(({ h3, ul }) => {
        const anyVisible = Array.from(ul.children).some((c) => !c.hidden);
        if (anyVisible) {
          showEl(ul);
          showEl(h3);
        } else {
          hideEl(ul);
          hideEl(h3);
        }
      });

      hasAnyResults ? hideEl(emptyMsg) : showEl(emptyMsg);
    };

    const syncUrl = () => {
      const yr = yearSelect.value;
      const term = searchInput.value.trim();
      const p = readParams();
      yr ? p.set(PARAM_YEAR, yr) : p.delete(PARAM_YEAR);
      term ? p.set(PARAM_SEARCH, term) : p.delete(PARAM_SEARCH);
      currentPage > 1 ? p.set(PARAM_PAGE, String(currentPage)) : p.delete(PARAM_PAGE);
      writeParams(p);
    };

    const update = () => {
      const filtered = getFiltered();
      const hasAnyResults = filtered.length > 0;
      const totalPages = Math.max(1, Math.ceil(filtered.length / POSTS_PER_PAGE));
      currentPage = clamp(currentPage, 1, totalPages);

      const start = (currentPage - 1) * POSTS_PER_PAGE;
      renderPageItems(filtered.slice(start, start + POSTS_PER_PAGE), hasAnyResults);

      if (hasAnyResults) {
        info.textContent = "Page " + currentPage + " of " + totalPages;
        setDisabled(prevBtn, currentPage === 1);
        setDisabled(nextBtn, currentPage === totalPages);
        filtered.length <= POSTS_PER_PAGE ? hideEl(pagination) : showEl(pagination);
      } else {
        info.textContent = "";
        hideEl(pagination);
      }

      updateEmptyMessage();
      syncUrl();
    };

    const p0 = readParams();
    yearSelect.value = p0.get(PARAM_YEAR) || "";
    searchInput.value = p0.get(PARAM_SEARCH) || "";

    const page0 = parseInt(p0.get(PARAM_PAGE) || "1", 10);
    currentPage = Number.isFinite(page0) && page0 > 0 ? page0 : 1;

    const scrollToTop = () => {
      wrapper.scrollIntoView({ behavior: "smooth" });
    };

    yearSelect.addEventListener("change", () => {
      clearPendingSearch();
      currentPage = 1;
      update();
    });

    let lastHandledValue = null;

    const runSearchUpdate = () => {
      const value = searchInput.value;
      if (value === lastHandledValue) return;
      lastHandledValue = value;
      currentPage = 1;
      clearPendingSearch();
      update();
    };

    searchInput.addEventListener("input", () => {
      if (!searchInput.value.trim()) {
        runSearchUpdate();
        return;
      }
      currentPage = 1;
      clearPendingSearch();
      // Debounce while typing to avoid re-filtering on every keystroke
      debounceId = window.setTimeout(() => {
        debounceId = null;
        lastHandledValue = searchInput.value;
        update();
      }, SEARCH_DEBOUNCE_MS);
    });

    searchInput.addEventListener("search", runSearchUpdate);

    prevBtn.addEventListener("click", () => {
      if (prevBtn.disabled) return;
      currentPage -= 1;
      update();
      scrollToTop();
    });

    nextBtn.addEventListener("click", () => {
      if (nextBtn.disabled) return;
      currentPage += 1;
      update();
      scrollToTop();
    });

    update();
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", init, { once: true });
  } else {
    init();
  }

})();
</script>

Styles

/* Blog archive | robertbirming.com */
#blog-archive > p:first-child {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5em;
  margin-block-end: 2.5rem;
}

:is(#blog-archive-year, #blog-archive-search, #blog-archive-pagination button) {
  appearance: none;
  margin: 0;
  padding: 0.6em 0.9em;
  border: 1px solid color-mix(in srgb, currentColor 25%, transparent);
  border-radius: 4px;
  font: inherit;
  line-height: 1.3;
  color: inherit;
  background-color: transparent;
}

#blog-archive-year {
  padding-inline-end: 2.25em;
  background-image:
    linear-gradient(45deg, transparent 50%, currentColor 50%),
    linear-gradient(135deg, currentColor 50%, transparent 50%);
  background-position: calc(100% - 1.2em) 55%, calc(100% - 0.85em) 55%;
  background-size: 0.35em 0.35em;
  background-repeat: no-repeat;
  cursor: pointer;
}

#blog-archive-search {
  flex: 1 1 12em;
}

#blog-archive-search::placeholder {
  color: color-mix(in srgb, currentColor 70%, transparent);
}

#blog-archive ul + h3 {
  margin-block-start: 2.5rem;
}

#blog-archive-pagination {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1em;
  margin-block-start: 2.5rem;
}

#blog-archive-prev::before {
  content: "\2190\00a0";
  content: "\2190\00a0" / "";
}

#blog-archive-next::after {
  content: "\00a0\2192";
  content: "\00a0\2192" / "";
}

#blog-archive-info,
#blog-archive-empty {
  font-size: 0.875em;
  color: color-mix(in srgb, var(--text-color) 80%, transparent);
}

#blog-archive-pagination button {
  cursor: pointer;
}

@media (hover: hover) {
  #blog-archive-pagination button:not(:disabled):hover {
    border-color: var(--link-color);
    color: var(--link-color);
  }
}

#blog-archive-pagination button:disabled {
  opacity: 0.4;
  cursor: default;
}

No JavaScript?

Bear's {{ posts|from:YYYY-MM-DD|to:YYYY-MM-DD }} variable filters posts by date, so you can build a simple yearly archive without any script. Create a page for each year, add the date range, and link the years together:

{{ posts|from:2024-01-01|to:2024-12-31 }}

<div class="year-nav">
  <ul>
    <li><a href="/archive/2025/">2025</a></li>
    <li><a href="/archive/2024/">2024</a></li>
  </ul>
</div>
/* Yearly archives | robertbirming.com */

.year-nav ul {
  display: flex;
  flex-wrap: wrap;
  gap: 0.3em 0.6em;
  margin-block: 0 1.5em;
  padding: 0;
  list-style: none;
  font-size: calc(var(--font-scale) * 0.9);
  color: color-mix(in srgb, var(--text-color), transparent 45%);
}

.year-nav li:not(:last-child)::after {
  content: "·";
  margin-inline-start: 0.6em;
  opacity: 0.5;
}

Happy blogging, and may your archives always be easy to explore.

Want more? Check out the full Bear Blog library.

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

  2. Inspired by the great work of Herman and Mighil's post sorting script.↩