@pyreon/zero 0.15.0 → 0.16.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 (52) hide show
  1. package/lib/{api-routes-DANluJic.js → api-routes-Ci0kVmM4.js} +2 -2
  2. package/lib/client.js +4 -1
  3. package/lib/env.js +6 -6
  4. package/lib/font.js +3 -3
  5. package/lib/{fs-router-ZebyutPa.js → fs-router-MewHc5SB.js} +25 -30
  6. package/lib/i18n-routing.js +112 -1
  7. package/lib/image.js +140 -58
  8. package/lib/index.js +252 -82
  9. package/lib/og-image.js +5 -5
  10. package/lib/rolldown-runtime-CjeV3_4I.js +18 -0
  11. package/lib/script.js +114 -25
  12. package/lib/seo.js +186 -15
  13. package/lib/server.js +274 -564
  14. package/lib/types/config.d.ts +275 -3
  15. package/lib/types/env.d.ts +2 -2
  16. package/lib/types/i18n-routing.d.ts +193 -2
  17. package/lib/types/image.d.ts +105 -5
  18. package/lib/types/index.d.ts +634 -182
  19. package/lib/types/script.d.ts +78 -6
  20. package/lib/types/seo.d.ts +128 -4
  21. package/lib/types/server.d.ts +575 -72
  22. package/lib/vite-plugin-xjWZwudX.js +2454 -0
  23. package/package.json +11 -10
  24. package/src/adapters/bun.ts +20 -1
  25. package/src/adapters/cloudflare.ts +78 -1
  26. package/src/adapters/index.ts +25 -3
  27. package/src/adapters/netlify.ts +63 -1
  28. package/src/adapters/node.ts +25 -1
  29. package/src/adapters/static.ts +26 -1
  30. package/src/adapters/validate.ts +8 -1
  31. package/src/adapters/vercel.ts +76 -1
  32. package/src/adapters/warn-missing-env.ts +49 -0
  33. package/src/app.ts +14 -0
  34. package/src/client.ts +18 -0
  35. package/src/entry-server.ts +55 -5
  36. package/src/env.ts +7 -7
  37. package/src/font.ts +3 -3
  38. package/src/fs-router.ts +72 -3
  39. package/src/i18n-routing.ts +246 -12
  40. package/src/image.tsx +242 -91
  41. package/src/index.ts +4 -4
  42. package/src/isr.ts +24 -6
  43. package/src/manifest.ts +675 -0
  44. package/src/og-image.ts +5 -5
  45. package/src/script.tsx +159 -36
  46. package/src/seo.ts +346 -15
  47. package/src/server.ts +10 -2
  48. package/src/ssg-plugin.ts +1211 -54
  49. package/src/types.ts +301 -10
  50. package/src/vercel-revalidate-handler.ts +204 -0
  51. package/src/vite-plugin.ts +108 -30
  52. package/lib/vite-plugin-E4BHYvYW.js +0 -855
package/src/seo.ts CHANGED
@@ -1,5 +1,9 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { readFile, rm, writeFile } from 'node:fs/promises'
3
+ import { join, resolve } from 'node:path'
1
4
  import type { Middleware } from '@pyreon/server'
2
5
  import type { Plugin } from 'vite'
6
+ import type { I18nRoutingConfig } from './i18n-routing'
3
7
 
4
8
  // ─── SEO utilities ──────────────────────────────────────────────────────────
5
9
  //
