iWahbe / Custom Links in Emacs's Org Mode

Custom Links in Emacs's Org Mode

All my notes live in Emacs' org-mode. Lot's of my notes are about GitHub. I found many instances of https://github.com/pulumi/registry/issues/12345 in my notes1, mostly hand typed. This was long2, both to type and to more importantly to read. Link descriptions are the preferred way to deal with this in org-mode, but they require even more typing. They look like this:

[[https://example.com][EXAMPLE]

And render like this:

EXAMPLE

That's a lot of typing. We can do better. GitHub issues are often abbreviated as <org>/<repo>#<issue-number>, and GitHub is often abbreviated as GH. I want to be able to type out GitHub issue links like this:

gh:pulumi/registry#12345

In the rest of this post, I'll explain the full scope of what we are trying to do, and how do accomplish that goal in only 65 lines of Emacs Lisp.

The task

We should introduce a new link type3 (gh) that is easy to write. Links written with our new link type should open correctly (C-o), export correctly and generally work as expected. This is the minimum requirement for this to be useful.

We also have a bonus task: Links should be able to generate their own description. That means going from gh:rust-lang/rust#31844 to Tracking issue for specialization (RFC 1210) #31844 when desired. This isn't required, but it makes our gh link type more useful.

Implementation

The first thing we need to do is define a new link type. On modern Emacs, we do that with org-link-set-parameters4:

(org-link-set-parameters "gh" :follow #'org-gh--follow)

This tells org-mode that gh is a valid link type, and that org-gh--follow should be invoked when you try to open it. The code for org-gh--follow is straight-forward:

(defun org-gh--follow (link _)
  (org-link-open-from-string (org-gh--expand link)))

It relies on some helper functions that we will use throughout this code:

(defun org-gh--expand (link)
  (apply #'format "https://www.github.com/%s/issues/%s" (org-gh--parse link)))

(defun org-gh--parse (link)
  "Parse a gh: LINK of the form gh:org/repo#issue into (org/repo . issue)."
  (let ((parts (string-split (string-remove-prefix "gh:" link) "#" t)))
    (unless (length= parts 2)
      (error "Invalid gh: link: %s" link))
    parts))

We now have working link highlighting and following:

Show that Ctrl-O works for our links

This works up until you try to export your gh link to another format. It doesn't export well:

LanguageOutput
HTML<a href="gh:a/b#1">gh:a/b#1</a>
Markdown<gh:a/b#1>

We need to tell org-mode how to export our link type:

(org-link-set-parameters "gh" :export #'org-gh--export)

(defun org-gh--export (link desc backend info)
  (if-let ((transcode-link (alist-get 'link (org-export-backend-transcoders
                                         (org-export-get-backend backend)))))
      (let ((link (org-element-create
                   'link (list :type "https"
	                       :path (org-gh--expand link)))))
        (funcall transcode-link link desc info))
    (concat "gh:" link)))

Let me explain how this works.5 When org-mode exports anything, it exports to a backend. Each backend defines a series of transcoders, which org-mode calls as appropriate on the parsed tree it is exporting. In org-gh--export, we get the transocder used for links, and then fabricate a synthetic HTTPS style link to invoke the transocder on. This allows us to avoid needing to define a separate behavior for each backend type. We don't want to need to update our gh link type for each backend that org-mode could export to.

These 27 lines are enough to accomplish the basic task we set out to complete.

  • Typing gh:org/repo#issue highlights as a link.
  • Calling Ctrl-O (org-open-at-point) takes us to the issues page.
  • Exporting custom links correctly exports https links.

I believe that this is enough for a useful extension to org-mode. It adds a new link type, inheriting most behavior correctly from the https: link type.

Bonus - Self-generating descriptions

Now that we have our own link type, we can do cool things with it. Let's make gh links automatically default their description to the issue or pull request's title.

Self-generating title demo

We can do this through the :insert-description link parameter:

(org-link-set-parameters "gh" :insert-description #'org-gh--insert-description)

This allows us to give a default description for gh type links. For a default description, we use <org>/<repo>#<number>: <title>. It's a bit verbose, but very explicit.

(defun org-gh--insert-description (link description)
  (or description
      (when-let ((title (apply #'org-gh--get-issue-title (org-gh--parse link))))
       (format "%s: %s" (string-remove-prefix "gh:" link) title))))

The wrapping (or description means that if there is already a description for the link, we leave that as is. Then we simply fetch the title and past it in. org-gh--get-issue-title just invokes the gh CLI tool to get the title of a (repository,issue number) pair as our default description:

(defun org-gh--get-issue-title (repo issue)
  (when-let ((gh (executable-find "gh")))
    (with-temp-buffer
      (unless (equal 0 (call-process
                        gh nil t nil
                        "issue" "view" issue
                        "--repo" repo
                        "--json" "title"))
        (user-error "Failed to get title from GH: %s"
                    (progn (goto-char (point-min))
                           (buffer-string))))
      (goto-char (point-min))
      (alist-get 'title (json-parse-buffer :object-type 'alist)))))

That's all you need to get default title descriptions.

Conclusion

I hope you have learned how to add custom link types to org-mode through this example. I hope this gives you some confidence in customizing Emacs yourself. You can find the complete code from this article on GitHub.

1

I work at Pulumi. Pulumi repos come up a lot.

2

Comparitivly, this whole post is about a workflow micro-optimization.

3

In org-mode, link types are defined by their prefix, seperated by a :.

4

org-link-abbrev-alist doesn't work for raw links, so we don't want to use it. A raw link is a link of the form gh:example/repo#1234, instead of [[gh:example/repo#1234]].

5

Getting this to work took me way to long.