@moku-labs/web 1.5.2 → 1.6.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.
@@ -1550,15 +1550,24 @@ declare function defineRoutes<T extends RouteMap>(routes: T): T;
1550
1550
  * directly: no `app.router` reference, no manual "bind", no module global, no
1551
1551
  * "not bound" guard, and no createApp ↔ routes cycle.
1552
1552
  *
1553
+ * Pass `defaultLocale` so the builder serves that locale at BARE paths, matching the
1554
+ * runtime `router.toUrl` (which reads the default locale from the i18n plugin):
1555
+ * `toUrl("home", { lang: defaultLocale })` then resolves to `/` instead of
1556
+ * `/{defaultLocale}/`. It is app-free, so the default locale cannot be inferred — the
1557
+ * consumer supplies it (e.g. `createUrls(routes, "en")`). Omit it to keep every locale
1558
+ * prefixed.
1559
+ *
1553
1560
  * @param routes - The route map (typically the value returned by {@link defineRoutes}).
1561
+ * @param defaultLocale - The locale to serve bare (its `{lang:?}` prefix is omitted).
1554
1562
  * @returns A {@link Urls} builder whose `toUrl` accepts only this map's route names.
1555
1563
  * @example
1556
1564
  * ```ts
1557
- * const url = createUrls(routes);
1558
- * url.toUrl("article", { lang: "en", slug: "hello" }); // "/en/hello/"
1565
+ * const url = createUrls(routes, "en");
1566
+ * url.toUrl("article", { lang: "en", slug: "hello" }); // "/hello/"
1567
+ * url.toUrl("article", { lang: "ru", slug: "hello" }); // "/ru/hello/"
1559
1568
  * ```
1560
1569
  */
1561
- declare function createUrls<T extends RouteMap>(routes: T): Urls<T>;
1570
+ declare function createUrls<T extends RouteMap>(routes: T, defaultLocale?: string): Urls<T>;
1562
1571
  //#endregion
1563
1572
  //#region src/plugins/router/index.d.ts
1564
1573
  /**
package/dist/browser.mjs CHANGED
@@ -1587,15 +1587,22 @@ function patternToUrlPattern(pattern, variant, langRegex) {
1587
1587
  * param is absent has its segment skipped entirely (no empty segment), so a missing
1588
1588
  * `{lang:?}` collapses cleanly instead of leaving a double slash.
1589
1589
  *
1590
+ * The default locale is served at BARE paths: when `defaultLocale` is given, the
1591
+ * optional `{lang:?}` segment is also skipped for it (so `{ lang: defaultLocale }`
1592
+ * resolves to `/…` while every other locale keeps its `/{locale}/…` prefix).
1593
+ *
1590
1594
  * @param pattern - The route pattern.
1591
1595
  * @param params - Param values to substitute.
1596
+ * @param defaultLocale - The locale served bare (its `{lang:?}` segment is omitted).
1592
1597
  * @returns The resolved relative URL string.
1593
1598
  * @example
1594
1599
  * ```ts
1595
1600
  * buildUrl("/{slug}/", { slug: "hello" }); // "/hello/"
1601
+ * buildUrl("/{lang:?}/", { lang: "en" }, "en"); // "/"
1602
+ * buildUrl("/{lang:?}/", { lang: "ru" }, "en"); // "/ru/"
1596
1603
  * ```
1597
1604
  */
