Compare commits

..
3 Commits
Author SHA1 Message Date
Firehose Bot 855bb37065 remove todo
can't find John's post on linkedin
2026-10-08 13:56:01 +01:00
Firehose Bot 1cd2d062a3 emacs lisp script to turn org-roam notes into blog posts
with acceptance tests
2026-10-08 13:40:18 +01:00
Firehose Bot 38873bde8c will it blend - live 2026-10-08 13:39:09 +01:00
12 changed files with 783 additions and 1 deletions
+5 -1
View File
@@ -1,6 +1,6 @@
# Makefile for Firehose monorepo
.PHONY: check precommit deps compile test format
.PHONY: check precommit deps compile test test-elisp format
# Common check target that runs all static analysis
check:
@@ -23,6 +23,10 @@ test:
@make -C app test
@make -C blogex test
# Run Emacs Lisp tests for roam-export-blog (requires emacs on PATH)
test-elisp:
emacs --batch -Q -l org -l ox-md -l scripts/roam-export-blog.el -l scripts/roam-export-blog-test.el -f ert-run-tests-batch-and-exit
# Format code
format:
@make -C app format
@@ -0,0 +1,50 @@
%{
title: "Will It Blend?",
author: "Willem van den Ende",
tags: ~w(AI pi.dev ComprehensionMaxxing),
description: "Will it Blend was a series of videos promoting a blender brand, in which various unlikely objects were put in a blender to see if they would blend.",
image: "/images/blog/2026/will-it-blend.svg",
published: true
}
---
**Problem** You find something interesting, but don't completely understand it, or are not sure where to start.
**Solution** Point your coding agent at the thing you read (a blog post, research paper, product announcement) and build something so that you can play with it.
Will it Blend was [a series of infomercials](https://en.wikipedia.org/wiki/Will_It_Blend%3F) promoting a blender brand, in which various unlikely objects, such as tablets, were put in a blender to see if they would blend.
![will-it-blend](/images/blog/2026/will-it-blend.svg)
I like it as a _metaphor_ for trying to get something to work that you have just come across. I often use it when reading about the new hot AI thing of the week (say decision models a couple of weeks ago). I’ll go and look for an open source implementation and have a play with it, asking my coding agent for help installing it and coming up with a micro project I can do with it.
Worst case I lost half an hour to an hour, and put the idea back on the backburner. But more often than not, I get something out of it that I can take forward: working software and better, more grounded, understanding of a topic.
## Examples of when I used this
I use this all the time now. And so do other people, it is not that original. I want to encourage you to see if things blend too, only takes half an hour or so.
### Decision models
For instance, a day after [Jev](https://en.wikipedia.org/wiki/Jev_(AI_model))came out, Laya popped up on Reddit. I cloned the repository, opened Pi and asked some questions about what you could do with it. Then I had a look at the example code on the Laya page, used my coding agent to run it, had a chat about how I could use it in a coding agent, and had the agent make some sample questions with sample data. I also wondered why it was taking so long to start, got the coding agent to reduce startup by 40 times (28 seconds to about 0.6 seconds or so), and I learnt in the process that this was a BERT model with some pre-training. I had seen BERT’s but not played with one in the wild before. This enabled me to write some comments on LinkedIn, and learn, indirectly, about my mistakes :-). Not bad for an hour or two on a sunday afternoon.
### Getting started with a local model in an open coding agent
Another example would be John Nolan , who put [my post on a local coding agent on Mac OS](/blog/engineering/my-local-agentic-dev-setup-today) through his coding agent to get Pi + local llms set up on his machine. I liked this, because this is also how I got started with making my own coding agent, and later installing Pi. With a coding agent in hand, and some vigilance or sandboxing, this is not much time well spent.
## Background
I learnt this in the spring of 2025 through Julia Turc, probably from her video on [Mixture of Experts: How LLMs Are Getting Smarter Without Getting Slower](https://www.youtube.com/watch?v=7yR5ScbK1qk). In this video, Julia takes a 1991 research paper on mixture of experts, pulls it through ChatGPT (this is several hundred AI summer years ago) and creates an executable Collab Notebook out of it.
I wrote a comment at the time:
> +1. I was wondering if llms would make it easier to implement papers (something I haven’t done in a while), and later that day, youtube surfaced this video with a live demo. I came for the mixture of experts explanation and stayed for the implementation and paper reading. It is insightful to see what you look at in a paper.
I was primed for this by Katherine Breslin’s talk at Agile Manchester 2025 (spring, this is important in AI summer years), in which she mentioned making a voice assistant in an afternoon on her own, something which had taken the Alexa team (on which she worked) 150+ people and multiple years.
## Your turn
Do you have research papers, blog posts or videos that leave you wondering? Wonder no more. These days I often start with putting my question in a (gemini) notebook, getting a summary, finding more sources, and asking questions. Then I take a summary document to my coding agent, refer to that in an initial prompt and then it usually works. I create a new git repository for every project, making small commits as I go. Often [with tests](/blog/engineering/synthetic-tdd): they make it easy to get started, express expectations, and keep going if the experiment is a success.
If you want specifics on what I use now: I use [Pi](/blog/engineering/tag/pi.dev) as coding agent. Models that I can run on a 64GB mac (e.g. Qwen 3.8 27B or 3.6 35B) are usually plenty. When I want it faster, or with less fan noise, I use a hosted GLM 5.3 Flash.
I hope you find this useful and will see if something you are curious about will Blend. Happy blending!
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 445 KiB

+1
View File
@@ -0,0 +1 @@
*.elc
+188
View File
@@ -0,0 +1,188 @@
;;; roam-export-blog-test.el --- ERT tests for roam-export-blog -*- lexical-binding: t; -*-
;;; Commentary:
;; Run with:
;; emacs --batch -Q -l org -l ox-md -l scripts/roam-export-blog.el \
;; -l scripts/roam-export-blog-test.el -f ert-run-tests-batch-and-exit
;;
;; Tests use a fixture vault under scripts/test-fixtures/vault and write
;; output into throw-away firehose repo skeletons under /tmp.
;;; Code:
(require 'ert)
(require 'roam-export-blog)
(defconst reb-test-dir
(directory-file-name (file-name-directory (or load-file-name default-directory))))
(defconst reb-test-repo-root (expand-file-name ".." reb-test-dir))
(defconst reb-test-vault (expand-file-name "test-fixtures/vault" reb-test-dir))
(defun reb-test--file (name)
"Path to fixture file NAME in the test vault."
(expand-file-name name reb-test-vault))
(defun reb-test--new-root ()
"Create a throw-away firehose repo skeleton and return its root."
(let ((root (make-temp-file "/tmp/reb-root-" t)))
(make-directory (expand-file-name "app/priv/blog/engineering" root) t)
(make-directory (expand-file-name "app/priv/static/images/blog" root) t)
root))
(defun reb-test--setup-org-id ()
"Point org-id at a writable temp locations file and index the fixture vault."
(setq org-id-locations-file (make-temp-file "/tmp/reb-orgid-"))
(setq org-id-locations nil)
(org-id-update-id-locations
(mapcar #'reb-test--file '("note.org" "jev.org" "laya.org" "notitle.org")) t))
(defmacro reb-test--with-export (root &rest body)
"Run BODY with a fresh repo ROOT and a pinned export date."
(declare (indent 1))
`(progn
(reb-test--setup-org-id)
(let ((roam-export-blog-firehose-root ,root)
(roam-export-blog--current-date '("2026" "10" "08")))
,@body)))
;;; Pure helpers
(ert-deftest roam-export-blog/slug ()
(should (equal "will-it-blend" (roam-export-blog--slug "Will It Blend")))
(should (equal "improve-diagnostic-in-the-tdd-cycle"
(roam-export-blog--slug "Improve Diagnostic in the TDD cycle")))
(should (equal "v0-2-0-rss-subscribe-links"
(roam-export-blog--slug "v0.2.0 — RSS Subscribe Links"))))
(ert-deftest roam-export-blog/truncate-description-short ()
(should (equal "Short paragraph."
(roam-export-blog--truncate-description "Short paragraph."))))
(ert-deftest roam-export-blog/truncate-description-at-word-boundary ()
(let* ((words (make-list 60 "word"))
(text (string-join words " "))
(result (roam-export-blog--truncate-description text)))
(should (<= (length result) (+ roam-export-blog-description-length 1)))
(should (string-suffix-p "…" result))
(should-not (string-suffix-p " " result))
(should (string-prefix-p "word word" result))))
;;; Roam ref resolution
(ert-deftest roam-export-blog/roam-refs ()
(reb-test--setup-org-id)
(should (equal "https://example.com/jev"
(roam-export-blog--roam-refs (reb-test--file "jev.org"))))
(should (null (roam-export-blog--roam-refs (reb-test--file "laya.org")))))
;;; Aborts
(ert-deftest roam-export-blog/no-title-aborts ()
(let ((root (reb-test--new-root)))
(reb-test--with-export root
(should-error (roam-export-blog--export-file (reb-test--file "notitle.org"))
:type 'user-error)
;; Aborting must not have written any posts.
(should (null
(directory-files-recursively
(expand-file-name "app/priv/blog" root) "\\.md$"))))))
(ert-deftest roam-export-blog/filename-collision-aborts ()
(let ((root (reb-test--new-root)))
(reb-test--with-export root
;; Pre-create the exact target file.
(make-directory
(expand-file-name "app/priv/blog/engineering/2026" root) t)
(write-region "" nil
(expand-file-name
"app/priv/blog/engineering/2026/10-08-will-it-blend.md" root))
(should-error (roam-export-blog--export-file (reb-test--file "note.org"))
:type 'user-error)
;; Aborting must not have copied any images (no side effects).
(should (null
(directory-files-recursively
(expand-file-name "app/priv/static/images/blog" root)
"."))))))
;;; Full export
(ert-deftest roam-export-blog/end-to-end ()
(let ((root (reb-test--new-root))
result path body)
(reb-test--with-export root
(setq result (roam-export-blog--export-file (reb-test--file "note.org"))))
(setq path (car result))
;; Written at the agreed path.
(should (equal (expand-file-name
"app/priv/blog/engineering/2026/10-08-will-it-blend.md" root)
path))
(should (file-exists-p path))
;; Image attachment copied into static images with unchanged content.
(let ((image (expand-file-name "app/priv/static/images/blog/2026/will-it-blend.svg" root)))
(should (file-exists-p image))
(should (equal (with-temp-buffer (insert-file-contents image) (buffer-string))
(with-temp-buffer
(insert-file-contents (reb-test--file ".attach/00/000000-0000-0000-0000-000000000001/will-it-blend.svg"))
(buffer-string)))))
(setq body (with-temp-buffer (insert-file-contents path) (buffer-string)))
;; Frontmatter.
(should (string-match-p "title: \"Will It Blend\"" body))
(should (string-match-p "author: \"Willem van den Ende\"" body))
(should (string-match-p "tags: ~w()" body))
(should (string-match-p
"description: \"Will it Blend was a series of videos promoting a blender brand.\""
body))
(should (string-match-p "image: \"/images/blog/2026/will-it-blend.svg\"" body))
(should (string-match-p "published: false" body))
;; Markdown body.
(should (string-match-p "Will it Blend was a series of videos promoting a blender brand." body))
(should (string-match-p
(regexp-quote "![will-it-blend](/images/blog/2026/will-it-blend.svg)")
body))
(should (string-match-p (regexp-quote "[Jev](https://example.com/jev)") body))
(should (string-match-p "Laya popped up" body))
(should (string-match-p "a missing note vanished" body))
(should (string-match-p "TODO source needed (LinkedIn)" body))
(should (string-match-p "^## I learnt this from Julia$" body))
(should (string-match-p "^> A quoted comment.$" body))
(should (string-match-p "^```elixir$" body))
(should (string-match-p (regexp-quote "**bold**") body))
;; Org syntax must not leak into the markdown.
(should-not (string-match-p "#\\+begin" body))
(should-not (string-match-p "attachment:" body))
(should-not (string-match-p "id:0000" body))
(should-not (string-match-p "A private comment that must not be exported" body))
;; Warnings: dangling ref, ref without ROAM_REFS, ignored non-image
;; attachment.
(should (= 3 (length (cdr result))))
(should (cl-some (lambda (w) (string-match-p "00000000-0000-0000-0000-000000009999" w))
(cdr result)))
(should (cl-some (lambda (w) (string-match-p "00000000-0000-0000-0000-000000000003" w))
(cdr result)))
(should (cl-some (lambda (w) (string-match-p "attached-notes.txt" w))
(cdr result)))))
(ert-deftest roam-export-blog/description-truncated-in-frontmatter ()
(let* ((root (reb-test--new-root))
(vault (make-temp-file "/tmp/reb-vault-" t))
(note (expand-file-name "longpar.org" vault))
(long-text (string-join (make-list 50 "lorem" ) " ")))
(with-temp-file note
(insert ":PROPERTIES:\n:ID: 00000000-0000-0000-0000-000000000005\n:END:\n"
"#+title: Long Paragraph\n\n" long-text "\n"))
(reb-test--with-export root
(let ((result (roam-export-blog--export-file note)))
(let ((body (with-temp-buffer
(insert-file-contents (car result))
(buffer-string))))
(string-match "description: \"\\([^\"]*\\)\"" body)
(let ((desc (match-string 1 body)))
(should (<= (length desc) (+ roam-export-blog-description-length 1)))
(should (string-suffix-p "…" desc))
(should (string-prefix-p "lorem lorem" desc))))))))
(provide 'roam-export-blog-test)
;;; roam-export-blog-test.el ends here
+453
View File
@@ -0,0 +1,453 @@
;;; roam-export-blog.el --- Export an org-roam note as a firehose blog post -*- lexical-binding: t; -*-
;;; Commentary:
;; M-x roam-export-blog, run from an org-roam note, converts the note to
;; markdown and writes it directly into the firehose repo as a draft post:
;;
;; <repo>/app/priv/blog/engineering/<YYYY>/<MM>-<DD>-<slug>.md
;;
;; with `published: false' frontmatter, so the repo's git diff acts as the
;; review mechanism.
;;
;; What happens during export:
;;
;; - `[[id:<uuid>][text]]' roam refs are resolved via org-id. When the
;; target note carries a :ROAM_REFS: property, the link becomes a
;; markdown link to the first URL; otherwise it degrades to plain text.
;; - `[[attachment:...]]' links are resolved via the org-attach ID scheme
;; (.attach/<xx>/<rest-of-id>/<file>). Images are copied into
;; app/priv/static/images/blog/<YYYY>/ and referenced with the
;; /images/blog/... web path. Non-image attachments are ignored.
;; - Org syntax is converted to markdown with a derived ox-md backend
;; (fenced code blocks with language, H2 top-level headings, no TOC).
;; - Dropped refs, ignored attachments and unhandled constructs are
;; reported as warnings.
;;; Code:
(require 'org)
(require 'ox-md)
(require 'org-id)
(require 'cl-lib)
(defgroup roam-export-blog nil
"Export org-roam notes as firehose blog posts."
:group 'org)
(defcustom roam-export-blog-firehose-root "/Users/willem/dev/elixir/firehose"
"Path to the firehose repository root."
:type 'directory)
(defcustom roam-export-blog-author "Willem van den Ende"
"Author name written into generated frontmatter."
:type 'string)
(defcustom roam-export-blog-description-length 200
"Maximum length of the generated description (before the ellipsis)."
:type 'integer)
(defcustom roam-export-blog-image-extensions
'("svg" "png" "jpg" "jpeg" "gif" "webp" "avif")
"Attachment extensions that count as images."
:type '(repeat string))
(defvar roam-export-blog--current-date nil
"Override for the export date during tests, as a list (YEAR MONTH DAY).")
;;; Derived markdown backend
(defun roam-export-blog-md-src-block (src-block _contents info)
"Transcode SRC-BLOCK into a fenced markdown code block with language."
(let ((lang (org-element-property :language src-block))
(value (org-export-format-code-default src-block info)))
(concat "```" (or lang "") "\n" value "```")))
(defun roam-export-blog-md-link (link desc info)
"Transcode LINK; file links to images become markdown images.
Everything else is delegated to `org-md-link'."
(let* ((type (org-element-property :type link))
(path (org-element-property :path link)))
(if (and (equal type "file") (roam-export-blog--image-p path))
(format "![%s](%s)"
(or (and desc (string-trim (substring-no-properties desc)))
(file-name-base path))
path)
(org-md-link link desc info))))
(org-export-define-derived-backend 'firehose-md 'md
:translate-alist '((src-block . roam-export-blog-md-src-block)
(link . roam-export-blog-md-link)))
(defconst roam-export-blog--export-options
'(:with-toc nil :section-numbers nil :md-toplevel-hlevel 2))
;;; Small helpers
(defun roam-export-blog--slug (title)
"Derive a filename slug from TITLE."
(let* ((lower (downcase title))
(slugged (replace-regexp-in-string "[^a-z0-9]+" "-" lower)))
(replace-regexp-in-string "\\`-+\\|-+\\'" "" slugged)))
(defun roam-export-blog--date-parts ()
"Export date as a list of strings (YEAR MONTH DAY)."
(or roam-export-blog--current-date
(list (format-time-string "%Y")
(format-time-string "%m")
(format-time-string "%d"))))
(defun roam-export-blog--escape-elixir-string (text)
"Escape TEXT for embedding in an Elixir double-quoted string."
(replace-regexp-in-string
"\"" "\\\\\""
(replace-regexp-in-string "\\\\" "\\\\\\\\" text)))
(defun roam-export-blog--truncate-description (text)
"Truncate TEXT to about `roam-export-blog-description-length' characters."
(let ((max roam-export-blog-description-length))
(if (<= (length text) max)
text
(let ((cut (cl-position ?\s (substring text 0 max) :from-end t)))
(concat (if cut (substring text 0 cut) (substring text 0 max))
"…")))))
(defun roam-export-blog--plain-text (element)
"Return a plain-text approximation of org ELEMENT."
(cond
((stringp element) element)
((memq (org-element-type element) '(code verbatim inline-src-block))
(or (org-element-property :value element) ""))
((eq (org-element-type element) 'entity)
(or (org-element-property :utf8 element) ""))
(t (mapconcat #'roam-export-blog--plain-text (org-element-contents element) ""))))
;;; Reading the note
(defun roam-export-blog--title (buffer)
"Return the #+TITLE: keyword value in BUFFER, or nil."
(with-current-buffer buffer
(cadr (assoc "TITLE" (org-collect-keywords '("TITLE"))))))
(defun roam-export-blog--first-paragraph-text (buffer)
"Return the plain text of the first paragraph in BUFFER."
(with-current-buffer buffer
(let ((para (org-element-map (org-element-parse-buffer) 'paragraph
#'identity nil t)))
(when para
(replace-regexp-in-string
"[ \t\n]+" " "
(string-trim
(substring-no-properties (roam-export-blog--plain-text para))))))))
(defun roam-export-blog--roam-refs (file)
"Return the first :ROAM_REFS: URL of the note in FILE, or nil."
(with-current-buffer (find-file-noselect file)
(org-with-wide-buffer
(goto-char (point-min))
(let ((value (org-entry-get (point) "ROAM_REFS")))
(when value (car (split-string value "[ \t]+")))))))
(defun roam-export-blog--note-title (file)
"Return the #+TITLE: of the note in FILE, or nil."
(roam-export-blog--title (find-file-noselect file)))
;;; Link processing
(defun roam-export-blog--vault-attach-dir (file)
"Return the .attach directory dominating FILE, or nil."
(when-let* ((root (locate-dominating-file file ".attach")))
(expand-file-name ".attach" root)))
(defun roam-export-blog--image-p (file)
"Return non-nil when FILE looks like an image attachment."
(member (downcase (file-name-extension file))
roam-export-blog-image-extensions))
(defun roam-export-blog--link-description (link)
"Return the description text of LINK, or nil."
(let ((begin (org-element-property :contents-begin link))
(end (org-element-property :contents-end link)))
(when (and begin end)
(buffer-substring-no-properties begin end))))
(defun roam-export-blog--desc-or (desc fallback)
"Return trimmed DESC, or FALLBACK when DESC is nil or empty.
An empty string is truthy in Elisp, so descriptions must be
treated as absent explicitly."
(let ((trimmed (when desc (string-trim desc))))
(if (and trimmed (not (string-empty-p trimmed)))
trimmed
fallback)))
(defun roam-export-blog--desc-verbatim-or (desc fallback)
"Return DESC unchanged, or FALLBACK when DESC is nil or blank.
Used for plain-text replacements: a description like \"Jev \"
may carry the spacing that separated it from surrounding text in
the org source."
(if (and desc (not (string-empty-p (string-trim desc))))
desc
fallback))
(defun roam-export-blog--attachment-path (link vault-attach-dir note-id)
"Return the absolute source path of attachment LINK, or nil.
Handles three shapes: file links already normalized by org to an
absolute path under the vault's .attach directory, plain
attachment: links and fuzzy links carrying an attachment: prefix.
Resolution follows the org-attach ID scheme:
.attach/<first two ID chars>/<rest of ID>/<path>."
(let* ((type (org-element-property :type link))
(path (org-element-property :path link))
(bare (if (and (equal type "fuzzy")
(string-prefix-p "attachment:" path))
(substring path (length "attachment:"))
path)))
(cond
((and (equal type "file") (file-name-absolute-p path)
vault-attach-dir
(string-prefix-p vault-attach-dir (expand-file-name path)))
(expand-file-name path))
((and vault-attach-dir note-id bare
(>= (length note-id) 2))
(let ((candidate (expand-file-name
(concat (substring note-id 0 2)
"/" (substring note-id 2)
"/" bare)
vault-attach-dir)))
(when (file-exists-p candidate) candidate)))
(t nil))))
(defun roam-export-blog--resolve-id (id)
"Return the file containing org ID, or nil."
(ignore-errors (org-id-find id)))
(defun roam-export-blog--link-replacement (link vault-attach-dir note-id)
"Compute the replacement for LINK.
Return (REPLACEMENT WARNINGS IMAGES), where REPLACEMENT is org
syntax to substitute for the link (nil leaves the link alone),
WARNINGS is a list of warning strings and IMAGES is a list of
(SOURCE . DEST) file copies to perform."
(let* ((type (org-element-property :type link))
(path (org-element-property :path link))
(desc (roam-export-blog--link-description link))
(replacement nil)
(warnings nil)
(images nil))
(cond
;; Roam refs: [[id:uuid][text]]
((equal type "id")
(let* ((target (roam-export-blog--resolve-id path))
(refs (when target (roam-export-blog--roam-refs (car target))))) (cond
((and target refs)
(setq replacement (format "[[%s][%s]]"
refs (roam-export-blog--desc-or desc refs))))
(target
(setq replacement (roam-export-blog--desc-verbatim-or
desc (or (roam-export-blog--note-title (car target))
"")))
(push (format "dropped ref: id %s has no ROAM_REFS, kept plain text %S"
path replacement)
warnings))
(t
(setq replacement (roam-export-blog--desc-verbatim-or desc ""))
(push (format "dropped ref: unresolved id %s, kept plain text %S"
path replacement)
warnings)))))
;; Attachments (and file links that org normalized from attachments)
((and vault-attach-dir
(or (member type '("file" "attachment"))
(and (equal type "fuzzy")
(string-prefix-p "attachment:" path))))
(let ((source (roam-export-blog--attachment-path link vault-attach-dir note-id)))
(cond
((and source (file-exists-p source) (roam-export-blog--image-p source))
(let* ((name (file-name-nondirectory source))
(year (car (roam-export-blog--date-parts)))
(web-path (format "/images/blog/%s/%s" year name))
(dest (expand-file-name
(format "app/priv/static/images/blog/%s/%s" year name)
roam-export-blog-firehose-root)))
(setq images (list (cons source dest)))
(setq replacement
(format "[[file:%s][%s]]" web-path
(roam-export-blog--desc-or desc
(file-name-base name))))))
((and source (file-exists-p source))
(setq replacement (roam-export-blog--desc-verbatim-or desc ""))
(push (format "ignored attachment (not an image): %s"
(file-name-nondirectory source))
warnings))
(t
(setq replacement (roam-export-blog--desc-verbatim-or desc ""))
(push (format "unhandled link dropped: %s:%s" type path)
warnings)))))
;; Any other file: link points into the vault, not the blog.
((equal type "file")
(setq replacement (roam-export-blog--desc-verbatim-or desc ""))
(push (format "unhandled link dropped: file:%s" path) warnings))
;; Everything else (https, mailto, ...) passes through untouched.
(t nil))
(list replacement warnings images)))
(defun roam-export-blog--process-links (buffer vault-attach-dir)
"Rewrite id/attachment/file links in BUFFER's text.
BUFFER is not modified; a processed copy of its text is returned.
Return (TEXT WARNINGS IMAGES) where IMAGES is an alist of
(SOURCE . DEST) image copies to perform."
(with-current-buffer buffer
(let ((text (buffer-string))
(note-id (org-entry-get (point-min) "ID"))
(warnings nil)
(all-images nil))
(let* ((tree (org-element-parse-buffer))
(replacements
(reverse
(org-element-map tree 'link
(lambda (link)
(let* ((result (roam-export-blog--link-replacement
link vault-attach-dir note-id))
(replacement (nth 0 result))
(warns (nth 1 result))
(images (nth 2 result)))
(setq warnings (append warnings warns)
all-images (append all-images images))
(when replacement
(list (org-element-property :begin link)
;; :end includes :post-blank (trailing
;; whitespace); exclude it from the
;; replaced region.
(- (org-element-property :end link)
(or (org-element-property :post-blank link) 0))
replacement))))
nil nil nil))))
(dolist (r replacements)
(let ((begin (nth 0 r))
(end (nth 1 r))
(replacement (nth 2 r)))
(setq text (concat (substring text 0 (1- begin))
replacement
(substring text (1- end))))))
(list text warnings all-images)))))
;;; Markdown generation
(defun roam-export-blog--to-markdown (text)
"Export org TEXT to markdown with the firehose-md backend."
(with-temp-buffer
(insert text)
(org-mode)
(let ((out (org-export-to-buffer
'firehose-md "*roam-export-blog export*"
nil nil nil nil roam-export-blog--export-options)))
(with-current-buffer out (buffer-string)))))
(defun roam-export-blog--cleanup (markdown)
"Collapse runs of blank lines in MARKDOWN."
(replace-regexp-in-string "\n\\(?:[ \t]*\n\\)+\\'" "\n"
(replace-regexp-in-string "\n\\{3,\\}" "\n\n" markdown)))
;;; Frontmatter and writing
(defun roam-export-blog--frontmatter (title description image)
"Build the Elixir-term frontmatter for the post."
(concat "%{\n"
(format " title: %S,\n" (roam-export-blog--escape-elixir-string title))
(format " author: %S,\n" roam-export-blog-author)
" tags: ~w(),\n"
(format " description: %S,\n"
(roam-export-blog--escape-elixir-string description))
(format " image: %S,\n" image)
" published: false\n"
"}\n---\n"))
(defun roam-export-blog--post-path (slug)
"Return the target post path for SLUG."
(let ((year (car (roam-export-blog--date-parts)))
(month (nth 1 (roam-export-blog--date-parts)))
(day (nth 2 (roam-export-blog--date-parts))))
(expand-file-name
(format "app/priv/blog/engineering/%s/%s-%s-%s.md" year month day slug)
roam-export-blog-firehose-root)))
(defun roam-export-blog--copy-images (images)
"Copy IMAGES (alist of SOURCE . DEST), warning on basename clashes."
(let ((copied nil)
(warnings nil))
(dolist (pair images)
(let ((source (car pair))
(dest (cdr pair)))
(cond
((member dest copied)
(push (format "skipped duplicate image copy: %s"
(file-name-nondirectory source))
warnings))
(t
(make-directory (file-name-directory dest) t)
(copy-file source dest t)
(push dest copied)))))
(nreverse warnings)))
;;; The command
(defun roam-export-blog--export-file (&optional file)
"Export org-roam note FILE to a firehose blog post.
Return (OUTPUT-PATH . WARNINGS). Abort with `user-error' when the
note has no title or the target file already exists."
(setq file (or file (buffer-file-name)))
(unless file
(user-error "roam-export-blog: not visiting a file"))
(let* ((buffer (find-file-noselect file))
(title (roam-export-blog--title buffer)))
(unless title
(user-error "roam-export-blog: %s has no #+title: keyword, aborting" file))
(let* ((slug (roam-export-blog--slug title))
(post-path (roam-export-blog--post-path slug)))
(when (file-exists-p post-path)
(user-error "roam-export-blog: %s already exists, aborting" post-path))
(let* ((processed (roam-export-blog--process-links
buffer (roam-export-blog--vault-attach-dir file)))
(text (nth 0 processed))
(link-warnings (nth 1 processed))
(images (nth 2 processed))
(copy-warnings (roam-export-blog--copy-images images))
(warnings (append (nreverse link-warnings) copy-warnings))
(image (when images
(let ((static (expand-file-name
"app/priv/static"
roam-export-blog-firehose-root)))
(string-remove-prefix static (cdr (car images))))))
(description (roam-export-blog--truncate-description
(roam-export-blog--first-paragraph-text buffer)))
(markdown (roam-export-blog--cleanup (roam-export-blog--to-markdown text))))
(make-directory (file-name-directory post-path) t)
(with-temp-file post-path
(insert (roam-export-blog--frontmatter title description image)
markdown))
(cons post-path warnings)))))
;;;###autoload
(defun roam-export-blog ()
"Export the current org-roam note as a firehose blog post."
(interactive)
(let ((result (roam-export-blog--export-file (buffer-file-name))))
(let ((path (car result))
(warnings (cdr result)))
(message "roam-export-blog: wrote %s%s" path
(if warnings
(format " (%d warning%s)" (length warnings)
(if (= 1 (length warnings)) "" "s"))
""))
(when warnings
(with-output-to-temp-buffer "*roam-export-blog warnings*"
(dolist (w warnings)
(princ (format "%s\n" w))))))))
(provide 'roam-export-blog)
;;; roam-export-blog.el ends here
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10"/></svg>

After

Width:  |  Height:  |  Size: 100 B

+7
View File
@@ -0,0 +1,7 @@
:PROPERTIES:
:ID: 00000000-0000-0000-0000-000000000002
:ROAM_REFS: https://example.com/jev
:END:
#+title: Jev
Body of Jev.
+6
View File
@@ -0,0 +1,6 @@
:PROPERTIES:
:ID: 00000000-0000-0000-0000-000000000003
:END:
#+title: Laya
Body of Laya.
+26
View File
@@ -0,0 +1,26 @@
:PROPERTIES:
:ID: 00000000-0000-0000-0000-000000000001
:END:
#+title: Will It Blend
Will it Blend was a series of videos promoting a blender brand.
# A private comment that must not be exported.
[[attachment:will-it-blend.svg]]
For instance, a day after [[id:00000000-0000-0000-0000-000000000002][Jev]] came out, [[id:00000000-0000-0000-0000-000000000003][Laya]] popped up, and [[id:00000000-0000-0000-0000-000000009999][a missing note]] vanished. TODO source needed (LinkedIn)
[[attachment:attached-notes.txt]]
* I learnt this from Julia
Body text with *bold*.
#+begin_quote
A quoted comment.
#+end_quote
#+begin_src elixir
IO.puts("hi")
#+end_src
+5
View File
@@ -0,0 +1,5 @@
:PROPERTIES:
:ID: 00000000-0000-0000-0000-000000000004
:END:
No title here.