Colophon
This capsule is rendered entirely from a small collection of org files. The author is an on-and-off emacs user, not particularly committed, rather unaware of all the awesome features, but is a text/plain and text/org believer and aspiring elisp practitioner. Anyways, it's actually kinda cool how it all works, the setup is rather minimal and is based on the org-export (ox) and the ox-gmi package, modified to fit the particular needs of this capsule.
That it is a collection of org files and not one file is kind of arbitrary, to be honest, but it leads to some folder structure in the output, so I'm keeping it this way.
There are
- one file for static sections, such as about, hand-crafted Antenna feed, etc.
- one file for all the daily notes
- and one file for each category of things, e.g., audiobooks, quick notes, music, etc.
The modified ox-gmi recursively renders the org files, such that every first-level heading is exported into its own file.
The cool thing is links. The links between headings are then rendered as links between files, and because the org links and the gmi export links follow matching patterns, it all works really well.
There's one hack specifically for the links: for some reason, the heading matcher can either work with file links or "fuzzy", and both a link to an unspecified headings, so there is one dirty hack, where strings that contain "gemini:" are matched to a specific rendering function, that I haven't found an idiomatic elisp way to fix. Guess that's an imperfection that I'll live with, because it fixes the problem, is unlikely to shoot in the foot, and — importantly — who cares, really.
Beware: the source file is about 730 LOC and quite wide at times. Feel free to take it and use it though.
;;; ox-gmi.el --- Gemini Back-End for Org Export Engine -*- lexical-binding: t; -*-
;; Copyright (C) 2020 Étienne Deparis
;; Author: Étienne Deparis <etienne@depar.is>
;; Created: 29 November 2020
;; Version: 0.1
;; Package-Requires: ((emacs "27.1"))
;; Keywords: wp
;; Homepage: https://git.umaneti.net/ox-gmi.el/
;;; License:
;; This file is not part of GNU Emacs.
;; However, it is distributed under the same license.
;; GNU Emacs is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation; either version 3, or (at your option)
;; any later version.
;; GNU Emacs is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with GNU Emacs. If not, see <https://www.gnu.org/licenses/>.
;;; Commentary:
;; This library implements a Gemini back-end for Org exporter, based on
;; `markdown' back-end. It also heavily depends on the `ascii' back-end.
;;; Code:
(require 'ox-ascii)
(require 'ox-publish)
;;; Define Back-End
(org-export-define-derived-backend 'gmi 'ascii
:menu-entry
'(?g "Export to Gemini"
((?G "To temporary buffer"
(lambda (a s v b) (org-gmi-export-as-gemini a s v)))
(?g "To file" (lambda (a s v b) (org-gmi-export-to-gemini a s v)))
(?o "To file and open"
(lambda (a s v b)
(if a (org-gmi-export-to-gemini t s v)
(org-open-file (org-gmi-export-to-gemini nil s v)))))))
:translate-alist '((center-block . org-gmi-center)
(example-block . org-gmi-preformatted-block)
(export-block . org-gmi-export-block)
(fixed-width . org-gmi-preformatted-block)
(footnote-reference . org-gmi-footnote-reference)
(headline . org-gmi-headline)
(inner-template . org-gmi-inner-template)
(item . org-gmi-item)
(keyword . org-gmi-keyword)
(line-break . org-gmi-line-break)
(link . org-gmi-link)
(paragraph . org-gmi-paragraph)
(quote-block . org-gmi-quote-block)
(section . org-gmi-section)
(src-block . org-gmi-preformatted-block)
(template . org-gmi-template)))
;;; Inner Variables
(defvar org-gmi--links-in-section '()
"AList storing all links in current section.")
(defvar org-gmi--heading-name-map nil
"Alist mapping heading titles to (NAME . DISPLAY-TITLE).
Used during export to resolve fuzzy links to headings with custom filenames and titles.
Each entry is (HEADING-TITLE . (FILENAME . DISPLAY-TITLE)).")
;;; Inner Functions
(defun org-gmi--build-headline (level title &optional tags)
"Generate a headline TITLE for the given LEVEL.
TAGS are the tags set on the section."
(let ((level-mark (make-string level ?#)))
(concat level-mark " " title tags "\n\n")))
(defun org-gmi--build-links (links)
"Return a string describing a list of links.
LINKS is an alist like `org-gmi--links-in-section'"
(mapconcat
#'(lambda (link)
(let ((dest (car link))
(reference (cadr link))
(label (car (cddr link))))
(format "=> %s [%d] %s" dest reference label)))
links "\n"))
(defun org-gmi--build-toc (info &optional depth)
"Return a table of contents.
INFO is a plist used as a communication channel.
Optional argument DEPTH, when non-nil, is an integer specifying the
depth of the table."
(mapconcat
#'(lambda (headline)
(let* ((prefix
(if (not (org-export-numbered-headline-p headline info)) ""
(concat
(mapconcat 'number-to-string
(org-export-get-headline-number headline info)
".")
". ")))
(title (org-export-data-with-backend
(org-export-get-alt-title headline info)
(org-export-toc-entry-backend 'gmi)
info))
(tags (and (plist-get info :with-tags)
(not (eq 'not-in-toc (plist-get info :with-tags)))
(org-make-tag-string
(org-export-get-tags headline info)))))
(concat prefix title tags)))
(org-export-collect-headlines info depth) "\n"))
(defun org-gmi--format-paragraph (paragraph &optional prefix)
"Transcode PARAGRAPH into Gemini format.
If PREFIX is non-nil, add it at the beginning of each lines."
(replace-regexp-in-string
"^\\s-?" (or prefix "")
(org-trim
(replace-regexp-in-string "\r?\n\\([^\r\n]\\)" " \\1" paragraph))))
(defun org-gmi--link-alone-on-line-p (link)
"Return t if the given LINK occupies its whole line."
(let* ((link-start (org-element-property :begin link))
(link-end (org-element-property :end link))
(full-link (buffer-substring-no-properties link-start link-end))
(raw-link (org-element-property :raw-link link)))
(save-excursion
(org-goto-line (org-current-line link-start))
(let ((line-content (org-trim (thing-at-point 'line t))))
(or (string= raw-link line-content)
(string= full-link line-content))))))
(defun org-gmi--number-to-utf8-exponent (number)
"Convert a NUMBER to its utf8 exponent display."
(let ((exponents '((?1 . "¹")
(?2 . "²")
(?3 . "³")
(?4 . "⁴")
(?5 . "⁵")
(?6 . "⁶")
(?7 . "⁷")
(?8 . "⁸")
(?9 . "⁹")
(?0 . "⁰"))))
(mapconcat
#'(lambda (digit) (cdr (assoc digit exponents)))
(number-to-string number)
"")))
;;; Transcode Functions
(defun org-gmi-preformatted-block (block _contents info)
"Transcode BLOCK element into Gemini format.
INFO is a plist used as a communication channel."
(let ((language (org-export-data (org-element-property :language block) info))
(caption (org-export-data (org-element-property :caption block) info)))
(setq caption (if (org-string-nw-p caption)
(format "%s - %s" language caption)
language))
(format "```%s\n%s```\n"
caption
(org-remove-indentation
(org-export-format-code-default block info)))))
(defun org-gmi-export-block (export-block _contents _info)
"Transcode a EXPORT-BLOCK element from Org to Gemini."
(if (member (org-element-property :type export-block) '("GEMINI" "GMI"))
(org-remove-indentation (org-element-property :value export-block))))
(defun org-gmi-center (_center contents info)
"Transcode a CENTER block from Org to Gemini.
CONTENTS is the block value. INFO is a plist holding contextual
information."
(format "```\n%s```"
(org-ascii--fill-string contents 80 info 'center)))
(defun org-gmi-entity (_entity contents _info)
"Transcode an ENTITY object from Org to Gemini.
CONTENTS is the entity itself."
contents)
(defun org-gmi-footnote-reference (footnote-reference _contents info)
"Transcode a FOOTNOTE-REFERENCE element from Org to Gemini.
CONTENTS is nil. INFO is a plist holding contextual information."
(org-gmi--number-to-utf8-exponent
(org-export-get-footnote-number footnote-reference info)))
(defun org-gmi-headline (headline contents info)
"Transcode HEADLINE element into Gemini format.
CONTENTS is the headline value. INFO is a plist used as
a communication channel."
(let* ((level (1+ (org-export-get-relative-level headline info)))
(title (org-export-data (org-element-property :title headline) info))
(todo (and (plist-get info :with-todo-keywords)
(let ((todo (org-element-property :todo-keyword
headline)))
(and todo (concat (org-export-data todo info) " ")))))
(tags (and (plist-get info :with-tags)
(let ((tag-list (org-export-get-tags headline info)))
(and tag-list
(concat " " (org-make-tag-string tag-list))))))
(priority
(and (plist-get info :with-priority)
(let ((char (org-element-property :priority headline)))
(and char (format "[#%c] " char)))))
;; Headline text without tags.
(heading (concat todo priority title)))
(if
;; Cannot create a headline. Fall-back to a list.
(or (org-export-low-level-p headline info)
(> level 6))
(let ((bullet
(if (not (org-export-numbered-headline-p headline info)) "*"
(concat (number-to-string
(car (last (org-export-get-headline-number
headline info))))
"."))))
(concat bullet (make-string (- 4 (length bullet)) ?\s)
heading tags "\n\n" contents))
;; Else
(concat (org-gmi--build-headline level heading tags) contents))))
(defun org-gmi-inner-template (contents info)
"Return body of document after converting it to Gemini syntax.
CONTENTS is the transcoded contents string. INFO is a plist
holding export options."
(concat
;; Document contents.
contents
;; Footnotes section.
(let ((definitions (org-export-collect-footnote-definitions info)))
(when definitions
(concat
"\n\n"
(org-gmi--build-headline
2 (org-ascii--translate "Footnotes" info))
(mapconcat
#'(lambda (ref)
(let ((id (car ref))
(def (nth 2 ref)))
(format "%s %s" (org-gmi--number-to-utf8-exponent id)
(replace-regexp-in-string
"\r?\n" " "
(org-trim (org-export-data def info))))))
definitions "\n\n"))))))
(defun org-gmi-template (contents info)
"Return body of document after converting it to Gemini syntax.
CONTENTS is the transcoded contents string. INFO is a plist
holding export options."
(concat
;; Document title.
(org-gmi--build-headline
1 (org-export-data (plist-get info :title) info))
;; TOC
(let* ((depth (plist-get info :with-toc))
(toc-contents (when depth
(org-gmi--build-toc
info (and (wholenump depth) depth)))))
(when (org-string-nw-p toc-contents)
(concat
(org-gmi--build-headline 2 (org-ascii--translate "Table of Contents" info))
toc-contents
"\n\n\n")))
;; Document contents.
contents))
(defun org-gmi-item (item contents info)
"Transcode ITEM element into Gemini format.
CONTENTS is the item value. INFO is a plist used as a
communication channel."
(let* ((type (org-element-property :type (org-export-get-parent item)))
(struct (org-element-property :structure item))
(bullet (if (not (eq type 'ordered)) "*"
(concat (number-to-string
(car (last (org-list-get-item-number
(org-element-property :begin item)
struct
(org-list-prevs-alist struct)
(org-list-parents-alist struct)))))
"."))))
(concat bullet
" "
(pcase (org-element-property :checkbox item)
(`on "[X] ")
(`trans "[-] ")
(`off "[ ] "))
(let ((tag (org-element-property :tag item)))
(and tag (format "%s: " (org-export-data tag info))))
(and contents (org-trim contents)))))
(defun org-gmi-keyword (keyword _contents _info)
"Transcode a KEYWORD element into Gemini format.
CONTENTS is nil. INFO is a plist used as a communication
channel."
(when (member (org-element-property :key keyword) '("GEMINI" "GMI"))
(org-element-property :value keyword)))
(defun org-gmi-line-break (_line-break _contents _info)
"Transcode LINE-BREAK object into Gemini format.
CONTENTS is nil. INFO is a plist used as a communication
channel."
" ")
(defun org-gmi-link (link desc _info)
"Transcode a LINK object from Org to Gemini.
DESC is the description part of the link, or the empty string."
(let* ((link-type (org-element-property :type link))
(path (org-element-property :path link))
(search-option (org-element-property :search-option link))
(target (org-element-property :raw-link link))
;; Check if this is a file link with a heading reference
(heading-ref (when (and (string= link-type "file")
search-option
(not (string-prefix-p "#" search-option)))
;; Remove leading * if present
(replace-regexp-in-string "^\\*+" "" search-option)))
(href
(cond
;; File link with heading reference: file:notes/alpha.org::Bananas -> notes/Bananas.gmi
;; Check if heading has NAME property and use that for filename
((and (string= link-type "file")
heading-ref
(string= ".org" (downcase (file-name-extension path "."))))
(let* ((dir (file-name-directory path))
;; First check current file's heading map
(map-entry (cdr (assoc heading-ref org-gmi--heading-name-map)))
;; If not found, look up in the target file
(external-props (when (not map-entry)
(org-gmi--get-heading-properties-from-file path heading-ref)))
(filename (cond
(map-entry (car map-entry))
(external-props (or (car external-props) heading-ref))
(t heading-ref))))
(concat "/" (or dir "") filename ".gmi")))
;; Regular file link to .org: file:notes/alpha.org -> notes/alpha.gmi
((and (string= link-type "file")
(string= ".org" (downcase (file-name-extension path "."))))
(format "%s.gmi" (file-name-sans-extension path)))
;; External links (http, https, gemini, etc.): keep as-is
((member link-type '("http" "https" "gemini" "gopher" "mailto"))
target)
;; Fuzzy link (internal link to heading): [[2026-01-01]] -> 2026-01-01.gmi
;; Check if the heading has a NAME property and use that for the filename
((string= link-type "fuzzy")
(if (string-match-p "gemini:" path)
target
(let* ((map-entry (cdr (assoc path org-gmi--heading-name-map)))
(filename (if map-entry (car map-entry) path)))
; (format "%s.gmi" filename)))
(format "%s.gmi" filename))))
;; Other links: keep as-is
(t target)))
(link-data (assoc href org-gmi--links-in-section))
(scheme (car (split-string href ":" t)))
;; Avoid cut lines in link labels
;; For fuzzy links and file links with heading refs, use TITLE property if available
(label (replace-regexp-in-string "\r?\n" " "
(or desc
;; For fuzzy links, check if heading has TITLE property
(when (string= link-type "fuzzy")
(let ((map-entry (cdr (assoc path org-gmi--heading-name-map))))
(when map-entry (cdr map-entry))))
;; For file links with heading ref, check TITLE property
(when (and heading-ref (string= link-type "file"))
(let* ((map-entry (cdr (assoc heading-ref org-gmi--heading-name-map)))
;; If not in current map, check external file
(external-props (when (not map-entry)
(org-gmi--get-heading-properties-from-file path heading-ref))))
(cond
(map-entry (cdr map-entry))
(external-props (or (cdr external-props) heading-ref))
(t heading-ref))))
href)))
(label-with-scheme
(if (and (not (string= desc href))
(not (string= scheme href)) ;; relative link
(not (member scheme '("gemini" "file"))))
(format "%s (%s)" label (upcase scheme))
label))
;; Default next-reference
(next-reference (1+ (length org-gmi--links-in-section))))
;; Do we need to add the link at the end of the section or should it be
;; directly printed in its own line?
(if (org-gmi--link-alone-on-line-p link)
(format "=> %s %s\n" href label-with-scheme)
;; As links are specific for a section, which should not be that long (?),
;; we will always use the first label encountered for a link as reference.
(unless link-data
(setq link-data (list href next-reference label-with-scheme))
(add-to-list 'org-gmi--links-in-section link-data t))
(format "%s[%d]" label (cadr link-data)))))
(defun org-gmi-paragraph (_paragraph contents _info)
"Transcode PARAGRAPH element into Gemini format.
CONTENTS is the paragraph value."
(org-gmi--format-paragraph contents))
(defun org-gmi-quote-block (_quote-block contents _info)
"Transcode QUOTE-BLOCK element into Gemini format.
CONTENTS is the quote-block value."
(org-gmi--format-paragraph contents "> "))
(defun org-gmi-section (_section contents _info)
"Transcode SECTION into Gemini format.
CONTENTS is the section value."
(let ((output
(concat contents "\n"
(org-gmi--build-links org-gmi--links-in-section))))
;; Reset link list
(setq org-gmi--links-in-section '())
output))
;;; Helper functions for per-heading export
(defun org-gmi--sanitize-filename (heading-text)
"Convert HEADING-TEXT to a safe filename.
Removes or replaces characters that are problematic in filenames."
(let ((sanitized heading-text))
;; Replace problematic characters with hyphens or remove them
(setq sanitized (replace-regexp-in-string "[/:*?\"<>|]" "-" sanitized))
;; Replace multiple spaces/hyphens with single hyphen
(setq sanitized (replace-regexp-in-string "[ \t]+" "-" sanitized))
(setq sanitized (replace-regexp-in-string "-+" "-" sanitized))
;; Remove leading/trailing hyphens
(setq sanitized (replace-regexp-in-string "^-+\\|-+$" "" sanitized))
;; Trim to reasonable length
(if (> (length sanitized) 200)
(substring sanitized 0 200)
sanitized)))
(defun org-gmi--get-heading-properties-from-file (file-path heading-title)
"Look up NAME and TITLE properties for HEADING-TITLE in FILE-PATH.
Returns a cons cell (NAME . TITLE) or nil if not found."
(when (and file-path (file-exists-p file-path))
(with-temp-buffer
(insert-file-contents file-path)
(org-mode)
(goto-char (point-min))
(let ((found nil))
(while (and (not found)
(re-search-forward
(concat "^\\*+ +" (regexp-quote heading-title) "\\s-*$")
nil t))
(let ((name-prop (org-entry-get nil "NAME"))
(title-prop (org-entry-get nil "TITLE")))
(setq found (cons name-prop title-prop))))
found))))
(defun org-gmi--get-first-level-headings ()
"Extract all first-level headings from current buffer.
Returns a list of plists with :begin, :end, :title, :raw-title, :name, and :title-prop properties."
(org-element-map (org-element-parse-buffer) 'headline
(lambda (hl)
(when (= (org-element-property :level hl) 1)
(let* ((begin (org-element-property :begin hl))
(title (org-element-interpret-data
(org-element-property :title hl)))
(name-prop (save-excursion
(goto-char begin)
(org-entry-get nil "NAME")))
(title-prop (save-excursion
(goto-char begin)
(org-entry-get nil "TITLE"))))
(list :begin begin
:end (org-element-property :end hl)
:title title
:raw-title (org-element-property :raw-value hl)
:name name-prop
:title-prop title-prop))))
nil nil 'headline))
;;; Interactive function
;;;###autoload
(defun org-gmi-export-as-gemini (&optional async subtreep visible-only)
"Export current buffer to a Gemini buffer.
If narrowing is active in the current buffer, only export its
narrowed part.
If a region is active, export that region.
A non-nil optional argument ASYNC means the process should happen
asynchronously. The resulting buffer should be accessible
through the `org-export-stack' interface.
When optional argument SUBTREEP is non-nil, export the sub-tree
at point, extracting information from the headline properties
first.
When optional argument VISIBLE-ONLY is non-nil, don't export
contents of hidden elements.
Export is done in a buffer named \"*Org Gemini Export*\", which will
be displayed when `org-export-show-temporary-export-buffer' is
non-nil."
(interactive)
(org-export-to-buffer
'gmi "*Org Gemini Export*"
async subtreep visible-only nil nil
#'(lambda ()
(if (featurep 'gemini-mode)
(gemini-mode)
(text-mode)))))
;;;###autoload
(defun org-gmi-export-to-gemini (&optional async subtreep visible-only)
"Export current buffer to a Gemini file.
If narrowing is active in the current buffer, only export its
narrowed part.
If a region is active, export that region.
A non-nil optional argument ASYNC means the process should happen
asynchronously. The resulting file should be accessible through
the `org-export-stack' interface.
When optional argument SUBTREEP is non-nil, export the sub-tree
at point, extracting information from the headline properties
first.
When optional argument VISIBLE-ONLY is non-nil, don't export
contents of hidden elements.
Return output file's name."
(interactive)
(let ((outfile (org-export-output-file-name ".gmi" subtreep)))
(org-export-to-file 'gmi outfile async subtreep visible-only)))
;;;###autoload
(defun org-gmi-export-to-gemini-per-heading (&optional async visible-only base-dir)
"Export each first-level heading to a separate Gemini file.
Each first-level heading in the current buffer is exported to its own
.gmi file, named after the heading text. Navigation links to previous
and next heading files are appended to each file.
A non-nil optional argument ASYNC means the process should happen
asynchronously.
When optional argument VISIBLE-ONLY is non-nil, don't export
contents of hidden elements.
Optional argument BASE-DIR specifies the directory where files should
be written. If nil, uses the current buffer's directory.
Return list of output file names."
(interactive)
(if async
(error "Async export not supported for per-heading export")
(let* ((headings (org-gmi--get-first-level-headings))
(output-dir (or base-dir
(file-name-directory (buffer-file-name))
default-directory))
(output-files '())
;; Read navigation link text from file properties
(previous-link-text (or (save-excursion
(goto-char (point-min))
(org-entry-get nil "PREVIOUS_TITLE"))
"Previous:"))
(next-link-text (or (save-excursion
(goto-char (point-min))
(org-entry-get nil "NEXT_TITLE"))
"Next:"))
;; Read index title from file properties
(index-title (save-excursion
(goto-char (point-min))
(org-entry-get nil "INDEX_TITLE")))
(index-links '()))
(when (null headings)
(user-error "No first-level headings found in buffer"))
;; Build heading name map for link resolution
;; Maps heading title -> (filename . display-title)
(setq org-gmi--heading-name-map
(mapcar (lambda (h)
(let ((heading-title (plist-get h :title))
(name-prop (plist-get h :name))
(title-prop (plist-get h :title-prop)))
(cons heading-title
(cons (or name-prop
(org-gmi--sanitize-filename heading-title))
(or title-prop heading-title)))))
headings))
;; Ensure output directory exists
(unless (file-exists-p output-dir)
(make-directory output-dir t))
;; Export each heading to its own file
(dotimes (i (length headings))
(let* ((heading (nth i headings))
(prev-heading (and (> i 0) (nth (1- i) headings)))
(next-heading (and (< i (1- (length headings))) (nth (1+ i) headings)))
(heading-title (plist-get heading :title))
(heading-name (plist-get heading :name))
(heading-title-prop (plist-get heading :title-prop))
;; Use TITLE property for display, or fallback to actual title
(display-title (or heading-title-prop heading-title))
;; Use NAME property if set, otherwise sanitize title
(filename (concat (or heading-name
(org-gmi--sanitize-filename heading-title))
".gmi"))
(output-file (expand-file-name filename output-dir))
(begin-pos (plist-get heading :begin))
(end-pos (plist-get heading :end))
;; Extract heading-level INDEX_TITLE property
(heading-index-title (save-excursion
(goto-char begin-pos)
(org-entry-get nil "INDEX_TITLE"))))
;; Collect link for index (use display title)
(push (cons filename display-title) index-links)
;; Export the heading's subtree
(save-excursion
(save-restriction
(goto-char begin-pos)
;; Export this subtree (disable TOC for per-heading export)
(let ((content (org-export-as 'gmi t visible-only nil '(:with-toc nil))))
;; Add navigation links
(with-temp-buffer
(insert content)
(goto-char (point-max))
;; Add newlines before navigation if content doesn't end with enough
(unless (looking-back "\n\n" (- (point) 2))
(insert "\n"))
(unless (looking-back "\n\n\n" (- (point) 3))
(insert "\n"))
;; Add navigation links
(when (or prev-heading next-heading)
(when prev-heading
(let ((prev-filename (concat (or (plist-get prev-heading :name)
(org-gmi--sanitize-filename
(plist-get prev-heading :title)))
".gmi"))
(prev-display-title (or (plist-get prev-heading :title-prop)
(plist-get prev-heading :title))))
(insert (format "=> %s %s %s\n" prev-filename previous-link-text prev-display-title))))
(when next-heading
(let ((next-filename (concat (or (plist-get next-heading :name)
(org-gmi--sanitize-filename
(plist-get next-heading :title)))
".gmi"))
(next-display-title (or (plist-get next-heading :title-prop)
(plist-get next-heading :title))))
(insert (format "=> %s %s %s\n" next-filename next-link-text next-display-title)))))
;; Add link to index.gmi if INDEX_TITLE is set
(when index-title
;; Use heading-level INDEX_TITLE if set, otherwise use file-level
(insert (format "=> index.gmi %s\n" (or heading-index-title index-title))))
;; Write to file
(write-region (point-min) (point-max) output-file)
(message "Exported %s" output-file)
(push output-file output-files)))))))
;; Generate index.gmi file if INDEX_TITLE is set
(when index-title
(let ((index-file (expand-file-name "index.gmi" output-dir)))
(with-temp-file index-file
(insert (org-gmi--build-headline 1 index-title))
(dolist (link (nreverse index-links))
(insert (format "=> %s %s\n" (car link) (cdr link)))))
(message "Generated index file: %s" index-file)
(push index-file output-files)))
;; Clean up heading name map
(setq org-gmi--heading-name-map nil)
(message "Exported %d files" (length output-files))
(nreverse output-files))))
;;;###autoload
(defun org-gmi-publish-to-gemini (plist filename pub-dir)
"Publish an org file to Gemini.
FILENAME is the filename of the Org file to be published. PLIST
is the property list for the given project. PUB-DIR is the
publishing directory.
Return output file name."
(org-publish-org-to 'gmi filename ".gmi" plist pub-dir))
;;;###autoload
(defun org-gmi-publish-to-gemini-per-heading (plist filename pub-dir)
"Publish an org file to multiple Gemini files, one per first-level heading.
Each first-level heading in FILENAME is published to its own .gmi file
in PUB-DIR. Navigation links to previous and next heading files are
appended to each file.
FILENAME is the filename of the Org file to be published. PLIST
is the property list for the given project. PUB-DIR is the
publishing directory.
Return list of output file names."
(let ((output-files '()))
(with-temp-buffer
(insert-file-contents filename)
(org-mode)
(setq output-files
(org-gmi-export-to-gemini-per-heading
nil
(plist-get plist :with-visibility)
pub-dir)))
output-files))
(provide 'ox-gmi)
;;; ox-gmi.el ends here
This allows calling ox with C-c C-e P p, which then recursively publishes the whole thing as a tree of .gmi files. It's kinda neat actually, works like magic.
Then the whole thing is served to the rest of the internet as a collection of plain files. That's about it.
Feel free to configure org-export for a project that has a single org file like
:PROPERTIES: :INDEX_TITLE: My Awesome Capsule :PREVIOUS_TITLE: navigate to the previous file: :NEXT_TITLE: navigate to the next file: :END: * headphones :PROPERTIES: :TITLE: A purposefully under-researched, yet lucky choice of headphones :END: This note is eternal wip. Write research? Ain't nobody got time for that. * feta :PROPERTIES: :TITLE: Feta cheese :END: Feta cheese. Love it. * honey :PROPERTIES: :INDEX_TITLE: This will override the caption on the link to root :TITLE: All sorts of honeys :END: Love all sorts of honeys, too. Also, good on feta * about :PROPERTIES: :TITLE: This capsule :NAME: this :END: Using some badass org-export package to transform a single org file into a full-blown capsule, how about that
△ That's about it. Try it out, let me know how it works for you