@weft-js/cli 0.1.0 → 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 +108 -0
  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 +174 -188
  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 +68 -444
  65. package/src/check/runtime.mjs +280 -0
  66. package/src/check.mjs +199 -207
  67. package/src/cli/authoring.mjs +213 -0
  68. package/src/cli/project.mjs +302 -0
  69. package/src/cli.mjs +228 -224
  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 +142 -0
  74. package/src/deploy/files.mjs +266 -0
  75. package/src/deploy/fit.mjs +166 -0
  76. package/src/deploy/hosts.mjs +126 -0
  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 +387 -0
  81. package/src/deploy/needs.mjs +149 -0
  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 -42
  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 +144 -116
  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 +194 -92
  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,35 +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
- return res.end(existsSync(missing) ? readFileSync(missing) : 'not found')
107
+ // a doctype even here: a frame showing this would put Chrome in Quirks Mode, an issue of the audit's own making
108
+ return res.end(
109
+ existsSync(missing)
110
+ ? readFileSync(missing)
111
+ : '<!doctype html><title>Not found</title><p>Not found</p>',
112
+ )
103
113
  }
104
114
  res.writeHead(200, { 'content-type': TYPES[extname(file)] ?? 'application/octet-stream' })
105
115
  res.end(readFileSync(file))
106
116
  })
107
- 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
+ })
108
122
  }
109
123
 
110
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. */
111
125
  async function chrome(exe) {
112
126
  const profile = mkdtempSync(join(tmpdir(), 'weft-audit-'))
113
- 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
+ )
114
143
  const portFile = join(profile, 'DevToolsActivePort')
115
144
  for (let i = 0; !existsSync(portFile) || !readFileSync(portFile, 'utf8').includes('\n'); i++) {
116
- 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
+ }
117
149
  await sleep(100)
118
150
  }
119
151
  const [port, path] = readFileSync(portFile, 'utf8').split('\n')
120
152
  const ws = new WebSocket(`ws://127.0.0.1:${port}${path}`)
121
- 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
+ })
122
157
  let next = 0
123
158
  const waiting = new Map()
124
159
  const listeners = new Set()
125
- ws.onmessage = e => {
160
+ ws.onmessage = (e) => {
126
161
  const m = JSON.parse(e.data)
127
162
  if (m.id !== undefined) {
128
163
  const w = waiting.get(m.id)
@@ -131,162 +166,101 @@ async function chrome(exe) {
131
166
  else w?.ok(m.result)
132
167
  } else for (const on of listeners) on(m)
133
168
  }
134
- const send = (method, params = {}, sessionId) => new Promise((ok, no) => {
135
- const id = ++next
136
- waiting.set(id, { ok, no })
137
- ws.send(JSON.stringify({ id, method, params, sessionId }))
138
- })
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
+ })
139
175
  const close = () => {
140
- try { ws.close() } catch {}
176
+ try {
177
+ ws.close()
178
+ } catch {}
141
179
  child.kill()
142
180
  // Chrome holds the profile a moment after it's told to go.
143
181
  setTimeout(() => rmSync(profile, { recursive: true, force: true }), 500).unref()
144
182
  }
