@growth-labs/seo 0.5.1 → 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/dist/index.d.ts.map +1 -1
- package/dist/index.js +28 -10
- 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/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 +0 -1
- 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/validation.d.ts.map +1 -1
- package/dist/utils/validation.js +24 -1
- 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 +30 -10
- package/src/middleware/seo.ts +10 -1
- 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 +0 -2
- 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/validation.ts +25 -1
- package/src/virtual.d.ts +12 -0
- package/src/vite-plugin.ts +47 -8
|
@@ -4,8 +4,6 @@ import { getConfig, getContentProvider } from '../_internal/state.js'
|
|
|
4
4
|
import type { ContentItem } from '../types.js'
|
|
5
5
|
import { generateArticleSitemap } from '../utils/sitemap.js'
|
|
6
6
|
|
|
7
|
-
export const prerender = false
|
|
8
|
-
|
|
9
7
|
export const GET: APIRoute = async (context) => {
|
|
10
8
|
const config = getConfig()
|
|
11
9
|
const contentProvider = getContentProvider()
|
|
@@ -5,8 +5,6 @@ import { resolveSeoConfig } from '../site-url.js'
|
|
|
5
5
|
import type { SitemapEntry } from '../utils/sitemap.js'
|
|
6
6
|
import { generateSitemapIndex } from '../utils/sitemap.js'
|
|
7
7
|
|
|
8
|
-
export const prerender = false
|
|
9
|
-
|
|
10
8
|
export const GET: APIRoute = async (context) => {
|
|
11
9
|
const config = resolveSeoConfig(getConfig())
|
|
12
10
|
const contentProvider = getContentProvider()
|
|
@@ -5,8 +5,6 @@ import { resolveAeoTwins } from '../options.js'
|
|
|
5
5
|
import type { ContentItem } from '../types.js'
|
|
6
6
|
import { generateMarkdownSitemap } from '../utils/sitemap-markdown.js'
|
|
7
7
|
|
|
8
|
-
export const prerender = false
|
|
9
|
-
|
|
10
8
|
export const GET: APIRoute = async (context) => {
|
|
11
9
|
const config = getConfig()
|
|
12
10
|
const contentProvider = getContentProvider()
|
|
@@ -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/validation.ts
CHANGED
|
@@ -196,7 +196,17 @@ export function validatePage(html: string, options: PageValidationOptions): Vali
|
|
|
196
196
|
errors.push('Missing valid Article JSON-LD for article route')
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
-
|
|
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
|
+
) {
|
|
200
210
|
errors.push('Missing robots max-image-preview:large directive')
|
|
201
211
|
}
|
|
202
212
|
|
|
@@ -264,6 +274,20 @@ function hasMaxImagePreviewLarge(html: string): boolean {
|
|
|
264
274
|
return false
|
|
265
275
|
}
|
|
266
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
|
+
|
|
267
291
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
268
292
|
return typeof value === 'object' && value !== null
|
|
269
293
|
}
|
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 };`)
|