diff options
Diffstat (limited to 'internal/generator')
77 files changed, 5457 insertions, 5841 deletions
diff --git a/internal/generator/doc.go b/internal/generator/doc.go index d7d4a53..be82bdc 100644 --- a/internal/generator/doc.go +++ b/internal/generator/doc.go @@ -4,21 +4,26 @@ // Responsibilities by area (file → role): // // - generator.go — Orchestration: load posts from disk, sort newest-first, -// paginate, parse theme+nav templates, write index.html / pageN.html, -// then call atom.Generate. -// - themes.go — Theme registry (name → template string) and getTheme / -// ListThemes for the CLI. -// - theme_*.go — One file per visual theme: full-page HTML that invokes -// {{template "navSharedCSSInner"}} inside <style>, then {{template "splashGate"}}, -// {{template "navhints" .}}, {{template "navmodal" .}}, -// {{template "navscript" .}}. -// - shared.go — navDefs: shared {{define}} blocks merged at parse time with -// the chosen theme so a single html/template parse sees every name. -// - theme_sounds.go — Per-theme Web Audio parameters (splash arpeggio, nav blip, -// modal open/close); embedded in pages as ThemeSoundsJSON for navscript. -// - templates.go — Short pointer: where templates and registry live. +// paginate, parse shell+nav templates, write index.html / pageN.html plus +// shared.css/shared.js and per-theme assets, then call atom.Generate. +// - themes.go — ListThemes / validThemeName helpers backed by the embedded FS. +// - shared.go — navDefs: shared {{define}} blocks (splashGate, navhints, +// navmodal) merged at parse time with shell.tmpl so a single html/template +// parse sees every name. +// - theme_sounds.go — Per-theme Web Audio parameters; one file is written per +// theme to dist/themes/<name>/sounds.json and the default theme's preset is +// also baked into shell.tmpl as window.SNONUX_SOUNDS so the splash chime +// can fire instantly. +// - favicon.go — Generates the 32×32 .ico binary written into the output dir. +// - templates.go — Short pointer: where templates and helpers live. // -// Dependency direction: themes and shared nav templates are composed only for +// Theme assets live as separate files under templates/themes/<name>/theme.css, +// theme.js, and meta.json. The single shell.tmpl loads the active theme's CSS +// synchronously in <head> (chosen from localStorage by a boot script) and +// shared.js orchestrates the rest. Switching themes is a localStorage write +// followed by location.reload(). +// +// Dependency direction: shell and shared nav templates are composed only for // the HTML path (generator.go). Package atom depends on config and post only, // not on themes or html/template, so feed logic stays isolated from page chrome. package generator diff --git a/internal/generator/favicon.go b/internal/generator/favicon.go index a4a8251..8664183 100644 --- a/internal/generator/favicon.go +++ b/internal/generator/favicon.go @@ -6,35 +6,12 @@ import ( "fmt" "image" "image/color" - "log" "os" "path/filepath" - "strings" - - "codeberg.org/snonux/snonux/internal/generator/templates" ) -// faviconHeadHTML is the <link rel="icon"> fragment injected into every theme's -// <head>. Loaded at startup from the embedded templates FS so no HTML lives in -// the Go sources. -var faviconHeadHTML = loadFaviconHeadHTML() - -func loadFaviconHeadHTML() string { - s, err := templates.Shared("favicon_head") - if err != nil { - log.Printf("warning: could not load favicon_head template: %v", err) - return "" - } - return s -} - -func injectSharedHead(theme string) string { - if strings.Contains(theme, `rel="icon"`) { - return theme - } - - return strings.Replace(theme, "</head>", faviconHeadHTML+"</head>", 1) -} +// shell.tmpl now embeds the favicon <link> directly, so no head-injection +// helper is needed; favicon.go only generates and writes the .ico binary. func writeFavicon(outputDir string) error { data, err := generateFaviconICO() diff --git a/internal/generator/generator.go b/internal/generator/generator.go index dc1f236..079cc38 100644 --- a/internal/generator/generator.go +++ b/internal/generator/generator.go @@ -12,17 +12,29 @@ import ( "codeberg.org/snonux/snonux/internal/config" "codeberg.org/snonux/snonux/internal/generator/atom" + "codeberg.org/snonux/snonux/internal/generator/templates" "codeberg.org/snonux/snonux/internal/post" ) // pageData holds the template variables for a single HTML page. +// Theme-specific values come from the default theme's meta.json — they are +// what the user sees on first paint. shared.js may swap them out at runtime +// when a non-default theme is saved in localStorage. type pageData struct { - Posts []postView - PrevPage string // URL of the newer page, empty if none - NextPage string // URL of the older page, empty if none - PrevPageJSON template.JS - NextPageJSON template.JS - ThemeSoundsJSON template.JS // Web Audio preset for this theme (splash + nav) + Posts []postView + PrevPage string // URL of the newer page, empty if none + NextPage string // URL of the older page, empty if none + PrevPageJSON template.JS + NextPageJSON template.JS + + DefaultTheme string // baked into the shell as SNONUX_DEFAULT_THEME + AllThemesJSON template.JS // [...] of all available theme names, JS literal + DefaultTitle string // <title> contents for the default theme + DefaultHeaderHTML template.HTML // <header> innerHTML for the default theme + DefaultSplashHTML template.HTML // #splash-overlay innerHTML for the default theme + DefaultPrevText template.HTML // pagination prev anchor text (theme-styled) + DefaultNextText template.HTML // pagination next anchor text + DefaultSoundsJSON template.JS // default theme's Web Audio preset (literal) } // postView is a render-friendly representation of a post for the HTML template. @@ -32,23 +44,62 @@ type postView struct { ContentHTML template.HTML // pre-rendered; trusted — generated by this tool } -// Run loads all posts, generates all HTML pages, and writes atom.xml. +// themeMeta mirrors the JSON structure written by the migration tool and used +// at runtime by shared.js to swap theme markup in. +type themeMeta struct { + Title string `json:"title"` + HeaderHTML string `json:"header_html"` + SplashInnerHTML string `json:"splash_inner_html"` + PrevPageText string `json:"prev_page_text"` + NextPageText string `json:"next_page_text"` +} + +func loadThemeMeta(name string) (themeMeta, error) { + var m themeMeta + b, err := templates.ThemeMeta(name) + if err != nil { + return m, fmt.Errorf("read theme meta %q: %w", name, err) + } + if err := json.Unmarshal(b, &m); err != nil { + return m, fmt.Errorf("parse theme meta %q: %w", name, err) + } + return m, nil +} + +// allThemesJSON returns a JS array literal of all theme names. +func allThemesJSON() (template.JS, error) { + names, err := templates.ThemeNames() + if err != nil { + return "", err + } + b, err := json.Marshal(names) + if err != nil { + return "", err + } + return template.JS(b), nil //nolint:gosec // marshalled from a fixed string slice +} + +// Run loads all posts, generates all HTML pages, and writes atom.xml plus the +// shared CSS/JS bundles and per-theme asset files. func Run(cfg *config.Config) error { posts, err := loadAllPosts(cfg.OutputDir) if err != nil { return err } - // Sort newest-first so page 1 (index.html) has the latest content. sort.Slice(posts, func(i, j int) bool { return posts[i].Timestamp.After(posts[j].Timestamp) }) pages := paginate(posts, config.PostsPerPage) - // Combine the theme HTML (which uses {{template "navhints"}} etc.) with the - // shared navDefs sub-templates so a single parse call resolves all references. - combined := injectSharedHead(getTheme(cfg.Theme)) + "\n" + navDefs + // Combine shell.tmpl with nav.tmpl partials so a single parse call resolves + // references to splashGate, navhints, navmodal. + shellSrc, err := templates.Shell() + if err != nil { + return fmt.Errorf("load shell template: %w", err) + } + combined := shellSrc + "\n" + navDefs tmpl, err := template.New("page").Parse(combined) if err != nil { return fmt.Errorf("parse page template: %w", err) @@ -57,9 +108,25 @@ func Run(cfg *config.Config) error { if err := writeFavicon(cfg.OutputDir); err != nil { return err } + if err := writeSharedAssets(cfg.OutputDir); err != nil { + return err + } + if err := writeAllThemeAssets(cfg.OutputDir); err != nil { + return err + } + + defaultTheme := validThemeName(cfg.Theme) + defaultMeta, err := loadThemeMeta(defaultTheme) + if err != nil { + return err + } + all, err := allThemesJSON() + if err != nil { + return err + } for i, page := range pages { - if err := writePage(tmpl, page, i, len(pages), cfg); err != nil { + if err := writePage(tmpl, page, i, len(pages), cfg, defaultTheme, defaultMeta, all); err != nil { return err } } @@ -126,8 +193,8 @@ func pageFilename(index int) string { const indexPageNavURL = "index.html?splash=0" // writePage renders one HTML page and writes it to cfg.OutputDir. -func writePage(tmpl *template.Template, posts []*post.Post, pageIndex, totalPages int, cfg *config.Config) error { - data := buildPageData(posts, pageIndex, totalPages, cfg.Theme) +func writePage(tmpl *template.Template, posts []*post.Post, pageIndex, totalPages int, cfg *config.Config, defaultTheme string, defaultMeta themeMeta, all template.JS) error { + data := buildPageData(posts, pageIndex, totalPages, defaultTheme, defaultMeta, all) filename := pageFilename(pageIndex) path := filepath.Join(cfg.OutputDir, filename) @@ -146,7 +213,7 @@ func writePage(tmpl *template.Template, posts []*post.Post, pageIndex, totalPage } // buildPageData constructs the template data for a single page. -func buildPageData(posts []*post.Post, pageIndex, totalPages int, theme string) pageData { +func buildPageData(posts []*post.Post, pageIndex, totalPages int, defaultTheme string, meta themeMeta, all template.JS) pageData { views := make([]postView, len(posts)) for i, p := range posts { views[i] = postView{ @@ -173,12 +240,19 @@ func buildPageData(posts []*post.Post, pageIndex, totalPages int, theme string) } return pageData{ - Posts: views, - PrevPage: prevPage, - NextPage: nextPage, - PrevPageJSON: jsonStringOrNull(prevPage), - NextPageJSON: jsonStringOrNull(nextPage), - ThemeSoundsJSON: themeSoundsJSON(theme), + Posts: views, + PrevPage: prevPage, + NextPage: nextPage, + PrevPageJSON: jsonStringOrNull(prevPage), + NextPageJSON: jsonStringOrNull(nextPage), + DefaultTheme: defaultTheme, + AllThemesJSON: all, + DefaultTitle: meta.Title, + DefaultHeaderHTML: template.HTML(meta.HeaderHTML), //nolint:gosec // source is in-tree theme metadata + DefaultSplashHTML: template.HTML(meta.SplashInnerHTML), //nolint:gosec // same + DefaultPrevText: template.HTML(meta.PrevPageText), //nolint:gosec // same + DefaultNextText: template.HTML(meta.NextPageText), //nolint:gosec // same + DefaultSoundsJSON: themeSoundsJSON(defaultTheme), } } @@ -202,3 +276,85 @@ func jsonStringOrNull(s string) template.JS { return template.JS(strings.TrimSpace(string(b))) //nolint:gosec // filename is tool-generated } + +// writeSharedAssets dumps shared.css and shared.js to the output dir. They are +// linked from every page and cached by browsers across navigations. +func writeSharedAssets(outputDir string) error { + css, err := templates.SharedCSS() + if err != nil { + return fmt.Errorf("read shared.css: %w", err) + } + if err := os.WriteFile(filepath.Join(outputDir, "shared.css"), css, 0o644); err != nil { + return fmt.Errorf("write shared.css: %w", err) + } + + js, err := templates.SharedJS() + if err != nil { + return fmt.Errorf("read shared.js: %w", err) + } + if err := os.WriteFile(filepath.Join(outputDir, "shared.js"), js, 0o644); err != nil { + return fmt.Errorf("write shared.js: %w", err) + } + + return nil +} + +// writeAllThemeAssets copies each theme's CSS, JS, meta.json, and writes its +// sounds.json to dist/themes/<name>/. shared.js fetches these on theme switch. +func writeAllThemeAssets(outputDir string) error { + names, err := templates.ThemeNames() + if err != nil { + return err + } + + root := filepath.Join(outputDir, "themes") + if err := os.MkdirAll(root, 0o755); err != nil { + return fmt.Errorf("create themes dir: %w", err) + } + + for _, name := range names { + dir := filepath.Join(root, name) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create theme dir %q: %w", name, err) + } + if err := writeThemeAsset(dir, name); err != nil { + return err + } + } + + return nil +} + +// writeThemeAsset copies the four per-theme files into dir. +func writeThemeAsset(dir, name string) error { + css, err := templates.ThemeCSS(name) + if err != nil { + return fmt.Errorf("read %s/theme.css: %w", name, err) + } + if err := os.WriteFile(filepath.Join(dir, "theme.css"), css, 0o644); err != nil { + return fmt.Errorf("write %s/theme.css: %w", name, err) + } + + js, err := templates.ThemeJS(name) + if err != nil { + return fmt.Errorf("read %s/theme.js: %w", name, err) + } + if err := os.WriteFile(filepath.Join(dir, "theme.js"), js, 0o644); err != nil { + return fmt.Errorf("write %s/theme.js: %w", name, err) + } + + meta, err := templates.ThemeMeta(name) + if err != nil { + return fmt.Errorf("read %s/meta.json: %w", name, err) + } + if err := os.WriteFile(filepath.Join(dir, "meta.json"), meta, 0o644); err != nil { + return fmt.Errorf("write %s/meta.json: %w", name, err) + } + + soundsJSON := themeSoundsJSON(name) + if err := os.WriteFile(filepath.Join(dir, "sounds.json"), []byte(soundsJSON), 0o644); err != nil { + return fmt.Errorf("write %s/sounds.json: %w", name, err) + } + + return nil +} diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go index 7b0009c..d477275 100644 --- a/internal/generator/generator_test.go +++ b/internal/generator/generator_test.go @@ -179,10 +179,19 @@ func TestBuildPageData_navLinks(t *testing.T) { }, } + meta, err := loadThemeMeta("neon") + if err != nil { + t.Fatalf("loadThemeMeta: %v", err) + } + all, err := allThemesJSON() + if err != nil { + t.Fatalf("allThemesJSON: %v", err) + } + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - data := buildPageData([]*post.Post{p}, tt.pageIndex, tt.totalPages, "neon") + data := buildPageData([]*post.Post{p}, tt.pageIndex, tt.totalPages, "neon", meta, all) if data.PrevPage != tt.wantPrev { t.Fatalf("PrevPage=%q; want %q", data.PrevPage, tt.wantPrev) } @@ -202,19 +211,24 @@ func TestBuildPageData_navLinks(t *testing.T) { } } -func TestGetTheme_unknownFallsBackToNeon(t *testing.T) { +func TestValidThemeName_unknownFallsBackToNeon(t *testing.T) { t.Parallel() - if got, want := getTheme("no-such-theme-"), getTheme("neon"); got != want { - t.Fatal("expected neon fallback") + if got := validThemeName("no-such-theme-"); got != "neon" { + t.Fatalf("validThemeName(\"no-such-theme-\") = %q; want \"neon\"", got) + } + if got := validThemeName("matrix"); got != "matrix" { + t.Fatalf("validThemeName(\"matrix\") = %q; want \"matrix\"", got) } } -func TestInjectSharedHead_addsFaviconLink(t *testing.T) { +func TestLoadThemeMeta_neonHasFields(t *testing.T) { t.Parallel() - - got := injectSharedHead(getTheme("neon")) - if !strings.Contains(got, `rel="icon" href="favicon.ico"`) { - t.Fatalf("favicon link missing from theme head") + m, err := loadThemeMeta("neon") + if err != nil { + t.Fatalf("loadThemeMeta(neon): %v", err) + } + if m.Title == "" || m.HeaderHTML == "" || m.SplashInnerHTML == "" { + t.Fatalf("neon meta missing required fields: %+v", m) } } diff --git a/internal/generator/templates.go b/internal/generator/templates.go index f50601a..df854ab 100644 --- a/internal/generator/templates.go +++ b/internal/generator/templates.go @@ -1,9 +1,10 @@ package generator -// HTML theme templates and shared sub-templates live as separate files under -// internal/generator/templates/{themes,shared}/*.tmpl and are loaded into the +// The unified shell template, shared CSS/JS bundles, and per-theme asset +// triples live under internal/generator/templates/ and are loaded into the // binary via embed.FS; see internal/generator/templates/embed.go. // -// themes.go exposes the theme registry and getTheme()/ListThemes() helpers. -// shared.go loads templates/shared/nav.tmpl into navDefs. -// favicon.go loads templates/shared/favicon_head.tmpl into faviconHeadHTML. +// themes.go exposes ListThemes()/validThemeName(). +// shared.go loads templates/shared/nav.tmpl into navDefs (splashGate, navhints, +// navmodal partials called from shell.tmpl). +// favicon.go generates the favicon.ico binary written into each output dir. diff --git a/internal/generator/templates/embed.go b/internal/generator/templates/embed.go index 91ba07f..ac3efbd 100644 --- a/internal/generator/templates/embed.go +++ b/internal/generator/templates/embed.go @@ -1,8 +1,8 @@ -// Package templates exposes the embedded HTML theme and shared sub-templates -// used by the generator. Themes and nav/HTML fragments live |
