diff --git a/.gitignore b/.gitignore index 3ff9027..8636d33 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ data/ vault/ /hyperhack /dist/ +*:Zone.Identifier diff --git a/SPEC.md b/SPEC.md index ebb1f0b..8e991c5 100644 --- a/SPEC.md +++ b/SPEC.md @@ -42,8 +42,8 @@ The author writes in Obsidian and publishes by syncing the vault to the bucket. | Users | One author. No registration. Public side is read-only. | | Backend | Go 1.27, latest stable (go1.27.3 on 2026-11-03). `go.mod`: `go 1.27` + `toolchain go1.27.3`. Builder image: `golang:1.27`. Check go.dev/dl before starting; Dependabot keeps it current. | | Frontend | Server-side rendering with `html/template`. No framework. Minimal vanilla JS: votes, ALTCHA, view and time beacon. | -| Themes | Light and dark, toggle in the top bar. System theme by default until the visitor picks one. The choice is stored in a cookie and rendered on the server (no inline JS). | -| Design | Beautiful, minimal, responsive. Designed during implementation. | +| Themes | Light and dark, toggle in the top bar. System theme by default until the visitor picks one. The choice is stored in a cookie and rendered on the server (no inline JS). The toggle is a form that posts to `/prefs`, so it works without JS. | +| Design | Beautiful, minimal, responsive. Designed during implementation. Fonts: Inter (text) and JetBrains Mono (code), OFL, self-hosted, Latin + Cyrillic subsets. | | Languages | Code, docs, UI: English. UI also in Russian. Default: `DEFAULT_LANG` (`en` / `ru`) in `.env`. Visitor switch in the top bar; the choice is stored in a cookie (the server must know it to render the page). Post text is not translated. | | Storage | Garage (S3 API), path-style addressing. Client: `github.com/minio/minio-go/v7`. | | Post format | One `.md` = one post. Metadata: Obsidian properties (YAML frontmatter). | @@ -118,6 +118,7 @@ Proposed variables (final names set during implementation): ## Bucket layout - `assets/` — all assets: covers, images in posts. Decided. +- `assets/logo.svg` — optional site logo in the top bar. Without it the service shows a built-in mark. - The service ignores keys where any path segment starts with `.` or `_` (same rule as Remotely Save). ### Posts @@ -258,7 +259,7 @@ The service stores per-post metrics only. Storage: SQLite. ### Estimated reading time - Public: shown on the post (e.g. "5 min read"). -- Computed from the post text at render time: word count / `READING_WPM`. +- Computed from the post text at render time: word count / `READING_WPM`, rounded up, at least 1 minute. - Proposed: code blocks and images are counted separately. - Not related to measured time on page. @@ -415,6 +416,8 @@ Trust boundaries: internet → Caddy → service; bucket → service (semi-trust | `/reindex` | POST | private network | Caddy block, `REINDEX_ALLOW`, `REINDEX_TOKEN` | | `/metrics` | GET | private network | Caddy block, `METRICS_ALLOW` | | `/healthz` | GET | public | constant body, no data | +| `/prefs` | POST | public | form body ≤ 4 KB, enum check for `theme` / `lang`, redirect only to a local path | +| `/static/*` | GET | public | embedded CSS and fonts only, no directory listing | Other methods → `405`. Unknown paths → `404`. @@ -606,3 +609,4 @@ Other methods → `405`. Unknown paths → `404`. - 2026-11-03 — Go 1.27 (go1.27.3). - 2026-11-03 — Config defaults: `POLL_INTERVAL` 60s, `READING_WPM` 200, `LOG_LEVEL` info, `LOG_FORMAT` json, `DATA_DIR` /data, `VAULT_DIR` /vault, `ALTCHA_MAX_NUMBER` 100000, `S3_REGION` garage. Log component `main`. The service listens on fixed port 8080. - 2026-11-03 — Sync: S3 client `minio-go/v7`, frontmatter via `go.yaml.in/yaml/v3`, GFM. Mirror state in `VAULT_DIR/.hyperhack.json`. S3 down at startup: serve from the mirror; exit 1 only with no mirror. No Remotely Save prefix. Keys with a `.` or `_` segment are ignored (was proposed). `MAX_OBJECTS` counts all keys. An invalid frontmatter field skips the post. 3 S3 attempts per poll. `/reindex` waits for the sync and returns `204`. +- 2026-11-04 — Pages: theme and language switch via a no-JS form `POST /prefs`; cookies `theme` / `lang` (`HttpOnly; SameSite=Lax; Max-Age=1 year`, `Secure` on https). Logo from `assets/logo.svg`, built-in mark otherwise. Fonts Inter + JetBrains Mono, self-hosted. Pagination `/?page=N`: page 1 is `/`, other out-of-range values → `404`. `` from the post; the top bar keeps the UI language. CSS and fonts at `/static/*`. diff --git a/SPEC.ru.md b/SPEC.ru.md index 813015a..ee5e937 100644 --- a/SPEC.ru.md +++ b/SPEC.ru.md @@ -42,8 +42,8 @@ | Пользователи | Один автор. Регистрации нет. Публичная часть только читает. | | Backend | Go 1.27, последняя стабильная (go1.27.3 на 2026-11-03). `go.mod`: `go 1.27` + `toolchain go1.27.3`. Образ сборки: `golang:1.27`. Перед стартом проверить go.dev/dl; дальше версию обновляет Dependabot. | | Frontend | Серверный рендер через `html/template`. Без фреймворка. Минимум vanilla JS: голоса, ALTCHA, beacon просмотров и времени. | -| Темы | Светлая и тёмная, переключатель в верхнем баре. По умолчанию тема системы, пока читатель не выбрал свою. Выбор хранится в cookie и рендерится на сервере (без inline JS). | -| Дизайн | Красивый, минималистичный, адаптивный. Делается на этапе реализации. | +| Темы | Светлая и тёмная, переключатель в верхнем баре. По умолчанию тема системы, пока читатель не выбрал свою. Выбор хранится в cookie и рендерится на сервере (без inline JS). Переключатель — форма, которая отправляет `/prefs`, поэтому работает без JS. | +| Дизайн | Красивый, минималистичный, адаптивный. Делается на этапе реализации. Шрифты: Inter (текст) и JetBrains Mono (код), OFL, свои, наборы латиница + кириллица. | | Языки | Код, доки, интерфейс — английский. Интерфейс также на русском. По умолчанию — `DEFAULT_LANG` (`en` / `ru`) в `.env`. Переключатель для читателя в верхнем баре; выбор хранится в cookie (сервер должен знать язык для рендера). Текст постов не переводится. | | Хранилище | Garage (S3 API), path-style адресация. Клиент: `github.com/minio/minio-go/v7`. | | Формат поста | Один `.md` = один пост. Метаданные — свойства Obsidian (YAML frontmatter). | @@ -118,6 +118,7 @@ ## Структура бакета - `assets/` — все ассеты: обложки, картинки в постах. Решено. +- `assets/logo.svg` — необязательный логотип сайта в верхнем баре. Без него сервис показывает встроенный знак. - Сервис игнорирует ключи, где любой сегмент пути начинается с `.` или `_` (как Remotely Save). ### Посты @@ -258,7 +259,7 @@ Garage не шлёт уведомления об изменениях в бак ### Расчётное время чтения - Публично: показывается на посте (например, «5 мин чтения»). -- Считается из текста поста при рендере: число слов / `READING_WPM`. +- Считается из текста поста при рендере: число слов / `READING_WPM`, с округлением вверх, минимум 1 минута. - Предложено: блоки кода и картинки учитываются отдельно. - Не связано с измеренным временем на странице. @@ -415,6 +416,8 @@ Garage не шлёт уведомления об изменениях в бак | `/reindex` | POST | приватная сеть | блок в Caddy, `REINDEX_ALLOW`, `REINDEX_TOKEN` | | `/metrics` | GET | приватная сеть | блок в Caddy, `METRICS_ALLOW` | | `/healthz` | GET | публичный | постоянный ответ, без данных | +| `/prefs` | POST | публичный | тело формы ≤ 4 КБ, проверка перечислений `theme` / `lang`, редирект только на локальный путь | +| `/static/*` | GET | публичный | только встроенные CSS и шрифты, без листинга папок | Другие методы → `405`. Неизвестные пути → `404`. @@ -606,3 +609,4 @@ Garage не шлёт уведомления об изменениях в бак - 2026-11-03 — Go 1.27 (go1.27.3). - 2026-11-03 — Значения по умолчанию: `POLL_INTERVAL` 60s, `READING_WPM` 200, `LOG_LEVEL` info, `LOG_FORMAT` json, `DATA_DIR` /data, `VAULT_DIR` /vault, `ALTCHA_MAX_NUMBER` 100000, `S3_REGION` garage. Компонент логов `main`. Сервис слушает фиксированный порт 8080. - 2026-11-03 — Синхронизация: клиент S3 `minio-go/v7`, frontmatter через `go.yaml.in/yaml/v3`, GFM. Состояние зеркала в `VAULT_DIR/.hyperhack.json`. S3 недоступен при старте: работаем из зеркала; выход с кодом 1 только без зеркала. Без префикса Remotely Save. Ключи с сегментом на `.` или `_` игнорируются (было предложено). `MAX_OBJECTS` считает все ключи. Неверное поле frontmatter — пост пропускается. 3 попытки S3 за опрос. `/reindex` ждёт синхронизацию и возвращает `204`. +- 2026-11-04 — Страницы: тема и язык переключаются формой без JS `POST /prefs`; cookie `theme` / `lang` (`HttpOnly; SameSite=Lax; Max-Age=1 год`, `Secure` на https). Логотип из `assets/logo.svg`, иначе встроенный знак. Шрифты Inter + JetBrains Mono, свои. Пагинация `/?page=N`: страница 1 — это `/`, другие значения вне диапазона → `404`. `` из поста; верхний бар остаётся на языке интерфейса. CSS и шрифты на `/static/*`. diff --git a/internal/content/content_test.go b/internal/content/content_test.go index e3ff52b..2e3ac66 100644 --- a/internal/content/content_test.go +++ b/internal/content/content_test.go @@ -31,6 +31,9 @@ func TestParse(t *testing.T) { if strings.Join(p.Tags, ",") != "go,selfhost" { t.Errorf("tags = %q", p.Tags) } + if p.Words != 2 { + t.Errorf("words = %d, want 2", p.Words) + } if !strings.Contains(string(p.HTML), "world") { t.Errorf("html = %s", p.HTML) } diff --git a/internal/content/index.go b/internal/content/index.go index e6c0a91..2564854 100644 --- a/internal/content/index.go +++ b/internal/content/index.go @@ -10,6 +10,7 @@ import ( type Index struct { posts []*Post // newest first bySlug map[string]*Post + Logo bool // assets/logo.svg is in the mirror } // NewIndex builds an index. Drafts are left out. diff --git a/internal/content/post.go b/internal/content/post.go index 66cd9f7..177b4f9 100644 --- a/internal/content/post.go +++ b/internal/content/post.go @@ -28,6 +28,7 @@ type Post struct { Cover string // key inside assets/, empty if not set Lang string Draft bool + Words int // body word count, for reading time HTML template.HTML // sanitized } @@ -127,6 +128,7 @@ func (r *Renderer) Parse(key string, src []byte) (*Post, error) { return nil, errors.New("no slug: set slug in frontmatter") } } + p.Words = len(bytes.Fields(body)) if p.HTML, err = r.render(body); err != nil { return nil, fmt.Errorf("render: %w", err) } diff --git a/internal/server/pages.go b/internal/server/pages.go new file mode 100644 index 0000000..5a05be6 --- /dev/null +++ b/internal/server/pages.go @@ -0,0 +1,317 @@ +package server + +import ( + "bytes" + "crypto/sha256" + "embed" + "encoding/hex" + "encoding/json" + "fmt" + "html/template" + "io/fs" + "log/slog" + "net/http" + "net/url" + "path" + "slices" + "strconv" + "strings" + "time" + + chromahtml "github.com/alecthomas/chroma/v2/formatters/html" + "github.com/alecthomas/chroma/v2/styles" + + "github.com/Mimic890/hyperhack/internal/config" + "github.com/Mimic890/hyperhack/internal/content" +) + +//go:embed web +var webFS embed.FS + +var ( + langs = []string{"en", "ru"} + themes = []string{"light", "dark"} +) + +const cookieMaxAge = 365 * 24 * 60 * 60 + +type site struct { + Name string + GitHub string + Logo bool +} + +// pageData is what every template gets. Fields a page does not use stay zero. +type pageData struct { + Lang string // UI language + DocLang string // : the post language on post pages + Theme string // "", "light" or "dark" + Path string // current URI, for the prefs form + CSS string + Site site + + Posts []*content.Post + Page, Pages int + + Post *content.Post + + Title, Text string // i18n keys for the error page +} + +type pages struct { + log *slog.Logger + cfg *config.Config + index func() *content.Index + tmpl map[string]map[string]*template.Template // lang → page → template + css []byte + cssV string + fonts fs.FS +} + +func newPages(log *slog.Logger, cfg *config.Config, index func() *content.Index) *pages { + msgs := map[string]map[string]string{} + for _, l := range langs { + b, err := webFS.ReadFile("web/i18n/" + l + ".json") + if err != nil { + panic(err) + } + m := map[string]string{} + if err := json.Unmarshal(b, &m); err != nil { + panic(fmt.Sprintf("i18n %s: %v", l, err)) + } + msgs[l] = m + } + + p := &pages{log: log, cfg: cfg, index: index, tmpl: map[string]map[string]*template.Template{}} + for _, l := range langs { + p.tmpl[l] = map[string]*template.Template{} + for _, name := range []string{"home", "post", "error"} { + t := template.Must(template.New("").Funcs(funcs(msgs[l], cfg.ReadingWPM)). + ParseFS(webFS, "web/templates/layout.html", "web/templates/"+name+".html")) + p.tmpl[l][name] = t + } + } + + base, err := webFS.ReadFile("web/static/style.css") + if err != nil { + panic(err) + } + p.css = append(base, themedChromaCSS()...) + sum := sha256.Sum256(p.css) + p.cssV = "/static/style.css?v=" + hex.EncodeToString(sum[:6]) + p.fonts, err = fs.Sub(webFS, "web/static/fonts") + if err != nil { + panic(err) + } + return p +} + +func funcs(msgs map[string]string, wpm int) template.FuncMap { + t := func(key string, kv ...any) string { + s, ok := msgs[key] + if !ok { + return key + } + for i := 0; i+1 < len(kv); i += 2 { + s = strings.ReplaceAll(s, "{"+fmt.Sprint(kv[i])+"}", fmt.Sprint(kv[i+1])) + } + return s + } + return template.FuncMap{ + "t": t, + "date": func(d time.Time) string { + return t("date", "day", d.Day(), "month", t("month_"+strconv.Itoa(int(d.Month()))), "year", d.Year()) + }, + "minutes": func(words int) int { return max(1, (words+wpm-1)/wpm) }, + "asset": func(key string) string { return (&url.URL{Path: "/" + key}).EscapedPath() }, + "add": func(a, b int) int { return a + b }, + } +} + +// themedChromaCSS scopes the light and dark code palettes to the effective theme, +// the same way style.css scopes its tokens. +func themedChromaCSS() []byte { + var b bytes.Buffer + for _, th := range []struct{ name, style, other string }{{"light", "github", "dark"}, {"dark", "github-dark", "light"}} { + b.WriteString(chromaCSS(th.style, `:root[data-theme="`+th.name+`"]`)) + b.WriteString("@media (prefers-color-scheme: " + th.name + ") {\n") + b.WriteString(chromaCSS(th.style, `:root:not([data-theme="`+th.other+`"])`)) + b.WriteString("}\n") + } + return b.Bytes() +} + +// chromaCSS writes the token rules of a chroma style under scope. +// The wrapper and .bg rules are dropped: style.css sets the code block background. +func chromaCSS(style, scope string) string { + var buf strings.Builder + if err := chromahtml.New(chromahtml.WithClasses(true)).WriteCSS(&buf, styles.Get(style)); err != nil { + panic(err) + } + var out strings.Builder + for line := range strings.Lines(buf.String()) { + _, rule, ok := strings.Cut(line, "*/ ") + if !ok || !strings.HasPrefix(rule, ".chroma .") { + continue + } + sel, body, _ := strings.Cut(rule, "{") + var scoped []string + for s := range strings.SplitSeq(sel, ",") { + scoped = append(scoped, scope+" "+strings.TrimSpace(s)) + } + out.WriteString(strings.Join(scoped, ", ") + " {" + body) + } + return out.String() +} + +func (p *pages) data(r *http.Request) pageData { + d := pageData{ + Lang: p.cfg.DefaultLang, + Path: r.URL.RequestURI(), + CSS: p.cssV, + Site: site{Name: p.cfg.SiteName, GitHub: p.cfg.GitHubURL}, + } + if c, err := r.Cookie("lang"); err == nil && slices.Contains(langs, c.Value) { + d.Lang = c.Value + } + if c, err := r.Cookie("theme"); err == nil && slices.Contains(themes, c.Value) { + d.Theme = c.Value + } + d.DocLang = d.Lang + if ix := p.index(); ix != nil { + d.Site.Logo = ix.Logo + } + return d +} + +func (p *pages) render(w http.ResponseWriter, status int, name string, d pageData) { + var buf bytes.Buffer + if err := p.tmpl[d.Lang][name].ExecuteTemplate(&buf, "layout.html", d); err != nil { + p.log.Error("template", "page", name, "err", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) + _, _ = w.Write(buf.Bytes()) +} + +func (p *pages) errorPage(w http.ResponseWriter, r *http.Request, status int) { + d := p.data(r) + d.Title, d.Text = "not_found", "not_found_text" + if status == http.StatusServiceUnavailable { + d.Title, d.Text = "not_ready", "not_ready_text" + w.Header().Set("Retry-After", "30") + } + p.render(w, status, "error", d) +} + +func (p *pages) home(w http.ResponseWriter, r *http.Request) { + ix := p.index() + if ix == nil { + p.errorPage(w, r, http.StatusServiceUnavailable) + return + } + posts := ix.Posts() + size := p.cfg.PageSize + d := p.data(r) + d.Pages = max(1, (len(posts)+size-1)/size) + d.Page = 1 + if s := r.URL.Query().Get("page"); s != "" { + n, err := strconv.Atoi(s) + if err != nil || n < 2 || n > d.Pages { // page 1 lives at "/" + p.errorPage(w, r, http.StatusNotFound) + return + } + d.Page = n + } + start := (d.Page - 1) * size + d.Posts = posts[start:min(start+size, len(posts))] + p.render(w, http.StatusOK, "home", d) +} + +func (p *pages) post(w http.ResponseWriter, r *http.Request) { + ix := p.index() + if ix == nil { + p.errorPage(w, r, http.StatusServiceUnavailable) + return + } + post := ix.Get(r.PathValue("slug")) + if post == nil { + p.errorPage(w, r, http.StatusNotFound) + return + } + d := p.data(r) + d.Post = post + d.DocLang = post.Lang + p.render(w, http.StatusOK, "post", d) +} + +func (p *pages) notFound(w http.ResponseWriter, r *http.Request) { + p.errorPage(w, r, http.StatusNotFound) +} + +// prefs handles the top-bar form: it sets the theme or lang cookie and goes back. +func (p *pages) prefs(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, 4<<10) + if err := r.ParseForm(); err != nil { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + set := func(name string, allowed []string) bool { + v := r.PostForm.Get(name) + if v == "" { + return true + } + if !slices.Contains(allowed, v) { + return false + } + http.SetCookie(w, &http.Cookie{ + Name: name, Value: v, Path: "/", MaxAge: cookieMaxAge, + HttpOnly: true, Secure: p.cfg.SiteURL.Scheme == "https", SameSite: http.SameSiteLaxMode, + }) + return true + } + if !set("theme", themes) || !set("lang", langs) { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + http.Redirect(w, r, localPath(r.PostForm.Get("back")), http.StatusSeeOther) +} + +// localPath returns back if it is a path on this site, else "/". This blocks open redirects. +func localPath(back string) string { + if len(back) > 2048 || !strings.HasPrefix(back, "/") || strings.HasPrefix(back, "//") || strings.HasPrefix(back, `/\`) { + return "/" + } + u, err := url.Parse(back) + if err != nil || u.Scheme != "" || u.Host != "" || u.User != nil { + return "/" + } + for _, c := range back { + if c < 0x20 || c == 0x7f { + return "/" + } + } + return back +} + +func (p *pages) style(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/css; charset=utf-8") + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") // URL carries a content hash + _, _ = w.Write(p.css) +} + +func (p *pages) font(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("file") + if ext := path.Ext(name); ext != ".woff2" && ext != ".txt" { + p.errorPage(w, r, http.StatusNotFound) + return + } + if _, err := fs.Stat(p.fonts, name); err != nil { + p.errorPage(w, r, http.StatusNotFound) + return + } + w.Header().Set("Cache-Control", "public, max-age=2592000") + http.ServeFileFS(w, r, p.fonts, name) +} diff --git a/internal/server/pages_test.go b/internal/server/pages_test.go new file mode 100644 index 0000000..2bd298c --- /dev/null +++ b/internal/server/pages_test.go @@ -0,0 +1,254 @@ +package server + +import ( + "encoding/json" + "html/template" + "log/slog" + "maps" + "net/http" + "net/http/httptest" + "net/url" + "slices" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/Mimic890/hyperhack/internal/config" + "github.com/Mimic890/hyperhack/internal/content" +) + +func testConfig() *config.Config { + u, _ := url.Parse("https://example.com") + return &config.Config{SiteName: "Test ", SiteURL: u, DefaultLang: "en", ReadingWPM: 200, PageSize: 2} +} + +func testIndex(n int) *content.Index { + var posts []*content.Post + for i := range n { + posts = append(posts, &content.Post{ + Key: string(rune('a'+i)) + ".md", Slug: "post-" + string(rune('a'+i)), Title: "Post " + string(rune('A'+i)), + Date: time.Date(2026, 11, 1+i, 0, 0, 0, 0, time.UTC), Lang: "en", Words: 450, HTML: "

