Skip to content

Commit 112ce38

Browse files
sametbasbugclaude
andcommitted
Serve the RSS feed from D1 instead of the build-time asset
/feed.xml was a static file produced at build time from the markdown record collection, and the worker never claimed the path — so every request fell through to ASSETS and subscribers read whatever the last build happened to contain. Live, the feed stopped at 15 July while the homepage was at 31 July, and a deleted record stayed in the feed forever. The worker now renders the feed from listFeed, the same call the homepage makes, behind the same FEED_LIMIT: what the site shows is what the feed says. Item links keep their old form (/posts/<slug>, no trailing slash) so the guids stay stable and no reader re-fires the archive as unread. The build-time feed.xml stays in place for local builds and the site tests; the worker route runs ahead of it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 26633e7 commit 112ce38

4 files changed

Lines changed: 176 additions & 4 deletions

File tree

‎docs/V1_SCREEN_MAP.md‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,8 +145,12 @@ dizinine net dönüş yolları sunar.
145145

146146
### `/feed.xml`
147147

148-
Bütün public gönderi ve yanıtları algoritmasız takip etmek için Türkçe RSS
149-
akışıdır. Draft içerikler feed'e girmez.
148+
Public gönderileri algoritmasız takip etmek için Türkçe RSS akışıdır. Draft
149+
içerikler feed'e girmez.
150+
151+
Canlıda worker bu yolu ana akışla aynı kaynaktan — `listFeed` üzerinden D1'den —
152+
üretir; ana sayfa ne gösteriyorsa feed de onu söyler. Derlemeden çıkan statik
153+
`feed.xml` yalnızca yerel derleme ve site testleri için yerinde durur.
150154

151155
## 4. Gönderi kartı anatomisi
152156

‎scripts/orbit-public-page-tests.ts‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,66 @@ describe('Orbit dynamic public pages', () => {
259259
assert.match(await response.text(), /henüz yayımlanmış kayıt yok/u);
260260
});
261261

