@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.
- 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 +108 -0
- 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 +174 -188
- 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 +68 -444
- package/src/check/runtime.mjs +280 -0
- package/src/check.mjs +199 -207
- package/src/cli/authoring.mjs +213 -0
- package/src/cli/project.mjs +302 -0
- package/src/cli.mjs +228 -224
- package/src/config.mjs +1 -1
- package/src/contributions.mjs +75 -25
- package/src/db-layer.mjs +13 -5
- package/src/deploy/cdn.mjs +142 -0
- package/src/deploy/files.mjs +266 -0
- package/src/deploy/fit.mjs +166 -0
- package/src/deploy/hosts.mjs +126 -0
- 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 +387 -0
- package/src/deploy/needs.mjs +149 -0
- 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 -42
- 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 +144 -116
- 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 +194 -92
- 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
|
@@ -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
|
+
}
|