body

", + }) + } + posts = append(posts, &content.Post{ + Key: "x.md", Slug: "xss", Title: ``, Description: `">`, + Date: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC), Updated: time.Date(2026, 11, 5, 0, 0, 0, 0, time.UTC), + Lang: "ru", Cover: "assets/covers/a b#c.webp", Words: 10, HTML: template.HTML("

Привет

"), + }) + return content.NewIndex(posts, slog.New(slog.DiscardHandler)) +} + +type testSite struct { + h http.Handler + idx atomic.Pointer[content.Index] +} + +func newSite(t *testing.T, cfg *config.Config, ix *content.Index) *testSite { + t.Helper() + s := &testSite{} + s.idx.Store(ix) + var ready atomic.Bool + ready.Store(true) + s.h = New(slog.New(slog.DiscardHandler), cfg, &ready, nil, s.idx.Load) + return s +} + +func (s *testSite) get(path string, cookies ...*http.Cookie) *httptest.ResponseRecorder { + r := httptest.NewRequest("GET", path, nil) + for _, c := range cookies { + r.AddCookie(c) + } + rec := httptest.NewRecorder() + s.h.ServeHTTP(rec, r) + return rec +} + +func mustContain(t *testing.T, body string, want ...string) { + t.Helper() + for _, w := range want { + if !strings.Contains(body, w) { + t.Errorf("missing %q", w) + } + } +} + +func TestI18nKeysMatch(t *testing.T) { + keys := map[string][]string{} + for _, l := range langs { + b, err := webFS.ReadFile("web/i18n/" + l + ".json") + if err != nil { + t.Fatal(err) + } + m := map[string]string{} + if err := json.Unmarshal(b, &m); err != nil { + t.Fatal(err) + } + keys[l] = slices.Sorted(maps.Keys(m)) + } + if !slices.Equal(keys["en"], keys["ru"]) { + t.Errorf("en and ru keys differ:\nen %v\nru %v", keys["en"], keys["ru"]) + } +} + +func TestHome(t *testing.T) { + s := newSite(t, testConfig(), testIndex(4)) // 5 posts, 2 per page + rec := s.get("/") + body := rec.Body.String() + if rec.Code != 200 || rec.Header().Get("Content-Type") != "text/html; charset=utf-8" { + t.Fatalf("home = %d %s", rec.Code, rec.Header().Get("Content-Type")) + } + mustContain(t, body, ``, "Test <Blog>", `href="/posts/post-d"`, "Post D", "Post C", + ``, "3 min read", "Page 1 of 3", `href="/?page=2"`, + ``, `
`, "

Привет

", + `src="/assets/covers/a%20b%23c.webp"`, "Updated November 5, 2026", "1 min read", + `alert") { + t.Error("title not escaped") + } + if rec := s.get("/posts/nope"); rec.Code != 404 || !strings.Contains(rec.Body.String(), "Page not found") { + t.Errorf("missing post = %d", rec.Code) + } +} + +func TestCookies(t *testing.T) { + s := newSite(t, testConfig(), testIndex(1)) + body := s.get("/", &http.Cookie{Name: "lang", Value: "ru"}, &http.Cookie{Name: "theme", Value: "dark"}).Body.String() + mustContain(t, body, ``, `content="dark"`, "Новые", "1 ноября 2026", "3 мин чтения", `aria-pressed="true">RU`) + + body = s.get("/", &http.Cookie{Name: "lang", Value: "de"}, &http.Cookie{Name: "theme", Value: `">