@weft-js/cli 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +74 -2
  3. package/bin/weft.mjs +8 -0
  4. package/docs/async.md +40 -0
  5. package/docs/browser.md +39 -0
  6. package/docs/config.md +93 -0
  7. package/docs/deploy.md +83 -0
  8. package/docs/design.md +97 -0
  9. package/docs/fonts.md +70 -0
  10. package/docs/icons.md +102 -0
  11. package/docs/lists.md +23 -0
  12. package/docs/meta.md +82 -0
  13. package/docs/plugins.md +143 -0
  14. package/docs/props.md +77 -0
  15. package/docs/rules.md +32 -0
  16. package/docs/rust.md +223 -0
  17. package/docs/start-short.md +8 -0
  18. package/docs/start.md +422 -0
  19. package/docs/state.md +96 -0
  20. package/docs/style.md +59 -0
  21. package/docs/surface.md +111 -0
  22. package/docs/testing.md +71 -0
  23. package/docs/ui-state.md +92 -0
  24. package/docs/verify.md +48 -0
  25. package/package.json +39 -4
  26. package/src/add-rust.mjs +123 -0
  27. package/src/add.mjs +358 -0
  28. package/src/agents-md.mjs +134 -0
  29. package/src/alias.mjs +59 -0
  30. package/src/audit.mjs +366 -0
  31. package/src/browser/cli.mjs +34 -0
  32. package/src/browser/client.mjs +132 -0
  33. package/src/browser/commands.mjs +143 -0
  34. package/src/catalog.mjs +496 -0
  35. package/src/check.mjs +330 -0
  36. package/src/cli.mjs +364 -0
  37. package/src/config.d.ts +1 -0
  38. package/src/config.mjs +5 -0
  39. package/src/contributions.mjs +69 -0
  40. package/src/db-layer.mjs +33 -0
  41. package/src/deploy/cdn.mjs +64 -0
  42. package/src/deploy/files.mjs +187 -0
  43. package/src/deploy/fit.mjs +62 -0
  44. package/src/deploy/hosts.mjs +56 -0
  45. package/src/deploy/index.mjs +273 -0
  46. package/src/deploy/needs.mjs +92 -0
  47. package/src/design-checks.mjs +439 -0
  48. package/src/diag.mjs +74 -0
  49. package/src/docs.mjs +132 -0
  50. package/src/doctor.mjs +30 -0
  51. package/src/files.mjs +47 -0
  52. package/src/fix.mjs +40 -0
  53. package/src/gate.mjs +276 -0
  54. package/src/index.mjs +9 -0
  55. package/src/infer/engine.cjs +525 -0
  56. package/src/infer/facts.cjs +252 -0
  57. package/src/infer/hosts.cjs +114 -0
  58. package/src/infer/lsp.mjs +131 -0
  59. package/src/infer/session.cjs +105 -0
  60. package/src/infer/shadow.cjs +46 -0
  61. package/src/infer/worker.cjs +97 -0
  62. package/src/jsx-scan.mjs +86 -0
  63. package/src/load-config.mjs +303 -0
  64. package/src/map.mjs +110 -0
  65. package/src/mcp.mjs +195 -0
  66. package/src/meta-table.mjs +28 -0
  67. package/src/mutate.mjs +140 -0
  68. package/src/plugin-checks.mjs +89 -0
  69. package/src/plugins.mjs +172 -0
  70. package/src/project.mjs +219 -0
  71. package/src/props-checks.mjs +46 -0
  72. package/src/props-listener.mjs +38 -0
  73. package/src/react-checks.mjs +36 -0
  74. package/src/share-checks.mjs +74 -0
  75. package/src/site.mjs +19 -0
  76. package/src/slots.mjs +96 -0
  77. package/src/start.mjs +296 -0
  78. package/src/state-checks.mjs +213 -0
  79. package/src/style.mjs +320 -0
  80. package/src/surface-checks.mjs +218 -0
  81. package/src/tailwind.mjs +22 -0
  82. package/src/tasks.mjs +99 -0
  83. package/src/testdiff.mjs +157 -0
  84. package/src/tests.mjs +572 -0
  85. package/src/text-checks.mjs +172 -0
  86. package/src/tokens.mjs +24 -0
  87. package/src/trust.mjs +282 -0
  88. package/src/weft.mjs +66 -0
  89. package/src/workspace.mjs +105 -0
  90. package/ts-plugin/index.js +137 -0
  91. package/ts-plugin/package.json +1 -0
package/src/audit.mjs ADDED
@@ -0,0 +1,366 @@
1
+ // `weft audit` and the last step of `weft build`: the built pages in a clean headless Chrome, at a phone's width and a
2
+ // desktop's, read the way a crawler, a screen reader and Lighthouse read them. `weft check` reads source; some failures
3
+ // only exist once CSS and a breakpoint have had their say (a button whose text a phone hides has no name), so this
4
+ // looks at the rendered page. No automation library: the DevTools protocol over the WebSocket Node already has.
5
+ //
6
+ // L001–L004 axe-core's rules, the ones Lighthouse grades (names, images, contrast, the rest), L010–L013 console and issues,
7
+ // L020–L025 the head a crawler reads, L030–L031 sideways scroll on a phone and layout shift.
8
+
9
+ import { spawn } from 'node:child_process'
10
+ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'
11
+ import { createServer } from 'node:http'
12
+ import { tmpdir } from 'node:os'
13
+ import { extname, join, relative, resolve, sep } from 'node:path'
14
+ import { fileURLToPath } from 'node:url'
15
+ import { findChrome } from './browser/client.mjs'
16
+ import { diagnostic } from './diag.mjs'
17
+
18
+ /** The two widths Lighthouse's defaults stand for: its mobile run (a Moto G Power) and its desktop run. */
19
+ export const FORMS = {
20
+ phone: { width: 412, height: 823, deviceScaleFactor: 1.75, mobile: true },
21
+ desktop: { width: 1350, height: 940, deviceScaleFactor: 1, mobile: false },
22
+ }
23
+
24
+ /**
25
+ * axe-core is what Lighthouse runs for Accessibility and for Agentic Browsing's accessibility tree: the WCAG 2 A and AA
26
+ * rules, and the best-practice ones Lighthouse also grades. `region` (all content in landmarks) Lighthouse leaves out.
27
+ */
28
+ const AXE = {
29
+ runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'] },
30
+ rules: { 'heading-order': { enabled: true }, 'empty-heading': { enabled: true }, 'tabindex': { enabled: true }, 'duplicate-id-aria': { enabled: true }, 'image-redundant-alt': { enabled: true }, 'skip-link': { enabled: true }, 'landmark-one-main': { enabled: true }, 'list': { enabled: true }, 'listitem': { enabled: true }, 'region': { enabled: false } },
31
+ resultTypes: ['violations'],
32
+ }
33
+ /** What a rule means in a warp app, where warp is how it usually happens. */
34
+ const WARP = {
35
+ 'heading-order': "warp's titled parts (Accordion, Chart, ActivityFeed) are h3, for a page section under an h2: one placed elsewhere takes `level`, or the place gets the heading it lacks. ",
36
+ }
37
+
38
+ /** axe rules with a code of their own; every other violation is L004, with the rule's id. */
39
+ const RULES = {
40
+ ...Object.fromEntries(['button-name', 'link-name', 'input-button-name', 'label', 'select-name', 'aria-command-name', 'aria-input-field-name', 'aria-toggle-field-name', 'aria-progressbar-name', 'aria-meter-name', 'aria-tooltip-name', 'aria-treeitem-name', 'aria-dialog-name', 'frame-title', 'summary-name'].map(r => [r, 'L001'])),
41
+ ...Object.fromEntries(['image-alt', 'role-img-alt', 'svg-img-alt', 'input-image-alt', 'object-alt', 'area-alt'].map(r => [r, 'L002'])),
42
+ 'color-contrast': 'L003',
43
+ }
44
+ let axe
45
+ const axeSource = () => (axe ??= readFileSync(fileURLToPath(import.meta.resolve('axe-core/axe.min.js')), 'utf8'))
46
+ /** The issue types Lighthouse's `inspector-issues` audit counts (low contrast is axe's, as L003). */
47
+ const ISSUES = new Set(['MixedContentIssue', 'CookieIssue', 'BlockedByResponseIssue', 'HeavyAdIssue', 'ContentSecurityPolicyIssue', 'SharedArrayBufferIssue', 'CorsIssue', 'AttributionReportingIssue', 'QuirksModeIssue', 'FederatedAuthRequestIssue', 'BounceTrackingIssue', 'StylesheetLoadingIssue'])
48
+
49
+ const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.avif': 'image/avif', '.gif': 'image/gif', '.ico': 'image/x-icon', '.woff2': 'font/woff2', '.woff': 'font/woff', '.txt': 'text/plain; charset=utf-8', '.xml': 'application/xml', '.wasm': 'application/wasm', '.webmanifest': 'application/manifest+json' }
50
+
51
+ const fail = (code, message) => Object.assign(new Error(message), { code })
52
+ const sleep = ms => new Promise(r => { setTimeout(r, ms) })
53
+
54
+ /** The public directory a build wrote: the newer of `.output/public` (server presets) and `dist` (Netlify, Vercel static). */
55
+ export function builtPublic(root) {
56
+ const found = ['.output/public', 'dist'].map(d => join(root, d)).filter(d => existsSync(join(d, 'index.html')))
57
+ return found.sort((a, b) => statSync(join(b, 'index.html')).mtimeMs - statSync(join(a, 'index.html')).mtimeMs)[0] ?? null
58
+ }
59
+
60
+ /** Every prerendered page under `dir`, as its URL path. */
61
+ export function pagesIn(dir) {
62
+ const out = []
63
+ const walk = d => {
64
+ for (const e of readdirSync(d, { withFileTypes: true })) {
65
+ if (e.name.startsWith('.') || e.name.startsWith('_')) continue
66
+ const f = join(d, e.name)
67
+ if (e.isDirectory()) walk(f)
68
+ else if (e.name.endsWith('.html') && !/^(404|200|500)\.html$/.test(e.name)) {
69
+ const rel = relative(dir, f).split(sep).join('/')
70
+ out.push(`/${rel.replace(/(^|\/)index\.html$/, '$1').replace(/\.html$/, '')}`)
71
+ }
72
+ }
73
+ }
74
+ walk(dir)
75
+ return out.sort()
76
+ }
77
+
78
+ /**
79
+ * One page for each kind: `/` and each top-level page, then the first page of each section at each depth (the docs'
80
+ * 1,600 pages share a few layouts, and a layout's fault shows on its first page).
81
+ */
82
+ export function samplePages(pages, max = 30) {
83
+ const seen = new Map()
84
+ for (const p of pages) {
85
+ const parts = p.split('/').filter(Boolean)
86
+ const key = parts.length <= 1 ? p : `${parts[0]}/${parts.length}`
87
+ if (!seen.has(key)) seen.set(key, p)
88
+ }
89
+ return [...seen.values()].slice(0, max)
90
+ }
91
+
92
+ /** A static server over a build's public directory, for the audit's Chrome. */
93
+ export function serveStatic(dir) {
94
+ dir = resolve(dir)
95
+ const server = createServer((req, res) => {
96
+ const path = decodeURIComponent(new URL(req.url, 'http://x').pathname)
97
+ const tries = [path, `${path}/index.html`, `${path.replace(/\/$/, '')}.html`].map(p => resolve(dir, `.${p}`))
98
+ const file = tries.find(f => (f === dir || f.startsWith(dir + sep)) && existsSync(f) && statSync(f).isFile())
99
+ const missing = join(dir, '404.html')
100
+ if (!file) {
101
+ res.writeHead(404, { 'content-type': TYPES['.html'] })
102
+ // a doctype even here: a frame showing this would put Chrome in Quirks Mode, an issue of the audit's own making
103
+ return res.end(existsSync(missing) ? readFileSync(missing) : '<!doctype html><title>Not found</title><p>Not found</p>')
104
+ }
105
+ res.writeHead(200, { 'content-type': TYPES[extname(file)] ?? 'application/octet-stream' })
106
+ res.end(readFileSync(file))
107
+ })
108
+ return new Promise(ok => { server.listen(0, '127.0.0.1', () => ok({ url: `http://127.0.0.1:${server.address().port}`, close: () => server.close() })) })
109
+ }
110
+
111
+ /** A clean headless Chrome (its own empty profile: no extensions, nothing stored) and a DevTools protocol client. As root (a container, CI) Chrome only starts unsandboxed. */
112
+ async function chrome(exe) {
113
+ const profile = mkdtempSync(join(tmpdir(), 'weft-audit-'))
114
+ const child = spawn(exe, [`--user-data-dir=${profile}`, '--headless=new', '--remote-debugging-port=0', '--no-first-run', '--no-default-browser-check', '--disable-extensions', '--disable-background-networking', '--mute-audio', ...(process.getuid?.() === 0 ? ['--no-sandbox'] : []), 'about:blank'], { stdio: 'ignore' })
115
+ const portFile = join(profile, 'DevToolsActivePort')
116
+ for (let i = 0; !existsSync(portFile) || !readFileSync(portFile, 'utf8').includes('\n'); i++) {
117
+ if (i > 150 || child.exitCode !== null) { child.kill(); throw fail('E_NO_CHROME', `Chrome at ${exe} didn't start`) }
118
+ await sleep(100)
119
+ }
120
+ const [port, path] = readFileSync(portFile, 'utf8').split('\n')
121
+ const ws = new WebSocket(`ws://127.0.0.1:${port}${path}`)
122
+ await new Promise((ok, no) => { ws.onopen = ok; ws.onerror = () => no(fail('E_CDP', 'could not reach Chrome over the DevTools protocol')) })
123
+ let next = 0
124
+ const waiting = new Map()
125
+ const listeners = new Set()
126
+ ws.onmessage = e => {
127
+ const m = JSON.parse(e.data)
128
+ if (m.id !== undefined) {
129
+ const w = waiting.get(m.id)
130
+ waiting.delete(m.id)
131
+ if (m.error) w?.no(fail('E_CDP', m.error.message))
132
+ else w?.ok(m.result)
133
+ } else for (const on of listeners) on(m)
134
+ }
135
+ const send = (method, params = {}, sessionId) => new Promise((ok, no) => {
136
+ const id = ++next
137
+ waiting.set(id, { ok, no })
138
+ ws.send(JSON.stringify({ id, method, params, sessionId }))
139
+ })
140
+ const close = () => {
141
+ try { ws.close() } catch {}
142
+ child.kill()
143
+ // Chrome holds the profile a moment after it's told to go.
144
+ setTimeout(() => rmSync(profile, { recursive: true, force: true }), 500).unref()
145
+ }
146
+ return { send, on: fn => { listeners.add(fn); return () => listeners.delete(fn) }, close }
147
+ }
148
+
149
+ /** In the page: how far the layout moved without a person asking (CLS, summed), and the first elements that moved. */
150
+ const SHIFT = `new Promise(ok => {
151
+ let cls = 0
152
+ const moved = []
153
+ new PerformanceObserver(list => {
154
+ for (const e of list.getEntries()) {
155
+ if (e.hadRecentInput) continue
156
+ cls += e.value
157
+ for (const s of e.sources ?? []) {
158
+ if (!s.node || s.node.nodeType !== 1 || moved.length === 3) continue
159
+ const open = s.node.outerHTML.slice(0, s.node.outerHTML.indexOf('>') + 1)
160
+ moved.push(open.length > 120 ? open.slice(0, 117) + '…>' : open)
161
+ }
162
+ }
163
+ }).observe({ type: 'layout-shift', buffered: true })
164
+ setTimeout(() => ok({ cls: Math.round(cls * 1000) / 1000, moved }), 50)
165
+ })`
166
+
167
+ /**
168
+ * The app's own pages as a test on a path (`src/routes`, `[id]` and `[...rest]` included). A build served as files has
169
+ * only its prerendered pages: a request for one of the others is the server's to answer, not a missing file.
170
+ */
171
+ export function appRoutes(root) {
172
+ const dir = join(root, 'src', 'routes')
173
+ if (!existsSync(dir)) return () => false
174
+ const tests = []
175
+ const walk = (at, prefix) => {
176
+ for (const e of readdirSync(at, { withFileTypes: true })) {
177
+ const name = e.name.replace(/^\(.*\)$/, '')
178
+ if (e.isDirectory()) { walk(join(at, e.name), name ? `${prefix}/${name}` : prefix); continue }
179
+ if (!/\.[jt]sx?$/.test(e.name) || /\.(server|test|spec)\./.test(e.name) || e.name.startsWith('_')) continue
180
+ const leaf = e.name.replace(/\.[jt]sx?$/, '')
181
+ const path = leaf === 'index' ? prefix : `${prefix}/${leaf}`
182
+ const re = path.split('/').map(seg => (/^\[\.\.\.\w+\]$/.test(seg) ? '\0' : /^\[\w+\]$/.test(seg) ? '[^/]+' : seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))).join('/').replace(/\/\0/g, '(?:/.+)?')
183
+ tests.push(new RegExp(`^${re || ''}/?$`))
184
+ }
185
+ }
186
+ walk(dir, '')
187
+ return path => tests.some(t => t.test(path))
188
+ }
189
+
190
+ /** In the page: what a crawler reads from the head, and what pokes past a phone's right edge. */
191
+ const HEAD = `(() => {
192
+ const meta = n => document.querySelector('meta[name="' + n + '"]')?.getAttribute('content')?.trim() ?? null
193
+ const wide = []
194
+ const edge = innerWidth + 1
195
+ if (document.documentElement.scrollWidth > edge) {
196
+ for (const el of document.body.querySelectorAll('*')) {
197
+ const r = el.getBoundingClientRect()
198
+ if (r.right > edge && r.width > 0 && getComputedStyle(el).position !== 'fixed' && !(el.parentElement && el.parentElement.getBoundingClientRect().right > edge)) {
199
+ const open = el.outerHTML.slice(0, el.outerHTML.indexOf('>') + 1)
200
+ wide.push(open.length > 120 ? open.slice(0, 117) + '…>' : open)
201
+ if (wide.length === 3) break
202
+ }
203
+ }
204
+ }
205
+ return {
206
+ title: document.title.trim(), lang: document.documentElement.getAttribute('lang') ?? '', description: meta('description'),
207
+ viewport: meta('viewport'), robots: meta('robots'), scroll: document.documentElement.scrollWidth - innerWidth, wide,
208
+ icon: !!document.querySelector('link[rel~="icon"]'),
209
+ }
210
+ })()`
211
+
212
+ /** Audit one page in one form factor: what Chrome said while it loaded, its head, its layout, and axe-core's rules. */
213
+ async function auditPage(browser, url, form, statics, served) {
214
+ const { targetId } = await browser.send('Target.createTarget', { url: 'about:blank' })
215
+ const { sessionId } = await browser.send('Target.attachToTarget', { targetId, flatten: true })
216
+ const send = (m, p) => browser.send(m, p, sessionId)
217
+ const found = []
218
+ let document = null
219
+ let loaded
220
+ const load = new Promise(ok => { loaded = ok })
221
+ const where = (u, line) => (u ? `${u.replace(/^https?:\/\/127\.0\.0\.1:\d+/, '')}${line === undefined ? '' : `:${line + 1}`}` : '')
222
+ const off = browser.on(m => {
223
+ if (m.sessionId !== sessionId) return
224
+ const p = m.params
225
+ if (m.method === 'Page.loadEventFired') loaded()
226
+ else if (m.method === 'Network.responseReceived' && p.type === 'Document' && !document) document = p.response
227
+ else if (m.method === 'Runtime.exceptionThrown') {
228
+ const d = p.exceptionDetails
229
+ found.push({ code: 'L010', key: d.exception?.description?.split('\n')[0] ?? d.text, at: where(d.url, d.lineNumber), text: d.exception?.description?.split('\n')[0] ?? d.text })
230
+ } else if (m.method === 'Runtime.consoleAPICalled' && (p.type === 'error' || p.type === 'assert')) {
231
+ const text = p.args.map(a => a.value ?? a.description ?? '').join(' ').split('\n')[0]
232
+ const top = p.stackTrace?.callFrames?.[0]
233
+ found.push({ code: 'L010', key: text, at: where(top?.url, top?.lineNumber), text })
234
+ } else if (m.method === 'Log.entryAdded' && p.entry.level === 'error') {
235
+ // a build's pages served as files have no server: its realtime and function routes answer 404 here and nowhere else
236
+ if (statics && /\/_weft\/(rt|ws|fn|data|m|x)\b/.test(`${p.entry.url ?? ''} ${p.entry.text}`)) return
237
+ if (statics && p.entry.url && /status of 404/.test(p.entry.text) && served(new URL(p.entry.url).pathname)) return
238
+ // the favicon: Chrome asks for it lazily, once a session, so the head probe asks instead (below)
239
+ if (/\/favicon\.ico$/.test(p.entry.url ?? '')) return
240
+ found.push({ code: 'L011', key: `${p.entry.url ?? ''} ${p.entry.text}`, at: where(p.entry.url), text: p.entry.text })
241
+ } else if (m.method === 'Audits.issueAdded') {
242
+ const { code, details } = p.issue
243
+ if (code === 'DeprecationIssue') {
244
+ const d = details.deprecationIssueDetails
245
+ found.push({ code: 'L012', key: d.type, at: where(d.sourceCodeLocation?.url, d.sourceCodeLocation?.lineNumber), text: d.type })
246
+ } else if (ISSUES.has(code)) found.push({ code: 'L013', key: code, text: code.replace(/Issue$/, '').replace(/([a-z])([A-Z])/g, '$1 $2') })
247
+ }
248
+ })
249
+ try {
250
+ const f = FORMS[form]
251
+ await Promise.all(['Page.enable', 'Runtime.enable', 'Log.enable', 'Network.enable', 'Audits.enable'].map(m => send(m)))
252
+ await send('Emulation.setDeviceMetricsOverride', { width: f.width, height: f.height, deviceScaleFactor: f.deviceScaleFactor, mobile: f.mobile })
253
+ await send('Emulation.setTouchEmulationEnabled', { enabled: f.mobile, maxTouchPoints: f.mobile ? 5 : 1 })
254
+ await send('Page.navigate', { url })
255
+ await Promise.race([load, sleep(15_000)])
256
+ await sleep(800) // hydration's errors, and a font swap's shift, land after load
257
+ if (document && document.status >= 400) found.push({ code: 'L025', key: String(document.status), text: `HTTP ${document.status}` })
258
+ const header = Object.entries(document?.headers ?? {}).find(([k]) => k.toLowerCase() === 'x-robots-tag')?.[1]
259
+ const head = (await send('Runtime.evaluate', { expression: HEAD, returnByValue: true })).result.value
260
+ if (!head.icon) {
261
+ const status = (await send('Runtime.evaluate', { expression: `fetch('/favicon.ico', { method: 'HEAD' }).then(r => r.status, () => 0)`, awaitPromise: true, returnByValue: true })).result.value
262
+ if (status >= 400) found.push({ code: 'L011', key: 'favicon', at: '/favicon.ico', text: `HTTP ${status}` })
263
+ }
264
+ if (!head.title) found.push({ code: 'L020', key: 'title' })
265
+ if (!head.description) found.push({ code: 'L021', key: 'description' })
266
+ if (!head.lang) found.push({ code: 'L022', key: 'lang' })
267
+ if (!head.viewport || !/width=device-width/.test(head.viewport)) found.push({ code: 'L023', key: 'viewport' })
268
+ const noindex = [header && /noindex|none/i.test(header) && `the \`x-robots-tag: ${header}\` header`, head.robots && /noindex|none/i.test(head.robots) && `<meta name="robots" content="${head.robots}">`].filter(Boolean)
269
+ if (noindex.length) found.push({ code: 'L024', key: noindex.join(), text: noindex.join(' and '), header: !!header })
270
+ if (f.mobile && head.scroll > 0) found.push({ code: 'L030', key: head.wide.join() || 'page', text: `${head.scroll}px`, wide: head.wide })
271
+ const shift = (await send('Runtime.evaluate', { expression: SHIFT, awaitPromise: true, returnByValue: true })).result.value
272
+ if (shift.cls > 0.05) found.push({ code: 'L031', key: shift.moved.join() || 'page', cls: shift.cls, moved: shift.moved })
273
+ await send('Runtime.evaluate', { expression: axeSource() })
274
+ const { result, exceptionDetails } = await send('Runtime.evaluate', { expression: `axe.run(document, ${JSON.stringify(AXE)}).then(r => r.violations)`, awaitPromise: true, returnByValue: true })
275
+ if (exceptionDetails) throw fail('E_AUDIT', `axe-core failed on ${url}: ${exceptionDetails.exception?.description ?? exceptionDetails.text}`)
276
+ for (const v of result.value) {
277
+ for (const n of v.nodes) {
278
+ const tag = n.html.slice(0, n.html.indexOf('>') + 1)
279
+ found.push({ code: RULES[v.id] ?? 'L004', key: `${v.id} ${n.target.join(' ')}`, rule: v.id, help: v.help, fix: n.failureSummary?.replace(/^Fix (any|all) of the following:\s*/, '').split('\n')[0].trim(), selector: n.target.join(' '), tag: tag.length > 160 ? `${tag.slice(0, 157)}…>` : tag, contrast: v.id === 'color-contrast' ? n.any[0]?.data : undefined })
280
+ }
281
+ }
282
+ return found
283
+ } finally {
284
+ off()
285
+ await browser.send('Target.closeTarget', { targetId }).catch(() => {})
286
+ }
287
+ }
288
+
289
+ const say = {
290
+ L001: f => `${f.tag} (\`${f.selector}\`) has no accessible name (${f.rule}): a screen reader announces only its role. When the source has text, CSS hid it at this width.`,
291
+ L002: f => `${f.tag} (\`${f.selector}\`) is an image with no text alternative (${f.rule}): a screen reader reads its file name.`,
292
+ L003: f => `${f.tag} (\`${f.selector}\`): ${f.contrast ? `${f.contrast.fgColor} on ${f.contrast.bgColor} is ${f.contrast.contrastRatio}:1 at ${f.contrast.fontSize}, under ${f.contrast.expectedContrastRatio}` : 'its text contrast is too low'}.`,
293
+ L004: f => `${f.tag} (\`${f.selector}\`): ${f.help} (axe \`${f.rule}\`).`,
294
+ L031: f => `The layout moved by ${f.cls} (CLS) while the page loaded, with no one touching it${f.moved.length ? `: ${f.moved.join(', ')} moved` : ''}. Over 0.1 costs Lighthouse's Performance score.`,
295
+ L010: f => `Console error${f.at ? ` at ${f.at}` : ''}: ${f.text}`,
296
+ L011: f => (f.at === '/favicon.ico' ? 'The page names no icon, so the browser asked for /favicon.ico and got a 404: Lighthouse counts it as a console error. Add `<link rel="icon">` through `site.head`, or a `public/favicon.ico`.' : `The browser logged an error${f.at ? ` for ${f.at}` : ''}: ${f.text}`),
297
+ L012: f => `A deprecated browser API is in use${f.at ? ` at ${f.at}` : ''}: ${f.text}. Lighthouse marks Best Practices down for it.`,
298
+ L013: f => `Chrome logged a ${f.text} issue for the page. Lighthouse marks Best Practices down for it.`,
299
+ L020: () => 'The page has no `<title>`: search results and tabs show its URL.',
300
+ L021: () => 'The page has no meta description: search results make one up from the page.',
301
+ L022: () => '`<html>` has no `lang`: screen readers guess the language, and translation offers go wrong.',
302
+ L023: () => 'No `<meta name="viewport" content="width=device-width, initial-scale=1">`: a phone renders the page at 980px and shrinks it.',
303
+ L024: f => `Search engines are told not to index this page, by ${f.text}.${f.header ? ' Hosts add this header to preview and permalink URLs (Netlify, Vercel): on the production URL it is gone.' : ''}`,
304
+ L025: f => `The page answered ${f.text}.`,
305
+ L030: f => `The page scrolls sideways on a phone: it is ${f.text} wider than the screen.${f.wide?.length ? ` Past the edge: ${f.wide.join(', ')}` : ''}`,
306
+ }
307
+ /** A page may ask not to be indexed, and a host's preview URL does on its behalf: L024 says so without failing. */
308
+ const SEVERITY = { L021: 'warning', L024: 'warning', L030: 'warning', L031: 'warning' }
309
+
310
+ /**
311
+ * @param {string} root project root
312
+ * @param {{ url?: string, dir?: string, routes?: string[], all?: boolean, max?: number, forms?: (keyof FORMS)[], concurrency?: number }} [options]
313
+ * `url`: audit a running site (a deploy, `weft start`); else `dir`, a build's public directory (found when left out)
314
+ * @returns {Promise<{ ok: boolean, skipped?: string, base: string, pages: string[], diagnostics: object[], ms: number }>}
315
+ */
316
+ export async function audit(root, options = {}) {
317
+ const t = performance.now()
318
+ const exe = findChrome()
319
+ const forms = options.forms ?? ['phone', 'desktop']
320
+ const skip = why => ({ ok: true, skipped: why, base: options.url ?? '', pages: [], diagnostics: [diagnostic({ code: 'L000', severity: 'warning', file: 'weft.config.ts', why })], ms: 0 })
321
+ if (!exe) return skip('No Chrome or Chromium here, so the built pages were not audited (L001–L030). Set `WEFT_CHROME`, or run `weft audit --url <deploy>` where there is one.')
322
+ let base = options.url?.replace(/\/$/, '')
323
+ let server
324
+ let routes = options.routes
325
+ if (!base) {
326
+ const dir = options.dir ?? builtPublic(root)
327
+ if (!dir) return skip('No built pages to audit: run `weft build` first, or pass `--url`.')
328
+ const pages = pagesIn(dir)
329
+ if (!pages.length) return skip(`No prerendered pages in ${relative(root, dir)} to audit: pass \`--url\` to audit a running server.`)
330
+ routes ??= options.all ? pages : samplePages(pages, options.max)
331
+ server = await serveStatic(dir)
332
+ base = server.url
333
+ }
334
+ routes ??= ['/']
335
+ const served = appRoutes(root)
336
+ const browser = await chrome(exe)
337
+ const findings = new Map()
338
+ try {
339
+ const jobs = routes.flatMap(route => forms.map(form => ({ route, form })))
340
+ let i = 0
341
+ const worker = async () => {
342
+ for (let job; (job = jobs[i++]);) {
343
+ for (const f of await auditPage(browser, `${base}${job.route}`, job.form, !!server, served)) {
344
+ const key = `${f.code} ${f.key}`
345
+ const seen = findings.get(key) ?? { ...f, routes: new Set(), forms: new Set() }
346
+ seen.routes.add(job.route)
347
+ seen.forms.add(job.form)
348
+ findings.set(key, seen)
349
+ }
350
+ }
351
+ }
352
+ await Promise.all(Array.from({ length: Math.min(options.concurrency ?? 4, jobs.length) }, worker))
353
+ } finally {
354
+ browser.close()
355
+ server?.close()
356
+ }
357
+ const diagnostics = [...findings.values()].map(f => {
358
+ const pages = routes.filter(r => f.routes.has(r))
359
+ const on = `${forms.filter(x => f.forms.has(x)).join(' and ')}, ${pages.length === 1 ? pages[0] : `${pages.length} pages (${pages.slice(0, 3).join(', ')}${pages.length > 3 ? ', …' : ''})`}`
360
+ const severity = f.code === 'L031' && f.cls > 0.1 ? 'error' : SEVERITY[f.code] ?? 'error'
361
+ // an axe rule outside L001–L003 carries its own fix: the rule's first remedy, and its page
362
+ const help = f.code === 'L004' ? `${f.fix ? `${f.fix}. ` : ''}${WARP[f.rule] ?? ''}The rule: https://dequeuniversity.com/rules/axe/4.14/${f.rule}` : undefined
363
+ return diagnostic({ code: f.code, severity, file: pages[0], why: `${say[f.code](f)} (${on})`, help })
364
+ }).sort((a, b) => (a.severity === b.severity ? a.code.localeCompare(b.code) : a.severity === 'error' ? -1 : 1))
365
+ return { ok: !diagnostics.some(d => d.severity === 'error'), base: options.url ?? base, pages: routes, diagnostics, ms: Math.round(performance.now() - t) }
366
+ }
@@ -0,0 +1,34 @@
1
+ // `weft browser <command>`: JSON on stdout with --json, a readable form on stderr otherwise.
2
+
3
+ import { browser, browserUsage, cliParams } from './commands.mjs'
4
+
5
+ export { browserUsage }
6
+
7
+ export async function browserCommand(args, { print, say, io }) {
8
+ const [cmd, ...words] = args.positional
9
+ if (!cmd || cmd === 'help') {
10
+ say(browserUsage)
11
+ return cmd ? 0 : 2
12
+ }
13
+ try {
14
+ const result = await browser(args.root, cmd, cliParams(cmd, words, args.flags), { url: args.flags.url, tab: args.flags.tab })
15
+ if (args.json) print(result)
16
+ else io.stdout.write(`${human(cmd, result)}\n`)
17
+ return result?.ok === false ? 1 : 0
18
+ } catch (error) {
19
+ if (error.code === 'E_USAGE') throw error
20
+ if (args.json) print({ ok: false, error: { code: error.code ?? 'E_INTERNAL', message: error.message, tabs: error.tabs } })
21
+ say(`weft browser ${cmd}: ${error.message}`)
22
+ return 1
23
+ }
24
+ }
25
+
26
+ function human(cmd, r) {
27
+ const text = v => (typeof v === 'string' ? v : v?.path ? `(${v.chars} chars in ${v.path})\n${v.head}` : '')
28
+ if (cmd === 'snapshot') return [`${r.title} ${r.url}`, r.delta ?? text(r.tree), r.graph ? `\n# graph\n${text(r.graph)}` : '', r.errors?.length ? `\n# errors\n${r.errors.map(e => `${e.message}${e.at ? ` (at ${e.at})` : ''}`).join('\n')}` : ''].filter(Boolean).join('\n')
29
+ if (cmd === 'act') return `${r.target}: ${r.changes}`
30
+ if (cmd === 'assert') return `${r.ok ? 'ok' : 'FAIL'} ${r.target} ${JSON.stringify(r.expected)}${r.ok ? '' : `, got ${JSON.stringify(r.actual)}`}`
31
+ if (cmd === 'graph') return text(r.graph)
32
+ if (cmd === 'record' && r.code) return r.code
33
+ return JSON.stringify(r, null, 2)
34
+ }
@@ -0,0 +1,132 @@
1
+ // The `weft browser` transport: commands go to the dev server, which relays them to an open tab of the app (@weft-js/core/agent
2
+ // in the page). No browser automation library: the page is whatever browser the agent or person already has open. When
3
+ // there is none (CI, a sandbox), `launch` starts a plain headless Chrome on the app as a host page, and `screenshot`
4
+ // reads it over the DevTools protocol.
5
+
6
+ import { spawn } from 'node:child_process'
7
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
8
+ import { join } from 'node:path'
9
+ import { loadConfig } from '../load-config.mjs'
10
+
11
+ /** The dev server: `--url`, else what the weft() plugin wrote to `.weft/dev.json`, else `environments.dev.url`. */
12
+ export async function devUrl(root, given) {
13
+ if (given) return given.replace(/\/$/, '')
14
+ const file = join(root, '.weft', 'dev.json')
15
+ if (existsSync(file)) {
16
+ try { return JSON.parse(readFileSync(file, 'utf8')).url.replace(/\/$/, '') } catch {}
17
+ }
18
+ const { config } = await loadConfig(root).catch(() => ({ config: {} }))
19
+ return (config.environments?.dev?.url ?? 'http://localhost:5173').replace(/\/$/, '')
20
+ }
21
+
22
+ const fail = (code, message, extra) => Object.assign(new Error(message), { code, ...extra })
23
+
24
+ async function request(url, init) {
25
+ try {
26
+ const res = await fetch(`${url}/__weft/agent`, { ...init, headers: { 'x-weft-agent': '1', 'content-type': 'application/json' } })
27
+ if (res.status === 404) throw fail('E_NO_BRIDGE', `${url} is not a weft dev server (no /__weft/agent): serve the app with the weft() Vite plugin`)
28
+ return await res.json()
29
+ } catch (e) {
30
+ if (e.code) throw e
31
+ throw fail('E_NO_DEV', `no dev server answers at ${url}: start it (vite), or pass --url`)
32
+ }
33
+ }
34
+
35
+ /** Run one command in a tab (the focused one, else the most recent). */
36
+ export async function call(root, cmd, args = {}, { url, tab, timeout } = {}) {
37
+ const base = await devUrl(root, url)
38
+ const out = await request(base, { method: 'POST', body: JSON.stringify({ cmd, args, tab, timeout }) })
39
+ if (!out.ok) throw fail(out.code ?? 'E_PAGE', out.error, { tabs: out.tabs })
40
+ return out.result
41
+ }
42
+
43
+ export async function tabs(root, { url } = {}) {
44
+ const base = await devUrl(root, url)
45
+ const out = await request(base, { method: 'GET' })
46
+ return { url: base, agent: out.agent, tabs: out.tabs }
47
+ }
48
+
49
+ /** Wait until a tab is connected (after `launch` or `navigate`). */
50
+ export async function waitForTab(root, { url, ms = 15_000 } = {}) {
51
+ const until = Date.now() + ms
52
+ for (;;) {
53
+ try {
54
+ const t = await tabs(root, { url })
55
+ if (t.tabs.length) return t
56
+ } catch (e) { if (Date.now() > until) throw e }
57
+ if (Date.now() > until) throw fail('E_NO_TAB', 'no tab connected in time')
58
+ await new Promise(r => { setTimeout(r, 150) })
59
+ }
60
+ }
61
+
62
+ // ---- a host page when the agent has no browser ------------------------------------------------------------------
63
+
64
+ const chromes = {
65
+ darwin: ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium', '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'],
66
+ linux: ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/snap/bin/chromium'],
67
+ win32: ['C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe'],
68
+ }
69
+
70
+ export function findChrome() {
71
+ const env = process.env.WEFT_CHROME ?? process.env.CHROME_PATH
72
+ if (env && existsSync(env)) return env
73
+ return (chromes[process.platform] ?? []).find(p => existsSync(p)) ?? null
74
+ }
75
+
76
+ const stateFile = root => join(root, '.weft', 'browser', 'chrome.json')
77
+
78
+ /** Start a headless Chrome on `route` (a plain page; @weft-js/core/agent in it connects back to the dev server). */
79
+ export async function launch(root, route = '/', { url, headed = false } = {}) {
80
+ const exe = findChrome()
81
+ if (!exe) throw fail('E_NO_CHROME', 'no Chrome or Chromium found; open the app in your own browser instead, or set WEFT_CHROME')
82
+ close(root)
83
+ const base = await devUrl(root, url)
84
+ const profile = join(root, '.weft', 'browser', 'profile')
85
+ mkdirSync(profile, { recursive: true })
86
+ const args = [`--user-data-dir=${profile}`, '--remote-debugging-port=0', '--no-first-run', '--no-default-browser-check', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', ...(headed ? [] : ['--headless=new']), ...(process.getuid?.() === 0 ? ['--no-sandbox'] : []) /* Chrome won't start as root with its sandbox: a container, CI */, `${base}${route.startsWith('/') ? route : `/${route}`}`]
87
+ const child = spawn(exe, args, { detached: true, stdio: 'ignore' })
88
+ child.unref()
89
+ writeFileSync(stateFile(root), JSON.stringify({ pid: child.pid, profile, exe }))
90
+ const t = await waitForTab(root, { url })
91
+ return { launched: true, pid: child.pid, headless: !headed, ...t }
92
+ }
93
+
94
+ export function close(root) {
95
+ const file = stateFile(root)
96
+ if (!existsSync(file)) return { closed: false }
97
+ const { pid } = JSON.parse(readFileSync(file, 'utf8'))
98
+ try { process.kill(pid) } catch {}
99
+ rmSync(file, { force: true })
100
+ return { closed: true, pid }
101
+ }
102
+
103
+ /** A PNG of the launched Chrome's page (DevTools protocol). An agent's own browser takes its own screenshots. */
104
+ export async function screenshot(root, out) {
105
+ const file = stateFile(root)
106
+ if (!existsSync(file)) throw fail('E_NO_CHROME', 'screenshot needs `weft browser launch`; in your own browser, use its screenshot tool')
107
+ const { profile } = JSON.parse(readFileSync(file, 'utf8'))
108
+ const [port] = readFileSync(join(profile, 'DevToolsActivePort'), 'utf8').split('\n')
109
+ const targets = await (await fetch(`http://127.0.0.1:${port}/json`)).json()
110
+ const page = targets.find(t => t.type === 'page')
111
+ if (!page) throw fail('E_NO_TAB', 'the launched Chrome has no page')
112
+ const ws = new WebSocket(page.webSocketDebuggerUrl)
113
+ await new Promise((ok, no) => { ws.onopen = ok; ws.onerror = () => no(fail('E_CDP', 'could not reach the page over the DevTools protocol')) })
114
+ const data = await new Promise((ok, no) => {
115
+ ws.onmessage = e => { const m = JSON.parse(e.data); if (m.id !== 1) return; if (m.error) no(fail('E_CDP', m.error.message)); else ok(m.result.data) }
116
+ ws.send(JSON.stringify({ id: 1, method: 'Page.captureScreenshot', params: { format: 'png' } }))
117
+ })
118
+ ws.close()
119
+ const path = out ?? join(root, '.weft', 'browser', `screenshot-${Date.now()}.png`)
120
+ mkdirSync(join(path, '..'), { recursive: true })
121
+ writeFileSync(path, Buffer.from(data, 'base64'))
122
+ return { path }
123
+ }
124
+
125
+ /** Big payloads go to a file; the reply holds the path (research 05: snapshots pile up in context). */
126
+ export function spill(root, name, text, limit = 8000) {
127
+ if (typeof text !== 'string' || text.length <= limit) return text
128
+ const path = join(root, '.weft', 'browser', `${name}-${Date.now()}.txt`)
129
+ mkdirSync(join(path, '..'), { recursive: true })
130
+ writeFileSync(path, text)
131
+ return { path, chars: text.length, head: `${text.slice(0, 1200)}\n…` }
132
+ }