Using an Emacs Lisp macro to define quick custom Org Mode links to project files; plus URLs and search

| org, emacs, coding
  • [2024-01-12 Fri] Added embark action to copy the exported link URL.
  • [2024-01-11 Thu] Switched to using Github links since Codeberg's down.
  • [2024-01-11 Thu] Updated my-copy-link to just return the link if called from Emacs Lisp. Fix getting the properties.
  • [2024-01-08 Mon] Add tip from Omar about embark-around-action-hooks
  • [2024-01-08 Mon] Simplify code by using consult--grep-position

Summary (882 words): Emacs macros make it easy to define sets of related functions for custom Org links. This makes it easier to link to projects and export or copy the links to the files in the web-based repos. You can also use that information to consult-ripgrep across lots of projects.

I'd like to get better at writing notes while coding and at turning those notes into blog posts and videos. I want to be able to link to files in projects easily with the ability to complete, follow, and export links. For example, [[subed:subed.el]] should become subed.el, which opens the file if I'm in Emacs and exports a link if I'm publishing a post. I've been making custom link types using org-link-set-parameters. I think it's time to make a macro that defines that set of functions for me. Emacs Lisp macros are a great way to write code to write code.

(defvar my-project-web-base-list nil "Local path . web repo URLs for easy linking.")

(defmacro my-org-project-link (type file-path git-url)
  `(progn
     (defun ,(intern (format "my-org-%s-complete" type)) ()
       ,(format "Complete a file from %s." type)
       (concat ,type ":" (completing-read "File: "
                                          (projectile-project-files ,file-path))))
     (defun ,(intern (format "my-org-%s-follow" type)) (link _)
       ,(format "Open a file from %s." type)
       (find-file
        (expand-file-name
         link
         ,file-path)))
     (defun ,(intern (format "my-org-%s-export" type)) (link desc format _)
       "Export link to file."
       (setq desc (or desc link))
       (when ,git-url
         (setq link (concat ,git-url (replace-regexp-in-string "^/" "" link))))
       (pcase format
         ((or 'html '11ty) (format "<a href=\"%s\">%s</a>"
                                   link
                                   (or desc link)))
         ('md (if desc (format "[%s](%s)" desc link)
                (format "<%s>" link)))
         ('latex (format "\\href{%s}{%s}" link desc))
         ('texinfo (format "@uref{%s,%s}" link desc))
         ('ascii (format "%s (%s)" desc link))
         (_ (format "%s (%s)" desc link))))
     (with-eval-after-load 'org
       (org-link-set-parameters
        ,type
        :complete (quote ,(intern (format "my-org-%s-complete" type)))
        :export (quote ,(intern (format "my-org-%s-export" type)))
        :follow (quote ,(intern (format "my-org-%s-follow" type))))
       (cl-pushnew (cons (expand-file-name ,file-path) ,git-url)
                   my-project-web-base-list
                   :test 'equal))))

Then I can define projects this way:

(my-org-project-link "subed"
                     "~/proj/subed/subed/"
                     "https://github.com/sachac/subed/blob/main/subed/"
                     ;; "https://codeberg.org/sachac/subed/src/branch/main/subed/"
                     )
(my-org-project-link "emacsconf-el"
                     "~/proj/emacsconf/lisp/"
                     "https://git.emacsconf.org/emacsconf-el/tree/")
(my-org-project-link "subed-record"
                     "~/proj/subed-record/"
                     "https://github.com/sachac/subed-record/blob/main/"
                     ;; "https://codeberg.org/sachac/subed-record/src/branch/main/"
                     )
(my-org-project-link "compile-media"
                     "~/proj/compile-media/"
                     "https://github.com/sachac/compile-media/blob/main/"
                     ;; "https://codeberg.org/sachac/compile-media/src/branch/main/"
                     )
(my-org-project-link "ox-11ty"
                     "~/proj/ox-11ty/"
                     "https://github.com/sachac/ox-11ty/blob/master/")

And I can complete them with the usual C-c C-l (org-insert-link) process:

completing-custom-links.gif
Figure 1: Completing a custom link with org-insert-link

Sketches are handled by my Org Mode sketch links, but we can add them anyway.

(cl-pushnew (cons (expand-file-name "~/sync/sketches/") "https://sketches.sachachua.com/filename/")
            my-project-web-base-list
            :test 'equal)

I've been really liking being able to refer to various emacsconf-el files by just selecting the link type and completing the filename, so maybe it'll be easier to write about lots of other stuff if I extend that to my other projects.

Quickly search my code

Since my-project-web-base-list is a list of projects I often think about or write about, I can also make something that searches through them. That way, I don't have to care about where my code is.

(defun my-consult-ripgrep-code ()
  (interactive)
  (consult-ripgrep (mapcar 'car my-project-web-base-list)))

I can add .rgignore files in directories to tell ripgrep to ignore things like node_modules or *.json.

I also want to search my Emacs configuration at the same time, although links to my config are handled by my dotemacs link type so I'll leave the URL as nil. This is also the way I can handle other unpublished directories.

(cl-pushnew (cons (expand-file-name "~/sync/emacs/Sacha.org") nil)
            my-project-web-base-list
            :test 'equal)
(cl-pushnew (cons (expand-file-name "~/proj/static-blog/_includes") nil)
            my-project-web-base-list
            :test 'equal)
(cl-pushnew (cons (expand-file-name "~/bin") nil)
            my-project-web-base-list
            :test 'equal)

Actually, let's throw my blog posts and Org files in there as well, since I often have code snippets. If it gets to be too much, I can always have different commands search different things.

(cl-pushnew (cons (expand-file-name "~/proj/static-blog/blog/") "https://sachachua.com/blog/")
            my-project-web-base-list
            :test 'equal)
(cl-pushnew (cons (expand-file-name "~/sync/orgzly") nil)
            my-project-web-base-list
            :test 'equal)
ripgrep-code.gif
Figure 2: Using my-consult-ripgrep-code

I don't have anything bound to M-s c (code) yet, so let's try that.

(keymap-global-set "M-s c" #'my-consult-ripgrep-code)

At some point, it might be fun to get Embark set up so that I can grab a link to something right from the consult-ripgrep interface. In the meantime, I can always jump to it and get the link.

Tip from Omar: embark-around-action-hooks

[2024-01-07 Sun] I modified oantolin's suggestion from the comments to work with consult-ripgrep, since consult-ripgrep gives me consult-grep targets instead of consult-location:

(cl-defun embark-consult--at-location (&rest args &key target type run &allow-other-keys)
  "RUN action at the target location."
  (save-window-excursion
    (save-excursion
      (save-restriction
        (pcase type
          ('consult-location (consult--jump (consult--get-location target)))
          ('org-heading (org-goto-marker-or-bmk (get-text-property 0 'org-marker target)))
          ('consult-grep (consult--jump (consult--grep-position target)))
          ('file (find-file target)))
        (apply run args)))))

(cl-pushnew #'embark-consult--at-location (alist-get 'org-store-link embark-around-action-hooks))

I think I can use it with M-s c to search for the code, then C-. C-c l on the matching line, where C-c l is my regular keybinding for storing links. Thanks, Omar!

In general, I don't want to have to think about where something is on my laptop or where it's published on the Web, I just want to

View org source for this post
This is part of my Emacs configuration.
You can comment with Disqus or you can e-mail me at sacha@sachachua.com.