Robert Birming

Bear archive toolkit

Turns your blog archive into something worth exploring. Groups posts by month, adds search, pagination, and a year filter with post counts, and creates shareable URLs for every search and filter.1

Preview

Head over to my blog archive to see it in action.

How to use

  1. Copy the script below and paste it into your Bear footer.
  2. Copy the styles into your theme.
  3. Enjoy and keep blogging.

Script

<script>
/* Archive toolkit | robertbirming.com */
(function () {
  "use strict";

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

  var POSTS_PER_PAGE = 25;
  var SEARCH_DEBOUNCE_MS = 140;

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

  var showEl = function (el) { el.hidden = false; };
  var hideEl = function (el) { el.hidden = true; };

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

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

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

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

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

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

    var tagsEl = main.querySelector("#tags");
    var tagsBlock = tagsEl ? tagsEl.closest("small") : null;

    var clamp = function (n, min, max) { return Math.min(max, Math.max(min, n)); };

    var parseDatetime = function (dt) {
      var 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);
    };

    var readParams = function () { return new URLSearchParams(location.search); };

    var writeParams = function (p) {
      var url = new URL(location.href);
      url.pathname = url.pathname.endsWith("/") ? url.pathname : url.pathname + "/";
      url.search = p.toString();
      history.replaceState(null, "", url.toString());
    };

    var setDisabled = function (btn, disabled) {
      btn.disabled = !!disabled;
      btn.setAttribute("aria-disabled", disabled ? "true" : "false");
    };

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

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

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

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

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

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

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

      li.dataset.bearmingArchiveYear = year;
      li.dataset.bearmingArchiveText = (li.textContent || "").toLowerCase();

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

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

      allItems.push(li);
    }

    if (!allItems.length) return;

    var sortedMonths = Object.keys(groups).sort(function (a, b) {
      return groups[b].date - groups[a].date;
    });

    sourceList.remove();

    var wrapper = document.createElement("div");
    wrapper.id = ID_WRAPPER;
    wrapper.className = "bearming-archive";
    main.appendChild(wrapper);

    var controls = document.createElement("div");
    controls.className = "bearming-archive-controls";

    var selectWrap = document.createElement("div");
    selectWrap.className = "bearming-archive-select";

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

    var optAll = document.createElement("option");
    optAll.value = "";
    optAll.textContent = "All posts (" + allItems.length + ")";
    yearSelect.appendChild(optAll);

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

    selectWrap.appendChild(yearSelect);

    var 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(selectWrap);
    controls.appendChild(searchInput);
    wrapper.appendChild(controls);

    var emptyMsg = document.createElement("p");
    emptyMsg.id = ID_EMPTY;
    emptyMsg.className = "bearming-archive-empty";
    hideEl(emptyMsg);
    wrapper.appendChild(emptyMsg);

    var monthHeaders = [];
    var monthLists = [];

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

      var h3 = document.createElement("h3");
      h3.className = "bearming-archive-month";
      h3.textContent = g.label;

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

      for (var j = 0; j < g.items.length; j++) {
        g.items[j].hidden = false;
        ul.appendChild(g.items[j]);
      }

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

      monthHeaders.push(h3);
      monthLists.push(ul);
    }

    var pagination = document.createElement("div");
    pagination.id = ID_PAGINATION;
    pagination.className = "bearming-archive-pagination";

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

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

    var 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);

    if (tagsBlock) wrapper.appendChild(tagsBlock);

    var currentPage = 1;
    var debounceId = null;

    var getFiltered = function () {
      var yr = yearSelect.value;
      var term = searchInput.value.trim().toLowerCase();

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

      var out = [];
      for (var fi = 0; fi < allItems.length; fi++) {
        var item = allItems[fi];
        if (yr && item.dataset.bearmingArchiveYear !== yr) continue;
        if (term && (item.dataset.bearmingArchiveText || "").indexOf(term) === -1) continue;
        out.push(item);
      }
      return out;
    };

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

    var renderPageItems = function (pageItems, hasAnyResults) {
      for (var ri = 0; ri < allItems.length; ri++) hideEl(allItems[ri]);
      for (var pi = 0; pi < pageItems.length; pi++) showEl(pageItems[pi]);

      for (var gi = 0; gi < monthLists.length; gi++) {
        var mUl = monthLists[gi];
        var anyVisible = false;
        for (var ci = 0; ci < mUl.children.length; ci++) {
          if (!mUl.children[ci].hidden) { anyVisible = true; break; }
        }
        if (anyVisible) {
          showEl(mUl);
          showEl(monthHeaders[gi]);
        } else {
          hideEl(mUl);
          hideEl(monthHeaders[gi]);
        }
      }

      if (hasAnyResults) hideEl(emptyMsg);
      else showEl(emptyMsg);
    };

    var syncUrl = function () {
      var yr = yearSelect.value;
      var term = searchInput.value.trim();
      var 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);
    };

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

      var 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();
    };

    var p0 = readParams();
    yearSelect.value = p0.get(PARAM_YEAR) || "";
    searchInput.value = p0.get(PARAM_SEARCH) || "";
    var page0 = parseInt(p0.get(PARAM_PAGE) || "1", 10);
    currentPage = Number.isFinite(page0) && page0 > 0 ? page0 : 1;

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

    searchInput.addEventListener("input", function () {
      currentPage = 1;
      if (debounceId) window.clearTimeout(debounceId);
      debounceId = window.setTimeout(update, SEARCH_DEBOUNCE_MS);
    });

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

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

    update();
  }

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

