Last active
August 13, 2026 13:05
-
-
Save Munksgaard/bbfe9902eacadaf8d4a7c405df338356 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name HexDocs repository and package links | |
| // @namespace https://hexdocs.pm/ | |
| // @version 1.1.0 | |
| // @description Adds repository and Hex.pm links and keyboard shortcuts to HexDocs. | |
| // @match https://hexdocs.pm/* | |
| // @match https://*.hexdocs.pm/* | |
| // @connect hex.pm | |
| // @grant GM_openInTab | |
| // @grant GM_setClipboard | |
| // @grant GM_xmlhttpRequest | |
| // @run-at document-idle | |
| // ==/UserScript== | |
| (() => { | |
| "use strict"; | |
| const REPOSITORY_LINK_ID = "hexdocs-repository-link"; | |
| const HEX_LINK_ID = "hexdocs-package-link"; | |
| const REPOSITORY_HOSTS = new Set([ | |
| "github.com", | |
| "gitlab.com", | |
| "bitbucket.org", | |
| "codeberg.org", | |
| "sourcehut.org", | |
| "git.sr.ht", | |
| ]); | |
| function repositoryFromSourceUrl(value) { | |
| try { | |
| const url = new URL(value); | |
| const parts = url.pathname.split("/").filter(Boolean); | |
| let repositoryParts; | |
| switch (url.hostname) { | |
| case "github.com": | |
| repositoryParts = parts.slice(0, 2); | |
| break; | |
| case "gitlab.com": { | |
| const marker = parts.indexOf("-"); | |
| repositoryParts = | |
| marker > 1 ? parts.slice(0, marker) : null; | |
| break; | |
| } | |
| case "bitbucket.org": | |
| case "codeberg.org": | |
| case "sourcehut.org": | |
| case "git.sr.ht": | |
| repositoryParts = parts.slice(0, 2); | |
| break; | |
| default: | |
| return null; | |
| } | |
| if (!repositoryParts || repositoryParts.length < 2) { | |
| return null; | |
| } | |
| repositoryParts[repositoryParts.length - 1] = | |
| repositoryParts.at(-1).replace(/\.git$/, ""); | |
| return `${url.origin}/${repositoryParts.join("/")}`; | |
| } catch { | |
| return null; | |
| } | |
| } | |
| function repositoryFromMetadata(value) { | |
| const repositoryUrl = repositoryFromSourceUrl(value); | |
| if (repositoryUrl) { | |
| return repositoryUrl; | |
| } | |
| try { | |
| const url = new URL(value); | |
| if (!REPOSITORY_HOSTS.has(url.hostname)) { | |
| return null; | |
| } | |
| url.hash = ""; | |
| url.search = ""; | |
| url.pathname = url.pathname | |
| .replace(/\.git\/?$/, "") | |
| .replace(/\/$/, ""); | |
| return url.toString(); | |
| } catch { | |
| return null; | |
| } | |
| } | |
| function repositoryFromPage() { | |
| const sourceLink = [...document.links].find( | |
| link => | |
| link.title === "View Source" && | |
| repositoryFromSourceUrl(link.href), | |
| ); | |
| return sourceLink | |
| ? repositoryFromSourceUrl(sourceLink.href) | |
| : null; | |
| } | |
| function packageName() { | |
| if (location.hostname === "hexdocs.pm") { | |
| return ( | |
| location.pathname.split("/").filter(Boolean)[0] || | |
| null | |
| ); | |
| } | |
| const suffix = ".hexdocs.pm"; | |
| return location.hostname.endsWith(suffix) | |
| ? location.hostname | |
| .slice(0, -suffix.length) | |
| .replaceAll("-", "_") | |
| : null; | |
| } | |
| function repositoryFromHex(callback) { | |
| const packageId = packageName(); | |
| if (!packageId) { | |
| return; | |
| } | |
| GM_xmlhttpRequest({ | |
| method: "GET", | |
| url: `https://hex.pm/api/packages/${encodeURIComponent(packageId)}`, | |
| onload(response) { | |
| if ( | |
| response.status < 200 || | |
| response.status >= 300 | |
| ) { | |
| return; | |
| } | |
| try { | |
| const links = | |
| JSON.parse(response.responseText).meta?.links || | |
| {}; | |
| const entries = Object.entries(links); | |
| const preferred = entries.find(([name]) => | |
| /github|gitlab|bitbucket|codeberg|source|repository/i.test( | |
| name, | |
| ), | |
| ); | |
| const hosted = entries.find(([, value]) => { | |
| try { | |
| return REPOSITORY_HOSTS.has( | |
| new URL(value).hostname, | |
| ); | |
| } catch { | |
| return false; | |
| } | |
| }); | |
| const value = (preferred || hosted || [])[1]; | |
| const repositoryUrl = | |
| repositoryFromMetadata(value); | |
| if (repositoryUrl) { | |
| callback(repositoryUrl); | |
| } | |
| } catch { | |
| // A malformed Hex API response should not affect the docs. | |
| } | |
| }, | |
| }); | |
| } | |
| function showToast(message) { | |
| document | |
| .getElementById("hexdocs-link-toast") | |
| ?.remove(); | |
| const toast = document.createElement("div"); | |
| toast.id = "hexdocs-link-toast"; | |
| toast.setAttribute("role", "status"); | |
| toast.textContent = message; | |
| document.body.append(toast); | |
| setTimeout(() => toast.remove(), 1800); | |
| } | |
| function addStyles() { | |
| if ( | |
| document.getElementById("hexdocs-link-styles") | |
| ) { | |
| return; | |
| } | |
| const style = document.createElement("style"); | |
| style.id = "hexdocs-link-styles"; | |
| style.textContent = ` | |
| #${REPOSITORY_LINK_ID}, | |
| #${HEX_LINK_ID} { | |
| align-items: center; | |
| border: 1px solid currentColor; | |
| border-radius: 0.4rem; | |
| color: inherit; | |
| display: inline-flex; | |
| flex: 0 0 auto; | |
| font-size: 0.8rem; | |
| gap: 0.35rem; | |
| line-height: 1; | |
| opacity: 0.8; | |
| padding: 0.4rem 0.55rem; | |
| text-decoration: none; | |
| } | |
| #${REPOSITORY_LINK_ID}:hover, | |
| #${REPOSITORY_LINK_ID}:focus-visible, | |
| #${HEX_LINK_ID}:hover, | |
| #${HEX_LINK_ID}:focus-visible { | |
| opacity: 1; | |
| } | |
| #${REPOSITORY_LINK_ID} svg, | |
| #${HEX_LINK_ID} svg { | |
| fill: none; | |
| height: 1rem; | |
| stroke: currentColor; | |
| stroke-linecap: round; | |
| stroke-linejoin: round; | |
| stroke-width: 1.8; | |
| width: 1rem; | |
| } | |
| #hexdocs-link-toast { | |
| background: #1f2937; | |
| border-radius: 0.4rem; | |
| bottom: 1rem; | |
| box-shadow: 0 0.25rem 1rem rgb(0 0 0 / 25%); | |
| color: white; | |
| font: 0.875rem/1.2 system-ui, sans-serif; | |
| left: 50%; | |
| padding: 0.65rem 0.9rem; | |
| position: fixed; | |
| transform: translateX(-50%); | |
| z-index: 10000; | |
| } | |
| @media (max-width: 640px) { | |
| #${REPOSITORY_LINK_ID} span, | |
| #${HEX_LINK_ID} span { | |
| display: none; | |
| } | |
| } | |
| `; | |
| document.head.append(style); | |
| } | |
| function createLink({ | |
| id, | |
| url, | |
| label, | |
| shortcut, | |
| icon, | |
| }) { | |
| const link = document.createElement("a"); | |
| link.id = id; | |
| link.href = url; | |
| link.target = "_blank"; | |
| link.rel = "noopener noreferrer"; | |
| link.title = | |
| `Open ${label} (${shortcut}): ${url}`; | |
| link.setAttribute( | |
| "aria-label", | |
| `Open ${label} (shortcut: ${shortcut})`, | |
| ); | |
| link.innerHTML = `${icon}<span>${label}</span>`; | |
| return link; | |
| } | |
| function install(repositoryUrl = null) { | |
| const navbar = | |
| document.querySelector(".search-settings"); | |
| const packageId = packageName(); | |
| if (!navbar || !packageId) { | |
| return; | |
| } | |
| addStyles(); | |
| const hexUrl = | |
| `https://hex.pm/packages/${encodeURIComponent(packageId)}`; | |
| let hexLink = | |
| document.getElementById(HEX_LINK_ID); | |
| if (!hexLink) { | |
| hexLink = createLink({ | |
| id: HEX_LINK_ID, | |
| url: hexUrl, | |
| label: "Hex.pm", | |
| shortcut: "h g", | |
| icon: ` | |
| <svg viewBox="0 0 24 24" aria-hidden="true"> | |
| <path d="m12 2.5 8 4.75v9.5l-8 4.75-8-4.75v-9.5L12 2.5Z" /> | |
| <path d="M8.5 8.5v7M15.5 8.5v7M8.5 12h7" /> | |
| </svg> | |
| `, | |
| }); | |
| const settingsButton = | |
| navbar.querySelector(".display-settings"); | |
| if (settingsButton) { | |
| settingsButton.before(hexLink); | |
| } else { | |
| navbar.append(hexLink); | |
| } | |
| } | |
| if ( | |
| repositoryUrl && | |
| !document.getElementById(REPOSITORY_LINK_ID) | |
| ) { | |
| const repositoryLink = createLink({ | |
| id: REPOSITORY_LINK_ID, | |
| url: repositoryUrl, | |
| label: "Repository", | |
| shortcut: "r g", | |
| icon: ` | |
| <svg viewBox="0 0 24 24" aria-hidden="true"> | |
| <path d="M6 3.5h10.5A1.5 1.5 0 0 1 18 5v14.5H7.5A2.5 2.5 0 0 1 5 17V5a1.5 1.5 0 0 1 1-1.5Z" /> | |
| <path d="M7.5 16.5H18M9 7h5" /> | |
| </svg> | |
| `, | |
| }); | |
| hexLink.before(repositoryLink); | |
| } | |
| if ( | |
| document.body.dataset | |
| .hexdocsLinkShortcutsInstalled | |
| ) { | |
| return; | |
| } | |
| document.body.dataset | |
| .hexdocsLinkShortcutsInstalled = "true"; | |
| let shortcutPrefix = null; | |
| let shortcutStartedAt = 0; | |
| document.addEventListener( | |
| "keydown", | |
| event => { | |
| const target = event.target; | |
| const isEditing = | |
| target instanceof Element && | |
| ( | |
| target.matches( | |
| "input, textarea, select", | |
| ) || | |
| target.isContentEditable | |
| ); | |
| if ( | |
| isEditing || | |
| event.ctrlKey || | |
| event.metaKey || | |
| event.altKey || | |
| event.shiftKey | |
| ) { | |
| return; | |
| } | |
| const key = event.key.toLowerCase(); | |
| const now = Date.now(); | |
| const links = { | |
| r: { | |
| element: document.getElementById( | |
| REPOSITORY_LINK_ID, | |
| ), | |
| name: "Repository", | |
| }, | |
| h: { | |
| element: | |
| document.getElementById(HEX_LINK_ID), | |
| name: "Hex.pm", | |
| }, | |
| }; | |
| if (links[key]?.element) { | |
| shortcutPrefix = key; | |
| shortcutStartedAt = now; | |
| return; | |
| } | |
| const destination = | |
| now - shortcutStartedAt < 1200 | |
| ? links[shortcutPrefix] | |
| : null; | |
| shortcutPrefix = null; | |
| shortcutStartedAt = 0; | |
| if (!destination?.element) { | |
| return; | |
| } | |
| if (key === "g") { | |
| event.preventDefault(); | |
| event.stopPropagation(); | |
| GM_openInTab(destination.element.href, { | |
| active: true, | |
| insert: true, | |
| }); | |
| } else if (key === "c") { | |
| event.preventDefault(); | |
| event.stopPropagation(); | |
| GM_setClipboard( | |
| destination.element.href, | |
| "text", | |
| ); | |
| showToast( | |
| `${destination.name} URL copied`, | |
| ); | |
| } | |
| }, | |
| true, | |
| ); | |
| } | |
| const repositoryUrl = repositoryFromPage(); | |
| install(repositoryUrl); | |
| if (!repositoryUrl) { | |
| repositoryFromHex(install); | |
| } | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment