add RSS generation
arnaucube git@arnaucube.com
Fri, 31 Jul 2026 21:57:45 +0200
5 files changed,
197 insertions(+),
1 deletions(-)
M
README.md
→
README.md
@@ -18,6 +18,8 @@ Which will re-generate the html files each time that a input file is modified, and will serve the html generated site at `http://127.0.0.1:8080`.
For a single use, you can simply use `blogo` command, which will generate the html files without serving them. +Each build also generates an RSS 2.0 feed at `rss.xml` and adds an RSS auto-discovery link to generated HTML pages. Posts may include an optional `date` in RFC 3339 or `YYYY-MM-DD` format. Posts without a date remain in the feed without a `pubDate`. + A complete usage example can be found in this repo: https://github.com/arnaucube/blogoExample ### Config example@@ -33,7 +35,8 @@ {
"thumb": "article0_thumb.md", "md": "article0.md", "metaimg": "img/article0-img.png", - "metadescr": "description of the article 0" + "metadescr": "description of the article 0", + "date": "2022-11-24" } ], "copyRaw": [
M
main.go
→
main.go
@@ -81,6 +81,7 @@ m["[blogo-summary]"] = config.MetaDescr
m["[blogo-img]"] = config.AbsoluteUrl + "/" + config.MetaImg m["[blogo-link]"] = config.AbsoluteUrl r := putHTMLToTemplate(indexTemplate, m) + r = addRSSDiscovery(r) writeFile(config.OutputDir+"/"+"index.html", r) // generate posts pages@@ -106,8 +107,11 @@ m["[blogo-link]"] = config.AbsoluteUrl + "/" + filename + ".html"
m["[blogo-img]"] = config.AbsoluteUrl + "/" + post.MetaImg r := putHTMLToTemplate(indexTemplate, m) + r = addRSSDiscovery(r) writeFile(config.OutputDir+"/"+filename+".html", r) } + + generateRSS() //copy raw fmt.Println("copying raw:")
M
readConfig.go
→
readConfig.go
@@ -11,6 +11,7 @@ Thumb string `json:"thumb"`
Md string `json:"md"` MetaImg string `json:"metaimg"` MetaDescr string `json:"metadescr"` + Date string `json:"date"` OutsideArticle string `json:"outsideArticle"` }
A
rss.go
@@ -0,0 +1,141 @@
+package main + +import ( + "bytes" + "encoding/xml" + "fmt" + "html" + "log" + "strings" + "time" +) + +const rssFilename = "rss.xml" + +type rssDocument struct { + XMLName xml.Name `xml:"rss"` + Version string `xml:"version,attr"` + Channel rssChannel `xml:"channel"` +} + +type rssChannel struct { + Title string `xml:"title"` + Link string `xml:"link"` + Description string `xml:"description"` + Items []rssItem `xml:"item"` +} + +type rssItem struct { + Title string `xml:"title"` + Link string `xml:"link"` + Description string `xml:"description,omitempty"` + PubDate string `xml:"pubDate,omitempty"` + GUID rssGUID `xml:"guid"` +} + +type rssGUID struct { + IsPermaLink bool `xml:"isPermaLink,attr"` + Value string `xml:",chardata"` +} + +func generateRSS() { + items := make([]rssItem, 0, len(config.Posts)) + for _, post := range config.Posts { + source := post.Md + if post.OutsideArticle != "" || source == "" { + source = post.Thumb + } + + title := markdownTitle(readFile(directory + "/" + config.PostsDir + source)) + link := post.OutsideArticle + if link == "" { + filename := strings.Split(post.Md, ".")[0] + ".html" + link = joinURL(config.AbsoluteUrl, filename) + } + + pubDate, err := rssDate(post.Date) + if err != nil { + log.Printf("invalid RSS date for %s: %v", source, err) + } + + items = append(items, rssItem{ + Title: title, + Link: link, + Description: post.MetaDescr, + PubDate: pubDate, + GUID: rssGUID{IsPermaLink: true, Value: link}, + }) + } + + document := rssDocument{ + Version: "2.0", + Channel: rssChannel{ + Title: config.Title, + Link: joinURL(config.AbsoluteUrl), + Description: config.MetaDescr, + Items: items, + }, + } + + var output bytes.Buffer + output.WriteString(xml.Header) + encoder := xml.NewEncoder(&output) + encoder.Indent("", " ") + check(encoder.Encode(document)) + writeFile(config.OutputDir+"/"+rssFilename, output.String()) +} + +func addRSSDiscovery(document string) string { + feedURL := joinURL(config.AbsoluteUrl, rssFilename) + tag := fmt.Sprintf(`<link rel="alternate" type="application/rss+xml" title="%s RSS" href="%s">`, + html.EscapeString(config.Title), html.EscapeString(feedURL)) + if strings.Contains(document, tag) { + return document + } + + headEnd := strings.Index(strings.ToLower(document), "</head>") + if headEnd == -1 { + return document + } + return document[:headEnd] + tag + "\n" + document[headEnd:] +} + +func markdownTitle(markdown string) string { + firstLine := strings.SplitN(markdown, "\n", 2)[0] + return strings.TrimSpace(strings.TrimLeft(firstLine, "#")) +} + +func joinURL(parts ...string) string { + if len(parts) == 0 { + return "" + } + + joined := strings.TrimRight(parts[0], "/") + for _, part := range parts[1:] { + part = strings.Trim(part, "/") + if part == "" { + continue + } + if joined == "" { + joined = "/" + part + } else { + joined += "/" + part + } + } + return joined +} + +func rssDate(value string) (string, error) { + if value == "" { + return "", nil + } + + formats := []string{time.RFC3339, "2006-01-02"} + for _, format := range formats { + parsed, err := time.Parse(format, value) + if err == nil { + return parsed.Format(time.RFC1123Z), nil + } + } + return "", fmt.Errorf("%q must use RFC 3339 or YYYY-MM-DD format", value) +}
A
rss_test.go
@@ -0,0 +1,47 @@
+package main + +import ( + "strings" + "testing" +) + +func TestJoinURL(t *testing.T) { + got := joinURL("https://example.com/", "/blog/", "post.html") + if got != "https://example.com/blog/post.html" { + t.Fatalf("unexpected URL: %s", got) + } +} + +func TestRSSDate(t *testing.T) { + got, err := rssDate("2026-07-31") + if err != nil { + t.Fatal(err) + } + if got != "Fri, 31 Jul 2026 00:00:00 +0000" { + t.Fatalf("unexpected RSS date: %s", got) + } + + if _, err := rssDate("July 31"); err == nil { + t.Fatal("expected invalid date to return an error") + } +} + +func TestMarkdownTitle(t *testing.T) { + if got := markdownTitle("## A post title\n\nBody"); got != "A post title" { + t.Fatalf("unexpected title: %s", got) + } +} + +func TestAddRSSDiscovery(t *testing.T) { + oldConfig := config + config = Config{Title: "Example & Blog", AbsoluteUrl: "https://example.com", RelativePath: "/notes"} + defer func() { config = oldConfig }() + + got := addRSSDiscovery("<html><head></head><body></body></html>") + if !strings.Contains(got, `rel="alternate"`) || !strings.Contains(got, `href="https://example.com/notes/rss.xml"`) { + t.Fatalf("RSS discovery link missing: %s", got) + } + if !strings.Contains(got, `title="Example & Blog RSS"`) { + t.Fatalf("RSS title was not escaped: %s", got) + } +}