@weft-js/start 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -3
- package/crate/Cargo.lock +1347 -0
- package/crate/Cargo.toml +72 -0
- package/crate/rust-toolchain.toml +3 -0
- package/crate/src/build/emit.rs +103 -0
- package/crate/src/build.rs +367 -0
- package/crate/src/ctx.rs +186 -0
- package/crate/src/envelope.rs +163 -0
- package/crate/src/functions.rs +173 -0
- package/crate/src/lib.rs +118 -0
- package/crate/src/native.rs +212 -0
- package/crate/src/otel.rs +62 -0
- package/crate/src/server.rs +203 -0
- package/package.json +46 -37
- package/src/app.mjs +51 -10
- package/src/asset-receipt.d.mts +13 -0
- package/src/asset-receipt.mjs +61 -0
- package/src/auth/better-auth.ts +215 -17
- package/src/auth/drizzle.ts +1 -1
- package/src/auth.ts +21 -6
- package/src/boot.ts +19 -8
- package/src/bus/postgres.ts +32 -19
- package/src/bus.ts +7 -4
- package/src/{capture.mjs → capture.ts} +34 -1
- package/src/changes-sql.d.mts +8 -2
- package/src/changes-sql.mjs +26 -10
- package/src/changes.ts +165 -66
- package/src/channel/connections.ts +216 -0
- package/src/channel/socket.ts +396 -0
- package/src/channel-fake.ts +19 -4
- package/src/channel.ts +124 -490
- package/src/client.tsx +9 -2
- package/src/contract/describe.mjs +231 -0
- package/src/contract.mjs +146 -155
- package/src/data/cache.ts +354 -0
- package/src/data/definitions.ts +94 -0
- package/src/data/draft.ts +104 -0
- package/src/data/foundation.ts +95 -0
- package/src/data/live-api.ts +22 -0
- package/src/data/mutation.ts +138 -0
- package/src/data/primitives.ts +135 -0
- package/src/data/query.ts +106 -0
- package/src/data/transport.ts +121 -0
- package/src/data/types.ts +303 -0
- package/src/data.ts +69 -894
- package/src/db/bun-sqlite.d.ts +4 -1
- package/src/db/changes-mysql.ts +61 -21
- package/src/db/changes-postgres.ts +197 -118
- package/src/db/changes-sqlite.ts +131 -64
- package/src/db/core.ts +38 -13
- package/src/db/index.ts +20 -4
- package/src/db/mysql.ts +22 -6
- package/src/db/postgres.ts +49 -13
- package/src/db/sqlite.ts +53 -27
- package/src/derive.d.mts +4 -1
- package/src/derive.mjs +136 -39
- package/src/dev-runtime.mjs +8 -4
- package/src/exec/errors.ts +57 -0
- package/src/exec/landing.ts +66 -0
- package/src/exec/scope.ts +174 -0
- package/src/exec/touch.ts +80 -0
- package/src/exec/validation.ts +176 -0
- package/src/exec.ts +191 -341
- package/src/extension.ts +8 -2
- package/src/first-paint.ts +26 -9
- package/src/form/value.ts +15 -0
- package/src/form.tsx +132 -43
- package/src/hub/channels.ts +102 -0
- package/src/hub/connections.ts +147 -0
- package/src/hub/delivery.ts +62 -0
- package/src/hub/feed.ts +168 -0
- package/src/hub/presence.ts +64 -0
- package/src/hub/route.ts +158 -0
- package/src/hub/subscriptions.ts +212 -0
- package/src/hub/topic.ts +98 -0
- package/src/hub/tracked.ts +12 -0
- package/src/hub/types.ts +190 -0
- package/src/hub/view.ts +147 -0
- package/src/hub.ts +132 -758
- package/src/index.ts +84 -10
- package/src/inline.mjs +356 -0
- package/src/inspect/compare.mjs +162 -0
- package/src/inspect/routes.mjs +154 -0
- package/src/inspect/samples.mjs +59 -0
- package/src/inspect/url.mjs +2 -0
- package/src/inspect.mjs +146 -187
- package/src/link/election.ts +178 -0
- package/src/link/lines.ts +27 -0
- package/src/link.ts +97 -168
- package/src/list-drizzle.ts +104 -18
- package/src/list.ts +127 -36
- package/src/listen.ts +32 -6
- package/src/live/api.ts +18 -0
- package/src/live/backoff.ts +6 -0
- package/src/live/frames.ts +42 -0
- package/src/live/read.ts +77 -0
- package/src/live/sub-types.ts +24 -0
- package/src/live/transport.ts +86 -0
- package/src/live/types.ts +52 -0
- package/src/live-fake.ts +20 -5
- package/src/live-target.mjs +113 -35
- package/src/live.ts +116 -225
- package/src/media.ts +8 -1
- package/src/migrations.mjs +14 -6
- package/src/mux.ts +161 -64
- package/src/og/DefaultCard.tsx +40 -9
- package/src/og/fonts.mjs +16 -8
- package/src/og/images.mjs +43 -22
- package/src/og/index.mjs +119 -43
- package/src/og/native.mjs +11 -4
- package/src/range.mjs +15 -3
- package/src/router.ts +56 -6
- package/src/routes.mjs +73 -22
- package/src/run/admission.ts +80 -0
- package/src/run/boot.tsx +115 -0
- package/src/run/cache-presentation.ts +67 -0
- package/src/run/cache.ts +254 -0
- package/src/run/helpers.ts +197 -0
- package/src/run/listeners.ts +155 -0
- package/src/run/navigation.ts +382 -0
- package/src/run/page-skeleton.tsx +35 -0
- package/src/run/prefetch.ts +128 -0
- package/src/run/retain-view.ts +18 -0
- package/src/run/shape-preparation.ts +97 -0
- package/src/run/shape-styles.ts +72 -0
- package/src/run/source-context/pin.ts +124 -0
- package/src/run/source-context.ts +311 -0
- package/src/run.tsx +50 -174
- package/src/rust/common.ts +163 -0
- package/src/rust/native.ts +282 -0
- package/src/rust.ts +103 -229
- package/src/server/card.tsx +65 -0
- package/src/server/compression.ts +115 -0
- package/src/server/core.ts +70 -0
- package/src/server/dispatch.ts +70 -0
- package/src/server/form.ts +85 -0
- package/src/server/functions.ts +129 -0
- package/src/server/handler.ts +39 -0
- package/src/server/head.ts +96 -0
- package/src/server/media.ts +60 -0
- package/src/server/navigation-cache.ts +80 -0
- package/src/server/navigation.tsx +85 -0
- package/src/server/page-cold.ts +95 -0
- package/src/server/page-stream.tsx +181 -0
- package/src/server/page-types.ts +51 -0
- package/src/server/page-warm.ts +130 -0
- package/src/server/page.ts +69 -0
- package/src/server/payload.ts +89 -0
- package/src/server/prerender.ts +60 -0
- package/src/server/preview.tsx +67 -0
- package/src/server/render.ts +167 -0
- package/src/server/request.ts +98 -0
- package/src/server/resolver.ts +228 -0
- package/src/server/routes.ts +150 -0
- package/src/server/services.ts +69 -0
- package/src/server/text.ts +29 -0
- package/src/server/types.ts +160 -0
- package/src/server.tsx +82 -964
- package/src/shape.mjs +30 -6
- package/src/share/display.ts +50 -0
- package/src/share/network.ts +119 -0
- package/src/share/receive.ts +70 -0
- package/src/share/state.ts +30 -0
- package/src/share/tab-queue.ts +28 -0
- package/src/share-server.ts +128 -43
- package/src/share.ts +103 -209
- package/src/sheets.mjs +30 -18
- package/src/sidecar/build.mjs +170 -0
- package/src/sidecar/cargo.mjs +164 -0
- package/src/sidecar/common.mjs +17 -0
- package/src/sidecar/dev-build.mjs +91 -0
- package/src/sidecar/dev-watch.mjs +88 -0
- package/src/sidecar/manifest.mjs +19 -0
- package/src/sidecar/process.mjs +58 -0
- package/src/sidecar/refresh.mjs +19 -0
- package/src/sidecar.mjs +75 -416
- package/src/socket/check.ts +50 -0
- package/src/socket/deliver.ts +16 -0
- package/src/socket/publish.ts +56 -0
- package/src/socket/rooms.ts +41 -0
- package/src/socket/route.ts +79 -0
- package/src/socket/shared.ts +26 -0
- package/src/socket/types.ts +48 -0
- package/src/socket.ts +205 -194
- package/src/stream.ts +10 -1
- package/src/verify/app.ts +39 -0
- package/src/verify/cleanup.ts +27 -0
- package/src/verify/database.ts +11 -0
- package/src/verify/live.ts +63 -0
- package/src/verify/notify.ts +35 -0
- package/src/verify/skip.ts +19 -0
- package/src/verify/support.ts +149 -0
- package/src/verify/timing.ts +15 -0
- package/src/verify/trigger.ts +14 -0
- package/src/verify.ts +77 -176
- package/src/view.tsx +96 -11
- package/src/vite/asset-bytes.mjs +95 -0
- package/src/vite/assets.mjs +117 -0
- package/src/vite/config-hooks.mjs +166 -0
- package/src/vite/dev-hooks.mjs +103 -0
- package/src/vite/dev.mjs +102 -0
- package/src/vite/entries.mjs +145 -0
- package/src/vite/module-hooks.mjs +288 -0
- package/src/vite/route-modules.mjs +289 -0
- package/src/vite/server-functions.mjs +222 -0
- package/src/vite/shape-entry-css.mjs +187 -0
- package/src/vite/shape-override.mjs +226 -0
- package/src/vite/shared.mjs +51 -0
- package/src/vite/sheets.mjs +92 -0
- package/src/vite/state.mjs +89 -0
- package/src/vite/syntax.mjs +139 -0
- package/src/vite.mjs +152 -853
- package/src/capture.d.mts +0 -1
package/src/og/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// renderCard for weft (research 20 §4, §9; promoted from spikes/og/card): weft SSR HTML → PNG/JPEG/WebP/SVG, on
|
|
1
|
+
// renderCard for weft (research 20 §4, §9; promoted from archive/evals-2026-11:spikes/og/card): weft SSR HTML → PNG/JPEG/WebP/SVG, on
|
|
2
2
|
// Takumi, with what Satori did better added around it:
|
|
3
3
|
// - strict styles: every declaration is checked once against the renderer (an invalid value would fail the whole
|
|
4
4
|
// card, an unknown property is silently dropped) and reported as H005; production drops the bad declaration and
|
|
@@ -19,8 +19,9 @@ export const PREFLIGHT = '*,*::before,*::after{box-sizing:border-box}'
|
|
|
19
19
|
export const SIZE = { width: 1200, height: 630 }
|
|
20
20
|
const TYPES = { png: 'image/png', jpeg: 'image/jpeg', webp: 'image/webp', svg: 'image/svg+xml' }
|
|
21
21
|
|
|
22
|
-
const stripComments = html => html.replace(/<!--[\s\S]*?-->/g, '')
|
|
23
|
-
const kebab = k
|
|
22
|
+
const stripComments = (html) => html.replace(/<!--[\s\S]*?-->/g, '')
|
|
23
|
+
const kebab = (k) =>
|
|
24
|
+
k.replace(/^Webkit/, '-webkit-').replace(/[A-Z]/g, (c) => '-' + c.toLowerCase())
|
|
24
25
|
// A value no CSS grammar accepts: a property the renderer knows rejects it, and one it doesn't know takes anything.
|
|
25
26
|
// (An identifier won't do: `text-overflow`, `grid-template-columns` and `font-family` accept custom idents.)
|
|
26
27
|
const PROBE = '}{'
|
|
@@ -37,10 +38,21 @@ const renderers = new Map() // fonts array → the renderer registered with them
|
|
|
37
38
|
* @param {{ name: string; data: Uint8Array; weight?: number; style?: 'normal' | 'italic' }[]} [fonts]
|
|
38
39
|
* @param {{ strict?: boolean; onDiagnostic?: (d: { code: string; severity: string; message: string }) => void; images?: unknown }} [options]
|
|
39
40
|
*/
|
|
40
|
-
export async function renderCard(
|
|
41
|
+
export async function renderCard(
|
|
42
|
+
html,
|
|
43
|
+
size = SIZE,
|
|
44
|
+
fonts = [],
|
|
45
|
+
{ strict = false, onDiagnostic, images } = {},
|
|
46
|
+
) {
|
|
41
47
|
let r = renderers.get(fonts)
|
|
42
|
-
if (!r)
|
|
43
|
-
|
|
48
|
+
if (!r)
|
|
49
|
+
renderers.set(
|
|
50
|
+
fonts,
|
|
51
|
+
(r = native().then((backend) => createCardRenderer({ backend, fonts, images }))),
|
|
52
|
+
)
|
|
53
|
+
const out = await (
|
|
54
|
+
await r
|
|
55
|
+
).render(html, { width: size.width ?? SIZE.width, height: size.height ?? SIZE.height, strict })
|
|
44
56
|
for (const d of out.diagnostics) onDiagnostic?.(d)
|
|
45
57
|
return out.body
|
|
46
58
|
}
|
|
@@ -53,19 +65,97 @@ export async function createCardRenderer({ backend, fonts = [], images: imageOpt
|
|
|
53
65
|
const renderer = new backend.Renderer()
|
|
54
66
|
for (const font of await normalizeFonts(fonts)) await renderer.registerFont(font)
|
|
55
67
|
const cache = imageCache(imageOptions.cacheBytes)
|
|
68
|
+
const prepare = createPreparation(renderer)
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
/** Diagnostics only (`weft check`, dev): nothing is fetched or rasterized. */
|
|
72
|
+
async check(html) {
|
|
73
|
+
return (await prepare(html)).diagnostics
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Renders a card. Never throws on a card's styles or images: those come back as diagnostics, and the card renders
|
|
78
|
+
* without them. `strict` (dev, `weft build`) throws on any error-severity diagnostic instead.
|
|
79
|
+
*/
|
|
80
|
+
async render(
|
|
81
|
+
html,
|
|
82
|
+
{ width = SIZE.width, height = SIZE.height, format = 'png', quality, strict = false } = {},
|
|
83
|
+
) {
|
|
84
|
+
const { node, css, diagnostics, imageNodes } = await prepare(html)
|
|
85
|
+
const { images, failed } = await loadImages(
|
|
86
|
+
imageNodes.map((n) => n.src),
|
|
87
|
+
{ cache, ...imageOptions },
|
|
88
|
+
)
|
|
89
|
+
for (const f of failed) {
|
|
90
|
+
diagnostics.push({
|
|
91
|
+
code: 'H008',
|
|
92
|
+
severity: 'warning',
|
|
93
|
+
message: `Card image ${f.src} didn't load (${f.reason}); the card renders without it.`,
|
|
94
|
+
})
|
|
95
|
+
for (const n of imageNodes)
|
|
96
|
+
if (n.src === f.src)
|
|
97
|
+
Object.assign(n, {
|
|
98
|
+
type: 'container',
|
|
99
|
+
src: undefined,
|
|
100
|
+
style: {
|
|
101
|
+
width: n.width && `${n.width}px`,
|
|
102
|
+
height: n.height && `${n.height}px`,
|
|
103
|
+
...n.style,
|
|
104
|
+
},
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
if (strict && diagnostics.some((d) => d.severity === 'error')) {
|
|
108
|
+
throw Object.assign(
|
|
109
|
+
new Error(
|
|
110
|
+
diagnostics
|
|
111
|
+
.filter((d) => d.severity === 'error')
|
|
112
|
+
.map((d) => `${d.code} ${d.message} (${d.element})`)
|
|
113
|
+
.join('\n'),
|
|
114
|
+
),
|
|
115
|
+
{ diagnostics },
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
const options = { width, height, images, css: [PREFLIGHT, ...css] }
|
|
119
|
+
const body =
|
|
120
|
+
format === 'svg'
|
|
121
|
+
? await renderer.renderSvg(node, options)
|
|
122
|
+
: new Uint8Array(
|
|
123
|
+
await renderer.render(node, {
|
|
124
|
+
...options,
|
|
125
|
+
format,
|
|
126
|
+
...(quality != null && { quality }),
|
|
127
|
+
}),
|
|
128
|
+
)
|
|
129
|
+
return { body, type: TYPES[format], diagnostics }
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function createPreparation(renderer) {
|
|
56
135
|
const verdicts = new Map() // `${prop}\0${value}` → null (fine) | { severity, message }
|
|
57
136
|
|
|
58
|
-
const accepts = async style => {
|
|
59
|
-
try {
|
|
137
|
+
const accepts = async (style) => {
|
|
138
|
+
try {
|
|
139
|
+
await renderer.measure({ type: 'container', style }, { width: 1, height: 1 })
|
|
140
|
+
return true
|
|
141
|
+
} catch {
|
|
142
|
+
return false
|
|
143
|
+
}
|
|
60
144
|
}
|
|
61
145
|
async function judge(prop, value) {
|
|
62
146
|
const key = `${prop}\0${value}`
|
|
63
147
|
if (verdicts.has(key)) return verdicts.get(key)
|
|
64
148
|
let v = null
|
|
65
149
|
if (!(await accepts({ [prop]: value }))) {
|
|
66
|
-
v = {
|
|
150
|
+
v = {
|
|
151
|
+
severity: 'error',
|
|
152
|
+
message: `\`${kebab(prop)}: ${value}\` isn't a value the card renderer accepts; the declaration is dropped.`,
|
|
153
|
+
}
|
|
67
154
|
} else if (await accepts({ [prop]: PROBE })) {
|
|
68
|
-
v = {
|
|
155
|
+
v = {
|
|
156
|
+
severity: 'warning',
|
|
157
|
+
message: `\`${kebab(prop)}\` isn't a property the card renderer supports; it has no effect.`,
|
|
158
|
+
}
|
|
69
159
|
}
|
|
70
160
|
verdicts.set(key, v)
|
|
71
161
|
return v
|
|
@@ -74,7 +164,8 @@ export async function createCardRenderer({ backend, fonts = [], images: imageOpt
|
|
|
74
164
|
/** Walks the tree: checks styles (dropping invalid declarations), and collects image nodes. */
|
|
75
165
|
async function prepare(html) {
|
|
76
166
|
const { node, css } = fromHtml(stripComments(html))
|
|
77
|
-
const diagnostics = [],
|
|
167
|
+
const diagnostics = [],
|
|
168
|
+
imageNodes = []
|
|
78
169
|
const walk = async (n, path) => {
|
|
79
170
|
if (n.type === 'image') imageNodes.push(n)
|
|
80
171
|
for (const [prop, value] of Object.entries(n.style ?? {})) {
|
|
@@ -83,39 +174,14 @@ export async function createCardRenderer({ backend, fonts = [], images: imageOpt
|
|
|
83
174
|
diagnostics.push({ code: 'H005', severity: v.severity, message: v.message, element: path })
|
|
84
175
|
if (v.severity === 'error') delete n.style[prop]
|
|
85
176
|
}
|
|
86
|
-
for (const [i, c] of (n.children ?? []).entries())
|
|
177
|
+
for (const [i, c] of (n.children ?? []).entries())
|
|
178
|
+
await walk(c, `${path} > ${c.tagName ?? c.type}:nth-child(${i + 1})`)
|
|
87
179
|
}
|
|
88
180
|
await walk(node, node.tagName ?? node.type)
|
|
89
181
|
return { node, css, diagnostics, imageNodes }
|
|
90
182
|
}
|
|
91
183
|
|
|
92
|
-
return
|
|
93
|
-
/** Diagnostics only (`weft check`, dev): nothing is fetched or rasterized. */
|
|
94
|
-
async check(html) {
|
|
95
|
-
return (await prepare(html)).diagnostics
|
|
96
|
-
},
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Renders a card. Never throws on a card's styles or images: those come back as diagnostics, and the card renders
|
|
100
|
-
* without them. `strict` (dev, `weft build`) throws on any error-severity diagnostic instead.
|
|
101
|
-
*/
|
|
102
|
-
async render(html, { width = SIZE.width, height = SIZE.height, format = 'png', quality, strict = false } = {}) {
|
|
103
|
-
const { node, css, diagnostics, imageNodes } = await prepare(html)
|
|
104
|
-
const { images, failed } = await loadImages(imageNodes.map(n => n.src), { cache, ...imageOptions })
|
|
105
|
-
for (const f of failed) {
|
|
106
|
-
diagnostics.push({ code: 'H008', severity: 'warning', message: `Card image ${f.src} didn't load (${f.reason}); the card renders without it.` })
|
|
107
|
-
for (const n of imageNodes) if (n.src === f.src) Object.assign(n, { type: 'container', src: undefined, style: { width: n.width && `${n.width}px`, height: n.height && `${n.height}px`, ...n.style } })
|
|
108
|
-
}
|
|
109
|
-
if (strict && diagnostics.some(d => d.severity === 'error')) {
|
|
110
|
-
throw Object.assign(new Error(diagnostics.filter(d => d.severity === 'error').map(d => `${d.code} ${d.message} (${d.element})`).join('\n')), { diagnostics })
|
|
111
|
-
}
|
|
112
|
-
const options = { width, height, images, css: [PREFLIGHT, ...css] }
|
|
113
|
-
const body = format === 'svg'
|
|
114
|
-
? await renderer.renderSvg(node, options)
|
|
115
|
-
: new Uint8Array(await renderer.render(node, { ...options, format, ...(quality != null && { quality }) }))
|
|
116
|
-
return { body, type: TYPES[format], diagnostics }
|
|
117
|
-
},
|
|
118
|
-
}
|
|
184
|
+
return prepare
|
|
119
185
|
}
|
|
120
186
|
|
|
121
187
|
/**
|
|
@@ -124,7 +190,10 @@ export async function createCardRenderer({ backend, fonts = [], images: imageOpt
|
|
|
124
190
|
*/
|
|
125
191
|
export function cardResponse({ body, type }, { dev = false } = {}) {
|
|
126
192
|
return new Response(body, {
|
|
127
|
-
headers: {
|
|
193
|
+
headers: {
|
|
194
|
+
'content-type': type,
|
|
195
|
+
'cache-control': dev ? 'no-store' : 'public, max-age=31536000, immutable',
|
|
196
|
+
},
|
|
128
197
|
})
|
|
129
198
|
}
|
|
130
199
|
|
|
@@ -133,14 +202,21 @@ export function cardResponse({ body, type }, { dev = false } = {}) {
|
|
|
133
202
|
* browser shows what the renderer draws.
|
|
134
203
|
*/
|
|
135
204
|
export function previewHtml(html, { fonts = [], width = SIZE.width, height = SIZE.height } = {}) {
|
|
136
|
-
const faces = fonts
|
|
205
|
+
const faces = fonts
|
|
206
|
+
.map(
|
|
207
|
+
(f) =>
|
|
208
|
+
`@font-face{font-family:"${f.name}";font-weight:${f.weight ?? 400};font-style:${f.style ?? 'normal'};src:url(data:font/${fontMime(f.data)};base64,${base64(f.data)})}`,
|
|
209
|
+
)
|
|
210
|
+
.join('')
|
|
137
211
|
return `<!doctype html><meta charset="utf-8"><style>${faces}${PREFLIGHT}html,body{margin:0}#card{width:${width}px;height:${height}px;overflow:hidden;display:flex}</style><div id="card">${stripComments(html)}</div>`
|
|
138
212
|
}
|
|
139
213
|
|
|
140
|
-
const fontMime =
|
|
214
|
+
const fontMime = (d) =>
|
|
215
|
+
({ wOFF: 'woff', wOF2: 'woff2', OTTO: 'otf' })[String.fromCharCode(...d.subarray(0, 4))] ?? 'ttf'
|
|
141
216
|
|
|
142
217
|
function base64(bytes) {
|
|
143
218
|
let s = ''
|
|
144
|
-
for (let i = 0; i < bytes.length; i += 0x8000)
|
|
219
|
+
for (let i = 0; i < bytes.length; i += 0x8000)
|
|
220
|
+
s += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
|
|
145
221
|
return btoa(s)
|
|
146
222
|
}
|
package/src/og/native.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// `@takumi-rs/core`: Node, Bun and Deno servers, `weft build`, Vercel and Netlify Node functions. A file of its own so
|
|
2
|
-
// an edge bundle (`@takumi-rs/wasm`, spikes/og/card/wasm.mjs) never pulls in the native addon. Both builds draw byte-identical cards (§9.4).
|
|
2
|
+
// an edge bundle (`@takumi-rs/wasm`, archive/evals-2026-11:spikes/og/card/wasm.mjs) never pulls in the native addon. Both builds draw byte-identical cards (§9.4).
|
|
3
3
|
export async function native() {
|
|
4
4
|
// Takumi's addon in the same process as a later Rolldown pass kills a Windows `weft build` (0xC0000005) before the
|
|
5
5
|
// server is written. WEFT_START_DIR means a build, and the cards come out the same.
|
|
6
|
-
if (globalThis.process?.platform === 'win32' && globalThis.process.env.WEFT_START_DIR)
|
|
6
|
+
if (globalThis.process?.platform === 'win32' && globalThis.process.env.WEFT_START_DIR)
|
|
7
|
+
return wasmBackend(globalThis.process.env.WEFT_START_DIR)
|
|
7
8
|
const { Renderer } = await import('@takumi-rs/core').catch(linked)
|
|
8
9
|
return { name: 'native', Renderer }
|
|
9
10
|
}
|
|
@@ -21,9 +22,15 @@ async function linked(error) {
|
|
|
21
22
|
|
|
22
23
|
/** The wasm renderer, found from `start` (the @weft-js/start directory) because a prerender runs from a bundle. */
|
|
23
24
|
export async function wasmBackend(start) {
|
|
24
|
-
const [{ createRequire }, { readFile }, { dirname, join }, { pathToFileURL }] = await Promise.all(
|
|
25
|
+
const [{ createRequire }, { readFile }, { dirname, join }, { pathToFileURL }] = await Promise.all(
|
|
26
|
+
['node:module', 'node:fs/promises', 'node:path', 'node:url'].map(
|
|
27
|
+
(m) => import(/* @vite-ignore */ m),
|
|
28
|
+
),
|
|
29
|
+
)
|
|
25
30
|
const root = dirname(dirname(createRequire(`${start}/package.json`).resolve('@takumi-rs/wasm')))
|
|
26
|
-
const { default: init, Renderer } = await import(
|
|
31
|
+
const { default: init, Renderer } = await import(
|
|
32
|
+
/* @vite-ignore */ pathToFileURL(join(root, 'dist/export.mjs')).href
|
|
33
|
+
)
|
|
27
34
|
await init({ module_or_path: await readFile(join(root, 'pkg/takumi_wasm_bg.wasm')) })
|
|
28
35
|
return { name: 'wasm', Renderer }
|
|
29
36
|
}
|
package/src/range.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Byte ranges for built video and audio. Nitro's static handler has none and runs before any middleware, and Safari
|
|
2
2
|
// won't play a video without them, so this Nitro plugin answers ranged requests for those files in front of the app.
|
|
3
|
-
export default nitro => {
|
|
3
|
+
export default (nitro) => {
|
|
4
4
|
const app = nitro.fetch
|
|
5
5
|
nitro.fetch = async (request, ...rest) => {
|
|
6
6
|
const range = /^bytes=(\d+)-(\d*)$/.exec(request.headers.get('range') ?? '')
|
|
@@ -13,7 +13,19 @@ export default nitro => {
|
|
|
13
13
|
const bytes = Buffer.from(await readAsset(id))
|
|
14
14
|
const start = Number(range[1])
|
|
15
15
|
const end = Math.min(range[2] ? Number(range[2]) : Infinity, bytes.length - 1)
|
|
16
|
-
if (start > end)
|
|
17
|
-
|
|
16
|
+
if (start > end)
|
|
17
|
+
return new Response(null, {
|
|
18
|
+
status: 416,
|
|
19
|
+
headers: { 'content-range': `bytes */${bytes.length}` },
|
|
20
|
+
})
|
|
21
|
+
return new Response(bytes.subarray(start, end + 1), {
|
|
22
|
+
status: 206,
|
|
23
|
+
headers: {
|
|
24
|
+
'content-type': asset.type,
|
|
25
|
+
'accept-ranges': 'bytes',
|
|
26
|
+
'content-range': `bytes ${start}-${end}/${bytes.length}`,
|
|
27
|
+
'cache-control': 'public, max-age=31536000, immutable',
|
|
28
|
+
},
|
|
29
|
+
})
|
|
18
30
|
}
|
|
19
31
|
}
|
package/src/router.ts
CHANGED
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
// renders its new route, and everything inside it goes. One state per request on the server (passed down as a
|
|
6
6
|
// prop), one per tab on the client.
|
|
7
7
|
|
|
8
|
-
import { state, untrack, type State,type SsrData } from '@weft-js/core'
|
|
8
|
+
import { state, untrack, type State, type SsrData } from '@weft-js/core'
|
|
9
9
|
import type { Merged } from '@weft-js/core/ssr'
|
|
10
10
|
import type { Wire } from './data'
|
|
11
|
+
import type { PreparedRouteShape } from './run/shape-preparation'
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* One level as the server sends it: `d` lists the data's deferred fields (promises on the server, research 25 §4),
|
|
@@ -31,12 +32,33 @@ export interface Level {
|
|
|
31
32
|
data: State<unknown>
|
|
32
33
|
/** A static level's HTML: empty for the page's own; undefined for a level with client code. */
|
|
33
34
|
html: State<string | undefined>
|
|
35
|
+
/** A temporary incoming shape for this outlet, while its route is pending. */
|
|
36
|
+
shape: State<PreparedRouteShape[] | undefined>
|
|
34
37
|
x?: string | undefined
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
/** A route, layout or island: its props are its own, so nothing here can call one without a cast. */
|
|
38
41
|
export type Component = (props: never) => unknown
|
|
39
42
|
|
|
43
|
+
/** Sizes are emitted, uncompressed bytes in Vite's recorded static closure. */
|
|
44
|
+
export interface RouteAssetCosts {
|
|
45
|
+
build: string
|
|
46
|
+
scope: 'vite-static-closure'
|
|
47
|
+
levels: { id: string; bytes: number | null }[]
|
|
48
|
+
routeBytes: number | null
|
|
49
|
+
eagerBytes: number | null
|
|
50
|
+
combinedBytes: number | null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface AssetReceipt {
|
|
54
|
+
build: string | null
|
|
55
|
+
version: 1
|
|
56
|
+
scope: 'vite-static-closure'
|
|
57
|
+
files: Record<string, { bytes: number | null; kind: string }>
|
|
58
|
+
routes: Record<string, { files: string[]; bytes: number | null } | null>
|
|
59
|
+
eager: { files: string[]; bytes: number | null } | null
|
|
60
|
+
}
|
|
61
|
+
|
|
40
62
|
export interface RouteState {
|
|
41
63
|
levels: Level[]
|
|
42
64
|
/** The current path (pathname + search). */
|
|
@@ -44,6 +66,10 @@ export interface RouteState {
|
|
|
44
66
|
status: State<number>
|
|
45
67
|
/** Loaded route components by id. */
|
|
46
68
|
comps: Record<string, Component>
|
|
69
|
+
/** Cost of the current match, when the client and receipt are from the same build. */
|
|
70
|
+
assetBytes?: RouteAssetCosts | null
|
|
71
|
+
/** Prepare a route's provided shape CSS until the caller releases it. */
|
|
72
|
+
prepareShape?: (id: string) => PreparedRouteShape | null
|
|
47
73
|
/** Client: shows a static level's HTML, with the next level inside it. */
|
|
48
74
|
mount?: (html: string, depth: number, next: () => unknown) => unknown
|
|
49
75
|
/** Around a level's component, at the same point on both sides: the server's markers, the client's key scope. */
|
|
@@ -64,19 +90,24 @@ export interface RoutePayload {
|
|
|
64
90
|
i?: string[]
|
|
65
91
|
/** A navigation's page data, for those islands. */
|
|
66
92
|
b?: SsrData | null
|
|
93
|
+
/** Emitted asset cost for these levels, when a matching build receipt exists. */
|
|
94
|
+
a?: RouteAssetCosts
|
|
67
95
|
}
|
|
68
96
|
|
|
69
97
|
/** The route state of the render in progress: a server pass sets it as it starts (passes are synchronous). */
|
|
70
98
|
export const active: { route?: RouteState } = {}
|
|
71
99
|
|
|
72
|
-
export function createRouteState(
|
|
100
|
+
export function createRouteState(
|
|
101
|
+
payload: RoutePayload,
|
|
102
|
+
comps: Record<string, Component>,
|
|
103
|
+
): RouteState {
|
|
73
104
|
const r: RouteState = { levels: [], path: state(payload.p), status: state(payload.s), comps }
|
|
74
105
|
payload.m.forEach((m, d) => {
|
|
75
106
|
const lv = level(r, d)
|
|
76
107
|
lv.id.value = m.id
|
|
77
108
|
lv.params.value = m.params
|
|
78
109
|
lv.data.value = m.data
|
|
79
|
-
lv.html.value = m.s ? m.h ?? '' : undefined
|
|
110
|
+
lv.html.value = m.s ? (m.h ?? '') : undefined
|
|
80
111
|
lv.x = m.x
|
|
81
112
|
})
|
|
82
113
|
return r
|
|
@@ -84,7 +115,13 @@ export function createRouteState(payload: RoutePayload, comps: Record<string, Co
|
|
|
84
115
|
|
|
85
116
|
/** Level `d`, made on first use (an empty level renders nothing). */
|
|
86
117
|
export function level(r: RouteState, d: number): Level {
|
|
87
|
-
return (r.levels[d] ??= {
|
|
118
|
+
return (r.levels[d] ??= {
|
|
119
|
+
id: state<string | undefined>(undefined),
|
|
120
|
+
params: state({}),
|
|
121
|
+
data: state<unknown>(undefined),
|
|
122
|
+
html: state<string | undefined>(undefined),
|
|
123
|
+
shape: state<PreparedRouteShape[] | undefined>(undefined),
|
|
124
|
+
})
|
|
88
125
|
}
|
|
89
126
|
|
|
90
127
|
const same = (a: unknown, b: unknown) => a === b || JSON.stringify(a) === JSON.stringify(b)
|
|
@@ -125,10 +162,17 @@ export function applyMatches(r: RouteState, matches: Match[], path: string, stat
|
|
|
125
162
|
*/
|
|
126
163
|
export const client: {
|
|
127
164
|
route?: RouteState
|
|
128
|
-
navigate?: (
|
|
129
|
-
|
|
165
|
+
navigate?: (
|
|
166
|
+
href: string,
|
|
167
|
+
options?: NavigateOptions & { replace?: boolean | undefined },
|
|
168
|
+
) => Promise<void>
|
|
169
|
+
/** `since`: what `now` said when the navigation left, so entries a write has passed don't land. */
|
|
170
|
+
seed?: (list: Wire[], since?: number) => void
|
|
130
171
|
seeds?: Wire[] | undefined
|
|
172
|
+
now?: () => number
|
|
131
173
|
invalidated?: (tags: string[]) => void
|
|
174
|
+
/** Clear navigation state before a session-changing reload. */
|
|
175
|
+
sessionChanged?: () => void
|
|
132
176
|
/** Dev: server code changed, so whatever a query read may have too. */
|
|
133
177
|
refreshed?: () => void
|
|
134
178
|
} = {}
|
|
@@ -141,6 +185,12 @@ export interface NavigateOptions {
|
|
|
141
185
|
scroll?: false
|
|
142
186
|
}
|
|
143
187
|
|
|
188
|
+
/** This tab's line to the app's other tabs: a list of tags a write invalidated, or `0` when the session changed. */
|
|
189
|
+
export const peers =
|
|
190
|
+
typeof document !== 'undefined' && typeof BroadcastChannel !== 'undefined'
|
|
191
|
+
? new BroadcastChannel('weft:tags')
|
|
192
|
+
: undefined
|
|
193
|
+
|
|
144
194
|
/** Go to `href` without a page load (a full load before `start()` ran, or for another origin). */
|
|
145
195
|
export function navigate(href: string, options?: NavigateOptions): Promise<void> {
|
|
146
196
|
if (client.navigate) return client.navigate(href, options)
|
package/src/routes.mjs
CHANGED
|
@@ -17,7 +17,7 @@ import { existsSync, readdirSync, statSync } from 'node:fs'
|
|
|
17
17
|
import { join, relative } from 'node:path'
|
|
18
18
|
|
|
19
19
|
const EXT = /\.(tsx|jsx|ts|js)$/
|
|
20
|
-
const posix = p => p.split('\\').join('/')
|
|
20
|
+
const posix = (p) => p.split('\\').join('/')
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
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
|
|
@@ -31,25 +31,31 @@ export function scanRoutes(root, dir = join(root, 'src', 'routes')) {
|
|
|
31
31
|
const out = { root, dir: posix(relative(root, dir)), routes: [], layouts: [], problems: [] }
|
|
32
32
|
if (!existsSync(dir)) return out
|
|
33
33
|
const files = []
|
|
34
|
-
const walk = d => {
|
|
34
|
+
const walk = (d) => {
|
|
35
35
|
for (const name of readdirSync(d).sort()) {
|
|
36
36
|
const abs = join(d, name)
|
|
37
37
|
if (statSync(abs).isDirectory()) {
|
|
38
38
|
if (!name.startsWith('_') && !name.startsWith('.')) walk(abs)
|
|
39
|
-
} else if (EXT.test(name) && !/\.(test|spec)\.|\.d\.ts$/.test(name))
|
|
39
|
+
} else if (EXT.test(name) && !/\.(test|spec)\.|\.d\.ts$/.test(name))
|
|
40
|
+
files.push(posix(relative(dir, abs)))
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
43
|
walk(dir)
|
|
43
44
|
|
|
44
|
-
const rel = f => posix(relative(root, join(dir, f)))
|
|
45
|
+
const rel = (f) => posix(relative(root, join(dir, f)))
|
|
45
46
|
const layoutFile = new Map() // route dir ('' = root) → its _layout file
|
|
46
47
|
for (const f of files) {
|
|
47
48
|
const m = /^(?:(.*)\/)?_layout\.[jt]sx?$/.exec(f)
|
|
48
49
|
if (m) layoutFile.set(m[1] ?? '', f)
|
|
49
50
|
}
|
|
50
|
-
const layoutOf = new Map([...layoutFile.keys()].map(d => [d, `layout:${d}`]))
|
|
51
|
+
const layoutOf = new Map([...layoutFile.keys()].map((d) => [d, `layout:${d}`]))
|
|
51
52
|
for (const [d, f] of [...layoutFile].sort((a, b) => a[0].length - b[0].length)) {
|
|
52
|
-
out.layouts.push({
|
|
53
|
+
out.layouts.push({
|
|
54
|
+
id: layoutOf.get(d),
|
|
55
|
+
file: rel(f),
|
|
56
|
+
dir: d,
|
|
57
|
+
parent: chain(d, layoutOf).at(-2),
|
|
58
|
+
})
|
|
53
59
|
}
|
|
54
60
|
|
|
55
61
|
const cards = new Map()
|
|
@@ -60,17 +66,41 @@ export function scanRoutes(root, dir = join(root, 'src', 'routes')) {
|
|
|
60
66
|
else cards.set(m[1], rel(f))
|
|
61
67
|
}
|
|
62
68
|
|
|
69
|
+
appendRoutes(files, out, rel, layoutOf, cards)
|
|
70
|
+
out.routes.sort((a, b) => cmpRank(a.rank, b.rank) || a.id.localeCompare(b.id))
|
|
71
|
+
for (const [b, file] of cards)
|
|
72
|
+
if (!out.routes.some((r) => r.card === file))
|
|
73
|
+
out.problems.push({ file, message: `no route \`${b}\` for this card` })
|
|
74
|
+
return out
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function appendRoutes(files, out, rel, layoutOf, cards) {
|
|
63
78
|
for (const f of files) {
|
|
64
79
|
const base = f.replace(EXT, '')
|
|
65
80
|
const name = base.split('/').pop()
|
|
66
81
|
if (base.endsWith('.og') || name === '_layout' || name === '_default') continue
|
|
67
82
|
const d = base.includes('/') ? base.slice(0, base.lastIndexOf('/')) : ''
|
|
68
83
|
if (name === '404' || name === '_error') {
|
|
69
|
-
if (d) {
|
|
70
|
-
|
|
84
|
+
if (d) {
|
|
85
|
+
out.problems.push({
|
|
86
|
+
file: rel(f),
|
|
87
|
+
message: `\`${name}\` is only read at the routes root for now; this one is ignored`,
|
|
88
|
+
})
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
out.routes.push({
|
|
92
|
+
id: name === '404' ? '404' : 'error',
|
|
93
|
+
file: rel(f),
|
|
94
|
+
layouts: chain('', layoutOf),
|
|
95
|
+
path: '',
|
|
96
|
+
pattern: '',
|
|
97
|
+
params: [],
|
|
98
|
+
rank: [],
|
|
99
|
+
kind: name === '404' ? '404' : 'error',
|
|
100
|
+
})
|
|
71
101
|
continue
|
|
72
102
|
}
|
|
73
|
-
if (name.startsWith('_') || d.split('/').some(s => s.startsWith('_'))) continue
|
|
103
|
+
if (name.startsWith('_') || d.split('/').some((s) => s.startsWith('_'))) continue
|
|
74
104
|
const segs = base === 'index' ? [] : base.split('/')
|
|
75
105
|
if (segs.at(-1) === 'index') segs.pop()
|
|
76
106
|
const params = []
|
|
@@ -86,19 +116,32 @@ export function scanRoutes(root, dir = join(root, 'src', 'routes')) {
|
|
|
86
116
|
} else if (/[[\]]/.test(s)) bad = true
|
|
87
117
|
else parts.push(s)
|
|
88
118
|
}
|
|
89
|
-
if (bad) {
|
|
90
|
-
|
|
119
|
+
if (bad) {
|
|
120
|
+
out.problems.push({
|
|
121
|
+
file: rel(f),
|
|
122
|
+
message: 'a segment mixes text and a `[param]`; use a whole segment `[name]`',
|
|
123
|
+
})
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
if (splat && parts.at(-1) !== '$') {
|
|
127
|
+
out.problems.push({ file: rel(f), message: '`[...rest]` must be the last segment' })
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
91
130
|
const pattern = '/' + parts.join('/')
|
|
92
131
|
out.routes.push({
|
|
93
|
-
id: pattern,
|
|
132
|
+
id: pattern,
|
|
133
|
+
file: rel(f),
|
|
134
|
+
layouts: chain(d, layoutOf),
|
|
135
|
+
path: '/' + segs.join('/'),
|
|
136
|
+
pattern,
|
|
137
|
+
params,
|
|
138
|
+
splat,
|
|
94
139
|
// 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),
|
|
140
|
+
rank: parts.map((p) => (p === '$' ? 0 : p.startsWith('$') ? 1 : 2)),
|
|
141
|
+
card: cards.get(base),
|
|
142
|
+
kind: 'page',
|
|
97
143
|
})
|
|
98
144
|
}
|
|
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
145
|
}
|
|
103
146
|
|
|
104
147
|
/** Layout ids from the root down to `d`. */
|
|
@@ -114,8 +157,10 @@ function chain(d, layoutOf) {
|
|
|
114
157
|
|
|
115
158
|
function cmpRank(a, b) {
|
|
116
159
|
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
117
|
-
|
|
118
|
-
|
|
160
|
+
// Shorter after: `/users/new` is tried before `/users/$id/edit`… only on ties. But a splat also matches nothing, so
|
|
161
|
+
// the route it would extend comes first: `/docs` before `/docs/$`, or the splat takes `/docs` itself.
|
|
162
|
+
if (a[i] === undefined) return b[i] === 0 && b.length === i + 1 ? -1 : 1
|
|
163
|
+
if (b[i] === undefined) return a[i] === 0 && a.length === i + 1 ? 1 : -1
|
|
119
164
|
if (a[i] !== b[i]) return b[i] - a[i]
|
|
120
165
|
}
|
|
121
166
|
return 0
|
|
@@ -131,12 +176,18 @@ export function fillPath(pattern, params) {
|
|
|
131
176
|
|
|
132
177
|
/** A route's file path as authors write it in types: `src/routes/users/[id].tsx` → `/users/[id]`, `index.tsx` → `/`. */
|
|
133
178
|
export function filePath(manifest, route) {
|
|
134
|
-
return
|
|
179
|
+
return (
|
|
180
|
+
'/' +
|
|
181
|
+
route.file
|
|
182
|
+
.slice(manifest.dir.length + 1)
|
|
183
|
+
.replace(EXT, '')
|
|
184
|
+
.replace(/(^|\/)index$/, '')
|
|
185
|
+
)
|
|
135
186
|
}
|
|
136
187
|
|
|
137
188
|
/** `.weft/routes.d.ts`: every route path, so `RouteProps<…, '/users/[id]'>` and `Meta<…, path>` only take real ones. */
|
|
138
189
|
export function routeTypes(manifest) {
|
|
139
|
-
const pages = manifest.routes.filter(r => r.kind === 'page')
|
|
140
|
-
const lines = pages.map(r => ` '${filePath(manifest, r)}': true`)
|
|
190
|
+
const pages = manifest.routes.filter((r) => r.kind === 'page')
|
|
191
|
+
const lines = pages.map((r) => ` '${filePath(manifest, r)}': true`)
|
|
141
192
|
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
193
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { ClientRoutes } from '../run'
|
|
2
|
+
import { guess, ours } from './helpers'
|
|
3
|
+
|
|
4
|
+
/** Admit a link only after the app's own event handlers have run. */
|
|
5
|
+
export function admitLink(event: Event, routes: ClientRoutes) {
|
|
6
|
+
if (event.defaultPrevented) return
|
|
7
|
+
if (
|
|
8
|
+
event instanceof MouseEvent &&
|
|
9
|
+
(event.metaKey ||
|
|
10
|
+
event.ctrlKey ||
|
|
11
|
+
event.shiftKey ||
|
|
12
|
+
event.altKey ||
|
|
13
|
+
((event.type === 'pointerdown' || event.type === 'click') && event.button !== 0))
|
|
14
|
+
)
|
|
15
|
+
return
|
|
16
|
+
if (event.type === 'pointerdown' && 'isPrimary' in event && !event.isPrimary) return
|
|
17
|
+
const path = event.composedPath()
|
|
18
|
+
const anchor = path.find((item) => item instanceof HTMLAnchorElement)
|
|
19
|
+
if (
|
|
20
|
+
!anchor ||
|
|
21
|
+
path.some(
|
|
22
|
+
(item) =>
|
|
23
|
+
item instanceof Element &&
|
|
24
|
+
item.matches('[data-navigation="off"], [contenteditable]:not([contenteditable="false"])'),
|
|
25
|
+
)
|
|
26
|
+
)
|
|
27
|
+
return
|
|
28
|
+
try {
|
|
29
|
+
const url = ours(anchor)
|
|
30
|
+
if (!url || !['http:', 'https:'].includes(url.protocol)) return
|
|
31
|
+
if (!guess(routes, url.pathname).length) return
|
|
32
|
+
return { anchor, url, noop: url.href === location.href }
|
|
33
|
+
} catch {
|
|
34
|
+
return undefined
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Pointer admission and its click fallback; the returned disposer removes only these listeners. */
|
|
39
|
+
export function routeClicks(
|
|
40
|
+
doc: Document,
|
|
41
|
+
routes: ClientRoutes,
|
|
42
|
+
go: (href: string) => void | Promise<void>,
|
|
43
|
+
) {
|
|
44
|
+
let accepted: { id: number; anchor: HTMLAnchorElement } | undefined
|
|
45
|
+
const down = (event: PointerEvent) => {
|
|
46
|
+
accepted = undefined
|
|
47
|
+
const target = admitLink(event, routes)
|
|
48
|
+
if (!target) return
|
|
49
|
+
accepted = { id: event.pointerId, anchor: target.anchor }
|
|
50
|
+
if (!target.noop) void Promise.resolve(go(target.url.href)).catch(() => {})
|
|
51
|
+
}
|
|
52
|
+
const cancel = (event: PointerEvent) => {
|
|
53
|
+
if (accepted?.id === event.pointerId) accepted = undefined
|
|
54
|
+
}
|
|
55
|
+
const click = (event: MouseEvent) => {
|
|
56
|
+
const record = accepted
|
|
57
|
+
accepted = undefined
|
|
58
|
+
const pointer = event as PointerEvent
|
|
59
|
+
if (
|
|
60
|
+
record &&
|
|
61
|
+
event.detail > 0 &&
|
|
62
|
+
(pointer.pointerId === record.id || event.composedPath().includes(record.anchor))
|
|
63
|
+
) {
|
|
64
|
+
event.preventDefault()
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
const target = admitLink(event, routes)
|
|
68
|
+
if (!target) return
|
|
69
|
+
event.preventDefault()
|
|
70
|
+
if (!target.noop) void go(target.url.href)
|
|
71
|
+
}
|
|
72
|
+
doc.addEventListener('pointerdown', down)
|
|
73
|
+
doc.addEventListener('pointercancel', cancel)
|
|
74
|
+
doc.addEventListener('click', click)
|
|
75
|
+
return () => {
|
|
76
|
+
doc.removeEventListener('pointerdown', down)
|
|
77
|
+
doc.removeEventListener('pointercancel', cancel)
|
|
78
|
+
doc.removeEventListener('click', click)
|
|
79
|
+
}
|
|
80
|
+
}
|