@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
@@ -6,18 +6,23 @@ import { join, relative } from 'node:path'
6
6
 
7
7
  const SKIP = new Set(['node_modules', '.weft', '.output', 'dist', '.netlify', '.vercel'])
8
8
  /** npm packages with a compiled addon: one built on a Mac doesn't load on a Linux function. */
9
- const ADDONS = /^(better-sqlite3|sqlite3|sharp|bcrypt|argon2|canvas|@node-rs\/.+|@napi-rs\/canvas|bufferutil|utf-8-validate)$/
9
+ const ADDONS =
10
+ /^(better-sqlite3|sqlite3|sharp|bcrypt|argon2|canvas|@node-rs\/.+|@napi-rs\/canvas|bufferutil|utf-8-validate)$/
10
11
  /** A bus that reaches every instance. The memory bus is what's left when none is named. */
11
- const BUSES = /\b(postgresBus|redisBus|natsBus|durableObjectBus)\s*\(|@weft-js\/realtime-(redis|nats|durable-object)/
12
+ const BUSES =
13
+ /\b(postgresBus|redisBus|natsBus|durableObjectBus)\s*\(|@weft-js\/realtime-(redis|nats|durable-object)/
12
14
 
13
- const posix = p => p.replaceAll('\\', '/')
15
+ const posix = (p) => p.replaceAll('\\', '/')
14
16
 
15
17
  function sources(root) {
16
18
  const out = []
17
- const walk = dir => {
19
+ const walk = (dir) => {
18
20
  for (const d of readdirSync(dir, { withFileTypes: true })) {
19
21
  const f = join(dir, d.name)
20
- if (d.isDirectory()) { if (!SKIP.has(d.name)) walk(f) } else if (/\.[cm]?[jt]sx?$/.test(d.name) && !/\.(test|spec)\.|\.d\.ts$/.test(d.name)) out.push(f)
22
+ if (d.isDirectory()) {
23
+ if (!SKIP.has(d.name)) walk(f)
24
+ } else if (/\.[cm]?[jt]sx?$/.test(d.name) && !/\.(test|spec)\.|\.d\.ts$/.test(d.name))
25
+ out.push(f)
21
26
  }
22
27
  }
23
28
  if (existsSync(join(root, 'src'))) walk(join(root, 'src'))
@@ -29,11 +34,19 @@ function freshContract(root, files) {
29
34
  const file = join(root, '.weft', 'contract.json')
30
35
  if (!existsSync(file)) return
31
36
  const at = statSync(file).mtimeMs
32
- if (files.some(f => statSync(f).mtimeMs > at)) return
33
- try { return JSON.parse(readFileSync(file, 'utf8')) } catch {}
37
+ if (files.some((f) => statSync(f).mtimeMs > at)) return
38
+ try {
39
+ return JSON.parse(readFileSync(file, 'utf8'))
40
+ } catch {}
34
41
  }
35
42
 
36
- const read = file => { try { return readFileSync(file, 'utf8') } catch { return '' } }
43
+ const read = (file) => {
44
+ try {
45
+ return readFileSync(file, 'utf8')
46
+ } catch {
47
+ return ''
48
+ }
49
+ }
37
50
 
38
51
  /**
39
52
  * @param {string} root
@@ -43,19 +56,71 @@ const read = file => { try { return readFileSync(file, 'utf8') } catch { return
43
56
  */
44
57
  export function needsOf(root, config = {}) {
45
58
  const files = sources(root)
46
- const rel = f => posix(relative(root, f))
59
+ const rel = (f) => posix(relative(root, f))
47
60
  const needs = { runtime: config.runtime ?? 'node' }
48
61
  const contract = freshContract(root, files)
49
62
  const realtimeFile = join(root, 'src/realtime.server.ts')
50
63
  const realtime = read(realtimeFile)
51
64
  let streams = false
52
65
  if (contract) {
53
- const live = contract.functions?.find(f => f.kind === 'channel' || f.kind === 'shared')
54
- if (live) needs.sockets = { file: '.weft/contract.json', what: `\`${live.name}\` is a ${live.kind === 'shared' ? '`sharedState`' : '`channel`'}` }
55
- streams = contract.functions?.some(f => ['stream', 'channel', 'shared'].includes(f.kind))
66
+ const live = contract.functions?.find((f) => f.kind === 'channel' || f.kind === 'shared')
67
+ if (live)
68
+ needs.sockets = {
69
+ file: '.weft/contract.json',
70
+ what: `\`${live.name}\` is a ${live.kind === 'shared' ? '`sharedState`' : '`channel`'}`,
71
+ }
72
+ streams = contract.functions?.some((f) => ['stream', 'channel', 'shared'].includes(f.kind))
56
73
  const native = Object.entries(contract.services ?? {}).find(([, s]) => s.kind === 'native')
57
74
  if (native) needs.native = { file: '.weft/contract.json', what: `\`native('${native[0]}')\`` }
58
75
  }
76
+ streams = scanNeeds({ files, rel, contract, needs, config, realtime, streams })
77
+ if (!needs.sockets && /\bshare\s*[:,}]/.test(realtime))
78
+ needs.sockets = { file: 'src/realtime.server.ts', what: '`realtime({ share })`' }
79
+ if (/\bchanges\s*[:,}]/.test(realtime) && !/listener:\s*['"]external['"]/.test(realtime))
80
+ needs.feed = { file: 'src/realtime.server.ts', what: '`changes`' }
81
+ const plugins = (config.plugins ?? [])
82
+ .map((p) => p?.name ?? p)
83
+ .filter((n) => typeof n === 'string')
84
+ const configText = ['weft.config.ts', 'weft.config.mts', 'weft.config.js', 'weft.config.mjs']
85
+ .map((n) => read(join(root, n)))
86
+ .join('\n')
87
+ if (
88
+ (streams || needs.sockets) &&
89
+ !BUSES.test(realtime) &&
90
+ !plugins.some((n) => /realtime-(redis|nats|durable-object)/.test(n)) &&
91
+ !BUSES.test(configText)
92
+ )
93
+ needs.memoryBus = {
94
+ file: existsSync(realtimeFile) ? 'src/realtime.server.ts' : 'src',
95
+ what: 'the memory bus',
96
+ }
97
+ if (!needs.uploads && /encoder:\s*['"]server['"]/.test(configText))
98
+ needs.uploads = { file: 'weft.config.ts', what: "`media({ encoder: 'server' })`" }
99
+ if (
100
+ needs.uploads &&
101
+ !plugins.some((n) => n.includes('plugin-media')) &&
102
+ !configText.includes('plugin-media')
103
+ )
104
+ delete needs.uploads // M017 says that one
105
+ const db = read(join(root, 'src/db/index.server.ts'))
106
+ const drizzle = read(join(root, 'drizzle.config.ts'))
107
+ if (
108
+ /\/db\/sqlite['"]/.test(db) ||
109
+ (/dialect:\s*['"](sqlite|turso)['"]/.test(drizzle) && !/libsql:|turso/.test(drizzle + db))
110
+ )
111
+ needs.sqlite = {
112
+ file: db ? 'src/db/index.server.ts' : 'drizzle.config.ts',
113
+ what: 'a SQLite file',
114
+ }
115
+ try {
116
+ const pkg = JSON.parse(read(join(root, 'package.json')) || '{}')
117
+ const addon = Object.keys(pkg.dependencies ?? {}).find((n) => ADDONS.test(n))
118
+ if (addon) needs.addons = { file: 'package.json', what: `\`${addon}\`` }
119
+ } catch {}
120
+ return needs
121
+ }
122
+
123
+ function scanNeeds({ files, rel, contract, needs, config, realtime, streams }) {
59
124
  for (const f of files) {
60
125
  const text = read(f)
61
126
  const server = /^src\/server\/|\.server\.[cm]?[jt]sx?$/.test(rel(f))
@@ -66,27 +131,19 @@ export function needsOf(root, config = {}) {
66
131
  }
67
132
  if (/\.live\(/.test(text)) streams = true
68
133
  if (/['"]@weft-js\/start\/rust['"]/.test(text)) {
69
- for (const m of text.matchAll(/\bremote\(\s*['"`]rust:\/\/([\w-]+)/g)) if (!config.rust?.services?.[m[1]]?.external) needs.sidecar ??= { file: rel(f), what: `\`remote('rust://${m[1]}')\`` }
134
+ for (const m of text.matchAll(/\bremote\(\s*['"`]rust:\/\/([\w-]+)/g))
135
+ if (!config.rust?.services?.[m[1]]?.external)
136
+ needs.sidecar ??= { file: rel(f), what: `\`remote('rust://${m[1]}')\`` }
70
137
  const n = /\bnative\(\s*['"`]([\w-]+)/.exec(text)
71
138
  if (n) needs.native ??= { file: rel(f), what: `\`native('${n[1]}')\`` }
72
139
  }
73
- if (!/listener:\s*['"]external['"]/.test(realtime) && /\.live\(([^;\n]*?)\brequired:\s*true|\blive:\s*\{[^}]*required:\s*true/.test(text)) needs.required ??= { file: rel(f), what: 'a live query marked `required`' }
74
- if (/\bctx\.media\b|\bz\.media\b/.test(text)) needs.uploads ??= { file: rel(f), what: 'media uploads' }
140
+ if (
141
+ !/listener:\s*['"]external['"]/.test(realtime) &&
142
+ /\.live\(([^;\n]*?)\brequired:\s*true|\blive:\s*\{[^}]*required:\s*true/.test(text)
143
+ )
144
+ needs.required ??= { file: rel(f), what: 'a live query marked `required`' }
145
+ if (/\bctx\.media\b|\bz\.media\b/.test(text))
146
+ needs.uploads ??= { file: rel(f), what: 'media uploads' }
75
147
  }
76
- if (!needs.sockets && /\bshare\s*[:,}]/.test(realtime)) needs.sockets = { file: 'src/realtime.server.ts', what: '`realtime({ share })`' }
77
- if (/\bchanges\s*[:,}]/.test(realtime) && !/listener:\s*['"]external['"]/.test(realtime)) needs.feed = { file: 'src/realtime.server.ts', what: '`changes`' }
78
- const plugins = (config.plugins ?? []).map(p => p?.name ?? p).filter(n => typeof n === 'string')
79
- const configText = ['weft.config.ts', 'weft.config.mts', 'weft.config.js', 'weft.config.mjs'].map(n => read(join(root, n))).join('\n')
80
- if ((streams || needs.sockets) && !BUSES.test(realtime) && !plugins.some(n => /realtime-(redis|nats|durable-object)/.test(n)) && !BUSES.test(configText)) needs.memoryBus = { file: existsSync(realtimeFile) ? 'src/realtime.server.ts' : 'src', what: 'the memory bus' }
81
- if (!needs.uploads && /encoder:\s*['"]server['"]/.test(configText)) needs.uploads = { file: 'weft.config.ts', what: "`media({ encoder: 'server' })`" }
82
- if (needs.uploads && !plugins.some(n => n.includes('plugin-media')) && !configText.includes('plugin-media')) delete needs.uploads // M017 says that one
83
- const db = read(join(root, 'src/db/index.server.ts'))
84
- const drizzle = read(join(root, 'drizzle.config.ts'))
85
- if (/\/db\/sqlite['"]/.test(db) || (/dialect:\s*['"](sqlite|turso)['"]/.test(drizzle) && !/libsql:|turso/.test(drizzle + db))) needs.sqlite = { file: db ? 'src/db/index.server.ts' : 'drizzle.config.ts', what: 'a SQLite file' }
86
- try {
87
- const pkg = JSON.parse(read(join(root, 'package.json')) || '{}')
88
- const addon = Object.keys(pkg.dependencies ?? {}).find(n => ADDONS.test(n))
89
- if (addon) needs.addons = { file: 'package.json', what: `\`${addon}\`` }
90
- } catch {}
91
- return needs
148
+ return streams
92
149
  }
@@ -0,0 +1,62 @@
1
+ import { named, hasName, GENERIC, UNNAMED } from './rules.mjs'
2
+
3
+ export function checkAccessibleNames({ el, report, labelled }) {
4
+ // -- accessible names --
5
+ if (['input', 'select', 'textarea'].includes(el.name)) {
6
+ const type = el.attr('type')?.value
7
+ const skip = ['hidden', 'submit', 'button', 'reset', 'image'].includes(type)
8
+ const id = el.attr('id')
9
+ // A computed id can pair with a label anywhere, a component around the field can render its label, and an
10
+ // aria-hidden field (a honeypot) has no name to give. `for` counts on a component too: a `<Field for>` passes it on.
11
+ const unseen =
12
+ id?.kind === 'expr' ||
13
+ el.attr('aria-hidden')?.value === 'true' ||
14
+ el.ancestors.some((a) => /^[A-Z]/.test(a))
15
+ if (
16
+ !skip &&
17
+ !unseen &&
18
+ !named(el) &&
19
+ !(id?.value && labelled.has(id.value)) &&
20
+ !el.ancestors.includes('label')
21
+ )
22
+ report(
23
+ 'D030',
24
+ el.start,
25
+ el.tagEnd,
26
+ `<${el.name}> has no label: a screen reader announces only "edit" (WCAG 1.3.1, 4.1.2).${el.attr('placeholder') ? ' A placeholder is not a label.' : ''}`,
27
+ )
28
+ }
29
+ if (
30
+ ['button', 'a', 'Button', 'Link'].includes(el.name) &&
31
+ !el.attr('label') &&
32
+ !named(el) &&
33
+ !(el.name === 'a' && !el.attr('href')) &&
34
+ !hasName(el.children)
35
+ )
36
+ report(
37
+ 'D031',
38
+ el.start,
39
+ el.tagEnd,
40
+ `<${el.name}> has no name a screen reader can read: only an icon, or nothing (WCAG 4.1.2).`,
41
+ )
42
+ const label = el.attr('aria-label') ?? el.attr('aria-labelledby')
43
+ const role = el.attr('role')
44
+ if (
45
+ label &&
46
+ GENERIC.has(el.name) &&
47
+ (!role || (role.kind === 'string' && UNNAMED.has(role.value)))
48
+ )
49
+ report(
50
+ 'D041',
51
+ label.start,
52
+ label.end,
53
+ `\`${label.name}\` on a <${el.name}>${role ? ` with role "${role.value}"` : ''}: ARIA forbids naming it, so screen readers skip the name (axe aria-prohibited-attr, Lighthouse).`,
54
+ )
55
+ if (el.name === 'img' && !el.attr('alt') && !el.attrs.some((a) => a.kind === 'spread'))
56
+ report(
57
+ 'D032',
58
+ el.start,
59
+ el.tagEnd,
60
+ '<img> has no `alt`: a screen reader reads out the file name (WCAG 1.1.1).',
61
+ )
62
+ }
@@ -0,0 +1,43 @@
1
+ import { styleDecls } from './values.mjs'
2
+ import { literalColor, ratio } from './rules.mjs'
3
+
4
+ export function checkContrast({ el, report }) {
5
+ // -- contrast of a literal pair --
6
+ const style = el.attr('style')
7
+ if (style) {
8
+ const decls = styleDecls(style)
9
+ const fg = decls.find((d) => d.prop === 'color')
10
+ const bg = decls.find((d) => d.prop === 'background' || d.prop === 'background-color')
11
+ const [a, b] = [fg && literalColor(fg.value), bg && literalColor(bg.value)]
12
+ if (a && b && ratio(a, b) < 4.5)
13
+ report(
14
+ 'D011',
15
+ style.start,
16
+ style.end,
17
+ `\`${fg.value}\` on \`${bg.value}\` is ${ratio(a, b).toFixed(2)}:1; body text needs 4.5:1 (WCAG 1.4.3).`,
18
+ )
19
+ const none = decls.find(
20
+ (d) =>
21
+ (d.prop === 'outline' || d.prop === 'outline-width') && /^(none|0(px)?)$/.test(d.value),
22
+ )
23
+ if (none)
24
+ report(
25
+ 'D036',
26
+ none.start,
27
+ none.end,
28
+ 'This removes the focus ring with nothing in its place: keyboard users lose their place (WCAG 2.4.7).',
29
+ )
30
+ }
31
+ const cls = el.attr('class') ?? el.attr('className')
32
+ if (
33
+ cls?.kind === 'string' &&
34
+ /(^|\s)(focus:)?outline-none(\s|$)/.test(cls.value) &&
35
+ !/focus(-visible)?:(ring|outline(?!-none))/.test(cls.value)
36
+ )
37
+ report(
38
+ 'D036',
39
+ cls.start,
40
+ cls.end,
41
+ '`outline-none` with no `focus-visible:` style in its place: keyboard users lose their place (WCAG 2.4.7).',
42
+ )
43
+ }
@@ -0,0 +1,60 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { HOST_HEADERS, cspDirectives, inlineAllowed, scriptsRun } from './rules.mjs'
4
+
5
+ export function checkCsp(root, files, reporter) {
6
+ // -- a CSP against the inlined sheets --
7
+ const config = ['ts', 'mts', 'js', 'mjs', 'json', 'yaml', 'yml']
8
+ .map((x) => join(root, `weft.config.${x}`))
9
+ .find(existsSync)
10
+ const configText = config ? readFileSync(config, 'utf8') : ''
11
+ if (!/inlineCss['"]?\s*:\s*false/.test(configText)) {
12
+ const hosting = HOST_HEADERS.filter(
13
+ (rel) => existsSync(join(root, rel)) && !files.some((f) => f.rel === rel),
14
+ ).map((rel) => ({ rel, text: readFileSync(join(root, rel), 'utf8') }))
15
+ for (const f of [
16
+ ...files,
17
+ ...hosting,
18
+ ...(config
19
+ ? [
20
+ {
21
+ rel: config
22
+ .slice(root.length + 1)
23
+ .split('\\')
24
+ .join('/'),
25
+ text: configText,
26
+ },
27
+ ]
28
+ : []),
29
+ ]) {
30
+ const m = /content-security-policy(?!-report-only)/i.exec(f.text)
31
+ if (!m) continue
32
+ const policy = cspDirectives(f.text)
33
+ const report = reporter(f)
34
+ if (!inlineAllowed(policy, 'style-src-elem', 'style-src', 'default-src'))
35
+ report(
36
+ 'D012',
37
+ m.index,
38
+ m.index + m[0].length,
39
+ "This Content-Security-Policy blocks inline styles, and a build inlines the first paint's CSS: the page shows unstyled until its sheets load.",
40
+ { description: 'Set site: { inlineCss: false }', safe: false, text: m[0] },
41
+ )
42
+ else if (!scriptsRun(policy))
43
+ report(
44
+ 'D012',
45
+ m.index,
46
+ m.index + m[0].length,
47
+ "This Content-Security-Policy refuses inline scripts and the client entry, so a page's deferred sheets never switch on: hover, focus and every rule its first paint left out stay missing.",
48
+ { description: 'Set site: { inlineCss: false }', safe: false, text: m[0] },
49
+ )
50
+ else if (!inlineAllowed(policy, 'script-src-elem', 'script-src', 'default-src'))
51
+ report.warning(
52
+ 'D012',
53
+ m.index,
54
+ m.index + m[0].length,
55
+ "This Content-Security-Policy refuses inline scripts, so a page's deferred sheets switch on only once the client entry has run: rules its first paint left out wait for it.",
56
+ { description: 'Set site: { inlineCss: false }', safe: false, text: m[0] },
57
+ )
58
+ }
59
+ }
60
+ }
@@ -0,0 +1,59 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { discover } from '../files.mjs'
4
+ import { declaration } from './values.mjs'
5
+
6
+ export function checkFiles(root, exclude, tokens, reporter) {
7
+ // -- CSS files --
8
+ for (const rel of discover(root, { include: ['**/*.css'], exclude })) {
9
+ const text = readFileSync(join(root, rel), 'utf8')
10
+ const css = text.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '))
11
+ const file = { rel, text }
12
+ const report = reporter(file)
13
+ for (const m of css.matchAll(/(?<=[{;\s])(-?-?[\w-]+)\s*:\s*([^;{}]+)/g)) {
14
+ const prop = m[1].toLowerCase()
15
+ if (prop.startsWith('--')) continue
16
+ const start = m.index + m[0].indexOf(m[2], m[1].length + 1)
17
+ // Restyling warp's parts and the app's theme file are the supported ways past tokens: worth a look, not a stop.
18
+ const opening = css.lastIndexOf('{', m.index)
19
+ const selector = css.slice(
20
+ Math.max(css.lastIndexOf('}', opening), css.lastIndexOf(';', opening)) + 1,
21
+ opening,
22
+ )
23
+ const theming = rel === 'src/theme.css' || /\[data-(?:scope|part)\b/.test(selector)
24
+ if (tokens)
25
+ declaration(
26
+ { prop, value: m[2].trim(), start, end: start + m[2].trimEnd().length },
27
+ theming ? report.warning : report,
28
+ )
29
+ if ((prop === 'outline' || prop === 'outline-width') && /^(none|0(px)?)\s*$/.test(m[2])) {
30
+ const rule = css.lastIndexOf('}', m.index)
31
+ const focused = css.slice(rule + 1, css.indexOf('{', rule + 1))
32
+ if (
33
+ /:focus(?!-within)/.test(focused) &&
34
+ !/box-shadow|border/.test(css.slice(m.index, css.indexOf('}', m.index)))
35
+ )
36
+ report(
37
+ 'D036',
38
+ start,
39
+ start + m[2].trimEnd().length,
40
+ `\`${focused.trim()}\` removes the focus ring with nothing in its place: keyboard users lose their place (WCAG 2.4.7).`,
41
+ )
42
+ }
43
+ }
44
+ }
45
+
46
+ // -- the page --
47
+ for (const rel of discover(root, { include: ['**/*.html'], exclude })) {
48
+ const text = readFileSync(join(root, rel), 'utf8')
49
+ const m = /<html\b[^>]*>/i.exec(text)
50
+ if (m && !/\blang\s*=/.test(m[0]))
51
+ reporter({ rel, text })(
52
+ 'D039',
53
+ m.index,
54
+ m.index + m[0].length,
55
+ '<html> has no `lang`: a screen reader guesses the language to speak (WCAG 3.1.1).',
56
+ { description: 'Set lang="en"', safe: false, text: `${m[0].slice(0, -1)} lang="en">` },
57
+ )
58
+ }
59
+ }
@@ -0,0 +1,61 @@
1
+ import { WARP } from './values.mjs'
2
+ import { positions } from '../diag.mjs'
3
+ import { allowed, addImport, headingSite } from './rules.mjs'
4
+
5
+ export function checkHeadingLinks({ el, f, report, warpName, looks, localComponents, scan }) {
6
+ const bare = /^h[1-6]$/.test(el.name)
7
+ if (bare || (el.name === 'Heading' && warpName('Heading'))) {
8
+ const site = headingSite(el, bare)
9
+ if (site && !allowed(f.text).has(`D006:${positions(f.text).lineCol(el.start)[0]}`))
10
+ looks.set(site.level, [
11
+ ...(looks.get(site.level) ?? []),
12
+ { ...site, el, text: f.text, report },
13
+ ])
14
+ }
15
+ if (
16
+ el.attr('data-weft')?.value === 'styled' &&
17
+ (/^(?:main|h1|article)$/.test(el.name) ||
18
+ el.attr('role')?.value === 'main' ||
19
+ el.attr('data-vt')?.value === 'main')
20
+ ) {
21
+ report(
22
+ 'D040',
23
+ el.start,
24
+ el.tagEnd,
25
+ `The style gate on <${el.name}>: this is the page's main content, and the first paint would leave it hidden until the stylesheet lands.`,
26
+ 'Mark a secondary part, or remove `data-weft="styled"`',
27
+ )
28
+ }
29
+ if (
30
+ el.name === 'a' &&
31
+ el.attr('href') &&
32
+ !el.attr('class') &&
33
+ !el.attr('className') &&
34
+ !el.attr('style') &&
35
+ !el.attr('data-scope') &&
36
+ !el.attr('data-part') &&
37
+ !el.attrs.some((a) => a.kind === 'spread')
38
+ ) {
39
+ const taken =
40
+ localComponents.has('Link') ||
41
+ scan.imports.some((i) => i.local === 'Link' && i.source !== WARP)
42
+ const close = f.text.lastIndexOf('</', el.end - 1)
43
+ report(
44
+ 'D007',
45
+ el.start,
46
+ el.tagEnd,
47
+ "A bare <a> in a warp app: it gets warp's link colour but no underline sweep or touch target, and nav links read as prose. Use <Link>, or `Shell`'s `nav` for navigation.",
48
+ !taken && f.text.slice(el.start, el.end).includes('</a')
49
+ ? {
50
+ description: 'Use <Link>',
51
+ safe: false,
52
+ edits: [
53
+ { start: el.start + 1, end: el.tagEnd, text: 'Link' },
54
+ { start: close + 2, end: el.end - 1, text: 'Link' },
55
+ ...addImport(f.text, ['Link']),
56
+ ].sort((a, b) => b.start - a.start),
57
+ }
58
+ : undefined,
59
+ )
60
+ }
61
+ }
@@ -0,0 +1,65 @@
1
+ import { STATIC, INTERACTIVE } from './rules.mjs'
2
+
3
+ export function checkInteraction({ el, f, report }) {
4
+ // -- behaviour on the wrong element --
5
+ if (
6
+ STATIC.has(el.name) &&
7
+ el.attr('onClick') &&
8
+ !el.attr('role') &&
9
+ !el.attr('tabindex') &&
10
+ !el.attr('tabIndex')
11
+ ) {
12
+ const swap =
13
+ (el.name === 'div' || el.name === 'span') &&
14
+ f.text.slice(el.start, el.end).includes(`</${el.name}`)
15
+ report(
16
+ 'D033',
17
+ el.start,
18
+ el.tagEnd,
19
+ `onClick on <${el.name}>: no keyboard, no focus, no role, so it only works with a mouse (WCAG 2.1.1).`,
20
+ swap
21
+ ? {
22
+ description: 'Use a real button',
23
+ safe: false,
24
+ edits: [
25
+ { start: el.start + 1, end: el.tagEnd, text: 'button type="button"' },
26
+ {
27
+ start: f.text.lastIndexOf('</', el.end - 1) + 2,
28
+ end: el.end - 1,
29
+ text: 'button',
30
+ },
31
+ ].sort((a, b) => b.start - a.start),
32
+ }
33
+ : undefined,
34
+ )
35
+ }
36
+ if (INTERACTIVE.has(el.name) && el.ancestors.some((a) => INTERACTIVE.has(a) && a !== 'label')) {
37
+ const parent = [...el.ancestors].reverse().find((a) => INTERACTIVE.has(a))
38
+ report(
39
+ 'D034',
40
+ el.start,
41
+ el.tagEnd,
42
+ `<${el.name}> inside <${parent}>: nested controls don't reach the keyboard and screen readers merge them.`,
43
+ )
44
+ }
45
+ const tabindex = el.attr('tabindex') ?? el.attr('tabIndex')
46
+ const tab =
47
+ tabindex?.kind === 'string'
48
+ ? Number(tabindex.value)
49
+ : tabindex?.kind === 'expr'
50
+ ? Number(tabindex.expr)
51
+ : 0
52
+ if (tab > 0)
53
+ report(
54
+ 'D037',
55
+ tabindex.start,
56
+ tabindex.end,
57
+ `\`tabindex="${tab}"\` jumps the page's tab order (WCAG 2.4.3).`,
58
+ {
59
+ description: 'Use tabindex 0',
60
+ safe: true,
61
+ text: tabindex.kind === 'string' ? '"0"' : '0',
62
+ at: [tabindex.valueStart, tabindex.valueEnd],
63
+ },
64
+ )
65
+ }
@@ -0,0 +1,67 @@
1
+ import { scanJsx } from '../jsx-scan.mjs'
2
+ import { WARP } from './values.mjs'
3
+ import { checkLayoutValues } from './layout-values.mjs'
4
+ import { checkHeadingLinks } from './heading-links.mjs'
5
+ import { checkNativeControls } from './native-controls.mjs'
6
+ import { checkContrast } from './contrast.mjs'
7
+ import { checkAccessibleNames } from './accessible-names.mjs'
8
+ import { checkInteraction } from './interaction.mjs'
9
+ import { checkOutline } from './outline.mjs'
10
+
11
+ export function checkJsx({ files, reporter, tokens, looks }) {
12
+ for (const f of files) {
13
+ if (!/\.[jt]sx$/.test(f.rel)) continue
14
+ let scan
15
+ try {
16
+ scan = scanJsx(f.text, f.rel)
17
+ } catch {
18
+ continue // syntax errors are W000's
19
+ }
20
+ const report = reporter(f)
21
+ const localComponents = new Set(scan.functions.keys())
22
+ const warpName = (n) => scan.imports.some((i) => i.source === WARP && i.local === n)
23
+ const labelled = new Set(
24
+ scan.elements.map((e) => e.attr('for')?.value ?? e.attr('htmlFor')?.value).filter(Boolean),
25
+ )
26
+ const headings = []
27
+ const solids = new Map()
28
+
29
+ for (const el of scan.elements) {
30
+ if (tokens) {
31
+ checkLayoutValues({ el, f, report })
32
+ checkHeadingLinks({ el, f, report, warpName, looks, localComponents, scan })
33
+ checkNativeControls({ el, f, report, localComponents })
34
+ }
35
+ checkContrast({ el, report })
36
+ checkAccessibleNames({ el, report, labelled })
37
+ checkInteraction({ el, f, report })
38
+ checkOutline({ el, warpName, headings, solids, scan })
39
+ }
40
+
41
+ for (let i = 1; i < headings.length; i++) {
42
+ const { level, el } = headings[i]
43
+ if (level > headings[i - 1].level + 1)
44
+ report.warning(
45
+ 'D035',
46
+ el.start,
47
+ el.tagEnd,
48
+ `The outline jumps from h${headings[i - 1].level} to h${level}: screen-reader users navigate by headings (WCAG 1.3.1).`,
49
+ )
50
+ }
51
+ for (const group of solids.values()) {
52
+ for (const el of group.slice(1))
53
+ report(
54
+ 'D038',
55
+ el.start,
56
+ el.tagEnd,
57
+ `${group.length} solid Buttons side by side: one primary action per region.`,
58
+ {
59
+ description: 'Make this one soft',
60
+ safe: false,
61
+ text: ' emphasis="soft"',
62
+ at: [el.tagEnd, el.tagEnd],
63
+ },
64
+ )
65
+ }
66
+ }
67
+ }
@@ -0,0 +1,68 @@
1
+ import { addImport } from './rules.mjs'
2
+ import { styleDecls, declaration, utility, layoutOf } from './values.mjs'
3
+
4
+ export function checkLayoutValues({ el, f, report }) {
5
+ const style = el.attr('style')
6
+ if (style) for (const d of styleDecls(style)) declaration(d, report)
7
+ const cls = el.attr('class') ?? el.attr('className')
8
+ if (cls?.kind === 'string') {
9
+ let at = cls.valueStart + 1
10
+ for (const c of cls.value.split(/(\s+)/)) {
11
+ const hit = c.trim() && utility(c)
12
+ if (hit)
13
+ report(
14
+ hit[0],
15
+ at,
16
+ at + c.length,
17
+ hit[1],
18
+ hit[2] ? { description: `Use ${hit[2]}`, safe: true, text: hit[2] } : undefined,
19
+ )
20
+ at += c.length
21
+ }
22
+ }
23
+ const layout =
24
+ /^[a-z]/.test(el.name) &&
25
+ [
26
+ 'div',
27
+ 'section',
28
+ 'ul',
29
+ 'ol',
30
+ 'nav',
31
+ 'header',
32
+ 'footer',
33
+ 'main',
34
+ 'aside',
35
+ 'article',
36
+ 'form',
37
+ ].includes(el.name) &&
38
+ layoutOf(el)
39
+ if (layout) {
40
+ const close = f.text.lastIndexOf('</', el.end - 1)
41
+ const selfClosed = !f.text.slice(el.start, el.end).includes(`</${el.name}`)
42
+ const as = el.name === 'div' ? '' : ` as="${el.name}"`
43
+ const gap = layout.gap ? ` gap="${layout.gap}"` : ''
44
+ const sole = el.attrs.length === 1
45
+ const fix = layout.exact &&
46
+ sole &&
47
+ !selfClosed && {
48
+ description: `Use <${layout.primitive}>`,
49
+ safe: false,
50
+ edits: [
51
+ {
52
+ start: el.start + 1,
53
+ end: el.attrs[0].end,
54
+ text: `${layout.primitive}${as}${gap}`,
55
+ },
56
+ { start: close + 2, end: el.end - 1, text: layout.primitive },
57
+ ...addImport(f.text, [layout.primitive]),
58
+ ].sort((a, b) => b.start - a.start),
59
+ }
60
+ report(
61
+ 'D004',
62
+ el.start,
63
+ el.tagEnd,
64
+ `Hand-rolled layout on <${el.name}>: <${layout.primitive}> says what this is and keeps the gap on the scale.`,
65
+ fix || undefined,
66
+ )
67
+ }
68
+ }