Diffstat (limited to 'internal')
| -rw-r--r-- | internal/b2/client.go | 17 | ||||
| -rw-r--r-- | internal/frontmatter/parser.go | 19 | ||||
| -rw-r--r-- | internal/glightbox/block.go | 5 | ||||
| -rw-r--r-- | internal/glightbox/html_renderer.go | 52 | ||||
| -rw-r--r-- | internal/glightbox/parser.go | 9 | ||||
| -rw-r--r-- | internal/router/basic-handler.go | 16 | ||||
| -rw-r--r-- | internal/router/handlers/lang-blog-title.go | 54 | ||||
| -rw-r--r-- | internal/router/handlers/lang-blog.go | 17 | ||||
| -rw-r--r-- | internal/router/handlers/lang-map.go | 16 | ||||
| -rw-r--r-- | internal/router/handlers/lang-user.go | 10 | ||||
| -rw-r--r-- | internal/router/handlers/lang.go | 18 | ||||
| -rw-r--r-- | internal/router/handlers/robots.txt.go | 48 | ||||
| -rw-r--r-- | internal/router/handlers/root.go | 18 | ||||
| -rw-r--r-- | internal/router/handlers/sitemap.xml.go | 79 | ||||
| -rw-r--r-- | internal/router/router.go | 113 | ||||
| -rw-r--r-- | internal/templatemanager/templatemanager.go | 53 |
16 files changed, 465 insertions, 79 deletions
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/frontmatter/parser.go b/internal/frontmatter/parser.go index 83312dc..6062113 100644 --- a/internal/frontmatter/parser.go +++ b/internal/frontmatter/parser.go @@ -9,15 +9,16 @@ import ( ) type Metadata struct { - Title string `yaml:"title"` - ShortDescription string `yaml:"shortDescription"` - ActionDate string `yaml:"actionDate"` - PublishedTime time.Time `yaml:"publishedTime"` - Thumbnail string `yaml:"thumbnail"` - Tags []string `yaml:"tags"` - Geolocation string `yaml:"geolocation"` - Medley string `yaml:"medley"` - MedleyPart int `yaml:"medleyPart"` + Title string `yaml:"title"` + ShortDescription string `yaml:"shortDescription"` + ActionDate string `yaml:"actionDate"` + PublishedTime time.Time `yaml:"publishedTime"` + Thumbnail string `yaml:"thumbnail"` + Tags []string `yaml:"tags"` + Geolocation string `yaml:"geolocation"` + Medley string `yaml:"medley"` + MedleyPart int `yaml:"medleyPart"` + ContentSettings map[string]string `yaml:"contentSettings"` } func ParseFrontmatter(content []byte) (metadata *Metadata, markdown []byte, err error) { diff --git a/internal/glightbox/block.go b/internal/glightbox/block.go index e53e67b..30cda1c 100644 --- a/internal/glightbox/block.go +++ b/internal/glightbox/block.go @@ -9,8 +9,9 @@ import ( // GLightboxBlock represents a light gallery block in the AST type GLightboxBlock struct { ast.BaseBlock - Images []GLightboxImage - Location *time.Location + Images []GLightboxImage + Location *time.Location + Namespace string } type GLightboxImage struct { diff --git a/internal/glightbox/html_renderer.go b/internal/glightbox/html_renderer.go index 7022793..ac34880 100644 --- a/internal/glightbox/html_renderer.go +++ b/internal/glightbox/html_renderer.go @@ -68,7 +68,8 @@ func (r *GLightboxHTMLRenderer) renderGLightbox(w util.BufWriter, source []byte, var elements []string for i, img := range gallery.Images { - imageUrlSegments := strings.Split(img.URL, ".") + fullImageUrl := gallery.Namespace + img.URL + imageUrlSegments := strings.Split(fullImageUrl, ".") imageUrlWithoutExt := strings.Join(imageUrlSegments[:len(imageUrlSegments)-1], ".") imageUrlParts := strings.Split(imageUrlWithoutExt, "/") imageNameParts := strings.Split(imageUrlParts[len(imageUrlParts)-1], "-") @@ -122,7 +123,7 @@ func (r *GLightboxHTMLRenderer) renderGLightbox(w util.BufWriter, source []byte, </picture> <span class="grid-tooltip-text"><p>%s</p></span> <span class="grid-item-index">%d</span> - </a>`, img.URL, strings.Join(tagClassList, " "), galleryID, dayDate.Format("2006-01-02 15:04:05 -07:00"), dataDescriptionAttribute, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, anchorlessCaptionHTML, i+1)) + </a>`, fullImageUrl, strings.Join(tagClassList, " "), galleryID, dayDate.Format("2006-01-02 15:04:05 -07:00"), dataDescriptionAttribute, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, anchorlessCaptionHTML, i+1)) if glightboxDescId != "" { elements = append(elements, fmt.Sprintf(` @@ -132,44 +133,43 @@ func (r *GLightboxHTMLRenderer) renderGLightbox(w util.BufWriter, source []byte, } } - w.WriteString(fmt.Sprintf(` -<div class="justify-content-center display-block"> + w.WriteString(strings.ReplaceAll(` +<div class="items-center flex flex-col"> <hr class="border-t-3 border-dotted border-main-hard mt-1 mb-2 w-[80%%] ml-auto mr-auto"> - <div class="grid masonry-grid-%s mx-auto"> - <div class="grid-sizer grid-sizer-%s"></div> - %s + <div class="grid masonry-grid-{id}"> + <div class="grid-sizer grid-sizer-{id}"></div> + `+strings.Join(elements, "\n")+` </div> <hr class="border-t-3 border-dotted border-main-hard mt-1 mb-2 w-[80%%] ml-auto mr-auto"> -</div>`, galleryID, galleryID, strings.Join(elements, "\n"))) +</div> - w.WriteString(fmt.Sprintf(` <script> - var msnry_%s = new Masonry('.masonry-grid-%s', { - itemSelector: '.grid-item-%s', - columnWidth: '.grid-sizer-%s', - percentPosition: false, - fitWidth: true + var pckry_{id} = new Packery('.masonry-grid-{id}', { + itemSelector: '.grid-item-{id}', + columnWidth: '.grid-sizer-{id}', + percentPosition: false }); - var imgLoad_%s_timer; - var imgLoad_%s = imagesLoaded('.masonry-grid-%s'); + var imgLoad_{id}_timer; + var imgLoad_{id} = imagesLoaded('.masonry-grid-{id}'); - function initMasonryLayout_%s(tm) { - clearTimeout(imgLoad_%s_timer); - imgLoad_%s_timer = setTimeout(() => msnry_%s.layout(), 100); + function initMasonryLayout_{id}(tm) { + clearTimeout(imgLoad_{id}_timer); + imgLoad_{id}_timer = setTimeout(() => pckry_{id}.layout(), 100); } - imgLoad_%s.on('progress', initMasonryLayout_%s); + imgLoad_{id}.on('progress', initMasonryLayout_{id}); - window.addEventListener('resize', initMasonryLayout_%s); + window.addEventListener('resize', initMasonryLayout_{id}); document.addEventListener('popout', (e) => { - window.removeEventListener('resize', initMasonryLayout_%s); - imgLoad_%s.off('progress', initMasonryLayout_%s); - initMasonryLayout_%s = null; - imgLoad_%s = null; + window.removeEventListener('resize', initMasonryLayout_{id}); + imgLoad_{id}.off('progress', initMasonryLayout_{id}); + initMasonryLayout_{id} = null; + imgLoad_{id} = null; }, {once: true}); -</script>`, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID)) +</script> +`, "{id}", galleryID)) } return ast.WalkContinue, nil diff --git a/internal/glightbox/parser.go b/internal/glightbox/parser.go index 6780090..b376034 100644 --- a/internal/glightbox/parser.go +++ b/internal/glightbox/parser.go @@ -29,7 +29,7 @@ func (p *GLightboxParser) Open(parent ast.Node, reader text.Reader, pc parser.Co return nil, parser.NoChildren } - r := regexp.MustCompile(`^\{Gallery:([A-Za-z0-9\+\-/]+)\}$`) + r := regexp.MustCompile(`^\{Gallery:([A-Za-z0-9\+\-/]+)(:([A-Za-z0-9\+\-/]+))?\}$`) trimmed := bytes.TrimSpace(line) parts := r.FindSubmatch(trimmed) @@ -44,7 +44,12 @@ func (p *GLightboxParser) Open(parent ast.Node, reader text.Reader, pc parser.Co return nil, parser.NoChildren } - return &GLightboxBlock{Location: loc}, parser.NoChildren + ns := "" + if len(parts) >= 4 { + ns = string(parts[3]) + } + + return &GLightboxBlock{Location: loc, Namespace: ns}, parser.NoChildren } func (p *GLightboxParser) Continue(node ast.Node, reader text.Reader, pc parser.Context) parser.State { diff --git a/internal/router/basic-handler.go b/internal/router/basic-handler.go index a859d10..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,6 +49,14 @@ 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 []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) { return fiber.StatusNoContent, nil } diff --git a/internal/router/handlers/lang-blog-title.go b/internal/router/handlers/lang-blog-title.go index f1be2cd..5bfc6e7 100644 --- a/internal/router/handlers/lang-blog-title.go +++ b/internal/router/handlers/lang-blog-title.go @@ -46,6 +46,57 @@ func (r *BlogPageHandler) ToValidateLang() router.LangSetting { return router.InPath } +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]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 +} + func (r *BlogPageHandler) RenderBody(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { _, pathParts, _, err := router.GetPathFromReferer(c) if err != nil { @@ -74,6 +125,7 @@ func (r *BlogPageHandler) RenderBody(c *fiber.Ctx, supplements *router.Supplemen templateMap["ParsedMarkdown"] = template.HTML(parsedMarkdown) templateMap["PublishedDate"] = metadata.PublishedTime.Format("2006-01-02 15:04:05 -07:00") templateMap["ActionDate"] = metadata.ActionDate + templateMap["ShortDescription"] = metadata.ShortDescription go supplements.ClientCache.View(c.IP(), pathParts[2]) @@ -92,8 +144,6 @@ func (r *BlogPageHandler) RenderHeader(c *fiber.Ctx, supplements *router.Supplem } templateMap["Title"] = metadata.Title - templateMap["PublishedDate"] = metadata.PublishedTime.Format("2006-01-02 15:04:05 -07:00") - templateMap["ActionDate"] = metadata.ActionDate return fiber.StatusOK, 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 dbf1437..551b485 100644 --- a/internal/router/handlers/lang-map.go +++ b/internal/router/handlers/lang-map.go @@ -3,6 +3,7 @@ package handlers import ( "fmt" "log/slog" + "slices" "strconv" "strings" @@ -39,8 +40,19 @@ 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 { + return a.Metadata.PublishedTime.Compare(b.Metadata.PublishedTime) + }) status := fiber.StatusOK if err != nil { slog.Error("received an error while scanning b2 pages", @@ -52,6 +64,7 @@ func (r *MapHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang } type MapMarker struct { + Index int `json:"Index"` Title string `json:"Title"` PageLink string `json:"PageLink"` Lat float64 `json:"Lat"` @@ -61,7 +74,7 @@ func (r *MapHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang } mapMarkers := make([]*MapMarker, 0, len(pages)) - for _, page := range pages { + for i, page := range pages { geolocationParts := strings.Split(page.Metadata.Geolocation, " ") if len(geolocationParts) < 2 { continue @@ -78,6 +91,7 @@ func (r *MapHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang } mapMarkers = append(mapMarkers, &MapMarker{ + Index: i, Title: page.Metadata.Title, PageLink: fmt.Sprintf("/%s/blog/%s", lang, page.FileName), Lat: x, diff --git a/internal/router/handlers/lang-user.go b/internal/router/handlers/lang-user.go index db566fe..eae1685 100644 --- a/internal/router/handlers/lang-user.go +++ b/internal/router/handlers/lang-user.go @@ -37,6 +37,16 @@ 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 []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 +} + func (r *UserHandler) RenderBody(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { templateMap["Title"] = supplements.Localization[lang].UserProfile.Header diff --git a/internal/router/handlers/lang.go b/internal/router/handlers/lang.go index fc4deb6..c618353 100644 --- a/internal/router/handlers/lang.go +++ b/internal/router/handlers/lang.go @@ -41,6 +41,24 @@ func (r *HomeHandler) ToValidateLang() router.LangSetting { return router.InPath } +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 +} + func (r *HomeHandler) RenderBody(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { templateMap["Title"] = supplements.Localization[lang].HomePage.Header templateMap["FilledHeartCount"] = uint(40) 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 c8c4cd4..a8890fa 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,6 +36,23 @@ func (r *RootHandler) CacheDuration() time.Duration { return 24 * time.Hour } +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) { + meta = []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"}, + } + if supplements.Meta.GoogleSiteVerification != "" { + meta = append(meta, router.MetaField{Name: "google-site-verification", Content: supplements.Meta.GoogleSiteVerification}) + } + return meta, nil +} + func (r *RootHandler) RenderBody(c *fiber.Ctx, supplements *router.Supplements, _ string, templateMap fiber.Map) (statusCode int, err error) { templateMap["Title"] = "Choose Your Language" templateMap["AvailableLanguages"] = supplements.AvailableLanguages 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 ef3f063..3242759 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,7 +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 []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) @@ -79,6 +100,7 @@ type Supplements struct { BlogTrigger *blogtrigger.BlogTriggerScheduler TemplateManager *templatemanager.TemplateManager MarkdownRenderer goldmark.Markdown + Meta config.MetaConfig } type Router struct { @@ -86,6 +108,7 @@ type Router struct { app *fiber.App templatedRoutes map[string]map[string]Route templatedPathMatcher *PathMatcher + canonicalEndpoint string } func NewRouter(cfg *config.Config) (*Router, error) { @@ -193,6 +216,8 @@ func NewRouter(cfg *config.Config) (*Router, error) { return nil, fmt.Errorf("fail to initialize template manager: %w", err) } + supplements.Meta = cfg.Meta + enablePrintRoutes := false if cfg.LogLevel <= slog.LevelDebug { enablePrintRoutes = true @@ -209,10 +234,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) { @@ -260,10 +299,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) @@ -302,8 +342,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) }) } } @@ -326,7 +366,7 @@ func (r *Router) InitRoutes() (err error) { r.app.Static("/", "./static") - Routes = make([]Route, 0) + // Routes = make([]Route, 0) return nil } @@ -340,18 +380,23 @@ func (r *Router) Listen(endpoint string) error { func (r *Router) Close() (err error) { allErrors := make([]error, 0) + slog.Debug("shutting down blog trigger scheduler") if err = r.supplements.BlogTrigger.Close(); err != nil { allErrors = append(allErrors, fmt.Errorf("fail to shutdown blog trigger scheduler: %w", err)) } + slog.Debug("shutting down fiber server") if err = r.app.Shutdown(); err != nil { allErrors = append(allErrors, fmt.Errorf("fail to shutdown fiber server: %w", err)) } + slog.Debug("dumping cache") if err = r.supplements.ClientCache.Close(); err != nil { allErrors = append(allErrors, fmt.Errorf("fail to dump cache: %w", err)) } + slog.Debug("closing db connection") if err = r.supplements.DB.Close(); err != nil { allErrors = append(allErrors, fmt.Errorf("fail to close db connection: %w", err)) } + slog.Debug("closing page cache") r.supplements.PageCache.Close() return errors.Join(allErrors...) } @@ -373,23 +418,40 @@ 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) } - content, err := r.supplements.TemplateManager.Render("general-page", fiber.Map{ - "L": r.supplements.Localization[lang], - "Lang": lang, - "QueryString": queryString, - }) + valueMap := fiber.Map{ + "L": r.supplements.Localization[lang], + "Lang": lang, + "QueryString": queryString, + "CanonicalEndpoint": r.canonicalEndpoint, + } + + var err error + if valueMap["Meta"], err = route.AddMeta(c, r.supplements, lang, valueMap); err != nil { + 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())) return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div") } 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 { @@ -434,17 +496,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 { @@ -492,8 +555,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 75309e4..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 { @@ -32,6 +35,9 @@ var templateFuncMap = template.FuncMap{ return items }, "replace": strings.ReplaceAll, + "fdiv": func(a, b int) float64 { + return float64(a) / float64(b) + }, } func NewTemplateManager(templates ...TemplateManagerTemplates) (*TemplateManager, error) { @@ -50,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, } } @@ -111,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 +} |