@growth-labs/seo 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/utils/validation.d.ts.map +1 -1
  2. package/dist/utils/validation.js +22 -0
  3. package/dist/utils/validation.js.map +1 -1
  4. package/package.json +9 -5
  5. package/src/_internal/state.ts +26 -0
  6. package/src/bindings.ts +146 -0
  7. package/src/cron/prune-aeo-r2.ts +140 -0
  8. package/src/durable-objects/aeo-revalidation-coord.ts +246 -0
  9. package/src/index.ts +380 -0
  10. package/src/middleware/seo.ts +350 -0
  11. package/src/options.ts +456 -0
  12. package/src/routes/aeo-twin.ts +130 -0
  13. package/src/routes/apple-news.ts +36 -0
  14. package/src/routes/llms-full.ts +36 -0
  15. package/src/routes/llms.ts +15 -0
  16. package/src/routes/podcast-narration.ts +45 -0
  17. package/src/routes/podcast.ts +27 -0
  18. package/src/routes/revalidate.ts +298 -0
  19. package/src/routes/robots.ts +21 -0
  20. package/src/routes/rss.ts +29 -0
  21. package/src/routes/sitemap-articles.ts +25 -0
  22. package/src/routes/sitemap-index.ts +89 -0
  23. package/src/routes/sitemap-markdown.ts +39 -0
  24. package/src/routes/sitemap-pages.ts +24 -0
  25. package/src/routes/sitemap-products.ts +24 -0
  26. package/src/routes/sitemap-videos.ts +24 -0
  27. package/src/runtime.ts +17 -0
  28. package/src/site-url-core.ts +71 -0
  29. package/src/site-url.ts +21 -0
  30. package/src/types.ts +166 -0
  31. package/src/utils/aeo-summary.ts +176 -0
  32. package/src/utils/aeo-twin-emitter.ts +173 -0
  33. package/src/utils/aeo.ts +223 -0
  34. package/src/utils/apple-news-anf.ts +163 -0
  35. package/src/utils/apple-news-rss.ts +136 -0
  36. package/src/utils/content-filter.ts +87 -0
  37. package/src/utils/crawler-class.ts +155 -0
  38. package/src/utils/define-content-provider.ts +65 -0
  39. package/src/utils/effective-auth.ts +44 -0
  40. package/src/utils/fcrdns.ts +269 -0
  41. package/src/utils/fresh-layer.ts +175 -0
  42. package/src/utils/hreflang.ts +26 -0
  43. package/src/utils/index.ts +91 -0
  44. package/src/utils/json-ld/article.ts +120 -0
  45. package/src/utils/json-ld/audio.ts +32 -0
  46. package/src/utils/json-ld/breadcrumb.ts +28 -0
  47. package/src/utils/json-ld/faq.ts +18 -0
  48. package/src/utils/json-ld/howto.ts +23 -0
  49. package/src/utils/json-ld/index.ts +12 -0
  50. package/src/utils/json-ld/item-list.ts +26 -0
  51. package/src/utils/json-ld/organization.ts +42 -0
  52. package/src/utils/json-ld/person.ts +25 -0
  53. package/src/utils/json-ld/product.ts +155 -0
  54. package/src/utils/json-ld/video.ts +20 -0
  55. package/src/utils/json-ld/website.ts +27 -0
  56. package/src/utils/llms-full.ts +90 -0
  57. package/src/utils/llms.ts +45 -0
  58. package/src/utils/meta.ts +184 -0
  59. package/src/utils/podcast.ts +112 -0
  60. package/src/utils/robots.ts +47 -0
  61. package/src/utils/rss.ts +64 -0
  62. package/src/utils/seo-head.ts +81 -0
  63. package/src/utils/sitemap-markdown.ts +80 -0
  64. package/src/utils/sitemap.ts +169 -0
  65. package/src/utils/staleness.ts +61 -0
  66. package/src/utils/validation.ts +308 -0
  67. package/src/virtual.d.ts +8 -0
  68. package/src/vite-plugin.ts +66 -0
