@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.
- package/lib/{api-routes-DANluJic.js → api-routes-Ci0kVmM4.js} +2 -2
- package/lib/client.js +4 -1
- package/lib/env.js +6 -6
- package/lib/font.js +3 -3
- package/lib/{fs-router-ZebyutPa.js → fs-router-MewHc5SB.js} +25 -30
- package/lib/i18n-routing.js +112 -1
- package/lib/image.js +140 -58
- package/lib/index.js +252 -82
- package/lib/og-image.js +5 -5
- package/lib/rolldown-runtime-CjeV3_4I.js +18 -0
- package/lib/script.js +114 -25
- package/lib/seo.js +186 -15
- package/lib/server.js +274 -564
- package/lib/types/config.d.ts +275 -3
- package/lib/types/env.d.ts +2 -2
- package/lib/types/i18n-routing.d.ts +193 -2
- package/lib/types/image.d.ts +105 -5
- package/lib/types/index.d.ts +634 -182
- package/lib/types/script.d.ts +78 -6
- package/lib/types/seo.d.ts +128 -4
- package/lib/types/server.d.ts +575 -72
- package/lib/vite-plugin-xjWZwudX.js +2454 -0
- package/package.json +11 -10
- package/src/adapters/bun.ts +20 -1
- package/src/adapters/cloudflare.ts +78 -1
- package/src/adapters/index.ts +25 -3
- package/src/adapters/netlify.ts +63 -1
- package/src/adapters/node.ts +25 -1
- package/src/adapters/static.ts +26 -1
- package/src/adapters/validate.ts +8 -1
- package/src/adapters/vercel.ts +76 -1
- package/src/adapters/warn-missing-env.ts +49 -0
- package/src/app.ts +14 -0
- package/src/client.ts +18 -0
- package/src/entry-server.ts +55 -5
- package/src/env.ts +7 -7
- package/src/font.ts +3 -3
- package/src/fs-router.ts +72 -3
- package/src/i18n-routing.ts +246 -12
- package/src/image.tsx +242 -91
- package/src/index.ts +4 -4
- package/src/isr.ts +24 -6
- package/src/manifest.ts +675 -0
- package/src/og-image.ts +5 -5
- package/src/script.tsx +159 -36
- package/src/seo.ts +346 -15
- package/src/server.ts +10 -2
- package/src/ssg-plugin.ts +1211 -54
- package/src/types.ts +301 -10
- package/src/vercel-revalidate-handler.ts +204 -0
- package/src/vite-plugin.ts +108 -30
- package/lib/vite-plugin-E4BHYvYW.js +0 -855
package/lib/seo.js
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
|
|
1
5
|
//#region src/seo.ts
|
|
2
6
|
/**
|
|
3
7
|
* Generate a sitemap.xml string from route file paths.
|
|
8
|
+
*
|
|
9
|
+
* When `i18n` is set (PR K — passed by `seoPlugin` after reading the
|
|
10
|
+
* i18n config from `zero({ i18n: ... })`), URLs are clustered by their
|
|
11
|
+
* un-prefixed (default-locale) form and each `<url>` carries
|
|
12
|
+
* `<xhtml:link rel="alternate" hreflang="...">` siblings for every
|
|
13
|
+
* locale variant + an `x-default` entry pointing at the default locale.
|
|
4
14
|
*/
|
|
5
|
-
function generateSitemap(routeFiles, config) {
|
|
15
|
+
function generateSitemap(routeFiles, config, i18n) {
|
|
6
16
|
const { origin, exclude = [], changefreq = "weekly", priority = .7 } = config;
|
|
7
|
-
|
|
8
|
-
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
9
|
-
${[...routeFiles.filter((f) => {
|
|
17
|
+
const clusters = clusterPathsByLocale([...routeFiles.filter((f) => {
|
|
10
18
|
const name = f.split("/").pop()?.replace(/\.\w+$/, "");
|
|
11
19
|
return name !== "_layout" && name !== "_error" && name !== "_loading";
|
|
12
20
|
}).map((f) => {
|
|
@@ -19,19 +27,141 @@ ${[...routeFiles.filter((f) => {
|
|
|
19
27
|
path: p,
|
|
20
28
|
changefreq,
|
|
21
29
|
priority
|
|
22
|
-
})), ...config.additionalPaths ?? []]
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
<priority>${entry.priority ?? priority}</priority>${entry.lastmod ? `\n <lastmod>${entry.lastmod}</lastmod>` : ""}
|
|
27
|
-
</url>`;
|
|
28
|
-
}).join("\n")}
|
|
30
|
+
})), ...config.additionalPaths ?? []], i18n);
|
|
31
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
32
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"${i18n != null && i18n.locales.length > 0 ? " xmlns:xhtml=\"http://www.w3.org/1999/xhtml\"" : ""}>
|
|
33
|
+
${clusters.map((cluster) => renderClusterEntry(cluster, origin, changefreq, priority, i18n)).join("\n")}
|
|
29
34
|
</urlset>`;
|
|
30
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Cluster URL entries by their un-prefixed (default-locale) form.
|
|
38
|
+
*
|
|
39
|
+
* Each output cluster has:
|
|
40
|
+
* - `canonical`: the SitemapEntry that should be used as the `<url>`
|
|
41
|
+
* payload (default-locale variant; falls back to the first variant
|
|
42
|
+
* if no default-locale entry exists in the cluster).
|
|
43
|
+
* - `variantsByLocale`: Map of locale → SitemapEntry for the cluster.
|
|
44
|
+
*
|
|
45
|
+
* Without i18n, every entry becomes its own single-variant cluster.
|
|
46
|
+
*
|
|
47
|
+
* @internal — exported for unit testing.
|
|
48
|
+
*/
|
|
49
|
+
function clusterPathsByLocale(entries, i18n) {
|
|
50
|
+
if (i18n == null || i18n.locales.length === 0) return entries.map((entry) => ({
|
|
51
|
+
canonical: entry,
|
|
52
|
+
variantsByLocale: new Map([[null, entry]])
|
|
53
|
+
}));
|
|
54
|
+
const strategy = i18n.strategy ?? "prefix-except-default";
|
|
55
|
+
const { defaultLocale, locales } = i18n;
|
|
56
|
+
const byUnPrefixed = /* @__PURE__ */ new Map();
|
|
57
|
+
for (const entry of entries) {
|
|
58
|
+
const { unPrefixed, locale } = stripLocalePrefix(entry.path, locales, defaultLocale, strategy);
|
|
59
|
+
let cluster = byUnPrefixed.get(unPrefixed);
|
|
60
|
+
if (!cluster) {
|
|
61
|
+
cluster = /* @__PURE__ */ new Map();
|
|
62
|
+
byUnPrefixed.set(unPrefixed, cluster);
|
|
63
|
+
}
|
|
64
|
+
cluster.set(locale, entry);
|
|
65
|
+
}
|
|
66
|
+
const out = [];
|
|
67
|
+
for (const variantsByLocale of byUnPrefixed.values()) {
|
|
68
|
+
const canonical = variantsByLocale.get(defaultLocale) ?? variantsByLocale.get(null) ?? [...variantsByLocale.values()][0];
|
|
69
|
+
out.push({
|
|
70
|
+
canonical,
|
|
71
|
+
variantsByLocale
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Strip the locale prefix from a path under the i18n strategy.
|
|
78
|
+
*
|
|
79
|
+
* Returns `{ unPrefixed, locale }`:
|
|
80
|
+
* - `/about` under `prefix-except-default` (default=en) → `{ unPrefixed: '/about', locale: 'en' }`
|
|
81
|
+
* - `/de/about` under either strategy → `{ unPrefixed: '/about', locale: 'de' }`
|
|
82
|
+
* - `/de` (locale root) → `{ unPrefixed: '/', locale: 'de' }`
|
|
83
|
+
* - `/about` under `prefix` → no locale match, returns `{ unPrefixed: '/about', locale: null }`
|
|
84
|
+
* (the URL doesn't fit any locale subtree — sitemap treats it as standalone).
|
|
85
|
+
*
|
|
86
|
+
* @internal — exported for unit testing.
|
|
87
|
+
*/
|
|
88
|
+
function stripLocalePrefix(path, locales, defaultLocale, strategy) {
|
|
89
|
+
for (const locale of locales) {
|
|
90
|
+
if (path === `/${locale}`) return {
|
|
91
|
+
unPrefixed: "/",
|
|
92
|
+
locale
|
|
93
|
+
};
|
|
94
|
+
if (path.startsWith(`/${locale}/`)) return {
|
|
95
|
+
unPrefixed: path.slice(`/${locale}`.length),
|
|
96
|
+
locale
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
if (strategy === "prefix-except-default") return {
|
|
100
|
+
unPrefixed: path,
|
|
101
|
+
locale: defaultLocale
|
|
102
|
+
};
|
|
103
|
+
return {
|
|
104
|
+
unPrefixed: path,
|
|
105
|
+
locale: null
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
function renderClusterEntry(cluster, origin, changefreq, priority, i18n) {
|
|
109
|
+
const { canonical, variantsByLocale } = cluster;
|
|
110
|
+
const lines = [
|
|
111
|
+
" <url>",
|
|
112
|
+
` <loc>${escapeXml(`${origin}${canonical.path === "/" ? "" : canonical.path}`)}</loc>`,
|
|
113
|
+
` <changefreq>${canonical.changefreq ?? changefreq}</changefreq>`,
|
|
114
|
+
` <priority>${canonical.priority ?? priority}</priority>`
|
|
115
|
+
];
|
|
116
|
+
if (canonical.lastmod) lines.push(` <lastmod>${canonical.lastmod}</lastmod>`);
|
|
117
|
+
if (i18n != null && i18n.locales.length > 0 && variantsByLocale.size > 1) {
|
|
118
|
+
for (const locale of i18n.locales) {
|
|
119
|
+
const variant = variantsByLocale.get(locale);
|
|
120
|
+
if (!variant) continue;
|
|
121
|
+
const variantLoc = `${origin}${variant.path === "/" ? "" : variant.path}`;
|
|
122
|
+
lines.push(` <xhtml:link rel="alternate" hreflang="${escapeXml(locale)}" href="${escapeXml(variantLoc)}"/>`);
|
|
123
|
+
}
|
|
124
|
+
const defaultVariant = variantsByLocale.get(i18n.defaultLocale);
|
|
125
|
+
if (defaultVariant) {
|
|
126
|
+
const defaultLoc = `${origin}${defaultVariant.path === "/" ? "" : defaultVariant.path}`;
|
|
127
|
+
lines.push(` <xhtml:link rel="alternate" hreflang="x-default" href="${escapeXml(defaultLoc)}"/>`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
lines.push(" </url>");
|
|
131
|
+
return lines.join("\n");
|
|
132
|
+
}
|
|
31
133
|
function escapeXml(str) {
|
|
32
134
|
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
33
135
|
}
|
|
34
136
|
/**
|
|
137
|
+
* Resolve the i18n config to feed `generateSitemap` for hreflang
|
|
138
|
+
* emission. Priority order:
|
|
139
|
+
* 1. Explicit user config — `hreflang: I18nRoutingConfig` (object)
|
|
140
|
+
* 2. Auto-detect from SSG manifest — `hreflang: true` + `manifestI18n`
|
|
141
|
+
* present (only happens in SSG mode where the manifest exists)
|
|
142
|
+
* 3. Nothing — emit plain sitemap without xhtml:link siblings
|
|
143
|
+
*
|
|
144
|
+
* @internal — exported for unit testing.
|
|
145
|
+
*/
|
|
146
|
+
function resolveHreflangI18n(hreflang, manifestI18n) {
|
|
147
|
+
if (hreflang == null || hreflang === false) return void 0;
|
|
148
|
+
if (hreflang === true) return manifestI18n;
|
|
149
|
+
return hreflang;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Duck-type guard for `I18nRoutingConfig`. The SSG manifest is JSON,
|
|
153
|
+
* so the embedded i18n field could in principle be malformed if a
|
|
154
|
+
* downstream user hand-edits the manifest (don't). Validate the shape
|
|
155
|
+
* before trusting it.
|
|
156
|
+
*
|
|
157
|
+
* @internal
|
|
158
|
+
*/
|
|
159
|
+
function isI18nRoutingConfig(value) {
|
|
160
|
+
if (value == null || typeof value !== "object") return false;
|
|
161
|
+
const v = value;
|
|
162
|
+
return Array.isArray(v.locales) && v.locales.every((l) => typeof l === "string") && typeof v.defaultLocale === "string";
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
35
165
|
* Generate a robots.txt string.
|
|
36
166
|
*/
|
|
37
167
|
function generateRobots(config = {}) {
|
|
@@ -82,22 +212,33 @@ function jsonLd(data) {
|
|
|
82
212
|
* pyreon(),
|
|
83
213
|
* zero(),
|
|
84
214
|
* seoPlugin({
|
|
85
|
-
* sitemap: {
|
|
215
|
+
* sitemap: {
|
|
216
|
+
* origin: "https://example.com",
|
|
217
|
+
* useSsgPaths: true, // include dynamic-route enumerations
|
|
218
|
+
* },
|
|
86
219
|
* robots: { sitemap: "https://example.com/sitemap.xml" },
|
|
87
220
|
* }),
|
|
88
221
|
* ],
|
|
89
222
|
* }
|
|
90
223
|
*/
|
|
91
224
|
function seoPlugin(config = {}) {
|
|
225
|
+
const useSsgPaths = config.sitemap?.useSsgPaths === true;
|
|
226
|
+
let distDir = "";
|
|
92
227
|
return {
|
|
93
228
|
name: "pyreon-zero-seo",
|
|
94
229
|
apply: "build",
|
|
230
|
+
...useSsgPaths ? { enforce: "post" } : {},
|
|
231
|
+
configResolved(resolved) {
|
|
232
|
+
distDir = resolve(resolved.root, resolved.build.outDir);
|
|
233
|
+
},
|
|
95
234
|
async generateBundle(_, _bundle) {
|
|
96
|
-
if (config.sitemap) {
|
|
235
|
+
if (config.sitemap && !useSsgPaths) {
|
|
97
236
|
const { scanRouteFiles } = await import("./fs-router-3xzp-4Wj.js");
|
|
98
237
|
const routesDir = `${process.cwd()}/src/routes`;
|
|
99
238
|
try {
|
|
100
|
-
const
|
|
239
|
+
const files = await scanRouteFiles(routesDir);
|
|
240
|
+
const hreflangI18n = resolveHreflangI18n(config.sitemap.hreflang, void 0);
|
|
241
|
+
const sitemap = generateSitemap(files, config.sitemap, hreflangI18n);
|
|
101
242
|
this.emitFile({
|
|
102
243
|
type: "asset",
|
|
103
244
|
fileName: "sitemap.xml",
|
|
@@ -113,6 +254,36 @@ function seoPlugin(config = {}) {
|
|
|
113
254
|
source: robots
|
|
114
255
|
});
|
|
115
256
|
}
|
|
257
|
+
},
|
|
258
|
+
async closeBundle() {
|
|
259
|
+
if (!config.sitemap || !useSsgPaths) return;
|
|
260
|
+
const { scanRouteFiles } = await import("./fs-router-3xzp-4Wj.js");
|
|
261
|
+
const routesDir = `${process.cwd()}/src/routes`;
|
|
262
|
+
const manifestPath = join(distDir, "_pyreon-ssg-paths.json");
|
|
263
|
+
try {
|
|
264
|
+
let ssgPaths = [];
|
|
265
|
+
let manifestI18n;
|
|
266
|
+
if (existsSync(manifestPath)) {
|
|
267
|
+
const raw = await readFile(manifestPath, "utf-8");
|
|
268
|
+
const parsed = JSON.parse(raw);
|
|
269
|
+
if (Array.isArray(parsed.paths)) ssgPaths = parsed.paths.filter((p) => typeof p === "string").map((path) => ({ path }));
|
|
270
|
+
if (isI18nRoutingConfig(parsed.i18n)) manifestI18n = parsed.i18n;
|
|
271
|
+
try {
|
|
272
|
+
await rm(manifestPath, { force: true });
|
|
273
|
+
} catch {}
|
|
274
|
+
}
|
|
275
|
+
let files = [];
|
|
276
|
+
try {
|
|
277
|
+
files = await scanRouteFiles(routesDir);
|
|
278
|
+
} catch {}
|
|
279
|
+
const merged = {
|
|
280
|
+
...config.sitemap,
|
|
281
|
+
additionalPaths: [...ssgPaths, ...config.sitemap.additionalPaths ?? []]
|
|
282
|
+
};
|
|
283
|
+
const hreflangI18n = resolveHreflangI18n(config.sitemap.hreflang, manifestI18n);
|
|
284
|
+
const sitemap = generateSitemap(files, merged, hreflangI18n);
|
|
285
|
+
await writeFile(join(distDir, "sitemap.xml"), sitemap, "utf-8");
|
|
286
|
+
} catch {}
|
|
116
287
|
}
|
|
117
288
|
};
|
|
118
289
|
}
|
|
@@ -132,5 +303,5 @@ function seoMiddleware(config = {}) {
|
|
|
132
303
|
}
|
|
133
304
|
|
|
134
305
|
//#endregion
|
|
135
|
-
export { generateRobots, generateSitemap, jsonLd, seoMiddleware, seoPlugin };
|
|
306
|
+
export { clusterPathsByLocale, generateRobots, generateSitemap, jsonLd, resolveHreflangI18n, seoMiddleware, seoPlugin, stripLocalePrefix };
|
|
136
307
|
//# sourceMappingURL=seo.js.map
|