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

Posted: - Modified: | org, emacs, coding, embark
  • [2025-03-27 Thu]: Use my-org-project- as the prefix to avoid collisions.
  • [2024-09-19 Thu]: Added function for replacing current link, bound to C-. r (my-embark-replace-link-with-exported-url)
  • [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-project-%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-project-%s-follow" type)) (link _)
       ,(format "Open a file from %s." type)
       (find-file
        (expand-file-name
         link
         ,file-path)))
     (defun ,(intern (format "my-org-project-%s-export" type)) (link desc format _)
       "Export link to file."
       (setq desc (or desc link))
       (when (and ,git-url link)
         (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))))
     (org-link-set-parameters
        ,type
        :complete (quote ,(intern (format "my-org-project-%s-complete" type)))
        :export (quote ,(intern (format "my-org-project-%s-export" type)))
        :follow (quote ,(intern (format "my-org-project-%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:

(with-eval-after-load 'org
(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/")
(my-org-project-link "11ty"
                     "~/proj/static-blog/"
                     "https://github.com/sachac/eleventy-blog-setup/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 write about it. One step closer, yay Emacs!

This is part of my Emacs configuration.
View org source for this post
You can view 6 comments or e-mail me at sacha@sachachua.com.

6 comments

it might be fun to get Embark set up so that I can grab a link to something right from the consult-ripgrep interface

Hi Sacha! If you mean to use org-store-link for grabbing those links, all you need is an Embark action hook to jump to the location before org-store-link runs, like this:

 
(cl-defun embark-consult--at-location (&rest args &key target type run &allow-other-keys)
"For a TARGET of TYPE `consult-location', RUN action at the target location."
(when (eq type 'consult-location)
(save-excursion
(consult--jump (car (consult--get-location target)))
(apply run args))))

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

Thank you very much for this tip and for Embark itself! =) I think I've figured out how to get the code to work with consult-grep by very clumsily getting the location out of it, since it didn't return anything for consult--get-location. Now I can store links by calling Embark and then org-store-link, yay!

It's odd that you needed to do something different: I tested that code with consult-grep. Maybe it's a version problem with either consult or embark?

Sorry for the weirdness. I'm trying it now with emacs -Q, consult
from the latest commit (bcb4f43f13db7772db838fa2615c44df80057689) and
embark from the latest commit (b9f2b3b9a5b9c72cf1416097b9941c4f275dae94), testing with the regular M-x consult-ripgrep. The underlying consult--grep has category 'consult-grep, not consult-location (as is used in consult-outline, consult-mark, consult-global-mark, consult-line, etc.) and doesn't have a consult-location property, so I think I still need to handle it separately. I was able to simplify it to ('consult-grep (consult--jump (consult--grep-position target))), though, so that feels better. =)

You're absolutely right! I'm not sure how I managed to convince myself the category was consult-location. Sorry about that and I'm glad you could figure things out regardless of my misleading advice!

No worries, I very much appreciate the time you took to suggest it and follow up, and I got to learn a little more about consult and embark. :) Looking forward to using those all over the place. I've just added some Embark actions for OCRing images and auto-transcribing audio, and I've copied the suggested whichkey configuration for a more compact display. Whee!