22 Commits

Author SHA1 Message Date
MMA f4188cfaa7 Add Docker Compose setup for local runs
Package the static site with nginx
2026-08-16 13:11:40 +03:00
Matthew Yang (杨佳明) 2f7e26b02b Fix other domains (#7)
* Make main compressor capable of other domains

* Fix missing bracket

* Domain needs to contain port as well

* Add decode functionality for other domains

---------

Co-authored-by: p2r3 <41925384+p2r3@users.noreply.github.com>
2026-08-16 13:06:17 +03:00
p2r3 b609b3acf7 fix desync between index and 404 page 2026-08-16 12:58:45 +03:00
p2r3 943a34935f simplify links, open in new tab 2026-08-16 12:57:33 +03:00
p2r3 0e147413eb move gh/youtube links to top of page 2026-08-16 12:57:33 +03:00
p2r3 611389d112 force color of all links to blue 2026-08-16 12:57:33 +03:00
MMA 79258d4611 add youtube video link 2026-08-16 12:57:33 +03:00
MMA 15684f9cb9 add source code link to website
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 12:57:33 +03:00
Ayan Khan 831a00b703 feat: optimize default QR error correction 2026-08-16 12:49:50 +03:00
p2r3 d47b958fc3 fix: adjust color of input field responsively 2026-08-16 12:47:02 +03:00
Ayan Khan 7a5f333f48 feat: add system-aware dark theme 2026-08-16 12:47:02 +03:00
Ayan Khan 3236e6e53d fix: preserve QRCode copyright notice 2026-08-16 12:37:20 +03:00
ccmdi 0b3aa5633f skip subalphabet encoding when no subalphabet fits the segment 2026-08-16 01:43:05 +03:00
ccmdi a79bd4a862 fix savings ratio being case sensitive 2026-08-15 19:24:17 +03:00
ccmdi 084b5c665c pad decoded percent escapes to two hex digits 2026-08-14 17:57:30 +03:00
p2r3 bdc3c33026 work around missing tilde character in huffman tree 2026-08-14 09:57:21 +03:00
Eric Spencer b12948bbef fix: undefined search reference and misplaced index suffix
`search` was never declared, so any link whose entire path is `/index.html`
or `/index.php` (the path is empty once the suffix is stripped) threw a
ReferenceError whenever the hostname had a subdomain or an unknown SLD.
`pathSegments.length` is what the check was reaching for.

With that fixed, those links reached a second bug: `indexSuffix` was
appended after the query and hash, so `x.io/index.html?q=1` decoded to
`x.io?q=1/index.html`. It is now spliced back in before the first `?`/`#`.
2026-08-14 09:45:13 +03:00
p2r3 2a99632a3b add link datasets to acknowledgements 2026-08-14 01:47:30 +03:00
p2r3 96dc069a34 revert deduplication 2026-08-13 15:15:58 +03:00
p2r3 9a8b6337a2 remove index to deduplicate from 404 page 2026-08-13 15:07:07 +03:00
p2r3 c48d02d03b Create CNAME 2026-08-13 14:56:04 +03:00
p2r3 1ddf7cc2a7 add link handling to 404 page 2026-08-13 14:53:25 +03:00
11 changed files with 380 additions and 24 deletions
+193
View File
@@ -0,0 +1,193 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ha.mr - link compressor</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Hammersmith+One&display=swap');
:root {
color-scheme: light dark;
}
html, body {
margin: 0;
width: 100%;
height: 100%;
}
body {
display: flex;
justify-content: center;
align-items: center;
font-size: 120%;
}
* {
font-family: "Hammersmith One", sans-serif;
}
#loader {
position: absolute;
border: 0.5em solid lightgray;
border-radius: 50%;
border-top: 0.5em solid gray;
width: 3em;
height: 3em;
-webkit-animation: spin 1.5s linear infinite;
animation: spin 1.5s linear infinite;
opacity: 1;
pointer-events: none;
transition: opacity 1s;
}
@-webkit-keyframes spin {
0% { -webkit-transform: rotate(0deg); }
100% { -webkit-transform: rotate(360deg); }
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
#content {
display: flex;
flex-direction: column;
align-items: center;
opacity: 0;
pointer-events: none;
transition: opacity 1s;
}
h1 {
margin: 0;
}
p {
margin-top: 0;
}
label {
font-size: 0.9em;
}
span {
text-align: center;
}
a {
color: rgb(50, 100, 255);
}
.title {
font-size: 2.5em;
}
.subtitle {
font-size: 0.6em;
margin-top: -10px;
font-style: italic;
opacity: 0.8;
}
.source-link {
font-size: 0.75em;
}
#input-link {
padding: 5px 8px;
border: 0;
outline: 0;
border-bottom: 2px solid light-dark(
rgba(0, 0, 0, 0.2),
rgba(255, 255, 255, 0.2)
);
border-radius: 10px;
margin: 10px 0;
font-size: 16px;
background-color: light-dark(
rgba(0, 0, 0, 0.1),
rgba(255, 255, 255, 0.1)
);
}
#output-link-container {
margin: 10px 0 0 0;
text-align: center;
}
#output-link {
word-break: break-all;
}
#output-ratio {
transition: color 0.5s;
color: gray;
user-select: none;
}
#qrcode {
display: none;
}
#qr-correct-level-container {
display: none;
}
#query-warning {
display: none;
font-size: 1rem;
text-align: justify;
text-align-last: center;
max-width: 80vmin;
background-color: gold;
padding: 5px 10px;
border-radius: 10px;
margin: 5px 0 15px 0;
}
#query-warning summary {
cursor: pointer;
}
header {
position: absolute;
top: 20px;
}
</style>
<script src="/qrcode.js"></script>
</head>
<body>
<div id="loader"></div>
<header>
<a class="source-link" href="https://github.com/p2r3/ha.mr" target="_blank">Source code on GitHub</a>
|
<a class="source-link" href="https://youtu.be/TOr1Vvji6jA" target="_blank">Explanation video</a>
</header>
<div id="content">
<h1 class="title">ha.mr</h1>
<p class="subtitle">(read: "hammer")</p>
<input type="text" id="input-link" placeholder="https://some-long.link/" autofocus>
<details id="query-warning">
<summary>Consider removing parameters...</summary>
<br>
Some links include data that isn't necessary for the link to function,
such as tracking information or page state. This data comes in the
form of <i>query parameters</i>, marked by "?" and "&amp;" characters.
<br><br>
Removing these segments can sometimes significantly shorten the link
without breaking it. For example, an Amazon product link contains a lot
of tracking information. Everything past the "?" symbol can be safely
removed, making the link much shorter.
</details>
<p id="output-link-container">
<a id="output-link" target="_blank">Enter a link above to compress</a>
<br><span id="output-ratio">&nbsp;</span>
</p>
<image width="256" height="256" id="qrcode"></image>
<span id="qr-correct-level-container">
<label>QR code error correction threshold</label>
<br>
<input type="range" name="qr-correct-level" id="qr-correct-level" autocomplete="off" min="0" max="3" value="1">
</span>
<span style="margin-top:20px">
<input type="checkbox" id="settings-emoji" name="settings-emoji" autocomplete="off">
<label for="settings-emoji"> Use emoji in text output</label><br>
</span>
<span>
<input type="checkbox" id="settings-qr" name="settings-qr" autocomplete="off">
<label for="settings-qr"> Output QR code</label><br>
</span>
</div>
<script type="module" src="/main.js"></script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
ha.mr
+5
View File
@@ -0,0 +1,5 @@
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY index.html 404.html /usr/share/nginx/html/
COPY alphabets.js compress.js main.js qrcode.js standalone.js /usr/share/nginx/html/
+2
View File
@@ -10,3 +10,5 @@ Compresses links and optimizes QR codes entirely in the browser, without a back-
## Acknowledgements
- https://www.npmjs.com/package/qrcode
- https://github.com/smythp/reddit_links_dataset
- https://github.com/ada-url/url-dataset
+5
View File
@@ -0,0 +1,5 @@
services:
web:
build: .
ports:
- "8080:80"
+33 -8
View File
@@ -245,9 +245,9 @@ export function compress (input, alphabet) {
queryParamIndex ++;
}
// Look for smallest subalphabet that fits this path segment
let subalphabetIndex = subalphabets.length - 1;
let subalphabet = subalphabets[subalphabetIndex];
for (let i = 0; i < subalphabets.length - 1; i ++) {
let subalphabetIndex = -1;
let subalphabet = null;
for (let i = 0; i < subalphabets.length; i ++) {
if (!Array.from(segment.value).some(c => !subalphabets[i].includes(c))) {
subalphabet = subalphabets[i];
subalphabetIndex = i;
@@ -263,13 +263,33 @@ export function compress (input, alphabet) {
huffmanNumber += BigInt(byte);
huffmanNumber = huffmanEncode(huffmanNumber, pathEncode["%"]);
i -= 2;
} else {
if (segment.value[i] === "~") {
/**
* HACK HACK HACK!!!
* Our Huffman tree is missing the tilde character (whoops!)
* It's too late to change it now without bumping the version
* number, and that currently costs 1 bit. Tildes are so rare
* that it makes more sense to %-encode them instead.
*/
huffmanNumber *= 256n;
huffmanNumber += BigInt(126);
huffmanNumber = huffmanEncode(huffmanNumber, pathEncode["%"]);
} else {
huffmanNumber = huffmanEncode(huffmanNumber, pathEncode[segment.value[i]]);
}
}
}
// Encode segment variant as 0
// (We're adding +1 here to introduce 0 as a special value indicating Huffman)
huffmanNumber *= BigInt(subalphabets.length + 1);
// If no subalphabet fits this segment, Huffman is the only option.
// Encoding a character missing from the subalphabet would produce the
// value 0, which the decoder treats as the end of the segment.
if (!subalphabet) {
number = huffmanNumber;
continue;
}
// Compute number after encoding with chosen subalphabet
const subalphabetLength = BigInt(subalphabet.length + 1);
let subalphabetNumber = firstIteration ? number : number * subalphabetLength;
@@ -301,7 +321,7 @@ export function compress (input, alphabet) {
// Encode either SLD + subdomain or full hostname
if (!knownSLD) {
// Write stopping token only if path follows
if (path || search) number = huffmanEncode(number, domainEncode["END"]);
if (pathSegments.length > 0) number = huffmanEncode(number, domainEncode["END"]);
for (let i = hostname.length - 1; i >= 0; i --) {
number = huffmanEncode(number, domainEncode[hostname[i]]);
}
@@ -309,7 +329,7 @@ export function compress (input, alphabet) {
// Encode subdomain
if (subdomain) {
// Write stopping token only if path follows
if (path || search) number = huffmanEncode(number, domainEncode["END"]);
if (pathSegments.length > 0) number = huffmanEncode(number, domainEncode["END"]);
for (let i = subdomain.length - 1; i >= 0; i--) {
number = huffmanEncode(number, domainEncode[subdomain[i]]);
}
@@ -473,7 +493,7 @@ export function decompress (input, alphabet) {
path += digit;
if (digit === "%") {
const byte = number % 256n;
path += byte.toString(16);
path += byte.toString(16).padStart(2, "0");
number /= 256n;
}
}
@@ -506,6 +526,10 @@ export function decompress (input, alphabet) {
number >>= 1n;
}
const pathSplitIndex = path.search(/[?#]/);
const pathBeforeQuery = pathSplitIndex === -1 ? path : path.slice(0, pathSplitIndex);
const pathFromQuery = pathSplitIndex === -1 ? "" : path.slice(pathSplitIndex);
let output = ""
+ (isHTTPS ? "https://" : "http://")
+ (hasWWW ? "www." : "")
@@ -513,8 +537,9 @@ export function decompress (input, alphabet) {
+ domain
+ (tld ? "." + tld : "")
+ (hasPort ? ":" + port : "")
+ path
+ indexSuffix;
+ pathBeforeQuery
+ indexSuffix
+ pathFromQuery;
return output;
}
+29 -4
View File
@@ -6,6 +6,11 @@
<title>ha.mr - link compressor</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Hammersmith+One&display=swap');
:root {
color-scheme: light dark;
}
html, body {
margin: 0;
width: 100%;
@@ -61,6 +66,9 @@
span {
text-align: center;
}
a {
color: rgb(50, 100, 255);
}
.title {
font-size: 2.5em;
}
@@ -70,22 +78,30 @@
font-style: italic;
opacity: 0.8;
}
.source-link {
font-size: 0.75em;
}
#input-link {
padding: 5px 8px;
border: 0;
outline: 0;
border-bottom: 2px solid darkgray;
border-bottom: 2px solid light-dark(
rgba(0, 0, 0, 0.2),
rgba(255, 255, 255, 0.2)
);
border-radius: 10px;
margin: 10px 0;
font-size: 16px;
background-color: rgb(230, 230, 230);
background-color: light-dark(
rgba(0, 0, 0, 0.1),
rgba(255, 255, 255, 0.1)
);
}
#output-link-container {
margin: 10px 0 0 0;
text-align: center;
}
#output-link {
color: rgb(50, 100, 255);
word-break: break-all;
}
#output-ratio {
@@ -113,6 +129,10 @@
#query-warning summary {
cursor: pointer;
}
header {
position: absolute;
top: 20px;
}
</style>
<script src="/qrcode.js"></script>
</head>
@@ -120,6 +140,12 @@
<div id="loader"></div>
<header>
<a class="source-link" href="https://github.com/p2r3/ha.mr" target="_blank">Source code on GitHub</a>
|
<a class="source-link" href="https://youtu.be/TOr1Vvji6jA" target="_blank">Explanation video</a>
</header>
<div id="content">
<h1 class="title">ha.mr</h1>
@@ -159,7 +185,6 @@
<input type="checkbox" id="settings-qr" name="settings-qr" autocomplete="off">
<label for="settings-qr"> Output QR code</label><br>
</span>
<br><br>
</div>
+68 -8
View File
@@ -5,6 +5,15 @@ import {
outputAlphabetEmoji
} from "./alphabets.js";
let domain = window.location.hostname;
if (domain !== "ha.mr" && domain !== "www.ha.mr") {
console.log(`This page is intended to be used on the ha.mr domain. You are currently on ${domain}.`);
}
const webPort = window.location.port;
if (webPort && webPort !== "80" && webPort !== "443") {
domain += `:${webPort}`;
}
var settings = {
emoji: false,
qr: false
@@ -42,7 +51,41 @@ const queryWarningElement = document.querySelector("#query-warning");
const qrCodeImage = document.querySelector("#qrcode");
const qrCodeCorrectionLevelContainer = document.querySelector("#qr-correct-level-container");
const qrCodeCorrectionLevelElement = document.querySelector("#qr-correct-level");
qrCodeCorrectionLevelElement.addEventListener("change", updateOutput);
let qrCorrectionManuallySet = false;
qrCodeCorrectionLevelElement.addEventListener("change", () => {
qrCorrectionManuallySet = true;
updateOutput();
});
function getOptimalErrorCorrectionLevel (text) {
const levels = ["M", "Q", "H"];
const baseVersion = QRCode.create(text, {
errorCorrectionLevel: levels[0]
}).version;
let optimalLevel = levels[0];
for (const level of levels.slice(1)) {
try {
const candidate = QRCode.create(text, {
errorCorrectionLevel: level
});
if (candidate.version > baseVersion) {
break;
}
optimalLevel = level;
} catch {
break;
}
}
return optimalLevel;
}
function updateOutput () {
const input = inputLinkElement.value.trim();
@@ -50,9 +93,10 @@ function updateOutput () {
const alphabet = settings.emoji ? outputAlphabetEmoji : outputAlphabetASCII;
const output = compress(input, alphabet);
let inputNormalized = input;
if (input.startsWith("https://")) {
const inputLower = input.toLowerCase();
if (inputLower.startsWith("https://")) {
inputNormalized = input.slice(8);
} else if (input.startsWith("http://")) {
} else if (inputLower.startsWith("http://")) {
inputNormalized = input.slice(7);
}
let excessiveParams = false;
@@ -81,14 +125,26 @@ function updateOutput () {
outputRatioElement.textContent = "Output is the same length as the input";
outputRatioElement.style.color = "gray";
}
outputLinkElement.textContent = `http://ha.mr#${output}`;
outputLinkElement.href = `http://ha.mr#${output}`;
outputLinkElement.textContent = `http://${domain}#${output}`;
outputLinkElement.href = `http://${domain}#${output}`;
outputLinkElement.style.color = "";
if (settings.qr) {
const errorCorrection = ["L", "M", "Q", "H"][qrCodeCorrectionLevelElement.value];
const correctionLevels = ["L", "M", "Q", "H"];
qrCodeImage.style.display = "inline";
qrCodeCorrectionLevelContainer.style.display = "inline";
let qrCodeLink = `HTTP://HA.MR/${compress(input, outputAlphabetQR)}`;
const qrCodeDomain = domain.toUpperCase();
const qrCodeLink = `HTTP://${qrCodeDomain}/${compress(input, outputAlphabetQR)}`;
if (!qrCorrectionManuallySet) {
const optimalLevel = getOptimalErrorCorrectionLevel(qrCodeLink);
qrCodeCorrectionLevelElement.value = correctionLevels.indexOf(optimalLevel);
}
const errorCorrection =
correctionLevels[qrCodeCorrectionLevelElement.value];
QRCode.toDataURL(qrCodeLink, {
errorCorrectionLevel: errorCorrection,
scale: 8
@@ -120,7 +176,11 @@ function updateOutput () {
queryWarningElement.style.display = "none";
}
}
inputLinkElement.addEventListener("input", updateOutput);
inputLinkElement.addEventListener("input", () => {
qrCorrectionManuallySet = false;
updateOutput();
});
(() => {
let payload = null;
+10
View File
@@ -0,0 +1,10 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /404.html;
}
}
+24
View File
File diff suppressed because one or more lines are too long
+8 -2
View File
@@ -7,8 +7,10 @@ import {
const input = process.argv[2]?.trim();
const alphabetName = process.argv[3]?.trim() || "ascii";
const command = process.argv[4]?.trim() || "encode";
if (!input) {
console.error(`Usage: hamr <link> [ascii|qr|emoji]`);
console.error(`Usage: hamr <link> [ascii|qr|emoji] {decode|encode}`);
console.error(`The final argument is optional and defaults to "encode".`);
process.exit(1);
}
@@ -19,9 +21,13 @@ if (input.toLowerCase().startsWith("http://ha.mr")) {
payload = input.slice(13);
} else if (input.toLowerCase().startsWith("ha.mr")) {
payload = input.slice(5);
} else if (command === "decode") {
const pos = input.indexOf("#");
payload = input.slice(pos);
console.log(`Payload: ${payload}`);
}
if (payload) {
if (payload || command === "decode") {
const isQRCode = input[0] === "/";
payload = payload.slice(1);
const useEmoji = Array.from(payload).some(c => !outputAlphabetASCII.includes(c));