@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/index.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// @weft-js/start: what route files import (research 25) and server modules define (research 27). A route module exports
|
|
2
|
+
// its component (`default`) and, when it needs them, `load` (async data, often `await q.fetch()`), `meta` (research
|
|
3
|
+
// 20 §3: static, awaited before the first byte, never reactive) and `prerender` (the params `weft build` writes as
|
|
4
|
+
// static pages, with their share cards; `true` on a route without params).
|
|
5
|
+
//
|
|
6
|
+
// // src/routes/users/[id].tsx
|
|
7
|
+
// export const load = async ({ params }: LoadArgs<'/users/[id]'>) => (await db.user(params.id)) ?? notFound()
|
|
8
|
+
// export const meta: Meta<typeof load> = ({ data }) => ({ title: data.name, description: data.bio })
|
|
9
|
+
// export default function User(props: RouteProps<typeof load, '/users/[id]'>) { return <h1>{props.data.name}</h1> }
|
|
10
|
+
|
|
11
|
+
import type { Merged, Meta as MetaObject, Site } from '@weft-js/core/ssr'
|
|
12
|
+
import type { Context } from './data'
|
|
13
|
+
|
|
14
|
+
export { Link } from './view'
|
|
15
|
+
export { Form, type FormApi, type FormProps } from './form'
|
|
16
|
+
export { navigate, type NavigateOptions } from './router'
|
|
17
|
+
export {
|
|
18
|
+
query, mutation, redirect, raise, invalid, invalidate, cache, skip, isRedirect, stale, StartError, FnError,
|
|
19
|
+
type Query, type Mutation, type QueryDef, type MutationDef, type AuthScope, type Context, type Errors, type FnOptions, type Life, type LiveMode, type LiveOptions, type LiveQuery, type LiveStatus, type OptimisticCache, type Redirect, type Skip, type StandardSchemaV1,
|
|
20
|
+
} from './data'
|
|
21
|
+
export { stream, type Stream, type StreamDef, type StreamOptions, type StreamStatus } from './stream'
|
|
22
|
+
export { channel, sharedState, type Channel, type ChannelDef, type ChannelStatus, type Connection, type PublishOptions, type SharedBox, type SharedState } from './channel'
|
|
23
|
+
export { liveHealth, realtime, realtimeStats, topic, tracked, type Limits, type LiveState, type RealtimeOptions, type Topic, type Tracked } from './hub'
|
|
24
|
+
export { dbChanges, type Change, type ChangeOptions, type Probe, type Sink, type Source } from './changes'
|
|
25
|
+
export { memoryBus, type Bus } from './bus'
|
|
26
|
+
export { listInput, type DbAdapter, type ListPage, type ListParams, type ListRequest, type ListSource, type ListSpec, type SourceSpec } from './list'
|
|
27
|
+
export type { Merged, Site } from '@weft-js/core/ssr'
|
|
28
|
+
|
|
29
|
+
/** Filled by the generated `.weft/routes.d.ts` with every route file's path (`'/users/[id]': true`). */
|
|
30
|
+
export interface RouteFiles {}
|
|
31
|
+
|
|
32
|
+
/** Route paths as files name them (`/users/[id]`); any string until `.weft/routes.d.ts` is generated. */
|
|
33
|
+
// oxlint-disable-next-line typescript/no-redundant-type-constituents -- `RouteFiles` is empty here and filled by a generated file
|
|
34
|
+
export type RoutePath = keyof RouteFiles extends never ? string : keyof RouteFiles & string
|
|
35
|
+
|
|
36
|
+
/** `'/users/[id]/posts/[...rest]'` → `{ id: string; rest: string }`. */
|
|
37
|
+
export type ParamsOf<P extends string> = string extends P
|
|
38
|
+
? Record<string, string>
|
|
39
|
+
: { [K in ParamNames<P>]: string }
|
|
40
|
+
type ParamNames<P extends string> = P extends `${string}[${infer N}]${infer Rest}`
|
|
41
|
+
? (N extends `...${infer S}` ? S : N) | ParamNames<Rest>
|
|
42
|
+
: never
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* What `load` gets. `request` is the incoming request (headers, cookies); `signal` aborts with it; `ctx` is what server
|
|
46
|
+
* functions get (the app's part from `src/context.server.ts`). `await listTodos.fetch()` here shares the request
|
|
47
|
+
* cache with the page's `listTodos()`, and `redirect()` answers 307.
|
|
48
|
+
*/
|
|
49
|
+
export interface LoadArgs<P extends string = string> {
|
|
50
|
+
params: ParamsOf<P>
|
|
51
|
+
request: Request
|
|
52
|
+
signal: AbortSignal
|
|
53
|
+
ctx: Context
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
declare const notFoundBrand: unique symbol
|
|
57
|
+
/** Return (or throw) it from `load`: the page renders the 404 route with status 404, and `meta` isn't called. */
|
|
58
|
+
export interface NotFound {
|
|
59
|
+
readonly isNotFound: true
|
|
60
|
+
readonly [notFoundBrand]?: true
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The not-found result, in the shape router-core's loaders recognise (`isNotFound`). */
|
|
64
|
+
export function notFound(): NotFound {
|
|
65
|
+
return { isNotFound: true }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type AnyLoad = (args: never) => unknown
|
|
69
|
+
|
|
70
|
+
/** A loader's data: what it resolves to, without `notFound()`. Top-level promise fields are deferred (streamed). */
|
|
71
|
+
export type DataOf<L> = L extends AnyLoad ? Exclude<Awaited<ReturnType<L>>, NotFound> : undefined
|
|
72
|
+
|
|
73
|
+
/** The data `meta` and cards see: deferred (promise) fields left out, since they're still streaming (H007). */
|
|
74
|
+
export type Settled<D> = D extends object ? { [K in keyof D as D[K] extends PromiseLike<unknown> ? never : K]: D[K] } : D
|
|
75
|
+
|
|
76
|
+
/** A route or layout component's props. `children` is the next level (layouts render it where the page goes). */
|
|
77
|
+
export interface RouteProps<L = undefined, P extends string = string> {
|
|
78
|
+
params: ParamsOf<P>
|
|
79
|
+
data: DataOf<L>
|
|
80
|
+
children?: unknown
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type { MetaObject }
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* `export const meta: Meta<typeof load> = ({ params, data }) => ({ title: data.name })`. Plain data, computed once
|
|
87
|
+
* per request after the loaders and before the first byte; it can't read boxes (H006) or deferred fields (H007).
|
|
88
|
+
*/
|
|
89
|
+
export type Meta<L = undefined, P extends string = string> =
|
|
90
|
+
| MetaObject
|
|
91
|
+
| ((args: { params: ParamsOf<P>; data: Settled<DataOf<L>> }) => MetaObject | Promise<MetaObject>)
|
|
92
|
+
|
|
93
|
+
/** A card's props (`*.og.tsx`, research 20 §4): the route's params and settled data, the merged metadata, the site. */
|
|
94
|
+
export interface OgProps<L = undefined, P extends string = string> {
|
|
95
|
+
params: ParamsOf<P>
|
|
96
|
+
data: Settled<DataOf<L>>
|
|
97
|
+
meta: Merged
|
|
98
|
+
site: Site
|
|
99
|
+
/** The page's path, and the title and description the built-in card would show. */
|
|
100
|
+
path: string
|
|
101
|
+
title: string
|
|
102
|
+
description: string
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The params of a param route whose cards `weft build` renders (static routes always are). */
|
|
106
|
+
export type Prerender<P extends string = string> = ParamsOf<P>[] | (() => ParamsOf<P>[] | Promise<ParamsOf<P>[]>)
|
package/src/inline.mjs
ADDED
package/src/inspect.mjs
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// `weft routes --meta` (research 20 §5, §10; research 25 §7) and the route half of `weft check`: every route's merged
|
|
2
|
+
// metadata and where its words came from, so an agent reviews every page's tags without a browser. Static routes run
|
|
3
|
+
// their loaders and render (one GET each, as a crawler's request would). Param routes run `meta` on placeholders
|
|
4
|
+
// (`params.id` is `<id>`, `data.name` is `<data.name>`), then on real samples: their `prerender` params and the links
|
|
5
|
+
// the rendered pages hold. Diagnostics H001–H007 and H011–H016 for each.
|
|
6
|
+
|
|
7
|
+
import { readFileSync } from 'node:fs'
|
|
8
|
+
import { join, resolve } from 'node:path'
|
|
9
|
+
import { createServer, parseSync } from 'vite'
|
|
10
|
+
import weft from '@weft-js/core/vite'
|
|
11
|
+
import weftStart, { startDir } from './vite.mjs'
|
|
12
|
+
import { deployUrl, descriptionProblem } from './derive.mjs'
|
|
13
|
+
import { filePath, scanRoutes } from './routes.mjs'
|
|
14
|
+
|
|
15
|
+
/** A param route's real pages to run, at most: enough to compare them, and few enough for `weft check`. */
|
|
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)
|
|
19
|
+
|
|
20
|
+
/** `/users/$id` → a matcher for the concrete paths of that route. */
|
|
21
|
+
const matcher = pattern => new RegExp('^' + pattern.replace(/[.+?^(){}|[\]\\]/g, '\\$&').replace(/\$[\w$]+/g, '[^/]+').replace(/\$$/, '.+') + '/?$')
|
|
22
|
+
const fillPath = (route, params) => route.pattern.replace(/\$([\w$]*)/g, (_, n) => String(params[n || route.splat]))
|
|
23
|
+
|
|
24
|
+
/** The same-site paths a page links to. */
|
|
25
|
+
const links = html => [...html.matchAll(/\shref="(\/[^"#?]*)/g)].map(m => m[1])
|
|
26
|
+
|
|
27
|
+
/** @param {string} code @param {string} file `{ present, reads }`: does the module export `meta`, and does it read `data` or `params`? */
|
|
28
|
+
export function metaExport(code, file) {
|
|
29
|
+
let ast
|
|
30
|
+
try { ast = parseSync(file, code, { lang: file.endsWith('x') ? 'tsx' : 'ts', sourceType: 'module' }) } catch { return { present: true, reads: true } }
|
|
31
|
+
for (const node of ast.program.body) {
|
|
32
|
+
if (node.type !== 'ExportNamedDeclaration') continue
|
|
33
|
+
if (node.specifiers?.some(s => (s.exported?.name ?? s.exported?.value) === 'meta')) return { present: true, reads: true } // from elsewhere: can't tell
|
|
34
|
+
const d = node.declaration
|
|
35
|
+
if (!d) continue
|
|
36
|
+
const decl = d.type === 'VariableDeclaration' ? d.declarations.find(x => x.id?.name === 'meta') : d.id?.name === 'meta' ? d : undefined
|
|
37
|
+
if (decl) return { present: true, reads: /\b(?:data|params|args|props)\b/.test(code.slice(decl.start, decl.end)) }
|
|
38
|
+
}
|
|
39
|
+
return { present: false, reads: false }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** What to paste: the page's own words, as a starting point an agent improves. */
|
|
43
|
+
function metaSnippet(route, title, description) {
|
|
44
|
+
if (route.params.length) return `export const meta: Meta<typeof load> = ({ data }) => ({ title: data.title, description: data.summary })`
|
|
45
|
+
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
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Only what reading the source can tell, for an app whose routes can't run here (`weft check` falls back to it):
|
|
50
|
+
* pages with no `meta`, and param routes whose `meta` reads neither `data` nor `params`.
|
|
51
|
+
*/
|
|
52
|
+
export function staticRouteChecks(root) {
|
|
53
|
+
const manifest = scanRoutes(root)
|
|
54
|
+
const out = []
|
|
55
|
+
for (const route of manifest.routes) {
|
|
56
|
+
if (route.kind !== 'page') continue
|
|
57
|
+
const { present, reads } = metaExport(readFileSync(join(root, route.file), 'utf8'), route.file)
|
|
58
|
+
const path = filePath(manifest, route)
|
|
59
|
+
if (!present) out.push({ code: 'H011', severity: 'warning', file: route.file, route: path, why: '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) })
|
|
60
|
+
else if (route.params.length && !reads) out.push({ code: 'H014', severity: 'warning', file: route.file, route: path, why: 'This route has params, but its `meta` reads neither `data` nor `params`: every page of it has the same tags.', help: metaSnippet(route) })
|
|
61
|
+
}
|
|
62
|
+
return out
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {{ root: string, site?: object, preset?: string }} options `preset: 'static'` means no server at runtime (H002).
|
|
67
|
+
*/
|
|
68
|
+
export async function routesMeta({ root, site = {}, preset }) {
|
|
69
|
+
root = resolve(root)
|
|
70
|
+
const server = await createServer({
|
|
71
|
+
root, configFile: false, logLevel: 'silent', appType: 'custom',
|
|
72
|
+
server: { middlewareMode: true, hmr: false, ws: false },
|
|
73
|
+
plugins: [weft({ hydratable: true }), weftStart({ root, site })],
|
|
74
|
+
})
|
|
75
|
+
try {
|
|
76
|
+
const { manifest, modules } = await server.ssrLoadModule('virtual:weft/routes')
|
|
77
|
+
const { createHandler, cardPath } = await server.ssrLoadModule(join(startDir, 'server.tsx'))
|
|
78
|
+
const { mergeMeta, headTags } = await server.ssrLoadModule('@weft-js/core/ssr')
|
|
79
|
+
await server.ssrLoadModule('virtual:weft/fns').catch(() => {})
|
|
80
|
+
// An app's server code often validates its environment as it loads, and this may not be that environment. Its pages
|
|
81
|
+
// can still be looked at: one that needs it answers 500, which is H016.
|
|
82
|
+
const context = await server.ssrLoadModule('virtual:weft/context').then(m => m.default, () => undefined)
|
|
83
|
+
const found = []
|
|
84
|
+
const known = deployUrl(site, process.env)
|
|
85
|
+
const handler = createHandler({ manifest, modules, site: known ? { ...site, url: known } : site, context, build: 'inspect', requestOrigin: false, onDiagnostic: d => found.push(d) })
|
|
86
|
+
const run = path => handler.inspect(path, new Request(`http://weft.local${path}`))
|
|
87
|
+
const out = []
|
|
88
|
+
const diagnostics = []
|
|
89
|
+
const pages = [] // { route, url, title, description, source }: what the rest compares
|
|
90
|
+
const seen = new Map() // path → the route's id, from the links on rendered pages
|
|
91
|
+
|
|
92
|
+
/** One run of a real page: its diagnostics, and its place in the comparison. */
|
|
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
|
+
}
|
|
101
|
+
|
|
102
|
+
const entries = []
|
|
103
|
+
for (const route of [...manifest.routes].sort((a, b) => a.params.length - b.params.length)) {
|
|
104
|
+
const mod = modules[route.file] ?? {}
|
|
105
|
+
const layouts = route.layouts.map(id => manifest.layouts.find(l => l.id === id))
|
|
106
|
+
const entry = { path: route.kind === 'page' ? filePath(manifest, route) : route.kind, kind: route.kind, file: route.file, layouts: layouts.map(l => l.file), params: route.params }
|
|
107
|
+
const report = (code, severity, why, help) => diagnostics.push({ code, severity, file: route.file, route: entry.path, why, ...(help && { help }) })
|
|
108
|
+
for (const f of [route.file, ...entry.layouts]) h006(readFileSync(join(root, f), 'utf8'), f, (why, file) => diagnostics.push({ code: 'H006', severity: 'error', file, route: entry.path, why }))
|
|
109
|
+
let head, status, url, data, redirect, source
|
|
110
|
+
if (route.kind !== 'page') {
|
|
111
|
+
const metas = [...layouts.map(l => modules[l.file]?.meta), mod.meta ?? { title: route.kind === '404' ? 'Not found' : 'Error' }]
|
|
112
|
+
head = mergeMeta(site, ...(await Promise.all(metas.map(m => (typeof m === 'function' ? m({ params: {}, data: placeholder('data') }) : m)))))
|
|
113
|
+
head.og.image = undefined
|
|
114
|
+
status = route.kind === '404' ? 404 : 500
|
|
115
|
+
url = null
|
|
116
|
+
data = 'none'
|
|
117
|
+
} else if (!route.params.length) {
|
|
118
|
+
const r = await take(route, route.pattern, report)
|
|
119
|
+
;({ head, status, source } = r)
|
|
120
|
+
redirect = r.redirect?.href
|
|
121
|
+
url = route.pattern
|
|
122
|
+
data = 'loaded'
|
|
123
|
+
if (status >= 500) report('H016', 'info', `This page answered ${status} here, so its metadata wasn't checked. It may need a database or an environment \`weft check\` doesn't have.`)
|
|
124
|
+
} else {
|
|
125
|
+
const params = Object.fromEntries(route.params.map(p => [p, `<${p}>`]))
|
|
126
|
+
url = route.pattern.replace(/\$([\w$]*)/g, (_, n) => `<${n || route.splat}>`)
|
|
127
|
+
const metas = []
|
|
128
|
+
for (const m of [...layouts.map(l => modules[l.file]?.meta), mod.meta]) {
|
|
129
|
+
// `meta` can do what placeholders can't (`data.tags.join()`), and that's no fault: the samples run it for real.
|
|
130
|
+
try { metas.push(typeof m === 'function' ? await m({ params, data: placeholder('data') }) : m) } catch {}
|
|
131
|
+
}
|
|
132
|
+
head = mergeMeta(site, ...metas)
|
|
133
|
+
if (head.og.image === 'auto') head.og.image = { url: `/_weft/og/${cardPath(url)}.png?v=<hash>`, width: 1200, height: 630 }
|
|
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)
|
|
157
|
+
}
|
|
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
|
+
} finally {
|
|
190
|
+
await server.close()
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
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
|
+
/**
|
|
265
|
+
* H006, statically: `meta` is plain data from `params` and `data`, so its source reads no `.value` and creates or calls
|
|
266
|
+
* nothing reactive. The AST gives the export's exact source range.
|
|
267
|
+
*/
|
|
268
|
+
export function h006(code, file, report) {
|
|
269
|
+
let ast
|
|
270
|
+
try { ast = parseSync(file, code, { lang: file.endsWith('x') ? 'tsx' : 'ts', sourceType: 'module' }) } catch { return }
|
|
271
|
+
for (const node of ast.program.body) {
|
|
272
|
+
if (node.type !== 'ExportNamedDeclaration' || !node.declaration) continue
|
|
273
|
+
const d = node.declaration
|
|
274
|
+
const decl = d.type === 'VariableDeclaration' ? d.declarations.find(x => x.id?.name === 'meta') : d.id?.name === 'meta' ? d : undefined
|
|
275
|
+
if (!decl) continue
|
|
276
|
+
const src = code.slice(decl.start, decl.end)
|
|
277
|
+
const hit = /\.value\b/.exec(src) ?? /\b(state|derived|resource|watch|use[A-Z]\w*)\s*\(/.exec(src)
|
|
278
|
+
if (hit) report(`\`meta\` ${hit[0].startsWith('.') ? 'reads a box (`.value`)' : `calls \`${hit[1]}()\``}: metadata is plain data, computed once per request from \`params\` and \`data\`.`, file)
|
|
279
|
+
}
|
|
280
|
+
}
|