Styles

/* Archive toolkit | robertbirming.com */
.bearming-archive-controls {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.8rem;
  margin-block: var(--space-block) 0.9rem;
}
.bearming-archive :is(input[type="search"], select) {
  padding-block: 0.55em;
  padding-inline: 0.7em;
  font: inherit;
  letter-spacing: inherit;
  color: var(--text);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  outline: none;
  appearance: none;
}
.bearming-archive :is(input[type="search"], select):focus-visible {
  border-color: color-mix(in srgb, var(--link) 45%, var(--surface));
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--link) 18%, transparent);
}
.bearming-archive input[type="search"] {
  flex: 1 1 18rem;
  min-width: 12rem;
  max-width: 30rem;
}
.bearming-archive input[type="search"]::placeholder {
  color: var(--muted);
}
.bearming-archive-select {
  position: relative;
  flex: 0 1 10rem;
  min-width: 8.5rem;
  max-width: 100%;
}
.bearming-archive-select select {
  width: 100%;
  padding-inline-end: 2.2em;
}
.bearming-archive-select::after {
  content: "";
  position: absolute;
  inset-block-start: 50%;
  inset-inline-end: 0.9em;
  width: 0.5em;
  height: 0.5em;
  border-inline-end: 2px solid var(--text);
  border-block-end: 2px solid var(--text);
  transform: translateY(-60%) rotate(45deg);
  pointer-events: none;
  opacity: 0.9;
}
.bearming-archive-empty {
  margin-block: 0.6rem var(--space-block);
  font-size: var(--font-small);
  color: var(--muted);
}
.bearming-archive-month {
  margin-block: 2.3rem 0;
  font-size: 1.2rem;
  font-weight: 600;
}
.blog-posts li[hidden] {
  display: none;
}
.bearming-archive-pagination {
  display: flex;
  justify-content: center;
  align-items: baseline;
  gap: 1rem;
  margin-block: var(--space-block) 0;
  font-size: var(--font-small);
}
.bearming-archive-pagination span {
  color: var(--muted);
  white-space: nowrap;
}
.bearming-archive-pagination button {
  padding: 0;
  border: 0;
  background: none;
  font: inherit;
  letter-spacing: inherit;
  color: var(--link);
  cursor: pointer;
}
.bearming-archive-pagination button:disabled,
.bearming-archive-pagination button[aria-disabled="true"] {
  opacity: 0.4;
  pointer-events: none;
}
.bearming-archive-pagination #bearming-archive-prev::before {
  content: "\2190\00a0";
}
.bearming-archive-pagination #bearming-archive-next::after {
  content: "\00a0\2192";
}

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

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

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