Commit881ed2aaRecorded3 Apr 2026Repositorysigil-rss

Add sigil-rss library with RSS 2.0 and Atom feed parsing

Message

Implements three modules: - (sigil rss types): feed and feed-entry structs - (sigil rss parser): XML parsing for RSS 2.0 and Atom with auto-detection - (sigil rss fetcher): HTTP feed fetching via sigil-http

Includes parser tests covering both formats, missing field handling, and format auto-detection. All 8 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

Changed
 .gitignore                     |   1 +
 .sigil/deps/sigil-crypto       |   1 +
 .sigil/deps/sigil-http         |   1 +
 .sigil/deps/sigil-socket       |   1 +
 .sigil/deps/sigil-stdlib       |   1 +
 .sigil/deps/sigil-sxml         |   1 +
 .sigil/deps/sigil-tls          |   1 +
 package.sgl                    |  25 +++++++++++++++++++++++++
 sigil.lock                     |  18 ++++++++++++++++++
 src/sigil/rss/fetcher.sgl      |  32 ++++++++++++++++++++++++++++++++
 src/sigil/rss/parser.sgl       | 131 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/sigil/rss/types.sgl        |  46 ++++++++++++++++++++++++++++++++++++++++++++++
 test/sigil/rss/parser-test.sgl | 147 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 13 files changed, 406 insertions(+)
