Add GoatCounter count.js component (as goat.js), MIT license logo
All checks were successful
Build And Deploy / build-and-deploy (push) Successful in 5m19s
All checks were successful
Build And Deploy / build-and-deploy (push) Successful in 5m19s
(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.
This commit is contained in:
parent
89c369d1cc
commit
2548a84e0b
7 changed files with 437 additions and 30 deletions
38
LICENSE
Normal file
38
LICENSE
Normal file
|
|
@ -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 <martin@arp242.net>
|
||||
|
||||
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.
|
||||
321
public/goat.js
Normal file
321
public/goat.js
Normal file
|
|
@ -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();
|
||||
});
|
||||
})();
|
||||
29
public/htmz.dev.js
Normal file
29
public/htmz.dev.js
Normal file
|
|
@ -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------------------------------------
|
||||
});
|
||||
}
|
||||
11
public/htmz.js
Normal file
11
public/htmz.js
Normal file
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
11
public/mit.svg
Normal file
11
public/mit.svg
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" role="img">
|
||||
<defs>
|
||||
<style>
|
||||
path {
|
||||
fill: #ffffff;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path d="M689.414 457.075l57.233-.196.837 243.787-57.233.197zM306.561 329.641h57.235v242.948h-57.235zm127.337-.775l57.235-.13.838 371.125-57.235.13zm-255.515-.064h57.237v371.963h-57.237z"/><path d="M512.005 0C228.872 0-1.475 229.68-1.48 511.996c-.002 282.316 230.343 512 513.474 512.004 283.133 0 513.482-229.68 513.484-511.996v-.008C1025.477 229.684 795.134.004 512.005 0zM963.56 512v.004c-.002 248.172-202.573 450.075-451.563 450.075-248.992-.004-451.559-201.91-451.555-450.083.003-248.172 202.573-450.075 451.563-450.075 248.986.004 451.553 201.907 451.555 450.075z"/>
|
||||
<path d="M678.541 396.794l.233-57.234 206.924.84-.232 57.233zm-116.467 60.183h57.235v242.949h-57.235zm-.834-127.872l57.231-.602.837 79.582-57.232.602z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 901 B |
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -1,34 +1,23 @@
|
|||
<!doctype html>
|
||||
<!--prettier-ignore-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link
|
||||
rel="preload"
|
||||
as="font"
|
||||
href="/unscii-16.woff"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
<link rel="preload" href="/unscii-16.woff" as="font" crossorigin="anonymous" />
|
||||
<link rel="preload" href="reset.css" as="style" />
|
||||
<link rel="preload" href="theme.css" as="style" />
|
||||
<link rel="stylesheet" type="text/css" href="/style.css" />
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100' width='100px' height='100px'><rect x='0' y='0' width='100' height='100' rx='15' ry='15' style='fill: rgb(0,0,0);'/><foreignObject width='100' height='100'><div xmlns='http://www.w3.org/1999/xhtml' style='width:100px;height:100px;line-height:100px;text-align:center;vertical-align:middle;color:transparent;text-shadow: 0 0 rgb(0 255 0);font-size:80px;'>👾</div></foreignObject></svg>"
|
||||
/>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100' width='100px' height='100px'><rect x='0' y='0' width='100' height='100' rx='15' ry='15' style='fill: rgb(0,0,0);'/><foreignObject width='100' height='100'><div xmlns='http://www.w3.org/1999/xhtml' style='width:100px;height:100px;line-height:100px;text-align:center;vertical-align:middle;color:transparent;text-shadow: 0 0 rgb(0 255 0);font-size:80px;'>👾</div></foreignObject></svg>"/>
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<meta name="darkreader-lock" />
|
||||
<link rel="sitemap" href="/sitemap-index.xml" />
|
||||
<script
|
||||
data-goatcounter="https://badblocks.goatcounter.com/count"
|
||||
async
|
||||
src="//gc.zgo.at/count.js"></script>
|
||||
<script>
|
||||
import "iconify-icon";
|
||||
</script>
|
||||
<script is:inline src="/htmz.js"></script>
|
||||
<script is:inline async src="/goat.js" data-goatcounter="https://badblocks.goatcounter.com/count"></script>
|
||||
<slot name="head" />
|
||||
</head>
|
||||
<body>
|
||||
<iframe hidden="" name="htmz" onload="window.htmz(this)"></iframe>
|
||||
<header>
|
||||
<h1>badblocks.dev</h1>
|
||||
<nav>
|
||||
|
|
@ -44,17 +33,11 @@
|
|||
</main>
|
||||
<footer>
|
||||
<p>
|
||||
<a
|
||||
href="https://creativecommons.org/licenses/by-nc-sa/4.0/"
|
||||
target="_blank"
|
||||
><iconify-icon icon="fa7-brands:creative-commons"
|
||||
></iconify-icon><iconify-icon icon="fa7-brands:creative-commons-by"
|
||||
></iconify-icon><iconify-icon icon="fa7-brands:creative-commons-nc"
|
||||
></iconify-icon><iconify-icon icon="fa7-brands:creative-commons-sa"
|
||||
></iconify-icon></a
|
||||
>
|
||||
<br />
|
||||
Made from scratch with BAHA: Bun, Astro, Htmx, and Alpine!
|
||||
<a href="https://opensource.org/license/mit" target="_blank">
|
||||
<img src="/mit.svg" title="MIT Licensed" alt="MIT License Logo" width="24" height="24" role="img">
|
||||
</a>
|
||||
<br>
|
||||
Made from scratch with BAHz: Bun, Astro, and Htmz!
|
||||
</p>
|
||||
</footer>
|
||||
</body>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue