@growth-labs/seo 0.5.0 → 0.6.0
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/README.md +8 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +84 -47
- package/dist/index.js.map +1 -1
- package/dist/middleware/seo.d.ts +1 -1
- package/dist/middleware/seo.d.ts.map +1 -1
- package/dist/middleware/seo.js +10 -1
- package/dist/middleware/seo.js.map +1 -1
- package/dist/options.d.ts +140 -0
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +18 -0
- package/dist/options.js.map +1 -1
- package/dist/routes/apple-news.d.ts +0 -1
- package/dist/routes/apple-news.d.ts.map +1 -1
- package/dist/routes/apple-news.js +0 -1
- package/dist/routes/apple-news.js.map +1 -1
- package/dist/routes/llms-full.d.ts +0 -1
- package/dist/routes/llms-full.d.ts.map +1 -1
- package/dist/routes/llms-full.js +0 -1
- package/dist/routes/llms-full.js.map +1 -1
- package/dist/routes/llms.d.ts +0 -1
- package/dist/routes/llms.d.ts.map +1 -1
- package/dist/routes/llms.js +0 -1
- package/dist/routes/llms.js.map +1 -1
- package/dist/routes/podcast-narration.d.ts +0 -1
- package/dist/routes/podcast-narration.d.ts.map +1 -1
- package/dist/routes/podcast-narration.js +0 -1
- package/dist/routes/podcast-narration.js.map +1 -1
- package/dist/routes/podcast.d.ts +0 -1
- package/dist/routes/podcast.d.ts.map +1 -1
- package/dist/routes/podcast.js +0 -1
- package/dist/routes/podcast.js.map +1 -1
- package/dist/routes/robots.d.ts +0 -1
- package/dist/routes/robots.d.ts.map +1 -1
- package/dist/routes/robots.js +0 -1
- package/dist/routes/robots.js.map +1 -1
- package/dist/routes/rss.d.ts +0 -1
- package/dist/routes/rss.d.ts.map +1 -1
- package/dist/routes/rss.js +0 -1
- package/dist/routes/rss.js.map +1 -1
- package/dist/routes/sitemap-articles.d.ts +0 -1
- package/dist/routes/sitemap-articles.d.ts.map +1 -1
- package/dist/routes/sitemap-articles.js +0 -1
- package/dist/routes/sitemap-articles.js.map +1 -1
- package/dist/routes/sitemap-index.d.ts +0 -1
- package/dist/routes/sitemap-index.d.ts.map +1 -1
- package/dist/routes/sitemap-index.js +45 -33
- package/dist/routes/sitemap-index.js.map +1 -1
- package/dist/routes/sitemap-markdown.d.ts +0 -1
- package/dist/routes/sitemap-markdown.d.ts.map +1 -1
- package/dist/routes/sitemap-markdown.js +0 -1
- package/dist/routes/sitemap-markdown.js.map +1 -1
- package/dist/routes/sitemap-pages.d.ts +0 -1
- package/dist/routes/sitemap-pages.d.ts.map +1 -1
- package/dist/routes/sitemap-pages.js +0 -1
- package/dist/routes/sitemap-pages.js.map +1 -1
- package/dist/routes/sitemap-products.d.ts +0 -1
- package/dist/routes/sitemap-products.d.ts.map +1 -1
- package/dist/routes/sitemap-products.js +0 -1
- package/dist/routes/sitemap-products.js.map +1 -1
- package/dist/routes/sitemap-videos.d.ts +0 -1
- package/dist/routes/sitemap-videos.d.ts.map +1 -1
- package/dist/routes/sitemap-videos.js +0 -1
- package/dist/routes/sitemap-videos.js.map +1 -1
- package/dist/utils/apple-news-rss.d.ts.map +1 -1
- package/dist/utils/apple-news-rss.js +10 -6
- package/dist/utils/apple-news-rss.js.map +1 -1
- package/dist/utils/rss.d.ts.map +1 -1
- package/dist/utils/rss.js +22 -1
- package/dist/utils/rss.js.map +1 -1
- package/dist/utils/sitemap.d.ts.map +1 -1
- package/dist/utils/sitemap.js +1 -1
- package/dist/utils/sitemap.js.map +1 -1
- package/dist/utils/validation.d.ts +5 -0
- package/dist/utils/validation.d.ts.map +1 -1
- package/dist/utils/validation.js +101 -4
- package/dist/utils/validation.js.map +1 -1
- package/dist/vite-plugin.d.ts +1 -1
- package/dist/vite-plugin.d.ts.map +1 -1
- package/dist/vite-plugin.js +45 -7
- package/dist/vite-plugin.js.map +1 -1
- package/package.json +2 -2
- package/src/components/AeoHead.astro +5 -2
- package/src/components/SeoHead.astro +5 -1
- package/src/index.ts +87 -47
- package/src/middleware/seo.ts +10 -1
- package/src/options.ts +21 -0
- package/src/routes/apple-news.ts +0 -2
- package/src/routes/llms-full.ts +0 -2
- package/src/routes/llms.ts +0 -2
- package/src/routes/podcast-narration.ts +0 -2
- package/src/routes/podcast.ts +0 -2
- package/src/routes/robots.ts +0 -2
- package/src/routes/rss.ts +0 -2
- package/src/routes/sitemap-articles.ts +0 -2
- package/src/routes/sitemap-index.ts +48 -37
- package/src/routes/sitemap-markdown.ts +0 -2
- package/src/routes/sitemap-pages.ts +0 -2
- package/src/routes/sitemap-products.ts +0 -2
- package/src/routes/sitemap-videos.ts +0 -2
- package/src/utils/apple-news-rss.ts +9 -6
- package/src/utils/rss.ts +24 -1
- package/src/utils/sitemap.ts +4 -2
- package/src/utils/validation.ts +119 -4
- package/src/virtual.d.ts +12 -0
- package/src/vite-plugin.ts +47 -8
|
@@ -4,8 +4,6 @@ import { getContentProvider } from '../_internal/state.js'
|
|
|
4
4
|
import type { ContentItem } from '../types.js'
|
|
5
5
|
import { generatePagesSitemap } from '../utils/sitemap.js'
|
|
6
6
|
|
|
7
|
-
export const prerender = false
|
|
8
|
-
|
|
9
7
|
export const GET: APIRoute = async (context) => {
|
|
10
8
|
const contentProvider = getContentProvider()
|
|
11
9
|
|
|
@@ -4,8 +4,6 @@ import { getContentProvider } from '../_internal/state.js'
|
|
|
4
4
|
import type { ContentItem } from '../types.js'
|
|
5
5
|
import { generateProductSitemap } from '../utils/sitemap.js'
|
|
6
6
|
|
|
7
|
-
export const prerender = false
|
|
8
|
-
|
|
9
7
|
export const GET: APIRoute = async (context) => {
|
|
10
8
|
const contentProvider = getContentProvider()
|
|
11
9
|
|
|
@@ -4,8 +4,6 @@ import { getContentProvider } from '../_internal/state.js'
|
|
|
4
4
|
import type { ContentItem } from '../types.js'
|
|
5
5
|
import { generateVideoSitemap } from '../utils/sitemap.js'
|
|
6
6
|
|
|
7
|
-
export const prerender = false
|
|
8
|
-
|
|
9
7
|
export const GET: APIRoute = async (context) => {
|
|
10
8
|
const contentProvider = getContentProvider()
|
|
11
9
|
|
|
@@ -60,14 +60,17 @@ export function generateAppleNewsRss({
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
const filtered = forAppleNews(items, { publishable: appleNews.defaultPublishable })
|
|
63
|
-
// Newest-first ordering.
|
|
63
|
+
// Newest-first ordering. Coerce missing/unparseable dates to 0 (sort last) —
|
|
64
|
+
// a NaN comparator result makes Array.sort leave the element in place, which
|
|
65
|
+
// could park a junk-dated item at the front of the capped feed.
|
|
66
|
+
const publishTime = (value?: string): number => {
|
|
67
|
+
if (!value) return 0
|
|
68
|
+
const t = new Date(value).getTime()
|
|
69
|
+
return Number.isNaN(t) ? 0 : t
|
|
70
|
+
}
|
|
64
71
|
const sorted = filtered
|
|
65
72
|
.slice()
|
|
66
|
-
.sort((a, b) =>
|
|
67
|
-
const at = a.datePublished ? new Date(a.datePublished).getTime() : 0
|
|
68
|
-
const bt = b.datePublished ? new Date(b.datePublished).getTime() : 0
|
|
69
|
-
return bt - at
|
|
70
|
-
})
|
|
73
|
+
.sort((a, b) => publishTime(b.datePublished) - publishTime(a.datePublished))
|
|
71
74
|
.slice(0, MAX_ITEMS)
|
|
72
75
|
|
|
73
76
|
const selfLink = `${options.site.replace(/\/$/, '')}${appleNews.feedPath}`
|
package/src/utils/rss.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import type { SeoOptionsWithResolvedSite } from '../options.js'
|
|
2
2
|
import type { ContentItem } from '../types.js'
|
|
3
3
|
|
|
4
|
+
// RSS is a "recent updates" surface, not a full archive (that's the sitemap).
|
|
5
|
+
// Capping the item count keeps the feed lightweight for readers AND bounds the
|
|
6
|
+
// prerendered /feed.xml asset — an uncapped full-body feed on a large catalog
|
|
7
|
+
// would eventually breach Cloudflare's 25 MiB per-asset limit. Mirrors the
|
|
8
|
+
// Apple News feed cap.
|
|
9
|
+
const RSS_MAX_ITEMS = 50
|
|
10
|
+
|
|
4
11
|
function escapeXml(str: string): string {
|
|
5
12
|
return str
|
|
6
13
|
.replace(/&/g, '&')
|
|
@@ -14,6 +21,17 @@ function toRfc822(dateStr: string): string {
|
|
|
14
21
|
return new Date(dateStr).toUTCString()
|
|
15
22
|
}
|
|
16
23
|
|
|
24
|
+
// Parse to epoch ms, coercing missing/unparseable dates to 0 so they sort last.
|
|
25
|
+
// A raw `new Date(bad).getTime()` returns NaN, and a NaN comparator result makes
|
|
26
|
+
// Array.sort leave the element in place — which can park a junk-dated item at the
|
|
27
|
+
// front of the capped feed. Content dates come from hand/LLM-authored frontmatter,
|
|
28
|
+
// so malformed values are realistic.
|
|
29
|
+
function publishTime(value: string | undefined): number {
|
|
30
|
+
if (!value) return 0
|
|
31
|
+
const t = new Date(value).getTime()
|
|
32
|
+
return Number.isNaN(t) ? 0 : t
|
|
33
|
+
}
|
|
34
|
+
|
|
17
35
|
export function generateRssFeed(
|
|
18
36
|
items: ContentItem[],
|
|
19
37
|
options: SeoOptionsWithResolvedSite,
|
|
@@ -24,7 +42,12 @@ export function generateRssFeed(
|
|
|
24
42
|
const language = defaults.locale.replace('_', '-').toLowerCase()
|
|
25
43
|
const lastBuildDate = new Date().toUTCString()
|
|
26
44
|
|
|
27
|
-
|
|
45
|
+
// Newest first, then cap. Missing/unparseable dates sort last (time 0).
|
|
46
|
+
const recentItems = [...items]
|
|
47
|
+
.sort((a, b) => publishTime(b.datePublished) - publishTime(a.datePublished))
|
|
48
|
+
.slice(0, RSS_MAX_ITEMS)
|
|
49
|
+
|
|
50
|
+
const itemsXml = recentItems
|
|
28
51
|
.map((item) => {
|
|
29
52
|
const pubDate = item.datePublished ? toRfc822(item.datePublished) : ''
|
|
30
53
|
const pubDateTag = pubDate ? `\n <pubDate>${escapeXml(pubDate)}</pubDate>` : ''
|
package/src/utils/sitemap.ts
CHANGED
|
@@ -105,9 +105,11 @@ ${entries}
|
|
|
105
105
|
|
|
106
106
|
export function generateVideoSitemap(items: ContentItem[]): string {
|
|
107
107
|
const entries = items
|
|
108
|
-
.filter((item)
|
|
108
|
+
.filter((item): item is ContentItem & { video: NonNullable<ContentItem['video']> } =>
|
|
109
|
+
Boolean(item.video && (item.video.contentUrl || item.video.embedUrl)),
|
|
110
|
+
)
|
|
109
111
|
.map((item) => {
|
|
110
|
-
const v = item.video
|
|
112
|
+
const v = item.video
|
|
111
113
|
const contentUrlTag = v.contentUrl
|
|
112
114
|
? `\n <video:content_loc>${escapeXml(v.contentUrl)}</video:content_loc>`
|
|
113
115
|
: ''
|
package/src/utils/validation.ts
CHANGED
|
@@ -7,6 +7,11 @@ export interface PageValidationOptions {
|
|
|
7
7
|
titleMaxLength: number
|
|
8
8
|
descriptionMaxLength: number
|
|
9
9
|
heroMinWidth?: number
|
|
10
|
+
pagePath?: string
|
|
11
|
+
requireH1?: boolean
|
|
12
|
+
requireHeroImage?: boolean
|
|
13
|
+
requireArticleSchema?: boolean
|
|
14
|
+
requireMaxImagePreviewLarge?: boolean
|
|
10
15
|
}
|
|
11
16
|
|
|
12
17
|
/**
|
|
@@ -139,7 +144,7 @@ export function validatePage(html: string, options: PageValidationOptions): Vali
|
|
|
139
144
|
html.match(/<meta\s[^>]*property=["']og:image["'][^>]*>/i) ??
|
|
140
145
|
html.match(/<meta\s[^>]*property=og:image[^>]*>/i)
|
|
141
146
|
if (!ogImageMatch) {
|
|
142
|
-
warnings
|
|
147
|
+
pushIssue(options.requireHeroImage, errors, warnings, 'Missing hero image og:image meta tag')
|
|
143
148
|
}
|
|
144
149
|
|
|
145
150
|
// og:image:width check against heroMinWidth
|
|
@@ -154,18 +159,27 @@ export function validatePage(html: string, options: PageValidationOptions): Vali
|
|
|
154
159
|
if (widthContentMatch) {
|
|
155
160
|
const width = Number(widthContentMatch[1])
|
|
156
161
|
if (!Number.isNaN(width) && width < options.heroMinWidth) {
|
|
157
|
-
|
|
162
|
+
pushIssue(
|
|
163
|
+
options.requireHeroImage,
|
|
164
|
+
errors,
|
|
165
|
+
warnings,
|
|
166
|
+
`Hero image width ${width}px is below minimum ${options.heroMinWidth}px`,
|
|
167
|
+
)
|
|
158
168
|
}
|
|
169
|
+
} else if (options.requireHeroImage) {
|
|
170
|
+
errors.push(`Hero image width is missing; minimum is ${options.heroMinWidth}px`)
|
|
159
171
|
}
|
|
172
|
+
} else if (options.requireHeroImage && ogImageMatch) {
|
|
173
|
+
errors.push(`Hero image width is missing; minimum is ${options.heroMinWidth}px`)
|
|
160
174
|
}
|
|
161
175
|
}
|
|
162
176
|
|
|
163
177
|
// H1 checks
|
|
164
178
|
const h1Matches = html.match(/<h1[\s>]/gi) ?? []
|
|
165
179
|
if (h1Matches.length === 0) {
|
|
166
|
-
warnings
|
|
180
|
+
pushIssue(options.requireH1, errors, warnings, 'Missing H1 tag')
|
|
167
181
|
} else if (h1Matches.length > 1) {
|
|
168
|
-
warnings
|
|
182
|
+
pushIssue(options.requireH1, errors, warnings, `Multiple H1 tags found (${h1Matches.length})`)
|
|
169
183
|
}
|
|
170
184
|
|
|
171
185
|
// JSON-LD presence check
|
|
@@ -174,9 +188,110 @@ export function validatePage(html: string, options: PageValidationOptions): Vali
|
|
|
174
188
|
warnings.push('Missing JSON-LD structured data')
|
|
175
189
|
}
|
|
176
190
|
|
|
191
|
+
if (
|
|
192
|
+
options.requireArticleSchema &&
|
|
193
|
+
isLikelyArticlePath(options.pagePath) &&
|
|
194
|
+
!hasArticleJsonLd(html)
|
|
195
|
+
) {
|
|
196
|
+
errors.push('Missing valid Article JSON-LD for article route')
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// max-image-preview:large tells search engines to show large image previews
|
|
200
|
+
// in Discover / News results — meaningful only for INDEXABLE pages. A noindex
|
|
201
|
+
// page (404, login, account, etc.) is excluded from search entirely, so the
|
|
202
|
+
// directive is moot there. Requiring it would force every consumer's noindex
|
|
203
|
+
// layout to carry a contradictory `noindex, ..., max-image-preview:large`
|
|
204
|
+
// robots string just to satisfy the linter. Skip the check on noindex pages.
|
|
205
|
+
if (
|
|
206
|
+
options.requireMaxImagePreviewLarge &&
|
|
207
|
+
!isNoindexPage(html) &&
|
|
208
|
+
!hasMaxImagePreviewLarge(html)
|
|
209
|
+
) {
|
|
210
|
+
errors.push('Missing robots max-image-preview:large directive')
|
|
211
|
+
}
|
|
212
|
+
|
|
177
213
|
return { errors, warnings }
|
|
178
214
|
}
|
|
179
215
|
|
|
216
|
+
function pushIssue(
|
|
217
|
+
asError: boolean | undefined,
|
|
218
|
+
errors: string[],
|
|
219
|
+
warnings: string[],
|
|
220
|
+
message: string,
|
|
221
|
+
) {
|
|
222
|
+
if (asError) errors.push(message)
|
|
223
|
+
else warnings.push(message)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function isLikelyArticlePath(pagePath: string | undefined): boolean {
|
|
227
|
+
if (!pagePath) return false
|
|
228
|
+
return /\/(article|articles|news|story|stories)\//i.test(pagePath)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function hasArticleJsonLd(html: string): boolean {
|
|
232
|
+
for (const rawJson of extractJsonLdBodies(html)) {
|
|
233
|
+
try {
|
|
234
|
+
const parsed = JSON.parse(rawJson) as unknown
|
|
235
|
+
if (hasArticleType(parsed)) return true
|
|
236
|
+
} catch {}
|
|
237
|
+
}
|
|
238
|
+
return false
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function extractJsonLdBodies(html: string): string[] {
|
|
242
|
+
const bodies: string[] = []
|
|
243
|
+
const pattern = /<script\s[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi
|
|
244
|
+
for (const match of html.matchAll(pattern)) {
|
|
245
|
+
if (match[1]) bodies.push(match[1].trim())
|
|
246
|
+
}
|
|
247
|
+
return bodies
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function hasArticleType(value: unknown): boolean {
|
|
251
|
+
if (Array.isArray(value)) return value.some(hasArticleType)
|
|
252
|
+
if (!isRecord(value)) return false
|
|
253
|
+
|
|
254
|
+
const type = value['@type']
|
|
255
|
+
if (type === 'Article' || type === 'NewsArticle' || type === 'BlogPosting') return true
|
|
256
|
+
if (
|
|
257
|
+
Array.isArray(type) &&
|
|
258
|
+
type.some((item) => item === 'Article' || item === 'NewsArticle' || item === 'BlogPosting')
|
|
259
|
+
) {
|
|
260
|
+
return true
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return hasArticleType(value['@graph'])
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function hasMaxImagePreviewLarge(html: string): boolean {
|
|
267
|
+
const metaTags = html.match(/<meta\s+[^>]*>/gi) ?? []
|
|
268
|
+
for (const tag of metaTags) {
|
|
269
|
+
if (getHtmlAttr(tag, 'name')?.toLowerCase() !== 'robots') continue
|
|
270
|
+
const content = getHtmlAttr(tag, 'content')?.toLowerCase() ?? ''
|
|
271
|
+
const directives = content.split(',').map((part) => part.trim())
|
|
272
|
+
if (directives.includes('max-image-preview:large')) return true
|
|
273
|
+
}
|
|
274
|
+
return false
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function isNoindexPage(html: string): boolean {
|
|
278
|
+
const metaTags = html.match(/<meta\s+[^>]*>/gi) ?? []
|
|
279
|
+
for (const tag of metaTags) {
|
|
280
|
+
const name = getHtmlAttr(tag, 'name')?.toLowerCase()
|
|
281
|
+
// Per the robots spec a per-bot directive (e.g. name="googlebot") counts;
|
|
282
|
+
// `content="none"` is equivalent to `noindex, nofollow`.
|
|
283
|
+
if (name !== 'robots' && name !== 'googlebot') continue
|
|
284
|
+
const content = getHtmlAttr(tag, 'content')?.toLowerCase() ?? ''
|
|
285
|
+
const directives = content.split(',').map((part) => part.trim())
|
|
286
|
+
if (directives.includes('noindex') || directives.includes('none')) return true
|
|
287
|
+
}
|
|
288
|
+
return false
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
292
|
+
return typeof value === 'object' && value !== null
|
|
293
|
+
}
|
|
294
|
+
|
|
180
295
|
function isNoindexMetaRefreshRedirect(html: string): boolean {
|
|
181
296
|
const metaTags = html.match(/<meta\s+[^>]*>/gi) ?? []
|
|
182
297
|
const hasRefresh = metaTags.some(
|
package/src/virtual.d.ts
CHANGED
|
@@ -6,3 +6,15 @@ declare module 'virtual:growth-labs/seo/config' {
|
|
|
6
6
|
export function getConfig(): ResolvedSeoOptions
|
|
7
7
|
export function getContentProvider(): ContentProvider | undefined
|
|
8
8
|
}
|
|
9
|
+
|
|
10
|
+
// Config-only twin: identical surface, but the generated module never imports
|
|
11
|
+
// the consumer's contentProviderModule. Imported by the always-runtime SEO
|
|
12
|
+
// middleware so the content store is not bundled into the deployed Worker.
|
|
13
|
+
declare module 'virtual:growth-labs/seo/config-lite' {
|
|
14
|
+
import type { ResolvedSeoOptions } from '@growth-labs/seo'
|
|
15
|
+
import type { ContentProvider } from '@growth-labs/seo'
|
|
16
|
+
|
|
17
|
+
export const config: ResolvedSeoOptions
|
|
18
|
+
export function getConfig(): ResolvedSeoOptions
|
|
19
|
+
export function getContentProvider(): ContentProvider | undefined
|
|
20
|
+
}
|
package/src/vite-plugin.ts
CHANGED
|
@@ -1,9 +1,34 @@
|
|
|
1
1
|
import type { Plugin } from 'vite'
|
|
2
|
-
import type
|
|
2
|
+
import { type ResolvedSeoOptions, resolveAeoTwins } from './options.js'
|
|
3
3
|
|
|
4
4
|
const VIRTUAL_MODULE_ID = 'virtual:growth-labs/seo/config'
|
|
5
5
|
const RESOLVED_VIRTUAL_MODULE_ID = `\0${VIRTUAL_MODULE_ID}`
|
|
6
6
|
|
|
7
|
+
// Mode-aware twin of the virtual config module, imported by the always-runtime
|
|
8
|
+
// SEO middleware and the head components (SeoHead/AeoHead). It seeds `_setConfig`
|
|
9
|
+
// always, and seeds the contentProvider ONLY in non-static modes:
|
|
10
|
+
//
|
|
11
|
+
// - STATIC mode (aeoTwins false/'static', no flexibleSampling): provider-free.
|
|
12
|
+
// The middleware runs in the DEPLOYED Worker on every request; importing the
|
|
13
|
+
// provider-bearing `config` here would pull the consumer's contentProviderModule
|
|
14
|
+
// — and transitively `astro:content` plus the whole content store (tens of MB
|
|
15
|
+
// on a large catalog) — into the runtime Worker, blowing Cloudflare's 10 MB
|
|
16
|
+
// limit even though the middleware only needs metadata. In static mode the
|
|
17
|
+
// middleware needs no provider (AEO twins are prerendered, members-gating is
|
|
18
|
+
// unavailable), so it stays out.
|
|
19
|
+
//
|
|
20
|
+
// - NON-STATIC mode (aeoTwins 'middleware'/'both', or flexibleSampling): the
|
|
21
|
+
// middleware genuinely needs the provider at runtime (Accept: text/markdown
|
|
22
|
+
// negotiation, LLM-training-crawler 403 on members items). Route modules load
|
|
23
|
+
// lazily under the Cloudflare adapter, so we cannot rely on an SSR route's
|
|
24
|
+
// import to seed the provider before the middleware runs. Seeding it via THIS
|
|
25
|
+
// module — imported at the middleware's own module load — is deterministic.
|
|
26
|
+
// Bundling the store is acceptable here: non-static consumers serve dynamic
|
|
27
|
+
// content at runtime by design and are not the ones relying on prerender to
|
|
28
|
+
// stay under 10 MB.
|
|
29
|
+
const VIRTUAL_LITE_ID = 'virtual:growth-labs/seo/config-lite'
|
|
30
|
+
const RESOLVED_VIRTUAL_LITE_ID = `\0${VIRTUAL_LITE_ID}`
|
|
31
|
+
|
|
7
32
|
export interface SeoVitePluginOptions {
|
|
8
33
|
config: ResolvedSeoOptions
|
|
9
34
|
/**
|
|
@@ -37,23 +62,37 @@ export function growthLabsSeoPlugin(opts: SeoVitePluginOptions | ResolvedSeoOpti
|
|
|
37
62
|
if (id === VIRTUAL_MODULE_ID) {
|
|
38
63
|
return RESOLVED_VIRTUAL_MODULE_ID
|
|
39
64
|
}
|
|
65
|
+
if (id === VIRTUAL_LITE_ID) {
|
|
66
|
+
return RESOLVED_VIRTUAL_LITE_ID
|
|
67
|
+
}
|
|
40
68
|
},
|
|
41
69
|
load(id) {
|
|
42
|
-
if (id !== RESOLVED_VIRTUAL_MODULE_ID) return
|
|
70
|
+
if (id !== RESOLVED_VIRTUAL_MODULE_ID && id !== RESOLVED_VIRTUAL_LITE_ID) return
|
|
71
|
+
|
|
72
|
+
// `config` always seeds the provider (when a module is wired). `config-lite`
|
|
73
|
+
// seeds the provider only in NON-static modes (see the module comment): in
|
|
74
|
+
// static mode it must stay provider-free so the always-runtime middleware
|
|
75
|
+
// doesn't drag the content store into the deployed Worker; in non-static
|
|
76
|
+
// modes the middleware needs the provider at runtime, so config-lite seeds
|
|
77
|
+
// it deterministically at the middleware's own load.
|
|
78
|
+
const aeo = resolveAeoTwins(config.aeoTwins)
|
|
79
|
+
const staticMode = (!aeo || aeo.mode === 'static') && !config.flexibleSampling.enabled
|
|
80
|
+
const isLite = id === RESOLVED_VIRTUAL_LITE_ID
|
|
81
|
+
const withProvider = Boolean(contentProviderModule) && (!isLite || !staticMode)
|
|
43
82
|
|
|
44
83
|
// The generated module seeds state via side-effect at import time.
|
|
45
|
-
// Routes/middleware that
|
|
46
|
-
//
|
|
47
|
-
//
|
|
84
|
+
// Routes/middleware that import it before calling getConfig() populate
|
|
85
|
+
// state in whatever environment they run — the main Worker AND the
|
|
86
|
+
// Cloudflare prerender worker.
|
|
48
87
|
const lines: string[] = [
|
|
49
|
-
`import { _setConfig, _setContentProvider } from '@growth-labs/seo/_internal/state';`,
|
|
88
|
+
`import { _setConfig${withProvider ? ', _setContentProvider' : ''} } from '@growth-labs/seo/_internal/state';`,
|
|
50
89
|
]
|
|
51
|
-
if (
|
|
90
|
+
if (withProvider) {
|
|
52
91
|
lines.push(`import _cp from ${JSON.stringify(contentProviderModule)};`)
|
|
53
92
|
}
|
|
54
93
|
lines.push(`const config = ${JSON.stringify(staticConfig)};`)
|
|
55
94
|
lines.push(`_setConfig(config);`)
|
|
56
|
-
if (
|
|
95
|
+
if (withProvider) {
|
|
57
96
|
lines.push(`_setContentProvider(_cp);`)
|
|
58
97
|
}
|
|
59
98
|
lines.push(`export { config };`)
|