@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,149 @@
1
+ // What an app needs from a host (research 46 §2), read from its files in one pass: no Vite server, no build. Each need
2
+ // carries the file that shows it, so a diagnostic can point there.
3
+
4
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
5
+ import { join, relative } from 'node:path'
6
+
7
+ const SKIP = new Set(['node_modules', '.weft', '.output', 'dist', '.netlify', '.vercel'])
8
+ /** npm packages with a compiled addon: one built on a Mac doesn't load on a Linux function. */
9
+ const ADDONS =
10
+ /^(better-sqlite3|sqlite3|sharp|bcrypt|argon2|canvas|@node-rs\/.+|@napi-rs\/canvas|bufferutil|utf-8-validate)$/
11
+ /** A bus that reaches every instance. The memory bus is what's left when none is named. */
12
+ const BUSES =
13
+ /\b(postgresBus|redisBus|natsBus|durableObjectBus)\s*\(|@weft-js\/realtime-(redis|nats|durable-object)/
14
+
15
+ const posix = (p) => p.replaceAll('\\', '/')
16
+
17
+ function sources(root) {
18
+ const out = []
19
+ const walk = (dir) => {
20
+ for (const d of readdirSync(dir, { withFileTypes: true })) {
21
+ const f = join(dir, d.name)
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)
26
+ }
27
+ }
28
+ if (existsSync(join(root, 'src'))) walk(join(root, 'src'))
29
+ return out
30
+ }
31
+
32
+ /** `.weft/contract.json`, when it's newer than every source file: what the last build saw is then what's there. */
33
+ function freshContract(root, files) {
34
+ const file = join(root, '.weft', 'contract.json')
35
+ if (!existsSync(file)) return
36
+ const at = statSync(file).mtimeMs
37
+ if (files.some((f) => statSync(f).mtimeMs > at)) return
38
+ try {
39
+ return JSON.parse(readFileSync(file, 'utf8'))
40
+ } catch {}
41
+ }
42
+
43
+ const read = (file) => {
44
+ try {
45
+ return readFileSync(file, 'utf8')
46
+ } catch {
47
+ return ''
48
+ }
49
+ }
50
+
51
+ /**
52
+ * @param {string} root
53
+ * @param {any} [config] the loaded weft.config
54
+ * @returns {{ sockets?: Need, required?: Need, feed?: Need, memoryBus?: Need, sidecar?: Need, native?: Need, sqlite?: Need, uploads?: Need, addons?: Need, runtime: string }}
55
+ * @typedef {{ file: string, what: string }} Need
56
+ */
57
+ export function needsOf(root, config = {}) {
58
+ const files = sources(root)
59
+ const rel = (f) => posix(relative(root, f))
60
+ const needs = { runtime: config.runtime ?? 'node' }
61
+ const contract = freshContract(root, files)
62
+ const realtimeFile = join(root, 'src/realtime.server.ts')
63
+ const realtime = read(realtimeFile)
64
+ let streams = false
65
+ if (contract) {
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))
73
+ const native = Object.entries(contract.services ?? {}).find(([, s]) => s.kind === 'native')
74
+ if (native) needs.native = { file: '.weft/contract.json', what: `\`native('${native[0]}')\`` }
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 }) {
124
+ for (const f of files) {
125
+ const text = read(f)
126
+ const server = /^src\/server\/|\.server\.[cm]?[jt]sx?$/.test(rel(f))
127
+ if (!contract && server) {
128
+ const m = /\b(channel|sharedState)\s*[(<]/.exec(text)
129
+ if (m && !needs.sockets) needs.sockets = { file: rel(f), what: `a \`${m[1]}\`` }
130
+ if (/\b(stream|channel|sharedState)\s*[(<]/.test(text)) streams = true
131
+ }
132
+ if (/\.live\(/.test(text)) streams = true
133
+ if (/['"]@weft-js\/start\/rust['"]/.test(text)) {
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]}')\`` }
137
+ const n = /\bnative\(\s*['"`]([\w-]+)/.exec(text)
138
+ if (n) needs.native ??= { file: rel(f), what: `\`native('${n[1]}')\`` }
139
+ }
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' }
147
+ }
148
+ return streams
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
+ }