Diff
.gitignoreadded
@@ -0,0 +1 @@
+1
build/
.sigil/deps/sigil-cryptoadded
@@ -0,0 +1 @@
+1
/home/daviwil/.sigil/deps/codeberg-sigil-sigil-927e028e3d13866a702b87d71ff8f2d09a36d830/packages/sigil-crypto
2
No newline at end of file
.sigil/deps/sigil-httpadded
@@ -0,0 +1 @@
+1
/home/daviwil/.sigil/deps/codeberg-sigil-sigil-927e028e3d13866a702b87d71ff8f2d09a36d830/packages/sigil-http
2
No newline at end of file
.sigil/deps/sigil-socketadded
@@ -0,0 +1 @@
+1
/home/daviwil/.sigil/deps/codeberg-sigil-sigil-927e028e3d13866a702b87d71ff8f2d09a36d830/packages/sigil-socket
2
No newline at end of file
.sigil/deps/sigil-stdlibadded
@@ -0,0 +1 @@
+1
/home/daviwil/.sigil/deps/codeberg-sigil-sigil-927e028e3d13866a702b87d71ff8f2d09a36d830/packages/sigil-stdlib
2
No newline at end of file
.sigil/deps/sigil-sxmladded
@@ -0,0 +1 @@
+1
/home/daviwil/.sigil/deps/codeberg-sigil-sigil-sxml-081b6337c28d2ae55cd7658002cd6bc5e79ea07b
2
No newline at end of file
.sigil/deps/sigil-tlsadded
@@ -0,0 +1 @@
+1
/home/daviwil/.sigil/deps/codeberg-sigil-sigil-927e028e3d13866a702b87d71ff8f2d09a36d830/packages/sigil-tls
2
No newline at end of file
package.sgladded
@@ -0,0 +1,25 @@
+1
;;; sigil-rss - RSS/Atom feed parsing library
+2
;;;
+3
;;; Provides feed fetching and parsing for RSS 2.0 and Atom formats,
+4
;;; normalizing entries into a common struct for downstream consumption.
+5
+6
(package
+7
name: "sigil-rss"
+8
version: "0.1.0"
+9
description: "RSS/Atom feed parsing library"
+10
url: "https://codeberg.org/sigil/sigil-rss"
+11
license: "BSD-3-Clause"
+12
authors: (list "David Wilson <[email protected]>")
+13
+14
dependencies: (list
+15
(from-git url: "codeberg:sigil/sigil" package: "sigil-stdlib" version: "^0.9.0")
+16
(from-git url: "codeberg:sigil/sigil" package: "sigil-http" version: "^0.9.0")
+17
(from-git url: "codeberg:sigil/sigil-sxml" version: "^0.9.0"))
+18
+19
tasks: (list
+20
(task
+21
name: 'build
+22
description: "Compile sigil-rss modules"
+23
steps: (list
+24
(compile-sigil-modules sources: "src/**/*.sgl"
+25
output-dir: (config-output-subdir "lib"))))))
sigil.lockadded
@@ -0,0 +1,18 @@
+1
;; Auto-generated by sigil deps install. Do not edit.
+2
(lock
+3
(package name: "sigil-stdlib"
+4
url: "codeberg:sigil/sigil"
+5
ref: "^0.9.0"
+6
sha: "0123d3fc7d2dd37e5c27e9fade7d11b817deb21a"
+7
package-selector: "sigil-stdlib")
+8
(package name: "sigil-http"
+9
url: "codeberg:sigil/sigil"
+10
ref: "^0.9.0"
+11
sha: "0123d3fc7d2dd37e5c27e9fade7d11b817deb21a"
+12
package-selector: "sigil-http")
+13
(package name: "sigil-sxml"
+14
url: "codeberg:sigil/sigil-sxml"
+15
ref: "^0.9.0"
+16
sha: "081b6337c28d2ae55cd7658002cd6bc5e79ea07b"
+17
version: "0.9.1")
+18
)
src/sigil/rss/fetcher.sgladded
@@ -0,0 +1,32 @@
+1
;;; (sigil rss fetcher) - HTTP feed fetching.
+2
;;;
+3
;;; Fetches RSS/Atom feeds over HTTP and parses them into feed structs.
+4
+5
(define-library (sigil rss fetcher)
+6
(import (sigil core)
+7
(sigil http client)
+8
(sigil rss types)
+9
(sigil rss parser))
+10
+11
(export fetch-feed
+12
fetch-feeds)
+13
+14
(begin
+15
+16
;;; Fetch and parse a single feed from a URL.
+17
;;;
+18
;;; Returns a feed struct on success, or an empty feed on failure.
+19
(define (fetch-feed url)
+20
(let ((response (http-get url)))
+21
(if (and response
+22
(http-response? response)
+23
(= (http-response-status response) 200))
+24
(parse-feed (http-response-body response))
+25
(feed))))
+26
+27
;;; Fetch and parse multiple feeds from a list of URLs.
+28
;;;
+29
;;; Returns a list of feed structs, one per URL. Failed fetches
+30
;;; produce empty feed structs.
+31
(define (fetch-feeds urls)
+32
(map fetch-feed urls))))
src/sigil/rss/parser.sgladded
@@ -0,0 +1,131 @@
+1
;;; (sigil rss parser) - RSS 2.0 and Atom XML parsing.
+2
;;;
+3
;;; Parses XML strings into feed/feed-entry structs.
+4
;;; Auto-detects feed format via the root element tag.
+5
+6
(define-library (sigil rss parser)
+7
(import (sigil core)
+8
(sigil string)
+9
(sigil sxml)
+10
(sigil sxml reader)
+11
(sigil rss types))
+12
+13
(export parse-rss
+14
parse-atom
+15
parse-feed)
+16
+17
(begin
+18
+19
;;; Get the text content of a child element by tag name.
+20
;;; Returns empty string if the child is not found.
+21
(define (child-text sxml tag)
+22
(let loop ((items (sxml-content sxml)))
+23
(cond
+24
((null? items) "")
+25
((and (sxml-element? (car items))
+26
(eq? (sxml-tag (car items)) tag))
+27
(let ((content (sxml-content (car items))))
+28
(if (and (pair? content) (string? (car content)))
+29
(car content)
+30
"")))
+31
(else (loop (cdr items))))))
+32
+33
;;; Find a child element by tag name.
+34
;;; Returns #f if not found.
+35
(define (find-child sxml tag)
+36
(let loop ((items (sxml-content sxml)))
+37
(cond
+38
((null? items) #f)
+39
((and (sxml-element? (car items))
+40
(eq? (sxml-tag (car items)) tag))
+41
(car items))
+42
(else (loop (cdr items))))))
+43
+44
;;; Find all child elements with a given tag name.
+45
(define (find-children sxml tag)
+46
(let loop ((items (sxml-content sxml)) (result '()))
+47
(cond
+48
((null? items) (reverse result))
+49
((and (sxml-element? (car items))
+50
(eq? (sxml-tag (car items)) tag))
+51
(loop (cdr items) (cons (car items) result)))
+52
(else (loop (cdr items) result)))))
+53
+54
;;; Parse a single RSS 2.0 <item> into a feed-entry.
+55
(define (parse-rss-item item feed-title)
+56
(feed-entry
+57
title: (child-text item 'title)
+58
link: (child-text item 'link)
+59
date: (child-text item 'pubDate)
+60
summary: (child-text item 'description)
+61
author: (child-text item 'author)
+62
id: (child-text item 'guid)
+63
feed-title: feed-title))
+64
+65
;;; Parse an RSS 2.0 XML string into a feed struct.
+66
;;;
+67
;;; Expects the standard RSS 2.0 structure with <rss><channel>...</channel></rss>.
+68
(define (parse-rss xml-string)
+69
(let ((sxml (xml->sxml xml-string)))
+70
(if (not sxml)
+71
(feed format: 'rss)
+72
(let ((channel (or (find-child sxml 'channel)
+73
sxml)))
+74
(let ((title (child-text channel 'title)))
+75
(feed
+76
title: title
+77
link: (child-text channel 'link)
+78
description: (child-text channel 'description)
+79
entries: (map (lambda (item) (parse-rss-item item title))
+80
(find-children channel 'item))
+81
format: 'rss))))))
+82
+83
;;; Get the href attribute from an Atom <link> element.
+84
(define (atom-link-href link-elem)
+85
(or (sxml-attr-ref link-elem 'href) ""))
+86
+87
;;; Parse a single Atom <entry> into a feed-entry.
+88
(define (parse-atom-entry entry feed-title)
+89
(let ((author-elem (find-child entry 'author)))
+90
(feed-entry
+91
title: (child-text entry 'title)
+92
link: (let ((link-elem (find-child entry 'link)))
+93
(if link-elem (atom-link-href link-elem) ""))
+94
date: (child-text entry 'updated)
+95
summary: (child-text entry 'summary)
+96
author: (if author-elem (child-text author-elem 'name) "")
+97
id: (child-text entry 'id)
+98
feed-title: feed-title)))
+99
+100
;;; Parse an Atom XML string into a feed struct.
+101
;;;
+102
;;; Expects the standard Atom structure with <feed>...</feed>.
+103
(define (parse-atom xml-string)
+104
(let ((sxml (xml->sxml xml-string)))
+105
(if (not sxml)
+106
(feed format: 'atom)
+107
(let ((title (child-text sxml 'title)))
+108
(feed
+109
title: title
+110
link: (let ((link-elem (find-child sxml 'link)))
+111
(if link-elem (atom-link-href link-elem) ""))
+112
description: (child-text sxml 'subtitle)
+113
entries: (map (lambda (entry) (parse-atom-entry entry title))
+114
(find-children sxml 'entry))
+115
format: 'atom)))))
+116
+117
;;; Parse a feed XML string, auto-detecting format.
+118
;;;
+119
;;; Detects RSS 2.0 (root tag 'rss) vs Atom (root tag 'feed)
+120
;;; and dispatches to the appropriate parser.
+121
(define (parse-feed xml-string)
+122
(let ((sxml (xml->sxml xml-string)))
+123
(if (not sxml)
+124
(feed)
+125
(let ((tag (sxml-tag sxml)))
+126
(cond
+127
((eq? tag 'rss) (parse-rss xml-string))
+128
((eq? tag 'feed) (parse-atom xml-string))
+129
;; Some feeds use rdf:RDF as root
+130
((eq? tag 'rdf:RDF) (parse-rss xml-string))
+131
(else (feed)))))))))
src/sigil/rss/types.sgladded
@@ -0,0 +1,46 @@
+1
;;; (sigil rss types) - Common feed and entry data types.
+2
;;;
+3
;;; Defines the normalized structs used throughout sigil-rss.
+4
;;; Both RSS 2.0 and Atom feeds are parsed into these common types.
+5
+6
(define-library (sigil rss types)
+7
(import (sigil core)
+8
(sigil struct))
+9
+10
(export feed-entry
+11
feed-entry?
+12
feed-entry-title
+13
feed-entry-link
+14
feed-entry-date
+15
feed-entry-summary
+16
feed-entry-author
+17
feed-entry-id
+18
feed-entry-feed-title
+19
+20
feed
+21
feed?
+22
feed-title
+23
feed-link
+24
feed-description
+25
feed-entries
+26
feed-format)
+27
+28
(begin
+29
+30
;;; A single feed entry, normalized from either RSS or Atom.
+31
(define-struct feed-entry
+32
(title default: "")
+33
(link default: "")
+34
(date default: "")
+35
(summary default: "")
+36
(author default: "")
+37
(id default: "")
+38
(feed-title default: ""))
+39
+40
;;; A parsed feed with metadata and entries.
+41
(define-struct feed
+42
(title default: "")
+43
(link default: "")
+44
(description default: "")
+45
(entries default: '())
+46
(format default: 'unknown))))
test/sigil/rss/parser-test.sgladded
@@ -0,0 +1,147 @@
+1
;;; Tests for (sigil rss parser)
+2
+3
(import (sigil test)
+4
(sigil rss types)
+5
(sigil rss parser))
+6
+7
(define sample-rss
+8
(string-append
+9
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
+10
"<rss version=\"2.0\">"
+11
"<channel>"
+12
"<title>Test Blog</title>"
+13
"<link>https://example.com</link>"
+14
"<description>A test blog feed</description>"
+15
"<item>"
+16
"<title>First Post</title>"
+17
"<link>https://example.com/first</link>"
+18
"<pubDate>Mon, 01 Jan 2024 00:00:00 GMT</pubDate>"
+19
"<description>The first post summary</description>"
+20
"<author>[email protected]</author>"
+21
"<guid>https://example.com/first</guid>"
+22
"</item>"
+23
"<item>"
+24
"<title>Second Post</title>"
+25
"<link>https://example.com/second</link>"
+26
"<pubDate>Tue, 02 Jan 2024 00:00:00 GMT</pubDate>"
+27
"<description>The second post summary</description>"
+28
"<author>[email protected]</author>"
+29
"<guid>https://example.com/second</guid>"
+30
"</item>"
+31
"</channel>"
+32
"</rss>"))
+33
+34
(define sample-atom
+35
(string-append
+36
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
+37
"<feed xmlns=\"http://www.w3.org/2005/Atom\">"
+38
"<title>Atom Blog</title>"
+39
"<link href=\"https://atom.example.com\"/>"
+40
"<subtitle>An Atom feed</subtitle>"
+41
"<entry>"
+42
"<title>Atom Entry One</title>"
+43
"<link href=\"https://atom.example.com/one\"/>"
+44
"<updated>2024-01-01T00:00:00Z</updated>"
+45
"<summary>First atom entry</summary>"
+46
"<author><name>Alice</name></author>"
+47
"<id>urn:uuid:entry-one</id>"
+48
"</entry>"
+49
"<entry>"
+50
"<title>Atom Entry Two</title>"
+51
"<link href=\"https://atom.example.com/two\"/>"
+52
"<updated>2024-01-02T00:00:00Z</updated>"
+53
"<summary>Second atom entry</summary>"
+54
"<author><name>Bob</name></author>"
+55
"<id>urn:uuid:entry-two</id>"
+56
"</entry>"
+57
"</feed>"))
+58
+59
(define sample-minimal-rss
+60
(string-append
+61
"<rss version=\"2.0\">"
+62
"<channel>"
+63
"<title>Minimal Feed</title>"
+64
"<item>"
+65
"<title>Only Title</title>"
+66
"</item>"
+67
"</channel>"
+68
"</rss>"))
+69
+70
;; ============================================================
+71
;; RSS 2.0 parsing
+72
;; ============================================================
+73
+74
(test-group "RSS 2.0 parsing"
+75
(test "parses feed metadata"
+76
(let ((f (parse-rss sample-rss)))
+77
(assert-equal "Test Blog" (feed-title f))
+78
(assert-equal "https://example.com" (feed-link f))
+79
(assert-equal "A test blog feed" (feed-description f))
+80
(assert-equal 'rss (feed-format f))))
+81
+82
(test "parses RSS items"
+83
(let* ((f (parse-rss sample-rss))
+84
(entries (feed-entries f)))
+85
(assert-equal 2 (length entries))
+86
(let ((first (car entries)))
+87
(assert-equal "First Post" (feed-entry-title first))
+88
(assert-equal "https://example.com/first" (feed-entry-link first))
+89
(assert-equal "Mon, 01 Jan 2024 00:00:00 GMT" (feed-entry-date first))
+90
(assert-equal "The first post summary" (feed-entry-summary first))
+91
(assert-equal "[email protected]" (feed-entry-author first))
+92
(assert-equal "https://example.com/first" (feed-entry-id first))
+93
(assert-equal "Test Blog" (feed-entry-feed-title first)))))
+94
+95
(test "handles missing fields gracefully"
+96
(let* ((f (parse-rss sample-minimal-rss))
+97
(entries (feed-entries f))
+98
(entry (car entries)))
+99
(assert-equal "Only Title" (feed-entry-title entry))
+100
(assert-equal "" (feed-entry-link entry))
+101
(assert-equal "" (feed-entry-date entry))
+102
(assert-equal "" (feed-entry-summary entry))
+103
(assert-equal "" (feed-entry-author entry)))))
+104
+105
;; ============================================================
+106
;; Atom parsing
+107
;; ============================================================
+108
+109
(test-group "Atom parsing"
+110
(test "parses feed metadata"
+111
(let ((f (parse-atom sample-atom)))
+112
(assert-equal "Atom Blog" (feed-title f))
+113
(assert-equal "https://atom.example.com" (feed-link f))
+114
(assert-equal "An Atom feed" (feed-description f))
+115
(assert-equal 'atom (feed-format f))))
+116
+117
(test "parses Atom entries"
+118
(let* ((f (parse-atom sample-atom))
+119
(entries (feed-entries f)))
+120
(assert-equal 2 (length entries))
+121
(let ((first (car entries)))
+122
(assert-equal "Atom Entry One" (feed-entry-title first))
+123
(assert-equal "https://atom.example.com/one" (feed-entry-link first))
+124
(assert-equal "2024-01-01T00:00:00Z" (feed-entry-date first))
+125
(assert-equal "First atom entry" (feed-entry-summary first))
+126
(assert-equal "Alice" (feed-entry-author first))
+127
(assert-equal "urn:uuid:entry-one" (feed-entry-id first))
+128
(assert-equal "Atom Blog" (feed-entry-feed-title first))))))
+129
+130
;; ============================================================
+131
;; Auto-detection
+132
;; ============================================================
+133
+134
(test-group "Auto-detection"
+135
(test "detects RSS format"
+136
(let ((f (parse-feed sample-rss)))
+137
(assert-equal 'rss (feed-format f))
+138
(assert-equal "Test Blog" (feed-title f))))
+139
+140
(test "detects Atom format"
+141
(let ((f (parse-feed sample-atom)))
+142
(assert-equal 'atom (feed-format f))
+143
(assert-equal "Atom Blog" (feed-title f))))
+144
+145
(test "returns empty feed for invalid XML"
+146
(let ((f (parse-feed "")))
+147
(assert-equal 'unknown (feed-format f)))))