@weft-js/cli 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 (171) hide show
  1. package/README.md +38 -14
  2. package/bin/weft.mjs +8 -1
  3. package/docs/async.md +16 -6
  4. package/docs/browser.md +22 -8
  5. package/docs/config.md +39 -9
  6. package/docs/core.md +30 -0
  7. package/docs/deploy.md +33 -8
  8. package/docs/design.md +12 -3
  9. package/docs/entrypoints.md +79 -0
  10. package/docs/fonts.md +12 -2
  11. package/docs/icons.md +13 -7
  12. package/docs/lists.md +3 -1
  13. package/docs/meta.md +13 -3
  14. package/docs/plugins.md +32 -9
  15. package/docs/props.md +13 -2
  16. package/docs/reactivity.md +26 -0
  17. package/docs/rules.md +19 -12
  18. package/docs/rust.md +47 -17
  19. package/docs/start-short.md +3 -2
  20. package/docs/start.md +226 -52
  21. package/docs/state.md +33 -7
  22. package/docs/style.md +31 -8
  23. package/docs/surface.md +61 -15
  24. package/docs/testing.md +15 -5
  25. package/docs/theme.md +182 -0
  26. package/docs/ui-state.md +19 -6
  27. package/docs/verify.md +13 -0
  28. package/package.json +13 -4
  29. package/src/add/config.mjs +93 -0
  30. package/src/add/errors.mjs +2 -0
  31. package/src/add/setup.mjs +88 -0
  32. package/src/add-rust.mjs +82 -27
  33. package/src/add.mjs +209 -181
  34. package/src/agents-md.mjs +144 -43
  35. package/src/alias.mjs +16 -5
  36. package/src/audit/page.mjs +313 -0
  37. package/src/audit/report.mjs +69 -0
  38. package/src/audit/utils.mjs +5 -0
  39. package/src/audit.mjs +159 -206
  40. package/src/browser/cli.mjs +24 -5
  41. package/src/browser/client.mjs +189 -25
  42. package/src/browser/commands.mjs +166 -51
  43. package/src/catalog/audit.mjs +129 -0
  44. package/src/catalog/auth.mjs +40 -0
  45. package/src/catalog/compiler.mjs +337 -0
  46. package/src/catalog/contracts.mjs +18 -0
  47. package/src/catalog/database.mjs +52 -0
  48. package/src/catalog/deploy.mjs +117 -0
  49. package/src/catalog/design.mjs +159 -0
  50. package/src/catalog/forms.mjs +25 -0
  51. package/src/catalog/hydration.mjs +29 -0
  52. package/src/catalog/icons.mjs +24 -0
  53. package/src/catalog/media.mjs +163 -0
  54. package/src/catalog/metadata.mjs +116 -0
  55. package/src/catalog/plugins.mjs +228 -0
  56. package/src/catalog/realtime.mjs +195 -0
  57. package/src/catalog/rust.mjs +75 -0
  58. package/src/catalog/server-functions.mjs +92 -0
  59. package/src/catalog/state.mjs +39 -0
  60. package/src/catalog/style-groups.mjs +54 -0
  61. package/src/catalog/tests.mjs +127 -0
  62. package/src/catalog/text.mjs +200 -0
  63. package/src/catalog/ui-state.mjs +42 -0
  64. package/src/catalog.mjs +67 -460
  65. package/src/check/runtime.mjs +280 -0
  66. package/src/check.mjs +198 -208
  67. package/src/cli/authoring.mjs +213 -0
  68. package/src/cli/project.mjs +302 -0
  69. package/src/cli.mjs +222 -250
  70. package/src/config.mjs +1 -1
  71. package/src/contributions.mjs +75 -25
  72. package/src/db-layer.mjs +13 -5
  73. package/src/deploy/cdn.mjs +94 -16
  74. package/src/deploy/files.mjs +100 -21
  75. package/src/deploy/fit.mjs +127 -23
  76. package/src/deploy/hosts.mjs +90 -20
  77. package/src/deploy/index/cli.mjs +20 -0
  78. package/src/deploy/index/environment.mjs +69 -0
  79. package/src/deploy/index/steps.mjs +87 -0
  80. package/src/deploy/index.mjs +228 -114
  81. package/src/deploy/needs.mjs +88 -31
  82. package/src/design-checks/accessible-names.mjs +62 -0
  83. package/src/design-checks/contrast.mjs +43 -0
  84. package/src/design-checks/csp.mjs +60 -0
  85. package/src/design-checks/files.mjs +59 -0
  86. package/src/design-checks/heading-links.mjs +61 -0
  87. package/src/design-checks/interaction.mjs +65 -0
  88. package/src/design-checks/jsx.mjs +67 -0
  89. package/src/design-checks/layout-values.mjs +68 -0
  90. package/src/design-checks/native-controls.mjs +48 -0
  91. package/src/design-checks/outline.mjs +39 -0
  92. package/src/design-checks/reporter.mjs +37 -0
  93. package/src/design-checks/rules.mjs +274 -0
  94. package/src/design-checks/values.mjs +262 -0
  95. package/src/design-checks.mjs +10 -400
  96. package/src/diag.mjs +32 -8
  97. package/src/docs.mjs +138 -43
  98. package/src/doctor.mjs +20 -8
  99. package/src/files.mjs +18 -5
  100. package/src/fix.mjs +10 -2
  101. package/src/gate/after.mjs +40 -0
  102. package/src/gate/before.mjs +54 -0
  103. package/src/gate/classify.mjs +72 -0
  104. package/src/gate/mutation.mjs +209 -0
  105. package/src/gate/resolve-cases.mjs +22 -0
  106. package/src/gate/result.mjs +63 -0
  107. package/src/gate/run.mjs +75 -0
  108. package/src/gate.mjs +104 -202
  109. package/src/infer/box-props.cjs +140 -0
  110. package/src/infer/core-boxes.cjs +46 -0
  111. package/src/infer/derived-writes.cjs +95 -0
  112. package/src/infer/engine/checks.cjs +193 -0
  113. package/src/infer/engine/flow.cjs +293 -0
  114. package/src/infer/engine/report.cjs +158 -0
  115. package/src/infer/engine/utils.cjs +15 -0
  116. package/src/infer/engine.cjs +108 -368
  117. package/src/infer/facts/sites.cjs +114 -0
  118. package/src/infer/facts.cjs +210 -117
  119. package/src/infer/hosts.cjs +69 -22
  120. package/src/infer/lsp.mjs +149 -40
  121. package/src/infer/session.cjs +73 -17
  122. package/src/infer/shadow.cjs +13 -5
  123. package/src/infer/worker.cjs +57 -9
  124. package/src/jsx-scan.mjs +63 -21
  125. package/src/load-config/schema.mjs +281 -0
  126. package/src/load-config.mjs +137 -138
  127. package/src/map.mjs +93 -26
  128. package/src/mcp/browser-tools.mjs +155 -0
  129. package/src/mcp.mjs +147 -69
  130. package/src/meta-table.mjs +43 -8
  131. package/src/mutate.mjs +120 -36
  132. package/src/plugin-checks.mjs +52 -23
  133. package/src/plugins.mjs +125 -39
  134. package/src/project.mjs +170 -79
  135. package/src/props-checks.mjs +24 -6
  136. package/src/props-listener.mjs +27 -9
  137. package/src/react-checks.mjs +35 -4
  138. package/src/server-state-checks.mjs +34 -0
  139. package/src/share-checks.mjs +121 -37
  140. package/src/site.mjs +33 -6
  141. package/src/slots.mjs +104 -19
  142. package/src/start/build.mjs +116 -0
  143. package/src/start/routes.mjs +38 -0
  144. package/src/start.mjs +193 -95
  145. package/src/state-checks/core-state.mjs +223 -0
  146. package/src/state-checks/modules.mjs +17 -0
  147. package/src/state-checks/scan.mjs +232 -0
  148. package/src/state-checks/server-graph.mjs +65 -0
  149. package/src/state-checks.mjs +109 -133
  150. package/src/style/find.mjs +121 -0
  151. package/src/style.mjs +133 -127
  152. package/src/surface-checks.mjs +212 -46
  153. package/src/tailwind.mjs +5 -4
  154. package/src/tasks.mjs +189 -27
  155. package/src/test-rules/context.mjs +109 -0
  156. package/src/test-rules/file-rules.mjs +279 -0
  157. package/src/test-rules/rule-helpers.mjs +244 -0
  158. package/src/test-rules/syntax.mjs +223 -0
  159. package/src/test-rules/test-rules.mjs +127 -0
  160. package/src/testdiff.mjs +94 -32
  161. package/src/tests.mjs +31 -483
  162. package/src/text-checks.mjs +229 -69
  163. package/src/tokens.mjs +12 -4
  164. package/src/trust/scan.mjs +95 -0
  165. package/src/trust.mjs +214 -102
  166. package/src/weft.mjs +41 -9
  167. package/src/workspace.mjs +96 -19
  168. package/ts-plugin/context.js +57 -0
  169. package/ts-plugin/index.js +4 -124
  170. package/ts-plugin/mapping.js +65 -0
  171. package/ts-plugin/proxy.js +144 -0
