-- SPDX-FileCopyrightText: © 2022—2026 Vladimir Zorin -- SPDX-License-Identifier: LicenseRef-OWL-1.0-or-later -- Licensed under OWL v1.0+. See LICENSE. --[==[ URL parsing, building, relative resolution, and percent-encoding per RFC 3986. Provides `parse` to decompose a URL into its components, `build` to reassemble a component table back into a string, `absolute` to resolve relative URLs against a base, and `escape`/`unescape` for percent-encoding and decoding. ]==] -- ========================================================================= -- Private helpers -- ========================================================================= local MARKER = string.char(1) local remove_dot_components = function(path) repeat local prev = path path = path:gsub("//", "/" .. MARKER .. "/", 1) until path == prev repeat local prev = path path = path:gsub("/%./", "/", 1) until path == prev repeat local prev = path path = path:gsub("[^/]+/%.%./([^/]+)", "%1", 1) until path == prev path = path:gsub("[^/]+/%.%./*$", "") path = path:gsub("/%.%.$", "/") path = path:gsub("/%.$", "/") path = path:gsub("^/%.%./", "/") path = path:gsub(MARKER, "") return path end local absolute_path = function(base_path, relative_path) if relative_path:sub(1, 1) == "/" then return remove_dot_components(relative_path) end local base_dir = base_path:gsub("[^/]*$", "") if not base_dir:find("/$") then base_dir = base_dir .. "/" end return remove_dot_components(base_dir .. relative_path) end -- ========================================================================= -- Percent-encoding -- ========================================================================= ---! Percent-encode a string per RFC 3986 ---@ escape(`s`{.str}) -> `encoded`{.str} --[===[ Replaces every byte that is not in the RFC 3986 unreserved set (`A-Z a-z 0-9 - . _ ~`) with its `%XX` hex-encoded form. This is not `application/x-www-form-urlencoded` encoding (spaces become `%20`, not `+`). ]===] local escape = function(s) return (s:gsub("[^A-Za-z0-9%-%.%_%~]", function(c) return string.format("%%%02X", string.byte(c)) end)) end ---! Percent-decode a string ---@ unescape(`s`{.str}) -> `decoded`{.str} --[===[ Replaces every `%XX` hex-encoded sequence with the corresponding byte. Does not decode `+` as space — that is `application/x-www-form-urlencoded` behavior, not generic percent-decoding. ]===] local unescape = function(s) return (s:gsub("%%(%x%x)", function(hex) return string.char(tonumber(hex, 16)) end)) end -- ========================================================================= -- URL parsing and building -- ========================================================================= ---! Parse a URL into its component parts per RFC 2396 ---@ parse(`url_str`{.str}) -> `parsed`{.tbl .or_nil}, `err`{.str .err} --[===[ Decomposes a URL string into a table with the following fields (all `nil` when absent): - `scheme` — e.g. `"https"` - `authority` — e.g. `"user:pass@host:443"` - `userinfo` — e.g. `"user:pass"` - `user` — e.g. `"user"` - `password` — e.g. `"pass"` - `host` — e.g. `"example.com"` (IPv6 brackets stripped) - `port` — e.g. `"443"` (string, not number) - `path` — e.g. `"/index.html"` - `params` — semicolon-separated parameters (RFC 2396 `;params`) - `query` — e.g. `"key=val&foo=bar"` (without leading `?`) - `fragment` — e.g. `"section1"` (without leading `#`) Returns `nil, "invalid url"` for empty or nil input. ]===] local parse = function(url_str) local parsed = {} if not url_str or url_str == "" then return nil, "invalid url" end url_str = url_str:gsub("^([%w][%w%+%-%.]*)%:", function(s) parsed.scheme = s return "" end) url_str = url_str:gsub("^//([^/]*)", function(n) parsed.authority = n return "" end) url_str = url_str:gsub("#(.*)$", function(f) parsed.fragment = f return "" end) url_str = url_str:gsub("%?(.*)", function(q) parsed.query = q return "" end) url_str = url_str:gsub("%;(.*)", function(p) parsed.params = p return "" end) if url_str ~= "" then parsed.path = url_str end local authority = parsed.authority if not authority then return parsed end authority = authority:gsub("^([^@]*)@", function(u) parsed.userinfo = u return "" end) authority = authority:gsub(":([^:%]]*)$", function(p) parsed.port = p return "" end) if authority ~= "" then parsed.host = authority:match("^%[(.+)%]$") or authority end local userinfo = parsed.userinfo if not userinfo then return parsed end userinfo = userinfo:gsub(":([^:]*)$", function(p) parsed.password = p return "" end) parsed.user = userinfo return parsed end ---! Rebuild a URL string from its parsed components ---@ build(`parsed`{.tbl}) -> `url_str`{.str} --[===[ Accepts a table with the same fields returned by `parse()` and reassembles them into a URL string. Fields that are `nil` are omitted. When both `host` and `authority` are present, `host` (plus `port`, `user`, `password`) takes precedence for rebuilding the authority portion. IPv6 hosts are automatically wrapped in brackets. ]===] local build = function(parsed) local url_str = parsed.path or "" if parsed.params then url_str = url_str .. ";" .. parsed.params end if parsed.query then url_str = url_str .. "?" .. parsed.query end local authority = parsed.authority if parsed.host then authority = parsed.host if authority:find(":") then authority = "[" .. authority .. "]" end if parsed.port then authority = authority .. ":" .. tostring(parsed.port) end local userinfo = parsed.userinfo if parsed.user then userinfo = parsed.user if parsed.password then userinfo = userinfo .. ":" .. parsed.password end end if userinfo then authority = userinfo .. "@" .. authority end end if authority then url_str = "//" .. authority .. url_str end if parsed.scheme then url_str = parsed.scheme .. ":" .. url_str end if parsed.fragment then url_str = url_str .. "#" .. parsed.fragment end return url_str end -- ========================================================================= -- Relative URL resolution -- ========================================================================= ---! Resolve a relative URL against a base URL per RFC 2396 section 5.2 ---@ absolute(`base_url`{.str}, `relative_url`{.str}) -> `resolved`{.str} --[===[ If `relative_url` has a scheme, it is returned as-is (already absolute). Otherwise, missing components are inherited from `base_url` following the RFC priority: scheme, then authority, then path (merged via dot-component normalization), then params, then query. Fragment is always from the relative URL. `base_url` may also be a parsed table. ]===] local absolute = function(base_url, relative_url) local base_parsed if type(base_url) == "table" then base_parsed = base_url base_url = build(base_parsed) else base_parsed = parse(base_url) end local relative_parsed = parse(relative_url) local result if not base_parsed then result = relative_url elseif not relative_parsed then result = base_url elseif relative_parsed.scheme then result = relative_url else relative_parsed.scheme = base_parsed.scheme if not relative_parsed.authority then relative_parsed.authority = base_parsed.authority if not relative_parsed.path then relative_parsed.path = base_parsed.path if not relative_parsed.params then relative_parsed.params = base_parsed.params if not relative_parsed.query then relative_parsed.query = base_parsed.query end end else relative_parsed.path = absolute_path(base_parsed.path or "", relative_parsed.path) end end result = build(relative_parsed) end return remove_dot_components(result) end return { parse = parse, build = build, absolute = absolute, escape = escape, unescape = unescape, }