@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.
Files changed (106) hide show
  1. package/README.md +8 -2
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +84 -47
  4. package/dist/index.js.map +1 -1
  5. package/dist/middleware/seo.d.ts +1 -1
  6. package/dist/middleware/seo.d.ts.map +1 -1
  7. package/dist/middleware/seo.js +10 -1
  8. package/dist/middleware/seo.js.map +1 -1
  9. package/dist/options.d.ts +140 -0
  10. package/dist/options.d.ts.map +1 -1
  11. package/dist/options.js +18 -0
  12. package/dist/options.js.map +1 -1
  13. package/dist/routes/apple-news.d.ts +0 -1
  14. package/dist/routes/apple-news.d.ts.map +1 -1
  15. package/dist/routes/apple-news.js +0 -1
  16. package/dist/routes/apple-news.js.map +1 -1
  17. package/dist/routes/llms-full.d.ts +0 -1
  18. package/dist/routes/llms-full.d.ts.map +1 -1
  19. package/dist/routes/llms-full.js +0 -1
  20. package/dist/routes/llms-full.js.map +1 -1
  21. package/dist/routes/llms.d.ts +0 -1
  22. package/dist/routes/llms.d.ts.map +1 -1
  23. package/dist/routes/llms.js +0 -1
  24. package/dist/routes/llms.js.map +1 -1
  25. package/dist/routes/podcast-narration.d.ts +0 -1
  26. package/dist/routes/podcast-narration.d.ts.map +1 -1
  27. package/dist/routes/podcast-narration.js +0 -1
  28. package/dist/routes/podcast-narration.js.map +1 -1
  29. package/dist/routes/podcast.d.ts +0 -1
  30. package/dist/routes/podcast.d.ts.map +1 -1
  31. package/dist/routes/podcast.js +0 -1
  32. package/dist/routes/podcast.js.map +1 -1
  33. package/dist/routes/robots.d.ts +0 -1
  34. package/dist/routes/robots.d.ts.map +1 -1
  35. package/dist/routes/robots.js +0 -1
  36. package/dist/routes/robots.js.map +1 -1
  37. package/dist/routes/rss.d.ts +0 -1
  38. package/dist/routes/rss.d.ts.map +1 -1
  39. package/dist/routes/rss.js +0 -1
  40. package/dist/routes/rss.js.map +1 -1
  41. package/dist/routes/sitemap-articles.d.ts +0 -1
  42. package/dist/routes/sitemap-articles.d.ts.map +1 -1
  43. package/dist/routes/sitemap-articles.js +0 -1
  44. package/dist/routes/sitemap-articles.js.map +1 -1
  45. package/dist/routes/sitemap-index.d.ts +0 -1
  46. package/dist/routes/sitemap-index.d.ts.map +1 -1
  47. package/dist/routes/sitemap-index.js +45 -33
  48. package/dist/routes/sitemap-index.js.map +1 -1
  49. package/dist/routes/sitemap-markdown.d.ts +0 -1
  50. package/dist/routes/sitemap-markdown.d.ts.map +1 -1
  51. package/dist/routes/sitemap-markdown.js +0 -1
  52. package/dist/routes/sitemap-markdown.js.map +1 -1
  53. package/dist/routes/sitemap-pages.d.ts +0 -1
  54. package/dist/routes/sitemap-pages.d.ts.map +1 -1
  55. package/dist/routes/sitemap-pages.js +0 -1
  56. package/dist/routes/sitemap-pages.js.map +1 -1
  57. package/dist/routes/sitemap-products.d.ts +0 -1
  58. package/dist/routes/sitemap-products.d.ts.map +1 -1
  59. package/dist/routes/sitemap-products.js +0 -1
  60. package/dist/routes/sitemap-products.js.map +1 -1
  61. package/dist/routes/sitemap-videos.d.ts +0 -1
  62. package/dist/routes/sitemap-videos.d.ts.map +1 -1
  63. package/dist/routes/sitemap-videos.js +0 -1
  64. package/dist/routes/sitemap-videos.js.map +1 -1
  65. package/dist/utils/apple-news-rss.d.ts.map +1 -1
  66. package/dist/utils/apple-news-rss.js +10 -6
  67. package/dist/utils/apple-news-rss.js.map +1 -1
  68. package/dist/utils/rss.d.ts.map +1 -1
  69. package/dist/utils/rss.js +22 -1
  70. package/dist/utils/rss.js.map +1 -1
  71. package/dist/utils/sitemap.d.ts.map +1 -1
  72. package/dist/utils/sitemap.js +1 -1
  73. package/dist/utils/sitemap.js.map +1 -1
  74. package/dist/utils/validation.d.ts +5 -0
  75. package/dist/utils/validation.d.ts.map +1 -1
  76. package/dist/utils/validation.js +101 -4
  77. package/dist/utils/validation.js.map +1 -1
  78. package/dist/vite-plugin.d.ts +1 -1
  79. package/dist/vite-plugin.d.ts.map +1 -1
  80. package/dist/vite-plugin.js +45 -7
  81. package/dist/vite-plugin.js.map +1 -1
  82. package/package.json +2 -2
  83. package/src/components/AeoHead.astro +5 -2
  84. package/src/components/SeoHead.astro +5 -1
  85. package/src/index.ts +87 -47
  86. package/src/middleware/seo.ts +10 -1
  87. package/src/options.ts +21 -0
  88. package/src/routes/apple-news.ts +0 -2
  89. package/src/routes/llms-full.ts +0 -2
  90. package/src/routes/llms.ts +0 -2
  91. package/src/routes/podcast-narration.ts +0 -2
  92. package/src/routes/podcast.ts +0 -2
  93. package/src/routes/robots.ts +0 -2
  94. package/src/routes/rss.ts +0 -2
  95. package/src/routes/sitemap-articles.ts +0 -2
  96. package/src/routes/sitemap-index.ts +48 -37
  97. package/src/routes/sitemap-markdown.ts +0 -2
  98. package/src/routes/sitemap-pages.ts +0 -2
  99. package/src/routes/sitemap-products.ts +0 -2
  100. package/src/routes/sitemap-videos.ts +0 -2
  101. package/src/utils/apple-news-rss.ts +9 -6
  102. package/src/utils/rss.ts +24 -1
  103. package/src/utils/sitemap.ts +4 -2
  104. package/src/utils/validation.ts +119 -4
  105. package/src/virtual.d.ts +12 -0
  106. package/src/vite-plugin.ts +47 -8
