// 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) } }