@pyreon/zero 0.24.4 → 0.24.6

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 (54) hide show
  1. package/package.json +10 -39
  2. package/src/actions.ts +0 -196
  3. package/src/adapters/bun.ts +0 -114
  4. package/src/adapters/cloudflare.ts +0 -166
  5. package/src/adapters/index.ts +0 -61
  6. package/src/adapters/netlify.ts +0 -154
  7. package/src/adapters/node.ts +0 -163
  8. package/src/adapters/static.ts +0 -42
  9. package/src/adapters/validate.ts +0 -23
  10. package/src/adapters/vercel.ts +0 -182
  11. package/src/adapters/warn-missing-env.ts +0 -49
  12. package/src/ai.ts +0 -623
  13. package/src/api-routes.ts +0 -219
  14. package/src/app.ts +0 -92
  15. package/src/cache.ts +0 -136
  16. package/src/client.ts +0 -143
  17. package/src/compression.ts +0 -116
  18. package/src/config.ts +0 -35
  19. package/src/cors.ts +0 -94
  20. package/src/csp.ts +0 -226
  21. package/src/entry-server.ts +0 -224
  22. package/src/env.ts +0 -344
  23. package/src/error-overlay.ts +0 -118
  24. package/src/favicon.ts +0 -841
  25. package/src/font.ts +0 -511
  26. package/src/fs-router.ts +0 -1519
  27. package/src/i18n-routing.ts +0 -533
  28. package/src/icon.tsx +0 -182
  29. package/src/icons-plugin.ts +0 -296
  30. package/src/image-plugin.ts +0 -751
  31. package/src/image-types.ts +0 -60
  32. package/src/image.tsx +0 -340
  33. package/src/index.ts +0 -92
  34. package/src/isr.ts +0 -394
  35. package/src/link.tsx +0 -304
  36. package/src/logger.ts +0 -144
  37. package/src/manifest.ts +0 -787
  38. package/src/meta.tsx +0 -354
  39. package/src/middleware.ts +0 -65
  40. package/src/not-found.ts +0 -44
  41. package/src/og-image.ts +0 -378
  42. package/src/rate-limit.ts +0 -140
  43. package/src/script.tsx +0 -260
  44. package/src/seo.ts +0 -617
  45. package/src/server.ts +0 -89
  46. package/src/sharp.d.ts +0 -22
  47. package/src/ssg-plugin.ts +0 -1582
  48. package/src/testing.ts +0 -146
  49. package/src/theme.tsx +0 -257
  50. package/src/types.ts +0 -624
  51. package/src/utils/use-intersection-observer.ts +0 -36
  52. package/src/utils/with-headers.ts +0 -13
  53. package/src/vercel-revalidate-handler.ts +0 -204
  54. package/src/vite-plugin.ts +0 -848
