@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.
Files changed (95) hide show
  1. package/dist/index.d.ts.map +1 -1
  2. package/dist/index.js +28 -10
  3. package/dist/index.js.map +1 -1
  4. package/dist/middleware/seo.d.ts +1 -1
  5. package/dist/middleware/seo.d.ts.map +1 -1
  6. package/dist/middleware/seo.js +10 -1
  7. package/dist/middleware/seo.js.map +1 -1
  8. package/dist/routes/apple-news.d.ts +0 -1
  9. package/dist/routes/apple-news.d.ts.map +1 -1
  10. package/dist/routes/apple-news.js +0 -1
  11. package/dist/routes/apple-news.js.map +1 -1
  12. package/dist/routes/llms-full.d.ts +0 -1
  13. package/dist/routes/llms-full.d.ts.map +1 -1
  14. package/dist/routes/llms-full.js +0 -1
  15. package/dist/routes/llms-full.js.map +1 -1
  16. package/dist/routes/llms.d.ts +0 -1
  17. package/dist/routes/llms.d.ts.map +1 -1
  18. package/dist/routes/llms.js +0 -1
  19. package/dist/routes/llms.js.map +1 -1
  20. package/dist/routes/podcast-narration.d.ts +0 -1
  21. package/dist/routes/podcast-narration.d.ts.map +1 -1
  22. package/dist/routes/podcast-narration.js +0 -1
  23. package/dist/routes/podcast-narration.js.map +1 -1
  24. package/dist/routes/podcast.d.ts +0 -1
  25. package/dist/routes/podcast.d.ts.map +1 -1
  26. package/dist/routes/podcast.js +0 -1
  27. package/dist/routes/podcast.js.map +1 -1
  28. package/dist/routes/robots.d.ts +0 -1
  29. package/dist/routes/robots.d.ts.map +1 -1
  30. package/dist/routes/robots.js +0 -1
  31. package/dist/routes/robots.js.map +1 -1
  32. package/dist/routes/rss.d.ts +0 -1
  33. package/dist/routes/rss.d.ts.map +1 -1
  34. package/dist/routes/rss.js +0 -1
  35. package/dist/routes/rss.js.map +1 -1
  36. package/dist/routes/sitemap-articles.d.ts +0 -1
  37. package/dist/routes/sitemap-articles.d.ts.map +1 -1
  38. package/dist/routes/sitemap-articles.js +0 -1
  39. package/dist/routes/sitemap-articles.js.map +1 -1
  40. package/dist/routes/sitemap-index.d.ts +0 -1
  41. package/dist/routes/sitemap-index.d.ts.map +1 -1
  42. package/dist/routes/sitemap-index.js +0 -1
  43. package/dist/routes/sitemap-index.js.map +1 -1
  44. package/dist/routes/sitemap-markdown.d.ts +0 -1
  45. package/dist/routes/sitemap-markdown.d.ts.map +1 -1
  46. package/dist/routes/sitemap-markdown.js +0 -1
  47. package/dist/routes/sitemap-markdown.js.map +1 -1
  48. package/dist/routes/sitemap-pages.d.ts +0 -1
  49. package/dist/routes/sitemap-pages.d.ts.map +1 -1
  50. package/dist/routes/sitemap-pages.js +0 -1
  51. package/dist/routes/sitemap-pages.js.map +1 -1
  52. package/dist/routes/sitemap-products.d.ts +0 -1
  53. package/dist/routes/sitemap-products.d.ts.map +1 -1
  54. package/dist/routes/sitemap-products.js +0 -1
  55. package/dist/routes/sitemap-products.js.map +1 -1
  56. package/dist/routes/sitemap-videos.d.ts +0 -1
  57. package/dist/routes/sitemap-videos.d.ts.map +1 -1
  58. package/dist/routes/sitemap-videos.js +0 -1
  59. package/dist/routes/sitemap-videos.js.map +1 -1
  60. package/dist/utils/apple-news-rss.d.ts.map +1 -1
  61. package/dist/utils/apple-news-rss.js +10 -6
  62. package/dist/utils/apple-news-rss.js.map +1 -1
  63. package/dist/utils/rss.d.ts.map +1 -1
  64. package/dist/utils/rss.js +22 -1
  65. package/dist/utils/rss.js.map +1 -1
  66. package/dist/utils/validation.d.ts.map +1 -1
  67. package/dist/utils/validation.js +24 -1
  68. package/dist/utils/validation.js.map +1 -1
  69. package/dist/vite-plugin.d.ts +1 -1
  70. package/dist/vite-plugin.d.ts.map +1 -1
  71. package/dist/vite-plugin.js +45 -7
  72. package/dist/vite-plugin.js.map +1 -1
  73. package/package.json +2 -2
  74. package/src/components/AeoHead.astro +5 -2
  75. package/src/components/SeoHead.astro +5 -1
  76. package/src/index.ts +30 -10
  77. package/src/middleware/seo.ts +10 -1
  78. package/src/routes/apple-news.ts +0 -2
  79. package/src/routes/llms-full.ts +0 -2
  80. package/src/routes/llms.ts +0 -2
  81. package/src/routes/podcast-narration.ts +0 -2
  82. package/src/routes/podcast.ts +0 -2
  83. package/src/routes/robots.ts +0 -2
  84. package/src/routes/rss.ts +0 -2
  85. package/src/routes/sitemap-articles.ts +0 -2
  86. package/src/routes/sitemap-index.ts +0 -2
  87. package/src/routes/sitemap-markdown.ts +0 -2
  88. package/src/routes/sitemap-pages.ts +0 -2
  89. package/src/routes/sitemap-products.ts +0 -2
  90. package/src/routes/sitemap-videos.ts +0 -2
  91. package/src/utils/apple-news-rss.ts +9 -6
  92. package/src/utils/rss.ts +24 -1
  93. package/src/utils/validation.ts +25 -1
  94. package/src/virtual.d.ts +12 -0
  95. 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
- const itemsXml = items
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>` : ''
@@ -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
- if (options.requireMaxImagePreviewLarge && !hasMaxImagePreviewLarge(html)) {
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
+ }
@@ -1,9 +1,34 @@
1
1
  import type { Plugin } from 'vite'
2
- import type { ResolvedSeoOptions } from './options.js'
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 `import 'virtual:growth-labs/seo/config'` before
46
- // calling getConfig() populate state in whatever environment they run —
47
- // the main Worker AND the Cloudflare prerender worker.
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 (contentProviderModule) {
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 (contentProviderModule) {
95
+ if (withProvider) {
57
96
  lines.push(`_setContentProvider(_cp);`)
58
97
  }
59
98
  lines.push(`export { config };`)