@sigx/lynx-navigation 0.1.0 → 0.1.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.
Files changed (139) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +355 -0
  3. package/dist/components/Drawer.d.ts +58 -0
  4. package/dist/components/Drawer.d.ts.map +1 -0
  5. package/dist/components/Drawer.js +76 -0
  6. package/dist/components/Drawer.js.map +1 -0
  7. package/dist/components/EdgeBackHandle.js +144 -0
  8. package/dist/components/EdgeBackHandle.js.map +1 -0
  9. package/dist/components/EntryScope.d.ts +26 -0
  10. package/dist/components/EntryScope.d.ts.map +1 -0
  11. package/dist/components/EntryScope.js +33 -0
  12. package/dist/components/EntryScope.js.map +1 -0
  13. package/dist/components/Header.d.ts +7 -0
  14. package/dist/components/Header.d.ts.map +1 -0
  15. package/dist/components/Header.js +103 -0
  16. package/dist/components/Header.js.map +1 -0
  17. package/dist/components/Link.js +1 -4
  18. package/dist/components/Link.js.map +1 -1
  19. package/dist/components/NavigationRoot.d.ts +1 -1
  20. package/dist/components/NavigationRoot.d.ts.map +1 -1
  21. package/dist/components/NavigationRoot.js +29 -3
  22. package/dist/components/NavigationRoot.js.map +1 -1
  23. package/dist/components/Screen.d.ts +98 -0
  24. package/dist/components/Screen.d.ts.map +1 -0
  25. package/dist/components/Screen.js +94 -0
  26. package/dist/components/Screen.js.map +1 -0
  27. package/dist/components/ScreenContainer.d.ts.map +1 -1
  28. package/dist/components/ScreenContainer.js +77 -0
  29. package/dist/components/ScreenContainer.js.map +1 -0
  30. package/dist/components/Stack.d.ts.map +1 -1
  31. package/dist/components/Stack.js +60 -24
  32. package/dist/components/Stack.js.map +1 -1
  33. package/dist/components/TabBar.d.ts +40 -0
  34. package/dist/components/TabBar.d.ts.map +1 -0
  35. package/dist/components/TabBar.js +63 -0
  36. package/dist/components/TabBar.js.map +1 -0
  37. package/dist/components/Tabs.d.ts +101 -0
  38. package/dist/components/Tabs.d.ts.map +1 -0
  39. package/dist/components/Tabs.js +135 -0
  40. package/dist/components/Tabs.js.map +1 -0
  41. package/dist/hooks/use-focus.d.ts +46 -0
  42. package/dist/hooks/use-focus.d.ts.map +1 -0
  43. package/dist/hooks/use-focus.js +77 -0
  44. package/dist/hooks/use-focus.js.map +1 -0
  45. package/dist/hooks/use-hardware-back.js +50 -0
  46. package/dist/hooks/use-hardware-back.js.map +1 -0
  47. package/dist/hooks/use-linking-nav.d.ts +92 -0
  48. package/dist/hooks/use-linking-nav.d.ts.map +1 -0
  49. package/dist/hooks/use-linking-nav.js +109 -0
  50. package/dist/hooks/use-linking-nav.js.map +1 -0
  51. package/dist/hooks/use-nav-internal.d.ts +38 -1
  52. package/dist/hooks/use-nav-internal.d.ts.map +1 -1
  53. package/dist/hooks/use-nav-internal.js +32 -0
  54. package/dist/hooks/use-nav-internal.js.map +1 -1
  55. package/dist/hooks/use-nav-serializer.d.ts +83 -0
  56. package/dist/hooks/use-nav-serializer.d.ts.map +1 -0
  57. package/dist/hooks/use-nav-serializer.js +181 -0
  58. package/dist/hooks/use-nav-serializer.js.map +1 -0
  59. package/dist/hooks/use-nav.js.map +1 -1
  60. package/dist/hooks/use-screen-options.d.ts +3 -0
  61. package/dist/hooks/use-screen-options.d.ts.map +1 -0
  62. package/dist/hooks/use-screen-options.js +43 -0
  63. package/dist/hooks/use-screen-options.js.map +1 -0
  64. package/dist/href.d.ts +16 -1
  65. package/dist/href.d.ts.map +1 -1
  66. package/dist/href.js +50 -7
  67. package/dist/href.js.map +1 -1
  68. package/dist/index.d.ts +18 -1
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/index.js +15 -0
  71. package/dist/index.js.map +1 -1
  72. package/dist/internal/screen-registry.d.ts +49 -0
  73. package/dist/internal/screen-registry.d.ts.map +1 -0
  74. package/dist/internal/screen-registry.js +59 -0
  75. package/dist/internal/screen-registry.js.map +1 -0
  76. package/dist/internal/screen-width.js +30 -0
  77. package/dist/internal/screen-width.js.map +1 -0
  78. package/dist/navigator/core.d.ts +20 -1
  79. package/dist/navigator/core.d.ts.map +1 -1
  80. package/dist/navigator/core.js +231 -36
  81. package/dist/navigator/core.js.map +1 -1
  82. package/dist/types.d.ts +56 -0
  83. package/dist/types.d.ts.map +1 -1
  84. package/dist/url/build.d.ts +16 -0
  85. package/dist/url/build.d.ts.map +1 -0
  86. package/dist/url/build.js +30 -0
  87. package/dist/url/build.js.map +1 -0
  88. package/dist/url/compile.d.ts +35 -0
  89. package/dist/url/compile.d.ts.map +1 -0
  90. package/dist/url/compile.js +83 -0
  91. package/dist/url/compile.js.map +1 -0
  92. package/dist/url/format.d.ts +26 -0
  93. package/dist/url/format.d.ts.map +1 -0
  94. package/dist/url/format.js +99 -0
  95. package/dist/url/format.js.map +1 -0
  96. package/dist/url/index.d.ts +13 -0
  97. package/dist/url/index.d.ts.map +1 -0
  98. package/dist/url/index.js +13 -0
  99. package/dist/url/index.js.map +1 -0
  100. package/dist/url/parse.d.ts +21 -0
  101. package/dist/url/parse.d.ts.map +1 -0
  102. package/dist/url/parse.js +90 -0
  103. package/dist/url/parse.js.map +1 -0
  104. package/dist/url/registry.d.ts +41 -0
  105. package/dist/url/registry.d.ts.map +1 -0
  106. package/dist/url/registry.js +56 -0
  107. package/dist/url/registry.js.map +1 -0
  108. package/dist/url/validate.d.ts +24 -0
  109. package/dist/url/validate.d.ts.map +1 -0
  110. package/dist/url/validate.js +37 -0
  111. package/dist/url/validate.js.map +1 -0
  112. package/package.json +44 -15
  113. package/src/components/Drawer.tsx +121 -0
  114. package/src/components/EdgeBackHandle.tsx +1 -1
  115. package/src/components/EntryScope.tsx +38 -0
  116. package/src/components/Header.tsx +124 -0
  117. package/src/components/NavigationRoot.tsx +9 -1
  118. package/src/components/Screen.tsx +116 -0
  119. package/src/components/ScreenContainer.tsx +14 -1
  120. package/src/components/Stack.tsx +21 -2
  121. package/src/components/TabBar.tsx +103 -0
  122. package/src/components/Tabs.tsx +212 -0
  123. package/src/hooks/use-focus.ts +77 -0
  124. package/src/hooks/use-linking-nav.ts +159 -0
  125. package/src/hooks/use-nav-internal.ts +48 -1
  126. package/src/hooks/use-nav-serializer.ts +239 -0
  127. package/src/hooks/use-screen-options.ts +48 -0
  128. package/src/href.ts +68 -11
  129. package/src/index.ts +29 -0
  130. package/src/internal/screen-registry.ts +89 -0
  131. package/src/navigator/core.ts +86 -4
  132. package/src/types.ts +56 -0
  133. package/src/url/build.ts +35 -0
  134. package/src/url/compile.ts +109 -0
  135. package/src/url/format.ts +92 -0
  136. package/src/url/index.ts +18 -0
  137. package/src/url/parse.ts +97 -0
  138. package/src/url/registry.ts +69 -0
  139. package/src/url/validate.ts +67 -0
