@weft-js/start 0.0.0-stage → 0.1.1

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.
Files changed (75) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +41 -2
  3. package/package.json +107 -4
  4. package/src/app.mjs +93 -0
  5. package/src/auth/better-auth.ts +58 -0
  6. package/src/auth/drizzle.ts +17 -0
  7. package/src/auth.ts +47 -0
  8. package/src/boot.ts +44 -0
  9. package/src/builtin.tsx +19 -0
  10. package/src/bus/postgres.ts +91 -0
  11. package/src/bus.ts +48 -0
  12. package/src/capture.d.mts +1 -0
  13. package/src/capture.mjs +15 -0
  14. package/src/changes-sql.d.mts +14 -0
  15. package/src/changes-sql.mjs +99 -0
  16. package/src/changes.ts +253 -0
  17. package/src/channel-fake.ts +55 -0
  18. package/src/channel.ts +622 -0
  19. package/src/client.tsx +20 -0
  20. package/src/contract.mjs +312 -0
  21. package/src/data.ts +929 -0
  22. package/src/db/bun-sqlite.d.ts +10 -0
  23. package/src/db/changes-mysql.ts +86 -0
  24. package/src/db/changes-postgres.ts +159 -0
  25. package/src/db/changes-sqlite.ts +116 -0
  26. package/src/db/core.ts +63 -0
  27. package/src/db/index.ts +27 -0
  28. package/src/db/mysql.ts +67 -0
  29. package/src/db/postgres.ts +65 -0
  30. package/src/db/sqlite.ts +139 -0
  31. package/src/derive.d.mts +16 -0
  32. package/src/derive.mjs +140 -0
  33. package/src/dev-runtime.mjs +42 -0
  34. package/src/exec.ts +519 -0
  35. package/src/extension.ts +38 -0
  36. package/src/first-paint.ts +57 -0
  37. package/src/form.tsx +134 -0
  38. package/src/gate.css +6 -0
  39. package/src/hub.ts +822 -0
  40. package/src/index.ts +106 -0
  41. package/src/inline.mjs +2 -0
  42. package/src/inspect.mjs +280 -0
  43. package/src/link.ts +461 -0
  44. package/src/list-drizzle.ts +50 -0
  45. package/src/list.ts +224 -0
  46. package/src/listen.ts +28 -0
  47. package/src/live-fake.ts +42 -0
  48. package/src/live-target.mjs +147 -0
  49. package/src/live.ts +499 -0
  50. package/src/media.ts +43 -0
  51. package/src/migrations.mjs +19 -0
  52. package/src/mux-session.ts +6 -0
  53. package/src/mux.ts +135 -0
  54. package/src/og/DefaultCard.tsx +60 -0
  55. package/src/og/fonts.mjs +64 -0
  56. package/src/og/images.mjs +54 -0
  57. package/src/og/index.mjs +146 -0
  58. package/src/og/native.mjs +29 -0
  59. package/src/range.mjs +19 -0
  60. package/src/router.ts +149 -0
  61. package/src/routes.mjs +142 -0
  62. package/src/run.tsx +202 -0
  63. package/src/rust.ts +333 -0
  64. package/src/server.tsx +1021 -0
  65. package/src/shape.d.mts +3 -0
  66. package/src/shape.mjs +73 -0
  67. package/src/share-server.ts +208 -0
  68. package/src/share.ts +503 -0
  69. package/src/sheets.mjs +75 -0
  70. package/src/sidecar.mjs +503 -0
  71. package/src/socket.ts +389 -0
  72. package/src/stream.ts +13 -0
  73. package/src/verify.ts +251 -0
  74. package/src/view.tsx +47 -0
  75. 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
@@ -0,0 +1,2 @@
1
+ /** The most stylesheet text one page carries inline with `criticalCss: false`, in characters. Past it a sheet stays a link. */
2
+ export const INLINE_CSS_MAX = 48 * 1024
@@ -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
+ }