@moku-labs/web 1.4.0 → 1.5.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.
@@ -557,6 +557,32 @@ type Api$3 = {
557
557
  */
558
558
  t(locale: string, key: string): string;
559
559
  };
560
+ //#endregion
561
+ //#region src/plugins/router/iso-match.d.ts
562
+ /**
563
+ * A compiled, engine-agnostic path matcher: the same `.exec({ pathname })` shape the
564
+ * router consumed from `URLPattern`, but backed by a native `RegExp` with named
565
+ * groups. Dropping `URLPattern` keeps route matching alive in every browser engine —
566
+ * Safari < 18.4 and Firefox < ~142 have no `URLPattern` global and would otherwise
567
+ * throw `ReferenceError` the instant the router compiles its table on boot.
568
+ */
569
+ interface PathMatcher {
570
+ /**
571
+ * Match a pathname, mirroring `URLPattern.exec`: the named-group bag (under
572
+ * `pathname.groups`) on a hit, or `null` on a miss.
573
+ *
574
+ * @param input - The match input carrying the `pathname` to test.
575
+ * @param input.pathname - The URL pathname to match, e.g. `/en/hello/`.
576
+ * @returns A `{ pathname: { groups } }` result on a match, or `null` on no match.
577
+ */
578
+ exec(input: {
579
+ readonly pathname: string;
580
+ }): {
581
+ readonly pathname: {
582
+ readonly groups: Record<string, string | undefined>;
583
+ };
584
+ } | null;
585
+ }
560
586
  declare namespace types_d_exports$5 {
561
587
  export { Api$2 as Api, ClientRoute, CompileInput, CompiledRoute, Config$2 as Config, ExtractApi$1 as ExtractApi, ExtractRouteParams, ExtractSegmentParameter, GenerateContext, HeadConfig$1 as HeadConfig, LayoutContext, LoadContext, MatcherTable, Prettify, RouteBuilder, RouteContext, RouteDefinition, RouteHandlers, RouteMap, RouteRequire, RouteState, RouterApi, RouterConfig, RouterState, State$2 as State, TypedRoute, Urls };
562
588
  }
