A URL looks like one opaque string, but it's really a handful of labelled fields packed together by a strict grammar: scheme://user:pass@host:port/path?query#hash. I built an inspector that takes any URL apart into editable fields, turns the query into a key/value table, and rebuilds the whole thing from scratch when you edit any piece — bidirectional, correctly encoded, no library. The discipline that made it small was knowing what not to hand-roll.
Don't reinvent the grammar — use the platform's URL object
The URL grammar is a minefield: userinfo boundaries, IPv6 [::1] hosts, default-port stripping, punycode, relative resolution. Every browser already implements the WHATWG spec correctly, so I lean on it. new URL(str) throws on anything that isn't a valid absolute URL, and otherwise hands you every part as a property. This one call is my parser and my validator — for everything except the query.
function parseInput(str){
const u = new URL(str); // throws if not a valid absolute URL
return { protocol:u.protocol, username:u.username, password:u.password,
hostname:u.hostname, port:u.port, pathname:u.pathname,
hash:u.hash, pairs:parseQuery(u.search) }; // query is ours
}
The query is the one part worth owning
The reason to hand-roll the query is that it is not a map — it's an ordered list of pairs. ?tag=a&tag=b is perfectly legal, and most servers read the repeat as an array. Split on &, then split each piece on the first = (values can contain more), and keep the result as an array so repeated keys survive. Group into {tag:["a","b"]} too early and you silently drop the duplicates.
function parseQuery(search){
let s = (search || "").replace(/^\?/, "");
if (s === "") return [];
return s.split("&").filter(Boolean).map(pair => {
const eq = pair.indexOf("="); // first '=' only
if (eq === -1) return { key: dec(pair), value: "", hasEq: false };
return { key: dec(pair.slice(0,eq)), value: dec(pair.slice(eq+1)), hasEq: true };
});
}
Decoding: + is a space, %XX is a byte, never throw
In a query, + means space — a form-encoding leftover — so replace it before decodeURIComponent, which turns each %XX back into its byte and reassembles UTF-8. The trap: a malformed escape like %zz makes decodeURIComponent throw, so I wrap it and fall back to the raw text rather than crash the whole parse on one bad param.
function dec(s){
try { return decodeURIComponent(s.replace(/\+/g, "%20")); }
catch (e) { return s; } // "Caf%C3%A9" -> "Café"; "1+2" -> "1 2"
}
Rebuild: encode each component on its own
Going the other way, encode the key and value separately with encodeURIComponent, then join with = and &. Per-component encoding is the whole trick — a & inside a value becomes %26 and can never be mistaken for a separator. Then reassemble in the one order the grammar allows, giving // only to special schemes or any URL with a host, so mailto: stays mailto:foo@bar without a bogus slash-slash.
Validate the rebuild by feeding it back
The cheapest possible validator is to parse what you just built. If new URL(assembled) succeeds, the edits produced a legal URL and u.href is the normalized form — default ports dropped, host lowercased — which I show alongside the raw rebuild so you see what the browser would actually send. If it throws, surface the message.
It's worth also showing the grouped view, because that's how frameworks hand the query to your code — the first time you see a key store its value, and on a repeat promote it to an array and push, exactly what URLSearchParams.getAll(key) returns for you.
function grouped(pairs){
const out = {};
for (const { key, value } of pairs){
if (!(key in out)) out[key] = value; // first: scalar
else if (Array.isArray(out[key])) out[key].push(value);
else out[key] = [out[key], value]; // 2nd: array
}
return out; // tag=food&tag=drink&page=2 -> { tag:["food","drink"], page:"2" }
}
The two directions share one state object. Typing in the URL box parses and repaints every field; editing a field or a cell reads the fields back and re-runs the rebuild. What keeps it loop-free is a quirk of the DOM: setting an input's .value in code never fires its input event, so "parse → fill fields" and "edit field → rebuild" can't ping-pong. Well under 150 lines of real logic. Paste a URL, flip a port, rename a param, and watch it rebuild:
Top comments (0)