@moku-labs/web 1.4.0 → 1.4.1

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;
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*)(?:\((.+)\))?$/;
1336
1338
  /**
1337
- * @file router plugin runtime matching domain.
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).
1338
1341
  *
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.
1342
+ * @param text - The static segment text.
1343
+ * @returns The regex-escaped segment.
1344
+ * @example
1345
+ * ```ts
1346
+ * escapeStaticSegment("about"); // "about"
1347
+ * ```
1342
1348
  */
1349
+ function escapeStaticSegment(text) {
1350
+ return text.replaceAll(REGEX_METACHARS, String.raw`\$&`);
1351
+ }
1343
1352
  /**
1344
- * Build a pathname matcher for a single route: tries the `withLang` URLPattern,
1345
- * then the `bare` pattern injecting `defaultLocale` on miss.
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.
1346
1356
  *
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).
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
+ * ```
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
+ }
1372
+ /**
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`.
1378
+ *
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
  /**
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
  /**
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;
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;
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
  /**
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.4.1"
117
117
  }