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 filepackage.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)))))