grug

A static website generator written for Guile Scheme
Log | Files | Refs | README | LICENSE

commit 310521c415f9e8b758933fbcb09ec836482879ec
parent e17d1e847a955dbfce44d4cee8621d222974bfc6
Author: Luke Willis <lukejw@loquat.dev>
Date:   Sat,  5 Jul 2025 02:21:40 -0400

Tie a lot of things together

Diffstat:
Aexample/grug.scm | 7+++++++
Mexample/pages/about.md | 5+++++
Aexample/pages/index.md | 8++++++++
Mgrug/builders.scm | 164+++++++++++++++++++++++++++++++++++++++----------------------------------------
Agrug/site.scm | 32++++++++++++++++++++++++++++++++
Mgrug/ui.scm | 25++++++++++++++++++++-----
Mgrug/utils.scm | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 208 insertions(+), 88 deletions(-)

diff --git a/example/grug.scm b/example/grug.scm @@ -0,0 +1,7 @@ +(use-modules (grug site) + (grug builders)) + +(site #:metadata `((title . "Example Site")) + #:builders (list (simple-pages "pages") + (copy-directory "css") + (blog "posts" #:prefix "/blog"))) diff --git a/example/pages/about.md b/example/pages/about.md @@ -1,2 +1,7 @@ `((title . "About Me")) This is some information about me. + +- I am `N/A` years old +- I am a test website + +Isn't that cool? diff --git a/example/pages/index.md b/example/pages/index.md @@ -0,0 +1,8 @@ +`((title . "Welcome")) +This is the homepage for this example site. + +Feel free to visit these pages: +- [About Me](about.html) +- [Blog](blog/index.html) + +Everything should work! diff --git a/grug/builders.scm b/grug/builders.scm @@ -5,6 +5,7 @@ #:use-module (htmlprag) #:use-module (grug utils) #:use-module (grug readers) + #:use-module (grug site) #:export (copy-directory simple-pages blog)) @@ -16,25 +17,25 @@ ;; This is good for things like CSS folders. (define* (copy-directory directory #:key - (prefix "site")) - (display "copy-directory\n") + (prefix "")) + (lambda (site) + (display "copy-directory\n") + (for-each + (lambda (path) + (let* ((output-path (string-append (site-build-directory site) prefix "/" path)) + (output-dir (dirname output-path))) + (format #t "\t~A -> ~A\n" path output-path) + (mkdir-p output-dir) + (copy-file path output-path))) + (reverse (ls-recursive directory))))) - (for-each - (lambda (path) - (let* ((output-path (string-append prefix "/" path)) - (output-dir (dirname output-path))) - (format #t "\t~A -> ~A\n" path output-path) - (unless (file-exists? output-dir) (mkdir output-dir)) - (copy-file path output-path))) - (reverse (ls-recursive directory)))) - -;; metadata should be an a-list -(define (basic-template body metadata) +;; Metadata should be an a-list +(define (basic-template site-metadata metadata body) `(*TOP* (*DECL* DOCTYPE html) (html (head (meta (@ (charset "utf-8"))) - (title ,(assoc-ref metadata 'title))) + (title ,(assoc-ref metadata 'title) " - " ,(assoc-ref site-metadata 'title))) (body (h1 ,(assoc-ref metadata 'title)) ,@body)))) @@ -43,31 +44,30 @@ ;; This is good for things like 'About' or 'Contact' pages. (define* (simple-pages directory #:key - (prefix "site") + (prefix "") (reader cmark) (template basic-template)) - (display "simple-pages\n") - - ;; Iterate through files in directory - (for-each - (lambda (path) - (let* ((output-dir - (string-append prefix - (substring (dirname path) - (string-length directory)))) - (output-path - (string-append output-dir "/" - (strip-extension path) - ".html"))) - (unless (file-exists? output-dir) (mkdir output-dir)) - (format #t "\t~A -> ~A\n" path output-path) - (receive (metadata contents) - (load-string-with-metadata path) - (let* ((base-shtml (reader contents)) - (built-shtml (template base-shtml metadata)) - (output (shtml->html built-shtml))) - (write-string-to-path output output-path))))) - (reverse (ls-recursive directory)))) + (lambda (site) + (display "simple-pages\n") + (for-each + (lambda (path) + (let* ((output-dir + (string-append (site-build-directory site) prefix + (substring (dirname path) + (string-length directory)))) + (output-path + (string-append output-dir "/" + (strip-extension path) + ".html"))) + (mkdir-p output-dir) + (format #t "\t~A -> ~A\n" path output-path) + (receive (metadata contents) + (load-string-with-metadata path) + (let* ((base-shtml (reader contents)) + (built-shtml (template (site-metadata site) metadata base-shtml)) + (output (shtml->html built-shtml))) + (write-string-to-path output output-path))))) + (reverse (ls-recursive directory))))) ;; `posts` should be a list of a-lists containing post metadata (define (basic-collection-template posts) @@ -76,18 +76,16 @@ (lambda (a b) (string>? (assoc-ref a 'date) (assoc-ref b 'date)))) - ;; Return shtml for all the posts posts `((h1 "Posts") - ,@(map (lambda (post) - `(article (h2 (a (@ (href ,(assoc-ref post 'uri))) - ,(assoc-ref post 'title))) - ;; Parse ISO 8601 date from 'date and reformat it to look nice - (h3 ,(date->string - (string->date (assoc-ref post 'date) - "~Y~m~d") - "~B ~d, ~Y")) - (p ,(assoc-ref post 'description)))) - posts))) + ,@(map + (lambda (post) + `(article (h2 (a (@ (href ,(assoc-ref post 'uri))) + ,(assoc-ref post 'title))) + ;; Parse ISO 8601 date from 'date and reformat it to look nice + (h3 ,(date->string + (string->date (assoc-ref post 'date) "~Y~m~d") "~B ~d, ~Y")) + (p ,(assoc-ref post 'description)))) + posts))) ;; Build a blog using the posts in the given directory. ;; @@ -95,43 +93,43 @@ ;; Posts have 'uri added to their metadata. (define* (blog directory #:key - (prefix "site") + (prefix "") (post-prefix "posts") (metadata `((title . "Recent Posts"))) (reader cmark) (template basic-template) (collection-template basic-collection-template)) - (display "blog\n") - - (let ((posts (map - (lambda (path) - (let* ((output-name - (string-append (strip-extension path) - ".html")) - (relative-output-dir - (string-append post-prefix - (substring (dirname path) - (string-length directory)))) - (relative-output-path - (string-append relative-output-dir "/" - output-name)) - (output-dir - (string-append prefix "/" relative-output-dir)) - (output-path - (string-append output-dir "/" - output-name))) - (unless (file-exists? output-dir) (mkdir output-dir)) - (format #t "\t~A -> ~A\n" path output-path) - (receive (metadata contents) - (load-string-with-metadata path) - (let* ((base-shtml (cmark contents)) - (built-shtml (template base-shtml metadata)) - (output (shtml->html built-shtml))) - (write-string-to-path output output-path)) - (acons 'uri relative-output-path metadata)))) - (reverse (ls-recursive directory))))) - ;; Build index.html - (let ((output (shtml->html (template (collection-template posts) metadata))) - (output-path (string-append prefix "/index.html"))) - (format #t "\t~A\n" output-path) - (write-string-to-path output output-path)))) + (lambda (site) + (display "blog\n") + (let ((posts (map + (lambda (path) + (let* ((output-name + (string-append (strip-extension path) + ".html")) + (relative-output-dir + (string-append post-prefix + (substring (dirname path) + (string-length directory)))) + (relative-output-path + (string-append relative-output-dir "/" + output-name)) + (output-dir + (string-append (site-build-directory site) prefix "/" relative-output-dir)) + (output-path + (string-append output-dir "/" + output-name))) + (format #t "\t~A -> ~A\n" path output-path) + (mkdir-p output-dir) + (receive (metadata contents) + (load-string-with-metadata path) + (let* ((base-shtml (cmark contents)) + (built-shtml (template (site-metadata site) metadata base-shtml)) + (output (shtml->html built-shtml))) + (write-string-to-path output output-path)) + (acons 'uri relative-output-path metadata)))) + (reverse (ls-recursive directory))))) + ;; Build index.html + (let ((output (shtml->html (template (site-metadata site) metadata (collection-template posts)))) + (output-path (string-append (site-build-directory site) prefix "/index.html"))) + (format #t "\t~A\n" output-path) + (write-string-to-path output output-path))))) diff --git a/grug/site.scm b/grug/site.scm @@ -0,0 +1,32 @@ +(define-module (grug site) + #:use-module (srfi srfi-9) + #:use-module (srfi srfi-26) + #:use-module (grug readers) + #:use-module (grug utils) + #:export (site + site? + site-build-directory + site-metadata + site-reader + site-builders + build-site)) + +(define-record-type <site> + (make-site build-directory metadata reader builders) + site? + (build-directory site-build-directory) + (metadata site-metadata) + (reader site-reader) + (builders site-builders)) + +(define* (site #:key + (build-directory "site") + (metadata `((title . "Grug Site"))) + (reader cmark) + (builders '())) + (make-site build-directory metadata reader builders)) + +(define (build-site site) + (delete-file-recursively (site-build-directory site)) + (mkdir (site-build-directory site)) + (for-each (cut <> site) (site-builders site))) diff --git a/grug/ui.scm b/grug/ui.scm @@ -1,12 +1,27 @@ ;;; Grug user interface. (define-module (grug ui) + #:declarative? #f #:use-module (ice-9 pretty-print) - #:use-module (grug builders) + #:use-module (grug site) #:export (main)) +(define (absolute-file-name file-name) + "Return a an absolute file name string relative to the current +working directory for FILE-NAME, a relative file name string. If +FILE-NAME happens to already be absolute, FILE-NAME is returned +as-is." + (if (absolute-file-name? file-name) + file-name + (string-append (getcwd) "/" file-name))) + +(define* (load-config filename) + (if (file-exists? filename) + (let ((obj (load (absolute-file-name filename)))) + (if (site? obj) + obj + (error "Configuration does not return site"))) + (error "Configuration not found"))) + (define* (main prog . args) - (pretty-print args) - (simple-pages "pages") - (copy-directory "css") - (blog "posts")) + (build-site (load-config "grug.scm"))) diff --git a/grug/utils.scm b/grug/utils.scm @@ -4,9 +4,12 @@ #:use-module (ice-9 eval-string) #:use-module (ice-9 ftw) #:use-module (ice-9 rdelim) + #:use-module (ice-9 match) #:use-module (ice-9 textual-ports) #:export (strip-extension ls-recursive + delete-file-recursively + mkdir-p load-string-with-metadata write-string-to-path)) @@ -25,6 +28,58 @@ (noop (lambda (path stat result) result))) (file-system-fold enter? leaf noop noop noop err '() directory))) +;; Written by Ludovic Courtès for GNU Guix. +(define* (delete-file-recursively dir + #:key follow-mounts?) + "Delete DIR recursively, like `rm -rf', without following symlinks. Don't +follow mount points either, unless FOLLOW-MOUNTS? is true. Report but ignore +errors." + (let ((dev (stat:dev (lstat dir)))) + (file-system-fold (lambda (dir stat result) ; enter? + (or follow-mounts? + (= dev (stat:dev stat)))) + (lambda (file stat result) ; leaf + (delete-file file)) + (const #t) ; down + (lambda (dir stat result) ; up + (rmdir dir)) + (const #t) ; skip + (lambda (file stat errno result) + (format (current-error-port) + "warning: failed to delete ~a: ~a~%" + file (strerror errno))) + #t + dir + + ;; Don't follow symlinks. + lstat))) + +;; Written by Ludovic Courtès for GNU Guix. +(define (mkdir-p dir) + "Create directory DIR and all its ancestors." + (define absolute? + (string-prefix? "/" dir)) + + (define not-slash + (char-set-complement (char-set #\/))) + + (let loop ((components (string-tokenize dir not-slash)) + (root (if absolute? + "" + "."))) + (match components + ((head tail ...) + (let ((path (string-append root "/" head))) + (catch 'system-error + (lambda () + (mkdir path) + (loop tail path)) + (lambda args + (if (= EEXIST (system-error-errno args)) + (loop tail path) + (apply throw args)))))) + (() #t)))) + ;; Load a file, parse metadata from the top of the file and return the rest as a string. ;; Using eval-string might be overkill. Perhaps a custom parser should be written? ;; TODO: Allow for multi-line metadata