@@ -20,6 +24,62 @@ export interface SitemapConfig {
20
24
  priority?: number
21
25
  /** Additional URLs to include (for dynamic routes). */
22
26
  additionalPaths?: SitemapEntry[]
27
+ /**
28
+ * When `true` AND the build is running in SSG mode, the sitemap reads
29
+ * the resolved-paths manifest emitted by the SSG plugin
30
+ * (`dist/_pyreon-ssg-paths.json`) and includes EVERY prerendered URL —
31
+ * including dynamic routes enumerated via `getStaticPaths` (PR A) and
32
+ * per-locale variants (PR H, when shipped). Without this flag the
33
+ * sitemap walks the file-system route tree directly and silently
34
+ * skips dynamic routes (`[id]` / `[...slug]`) because their concrete
35
+ * values aren't knowable without running each route's enumerator.
36
+ *
37
+ * Sequencing: when `true`, sitemap.xml emission moves from Vite's
38
+ * `generateBundle` hook (where the SSG plugin's path enumeration
39
+ * hasn't run yet) to `closeBundle` with `enforce: 'post'` so it
40
+ * runs AFTER the SSG plugin. The user must ensure `seoPlugin()` is
41
+ * placed AFTER `zero()` in the Vite plugin array (the canonical
42
+ * ordering — `closeBundle` hooks fire in plugin-registration order).
43
+ *
44
+ * Falls back gracefully: when the manifest doesn't exist (mode is
45
+ * not `ssg`, or the SSG step was skipped), the sitemap still walks
46
+ * the file-system routes — same shape as without this flag.
47
+ *
48
+ * Default: `false` (preserves prior behaviour). Set `true` for SSG
49
+ * sites that ship dynamic-route enumerations.
50
+ */
51
+ useSsgPaths?: boolean
52
+ /**
53
+ * Emit `<xhtml:link rel="alternate" hreflang="...">` cross-references
54
+ * inside each `<url>` entry, declaring the locale variants of every
55
+ * page (PR K — i18n follow-up).
56
+ *
57
+ * Accepts:
58
+ * - `true` — read the i18n config from the SSG paths manifest
59
+ * (which `zero({ i18n: ... })` automatically embeds when SSG runs).
60
+ * Zero-config win — declare i18n once, sitemap picks it up.
61
+ * - `I18nRoutingConfig` — pass the i18n config explicitly. Use when
62
+ * the project doesn't run SSG (file-scan sitemap path) but still
63
+ * wants hreflang in the emitted sitemap.
64
+ * - `false` / omitted — no hreflang, plain `<url>` entries.
65
+ *
66
+ * The emitted shape per page-cluster is the Google-recommended form:
67
+ *
68
+ * <url>
69
+ * <loc>https://example.com/about</loc>
70
+ * <xhtml:link rel="alternate" hreflang="en" href="https://example.com/about"/>
71
+ * <xhtml:link rel="alternate" hreflang="de" href="https://example.com/de/about"/>
72
+ * <xhtml:link rel="alternate" hreflang="cs" href="https://example.com/cs/about"/>
73
+ * <xhtml:link rel="alternate" hreflang="x-default" href="https://example.com/about"/>
74
+ * </url>
75
+ *
76
+ * The `x-default` entry points at the default-locale URL so search
77
+ * engines have a fallback when the user's language doesn't match any
78
+ * of the configured locales. URLs are clustered by their un-prefixed
79
+ * (default-locale) form — `/about`, `/de/about`, `/cs/about` collapse
80
+ * into ONE `<url>` entry with three `xhtml:link` siblings.
81
+ */
82
+ hreflang?: boolean | I18nRoutingConfig
23
83
  }
24
84
 
