diff --git a/api/src/feeds.test.ts b/api/src/feeds.test.ts
new file mode 100644
index 00000000..19eaad7b
--- /dev/null
+++ b/api/src/feeds.test.ts
@@ -0,0 +1,33 @@
+import { test, after } from 'node:test';
+import assert from 'node:assert/strict';
+import Fastify from 'fastify';
+import { feedRoutes } from './feeds.ts';
+import { pool } from './db.ts';
+
+after(() => pool.end());
+
+test('sitemap and feed: every public page; a published version appears with its date, summary, author and lines', async () => {
+ const app = Fastify(); feedRoutes(app);
+ const work = (await pool.query(`SELECT id, url FROM poems WHERE url LIKE '/p266/ghazal/%' ORDER BY id LIMIT 1`)).rows[0];
+ const when = new Date(Date.now() + 60_000).toISOString(); // newer than anything else, so it comes first
+ const rev = (await pool.query(
+ `INSERT INTO revisions (entity, entity_id, version, base_version, content, status, author_email, summary, published_at, credits)
+ VALUES ('work', $1, 99, 98, '', 'published', 'feed-test@divan.test', 'آزمائشی ترمیم', $2, $3) RETURNING id`,
+ [work.id, when, { by: 'آزمائشی موڈریٹر', publishedBy: 'ایڈمن' }])).rows[0].id;
+ try {
+ const s = (await app.inject('/api/sitemap')).json();
+ assert.ok(s.poets.length > 300 && s.categories.length > 500 && s.works.length > 10000, 'all the public pages');
+ assert.ok(s.poets.includes('/p266'));
+ assert.equal(new Date(s.works.find((w: any) => w.url === work.url).updated).toISOString(), when, "a work's date is its latest version");
+
+ const feed = (await app.inject('/api/feed?limit=5')).json();
+ assert.ok(feed.length >= 1 && feed.length <= 5);
+ const first = feed[0];
+ assert.deepEqual([first.kind, first.url, first.version, first.summary, first.by], ['work', work.url, 99, 'آزمائشی ترمیم', 'آزمائشی موڈریٹر']);
+ assert.ok(first.lines.length >= 1, 'the opening lines');
+ assert.equal((await app.inject('/api/feed?limit=999')).json().length <= 100, true, 'at most 100');
+ } finally {
+ await pool.query('DELETE FROM revisions WHERE id = $1', [rev]);
+ await app.close();
+ }
+});
diff --git a/api/src/feeds.ts b/api/src/feeds.ts
new file mode 100644
index 00000000..b2463a3f
--- /dev/null
+++ b/api/src/feeds.ts
@@ -0,0 +1,60 @@
+// What the site's sitemap.xml and rss.xml are made from (#83, #87).
+// GET /api/sitemap every public page: poets, books/chapters, works (with the date of their latest Divan
+// version), tags, e-books
+// GET /api/feed?limit= the latest published changes, newest first: new versions of works (version, summary, the
+// opening lines), e-books, arrangements and tags; who made each (public names)
+import type { FastifyInstance } from 'fastify';
+import { pool } from './db.ts';
+
+export function feedRoutes(app: FastifyInstance) {
+ app.get('/api/sitemap', async (_req, reply) => {
+ const [poets, cats, works, tags, ebooks] = await Promise.all([
+ pool.query('SELECT url FROM poets ORDER BY id'),
+ pool.query('SELECT url FROM categories WHERE parent_id IS NOT NULL ORDER BY id'),
+ pool.query(`SELECT p.url, max(r.published_at) AS updated FROM poems p
+ LEFT JOIN revisions r ON r.entity = 'work' AND r.entity_id = p.id AND r.status = 'published'
+ GROUP BY p.id ORDER BY p.id`),
+ pool.query('SELECT DISTINCT t.type, t.name FROM tags t JOIN entity_tags e ON e.tag_id = t.id ORDER BY t.type, t.name'),
+ pool.query('SELECT b.id, t.url AS poet_url FROM ebooks b JOIN poets t ON t.id = b.poet_id WHERE b.published ORDER BY b.id'),
+ ]);
+ reply.header('cache-control', 'public, max-age=3600');
+ return {
+ poets: poets.rows.map((r) => r.url),
+ categories: cats.rows.map((r) => r.url),
+ works: works.rows.map((r) => ({ url: r.url, updated: r.updated })),
+ tags: tags.rows,
+ ebooks: ebooks.rows,
+ };
+ });
+
+ app.get<{ Querystring: { limit?: string } }>('/api/feed', async (req, reply) => {
+ const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), 100);
+ const { rows } = await pool.query(
+ `SELECT r.id, r.entity, r.entity_id, r.version, r.summary, r.published_at, r.credits,
+ p.title AS work_title, p.url AS work_url, pp.nickname AS work_poet,
+ c.title AS cat_title, c.url AS cat_url, cp.nickname AS cat_poet,
+ b.title AS ebook_title, bp.nickname AS ebook_poet
+ FROM revisions r
+ LEFT JOIN poems p ON r.entity IN ('work', 'tags-work') AND p.id = r.entity_id LEFT JOIN poets pp ON pp.id = p.poet_id
+ LEFT JOIN categories c ON r.entity IN ('order', 'tags-category') AND c.id = r.entity_id LEFT JOIN poets cp ON cp.id = c.poet_id
+ LEFT JOIN ebooks b ON r.entity = 'ebook' AND b.id = r.entity_id LEFT JOIN poets bp ON bp.id = b.poet_id
+ WHERE r.status = 'published' AND r.published_at IS NOT NULL
+ ORDER BY r.published_at DESC, r.id DESC LIMIT $1`, [limit]);
+ // the opening lines of each new version of a work, for the feed's description
+ const workIds = rows.filter((r) => r.entity === 'work').map((r) => r.entity_id);
+ const lines = workIds.length ? (await pool.query(
+ `SELECT poem_id, array_agg(text ORDER BY vorder) AS lines FROM verses WHERE poem_id = ANY($1) AND vorder <= 2 GROUP BY poem_id`,
+ [workIds])).rows : [];
+ reply.header('cache-control', 'public, max-age=300');
+ return rows.map((r) => {
+ const kind = r.entity === 'work' ? 'work' : r.entity === 'ebook' ? 'ebook' : r.entity === 'order' ? 'order' : 'tags';
+ const title = r.work_title ?? r.cat_title ?? r.ebook_title ?? '';
+ const url = r.work_url ?? r.cat_url ?? (r.entity === 'ebook' ? `/ebook/${r.entity_id}` : '/');
+ return {
+ id: Number(r.id), kind, version: r.version, title, url, poet: r.work_poet ?? r.cat_poet ?? r.ebook_poet ?? null,
+ summary: r.summary ?? null, published: r.published_at, by: r.credits?.by ?? null,
+ lines: lines.find((l) => l.poem_id === r.entity_id)?.lines ?? [],
+ };
+ });
+ });
+}
diff --git a/api/src/server.ts b/api/src/server.ts
index b5fcb65c..29155658 100644
--- a/api/src/server.ts
+++ b/api/src/server.ts
@@ -22,6 +22,7 @@ import { moderationRoutes } from './moderation.ts';
import { siteRoutes } from './site.ts';
import { tagRoutes, pageTags } from './tags.ts';
import { ebookRoutes } from './ebooks.ts';
+import { feedRoutes } from './feeds.ts';
const app = Fastify({ logger: { level: process.env.LOG_LEVEL ?? 'info' } });
const PAGE_SIZE = 20;
@@ -199,6 +200,7 @@ moderationRoutes(app);
siteRoutes(app);
tagRoutes(app);
ebookRoutes(app);
+feedRoutes(app);
const port = Number(process.env.PORT ?? 4100);
await app.listen({ port, host: process.env.HOST ?? '127.0.0.1' });
diff --git a/web/src/components/Icon.astro b/web/src/components/Icon.astro
index 0e779352..3a2915ef 100644
--- a/web/src/components/Icon.astro
+++ b/web/src/components/Icon.astro
@@ -55,6 +55,7 @@ const PATHS = {
search: '