// Pure href/target core behind , and . Split out so the two rules —
// a scheme a browser will execute is never emitted, and "external" is decided on the value that
// SURVIVES that check — are testable without a renderer. `javascript:` used to pass through
// `href={props.href}` untouched AND read as internal (the test was `/^https?:\/\//`), so it also
// lost `rel="noopener"`.
import { safeUrl } from '@ultimat3/core';
export interface LinkTarget {
/** `undefined` means emit no `href` at all: an inert anchor beats a live unchecked one. */
readonly href: string | undefined;
readonly external: boolean;
}
/**
* `external` is `true` only for a URL that both survived the scheme check and names another
* origin's protocol — so a refused value can never acquire `target="_blank"` on the way out.
*/
export function linkTarget(href: string, declaredExternal?: boolean | undefined): LinkTarget {
const safe = safeUrl(href, 'href');
if (safe === null) return { href: undefined, external: false };
return { href: safe, external: declaredExternal === true || namesAnotherOrigin(safe) };
}
/** An origin no real link can name: whatever resolves AWAY from it carried its own authority. */
const SENTINEL_ORIGIN = 'http://link-target.invalid';
/**
* Decided the way a browser resolves the href, not by its spelling: `//cdn.test`, `HTTPS://…` and
* a leading space all leave the page's origin, and a regex over the raw string read each of them
* as internal — no `noopener`, no external hint. A value the parser refuses is treated as external,
* because the hardening is the safe side of a wrong guess.
*/
function namesAnotherOrigin(href: string): boolean {
try {
const url = new URL(href, `${SENTINEL_ORIGIN}/`);
return (
(url.protocol === 'http:' || url.protocol === 'https:') && url.origin !== SENTINEL_ORIGIN
);
} catch {
return true;
}
}
/**
* The `rel` an anchor carries. A relation is ADDED to the external hardening, never traded for
* it — which is why `` takes the relation as a closed pair and not a free `rel` string a
* caller could use to write the hardening away.
*/
export function linkRel(
external: boolean,
relation?: 'next' | 'prev' | undefined,
): string | undefined {
if (!external) return relation;
return relation === undefined ? 'noopener noreferrer' : `${relation} noopener noreferrer`;
}