commit 8afe7720db2af575ff81c4b4681e6d644bbf96f9
Author: mio
Date: Sat Jun 29 20:38:42 2024 +0000
Initial commit
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..05abb75
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+lamium
+lm
+todo.txt
diff --git a/cli.go b/cli.go
new file mode 100644
index 0000000..b68d229
--- /dev/null
+++ b/cli.go
@@ -0,0 +1,76 @@
+package main
+
+import (
+ "fmt"
+
+ tpl "lamium/template"
+)
+
+var (
+ // App information.
+ app = struct {
+ name string
+ cmd string
+ desc string
+ version string
+ configPath string
+ }{
+ name: "Lamium",
+ cmd: "lm",
+ desc: "Static web album generator",
+ version: "0.1.0",
+ configPath: "lamium.json",
+ }
+
+ // Command line menu options.
+ helpOpts = [][]string{
+ {"Usage: " + app.cmd + " [option]\n"},
+ {"Options:"},
+ {"-h", "help", "Show this help message"},
+ {"-m", "make", "Make album in the configured directory"},
+ {"-n", "new", "Place a new config in the current directory"},
+ {"-v", "version", "Show app version"},
+ }
+)
+
+// Prints a help message to standard output.
+func showHelp() {
+ for i := range helpOpts {
+ if len(helpOpts[i]) >= 3 {
+ fmt.Println(helpOpts[i][0]+", "+helpOpts[i][1], "\t\t"+
+ helpOpts[i][2])
+ } else {
+ fmt.Println(helpOpts[i][0])
+ }
+ }
+}
+
+// Prints version information.
+func showVersion() {
+ fmt.Println(app.name, app.version, "—", app.desc)
+}
+
+// Maps command line options to corresponding functions.
+func parseArgs(args []string) {
+ if len(args) > 1 {
+ switch args[1] {
+ case "-h", "h", "help":
+ showHelp()
+ case "-m", "m", "make":
+ conf := loadConfig(app.configPath)
+ index := map[string]string{
+ "html": conf.IndexHTML,
+ "type": conf.IndexType,
+ "title": conf.IndexTitle,
+ }
+ tpl.MakeAlbum(conf.ThemeDir, conf.HTMLDir, index, conf.Sets)
+ case "-n", "n", "new":
+ genConfig(app.configPath)
+ tpl.GenTheme(false)
+ case "-v", "v", "version":
+ showVersion()
+ }
+ } else {
+ showHelp()
+ }
+}
diff --git a/config.go b/config.go
new file mode 100644
index 0000000..57dfe99
--- /dev/null
+++ b/config.go
@@ -0,0 +1,79 @@
+package main
+
+import (
+ "encoding/json"
+ "path/filepath"
+
+ tpl "lamium/template"
+ util "lamium/util"
+)
+
+type (
+ // A Config defines settings for album generation.
+ Config struct {
+ HTMLDir string `json:"htmlDir"`
+ IndexHTML string `json:"indexHTML"`
+ IndexTitle string `json:"indexTitle"`
+ IndexType string `json:"indexType"`
+ ThemeDir string `json:"themeDir"`
+ Sets []tpl.Set `json:"sets"`
+ }
+)
+
+var (
+ // Example configuration.
+ sampleConfig = Config{
+ HTMLDir: "public_html",
+ IndexHTML: "index.html",
+ IndexTitle: "My Album",
+ IndexType: "text",
+ ThemeDir: "themes/nettle",
+ Sets: []tpl.Set{
+ {
+ Name: "set1",
+ ImageDir: "set1",
+ IndexHTML: "index.html",
+ Title: "Set 1",
+ SortOrder: "modTimeDesc",
+ CSSClassIframe: "frame",
+ CSSClassLink: "link",
+ CSSClassThumb: "thumb",
+ LightboxOn: true,
+ LinkType: "page",
+ ThumbDir: "thumbs",
+ ThumbWidth: 200,
+ ThumbHeight: 200,
+ ThumbPct: 0,
+ ThumbClip: "square",
+ ThumbUnifyMode: "height",
+ },
+ },
+ }
+
+ // Error messages.
+ configErr = struct {
+ configNotLoaded string
+ jsonNotSaved string
+ }{
+ configNotLoaded: "Error: config could not be loaded:",
+ jsonNotSaved: "Error: JSON config could not be saved:",
+ }
+)
+
+// genConfig outputs an example configuration file at path.
+func genConfig(path string) {
+ conf, jsonErr := json.MarshalIndent(sampleConfig, "", " ")
+ if !util.HasError(jsonErr, configErr.jsonNotSaved+" "+path, true) {
+ util.MakeDir(filepath.Dir(path))
+ util.SaveFile(path, string(conf), true)
+ }
+}
+
+// loadConfig returns a *Config interface of settings after reading
+// the configuration file at path.
+func loadConfig(path string) *Config {
+ contents := util.LoadFile(path, true)
+ conf := &Config{}
+ json.Unmarshal([]byte(contents), &conf)
+ return conf
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..ac33147
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,5 @@
+module lamium
+
+go 1.22.4
+
+require golang.org/x/image v0.18.0
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..9d0133a
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,2 @@
+golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
+golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
diff --git a/image/image.go b/image/image.go
new file mode 100644
index 0000000..47ef108
--- /dev/null
+++ b/image/image.go
@@ -0,0 +1,364 @@
+// Package image uses the official Go image/draw library to provide more options // for image downscaling. It includes functions related to the supported image
+// formats.
+package image
+
+import (
+ "fmt"
+ "image"
+ "image/gif"
+ "image/jpeg"
+ "image/png"
+ "io"
+ "math"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+
+ "golang.org/x/image/draw"
+
+ util "lamium/util"
+)
+
+var (
+ // Error messages.
+ err = struct {
+ imageNotCreated string
+ imageNotLoaded string
+ imageNotDecoded string
+ imageNotEncoded string
+ }{
+ imageNotCreated: "Error: new image not created.",
+ imageNotLoaded: "Error: image could not be loaded:",
+ imageNotDecoded: "Error: image could not be decoded:",
+ imageNotEncoded: "Error: image could not be encoded:",
+ }
+
+ // Supported image extensions.
+ imageExt = map[string][]string{
+ "gif": {".gif", ".GIF"},
+ "jpg": {".jpeg", ".jpg", ".JPEG", ".JPG"},
+ "png": {".png", ".PNG"},
+ }
+
+ // Image information messages.
+ msg = struct {
+ debugSrcImg string
+ debugSrcPath string
+ debugNewImg string
+ imgResize string
+ }{
+ debugSrcImg: "Source image size:",
+ debugSrcPath: "Image:",
+ debugNewImg: "New image size:",
+ imgResize: "Resizing",
+ }
+)
+
+// IsSupported checks an image's file extension returns true if it is one of the // supported extensions.
+func IsSupported(path string) bool {
+ var exts []string
+ for _, s := range imageExt {
+ for _, v := range s {
+ exts = append(exts, v)
+ }
+ }
+ if slices.Contains(exts, filepath.Ext(path)) {
+ return true
+ } else {
+ return false
+ }
+}
+
+// CopyImages copies image files from srcPath to destPath, overwriting files at
+// the destination if overwrite is set to true.
+func CopyImages(srcPath, destPath string, overwrite bool) {
+ files := util.GetFileList(srcPath, "name", false)
+ for _, file := range files {
+ destFile := filepath.Join(destPath, filepath.Base(file))
+ if (!util.FileExists(destFile) || (util.FileExists(destFile) &&
+ overwrite)) && IsSupported(file) {
+ util.CopyFile(file, destFile)
+ }
+ }
+}
+
+// GetImageList returns a slice of strings of image files in srcPath.
+//
+// For sortOrder options, see util.GetFileList.
+func GetImageList(srcPath, sortOrder string, includeDirs bool) []string {
+ files := util.GetFileList(srcPath, sortOrder, includeDirs)
+ var list []string
+ for _, file := range files {
+ if IsSupported(file) {
+ list = append(list, file)
+ }
+ }
+ return list
+}
+
+// DecodeImage returns an image.Image object from an image path.
+func DecodeImage(path string) image.Image {
+ var (
+ img image.Image
+ decErr error
+ )
+ srcFile, openErr := os.Open(filepath.FromSlash(path))
+ defer srcFile.Close()
+ if !util.HasError(openErr, err.imageNotLoaded+" "+path) {
+ if slices.Contains(imageExt["jpg"], filepath.Ext(path)) {
+ img, decErr = jpeg.Decode(srcFile)
+ } else if slices.Contains(imageExt["png"], filepath.Ext(path)) {
+ img, decErr = png.Decode(srcFile)
+ } else if slices.Contains(imageExt["gif"], filepath.Ext(path)) {
+ img, decErr = gif.Decode(srcFile)
+ } else {
+ util.HasError(decErr, err.imageNotDecoded+" "+path)
+ }
+ }
+ util.HasError(decErr, err.imageNotDecoded+" "+path)
+ return img
+}
+
+// EncodeImage writes an image.Image object as image file path.
+func EncodeImage(path string, writer io.Writer, img image.Image) {
+ var encErr error
+ if slices.Contains(imageExt["jpg"], filepath.Ext(path)) {
+ encErr = jpeg.Encode(writer, img, nil)
+ } else if slices.Contains(imageExt["png"], filepath.Ext(path)) {
+ encErr = png.Encode(writer, img)
+ } else if slices.Contains(imageExt["gif"], filepath.Ext(path)) {
+ encErr = gif.Encode(writer, img, nil)
+ } else {
+ util.HasError(encErr, err.imageNotEncoded+" "+path)
+ }
+ util.HasError(encErr, err.imageNotEncoded+" "+path)
+}
+
+// CalcImageSize returns the new width and height of image.Image srcImg
+// based on pct, clip and unifyMode.
+//
+// pct is percentage of the original size, an int value between 1-99
+// inclusively.
+//
+// clip options: square, rect.
+//
+// unifyMode options with example scenarios if width is 200 and height is 100:
+//
+// maxSize: images are the maximum size at the longer side.
+// An image in landscape would be 200px in width and less in height.
+// An image in portrait would have a height of 200px, less in width.
+// A square image would have a width and height of 200px.
+//
+// minSize: images are the minimum size at the shorter side.
+// An image in landscape would be 100px in height and more in width.
+// An image in portrait would have a width of 100px, more in height.
+// A square image would have a width and height of 100px.
+//
+// width: images have the same width.
+// An image in landscape would be 200px wide, less in height.
+// Animage in portrait would be 200px wide, more in height
+// A square image would have a width and height of 200px.
+//
+// height: images have the same height.
+// An image in landscape would be 100px high and wider than 100px.
+// An image in portrait would be 100px high, and narrower than 100px.
+// A square image would have a width and height of 100px.
+//
+// none: images are sized at a percentage of the original image regardless of
+// set width and height. The current default is 50% of the original image
+// dimensions, e.g. a 600 x 800px source image yields a new 300 x 400px image.
+func CalcImageSize(srcImg image.Image, width, height, pct int,
+ clip string, unifyMode string) (int, int) {
+ // Constrain width and height to source dimensions.
+ w, h := width, height
+ if w > srcImg.Bounds().Dx() || w < 0 {
+ w = srcImg.Bounds().Dx()
+ }
+ if h > srcImg.Bounds().Dy() || h < 0 {
+ h = srcImg.Bounds().Dy()
+ }
+ // Get source aspect ratio.
+ minSrcSize, maxSrcSize := util.MinMax(srcImg.Bounds().Dx(),
+ srcImg.Bounds().Dy())
+ var ratio = float64(minSrcSize) / float64(maxSrcSize)
+ switch unifyMode {
+ case "maxSize":
+ // Use the higher value of width/height as maximum size.
+ _, maxSize := util.MinMax(w, h)
+ if srcImg.Bounds().Dx() > srcImg.Bounds().Dy() {
+ w = maxSize
+ h = int(math.Round(float64(w) * ratio))
+ } else if srcImg.Bounds().Dy() > srcImg.Bounds().Dx() {
+ w = int(math.Round(float64(maxSize) * ratio))
+ h = maxSize
+ } else {
+ w, h = maxSize, maxSize
+ }
+ case "minSize":
+ // Use the lower value of width/height as minimum size.
+ minSize, _ := util.MinMax(w, h)
+ if srcImg.Bounds().Dx() > srcImg.Bounds().Dy() {
+ w = int(math.Round(float64(srcImg.Bounds().Dx()) /
+ (float64(srcImg.Bounds().Dy()) / float64(h))))
+ h = minSize
+ } else if srcImg.Bounds().Dx() < srcImg.Bounds().Dy() {
+ w = minSize
+ h = int(math.Round(float64(srcImg.Bounds().Dy()) /
+ (float64(srcImg.Bounds().Dx()) / float64(w))))
+ } else {
+ w, h = minSize, minSize
+ }
+ case "width":
+ // Use width as baseline for aspect ratio.
+ if srcImg.Bounds().Dx() != srcImg.Bounds().Dy() {
+ h = int(math.Round(float64(srcImg.Bounds().Dy()) /
+ (float64(srcImg.Bounds().Dx()) / float64(w))))
+ } else {
+ h = w
+ }
+ case "height":
+ // Use height as baseline for aspect ratio.
+ if srcImg.Bounds().Dx() != srcImg.Bounds().Dy() {
+ w = int(math.Round(float64(srcImg.Bounds().Dx()) /
+ (float64(srcImg.Bounds().Dy()) / float64(h))))
+ } else {
+ w = h
+ }
+ case "none":
+ // Derive values as a percentage of source image size.
+ pc := pct
+ if pct > 0 || pct < 100 {
+ pc = 50
+ }
+ if srcImg.Bounds().Dx() > srcImg.Bounds().Dy() {
+ w = int(math.Round(float64(srcImg.Bounds().Dx()) *
+ (float64(pc) / 100)))
+ h = int(math.Round(float64(srcImg.Bounds().Dx()) * ratio *
+ (float64(pc) / 100)))
+ } else {
+ w = int(math.Round(float64(srcImg.Bounds().Dy()) * ratio *
+ (float64(pc) / 100)))
+ h = int(math.Round(float64(srcImg.Bounds().Dy()) *
+ (float64(pc) / 100)))
+ }
+ }
+
+ if clip == "square" {
+ switch unifyMode {
+ case "minSize":
+ w, _ = util.MinMax(w, h)
+ h = w
+ case "maxSize", "none":
+ _, w = util.MinMax(w, h)
+ h = w
+ case "width":
+ h = w
+ case "height":
+ w = h
+ }
+ }
+
+ return w, h
+}
+
+// CalcClipSize returns the new width and height as well as an image.Rectangle
+// object for image.Image srcImg based on a set width and height and clip mode
+// (square or rect).
+func CalcClipSize(srcImg image.Image, width, height int, clip string) (int,
+ int, image.Rectangle) {
+ var w, h int
+ var srcBounds image.Rectangle
+ if clip == "square" {
+ // Center the square on rectangular images.
+ // Use max length for new image size if width and height are
+ // different.
+ _, w = util.MinMax(width, height)
+ h = w
+ // Get the shorter side.
+ srcMin, _ := util.MinMax(srcImg.Bounds().Dx(), srcImg.Bounds().Dy())
+ x1, y1 := 0, 0
+ x2, y2 := srcMin, srcMin
+ // Shift the start and end values on the longer side of the
+ // source image's image.Rectangle.
+ if srcImg.Bounds().Dx() != srcImg.Bounds().Dy() &&
+ srcMin == srcImg.Bounds().Dx() {
+ y1 = int(math.Round(float64(srcImg.Bounds().Dy()-
+ srcMin) / float64(2)))
+ y2 = int(srcMin + y1)
+ } else if srcImg.Bounds().Dx() != srcImg.Bounds().Dy() &&
+ srcMin == srcImg.Bounds().Dy() {
+ x1 = int(math.Round(float64(srcImg.Bounds().Dx()-
+ srcMin) / float64(2)))
+ x2 = int(srcMin + x1)
+ }
+ srcBounds = image.Rect(x1, y1, x2, y2)
+ } else {
+ w, h = width, height
+ srcBounds = srcImg.Bounds()
+ }
+ return w, h, srcBounds
+}
+
+// ResizeImage creates a new downscaled image at destPath from source image
+// srcPath.
+//
+// For more details about the pct, clip, and unifyMode parameters, see
+// CalcImageSize.
+func ResizeImage(srcPath, destPath string, width, height, pct int, clip,
+ unifyMode string, debug ...bool) {
+ util.MakeDir(filepath.Dir(destPath), true)
+ var destFile *os.File
+ var createErr error
+ if IsSupported(srcPath) {
+ destFile, createErr = os.Create(filepath.FromSlash(destPath))
+ defer destFile.Close()
+ }
+ if !util.HasError(createErr, err.imageNotCreated) && IsSupported(srcPath) {
+ w, h := width, height
+ var srcBounds image.Rectangle
+ srcImg := DecodeImage(srcPath)
+ w, h = CalcImageSize(srcImg, w, h, pct, clip, unifyMode)
+ w, h, srcBounds = CalcClipSize(srcImg, w, h, clip)
+
+ destImg := image.NewRGBA(image.Rect(0, 0, w, h))
+ draw.BiLinear.Scale(destImg, destImg.Rect, srcImg, srcBounds, draw.Over,
+ nil)
+ EncodeImage(destPath, destFile, destImg)
+
+ if slices.Contains(debug, true) {
+ fmt.Println(msg.debugSrcPath, srcPath)
+ fmt.Println(msg.debugSrcImg, srcImg.Bounds().Dx(),
+ srcImg.Bounds().Dy())
+ fmt.Println(msg.debugNewImg, w, h)
+
+ }
+ }
+}
+
+// ResizeImages creates multiple downscaled images at directory destPath from
+// source images at directory srcPath.
+func ResizeImages(srcPath, destPath string, width, height, pct int, clip string,
+ unifyMode string, overwrite bool, debug ...bool) {
+ files := util.GetFileList(srcPath, "name", false)
+ debugOn := false
+ if slices.Contains(debug, true) {
+ debugOn = true
+ }
+ for _, file := range files {
+ destImg := filepath.Join(destPath, filepath.Base(file))
+ if !util.FileExists(destImg) || (util.FileExists(destImg) &&
+ overwrite) {
+ ResizeImage(file, destImg, width, height, pct, clip, unifyMode,
+ debugOn)
+ }
+ }
+}
+
+// GetImageTitle returns a title string based on the image filename in path.
+func GetImageTitle(path string) string {
+ repChars := map[string]string{"-": " ", "_": " "}
+ str := strings.Replace(util.MultiReplace(filepath.Base(path), repChars),
+ filepath.Ext(path), "", 1)
+ return str
+}
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..c3b46b3
--- /dev/null
+++ b/main.go
@@ -0,0 +1,9 @@
+package main
+
+import (
+ "os"
+)
+
+func main() {
+ parseArgs(os.Args)
+}
diff --git a/readme.md b/readme.md
new file mode 100644
index 0000000..4fd8fab
--- /dev/null
+++ b/readme.md
@@ -0,0 +1,59 @@
+# Lamium
+
+A simple HTML album generator.
+
+Features:
+
+- Generate rectangular or square image thumbnails
+- Default plain theme with CSS lightbox
+- Supported image types: .jpg, .png
+- Supported image caption files: .txt, .html
+
+
+*This is currently a preview release and may contain issues.*
+
+
+## Build
+
+- Download the source or `git clone` the repo.
+
+- Install [Go](https://golang.org), then build: `go build -o lm .`
+
+
+## Usage
+
+- Make a new album directory and change into it: `mkdir [album] && cd [album]`
+
+- Add a folder or "set" of images to the directory, including any image caption files.
+
+- Generate a new sample config and theme: `lm new`
+
+- Edit the settings in the `.json` config file.
+
+- Customise the default theme templates as desired.
+
+- Generate the album: `lm make`
+
+By default the `make` option will output a `public_html` folder inside the album source directory with the generated HTML files, images and thumbnails. The HTML album can then be uploaded to a web server directory using rsync or another file transfer application.
+
+
+Example album structure:
+
+```
+album/
+ |- set1/
+ | |- image1.jpg
+ | |- image1.txt
+ | |- image2.jpg
+ | |- image2.txt
+ | |- index.txt
+ |- themes/
+ | |- [theme]/
+ | |- index.txt
+ |- lamium.json
+```
+
+
+## License
+
+[0BSD](https://spdx.org/licenses/0BSD.html)
diff --git a/template/template.go b/template/template.go
new file mode 100644
index 0000000..0321826
--- /dev/null
+++ b/template/template.go
@@ -0,0 +1,668 @@
+// Package template implements HTML page generation functions from a default
+// or custom theme.
+package template
+
+import (
+ "embed"
+ "html"
+ "os"
+ "path/filepath"
+ "regexp"
+ "slices"
+ "strconv"
+ "strings"
+
+ image "lamium/image"
+ util "lamium/util"
+)
+
+type (
+ // A Set is a collection of images. The Set type contains properties
+ // about the collection such as the source directory for the images
+ // and how to display them.
+ //
+ // JSON tags are included for config file marshalling.
+ Set struct {
+ Name string `json:"name"`
+ ImageDir string `json:"imageDir"`
+ IndexHTML string `json:"indexHTML"`
+ Title string `json:"title"`
+ SortOrder string `json:"sortOrder"`
+ CSSClassIframe string `json:"cssClassIframe"`
+ CSSClassLink string `json:"cssClassLink"`
+ CSSClassThumb string `json:"cssClassThumb"`
+ LightboxOn bool `json:"lightboxOn"`
+ LinkType string `json:"linkType"`
+ ThumbDir string `json:"thumbDir"`
+ ThumbWidth int `json:"thumbWidth"`
+ ThumbHeight int `json:"thumbHeight"`
+ ThumbPct int `json:"thumbPercent"`
+ ThumbClip string `json:"thumbClip"`
+ ThumbUnifyMode string `json:"thumbUnifyMode"`
+ }
+)
+
+var (
+ // File extensions for captions.
+ CaptionExt = map[string]string{
+ "html": ".html",
+ "txt": ".txt",
+ }
+
+ // Supported HTML markup tags.
+ MarkupHTML = map[string]string{
+ "<b>": "",
+ "</b>": "",
+ "<blockquote>": "",
+ "</blockquote>": "
",
+ "<br>": "
",
+ "<code>": "",
+ "</code>": "
",
+ "<del>": "",
+ "</del>": "",
+ "<dd>": "",
+ "</dd>": "",
+ "<dl>": "",
+ "</dl>": "
",
+ "<dt>": "",
+ "</dt>": "",
+ "<em>": "",
+ "</em>": "",
+ "<h1>": "",
+ "</h1>": "
",
+ "<h2>": "",
+ "</h2>": "
",
+ "<h3>": "",
+ "</h3>": "
",
+ "<i>": "",
+ "</i>": "",
+ "<ol>": "",
+ "</ol>": "
",
+ "<p>": "",
+ "</p>": "
",
+ "<strong>": "",
+ "</strong>": "",
+ "<s>": "",
+ "</s>": "",
+ "<u>": "",
+ "</u>": "",
+ "<ul>": "",
+ }
+
+ // Supported markup syntax regexp for text files and the HTML tag
+ // translations.
+ MarkupTxt = []map[string]string{
+ {`^\*\*`: ""},
+ {`\*\*$`: ""},
+ {`\*\*\n`: "\n"},
+ {`\t\*\*`: "\t"},
+ {`\*\*\t`: "\t"},
+ {`[[:space:]]\*\*`: " "},
+ {`\*\*[[:space:]]`: " "},
+ {`^\*`: ""},
+ {`\*$`: ""},
+ {`\*\n`: "\n"},
+ {`\t\*`: "\t"},
+ {`\*\t`: "\t"},
+ {`[[:space:]]\*`: " "},
+ {`\*[[:space:]]`: " "},
+ {`^__`: ""},
+ {`__$`: ""},
+ {`__\n`: "\n"},
+ {`\t__`: "\t"},
+ {`__\t`: "\t"},
+ {`[[:space:]]__`: " "},
+ {`__[[:space:]]`: " "},
+ {`^\~\~`: ""},
+ {`\~\~$`: ""},
+ {`\~\~\n`: "\n"},
+ {`\t\~\~`: "\t"},
+ {`\~\~\t`: "\t"},
+ {`[[:space:]]\~\~`: " "},
+ {`\~\~[[:space:]]`: " "},
+ {`\[url=`: `"},
+ {`\[h1\]`: ""},
+ {`\[\/h1\]`: "
"},
+ {`\[h2\]`: ""},
+ {`\[\/h2\]`: "
"},
+ {`\[h3\]`: ""},
+ {`\[\/h3\]`: "
"},
+ {`\]`: `">`},
+ {"\n\n": "
\n"},
+ {" \n": "
"},
+ }
+
+ // Template variables used in partials.
+ PartVar = map[string]string{
+ "id": "{{ id }}",
+ "iframeClass": "{{ iframe_class }}",
+ "link": "{{ link }}",
+ "linkClass": "{{ link_class }}",
+ "navNext": "{{ nav_next }}",
+ "navPrev": "{{ nav_prev }}",
+ "navTop": "{{ nav_top }}",
+ "thumb": "{{ thumb }}",
+ "thumbClass": "{{ thumb_class }}",
+ "set": "[set]",
+ }
+
+ // HTML partials that are inserted into templates.
+ PartHTML = map[string]string{
+ "img": `` + "\n",
+ "lightbox": `
+ ` + "\n",
+ "nav": `←
+ ↑
+ →`,
+ }
+
+ // Sample theme directory path relative to the album root.
+ //
+ // If changing this value, the go:embed directive for the ThemeFile variable
+ // also needs to be updated, to store the theme files at compile time.
+ SampleThemeDir = "themes/nettle"
+
+ // Theme template filenames.
+ Theme = struct {
+ Index string `json:"index"`
+ Image string `json:"_image"`
+ Set string `json:"_set"`
+ Sets string `json:"_sets"`
+ Text string `json:"_text"`
+ AlbumCSS string `json:"albumCSS"`
+ LightboxCSS string `json:"lightboxCSS"`
+ }{
+ Index: "index.html",
+ Image: "_image.html",
+ Set: "_set.html",
+ Sets: "_sets.html",
+ Text: "_text.html",
+ AlbumCSS: "album.css",
+ LightboxCSS: "lightbox.css",
+ }
+
+ // Template variables used in theme files.
+ ThemeVar = map[string]map[string]string{
+ Theme.Index: {
+ "content": "{{ index_content }}",
+ "relPath": "{{ index_relpath }}",
+ "title": "{{ index_title }}",
+ },
+ Theme.Image: {
+ "caption": "{{ image_caption }}",
+ "path": "{{ image_path }}",
+ "title": "{{ image_title }}",
+ "nav": "{{ nav }}",
+ },
+ Theme.Set: {
+ "content": "{{ set }}",
+ "desc": "{{ set_desc }}",
+ "lightbox": "{{ set_lightbox }}",
+ "name": "{{ set_name }}",
+ "title": "{{ set_title }}",
+ },
+ Theme.Sets: {
+ "content": "{{ set_[set] }}",
+ "desc": "{{ set_[set]_desc }}",
+ "lightbox": "{{ set_[set]_lightbox }}",
+ "name": "{{ set_[set]_name }}",
+ "title": "{{ set_[set]_title }}",
+ },
+ Theme.Text: {
+ "content": "{{ text_content }}",
+ "title": "{{ text_title }}",
+ },
+ }
+)
+
+// Compile-time theme embed directive.
+// Ensure this path corresponds with SampleThemeDir.
+//
+//go:embed themes/nettle/*
+var ThemeFile embed.FS
+
+// GetHTMLFilename returns the value of path with the base filename reset to
+// Theme.Index if path contains a conflicting or invalid filename. Otherwise,
+// returns a copy of path.
+func GetHTMLFilename(path string) string {
+ str := filepath.Join(filepath.Dir(path), Theme.Index)
+ if util.ReplaceExt(filepath.Base(path), "") != "" && filepath.Ext(path) ==
+ filepath.Ext(Theme.Index) {
+ str = path
+ }
+ return str
+}
+
+// CheckDup returns a renamed path if a file by the same name already
+// exists in the same srcDir and is not a caption text file. If the file path
+// does not already exist, returns the original path.
+func CheckDup(path, srcDir, ext string) string {
+ files := util.GetFileList(srcDir, "name", false)
+ name := util.ReplaceExt(filepath.Base(path), "")
+ num := strconv.Itoa(len(files) + 1)
+ newPath := filepath.Join(filepath.Dir(path), name+num+ext)
+ for _, file := range files {
+ if util.ReplaceExt(filepath.Base(file), "") == name &&
+ filepath.Base(file) != name+CaptionExt["txt"] {
+ return newPath
+ }
+ }
+ return path
+}
+
+// GenTheme outputs the theme files to the album directory at the
+// SampleThemeDir path.
+func GenTheme(exitOnErr bool) {
+ util.MakeDir(SampleThemeDir, exitOnErr)
+ files := []string{Theme.Index, Theme.Image, Theme.Set, Theme.Sets,
+ Theme.Text, Theme.AlbumCSS, Theme.LightboxCSS}
+ for _, f := range files {
+ content, _ := ThemeFile.ReadFile(filepath.Join(SampleThemeDir, f))
+ util.SaveFile(filepath.Join(SampleThemeDir, f), string(content),
+ exitOnErr)
+ }
+}
+
+// GetSetHTML returns an HTML string of image thumbnails for an image set.
+//
+// linkClass and thumbClass are CSS classes to assign to the links and
+// thumbnails respectively for styling.
+//
+// If imageType is "image", the HTML target links will link directly to
+// images instead of their corresponding HTML pages.
+//
+// If lightboxOn is true, the HTML target links will be set to anchors
+// that are image iframe ids.
+func GetSetHTML(srcPath, thumbPath, sortOrder, linkType, linkClass,
+ thumbClass string, lightboxOn, isTopLevel bool) string {
+ var htmlStr string
+ files := image.GetImageList(srcPath, sortOrder, false)
+ imgVars := map[string]string{
+ PartVar["linkClass"]: linkClass,
+ PartVar["thumbClass"]: thumbClass,
+ }
+ if linkType == "image" {
+ for _, file := range files {
+ // Adjust path for top-level page.
+ if isTopLevel {
+ imgVars[PartVar["link"]] = file
+ } else {
+ imgVars[PartVar["link"]] = filepath.Base(file)
+ }
+ imgVars[PartVar["thumb"]] = filepath.Join(thumbPath,
+ filepath.Base(file))
+ htmlStr += util.MultiReplace(PartHTML["img"], imgVars)
+ }
+ } else {
+ for _, file := range files {
+ if lightboxOn {
+ imgVars[PartVar["link"]] = "#" +
+ util.ReplaceExt(filepath.Base(file), "")
+ } else if !lightboxOn && isTopLevel {
+ imgVars[PartVar["link"]] = strings.Replace(file,
+ filepath.Ext(file), filepath.Ext(Theme.Index), 1)
+ } else {
+ imgVars[PartVar["link"]] = filepath.Base(
+ strings.Replace(file,
+ filepath.Ext(file), filepath.Ext(Theme.Index), 1))
+ }
+ imgVars[PartVar["thumb"]] = filepath.Join(thumbPath,
+ filepath.Base(file))
+ htmlStr += util.MultiReplace(PartHTML["img"], imgVars)
+ }
+ }
+ return strings.TrimRight(htmlStr, "\n")
+}
+
+// GetSetLBHTML returns an HTML string of image iframes and navigation links for // an image set.
+//
+// iframeClass is a CSS class to assign to the iframes for styling.
+func GetSetLBHTML(srcPath, sortOrder, iframeClass string,
+ isTopLevel bool) string {
+ var htmlStr string
+ files := image.GetImageList(srcPath, sortOrder, false)
+ lbVars := map[string]string{
+ PartVar["iframeClass"]: iframeClass,
+ }
+ for f := 0; f < len(files); f++ {
+ prev, next := "#", "#"
+ if len(files) > 1 {
+ if f == 0 {
+ next = "#" + util.ReplaceExt(filepath.Base(files[f+1]), "")
+ } else if f == (len(files) - 1) {
+ prev = "#" + util.ReplaceExt(filepath.Base(files[f-1]), "")
+ next = "#"
+ } else {
+ prev = "#" + util.ReplaceExt(filepath.Base(files[f-1]), "")
+ next = "#" + util.ReplaceExt(filepath.Base(files[f+1]), "")
+ }
+ }
+ lbVars[PartVar["navNext"]] = next
+ lbVars[PartVar["navPrev"]] = prev
+ lbVars[PartVar["id"]] = util.ReplaceExt(filepath.Base(files[f]), "")
+ if isTopLevel {
+ lbVars[PartVar["link"]] = strings.Replace(files[f],
+ filepath.Ext(files[f]), filepath.Ext(Theme.Index), 1)
+ } else {
+ lbVars[PartVar["link"]] = filepath.Base(strings.Replace(
+ files[f], filepath.Ext(files[f]), filepath.Ext(Theme.Index), 1))
+ }
+ htmlStr += util.MultiReplace(PartHTML["lightbox"], lbVars)
+ }
+ return strings.TrimRight(htmlStr, "\n")
+}
+
+// TxtToHTML returns an HTML string with supported markup syntax in string str
+// replaced with HTML tags.
+func TxtToHTML(str string) string {
+ htmlStr := html.EscapeString(str)
+ for _, r := range MarkupTxt {
+ for reg, repl := range r {
+ re, _ := regexp.Compile(reg)
+ htmlStr = string(re.ReplaceAll([]byte(htmlStr), []byte(repl)))
+ }
+ }
+ htmlStr = "
" + strings.TrimRight(htmlStr, "\n") + "
"
+ return htmlStr
+}
+
+// FilterHTML returns an HTML string with some characters escaped
+// (see html.EscapeString) and supported markup tags exempted.
+func FilterHTML(str string) string {
+ htmlStr := html.EscapeString(str)
+ htmlStr = strings.TrimRight(util.MultiReplace(htmlStr, MarkupHTML), "\n")
+ return htmlStr
+}
+
+// GetCaption returns an HTML string with the contents of an image caption for
+// string path, if the caption file exists. If the caption file does not exist,
+// returns an empty string.
+func GetCaption(path string) string {
+ for _, ext := range CaptionExt {
+ capPath := path
+ if strings.HasSuffix(path, filepath.Ext(path)) &&
+ filepath.Ext(path) != "" {
+ // Files.
+ capPath = strings.Replace(path, filepath.Ext(path), ext, 1)
+ } else {
+ // Directories.
+ capPath += ext
+ }
+ if util.FileExists(capPath) {
+ if ext == ".txt" {
+ return TxtToHTML(util.LoadFile(capPath))
+ } else if ext == ".html" {
+ return FilterHTML(util.LoadFile(capPath))
+ }
+ }
+ }
+ return ""
+}
+
+// Like GetCaption but for multiple files in a directory at string path,
+// GetCaptions a map with image files as keys and caption contents as values.
+//
+// If titleFallback is true, sets the caption contents to the image title
+// if no caption file is found for a given image.
+func GetCaptions(path string, titleFallback bool) map[string]string {
+ caps := make(map[string]string)
+ files := image.GetImageList(path, "name", false)
+ // Load caption file if available, fallback to deriving title from the
+ // filename if title fallback is enabled, or empty string if unknown.
+ for _, file := range files {
+ caps[file] = GetCaption(file)
+ if caps[file] == "" && titleFallback {
+ caps[file] = image.GetImageTitle(file)
+ }
+ }
+ return caps
+}
+
+// CopyCSS copies the theme CSS files from themePath to destDir.
+func CopyCSS(themePath, destDir string) {
+ util.MakeDir(destDir, true)
+ util.CopyFile(filepath.Join(themePath, Theme.LightboxCSS),
+ filepath.Join(destDir, Theme.LightboxCSS))
+ util.CopyFile(filepath.Join(themePath, Theme.AlbumCSS),
+ filepath.Join(destDir, Theme.AlbumCSS))
+}
+
+// PrepImages copies images in Set set to destDir, and creates a directory of
+// thumbnails at destDir.
+func PrepImages(destDir string, set Set) {
+ dsPath := filepath.FromSlash(destDir)
+ util.MakeDir(filepath.Join(dsPath, set.ImageDir))
+ image.CopyImages(set.ImageDir, filepath.Join(dsPath, set.ImageDir),
+ false)
+
+ util.MakeDir(filepath.Join(dsPath, set.ImageDir, set.ThumbDir))
+ image.ResizeImages(filepath.Join(dsPath, set.ImageDir),
+ filepath.Join(dsPath, set.ImageDir, set.ThumbDir),
+ set.ThumbWidth, set.ThumbHeight, set.ThumbPct, set.ThumbClip,
+ set.ThumbUnifyMode, false)
+}
+
+// GetPartial returns an HTML string of partial themeFile from theme themePath
+// with template variables partialVars inserted.
+func GetPartial(themePath, themeFile string,
+ partialVars map[string]string) string {
+ // Replace variables in partial template and trim end of file newlines.
+ partial := util.LoadFile(filepath.Join(filepath.FromSlash(themePath),
+ themeFile))
+ return strings.TrimRight(util.MultiReplace(partial, partialVars),
+ "\n")
+}
+
+// MakePage saves a page at destPath with the contents of themeFile from
+// themePath and the values from pageVars template variables inserted.
+func MakePage(themePath, themeFile, destPath string,
+ pageVars map[string]string) {
+ // Replace variables in index template.
+ dsPath := filepath.FromSlash(destPath)
+ util.MakeDir(filepath.Dir(dsPath))
+ index := util.LoadFile(filepath.Join(filepath.FromSlash(themePath),
+ themeFile))
+ page := util.MultiReplace(index, pageVars)
+ os.Remove(dsPath)
+ util.SaveFile(dsPath, page)
+}
+
+// MakeHTMLImagePages saves individual HTML pages for set images at destDir.
+func MakeHTMLImagePages(themePath, destDir string, sets []Set) {
+ for _, set := range sets {
+ util.MakeDir(filepath.Join(destDir, set.ImageDir))
+ caps := GetCaptions(set.ImageDir, true)
+ files := image.GetImageList(set.ImageDir, set.SortOrder, false)
+
+ PrepImages(destDir, set)
+ for f := 0; f < len(files); f++ {
+ nav := ""
+ // No nav HTML emitted in lightbox mode.
+ if !set.LightboxOn {
+ prev, next := "#", "#"
+ if len(files) > 1 {
+ if f == 0 {
+ next = util.ReplaceExt(filepath.Base(files[f+1]),
+ filepath.Ext(Theme.Index))
+ } else if f == (len(files) - 1) {
+ prev = util.ReplaceExt(filepath.Base(files[f-1]),
+ filepath.Ext(Theme.Index))
+ next = "#"
+ } else {
+ prev = util.ReplaceExt(filepath.Base(files[f-1]),
+ filepath.Ext(Theme.Index))
+ next = util.ReplaceExt(filepath.Base(files[f+1]),
+ filepath.Ext(Theme.Index))
+ }
+ }
+ navVars := map[string]string{
+ PartVar["navNext"]: next,
+ PartVar["navPrev"]: prev,
+ PartVar["navTop"]: set.IndexHTML,
+ }
+ nav = util.MultiReplace(PartVar["nav"], navVars)
+ }
+ partialVars := map[string]string{
+ ThemeVar[Theme.Image]["caption"]: caps[files[f]],
+ ThemeVar[Theme.Image]["path"]: filepath.Base(files[f]),
+ ThemeVar[Theme.Image]["title"]: image.GetImageTitle(files[f]),
+ ThemeVar[Theme.Image]["nav"]: nav,
+ }
+ partial := GetPartial(themePath, Theme.Image, partialVars)
+ pageVars := map[string]string{
+ ThemeVar[Theme.Index]["content"]: partial,
+ ThemeVar[Theme.Index]["relPath"]: "..",
+ ThemeVar[Theme.Index]["title"]: image.GetImageTitle(files[f]),
+ }
+ pagePath := filepath.Join(destDir, set.ImageDir,
+ strings.Replace(filepath.Base(files[f]),
+ filepath.Ext(files[f]), filepath.Ext(Theme.Index), 1))
+ MakePage(themePath, Theme.Index, pagePath, pageVars)
+ }
+ }
+}
+
+// MakeHTMLSetPages saves a page displaying each set, if enabled in the set's
+// properties, at destDir.
+func MakeHTMLSetPages(themePath, destDir string, sets []Set) {
+ tmPath := filepath.FromSlash(themePath)
+ dsPath := filepath.FromSlash(destDir)
+ setHTML := util.LoadFile(filepath.Join(tmPath, Theme.Set))
+ for _, set := range sets {
+ if set.IndexHTML != "" {
+ // Move images if not already present.
+ PrepImages(destDir, set)
+
+ // Replace variables in partial template.
+ setCont := GetSetHTML(set.ImageDir, set.ThumbDir, set.SortOrder,
+ set.LinkType, set.CSSClassLink, set.CSSClassThumb,
+ set.LightboxOn, false)
+ setDesc := GetCaption(filepath.Join(set.ImageDir, set.ImageDir))
+ setLB := ""
+ if set.LightboxOn {
+ setLB = GetSetLBHTML(set.ImageDir, set.SortOrder,
+ set.CSSClassIframe, false)
+ }
+ partialVars := map[string]string{
+ ThemeVar[Theme.Set]["content"]: setCont,
+ ThemeVar[Theme.Set]["desc"]: setDesc,
+ ThemeVar[Theme.Set]["lightbox"]: setLB,
+ ThemeVar[Theme.Set]["name"]: set.Name,
+ ThemeVar[Theme.Set]["title"]: set.Title,
+ }
+ pagePath := GetHTMLFilename(filepath.Join(dsPath, set.ImageDir,
+ set.IndexHTML))
+ pagePath = CheckDup(pagePath, set.ImageDir,
+ filepath.Ext(Theme.Index))
+
+ // Remove cached page.
+ os.Remove(pagePath)
+ setPage := strings.TrimRight(util.MultiReplace(setHTML,
+ partialVars), "\n")
+ // Replace variables in page template.
+ pageVars := map[string]string{
+ ThemeVar[Theme.Index]["content"]: setPage,
+ ThemeVar[Theme.Index]["relPath"]: "..",
+ ThemeVar[Theme.Index]["title"]: image.GetImageTitle(set.Title),
+ }
+ MakePage(themePath, Theme.Index, pagePath, pageVars)
+ }
+ }
+}
+
+// MakeHTMLIndexPage saves a top-level HTML page at destDir.
+func MakeHTMLIndexPage(themePath, destDir string, index map[string]string,
+ sets []Set) {
+ tmPath := filepath.FromSlash(themePath)
+ pagePath := GetHTMLFilename(filepath.Join(filepath.FromSlash(destDir),
+ index["html"]))
+ var cache []string
+
+ if index["type"] == "sets" {
+ for _, set := range sets {
+ setHTML := util.LoadFile(filepath.Join(tmPath, Theme.Sets))
+ setCont := GetSetHTML(set.ImageDir, filepath.Join(set.ImageDir,
+ set.ThumbDir), set.SortOrder, set.LinkType,
+ set.CSSClassLink, set.CSSClassThumb, set.LightboxOn, true)
+ setDesc := GetCaption(filepath.Join(set.ImageDir, set.ImageDir))
+ setLB := ""
+ if set.LightboxOn {
+ setLB = GetSetLBHTML(set.ImageDir, set.SortOrder,
+ set.CSSClassIframe, true)
+ }
+ contVar := strings.Replace(ThemeVar[Theme.Sets]["content"],
+ PartVar["set"], set.Name, 1)
+ descVar := strings.Replace(ThemeVar[Theme.Sets]["desc"],
+ PartVar["set"], set.Name, 1)
+ lbVar := strings.Replace(ThemeVar[Theme.Sets]["lightbox"],
+ PartVar["set"], set.Name, 1)
+ nameVar := strings.Replace(ThemeVar[Theme.Sets]["name"],
+ PartVar["set"], set.Name, 1)
+ titleVar := strings.Replace(ThemeVar[Theme.Sets]["title"],
+ PartVar["set"], set.Name, 1)
+ partialVars := map[string]string{
+ contVar: setCont,
+ descVar: setDesc,
+ lbVar: setLB,
+ nameVar: set.Name,
+ titleVar: set.Title,
+ }
+
+ // Remove cached page in the first pass.
+ // For subsequent sets on the same page, save file directly after
+ // replacing variables in the file already in the output directory.
+ if !slices.Contains(cache, pagePath) {
+ os.Remove(pagePath)
+ cache = append(cache, pagePath)
+ setHTML = strings.TrimRight(util.MultiReplace(setHTML,
+ partialVars), "\n")
+ pageVars := map[string]string{
+ ThemeVar[Theme.Index]["content"]: setHTML,
+ ThemeVar[Theme.Index]["relPath"]: ".",
+ ThemeVar[Theme.Index]["title"]: index["title"],
+ }
+ MakePage(themePath, Theme.Index, pagePath, pageVars)
+ } else {
+ setHTML = util.LoadFile(pagePath)
+ setHTML = strings.TrimRight(util.MultiReplace(setHTML, partialVars),
+ "\n")
+ util.SaveFile(pagePath, setHTML)
+ }
+ }
+ } else {
+ // Text type.
+ textHTML := util.LoadFile(filepath.Join(tmPath, Theme.Text))
+ content := GetCaption(GetHTMLFilename(index["html"]))
+ partialVars := map[string]string{
+ ThemeVar[Theme.Text]["content"]: content,
+ ThemeVar[Theme.Text]["title"]: index["title"],
+ }
+ partial := strings.TrimRight(util.MultiReplace(textHTML,
+ partialVars), "\n")
+ pageVars := map[string]string{
+ ThemeVar[Theme.Index]["content"]: partial,
+ ThemeVar[Theme.Index]["relPath"]: ".",
+ ThemeVar[Theme.Index]["title"]: index["title"],
+ }
+ MakePage(themePath, Theme.Index, pagePath, pageVars)
+ }
+}
+
+// MakeAlbum generates a directory of album files.
+func MakeAlbum(themePath, destDir string, index map[string]string, sets []Set) {
+ CopyCSS(themePath, destDir)
+ MakeHTMLImagePages(themePath, destDir, sets)
+ MakeHTMLSetPages(themePath, destDir, sets)
+ if index["html"] != "" {
+ MakeHTMLIndexPage(themePath, destDir, index, sets)
+ }
+}
diff --git a/template/themes/nettle/_image.html b/template/themes/nettle/_image.html
new file mode 100644
index 0000000..f9fdb75
--- /dev/null
+++ b/template/themes/nettle/_image.html
@@ -0,0 +1,9 @@
+
+
diff --git a/template/themes/nettle/_set.html b/template/themes/nettle/_set.html
new file mode 100644
index 0000000..b85072e
--- /dev/null
+++ b/template/themes/nettle/_set.html
@@ -0,0 +1,12 @@
+
+
+
+ {{ set_desc }}
+
+
+ {{ set }}
+
+
+ {{ set_lightbox }}
+
+
diff --git a/template/themes/nettle/_sets.html b/template/themes/nettle/_sets.html
new file mode 100644
index 0000000..b02784a
--- /dev/null
+++ b/template/themes/nettle/_sets.html
@@ -0,0 +1,25 @@
+
+
+
+ {{ set_set1_desc }}
+
+
+ {{ set_set1 }}
+
+
+ {{ set_set1_lightbox }}
+
+
+
+
+
+
+ {{ set_set2_desc }}
+
+
+ {{ set_set2 }}
+
+
+ {{ set_set2_lightbox }}
+
+
diff --git a/template/themes/nettle/_text.html b/template/themes/nettle/_text.html
new file mode 100644
index 0000000..9f19702
--- /dev/null
+++ b/template/themes/nettle/_text.html
@@ -0,0 +1,4 @@
+
+
diff --git a/template/themes/nettle/album.css b/template/themes/nettle/album.css
new file mode 100644
index 0000000..b7f86b0
--- /dev/null
+++ b/template/themes/nettle/album.css
@@ -0,0 +1,41 @@
+:root {
+ --thumb-width: 200px;
+ --thumbs-row: 4;
+
+ --desc-width: 50rem;
+ --font: Open Sans, Helvetica Neue, Helvetica, sans-serif;
+ --col-body-text: rgba(10, 50, 10, 1.0);
+ --col-set-title: rgba(15, 110, 10, 1.0);
+ --col-set-desc: rgba(10, 50, 10, 1.0);
+}
+
+body {
+ color: var(--col-body-text);
+ font-family: var(--font);
+ font-size: 1rem;
+}
+
+.set {
+ margin: 3rem auto 6rem;
+ text-align: center;
+}
+
+.set .title {
+ color: var(--col-set-title);
+ font-size: 2rem;
+ font-weight: bold;
+ margin: 1rem auto;
+ max-width: var(--desc-width);
+}
+.set .desc {
+ color: var(--col-set-desc);
+ margin: 1rem auto 3rem;
+ max-width: var(--desc-width);
+}
+
+.thumbs {
+ /* Set font size to hide newline spaces between thumbnails. */
+ font-size: 0;
+ margin: 0 auto;
+ max-width: calc(var(--thumb-width) * var(--thumbs-row));
+}
diff --git a/template/themes/nettle/index.html b/template/themes/nettle/index.html
new file mode 100644
index 0000000..f736f5a
--- /dev/null
+++ b/template/themes/nettle/index.html
@@ -0,0 +1,13 @@
+
+
+
+ {{ index_title }}
+
+
+
+
+
+ {{ index_content }}
+
+
+
diff --git a/template/themes/nettle/lightbox.css b/template/themes/nettle/lightbox.css
new file mode 100644
index 0000000..2af0e0c
--- /dev/null
+++ b/template/themes/nettle/lightbox.css
@@ -0,0 +1,103 @@
+:root {
+ --nav-close: 3rem;
+ --nav-width: 10rem;
+ --v-padding: 4rem;
+
+ --col-caption: rgba(15, 110, 10, 1);
+ --col-link: rgba(15, 110, 10, 1.0);
+ --col-link-visited: rgba(20, 145, 10, 1.0);
+ --col-overlay: rgba(255, 255, 255, 0.95);
+}
+
+body {
+ overflow-x: hidden;
+}
+
+.lightbox .frame {
+ background: var(--col-overlay);
+ border: 0;
+ display: none;
+ height: 100%;
+ left: 0;
+ opacity: 0;
+ overflow-x: hidden;
+ padding: var(--v-padding) 0;
+ position: fixed;
+ top: 0;
+ transition: opacity 0.5s ease;
+ width: 100%;
+ z-index: 5;
+}
+.lightbox .frame:target {
+ display: block;
+ opacity: 1;
+}
+
+/* Navigation */
+.lightbox .nav {
+ display: none;
+ font-size: 1.5rem;
+ left: calc(50% - (var(--nav-width) / 2));
+ max-width: var(--nav-width);
+ position: fixed;
+ top: calc(var(--v-padding) / 2);
+}
+.lightbox .frame:target + .nav {
+ display: block;
+ z-index: 10;
+}
+/* Disable the prev button for the first item,
+and the next button for the last item */
+.lightbox .nav:nth-child(2) .prev,
+.lightbox .nav:last-child .next {
+ opacity: 0;
+ z-index: -1;
+}
+
+.lightbox .prev,
+.lightbox .close,
+.lightbox .next {
+ color: var(--col-link);
+ position: relative;
+ text-decoration: none;
+}
+.lightbox .close {
+ left: var(--nav-close);
+}
+.lightbox .next {
+ left: calc(var(--nav-close) * 2);
+}
+.lightbox .prev:visited,
+.lightbox .close:visited,
+.lightbox .next:visited {
+ color: var(--col-link-visited);
+}
+
+/* iframe page contents */
+.image-nav, .image-fig {
+ text-align: center;
+}
+.image-fig .img {
+ max-width: 90%;
+ max-height: 60vh;
+}
+.image-fig .caption {
+ color: var(--col-caption);
+ margin: 0 auto;
+ max-width: 60%;
+}
+.image-fig .caption a {
+ color: var(--col-link);
+}
+.image-fig .caption a:visited {
+ color: var(--col-link-visited);
+}
+.image-fig .caption h1 {
+ font-size: 1.4rem;
+}
+.image-fig .caption h2 {
+ font-size: 1.2rem;
+}
+.image-fig .caption h3 {
+ font-size: 1rem;
+}
diff --git a/util/util.go b/util/util.go
new file mode 100644
index 0000000..fe2d1c1
--- /dev/null
+++ b/util/util.go
@@ -0,0 +1,233 @@
+// Package util includes various helper functions for common
+// string manipulation and file handling operations.
+package util
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+)
+
+var (
+ // Error messages.
+ err = struct {
+ dirNotCreated string
+ dirNotOpened string
+ dirNotRead string
+ fileNotCopied string
+ fileNotCreated string
+ fileNotLoaded string
+ }{
+ dirNotCreated: "Error: directory could not be created:",
+ dirNotOpened: "Error: directory could not be opened:",
+ dirNotRead: "Error: directory not readable:",
+ fileNotCopied: "Error: file could not be copied:",
+ fileNotCreated: "Error: file could not be created.",
+ fileNotLoaded: "Error: file could not be loaded:",
+ }
+)
+
+// HasError returns true if error e is a non-empty value and prints
+// string msg to standard output, or false otherwise. If exitOnErr is
+// true, sends exit signal code 3 instead of a returning a boolean value.
+func HasError(e error, msg string, exitOnErr ...bool) bool {
+ if e != nil {
+ fmt.Println(msg)
+ if len(exitOnErr) == 1 {
+ if exitOnErr[0] {
+ os.Exit(3)
+ }
+ }
+ return true
+ } else {
+ return false
+ }
+}
+
+// FileExists returns true a file or directory at path exists, or
+// false otherwise.
+func FileExists(path string) bool {
+ if _, fileErr := os.Stat(path); fileErr == nil {
+ return true
+ } else {
+ return false
+ }
+}
+
+// MinMax returns the smaller and larger value of two numbers,
+// in that order.
+func MinMax(n1, n2 int) (int, int) {
+ if n1 < n2 {
+ return n1, n2
+ } else {
+ return n2, n1
+ }
+}
+
+// MultiReplace returns a copy of string str after replacing values
+// from map of key-value string pairs rep.
+func MultiReplace(str string, rep map[string]string) string {
+ s := str
+ for k, v := range rep {
+ s = strings.ReplaceAll(s, k, v)
+ }
+ return s
+}
+
+// MutliOrdReplace returns a copy of string str after replacing values
+// from rep, a slice of maps containing key-value string pairs. Like
+// MultiReplace for multiple maps.
+func MultiOrdReplace(str string, rep []map[string]string) string {
+ s := str
+ for _, e := range rep {
+ for k, v := range e {
+ s = strings.ReplaceAll(s, k, v)
+ }
+ }
+ return s
+}
+
+// ReplaceExt returns a copy of the string path with its file extension
+// replaced with string ext. If path has no extension, returns a copy of path
+// with ext appended to it.
+func ReplaceExt(path, ext string) string {
+ if filepath.Ext(path) != "" {
+ return strings.Replace(path, filepath.Ext(path), ext, 1)
+ }
+ return path + ext
+}
+
+// ConvertPath returns a copy of string path with Unix shorthand locations
+// "~/" and "$HOME" expanded to the full absolute path.
+func ConvertPath(path string) string {
+ home, homeErr := os.UserHomeDir()
+ if homeErr == nil {
+ if strings.HasPrefix(path, "~/") {
+ return strings.Replace(path, "~/", home+string(os.PathSeparator), 1)
+ } else if strings.HasPrefix(path, "~"+filepath.Base(home)) {
+ return strings.Replace(path, "~"+filepath.Base(home), home, 1)
+ } else if strings.HasPrefix(path, "$HOME") {
+ return strings.Replace(path, "$HOME", home, 1)
+ }
+ }
+ return path
+}
+
+// GetFileList returns a string slice containing a list of files at path.
+//
+// sortOrder options: modTime, modTimeDesc, name, nameDesc
+//
+// If includeDirs is true, include directory names in the list.
+func GetFileList(path string, sortOrder string, includeDirs bool) []string {
+ dir, openErr := os.Open(filepath.FromSlash(path))
+ HasError(openErr, err.dirNotOpened+" "+path)
+ files, readErr := dir.ReadDir(0)
+ HasError(readErr, err.dirNotRead+" "+path)
+
+ var list []string
+ if strings.Contains(sortOrder, "modTime") {
+ mapTimes := make(map[string]string)
+ var times []string
+ for _, file := range files {
+ if !file.IsDir() || (file.IsDir() && includeDirs) {
+ fi, _ := file.Info()
+ mapTimes[fmt.Sprint(fi.ModTime())] = filepath.Join(path,
+ fi.Name())
+ times = append(times, fmt.Sprint(fi.ModTime()))
+ }
+ }
+ slices.Sort(times)
+ for _, t := range times {
+ list = append(list, mapTimes[t])
+ }
+ } else if strings.Contains(sortOrder, "name") {
+ for _, file := range files {
+ if !file.IsDir() || (file.IsDir() && includeDirs) {
+ list = append(list, filepath.Join(path, file.Name()))
+ }
+ }
+ slices.Sort(list)
+ }
+
+ if strings.Contains(sortOrder, "Desc") {
+ slices.Reverse(list)
+ }
+ return list
+}
+
+// MakeDir returns true if a new directory is created successfully or already
+// exists at path, or false otherwise. If exitOnErr is true, sends an
+// application exit signal if an error occurs.
+func MakeDir(path string, exitOnErr ...bool) bool {
+ toExit := false
+ if len(exitOnErr) == 1 {
+ if exitOnErr[0] == true {
+ toExit = true
+ }
+ }
+ if !HasError(os.MkdirAll(filepath.FromSlash(path), 0755), err.dirNotCreated+
+ " "+path, toExit) {
+ return true
+ } else {
+ return false
+ }
+}
+
+// CopyFile returns true if a file is copied successfully from srcPath to
+// destPath, or false otherwise.
+func CopyFile(srcPath, destPath string) bool {
+ src, srcErr := os.Open(srcPath)
+ HasError(srcErr, err.fileNotLoaded+" "+srcPath)
+ defer src.Close()
+
+ dest, destErr := os.Create(destPath)
+ HasError(destErr, err.fileNotCreated)
+ defer dest.Close()
+
+ _, copyErr := io.Copy(dest, src)
+ return HasError(copyErr, err.fileNotCopied+" "+destPath)
+}
+
+// CopyFiles copies files in srcPath to destPath. If overwrite is true,
+// copying overwrites files of the same name at destPath.
+func CopyFiles(srcPath string, destPath string, overwrite bool) {
+ files := GetFileList(srcPath, "name", false)
+ for _, file := range files {
+ destFile := filepath.Join(destPath, filepath.Base(file))
+ if !FileExists(destFile) || (FileExists(destFile) && overwrite) {
+ CopyFile(file, destFile)
+ }
+ }
+}
+
+// SaveFile writes the string contents of str to file at path. If exitOnErr is
+// true, sends an application exit signal if an error occurs.
+func SaveFile(path string, str string, exitOnErr ...bool) {
+ fh, fileErr := os.Create(filepath.FromSlash(path))
+ toExit := false
+ if len(exitOnErr) == 1 {
+ if exitOnErr[0] == true {
+ toExit = true
+ }
+ }
+ if !HasError(fileErr, err.fileNotCreated, toExit) {
+ fmt.Fprint(fh, str)
+ }
+}
+
+// LoadFile returns the contents of path as string. If exitOnErr is true, sends
+// an application exit signal if an error occurs.
+func LoadFile(path string, exitOnErr ...bool) string {
+ contents, fileErr := os.ReadFile(filepath.FromSlash(path))
+ toExit := false
+ if len(exitOnErr) == 1 {
+ if exitOnErr[0] == true {
+ toExit = true
+ }
+ }
+ HasError(fileErr, err.fileNotLoaded+" "+path, toExit)
+ return string(contents)
+}