@weft-js/start 0.0.0-stage → 0.1.0
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 +16 -0
- package/README.md +41 -2
- package/package.json +107 -4
- package/src/app.mjs +82 -0
- package/src/auth/better-auth.ts +58 -0
- package/src/auth/drizzle.ts +17 -0
- package/src/auth.ts +47 -0
- package/src/boot.ts +44 -0
- package/src/builtin.tsx +19 -0
- package/src/bus/postgres.ts +91 -0
- package/src/bus.ts +48 -0
- package/src/capture.d.mts +1 -0
- package/src/capture.mjs +15 -0
- package/src/changes-sql.d.mts +14 -0
- package/src/changes-sql.mjs +99 -0
- package/src/changes.ts +253 -0
- package/src/channel-fake.ts +55 -0
- package/src/channel.ts +616 -0
- package/src/client.tsx +20 -0
- package/src/contract.mjs +308 -0
- package/src/data.ts +929 -0
- package/src/db/bun-sqlite.d.ts +10 -0
- package/src/db/changes-mysql.ts +86 -0
- package/src/db/changes-postgres.ts +159 -0
- package/src/db/changes-sqlite.ts +116 -0
- package/src/db/core.ts +63 -0
- package/src/db/index.ts +27 -0
- package/src/db/mysql.ts +67 -0
- package/src/db/postgres.ts +65 -0
- package/src/db/sqlite.ts +139 -0
- package/src/derive.d.mts +16 -0
- package/src/derive.mjs +140 -0
- package/src/dev-runtime.mjs +42 -0
- package/src/exec.ts +519 -0
- package/src/extension.ts +38 -0
- package/src/first-paint.ts +57 -0
- package/src/form.tsx +134 -0
- package/src/gate.css +6 -0
- package/src/hub.ts +822 -0
- package/src/index.ts +106 -0
- package/src/inline.mjs +2 -0
- package/src/inspect.mjs +280 -0
- package/src/link.ts +456 -0
- package/src/list-drizzle.ts +50 -0
- package/src/list.ts +224 -0
- package/src/listen.ts +28 -0
- package/src/live-fake.ts +42 -0
- package/src/live-target.mjs +144 -0
- package/src/live.ts +499 -0
- package/src/media.ts +43 -0
- package/src/migrations.mjs +19 -0
- package/src/mux-session.ts +6 -0
- package/src/mux.ts +135 -0
- package/src/og/DefaultCard.tsx +60 -0
- package/src/og/fonts.mjs +64 -0
- package/src/og/images.mjs +54 -0
- package/src/og/index.mjs +146 -0
- package/src/og/native.mjs +29 -0
- package/src/range.mjs +19 -0
- package/src/router.ts +149 -0
- package/src/routes.mjs +142 -0
- package/src/run.tsx +198 -0
- package/src/rust.ts +333 -0
- package/src/server.tsx +1014 -0
- package/src/shape.d.mts +3 -0
- package/src/shape.mjs +73 -0
- package/src/share-server.ts +208 -0
- package/src/share.ts +503 -0
- package/src/sheets.mjs +75 -0
- package/src/sidecar.mjs +503 -0
- package/src/socket.ts +389 -0
- package/src/stream.ts +13 -0
- package/src/verify.ts +251 -0
- package/src/view.tsx +47 -0
- package/src/vite.mjs +899 -0
package/src/routes.mjs
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// File routes (research 25 §2): `src/routes` → a route manifest, plain data that the server, the client table, the
|
|
2
|
+
// Vite plugin and `weft routes` all read. Conventions:
|
|
3
|
+
//
|
|
4
|
+
// index.tsx /
|
|
5
|
+
// about.tsx /about
|
|
6
|
+
// users/[id].tsx /users/:id (router-core path `/users/$id`)
|
|
7
|
+
// docs/[...rest].tsx /docs/* (router-core splat `$`, the param is `rest`)
|
|
8
|
+
// _layout.tsx wraps every route in its directory and below (the root one wraps the whole app)
|
|
9
|
+
// 404.tsx the not-found page (root only), rendered inside the root layout with status 404
|
|
10
|
+
// _error.tsx the error page (root only), rendered inside the root layout with status 500
|
|
11
|
+
// users/[id].og.tsx the share card of `users/[id]` (research 20 §4); `_default.og.tsx` replaces the built-in card
|
|
12
|
+
//
|
|
13
|
+
// Any other file or directory starting with `_` is ignored, and so are tests. A route module may export `default`
|
|
14
|
+
// (the component), `load`, `meta` and `prerender` (research 20 §3, §4.3).
|
|
15
|
+
|
|
16
|
+
import { existsSync, readdirSync, statSync } from 'node:fs'
|
|
17
|
+
import { join, relative } from 'node:path'
|
|
18
|
+
|
|
19
|
+
const EXT = /\.(tsx|jsx|ts|js)$/
|
|
20
|
+
const posix = p => p.split('\\').join('/')
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @typedef {{ id: string, file: string, layouts: string[], path: string, pattern: string, params: string[], splat?: string, rank: number[], card?: string, kind: 'page' | '404' | 'error' }} RouteEntry
|
|
24
|
+
* @typedef {{ id: string, file: string, dir: string, parent?: string }} LayoutEntry
|
|
25
|
+
* @typedef {{ root: string, dir: string, routes: RouteEntry[], layouts: LayoutEntry[], defaultCard?: string, problems: { file: string, message: string }[] }} Manifest
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** Scan `<root>/src/routes` (or `dir`). Paths in the manifest are relative to `root`, POSIX-style. */
|
|
29
|
+
export function scanRoutes(root, dir = join(root, 'src', 'routes')) {
|
|
30
|
+
/** @type {Manifest} */
|
|
31
|
+
const out = { root, dir: posix(relative(root, dir)), routes: [], layouts: [], problems: [] }
|
|
32
|
+
if (!existsSync(dir)) return out
|
|
33
|
+
const files = []
|
|
34
|
+
const walk = d => {
|
|
35
|
+
for (const name of readdirSync(d).sort()) {
|
|
36
|
+
const abs = join(d, name)
|
|
37
|
+
if (statSync(abs).isDirectory()) {
|
|
38
|
+
if (!name.startsWith('_') && !name.startsWith('.')) walk(abs)
|
|
39
|
+
} else if (EXT.test(name) && !/\.(test|spec)\.|\.d\.ts$/.test(name)) files.push(posix(relative(dir, abs)))
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
walk(dir)
|
|
43
|
+
|
|
44
|
+
const rel = f => posix(relative(root, join(dir, f)))
|
|
45
|
+
const layoutFile = new Map() // route dir ('' = root) → its _layout file
|
|
46
|
+
for (const f of files) {
|
|
47
|
+
const m = /^(?:(.*)\/)?_layout\.[jt]sx?$/.exec(f)
|
|
48
|
+
if (m) layoutFile.set(m[1] ?? '', f)
|
|
49
|
+
}
|
|
50
|
+
const layoutOf = new Map([...layoutFile.keys()].map(d => [d, `layout:${d}`]))
|
|
51
|
+
for (const [d, f] of [...layoutFile].sort((a, b) => a[0].length - b[0].length)) {
|
|
52
|
+
out.layouts.push({ id: layoutOf.get(d), file: rel(f), dir: d, parent: chain(d, layoutOf).at(-2) })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const cards = new Map()
|
|
56
|
+
for (const f of files) {
|
|
57
|
+
const m = /^(.*)\.og\.[jt]sx?$/.exec(f)
|
|
58
|
+
if (!m) continue
|
|
59
|
+
if (m[1] === '_default') out.defaultCard = rel(f)
|
|
60
|
+
else cards.set(m[1], rel(f))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const f of files) {
|
|
64
|
+
const base = f.replace(EXT, '')
|
|
65
|
+
const name = base.split('/').pop()
|
|
66
|
+
if (base.endsWith('.og') || name === '_layout' || name === '_default') continue
|
|
67
|
+
const d = base.includes('/') ? base.slice(0, base.lastIndexOf('/')) : ''
|
|
68
|
+
if (name === '404' || name === '_error') {
|
|
69
|
+
if (d) { out.problems.push({ file: rel(f), message: `\`${name}\` is only read at the routes root for now; this one is ignored` }); continue }
|
|
70
|
+
out.routes.push({ id: name === '404' ? '404' : 'error', file: rel(f), layouts: chain('', layoutOf), path: '', pattern: '', params: [], rank: [], kind: name === '404' ? '404' : 'error' })
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
if (name.startsWith('_') || d.split('/').some(s => s.startsWith('_'))) continue
|
|
74
|
+
const segs = base === 'index' ? [] : base.split('/')
|
|
75
|
+
if (segs.at(-1) === 'index') segs.pop()
|
|
76
|
+
const params = []
|
|
77
|
+
let splat
|
|
78
|
+
const parts = []
|
|
79
|
+
let bad = false
|
|
80
|
+
for (const s of segs) {
|
|
81
|
+
const p = /^\[(\.\.\.)?([A-Za-z_$][\w$]*)\]$/.exec(s)
|
|
82
|
+
if (p) {
|
|
83
|
+
if (p[1]) splat = p[2]
|
|
84
|
+
params.push(p[2])
|
|
85
|
+
parts.push(p[1] ? '$' : `$${p[2]}`)
|
|
86
|
+
} else if (/[[\]]/.test(s)) bad = true
|
|
87
|
+
else parts.push(s)
|
|
88
|
+
}
|
|
89
|
+
if (bad) { out.problems.push({ file: rel(f), message: 'a segment mixes text and a `[param]`; use a whole segment `[name]`' }); continue }
|
|
90
|
+
if (splat && parts.at(-1) !== '$') { out.problems.push({ file: rel(f), message: '`[...rest]` must be the last segment' }); continue }
|
|
91
|
+
const pattern = '/' + parts.join('/')
|
|
92
|
+
out.routes.push({
|
|
93
|
+
id: pattern, file: rel(f), layouts: chain(d, layoutOf), path: '/' + segs.join('/'), pattern, params, splat,
|
|
94
|
+
// Static segments outrank params, params outrank a splat (router-core's order; the client table uses it).
|
|
95
|
+
rank: parts.map(p => (p === '$' ? 0 : p.startsWith('$') ? 1 : 2)),
|
|
96
|
+
card: cards.get(base), kind: 'page',
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
out.routes.sort((a, b) => cmpRank(a.rank, b.rank) || a.id.localeCompare(b.id))
|
|
100
|
+
for (const [b, file] of cards) if (!out.routes.some(r => r.card === file)) out.problems.push({ file, message: `no route \`${b}\` for this card` })
|
|
101
|
+
return out
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Layout ids from the root down to `d`. */
|
|
105
|
+
function chain(d, layoutOf) {
|
|
106
|
+
const out = []
|
|
107
|
+
const parts = d ? d.split('/') : []
|
|
108
|
+
for (let i = 0; i <= parts.length; i++) {
|
|
109
|
+
const id = layoutOf.get(parts.slice(0, i).join('/'))
|
|
110
|
+
if (id) out.push(id)
|
|
111
|
+
}
|
|
112
|
+
return out
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function cmpRank(a, b) {
|
|
116
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
117
|
+
if (a[i] === undefined) return 1 // shorter after: `/users/new` is tried before `/users/$id/edit`… only on ties
|
|
118
|
+
if (b[i] === undefined) return -1
|
|
119
|
+
if (a[i] !== b[i]) return b[i] - a[i]
|
|
120
|
+
}
|
|
121
|
+
return 0
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** `/users/$id` with `{ id: '42' }` → `/users/42`; a splat fills `$`. */
|
|
125
|
+
export function fillPath(pattern, params) {
|
|
126
|
+
return pattern.replace(/\$([\w$]*)/g, (_, name) => {
|
|
127
|
+
const v = params[name || '_splat'] ?? params[name]
|
|
128
|
+
return name ? encodeURIComponent(String(v)) : String(v ?? '')
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** A route's file path as authors write it in types: `src/routes/users/[id].tsx` → `/users/[id]`, `index.tsx` → `/`. */
|
|
133
|
+
export function filePath(manifest, route) {
|
|
134
|
+
return '/' + route.file.slice(manifest.dir.length + 1).replace(EXT, '').replace(/(^|\/)index$/, '')
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** `.weft/routes.d.ts`: every route path, so `RouteProps<…, '/users/[id]'>` and `Meta<…, path>` only take real ones. */
|
|
138
|
+
export function routeTypes(manifest) {
|
|
139
|
+
const pages = manifest.routes.filter(r => r.kind === 'page')
|
|
140
|
+
const lines = pages.map(r => ` '${filePath(manifest, r)}': true`)
|
|
141
|
+
return `// Generated by weft from ${manifest.dir}. Do not edit.\nimport '@weft-js/start'\ndeclare module '@weft-js/start' {\n interface RouteFiles {\n${lines.join('\n')}\n }\n}\n`
|
|
142
|
+
}
|
package/src/run.tsx
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// The client (`client.tsx` starts it) and its router (research 25 §3): hydrate the server's page, then navigate without page loads. A navigation
|
|
2
|
+
// fetches `/_weft/data/<path>` (the server matches, runs the loaders and merges the head, so loaders never ship to
|
|
3
|
+
// the client), loads the route chunks it needs (started in parallel from a guess against the route table), and
|
|
4
|
+
// writes the levels: a level that keeps its route keeps its component and gets new params/data in place.
|
|
5
|
+
// `setHead` writes the server's merged tags for the tab title and history; crawlers only read the server's HTML.
|
|
6
|
+
//
|
|
7
|
+
// Server functions (research 27): the query cache seeds itself from the page's `#weft-cache` as it loads, and gets
|
|
8
|
+
// each navigation's `c` through `client.seed`, so hydration fetches nothing (and an app without queries doesn't ship
|
|
9
|
+
// the cache). Redirects from loaders and server functions navigate (at most 5 in a row); a
|
|
10
|
+
// mutation that invalidates a tag the current route's loaders read refetches the route data in place.
|
|
11
|
+
|
|
12
|
+
import { drain, enter, hydrate, level as staticLevel, wake } from '@weft-js/core/dom'
|
|
13
|
+
import { flush } from '@weft-js/core'
|
|
14
|
+
import { setHead } from '@weft-js/core/head'
|
|
15
|
+
import type { Held } from './boot'
|
|
16
|
+
import { applyMatches, client, createRouteState, type Component, type Match, type NavigateOptions, type RoutePayload } from './router'
|
|
17
|
+
import { App } from './view'
|
|
18
|
+
import { ErrorPage, NotFoundPage } from './builtin'
|
|
19
|
+
|
|
20
|
+
type Hot = ImportMeta & { hot?: { on(event: string, on: (data: { queries: boolean }) => unknown): void } }
|
|
21
|
+
|
|
22
|
+
/** The client half of `virtual:weft/routes`: the page routes in match order, and a chunk loader per route id. */
|
|
23
|
+
export interface ClientRoutes {
|
|
24
|
+
table: { id: string; pattern: string; layouts: string[] }[]
|
|
25
|
+
load: Record<string, () => Promise<{ default?: Component }>>
|
|
26
|
+
/** Each island's code, by id: all a static level loads (PLAN §11). */
|
|
27
|
+
islands?: Record<string, () => Promise<{ default: Component }>>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Hydrate the page in `#app` and take over its links. Resolves once hydrated. */
|
|
31
|
+
const SLOW_FADE = 100 // ms from asking for a fade to it starting
|
|
32
|
+
|
|
33
|
+
export async function start(routes: ClientRoutes, options: { container?: Element } = {}) {
|
|
34
|
+
const container = options.container ?? document.getElementById('app')!
|
|
35
|
+
const page: RoutePayload = JSON.parse(document.getElementById('weft-route')!.textContent)
|
|
36
|
+
const data = JSON.parse(document.getElementById('weft-data')?.textContent ?? 'null')
|
|
37
|
+
let tags = page.m.map(m => m.t ?? [])
|
|
38
|
+
const comps: Record<string, Component> = {}
|
|
39
|
+
// The built-in 404 and error pages stand in only when the app has no route file for them.
|
|
40
|
+
const builtin: Record<string, Component> = { 404: NotFoundPage, error: ErrorPage }
|
|
41
|
+
const chunk = (id: string) =>
|
|
42
|
+
comps[id] ? Promise.resolve()
|
|
43
|
+
: routes.load[id] ? routes.load[id]().then(m => { if (m.default) comps[id] = m.default })
|
|
44
|
+
: Promise.resolve(builtin[id] && (comps[id] = builtin[id]))
|
|
45
|
+
const islands: Record<string, Component> = {}
|
|
46
|
+
const island = (id: string) => islands[id] ? undefined : routes.islands![id]!().then(m => { islands[id] = m.default })
|
|
47
|
+
await Promise.all([...page.m.filter(m => !m.s).map(m => chunk(m.id)), ...(page.i ?? []).map(island)])
|
|
48
|
+
const route = createRouteState(page, comps)
|
|
49
|
+
let bag: RoutePayload['b']
|
|
50
|
+
route.mount = (html, depth, next) => staticLevel(html, depth, next, id => islands[id]! as never, bag ?? undefined, id => Promise.resolve(island(id)))
|
|
51
|
+
route.around = (_d, _id, body) => enter(body)
|
|
52
|
+
hydrate(() => <App route={route} />, container, { data })
|
|
53
|
+
client.route = route
|
|
54
|
+
const q = (window as Window & { __wq?: Held }).__wq
|
|
55
|
+
if (q) {
|
|
56
|
+
q.w = wake
|
|
57
|
+
if (q.l) wake(q.l)
|
|
58
|
+
drain()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let current: AbortController | undefined
|
|
62
|
+
let warm: { path: string; at: number; res: Promise<Response> } | undefined
|
|
63
|
+
// A fade pictures the whole page twice, and the picture holds up every frame. Where that's slow, the page just changes.
|
|
64
|
+
let fades = !(navigator.hardwareConcurrency <= 4)
|
|
65
|
+
async function go(href: string, opts: NavigateOptions & { pop?: boolean; hops?: number } = {}): Promise<void> {
|
|
66
|
+
const url = new URL(href, location.href)
|
|
67
|
+
if (url.origin !== location.origin) return location.assign(url.href)
|
|
68
|
+
current?.abort()
|
|
69
|
+
const ctrl = (current = new AbortController())
|
|
70
|
+
const path = url.pathname + url.search
|
|
71
|
+
for (const id of guess(routes, url.pathname)) void chunk(id) // chunk loads start with the data fetch, not after it
|
|
72
|
+
let next: RoutePayload
|
|
73
|
+
try {
|
|
74
|
+
const held = warm?.path === path && performance.now() - warm.at < 10_000 ? warm.res : undefined
|
|
75
|
+
warm = undefined
|
|
76
|
+
next = await (held ?? ask(url, ctrl.signal)).then(r => r.clone().json())
|
|
77
|
+
} catch (e) {
|
|
78
|
+
if (ctrl.signal.aborted) return
|
|
79
|
+
return location.assign(url.href) // the data request failed: let the server render the page
|
|
80
|
+
}
|
|
81
|
+
if (next.r) {
|
|
82
|
+
const hops = (opts.hops ?? 0) + 1
|
|
83
|
+
return hops > 5 ? location.assign(next.r) : go(next.r, { replace: opts.pop || opts.replace, hops }) // a back/forward to a moved URL replaces its entry
|
|
84
|
+
}
|
|
85
|
+
await Promise.all([...next.m.map(m => chunk(m.id)), ...(next.i ?? []).map(island)])
|
|
86
|
+
if (ctrl.signal.aborted) return
|
|
87
|
+
if (!opts.pop) history[opts.replace ? 'replaceState' : 'pushState'](null, '', url.href)
|
|
88
|
+
at = path
|
|
89
|
+
if (next.c) client.seed ? client.seed(next.c) : (client.seeds ??= []).push(...next.c)
|
|
90
|
+
tags = next.m.map(m => m.t ?? [])
|
|
91
|
+
bag = next.b
|
|
92
|
+
const swap = () => {
|
|
93
|
+
applyMatches(route, next.m.map(revive), path, next.s)
|
|
94
|
+
if (next.m.some(m => m.s)) current_(url.pathname)
|
|
95
|
+
if (next.h) setHead(next.h, url.pathname)
|
|
96
|
+
flush()
|
|
97
|
+
if (opts.scroll !== false && !opts.pop) {
|
|
98
|
+
const target = url.hash && document.getElementById(decodeURIComponent(url.hash.slice(1)))
|
|
99
|
+
target ? target.scrollIntoView() : window.scrollTo(0, 0)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// The old page stays on screen, as a picture, until the new one is ready to fade in. `hidden`: nothing would show it.
|
|
103
|
+
if (fades && document.startViewTransition && !document.hidden && !matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
|
104
|
+
const started = performance.now()
|
|
105
|
+
const fade = document.startViewTransition(swap)
|
|
106
|
+
void fade.ready.then(() => { if (performance.now() - started > SLOW_FADE) fades = false }, () => {})
|
|
107
|
+
// a transition the browser aborts (a second navigation, a hidden tab) rejects `finished`: the page already swapped
|
|
108
|
+
void fade.finished.catch(() => {})
|
|
109
|
+
await fade.updateCallbackDone
|
|
110
|
+
}
|
|
111
|
+
else swap()
|
|
112
|
+
}
|
|
113
|
+
client.navigate = go
|
|
114
|
+
// Dev: server code changed under the page (vite.mjs). The route's data comes again, in place.
|
|
115
|
+
const refresh = async ({ queries }: { queries: boolean }) => {
|
|
116
|
+
const url = new URL(location.href)
|
|
117
|
+
const res = await ask(url)
|
|
118
|
+
// It doesn't load yet, and the error page would replace what's here: the next save asks again.
|
|
119
|
+
if (!res.ok || (await res.clone().json()).s >= 500) return
|
|
120
|
+
if (queries) client.refreshed?.()
|
|
121
|
+
warm = { path: url.pathname + url.search, at: performance.now(), res: Promise.resolve(res) }
|
|
122
|
+
await go(url.href, { pop: true })
|
|
123
|
+
}
|
|
124
|
+
if ((import.meta as Hot).hot) (import.meta as Hot).hot!.on('weft:refresh', changed => void refresh(changed))
|
|
125
|
+
// A mutation invalidated tags: when the route's loaders read one, refetch the route data (no history entry, no scroll).
|
|
126
|
+
client.invalidated = t => {
|
|
127
|
+
warm = undefined
|
|
128
|
+
if (tags.some(l => l.some(x => t.includes(x)))) void go(location.href, { pop: true })
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
document.addEventListener('click', e => {
|
|
132
|
+
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
|
|
133
|
+
const url = ours(e.target)
|
|
134
|
+
if (!url) return
|
|
135
|
+
e.preventDefault()
|
|
136
|
+
void go(url.href)
|
|
137
|
+
})
|
|
138
|
+
// A link the pointer rests on, or the keyboard lands on, has its chunks and data by the time it is pressed.
|
|
139
|
+
// `data-preload="off"` on a link opts out, and so does Save-Data.
|
|
140
|
+
let pending: ReturnType<typeof setTimeout> | undefined
|
|
141
|
+
const intent = (e: Event) => {
|
|
142
|
+
clearTimeout(pending)
|
|
143
|
+
const url = ours(e.target)
|
|
144
|
+
const path = url && url.pathname + url.search
|
|
145
|
+
if (!url || warm?.path === path || (e.target as Element).closest('[data-preload="off"]') || (navigator as Navigator & { connection?: { saveData?: boolean } }).connection?.saveData) return
|
|
146
|
+
pending = setTimeout(() => {
|
|
147
|
+
for (const id of guess(routes, url.pathname)) void chunk(id)
|
|
148
|
+
warm = { path: path!, at: performance.now(), res: ask(url) }
|
|
149
|
+
warm.res.catch(() => { warm = undefined })
|
|
150
|
+
}, e.type === 'focusin' ? 0 : 80)
|
|
151
|
+
}
|
|
152
|
+
document.addEventListener('pointerover', intent)
|
|
153
|
+
document.addEventListener('focusin', intent)
|
|
154
|
+
// An in-page anchor fires this too, and the page hasn't changed: re-rendering it would lose what's been scrolled to.
|
|
155
|
+
let at = location.pathname + location.search
|
|
156
|
+
addEventListener('popstate', () => {
|
|
157
|
+
if (location.pathname + location.search !== at) void go(location.href, { pop: true })
|
|
158
|
+
})
|
|
159
|
+
return route
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const ask = (url: URL, signal?: AbortSignal) => fetch(`/_weft/data${url.pathname === '/' ? '' : url.pathname}${url.search}`, { signal: signal ?? null, headers: { accept: 'application/json' } })
|
|
163
|
+
|
|
164
|
+
/** The URL a client navigation would take from this event's link, or nothing for a link the browser should follow. */
|
|
165
|
+
function ours(target: EventTarget | null) {
|
|
166
|
+
const a = (target as Element | null)?.closest?.('a')
|
|
167
|
+
if (!a || (a.target && a.target !== '_self') || a.hasAttribute('download') || a.getAttribute('rel')?.includes('external')) return
|
|
168
|
+
const url = new URL(a.href, location.href)
|
|
169
|
+
if (url.origin !== location.origin || url.pathname.startsWith('/_weft/')) return
|
|
170
|
+
return url.pathname === location.pathname && url.search === location.search && url.hash ? undefined : url // not an in-page anchor
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** A kept static level has no binding for its links' current-page state: the same rule as `Link`, after the swap. */
|
|
174
|
+
function current_(path: string) {
|
|
175
|
+
flush()
|
|
176
|
+
for (const a of document.querySelectorAll('a[aria-current="page"]')) if (a.getAttribute('href') !== path) a.removeAttribute('aria-current')
|
|
177
|
+
for (const a of document.querySelectorAll(`a[href="${CSS.escape(path)}"]:not([aria-current])`)) a.setAttribute('aria-current', 'page')
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Deferred fields arrive resolved on navigation: they become settled promises again, so resources read them alike. */
|
|
181
|
+
function revive(m: Match): Match {
|
|
182
|
+
if (!m.d?.length) return m
|
|
183
|
+
const data = { ...(m.data as Record<string, unknown>) }
|
|
184
|
+
for (const k of m.d) data[k] = Promise.resolve(data[k])
|
|
185
|
+
return { ...m, data }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The route ids `pathname` probably needs (its layouts and page), from the table; the server's answer decides. */
|
|
189
|
+
function guess(routes: ClientRoutes, pathname: string): string[] {
|
|
190
|
+
const parts = pathname.split('/').filter(Boolean)
|
|
191
|
+
for (const r of routes.table) {
|
|
192
|
+
const segs = r.pattern.split('/').filter(Boolean)
|
|
193
|
+
const splat = segs.at(-1) === '$'
|
|
194
|
+
if (splat ? parts.length < segs.length - 1 : parts.length !== segs.length) continue
|
|
195
|
+
if (segs.every((s, i) => s === '$' || s.startsWith('$') || s === decodeURIComponent(parts[i] ?? ''))) return [...r.layouts, r.id]
|
|
196
|
+
}
|
|
197
|
+
return []
|
|
198
|
+
}
|