@weft-js/start 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/README.md +36 -3
  2. package/crate/Cargo.lock +1347 -0
  3. package/crate/Cargo.toml +72 -0
  4. package/crate/rust-toolchain.toml +3 -0
  5. package/crate/src/build/emit.rs +103 -0
  6. package/crate/src/build.rs +367 -0
  7. package/crate/src/ctx.rs +186 -0
  8. package/crate/src/envelope.rs +163 -0
  9. package/crate/src/functions.rs +173 -0
  10. package/crate/src/lib.rs +118 -0
  11. package/crate/src/native.rs +212 -0
  12. package/crate/src/otel.rs +62 -0
  13. package/crate/src/server.rs +203 -0
  14. package/package.json +46 -37
  15. package/src/app.mjs +38 -8
  16. package/src/asset-receipt.d.mts +13 -0
  17. package/src/asset-receipt.mjs +61 -0
  18. package/src/auth/better-auth.ts +215 -17
  19. package/src/auth/drizzle.ts +1 -1
  20. package/src/auth.ts +21 -6
  21. package/src/boot.ts +19 -8
  22. package/src/bus/postgres.ts +32 -19
  23. package/src/bus.ts +7 -4
  24. package/src/{capture.mjs → capture.ts} +34 -1
  25. package/src/changes-sql.d.mts +8 -2
  26. package/src/changes-sql.mjs +26 -10
  27. package/src/changes.ts +165 -66
  28. package/src/channel/connections.ts +216 -0
  29. package/src/channel/socket.ts +396 -0
  30. package/src/channel-fake.ts +19 -4
  31. package/src/channel.ts +120 -492
  32. package/src/client.tsx +9 -2
  33. package/src/contract/describe.mjs +231 -0
  34. package/src/contract.mjs +144 -157
  35. package/src/data/cache.ts +354 -0
  36. package/src/data/definitions.ts +94 -0
  37. package/src/data/draft.ts +104 -0
  38. package/src/data/foundation.ts +95 -0
  39. package/src/data/live-api.ts +22 -0
  40. package/src/data/mutation.ts +138 -0
  41. package/src/data/primitives.ts +135 -0
  42. package/src/data/query.ts +106 -0
  43. package/src/data/transport.ts +121 -0
  44. package/src/data/types.ts +303 -0
  45. package/src/data.ts +69 -894
  46. package/src/db/bun-sqlite.d.ts +4 -1
  47. package/src/db/changes-mysql.ts +61 -21
  48. package/src/db/changes-postgres.ts +197 -118
  49. package/src/db/changes-sqlite.ts +131 -64
  50. package/src/db/core.ts +38 -13
  51. package/src/db/index.ts +20 -4
  52. package/src/db/mysql.ts +22 -6
  53. package/src/db/postgres.ts +49 -13
  54. package/src/db/sqlite.ts +53 -27
  55. package/src/derive.d.mts +4 -1
  56. package/src/derive.mjs +136 -39
  57. package/src/dev-runtime.mjs +8 -4
  58. package/src/exec/errors.ts +57 -0
  59. package/src/exec/landing.ts +66 -0
  60. package/src/exec/scope.ts +174 -0
  61. package/src/exec/touch.ts +80 -0
  62. package/src/exec/validation.ts +176 -0
  63. package/src/exec.ts +191 -341
  64. package/src/extension.ts +8 -2
  65. package/src/first-paint.ts +26 -9
  66. package/src/form/value.ts +15 -0
  67. package/src/form.tsx +132 -43
  68. package/src/hub/channels.ts +102 -0
  69. package/src/hub/connections.ts +147 -0
  70. package/src/hub/delivery.ts +62 -0
  71. package/src/hub/feed.ts +168 -0
  72. package/src/hub/presence.ts +64 -0
  73. package/src/hub/route.ts +158 -0
  74. package/src/hub/subscriptions.ts +212 -0
  75. package/src/hub/topic.ts +98 -0
  76. package/src/hub/tracked.ts +12 -0
  77. package/src/hub/types.ts +190 -0
  78. package/src/hub/view.ts +147 -0
  79. package/src/hub.ts +132 -758
  80. package/src/index.ts +84 -10
  81. package/src/inline.mjs +356 -0
  82. package/src/inspect/compare.mjs +162 -0
  83. package/src/inspect/routes.mjs +154 -0
  84. package/src/inspect/samples.mjs +59 -0
  85. package/src/inspect/url.mjs +2 -0
  86. package/src/inspect.mjs +146 -187
  87. package/src/link/election.ts +178 -0
  88. package/src/link/lines.ts +27 -0
  89. package/src/link.ts +90 -166
  90. package/src/list-drizzle.ts +104 -18
  91. package/src/list.ts +127 -36
  92. package/src/listen.ts +32 -6
  93. package/src/live/api.ts +18 -0
  94. package/src/live/backoff.ts +6 -0
  95. package/src/live/frames.ts +42 -0
  96. package/src/live/read.ts +77 -0
  97. package/src/live/sub-types.ts +24 -0
  98. package/src/live/transport.ts +86 -0
  99. package/src/live/types.ts +52 -0
  100. package/src/live-fake.ts +20 -5
  101. package/src/live-target.mjs +112 -37
  102. package/src/live.ts +116 -225
  103. package/src/media.ts +8 -1
  104. package/src/migrations.mjs +14 -6
  105. package/src/mux.ts +161 -64
  106. package/src/og/DefaultCard.tsx +40 -9
  107. package/src/og/fonts.mjs +16 -8
  108. package/src/og/images.mjs +43 -22
  109. package/src/og/index.mjs +119 -43
  110. package/src/og/native.mjs +11 -4
  111. package/src/range.mjs +15 -3
  112. package/src/router.ts +56 -6
  113. package/src/routes.mjs +73 -22
  114. package/src/run/admission.ts +80 -0
  115. package/src/run/boot.tsx +115 -0
  116. package/src/run/cache-presentation.ts +67 -0
  117. package/src/run/cache.ts +254 -0
  118. package/src/run/helpers.ts +197 -0
  119. package/src/run/listeners.ts +155 -0
  120. package/src/run/navigation.ts +382 -0
  121. package/src/run/page-skeleton.tsx +35 -0
  122. package/src/run/prefetch.ts +128 -0
  123. package/src/run/retain-view.ts +18 -0
  124. package/src/run/shape-preparation.ts +97 -0
  125. package/src/run/shape-styles.ts +72 -0
  126. package/src/run/source-context/pin.ts +124 -0
  127. package/src/run/source-context.ts +311 -0
  128. package/src/run.tsx +50 -178
  129. package/src/rust/common.ts +163 -0
  130. package/src/rust/native.ts +282 -0
  131. package/src/rust.ts +103 -229
  132. package/src/server/card.tsx +65 -0
  133. package/src/server/compression.ts +115 -0
  134. package/src/server/core.ts +70 -0
  135. package/src/server/dispatch.ts +70 -0
  136. package/src/server/form.ts +85 -0
  137. package/src/server/functions.ts +129 -0
  138. package/src/server/handler.ts +39 -0
  139. package/src/server/head.ts +96 -0
  140. package/src/server/media.ts +60 -0
  141. package/src/server/navigation-cache.ts +80 -0
  142. package/src/server/navigation.tsx +85 -0
  143. package/src/server/page-cold.ts +95 -0
  144. package/src/server/page-stream.tsx +181 -0
  145. package/src/server/page-types.ts +51 -0
  146. package/src/server/page-warm.ts +130 -0
  147. package/src/server/page.ts +69 -0
  148. package/src/server/payload.ts +89 -0
  149. package/src/server/prerender.ts +60 -0
  150. package/src/server/preview.tsx +67 -0
  151. package/src/server/render.ts +167 -0
  152. package/src/server/request.ts +98 -0
  153. package/src/server/resolver.ts +228 -0
  154. package/src/server/routes.ts +150 -0
  155. package/src/server/services.ts +69 -0
  156. package/src/server/text.ts +29 -0
  157. package/src/server/types.ts +160 -0
  158. package/src/server.tsx +82 -971
  159. package/src/shape.mjs +30 -6
  160. package/src/share/display.ts +50 -0
  161. package/src/share/network.ts +119 -0
  162. package/src/share/receive.ts +70 -0
  163. package/src/share/state.ts +30 -0
  164. package/src/share/tab-queue.ts +28 -0
  165. package/src/share-server.ts +128 -43
  166. package/src/share.ts +103 -209
  167. package/src/sheets.mjs +30 -18
  168. package/src/sidecar/build.mjs +170 -0
  169. package/src/sidecar/cargo.mjs +164 -0
  170. package/src/sidecar/common.mjs +17 -0
  171. package/src/sidecar/dev-build.mjs +91 -0
  172. package/src/sidecar/dev-watch.mjs +88 -0
  173. package/src/sidecar/manifest.mjs +19 -0
  174. package/src/sidecar/process.mjs +58 -0
  175. package/src/sidecar/refresh.mjs +19 -0
  176. package/src/sidecar.mjs +75 -416
  177. package/src/socket/check.ts +50 -0
  178. package/src/socket/deliver.ts +16 -0
  179. package/src/socket/publish.ts +56 -0
  180. package/src/socket/rooms.ts +41 -0
  181. package/src/socket/route.ts +79 -0
  182. package/src/socket/shared.ts +26 -0
  183. package/src/socket/types.ts +48 -0
  184. package/src/socket.ts +205 -194
  185. package/src/stream.ts +10 -1
  186. package/src/verify/app.ts +39 -0
  187. package/src/verify/cleanup.ts +27 -0
  188. package/src/verify/database.ts +11 -0
  189. package/src/verify/live.ts +63 -0
  190. package/src/verify/notify.ts +35 -0
  191. package/src/verify/skip.ts +19 -0
  192. package/src/verify/support.ts +149 -0
  193. package/src/verify/timing.ts +15 -0
  194. package/src/verify/trigger.ts +14 -0
  195. package/src/verify.ts +77 -176
  196. package/src/view.tsx +96 -11
  197. package/src/vite/asset-bytes.mjs +95 -0
  198. package/src/vite/assets.mjs +117 -0
  199. package/src/vite/config-hooks.mjs +166 -0
  200. package/src/vite/dev-hooks.mjs +103 -0
  201. package/src/vite/dev.mjs +102 -0
  202. package/src/vite/entries.mjs +145 -0
  203. package/src/vite/module-hooks.mjs +288 -0
  204. package/src/vite/route-modules.mjs +289 -0
  205. package/src/vite/server-functions.mjs +222 -0
  206. package/src/vite/shape-entry-css.mjs +187 -0
  207. package/src/vite/shape-override.mjs +226 -0
  208. package/src/vite/shared.mjs +51 -0
  209. package/src/vite/sheets.mjs +92 -0
  210. package/src/vite/state.mjs +89 -0
  211. package/src/vite/syntax.mjs +139 -0
  212. package/src/vite.mjs +152 -853
  213. 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