145
- return { send, on: fn => { listeners.add(fn); return () => listeners.delete(fn) }, close }
146
- }
147
-
148
- /** In the page: how far the layout moved without a person asking (CLS, summed), and the first elements that moved. */
149
- const SHIFT = `new Promise(ok => {
150
- let cls = 0
151
- const moved = []
152
- new PerformanceObserver(list => {
153
- for (const e of list.getEntries()) {
154
- if (e.hadRecentInput) continue
155
- cls += e.value
156
- for (const s of e.sources ?? []) {
157
- if (!s.node || s.node.nodeType !== 1 || moved.length === 3) continue
158
- const open = s.node.outerHTML.slice(0, s.node.outerHTML.indexOf('>') + 1)
159
- moved.push(open.length > 120 ? open.slice(0, 117) + '…>' : open)
160
- }
161
- }
162
- }).observe({ type: 'layout-shift', buffered: true })
163
- setTimeout(() => ok({ cls: Math.round(cls * 1000) / 1000, moved }), 50)
164
- })`
165
-
166
- /** In the page: what a crawler reads from the head, and what pokes past a phone's right edge. */
167
- const HEAD = `(() => {
168
- const meta = n => document.querySelector('meta[name="' + n + '"]')?.getAttribute('content')?.trim() ?? null
169
- const wide = []
170
- const edge = innerWidth + 1
171
- if (document.documentElement.scrollWidth > edge) {
172
- for (const el of document.body.querySelectorAll('*')) {
173
- const r = el.getBoundingClientRect()
174
- if (r.right > edge && r.width > 0 && getComputedStyle(el).position !== 'fixed' && !(el.parentElement && el.parentElement.getBoundingClientRect().right > edge)) {
175
- const open = el.outerHTML.slice(0, el.outerHTML.indexOf('>') + 1)
176
- wide.push(open.length > 120 ? open.slice(0, 117) + '…>' : open)
177
- if (wide.length === 3) break
178
- }
179
- }
180
- }
181
183
  return {
182
- title: document.title.trim(), lang: document.documentElement.getAttribute('lang') ?? '', description: meta('description'),
183
- viewport: meta('viewport'), robots: meta('robots'), scroll: document.documentElement.scrollWidth - innerWidth, wide,
184
+ send,
185
+ on: (fn) => {
186
+ listeners.add(fn)
187
+ return () => listeners.delete(fn)
188
+ },
189
+ close,
184
190
  }
185
- })()`
191
+ }
186
192
 
