Skip to content

Commit 3a3b96d

Browse files
sametbasbugclaude
andcommitted
Build the sitemap from D1 instead of the build
Sitemap derleme anında donuyordu ve bunun bedeli tam olarak şuydu: canlıda yedi ajan varken listede dördü, D1'e yazılan gönderilerin ise hiçbiri yoktu. Ajanın yayımladığı bir kayıt, bir sonraki dağıtıma kadar arama motoruna hiç duyurulmuyordu — sitemap'in tek işi buyken. Google'ın indekslediği sayfa sayısı bugün beş; snippet'lerin tarihi 10–13 Temmuz. İkinci kusur daha sinsiydi: liste `noindex` sayfaları (panel, mesajlar, takip akışı) da taşıyordu. Sitemap "bunu indeksle" demek, sayfa "etme" demek; Search Console bunu hata sayar ve tarama bütçesi boşa gider. `lastmod` ise hiç yoktu, yani tazelik sinyali de yoktu. Liste artık worker'da D1'den üretiliyor: ajanlar, gönderiler (imleçle sayfalanarak) ve konular, her biri kendi `lastmod`'uyla. Sabit sayfa listesi elle yazıldı ve dışarıda bıraktıkları gerekçeleriyle kayıtlı. Eski adresler kalıcı olarak /sitemap.xml'e taşındı; robots.txt oraya işaret ediyor. /saved ve /search de `noindex` oldu: biri bu tarayıcıya özel, diğeri sorgu başına aynı kayıtların yeni bir kopyası. Mutasyonla sınandı: imleci taşımamak ikinci kaydı düşürüyor, listeye bir `noindex` yolu eklemek dışlama testini kırıyor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 091e9d0 commit 3a3b96d

9 files changed

Lines changed: 296 additions & 80 deletions

File tree

‎astro.config.mjs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { defineConfig } from 'astro/config';
2-
import sitemap from '@astrojs/sitemap';
32

43
export default defineConfig({
54
site: 'https://orbit.sametbasbug.dev',
65
devToolbar: {
76
enabled: false,
87
},
9-
integrations: [sitemap({ filter: (page) => !page.includes('/orbit-runtime/') && !page.includes('/projects') && !page.includes('/mcp/avatar-upload') })],
8+
/* Sitemap derlemeden değil worker'dan geliyor (src/server/public/sitemap.ts):
9+
derleme zamanı liste, ajanların D1'e yazdığı hiçbir kaydı göremiyordu. */
1010
});

‎package-lock.json‎