@@ -0,0 +1,69 @@
1
+ import { diagnostic } from '../diag.mjs'
2
+
3
+ /** What a rule means in a warp app, where warp is how it usually happens. */
4
+ const WARP = {
5
+ 'heading-order':
6
+ "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. ",
7
+ }
8
+
9
+ const say = {
10
+ L001: (f) =>
11
+ `${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.`,
12
+ L002: (f) =>
13
+ `${f.tag} (\`${f.selector}\`) is an image with no text alternative (${f.rule}): a screen reader reads its file name.`,
14
+ L003: (f) =>
15
+ `${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'}.`,
16
+ L004: (f) => `${f.tag} (\`${f.selector}\`): ${f.help} (axe \`${f.rule}\`).`,
17
+ L031: (f) =>
18
+ `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.`,
19
+ L010: (f) => `Console error${f.at ? ` at ${f.at}` : ''}: ${f.text}`,
20
+ L011: (f) =>
21
+ f.at === '/favicon.ico'
22
+ ? '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`.'
23
+ : `The browser logged an error${f.at ? ` for ${f.at}` : ''}: ${f.text}`,
24
+ L012: (f) =>
25
+ `A deprecated browser API is in use${f.at ? ` at ${f.at}` : ''}: ${f.text}. Lighthouse marks Best Practices down for it.`,
26
+ L013: (f) =>
27
+ `Chrome logged a ${f.text} issue for the page. Lighthouse marks Best Practices down for it.`,
28
+ L020: () => 'The page has no `<title>`: search results and tabs show its URL.',
29
+ L021: () => 'The page has no meta description: search results make one up from the page.',
30
+ L022: () =>
31
+ '`<html>` has no `lang`: screen readers guess the language, and translation offers go wrong.',
32
+ L023: () =>
33
+ 'No `<meta name="viewport" content="width=device-width, initial-scale=1">`: a phone renders the page at 980px and shrinks it.',
34
+ L024: (f) =>
35
+ `Search engines are told not to index this page, by ${f.text}.${f.header ? ' The audited endpoint serves this header: check its host or proxy rules as well as the page head.' : ''}`,
36
+ L025: (f) => `The page answered ${f.text}.`,
37
+ L030: (f) =>
38
+ `The page scrolls sideways on a phone: it is ${f.text} wider than the screen.${f.wide?.length ? ` Past the edge: ${f.wide.join(', ')}` : ''}`,
39
+ }
40
+ /** A page may ask not to be indexed, and a host's preview URL does on its behalf: L024 says so without failing. */
41
+ const SEVERITY = { L021: 'warning', L024: 'warning', L030: 'warning', L031: 'warning' }
42
+
43
+ export function reportFindings(findings, routes, forms, indexing = 'auto') {
44
+ const diagnostics = [...findings.values()]
45
+ .map((f) => {
46
+ const pages = routes.filter((r) => f.routes.has(r))
47
+ 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 ? ', …' : ''})`}`
48
+ const severity =
49
+ (f.code === 'L031' && f.cls > 0.1) || (f.code === 'L024' && indexing === 'public')
50
+ ? 'error'
51
+ : (SEVERITY[f.code] ?? 'error')
52
+ // an axe rule outside L001–L003 carries its own fix: the rule's first remedy, and its page
53
+ const help =
54
+ f.code === 'L004'
55
+ ? `${f.fix ? `${f.fix}. ` : ''}${WARP[f.rule] ?? ''}The rule: https://dequeuniversity.com/rules/axe/4.14/${f.rule}`
56
+ : undefined
57
+ return diagnostic({
58
+ code: f.code,
59
+ severity,
60
+ file: pages[0],
61
+ why: `${say[f.code](f)} (${on})`,
62
+ help,
63
+ })
64
+ })
65
+ .sort((a, b) =>
66
+ a.severity === b.severity ? a.code.localeCompare(b.code) : a.severity === 'error' ? -1 : 1,
67
+ )
68
+ return diagnostics
69
+ }
@@ -0,0 +1,5 @@
1
+ export const fail = (code, message) => Object.assign(new Error(message), { code })
2
+ export const sleep = (ms) =>
3
+ new Promise((r) => {
4
+ setTimeout(r, ms)
5
+ })
package/src/audit.mjs CHANGED
@@ -11,9 +11,12 @@ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync }
11
11
  import { createServer } from 'node:http'
