Sitelet https://github.com/unjs/ufo/pull/353
Skip to content

fix(parseURL): support protocols without double slashes - #353

Open
DucMinhNe wants to merge 1 commit into
unjs:mainfrom
DucMinhNe:fix/parse-url-protocols-without-slashes
Open

DucMinhNe wants to merge 1 commit into
unjs:mainfrom
DucMinhNe:fix/parse-url-protocols-without-slashes

Conversation

@DucMinhNe

@DucMinhNe DucMinhNe commented Jun 15, 2026 •

Copy link
Copy Markdown

Problem

parseurl() silently drops all data for protocols that have an opaque path (no // authority), e.g. mailto:, tel:, urn:. Every field comes back empty and the value can't be reconstructed:

import { parseURL, stringifyParsedURL } from "ufo";

parseURL("mailto:foo@bar.com");
// { protocol: "", auth: "", host: "", pathname: "", search: "", hash: "" }  ← everything lost

stringifyParsedURL(parseURL("mailto:foo@bar.com"));
// "//"   ← round-trip produces a stray `//` and total data loss

The same happens for tel:+123456789, urn:isbn:..., and any other scheme that uses an opaque path rather than a //host authority. hasProtocol() already reports these as having a protocol, but the main parser regex requires //, so the match fails and an all-empty result is returned.

Fix

This mirrors the existing handling for the hard-coded data: / blob: / javascript: / vbscript: schemes (added in #158), but generalises it so any protocol without a // authority preserves its opaque path:

  • parseURL gets a fallback branch (after the hasProtocol check) that captures protocol: + the remaining opaque path when it is not followed by //. URLs that do use // (http://, file://, protocol-relative //host) are untouched by the negative lookahead and still flow through the existing host parser.
  • stringifyParsedURL only emits the // authority separator when there is an authority to separate (a host/auth), for protocol-relative URLs, or for file: (which canonically keeps its empty // authority). Opaque-path protocols no longer gain a spurious //. This is consistent with maintainer precedent in fix(parseURL): handle data: and blob protocols #159 / Unexpected behavior when passing a data: URL into parseurl() #158 / Migrate to native URL  #208.

After the fix:

parseURL("mailto:foo@bar.com");
// { protocol: "mailto:", auth: "", host: "", pathname: "foo@bar.com", search: "", hash: "" }

stringifyParsedURL(parseURL("mailto:foo@bar.com")); // "mailto:foo@bar.com"
stringifyParsedURL(parseURL("tel:+123456789"));     // "tel:+123456789"
stringifyParsedURL(parseURL("urn:isbn:9780136091813")); // "urn:isbn:9780136091813"

This complements #352 (which fixes stringifyParsedURL for the four hard-coded opaque schemes but leaves the parseURL side broken for mailto:/tel:/urn:).

Notes

  • localhost:3000-style inputs are unaffected: with no protocol scheme recognised before, they continue to parse as before. The behaviour here matches the WHATWG URL model, where a scheme with an opaque path keeps that path verbatim.
  • file:// round-tripping is preserved exactly (its empty // authority is kept).

Tests

Added parse + round-trip cases for mailto:, tel: and urn: in test/parse.test.ts and test/utilities.test.ts. Full suite, typecheck and lint all pass (497 tests green).

Summary by CodeRabbit

  • New Features
    • Added comprehensive support for special protocol schemes including mailto:, tel:, urn:, and data: URL formats.
    • These previously problematic non-standard URL types now parse correctly and preserve their original structure.
    • Enhanced compatibility throughout the application ensures email links, phone numbers, and URI-based schemes function reliably.

@coderabbitai

coderabbitai Bot commented Jun 15, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

parseURL gains a new branch detecting opaque-path protocols (e.g., mailto:, tel:, urn:) that lack a // authority segment, routing their remainder through parsePath and returning empty auth/host. stringifyParsedURL is updated to emit // only when a protocol-relative URL or an authority component is present, preventing spurious // in round-trips. Tests are added for all three new schemes.

Changes

Opaque-path protocol support

Layer / File(s) Summary
parseURL opaque branch and stringifyParsedURL // fix
src/parse.ts
parseURL detects schemes not followed by //, parses the remainder via parsePath, and returns protocol + pathname/search/hash with auth and host empty. stringifyParsedURL replaces its proto computation to only emit // when a protocol-relative URL or an authority (host, auth, or file:) is present.
Opaque-protocol parse and stringify tests
test/parse.test.ts, test/utilities.test.ts
Adds three parseURL test vectors for mailto:foo@bar.com, tel:+123456789, and urn:isbn:9780136091813 asserting protocol normalization and pathname placement; adds matching stringifyParsedURL cases asserting correct serialized output.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Poem

🐇 Hop hop, no // for mailto:!
The rabbit parsed each scheme with care,
tel: and urn: float through the air,
Auth and host stay empty there,
Round-trips round-trip, oh what flair! ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and accurately summarizes the main change: adding support for protocols without double slashes (opaque-path protocols like mailto:, tel:, urn:).
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/parse.ts`:
- Around line 207-220: The hasAuthority logic on line 218 only checks for actual
host, auth, or file: protocol, but doesn't preserve URLs with explicit empty
authority sections (like `https:///x`). The parsed object from parseurl() must
contain a property indicating whether an authority section was explicitly
present in the original URL. Update the hasAuthority condition to also check for
this property in the parsed object, so that URLs with explicit `//` separators
(even with empty authority) maintain their structure during the round-trip parse
and stringify operations.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 15738bd2-1b74-4851-9d4e-a58f2bbe7e11

📥 Commits

Reviewing files that changed from the base of the PR and between f06c800 and 59ad118.

📒 Files selected for processing (3)
  • src/parse.ts
  • test/parse.test.ts
  • test/utilities.test.ts

Comment thread src/parse.ts
Comment on lines +207 to +220
// Only emit the `//` authority separator when there is an authority to
// separate (a host/auth) or for protocol-relative URLs. Opaque-path
// protocols such as `mailto:`, `tel:` or `data:` must not gain a spurious
// `//`, otherwise the parse↔stringify round-trip corrupts the URL.
let proto = "";
if (parsed[protocolRelative]) {
proto = (parsed.protocol || "") + "//";
} else if (parsed.protocol) {
// `file:` URLs canonically keep their (possibly empty) `//` authority,
// while opaque-path protocols (`mailto:`, `tel:`, `data:`, ...) without a
// host/auth must not gain a spurious `//`.
const hasAuthority = host || auth || parsed.protocol === "file:";
proto = hasAuthority ? parsed.protocol + "//" : parsed.protocol;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve explicit empty authority during re-stringify.

Line 218 infers authority only from host, auth, or file:. For inputs like https:///x, parseurl() keeps protocol: "https:", host: "", pathname: "/x", but stringifyParsedurl(/sitelet?url=https%3A%2F%2Fgithub.com%2Funjs%2Fufo%2Fpull%2FparseURL%28...)) becomes https:/x (one slash lost). That breaks round-trip structure for URLs that explicitly contained // with an empty authority.

💡 Proposed fix
+const hasURLAuthority = Symbol.for("ufo:hasAuthority");
+
 export interface ParsedURL {
   protocol?: string;
   host?: string;
   auth?: string;
   href?: string;
   pathname: string;
   hash: string;
   search: string;
   [protocolRelative]?: boolean;
+  [hasURLAuthority]?: boolean;
 }
   return {
     protocol: protocol.toLowerCase(),
     auth: auth ? auth.slice(0, Math.max(0, auth.length - 1)) : "",
     host,
     pathname,
     search,
     hash,
     [protocolRelative]: !protocol,
+    [hasURLAuthority]: true,
   };
-    const hasAuthority = host || auth || parsed.protocol === "file:";
+    const hasAuthority =
+      parsed[hasURLAuthority] || host || auth || parsed.protocol === "file:";
     proto = hasAuthority ? parsed.protocol + "//" : parsed.protocol;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/parse.ts` around lines 207 - 220, The hasAuthority logic on line 218 only
checks for actual host, auth, or file: protocol, but doesn't preserve URLs with
explicit empty authority sections (like `https:///x`). The parsed object from
parseurl() must contain a property indicating whether an authority section was
explicitly present in the original URL. Update the hasAuthority condition to
also check for this property in the parsed object, so that URLs with explicit
`//` separators (even with empty authority) maintain their structure during the
round-trip parse and stringify operations.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant