@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
package/src/index.ts CHANGED
@@ -11,19 +11,89 @@
11
11
  import type { Merged, Meta as MetaObject, Site } from '@weft-js/core/ssr'
12
12
  import type { Context } from './data'
13
13
 
14
- export { Link } from './view'
14
+ export { Link, currentPath } from './view'
15
15
  export { Form, type FormApi, type FormProps } from './form'
16
16
  export { navigate, type NavigateOptions } from './router'
17
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,
18
+ query,
19
+ mutation,
20
+ redirect,
21
+ raise,
22
+ invalid,
23
+ invalidate,
24
+ cache,
25
+ skip,
26
+ isRedirect,
27
+ stale,
28
+ StartError,
29
+ FnError,
30
+ type Query,
31
+ type Mutation,
32
+ type QueryDef,
33
+ type MutationDef,
34
+ type AuthScope,
35
+ type Context,
36
+ type Errors,
37
+ type FnOptions,
38
+ type Life,
39
+ type LiveMode,
40
+ type LiveOptions,
41
+ type LiveQuery,
42
+ type LiveStatus,
43
+ type OptimisticCache,
44
+ type Redirect,
45
+ type Skip,
46
+ type StandardSchemaV1,
20
47
  } 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'
48
+ export {
49
+ stream,
50
+ type Stream,
51
+ type StreamDef,
52
+ type StreamOptions,
53
+ type StreamStatus,
54
+ } from './stream'
55
+ export {
56
+ channel,
57
+ sharedState,
58
+ type Channel,
59
+ type ChannelDef,
60
+ type ChannelStatus,
61
+ type Connection,
62
+ type PublishOptions,
63
+ type SharedBox,
64
+ type SharedState,
65
+ } from './channel'
66
+ export {
67
+ liveHealth,
68
+ realtime,
69
+ realtimeStats,
70
+ topic,
71
+ tracked,
72
+ type Limits,
73
+ type LiveState,
74
+ type RealtimeOptions,
75
+ type Topic,
76
+ type Tracked,
77
+ } from './hub'
78
+ export {
79
+ dbChanges,
80
+ type Change,
81
+ type ChangeOptions,
82
+ type Probe,
83
+ type Sink,
84
+ type Source,
85
+ } from './changes'
25
86
  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'
87
+ export {
88
+ listInput,
89
+ type DbAdapter,
90
+ type ListPage,
91
+ type ListParams,
92
+ type ListRequest,
93
+ type ListSource,
94
+ type ListSpec,
95
+ type SourceSpec,
96
+ } from './list'
27
97
  export type { Merged, Site } from '@weft-js/core/ssr'
28
98
 
29
99
  /** Filled by the generated `.weft/routes.d.ts` with every route file's path (`'/users/[id]': true`). */
@@ -71,7 +141,9 @@ type AnyLoad = (args: never) => unknown
71
141
  export type DataOf<L> = L extends AnyLoad ? Exclude<Awaited<ReturnType<L>>, NotFound> : undefined
72
142
 
73
143
  /** 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
144
+ export type Settled<D> = D extends object
145
+ ? { [K in keyof D as D[K] extends PromiseLike<unknown> ? never : K]: D[K] }
146
+ : D
75
147
 
76
148
  /** A route or layout component's props. `children` is the next level (layouts render it where the page goes). */