@@ -1,5 +1,5 @@
1
1
  import type { Plugin } from 'vite';
2
- import type { ResolvedSeoOptions } from './options.js';
2
+ import { type ResolvedSeoOptions } from './options.js';
3
3
  export interface SeoVitePluginOptions {
4
4
  config: ResolvedSeoOptions;
5
5
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"vite-plugin.d.ts","sourceRoot":"","sources":["../src/vite-plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAA;AAClC,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAA;AAKtD,MAAM,WAAW,oBAAoB;IACpC,MAAM,EAAE,kBAAkB,CAAA;IAC1B;;;;;;;;;OASG;IACH,qBAAqB,CAAC,EAAE,MAAM,CAAA;CAC9B;AAED,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,oBAAoB,GAAG,kBAAkB,GAAG,MAAM,CA4C3F"}
1
+ {"version":3,"file":"vite-plugin.d.ts","sourceRoot":"","sources":["../src/vite-plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAA;AAClC,OAAO,EAAE,KAAK,kBAAkB,EAAmB,MAAM,cAAc,CAAA;AA8BvE,MAAM,WAAW,oBAAoB;IACpC,MAAM,EAAE,kBAAkB,CAAA;IAC1B;;;;;;;;;OASG;IACH,qBAAqB,CAAC,EAAE,MAAM,CAAA;CAC9B;AAED,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,oBAAoB,GAAG,kBAAkB,GAAG,MAAM,CA0D3F"}
@@ -1,5 +1,30 @@
1
+ import { resolveAeoTwins } from './options.js';
1
2
  const VIRTUAL_MODULE_ID = 'virtual:growth-labs/seo/config';
2
3
  const RESOLVED_VIRTUAL_MODULE_ID = `\0${VIRTUAL_MODULE_ID}`;
4
+ // Mode-aware twin of the virtual config module, imported by the always-runtime
5
+ // SEO middleware and the head components (SeoHead/AeoHead). It seeds `_setConfig`
6
+ // always, and seeds the contentProvider ONLY in non-static modes:
7
+ //
8
+ // - STATIC mode (aeoTwins false/'static', no flexibleSampling): provider-free.
9
+ // The middleware runs in the DEPLOYED Worker on every request; importing the
10
+ // provider-bearing `config` here would pull the consumer's contentProviderModule
11
+ // — and transitively `astro:content` plus the whole content store (tens of MB
12
+ // on a large catalog) — into the runtime Worker, blowing Cloudflare's 10 MB
13
+ // limit even though the middleware only needs metadata. In static mode the
14
+ // middleware needs no provider (AEO twins are prerendered, members-gating is
15
+ // unavailable), so it stays out.
16
+ //
17
+ // - NON-STATIC mode (aeoTwins 'middleware'/'both', or flexibleSampling): the
18
+ // middleware genuinely needs the provider at runtime (Accept: text/markdown
19
+ // negotiation, LLM-training-crawler 403 on members items). Route modules load
20
+ // lazily under the Cloudflare adapter, so we cannot rely on an SSR route's
21
+ // import to seed the provider before the middleware runs. Seeding it via THIS
22
+ // module — imported at the middleware's own module load — is deterministic.
23
+ // Bundling the store is acceptable here: non-static consumers serve dynamic
24
+ // content at runtime by design and are not the ones relying on prerender to
25
+ // stay under 10 MB.
26
+ const VIRTUAL_LITE_ID = 'virtual:growth-labs/seo/config-lite';
27
+ const RESOLVED_VIRTUAL_LITE_ID = `\0${VIRTUAL_LITE_ID}`;
3
28
  export function growthLabsSeoPlugin(opts) {
4
29
  // Back-compat: callers from 0.2.x passed ResolvedSeoOptions directly.
5
30
  const normalized = 'config' in opts && 'site' in opts.config
@@ -15,23 +40,36 @@ export function growthLabsSeoPlugin(opts) {
15
40
  if (id === VIRTUAL_MODULE_ID) {
16
41
  return RESOLVED_VIRTUAL_MODULE_ID;
17
42
  }
43
+ if (id === VIRTUAL_LITE_ID) {
44
+ return RESOLVED_VIRTUAL_LITE_ID;
45
+ }
18
46
  },
19
47
  load(id) {
20
- if (id !== RESOLVED_VIRTUAL_MODULE_ID)
48
+ if (id !== RESOLVED_VIRTUAL_MODULE_ID && id !== RESOLVED_VIRTUAL_LITE_ID)
21
49
  return;
50
+ // `config` always seeds the provider (when a module is wired). `config-lite`
51
+ // seeds the provider only in NON-static modes (see the module comment): in
52
+ // static mode it must stay provider-free so the always-runtime middleware
53
+ // doesn't drag the content store into the deployed Worker; in non-static
54
+ // modes the middleware needs the provider at runtime, so config-lite seeds
55
+ // it deterministically at the middleware's own load.
56
+ const aeo = resolveAeoTwins(config.aeoTwins);
57
+ const staticMode = (!aeo || aeo.mode === 'static') && !config.flexibleSampling.enabled;
58
+ const isLite = id === RESOLVED_VIRTUAL_LITE_ID;
59
+ const withProvider = Boolean(contentProviderModule) && (!isLite || !staticMode);
22
60
  // The generated module seeds state via side-effect at import time.
23
- // Routes/middleware that `import 'virtual:growth-labs/seo/config'` before
24
- // calling getConfig() populate state in whatever environment they run —
25
- // the main Worker AND the Cloudflare prerender worker.
61
+ // Routes/middleware that import it before calling getConfig() populate
62
+ // state in whatever environment they run — the main Worker AND the
63
+ // Cloudflare prerender worker.
26
64
  const lines = [
27
- `import { _setConfig, _setContentProvider } from '@growth-labs/seo/_internal/state';`,
65
+ `import { _setConfig${withProvider ? ', _setContentProvider' : ''} } from '@growth-labs/seo/_internal/state';`,
28
66
  ];
29
- if (contentProviderModule) {
67
+ if (withProvider) {
30
68
  lines.push(`import _cp from ${JSON.stringify(contentProviderModule)};`);
31
69
  }
32
70
  lines.push(`const config = ${JSON.stringify(staticConfig)};`);
33
71
  lines.push(`_setConfig(config);`);
34
- if (contentProviderModule) {
72
+ if (withProvider) {
35
73
  lines.push(`_setContentProvider(_cp);`);
36
74
  }
37
75
  lines.push(`export { config };`);
@@ -1 +1 @@
1
- {"version":3,"file":"vite-plugin.js","sourceRoot":"","sources":["../src/vite-plugin.ts"],"names":[],"mappings":"AAGA,MAAM,iBAAiB,GAAG,gCAAgC,CAAA;AAC1D,MAAM,0BAA0B,GAAG,KAAK,iBAAiB,EAAE,CAAA;AAiB3D,MAAM,UAAU,mBAAmB,CAAC,IAA+C;IAClF,sEAAsE;IACtE,MAAM,UAAU,GACf,QAAQ,IAAI,IAAI,IAAI,MAAM,IAAK,IAA6B,CAAC,MAAM;QAClE,CAAC,CAAE,IAA6B;QAChC,CAAC,CAAC,EAAE,MAAM,EAAE,IAA0B,EAAE,CAAA;IAE1C,MAAM,EAAE,MAAM,EAAE,qBAAqB,EAAE,GAAG,UAAU,CAAA;IACpD,uEAAuE;IACvE,2CAA2C;IAC3C,MAAM,EAAE,eAAe,EAAE,CAAC,EAAE,GAAG,YAAY,EAAE,GAAG,MAAiC,CAAA;IAEjF,OAAO;QACN,IAAI,EAAE,wBAAwB;QAC9B,SAAS,CAAC,EAAE;YACX,IAAI,EAAE,KAAK,iBAAiB,EAAE,CAAC;gBAC9B,OAAO,0BAA0B,CAAA;YAClC,CAAC;QACF,CAAC;QACD,IAAI,CAAC,EAAE;YACN,IAAI,EAAE,KAAK,0BAA0B;gBAAE,OAAM;YAE7C,mEAAmE;YACnE,0EAA0E;YAC1E,wEAAwE;YACxE,uDAAuD;YACvD,MAAM,KAAK,GAAa;gBACvB,qFAAqF;aACrF,CAAA;YACD,IAAI,qBAAqB,EAAE,CAAC;gBAC3B,KAAK,CAAC,IAAI,CAAC,mBAAmB,IAAI,CAAC,SAAS,CAAC,qBAAqB,CAAC,GAAG,CAAC,CAAA;YACxE,CAAC;YACD,KAAK,CAAC,IAAI,CAAC,kBAAkB,IAAI,CAAC,SAAS,CAAC,YAAY,CAAC,GAAG,CAAC,CAAA;YAC7D,KAAK,CAAC,IAAI,CAAC,qBAAqB,CAAC,CAAA;YACjC,IAAI,qBAAqB,EAAE,CAAC;gBAC3B,KAAK,CAAC,IAAI,CAAC,2BAA2B,CAAC,CAAA;YACxC,CAAC;YACD,KAAK,CAAC,IAAI,CAAC,oBAAoB,CAAC,CAAA;YAChC,KAAK,CAAC,IAAI,CACT,mFAAmF,CACnF,CAAA;YACD,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;QACxB,CAAC;KACD,CAAA;AACF,CAAC"}
1
+ {"version":3,"file":"vite-plugin.js","sourceRoot":"","sources":["../src/vite-plugin.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@growth-labs/seo",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "types": "./dist/index.d.ts",
6
6
  "exports": {
@@ -73,7 +73,7 @@
73
73
  "astro": "^6.1.10",
74
74
  "typescript": "^5.7.0",
75
75
  "vite": "^7.3.2",
76
- "vitest": "^3.0.0"
76
+ "vitest": "^4.1.8"
77
77
  },
78
78
  "scripts": {
79
79
  "build": "tsc",
@@ -1,7 +1,10 @@
1
1
  ---
2
2
  // Side-effect: seeds state in whatever environment Vite bundles for.
3
- // Required so `getConfig()` works in the Cloudflare prerender Worker.
4
- import 'virtual:growth-labs/seo/config'
3
+ // Required so `getConfig()` works in the Cloudflare prerender Worker. Uses the
4
+ // provider-free (in static mode) `config-lite` twin — AeoHead only reads
5
+ // getConfig()/resolveAeoTwins(), never getContentProvider() — so it doesn't drag
6
+ // the content store into an SSR page that renders this head component.
7
+ import 'virtual:growth-labs/seo/config-lite'
5
8
 
6
9
  // Use package self-imports (not relative ../options.js / ../state.js) because
7
10
  // this component ships as source from src/components/ but options.ts and state.ts
@@ -20,7 +20,11 @@
20
20
  // Centralizing here eliminates that class of bug across ~12 sites.
21
21
  //
22
22
  // Side-effect import seeds state in both main and Cloudflare prerender Workers.
23
- import 'virtual:growth-labs/seo/config'
23
+ // SeoHead only reads getConfig() — never getContentProvider() — so it imports the
24
+ // mode-aware `config-lite` twin. In static mode that keeps the contentProvider (and
25
+ // the content store) out of any SSR page that renders this layout; the feed/AEO
26
+ // routes that DO need the provider import the full `config` themselves.
27
+ import 'virtual:growth-labs/seo/config-lite'
24
28
 
25
29
  import { getConfig, type ContentItem } from '@growth-labs/seo'
26
30
  import { resolveSeoConfig } from '@growth-labs/seo/site-url'
package/src/index.ts CHANGED
@@ -89,53 +89,83 @@ export default function seo(userOptions: SeoOptions): AstroIntegration {
89
89
 
90
90
  const injected: string[] = []
91
91
  const skipped: Array<{ pattern: string; reason: string }> = []
92
+ const injectedRoutes = options.injectedRoutes
92
93
 
93
- // ─── Sitemaps (provider-wired gated) ───
94
- if (providerWired) {
95
- injectRoute({
96
- pattern: SITEMAP_INDEX_PATH,
97
- entrypoint: resolveEntrypoint('./routes/sitemap-index'),
98
- prerender: false,
99
- })
100
- injected.push(SITEMAP_INDEX_PATH)
101
- injectRoute({
102
- pattern: '/sitemap-articles.xml',
103
- entrypoint: resolveEntrypoint('./routes/sitemap-articles'),
104
- prerender: false,
105
- })
106
- injected.push('/sitemap-articles.xml')
94
+ // ─── Prerender content routes in static mode ───
95
+ // The injected feed/sitemap/llms routes call the contentProvider, which
96
+ // (for content-layer-backed providers) transitively imports `astro:content`
97
+ // and the entire content store. As SSR (`prerender: false`) routes, that
98
+ // store ships in the DEPLOYED Worker — on a large catalog this blows
99
+ // Cloudflare's 10 MB Worker size limit. In "static" mode the feeds carry
100
+ // no per-request variation, so we prerender them: the provider runs in the
101
+ // build-time prerender Worker and the feeds are emitted as static Assets
102
+ // (which are NOT counted against the Worker size limit). Non-static modes
103
+ // (aeo 'middleware'/'both', flexibleSampling) keep SSR behavior unchanged —
104
+ // they genuinely branch per request and the middleware needs the provider.
105
+ //
106
+ // NOTE: the route modules must NOT carry a literal `export const prerender`
107
+ // — Astro's regex scanner would override this injected value. They were
108
+ // stripped of that export so this flag is authoritative.
109
+ const prerenderContentRoutes =
110
+ (!aeo || aeo.mode === 'static') && !options.flexibleSampling.enabled
111
+
112
+ const injectSeoRoute = (pattern: string, entrypoint: string) => {
107
113
  injectRoute({
108
- pattern: '/sitemap-pages.xml',
109
- entrypoint: resolveEntrypoint('./routes/sitemap-pages'),
110
- prerender: false,
114
+ pattern,
115
+ entrypoint: resolveEntrypoint(entrypoint),
116
+ prerender: prerenderContentRoutes,
111
117
  })
112
- injected.push('/sitemap-pages.xml')
113
- injectRoute({
114
- pattern: '/sitemap-videos.xml',
115
- entrypoint: resolveEntrypoint('./routes/sitemap-videos'),
116
- prerender: false,
118
+ injected.push(pattern)
119
+ }
120
+
121
+ const skipDisabledRoute = (pattern: string, optionName: string) => {
122
+ skipped.push({
123
+ pattern,
124
+ reason: `injectedRoutes.${optionName} is false`,
117
125
  })
118
- injected.push('/sitemap-videos.xml')
119
- if (options.commerce?.enabled) {
120
- injectRoute({
121
- pattern: '/sitemap-products.xml',
122
- entrypoint: resolveEntrypoint('./routes/sitemap-products'),
123
- prerender: false,
124
- })
125
- injected.push('/sitemap-products.xml')
126
+ }
127
+
128
+ // ─── Sitemaps (provider-wired gated) ───
129
+ if (providerWired) {
130
+ if (injectedRoutes.sitemapIndex) {
131
+ injectSeoRoute(SITEMAP_INDEX_PATH, './routes/sitemap-index')
132
+ } else {
133
+ skipDisabledRoute(SITEMAP_INDEX_PATH, 'sitemapIndex')
134
+ }
135
+
136
+ if (injectedRoutes.sitemapArticles) {
137
+ injectSeoRoute('/sitemap-articles.xml', './routes/sitemap-articles')
138
+ } else {
139
+ skipDisabledRoute('/sitemap-articles.xml', 'sitemapArticles')
140
+ }
141
+
142
+ if (injectedRoutes.sitemapPages) {
143
+ injectSeoRoute('/sitemap-pages.xml', './routes/sitemap-pages')
144
+ } else {
145
+ skipDisabledRoute('/sitemap-pages.xml', 'sitemapPages')
146
+ }
147
+
148
+ if (injectedRoutes.sitemapVideos) {
149
+ injectSeoRoute('/sitemap-videos.xml', './routes/sitemap-videos')
150
+ } else {
151
+ skipDisabledRoute('/sitemap-videos.xml', 'sitemapVideos')
152
+ }
153
+
154
+ if (!injectedRoutes.sitemapProducts) {
155
+ skipDisabledRoute('/sitemap-products.xml', 'sitemapProducts')
156
+ } else if (options.commerce?.enabled) {
157
+ injectSeoRoute('/sitemap-products.xml', './routes/sitemap-products')
126
158
  } else {
127
159
  skipped.push({
128
160
  pattern: '/sitemap-products.xml',
129
161
  reason: 'commerce.enabled is false',
130
162
  })
131
163
  }
132
- if (options.markdownSitemap && aeo && aeo.mode !== 'middleware') {
133
- injectRoute({
134
- pattern: '/sitemap-markdown.xml',
135
- entrypoint: resolveEntrypoint('./routes/sitemap-markdown'),
136
- prerender: false,
137
- })
138
- injected.push('/sitemap-markdown.xml')
164
+
165
+ if (!injectedRoutes.sitemapMarkdown) {
166
+ skipDisabledRoute('/sitemap-markdown.xml', 'sitemapMarkdown')
167
+ } else if (options.markdownSitemap && aeo && aeo.mode !== 'middleware') {
168
+ injectSeoRoute('/sitemap-markdown.xml', './routes/sitemap-markdown')
139
169
  } else if (options.markdownSitemap) {
140
170
  skipped.push({
141
171
  pattern: '/sitemap-markdown.xml',
@@ -165,7 +195,7 @@ export default function seo(userOptions: SeoOptions): AstroIntegration {
165
195
  injectRoute({
166
196
  pattern: '/robots.txt',
167
197
  entrypoint: resolveEntrypoint('./routes/robots'),
168
- prerender: false,
198
+ prerender: prerenderContentRoutes,
169
199
  })
170
200
  injected.push('/robots.txt')
171
201
  } else {
@@ -181,7 +211,7 @@ export default function seo(userOptions: SeoOptions): AstroIntegration {
181
211
  injectRoute({
182
212
  pattern: '/llms.txt',
183
213
  entrypoint: resolveEntrypoint('./routes/llms'),
184
- prerender: false,
214
+ prerender: prerenderContentRoutes,
185
215
  })
186
216
  injected.push('/llms.txt')
187
217
  }
@@ -189,7 +219,7 @@ export default function seo(userOptions: SeoOptions): AstroIntegration {
189
219
  injectRoute({
190
220
  pattern: '/llms-full.txt',
191
221
  entrypoint: resolveEntrypoint('./routes/llms-full'),
192
- prerender: false,
222
+ prerender: prerenderContentRoutes,
193
223
  })
194
224
  injected.push('/llms-full.txt')
195
225
  }
@@ -199,7 +229,7 @@ export default function seo(userOptions: SeoOptions): AstroIntegration {
199
229
  injectRoute({
200
230
  pattern: '/feed.xml',
201
231
  entrypoint: resolveEntrypoint('./routes/rss'),
202
- prerender: false,
232
+ prerender: prerenderContentRoutes,
203
233
  })
204
234
  injected.push('/feed.xml')
205
235
  }
@@ -209,7 +239,7 @@ export default function seo(userOptions: SeoOptions): AstroIntegration {
209
239
  injectRoute({
210
240
  pattern: options.appleNews.feedPath,
211
241
  entrypoint: resolveEntrypoint('./routes/apple-news'),
212
- prerender: false,
242
+ prerender: prerenderContentRoutes,
213
243
  })
214
244
  injected.push(options.appleNews.feedPath)
215
245
  }
@@ -219,7 +249,7 @@ export default function seo(userOptions: SeoOptions): AstroIntegration {
219
249
  injectRoute({
220
250
  pattern: options.podcast.feedPath,
221
251
  entrypoint: resolveEntrypoint('./routes/podcast'),
222
- prerender: false,
252
+ prerender: prerenderContentRoutes,
223
253
  })
224
254
  injected.push(options.podcast.feedPath)
225
255
  }
@@ -229,7 +259,7 @@ export default function seo(userOptions: SeoOptions): AstroIntegration {
229
259
  injectRoute({
230
260
  pattern: options.audioNarration.podcastFeedPath,
231
261
  entrypoint: resolveEntrypoint('./routes/podcast-narration'),
232
- prerender: false,
262
+ prerender: prerenderContentRoutes,
233
263
  })
234
264
  injected.push(options.audioNarration.podcastFeedPath)
235
265
  }
@@ -282,8 +312,10 @@ export default function seo(userOptions: SeoOptions): AstroIntegration {
282
312
  // Note: we try to load items here to check the guard, but this is
283
313
  // best-effort. If contentProvider uses astro:content or other Vite
284
314
  // virtuals, it will fail at raw-Node import time and we skip silently.
285
- // The guard is a defence-in-depth check; the primary guarantee comes
286
- // from the consumer-set `export const prerender = false` on gated routes.
315
+ // The guard is a defence-in-depth check. Note flexibleSampling forces
316
+ // non-static mode, so `prerenderContentRoutes` is false here and the
317
+ // injected content routes stay SSR (their prerender is controlled by the
318
+ // injectRoute flag above, not a literal `export const prerender`).
287
319
  if (contentProvider && options.flexibleSampling?.enabled) {
288
320
  try {
289
321
  const items = await contentProvider({ type: 'articles' }, {} as never)
@@ -313,12 +345,17 @@ export default function seo(userOptions: SeoOptions): AstroIntegration {
313
345
 
314
346
  for (const file of htmlFiles) {
315
347
  const html = readFileSync(file, 'utf-8')
348
+ const relPath = file.replace(outDir, '')
316
349
  const result = validatePage(html, {
317
350
  titleMaxLength: options.validation.titleMaxLength,
318
351
  descriptionMaxLength: options.validation.descriptionMaxLength,
319
352
  heroMinWidth: options.validation.heroMinWidth,
353
+ pagePath: relPath,
354
+ requireH1: options.validation.requireH1,
355
+ requireHeroImage: options.validation.requireHeroImage,
356
+ requireArticleSchema: options.validation.requireArticleSchema,
357
+ requireMaxImagePreviewLarge: options.validation.requireMaxImagePreviewLarge,
320
358
  })
321
- const relPath = file.replace(outDir, '')
322
359
  for (const error of result.errors) {
323
360
  logger.error(`${relPath}: ${error}`)
324
361
  errorCount++
@@ -352,6 +389,9 @@ export default function seo(userOptions: SeoOptions): AstroIntegration {
352
389
 
353
390
  if (errorCount || warningCount) {
354
391
  logger.info(`SEO validation: ${errorCount} errors, ${warningCount} warnings`)
392
+ if (errorCount > 0) {
393
+ throw new Error(`[@growth-labs/seo] SEO validation failed with ${errorCount} errors`)
394
+ }
355
395
  } else {
356
396
  logger.info('SEO validation: all checks passed')
357
397
  }
@@ -1,4 +1,13 @@
1
- import 'virtual:growth-labs/seo/config'
1
+ // Provider-free config import: the middleware runs in the deployed Worker on
2
+ // every request and only needs metadata-level lookups. Importing the
3
+ // provider-bearing 'config' module here would pull the consumer's
4
+ // contentProviderModule — and transitively `astro:content` plus the whole
5
+ // content-layer store — into the runtime Worker, blowing Cloudflare's 10 MB
6
+ // size limit on large catalogs. The provider is seeded separately by the
7
+ // injected content routes (prerendered in static mode). getContentProvider()
8
+ // returns undefined here in static mode, which the provider-gated branches
9
+ // below already tolerate.
10
+ import 'virtual:growth-labs/seo/config-lite'
2
11
  import { getConfig, getContentProvider } from '../_internal/state.js'
3
12
  import type { SeoEnv } from '../bindings.js'
4
13
  import { type ResolvedSeoOptions, resolveAeoTwins } from '../options.js'
package/src/options.ts CHANGED
@@ -185,6 +185,19 @@ const crawlerPolicySchema = z
185
185
  })
186
186
  .default({})
187
187
 
188
+ // ─── Injected route ownership ───
189
+
190
+ const injectedRoutesSchema = z
191
+ .object({
192
+ sitemapIndex: z.boolean().default(true),
193
+ sitemapArticles: z.boolean().default(true),
194
+ sitemapPages: z.boolean().default(true),
195
+ sitemapVideos: z.boolean().default(true),
196
+ sitemapProducts: z.boolean().default(true),
197
+ sitemapMarkdown: z.boolean().default(true),
198
+ })
199
+ .default({})
200
+
188
201
  // ─── Main schema ───
189
202
 
190
203
  const siteUrlSchema = z.union([
@@ -217,6 +230,10 @@ export const seoOptionsSchema = z.object({
217
230
  markdownSitemap: z.boolean().default(true),
218
231
  rss: z.boolean().default(false),
219
232
 
233
+ // ─── Injected route ownership ───
234
+ // Set a route false when the consumer application owns that public path.
235
+ injectedRoutes: injectedRoutesSchema,
236
+
220
237
  // ─── AEO twins ───
221
238
  // Boolean form = { mode: 'static' } when true, no twins emitted when false.
222
239
  aeoTwins: z.union([z.boolean(), aeoTwinsObjectSchema]).default(false),
@@ -269,6 +286,10 @@ export const seoOptionsSchema = z.object({
269
286
  heroMinWidth: z.number().default(1200),
270
287
  titleMaxLength: z.number().default(110),
271
288
  descriptionMaxLength: z.number().default(160),
289
+ requireH1: z.boolean().default(true),
290
+ requireHeroImage: z.boolean().default(true),
291
+ requireArticleSchema: z.boolean().default(true),
292
+ requireMaxImagePreviewLarge: z.boolean().default(true),
272
293
  enabled: z.boolean().default(true),
273
294
  })
274
295
  .default({}),
@@ -5,8 +5,6 @@ import { resolveSeoConfig } from '../site-url.js'
5
5
  import type { ContentItem } from '../types.js'
6
6
  import { generateAppleNewsRss } from '../utils/apple-news-rss.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()
@@ -4,8 +4,6 @@ import { getConfig, getContentProvider } from '../_internal/state.js'
4
4
  import type { ContentItem } from '../types.js'
5
5
  import { generateLlmsFull } from '../utils/llms-full.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()
@@ -3,8 +3,6 @@ import type { APIRoute } from 'astro'
3
3
  import { getConfig } from '../_internal/state.js'
4
4
  import { generateLlmsTxt } from '../utils/llms.js'
5
5
 
6
- export const prerender = false
7
-
8
6
  export const GET: APIRoute = async () => {
9
7
  const config = getConfig()
10
8
  const txt = generateLlmsTxt(config)
@@ -5,8 +5,6 @@ import { resolveSeoConfig } from '../site-url.js'
5
5
  import type { ContentItem } from '../types.js'
6
6
  import { generatePodcastFeed } from '../utils/podcast.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 { resolveSeoConfig } from '../site-url.js'
5
5
  import type { ContentItem } from '../types.js'
6
6
  import { generatePodcastFeed } from '../utils/podcast.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()
@@ -4,8 +4,6 @@ import { getConfig, getContentProvider } from '../_internal/state.js'
4
4
  import { resolveSeoConfig } from '../site-url.js'
5
5
  import { generateRobotsTxt } from '../utils/robots.js'
6
6
 
7
- export const prerender = false
8
-
9
7
  export const GET: APIRoute = async () => {
10
8
  const config = resolveSeoConfig(getConfig())
11
9
  // The sitemap-index route is only injected when a content provider is wired.
package/src/routes/rss.ts CHANGED
@@ -6,8 +6,6 @@ import type { ContentItem } from '../types.js'
6
6
  import { forRss } from '../utils/content-filter.js'
7
7
  import { generateRssFeed } from '../utils/rss.js'
8
8
 
9
- export const prerender = false
10
-
11
9
  export const GET: APIRoute = async (context) => {
12
10
  const config = resolveSeoConfig(getConfig())
13
11
  const contentProvider = getContentProvider()
@@ -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()
@@ -14,6 +12,7 @@ export const GET: APIRoute = async (context) => {
14
12
  const sitemaps: SitemapEntry[] = []
15
13
 
16
14
  const { site } = config
15
+ const injectedRoutes = config.injectedRoutes
17
16
 
18
17
  // Fetch articles lastmod if possible
19
18
  let articlesLastmod: string | undefined
@@ -22,43 +21,49 @@ export const GET: APIRoute = async (context) => {
22
21
  let productsLastmod: string | undefined
23
22
 
24
23
  if (contentProvider) {
25
- try {
26
- const articles = await contentProvider({ type: 'articles' }, context as any)
27
- if (articles.length > 0) {
28
- const dates = articles
29
- .map((a) => a.dateModified ?? a.datePublished)
30
- .filter(Boolean) as string[]
31
- if (dates.length > 0) {
32
- articlesLastmod = dates.sort().at(-1)
24
+ if (injectedRoutes.sitemapArticles) {
25
+ try {
26
+ const articles = await contentProvider({ type: 'articles' }, context as any)
27
+ if (articles.length > 0) {
28
+ const dates = articles
29
+ .map((a) => a.dateModified ?? a.datePublished)
30
+ .filter(Boolean) as string[]
31
+ if (dates.length > 0) {
32
+ articlesLastmod = dates.sort().at(-1)
33
+ }
33
34
  }
34
- }
35
- } catch {}
35
+ } catch {}
36
+ }
36
37
 
37
- try {
38
- const pages = await contentProvider({ type: 'pages' }, context as any)
39
- if (pages.length > 0) {
40
- const dates = pages
41
- .map((p) => p.dateModified ?? p.datePublished)
42
- .filter(Boolean) as string[]
43
- if (dates.length > 0) {
44
- pagesLastmod = dates.sort().at(-1)
38
+ if (injectedRoutes.sitemapPages) {
39
+ try {
40
+ const pages = await contentProvider({ type: 'pages' }, context as any)
41
+ if (pages.length > 0) {
42
+ const dates = pages
43
+ .map((p) => p.dateModified ?? p.datePublished)
44
+ .filter(Boolean) as string[]
45
+ if (dates.length > 0) {
46
+ pagesLastmod = dates.sort().at(-1)
47
+ }
45
48
  }
46
- }
47
- } catch {}
49
+ } catch {}
50
+ }
48
51
 
49
- try {
50
- const videos = await contentProvider({ type: 'videos' }, context as any)
51
- if (videos.length > 0) {
52
- const dates = videos
53
- .map((v) => v.dateModified ?? v.datePublished)
54
- .filter(Boolean) as string[]
55
- if (dates.length > 0) {
56
- videosLastmod = dates.sort().at(-1)
52
+ if (injectedRoutes.sitemapVideos) {
53
+ try {
54
+ const videos = await contentProvider({ type: 'videos' }, context as any)
55
+ if (videos.length > 0) {
56
+ const dates = videos
57
+ .map((v) => v.dateModified ?? v.datePublished)
58
+ .filter(Boolean) as string[]
59
+ if (dates.length > 0) {
60
+ videosLastmod = dates.sort().at(-1)
61
+ }
57
62
  }
58
- }
59
- } catch {}
63
+ } catch {}
64
+ }
60
65
 
61
- if (config.commerce?.enabled) {
66
+ if (config.commerce?.enabled && injectedRoutes.sitemapProducts) {
62
67
  try {
63
68
  const products = await contentProvider({ type: 'products' }, context as any)
64
69
  if (products.length > 0) {
@@ -73,11 +78,17 @@ export const GET: APIRoute = async (context) => {
73
78
  }
74
79
  }
75
80
 
76
- sitemaps.push({ loc: `${site}/sitemap-articles.xml`, lastmod: articlesLastmod })
77
- sitemaps.push({ loc: `${site}/sitemap-pages.xml`, lastmod: pagesLastmod })
78
- sitemaps.push({ loc: `${site}/sitemap-videos.xml`, lastmod: videosLastmod })
81
+ if (injectedRoutes.sitemapArticles) {
82
+ sitemaps.push({ loc: `${site}/sitemap-articles.xml`, lastmod: articlesLastmod })
83
+ }
84
+ if (injectedRoutes.sitemapPages) {
85
+ sitemaps.push({ loc: `${site}/sitemap-pages.xml`, lastmod: pagesLastmod })
86
+ }
87
+ if (injectedRoutes.sitemapVideos) {
88
+ sitemaps.push({ loc: `${site}/sitemap-videos.xml`, lastmod: videosLastmod })
89
+ }
79
90
 
80
- if (config.commerce?.enabled) {
91
+ if (config.commerce?.enabled && injectedRoutes.sitemapProducts) {
81
92
  sitemaps.push({ loc: `${site}/sitemap-products.xml`, lastmod: productsLastmod })
82
93
  }
83
94
 
@@ -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()