Initial commit
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Assisted-by: GLM 5.3
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
// Package feeds renders the RSS 2.0, Atom 1.0, JSON Feed 1.1 and
|
||||
// sitemap documents for the public API.
|
||||
package feeds
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
json "encoding/json/v2"
|
||||
"fmt"
|
||||
"html"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/config"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/post"
|
||||
)
|
||||
|
||||
// FeedItemLimit caps the number of items in a feed.
|
||||
const FeedItemLimit = 20
|
||||
|
||||
// SitemapURLLimit caps the URLs in one sitemap document: the sitemaps.org
|
||||
// protocol defines 50 000 as the maximum a single document may carry, so
|
||||
// a larger site truncates to the newest posts rather than shipping a
|
||||
// document consumers may refuse whole.
|
||||
const SitemapURLLimit = 50_000
|
||||
|
||||
// language returns the site language, defaulting to English.
|
||||
func language(site config.Site) string {
|
||||
if site.Language != "" {
|
||||
return site.Language
|
||||
}
|
||||
return DefaultLanguage
|
||||
}
|
||||
|
||||
// DefaultLanguage is the feed language when [site].language is empty.
|
||||
const DefaultLanguage = "en"
|
||||
|
||||
// RenderRSSFeed renders an RSS 2.0 XML feed for the given posts.
|
||||
// selfPath is the path of the feed being rendered, so a reader can see
|
||||
// which document it fetched.
|
||||
func RenderRSSFeed(posts []*post.Post, site config.Site, baseURL, selfPath string) string {
|
||||
items := make([]string, 0, min(len(posts), FeedItemLimit))
|
||||
for _, p := range posts[:min(len(posts), FeedItemLimit)] {
|
||||
items = append(items, rssItem(p, baseURL))
|
||||
}
|
||||
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<channel>
|
||||
<title>%s</title>
|
||||
<link>%s</link>
|
||||
<description>%s</description>
|
||||
<language>%s</language>
|
||||
<atom:link rel="self" type="application/rss+xml" href="%s"/>
|
||||
%s
|
||||
</channel>
|
||||
</rss>`,
|
||||
html.EscapeString(site.Title),
|
||||
html.EscapeString(baseURL),
|
||||
html.EscapeString(site.Description),
|
||||
html.EscapeString(language(site)),
|
||||
html.EscapeString(baseURL+selfPath),
|
||||
strings.Join(items, "\n"))
|
||||
}
|
||||
|
||||
func rssItem(p *post.Post, baseURL string) string {
|
||||
permalink := baseURL + "/" + p.Slug()
|
||||
var extra strings.Builder
|
||||
if dateStr := p.DateString(); dateStr != "" {
|
||||
fmt.Fprintf(&extra, "\n <pubDate>%s</pubDate>", html.EscapeString(rfc822Date(dateStr)))
|
||||
}
|
||||
if creator := p.FediverseCreator(); creator != "" {
|
||||
fmt.Fprintf(&extra, "\n <dc:creator>%s</dc:creator>", html.EscapeString(creator))
|
||||
}
|
||||
if lang := p.Lang(); lang != "" {
|
||||
fmt.Fprintf(&extra, "\n <dc:language>%s</dc:language>", html.EscapeString(lang))
|
||||
}
|
||||
return fmt.Sprintf(` <item>
|
||||
<title>%s</title>
|
||||
<link>%s</link>
|
||||
<guid>%s</guid>
|
||||
<description>%s</description>%s
|
||||
</item>`,
|
||||
html.EscapeString(p.Title()),
|
||||
html.EscapeString(permalink),
|
||||
html.EscapeString(permalink),
|
||||
html.EscapeString(p.Excerpt()),
|
||||
extra.String())
|
||||
}
|
||||
|
||||
// rfc822Date formats a YYYY-MM-DD string as an RFC 822 timestamp, or
|
||||
// returns it unchanged when unparseable.
|
||||
func rfc822Date(value string) string {
|
||||
t, err := time.Parse("2006-01-02", value)
|
||||
if err != nil {
|
||||
return value
|
||||
}
|
||||
return t.UTC().Format("Mon, 02 Jan 2006 15:04:05") + " GMT"
|
||||
}
|
||||
|
||||
// rfc3339Date formats a YYYY-MM-DD string as RFC 3339 at UTC midnight,
|
||||
// or returns it unchanged when it cannot be parsed.
|
||||
func rfc3339Date(value string) string {
|
||||
t, err := time.Parse("2006-01-02", value)
|
||||
if err != nil {
|
||||
return value
|
||||
}
|
||||
return t.UTC().Format("2006-01-02T15:04:05-07:00")
|
||||
}
|
||||
|
||||
// RenderAtomFeed renders an Atom 1.0 XML feed for the given posts.
|
||||
// selfPath is the path of the feed being rendered, so the rel=self link
|
||||
// names the document the client actually fetched rather than always the
|
||||
// site-wide feed.
|
||||
func RenderAtomFeed(posts []*post.Post, site config.Site, baseURL, selfPath string) string {
|
||||
title := html.EscapeString(site.Title)
|
||||
link := html.EscapeString(baseURL)
|
||||
feedURL := html.EscapeString(baseURL + selfPath)
|
||||
description := html.EscapeString(site.Description)
|
||||
updated := atomUpdated(posts)
|
||||
|
||||
items := make([]string, 0, min(len(posts), FeedItemLimit))
|
||||
for _, p := range posts[:min(len(posts), FeedItemLimit)] {
|
||||
items = append(items, atomEntry(p, baseURL))
|
||||
}
|
||||
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<title>%s</title>
|
||||
<link rel="alternate" type="text/html" href="%s"/>
|
||||
<link rel="self" type="application/atom+xml" href="%s"/>
|
||||
<id>%s/</id>
|
||||
<updated>%s</updated>
|
||||
<subtitle>%s</subtitle>
|
||||
%s
|
||||
</feed>`,
|
||||
title, link, feedURL, link,
|
||||
html.EscapeString(updated), description,
|
||||
strings.Join(items, "\n"))
|
||||
}
|
||||
|
||||
func atomUpdated(posts []*post.Post) string {
|
||||
for _, p := range posts {
|
||||
if dateStr := p.DateString(); dateStr != "" {
|
||||
return rfc3339Date(dateStr)
|
||||
}
|
||||
}
|
||||
// No post carries a date, so the feed has no update time of its own.
|
||||
// The epoch is used rather than the current time, because a document
|
||||
// that changes on every request defeats every cache in front of it.
|
||||
return "1970-01-01T00:00:00Z"
|
||||
}
|
||||
|
||||
func atomEntry(p *post.Post, baseURL string) string {
|
||||
permalink := baseURL + "/" + p.Slug()
|
||||
updated, published := "", ""
|
||||
if dateStr := p.DateString(); dateStr != "" {
|
||||
updated = rfc3339Date(dateStr)
|
||||
published = updated
|
||||
}
|
||||
authorTag := ""
|
||||
if creator := p.FediverseCreator(); creator != "" {
|
||||
authorTag = fmt.Sprintf("\n <author><name>%s</name></author>", html.EscapeString(creator))
|
||||
}
|
||||
langAttr := ""
|
||||
if lang := p.Lang(); lang != "" {
|
||||
langAttr = fmt.Sprintf(` xml:lang="%s"`, html.EscapeString(lang))
|
||||
}
|
||||
return fmt.Sprintf(` <entry>
|
||||
<title%s>%s</title>
|
||||
<link rel="alternate" type="text/html" href="%s"/>
|
||||
<id>%s</id>
|
||||
<updated>%s</updated>
|
||||
<published>%s</published>
|
||||
<summary>%s</summary>%s
|
||||
</entry>`,
|
||||
langAttr, html.EscapeString(p.Title()),
|
||||
html.EscapeString(permalink), html.EscapeString(permalink),
|
||||
updated, published,
|
||||
html.EscapeString(p.Excerpt()), authorTag)
|
||||
}
|
||||
|
||||
// JSONFeed is a JSON Feed 1.1 document. Empty optional fields are
|
||||
// omitted, matching the feed specification.
|
||||
type JSONFeed struct {
|
||||
Version string `json:"version"`
|
||||
Title string `json:"title"`
|
||||
HomePageURL string `json:"home_page_url"`
|
||||
FeedURL string `json:"feed_url"`
|
||||
Description string `json:"description"`
|
||||
Language string `json:"language"`
|
||||
Items []JSONItem `json:"items,omitempty"`
|
||||
}
|
||||
|
||||
// JSONItem is one JSON Feed item. Empty optional fields are omitted.
|
||||
type JSONItem struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
ContentHTML string `json:"content_html"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
DatePublished string `json:"date_published,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Authors []JSONAuthor `json:"authors,omitempty"`
|
||||
}
|
||||
|
||||
// JSONAuthor is the author entry of a JSON Feed item.
|
||||
type JSONAuthor struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// RenderJSONFeed builds a JSON Feed 1.1 document for the given posts.
|
||||
// selfPath is the path of the feed being rendered, for feed_url.
|
||||
func RenderJSONFeed(posts []*post.Post, site config.Site, baseURL, selfPath string) JSONFeed {
|
||||
limit := min(len(posts), FeedItemLimit)
|
||||
items := make([]JSONItem, 0, limit)
|
||||
for _, p := range posts[:limit] {
|
||||
items = append(items, jsonFeedItem(p, baseURL, site))
|
||||
}
|
||||
return JSONFeed{
|
||||
Version: "https://jsonfeed.org/version/1.1",
|
||||
Title: site.Title,
|
||||
HomePageURL: baseURL,
|
||||
FeedURL: baseURL + selfPath,
|
||||
Description: site.Description,
|
||||
Language: language(site),
|
||||
Items: items,
|
||||
}
|
||||
}
|
||||
|
||||
func jsonFeedItem(p *post.Post, baseURL string, site config.Site) JSONItem {
|
||||
permalink := baseURL + "/" + p.Slug()
|
||||
htmlOut, err := p.HTML()
|
||||
if err != nil {
|
||||
// The item still goes out (a feed with a body-less entry beats a
|
||||
// feed that 500s for one bad post), but not silently: the detail
|
||||
// endpoint fails loudly for the same post, and the feed should
|
||||
// leave the same trace.
|
||||
slog.Warn("feeds: cannot render post for the JSON feed", "slug", p.Slug(), "error", err)
|
||||
htmlOut = ""
|
||||
}
|
||||
item := JSONItem{
|
||||
ID: permalink,
|
||||
URL: permalink,
|
||||
Title: p.Title(),
|
||||
ContentHTML: htmlOut,
|
||||
Summary: p.Excerpt(),
|
||||
DatePublished: p.DateString(),
|
||||
Tags: p.Tags(),
|
||||
}
|
||||
author := p.FediverseCreator()
|
||||
if author == "" {
|
||||
author = site.FediverseCreator
|
||||
}
|
||||
if author != "" {
|
||||
item.Authors = []JSONAuthor{{Name: author}}
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
// MarshalJSONFeed serialises a feed. encoding/json/v2 escapes only what
|
||||
// JSON requires, so the HTML inside content_html reaches the client as the
|
||||
// post was rendered rather than with every angle bracket escaped.
|
||||
func MarshalJSONFeed(feed JSONFeed) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := json.MarshalWrite(&buf, feed, json.Deterministic(true)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// RenderSitemap renders an XML sitemap, preferring the file
|
||||
// modification time for <lastmod>. At most SitemapURLLimit URLs are
|
||||
// included, newest posts first (the caller lists them that way).
|
||||
func RenderSitemap(posts []*post.Post, baseURL string) string {
|
||||
var urls []string
|
||||
for _, p := range posts[:min(len(posts), SitemapURLLimit)] {
|
||||
entry := " <url><loc>" + html.EscapeString(baseURL+"/"+p.Slug()) + "</loc>"
|
||||
if lastmod := lastmodFor(p); lastmod != "" {
|
||||
entry += "<lastmod>" + html.EscapeString(lastmod) + "</lastmod>"
|
||||
}
|
||||
entry += "</url>"
|
||||
urls = append(urls, entry)
|
||||
}
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
` + strings.Join(urls, "\n") + `
|
||||
</urlset>`
|
||||
}
|
||||
|
||||
func lastmodFor(p *post.Post) string {
|
||||
if p.Path != "" {
|
||||
if info, err := os.Stat(p.Path); err == nil {
|
||||
return info.ModTime().UTC().Format("2006-01-02")
|
||||
}
|
||||
}
|
||||
return p.DateString()
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package feeds
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/config"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/frontmatter"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/post"
|
||||
)
|
||||
|
||||
var testSite = config.Site{
|
||||
Title: "Můj blog",
|
||||
Description: "Testovací blog",
|
||||
BaseURL: "https://site.example",
|
||||
Language: "cs",
|
||||
Author: "Petr",
|
||||
}
|
||||
|
||||
func samplePosts(t *testing.T) []*post.Post {
|
||||
t.Helper()
|
||||
p1 := parsePost(t, "+++\ntitle = \"První & <pos>\"\nslug = \"prvni\"\ndate = 2026-08-18\nlang = \"cs\"\ntags = [\"go\"]\nfediverse_creator = \"@petr@social\"\n+++\nTělo **jedna**.\n")
|
||||
p2 := parsePost(t, "+++\ntitle = \"Druhý\"\nslug = \"druhy\"\n+++\nTělo dva.\n")
|
||||
p1.Path = filepath.Join(t.TempDir(), "prvni.md")
|
||||
return []*post.Post{p1, p2}
|
||||
}
|
||||
|
||||
func parsePost(t *testing.T, content string) *post.Post {
|
||||
t.Helper()
|
||||
p, err := post.Parse(content)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestRenderRSSFeed(t *testing.T) {
|
||||
posts := samplePosts(t)
|
||||
out := RenderRSSFeed(posts, testSite, "https://site.example", "/api/volumen/feed.xml")
|
||||
for _, want := range []string{
|
||||
`<?xml version="1.0" encoding="UTF-8"?>`,
|
||||
`<rss version="2.0"`,
|
||||
`<title>Můj blog</title>`,
|
||||
`<title>První & <pos></title>`,
|
||||
`<link>https://site.example/prvni</link>`,
|
||||
`<guid>https://site.example/prvni</guid>`,
|
||||
`<pubDate>Tue, 18 Aug 2026 00:00:00 GMT</pubDate>`,
|
||||
`<dc:creator>@petr@social</dc:creator>`,
|
||||
`<dc:language>cs</dc:language>`,
|
||||
`<language>cs</language>`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("rss missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
// Post without a date has no pubDate.
|
||||
if strings.Contains(out, "druhý</title>") && strings.Count(out, "<pubDate>") != 1 {
|
||||
t.Fatalf("unexpected pubDate count:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRSSFeedItemLimit(t *testing.T) {
|
||||
var posts []*post.Post
|
||||
for range FeedItemLimit + 5 {
|
||||
p := parsePost(t, "+++\nslug = \"p\"\ntitle = \"t\"\n+++\nx\n")
|
||||
posts = append(posts, p)
|
||||
}
|
||||
out := RenderRSSFeed(posts, testSite, "https://site.example", "/api/volumen/feed.xml")
|
||||
if got := strings.Count(out, "<item>"); got != FeedItemLimit {
|
||||
t.Fatalf("items = %d, want %d", got, FeedItemLimit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderAtomFeed(t *testing.T) {
|
||||
posts := samplePosts(t)
|
||||
out := RenderAtomFeed(posts, testSite, "https://site.example", "/api/volumen/feed.atom")
|
||||
for _, want := range []string{
|
||||
`<feed xmlns="http://www.w3.org/2005/Atom">`,
|
||||
`<link rel="self" type="application/atom+xml" href="https://site.example/api/volumen/feed.atom"/>`,
|
||||
`<id>https://site.example/</id>`,
|
||||
`<updated>2026-08-18T00:00:00+00:00</updated>`,
|
||||
`<title xml:lang="cs">První & <pos></title>`,
|
||||
`<published>2026-08-18T00:00:00+00:00</published>`,
|
||||
`<author><name>@petr@social</name></author>`,
|
||||
`<summary>Tělo jedna.</summary>`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("atom missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderAtomFeedWithoutDates(t *testing.T) {
|
||||
p := parsePost(t, "+++\nslug = \"x\"\ntitle = \"X\"\n+++\nb\n")
|
||||
out := RenderAtomFeed([]*post.Post{p}, testSite, "https://site.example", "/api/volumen/feed.atom")
|
||||
// An entry without a date emits empty updated and published
|
||||
// elements, which consumers rely on.
|
||||
if !strings.Contains(out, "<updated></updated>") {
|
||||
t.Fatalf("empty updated element missing:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "<published></published>") {
|
||||
t.Fatalf("empty published element missing:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderJSONFeed(t *testing.T) {
|
||||
posts := samplePosts(t)
|
||||
out := RenderJSONFeed(posts, testSite, "https://site.example", "/api/volumen/feed.json")
|
||||
if out.Version != "https://jsonfeed.org/version/1.1" {
|
||||
t.Fatalf("version = %v", out.Version)
|
||||
}
|
||||
if out.Title != "Můj blog" || out.Language != "cs" {
|
||||
t.Fatalf("feed = %+v", out)
|
||||
}
|
||||
if out.FeedURL != "https://site.example/api/volumen/feed.json" {
|
||||
t.Fatalf("feed_url = %v", out.FeedURL)
|
||||
}
|
||||
if len(out.Items) != 2 {
|
||||
t.Fatalf("items = %v", out.Items)
|
||||
}
|
||||
first := out.Items[0]
|
||||
if first.ID != "https://site.example/prvni" {
|
||||
t.Fatalf("item = %+v", first)
|
||||
}
|
||||
if !strings.Contains(first.ContentHTML, "<strong>jedna</strong>") {
|
||||
t.Fatalf("content_html = %v", first.ContentHTML)
|
||||
}
|
||||
if first.DatePublished != "2026-08-18" {
|
||||
t.Fatalf("date_published = %v", first.DatePublished)
|
||||
}
|
||||
if len(first.Authors) != 1 || first.Authors[0].Name != "@petr@social" {
|
||||
t.Fatalf("authors = %v", first.Authors)
|
||||
}
|
||||
if second := out.Items[1]; second.DatePublished != "" {
|
||||
t.Fatalf("date_published should be empty: %+v", second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderJSONFeedSiteAuthorFallback(t *testing.T) {
|
||||
p := parsePost(t, "+++\nslug = \"x\"\ntitle = \"X\"\n+++\nb\n")
|
||||
site := config.Site{Title: "T", FediverseCreator: "@site@host"}
|
||||
out := RenderJSONFeed([]*post.Post{p}, site, "https://site.example", "/api/volumen/feed.json")
|
||||
if len(out.Items) != 1 || out.Items[0].Authors[0].Name != "@site@host" {
|
||||
t.Fatalf("authors = %v", out.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSitemap(t *testing.T) {
|
||||
posts := samplePosts(t)
|
||||
out := RenderSitemap(posts, "https://site.example")
|
||||
for _, want := range []string{
|
||||
`<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`,
|
||||
`<loc>https://site.example/prvni</loc>`,
|
||||
`<lastmod>`,
|
||||
`<loc>https://site.example/druhy</loc>`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("sitemap missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSitemapLastmodFallsBackToDate(t *testing.T) {
|
||||
p := parsePost(t, "+++\nslug = \"x\"\ndate = 2026-01-02\n+++\nb\n")
|
||||
out := RenderSitemap([]*post.Post{p}, "https://site.example")
|
||||
if !strings.Contains(out, "<lastmod>2026-01-02</lastmod>") {
|
||||
t.Fatalf("sitemap = %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnparseableDatePassesThrough(t *testing.T) {
|
||||
if got := rfc822Date("not-a-date"); got != "not-a-date" {
|
||||
t.Fatalf("rfc822Date = %q", got)
|
||||
}
|
||||
if got := rfc3339Date("not-a-date"); got != "not-a-date" {
|
||||
t.Fatalf("rfc3339Date = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSitemapLastmodFromRealFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "x.md")
|
||||
if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
p := parsePost(t, "+++\nslug = \"x\"\n+++\nb\n")
|
||||
p.Path = path
|
||||
out := RenderSitemap([]*post.Post{p}, "https://site.example")
|
||||
if !strings.Contains(out, "<lastmod>") {
|
||||
t.Fatalf("sitemap = %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalJSONFeedNoHTMLEscaping(t *testing.T) {
|
||||
posts := samplePosts(t)
|
||||
feed := RenderJSONFeed(posts, testSite, "https://site.example", "/api/volumen/feed.json")
|
||||
out, err := MarshalJSONFeed(feed)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalJSONFeed: %v", err)
|
||||
}
|
||||
if strings.Contains(string(out), `\u0026`) || strings.Contains(string(out), `\u003c`) {
|
||||
t.Fatalf("HTML escaping leaked in: %s", out)
|
||||
}
|
||||
if !strings.Contains(string(out), `"title":"První & <pos>"`) {
|
||||
t.Fatalf("literal characters missing: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// One sitemap document carries at most SitemapURLLimit URLs, the
|
||||
// sitemaps.org protocol ceiling.
|
||||
func TestRenderSitemapCapsURLs(t *testing.T) {
|
||||
posts := make([]*post.Post, SitemapURLLimit+250)
|
||||
for i := range posts {
|
||||
meta := frontmatter.NewMeta()
|
||||
meta.Set("slug", fmt.Sprintf("post-%d", i))
|
||||
posts[i] = post.New(meta, "body")
|
||||
}
|
||||
out := RenderSitemap(posts, "https://site.example")
|
||||
if got := strings.Count(out, "<url>"); got != SitemapURLLimit {
|
||||
t.Fatalf("sitemap holds %d URLs, want %d", got, SitemapURLLimit)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user