@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,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, '&')
|
|
7
|
+
.replace(/</g, '<')
|
|
8
|
+
.replace(/>/g, '>')
|
|
9
|
+
.replace(/"/g, '"')
|
|
10
|
+
.replace(/'/g, ''')
|
|
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
|
+
}
|