lamium/template/template.go

659 lines
21 KiB
Go
Raw Normal View History

2024-06-29 20:38:42 +00:00
// 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{
"&lt;b&gt;": "<b>",
"&lt;/b&gt;": "</b>",
"&lt;blockquote&gt;": "<blockquote>",
"&lt;/blockquote&gt;": "</blockquote>",
"&lt;br&gt;": "<br>",
"&lt;code&gt;": "<code>",
"&lt;/code&gt;": "</code>",
"&lt;del&gt;": "<del>",
"&lt;/del&gt;": "</del>",
"&lt;dd&gt;": "<dd>",
"&lt;/dd&gt;": "</dd>",
"&lt;dl&gt;": "<dl>",
"&lt;/dl&gt;": "</dl>",
"&lt;dt&gt;": "<dt>",
"&lt;/dt&gt;": "</dt>",
"&lt;em&gt;": "<em>",
"&lt;/em&gt;": "</em>",
"&lt;h1&gt;": "<h1>",
"&lt;/h1&gt;": "</h1>",
"&lt;h2&gt;": "<h2>",
"&lt;/h2&gt;": "</h2>",
"&lt;h3&gt;": "<h3>",
"&lt;/h3&gt;": "</h3>",
"&lt;i&gt;": "<i>",
"&lt;/i&gt;": "</i>",
"&lt;ol&gt;": "<ol>",
"&lt;/ol&gt;": "</ol>",
"&lt;p&gt;": "<p>",
"&lt;/p&gt;": "</p>",
"&lt;strong&gt;": "<strong>",
"&lt;/strong&gt;": "</strong>",
"&lt;s&gt;": "<s>",
"&lt;/s&gt;": "</s>",
"&lt;u&gt;": "<u>",
"&lt;/u&gt;": "</u>",
"&lt;ul&gt;": "<ul>",
"&lt;/ul&gt;": "</ul>",
}
// Supported markup syntax regexp for text files and the HTML tag
// translations.
MarkupTxt = []map[string]string{
{`^\*\*`: "<strong>"},
{`\*\*$`: "</strong>"},
{`\*\*\n`: "</strong>\n"},
{`\t\*\*`: "\t<strong>"},
{`\*\*\t`: "</strong>\t"},
{`[[:space:]]\*\*`: " <strong>"},
{`\*\*[[:space:]]`: "</strong> "},
{`^\*`: "<em>"},
{`\*$`: "</em>"},
{`\*\n`: "</em>\n"},
{`\t\*`: "\t<em>"},
{`\*\t`: "</em>\t"},
{`[[:space:]]\*`: " <em>"},
{`\*[[:space:]]`: "</em> "},
{`^__`: "<u>"},
{`__$`: "</u>"},
{`__\n`: "</u>\n"},
{`\t__`: "\t<u>"},
{`__\t`: "</u>\t"},
{`[[:space:]]__`: " <u>"},
{`__[[:space:]]`: "</u> "},
{`^\~\~`: "<s>"},
{`\~\~$`: "</s>"},
{`\~\~\n`: "</s>\n"},
{`\t\~\~`: "\t<s>"},
{`\~\~\t`: "</s>\t"},
{`[[:space:]]\~\~`: " <s>"},
{`\~\~[[:space:]]`: "</s> "},
{"\n\n": "</p>\n<p>"},
{" \n": "<br>"},
}
// 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": `<a class="` + PartVar["linkClass"] + `" href="` +
PartVar["link"] + `"><img class="` + PartVar["thumbClass"] +
`" src="` + PartVar["thumb"] + `"></a>` + "\n",
"lightbox": `<iframe class="` + PartVar["iframeClass"] + `" id="` +
PartVar["id"] + `" src="` + PartVar["link"] + `"></iframe>
<nav class="nav">
<a class="prev" href="` + PartVar["navPrev"] + `">&larr;</a>
<a class="close" href="#">&#9747;</a>
<a class="next" href="` + PartVar["navNext"] + `">&rarr;</a>` +
`</nav>` + "\n",
"nav": `<a class="link" href="` + PartVar["navPrev"] + `">&larr;</a>
<a class="link" href="` + PartVar["navTop"] + `">&uarr;</a>
<a class="link" href="` + PartVar["navNext"] + `">&rarr;</a>`,
}
// 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 = "<p>" + strings.TrimRight(htmlStr, "\n") + "</p>"
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, ext) {
// 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 {
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)
}
}