12
12
  import { tmpdir } from 'node:os'
13
13
  import { extname, join, relative, resolve, sep } from 'node:path'
14
- import { fileURLToPath } from 'node:url'
15
14
  import { findChrome } from './browser/client.mjs'
16
15
  import { diagnostic } from './diag.mjs'
16
+ import { fail, sleep } from './audit/utils.mjs'
17
+ import { auditPage } from './audit/page.mjs'
18
+ import { reportFindings } from './audit/report.mjs'
19
+ import { loadConfig } from './load-config.mjs'
17
20
 
18
21
  /** The two widths Lighthouse's defaults stand for: its mobile run (a Moto G Power) and its desktop run. */
19
22
  export const FORMS = {
@@ -21,46 +24,44 @@ export const FORMS = {
21
24
  desktop: { width: 1350, height: 940, deviceScaleFactor: 1, mobile: false },
22
25
  }
23
26
 
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',
27
+ const TYPES = {
28
+ '.html': 'text/html; charset=utf-8',
29
+ '.js': 'text/javascript',
30
+ '.mjs': 'text/javascript',
31
+ '.css': 'text/css',
32
+ '.json': 'application/json',
33
+ '.svg': 'image/svg+xml',
34
+ '.png': 'image/png',
35
+ '.jpg': 'image/jpeg',
36
+ '.jpeg': 'image/jpeg',
37
+ '.webp': 'image/webp',
38
+ '.avif': 'image/avif',
39
+ '.gif': 'image/gif',
40
+ '.ico': 'image/x-icon',
41
+ '.woff2': 'font/woff2',
42
+ '.woff': 'font/woff',
43
+ '.txt': 'text/plain; charset=utf-8',
44
+ '.xml': 'application/xml',
45
+ '.wasm': 'application/wasm',
46
+ '.webmanifest': 'application/manifest+json',
43
47
  }
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
48
 
54
49
  /** The public directory a build wrote: the newer of `.output/public` (server presets) and `dist` (Netlify, Vercel static). */
55
50
  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
51
+ const found = ['.output/public', 'dist']
52
+ .map((d) => join(root, d))
53
+ .filter((d) => existsSync(join(d, 'index.html')))
54
+ return (
55
+ found.sort(
56
+ (a, b) => statSync(join(b, 'index.html')).mtimeMs - statSync(join(a, 'index.html')).mtimeMs,
57
+ )[0] ?? null
58
+ )
58
59
  }
