@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,155 @@
1
+ import type { ResolvedSeoOptions } from '../../options.js'
2
+ import type { ContentProduct, JsonLdObject, ProductVariant } from '../../types.js'
3
+
4
+ const AVAILABILITY_MAP: Record<string, string> = {
5
+ InStock: 'https://schema.org/InStock',
6
+ OutOfStock: 'https://schema.org/OutOfStock',
7
+ PreOrder: 'https://schema.org/PreOrder',
8
+ Discontinued: 'https://schema.org/Discontinued',
9
+ }
10
+
11
+ function buildOffer(
12
+ price: number,
13
+ currency: string,
14
+ availability: string,
15
+ url: string,
16
+ condition?: string,
17
+ hasReturnPolicy?: boolean,
18
+ ): JsonLdObject {
19
+ const offer: JsonLdObject = {
20
+ '@type': 'Offer',
21
+ price,
22
+ priceCurrency: currency,
23
+ availability: AVAILABILITY_MAP[availability] ?? `https://schema.org/${availability}`,
24
+ url,
25
+ }
26
+ if (condition) {
27
+ offer.itemCondition = `https://schema.org/${condition}`
28
+ }
29
+ if (hasReturnPolicy) {
30
+ offer.hasMerchantReturnPolicy = { '@id': '#return-policy' }
31
+ }
32
+ return offer
33
+ }
34
+
35
+ function buildVariantProduct(
36
+ variant: ProductVariant,
37
+ url: string,
38
+ productCurrency: string,
39
+ hasReturnPolicy: boolean,
40
+ ): JsonLdObject {
41
+ const currency = variant.currency ?? productCurrency
42
+ const v: JsonLdObject = {
43
+ '@type': 'Product',
44
+ name: variant.name,
45
+ offers: buildOffer(
46
+ variant.price,
47
+ currency,
48
+ variant.availability,
49
+ url,
50
+ undefined,
51
+ hasReturnPolicy,
52
+ ),
53
+ }
54
+ if (variant.sku) v.sku = variant.sku
55
+ if (variant.image) v.image = variant.image
56
+ return v
57
+ }
58
+
59
+ export function generateProductJsonLd(
60
+ product: ContentProduct,
61
+ url: string,
62
+ options: ResolvedSeoOptions,
63
+ ): JsonLdObject {
64
+ const hasReturnPolicy = !!(options.commerce?.enabled && options.commerce.returnPolicy)
65
+
66
+ const result: JsonLdObject = {
67
+ '@context': 'https://schema.org',
68
+ '@type': 'Product',
69
+ name: product.name,
70
+ description: product.description,
71
+ image: product.images,
72
+ offers: buildOffer(
73
+ product.price,
74
+ product.currency,
75
+ product.availability,
76
+ url,
77
+ product.condition,
78
+ hasReturnPolicy,
79
+ ),
80
+ }
81
+
82
+ if (product.sku) result.sku = product.sku
83
+ if (product.gtin) result.gtin = product.gtin
84
+ if (product.mpn) result.mpn = product.mpn
85
+
86
+ if (product.brand) {
87
+ result.brand = {
88
+ '@type': 'Brand',
89
+ name: product.brand,
90
+ }
91
+ }
92
+
93
+ if (product.rating) {
94
+ result.aggregateRating = {
95
+ '@type': 'AggregateRating',
96
+ ratingValue: product.rating.value,
97
+ reviewCount: product.rating.count,
98
+ ...(product.rating.bestRating !== undefined ? { bestRating: product.rating.bestRating } : {}),
99
+ }
100
+ }
101
+
102
+ if (product.reviews?.length) {
103
+ result.review = product.reviews.map((review) => {
104
+ const r: JsonLdObject = {
105
+ '@type': 'Review',
106
+ author: { '@type': 'Person', name: review.author },
107
+ reviewRating: {
108
+ '@type': 'Rating',
109
+ ratingValue: review.rating,
110
+ },
111
+ }
112
+ if (review.body) r.reviewBody = review.body
113
+ if (review.datePublished) r.datePublished = review.datePublished
114
+ return r
115
+ })
116
+ }
117
+
118
+ return result
119
+ }
120
+
121
+ export function generateProductGroupJsonLd(
122
+ product: ContentProduct,
123
+ url: string,
124
+ options: ResolvedSeoOptions,
125
+ ): JsonLdObject {
126
+ const hasReturnPolicy = !!(options.commerce?.enabled && options.commerce.returnPolicy)
127
+ const variants = product.variants ?? []
128
+
129
+ const result: JsonLdObject = {
130
+ '@context': 'https://schema.org',
131
+ '@type': 'ProductGroup',
132
+ name: product.name,
133
+ description: product.description,
134
+ image: product.images,
135
+ hasVariant: variants.map((v) => buildVariantProduct(v, url, product.currency, hasReturnPolicy)),
136
+ }
137
+
138
+ if (product.brand) {
139
+ result.brand = {
140
+ '@type': 'Brand',
141
+ name: product.brand,
142
+ }
143
+ }
144
+
145
+ if (product.rating) {
146
+ result.aggregateRating = {
147
+ '@type': 'AggregateRating',
148
+ ratingValue: product.rating.value,
149
+ reviewCount: product.rating.count,
150
+ ...(product.rating.bestRating !== undefined ? { bestRating: product.rating.bestRating } : {}),
151
+ }
152
+ }
153
+
154
+ return result
155
+ }
@@ -0,0 +1,20 @@
1
+ import type { ContentItem, JsonLdObject } from '../../types.js'
2
+
3
+ export function generateVideoJsonLd(item: ContentItem): JsonLdObject {
4
+ const video = item.video!
5
+
6
+ const result: JsonLdObject = {
7
+ '@context': 'https://schema.org',
8
+ '@type': 'VideoObject',
9
+ name: item.title,
10
+ thumbnailUrl: video.thumbnailUrl,
11
+ duration: video.duration,
12
+ }
13
+
14
+ if (item.description) result.description = item.description
15
+ if (video.contentUrl) result.contentUrl = video.contentUrl
16
+ if (video.embedUrl) result.embedUrl = video.embedUrl
17
+ if (item.datePublished) result.uploadDate = item.datePublished
18
+
19
+ return result
20
+ }
@@ -0,0 +1,27 @@
1
+ import type { SeoOptionsWithResolvedSite } from '../../options.js'
2
+ import type { JsonLdObject } from '../../types.js'
3
+
4
+ export function generateWebSiteJsonLd(options: SeoOptionsWithResolvedSite): JsonLdObject {
5
+ const { site, organization, locales } = options
6
+
7
+ const result: JsonLdObject = {
8
+ '@context': 'https://schema.org',
9
+ '@type': 'WebSite',
10
+ name: organization.name,
11
+ url: site,
12
+ potentialAction: {
13
+ '@type': 'SearchAction',
14
+ target: {
15
+ '@type': 'EntryPoint',
16
+ urlTemplate: `${site}/?q={search_term_string}`,
17
+ },
18
+ 'query-input': 'required name=search_term_string',
19
+ },
20
+ }
21
+
22
+ if (locales?.length) {
23
+ result.inLanguage = locales.map((l) => l.lang)
24
+ }
25
+
26
+ return result
27
+ }
@@ -0,0 +1,90 @@
1
+ import type { ContentItem } from '../types.js'
2
+ import { forLlmsFull } from './content-filter.js'
3
+
4
+ export interface GenerateLlmsFullOptions {
5
+ items: ContentItem[]
6
+ siteName: string
7
+ // Resolver that produces the full markdown body for an item. Items for which
8
+ // the resolver returns undefined are emitted as title/url/description only.
9
+ contentMarkdown?: (item: ContentItem) => string | undefined
10
+ // Soft cap on total bytes. Defaults to 8 MB. When exceeded, truncation happens
11
+ // on item boundaries (never mid-body) with a trailing note. Bytes, not UTF-16
12
+ // length — measured via TextEncoder.
13
+ maxBytes?: number
14
+ }
15
+
16
+ const DEFAULT_MAX_BYTES = 8 * 1024 * 1024
17
+
18
+ /**
19
+ * Generate `/llms-full.txt` — a bulk public corpus dump for LLM retrievers.
20
+ *
21
+ * Structure:
22
+ * # <siteName>
23
+ *
24
+ * ## <article title>
25
+ * URL: <canonical>
26
+ *
27
+ * <markdown body>
28
+ *
29
+ * ---
30
+ *
31
+ * [next article...]
32
+ *
33
+ * Rules:
34
+ * - Members items excluded UNCONDITIONALLY.
35
+ * - Size cap honored; on-boundary truncation with a note.
36
+ * - Deterministic order: items passed in, order preserved.
37
+ */
38
+ export function generateLlmsFull({
39
+ items,
40
+ siteName,
41
+ contentMarkdown,
42
+ maxBytes = DEFAULT_MAX_BYTES,
43
+ }: GenerateLlmsFullOptions): string {
44
+ const filtered = forLlmsFull(items)
45
+ const encoder = new TextEncoder()
46
+
47
+ const header = `# ${siteName}\n\n`
48
+ let byteCount = encoder.encode(header).length
49
+ const parts: string[] = [header]
50
+ let truncated = false
51
+
52
+ for (const item of filtered) {
53
+ const body = contentMarkdown?.(item)
54
+ const chunk = buildChunk(item, body)
55
+ const chunkBytes = encoder.encode(chunk).length
56
+ if (byteCount + chunkBytes > maxBytes) {
57
+ truncated = true
58
+ break
59
+ }
60
+ parts.push(chunk)
61
+ byteCount += chunkBytes
62
+ }
63
+
64
+ if (truncated) {
65
+ parts.push(
66
+ '\n\n---\n\n> Corpus truncated at configured size cap. See /sitemap-markdown.xml for the full twin URL list.\n',
67
+ )
68
+ }
69
+
70
+ return parts.join('')
71
+ }
72
+
73
+ function buildChunk(item: ContentItem, body: string | undefined): string {
74
+ const lines: string[] = []
75
+ lines.push(`## ${item.title}`)
76
+ lines.push('')
77
+ lines.push(`URL: ${item.url}`)
78
+ if (item.datePublished) lines.push(`Published: ${item.datePublished}`)
79
+ if (item.dateModified) lines.push(`Modified: ${item.dateModified}`)
80
+ lines.push('')
81
+ if (body) {
82
+ lines.push(body.trim())
83
+ } else if (item.description) {
84
+ lines.push(item.description.trim())
85
+ }
86
+ lines.push('')
87
+ lines.push('---')
88
+ lines.push('')
89
+ return `${lines.join('\n')}\n`
90
+ }
@@ -0,0 +1,45 @@
1
+ import type { ResolvedSeoOptions } from '../options.js'
2
+
3
+ /**
4
+ * Generate llms.txt per the llmstxt.org spec
5
+ * Format: # Title, > description, ## sections with links, ## Optional links
6
+ */
7
+ export function generateLlmsTxt(options: ResolvedSeoOptions): string {
8
+ const { organization, llmsContent } = options
9
+
10
+ const lines: string[] = []
11
+
12
+ // Title
13
+ lines.push(`# ${organization.name}`)
14
+ lines.push('')
15
+
16
+ if (llmsContent) {
17
+ // Description block
18
+ if (llmsContent.description) {
19
+ lines.push(`> ${llmsContent.description}`)
20
+ lines.push('')
21
+ }
22
+
23
+ // Sections with links
24
+ for (const section of llmsContent.sections) {
25
+ lines.push(`## ${section.heading}`)
26
+ lines.push('')
27
+ for (const link of section.links) {
28
+ lines.push(`- [${link.title}](${link.url}): ${link.description}`)
29
+ }
30
+ lines.push('')
31
+ }
32
+
33
+ // Optional links
34
+ if (llmsContent.optionalLinks?.length) {
35
+ lines.push('## Optional')
36
+ lines.push('')
37
+ for (const link of llmsContent.optionalLinks) {
38
+ lines.push(`- [${link.title}](${link.url})`)
39
+ }
40
+ lines.push('')
41
+ }
42
+ }
43
+
44
+ return `${lines.join('\n').trimEnd()}\n`
45
+ }
@@ -0,0 +1,184 @@
1
+ import type { ResolvedSeoOptions } from '../options.js'
2
+ import type { CanonicalLink, ContentItem, MetaTag } from '../types.js'
3
+
4
+ export type OgVariant = 'article' | 'product' | 'website'
5
+
6
+ export function applyTrailingSlash(url: string, policy: string): string {
7
+ if (policy === 'never') {
8
+ // Strip trailing slash but preserve root slash
9
+ if (url.endsWith('/') && url !== '/') {
10
+ try {
11
+ const parsed = new URL(url)
12
+ if (parsed.pathname !== '/') {
13
+ parsed.pathname = parsed.pathname.replace(/\/+$/, '')
14
+ return parsed.toString()
15
+ }
16
+ return url
17
+ } catch {
18
+ return url.endsWith('/') && url.length > 1 ? url.slice(0, -1) : url
19
+ }
20
+ }
21
+ return url
22
+ }
23
+ if (policy === 'always') {
24
+ if (!url.endsWith('/')) {
25
+ try {
26
+ const parsed = new URL(url)
27
+ // Don't add slash if there's a query string or hash
28
+ if (!parsed.search && !parsed.hash) {
29
+ parsed.pathname = parsed.pathname.replace(/\/*$/, '/')
30
+ return parsed.toString()
31
+ }
32
+ return url
33
+ } catch {
34
+ return `${url}/`
35
+ }
36
+ }
37
+ return url
38
+ }
39
+ // ignore
40
+ return url
41
+ }
42
+
43
+ export function generateCanonical(url: string, options: ResolvedSeoOptions): CanonicalLink {
44
+ return {
45
+ rel: 'canonical',
46
+ href: applyTrailingSlash(url, options.trailingSlash),
47
+ }
48
+ }
49
+
50
+ export interface GenerateMetaOptions {
51
+ // When true, emits <meta name="googlebot" content="noarchive"> to prevent Google
52
+ // from serving the (paywalled) full body as a cached page under Flexible Sampling.
53
+ // Called by middleware when the response includes the full gated body for verified
54
+ // Googlebot.
55
+ flexibleSamplingActive?: boolean
56
+ }
57
+
58
+ export function generateMeta(
59
+ item: ContentItem,
60
+ variant: OgVariant,
61
+ options: ResolvedSeoOptions,
62
+ metaOptions: GenerateMetaOptions = {},
63
+ ): MetaTag[] {
64
+ const tags: MetaTag[] = []
65
+ const { defaults, organization } = options
66
+ const canonicalUrl = applyTrailingSlash(item.url, options.trailingSlash)
67
+ const firstImage = Array.isArray(item.image) ? item.image[0] : item.image
68
+ const image = firstImage ?? defaults.defaultImage ?? ''
69
+
70
+ const ogType =
71
+ variant === 'article' ? 'article' : variant === 'product' ? 'og:product' : 'website'
72
+
73
+ // og:type
74
+ tags.push({ property: 'og:type', content: ogType })
75
+
76
+ // og:title
77
+ tags.push({ property: 'og:title', content: item.title })
78
+
79
+ // og:description
80
+ if (item.description) {
81
+ tags.push({ property: 'og:description', content: item.description })
82
+ }
83
+
84
+ // og:url
85
+ tags.push({ property: 'og:url', content: canonicalUrl })
86
+
87
+ // og:image
88
+ if (image) {
89
+ tags.push({ property: 'og:image', content: image })
90
+ tags.push({ property: 'og:image:width', content: '1200' })
91
+ tags.push({ property: 'og:image:height', content: '630' })
92
+ }
93
+
94
+ // og:site_name
95
+ tags.push({ property: 'og:site_name', content: organization.name })
96
+
97
+ // og:locale
98
+ const locale = item.locale ?? defaults.locale
99
+ tags.push({ property: 'og:locale', content: locale })
100
+
101
+ // og:locale:alternate
102
+ if (item.alternateLocales?.length) {
103
+ for (const alt of item.alternateLocales) {
104
+ tags.push({ property: 'og:locale:alternate', content: alt.lang })
105
+ }
106
+ }
107
+
108
+ // twitter:card
109
+ tags.push({ name: 'twitter:card', content: defaults.twitterCardType })
110
+
111
+ // twitter:site
112
+ if (defaults.twitterSite) {
113
+ tags.push({ name: 'twitter:site', content: defaults.twitterSite })
114
+ }
115
+
116
+ // twitter:title
117
+ tags.push({ name: 'twitter:title', content: item.title })
118
+
119
+ // twitter:description
120
+ if (item.description) {
121
+ tags.push({ name: 'twitter:description', content: item.description })
122
+ }
123
+
124
+ // twitter:image
125
+ if (image) {
126
+ tags.push({ name: 'twitter:image', content: image })
127
+ }
128
+
129
+ // robots
130
+ tags.push({ name: 'robots', content: 'max-image-preview:large' })
131
+
132
+ // Flexible Sampling: prevent Google from serving the premium body from cache.
133
+ if (metaOptions.flexibleSamplingActive) {
134
+ tags.push({ name: 'googlebot', content: 'noarchive' })
135
+ }
136
+
137
+ // Apple News meta tags.
138
+ // `apple-news-publishable` is opt-in for the channel to pick up the article.
139
+ // Resolved at item level: `appleNewsPublishable: 'no'` overrides the channel default.
140
+ // Channel default comes from options.appleNews.defaultPublishable.
141
+ if (options.appleNews?.enabled) {
142
+ const channelDefault = options.appleNews.defaultPublishable
143
+ const effective = item.appleNewsPublishable ?? channelDefault
144
+ if (effective === 'yes' && item.access !== 'members') {
145
+ tags.push({ name: 'apple-news-publishable', content: 'yes' })
146
+ }
147
+ if (item.appleNewsId) {
148
+ tags.push({ name: 'apple-news-id', content: item.appleNewsId })
149
+ }
150
+ }
151
+
152
+ // news_keywords — read by both Google News and Apple News.
153
+ if (item.newsKeywords?.length) {
154
+ tags.push({ name: 'news_keywords', content: item.newsKeywords.join(', ') })
155
+ }
156
+
157
+ // Article-specific
158
+ if (variant === 'article') {
159
+ if (item.datePublished) {
160
+ tags.push({ property: 'article:published_time', content: item.datePublished })
161
+ }
162
+ if (item.dateModified) {
163
+ tags.push({ property: 'article:modified_time', content: item.dateModified })
164
+ }
165
+ if (item.authors?.length) {
166
+ for (const author of item.authors) {
167
+ tags.push({ property: 'article:author', content: author.url ?? author.name })
168
+ }
169
+ }
170
+ }
171
+
172
+ // Product-specific
173
+ if (variant === 'product' && item.product) {
174
+ const p = item.product
175
+ tags.push({ property: 'product:price:amount', content: String(p.price) })
176
+ tags.push({ property: 'product:price:currency', content: p.currency })
177
+ tags.push({ property: 'product:availability', content: p.availability })
178
+ if (p.brand) {
179
+ tags.push({ property: 'product:brand', content: p.brand })
180
+ }
181
+ }
182
+
183
+ return tags
184
+ }
@@ -0,0 +1,112 @@
1
+ import type { SeoOptionsWithResolvedSite } from '../options.js'
2
+ import type { ContentItem } from '../types.js'
3
+
4
+ function escapeXml(str: string): string {
5
+ return str
6
+ .replace(/&/g, '&amp;')
7
+ .replace(/</g, '&lt;')
8
+ .replace(/>/g, '&gt;')
9
+ .replace(/"/g, '&quot;')
10
+ .replace(/'/g, '&apos;')
11
+ }
12
+
13
+ /**
14
+ * Convert ISO 8601 duration (e.g. PT8M30S, PT1H2M3S) to M:SS or H:MM:SS
15
+ */
16
+ export function convertIso8601Duration(duration: string): string {
17
+ const match = duration.match(/^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$/)
18
+ if (!match) return duration
19
+
20
+ const hours = parseInt(match[1] ?? '0', 10)
21
+ const minutes = parseInt(match[2] ?? '0', 10)
22
+ const seconds = parseInt(match[3] ?? '0', 10)
23
+
24
+ if (hours > 0) {
25
+ const mm = String(minutes).padStart(2, '0')
26
+ const ss = String(seconds).padStart(2, '0')
27
+ return `${hours}:${mm}:${ss}`
28
+ }
29
+ const ss = String(seconds).padStart(2, '0')
30
+ return `${minutes}:${ss}`
31
+ }
32
+
33
+ function toRfc822(dateStr: string): string {
34
+ return new Date(dateStr).toUTCString()
35
+ }
36
+
37
+ export function generatePodcastFeed(
38
+ episodes: ContentItem[],
39
+ options: SeoOptionsWithResolvedSite,
40
+ ): string {
41
+ const { site, organization } = options
42
+ const podcast = options.podcast
43
+
44
+ // Use podcast config if available, fall back to organization
45
+ const author = podcast?.author ?? organization.name
46
+ const podcastTitle = podcast?.title ?? organization.name
47
+ const podcastDescription = podcast?.description ?? ''
48
+ const podcastImage = podcast?.image ?? organization.logo
49
+ const podcastCategory = podcast?.category ?? 'Technology'
50
+ const podcastLanguage = podcast?.language ?? 'en'
51
+ const podcastExplicit = podcast?.explicit ?? false
52
+ const podcastType = podcast?.type ?? 'episodic'
53
+ const ownerEmail = podcast?.email ?? ''
54
+
55
+ const episodesXml = episodes
56
+ .map((item) => {
57
+ const pubDate = item.datePublished ? toRfc822(item.datePublished) : ''
58
+ const pubDateTag = pubDate ? `\n <pubDate>${escapeXml(pubDate)}</pubDate>` : ''
59
+ const descTag = item.description
60
+ ? `\n <description>${escapeXml(item.description)}</description>`
61
+ : ''
62
+
63
+ const enclosureTag = item.audio
64
+ ? `\n <enclosure url="${escapeXml(item.audio.url)}" type="audio/mpeg" length="0"/>`
65
+ : ''
66
+
67
+ const durationTag = item.audio?.duration
68
+ ? `\n <itunes:duration>${escapeXml(convertIso8601Duration(item.audio.duration))}</itunes:duration>`
69
+ : ''
70
+
71
+ const ep = item.podcastEpisode
72
+ const episodeTag =
73
+ ep?.episodeNumber != null
74
+ ? `\n <itunes:episode>${ep.episodeNumber}</itunes:episode>`
75
+ : ''
76
+ const seasonTag =
77
+ ep?.seasonNumber != null ? `\n <itunes:season>${ep.seasonNumber}</itunes:season>` : ''
78
+ const episodeTypeTag = ep?.episodeType
79
+ ? `\n <itunes:episodeType>${escapeXml(ep.episodeType)}</itunes:episodeType>`
80
+ : ''
81
+
82
+ return ` <item>
83
+ <title>${escapeXml(item.title)}</title>
84
+ <link>${escapeXml(item.url)}</link>
85
+ <guid isPermaLink="true">${escapeXml(item.url)}</guid>${pubDateTag}${descTag}${enclosureTag}${durationTag}${episodeTag}${seasonTag}${episodeTypeTag}
86
+ </item>`
87
+ })
88
+ .join('\n')
89
+
90
+ return `<?xml version="1.0" encoding="UTF-8"?>
91
+ <rss version="2.0"
92
+ xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"
93
+ xmlns:podcast="https://podcastindex.org/namespace/1.0"
94
+ xmlns:atom="http://www.w3.org/2005/Atom">
95
+ <channel>
96
+ <title>${escapeXml(podcastTitle)}</title>
97
+ <description>${escapeXml(podcastDescription)}</description>
98
+ <link>${escapeXml(site)}</link>
99
+ <language>${escapeXml(podcastLanguage)}</language>
100
+ <itunes:author>${escapeXml(author)}</itunes:author>
101
+ <itunes:owner>
102
+ <itunes:name>${escapeXml(author)}</itunes:name>
103
+ <itunes:email>${escapeXml(ownerEmail)}</itunes:email>
104
+ </itunes:owner>
105
+ <itunes:image href="${escapeXml(podcastImage)}"/>
106
+ <itunes:category text="${escapeXml(podcastCategory)}"/>
107
+ <itunes:explicit>${podcastExplicit ? 'true' : 'false'}</itunes:explicit>
108
+ <itunes:type>${escapeXml(podcastType)}</itunes:type>
109
+ ${episodesXml}
110
+ </channel>
111
+ </rss>`
112
+ }
@@ -0,0 +1,47 @@
1
+ import type { SeoOptionsWithResolvedSite } from '../options.js'
2
+ import { SITEMAP_INDEX_PATH } from './sitemap.js'
3
+
4
+ export interface GenerateRobotsTxtOptions {
5
+ /**
6
+ * Emit a `Sitemap:` line pointing at SITEMAP_INDEX_PATH.
7
+ *
8
+ * The caller is responsible for ensuring the sitemap index route is
9
+ * actually being served. Pass `false` when no content provider is wired;
10
+ * otherwise crawlers follow the Sitemap: line to an empty or 404 index,
11
+ * which is a live, crawler-visible SEO regression.
12
+ */
13
+ includeSitemap: boolean
14
+ }
15
+
16
+ export function generateRobotsTxt(
17
+ options: SeoOptionsWithResolvedSite,
18
+ { includeSitemap }: GenerateRobotsTxtOptions,
19
+ ): string {
20
+ const { site, robots } = options
21
+ const lines: string[] = []
22
+
23
+ lines.push('User-agent: *')
24
+ lines.push('Allow: /')
25
+ if (includeSitemap) {
26
+ lines.push(`Sitemap: ${new URL(SITEMAP_INDEX_PATH, site).toString()}`)
27
+ }
28
+
29
+ if (robots.additionalRules?.length) {
30
+ for (const rule of robots.additionalRules) {
31
+ lines.push('')
32
+ lines.push(`User-agent: ${rule.userAgent}`)
33
+ if (rule.allow?.length) {
34
+ for (const path of rule.allow) {
35
+ lines.push(`Allow: ${path}`)
36
+ }
37
+ }
38
+ if (rule.disallow?.length) {
39
+ for (const path of rule.disallow) {
40
+ lines.push(`Disallow: ${path}`)
41
+ }
42
+ }
43
+ }
44
+ }
45
+
46
+ return `${lines.join('\n')}\n`
47
+ }