-
-
Notifications
You must be signed in to change notification settings - Fork 9.9k
Expand file tree
/
Copy pathurlUtils.ts
More file actions
331 lines (289 loc) · 10.2 KB
/
urlUtils.ts
File metadata and controls
331 lines (289 loc) · 10.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import resolvePathnameUnsafe from 'resolve-pathname';
/**
* Much like `path.join`, but much better. Takes an array of URL segments, and
* joins them into a reasonable URL.
*
* - `["file:", "/home", "/user/", "website"]` => `file:///home/user/website`
* - `["file://", "home", "/user/", "website"]` => `file://home/user/website` (relative!)
* - Remove trailing slash before parameters or hash.
* - Replace `?` in query parameters with `&`.
* - Dedupe forward slashes in the entire path, avoiding protocol slashes.
*
* @throws {TypeError} If any of the URL segment is not a string, this throws.
*/
export function normalizeUrl(rawUrls: string[]): string {
const urls = [...rawUrls];
const resultArray = [];
let hasStartingSlash = false;
let hasEndingSlash = false;
const isNonEmptyArray = (arr: string[]): arr is [string, ...string[]] =>
arr.length > 0;
if (!isNonEmptyArray(urls)) {
return '';
}
// If the first part is a plain protocol, we combine it with the next part.
if (urls[0].match(/^[^/:]+:\/*$/) && urls.length > 1) {
const first = urls.shift()!;
if (first.startsWith('file:') && urls[0].startsWith('/')) {
// Force a double slash here, else we lose the information that the next
// segment is an absolute path
urls[0] = `${first}//${urls[0]}`;
} else {
urls[0] = first + urls[0];
}
}
// There must be two or three slashes in the file protocol,
// two slashes in anything else.
const replacement = urls[0].match(/^file:\/\/\//) ? '$1:///' : '$1://';
urls[0] = urls[0].replace(/^(?<protocol>[^/:]+):\/*/, replacement);
for (let i = 0; i < urls.length; i += 1) {
let component = urls[i];
if (typeof component !== 'string') {
throw new TypeError(`Url must be a string. Received ${typeof component}`);
}
if (component === '') {
if (i === urls.length - 1 && hasEndingSlash) {
resultArray.push('/');
}
continue;
}
if (component !== '/') {
if (i > 0) {
// Removing the starting slashes for each component but the first.
component = component.replace(
/^\/+/,
// Special case where the first element of rawUrls is empty
// ["", "/hello"] => /hello
component.startsWith('/') && !hasStartingSlash ? '/' : '',
);
}
hasEndingSlash = component.endsWith('/');
// Removing the ending slashes for each component but the last. For the
// last component we will combine multiple slashes to a single one.
component = component.replace(/\/+$/, i < urls.length - 1 ? '' : '/');
}
hasStartingSlash = true;
resultArray.push(component);
}
let str = resultArray.join('/');
// Each input component is now separated by a single slash except the possible
// first plain protocol part.
// Remove trailing slash before parameters or hash.
str = str.replace(/\/(?<search>\?|&|#[^!/])/g, '$1');
// Replace ? in parameters with &.
const parts = str.split('?');
str = parts.shift()! + (parts.length > 0 ? '?' : '') + parts.join('&');
// Dedupe forward slashes in the entire path, avoiding protocol slashes.
str = str.replace(/(?<textBefore>[^:/]\/)\/+/g, '$1');
// Dedupe forward slashes at the beginning of the path.
str = str.replace(/^\/+/g, '/');
return str;
}
/**
* Takes a file's path, relative to its content folder, and computes its edit
* URL. If `editUrl` is `undefined`, this returns `undefined`, as is the case
* when the user doesn't want an edit URL in her config.
*/
export function getEditUrl(
fileRelativePath: string,
editUrl?: string,
): string | undefined {
return editUrl
? // Don't use posixPath for this: we need to force a forward slash path
normalizeUrl([editUrl, fileRelativePath.replace(/\\/g, '/')])
: undefined;
}
/**
* Converts file path to a reasonable URL path, e.g. `'index.md'` -> `'/'`,
* `'foo/bar.js'` -> `'/foo/bar'`
*/
export function fileToPath(file: string): string {
const indexRE = /(?<dirname>^|.*\/)index\.(?:mdx?|jsx?|tsx?)$/i;
const extRE = /\.(?:mdx?|jsx?|tsx?)$/;
if (indexRE.test(file)) {
return file.replace(indexRE, '/$1');
}
return `/${file.replace(extRE, '').replace(/\\/g, '/')}`;
}
/**
* Similar to `encodeURI`, but uses `encodeURIComponent` and assumes there's no
* query.
*
* `encodeURI("/question?/answer")` => `"/question?/answer#section"`;
* `encodePath("/question?/answer#section")` => `"/question%3F/answer%23foo"`
*/
export function encodePath(userPath: string): string {
return userPath
.split('/')
.map((item) => encodeURIComponent(item))
.join('/');
}
/**
* Whether `str` is a valid pathname. It must be absolute, and not contain
* special characters.
*/
export function isValidPathname(str: string): boolean {
if (!str.startsWith('/')) {
return false;
}
try {
const parsedPathname = new URL(str, 'https://domain.com').pathname;
return parsedPathname === str || parsedPathname === encodeURI(str);
} catch {
return false;
}
}
export function parseURLOrPath(url: string, base?: string | URL): URL {
try {
// TODO Docusaurus v4: use URL.parse()
// Node 24 supports it, use URL.parse could be faster?
// see https://kilianvalkhof.com/2024/javascript/the-problem-with-new-url-and-how-url-parse-fixes-that/
return new URL(url, base ?? 'https://example.com');
} catch (e) {
throw new Error(
`Can't parse URL ${url}${base ? ` with base ${base}` : ''}`,
{cause: e},
);
}
}
export type URLPath = {pathname: string; search?: string; hash?: string};
export function toURLPath(url: URL): URLPath {
const {pathname} = url;
// Fixes annoying url.search behavior
// "" => undefined
// "?" => ""
// "?param => "param"
const search = url.search
? url.search.slice(1)
: url.href.includes('?')
? ''
: undefined;
// Fixes annoying url.hash behavior
// "" => undefined
// "#" => ""
// "#param => "param"
const hash = url.hash
? url.hash.slice(1)
: url.href.includes('#')
? ''
: undefined;
return {
pathname,
search,
hash,
};
}
/**
* Let's name the concept of (pathname + search + hash) as URLPath
* See also https://x.com/kettanaito/status/1741768992866308120
* Note: this function also resolves relative pathnames while parsing!
*/
export function parseURLPath(urlPath: string, fromPath?: string): URLPath {
const base = fromPath ? parseURLOrPath(fromPath) : undefined;
const url = parseURLOrPath(urlPath, base);
return toURLPath(url);
}
/**
* This returns results for strings like "foo", "../foo", "./foo.mdx?qs#hash"
* Unlike "parseURLPath()" above, this will not resolve the pathnames
* Te returned pathname of "../../foo.mdx" will be "../../foo.mdx", not "/foo"
* This returns null if the url is not "local" (contains domain/protocol etc)
*/
export function parseLocalURLPath(urlPath: string): URLPath | null {
// Workaround because URL("") requires a protocol
const unspecifiedProtocol = 'unspecified:';
const url = parseURLOrPath(urlPath, `${unspecifiedProtocol}//`);
// Ignore links with specified protocol / host
// (usually fully qualified links starting with https://)
if (
url.protocol !== unspecifiedProtocol ||
url.host !== '' ||
url.username !== '' ||
url.password !== ''
) {
return null;
}
// We can't use "new URL()" result because it always tries to resolve urls
// IE it will remove any "./" or "../" in the pathname, which we don't want
// We have to parse it manually...
let localUrlPath = urlPath;
// Extract and remove the #hash part
const hashIndex = localUrlPath.indexOf('#');
const hash =
hashIndex !== -1 ? localUrlPath.substring(hashIndex + 1) : undefined;
localUrlPath =
hashIndex !== -1 ? localUrlPath.substring(0, hashIndex) : localUrlPath;
// Extract and remove ?search part
const searchIndex = localUrlPath.indexOf('?');
const search =
searchIndex !== -1 ? localUrlPath.substring(searchIndex + 1) : undefined;
localUrlPath =
searchIndex !== -1 ? localUrlPath.substring(0, searchIndex) : localUrlPath;
return {
pathname: localUrlPath,
search,
hash,
};
}
export function serializeURLPath(urlPath: URLPath): string {
const search = urlPath.search === undefined ? '' : `?${urlPath.search}`;
const hash = urlPath.hash === undefined ? '' : `#${urlPath.hash}`;
return `${urlPath.pathname}${search}${hash}`;
}
/**
* Resolve pathnames and fail-fast if resolution fails. Uses standard URL
* semantics (provided by `resolve-pathname` which is used internally by React
* router)
*/
export function resolvePathname(to: string, from?: string): string {
// TODO do we really need resolve-pathname lib anymore?
// possible alternative: decodeURI(parseURLPath(to, from).pathname);
return resolvePathnameUnsafe(to, from);
}
/** Constructs an SSH URL that can be used to push to GitHub. */
export function buildSshUrl(
githubHost: string,
organizationName: string,
projectName: string,
githubPort?: string,
): string {
if (githubPort) {
return `ssh://git@${githubHost}:${githubPort}/${organizationName}/${projectName}.git`;
}
return `git@${githubHost}:${organizationName}/${projectName}.git`;
}
/** Constructs an HTTP URL that can be used to push to GitHub. */
export function buildHttpsUrl(
gitCredentials: string,
githubHost: string,
organizationName: string,
projectName: string,
githubPort?: string,
): string {
if (githubPort) {
return `https://${gitCredentials}@${githubHost}:${githubPort}/${organizationName}/${projectName}.git`;
}
return `https://${gitCredentials}@${githubHost}/${organizationName}/${projectName}.git`;
}
/**
* Whether the current URL is an SSH protocol. In addition to looking for
* `ssh:`, it will also allow protocol-less URLs like
* `git@github.com:facebook/docusaurus.git`.
*/
export function hasSSHProtocol(sourceRepoUrl: string): boolean {
try {
if (new URL(sourceRepoUrl).protocol === 'ssh:') {
return true;
}
return false;
} catch {
// Fails when there isn't a protocol
return /^(?:[\w-]+@)?[\w.-]+:[\w./-]+/.test(sourceRepoUrl);
}
}