@sigx/lynx-navigation 0.1.0 → 0.1.2
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.
- package/LICENSE +1 -1
- package/README.md +355 -0
- package/dist/components/Drawer.d.ts +56 -0
- package/dist/components/Drawer.d.ts.map +1 -0
- package/dist/components/Drawer.js +74 -0
- package/dist/components/Drawer.js.map +1 -0
- package/dist/components/EdgeBackHandle.js +144 -0
- package/dist/components/EdgeBackHandle.js.map +1 -0
- package/dist/components/EntryScope.d.ts +26 -0
- package/dist/components/EntryScope.d.ts.map +1 -0
- package/dist/components/EntryScope.js +33 -0
- package/dist/components/EntryScope.js.map +1 -0
- package/dist/components/Header.d.ts +7 -0
- package/dist/components/Header.d.ts.map +1 -0
- package/dist/components/Header.js +103 -0
- package/dist/components/Header.js.map +1 -0
- package/dist/components/Link.js +1 -4
- package/dist/components/Link.js.map +1 -1
- package/dist/components/NavigationRoot.d.ts +1 -1
- package/dist/components/NavigationRoot.d.ts.map +1 -1
- package/dist/components/NavigationRoot.js +29 -3
- package/dist/components/NavigationRoot.js.map +1 -1
- package/dist/components/Screen.d.ts +98 -0
- package/dist/components/Screen.d.ts.map +1 -0
- package/dist/components/Screen.js +94 -0
- package/dist/components/Screen.js.map +1 -0
- package/dist/components/ScreenContainer.d.ts.map +1 -1
- package/dist/components/ScreenContainer.js +77 -0
- package/dist/components/ScreenContainer.js.map +1 -0
- package/dist/components/Stack.d.ts.map +1 -1
- package/dist/components/Stack.js +60 -24
- package/dist/components/Stack.js.map +1 -1
- package/dist/components/TabBar.d.ts +40 -0
- package/dist/components/TabBar.d.ts.map +1 -0
- package/dist/components/TabBar.js +63 -0
- package/dist/components/TabBar.js.map +1 -0
- package/dist/components/Tabs.d.ts +101 -0
- package/dist/components/Tabs.d.ts.map +1 -0
- package/dist/components/Tabs.js +140 -0
- package/dist/components/Tabs.js.map +1 -0
- package/dist/hooks/use-focus.d.ts +46 -0
- package/dist/hooks/use-focus.d.ts.map +1 -0
- package/dist/hooks/use-focus.js +81 -0
- package/dist/hooks/use-focus.js.map +1 -0
- package/dist/hooks/use-hardware-back.js +50 -0
- package/dist/hooks/use-hardware-back.js.map +1 -0
- package/dist/hooks/use-linking-nav.d.ts +92 -0
- package/dist/hooks/use-linking-nav.d.ts.map +1 -0
- package/dist/hooks/use-linking-nav.js +109 -0
- package/dist/hooks/use-linking-nav.js.map +1 -0
- package/dist/hooks/use-nav-internal.d.ts +38 -1
- package/dist/hooks/use-nav-internal.d.ts.map +1 -1
- package/dist/hooks/use-nav-internal.js +32 -0
- package/dist/hooks/use-nav-internal.js.map +1 -1
- package/dist/hooks/use-nav-serializer.d.ts +83 -0
- package/dist/hooks/use-nav-serializer.d.ts.map +1 -0
- package/dist/hooks/use-nav-serializer.js +181 -0
- package/dist/hooks/use-nav-serializer.js.map +1 -0
- package/dist/hooks/use-nav.js.map +1 -1
- package/dist/hooks/use-screen-options.d.ts +3 -0
- package/dist/hooks/use-screen-options.d.ts.map +1 -0
- package/dist/hooks/use-screen-options.js +43 -0
- package/dist/hooks/use-screen-options.js.map +1 -0
- package/dist/href.d.ts +16 -1
- package/dist/href.d.ts.map +1 -1
- package/dist/href.js +50 -7
- package/dist/href.js.map +1 -1
- package/dist/index.d.ts +18 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -1
- package/dist/internal/screen-registry.d.ts +49 -0
- package/dist/internal/screen-registry.d.ts.map +1 -0
- package/dist/internal/screen-registry.js +59 -0
- package/dist/internal/screen-registry.js.map +1 -0
- package/dist/internal/screen-width.js +30 -0
- package/dist/internal/screen-width.js.map +1 -0
- package/dist/navigator/core.d.ts +20 -1
- package/dist/navigator/core.d.ts.map +1 -1
- package/dist/navigator/core.js +231 -36
- package/dist/navigator/core.js.map +1 -1
- package/dist/types.d.ts +56 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/url/build.d.ts +16 -0
- package/dist/url/build.d.ts.map +1 -0
- package/dist/url/build.js +30 -0
- package/dist/url/build.js.map +1 -0
- package/dist/url/compile.d.ts +35 -0
- package/dist/url/compile.d.ts.map +1 -0
- package/dist/url/compile.js +83 -0
- package/dist/url/compile.js.map +1 -0
- package/dist/url/format.d.ts +29 -0
- package/dist/url/format.d.ts.map +1 -0
- package/dist/url/format.js +102 -0
- package/dist/url/format.js.map +1 -0
- package/dist/url/index.d.ts +13 -0
- package/dist/url/index.d.ts.map +1 -0
- package/dist/url/index.js +13 -0
- package/dist/url/index.js.map +1 -0
- package/dist/url/parse.d.ts +21 -0
- package/dist/url/parse.d.ts.map +1 -0
- package/dist/url/parse.js +94 -0
- package/dist/url/parse.js.map +1 -0
- package/dist/url/registry.d.ts +41 -0
- package/dist/url/registry.d.ts.map +1 -0
- package/dist/url/registry.js +56 -0
- package/dist/url/registry.js.map +1 -0
- package/dist/url/validate.d.ts +24 -0
- package/dist/url/validate.d.ts.map +1 -0
- package/dist/url/validate.js +37 -0
- package/dist/url/validate.js.map +1 -0
- package/package.json +44 -15
- package/src/components/Drawer.tsx +119 -0
- package/src/components/EdgeBackHandle.tsx +1 -1
- package/src/components/EntryScope.tsx +38 -0
- package/src/components/Header.tsx +129 -0
- package/src/components/NavigationRoot.tsx +9 -1
- package/src/components/Screen.tsx +116 -0
- package/src/components/ScreenContainer.tsx +14 -1
- package/src/components/Stack.tsx +21 -2
- package/src/components/TabBar.tsx +104 -0
- package/src/components/Tabs.tsx +216 -0
- package/src/hooks/use-focus.ts +88 -0
- package/src/hooks/use-linking-nav.ts +159 -0
- package/src/hooks/use-nav-internal.ts +48 -1
- package/src/hooks/use-nav-serializer.ts +239 -0
- package/src/hooks/use-screen-options.ts +48 -0
- package/src/href.ts +68 -11
- package/src/index.ts +29 -0
- package/src/internal/screen-registry.ts +89 -0
- package/src/navigator/core.ts +86 -4
- package/src/types.ts +56 -0
- package/src/url/build.ts +35 -0
- package/src/url/compile.ts +109 -0
- package/src/url/format.ts +95 -0
- package/src/url/index.ts +18 -0
- package/src/url/parse.ts +102 -0
- package/src/url/registry.ts +69 -0
- 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
|
+
}
|
package/src/url/build.ts
ADDED
|
@@ -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,95 @@
|
|
|
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 on the way out — `parseSearch` returns the
|
|
13
|
+
* raw string and leaves any JSON decoding to the route's `search` schema
|
|
14
|
+
* (e.g. a Zod `transform`), so the round-trip is intentionally one-way at
|
|
15
|
+
* this layer.
|
|
16
|
+
*
|
|
17
|
+
* Returns `''` (empty string) when there are no entries — callers join with
|
|
18
|
+
* `?` only when the result is non-empty.
|
|
19
|
+
*/
|
|
20
|
+
export function formatSearch(search: Record<string, unknown> | undefined): string {
|
|
21
|
+
if (!search) return '';
|
|
22
|
+
const keys = Object.keys(search).sort();
|
|
23
|
+
const parts: string[] = [];
|
|
24
|
+
for (const key of keys) {
|
|
25
|
+
const value = search[key];
|
|
26
|
+
if (value === undefined || value === null) continue;
|
|
27
|
+
const encoded = encodeURIComponent(key);
|
|
28
|
+
if (Array.isArray(value)) {
|
|
29
|
+
for (const item of value) {
|
|
30
|
+
if (item === undefined || item === null) continue;
|
|
31
|
+
parts.push(`${encoded}=${encodeURIComponent(serializeScalar(item))}`);
|
|
32
|
+
}
|
|
33
|
+
} else {
|
|
34
|
+
parts.push(`${encoded}=${encodeURIComponent(serializeScalar(value))}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return parts.join('&');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Parse a `key=value&key=value` querystring into a string-keyed bag. Values
|
|
42
|
+
* are decoded but kept as strings — typed coercion happens in the route's
|
|
43
|
+
* `search` schema (e.g. Zod's `z.coerce.number()`).
|
|
44
|
+
*
|
|
45
|
+
* Multiple occurrences of the same key produce an array. Schemas that don't
|
|
46
|
+
* expect arrays will reject this — that's the right failure mode.
|
|
47
|
+
*/
|
|
48
|
+
export function parseSearch(query: string): Record<string, string | string[]> {
|
|
49
|
+
const result: Record<string, string | string[]> = {};
|
|
50
|
+
if (!query) return result;
|
|
51
|
+
// Strip leading `?` if present (formatHref doesn't include it but callers
|
|
52
|
+
// sometimes pass `?a=1`).
|
|
53
|
+
const cleaned = query.startsWith('?') ? query.slice(1) : query;
|
|
54
|
+
if (!cleaned) return result;
|
|
55
|
+
for (const pair of cleaned.split('&')) {
|
|
56
|
+
if (!pair) continue;
|
|
57
|
+
const eqIdx = pair.indexOf('=');
|
|
58
|
+
const rawKey = eqIdx === -1 ? pair : pair.slice(0, eqIdx);
|
|
59
|
+
const rawValue = eqIdx === -1 ? '' : pair.slice(eqIdx + 1);
|
|
60
|
+
const key = safeDecode(rawKey);
|
|
61
|
+
const value = safeDecode(rawValue);
|
|
62
|
+
const existing = result[key];
|
|
63
|
+
if (existing === undefined) {
|
|
64
|
+
result[key] = value;
|
|
65
|
+
} else if (Array.isArray(existing)) {
|
|
66
|
+
existing.push(value);
|
|
67
|
+
} else {
|
|
68
|
+
result[key] = [existing, value];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function serializeScalar(v: unknown): string {
|
|
75
|
+
switch (typeof v) {
|
|
76
|
+
case 'string': return v;
|
|
77
|
+
case 'number':
|
|
78
|
+
case 'boolean':
|
|
79
|
+
case 'bigint':
|
|
80
|
+
return String(v);
|
|
81
|
+
default:
|
|
82
|
+
// Objects/arrays/etc — JSON. Schemas can decide how to interpret.
|
|
83
|
+
return JSON.stringify(v);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function safeDecode(s: string): string {
|
|
88
|
+
try {
|
|
89
|
+
return decodeURIComponent(s.replace(/\+/g, ' '));
|
|
90
|
+
} catch {
|
|
91
|
+
// Malformed % escape — fall back to the raw text rather than throwing
|
|
92
|
+
// from a navigation hot path. The schema will reject if it matters.
|
|
93
|
+
return s;
|
|
94
|
+
}
|
|
95
|
+
}
|
package/src/url/index.ts
ADDED
|
@@ -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';
|
package/src/url/parse.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
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
|
+
// Drop the fragment before any path/query work — `#…` is a client-side
|
|
82
|
+
// anchor that must not leak into the route pathname or query values.
|
|
83
|
+
const hashIdx = url.indexOf('#');
|
|
84
|
+
const noHash = hashIdx >= 0 ? url.slice(0, hashIdx) : url;
|
|
85
|
+
|
|
86
|
+
// If the URL has a scheme, defer to lynx-linking's parser — handles
|
|
87
|
+
// `myapp://host/path?q` correctly. Otherwise treat the whole thing as
|
|
88
|
+
// pathname+query.
|
|
89
|
+
const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.\-]*:/.test(noHash);
|
|
90
|
+
if (hasScheme) {
|
|
91
|
+
const parsed = parseUrl(noHash);
|
|
92
|
+
// Reconstruct the query string from the already-parsed bag. We have
|
|
93
|
+
// to do this because parseUrl decoded keys/values, but parseSearch
|
|
94
|
+
// expects encoded form. Simpler: split the original ourselves.
|
|
95
|
+
const qIdx = noHash.indexOf('?');
|
|
96
|
+
const query = qIdx >= 0 ? noHash.slice(qIdx + 1) : '';
|
|
97
|
+
return { pathname: parsed.path, query };
|
|
98
|
+
}
|
|
99
|
+
const qIdx = noHash.indexOf('?');
|
|
100
|
+
if (qIdx === -1) return { pathname: noHash, query: '' };
|
|
101
|
+
return { pathname: noHash.slice(0, qIdx), query: noHash.slice(qIdx + 1) };
|
|
102
|
+
}
|
|
@@ -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
|
+
}
|