@govela/app 0.0.0-stage → 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 +21 -0
- package/README.md +46 -2
- package/bin/vela-app.js +46 -0
- package/client.d.ts +84 -0
- package/client.js +690 -0
- package/match.d.ts +23 -0
- package/match.js +69 -0
- package/package.json +49 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 abullo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,47 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @govela/app
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The client router of Vela App, the application layer above the Vela compiler
|
|
4
|
+
with a Go server. The contract is [docs/APP.md](../../docs/APP.md).
|
|
5
|
+
|
|
6
|
+
It also installs the `vela-app` command, which generates the app sources,
|
|
7
|
+
runs the development loop and makes production builds:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install @govela/app @govela/runtime
|
|
11
|
+
npx vela-app dev
|
|
12
|
+
npx vela-app build
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`bin/vela-app.js` runs the binary of the optional dependency
|
|
16
|
+
`@govela/app-<os>-<cpu>` for this platform (linux x64/arm64, macOS
|
|
17
|
+
x64/arm64, Windows x64), or `$VELA_APP_BINARY`. Do not install with
|
|
18
|
+
`--omit=optional`. The guide is
|
|
19
|
+
[docs/VELA_APP.md](../../docs/VELA_APP.md).
|
|
20
|
+
|
|
21
|
+
`@govela/app/match` has the built-in
|
|
22
|
+
matchers (`int`, `uuid`, `slug`) and `matchPath`, which the route table that
|
|
23
|
+
`vela-app gen` writes (`src/_app/routes.js`) uses; the Go package
|
|
24
|
+
`github.com/elauven/vela/app` matches with the same rules.
|
|
25
|
+
|
|
26
|
+
`@govela/app/client`:
|
|
27
|
+
|
|
28
|
+
- `start({ VelaApp, routes })`, which the generated `client.js` calls,
|
|
29
|
+
hydrates the server document and installs the router: links of the same
|
|
30
|
+
origin navigate inside the app, the server sends the data of the changed
|
|
31
|
+
segments only, and layouts above them keep their DOM and state; a static
|
|
32
|
+
section arrives as server HTML, and a `RenderFragment` page as a server
|
|
33
|
+
rendered root that the router hydrates;
|
|
34
|
+
- `goto(url, { replace, state, invalidate })` and `invalidate()`;
|
|
35
|
+
- the `navigating` store;
|
|
36
|
+
- `beforeNavigate(fn)` and `afterNavigate(fn)`, which return a remover for
|
|
37
|
+
`onMount`;
|
|
38
|
+
- POST forms inside the app root are posted with `fetch` to the page's Go
|
|
39
|
+
action; links prefetch on hover (`data-vela-preload`, `start({ preload
|
|
40
|
+
})`).
|
|
41
|
+
|
|
42
|
+
The rules for links, history, scroll, focus and the head are in the
|
|
43
|
+
[Navigation](../../docs/APP.md#navigation) section of APP.md. Types for
|
|
44
|
+
editors are in `client.d.ts` and `match.d.ts`.
|
|
45
|
+
|
|
46
|
+
It imports only the public entry points of `@govela/runtime` (a peer
|
|
47
|
+
dependency) and has its own version, independent of the compiler's.
|
package/bin/vela-app.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The `vela-app` command of @govela/app: runs the vela-app binary of this
|
|
3
|
+
// platform (the optional dependency @govela/app-<os>-<cpu>), or
|
|
4
|
+
// $VELA_APP_BINARY, with the arguments, standard streams and exit code of
|
|
5
|
+
// the command.
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { createRequire } from 'node:module';
|
|
9
|
+
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
12
|
+
const platforms = ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'win32-x64'];
|
|
13
|
+
|
|
14
|
+
function binaryPath() {
|
|
15
|
+
if (process.env.VELA_APP_BINARY) {
|
|
16
|
+
return process.env.VELA_APP_BINARY;
|
|
17
|
+
}
|
|
18
|
+
const platform = `${process.platform}-${process.arch}`;
|
|
19
|
+
const name = `${manifest.name}-${platform}`;
|
|
20
|
+
if (!platforms.includes(platform)) {
|
|
21
|
+
throw new Error(`vela-app: no prebuilt binary for ${platform}; supported: ${platforms.join(', ')}. Install it with \`go install github.com/elauven/vela/app/cmd/vela-app@v${manifest.version}\` and set VELA_APP_BINARY to it.`);
|
|
22
|
+
}
|
|
23
|
+
const file = `bin/vela-app${process.platform === 'win32' ? '.exe' : ''}`;
|
|
24
|
+
try {
|
|
25
|
+
return require.resolve(`${name}/${file}`);
|
|
26
|
+
} catch {
|
|
27
|
+
throw new Error(`vela-app: the package ${name} is not installed. It is an optional dependency of ${manifest.name}; reinstall without --no-optional / --omit=optional, or set VELA_APP_BINARY to a vela-app binary.`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let binary;
|
|
32
|
+
try {
|
|
33
|
+
binary = binaryPath();
|
|
34
|
+
} catch (error) {
|
|
35
|
+
console.error(error.message);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
const result = spawnSync(binary, process.argv.slice(2), { stdio: 'inherit', windowsHide: true });
|
|
39
|
+
if (result.error) {
|
|
40
|
+
console.error(`vela-app: cannot run ${binary}: ${result.error.message}`);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
if (result.signal) {
|
|
44
|
+
process.kill(process.pid, result.signal);
|
|
45
|
+
}
|
|
46
|
+
process.exit(result.status ?? 1);
|
package/client.d.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Types of @govela/app/client for editors (docs/APP.md).
|
|
2
|
+
|
|
3
|
+
/** The route table of src/_app/routes.js, which vela-app generates. */
|
|
4
|
+
export interface RouteTable {
|
|
5
|
+
routes: { id: string; chain: { id: string; error?: string }[]; render?: 'auto' | 'data' | 'fragment' | 'document' }[];
|
|
6
|
+
components: Record<string, { load(): Promise<unknown> }>;
|
|
7
|
+
match(path: string): { route: { id: string }; params: Record<string, string> } | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** The route prop of route components (R-1). */
|
|
11
|
+
export interface Route {
|
|
12
|
+
id: string;
|
|
13
|
+
url: string;
|
|
14
|
+
path: string;
|
|
15
|
+
params: Record<string, string>;
|
|
16
|
+
query: Record<string, string>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** One end of a navigation: the URL and its route id (null when no route matches). */
|
|
20
|
+
export interface NavigationTarget {
|
|
21
|
+
url: URL;
|
|
22
|
+
route: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type NavigationType = 'link' | 'goto' | 'popstate' | 'invalidate' | 'form';
|
|
26
|
+
|
|
27
|
+
export interface Navigation {
|
|
28
|
+
from: NavigationTarget;
|
|
29
|
+
to: NavigationTarget;
|
|
30
|
+
type: NavigationType;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface BeforeNavigation extends Navigation {
|
|
34
|
+
/** Keeps the current page. */
|
|
35
|
+
cancel(): void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface StartOptions {
|
|
39
|
+
/** The generated app root, src/_app/VelaApp.vela. */
|
|
40
|
+
VelaApp: Function;
|
|
41
|
+
/** The generated route table, src/_app/routes.js. Without it links load documents. */
|
|
42
|
+
routes?: RouteTable;
|
|
43
|
+
/** The element that holds the app root, or its id. */
|
|
44
|
+
root?: string | Element;
|
|
45
|
+
/** Receives hydration mismatch reports. */
|
|
46
|
+
onMismatch?: (report: unknown) => void;
|
|
47
|
+
/** The default prefetch of links; data-vela-preload on a link or an ancestor overrides it. */
|
|
48
|
+
preload?: 'hover' | 'viewport' | 'off';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Hydrates the server document and installs the router. */
|
|
52
|
+
export function start(options: StartOptions): Promise<{ app: unknown; routes?: RouteTable }>;
|
|
53
|
+
|
|
54
|
+
export interface GotoOptions {
|
|
55
|
+
/** Replace the current history entry instead of adding one. */
|
|
56
|
+
replace?: boolean;
|
|
57
|
+
/** Kept in history.state.state. */
|
|
58
|
+
state?: unknown;
|
|
59
|
+
/** Run every loader again. */
|
|
60
|
+
invalidate?: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Navigates like a link click; resolves when the navigation ends. */
|
|
64
|
+
export function goto(url: string | URL, options?: GotoOptions): Promise<void>;
|
|
65
|
+
|
|
66
|
+
/** Loads the current URL again with every loader, keeping scroll and focus. */
|
|
67
|
+
export function invalidate(): Promise<void>;
|
|
68
|
+
|
|
69
|
+
/** null, or the navigation that is loading. */
|
|
70
|
+
export const navigating: {
|
|
71
|
+
subscribe(run: (value: Navigation | null) => void): () => void;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** Calls fn before every navigation of the router; returns a function that removes it. */
|
|
75
|
+
export function beforeNavigate(fn: (navigation: BeforeNavigation) => void): () => void;
|
|
76
|
+
|
|
77
|
+
/** Calls fn after every navigation of the router; returns a function that removes it. */
|
|
78
|
+
export function afterNavigate(fn: (navigation: Navigation) => void): () => void;
|
|
79
|
+
|
|
80
|
+
/** The navigation protocol version (N-1). */
|
|
81
|
+
export const protocolVersion: 1;
|
|
82
|
+
|
|
83
|
+
/** The request headers of a navigation (N-2). */
|
|
84
|
+
export const headers: Readonly<{ navigate: string; have: string; build: string }>;
|
package/client.js
ADDED
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
// Client router of Vela App (docs/APP.md). This module may import only the
|
|
2
|
+
// public entry points of @govela/runtime and its own files (docs/APP.md B-4,
|
|
3
|
+
// TestAppLayerIsSeparable).
|
|
4
|
+
import { tick, writable } from '@govela/runtime';
|
|
5
|
+
import { hydrate, preloadHydration, serverComponent, staticHTML } from '@govela/runtime/hydrate';
|
|
6
|
+
|
|
7
|
+
// The navigation protocol version (docs/APP.md N-1).
|
|
8
|
+
export const protocolVersion = 1;
|
|
9
|
+
|
|
10
|
+
// Request headers of a navigation (docs/APP.md N-2).
|
|
11
|
+
export const headers = Object.freeze({
|
|
12
|
+
navigate: 'X-Vela-Navigate',
|
|
13
|
+
have: 'X-Vela-Have',
|
|
14
|
+
build: 'X-Vela-Build',
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
// A chain of redirects longer than this loads the document (N-5).
|
|
18
|
+
const maxRedirects = 10;
|
|
19
|
+
// The key of the history index in history.state.
|
|
20
|
+
const indexKey = 'vela:index';
|
|
21
|
+
// The sessionStorage key of the saved scroll positions.
|
|
22
|
+
const scrollKey = 'vela:scroll';
|
|
23
|
+
|
|
24
|
+
const navigatingStore = writable(null);
|
|
25
|
+
|
|
26
|
+
// navigating is a store: null, or { from, to, type } while a navigation
|
|
27
|
+
// loads, type 'form' while a form action runs. from and to are { url,
|
|
28
|
+
// route } with route the route id.
|
|
29
|
+
export const navigating = { subscribe: navigatingStore.subscribe };
|
|
30
|
+
|
|
31
|
+
const beforeHooks = new Set();
|
|
32
|
+
const afterHooks = new Set();
|
|
33
|
+
|
|
34
|
+
// beforeNavigate(fn) calls fn({ from, to, type, cancel }) before every
|
|
35
|
+
// navigation of the router; cancel() keeps the current page. Full loads,
|
|
36
|
+
// such as leaving the site, are not reported. It returns a function that
|
|
37
|
+
// removes fn: return it from onMount, so it is removed on destroy.
|
|
38
|
+
export function beforeNavigate(fn) {
|
|
39
|
+
beforeHooks.add(fn);
|
|
40
|
+
return () => beforeHooks.delete(fn);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// afterNavigate(fn) calls fn({ from, to, type }) after every navigation of
|
|
44
|
+
// the router, once the page shows the new route. It returns a function that
|
|
45
|
+
// removes fn.
|
|
46
|
+
export function afterNavigate(fn) {
|
|
47
|
+
afterHooks.add(fn);
|
|
48
|
+
return () => afterHooks.delete(fn);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let router = null;
|
|
52
|
+
|
|
53
|
+
// goto(url, { replace, state, invalidate }) navigates like a link click.
|
|
54
|
+
// replace replaces the history entry; state is kept in history.state.state;
|
|
55
|
+
// invalidate runs every loader again. It resolves when the navigation ends.
|
|
56
|
+
// Before start(), and for another origin, it loads the document.
|
|
57
|
+
export function goto(url, { replace = false, state, invalidate = false } = {}) {
|
|
58
|
+
const target = new URL(url, location.href);
|
|
59
|
+
if (!router) {
|
|
60
|
+
location.assign(target.href);
|
|
61
|
+
return Promise.resolve();
|
|
62
|
+
}
|
|
63
|
+
return router.navigate(target, { type: 'goto', replace, state, invalidate });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// invalidate() loads the current URL again with every loader, keeping the
|
|
67
|
+
// scroll position, the focus and the history entry.
|
|
68
|
+
export function invalidate() {
|
|
69
|
+
if (!router) return Promise.resolve();
|
|
70
|
+
return router.navigate(new URL(location.href), { type: 'invalidate', replace: true, invalidate: true, keep: true });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// start({ VelaApp, routes, root, onMismatch, preload }) hydrates the app
|
|
74
|
+
// root of a server document (R-8) and installs the router: it loads the
|
|
75
|
+
// lazy components the payload names, adopts the server DOM, and from then
|
|
76
|
+
// on navigates inside the app without loading documents (N-2) and posts
|
|
77
|
+
// forms with fetch. root is the element or its id ('vela-app'); preload is
|
|
78
|
+
// the default prefetch mode of links ('hover', 'viewport' or 'off'). It
|
|
79
|
+
// resolves with { app, routes } and dispatches 'vela:hydrated' on root;
|
|
80
|
+
// every navigation of the router then dispatches 'vela:navigated', and a
|
|
81
|
+
// failed form action 'vela:form'.
|
|
82
|
+
export async function start({ VelaApp, routes, root = 'vela-app', onMismatch, preload = 'hover' } = {}) {
|
|
83
|
+
if (typeof VelaApp !== 'function') throw new TypeError('@govela/app/client: start() needs VelaApp');
|
|
84
|
+
const target = typeof root === 'string' ? document.getElementById(root) : root;
|
|
85
|
+
if (!target) throw new Error(`@govela/app/client: no element #${root} in the document`);
|
|
86
|
+
// hydrate() removes the payload; the router keeps route and chain (N-7).
|
|
87
|
+
const props = payloadProps(target);
|
|
88
|
+
await preloadHydration(target);
|
|
89
|
+
const options = onMismatch ? { onMismatch } : {};
|
|
90
|
+
const app = hydrate(VelaApp, target, options);
|
|
91
|
+
if (routes && props && Array.isArray(props.chain)) {
|
|
92
|
+
router = createRouter({ VelaApp, app, routes, target, route: props.route, chain: props.chain, preload });
|
|
93
|
+
}
|
|
94
|
+
target.dispatchEvent(new CustomEvent('vela:hydrated', { bubbles: true, detail: { app } }));
|
|
95
|
+
return { app, routes };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function payloadProps(target) {
|
|
99
|
+
for (const node of target.childNodes) {
|
|
100
|
+
if (node.nodeType === 1 && node.localName === 'script' && node.hasAttribute('data-vela-root')) {
|
|
101
|
+
try {
|
|
102
|
+
return JSON.parse(node.textContent).props || null;
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function createRouter({ VelaApp, app, routes, target, route, chain, preload }) {
|
|
112
|
+
const build = document.querySelector('meta[name="vela-build"]')?.getAttribute('content') || '';
|
|
113
|
+
const scroll = readScroll();
|
|
114
|
+
let index = typeof history.state?.[indexKey] === 'number' ? history.state[indexKey] : 0;
|
|
115
|
+
let controller = null;
|
|
116
|
+
let ignorePop = false;
|
|
117
|
+
// The VelaApp instances that render the chain (task 077): the hydrated
|
|
118
|
+
// root, then one per interactive fragment, which renders the levels from
|
|
119
|
+
// its start. views are the values that replace a level's component: an
|
|
120
|
+
// interactive fragment (the next instance) or a static one.
|
|
121
|
+
const stack = [{ instance: app, start: 0, views: {} }];
|
|
122
|
+
const statics = new WeakSet();
|
|
123
|
+
// The URL the page shows, without its hash.
|
|
124
|
+
let shown = withoutHash(location.href);
|
|
125
|
+
|
|
126
|
+
history.replaceState({ ...history.state, [indexKey]: index }, '');
|
|
127
|
+
history.scrollRestoration = 'manual';
|
|
128
|
+
if (scroll[index]) scrollTo(scroll[index].x, scroll[index].y);
|
|
129
|
+
const announcer = createAnnouncer();
|
|
130
|
+
|
|
131
|
+
const describe = (url, match) => ({ url, route: match ? match.route.id : null });
|
|
132
|
+
const current = () => describe(new URL(location.href), route ? { route } : null);
|
|
133
|
+
|
|
134
|
+
function saveScroll() {
|
|
135
|
+
scroll[index] = { x: scrollX, y: scrollY };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function fullLoad(url, how) {
|
|
139
|
+
navigatingStore.set(null);
|
|
140
|
+
if (how === 'popstate') location.reload();
|
|
141
|
+
else if (how === 'replace') location.replace(url.href);
|
|
142
|
+
else location.assign(url.href);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function navigate(url, opts) {
|
|
146
|
+
const how = opts.type === 'popstate' ? 'popstate' : opts.replace ? 'replace' : 'push';
|
|
147
|
+
if (url.origin !== location.origin) return fullLoad(url, how);
|
|
148
|
+
const match = routes.match(url.pathname);
|
|
149
|
+
const from = current();
|
|
150
|
+
const to = describe(url, match);
|
|
151
|
+
let cancelled = false;
|
|
152
|
+
for (const fn of [...beforeHooks]) {
|
|
153
|
+
fn({ from, to, type: opts.type, cancel: () => { cancelled = true; } });
|
|
154
|
+
}
|
|
155
|
+
if (cancelled) {
|
|
156
|
+
if (opts.type === 'popstate' && opts.delta) {
|
|
157
|
+
// Put the history back where the page is.
|
|
158
|
+
ignorePop = true;
|
|
159
|
+
history.go(-opts.delta);
|
|
160
|
+
}
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
// A URL the client cannot route is a document (N-2).
|
|
164
|
+
if (!match) return fullLoad(url, how);
|
|
165
|
+
|
|
166
|
+
if (controller) controller.abort();
|
|
167
|
+
const own = new AbortController();
|
|
168
|
+
controller = own;
|
|
169
|
+
if (opts.invalidate) clearPrefetched();
|
|
170
|
+
navigatingStore.set({ from, to, type: opts.type });
|
|
171
|
+
// The modules of a data route load while the server answers; another
|
|
172
|
+
// route may get a fragment that needs none of them.
|
|
173
|
+
if (match.route.render === 'data') {
|
|
174
|
+
for (const level of match.route.chain) components(routes, level.id)?.load().catch(() => {});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let body;
|
|
178
|
+
let at = url;
|
|
179
|
+
const have = opts.invalidate ? '' : haveKeys().join(',');
|
|
180
|
+
for (let redirects = 0; ; redirects++) {
|
|
181
|
+
let result;
|
|
182
|
+
try {
|
|
183
|
+
// A prefetched answer serves one navigation (task 078).
|
|
184
|
+
const cached = redirects === 0 && !opts.invalidate ? takePrefetched(at, have) : null;
|
|
185
|
+
result = await (cached ? cached.catch(() => fetchNavigation(at, have, own.signal)) : fetchNavigation(at, have, own.signal));
|
|
186
|
+
} catch {
|
|
187
|
+
if (own.signal.aborted) return;
|
|
188
|
+
// A network error or an answer that is not the protocol (N-1).
|
|
189
|
+
return fullLoad(at, how);
|
|
190
|
+
}
|
|
191
|
+
if (controller !== own) return;
|
|
192
|
+
if (result && result.redirected) {
|
|
193
|
+
// Not the app's answer, such as a proxy's redirect.
|
|
194
|
+
return fullLoad(new URL(result.redirected), how);
|
|
195
|
+
}
|
|
196
|
+
body = result;
|
|
197
|
+
if (!body || body.v !== protocolVersion) return fullLoad(at, how);
|
|
198
|
+
if (body.type !== 'redirect') break;
|
|
199
|
+
const next = new URL(body.location, at);
|
|
200
|
+
if (redirects + 1 >= maxRedirects || next.origin !== location.origin || !routes.match(next.pathname)) {
|
|
201
|
+
return fullLoad(next, how);
|
|
202
|
+
}
|
|
203
|
+
at = next;
|
|
204
|
+
to.url = next;
|
|
205
|
+
to.route = routes.match(next.pathname).route.id;
|
|
206
|
+
}
|
|
207
|
+
await show(body, { at, how, opts, own, from, to, fallback: () => fullLoad(at, how) });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// fetchNavigation asks the server for the navigation to url (N-2). It
|
|
211
|
+
// resolves with the JSON body, or { redirected } when something other
|
|
212
|
+
// than the app redirected the request.
|
|
213
|
+
async function fetchNavigation(url, have, signal) {
|
|
214
|
+
const response = await fetch(url.href, {
|
|
215
|
+
credentials: 'same-origin',
|
|
216
|
+
signal,
|
|
217
|
+
headers: { [headers.navigate]: `auto;v=${protocolVersion}`, [headers.have]: have, [headers.build]: build },
|
|
218
|
+
});
|
|
219
|
+
if (response.redirected) return { redirected: response.url };
|
|
220
|
+
return response.json();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// show puts a data or fragment response on the page: it loads what the
|
|
224
|
+
// response needs, updates the history, the app root and the head, then
|
|
225
|
+
// scroll and focus, and reports the navigation. fallback is the full
|
|
226
|
+
// load of a response the router cannot show.
|
|
227
|
+
async function show(body, { at, how, opts, own, from, to, fallback, form = false }) {
|
|
228
|
+
const fragment = body.type === 'fragment' ? body.fragment : null;
|
|
229
|
+
if ((body.type !== 'data' && !(fragment && typeof fragment.html === 'string')) || !Array.isArray(body.segments) || !(body.start >= 0 && body.start <= chain.length)) {
|
|
230
|
+
return fallback();
|
|
231
|
+
}
|
|
232
|
+
let view = null;
|
|
233
|
+
try {
|
|
234
|
+
view = await prepare(body);
|
|
235
|
+
} catch {
|
|
236
|
+
if (controller === own) fallback();
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (controller !== own) return;
|
|
240
|
+
controller = null;
|
|
241
|
+
|
|
242
|
+
if (!opts.keep) saveScroll();
|
|
243
|
+
if (how === 'popstate') {
|
|
244
|
+
index = opts.index;
|
|
245
|
+
} else {
|
|
246
|
+
const state = { [indexKey]: how === 'push' ? index + 1 : index };
|
|
247
|
+
if (opts.state !== undefined) state.state = opts.state;
|
|
248
|
+
if (how === 'push') {
|
|
249
|
+
history.pushState(state, '', at.href);
|
|
250
|
+
index++;
|
|
251
|
+
// The entries after this one are gone.
|
|
252
|
+
for (const key of Object.keys(scroll)) if (Number(key) > index) delete scroll[key];
|
|
253
|
+
} else {
|
|
254
|
+
history.replaceState(state, '', at.href);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
shown = withoutHash(at.href);
|
|
258
|
+
|
|
259
|
+
const autofocus = new Set(target.querySelectorAll('[autofocus]'));
|
|
260
|
+
apply(body, view);
|
|
261
|
+
applyHead(body.head);
|
|
262
|
+
await tick();
|
|
263
|
+
|
|
264
|
+
if (!opts.keep) {
|
|
265
|
+
if (how === 'popstate') {
|
|
266
|
+
const saved = scroll[index];
|
|
267
|
+
scrollTo(saved ? saved.x : 0, saved ? saved.y : 0);
|
|
268
|
+
} else if (!form) {
|
|
269
|
+
scrollToHash(at);
|
|
270
|
+
}
|
|
271
|
+
if (!form) moveFocus(target, autofocus);
|
|
272
|
+
announcer.textContent = document.title || at.pathname;
|
|
273
|
+
}
|
|
274
|
+
observeViewport();
|
|
275
|
+
navigatingStore.set(null);
|
|
276
|
+
const done = { from, to, type: opts.type };
|
|
277
|
+
for (const fn of [...afterHooks]) fn(done);
|
|
278
|
+
target.dispatchEvent(new CustomEvent('vela:navigated', { bubbles: true, detail: done }));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// prepare loads the modules a data response needs, or makes the
|
|
282
|
+
// component value of a fragment (R-8): staticHTML, or serverComponent
|
|
283
|
+
// after the lazy pages inside it loaded.
|
|
284
|
+
async function prepare(body) {
|
|
285
|
+
const fragment = body.type === 'fragment' ? body.fragment : null;
|
|
286
|
+
if (!fragment) {
|
|
287
|
+
await Promise.all(body.segments.map((segment) => components(routes, segment.id)?.load()));
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
if (fragment.static) {
|
|
291
|
+
const view = staticHTML(fragment.html);
|
|
292
|
+
statics.add(view);
|
|
293
|
+
return view;
|
|
294
|
+
}
|
|
295
|
+
const parsed = document.createElement('template');
|
|
296
|
+
parsed.innerHTML = fragment.html;
|
|
297
|
+
await preloadHydration(parsed.content);
|
|
298
|
+
return adoptFragment(fragment.html, body.start);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ── Prefetch (task 078) ────────────────────────────────────────────────
|
|
302
|
+
// A link prefetches its navigation on hover (after 50 ms), on touchstart,
|
|
303
|
+
// or when it enters the viewport, as data-vela-preload on it or an
|
|
304
|
+
// ancestor says ("hover", "viewport" or "off"; start({ preload }) is the
|
|
305
|
+
// default). The answer serves one navigation within 30 s, for the same
|
|
306
|
+
// URL and X-Vela-Have.
|
|
307
|
+
const prefetched = new Map();
|
|
308
|
+
const prefetchTTL = 30000;
|
|
309
|
+
|
|
310
|
+
function prefetchKey(url, have) {
|
|
311
|
+
return `${withoutHash(url.href)}\n${have}`;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function takePrefetched(url, have) {
|
|
315
|
+
const key = prefetchKey(url, have);
|
|
316
|
+
const entry = prefetched.get(key);
|
|
317
|
+
prefetched.delete(key);
|
|
318
|
+
return entry && Date.now() - entry.at < prefetchTTL ? entry.body : null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function clearPrefetched() {
|
|
322
|
+
prefetched.clear();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function prefetch(url) {
|
|
326
|
+
if (url.origin !== location.origin || withoutHash(url.href) === withoutHash(location.href)) return;
|
|
327
|
+
const match = routes.match(url.pathname);
|
|
328
|
+
if (!match) return;
|
|
329
|
+
const have = haveKeys().join(',');
|
|
330
|
+
const key = prefetchKey(url, have);
|
|
331
|
+
const now = Date.now();
|
|
332
|
+
for (const [k, entry] of prefetched) if (now - entry.at >= prefetchTTL) prefetched.delete(k);
|
|
333
|
+
if (prefetched.has(key)) return;
|
|
334
|
+
if (match.route.render === 'data') {
|
|
335
|
+
for (const level of match.route.chain) components(routes, level.id)?.load().catch(() => {});
|
|
336
|
+
}
|
|
337
|
+
const body = fetchNavigation(url, have, undefined);
|
|
338
|
+
prefetched.set(key, { at: now, body });
|
|
339
|
+
// What the answer needs loads too; a failure only drops the entry.
|
|
340
|
+
body.then((answer) => (answer && answer.v === protocolVersion && (answer.type === 'data' || answer.type === 'fragment') ? prepareModules(answer) : null))
|
|
341
|
+
.catch(() => prefetched.delete(key));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// prepareModules loads the modules of a prefetched answer without making
|
|
345
|
+
// its component value.
|
|
346
|
+
function prepareModules(body) {
|
|
347
|
+
if (body.type === 'data') return Promise.all(body.segments.map((segment) => components(routes, segment.id)?.load()));
|
|
348
|
+
if (body.fragment && !body.fragment.static) {
|
|
349
|
+
const parsed = document.createElement('template');
|
|
350
|
+
parsed.innerHTML = body.fragment.html;
|
|
351
|
+
return preloadHydration(parsed.content);
|
|
352
|
+
}
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function preloadMode(link) {
|
|
357
|
+
const holder = link.closest('[data-vela-preload]');
|
|
358
|
+
return holder ? holder.getAttribute('data-vela-preload') : preload;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
let hoverTimer = null;
|
|
362
|
+
function onPointerOver(event) {
|
|
363
|
+
const link = routableLink(event.target);
|
|
364
|
+
if (!link || preloadMode(link) === 'off') return;
|
|
365
|
+
clearTimeout(hoverTimer);
|
|
366
|
+
hoverTimer = setTimeout(() => prefetch(new URL(link.href)), 50);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function onPointerOut(event) {
|
|
370
|
+
const link = routableLink(event.target);
|
|
371
|
+
if (link && !link.contains(event.relatedTarget)) clearTimeout(hoverTimer);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function onTouchStart(event) {
|
|
375
|
+
const link = routableLink(event.target);
|
|
376
|
+
if (link && preloadMode(link) !== 'off') prefetch(new URL(link.href));
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const viewport = typeof IntersectionObserver === 'function'
|
|
380
|
+
? new IntersectionObserver((entries) => {
|
|
381
|
+
for (const entry of entries) {
|
|
382
|
+
if (!entry.isIntersecting) continue;
|
|
383
|
+
viewport.unobserve(entry.target);
|
|
384
|
+
prefetch(new URL(entry.target.href));
|
|
385
|
+
}
|
|
386
|
+
})
|
|
387
|
+
: null;
|
|
388
|
+
|
|
389
|
+
// observeViewport watches the links whose mode is viewport; it runs at
|
|
390
|
+
// start and after each navigation.
|
|
391
|
+
function observeViewport() {
|
|
392
|
+
if (!viewport) return;
|
|
393
|
+
viewport.disconnect();
|
|
394
|
+
for (const link of document.querySelectorAll('a[href]')) {
|
|
395
|
+
if (routableLink(link) === link && preloadMode(link) === 'viewport') viewport.observe(link);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ── Form actions (task 078) ────────────────────────────────────────────
|
|
400
|
+
// A POST <form> inside the app root posts with fetch. A failure sets the
|
|
401
|
+
// page's form prop; a success shows the data of the page with every
|
|
402
|
+
// loader run again. data-vela-reload on the form or its submitter leaves
|
|
403
|
+
// it to the browser.
|
|
404
|
+
function onSubmit(event) {
|
|
405
|
+
if (event.defaultPrevented) return;
|
|
406
|
+
const form = event.target;
|
|
407
|
+
if (!(form instanceof HTMLFormElement) || !target.contains(form)) return;
|
|
408
|
+
const submitter = event.submitter || null;
|
|
409
|
+
const attr = (name, formName) => (submitter && submitter.hasAttribute(formName) ? submitter.getAttribute(formName) : form.getAttribute(name));
|
|
410
|
+
if ((attr('method', 'formmethod') || 'get').toLowerCase() !== 'post') return;
|
|
411
|
+
if (form.hasAttribute('data-vela-reload') || (submitter && submitter.hasAttribute('data-vela-reload'))) return;
|
|
412
|
+
const formTarget = attr('target', 'formtarget');
|
|
413
|
+
if (formTarget && formTarget !== '_self') return;
|
|
414
|
+
const action = new URL(submitter && submitter.hasAttribute('formaction') ? submitter.formAction : form.action, location.href);
|
|
415
|
+
if (action.origin !== location.origin) return;
|
|
416
|
+
event.preventDefault();
|
|
417
|
+
submit(form, submitter, action, attr('enctype', 'formenctype'));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function submit(form, submitter, action, enctype) {
|
|
421
|
+
let data;
|
|
422
|
+
try {
|
|
423
|
+
data = new FormData(form, submitter || undefined);
|
|
424
|
+
} catch {
|
|
425
|
+
data = new FormData(form);
|
|
426
|
+
}
|
|
427
|
+
// The browser posts again by itself when the router cannot answer.
|
|
428
|
+
const native = () => {
|
|
429
|
+
navigatingStore.set(null);
|
|
430
|
+
form.setAttribute('data-vela-reload', '');
|
|
431
|
+
if (submitter && submitter.form === form) form.requestSubmit(submitter);
|
|
432
|
+
else form.requestSubmit();
|
|
433
|
+
};
|
|
434
|
+
if (controller) controller.abort();
|
|
435
|
+
const own = new AbortController();
|
|
436
|
+
controller = own;
|
|
437
|
+
clearPrefetched();
|
|
438
|
+
const from = current();
|
|
439
|
+
// The page the action belongs to: the URL without ?/name.
|
|
440
|
+
const page = new URL(action.href);
|
|
441
|
+
page.search = page.search.slice(1).split('&').filter((part) => part && !part.startsWith('/')).join('&');
|
|
442
|
+
page.hash = '';
|
|
443
|
+
const to = describe(page, routes.match(page.pathname));
|
|
444
|
+
navigatingStore.set({ from, to, type: 'form' });
|
|
445
|
+
let body;
|
|
446
|
+
try {
|
|
447
|
+
const response = await fetch(action.href, {
|
|
448
|
+
method: 'POST',
|
|
449
|
+
credentials: 'same-origin',
|
|
450
|
+
signal: own.signal,
|
|
451
|
+
body: (enctype || '').toLowerCase() === 'multipart/form-data' ? data : new URLSearchParams(data),
|
|
452
|
+
headers: { [headers.navigate]: `auto;v=${protocolVersion}`, [headers.have]: '', [headers.build]: build },
|
|
453
|
+
});
|
|
454
|
+
if (response.redirected) {
|
|
455
|
+
if (controller === own) fullLoad(new URL(response.url), 'push');
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
body = await response.json();
|
|
459
|
+
} catch {
|
|
460
|
+
if (own.signal.aborted) return;
|
|
461
|
+
return native();
|
|
462
|
+
}
|
|
463
|
+
if (controller !== own) return;
|
|
464
|
+
if (!body || body.v !== protocolVersion) return native();
|
|
465
|
+
if (body.type === 'form') {
|
|
466
|
+
controller = null;
|
|
467
|
+
applyForm(body.form);
|
|
468
|
+
await tick();
|
|
469
|
+
navigatingStore.set(null);
|
|
470
|
+
target.dispatchEvent(new CustomEvent('vela:form', { bubbles: true, detail: { status: body.status, form: body.form } }));
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
if (body.type === 'redirect') {
|
|
474
|
+
controller = null;
|
|
475
|
+
navigatingStore.set(null);
|
|
476
|
+
return navigate(new URL(body.location, action), { type: 'goto' });
|
|
477
|
+
}
|
|
478
|
+
if (body.type !== 'data' && body.type !== 'fragment') return native();
|
|
479
|
+
const how = withoutHash(page.href) === shown ? 'replace' : 'push';
|
|
480
|
+
await show(body, { at: page, how, opts: { type: 'form', keep: how === 'replace' }, own, from, to, fallback: native, form: true });
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// applyForm sets the form prop of the page level after a failed action.
|
|
484
|
+
function applyForm(value) {
|
|
485
|
+
const j = chain.length - 1;
|
|
486
|
+
if (j < 0) return;
|
|
487
|
+
apply({ start: j, segments: [{ ...chain[j], form: value }], route }, null, true);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// haveKeys are the keys of X-Vela-Have: the chain up to the first level
|
|
491
|
+
// of a static fragment, whose levels hold no data and render as one
|
|
492
|
+
// piece, so the server sends them again when anything in them changes.
|
|
493
|
+
function haveKeys() {
|
|
494
|
+
let limit = chain.length;
|
|
495
|
+
for (const entry of stack) {
|
|
496
|
+
for (const [level, value] of Object.entries(entry.views)) {
|
|
497
|
+
if (statics.has(value)) limit = Math.min(limit, Number(level));
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
return chain.slice(0, limit).map((segment) => segment.key);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// adoptFragment is the component value of an interactive fragment: a
|
|
504
|
+
// VelaApp root rendered from level start. The instance it creates joins
|
|
505
|
+
// the stack when it mounts.
|
|
506
|
+
function adoptFragment(html, level) {
|
|
507
|
+
const adopted = serverComponent(VelaApp, html);
|
|
508
|
+
const view = function (mountTarget, props, slots) {
|
|
509
|
+
const instance = adopted(mountTarget, props, slots);
|
|
510
|
+
stack.push({ instance, start: level, views: {} });
|
|
511
|
+
return instance;
|
|
512
|
+
};
|
|
513
|
+
// The wrapper keeps what the runtime reads from the factory, such as
|
|
514
|
+
// its declared props.
|
|
515
|
+
return Object.assign(view, adopted);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// apply puts a data or fragment response on the page (N-7, R-8). Data
|
|
519
|
+
// from level j goes to the innermost instance that renders level j; a
|
|
520
|
+
// fragment at j goes into the views of the innermost instance that holds
|
|
521
|
+
// level j below its own first level (the root holds every level). The
|
|
522
|
+
// instances below the target go with the levels they rendered.
|
|
523
|
+
function apply(body, view, keepForm = false) {
|
|
524
|
+
const j = body.start;
|
|
525
|
+
let t = stack.length - 1;
|
|
526
|
+
while (t > 0 && (view ? stack[t].start >= j : stack[t].start > j)) t--;
|
|
527
|
+
stack.length = t + 1;
|
|
528
|
+
const owner = stack[t];
|
|
529
|
+
const views = {};
|
|
530
|
+
for (const [level, value] of Object.entries(owner.views)) {
|
|
531
|
+
if (Number(level) < j) views[level] = value;
|
|
532
|
+
}
|
|
533
|
+
if (view) views[j] = view;
|
|
534
|
+
owner.views = views;
|
|
535
|
+
// A form prop lasts until the next navigation.
|
|
536
|
+
const kept = chain.slice(0, j).map((segment) => (keepForm || !('form' in segment) ? segment : withoutForm(segment)));
|
|
537
|
+
chain = kept.concat(body.segments);
|
|
538
|
+
route = body.route;
|
|
539
|
+
for (const entry of stack.slice()) entry.instance.set({ route, chain, start: entry.start, views: entry.views });
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// routableLink is the <a> at node that the router navigates, or null.
|
|
543
|
+
// An SVG <a> is left to the browser.
|
|
544
|
+
function routableLink(node) {
|
|
545
|
+
const link = node instanceof Element ? node.closest('a[href]') : null;
|
|
546
|
+
if (!(link instanceof HTMLAnchorElement)) return null;
|
|
547
|
+
if ((link.target && link.target !== '_self') || link.hasAttribute('download') || link.hasAttribute('data-vela-reload')) return null;
|
|
548
|
+
if (/(^|\s)external(\s|$)/i.test(link.getAttribute('rel') || '')) return null;
|
|
549
|
+
return new URL(link.href).origin === location.origin ? link : null;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function onClick(event) {
|
|
553
|
+
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
554
|
+
const link = routableLink(event.target);
|
|
555
|
+
if (!link) return;
|
|
556
|
+
const url = new URL(link.href);
|
|
557
|
+
// A #hash on the same page scrolls; the browser does that.
|
|
558
|
+
if (url.hash && withoutHash(url.href) === withoutHash(location.href)) return;
|
|
559
|
+
event.preventDefault();
|
|
560
|
+
navigate(url, { type: 'link', replace: url.href === location.href });
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function onPopState(event) {
|
|
564
|
+
const to = event.state && typeof event.state[indexKey] === 'number' ? event.state[indexKey] : null;
|
|
565
|
+
if (ignorePop) {
|
|
566
|
+
ignorePop = false;
|
|
567
|
+
if (to !== null) index = to;
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
if (to === null) {
|
|
571
|
+
// An entry the browser made for a #hash link: give it an index.
|
|
572
|
+
saveScroll();
|
|
573
|
+
index++;
|
|
574
|
+
history.replaceState({ ...event.state, [indexKey]: index }, '');
|
|
575
|
+
if (withoutHash(location.href) === shown) return;
|
|
576
|
+
navigate(new URL(location.href), { type: 'popstate', index, delta: 0 });
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
if (withoutHash(location.href) === shown) {
|
|
580
|
+
// Only the hash differs.
|
|
581
|
+
saveScroll();
|
|
582
|
+
index = to;
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
navigate(new URL(location.href), { type: 'popstate', index: to, delta: to - index });
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
document.addEventListener('click', onClick);
|
|
589
|
+
document.addEventListener('submit', onSubmit);
|
|
590
|
+
document.addEventListener('mouseover', onPointerOver);
|
|
591
|
+
document.addEventListener('mouseout', onPointerOut);
|
|
592
|
+
document.addEventListener('touchstart', onTouchStart, { passive: true });
|
|
593
|
+
observeViewport();
|
|
594
|
+
addEventListener('popstate', onPopState);
|
|
595
|
+
addEventListener('pagehide', () => {
|
|
596
|
+
saveScroll();
|
|
597
|
+
writeScroll(scroll);
|
|
598
|
+
});
|
|
599
|
+
return { navigate };
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function withoutForm(segment) {
|
|
603
|
+
const { form, ...rest } = segment;
|
|
604
|
+
return rest;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function components(routes, id) {
|
|
608
|
+
return routes.components ? routes.components[id] || null : null;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function withoutHash(href) {
|
|
612
|
+
const at = href.indexOf('#');
|
|
613
|
+
return at < 0 ? href : href.slice(0, at);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function readScroll() {
|
|
617
|
+
try {
|
|
618
|
+
return JSON.parse(sessionStorage.getItem(scrollKey)) || {};
|
|
619
|
+
} catch {
|
|
620
|
+
return {};
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function writeScroll(scroll) {
|
|
625
|
+
try {
|
|
626
|
+
sessionStorage.setItem(scrollKey, JSON.stringify(scroll));
|
|
627
|
+
} catch {
|
|
628
|
+
// Storage may be unavailable; positions are then kept for this page only.
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function scrollToHash(url) {
|
|
633
|
+
if (url.hash) {
|
|
634
|
+
let id = url.hash.slice(1);
|
|
635
|
+
try {
|
|
636
|
+
id = decodeURIComponent(id);
|
|
637
|
+
} catch {
|
|
638
|
+
// Keep the raw hash.
|
|
639
|
+
}
|
|
640
|
+
const element = document.getElementById(id) || document.getElementsByName(id)[0];
|
|
641
|
+
if (element) {
|
|
642
|
+
element.scrollIntoView();
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
scrollTo(0, 0);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// moveFocus focuses the first [autofocus] element the navigation added, or
|
|
650
|
+
// the body, so that keyboard and screen reader users start on the new page.
|
|
651
|
+
function moveFocus(target, before) {
|
|
652
|
+
const added = [...target.querySelectorAll('[autofocus]')].find((element) => !before.has(element));
|
|
653
|
+
if (added && typeof added.focus === 'function') {
|
|
654
|
+
added.focus();
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
const body = document.body;
|
|
658
|
+
const had = body.hasAttribute('tabindex');
|
|
659
|
+
if (!had) body.setAttribute('tabindex', '-1');
|
|
660
|
+
body.focus({ preventScroll: true });
|
|
661
|
+
if (!had) body.removeAttribute('tabindex');
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// createAnnouncer adds a polite live region that announces the title of
|
|
665
|
+
// each new page; it is outside the app root, so hydration never sees it.
|
|
666
|
+
function createAnnouncer() {
|
|
667
|
+
const region = document.createElement('div');
|
|
668
|
+
region.id = 'vela-announcer';
|
|
669
|
+
region.setAttribute('aria-live', 'polite');
|
|
670
|
+
region.setAttribute('aria-atomic', 'true');
|
|
671
|
+
region.style.cssText = 'position:absolute;left:0;top:0;width:1px;height:1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0';
|
|
672
|
+
document.body.appendChild(region);
|
|
673
|
+
return region;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// applyHead replaces the title and the <meta data-vela-head> tags (R-6).
|
|
677
|
+
// Without a head the page keeps its head (N-5).
|
|
678
|
+
function applyHead(head) {
|
|
679
|
+
if (!head) return;
|
|
680
|
+
document.title = head.title || '';
|
|
681
|
+
for (const meta of document.head.querySelectorAll('meta[data-vela-head]')) meta.remove();
|
|
682
|
+
for (const m of head.meta || []) {
|
|
683
|
+
const meta = document.createElement('meta');
|
|
684
|
+
if (m.property) meta.setAttribute('property', m.property);
|
|
685
|
+
else meta.setAttribute('name', m.name || '');
|
|
686
|
+
meta.setAttribute('content', m.content || '');
|
|
687
|
+
meta.setAttribute('data-vela-head', '');
|
|
688
|
+
document.head.appendChild(meta);
|
|
689
|
+
}
|
|
690
|
+
}
|
package/match.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Types of @govela/app/match for editors (docs/APP.md P-6, P-8, P-13).
|
|
2
|
+
|
|
3
|
+
export type Matcher = (segment: string) => boolean;
|
|
4
|
+
|
|
5
|
+
export const int: Matcher;
|
|
6
|
+
export const uuid: Matcher;
|
|
7
|
+
export const slug: Matcher;
|
|
8
|
+
export const builtinMatchers: Readonly<{ int: Matcher; uuid: Matcher; slug: Matcher }>;
|
|
9
|
+
|
|
10
|
+
export interface PatternSegment {
|
|
11
|
+
kind: 'static' | 'param' | 'rest';
|
|
12
|
+
value: string;
|
|
13
|
+
matcher?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface MatchTable<R> {
|
|
17
|
+
routes: R[];
|
|
18
|
+
patterns: { route: number; segments: PatternSegment[] }[];
|
|
19
|
+
matchers: Record<string, Matcher>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Finds the route of a path: the first pattern that matches in full. */
|
|
23
|
+
export function matchPath<R>(table: MatchTable<R>, path: string): { route: R; params: Record<string, string> } | null;
|
package/match.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Route matching of Vela App (docs/APP.md P-6, P-8, P-13). The Go package
|
|
2
|
+
// github.com/elauven/vela/app implements the same rules; the shared
|
|
3
|
+
// fixtures in app/testdata test both.
|
|
4
|
+
|
|
5
|
+
// The built-in matchers (P-13).
|
|
6
|
+
export function int(segment) {
|
|
7
|
+
return /^[0-9]+$/.test(segment);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function uuid(segment) {
|
|
11
|
+
return /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(segment);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function slug(segment) {
|
|
15
|
+
return /^[a-z0-9]+(-[a-z0-9]+)*$/.test(segment);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const builtinMatchers = Object.freeze({ int, uuid, slug });
|
|
19
|
+
|
|
20
|
+
// splitPath splits an absolute path into decoded segments before
|
|
21
|
+
// decoding each one, so %2F stays inside its segment (P-6). It returns
|
|
22
|
+
// null for a path that is not absolute or cannot be decoded.
|
|
23
|
+
function splitPath(path) {
|
|
24
|
+
if (typeof path !== 'string' || path[0] !== '/') return null;
|
|
25
|
+
if (path === '/') return [];
|
|
26
|
+
const parts = path.slice(1).split('/');
|
|
27
|
+
try {
|
|
28
|
+
return parts.map((part) => decodeURIComponent(part));
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function matchPattern(segments, path, matchers) {
|
|
35
|
+
const params = {};
|
|
36
|
+
for (let i = 0; i < segments.length; i++) {
|
|
37
|
+
const segment = segments[i];
|
|
38
|
+
if (segment.kind === 'rest') {
|
|
39
|
+
params[segment.value] = path.slice(i).join('/');
|
|
40
|
+
return params;
|
|
41
|
+
}
|
|
42
|
+
if (i >= path.length) return null;
|
|
43
|
+
const value = path[i];
|
|
44
|
+
if (segment.kind === 'static') {
|
|
45
|
+
if (value !== segment.value) return null;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (value === '') return null;
|
|
49
|
+
if (segment.matcher) {
|
|
50
|
+
const match = matchers[segment.matcher];
|
|
51
|
+
if (!match || !match(value)) return null;
|
|
52
|
+
}
|
|
53
|
+
params[segment.value] = value;
|
|
54
|
+
}
|
|
55
|
+
return segments.length === path.length ? params : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// matchPath finds the route of a path in a generated table: { routes,
|
|
59
|
+
// patterns, matchers }. Patterns are in ranking order, and the first full
|
|
60
|
+
// match wins. It returns { route, params } or null.
|
|
61
|
+
export function matchPath(table, path) {
|
|
62
|
+
const segments = splitPath(path);
|
|
63
|
+
if (!segments) return null;
|
|
64
|
+
for (const pattern of table.patterns) {
|
|
65
|
+
const params = matchPattern(pattern.segments, segments, table.matchers);
|
|
66
|
+
if (params) return { route: table.routes[pattern.route], params };
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,51 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@govela/app",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Vela App (docs/APP.md): the client router (hydration, data navigation, route matching) and the vela-app command.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/elauven/vela.git",
|
|
9
|
+
"directory": "packages/app"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"bin": {
|
|
13
|
+
"vela-app": "bin/vela-app.js"
|
|
14
|
+
},
|
|
15
|
+
"sideEffects": false,
|
|
16
|
+
"exports": {
|
|
17
|
+
"./client": {
|
|
18
|
+
"types": "./client.d.ts",
|
|
19
|
+
"default": "./client.js"
|
|
20
|
+
},
|
|
21
|
+
"./match": {
|
|
22
|
+
"types": "./match.d.ts",
|
|
23
|
+
"default": "./match.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"bin/vela-app.js",
|
|
28
|
+
"client.js",
|
|
29
|
+
"client.d.ts",
|
|
30
|
+
"match.js",
|
|
31
|
+
"match.d.ts",
|
|
32
|
+
"LICENSE"
|
|
33
|
+
],
|
|
34
|
+
"govela": {
|
|
35
|
+
"runtimeContract": 4,
|
|
36
|
+
"protocol": 1
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"@govela/runtime": ">=0.4.8"
|
|
40
|
+
},
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=18"
|
|
43
|
+
},
|
|
44
|
+
"optionalDependencies": {
|
|
45
|
+
"@govela/app-darwin-arm64": "0.1.2",
|
|
46
|
+
"@govela/app-darwin-x64": "0.1.2",
|
|
47
|
+
"@govela/app-linux-arm64": "0.1.2",
|
|
48
|
+
"@govela/app-linux-x64": "0.1.2",
|
|
49
|
+
"@govela/app-win32-x64": "0.1.2"
|
|
50
|
+
}
|
|
51
|
+
}
|