Commite60c819dRecorded22 Sep 2026Repositorypress

Add (press feeds): RSS 2.0 and JSON Feed 1.1 for matched content

Message

A pipeline stage, with-feeds, that writes a feed of a site's dated content as two synthetic assets with their own producers (the shape generate-docs-css uses, so attach-asset-producers keeps them and execute-changed rebuilds them on every incremental build): RSS 2.0 with the Atom self link, dc:creator and RFC 822 dates, and JSON Feed 1.1 with authors, RFC 3339 dates and the summary: front matter when a post has one. The entries are the matched items that carry a date:, newest first, at most limit: (20); each has its absolute URL (the site's base-url: plus its canonical path, /index.html dropped) and its rendered body with the site's transforms applied, so a Markdown link resolves to its output path, and a leading top-level heading left out (it is the title). The RFC 822 weekday is Sakamoto's calendar arithmetic, not a timestamp: the stdlib's time-weekday reads the system zone, which moves a midnight date a day west of Greenwich. Re-exported from (press).

The pieces (feed-items, feed-item-url, feed-item-html, rss-string, json-feed-string, rfc-822-date, rfc-3339-date) are exported for tests and custom feeds; test/test-feeds.sgl covers the dates, the selection and order, the URLs, the transforms, both documents' shape and escaping, the stage's assets and the files they write (13 tests; the gate's legacy count moves from 45 to 58). README, docs/publish.md and the CHANGELOG describe the stage. Proven end to end on a scratch site through a dev-redirects.sgl pointing at this checkout.

Changed
 CHANGELOG.md        |   6 ++++
 README.md           |  29 ++++++++++++++++
 docs/publish.md     |  35 +++++++++++++++++++
 src/press.sgl       |   4 +++
 src/press/feeds.sgl | 316 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 test/gate-d4b.sh    |   6 ++--
 test/test-feeds.sgl | 150 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 7 files changed, 543 insertions(+), 3 deletions(-)
Diff
CHANGELOG.mdmodified
@@ -7,6 +7,12 @@ All notable changes to this project will be documented in this file.
7
The format is based on [Keep a Changelog](https://keepachangelog.com/),
8
and this project adheres to [Semantic Versioning](https://semver.org/).
9
+10
## [0.1.6] - 2026-09-22
+11
+12
### Added
+13
+14
- Added `(press feeds)` and its `with-feeds` stage: RSS 2.0 (with the Atom self link) and JSON Feed 1.1 for matched content, newest first by `date:`, absolute URLs from `base-url:`, rendered bodies with the site's transforms applied, RFC 822 and RFC 3339 dates.
+15
16
## [0.6.0] - 2026-02-22
17
18
### Added
README.mdmodified
@@ -62,6 +62,35 @@ Available stages:
62
| `attach-asset-producers` | Set up asset copying |
63
| `rewrite-links` | Resolve internal links |
64
| `syntax-highlight` | Apply syntax highlighting to code blocks |
+65
| `with-feeds` | Write RSS 2.0 and JSON Feed 1.1 for matched content |
+66
+67
## Feeds
+68
+69
`with-feeds` writes an RSS 2.0 feed and a JSON Feed 1.1 for the content it
+70
matches, newest first by the `date:` front matter, with absolute URLs built
+71
from the site's `base-url:` and each entry's rendered body:
+72
+73
```scheme
+74
(import (press)
+75
(press generate))
+76
+77
(run-publish
+78
(site title: "My Blog"
+79
base-url: "https://example.com"
+80
content: "posts/**/*.md"
+81
pipeline: (lambda (stages)
+82
(append stages
+83
(list (with-feeds match: (matches-path? "/posts/")
+84
title: "My Blog"
+85
description: "Notes on what I build"
+86
rss: "/posts/feed.xml"
+87
json: "/posts/feed.json"
+88
author: "Me"))))))
+89
```
+90
+91
Pass `rss: #f` or `json: #f` to write one format only; `limit:` caps the
+92
entries (20 by default). A post's `summary:` front matter becomes the JSON
+93
Feed entry's summary.
94
95
## API documentation
96
docs/publish.mdmodified
@@ -16,6 +16,7 @@ The main module re-exports from several submodules:
16
| `(press stages)` | Filtering, navigation, indexing |
17
| `(press render)` | Content/asset producers, themes |
18
| `(press generate)` | Collection pages, SXML pages |
+19
| `(press feeds)` | RSS 2.0 and JSON Feed 1.1 |
20
| `(press transform)`| Syntax highlighting, link resolution |
21
| `(press dev-server)`| Development server with live reload |
22
@@ -242,6 +243,40 @@ Run a publish script with CLI subcommands.
243
(run-publish docs-site)
244
```
245
+246
### Blog with Feeds
+247
+248
`with-feeds` adds an RSS 2.0 feed and a JSON Feed 1.1 as synthetic assets.
+249
The entries are the matched items that carry a `date:`, newest first;
+250
each has the item's absolute URL (the site's `base-url:` plus its
+251
canonical path) and its rendered body with the site's transforms applied
+252
(a leading top-level heading, the title, is left out of the body).
+253
+254
```scheme
+255
(import (press)
+256
(press generate))
+257
+258
(define blog
+259
(site title: "Blog"
+260
base-url: "https://example.com"
+261
content: "posts/**/*.md"
+262
pipeline: (lambda (stages)
+263
(append stages
+264
(list (with-feeds match: (matches-path? "/posts/")
+265
description: "New posts"
+266
rss: "/posts/feed.xml"
+267
json: "/posts/feed.json"
+268
author: "Me"
+269
limit: 20))))))
+270
+271
(run-publish blog)
+272
```
+273
+274
`title:` defaults to the site's title and `base-url:` to the site's;
+275
`rss: #f` or `json: #f` leaves that format out; a post's `summary:`
+276
front matter becomes the JSON Feed entry's summary. `(press feeds)` also
+277
exports the pieces (`feed-items`, `rss-string`, `json-feed-string`,
+278
`rfc-822-date`, `rfc-3339-date`) for a custom feed.
+279
280
### Documentation Site with Syntax Highlighting
281
282
```scheme
src/press.sglmodified
@@ -36,6 +36,7 @@
36
(press stages)
37
(press render)
38
(press generate)
+39
(press feeds)
40
(press transform)
41
(press dev-server)
42
(press docs))
@@ -67,6 +68,9 @@
68
collection-page
69
sxml-page
70
+71
;; Feeds (re-export from feeds)
+72
with-feeds
+73
74
;; Theme support (re-export)
75
default-theme
76
sxml->html
src/press/feeds.sgladded
@@ -0,0 +1,316 @@
+1
;;; (press feeds) - RSS and JSON Feed generation
+2
;;;
+3
;;; A pipeline stage that writes a feed of a site's dated content: RSS 2.0
+4
;;; (with the Atom self link) and JSON Feed 1.1, either or both. The
+5
;;; matched items are sorted newest first by their `date:` front matter,
+6
;;; and each entry carries the item's absolute URL and its rendered body,
+7
;;; with the site's transforms applied so internal links resolve.
+8
;;;
+9
;;; ```scheme
+10
;;; (with-feeds match: (matches-path? "/posts/")
+11
;;; title: "My Blog"
+12
;;; description: "Notes on things I build"
+13
;;; rss: "/posts/feed.xml"
+14
;;; json: "/posts/feed.json")
+15
;;; ```
+16
;;;
+17
;;; The absolute URLs come from the site's `base-url:` (or the stage's own
+18
;;; `base-url:` keyword). The feeds are synthetic assets with their own
+19
;;; producers, so they are written with the rest of the output and rebuilt
+20
;;; on every incremental build.
+21
;;;
+22
;;; The pure builders `rss-string` and `json-feed-string` take a config, a
+23
;;; state and a list of items and answer the feed text, so a feed can be
+24
;;; tested, or written some other way, without the stage.
+25
+26
(define-library (press feeds)
+27
(import (sigil core)
+28
(sigil list)
+29
(sigil math)
+30
(sigil string)
+31
(sigil io)
+32
(sigil fs)
+33
(sigil path)
+34
(sigil time)
+35
(sigil sxml)
+36
(sigil json)
+37
(press core)
+38
(press scan)
+39
(press model))
+40
(export
+41
;; The pipeline stage
+42
with-feeds
+43
+44
;; The pieces, for tests and custom feeds
+45
feed-items
+46
feed-item-url
+47
feed-item-html
+48
rss-string
+49
json-feed-string
+50
rfc-822-date
+51
rfc-3339-date)
+52
+53
(begin
+54
+55
;; ============================================================
+56
;; The Stage
+57
;; ============================================================
+58
+59
;;; Create a pipeline stage that adds an RSS feed and/or a JSON Feed.
+60
;;;
+61
;;; Keywords:
+62
;;; - `match:` a predicate on items (see `has-tag?`, `matches-path?` in
+63
;;; (press generate)); every item when omitted
+64
;;; - `title:` the feed's title (the site's title when omitted)
+65
;;; - `description:` one line about the feed
+66
;;; - `base-url:` the site's absolute URL, e.g. "https://example.com"
+67
;;; (the config's `base-url:` when omitted)
+68
;;; - `rss:` the RSS output path, e.g. "/feed.xml" (#f for none)
+69
;;; - `json:` the JSON Feed output path, e.g. "/feed.json" (#f for none)
+70
;;; - `author:` the author's name, carried by every entry
+71
;;; - `language:` the RSS channel language (default "en")
+72
;;; - `limit:` at most this many entries, newest first (default 20)
+73
(define (with-feeds (keys: (match #f)
+74
(title #f)
+75
(description "")
+76
(base-url #f)
+77
(rss "/feed.xml")
+78
(json "/feed.json")
+79
(author #f)
+80
(language "en")
+81
(limit 20)))
+82
(lambda (config state)
+83
(let* ((output-dir (dict-ref config output-dir: "public"))
+84
(options #{title: title
+85
description: description
+86
base-url: base-url
+87
author: author
+88
language: language
+89
rss-path: rss
+90
json-path: json})
+91
(asset (lambda (path mime-type make-text)
+92
#{source-path: #f
+93
output-path: path
+94
mime-type: mime-type
+95
producer: (lambda ()
+96
(let* ((items (feed-items state match limit))
+97
(full-path (string-append output-dir path)))
+98
(ensure-directory (path-dirname full-path))
+99
(write-file-string full-path
+100
(make-text config state items options))
+101
path))}))
+102
(assets (append
+103
(if rss (list (asset rss "application/rss+xml" rss-string)) '())
+104
(if json (list (asset json "application/feed+json" json-feed-string)) '()))))
+105
(add-assets state assets))))
+106
+107
;; ============================================================
+108
;; Selecting Entries
+109
;; ============================================================
+110
+111
;;; The items a feed lists: those the predicate accepts (all when #f)
+112
;;; that carry a `date:`, newest first, at most `limit` of them.
+113
(define (feed-items state match limit)
+114
(let* ((items (state-items state))
+115
(matching (filter (lambda (item)
+116
(and (or (not match) (match item))
+117
(item-date item)))
+118
items))
+119
(sorted (list-sort (lambda (a b)
+120
(string>? (item-date a) (item-date b)))
+121
matching)))
+122
(take-at-most sorted limit)))
+123
+124
(define (take-at-most items n)
+125
(if (or (null? items) (and n (<= n 0)))
+126
'()
+127
(cons (car items) (take-at-most (cdr items) (and n (- n 1))))))
+128
+129
;; The item's date as the string its front matter gave, or #f.
+130
(define (item-date item)
+131
(let ((date (dict-ref (item-metadata item) date: #f)))
+132
(and date (format "~a" date))))
+133
+134
;; ============================================================
+135
;; URLs and Bodies
+136
;; ============================================================
+137
+138
;;; The item's absolute URL: the base URL and the item's canonical path
+139
;;; (an `index.html` output is its directory).
+140
(define (feed-item-url base-url item)
+141
(string-append (trim-slash base-url)
+142
(output-path->url (item-output-path item))))
+143
+144
(define (trim-slash url)
+145
(if (string-ends-with? url "/")
+146
(substring url 0 (- (string-length url) 1))
+147
url))
+148
+149
(define (output-path->url path)
+150
(if (string-ends-with? path "/index.html")
+151
(substring path 0 (- (string-length path) 10))
+152
path))
+153
+154
;;; The item's body as HTML, the config's transforms applied (so a
+155
;;; Markdown link to another page becomes its output path). A leading
+156
;;; top-level heading is left out: it is the entry's title, which a
+157
;;; reader shows on its own.
+158
(define (feed-item-html config state item)
+159
(let* ((raw (item-body item))
+160
(all (if (and (struct? raw) (document? raw))
+161
(document-children raw)
+162
raw))
+163
(children (if (and (pair? all) (pair? (car all)) (eq? (caar all) 'h1))
+164
(cdr all)
+165
all))
+166
(transforms (dict-ref config transforms: '()))
+167
(transformed (map (lambda (node)
+168
(apply-transforms node transforms config state item))
+169
children)))
+170
(apply string-append (map sxml->html transformed))))
+171
+172
;; Every transform over every node of the tree, children first (the
+173
;; shape (press render) uses for a page).
+174
(define (apply-transforms node transforms config state item)
+175
(fold-left (lambda (n transform) (walk n transform config state item))
+176
node
+177
transforms))
+178
+179
(define (walk node transform config state item)
+180
(if (pair? node)
+181
(let* ((tag (car node))
+182
(rest (cdr node))
+183
(has-attrs? (and (pair? rest) (pair? (car rest)) (eq? (caar rest) '@)))
+184
(attrs (if has-attrs? (cdar rest) '()))
+185
(children (if has-attrs? (cdr rest) rest))
+186
(walked (map (lambda (c) (walk c transform config state item)) children))
+187
(rebuilt (if has-attrs?
+188
`(,tag (@ ,@attrs) ,@walked)
+189
`(,tag ,@walked))))
+190
(transform config state item rebuilt))
+191
node))
+192
+193
;; ============================================================
+194
;; Dates
+195
;; ============================================================
+196
+197
;; A front-matter date ("2025-01-15", or with a time) as (y m d h mi s).
+198
(define (date-fields text)
+199
(parse-iso-date text))
+200
+201
(define WEEKDAYS (vector "Sun" "Mon" "Tue" "Wed" "Thu" "Fri" "Sat"))
+202
(define MONTHS (vector "Jan" "Feb" "Mar" "Apr" "May" "Jun"
+203
"Jul" "Aug" "Sep" "Oct" "Nov" "Dec"))
+204
+205
(define (pad2 n)
+206
(if (< n 10) (string-append "0" (number->string n)) (number->string n)))
+207
+208
;; The day of the week (0 = Sunday) of a civil date, by Sakamoto's
+209
;; method: arithmetic on the calendar, no timestamp and no time zone
+210
;; (the stdlib's time-weekday reads a timestamp in the system's zone,
+211
;; which would move a midnight date a day west of Greenwich).
+212
(define SAKAMOTO (vector 0 3 2 5 0 3 5 1 4 6 2 4))
+213
(define (weekday y m d)
+214
(let ((y (if (< m 3) (- y 1) y)))
+215
(modulo (+ y (quotient y 4) (- (quotient y 100)) (quotient y 400)
+216
(vector-ref SAKAMOTO (- m 1)) d)
+217
7)))
+218
+219
;;; The RFC 822 form RSS wants: "Wed, 15 Jan 2025 00:00:00 +0000".
+220
(define (rfc-822-date text)
+221
(let* ((f (date-fields text))
+222
(y (list-ref f 0)) (m (list-ref f 1)) (d (list-ref f 2))
+223
(h (list-ref f 3)) (mi (list-ref f 4)) (s (list-ref f 5))
+224
(weekday (weekday y m d)))
+225
(string-append (vector-ref WEEKDAYS weekday) ", "
+226
(pad2 d) " " (vector-ref MONTHS (- m 1)) " " (number->string y) " "
+227
(pad2 h) ":" (pad2 mi) ":" (pad2 s) " +0000")))
+228
+229
;;; The RFC 3339 form JSON Feed wants: "2025-01-15T00:00:00Z".
+230
(define (rfc-3339-date text)
+231
(format-iso-date (date-fields text)))
+232
+233
;; ============================================================
+234
;; RSS 2.0
+235
;; ============================================================
+236
+237
(define (option options key config-key config default)
+238
(or (dict-ref options key #f)
+239
(and config-key (dict-ref config config-key #f))
+240
default))
+241
+242
;;; The RSS 2.0 document for the items, as a string.
+243
(define (rss-string config state items options)
+244
(let* ((base-url (option options base-url: base-url: config "/"))
+245
(title (option options title: title: config "Feed"))
+246
(description (option options description: #f config ""))
+247
(language (option options language: #f config "en"))
+248
(author (dict-ref options author: #f))
+249
(self-path (dict-ref options rss-path: #f))
+250
(entry (lambda (item)
+251
(let ((url (feed-item-url base-url item)))
+252
`(item
+253
(title ,(item-title item))
+254
(link ,url)
+255
(guid (@ (isPermaLink "true")) ,url)
+256
(pubDate ,(rfc-822-date (item-date item)))
+257
,@(if author `((dc:creator ,author)) '())
+258
(description ,(feed-item-html config state item))))))
+259
(doc `(rss (@ (version "2.0")
+260
(xmlns:atom "http://www.w3.org/2005/Atom")
+261
(xmlns:dc "http://purl.org/dc/elements/1.1/"))
+262
(channel
+263
(title ,title)
+264
(link ,(string-append (trim-slash base-url) "/"))
+265
(description ,description)
+266
(language ,language)
+267
,@(if self-path
+268
`((atom:link (@ (href ,(string-append (trim-slash base-url) self-path))
+269
(rel "self")
+270
(type "application/rss+xml"))))
+271
'())
+272
,@(if (pair? items)
+273
`((lastBuildDate ,(rfc-822-date (item-date (car items)))))
+274
'())
+275
,@(map entry items)))))
+276
(string-append "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
+277
(sxml->xml doc)
+278
"\n")))
+279
+280
;; ============================================================
+281
;; JSON Feed 1.1
+282
;; ============================================================
+283
+284
;;; The JSON Feed 1.1 document for the items, as a string.
+285
(define (json-feed-string config state items options)
+286
(let* ((base-url (option options base-url: base-url: config "/"))
+287
(title (option options title: title: config "Feed"))
+288
(description (option options description: #f config ""))
+289
(language (option options language: #f config "en"))
+290
(author (dict-ref options author: #f))
+291
(self-path (dict-ref options json-path: #f))
+292
(authors (if author (list #{name: author}) '()))
+293
(entry (lambda (item)
+294
(let* ((url (feed-item-url base-url item))
+295
(summary (dict-ref (item-metadata item) summary: #f))
+296
(base #{id: url
+297
url: url
+298
title: (item-title item)
+299
date_published: (rfc-3339-date (item-date item))
+300
content_html: (feed-item-html config state item)}))
+301
(if summary
+302
(dict-set base summary: (format "~a" summary))
+303
base))))
+304
(feed (let ((head #{version: "https://jsonfeed.org/version/1.1"
+305
title: title
+306
home_page_url: (string-append (trim-slash base-url) "/")
+307
description: description
+308
language: language}))
+309
(let* ((with-self (if self-path
+310
(dict-set head feed_url: (string-append (trim-slash base-url) self-path))
+311
head))
+312
(with-authors (if (pair? authors)
+313
(dict-set with-self authors: authors)
+314
with-self)))
+315
(dict-set with-authors items: (map entry items))))))
+316
(string-append (json-encode feed indent: #t) "\n")))))
test/gate-d4b.shmodified
@@ -30,10 +30,10 @@ test ! -e "$repo_dir/.sigil"
30
echo D4B_GATE_00_FRESH_OUTPUT_AND_CACHE
31
32
"$sigil" "${legacy_load_paths[@]}" -L test test \
−33
test/test-basic.sgl test/test-docs.sgl test/test-functional.sgl --report \
+33
test/test-basic.sgl test/test-docs.sgl test/test-functional.sgl test/test-feeds.sgl --report \
34
| tee "$cache_dir/legacy.log"
−35
rg -q '45 passed' "$cache_dir/legacy.log"
−36
echo D4B_GATE_00B_LEGACY_PRESS_45_45
+35
rg -q '58 passed' "$cache_dir/legacy.log"
+36
echo D4B_GATE_00B_LEGACY_PRESS_58_58
37
38
"$sigil" "${load_paths[@]}" -L test test test/test-sigil-doc.sgl --report \
39
| tee "$cache_dir/adapter.log"
test/test-feeds.sgladded
@@ -0,0 +1,150 @@
+1
;;; test-feeds.sgl - Tests for (press feeds)
+2
;;;
+3
;;; The feed stage over an in-memory state: the entries it selects and
+4
;;; their order, the two documents it writes (RSS 2.0 and JSON Feed 1.1),
+5
;;; the dates in each format, the escaping, the absolute URLs, and the
+6
;;; transforms applied to a body.
+7
+8
(import (sigil core)
+9
(sigil string)
+10
(sigil fs)
+11
(sigil io)
+12
(sigil json)
+13
(sigil test)
+14
(press core)
+15
(press scan)
+16
(press model)
+17
(press generate)
+18
(press feeds))
+19
+20
(define (post title path date body . rest)
+21
(let ((summary (if (pair? rest) (car rest) #f)))
+22
#{source-path: (string-append "content" path ".md")
+23
output-path: (string-append path "/index.html")
+24
title: title
+25
metadata: (if summary
+26
#{date: date summary: summary}
+27
#{date: date})
+28
format: 'markdown
+29
body-loader: (lambda () (document metadata: #{} children: body))}))
+30
+31
(define config #{title: "Site" base-url: "https://example.com/" output-dir: "/tmp/press-feeds-test"})
+32
+33
(define posts
+34
(list (post "Second & last" "/posts/second" "2025-02-10" '((p "Two " (b "bold") " & more")))
+35
(post "First" "/posts/first" "2025-01-15" '((p "One") (p (a (@ (href "../about.md")) "about"))) "the first one")
+36
#{source-path: "content/about.md" output-path: "/about/index.html" title: "About"
+37
metadata: #{} format: 'markdown
+38
body-loader: (lambda () (document metadata: #{} children: '((p "no date, never listed"))))}
+39
(post "Elsewhere" "/notes/x" "2025-03-01" '((p "not a post")))))
+40
+41
(define state (add-items (initial-state) posts))
+42
+43
(define options #{title: "Posts" description: "the posts" base-url: #f author: "Ann"
+44
language: "en" rss-path: "/posts/feed.xml" json-path: "/posts/feed.json"})
+45
+46
(test-group "feeds"
+47
+48
(test-group "dates"
+49
(test "RFC 822 from a date, the weekday by the calendar"
+50
(assert-equal "Wed, 15 Jan 2025 00:00:00 +0000" (rfc-822-date "2025-01-15"))
+51
(assert-equal "Sun, 01 Mar 2026 00:00:00 +0000" (rfc-822-date "2026-03-01"))
+52
(assert-equal "Sat, 29 Feb 2020 13:05:09 +0000" (rfc-822-date "2020-02-29T13:05:09Z")))
+53
(test "RFC 3339 from a date"
+54
(assert-equal "2025-01-15T00:00:00Z" (rfc-3339-date "2025-01-15"))
+55
(assert-equal "2020-02-29T13:05:09Z" (rfc-3339-date "2020-02-29T13:05:09Z"))))
+56
+57
(test-group "entries"
+58
(test "the matched, dated items, newest first, at most the limit"
+59
(let ((items (feed-items state (matches-path? "/posts/") 20)))
+60
(assert-equal '("Second & last" "First") (map item-title items)))
+61
(let ((items (feed-items state #f 2)))
+62
(assert-equal '("Elsewhere" "Second & last") (map item-title items)))
+63
(assert-equal '() (feed-items state (matches-path? "/nowhere/") 20)))
+64
(test "the absolute URL drops index.html and doubles no slash"
+65
(assert-equal "https://example.com/posts/first/"
+66
(feed-item-url "https://example.com/" (cadr posts)))
+67
(assert-equal "https://example.com/posts/first/"
+68
(feed-item-url "https://example.com" (cadr posts))))
+69
(test "the body is HTML with the config's transforms applied"
+70
(let* ((cfg (dict-set config transforms:
+71
(list (lambda (c s item node)
+72
(if (and (pair? node) (eq? (car node) 'a))
+73
`(a (@ (href "/about/")) ,@(cddr node))
+74
node)))))
+75
(html (feed-item-html cfg state (cadr posts))))
+76
(assert-equal "<p>One</p><p><a href=\"/about/\">about</a></p>" html)))
+77
(test "a leading top-level heading (the title) is left out of the body"
+78
(let ((item (post "Titled" "/posts/t" "2025-04-01" '((h1 "Titled") (p "body") (h1 "Not first")))))
+79
(assert-equal "<p>body</p><h1>Not first</h1>" (feed-item-html config state item)))))
+80
+81
(test-group "rss"
+82
(let* ((items (feed-items state (matches-path? "/posts/") 20))
+83
(text (rss-string config state items options)))
+84
(test "a 2.0 document with the channel, the self link and the entries"
+85
(assert-true (string-starts-with? text "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<rss version=\"2.0\""))
+86
(assert-true (string-contains? text "<channel><title>Posts</title><link>https://example.com/</link><description>the posts</description><language>en</language>"))
+87
(assert-true (string-contains? text "<atom:link href=\"https://example.com/posts/feed.xml\" rel=\"self\" type=\"application/rss+xml\""))
+88
(assert-true (string-contains? text "<lastBuildDate>Mon, 10 Feb 2025 00:00:00 +0000</lastBuildDate>"))
+89
(assert-true (string-contains? text "<item><title>Second &amp; last</title><link>https://example.com/posts/second/</link><guid isPermaLink=\"true\">https://example.com/posts/second/</guid><pubDate>Mon, 10 Feb 2025 00:00:00 +0000</pubDate><dc:creator>Ann</dc:creator>"))
+90
(assert-true (string-contains? text "<item><title>First</title>")))
+91
(test "the body is escaped text, not markup"
+92
(assert-true (string-contains? text "<description>&lt;p&gt;Two &lt;b&gt;bold&lt;/b&gt; &amp;amp; more&lt;/p&gt;</description>"))
+93
(assert-false (string-contains? text "<description><p>")))
+94
(test "the newest entry comes first"
+95
(assert-true (< (string-find text "<title>Second &amp; last</title>")
+96
(string-find text "<title>First</title>"))))))
+97
+98
(test-group "json-feed"
+99
(let* ((items (feed-items state (matches-path? "/posts/") 20))
+100
(text (json-feed-string config state items options))
+101
(feed (json-decode text)))
+102
(test "a 1.1 document with the head and the entries"
+103
(assert-equal "https://jsonfeed.org/version/1.1" (dict-ref feed version:))
+104
(assert-equal "Posts" (dict-ref feed title:))
+105
(assert-equal "https://example.com/" (dict-ref feed home_page_url:))
+106
(assert-equal "https://example.com/posts/feed.json" (dict-ref feed feed_url:))
+107
(assert-equal "the posts" (dict-ref feed description:))
+108
(assert-equal "Ann" (dict-ref (car (array->list (dict-ref feed authors:))) name:))
+109
(assert-equal 2 (length (array->list (dict-ref feed items:)))))
+110
(test "each entry: id and url, title, the RFC 3339 date, the HTML body, the summary when given"
+111
(let ((second (car (array->list (dict-ref feed items:))))
+112
(first (cadr (array->list (dict-ref feed items:)))))
+113
(assert-equal "https://example.com/posts/second/" (dict-ref second id:))
+114
(assert-equal "https://example.com/posts/second/" (dict-ref second url:))
+115
(assert-equal "Second & last" (dict-ref second title:))
+116
(assert-equal "2025-02-10T00:00:00Z" (dict-ref second date_published:))
+117
(assert-equal "<p>Two <b>bold</b> &amp; more</p>" (dict-ref second content_html:))
+118
(assert-false (dict-ref second summary: #f))
+119
(assert-equal "the first one" (dict-ref first summary:))))))
+120
+121
(test-group "stage"
+122
(test "with-feeds adds the two assets and their producers write the files"
+123
(let* ((dir "/tmp/press-feeds-test")
+124
(stage (with-feeds match: (matches-path? "/posts/")
+125
title: "Posts"
+126
description: "the posts"
+127
rss: "/posts/feed.xml"
+128
json: "/posts/feed.json"
+129
author: "Ann"))
+130
(result (stage config state))
+131
(assets (state-assets result)))
+132
(assert-equal 2 (length assets))
+133
(assert-equal '("/posts/feed.xml" "/posts/feed.json") (map (lambda (a) (dict-ref a output-path:)) assets))
+134
(assert-equal '("application/rss+xml" "application/feed+json") (map (lambda (a) (dict-ref a mime-type:)) assets))
+135
(for-each (lambda (asset) ((dict-ref asset producer:))) assets)
+136
(let ((rss (read-file-string (string-append dir "/posts/feed.xml")))
+137
(json (json-decode (read-file-string (string-append dir "/posts/feed.json")))))
+138
(assert-true (string-contains? rss "<rss version=\"2.0\""))
+139
(assert-true (string-contains? rss "https://example.com/posts/second/"))
+140
(assert-equal 2 (length (array->list (dict-ref json items:))))
+141
(assert-equal "https://example.com/posts/first/" (dict-ref (cadr (array->list (dict-ref json items:))) url:)))))
+142
(test "rss: #f or json: #f leaves that feed out; the site's title and base-url are the defaults"
+143
(let* ((stage (with-feeds json: #f))
+144
(assets (state-assets (stage config state))))
+145
(assert-equal '("/feed.xml") (map (lambda (a) (dict-ref a output-path:)) assets)))
+146
(let* ((text (rss-string config state (feed-items state #f 20)
+147
#{title: #f description: "" base-url: #f author: #f language: "en"
+148
rss-path: "/feed.xml" json-path: #f})))
+149
(assert-true (string-contains? text "<title>Site</title><link>https://example.com/</link>"))
+150
(assert-true (string-contains? text "<item><title>Elsewhere</title>"))))))