@pyreon/zero 0.15.0 → 0.18.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 +307 -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 +666 -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 +607 -72
  22. package/lib/vite-plugin-y0NmCLJA.js +2476 -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 +333 -10
  50. package/src/vercel-revalidate-handler.ts +204 -0
  51. package/src/vite-plugin.ts +171 -41
  52. 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
- return `<?xml version="1.0" encoding="UTF-8"?>
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 ?? []].map((entry) => {
23
- return ` <url>
24
- <loc>${escapeXml(`${origin}${entry.path === "/" ? "" : entry.path}`)}</loc>
25
- <changefreq>${entry.changefreq ?? changefreq}</changefreq>
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
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: { origin: "https://example.com" },
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 sitemap = generateSitemap(await scanRouteFiles(routesDir), config.sitemap);
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