@absolutejs/absolute 0.19.0-beta.819 → 0.19.0-beta.820
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/dist/angular/components/core/streamingSlotRegistrar.js +1 -1
- package/dist/angular/components/core/streamingSlotRegistry.js +2 -2
- package/dist/src/svelte/router/browser.d.ts +4 -0
- package/dist/src/svelte/router/goto.d.ts +15 -0
- package/dist/src/svelte/router/hashMode.d.ts +21 -0
- package/dist/src/svelte/router/index.d.ts +4 -0
- package/dist/src/svelte/router/matchPath.d.ts +50 -0
- package/dist/src/svelte/router/page.svelte.d.ts +24 -0
- package/dist/src/svelte/router/prefetchCache.d.ts +21 -0
- package/dist/src/svelte/router/pushState.d.ts +13 -0
- package/dist/src/svelte/router/viewTransitions.d.ts +6 -0
- package/dist/svelte/router/Link.svelte +124 -0
- package/dist/svelte/router/Link.svelte.d.ts +21 -0
- package/dist/svelte/router/Route.svelte +51 -0
- package/dist/svelte/router/Route.svelte.d.ts +13 -0
- package/dist/svelte/router/Router.svelte +164 -0
- package/dist/svelte/router/Router.svelte.d.ts +16 -0
- package/dist/svelte/router/browser.ts +14 -0
- package/dist/svelte/router/goto.ts +89 -0
- package/dist/svelte/router/hashMode.ts +37 -0
- package/dist/svelte/router/index.ts +23 -0
- package/dist/svelte/router/matchPath.ts +158 -0
- package/dist/svelte/router/page.svelte.ts +57 -0
- package/dist/svelte/router/prefetchCache.ts +89 -0
- package/dist/svelte/router/pushState.ts +35 -0
- package/dist/svelte/router/viewTransitions.ts +31 -0
- package/dist/types/svelteRouter.d.ts +65 -0
- package/dist/types/svelteRouter.ts +91 -0
- package/package.json +30 -1
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { GotoOptions, RouterMode } from '../../types/svelteRouter';
|
|
2
|
+
import { buildHashHref } from './hashMode';
|
|
3
|
+
import { setPage } from './page.svelte';
|
|
4
|
+
import { consumePrefetch } from './prefetchCache';
|
|
5
|
+
import { withViewTransition } from './viewTransitions';
|
|
6
|
+
|
|
7
|
+
let activeMode: RouterMode = 'history';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Internal — called by Router.svelte on mount so navigation primitives
|
|
11
|
+
* know which URL strategy to use. Hash mode rewrites the URL bar to
|
|
12
|
+
* `#/path` instead of `/path`.
|
|
13
|
+
*/
|
|
14
|
+
export const setRouterMode = (mode: RouterMode) => {
|
|
15
|
+
activeMode = mode;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const resolveAbsoluteUrl = (target: string) => {
|
|
19
|
+
if (typeof window === 'undefined') {
|
|
20
|
+
// Programmatic goto on the server is rare but we tolerate it as a
|
|
21
|
+
// way for tests to drive the router without a real DOM.
|
|
22
|
+
return new URL(target, 'http://localhost/');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return new URL(target, window.location.href);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const isExternal = (target: URL) => {
|
|
29
|
+
if (typeof window === 'undefined') return false;
|
|
30
|
+
|
|
31
|
+
return target.origin !== window.location.origin;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const writeHistory = (target: URL, options: GotoOptions) => {
|
|
35
|
+
if (typeof window === 'undefined') return;
|
|
36
|
+
|
|
37
|
+
const href =
|
|
38
|
+
activeMode === 'hash'
|
|
39
|
+
? `${window.location.pathname}${window.location.search}${buildHashHref(target.pathname + target.search)}`
|
|
40
|
+
: `${target.pathname}${target.search}${target.hash}`;
|
|
41
|
+
|
|
42
|
+
const method = options.replaceState
|
|
43
|
+
? window.history.replaceState
|
|
44
|
+
: window.history.pushState;
|
|
45
|
+
method.call(window.history, options.state ?? null, '', href);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const applyScrollAndFocus = (options: GotoOptions) => {
|
|
49
|
+
if (typeof window === 'undefined') return;
|
|
50
|
+
|
|
51
|
+
if (!options.noScroll) window.scrollTo({ left: 0, top: 0 });
|
|
52
|
+
if (!options.keepFocus && document.activeElement instanceof HTMLElement) {
|
|
53
|
+
document.activeElement.blur();
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Programmatically navigate to a URL. Updates `page.url`, writes history,
|
|
59
|
+
* and (when supported) wraps the swap in `document.startViewTransition`.
|
|
60
|
+
*
|
|
61
|
+
* Mirrors SvelteKit's `goto` from `$app/navigation` — same name, same
|
|
62
|
+
* options shape, so a SvelteKit user finds the primitive familiar.
|
|
63
|
+
*/
|
|
64
|
+
export const goto = async (target: string, options: GotoOptions = {}) => {
|
|
65
|
+
const url = resolveAbsoluteUrl(target);
|
|
66
|
+
|
|
67
|
+
if (isExternal(url)) {
|
|
68
|
+
// External URLs go through the browser — we don't try to SPA them.
|
|
69
|
+
if (typeof window !== 'undefined') {
|
|
70
|
+
window.location.href = url.href;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
consumePrefetch(target);
|
|
77
|
+
|
|
78
|
+
const mutate = () => {
|
|
79
|
+
writeHistory(url, options);
|
|
80
|
+
setPage({
|
|
81
|
+
params: {},
|
|
82
|
+
state: options.state ?? null,
|
|
83
|
+
url
|
|
84
|
+
});
|
|
85
|
+
applyScrollAndFocus(options);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
await withViewTransition(mutate);
|
|
89
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hash mode: routing happens against `window.location.hash` with the
|
|
3
|
+
* leading `#/` stripped. Useful for static deploys (GitHub Pages, S3)
|
|
4
|
+
* where the host can't be configured to wildcard-route to one HTML file.
|
|
5
|
+
*
|
|
6
|
+
* URLs look like `https://example.com/#/dashboard/settings`. The
|
|
7
|
+
* `pathname` part stays at `/` so the server always serves the same
|
|
8
|
+
* page; `<Route>` matching looks at the hash instead.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Extract the routable pathname from a full URL when hash mode is on.
|
|
13
|
+
* Returns the part after `#/`, prefixed with `/` so it parses as a
|
|
14
|
+
* normal pathname.
|
|
15
|
+
*/
|
|
16
|
+
export const hashPathnameOf = (url: URL) => {
|
|
17
|
+
const hash = url.hash;
|
|
18
|
+
if (!hash || hash === '#') return '/';
|
|
19
|
+
|
|
20
|
+
// Tolerate both `#/foo` and `#foo`.
|
|
21
|
+
const trimmed = hash.startsWith('#/') ? hash.slice(2) : hash.slice(1);
|
|
22
|
+
|
|
23
|
+
if (trimmed === '') return '/';
|
|
24
|
+
|
|
25
|
+
return `/${trimmed.replace(/^\/+/, '')}`;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Build a hash URL from a routable pathname. Used by goto() / pushState()
|
|
30
|
+
* when in hash mode — converts `/dashboard/settings` to `#/dashboard/settings`
|
|
31
|
+
* and writes the result back to the URL.
|
|
32
|
+
*/
|
|
33
|
+
export const buildHashHref = (pathname: string) => {
|
|
34
|
+
const trimmed = pathname.replace(/^\/+/, '');
|
|
35
|
+
|
|
36
|
+
return trimmed === '' ? '#/' : `#/${trimmed}`;
|
|
37
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Svelte components are imported by their .svelte path, not from this entry:
|
|
2
|
+
// import Router from '@absolutejs/absolute/svelte/router/Router.svelte';
|
|
3
|
+
// import Route from '@absolutejs/absolute/svelte/router/Route.svelte';
|
|
4
|
+
// import Link from '@absolutejs/absolute/svelte/router/Link.svelte';
|
|
5
|
+
//
|
|
6
|
+
// This entry only re-exports the non-component runtime API (programmatic
|
|
7
|
+
// navigation, reactive state, shallow routing). It mirrors the existing
|
|
8
|
+
// `@absolutejs/absolute/svelte/components/*.svelte` convention used by
|
|
9
|
+
// the framework's other Svelte components (Island, Image, StreamSlot,
|
|
10
|
+
// etc.) so user .svelte files can import the components directly via
|
|
11
|
+
// AbsoluteJS's Svelte compile pipeline.
|
|
12
|
+
|
|
13
|
+
export { goto } from './goto';
|
|
14
|
+
export { page } from './page.svelte';
|
|
15
|
+
export { pushState, replaceState } from './pushState';
|
|
16
|
+
|
|
17
|
+
export type {
|
|
18
|
+
ExtractRouteParams,
|
|
19
|
+
GotoOptions,
|
|
20
|
+
LinkPrefetchMode,
|
|
21
|
+
PageState,
|
|
22
|
+
RouterMode
|
|
23
|
+
} from '../../types/svelteRouter';
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtractRouteParams,
|
|
3
|
+
RouteMatchResult
|
|
4
|
+
} from '../../types/svelteRouter';
|
|
5
|
+
|
|
6
|
+
type CompiledSegment =
|
|
7
|
+
| { kind: 'static'; value: string }
|
|
8
|
+
| { kind: 'param'; name: string; optional: boolean }
|
|
9
|
+
| { kind: 'wildcard' };
|
|
10
|
+
|
|
11
|
+
type CompiledPattern = {
|
|
12
|
+
segments: CompiledSegment[];
|
|
13
|
+
score: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const STATIC_SEGMENT_WEIGHT = 100;
|
|
17
|
+
const PARAM_SEGMENT_WEIGHT = 10;
|
|
18
|
+
const WILDCARD_SEGMENT_WEIGHT = 1;
|
|
19
|
+
const OPTIONAL_PENALTY = 1;
|
|
20
|
+
|
|
21
|
+
const splitPath = (path: string) => {
|
|
22
|
+
const trimmed = path.replace(/^\/+/, '').replace(/\/+$/, '');
|
|
23
|
+
if (trimmed === '') return [];
|
|
24
|
+
|
|
25
|
+
return trimmed.split('/');
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const compileSegment = (raw: string): CompiledSegment => {
|
|
29
|
+
if (raw === '*' || raw.startsWith('*')) {
|
|
30
|
+
return { kind: 'wildcard' };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (raw.startsWith(':')) {
|
|
34
|
+
const body = raw.slice(1);
|
|
35
|
+
const optional = body.endsWith('?');
|
|
36
|
+
const name = optional ? body.slice(0, -1) : body;
|
|
37
|
+
|
|
38
|
+
return { kind: 'param', name, optional };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { kind: 'static', value: raw };
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Compile a `<Route path>` pattern into segments + a specificity score.
|
|
46
|
+
* Higher score = more specific (longer static prefix beats parameterised).
|
|
47
|
+
*/
|
|
48
|
+
export const compilePattern = (pattern: string): CompiledPattern => {
|
|
49
|
+
const segments = splitPath(pattern).map(compileSegment);
|
|
50
|
+
|
|
51
|
+
let score = 0;
|
|
52
|
+
for (const segment of segments) {
|
|
53
|
+
if (segment.kind === 'static') score += STATIC_SEGMENT_WEIGHT;
|
|
54
|
+
else if (segment.kind === 'param') {
|
|
55
|
+
score += PARAM_SEGMENT_WEIGHT;
|
|
56
|
+
if (segment.optional) score -= OPTIONAL_PENALTY;
|
|
57
|
+
} else if (segment.kind === 'wildcard')
|
|
58
|
+
score += WILDCARD_SEGMENT_WEIGHT;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { segments, score };
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Match a URL pathname against a compiled pattern. Returns the extracted
|
|
66
|
+
* params on a successful match, or a miss otherwise.
|
|
67
|
+
*/
|
|
68
|
+
export const matchPattern = <Path extends string>(
|
|
69
|
+
pattern: CompiledPattern,
|
|
70
|
+
pathname: string
|
|
71
|
+
): RouteMatchResult<ExtractRouteParams<Path>> => {
|
|
72
|
+
const pathSegments = splitPath(pathname);
|
|
73
|
+
const params: Record<string, string | undefined> = {};
|
|
74
|
+
|
|
75
|
+
let pi = 0;
|
|
76
|
+
for (let si = 0; si < pattern.segments.length; si++) {
|
|
77
|
+
const segment = pattern.segments[si];
|
|
78
|
+
if (!segment) continue;
|
|
79
|
+
|
|
80
|
+
if (segment.kind === 'wildcard') {
|
|
81
|
+
params['wildcard'] = pathSegments.slice(pi).join('/');
|
|
82
|
+
return {
|
|
83
|
+
matched: true,
|
|
84
|
+
params: params as ExtractRouteParams<Path>
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const candidate = pathSegments[pi];
|
|
89
|
+
|
|
90
|
+
if (candidate === undefined) {
|
|
91
|
+
if (segment.kind === 'param' && segment.optional) {
|
|
92
|
+
params[segment.name] = undefined;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { matched: false };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (segment.kind === 'static') {
|
|
100
|
+
if (segment.value !== candidate) return { matched: false };
|
|
101
|
+
pi++;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// param
|
|
106
|
+
params[segment.name] = candidate;
|
|
107
|
+
pi++;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (pi !== pathSegments.length) {
|
|
111
|
+
return { matched: false };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
matched: true,
|
|
116
|
+
params: params as ExtractRouteParams<Path>
|
|
117
|
+
};
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Stable comparator for compiled patterns. Higher specificity sorts first.
|
|
122
|
+
* When two patterns have equal score, declaration order (the original index)
|
|
123
|
+
* decides — passed in via the `index` field on each entry.
|
|
124
|
+
*/
|
|
125
|
+
export const comparePatterns = (
|
|
126
|
+
a: { score: number; index: number },
|
|
127
|
+
b: { score: number; index: number }
|
|
128
|
+
) => {
|
|
129
|
+
if (a.score !== b.score) return b.score - a.score;
|
|
130
|
+
|
|
131
|
+
return a.index - b.index;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Join a basepath stack with a child pattern, producing an absolute pattern
|
|
136
|
+
* that the route matcher can compile against an incoming pathname.
|
|
137
|
+
*
|
|
138
|
+
* Handles slash edge cases:
|
|
139
|
+
* joinBasepath('', '/users') → '/users'
|
|
140
|
+
* joinBasepath('/portal', '/users') → '/portal/users'
|
|
141
|
+
* joinBasepath('/portal/', '/users') → '/portal/users'
|
|
142
|
+
* joinBasepath('/portal', 'users') → '/portal/users'
|
|
143
|
+
* joinBasepath('/portal', '/') → '/portal'
|
|
144
|
+
*/
|
|
145
|
+
export const joinBasepath = (basepath: string, pattern: string) => {
|
|
146
|
+
const trimmedBase = basepath.replace(/\/+$/, '');
|
|
147
|
+
const trimmedPattern = pattern.replace(/^\/+/, '');
|
|
148
|
+
|
|
149
|
+
if (trimmedPattern === '') {
|
|
150
|
+
return trimmedBase === '' ? '/' : trimmedBase;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (trimmedBase === '') {
|
|
154
|
+
return `/${trimmedPattern}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return `${trimmedBase}/${trimmedPattern}`;
|
|
158
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { PageState } from '../../types/svelteRouter';
|
|
2
|
+
|
|
3
|
+
const initialUrl = () => {
|
|
4
|
+
if (typeof window !== 'undefined') {
|
|
5
|
+
return new URL(window.location.href);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// On the server we don't know the URL yet — Router.svelte initializes
|
|
9
|
+
// it from its `url` prop. Use a placeholder that Router.svelte will
|
|
10
|
+
// overwrite immediately.
|
|
11
|
+
return new URL('http://localhost/');
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const initialState = (): PageState => ({
|
|
15
|
+
params: {},
|
|
16
|
+
state: undefined,
|
|
17
|
+
url: initialUrl()
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const inner = $state<PageState>(initialState());
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Reactive route state. Mirrors SvelteKit's `page` from `$app/state`:
|
|
24
|
+
*
|
|
25
|
+
* import { page } from '@absolutejs/absolute/svelte/router';
|
|
26
|
+
* page.url.pathname // current path (reactive)
|
|
27
|
+
* page.url.searchParams // parsed URLSearchParams (reactive)
|
|
28
|
+
* page.params.id // active route params (reactive)
|
|
29
|
+
* page.state // history.state for the current entry
|
|
30
|
+
*
|
|
31
|
+
* Backed by `$state`. Direct property access in templates re-renders.
|
|
32
|
+
*/
|
|
33
|
+
export const page = inner;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Internal — only Router.svelte and the navigation primitives call this.
|
|
37
|
+
* Replaces the entire page state in one assignment so subscribers fire
|
|
38
|
+
* once per navigation rather than once per mutated field.
|
|
39
|
+
*/
|
|
40
|
+
export const setPage = (next: Partial<PageState>) => {
|
|
41
|
+
if (next.url !== undefined) inner.url = next.url;
|
|
42
|
+
if (next.params !== undefined) inner.params = next.params;
|
|
43
|
+
if (next.state !== undefined) inner.state = next.state;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Internal — used during SSR to seed the page state before render so
|
|
48
|
+
* `<Route>` blocks see the correct `page.url`.
|
|
49
|
+
*/
|
|
50
|
+
export const seedPage = (
|
|
51
|
+
url: URL,
|
|
52
|
+
params: Record<string, string | undefined> = {}
|
|
53
|
+
) => {
|
|
54
|
+
inner.url = url;
|
|
55
|
+
inner.params = params;
|
|
56
|
+
inner.state = undefined;
|
|
57
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
const PREFETCH_CACHE_LIMIT = 16;
|
|
2
|
+
const HOVER_DEBOUNCE_MS = 250;
|
|
3
|
+
|
|
4
|
+
type CacheEntry = {
|
|
5
|
+
url: string;
|
|
6
|
+
promise: Promise<Response>;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const cache = new Map<string, CacheEntry>();
|
|
10
|
+
|
|
11
|
+
const isSlowConnection = () => {
|
|
12
|
+
if (typeof navigator === 'undefined') return false;
|
|
13
|
+
|
|
14
|
+
const connection = (
|
|
15
|
+
navigator as Navigator & {
|
|
16
|
+
connection?: { saveData?: boolean };
|
|
17
|
+
}
|
|
18
|
+
).connection;
|
|
19
|
+
|
|
20
|
+
return connection?.saveData === true;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const prefersReducedData = () => {
|
|
24
|
+
if (typeof window === 'undefined' || !window.matchMedia) return false;
|
|
25
|
+
|
|
26
|
+
return window.matchMedia('(prefers-reduced-data: reduce)').matches;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const evictOldest = () => {
|
|
30
|
+
const oldest = cache.keys().next();
|
|
31
|
+
if (oldest.done) return;
|
|
32
|
+
cache.delete(oldest.value);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Prefetch a URL into the in-memory cache. No-op if the user has signalled
|
|
37
|
+
* data-saver / reduced-data, or if the URL is already cached.
|
|
38
|
+
*/
|
|
39
|
+
export const prefetch = (url: string) => {
|
|
40
|
+
if (typeof fetch === 'undefined') return;
|
|
41
|
+
if (isSlowConnection() || prefersReducedData()) return;
|
|
42
|
+
if (cache.has(url)) return;
|
|
43
|
+
|
|
44
|
+
while (cache.size >= PREFETCH_CACHE_LIMIT) evictOldest();
|
|
45
|
+
|
|
46
|
+
const promise = fetch(url, { credentials: 'same-origin' }).catch(
|
|
47
|
+
() => new Response(null, { status: 0 })
|
|
48
|
+
);
|
|
49
|
+
cache.set(url, { promise, url });
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Consume a cached prefetch entry on actual navigation, removing it from
|
|
54
|
+
* the cache. Returns the cached Promise<Response> or undefined.
|
|
55
|
+
*/
|
|
56
|
+
export const consumePrefetch = (url: string) => {
|
|
57
|
+
const entry = cache.get(url);
|
|
58
|
+
if (!entry) return undefined;
|
|
59
|
+
cache.delete(url);
|
|
60
|
+
|
|
61
|
+
return entry.promise;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const clearPrefetchCache = () => {
|
|
65
|
+
cache.clear();
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
type HoverHandle = {
|
|
69
|
+
cancel: () => void;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Wrap a prefetch trigger in a hover-debounce so glancing across many links
|
|
74
|
+
* doesn't fire a fetch storm. The returned handle's `cancel()` aborts the
|
|
75
|
+
* pending hover prefetch (e.g. on `pointerleave`).
|
|
76
|
+
*/
|
|
77
|
+
export const scheduleHoverPrefetch = (url: string): HoverHandle => {
|
|
78
|
+
if (typeof window === 'undefined') {
|
|
79
|
+
return { cancel: () => {} };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const timer = window.setTimeout(() => {
|
|
83
|
+
prefetch(url);
|
|
84
|
+
}, HOVER_DEBOUNCE_MS);
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
cancel: () => window.clearTimeout(timer)
|
|
88
|
+
};
|
|
89
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { setPage } from './page.svelte';
|
|
2
|
+
|
|
3
|
+
const resolveTarget = (target: string) => {
|
|
4
|
+
if (typeof window === 'undefined')
|
|
5
|
+
return new URL(target, 'http://localhost/');
|
|
6
|
+
|
|
7
|
+
return new URL(target, window.location.href);
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Shallow routing: update the URL bar and `page.state` without re-running
|
|
12
|
+
* `<Route>` matching. Useful for modals / drawers / overlays that want a
|
|
13
|
+
* shareable URL without swapping the active route's content.
|
|
14
|
+
*
|
|
15
|
+
* Mirrors SvelteKit's `pushState` from `$app/navigation`.
|
|
16
|
+
*/
|
|
17
|
+
export const pushState = (target: string, state: unknown) => {
|
|
18
|
+
if (typeof window === 'undefined') return;
|
|
19
|
+
|
|
20
|
+
const url = resolveTarget(target);
|
|
21
|
+
window.history.pushState(state, '', url.href);
|
|
22
|
+
setPage({ state, url });
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Same as `pushState` but uses `history.replaceState`. Mirrors SvelteKit's
|
|
27
|
+
* `replaceState` from `$app/navigation`.
|
|
28
|
+
*/
|
|
29
|
+
export const replaceState = (target: string, state: unknown) => {
|
|
30
|
+
if (typeof window === 'undefined') return;
|
|
31
|
+
|
|
32
|
+
const url = resolveTarget(target);
|
|
33
|
+
window.history.replaceState(state, '', url.href);
|
|
34
|
+
setPage({ state, url });
|
|
35
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
type StartViewTransition = (callback: () => void | Promise<void>) => {
|
|
2
|
+
finished: Promise<void>;
|
|
3
|
+
};
|
|
4
|
+
|
|
5
|
+
const supportsViewTransitions = () => {
|
|
6
|
+
if (typeof document === 'undefined') return false;
|
|
7
|
+
|
|
8
|
+
return (
|
|
9
|
+
typeof (document as { startViewTransition?: StartViewTransition })
|
|
10
|
+
.startViewTransition === 'function'
|
|
11
|
+
);
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Wrap a state mutation in `document.startViewTransition` when supported.
|
|
16
|
+
* Falls through to a synchronous call otherwise. Reduced-motion users get
|
|
17
|
+
* instant swaps via the browser's own handling of `prefers-reduced-motion`.
|
|
18
|
+
*/
|
|
19
|
+
export const withViewTransition = async (
|
|
20
|
+
mutate: () => void | Promise<void>
|
|
21
|
+
) => {
|
|
22
|
+
if (!supportsViewTransitions()) {
|
|
23
|
+
await mutate();
|
|
24
|
+
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const start = (document as { startViewTransition: StartViewTransition })
|
|
29
|
+
.startViewTransition;
|
|
30
|
+
await start(() => mutate()).finished;
|
|
31
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public type surface for the AbsoluteJS Svelte router.
|
|
3
|
+
*
|
|
4
|
+
* `ExtractRouteParams<P>` walks a path-pattern string literal and produces
|
|
5
|
+
* a typed `params` shape:
|
|
6
|
+
*
|
|
7
|
+
* ExtractRouteParams<'/users/:id'> → { id: string }
|
|
8
|
+
* ExtractRouteParams<'/users/:id/posts/:pid'> → { id: string; pid: string }
|
|
9
|
+
* ExtractRouteParams<'/users/:id?'> → { id: string | undefined }
|
|
10
|
+
* ExtractRouteParams<'/files/*'> → { wildcard: string }
|
|
11
|
+
* ExtractRouteParams<'/dashboard'> → Record<string, never>
|
|
12
|
+
*
|
|
13
|
+
* Edge cases:
|
|
14
|
+
* - Optional params (`:name?`) appear as `string | undefined`
|
|
15
|
+
* - Wildcard tail (`*`) is exposed as the `wildcard` key
|
|
16
|
+
* - Same-name twice in one path is intentionally not detected at the type
|
|
17
|
+
* level (it's a logic bug in the user's pattern, not something the type
|
|
18
|
+
* system meaningfully rescues).
|
|
19
|
+
*/
|
|
20
|
+
type IsOptionalParamSegment<Segment extends string> = Segment extends `${string}?` ? true : false;
|
|
21
|
+
type StripOptionalSuffix<Segment extends string> = Segment extends `${infer Name}?` ? Name : Segment;
|
|
22
|
+
type WildcardSegment = '*' | `*${string}`;
|
|
23
|
+
type ParseSegment<Segment extends string> = Segment extends WildcardSegment ? {
|
|
24
|
+
wildcard: string;
|
|
25
|
+
} : Segment extends `:${infer Name}` ? IsOptionalParamSegment<Name> extends true ? {
|
|
26
|
+
[K in StripOptionalSuffix<Name>]: string | undefined;
|
|
27
|
+
} : {
|
|
28
|
+
[K in Name]: string;
|
|
29
|
+
} : Record<never, never>;
|
|
30
|
+
type SplitPath<Path extends string> = Path extends `/${infer Rest}` ? SplitPath<Rest> : Path extends `${infer Head}/${infer Tail}` ? ParseSegment<Head> & SplitPath<Tail> : ParseSegment<Path>;
|
|
31
|
+
type Simplify<T> = {
|
|
32
|
+
[K in keyof T]: T[K];
|
|
33
|
+
} & {};
|
|
34
|
+
export type ExtractRouteParams<Path extends string> = string extends Path ? Record<string, string> : Simplify<SplitPath<Path>> extends infer Result ? keyof Result extends never ? Record<string, never> : Result : never;
|
|
35
|
+
export type RouteMatch<Params extends Record<string, unknown>> = {
|
|
36
|
+
matched: true;
|
|
37
|
+
params: Params;
|
|
38
|
+
};
|
|
39
|
+
export type RouteMiss = {
|
|
40
|
+
matched: false;
|
|
41
|
+
};
|
|
42
|
+
export type RouteMatchResult<Params extends Record<string, unknown>> = RouteMatch<Params> | RouteMiss;
|
|
43
|
+
export type RouterMode = 'history' | 'hash';
|
|
44
|
+
export type GotoOptions = {
|
|
45
|
+
/** Use `history.replaceState` instead of `pushState`. */
|
|
46
|
+
replaceState?: boolean;
|
|
47
|
+
/** Don't reset focus to body on navigation. */
|
|
48
|
+
keepFocus?: boolean;
|
|
49
|
+
/** Don't scroll to top on navigation. */
|
|
50
|
+
noScroll?: boolean;
|
|
51
|
+
/** Value attached to `history.state`. */
|
|
52
|
+
state?: unknown;
|
|
53
|
+
};
|
|
54
|
+
export type LinkPrefetchMode = 'hover' | 'viewport' | 'none';
|
|
55
|
+
export type PageState = {
|
|
56
|
+
url: URL;
|
|
57
|
+
params: Record<string, string | undefined>;
|
|
58
|
+
state: unknown;
|
|
59
|
+
};
|
|
60
|
+
export type RouterContextValue = {
|
|
61
|
+
/** Stacked basepath from outer to inner Router (joined). */
|
|
62
|
+
basepath: string;
|
|
63
|
+
mode: RouterMode;
|
|
64
|
+
};
|
|
65
|
+
export {};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public type surface for the AbsoluteJS Svelte router.
|
|
3
|
+
*
|
|
4
|
+
* `ExtractRouteParams<P>` walks a path-pattern string literal and produces
|
|
5
|
+
* a typed `params` shape:
|
|
6
|
+
*
|
|
7
|
+
* ExtractRouteParams<'/users/:id'> → { id: string }
|
|
8
|
+
* ExtractRouteParams<'/users/:id/posts/:pid'> → { id: string; pid: string }
|
|
9
|
+
* ExtractRouteParams<'/users/:id?'> → { id: string | undefined }
|
|
10
|
+
* ExtractRouteParams<'/files/*'> → { wildcard: string }
|
|
11
|
+
* ExtractRouteParams<'/dashboard'> → Record<string, never>
|
|
12
|
+
*
|
|
13
|
+
* Edge cases:
|
|
14
|
+
* - Optional params (`:name?`) appear as `string | undefined`
|
|
15
|
+
* - Wildcard tail (`*`) is exposed as the `wildcard` key
|
|
16
|
+
* - Same-name twice in one path is intentionally not detected at the type
|
|
17
|
+
* level (it's a logic bug in the user's pattern, not something the type
|
|
18
|
+
* system meaningfully rescues).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
type IsOptionalParamSegment<Segment extends string> =
|
|
22
|
+
Segment extends `${string}?` ? true : false;
|
|
23
|
+
|
|
24
|
+
type StripOptionalSuffix<Segment extends string> =
|
|
25
|
+
Segment extends `${infer Name}?` ? Name : Segment;
|
|
26
|
+
|
|
27
|
+
type WildcardSegment = '*' | `*${string}`;
|
|
28
|
+
|
|
29
|
+
type ParseSegment<Segment extends string> = Segment extends WildcardSegment
|
|
30
|
+
? { wildcard: string }
|
|
31
|
+
: Segment extends `:${infer Name}`
|
|
32
|
+
? IsOptionalParamSegment<Name> extends true
|
|
33
|
+
? { [K in StripOptionalSuffix<Name>]: string | undefined }
|
|
34
|
+
: { [K in Name]: string }
|
|
35
|
+
: Record<never, never>;
|
|
36
|
+
|
|
37
|
+
type SplitPath<Path extends string> = Path extends `/${infer Rest}`
|
|
38
|
+
? SplitPath<Rest>
|
|
39
|
+
: Path extends `${infer Head}/${infer Tail}`
|
|
40
|
+
? ParseSegment<Head> & SplitPath<Tail>
|
|
41
|
+
: ParseSegment<Path>;
|
|
42
|
+
|
|
43
|
+
type Simplify<T> = { [K in keyof T]: T[K] } & {};
|
|
44
|
+
|
|
45
|
+
export type ExtractRouteParams<Path extends string> = string extends Path
|
|
46
|
+
? Record<string, string>
|
|
47
|
+
: Simplify<SplitPath<Path>> extends infer Result
|
|
48
|
+
? keyof Result extends never
|
|
49
|
+
? Record<string, never>
|
|
50
|
+
: Result
|
|
51
|
+
: never;
|
|
52
|
+
|
|
53
|
+
export type RouteMatch<Params extends Record<string, unknown>> = {
|
|
54
|
+
matched: true;
|
|
55
|
+
params: Params;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type RouteMiss = {
|
|
59
|
+
matched: false;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export type RouteMatchResult<Params extends Record<string, unknown>> =
|
|
63
|
+
| RouteMatch<Params>
|
|
64
|
+
| RouteMiss;
|
|
65
|
+
|
|
66
|
+
export type RouterMode = 'history' | 'hash';
|
|
67
|
+
|
|
68
|
+
export type GotoOptions = {
|
|
69
|
+
/** Use `history.replaceState` instead of `pushState`. */
|
|
70
|
+
replaceState?: boolean;
|
|
71
|
+
/** Don't reset focus to body on navigation. */
|
|
72
|
+
keepFocus?: boolean;
|
|
73
|
+
/** Don't scroll to top on navigation. */
|
|
74
|
+
noScroll?: boolean;
|
|
75
|
+
/** Value attached to `history.state`. */
|
|
76
|
+
state?: unknown;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export type LinkPrefetchMode = 'hover' | 'viewport' | 'none';
|
|
80
|
+
|
|
81
|
+
export type PageState = {
|
|
82
|
+
url: URL;
|
|
83
|
+
params: Record<string, string | undefined>;
|
|
84
|
+
state: unknown;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export type RouterContextValue = {
|
|
88
|
+
/** Stacked basepath from outer to inner Router (joined). */
|
|
89
|
+
basepath: string;
|
|
90
|
+
mode: RouterMode;
|
|
91
|
+
};
|