187
- /** Audit one page in one form factor: what Chrome said while it loaded, its head, its layout, and axe-core's rules. */
188
- async function auditPage(browser, url, form, statics) {
189
- const { targetId } = await browser.send('Target.createTarget', { url: 'about:blank' })
190
- const { sessionId } = await browser.send('Target.attachToTarget', { targetId, flatten: true })
191
- const send = (m, p) => browser.send(m, p, sessionId)
192
- const found = []
193
- let document = null
194
- let loaded
195
- const load = new Promise(ok => { loaded = ok })
196
- const where = (u, line) => (u ? `${u.replace(/^https?:\/\/127\.0\.0\.1:\d+/, '')}${line === undefined ? '' : `:${line + 1}`}` : '')
197
- const off = browser.on(m => {
198
- if (m.sessionId !== sessionId) return
199
- const p = m.params
200
- if (m.method === 'Page.loadEventFired') loaded()
201
- else if (m.method === 'Network.responseReceived' && p.type === 'Document' && !document) document = p.response
202
- else if (m.method === 'Runtime.exceptionThrown') {
203
- const d = p.exceptionDetails
204
- 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 })
205
- } else if (m.method === 'Runtime.consoleAPICalled' && (p.type === 'error' || p.type === 'assert')) {
206
- const text = p.args.map(a => a.value ?? a.description ?? '').join(' ').split('\n')[0]
207
- const top = p.stackTrace?.callFrames?.[0]
208
- found.push({ code: 'L010', key: text, at: where(top?.url, top?.lineNumber), text })
209
- } else if (m.method === 'Log.entryAdded' && p.entry.level === 'error') {
210
- // a build's pages served as files have no server: its realtime and function routes answer 404 here and nowhere else
211
- if (statics && /\/_weft\/(rt|ws|fn|data|m|x)\b/.test(`${p.entry.url ?? ''} ${p.entry.text}`)) return
212
- found.push({ code: 'L011', key: `${p.entry.url ?? ''} ${p.entry.text}`, at: where(p.entry.url), text: p.entry.text })
213
- } else if (m.method === 'Audits.issueAdded') {
214
- const { code, details } = p.issue
215
- if (code === 'DeprecationIssue') {
216
- const d = details.deprecationIssueDetails
217
- found.push({ code: 'L012', key: d.type, at: where(d.sourceCodeLocation?.url, d.sourceCodeLocation?.lineNumber), text: d.type })
218
- } else if (ISSUES.has(code)) found.push({ code: 'L013', key: code, text: code.replace(/Issue$/, '').replace(/([a-z])([A-Z])/g, '$1 $2') })
219
- }
220
- })
221
- try {
222
- const f = FORMS[form]
223
- await Promise.all(['Page.enable', 'Runtime.enable', 'Log.enable', 'Network.enable', 'Audits.enable'].map(m => send(m)))
224
- await send('Emulation.setDeviceMetricsOverride', { width: f.width, height: f.height, deviceScaleFactor: f.deviceScaleFactor, mobile: f.mobile })
225
- await send('Emulation.setTouchEmulationEnabled', { enabled: f.mobile, maxTouchPoints: f.mobile ? 5 : 1 })
226
- await send('Page.navigate', { url })
227
- await Promise.race([load, sleep(15_000)])
228
- await sleep(800) // hydration's errors, and a font swap's shift, land after load
229
- if (document && document.status >= 400) found.push({ code: 'L025', key: String(document.status), text: `HTTP ${document.status}` })
230
- const header = Object.entries(document?.headers ?? {}).find(([k]) => k.toLowerCase() === 'x-robots-tag')?.[1]
231
- const head = (await send('Runtime.evaluate', { expression: HEAD, returnByValue: true })).result.value
232
- if (!head.title) found.push({ code: 'L020', key: 'title' })
233
- if (!head.description) found.push({ code: 'L021', key: 'description' })
234
- if (!head.lang) found.push({ code: 'L022', key: 'lang' })
235
- if (!head.viewport || !/width=device-width/.test(head.viewport)) found.push({ code: 'L023', key: 'viewport' })
236
- 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)
237
- if (noindex.length) found.push({ code: 'L024', key: noindex.join(), text: noindex.join(' and '), header: !!header })
238
- if (f.mobile && head.scroll > 0) found.push({ code: 'L030', key: head.wide.join() || 'page', text: `${head.scroll}px`, wide: head.wide })
239
- const shift = (await send('Runtime.evaluate', { expression: SHIFT, awaitPromise: true, returnByValue: true })).result.value
240
- if (shift.cls > 0.05) found.push({ code: 'L031', key: shift.moved.join() || 'page', cls: shift.cls, moved: shift.moved })
241
- await send('Runtime.evaluate', { expression: axeSource() })
242
- const { result, exceptionDetails } = await send('Runtime.evaluate', { expression: `axe.run(document, ${JSON.stringify(AXE)}).then(r => r.violations)`, awaitPromise: true, returnByValue: true })
243
- if (exceptionDetails) throw fail('E_AUDIT', `axe-core failed on ${url}: ${exceptionDetails.exception?.description ?? exceptionDetails.text}`)
244
- for (const v of result.value) {
245
- for (const n of v.nodes) {
246
- const tag = n.html.slice(0, n.html.indexOf('>') + 1)
247
- 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 })
193
+ /**
194
+ * The app's own pages as a test on a path (`src/routes`, `[id]` and `[...rest]` included). A build served as files has
195
+ * only its prerendered pages: a request for one of the others is the server's to answer, not a missing file.
196
+ */
197
+ export function appRoutes(root) {
198
+ const dir = join(root, 'src', 'routes')
199
+ if (!existsSync(dir)) return () => false
200
+ const tests = []
201
+ const walk = (at, prefix) => {
202
+ for (const e of readdirSync(at, { withFileTypes: true })) {
203
+ const name = e.name.replace(/^\(.*\)$/, '')
204
+ if (e.isDirectory()) {
205
+ walk(join(at, e.name), name ? `${prefix}/${name}` : prefix)
206
+ continue
248
207
  }
208
+ if (
209
+ !/\.[jt]sx?$/.test(e.name) ||
210
+ /\.(server|test|spec)\./.test(e.name) ||
211
+ e.name.startsWith('_')
212
+ )
213
+ continue
214
+ const leaf = e.name.replace(/\.[jt]sx?$/, '')
215
+ const path = leaf === 'index' ? prefix : `${prefix}/${leaf}`
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, '(?:/.+)?')
227
+ tests.push(new RegExp(`^${re || ''}/?$`))
249
228
  }
250
- return found
251
- } finally {
252
- off()
253
- await browser.send('Target.closeTarget', { targetId }).catch(() => {})
254
229
  }
230
+ walk(dir, '')
231
+ return (path) => tests.some((t) => t.test(path))
255
232
  }
256
233
 
