URL Anatomy
by sloprun seed
2026-08-14
0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously
A web address is seven parts held together by punctuation, and this pulls one apart into colour: scheme, sign-in, host, port, path, query, fragment. Hover, tap or arrow along any coloured piece and it tells you what that piece actually does — why the port is usually invisible, why capitals matter in the path but not in the host, why everything after the # never reaches the server at all. The query parameters become an editable table wired straight back to the address, so every keystroke rebuilds the link live and hovering a row lights up its pair above. Start on the preloaded shopping link and press drop 4 tracking parameters to watch a third of the address disappear without breaking it, then try accents & emoji with show decoded on to read what the wall of % signs was hiding.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>URL Anatomy — sloprun.dev</title>
<style>
/* sloprun design tokens — inline this block into every demo (self-contained rule).
Identity: instrument-panel. Machine facts in mono; human words in sans.
Green is EARNED: only for "it ran / it worked" states, never decoration. */
:root {
--bg: #F6F6F3; /* neutral paper, not cream */
--surface: #FFFFFF;
--ink: #1A1D21;
--muted: #5A6068;
--line: #E3E2DC;
--accent: #C05600; /* phosphor amber, darkened for light bg */
--accent-soft: #FFF3E6;
--run: #1A7F37; /* earned green */
--run-soft: #E7F4EA;
--danger: #C93C37;
--radius: 10px;
--font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
}
:root[data-theme="dark"] {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }
/* ---- part palette (deliberately no green: green is earned) ---- */
:root {
--s-scheme-fg:#4F46E5; --s-scheme-bg:#EAE8FD;
--s-user-fg:#B4123F; --s-user-bg:#FCE7EC;
--s-host-fg:#B45309; --s-host-bg:#FFF1DF;
--s-port-fg:#0E7490; --s-port-bg:#DDF2F7;
--s-path-fg:#1D4ED8; --s-path-bg:#E3ECFE;
--s-query-fg:#7E22CE; --s-query-bg:#F4E8FD;
--s-frag-fg:#475569; --s-frag-bg:#E7EDF3;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--s-scheme-fg:#B0AAFF; --s-scheme-bg:#262247;
--s-user-fg:#FDA4AF; --s-user-bg:#3B2027;
--s-host-fg:#FFB454; --s-host-bg:#2F2418;
--s-port-fg:#67E8F9; --s-port-bg:#12333A;
--s-path-fg:#9DBBFF; --s-path-bg:#182848;
--s-query-fg:#DDB4FE; --s-query-bg:#2E1F45;
--s-frag-fg:#C7D2DE; --s-frag-bg:#252B33;
}
}
:root[data-theme="dark"] {
--s-scheme-fg:#B0AAFF; --s-scheme-bg:#262247;
--s-user-fg:#FDA4AF; --s-user-bg:#3B2027;
--s-host-fg:#FFB454; --s-host-bg:#2F2418;
--s-port-fg:#67E8F9; --s-port-bg:#12333A;
--s-path-fg:#9DBBFF; --s-path-bg:#182848;
--s-query-fg:#DDB4FE; --s-query-bg:#2E1F45;
--s-frag-fg:#C7D2DE; --s-frag-bg:#252B33;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body { line-height: 1.5; -webkit-text-size-adjust: 100%; overflow-x: hidden; }
.wrap { max-width: 1120px; margin: 0 auto; padding: 22px 16px 44px; }
/* ---- header ---- */
header { margin-bottom: 16px; }
h1 { font-size: 1.35rem; margin: 0 0 6px; letter-spacing: -0.01em; }
.lede { margin: 0 0 10px; color: var(--muted); max-width: 66ch; font-size: .95rem; }
.spec { display: flex; flex-wrap: wrap; gap: 6px; }
.spec b {
font-family: var(--font-mono); font-size: .7rem; font-weight: 500; letter-spacing: .02em;
padding: 3px 7px; border: 1px solid var(--line); border-radius: 999px;
color: var(--muted); background: var(--surface); white-space: nowrap;
}
/* ---- shell ---- */
.card {
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); padding: 14px; margin-bottom: 14px;
}
.card h2 {
font-family: var(--font-mono); font-size: .72rem; font-weight: 600; letter-spacing: .08em;
text-transform: uppercase; color: var(--muted); margin: 0 0 10px;
}
.row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.spread { justify-content: space-between; }
.grid { display: grid; gap: 14px; grid-template-columns: 1fr; align-items: start; }
.grid > div { min-width: 0; }
@media (min-width: 940px) { .grid { grid-template-columns: minmax(0,1.05fr) minmax(0,0.95fr); } }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
/* ---- controls ---- */
button, input, textarea { font: inherit; color: inherit; }
.btn {
font-family: var(--font-mono); font-size: .74rem; padding: 5px 9px;
background: var(--surface); color: var(--muted);
border: 1px solid var(--line); border-radius: 7px; cursor: pointer;
}
.btn:hover:not(:disabled) { border-color: var(--accent); color: var(--ink); }
.btn[aria-pressed="true"] {
background: var(--accent-soft); border-color: var(--accent); color: var(--accent); font-weight: 600;
}
.btn:disabled { opacity: .45; cursor: default; }
.btn.strong { color: var(--ink); border-color: var(--muted); }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
textarea#url {
width: 100%; min-height: 78px; resize: vertical; display: block;
background: var(--bg); color: var(--ink);
border: 1px solid var(--line); border-radius: 8px; padding: 10px;
font-family: var(--font-mono); font-size: .84rem; line-height: 1.6;
word-break: break-all;
}
textarea#url:focus { border-color: var(--accent); }
.status { font-family: var(--font-mono); font-size: .72rem; color: var(--muted); }
.status.ok { color: var(--run); }
.status.warn { color: var(--accent); }
.status.bad { color: var(--danger); }
.note { font-size: .78rem; color: var(--muted); margin: 8px 0 0; max-width: 72ch; }
.note code { font-family: var(--font-mono); font-size: .95em; }
/* ---- stats ---- */
.stats { display: flex; flex-wrap: wrap; gap: 1px; background: var(--line);
border: 1px solid var(--line); border-radius: 8px; overflow: hidden; margin-top: 12px; }
.stat { background: var(--surface); padding: 8px 10px; flex: 1 1 92px; min-width: 0; }
.stat .k { font-family: var(--font-mono); font-size: .6rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
.stat .v { font-family: var(--font-mono); font-size: 1.05rem; font-weight: 600; letter-spacing: -0.02em; }
.stat .v small { font-size: .66rem; font-weight: 500; color: var(--muted); }
/* ---- flags ---- */
.flags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.flags:empty { margin-top: 0; }
.flag {
font-family: var(--font-mono); font-size: .68rem; padding: 3px 8px; border-radius: 999px;
border: 1px solid var(--line); color: var(--muted); background: var(--bg);
}
.flag.warn { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
.flag.bad { color: var(--danger); border-color: var(--danger); background: transparent; }
/* ---- ribbon ---- */
.ribbon {
font-family: var(--font-mono); font-size: .96rem; line-height: 2.15;
background: var(--bg); border: 1px solid var(--line); border-radius: 8px;
padding: 10px 12px; word-break: break-all; overflow-wrap: anywhere;
}
@media (max-width: 520px) { .ribbon { font-size: .84rem; padding: 9px 10px; } }
.ribbon .sep { color: var(--muted); opacity: .75; padding: 0 1px; }
.seg {
font: inherit; border: 0; padding: 3px 2px; margin: 0; cursor: pointer;
border-radius: 4px; background: transparent; color: var(--ink); text-align: left;
-webkit-box-decoration-break: clone; box-decoration-break: clone;
border-bottom: 2px solid transparent;
}
.seg.scheme { background: var(--s-scheme-bg); color: var(--s-scheme-fg); }
.seg.user { background: var(--s-user-bg); color: var(--s-user-fg); }
.seg.host { background: var(--s-host-bg); color: var(--s-host-fg); }
.seg.port { background: var(--s-port-bg); color: var(--s-port-fg); }
.seg.path { background: var(--s-path-bg); color: var(--s-path-fg); }
.seg.query { background: var(--s-query-bg); color: var(--s-query-fg); }
.seg.frag { background: var(--s-frag-bg); color: var(--s-frag-fg); }
.ribbon.focusing .seg { opacity: .3; }
.ribbon.focusing .sep { opacity: .3; }
.ribbon.focusing .seg.lit { opacity: 1; }
.seg.sel { border-bottom-color: currentColor; font-weight: 600; }
.seg .sub { opacity: .6; }
/* ---- legend ---- */
.legend { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.lg {
font-family: var(--font-mono); font-size: .7rem; padding: 4px 8px; cursor: pointer;
border: 1px solid var(--line); border-radius: 999px; background: var(--surface); color: var(--muted);
display: inline-flex; align-items: center; gap: 6px;
}
.lg .dot { width: 9px; height: 9px; border-radius: 3px; flex: 0 0 auto; }
.lg .n { opacity: .65; }
.lg:hover:not(:disabled) { color: var(--ink); border-color: var(--muted); }
.lg[aria-pressed="true"] { color: var(--ink); border-color: var(--lgc); font-weight: 600; }
.lg:disabled { opacity: .38; cursor: default; }
/* ---- explain panel ---- */
.ex {
margin-top: 12px; border: 1px solid var(--line); border-left: 3px solid var(--exc, var(--line));
border-radius: 8px; padding: 11px 12px; background: var(--bg); min-height: 124px;
}
.exhead { display: flex; flex-wrap: wrap; gap: 8px; align-items: baseline; }
.exname { font-family: var(--font-mono); font-size: .72rem; letter-spacing: .07em; text-transform: uppercase;
color: var(--exc, var(--muted)); font-weight: 700; }
.exval { font-family: var(--font-mono); font-size: .8rem; word-break: break-all; overflow-wrap: anywhere; min-width: 0; }
.exbody { margin: 8px 0 0; font-size: .88rem; max-width: 70ch; }
.exrows { margin-top: 9px; display: grid; gap: 4px; }
.exrow { display: grid; grid-template-columns: 10.6em minmax(0,1fr); gap: 8px; font-size: .78rem; }
.exrow .rk { font-family: var(--font-mono); font-size: .66rem; letter-spacing: .05em; text-transform: uppercase;
color: var(--muted); padding-top: 2px; }
.exrow .rv { font-family: var(--font-mono); word-break: break-all; overflow-wrap: anywhere; }
@media (max-width: 520px) { .exrow { grid-template-columns: 1fr; gap: 1px; } }
.chips { display: flex; flex-wrap: wrap; gap: 5px; }
.chip {
font-family: var(--font-mono); font-size: .72rem; padding: 2px 6px; border-radius: 5px;
background: var(--surface); border: 1px solid var(--line); word-break: break-all;
}
.chip .cl { color: var(--muted); font-size: .9em; }
/* ---- parameter table ---- */
.ptable { display: grid; gap: 5px; }
.phead, .prow { display: grid; gap: 8px; align-items: center;
grid-template-columns: 1.6em minmax(0,1fr) minmax(0,1.25fr) 1.9em; }
.phead { font-family: var(--font-mono); font-size: .6rem; letter-spacing: .08em; text-transform: uppercase;
color: var(--muted); padding: 0 4px 4px; border-bottom: 1px solid var(--line); }
.prow { padding: 5px 4px; border-radius: 7px; border: 1px solid transparent; }
.prow.off { opacity: .5; }
.prow.hit { border-color: var(--accent); background: var(--accent-soft); }
.prow input[type="text"] {
width: 100%; min-width: 0; background: var(--bg); color: var(--ink);
border: 1px solid var(--line); border-radius: 6px; padding: 4px 6px;
font-family: var(--font-mono); font-size: .78rem;
}
.prow input[type="text"]:focus { border-color: var(--accent); }
.prow.off input[type="text"] { text-decoration: line-through; }
.prow input[type="checkbox"] { width: 15px; height: 15px; accent-color: var(--accent); cursor: pointer; }
.xbtn { font-family: var(--font-mono); font-size: .8rem; line-height: 1; padding: 5px 6px; color: var(--muted);
background: transparent; border: 1px solid transparent; border-radius: 6px; cursor: pointer; }
.xbtn:hover { color: var(--danger); border-color: var(--danger); }
.pwire { grid-column: 1 / -1; font-family: var(--font-mono); font-size: .66rem; color: var(--muted);
word-break: break-all; overflow-wrap: anywhere; padding-left: calc(1.6em + 8px); }
.pwire:empty { display: none; }
.pwire b { color: var(--accent); font-weight: 600; white-space: nowrap; }
@media (max-width: 560px) {
.phead { display: none; }
.prow { grid-template-columns: 1.6em minmax(0,1fr) 1.9em; row-gap: 5px; }
.prow .pv { grid-column: 2 / 3; }
.pwire { padding-left: 0; }
}
.empty { font-family: var(--font-mono); font-size: .74rem; color: var(--muted); padding: 10px 2px; }
/* ---- parts list ---- */
.parts { display: grid; gap: 1px; background: var(--line); border: 1px solid var(--line);
border-radius: 8px; overflow: hidden; }
.part { display: grid; grid-template-columns: 7.4em minmax(0,1fr); gap: 8px; align-items: baseline;
background: var(--surface); padding: 7px 9px; text-align: left; border: 0; cursor: pointer; width: 100%; }
.part:hover:not(:disabled) { background: var(--bg); }
.part[aria-pressed="true"] { background: var(--bg); box-shadow: inset 2px 0 0 var(--pc); }
.part .pn { font-family: var(--font-mono); font-size: .66rem; letter-spacing: .06em; text-transform: uppercase;
color: var(--pc, var(--muted)); font-weight: 700; }
.part .pval { font-family: var(--font-mono); font-size: .77rem; word-break: break-all; overflow-wrap: anywhere; }
.part:disabled { cursor: default; }
.part:disabled .pn { color: var(--muted); opacity: .7; font-weight: 500; }
.part:disabled .pval { color: var(--muted); font-family: var(--font-sans); font-size: .74rem; font-style: italic; }
@media (max-width: 400px) { .part { grid-template-columns: 1fr; gap: 1px; } }
/* ---- encoding list ---- */
.encs { display: grid; gap: 1px; background: var(--line); border: 1px solid var(--line);
border-radius: 8px; overflow: hidden; }
.enc { display: grid; grid-template-columns: auto 1.7em minmax(0,1fr) auto; gap: 9px; align-items: center;
background: var(--surface); padding: 6px 9px; font-size: .78rem; }
.enc .e { font-family: var(--font-mono); font-size: .76rem; color: var(--accent); white-space: nowrap; }
.enc .c { font-family: var(--font-mono); font-size: .92rem; text-align: center; }
.enc .c.ghost { color: var(--muted); font-size: .74rem; }
.enc .w { color: var(--muted); min-width: 0; overflow-wrap: anywhere; }
.enc .n { font-family: var(--font-mono); font-size: .68rem; color: var(--muted); white-space: nowrap; }
details { margin: 0; }
summary { cursor: pointer; font-family: var(--font-mono); font-size: .74rem; color: var(--muted); list-style: none; }
summary::-webkit-details-marker { display: none; }
summary:hover { color: var(--ink); }
details .body { font-size: .84rem; color: var(--muted); max-width: 78ch; }
details .body p { margin: 10px 0; }
details .body b { color: var(--ink); }
details .body code { font-family: var(--font-mono); font-size: .95em; }
footer { margin-top: 24px; font-family: var(--font-mono); font-size: .7rem; color: var(--muted); }
/* fingers need more room than a mouse pointer does */
@media (pointer: coarse) {
.btn { padding: 8px 11px; }
.lg { padding: 7px 10px; }
.prow input[type="checkbox"] { width: 20px; height: 20px; }
.prow input[type="text"] { padding: 7px 8px; }
.xbtn { padding: 9px 10px; }
}
@media (prefers-reduced-motion: no-preference) {
.btn, .lg, .seg, .part, .prow, .sep, input { transition: background-color .12s ease, border-color .12s ease, color .12s ease, opacity .12s ease; }
}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>URL Anatomy</h1>
<p class="lede">A web address is a stack of parts held together by punctuation. Paste one in and it falls apart
into colour — hover, tap or arrow along any piece to find out what it actually does, then edit the parameters
and watch the address rebuild itself on every keystroke.</p>
<div class="spec">
<b>7 parts</b><b>live rebuild</b><b>percent-decoder</b><b>nothing leaves this page</b>
</div>
</header>
<section class="card">
<div class="row spread" style="margin-bottom:8px">
<h2 style="margin:0">The address</h2>
<span class="status" id="valid">—</span>
</div>
<label class="sr" for="url">Web address to take apart</label>
<textarea id="url" spellcheck="false" autocapitalize="off" autocorrect="off"></textarea>
<div class="row" style="margin-top:9px">
<button class="btn strong" type="button" id="copy">copy address</button>
<span class="status" id="copyMsg" role="status" aria-live="polite"></span>
</div>
<div class="row" style="margin-top:9px">
<span class="status" style="margin-right:2px">try:</span>
<button class="btn" type="button" data-sample="shop">shopping link</button>
<button class="btn" type="button" data-sample="search">search results</button>
<button class="btn" type="button" data-sample="video">video timestamp</button>
<button class="btn" type="button" data-sample="unicode">accents & emoji</button>
<button class="btn" type="button" data-sample="mail">email link</button>
<button class="btn" type="button" data-sample="login">password in the link</button>
</div>
<div class="stats">
<div class="stat"><div class="k">characters</div><div class="v" id="sLen">0</div></div>
<div class="stat"><div class="k">parts used</div><div class="v" id="sParts">0</div></div>
<div class="stat"><div class="k">parameters</div><div class="v" id="sParams">0</div></div>
<div class="stat"><div class="k">%-escapes</div><div class="v" id="sEsc">0</div></div>
<div class="stat"><div class="k">path steps</div><div class="v" id="sDepth">0</div></div>
</div>
<div class="flags" id="flags"></div>
</section>
<section class="card">
<div class="row spread" style="margin-bottom:8px">
<h2 style="margin:0">Anatomy</h2>
<span class="status">hover, click, or arrow along</span>
</div>
<div class="ribbon" id="ribbon" role="group" aria-label="The address, split into its parts"></div>
<div class="legend" id="legend"></div>
<div class="ex" id="ex" role="status" aria-live="polite"></div>
</section>
<div class="grid">
<div>
<section class="card">
<div class="row spread" style="margin-bottom:8px">
<h2 style="margin:0">Query parameters</h2>
<span class="status" id="qStatus">—</span>
</div>
<div class="row" style="margin-bottom:10px">
<button class="btn" type="button" id="tDecode" aria-pressed="false">show decoded</button>
<button class="btn" type="button" id="tPlus" aria-pressed="false">spaces as +</button>
<button class="btn" type="button" id="tLiteral" aria-pressed="false">keep accents literal</button>
</div>
<div class="ptable" id="ptable"></div>
<div class="row" style="margin-top:10px">
<button class="btn" type="button" id="addParam">+ add parameter</button>
<button class="btn" type="button" id="dropTrack">drop tracking</button>
<button class="btn" type="button" id="allOn">turn all back on</button>
</div>
<p class="note" id="qNote"></p>
</section>
</div>
<div>
<section class="card">
<h2>The seven parts</h2>
<div class="parts" id="parts"></div>
</section>
<section class="card">
<div class="row spread" style="margin-bottom:8px">
<h2 style="margin:0">What the % signs hide</h2>
<span class="status" id="encCount">—</span>
</div>
<div id="encs"></div>
<p class="note" id="encNote"></p>
</section>
</div>
</div>
<section class="card">
<details>
<summary>How this pulls an address apart (and where it stops)</summary>
<div class="body">
<p>The split follows the shape web addresses have agreed on for decades: an optional <b>scheme</b> up to the
first colon, an optional <b>authority</b> after <code>//</code> (sign-in details, host, port), then the
<b>path</b>, then everything after the first <code>?</code> is the <b>query</b>, then everything after the
first <code>#</code> is the <b>fragment</b>. Later <code>?</code> and <code>#</code> characters are just
ordinary text inside whichever part they landed in.</p>
<p>Query parameters are a convention rather than a rule. Pairs joined by <code>&</code> and split on the
first <code>=</code> is what every web framework expects, and it is what this page does — but a server is
free to read the query however it likes. Repeated names are kept as separate rows, because that is usually
deliberate.</p>
<p>Inside a query, <code>+</code> and <code>%20</code> both mean a space; everywhere else in the address a
<code>+</code> is just a plus sign. The <b>spaces as +</b> toggle rewrites between the two forms, so the
address changes while its meaning does not.</p>
<p>Two things this deliberately does not do: it does not convert accented host names into their
<code>xn--</code> ASCII form, and it does not tidy the address up (no lower-casing the host, no collapsing
<code>/./</code>). You see what you typed, split up — not what a browser would clean it into.</p>
</div>
</details>
</section>
<footer>demo · sloprun.dev</footer>
</div>
<script>
(function () {
"use strict";
/* ---------------- samples ---------------- */
var SAMPLES = {
shop: "https://shop.example.com:8443/catalog/winter%20boots/size-9?q=waterproof+boots&colour=deep%20teal&page=2&sort=price_desc&utm_source=newsletter&utm_medium=email&utm_campaign=spring%2Fsale&fbclid=IwAR3xQ9#customer-reviews",
search: "https://www.example.org/search?q=how+to+make+cold+brew&lang=en-GB&safe=off&pages=1-3&t=1723550400#results",
video: "https://videos.example.net/watch?v=8Kd3nQ_a1Zc&list=PL9tYb2&index=4&t=215s&utm_source=share#comments",
unicode: "https://café.example.co.uk/menu/déjeuner/plat%20du%20jour?dish=croque%20monsieur¬e=100%25%20butter&pastry=%F0%9F%A5%90&table=2#brunch",
mail: "mailto:[email protected]?subject=Coffee%20on%20Friday%3F&body=Hi%20there%2C%0A%0AAre%20you%20free%20at%2010%3F&cc=team%40example.com",
login: "ftp://archie:[email protected]:2121/backups/2026/q1.tar.gz?expires=1735689600&sig=a3f9c1e0#top"
};
var TRACKERS = ["utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "utm_id", "gclid",
"fbclid", "msclkid", "dclid", "igshid", "mc_cid", "mc_eid", "yclid", "_ga", "ref_src", "si", "spm"];
var FAMS = {
scheme: { label: "scheme", css: "--s-scheme-fg" },
user: { label: "sign-in", css: "--s-user-fg" },
host: { label: "host", css: "--s-host-fg" },
port: { label: "port", css: "--s-port-fg" },
path: { label: "path", css: "--s-path-fg" },
query: { label: "query", css: "--s-query-fg" },
frag: { label: "fragment", css: "--s-frag-fg" }
};
var FAM_ORDER = ["scheme", "user", "host", "port", "path", "query", "frag"];
var SCHEMES = {
https: "an encrypted web request, the usual one",
http: "a plain, unencrypted web request",
ftp: "a file-transfer connection, an older way to move files around",
mailto: "opens a new message in your email app instead of loading a page",
tel: "dials a number on a phone",
sms: "starts a text message",
file: "a file already on this machine, so nothing travels anywhere",
ws: "a live two-way connection that stays open",
wss: "a live two-way connection that stays open, encrypted"
};
var DEFAULT_PORTS = { http: "80", https: "443", ftp: "21", ws: "80", wss: "443" };
var OPAQUE = {
mailto: { what: "the address to write to", body: "Not folders at all. A mailto link has no host and no path to walk down — everything after the colon is simply who the message is addressed to, handed straight to your email app." },
tel: { what: "the number to dial", body: "Not folders at all. Everything after the colon is the number itself, handed straight to whatever app makes calls." },
sms: { what: "the number to text", body: "Not folders at all. Everything after the colon is the number itself, handed straight to whatever app sends messages." }
};
var MULTI_SUFFIX = ["co.uk", "org.uk", "ac.uk", "me.uk", "co.jp", "ne.jp", "or.jp", "com.au", "net.au",
"org.au", "co.nz", "com.br", "co.in", "co.za", "com.mx", "com.tr", "co.kr", "com.sg", "com.hk", "com.cn"];
var CHAR_NAMES = {
" ": "a space", "/": "a slash", "?": "a question mark", "#": "a hash", "&": "an ampersand",
"=": "an equals sign", "@": "an at sign", "+": "a plus sign", "%": "a percent sign",
":": "a colon", ";": "a semicolon", ",": "a comma", "\"": "a double quote", "'": "an apostrophe",
"<": "a less-than sign", ">": "a greater-than sign", "[": "an open bracket", "]": "a close bracket",
"{": "an open brace", "}": "a close brace", "|": "a pipe", "\\": "a backslash", "^": "a caret",
"`": "a backtick", "$": "a dollar sign", "(": "an open bracket", ")": "a close bracket",
"*": "an asterisk", "!": "an exclamation mark", "~": "a tilde",
"\n": "a line break", "\r": "a carriage return", "\t": "a tab"
};
var NON_ASCII = /[^\u0000-\u007F]/;
var NON_ASCII_G = /[^\u0000-\u007F]/gu; /* /u so an emoji counts as ONE character, not two halves */
/* ---------------- helpers ---------------- */
function $(id) { return document.getElementById(id); }
function el(tag, cls, txt) {
var n = document.createElement(tag);
if (cls) n.className = cls;
if (txt !== undefined && txt !== null) n.textContent = txt;
return n;
}
function clear(n) { while (n.firstChild) n.removeChild(n.firstChild); }
function decodeQ(s) {
try { return decodeURIComponent(String(s).replace(/\+/g, " ")); } catch (e) { return String(s); }
}
function decodePath(s) {
try { return decodeURIComponent(String(s)); } catch (e) { return String(s); }
}
function pctBytes(ch) {
var cp = ch.codePointAt(0);
if (cp >= 0xD800 && cp <= 0xDFFF) return ch; /* half a pair on its own — leave it be */
var bytes = new TextEncoder().encode(ch), out = "", i, h;
for (i = 0; i < bytes.length; i++) {
h = bytes[i].toString(16).toUpperCase();
out += "%" + (h.length === 1 ? "0" + h : h);
}
return out;
}
function nf(n) { return Number(n).toLocaleString("en-US"); }
var reduceMotion = !!(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
/* ---------------- parser ---------------- */
function parse(raw) {
var s = String(raw);
var P = {
raw: s, scheme: "", hasAuthority: false, hasUser: false, user: "", hasPass: false, pass: "",
host: "", port: "", hasPort: false, path: "", query: "", fragment: "",
hasQuery: false, hasFragment: false, params: []
};
var n = s.length, i = 0;
var m = /^([A-Za-z][A-Za-z0-9+.\-]*):/.exec(s);
if (m) { P.scheme = m[1]; i = m[0].length; }
if (s.slice(i, i + 2) === "//") {
P.hasAuthority = true;
i += 2;
var end = i;
while (end < n && "/?#".indexOf(s.charAt(end)) === -1) end++;
var auth = s.slice(i, end);
i = end;
var at = auth.lastIndexOf("@");
var hp = auth;
if (at !== -1) {
var ui = auth.slice(0, at);
hp = auth.slice(at + 1);
P.hasUser = true;
var ci = ui.indexOf(":");
if (ci !== -1) { P.user = ui.slice(0, ci); P.pass = ui.slice(ci + 1); P.hasPass = true; }
else { P.user = ui; }
}
if (hp.charAt(0) === "[") {
var rb = hp.indexOf("]");
if (rb !== -1) {
P.host = hp.slice(0, rb + 1);
var after = hp.slice(rb + 1);
if (after.charAt(0) === ":") { P.port = after.slice(1); P.hasPort = true; }
} else { P.host = hp; }
} else {
var ci2 = hp.lastIndexOf(":");
if (ci2 !== -1) { P.host = hp.slice(0, ci2); P.port = hp.slice(ci2 + 1); P.hasPort = true; }
else { P.host = hp; }
}
}
var pe = i;
while (pe < n && "?#".indexOf(s.charAt(pe)) === -1) pe++;
P.path = s.slice(i, pe);
i = pe;
if (s.charAt(i) === "?") {
P.hasQuery = true;
i++;
var qe = i;
while (qe < n && s.charAt(qe) !== "#") qe++;
P.query = s.slice(i, qe);
i = qe;
}
if (s.charAt(i) === "#") { P.hasFragment = true; P.fragment = s.slice(i + 1); }
if (P.query !== "") {
P.query.split("&").forEach(function (pair) {
if (pair === "") return;
var eq = pair.indexOf("=");
if (eq === -1) P.params.push({ k: pair, v: "", noEq: true, on: true });
else P.params.push({ k: pair.slice(0, eq), v: pair.slice(eq + 1), noEq: false, on: true });
});
}
return P;
}
function compose() {
var P = state.P, s = "";
if (P.scheme) s += P.scheme + ":";
if (P.hasAuthority) {
s += "//";
if (P.hasUser) {
s += P.user;
if (P.hasPass) s += ":" + P.pass;
s += "@";
}
s += P.host;
if (P.hasPort) s += ":" + P.port;
}
s += P.path;
var act = state.params.filter(function (p) { return p.on; });
if (act.length) {
s += "?" + act.map(function (p) { return p.noEq ? p.k : p.k + "=" + p.v; }).join("&");
}
if (P.hasFragment) s += "#" + P.fragment;
return s;
}
/* ---------------- state ---------------- */
var state = {
url: "",
P: parse(""),
params: [],
baseLen: 0,
sel: null,
previewing: false,
hover: null,
opts: { decode: false, plus: false, literal: false }
};
var urlBox = $("url"), ribbon = $("ribbon"), legendBox = $("legend"), exBox = $("ex");
/* ---------------- encoding transforms ----------------
Two independent dimensions, one function each, so a toggle only ever moves
its own dimension: flipping "keep accents literal" must not quietly rewrite
the + signs, and neither may hand back a character that is illegal raw. */
function applyPlus(s) {
var out = String(s);
return state.opts.plus ? out.replace(/%20/g, "+") : out.replace(/\+/g, "%20");
}
function applyLiteral(s) {
var out = String(s);
if (!state.opts.literal) return out.replace(NON_ASCII_G, function (c) { return pctBytes(c); });
/* A run like %C3%A9%20 decodes to "é " — only the é may stay literal, or the
space would land in the address raw and break it (and never come back). */
return out.replace(/(?:%[0-9A-Fa-f]{2})+/g, function (run) {
try {
var d = decodeURIComponent(run);
if (!NON_ASCII.test(d)) return run;
return Array.from(d).map(function (ch) {
return NON_ASCII.test(ch) ? ch : pctBytes(ch);
}).join("");
} catch (e) { return run; }
});
}
function encodeTyped(text) { return applyLiteral(applyPlus(encodeURIComponent(String(text)))); }
/* Characters that would split a pair apart if they went in raw. Typing one in
the table encodes it on the spot, so the table and the address can never
drift into telling two different stories. */
function pctChar(c) { return "%" + c.charCodeAt(0).toString(16).toUpperCase(); }
function escapeStructural(s, isKey) {
return String(s).replace(isKey ? /[&#=]/g : /[&#]/g, pctChar);
}
function guardRaw(input, isKey) {
var raw = input.value, safe = escapeStructural(raw, isKey);
if (safe !== raw) {
var pos = input.selectionStart == null ? raw.length : input.selectionStart;
var head = escapeStructural(raw.slice(0, pos), isKey).length;
input.value = safe;
try { input.setSelectionRange(head, head); } catch (e) { /* not selectable */ }
}
return safe;
}
/* ---------------- load / rebuild ---------------- */
function adoptURL(str) {
state.url = String(str);
state.P = parse(state.url);
state.params = state.P.params.map(function (p) {
return { k: p.k, v: p.v, noEq: p.noEq, on: true };
});
state.baseLen = state.url.length;
}
function setURL(str, sel) {
adoptURL(String(str).replace(/[\r\n]+/g, "").trim());
urlBox.value = state.url;
state.sel = sel || { kind: "fam", fam: state.P.host ? "host" : "scheme" };
renderAll(true);
}
function paramsChanged(structural) {
var str = compose();
state.url = str;
urlBox.value = str;
state.P = parse(str);
renderAll(!!structural);
}
function activeIndexes() {
var out = [];
state.params.forEach(function (p, i) { if (p.on) out.push(i); });
return out;
}
function isTracker(k) {
var key = String(k).toLowerCase();
return TRACKERS.indexOf(key) !== -1 || /^utm_/.test(key);
}
function countParts() {
var P = state.P, c = 0;
if (P.scheme) c++;
if (P.hasUser) c++;
if (P.host) c++;
if (P.hasPort) c++;
if (P.path) c++;
if (P.hasQuery) c++;
if (P.hasFragment) c++;
return c;
}
function pathSteps() {
var P = state.P;
return P.path ? P.path.split("/").filter(function (x) { return x !== ""; }) : [];
}
/* ---------------- host labels ---------------- */
function hostLabels(host) {
if (!host || host.charAt(0) === "[" || /^[\d.]+$/.test(host)) return null;
var labels = host.split(".");
if (labels.length < 2) return null;
var last2 = labels.slice(-2).join(".").toLowerCase();
var sc = MULTI_SUFFIX.indexOf(last2) !== -1 ? 2 : 1;
if (labels.length <= sc) return null;
var suffix = labels.slice(-sc).join(".");
var name = labels[labels.length - sc - 1];
var sub = labels.slice(0, labels.length - sc - 1).join(".");
return { sub: sub, name: name, suffix: suffix, registrable: name + "." + suffix };
}
/* ---------------- ribbon ---------------- */
function buildTokens() {
var P = state.P, t = [], act = activeIndexes();
function sep(x) { t.push({ fam: "sep", text: x }); }
function seg(fam, text, meta) { if (text !== "") t.push({ fam: fam, text: text, meta: meta }); }
if (P.scheme) { seg("scheme", P.scheme, { kind: "scheme" }); sep(":"); }
if (P.hasAuthority) sep("//");
if (P.hasUser) {
seg("user", P.user, { kind: "user" });
if (P.hasPass) { sep(":"); seg("user", P.pass, { kind: "pass" }); }
sep("@");
}
if (P.host) seg("host", P.host, { kind: "host" });
if (P.hasPort) { sep(":"); seg("port", P.port, { kind: "port" }); }
if (P.path) {
P.path.split("/").forEach(function (piece, i) {
if (i > 0) sep("/");
if (piece !== "") seg("path", piece, { kind: "pathseg", idx: i });
});
}
if (P.hasQuery) {
sep("?");
P.params.forEach(function (p, j) {
if (j > 0) sep("&");
var pi = act[j];
seg("query", p.k, { kind: "qkey", pi: pi });
if (!p.noEq) {
sep("=");
if (p.v !== "") seg("query", p.v, { kind: "qval", pi: pi });
}
});
}
if (P.hasFragment) { sep("#"); seg("frag", P.fragment, { kind: "frag" }); }
return t;
}
function famOf(meta) {
switch (meta.kind) {
case "scheme": return "scheme";
case "user": case "pass": return "user";
case "host": return "host";
case "port": return "port";
case "pathseg": return "path";
case "qkey": case "qval": case "qpair": return "query";
case "frag": return "frag";
case "fam": return meta.fam;
}
return "host";
}
function selAlive(meta) {
if (!meta) return false;
var P = state.P;
if (meta.pi !== undefined) return !!state.params[meta.pi];
if (meta.kind === "pathseg") return P.path.split("/")[meta.idx] !== undefined;
switch (famOf(meta)) {
case "scheme": return P.scheme !== "";
case "user": return P.hasUser && (meta.kind !== "pass" || P.hasPass);
case "host": return P.host !== "";
case "port": return P.hasPort;
case "path": return P.path !== "";
case "query": return P.hasQuery;
case "frag": return P.hasFragment;
}
return false;
}
function sameSeg(a, b) {
if (!a || !b) return false;
if (b.kind === "fam") return famOf(a) === b.fam;
if (b.pi !== undefined) return a.pi === b.pi;
if (b.idx !== undefined) return a.kind === b.kind && a.idx === b.idx;
return a.kind === b.kind;
}
function renderRibbon() {
clear(ribbon);
var toks = buildTokens();
if (!toks.length) {
ribbon.appendChild(el("span", "sep", "(nothing here yet)"));
return;
}
toks.forEach(function (tk) {
if (tk.fam === "sep") { ribbon.appendChild(el("span", "sep", tk.text)); return; }
var b = el("button", "seg " + tk.fam);
b.type = "button";
b.tabIndex = -1;
if (tk.meta.kind === "host") paintHost(b, tk.text);
else b.textContent = tk.text;
b.setAttribute("aria-label", ariaFor(tk));
b._meta = tk.meta;
if (sameSeg(tk.meta, state.sel)) b.classList.add("sel");
b.addEventListener("mouseenter", function () { preview(tk.meta); });
b.addEventListener("mouseleave", unpreview);
b.addEventListener("focus", function () { preview(tk.meta); });
b.addEventListener("blur", unpreview);
b.addEventListener("click", function () { select(tk.meta); });
ribbon.appendChild(b);
});
var first = ribbon.querySelector("button.seg.sel") || ribbon.querySelector("button.seg");
if (first) first.tabIndex = 0;
}
function ariaFor(tk) {
var name = FAMS[tk.fam] ? FAMS[tk.fam].label : tk.fam;
if (tk.meta.kind === "pass") name = "password";
else if (tk.meta.kind === "qkey") name = "parameter name";
else if (tk.meta.kind === "qval") name = "parameter value";
else if (tk.meta.kind === "pathseg") name = "path step";
return name + ": " + tk.text;
}
function paintHost(node, host) {
var lab = hostLabels(host);
if (!lab || !lab.sub) { node.textContent = host; return; }
node.appendChild(el("span", "sub", lab.sub + "."));
node.appendChild(document.createTextNode(lab.registrable));
}
ribbon.addEventListener("keydown", function (e) {
var btns = Array.prototype.slice.call(ribbon.querySelectorAll("button.seg"));
var i = btns.indexOf(document.activeElement);
if (i === -1 || !btns.length) return;
var j = -1;
if (e.key === "ArrowRight" || e.key === "ArrowDown") j = (i + 1) % btns.length;
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") j = (i - 1 + btns.length) % btns.length;
else if (e.key === "Home") j = 0;
else if (e.key === "End") j = btns.length - 1;
if (j < 0) return;
e.preventDefault();
btns.forEach(function (b) { b.tabIndex = -1; });
btns[j].tabIndex = 0;
btns[j].focus();
});
function spotlight(meta) {
var btns = ribbon.querySelectorAll("button.seg");
if (!meta) { ribbon.classList.remove("focusing"); return; }
ribbon.classList.add("focusing");
Array.prototype.forEach.call(btns, function (b) {
b.classList.toggle("lit", sameSeg(b._meta, meta));
});
}
/* ---------------- legend ---------------- */
function renderLegend() {
clear(legendBox);
var P = state.P;
var counts = {
scheme: P.scheme ? 1 : 0,
user: P.hasUser ? 1 : 0,
host: P.host ? 1 : 0,
port: P.hasPort ? 1 : 0,
path: pathSteps().length,
query: P.params.length,
frag: P.hasFragment ? 1 : 0
};
FAM_ORDER.forEach(function (fam) {
var present = counts[fam] > 0 || (fam === "path" && !!P.path);
var b = el("button", "lg");
b.type = "button";
b.disabled = !present;
b.style.setProperty("--lgc", "var(" + FAMS[fam].css + ")");
var dot = el("span", "dot");
dot.style.background = "var(" + FAMS[fam].css + ")";
b.appendChild(dot);
b.appendChild(document.createTextNode(FAMS[fam].label));
if ((fam === "path" || fam === "query") && counts[fam] > 0) {
b.appendChild(el("span", "n", "×" + counts[fam]));
}
var meta = { kind: "fam", fam: fam };
b.setAttribute("aria-pressed", String(!!(state.sel && state.sel.kind === "fam" && state.sel.fam === fam)));
if (present) {
b.addEventListener("mouseenter", function () { preview(meta); });
b.addEventListener("mouseleave", unpreview);
b.addEventListener("focus", function () { preview(meta); });
b.addEventListener("blur", unpreview);
b.addEventListener("click", function () { select(meta); });
}
legendBox.appendChild(b);
});
}
/* ---------------- explanations ---------------- */
function chipWith(text, label) {
var c = el("span", "chip");
c.appendChild(document.createTextNode(text === "" ? "(empty)" : text));
if (label) c.appendChild(el("span", "cl", " " + label));
return c;
}
function explain(meta) {
var P = state.P, fam = famOf(meta), kind = meta.kind;
var out = { fam: fam, name: FAMS[fam].label, value: "", body: "", rows: [], extra: null };
if (fam === "scheme") {
out.value = P.scheme;
var known = SCHEMES[P.scheme.toLowerCase()];
out.body = "How to talk to the other machine, and which app should handle the link. This one is " +
P.scheme + (known ? " — " + known + "." : ", handed to whichever app has claimed that name.") +
" It always sits at the very front and ends at the first colon.";
out.rows.push(["others you meet", "https · http · mailto · tel · file"]);
return out;
}
if (fam === "user") {
out.name = kind === "pass" ? "password" : "sign-in";
out.value = kind === "pass" ? P.pass : P.user;
out.body = kind === "pass"
? "A password parked inside the address itself. Nothing before the @ is secret: it lands in browser history, in server logs, and in the link the moment someone pastes it to a friend. Browsers refuse this for web pages now; older file-transfer tools still take it."
: "A user name carried inside the address, between the // and the @. It was normal in the file-transfer era and has all but vanished from the web, because sites would rather you signed in on a page.";
if (P.hasPass && kind !== "pass") out.rows.push(["also carries", "a password, in plain view"]);
if (P.hasPass && kind === "pass") out.rows.push(["safer instead", "sign in on the page, or use a one-off link"]);
return out;
}
if (fam === "host") {
out.value = P.host;
var bracketed = P.host.charAt(0) === "[";
var numeric = /^[\d.]+$/.test(P.host);
var lab = hostLabels(P.host);
var bare = !bracketed && !numeric && !lab;
out.body = bare
? "Which machine to ask. This is a bare name with no suffix after it, which means it is not a name " +
"anybody could have registered — it only means something on the machine or network you are already " +
"on. " + P.host + " is resolved locally rather than looked up out on the internet."
: bracketed
? "Which machine to ask — written as a raw address rather than a name, so nothing has to be looked " +
"up first. The square brackets are there because this style of address is full of colons, and without " +
"them the last colon would be mistaken for the start of a port number."
: numeric
? "Which machine to ask — written as a raw address rather than a name, so nothing has to be looked " +
"up first. Names are the friendly layer on top; this is what a name would have turned into anyway."
: "Which machine to ask. Read it right to left: the last piece is the family name, the piece " +
"before it is the name somebody registered, and anything further left is a sub-name that owner " +
"invented for themselves, for free. Capital letters here make no difference at all.";
if (lab) {
var chips = el("div", "chips");
if (lab.sub) chips.appendChild(chipWith(lab.sub, "sub-name"));
chips.appendChild(chipWith(lab.name, "registered name"));
chips.appendChild(chipWith(lab.suffix, "suffix"));
out.extra = chips;
out.rows.push(["the paid-for bit", lab.registrable]);
} else if (P.host) {
out.rows.push(["shape", bracketed ? "a raw address in brackets — no name involved"
: numeric ? "a raw address — no name involved" : "a single label, with no suffix after it"]);
}
if (NON_ASCII.test(P.host)) out.rows.push(["note", "accented host — it travels as an xn-- spelling"]);
return out;
}
if (fam === "port") {
out.value = P.port;
var def = DEFAULT_PORTS[P.scheme];
out.body = "Which door to knock on. One machine can run many services side by side, and the port picks " +
"between them. Addresses assume " + (def ? def + " for " + P.scheme : "a standard number for each scheme") +
", which is why you almost never see a port written out — it only shows up when it is unusual.";
if (def) {
out.rows.push(["default for " + P.scheme, def + (P.port === def
? " — the same, so it could be left out" : " — this one is different")]);
}
return out;
}
if (fam === "path") {
var steps = pathSteps();
var opq = !P.hasAuthority && OPAQUE[String(P.scheme).toLowerCase()];
if (kind === "pathseg" && opq) kind = "path";
if (kind === "pathseg") {
var raw = P.path.split("/")[meta.idx] || "";
out.name = "path step";
out.value = raw;
out.body = "One step along the path. The path names what you are asking for, laid out like folders — " +
"but nothing says it has to match real folders on the machine. The server can hand out whatever it likes " +
"for a given path.";
var dec = decodePath(raw);
if (dec !== raw) out.rows.push(["really says", dec]);
out.rows.push(["full path", P.path]);
}
var op = !P.hasAuthority && OPAQUE[String(P.scheme).toLowerCase()];
if (kind !== "pathseg") {
out.value = P.path;
if (op) {
out.name = "what to act on";
out.body = op.body;
out.rows.push(["reads as", op.what]);
} else {
out.body = "Which page or file you are asking for, written like folders. Everything between the host and " +
"the first ? is path. Unlike the host, capital letters here usually do matter.";
out.rows.push(["steps", nf(steps.length)]);
}
}
var pc = el("div", "chips");
if (op) {
pc.appendChild(chipWith(decodePath(P.path), op.what));
} else {
if (!steps.length) pc.appendChild(chipWith("/", "the front door"));
steps.forEach(function (sg, i) { pc.appendChild(chipWith(decodePath(sg), "step " + (i + 1))); });
}
out.extra = pc;
return out;
}
if (fam === "query") {
if (kind === "qkey" || kind === "qval" || kind === "qpair") {
var p = state.params[meta.pi];
if (p) {
out.name = kind === "qkey" ? "parameter name"
: (kind === "qval" ? "parameter value" : "parameter");
out.value = kind === "qkey" ? p.k
: (kind === "qval" ? p.v : (p.noEq ? p.k : p.k + "=" + p.v));
out.body = "One instruction for the page, written as name=value. What it means is entirely up to the " +
"page — search words, a page number, a sort order, a filter. Edit it in the table and the address " +
"rebuilds itself as you type.";
out.rows.push(["name", p.k === "" ? "(empty)" : p.k]);
out.rows.push(["value as written", p.noEq ? "(no value at all)" : (p.v === "" ? "(empty)" : p.v)]);
var dv = decodeQ(p.v);
if (!p.noEq && dv !== p.v) out.rows.push(["really says", dv]);
if (isTracker(p.k)) {
out.rows.push(["what it is for", "telling the site who sent you — the page almost always works without it"]);
}
return out;
}
}
out.value = P.query;
out.body = "Extra instructions for the page: everything after the ? split on every &. Each piece is a name " +
"and a value. Order rarely matters, repeats are allowed, and a server quietly ignores anything it does not " +
"recognise.";
out.rows.push(["pairs", nf(P.params.length)]);
var tr = state.params.filter(function (x) { return x.on && isTracker(x.k); }).length;
if (tr) out.rows.push(["of those, tracking", nf(tr)]);
return out;
}
out.value = P.fragment;
out.body = "Where to jump to once the page has arrived — usually the id of a heading. This is the one " +
"part that never leaves your browser: everything after the # is cut off before the request goes out, so the " +
"server has no idea it exists.";
var d2 = decodePath(P.fragment);
if (d2 !== P.fragment) out.rows.push(["really says", d2]);
out.rows.push(["sent to the server", "no — never"]);
return out;
}
function drawExplain(meta) {
clear(exBox);
if (!meta) {
exBox.style.setProperty("--exc", "var(--muted)");
var h0 = el("div", "exhead");
h0.appendChild(el("span", "exname", "pick a piece"));
exBox.appendChild(h0);
exBox.appendChild(el("p", "exbody",
"Hover, tap, or arrow along any coloured piece of the address above and its job shows up here."));
return;
}
var info = explain(meta);
exBox.style.setProperty("--exc", "var(" + FAMS[info.fam].css + ")");
var head = el("div", "exhead");
head.appendChild(el("span", "exname", info.name));
head.appendChild(el("span", "exval", info.value === "" ? "(empty)" : info.value));
exBox.appendChild(head);
exBox.appendChild(el("p", "exbody", info.body));
if (info.rows.length) {
var rows = el("div", "exrows");
info.rows.forEach(function (r) {
var row = el("div", "exrow");
row.appendChild(el("div", "rk", r[0]));
row.appendChild(el("div", "rv", r[1]));
rows.appendChild(row);
});
exBox.appendChild(rows);
}
if (info.extra) {
var wrap = el("div", "exrows");
wrap.appendChild(info.extra);
exBox.appendChild(wrap);
}
}
/* A hand-off token. Leaving one piece and arriving at the next fire in an order
the browser does not promise (arrow keys focus the new button before blurring
the old one), so a bare unpreview would wipe the explanation that just arrived.
Every preview bumps the token; an unpreview only clears if nothing took over. */
var hoverTok = 0;
function preview(meta) {
hoverTok++;
state.previewing = true;
state.hover = meta;
drawExplain(meta);
spotlight(meta);
}
function unpreview() {
var mine = hoverTok;
window.setTimeout(function () {
if (mine !== hoverTok) return;
state.previewing = false;
state.hover = null;
spotlight(null);
drawExplain(state.sel);
}, 0);
}
function select(meta) {
var hadFocus = ribbon.contains(document.activeElement);
state.sel = meta;
renderRibbon();
renderLegend();
renderParts();
preview(meta);
if (hadFocus) {
var b = ribbon.querySelector("button.seg.sel");
if (b) { b.tabIndex = 0; b.focus(); }
}
if (meta.pi !== undefined) flashRow(meta.pi);
}
/* ---------------- parts list ---------------- */
function renderParts() {
var box = $("parts"), P = state.P;
clear(box);
var defs = [
{ fam: "scheme", val: P.scheme, gone: "not given — a browser would fill in https" },
{ fam: "user", val: P.hasUser ? (P.user + (P.hasPass ? ":" + P.pass : "")) : "", gone: "none, which is how it should be" },
{ fam: "host", val: P.host, gone: "none — this address points at nothing remote" },
{ fam: "port", val: P.hasPort ? P.port : "", gone: DEFAULT_PORTS[P.scheme] ? "not given — " + DEFAULT_PORTS[P.scheme] + " is assumed" : "not given" },
{ fam: "path", val: P.path, gone: "empty — the site's front door" },
{ fam: "query", val: P.hasQuery ? P.query : "", gone: "no instructions attached" },
{ fam: "frag", val: P.hasFragment ? P.fragment : "", gone: "no jump target" }
];
defs.forEach(function (d) {
var has = d.val !== "";
var b = el("button", "part");
b.type = "button";
b.style.setProperty("--pc", "var(" + FAMS[d.fam].css + ")");
b.appendChild(el("span", "pn", FAMS[d.fam].label));
var v = el("span", "pval");
if (has) {
v.textContent = state.opts.decode
? (d.fam === "query"
? d.val.split("&").map(decodeQ).join(" · ")
: decodePath(d.val))
: d.val;
} else {
v.textContent = d.gone;
}
b.appendChild(v);
if (!has) {
b.disabled = true;
} else {
var meta = { kind: "fam", fam: d.fam };
b.setAttribute("aria-pressed", String(!!(state.sel && state.sel.kind === "fam" && state.sel.fam === d.fam)));
b.addEventListener("mouseenter", function () { preview(meta); });
b.addEventListener("mouseleave", unpreview);
b.addEventListener("focus", function () { preview(meta); });
b.addEventListener("blur", unpreview);
b.addEventListener("click", function () { select(meta); });
}
box.appendChild(b);
});
}
/* ---------------- parameter table ---------------- */
function renderTable() {
var box = $("ptable");
clear(box);
if (!state.params.length) {
box.appendChild(el("div", "empty", "no parameters — add one and a ? appears in the address"));
updateParamButtons();
return;
}
var head = el("div", "phead");
head.appendChild(el("span", null, "on"));
head.appendChild(el("span", null, "name"));
head.appendChild(el("span", null, "value"));
head.appendChild(el("span", null, ""));
box.appendChild(head);
state.params.forEach(function (p, i) {
var row = el("div", "prow" + (p.on ? "" : " off"));
row.setAttribute("data-i", String(i));
var rowMeta = { kind: "qpair", pi: i };
row.addEventListener("focusin", function () { preview(rowMeta); });
row.addEventListener("focusout", function () { unpreview(); });
row.addEventListener("mouseenter", function () {
if (!row.contains(document.activeElement)) preview(rowMeta);
});
row.addEventListener("mouseleave", function () {
if (!row.contains(document.activeElement)) unpreview();
});
var cb = document.createElement("input");
cb.type = "checkbox";
cb.checked = p.on;
cb.setAttribute("aria-label", "include " + (p.k || "this parameter") + " in the address");
cb.addEventListener("change", function () {
p.on = cb.checked;
row.classList.toggle("off", !p.on);
paramsChanged(false);
});
row.appendChild(cb);
var kIn = document.createElement("input");
kIn.type = "text";
kIn.className = "pk";
kIn.spellcheck = false;
kIn.value = state.opts.decode ? decodeQ(p.k) : p.k;
kIn.setAttribute("aria-label", "parameter name");
kIn.addEventListener("input", function () {
p.k = state.opts.decode ? encodeTyped(kIn.value) : guardRaw(kIn, true);
paramsChanged(false);
updateWire(row, p);
});
row.appendChild(kIn);
var vIn = document.createElement("input");
vIn.type = "text";
vIn.className = "pv";
vIn.spellcheck = false;
vIn.value = p.noEq ? "" : (state.opts.decode ? decodeQ(p.v) : p.v);
if (p.noEq) vIn.placeholder = "(no value)";
vIn.setAttribute("aria-label", "value of " + (p.k || "this parameter"));
vIn.addEventListener("input", function () {
if (vIn.value !== "") p.noEq = false;
p.v = state.opts.decode ? encodeTyped(vIn.value) : guardRaw(vIn, false);
paramsChanged(false);
updateWire(row, p);
});
row.appendChild(vIn);
var x = el("button", "xbtn", "✕");
x.type = "button";
x.setAttribute("aria-label", "remove " + (p.k || "this parameter"));
x.addEventListener("click", function () {
var at = state.params.indexOf(p);
if (at !== -1) state.params.splice(at, 1);
paramsChanged(true);
$("addParam").focus();
});
row.appendChild(x);
row.appendChild(el("div", "pwire"));
box.appendChild(row);
updateWire(row, p);
});
updateParamButtons();
}
function updateWire(row, p) {
var wire = row.querySelector(".pwire");
if (!wire) return;
clear(wire);
var shown = state.opts.decode ? decodeQ(p.v) : p.v;
var other = state.opts.decode ? p.v : decodeQ(p.v);
var wrote = false;
if (!p.noEq && other !== shown) {
wire.appendChild(document.createTextNode(
(state.opts.decode ? "goes out as " : "really says ") + (p.k ? p.k + "=" : "") + other));
wrote = true;
}
if (isTracker(p.k)) {
if (wrote) wire.appendChild(document.createTextNode(" · "));
wire.appendChild(el("b", null, "tracking"));
}
}
function updateParamButtons() {
var tr = state.params.filter(function (p) { return p.on && isTracker(p.k); }).length;
var off = state.params.filter(function (p) { return !p.on; }).length;
var on = state.params.length - off;
var d = $("dropTrack");
d.disabled = tr === 0;
d.textContent = tr ? "drop " + tr + " tracking parameter" + (tr > 1 ? "s" : "") : "no tracking to drop";
$("allOn").disabled = off === 0;
$("qStatus").textContent = state.params.length
? on + " of " + state.params.length + " in the address" : "none";
var delta = state.baseLen - state.url.length;
$("qNote").textContent = (off > 0 && delta > 0)
? "With " + off + " parameter" + (off > 1 ? "s" : "") + " switched off the address is " + delta +
" characters shorter than the one you started with — untick rather than delete and you can always put it back."
: "Hover or tab into a row and its pair lights up in the address above. Type in any cell and the address rebuilds on every keystroke; untick a row to see the link without it.";
}
function flashRow(pi) {
var row = document.querySelector('.prow[data-i="' + pi + '"]');
if (!row) return;
row.classList.add("hit");
if (row.scrollIntoView) {
row.scrollIntoView({ block: "nearest", behavior: reduceMotion ? "auto" : "smooth" });
}
window.setTimeout(function () { row.classList.remove("hit"); }, 1100);
}
/* ---------------- percent-encoding inspector ---------------- */
function escapeReport(s) {
var map = Object.create(null), order = [], total = 0, broken = 0;
var re = /(?:%[0-9A-Fa-f]{2})+/g, m;
function add(code, ch) {
if (!map[code]) { map[code] = { code: code, ch: ch, n: 0 }; order.push(code); }
map[code].n++;
}
while ((m = re.exec(s)) !== null) {
var run = m[0];
total += run.length / 3;
var dec = null;
try { dec = decodeURIComponent(run); } catch (e) { dec = null; }
if (dec === null) { broken++; add(run.toUpperCase(), null); continue; }
Array.from(dec).forEach(function (ch) { add(pctBytes(ch), ch); });
}
var list = order.map(function (c) { return map[c]; });
list.sort(function (a, b) { return b.n - a.n || (a.code < b.code ? -1 : 1); });
return { list: list, total: total, broken: broken };
}
function charWord(ch) {
if (ch === null) return "not valid — a % that leads nowhere";
if (CHAR_NAMES[ch]) return CHAR_NAMES[ch];
if (NON_ASCII.test(ch)) {
var cp = ch.codePointAt(0);
if (cp >= 0x1F000) return "an emoji";
if ((cp >= 0x2000 && cp <= 0x2BFF) || (cp >= 0x3000 && cp <= 0x303F)) return "a symbol";
return "a letter from outside plain English";
}
return "the character " + ch;
}
function renderEncodings() {
var box = $("encs"), note = $("encNote"), count = $("encCount");
clear(box);
var rep = escapeReport(state.url);
var plusCount = state.P.hasQuery ? state.P.query.split("+").length - 1 : 0;
count.textContent = rep.total
? nf(rep.total) + " escape" + (rep.total > 1 ? "s" : "") + " · " + rep.list.length + " distinct"
: "none";
if (!rep.list.length && !plusCount) {
box.appendChild(el("div", "empty", "nothing hidden — every character here is written plainly"));
note.textContent = "Only a small set of characters is safe in an address. Everything else gets swapped for a " +
"% and a number, which is why links to pages with spaces or accents look so noisy.";
return;
}
var wrap = el("div", "encs");
rep.list.slice(0, 12).forEach(function (r) {
var row = el("div", "enc");
row.appendChild(el("span", "e", r.code));
var ghost = r.ch === null || r.ch === " " || r.ch === "\n" || r.ch === "\r" || r.ch === "\t";
var c = el("span", "c" + (ghost ? " ghost" : ""));
c.textContent = r.ch === null ? "?" : (r.ch === " " ? "␣" : (r.ch === "\n" || r.ch === "\r" ? "⏎" : (r.ch === "\t" ? "⇥" : r.ch)));
row.appendChild(c);
row.appendChild(el("span", "w", charWord(r.ch)));
row.appendChild(el("span", "n", "×" + r.n));
wrap.appendChild(row);
});
if (plusCount) {
var prow = el("div", "enc");
prow.appendChild(el("span", "e", "+"));
prow.appendChild(el("span", "c ghost", "␣"));
prow.appendChild(el("span", "w", "a space — the older shorthand, and only inside the query"));
prow.appendChild(el("span", "n", "×" + plusCount));
wrap.appendChild(prow);
}
box.appendChild(wrap);
var extra = [];
if (rep.list.length > 12) extra.push((rep.list.length - 12) + " more kinds are not shown.");
if (/%25[0-9A-Fa-f]{2}/.test(state.url)) {
extra.push("There is a %25 in here, which is an encoded % — a sign something was encoded twice.");
}
if (rep.broken) extra.push("Some % signs are not followed by two valid digits, so they cannot be decoded.");
note.textContent = extra.length ? extra.join(" ")
: (state.opts.decode
? "Every character above had to be disguised to survive the trip. The table on the left is already showing you what they really say."
: "Every character above had to be disguised to survive the trip. Turn on “show decoded” to read the parameters the way a person would.");
}
/* ---------------- validity ---------------- */
function analyse() {
var P = state.P, flags = [], ok = true;
if (state.url === "") return { state: "empty", msg: "nothing to take apart", flags: flags };
if (!P.scheme) flags.push({ t: "no scheme — a browser would guess https://", c: "warn" });
if (/\s/.test(state.url)) { flags.push({ t: "a raw space — it should be %20", c: "bad" }); ok = false; }
if (/%(?![0-9A-Fa-f]{2})/.test(state.url)) {
flags.push({ t: "a % that leads nowhere — a literal percent is %25", c: "bad" });
ok = false;
}
if (P.hasAuthority && P.host === "") { flags.push({ t: "no host after the //", c: "bad" }); ok = false; }
if (P.hasPort && !/^\d+$/.test(P.port)) { flags.push({ t: "the port is not a number", c: "bad" }); ok = false; }
if (P.hasPass) flags.push({ t: "password sitting in plain sight", c: "bad" });
if (P.scheme === "http") flags.push({ t: "http — not encrypted", c: "warn" });
if (P.hasPort && DEFAULT_PORTS[P.scheme] && P.port === DEFAULT_PORTS[P.scheme]) {
flags.push({ t: "port " + P.port + " is the default — it can be left out", c: "warn" });
}
if (/[A-Z]/.test(P.host)) flags.push({ t: "capitals in the host make no difference", c: "warn" });
var tr = state.params.filter(function (p) { return p.on && isTracker(p.k); }).length;
if (tr) flags.push({ t: tr + " tracking parameter" + (tr > 1 ? "s" : ""), c: "warn" });
if (/%25[0-9A-Fa-f]{2}/.test(state.url)) flags.push({ t: "looks encoded twice (%25)", c: "warn" });
if (state.url.length > 400) flags.push({ t: "a very long address", c: "warn" });
var firstBad = null;
flags.forEach(function (f) { if (f.c === "bad" && !firstBad) firstBad = f.t; });
if (!ok) return { state: "bad", msg: firstBad || "this will not survive as a link", flags: flags };
if (!P.scheme) {
return {
state: "warn",
msg: "no scheme yet · " + countParts() + " of 7 parts present",
flags: flags
};
}
var risky = flags.filter(function (f) { return f.c === "bad"; }).length;
if (risky) {
return {
state: "warn",
msg: countParts() + " of 7 parts present · " +
(risky === 1 ? "one thing to be uneasy about" : risky + " things to be uneasy about"),
flags: flags
};
}
return { state: "ok", msg: "reads cleanly · " + countParts() + " of 7 parts present", flags: flags };
}
/* ---------------- render ---------------- */
function renderAll(withTable) {
var P = state.P;
if (!selAlive(state.sel)) state.sel = null;
if (!selAlive(state.hover)) { state.hover = null; state.previewing = false; }
renderRibbon();
renderLegend();
renderParts();
if (withTable) renderTable(); else updateParamButtons();
renderEncodings();
$("sLen").textContent = nf(state.url.length);
var sp = $("sParts");
clear(sp);
sp.appendChild(document.createTextNode(String(countParts())));
sp.appendChild(el("small", null, " / 7"));
$("sParams").textContent = nf(P.params.length);
$("sEsc").textContent = nf(escapeReport(state.url).total);
$("sDepth").textContent = nf(pathSteps().length);
var a = analyse();
var v = $("valid");
v.textContent = a.msg;
v.className = "status" + (a.state === "empty" ? "" : " " + a.state);
var fb = $("flags");
clear(fb);
a.flags.forEach(function (f) { fb.appendChild(el("span", "flag " + f.c, f.t)); });
if (state.previewing && state.hover) {
drawExplain(state.hover);
spotlight(state.hover);
} else {
spotlight(null);
drawExplain(state.sel);
}
}
/* ---------------- wiring ---------------- */
urlBox.addEventListener("input", function () {
if (/[\r\n]/.test(urlBox.value)) urlBox.value = urlBox.value.replace(/[\r\n]+/g, "");
adoptURL(urlBox.value);
if (state.sel && state.sel.kind !== "fam") state.sel = { kind: "fam", fam: famOf(state.sel) };
renderAll(true);
});
urlBox.addEventListener("keydown", function (e) { if (e.key === "Enter") e.preventDefault(); });
Array.prototype.forEach.call(document.querySelectorAll("[data-sample]"), function (b) {
b.addEventListener("click", function () {
setURL(SAMPLES[b.getAttribute("data-sample")]);
var msg = $("copyMsg");
msg.textContent = "";
msg.className = "status";
});
});
$("copy").addEventListener("click", function () {
var msg = $("copyMsg");
function done() {
msg.textContent = "copied to the clipboard";
msg.className = "status ok";
window.setTimeout(function () { msg.textContent = ""; msg.className = "status"; }, 1800);
}
function fallback() {
try {
urlBox.focus();
urlBox.setSelectionRange(0, urlBox.value.length);
if (document.execCommand && document.execCommand("copy")) { done(); return; }
} catch (e) { /* fall through */ }
msg.textContent = "selected — press ctrl/cmd+c";
msg.className = "status";
}
if (navigator.clipboard && navigator.clipboard.writeText) {
try { navigator.clipboard.writeText(state.url).then(done, fallback); }
catch (e) { fallback(); }
} else { fallback(); }
});
function toggle(id, key, after) {
var b = $(id);
b.addEventListener("click", function () {
state.opts[key] = !state.opts[key];
b.setAttribute("aria-pressed", String(state.opts[key]));
after();
});
}
function remapParams(fn) {
state.params.forEach(function (p) {
p.k = fn(p.k);
if (!p.noEq) p.v = fn(p.v);
});
paramsChanged(true);
}
toggle("tDecode", "decode", function () { renderTable(); renderParts(); renderEncodings(); });
toggle("tPlus", "plus", function () { remapParams(applyPlus); });
toggle("tLiteral", "literal", function () { remapParams(applyLiteral); });
$("addParam").addEventListener("click", function () {
state.params.push({ k: "sort", v: "newest", noEq: false, on: true });
paramsChanged(true);
var keys = document.querySelectorAll(".prow .pk");
if (keys.length) { keys[keys.length - 1].focus(); keys[keys.length - 1].select(); }
});
$("dropTrack").addEventListener("click", function () {
state.params.forEach(function (p) { if (isTracker(p.k)) p.on = false; });
paramsChanged(true);
});
$("allOn").addEventListener("click", function () {
state.params.forEach(function (p) { p.on = true; });
paramsChanged(true);
});
/* ---------------- platform theme ---------------- */
window.addEventListener("message", function (e) {
var d = e.data;
if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
document.documentElement.setAttribute("data-theme", d.theme);
}
});
/* ---------------- boot ---------------- */
setURL(SAMPLES.shop, { kind: "fam", fam: "host" });
})();
</script>
</body>
</html>
source-visible by construction · nothing is published here without its code
Comments
0 totalNo comments yet. If you ran it, say what happened.