Recently Written · git

cl-yag

Mirror and rewrite of Solène Rapenne's cl-yag SSG of renown

git clone https://github.com/equwal/cl-yag

Log | Files | Refs


commit 0428f39713dfcadd7d1ba413e6ebd47430d92cf2
Spenser Truex <truex@equwal.com>
2025-08-31 21:53:02 -0400

Style: Convert comments to docstrings

Better style, now the project can be documented with Quickref, and
inspection tools work more nicely.

 1                         | 218 ++++++++++++++++++++++++++++++++++++++++++++++
 LICENSE                   |   2 +-
 Makefile                  |  20 ++++-
 README.md                 |  40 ++++-----
 cl-yag.ad                 |  16 ++++
 config.lisp               |   4 +
 data/README.md            |  22 +++--
 data/articles.lisp        |   3 +
 generate.lisp             |   7 ++
 generator.lisp            | 136 ++++++++++++++++++++++++-----
 generators/gemini.lisp    |  76 ++++++++++++++++
 generators/gopher.lisp    |  55 ++++++------
 packages.lisp             |  24 +++++
 templates/gemini_head.tpl |   6 ++
 templates/gopher_head.tpl |   2 +-
 15 files changed, 546 insertions(+), 85 deletions(-)
diff --cc 1
index 0000000,0000000..86dd0aa
new file mode 100644
--- /dev/null
+++ b/1
@@@ -1,0 -1,0 +1,218 @@@
++
++(in-package :cl-yag)
++
++;; load data from metadata and load config
++;; TODO: own file?
++
++(defparameter *converters* '())
++(defstruct converter name command extension)
++;; we add a converter to the list of the one availables
++(defun converter(&optional &key name command extension)
++  (setf *converters*
++        (append
++         (list name
++               (make-converter :name name
++                               :command command
++                               :extension extension))
++         *converters*)))
++(defun post(&optional &key title tag date id (tiny nil) (author (getf *config* :webmaster)) (converter nil))
++  (push (make-article :title title
++                      :tag tag
++                      :date (date-parse date)
++                      :rawdate date
++                      :tiny tiny
++                      :author author
++                      :id id
++                      :converter converter)
++        *articles*))
++
++
++(defparameter *articles* '())
++(load "data/articles.lisp")
++(setf *articles* (reverse *articles*))
++
++;;;; GLOBAL VARIABLES
++
++(defparameter *days* '("Monday" "Tuesday" "Wednesday" "Thursday"
++                       "Friday" "Saturday" "Sunday"))
++(defparameter *months* '("January" "February" "March" "April"
++                         "May" "June" "July" "August" "September"
++                         "October" "November" "December"))
++
++(defvar *generators* '())
++
++;; structure to store links
++(defstruct article title tag date id tiny author rawdate converter)
++(defstruct generator key create-site-fn)
++
++(make-generator :key :html
++                :create-site-fn #'create-html-site)
++
++(make-generator :key :gopher
++                :create-site-fn #'create-gopher-hole)
++
++
++;; put these somewhere else and automagically get them pushed to *generators*
++
++;;;; FUNCTIONS
++
++(require 'asdf)
++
++;; return the day of the week
++(defun get-day-of-week(day month year)
++  (multiple-value-bind
++   (second minute hour date month year day-of-week dst-p tz)
++   (decode-universal-time (encode-universal-time 0 0 0 day month year))
++   (declare (ignore second minute hour date month year dst-p tz))
++   day-of-week))
++
++;; parse the date to
++(defun date-parse(date)
++  (if (= 8 (length date))
++      (let* ((year     (parse-integer date :start 0 :end 4))
++             (monthnum (parse-integer date :start 4 :end 6))
++             (daynum   (parse-integer date :start 6 :end 8))
++             (day      (nth (get-day-of-week daynum monthnum year) *days*))
++             (month    (nth (- monthnum 1) *months*)))
++        (list
++         :dayname day
++         :daynumber daynum
++         :monthname month
++         :monthnumber monthnum
++         :year year))
++    nil))
++
++;; common-lisp don't have a replace string function natively
++(defun replace-all (string part replacement &key (test #'char=))
++  (with-output-to-string (out)
++    (loop with part-length = (length part)
++       for old-pos = 0 then (+ pos part-length)
++       for pos = (search part string
++                         :start2 old-pos
++                         :test test)
++       do (write-string string out
++                        :start old-pos
++                        :end (or pos (length string)))
++       when pos do (write-string replacement out)
++       while pos)))
++
++;; common-lisp don't have a split string function natively
++(defun split-str(text &optional (separator #\Space))
++  "this function split a string with separator and return a list"
++  (let ((text (concatenate 'string text (string separator))))
++    (loop for char across text
++       counting char into count
++       when (char= char separator)
++       collect
++       ;; we look at the position of the left separator from right to left
++         (let ((left-separator-position (position separator text :from-end t :end (- count 1))))
++           (subseq text
++                   ;; if we can't find a separator at the left of the current, then it's the start of
++                   ;; the string
++                   (if left-separator-position (+ 1 left-separator-position) 0)
++                   (- count 1))))))
++
++;; load a file as a string
++;; we escape ~ to avoid failures with format
++(defun load-file(path)
++  (if (probe-file path)
++      (with-open-file (stream path)
++        (let ((contents (make-string (file-length stream))))
++          (read-sequence contents stream)
++          contents))
++    (progn
++      (format t "ERROR : file ~a not found. Aborting~%" path)
++      (quit))))
++
++;; save a string in a file
++(defun save-file(path data)
++  (with-open-file (stream path :direction :output :if-exists :supersede)
++		  (write-sequence data stream)))
++
++;; simplify the str replace work
++(defmacro template(before &body after)
++  `(progn
++     (setf output (replace-all output ,before ,@after))))
++
++;; simplify the declaration of a new page type
++(defmacro prepare(template &body code)
++  `(progn
++     (let ((output (load-file ,template)))
++       ,@code
++       output)))
++
++
++;; get the converter object of "article"
++(defmacro with-converter(&body code)
++  `(progn
++     (let ((converter-name (if (article-converter article)
++			       (article-converter article)
++			     (getf *config* :default-converter))))
++       (let ((converter-object (getf *converters* converter-name)))
++	 ,@code))))
++
++;; generate the html file from the source file
++;; using the converter associated with the post
++(defun use-converter-to-html(filename &optional (converter-name nil))
++  (let* ((converter-object (getf *converters*
++                                 (or converter-name
++			             (getf *config* :default-converter))))
++         (output           (converter-command converter-object))
++         (src-file (format nil "~a~a" filename (converter-extension converter-object)))
++         (dst-file (format nil "temp/data/~a.html" filename ))
++         (full-src-file (format nil "data/~a" src-file)))
++      ;; skip generating if the destination exists
++      ;; and is more recent than source
++      (unless (and
++               (probe-file dst-file)
++               (>=
++                (file-write-date dst-file)
++                (file-write-date full-src-file)))
++        (ensure-directories-exist "temp/data/")
++        (template "%IN" src-file)
++        (template "%OUT" dst-file)
++        (format t "~a~%" output)
++        (uiop:run-program output))))
++
++;; format the date
++(defun date-format(format date)
++  (let ((output format))
++    (template "%DayName"     (getf date :dayname))
++    (template "%DayNumber"   (format nil "~2,'0d" (getf date :daynumber)))
++    (template "%MonthName"   (getf date :monthname))
++    (template "%MonthNumber" (format nil "~2,'0d" (getf date :monthnumber)))
++    (template "%Year"        (write-to-string (getf date :year )))
++    output))
++
++;; xml generation of the items for the rss
++(defun generate-rss-item (fn)
++  (apply #'concatenate 'string
++         (loop for article in *articles*
++            for i from 1 to (min (length *articles*) (getf *config* :rss-item-number))
++            collect
++              (prepare "templates/rss-item.tpl"
++                       (template "%%Title%%" (article-title article))
++                       (template "%%Description%%" (load-file (format nil "temp/data/~d.html" (article-id article))))
++		       (template "%%Date%%" (format nil
++						    (date-format "~a, %DayNumber ~a %Year 00:00:00 GMT"
++								 (article-date article))
++						    (subseq (getf (article-date article) :dayname) 0 3)
++						    (subseq (getf (article-date article) :monthname) 0 3)))
++                       (template "%%Url%%" (funcall fn article))))))
++
++
++;; Generate the rss xml data
++(defun generate-rss(fn)
++  (prepare "templates/rss.tpl"
++	   (template "%%Description%%" (getf *config* :description))
++	   (template "%%Title%%" (getf *config* :title))
++	   (template "%%Url%%" (getf *config* :url))
++	   (template "%%Items%%" (generate-rss-item fn))))
++
++; This is function called when running the tool
++(defun generate-site()
++  (loop for i in *generators*
++        do (if (getf *config* (getf i :key))
++             (funcall (getf i :create-site-fn)))))
++
++
diff --cc Makefile
index 22ab1d5,bf8741e..f2e6d33
--- a/Makefile
+++ b/Makefile
@@@ -1,23 -1,19 +1,35 @@@
- LISP=sbcl #ecl
 -LISP=          ecl
++# Find the first available Lisp implementation
++LISP := $(shell for lisp in ecl sbcl clisp abcl gcl cmu corman lucid lispworks allegro; do \
++	if command -v $$lisp >/dev/null 2>&1; then \
++		echo $$lisp; \
++		break; \
++	fi; \
++done)
  
- all: html gopher
 -all: dirs html
++all: html gopher gemini
  
  html: $(HTML) css
 +	mkdir -p "output/html/static"
  	$(LISP) --load generator.lisp
  
 -dirs:
 -	mkdir -p "output/html/static"
 -	mkdir -p "output/gopher"
++gemini:
+ 	mkdir -p "output/gemini/articles/"
+ 
 -
 -clean:
 -	rm -fr output/html/* output/gopher/* "temp"
 -
  css:
  	mkdir -p "output/html/static"
  	cp -fr static/* "output/html/static/"
 +
 +gopher:
 +	mkdir -p "output/gopher"
 +
- clean: cleanhtml cleangopher
++clean: cleanhtml cleangopher cleangemini
 +	rm -fr "temp"
 +
 +cleanhtml:
 +	rm -fr output/html/*
 +
 +cleangopher:
- 	output/gopher/* 
++	rm -fr output/gopher/*
++
++cleangemini:
++	rm -fr output/gemini/* 
diff --cc cl-yag.ad
index 0000000,0000000..1676d44
new file mode 100644
--- /dev/null
+++ b/cl-yag.ad
@@@ -1,0 -1,0 +1,16 @@@
++(defpackage :cl-yag-system (:use :cl :asdf))
++(in-package :cl-yag-system)
++
++(defsystem cl-yag
++  :name "cl-yag"
++  :author "Solene Rapenne"
++  :version "0.1"
++  :maintainer "Spenser Truex <truex@equwal.com>"
++  :license "GNU GPLv3.0"
++  :description "Simple Static Site Generator for arbitrary files and targets."
++  :serial t
++  :components ((:file "packages")
++               (:file "generators/html")
++               (:file "generators/gopher")
++               (:file "generator")
++               (:file "data/articles")))
diff --cc config.lisp
index 0000000,0000000..40e2b94
new file mode 100644
--- /dev/null
+++ b/config.lisp
@@@ -1,0 -1,0 +1,4 @@@
++;; This file loads the configuration from data/articles.lisp
++;; It's separated to avoid circular dependencies when loading generators
++
++(load "data/articles.lisp")
diff --cc generate.lisp
index 0000000,0000000..1e65c85
new file mode 100644
--- /dev/null
+++ b/generate.lisp
@@@ -1,0 -1,0 +1,7 @@@
++(asdf:load-system :cl-yag)
++
++;;;; EXECUTION
++
++(generate-site)
++
++(quit)
diff --cc generator.lisp
index e58a43c,c7cedfe..bd846d1
--- a/generator.lisp
+++ b/generator.lisp
@@@ -1,5 -1,5 +1,22 @@@
  ;;;; GLOBAL VARIABLES
  
++(defun quit ()
++ "quit portably (ripped from Sharp-CLOCC)"
++  #+abcl (ext:quit)
++  #+allegro (excl:exit)
++  #+clisp (ext:quit)
++  #+cmu (ext:quit)
++  #+cormanlisp (win32:exitprocess)
++  #+ecl (ext:quit)
++  #+gcl (lisp:bye)
++  #+lispworks (lw:quit)
++  #+lucid (lcl:quit)
++  #+sbcl (sb-ext:quit)
++  #-(or allegro clisp cmu cormanlisp gcl lispworks lucid sbcl)
++  (error 'not-implemented :proc (list 'quit))
++  )
++
++
  (defparameter *articles* '())
  (defparameter *converters* '())
  (defparameter *days* '("Monday" "Tuesday" "Wednesday" "Thursday"
@@@ -20,18 -16,16 +37,18 @@@
  
  (require 'asdf)
  
 -;; return the day of the week
 +(load "config.lisp")
 +
- ;; return the day of the week
  (defun get-day-of-week(day month year)
++  "Return the day of the week."
    (multiple-value-bind
     (second minute hour date month year day-of-week dst-p tz)
     (decode-universal-time (encode-universal-time 0 0 0 day month year))
     (declare (ignore second minute hour date month year dst-p tz))
     day-of-week))
  
--;; parse the date to
  (defun date-parse(date)
++  "Parse a date into components."
    (if (= 8 (length date))
        (let* ((year     (parse-integer date :start 0 :end 4))
               (monthnum (parse-integer date :start 4 :end 6))
@@@ -57,8 -51,8 +74,8 @@@
                        :converter converter)
          *articles*))
  
--;; we add a converter to the list of the one availables
  (defun converter(&optional &key name command extension)
++  "Add a converter to the list of those available."
    (setf *converters*
          (append
           (list name
@@@ -67,12 -61,13 +84,18 @@@
                                 :extension extension))
           *converters*)))
  
--;; load data from metadata and load config
++;; we add a generator to the list of available ones
++(defun make-generator(&optional &key key create-site-fn)
++  (push (make-generator :key key
++                       :create-site-fn create-site-fn)
++        *generators*))
++
++;; Load data from metadata and config
  (load "data/articles.lisp")
  (setf *articles* (reverse *articles*))
  
 -
--;; common-lisp don't have a replace string function natively
  (defun replace-all (string part replacement &key (test #'char=))
++  "Replace all occurrences of PART with REPLACEMENT in STRING."
    (with-output-to-string (out)
      (loop with part-length = (length part)
         for old-pos = 0 then (+ pos part-length)
@@@ -85,9 -80,9 +108,8 @@@
         when pos do (write-string replacement out)
         while pos)))
  
--;; common-lisp don't have a split string function natively
  (defun split-str(text &optional (separator #\Space))
--  "this function split a string with separator and return a list"
++  "This function splits a string with separator and returns a list."
    (let ((text (concatenate 'string text (string separator))))
      (loop for char across text
         counting char into count
@@@ -101,9 -96,9 +123,8 @@@
                     (if left-separator-position (+ 1 left-separator-position) 0)
                     (- count 1))))))
  
--;; load a file as a string
--;; we escape ~ to avoid failures with format
  (defun load-file(path)
++  "Load a file as a string. We escape ~ to avoid failures with format."
    (if (probe-file path)
        (with-open-file (stream path)
          (let ((contents (make-string (file-length stream))))
@@@ -113,36 -108,27 +134,34 @@@
        (format t "ERROR : file ~a not found. Aborting~%" path)
        (quit))))
  
--;; save a string in a file
  (defun save-file(path data)
++  "Save a string to a file."
    (with-open-file (stream path :direction :output :if-exists :supersede)
  		  (write-sequence data stream)))
  
--;; simplify the str replace work
  (defmacro template(before &body after)
++  "Simplify string replacement work in templates."
    `(progn
       (setf output (replace-all output ,before ,@after))))
  
- ;; simplify the declaration of a new page type
 -;; get the converter object of "article"
 +(defmacro prepare(template &body code)
++  "Simplify the declaration of a new page type by loading a template and executing code."
 +  `(progn
 +     (let ((output (load-file ,template)))
 +       ,@code
 +       output)))
 +
 +
- ;; get the converter object of "article"
  (defmacro with-converter(&body code)
++  "Get the converter object for an article and execute code in that context."
    `(progn
-      (let ((converter-name (if (article-converter article)
- 			       (article-converter article)
+      (let ((converter-name (or (article-converter article)
  			     (getf *config* :default-converter))))
         (let ((converter-object (getf *converters* converter-name)))
  	 ,@code))))
  
--;; generate the html file from the source file
--;; using the converter associated with the post
  (defun use-converter-to-html(filename &optional (converter-name nil))
++  "Generate HTML file from source file using the converter associated with the post."
    (let* ((converter-object (getf *converters*
                                   (or converter-name
  			             (getf *config* :default-converter))))
@@@ -163,8 -149,8 +182,8 @@@
          (format t "~a~%" output)
          (uiop:run-program output))))
  
--;; format the date
  (defun date-format(format date)
++  "Format a date using the given format string with template substitutions."
    (let ((output format))
      (template "%DayName"     (getf date :dayname))
      (template "%DayNumber"   (format nil "~2,'0d" (getf date :daynumber)))
@@@ -173,8 -159,132 +192,80 @@@
      (template "%Year"        (write-to-string (getf date :year )))
      output))
  
- ;; xml generation of the items for the rss
 -;; simplify the declaration of a new page type
 -(defmacro prepare(template &body code)
 -  `(progn
 -     (let ((output (load-file ,template)))
 -       ,@code
 -       output)))
 -
 -;; simplify the file saving by using the layout
+ (defmacro generate(name &body data)
++  "Simplify file saving by using the layout system."
+   `(progn
+      (save-file ,name (generate-layout ,@data))))
+ 
 -;; generate a gemini index file
 -(defun generate-gemini-index(articles)
 -  (let ((output (load-file "templates/gemini_head.tpl")))
 -    (dolist (article articles)
 -      (setf output
 -	    (string
 -	     (concatenate 'string output
 -                          (format nil "=> ~a/articles/~a.gmi ~a-~2,'0d-~2,'0d ~a~%"
 -                                  (getf *config* :gemini-path)
 -                                  (article-id article)
 -                                  (getf (article-date article) :year)
 -                                  (getf (article-date article) :monthnumber)
 -                                  (getf (article-date article) :daynumber)
 -                                  (article-title article))))))
 -    output))
 -
 -;; generate a gopher index file
 -(defun generate-gopher-index(articles)
 -  (let ((output (load-file "templates/gopher_head.tpl")))
 -    (dolist (article articles)
 -      (setf output
 -	    (string
 -	     (concatenate 'string output
 -                          (format nil (getf *config* :gopher-format)
 -                                  0 ;;;; gopher type, 0 for text files
 -				  ;; here we create a 80 width char string with title on the left
 -				  ;; and date on the right
 -				  ;; we truncate the article title if it's too large
 -				  (let ((title (format nil "~80a"
 -						       (if (< 80 (length (article-title article)))
 -							   (subseq (article-title article) 0 80)
 -							   (article-title article)))))
 -				    (replace title (article-rawdate article) :start1 (- (length title) (length (article-rawdate article)))))
 -				  (concatenate 'string
 -                                               (getf *config* :gopher-path) "/article-" (article-id article) ".txt")
 -				  (getf *config* :gopher-server)
 -				  (getf *config* :gopher-port)
 -				  )))))
 -    output))
 -
 -;; generate the list of tags
+ (defun articles-by-tag()
++  "Generate a list of tags with associated article IDs."
+   (let ((tag-list))
+     (loop for article in *articles* do
+ 	  (when (article-tag article) ;; we don't want an error if no tag
+ 	    (loop for tag in (split-str (article-tag article)) do ;; for each word in tag keyword
+ 		  (setf (getf tag-list (intern tag "KEYWORD")) ;; we create the keyword is inexistent and add ID to :value
+ 			(list
+ 			 :name tag
+ 			 :value (push (article-id article) (getf (getf tag-list (intern tag "KEYWORD")) :value)))))))
+     (loop for i from 1 to (length tag-list) by 2 collect ;; removing the keywords
+ 	  (nth i tag-list))))
+ 
 -;; generates the html of the list of tags for an article
+ (defun get-tag-list-article(&optional article)
++  "Generate HTML for the list of tags for a specific article."
+   (apply #'concatenate 'string
+          (mapcar #'(lambda (item)
+                      (prepare "templates/one-tag.tpl" (template "%%Name%%" item)))
+                  (split-str (article-tag article)))))
+ 
 -;; generates the html of the whole list of tags
+ (defun get-tag-list()
++  "Generate HTML for the complete list of all tags."
+   (apply #'concatenate 'string
+          (mapcar #'(lambda (item)
+                      (prepare "templates/one-tag.tpl"
+                               (template "%%Name%%" (getf item :name))))
+                  (articles-by-tag))))
+ 
 -
 -;; generates the html of only one article
 -;; this is called in a loop to produce the homepage
+ (defun create-article(article &optional &key (tiny t) (no-text nil))
++  "Generate HTML for a single article. Called in a loop to produce the homepage."
+   (prepare "templates/article.tpl"
+ 	   (template "%%Author%%" (let ((author (article-author article)))
+                                     (or author (getf *config* :webmaster))))
+ 	   (template "%%Date%%"   (date-format (getf *config* :date-format)
+ 					       (article-date article)))
+            (template "%%Raw-Date%%" (article-rawdate article))
+            (template "%%Title%%"  (article-title article))
+            (template "%%Id%%"     (article-id article))
+ 	   (template "%%Tags%%"   (get-tag-list-article article))
+ 	   (template "%%Date-Url%%"  (date-format "%Year-%MonthNumber-%DayNumber"
+ 						  (article-date article)))
+ 	   (template "%%Text%%"   (if no-text
+ 				      ""
+                                       (if (and tiny (article-tiny article))
+                                           (format nil "<p>~a</p>" (article-tiny article))
+                                           (load-file (format nil "temp/data/~d.html" (article-id article))))))))
+ 
 -;; return a html string
 -;; produce the code of a whole page with title+layout with the parameter as the content
+ (defun generate-layout(body &optional &key (title nil))
++  "Return HTML string for a complete page with title and layout, using the parameter as content."
+   (prepare "templates/layout.tpl"
+ 	   (template "%%Title%%" (or title (getf *config* :title)))
+ 	   (template "%%Tags%%" (get-tag-list))
+ 	   (template "%%Body%%" body)
+ 	   output))
+ 
 -
 -;; html generation of index homepage
+ (defun generate-semi-mainpage(&key (tiny t) (no-text nil))
++  "Generate HTML for the index homepage."
+   (apply #'concatenate 'string
+          (loop for article in *articles* collect
+               (create-article article :tiny tiny :no-text no-text))))
+ 
 -;; html generation of a tag homepage
+ (defun generate-tag-mainpage(articles-in-tag)
++  "Generate HTML for a tag-specific homepage."
+   (apply #'concatenate 'string
+          (loop for article in *articles*
+             when (member (article-id article) articles-in-tag :test #'equal)
+             collect (create-article article :tiny t))))
 -
 -;; xml generation of the items for the rss
 -(defun generate-rss-item(&key (gopher nil))
 +(defun generate-rss-item (fn)
++  "Generate XML for RSS feed items."
    (apply #'concatenate 'string
           (loop for article in *articles*
              for i from 1 to (min (length *articles*) (getf *config* :rss-item-number))
@@@ -187,28 -297,190 +278,27 @@@
  								 (article-date article))
  						    (subseq (getf (article-date article) :dayname) 0 3)
  						    (subseq (getf (article-date article) :monthname) 0 3)))
 -                       (template "%%Url%%"
 -                                 (if gopher
 -                                     (format nil "gopher://~a:~d/0~a/article-~a.txt"
 -					     (getf *config* :gopher-server)
 -					     (getf *config* :gopher-port)
 -                                             (getf *config* :gopher-path)
 -					     (article-id article))
 -                                     (format nil "~d~d-~d.html"
 -                                             (getf *config* :url)
 -                                             (date-format "%Year-%MonthNumber-%DayNumber"
 -                                                          (article-date article))
 -                                             (article-id article))))))))
 -
 -
 -;; Generate the rss xml data
 -(defun generate-rss(&key (gopher nil))
 +                       (template "%%Url%%" (funcall fn article))))))
 +
 +
- ;; Generate the rss xml data
 +(defun generate-rss(fn)
++  "Generate complete RSS XML data."
    (prepare "templates/rss.tpl"
  	   (template "%%Description%%" (getf *config* :description))
  	   (template "%%Title%%" (getf *config* :title))
  	   (template "%%Url%%" (getf *config* :url))
 -	   (template "%%Items%%" (generate-rss-item :gopher gopher))))
 -
 -;; We do all the website
 -(defun create-html-site()
 -
 -  ;; produce each article file
 -  (loop for article in *articles*
 -     do
 -     ;; use the article's converter to get html code of it
 -       (use-converter-to-html (article-id article) (article-converter article))
 -
 -	(generate  (format nil "output/html/~d-~d.html"
 -			   (date-format "%Year-%MonthNumber-%DayNumber"
 -					(article-date article))
 -			   (article-id article))
 -		   (create-article article :tiny nil)
 -		   :title (concatenate 'string (getf *config* :title) " : " (article-title article))))
 -
 -  ;; produce index.html
 -  (generate "output/html/index.html" (generate-semi-mainpage))
 -
 -  ;; produce index-titles.html where there are only articles titles
 -  (generate "output/html/index-titles.html" (generate-semi-mainpage :no-text t))
 -
 -  ;; produce index file for each tag
 -  (loop for tag in (articles-by-tag) do
 -       (generate (format nil "output/html/tag-~d.html" (getf tag :NAME))
 -		  (generate-tag-mainpage (getf tag :VALUE))))
 -
 -  ;; generate rss gopher in html folder if gopher is t
 -  (when (getf *config* :gopher)
 -    (save-file "output/html/rss-gopher.xml" (generate-rss :gopher t)))
 -
 -  ;;(generate-file-rss)
 -  (save-file "output/html/rss.xml" (generate-rss)))
 -
 -;; we do all the gemini capsule
 -(defun create-gemini-capsule()
 -
 -  ;; produce the index.gmi file
 -  (save-file (concatenate 'string "output/gemini/" (getf *config* :gemini-index))
 -             (generate-gemini-index *articles*))
 -
 -  ;; produce a tag list menu
 -  (let* ((directory-path "output/gemini/_tags_/")
 -         (index-path (concatenate 'string directory-path (getf *config* :gemini-index))))
 -    (ensure-directories-exist directory-path)
 -    (save-file index-path
 -               (let ((output (load-file "templates/gemini_head.tpl")))
 -                 (loop for tag in
 -                      ;; sort tags per articles in it
 -                      (sort (articles-by-tag) #'>
 -                            :key #'(lambda (x) (length (getf x :value))))
 -                    do
 -                      (setf output
 -	                    (string
 -	                     (concatenate
 -                              'string output
 -                              (format nil "=> ~a/~a/index.gmi ~a ~d~%"
 -                                      (getf *config* :gemini-path)
 -                                      (getf tag :name)
 -                                      (getf tag :name)
 -                                      (length (getf tag :value)))))))
 -                 output)))
 -
 -  ;; produce each tag gemini index
 -  (loop for tag in (articles-by-tag) do
 -       (let* ((directory-path (concatenate 'string "output/gemini/" (getf tag :NAME) "/"))
 -              (index-path (concatenate 'string directory-path (getf *config* :gemini-index)))
 -              (articles-with-tag (loop for article in *articles*
 -                                    when (member (article-id article) (getf tag :VALUE) :test #'equal)
 -                                    collect article)))
 -         (ensure-directories-exist directory-path)
 -         (save-file index-path (generate-gemini-index articles-with-tag))))
 -
 -  ;; produce each article file (adding some headers)
 -  (loop for article in *articles*
 -     do
 -       (with-converter
 -	   (let ((id (article-id article)))
 -	     (save-file (format nil "output/gemini/articles/~a.gmi" id)
 -                        (format nil "~{~a~}"
 -                                (list
 -                                 "Title : " (article-title article) #\Newline
 -                                 "Author: " (article-author article) #\Newline
 -                                 "Date  : " (date-format (getf *config* :date-format) (article-date article)) #\Newline
 -                                 "Tags  : " (article-tag article) #\Newline #\Newline
 -		                 (load-file (format nil "data/~d~d" id (converter-extension converter-object))))))))))
 -
 -;; we do all the gopher hole
 -(defun create-gopher-hole()
 -
 -  ;;(generate-file-rss)
 -  (save-file "output/gopher/rss.xml" (generate-rss :gopher t))
 -
 -  ;; produce the gophermap file
 -  (save-file (concatenate 'string "output/gopher/" (getf *config* :gopher-index))
 -             (generate-gopher-index *articles*))
 -
 -  ;; produce a tag list menu
 -  (let* ((directory-path "output/gopher/_tags_/")
 -         (index-path (concatenate 'string directory-path (getf *config* :gopher-index))))
 -    (ensure-directories-exist directory-path)
 -    (save-file index-path
 -               (let ((output (load-file "templates/gopher_head.tpl")))
 -                 (loop for tag in
 -                      ;; sort tags per articles in it
 -                      (sort (articles-by-tag) #'>
 -                            :key #'(lambda (x) (length (getf x :value))))
 -                    do
 -                      (setf output
 -	                    (string
 -	                     (concatenate
 -                              'string output
 -                              (format nil (getf *config* :gopher-format)
 -                                      1 ;; gopher type, 1 for menus
 -                                      ;; here we create a 72 width char string with title on the left
 -				      ;; and number of articles on the right
 -				      ;; we truncate the article title if it's too large
 -				      (let ((title (format nil "~72a"
 -						           (if (< 72 (length (getf tag :NAME)))
 -							       (subseq (getf tag :NAME) 0 80)
 -							       (getf tag :NAME))))
 -                                            (article-number (format nil "~d article~p" (length (getf tag :value)) (length (getf tag :value)))))
 -				        (replace title article-number :start1 (- (length title) (length article-number))))
 -                                      (concatenate 'string
 -                                                   (getf *config* :gopher-path) "/" (getf tag :NAME) "/")
 -				      (getf *config* :gopher-server)
 -				      (getf *config* :gopher-port)
 -				      )))))
 -                 output)))
 -
 -  ;; produce each tag gophermap index
 -  (loop for tag in (articles-by-tag) do
 -       (let* ((directory-path (concatenate 'string "output/gopher/" (getf tag :NAME) "/"))
 -              (index-path (concatenate 'string directory-path (getf *config* :gopher-index)))
 -              (articles-with-tag (loop for article in *articles*
 -                                    when (member (article-id article) (getf tag :VALUE) :test #'equal)
 -                                    collect article)))
 -         (ensure-directories-exist directory-path)
 -         (save-file index-path (generate-gopher-index articles-with-tag))))
 -
 -  ;; produce each article file (adding some headers)
 -  (loop for article in *articles*
 -     do
 -       (with-converter
 -	   (let ((id (article-id article)))
 -	     (save-file (format nil "output/gopher/article-~d.txt" id)
 -                        (format nil "Title: ~a~%Author: ~a~%Date: ~a~%Tags: ~a~%============~%~%~a"
 -                                 (article-title article)
 -                                 (article-author article)
 -                                 (date-format (getf *config* :date-format) (article-date article))
 -                                 (article-tag article)
 -		                         (load-file (format nil "data/~d~d" id (converter-extension converter-object)))))))))
 -
 -
 -;; This is function called when running the tool
 +	   (template "%%Items%%" (generate-rss-item fn))))
 +
- ; This is function called when running the tool
  (defun generate-site()
 -  (if (getf *config* :html)
 -      (create-html-site))
 -  (if (getf *config* :gemini)
 -      (create-gemini-capsule))
 -  (if (getf *config* :gopher)
 -      (create-gopher-hole)))
++  "Main function called when running the site generation tool."
 +  (loop for i in *generators*
 +        do (if (getf *config* (getf i :key))
 +             (funcall (function (getf i :create-site-fn))))))
 +
 +;; all the generators
 +(load "generators/html.lisp")
 +(load "generators/gopher.lisp")
- 
- 
++(load "generators/gemini.lisp")
  
  ;;;; EXECUTION
  
diff --cc generators/gemini.lisp
index 0000000,0000000..d9f7728
new file mode 100644
--- /dev/null
+++ b/generators/gemini.lisp
@@@ -1,0 -1,0 +1,76 @@@
++(defun generate-rss-gemini(article)
++  (format nil "gemini://~a/articles/~a.gmi"
++          (getf *config* :gemini-path)
++          (article-id article)))
++
++;; generate a gemini index file
++(defun generate-gemini-index(articles)
++  (let ((output (load-file "templates/gemini_head.tpl")))
++    (dolist (article articles)
++      (setf output
++	    (string
++	     (concatenate 'string output
++                          (format nil "=> ~a/articles/~a.gmi ~a-~2,'0d-~2,'0d ~a~%"
++                                  (getf *config* :gemini-path)
++                                  (article-id article)
++                                  (getf (article-date article) :year)
++                                  (getf (article-date article) :monthnumber)
++                                  (getf (article-date article) :daynumber)
++                                  (article-title article))))))
++    output))
++
++;; we do all the gemini capsule
++(defun create-gemini-capsule()
++
++  ;; produce the index.gmi file
++  (save-file (concatenate 'string "output/gemini/" (getf *config* :gemini-index))
++             (generate-gemini-index *articles*))
++
++  ;; produce a tag list menu
++  (let* ((directory-path "output/gemini/_tags_/")
++         (index-path (concatenate 'string directory-path (getf *config* :gemini-index))))
++    (ensure-directories-exist directory-path)
++    (save-file index-path
++               (let ((output (load-file "templates/gemini_head.tpl")))
++                 (loop for tag in
++                      ;; sort tags per articles in it
++                      (sort (articles-by-tag) #'>
++                            :key #'(lambda (x) (length (getf x :value))))
++                    do
++                      (setf output
++	                    (string
++	                     (concatenate
++                              'string output
++                              (format nil "=> ~a/~a/index.gmi ~a ~d~%"
++                                      (getf *config* :gemini-path)
++                                      (getf tag :name)
++                                      (getf tag :name)
++                                      (length (getf tag :value)))))))
++                 output)))
++
++  ;; produce each tag gemini index
++  (loop for tag in (articles-by-tag) do
++       (let* ((directory-path (concatenate 'string "output/gemini/" (getf tag :NAME) "/"))
++              (index-path (concatenate 'string directory-path (getf *config* :gemini-index)))
++              (articles-with-tag (loop for article in *articles*
++                                    when (member (article-id article) (getf tag :VALUE) :test #'equal)
++                                    collect article)))
++         (ensure-directories-exist directory-path)
++         (save-file index-path (generate-gemini-index articles-with-tag))))
++
++  ;; produce each article file (adding some headers)
++  (loop for article in *articles*
++     do
++       (with-converter
++	   (let ((id (article-id article)))
++	     (save-file (format nil "output/gemini/articles/~a.gmi" id)
++                        (format nil "~{~a~}"
++                                (list
++                                 "Title : " (article-title article) #\Newline
++                                 "Author: " (article-author article) #\Newline
++                                 "Date  : " (date-format (getf *config* :date-format) (article-date article)) #\Newline
++                                 "Tags  : " (article-tag article) #\Newline #\Newline
++		                 (load-file (format nil "data/~d~d" id (converter-extension converter-object))))))))))
++
++(make-generator :key :gemini
++                :create-site-fn #'create-gemini-capsule)
diff --cc generators/gopher.lisp
index c0c629c,0000000..a0c9443
mode 100644,000000..100644
--- a/generators/gopher.lisp
+++ b/generators/gopher.lisp
@@@ -1,102 -1,0 +1,99 @@@
 +(defun generate-rss-gopher(article)
 +  (format nil "gopher://~a:~d/0~a/article-~a.txt"
 +          (getf *config* :gopher-server)
 +          (getf *config* :gopher-port)
 +          (getf *config* :gopher-path)
 +          (article-id article)))
 +
++;; generate a gopher index file
++(defun generate-gopher-index(articles)
++  (let ((output (load-file "templates/gopher_head.tpl")))
++    (dolist (article articles)
++      (setf output
++	    (string
++	     (concatenate 'string output
++                          (format nil (getf *config* :gopher-format)
++                                  0 ;;;; gopher type, 0 for text files
++				  ;; here we create a 80 width char string with title on the left
++				  ;; and date on the right
++				  ;; we truncate the article title if it's too large
++				  (let ((title (format nil "~80a"
++						       (if (< 80 (length (article-title article)))
++							   (subseq (article-title article) 0 80)
++							   (article-title article)))))
++				    (replace title (article-rawdate article) :start1 (- (length title) (length (article-rawdate article)))))
++				  (concatenate 'string
++                                               (getf *config* :gopher-path) "/article-" (article-id article) ".txt")
++				  (getf *config* :gopher-server)
++				  (getf *config* :gopher-port)
++				  )))))
++    output))
++
 +;; we do all the gopher hole
 +(defun create-gopher-hole()
 +
 +  ;;(generate-file-rss)
 +  (save-file "output/gopher/rss.xml" (generate-rss #'generate-rss-gopher))
 +
 +  ;; produce the gophermap file
 +  (save-file (concatenate 'string "output/gopher/" (getf *config* :gopher-index))
 +             (generate-gopher-index *articles*))
 +
 +  ;; produce a tag list menu
 +  (let* ((directory-path "output/gopher/_tags_/")
 +         (index-path (concatenate 'string directory-path (getf *config* :gopher-index))))
 +    (ensure-directories-exist directory-path)
 +    (save-file index-path
 +               (let ((output (load-file "templates/gopher_head.tpl")))
 +                 (loop for tag in
 +                      ;; sort tags per articles in it
 +                      (sort (articles-by-tag) #'>
 +                            :key #'(lambda (x) (length (getf x :value))))
 +                    do
 +                      (setf output
 +	                    (string
 +	                     (concatenate
 +                              'string output
 +                              (format nil (getf *config* :gopher-format)
 +                                      1 ;; gopher type, 1 for menus
 +                                      ;; here we create a 72 width char string with title on the left
 +				      ;; and number of articles on the right
 +				      ;; we truncate the article title if it's too large
 +				      (let ((title (format nil "~72a"
 +						           (if (< 72 (length (getf tag :NAME)))
 +							       (subseq (getf tag :NAME) 0 80)
 +							       (getf tag :NAME))))
 +                                            (article-number (format nil "~d article~p" (length (getf tag :value)) (length (getf tag :value)))))
 +				        (replace title article-number :start1 (- (length title) (length article-number))))
 +                                      (concatenate 'string
 +                                                   (getf *config* :gopher-path) "/" (getf tag :NAME) "/")
 +				      (getf *config* :gopher-server)
 +				      (getf *config* :gopher-port)
 +				      )))))
 +                 output)))
 +
- (make-generator :key :gopher
-                 :create-site-fn #'create-gopher-hole)
- 
 +  ;; produce each tag gophermap index
 +  (loop for tag in (articles-by-tag) do
 +       (let* ((directory-path (concatenate 'string "output/gopher/" (getf tag :NAME) "/"))
 +              (index-path (concatenate 'string directory-path (getf *config* :gopher-index)))
 +              (articles-with-tag (loop for article in *articles*
 +                                    when (member (article-id article) (getf tag :VALUE) :test #'equal)
 +                                    collect article)))
 +         (ensure-directories-exist directory-path)
 +         (save-file index-path (generate-gopher-index articles-with-tag))))
 +
 +  ;; produce each article file (adding some headers)
 +  (loop for article in *articles*
 +     do
 +       (with-converter
 +	   (let ((id (article-id article)))
 +	     (save-file (format nil "output/gopher/article-~d.txt" id)
 +                        (format nil "Title: ~a~%Author: ~a~%Date: ~a~%Tags: ~a~%============~%~%~a"
 +                                 (article-title article)
 +                                 (article-author article)
 +                                 (date-format (getf *config* :date-format) (article-date article))
 +                                 (article-tag article)
 +		                         (load-file (format nil "data/~d~d" id (converter-extension converter-object)))))))))
 +
- ;; generate a gopher index file
- (defun generate-gopher-index(articles)
-   (let ((output (load-file "templates/gopher_head.tpl")))
-     (dolist (article articles)
-       (setf output
- 	    (string
- 	     (concatenate 'string output
-                           (format nil (getf *config* :gopher-format)
-                                   0 ;;;; gopher type, 0 for text files
- 				  ;; here we create a 80 width char string with title on the left
- 				  ;; and date on the right
- 				  ;; we truncate the article title if it's too large
- 				  (let ((title (format nil "~80a"
- 						       (if (< 80 (length (article-title article)))
- 							   (subseq (article-title article) 0 80)
- 							   (article-title article)))))
- 				    (replace title (article-rawdate article) :start1 (- (length title) (length (article-rawdate article)))))
- 				  (concatenate 'string
-                                                (getf *config* :gopher-path) "/article-" (article-id article) ".txt")
- 				  (getf *config* :gopher-server)
- 				  (getf *config* :gopher-port)
- 				  )))))
-     output))
- 
- 
- 
++(make-generator :key :gopher
++                :create-site-fn #'create-gopher-hole)
diff --cc packages.lisp
index 0000000,0000000..a4b1fef
new file mode 100644
--- /dev/null
+++ b/packages.lisp
@@@ -1,0 -1,0 +1,24 @@@
++
++(in-package :cl-yag-system)
++
++(defpackage #:html-generator
++   (:use #:cl)
++   (:export #:create-html-site
++            #:generate-rss-html))
++
++(defpackage #:gopher-generator
++   (:use #:cl)
++   (:export #:create-gopher-hole
++            #:generate-rss-gopher))
++
++(defpackage #:cl-yag
++   (:use #:cl)
++   (:import-from #:html-generator
++
++                 #:create-html-site
++                 #:generate-rss-html)
++   (:import-from #:gopher-generator
++
++                 #:create-gopher-hole
++                 #:generate-rss-gopher)
++   (:export #:generate-site))
diff --cc templates/gemini_head.tpl
index 0000000,8845c90..ba5afb4
mode 000000,100644..100644
--- a/templates/gemini_head.tpl
+++ b/templates/gemini_head.tpl
@@@ -1,0 -1,6 +1,6 @@@
 -Hello, this is the head of your gophermap page, you can
++Hello, this is the head of your gemini capsule, you can
+ customize it how you want !
+ 
 -=> /index.md Home
++=> /index.gmi Home
+ 
 -------------------------------------------------------------------
++------------------------------------------------------------------