244 lines
10 KiB
EmacsLisp
244 lines
10 KiB
EmacsLisp
;; build.el — org-publish configuration for personal site
|
|
(require 'org)
|
|
(require 'ox-publish)
|
|
(require 'ox-html)
|
|
|
|
(setq org-publish-use-timestamps-flag nil
|
|
org-export-with-broken-links t
|
|
org-html-validation-link nil
|
|
org-html-head-include-default-style nil
|
|
org-html-head-include-scripts nil)
|
|
|
|
(setq site-nav
|
|
"<div class=\"site-contact\">
|
|
<a href=\"mailto:aner@zakobar.com\" class=\"contact-link\">
|
|
<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"2\" y=\"4\" width=\"20\" height=\"16\" rx=\"2\"/><path d=\"m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7\"/></svg>
|
|
aner@zakobar.com
|
|
</a>
|
|
<a href=\"https://www.linkedin.com/in/aner-zakobar/\" class=\"contact-link\" target=\"_blank\" rel=\"noopener\">
|
|
<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z\"/><rect x=\"2\" y=\"9\" width=\"4\" height=\"12\"/><circle cx=\"4\" cy=\"4\" r=\"2\"/></svg>
|
|
linkedin.com/in/aner-zakobar
|
|
</a>
|
|
<a href=\"/feed.xml\" class=\"contact-link\">
|
|
<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M4 11a9 9 0 0 1 9 9\"/><path d=\"M4 4a16 16 0 0 1 16 16\"/><circle cx=\"5\" cy=\"19\" r=\"1\" fill=\"currentColor\" stroke=\"none\"/></svg>
|
|
RSS
|
|
</a>
|
|
</div>")
|
|
|
|
(setq blog-nav
|
|
"<div class=\"blog-nav\">
|
|
<a href=\"/\" class=\"back-link\">← aner.zakobar.com</a>
|
|
</div>")
|
|
|
|
(defun things-extract-keyword (file keyword)
|
|
"Extract #+KEYWORD: value from FILE, or nil if absent/empty."
|
|
(with-temp-buffer
|
|
(insert-file-contents file)
|
|
(goto-char (point-min))
|
|
(when (re-search-forward
|
|
(concat "^#\\+" keyword ":[ \t]*\\(.*\\)$") nil t)
|
|
(let ((v (string-trim (match-string 1))))
|
|
(unless (string= v "") v)))))
|
|
|
|
(defun things-generate-page (_project)
|
|
"Scan things/ and write content/generated/things-body.org."
|
|
(let* ((things-dir "./things")
|
|
(gen-dir "./content/generated")
|
|
(files (when (file-directory-p things-dir)
|
|
(file-expand-wildcards (concat things-dir "/*.org") t))))
|
|
(make-directory gen-dir t)
|
|
(with-temp-file (concat gen-dir "/things-body.org")
|
|
(insert "#+BEGIN_EXPORT html\n<div class=\"things-grid\">\n")
|
|
(dolist (file files)
|
|
(let* ((title (things-extract-keyword file "THINGS_TITLE"))
|
|
(desc (things-extract-keyword file "THINGS_DESC"))
|
|
(path (things-extract-keyword file "THINGS_PATH"))
|
|
)
|
|
(when (and title desc path)
|
|
(insert
|
|
(concat
|
|
"<div class=\"things-card\">\n"
|
|
" <div class=\"things-card-body\">\n"
|
|
" <h3><a href=\"/" path "/\">" title "</a></h3>\n"
|
|
" <p>" desc "</p>\n"
|
|
" </div>\n"
|
|
"</div>\n")))))
|
|
(insert "</div>\n#+END_EXPORT\n"))))
|
|
|
|
(defun blog-escape-xml (str)
|
|
"Escape XML special characters in STR."
|
|
(let ((s (or str "")))
|
|
(setq s (replace-regexp-in-string "&" "&" s))
|
|
(setq s (replace-regexp-in-string "<" "<" s))
|
|
(setq s (replace-regexp-in-string ">" ">" s))
|
|
s))
|
|
|
|
(defun blog-date-to-rfc822 (date-str)
|
|
"Convert YYYY-MM-DD string to RFC 822 format."
|
|
(let* ((parts (split-string date-str "-"))
|
|
(year (string-to-number (nth 0 parts)))
|
|
(month (string-to-number (nth 1 parts)))
|
|
(day (string-to-number (nth 2 parts)))
|
|
(month-names '("Jan" "Feb" "Mar" "Apr" "May" "Jun"
|
|
"Jul" "Aug" "Sep" "Oct" "Nov" "Dec"))
|
|
(day-names '("Sun" "Mon" "Tue" "Wed" "Thu" "Fri" "Sat"))
|
|
(time (encode-time 0 0 0 day month year))
|
|
(decoded (decode-time time))
|
|
(dow (nth 6 decoded)))
|
|
(format "%s, %02d %s %04d 00:00:00 +0000"
|
|
(nth dow day-names)
|
|
day
|
|
(nth (1- month) month-names)
|
|
year)))
|
|
|
|
(defun blog-generate-rss (_project)
|
|
"Write public/feed.xml from all blog posts."
|
|
(let* ((blog-dir "./content/blog")
|
|
(base-url "https://aner.zakobar.com")
|
|
(pattern (concat blog-dir "/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]-*.org"))
|
|
(files (sort (file-expand-wildcards pattern t)
|
|
(lambda (a b) (string> (file-name-base a)
|
|
(file-name-base b))))))
|
|
(make-directory "./public" t)
|
|
(with-temp-file "./public/feed.xml"
|
|
(insert "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n")
|
|
(insert "<rss version=\"2.0\" xmlns:atom=\"http://www.w3.org/2005/Atom\">\n")
|
|
(insert " <channel>\n")
|
|
(insert " <title>Aner Zakobar</title>\n")
|
|
(insert (format " <link>%s</link>\n" base-url))
|
|
(insert " <description>Personal blog of Aner Zakobar</description>\n")
|
|
(insert (format " <atom:link href=\"%s/feed.xml\" rel=\"self\" type=\"application/rss+xml\"/>\n" base-url))
|
|
(dolist (file files)
|
|
(let* ((base (file-name-base file))
|
|
(date (substring base 0 10))
|
|
(title (blog-escape-xml (blog-extract-title file)))
|
|
(excerpt (blog-escape-xml (blog-extract-excerpt file)))
|
|
(url (format "%s/blog/%s.html" base-url base)))
|
|
(insert " <item>\n")
|
|
(insert (format " <title>%s</title>\n" title))
|
|
(insert (format " <link>%s</link>\n" url))
|
|
(insert (format " <guid>%s</guid>\n" url))
|
|
(insert (format " <pubDate>%s</pubDate>\n" (blog-date-to-rfc822 date)))
|
|
(insert (format " <description>%s</description>\n" excerpt))
|
|
(insert " </item>\n")))
|
|
(insert " </channel>\n")
|
|
(insert "</rss>\n"))))
|
|
|
|
(defun site-prepare (project)
|
|
"Run all site preparation steps."
|
|
(blog-generate-listings project)
|
|
(blog-generate-rss project)
|
|
(things-generate-page project))
|
|
|
|
(defun blog-extract-title (file)
|
|
"Extract #+TITLE: value from FILE."
|
|
(with-temp-buffer
|
|
(insert-file-contents file)
|
|
(goto-char (point-min))
|
|
(if (re-search-forward "^#\\+TITLE:[ \t]*\\(.+\\)$" nil t)
|
|
(string-trim (match-string 1))
|
|
(file-name-base file))))
|
|
|
|
(defun blog-extract-excerpt (file &optional max-chars)
|
|
"Extract first body paragraph from FILE, up to MAX-CHARS characters."
|
|
(let ((max (or max-chars 250)))
|
|
(with-temp-buffer
|
|
(insert-file-contents file)
|
|
(goto-char (point-min))
|
|
;; Skip metadata lines and headings
|
|
(while (and (not (eobp))
|
|
(looking-at "^\\(#\\+\\|\\*\\|[ \t]*$\\)"))
|
|
(forward-line 1))
|
|
(let ((start (point)))
|
|
;; Collect lines until blank line or heading
|
|
(while (and (not (eobp))
|
|
(not (looking-at "^\\(\\*\\|[ \t]*$\\)")))
|
|
(forward-line 1))
|
|
(let ((text (string-trim (buffer-substring-no-properties start (point)))))
|
|
;; Collapse whitespace
|
|
(setq text (replace-regexp-in-string "[ \t\n]+" " " text))
|
|
(if (> (length text) max)
|
|
(concat (substring text 0 max) "...")
|
|
text))))))
|
|
|
|
(defun blog-generate-listings (_project)
|
|
"Scan content/blog/ and generate recent-posts.org and all-posts.org."
|
|
(let* ((blog-dir "./content/blog")
|
|
(gen-dir "./content/generated")
|
|
(pattern (concat blog-dir "/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]-*.org"))
|
|
(files (sort (file-expand-wildcards pattern t)
|
|
(lambda (a b) (string> (file-name-base a)
|
|
(file-name-base b)))))
|
|
(make-entry
|
|
(lambda (file)
|
|
(let* ((base (file-name-base file))
|
|
(date (substring base 0 10))
|
|
(title (blog-extract-title file))
|
|
(excerpt (blog-extract-excerpt file))
|
|
(link (concat "file:../blog/" base ".org")))
|
|
(concat
|
|
"*[[" link "][" title "]]* — " date "\n\n"
|
|
excerpt "\n\n"
|
|
"[[" link "][Read more →]]\n\n")))))
|
|
(make-directory gen-dir t)
|
|
(with-temp-file (concat gen-dir "/recent-posts.org")
|
|
(let ((first t))
|
|
(dolist (file (seq-take files 3))
|
|
(unless first (insert "-----\n\n"))
|
|
(insert (funcall make-entry file))
|
|
(setq first nil))))
|
|
(with-temp-file (concat gen-dir "/all-posts.org")
|
|
(let ((first t))
|
|
(dolist (file files)
|
|
(unless first (insert "-----\n\n"))
|
|
(insert (funcall make-entry file))
|
|
(setq first nil))))))
|
|
(setq org-publish-project-alist
|
|
`(("site-pages"
|
|
:base-directory "./content"
|
|
:base-extension "org"
|
|
:publishing-directory "./public"
|
|
:recursive t
|
|
:exclude "generated/\\|blog/"
|
|
:publishing-function org-html-publish-to-html
|
|
:preparation-function site-prepare
|
|
:html-head-include-default-style nil
|
|
:html-head-include-scripts nil
|
|
:html-head "<link rel=\"stylesheet\" href=\"/style.css\"><script src=\"/animation.js\" defer></script><link rel=\"alternate\" type=\"application/rss+xml\" title=\"Aner Zakobar\" href=\"/feed.xml\">"
|
|
:html-preamble ,site-nav
|
|
:html-postamble nil
|
|
:with-author nil
|
|
:with-creator nil
|
|
:with-timestamps nil
|
|
:section-numbers nil
|
|
:with-toc nil)
|
|
|
|
("site-blog"
|
|
:base-directory "./content/blog"
|
|
:base-extension "org"
|
|
:publishing-directory "./public/blog"
|
|
:recursive nil
|
|
:publishing-function org-html-publish-to-html
|
|
:html-head-include-default-style nil
|
|
:html-head-include-scripts nil
|
|
:html-head "<link rel=\"stylesheet\" href=\"/style.css\"><script src=\"/animation.js\" defer></script><link rel=\"alternate\" type=\"application/rss+xml\" title=\"Aner Zakobar\" href=\"/feed.xml\">"
|
|
:html-preamble ,blog-nav
|
|
:html-postamble nil
|
|
:with-author nil
|
|
:with-creator nil
|
|
:with-timestamps nil
|
|
:section-numbers nil
|
|
:with-toc nil)
|
|
|
|
("site-static"
|
|
:base-directory "./static"
|
|
:base-extension "css\\|js\\|png\\|jpg\\|jpeg\\|gif\\|svg\\|ico\\|woff2\\|woff\\|ttf"
|
|
:publishing-directory "./public"
|
|
:recursive t
|
|
:publishing-function org-publish-attachment)
|
|
|
|
("site"
|
|
:components ("site-pages" "site-blog" "site-static"))))
|
|
|
|
(org-publish "site" t)
|