25
85
  export interface SitemapEntry {
@@ -33,8 +93,18 @@ export type ChangeFreq = 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' |
33
93
 
34
94
  /**
35
95
  * Generate a sitemap.xml string from route file paths.
96
+ *
97
+ * When `i18n` is set (PR K — passed by `seoPlugin` after reading the
98
+ * i18n config from `zero({ i18n: ... })`), URLs are clustered by their
99
+ * un-prefixed (default-locale) form and each `<url>` carries
100
+ * `<xhtml:link rel="alternate" hreflang="...">` siblings for every
101
+ * locale variant + an `x-default` entry pointing at the default locale.
36
102
  */
37
- export function generateSitemap(routeFiles: string[], config: SitemapConfig): string {
103
+ export function generateSitemap(
104
+ routeFiles: string[],
105
+ config: SitemapConfig,
106
+ i18n?: I18nRoutingConfig,
107
+ ): string {
38
108
  const { origin, exclude = [], changefreq = 'weekly', priority = 0.7 } = config
39
109
 
40
110
  const paths = routeFiles
@@ -70,23 +140,157 @@ export function generateSitemap(routeFiles: string[], config: SitemapConfig): st
70
140
  ...(config.additionalPaths ?? []),
71
141
  ]
72
142
 
73
- const entries = allPaths
74
- .map((entry) => {
75
- const loc = `${origin}${entry.path === '/' ? '' : entry.path}`
76
- return ` <url>
77
- <loc>${escapeXml(loc)}</loc>
78
- <changefreq>${entry.changefreq ?? changefreq}</changefreq>
79
- <priority>${entry.priority ?? priority}</priority>${entry.lastmod ? `\n <lastmod>${entry.lastmod}</lastmod>` : ''}
80
- </url>`
81
- })
143
+ // PR K: when i18n is set, cluster URLs by their un-prefixed (default-
144
+ // locale) form so each `<url>` entry can carry the hreflang siblings
145
+ // for every locale variant. Without i18n the cluster collapses to a
146
+ // single-entry form (one per path) and the renderer skips xhtml:link.
147
+ const clusters = clusterPathsByLocale(allPaths, i18n)
148
+ const hasHreflang = i18n != null && i18n.locales.length > 0
149
+ const xmlnsHreflang = hasHreflang ? ' xmlns:xhtml="http://www.w3.org/1999/xhtml"' : ''
150
+
151
+ const entries = clusters
152
+ .map((cluster) => renderClusterEntry(cluster, origin, changefreq, priority, i18n))
82
153
  .join('\n')
83
154
 
84
155
  return `<?xml version="1.0" encoding="UTF-8"?>
85
- <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
156
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"${xmlnsHreflang}>
86
157
  ${entries}
87
158
  </urlset>`
88
159
  }
89
160
 
161
+ /**
162
+ * Cluster URL entries by their un-prefixed (default-locale) form.
163
+ *
164
+ * Each output cluster has:
165
+ * - `canonical`: the SitemapEntry that should be used as the `<url>`
166
+ * payload (default-locale variant; falls back to the first variant
167
+ * if no default-locale entry exists in the cluster).
168
+ * - `variantsByLocale`: Map of locale → SitemapEntry for the cluster.
169
+ *
170
+ * Without i18n, every entry becomes its own single-variant cluster.
171
+ *
172
+ * @internal — exported for unit testing.
173
+ */
174
+ export function clusterPathsByLocale(
175
+ entries: SitemapEntry[],
176
+ i18n: I18nRoutingConfig | undefined,
177
+ ): Cluster[] {
178
+ if (i18n == null || i18n.locales.length === 0) {
179
+ return entries.map((entry) => ({
180
+ canonical: entry,
181
+ variantsByLocale: new Map([[null, entry]]),
182
+ }))
183
+ }
184
+
185
+ const strategy = i18n.strategy ?? 'prefix-except-default'
186
+ const { defaultLocale, locales } = i18n
187
+ // Build a map: unPrefixedPath → (locale | null → entry).
188
+ const byUnPrefixed = new Map<string, Map<string | null, SitemapEntry>>()
189
+ for (const entry of entries) {
190
+ const { unPrefixed, locale } = stripLocalePrefix(entry.path, locales, defaultLocale, strategy)
191
+ let cluster = byUnPrefixed.get(unPrefixed)
192
+ if (!cluster) {
193
+ cluster = new Map()
194
+ byUnPrefixed.set(unPrefixed, cluster)
195
+ }
196
+ cluster.set(locale, entry)
197
+ }
198
+ // Build Cluster[] in insertion order (preserves the caller's path
199
+ // order so sitemap diffs stay stable across runs).
200
+ const out: Cluster[] = []
201
+ for (const variantsByLocale of byUnPrefixed.values()) {
202
+ // Pick the default-locale variant as canonical when present;
203
+ // otherwise the first variant inserted.
204
+ const canonical
205
+ = variantsByLocale.get(defaultLocale)
206
+ ?? variantsByLocale.get(null)
207
+ ?? [...variantsByLocale.values()][0]!
208
+ out.push({ canonical, variantsByLocale })
209
+ }
210
+ return out
211
+ }
212
+
213
+ /** A URL cluster — the canonical entry + per-locale variants. @internal */
214
+ export interface Cluster {
215
+ canonical: SitemapEntry
216
+ variantsByLocale: Map<string | null, SitemapEntry>
217
+ }
218
+
219
+ /**
220
+ * Strip the locale prefix from a path under the i18n strategy.
221
+ *
222
+ * Returns `{ unPrefixed, locale }`:
223
+ * - `/about` under `prefix-except-default` (default=en) → `{ unPrefixed: '/about', locale: 'en' }`
224
+ * - `/de/about` under either strategy → `{ unPrefixed: '/about', locale: 'de' }`
225
+ * - `/de` (locale root) → `{ unPrefixed: '/', locale: 'de' }`
226
+ * - `/about` under `prefix` → no locale match, returns `{ unPrefixed: '/about', locale: null }`
227
+ * (the URL doesn't fit any locale subtree — sitemap treats it as standalone).
228
+ *
229
+ * @internal — exported for unit testing.
230
+ */
231
+ export function stripLocalePrefix(
232
+ path: string,
233
+ locales: readonly string[],
234
+ defaultLocale: string,
235
+ strategy: 'prefix' | 'prefix-except-default',
236
+ ): { unPrefixed: string; locale: string | null } {
237
+ for (const locale of locales) {
238
+ if (path === `/${locale}`) return { unPrefixed: '/', locale }
239
+ if (path.startsWith(`/${locale}/`)) {
240
+ return { unPrefixed: path.slice(`/${locale}`.length), locale }
241
+ }
242
+ }
243
+ // No explicit locale prefix. Under prefix-except-default, the
244
+ // un-prefixed path belongs to the default locale by convention.
245
+ if (strategy === 'prefix-except-default') {
246
+ return { unPrefixed: path, locale: defaultLocale }
247
+ }
248
+ // Under `prefix`, an un-prefixed URL doesn't fit any locale subtree
249
+ // (every locale should carry an explicit prefix). Treat as standalone.
250
+ return { unPrefixed: path, locale: null }
251
+ }
252
+
253
+ function renderClusterEntry(
254
+ cluster: Cluster,
255
+ origin: string,
256
+ changefreq: ChangeFreq,
257
+ priority: number,
258
+ i18n: I18nRoutingConfig | undefined,
259
+ ): string {
260
+ const { canonical, variantsByLocale } = cluster
261
+ const loc = `${origin}${canonical.path === '/' ? '' : canonical.path}`
262
+
263
+ const lines: string[] = [
264
+ ' <url>',
265
+ ` <loc>${escapeXml(loc)}</loc>`,
266
+ ` <changefreq>${canonical.changefreq ?? changefreq}</changefreq>`,
267
+ ` <priority>${canonical.priority ?? priority}</priority>`,
268
+ ]
269
+ if (canonical.lastmod) lines.push(` <lastmod>${canonical.lastmod}</lastmod>`)
270
+
271
+ if (i18n != null && i18n.locales.length > 0 && variantsByLocale.size > 1) {
272
+ // hreflang per locale variant + x-default → default locale's URL.
273
+ for (const locale of i18n.locales) {
274
+ const variant = variantsByLocale.get(locale)
275
+ if (!variant) continue
276
+ const variantLoc = `${origin}${variant.path === '/' ? '' : variant.path}`
277
+ lines.push(
278
+ ` <xhtml:link rel="alternate" hreflang="${escapeXml(locale)}" href="${escapeXml(variantLoc)}"/>`,
279
+ )
280
+ }
281
+ // x-default — the fallback when no locale matches the user.
282
+ const defaultVariant = variantsByLocale.get(i18n.defaultLocale)
283
+ if (defaultVariant) {
284
+ const defaultLoc = `${origin}${defaultVariant.path === '/' ? '' : defaultVariant.path}`
285
+ lines.push(
286
+ ` <xhtml:link rel="alternate" hreflang="x-default" href="${escapeXml(defaultLoc)}"/>`,
287
+ )
288
+ }
289
+ }
290
+ lines.push(' </url>')
291
+ return lines.join('\n')
292
+ }
293
+
90
294
  function escapeXml(str: string): string {
91
295
  return str
92
296
  .replace(/&/g, '&amp;')
@@ -96,6 +300,43 @@ function escapeXml(str: string): string {
96
300
  .replace(/'/g, '&apos;')
97
301
  }
98
302
 
303
+ /**
304
+ * Resolve the i18n config to feed `generateSitemap` for hreflang
305
+ * emission. Priority order:
306
+ * 1. Explicit user config — `hreflang: I18nRoutingConfig` (object)
307
+ * 2. Auto-detect from SSG manifest — `hreflang: true` + `manifestI18n`
308
+ * present (only happens in SSG mode where the manifest exists)
309
+ * 3. Nothing — emit plain sitemap without xhtml:link siblings
310
+ *
311
+ * @internal — exported for unit testing.
312
+ */
313
+ export function resolveHreflangI18n(
314
+ hreflang: boolean | I18nRoutingConfig | undefined,
315
+ manifestI18n: I18nRoutingConfig | undefined,
316
+ ): I18nRoutingConfig | undefined {
317
+ if (hreflang == null || hreflang === false) return undefined
318
+ if (hreflang === true) return manifestI18n
319
+ return hreflang
320
+ }
321
+
322
+ /**
323
+ * Duck-type guard for `I18nRoutingConfig`. The SSG manifest is JSON,
324
+ * so the embedded i18n field could in principle be malformed if a
325
+ * downstream user hand-edits the manifest (don't). Validate the shape
326
+ * before trusting it.
327
+ *
328
+ * @internal
329
+ */
330
+ function isI18nRoutingConfig(value: unknown): value is I18nRoutingConfig {
331
+ if (value == null || typeof value !== 'object') return false
332
+ const v = value as Record<string, unknown>
333
+ return (
334
+ Array.isArray(v.locales)
335
+ && v.locales.every((l: unknown) => typeof l === 'string')
336
+ && typeof v.defaultLocale === 'string'
337
+ )
338
+ }
339
+
99
340
  // ─── Robots.txt ─────────────────────────────────────────────────────────────
100
341
 
101
342
  export interface RobotsConfig {
@@ -194,26 +435,51 @@ export interface SeoPluginConfig {
194
435
  * pyreon(),
195
436
  * zero(),
196
437
  * seoPlugin({
197
- * sitemap: { origin: "https://example.com" },
438
+ * sitemap: {
439
+ * origin: "https://example.com",
440
+ * useSsgPaths: true, // include dynamic-route enumerations
441
+ * },
198
442
  * robots: { sitemap: "https://example.com/sitemap.xml" },
199
443
  * }),
200
444
  * ],
201
445
  * }
202
446
  */
203
447
  export function seoPlugin(config: SeoPluginConfig = {}): Plugin {
448
+ // PR F — when `useSsgPaths` is true, sitemap.xml emission moves to
449
+ // `closeBundle` (post-SSG) so the SSG plugin's resolved-paths manifest
450
+ // is available. Otherwise it stays at `generateBundle` for the
451
+ // file-scan-only fast path.
452
+ const useSsgPaths = config.sitemap?.useSsgPaths === true
453
+ let distDir = ''
454
+
204
455
  return {
205
456
  name: 'pyreon-zero-seo',
206
457
  apply: 'build',
458
+ // `enforce: 'post'` for the closeBundle case so we run AFTER the
459
+ // SSG plugin's path-manifest write. `closeBundle` hooks fire in
460
+ // plugin-registration order, but enforce-post pushes us to the
461
+ // tail regardless of where seoPlugin lands in the user's array.
462
+ ...(useSsgPaths ? ({ enforce: 'post' } as const) : {}),
463
+
464
+ configResolved(resolved) {
465
+ distDir = resolve(resolved.root, resolved.build.outDir)
466
+ },
207
467
 
208
468
  async generateBundle(_, _bundle) {
209
- // Generate sitemap.xml
210
- if (config.sitemap) {
469
+ // Skip sitemap emission here when `useSsgPaths` is true — moves to
470
+ // `closeBundle` below where the SSG manifest is readable.
471
+ if (config.sitemap && !useSsgPaths) {
211
472
  const { scanRouteFiles } = await import('./fs-router')
212
473
  const routesDir = `${process.cwd()}/src/routes`
213
474
 
214
475
  try {
215
476
  const files = await scanRouteFiles(routesDir)
216
- const sitemap = generateSitemap(files, config.sitemap)
477
+ // File-scan path can't auto-detect i18n from the SSG manifest
478
+ // (the manifest only exists in SSG mode). Honour explicit user
479
+ // config (`hreflang: { locales: [...] }`); auto-detect mode
480
+ // (`hreflang: true`) is a no-op here since there's no manifest.
481
+ const hreflangI18n = resolveHreflangI18n(config.sitemap.hreflang, undefined)
482
+ const sitemap = generateSitemap(files, config.sitemap, hreflangI18n)
217
483
 
218
484
  this.emitFile({
219
485
  type: 'asset',
@@ -236,6 +502,71 @@ export function seoPlugin(config: SeoPluginConfig = {}): Plugin {
236
502
  })
237
503
  }
238
504
  },
505
+
506
+ async closeBundle() {
507
+ // PR F — `useSsgPaths` path. Read the manifest the SSG plugin
508
+ // wrote at its own `closeBundle`, merge into the file-scan paths,
509
+ // emit sitemap.xml to dist via writeFile (Vite's `emitFile` API
510
+ // only works during the bundling phase, not at closeBundle).
511
+ if (!config.sitemap || !useSsgPaths) return
512
+
513
+ const { scanRouteFiles } = await import('./fs-router')
514
+ const routesDir = `${process.cwd()}/src/routes`
515
+ const manifestPath = join(distDir, '_pyreon-ssg-paths.json')
516
+
517
+ try {
518
+ let ssgPaths: SitemapEntry[] = []
519
+ // PR K: pick up the i18n config the SSG plugin embeds into the
520
+ // manifest when `zero({ i18n: ... })` is set. Read it here so
521
+ // hreflang siblings emit without the user having to declare
522
+ // i18n in two places.
523
+ let manifestI18n: I18nRoutingConfig | undefined
524
+ if (existsSync(manifestPath)) {
525
+ const raw = await readFile(manifestPath, 'utf-8')
526
+ const parsed = JSON.parse(raw) as { paths?: unknown; i18n?: unknown }
527
+ if (Array.isArray(parsed.paths)) {
528
+ ssgPaths = parsed.paths
529
+ .filter((p): p is string => typeof p === 'string')
530
+ .map((path) => ({ path }))
531
+ }
532
+ if (isI18nRoutingConfig(parsed.i18n)) manifestI18n = parsed.i18n
533
+ // Cleanup — manifest is an internal artifact, not for
534
+ // the published static host.
535
+ try {
536
+ await rm(manifestPath, { force: true })
537
+ } catch {
538
+ // best-effort
539
+ }
540
+ }
541
+
542
+ // File-scan still runs as a fallback for static routes that
543
+ // weren't enumerated by the SSG manifest (e.g. mode is `ssg`
544
+ // but the manifest write was skipped, or static routes
545
+ // are present alongside the SSG output). The merge dedups
546
+ // by path so a static route emitted by both paths only
547
+ // appears once in the sitemap.
548
+ let files: string[] = []
549
+ try {
550
+ files = await scanRouteFiles(routesDir)
551
+ } catch {
552
+ // routesDir missing — only the SSG manifest paths land in the sitemap.
553
+ }
554
+
555
+ const merged: SitemapConfig = {
556
+ ...config.sitemap,
557
+ additionalPaths: [...ssgPaths, ...(config.sitemap.additionalPaths ?? [])],
558
+ }
559
+ // Resolve hreflang i18n config in priority order:
560
+ // 1. Explicit user config (object form: hreflang: { locales: [...] })
561
+ // 2. Auto-detect from SSG manifest (hreflang: true)
562
+ // 3. Nothing — emit plain sitemap without xhtml:link
563
+ const hreflangI18n = resolveHreflangI18n(config.sitemap.hreflang, manifestI18n)
564
+ const sitemap = generateSitemap(files, merged, hreflangI18n)
565
+ await writeFile(join(distDir, 'sitemap.xml'), sitemap, 'utf-8')
566
+ } catch {
567
+ // Sitemap generation failed — skip silently
568
+ }
569
+ },
239
570
  }
240
571
  }
241
572
 
package/src/server.ts CHANGED
@@ -24,7 +24,7 @@ export { defineConfig, resolveConfig } from "./config";
24
24
 
25
25
  // ─── File-system routing ────────────────────────────────────────────────────
26
26
 
27
- export type { GenerateRouteModuleOptions } from './fs-router'
27
+ export type { GenerateRouteModuleOptions, GetStaticPaths } from './fs-router'
28
28
  export {
29
29
  filePathToUrlPath,
30
30
  generateMiddlewareModule,
@@ -37,6 +37,14 @@ export {
37
37
 
38
38
  export { createISRHandler } from "./isr";
39
39
 
40
+ // ─── Vercel revalidate handler (M3.1) ───────────────────────────────────────
41
+
42
+ export type { VercelRevalidateHandlerOptions } from "./vercel-revalidate-handler";
43
+ export {
44
+ _resetVercelRevalidateHandlerCache,
45
+ vercelRevalidateHandler,
46
+ } from "./vercel-revalidate-handler";
47
+
40
48
  // ─── Adapters ───────────────────────────────────────────────────────────────
41
49
 
42
50
  export {
@@ -59,7 +67,7 @@ export { compose, getContext } from "./middleware";
59
67
 
60
68
  // ─── Vite plugins ───────────────────────────────────────────────────────────
61
69
 
62
- export { zeroPlugin as default } from "./vite-plugin";
70
+ export { zeroPlugin as default, getZeroPluginConfig } from "./vite-plugin";
63
71
  export type { FaviconPluginConfig, FaviconLocaleConfig } from "./favicon";
64
72
  export { faviconPlugin, faviconLinks } from "./favicon";
65
73
  export type { SeoPluginConfig, SitemapConfig, RobotsConfig } from "./seo";