From 2548a84e0b9432c9cad407b3f67d6d06eabc921a Mon Sep 17 00:00:00 2001 From: badbl0cks <4161747+badbl0cks@users.noreply.github.com> Date: Sat, 7 Mar 2026 22:38:34 -0800 Subject: [PATCH] Add GoatCounter count.js component (as goat.js), MIT license logo (mit.svg), and htmz assets (htmz.js and htmz.dev.js). Add LICENSE file containing this project's MIT license and 3rd-party component licenses. --- LICENSE | 38 +++++ public/goat.js | 321 +++++++++++++++++++++++++++++++++++ public/htmz.dev.js | 29 ++++ public/htmz.js | 11 ++ public/mit.svg | 11 ++ public/theme.css | 18 +- src/layouts/BaseLayout.astro | 39 ++--- 7 files changed, 437 insertions(+), 30 deletions(-) create mode 100644 LICENSE create mode 100644 public/goat.js create mode 100644 public/htmz.dev.js create mode 100644 public/htmz.js create mode 100644 public/mit.svg diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3dd6b7c --- /dev/null +++ b/LICENSE @@ -0,0 +1,38 @@ +Copyright 2026 badblocks + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the “Software”), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +This software also contains various permissively-licensed 3rd-party +components, the licenses for which are listed below. + +––– public/goat.js; ISC license –––––––––––––––––––––––––––––––––––––––––––––– + +Copyright © Martin Tournoij + +Permission to use, copy, modify, and/or distribute this software for any purpose +with or without fee is hereby granted, provided that the above copyright notice +and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +THIS SOFTWARE. diff --git a/public/goat.js b/public/goat.js new file mode 100644 index 0000000..8e06d8a --- /dev/null +++ b/public/goat.js @@ -0,0 +1,321 @@ +// GoatCounter: https://www.goatcounter.com +// This file is released under the ISC license: https://opensource.org/licenses/ISC +(function () { + "use strict"; + + window.goatcounter = window.goatcounter || {}; + + // Load settings from data-goatcounter-settings. + var s = document.querySelector("script[data-goatcounter]"); + if (s && s.dataset.goatcounterSettings) { + try { + var set = JSON.parse(s.dataset.goatcounterSettings); + } catch (err) { + console.error("invalid JSON in data-goatcounter-settings: " + err); + } + for (var k in set) + if ( + [ + "no_onload", + "no_events", + "allow_local", + "allow_frame", + "path", + "title", + "referrer", + "event", + ].indexOf(k) > -1 + ) + window.goatcounter[k] = set[k]; + } + + var enc = encodeURIComponent; + + // Get all data we're going to send off to the counter endpoint. + window.goatcounter.get_data = function (vars) { + vars = vars || {}; + var data = { + p: vars.path === undefined ? goatcounter.path : vars.path, + r: vars.referrer === undefined ? goatcounter.referrer : vars.referrer, + t: vars.title === undefined ? goatcounter.title : vars.title, + e: !!(vars.event || goatcounter.event), + s: window.screen.width, + b: is_bot(), + q: location.search, + }; + + var rcb, pcb, tcb; // Save callbacks to apply later. + if (typeof data.r === "function") rcb = data.r; + if (typeof data.t === "function") tcb = data.t; + if (typeof data.p === "function") pcb = data.p; + + if (is_empty(data.r)) data.r = document.referrer; + if (is_empty(data.t)) data.t = document.title; + if (is_empty(data.p)) data.p = get_path(); + + if (rcb) data.r = rcb(data.r); + if (tcb) data.t = tcb(data.t); + if (pcb) data.p = pcb(data.p); + return data; + }; + + // Check if a value is "empty" for the purpose of get_data(). + var is_empty = function (v) { + return v === null || v === undefined || typeof v === "function"; + }; + + // See if this looks like a bot; there is some additional filtering on the + // backend, but these properties can't be fetched from there. + var is_bot = function () { + // Headless browsers are probably a bot. + var w = window, + d = document; + if (w.callPhantom || w._phantom || w.phantom) return 150; + if (w.__nightmare) return 151; + if (d.__selenium_unwrapped || d.__webdriver_evaluate || d.__driver_evaluate) + return 152; + if (navigator.webdriver) return 153; + return 0; + }; + + // Object to urlencoded string, starting with a ?. + var urlencode = function (obj) { + var p = []; + for (var k in obj) + if ( + obj[k] !== "" && + obj[k] !== null && + obj[k] !== undefined && + obj[k] !== false + ) + p.push(enc(k) + "=" + enc(obj[k])); + return "?" + p.join("&"); + }; + + // Show a warning in the console. + var warn = function (msg) { + if (console && "warn" in console) console.warn("goatcounter: " + msg); + }; + + // Get the endpoint to send requests to. + var get_endpoint = function () { + var s = document.querySelector("script[data-goatcounter]"); + return s && s.dataset.goatcounter + ? s.dataset.goatcounter + : goatcounter.endpoint; + }; + + // Get current path. + var get_path = function () { + var loc = location, + c = document.querySelector('link[rel="canonical"][href]'); + if (c) { + // May be relative or point to different domain. + var a = document.createElement("a"); + a.href = c.href; + if ( + a.hostname.replace(/^www\./, "") === + location.hostname.replace(/^www\./, "") + ) + loc = a; + } + return loc.pathname + loc.search || "/"; + }; + + // Run function after DOM is loaded. + var on_load = function (f) { + if (document.body === null) + document.addEventListener( + "DOMContentLoaded", + function () { + f(); + }, + false, + ); + else f(); + }; + + // Filter some requests that we (probably) don't want to count. + window.goatcounter.filter = function () { + if ( + "visibilityState" in document && + document.visibilityState === "prerender" + ) + return "visibilityState"; + if (!goatcounter.allow_frame && location !== parent.location) + return "frame"; + if ( + !goatcounter.allow_local && + location.hostname.match( + /(localhost$|^127\.|^10\.|^172\.(1[6-9]|2[0-9]|3[0-1])\.|^192\.168\.|^0\.0\.0\.0$)/, + ) + ) + return "localhost"; + if (!goatcounter.allow_local && location.protocol === "file:") + return "localfile"; + if (localStorage && localStorage.getItem("skipgc") === "t") + return "disabled with #toggle-goatcounter"; + return false; + }; + + // Get URL to send to GoatCounter. + window.goatcounter.url = function (vars) { + var data = window.goatcounter.get_data(vars || {}); + if (data.p === null) + // null from user callback. + return; + data.rnd = Math.random().toString(36).substr(2, 5); // Browsers don't always listen to Cache-Control. + + var endpoint = get_endpoint(); + if (!endpoint) return warn("no endpoint found"); + + return endpoint + urlencode(data); + }; + + // Count a hit. + window.goatcounter.count = function (vars) { + var f = goatcounter.filter(); + if (f) return warn("not counting because of: " + f); + var url = goatcounter.url(vars); + if (!url) return warn("not counting because path callback returned null"); + + if (!navigator || !navigator.sendBeacon || !navigator.sendBeacon(url)) { + // This mostly fails due to being blocked by CSP; try again with an + // image-based fallback. + var img = document.createElement("img"); + img.src = url; + img.style.position = "absolute"; // Affect layout less. + img.style.bottom = "0px"; + img.style.width = "1px"; + img.style.height = "1px"; + img.loading = "eager"; + img.setAttribute("alt", ""); + img.setAttribute("aria-hidden", "true"); + + var rm = function () { + if (img && img.parentNode) img.parentNode.removeChild(img); + }; + img.addEventListener("load", rm, false); + document.body.appendChild(img); + } + }; + + // Get a query parameter. + window.goatcounter.get_query = function (name) { + var s = location.search.substr(1).split("&"); + for (var i = 0; i < s.length; i++) + if (s[i].toLowerCase().indexOf(name.toLowerCase() + "=") === 0) + return s[i].substr(name.length + 1); + }; + + // Track click events. + window.goatcounter.bind_events = function () { + if (!document.querySelectorAll) + // Just in case someone uses an ancient browser. + return; + + var send = function (elem) { + return function () { + goatcounter.count({ + event: true, + path: elem.dataset.goatcounterClick || elem.name || elem.id || "", + title: + elem.dataset.goatcounterTitle || + elem.title || + (elem.innerHTML || "").substr(0, 200) || + "", + referrer: + elem.dataset.goatcounterReferrer || + elem.dataset.goatcounterReferral || + "", + }); + }; + }; + + Array.prototype.slice + .call(document.querySelectorAll("*[data-goatcounter-click]")) + .forEach(function (elem) { + if (elem.dataset.goatcounterBound) return; + var f = send(elem); + elem.addEventListener("click", f, false); + elem.addEventListener("auxclick", f, false); // Middle click. + elem.dataset.goatcounterBound = "true"; + }); + }; + + // Add a "visitor counter" frame or image. + window.goatcounter.visit_count = function (opt) { + on_load(function () { + opt = opt || {}; + opt.type = opt.type || "html"; + opt.append = opt.append || "body"; + opt.path = opt.path || get_path(); + opt.attr = opt.attr || { + width: "200", + height: opt.no_branding ? "60" : "80", + }; + + opt.attr["src"] = + get_endpoint() + "er/" + enc(opt.path) + "." + enc(opt.type) + "?"; + if (opt.no_branding) opt.attr["src"] += "&no_branding=1"; + if (opt.style) opt.attr["src"] += "&style=" + enc(opt.style); + if (opt.start) opt.attr["src"] += "&start=" + enc(opt.start); + if (opt.end) opt.attr["src"] += "&end=" + enc(opt.end); + + var tag = { png: "img", svg: "img", html: "iframe" }[opt.type]; + if (!tag) return warn("visit_count: unknown type: " + opt.type); + + if (opt.type === "html") { + opt.attr["frameborder"] = "0"; + opt.attr["scrolling"] = "no"; + } + + var d = document.createElement(tag); + for (var k in opt.attr) d.setAttribute(k, opt.attr[k]); + + var p = document.querySelector(opt.append); + if (!p) + return warn( + "visit_count: element to append to not found: " + opt.append, + ); + p.appendChild(d); + }); + }; + + // Make it easy to skip your own views. + if (location.hash === "#toggle-goatcounter") { + if (localStorage.getItem("skipgc") === "t") { + localStorage.removeItem("skipgc", "t"); + alert("GoatCounter tracking is now ENABLED in this browser."); + } else { + localStorage.setItem("skipgc", "t"); + alert( + "GoatCounter tracking is now DISABLED in this browser until " + + location + + " is loaded again.", + ); + } + } + + if (!goatcounter.no_onload) + on_load(function () { + // 1. Page is visible, count request. + // 2. Page is not yet visible; wait until it switches to 'visible' and count. + // See #487 + if ( + !("visibilityState" in document) || + document.visibilityState === "visible" + ) + goatcounter.count(); + else { + var f = function (e) { + if (document.visibilityState !== "visible") return; + document.removeEventListener("visibilitychange", f); + goatcounter.count(); + }; + document.addEventListener("visibilitychange", f); + } + + if (!goatcounter.no_events) goatcounter.bind_events(); + }); +})(); diff --git a/public/htmz.dev.js b/public/htmz.dev.js new file mode 100644 index 0000000..d50e4b6 --- /dev/null +++ b/public/htmz.dev.js @@ -0,0 +1,29 @@ +function htmz(frame) { + // -------------------------------->1------------------------------------ + // No history + // ---------------------------------------------------------------------- + // This clears the iframe's history from the global history + // by removing the iframe from the DOM (but immediately adding it back + // for subsequent requests). + // ---------------------------------1<----------------------------------- + // -------------------------------->2----------------------------------- + // Repeat GETs + // ---------------------------------------------------------------------- + // This clears the iframe URL for a fresh start on next load. + // ---------------------------------2<----------------------------------- + // ------------------------------->1&2----------------------------------- + if (frame.contentWindow.location.href === "about:blank") return; + // --------------------------------1&2<---------------------------------- + setTimeout(() => { + document + .querySelector(frame.contentWindow.location.hash || null) + ?.replaceWith(...frame.contentDocument.body.childNodes); + // ---------------------------------2<----------------------------------- + frame.contentWindow.location.replace("about:blank"); + // -------------------------------->2------------------------------------ + // ---------------------------------1<----------------------------------- + frame.remove(); + document.body.appendChild(frame); + // -------------------------------->1------------------------------------ + }); +} diff --git a/public/htmz.js b/public/htmz.js new file mode 100644 index 0000000..721c261 --- /dev/null +++ b/public/htmz.js @@ -0,0 +1,11 @@ +function htmz(frame) { + if (frame.contentWindow.location.href === "about:blank") return; + setTimeout(() => { + document + .querySelector(frame.contentWindow.location.hash || null) + ?.replaceWith(...frame.contentDocument.body.childNodes); + frame.contentWindow.location.replace("about:blank"); + frame.remove(); + document.body.appendChild(frame); + }); +} diff --git a/public/mit.svg b/public/mit.svg new file mode 100644 index 0000000..9fad476 --- /dev/null +++ b/public/mit.svg @@ -0,0 +1,11 @@ + + + + + + + diff --git a/public/theme.css b/public/theme.css index 1ea83b2..2764b64 100644 --- a/public/theme.css +++ b/public/theme.css @@ -141,6 +141,20 @@ a { text-decoration: none; } } +footer a { + text-decoration: none; + width: fit-content; + display: inline-block; + margin: 0 auto; + padding: 0; + + &:hover::before { + content: none; + } + &:hover::after { + content: none; + } +} ul { list-style-type: square; } @@ -159,8 +173,8 @@ label:has(> input[type="radio"]) { display: flex; margin-block: var(--stack-large); } -/* TODO: use textarea-wrapper strategy to implement invisible inputs idea, -but have the text-glow effect done on the ::after pseudo-element and hide +/* TODO: use textarea-wrapper strategy to implement invisible inputs idea, +but have the text-glow effect done on the ::after pseudo-element and hide the input when it isnt focused, and swap when focused */ input[type="text"], input[type="email"], diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index 8f7ad5f..36d0ffb 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -1,34 +1,23 @@ + - + - + - - + + +

badblocks.dev