59
60
 
60
61
  /** Every prerendered page under `dir`, as its URL path. */
61
62
  export function pagesIn(dir) {
62
63
  const out = []
63
- const walk = d => {
64
+ const walk = (d) => {
64
65
  for (const e of readdirSync(d, { withFileTypes: true })) {
65
66
  if (e.name.startsWith('.') || e.name.startsWith('_')) continue
66
67
  const f = join(d, e.name)
@@ -72,7 +73,7 @@ export function pagesIn(dir) {
72
73
  }
73
74
  }
74
75
  walk(dir)
75
- return out.sort()
76
+ return out.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
76
77
  }
77
78
 
78
79
  /**
@@ -94,36 +95,69 @@ export function serveStatic(dir) {
94
95
  dir = resolve(dir)
95
96
  const server = createServer((req, res) => {
96
97
  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())
98
+ const tries = [path, `${path}/index.html`, `${path.replace(/\/$/, '')}.html`].map((p) =>
99
+ resolve(dir, `.${p}`),
100
+ )
101
+ const file = tries.find(
102
+ (f) => (f === dir || f.startsWith(dir + sep)) && existsSync(f) && statSync(f).isFile(),
103
+ )
99
104
  const missing = join(dir, '404.html')
100
105
  if (!file) {
101
106
  res.writeHead(404, { 'content-type': TYPES['.html'] })
102
107
  // 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>')
108
+ return res.end(
109
+ existsSync(missing)
110
+ ? readFileSync(missing)
111
+ : '<!doctype html><title>Not found</title><p>Not found</p>',
112
+ )
104
113
  }
105
114
  res.writeHead(200, { 'content-type': TYPES[extname(file)] ?? 'application/octet-stream' })
106
115
  res.end(readFileSync(file))
107
116
  })
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() })) })
117
+ return new Promise((ok) => {
118
+ server.listen(0, '127.0.0.1', () =>
119
+ ok({ url: `http://127.0.0.1:${server.address().port}`, close: () => server.close() }),
120
+ )
121
+ })
109
122
  }
110
123
 
111
124
  /** 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
125
  async function chrome(exe) {
113
126
  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' })
127
+ const child = spawn(
128
+ exe,
129
+ [
130
+ `--user-data-dir=${profile}`,
131
+ '--headless=new',
132
+ '--remote-debugging-port=0',
133
+ '--no-first-run',
134
+ '--no-default-browser-check',
135
+ '--disable-extensions',
136
+ '--disable-background-networking',
137
+ '--mute-audio',
138
+ ...(process.getuid?.() === 0 ? ['--no-sandbox'] : []),
139
+ 'about:blank',
140
+ ],
141
+ { stdio: 'ignore' },
142
+ )
115
143
  const portFile = join(profile, 'DevToolsActivePort')
116
144
  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`) }
145
+ if (i > 150 || child.exitCode !== null) {
146
+ child.kill()
147
+ throw fail('E_NO_CHROME', `Chrome at ${exe} didn't start`)
148
+ }
118
149
  await sleep(100)
119
150
  }
120
151
  const [port, path] = readFileSync(portFile, 'utf8').split('\n')
121
152
  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')) })
153
+ await new Promise((ok, no) => {
154
+ ws.onopen = ok
155
+ ws.onerror = () => no(fail('E_CDP', 'could not reach Chrome over the DevTools protocol'))
156
+ })
123
157
  let next = 0
124
158
  const waiting = new Map()
125
159
  const listeners = new Set()
126
- ws.onmessage = e => {
160
+ ws.onmessage = (e) => {
127
161
  const m = JSON.parse(e.data)
128
162
  if (m.id !== undefined) {
129
163
  const w = waiting.get(m.id)
@@ -132,38 +166,30 @@ async function chrome(exe) {
132
166
  else w?.ok(m.result)
133
167
  } else for (const on of listeners) on(m)
134
168
  }
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
- })
169
+ const send = (method, params = {}, sessionId) =>
170
+ new Promise((ok, no) => {
171
+ const id = ++next
172
+ waiting.set(id, { ok, no })
173
+ ws.send(JSON.stringify({ id, method, params, sessionId }))
174
+ })
140
175
  const close = () => {
141
- try { ws.close() } catch {}
176
+ try {
177
+ ws.close()
178
+ } catch {}
142
179
  child.kill()
143
180
  // Chrome holds the profile a moment after it's told to go.
144
181
  setTimeout(() => rmSync(profile, { recursive: true, force: true }), 500).unref()
145
182
  }
146
- return { send, on: fn => { listeners.add(fn); return () => listeners.delete(fn) }, close }
183
+ return {
184
+ send,
185
+ on: (fn) => {
186
+ listeners.add(fn)
187
+ return () => listeners.delete(fn)
188
+ },
189
+ close,
190
+ }
147
191
  }
148
192
 
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
193
  /**
168
194
  * The app's own pages as a test on a path (`src/routes`, `[id]` and `[...rest]` included). A build served as files has
169
195
  * only its prerendered pages: a request for one of the others is the server's to answer, not a missing file.
@@ -175,150 +201,66 @@ export function appRoutes(root) {
175
201
  const walk = (at, prefix) => {
176
202
  for (const e of readdirSync(at, { withFileTypes: true })) {
177
203
  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
204
+ if (e.isDirectory()) {
205
+ walk(join(at, e.name), name ? `${prefix}/${name}` : prefix)
206
+ continue
207
+ }
208
+ if (
209
+ !/\.[jt]sx?$/.test(e.name) ||
210
+ /\.(server|test|spec)\./.test(e.name) ||
211
+ e.name.startsWith('_')
212
+ )
213
+ continue
180
214
  const leaf = e.name.replace(/\.[jt]sx?$/, '')
181
215
  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, '(?:/.+)?')
216
+ const re = path
217
+ .split('/')
218
+ .map((seg) =>
219
+ /^\[\.\.\.\w+\]$/.test(seg)
220
+ ? '\0'
221
+ : /^\[\w+\]$/.test(seg)
222
+ ? '[^/]+'
223
+ : seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
224
+ )
225
+ .join('/')
226
+ .replace(/\/\0/g, '(?:/.+)?')
183
227
  tests.push(new RegExp(`^${re || ''}/?$`))
184
228
  }
185
229
  }
186
230
  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
- }
231
+ return (path) => tests.some((t) => t.test(path))
287
232
  }
288
233
 
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
234
  /**
311
235
  * @param {string} root project root
312
- * @param {{ url?: string, dir?: string, routes?: string[], all?: boolean, max?: number, forms?: (keyof FORMS)[], concurrency?: number }} [options]
236
+ * @param {{ url?: string, dir?: string, routes?: string[], all?: boolean, max?: number, forms?: (keyof FORMS)[], concurrency?: number, indexing?: 'auto' | 'public' | 'private' }} [options]
313
237
  * `url`: audit a running site (a deploy, `weft start`); else `dir`, a build's public directory (found when left out)
314
238
  * @returns {Promise<{ ok: boolean, skipped?: string, base: string, pages: string[], diagnostics: object[], ms: number }>}
315
239
  */
316
240
  export async function audit(root, options = {}) {
317
241
  const t = performance.now()
242
+ const loaded = options.indexing === undefined ? await loadConfig(root) : undefined
243
+ if (loaded?.diagnostics?.some((d) => d.severity === 'error'))
244
+ throw new Error('Fix weft.config before auditing, or pass --indexing explicitly')
245
+ const configured = loaded?.config?.build?.audit
246
+ const indexing =
247
+ options.indexing ?? (typeof configured === 'object' ? configured.indexing : undefined) ?? 'auto'
248
+ if (!['auto', 'public', 'private'].includes(indexing))
249
+ throw new Error('indexing must be auto, public or private')
318
250
  const exe = findChrome()
319
251
  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.')
252
+ const skip = (why) => ({
253
+ ok: true,
254
+ skipped: why,
255
+ base: options.url ?? '',
256
+ pages: [],
257
+ diagnostics: [diagnostic({ code: 'L000', severity: 'warning', file: 'weft.config.ts', why })],
258
+ ms: 0,
259
+ })
260
+ if (!exe)
261
+ return skip(
262
+ '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.',
263
+ )
322
264
  let base = options.url?.replace(/\/$/, '')
323
265
  let server
324
266
  let routes = options.routes
@@ -326,7 +268,10 @@ export async function audit(root, options = {}) {
326
268
  const dir = options.dir ?? builtPublic(root)
327
269
  if (!dir) return skip('No built pages to audit: run `weft build` first, or pass `--url`.')
328
270
  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.`)
271
+ if (!pages.length)
272
+ return skip(
273
+ `No prerendered pages in ${relative(root, dir)} to audit: pass \`--url\` to audit a running server.`,
274
+ )
330
275
  routes ??= options.all ? pages : samplePages(pages, options.max)
331
276
  server = await serveStatic(dir)
332
277
  base = server.url
@@ -336,11 +281,18 @@ export async function audit(root, options = {}) {
336
281
  const browser = await chrome(exe)
337
282
  const findings = new Map()
338
283
  try {
339
- const jobs = routes.flatMap(route => forms.map(form => ({ route, form })))
284
+ const jobs = routes.flatMap((route) => forms.map((form) => ({ route, form })))
340
285
  let i = 0
341
286
  const worker = async () => {
342
287
  for (let job; (job = jobs[i++]);) {
343
- for (const f of await auditPage(browser, `${base}${job.route}`, job.form, !!server, served)) {
288
+ for (const f of await auditPage({
289
+ browser,
290
+ url: `${base}${job.route}`,
291
+ form: job.form,
292
+ statics: !!server,
293
+ served,
294
+ forms: FORMS,
295
+ })) {
344
296
  const key = `${f.code} ${f.key}`
345
297
  const seen = findings.get(key) ?? { ...f, routes: new Set(), forms: new Set() }
346
298
  seen.routes.add(job.route)
@@ -349,18 +301,19 @@ export async function audit(root, options = {}) {
349
301
  }
350
302
  }
351
303
  }
352
- await Promise.all(Array.from({ length: Math.min(options.concurrency ?? 4, jobs.length) }, worker))
304
+ await Promise.all(
305
+ Array.from({ length: Math.min(options.concurrency ?? 1, jobs.length) }, worker),
306
+ )
353
307
  } finally {
354
308
  browser.close()
355
309
  server?.close()
356
310
  }
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) }
311
+ const diagnostics = reportFindings(findings, routes, forms, indexing)
312
+ return {
313
+ ok: !diagnostics.some((d) => d.severity === 'error'),
314
+ base: options.url ?? base,
315
+ pages: routes,
316
+ diagnostics,
317
+ ms: Math.round(performance.now() - t),
318
+ }
366
319
  }
@@ -11,23 +11,42 @@ export async function browserCommand(args, { print, say, io }) {
11
11
  return cmd ? 0 : 2
12
12
  }
13
13
  try {
14
- const result = await browser(args.root, cmd, cliParams(cmd, words, args.flags), { url: args.flags.url, tab: args.flags.tab })
14
+ const result = await browser(args.root, cmd, cliParams(cmd, words, args.flags), {
15
+ url: args.flags.url,
16
+ tab: args.flags.tab,
17
+ })
15
18
  if (args.json) print(result)
16
19
  else io.stdout.write(`${human(cmd, result)}\n`)
17
20
  return result?.ok === false ? 1 : 0
18
21
  } catch (error) {
19
22
  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 } })
23
+ if (args.json)
24
+ print({
25
+ ok: false,
26
+ error: { code: error.code ?? 'E_INTERNAL', message: error.message, tabs: error.tabs },
27
+ })
21
28
  say(`weft browser ${cmd}: ${error.message}`)
22
29
  return 1
23
30
  }
24
31
  }
25
32
 
26
33
  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')
34
+ const text = (v) =>
35
+ typeof v === 'string' ? v : v?.path ? `(${v.chars} chars in ${v.path})\n${v.head}` : ''
36
+ if (cmd === 'snapshot')
37
+ return [
38
+ `${r.title} ${r.url}`,
39
+ r.delta ?? text(r.tree),
40
+ r.graph ? `\n# graph\n${text(r.graph)}` : '',
41
+ r.errors?.length
42
+ ? `\n# errors\n${r.errors.map((e) => `${e.message}${e.at ? ` (at ${e.at})` : ''}`).join('\n')}`
43
+ : '',
44
+ ]
45
+ .filter(Boolean)
46
+ .join('\n')
29
47
  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)}`}`
48
+ if (cmd === 'assert')
49
+ return `${r.ok ? 'ok' : 'FAIL'} ${r.target} ${JSON.stringify(r.expected)}${r.ok ? '' : `, got ${JSON.stringify(r.actual)}`}`
31
50
  if (cmd === 'graph') return text(r.graph)
32
51
  if (cmd === 'record' && r.code) return r.code
33
52
  return JSON.stringify(r, null, 2)