@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
package/dist/vite-plugin.d.ts
CHANGED
|
@@ -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,
|
|
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"}
|
package/dist/vite-plugin.js
CHANGED
|
@@ -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
|
|
24
|
-
//
|
|
25
|
-
//
|
|
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 (
|
|
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 (
|
|
72
|
+
if (withProvider) {
|
|
35
73
|
lines.push(`_setContentProvider(_cp);`);
|
|
36
74
|
}
|
|
37
75
|
lines.push(`export { config };`);
|
package/dist/vite-plugin.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"vite-plugin.js","sourceRoot":"","sources":["../src/vite-plugin.ts"],"names":[],"mappings":"
|
|
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.
|
|
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": "^
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
// ───
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
109
|
-
entrypoint: resolveEntrypoint(
|
|
110
|
-
prerender:
|
|
114
|
+
pattern,
|
|
115
|
+
entrypoint: resolveEntrypoint(entrypoint),
|
|
116
|
+
prerender: prerenderContentRoutes,
|
|
111
117
|
})
|
|
112
|
-
injected.push(
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
286
|
-
//
|
|
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
|
}
|
package/src/middleware/seo.ts
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
|
|
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({}),
|
package/src/routes/apple-news.ts
CHANGED
|
@@ -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()
|
package/src/routes/llms-full.ts
CHANGED
|
@@ -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()
|
package/src/routes/llms.ts
CHANGED
|
@@ -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()
|
package/src/routes/podcast.ts
CHANGED
|
@@ -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()
|
package/src/routes/robots.ts
CHANGED
|
@@ -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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
}
|
|
35
|
+
} catch {}
|
|
36
|
+
}
|
|
36
37
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
}
|
|
49
|
+
} catch {}
|
|
50
|
+
}
|
|
48
51
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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()
|