Categories: geek

RSS - Atom - Subscribe via email

Getting a Google Docs draft ready for Mailchimp via Emacs and Org Mode

| emacs, org

I've been volunteering to help with the Bike Brigade newsletter. I like that there are people who are out there helping improve food security by delivering food bank hampers to recipients. Collecting information for the newsletter also helps me feel more appreciation for the lively Toronto biking scene, even though I still can't make it out to most events. The general workflow is:

  1. collect info
  2. draft the newsletter somewhere other volunteers can give feedback on
  3. convert the newsletter to Mailchimp
  4. send a test message
  5. make any edits requested
  6. schedule the email campaign

We have the Mailchimp Essentials plan, so I can't just export HTML for the whole newsletter. Someday I should experiment with services that might let me generate the whole newsletter from Emacs. That would be neat. Anyway, with Mailchimp's block-based editor, at least I can paste in HTML code for the text/buttons. That way, I don't have to change colours or define links by hand.

The logistics volunteers coordinate via Slack, so a Slack Canvas seemed like a good way to draft the newsletter. I've previously written about my workflow for copying blocks from a Slack Canvas and then using Emacs to transform the rich text, including recolouring the links in the section with light text on a dark background. However, copying rich text from a Slack Canvas turned out to be unreliable. Sometimes it would copy what I wanted, and sometimes nothing would get copied. There was no way to export HTML from the Slack Canvas, either.

I switched to using Google Docs for the drafts. It was a little less convenient to add items from Slack messages and I couldn't easily right-click to download the images that I pasted in. It was more reliable in terms of copying, but only if I used xclip to save the clipboard into a file instead of trying to do the whole thing in memory.

I finally got to spend a little time automating a new workflow. This time I exported the Google Doc as a zip that had the HTML file and all the images in a subdirectory. The HTML source is not very pleasant to work with. It has lots of extra markup I don't need. Here's what an entry looks like:

2025-09-17_09-22-35.png
Figure 1: Exported HTML for an entry

Things I wanted to do with the HTML:

  • Remove the google.com/url redirection for the links. Mailchimp will add its own redirection for click-tracking, but at least the links can look simpler when I paste them in.
  • Remove all the extra classes and styles.
  • Turn [ call to action ] into fancier Mailchimp buttons.

Also, instead of transforming one block at a time, I decided to make an Org Mode document with all the different blocks I needed. That way, I could copy and paste things in quick succession.

Here's what the result looks like. It makes a table of contents, adds the sign-up block, and adds the different links and blocks I need to paste into Mailchimp.

2025-09-17_10-03-27.png
Figure 2: Screenshot of newsletter Org file with blocks for easy copying

I need to copy and paste the image filenames into the upload dialog on Mailchimp, so I use my custom Org Mode link type for copying to the clipboard. For the HTML code, I use #+begin_src html ... #+end_src instead of #+begin_export html ... #+end_export so that I can use Embark and embark-org to quickly copy the contents of the source block. (That doesn't work for export blocks yet.) I have C-. bound to embark-act, the source block is detected by the functions that embark-org.el added to embark-target-finders, and the c binding in embark-org-src-block-map calls embark-org-copy-block-contents. So all I need to do is C-. c in a block to copy its contents.

