From 0a091112c1a3636da39bda500702e7f18c2b5926 Mon Sep 17 00:00:00 2001 From: Alexander Artemenko Date: Sun, 31 Mar 2024 15:39:29 +0300 Subject: [PATCH] Initial --- .github/workflows/ci.yml | 57 ++++++++ .github/workflows/docs.yml | 48 +++++++ .github/workflows/linter.yml | 60 ++++++++ .gitignore | 7 + ChangeLog.md | 1 + README.md | 1 + clpmfile | 24 ++++ docs/changelog.lisp | 13 ++ docs/index.lisp | 104 ++++++++++++++ example/.staticlrc | 3 + example/index.page | 17 +++ qlfile | 4 + qlfile.lock | 12 ++ src/ci.lisp | 42 ++++++ src/content.lisp | 236 +++++++++++++++++++++++++++++++ src/content/defaults.lisp | 13 ++ src/content/page.lisp | 22 +++ src/content/reader.lisp | 67 +++++++++ src/core.lisp | 38 +++++ src/format.lisp | 14 ++ src/format/html.lisp | 7 + src/format/md.lisp | 13 ++ src/playground.lisp | 43 ++++++ src/plugin.lisp | 32 +++++ src/plugins.lisp | 6 + src/plugins/sitemap.lisp | 39 +++++ src/site.lisp | 108 ++++++++++++++ src/theme.lisp | 87 ++++++++++++ src/themes/closure-template.lisp | 64 +++++++++ src/utils.lisp | 155 ++++++++++++++++++++ staticl-ci.asd | 11 ++ staticl-docs.asd | 11 ++ staticl-tests.asd | 13 ++ staticl.asd | 24 ++++ t/core.lisp | 11 ++ themes/hyde/base.tmpl | 57 ++++++++ themes/hyde/index.tmpl | 34 +++++ themes/hyde/post.tmpl | 27 ++++ themes/hyde/theme.lisp | 2 + 39 files changed, 1527 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/docs.yml create mode 100644 .github/workflows/linter.yml create mode 100644 .gitignore create mode 100644 ChangeLog.md create mode 100644 README.md create mode 100644 clpmfile create mode 100644 docs/changelog.lisp create mode 100644 docs/index.lisp create mode 100644 example/.staticlrc create mode 100644 example/index.page create mode 100644 qlfile create mode 100644 qlfile.lock create mode 100644 src/ci.lisp create mode 100644 src/content.lisp create mode 100644 src/content/defaults.lisp create mode 100644 src/content/page.lisp create mode 100644 src/content/reader.lisp create mode 100644 src/core.lisp create mode 100644 src/format.lisp create mode 100644 src/format/html.lisp create mode 100644 src/format/md.lisp create mode 100644 src/playground.lisp create mode 100644 src/plugin.lisp create mode 100644 src/plugins.lisp create mode 100644 src/plugins/sitemap.lisp create mode 100644 src/site.lisp create mode 100644 src/theme.lisp create mode 100644 src/themes/closure-template.lisp create mode 100644 src/utils.lisp create mode 100644 staticl-ci.asd create mode 100644 staticl-docs.asd create mode 100644 staticl-tests.asd create mode 100644 staticl.asd create mode 100644 t/core.lisp create mode 100644 themes/hyde/base.tmpl create mode 100644 themes/hyde/index.tmpl create mode 100644 themes/hyde/post.tmpl create mode 100644 themes/hyde/theme.lisp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b5eb327 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +{ + "name": "CI", + "on": { + "push": { + "branches": [ + "master" + ] + }, + "pull_request": null, + "schedule": [ + { + "cron": "0 10 * * 1" + } + ] + }, + "jobs": { + "run-tests": { + "strategy": { + "fail-fast": false, + "matrix": { + "lisp": [ + "sbcl-bin", + "ccl-bin/1.12.0" + ] + } + }, + "runs-on": "ubuntu-latest", + "env": { + "OS": "ubuntu-latest", + "QUICKLISP_DIST": "quicklisp", + "LISP": "${{ matrix.lisp }}" + }, + "steps": [ + { + "name": "Checkout Code", + "uses": "actions/checkout@v4" + }, + { + "name": "Setup Common Lisp Environment", + "uses": "40ants/setup-lisp@v4", + "with": { + "asdf-system": "staticl", + "cache": "true" + } + }, + { + "name": "Run Tests", + "uses": "40ants/run-tests@v2", + "with": { + "asdf-system": "staticl", + "coveralls-token": "\n${{ matrix.lisp == 'sbcl-bin' &&\n matrix.os == 'ubuntu-latest' &&\n matrix.quicklisp == 'ultralisp' &&\n secrets.github_token }}" + } + } + ] + } + } +} \ No newline at end of file diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..0580c12 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,48 @@ +{ + "name": "DOCS", + "on": { + "push": { + "branches": [ + "master" + ] + }, + "pull_request": null, + "schedule": [ + { + "cron": "0 10 * * 1" + } + ] + }, + "jobs": { + "build-docs": { + "runs-on": "ubuntu-latest", + "env": { + "OS": "ubuntu-latest", + "QUICKLISP_DIST": "quicklisp", + "LISP": "sbcl-bin" + }, + "steps": [ + { + "name": "Checkout Code", + "uses": "actions/checkout@v4" + }, + { + "name": "Setup Common Lisp Environment", + "uses": "40ants/setup-lisp@v4", + "with": { + "asdf-system": "staticl-docs", + "cache": "true" + } + }, + { + "name": "Build Docs", + "uses": "40ants/build-docs@v1", + "with": { + "asdf-system": "staticl-docs", + "error-on-warnings": true + } + } + ] + } + } +} \ No newline at end of file diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml new file mode 100644 index 0000000..7ff0988 --- /dev/null +++ b/.github/workflows/linter.yml @@ -0,0 +1,60 @@ +{ + "name": "LINTER", + "on": { + "push": { + "branches": [ + "master" + ] + }, + "pull_request": null, + "schedule": [ + { + "cron": "0 10 * * 1" + } + ] + }, + "jobs": { + "linter": { + "runs-on": "ubuntu-latest", + "env": { + "OS": "ubuntu-latest", + "QUICKLISP_DIST": "quicklisp", + "LISP": "sbcl-bin" + }, + "steps": [ + { + "name": "Checkout Code", + "uses": "actions/checkout@v4" + }, + { + "name": "Setup Common Lisp Environment", + "uses": "40ants/setup-lisp@v4", + "with": { + "asdf-system": "staticl", + "cache": "true" + } + }, + { + "name": "Change dist to Ultralisp if qlfile does not exist", + "run": "if [[ ! -e qlfile ]]; then echo 'dist ultralisp http://dist.ultralisp.org' > qlfile; fi", + "shell": "bash" + }, + { + "name": "Update Qlot", + "run": "qlot update --no-deps", + "shell": "bash" + }, + { + "name": "Install SBLint wrapper", + "run": "qlot exec ros install 40ants-asdf-system 40ants-linter", + "shell": "bash" + }, + { + "name": "Run Linter", + "run": "qlot exec 40ants-linter --system \"staticl, staticl-docs, staticl-tests\"", + "shell": "bash" + } + ] + } + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..643570e --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.qlot/ +/docs/build/ +*~ +.#* +.*.~undo-tree~ +*.fasl +/example/stage/ diff --git a/ChangeLog.md b/ChangeLog.md new file mode 100644 index 0000000..c961348 --- /dev/null +++ b/ChangeLog.md @@ -0,0 +1 @@ +This file will be autogenerated by GitHub action. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c961348 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +This file will be autogenerated by GitHub action. diff --git a/clpmfile b/clpmfile new file mode 100644 index 0000000..6c46c73 --- /dev/null +++ b/clpmfile @@ -0,0 +1,24 @@ +;;; -*- Mode: common-lisp; -*- +(:api-version "0.4") + +(:source "quicklisp" + :url "https://beta.quicklisp.org/dist/quicklisp.txt" + :type :quicklisp) + +;; Don't move abover quicklisp before this issue +;; will be resolved: +;; https://github.com/ultralisp/ultralisp/issues/197 +(:source "ultralisp" + :url "https://clpi.ultralisp.org/" + :type :clpi) + +;; Does not work because https://dist.ultralisp.org/ultralisp-versions.txt is missing +;; (:source "ultralisp" +;; :url "https://dist.ultralisp.org/ultralisp.txt" +;; :type :quicklisp) + +(:asd "staticl.asd") +(:asd "staticl-tests.asd") +(:asd "staticl-ci.asd") +(:asd "staticl-docs.asd") + diff --git a/docs/changelog.lisp b/docs/changelog.lisp new file mode 100644 index 0000000..6e83883 --- /dev/null +++ b/docs/changelog.lisp @@ -0,0 +1,13 @@ +(uiop:define-package #:staticl-docs/changelog + (:use #:cl) + (:import-from #:40ants-doc/changelog + #:defchangelog)) +(in-package #:staticl-docs/changelog) + + +(defchangelog (:ignore-words ("SLY" + "ASDF" + "REPL" + "HTTP")) + (0.1.0 2023-02-05 + "* Initial version.")) diff --git a/docs/index.lisp b/docs/index.lisp new file mode 100644 index 0000000..e97f908 --- /dev/null +++ b/docs/index.lisp @@ -0,0 +1,104 @@ +(uiop:define-package #:staticl-docs/index + (:use #:cl) + (:import-from #:pythonic-string-reader + #:pythonic-string-syntax) + #+quicklisp + (:import-from #:quicklisp) + (:import-from #:named-readtables + #:in-readtable) + (:import-from #:40ants-doc + #:defsection + #:defsection-copy) + (:import-from #:staticl-docs/changelog + #:@changelog) + (:import-from #:docs-config + #:docs-config) + (:import-from #:40ants-doc/autodoc + #:defautodoc) + (:export #:@index + #:@readme + #:@changelog)) +(in-package #:staticl-docs/index) + +(in-readtable pythonic-string-syntax) + + +(defmethod docs-config ((system (eql (asdf:find-system "staticl-docs")))) + ;; 40ANTS-DOC-THEME-40ANTS system will bring + ;; as dependency a full 40ANTS-DOC but we don't want + ;; unnecessary dependencies here: + #+quicklisp + (ql:quickload "40ants-doc-theme-40ants") + #-quicklisp + (asdf:load-system "40ants-doc-theme-40ants") + + (list :theme + (find-symbol "40ANTS-THEME" + (find-package "40ANTS-DOC-THEME-40ANTS"))) + ) + + +(defsection @index (:title "staticl - Flexible static site generator." + :ignore-words ("JSON" + "HTTP" + "TODO" + "Unlicense" + "REPL" + "ASDF:PACKAGE-INFERRED-SYSTEM" + "ASDF" + "40A" + "API" + "URL" + "URI" + "RPC" + "GIT")) + (staticl system) + " +[![](https://github-actions.40ants.com/40ants/staticl/matrix.svg?only=ci.run-tests)](https://github.com/40ants/staticl/actions) + +![Quicklisp](http://quickdocs.org/badge/staticl.svg) +" + (@installation section) + (@usage section) + (@api section)) + + +(defsection-copy @readme @index) + + +(defsection @installation (:title "Installation") + """ +You can install this library from Quicklisp, but you want to receive updates quickly, then install it from Ultralisp.org: + +``` +(ql-dist:install-dist "http://dist.ultralisp.org/" + :prompt nil) +(ql:quickload :staticl) +``` +""") + + +(defsection @usage (:title "Usage") + " +TODO: Write a library description. Put some examples here. +") + + +(defsection @processing-pipeline (:title "Processing Pipeline") + " +First of all, a SITE object is created and filled with options from `.staticlrc` file. + +Then STATICL:STAGE function calls STATICL/CONTENT:READ-CONTENT generic-function which returns a list +of STATICL/CONTENT:CONTENT objects. On the next stage, initial list of content objects are passed to a +generic-function STATICL/CONTENT:PREPROCESS called with a preprocessor returned by a +generic-function STATICL/PLUGINS:SITE-PLUGINS as a first argument. Each preprocessor may +return additional STATICL/CONTENT:CONTENT objects such as index pages, RSS or ATOM feeds, sitemaps etc. + +When all content was preprocessed, a generic-function STATICL/CONTENT:WRITE-CONTENT is called +on each STATICL/CONTENT:CONTENT object and a SITE object. Content objects are having a format slot, +so internally STATICL/CONTENT:WRITE-CONTENT generic-function creates an object of corresponding format class +or takes it from the cache and then calls STATICL/CONTENT:WRITE-CONTENT-TO-STREAM using this format object. +") + + +(defautodoc @api (:system "staticl")) diff --git a/example/.staticlrc b/example/.staticlrc new file mode 100644 index 0000000..d3aa6f8 --- /dev/null +++ b/example/.staticlrc @@ -0,0 +1,3 @@ +;;; -*- mode : lisp -*- +(:title "Example Staticl Site" + :plugins ((sitemap))) diff --git a/example/index.page b/example/index.page new file mode 100644 index 0000000..a93c48a --- /dev/null +++ b/example/index.page @@ -0,0 +1,17 @@ +;;;;; +title: An index page. +tags: foo, bar +created-at: 2024-03-30 08:18:05 +format: md +;;;;; + + + + +Here is my content. + + + +Excerpt separator can also be extracted from content. +Add `excerpt: ` to the above metadata. +Excerpt separator is `` by default. diff --git a/qlfile b/qlfile new file mode 100644 index 0000000..55a914c --- /dev/null +++ b/qlfile @@ -0,0 +1,4 @@ +dist ultralisp http://dist.ultralisp.org/ + +# This branch is what I use in my Emacs. +github slynk svetlyak40wt/sly :branch patches diff --git a/qlfile.lock b/qlfile.lock new file mode 100644 index 0000000..0aa8290 --- /dev/null +++ b/qlfile.lock @@ -0,0 +1,12 @@ +("quicklisp" . + (:class qlot/source/dist:source-dist + :initargs (:distribution "http://beta.quicklisp.org/dist/quicklisp.txt" :%version :latest) + :version "2023-10-21")) +("ultralisp" . + (:class qlot/source/dist:source-dist + :initargs (:distribution "http://dist.ultralisp.org/" :%version :latest) + :version "20240330015000")) +("slynk" . + (:class qlot/source/github:source-github + :initargs (:repos "svetlyak40wt/sly" :ref nil :branch "patches" :tag nil) + :version "github-030a8441f57f7e0bb401570935e741dfd9edfb83")) diff --git a/src/ci.lisp b/src/ci.lisp new file mode 100644 index 0000000..0118a18 --- /dev/null +++ b/src/ci.lisp @@ -0,0 +1,42 @@ +(uiop:define-package #:staticl-ci/ci + (:use #:cl) + (:import-from #:40ants-ci/jobs/linter) + (:import-from #:40ants-ci/jobs/run-tests + #:run-tests) + (:import-from #:40ants-ci/jobs/docs + #:build-docs) + (:import-from #:40ants-ci/workflow + #:defworkflow)) +(in-package #:staticl-ci/ci) + + +(defworkflow linter + :on-push-to "master" + :by-cron "0 10 * * 1" + :on-pull-request t + :cache t + :jobs ((40ants-ci/jobs/linter:linter + :asdf-systems ("staticl" + "staticl-docs" + "staticl-tests")))) + +(defworkflow docs + :on-push-to "master" + :by-cron "0 10 * * 1" + :on-pull-request t + :cache t + :jobs ((build-docs :asdf-system "staticl-docs"))) + + +(defworkflow ci + :on-push-to "master" + :by-cron "0 10 * * 1" + :on-pull-request t + :cache t + :jobs ((run-tests + :asdf-system "staticl" + :lisp ("sbcl-bin" + ;; Issue https://github.com/roswell/roswell/issues/534 + ;; is still reproduces on 2023-02-06: + "ccl-bin/1.12.0") + :coverage t))) diff --git a/src/content.lisp b/src/content.lisp new file mode 100644 index 0000000..b599e39 --- /dev/null +++ b/src/content.lisp @@ -0,0 +1,236 @@ +(uiop:define-package #:staticl/content + (:use #:cl) + (:import-from #:staticl/theme + #:template-vars) + (:import-from #:serapeum + #:dict) + (:import-from #:org.shirakumo.fuzzy-dates) + (:import-from #:staticl/theme + #:template-vars) + (:import-from #:staticl/site + #:site-theme + #:site-content-root + #:site) + (:import-from #:alexandria + #:with-output-to-file + #:length=) + (:import-from #:staticl/utils + #:normalize-plist + #:do-files) + (:import-from #:staticl/content/reader + #:read-content-file) + (:import-from #:local-time + #:universal-to-timestamp + #:timestamp) + (:import-from #:serapeum + #:soft-list-of) + (:import-from #:utilities.print-items + #:print-items-mixin + #:print-items) + (:export #:supported-content-types + #:content-type + #:content + #:read-contents + #:read-content-from-disk + #:content-class + #:write-content-to-stream + #:write-content + #:preprocess + #:get-target-filename + #:content-with-title-mixin + #:content-with-tags-mixin + #:content-from-file)) +(in-package #:staticl/content) + + +(defclass content-type () + ((file-type :initarg :type + :type string + :reader content-file-type) + (content-class :initarg :content-class + :reader content-class + :type standard-class))) + + +(defmethod initialize-instance :around ((instance content-type) &rest initargs) + (when (getf initargs :content-class) + (setf (getf initargs :content-class) + (find-class (getf initargs :content-class)))) + + (apply #'call-next-method + instance + initargs)) + + +(defclass content (print-items-mixin) + ()) + + +(defclass content-with-title-mixin () + ((title :initarg :title + :type string + :reader content-title))) + + +(defmethod print-items append ((obj content-with-title-mixin)) + (list (list :title "~S" (content-title obj)))) + + +(defclass content-with-tags-mixin () + ((tags :initarg :tags + :type (soft-list-of string) + :reader content-tags))) + + +(defclass content-from-file (content-with-title-mixin + content-with-tags-mixin + content) + ((format :initarg :format + :type string + :reader content-format) + (template :initarg :template + :type string + :reader content-template) + (created-at :initarg :created-at + :type timestamp + :reader content-created-at) + (file :initarg :file + :type pathname + :reader content-file + :documentation "Absolute pathname to the file read from disk or NIL for content objects which have no source file, like RSS feeds.") + (text :initarg :text + :type string + :reader content-text)) + (:default-initargs + :template (error "Please, specify :TEMPLATE initarg in subclass of CONTENT-FROM-FILE."))) + + +(defmethod print-items append ((obj content-from-file)) + (list (list :file (list :after :title) "= ~S" (content-file obj)))) + + +;; (defmethod print-object ((obj content) stream) +;; (print-unreadable-object (obj stream :type t) +;; (when (and (slot-boundp obj 'title) +;; (slot-boundp obj 'file)) +;; (format stream "~S :file ~S" +;; (content-title obj) +;; (content-file obj))))) + + +(defmethod initialize-instance ((obj content) &rest initargs) + (apply #'call-next-method + obj + (normalize-plist initargs + :created-at (lambda (value) + (etypecase value + (null value) + (string + (universal-to-timestamp + (org.shirakumo.fuzzy-dates:parse value))) + (local-time:timestamp + value))) + :tags (lambda (value) + (etypecase value + (string + (mapcar #'str:trim + (str:split "," value + :omit-nulls t)))))))) + + +(defgeneric supported-content-types (site) + (:documentation "Returns a list of CONTENT-TYPE objects.") + + (:method :around ((site site)) + (loop with types = (make-hash-table :test 'equal) + with all-content-types = (call-next-method) + for content-type in all-content-types + do (push content-type + (gethash (content-file-type content-type) + types)) + finally (return + (progn + (loop for type being the hash-key of types + using (hash-value content-types) + unless (length= 1 content-types) + do (error "There are ~A content-type objects having the same ~S type: ~{~S~^, ~}" + (length content-types) + type + (mapcar #'class-name + (mapcar #'class-of content-types)))) + ;; Returning original list if there is no duplicates: + (values all-content-types)))))) + + +(defgeneric read-content-from-disk (site content-type) + (:documentation "Returns a list of CONTENT objects corresponding to a given content type") + + (:method ((site site) (content-type content-type)) + (uiop:while-collecting (collect) + (do-files (file (site-content-root site) + :file-type (content-file-type content-type)) + (let* ((args (read-content-file file)) + (obj (apply #'make-instance (content-class content-type) + args))) + (collect obj)))))) + + +(defgeneric read-contents (site) + (:documentation "Returns a list of CONTENT objects loaded from files.") + + (:method ((site site)) + (loop for content-type in (supported-content-types site) + append (read-content-from-disk site content-type)))) + + +(defgeneric write-content (site content stage-dir) + (:documentation "Writes CONTENT object to the STAGE-DIR.") + + (:method ((site site) (content content) (stage-dir pathname)) + (let* ((target-filename (get-target-filename site content stage-dir))) + (ensure-directories-exist target-filename) + + (with-output-to-file (stream target-filename :if-exists :supersede) + (write-content-to-stream site content stream)) + (values)))) + + +(defgeneric get-target-filename (site content stage-dir) + (:documentation "Should return an absolute pathname to a file where this content item should be rendered.") + + (:method ((site site) (content content) (stage-dir pathname)) + (let ((relative-path (enough-namestring (content-file content) + (site-content-root site)))) + (merge-pathnames + (merge-pathnames (make-pathname :type "html") + relative-path) + stage-dir)))) + + +(defgeneric write-content-to-stream (site content stream) + (:documentation "Writes CONTENT object to the STREAM using given FORMAT.") + + (:method ((site site) (content content-from-file) (stream stream)) + (let* ((theme (site-theme site)) + (content-vars (template-vars content)) + (site-vars (template-vars site)) + (vars (dict "site" site-vars + "content" content-vars)) + (template-name (content-template content))) + + (staticl/theme:render theme template-name vars stream)))) + + +(defgeneric preprocess (site plugin content-objects) + (:documentation "Returns an additional list content objects such as RSS feeds or sitemaps.")) + + +(defmethod template-vars ((content content-from-file) &key (hash (dict))) + (setf (gethash "title" hash) + (content-title content) + (gethash "html" hash) + (staticl/format:to-html (content-text content) + (content-format content))) + (if (next-method-p) + (call-next-method content :hash hash) + (values hash))) diff --git a/src/content/defaults.lisp b/src/content/defaults.lisp new file mode 100644 index 0000000..80c6f40 --- /dev/null +++ b/src/content/defaults.lisp @@ -0,0 +1,13 @@ +(uiop:define-package #:staticl/content/defaults + (:use #:cl) + (:import-from #:staticl/content + #:supported-content-types) + (:import-from #:staticl/site + #:site) + (:import-from #:staticl/content/page + #:page-type)) +(in-package #:staticl/content/defaults) + + +(defmethod supported-content-types ((site site)) + (list (make-instance 'page-type))) diff --git a/src/content/page.lisp b/src/content/page.lisp new file mode 100644 index 0000000..b401f99 --- /dev/null +++ b/src/content/page.lisp @@ -0,0 +1,22 @@ +(uiop:define-package #:staticl/content/page + (:use #:cl) + (:import-from #:staticl/content + #:content-from-file + #:content-type) + (:export #:page-type + #:page)) +(in-package #:staticl/content/page) + + +(defclass page (content-from-file) + () + (:default-initargs + ;; In coleslaw page and post share the same template + :template "post")) + + +(defclass page-type (content-type) + () + (:default-initargs + :type "page" + :content-class 'page)) diff --git a/src/content/reader.lisp b/src/content/reader.lisp new file mode 100644 index 0000000..794b679 --- /dev/null +++ b/src/content/reader.lisp @@ -0,0 +1,67 @@ +(uiop:define-package #:staticl/content/reader + (:use #:cl) + (:import-from #:cl-ppcre + #:scan-to-strings) + (:import-from #:alexandria + #:proper-list + #:make-keyword) + (:import-from #:serapeum + #:soft-list-of + #:->) + (:export #:read-content-file)) +(in-package #:staticl/content/reader) + + +(defparameter *default-metadata-separator* + ";;;;;") + + +(-> parse-initarg (string) + (values proper-list &optional)) + +(defun parse-initarg (line) + "Given a metadata header, LINE, parse an initarg name/value pair from it." + (let ((name (string-upcase (subseq line 0 (position #\: line)))) + (match (nth-value 1 (scan-to-strings "[a-zA-Z]+:\\s+(.*)" line)))) + (when match + (list (make-keyword name) + (aref match 0))))) + + +(-> parse-metadata (stream &key (:separator string)) + (values proper-list &optional)) + +(defun parse-metadata (stream &key (separator *default-metadata-separator*)) + "Given a STREAM, parse metadata from it or signal an appropriate condition." + (flet ((get-next-line (input) + (string-trim '(#\Space #\Return #\Newline #\Tab) (read-line input nil)))) + (unless (string= (get-next-line stream) separator) + (error "The file, ~a, lacks the expected header: ~a" (file-namestring stream) separator)) + (loop for line = (get-next-line stream) + until (string= line separator) + appending (parse-initarg line)))) + + +(-> read-content (pathname &key (:separator string)) + (values proper-list &optional)) + +(defun read-content-file (file &key (separator *default-metadata-separator*)) + "Returns a plist of metadata from FILE with :TEXT holding the content going after the SEPARATOR." + (unless (uiop:absolute-pathname-p file) + (error "Path ~S should be an absolute pathname." + file)) + + (flet ((slurp-remainder (stream) + (let ((seq (make-string (- (file-length stream) + (file-position stream))))) + (read-sequence seq stream) + (remove #\Nul seq)))) + (declare (dynamic-extent #'slurp-remainder)) + + (with-open-file (in file :external-format :utf-8) + (let ((metadata (parse-metadata in :separator separator)) + (content (slurp-remainder in)) + ;; (filepath (enough-namestring file (repo-dir *config*))) + ) + (list* :text content :file file + metadata))))) diff --git a/src/core.lisp b/src/core.lisp new file mode 100644 index 0000000..a813308 --- /dev/null +++ b/src/core.lisp @@ -0,0 +1,38 @@ +(uiop:define-package #:staticl + (:use #:cl) + (:import-from #:staticl/site + #:site-plugins + #:make-site) + (:import-from #:staticl/content + #:write-content + #:read-contents + #:preprocess) + (:import-from #:serapeum + #:->) + (:nicknames #:staticl/core) + (:export #:generate + #:stage)) +(in-package #:staticl) + + +(-> stage (&key + (:root-dir pathname) + (:stage-dir pathname)) + (values &optional)) + +(defun stage (&key + (root-dir *default-pathname-defaults*) + (stage-dir (merge-pathnames (make-pathname :directory '(:relative "stage")) + (uiop:ensure-directory-pathname root-dir)))) + (let* ((site (make-site root-dir)) + (initial-content (read-contents site)) + (plugins (site-plugins site)) + (additional-content + (loop for plugin in plugins + append (preprocess site plugin + initial-content))) + (all-content (append initial-content + additional-content))) + (loop for content in all-content + do (write-content site content stage-dir)) + (values))) diff --git a/src/format.lisp b/src/format.lisp new file mode 100644 index 0000000..ac5892c --- /dev/null +++ b/src/format.lisp @@ -0,0 +1,14 @@ +(uiop:define-package #:staticl/format + (:use #:cl) + (:import-from #:alexandria + #:make-keyword) + (:import-from #:staticl/site + #:site) + (:export #:to-html)) +(in-package #:staticl/format) + + +(defgeneric to-html (text format) + (:method ((text string) (format string)) + (to-html text + (make-keyword (string-upcase format))))) diff --git a/src/format/html.lisp b/src/format/html.lisp new file mode 100644 index 0000000..99e4fb2 --- /dev/null +++ b/src/format/html.lisp @@ -0,0 +1,7 @@ +(uiop:define-package #:staticl/format/html + (:use #:cl)) +(in-package #:staticl/format/html) + + +(defmethod staticl/format:to-html ((text string) (format (eql :html))) + text) diff --git a/src/format/md.lisp b/src/format/md.lisp new file mode 100644 index 0000000..2229116 --- /dev/null +++ b/src/format/md.lisp @@ -0,0 +1,13 @@ +(uiop:define-package #:staticl/format/md + (:use #:cl) + (:import-from #:3bmd-code-blocks) + (:import-from #:3bmd)) +(in-package #:staticl/format/md) + + +(defmethod staticl/format:to-html ((text string) (format (eql :md))) + ;; TODO: move this binding to some outer scope and make + ;; it possible to turn on different extensions via SITE's settings. + (let ((3bmd-code-blocks:*code-blocks* t)) + (with-output-to-string (str) + (3bmd:parse-string-and-print-to-stream text str)))) diff --git a/src/playground.lisp b/src/playground.lisp new file mode 100644 index 0000000..8e9b0f5 --- /dev/null +++ b/src/playground.lisp @@ -0,0 +1,43 @@ +(uiop:define-package #:staticl/playground + (:use #:cl)) +(in-package #:staticl/playground) + + +(defclass site () + ()) + + +(defclass my-site (site) + ()) + + +(defclass content () + ()) + +(defclass post (content) + ()) + + +(defclass multilingual-post (post) + ()) + + +;; (defgeneric write-content (site content) +;; (:method (site content) +;; (log:info "Called (T T) method")) + +;; (:method ((site site) (content post)) +;; (log:info "Called (SITE POST) method")) + +;; (:method ((site my-site) content) +;; (log:info "Called (MY-SITE T) method"))) + +(defgeneric write-content (site content) + (:method (site content) + (log:info "Called (T T) method")) + + (:method ((site site) (content post)) + (log:info "Called (SITE POST) method")) + + (:method ((site site) (content multilingual-post)) + (log:info "Called (SITE MULTILINGUAL-POST) method"))) diff --git a/src/plugin.lisp b/src/plugin.lisp new file mode 100644 index 0000000..f9a575c --- /dev/null +++ b/src/plugin.lisp @@ -0,0 +1,32 @@ +(uiop:define-package #:staticl/plugin + (:use #:cl) + (:import-from #:log) + (:import-from #:serapeum + #:fmt + #:->) + (:export #:plugin + #:make-plugin)) +(in-package #:staticl/plugin) + + +(defclass plugin () + ()) + + +(-> make-plugin (symbol &rest t) + (values plugin &optional)) + +(defun make-plugin (name &rest initargs) + (let* ((class-name (string-upcase name)) + (package-name (fmt "STATICL/PLUGINS/~A" + class-name)) + (class-symbol (uiop:find-symbol* class-name package-name nil)) + (class (when class-symbol + (find-class class-symbol)))) + (unless class + (error "Unable to find plugin class ~A in package ~A." + class-name + package-name)) + + (apply #'make-instance class + initargs))) diff --git a/src/plugins.lisp b/src/plugins.lisp new file mode 100644 index 0000000..9d1cf77 --- /dev/null +++ b/src/plugins.lisp @@ -0,0 +1,6 @@ +(uiop:define-package #:staticl/plugins + (:use #:cl)) +(in-package #:staticl/plugins) + + + diff --git a/src/plugins/sitemap.lisp b/src/plugins/sitemap.lisp new file mode 100644 index 0000000..318102c --- /dev/null +++ b/src/plugins/sitemap.lisp @@ -0,0 +1,39 @@ +(uiop:define-package #:staticl/plugins/sitemap + (:use #:cl) + (:import-from #:staticl/plugin + #:plugin) + (:import-from #:staticl/content + #:write-content-to-stream + #:get-target-filename + #:content + #:preprocess) + (:import-from #:staticl/site + #:site) + (:import-from #:serapeum + #:soft-list-of)) +(in-package #:staticl/plugins/sitemap) + + +(defclass sitemap (plugin) + ()) + + +(defclass sitemap-file (content) + ((contents :initarg :contents + :type (soft-list-of content) + :reader sitemap-content))) + + +(defmethod preprocess ((site site) (sitemap sitemap) contents) + (list (make-instance 'sitemap-file + :contents contents))) + + +(defmethod get-target-filename ((site site) (sitemap sitemap-file) stage-dir) + (merge-pathnames (make-pathname :name "sitemap" + :type "xml") + stage-dir)) + + +(defmethod write-content-to-stream ((site site) (sitemap sitemap-file) (stream stream)) + (write-string "TODO: make-sure to implement sitemaps" stream)) diff --git a/src/site.lisp b/src/site.lisp new file mode 100644 index 0000000..ab02052 --- /dev/null +++ b/src/site.lisp @@ -0,0 +1,108 @@ +(uiop:define-package #:staticl/site + (:use #:cl) + (:import-from #:log) + (:import-from #:serapeum + #:dict + #:soft-list-of + #:->) + (:import-from #:staticl/utils + #:load-files-from + #:normalize-plist + #:find-class-by-name + #:load-system) + (:import-from #:staticl/plugin + #:make-plugin + #:plugin) + (:import-from #:staticl/theme + #:template-vars + #:load-theme + #:theme) + (:export + #:site + #:site-content-root + #:site-title + #:make-site + #:site-plugins + #:site-theme)) +(in-package #:staticl/site) + + +(defclass site () + ((root :initarg :root + :type pathname + :reader site-content-root + :documentation "A directory pathname where .staticlrc file can be found.") + (title :initarg :title + :type string + :reader site-title + :documentation "Site's title.") + (theme :initarg :theme + :type theme + :reader site-theme + :documentation "A theme object for the site.") + (plugins :initarg :plugins + :type (soft-list-of plugin) + :reader site-plugins + :documentation "A list of site plugins.")) + (:default-initargs + :theme "hyde" + :root (error "ROOT argument is required."))) + + +(defmethod print-object ((obj site) stream) + (print-unreadable-object (obj stream :type t) + (format stream "~S :root ~S" + (site-title obj) + (site-content-root obj)))) + + +(defmethod initialize-instance :around ((obj site) &rest initargs &key root &allow-other-keys) + (apply #'call-next-method + obj + (normalize-plist initargs + :theme (lambda (value) + (etypecase value + (string + (load-theme value + :site-root root)))) + :plugins (lambda (value) + (etypecase value + (list + (serapeum:mapply #'make-plugin + value))))))) + + +(-> make-site (pathname) + (values site &optional)) + +(defun make-site (root) + (let* ((root (probe-file (uiop:ensure-directory-pathname root))) + (rc-file (merge-pathnames #P".staticlrc" + root))) + (unless (probe-file rc-file) + (error "File ~A was not found." + rc-file)) + (let* ((args (uiop:read-file-form rc-file)) + (depends-on (getf args :depends-on)) + (class-name (getf args :class + "staticl/site:site")) + (site-plugins-dir (merge-pathnames (make-pathname :directory '(:relative "plugins")) + root))) + + (when depends-on + (mapcar #'load-system depends-on)) + + (load-files-from site-plugins-dir) + + ;; We should load class only after all + ;; dependencies were loaded + (let ((class (find-class-by-name class-name))) + + (apply #'make-instance class + :root root + args))))) + + +(defmethod template-vars ((site site) &key (hash (dict))) + (setf (gethash "title" hash) + (site-title site))) diff --git a/src/theme.lisp b/src/theme.lisp new file mode 100644 index 0000000..11f5452 --- /dev/null +++ b/src/theme.lisp @@ -0,0 +1,87 @@ +(uiop:define-package #:staticl/theme + (:use #:cl) + (:import-from #:serapeum + #:-> + #:fmt) + (:import-from #:utilities.print-items + #:print-items-mixin) + (:import-from #:staticl/utils + #:find-class-by-name) + (:import-from #:alexandria + #:remove-from-plistf) + (:import-from #:utilities.print-items + #:print-items) + (:export #:theme + #:template-vars + #:render)) +(in-package #:staticl/theme) + + +(defclass theme (print-items-mixin) + ((path :initarg :path + :type pathname + :reader theme-path))) + + +(defmethod print-items append ((theme theme)) + (list (list :path "~S" (theme-path theme)))) + + +(defgeneric template-vars (object &key hash ) + (:documentation "Fills a hash-table given as HASH argument with variables for filling a template. + + If hash is NIL, then a new hash-table should be allocated with EQUAL :TEST argument. + + Returned hash-table will be used for rendering a template for an OBJECT.")) + + +(defgeneric render (theme template-name vars stream) + (:documentation "Renders fills template named TEMPLATE-NAME with given VARS and renders into a given STREAM. + + - NAME argument is a string. + - VARS argument is a hash table with string keys.")) + + +(-> load-theme-from-dir (pathname string) + (values (or null theme) &optional)) + +(defun load-theme-from-dir (dir theme-name) + (let* ((theme-dir + (merge-pathnames + (make-pathname :directory (list :relative theme-name)) + (uiop:ensure-directory-pathname dir))) + (config-filename + (merge-pathnames + (make-pathname :name "theme" + :type "lisp") + theme-dir))) + (when (probe-file config-filename) + (let* ((initargs (uiop:read-file-form config-filename)) + (class-name (getf initargs :class))) + (unless class-name + (error "Theme metadata should contain a :CLASS attribute.")) + (remove-from-plistf initargs :class) + + (let* ((class (find-class-by-name class-name + :default-package-template "STATICL/THEMES/~A"))) + (apply #'make-instance class + :path theme-dir + initargs)))))) + + +(-> load-theme (string &key (:site-root (or null pathname))) + (values theme &optional)) + +(defun load-theme (name &key site-root) + (let ((builtin-themes-dir + (asdf:system-relative-pathname "staticl" + "themes"))) + (or (when site-root + (load-theme-from-dir site-root name)) + (load-theme-from-dir builtin-themes-dir + name) + (error "Theme named ~S not found in ~{~A~#[~; and ~:;, ~]~}" + name + (remove-if #'null + (list site-root + builtin-themes-dir)))))) diff --git a/src/themes/closure-template.lisp b/src/themes/closure-template.lisp new file mode 100644 index 0000000..07eed4d --- /dev/null +++ b/src/themes/closure-template.lisp @@ -0,0 +1,64 @@ +(uiop:define-package #:staticl/themes/closure-template + (:use #:cl) + (:import-from #:closure-template + #:compile-template) + (:import-from #:staticl/utils + #:transform-keys + #:do-files) + (:import-from #:staticl/theme) + (:import-from #:str + #:replace-all)) +(in-package #:staticl/themes/closure-template) + + +(defclass closure-template (staticl/theme:theme) + ((namespace :initarg :namespace + :type string + :reader template-namespace)) + (:default-initargs + :namespace (error ":NAMESPACE argument show be given and correspond to the namespace used in *.tmpl files."))) + + +(defmethod initialize-instance :after ((obj closure-template) &rest initargs &key path &allow-other-keys) + (declare (ignore initargs)) + + (do-files (filename path :file-type "tmpl") + (compile-template :common-lisp-backend filename))) + + +(defun normalize-key (text) + "Transforms dict key to a THIS_UGLY_FORMAT which can be used from Closure Templates." + (replace-all "-" "_" + (string-upcase text))) + + +(defun render-helper (template template-name vars stream) + (let* ((package-name (string-upcase (template-namespace template))) + (func-name (string-upcase template-name)) + (symbol (find-symbol func-name package-name)) + (transformed-vars (transform-keys vars + #'normalize-key))) + (unless (and symbol + (fboundp symbol)) + (error "Unable to render template because there is no ~A function in package ~A." + func-name + package-name)) + + (write-string (funcall symbol transformed-vars) + stream))) + + +(defmethod staticl/theme:render ((template closure-template) (template-name string) (vars hash-table) (stream stream)) + (cond + ((string-equal template-name "base") + (render-helper template template-name vars stream)) + (t + (render-helper template "base" + (serapeum:dict* vars + ;; Closure templates do not have + ;; inheritance, so we just render + ;; inner part of the page separately: + :raw (with-output-to-string (string-stream) + (render-helper template template-name vars + string-stream))) + stream)))) diff --git a/src/utils.lisp b/src/utils.lisp new file mode 100644 index 0000000..97259da --- /dev/null +++ b/src/utils.lisp @@ -0,0 +1,155 @@ +(uiop:define-package #:staticl/utils + (:use #:cl) + (:import-from #:log) + (:import-from #:str + #:trim-left) + (:import-from #:serapeum + #:maphash-new + #:fmt + #:->) + (:import-from #:alexandria + #:proper-list + #:with-gensyms) + (:import-from #:cl-fad + #:walk-directory) + (:export + #:do-files + #:normalize-plist)) +(in-package #:staticl/utils) + + +(-> load-system (string) + (values &optional)) + +(defun load-system (name) + (log:info "Loading system" name) + #+quicklisp + (ql:quickload name) + #-quicklisp + (asdf:load-system name) + (values)) + + +(-> split-package-and-symbol-name (string &key (:default-package-template (or null string))) + (values string + string + &optional)) + + +(defun split-package-and-symbol-name (text &key default-package-template) + (cond + ((find #\: text :test #'char=) + (destructuring-bind (package-name symbol-name) + (str:split #\: text :omit-nulls t :limit 2) + (values package-name + ;; In case if text is "SOME::FOO" + ;; we need to trim : prefix from symbol-name + (trim-left symbol-name :char-bag '(#\:))))) + (t + (unless default-package-template + (error "Unable to determine a package name of ~S." + text)) + + (values (fmt default-package-template + text) + text)))) + + +(-> find-class-by-name (string &key (:default-package-template (or null string))) + (values standard-class + &optional)) + + +(defun find-class-by-name (name &key default-package-template) + (log:debug "Searching class by name" name) + (multiple-value-bind (package-name symbol-name) + (split-package-and-symbol-name name + :default-package-template default-package-template) + (let ((symbol (uiop:find-symbol* (string-upcase symbol-name) + (string-upcase package-name)))) + (find-class symbol)))) + + +(defmacro do-files ((filename root-path &key file-type) &body body) + "For each file under ROOT-PATH, run BODY. If FILE-TYPE is provided, only run +BODY on files that match the given extension." + (with-gensyms (correct-file-type-p thunk) + `(flet ((,correct-file-type-p (file) + (string-equal (pathname-type file) + ,file-type)) + (,thunk (,filename) + ,@body)) + (declare (dynamic-extent #',correct-file-type-p + #',thunk)) + (walk-directory (uiop:ensure-directory-pathname ,root-path) + #',thunk + :follow-symlinks nil + :test (if ,file-type + #',correct-file-type-p + (constantly t)))))) + + +(-> normalize-plist (proper-list &key &allow-other-keys) + (values proper-list &optional)) + +(defun normalize-plist (plist &rest normalizers-plist &key &allow-other-keys) + "Returns a new list where each value is replaced with results of call of normalizing functions. + + For example: + + ``` + CL-USER> (normalize-plist '(:foo \"Bar\" :blah 123) + :foo (lambda (value) + (alexandria:make-keyword (string-upcase value)))) + (LIST :FOO :BAR :BLAH 123) + ``` +" + (loop for (key value) on plist by #'cddr + for normalizer = (getf normalizers-plist key) + append (list key + (if normalizer + (funcall normalizer value) + value)))) + + +(-> load-files-from (pathname) + (values &optional)) + +(defun load-files-from (path) + (let ((path (uiop:ensure-directory-pathname path))) + (when (probe-file path) + (flet ((lisp-file-p (filename) + (string-equal (pathname-type filename) + "lisp"))) + (declare (dynamic-extent #'lisp-file-p)) + + (cl-fad:walk-directory path + #'load + :test #'lisp-file-p)))) + (values)) + + + +(-> transform-keys (hash-table (function (string) + (values string &optional))) + (values hash-table &optional)) + +(defun transform-keys (dict fn) + "Transforms to uppercase or string keys of dict and keys of all nested dicts." + (labels ((rec (obj) + (typecase obj + (null + obj) + (hash-table + (maphash-new #'to-upper obj)) + (list + (mapcar #'rec obj)) + (t obj))) + (to-upper (key value) + (values (typecase key + (string (funcall fn key)) + (t key)) + (rec value)))) + (declare (dynamic-extent #'rec + #'to-upper)) + (rec dict))) diff --git a/staticl-ci.asd b/staticl-ci.asd new file mode 100644 index 0000000..a719b2a --- /dev/null +++ b/staticl-ci.asd @@ -0,0 +1,11 @@ +(defsystem "staticl-ci" + :author "Alexander Artemenko " + :license "Unlicense" + :homepage "https://40ants.com/staticl/" + :class :package-inferred-system + :description "Provides CI settings for staticl." + :source-control (:git "https://github.com/40ants/staticl") + :bug-tracker "https://github.com/40ants/staticl/issues" + :pathname "src" + :depends-on ("40ants-ci" + "staticl-ci/ci")) diff --git a/staticl-docs.asd b/staticl-docs.asd new file mode 100644 index 0000000..fdc4312 --- /dev/null +++ b/staticl-docs.asd @@ -0,0 +1,11 @@ +(defsystem "staticl-docs" + :author "Alexander Artemenko " + :license "Unlicense" + :homepage "https://40ants.com/staticl/" + :class :package-inferred-system + :description "Provides documentation for staticl." + :source-control (:git "https://github.com/40ants/staticl") + :bug-tracker "https://github.com/40ants/staticl/issues" + :pathname "docs" + :depends-on ("staticl" + "staticl-docs/index")) diff --git a/staticl-tests.asd b/staticl-tests.asd new file mode 100644 index 0000000..a347264 --- /dev/null +++ b/staticl-tests.asd @@ -0,0 +1,13 @@ +(defsystem "staticl-tests" + :author "Alexander Artemenko " + :license "Unlicense" + :homepage "https://40ants.com/staticl/" + :class :package-inferred-system + :description "Provides tests for staticl." + :source-control (:git "https://github.com/40ants/staticl") + :bug-tracker "https://github.com/40ants/staticl/issues" + :pathname "t" + :depends-on ("staticl-tests/core") + :perform (test-op (op c) + (unless (symbol-call :rove :run c) + (error "Tests failed")))) diff --git a/staticl.asd b/staticl.asd new file mode 100644 index 0000000..3aa141b --- /dev/null +++ b/staticl.asd @@ -0,0 +1,24 @@ +#-asdf3.1 (error "staticl requires ASDF 3.1 because for lower versions pathname does not work for package-inferred systems.") +(defsystem "staticl" + :description "Flexible and customizable static site generator with a lot of plugins!" + :author "Alexander Artemenko " + :license "Unlicense" + :homepage "https://40ants.com/staticl/" + :source-control (:git "https://github.com/40ants/staticl") + :bug-tracker "https://github.com/40ants/staticl/issues" + :class :40ants-asdf-system + :defsystem-depends-on ("40ants-asdf-system") + :pathname "src" + :depends-on ("staticl/core" + "staticl/content/defaults" + "staticl/plugins/sitemap" + "staticl/themes/closure-template" + "staticl/format/html" + "staticl/format/md") + :in-order-to ((test-op (test-op "staticl-tests")))) + + + +(asdf:register-system-packages "log4cl" '("LOG")) +(asdf:register-system-packages "3bmd-ext-code-blocks" '("3BMD-CODE-BLOCKS")) +(asdf:register-system-packages "fuzzy-dates" '("ORG.SHIRAKUMO.FUZZY-DATES")) diff --git a/t/core.lisp b/t/core.lisp new file mode 100644 index 0000000..d91f352 --- /dev/null +++ b/t/core.lisp @@ -0,0 +1,11 @@ +(uiop:define-package #:staticl-tests/core + (:use #:cl) + (:import-from #:rove + #:deftest + #:ok + #:testing)) +(in-package #:staticl-tests/core) + + +(deftest test-example () + (ok t "Replace this test with something useful.")) diff --git a/themes/hyde/base.tmpl b/themes/hyde/base.tmpl new file mode 100644 index 0000000..24303cd --- /dev/null +++ b/themes/hyde/base.tmpl @@ -0,0 +1,57 @@ +{namespace coleslaw.theme.hyde} + +{template base} +{\n} + + + {$config.title} + + + + + + + {if $injections.head} + {foreach $injection in $injections.head} + {$injection |noAutoescape} + {/foreach} + {/if} + + + +
+ {$raw |noAutoescape} +
+ {if $injections.body} + {foreach $injection in $injections.body} + {$injection |noAutoescape} + {/foreach} + {/if} +
+
+ Unless otherwise credited all material + {if $config.license} + {$config.license} + {else} + + Creative Commons License + + {/if} + by {$config.author} + +
+ + +{/template} diff --git a/themes/hyde/index.tmpl b/themes/hyde/index.tmpl new file mode 100644 index 0000000..29cbee9 --- /dev/null +++ b/themes/hyde/index.tmpl @@ -0,0 +1,34 @@ +{namespace coleslaw.theme.hyde} + +{template index} +

{$index.title}

+{foreach $obj in $index.content} + +{/foreach} +
+ {if $prev} Previous {/if} + {if $next} Next {/if} +
+{if $tags} +
+

This blog covers + {foreach $tag in $tags} + {$tag.name}{nil} + {if not isLast($tag)},{sp}{/if} + {/foreach} +

+{/if} +{if $months} +
+

View content from + {foreach $month in $months} + {$month.name}{nil} + {if not isLast($month)},{sp}{/if} + {/foreach} +

+{/if} +{/template} diff --git a/themes/hyde/post.tmpl b/themes/hyde/post.tmpl new file mode 100644 index 0000000..d47c0c2 --- /dev/null +++ b/themes/hyde/post.tmpl @@ -0,0 +1,27 @@ +{namespace coleslaw.theme.hyde} + +{template post} +{\n} +
{\n} + {$content.html |noAutoescape} +
{\n} +
{\n} + {if $prev} Previous
{/if}{\n} + {if $next} Next
{/if}{\n} +
{\n} +{/template} diff --git a/themes/hyde/theme.lisp b/themes/hyde/theme.lisp new file mode 100644 index 0000000..e6856e4 --- /dev/null +++ b/themes/hyde/theme.lisp @@ -0,0 +1,2 @@ +(:class "closure-template" + :namespace "coleslaw.theme.hyde")