diff options
| author | 2026-03-31 00:54:20 +0700 | |
|---|---|---|
| committer | 2026-03-31 00:54:20 +0700 | |
| commit | abc9e2fc1e93fba0b274f31b95340c411a73dd6d (patch) | |
| tree | 3d0b70016ce8f160203629da07c1a8ae3edff8af | |
| parent | 8d0385546b0a5f4f3b0832fce1b4c0a10fa0a927 (diff) | |
| parent | a8f566cbeb8358aca7fb4532d8b69c513ef197f3 (diff) | |
| download | web-abc9e2fc1e93fba0b274f31b95340c411a73dd6d.tar.gz web-abc9e2fc1e93fba0b274f31b95340c411a73dd6d.zip | |
v0.13.0 (#25)v0.13.0
- [feat: enable etag
middleware](https://github.com/SayaAndy/saya-today-web/commit/c7742b341a46360d16dd9035c046f004d0fe760c)
- feat: enable compress middleware
- feat: trim end slash of each request url (except for root)
- [feat: add linked
data](https://github.com/SayaAndy/saya-today-web/commit/a8f566cbeb8358aca7fb4532d8b69c513ef197f3)
- [feat: build
sitemap.xml](https://github.com/SayaAndy/saya-today-web/commit/7a40c01dd14f04787f5052d0c89e0d6fb9ce597f)
- [feat: generate
robots.txt](https://github.com/SayaAndy/saya-today-web/commit/be906e1cc4ec3e92bcb14ee08256b53f3a4b44e2)
- [feat: specify canonical hrefs for each
page](https://github.com/SayaAndy/saya-today-web/commit/a0ace41ad3b0f4661e98d8fa8ef254369cc80d1e)
- [feat: use semantic html tags for
pages](https://github.com/SayaAndy/saya-today-web/commit/2bcf8ee39555ea98be309b85547c797b0d172b84)
- feat: specify more meta properties for search engines
- fix: correct user and catalogue meta
- format: specify og property names in property field
- feat: user profile description
24 files changed, 422 insertions, 76 deletions
diff --git a/config/config.go b/config/config.go index 1393dd8..fd8c480 100644 --- a/config/config.go +++ b/config/config.go @@ -16,6 +16,7 @@ type Config struct { AvailableLanguages []AvailableLanguageConfig `json:"AvailableLanguages" yaml:"availableLanguages" validate:"required"` Auth AuthConfig `json:"Auth" yaml:"auth" validate:"required"` Mail MailConfig `json:"Mail" yaml:"mail" validate:"required"` + CanonicalEndpoint string `json:"CanonicalEndpoint" yaml:"canonicalEndpoint" validate:"required"` } type BlogPagesConfig struct { diff --git a/config/config.local.yaml b/config/config.local.yaml index e23c808..c02dc2d 100644 --- a/config/config.local.yaml +++ b/config/config.local.yaml @@ -45,3 +45,4 @@ mail: salt: '${MAIL_SALT}' trigger: onNewPost: "* * * * *" +canonicalEndpoint: 'http://127.0.0.1:3000' diff --git a/config/config.prod.yaml b/config/config.prod.yaml index 0bee79d..4de853c 100644 --- a/config/config.prod.yaml +++ b/config/config.prod.yaml @@ -44,3 +44,4 @@ mail: salt: '${MAIL_SALT}' trigger: onNewPost: "0/5 * * * *" +canonicalEndpoint: 'https://${FQDN}' diff --git a/config/config.stage.yaml b/config/config.stage.yaml index 1c15dae..efa94ae 100644 --- a/config/config.stage.yaml +++ b/config/config.stage.yaml @@ -44,3 +44,4 @@ mail: salt: '${MAIL_SALT}' trigger: onNewPost: "0/3 * * * *" +canonicalEndpoint: 'https://${FQDN}' diff --git a/internal/b2/client.go b/internal/b2/client.go index d712736..fda79e0 100644 --- a/internal/b2/client.go +++ b/internal/b2/client.go @@ -33,10 +33,11 @@ func NewB2Client(cfg *config.B2Config) (*B2Client, error) { } type BlogPage struct { - Link string - FileName string - Lang string - Metadata *frontmatter.Metadata + Link string + FileName string + Lang string + ModifiedTime time.Time + Metadata *frontmatter.Metadata } func (c *B2Client) Scan(prefix string) ([]*BlogPage, error) { @@ -79,9 +80,13 @@ func (c *B2Client) Scan(prefix string) ([]*BlogPage, error) { tags := strings.Split(attrs.Info["tags"], ",") slices.Sort(tags) + lang, _ := strings.CutPrefix(linkParts[0], c.prefix) + filePaths = append(filePaths, &BlogPage{ - Link: obj.Name(), - FileName: fileName, + Link: obj.Name(), + FileName: fileName, + Lang: lang, + ModifiedTime: attrs.LastModified, Metadata: &frontmatter.Metadata{ Title: attrs.Info["title"], ShortDescription: attrs.Info["short-description"], diff --git a/internal/router/basic-handler.go b/internal/router/basic-handler.go index 4a94a48..b973d01 100644 --- a/internal/router/basic-handler.go +++ b/internal/router/basic-handler.go @@ -34,6 +34,14 @@ func (r *BasicHandler) TemplatesToInject() []string { return []string{} } +func (r *BasicHandler) SitemapInfo(supplements *Supplements) []SitemapInfo { + return nil +} + +func (r *BasicHandler) ContentType() string { + return fiber.MIMETextHTMLCharsetUTF8 +} + func (r *BasicHandler) Render(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { if !r.IsTemplated() { panic("handler did not implement Render method (while being non-templated)") @@ -41,10 +49,12 @@ func (r *BasicHandler) Render(c *fiber.Ctx, supplements *Supplements, lang strin return fiber.StatusNoContent, nil } -func (r *BasicHandler) AddMeta(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (meta map[string]string, err error) { - return map[string]string{ - "robots": "noindex,nofollow", - }, nil +func (r *BasicHandler) AddMeta(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (meta []MetaField, err error) { + return []MetaField{{Name: "robots", Content: "noindex,nofollow"}}, nil +} + +func (r *BasicHandler) AddLinkedData(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (ld map[string]any, err error) { + return nil, nil } func (r *BasicHandler) RenderHeader(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { diff --git a/internal/router/handlers/lang-blog-title.go b/internal/router/handlers/lang-blog-title.go index 97de089..5bfc6e7 100644 --- a/internal/router/handlers/lang-blog-title.go +++ b/internal/router/handlers/lang-blog-title.go @@ -46,17 +46,54 @@ func (r *BlogPageHandler) ToValidateLang() router.LangSetting { return router.InPath } -func (r *BlogPageHandler) AddMeta(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (meta map[string]string, err error) { +func (r *BlogPageHandler) SitemapInfo(supplements *router.Supplements) []router.SitemapInfo { + sitemapInfo := []router.SitemapInfo{} + + pages, err := supplements.B2Client.Scan("") + if err != nil { + return sitemapInfo + } + + for _, page := range pages { + sitemapInfo = append(sitemapInfo, router.SitemapInfo{ + Loc: "/" + page.Lang + "/blog/" + page.FileName, + LastModified: page.ModifiedTime, + Priority: 1.0, + }) + } + + return sitemapInfo +} + +func (r *BlogPageHandler) AddMeta(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (meta []router.MetaField, err error) { + metadata, _, err := supplements.B2Client.ReadFrontmatter(lang + "/" + c.Params("title") + ".md") + if err != nil { + return nil, fmt.Errorf("failed to read frontmatter of the desired blog post: %w", err) + } + + return []router.MetaField{ + {Property: "og:title", Content: metadata.Title}, + {Property: "og:description", Content: fmt.Sprintf("%s [%s]", metadata.ShortDescription, metadata.ActionDate)}, + {Property: "og:image", Content: fmt.Sprintf("https://f003.backblazeb2.com/file/sayana-photos/webp-320p/%s.webp", metadata.Thumbnail)}, + {Property: "og:url", Content: fmt.Sprintf("%s/%s/blog/%s", templateMap["CanonicalEndpoint"], lang, c.Params("title"))}, + {Property: "og:type", Content: "website"}, + {Name: "twitter:card", Content: "summary_large_image"}, + }, nil +} + +func (r *BlogPageHandler) AddLinkedData(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (ld map[string]any, err error) { metadata, _, err := supplements.B2Client.ReadFrontmatter(lang + "/" + c.Params("title") + ".md") if err != nil { return nil, fmt.Errorf("failed to read frontmatter of the desired blog post: %w", err) } - return map[string]string{ - "og:title": metadata.Title, - "og:description": fmt.Sprintf("%s [%s]", metadata.ShortDescription, metadata.ActionDate), - "og:image": fmt.Sprintf("https://f003.backblazeb2.com/file/sayana-photos/webp-320p/%s.webp", metadata.Thumbnail), - "twitter:card": "summary_large_image", + return map[string]any{ + "@context": "https://schema.org", + "@type": "Article", + "headline": metadata.Title, + "description": fmt.Sprintf("%s [%s]", metadata.ShortDescription, metadata.ActionDate), + "author": map[string]string{"@type": "Person", "name": "Saya Andy"}, + "datePublished": metadata.PublishedTime.UTC().Format(time.RFC3339), }, nil } diff --git a/internal/router/handlers/lang-blog.go b/internal/router/handlers/lang-blog.go index e4c20c6..709d3dc 100644 --- a/internal/router/handlers/lang-blog.go +++ b/internal/router/handlers/lang-blog.go @@ -47,6 +47,23 @@ func (r *CatalogueHandler) ToValidateLang() router.LangSetting { return router.InPath } +func (r *CatalogueHandler) SitemapInfo(supplements *router.Supplements) []router.SitemapInfo { + sitemapInfo := []router.SitemapInfo{} + for _, lang := range supplements.AvailableLanguages { + sitemapInfo = append(sitemapInfo, router.SitemapInfo{Loc: "/" + lang.Name + "/blog", Priority: 0.3}) + } + return sitemapInfo +} + +func (r *CatalogueHandler) AddMeta(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (meta []router.MetaField, err error) { + return []router.MetaField{ + {Property: "og:title", Content: supplements.Localization[lang].BlogSearch.Header}, + {Property: "og:description", Content: supplements.Localization[lang].BlogSearch.Description}, + {Property: "og:url", Content: fmt.Sprintf("%s/%s/blog", templateMap["CanonicalEndpoint"], lang)}, + {Property: "og:type", Content: "website"}, + }, nil +} + func (r *CatalogueHandler) RenderBody(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { querySort := c.Query("sort") if querySort == "" { diff --git a/internal/router/handlers/lang-map.go b/internal/router/handlers/lang-map.go index 7df6104..551b485 100644 --- a/internal/router/handlers/lang-map.go +++ b/internal/router/handlers/lang-map.go @@ -40,6 +40,14 @@ func (r *MapHandler) ToValidateLang() router.LangSetting { return router.InPath } +func (r *MapHandler) SitemapInfo(supplements *router.Supplements) []router.SitemapInfo { + sitemapInfo := []router.SitemapInfo{} + for _, lang := range supplements.AvailableLanguages { + sitemapInfo = append(sitemapInfo, router.SitemapInfo{Loc: "/" + lang.Name + "/map", Priority: 0.3}) + } + return sitemapInfo +} + func (r *MapHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { pages, err := supplements.B2Client.Scan(lang + "/") slices.SortFunc(pages, func(a *b2.BlogPage, b *b2.BlogPage) int { diff --git a/internal/router/handlers/lang-user.go b/internal/router/handlers/lang-user.go index c22bcac..eae1685 100644 --- a/internal/router/handlers/lang-user.go +++ b/internal/router/handlers/lang-user.go @@ -37,10 +37,13 @@ func (r *UserHandler) ToValidateLang() router.LangSetting { return router.InPath } -func (r *UserHandler) AddMeta(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (meta map[string]string, err error) { - return map[string]string{ - "og:title": supplements.Localization[lang].BlogSearch.Header, - "og:description": supplements.Localization[lang].BlogSearch.Description, +func (r *UserHandler) AddMeta(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (meta []router.MetaField, err error) { + return []router.MetaField{ + {Name: "robots", Content: "noindex,nofollow"}, + {Property: "og:title", Content: supplements.Localization[lang].UserProfile.Header}, + {Property: "og:description", Content: supplements.Localization[lang].UserProfile.Description}, + {Property: "og:url", Content: fmt.Sprintf("%s/%s/user", templateMap["CanonicalEndpoint"], lang)}, + {Property: "og:type", Content: "website"}, }, nil } diff --git a/internal/router/handlers/lang.go b/internal/router/handlers/lang.go index 74166ae..c618353 100644 --- a/internal/router/handlers/lang.go +++ b/internal/router/handlers/lang.go @@ -41,11 +41,21 @@ func (r *HomeHandler) ToValidateLang() router.LangSetting { return router.InPath } -func (r *HomeHandler) AddMeta(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (meta map[string]string, err error) { - return map[string]string{ - "og:title": supplements.Localization[lang].HomePage.Header, - "og:description": supplements.Localization[lang].HomePage.HomePageDescription, - "og:image": "https://f003.backblazeb2.com/file/sayana-static/home-page-gifs/" + fmt.Sprintf("otter-%d.gif", rand.Int()%3+1), +func (r *HomeHandler) SitemapInfo(supplements *router.Supplements) []router.SitemapInfo { + sitemapInfo := []router.SitemapInfo{} + for _, lang := range supplements.AvailableLanguages { + sitemapInfo = append(sitemapInfo, router.SitemapInfo{Loc: "/" + lang.Name, Priority: 0.7}) + } + return sitemapInfo +} + +func (r *HomeHandler) AddMeta(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (meta []router.MetaField, err error) { + return []router.MetaField{ + {Property: "og:title", Content: supplements.Localization[lang].HomePage.Header}, + {Property: "og:description", Content: supplements.Localization[lang].HomePage.HomePageDescription}, + {Property: "og:image", Content: fmt.Sprintf("https://f003.backblazeb2.com/file/sayana-static/home-page-gifs/otter-%d.gif", rand.Int()%3+1)}, + {Property: "og:url", Content: fmt.Sprintf("%s/%s", templateMap["CanonicalEndpoint"], lang)}, + {Property: "og:type", Content: "website"}, }, nil } diff --git a/internal/router/handlers/robots.txt.go b/internal/router/handlers/robots.txt.go new file mode 100644 index 0000000..fc60af4 --- /dev/null +++ b/internal/router/handlers/robots.txt.go @@ -0,0 +1,48 @@ +package handlers + +import ( + "time" + + "github.com/SayaAndy/saya-today-web/internal/router" + "github.com/gofiber/fiber/v2" +) + +type RobotsTxtHandler struct { + router.BasicHandler +} + +func init() { + router.Routes = append(router.Routes, &RobotsTxtHandler{}) +} + +func (r *RobotsTxtHandler) Filter() (method string, path string) { + return "GET", "/robots.txt" +} + +func (r *RobotsTxtHandler) IsTemplated() bool { + return false +} + +func (r *RobotsTxtHandler) TemplatesToInject() []string { + return []string{"views/pages/robots.txt"} +} + +func (r *RobotsTxtHandler) ToCache() router.CacheSetting { + return router.ByUrlOnly +} + +func (r *RobotsTxtHandler) CacheDuration() time.Duration { + return time.Hour +} + +func (r *RobotsTxtHandler) ToValidateLang() router.LangSetting { + return router.NotRequired +} + +func (r *RobotsTxtHandler) ContentType() string { + return fiber.MIMETextPlainCharsetUTF8 +} + +func (r *RobotsTxtHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { + return fiber.StatusOK, nil +} diff --git a/internal/router/handlers/root.go b/internal/router/handlers/root.go index 9f15782..cd01fcd 100644 --- a/internal/router/handlers/root.go +++ b/internal/router/handlers/root.go @@ -1,6 +1,7 @@ package handlers import ( + "fmt" "time" "github.com/SayaAndy/saya-today-web/internal/router" @@ -35,10 +36,16 @@ func (r *RootHandler) CacheDuration() time.Duration { return 24 * time.Hour } -func (r *RootHandler) AddMeta(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (meta map[string]string, err error) { - return map[string]string{ - "og:title": "Saya Blog", - "og:description": "The blog of a 25 years old, travelling God knows where, proud to be not mainstream // Личный блог 25-летки, странствующего по ебеням, зато не мейнстрим", +func (r *RootHandler) SitemapInfo(_ *router.Supplements) []router.SitemapInfo { + return []router.SitemapInfo{{Loc: "/", Priority: 0.7}} +} + +func (r *RootHandler) AddMeta(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (meta []router.MetaField, err error) { + return []router.MetaField{ + {Property: "og:title", Content: "Saya Blog"}, + {Property: "og:description", Content: "The blog of a 25 years old, travelling God knows where, proud to be not mainstream // Личный блог 25-летки, странствующего по ебеням, зато не мейнстрим"}, + {Property: "og:url", Content: fmt.Sprint(templateMap["CanonicalEndpoint"]) + "/"}, + {Property: "og:type", Content: "website"}, }, nil } diff --git a/internal/router/handlers/sitemap.xml.go b/internal/router/handlers/sitemap.xml.go new file mode 100644 index 0000000..bb1df93 --- /dev/null +++ b/internal/router/handlers/sitemap.xml.go @@ -0,0 +1,79 @@ +package handlers + +import ( + "log/slog" + "time" + + "github.com/SayaAndy/saya-today-web/internal/router" + "github.com/gofiber/fiber/v2" +) + +type SitemapXmlHandler struct { + router.BasicHandler +} + +func init() { + router.Routes = append(router.Routes, &SitemapXmlHandler{}) +} + +func (r *SitemapXmlHandler) Filter() (method string, path string) { + return "GET", "/sitemap.xml" +} + +func (r *SitemapXmlHandler) IsTemplated() bool { + return false +} + +func (r *SitemapXmlHandler) TemplatesToInject() []string { + return []string{"views/pages/sitemap.xml"} +} + +func (r *SitemapXmlHandler) ToCache() router.CacheSetting { + return router.ByUrlOnly +} + +func (r *SitemapXmlHandler) CacheDuration() time.Duration { + return time.Hour +} + +func (r *SitemapXmlHandler) ToValidateLang() router.LangSetting { + return router.NotRequired +} + +func (r *SitemapXmlHandler) ContentType() string { + return fiber.MIMEApplicationXMLCharsetUTF8 +} + +func (r *SitemapXmlHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { + urls := []router.SitemapInfo{} + for _, route := range router.Routes { + sitemapInfo := route.SitemapInfo(supplements) + if sitemapInfo == nil { + continue + } + + method, match := route.Filter() + lastMod, err := supplements.TemplateManager.GetLastModified(method + " " + match) + if err != nil { + slog.Warn("failed to get last modified for a route", + slog.String("error", err.Error()), + slog.String("method", method), + slog.String("match", match)) + lastMod = time.Now() + } + + for i := range sitemapInfo { + if sitemapInfo[i].LastModified.Before(lastMod) { + sitemapInfo[i].LastModified = lastMod + } + if sitemapInfo[i].ChangeFreq == "" { + sitemapInfo[i].ChangeFreq = "monthly" + } + urls = append(urls, sitemapInfo[i]) + } + } + + templateMap["URLs"] = urls + + return fiber.StatusOK, nil +} diff --git a/internal/router/router.go b/internal/router/router.go index f28add2..0d0ce8c 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -2,8 +2,10 @@ package router import ( "database/sql" + "encoding/json" "errors" "fmt" + "html/template" "log/slog" "net/url" "slices" @@ -21,7 +23,9 @@ import ( "github.com/SayaAndy/saya-today-web/locale" "github.com/dgraph-io/ristretto/v2" "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/compress" "github.com/gofiber/fiber/v2/middleware/cors" + "github.com/gofiber/fiber/v2/middleware/etag" "github.com/golang-migrate/migrate/v4" "github.com/golang-migrate/migrate/v4/database/sqlite3" "github.com/yuin/goldmark" @@ -52,6 +56,19 @@ var ( Routes = make([]Route, 0) ) +type SitemapInfo struct { + Loc string + LastModified time.Time + ChangeFreq string + Priority float32 +} + +type MetaField struct { + Name string + Property string + Content string +} + type Route interface { Filter() (method string, path string) IsTemplated() bool @@ -59,8 +76,11 @@ type Route interface { CacheDuration() time.Duration ToValidateLang() LangSetting TemplatesToInject() []string + SitemapInfo(supplements *Supplements) []SitemapInfo + ContentType() string Render(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) - AddMeta(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (meta map[string]string, err error) + AddMeta(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (meta []MetaField, err error) + AddLinkedData(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (ld map[string]any, err error) RenderHeader(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) RenderBody(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) RenderFooter(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) @@ -87,6 +107,7 @@ type Router struct { app *fiber.App templatedRoutes map[string]map[string]Route templatedPathMatcher *PathMatcher + canonicalEndpoint string } func NewRouter(cfg *config.Config) (*Router, error) { @@ -210,10 +231,24 @@ func NewRouter(cfg *config.Config) (*Router, error) { AllowHeaders: "Origin, Content-Type, Accept", })) + app.Use(compress.New(compress.Config{ + Level: compress.LevelBestSpeed, + })) + + app.Use(etag.New()) + + app.Use(func(c *fiber.Ctx) error { + path := c.Path() + if len(path) > 1 && path[len(path)-1] == '/' { + return c.Redirect(strings.TrimRight(path, "/"), fiber.StatusMovedPermanently) + } + return c.Next() + }) + templatedRoutes := make(map[string]map[string]Route) templatedPathMatcher := NewPathMatcher() - return &Router{supplements, app, templatedRoutes, templatedPathMatcher}, nil + return &Router{supplements, app, templatedRoutes, templatedPathMatcher, cfg.CanonicalEndpoint}, nil } func (r *Router) InitRoutes() (err error) { @@ -261,10 +296,11 @@ func (r *Router) InitRoutes() (err error) { } defaultMap := fiber.Map{ - "L": r.supplements.Localization[lang], - "Lang": lang, - "Path": trimmedPath, - "QueryString": queryString, + "L": r.supplements.Localization[lang], + "Lang": lang, + "Path": trimmedPath, + "QueryString": queryString, + "CanonicalEndpoint": r.canonicalEndpoint, } statusCode, err := currentRoute.Render(c, r.supplements, lang, defaultMap) @@ -303,8 +339,8 @@ func (r *Router) InitRoutes() (err error) { go r.supplements.PageCache.SetWithTTL(cacheKey, content, int64(len(content)), route.CacheDuration()) } - c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8) - return c.Status(statusCode).Type("html").Send(content) + c.Set(fiber.HeaderContentType, route.ContentType()) + return c.Status(statusCode).Send(content) }) } } @@ -327,7 +363,7 @@ func (r *Router) InitRoutes() (err error) { r.app.Static("/", "./static") - Routes = make([]Route, 0) + // Routes = make([]Route, 0) return nil } @@ -379,14 +415,15 @@ func (r *Router) generalPage(c *fiber.Ctx, route Route, lang string) error { } if val, ok := r.supplements.PageCache.Get(cacheKey); val != nil && ok { - c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8) - return c.Status(fiber.StatusOK).Type("html").Send(val) + c.Set(fiber.HeaderContentType, route.ContentType()) + return c.Status(fiber.StatusOK).Send(val) } valueMap := fiber.Map{ - "L": r.supplements.Localization[lang], - "Lang": lang, - "QueryString": queryString, + "L": r.supplements.Localization[lang], + "Lang": lang, + "QueryString": queryString, + "CanonicalEndpoint": r.canonicalEndpoint, } var err error @@ -394,6 +431,15 @@ func (r *Router) generalPage(c *fiber.Ctx, route Route, lang string) error { slog.Warn("failed to receive meta", slog.String("path", path), slog.String("error", err.Error())) } + var ld map[string]any + if ld, err = route.AddLinkedData(c, r.supplements, lang, valueMap); err != nil { + slog.Warn("failed to receive linked data", slog.String("path", path), slog.String("error", err.Error())) + } + if ld != nil { + ldBytes, _ := json.Marshal(ld) + valueMap["LinkedData"] = template.JS(ldBytes) + } + content, err := r.supplements.TemplateManager.Render("general-page", valueMap) if err != nil { slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error())) @@ -401,8 +447,8 @@ func (r *Router) generalPage(c *fiber.Ctx, route Route, lang string) error { } go r.supplements.PageCache.SetWithTTL(cacheKey, content, int64(len(content)), route.CacheDuration()) - c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8) - return c.Status(fiber.StatusOK).Type("html").Send(content) + c.Set(fiber.HeaderContentType, route.ContentType()) + return c.Status(fiber.StatusOK).Send(content) } func (r *Router) generalPageSegment(c *fiber.Ctx, part string) error { @@ -447,17 +493,18 @@ func (r *Router) generalPageSegment(c *fiber.Ctx, part string) error { if route.ToCache() != Disabled { if val, ok := r.supplements.PageCache.Get(cacheKey); val != nil && ok { - c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8) - return c.Status(fiber.StatusOK).Type("html").Send(val) + c.Set(fiber.HeaderContentType, route.ContentType()) + return c.Status(fiber.StatusOK).Send(val) } } var statusCode int defaultMap := fiber.Map{ - "L": r.supplements.Localization[lang], - "Lang": lang, - "Path": strings.Trim(path, "/"), - "QueryString": queryString, + "L": r.supplements.Localization[lang], + "Lang": lang, + "Path": strings.Trim(path, "/"), + "QueryString": queryString, + "CanonicalEndpoint": r.canonicalEndpoint, } switch part { @@ -505,8 +552,8 @@ func (r *Router) generalPageSegment(c *fiber.Ctx, part string) error { go r.supplements.PageCache.SetWithTTL(cacheKey, content, int64(len(content)), route.CacheDuration()) } - c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8) - return c.Status(statusCode).Type("html").Send(content) + c.Set(fiber.HeaderContentType, route.ContentType()) + return c.Status(statusCode).Send(content) } func (r *Router) getAndValidateLang(c *fiber.Ctx, langSetting LangSetting, defaultLang ...string) (string, error) { diff --git a/internal/templatemanager/templatemanager.go b/internal/templatemanager/templatemanager.go index b23e2e8..9d14e15 100644 --- a/internal/templatemanager/templatemanager.go +++ b/internal/templatemanager/templatemanager.go @@ -4,8 +4,10 @@ import ( "bytes" "fmt" "html/template" + "os" "path/filepath" "strings" + "time" ) type TemplateManager struct { @@ -13,8 +15,9 @@ type TemplateManager struct { } type templateManagerRender struct { - Main string - Tmpl *template.Template + Main string + Tmpl *template.Template + LastModified time.Time } type TemplateManagerTemplates struct { @@ -53,10 +56,15 @@ func NewTemplateManager(templates ...TemplateManagerTemplates) (*TemplateManager if err != nil { return nil, err } + modTime, err := setLastModified(tmplStruct.Files...) + if err != nil { + return nil, err + } templateMap[tmplStruct.Name] = templateManagerRender{ - Main: filepath.Base(tmplStruct.Files[0]), - Tmpl: tmpl, + Main: filepath.Base(tmplStruct.Files[0]), + Tmpl: tmpl, + LastModified: modTime, } } @@ -114,10 +122,40 @@ func (tm *TemplateManager) Add(name string, files ...string) error { return fmt.Errorf("failed to add template into manager: %w", err) } + modTime, err := setLastModified(files...) + if err != nil { + return fmt.Errorf("failed to add template into manager: %w", err) + } + tm.templates[name] = templateManagerRender{ - Main: filepath.Base(files[0]), - Tmpl: tmpl, + Main: filepath.Base(files[0]), + Tmpl: tmpl, + LastModified: modTime, } return nil } + +func (tm *TemplateManager) GetLastModified(name string) (time.Time, error) { + tmpl, exists := tm.templates[name] + if !exists { + return time.Time{}, fmt.Errorf("template %s not found", name) + } + + return tmpl.LastModified, nil +} + +func setLastModified(filenames ...string) (time.Time, error) { + lastModified := time.Time{} + for _, filename := range filenames { + stat, err := os.Stat(filename) + if err != nil { + return lastModified, fmt.Errorf("failed to stat file: path '%s': %w", filename, err) + } + modTime := stat.ModTime() + if lastModified.Before(modTime) { + lastModified = modTime + } + } + return lastModified, nil +} diff --git a/locale/localization.en.yaml b/locale/localization.en.yaml index e165866..9923881 100644 --- a/locale/localization.en.yaml +++ b/locale/localization.en.yaml @@ -48,6 +48,7 @@ Mail: CapturedOn: 'Captured on' UserProfile: Header: 'Personal Settings' + Description: 'Set and verify your e-mail here, as well as subscribe to favorite blog tags' EmailHeader: 'E-Mail' VerificationCodeHeader: 'Verification Code' TypeEmail: 'Please type your email' diff --git a/locale/localization.go b/locale/localization.go index a4f36f1..7080355 100644 --- a/locale/localization.go +++ b/locale/localization.go @@ -85,6 +85,7 @@ type NewPostConfig struct { type UserProfileConfig struct { Header string `yaml:"Header" json:"Header"` + Description string `yaml:"Description" json:"Description"` EmailHeader string `yaml:"EmailHeader" json:"EmailHeader"` VerificationCodeHeader string `yaml:"VerificationCodeHeader" json:"VerificationCodeHeader"` TypeEmail string `yaml:"TypeEmail" json:"TypeEmail"` diff --git a/locale/localization.ru.yaml b/locale/localization.ru.yaml index e65848a..34e5387 100644 --- a/locale/localization.ru.yaml +++ b/locale/localization.ru.yaml @@ -48,6 +48,7 @@ Mail: CapturedOn: 'Снималось' UserProfile: Header: 'Личные настройки' + Description: 'Здесь можно настроить и верифицировать свою электронную почту, а также подписаться на избранные темы блога' EmailHeader: 'E-Mail' VerificationCodeHeader: 'Код верификации' TypeEmail: 'Пожалуйста, введите свою почту' diff --git a/views/layouts/general-page.html b/views/layouts/general-page.html index 3f5bcfd..7167d12 100644 --- a/views/layouts/general-page.html +++ b/views/layouts/general-page.html @@ -3,13 +3,24 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - {{- range $k, $v := .Meta }} - <meta name="{{ $k }}" content="{{ $v }}"> + {{- range .Meta }} + <meta {{with .Name}}name="{{.}}" {{end}}{{with .Property}}property="{{.}}" {{end}}{{with .Content}}content="{{.}}"{{end}}> + {{- if eq .Property "og:description" }} + <meta name="description" href="{{.Content}}"> + {{- end }} + {{- if eq .Property "og:url" }} + <link rel="canonical" href="{{.Content}}"> + {{- end }} {{- end }} <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css"> <link rel="stylesheet" href="https://f003.backblazeb2.com/file/sayana-static/libs/leaflet/1.9.4/leaflet.css"> <link rel="stylesheet" href="https://f003.backblazeb2.com/file/sayana-static/libs/glightbox/3.3.0/css/glightbox.min.css"> <link href="/output.css" rel="stylesheet"> + {{- with .LinkedData }} + <script type="application/ld+json"> + {{ . }} + </script> + {{- end }} </head> <body data-theme="ram" class="font-andika text-main-hard overflow-hidden bg-interlocked-hexagons h-dvh flex flex-row"> @@ -180,7 +191,7 @@ flex-row sticky w-dvw h-fit xs:w-[90dvw] sm:left-0 sm:flex-col sm:fixed sm:w-fit z-20 shadow-[0_0_1rem_black]"> - <div class="text-[3rem] flex flex-row sm:flex-col gap-0 relative z-20"> + <nav aria-label="Main navigation" class="text-[3rem] flex flex-row sm:flex-col gap-0 relative z-20"> <a onclick="return changeUrl('/../', true);" href="./" id="sidebar-back-button" class="themed-button themed-button-sidebar w-fit h-fit hidden" draggable="false"> <i class="fas fa-circle-left flex w-18 h-18 content-center text-center text-sidebar"></i> </a> @@ -199,7 +210,7 @@ <a onclick="return changeUrl('/{{ .Lang }}/user');" href="/{{ .Lang }}/user" id="sidebar-user-button" class="themed-button themed-button-sidebar w-fit h-fit hidden" draggable="false"> <i class="fas fa-user flex w-18 h-18 content-center text-center text-sidebar"></i> </a> - </div> + </nav> <div class="theme-wheel" id="theme-wheel"> <div class="theme-icon" data-theme="olive"></div> <div class="theme-icon" data-theme="lettuce"></div> @@ -247,21 +258,21 @@ <div class="bg-background-dark border-r-2 border-dashed border-main-hard"></div> <div class="max-h-full max-w-full flex flex-col grow bg-background-dark relative pt-4 ml-8 pr-4"> - <div id="general-page-header" class="max-xs:hidden [@media(max-height:32rem)]:hidden flex flex-col basis-8" + <header id="general-page-header" class="max-xs:hidden [@media(max-height:32rem)]:hidden flex flex-col basis-8" hx-get="/api/v1/general-page/header{{ if .QueryString }}?{{ .QueryString }}{{ end }}" hx-trigger="load" hx-target="this" hx-swap="innerHTML"> - </div> + </header> <div id="general-page-header-trigger" hx-get="/api/v1/general-page/header{{ if .QueryString }}?{{ .QueryString }}{{ end }}" hx-trigger="popstate from:window" hx-target="#general-page-header" hx-swap="innerHTML"></div> <hr class="max-xs:hidden [@media(max-height:32rem)]:hidden border-t-4 border-dotted border-main-hard mb-2"> - <div id="general-page-body" class="relative flex flex-col grow overflow-y-auto" + <main id="general-page-body" class="relative flex flex-col grow overflow-y-auto" hx-get="/api/v1/general-page/body{{ if .QueryString }}?{{ .QueryString }}{{ end }}" hx-trigger="load" hx-target="this" hx-swap="innerHTML"> - </div> + </main> <div id="general-page-body-trigger" hx-get="/api/v1/general-page/body{{ if .QueryString }}?{{ .QueryString }}{{ end }}" hx-trigger="popstate from:window" hx-target="#general-page-body" hx-swap="innerHTML"></div> - <div id="general-page-footer" class="flex flex-row basis-4 my-1" + <footer id="general-page-footer" class="flex flex-row basis-4 my-1" hx-get="/api/v1/general-page/footer{{ if .QueryString }}?{{ .QueryString }}{{ end }}" hx-trigger="load" hx-target="this" hx-swap="innerHTML"> - </div> + </footer> <div id="general-page-footer-trigger" hx-get="/api/v1/general-page/footer{{ if .QueryString }}?{{ .QueryString }}{{ end }}" hx-trigger="popstate from:window" hx-target="#general-page-footer" hx-swap="innerHTML"></div> <hr class="border-t-4 border-dotted border-main-hard mb-2"> diff --git a/views/pages/blog-page.html b/views/pages/blog-page.html index c81406f..3fc4526 100644 --- a/views/pages/blog-page.html +++ b/views/pages/blog-page.html @@ -1,12 +1,12 @@ {{ define "body" }} <div class="absolute w-full h-full inset-0 inset-shadow-[0_0_0.4rem_black] pointer-events-none z-20"></div> -<div class="relative bg-background-light flex flex-col px-8 py-4 overflow-y-auto"> - <p class="max-xs:block [@media(max-height:32rem)]:block hidden font-andika text-2xl font-bold italic text-main-hard tracking-[.0125rem] mb-1">{{ .Title }}</p> +<article class="relative bg-background-light flex flex-col px-8 py-4 overflow-y-auto"> + <h1 class="max-xs:block [@media(max-height:32rem)]:block hidden font-andika text-2xl font-bold italic text-main-hard tracking-[.0125rem] mb-1">{{ .Title }}</h1> <p class="block grow text-lg font-andika text-secondary"> <b>{{ .L.Metadata.Published }}</b>: - <span hx-get="/api/v1/tz" hx-vals='js:{timestamp: "{{ .PublishedDate }}", tz: clientTimeZone}' hx-target="this" hx-swap="innerHTML" hx-trigger="load"> + <time datetime="{{ .PublishedDate }}" hx-get="/api/v1/tz" hx-vals='js:{timestamp: "{{ .PublishedDate }}", tz: clientTimeZone}' hx-target="this" hx-swap="innerHTML" hx-trigger="load"> {{ .PublishedDate }} - </span> + </time> </p> <p class="block grow text-lg font-andika text-secondary"><b>{{ .L.Metadata.Action }}</b>: {{ .ActionDate }}</p> <p class="block grow text-md font-andika italic text-main-hard">{{ .ShortDescription }}</p> @@ -60,7 +60,7 @@ }); </script> {{- end }} -</div> +</article> {{ end }} {{ define "header" }} diff --git a/views/pages/global-map.html b/views/pages/global-map.html index 4b07bd2..604b52e 100644 --- a/views/pages/global-map.html +++ b/views/pages/global-map.html @@ -3,9 +3,13 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <meta name="og:title" content="{{ .L.GlobalMap.Header }}"> - <meta name="og:description" content="{{ .L.GlobalMap.Description }}"> + <meta name="description" content="{{ .L.GlobalMap.Description }}"> + <meta property="og:title" content="{{ .L.GlobalMap.Header }}"> + <meta property="og:description" content="{{ .L.GlobalMap.Description }}"> + <meta property="og:url" content="{{ .CanonicalEndpoint }}/{{ .Lang }}/map"> + <meta property="og:type" content="site"> <title>{{ .L.GlobalMap.Header }} // SAYA TODAY</title> + <link rel="canonical" href="{{ .CanonicalEndpoint }}/{{ .Lang }}/map"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css"> {{ block "header" . }}{{ end }} <link href="/output.css" rel="stylesheet"> diff --git a/views/pages/robots.txt b/views/pages/robots.txt new file mode 100644 index 0000000..1e5bebb --- /dev/null +++ b/views/pages/robots.txt @@ -0,0 +1,5 @@ +User-agent: * +Allow: / +Disallow: /api/ + +Sitemap: {{ .CanonicalEndpoint }}/sitemap.xml
\ No newline at end of file diff --git a/views/pages/sitemap.xml b/views/pages/sitemap.xml new file mode 100644 index 0000000..1474a56 --- /dev/null +++ b/views/pages/sitemap.xml @@ -0,0 +1,10 @@ +<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> + {{- range .URLs }} + <url> + <loc>{{ $.CanonicalEndpoint }}{{ .Loc }}</loc> + <lastmod>{{ .LastModified.UTC.Format "2006-01-02T15:04:05Z" }}</lastmod> + <changefreq>{{ .ChangeFreq }}</changefreq> + <priority>{{ printf "%.1f" .Priority }}</priority> + </url> + {{- end }} +</urlset> |