Here's the code to process the newsletter draft
(defun my-brigade-process-latest-newsletter-draft (date)
  "Create an Org file with the HTML for different blocks."
  (interactive (list (if current-prefix-arg (org-read-date nil t nil "Date: ")
                       (org-read-date nil t "+Sun"))))
  (when (stringp date) (setq date (date-to-time date)))
  (let ((default-directory "~/Downloads/newsletter")
        file
        dom
        sections)
    (call-process "unzip" nil nil nil "-o" (my-latest-file "~/Downloads" "\\.zip$"))
    (setq file (my-latest-file default-directory))
    (with-temp-buffer
      (insert-file-contents-literally file)
      (goto-char (point-min))
      (my-transform-html '(my-brigade-save-newsletter-images) (buffer-string))
      (setq dom (my-brigade-simplify-html (libxml-parse-html-region (point-min) (point-max))))
      (setq sections
            (my-html-group-by-tag
             'h1
             (dom-children
              (dom-by-tag
               dom'body)))))
    (with-current-buffer (get-buffer-create "*newsletter*")
      (erase-buffer)
      (org-mode)
      (insert
       (format-time-string "%B %-e, %Y" date) "\n"
       "* In this e-mail\n#+begin_src html\n"
       "<p>Hi Bike Brigaders! Here’s what's happening this week, with quick signup links. In this e-mail:</p>"
       (my-transform-html
        '(my-brigade-remove-meta-recursively
          my-brigade-just-headings)
        (copy-tree dom))
       "\n#+end_src\n\n")
      (insert "* Sign-up block\n\n#+begin_src html\n"
              (my-brigade-copy-signup-block date)
              "\n#+end_src\n\n")
      (dolist (sec '("Bike Brigade" "In our community"))
        (insert "* " sec "\n"
                (mapconcat
                 (lambda (group)
                   (let* ((item (apply 'dom-node 'div nil
                                       (append
                                        (list (dom-node 'h2 nil (car group)))
                                        (cdr group))))
                          (image (my-brigade-image (car group))))
                     (format "** %s\n\n%s\n%s\n\n#+begin_src html\n%s\n#+end_src\n\n"
                             (car group)
                             (if image (org-link-make-string (concat "copy:" image)) "")
                             (or (my-html-last-link-href item) "")
                             (my-transform-html
                              (delq nil
                                    (list
                                     'my-transform-html-remove-images
                                     'my-transform-html-remove-italics
                                     'my-brigade-simplify-html
                                     'my-brigade-format-buttons
                                     (when (string= sec "In our community")
                                       'my-brigade-recolor-recursively)))
                              item))))
                 (my-html-group-by-tag 'h2 (cdr (assoc sec sections 'string=)))
                 "")))
      (insert "* Other updates\n"
              (format "#+begin_src html\n%s\n#+end_src\n\n"
                      (my-transform-html
                       '(my-transform-html-remove-images
                         my-transform-html-remove-italics
                         my-brigade-simplify-html)
                       (car (cdr (assoc "Other updates" sections 'string=))))))
      (goto-char (point-min))
      (display-buffer (current-buffer)))))

(defun my-html-group-by-tag (tag dom-list)
  "Use TAG to divide DOM-LIST into sections. Return an alist of (section . children)."
  (let (section-name current-section results)
    (dolist (node dom-list)
      (if (and (eq (dom-tag node) tag)
               (not (string= (string-trim (dom-texts node)) "")))
          (progn
            (when current-section
              (push (cons section-name (nreverse current-section))  results)
              (setq current-section nil))
            (setq section-name (string-trim (dom-texts node))))
        (when section-name
          (push node current-section))))
    (when current-section
      (push (cons section-name (reverse current-section))  results)
      (setq current-section nil))
    (nreverse results)))

(defun my-html-last-link-href (node)
  "Return the last link HREF in NODE."
  (dom-attr (car (last (dom-by-tag node 'a))) 'href))

(defun my-brigade-image (heading)
  "Find the latest image related to HEADING."
  (car
   (nreverse
    (directory-files my-brigade-newsletter-images-directory
                        t (regexp-quote (my-brigade-newsletter-heading-to-image-file-name heading))))))

Some of the functions it uses are in my config, particularly the section on Transforming HTML clipboard contents with Emacs to smooth out Mailchimp annoyances: dates, images, comments, colours.

Along the way, I learned that svg-print is a good way to turn document object models back into HTML.

When I saw two more events and one additional link that I wanted to include, I was glad I already had this code sorted out. It made it easy to paste the images and details into the Google Doc, reformat it slightly, and get the info through the process so that it ended up in the newsletter with a usefully-named image and correctly-coloured links.

I think this is a good combination of Google Docs for getting other people's feedback and letting them edit, and Org Mode for keeping myself sane as I turn it into whatever Mailchimp wants.

My next step for improving this workflow might be to check out other e-mail providers in case I can get Emacs to make the whole template. That way, I don't have to keep switching between applications and using the mouse to duplicate blocks and edit the code.

This is part of my Emacs configuration.
View org source for this post

2025-09-15 Emacs news

| emacs, emacs-news

There were lots of Emacs-related discussions on Hacker News, mainly because of two posts about Emacs's extensibility: an Org Mode example and a completion example. This guide on connecting to databases from Org Mode Babel blocks started a few discussions. There were a couple of Emacs Carnival posts on obscure packages, too. Also, if you want to present at EmacsConf 2025 (great way to meet like-minded folks), please send your proposal in by this Friday (Sept 19). Thanks!

Links from reddit.com/r/emacs, r/orgmode, r/spacemacs, r/planetemacs, Mastodon #emacs, Bluesky #emacs, Hacker News, lobste.rs, programming.dev, lemmy.world, lemmy.ml, planet.emacslife.com, YouTube, the Emacs NEWS file, Emacs Calendar, and emacs-devel. Thanks to Andrés Ramírez for emacs-devel links. Do you have an Emacs-related link or announcement? Please e-mail me at sacha@sachachua.com. Thank you!

View org source for this post

Emacs and dom.el: quick notes on parsing HTML and turning DOMs back into HTML

| elisp

libxml-parse-html-region turns HTML into a DOM (document object model). There's also xml-parse-file and xml-parse-region. xml-parse-string actually parses the character data at point and returns it as a string instead of parsing a string as a parameter. If you have a string and you want to parse it, insert it into a temporary buffer and use libxml-parse-html-region or xml-parse-region.

(let ((s "<span>Hello world</span>")
      dom)
  (setq dom
        (with-temp-buffer
          (insert s)
          (libxml-parse-html-region))))
(html nil (body nil (span nil Hello world)))

Then you can use functions like dom-by-tag, dom-search, dom-attr, dom-children, etc. If you need to make a deep copy of the DOM, you can use copy-tree.

Turning the DOM back into HTML can be a little tricky. By default, dom-print escapes & in attributes, which could mess up things like href:

  (with-temp-buffer
    (dom-print (dom-node 'a '((href . "https://example.com?a=b&c=d"))))
     (buffer-string))
  <a href="https://example.com?a=b&amp;c=d" />

shr-dom-print handles & correctly, but it adds spaces in between elements. Also, you need to escape HTML entities in text, maybe with org-html-encode-plain-text.

  (with-temp-buffer
    (shr-dom-print
      (dom-node 'p nil
                (dom-node 'span nil "hello")
                (dom-node 'span nil "world")
                (dom-node 'a '((href . "https://example.com?a=b&c=d"))
                          (org-html-encode-plain-text "text & stuff"))))
    (buffer-string))
  <p> <span>hello</span> <span>world</span> <a href="https://example.com?a=b&c=d">text &amp; stuff</a></p>

svg-print does the right thing when it comes to href and tags, but you need to escape HTML entities yourself as usual.

(with-temp-buffer
  (svg-print
   (dom-node 'p nil
             (dom-node 'span nil "hello")
             (dom-node 'span nil "world")
             (dom-node 'a '((href . "https://example.com?a=b&c=d"))
                       (org-html-encode-plain-text "text & stuff"))))
  (buffer-string))
  <p><span>hello</span><span>world</span><a href="https://example.com?a=b&c=d">text &amp; stuff</a></p>

Looks like I'll be using svg-print for more than just SVGs.

Relevant Emacs info pages:

View org source for this post

2025-09-08 Emacs news

| emacs, emacs-news

There was lots of conversation around Why Rewriting Emacs Is Hard this week, with threads on Reddit, HN, and lobste.rs. Also, if you want to present at EmacsConf 2025 (great way to meet like-minded folks), please send your proposal in by next Friday (Sept 19). Thanks!

Links from reddit.com/r/emacs, r/orgmode, r/spacemacs, r/planetemacs, Mastodon #emacs, Bluesky #emacs, Hacker News, lobste.rs, programming.dev, lemmy.world, lemmy.ml, planet.emacslife.com, YouTube, the Emacs NEWS file, Emacs Calendar, and emacs-devel. Thanks to Andrés Ramírez for emacs-devel links. Do you have an Emacs-related link or announcement? Please e-mail me at sacha@sachachua.com. Thank you!

View org source for this post

2025-09-01 Emacs news

| emacs, emacs-news

People continue to share interesting Emacs elevator pitches. The Emacs Carnival September theme is "Obscure packages." Recommend a hidden gem!

Links from reddit.com/r/emacs, r/orgmode, r/spacemacs, r/planetemacs, Mastodon #emacs, Bluesky #emacs, Hacker News, lobste.rs, programming.dev, lemmy.world, lemmy.ml, planet.emacslife.com, YouTube, the Emacs NEWS file, Emacs Calendar, and emacs-devel. Thanks to Andrés Ramírez for emacs-devel links. Do you have an Emacs-related link or announcement? Please e-mail me at sacha@sachachua.com. Thank you!

View org source for this post

Nginx maps: How I redirect sketch IDs to blog posts or sketches

| geek, tech

Assumed audience: I'm mostly writing these notes for myself, because I'd forgotten how this actually works. Maybe it might be interesting for people who also like identifying their posts or sketches and who use Nginx.

Since August 2022, I've been identifying sketches with YYYY-MM-DD-NN, where NN is a number derived from my journaling system. I used to use just YYYY-MM-DD and the title, but sometimes I renamed sketches. Then I used YYYY-MM-DDa (ex: 2022-08-01f). Sometimes optical character recognition had a hard time distinguishing my hand-drawn "d" from "a", though. Numbers were generally more reliable. And besides, that way, there's room for growth in case I have more than 26 journal entries or sketches in a day. (Not that I've gone anywhere near that volume.)

Anyway, I want to have short URLs like sachachua.com/2025-07-31-10 or sach.ac/2025-07-31-10 go to a blog post if one's available, or just the sketch if I haven't written about it yet, like sach.ac/2025-01-16-01. Here's how I do it.

To publish my site, I export HTML files from Org Mode and I use the 11ty static site generator to put them together in a blog.

One of my 11ty files is map.11ty.cjs, which has the following code:

map.11ty.cjs
module.exports = class AllPosts {
  data() {
    return {
      permalink: '/conf/nginx.map',
      eleventyExcludeFromCollections: true
    };
  }
  async render (data) {
    let list = data.collections._posts;
    let cats = data.siteCategoriesWithParents;
    let nested = cats.filter(o => o.parent);
    let hash = {};
    await list.reduce(async (prev, o) => {
      await prev;
      if (o.data?.zid) {
        hash[o.data.zid] = o.url;
      }
      let matches = (await o.template.inputContent).matchAll(/{% +sketchFull +\"([0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9](-[0-9][0-9]|[a-z]))?/g);
      for (const m of matches) {
        if (m[1] && !hash[m[1]]) {
          hash[m[1]] = o.url;
        }
      }
    });
    let byZIDs = Object.entries(hash).map((o) => `/${o[0]} ${o[1]};\n`).join('');
    let byBlogID = list.filter(o => o.data.id).map((o) => `/blog/p/${o.data.id} ${o.url};`).join("\n") + "\n";
    let nestedCategories = nested.map((o) => {
      let slugPath = o.parentPath.map((p) => p.slug).join('/') + '/' + o.slug;
      let catPage = `/blog/category/${slugPath} /blog/category/${o.slug};\n`;
      let catFeed = `/blog/category/${slugPath}/feed/ /blog/category/${o.slug}/feed/;\n`;
      let catAtom = `/blog/category/${slugPath}/feed/atom/ /blog/category/${o.slug}/feed/atom/;\n`;
      return catAtom + catFeed + catPage;
    }).join('');
    const result = byBlogID + nestedCategories + byZIDs;
    return result;
  }
};

The code analyzes each of my blog posts and looks for full-sized sketches. If it finds a full-size sketch, it writes a rule that redirects that ID to that blog post. This is what part of the resulting nginx.map looks like:

/2025-07-31-10 /blog/2025/08/monthly-review-july-2025/;
/2025-08-04-01 /blog/2025/08/turning-42-life-as-a-41-year-old/;
/2025-08-03-08 /blog/2025/08/turning-42-life-as-a-41-year-old/;
/2025-08-31-01 /blog/2025/08/emacs-elevator-pitch-tinkerers-unite/;

In my Nginx web server config (nginx.conf), I have:

http {
  # ...
  include /etc/nginx/redirections.map;
  # ...
}

And that redirections.map uses the map module and looks like this:

map $request_uri $new_uri {
   default "";
   include /var/www/static-blog/conf/nginx.map;
   / /blog;
   # ...
}

Then in my Nginx site configuration, I redirect to that $new_uri if available. As a fallback, I also detect yyyy-mm-dd-nn URLs and redirect them to my sketchbook.

server {
   # ... other rules
   if ($new_uri) {
       return 302 $new_uri;
   }
   location ~ ^/([0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9](a-z|-[0-9][0-9])?)$ {
      return 302 https://sketches.sachachua.com/$1;
   }
}

The Node app that runs sketches.sachachua.com searches by ID if it gets one. Here's the code in the Express router:

router.get('/:filename', controller.serveImagePageByFilename);

which calls this controller:

exports.serveImagePageByFilename = async (req, res) => {
  if (req.params.filename) {
    let f = await findImageByFilename(req.params.filename);
    if (f) {
      renderSingle(req, res, f);
    } else {
      res.sendStatus(404);
    }
  } else {
    res.sendStatus(404);
  }
};

which uses this function:

const findImageByFilename = (filename) => {
  return getSketches().then((sketches) => {
    let id = filename.match(/^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]([a-z]|-[0-9][0-9])/);
    let m = filename.match(/^[^#]+/);
    let file = sketches.find((x) => path.parse(x.filename).name == filename);
    if (!file) {
      file = sketches.find((x) => x.filename.startsWith(id));
    }
    if (!file && m) {
      file = sketches.find((x) => x.filename.startsWith(m[0]));
    }
    if (!file) {
      file = sketches.find((x) => path.parse(x.filename).name.replace(' #.*') == filename);
    }
    if (!file) {
      file = sketches.find((x) => x.filename.startsWith(filename));
    }
    return file;
  });
};

(getSketches just reads the JSON I've saved.)

So my workflow for a public sketch is:

  1. Draw the sketch.
  2. In my journaling system, write the title and tags for the sketch. Get an ID.
  3. Write the ID on the sketch.
  4. Export the sketch from the iPad as a JPEG.
  5. Wait for things to sync in the background.
  6. Process the sketch. I can use my-image-rename-current-image-based-on-id to rename it manually, or I can use my-sketch-process to recognize the ID and process the text. (My Emacs configuration)
  7. Wait for things to sync in the background. In the meantime, edit the text version of the sketch.
  8. Reload my public sketches, which regenerates the sketches.json that lists all the sketches.
  9. Use my-write-about-sketch or my-insert-sketch-and-text to include the sketch and the text in a draft blog post. (My Emacs configuration) Do my usual blogging process.
View org source for this post

Emacs elevator pitch: tinkerers unite

| emacs, community

This is for the Emacs Carnival 2025-08: Your Elevator Pitch for Emacs hosted by Jeremy Friesen. Emacs is a text editor, but people have made it so much more.

Text and links from sketch

Emacs elevator pitch https://sach.ac/2025-08-31-01

That's the theme for the August Emacs Carnival.

I don't spend much time in elevators these days, and I didn't talk much to strangers even during the before-times.

So let's imagine this is more of, say, a meetup. (Someday I'll get back to going to those.) Could be tech, could be something else. Could be online, could be in person.

I don't have to convince everyone. I don't even have to convince a single person. My goal is to listen for the tinkerers: the ones who like to ask "Why?" and "What if?" and who try things out. They're interesting.

I can find them by:

  • watching talks (sketchnotes are a great thank-you gift)
  • eavesdropping or asking questions
  • sharing what I'm tinkering with

No: Why would anyone do that? Yes: Have you thought of trying xyz?

For tinkerers, the juice might be worth the squeeze. Emacs can be challenging, but it can also pay off. It can even be fun.

Even when I find a fellow tinkerer, the conversation isn't "Have you tried Emacs? You should try Emacs." It'll probably be more like:

"I'd love to keep hearing about your experiments. Do you have something I can subscribe to or follow?" (Side-quest: Try to convince them to blog.)

and then the conversation can unfold over time:

  • "Ooh, I like that idea. Here's my take on it."
  • "How did you do that? What's that?!"
  • "Oh, yeah, Emacs. It's very programmable, so I can get it to do all sorts of stuff for me. Wanna see?"

…and sometimes they fall into the rabbit hole themselves, as tinkerers often do. But even if they don't try Emacs (or don't stick with it), cross-pollination is great. And sometimes Emacs changes their life.

To get a sense of the kinds of things someone has gotten Emacs to do, check out Alvaro Ramirez's post. I have a list like that at emacs. On the topic of cross-pollination, I like Jeremy Friesen's EmacsConf 2023 talk on Mentoring VS-Coders as an Emacsian (or How to show not tell people about the wonders of Emacs).

It's always fun to come across a fellow tinkerer. I love seeing what people come up with. Emacs works out really well for tinkerers. It's not just about taking advantage of the technical capabilities (and you can do a surprising amount with text, images, and interaction), it's also about being part of a great community that's in it long-term. Good stuff.

Feel free to use this sketch under the Creative Commons Attribution License.

View org source for this post