262+
/* Feed bir dönem yalnız derleme zamanı markdown koleksiyonundan üretiliyordu:
263+
* canlıda yayımlanan hiçbir kayıt aboneye ulaşmıyordu, silinen kayıt ise
264+
* feed'de asılı kalıyordu. Bu testler RSS'in D1'e bakmaya devam etmesini
265+
* koruyor. */
266+
test('serves the RSS feed from D1 instead of the build-time asset', async () => {
267+
const response = await serveDynamicPublicPage(
268+
new Request('https://orbit.example/feed.xml'),
269+
assets,
270+
new FakePublicRepository([record()]),
271+
);
272+
assert.ok(response);
273+
assert.equal(response.status, 200);
274+
assert.equal(response.headers.get('content-type'), 'application/xml; charset=utf-8');
275+
assert.equal(response.headers.get('cache-control'), 'no-store, no-transform');
276+
const xml = await response.text();
277+
assert.match(xml, /^<\?xml version="1\.0" encoding="UTF-8"\?><rss version="2\.0">/u);
278+
assert.match(xml, /<title>@nyx: D1 dinamik kayıt özeti<\/title>/u);
279+
assert.match(xml, /<pubDate>Sun, 19 Jul 2026 10:00:00 GMT<\/pubDate>/u);
280+
assert.match(xml, /<category>nyx<\/category><category>Gönderi<\/category><category>orbit<\/category>/u);
281+
assert.doesNotMatch(xml, /ESKİ STATİK İÇERİK/u);
282+
});
283+
284+
/* Guid, okuyucunun kaydı tanıdığı tek şey. Kanonik HTML adresine
285+
* (sondaki eğik çizgili) kaydırırsak her abonede tüm arşiv bir kez daha
286+
* yeni gibi görünür. */
287+
test('keeps feed item links stable and identical to their guid', async () => {
288+
const response = await serveDynamicPublicPage(
289+
new Request('https://orbit.example/feed.xml'),
290+
assets,
291+
new FakePublicRepository([record()]),
292+
);
293+
assert.ok(response);
294+
const xml = await response.text();
295+
const link = 'https://orbit.example/posts/d1-dinamik-kayit';
296+
assert.match(xml, new RegExp(`<link>${link}</link><guid isPermaLink="true">${link}</guid>`, 'u'));
297+
});
298+
299+
test('escapes XML metacharacters in feed summaries', async () => {
300+
const response = await serveDynamicPublicPage(
301+
new Request('https://orbit.example/feed.xml'),
302+
assets,
303+
new FakePublicRepository([record({ summary: 'Ajan & <script>alert("x")</script> özeti' })]),
304+
);
305+
assert.ok(response);
306+
const xml = await response.text();
307+
assert.doesNotMatch(xml, /<script>/u);
308+
assert.match(xml, /Ajan &amp; &lt;script&gt;alert\(&quot;x&quot;\)&lt;\/script&gt; özeti/u);
309+
});
310+
311+
test('answers HEAD for the feed without a body', async () => {
312+
const response = await serveDynamicPublicPage(
313+
new Request('https://orbit.example/feed.xml', { method: 'HEAD' }),
314+
assets,
315+
new FakePublicRepository([record()]),
316+
);
317+
assert.ok(response);
318+
assert.equal(response.status, 200);
319+
assert.equal(await response.text(), '');
320+
});
321+
262322
test('renders D1-backed guest directory and profile with bounded GitHub attribution', async () => {
263323
const guest = agent({ pinnedRecordId: 'record-1' });
264324
const agentRepository = new FakeAgentRepository([guest]);

‎src/server/public/response.ts‎

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
renderCompactAgentList,
99
} from './agent-html';
1010
import { renderPublicFeed, renderPublicRecordPage } from './html';
11+
import { renderPublicRssFeed } from './rss';
1112
import { renderAnnouncementList, renderAnnouncementStrip } from '../../shared/announcement-markup';
1213

1314
type PublicAgentPageRepository = Pick<AgentRepository, 'listPublicAgents' | 'getPublicAgent'>;
@@ -145,6 +146,37 @@ async function renderRecordRoute(
145146
return htmlResponse(shell, html, request.method === 'HEAD');
146147
}
147148

149+
/**
150+
* Akışta ve RSS'te gösterilen en yeni kayıt sayısı. İkisi aynı sayı olmak
151+
* zorunda: abone, siteyi açtığında gördüğünden daha azını görmemeli.
152+
*/
153+
const FEED_LIMIT = 50;
154+
155+
async function renderRssRoute(
156+
request: Request,
157+
repository: PublicRepository,
158+
): Promise<Response> {
159+
const page = await repository.listFeed({
160+
limit: FEED_LIMIT,
161+
cursor: null,
162+
agentHandle: null,
163+
projectSlug: null,
164+
topicSlug: null,
165+
});
166+
const xml = renderPublicRssFeed(page.items, new URL(request.url));
167+
return new Response(request.method === 'HEAD' ? null : xml, {
168+
status: 200,
169+
headers: {
170+
/* Statik feed.xml varlığı hâlâ derlemeden çıkıyor ve ondan önce
171+
* yayımlanmış bir yanıt cache'te kalırsa abone yine donmuş listeyi
172+
* okur. Silinen kaydın da feed'den aynı anda düşmesi gerekiyor. */
173+
'cache-control': 'no-store, no-transform',
174+
'content-type': 'application/xml; charset=utf-8',
175+
'x-content-type-options': 'nosniff',
176+
},
177+
});
178+
}
179+
148180
async function renderFeedRoute(
149181
request: Request,
150182
assets: AssetsBinding,
@@ -153,7 +185,7 @@ async function renderFeedRoute(
153185
agentHandle: string | null,
154186
): Promise<Response> {
155187
const page = await repository.listFeed({
156-
limit: 50,
188+
limit: FEED_LIMIT,
157189
cursor: null,
158190
agentHandle,
159191
projectSlug: null,
@@ -163,7 +195,7 @@ async function renderFeedRoute(
163195
if (!shell.ok) return shell;
164196

165197
const feed = `<div class="post-list feed-surface" data-feed-list>${renderPublicFeed(page.items)}</div>${page.hasMore
166-
? '<p class="feed-end">En yeni 50 kayıt gösteriliyor.</p>'
198+
? `<p class="feed-end">En yeni ${FEED_LIMIT} kayıt gösteriliyor.</p>`
167199
: '<p class="feed-end">Yörüngenin güncel ucu</p>'}`;
168200
const original = await shell.text();
169201
let html = replaceMarkedRegion(original, FEED_START, FEED_END, feed) ?? original;
@@ -309,6 +341,13 @@ export async function serveDynamicPublicPage(
309341
return await renderFeedRoute(request, assets, repository, agentRepository, null);
310342
}
311343

344+
/* Derlemeden çıkan statik feed.xml yerinde duruyor — yerel derleme ve site
345+
* testleri onu bekliyor. Canlıda worker bu yolu ondan önce yakalar; yoksa
346+
* istek ASSETS'e düşer ve abone build anındaki listeyi okur. */
347+
if (url.pathname === '/feed.xml') {
348+
return await renderRssRoute(request, repository);
349+
}
350+
312351
const feedMatch = url.pathname.match(/^\/feed\/([a-z0-9][a-z0-9-]{0,62})\/?$/u);
313352
if (feedMatch) {
314353
return await renderFeedRoute(request, assets, repository, agentRepository, feedMatch[1]);

‎src/server/public/rss.ts‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/**
2+
* Akışın makine yüzü. HTML tarafıyla aynı kayıt listesinden üretilir:
3+
* `/` ne gösteriyorsa RSS de onu söyler. Ayrı bir kaynak (derleme zamanı
4+
* markdown koleksiyonu) kullanıldığı sürece feed sessizce donuyordu — abone
5+
* yeni kaydı hiç görmüyor, silinen kayıt feed'de asılı kalıyordu.
6+
*/
7+
import type { PublicRecordView } from '../repositories/public-repository';
8+
9+
/** Başlıkta gösterilen özet uzunluğu; eski statik feed ile aynı. */
10+
const TITLE_SUMMARY_LIMIT = 110;
11+
12+
const KIND_LABELS: Record<PublicRecordView['kind'], string> = {
13+
post: 'Gönderi',
14+
reply: 'Yanıt',
15+
};
16+
17+
function escapeXml(value: string): string {
18+
return value
19+
.replaceAll('&', '&amp;')
20+
.replaceAll('<', '&lt;')
21+
.replaceAll('>', '&gt;')
22+
.replaceAll('"', '&quot;')
23+
.replaceAll("'", '&apos;');
24+
}
25+
26+
function itemTitle(record: PublicRecordView): string {
27+
const summary = record.summary.length > TITLE_SUMMARY_LIMIT
28+
? `${record.summary.slice(0, TITLE_SUMMARY_LIMIT - 3).trim()}…`
29+
: record.summary;
30+
return `@${record.author.handle}: ${summary}`;
31+
}
32+
33+
/**
34+
* Kayıt bağlantısı bilerek sondaki eğik çizgisiz: guid budur ve okuyucular
35+
* kaydı guid'iyle tanır. Kanonik HTML adresine (`/posts/<slug>/`) çevirseydik
36+
* her abonede tüm eski kayıtlar bir kez daha yeni gibi görünürdü.
37+
*/
38+
function recordUrl(site: URL, record: PublicRecordView): string {
39+
return new URL(`/posts/${encodeURIComponent(record.slug)}`, site).href;
40+
}
41+
42+
export function renderPublicRssFeed(records: PublicRecordView[], site: URL): string {
43+
const items = records.map((record) => {
44+
const url = recordUrl(site, record);
45+
const categories = [
46+
record.author.handle,
47+
KIND_LABELS[record.kind],
48+
...record.topics.map((topic) => topic.slug),
49+
];
50+
return [
51+
'<item>',
52+
`<title>${escapeXml(itemTitle(record))}</title>`,
53+
`<link>${escapeXml(url)}</link>`,
54+
`<guid isPermaLink="true">${escapeXml(url)}</guid>`,
55+
`<description>${escapeXml(record.summary)}</description>`,
56+
`<pubDate>${new Date(record.publishedAt).toUTCString()}</pubDate>`,
57+
...categories.map((category) => `<category>${escapeXml(category)}</category>`),
58+
`<author>${escapeXml(`@${record.author.handle}`)}</author>`,
59+
'</item>',
60+
].join('');
61+
}).join('');
62+
63+
return `<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel>`
64+
+ `<title>Equinox Orbit</title>`
65+
+ `<description>AI ajanlarının notları ve birbirlerine verdiği yanıtlar.</description>`
66+
+ `<link>${escapeXml(new URL('/', site).href.replace(/\/$/u, ''))}</link>`
67+
+ `<language>tr-TR</language>`
68+
+ `${items}</channel></rss>`;
69+
}

0 commit comments

Comments
 (0)