Lines changed: 0 additions & 67 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,6 @@
6262
"dependencies": {
6363
"@astrojs/cloudflare": "14.2.1",
6464
"@astrojs/rss": "^4.0.19",
65-
"@astrojs/sitemap": "3.7.3",
6665
"astro": "7.2.2",
6766
"gray-matter": "^4.0.3",
6867
"micromark": "^4.0.2",

‎public/robots.txt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
User-agent: *
22
Allow: /
33

4-
Sitemap: https://orbit.sametbasbug.dev/sitemap-index.xml
4+
Sitemap: https://orbit.sametbasbug.dev/sitemap.xml

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

Lines changed: 97 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -105,10 +105,20 @@ class FakeAgentRepository {
105105
class FakePublicRepository implements PublicRepository {
106106
readonly records: PublicRecordView[];
107107
readonly announcements: PublicAnnouncementView[];
108-
109-
constructor(records: PublicRecordView[], announcements: PublicAnnouncementView[] = []) {
108+
readonly topics: PublicDictionaryItem[];
109+
/** Sitemap sayfalamasını sınamak için: ilk sayfa `hasMore` der. */
110+
readonly feedPageSize: number | null;
111+
feedCalls = 0;
112+
113+
constructor(
114+
records: PublicRecordView[],
115+
announcements: PublicAnnouncementView[] = [],
116+
options: { topics?: PublicDictionaryItem[]; feedPageSize?: number } = {},
117+
) {
110118
this.records = records;
111119
this.announcements = announcements;
120+
this.topics = options.topics ?? [];
121+
this.feedPageSize = options.feedPageSize ?? null;
112122
}
113123

114124
/* Sahte depo hedef kitleye göre filtrelemez, çünkü filtrenin yeri burası
@@ -120,10 +130,17 @@ class FakePublicRepository implements PublicRepository {
120130
}
121131

122132
async listFeed(input: Parameters<PublicRepository['listFeed']>[0]): Promise<PublicPage> {
123-
return {
124-
items: this.records.filter((item) => item.kind === 'post' && (!input.agentHandle || item.author.handle === input.agentHandle)),
125-
hasMore: false,
126-
};
133+
this.feedCalls += 1;
134+
const matching = this.records
135+
.filter((item) => item.kind === 'post' && (!input.agentHandle || item.author.handle === input.agentHandle));
136+
if (this.feedPageSize === null) return { items: matching, hasMore: false };
137+
/* Gerçek sorgu gibi imleçten devam ediyor: imleci taşımayan bir çağıran
138+
* ilk sayfayı sonsuza kadar okur. */
139+
const start = input.cursor
140+
? matching.findIndex((item) => item.id === input.cursor!.id) + 1
141+
: 0;
142+
const items = matching.slice(start, start + this.feedPageSize);
143+
return { items, hasMore: start + items.length < matching.length };
127144
}
128145

129146
async searchRecords(): Promise<PublicPage> {
@@ -162,7 +179,7 @@ class FakePublicRepository implements PublicRepository {
162179
}
163180

164181
async listTopics(): Promise<PublicDictionaryItem[]> {
165-
return [];
182+
return this.topics;
166183
}
167184

168185
async listTopicsPage(): Promise<{ items: PublicDictionaryItem[]; hasMore: boolean }> {
@@ -242,6 +259,79 @@ describe('Orbit dynamic public pages', () => {
242259
assert.doesNotMatch(html, /__ORBIT_/u);
243260
});
244261

262+
/*
263+
* Sitemap uzun süre derleme anında donuyordu: canlıdaki yedi ajanın dördü,
264+
* D1'e yazılan gönderilerin hiçbiri listede yoktu ve Google yeni içeriği
265+
* yalnız bir bağlantıya rastlarsa görüyordu. Buradaki testler listenin
266+
* canlıdan üretildiğini ve neyi DIŞARIDA bıraktığını koruyor.
267+
*/
268+
test('builds the sitemap from D1 agents, records and topics', async () => {
269+
const first = record({ id: 'record-1', slug: 'ilk-kayit', publishedAt: Date.UTC(2026, 6, 19, 10, 0), updatedAt: Date.UTC(2026, 6, 20, 8, 0) });
270+
const second = record({ id: 'record-2', slug: 'ikinci-kayit', publishedAt: Date.UTC(2026, 7, 1, 10, 0), updatedAt: Date.UTC(2026, 7, 2, 9, 0) });
271+
const repository = new FakePublicRepository([first, second], [], {
272+
topics: [{ id: 'topic-orbit', slug: 'orbit', name: 'Orbit', description: '', accent: '#6f63e8' }],
273+
/* Sayfa başına tek kayıt: imleci taşımayan bir toplayıcı ikinci kaydı
274+
* hiç görmez. */
275+
feedPageSize: 1,
276+
});
277+
const response = await serveDynamicPublicPage(
278+
new Request('https://orbit.example/sitemap.xml'),
279+
assets,
280+
repository,
281+
new FakeAgentRepository([agent(), agent({ id: 'agent-nyx', handle: 'nyx' })]),
282+
);
283+
assert.ok(response);
284+
assert.equal(response.status, 200);
285+
assert.equal(response.headers.get('content-type'), 'application/xml; charset=utf-8');
286+
const xml = await response.text();
287+
assert.match(xml, /^<\?xml version="1\.0" encoding="UTF-8"\?><urlset xmlns="http:\/\/www\.sitemaps\.org\/schemas\/sitemap\/0\.9">/u);
288+
assert.match(xml, /<loc>https:\/\/orbit\.example\/<\/loc>/u);
289+
assert.match(xml, /<loc>https:\/\/orbit\.example\/agents\/guest-mind\/<\/loc>/u);
290+
assert.match(xml, /<loc>https:\/\/orbit\.example\/agents\/nyx\/<\/loc>/u);
291+
assert.match(xml, /<loc>https:\/\/orbit\.example\/topics\/orbit\/<\/loc>/u);
292+
assert.match(xml, /<loc>https:\/\/orbit\.example\/posts\/ilk-kayit\/<\/loc>/u);
293+
/* Sayfalama: ikinci kayıt yalnız imleç taşındıysa listede olur. */
294+
assert.match(xml, /<loc>https:\/\/orbit\.example\/posts\/ikinci-kayit\/<\/loc>/u);
295+
/* Tazelik sinyali: kaydın kendi güncellenme anı, derleme anı değil. */
296+
assert.match(xml, /<loc>https:\/\/orbit\.example\/posts\/ikinci-kayit\/<\/loc><lastmod>2026-08-02T09:00:00\.000Z<\/lastmod>/u);
297+
/* Ana sayfa bir listedir; yaşı içindeki en yeni kaydın yaşıdır. */
298+
assert.match(xml, /<loc>https:\/\/orbit\.example\/<\/loc><lastmod>2026-08-02T09:00:00\.000Z<\/lastmod>/u);
299+
});
300+
301+
test('keeps noindex shells and derived pages out of the sitemap', async () => {
302+
const response = await serveDynamicPublicPage(
303+
new Request('https://orbit.example/sitemap.xml'),
304+
assets,
305+
new FakePublicRepository([record()]),
306+
new FakeAgentRepository([agent()]),
307+
);
308+
assert.ok(response);
309+
const xml = await response.text();
310+
/* Sitemap "bunu indeksle" demek; sayfanın kendisi `noindex` diyorsa liste
311+
* kendi kendisiyle çelişir ve tarama bütçesi boşa gider. */
312+
for (const path of ['/dashboard/', '/dashboard/platform/', '/messages/', '/following/', '/saved/', '/search/']) {
313+
assert.doesNotMatch(xml, new RegExp(`<loc>https://orbit\\.example${path}</loc>`, 'u'), `${path} sitemap'te olmamalı`);
314+
}
315+
/* Türev yüzeyler: ajan akışı profilin kopyası, takip listeleri profilden
316+
* türüyor, çalışma kabuğu hiç kamusal değil. */
317+
assert.doesNotMatch(xml, /<loc>[^<]*\/feed\//u);
318+
assert.doesNotMatch(xml, /<loc>[^<]*\/takipcileri/u);
319+
assert.doesNotMatch(xml, /<loc>[^<]*\/orbit-runtime\//u);
320+
});
321+
322+
test('moves the old build-time sitemap addresses to the live one', async () => {
323+
for (const path of ['/sitemap-index.xml', '/sitemap-0.xml']) {
324+
const response = await serveDynamicPublicPage(
325+
new Request(`https://orbit.example${path}`),
326+
assets,
327+
new FakePublicRepository([record()]),
328+
);
329+
assert.ok(response);
330+
assert.equal(response.status, 301);
331+
assert.equal(response.headers.get('location'), 'https://orbit.example/sitemap.xml');
332+
}
333+
});
334+
245335
test('replaces the build-time homepage records with D1 records', async () => {
246336
const response = await serveDynamicPublicPage(
247337
new Request('https://orbit.example/'),

‎src/pages/saved.astro‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@ import BaseLayout from '../layouts/BaseLayout.astro';
33
import Icon from '../components/Icon.astro';
44
---
55

6-
<BaseLayout title="Kaydedilenler" description="Bu cihazda kaydettiğin Orbit gönderileri.">
6+
<!-- Liste yalnız bu tarayıcıda duruyor: arama motorunun indeksleyeceği şey
7+
boş bir kabuk, kimsenin arayacağı bir sayfa değil. -->
8+
<BaseLayout
9+
title="Kaydedilenler"
10+
description="Bu cihazda kaydettiğin Orbit gönderileri."
11+
robots="noindex, follow"
12+
>
713
<div class="page-shell saved-page">
814
<header class="page-intro saved-intro">
915
<p class="section-label">Kişisel alan</p>

‎src/pages/search.astro‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@ import BaseLayout from '../layouts/BaseLayout.astro';
33
import Icon from '../components/Icon.astro';
44
---
55

6-
<BaseLayout title="Ara" description="Orbit ajanları, gönderileri ve yanıtları içinde ara.">
6+
<!-- Arama sonucu sayfası indekslenmiyor: içerik zaten kendi adreslerinde
7+
duruyor ve sorgulu adresler aynı kayıtların sonsuz kopyasını üretir. -->
8+
<BaseLayout
9+
title="Ara"
10+
description="Orbit ajanları, gönderileri ve yanıtları içinde ara."
11+
robots="noindex, follow"
12+
>
713
<div class="page-shell search-page">
814
<header class="page-intro search-intro">
915
<p class="section-label">Orbit keşfi</p>

‎src/server/public/response.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
} from './agent-html';
1212
import { renderPublicFeed, renderPublicRecordPage } from './html';
1313
import { renderPublicRssFeed } from './rss';
14+
import { collectSitemapEntries, renderSitemap } from './sitemap';
1415
import { renderAnnouncementList, renderAnnouncementPanel } from '../../shared/announcement-markup';
1516

1617
type PublicAgentPageRepository = Pick<AgentRepository, 'listPublicAgents' | 'getPublicAgent'>;
@@ -185,6 +186,25 @@ async function renderRssRoute(
185186
});
186187
}
187188

189+
async function renderSitemapRoute(
190+
request: Request,
191+
repository: PublicRepository,
192+
agentRepository: PublicAgentPageRepository | undefined,
193+
): Promise<Response> {
194+
const entries = await collectSitemapEntries(new URL(request.url), repository, agentRepository);
195+
const xml = renderSitemap(entries);
196+
return new Response(request.method === 'HEAD' ? null : xml, {
197+
status: 200,
198+
headers: {
199+
/* Yeni kayıt yayımlandığı anda listede olmalı; arada duran bir cache
200+
* sitemap'i yine derleme zamanı kadar taze yapardı. */
201+
'cache-control': 'no-store, no-transform',
202+
'content-type': 'application/xml; charset=utf-8',
203+
'x-content-type-options': 'nosniff',
204+
},
205+
});
206+
}
207+
188208
async function renderFeedRoute(
189209
request: Request,
190210
assets: AssetsBinding,
@@ -434,6 +454,25 @@ export async function serveDynamicPublicPage(
434454
return await renderRssRoute(request, repository);
435455
}
436456

457+
/*
458+
* Sitemap de canlıdan üretiliyor. Eski derleme zamanı dosyalarının adresi
459+
* kalıcı olarak buraya taşınıyor: robots.txt yıllarca oraya işaret etti ve
460+
* Search Console'a gönderilen adres değişmiş olabilir.
461+
*/
462+
if (url.pathname === '/sitemap.xml') {
463+
return await renderSitemapRoute(request, repository, agentRepository);
464+
}
465+
466+
if (url.pathname === '/sitemap-index.xml' || url.pathname === '/sitemap-0.xml') {
467+
return new Response(null, {
468+
status: 301,
469+
headers: {
470+
location: new URL('/sitemap.xml', request.url).href,
471+
'cache-control': 'public, max-age=86400',
472+
},
473+
});
474+
}
475+
437476
const feedMatch = url.pathname.match(/^\/feed\/([a-z0-9][a-z0-9-]{0,62})\/?$/u);
438477
if (feedMatch) {
439478
return await renderFeedRoute(request, assets, repository, feedMatch[1]);

0 commit comments

Comments
 (0)