+ }
@@ -0,0 +1,2 @@
1
+ export const fillPath = (route, params) =>
2
+ route.pattern.replace(/\$([\w$]*)/g, (_, n) => String(params[n || route.splat]))
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, descriptionProblem } from './derive.mjs'
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
- /** 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)
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 = 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]))
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 { ast = parseSync(file, code, { lang: file.endsWith('x') ? 'tsx' : 'ts', sourceType: 'module' }) } catch { return { present: true, reads: true } }
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')) return { present: true, reads: true } // from elsewhere: can't tell
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 = 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)) }
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) return `export const meta: Meta<typeof load> = ({ data }) => ({ title: data.title, description: data.summary })`
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) 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) })
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, configFile: false, logLevel: 'silent', appType: 'custom',
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(m => m.default, () => undefined)
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({ 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}`))
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
- /** 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
- }
169
+ const take = makeTaker(found, run, seen, manifest, pages)
101
170
 
102
171
  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)
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 { ast = parseSync(file, code, { lang: file.endsWith('x') ? 'tsx' : 'ts', sourceType: 'module' }) } catch { return }
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 = d.type === 'VariableDeclaration' ? d.declarations.find(x => x.id?.name === 'meta') : d.id?.name === 'meta' ? d : undefined
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 = /\.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)
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
  }