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 (Shift Tab 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'
+ ]
+]));