@pyreon/zero 0.24.5 → 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.
- package/package.json +10 -39
- package/src/actions.ts +0 -196
- package/src/adapters/bun.ts +0 -114
- package/src/adapters/cloudflare.ts +0 -166
- package/src/adapters/index.ts +0 -61
- package/src/adapters/netlify.ts +0 -154
- package/src/adapters/node.ts +0 -163
- package/src/adapters/static.ts +0 -42
- package/src/adapters/validate.ts +0 -23
- package/src/adapters/vercel.ts +0 -182
- package/src/adapters/warn-missing-env.ts +0 -49
- package/src/ai.ts +0 -623
- package/src/api-routes.ts +0 -219
- package/src/app.ts +0 -92
- package/src/cache.ts +0 -136
- package/src/client.ts +0 -143
- package/src/compression.ts +0 -116
- package/src/config.ts +0 -35
- package/src/cors.ts +0 -94
- package/src/csp.ts +0 -226
- package/src/entry-server.ts +0 -224
- package/src/env.ts +0 -344
- package/src/error-overlay.ts +0 -118
- package/src/favicon.ts +0 -841
- package/src/font.ts +0 -511
- package/src/fs-router.ts +0 -1519
- package/src/i18n-routing.ts +0 -533
- package/src/icon.tsx +0 -182
- package/src/icons-plugin.ts +0 -296
- package/src/image-plugin.ts +0 -751
- package/src/image-types.ts +0 -60
- package/src/image.tsx +0 -340
- package/src/index.ts +0 -92
- package/src/isr.ts +0 -394
- package/src/link.tsx +0 -304
- package/src/logger.ts +0 -144
- package/src/manifest.ts +0 -787
- package/src/meta.tsx +0 -354
- package/src/middleware.ts +0 -65
- package/src/not-found.ts +0 -44
- package/src/og-image.ts +0 -378
- package/src/rate-limit.ts +0 -140
- package/src/script.tsx +0 -260
- package/src/seo.ts +0 -617
- package/src/server.ts +0 -89
- package/src/sharp.d.ts +0 -22
- package/src/ssg-plugin.ts +0 -1582
- package/src/testing.ts +0 -146
- package/src/theme.tsx +0 -257
- package/src/types.ts +0 -624
- package/src/utils/use-intersection-observer.ts +0 -36
- package/src/utils/with-headers.ts +0 -13
- package/src/vercel-revalidate-handler.ts +0 -204
- 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, '&')
|
|
312
|
-
.replace(/</g, '<')
|
|
313
|
-
.replace(/>/g, '>')
|
|
314
|
-
.replace(/"/g, '"')
|
|
315
|
-
.replace(/'/g, ''')
|
|
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
|
-
}
|