package/src/types.ts CHANGED
@@ -51,6 +51,20 @@ export interface RouteDefinition<
51
51
  > {
52
52
  /** Component factory or lazy importer. */
53
53
  component: ComponentLike;
54
+ /**
55
+ * Fallback shown while a lazy `component` is loading.
56
+ *
57
+ * Set this only on routes whose `component` was created with `lazy(...)`.
58
+ * The fallback is rendered inside a `<Suspense>` boundary wrapping the
59
+ * screen mount, so the user sees this UI while the screen's chunk is
60
+ * being fetched. When omitted, lazy routes still work — the caller is
61
+ * responsible for placing its own `<Suspense>` boundary (e.g. above the
62
+ * `<NavigationRoot>` or inside the screen component).
63
+ *
64
+ * Accepts a component factory (`MyLoadingScreen`) or a function returning
65
+ * JSX (`() => <Spinner />`). Eager routes ignore this field.
66
+ */
67
+ fallback?: ComponentLike | (() => unknown);
54
68
  /** Standard-Schema validator for path params. Optional. */
55
69
  params?: Params;
56
70
  /** Standard-Schema validator for query/search params. Optional. */
@@ -169,3 +183,45 @@ export interface TransitionState {
169
183
  /** Animation progress signal — typed loosely; cast at the runtime boundary. */
170
184
  readonly progress: unknown;
171
185
  }
186
+
187
+ /**
188
+ * Per-screen display options written by `<Screen>` into its entry's registry.
189
+ *
190
+ * Read by persistent navigator chrome (the `<HeaderBar>` shipped in the
191
+ * `header` slice; `<TabBar>` later). All fields are optional — consumers
192
+ * apply sensible defaults (headerShown defaults to true, gestureEnabled to
193
+ * true, title falls back to the route name).
194
+ *
195
+ * `title` accepts a function so the header can be derived from reactive
196
+ * state (e.g. a user's display name signal). Plain strings are wrapped in
197
+ * a thunk by consumers when read.
198
+ */
199
+ export interface ScreenOptions {
200
+ /** Header title. Either a static string or a getter (re-tracked each render). */
201
+ title?: string | (() => string);
202
+ /** When false, the navigator's header is hidden for this screen. Default true. */
203
+ headerShown?: boolean;
204
+ /** When false, the iOS edge-swipe-back gesture is disabled for this screen. Default true. */
205
+ gestureEnabled?: boolean;
206
+ }
207
+
208
+ /**
209
+ * Slot fills written by `<Screen.Header>` / `<Screen.HeaderLeft>` /
210
+ * `<Screen.HeaderRight>` / `<Screen.TabBarItem>`.
211
+ *
212
+ * Each fill is the rendered output of that sub-component's `default` slot,
213
+ * captured as a thunk so the navigator's persistent chrome can call it at
214
+ * render time. `tabBarItem` is a scoped slot — the consumer passes
215
+ * `{ active }` so the same screen's tab-bar item can style itself
216
+ * differently when focused vs. not.
217
+ */
218
+ export interface ScreenSlotFills {
219
+ /** Full header replacement. When set, takes precedence over title + headerLeft/Right. */
220
+ header?: () => unknown;
221
+ /** Left-side header content (typically back arrow override). */
222
+ headerLeft?: () => unknown;
223
+ /** Right-side header content (typically action buttons). */
224
+ headerRight?: () => unknown;
225
+ /** Tab-bar item — scoped slot receives `{ active }` indicating focus. */
226
+ tabBarItem?: (ctx: { active: boolean }) => unknown;
227
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Typed → URL: build the `url` field of an Href from a route's params/search.
3
+ *
4
+ * Mirror of parse.ts. Used by `hrefFor()` after schema validation succeeds.
5
+ */
6
+
7
+ import { formatSearch } from './format.js';
8
+ import { getCompiledPath, getRouteRegistry } from './registry.js';
9
+
10
+ /**
11
+ * Build the URL form of a route + params + search, or `null` if the route
12
+ * declares no `path` template (typed navigation still works — only deep-link
13
+ * serialization is unavailable).
14
+ *
15
+ * Params must already be valid for the route's schema (callers run this after
16
+ * `validateSync`). Search values are stringified as-is — schema-validated
17
+ * inputs survive round-tripping because `parseHref` re-runs the same schema.
18
+ */
19
+ export function buildUrl(
20
+ routeName: string,
21
+ params: Record<string, unknown> | undefined,
22
+ search: Record<string, unknown> | undefined,
23
+ ): string | null {
24
+ const registry = getRouteRegistry();
25
+ const compiled = getCompiledPath(registry, routeName);
26
+ if (!compiled) return null;
27
+ const formatted = compiled.format(
28
+ // The compiler accepts string|number; we widen to unknown and let it
29
+ // String()-coerce. Booleans/null get filtered by the missing-param
30
+ // check, which is what we want — boolean path params don't exist.
31
+ (params ?? {}) as Record<string, string | number>,
32
+ );
33
+ const querystring = formatSearch(search);
34
+ return querystring.length > 0 ? `${formatted}?${querystring}` : formatted;
35
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Path template compiler.
3
+ *
4
+ * Turns a route's `path` (e.g. `/users/:id/posts/:postId`) into a compiled
5
+ * object that can both match a URL pathname against it and format a typed
6
+ * params object back into a URL.
7
+ *
8
+ * Supported syntax (intentionally minimal for v1):
9
+ * - Literal segments: `/users`, `/users/me`
10
+ * - Named params: `:id` (matches `[^/]+`)
11
+ * - Trailing slashes tolerated on match
12
+ *
13
+ * Out of scope for v1 (future-compatible — additions won't break v1 paths):
14
+ * - Wildcards `*`
15
+ * - Optional params `:id?`
16
+ * - Typed/constrained params `:id<number>` or `:id(\\d+)`
17
+ */
18
+
19
+ /** Result of compiling a path template — used by parse + format. */
20
+ export interface CompiledPath {
21
+ readonly source: string;
22
+ readonly paramNames: readonly string[];
23
+ /** Regex that matches a URL pathname. Captures are param values in order. */
24
+ readonly regex: RegExp;
25
+ /**
26
+ * Render a URL pathname for this template given param values. Each value
27
+ * is `encodeURIComponent`-encoded. Throws if a required `:name` is missing.
28
+ */
29
+ format(params: Record<string, string | number>): string;
30
+ }
31
+
32
+ const PARAM_RE = /:([A-Za-z_][A-Za-z0-9_]*)/g;
33
+
34
+ /**
35
+ * Compile a path template. Throws on malformed input (duplicate param names,
36
+ * unexpected `:` syntax). Pure — safe to memoize.
37
+ */
38
+ export function compilePath(template: string): CompiledPath {
39
+ if (typeof template !== 'string') {
40
+ throw new TypeError(`compilePath: expected string, got ${typeof template}`);
41
+ }
42
+ if (template.length === 0) {
43
+ throw new Error('compilePath: path template must not be empty');
44
+ }
45
+ // Normalize: ensure leading `/`. Trailing slashes are tolerated on match,
46
+ // but the canonical formatted output preserves the template's trailing
47
+ // slash policy.
48
+ const normalized = template.startsWith('/') ? template : `/${template}`;
49
+
50
+ const paramNames: string[] = [];
51
+ // Build the regex by replacing :name with a capture group. We escape the
52
+ // surrounding literal text so paths with regex-special chars (`.`, `+`)
53
+ // match literally — only `:name` is treated as a placeholder.
54
+ let lastIndex = 0;
55
+ let pattern = '';
56
+ PARAM_RE.lastIndex = 0;
57
+ for (let m = PARAM_RE.exec(normalized); m !== null; m = PARAM_RE.exec(normalized)) {
58
+ const name = m[1];
59
+ if (paramNames.includes(name)) {
60
+ throw new Error(
61
+ `compilePath: duplicate param name ':${name}' in '${template}'`,
62
+ );
63
+ }
64
+ paramNames.push(name);
65
+ pattern += escapeRegex(normalized.slice(lastIndex, m.index));
66
+ pattern += '([^/]+)';
67
+ lastIndex = m.index + m[0].length;
68
+ }
69
+ pattern += escapeRegex(normalized.slice(lastIndex));
70
+
71
+ // Trim a trailing slash from the pattern so `/users/` and `/users` both
72
+ // match `/users/:?`. We keep the formatter's output as-templated.
73
+ const matchPattern = pattern.endsWith('/') ? pattern.slice(0, -1) : pattern;
74
+ const regex = new RegExp(`^${matchPattern}/?$`);
75
+
76
+ return {
77
+ source: template,
78
+ paramNames,
79
+ regex,
80
+ format(params: Record<string, string | number>): string {
81
+ let out = '';
82
+ let i = 0;
83
+ PARAM_RE.lastIndex = 0;
84
+ for (
85
+ let m = PARAM_RE.exec(normalized);
86
+ m !== null;
87
+ m = PARAM_RE.exec(normalized)
88
+ ) {
89
+ const name = m[1];
90
+ const value = params[name];
91
+ if (value === undefined || value === null) {
92
+ throw new Error(
93
+ `compilePath.format: missing required param ':${name}' for '${template}'`,
94
+ );
95
+ }
96
+ out += normalized.slice(i, m.index);
97
+ out += encodeURIComponent(String(value));
98
+ i = m.index + m[0].length;
99
+ }
100
+ out += normalized.slice(i);
101
+ return out;
102
+ },
103
+ };
104
+ }
105
+
106
+ const REGEX_SPECIALS = /[.*+?^${}()|[\]\\]/g;
107
+ function escapeRegex(s: string): string {
108
+ return s.replace(REGEX_SPECIALS, '\\$&');
109
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Format helpers: typed params/search → URL string.
3
+ *
4
+ * Used by `hrefFor()` to render the `url` field of an Href.
5
+ */
6
+
7
+ /**
8
+ * Serialize an object as a `key=value&key=value` querystring.
9
+ *
10
+ * Keys are sorted to make the output deterministic (useful for tests and
11
+ * persistence diffs). `undefined`/`null` values are skipped. Non-primitive
12
+ * values are JSON-stringified — parseHref reverses this on the receiving end.
13
+ *
14
+ * Returns `''` (empty string) when there are no entries — callers join with
15
+ * `?` only when the result is non-empty.
16
+ */
17
+ export function formatSearch(search: Record<string, unknown> | undefined): string {
18
+ if (!search) return '';
19
+ const keys = Object.keys(search).sort();
20
+ const parts: string[] = [];
21
+ for (const key of keys) {
22
+ const value = search[key];
23
+ if (value === undefined || value === null) continue;
24
+ const encoded = encodeURIComponent(key);
25
+ if (Array.isArray(value)) {
26
+ for (const item of value) {
27
+ if (item === undefined || item === null) continue;
28
+ parts.push(`${encoded}=${encodeURIComponent(serializeScalar(item))}`);
29
+ }
30
+ } else {
31
+ parts.push(`${encoded}=${encodeURIComponent(serializeScalar(value))}`);
32
+ }
33
+ }
34
+ return parts.join('&');
35
+ }
36
+
37
+ /**
38
+ * Parse a `key=value&key=value` querystring into a string-keyed bag. Values
39
+ * are decoded but kept as strings — typed coercion happens in the route's
40
+ * `search` schema (e.g. Zod's `z.coerce.number()`).
41
+ *
42
+ * Multiple occurrences of the same key produce an array. Schemas that don't
43
+ * expect arrays will reject this — that's the right failure mode.
44
+ */
45
+ export function parseSearch(query: string): Record<string, string | string[]> {
46
+ const result: Record<string, string | string[]> = {};
47
+ if (!query) return result;
48
+ // Strip leading `?` if present (formatHref doesn't include it but callers
49
+ // sometimes pass `?a=1`).
50
+ const cleaned = query.startsWith('?') ? query.slice(1) : query;
51
+ if (!cleaned) return result;
52
+ for (const pair of cleaned.split('&')) {
53
+ if (!pair) continue;
54
+ const eqIdx = pair.indexOf('=');
55
+ const rawKey = eqIdx === -1 ? pair : pair.slice(0, eqIdx);
56
+ const rawValue = eqIdx === -1 ? '' : pair.slice(eqIdx + 1);
57
+ const key = safeDecode(rawKey);
58
+ const value = safeDecode(rawValue);
59
+ const existing = result[key];
60
+ if (existing === undefined) {
61
+ result[key] = value;
62
+ } else if (Array.isArray(existing)) {
63
+ existing.push(value);
64
+ } else {
65
+ result[key] = [existing, value];
66
+ }
67
+ }
68
+ return result;
69
+ }
70
+
71
+ function serializeScalar(v: unknown): string {
72
+ switch (typeof v) {
73
+ case 'string': return v;
74
+ case 'number':
75
+ case 'boolean':
76
+ case 'bigint':
77
+ return String(v);
78
+ default:
79
+ // Objects/arrays/etc — JSON. Schemas can decide how to interpret.
80
+ return JSON.stringify(v);
81
+ }
82
+ }
83
+
84
+ function safeDecode(s: string): string {
85
+ try {
86
+ return decodeURIComponent(s.replace(/\+/g, ' '));
87
+ } catch {
88
+ // Malformed % escape — fall back to the raw text rather than throwing
89
+ // from a navigation hot path. The schema will reject if it matters.
90
+ return s;
91
+ }
92
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * URL bridge — internal barrel.
3
+ *
4
+ * Not re-exported from the package root. Public surface is `hrefFor` /
5
+ * `parseHref` in ../href.ts plus `_setRouteRegistry` for tests/bootstrap.
6
+ */
7
+
8
+ export { compilePath, type CompiledPath } from './compile.js';
9
+ export { buildUrl } from './build.js';
10
+ export { parseHrefImpl } from './parse.js';
11
+ export { formatSearch, parseSearch } from './format.js';
12
+ export {
13
+ _setRouteRegistry,
14
+ _clearRouteRegistry,
15
+ getRouteRegistry,
16
+ getCompiledPath,
17
+ } from './registry.js';
18
+ export { validateSync, type ValidateOutcome } from './validate.js';
@@ -0,0 +1,97 @@
1
+ /**
2
+ * URL → typed Href parser.
3
+ *
4
+ * Walks every registered route with a `path`, tries to match its compiled
5
+ * regex against the URL's pathname, and on a hit validates the extracted
6
+ * params + search through the route's Standard Schema. First match wins;
7
+ * iteration order follows `Object.keys` of the registered routes map.
8
+ *
9
+ * Validation failures return `null` rather than throwing — deep-link handlers
10
+ * fall back to the initial route on a bad URL instead of crashing the app.
11
+ */
12
+
13
+ import type { Href } from '../href.js';
14
+ import type { RouteId } from '../register.js';
15
+ import { parse as parseUrl } from '@sigx/lynx-linking';
16
+ import type { CompiledPath } from './compile.js';
17
+ import { parseSearch } from './format.js';
18
+ import { getCompiledPath, getRouteRegistry } from './registry.js';
19
+ import { validateSync } from './validate.js';
20
+
21
+ /**
22
+ * Parse a URL string against the active route registry.
23
+ *
24
+ * Accepts both absolute URLs (`myapp://host/users/42?tab=about`) and
25
+ * pathname-only forms (`/users/42?tab=about`). Returns `null` if no route's
26
+ * `path` matches the URL or if schema validation rejects the extracted bits.
27
+ */
28
+ export function parseHrefImpl(url: string): Href | null {
29
+ if (typeof url !== 'string' || url.length === 0) return null;
30
+
31
+ // Use lynx-linking's parser for the scheme/host split — but accept paths
32
+ // that don't have a scheme too (raw `/users/42` is the common case from
33
+ // in-app routing).
34
+ const { pathname, query } = splitPathAndQuery(url);
35
+ if (!pathname) return null;
36
+
37
+ const registry = getRouteRegistry();
38
+ const rawSearch = parseSearch(query);
39
+
40
+ for (const name of Object.keys(registry.routes)) {
41
+ const compiled = getCompiledPath(registry, name);
42
+ if (!compiled) continue;
43
+ const match = compiled.regex.exec(pathname);
44
+ if (!match) continue;
45
+
46
+ const rawParams = extractParams(compiled, match);
47
+ const def = registry.routes[name];
48
+
49
+ const paramsOutcome = validateSync(def.params, rawParams);
50
+ if (!paramsOutcome.ok) continue;
51
+ const searchOutcome = validateSync(def.search, rawSearch);
52
+ if (!searchOutcome.ok) continue;
53
+
54
+ return {
55
+ route: name as RouteId,
56
+ params: paramsOutcome.value as Record<string, never>,
57
+ search: searchOutcome.value as Record<string, never>,
58
+ url,
59
+ };
60
+ }
61
+ return null;
62
+ }
63
+
64
+ function extractParams(
65
+ compiled: CompiledPath,
66
+ match: RegExpExecArray,
67
+ ): Record<string, string> {
68
+ const out: Record<string, string> = {};
69
+ for (let i = 0; i < compiled.paramNames.length; i++) {
70
+ const raw = match[i + 1];
71
+ try {
72
+ out[compiled.paramNames[i]] = decodeURIComponent(raw);
73
+ } catch {
74
+ out[compiled.paramNames[i]] = raw;
75
+ }
76
+ }
77
+ return out;
78
+ }
79
+
80
+ function splitPathAndQuery(url: string): { pathname: string; query: string } {
81
+ // If the URL has a scheme, defer to lynx-linking's parser — handles
82
+ // `myapp://host/path?q` correctly. Otherwise treat the whole thing as
83
+ // pathname+query.
84
+ const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.\-]*:/.test(url);
85
+ if (hasScheme) {
86
+ const parsed = parseUrl(url);
87
+ // Reconstruct the query string from the already-parsed bag. We have
88
+ // to do this because parseUrl decoded keys/values, but parseSearch
89
+ // expects encoded form. Simpler: split the original ourselves.
90
+ const qIdx = url.indexOf('?');
91
+ const query = qIdx >= 0 ? url.slice(qIdx + 1) : '';
92
+ return { pathname: parsed.path, query };
93
+ }
94
+ const qIdx = url.indexOf('?');
95
+ if (qIdx === -1) return { pathname: url, query: '' };
96
+ return { pathname: url.slice(0, qIdx), query: url.slice(qIdx + 1) };
97
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Module-level route registry for the URL bridge.
3
+ *
4
+ * `hrefFor()` and `parseHref()` are designed to be callable from anywhere —
5
+ * including outside the component tree (e.g. deep-link bootstrapping in
6
+ * native bridge code). They can't reach the in-tree `useNavRoutes` injectable
7
+ * directly, so we mirror the active routes here.
8
+ *
9
+ * `<NavigationRoot>` sets this synchronously during setup. Tests that exercise
10
+ * the URL helpers without a NavigationRoot can call `_setRouteRegistry`
11
+ * directly. The leading underscore is a convention: not part of the supported
12
+ * public API (test/integration use only).
13
+ */
14
+
15
+ import type { CompiledPath } from './compile.js';
16
+ import { compilePath } from './compile.js';
17
+ import type { RouteMap } from '../types.js';
18
+
19
+ interface RegistryState {
20
+ readonly routes: RouteMap;
21
+ /** Lazy-compiled paths keyed by route name. */
22
+ readonly compiled: Map<string, CompiledPath>;
23
+ }
24
+
25
+ let current: RegistryState | null = null;
26
+
27
+ /**
28
+ * Set the active route registry. Called by `<NavigationRoot>` on setup and
29
+ * available to tests/bootstrap code as `_setRouteRegistry`.
30
+ *
31
+ * Last write wins — multi-root apps and rapid mount/unmount cycles in tests
32
+ * always see the most recent `<NavigationRoot>`'s routes. If you need a
33
+ * specific registry for a one-off call, pass it explicitly to the helper
34
+ * (parseHrefWithRoutes / hrefForWithRoutes — currently internal).
35
+ */
36
+ export function _setRouteRegistry(routes: RouteMap): void {
37
+ current = { routes, compiled: new Map() };
38
+ }
39
+
40
+ /** Clear the registry. Mainly for tests that want to assert the unset path. */
41
+ export function _clearRouteRegistry(): void {
42
+ current = null;
43
+ }
44
+
45
+ /** Get the active registry or throw a friendly error if none is set. */
46
+ export function getRouteRegistry(): RegistryState {
47
+ if (!current) {
48
+ throw new Error(
49
+ '[lynx-navigation] No route registry set — render a <NavigationRoot> first, or call _setRouteRegistry() for tests.',
50
+ );
51
+ }
52
+ return current;
53
+ }
54
+
55
+ /**
56
+ * Look up (or lazily compile) the path template for a route name. Returns
57
+ * `null` when the route exists but declares no `path`.
58
+ */
59
+ export function getCompiledPath(registry: RegistryState, name: string): CompiledPath | null {
60
+ const def = registry.routes[name];
61
+ if (!def) return null;
62
+ if (!def.path) return null;
63
+ let compiled = registry.compiled.get(name);
64
+ if (!compiled) {
65
+ compiled = compilePath(def.path);
66
+ registry.compiled.set(name, compiled);
67
+ }
68
+ return compiled;
69
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Standard Schema validation helper (sync only).
3
+ *
4
+ * `hrefFor` and `parseHref` run on hot paths (link rendering, deep-link
5
+ * resolution) so we restrict to sync validators. Zod/Valibot/ArkType are all
6
+ * sync, which covers the common case. Async validators throw a clear error.
7
+ */
8
+
9
+ import type { StandardSchemaV1 } from '../types.js';
10
+
11
+ /**
12
+ * Extended runtime view of a Standard Schema — adds the `validate` function
13
+ * that the spec mandates but `types.ts`'s minimal type omits (for test-fixture
14
+ * ergonomics). Treat any schema-shaped object as potentially carrying it.
15
+ */
16
+ interface StandardSchemaRuntime {
17
+ readonly '~standard': {
18
+ readonly version: 1;
19
+ readonly vendor: string;
20
+ readonly validate?: (input: unknown) => StandardResult | PromiseLike<StandardResult>;
21
+ };
22
+ }
23
+
24
+ type StandardResult =
25
+ | { readonly value: unknown; readonly issues?: undefined }
26
+ | { readonly issues: ReadonlyArray<{ readonly message: string }> };
27
+
28
+ /** Outcome of a sync validation call — discriminated for explicit handling. */
29
+ export type ValidateOutcome =
30
+ | { readonly ok: true; readonly value: unknown }
31
+ | { readonly ok: false; readonly issues: ReadonlyArray<string> };
32
+
33
+ /**
34
+ * Run a Standard Schema's `validate` synchronously. When the schema lacks a
35
+ * `validate` function (e.g. our test `fakeSchema`), passthrough — assume the
36
+ * input is already in the correct shape. This is a deliberate ergonomic
37
+ * choice so the type-spike fixtures stay terse.
38
+ */
39
+ export function validateSync(
40
+ schema: StandardSchemaV1 | undefined,
41
+ input: unknown,
42
+ ): ValidateOutcome {
43
+ if (!schema) return { ok: true, value: input };
44
+ const validate = (schema as StandardSchemaRuntime)['~standard']?.validate;
45
+ if (!validate) return { ok: true, value: input };
46
+ const result = validate(input);
47
+ if (isPromiseLike(result)) {
48
+ throw new Error(
49
+ '[lynx-navigation] Async schema validation is not supported on the URL bridge — use a sync validator (Zod/Valibot/ArkType are all sync).',
50
+ );
51
+ }
52
+ if (result.issues !== undefined && result.issues.length > 0) {
53
+ return {
54
+ ok: false,
55
+ issues: result.issues.map((i) => i.message),
56
+ };
57
+ }
58
+ return { ok: true, value: (result as { value: unknown }).value };
59
+ }
60
+
61
+ function isPromiseLike<T>(v: unknown): v is PromiseLike<T> {
62
+ return (
63
+ v !== null
64
+ && typeof v === 'object'
65
+ && typeof (v as { then?: unknown }).then === 'function'
66
+ );
67
+ }