@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.
- package/README.md +38 -14
- package/bin/weft.mjs +8 -1
- package/docs/async.md +16 -6
- package/docs/browser.md +22 -8
- package/docs/config.md +39 -9
- package/docs/core.md +30 -0
- package/docs/deploy.md +33 -8
- package/docs/design.md +12 -3
- package/docs/entrypoints.md +79 -0
- package/docs/fonts.md +12 -2
- package/docs/icons.md +13 -7
- package/docs/lists.md +3 -1
- package/docs/meta.md +13 -3
- package/docs/plugins.md +32 -9
- package/docs/props.md +13 -2
- package/docs/reactivity.md +26 -0
- package/docs/rules.md +19 -12
- package/docs/rust.md +47 -17
- package/docs/start-short.md +3 -2
- package/docs/start.md +226 -52
- package/docs/state.md +33 -7
- package/docs/style.md +31 -8
- package/docs/surface.md +61 -15
- package/docs/testing.md +15 -5
- package/docs/theme.md +182 -0
- package/docs/ui-state.md +19 -6
- package/docs/verify.md +13 -0
- package/package.json +13 -4
- package/src/add/config.mjs +93 -0
- package/src/add/errors.mjs +2 -0
- package/src/add/setup.mjs +88 -0
- package/src/add-rust.mjs +82 -27
- package/src/add.mjs +209 -181
- package/src/agents-md.mjs +144 -43
- package/src/alias.mjs +16 -5
- package/src/audit/page.mjs +313 -0
- package/src/audit/report.mjs +69 -0
- package/src/audit/utils.mjs +5 -0
- package/src/audit.mjs +159 -206
- package/src/browser/cli.mjs +24 -5
- package/src/browser/client.mjs +189 -25
- package/src/browser/commands.mjs +166 -51
- package/src/catalog/audit.mjs +129 -0
- package/src/catalog/auth.mjs +40 -0
- package/src/catalog/compiler.mjs +337 -0
- package/src/catalog/contracts.mjs +18 -0
- package/src/catalog/database.mjs +52 -0
- package/src/catalog/deploy.mjs +117 -0
- package/src/catalog/design.mjs +159 -0
- package/src/catalog/forms.mjs +25 -0
- package/src/catalog/hydration.mjs +29 -0
- package/src/catalog/icons.mjs +24 -0
- package/src/catalog/media.mjs +163 -0
- package/src/catalog/metadata.mjs +116 -0
- package/src/catalog/plugins.mjs +228 -0
- package/src/catalog/realtime.mjs +195 -0
- package/src/catalog/rust.mjs +75 -0
- package/src/catalog/server-functions.mjs +92 -0
- package/src/catalog/state.mjs +39 -0
- package/src/catalog/style-groups.mjs +54 -0
- package/src/catalog/tests.mjs +127 -0
- package/src/catalog/text.mjs +200 -0
- package/src/catalog/ui-state.mjs +42 -0
- package/src/catalog.mjs +67 -460
- package/src/check/runtime.mjs +280 -0
- package/src/check.mjs +198 -208
- package/src/cli/authoring.mjs +213 -0
- package/src/cli/project.mjs +302 -0
- package/src/cli.mjs +222 -250
- package/src/config.mjs +1 -1
- package/src/contributions.mjs +75 -25
- package/src/db-layer.mjs +13 -5
- package/src/deploy/cdn.mjs +94 -16
- package/src/deploy/files.mjs +100 -21
- package/src/deploy/fit.mjs +127 -23
- package/src/deploy/hosts.mjs +90 -20
- package/src/deploy/index/cli.mjs +20 -0
- package/src/deploy/index/environment.mjs +69 -0
- package/src/deploy/index/steps.mjs +87 -0
- package/src/deploy/index.mjs +228 -114
- package/src/deploy/needs.mjs +88 -31
- package/src/design-checks/accessible-names.mjs +62 -0
- package/src/design-checks/contrast.mjs +43 -0
- package/src/design-checks/csp.mjs +60 -0
- package/src/design-checks/files.mjs +59 -0
- package/src/design-checks/heading-links.mjs +61 -0
- package/src/design-checks/interaction.mjs +65 -0
- package/src/design-checks/jsx.mjs +67 -0
- package/src/design-checks/layout-values.mjs +68 -0
- package/src/design-checks/native-controls.mjs +48 -0
- package/src/design-checks/outline.mjs +39 -0
- package/src/design-checks/reporter.mjs +37 -0
- package/src/design-checks/rules.mjs +274 -0
- package/src/design-checks/values.mjs +262 -0
- package/src/design-checks.mjs +10 -400
- package/src/diag.mjs +32 -8
- package/src/docs.mjs +138 -43
- package/src/doctor.mjs +20 -8
- package/src/files.mjs +18 -5
- package/src/fix.mjs +10 -2
- package/src/gate/after.mjs +40 -0
- package/src/gate/before.mjs +54 -0
- package/src/gate/classify.mjs +72 -0
- package/src/gate/mutation.mjs +209 -0
- package/src/gate/resolve-cases.mjs +22 -0
- package/src/gate/result.mjs +63 -0
- package/src/gate/run.mjs +75 -0
- package/src/gate.mjs +104 -202
- package/src/infer/box-props.cjs +140 -0
- package/src/infer/core-boxes.cjs +46 -0
- package/src/infer/derived-writes.cjs +95 -0
- package/src/infer/engine/checks.cjs +193 -0
- package/src/infer/engine/flow.cjs +293 -0
- package/src/infer/engine/report.cjs +158 -0
- package/src/infer/engine/utils.cjs +15 -0
- package/src/infer/engine.cjs +108 -368
- package/src/infer/facts/sites.cjs +114 -0
- package/src/infer/facts.cjs +210 -117
- package/src/infer/hosts.cjs +69 -22
- package/src/infer/lsp.mjs +149 -40
- package/src/infer/session.cjs +73 -17
- package/src/infer/shadow.cjs +13 -5
- package/src/infer/worker.cjs +57 -9
- package/src/jsx-scan.mjs +63 -21
- package/src/load-config/schema.mjs +281 -0
- package/src/load-config.mjs +137 -138
- package/src/map.mjs +93 -26
- package/src/mcp/browser-tools.mjs +155 -0
- package/src/mcp.mjs +147 -69
- package/src/meta-table.mjs +43 -8
- package/src/mutate.mjs +120 -36
- package/src/plugin-checks.mjs +52 -23
- package/src/plugins.mjs +125 -39
- package/src/project.mjs +170 -79
- package/src/props-checks.mjs +24 -6
- package/src/props-listener.mjs +27 -9
- package/src/react-checks.mjs +35 -4
- package/src/server-state-checks.mjs +34 -0
- package/src/share-checks.mjs +121 -37
- package/src/site.mjs +33 -6
- package/src/slots.mjs +104 -19
- package/src/start/build.mjs +116 -0
- package/src/start/routes.mjs +38 -0
- package/src/start.mjs +193 -95
- package/src/state-checks/core-state.mjs +223 -0
- package/src/state-checks/modules.mjs +17 -0
- package/src/state-checks/scan.mjs +232 -0
- package/src/state-checks/server-graph.mjs +65 -0
- package/src/state-checks.mjs +109 -133
- package/src/style/find.mjs +121 -0
- package/src/style.mjs +133 -127
- package/src/surface-checks.mjs +212 -46
- package/src/tailwind.mjs +5 -4
- package/src/tasks.mjs +189 -27
- package/src/test-rules/context.mjs +109 -0
- package/src/test-rules/file-rules.mjs +279 -0
- package/src/test-rules/rule-helpers.mjs +244 -0
- package/src/test-rules/syntax.mjs +223 -0
- package/src/test-rules/test-rules.mjs +127 -0
- package/src/testdiff.mjs +94 -32
- package/src/tests.mjs +31 -483
- package/src/text-checks.mjs +229 -69
- package/src/tokens.mjs +12 -4
- package/src/trust/scan.mjs +95 -0
- package/src/trust.mjs +214 -102
- package/src/weft.mjs +41 -9
- package/src/workspace.mjs +96 -19
- package/ts-plugin/context.js +57 -0
- package/ts-plugin/index.js +4 -124
- package/ts-plugin/mapping.js +65 -0
- package/ts-plugin/proxy.js +144 -0
package/src/deploy/needs.mjs
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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()) {
|
|
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 {
|
|
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 =
|
|
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)
|
|
55
|
-
|
|
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))
|
|
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 (
|
|
74
|
-
|
|
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
|
-
|
|
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
|
+
}
|