// Package templates exposes the embedded HTML shell, shared CSS/JS bundles,
// and per-theme assets used by the generator. Themes live as directories
// under templates/themes// containing theme.css, theme.js, meta.json,
// and sounds.json.
// All assets are compiled into the binary via //go:embed so snonux still ships
// as a single self-contained executable.
package templates
import (
"embed"
"fmt"
"io/fs"
"path"
"sort"
)
// Per-theme directory convention (under templates/themes//):
//
// theme.css — required, always present
// theme.js — required, always present
// meta.json — required, always present
// sounds.json — required, always present
// ambient.ogg — optional, background music loop
// .woff2 — optional, one or more self-hosted web fonts
// .woff — optional, fallback / non-woff2 web fonts
// FONT_LICENSE.txt — required iff any font file is present;
// contains attribution + license + source URL
//
// All fonts are served from the same origin as the site (no third-party
// CDNs). The four required files above have dedicated accessors; every
// other file in a theme directory is shipped verbatim by ThemeExtraFiles
// and writeThemeAsset.
//
// NOTE on the embed glob below: //go:embed requires every pattern to
// match at least one file. When you introduce a new font extension
// that no other theme uses yet, append the matching glob here.
//
//go:embed shell.tmpl shared/*.tmpl shared/shared.css shared/shared.js themes/*/theme.css themes/*/theme.js themes/*/meta.json themes/*/sounds.json themes/*/*.ogg themes/*/*.woff themes/*/*.woff2 themes/*/FONT_LICENSE.txt themes/*/MUSIC_LICENSE.txt
var FS embed.FS
// themeStandardFiles lists the per-theme files that have dedicated
// accessor functions and that writeThemeAsset writes by name.
// Any other file inside a theme directory (fonts, license notices, …)
// is treated as an "extra" asset and copied verbatim by ThemeExtraFiles.
var themeStandardFiles = map[string]struct{}{
"theme.css": {},
"theme.js": {},
"meta.json": {},
"sounds.json": {},
}
// ThemeExtraFile represents a per-theme asset (e.g. a .woff font or a
// FONT_LICENSE.txt) that is not one of the four standard files but should
// still be copied verbatim into dist/themes//.
type ThemeExtraFile struct {
// Name is the basename of the file inside the theme directory.
Name string
// Data is the raw file content.
Data []byte
}
// ThemeExtraFiles returns every file under templates/themes// that
// is NOT one of the four standard files (theme.css, theme.js, meta.json,
// sounds.json). The returned slice is sorted by name for deterministic
// output.
func ThemeExtraFiles(name string) ([]ThemeExtraFile, error) {
dir := path.Join("themes", name)
entries, err := fs.ReadDir(FS, dir)
if err != nil {
return nil, fmt.Errorf("list theme dir %q: %w", name, err)
}
out := make([]ThemeExtraFile, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
continue
}
if _, ok := themeStandardFiles[e.Name()]; ok {
continue
}
b, err := FS.ReadFile(path.Join(dir, e.Name()))
if err != nil {
return nil, fmt.Errorf("read theme extra %q/%q: %w", name, e.Name(), err)
}
out = append(out, ThemeExtraFile{Name: e.Name(), Data: b})
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
}
// Shell returns the body of shell.tmpl — the single page template used for
// every generated HTML page. Theme-specific markup is injected at gen time
// from the default theme's meta.json (and at runtime by shared.js for any
// other selected theme).
func Shell() (string, error) {
b, err := FS.ReadFile("shell.tmpl")
if err != nil {
return "", fmt.Errorf("read shell.tmpl: %w", err)
}
return string(b), nil
}
// Shared reads a named shared sub-template (currently only "nav").
func Shared(name string) (string, error) {
b, err := FS.ReadFile(path.Join("shared", name+".tmpl"))
if err != nil {
return "", fmt.Errorf("read shared template %q: %w", name, err)
}
return string(b), nil
}
// SharedCSS returns the bundled CSS that every page links via shared.css.
// Used by the generator to write dist/shared.css.
func SharedCSS() ([]byte, error) {
return FS.ReadFile("shared/shared.css")
}
// SharedJS returns the bundled JS that every page references via shared.js.
// Used by the generator to write dist/shared.js.
func SharedJS() ([]byte, error) {
return FS.ReadFile("shared/shared.js")
}
// ThemeCSS returns the per-theme stylesheet bytes for the named theme.
func ThemeCSS(name string) ([]byte, error) {
return FS.ReadFile(path.Join("themes", name, "theme.css"))
}
// ThemeJS returns the per-theme script bytes for the named theme.
func ThemeJS(name string) ([]byte, error) {
return FS.ReadFile(path.Join("themes", name, "theme.js"))
}
// ThemeMeta returns the per-theme meta.json bytes for the named theme.
func ThemeMeta(name string) ([]byte, error) {
return FS.ReadFile(path.Join("themes", name, "meta.json"))
}
// ThemeSounds returns the per-theme sounds.json bytes for the named theme.
func ThemeSounds(name string) ([]byte, error) {
return FS.ReadFile(path.Join("themes", name, "sounds.json"))
}
// ThemeNames returns a sorted list of available theme names derived from the
// directories present under templates/themes/.
func ThemeNames() ([]string, error) {
entries, err := fs.ReadDir(FS, "themes")
if err != nil {
return nil, fmt.Errorf("list theme dir: %w", err)
}
names := make([]string, 0, len(entries))
for _, e := range entries {
if !e.IsDir() {
continue
}
names = append(names, e.Name())
}
sort.Strings(names)
return names, nil
}