@@ -836,10 +862,10 @@ interface CompiledRoute {
836
862
  readonly pattern: string;
837
863
  /** Dynamic-segment count (lower = more specific = matched first). */
838
864
  readonly dynamicSegmentCount: number;
839
- /** Pre-built URLPattern matchers (lang-aware + bare fallback). */
865
+ /** Pre-built path matchers (lang-aware + bare fallback). */
840
866
  readonly matchers: {
841
- readonly withLang: URLPattern;
842
- readonly bare: URLPattern;
867
+ readonly withLang: PathMatcher;
868
+ readonly bare: PathMatcher;
843
869
  };
844
870
  /** Resolve pathname into params (withLang first, then bare with defaultLocale injected). */
845
871
  readonly matchFn: (pathname: string) => Record<string, string> | null;
@@ -1112,6 +1138,25 @@ type Api$1 = {
1112
1138
  * ```
1113
1139
  */
1114
1140
  render(route: ResolvedRoute, data: unknown): string;
1141
+ /**
1142
+ * Compose the SITE-LEVEL `<head>` Open Graph / Twitter block for a bare-path redirect or
1143
+ * landing page that has no route identity (e.g. the apex-domain `/` redirect a
1144
+ * `localeRedirects` build emits). Returns `""` UNLESS `defaultOgImage` is configured, so
1145
+ * apps that opt out keep a bare redirect. Pulled synchronously by `build`.
1146
+ *
1147
+ * @param input - The landing URL (resolved to an absolute canonical) plus an optional locale.
1148
+ * @param input.url - The page's URL or path (absolutized via `site.canonical`) → `og:url`.
1149
+ * @param input.locale - Optional locale whose `og:locale` is emitted (e.g. the default locale).
1150
+ * @returns The serialized inner HTML of the site-level head block, or `""` when disabled.
1151
+ * @example
1152
+ * ```ts
1153
+ * api.siteHead({ url: "/en/", locale: "en" });
1154
+ * ```
1155
+ */
1156
+ siteHead(input: {
1157
+ url: string;
1158
+ locale?: string;
1159
+ }): string;
1115
1160
  };
1116
1161
  declare namespace types_d_exports$6 {
1117
1162
  export { COMPONENT_HOOK_NAMES, ComponentContext, ComponentDef, ComponentHooks, ComponentInstance, ExtractApi, PageData, ResolvedSpaConfig, SpaApi, SpaConfig, SpaContext, SpaDataReader, SpaEmitFunction, SpaEvents, SpaKernel, SpaKernelDeps, SpaRequire, SpaState };
package/dist/browser.mjs CHANGED
@@ -1331,22 +1331,101 @@ function extractGroups(groups) {
1331
1331
  }
1332
1332
  return params;
1333
1333
  }
1334
- //#endregion
1335
- //#region src/plugins/router/builders/match.ts
1334
+ /** Regex metacharacters escaped when a static path segment is inlined into a compiled pattern. */
1335
+ const REGEX_METACHARS = /[.*+?^${}()|[\]\\]/g;
1336
+ /** Matches a `:name` or `:name(regex)` URLPattern group occupying one whole segment. */
1337
+ const NAMED_GROUP = /^:([A-Za-z_]\w*)(?:\((.+)\))?$/;
1338
+ /**
1339
+ * Escape a static path segment so its literal text matches verbatim inside the
1340
+ * compiled `RegExp` (a segment like `c++` must not be read as regex syntax).
1341
+ *
1342
+ * @param text - The static segment text.
1343
+ * @returns The regex-escaped segment.
1344
+ * @example
1345
+ * ```ts
1346
+ * escapeStaticSegment("about"); // "about"
1347
+ * ```
1348
+ */
1349
+ function escapeStaticSegment(text) {
1350
+ return text.replaceAll(REGEX_METACHARS, String.raw`\$&`);
1351
+ }
1336
1352
  /**
1337
- * @file router plugin runtime matching domain.
1353
+ * Compile one URLPattern source segment (no surrounding slash) into a regex fragment
1354
+ * that captures a single path segment: `:name` → a named `[^/]+` group, `:name(re)` →
1355
+ * a named group constrained by `re`, and static text → its escaped literal.
1338
1356
  *
1339
- * Pure functions that turn compiled patterns into a pathname matcher: build the
1340
- * lang-aware/bare `URLPattern` pair, the `matchFn` (withLang first, bare fallback
1341
- * injecting `defaultLocale`), and extract/strip params. No `ctx` here.
1357
+ * @param segment - One source segment, e.g. `:slug`, `:lang(en|uk)`, or `archive`.
1358
+ * @returns The regex fragment for that segment.
1359
+ * @example
1360
+ * ```ts
1361
+ * segmentToRegex(":lang(en|uk)"); // "(?<lang>en|uk)"
1362
+ * ```
1342
1363
  */
1364
+ function segmentToRegex(segment) {
1365
+ const named = NAMED_GROUP.exec(segment);
1366
+ if (named) {
1367
+ const [, name, constraint] = named;
1368
+ return `(?<${name}>${constraint ?? "[^/]+"})`;
1369
+ }
1370
+ return escapeStaticSegment(segment);
1371
+ }
1343
1372
  /**
1344
- * Build a pathname matcher for a single route: tries the `withLang` URLPattern,
1345
- * then the `bare` pattern injecting `defaultLocale` on miss.
1373
+ * Compile a URLPattern pathname source string into a {@link PathMatcher} backed by a
1374
+ * native `RegExp` a drop-in replacement for `new URLPattern({ pathname })` over the
1375
+ * subset the router emits: `:name`, `:name(regex)`, the optional `:name?` segment
1376
+ * (whose leading `/` is absorbed, so `/:lang?` matches `/en` or nothing), static
1377
+ * segments, and a required trailing slash. Anchored full-match, like `URLPattern`.
1346
1378
  *
1347
- * @param matchers - The pre-built `withLang` and `bare` URLPattern pair.
1348
- * @param matchers.withLang - The locale-aware URLPattern variant.
1349
- * @param matchers.bare - The bare URLPattern variant (no leading locale segment).
1379
+ * @param source - The URLPattern pathname source, e.g. `/:lang?/:slug/`.
1380
+ * @returns A matcher whose `.exec({ pathname })` yields named groups or `null`.
1381
+ * @example
1382
+ * ```ts
1383
+ * const m = createPathMatcher("/:lang?/:slug/");
1384
+ * m.exec({ pathname: "/en/hello/" }); // { pathname: { groups: { lang: "en", slug: "hello" } } }
1385
+ * ```
1386
+ */
1387
+ function createPathMatcher(source) {
1388
+ const segments = source.split("/");
1389
+ let pattern = "^";
1390
+ for (let index = 1; index < segments.length; index += 1) {
1391
+ const segment = segments[index] ?? "";
1392
+ if (segment === "") {
1393
+ pattern += "/";
1394
+ continue;
1395
+ }
1396
+ const optional = segment.endsWith("?");
1397
+ const fragment = segmentToRegex(optional ? segment.slice(0, -1) : segment);
1398
+ pattern += optional ? `(?:/${fragment})?` : `/${fragment}`;
1399
+ }
1400
+ pattern += "$";
1401
+ const regexp = new RegExp(pattern);
1402
+ return {
1403
+ /**
1404
+ * Run the compiled regex over a pathname (the {@link PathMatcher.exec} contract).
1405
+ *
1406
+ * @param input - The match input carrying the `pathname` to test.
1407
+ * @param input.pathname - The URL pathname to match, e.g. `/en/hello/`.
1408
+ * @returns A `{ pathname: { groups } }` result on a match, or `null` on no match.
1409
+ * @example
1410
+ * ```ts
1411
+ * matcher.exec({ pathname: "/en/hello/" });
1412
+ * ```
1413
+ */
1414
+ exec(input) {
1415
+ const result = regexp.exec(input.pathname);
1416
+ if (!result) return null;
1417
+ return { pathname: { groups: result.groups ?? {} } };
1418
+ } };
1419
+ }
1420
+ //#endregion
1421
+ //#region src/plugins/router/builders/match.ts
1422
+ /**
1423
+ * Build a pathname matcher for a single route: tries the `withLang` matcher,
1424
+ * then the `bare` matcher injecting `defaultLocale` on miss.
1425
+ *
1426
+ * @param matchers - The pre-built `withLang` and `bare` matcher pair.
1427
+ * @param matchers.withLang - The locale-aware matcher variant.
1428
+ * @param matchers.bare - The bare matcher variant (no leading locale segment).
1350
1429
  * @param defaultLocale - Locale injected when the bare fallback matches.
1351
1430
  * @returns A function resolving a pathname into params, or `null` on no match.
1352
1431
  * @example
@@ -1390,14 +1469,6 @@ function matchRoute(compiled, pathname) {
1390
1469
  }
1391
1470
  //#endregion
1392
1471
  //#region src/plugins/router/builders/compile.ts
1393
- /**
1394
- * @file router plugin — compilation + validation domain.
1395
- *
1396
- * Pure functions invoked from `onInit`: validate the route map, then compile each
1397
- * route into URLPattern matchers + URL/file builders, count dynamic segments,
1398
- * sort by specificity, and assemble the immutable `MatcherTable`. Receives DATA
1399
- * only (`CompileInput`) — never the plugin ctx.
1400
- */
1401
1472
  /** Shared `[web]` error prefix for router validation failures. */
1402
1473
  const ERROR_PREFIX$6 = "[web] router";
1403
1474
  /** Maximum number of optional `{lang:?}` segments a single pattern may declare. */
@@ -1569,8 +1640,8 @@ function buildFilePath(pattern, params) {
1569
1640
  function buildMatchers(pattern, locales) {
1570
1641
  const langRegex = `(${locales.join("|")})`;
1571
1642
  return {
1572
- withLang: new URLPattern({ pathname: patternToUrlPattern(pattern, "withLang", langRegex) }),
1573
- bare: new URLPattern({ pathname: patternToUrlPattern(pattern, "bare", langRegex) })
1643
+ withLang: createPathMatcher(patternToUrlPattern(pattern, "withLang", langRegex)),
1644
+ bare: createPathMatcher(patternToUrlPattern(pattern, "bare", langRegex))
1574
1645
  };
1575
1646
  }
1576
1647
  /**
@@ -2443,6 +2514,42 @@ function composeHead(input) {
2443
2514
  }), ...head.elements ?? []]);
2444
2515
  }
2445
2516
  /**
2517
+ * Compose the SITE-LEVEL Open Graph / Twitter block for a bare-path redirect or landing
2518
+ * page that has no per-route head of its own. Returns `[]` UNLESS a `defaultOgImage` is
2519
+ * configured — so apps that opt out keep a bare redirect (no behavior change). The site
2520
+ * name + description become the card's title/description (`og:type=website`); `url` is the
2521
+ * canonical the page points at. A bare article/tag alias gets this site card as a fallback;
2522
+ * crawlers that honor the page's `rel=canonical` still resolve the per-route card.
2523
+ *
2524
+ * @param input - The site slice, head defaults, landing URL, and optional `og:locale`.
2525
+ * @returns The ordered site-level head element set, or `[]` when no default image is set.
2526
+ * @example composeSiteHead({ site, defaults, url: "https://blog.dev/en/", ogLocale: "en_US" })
2527
+ */
2528
+ function composeSiteHead(input) {
2529
+ const { site, defaults, url, ogLocale } = input;
2530
+ const image = defaults.defaultOgImage;
2531
+ if (image === void 0) return [];
2532
+ const absoluteImage = resolveImage(image, site);
2533
+ const name = site.name();
2534
+ const description = site.description();
2535
+ const elements = [
2536
+ meta("description", description),
2537
+ og("og:type", "website"),
2538
+ og("og:site_name", name),
2539
+ og("og:title", name),
2540
+ og("og:description", description),
2541
+ og("og:url", url),
2542
+ og("og:image", absoluteImage),
2543
+ twitter("twitter:card", defaults.twitterCard),
2544
+ twitter("twitter:title", name),
2545
+ twitter("twitter:description", description),
2546
+ twitter("twitter:image", absoluteImage)
2547
+ ];
2548
+ if (defaults.twitterHandle) elements.push(twitter("twitter:site", defaults.twitterHandle));
2549
+ if (ogLocale) elements.push(og("og:locale", ogLocale));
2550
+ return elements;
2551
+ }
2552
+ /**
2446
2553
  * HTML-escape a value for safe insertion into an attribute or text node. `&` is
2447
2554
  * escaped first so already-escaped entities are not double-escaped.
2448
2555
  *
@@ -2531,28 +2638,53 @@ function readDefaults(state) {
2531
2638
  * ```
2532
2639
  */
2533
2640
  function createApi$1(ctx) {
2534
- return {
2535
- /**
2536
- * Compose the final `<head>` inner HTML for a route (pulled by `build`).
2537
- *
2538
- * @param route - The resolved route descriptor (incl. its `.head()` HeadConfig).
2539
- * @param data - The page data object passed to the route's loader/render.
2540
- * @returns The serialized inner HTML of `<head>`.
2541
- * @example
2542
- * ```ts
2543
- * api.render(route, { title: "Post" });
2544
- * ```
2545
- */
2546
- render(route, data) {
2547
- return serializeHead(composeHead({
2548
- route,
2549
- data,
2550
- defaults: readDefaults(ctx.state),
2551
- site: ctx.require(sitePlugin),
2552
- i18n: ctx.require(i18nPlugin),
2553
- router: ctx.require(routerPlugin)
2554
- }));
2555
- } };
2641
+ return {
2642
+ /**
2643
+ * Compose the final `<head>` inner HTML for a route (pulled by `build`).
2644
+ *
2645
+ * @param route - The resolved route descriptor (incl. its `.head()` HeadConfig).
2646
+ * @param data - The page data object passed to the route's loader/render.
2647
+ * @returns The serialized inner HTML of `<head>`.
2648
+ * @example
2649
+ * ```ts
2650
+ * api.render(route, { title: "Post" });
2651
+ * ```
2652
+ */
2653
+ render(route, data) {
2654
+ return serializeHead(composeHead({
2655
+ route,
2656
+ data,
2657
+ defaults: readDefaults(ctx.state),
2658
+ site: ctx.require(sitePlugin),
2659
+ i18n: ctx.require(i18nPlugin),
2660
+ router: ctx.require(routerPlugin)
2661
+ }));
2662
+ },
2663
+ /**
2664
+ * Compose the site-level OG/Twitter block for a bare-path redirect/landing page. Resolves
2665
+ * `site`/`i18n` via `ctx.require`, absolutizes `url` against the site base, and emits an
2666
+ * `og:locale` for `locale` when supplied. Returns `""` when no `defaultOgImage` is configured.
2667
+ *
2668
+ * @param input - The landing URL/path plus an optional locale (for `og:locale`).
2669
+ * @param input.url - The page's URL or path (absolutized via `site.canonical`) → `og:url`.
2670
+ * @param input.locale - Optional locale whose `og:locale` is emitted.
2671
+ * @returns The serialized inner HTML of the site-level head block, or `""` when disabled.
2672
+ * @example
2673
+ * ```ts
2674
+ * api.siteHead({ url: "/en/", locale: "en" });
2675
+ * ```
2676
+ */
2677
+ siteHead(input) {
2678
+ const site = ctx.require(sitePlugin);
2679
+ const ogLocale = input.locale === void 0 ? void 0 : ctx.require(i18nPlugin).ogLocale(input.locale);
2680
+ return serializeHead(composeSiteHead({
2681
+ site,
2682
+ defaults: readDefaults(ctx.state),
2683
+ url: site.canonical(input.url),
2684
+ ...ogLocale === void 0 ? {} : { ogLocale }
2685
+ }));
2686
+ }
2687
+ };
2556
2688
  }
2557
2689
  //#endregion
2558
2690
  //#region src/plugins/head/config.ts
package/dist/index.cjs CHANGED
@@ -1889,22 +1889,101 @@ function extractGroups(groups) {
1889
1889
  }
1890
1890
  return params;
1891
1891
  }
1892
- //#endregion
1893
- //#region src/plugins/router/builders/match.ts
1892
+ /** Regex metacharacters escaped when a static path segment is inlined into a compiled pattern. */
1893
+ const REGEX_METACHARS = /[.*+?^${}()|[\]\\]/g;
1894
+ /** Matches a `:name` or `:name(regex)` URLPattern group occupying one whole segment. */
1895
+ const NAMED_GROUP = /^:([A-Za-z_]\w*)(?:\((.+)\))?$/;
1894
1896
  /**
1895
- * @file router plugin runtime matching domain.
1897
+ * Escape a static path segment so its literal text matches verbatim inside the
1898
+ * compiled `RegExp` (a segment like `c++` must not be read as regex syntax).
1896
1899
  *
1897
- * Pure functions that turn compiled patterns into a pathname matcher: build the
1898
- * lang-aware/bare `URLPattern` pair, the `matchFn` (withLang first, bare fallback
1899
- * injecting `defaultLocale`), and extract/strip params. No `ctx` here.
1900
+ * @param text - The static segment text.
1901
+ * @returns The regex-escaped segment.
1902
+ * @example
1903
+ * ```ts
1904
+ * escapeStaticSegment("about"); // "about"
1905
+ * ```
1900
1906
  */
1907
+ function escapeStaticSegment(text) {
1908
+ return text.replaceAll(REGEX_METACHARS, String.raw`\$&`);
1909
+ }
1901
1910
  /**
1902
- * Build a pathname matcher for a single route: tries the `withLang` URLPattern,
1903
- * then the `bare` pattern injecting `defaultLocale` on miss.
1911
+ * Compile one URLPattern source segment (no surrounding slash) into a regex fragment
1912
+ * that captures a single path segment: `:name` a named `[^/]+` group, `:name(re)`
1913
+ * a named group constrained by `re`, and static text → its escaped literal.
1904
1914
  *
1905
- * @param matchers - The pre-built `withLang` and `bare` URLPattern pair.
1906
- * @param matchers.withLang - The locale-aware URLPattern variant.
1907
- * @param matchers.bare - The bare URLPattern variant (no leading locale segment).
1915
+ * @param segment - One source segment, e.g. `:slug`, `:lang(en|uk)`, or `archive`.
1916
+ * @returns The regex fragment for that segment.
1917
+ * @example
1918
+ * ```ts
1919
+ * segmentToRegex(":lang(en|uk)"); // "(?<lang>en|uk)"
1920
+ * ```
1921
+ */
1922
+ function segmentToRegex(segment) {
1923
+ const named = NAMED_GROUP.exec(segment);
1924
+ if (named) {
1925
+ const [, name, constraint] = named;
1926
+ return `(?<${name}>${constraint ?? "[^/]+"})`;
1927
+ }
1928
+ return escapeStaticSegment(segment);
1929
+ }
1930
+ /**
1931
+ * Compile a URLPattern pathname source string into a {@link PathMatcher} backed by a
1932
+ * native `RegExp` — a drop-in replacement for `new URLPattern({ pathname })` over the
1933
+ * subset the router emits: `:name`, `:name(regex)`, the optional `:name?` segment
1934
+ * (whose leading `/` is absorbed, so `/:lang?` matches `/en` or nothing), static
1935
+ * segments, and a required trailing slash. Anchored full-match, like `URLPattern`.
1936
+ *
1937
+ * @param source - The URLPattern pathname source, e.g. `/:lang?/:slug/`.
1938
+ * @returns A matcher whose `.exec({ pathname })` yields named groups or `null`.
1939
+ * @example
1940
+ * ```ts
1941
+ * const m = createPathMatcher("/:lang?/:slug/");
1942
+ * m.exec({ pathname: "/en/hello/" }); // { pathname: { groups: { lang: "en", slug: "hello" } } }
1943
+ * ```
1944
+ */
1945
+ function createPathMatcher(source) {
1946
+ const segments = source.split("/");
1947
+ let pattern = "^";
1948
+ for (let index = 1; index < segments.length; index += 1) {
1949
+ const segment = segments[index] ?? "";
1950
+ if (segment === "") {
1951
+ pattern += "/";
1952
+ continue;
1953
+ }
1954
+ const optional = segment.endsWith("?");
1955
+ const fragment = segmentToRegex(optional ? segment.slice(0, -1) : segment);
1956
+ pattern += optional ? `(?:/${fragment})?` : `/${fragment}`;
1957
+ }
1958
+ pattern += "$";
1959
+ const regexp = new RegExp(pattern);
1960
+ return {
1961
+ /**
1962
+ * Run the compiled regex over a pathname (the {@link PathMatcher.exec} contract).
1963
+ *
1964
+ * @param input - The match input carrying the `pathname` to test.
1965
+ * @param input.pathname - The URL pathname to match, e.g. `/en/hello/`.
1966
+ * @returns A `{ pathname: { groups } }` result on a match, or `null` on no match.
1967
+ * @example
1968
+ * ```ts
1969
+ * matcher.exec({ pathname: "/en/hello/" });
1970
+ * ```
1971
+ */
1972
+ exec(input) {
1973
+ const result = regexp.exec(input.pathname);
1974
+ if (!result) return null;
1975
+ return { pathname: { groups: result.groups ?? {} } };
1976
+ } };
1977
+ }
1978
+ //#endregion
1979
+ //#region src/plugins/router/builders/match.ts
1980
+ /**
1981
+ * Build a pathname matcher for a single route: tries the `withLang` matcher,
1982
+ * then the `bare` matcher injecting `defaultLocale` on miss.
1983
+ *
1984
+ * @param matchers - The pre-built `withLang` and `bare` matcher pair.
1985
+ * @param matchers.withLang - The locale-aware matcher variant.
1986
+ * @param matchers.bare - The bare matcher variant (no leading locale segment).
1908
1987
  * @param defaultLocale - Locale injected when the bare fallback matches.
1909
1988
  * @returns A function resolving a pathname into params, or `null` on no match.
1910
1989
  * @example
@@ -1948,14 +2027,6 @@ function matchRoute(compiled, pathname) {
1948
2027
  }
1949
2028
  //#endregion
1950
2029
  //#region src/plugins/router/builders/compile.ts
1951
- /**
1952
- * @file router plugin — compilation + validation domain.
1953
- *
1954
- * Pure functions invoked from `onInit`: validate the route map, then compile each
1955
- * route into URLPattern matchers + URL/file builders, count dynamic segments,
1956
- * sort by specificity, and assemble the immutable `MatcherTable`. Receives DATA
1957
- * only (`CompileInput`) — never the plugin ctx.
1958
- */
1959
2030
  /** Shared `[web]` error prefix for router validation failures. */
1960
2031
  const ERROR_PREFIX$11 = "[web] router";
1961
2032
  /** Maximum number of optional `{lang:?}` segments a single pattern may declare. */
@@ -2127,8 +2198,8 @@ function buildFilePath(pattern, params) {
2127
2198
  function buildMatchers(pattern, locales) {
2128
2199
  const langRegex = `(${locales.join("|")})`;
2129
2200
  return {
2130
- withLang: new URLPattern({ pathname: patternToUrlPattern(pattern, "withLang", langRegex) }),
2131
- bare: new URLPattern({ pathname: patternToUrlPattern(pattern, "bare", langRegex) })
2201
+ withLang: createPathMatcher(patternToUrlPattern(pattern, "withLang", langRegex)),
2202
+ bare: createPathMatcher(patternToUrlPattern(pattern, "bare", langRegex))
2132
2203
  };
2133
2204
  }
2134
2205
  /**
@@ -3001,6 +3072,42 @@ function composeHead(input) {
3001
3072
  }), ...head.elements ?? []]);
3002
3073
  }
3003
3074
  /**
3075
+ * Compose the SITE-LEVEL Open Graph / Twitter block for a bare-path redirect or landing
3076
+ * page that has no per-route head of its own. Returns `[]` UNLESS a `defaultOgImage` is
3077
+ * configured — so apps that opt out keep a bare redirect (no behavior change). The site
3078
+ * name + description become the card's title/description (`og:type=website`); `url` is the
3079
+ * canonical the page points at. A bare article/tag alias gets this site card as a fallback;
3080
+ * crawlers that honor the page's `rel=canonical` still resolve the per-route card.
3081
+ *
3082
+ * @param input - The site slice, head defaults, landing URL, and optional `og:locale`.
3083
+ * @returns The ordered site-level head element set, or `[]` when no default image is set.
3084
+ * @example composeSiteHead({ site, defaults, url: "https://blog.dev/en/", ogLocale: "en_US" })
3085
+ */
3086
+ function composeSiteHead(input) {
3087
+ const { site, defaults, url, ogLocale } = input;
3088
+ const image = defaults.defaultOgImage;
3089
+ if (image === void 0) return [];
3090
+ const absoluteImage = resolveImage(image, site);
3091
+ const name = site.name();
3092
+ const description = site.description();
3093
+ const elements = [
3094
+ meta("description", description),
3095
+ og("og:type", "website"),
3096
+ og("og:site_name", name),
3097
+ og("og:title", name),
3098
+ og("og:description", description),
3099
+ og("og:url", url),
3100
+ og("og:image", absoluteImage),
3101
+ twitter("twitter:card", defaults.twitterCard),
3102
+ twitter("twitter:title", name),
3103
+ twitter("twitter:description", description),
3104
+ twitter("twitter:image", absoluteImage)
3105
+ ];
3106
+ if (defaults.twitterHandle) elements.push(twitter("twitter:site", defaults.twitterHandle));
3107
+ if (ogLocale) elements.push(og("og:locale", ogLocale));
3108
+ return elements;
3109
+ }
3110
+ /**
3004
3111
  * HTML-escape a value for safe insertion into an attribute or text node. `&` is
3005
3112
  * escaped first so already-escaped entities are not double-escaped.
3006
3113
  *
@@ -3089,28 +3196,53 @@ function readDefaults(state) {
3089
3196
  * ```
3090
3197
  */
3091
3198
  function createApi$4(ctx) {
3092
- return {
3093
- /**
3094
- * Compose the final `<head>` inner HTML for a route (pulled by `build`).
3095
- *
3096
- * @param route - The resolved route descriptor (incl. its `.head()` HeadConfig).
3097
- * @param data - The page data object passed to the route's loader/render.
3098
- * @returns The serialized inner HTML of `<head>`.
3099
- * @example
3100
- * ```ts
3101
- * api.render(route, { title: "Post" });
3102
- * ```
3103
- */
3104
- render(route, data) {
3105
- return serializeHead(composeHead({
3106
- route,
3107
- data,
3108
- defaults: readDefaults(ctx.state),
3109
- site: ctx.require(sitePlugin),
3110
- i18n: ctx.require(i18nPlugin),
3111
- router: ctx.require(routerPlugin)
3112
- }));
3113
- } };
3199
+ return {
3200
+ /**
3201
+ * Compose the final `<head>` inner HTML for a route (pulled by `build`).
3202
+ *
3203
+ * @param route - The resolved route descriptor (incl. its `.head()` HeadConfig).
3204
+ * @param data - The page data object passed to the route's loader/render.
3205
+ * @returns The serialized inner HTML of `<head>`.
3206
+ * @example
3207
+ * ```ts
3208
+ * api.render(route, { title: "Post" });
3209
+ * ```
3210
+ */
3211
+ render(route, data) {
3212
+ return serializeHead(composeHead({
3213
+ route,
3214
+ data,
3215
+ defaults: readDefaults(ctx.state),
3216
+ site: ctx.require(sitePlugin),
3217
+ i18n: ctx.require(i18nPlugin),
3218
+ router: ctx.require(routerPlugin)
3219
+ }));
3220
+ },
3221
+ /**
3222
+ * Compose the site-level OG/Twitter block for a bare-path redirect/landing page. Resolves
3223
+ * `site`/`i18n` via `ctx.require`, absolutizes `url` against the site base, and emits an
3224
+ * `og:locale` for `locale` when supplied. Returns `""` when no `defaultOgImage` is configured.
3225
+ *
3226
+ * @param input - The landing URL/path plus an optional locale (for `og:locale`).
3227
+ * @param input.url - The page's URL or path (absolutized via `site.canonical`) → `og:url`.
3228
+ * @param input.locale - Optional locale whose `og:locale` is emitted.
3229
+ * @returns The serialized inner HTML of the site-level head block, or `""` when disabled.
3230
+ * @example
3231
+ * ```ts
3232
+ * api.siteHead({ url: "/en/", locale: "en" });
3233
+ * ```
3234
+ */
3235
+ siteHead(input) {
3236
+ const site = ctx.require(sitePlugin);
3237
+ const ogLocale = input.locale === void 0 ? void 0 : ctx.require(i18nPlugin).ogLocale(input.locale);
3238
+ return serializeHead(composeSiteHead({
3239
+ site,
3240
+ defaults: readDefaults(ctx.state),
3241
+ url: site.canonical(input.url),
3242
+ ...ogLocale === void 0 ? {} : { ogLocale }
3243
+ }));
3244
+ }
3245
+ };
3114
3246
  }
3115
3247
  //#endregion
3116
3248
  //#region src/plugins/head/config.ts
@@ -3683,19 +3815,26 @@ async function processImages(ctx, options = {}) {
3683
3815
  * bare path that points at the default-locale-prefixed URL. Deliberately does NOT
3684
3816
  * emit a Cloudflare `_redirects` catch-all (an SSG infinite-loop trap). Gated by
3685
3817
  * `config.localeRedirects` (false/unset disables).
3818
+ *
3819
+ * When `head.defaultOgImage` is configured, each redirect page ALSO carries the
3820
+ * site-level Open Graph / Twitter block (`head.siteHead`) so a social crawler that
3821
+ * fetches the apex domain (or any locale-less alias) — and does not follow the
3822
+ * meta-refresh — still gets a branded preview card. No image configured ⇒ bare redirect.
3686
3823
  */
3687
3824
  /**
3688
- * Render a redirect HTML page: a `0;url` refresh meta + a canonical link to `target`.
3825
+ * Render a redirect HTML page: a `0;url` refresh meta + a canonical link to `target`,
3826
+ * with an optional site-level OG/Twitter block injected at the end of `<head>`.
3689
3827
  *
3690
3828
  * @param target - The default-locale-prefixed URL to redirect to.
3829
+ * @param headExtra - Extra `<head>` inner HTML (the site-level OG block), or `""` for none.
3691
3830
  * @returns The complete redirect HTML document string.
3692
3831
  * @example
3693
3832
  * ```ts
3694
- * redirectHtml("/en/about/");
3833
+ * redirectHtml("/en/about/", '<meta property="og:image" content="…">');
3695
3834
  * ```
3696
3835
  */
3697
- function redirectHtml(target) {
3698
- return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="refresh" content="0;url=${target}"><link rel="canonical" href="${target}"></head><body><a href="${target}">Redirecting…</a></body></html>`;
3836
+ function redirectHtml(target, headExtra = "") {
3837
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="refresh" content="0;url=${target}"><link rel="canonical" href="${target}">${headExtra}</head><body><a href="${target}">Redirecting…</a></body></html>`;
3699
3838
  }
3700
3839
  /**
3701
3840
  * Correlate manifest definitions to compiled `TypedRoute` entries by pattern (the
@@ -3787,16 +3926,17 @@ async function expandRedirects(definition, entry, defaultLocale, ctx) {
3787
3926
  * @param job.file - The redirect page's output path, relative to `outDir`.
3788
3927
  * @param job.target - The absolute default-locale URL the page redirects to.
3789
3928
  * @param outDir - The build output directory the file is resolved against.
3929
+ * @param headExtra - The site-level OG block to inject into `<head>`, or `""` for none.
3790
3930
  * @returns Resolves once the redirect HTML page is written.
3791
3931
  * @example
3792
3932
  * ```ts
3793
- * await writeRedirectFile({ file: "about/index.html", target: "/en/about/" }, "dist");
3933
+ * await writeRedirectFile({ file: "about/index.html", target: "/en/about/" }, "dist", "");
3794
3934
  * ```
3795
3935
  */
3796
- async function writeRedirectFile(job, outDir) {
3936
+ async function writeRedirectFile(job, outDir, headExtra = "") {
3797
3937
  const filePath = node_path$1.default.join(outDir, job.file);
3798
3938
  await (0, node_fs_promises.mkdir)(node_path$1.default.dirname(filePath), { recursive: true });
3799
- await (0, node_fs_promises.writeFile)(filePath, redirectHtml(job.target), "utf8");
3939
+ await (0, node_fs_promises.writeFile)(filePath, redirectHtml(job.target, headExtra), "utf8");
3800
3940
  }
3801
3941
  /**
3802
3942
  * Emits one bare-path redirect HTML page per locale-prefixed route path, each a
@@ -3819,7 +3959,14 @@ async function generateLocaleRedirects(ctx) {
3819
3959
  const router = ctx.require(routerPlugin);
3820
3960
  const defaultLocale = ctx.require(i18nPlugin).defaultLocale();
3821
3961
  const jobs = (await Promise.all(pairRoutes(router).map(([definition, entry]) => expandRedirects(definition, entry, defaultLocale, ctx)))).flat();
3822
- await Promise.all(jobs.map((job) => writeRedirectFile(job, ctx.config.outDir)));
3962
+ const head = ctx.has("head") ? ctx.require(headPlugin) : void 0;
3963
+ await Promise.all(jobs.map((job) => {
3964
+ const headExtra = head ? head.siteHead({
3965
+ url: job.target,
3966
+ locale: defaultLocale
3967
+ }) : "";
3968
+ return writeRedirectFile(job, ctx.config.outDir, headExtra);
3969
+ }));
3823
3970
  ctx.log.debug("build:locale-redirects", { written: jobs.length });
3824
3971
  return { written: jobs.length };
3825
3972
  }
package/dist/index.d.cts CHANGED
@@ -557,6 +557,32 @@ type Api$6 = {
557
557
  */
558
558
  t(locale: string, key: string): string;
559
559
  };
560
+ //#endregion
561
+ //#region src/plugins/router/iso-match.d.ts
562
+ /**
563
+ * A compiled, engine-agnostic path matcher: the same `.exec({ pathname })` shape the
564
+ * router consumed from `URLPattern`, but backed by a native `RegExp` with named
565
+ * groups. Dropping `URLPattern` keeps route matching alive in every browser engine —
566
+ * Safari < 18.4 and Firefox < ~142 have no `URLPattern` global and would otherwise
567
+ * throw `ReferenceError` the instant the router compiles its table on boot.
568
+ */
569
+ interface PathMatcher {
570
+ /**
571
+ * Match a pathname, mirroring `URLPattern.exec`: the named-group bag (under
572
+ * `pathname.groups`) on a hit, or `null` on a miss.
573
+ *
574
+ * @param input - The match input carrying the `pathname` to test.
575
+ * @param input.pathname - The URL pathname to match, e.g. `/en/hello/`.
576
+ * @returns A `{ pathname: { groups } }` result on a match, or `null` on no match.
577
+ */
578
+ exec(input: {
579
+ readonly pathname: string;
580
+ }): {
581
+ readonly pathname: {
582
+ readonly groups: Record<string, string | undefined>;
583
+ };
584
+ } | null;
585
+ }
560
586
  declare namespace types_d_exports$8 {
561
587
  export { Api$5 as Api, ClientRoute, CompileInput, CompiledRoute, Config$5 as Config, ExtractApi$2 as ExtractApi, ExtractRouteParams, ExtractSegmentParameter, GenerateContext, HeadConfig$1 as HeadConfig, LayoutContext, LoadContext, MatcherTable, Prettify, RouteBuilder, RouteContext, RouteDefinition, RouteHandlers, RouteMap, RouteRequire, RouteState, RouterApi, RouterConfig, RouterState, State$5 as State, TypedRoute, Urls };
562
588
  }
@@ -836,10 +862,10 @@ interface CompiledRoute {
836
862
  readonly pattern: string;
837
863
  /** Dynamic-segment count (lower = more specific = matched first). */
838
864
  readonly dynamicSegmentCount: number;
839
- /** Pre-built URLPattern matchers (lang-aware + bare fallback). */
865
+ /** Pre-built path matchers (lang-aware + bare fallback). */
840
866
  readonly matchers: {
841
- readonly withLang: URLPattern;
842
- readonly bare: URLPattern;
867
+ readonly withLang: PathMatcher;
868
+ readonly bare: PathMatcher;
843
869
  };
844
870
  /** Resolve pathname into params (withLang first, then bare with defaultLocale injected). */
845
871
  readonly matchFn: (pathname: string) => Record<string, string> | null;
@@ -1112,6 +1138,25 @@ type Api$4 = {
1112
1138
  * ```
1113
1139
  */
1114
1140
  render(route: ResolvedRoute, data: unknown): string;
1141
+ /**
1142
+ * Compose the SITE-LEVEL `<head>` Open Graph / Twitter block for a bare-path redirect or
1143
+ * landing page that has no route identity (e.g. the apex-domain `/` redirect a
1144
+ * `localeRedirects` build emits). Returns `""` UNLESS `defaultOgImage` is configured, so
1145
+ * apps that opt out keep a bare redirect. Pulled synchronously by `build`.
1146
+ *
1147
+ * @param input - The landing URL (resolved to an absolute canonical) plus an optional locale.
1148
+ * @param input.url - The page's URL or path (absolutized via `site.canonical`) → `og:url`.
1149
+ * @param input.locale - Optional locale whose `og:locale` is emitted (e.g. the default locale).
1150
+ * @returns The serialized inner HTML of the site-level head block, or `""` when disabled.
1151
+ * @example
1152
+ * ```ts
1153
+ * api.siteHead({ url: "/en/", locale: "en" });
1154
+ * ```
1155
+ */
1156
+ siteHead(input: {
1157
+ url: string;
1158
+ locale?: string;
1159
+ }): string;
1115
1160
  };
1116
1161
  declare namespace types_d_exports$9 {
1117
1162
  export { COMPONENT_HOOK_NAMES, ComponentContext, ComponentDef, ComponentHooks, ComponentInstance, ExtractApi$1 as ExtractApi, PageData, ResolvedSpaConfig, SpaApi, SpaConfig, SpaContext, SpaDataReader, SpaEmitFunction, SpaEvents, SpaKernel, SpaKernelDeps, SpaRequire, SpaState };
package/dist/index.d.mts CHANGED
@@ -557,6 +557,32 @@ type Api$6 = {
557
557
  */
558
558
  t(locale: string, key: string): string;
559
559
  };
560
+ //#endregion
561
+ //#region src/plugins/router/iso-match.d.ts
562
+ /**
563
+ * A compiled, engine-agnostic path matcher: the same `.exec({ pathname })` shape the
564
+ * router consumed from `URLPattern`, but backed by a native `RegExp` with named
565
+ * groups. Dropping `URLPattern` keeps route matching alive in every browser engine —
566
+ * Safari < 18.4 and Firefox < ~142 have no `URLPattern` global and would otherwise
567
+ * throw `ReferenceError` the instant the router compiles its table on boot.
568
+ */
569
+ interface PathMatcher {
570
+ /**
571
+ * Match a pathname, mirroring `URLPattern.exec`: the named-group bag (under
572
+ * `pathname.groups`) on a hit, or `null` on a miss.
573
+ *
574
+ * @param input - The match input carrying the `pathname` to test.
575
+ * @param input.pathname - The URL pathname to match, e.g. `/en/hello/`.
576
+ * @returns A `{ pathname: { groups } }` result on a match, or `null` on no match.
577
+ */
578
+ exec(input: {
579
+ readonly pathname: string;
580
+ }): {
581
+ readonly pathname: {
582
+ readonly groups: Record<string, string | undefined>;
583
+ };
584
+ } | null;
585
+ }
560
586
  declare namespace types_d_exports$8 {
561
587
  export { Api$5 as Api, ClientRoute, CompileInput, CompiledRoute, Config$5 as Config, ExtractApi$2 as ExtractApi, ExtractRouteParams, ExtractSegmentParameter, GenerateContext, HeadConfig$1 as HeadConfig, LayoutContext, LoadContext, MatcherTable, Prettify, RouteBuilder, RouteContext, RouteDefinition, RouteHandlers, RouteMap, RouteRequire, RouteState, RouterApi, RouterConfig, RouterState, State$5 as State, TypedRoute, Urls };
562
588
  }
@@ -836,10 +862,10 @@ interface CompiledRoute {
836
862
  readonly pattern: string;
837
863
  /** Dynamic-segment count (lower = more specific = matched first). */
838
864
  readonly dynamicSegmentCount: number;
839
- /** Pre-built URLPattern matchers (lang-aware + bare fallback). */
865
+ /** Pre-built path matchers (lang-aware + bare fallback). */
840
866
  readonly matchers: {
841
- readonly withLang: URLPattern;
842
- readonly bare: URLPattern;
867
+ readonly withLang: PathMatcher;
868
+ readonly bare: PathMatcher;
843
869
  };
844
870
  /** Resolve pathname into params (withLang first, then bare with defaultLocale injected). */
845
871
  readonly matchFn: (pathname: string) => Record<string, string> | null;
@@ -1112,6 +1138,25 @@ type Api$4 = {
1112
1138
  * ```
1113
1139
  */
1114
1140
  render(route: ResolvedRoute, data: unknown): string;
1141
+ /**
1142
+ * Compose the SITE-LEVEL `<head>` Open Graph / Twitter block for a bare-path redirect or
1143
+ * landing page that has no route identity (e.g. the apex-domain `/` redirect a
1144
+ * `localeRedirects` build emits). Returns `""` UNLESS `defaultOgImage` is configured, so
1145
+ * apps that opt out keep a bare redirect. Pulled synchronously by `build`.
1146
+ *
1147
+ * @param input - The landing URL (resolved to an absolute canonical) plus an optional locale.
1148
+ * @param input.url - The page's URL or path (absolutized via `site.canonical`) → `og:url`.
1149
+ * @param input.locale - Optional locale whose `og:locale` is emitted (e.g. the default locale).
1150
+ * @returns The serialized inner HTML of the site-level head block, or `""` when disabled.
1151
+ * @example
1152
+ * ```ts
1153
+ * api.siteHead({ url: "/en/", locale: "en" });
1154
+ * ```
1155
+ */
1156
+ siteHead(input: {
1157
+ url: string;
1158
+ locale?: string;
1159
+ }): string;
1115
1160
  };
1116
1161
  declare namespace types_d_exports$9 {
1117
1162
  export { COMPONENT_HOOK_NAMES, ComponentContext, ComponentDef, ComponentHooks, ComponentInstance, ExtractApi$1 as ExtractApi, PageData, ResolvedSpaConfig, SpaApi, SpaConfig, SpaContext, SpaDataReader, SpaEmitFunction, SpaEvents, SpaKernel, SpaKernelDeps, SpaRequire, SpaState };
package/dist/index.mjs CHANGED
@@ -1876,22 +1876,101 @@ function extractGroups(groups) {
1876
1876
  }
1877
1877
  return params;
1878
1878
  }
1879
- //#endregion
1880
- //#region src/plugins/router/builders/match.ts
1879
+ /** Regex metacharacters escaped when a static path segment is inlined into a compiled pattern. */
1880
+ const REGEX_METACHARS = /[.*+?^${}()|[\]\\]/g;
1881
+ /** Matches a `:name` or `:name(regex)` URLPattern group occupying one whole segment. */
1882
+ const NAMED_GROUP = /^:([A-Za-z_]\w*)(?:\((.+)\))?$/;
1881
1883
  /**
1882
- * @file router plugin runtime matching domain.
1884
+ * Escape a static path segment so its literal text matches verbatim inside the
1885
+ * compiled `RegExp` (a segment like `c++` must not be read as regex syntax).
1883
1886
  *
1884
- * Pure functions that turn compiled patterns into a pathname matcher: build the
1885
- * lang-aware/bare `URLPattern` pair, the `matchFn` (withLang first, bare fallback
1886
- * injecting `defaultLocale`), and extract/strip params. No `ctx` here.
1887
+ * @param text - The static segment text.
1888
+ * @returns The regex-escaped segment.
1889
+ * @example
1890
+ * ```ts
1891
+ * escapeStaticSegment("about"); // "about"
1892
+ * ```
1887
1893
  */
1894
+ function escapeStaticSegment(text) {
1895
+ return text.replaceAll(REGEX_METACHARS, String.raw`\$&`);
1896
+ }
1888
1897
  /**
1889
- * Build a pathname matcher for a single route: tries the `withLang` URLPattern,
1890
- * then the `bare` pattern injecting `defaultLocale` on miss.
1898
+ * Compile one URLPattern source segment (no surrounding slash) into a regex fragment
1899
+ * that captures a single path segment: `:name` a named `[^/]+` group, `:name(re)`
1900
+ * a named group constrained by `re`, and static text → its escaped literal.
1891
1901
  *
1892
- * @param matchers - The pre-built `withLang` and `bare` URLPattern pair.
1893
- * @param matchers.withLang - The locale-aware URLPattern variant.
1894
- * @param matchers.bare - The bare URLPattern variant (no leading locale segment).
1902
+ * @param segment - One source segment, e.g. `:slug`, `:lang(en|uk)`, or `archive`.
1903
+ * @returns The regex fragment for that segment.
1904
+ * @example
1905
+ * ```ts
1906
+ * segmentToRegex(":lang(en|uk)"); // "(?<lang>en|uk)"
1907
+ * ```
1908
+ */
1909
+ function segmentToRegex(segment) {
1910
+ const named = NAMED_GROUP.exec(segment);
1911
+ if (named) {
1912
+ const [, name, constraint] = named;
1913
+ return `(?<${name}>${constraint ?? "[^/]+"})`;
1914
+ }
1915
+ return escapeStaticSegment(segment);
1916
+ }
1917
+ /**
1918
+ * Compile a URLPattern pathname source string into a {@link PathMatcher} backed by a
1919
+ * native `RegExp` — a drop-in replacement for `new URLPattern({ pathname })` over the
1920
+ * subset the router emits: `:name`, `:name(regex)`, the optional `:name?` segment
1921
+ * (whose leading `/` is absorbed, so `/:lang?` matches `/en` or nothing), static
1922
+ * segments, and a required trailing slash. Anchored full-match, like `URLPattern`.
1923
+ *
1924
+ * @param source - The URLPattern pathname source, e.g. `/:lang?/:slug/`.
1925
+ * @returns A matcher whose `.exec({ pathname })` yields named groups or `null`.
1926
+ * @example
1927
+ * ```ts
1928
+ * const m = createPathMatcher("/:lang?/:slug/");
1929
+ * m.exec({ pathname: "/en/hello/" }); // { pathname: { groups: { lang: "en", slug: "hello" } } }
1930
+ * ```
1931
+ */
1932
+ function createPathMatcher(source) {
1933
+ const segments = source.split("/");
1934
+ let pattern = "^";
1935
+ for (let index = 1; index < segments.length; index += 1) {
1936
+ const segment = segments[index] ?? "";
1937
+ if (segment === "") {
1938
+ pattern += "/";
1939
+ continue;
1940
+ }
1941
+ const optional = segment.endsWith("?");
1942
+ const fragment = segmentToRegex(optional ? segment.slice(0, -1) : segment);
1943
+ pattern += optional ? `(?:/${fragment})?` : `/${fragment}`;
1944
+ }
1945
+ pattern += "$";
1946
+ const regexp = new RegExp(pattern);
1947
+ return {
1948
+ /**
1949
+ * Run the compiled regex over a pathname (the {@link PathMatcher.exec} contract).
1950
+ *
1951
+ * @param input - The match input carrying the `pathname` to test.
1952
+ * @param input.pathname - The URL pathname to match, e.g. `/en/hello/`.
1953
+ * @returns A `{ pathname: { groups } }` result on a match, or `null` on no match.
1954
+ * @example
1955
+ * ```ts
1956
+ * matcher.exec({ pathname: "/en/hello/" });
1957
+ * ```
1958
+ */
1959
+ exec(input) {
1960
+ const result = regexp.exec(input.pathname);
1961
+ if (!result) return null;
1962
+ return { pathname: { groups: result.groups ?? {} } };
1963
+ } };
1964
+ }
1965
+ //#endregion
1966
+ //#region src/plugins/router/builders/match.ts
1967
+ /**
1968
+ * Build a pathname matcher for a single route: tries the `withLang` matcher,
1969
+ * then the `bare` matcher injecting `defaultLocale` on miss.
1970
+ *
1971
+ * @param matchers - The pre-built `withLang` and `bare` matcher pair.
1972
+ * @param matchers.withLang - The locale-aware matcher variant.
1973
+ * @param matchers.bare - The bare matcher variant (no leading locale segment).
1895
1974
  * @param defaultLocale - Locale injected when the bare fallback matches.
1896
1975
  * @returns A function resolving a pathname into params, or `null` on no match.
1897
1976
  * @example
@@ -1935,14 +2014,6 @@ function matchRoute(compiled, pathname) {
1935
2014
  }
1936
2015
  //#endregion
1937
2016
  //#region src/plugins/router/builders/compile.ts
1938
- /**
1939
- * @file router plugin — compilation + validation domain.
1940
- *
1941
- * Pure functions invoked from `onInit`: validate the route map, then compile each
1942
- * route into URLPattern matchers + URL/file builders, count dynamic segments,
1943
- * sort by specificity, and assemble the immutable `MatcherTable`. Receives DATA
1944
- * only (`CompileInput`) — never the plugin ctx.
1945
- */
1946
2017
  /** Shared `[web]` error prefix for router validation failures. */
1947
2018
  const ERROR_PREFIX$11 = "[web] router";
1948
2019
  /** Maximum number of optional `{lang:?}` segments a single pattern may declare. */
@@ -2114,8 +2185,8 @@ function buildFilePath(pattern, params) {
2114
2185
  function buildMatchers(pattern, locales) {
2115
2186
  const langRegex = `(${locales.join("|")})`;
2116
2187
  return {
2117
- withLang: new URLPattern({ pathname: patternToUrlPattern(pattern, "withLang", langRegex) }),
2118
- bare: new URLPattern({ pathname: patternToUrlPattern(pattern, "bare", langRegex) })
2188
+ withLang: createPathMatcher(patternToUrlPattern(pattern, "withLang", langRegex)),
2189
+ bare: createPathMatcher(patternToUrlPattern(pattern, "bare", langRegex))
2119
2190
  };
2120
2191
  }
2121
2192
  /**
@@ -2988,6 +3059,42 @@ function composeHead(input) {
2988
3059
  }), ...head.elements ?? []]);
2989
3060
  }
2990
3061
  /**
3062
+ * Compose the SITE-LEVEL Open Graph / Twitter block for a bare-path redirect or landing
3063
+ * page that has no per-route head of its own. Returns `[]` UNLESS a `defaultOgImage` is
3064
+ * configured — so apps that opt out keep a bare redirect (no behavior change). The site
3065
+ * name + description become the card's title/description (`og:type=website`); `url` is the
3066
+ * canonical the page points at. A bare article/tag alias gets this site card as a fallback;
3067
+ * crawlers that honor the page's `rel=canonical` still resolve the per-route card.
3068
+ *
3069
+ * @param input - The site slice, head defaults, landing URL, and optional `og:locale`.
3070
+ * @returns The ordered site-level head element set, or `[]` when no default image is set.
3071
+ * @example composeSiteHead({ site, defaults, url: "https://blog.dev/en/", ogLocale: "en_US" })
3072
+ */
3073
+ function composeSiteHead(input) {
3074
+ const { site, defaults, url, ogLocale } = input;
3075
+ const image = defaults.defaultOgImage;
3076
+ if (image === void 0) return [];
3077
+ const absoluteImage = resolveImage(image, site);
3078
+ const name = site.name();
3079
+ const description = site.description();
3080
+ const elements = [
3081
+ meta("description", description),
3082
+ og("og:type", "website"),
3083
+ og("og:site_name", name),
3084
+ og("og:title", name),
3085
+ og("og:description", description),
3086
+ og("og:url", url),
3087
+ og("og:image", absoluteImage),
3088
+ twitter("twitter:card", defaults.twitterCard),
3089
+ twitter("twitter:title", name),
3090
+ twitter("twitter:description", description),
3091
+ twitter("twitter:image", absoluteImage)
3092
+ ];
3093
+ if (defaults.twitterHandle) elements.push(twitter("twitter:site", defaults.twitterHandle));
3094
+ if (ogLocale) elements.push(og("og:locale", ogLocale));
3095
+ return elements;
3096
+ }
3097
+ /**
2991
3098
  * HTML-escape a value for safe insertion into an attribute or text node. `&` is
2992
3099
  * escaped first so already-escaped entities are not double-escaped.
2993
3100
  *
@@ -3076,28 +3183,53 @@ function readDefaults(state) {
3076
3183
  * ```
3077
3184
  */
3078
3185
  function createApi$4(ctx) {
3079
- return {
3080
- /**
3081
- * Compose the final `<head>` inner HTML for a route (pulled by `build`).
3082
- *
3083
- * @param route - The resolved route descriptor (incl. its `.head()` HeadConfig).
3084
- * @param data - The page data object passed to the route's loader/render.
3085
- * @returns The serialized inner HTML of `<head>`.
3086
- * @example
3087
- * ```ts
3088
- * api.render(route, { title: "Post" });
3089
- * ```
3090
- */
3091
- render(route, data) {
3092
- return serializeHead(composeHead({
3093
- route,
3094
- data,
3095
- defaults: readDefaults(ctx.state),
3096
- site: ctx.require(sitePlugin),
3097
- i18n: ctx.require(i18nPlugin),
3098
- router: ctx.require(routerPlugin)
3099
- }));
3100
- } };
3186
+ return {
3187
+ /**
3188
+ * Compose the final `<head>` inner HTML for a route (pulled by `build`).
3189
+ *
3190
+ * @param route - The resolved route descriptor (incl. its `.head()` HeadConfig).
3191
+ * @param data - The page data object passed to the route's loader/render.
3192
+ * @returns The serialized inner HTML of `<head>`.
3193
+ * @example
3194
+ * ```ts
3195
+ * api.render(route, { title: "Post" });
3196
+ * ```
3197
+ */
3198
+ render(route, data) {
3199
+ return serializeHead(composeHead({
3200
+ route,
3201
+ data,
3202
+ defaults: readDefaults(ctx.state),
3203
+ site: ctx.require(sitePlugin),
3204
+ i18n: ctx.require(i18nPlugin),
3205
+ router: ctx.require(routerPlugin)
3206
+ }));
3207
+ },
3208
+ /**
3209
+ * Compose the site-level OG/Twitter block for a bare-path redirect/landing page. Resolves
3210
+ * `site`/`i18n` via `ctx.require`, absolutizes `url` against the site base, and emits an
3211
+ * `og:locale` for `locale` when supplied. Returns `""` when no `defaultOgImage` is configured.
3212
+ *
3213
+ * @param input - The landing URL/path plus an optional locale (for `og:locale`).
3214
+ * @param input.url - The page's URL or path (absolutized via `site.canonical`) → `og:url`.
3215
+ * @param input.locale - Optional locale whose `og:locale` is emitted.
3216
+ * @returns The serialized inner HTML of the site-level head block, or `""` when disabled.
3217
+ * @example
3218
+ * ```ts
3219
+ * api.siteHead({ url: "/en/", locale: "en" });
3220
+ * ```
3221
+ */
3222
+ siteHead(input) {
3223
+ const site = ctx.require(sitePlugin);
3224
+ const ogLocale = input.locale === void 0 ? void 0 : ctx.require(i18nPlugin).ogLocale(input.locale);
3225
+ return serializeHead(composeSiteHead({
3226
+ site,
3227
+ defaults: readDefaults(ctx.state),
3228
+ url: site.canonical(input.url),
3229
+ ...ogLocale === void 0 ? {} : { ogLocale }
3230
+ }));
3231
+ }
3232
+ };
3101
3233
  }
3102
3234
  //#endregion
3103
3235
  //#region src/plugins/head/config.ts
@@ -3670,19 +3802,26 @@ async function processImages(ctx, options = {}) {
3670
3802
  * bare path that points at the default-locale-prefixed URL. Deliberately does NOT
3671
3803
  * emit a Cloudflare `_redirects` catch-all (an SSG infinite-loop trap). Gated by
3672
3804
  * `config.localeRedirects` (false/unset disables).
3805
+ *
3806
+ * When `head.defaultOgImage` is configured, each redirect page ALSO carries the
3807
+ * site-level Open Graph / Twitter block (`head.siteHead`) so a social crawler that
3808
+ * fetches the apex domain (or any locale-less alias) — and does not follow the
3809
+ * meta-refresh — still gets a branded preview card. No image configured ⇒ bare redirect.
3673
3810
  */
3674
3811
  /**
3675
- * Render a redirect HTML page: a `0;url` refresh meta + a canonical link to `target`.
3812
+ * Render a redirect HTML page: a `0;url` refresh meta + a canonical link to `target`,
3813
+ * with an optional site-level OG/Twitter block injected at the end of `<head>`.
3676
3814
  *
3677
3815
  * @param target - The default-locale-prefixed URL to redirect to.
3816
+ * @param headExtra - Extra `<head>` inner HTML (the site-level OG block), or `""` for none.
3678
3817
  * @returns The complete redirect HTML document string.
3679
3818
  * @example
3680
3819
  * ```ts
3681
- * redirectHtml("/en/about/");
3820
+ * redirectHtml("/en/about/", '<meta property="og:image" content="…">');
3682
3821
  * ```
3683
3822
  */
3684
- function redirectHtml(target) {
3685
- return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="refresh" content="0;url=${target}"><link rel="canonical" href="${target}"></head><body><a href="${target}">Redirecting…</a></body></html>`;
3823
+ function redirectHtml(target, headExtra = "") {
3824
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="refresh" content="0;url=${target}"><link rel="canonical" href="${target}">${headExtra}</head><body><a href="${target}">Redirecting…</a></body></html>`;
3686
3825
  }
3687
3826
  /**
3688
3827
  * Correlate manifest definitions to compiled `TypedRoute` entries by pattern (the
@@ -3774,16 +3913,17 @@ async function expandRedirects(definition, entry, defaultLocale, ctx) {
3774
3913
  * @param job.file - The redirect page's output path, relative to `outDir`.
3775
3914
  * @param job.target - The absolute default-locale URL the page redirects to.
3776
3915
  * @param outDir - The build output directory the file is resolved against.
3916
+ * @param headExtra - The site-level OG block to inject into `<head>`, or `""` for none.
3777
3917
  * @returns Resolves once the redirect HTML page is written.
3778
3918
  * @example
3779
3919
  * ```ts
3780
- * await writeRedirectFile({ file: "about/index.html", target: "/en/about/" }, "dist");
3920
+ * await writeRedirectFile({ file: "about/index.html", target: "/en/about/" }, "dist", "");
3781
3921
  * ```
3782
3922
  */
3783
- async function writeRedirectFile(job, outDir) {
3923
+ async function writeRedirectFile(job, outDir, headExtra = "") {
3784
3924
  const filePath = path.join(outDir, job.file);
3785
3925
  await mkdir(path.dirname(filePath), { recursive: true });
3786
- await writeFile(filePath, redirectHtml(job.target), "utf8");
3926
+ await writeFile(filePath, redirectHtml(job.target, headExtra), "utf8");
3787
3927
  }
3788
3928
  /**
3789
3929
  * Emits one bare-path redirect HTML page per locale-prefixed route path, each a
@@ -3806,7 +3946,14 @@ async function generateLocaleRedirects(ctx) {
3806
3946
  const router = ctx.require(routerPlugin);
3807
3947
  const defaultLocale = ctx.require(i18nPlugin).defaultLocale();
3808
3948
  const jobs = (await Promise.all(pairRoutes(router).map(([definition, entry]) => expandRedirects(definition, entry, defaultLocale, ctx)))).flat();
3809
- await Promise.all(jobs.map((job) => writeRedirectFile(job, ctx.config.outDir)));
3949
+ const head = ctx.has("head") ? ctx.require(headPlugin) : void 0;
3950
+ await Promise.all(jobs.map((job) => {
3951
+ const headExtra = head ? head.siteHead({
3952
+ url: job.target,
3953
+ locale: defaultLocale
3954
+ }) : "";
3955
+ return writeRedirectFile(job, ctx.config.outDir, headExtra);
3956
+ }));
3810
3957
  ctx.log.debug("build:locale-redirects", { written: jobs.length });
3811
3958
  return { written: jobs.length };
3812
3959
  }
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.4.0"
116
+ "version": "1.5.0"
117
117
  }