Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f4188cfaa7 | |||
| 2f7e26b02b | |||
| b609b3acf7 | |||
| 943a34935f | |||
| 0e147413eb | |||
| 611389d112 | |||
| 79258d4611 | |||
| 15684f9cb9 | |||
| 831a00b703 | |||
| d47b958fc3 | |||
| 7a5f333f48 | |||
| 3236e6e53d | |||
| 0b3aa5633f | |||
| a79bd4a862 | |||
| 084b5c665c | |||
| bdc3c33026 | |||
| b12948bbef | |||
| 2a99632a3b | |||
| 96dc069a34 | |||
| 9a8b6337a2 | |||
| c48d02d03b | |||
| 1ddf7cc2a7 |
@@ -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 "&" 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"> </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>
|
||||
@@ -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/
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
ports:
|
||||
- "8080:80"
|
||||
+34
-9
@@ -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;
|
||||
@@ -264,12 +264,32 @@ export function compress (input, alphabet) {
|
||||
huffmanNumber = huffmanEncode(huffmanNumber, pathEncode["%"]);
|
||||
i -= 2;
|
||||
} else {
|
||||
huffmanNumber = huffmanEncode(huffmanNumber, pathEncode[segment.value[i]]);
|
||||
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;
|
||||
}
|
||||
|
||||
+30
-5
@@ -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,10 +185,9 @@
|
||||
<input type="checkbox" id="settings-qr" name="settings-qr" autocomplete="off">
|
||||
<label for="settings-qr"> Output QR code</label><br>
|
||||
</span>
|
||||
<br><br>
|
||||
|
||||
</div>
|
||||
|
||||
<script type="module" src="/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
+8
-2
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user