Using an Emacs Lisp macro to define quick custom Org Mode links to project files; plus URLs and search
| org, emacs, codingC-. r
(my-embark-replace-link-with-exported-url
)
: Added function for replacing current link, bound to - Added embark action to copy the exported link URL.
- Switched to using Github links since Codeberg's down.
- Updated my-copy-link to just return the link if called from Emacs Lisp. Fix getting the properties.
embark-around-action-hooks
Add tip from Omar about consult--grep-position
Simplify code by using
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:
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.
Copy web link
- : Fix Wayback link handling.
- : Add Wayback machine.
Keeping a list of projects and their web versions also makes it easier
for me to get the URL for something. I try to post as much as possible
on the Web so that it's easier for me to find things again and so that
other people can pick up ideas from my notes. Things are a bit
scattered: my blog, repositories on Github and Codeberg, my
sketches… I don't want to think about where the code has ended
up, I just want to grab the URL. If I'm going to put the link into an
Org Mode document, that's super easy. I just take advantage of the
things I've added to org-store-link
. If I'm going to put it into an
e-mail or a toot or wherever else, I just want the bare URL.
I can think of two ways to approach this. One is a command that copies just the URL by figuring it out from the buffer filename, which allows me to special-case a bunch of things:
(defun my-copy-link (&optional filename skip-links) "Return the URL of this file. If FILENAME is non-nil, use that instead. If SKIP-LINKS is non-nil, skip custom links. If we're in a Dired buffer, use the file at point." (interactive) (setq filename (or filename (if (derived-mode-p 'dired-mode) (dired-get-filename)) (buffer-file-name))) (if-let* ((project-re (concat "\\(" (regexp-opt (mapcar 'car my-project-web-base-list)) "\\)" "\\(.*\\)")) (url (cond ((and (derived-mode-p 'org-mode) (eq (org-element-type (org-element-context)) 'link) (not skip-links)) (pcase (org-element-property :type (org-element-context)) ((or "https" "http") (org-element-property :raw-link (org-element-context))) ("yt" (org-element-property :path (org-element-context))) ;; if it's a custom link, visit it and get the link (_ (save-window-excursion (org-open-at-point) (my-copy-link nil t))))) ;; links to my config usually have a CUSTOM_ID property ((string= (buffer-file-name) (expand-file-name "~/sync/emacs/Sacha.org")) (concat "https://sachachua.com/dotemacs#" (org-entry-get-with-inheritance "CUSTOM_ID"))) ;; blog post drafts have permalinks ((and (derived-mode-p 'org-mode) (org-entry-get-with-inheritance "EXPORT_ELEVENTY_PERMALINK")) (concat "https://sachachua.com" (org-entry-get-with-inheritance "EXPORT_ELEVENTY_PERMALINK"))) ;; some projects have web repos ((string-match project-re filename) (concat (assoc-default (match-string 1 filename) my-project-web-base-list) (url-hexify-string (match-string 2 filename))))))) (progn (when (called-interactively-p 'any) (kill-new url) (message "%s" url)) url) (error "Couldn't figure out URL.")))
Another approach is to hitch a ride on the Org Mode link storage and
export functions and just grab the URL from whatever link I've stored
with org-store-link
, which I've bound to C-c l
. I almost always
have an HTML version of the exported link. We can even use XML parsing
instead of regular expressions.
(defun my-org-link-as-url (link) "Return the final URL for LINK." (dom-attr (dom-by-tag (with-temp-buffer (insert (org-export-string-as link 'html t)) (xml-parse-region (point-min) (point-max))) 'a) 'href)) (defun my-org-stored-link-as-url (&optional link insert) "Copy the stored link as a plain URL. If LINK is specified, use that instead." (interactive (list nil current-prefix-arg)) (setq link (or link (caar org-stored-links))) (let ((url (if link (my-org-link-as-url link) (error "No stored link")))) (when (called-interactively-p 'any) (if url (if insert (insert url) (kill-new url)) (error "Could not find URL."))) url)) (ert-deftest my-org-stored-link-as-url () (should (string= (my-org-stored-link-as-url "[[dotemacs:web-link]]") "https://sachachua.com/dotemacs#web-link")) (should (string= (my-org-stored-link-as-url "[[dotemacs:org-mode-sketch-links][my Org Mode sketch links]]") "https://sachachua.com/dotemacs#org-mode-sketch-links"))) (defun my-embark-org-copy-exported-url-as-wayback (link &rest _) (interactive "MLink: ") (let ((url (my-embark-org-copy-exported-url link))) (when (not (string-match (regexp-quote "^https://web.archive.org") url)) (setq url (concat "https://web.archive.org/web/" (format-time-string "%Y%m%d%H%M%S/") url))) (when (called-interactively-p 'any) (kill-new url) (message "Copied %s" url)) url)) (defun my-embark-org-copy-exported-url (link &rest _) (interactive "MLink: \np") (let ((url (my-org-link-as-url link))) (when (and (derived-mode-p 'org-mode) (org-entry-get-with-inheritance "EXPORT_ELEVENTY_PERMALINK") (string-match "^/" url)) ;; local file links are copied to blog directories (setq url (concat "https://sachachua.com" (org-entry-get-with-inheritance "EXPORT_ELEVENTY_PERMALINK") (replace-regexp-in-string "[\\?&].*" "" (file-name-nondirectory link))))) (when (called-interactively-p 'any) (kill-new url) (message "Copied %s" url)) url)) (defun my-embark-replace-link-with-exported-url (link &rest _) (my-insert-or-replace-link (my-org-link-as-url link))) (with-eval-after-load 'embark-org (mapc (lambda (map) (keymap-set map "u" #'my-embark-org-copy-exported-url) (keymap-set map "U" #'my-embark-org-copy-exported-url-as-wayback) (keymap-set map "r" #'my-embark-replace-link-with-exported-url)) (list embark-url-map embark-org-link-map embark-org-link-copy-map)))
We'll see which one I end up using. I think both approaches might come in handy.
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)
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
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!