repository-location.mjs
71 lines · archify/renderers/shared/repository-location.mjs at a406594
1// Repository identity and forge links are independent of local Git object checks.
2// This module never contacts a remote server or reads the user's SSH config.
3// Public forge hosts select their provider automatically. GitLab is also
4// self-hosted, so an explicit `provider: "gitlab"` hint selects it on any host.
5const PROVIDER_HOSTS = { 'github.com': 'github', 'gitee.com': 'gitee', 'gitlab.com': 'gitlab' };
7export function parseRepositoryRemote(value, { authored = false, provider: providerHint } = {}) {
8 if (typeof value !== 'string') return null;
9 const raw = authored ? value : value.trim();
10 if (!raw || /[\s\\\u0000-\u001f\u007f?#]/.test(raw)) return null;
11 const scp = raw.match(/^git@([^/:]+):(.+)$/);
12 const scpAbsolute = Boolean(scp && scp[2].startsWith('/'));
13 const expanded = scp ? `ssh://git@${scp[1]}/${scp[2].replace(/^\//, '')}` : raw;
14 const match = expanded.match(/^(https?|ssh):\/\/([^/]+)\/(.+)$/i);
15 if (!match) return null;
16 // Validate the original path before URL parsing can collapse dot segments.
17 let segments;
18 try {
19 segments = match[3].replace(/\/$/, '').split('/');
20 // Git passes SCP paths literally; percent escapes are decoded only in URIs.
21 if (!scp) segments = segments.map(decodeURIComponent);
22 }
23 catch { return null; }
24 if (segments.some((part) => !part || part === '.' || part === '..' || /[/\\\s\u0000-\u001f\u007f?#]/.test(part))) return null;
25 let url;
26 try { url = new URL(expanded); } catch { return null; }
27 const protocol = url.protocol;
28 if (protocol === 'ssh:' && (url.username !== 'git' || url.password)) return null;
29 if (authored && protocol !== 'ssh:' && (url.username || url.password)) return null;
30 const hostname = url.hostname.toLowerCase();
31 if (!hostname) return null;
32 const provider = Object.hasOwn(PROVIDER_HOSTS, hostname) ? PROVIDER_HOSTS[hostname] : providerHint === 'gitlab' ? 'gitlab' : null;
33 const last = segments.length - 1;
34 if (provider) segments[last] = segments[last].replace(provider === 'github' ? /\.git$/i : /\.git$/, '');
35 if (!segments[last] || segments[last] === '.' || segments[last] === '..') return null;
36 const repositoryPath = segments.join('/');
37 // Only known forges map HTTPS and SSH to one repository namespace. Other
38 // hosts retain transport, port and remote-relative/absolute path semantics.
39 const port = url.port || (protocol === 'ssh:' ? '22' : protocol === 'https:' ? '443' : '80');
40 const endpoint = provider && ((protocol === 'https:' && port === '443') || (protocol === 'ssh:' && port === '22'))
41 ? 'standard' : `${protocol}${port}`;
42 const pathKind = provider ? 'repository' : scp && !scpAbsolute ? 'relative' : 'absolute';
43 // path-contract-allow: url-path -- GitHub and GitLab repository paths are case-insensitive URL identities.
44 const identityPath = provider === 'github' || provider === 'gitlab' ? repositoryPath.toLowerCase() : repositoryPath;
45 const encodedPath = segments.map(encodeURIComponent).join('/');
46 const canonicalUrl = scp ? `git@${hostname}:${scpAbsolute ? '/' : ''}${repositoryPath}`
47 : `${protocol}//${protocol === 'ssh:' ? 'git@' : ''}${url.host}/${encodedPath}`;
48 return { identity: JSON.stringify([hostname, endpoint, pathKind, identityPath]), url: canonicalUrl, provider, protocol, path: repositoryPath, endpoint };
49}
51export function redactRepositoryRemote(value) {
52 return String(value || '')
53 .replace(/^((?:https?|ssh):\/\/)[^/]*@/i, '$1REDACTED@')
54 .replace(/[?#].*$/s, '?REDACTED');
55}
57export function repositorySourceHref(provider, url, revision, source) {
58 const encodedPath = source.path.split('/').map(encodeURIComponent).join('/');
59 const end = source.endLine && source.endLine !== source.line
60 ? `-${provider === 'github' ? 'L' : ''}${source.endLine}` : '';
61 const fragment = source.line ? `#L${source.line}${end}` : '';
62 if (provider !== 'gitlab') return `${url}/blob/${revision}/${encodedPath}${fragment}`;
63 // GitLab renders Markdown blobs, where line anchors select nothing; the
64 // plain view keeps the cited lines highlighted.
65 const plain = source.line && /\.(?:md|markdown)$/i.test(source.path) ? '?plain=1' : '';
66 return `${url}/-/blob/${revision}/${encodedPath}${plain}${fragment}`;
67}
69export function repositoryTreeHref(provider, url, revision) {
70 return `${url}/${provider === 'gitlab' ? '-/' : ''}tree/${revision}`;
71}