Skip to content

Commit afd4afb

Browse files
committed
Support specifying base url for source code
Resolves #2069
1 parent 9aece44 commit afd4afb

File tree

6 files changed

+161
-271
lines changed

6 files changed

+161
-271
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
### Features
44

55
- Added support for specifying the tsconfig.json file in packages mode with `{ "typedoc": { "tsconfig": "tsconfig.lib.json" }}` in package.json, #2061.
6+
- Added support for specifying the base file url for links to source code, #2068.
67

78
### Bug Fixes
89

src/lib/converter/plugins/SourcePlugin.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,8 @@ export class SourcePlugin extends ConverterComponent {
183183
const repository = Repository.tryCreateRepository(
184184
dirName,
185185
this.gitRevision,
186-
this.gitRemote
186+
this.gitRemote,
187+
this.application.logger
187188
);
188189
if (repository) {
189190
this.repositories[repository.path.toLowerCase()] = repository;

src/lib/converter/utils/repository.ts

Lines changed: 104 additions & 136 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { spawnSync } from "child_process";
2-
import { RepositoryType } from "../../models";
2+
import type { Logger } from "../../utils";
33
import { BasePath } from "../utils/base-path";
44

55
const TEN_MEGABYTES: number = 1024 * 10000;
@@ -19,131 +19,43 @@ export const gitIsInstalled = git("--version").status === 0;
1919
*/
2020
export class Repository {
2121
/**
22-
* The root path of this repository.
22+
* The path of this repository on disk.
2323
*/
2424
path: string;
2525

2626
/**
27-
* The name of the branch this repository is on right now.
27+
* All files tracked by the repository.
2828
*/
29-
branch: string;
29+
files = new Set<string>();
3030

3131
/**
32-
* A list of all files tracked by the repository.
32+
* The base url for link creation.
3333
*/
34-
files: string[] = [];
34+
baseUrl: string;
3535

3636
/**
37-
* The user/organization name of this repository on GitHub.
37+
* The anchor prefix used to select lines, usually `L`
3838
*/
39-
user?: string;
40-
41-
/**
42-
* The project name of this repository on GitHub.
43-
*/
44-
project?: string;
45-
46-
/**
47-
* The hostname for this GitHub/Bitbucket/.etc project.
48-
*
49-
* Defaults to: `github.com` (for normal, public GitHub instance projects)
50-
*
51-
* Can be the hostname for an enterprise version of GitHub, e.g. `github.acme.com`
52-
* (if found as a match in the list of git remotes).
53-
*/
54-
hostname = "github.com";
55-
56-
/**
57-
* Whether this is a GitHub, Bitbucket, or other type of repository.
58-
*/
59-
type: RepositoryType = RepositoryType.GitHub;
60-
61-
private urlCache = new Map<string, string>();
39+
anchorPrefix: string;
6240

6341
/**
6442
* Create a new Repository instance.
6543
*
6644
* @param path The root path of the repository.
6745
*/
68-
constructor(path: string, gitRevision: string, repoLinks: string[]) {
46+
constructor(path: string, baseUrl: string) {
6947
this.path = path;
70-
this.branch = gitRevision || "master";
71-
72-
for (let i = 0, c = repoLinks.length; i < c; i++) {
73-
let match =
74-
/(github(?!.us)(?:\.[a-z]+)*\.[a-z]{2,})[:/]([^/]+)\/(.*)/.exec(
75-
repoLinks[i]
76-
);
77-
78-
// Github Enterprise
79-
if (!match) {
80-
match = /(\w+\.githubprivate.com)[:/]([^/]+)\/(.*)/.exec(
81-
repoLinks[i]
82-
);
83-
}
84-
85-
// Github Enterprise
86-
if (!match) {
87-
match = /(\w+\.ghe.com)[:/]([^/]+)\/(.*)/.exec(repoLinks[i]);
88-
}
89-
90-
// Github Enterprise
91-
if (!match) {
92-
match = /(\w+\.github.us)[:/]([^/]+)\/(.*)/.exec(repoLinks[i]);
93-
}
94-
95-
if (!match) {
96-
match = /(bitbucket.org)[:/]([^/]+)\/(.*)/.exec(repoLinks[i]);
97-
}
98-
99-
if (!match) {
100-
match = /(gitlab.com)[:/]([^/]+)\/(.*)/.exec(repoLinks[i]);
101-
}
102-
103-
if (match) {
104-
this.hostname = match[1];
105-
this.user = match[2];
106-
this.project = match[3];
107-
if (this.project.endsWith(".git")) {
108-
this.project = this.project.slice(0, -4);
109-
}
110-
break;
111-
}
112-
}
113-
114-
if (this.hostname.includes("bitbucket.org")) {
115-
this.type = RepositoryType.Bitbucket;
116-
} else if (this.hostname.includes("gitlab.com")) {
117-
this.type = RepositoryType.GitLab;
118-
} else {
119-
this.type = RepositoryType.GitHub;
120-
}
48+
this.baseUrl = baseUrl;
49+
this.anchorPrefix = guessAnchorPrefix(this.baseUrl);
12150

12251
let out = git("-C", path, "ls-files");
12352
if (out.status === 0) {
12453
out.stdout.split("\n").forEach((file) => {
12554
if (file !== "") {
126-
this.files.push(BasePath.normalize(path + "/" + file));
55+
this.files.add(BasePath.normalize(path + "/" + file));
12756
}
12857
});
12958
}
130-
131-
if (!gitRevision) {
132-
out = git("-C", path, "rev-parse", "--short", "HEAD");
133-
if (out.status === 0) {
134-
this.branch = out.stdout.replace("\n", "");
135-
}
136-
}
137-
}
138-
139-
/**
140-
* Check whether the given file is tracked by this repository.
141-
*
142-
* @param fileName The name of the file to test for.
143-
* @returns TRUE when the file is part of the repository, otherwise FALSE.
144-
*/
145-
contains(fileName: string): boolean {
146-
return this.files.includes(fileName);
14759
}
14860

14961
/**
@@ -153,39 +65,15 @@ export class Repository {
15365
* @returns A URL pointing to the web preview of the given file or undefined.
15466
*/
15567
getURL(fileName: string): string | undefined {
156-
if (this.urlCache.has(fileName)) {
157-
return this.urlCache.get(fileName)!;
158-
}
159-
160-
if (!this.user || !this.project || !this.contains(fileName)) {
68+
if (!this.files.has(fileName)) {
16169
return;
16270
}
16371

164-
const url = [
165-
`https://${this.hostname}`,
166-
this.user,
167-
this.project,
168-
this.type === RepositoryType.GitLab ? "-" : undefined,
169-
this.type === RepositoryType.Bitbucket ? "src" : "blob",
170-
this.branch,
171-
fileName.substring(this.path.length + 1),
172-
]
173-
.filter((s) => !!s)
174-
.join("/");
175-
176-
this.urlCache.set(fileName, url);
177-
return url;
72+
return `${this.baseUrl}/${fileName.substring(this.path.length + 1)}`;
17873
}
17974

18075
getLineNumberAnchor(lineNumber: number): string {
181-
switch (this.type) {
182-
default:
183-
case RepositoryType.GitHub:
184-
case RepositoryType.GitLab:
185-
return "L" + lineNumber;
186-
case RepositoryType.Bitbucket:
187-
return "lines-" + lineNumber;
188-
}
76+
return `${this.anchorPrefix}${lineNumber}`;
18977
}
19078

19179
/**
@@ -200,19 +88,99 @@ export class Repository {
20088
static tryCreateRepository(
20189
path: string,
20290
gitRevision: string,
203-
gitRemote: string
91+
gitRemote: string,
92+
logger: Logger
20493
): Repository | undefined {
205-
const out = git("-C", path, "rev-parse", "--show-toplevel");
206-
const remotesOutput = git("-C", path, "remote", "get-url", gitRemote);
207-
208-
if (out.status !== 0 || remotesOutput.status !== 0) {
209-
return;
94+
const topLevel = git("-C", path, "rev-parse", "--show-toplevel");
95+
if (topLevel.status !== 0) return;
96+
97+
gitRevision ||= git(
98+
"-C",
99+
path,
100+
"rev-parse",
101+
"--short",
102+
"HEAD"
103+
).stdout.trim();
104+
if (!gitRevision) return; // Will only happen in a repo with no commits.
105+
106+
let baseUrl: string | undefined;
107+
if (/^https?:\/\//.test(gitRemote)) {
108+
baseUrl = `${gitRemote}/${gitRevision}`;
109+
} else {
110+
const remotesOut = git("-C", path, "remote", "get-url", gitRemote);
111+
if (remotesOut.status === 0) {
112+
baseUrl = guessBaseUrl(
113+
gitRevision,
114+
remotesOut.stdout.split("\n")
115+
);
116+
} else {
117+
logger.warn(
118+
`The provided git remote "${gitRemote}" was not valid. Source links will be broken.`
119+
);
120+
}
210121
}
211122

123+
if (!baseUrl) return;
124+
212125
return new Repository(
213-
BasePath.normalize(out.stdout.replace("\n", "")),
214-
gitRevision,
215-
remotesOutput.stdout.split("\n")
126+
BasePath.normalize(topLevel.stdout.replace("\n", "")),
127+
baseUrl
216128
);
217129
}
218130
}
131+
132+
// Should have three capturing groups:
133+
// 1. hostname
134+
// 2. user
135+
// 3. project
136+
const repoExpressions = [
137+
/(github(?!.us)(?:\.[a-z]+)*\.[a-z]{2,})[:/]([^/]+)\/(.*)/,
138+
/(\w+\.githubprivate.com)[:/]([^/]+)\/(.*)/, // GitHub enterprise
139+
/(\w+\.ghe.com)[:/]([^/]+)\/(.*)/, // GitHub enterprise
140+
/(\w+\.github.us)[:/]([^/]+)\/(.*)/, // GitHub enterprise
141+
/(bitbucket.org)[:/]([^/]+)\/(.*)/,
142+
/(gitlab.com)[:/]([^/]+)\/(.*)/,
143+
];
144+
145+
export function guessBaseUrl(
146+
gitRevision: string,
147+
remotes: string[]
148+
): string | undefined {
149+
let hostname = "";
150+
let user = "";
151+
let project = "";
152+
outer: for (const repoLink of remotes) {
153+
for (const regex of repoExpressions) {
154+
const match = regex.exec(repoLink);
155+
if (match) {
156+
hostname = match[1];
157+
user = match[2];
158+
project = match[3];
159+
break outer;
160+
}
161+
}
162+
}
163+
164+
if (!hostname) return;
165+
166+
if (project.endsWith(".git")) {
167+
project = project.slice(0, -4);
168+
}
169+
170+
let sourcePath = "blob";
171+
if (hostname.includes("gitlab")) {
172+
sourcePath = "-/blob";
173+
} else if (hostname.includes("bitbucket")) {
174+
sourcePath = "src";
175+
}
176+
177+
return `https://${hostname}/${user}/${project}/${sourcePath}/${gitRevision}`;
178+
}
179+
180+
function guessAnchorPrefix(url: string) {
181+
if (url.includes("bitbucket")) {
182+
return "lines-";
183+
}
184+
185+
return "L";
186+
}

src/lib/models/sources/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1 @@
11
export { SourceReference } from "./file";
2-
export { RepositoryType } from "./repository";

src/lib/models/sources/repository.ts

Lines changed: 0 additions & 5 deletions
This file was deleted.

0 commit comments

Comments
 (0)