// 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>": "
",
"<del>": "", "</p>": "
", "<strong>": "", "</strong>": "", "<s>": ""},
{" \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, 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) } }