#+TITLE: Sacha Chua's Emacs configuration #+ELEVENTY_COLLECTIONS: _posts #+ELEVENTY_BASE_DIR: ~/proj/static-blog/ #+ELEVENTY_CATEGORIES+: emacs #+ELEVENTY_LAYOUT: layouts/post #+ELEVENTY_BASE_URL: https://sachachua.com * Using an Emacs Lisp macro to define quick custom Org Mode links to project files; plus URLs and search :org:emacs:coding:embark: :PROPERTIES: :CUSTOM_ID: git-projects :EXPORT_DATE: 2024-01-07T08:07:09-0500 :EXPORT_ELEVENTY_PERMALINK: /blog/2024/01/using-an-emacs-lisp-macro-to-define-quick-custom-org-mode-links-to-project-files/ :EXPORT_ELEVENTY_FILE_NAME: blog/2024/01/using-an-emacs-lisp-macro-to-define-quick-custom-org-mode-links-to-project-files/ :EXPORT_ELEVENTY_WORDS: 885 :EXPORT_MODIFIED: 2025-03-27T13:36:43-0400 :END: #+begin_update - [2025-03-27]: 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~ #+end_update #+begin_summary 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. #+end_summary 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: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. #+NAME: org-project-link #+begin_src emacs-lisp (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 "%s" 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))) #+end_src Then I can define projects this way: #+begin_src emacs-lisp (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/")) #+end_src #+RESULTS: :results: ((/home/sacha/proj/ox-11ty/ . https://github.com/sachac/ox-11ty/blob/master/) (/home/sacha/proj/compile-media/ . https://codeberg.org/sachac/compile-media/src/branch/main/) (/home/sacha/proj/subed-record/ . https://codeberg.org/sachac/subed-record/src/branch/main/) (/home/sacha/proj/emacsconf/lisp/ . https://git.emacsconf.org/emacsconf-el/tree/) (/home/sacha/proj/subed/subed/ . https://codeberg.org/sachac/subed/src/branch/main/subed/)) :end: And I can complete them with the usual ~C-c C-l~ (~org-insert-link~) process: #+BEGIN_COMMENT Demonstrate completion to subed-vtt #+END_COMMENT #+CAPTION: Completing a custom link with ~org-insert-link~ [[file:images/completing-custom-links.gif]] Sketches are handled by [[dotemacs:org-mode-sketch-links][my Org Mode sketch links]], but we can add them anyway. #+begin_src emacs-lisp (cl-pushnew (cons (expand-file-name "~/sync/sketches/") "https://sketches.sachachua.com/filename/") my-project-web-base-list :test 'equal) #+end_src 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 :PROPERTIES: :CUSTOM_ID: web-link :EXPORT_MODIFIED: 2024-01-20T07:44:20-0500 :END: #+begin_update - [2024-01-20]: Fix Wayback link handling. - [2024-01-19]: Add Wayback machine. #+end_update 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: [[https://sachachua.com][my blog]], repositories on [[https://github.com/sachac/][Github]] and [[https://codeberg.org/sachac/][Codeberg]], [[https://sketches.sachachua.com][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: #+begin_src emacs-lisp (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."))) #+end_src 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. #+begin_src emacs-lisp (defun my-org-link-as-url (link) "Return the final URL for LINK." (cond ((string-match "^/" link) (concat my-blog-base-url (replace-regexp-in-string "^/" "" link))) ((string-match "^https://" link) link) (t (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 _) (interactive (list (org-element-property :raw-link (org-element-context)))) (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 e" #'my-embark-replace-link-with-exported-url)) (list embark-url-map embark-org-link-map embark-org-link-copy-map))) #+end_src 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. #+begin_src emacs-lisp (defun my-consult-ripgrep-code () (interactive) (consult-ripgrep (mapcar 'car my-project-web-base-list))) #+end_src 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 [[dotemacs:links-to-my-config][my dotemacs link type]] so I'll leave the URL as nil. This is also the way I can handle other unpublished directories. #+begin_src emacs-lisp (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) #+end_src 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. #+begin_src emacs-lisp (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) #+end_src #+BEGIN_COMMENT Demonstrate [[elisp:my-consult-ripgrep-code]] for defun file duration #+END_COMMENT #+CAPTION: Using my-consult-ripgrep-code [[file:images/ripgrep-code.gif]] I don't have anything bound to ~M-s c~ (code) yet, so let's try that. #+begin_src emacs-lisp (keymap-global-set "M-s c" #'my-consult-ripgrep-code) #+end_src 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~: #+begin_src emacs-lisp (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)) #+end_src 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!