diff --git a/readme.md b/readme.md index d89e67e98b15..806ab8d8fdda 100644 --- a/readme.md +++ b/readme.md @@ -44,6 +44,7 @@ GitHub Enterprise is also supported by [authorizing your own domain in the optio - [Adds search filter for 'Everything commented by you'](https://cloud.githubusercontent.com/assets/940070/25518367/cb917d3e-2c36-11e7-8475-c4e6dbe0ed6c.png) - [Moves destructive buttons ("Close issue", "Cancel") in commenting forms away from primary button](#comment-box) - [Adds `Yours` button to Issues/Pull Requests page](https://cloud.githubusercontent.com/assets/1282980/14636384/0d8770e4-0623-11e6-8520-2054bece2771.png) +- [Condenses long URLs into references like _user/repo/.file@`d71718d`_](https://user-images.githubusercontent.com/1402241/27252232-8fdf8ed0-538b-11e7-8f19-12d317c9cd32.png) - Easier copy-pasting from diffs by making +/- signs unselectable - Shows the reactions popover on hover instead of click - Supports indenting with the tab key in textareas like the comment box (ShiftTab for original behavior) diff --git a/src/content.js b/src/content.js index f086f82370aa..909fefdd1cbf 100644 --- a/src/content.js +++ b/src/content.js @@ -16,6 +16,7 @@ import showRealNames from './libs/show-names'; import filePathCopyBtnListner from './libs/copy-file-path'; import addFileCopyButton from './libs/copy-file'; import linkifyCode, {editTextNodes} from './libs/linkify-urls-in-code'; +import shortenLinks from './libs/shorten-links'; import * as icons from './libs/icons'; import * as pageDetect from './libs/page-detect'; @@ -552,6 +553,7 @@ function init() { addCompareTab(); removeProjectsTab(); addTitleToEmojis(); + shortenLinks(); diffFileHeader.destroy(); enableCopyOnY.destroy(); diff --git a/src/libs/shorten-links.js b/src/libs/shorten-links.js new file mode 100644 index 000000000000..2cb278664b27 --- /dev/null +++ b/src/libs/shorten-links.js @@ -0,0 +1,178 @@ +import select from 'select-dom'; +import {getRepoURL} from './page-detect'; + +const patchDiffRegex = /[.](patch|diff)$/; +const releaseRegex = /releases[/]tag[/]([^/]+)/; +const labelRegex = /labels[/]([^/]+)/; +const releaseArchiveRegex = /archive[/](.+)([.]zip|[.]tar[.]gz)/; +const releaseDownloadRegex = /releases[/]download[/]([^/]+)[/](.+)/; + +const reservedPaths = [ + 'join', + 'site', + 'blog', + 'about', + 'login', + 'pulls', + 'search', + 'issues', + 'explore', + 'contact', + 'pricing', + 'trending', + 'settings', + 'features', + 'business', + 'personal', + 'security', + 'dashboard', + 'showcases', + 'open-source', + 'marketplace' +]; + +const styleRevision = revision => { + if (!revision) { + return; + } + revision = revision.replace(patchDiffRegex, ''); + if (/^[0-9a-f]{40}$/.test(revision)) { + revision = revision.substr(0, 7); + } + return `${revision}`; +}; + +// Filter out null values +const joinValues = (array, delimiter = '/') => { + return array.filter(s => s).join(delimiter); +}; + +export function shortenUrl(href) { + /** + * Parse URL + */ + const { + origin, + pathname, + search, + hash + } = new URL(href); + + const isRaw = [ + 'https://raw.githubusercontent.com', + 'https://cdn.rawgit.com', + 'https://rawgit.com' + ].includes(origin); + + let [ + user, + repo, + type, + revision, + ...filePath + ] = pathname.substr(1).split('/'); + + if (isRaw) { + [ + user, + repo, + // Raw URLs don't have `blob` here + revision, + ...filePath + ] = pathname.substr(1).split('/'); + type = 'raw'; + } + + revision = styleRevision(revision); + filePath = filePath.join('/'); + + const isLocal = origin === location.origin; + const isThisRepo = (isLocal || isRaw) && getRepoURL() === `${user}/${repo}`; + const isReserved = reservedPaths.includes(user); + const [, diffOrPatch] = pathname.match(patchDiffRegex) || []; + const [, release] = pathname.match(releaseRegex) || []; + const [, releaseTag, releaseTagExt] = pathname.match(releaseArchiveRegex) || []; + const [, downloadTag, downloadFilename] = pathname.match(releaseDownloadRegex) || []; + const [, label] = pathname.match(labelRegex) || []; + const isFileOrDir = revision && [ + 'raw', + 'tree', + 'blob', + 'blame', + 'commits' + ].includes(type); + + const repoUrl = isThisRepo ? '' : `${user}/${repo}`; + + /** + * Shorten URL + */ + + if (isReserved || (!isLocal && !isRaw)) { + return href + .replace(/^https:[/][/]/, '') + .replace(/^www[.]/, '') + .replace(/[/]$/, ''); + } + + if (user && !repo) { + return `@${user}${search}${hash}`; + } + + if (isFileOrDir) { + const file = `${repoUrl}${filePath ? '/' + filePath : ''}`; + const revisioned = joinValues([file, revision], '@'); + const partial = `${revisioned}${search}${hash}`; + if (type !== 'blob' && type !== 'tree') { + return `${partial} (${type})`; + } + return partial; + } + + if (diffOrPatch) { + const partial = joinValues([repoUrl, revision], '@'); + return `${partial}.${diffOrPatch}${search}${hash}`; + } + + if (release) { + const partial = joinValues([repoUrl, `${release}`], '@'); + return `${partial}${search}${hash} (release)`; + } + + if (releaseTagExt) { + const partial = joinValues([repoUrl, `${releaseTag}`], '@'); + return `${partial}${releaseTagExt}${search}${hash}`; + } + + if (downloadFilename) { + const partial = joinValues([repoUrl, `${downloadTag}`], '@'); + return `${partial} ${downloadFilename}${search}${hash} (download)`; + } + + if (label) { + return joinValues([repoUrl, label]) + `${search}${hash} (label)`; + } + + // Drop leading and trailing slash of relative path + return `${pathname.replace(/^[/]|[/]$/g, '')}${search}${hash}`; +} + +export default () => { + for (const a of select.all('a[href]')) { + // Don't change if it was already customized + // .href automatically adds a / to naked origins + // so that needs to be tested too + if (a.href !== a.textContent && a.href !== `${a.textContent}/`) { + continue; + } + + const shortened = shortenUrl(a.href); + + // Don't touch the dom if there's nothing to change + if (shortened === a.textContent) { + continue; + } + + a.innerHTML = shortened; + } +}; diff --git a/test/fixtures/window.js b/test/fixtures/window.js index c7e0cfb2b16a..5d9c9de1f0cd 100644 --- a/test/fixtures/window.js +++ b/test/fixtures/window.js @@ -1,20 +1,7 @@ -const url = require('url'); +const URL = require('url').URL; -function WindowMock(initialURI) { - this._currentURI = initialURI; +function WindowMock(initialURI = 'https://github.com') { + this.location = new URL(initialURI); } -WindowMock.prototype.location = { - set href(uri) { - const uriParts = url.parse(uri); - this.hostname = uriParts.hostname; - this.pathname = uriParts.pathname; - this._currentURI = uri; - }, - - get href() { - return this._currentURI; - } -}; - module.exports = WindowMock; diff --git a/test/shorten-links.js b/test/shorten-links.js new file mode 100644 index 000000000000..13617c48264c --- /dev/null +++ b/test/shorten-links.js @@ -0,0 +1,357 @@ +import {URL} from 'url'; +import test from 'ava'; +import {shortenUrl} from '../src/libs/shorten-links'; +import Window from './fixtures/window'; + +global.URL = URL; +global.window = new Window('https://github.com/sindresorhus/refined-github/pull/473'); +global.location = window.location; + +function urlMatcherMacro(t, shouldMatch = []) { + for (const [originalUrl, expectedShortenedUrl] of shouldMatch) { + t.is(shortenUrl(originalUrl), expectedShortenedUrl); + } +} + +test('shortenUrl', urlMatcherMacro, new Map([ + [ + 'https://github.com/sindresorhus/refined-github/', + 'sindresorhus/refined-github' + ], + [ + 'https://github.com/sindresorhus/refined-github/tree/v0.12', + 'v0.12' + ], + [ + 'https://github.com/sindresorhus/refined-github/tree/d71718db6aa4feb8dc10edbad1134472468e971a', + 'd71718d' + ], + [ + 'https://github.com/nodejs/node/', + 'nodejs/node' + ], + [ + 'https://github.com/nodejs/node/tree/v0.12', + 'nodejs/node@v0.12' + ], + [ + 'https://github.com/nodejs/node/tree/d71718db6aa4feb8dc10edbad1134472468e971a', + 'nodejs/node@d71718d' + ], + [ + 'https://github.com/sindresorhus/refined-github/tree/master/doc', + '/doc@master' + ], + [ + 'https://github.com/sindresorhus/refined-github/tree/v0.12/doc', + '/doc@v0.12' + ], + [ + 'https://github.com/sindresorhus/refined-github/tree/d71718db6aa4feb8dc10edbad1134472468e971a/doc', + '/doc@d71718d' + ], + [ + 'https://github.com/nodejs/node/tree/master/doc', + 'nodejs/node/doc@master' + ], + [ + 'https://github.com/nodejs/node/tree/v0.12/doc', + 'nodejs/node/doc@v0.12' + ], + [ + 'https://github.com/nodejs/node/tree/d71718db6aa4feb8dc10edbad1134472468e971a/doc', + 'nodejs/node/doc@d71718d' + ], + [ + 'https://github.com/sindresorhus/refined-github/blob/master/.gitignore', + '/.gitignore@master' + ], + [ + 'https://github.com/sindresorhus/refined-github/blob/v0.12/.gitignore', + '/.gitignore@v0.12' + ], + [ + 'https://github.com/sindresorhus/refined-github/blob/cc8fc46/.gitignore', + '/.gitignore@cc8fc46' + ], + [ + 'https://github.com/nodejs/node/blob/master/.gitignore', + 'nodejs/node/.gitignore@master' + ], + [ + 'https://github.com/nodejs/node/blob/v0.12/.gitignore', + 'nodejs/node/.gitignore@v0.12' + ], + [ + 'https://github.com/nodejs/node/blob/cc8fc46/.gitignore', + 'nodejs/node/.gitignore@cc8fc46' + ], + [ + 'https://github.com/sindresorhus/refined-github/blame/master/.gitignore', + '/.gitignore@master (blame)' + ], + [ + 'https://github.com/sindresorhus/refined-github/blame/v0.12/.gitignore', + '/.gitignore@v0.12 (blame)' + ], + [ + 'https://github.com/sindresorhus/refined-github/blame/cc8fc46/.gitignore', + '/.gitignore@cc8fc46 (blame)' + ], + [ + 'https://github.com/nodejs/node/blame/master/.gitignore', + 'nodejs/node/.gitignore@master (blame)' + ], + [ + 'https://github.com/nodejs/node/blame/v0.12/.gitignore', + 'nodejs/node/.gitignore@v0.12 (blame)' + ], + [ + 'https://github.com/nodejs/node/blame/cc8fc46/.gitignore', + 'nodejs/node/.gitignore@cc8fc46 (blame)' + ], + [ + 'https://github.com/sindresorhus/refined-github/commits/master/.gitignore', + '/.gitignore@master (commits)' + ], + [ + 'https://github.com/sindresorhus/refined-github/commits/v0.12/.gitignore', + '/.gitignore@v0.12 (commits)' + ], + [ + 'https://github.com/sindresorhus/refined-github/commits/cc8fc46/.gitignore', + '/.gitignore@cc8fc46 (commits)' + ], + [ + 'https://github.com/nodejs/node/commits/master/.gitignore', + 'nodejs/node/.gitignore@master (commits)' + ], + [ + 'https://github.com/nodejs/node/commits/v0.12/.gitignore', + 'nodejs/node/.gitignore@v0.12 (commits)' + ], + [ + 'https://github.com/nodejs/node/commits/cc8fc46/.gitignore', + 'nodejs/node/.gitignore@cc8fc46 (commits)' + ], + [ + 'https://github.com/sindresorhus/refined-github/commit/cc8fc46.diff', + 'cc8fc46.diff' + ], + [ + 'https://github.com/sindresorhus/refined-github/commit/cc8fc46.patch', + 'cc8fc46.patch' + ], + [ + 'https://github.com/nodejs/node/commit/cc8fc46.diff', + 'nodejs/node@cc8fc46.diff' + ], + [ + 'https://github.com/nodejs/node/commit/cc8fc46.patch', + 'nodejs/node@cc8fc46.patch' + ], + [ + 'https://github.com/sindresorhus/refined-github/releases/tag/v0.12.0', + 'v0.12.0 (release)' + ], + [ + 'https://github.com/nodejs/node/releases/tag/v0.12.0', + 'nodejs/node@v0.12.0 (release)' + ], + [ + 'https://github.com/sindresorhus/refined-github/milestone/25', + 'sindresorhus/refined-github/milestone/25' + ], + [ + 'https://github.com/nodejs/node/milestone/25', + 'nodejs/node/milestone/25' + ], + [ + 'https://github.com/sindresorhus/refined-github/labels/npm', + 'npm (label)' + ], + [ + 'https://github.com/nodejs/node/labels/npm', + 'nodejs/node/npm (label)' + ], + [ + 'https://github.com/sindresorhus/refined-github/archive/6.4.1.zip', + '6.4.1.zip' + ], + [ + 'https://github.com/sindresorhus/refined-github/releases/download/6.4.1/now-macos', + '6.4.1 now-macos (download)' + ], + [ + 'https://github.com/zeit/now-cli/archive/6.4.1.zip', + 'zeit/now-cli@6.4.1.zip' + ], + [ + 'https://github.com/zeit/now-cli/releases/download/6.4.1/now-macos', + 'zeit/now-cli@6.4.1 now-macos (download)' + ], + [ + 'https://github.com/sindresorhus/refined-github/wiki', + 'sindresorhus/refined-github/wiki' + ], + [ + 'https://github.com/sindresorhus/refined-github/pulse', + 'sindresorhus/refined-github/pulse' + ], + [ + 'https://github.com/sindresorhus/refined-github/labels', + 'sindresorhus/refined-github/labels' + ], + [ + 'https://github.com/sindresorhus/refined-github/network', + 'sindresorhus/refined-github/network' + ], + [ + 'https://github.com/sindresorhus/refined-github/projects', + 'sindresorhus/refined-github/projects' + ], + [ + 'https://github.com/sindresorhus/refined-github/releases', + 'sindresorhus/refined-github/releases' + ], + [ + 'https://github.com/sindresorhus/refined-github/milestones', + 'sindresorhus/refined-github/milestones' + ], + [ + 'https://github.com/sindresorhus/refined-github/contributors', + 'sindresorhus/refined-github/contributors' + ], + [ + 'https://github.com/nodejs/node/wiki', + 'nodejs/node/wiki' + ], + [ + 'https://github.com/nodejs/node/pulse', + 'nodejs/node/pulse' + ], + [ + 'https://github.com/nodejs/node/labels', + 'nodejs/node/labels' + ], + [ + 'https://github.com/nodejs/node/network', + 'nodejs/node/network' + ], + [ + 'https://github.com/nodejs/node/projects', + 'nodejs/node/projects' + ], + [ + 'https://github.com/nodejs/node/releases', + 'nodejs/node/releases' + ], + [ + 'https://github.com/nodejs/node/milestones', + 'nodejs/node/milestones' + ], + [ + 'https://github.com/nodejs/node/contributors', + 'nodejs/node/contributors' + ], + [ + 'https://github.com/nodejs/node/graphs/commit-activity', + 'nodejs/node/graphs/commit-activity' + ], + [ + 'https://rawgit.com/sindresorhus/refined-github/master/.gitignore', + '/.gitignore@master (raw)' + ], + [ + 'https://cdn.rawgit.com/sindresorhus/refined-github/v0.12/.gitignore', + '/.gitignore@v0.12 (raw)' + ], + [ + 'https://cdn.rawgit.com/sindresorhus/refined-github/d71718db/.gitignore', + '/.gitignore@d71718db (raw)' + ], + [ + 'https://raw.githubusercontent.com/sindresorhus/refined-github/master/.gitignore', + '/.gitignore@master (raw)' + ], + [ + 'https://raw.githubusercontent.com/sindresorhus/refined-github/v0.12/.gitignore', + '/.gitignore@v0.12 (raw)' + ], + [ + 'https://raw.githubusercontent.com/sindresorhus/refined-github/d71718db/.gitignore', + '/.gitignore@d71718db (raw)' + ], + [ + 'https://rawgit.com/nodejs/node/master/.gitignore', + 'nodejs/node/.gitignore@master (raw)' + ], + [ + 'https://cdn.rawgit.com/nodejs/node/v0.12/.gitignore', + 'nodejs/node/.gitignore@v0.12 (raw)' + ], + [ + 'https://cdn.rawgit.com/nodejs/node/d71718db/.gitignore', + 'nodejs/node/.gitignore@d71718db (raw)' + ], + [ + 'https://raw.githubusercontent.com/nodejs/node/master/.gitignore', + 'nodejs/node/.gitignore@master (raw)' + ], + [ + 'https://raw.githubusercontent.com/nodejs/node/v0.12/.gitignore', + 'nodejs/node/.gitignore@v0.12 (raw)' + ], + [ + 'https://raw.githubusercontent.com/nodejs/node/d71718db/.gitignore', + 'nodejs/node/.gitignore@d71718db (raw)' + ], + [ + 'https://github.com/sindresorhus', + '@sindresorhus' + ], + [ + 'https://github.com/nodejs', + '@nodejs' + ], + [ + 'https://github.com/pulls', + 'github.com/pulls' + ], + [ + 'https://github.com/issues', + 'github.com/issues' + ], + [ + 'https://github.com/trending', + 'github.com/trending' + ], + [ + 'https://github.com/features', + 'github.com/features' + ], + [ + 'https://github.com/marketplace', + 'github.com/marketplace' + ], + [ + 'https://github.com/trending/developers', + 'github.com/trending/developers' + ], + [ + 'https://github.com/settings/profile', + 'github.com/settings/profile' + ], + [ + 'https://www.npmjs.com/', + 'npmjs.com' + ], + [ + 'https://www.npmjs.com/package/node', + 'npmjs.com/package/node' + ], + [ + 'https://example.com/nodejs/node/blob/cc8fc46/.gitignore', + 'example.com/nodejs/node/blob/cc8fc46/.gitignore' + ] +]));