@weft-js/start 0.1.1 → 0.1.3
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 +1278 -12
- package/crate/Cargo.lock +1347 -0
- package/crate/Cargo.toml +72 -0
- package/crate/README.md +322 -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 +38 -8
- 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 +120 -492
- package/src/client.tsx +9 -2
- package/src/contract/describe.mjs +231 -0
- package/src/contract.mjs +144 -157
- 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 +90 -166
- 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 +112 -37
- 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 -178
- 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 -971
- 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
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { filePath } from '../routes.mjs'
|
|
4
|
+
|
|
5
|
+
/** `<data>`, `<data.name>`, `<data.user.name>`: reads become their own names, for meta run without a loader. */
|
|
6
|
+
function placeholder(path) {
|
|
7
|
+
const text = `<${path}>`
|
|
8
|
+
return new Proxy(function () {}, {
|
|
9
|
+
get(_, k) {
|
|
10
|
+
if (k === Symbol.toPrimitive || k === 'toString' || k === 'valueOf' || k === 'toJSON')
|
|
11
|
+
return () => text
|
|
12
|
+
if (typeof k === 'symbol') return undefined
|
|
13
|
+
return placeholder(`${path}.${k}`)
|
|
14
|
+
},
|
|
15
|
+
apply: () => text,
|
|
16
|
+
})
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function routeHead(route, layouts, mod, context, report) {
|
|
20
|
+
const { site, mergeMeta, cardPath, take, modules } = context
|
|
21
|
+
let head, status, url, data, redirect, source
|
|
22
|
+
if (route.kind !== 'page') {
|
|
23
|
+
const metas = [
|
|
24
|
+
...layouts.map((l) => modules[l.file]?.meta),
|
|
25
|
+
mod.meta ?? { title: route.kind === '404' ? 'Not found' : 'Error' },
|
|
26
|
+
]
|
|
27
|
+
head = mergeMeta(
|
|
28
|
+
site,
|
|
29
|
+
...(await Promise.all(
|
|
30
|
+
metas.map((m) =>
|
|
31
|
+
typeof m === 'function' ? m({ params: {}, data: placeholder('data') }) : m,
|
|
32
|
+
),
|
|
33
|
+
)),
|
|
34
|
+
)
|
|
35
|
+
head.og.image = undefined
|
|
36
|
+
status = route.kind === '404' ? 404 : 500
|
|
37
|
+
url = null
|
|
38
|
+
data = 'none'
|
|
39
|
+
} else if (!route.params.length) {
|
|
40
|
+
const r = await take(route, route.pattern, report)
|
|
41
|
+
;({ head, status, source } = r)
|
|
42
|
+
redirect = r.redirect?.href
|
|
43
|
+
url = route.pattern
|
|
44
|
+
data = 'loaded'
|
|
45
|
+
if (status >= 500)
|
|
46
|
+
report(
|
|
47
|
+
'H016',
|
|
48
|
+
'info',
|
|
49
|
+
`This page answered ${status} here, so its metadata wasn't checked. It may need a database or an environment \`weft check\` doesn't have.`,
|
|
50
|
+
)
|
|
51
|
+
} else {
|
|
52
|
+
const params = Object.fromEntries(route.params.map((p) => [p, `<${p}>`]))
|
|
53
|
+
url = route.pattern.replace(/\$([\w$]*)/g, (_, n) => `<${n || route.splat}>`)
|
|
54
|
+
const metas = []
|
|
55
|
+
for (const m of [...layouts.map((l) => modules[l.file]?.meta), mod.meta]) {
|
|
56
|
+
// `meta` can do what placeholders can't (`data.tags.join()`), and that's no fault: the samples run it for real.
|
|
57
|
+
try {
|
|
58
|
+
metas.push(typeof m === 'function' ? await m({ params, data: placeholder('data') }) : m)
|
|
59
|
+
} catch {}
|
|
60
|
+
}
|
|
61
|
+
head = mergeMeta(site, ...metas)
|
|
62
|
+
if (head.og.image === 'auto')
|
|
63
|
+
head.og.image = {
|
|
64
|
+
url: `/_weft/og/${cardPath(url)}.png?v=<hash>`,
|
|
65
|
+
width: 1200,
|
|
66
|
+
height: 630,
|
|
67
|
+
}
|
|
68
|
+
status = 200
|
|
69
|
+
data = 'placeholder'
|
|
70
|
+
}
|
|
71
|
+
return { head, status, url, data, redirect, source }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function applyHead(entry, route, mod, resolved, context) {
|
|
75
|
+
const { head, status, url, data, redirect, source } = resolved
|
|
76
|
+
const { manifest, headTags, out } = context
|
|
77
|
+
const image = head.og.image
|
|
78
|
+
entry.url = url
|
|
79
|
+
entry.status = status
|
|
80
|
+
if (redirect !== undefined) {
|
|
81
|
+
// `load` redirects (research 27 §3): the response has no body, so no tags and no card.
|
|
82
|
+
Object.assign(entry, { redirect, data, card: null, image: 'none', meta: null, tags: [] })
|
|
83
|
+
out.push(entry)
|
|
84
|
+
return false
|
|
85
|
+
}
|
|
86
|
+
entry.data = data
|
|
87
|
+
entry.card = route.kind !== 'page' ? null : (route.card ?? manifest.defaultCard ?? 'built-in')
|
|
88
|
+
entry.image =
|
|
89
|
+
typeof image !== 'object' || !image
|
|
90
|
+
? 'none'
|
|
91
|
+
: !image.url.startsWith('/_weft/og/')
|
|
92
|
+
? 'url'
|
|
93
|
+
: !route.params.length
|
|
94
|
+
? 'build'
|
|
95
|
+
: mod.prerender
|
|
96
|
+
? 'prerender+on-demand'
|
|
97
|
+
: 'on-demand'
|
|
98
|
+
entry.meta = JSON.parse(JSON.stringify(head))
|
|
99
|
+
entry.tags = url
|
|
100
|
+
? headTags(head, url).map(([tag, attrs, text]) =>
|
|
101
|
+
text === undefined ? [tag, attrs] : [tag, attrs, text],
|
|
102
|
+
)
|
|
103
|
+
: headTags(head, '/')
|
|
104
|
+
if (source) entry.source = source
|
|
105
|
+
return true
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function inspectRoute(route, context) {
|
|
109
|
+
const { root, manifest, modules, h006, diagnostics, out, entries, preset } = context
|
|
110
|
+
const mod = modules[route.file] ?? {}
|
|
111
|
+
const layouts = route.layouts.map((id) => manifest.layouts.find((l) => l.id === id))
|
|
112
|
+
const entry = {
|
|
113
|
+
path: route.kind === 'page' ? filePath(manifest, route) : route.kind,
|
|
114
|
+
kind: route.kind,
|
|
115
|
+
file: route.file,
|
|
116
|
+
layouts: layouts.map((l) => l.file),
|
|
117
|
+
params: route.params,
|
|
118
|
+
}
|
|
119
|
+
const report = (code, severity, why, help) =>
|
|
120
|
+
diagnostics.push({
|
|
121
|
+
code,
|
|
122
|
+
severity,
|
|
123
|
+
file: route.file,
|
|
124
|
+
route: entry.path,
|
|
125
|
+
why,
|
|
126
|
+
...(help && { help }),
|
|
127
|
+
})
|
|
128
|
+
for (const f of [route.file, ...entry.layouts])
|
|
129
|
+
h006(readFileSync(join(root, f), 'utf8'), f, (why, file) =>
|
|
130
|
+
diagnostics.push({ code: 'H006', severity: 'error', file, route: entry.path, why }),
|
|
131
|
+
)
|
|
132
|
+
const resolved = await routeHead(route, layouts, mod, context, report)
|
|
133
|
+
const { head, status } = resolved
|
|
134
|
+
if (!applyHead(entry, route, mod, resolved, context)) return
|
|
135
|
+
// Route-level checks on the merged result (the config-level ones run in `weft check`).
|
|
136
|
+
if (preset === 'static' && entry.image === 'on-demand')
|
|
137
|
+
report(
|
|
138
|
+
'H002',
|
|
139
|
+
'error',
|
|
140
|
+
'A param route with a generated card on a static-only preset: export `prerender` with the params to build, or set `og.image`.',
|
|
141
|
+
)
|
|
142
|
+
if (route.kind === 'page' && status === 200 && !route.params.length && !head.description)
|
|
143
|
+
report('H003', 'warning', 'No description after merging the config, layouts and route.')
|
|
144
|
+
entries.push({ route, entry, report })
|
|
145
|
+
out.push(entry)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Inspect each route in the same order as before. */
|
|
149
|
+
export async function inspectRoutes(context) {
|
|
150
|
+
const { manifest, entries } = context
|
|
151
|
+
for (const route of [...manifest.routes].sort((a, b) => a.params.length - b.params.length))
|
|
152
|
+
await inspectRoute(route, context)
|
|
153
|
+
return entries
|
|
154
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { fillPath } from './url.mjs'
|
|
2
|
+
|
|
3
|
+
/** A param route's real pages to run, at most: enough to compare them, and few enough for `weft check`. */
|
|
4
|
+
const SAMPLES = 50
|
|
5
|
+
|
|
6
|
+
/** Run the real samples of param routes. */
|
|
7
|
+
export async function inspectSamples(entries, modules, seen, take) {
|
|
8
|
+
// The param routes' real pages: `prerender` first (what the build will make), then what rendered pages link to.
|
|
9
|
+
for (const { route, entry, report } of entries) {
|
|
10
|
+
if (route.kind !== 'page' || !route.params.length) continue
|
|
11
|
+
const mod = modules[route.file] ?? {}
|
|
12
|
+
let given = []
|
|
13
|
+
try {
|
|
14
|
+
given = (typeof mod.prerender === 'function' ? await mod.prerender() : mod.prerender) ?? []
|
|
15
|
+
} catch {}
|
|
16
|
+
if (!Array.isArray(given)) given = []
|
|
17
|
+
const urls = new Set(given.slice(0, SAMPLES).map((p) => fillPath(route, p)))
|
|
18
|
+
for (const [href, id] of seen) if (id === route.id && urls.size < SAMPLES) urls.add(href)
|
|
19
|
+
entry.samples = []
|
|
20
|
+
for (const u of urls) {
|
|
21
|
+
const r = await take(route, u, report)
|
|
22
|
+
if (r.status === 200 && !r.redirect)
|
|
23
|
+
entry.samples.push({
|
|
24
|
+
url: u,
|
|
25
|
+
title: r.head.title,
|
|
26
|
+
description: r.head.description,
|
|
27
|
+
image: typeof r.head.og.image === 'object' ? r.head.og.image?.url : undefined,
|
|
28
|
+
source: r.source,
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
entry.source = entry.samples[0]?.source
|
|
32
|
+
if (!entry.samples.length)
|
|
33
|
+
report(
|
|
34
|
+
'H016',
|
|
35
|
+
'info',
|
|
36
|
+
'No real page of this route to look at, so only its source was checked. Export `prerender`, or link to one from a page.',
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Draw one card for each route that has one. */
|
|
42
|
+
export async function inspectCards(entries, manifest, found, handler) {
|
|
43
|
+
// H005 and H008: a card only says what's wrong with it when it's drawn, so one page of each route with its own draws.
|
|
44
|
+
for (const { route, entry, report } of entries) {
|
|
45
|
+
const image = entry.samples?.[0]?.image ?? entry.meta?.og?.image?.url
|
|
46
|
+
if (
|
|
47
|
+
!(route.card || manifest.defaultCard) ||
|
|
48
|
+
!image?.startsWith('/_weft/og/') ||
|
|
49
|
+
image.includes('<')
|
|
50
|
+
)
|
|
51
|
+
continue
|
|
52
|
+
found.length = 0
|
|
53
|
+
try {
|
|
54
|
+
await handler(new Request(`http://weft.local${image}`))
|
|
55
|
+
} catch {}
|
|
56
|
+
for (const d of found)
|
|
57
|
+
if (d.code === 'H005' || d.code === 'H008') report(d.code, d.severity, d.message)
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/inspect.mjs
CHANGED
|
@@ -9,39 +9,61 @@ import { join, resolve } from 'node:path'
|
|
|
9
9
|
import { createServer, parseSync } from 'vite'
|
|
10
10
|
import weft from '@weft-js/core/vite'
|
|
11
11
|
import weftStart, { startDir } from './vite.mjs'
|
|
12
|
-
import { deployUrl
|
|
12
|
+
import { deployUrl } from './derive.mjs'
|
|
13
|
+
import { compare } from './inspect/compare.mjs'
|
|
14
|
+
import { inspectRoutes } from './inspect/routes.mjs'
|
|
15
|
+
import { inspectSamples, inspectCards } from './inspect/samples.mjs'
|
|
13
16
|
import { filePath, scanRoutes } from './routes.mjs'
|
|
14
17
|
|
|
15
|
-
|
|
16
|
-
const SAMPLES = 50
|
|
17
|
-
const quote = s => JSON.stringify(s)
|
|
18
|
-
const short = (s, n = 70) => (s.length > n ? s.slice(0, n - 1) + '…' : s)
|
|
18
|
+
const quote = (s) => JSON.stringify(s)
|
|
19
19
|
|
|
20
20
|
/** `/users/$id` → a matcher for the concrete paths of that route. */
|
|
21
|
-
const matcher =
|
|
22
|
-
|
|
21
|
+
const matcher = (pattern) =>
|
|
22
|
+
new RegExp(
|
|
23
|
+
'^' +
|
|
24
|
+
pattern
|
|
25
|
+
.replace(/[.+?^(){}|[\]\\]/g, '\\$&')
|
|
26
|
+
.replace(/\$[\w$]+/g, '[^/]+')
|
|
27
|
+
.replace(/\$$/, '.+') +
|
|
28
|
+
'/?$',
|
|
29
|
+
)
|
|
23
30
|
|
|
24
31
|
/** The same-site paths a page links to. */
|
|
25
|
-
const links = html => [...html.matchAll(/\shref="(\/[^"#?]*)/g)].map(m => m[1])
|
|
32
|
+
const links = (html) => [...html.matchAll(/\shref="(\/[^"#?]*)/g)].map((m) => m[1])
|
|
26
33
|
|
|
27
34
|
/** @param {string} code @param {string} file `{ present, reads }`: does the module export `meta`, and does it read `data` or `params`? */
|
|
28
35
|
export function metaExport(code, file) {
|
|
29
36
|
let ast
|
|
30
|
-
try {
|
|
37
|
+
try {
|
|
38
|
+
ast = parseSync(file, code, { lang: file.endsWith('x') ? 'tsx' : 'ts', sourceType: 'module' })
|
|
39
|
+
} catch {
|
|
40
|
+
return { present: true, reads: true }
|
|
41
|
+
}
|
|
31
42
|
for (const node of ast.program.body) {
|
|
32
43
|
if (node.type !== 'ExportNamedDeclaration') continue
|
|
33
|
-
if (node.specifiers?.some(s => (s.exported?.name ?? s.exported?.value) === 'meta'))
|
|
44
|
+
if (node.specifiers?.some((s) => (s.exported?.name ?? s.exported?.value) === 'meta'))
|
|
45
|
+
return { present: true, reads: true } // from elsewhere: can't tell
|
|
34
46
|
const d = node.declaration
|
|
35
47
|
if (!d) continue
|
|
36
|
-
const decl =
|
|
37
|
-
|
|
48
|
+
const decl =
|
|
49
|
+
d.type === 'VariableDeclaration'
|
|
50
|
+
? d.declarations.find((x) => x.id?.name === 'meta')
|
|
51
|
+
: d.id?.name === 'meta'
|
|
52
|
+
? d
|
|
53
|
+
: undefined
|
|
54
|
+
if (decl)
|
|
55
|
+
return {
|
|
56
|
+
present: true,
|
|
57
|
+
reads: /\b(?:data|params|args|props)\b/.test(code.slice(decl.start, decl.end)),
|
|
58
|
+
}
|
|
38
59
|
}
|
|
39
60
|
return { present: false, reads: false }
|
|
40
61
|
}
|
|
41
62
|
|
|
42
63
|
/** What to paste: the page's own words, as a starting point an agent improves. */
|
|
43
64
|
function metaSnippet(route, title, description) {
|
|
44
|
-
if (route.params.length)
|
|
65
|
+
if (route.params.length)
|
|
66
|
+
return `export const meta: Meta<typeof load> = ({ data }) => ({ title: data.title, description: data.summary })`
|
|
45
67
|
return `export const meta: Meta = { title: ${quote(title || 'What this page is')}, description: ${quote(description || 'One sentence on what this page gives the reader.')} }`
|
|
46
68
|
}
|
|
47
69
|
|
|
@@ -56,19 +78,63 @@ export function staticRouteChecks(root) {
|
|
|
56
78
|
if (route.kind !== 'page') continue
|
|
57
79
|
const { present, reads } = metaExport(readFileSync(join(root, route.file), 'utf8'), route.file)
|
|
58
80
|
const path = filePath(manifest, route)
|
|
59
|
-
if (!present)
|
|
60
|
-
|
|
81
|
+
if (!present)
|
|
82
|
+
out.push({
|
|
83
|
+
code: 'H011',
|
|
84
|
+
severity: 'warning',
|
|
85
|
+
file: route.file,
|
|
86
|
+
route: path,
|
|
87
|
+
why: 'This page sets no `meta`, so its title and description come from what it renders. Words written for the page are better.',
|
|
88
|
+
help: metaSnippet(route),
|
|
89
|
+
})
|
|
90
|
+
else if (route.params.length && !reads)
|
|
91
|
+
out.push({
|
|
92
|
+
code: 'H014',
|
|
93
|
+
severity: 'warning',
|
|
94
|
+
file: route.file,
|
|
95
|
+
route: path,
|
|
96
|
+
why: 'This route has params, but its `meta` reads neither `data` nor `params`: every page of it has the same tags.',
|
|
97
|
+
help: metaSnippet(route),
|
|
98
|
+
})
|
|
61
99
|
}
|
|
62
100
|
return out
|
|
63
101
|
}
|
|
64
102
|
|
|
103
|
+
function makeTaker(found, run, seen, manifest, pages) {
|
|
104
|
+
/** One run of a real page: its diagnostics, and its place in the comparison. */
|
|
105
|
+
return async (route, url, report) => {
|
|
106
|
+
found.length = 0
|
|
107
|
+
const r = await run(url)
|
|
108
|
+
for (const d of found) report(d.code, d.severity, d.message.replace(/^H\d{3} /, ''))
|
|
109
|
+
if (r.html)
|
|
110
|
+
for (const href of links(r.html))
|
|
111
|
+
if (!seen.has(href))
|
|
112
|
+
seen.set(
|
|
113
|
+
href,
|
|
114
|
+
manifest.routes.find((x) => x.kind === 'page' && matcher(x.pattern).test(href))?.id,
|
|
115
|
+
)
|
|
116
|
+
if (r.status === 200 && !r.redirect)
|
|
117
|
+
pages.push({
|
|
118
|
+
route,
|
|
119
|
+
url,
|
|
120
|
+
title: r.head.title,
|
|
121
|
+
description: r.head.description,
|
|
122
|
+
source: r.source,
|
|
123
|
+
})
|
|
124
|
+
return r
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
65
128
|
/**
|
|
66
129
|
* @param {{ root: string, site?: object, preset?: string }} options `preset: 'static'` means no server at runtime (H002).
|
|
67
130
|
*/
|
|
68
131
|
export async function routesMeta({ root, site = {}, preset }) {
|
|
69
132
|
root = resolve(root)
|
|
70
133
|
const server = await createServer({
|
|
71
|
-
root,
|
|
134
|
+
root,
|
|
135
|
+
configFile: false,
|
|
136
|
+
logLevel: 'silent',
|
|
137
|
+
appType: 'custom',
|
|
72
138
|
server: { middlewareMode: true, hmr: false, ws: false },
|
|
73
139
|
plugins: [weft({ hydratable: true }), weftStart({ root, site })],
|
|
74
140
|
})
|
|
@@ -79,202 +145,95 @@ export async function routesMeta({ root, site = {}, preset }) {
|
|
|
79
145
|
await server.ssrLoadModule('virtual:weft/fns').catch(() => {})
|
|
80
146
|
// An app's server code often validates its environment as it loads, and this may not be that environment. Its pages
|
|
81
147
|
// can still be looked at: one that needs it answers 500, which is H016.
|
|
82
|
-
const context = await server.ssrLoadModule('virtual:weft/context').then(
|
|
148
|
+
const context = await server.ssrLoadModule('virtual:weft/context').then(
|
|
149
|
+
(m) => m.default,
|
|
150
|
+
() => undefined,
|
|
151
|
+
)
|
|
83
152
|
const found = []
|
|
84
153
|
const known = deployUrl(site, process.env)
|
|
85
|
-
const handler = createHandler({
|
|
86
|
-
|
|
154
|
+
const handler = createHandler({
|
|
155
|
+
manifest,
|
|
156
|
+
modules,
|
|
157
|
+
site: known ? { ...site, url: known } : site,
|
|
158
|
+
context,
|
|
159
|
+
build: 'inspect',
|
|
160
|
+
requestOrigin: false,
|
|
161
|
+
onDiagnostic: (d) => found.push(d),
|
|
162
|
+
})
|
|
163
|
+
const run = (path) => handler.inspect(path, new Request(`http://weft.local${path}`))
|
|
87
164
|
const out = []
|
|
88
165
|
const diagnostics = []
|
|
89
166
|
const pages = [] // { route, url, title, description, source }: what the rest compares
|
|
90
167
|
const seen = new Map() // path → the route's id, from the links on rendered pages
|
|
91
168
|
|
|
92
|
-
|
|
93
|
-
const take = async (route, url, report) => {
|
|
94
|
-
found.length = 0
|
|
95
|
-
const r = await run(url)
|
|
96
|
-
for (const d of found) report(d.code, d.severity, d.message.replace(/^H\d{3} /, ''))
|
|
97
|
-
if (r.html) for (const href of links(r.html)) if (!seen.has(href)) seen.set(href, manifest.routes.find(x => x.kind === 'page' && matcher(x.pattern).test(href))?.id)
|
|
98
|
-
if (r.status === 200 && !r.redirect) pages.push({ route, url, title: r.head.title, description: r.head.description, source: r.source })
|
|
99
|
-
return r
|
|
100
|
-
}
|
|
169
|
+
const take = makeTaker(found, run, seen, manifest, pages)
|
|
101
170
|
|
|
102
171
|
const entries = []
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
status = 200
|
|
135
|
-
data = 'placeholder'
|
|
136
|
-
}
|
|
137
|
-
const image = head.og.image
|
|
138
|
-
entry.url = url
|
|
139
|
-
entry.status = status
|
|
140
|
-
if (redirect !== undefined) {
|
|
141
|
-
// `load` redirects (research 27 §3): the response has no body, so no tags and no card.
|
|
142
|
-
Object.assign(entry, { redirect, data, card: null, image: 'none', meta: null, tags: [] })
|
|
143
|
-
out.push(entry)
|
|
144
|
-
continue
|
|
145
|
-
}
|
|
146
|
-
entry.data = data
|
|
147
|
-
entry.card = route.kind !== 'page' ? null : route.card ?? manifest.defaultCard ?? 'built-in'
|
|
148
|
-
entry.image = typeof image !== 'object' || !image ? 'none' : !image.url.startsWith('/_weft/og/') ? 'url' : !route.params.length ? 'build' : mod.prerender ? 'prerender+on-demand' : 'on-demand'
|
|
149
|
-
entry.meta = JSON.parse(JSON.stringify(head))
|
|
150
|
-
entry.tags = url ? headTags(head, url).map(([tag, attrs, text]) => (text === undefined ? [tag, attrs] : [tag, attrs, text])) : headTags(head, '/')
|
|
151
|
-
if (source) entry.source = source
|
|
152
|
-
// Route-level checks on the merged result (the config-level ones run in `weft check`).
|
|
153
|
-
if (preset === 'static' && entry.image === 'on-demand') report('H002', 'error', 'A param route with a generated card on a static-only preset: export `prerender` with the params to build, or set `og.image`.')
|
|
154
|
-
if (route.kind === 'page' && status === 200 && !route.params.length && !head.description) report('H003', 'warning', 'No description after merging the config, layouts and route.')
|
|
155
|
-
entries.push({ route, entry, report })
|
|
156
|
-
out.push(entry)
|
|
172
|
+
await inspectRoutes({
|
|
173
|
+
root,
|
|
174
|
+
manifest,
|
|
175
|
+
modules,
|
|
176
|
+
site,
|
|
177
|
+
mergeMeta,
|
|
178
|
+
cardPath,
|
|
179
|
+
take,
|
|
180
|
+
headTags,
|
|
181
|
+
h006,
|
|
182
|
+
diagnostics,
|
|
183
|
+
out,
|
|
184
|
+
entries,
|
|
185
|
+
preset,
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
await inspectSamples(entries, modules, seen, take)
|
|
189
|
+
await inspectCards(entries, manifest, found, handler)
|
|
190
|
+
|
|
191
|
+
diagnostics.push(...compare({ root, entries, pages, site, known, metaExport, metaSnippet }))
|
|
192
|
+
out.sort(
|
|
193
|
+
(a, b) =>
|
|
194
|
+
manifest.routes.findIndex((r) => r.file === a.file) -
|
|
195
|
+
manifest.routes.findIndex((r) => r.file === b.file),
|
|
196
|
+
)
|
|
197
|
+
return {
|
|
198
|
+
version: 1,
|
|
199
|
+
ok: !diagnostics.some((d) => d.severity === 'error'),
|
|
200
|
+
site,
|
|
201
|
+
routes: out,
|
|
202
|
+
diagnostics,
|
|
157
203
|
}
|
|
158
|
-
|
|
159
|
-
// The param routes' real pages: `prerender` first (what the build will make), then what rendered pages link to.
|
|
160
|
-
for (const { route, entry, report } of entries) {
|
|
161
|
-
if (route.kind !== 'page' || !route.params.length) continue
|
|
162
|
-
const mod = modules[route.file] ?? {}
|
|
163
|
-
let given = []
|
|
164
|
-
try { given = (typeof mod.prerender === 'function' ? await mod.prerender() : mod.prerender) ?? [] } catch {}
|
|
165
|
-
if (!Array.isArray(given)) given = []
|
|
166
|
-
const urls = new Set(given.slice(0, SAMPLES).map(p => fillPath(route, p)))
|
|
167
|
-
for (const [href, id] of seen) if (id === route.id && urls.size < SAMPLES) urls.add(href)
|
|
168
|
-
entry.samples = []
|
|
169
|
-
for (const u of urls) {
|
|
170
|
-
const r = await take(route, u, report)
|
|
171
|
-
if (r.status === 200 && !r.redirect) entry.samples.push({ url: u, title: r.head.title, description: r.head.description, image: typeof r.head.og.image === 'object' ? r.head.og.image?.url : undefined, source: r.source })
|
|
172
|
-
}
|
|
173
|
-
entry.source = entry.samples[0]?.source
|
|
174
|
-
if (!entry.samples.length) report('H016', 'info', 'No real page of this route to look at, so only its source was checked. Export `prerender`, or link to one from a page.')
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// H005 and H008: a card only says what's wrong with it when it's drawn, so one page of each route with its own draws.
|
|
178
|
-
for (const { route, entry, report } of entries) {
|
|
179
|
-
const image = entry.samples?.[0]?.image ?? entry.meta?.og?.image?.url
|
|
180
|
-
if (!(route.card || manifest.defaultCard) || !image?.startsWith('/_weft/og/') || image.includes('<')) continue
|
|
181
|
-
found.length = 0
|
|
182
|
-
try { await handler(new Request(`http://weft.local${image}`)) } catch {}
|
|
183
|
-
for (const d of found) if (d.code === 'H005' || d.code === 'H008') report(d.code, d.severity, d.message)
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
diagnostics.push(...compare(root, entries, pages, site, known))
|
|
187
|
-
out.sort((a, b) => manifest.routes.findIndex(r => r.file === a.file) - manifest.routes.findIndex(r => r.file === b.file))
|
|
188
|
-
return { version: 1, ok: !diagnostics.some(d => d.severity === 'error'), site, routes: out, diagnostics }
|
|
189
204
|
} finally {
|
|
190
205
|
await server.close()
|
|
191
206
|
}
|
|
192
207
|
}
|
|
193
208
|
|
|
194
|
-
/** The checks that take every page at once: is each one's text its own, and is it text at all? */
|
|
195
|
-
function compare(root, entries, pages, site, known) {
|
|
196
|
-
const out = []
|
|
197
|
-
const push = (code, severity, route, why, extra) => out.push({ code, severity, file: route.file, route: route.path, why, ...extra })
|
|
198
|
-
const byRoute = new Map()
|
|
199
|
-
for (const p of pages) byRoute.set(p.route, [...(byRoute.get(p.route) ?? []), p])
|
|
200
|
-
|
|
201
|
-
if (!known) out.push({ code: 'H001', severity: 'warning', file: 'weft.config.ts', route: '*', why: "`site.url` is not set: a build has no absolute URLs (no canonical, no og:url, a relative og:image), and a server takes them from the request's Host header. Crawlers don't resolve relative URLs." , help: 'Set `site.url` in weft.config.ts, or `WEFT_SITE_URL` in the deploy environment.' })
|
|
202
|
-
|
|
203
|
-
for (const { route, entry } of entries) {
|
|
204
|
-
if (route.kind !== 'page') continue
|
|
205
|
-
const own = byRoute.get(route) ?? []
|
|
206
|
-
const at = { file: route.file, path: entry.path }
|
|
207
|
-
const first = own[0]
|
|
208
|
-
const { present, reads } = metaExport(readFileSync(join(root, route.file), 'utf8'), route.file)
|
|
209
|
-
const words = s => s === 'route' || s === 'layout'
|
|
210
|
-
if (first && !(first.source.title === 'route' && first.source.description === 'route')) {
|
|
211
|
-
const missing = [first.source.title !== 'route' && 'title', first.source.description !== 'route' && 'description'].filter(Boolean).join(' and ')
|
|
212
|
-
const whence = (what, s, text) => `${what} ${s === 'auto' ? `comes from the page (${quote(short(text))})` : s === 'layout' ? 'comes from a layout' : s === 'config' ? 'is the site default' : 'is empty'}`
|
|
213
|
-
push('H011', 'warning', at, `${present ? `\`meta\` sets no ${missing}` : 'This page sets no `meta`'}: the ${[first.source.title !== 'route' && whence('title', first.source.title, first.title), first.source.description !== 'route' && whence('description', first.source.description, first.description ?? '')].filter(Boolean).join(', and the ')}. That works, but words written for this page are better.`, { help: metaSnippet(route, first.source.title === 'auto' ? stripTemplate(first.title, site.title) : '', first.description) })
|
|
214
|
-
} else if (!first && !present) push('H011', 'warning', at, 'This page sets no `meta`, so its title and description come from what it renders. Words written for the page are better.', { help: metaSnippet(route) })
|
|
215
|
-
|
|
216
|
-
if (route.params.length && present && !reads) push('H014', 'warning', at, 'This route has params, but its `meta` reads neither `data` nor `params`: every page of it has the same tags.', { help: metaSnippet(route) })
|
|
217
|
-
else if (route.params.length && own.length > 1) {
|
|
218
|
-
const same = key => new Set(own.map(p => p[key])).size === 1 && own[0][key]
|
|
219
|
-
for (const key of ['title', 'description']) if (same(key)) push('H014', 'warning', at, `All ${own.length} pages of this route looked at have the same ${key} (${quote(short(own[0][key]))}), so a share or a search result can't tell them apart.`, { help: metaSnippet(route) })
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
const longTitle = own.find(p => p.title.length > 60)
|
|
223
|
-
if (longTitle) push('H004', 'warning', at, `The title is ${longTitle.title.length} characters (over 60): \`${longTitle.title}\`${own.length > 1 ? ` on ${longTitle.url}` : ''}.`)
|
|
224
|
-
const longText = own.find(p => p.description && p.description.length > 160)
|
|
225
|
-
if (longText) push('H004', 'warning', at, `The description is ${longText.description.length} characters (over 160)${own.length > 1 ? ` on ${longText.url}` : ''}.`)
|
|
226
|
-
const bad = own.filter(p => p.description && words(p.source.description) && descriptionProblem(p.description))
|
|
227
|
-
if (bad[0]) push('H015', 'warning', at, `The description ${own.length > 1 ? `of ${bad[0].url}${bad.length > 1 ? ` (and ${bad.length - 1} more)` : ''} ` : ''}isn't a sentence: ${descriptionProblem(bad[0].description)}: ${quote(short(bad[0].description))}.`, { help: 'Write one plain sentence on what the page gives the reader. Never slice raw markdown or code into `description`.' })
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
const clash = (key, code) => {
|
|
231
|
-
const groups = new Map()
|
|
232
|
-
for (const p of pages) if (p[key]) groups.set(p[key], [...(groups.get(p[key]) ?? []), p])
|
|
233
|
-
for (const [text, list] of groups) {
|
|
234
|
-
const routes = [...new Set(list.map(p => p.route))]
|
|
235
|
-
if (routes.length < 2) continue // one param route's pages are H014's
|
|
236
|
-
const what = key === 'title' ? 'title' : text === site.description ? 'description (the site default)' : 'description'
|
|
237
|
-
const a = routes[0]
|
|
238
|
-
push(code, 'warning', { file: a.file, path: list[0].url }, `${routes.length} routes share this ${what}: ${list.slice(0, 4).map(p => p.url).join(', ')}${list.length > 4 ? `, and ${list.length - 4} more` : ''} (${quote(short(text))}).`, { help: `Give each page its own ${key} in \`meta\`, from what is on it.` })
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
clash('title', 'H012')
|
|
242
|
-
clash('description', 'H013')
|
|
243
|
-
return out
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
const stripTemplate = (title, template) => {
|
|
247
|
-
const [a = '', b = ''] = template?.split('%s') ?? []
|
|
248
|
-
return title.startsWith(a) && title.endsWith(b) ? title.slice(a.length, title.length - b.length) : title
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
/** `<data>`, `<data.name>`, `<data.user.name>`: reads become their own names, for meta run without a loader. */
|
|
252
|
-
function placeholder(path) {
|
|
253
|
-
const text = `<${path}>`
|
|
254
|
-
return new Proxy(function () {}, {
|
|
255
|
-
get(_, k) {
|
|
256
|
-
if (k === Symbol.toPrimitive || k === 'toString' || k === 'valueOf' || k === 'toJSON') return () => text
|
|
257
|
-
if (typeof k === 'symbol') return undefined
|
|
258
|
-
return placeholder(`${path}.${k}`)
|
|
259
|
-
},
|
|
260
|
-
apply: () => text,
|
|
261
|
-
})
|
|
262
|
-
}
|
|
263
|
-
|
|
264
209
|
/**
|
|
265
210
|
* H006, statically: `meta` is plain data from `params` and `data`, so its source reads no `.value` and creates or calls
|
|
266
211
|
* nothing reactive. The AST gives the export's exact source range.
|
|
267
212
|
*/
|
|
268
213
|
export function h006(code, file, report) {
|
|
269
214
|
let ast
|
|
270
|
-
try {
|
|
215
|
+
try {
|
|
216
|
+
ast = parseSync(file, code, { lang: file.endsWith('x') ? 'tsx' : 'ts', sourceType: 'module' })
|
|
217
|
+
} catch {
|
|
218
|
+
return
|
|
219
|
+
}
|
|
271
220
|
for (const node of ast.program.body) {
|
|
272
221
|
if (node.type !== 'ExportNamedDeclaration' || !node.declaration) continue
|
|
273
222
|
const d = node.declaration
|
|
274
|
-
const decl =
|
|
223
|
+
const decl =
|
|
224
|
+
d.type === 'VariableDeclaration'
|
|
225
|
+
? d.declarations.find((x) => x.id?.name === 'meta')
|
|
226
|
+
: d.id?.name === 'meta'
|
|
227
|
+
? d
|
|
228
|
+
: undefined
|
|
275
229
|
if (!decl) continue
|
|
276
230
|
const src = code.slice(decl.start, decl.end)
|
|
277
|
-
const hit =
|
|
278
|
-
|
|
231
|
+
const hit =
|
|
232
|
+
/\.value\b/.exec(src) ?? /\b(state|derived|resource|watch|use[A-Z]\w*)\s*\(/.exec(src)
|
|
233
|
+
if (hit)
|
|
234
|
+
report(
|
|
235
|
+
`\`meta\` ${hit[0].startsWith('.') ? 'reads a box (`.value`)' : `calls \`${hit[1]}()\``}: metadata is plain data, computed once per request from \`params\` and \`data\`.`,
|
|
236
|
+
file,
|
|
237
|
+
)
|
|
279
238
|
}
|
|
280
239
|
}
|