@@ -0,0 +1,45 @@
1
+ import 'virtual:growth-labs/seo/config'
2
+ import type { APIRoute } from 'astro'
3
+ import { getConfig, getContentProvider } from '../_internal/state.js'
4
+ import { resolveSeoConfig } from '../site-url.js'
5
+ import type { ContentItem } from '../types.js'
6
+ import { generatePodcastFeed } from '../utils/podcast.js'
7
+
8
+ export const prerender = false
9
+
10
+ export const GET: APIRoute = async (context) => {
11
+ const config = resolveSeoConfig(getConfig())
12
+ const contentProvider = getContentProvider()
13
+
14
+ let articles: ContentItem[] = []
15
+ if (contentProvider) {
16
+ try {
17
+ articles = await contentProvider({ type: 'articles' }, context as any)
18
+ } catch {}
19
+ }
20
+
21
+ const withAudio = articles.filter((a) => a.audio)
22
+
23
+ const narrationPodcastConfig = {
24
+ ...config,
25
+ podcast: {
26
+ enabled: true,
27
+ title: `${config.organization.name} — Listen`,
28
+ description: `Narrated articles from ${config.organization.name}`,
29
+ author: config.audioNarration?.narratorName ?? config.organization.name,
30
+ email: '',
31
+ image: config.organization.logo,
32
+ category: 'News',
33
+ language: config.defaults.locale.split('_')[0] ?? 'en',
34
+ explicit: false,
35
+ feedPath: config.audioNarration?.podcastFeedPath ?? '/listen.xml',
36
+ type: 'episodic' as const,
37
+ },
38
+ }
39
+
40
+ const xml = generatePodcastFeed(withAudio, narrationPodcastConfig)
41
+
42
+ return new Response(xml, {
43
+ headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' },
44
+ })
45
+ }
@@ -0,0 +1,27 @@
1
+ import 'virtual:growth-labs/seo/config'
2
+ import type { APIRoute } from 'astro'
3
+ import { getConfig, getContentProvider } from '../_internal/state.js'
4
+ import { resolveSeoConfig } from '../site-url.js'
5
+ import type { ContentItem } from '../types.js'
6
+ import { generatePodcastFeed } from '../utils/podcast.js'
7
+
8
+ export const prerender = false
9
+
10
+ export const GET: APIRoute = async (context) => {
11
+ const config = resolveSeoConfig(getConfig())
12
+ const contentProvider = getContentProvider()
13
+
14
+ let articles: ContentItem[] = []
15
+ if (contentProvider) {
16
+ try {
17
+ articles = await contentProvider({ type: 'articles' }, context as any)
18
+ } catch {}
19
+ }
20
+
21
+ const withAudio = articles.filter((a) => a.audio)
22
+ const xml = generatePodcastFeed(withAudio, config)
23
+
24
+ return new Response(xml, {
25
+ headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' },
26
+ })
27
+ }
@@ -0,0 +1,298 @@
1
+ import 'virtual:growth-labs/seo/config'
2
+ import type { APIRoute } from 'astro'
3
+ import { getConfig, getContentProvider } from '../_internal/state.js'
4
+ import type { DurableObjectNamespaceLike, SeoEnv } from '../bindings.js'
5
+ import { resolveAeoTwins } from '../options.js'
6
+ import { getRuntimeEnv } from '../runtime.js'
7
+ import { resolveSeoConfig } from '../site-url.js'
8
+ import type { ContentItem, ContentProvider } from '../types.js'
9
+ import { generateAeoMarkdown } from '../utils/aeo.js'
10
+ import { generateSummaryTwin } from '../utils/aeo-summary.js'
11
+ import type { FreshLayerBinding } from '../utils/fresh-layer.js'
12
+ import { deleteFreshTwin, writeFreshTwin } from '../utils/fresh-layer.js'
13
+
14
+ export const prerender = false
15
+
16
+ // ─── Wire format ───
17
+
18
+ interface RevalidateBody {
19
+ slugs?: string[]
20
+ action?: 'publish' | 'unpublish'
21
+ idempotency_key?: string
22
+ }
23
+
24
+ const MAX_BATCH = 50
25
+ const DEFAULT_RATE_LIMIT_RPM = 10
26
+
27
+ /**
28
+ * POST /_seo/revalidate — CMS webhook target. Re-renders the listed slugs,
29
+ * writes to R2 under the current deployment prefix, purges the edge cache for
30
+ * the markdown sitemap so newly-published content is discoverable within seconds.
31
+ *
32
+ * Auth: Authorization: Bearer <revalidateToken>. Token is the same ≥32-byte
33
+ * value configured in aeoTwins.revalidateToken. Compared with constant-time
34
+ * string compare.
35
+ *
36
+ * Rate limit + per-slug lock + idempotency all delegate to the Revalidation
37
+ * Coordinator Durable Object (spec "Revalidation Coordinator DO topology").
38
+ */
39
+ export const POST: APIRoute = async (context) => {
40
+ const rawConfig = getConfig()
41
+ const contentProvider = getContentProvider()
42
+ const aeo = resolveAeoTwins(rawConfig.aeoTwins)
43
+
44
+ if (!aeo?.onDemandRevalidation || !aeo.freshLayer || !aeo.revalidateToken) {
45
+ return json({ error: 'revalidation_disabled' }, 404)
46
+ }
47
+
48
+ const env = getRuntimeEnv(context) as SeoEnv
49
+ if (!env) {
50
+ return json({ error: 'binding_unavailable' }, 503)
51
+ }
52
+ const config = resolveSeoConfig(rawConfig, env as unknown as Record<string, unknown>)
53
+
54
+ // 1. Auth: Bearer token, constant-time compare.
55
+ const authz = context.request.headers.get('Authorization') ?? ''
56
+ const bearerPrefix = 'Bearer '
57
+ if (!authz.startsWith(bearerPrefix)) {
58
+ return json({ error: 'unauthorized' }, 401)
59
+ }
60
+ const presented = authz.slice(bearerPrefix.length)
61
+ if (!constantTimeEqual(presented, aeo.revalidateToken)) {
62
+ return json({ error: 'unauthorized' }, 401)
63
+ }
64
+
65
+ // 2. Parse body.
66
+ let body: RevalidateBody
67
+ try {
68
+ body = (await context.request.json()) as RevalidateBody
69
+ } catch {
70
+ return json({ error: 'bad_json' }, 400)
71
+ }
72
+ const slugs = body.slugs ?? []
73
+ if (!Array.isArray(slugs) || slugs.length === 0) {
74
+ return json({ error: 'no_slugs' }, 400)
75
+ }
76
+ if (slugs.length > MAX_BATCH) {
77
+ return json({ error: 'batch_too_large', limit: MAX_BATCH }, 400)
78
+ }
79
+
80
+ // 3. Resolve the Revalidation Coordinator DO for this hostname.
81
+ const hostname = new URL(context.request.url).host
82
+ const coordBindingName = aeo.freshLayer.coordinatorBindingName
83
+ const coordBinding = (env as unknown as Record<string, unknown>)[coordBindingName] as
84
+ | DurableObjectNamespaceLike
85
+ | undefined
86
+ const coord = coordBinding ? coordBinding.get(coordBinding.idFromName(hostname)) : undefined
87
+
88
+ // 4. Rate-limit check (per-token).
89
+ if (coord) {
90
+ const rl = await coordCall(coord, {
91
+ action: 'rate-check',
92
+ token: presented,
93
+ limitRpm: DEFAULT_RATE_LIMIT_RPM,
94
+ })
95
+ if (!rl.ok) {
96
+ return new Response(
97
+ JSON.stringify({ error: 'rate_limited', retryAfterMs: rl.retryAfterMs }),
98
+ {
99
+ status: 429,
100
+ headers: {
101
+ 'Content-Type': 'application/json',
102
+ 'Retry-After': String(Math.ceil(Number(rl.retryAfterMs ?? 5000) / 1000)),
103
+ },
104
+ },
105
+ )
106
+ }
107
+ }
108
+
109
+ // 5. Idempotency check — dedupe concurrent webhooks for the same CMS event.
110
+ if (coord && body.idempotency_key) {
111
+ const cached = await coordCall(coord, {
112
+ action: 'idempotency-check',
113
+ key: body.idempotency_key,
114
+ })
115
+ if (cached.ok && cached.hit) {
116
+ return json({ ok: true, deduped: true, ...(cached.result as Record<string, unknown>) })
117
+ }
118
+ }
119
+
120
+ // 6. Resolve fresh layer + content provider.
121
+ const freshLayerImpl = (env as unknown as Record<string, unknown>)[aeo.freshLayer.bindingName]
122
+ if (!freshLayerImpl || !contentProvider) {
123
+ return json({ error: 'binding_unavailable' }, 503)
124
+ }
125
+ const freshLayer: FreshLayerBinding = {
126
+ type: aeo.freshLayer.type,
127
+ impl: freshLayerImpl as FreshLayerBinding['impl'],
128
+ deploymentId: env.CF_VERSION_METADATA?.id ?? 'dev',
129
+ }
130
+
131
+ const outcomes: Array<Record<string, unknown>> = []
132
+ for (const slug of slugs) {
133
+ const outcome = await revalidateSlug({
134
+ slug,
135
+ action: body.action ?? 'publish',
136
+ config,
137
+ contentProvider,
138
+ freshLayer,
139
+ coord,
140
+ })
141
+ outcomes.push(outcome)
142
+ }
143
+
144
+ // 7. Purge sitemap-markdown cache so newly-published articles show up.
145
+ await purgeSitemapMarkdownCache(context.request).catch(() => {})
146
+
147
+ // 8. Cache idempotency result for future replays.
148
+ if (coord && body.idempotency_key) {
149
+ await coordCall(coord, {
150
+ action: 'idempotency-set',
151
+ key: body.idempotency_key,
152
+ result: { outcomes },
153
+ })
154
+ }
155
+
156
+ return json({ ok: true, outcomes })
157
+ }
158
+
159
+ // ─── Per-slug revalidate ───
160
+
161
+ interface RevalidateSlugInput {
162
+ slug: string
163
+ action: 'publish' | 'unpublish'
164
+ config: ReturnType<typeof resolveSeoConfig>
165
+ contentProvider: ContentProvider
166
+ freshLayer: FreshLayerBinding
167
+ coord: ReturnType<DurableObjectNamespaceLike['get']> | undefined
168
+ }
169
+
170
+ async function revalidateSlug(input: RevalidateSlugInput): Promise<Record<string, unknown>> {
171
+ const { slug, action, config, contentProvider, freshLayer, coord } = input
172
+ const start = Date.now()
173
+
174
+ // Acquire per-slug lock; if someone else holds it, serialize.
175
+ let leaseId: string | undefined
176
+ if (coord) {
177
+ const acquire = await coordCall(coord, { action: 'acquire-lock', slug })
178
+ if (acquire.ok) {
179
+ leaseId = acquire.leaseId as string
180
+ } else if (acquire.error === 'locked') {
181
+ // Someone else is rendering this slug. Return without duplicating work;
182
+ // the winner's writeback will be visible to subsequent requests.
183
+ return { slug, outcome: 'serialized' }
184
+ } else {
185
+ return { slug, outcome: 'error', error: acquire.error }
186
+ }
187
+ }
188
+
189
+ try {
190
+ if (action === 'unpublish') {
191
+ const url = new URL(`${config.site.replace(/\/$/, '')}/${slug}`).pathname
192
+ const aeo = resolveAeoTwins(config.aeoTwins)
193
+ const twinPath = `${url.replace(/\/+$/, '')}.md`
194
+ const summaryPath = `${twinPath}.summary.md`
195
+ await deleteFreshTwin(freshLayer, twinPath)
196
+ if (aeo?.summaryTwin) await deleteFreshTwin(freshLayer, summaryPath)
197
+ return { slug, outcome: 'deleted', durationMs: Date.now() - start }
198
+ }
199
+
200
+ // Publish / update.
201
+ let items: ContentItem[]
202
+ try {
203
+ items = await contentProvider({ type: 'articles', slugs: [slug] }, {} as never)
204
+ } catch (err) {
205
+ return {
206
+ slug,
207
+ outcome: 'error',
208
+ error: err instanceof Error ? err.message : String(err),
209
+ }
210
+ }
211
+ const item = items.find((i) => i.url.endsWith(`/${slug}`) || i.url === slug)
212
+ if (!item) return { slug, outcome: 'not_found' }
213
+
214
+ const body = item.description ?? ''
215
+ const aeo = resolveAeoTwins(config.aeoTwins)
216
+ const primaryUrl = (aeo?.twinUrl ?? defaultTwin)(item.url)
217
+ const summaryUrl = aeo?.summaryTwin ? `${primaryUrl}.summary.md` : undefined
218
+ const markdown = generateAeoMarkdown(item, {
219
+ publisherName: config.organization.name,
220
+ schemaType: config.schemaType,
221
+ content: body,
222
+ ragChunkMarkers: aeo?.ragChunkMarkers,
223
+ canonical: item.url,
224
+ twinUrl: primaryUrl,
225
+ summaryUrl,
226
+ })
227
+ await writeFreshTwin(freshLayer, pathOf(primaryUrl), markdown)
228
+ if (aeo?.summaryTwin && summaryUrl) {
229
+ const summary = generateSummaryTwin(item, {
230
+ publisherName: config.organization.name,
231
+ schemaType: config.schemaType,
232
+ content: body,
233
+ fullUrl: primaryUrl,
234
+ })
235
+ await writeFreshTwin(freshLayer, pathOf(summaryUrl), summary.markdown)
236
+ }
237
+
238
+ return { slug, outcome: 'written', durationMs: Date.now() - start }
239
+ } finally {
240
+ if (coord && leaseId) {
241
+ await coordCall(coord, { action: 'release-lock', slug, leaseId }).catch(() => {})
242
+ }
243
+ }
244
+ }
245
+
246
+ // ─── Helpers ───
247
+
248
+ async function coordCall(
249
+ stub: { fetch(request: Request): Promise<Response> },
250
+ body: Record<string, unknown>,
251
+ ): Promise<Record<string, unknown>> {
252
+ try {
253
+ const res = await stub.fetch(
254
+ new Request('https://internal/', {
255
+ method: 'POST',
256
+ body: JSON.stringify(body),
257
+ headers: { 'Content-Type': 'application/json' },
258
+ }),
259
+ )
260
+ return (await res.json()) as Record<string, unknown>
261
+ } catch {
262
+ return { ok: false, error: 'coord_unavailable' }
263
+ }
264
+ }
265
+
266
+ function constantTimeEqual(a: string, b: string): boolean {
267
+ if (a.length !== b.length) return false
268
+ let diff = 0
269
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
270
+ return diff === 0
271
+ }
272
+
273
+ function defaultTwin(url: string): string {
274
+ return `${url.replace(/\/+$/, '')}.md`
275
+ }
276
+
277
+ function pathOf(url: string): string {
278
+ try {
279
+ return new URL(url).pathname
280
+ } catch {
281
+ return url.startsWith('/') ? url : `/${url}`
282
+ }
283
+ }
284
+
285
+ async function purgeSitemapMarkdownCache(request: Request): Promise<void> {
286
+ const origin = new URL(request.url).origin
287
+ const key = new Request(`${origin}/sitemap-markdown.xml`)
288
+ await caches.default.delete(key).catch(() => {})
289
+ }
290
+
291
+ function json(body: Record<string, unknown>, status = 200): Response {
292
+ return new Response(JSON.stringify(body), {
293
+ status,
294
+ headers: { 'Content-Type': 'application/json' },
295
+ })
296
+ }
297
+
298
+ declare const caches: { default: { delete(key: Request): Promise<boolean> } }
@@ -0,0 +1,21 @@
1
+ import 'virtual:growth-labs/seo/config'
2
+ import type { APIRoute } from 'astro'
3
+ import { getConfig, getContentProvider } from '../_internal/state.js'
4
+ import { resolveSeoConfig } from '../site-url.js'
5
+ import { generateRobotsTxt } from '../utils/robots.js'
6
+
7
+ export const prerender = false
8
+
9
+ export const GET: APIRoute = async () => {
10
+ const config = resolveSeoConfig(getConfig())
11
+ // The sitemap-index route is only injected when a content provider is wired.
12
+ // Detect that at request time via getContentProvider() — the virtual module
13
+ // seeds state with the same provider the integration gated injection on.
14
+ // Without this, /robots.txt would ship a Sitemap: line pointing at a 404.
15
+ const includeSitemap = getContentProvider() !== undefined
16
+ const txt = generateRobotsTxt(config, { includeSitemap })
17
+
18
+ return new Response(txt, {
19
+ headers: { 'Content-Type': 'text/plain; charset=utf-8' },
20
+ })
21
+ }
@@ -0,0 +1,29 @@
1
+ import 'virtual:growth-labs/seo/config'
2
+ import type { APIRoute } from 'astro'
3
+ import { getConfig, getContentProvider } from '../_internal/state.js'
4
+ import { resolveSeoConfig } from '../site-url.js'
5
+ import type { ContentItem } from '../types.js'
6
+ import { forRss } from '../utils/content-filter.js'
7
+ import { generateRssFeed } from '../utils/rss.js'
8
+
9
+ export const prerender = false
10
+
11
+ export const GET: APIRoute = async (context) => {
12
+ const config = resolveSeoConfig(getConfig())
13
+ const contentProvider = getContentProvider()
14
+
15
+ let articles: ContentItem[] = []
16
+ if (contentProvider) {
17
+ try {
18
+ articles = await contentProvider({ type: 'articles' }, context)
19
+ } catch {}
20
+ }
21
+
22
+ // Members items excluded unconditionally. Public items respect includeInFeed.
23
+ const filtered = forRss(articles)
24
+ const xml = generateRssFeed(filtered, config)
25
+
26
+ return new Response(xml, {
27
+ headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' },
28
+ })
29
+ }
@@ -0,0 +1,25 @@
1
+ import 'virtual:growth-labs/seo/config'
2
+ import type { APIRoute } from 'astro'
3
+ import { getConfig, getContentProvider } from '../_internal/state.js'
4
+ import type { ContentItem } from '../types.js'
5
+ import { generateArticleSitemap } from '../utils/sitemap.js'
6
+
7
+ export const prerender = false
8
+
9
+ export const GET: APIRoute = async (context) => {
10
+ const config = getConfig()
11
+ const contentProvider = getContentProvider()
12
+
13
+ let articles: ContentItem[] = []
14
+ if (contentProvider) {
15
+ try {
16
+ articles = await contentProvider({ type: 'articles' }, context as any)
17
+ } catch {}
18
+ }
19
+
20
+ const xml = generateArticleSitemap(articles, config)
21
+
22
+ return new Response(xml, {
23
+ headers: { 'Content-Type': 'application/xml; charset=utf-8' },
24
+ })
25
+ }
@@ -0,0 +1,89 @@
1
+ import 'virtual:growth-labs/seo/config'
2
+ import type { APIRoute } from 'astro'
3
+ import { getConfig, getContentProvider } from '../_internal/state.js'
4
+ import { resolveSeoConfig } from '../site-url.js'
5
+ import type { SitemapEntry } from '../utils/sitemap.js'
6
+ import { generateSitemapIndex } from '../utils/sitemap.js'
7
+
8
+ export const prerender = false
9
+
10
+ export const GET: APIRoute = async (context) => {
11
+ const config = resolveSeoConfig(getConfig())
12
+ const contentProvider = getContentProvider()
13
+
14
+ const sitemaps: SitemapEntry[] = []
15
+
16
+ const { site } = config
17
+
18
+ // Fetch articles lastmod if possible
19
+ let articlesLastmod: string | undefined
20
+ let pagesLastmod: string | undefined
21
+ let videosLastmod: string | undefined
22
+ let productsLastmod: string | undefined
23
+
24
+ if (contentProvider) {
25
+ try {
26
+ const articles = await contentProvider({ type: 'articles' }, context as any)
27
+ if (articles.length > 0) {
28
+ const dates = articles
29
+ .map((a) => a.dateModified ?? a.datePublished)
30
+ .filter(Boolean) as string[]
31
+ if (dates.length > 0) {
32
+ articlesLastmod = dates.sort().at(-1)
33
+ }
34
+ }
35
+ } catch {}
36
+
37
+ try {
38
+ const pages = await contentProvider({ type: 'pages' }, context as any)
39
+ if (pages.length > 0) {
40
+ const dates = pages
41
+ .map((p) => p.dateModified ?? p.datePublished)
42
+ .filter(Boolean) as string[]
43
+ if (dates.length > 0) {
44
+ pagesLastmod = dates.sort().at(-1)
45
+ }
46
+ }
47
+ } catch {}
48
+
49
+ try {
50
+ const videos = await contentProvider({ type: 'videos' }, context as any)
51
+ if (videos.length > 0) {
52
+ const dates = videos
53
+ .map((v) => v.dateModified ?? v.datePublished)
54
+ .filter(Boolean) as string[]
55
+ if (dates.length > 0) {
56
+ videosLastmod = dates.sort().at(-1)
57
+ }
58
+ }
59
+ } catch {}
60
+
61
+ if (config.commerce?.enabled) {
62
+ try {
63
+ const products = await contentProvider({ type: 'products' }, context as any)
64
+ if (products.length > 0) {
65
+ const dates = products
66
+ .map((p) => p.dateModified ?? p.datePublished)
67
+ .filter(Boolean) as string[]
68
+ if (dates.length > 0) {
69
+ productsLastmod = dates.sort().at(-1)
70
+ }
71
+ }
72
+ } catch {}
73
+ }
74
+ }
75
+
76
+ sitemaps.push({ loc: `${site}/sitemap-articles.xml`, lastmod: articlesLastmod })
77
+ sitemaps.push({ loc: `${site}/sitemap-pages.xml`, lastmod: pagesLastmod })
78
+ sitemaps.push({ loc: `${site}/sitemap-videos.xml`, lastmod: videosLastmod })
79
+
80
+ if (config.commerce?.enabled) {
81
+ sitemaps.push({ loc: `${site}/sitemap-products.xml`, lastmod: productsLastmod })
82
+ }
83
+
84
+ const xml = generateSitemapIndex(sitemaps)
85
+
86
+ return new Response(xml, {
87
+ headers: { 'Content-Type': 'application/xml; charset=utf-8' },
88
+ })
89
+ }
@@ -0,0 +1,39 @@
1
+ import 'virtual:growth-labs/seo/config'
2
+ import type { APIRoute } from 'astro'
3
+ import { getConfig, getContentProvider } from '../_internal/state.js'
4
+ import { resolveAeoTwins } from '../options.js'
5
+ import type { ContentItem } from '../types.js'
6
+ import { generateMarkdownSitemap } from '../utils/sitemap-markdown.js'
7
+
8
+ export const prerender = false
9
+
10
+ export const GET: APIRoute = async (context) => {
11
+ const config = getConfig()
12
+ const contentProvider = getContentProvider()
13
+
14
+ // Only emit when twin URLs actually exist on disk (static or both modes).
15
+ // Middleware-mode twins live at the same URL as HTML; no distinct twin URLs.
16
+ const aeo = resolveAeoTwins(config.aeoTwins)
17
+ if (!config.markdownSitemap || !aeo || aeo.mode === 'middleware') {
18
+ return new Response('sitemap-markdown not enabled', { status: 404 })
19
+ }
20
+
21
+ let articles: ContentItem[] = []
22
+ if (contentProvider) {
23
+ try {
24
+ articles = await contentProvider({ type: 'articles' }, context)
25
+ } catch {}
26
+ }
27
+
28
+ const xml = generateMarkdownSitemap({
29
+ items: articles,
30
+ twinUrl: aeo.twinUrl,
31
+ // R2-freshness freshLayerLastmod is wired at middleware level via the DO;
32
+ // at the build-route layer we use build-time dateModified. After revalidation
33
+ // writes, the consumer's cache purge causes this route to regenerate.
34
+ })
35
+
36
+ return new Response(xml, {
37
+ headers: { 'Content-Type': 'application/xml; charset=utf-8' },
38
+ })
39
+ }
@@ -0,0 +1,24 @@
1
+ import 'virtual:growth-labs/seo/config'
2
+ import type { APIRoute } from 'astro'
3
+ import { getContentProvider } from '../_internal/state.js'
4
+ import type { ContentItem } from '../types.js'
5
+ import { generatePagesSitemap } from '../utils/sitemap.js'
6
+
7
+ export const prerender = false
8
+
9
+ export const GET: APIRoute = async (context) => {
10
+ const contentProvider = getContentProvider()
11
+
12
+ let pages: ContentItem[] = []
13
+ if (contentProvider) {
14
+ try {
15
+ pages = await contentProvider({ type: 'pages' }, context as any)
16
+ } catch {}
17
+ }
18
+
19
+ const xml = generatePagesSitemap(pages)
20
+
21
+ return new Response(xml, {
22
+ headers: { 'Content-Type': 'application/xml; charset=utf-8' },
23
+ })
24
+ }
@@ -0,0 +1,24 @@
1
+ import 'virtual:growth-labs/seo/config'
2
+ import type { APIRoute } from 'astro'
3
+ import { getContentProvider } from '../_internal/state.js'
4
+ import type { ContentItem } from '../types.js'
5
+ import { generateProductSitemap } from '../utils/sitemap.js'
6
+
7
+ export const prerender = false
8
+
9
+ export const GET: APIRoute = async (context) => {
10
+ const contentProvider = getContentProvider()
11
+
12
+ let products: ContentItem[] = []
13
+ if (contentProvider) {
14
+ try {
15
+ products = await contentProvider({ type: 'products' }, context as any)
16
+ } catch {}
17
+ }
18
+
19
+ const xml = generateProductSitemap(products)
20
+
21
+ return new Response(xml, {
22
+ headers: { 'Content-Type': 'application/xml; charset=utf-8' },
23
+ })
24
+ }
@@ -0,0 +1,24 @@
1
+ import 'virtual:growth-labs/seo/config'
2
+ import type { APIRoute } from 'astro'
3
+ import { getContentProvider } from '../_internal/state.js'
4
+ import type { ContentItem } from '../types.js'
5
+ import { generateVideoSitemap } from '../utils/sitemap.js'
6
+
7
+ export const prerender = false
8
+
9
+ export const GET: APIRoute = async (context) => {
10
+ const contentProvider = getContentProvider()
11
+
12
+ let videos: ContentItem[] = []
13
+ if (contentProvider) {
14
+ try {
15
+ videos = await contentProvider({ type: 'videos' }, context as any)
16
+ } catch {}
17
+ }
18
+
19
+ const xml = generateVideoSitemap(videos)
20
+
21
+ return new Response(xml, {
22
+ headers: { 'Content-Type': 'application/xml; charset=utf-8' },
23
+ })
24
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { env as cloudflareEnv } from 'cloudflare:workers'
2
+
3
+ interface RuntimeContext {
4
+ locals?: {
5
+ cfContext?: {
6
+ waitUntil?: (promise: Promise<unknown>) => void
7
+ }
8
+ }
9
+ }
10
+
11
+ export function getRuntimeEnv(_context: RuntimeContext): Record<string, unknown> {
12
+ return cloudflareEnv as Record<string, unknown>
13
+ }
14
+
15
+ export function getWaitUntil(context: RuntimeContext): (promise: Promise<unknown>) => void {
16
+ return context.locals?.cfContext?.waitUntil?.bind(context.locals.cfContext) ?? (() => {})
17
+ }