package/src/seo.ts DELETED
@@ -1,617 +0,0 @@
1
- import { existsSync } from 'node:fs'
2
- import { readFile, rm, writeFile } from 'node:fs/promises'
3
- import { join, resolve } from 'node:path'
4
- import type { Middleware } from '@pyreon/server'
5
- import type { Plugin } from 'vite'
6
- import type { I18nRoutingConfig } from './i18n-routing'
7
-
8
- // ─── SEO utilities ──────────────────────────────────────────────────────────
9
- //
10
- // Zero provides built-in SEO tooling:
11
- // - Automatic sitemap.xml generation from file-based routes
12
- // - Configurable robots.txt
13
- // - Structured data (JSON-LD) helpers
14
- // - Open Graph / Twitter Card meta helpers
15
-
16
- export interface SitemapConfig {
17
- /** Base URL of the site (required). e.g. "https://example.com" */
18
- origin: string
19
- /** Paths to exclude from the sitemap. */
20
- exclude?: string[]
21
- /** Default change frequency. Default: "weekly" */
22
- changefreq?: ChangeFreq
23
- /** Default priority. Default: 0.7 */
24
- priority?: number
25
- /** Additional URLs to include (for dynamic routes). */
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
83
- }
84
-
85
- export interface SitemapEntry {
86
- path: string
87
- changefreq?: ChangeFreq
88
- priority?: number
89
- lastmod?: string
90
- }
91
-
92
- export type ChangeFreq = 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never'
93
-
94
- /**
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.
102
- */
103
- export function generateSitemap(
104
- routeFiles: string[],
105
- config: SitemapConfig,
106
- i18n?: I18nRoutingConfig,
107
- ): string {
108
- const { origin, exclude = [], changefreq = 'weekly', priority = 0.7 } = config
109
-
110
- const paths = routeFiles
111
- .filter((f) => {
112
- // Exclude layout, error, loading files
113
- const name = f
114
- .split('/')
115
- .pop()
116
- ?.replace(/\.\w+$/, '')
117
- return name !== '_layout' && name !== '_error' && name !== '_loading'
118
- })
119
- .map((f) => {
120
- // Convert file path to URL
121
- let path = f
122
- .replace(/\.\w+$/, '')
123
- .replace(/\/index$/, '/')
124
- .replace(/^index$/, '/')
125
-
126
- // Skip dynamic routes — they need additionalPaths
127
- if (path.includes('[')) return null
128
-
129
- // Strip route groups
130
- path = path.replace(/\([\w-]+\)\//g, '')
131
-
132
- if (!path.startsWith('/')) path = `/${path}`
133
- return path
134
- })
135
- .filter((p): p is string => p !== null)
136
- .filter((p) => !exclude.some((e) => p.startsWith(e)))
137
-
138
- // Dedup by path (first-wins, order-preserving). The same static route
139
- // routinely appears in BOTH the file-system route scan AND
140
- // `additionalPaths` (e.g. SSG-emitted paths merged in via
141
- // `seoPlugin`), which previously produced a DUPLICATE `<url>` entry —
142
- // the i18n branch of `clusterPathsByLocale` dedups via `byUnPrefixed`,
143
- // but the non-i18n branch is a raw 1:1 `entries.map(...)`, so without
144
- // this the duplicate reached the emitted sitemap. Dedup here covers
145
- // both branches at the single source. The route-scan entry wins so its
146
- // configured `changefreq`/`priority` is kept over a bare dup.
147
- const allPaths: SitemapEntry[] = (() => {
148
- const byPath = new Map<string, SitemapEntry>()
149
- for (const e of [
150
- ...paths.map((p) => ({ path: p, changefreq, priority })),
151
- ...(config.additionalPaths ?? []),
152
- ]) {
153
- if (!byPath.has(e.path)) byPath.set(e.path, e)
154
- }
155
- return [...byPath.values()]
156
- })()
157
-
158
- // PR K: when i18n is set, cluster URLs by their un-prefixed (default-
159
- // locale) form so each `<url>` entry can carry the hreflang siblings
160
- // for every locale variant. Without i18n the cluster collapses to a
161
- // single-entry form (one per path) and the renderer skips xhtml:link.
162
- const clusters = clusterPathsByLocale(allPaths, i18n)
163
- const hasHreflang = i18n != null && i18n.locales.length > 0
164
- const xmlnsHreflang = hasHreflang ? ' xmlns:xhtml="http://www.w3.org/1999/xhtml"' : ''
165
-
166
- const entries = clusters
167
- .map((cluster) => renderClusterEntry(cluster, origin, changefreq, priority, i18n))
168
- .join('\n')
169
-
170
- return `<?xml version="1.0" encoding="UTF-8"?>
171
- <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"${xmlnsHreflang}>
172
- ${entries}
173
- </urlset>`
174
- }
175
-
176
- /**
177
- * Cluster URL entries by their un-prefixed (default-locale) form.
178
- *
179
- * Each output cluster has:
180
- * - `canonical`: the SitemapEntry that should be used as the `<url>`
181
- * payload (default-locale variant; falls back to the first variant
182
- * if no default-locale entry exists in the cluster).
183
- * - `variantsByLocale`: Map of locale → SitemapEntry for the cluster.
184
- *
185
- * Without i18n, every entry becomes its own single-variant cluster.
186
- *
187
- * @internal — exported for unit testing.
188
- */
189
- export function clusterPathsByLocale(
190
- entries: SitemapEntry[],
191
- i18n: I18nRoutingConfig | undefined,
192
- ): Cluster[] {
193
- if (i18n == null || i18n.locales.length === 0) {
194
- return entries.map((entry) => ({
195
- canonical: entry,
196
- variantsByLocale: new Map([[null, entry]]),
197
- }))
198
- }
199
-
200
- const strategy = i18n.strategy ?? 'prefix-except-default'
201
- const { defaultLocale, locales } = i18n
202
- // Build a map: unPrefixedPath → (locale | null → entry).
203
- const byUnPrefixed = new Map<string, Map<string | null, SitemapEntry>>()
204
- for (const entry of entries) {
205
- const { unPrefixed, locale } = stripLocalePrefix(entry.path, locales, defaultLocale, strategy)
206
- let cluster = byUnPrefixed.get(unPrefixed)
207
- if (!cluster) {
208
- cluster = new Map()
209
- byUnPrefixed.set(unPrefixed, cluster)
210
- }
211
- cluster.set(locale, entry)
212
- }
213
- // Build Cluster[] in insertion order (preserves the caller's path
214
- // order so sitemap diffs stay stable across runs).
215
- const out: Cluster[] = []
216
- for (const variantsByLocale of byUnPrefixed.values()) {
217
- // Pick the default-locale variant as canonical when present;
218
- // otherwise the first variant inserted.
219
- const canonical
220
- = variantsByLocale.get(defaultLocale)
221
- ?? variantsByLocale.get(null)
222
- ?? [...variantsByLocale.values()][0]!
223
- out.push({ canonical, variantsByLocale })
224
- }
225
- return out
226
- }
227
-
228
- /** A URL cluster — the canonical entry + per-locale variants. @internal */
229
- export interface Cluster {
230
- canonical: SitemapEntry
231
- variantsByLocale: Map<string | null, SitemapEntry>
232
- }
233
-
234
- /**
235
- * Strip the locale prefix from a path under the i18n strategy.
236
- *
237
- * Returns `{ unPrefixed, locale }`:
238
- * - `/about` under `prefix-except-default` (default=en) → `{ unPrefixed: '/about', locale: 'en' }`
239
- * - `/de/about` under either strategy → `{ unPrefixed: '/about', locale: 'de' }`
240
- * - `/de` (locale root) → `{ unPrefixed: '/', locale: 'de' }`
241
- * - `/about` under `prefix` → no locale match, returns `{ unPrefixed: '/about', locale: null }`
242
- * (the URL doesn't fit any locale subtree — sitemap treats it as standalone).
243
- *
244
- * @internal — exported for unit testing.
245
- */
246
- export function stripLocalePrefix(
247
- path: string,
248
- locales: readonly string[],
249
- defaultLocale: string,
250
- strategy: 'prefix' | 'prefix-except-default',
251
- ): { unPrefixed: string; locale: string | null } {
252
- for (const locale of locales) {
253
- if (path === `/${locale}`) return { unPrefixed: '/', locale }
254
- if (path.startsWith(`/${locale}/`)) {
255
- return { unPrefixed: path.slice(`/${locale}`.length), locale }
256
- }
257
- }
258
- // No explicit locale prefix. Under prefix-except-default, the
259
- // un-prefixed path belongs to the default locale by convention.
260
- if (strategy === 'prefix-except-default') {
261
- return { unPrefixed: path, locale: defaultLocale }
262
- }
263
- // Under `prefix`, an un-prefixed URL doesn't fit any locale subtree
264
- // (every locale should carry an explicit prefix). Treat as standalone.
265
- return { unPrefixed: path, locale: null }
266
- }
267
-
268
- function renderClusterEntry(
269
- cluster: Cluster,
270
- origin: string,
271
- changefreq: ChangeFreq,
272
- priority: number,
273
- i18n: I18nRoutingConfig | undefined,
274
- ): string {
275
- const { canonical, variantsByLocale } = cluster
276
- const loc = `${origin}${canonical.path === '/' ? '' : canonical.path}`
277
-
278
- const lines: string[] = [
279
- ' <url>',
280
- ` <loc>${escapeXml(loc)}</loc>`,
281
- ` <changefreq>${canonical.changefreq ?? changefreq}</changefreq>`,
282
- ` <priority>${canonical.priority ?? priority}</priority>`,
283
- ]
284
- if (canonical.lastmod) lines.push(` <lastmod>${canonical.lastmod}</lastmod>`)
285
-
286
- if (i18n != null && i18n.locales.length > 0 && variantsByLocale.size > 1) {
287
- // hreflang per locale variant + x-default → default locale's URL.
288
- for (const locale of i18n.locales) {
289
- const variant = variantsByLocale.get(locale)
290
- if (!variant) continue
291
- const variantLoc = `${origin}${variant.path === '/' ? '' : variant.path}`
292
- lines.push(
293
- ` <xhtml:link rel="alternate" hreflang="${escapeXml(locale)}" href="${escapeXml(variantLoc)}"/>`,
294
- )
295
- }
296
- // x-default — the fallback when no locale matches the user.
297
- const defaultVariant = variantsByLocale.get(i18n.defaultLocale)
298
- if (defaultVariant) {
299
- const defaultLoc = `${origin}${defaultVariant.path === '/' ? '' : defaultVariant.path}`
300
- lines.push(
301
- ` <xhtml:link rel="alternate" hreflang="x-default" href="${escapeXml(defaultLoc)}"/>`,
302
- )
303
- }
304
- }
305
- lines.push(' </url>')
306
- return lines.join('\n')
307
- }
308
-
309
- function escapeXml(str: string): string {
310
- return str
311
- .replace(/&/g, '&amp;')
312
- .replace(/</g, '&lt;')
313
- .replace(/>/g, '&gt;')
314
- .replace(/"/g, '&quot;')
315
- .replace(/'/g, '&apos;')
316
- }
317
-
318
- /**
319
- * Resolve the i18n config to feed `generateSitemap` for hreflang
320
- * emission. Priority order:
321
- * 1. Explicit user config — `hreflang: I18nRoutingConfig` (object)
322
- * 2. Auto-detect from SSG manifest — `hreflang: true` + `manifestI18n`
323
- * present (only happens in SSG mode where the manifest exists)
324
- * 3. Nothing — emit plain sitemap without xhtml:link siblings
325
- *
326
- * @internal — exported for unit testing.
327
- */
328
- export function resolveHreflangI18n(
329
- hreflang: boolean | I18nRoutingConfig | undefined,
330
- manifestI18n: I18nRoutingConfig | undefined,
331
- ): I18nRoutingConfig | undefined {
332
- if (hreflang == null || hreflang === false) return undefined
333
- if (hreflang === true) return manifestI18n
334
- return hreflang
335
- }
336
-
337
- /**
338
- * Duck-type guard for `I18nRoutingConfig`. The SSG manifest is JSON,
339
- * so the embedded i18n field could in principle be malformed if a
340
- * downstream user hand-edits the manifest (don't). Validate the shape
341
- * before trusting it.
342
- *
343
- * @internal
344
- */
345
- function isI18nRoutingConfig(value: unknown): value is I18nRoutingConfig {
346
- if (value == null || typeof value !== 'object') return false
347
- const v = value as Record<string, unknown>
348
- return (
349
- Array.isArray(v.locales)
350
- && v.locales.every((l: unknown) => typeof l === 'string')
351
- && typeof v.defaultLocale === 'string'
352
- )
353
- }
354
-
355
- // ─── Robots.txt ─────────────────────────────────────────────────────────────
356
-
357
- export interface RobotsConfig {
358
- /** Rules per user-agent. */
359
- rules?: RobotsRule[]
360
- /** Sitemap URL. */
361
- sitemap?: string
362
- /** Host directive. */
363
- host?: string
364
- }
365
-
366
- export interface RobotsRule {
367
- userAgent: string
368
- allow?: string[]
369
- disallow?: string[]
370
- crawlDelay?: number
371
- }
372
-
373
- /**
374
- * Generate a robots.txt string.
375
- */
376
- export function generateRobots(config: RobotsConfig = {}): string {
377
- const { rules = [{ userAgent: '*', allow: ['/'] }], sitemap, host } = config
378
- const lines: string[] = []
379
-
380
- for (const rule of rules) {
381
- lines.push(`User-agent: ${rule.userAgent}`)
382
- if (rule.allow) {
383
- for (const path of rule.allow) lines.push(`Allow: ${path}`)
384
- }
385
- if (rule.disallow) {
386
- for (const path of rule.disallow) lines.push(`Disallow: ${path}`)
387
- }
388
- if (rule.crawlDelay) lines.push(`Crawl-delay: ${rule.crawlDelay}`)
389
- lines.push('')
390
- }
391
-
392
- if (sitemap) lines.push(`Sitemap: ${sitemap}`)
393
- if (host) lines.push(`Host: ${host}`)
394
-
395
- return lines.join('\n')
396
- }
397
-
398
- // ─── Structured data (JSON-LD) ──────────────────────────────────────────────
399
-
400
- export type JsonLdType =
401
- | 'WebSite'
402
- | 'WebPage'
403
- | 'Article'
404
- | 'BlogPosting'
405
- | 'Product'
406
- | 'Organization'
407
- | 'Person'
408
- | 'BreadcrumbList'
409
- | 'FAQPage'
410
- | (string & {})
411
-
412
- /**
413
- * Generate a JSON-LD script tag string for structured data.
414
- *
415
- * @example
416
- * useHead({
417
- * script: [jsonLd({
418
- * "@type": "WebSite",
419
- * name: "My Site",
420
- * url: "https://example.com",
421
- * })],
422
- * })
423
- */
424
- export function jsonLd(data: Record<string, unknown>): string {
425
- const ld = {
426
- '@context': 'https://schema.org',
427
- ...data,
428
- }
429
- return `<script type="application/ld+json">${JSON.stringify(ld)}</script>`
430
- }
431
-
432
- // ─── SEO Vite plugin ────────────────────────────────────────────────────────
433
-
434
- export interface SeoPluginConfig {
435
- /** Sitemap configuration. */
436
- sitemap?: SitemapConfig
437
- /** Robots.txt configuration. */
438
- robots?: RobotsConfig
439
- }
440
-
441
- /**
442
- * Zero SEO Vite plugin.
443
- * Generates sitemap.xml and robots.txt at build time.
444
- *
445
- * @example
446
- * import { seoPlugin } from "@pyreon/zero/seo"
447
- *
448
- * export default {
449
- * plugins: [
450
- * pyreon(),
451
- * zero(),
452
- * seoPlugin({
453
- * sitemap: {
454
- * origin: "https://example.com",
455
- * useSsgPaths: true, // include dynamic-route enumerations
456
- * },
457
- * robots: { sitemap: "https://example.com/sitemap.xml" },
458
- * }),
459
- * ],
460
- * }
461
- */
462
- export function seoPlugin(config: SeoPluginConfig = {}): Plugin {
463
- // PR F — when `useSsgPaths` is true, sitemap.xml emission moves to
464
- // `closeBundle` (post-SSG) so the SSG plugin's resolved-paths manifest
465
- // is available. Otherwise it stays at `generateBundle` for the
466
- // file-scan-only fast path.
467
- const useSsgPaths = config.sitemap?.useSsgPaths === true
468
- let distDir = ''
469
-
470
- return {
471
- name: 'pyreon-zero-seo',
472
- apply: 'build',
473
- // `enforce: 'post'` for the closeBundle case so we run AFTER the
474
- // SSG plugin's path-manifest write. `closeBundle` hooks fire in
475
- // plugin-registration order, but enforce-post pushes us to the
476
- // tail regardless of where seoPlugin lands in the user's array.
477
- ...(useSsgPaths ? ({ enforce: 'post' } as const) : {}),
478
-
479
- configResolved(resolved) {
480
- distDir = resolve(resolved.root, resolved.build.outDir)
481
- },
482
-
483
- async generateBundle(_, _bundle) {
484
- // Skip sitemap emission here when `useSsgPaths` is true — moves to
485
- // `closeBundle` below where the SSG manifest is readable.
486
- if (config.sitemap && !useSsgPaths) {
487
- const { scanRouteFiles } = await import('./fs-router')
488
- const routesDir = `${process.cwd()}/src/routes`
489
-
490
- try {
491
- const files = await scanRouteFiles(routesDir)
492
- // File-scan path can't auto-detect i18n from the SSG manifest
493
- // (the manifest only exists in SSG mode). Honour explicit user
494
- // config (`hreflang: { locales: [...] }`); auto-detect mode
495
- // (`hreflang: true`) is a no-op here since there's no manifest.
496
- const hreflangI18n = resolveHreflangI18n(config.sitemap.hreflang, undefined)
497
- const sitemap = generateSitemap(files, config.sitemap, hreflangI18n)
498
-
499
- this.emitFile({
500
- type: 'asset',
501
- fileName: 'sitemap.xml',
502
- source: sitemap,
503
- })
504
- } catch {
505
- // Sitemap generation failed — skip silently
506
- }
507
- }
508
-
509
- // Generate robots.txt
510
- if (config.robots) {
511
- const robots = generateRobots(config.robots)
512
-
513
- this.emitFile({
514
- type: 'asset',
515
- fileName: 'robots.txt',
516
- source: robots,
517
- })
518
- }
519
- },
520
-
521
- async closeBundle() {
522
- // PR F — `useSsgPaths` path. Read the manifest the SSG plugin
523
- // wrote at its own `closeBundle`, merge into the file-scan paths,
524
- // emit sitemap.xml to dist via writeFile (Vite's `emitFile` API
525
- // only works during the bundling phase, not at closeBundle).
526
- if (!config.sitemap || !useSsgPaths) return
527
-
528
- const { scanRouteFiles } = await import('./fs-router')
529
- const routesDir = `${process.cwd()}/src/routes`
530
- const manifestPath = join(distDir, '_pyreon-ssg-paths.json')
531
-
532
- try {
533
- let ssgPaths: SitemapEntry[] = []
534
- // PR K: pick up the i18n config the SSG plugin embeds into the
535
- // manifest when `zero({ i18n: ... })` is set. Read it here so
536
- // hreflang siblings emit without the user having to declare
537
- // i18n in two places.
538
- let manifestI18n: I18nRoutingConfig | undefined
539
- if (existsSync(manifestPath)) {
540
- const raw = await readFile(manifestPath, 'utf-8')
541
- const parsed = JSON.parse(raw) as { paths?: unknown; i18n?: unknown }
542
- if (Array.isArray(parsed.paths)) {
543
- ssgPaths = parsed.paths
544
- .filter((p): p is string => typeof p === 'string')
545
- .map((path) => ({ path }))
546
- }
547
- if (isI18nRoutingConfig(parsed.i18n)) manifestI18n = parsed.i18n
548
- // Cleanup — manifest is an internal artifact, not for
549
- // the published static host.
550
- try {
551
- await rm(manifestPath, { force: true })
552
- } catch {
553
- // best-effort
554
- }
555
- }
556
-
557
- // File-scan still runs as a fallback for static routes that
558
- // weren't enumerated by the SSG manifest (e.g. mode is `ssg`
559
- // but the manifest write was skipped, or static routes
560
- // are present alongside the SSG output). The merge dedups
561
- // by path so a static route emitted by both paths only
562
- // appears once in the sitemap.
563
- let files: string[] = []
564
- try {
565
- files = await scanRouteFiles(routesDir)
566
- } catch {
567
- // routesDir missing — only the SSG manifest paths land in the sitemap.
568
- }
569
-
570
- const merged: SitemapConfig = {
571
- ...config.sitemap,
572
- additionalPaths: [...ssgPaths, ...(config.sitemap.additionalPaths ?? [])],
573
- }
574
- // Resolve hreflang i18n config in priority order:
575
- // 1. Explicit user config (object form: hreflang: { locales: [...] })
576
- // 2. Auto-detect from SSG manifest (hreflang: true)
577
- // 3. Nothing — emit plain sitemap without xhtml:link
578
- const hreflangI18n = resolveHreflangI18n(config.sitemap.hreflang, manifestI18n)
579
- const sitemap = generateSitemap(files, merged, hreflangI18n)
580
- await writeFile(join(distDir, 'sitemap.xml'), sitemap, 'utf-8')
581
- } catch {
582
- // Sitemap generation failed — skip silently
583
- }
584
- },
585
- }
586
- }
587
-
588
- // ─── SEO middleware (serve sitemap/robots in dev) ────────────────────────────
589
-
590
- /**
591
- * SEO middleware for dev server.
592
- * Serves sitemap.xml and robots.txt dynamically during development.
593
- */
594
- export function seoMiddleware(config: SeoPluginConfig = {}): Middleware {
595
- return async (ctx) => {
596
- if (ctx.url.pathname === '/robots.txt' && config.robots) {
597
- return new Response(generateRobots(config.robots), {
598
- headers: { 'Content-Type': 'text/plain' },
599
- })
600
- }
601
-
602
- if (ctx.url.pathname === '/sitemap.xml' && config.sitemap) {
603
- try {
604
- const { scanRouteFiles } = await import('./fs-router')
605
- const routesDir = `${process.cwd()}/src/routes`
606
- const files = await scanRouteFiles(routesDir)
607
- const sitemap = generateSitemap(files, config.sitemap)
608
-
609
- return new Response(sitemap, {
610
- headers: { 'Content-Type': 'application/xml' },
611
- })
612
- } catch {
613
- // Sitemap generation failed — continue to rendering
614
- }
615
- }
616
- }
617
- }