257
- const say = {
258
- 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.`,
259
- L002: f => `${f.tag} (\`${f.selector}\`) is an image with no text alternative (${f.rule}): a screen reader reads its file name.`,
260
- 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'}.`,
261
- L004: f => `${f.tag} (\`${f.selector}\`): ${f.help} (axe \`${f.rule}\`).`,
262
- 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.`,
263
- L010: f => `Console error${f.at ? ` at ${f.at}` : ''}: ${f.text}`,
264
- 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}`),
265
- L012: f => `A deprecated browser API is in use${f.at ? ` at ${f.at}` : ''}: ${f.text}. Lighthouse marks Best Practices down for it.`,
266
- L013: f => `Chrome logged a ${f.text} issue for the page. Lighthouse marks Best Practices down for it.`,
267
- L020: () => 'The page has no `<title>`: search results and tabs show its URL.',
268
- L021: () => 'The page has no meta description: search results make one up from the page.',
269
- L022: () => '`<html>` has no `lang`: screen readers guess the language, and translation offers go wrong.',
270
- L023: () => 'No `<meta name="viewport" content="width=device-width, initial-scale=1">`: a phone renders the page at 980px and shrinks it.',
271
- 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.' : ''}`,
272
- L025: f => `The page answered ${f.text}.`,
273
- 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(', ')}` : ''}`,
274
- }
275
- /** A page may ask not to be indexed, and a host's preview URL does on its behalf: L024 says so without failing. */
276
- const SEVERITY = { L021: 'warning', L024: 'warning', L030: 'warning', L031: 'warning' }
277
-
278
234
  /**
279
235
  * @param {string} root project root
280
- * @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]
281
237
  * `url`: audit a running site (a deploy, `weft start`); else `dir`, a build's public directory (found when left out)
282
238
  * @returns {Promise<{ ok: boolean, skipped?: string, base: string, pages: string[], diagnostics: object[], ms: number }>}
283
239
  */
284
240
  export async function audit(root, options = {}) {
285
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')
286
250
  const exe = findChrome()
287
251
  const forms = options.forms ?? ['phone', 'desktop']
288
- const skip = why => ({ ok: true, skipped: why, base: options.url ?? '', pages: [], diagnostics: [diagnostic({ code: 'L000', severity: 'warning', file: 'weft.config.ts', why })], ms: 0 })
289
- 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
+ )
290
264
  let base = options.url?.replace(/\/$/, '')
291
265
  let server
292
266
  let routes = options.routes
@@ -294,20 +268,31 @@ export async function audit(root, options = {}) {
294
268
  const dir = options.dir ?? builtPublic(root)
295
269
  if (!dir) return skip('No built pages to audit: run `weft build` first, or pass `--url`.')
296
270
  const pages = pagesIn(dir)
297
- 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
+ )
298
275
  routes ??= options.all ? pages : samplePages(pages, options.max)
299
276
  server = await serveStatic(dir)
300
277
  base = server.url
301
278
  }
302
279
  routes ??= ['/']
280
+ const served = appRoutes(root)
303
281
  const browser = await chrome(exe)
304
282
  const findings = new Map()
305
283
  try {
306
- const jobs = routes.flatMap(route => forms.map(form => ({ route, form })))
284
+ const jobs = routes.flatMap((route) => forms.map((form) => ({ route, form })))
307
285
  let i = 0
308
286
  const worker = async () => {
309
287
  for (let job; (job = jobs[i++]);) {
310
- for (const f of await auditPage(browser, `${base}${job.route}`, job.form, !!server)) {
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
+ })) {
311
296
  const key = `${f.code} ${f.key}`
312
297
  const seen = findings.get(key) ?? { ...f, routes: new Set(), forms: new Set() }
313
298
  seen.routes.add(job.route)
@@ -316,18 +301,19 @@ export async function audit(root, options = {}) {
316
301
  }
317
302
  }
318
303
  }
319
- 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
+ )
320
307
  } finally {
321
308
  browser.close()
322
309
  server?.close()
323
310
  }
324
- const diagnostics = [...findings.values()].map(f => {
325
- const pages = routes.filter(r => f.routes.has(r))
326
- 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 ? ', …' : ''})`}`
327
- const severity = f.code === 'L031' && f.cls > 0.1 ? 'error' : SEVERITY[f.code] ?? 'error'
328
- // an axe rule outside L001–L003 carries its own fix: the rule's first remedy, and its page
329
- const help = f.code === 'L004' ? `${f.fix ? `${f.fix}. ` : ''}${WARP[f.rule] ?? ''}The rule: https://dequeuniversity.com/rules/axe/4.14/${f.rule}` : undefined
330
- return diagnostic({ code: f.code, severity, file: pages[0], why: `${say[f.code](f)} (${on})`, help })
331
- }).sort((a, b) => (a.severity === b.severity ? a.code.localeCompare(b.code) : a.severity === 'error' ? -1 : 1))
332
- 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
+ }
333
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)