1598
- function buildUrl(pattern, params) {
1605
+ function buildUrl(pattern, params, defaultLocale) {
1599
1606
  const out = [];
1600
1607
  for (const segment of pattern.split("/")) {
1601
1608
  const placeholder = parsePlaceholder(segment);
@@ -1605,6 +1612,7 @@ function buildUrl(pattern, params) {
1605
1612
  }
1606
1613
  const value = params[placeholder.name] ?? "";
1607
1614
  if (placeholder.optional && value === "") continue;
1615
+ if (placeholder.name === "lang" && placeholder.optional && value === defaultLocale) continue;
1608
1616
  out.push(value);
1609
1617
  }
1610
1618
  return out.join("/");
@@ -1614,14 +1622,15 @@ function buildUrl(pattern, params) {
1614
1622
  *
1615
1623
  * @param pattern - The route pattern.
1616
1624
  * @param params - Param values to substitute.
1625
+ * @param defaultLocale - The locale served bare (forwarded to {@link buildUrl}).
1617
1626
  * @returns The output file path, e.g. `hello/index.html`.
1618
1627
  * @example
1619
1628
  * ```ts
1620
1629
  * buildFilePath("/{slug}/", { slug: "hello" });
1621
1630
  * ```
1622
1631
  */
1623
- function buildFilePath(pattern, params) {
1624
- const cleanPath = buildUrl(pattern, params).replace(/^\//, "").replace(/\/$/, "");
1632
+ function buildFilePath(pattern, params, defaultLocale) {
1633
+ const cleanPath = buildUrl(pattern, params, defaultLocale).replace(/^\//, "").replace(/\/$/, "");
1625
1634
  return cleanPath === "" ? "index.html" : `${cleanPath}/index.html`;
1626
1635
  }
1627
1636
  /**
@@ -1650,15 +1659,16 @@ function buildMatchers(pattern, locales) {
1650
1659
  * pattern.
1651
1660
  *
1652
1661
  * @param pattern - The route pattern bound into the closure.
1662
+ * @param defaultLocale - The locale served bare (bound into the closure).
1653
1663
  * @returns A function mapping params to the resolved relative URL.
1654
1664
  * @example
1655
1665
  * ```ts
1656
- * const toUrl = createToUrlFn("/{slug}/");
1666
+ * const toUrl = createToUrlFn("/{slug}/", "en");
1657
1667
  * toUrl({ slug: "x" }); // "/x/"
1658
1668
  * ```
1659
1669
  */
1660
- function createToUrlFunction(pattern) {
1661
- return (params) => buildUrl(pattern, params);
1670
+ function createToUrlFunction(pattern, defaultLocale) {
1671
+ return (params) => buildUrl(pattern, params, defaultLocale);
1662
1672
  }
1663
1673
  /**
1664
1674
  * Build the `toFile` closure for a route — resolves the output file path from
@@ -1667,15 +1677,16 @@ function createToUrlFunction(pattern) {
1667
1677
  *
1668
1678
  * @param pattern - The route pattern bound into the closure.
1669
1679
  * @param definition - The route definition carrying any `toFile` override.
1680
+ * @param defaultLocale - The locale served bare (bound into the closure).
1670
1681
  * @returns A function mapping params to the output file path.
1671
1682
  * @example
1672
1683
  * ```ts
1673
- * const toFile = createToFileFn("/{slug}/", definition);
1684
+ * const toFile = createToFileFn("/{slug}/", definition, "en");
1674
1685
  * toFile({ slug: "x" }); // "x/index.html"
1675
1686
  * ```
1676
1687
  */
1677
- function createToFileFunction(pattern, definition) {
1678
- return (params) => definition._handlers.toFile?.(params) ?? buildFilePath(pattern, params);
1688
+ function createToFileFunction(pattern, definition, defaultLocale) {
1689
+ return (params) => definition._handlers.toFile?.(params) ?? buildFilePath(pattern, params, defaultLocale);
1679
1690
  }
1680
1691
  /**
1681
1692
  * Compile a single route definition into its `CompiledRoute` entry.
@@ -1692,8 +1703,8 @@ function createToFileFunction(pattern, definition) {
1692
1703
  function compileRoute(name, definition, input) {
1693
1704
  const { pattern } = definition;
1694
1705
  const matchers = buildMatchers(pattern, input.locales);
1695
- const toUrl = createToUrlFunction(pattern);
1696
- const toFile = createToFileFunction(pattern, definition);
1706
+ const toUrl = createToUrlFunction(pattern, input.defaultLocale);
1707
+ const toFile = createToFileFunction(pattern, definition, input.defaultLocale);
1697
1708
  return {
1698
1709
  name,
1699
1710
  pattern,
@@ -2124,15 +2135,24 @@ function defineRoutes(routes) {
2124
2135
  * directly: no `app.router` reference, no manual "bind", no module global, no
2125
2136
  * "not bound" guard, and no createApp ↔ routes cycle.
2126
2137
  *
2138
+ * Pass `defaultLocale` so the builder serves that locale at BARE paths, matching the
2139
+ * runtime `router.toUrl` (which reads the default locale from the i18n plugin):
2140
+ * `toUrl("home", { lang: defaultLocale })` then resolves to `/` instead of
2141
+ * `/{defaultLocale}/`. It is app-free, so the default locale cannot be inferred — the
2142
+ * consumer supplies it (e.g. `createUrls(routes, "en")`). Omit it to keep every locale
2143
+ * prefixed.
2144
+ *
2127
2145
  * @param routes - The route map (typically the value returned by {@link defineRoutes}).
2146
+ * @param defaultLocale - The locale to serve bare (its `{lang:?}` prefix is omitted).
2128
2147
  * @returns A {@link Urls} builder whose `toUrl` accepts only this map's route names.
2129
2148
  * @example
2130
2149
  * ```ts
2131
- * const url = createUrls(routes);
2132
- * url.toUrl("article", { lang: "en", slug: "hello" }); // "/en/hello/"
2150
+ * const url = createUrls(routes, "en");
2151
+ * url.toUrl("article", { lang: "en", slug: "hello" }); // "/hello/"
2152
+ * url.toUrl("article", { lang: "ru", slug: "hello" }); // "/ru/hello/"
2133
2153
  * ```
2134
2154
  */
2135
- function createUrls(routes) {
2155
+ function createUrls(routes, defaultLocale) {
2136
2156
  return {
2137
2157
  /**
2138
2158
  * Build a route's URL path from its name and params.
@@ -2143,13 +2163,13 @@ function createUrls(routes) {
2143
2163
  * @throws {Error} If `name` is not present in the route map.
2144
2164
  * @example
2145
2165
  * ```ts
2146
- * url.toUrl("home", { lang: "en" }); // "/en/"
2166
+ * url.toUrl("home", { lang: "ru" }); // "/ru/"
2147
2167
  * ```
2148
2168
  */
2149
2169
  toUrl(name, params = {}) {
2150
2170
  const definition = routes[name];
2151
2171
  if (!definition) throw new Error(`[web] router: unknown route name "${String(name)}".\n Check the name matches a key in the route map passed to createUrls.`);
2152
- return buildUrl(definition.pattern, params);
2172
+ return buildUrl(definition.pattern, params, defaultLocale);
2153
2173
  } };
2154
2174
  }
2155
2175
  //#endregion
@@ -4424,10 +4444,31 @@ function contentApi(ctx) {
4424
4444
  */
4425
4445
  async function resolveArticle(ctx, slug, locale) {
4426
4446
  const native = await ctx.provider.readArticle(slug, locale, locale, false);
4427
- if (native !== null) return native;
4447
+ if (native !== null) return bareDefaultLocaleUrl(ctx, native);
4428
4448
  const fallbackLocale = ctx.defaultLocale();
4429
4449
  if (fallbackLocale === locale) return null;
4430
- return ctx.provider.readArticle(slug, fallbackLocale, locale, true);
4450
+ const fallback = await ctx.provider.readArticle(slug, fallbackLocale, locale, true);
4451
+ return fallback === null ? fallback : bareDefaultLocaleUrl(ctx, fallback);
4452
+ }
4453
+ /**
4454
+ * The default locale is served at BARE paths, so strip the leading `/{defaultLocale}`
4455
+ * from a default-locale article's `url` (`/en/hello/` → `/hello/`). Providers build
4456
+ * always-prefixed URLs (they have no i18n access); this is the single place the bare
4457
+ * default is applied, so article canonicals + feed GUIDs match the served URL.
4458
+ * Idempotent (a bare URL has no prefix to strip) and a no-op for non-default locales.
4459
+ *
4460
+ * @param ctx - Kernel-free domain context (i18n helpers).
4461
+ * @param article - The resolved article (mutated in place).
4462
+ * @returns The same article, with a bare `url` when it is the default locale.
4463
+ * @example
4464
+ * ```ts
4465
+ * bareDefaultLocaleUrl(ctx, { ...article, locale: "en", url: "/en/hello/" }).url; // "/hello/"
4466
+ * ```
4467
+ */
4468
+ function bareDefaultLocaleUrl(ctx, article) {
4469
+ const prefix = `/${ctx.defaultLocale()}`;
4470
+ if (article.locale === ctx.defaultLocale() && article.url.startsWith(`${prefix}/`)) article.url = article.url.slice(prefix.length);
4471
+ return article;
4431
4472
  }
4432
4473
  /**
4433
4474
  * Comparator sorting articles by frontmatter date descending (newest first),
package/dist/index.cjs CHANGED
@@ -1232,10 +1232,31 @@ function contentApi(ctx) {
1232
1232
  */
1233
1233
  async function resolveArticle(ctx, slug, locale) {
1234
1234
  const native = await ctx.provider.readArticle(slug, locale, locale, false);
1235
- if (native !== null) return native;
1235
+ if (native !== null) return bareDefaultLocaleUrl(ctx, native);
1236
1236
  const fallbackLocale = ctx.defaultLocale();
1237
1237
  if (fallbackLocale === locale) return null;
1238
- return ctx.provider.readArticle(slug, fallbackLocale, locale, true);
1238
+ const fallback = await ctx.provider.readArticle(slug, fallbackLocale, locale, true);
1239
+ return fallback === null ? fallback : bareDefaultLocaleUrl(ctx, fallback);
1240
+ }
1241
+ /**
1242
+ * The default locale is served at BARE paths, so strip the leading `/{defaultLocale}`
1243
+ * from a default-locale article's `url` (`/en/hello/` → `/hello/`). Providers build
1244
+ * always-prefixed URLs (they have no i18n access); this is the single place the bare
1245
+ * default is applied, so article canonicals + feed GUIDs match the served URL.
1246
+ * Idempotent (a bare URL has no prefix to strip) and a no-op for non-default locales.
1247
+ *
1248
+ * @param ctx - Kernel-free domain context (i18n helpers).
1249
+ * @param article - The resolved article (mutated in place).
1250
+ * @returns The same article, with a bare `url` when it is the default locale.
1251
+ * @example
1252
+ * ```ts
1253
+ * bareDefaultLocaleUrl(ctx, { ...article, locale: "en", url: "/en/hello/" }).url; // "/hello/"
1254
+ * ```
1255
+ */
1256
+ function bareDefaultLocaleUrl(ctx, article) {
1257
+ const prefix = `/${ctx.defaultLocale()}`;
1258
+ if (article.locale === ctx.defaultLocale() && article.url.startsWith(`${prefix}/`)) article.url = article.url.slice(prefix.length);
1259
+ return article;
1239
1260
  }
1240
1261
  /**
1241
1262
  * Comparator sorting articles by frontmatter date descending (newest first),
@@ -2145,15 +2166,22 @@ function patternToUrlPattern(pattern, variant, langRegex) {
2145
2166
  * param is absent has its segment skipped entirely (no empty segment), so a missing
2146
2167
  * `{lang:?}` collapses cleanly instead of leaving a double slash.
2147
2168
  *
2169
+ * The default locale is served at BARE paths: when `defaultLocale` is given, the
2170
+ * optional `{lang:?}` segment is also skipped for it (so `{ lang: defaultLocale }`
2171
+ * resolves to `/…` while every other locale keeps its `/{locale}/…` prefix).
2172
+ *
2148
2173
  * @param pattern - The route pattern.
2149
2174
  * @param params - Param values to substitute.
2175
+ * @param defaultLocale - The locale served bare (its `{lang:?}` segment is omitted).
2150
2176
  * @returns The resolved relative URL string.
2151
2177
  * @example
2152
2178
  * ```ts
2153
2179
  * buildUrl("/{slug}/", { slug: "hello" }); // "/hello/"
2180
+ * buildUrl("/{lang:?}/", { lang: "en" }, "en"); // "/"
2181
+ * buildUrl("/{lang:?}/", { lang: "ru" }, "en"); // "/ru/"
2154
2182
  * ```
2155
2183
  */
2156
- function buildUrl(pattern, params) {
2184
+ function buildUrl(pattern, params, defaultLocale) {
2157
2185
  const out = [];
2158
2186
  for (const segment of pattern.split("/")) {
2159
2187
  const placeholder = parsePlaceholder(segment);
@@ -2163,6 +2191,7 @@ function buildUrl(pattern, params) {
2163
2191
  }
2164
2192
  const value = params[placeholder.name] ?? "";
2165
2193
  if (placeholder.optional && value === "") continue;
2194
+ if (placeholder.name === "lang" && placeholder.optional && value === defaultLocale) continue;
2166
2195
  out.push(value);
2167
2196
  }
2168
2197
  return out.join("/");
@@ -2172,14 +2201,15 @@ function buildUrl(pattern, params) {
2172
2201
  *
2173
2202
  * @param pattern - The route pattern.
2174
2203
  * @param params - Param values to substitute.
2204
+ * @param defaultLocale - The locale served bare (forwarded to {@link buildUrl}).
2175
2205
  * @returns The output file path, e.g. `hello/index.html`.
2176
2206
  * @example
2177
2207
  * ```ts
2178
2208
  * buildFilePath("/{slug}/", { slug: "hello" });
2179
2209
  * ```
2180
2210
  */
2181
- function buildFilePath(pattern, params) {
2182
- const cleanPath = buildUrl(pattern, params).replace(/^\//, "").replace(/\/$/, "");
2211
+ function buildFilePath(pattern, params, defaultLocale) {
2212
+ const cleanPath = buildUrl(pattern, params, defaultLocale).replace(/^\//, "").replace(/\/$/, "");
2183
2213
  return cleanPath === "" ? "index.html" : `${cleanPath}/index.html`;
2184
2214
  }
2185
2215
  /**
@@ -2208,15 +2238,16 @@ function buildMatchers(pattern, locales) {
2208
2238
  * pattern.
2209
2239
  *
2210
2240
  * @param pattern - The route pattern bound into the closure.
2241
+ * @param defaultLocale - The locale served bare (bound into the closure).
2211
2242
  * @returns A function mapping params to the resolved relative URL.
2212
2243
  * @example
2213
2244
  * ```ts
2214
- * const toUrl = createToUrlFn("/{slug}/");
2245
+ * const toUrl = createToUrlFn("/{slug}/", "en");
2215
2246
  * toUrl({ slug: "x" }); // "/x/"
2216
2247
  * ```
2217
2248
  */
2218
- function createToUrlFunction(pattern) {
2219
- return (params) => buildUrl(pattern, params);
2249
+ function createToUrlFunction(pattern, defaultLocale) {
2250
+ return (params) => buildUrl(pattern, params, defaultLocale);
2220
2251
  }
2221
2252
  /**
2222
2253
  * Build the `toFile` closure for a route — resolves the output file path from
@@ -2225,15 +2256,16 @@ function createToUrlFunction(pattern) {
2225
2256
  *
2226
2257
  * @param pattern - The route pattern bound into the closure.
2227
2258
  * @param definition - The route definition carrying any `toFile` override.
2259
+ * @param defaultLocale - The locale served bare (bound into the closure).
2228
2260
  * @returns A function mapping params to the output file path.
2229
2261
  * @example
2230
2262
  * ```ts
2231
- * const toFile = createToFileFn("/{slug}/", definition);
2263
+ * const toFile = createToFileFn("/{slug}/", definition, "en");
2232
2264
  * toFile({ slug: "x" }); // "x/index.html"
2233
2265
  * ```
2234
2266
  */
2235
- function createToFileFunction(pattern, definition) {
2236
- return (params) => definition._handlers.toFile?.(params) ?? buildFilePath(pattern, params);
2267
+ function createToFileFunction(pattern, definition, defaultLocale) {
2268
+ return (params) => definition._handlers.toFile?.(params) ?? buildFilePath(pattern, params, defaultLocale);
2237
2269
  }
2238
2270
  /**
2239
2271
  * Compile a single route definition into its `CompiledRoute` entry.
@@ -2250,8 +2282,8 @@ function createToFileFunction(pattern, definition) {
2250
2282
  function compileRoute(name, definition, input) {
2251
2283
  const { pattern } = definition;
2252
2284
  const matchers = buildMatchers(pattern, input.locales);
2253
- const toUrl = createToUrlFunction(pattern);
2254
- const toFile = createToFileFunction(pattern, definition);
2285
+ const toUrl = createToUrlFunction(pattern, input.defaultLocale);
2286
+ const toFile = createToFileFunction(pattern, definition, input.defaultLocale);
2255
2287
  return {
2256
2288
  name,
2257
2289
  pattern,
@@ -2682,15 +2714,24 @@ function defineRoutes(routes) {
2682
2714
  * directly: no `app.router` reference, no manual "bind", no module global, no
2683
2715
  * "not bound" guard, and no createApp ↔ routes cycle.
2684
2716
  *
2717
+ * Pass `defaultLocale` so the builder serves that locale at BARE paths, matching the
2718
+ * runtime `router.toUrl` (which reads the default locale from the i18n plugin):
2719
+ * `toUrl("home", { lang: defaultLocale })` then resolves to `/` instead of
2720
+ * `/{defaultLocale}/`. It is app-free, so the default locale cannot be inferred — the
2721
+ * consumer supplies it (e.g. `createUrls(routes, "en")`). Omit it to keep every locale
2722
+ * prefixed.
2723
+ *
2685
2724
  * @param routes - The route map (typically the value returned by {@link defineRoutes}).
2725
+ * @param defaultLocale - The locale to serve bare (its `{lang:?}` prefix is omitted).
2686
2726
  * @returns A {@link Urls} builder whose `toUrl` accepts only this map's route names.
2687
2727
  * @example
2688
2728
  * ```ts
2689
- * const url = createUrls(routes);
2690
- * url.toUrl("article", { lang: "en", slug: "hello" }); // "/en/hello/"
2729
+ * const url = createUrls(routes, "en");
2730
+ * url.toUrl("article", { lang: "en", slug: "hello" }); // "/hello/"
2731
+ * url.toUrl("article", { lang: "ru", slug: "hello" }); // "/ru/hello/"
2691
2732
  * ```
2692
2733
  */
2693
- function createUrls(routes) {
2734
+ function createUrls(routes, defaultLocale) {
2694
2735
  return {
2695
2736
  /**
2696
2737
  * Build a route's URL path from its name and params.
@@ -2701,13 +2742,13 @@ function createUrls(routes) {
2701
2742
  * @throws {Error} If `name` is not present in the route map.
2702
2743
  * @example
2703
2744
  * ```ts
2704
- * url.toUrl("home", { lang: "en" }); // "/en/"
2745
+ * url.toUrl("home", { lang: "ru" }); // "/ru/"
2705
2746
  * ```
2706
2747
  */
2707
2748
  toUrl(name, params = {}) {
2708
2749
  const definition = routes[name];
2709
2750
  if (!definition) throw new Error(`[web] router: unknown route name "${String(name)}".\n Check the name matches a key in the route map passed to createUrls.`);
2710
- return buildUrl(definition.pattern, params);
2751
+ return buildUrl(definition.pattern, params, defaultLocale);
2711
2752
  } };
2712
2753
  }
2713
2754
  //#endregion
@@ -3992,10 +4033,31 @@ function wrap(body) {
3992
4033
  return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>404 — Not Found</title></head><body>${body}</body></html>`;
3993
4034
  }
3994
4035
  /**
4036
+ * Resolve the 404 page HTML from `config.notFound`. Precedence: `path` (a
4037
+ * complete page file, read verbatim) > `body` (a fragment, wrapped in the
4038
+ * minimal shell) > the built-in default.
4039
+ *
4040
+ * @param notFound - The `config.notFound` value (already known to be truthy).
4041
+ * @returns The complete HTML document to write.
4042
+ * @example
4043
+ * ```ts
4044
+ * const html = await resolveHtml({ path: "src/404.html" });
4045
+ * ```
4046
+ */
4047
+ async function resolveHtml(notFound) {
4048
+ if (typeof notFound === "object" && notFound.path) try {
4049
+ return await (0, node_fs_promises.readFile)(notFound.path, "utf8");
4050
+ } catch (error) {
4051
+ throw new Error(`build:not-found — could not read notFound.path "${notFound.path}"`, { cause: error });
4052
+ }
4053
+ return wrap(typeof notFound === "object" && notFound.body ? notFound.body : DEFAULT_BODY);
4054
+ }
4055
+ /**
3995
4056
  * Emits `outDir/404.html`. When `config.notFound` is `true`, writes the built-in
3996
- * default page; when it is `{ body }`, writes the supplied HTML body content
3997
- * verbatim inside the document shell. No-op (returns `null`) when `notFound` is
3998
- * false/unset.
4057
+ * default page; `{ body }` writes the supplied HTML body content inside the
4058
+ * minimal document shell; `{ path }` writes the referenced HTML page file
4059
+ * verbatim (the app owns the whole document). No-op (returns `null`) when
4060
+ * `notFound` is false/unset.
3999
4061
  *
4000
4062
  * @param ctx - Plugin context (provides `config`, `log`).
4001
4063
  * @returns The written file path, or `null` when disabled.
@@ -4010,10 +4072,10 @@ async function generateNotFound(ctx) {
4010
4072
  ctx.log.debug("build:not-found", { skipped: true });
4011
4073
  return null;
4012
4074
  }
4013
- const body = typeof notFound === "object" && notFound.body ? notFound.body : DEFAULT_BODY;
4075
+ const html = await resolveHtml(notFound);
4014
4076
  await (0, node_fs_promises.mkdir)(outDir, { recursive: true });
4015
4077
  const file = node_path$1.default.join(outDir, "404.html");
4016
- await (0, node_fs_promises.writeFile)(file, wrap(body), "utf8");
4078
+ await (0, node_fs_promises.writeFile)(file, html, "utf8");
4017
4079
  ctx.log.debug("build:not-found", { path: file });
4018
4080
  return { path: file };
4019
4081
  }
@@ -5096,7 +5158,24 @@ function renderBodyCached(ctx, instance, routeContext, data, reuse) {
5096
5158
  * ```
5097
5159
  */
5098
5160
  async function writeDocument(outDir, entry, params, html) {
5099
- const filePath = node_path.default.join(outDir, entry.toFile(params));
5161
+ await writeDocumentAt(outDir, entry.toFile(params), html);
5162
+ }
5163
+ /**
5164
+ * Write an HTML document to an explicit relative path under `outDir`, creating parent
5165
+ * directories first. Backs both the canonical page path ({@link writeDocument}) and the
5166
+ * default-locale `/{defaultLocale}/…` alias copy ({@link renderInstance}).
5167
+ *
5168
+ * @param outDir - The build output directory.
5169
+ * @param relativeFile - The output file path relative to `outDir` (e.g. `en/index.html`).
5170
+ * @param html - The complete HTML document to write.
5171
+ * @returns A promise resolved once the file is written.
5172
+ * @example
5173
+ * ```ts
5174
+ * await writeDocumentAt("dist", "en/about/index.html", "<!DOCTYPE html>…");
5175
+ * ```
5176
+ */
5177
+ async function writeDocumentAt(outDir, relativeFile, html) {
5178
+ const filePath = node_path.default.join(outDir, relativeFile);
5100
5179
  await (0, node_fs_promises.mkdir)(node_path.default.dirname(filePath), { recursive: true });
5101
5180
  await (0, node_fs_promises.writeFile)(filePath, html, "utf8");
5102
5181
  }
@@ -5106,14 +5185,19 @@ async function writeDocument(outDir, entry, params, html) {
5106
5185
  * `<head>`/body → assemble the document (template fill or in-code shell) → write.
5107
5186
  * Uses the configured shell `template` when supplied, otherwise the in-code shell.
5108
5187
  *
5188
+ * The default locale is served at BARE paths, so each default-locale page on a
5189
+ * `{lang:?}` route is ALSO written to `/{defaultLocale}/…` — the SAME rendered HTML (its
5190
+ * canonical already points at the bare URL) — so an explicit `/{defaultLocale}/…` link
5191
+ * serves the page directly with no redirect. Both pages are returned so each gets a sidecar.
5192
+ *
5109
5193
  * @param ctx - Plugin context (provides `require`, `state`, `config`, `has`).
5110
5194
  * @param instance - The concrete page instance to render.
5111
- * @param shell - Per-build wiring shared across instances (asset tags + template).
5195
+ * @param shell - Per-build wiring shared across instances (asset tags + template + default locale).
5112
5196
  * @param reuse - Whether this run may reuse a cached body (incremental, no code change).
5113
- * @returns The instance's URL, rendered HTML, loaded data, and client-nav flag.
5197
+ * @returns The rendered page(s): the canonical page, plus the `/{defaultLocale}/` alias when emitted.
5114
5198
  * @example
5115
5199
  * ```ts
5116
- * await renderInstance(ctx, instance, { assets: "", template: null }, false);
5200
+ * await renderInstance(ctx, instance, shell, false);
5117
5201
  * ```
5118
5202
  */
5119
5203
  async function renderInstance(ctx, instance, shell, reuse) {
@@ -5135,20 +5219,31 @@ async function renderInstance(ctx, instance, shell, reuse) {
5135
5219
  };
5136
5220
  const html = shell.template === null ? renderDocument(parts) : fillTemplate(shell.template, parts);
5137
5221
  await writeDocument(ctx.config.outDir, entry, params, html);
5138
- return {
5222
+ const clientNavigable = definition._handlers.render !== void 0;
5223
+ const pages = [{
5139
5224
  url,
5140
5225
  html,
5141
5226
  data,
5142
- clientNavigable: definition._handlers.render !== void 0
5143
- };
5227
+ clientNavigable
5228
+ }];
5229
+ if (locale === shell.defaultLocale && entry.pattern.includes("{lang:?}")) {
5230
+ await writeDocumentAt(ctx.config.outDir, `${shell.defaultLocale}/${entry.toFile(params)}`, html);
5231
+ pages.push({
5232
+ url: `/${shell.defaultLocale}${url}`,
5233
+ html,
5234
+ data,
5235
+ clientNavigable
5236
+ });
5237
+ }
5238
+ return pages;
5144
5239
  }
5145
5240
  /**
5146
5241
  * Prepare the per-build {@link RenderShell} ONCE (O(1) per page): read the optional
5147
5242
  * shell `template` from disk when configured + present, and precompute the injected
5148
5243
  * asset tags. `template` is `null` when unset/missing (use the in-code shell).
5149
5244
  *
5150
- * @param ctx - Plugin context (provides `config`, `state`).
5151
- * @returns The shared shell wiring (asset tags + template-or-null) for every page.
5245
+ * @param ctx - Plugin context (provides `config`, `state`, `require`).
5246
+ * @returns The shared shell wiring (asset tags + template-or-null + default locale) for every page.
5152
5247
  * @example
5153
5248
  * ```ts
5154
5249
  * const shell = await prepareShell(ctx);
@@ -5159,7 +5254,8 @@ async function prepareShell(ctx) {
5159
5254
  const template = typeof templatePath === "string" && (0, node_fs.existsSync)(templatePath) ? await (0, node_fs_promises.readFile)(templatePath, "utf8") : null;
5160
5255
  return {
5161
5256
  assets: buildAssetTags(ctx),
5162
- template
5257
+ template,
5258
+ defaultLocale: ctx.require(i18nPlugin).defaultLocale()
5163
5259
  };
5164
5260
  }
5165
5261
  /**
@@ -5293,7 +5389,7 @@ async function renderPages(ctx, options) {
5293
5389
  const byPattern = makeEntryMap(router);
5294
5390
  if (!reuse) ctx.state.renderCache.clear();
5295
5391
  const shell = await prepareShell(ctx);
5296
- const rendered = await renderInBatches(await expandAllInstances(manifest, locales, byPattern, ctx), reuse ? INCREMENTAL_BATCH_SIZE : RENDER_BATCH_SIZE, (instance) => renderInstance(ctx, instance, shell, reuse));
5392
+ const rendered = (await renderInBatches(await expandAllInstances(manifest, locales, byPattern, ctx), reuse ? INCREMENTAL_BATCH_SIZE : RENDER_BATCH_SIZE, (instance) => renderInstance(ctx, instance, shell, reuse))).flat();
5297
5393
  await writeDataSidecars(ctx, rendered, router.mode());
5298
5394
  ctx.log.debug("build:pages", { count: rendered.length });
5299
5395
  return {
package/dist/index.d.cts CHANGED
@@ -1663,12 +1663,18 @@ type Config$3 = {
1663
1663
  injectAssets?: boolean; /** Directory copied verbatim into `outDir` (skipped silently if absent). Default `"public"`. */
1664
1664
  publicDir?: string;
1665
1665
  /**
1666
- * Emit `outDir/404.html`. `true` for the built-in default page, or
1667
- * `{ body }` to supply the page's literal HTML body content (written into the
1668
- * 404 page verbatim). Default `false`.
1666
+ * Emit `outDir/404.html`. One of:
1667
+ * - `true` the built-in default page.
1668
+ * - `{ body }` — literal HTML body content, wrapped in a minimal document shell.
1669
+ * - `{ path }` — path to a complete HTML page file (resolved from the project
1670
+ * root), written out VERBATIM so the app owns the whole document (its own
1671
+ * `<head>`, asset links, and body).
1672
+ *
1673
+ * `path` takes precedence over `body` when both are set. Default `false`.
1669
1674
  */
1670
1675
  notFound?: boolean | {
1671
1676
  body?: string;
1677
+ path?: string;
1672
1678
  }; /** Emit per-path i18n bare-path redirect HTML pages. Default `false`. */
1673
1679
  localeRedirects?: boolean; /** Authoritative client bundle entry path (overrides the conventional scan). */
1674
1680
  clientEntry?: string;
@@ -3320,15 +3326,24 @@ declare function defineRoutes<T extends RouteMap>(routes: T): T;
3320
3326
  * directly: no `app.router` reference, no manual "bind", no module global, no
3321
3327
  * "not bound" guard, and no createApp ↔ routes cycle.
3322
3328
  *
3329
+ * Pass `defaultLocale` so the builder serves that locale at BARE paths, matching the
3330
+ * runtime `router.toUrl` (which reads the default locale from the i18n plugin):
3331
+ * `toUrl("home", { lang: defaultLocale })` then resolves to `/` instead of
3332
+ * `/{defaultLocale}/`. It is app-free, so the default locale cannot be inferred — the
3333
+ * consumer supplies it (e.g. `createUrls(routes, "en")`). Omit it to keep every locale
3334
+ * prefixed.
3335
+ *
3323
3336
  * @param routes - The route map (typically the value returned by {@link defineRoutes}).
3337
+ * @param defaultLocale - The locale to serve bare (its `{lang:?}` prefix is omitted).
3324
3338
  * @returns A {@link Urls} builder whose `toUrl` accepts only this map's route names.
3325
3339
  * @example
3326
3340
  * ```ts
3327
- * const url = createUrls(routes);
3328
- * url.toUrl("article", { lang: "en", slug: "hello" }); // "/en/hello/"
3341
+ * const url = createUrls(routes, "en");
3342
+ * url.toUrl("article", { lang: "en", slug: "hello" }); // "/hello/"
3343
+ * url.toUrl("article", { lang: "ru", slug: "hello" }); // "/ru/hello/"
3329
3344
  * ```
3330
3345
  */
3331
- declare function createUrls<T extends RouteMap>(routes: T): Urls<T>;
3346
+ declare function createUrls<T extends RouteMap>(routes: T, defaultLocale?: string): Urls<T>;
3332
3347
  //#endregion
3333
3348
  //#region src/plugins/router/index.d.ts
3334
3349
  /**
package/dist/index.d.mts CHANGED
@@ -1663,12 +1663,18 @@ type Config$3 = {
1663
1663
  injectAssets?: boolean; /** Directory copied verbatim into `outDir` (skipped silently if absent). Default `"public"`. */
1664
1664
  publicDir?: string;
1665
1665
  /**
1666
- * Emit `outDir/404.html`. `true` for the built-in default page, or
1667
- * `{ body }` to supply the page's literal HTML body content (written into the
1668
- * 404 page verbatim). Default `false`.
1666
+ * Emit `outDir/404.html`. One of:
1667
+ * - `true` the built-in default page.
1668
+ * - `{ body }` — literal HTML body content, wrapped in a minimal document shell.
1669
+ * - `{ path }` — path to a complete HTML page file (resolved from the project
1670
+ * root), written out VERBATIM so the app owns the whole document (its own
1671
+ * `<head>`, asset links, and body).
1672
+ *
1673
+ * `path` takes precedence over `body` when both are set. Default `false`.
1669
1674
  */
1670
1675
  notFound?: boolean | {
1671
1676
  body?: string;
1677
+ path?: string;
1672
1678
  }; /** Emit per-path i18n bare-path redirect HTML pages. Default `false`. */
1673
1679
  localeRedirects?: boolean; /** Authoritative client bundle entry path (overrides the conventional scan). */
1674
1680
  clientEntry?: string;
@@ -3320,15 +3326,24 @@ declare function defineRoutes<T extends RouteMap>(routes: T): T;
3320
3326
  * directly: no `app.router` reference, no manual "bind", no module global, no
3321
3327
  * "not bound" guard, and no createApp ↔ routes cycle.
3322
3328
  *
3329
+ * Pass `defaultLocale` so the builder serves that locale at BARE paths, matching the
3330
+ * runtime `router.toUrl` (which reads the default locale from the i18n plugin):
3331
+ * `toUrl("home", { lang: defaultLocale })` then resolves to `/` instead of
3332
+ * `/{defaultLocale}/`. It is app-free, so the default locale cannot be inferred — the
3333
+ * consumer supplies it (e.g. `createUrls(routes, "en")`). Omit it to keep every locale
3334
+ * prefixed.
3335
+ *
3323
3336
  * @param routes - The route map (typically the value returned by {@link defineRoutes}).
3337
+ * @param defaultLocale - The locale to serve bare (its `{lang:?}` prefix is omitted).
3324
3338
  * @returns A {@link Urls} builder whose `toUrl` accepts only this map's route names.
3325
3339
  * @example
3326
3340
  * ```ts
3327
- * const url = createUrls(routes);
3328
- * url.toUrl("article", { lang: "en", slug: "hello" }); // "/en/hello/"
3341
+ * const url = createUrls(routes, "en");
3342
+ * url.toUrl("article", { lang: "en", slug: "hello" }); // "/hello/"
3343
+ * url.toUrl("article", { lang: "ru", slug: "hello" }); // "/ru/hello/"
3329
3344
  * ```
3330
3345
  */
3331
- declare function createUrls<T extends RouteMap>(routes: T): Urls<T>;
3346
+ declare function createUrls<T extends RouteMap>(routes: T, defaultLocale?: string): Urls<T>;
3332
3347
  //#endregion
3333
3348
  //#region src/plugins/router/index.d.ts
3334
3349
  /**
package/dist/index.mjs CHANGED
@@ -1219,10 +1219,31 @@ function contentApi(ctx) {
1219
1219
  */
1220
1220
  async function resolveArticle(ctx, slug, locale) {
1221
1221
  const native = await ctx.provider.readArticle(slug, locale, locale, false);
1222
- if (native !== null) return native;
1222
+ if (native !== null) return bareDefaultLocaleUrl(ctx, native);
1223
1223
  const fallbackLocale = ctx.defaultLocale();
1224
1224
  if (fallbackLocale === locale) return null;
1225
- return ctx.provider.readArticle(slug, fallbackLocale, locale, true);
1225
+ const fallback = await ctx.provider.readArticle(slug, fallbackLocale, locale, true);
1226
+ return fallback === null ? fallback : bareDefaultLocaleUrl(ctx, fallback);
1227
+ }
1228
+ /**
1229
+ * The default locale is served at BARE paths, so strip the leading `/{defaultLocale}`
1230
+ * from a default-locale article's `url` (`/en/hello/` → `/hello/`). Providers build
1231
+ * always-prefixed URLs (they have no i18n access); this is the single place the bare
1232
+ * default is applied, so article canonicals + feed GUIDs match the served URL.
1233
+ * Idempotent (a bare URL has no prefix to strip) and a no-op for non-default locales.
1234
+ *
1235
+ * @param ctx - Kernel-free domain context (i18n helpers).
1236
+ * @param article - The resolved article (mutated in place).
1237
+ * @returns The same article, with a bare `url` when it is the default locale.
1238
+ * @example
1239
+ * ```ts
1240
+ * bareDefaultLocaleUrl(ctx, { ...article, locale: "en", url: "/en/hello/" }).url; // "/hello/"
1241
+ * ```
1242
+ */
1243
+ function bareDefaultLocaleUrl(ctx, article) {
1244
+ const prefix = `/${ctx.defaultLocale()}`;
1245
+ if (article.locale === ctx.defaultLocale() && article.url.startsWith(`${prefix}/`)) article.url = article.url.slice(prefix.length);
1246
+ return article;
1226
1247
  }
1227
1248
  /**
1228
1249
  * Comparator sorting articles by frontmatter date descending (newest first),
@@ -2132,15 +2153,22 @@ function patternToUrlPattern(pattern, variant, langRegex) {
2132
2153
  * param is absent has its segment skipped entirely (no empty segment), so a missing
2133
2154
  * `{lang:?}` collapses cleanly instead of leaving a double slash.
2134
2155
  *
2156
+ * The default locale is served at BARE paths: when `defaultLocale` is given, the
2157
+ * optional `{lang:?}` segment is also skipped for it (so `{ lang: defaultLocale }`
2158
+ * resolves to `/…` while every other locale keeps its `/{locale}/…` prefix).
2159
+ *
2135
2160
  * @param pattern - The route pattern.
2136
2161
  * @param params - Param values to substitute.
2162
+ * @param defaultLocale - The locale served bare (its `{lang:?}` segment is omitted).
2137
2163
  * @returns The resolved relative URL string.
2138
2164
  * @example
2139
2165
  * ```ts
2140
2166
  * buildUrl("/{slug}/", { slug: "hello" }); // "/hello/"
2167
+ * buildUrl("/{lang:?}/", { lang: "en" }, "en"); // "/"
2168
+ * buildUrl("/{lang:?}/", { lang: "ru" }, "en"); // "/ru/"
2141
2169
  * ```
2142
2170
  */
2143
- function buildUrl(pattern, params) {
2171
+ function buildUrl(pattern, params, defaultLocale) {
2144
2172
  const out = [];
2145
2173
  for (const segment of pattern.split("/")) {
2146
2174
  const placeholder = parsePlaceholder(segment);
@@ -2150,6 +2178,7 @@ function buildUrl(pattern, params) {
2150
2178
  }
2151
2179
  const value = params[placeholder.name] ?? "";
2152
2180
  if (placeholder.optional && value === "") continue;
2181
+ if (placeholder.name === "lang" && placeholder.optional && value === defaultLocale) continue;
2153
2182
  out.push(value);
2154
2183
  }
2155
2184
  return out.join("/");
@@ -2159,14 +2188,15 @@ function buildUrl(pattern, params) {
2159
2188
  *
2160
2189
  * @param pattern - The route pattern.
2161
2190
  * @param params - Param values to substitute.
2191
+ * @param defaultLocale - The locale served bare (forwarded to {@link buildUrl}).
2162
2192
  * @returns The output file path, e.g. `hello/index.html`.
2163
2193
  * @example
2164
2194
  * ```ts
2165
2195
  * buildFilePath("/{slug}/", { slug: "hello" });
2166
2196
  * ```
2167
2197
  */
2168
- function buildFilePath(pattern, params) {
2169
- const cleanPath = buildUrl(pattern, params).replace(/^\//, "").replace(/\/$/, "");
2198
+ function buildFilePath(pattern, params, defaultLocale) {
2199
+ const cleanPath = buildUrl(pattern, params, defaultLocale).replace(/^\//, "").replace(/\/$/, "");
2170
2200
  return cleanPath === "" ? "index.html" : `${cleanPath}/index.html`;
2171
2201
  }
2172
2202
  /**
@@ -2195,15 +2225,16 @@ function buildMatchers(pattern, locales) {
2195
2225
  * pattern.
2196
2226
  *
2197
2227
  * @param pattern - The route pattern bound into the closure.
2228
+ * @param defaultLocale - The locale served bare (bound into the closure).
2198
2229
  * @returns A function mapping params to the resolved relative URL.
2199
2230
  * @example
2200
2231
  * ```ts
2201
- * const toUrl = createToUrlFn("/{slug}/");
2232
+ * const toUrl = createToUrlFn("/{slug}/", "en");
2202
2233
  * toUrl({ slug: "x" }); // "/x/"
2203
2234
  * ```
2204
2235
  */
2205
- function createToUrlFunction(pattern) {
2206
- return (params) => buildUrl(pattern, params);
2236
+ function createToUrlFunction(pattern, defaultLocale) {
2237
+ return (params) => buildUrl(pattern, params, defaultLocale);
2207
2238
  }
2208
2239
  /**
2209
2240
  * Build the `toFile` closure for a route — resolves the output file path from
@@ -2212,15 +2243,16 @@ function createToUrlFunction(pattern) {
2212
2243
  *
2213
2244
  * @param pattern - The route pattern bound into the closure.
2214
2245
  * @param definition - The route definition carrying any `toFile` override.
2246
+ * @param defaultLocale - The locale served bare (bound into the closure).
2215
2247
  * @returns A function mapping params to the output file path.
2216
2248
  * @example
2217
2249
  * ```ts
2218
- * const toFile = createToFileFn("/{slug}/", definition);
2250
+ * const toFile = createToFileFn("/{slug}/", definition, "en");
2219
2251
  * toFile({ slug: "x" }); // "x/index.html"
2220
2252
  * ```
2221
2253
  */
2222
- function createToFileFunction(pattern, definition) {
2223
- return (params) => definition._handlers.toFile?.(params) ?? buildFilePath(pattern, params);
2254
+ function createToFileFunction(pattern, definition, defaultLocale) {
2255
+ return (params) => definition._handlers.toFile?.(params) ?? buildFilePath(pattern, params, defaultLocale);
2224
2256
  }
2225
2257
  /**
2226
2258
  * Compile a single route definition into its `CompiledRoute` entry.
@@ -2237,8 +2269,8 @@ function createToFileFunction(pattern, definition) {
2237
2269
  function compileRoute(name, definition, input) {
2238
2270
  const { pattern } = definition;
2239
2271
  const matchers = buildMatchers(pattern, input.locales);
2240
- const toUrl = createToUrlFunction(pattern);
2241
- const toFile = createToFileFunction(pattern, definition);
2272
+ const toUrl = createToUrlFunction(pattern, input.defaultLocale);
2273
+ const toFile = createToFileFunction(pattern, definition, input.defaultLocale);
2242
2274
  return {
2243
2275
  name,
2244
2276
  pattern,
@@ -2669,15 +2701,24 @@ function defineRoutes(routes) {
2669
2701
  * directly: no `app.router` reference, no manual "bind", no module global, no
2670
2702
  * "not bound" guard, and no createApp ↔ routes cycle.
2671
2703
  *
2704
+ * Pass `defaultLocale` so the builder serves that locale at BARE paths, matching the
2705
+ * runtime `router.toUrl` (which reads the default locale from the i18n plugin):
2706
+ * `toUrl("home", { lang: defaultLocale })` then resolves to `/` instead of
2707
+ * `/{defaultLocale}/`. It is app-free, so the default locale cannot be inferred — the
2708
+ * consumer supplies it (e.g. `createUrls(routes, "en")`). Omit it to keep every locale
2709
+ * prefixed.
2710
+ *
2672
2711
  * @param routes - The route map (typically the value returned by {@link defineRoutes}).
2712
+ * @param defaultLocale - The locale to serve bare (its `{lang:?}` prefix is omitted).
2673
2713
  * @returns A {@link Urls} builder whose `toUrl` accepts only this map's route names.
2674
2714
  * @example
2675
2715
  * ```ts
2676
- * const url = createUrls(routes);
2677
- * url.toUrl("article", { lang: "en", slug: "hello" }); // "/en/hello/"
2716
+ * const url = createUrls(routes, "en");
2717
+ * url.toUrl("article", { lang: "en", slug: "hello" }); // "/hello/"
2718
+ * url.toUrl("article", { lang: "ru", slug: "hello" }); // "/ru/hello/"
2678
2719
  * ```
2679
2720
  */
2680
- function createUrls(routes) {
2721
+ function createUrls(routes, defaultLocale) {
2681
2722
  return {
2682
2723
  /**
2683
2724
  * Build a route's URL path from its name and params.
@@ -2688,13 +2729,13 @@ function createUrls(routes) {
2688
2729
  * @throws {Error} If `name` is not present in the route map.
2689
2730
  * @example
2690
2731
  * ```ts
2691
- * url.toUrl("home", { lang: "en" }); // "/en/"
2732
+ * url.toUrl("home", { lang: "ru" }); // "/ru/"
2692
2733
  * ```
2693
2734
  */
2694
2735
  toUrl(name, params = {}) {
2695
2736
  const definition = routes[name];
2696
2737
  if (!definition) throw new Error(`[web] router: unknown route name "${String(name)}".\n Check the name matches a key in the route map passed to createUrls.`);
2697
- return buildUrl(definition.pattern, params);
2738
+ return buildUrl(definition.pattern, params, defaultLocale);
2698
2739
  } };
2699
2740
  }
2700
2741
  //#endregion
@@ -3979,10 +4020,31 @@ function wrap(body) {
3979
4020
  return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>404 — Not Found</title></head><body>${body}</body></html>`;
3980
4021
  }
3981
4022
  /**
4023
+ * Resolve the 404 page HTML from `config.notFound`. Precedence: `path` (a
4024
+ * complete page file, read verbatim) > `body` (a fragment, wrapped in the
4025
+ * minimal shell) > the built-in default.
4026
+ *
4027
+ * @param notFound - The `config.notFound` value (already known to be truthy).
4028
+ * @returns The complete HTML document to write.
4029
+ * @example
4030
+ * ```ts
4031
+ * const html = await resolveHtml({ path: "src/404.html" });
4032
+ * ```
4033
+ */
4034
+ async function resolveHtml(notFound) {
4035
+ if (typeof notFound === "object" && notFound.path) try {
4036
+ return await readFile(notFound.path, "utf8");
4037
+ } catch (error) {
4038
+ throw new Error(`build:not-found — could not read notFound.path "${notFound.path}"`, { cause: error });
4039
+ }
4040
+ return wrap(typeof notFound === "object" && notFound.body ? notFound.body : DEFAULT_BODY);
4041
+ }
4042
+ /**
3982
4043
  * Emits `outDir/404.html`. When `config.notFound` is `true`, writes the built-in
3983
- * default page; when it is `{ body }`, writes the supplied HTML body content
3984
- * verbatim inside the document shell. No-op (returns `null`) when `notFound` is
3985
- * false/unset.
4044
+ * default page; `{ body }` writes the supplied HTML body content inside the
4045
+ * minimal document shell; `{ path }` writes the referenced HTML page file
4046
+ * verbatim (the app owns the whole document). No-op (returns `null`) when
4047
+ * `notFound` is false/unset.
3986
4048
  *
3987
4049
  * @param ctx - Plugin context (provides `config`, `log`).
3988
4050
  * @returns The written file path, or `null` when disabled.
@@ -3997,10 +4059,10 @@ async function generateNotFound(ctx) {
3997
4059
  ctx.log.debug("build:not-found", { skipped: true });
3998
4060
  return null;
3999
4061
  }
4000
- const body = typeof notFound === "object" && notFound.body ? notFound.body : DEFAULT_BODY;
4062
+ const html = await resolveHtml(notFound);
4001
4063
  await mkdir(outDir, { recursive: true });
4002
4064
  const file = path.join(outDir, "404.html");
4003
- await writeFile(file, wrap(body), "utf8");
4065
+ await writeFile(file, html, "utf8");
4004
4066
  ctx.log.debug("build:not-found", { path: file });
4005
4067
  return { path: file };
4006
4068
  }
@@ -5083,7 +5145,24 @@ function renderBodyCached(ctx, instance, routeContext, data, reuse) {
5083
5145
  * ```
5084
5146
  */
5085
5147
  async function writeDocument(outDir, entry, params, html) {
5086
- const filePath = path.join(outDir, entry.toFile(params));
5148
+ await writeDocumentAt(outDir, entry.toFile(params), html);
5149
+ }
5150
+ /**
5151
+ * Write an HTML document to an explicit relative path under `outDir`, creating parent
5152
+ * directories first. Backs both the canonical page path ({@link writeDocument}) and the
5153
+ * default-locale `/{defaultLocale}/…` alias copy ({@link renderInstance}).
5154
+ *
5155
+ * @param outDir - The build output directory.
5156
+ * @param relativeFile - The output file path relative to `outDir` (e.g. `en/index.html`).
5157
+ * @param html - The complete HTML document to write.
5158
+ * @returns A promise resolved once the file is written.
5159
+ * @example
5160
+ * ```ts
5161
+ * await writeDocumentAt("dist", "en/about/index.html", "<!DOCTYPE html>…");
5162
+ * ```
5163
+ */
5164
+ async function writeDocumentAt(outDir, relativeFile, html) {
5165
+ const filePath = path.join(outDir, relativeFile);
5087
5166
  await mkdir(path.dirname(filePath), { recursive: true });
5088
5167
  await writeFile(filePath, html, "utf8");
5089
5168
  }
@@ -5093,14 +5172,19 @@ async function writeDocument(outDir, entry, params, html) {
5093
5172
  * `<head>`/body → assemble the document (template fill or in-code shell) → write.
5094
5173
  * Uses the configured shell `template` when supplied, otherwise the in-code shell.
5095
5174
  *
5175
+ * The default locale is served at BARE paths, so each default-locale page on a
5176
+ * `{lang:?}` route is ALSO written to `/{defaultLocale}/…` — the SAME rendered HTML (its
5177
+ * canonical already points at the bare URL) — so an explicit `/{defaultLocale}/…` link
5178
+ * serves the page directly with no redirect. Both pages are returned so each gets a sidecar.
5179
+ *
5096
5180
  * @param ctx - Plugin context (provides `require`, `state`, `config`, `has`).
5097
5181
  * @param instance - The concrete page instance to render.
5098
- * @param shell - Per-build wiring shared across instances (asset tags + template).
5182
+ * @param shell - Per-build wiring shared across instances (asset tags + template + default locale).
5099
5183
  * @param reuse - Whether this run may reuse a cached body (incremental, no code change).
5100
- * @returns The instance's URL, rendered HTML, loaded data, and client-nav flag.
5184
+ * @returns The rendered page(s): the canonical page, plus the `/{defaultLocale}/` alias when emitted.
5101
5185
  * @example
5102
5186
  * ```ts
5103
- * await renderInstance(ctx, instance, { assets: "", template: null }, false);
5187
+ * await renderInstance(ctx, instance, shell, false);
5104
5188
  * ```
5105
5189
  */
5106
5190
  async function renderInstance(ctx, instance, shell, reuse) {
@@ -5122,20 +5206,31 @@ async function renderInstance(ctx, instance, shell, reuse) {
5122
5206
  };
5123
5207
  const html = shell.template === null ? renderDocument(parts) : fillTemplate(shell.template, parts);
5124
5208
  await writeDocument(ctx.config.outDir, entry, params, html);
5125
- return {
5209
+ const clientNavigable = definition._handlers.render !== void 0;
5210
+ const pages = [{
5126
5211
  url,
5127
5212
  html,
5128
5213
  data,
5129
- clientNavigable: definition._handlers.render !== void 0
5130
- };
5214
+ clientNavigable
5215
+ }];
5216
+ if (locale === shell.defaultLocale && entry.pattern.includes("{lang:?}")) {
5217
+ await writeDocumentAt(ctx.config.outDir, `${shell.defaultLocale}/${entry.toFile(params)}`, html);
5218
+ pages.push({
5219
+ url: `/${shell.defaultLocale}${url}`,
5220
+ html,
5221
+ data,
5222
+ clientNavigable
5223
+ });
5224
+ }
5225
+ return pages;
5131
5226
  }
5132
5227
  /**
5133
5228
  * Prepare the per-build {@link RenderShell} ONCE (O(1) per page): read the optional
5134
5229
  * shell `template` from disk when configured + present, and precompute the injected
5135
5230
  * asset tags. `template` is `null` when unset/missing (use the in-code shell).
5136
5231
  *
5137
- * @param ctx - Plugin context (provides `config`, `state`).
5138
- * @returns The shared shell wiring (asset tags + template-or-null) for every page.
5232
+ * @param ctx - Plugin context (provides `config`, `state`, `require`).
5233
+ * @returns The shared shell wiring (asset tags + template-or-null + default locale) for every page.
5139
5234
  * @example
5140
5235
  * ```ts
5141
5236
  * const shell = await prepareShell(ctx);
@@ -5146,7 +5241,8 @@ async function prepareShell(ctx) {
5146
5241
  const template = typeof templatePath === "string" && existsSync(templatePath) ? await readFile(templatePath, "utf8") : null;
5147
5242
  return {
5148
5243
  assets: buildAssetTags(ctx),
5149
- template
5244
+ template,
5245
+ defaultLocale: ctx.require(i18nPlugin).defaultLocale()
5150
5246
  };
5151
5247
  }
5152
5248
  /**
@@ -5280,7 +5376,7 @@ async function renderPages(ctx, options) {
5280
5376
  const byPattern = makeEntryMap(router);
5281
5377
  if (!reuse) ctx.state.renderCache.clear();
5282
5378
  const shell = await prepareShell(ctx);
5283
- const rendered = await renderInBatches(await expandAllInstances(manifest, locales, byPattern, ctx), reuse ? INCREMENTAL_BATCH_SIZE : RENDER_BATCH_SIZE, (instance) => renderInstance(ctx, instance, shell, reuse));
5379
+ const rendered = (await renderInBatches(await expandAllInstances(manifest, locales, byPattern, ctx), reuse ? INCREMENTAL_BATCH_SIZE : RENDER_BATCH_SIZE, (instance) => renderInstance(ctx, instance, shell, reuse))).flat();
5284
5380
  await writeDataSidecars(ctx, rendered, router.mode());
5285
5381
  ctx.log.debug("build:pages", { count: rendered.length });
5286
5382
  return {
package/package.json CHANGED
@@ -113,5 +113,5 @@
113
113
  "test:cli-e2e": "bun test src/plugins/cli/__tests__/e2e/",
114
114
  "test:coverage": "vitest run --project unit --project integration --coverage"
115
115
  },
116
- "version": "1.5.2"
116
+ "version": "1.6.0"
117
117
  }