77
149
  export interface RouteProps<L = undefined, P extends string = string> {
@@ -103,4 +175,6 @@ export interface OgProps<L = undefined, P extends string = string> {
103
175
  }
104
176
 
105
177
  /** 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>[]>)
178
+ export type Prerender<P extends string = string> =
179
+ | ParamsOf<P>[]
180
+ | (() => ParamsOf<P>[] | Promise<ParamsOf<P>[]>)
package/src/inline.mjs CHANGED
@@ -1,2 +1,358 @@
1
+ // A page's stylesheets. All of them inline while the document fits the first flight; past that, the rules the server's
2
+ // HTML can reach, with every sheet behind them as an immutable link that doesn't hold the paint.
3
+
4
+ import { esc } from '@weft-js/core/markup'
5
+
6
+ /**
7
+ * The one order every app shares. Layers rank by first appearance, so this goes before any sheet: warp's own too, or the
8
+ * order would follow whichever component's sheet a page lists first.
9
+ */
10
+ export const LAYER_ORDER =
11
+ '@layer warp,app,theme,base,components,utilities;@layer warp.tokens,warp.base,warp.signature,warp.layout,warp.components,warp.inputs,warp.inputs-rich,warp.data,warp.app,warp.flows,warp.marketing,warp.canvas,warp.flow;'
12
+ /** Head plus shell, in compressed bytes: about what a first flight carries, less headers and TLS. */
13
+ export const FIRST_PAINT_BYTES = 12 * 1024
14
+ /** Compression at the server's brotli 5, measured on built warp pages (5.9 to 6.2 for CSS, 3.7 to 4 for HTML). */
15
+ const CSS_RATIO = 6
16
+ const HTML_RATIO = 4
17
+ const CRITICAL_MAX = 64
1
18
  /** The most stylesheet text one page carries inline with `criticalCss: false`, in characters. Past it a sheet stays a link. */
2
19
  export const INLINE_CSS_MAX = 48 * 1024
20
+
21
+ // What the shell has, however its markup is quoted.
22
+ const ATTR = /\b(data-[\w-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>"']+)))?/gi
23
+ const CLASS = /\bclass\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>"']+))/gi
24
+ const PROP = /--[A-Za-z_][\w-]*/g
25
+ // What a selector asks for. An attribute selector goes first and whole, or `[href$=".pdf"]` reads as a class.
26
+ const SELECTOR_WORD =
27
+ /\[\s*([\w-]+)\s*(?:([~|^$*]?=)\s*(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'|([^\s\]]+))\s*(\w)?\s*)?\]|\.((?:[\w-]|[^\0-\x7f]|\\.)+)/g
28
+ // A reference the shell's text can't be compared with, so nothing is ruled out on its value.
29
+ const ENTITY = /&(?!(?:amp|quot|lt|gt);)/
30
+ const ANY_VALUE = '=*'
31
+ // Set on <html> by a script or `setDial`, so the server's HTML can't vouch for them.
32
+ const ROOT_ATTR = /^data-(?:theme|expression|density|form|pointer|motion|flair|fields|warp|scope)$/
33
+ // `dialScript` restores these before the first paint, so the server's HTML doesn't show their values.
34
+ const DIAL = /^data-(?:theme|expression|density|form|pointer|motion|flair|fields)$/
35
+ // Rules that can't paint until someone acts, so the late sheet is soon enough for them. A state the server can render
36
+ // (`:checked`, `aria-checked`, `:disabled`) has to stay.
37
+ const LATER =
38
+ /:(?:hover|active|focus|focus-visible|focus-within|popover-open|modal)\b|::(?:backdrop|selection)/
39
+ const PRINT = /^@media\s+print\s*(?:and\b[^,]*)?$/i
40
+ const FORCED_COLORS = /^@media\s*\(\s*forced-colors\s*:\s*active\s*\)\s*(?:and\b[^,]*)?$/i
41
+ const GROUPING = /^@(?:media|supports|layer|container|scope|starting-style)\b/
42
+
43
+ const print = (nodes) =>
44
+ nodes.map((n) => (n.body === undefined ? `${n.head};` : `${n.head}{${n.body}}`)).join('')
45
+
46
+ /** Calls `visit` at each top-level comma and bracket of `text` from `from`, skipping strings, and stops at the end of the bracket it began in. */
47
+ function scan(text, from, visit) {
48
+ let depth = 0,
49
+ quote = ''
50
+ for (let i = from; i < text.length; i++) {
51
+ const c = text[i]
52
+ if (quote) {
53
+ if (c === '\\') i++
54
+ else if (c === quote) quote = ''
55
+ continue
56
+ }
57
+ if (c === '"' || c === "'") quote = c
58
+ else if (c === '(' || c === '[') depth++
59
+ else if (c === ')' || c === ']') {
60
+ if (--depth < 0) return i
61
+ } else if (c === ',' && !depth) visit(i)
62
+ }
63
+ return text.length
64
+ }
65
+
66
+ function selectors(head) {
67
+ const out = []
68
+ let from = 0
69
+ scan(head, 0, (i) => {
70
+ out.push(head.slice(from, i))
71
+ from = i + 1
72
+ })
73
+ out.push(head.slice(from))
74
+ return out
75
+ }
76
+
77
+ /** A selector with what proves nothing removed: `:not()`, and `:is()`, `:where()` or `:has()` holding alternatives. */
78
+ function required(selector) {
79
+ let rest = selector
80
+ for (;;) {
81
+ const m = /:(not|is|where|has)\(/.exec(rest)
82
+ if (!m) return rest
83
+ const open = m.index + m[0].length
84
+ let comma = false
85
+ const end = scan(rest, open, () => {
86
+ comma = true
87
+ })
88
+ rest =
89
+ rest.slice(0, m.index) +
90
+ (m[1] === 'not' || comma ? '' : ` ${rest.slice(open, end)} `) +
91
+ rest.slice(end + 1)
92
+ }
93
+ }
94
+
95
+ /**
96
+ * What a selector needs the shell to have: `data-x` and `.class`, and a value for a plain `=` on a plain word. An
97
+ * operator, a flag, a value with a space or an escape can match text the shell's words don't show, so only the name counts.
98
+ */
99
+ function* demands(selector) {
100
+ for (const m of selector.matchAll(SELECTOR_WORD)) {
101
+ if (m[7]) {
102
+ if (!/\\[\da-f]/i.test(m[7])) yield `.${m[7].replace(/\\(.)/g, '$1')}`
103
+ continue
104
+ }
105
+ const name = m[1].toLowerCase()
106
+ if (!name.startsWith('data-')) continue
107
+ yield name
108
+ const value = m[3] ?? m[4] ?? m[5]
109
+ if (m[2] === '=' && !m[6] && /^[\w-]+$/.test(value)) yield { value }
110
+ }
111
+ }
112
+
113
+ const ROOTED = /^\s*(?::root|html|body)\b/
114
+
115
+ /** Can the shell match this, going by the attributes, their values and the classes it has? Anything else stays. */
116
+ function present(rest, found) {
117
+ if (ROOTED.test(rest)) return true
118
+ let root = false
119
+ for (const d of demands(rest)) {
120
+ if (typeof d === 'string') {
121
+ root = ROOT_ATTR.test(d)
122
+ if (!root && !found.has(d)) return false
123
+ } else if (!root && !found.has(ANY_VALUE) && !found.has(d.value)) return false
124
+ }
125
+ return true
126
+ }
127
+
128
+ /** Could this paint before anyone acts, on the shell the server sent? */
129
+ function paints(selector, found) {
130
+ const rest = required(selector)
131
+ return !LATER.test(rest.replace(/\[[^\]]*\]/g, '')) && present(rest, found)
132
+ }
133
+
134
+ /** A sheet without what can't show in the first paint. */
135
+ function firstPaint(css, parse, found) {
136
+ const walk = (source) =>
137
+ print(
138
+ parse(source).flatMap((n) => {
139
+ if (n.body === undefined) return [n]
140
+ if (PRINT.test(n.head) || FORCED_COLORS.test(n.head)) return []
141
+ if (GROUPING.test(n.head)) {
142
+ const body = walk(n.body)
143
+ return body ? [{ head: n.head, body }] : []
144
+ }
145
+ if (n.head[0] === '@') return [n]
146
+ const kept = selectors(n.head).filter((s) => paints(s, found))
147
+ return kept.length ? [{ head: kept.join(','), body: n.body }] : []
148
+ }),
149
+ )
150
+ return walk(css)
151
+ }
152
+
153
+ /** `@font-face` rules no remaining rule names: warp's metric fallbacks for families this page doesn't set. */
154
+ function usedFaces(css, parse) {
155
+ const rest = print(parse(css).filter((n) => !n.head.startsWith('@font-face')))
156
+ return print(
157
+ parse(css).filter((n) => {
158
+ if (!n.head.startsWith('@font-face')) return true
159
+ const family = /font-family:\s*["']?([^"';}]+)/.exec(n.body)?.[1]
160
+ return !family || rest.includes(family)
161
+ }),
162
+ )
163
+ }
164
+
165
+ const critical = new Map()
166
+ const vocabularies = new Map()
167
+
168
+ /** Every attribute, value and class the sheets name: only these can change a page's rules, so only they key the cache. */
169
+ function vocabulary(sheets, id) {
170
+ let words = vocabularies.get(id)
171
+ if (!words) {
172
+ words = new Set()
173
+ words.dials = []
174
+ for (const s of sheets) {
175
+ let name
176
+ for (const d of demands(s.css)) {
177
+ words.add(d.value ?? d)
178
+ if (typeof d === 'string') name = d
179
+ else if (DIAL.test(name)) words.dials.push(d.value)
180
+ }
181
+ for (const m of s.css.matchAll(PROP)) words.add(m[0])
182
+ }
183
+ if (vocabularies.size >= CRITICAL_MAX) vocabularies.delete(vocabularies.keys().next().value)
184
+ vocabularies.set(id, words)
185
+ }
186
+ return words
187
+ }
188
+
189
+ /** Adds to `found` what these sources have of the sheets' words. */
190
+ function read(sources, known, found) {
191
+ const see = (w) => {
192
+ if (known.has(w)) found.add(w)
193
+ }
194
+ const once = (seen, value) => !seen.has(value) && !!seen.add(value) // a page of rows repeats a few values thousands of times
195
+ const names = new Set(),
196
+ values = new Set(),
197
+ classes = new Set()
198
+ for (const source of sources) {
199
+ for (const m of source.matchAll(ATTR)) {
200
+ if (once(names, m[1])) see(m[1].toLowerCase())
201
+ const value = m[2] ?? m[3] ?? m[4]
202
+ if (!value || !once(values, value)) continue
203
+ if (ENTITY.test(value)) found.add(ANY_VALUE)
204
+ for (const w of value.split(/\s+/)) see(w)
205
+ }
206
+ for (const m of source.matchAll(CLASS)) {
207
+ const value = m[1] ?? m[2] ?? m[3]
208
+ if (!once(classes, value)) continue
209
+ if (ENTITY.test(value)) found.add(ANY_VALUE)
210
+ for (const w of value.split(/\s+/)) see(`.${w}`)
211
+ }
212
+ for (const m of source.matchAll(PROP)) see(m[0])
213
+ }
214
+ return found
215
+ }
216
+
217
+ /** The rules `found` reaches in these sheets. Kept by the page's shape, not its text, so rows don't miss it. */
218
+ function criticalCss(sheets, found, { parse, purge }) {
219
+ const key = `${sheets.map((s) => s.href).join()}|${[...found].sort((a, b) => (a < b ? -1 : 1)).join()}`
220
+ let css = critical.get(key)
221
+ if (css === undefined) {
222
+ // Keeps a value the shell's words can't vouch for, as firstPaint does.
223
+ const has = (w) => found.has(w) || found.has(ANY_VALUE) || !/^-{0,2}[\w-]+$/.test(w)
224
+ css = usedFaces(
225
+ purge(
226
+ sheets.map((s) => firstPaint(s.css, parse, found)),
227
+ { has },
228
+ ).join(''),
229
+ parse,
230
+ )
231
+ if (critical.size >= CRITICAL_MAX) critical.delete(critical.keys().next().value)
232
+ critical.set(key, css)
233
+ }
234
+ return css
235
+ }
236
+
237
+ /** A stylesheet's short id. The browser runs this same function (`LATE_SHEETS`), so both sides agree. */
238
+ export function sheetId(href) {
239
+ let x = 2166136261
240
+ for (let i = 0; i < href.length; i++) x = Math.imul(x ^ href.charCodeAt(i), 16777619)
241
+ return (x >>> 0).toString(36)
242
+ }
243
+
244
+ /**
245
+ * Browser: lists the stylesheets it now has, by `sheetId`, in the `weft-css` cookie (the latest 32). A page whose sheets
246
+ * are all there is sent links, not rules to paint with first (`pageStyles`). Sent as its source, so it reads nothing else.
247
+ */
248
+ function keepSheets(id) {
249
+ const c = document.cookie.match(/(?:^|; )weft-css=([^;]*)/),
250
+ s = c ? c[1].split('.') : []
251
+ document.querySelectorAll('link[rel=stylesheet]').forEach(function (l) {
252
+ const x = l.sheet && id(l.getAttribute('href'))
253
+ if (x && s.indexOf(x) < 0) s.push(x)
254
+ })
255
+ document.cookie = 'weft-css=' + s.slice(-32).join('.') + ';path=/;max-age=31536000;samesite=lax'
256
+ }
257
+
258
+ /** The sheet ids the request's `weft-css` cookie lists. */
259
+ export function keptSheets(cookie) {
260
+ const m = cookie && /(?:^|;\s*)weft-css=([^;]*)/.exec(cookie)
261
+ return m ? new Set(m[1].split('.')) : undefined
262
+ }
263
+
264
+ /**
265
+ * Switches on the head's deferred sheets once they're all here, or after 3s without the one that failed. One at a time,
266
+ * each is a restyle of the whole page, and 20 of them cost a table of 50 rows more than all its script. `keep`: then
267
+ * lists them in `weft-css` (`keepSheets`), for a visitor who already has the site's cookies: one without gets none.
268
+ */
269
+ const lateSheets = (keep) =>
270
+ `(a=>Promise.race([Promise.all(a.map(l=>l.sheet||new Promise(r=>{l.addEventListener('load',r);l.addEventListener('error',r)}))),new Promise(r=>setTimeout(r,3e3))]).then(()=>{a.forEach(l=>l.media='all')${keep ? `;(${keepSheets.toString()})(${sheetId.toString()})` : ''}}))([...document.querySelectorAll('link[data-late]')])`
271
+ export const LATE_SHEETS = lateSheets(false)
272
+ export const LATE_SHEETS_KEPT = lateSheets(true)
273
+ /** The client entry's second chance: a `<style>` a CSP refused has no sheet, so the disabled link beside it is all that's left. */
274
+ export const REFUSED_SHEETS = `document.querySelectorAll('style+link[disabled]').forEach(l=>{l.previousElementSibling.sheet||(l.disabled=false)})`
275
+ // Put the `<style>` before this script in front of the head's first link.
276
+ const MOVE = `(s=>{var d=document,l=d.head.querySelector('link[rel=stylesheet]');l&&l.before(s)})(document.currentScript.previousElementSibling)`
277
+ const blocking = (href) => `<link rel="stylesheet" href="${esc(href)}">\n`
278
+
279
+ /**
280
+ * `assets.inline` is `{ href: css }`, and `assets.critical` weft's `{ parse, purge }` (none in dev). Returns the head's
281
+ * stylesheets, and `late(sheets, chunk)`: what a streamed chunk needs in front of it. A parser-inserted link holds all
282
+ * that follows until it loads, so a sheet the head has already asked for is never one: the chunk brings the rules its own
283
+ * HTML reaches, and they move in front of those sheets, which have to keep winning once they load. A sheet the head
284
+ * lacks can only be a link, and its Boundary swaps once it's here. `seen`: an earlier render's `{ split, found }`, for a head
285
+ * sent before this page's HTML exists; its `late` then brings what the HTML has beyond that. `have`: the sheet ids the
286
+ * browser says it has (`keptSheets`). With all of the page's, it gets links (`links`): nothing to inline, or to look for.
287
+ * `keep`: the request has cookies, so the page may list the sheets it loads in one more.
288
+ */
289
+ export function pageStyles(assets, css = [], html = '', extra = {}) {
290
+ const { head, seen, have, keep = false } = extra
291
+ const first = `<style>${LAYER_ORDER}</style>\n`
292
+ if (have && css.length && css.every((c) => have.has(sheetId(c.href)))) {
293
+ const carried = new Set(css.map((c) => c.href))
294
+ return {
295
+ head: first + css.map((c) => blocking(c.href)).join(''),
296
+ split: false,
297
+ links: true,
298
+ found: () => new Set(),
299
+ late(sheets = []) {
300
+ const hrefs = sheets.map((s) => s.href).filter((h) => !carried.has(h))
301
+ for (const h of hrefs) carried.add(h)
302
+ return hrefs.map(blocking).join('')
303
+ },
304
+ }
305
+ }
306
+ const inline = css
307
+ .filter((c) => assets.inline?.[c.href] !== undefined)
308
+ .map((c) => ({ href: c.href, css: assets.inline[c.href] }))
309
+ const raw = inline.reduce((n, s) => n + s.css.length, 0)
310
+ const split = seen
311
+ ? seen.split
312
+ : !!assets.critical &&
313
+ inline.length > 0 &&
314
+ raw / CSS_RATIO + (html.length + (head?.length ?? 0)) / HTML_RATIO > FIRST_PAINT_BYTES
315
+ const carried = new Set(css.map((c) => c.href))
316
+ const known = split && vocabulary(inline, inline.map((s) => s.href).join())
317
+ let sent =
318
+ split &&
319
+ (seen
320
+ ? new Set(seen.found)
321
+ : read([html, head ?? ''], known, new Set(head?.includes('warp:dials') ? known.dials : [])))
322
+ const tags = split
323
+ ? `<style>${criticalCss(inline, sent, assets.critical)}</style>\n` +
324
+ css
325
+ .map((c) =>
326
+ assets.inline?.[c.href] === undefined
327
+ ? blocking(c.href)
328
+ : `<link rel="stylesheet" href="${esc(c.href)}" media="print" data-late>`,
329
+ )
330
+ .join('') +
331
+ `<noscript>${inline.map((s) => blocking(s.href).trim()).join('')}</noscript>\n<script>${keep ? LATE_SHEETS_KEPT : LATE_SHEETS}</script>\n`
332
+ : css
333
+ .map((c) =>
334
+ assets.inline?.[c.href] === undefined
335
+ ? blocking(c.href)
336
+ : `<style>${assets.inline[c.href]}</style>\n<link rel="stylesheet" href="${esc(c.href)}" disabled>\n`,
337
+ )
338
+ .join('')
339
+ return {
340
+ head: first + tags,
341
+ split,
342
+ links: false,
343
+ /** The words the page's rules have reached so far. */
344
+ found: () => sent || new Set(),
345
+ late(sheets = [], chunk = '') {
346
+ const hrefs = sheets.map((s) => s.href).filter((h) => !carried.has(h))
347
+ for (const h of hrefs) carried.add(h)
348
+ const links = hrefs.map(blocking).join('')
349
+ if (!split) return links
350
+ const found = read([chunk], known, new Set(sent))
351
+ if (found.size === sent.size) return links
352
+ // All of them, repeats too: warp's rules weigh nothing, so order decides, and new ones alone would land after the head's.
353
+ const rules = criticalCss(inline, found, assets.critical)
354
+ sent = found
355
+ return links + (rules && `<style>${rules}</style><script>${MOVE}</script>\n`)
356
+ },
357
+ }
358
+ }
@@ -0,0 +1,162 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { descriptionProblem } from '../derive.mjs'
4
+
5
+ const quote = (s) => JSON.stringify(s)
6
+ const short = (s, n = 70) => (s.length > n ? s.slice(0, n - 1) + '…' : s)
7
+
8
+ function compareLengths(own, words, at, push) {
9
+ const longTitle = own.find((p) => p.title.length > 60)
10
+ if (longTitle)
11
+ push(
12
+ 'H004',
13
+ 'warning',
14
+ at,
15
+ `The title is ${longTitle.title.length} characters (over 60): \`${longTitle.title}\`${own.length > 1 ? ` on ${longTitle.url}` : ''}.`,
16
+ )
17
+ const longText = own.find((p) => p.description && p.description.length > 160)
18
+ if (longText)
19
+ push(
20
+ 'H004',
21
+ 'warning',
22
+ at,
23
+ `The description is ${longText.description.length} characters (over 160)${own.length > 1 ? ` on ${longText.url}` : ''}.`,
24
+ )
25
+ const bad = own.filter(
26
+ (p) => p.description && words(p.source.description) && descriptionProblem(p.description),
27
+ )
28
+ if (bad[0])
29
+ push(
30
+ 'H015',
31
+ 'warning',
32
+ at,
33
+ `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))}.`,
34
+ {
35
+ help: 'Write one plain sentence on what the page gives the reader. Never slice raw markdown or code into `description`.',
36
+ },
37
+ )
38
+ }
39
+
40
+ function compareRoute(route, entry, c) {
41
+ const { root, byRoute, site, push, metaExport, metaSnippet } = c
42
+ if (route.kind !== 'page') return
43
+ const own = byRoute.get(route) ?? []
44
+ const at = { file: route.file, path: entry.path }
45
+ const first = own[0]
46
+ const { present, reads } = metaExport(readFileSync(join(root, route.file), 'utf8'), route.file)
47
+ const words = (s) => s === 'route' || s === 'layout'
48
+ if (first && !(first.source.title === 'route' && first.source.description === 'route')) {
49
+ const missing = [
50
+ first.source.title !== 'route' && 'title',
51
+ first.source.description !== 'route' && 'description',
52
+ ]
53
+ .filter(Boolean)
54
+ .join(' and ')
55
+ const whence = (what, s, text) =>
56
+ `${what} ${s === 'auto' ? `comes from the page (${quote(short(text))})` : s === 'layout' ? 'comes from a layout' : s === 'config' ? 'is the site default' : 'is empty'}`
57
+ push(
58
+ 'H011',
59
+ 'warning',
60
+ at,
61
+ `${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.`,
62
+ {
63
+ help: metaSnippet(
64
+ route,
65
+ first.source.title === 'auto' ? stripTemplate(first.title, site.title) : '',
66
+ first.description,
67
+ ),
68
+ },
69
+ )
70
+ } else if (!first && !present)
71
+ push(
72
+ 'H011',
73
+ 'warning',
74
+ at,
75
+ 'This page sets no `meta`, so its title and description come from what it renders. Words written for the page are better.',
76
+ { help: metaSnippet(route) },
77
+ )
78
+
79
+ if (route.params.length && present && !reads)
80
+ push(
81
+ 'H014',
82
+ 'warning',
83
+ at,
84
+ 'This route has params, but its `meta` reads neither `data` nor `params`: every page of it has the same tags.',
85
+ { help: metaSnippet(route) },
86
+ )
87
+ else if (route.params.length && own.length > 1) {
88
+ const same = (key) => new Set(own.map((p) => p[key])).size === 1 && own[0][key]
89
+ for (const key of ['title', 'description'])
90
+ if (same(key))
91
+ push(
92
+ 'H014',
93
+ 'warning',
94
+ at,
95
+ `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.`,
96
+ { help: metaSnippet(route) },
97
+ )
98
+ }
99
+
100
+ compareLengths(own, words, at, push)
101
+ }
102
+
103
+ /** The checks that take every page at once: is each one's text its own, and is it text at all? */
104
+ export function compare(c) {
105
+ const { root, entries, pages, site, known, metaExport, metaSnippet } = c
106
+ const out = []
107
+ const push = (code, severity, route, why, extra) =>
108
+ out.push({ code, severity, file: route.file, route: route.path, why, ...extra })
109
+ const byRoute = new Map()
110
+ for (const p of pages) byRoute.set(p.route, [...(byRoute.get(p.route) ?? []), p])
111
+
112
+ if (!known)
113
+ out.push({
114
+ code: 'H001',
115
+ severity: 'warning',
116
+ file: 'weft.config.ts',
117
+ route: '*',
118
+ 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.",
119
+ help: 'Set `site.url` in weft.config.ts, or `WEFT_SITE_URL` in the deploy environment.',
120
+ })
121
+
122
+ for (const { route, entry } of entries)
123
+ compareRoute(route, entry, { root, byRoute, site, push, metaExport, metaSnippet })
124
+
125
+ const clash = (key, code) => {
126
+ const groups = new Map()
127
+ for (const p of pages) if (p[key]) groups.set(p[key], [...(groups.get(p[key]) ?? []), p])
128
+ for (const [text, list] of groups) {
129
+ const routes = [...new Set(list.map((p) => p.route))]
130
+ if (routes.length < 2) continue // one param route's pages are H014's
131
+ const what =
132
+ key === 'title'
133
+ ? 'title'
134
+ : text === site.description
135
+ ? 'description (the site default)'
136
+ : 'description'
137
+ const a = routes[0]
138
+ push(
139
+ code,
140
+ 'warning',
141
+ { file: a.file, path: list[0].url },
142
+ `${routes.length} routes share this ${what}: ${list
143
+ .slice(0, 4)
144
+ .map((p) => p.url)
145
+ .join(
146
+ ', ',
147
+ )}${list.length > 4 ? `, and ${list.length - 4} more` : ''} (${quote(short(text))}).`,
148
+ { help: `Give each page its own ${key} in \`meta\`, from what is on it.` },
149
+ )
150
+ }
151
+ }
152
+ clash('title', 'H012')
153
+ clash('description', 'H013')
154
+ return out
155
+ }
156
+
157
+ const stripTemplate = (title, template) => {
158
+ const [a = '', b = ''] = template?.split('%s') ?? []
159
+ return title.startsWith(a) && title.endsWith(b)
160
+ ? title.slice(a.length, title.length - b.length)
161
+ : title
162
+ }