@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
@@ -123,6 +123,11 @@ export function createServer(options: CreateServerOptions) {
123
123
  const { App } = createApp({
124
124
  routes: options.routes,
125
125
  routerMode: "history",
126
+ // Forward zero's `base` to createRouter so RouterLinks render
127
+ // correctly prefixed hrefs during SSR — must match the value
128
+ // the client-side `startClient` reads from `__ZERO_BASE__` so
129
+ // hydration doesn't mismatch.
130
+ ...(config.base && config.base !== "/" ? { base: config.base } : {}),
126
131
  });
127
132
 
128
133
  const handler = createHandler({
@@ -134,18 +139,40 @@ export function createServer(options: CreateServerOptions) {
134
139
  ...(options.clientEntry ? { clientEntry: options.clientEntry } : {}),
135
140
  });
136
141
 
137
- // Wrap handler with 404 detection when a notFoundComponent is provided
142
+ // M1.2 Runtime SSR 404 routes through the router (PR L5).
143
+ // When a URL doesn't match any leaf, @pyreon/router's resolveRoute
144
+ // walks up to the closest parent `notFoundComponent` and builds a
145
+ // synthetic chain `[...ancestorLayouts, syntheticLeaf]`. The handler
146
+ // renders that chain, producing 404 HTML INSIDE the layout's chrome,
147
+ // and reads `resolved.isNotFound` to set HTTP status 404. This
148
+ // replaces the pre-M1 URL-pattern wrapper that bypassed the router
149
+ // for unmatched URLs and rendered the not-found component standalone
150
+ // (no layout wrapping).
151
+ //
152
+ // `options.notFoundComponent` is a legacy fallback for apps that
153
+ // don't carry `_404.tsx` in their routes tree. When set AND the
154
+ // routes tree has no reachable `notFoundComponent`, we render the
155
+ // standalone shape as a final fallback. The canonical pattern is
156
+ // `_404.tsx` inside a `_layout.tsx` directory — that goes through
157
+ // PR L5's router-driven path and gets layout chrome for free.
138
158
  if (!options.notFoundComponent) return handler;
139
159
 
140
160
  const NotFound = options.notFoundComponent;
141
- const routePatterns = flattenRoutePatterns(options.routes);
161
+ const hasRouteTreeNotFound = routeTreeHasNotFound(options.routes);
142
162
 
143
163
  return async (req: Request) => {
164
+ // Route-tree notFoundComponent present → handler handles 404 via
165
+ // resolveRoute's `isNotFound` fallback (PR L5). Skip the legacy
166
+ // wrapper entirely — handler.ts sets status 404 + renders layout
167
+ // chrome correctly.
168
+ if (hasRouteTreeNotFound) return handler(req);
169
+
170
+ // Legacy fallback: routes tree has no notFoundComponent but the
171
+ // caller passed `options.notFoundComponent`. Run the URL-pattern
172
+ // check + standalone render for backward compat.
144
173
  const url = new URL(req.url);
145
174
  const pathname = url.pathname;
146
-
147
- // Check if any defined route matches this path
148
- if (!routePatterns.some((pattern) => matchPattern(pattern, pathname))) {
175
+ if (!routePatternsCache(options.routes).some((p) => matchPattern(p, pathname))) {
149
176
  const fullHtml = await render404Page(NotFound, options.template);
150
177
  return new Response(fullHtml, {
151
178
  status: 404,
@@ -157,6 +184,29 @@ export function createServer(options: CreateServerOptions) {
157
184
  };
158
185
  }
159
186
 
187
+ /** Walk the route tree looking for any record with a `notFoundComponent`. */
188
+ function routeTreeHasNotFound(routes: RouteRecord[]): boolean {
189
+ for (const r of routes) {
190
+ if (typeof (r as { notFoundComponent?: unknown }).notFoundComponent === "function") {
191
+ return true;
192
+ }
193
+ if (r.children && routeTreeHasNotFound(r.children as RouteRecord[])) {
194
+ return true;
195
+ }
196
+ }
197
+ return false;
198
+ }
199
+
200
+ /** Lazy cache of flattened patterns — only computed if legacy fallback fires. */
201
+ const _routePatternsCache = new WeakMap<RouteRecord[], string[]>();
202
+ function routePatternsCache(routes: RouteRecord[]): string[] {
203
+ const cached = _routePatternsCache.get(routes);
204
+ if (cached) return cached;
205
+ const out = flattenRoutePatterns(routes);
206
+ _routePatternsCache.set(routes, out);
207
+ return out;
208
+ }
209
+
160
210
  /** Extract all URL patterns from a nested route tree. */
161
211
  function flattenRoutePatterns(routes: RouteRecord[], prefix = ""): string[] {
162
212
  const patterns: string[] = [];
package/src/env.ts CHANGED
@@ -244,14 +244,14 @@ type InferEnvSchema<T> = {
244
244
  * ```
245
245
  */
246
246
  export function validateEnv<T extends Record<string, SchemaEntry>>(
247
- schema: T,
247
+ envSchema: T,
248
248
  source?: Record<string, string | undefined>,
249
249
  ): InferEnvSchema<T> {
250
250
  const env = source ?? (typeof process !== 'undefined' ? process.env : {})
251
251
  const result: Record<string, unknown> = {}
252
252
  const errors: string[] = []
253
253
 
254
- for (const [key, entry] of Object.entries(schema)) {
254
+ for (const [key, entry] of Object.entries(envSchema)) {
255
255
  const validator = toValidator(entry)
256
256
  try {
257
257
  result[key] = validator.parse(env[key], key)
@@ -284,12 +284,12 @@ export function validateEnv<T extends Record<string, SchemaEntry>>(
284
284
  * ```
285
285
  */
286
286
  export function publicEnv(): Record<string, string>
287
- export function publicEnv<T extends Record<string, SchemaEntry>>(schema: T): InferEnvSchema<T>
288
- export function publicEnv(schema?: Record<string, SchemaEntry>): Record<string, unknown> {
287
+ export function publicEnv<T extends Record<string, SchemaEntry>>(envSchema: T): InferEnvSchema<T>
288
+ export function publicEnv(envSchema?: Record<string, SchemaEntry>): Record<string, unknown> {
289
289
  const prefix = 'ZERO_PUBLIC_'
290
290
  const env = typeof process !== 'undefined' ? process.env : {}
291
291
 
292
- if (!schema) {
292
+ if (!envSchema) {
293
293
  const result: Record<string, string> = {}
294
294
  for (const [key, value] of Object.entries(env)) {
295
295
  if (key.startsWith(prefix) && value !== undefined) {
@@ -300,10 +300,10 @@ export function publicEnv(schema?: Record<string, SchemaEntry>): Record<string,
300
300
  }
301
301
 
302
302
  const prefixedSource: Record<string, string | undefined> = {}
303
- for (const key of Object.keys(schema)) {
303
+ for (const key of Object.keys(envSchema)) {
304
304
  prefixedSource[key] = env[`${prefix}${key}`]
305
305
  }
306
- return validateEnv(schema, prefixedSource)
306
+ return validateEnv(envSchema, prefixedSource)
307
307
  }
308
308
 
309
309
  // ─── Custom validator escape hatch ──────────────────────────────────────────
package/src/font.ts CHANGED
@@ -159,11 +159,11 @@ export function parseGoogleFamily(input: string): ResolvedFont {
159
159
  for (const entry of entries) {
160
160
  if (entry.includes(',')) {
161
161
  // Tuple format: "0,300" or "1,500" — last value is the weight
162
- const parts = entry.split(',')
163
- const weight = Number(parts[parts.length - 1])
162
+ const tuple = entry.split(',')
163
+ const weight = Number(tuple[tuple.length - 1])
164
164
  if (weight > 0) weights.add(weight)
165
165
  // Detect italic from tuple: "1,xxx" means italic
166
- if (parts[0] === '1') italic = true
166
+ if (tuple[0] === '1') italic = true
167
167
  } else if (entry.includes('..')) {
168
168
  // Variable range already handled above — skip
169
169
  } else {
package/src/fs-router.ts CHANGED
@@ -2,6 +2,34 @@ import { readFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import type { FileRoute, RenderMode, RouteFileExports } from './types'
4
4
 
5
+ /**
6
+ * Return type of a route file's `getStaticPaths()` export. Each entry
7
+ * supplies one set of concrete values for the route's dynamic segments;
8
+ * the SSG plugin expands the route's URL pattern with these params and
9
+ * renders one HTML file per entry.
10
+ *
11
+ * @example
12
+ * ```tsx
13
+ * // src/routes/posts/[id].tsx
14
+ * import type { GetStaticPaths } from '@pyreon/zero/server'
15
+ *
16
+ * export const getStaticPaths: GetStaticPaths<{ id: string }> = async () => {
17
+ * const posts = await fetch('https://api.example.com/posts').then(r => r.json())
18
+ * return posts.map((p) => ({ params: { id: p.slug } }))
19
+ * }
20
+ *
21
+ * export default function Post() { ... }
22
+ * ```
23
+ *
24
+ * For catch-all routes (`/blog/[...slug].tsx`), pass the full path through
25
+ * the catch-all param: `{ params: { slug: 'a/b' } }` → `/blog/a/b`.
26
+ */
27
+ export type GetStaticPaths<
28
+ TParams extends Record<string, string> = Record<string, string>,
29
+ > = () =>
30
+ | Array<{ params: TParams }>
31
+ | Promise<Array<{ params: TParams }>>
32
+
5
33
  // ─── File-system route conventions ──────────────────────────────────────────
6
34
  //
7
35
  // src/routes/
@@ -42,6 +70,8 @@ const ROUTE_EXPORT_NAMES = [
42
70
  'middleware',
43
71
  'loaderKey',
44
72
  'gcTime',
73
+ 'getStaticPaths',
74
+ 'revalidate',
45
75
  ] as const
46
76
 
47
77
  type RouteExportName = (typeof ROUTE_EXPORT_NAMES)[number]
@@ -102,12 +132,27 @@ export function detectRouteExports(source: string): RouteFileExports {
102
132
  const rawRenderMode = found.has('renderMode')
103
133
  ? extractLiteralExport(source, 'renderMode')
104
134
  : undefined
135
+ // PR I — capture `revalidate` as a literal so the build-time ISR
136
+ // manifest (`dist/_pyreon-revalidate.json`) can be emitted from the
137
+ // SSG plugin without loading the route module. The route generator
138
+ // does NOT inline `revalidate` into the route record — it's a build-
139
+ // time-only concern that adapters consume via the manifest.
140
+ const rawRevalidate = found.has('revalidate')
141
+ ? extractLiteralExport(source, 'revalidate')
142
+ : undefined
105
143
  const cleanMeta = rawMeta !== undefined ? stripTypeAssertions(rawMeta) : undefined
106
144
  const cleanRenderMode =
107
145
  rawRenderMode !== undefined ? stripTypeAssertions(rawRenderMode) : undefined
146
+ const cleanRevalidate =
147
+ rawRevalidate !== undefined ? stripTypeAssertions(rawRevalidate) : undefined
108
148
  const metaLiteral = cleanMeta !== undefined && isPureLiteral(cleanMeta) ? cleanMeta : undefined
109
149
  const renderModeLiteral =
110
150
  cleanRenderMode !== undefined && isPureLiteral(cleanRenderMode) ? cleanRenderMode : undefined
151
+ // `revalidate` literals are number (`60`) or boolean (`false`) — never
152
+ // an object/array — so `isPureLiteral` is overkill. Keep the same
153
+ // safety check for defense-in-depth.
154
+ const revalidateLiteral =
155
+ cleanRevalidate !== undefined && isPureLiteral(cleanRevalidate) ? cleanRevalidate : undefined
111
156
 
112
157
  return {
113
158
  hasLoader: found.has('loader'),
@@ -118,8 +163,11 @@ export function detectRouteExports(source: string): RouteFileExports {
118
163
  hasMiddleware: found.has('middleware'),
119
164
  hasLoaderKey: found.has('loaderKey'),
120
165
  hasGcTime: found.has('gcTime'),
166
+ hasGetStaticPaths: found.has('getStaticPaths'),
167
+ hasRevalidate: found.has('revalidate'),
121
168
  ...(metaLiteral !== undefined ? { metaLiteral } : {}),
122
169
  ...(renderModeLiteral !== undefined ? { renderModeLiteral } : {}),
170
+ ...(revalidateLiteral !== undefined ? { revalidateLiteral } : {}),
123
171
  }
124
172
  }
125
173
 
@@ -751,6 +799,8 @@ const EMPTY_EXPORTS: RouteFileExports = {
751
799
  hasMiddleware: false,
752
800
  hasLoaderKey: false,
753
801
  hasGcTime: false,
802
+ hasGetStaticPaths: false,
803
+ hasRevalidate: false,
754
804
  }
755
805
 
756
806
  /**
@@ -767,7 +817,8 @@ export function hasAnyMetaExport(exports: RouteFileExports): boolean {
767
817
  exports.hasError ||
768
818
  exports.hasMiddleware ||
769
819
  exports.hasLoaderKey ||
770
- exports.hasGcTime
820
+ exports.hasGcTime ||
821
+ exports.hasGetStaticPaths
771
822
  )
772
823
  }
773
824
 
@@ -1095,6 +1146,8 @@ export function generateRouteModuleFromRoutes(
1095
1146
  if (exp.hasGuard) props.push(`${indent} beforeEnter: ${mod}.guard`)
1096
1147
  if (exp.hasLoaderKey) props.push(`${indent} loaderKey: ${mod}.loaderKey`)
1097
1148
  if (exp.hasGcTime) props.push(`${indent} gcTime: ${mod}.gcTime`)
1149
+ if (exp.hasGetStaticPaths)
1150
+ props.push(`${indent} getStaticPaths: ${mod}.getStaticPaths`)
1098
1151
  if (exp.hasMeta || exp.hasRenderMode) {
1099
1152
  const metaParts: string[] = []
1100
1153
  if (exp.hasMeta) metaParts.push(`...${mod}.meta`)
@@ -1132,7 +1185,13 @@ export function generateRouteModuleFromRoutes(
1132
1185
  const inlineableMeta =
1133
1186
  (!exp.hasMeta || exp.metaLiteral !== undefined) &&
1134
1187
  (!exp.hasRenderMode || exp.renderModeLiteral !== undefined)
1135
- const needsFunctionExports = exp.hasLoader || exp.hasGuard || exp.hasError
1188
+ // getStaticPaths is a build-time export consumed by the SSG plugin's
1189
+ // path-resolution phase. Like loader/guard/error, it can't be inlined
1190
+ // as a literal — we need the actual function reference. Force the
1191
+ // generator into the mixed branch (case 2) when present so a namespace
1192
+ // import is emitted and `mod.getStaticPaths` lands on the route record.
1193
+ const needsFunctionExports =
1194
+ exp.hasLoader || exp.hasGuard || exp.hasError || exp.hasGetStaticPaths
1136
1195
 
1137
1196
  if (hasMeta && inlineableMeta && !needsFunctionExports) {
1138
1197
  // Optimal path — component lazy, metadata inlined.
@@ -1174,6 +1233,15 @@ export function generateRouteModuleFromRoutes(
1174
1233
  const mod = nextModuleImport(page.filePath)
1175
1234
  props.push(`${indent} gcTime: ${mod}.gcTime`)
1176
1235
  }
1236
+ if (exp.hasGetStaticPaths) {
1237
+ // getStaticPaths runs at SSG build time (not request time), so
1238
+ // routing it through a dynamic import is fine — but going through
1239
+ // a namespace import keeps it consistent with loaderKey/gcTime
1240
+ // and avoids per-call import overhead during the SSG enumeration
1241
+ // phase.
1242
+ const mod = nextModuleImport(page.filePath)
1243
+ props.push(`${indent} getStaticPaths: ${mod}.getStaticPaths`)
1244
+ }
1177
1245
  emitInlineMeta(exp, props, indent)
1178
1246
  if (errorName) {
1179
1247
  // For error components we can't easily await — pass the lazy
@@ -1195,6 +1263,8 @@ export function generateRouteModuleFromRoutes(
1195
1263
  if (exp.hasGuard) props.push(`${indent} beforeEnter: ${mod}.guard`)
1196
1264
  if (exp.hasLoaderKey) props.push(`${indent} loaderKey: ${mod}.loaderKey`)
1197
1265
  if (exp.hasGcTime) props.push(`${indent} gcTime: ${mod}.gcTime`)
1266
+ if (exp.hasGetStaticPaths)
1267
+ props.push(`${indent} getStaticPaths: ${mod}.getStaticPaths`)
1198
1268
  if (exp.hasMeta || exp.hasRenderMode) {
1199
1269
  const metaParts: string[] = []
1200
1270
  if (exp.hasMeta) metaParts.push(`...${mod}.meta`)
@@ -1341,7 +1411,6 @@ export function generateRouteModuleFromRoutes(
1341
1411
  * skipping no-middleware files keeps both paths working.
1342
1412
  */
1343
1413
  export function generateMiddlewareModule(files: string[], routesDir: string): string {
1344
- const { readFileSync } = require('node:fs') as typeof import('node:fs')
1345
1414
  const routes = parseFileRoutes(files)
1346
1415
  const imports: string[] = []
1347
1416
  const entries: string[] = []
@@ -1,19 +1,39 @@
1
1
  import { createContext } from '@pyreon/core'
2
2
  import { signal } from '@pyreon/reactivity'
3
3
  import type { Plugin } from 'vite'
4
+ import type { FileRoute } from './types'
4
5
 
5
6
  // ─── Localized routing ─────────────────────────────────────────────────────
6
7
  //
7
- // Adds locale-prefixed routes to Zero's file-system router:
8
- // - /about /en/about, /de/about, /cs/about
9
- // - / → /en, /de, /cs (or default locale without prefix)
10
- // - Automatic locale detection from Accept-Language header
11
- // - Redirect to preferred locale
12
- // - hreflang link generation
8
+ // Adds locale-prefixed routes to Zero's file-system router (PR H of the SSG
9
+ // roadmap). Two complementary halves:
10
+ //
11
+ // 1. **Build-time route duplication** `expandRoutesForLocales(routes, config)`
12
+ // fans every `FileRoute` into per-locale variants according to the
13
+ // configured `strategy`. Called from `vite-plugin.ts`'s virtual-routes
14
+ // load AND `ssg-plugin.ts`'s pre-render path expansion. Wired via the
15
+ // `i18n?: I18nRoutingConfig` field on `ZeroConfig`.
16
+ //
17
+ // 2. **Request-time locale detection** — the `i18nRouting()` Vite plugin
18
+ // below attaches a middleware that reads `Accept-Language` / cookies,
19
+ // sets the `localeSignal` for `useLocale()`, and redirects root
20
+ // requests to the detected locale. Independent from (1) — `i18nRouting()`
21
+ // only handles middleware; route duplication happens via
22
+ // `expandRoutesForLocales` regardless of whether this plugin is mounted.
23
+ //
24
+ // Examples (with `locales: ["en","de","cs"]`, `defaultLocale: "en"`):
25
+ // - `prefix-except-default` (default): `/about` (en, unprefixed) +
26
+ // `/de/about`, `/cs/about`. Best for SEO-on-default-locale apps.
27
+ // - `prefix`: `/en/about`, `/de/about`, `/cs/about`. Every URL
28
+ // self-identifies its locale.
13
29
  //
14
30
  // Usage:
15
- // import { i18nRouting } from "@pyreon/zero"
16
- // export default { plugins: [Pyreon], defaultLocale: "en" })] }
31
+ // // zero.config.ts
32
+ // import { defineConfig, i18nRouting } from "@pyreon/zero"
33
+ // export default defineConfig({
34
+ // i18n: { locales: ["en","de","cs"], defaultLocale: "en" },
35
+ // plugins: [i18nRouting({ locales: ["en","de","cs"], defaultLocale: "en" })],
36
+ // })
17
37
 
18
38
  export interface I18nRoutingConfig {
19
39
  /** Supported locales. e.g. ["en", "de", "cs"] */
@@ -108,6 +128,218 @@ export function buildLocalePath(
108
128
  return `/${locale}${clean}`
109
129
  }
110
130
 
131
+ /**
132
+ * Fan a `FileRoute[]` into per-locale duplicates so the file-system router
133
+ * knows about every localized URL pattern at build time. PR H — was the
134
+ * missing half of the i18n story before this PR (the `i18nRouting()` Vite
135
+ * plugin only handled request-time locale detection; routes themselves
136
+ * were never duplicated, so static-host SSG outputs and SSR matching had
137
+ * no `/de/about` / `/cs/about` records to render against).
138
+ *
139
+ * Strategy semantics:
140
+ *
141
+ * - **`prefix-except-default`** (default): the default locale's routes
142
+ * keep their original `urlPath` unchanged (`/about` stays `/about`); all
143
+ * non-default locales get a prefix (`/de/about`, `/cs/about`). Best for
144
+ * SEO-on-default-locale apps — search engines see canonical URLs at
145
+ * `/about` while non-default speakers get explicit prefixes.
146
+ *
147
+ * - **`prefix`**: every locale gets its own prefix, including the default
148
+ * (`/en/about`, `/de/about`, `/cs/about`). Root `/` becomes `/en` /
149
+ * `/de` / `/cs`. Better when no locale is "primary" — every URL
150
+ * self-identifies its locale.
151
+ *
152
+ * Layouts, error boundaries, loading components, and 404 pages duplicate
153
+ * along with their pages — same source file (same `filePath`), new
154
+ * locale-prefixed `urlPath` / `dirPath` / `depth`. The route tree built
155
+ * from the expanded array therefore has one fully-formed subtree per
156
+ * locale, so layout matching, dynamic params (`[id]` → `:id`), and
157
+ * catch-all routes (`[...slug]` → `:slug*`) all compose naturally with
158
+ * the locale prefix — no special cases.
159
+ *
160
+ * `getStaticPaths` composition (for SSG): each duplicate route inherits
161
+ * the same `exports.getStaticPaths`. The SSG plugin's `expandUrlPattern`
162
+ * step then expands `/blog/[slug]` × `[en, de]` × `getStaticPaths()
163
+ * → ['a', 'b']` into `/blog/a`, `/blog/b`, `/de/blog/a`, `/de/blog/b`
164
+ * (or all six prefixed forms under `'prefix'` strategy). Cardinality
165
+ * compounds, which is by design — `ssg.concurrency` (PR D) limits
166
+ * in-flight renders independent of route count.
167
+ *
168
+ * No-op when `config.locales` is empty or contains only the default
169
+ * locale (prefix-except-default strategy with no other locales) — returns
170
+ * the input array unchanged. Always return a fresh array on duplication
171
+ * so callers don't accidentally mutate cached input.
172
+ *
173
+ * Reference: the helper is called from `vite-plugin.ts`'s virtual route
174
+ * module load AND `ssg-plugin.ts`'s pre-render path expansion. Tested in
175
+ * isolation — duplication is a pure transform on FileRoute[] with no
176
+ * filesystem or network side effects.
177
+ */
178
+ export function expandRoutesForLocales(
179
+ routes: FileRoute[],
180
+ config: I18nRoutingConfig,
181
+ ): FileRoute[] {
182
+ const strategy = config.strategy ?? 'prefix-except-default'
183
+ const { locales, defaultLocale } = config
184
+
185
+ // Cheap no-op guards. Empty `locales` would otherwise produce an empty
186
+ // route array, killing the app silently.
187
+ if (locales.length === 0) return routes
188
+
189
+ // PR L2 — Validate every locale string before they reach the filesystem.
190
+ // The locales drive both URL pattern emission (`/${locale}/...`) AND
191
+ // filesystem writes (`mkdir(dist/${locale})` in ssg-plugin.ts's per-
192
+ // locale 404 emit). User-supplied input with `/`, `..`, `\`, NUL, or
193
+ // leading dots could write outside dist OR produce broken URLs.
194
+ // Validate at the single entry point so every downstream consumer
195
+ // (vite-plugin's virtual-routes load AND ssg-plugin's path expansion)
196
+ // benefits from one check.
197
+ //
198
+ // Reject:
199
+ // - empty string (kills the app silently with no usable URLs)
200
+ // - leading/trailing whitespace (URL-malformed)
201
+ // - `/` or `\` (path traversal AND structurally invalid as a URL
202
+ // segment — `/de/sub/about` would split into nested directories)
203
+ // - `..` or `.` whole-string (path traversal)
204
+ // - NUL char (system-call boundary breaks)
205
+ // - leading `.` (hidden directory; macOS/Linux dotfile pattern that
206
+ // would create `dist/.locale/` invisible to most ls outputs)
207
+ //
208
+ // Runs AFTER the empty-locales no-op guard so apps temporarily
209
+ // toggling to `i18n: { locales: [], ... }` (mid-migration shape)
210
+ // don't trip on an unused defaultLocale.
211
+ for (const locale of locales) validateLocale(locale)
212
+ validateLocale(defaultLocale)
213
+ if (
214
+ strategy === 'prefix-except-default'
215
+ && locales.length === 1
216
+ && locales[0] === defaultLocale
217
+ ) {
218
+ return routes
219
+ }
220
+
221
+ const expanded: FileRoute[] = []
222
+ for (const route of routes) {
223
+ for (const locale of locales) {
224
+ // For prefix-except-default, the default locale uses the ORIGINAL
225
+ // urlPath / dirPath / depth — no prefix applied.
226
+ if (strategy === 'prefix-except-default' && locale === defaultLocale) {
227
+ expanded.push(route)
228
+ continue
229
+ }
230
+
231
+ // PR H follow-up: skip duplication of ROOT-level layouts under
232
+ // `prefix-except-default`. The unprefixed default-locale root
233
+ // `_layout.tsx` (urlPath `/`) is the parent of the matched chain
234
+ // for EVERY path, including locale-prefixed ones — the route
235
+ // tree's hierarchical matching wraps `/de/about` under `/_layout`
236
+ // automatically. Producing a duplicate `/de/_layout` would cause
237
+ // the matcher to nest BOTH layouts (`/_layout` → `/de/_layout` →
238
+ // page), mounting the layout component twice and rendering two
239
+ // navbars / two PyreonUI providers.
240
+ //
241
+ // Non-root layouts (e.g. `/dashboard/_layout` at urlPath
242
+ // `/dashboard`) MUST still be duplicated — `/de/dashboard/users`
243
+ // is NOT a child of the unprefixed `/dashboard/_layout` (the
244
+ // path patterns don't match), so the de-prefixed dashboard needs
245
+ // its own `_layout`.
246
+ //
247
+ // Under `prefix` strategy this skip does NOT apply: there is no
248
+ // unprefixed default to inherit from, so every locale needs its
249
+ // own root layout (`/en/_layout`, `/de/_layout`, …).
250
+ if (
251
+ strategy === 'prefix-except-default'
252
+ && route.isLayout
253
+ && route.urlPath === '/'
254
+ ) {
255
+ continue
256
+ }
257
+
258
+ const newUrlPath = prefixUrlPath(route.urlPath, locale)
259
+ // dirPath needs the locale segment too so the route-tree builder
260
+ // groups localized siblings correctly. Original empty `dirPath`
261
+ // (root-level routes) becomes the bare locale.
262
+ const newDirPath = route.dirPath === '' ? locale : `${locale}/${route.dirPath}`
263
+ // Recompute depth from the new urlPath. Layouts at the root (depth
264
+ // 0) become depth 1 under their locale prefix; nested routes shift
265
+ // up by 1.
266
+ const newDepth = newUrlPath === '/' ? 0 : newUrlPath.split('/').filter(Boolean).length
267
+
268
+ expanded.push({
269
+ ...route,
270
+ urlPath: newUrlPath,
271
+ dirPath: newDirPath,
272
+ depth: newDepth,
273
+ })
274
+ }
275
+ }
276
+ return expanded
277
+ }
278
+
279
+ /**
280
+ * Prepend `/locale` to a URL pattern. Handles three shapes:
281
+ * `/` → `/de`
282
+ * `/about` → `/de/about`
283
+ * `/users/:id` / `/blog/:slug*` → `/de/users/:id` / `/de/blog/:slug*`
284
+ *
285
+ * Internal helper to `expandRoutesForLocales`; not exported because the
286
+ * public surface for path-building is `buildLocalePath` (which strips
287
+ * existing locale prefixes — different semantics).
288
+ */
289
+ function prefixUrlPath(urlPath: string, locale: string): string {
290
+ if (urlPath === '/') return `/${locale}`
291
+ return `/${locale}${urlPath}`
292
+ }
293
+
294
+ /**
295
+ * Validate a locale string (PR L2).
296
+ *
297
+ * The locale drives both URL pattern emission AND filesystem writes
298
+ * (see `expandRoutesForLocales` for full rationale). Reject input that
299
+ * would either:
300
+ * - break path-traversal boundaries (`..`, `/`, `\`)
301
+ * - produce invalid URL segments (whitespace, NUL)
302
+ * - create hidden-file artifacts (`.` leading)
303
+ * - silently kill the app (empty string)
304
+ *
305
+ * Throws with an actionable `[Pyreon]` error message. Called per-locale
306
+ * by `expandRoutesForLocales` after the empty-locales no-op guard.
307
+ *
308
+ * @internal — exported for unit testing.
309
+ */
310
+ export function validateLocale(locale: string): void {
311
+ if (typeof locale !== 'string' || locale === '') {
312
+ throw new Error(
313
+ `[Pyreon] Invalid i18n locale: ${JSON.stringify(locale)}. Locales must be non-empty strings (e.g. "en", "de", "en-US").`,
314
+ )
315
+ }
316
+ if (locale.trim() !== locale) {
317
+ throw new Error(
318
+ `[Pyreon] Invalid i18n locale: ${JSON.stringify(locale)}. Leading or trailing whitespace not allowed.`,
319
+ )
320
+ }
321
+ if (locale.includes('/') || locale.includes('\\')) {
322
+ throw new Error(
323
+ `[Pyreon] Invalid i18n locale: ${JSON.stringify(locale)}. Path separators ("/", "\\\\") not allowed — they would break URL emission and could write outside the dist directory.`,
324
+ )
325
+ }
326
+ if (locale === '..' || locale === '.') {
327
+ throw new Error(
328
+ `[Pyreon] Invalid i18n locale: ${JSON.stringify(locale)}. Path-traversal segments not allowed.`,
329
+ )
330
+ }
331
+ if (locale.startsWith('.')) {
332
+ throw new Error(
333
+ `[Pyreon] Invalid i18n locale: ${JSON.stringify(locale)}. Leading dot not allowed — it would create a hidden-file directory (\`dist/.${locale.slice(1)}/\`) invisible to most file listings.`,
334
+ )
335
+ }
336
+ if (locale.includes('\0')) {
337
+ throw new Error(
338
+ `[Pyreon] Invalid i18n locale: ${JSON.stringify(locale)}. NUL characters not allowed.`,
339
+ )
340
+ }
341
+ }
342
+
111
343
  /**
112
344
  * Create a LocaleContext for use in components and loaders.
113
345
  */
@@ -176,10 +408,12 @@ export function i18nRouting(config: I18nRoutingConfig): Plugin {
176
408
  return {
177
409
  name: 'pyreon-zero-i18n-routing',
178
410
 
179
- // Route duplication is NOT handled here. The fs-router's `scanRouteFiles`
180
- // consumes the i18n config to duplicate routes per locale at build time.
181
- // This plugin only provides: (1) the server middleware for locale detection
182
- // and (2) the runtime hooks (useLocale, setLocale) for client-side use.
411
+ // Route duplication is NOT handled here. It happens in
412
+ // `vite-plugin.ts` and `ssg-plugin.ts` via `expandRoutesForLocales`,
413
+ // gated by the `i18n` field on `ZeroConfig`. This plugin only
414
+ // provides: (1) the dev server middleware for locale detection
415
+ // (Accept-Language, cookies, root redirect) and (2) the runtime
416
+ // hooks (useLocale, setLocale) for client-side use.
183
417
  configResolved() {},
184
418
 
185
419
  configureServer(server) {