;; -*- lexical-binding: t -*- (require 'ennu-html) (require 'ennu-image) (require 'ox) (require 'seq) (require 'cl) (require 'memoize) (defvar ennu-version "0.1.0" "Ennu version string") (defmacro ennu-with-file-contents (file &rest body) "Create a temporary buffer, insert contents of FILE into that buffer and evaluate BODY. The value returned is the value of the last form in BODY." (declare (indent defun)) `(with-temp-buffer (insert-file-contents ,file) ,@body)) (defun ennu--org-output-filename (filename) (concat (file-name-sans-extension filename) ".html")) (defun ennu-publish-post (post) ;; TODO: Tangle post `((,post) (,(ennu--org-output-filename post)) ,(lambda (output-file) (ennu-with-file-contents post (org-export-to-file 'ennu-html output-file)) ;; (when-let (tangle-dir (or (plist-get posts-plist :valai-tangle-directory) ;; valai-tangle-directory)) ;; (dolist (tangled-file ;; (org-babel-tangle-file post-path)) ;; (when (member (file-name-extension tangled-file) '("sh" "py")) ;; (chmod tangled-file (string-to-number "755" 8))) ;; (make-directory tangle-dir t) ;; (rename-file tangled-file ;; (expand-file-name (file-name-nondirectory tangled-file) ;; tangle-dir) ;; t))) ))) (defun ennu-publish-page (pages-directory page) `((,page) (,(ennu--org-output-filename (string-remove-prefix (file-name-as-directory pages-directory) page))) ,(lambda (output-file) (ennu-with-file-contents page (org-export-to-file 'html output-file))))) (defun ennu-video-poster (video) (pcase (directory-files (ennu-setting :images-directory) nil (concat (file-name-sans-extension video) "\\.\\(jpg\\|png\\)$")) (`(,poster . ,_) poster) (`() (user-error "Poster for %s not found" video)))) (defun ennu-post-thumbnail (post) (or (plist-get (ennu-post-metadata post) :thumbnail) (seq-some (lambda (link) (pcase link (`("image" . ,path) path) (`("video" . ,path) (ennu-video-poster path)))) (ennu-post-links post)))) (defun ennu-add-tongue-suffix (filename tongue) (pcase tongue ("en" filename) (_ (format "%s.%s.%s" (file-name-sans-extension filename) tongue (file-name-extension filename))))) (defun ennu-publish-index (filename-prefix tongue title subtitle posts-per-page posts) (defun ennu-index-filename (filename-prefix tongue &optional extension page-number) (let ((extension (if extension (concat "." extension) ""))) (ennu-add-tongue-suffix (if page-number (format "%s-%s%s" filename-prefix page-number extension) (concat filename-prefix extension)) tongue))) (let* ((number-of-pages (ceiling (length posts) posts-per-page)) (page-numbers (number-sequence 1 number-of-pages))) `(,posts ,(cons (ennu-add-tongue-suffix (format "%s.html" filename-prefix) tongue) (seq-map (apply-partially 'ennu-index-filename filename-prefix tongue "html") page-numbers)) ,(lambda (home-page &rest output-files) (seq-mapn (lambda (posts page-number output-file) (with-temp-buffer (insert (format "#+TITLE: %s\n" title subtitle)) (insert (format "#+LANGUAGE: %s\n" tongue)) (insert "#+OPTIONS: num:nil toc:nil\n\n") (when subtitle (insert (format "%s\n\n" subtitle))) (seq-do (lambda (post) (let ((metadata (ennu-post-metadata post))) (insert (format "* [[post:%s]]\n" (file-name-base post))) (insert (format-time-string "/%b %e, %Y/\n\n" (plist-get metadata :date))) (when-let ((thumbnail (ennu-post-thumbnail post))) (insert (format "[[thumbnail:%s]]\n\n" thumbnail))) (when-let ((summary (plist-get metadata :summary))) (insert summary) (insert "\n\n")) (when-let ((tags (plist-get metadata :filetags))) (insert "Tags: ") (insert (string-join (seq-map (apply-partially 'format "[[tag:%s]]") tags) ", ")) (insert "\n\n")))) posts) (unless (= page-number 1) (insert (format "[[./%s][Newer posts]]\n" (ennu-index-filename (file-name-nondirectory filename-prefix) tongue nil (1- page-number))))) (unless (= page-number number-of-pages) (insert (format "[[./%s][Older posts]]\n" (ennu-index-filename (file-name-nondirectory filename-prefix) tongue nil (1+ page-number))))) (org-export-to-file 'ennu-html output-file))) (seq-partition posts posts-per-page) page-numbers output-files) (copy-file (first output-files) home-page))))) (defun ennu--absolute-uri (path) (format "%s://%s/%s" (ennu-setting :blog-scheme) (ennu-setting :blog-domain) path)) (defun ennu--atom-date (date) (format-time-string "%Y-%m-%dT%H:%M:%SZ" date)) (defun ennu-publish-feed (feed-file title subtitle rights posts) `(,posts (,feed-file) ,(lambda (output-file) (with-temp-file output-file (insert (xmlgen `(feed :xmlns "http://www.w3.org/2005/Atom" (id ,(ennu--absolute-uri "")) (title ,title) (updated ,(ennu--atom-date (plist-get (ennu-post-metadata (first posts)) :date))) (link :rel "self" :href ,(ennu--absolute-uri feed-file)) (generator ,(format "Emacs %d.%d Org-mode %s ennu %s" emacs-major-version emacs-minor-version (org-version) ennu-version)) (rights ,rights) ,@(when subtitle `((subtitle ,subtitle))) ,@(seq-map 'ennu--feed-entry posts)))))))) (defun ennu--feed-entry (post) (let* ((metadata (ennu-post-metadata post)) (lang (plist-get metadata :lang)) (link (ennu--absolute-uri (ennu--org-output-filename post)))) `(entry (id ,link) (title :xml:lang ,lang ,(plist-get metadata :title)) (updated ,(ennu--atom-date (plist-get metadata :date))) (author (name ,(plist-get metadata :author)) (email ,user-mail-address)) (content :type "html" :xml:lang ,lang ,(ennu-with-file-contents post (org-export-as 'ennu-html nil nil t))) (link :rel "alternate" :href ,link) ,@(seq-map (lambda (tag) `(category :term ,tag)) (plist-get metadata :filetags))))) (defun ennu-setting (property) (pcase property (:blog-scheme (or (plist-get ennu-blog :blog-scheme) "https")) (:atom-feed-file (or (plist-get ennu-blog :atom-feed-file) "blog.atom")) (:index-posts-per-page (or (plist-get ennu-blog :index-posts-per-page) 12)) (:atom-feed-number-of-posts (or (plist-get ennu-blog :atom-feed-number-of-posts) 12)) (:thumbnail-image-width (or (plist-get ennu-blog :thumbnail-image-width) 320)) (:default-image-width (or (plist-get ennu-blog :default-image-width) 640)) (:image-link-width (or (plist-get ennu-blog :image-link-width) 1024)) (:tag-directory (or (plist-get ennu-blog :tag-directory) "tag")) ((or :blog-domain :blog-license :blog-title :images-directory :output-directory :posts-directory :static-directory :tag-directory :video-directory :working-directory) (or (plist-get ennu-blog property) (user-error "Property %s not defined" property))) ((or :blog-subtitle :pages-directory :unattached-static-files) (plist-get ennu-blog property)) (_ (error "Unknown property %s" property)))) (defun ennu-image-output-filename (image width) (format "%s-%spx.%s" (file-name-sans-extension image) width (file-name-extension image))) (defun ennu--expand-relative (name directory) (concat (file-name-as-directory directory) name)) (defun ennu-publish-image (widths image) `((,image) ,(seq-map (apply-partially 'ennu-image-output-filename image) widths) ,(lambda (&rest output-files) (seq-mapn (lambda (output-file width) (ennu-image-optimize-image (ennu-image-resize-image image output-file width))) output-files widths)))) (defun ennu-publish-copy (file) `((,file) (,file) ,(apply-partially 'copy-file file))) (defun ennu-post-links (post) (ennu-with-file-contents post (org-element-map (org-element-parse-buffer) 'link (lambda (link) (pcase link (`(link ,properties . ,_) (let ((link-type (org-element-property :type link))) (when (member link-type (list "image" "static" "video")) (cons link-type (org-element-property :path link)))))))))) (defun plist-put* (plist &rest key-value-pairs) (pcase key-value-pairs (`(,key ,value) (plist-put plist key value)) (`(,key ,value . ,tail) (apply 'plist-put* (plist-put plist key value) tail)))) (defun ennu-post-metadata (post) (ennu--post-metadata-memoized post (file-attribute-modification-time (file-attributes post)))) (defmemoize ennu--post-metadata-memoized (post last-modified) (ennu-with-file-contents post (let ((metadata (org-export-get-environment 'ennu-html)) (export (apply-partially 'org-export-with-backend 'ennu-html))) (seq-do (lambda (key) (unless (plist-member metadata key) (user-error "Metadata %s not specified" key))) ennu-mandatory-metadata) (plist-put* metadata :title (funcall export (first (plist-get metadata :title))) :date (org-timestamp-to-time (first (plist-get metadata :date))) :author (funcall export (first (plist-get metadata :author))))))) (defvar ennu-mandatory-metadata (list :title :date)) (defun newest-file (files) (pcase files (`(,head . ,tail) (seq-reduce (lambda (file1 file2) (if (file-newer-than-file-p file1 file2) file1 file2)) tail head)))) (defun ennu-mkdir-p (directory) (make-directory directory t)) (defun ennu-copy (source destination) "Copy file or directory from SOURCE to DESTINATION." (if (file-directory-p source) (copy-directory source destination) (make-directory (file-name-directory destination) t) (copy-file source destination))) (defun ennu--do-operation (temporary-directory operation) (let ((expand (lambda (directory file) (expand-file-name file directory)))) (pcase operation (`(,input-files ,output-files ,publish) (let ((absolute-output-files (seq-map (apply-partially expand temporary-directory) output-files)) (previous-output-files (seq-map (apply-partially expand (ennu-setting :output-directory)) output-files))) (cond ((and (seq-every-p 'file-exists-p previous-output-files) (file-newer-than-file-p (newest-file previous-output-files) (newest-file input-files))) (message "Skipping publishing %s to %s" input-files output-files) (seq-mapn 'ennu-copy previous-output-files absolute-output-files)) (t (message "Publishing %s to %s" input-files output-files) (seq-do 'ennu-mkdir-p (seq-uniq (seq-map 'file-name-directory absolute-output-files))) (apply publish absolute-output-files)))))))) (defun ennu--later-post (post1 post2) (time-less-p (plist-get (ennu-post-metadata post2) :date) (plist-get (ennu-post-metadata post1) :date))) (defun ennu-publish-static-file (file) `((,file) (,file) ,(apply-partially 'copy-file file))) (defun ennu-posts (posts-directory) (sort (file-expand-wildcards (concat (file-name-as-directory (ennu-setting :posts-directory)) "*.org")) 'ennu--later-post)) (defun ennu-publish-link (link) (pcase link (`("image" . ,path) (ennu-publish-image (list (ennu-setting :default-image-width) (ennu-setting :image-link-width)) (ennu--expand-relative path (ennu-setting :images-directory)))) (`("static" . ,path) (ennu-publish-copy (ennu--expand-relative path (ennu-setting :static-directory)))) (`("video" . ,path) (ennu-publish-copy (ennu--expand-relative path (ennu-setting :video-directory))) (ennu-publish-copy (ennu--expand-relative (ennu-video-poster path) (ennu-setting :images-directory)))))) (defun ennu-post-tags (post) (plist-get (ennu-post-metadata post) :filetags)) (defun ennu-post-tongue (post) (plist-get (ennu-post-metadata post) :language)) (defmacro ennu-with-current-directory (directory &rest body) "Change to DIRECTORY, evaluate BODY and restore the current working directory. The value returned is the value of the last form in BODY." (declare (indent defun)) (let ((current-directory-symbol (make-symbol "current-directory"))) `(let ((,current-directory-symbol default-directory)) (unwind-protect (progn (cd ,directory) ,@body) (cd ,current-directory-symbol))))) (defmacro ennu-with-temporary-directory (temporary-directory &rest body) "Create temporary directory, evaluate BODY with the absolute path of that directory assigned to TEMPORARY-DIRECTORY and finally delete the temporary directory. The value returned is the value of the last form in BODY." (declare (indent defun)) `(let ((,temporary-directory (make-temp-file "ennu" t))) (unwind-protect ,@body (delete-directory ,temporary-directory t)))) (defun ennu-publish () (interactive) (let ((make-backup-files nil) (blog-title (ennu-setting :blog-title)) (blog-subtitle (ennu-setting :blog-subtitle)) (posts-per-page (ennu-setting :index-posts-per-page))) (ennu-with-current-directory (ennu-setting :working-directory) (ennu-with-temporary-directory temporary-directory (seq-do (apply-partially 'ennu--do-operation temporary-directory) (append (let* ((posts (ennu-posts (ennu-setting :posts-directory))) (tags (seq-uniq (seq-mapcat 'ennu-post-tags posts))) (tongues (seq-uniq (seq-map 'ennu-post-tongue posts)))) (append ;; Publish posts (seq-map 'ennu-publish-post posts) ;; Publish feed (list (ennu-publish-feed (ennu-setting :atom-feed-file) blog-title blog-subtitle (ennu-setting :blog-license) (seq-take posts (ennu-setting :atom-feed-number-of-posts)))) ;; Publish indices (seq-map (lambda (tongue) (ennu-publish-index "index" tongue blog-title blog-subtitle posts-per-page (seq-filter (lambda (post) (string= tongue (ennu-post-tongue post))) posts))) tongues) (seq-map (lambda (tag) (let ((posts (seq-filter (lambda (post) (member tag (ennu-post-tags post))) posts))) (ennu-publish-index (ennu--expand-relative tag (ennu-setting :tag-directory)) (ennu-post-tongue (first posts)) tag "" posts-per-page posts))) tags) ;; Publish links (seq-map 'ennu-publish-link (seq-uniq (seq-mapcat 'ennu-post-links posts))) ;; Publish thumbnails (seq-map (apply-partially 'ennu-publish-image (list (ennu-setting :thumbnail-image-width))) (seq-map (lambda (image) (ennu--expand-relative image (ennu-setting :images-directory))) (seq-uniq (seq-filter 'identity (seq-map 'ennu-post-thumbnail posts))))))) ;; Publish pages (when-let ((pages-directory (ennu-setting :pages-directory))) (seq-map (apply-partially 'ennu-publish-page pages-directory) (seq-map (apply-partially 'string-remove-prefix (file-name-as-directory (expand-file-name default-directory))) (directory-files-recursively pages-directory "\\.org$")))) ;; Publish unattached static files (seq-map 'ennu-publish-static-file (ennu-setting :unattached-static-files)))) ;; Replace old output directory (let ((output (ennu-setting :output-directory))) (delete-directory output t) (rename-file temporary-directory output t)))))) (provide 'ennu)