Add a library for RIP 4 rad: URIs

The desktop and radicle-explorer render the same comment text, so they
have to agree on what a reference means.
This commit is contained in:
Rūdolfs Ošiņš
2026-10-05 17:29:02 +02:00
parent 8f21eab664
commit 990e51c38e
2 changed files with 917 additions and 0 deletions
+428
View File
@@ -0,0 +1,428 @@
import bs58 from "bs58";
export const issueType = "xyz.radicle.issue";
export const patchType = "xyz.radicle.patch";
export const releaseType = "dev.radicle.artifact";
const explorerCobPaths: Record<string, string> = {
[issueType]: "issues",
[patchType]: "patches",
[releaseType]: "releases",
};
export interface RadAuthority {
node: string;
address?: string;
}
export type RadResource =
| { type: "commit"; ref: string }
| { type: "tag"; ref: string }
| { type: "tree"; oid: string }
| { type: "blob"; oid: string }
| { type: "cob"; typeName: string; oid?: string };
export interface QueryParam {
param: string;
value?: string;
}
export interface RadUri {
authority?: RadAuthority;
repo: string;
namespace?: string;
resource?: RadResource;
query?: QueryParam[];
fragment?: string;
}
export type RadReference =
{ type: "uri"; uri: RadUri } | { type: "did"; node: string };
const base58 = "[1-9A-HJ-NP-Za-km-z]";
const repoIdPattern = new RegExp(`^z${base58}{27,28}$`);
const nodeIdPattern = new RegExp(`^z6Mk${base58}{44}$`);
const oidPattern = /^[0-9a-fA-F]{40}$/;
const gitRefPattern = /^[A-Za-z0-9._~-]+(?:\/[A-Za-z0-9._~-]+)*$/;
const cobTypePattern =
/^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*(?:\.[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*)+$/;
const hostPattern =
/^(?:\[[0-9A-Fa-f:.vV]+\]|(?:[A-Za-z0-9._~!$&'()*+,;=-]|%[0-9A-Fa-f]{2})*)$/;
const portPattern = /^[0-9]*$/;
const queryOrFragmentPattern =
/^(?:[A-Za-z0-9._~!$&'()*+,;=:@/?-]|%[0-9A-Fa-f]{2})*$/;
function decodesTo(id: string, check: (bytes: Uint8Array) => boolean) {
try {
return check(bs58.decode(id.slice(1)));
} catch {
return false;
}
}
export function isRepoId(id: string): boolean {
return (
repoIdPattern.test(id) && decodesTo(id, bytes => bytes.byteLength === 20)
);
}
export function isNodeId(id: string): boolean {
return (
nodeIdPattern.test(id) &&
decodesTo(
id,
bytes =>
bytes.byteLength === 34 && bytes[0] === 0xed && bytes[1] === 0x01,
)
);
}
export function isOid(oid: string): boolean {
return oidPattern.test(oid);
}
function parseAuthority(input: string): RadAuthority | undefined {
const at = input.indexOf("@");
const node = at === -1 ? input : input.slice(0, at);
if (!isNodeId(node)) return undefined;
if (at === -1) return { node };
const address = input.slice(at + 1);
const colon = address.lastIndexOf(":");
if (colon === -1) return undefined;
if (
!hostPattern.test(address.slice(0, colon)) ||
!portPattern.test(address.slice(colon + 1))
) {
return undefined;
}
return { node, address };
}
function parseResource(segments: string[]): RadResource | undefined | null {
if (segments.length === 0) return undefined;
const [kind, ...rest] = segments;
switch (kind.toLowerCase()) {
case "commit":
case "tag": {
const ref = rest.join("/");
if (!gitRefPattern.test(ref)) return null;
return {
type: kind.toLowerCase() as "commit" | "tag",
ref: isOid(ref) ? ref.toLowerCase() : ref,
};
}
case "tree":
case "blob":
if (rest.length !== 1 || !isOid(rest[0])) return null;
return {
type: kind.toLowerCase() as "tree" | "blob",
oid: rest[0].toLowerCase(),
};
case "cob": {
const [typeName, oid, ...extra] = rest;
if (extra.length > 0 || !typeName || !cobTypePattern.test(typeName)) {
return null;
}
if (oid === undefined) return { type: "cob", typeName };
if (!isOid(oid)) return null;
return { type: "cob", typeName, oid: oid.toLowerCase() };
}
default:
return null;
}
}
function parseQuery(input: string): QueryParam[] {
return input.split("&").map(pair => {
const equals = pair.indexOf("=");
return equals === -1
? { param: pair }
: { param: pair.slice(0, equals), value: pair.slice(equals + 1) };
});
}
export function parseRadUri(input: string): RadUri | undefined {
if (input.slice(0, 4).toLowerCase() !== "rad:") return undefined;
let rest = input.slice(4);
let fragment: string | undefined;
const hash = rest.indexOf("#");
if (hash !== -1) {
fragment = rest.slice(hash + 1);
rest = rest.slice(0, hash);
if (!queryOrFragmentPattern.test(fragment)) return undefined;
}
let query: QueryParam[] | undefined;
const question = rest.indexOf("?");
if (question !== -1) {
const raw = rest.slice(question + 1);
rest = rest.slice(0, question);
if (!queryOrFragmentPattern.test(raw)) return undefined;
query = parseQuery(raw);
}
let authority: RadAuthority | undefined;
let legacy = false;
if (rest.startsWith("//")) {
rest = rest.slice(2);
if (rest.startsWith("/")) {
rest = rest.slice(1);
} else {
const slash = rest.indexOf("/");
const head = slash === -1 ? rest : rest.slice(0, slash);
if (isRepoId(head)) {
legacy = true;
} else {
authority = parseAuthority(head);
if (!authority || slash === -1) return undefined;
rest = rest.slice(slash + 1);
}
}
}
const segments = rest.split("/");
const repo = segments.shift() ?? "";
if (!isRepoId(repo)) return undefined;
let namespace: string | undefined;
if (segments.length > 0 && nodeIdPattern.test(segments[0])) {
namespace = segments.shift();
if (!namespace || !isNodeId(namespace)) return undefined;
}
const resource = parseResource(segments);
if (resource === null) return undefined;
if (legacy && (resource || query || fragment !== undefined)) {
return undefined;
}
return { authority, repo, namespace, resource, query, fragment };
}
function formatResource(resource: RadResource): string {
switch (resource.type) {
case "commit":
case "tag":
return `/${resource.type}/${resource.ref}`;
case "tree":
case "blob":
return `/${resource.type}/${resource.oid}`;
case "cob":
return resource.oid === undefined
? `/cob/${resource.typeName}`
: `/cob/${resource.typeName}/${resource.oid}`;
}
}
export function formatRadUri(
uri: RadUri,
options: { forceAuthority?: boolean } = {},
): string {
let result = "rad:";
if (uri.authority) {
const { node, address } = uri.authority;
result += `//${node}${address === undefined ? "" : `@${address}`}/`;
} else if (options.forceAuthority) {
result += "///";
}
result += uri.repo;
if (uri.namespace) result += `/${uri.namespace}`;
if (uri.resource) result += formatResource(uri.resource);
if (uri.query && uri.query.length > 0) {
result += `?${uri.query
.map(({ param, value }) =>
value === undefined ? param : `${param}=${value}`,
)
.join("&")}`;
}
if (uri.fragment !== undefined) result += `#${uri.fragment}`;
return result;
}
export function parseDid(input: string): string | undefined {
if (!input.startsWith("did:key:")) return undefined;
const node = input.slice(8);
return isNodeId(node) ? node : undefined;
}
export function formatDid(node: string): string {
return `did:key:${node}`;
}
export function parseReference(input: string): RadReference | undefined {
const node = parseDid(input);
if (node) return { type: "did", node };
const uri = parseRadUri(input);
return uri ? { type: "uri", uri } : undefined;
}
export function formatReference(reference: RadReference): string {
return reference.type === "did"
? formatDid(reference.node)
: formatRadUri(reference.uri);
}
function queryPath(uri: RadUri): string | undefined {
return uri.query?.find(
({ param, value }) =>
(param === "path" || param === "tree" || param === "blob") &&
value !== undefined &&
value !== "",
)?.value;
}
function explorerRevision(ref: string): string | undefined {
if (!ref.startsWith("refs/")) return ref;
for (const prefix of ["refs/heads/", "refs/tags/"]) {
if (ref.startsWith(prefix)) return ref.slice(prefix.length);
}
return undefined;
}
function explorerResourcePath(uri: RadUri): string | undefined {
const { resource } = uri;
const remote = uri.namespace ? `/remotes/${uri.namespace}` : "";
if (!resource) return remote;
switch (resource.type) {
case "commit":
case "tag": {
const revision = explorerRevision(resource.ref);
if (revision === undefined) return undefined;
const path = queryPath(uri);
if (path !== undefined) return `${remote}/tree/${revision}/${path}`;
if (resource.type === "commit" && isOid(revision)) {
return `/commits/${revision}`;
}
return `${remote}/tree/${revision}`;
}
case "tree":
case "blob":
return undefined;
case "cob": {
const kind = explorerCobPaths[resource.typeName];
if (!kind) return undefined;
return resource.oid === undefined
? `/${kind}`
: `/${kind}/${resource.oid}`;
}
}
}
export function explorerUrl(
reference: RadReference,
base: string,
host: string,
): string | undefined {
const node = `${base.replace(/\/+$/, "")}/nodes/${host}`;
if (reference.type === "did") {
return `${node}/users/${formatDid(reference.node)}`;
}
const path = explorerResourcePath(reference.uri);
if (path === undefined) return undefined;
return `${node}/rad:${reference.uri.repo}${path}`;
}
function decodeSegment(segment: string): string {
try {
return decodeURIComponent(segment);
} catch {
return segment;
}
}
export function parseExplorerUrl(href: string): RadReference | undefined {
let url: URL;
try {
url = new URL(href);
} catch {
return undefined;
}
if (url.protocol !== "https:" && url.protocol !== "http:") return undefined;
const segments = url.pathname
.split("/")
.filter(segment => segment !== "")
.map(decodeSegment);
const users = segments.indexOf("users");
if (users !== -1 && users === segments.length - 2) {
const node = parseDid(segments[users + 1]);
return node ? { type: "did", node } : undefined;
}
const index = segments.findIndex(segment => segment.startsWith("rad:"));
if (index === -1) return undefined;
const repo = segments[index].slice(4);
if (!isRepoId(repo)) return undefined;
let tail = segments.slice(index + 1);
let namespace: string | undefined;
if (tail[0] === "remotes") {
namespace = tail[1];
if (namespace === undefined || !isNodeId(namespace)) return undefined;
tail = tail.slice(2);
}
const [kind, ...rest] = tail;
if (kind === undefined || (kind === "tree" && rest.length === 0)) {
return { type: "uri", uri: { repo, namespace } };
}
if (kind === "tree") {
const [revision, ...path] = rest;
if (isOid(revision)) {
return {
type: "uri",
uri: {
repo,
namespace,
resource: { type: "commit", ref: revision.toLowerCase() },
query:
path.length > 0
? [{ param: "path", value: path.join("/") }]
: undefined,
},
};
}
if (path.length > 0 || !gitRefPattern.test(revision)) return undefined;
return {
type: "uri",
uri: { repo, namespace, resource: { type: "commit", ref: revision } },
};
}
const [raw, ...extra] = rest;
if ((extra.length > 0 && kind !== "patches") || !raw || !isOid(raw)) {
return undefined;
}
const oid = raw.toLowerCase();
if (kind === "commits") {
return {
type: "uri",
uri: { repo, resource: { type: "commit", ref: oid } },
};
}
const typeName = Object.keys(explorerCobPaths).find(
key => explorerCobPaths[key] === kind,
);
if (!typeName) return undefined;
return {
type: "uri",
uri: { repo, resource: { type: "cob", typeName, oid } },
};
}
+489
View File
@@ -0,0 +1,489 @@
import { describe, expect, test } from "vitest";
import type { RadUri } from "@app/lib/radUri";
import {
explorerUrl,
formatRadUri,
issueType,
parseExplorerUrl,
parseRadUri,
parseReference,
patchType,
releaseType,
} from "@app/lib/radUri";
// The RIP 4 fixture `zk1arLZ…` decodes to 21 bytes, so like the
// `radicle-uri` crate these cases use `z21arLZ…`, one character apart.
const repo00 = "z3gqcJUbbbbbbbbbbbbbbbCSGazv5";
const repo01 = "z21arLZbbbbbbbbbbbbbbb26us45E";
const node01 = "z6MksFbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbS9wzpT";
const node02 = "z6Mku8bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbo9XVAx";
const seed = "seed.example.com:8776";
const oid = "e9cf5263ffffffffffffffffffffffffffffffff";
describe("RIP 4 examples", () => {
const cases: [string, string, RadUri][] = [
["tc00a", `rad:${repo00}`, { repo: repo00 }],
["tc00b", `rad://${repo00}`, { repo: repo00 }],
["tc00c", `rad:///${repo00}`, { repo: repo00 }],
["tc01a", `rad:${repo01}/${node01}`, { repo: repo01, namespace: node01 }],
["tc01b", `rad://${repo01}/${node01}`, { repo: repo01, namespace: node01 }],
[
"tc01c",
`rad:///${repo01}/${node01}`,
{ repo: repo01, namespace: node01 },
],
[
"tc02",
`rad://${node01}/${repo01}`,
{ authority: { node: node01 }, repo: repo01 },
],
[
"tc03",
`rad://${node01}/${repo01}/${node01}`,
{ authority: { node: node01 }, repo: repo01, namespace: node01 },
],
[
"tc04",
`rad://${node01}@${seed}/${repo01}`,
{ authority: { node: node01, address: seed }, repo: repo01 },
],
[
"tc05",
`rad://${node01}@${seed}/${repo01}/${node02}`,
{
authority: { node: node01, address: seed },
repo: repo01,
namespace: node02,
},
],
[
"tc10a",
`rad:${repo00}/commit/72db6dffffffffffffffffffffffffffffffffff`,
{
repo: repo00,
resource: {
type: "commit",
ref: "72db6dffffffffffffffffffffffffffffffffff",
},
},
],
[
"tc10b",
`rad:///${repo00}/commit/72db6dffffffffffffffffffffffffffffffffff`,
{
repo: repo00,
resource: {
type: "commit",
ref: "72db6dffffffffffffffffffffffffffffffffff",
},
},
],
[
"tc12",
`rad://${node01}/${repo01}/commit/${oid}`,
{
authority: { node: node01 },
repo: repo01,
resource: { type: "commit", ref: oid },
},
],
[
"tc13",
`rad://${node01}@${seed}/${repo01}/${node02}/commit/72db6dffffffffffffffffffffffffffffffffff`,
{
authority: { node: node01, address: seed },
repo: repo01,
namespace: node02,
resource: {
type: "commit",
ref: "72db6dffffffffffffffffffffffffffffffffff",
},
},
],
[
"tc30",
`rad:${repo01}/cob/org.example/${oid}`,
{
repo: repo01,
resource: { type: "cob", typeName: "org.example", oid },
},
],
[
"tc31",
`rad://${node01}/${repo01}/cob/org.example/${oid}`,
{
authority: { node: node01 },
repo: repo01,
resource: { type: "cob", typeName: "org.example", oid },
},
],
[
"tc32",
`rad:${repo01}/cob/org.example`,
{ repo: repo01, resource: { type: "cob", typeName: "org.example" } },
],
[
"tc40a",
`rad:${repo01}/tree/3eb47e9fffffffffffffffffffffffffffffffff?path=src`,
{
repo: repo01,
resource: {
type: "tree",
oid: "3eb47e9fffffffffffffffffffffffffffffffff",
},
query: [{ param: "path", value: "src" }],
},
],
[
"tc41a",
`rad:${repo01}/commit/master?blob=README.md`,
{
repo: repo01,
resource: { type: "commit", ref: "master" },
query: [{ param: "blob", value: "README.md" }],
},
],
[
"tc42a",
`rad:${repo01}/commit/baz?path=foo/doc`,
{
repo: repo01,
resource: { type: "commit", ref: "baz" },
query: [{ param: "path", value: "foo/doc" }],
},
],
[
"tc43a",
`rad:${repo01}/commit/baz/foo?path=doc&path=src`,
{
repo: repo01,
resource: { type: "commit", ref: "baz/foo" },
query: [
{ param: "path", value: "doc" },
{ param: "path", value: "src" },
],
},
],
[
"tc44",
`rad:///${repo01}/commit/refs/notes/commit?blob=${oid}`,
{
repo: repo01,
resource: { type: "commit", ref: "refs/notes/commit" },
query: [{ param: "blob", value: oid }],
},
],
];
test.each(cases)("%s parses", (_, input, expected) => {
expect(parseRadUri(input)).toEqual(expected);
});
});
describe("parseRadUri", () => {
test("is case-insensitive in the scheme and resource type", () => {
expect(parseRadUri(`RAD:${repo00}/COMMIT/master`)).toEqual({
repo: repo00,
resource: { type: "commit", ref: "master" },
});
});
test.each([
[
`cob/${issueType}/${oid.toUpperCase()}`,
{ type: "cob", typeName: issueType, oid },
],
[`tree/${oid.toUpperCase()}`, { type: "tree", oid }],
[`commit/${oid.toUpperCase()}`, { type: "commit", ref: oid }],
])("lowercases the oid in %s", (resource, expected) => {
expect(parseRadUri(`rad:${repo00}/${resource}`)?.resource).toEqual(
expected,
);
});
test("parses an IPv6 authority", () => {
expect(parseRadUri(`rad://${node01}@[::1]:8776/${repo00}`)).toEqual({
authority: { node: node01, address: "[::1]:8776" },
repo: repo00,
});
});
test("parses a namespace followed by a resource", () => {
expect(parseRadUri(`rad:${repo00}/${node01}/commit/master`)).toEqual({
repo: repo00,
namespace: node01,
resource: { type: "commit", ref: "master" },
});
});
test("reads a question mark after the fragment as part of it", () => {
expect(parseRadUri(`rad:${repo00}#a?b`)).toEqual({
repo: repo00,
fragment: "a?b",
});
});
});
describe("rejects", () => {
test.each([
["no scheme", repo00],
["another scheme", `https:${repo00}`],
["no repo", "rad:"],
["empty authority without a repo", "rad:///"],
["an absolute path without authority", `rad:/${repo00}`],
["a non-base58 repo id", "rad:z3gqcJUbbbbbbbbbbbbbbbCSGazv0"],
["a repo id of the wrong length", "rad:zk1arLZbbbbbbbbbbbbbbb26us45E"],
["an abbreviated tree oid", `rad:${repo00}/tree/abc123`],
["a blob that is not an oid", `rad:${repo00}/blob/not-a-sha1`],
["an abbreviated cob oid", `rad:${repo00}/cob/${issueType}/e9cf5263`],
["an unknown resource type", `rad:${repo00}/issues/${oid}`],
[
"an invalid authority node",
`rad://z6Mk11111111111111111111111111111111111111111111/${repo00}`,
],
[
"an invalid namespace",
`rad:${repo00}/z6Mk11111111111111111111111111111111111111111111`,
],
["an address without a port", `rad://${node01}@seed.example.com/${repo00}`],
["trailing input", `rad:${repo00}/commit/master trailing`],
["a trailing slash", `rad:${repo00}/`],
["a resource on the legacy form", `rad://${repo00}/commit/master`],
["a query on the legacy form", `rad://${repo00}?x`],
["an invalid host", `rad://${node01}@seed example.com:8776/${repo00}`],
["an authority without a repo", `rad://${node01}`],
["invalid query characters", `rad:${repo00}?a b`],
["invalid fragment characters", `rad:${repo00}#a b`],
["a tree with extra segments", `rad:${repo00}/tree/${oid}/src`],
["a cob with extra segments", `rad:${repo00}/cob/${issueType}/${oid}/x`],
["a cob type without a dot", `rad:${repo00}/cob/issue/${oid}`],
])("%s", (_, input) => {
expect(parseRadUri(input)).toBeUndefined();
});
});
describe("formatRadUri", () => {
test.each([
`rad:${repo00}`,
`rad:${repo00}/${node01}`,
`rad://${node01}/${repo00}`,
`rad://${node01}@${seed}/${repo00}/${node02}/commit/master?path=src#L10`,
`rad:${repo00}/tag/v1.0`,
`rad:${repo00}/blob/${oid}`,
`rad:${repo00}/cob/${patchType}/${oid}`,
`rad:${repo00}?raw&path=src`,
`rad:${repo00}/commit/master#section-1`,
`rad:${repo00}?`,
])("round-trips %s", input => {
const uri = parseRadUri(input);
expect(uri).toBeDefined();
expect(formatRadUri(uri!)).toBe(input);
});
test("drops an empty authority", () => {
expect(formatRadUri(parseRadUri(`rad:///${repo00}/commit/master`)!)).toBe(
`rad:${repo00}/commit/master`,
);
});
test("writes an empty authority when forced", () => {
expect(formatRadUri({ repo: repo00 }, { forceAuthority: true })).toBe(
`rad:///${repo00}`,
);
});
});
describe("parseReference", () => {
test("parses a DID", () => {
expect(parseReference(`did:key:${node01}`)).toEqual({
type: "did",
node: node01,
});
});
test("rejects a DID of another method", () => {
expect(parseReference(`did:web:${node01}`)).toBeUndefined();
});
test("rejects an invalid DID", () => {
expect(
parseReference(
"did:key:z6Mk11111111111111111111111111111111111111111111",
),
).toBeUndefined();
});
});
describe("explorer URLs", () => {
const template = "https://app.radicle.xyz/";
const host = "seed.radicle.xyz";
const base = `https://app.radicle.xyz/nodes/${host}`;
test.each([
[`rad:${repo00}`, `${base}/rad:${repo00}`],
[
`rad:${repo00}/cob/${issueType}/${oid}`,
`${base}/rad:${repo00}/issues/${oid}`,
],
[
`rad:${repo00}/cob/${patchType}/${oid}`,
`${base}/rad:${repo00}/patches/${oid}`,
],
[
`rad:${repo00}/cob/${releaseType}/${oid}`,
`${base}/rad:${repo00}/releases/${oid}`,
],
[`rad:${repo00}/commit/${oid}`, `${base}/rad:${repo00}/commits/${oid}`],
[`rad:${repo00}/${node01}`, `${base}/rad:${repo00}/remotes/${node01}`],
[`did:key:${node01}`, `${base}/users/did:key:${node01}`],
])("maps %s both ways", (reference, url) => {
const parsed = parseReference(reference)!;
expect(explorerUrl(parsed, template, host)).toBe(url);
expect(parseExplorerUrl(url)).toEqual(parsed);
});
test("maps a branch to its tree", () => {
expect(
explorerUrl(
parseReference(`rad:${repo00}/commit/master`)!,
template,
host,
),
).toBe(`${base}/rad:${repo00}/tree/master`);
});
test.each([
[`commit/master?path=src/lib`, `/tree/master/src/lib`],
[`commit/${oid}?blob=README.md`, `/tree/${oid}/README.md`],
[`tag/v1.0?tree=docs`, `/tree/v1.0/docs`],
[`${node01}/commit/master`, `/remotes/${node01}/tree/master`],
[`${node01}/commit/${oid}`, `/commits/${oid}`],
])("maps %s", (resource, path) => {
expect(
explorerUrl(parseReference(`rad:${repo00}/${resource}`)!, template, host),
).toBe(`${base}/rad:${repo00}${path}`);
});
test("has no page for an unknown cob type", () => {
expect(
explorerUrl(
parseReference(`rad:${repo00}/cob/org.example/${oid}`)!,
template,
host,
),
).toBeUndefined();
});
test("decodes percent-encoded ids", () => {
expect(parseExplorerUrl(`${base}/users/did%3Akey%3A${node01}`)).toEqual({
type: "did",
node: node01,
});
});
test("rejects an abbreviated oid", () => {
expect(
parseExplorerUrl(`${base}/rad:${repo00}/issues/e9cf5263`),
).toBeUndefined();
});
});
describe("explorer URL edge cases", () => {
const base = "https://app.radicle.xyz";
const node = `${base}/nodes/seed.radicle.xyz`;
test.each([
[`tree/${oid}`, undefined],
[`blob/${oid}`, undefined],
["commit/master?path=", `${node}/rad:${repo00}/tree/master`],
[`cob/${issueType}`, `${node}/rad:${repo00}/issues`],
["commit/refs/heads/main", `${node}/rad:${repo00}/tree/main`],
["tag/refs/tags/v1.0", `${node}/rad:${repo00}/tree/v1.0`],
["commit/refs/notes/commit", undefined],
])("maps %s", (resource, url) => {
expect(
explorerUrl(
parseReference(`rad:${repo00}/${resource}`)!,
base,
"seed.radicle.xyz",
),
).toBe(url);
});
test.each([
["a non-web scheme", `ftp://app.radicle.xyz/nodes/h/rad:${repo00}`],
["an invalid repo id", `${node}/rad:z3gqcJUbbbbbbbbbbbbbbbCSGazv0`],
["extra segments", `${node}/rad:${repo00}/issues/${oid}/x`],
["an unknown section", `${node}/rad:${repo00}/wiki/${oid}`],
["users before the end", `${node}/users/did:key:${node01}/x`],
["a branch followed by a path", `${node}/rad:${repo00}/tree/feature/x`],
["a history page", `${node}/rad:${repo00}/history/main`],
["an issue with extra segments", `${node}/rad:${repo00}/issues/${oid}/x`],
[
"an invalid remote",
`${node}/rad:${repo00}/remotes/z6Mk11111111111111111111111111111111111111111111`,
],
])("parseExplorerUrl rejects %s", (_, url) => {
expect(parseExplorerUrl(url)).toBeUndefined();
});
test("parseExplorerUrl lowercases the oid", () => {
expect(
parseExplorerUrl(`${node}/rad:${repo00}/commits/${oid.toUpperCase()}`),
).toEqual({
type: "uri",
uri: { repo: repo00, resource: { type: "commit", ref: oid } },
});
});
test.each([
[
"a patch revision",
`${node}/rad:${repo00}/patches/${oid}/${oid}?tab=changes`,
{ repo: repo00, resource: { type: "cob", typeName: patchType, oid } },
],
[
"an issue under a remote",
`${node}/rad:${repo00}/remotes/${node01}/issues/${oid}`,
{ repo: repo00, resource: { type: "cob", typeName: issueType, oid } },
],
[
"a remote's tree",
`${node}/rad:${repo00}/remotes/${node01}/tree`,
{ repo: repo00, namespace: node01 },
],
[
"a branch",
`${node}/rad:${repo00}/tree/main`,
{ repo: repo00, resource: { type: "commit", ref: "main" } },
],
[
"a remote's branch",
`${node}/rad:${repo00}/remotes/${node01}/tree/main`,
{
repo: repo00,
namespace: node01,
resource: { type: "commit", ref: "main" },
},
],
[
"a path at a commit",
`${node}/rad:${repo00}/tree/${oid}/src/lib`,
{
repo: repo00,
resource: { type: "commit", ref: oid },
query: [{ param: "path", value: "src/lib" }],
},
],
])("parseExplorerUrl reads %s", (_, url, uri) => {
expect(parseExplorerUrl(url)).toEqual({ type: "uri", uri });
});
test("maps a path at a commit both ways", () => {
const url = `${node}/rad:${repo00}/tree/${oid}/src/lib`;
expect(explorerUrl(parseExplorerUrl(url)!, base, "seed.radicle.xyz")).toBe(
url,
);
});
});