@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.
- package/dist/utils/validation.d.ts.map +1 -1
- package/dist/utils/validation.js +22 -0
- package/dist/utils/validation.js.map +1 -1
- package/package.json +9 -5
- package/src/_internal/state.ts +26 -0
- package/src/bindings.ts +146 -0
- package/src/cron/prune-aeo-r2.ts +140 -0
- package/src/durable-objects/aeo-revalidation-coord.ts +246 -0
- package/src/index.ts +380 -0
- package/src/middleware/seo.ts +350 -0
- package/src/options.ts +456 -0
- package/src/routes/aeo-twin.ts +130 -0
- package/src/routes/apple-news.ts +36 -0
- package/src/routes/llms-full.ts +36 -0
- package/src/routes/llms.ts +15 -0
- package/src/routes/podcast-narration.ts +45 -0
- package/src/routes/podcast.ts +27 -0
- package/src/routes/revalidate.ts +298 -0
- package/src/routes/robots.ts +21 -0
- package/src/routes/rss.ts +29 -0
- package/src/routes/sitemap-articles.ts +25 -0
- package/src/routes/sitemap-index.ts +89 -0
- package/src/routes/sitemap-markdown.ts +39 -0
- package/src/routes/sitemap-pages.ts +24 -0
- package/src/routes/sitemap-products.ts +24 -0
- package/src/routes/sitemap-videos.ts +24 -0
- package/src/runtime.ts +17 -0
- package/src/site-url-core.ts +71 -0
- package/src/site-url.ts +21 -0
- package/src/types.ts +166 -0
- package/src/utils/aeo-summary.ts +176 -0
- package/src/utils/aeo-twin-emitter.ts +173 -0
- package/src/utils/aeo.ts +223 -0
- package/src/utils/apple-news-anf.ts +163 -0
- package/src/utils/apple-news-rss.ts +136 -0
- package/src/utils/content-filter.ts +87 -0
- package/src/utils/crawler-class.ts +155 -0
- package/src/utils/define-content-provider.ts +65 -0
- package/src/utils/effective-auth.ts +44 -0
- package/src/utils/fcrdns.ts +269 -0
- package/src/utils/fresh-layer.ts +175 -0
- package/src/utils/hreflang.ts +26 -0
- package/src/utils/index.ts +91 -0
- package/src/utils/json-ld/article.ts +120 -0
- package/src/utils/json-ld/audio.ts +32 -0
- package/src/utils/json-ld/breadcrumb.ts +28 -0
- package/src/utils/json-ld/faq.ts +18 -0
- package/src/utils/json-ld/howto.ts +23 -0
- package/src/utils/json-ld/index.ts +12 -0
- package/src/utils/json-ld/item-list.ts +26 -0
- package/src/utils/json-ld/organization.ts +42 -0
- package/src/utils/json-ld/person.ts +25 -0
- package/src/utils/json-ld/product.ts +155 -0
- package/src/utils/json-ld/video.ts +20 -0
- package/src/utils/json-ld/website.ts +27 -0
- package/src/utils/llms-full.ts +90 -0
- package/src/utils/llms.ts +45 -0
- package/src/utils/meta.ts +184 -0
- package/src/utils/podcast.ts +112 -0
- package/src/utils/robots.ts +47 -0
- package/src/utils/rss.ts +64 -0
- package/src/utils/seo-head.ts +81 -0
- package/src/utils/sitemap-markdown.ts +80 -0
- package/src/utils/sitemap.ts +169 -0
- package/src/utils/staleness.ts +61 -0
- package/src/utils/validation.ts +308 -0
- package/src/virtual.d.ts +8 -0
- 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
|
+
}
|