@weft-js/cli 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +16 -0
- package/README.md +74 -2
- package/bin/weft.mjs +8 -0
- package/docs/async.md +40 -0
- package/docs/browser.md +39 -0
- package/docs/config.md +93 -0
- package/docs/design.md +97 -0
- package/docs/fonts.md +70 -0
- package/docs/icons.md +102 -0
- package/docs/lists.md +23 -0
- package/docs/meta.md +82 -0
- package/docs/plugins.md +143 -0
- package/docs/props.md +77 -0
- package/docs/rules.md +32 -0
- package/docs/rust.md +223 -0
- package/docs/start-short.md +8 -0
- package/docs/start.md +422 -0
- package/docs/state.md +96 -0
- package/docs/style.md +59 -0
- package/docs/surface.md +111 -0
- package/docs/testing.md +71 -0
- package/docs/ui-state.md +92 -0
- package/docs/verify.md +48 -0
- package/package.json +39 -4
- package/src/add-rust.mjs +123 -0
- package/src/add.mjs +358 -0
- package/src/agents-md.mjs +134 -0
- package/src/alias.mjs +59 -0
- package/src/audit.mjs +333 -0
- package/src/browser/cli.mjs +34 -0
- package/src/browser/client.mjs +132 -0
- package/src/browser/commands.mjs +143 -0
- package/src/catalog.mjs +479 -0
- package/src/check.mjs +328 -0
- package/src/cli.mjs +332 -0
- package/src/config.d.ts +1 -0
- package/src/config.mjs +5 -0
- package/src/contributions.mjs +69 -0
- package/src/db-layer.mjs +33 -0
- package/src/design-checks.mjs +439 -0
- package/src/diag.mjs +74 -0
- package/src/docs.mjs +131 -0
- package/src/doctor.mjs +30 -0
- package/src/files.mjs +47 -0
- package/src/fix.mjs +40 -0
- package/src/gate.mjs +276 -0
- package/src/index.mjs +9 -0
- package/src/infer/engine.cjs +525 -0
- package/src/infer/facts.cjs +252 -0
- package/src/infer/hosts.cjs +114 -0
- package/src/infer/lsp.mjs +131 -0
- package/src/infer/session.cjs +105 -0
- package/src/infer/shadow.cjs +46 -0
- package/src/infer/worker.cjs +97 -0
- package/src/jsx-scan.mjs +86 -0
- package/src/load-config.mjs +274 -0
- package/src/map.mjs +110 -0
- package/src/mcp.mjs +195 -0
- package/src/meta-table.mjs +28 -0
- package/src/mutate.mjs +140 -0
- package/src/plugin-checks.mjs +89 -0
- package/src/plugins.mjs +172 -0
- package/src/project.mjs +219 -0
- package/src/props-checks.mjs +46 -0
- package/src/props-listener.mjs +38 -0
- package/src/react-checks.mjs +36 -0
- package/src/share-checks.mjs +74 -0
- package/src/site.mjs +19 -0
- package/src/slots.mjs +96 -0
- package/src/start.mjs +292 -0
- package/src/state-checks.mjs +213 -0
- package/src/style.mjs +320 -0
- package/src/surface-checks.mjs +218 -0
- package/src/tailwind.mjs +22 -0
- package/src/tasks.mjs +99 -0
- package/src/testdiff.mjs +157 -0
- package/src/tests.mjs +572 -0
- package/src/text-checks.mjs +172 -0
- package/src/tokens.mjs +24 -0
- package/src/trust.mjs +282 -0
- package/src/weft.mjs +66 -0
- package/src/workspace.mjs +105 -0
- package/ts-plugin/index.js +137 -0
- package/ts-plugin/package.json +1 -0
package/docs/start.md
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
# Full stack: `@weft-js/start`
|
|
2
|
+
|
|
3
|
+
Needs one of Node, Bun or Deno, and no Rust (`weft docs rust` is opt-in). An app builds for and runs on all three.
|
|
4
|
+
|
|
5
|
+
## Files
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
src/routes/** file routes: _layout.tsx, index.tsx, [id].tsx, [...rest].tsx, 404.tsx, _error.tsx
|
|
9
|
+
src/server/** server modules: queries and mutations (the client gets stubs); a Rust body: `weft docs rust`
|
|
10
|
+
src/db/schema.ts Drizzle tables; safe to import anywhere
|
|
11
|
+
src/db/index.server.ts the database client
|
|
12
|
+
src/auth.server.ts the auth provider (Better Auth by default), mounted at /api/auth/*
|
|
13
|
+
src/context.server.ts what `ctx` holds
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Any auth provider works: `defineAuth` from `@weft-js/start/auth` takes a `handler(request)` for `/api/auth/*` and a
|
|
17
|
+
`session(request)` that returns `{ user, session }`. `ctx.user` and `auth: 'user'` come from those two.
|
|
18
|
+
|
|
19
|
+
## Routes
|
|
20
|
+
|
|
21
|
+
<!-- example: skip needs the app's own getList query -->
|
|
22
|
+
```tsx
|
|
23
|
+
// src/routes/lists/[id].tsx
|
|
24
|
+
import type { LoadArgs, Meta, RouteProps } from '@weft-js/start'
|
|
25
|
+
import { getList } from '#/server/lists'
|
|
26
|
+
|
|
27
|
+
export const load = async ({ params }: LoadArgs<'/lists/[id]'>) => ({ list: await getList.fetch({ id: params.id }) })
|
|
28
|
+
export const meta: Meta<typeof load> = ({ data }) => ({ title: data.list.name })
|
|
29
|
+
export default function List(props: RouteProps<typeof load, '/lists/[id]'>) { return <h1>{props.data.list.name}</h1> }
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`props.data` and `const { data } = props` stay live; compute from them in JSX or `derived`, not in the component body (W001).
|
|
33
|
+
|
|
34
|
+
Import your own code as `#/server/todos`, which is `src/server/todos`. That alias is `paths` in `tsconfig.json`: change it or add your own there, and Vite and `weft check` follow. Generated files have their own prefix: `weft:slot/<id>` and `weft:tokens/<name>`.
|
|
35
|
+
|
|
36
|
+
`load` and `meta` never ship to the client. A layout's `load` runs before its children's, so it's where a guard goes.
|
|
37
|
+
A layout's `params` are its page's, a `[...rest]` included, so a layout can mark the page it shows.
|
|
38
|
+
Set `meta` on every page as part of building it: a title and one plain sentence from what is on the page. On a param
|
|
39
|
+
route build it from `data` and `params`. `weft docs meta` has the rest.
|
|
40
|
+
Links are `<Link href>`; code navigates with `navigate(href)`.
|
|
41
|
+
|
|
42
|
+
## Server functions
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
// src/server/todos.ts
|
|
46
|
+
import { mutation, query } from '@weft-js/start'
|
|
47
|
+
import { schemasOf } from '@weft-js/start/db'
|
|
48
|
+
import { eq } from 'drizzle-orm'
|
|
49
|
+
import { z } from 'zod'
|
|
50
|
+
import * as schema from '#/db/schema'
|
|
51
|
+
|
|
52
|
+
const { todos } = schema
|
|
53
|
+
const schemas = schemasOf(schema, { todos: { title: (s: z.ZodString) => s.trim().min(1) } })
|
|
54
|
+
|
|
55
|
+
export const listTodos = query({
|
|
56
|
+
tags: ['todos'], // what invalidates it
|
|
57
|
+
auth: 'user', // anyone else gets A002; ctx.user is non-null here
|
|
58
|
+
run: ({ ctx }) => ctx.db.select().from(todos).where(eq(todos.userId, ctx.user.id)),
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
export const addTodo = mutation({
|
|
62
|
+
input: schemas.todos.insert.pick({ title: true }),
|
|
63
|
+
auth: 'user',
|
|
64
|
+
invalidates: ['todos'], // queries with this tag come back in the same response
|
|
65
|
+
optimistic: ({ input }, cache) => cache.update(listTodos, undefined, l => void l.push({ id: `tmp:${input.title}`, title: input.title, done: false, userId: '', createdAt: 0 })),
|
|
66
|
+
run: async ({ input, ctx }) => {
|
|
67
|
+
const [row] = await ctx.db.insert(todos).values({ id: crypto.randomUUID(), userId: ctx.user.id, createdAt: Date.now(), ...input }).returning()
|
|
68
|
+
return row
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
- `q()` in a component or `defineState` setup: a `Resource`. `(q().value ?? []).map(…)`. `q(() => (id.value ? { id: id.value } : skip))` for a tracked input. Created inside a `<Boundary pending={…}>`, the server streams it; created outside one, the first byte waits for it.
|
|
74
|
+
- `await q.fetch(input)` in `load`, handlers and other server functions (Q001 otherwise).
|
|
75
|
+
- `m(input)` in handlers. `m.pending.value`, `m.error.value`. It rejects with an `FnError`: `.message`, `.code`, `.status`, `.issues`.
|
|
76
|
+
- Refuse with `throw new FnError({ message: 'Not yours' }, 403)`, a user-facing message and a status.
|
|
77
|
+
- `redirect('/x')` in `load` (307) or `run` (the client follows).
|
|
78
|
+
- `tags: ({ id }) => ['todos', `todo:${id}`]` and `invalidates: ({ id }) => […]` take the input.
|
|
79
|
+
- `cache: { life: 'minutes' }` shares a value across requests, so it can't read `ctx.user` (Q006); add `scope: 'user'` for one value per user.
|
|
80
|
+
- `output: schema` is checked in dev (C002) and goes in `.weft/contract.json`.
|
|
81
|
+
|
|
82
|
+
A table a function touches must be in its `tags`/`invalidates` (Q011): a tag named after the table, or `table:…`.
|
|
83
|
+
|
|
84
|
+
**`auth: 'user'` says who is calling, not what they may touch.** A `run` taking a row's id must check that row belongs
|
|
85
|
+
to `ctx.user`. `and(eq(todos.id, input.id), eq(todos.userId, ctx.user.id))` in the `where` is the usual way.
|
|
86
|
+
|
|
87
|
+
## Database
|
|
88
|
+
|
|
89
|
+
Drizzle, async on every driver: `await ctx.db.select()…`, `const [row] = await ….returning()`,
|
|
90
|
+
`await ctx.db.transaction(async tx => …)`. Change `src/db/schema.ts`, run `weft db generate` (Q015 until
|
|
91
|
+
you do), reload: migrations apply before the next statement a request runs (Q014 names a missing table). Never edit
|
|
92
|
+
`drizzle/` by hand. A page with no queries needs no database: with it down it still serves, and a request that does use
|
|
93
|
+
it answers 503 with Q016, then works once the database is back, no restart.
|
|
94
|
+
|
|
95
|
+
Access beyond ownership goes in the `where`, so a stranger's id matches no row: `and(eq(todos.id, input.id),
|
|
96
|
+
or(eq(todos.userId, ctx.user.id), exists(ctx.db.select({ one: sql\`1\` }).from(shares).where(…))))`. A mutation touching
|
|
97
|
+
two queries' rows calls `cache.update` once for each in `optimistic`, and names both tags in `invalidates`. Tags are
|
|
98
|
+
tables' names: Q011 checks them at run time, in `weft dev`, not in `weft check`.
|
|
99
|
+
|
|
100
|
+
## Auth
|
|
101
|
+
|
|
102
|
+
`ctx.user` and `ctx.session` come from `session(auth, request)` (`@weft-js/start/auth`) in `src/context.server.ts`. Sign-in is a mutation:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
// src/server/session.ts
|
|
106
|
+
import { mutation, redirect } from '@weft-js/start'
|
|
107
|
+
import { forward } from '@weft-js/start/auth'
|
|
108
|
+
import { z } from 'zod'
|
|
109
|
+
import auth from '#/auth.server'
|
|
110
|
+
|
|
111
|
+
export const signIn = mutation({
|
|
112
|
+
input: z.object({ email: z.email(), password: z.string().min(8) }),
|
|
113
|
+
run: async ({ input, ctx }) => {
|
|
114
|
+
await forward(ctx, auth.api.signInEmail({ body: input, headers: ctx.request.headers, asResponse: true }))
|
|
115
|
+
redirect('/todos') // with a new session, a full load: nothing cached survives (A004 without one)
|
|
116
|
+
},
|
|
117
|
+
})
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
A guard: `export const load = ({ ctx }: LoadArgs) => ctx.user ?? redirect('/login')` in the layout above the pages.
|
|
121
|
+
|
|
122
|
+
`BETTER_AUTH_URL` in `.env` must match where the app runs: on another port (`weft dev --port`), sign-in answers
|
|
123
|
+
`INVALID_ORIGIN`.
|
|
124
|
+
|
|
125
|
+
## Runtimes
|
|
126
|
+
|
|
127
|
+
The CLI runs on whichever of the three starts it, with none of the others installed: `bun --bun`, or `deno run -A
|
|
128
|
+
npm:@weft-js/cli`. `npm create @weft-js`, `bun create @weft-js` and `deno run -A npm:@weft-js/create` each scaffold for their own runtime
|
|
129
|
+
and package manager. HMR and sockets in `weft dev` work on all three.
|
|
130
|
+
|
|
131
|
+
A save in `weft dev` keeps the page: a component or route swaps in place with its `state`, typed input, focus and scroll;
|
|
132
|
+
CSS updates; a change to `load`, `meta` or server code refetches the page's data in place. `state` keeps its value while
|
|
133
|
+
its name and initializer stay the same. Adding or removing a route, or a module that exports more than components with
|
|
134
|
+
nothing above it to take the update, is a full reload, and the terminal says why. A tab that hit a syntax error comes back
|
|
135
|
+
when it's fixed.
|
|
136
|
+
|
|
137
|
+
`weft build` targets `runtime` in weft.config.ts (`node`, `bun`, `deno`; `--runtime` overrides), which is the runtime
|
|
138
|
+
that ran it if unset, and writes `.output/` and `.weft/contract.json`. Runtime-specific APIs (`Bun.*`, `Deno.*`, `better-sqlite3`) in server
|
|
139
|
+
code break the other targets; `node:` modules and web APIs work on all three.
|
|
140
|
+
|
|
141
|
+
## Realtime
|
|
142
|
+
|
|
143
|
+
Never poll, and never open a socket yourself (R007, T013): a live query, a `stream` and a `channel` reconnect, resume and
|
|
144
|
+
clean up for you.
|
|
145
|
+
|
|
146
|
+
**Live queries need no server code.** Tags are the contract: any mutation that invalidates one of the query's tags,
|
|
147
|
+
on any tab, refetches it for every reader. Unchanged rows keep their identity, so the page writes what changed.
|
|
148
|
+
|
|
149
|
+
```tsx
|
|
150
|
+
const todos = listTodos.live() // the same Resource as listTodos(), plus todos.live.value
|
|
151
|
+
<p>{todos.live.value}</p> // 'connecting' | 'live' | 'degraded' | 'reconnecting' | 'offline'
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
A live query needs `tags` (W053), and its mutations need one of them in `invalidates` (R003 in dev).
|
|
155
|
+
|
|
156
|
+
**Topics and streams carry messages.** Define them in server modules, next to queries:
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
// src/server/prices.ts
|
|
160
|
+
import { stream, topic } from '@weft-js/start'
|
|
161
|
+
import { z } from 'zod'
|
|
162
|
+
|
|
163
|
+
const Price = z.object({ symbol: z.string(), price: z.number() })
|
|
164
|
+
|
|
165
|
+
export const priceTopic = topic('prices', { key: z.string().max(12), message: Price })
|
|
166
|
+
export const prices = stream({
|
|
167
|
+
input: z.object({ symbols: z.array(z.string().max(12)).max(50) }), // bound it (R005)
|
|
168
|
+
message: Price,
|
|
169
|
+
auth: 'user', // optional; re-checked every 5 minutes
|
|
170
|
+
async *run({ input, signal, lastEventId }) {
|
|
171
|
+
yield* priceTopic.subscribe(input.symbols, { signal, after: lastEventId }) // subscribe first (R006)
|
|
172
|
+
},
|
|
173
|
+
})
|
|
174
|
+
// anywhere on the server: await priceTopic.publish('AAPL', { symbol: 'AAPL', price: 1 })
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
```tsx
|
|
178
|
+
const tick = prices({ symbols: ['AAPL'] }) // tick.value: the latest message
|
|
179
|
+
const board = prices(() => ({ symbols: syms.value }), { // input is tracked; `skip` works
|
|
180
|
+
fold: (acc, m) => ({ ...acc, [m.symbol]: m }), initial: {}, keep: '30s',
|
|
181
|
+
})
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Never fold a stream with `watch` into `state` (W052): that list dies with the component. `fold` lives in the cache, so
|
|
185
|
+
a remount within `keep` finds it. Without `fold` you get the latest value, and a slow client skips messages.
|
|
186
|
+
`yield tracked(row.id, row)` from your own source gives a resumed `run` that id as `lastEventId`. `lastEventId` is
|
|
187
|
+
undefined after a reset: start again, or read from the database.
|
|
188
|
+
|
|
189
|
+
**One instance needs nothing.** Several need a bus: `weft build` refuses the memory bus on a multi-instance preset (R002).
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
// src/realtime.server.ts
|
|
193
|
+
import { realtime } from '@weft-js/start'
|
|
194
|
+
import { postgresBus } from '@weft-js/start/bus/postgres'
|
|
195
|
+
|
|
196
|
+
export default realtime({ bus: postgresBus({ url: process.env.DATABASE_URL! }), origins: ['https://other.example'], maxDuration: 300 })
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
`postgresBus` needs a direct connection: pgBouncer in transaction mode breaks LISTEN. `maxDuration` is the platform's
|
|
200
|
+
response limit in seconds: streams end 10 s earlier and resume. `stale` is true when the server runs a newer build
|
|
201
|
+
than this tab: offer a reload.
|
|
202
|
+
|
|
203
|
+
`weft add @weft-js/realtime-redis` or `@weft-js/realtime-nats` puts a bus in `plugins`, on `REDIS_URL` or `NATS_URL`; a `bus` set here
|
|
204
|
+
still wins. `durableObjectBus({ namespace })` (`@weft-js/realtime-durable-object`, the `cloudflare_durable` preset only) needs a
|
|
205
|
+
binding, so it's set here.
|
|
206
|
+
|
|
207
|
+
Payments: `weft add pay`, then `ctx.entitlements` and `requires: ['pro']` on any function. `weft docs pay`. A query's `tags`
|
|
208
|
+
can take `ctx`, for one user's tag: `tags: (_, ctx) => [`orders:${ctx.user.id}`]`.
|
|
209
|
+
|
|
210
|
+
**Testing** uses the real hub and client over `@weft-js/core/test`'s fake transport: `await net.settle()`, then
|
|
211
|
+
`await net.push(prices, message)`, `await net.poke('todos')`, `await net.drop()`, `net.offline()`, `net.reconnects()`,
|
|
212
|
+
`net.subscriptions()`.
|
|
213
|
+
|
|
214
|
+
## Writes from outside the app
|
|
215
|
+
|
|
216
|
+
`.live()` already follows mutations. A cron job, `psql`, a worker or another service writes nowhere near them: give the database's writes to `realtime` and they reach the same queries, the same way.
|
|
217
|
+
|
|
218
|
+
<!-- example: skip needs a notes table in the app's schema -->
|
|
219
|
+
```ts
|
|
220
|
+
// src/realtime.server.ts
|
|
221
|
+
import { realtime } from '@weft-js/start'
|
|
222
|
+
import { dbChanges } from '@weft-js/start/changes'
|
|
223
|
+
import { db } from '#/db/index.server'
|
|
224
|
+
import { notes } from '#/db/schema'
|
|
225
|
+
|
|
226
|
+
export default realtime({ changes: dbChanges(db, [notes]) })
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
<!-- example: skip needs a notes table in the app's schema -->
|
|
230
|
+
```ts
|
|
231
|
+
// src/server/notes.ts
|
|
232
|
+
import { query } from '@weft-js/start'
|
|
233
|
+
import { eq } from 'drizzle-orm'
|
|
234
|
+
import { notes } from '#/db/schema'
|
|
235
|
+
|
|
236
|
+
export const myNotes = query({
|
|
237
|
+
tags: ['notes'], // table name, or `notes:${id}` for one row
|
|
238
|
+
auth: 'user',
|
|
239
|
+
live: { required: true, verify: 'staging' }, // both optional
|
|
240
|
+
run: ({ ctx }) => ctx.db.select().from(notes).where(eq(notes.owner, ctx.user.id)),
|
|
241
|
+
})
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Then `weft db live` writes the migration (triggers for SQLite, MySQL and Postgres), and you apply it. Tags are `notes` and `notes:<pk>`; a compound key joins with `:`. `dbChanges(db, tables, { tagged })` maps a change to your own tags, and for MongoDB it's `mongoChanges(db, collections)` from `@weft-js/db/mongo`.
|
|
245
|
+
|
|
246
|
+
A notification carries a table and primary keys, never a column. Each client refetches through its own `query`, so `auth` and every filter run for that user: someone who can't see a row gets the same answer as before. An app's own write is not poked twice, and a bulk write is one refetch: over 32 ids in a statement it's a table-level change, which reaches `notes` and every `notes:…`. Limits: at most one flush per 250 ms; a dropped feed pokes every table when it's back.
|
|
247
|
+
|
|
248
|
+
**What you get depends on where it runs**, so ask: `weft doctor` (`--preset vercel`) says what works for a target, and `weft check` warns about it. At startup the server tries the real thing, settles on a mode, and logs it once. The same answer is on the page:
|
|
249
|
+
|
|
250
|
+
```tsx
|
|
251
|
+
const notes = myNotes.live()
|
|
252
|
+
notes.live.value // … | 'degraded': connected, and outside writes arrive late or not at all
|
|
253
|
+
notes.liveMode.value // 'feed' | 'external' | 'poll' | 'app' | 'off'
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
| Mode | Means |
|
|
257
|
+
|---|---|
|
|
258
|
+
| `feed` | This server hears every change. |
|
|
259
|
+
| `external` | A separate listener hears them, and publishes to the bus. |
|
|
260
|
+
| `poll` | A fallback: late, and by table only. `degraded`. |
|
|
261
|
+
| `app` | No `changes`: mutations only. Not degraded; it's what you asked for. |
|
|
262
|
+
| `off` | There's nothing to read: mutations only. `degraded`. |
|
|
263
|
+
|
|
264
|
+
| Database | How | Limits |
|
|
265
|
+
|---|---|---|
|
|
266
|
+
| Postgres | Triggers and NOTIFY, on one held connection | A pooler in transaction mode loses it: the probe finds that out by trying, and falls to `postgres-stats`. Give it a direct connection with `{ listenUrl }`. Applying the migration needs TRIGGER; NOTIFY is limited to 8000 bytes, so big keys are table-level. Row-level security is yours: triggers fire regardless, and the refetch applies it. |
|
|
267
|
+
| SQLite | `PRAGMA data_version` every 100 ms on a second connection, then the triggers' log. This is polling, of the cheapest read there is | Same host only: a network file system turns it `off`. The log holds 4096 rows; a reader past that pokes every table. With no triggers, any commit pokes every listed table. Instances on other machines can't share the file anyway. |
|
|
268
|
+
| MySQL | The triggers' log, read every 500 ms | Polling, with a row in the log per row written. No in-order commit guarantee, so it reads a window of 512 back. Each server prunes old rows from `__weft_changes`. |
|
|
269
|
+
| MongoDB | A change stream | Needs a replica set: a standalone is `off`. Barriers use a capped `__weft_barrier`, about 120 ms slower per change. |
|
|
270
|
+
|
|
271
|
+
Polling here is the framework's watcher, in one place on the server. R007 and T013 still hold for your code: never poll in it.
|
|
272
|
+
|
|
273
|
+
**On Vercel, Netlify, Lambda, Azure Functions, Firebase, Cloudflare and Deno Deploy** a process ends with its request, so nothing can hold a connection. Mutations still work, and `.live()` shows `degraded` (R014). Two ways to get the feed: deploy to a server preset, or run one always-on listener anywhere, with a bus both can reach:
|
|
274
|
+
|
|
275
|
+
<!-- example: skip two files in one block; needs a notes table -->
|
|
276
|
+
```ts
|
|
277
|
+
// listener.ts: on a VPS, Fly or Railway. `weft db listen` runs it.
|
|
278
|
+
export default realtime({ bus: postgresBus({ url: env.DATABASE_URL }), changes: dbChanges(db, [notes]) })
|
|
279
|
+
// the app, on Vercel
|
|
280
|
+
export default realtime({ bus: postgresBus({ url: env.DATABASE_URL }), changes: dbChanges(db, [notes], { listener: 'external' }) })
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
`weft db listen` loads your server modules from source, so it runs from a checkout with dependencies installed, and with the same env as the app. A listener that stops is noticed: its heartbeat ends, and within a minute `.live()` shows `reconnecting`.
|
|
284
|
+
|
|
285
|
+
**`live: { required: true }`** (or `q.live(input, { required: true })`) refuses to ship a page that only looks live. `weft check` and `weft build` fail with R013 when the target can't do it. What only a running server can know fails at startup with R015: the process stops rather than serve stale data, and in dev it says so instead. For a health route, `liveHealth()` from `@weft-js/start` answers `ok: false` while a required query has no working feed. An outage after a good start reports there too, and doesn't stop the server: restarting wouldn't bring the database back, and the feed recovers by itself.
|
|
286
|
+
|
|
287
|
+
**`verify`** proves the chain end to end on every `weft check` and `weft build`, cached by query and contract:
|
|
288
|
+
|
|
289
|
+
```ts
|
|
290
|
+
live: { required: true, verify: 'staging' } // a name in weft.config.ts
|
|
291
|
+
live: { verify: { url: 'https://staging.example.dev' } } // a running app
|
|
292
|
+
live: { verify: { db: process.env.STAGING_DATABASE_URL } } // a database, as an outside writer
|
|
293
|
+
live: { verify: true } // finds WEFT_STAGING_URL, STAGING_DATABASE_URL
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Setup, once:
|
|
297
|
+
|
|
298
|
+
```ts
|
|
299
|
+
// weft.config.ts
|
|
300
|
+
live: { targets: { staging: { url: 'https://staging.example.dev', db: 'STAGING_DATABASE_URL', production: false } } }
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Set `STAGING_DATABASE_URL`, and `WEFT_STAGING_COOKIE` and `WEFT_STAGING_OTHER_COOKIE` for a signed-in user and one who shouldn't see the row. If the query's table has a required column, say what to write: `verify: { target: 'staging', row: { owner: 'alice' } }`.
|
|
304
|
+
|
|
305
|
+
It writes one marked row through a separate connection, then checks each link: `trigger`, `write`, `notify`, `app`, `poke`, `refetch`, `denied`, `cleanup`. It reports each one's time, and a failure names the link. Both `url` and `db` test the whole chain; with one, it says which links it didn't (R023). Postgres only for now.
|
|
306
|
+
|
|
307
|
+
A target must be named with `production: false`, or have a host that says staging, and anything that looks like production is refused either way (R022). It touches only its own row, marked uniquely so builds don't collide, and removes it even on failure. The row can't sit in a transaction, because NOTIFY only fires on commit. A password in source is an error (R020): read it from the environment. A target that isn't available fails `weft build`, and warns and skips in `weft check` and dev (R021).
|
|
308
|
+
|
|
309
|
+
## Channels
|
|
310
|
+
|
|
311
|
+
`channel` is a WebSocket with named messages each way, for what the client sends often: chat, typing, cursors. For
|
|
312
|
+
anything else use a mutation and `.live()`. Define it in a server module. `authorize` is required (R001), and runs on
|
|
313
|
+
every join and every 5 minutes.
|
|
314
|
+
|
|
315
|
+
<!-- example: skip needs the app's own Message schema, isMember and insertMessage -->
|
|
316
|
+
```ts
|
|
317
|
+
// src/server/chat.ts
|
|
318
|
+
import { channel } from '@weft-js/start'
|
|
319
|
+
import { z } from 'zod'
|
|
320
|
+
import { isMember, insertMessage, Message } from '#/server/messages'
|
|
321
|
+
|
|
322
|
+
export const room = channel({
|
|
323
|
+
params: z.object({ roomId: z.string().max(64) }),
|
|
324
|
+
authorize: ({ ctx, params }) => isMember(ctx.user, params.roomId), // false: status 'unauthorized' (R011)
|
|
325
|
+
client: { say: z.object({ text: z.string().min(1).max(2000) }) }, // always validated; bound them (R005)
|
|
326
|
+
server: { said: Message }, // validated in dev and tests
|
|
327
|
+
on: {
|
|
328
|
+
say: async ({ msg, ctx, params, room }) => {
|
|
329
|
+
const row = await insertMessage(ctx, params.roomId, msg.text) // throw StartError or FnError to refuse `send`
|
|
330
|
+
await room.publish('said', row) // `{ exceptSelf: true }` skips the sender
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
limits: { perSecond: 10 }, // default 20 a second per socket
|
|
334
|
+
})
|
|
335
|
+
// from a mutation or a job: await room.in({ roomId }).publish('said', row)
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
```tsx
|
|
339
|
+
const chat = room.connect(() => ({ roomId: props.roomId })) // tracked; `skip` leaves; { keep: '30s' }
|
|
340
|
+
const said = chat.collect('said', { max: 200, key: m => m.id }) // Derived<Message[]>, kept in the cache
|
|
341
|
+
chat.on('typing', ({ userId }) => show(userId)) // removed with the owner
|
|
342
|
+
await chat.send('say', { text }) // resolves on the server's ack; rejects with the refusal
|
|
343
|
+
chat.status.value // 'connecting' | 'open' | 'reconnecting' | 'closed' | 'unauthorized'
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
**The channel is a wire, not a log.** A reconnect replays what the last 60 s or 256 messages hold, per instance. Past
|
|
347
|
+
that, or on a new server, `collect` empties, `chat.resets` ticks and `chat.onReset(fn)` runs. So what must survive goes
|
|
348
|
+
in the database: read history with a query, `chat.collect` the new messages with `key`, and invalidate the query on a
|
|
349
|
+
reset.
|
|
350
|
+
|
|
351
|
+
<!-- example: skip continues the chat example above -->
|
|
352
|
+
```tsx
|
|
353
|
+
import { derived } from '@weft-js/core'
|
|
354
|
+
import { invalidate } from '@weft-js/start'
|
|
355
|
+
const history = listMessages({ roomId })
|
|
356
|
+
const said = chat.collect('said', { key: m => m.id })
|
|
357
|
+
chat.onReset(() => invalidate('messages'))
|
|
358
|
+
const all = derived(() => dedupe([...(history.value ?? []), ...said.value]))
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
A refused join is only known once the socket is up, and `status` is `'connecting'` on the server. To render "not
|
|
362
|
+
allowed" on the server, share one function between `authorize` and the route's `load` (`allowed: mayEnter(ctx.user, id)`).
|
|
363
|
+
`authorize` doesn't narrow types: `ctx.user` in `on` is still nullable. Ephemeral messages such as `typing` go with
|
|
364
|
+
`{ exceptSelf: true }`, sent at most once a second.
|
|
365
|
+
|
|
366
|
+
In JSX leave a handler's `e` unannotated. A separate handler takes `Event & { currentTarget: HTMLInputElement }`: `input`
|
|
367
|
+
is a plain `Event` in TypeScript's DOM types, not `InputEvent`.
|
|
368
|
+
|
|
369
|
+
`send` has no offline queue. While the socket reconnects it waits up to 10 s, then rejects. A `send` in flight when the
|
|
370
|
+
socket dies rejects too, because it may or may not have arrived: put an id in the message if a retry must not double.
|
|
371
|
+
|
|
372
|
+
**Origin:** the socket takes the app's own origin and `realtime({ origins })`, exactly (R010, R004). There's nothing
|
|
373
|
+
else to configure, and no handler of your own (R004, R007). `invalidate('session:' + userId)` after a sign-out or a
|
|
374
|
+
kick re-checks that user's sockets at once. A user whose session ended is closed with 4401, and `status` is
|
|
375
|
+
`unauthorized`.
|
|
376
|
+
|
|
377
|
+
**Shared state** is one value per key, last writer wins, as a box. A write is optimistic. A refused one goes back and
|
|
378
|
+
sets `.error`.
|
|
379
|
+
|
|
380
|
+
<!-- example: skip needs the app's isParticipant and isPresenter rules -->
|
|
381
|
+
```ts
|
|
382
|
+
import { sharedState, skip } from '@weft-js/start'
|
|
383
|
+
import { z } from 'zod'
|
|
384
|
+
|
|
385
|
+
export const deckSlide = sharedState('deck-slide', {
|
|
386
|
+
key: z.string().max(64), value: z.number().int().max(999), initial: 0,
|
|
387
|
+
authorize: { read: isParticipant, write: isPresenter }, // ({ ctx, key }) => boolean, both required
|
|
388
|
+
})
|
|
389
|
+
await deckSlide.in(id).set(3) // on the server
|
|
390
|
+
const slide = deckSlide.connect(() => (id.value ? id.value : skip)) // in a defineState: State<number> & { status, error }
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
Its value lives in server memory: it survives a restart only while another instance runs. What must last is a mutation.
|
|
394
|
+
|
|
395
|
+
**Tabs** need no server: `tabs.channel` is in `weft docs browser`, and a box kept the same in every tab
|
|
396
|
+
(`sync: 'tabs'`) is in `weft docs state`.
|
|
397
|
+
|
|
398
|
+
**Testing:** `room.connect` runs the real server code over the fake socket: `await net.settle()`, then
|
|
399
|
+
`await net.publish(room, { roomId }, 'said', row)`, `net.sent(room)`, `await net.drop()`, `await net.offline()`,
|
|
400
|
+
`await net.session({ user })`. From `@weft-js/core/test`, `tabs.post(name, msg)` and `tabs.posted(name)` play another tab, and two
|
|
401
|
+
`render()`s sharing one `storage` are two tabs.
|
|
402
|
+
|
|
403
|
+
## CSS on a page
|
|
404
|
+
|
|
405
|
+
A built page carries the CSS its first paint needs in a `<style>`, and links the rest at the end of its shell, where it blocks nothing above it. The links are at most two hashed files: one with what every route shares, one with the rest of that route. Client navigation loads each chunk's own CSS and waits for it.
|
|
406
|
+
|
|
407
|
+
The server finds the first-paint CSS by reading the page's HTML against a table from the build, with lookups and no CSS parsing. So the same HTML always gets the same CSS. Nothing depends on what rendered it: a prerendered page, a `@weft static` level, a page from a cache and a live render take one path, and there is no `Vary`. A streamed chunk carries only the styles that response hasn't sent.
|
|
408
|
+
|
|
409
|
+
Rules for hover, focus and other states aren't in the first paint, so they work once the late sheet lands. A class a head script sets isn't in the HTML: name it in `site.criticalKeep`. A dial value a head script sets with `dataset.x = 'y'` is found by itself. A stored dial choice the head doesn't name shows the default until the sheet lands.
|
|
410
|
+
|
|
411
|
+
If part of a page must not show before the late sheet, mark it:
|
|
412
|
+
|
|
413
|
+
```tsx
|
|
414
|
+
<section data-weft="styled">…</section>
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
It keeps its room, stays hidden until the sheet lands, and shows anyway after 3 s with no JavaScript, so a missing file can't hide it for good. Assistive tech doesn't see it while hidden. `weft check` warns on this mark on main content or an `h1` (D040). Nothing adds the mark for you: the first paint already holds every rule that matches the HTML.
|
|
418
|
+
|
|
419
|
+
`site.criticalCss: false` goes back to one sheet per chunk, inlined up to 48 KB and linked past that. `site.inlineCss: false` keeps plain links.
|
|
420
|
+
|
|
421
|
+
**Deploying.** A page cached before a deploy links the sheets it was built with, so `weft build` keeps old combined sheets for 30 days in `.weft/sheets`. Keep that folder between builds, or the old files go: a cached page then paints from its own `<style>`, without hover and focus styles until it is cached again. The folder is copied into the build's assets, so a preset that serves those assets needs nothing else.
|
|
422
|
+
|
package/docs/state.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# State (`@weft-js/core`)
|
|
2
|
+
|
|
3
|
+
Every reactive function in one place. The rules for using them are `weft docs rules`; props are `weft docs props`.
|
|
4
|
+
|
|
5
|
+
```tsx
|
|
6
|
+
import { derived, flush, onCleanup, onMount, state, watch } from '@weft-js/core'
|
|
7
|
+
|
|
8
|
+
export function Counter() {
|
|
9
|
+
const count = state(0)
|
|
10
|
+
const label = derived(() => `Clicked ${count.value} times`)
|
|
11
|
+
let input: HTMLInputElement | undefined
|
|
12
|
+
|
|
13
|
+
watch(() => label.value, text => { document.title = text; return () => (document.title = 'App') })
|
|
14
|
+
onMount(() => input?.focus())
|
|
15
|
+
onCleanup(() => console.log('Counter removed'))
|
|
16
|
+
|
|
17
|
+
const reset = () => {
|
|
18
|
+
count.value = 0
|
|
19
|
+
flush() // the DOM shows 0 now, before the focus below
|
|
20
|
+
input?.select()
|
|
21
|
+
}
|
|
22
|
+
return (
|
|
23
|
+
<p>
|
|
24
|
+
<button type="button" onClick={() => count.value++}>{label.value}</button>
|
|
25
|
+
<input ref={(n: HTMLInputElement) => (input = n)} aria-label="Note" />
|
|
26
|
+
<button type="button" onClick={reset}>Reset</button>
|
|
27
|
+
</p>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Boxes and derived values
|
|
33
|
+
|
|
34
|
+
| Call | Gives |
|
|
35
|
+
|---|---|
|
|
36
|
+
| `state(init, options?)` | A box: read and write `.value`. A write that is `Object.is` the old value does nothing |
|
|
37
|
+
| `box.set(d => { d.x = 1 })` | Updates a copy: arrays and objects are copied one level, then written as a new value |
|
|
38
|
+
| `box.get(key)` | One entry of an object box. The reader re-runs only when that entry changes (W017) |
|
|
39
|
+
| `state.linked(() => source, { key? })` | A box that follows `source` until written. With `key`, an edit holds while the key stays, and `.conflict.value` says when the source moved under it |
|
|
40
|
+
| `derived(() => …)` | A read-only value, computed when read and cached until what it read changes |
|
|
41
|
+
|
|
42
|
+
`get`, `set` and `state.linked` come from `@weft-js/core/keyed`. The main entry and the weft() Vite plugin bring it in
|
|
43
|
+
when a module uses them, so you only import it yourself when the name is built at run time (`box[name]`).
|
|
44
|
+
|
|
45
|
+
Writes are batched: effects and DOM updates run together on the next microtask. `flush()` runs them now, for example
|
|
46
|
+
before `focus()` on an element a write just showed. `batch(fn)` runs `fn` and exists only to say this at the call.
|
|
47
|
+
|
|
48
|
+
## Effects and owners
|
|
49
|
+
|
|
50
|
+
- `watch(() => source, (value, prev) => cleanup?)`: runs `act` now and whenever `source` changes. Only `source` is
|
|
51
|
+
tracked. A returned function runs before the next run and when the owner goes.
|
|
52
|
+
- `onMount(fn)`: runs once the component's DOM is in place and refs are set. Never on the server.
|
|
53
|
+
- `onCleanup(fn)`: runs when the owner (a component, a `createRoot`, a list row) is disposed.
|
|
54
|
+
- `untrack(() => …)`: reads without subscribing. The result never updates (W018 if only JSX reads it).
|
|
55
|
+
- `createRoot(dispose => …)`: an owner that outlives its caller, ended by `dispose()`.
|
|
56
|
+
- `context(fallback)`: `ctx.provide(value, () => …)` sets a value for everything created inside; `ctx.use` reads the
|
|
57
|
+
nearest one.
|
|
58
|
+
|
|
59
|
+
## Shared state: `defineState`
|
|
60
|
+
|
|
61
|
+
A named scope of state that any component can import. Its setup runs once per app, the first time it's used, and
|
|
62
|
+
returns boxes, derived values and actions. Each action call is one transaction and one undo step.
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
import { defineState, derived, state } from '@weft-js/core'
|
|
66
|
+
import { z } from 'zod'
|
|
67
|
+
|
|
68
|
+
export const cart = defineState('cart', () => {
|
|
69
|
+
const lines = state<Record<string, number>>({}, { persist: { in: 'local', schema: z.record(z.string(), z.number()), version: 1 }, sync: 'tabs' })
|
|
70
|
+
const count = derived(() => Object.values(lines.value).reduce((a, b) => a + b, 0))
|
|
71
|
+
const add = (id: string) => lines.set(d => { d[id] = (d[id] ?? 0) + 1 })
|
|
72
|
+
return { lines, count, add }
|
|
73
|
+
}, { history: { limit: 100 } })
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Read it as `cart.count.value` and call `cart.add('p1')`. The handle also has `$name`, `$reset()` (every box back to
|
|
77
|
+
its initial value), `$snapshot()` (a plain copy) and, with `history`, `$history`: `undo()`, `redo()`, `clear()`,
|
|
78
|
+
`canUndo`, `canRedo` and `entries`.
|
|
79
|
+
|
|
80
|
+
`state` options inside a setup:
|
|
81
|
+
|
|
82
|
+
| Option | Does |
|
|
83
|
+
|---|---|
|
|
84
|
+
| `persist: 'local' \| 'session' \| 'cookie' \| 'memory'` | Keeps the box through reloads. The short form takes a primitive `init` only |
|
|
85
|
+
| `persist: { in, schema, version, migrate, maxAge }` | Objects and arrays need a Standard Schema (Zod, Valibot, ArkType). `migrate` maps old versions; `maxAge` is for cookies, in seconds |
|
|
86
|
+
| `sync: 'tabs'` | The same value in every tab. Needs `persist`, and not `'session'` |
|
|
87
|
+
| `history: false` | Leaves the box out of undo |
|
|
88
|
+
| `name` | The box's name in its scope. A persisted box the setup doesn't return needs one |
|
|
89
|
+
|
|
90
|
+
Misuse throws a `StateError` in dev with a code and a fix: S008 (persistence or sync set up wrongly), S009 (two
|
|
91
|
+
scopes with one name, a reserved `$` key, or `$history` without `history`). `weft check` reports S001 (server data
|
|
92
|
+
copied into `state`) and S012 (`state.linked` over a query with no `key`). On the server, a scope is per request:
|
|
93
|
+
reaching one outside a render is S002, and reading a `local` or `session` box there shows `init` (S013).
|
|
94
|
+
|
|
95
|
+
In tests, `scope(cart)` from `@weft-js/core/test` makes a fresh instance with no UI, and `render(App, props, { state,
|
|
96
|
+
storage })` seeds scopes and shares storage between renders: `weft docs testing`.
|
package/docs/style.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Styling: style groups
|
|
2
|
+
|
|
3
|
+
Style with **groups** from `@weft-js/core/style`, not class lists. A group is a named thing (`order-card`), its variations
|
|
4
|
+
(axes), its pieces (parts) and its states, all written in tokens. The markup says what an element is and which
|
|
5
|
+
variation it's in: `<article data-g="order-card" data-status="late">`.
|
|
6
|
+
|
|
7
|
+
```tsx
|
|
8
|
+
import { group } from '@weft-js/core/style'
|
|
9
|
+
|
|
10
|
+
/** A summary card for one order in a list. */
|
|
11
|
+
export const orderCard = group('order-card', {
|
|
12
|
+
base: { surface: 'raised', pad: 'group', radius: 'surface', layout: 'stack', gap: 'related' },
|
|
13
|
+
axes: { status: { open: { tone: 'info' }, shipped: { tone: 'positive' }, late: { tone: 'critical' } } },
|
|
14
|
+
parts: { title: { text: 'title' }, meta: { text: 'caption', ink: 'subtle' } },
|
|
15
|
+
states: { selected: { ring: 'accent' } },
|
|
16
|
+
defaults: { status: 'open' },
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
type Order = { name: string; date: string; status: 'open' | 'shipped' | 'late' }
|
|
20
|
+
|
|
21
|
+
export function OrderCard(props: { order: Order; selected: boolean }) {
|
|
22
|
+
return (
|
|
23
|
+
<article {...orderCard({ status: props.order.status, selected: props.selected })}>
|
|
24
|
+
<h3 {...orderCard.title}>{props.order.name}</h3>
|
|
25
|
+
<p {...orderCard.meta}>{props.order.date}</p>
|
|
26
|
+
</article>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Rules
|
|
32
|
+
|
|
33
|
+
1. **Find before you write.** `weft style find card` (MCP `style_find`) lists the groups that fit. Reuse one, or
|
|
34
|
+
add an axis value to it. A second group for the same thing is D021.
|
|
35
|
+
2. **Values are tokens.** Each key is an intent (`surface pad gap radius tone emphasis text ink layout align justify
|
|
36
|
+
ring …`) and each value one of its tokens. `pad: '13px'` is a type error and D025. Raw CSS goes in
|
|
37
|
+
`css: { … }`, which is counted (D023): use it only where no intent sets the property.
|
|
38
|
+
3. **Variation is an axis value**, set from state: `orderCard({ status: order.status })`. A reactive axis is one
|
|
39
|
+
attribute write; no class string is rebuilt.
|
|
40
|
+
4. **A one-off is an accent**: `orderCard({ status: 'late', accent: { radius: 'round' } })`. Tokens only. The same
|
|
41
|
+
accent three times means it's an axis value (D022). An accent that sets what the site's axis value sets is D024.
|
|
42
|
+
5. **One group per element** (D024). For a second look, nest an element or make it an axis.
|
|
43
|
+
6. **States are ARIA or data attributes** (`selected` → `aria-selected`), and `hover`/`focus`/`active`/`disabled`
|
|
44
|
+
are read from the element, not passed.
|
|
45
|
+
7. **No class soup.** More than 8 classes on an element, or the same class list in two places, is D020.
|
|
46
|
+
|
|
47
|
+
## Tools
|
|
48
|
+
|
|
49
|
+
- `weft style find <query>`: groups that fit, best first, with axes, parts, states and how often each is used.
|
|
50
|
+
- `weft style explain order-card --status late` (or `order-card[status=late]`, `order-card.title`, or a DOM selector
|
|
51
|
+
copied from devtools): every declaration on the root and parts, in cascade order, with the token, axis, state,
|
|
52
|
+
compound or accent it came from, and the CSS that variation emits. MCP `style_explain`.
|
|
53
|
+
- `.weft/styles.json`: the generated matrix (every group's axes, values, parts, accents, resolved CSS, uses and
|
|
54
|
+
`similar` groups). `weft dev`/`build` and `weft check` keep it current; `weft style matrix` writes it. Read it;
|
|
55
|
+
never edit it.
|
|
56
|
+
|
|
57
|
+
Codes: D020 class soup, D021 near-duplicate groups, D022 a repeated accent, D023 the `css` escape, D024 styles that
|
|
58
|
+
fight (two groups on one element, overlapping compounds, an accent against an axis), D025 an unknown name, W060 a
|
|
59
|
+
group that isn't literal data.
|