@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/design-checks.mjs
CHANGED
|
@@ -11,187 +11,12 @@
|
|
|
11
11
|
|
|
12
12
|
import { existsSync, readFileSync } from 'node:fs'
|
|
13
13
|
import { join } from 'node:path'
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
14
|
+
import { makeReporter } from './design-checks/reporter.mjs'
|
|
15
|
+
import { checkJsx } from './design-checks/jsx.mjs'
|
|
16
|
+
import { checkCsp } from './design-checks/csp.mjs'
|
|
17
|
+
import { checkFiles } from './design-checks/files.mjs'
|
|
18
|
+
import { roleDrift } from './design-checks/rules.mjs'
|
|
17
19
|
|
|
18
|
-
/** warp's spacing scale at the default density, px → token (warp.css). */
|
|
19
|
-
const SPACE = new Map([[2, 'hair'], [4, 'tight'], [8, 'related'], [16, 'group']])
|
|
20
|
-
const SPACE_NAMES = 'hair tight related group section page'.split(' ')
|
|
21
|
-
/** warp's radii at the quiet stop, where a raw radius is most likely to have been copied from. */
|
|
22
|
-
const RADIUS = new Map([[4, 'mark'], [8, 'control'], [12, 'surface']])
|
|
23
|
-
const WARP = '@weft-js/warp'
|
|
24
|
-
/** The role each heading level takes by default: the same table as warp's `Heading` (packages/warp/src/Type.tsx). */
|
|
25
|
-
const HEADING_ROLE = { 1: 'title', 2: 'heading', 3: 'heading', 4: 'label', 5: 'label', 6: 'label' }
|
|
26
|
-
|
|
27
|
-
const COLOR_EXPECTED = 'var(--warp-color-text), -text-subtle, -surface, -surface-raised, -accent-solid, -border, or a `tone` on the component'
|
|
28
|
-
const COLOR = /#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(|(?<![-\w])(?:white|black|red|green|blue|gray|grey|orange|yellow|purple|pink|indigo|teal)(?![-\w])/i
|
|
29
|
-
const COLOR_PROPS = /^(color|background|background-color|border-color|border(-(top|right|bottom|left|block|inline)(-(start|end))?)?|outline-color|fill|stroke|box-shadow|caret-color|accent-color)$/
|
|
30
|
-
const SPACE_PROPS = /^(gap|row-gap|column-gap|padding|margin)(-(top|right|bottom|left|block|inline)(-(start|end))?)?$/
|
|
31
|
-
const TYPE_PROPS = /^(font-size|font-weight|line-height|letter-spacing)$/
|
|
32
|
-
|
|
33
|
-
const kebab = s => s.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`)
|
|
34
|
-
|
|
35
|
-
/** `16px 8px` → the same with tokens, or null if any part isn't exactly on the scale. */
|
|
36
|
-
function scaled(value) {
|
|
37
|
-
const parts = value.trim().split(/\s+/)
|
|
38
|
-
const out = parts.map(p => {
|
|
39
|
-
if (p === '0' || p === '0px' || p === 'auto') return p
|
|
40
|
-
const m = /^(\d+(?:\.\d+)?)(px|rem)?$/.exec(p)
|
|
41
|
-
if (!m) return null
|
|
42
|
-
const px = m[2] === 'rem' ? Number(m[1]) * 16 : Number(m[1])
|
|
43
|
-
return SPACE.has(px) ? `var(--warp-space-${SPACE.get(px)})` : null
|
|
44
|
-
})
|
|
45
|
-
return out.every(Boolean) ? out.join(' ') : null
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** Declarations in a `style` attribute: `{ prop, value, numeric, start, end }` with offsets into the file. */
|
|
49
|
-
function styleDecls(attr) {
|
|
50
|
-
if (attr.kind === 'string') {
|
|
51
|
-
const at = attr.valueStart + 1
|
|
52
|
-
return [...attr.value.matchAll(/([\w-]+)\s*:\s*([^;]+)/g)].map(m => {
|
|
53
|
-
const start = at + m.index + m[0].indexOf(m[2], m[1].length + 1)
|
|
54
|
-
return { prop: m[1].toLowerCase(), value: m[2].trim(), start, end: start + m[2].trimEnd().length }
|
|
55
|
-
})
|
|
56
|
-
}
|
|
57
|
-
const out = []
|
|
58
|
-
for (const m of (attr.expr ?? '').matchAll(/([\w$]+|['"][\w-]+['"])\s*:\s*(?:(['"`])([^'"`$]*)\2|(\d+(?:\.\d+)?)(?=\s*[,}\s]))/g)) {
|
|
59
|
-
const value = m[3] ?? m[4]
|
|
60
|
-
const inner = m[0].lastIndexOf(value)
|
|
61
|
-
const start = attr.valueStart + m.index + inner - (m[3] !== undefined ? 1 : 0)
|
|
62
|
-
out.push({ prop: kebab(m[1].replace(/['"]/g, '')), value, numeric: m[3] === undefined, start, end: start + value.length + (m[3] !== undefined ? 2 : 0), quote: m[2] })
|
|
63
|
-
}
|
|
64
|
-
return out
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** The D-code for one raw declaration, or null. `report` takes (code, start, end, why, fix). */
|
|
68
|
-
function declaration({ prop, value, numeric, start, end, quote }, report) {
|
|
69
|
-
const css = (numeric && SPACE_PROPS.test(prop) ? `${value}px` : value).replace(/\s*!important\s*$/i, '')
|
|
70
|
-
if (/var\(|^(inherit|initial|unset|transparent|currentcolor|none)$/i.test(css.trim())) return
|
|
71
|
-
if (COLOR_PROPS.test(prop) && COLOR.test(css)) return report('D001', start, end, `Raw colour \`${prop}: ${css}\`: it won't follow the theme, the dials or dark mode. Use ${COLOR_EXPECTED}.`)
|
|
72
|
-
if (SPACE_PROPS.test(prop)) {
|
|
73
|
-
if (/^(0(px)?|auto)$/.test(css.trim())) return
|
|
74
|
-
const token = scaled(css)
|
|
75
|
-
const text = token && (quote ? `${quote}${token}${quote}` : numeric ? `'${token}'` : token)
|
|
76
|
-
return report('D002', start, end, `\`${prop}: ${css}\` is ${token ? 'a scale step written by hand' : 'off the spacing scale'}: gaps are relationships (${SPACE_NAMES.join(', ')}).`, token ? { description: `Use ${token}`, safe: true, text } : undefined)
|
|
77
|
-
}
|
|
78
|
-
if (TYPE_PROPS.test(prop)) return report('D003', start, end, `Raw \`${prop}: ${css}\`: type comes from roles, not sizes. Use \`<Heading role>\` or \`<Text role>\` (display title heading body label caption eyebrow code).`)
|
|
79
|
-
if (prop === 'border-radius') {
|
|
80
|
-
const m = /^(\d+)(?:px)?$/.exec(css.trim())
|
|
81
|
-
const name = m && RADIUS.get(Number(m[1]))
|
|
82
|
-
const text = name && `var(--warp-radius-${name})`
|
|
83
|
-
return report('D003', start, end, `Raw \`border-radius: ${css}\`: radii come from the expression dial (mark, control, surface, round).`, name ? { description: `Use ${text}`, safe: true, text: quote ? `${quote}${text}${quote}` : numeric ? `'${text}'` : text } : undefined)
|
|
84
|
-
}
|
|
85
|
-
if (prop === 'box-shadow' && !/^none$/i.test(css.trim())) return report('D003', start, end, 'A raw shadow: elevation comes from `--warp-elevation-0…3`, which follow the dials and the theme.')
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// -- Tailwind classes, for an app that has warp's tokens too --
|
|
89
|
-
|
|
90
|
-
const TW_SPACE = new Map([['0.5', 'hair'], ['1', 'tight'], ['2', 'related'], ['4', 'group']])
|
|
91
|
-
const TW_PALETTE = 'slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose'
|
|
92
|
-
const TW_PX = 4
|
|
93
|
-
|
|
94
|
-
/** D001–D003 for one class; returns `[code, why, replacement?]` or null. */
|
|
95
|
-
function utility(cls) {
|
|
96
|
-
const bare = cls.slice(cls.lastIndexOf(':') + 1).replace(/^-/, '').replace(/!$/, '')
|
|
97
|
-
const variant = cls.slice(0, cls.length - cls.slice(cls.lastIndexOf(':') + 1).length)
|
|
98
|
-
const space = /^(p|px|py|pt|pr|pb|pl|ps|pe|m|mx|my|mt|mr|mb|ml|ms|me|gap|gap-x|gap-y|space-x|space-y)-(.+)$/.exec(bare)
|
|
99
|
-
if (space) {
|
|
100
|
-
const [, util, v] = space
|
|
101
|
-
if (['auto', 'px', '0'].includes(v) || SPACE_NAMES.includes(v)) return null
|
|
102
|
-
const arb = /^\[(.+)\]$/.exec(v)?.[1]
|
|
103
|
-
const px = arb ? (/^(\d+(?:\.\d+)?)(px|rem)$/.exec(arb) ?? [])[1] * (arb.endsWith('rem') ? 16 : 1) : Number(v) * TW_PX
|
|
104
|
-
const name = arb ? SPACE.get(px) : TW_SPACE.get(v)
|
|
105
|
-
if (arb || !Number.isNaN(Number(v))) return ['D002', `\`${cls}\` is ${name ? 'a scale step under another name' : 'off the spacing scale'}.`, name && `${variant}${util}-${name}`]
|
|
106
|
-
return null
|
|
107
|
-
}
|
|
108
|
-
if (new RegExp(`^(bg|text|border|ring|fill|stroke|from|to|via|divide|outline|decoration|accent|caret|shadow)-(${TW_PALETTE})-\\d{2,3}(/\\d+)?$`).test(bare) || /^(bg|text|border|ring|fill|stroke)-(white|black)$/.test(bare))
|
|
109
|
-
return ['D001', `\`${cls}\` is a raw palette colour: it won't follow the theme or the dials. Use \`bg-surface\`, \`text-text\`, \`text-text-subtle\`, \`bg-accent\`, \`border-border\`.`]
|
|
110
|
-
if (/^(bg|text|border|ring|fill|stroke|from|to|via|outline)-\[(#|rgb|hsl|oklch)/.test(bare)) return ['D001', `\`${cls}\` is a raw colour: it won't follow the theme or the dials.`]
|
|
111
|
-
const radius = /^rounded(-[a-z]+)?-\[(\d+)px\]$/.exec(bare)
|
|
112
|
-
if (radius) return ['D003', `\`${cls}\` is a raw radius.`, RADIUS.has(Number(radius[2])) && !radius[1] ? `${variant}rounded-${RADIUS.get(Number(radius[2]))}` : undefined]
|
|
113
|
-
if (/^(text|leading|tracking|font|shadow)-\[/.test(bare) || /^(text-(xs|sm|base|lg|[2-9]?xl)|font-(thin|light|normal|medium|semibold|bold|extrabold|black))$/.test(bare)) return ['D003', `\`${cls}\`: type and elevation come from roles, not sizes. Use \`<Heading role>\` or \`<Text role>\`.`]
|
|
114
|
-
return null
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** `<div class="flex flex-col gap-4">` and `style={{ display: 'flex', … }}`: the primitive it spells out, or null. */
|
|
118
|
-
function layoutOf(el) {
|
|
119
|
-
const cls = el.attr('class') ?? el.attr('className')
|
|
120
|
-
if (cls?.kind === 'string') {
|
|
121
|
-
const c = cls.value.split(/\s+/).filter(Boolean)
|
|
122
|
-
if (!c.includes('flex') && !c.includes('grid')) return null
|
|
123
|
-
const dir = c.includes('flex-col') ? 'Stack' : c.includes('flex') ? 'Row' : null
|
|
124
|
-
if (!dir) return null
|
|
125
|
-
const gap = c.find(x => x.startsWith('gap-'))?.slice(4)
|
|
126
|
-
const name = SPACE_NAMES.includes(gap) ? gap : TW_SPACE.get(gap)
|
|
127
|
-
return { primitive: dir, gap: name, attr: cls, exact: c.every(x => ['flex', 'flex-col', 'flex-row', 'flex-wrap'].includes(x) || x.startsWith('gap-')) && (!gap || !!name) }
|
|
128
|
-
}
|
|
129
|
-
const style = el.attr('style')
|
|
130
|
-
if (!style) return null
|
|
131
|
-
const decls = styleDecls(style)
|
|
132
|
-
if (!decls.some(d => d.prop === 'display' && d.value === 'flex')) return null
|
|
133
|
-
const dir = decls.find(d => d.prop === 'flex-direction')?.value
|
|
134
|
-
const gap = decls.find(d => d.prop === 'gap')
|
|
135
|
-
const px = gap && Number(gap.value.replace('px', ''))
|
|
136
|
-
const name = gap && SPACE.get(px)
|
|
137
|
-
const only = decls.every(d => ['display', 'flex-direction', 'gap'].includes(d.prop))
|
|
138
|
-
return { primitive: dir === 'column' ? 'Stack' : 'Row', gap: name, attr: style, exact: only && (!gap || !!name) && style.kind === 'expr' && !/\.\.\./.test(style.expr) && (dir === 'column' || dir === undefined ? true : dir === 'row') }
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const NATIVE = { button: 'Button', select: 'Select', textarea: 'Textarea' }
|
|
142
|
-
// the input types a browser paints, and the warp control for each; `Input` passes `type` through, so it counts too
|
|
143
|
-
const NATIVE_TYPES = { date: 'DateField', 'datetime-local': 'DateTimeField', time: 'TimeField', month: 'MonthPicker', week: 'DateField', number: 'NumberField', range: 'Slider', color: 'ColorField', checkbox: 'Checkbox', radio: 'Radio', file: 'a dropzone' }
|
|
144
|
-
const INTERACTIVE = new Set(['a', 'button', 'input', 'select', 'textarea', 'summary', 'Button', 'Toggle', 'Input', 'Search', 'Textarea', 'Select', 'Checkbox', 'Switch', 'Chip', 'Link'])
|
|
145
|
-
const STATIC = new Set(['div', 'span', 'p', 'li', 'section', 'article', 'td', 'tr', 'img', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'label'])
|
|
146
|
-
|
|
147
|
-
const NAMED = ['aria-label', 'aria-labelledby', 'title']
|
|
148
|
-
/** Elements whose implicit role (generic, paragraph, emphasis…) may not be named, and the explicit roles that can't be either. */
|
|
149
|
-
const GENERIC = new Set(['div', 'span', 'p', 'b', 'i', 'em', 'strong', 'small', 'code', 's', 'u', 'sub', 'sup', 'mark', 'del', 'ins', 'kbd', 'samp', 'var'])
|
|
150
|
-
const UNNAMED = new Set(['generic', 'presentation', 'none', 'paragraph', 'caption', 'code', 'deletion', 'emphasis', 'insertion', 'strong', 'subscript', 'superscript', 'mark', 'time'])
|
|
151
|
-
const named = el => NAMED.some(n => el.attr(n)) || el.attrs.some(a => a.kind === 'spread')
|
|
152
|
-
/** Children with something a screen reader can read: text, an expression, a component, an image with alt. */
|
|
153
|
-
const hasName = children => {
|
|
154
|
-
if (/<img\b[^>]*\balt=(["'])[^"']+\1/.test(children)) return true
|
|
155
|
-
const stripped = children.replace(/<svg[\s\S]*?<\/svg>/g, '').replace(/<(Icon|[A-Z]\w*Icon)\b[^>]*\/>/g, '').replace(/<[^>]*aria-hidden[^>]*>[\s\S]*?<\/[^>]+>/g, '')
|
|
156
|
-
return /[^\s<>{}]/.test(stripped.replace(/<\/?[a-z][^>]*>/g, ''))
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/** Parse `#rgb`, `#rrggbb`, `rgb(r, g, b)`, `white` and `black`, or null. */
|
|
160
|
-
function literalColor(v) {
|
|
161
|
-
const s = v.trim().toLowerCase()
|
|
162
|
-
if (s === 'white') return [255, 255, 255]
|
|
163
|
-
if (s === 'black') return [0, 0, 0]
|
|
164
|
-
const h = /^#([0-9a-f]{3}|[0-9a-f]{6})$/.exec(s)?.[1]
|
|
165
|
-
if (h) return (h.length === 3 ? Array.from(h, c => c + c) : h.match(/../g)).map(x => parseInt(x, 16))
|
|
166
|
-
const m = /^rgb\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)\s*\)$/.exec(s)
|
|
167
|
-
return m ? m.slice(1).map(Number) : null
|
|
168
|
-
}
|
|
169
|
-
const lum = c => { const [r, g, b] = c.map(x => { const s = x / 255; return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4 }); return 0.2126 * r + 0.7152 * g + 0.0722 * b }
|
|
170
|
-
const ratio = (a, b) => { const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p); return (x + 0.05) / (y + 0.05) }
|
|
171
|
-
|
|
172
|
-
/** Lines an allow comment covers: `// weft-allow D001: reason` (or `{/* … *\/}`) on the line above or at the line's end. */
|
|
173
|
-
function allowed(text) {
|
|
174
|
-
const out = new Map()
|
|
175
|
-
text.split('\n').forEach((line, i) => {
|
|
176
|
-
const m = /weft-allow\s+((?:D\d{3}[\s,]*)+):\s*\S/.exec(line)
|
|
177
|
-
if (!m) return
|
|
178
|
-
for (const code of m[1].match(/D\d{3}/g)) for (const n of [i + 1, i + 2]) out.set(`${code}:${n}`, true)
|
|
179
|
-
})
|
|
180
|
-
return out
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/** The edit that imports `names` from warp: into the file's existing import, or a new first line. */
|
|
184
|
-
function addImport(text, names) {
|
|
185
|
-
const m = new RegExp(`import\\s*\\{([^}]*)\\}\\s*from\\s*['"]${WARP}['"]`).exec(text)
|
|
186
|
-
if (!m) return [{ start: 0, end: 0, text: `import { ${names.join(', ')} } from '${WARP}'\n` }]
|
|
187
|
-
const have = m[1].split(',').map(s => s.trim()).filter(Boolean)
|
|
188
|
-
const missing = names.filter(n => !have.includes(n))
|
|
189
|
-
if (!missing.length) return []
|
|
190
|
-
const at = m.index + m[0].indexOf(m[1]) + m[1].trimEnd().length
|
|
191
|
-
return [{ start: at, end: at, text: `, ${missing.join(', ')}` }]
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/** Does the app have warp's tokens? */
|
|
195
20
|
export function hasTokens(root) {
|
|
196
21
|
const file = join(root, 'package.json')
|
|
197
22
|
if (!existsSync(file)) return false
|
|
@@ -208,232 +33,17 @@ export function hasTokens(root) {
|
|
|
208
33
|
* @param {{ files: { rel: string, text: string }[], exclude?: string[], tokens: boolean }} options
|
|
209
34
|
*/
|
|
210
35
|
export function designChecks(root, { files: all, exclude = [], tokens }) {
|
|
211
|
-
const files = all.filter(f => !/\.og\.[jt]sx$/.test(f.rel)) // a share card is drawn to a PNG: no theme, no tokens, no reader
|
|
36
|
+
const files = all.filter((f) => !/\.og\.[jt]sx$/.test(f.rel)) // a share card is drawn to a PNG: no theme, no tokens, no reader
|
|
212
37
|
const out = []
|
|
213
38
|
const looks = new Map() // heading level → every site that renders it, across the app, for D006
|
|
214
|
-
const reporter =
|
|
215
|
-
const pos = positions(file.text)
|
|
216
|
-
const allow = allowed(file.text)
|
|
217
|
-
return (code, start, end, why, fix, severity) => {
|
|
218
|
-
if (allow.has(`${code}:${pos.lineCol(start)[0]}`)) return
|
|
219
|
-
const edits = fix && (fix.edits ?? [{ start: fix.at?.[0] ?? start, end: fix.at?.[1] ?? end, text: fix.text }]).map(e => ({ file: file.rel, span: pos.span(e.start, e.end), offset: [e.start, e.end], text: e.text }))
|
|
220
|
-
out.push(diagnostic({ code, severity, file: file.rel, span: pos.span(start, end), offset: [start, end], why, fix: fix && { description: fix.description, safe: fix.safe, edits } }))
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
for (const f of files) {
|
|
225
|
-
if (!/\.[jt]sx$/.test(f.rel)) continue
|
|
226
|
-
let scan
|
|
227
|
-
try {
|
|
228
|
-
scan = scanJsx(f.text, f.rel)
|
|
229
|
-
} catch {
|
|
230
|
-
continue // syntax errors are W000's
|
|
231
|
-
}
|
|
232
|
-
const report = reporter(f)
|
|
233
|
-
const localComponents = new Set(scan.functions.keys())
|
|
234
|
-
const warpName = n => scan.imports.some(i => i.source === WARP && i.local === n)
|
|
235
|
-
const labelled = new Set(scan.elements.map(e => e.attr('for')?.value ?? e.attr('htmlFor')?.value).filter(Boolean))
|
|
236
|
-
const headings = []
|
|
237
|
-
const solids = new Map()
|
|
238
|
-
|
|
239
|
-
for (const el of scan.elements) {
|
|
240
|
-
// -- values --
|
|
241
|
-
if (tokens) {
|
|
242
|
-
const style = el.attr('style')
|
|
243
|
-
if (style) for (const d of styleDecls(style)) declaration(d, report)
|
|
244
|
-
const cls = el.attr('class') ?? el.attr('className')
|
|
245
|
-
if (cls?.kind === 'string') {
|
|
246
|
-
let at = cls.valueStart + 1
|
|
247
|
-
for (const c of cls.value.split(/(\s+)/)) {
|
|
248
|
-
const hit = c.trim() && utility(c)
|
|
249
|
-
if (hit) report(hit[0], at, at + c.length, hit[1], hit[2] ? { description: `Use ${hit[2]}`, safe: true, text: hit[2] } : undefined)
|
|
250
|
-
at += c.length
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
const layout = /^[a-z]/.test(el.name) && ['div', 'section', 'ul', 'ol', 'nav', 'header', 'footer', 'main', 'aside', 'article', 'form'].includes(el.name) && layoutOf(el)
|
|
254
|
-
if (layout) {
|
|
255
|
-
const close = f.text.lastIndexOf('</', el.end - 1)
|
|
256
|
-
const selfClosed = !f.text.slice(el.start, el.end).includes(`</${el.name}`)
|
|
257
|
-
const as = el.name === 'div' ? '' : ` as="${el.name}"`
|
|
258
|
-
const gap = layout.gap ? ` gap="${layout.gap}"` : ''
|
|
259
|
-
const sole = el.attrs.length === 1
|
|
260
|
-
const fix = layout.exact && sole && !selfClosed && {
|
|
261
|
-
description: `Use <${layout.primitive}>`, safe: false,
|
|
262
|
-
edits: [
|
|
263
|
-
{ start: el.start + 1, end: el.attrs[0].end, text: `${layout.primitive}${as}${gap}` },
|
|
264
|
-
{ start: close + 2, end: el.end - 1, text: layout.primitive },
|
|
265
|
-
...addImport(f.text, [layout.primitive]),
|
|
266
|
-
].sort((a, b) => b.start - a.start),
|
|
267
|
-
}
|
|
268
|
-
report('D004', el.start, el.tagEnd, `Hand-rolled layout on <${el.name}>: <${layout.primitive}> says what this is and keeps the gap on the scale.`, fix || undefined)
|
|
269
|
-
}
|
|
270
|
-
const bare = /^h[1-6]$/.test(el.name)
|
|
271
|
-
if (bare || (el.name === 'Heading' && warpName('Heading'))) {
|
|
272
|
-
const site = headingSite(el, bare)
|
|
273
|
-
if (site && !allowed(f.text).has(`D006:${positions(f.text).lineCol(el.start)[0]}`)) looks.set(site.level, [...(looks.get(site.level) ?? []), { ...site, el, text: f.text, report }])
|
|
274
|
-
}
|
|
275
|
-
if (el.attr('data-weft')?.value === 'styled' && (/^(?:main|h1|article)$/.test(el.name) || el.attr('role')?.value === 'main' || el.attr('data-vt')?.value === 'main')) {
|
|
276
|
-
report('D040', el.start, el.tagEnd, `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.`, 'Mark a secondary part, or remove `data-weft="styled"`')
|
|
277
|
-
}
|
|
278
|
-
if (el.name === 'a' && el.attr('href') && !el.attr('class') && !el.attr('className') && !el.attr('style') && !el.attr('data-scope') && !el.attr('data-part') && !el.attrs.some(a => a.kind === 'spread')) {
|
|
279
|
-
const taken = localComponents.has('Link') || scan.imports.some(i => i.local === 'Link' && i.source !== WARP)
|
|
280
|
-
const close = f.text.lastIndexOf('</', el.end - 1)
|
|
281
|
-
report('D007', el.start, el.tagEnd, '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.', !taken && f.text.slice(el.start, el.end).includes('</a') ? {
|
|
282
|
-
description: 'Use <Link>', safe: false,
|
|
283
|
-
edits: [{ start: el.start + 1, end: el.tagEnd, text: 'Link' }, { start: close + 2, end: el.end - 1, text: 'Link' }, ...addImport(f.text, ['Link'])].sort((a, b) => b.start - a.start),
|
|
284
|
-
} : undefined)
|
|
285
|
-
}
|
|
286
|
-
const typed = (el.name === 'input' || el.name === 'Input') && NATIVE_TYPES[el.attr('type')?.value]
|
|
287
|
-
if (typed && !el.attr('hidden') && !el.attr('data-part')) report('D005', el.start, el.tagEnd, `\`type="${el.attr('type').value}"\` is painted by the browser: <${typed}> is warp's.`)
|
|
288
|
-
if (/^[a-z]/.test(el.name) && el.name !== 'iframe' && el.attr('title')) report('D005', el.start, el.tagEnd, 'A `title` tooltip: the browser paints it, late, and never on touch or keyboard. Use <Tooltip>, or `aria-label` for a name.')
|
|
289
|
-
if (NATIVE[el.name] && !localComponents.has(NATIVE[el.name])) {
|
|
290
|
-
const swap = el.name === 'button' && !el.attr('class') && !el.attr('className') && !el.attr('style') && f.text.slice(el.start, el.end).includes('</button')
|
|
291
|
-
report('D005', el.start, el.tagEnd, `Native <${el.name}> in a warp app: <${NATIVE[el.name]}> brings the focus ring, the sweep, the tones and the touch target.`, swap ? {
|
|
292
|
-
description: 'Use <Button>', safe: false,
|
|
293
|
-
edits: [{ start: el.start + 1, end: el.tagEnd, text: 'Button' }, { start: f.text.lastIndexOf('</', el.end - 1) + 2, end: el.end - 1, text: 'Button' }, ...addImport(f.text, ['Button'])].sort((a, b) => b.start - a.start),
|
|
294
|
-
} : undefined)
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// -- contrast of a literal pair --
|
|
299
|
-
const style = el.attr('style')
|
|
300
|
-
if (style) {
|
|
301
|
-
const decls = styleDecls(style)
|
|
302
|
-
const fg = decls.find(d => d.prop === 'color')
|
|
303
|
-
const bg = decls.find(d => d.prop === 'background' || d.prop === 'background-color')
|
|
304
|
-
const [a, b] = [fg && literalColor(fg.value), bg && literalColor(bg.value)]
|
|
305
|
-
if (a && b && ratio(a, b) < 4.5) report('D011', style.start, style.end, `\`${fg.value}\` on \`${bg.value}\` is ${ratio(a, b).toFixed(2)}:1; body text needs 4.5:1 (WCAG 1.4.3).`)
|
|
306
|
-
const none = decls.find(d => (d.prop === 'outline' || d.prop === 'outline-width') && /^(none|0(px)?)$/.test(d.value))
|
|
307
|
-
if (none) report('D036', none.start, none.end, 'This removes the focus ring with nothing in its place: keyboard users lose their place (WCAG 2.4.7).')
|
|
308
|
-
}
|
|
309
|
-
const cls = el.attr('class') ?? el.attr('className')
|
|
310
|
-
if (cls?.kind === 'string' && /(^|\s)(focus:)?outline-none(\s|$)/.test(cls.value) && !/focus(-visible)?:(ring|outline(?!-none))/.test(cls.value)) report('D036', cls.start, cls.end, '`outline-none` with no `focus-visible:` style in its place: keyboard users lose their place (WCAG 2.4.7).')
|
|
311
|
-
|
|
312
|
-
// -- accessible names --
|
|
313
|
-
if (['input', 'select', 'textarea'].includes(el.name)) {
|
|
314
|
-
const type = el.attr('type')?.value
|
|
315
|
-
const skip = ['hidden', 'submit', 'button', 'reset', 'image'].includes(type)
|
|
316
|
-
const id = el.attr('id')
|
|
317
|
-
// A computed id can pair with a label anywhere, a component around the field can render its label, and an
|
|
318
|
-
// aria-hidden field (a honeypot) has no name to give. `for` counts on a component too: a `<Field for>` passes it on.
|
|
319
|
-
const unseen = id?.kind === 'expr' || el.attr('aria-hidden')?.value === 'true' || el.ancestors.some(a => /^[A-Z]/.test(a))
|
|
320
|
-
if (!skip && !unseen && !named(el) && !(id?.value && labelled.has(id.value)) && !el.ancestors.includes('label')) report('D030', el.start, el.tagEnd, `<${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.' : ''}`)
|
|
321
|
-
}
|
|
322
|
-
if (['button', 'a', 'Button', 'Link'].includes(el.name) && !el.attr('label') && !named(el) && !(el.name === 'a' && !el.attr('href')) && !hasName(el.children)) report('D031', el.start, el.tagEnd, `<${el.name}> has no name a screen reader can read: only an icon, or nothing (WCAG 4.1.2).`)
|
|
323
|
-
const label = el.attr('aria-label') ?? el.attr('aria-labelledby')
|
|
324
|
-
const role = el.attr('role')
|
|
325
|
-
if (label && GENERIC.has(el.name) && (!role || (role.kind === 'string' && UNNAMED.has(role.value)))) report('D041', label.start, label.end, `\`${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).`)
|
|
326
|
-
if (el.name === 'img' && !el.attr('alt') && !el.attrs.some(a => a.kind === 'spread')) report('D032', el.start, el.tagEnd, '<img> has no `alt`: a screen reader reads out the file name (WCAG 1.1.1).')
|
|
327
|
-
|
|
328
|
-
// -- behaviour on the wrong element --
|
|
329
|
-
if (STATIC.has(el.name) && el.attr('onClick') && !el.attr('role') && !el.attr('tabindex') && !el.attr('tabIndex')) {
|
|
330
|
-
const swap = (el.name === 'div' || el.name === 'span') && f.text.slice(el.start, el.end).includes(`</${el.name}`)
|
|
331
|
-
report('D033', el.start, el.tagEnd, `onClick on <${el.name}>: no keyboard, no focus, no role, so it only works with a mouse (WCAG 2.1.1).`, swap ? {
|
|
332
|
-
description: 'Use a real button', safe: false,
|
|
333
|
-
edits: [{ start: el.start + 1, end: el.tagEnd, text: 'button type="button"' }, { start: f.text.lastIndexOf('</', el.end - 1) + 2, end: el.end - 1, text: 'button' }].sort((a, b) => b.start - a.start),
|
|
334
|
-
} : undefined)
|
|
335
|
-
}
|
|
336
|
-
if (INTERACTIVE.has(el.name) && el.ancestors.some(a => INTERACTIVE.has(a) && a !== 'label')) {
|
|
337
|
-
const parent = [...el.ancestors].reverse().find(a => INTERACTIVE.has(a))
|
|
338
|
-
report('D034', el.start, el.tagEnd, `<${el.name}> inside <${parent}>: nested controls don't reach the keyboard and screen readers merge them.`)
|
|
339
|
-
}
|
|
340
|
-
const tabindex = el.attr('tabindex') ?? el.attr('tabIndex')
|
|
341
|
-
const tab = tabindex?.kind === 'string' ? Number(tabindex.value) : tabindex?.kind === 'expr' ? Number(tabindex.expr) : 0
|
|
342
|
-
if (tab > 0) report('D037', tabindex.start, tabindex.end, `\`tabindex="${tab}"\` jumps the page's tab order (WCAG 2.4.3).`, { description: 'Use tabindex 0', safe: true, text: tabindex.kind === 'string' ? '"0"' : '0', at: [tabindex.valueStart, tabindex.valueEnd] })
|
|
39
|
+
const reporter = makeReporter(out)
|
|
343
40
|
|
|
344
|
-
|
|
345
|
-
const h = /^h([1-6])$/.exec(el.name)
|
|
346
|
-
const level = h ? Number(h[1]) : el.name === 'Heading' && warpName('Heading') ? Number(el.attr('level')?.value ?? /^\d$/.exec(el.attr('level')?.expr ?? '')?.[0] ?? 2) : 0
|
|
347
|
-
if (level) headings.push({ level, el })
|
|
348
|
-
|
|
349
|
-
// -- one primary action per region --
|
|
350
|
-
if (el.name === 'Button' && warpName('Button') && (!el.attr('emphasis') || el.attr('emphasis')?.value === 'solid') && !el.attr('tone')) {
|
|
351
|
-
const region = el.ancestors.findLast(a => ['Card', 'Modal', 'Drawer', 'Popover', 'section', 'form', 'header', 'footer', 'main', 'aside', 'Alert'].includes(a)) ?? ''
|
|
352
|
-
const key = `${region}:${el.ancestors.length}:${scanParent(el, scan.elements)?.start ?? 0}`
|
|
353
|
-
solids.set(key, [...(solids.get(key) ?? []), el])
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
for (let i = 1; i < headings.length; i++) {
|
|
358
|
-
const { level, el } = headings[i]
|
|
359
|
-
if (level > headings[i - 1].level + 1) report('D035', el.start, el.tagEnd, `The outline jumps from h${headings[i - 1].level} to h${level}: screen-reader users navigate by headings (WCAG 1.3.1).`, undefined, 'warning')
|
|
360
|
-
}
|
|
361
|
-
for (const group of solids.values()) {
|
|
362
|
-
for (const el of group.slice(1)) report('D038', el.start, el.tagEnd, `${group.length} solid Buttons side by side: one primary action per region.`, { description: 'Make this one soft', safe: false, text: ' emphasis="soft"', at: [el.tagEnd, el.tagEnd] })
|
|
363
|
-
}
|
|
364
|
-
}
|
|
41
|
+
checkJsx({ files, reporter, tokens, looks })
|
|
365
42
|
|
|
366
43
|
for (const [level, sites] of looks) roleDrift(level, sites)
|
|
367
44
|
|
|
368
|
-
|
|
369
|
-
for (const rel of discover(root, { include: ['**/*.css'], exclude })) {
|
|
370
|
-
const text = readFileSync(join(root, rel), 'utf8')
|
|
371
|
-
const css = text.replace(/\/\*[\s\S]*?\*\//g, m => m.replace(/[^\n]/g, ' '))
|
|
372
|
-
const file = { rel, text }
|
|
373
|
-
const report = reporter(file)
|
|
374
|
-
for (const m of css.matchAll(/(?<=[{;\s])(-?-?[\w-]+)\s*:\s*([^;{}]+)/g)) {
|
|
375
|
-
const prop = m[1].toLowerCase()
|
|
376
|
-
if (prop.startsWith('--')) continue
|
|
377
|
-
const start = m.index + m[0].indexOf(m[2], m[1].length + 1)
|
|
378
|
-
if (tokens) declaration({ prop, value: m[2].trim(), start, end: start + m[2].trimEnd().length }, report)
|
|
379
|
-
if ((prop === 'outline' || prop === 'outline-width') && /^(none|0(px)?)\s*$/.test(m[2])) {
|
|
380
|
-
const rule = css.lastIndexOf('}', m.index)
|
|
381
|
-
const selector = css.slice(rule + 1, css.indexOf('{', rule + 1))
|
|
382
|
-
if (/:focus(?!-within)/.test(selector) && !/box-shadow|border/.test(css.slice(m.index, css.indexOf('}', m.index)))) report('D036', start, start + m[2].trimEnd().length, `\`${selector.trim()}\` removes the focus ring with nothing in its place: keyboard users lose their place (WCAG 2.4.7).`)
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
}
|
|
45
|
+
checkCsp(root, files, reporter)
|
|
386
46
|
|
|
387
|
-
|
|
388
|
-
for (const rel of discover(root, { include: ['**/*.html'], exclude })) {
|
|
389
|
-
const text = readFileSync(join(root, rel), 'utf8')
|
|
390
|
-
const m = /<html\b[^>]*>/i.exec(text)
|
|
391
|
-
if (m && !/\blang\s*=/.test(m[0])) reporter({ rel, text })('D039', m.index, m.index + m[0].length, '<html> has no `lang`: a screen reader guesses the language to speak (WCAG 3.1.1).', { description: 'Set lang="en"', safe: false, text: `${m[0].slice(0, -1)} lang="en">` })
|
|
392
|
-
}
|
|
47
|
+
checkFiles(root, exclude, tokens, reporter)
|
|
393
48
|
return out
|
|
394
49
|
}
|
|
395
|
-
|
|
396
|
-
/** What one heading renders at: `{ level, role, set }`, or null when a `level` or `role` expression hides it. `role` is null for a bare `<hN>`. */
|
|
397
|
-
function headingSite(el, bare) {
|
|
398
|
-
if (bare) return { level: Number(el.name[1]), role: null }
|
|
399
|
-
const lv = el.attr('level')
|
|
400
|
-
const level = !lv ? 2 : Number(lv.value ?? /^\d$/.exec(lv.expr ?? '')?.[0])
|
|
401
|
-
if (!HEADING_ROLE[level]) return null
|
|
402
|
-
const attr = el.attr('role')
|
|
403
|
-
if (attr && attr.kind !== 'string') return null
|
|
404
|
-
return { level, role: attr?.value ?? HEADING_ROLE[level], set: attr }
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
/** D006: the roles one level renders at across the app. The most used `Heading` role wins, then the level's default; every other site is fixed to it. */
|
|
408
|
-
function roleDrift(level, sites) {
|
|
409
|
-
const counts = new Map()
|
|
410
|
-
for (const s of sites) if (s.role) counts.set(s.role, (counts.get(s.role) ?? 0) + 1)
|
|
411
|
-
if (!counts.size || new Set(sites.map(s => s.role)).size < 2) return
|
|
412
|
-
const fallback = HEADING_ROLE[level]
|
|
413
|
-
const top = Math.max(...counts.values())
|
|
414
|
-
const role = counts.get(fallback) === top ? fallback : [...counts].find(([, n]) => n === top)[0]
|
|
415
|
-
const prop = role === fallback ? '' : ` role="${role}"`
|
|
416
|
-
const all = [...counts.keys()].join(', ')
|
|
417
|
-
for (const { el, text, report, role: has, set } of sites) {
|
|
418
|
-
if (has === role) continue
|
|
419
|
-
if (has === null) {
|
|
420
|
-
const swap = !el.attr('class') && !el.attr('className') && !el.attr('style') && text.slice(el.start, el.end).includes(`</${el.name}`)
|
|
421
|
-
report('D006', el.start, el.tagEnd, `A bare <h${level}> beside <Heading level={${level}}>: this one renders at the browser's size, the rest at \`${role}\`.`, swap ? {
|
|
422
|
-
description: `Use <Heading level={${level}}>`, safe: false,
|
|
423
|
-
edits: [{ start: el.start + 1, end: el.tagEnd, text: `Heading level={${level}}${prop}` }, { start: text.lastIndexOf('</', el.end - 1) + 2, end: el.end - 1, text: 'Heading' }, ...addImport(text, ['Heading'])].sort((a, b) => b.start - a.start),
|
|
424
|
-
} : undefined)
|
|
425
|
-
continue
|
|
426
|
-
}
|
|
427
|
-
const fix = set
|
|
428
|
-
? { description: role === fallback ? `Drop \`role\`: h${level} is \`${role}\`` : `Use role="${role}"`, safe: false, edits: [role === fallback ? { start: text.slice(0, set.start).trimEnd().length, end: set.end, text: '' } : { start: set.valueStart, end: set.valueEnd, text: `"${role}"` }] }
|
|
429
|
-
: { description: `Use role="${role}"`, safe: false, text: prop, at: [el.tagEnd, el.tagEnd] }
|
|
430
|
-
report('D006', el.start, el.tagEnd, `h${level} renders at two roles in this app (${all}): a level is one size everywhere. This one is \`${has}\`, the rest \`${role}\`. \`// weft-allow D006: <reason>\` keeps a real exception.`, fix)
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
/** The nearest element that contains `el`. */
|
|
435
|
-
function scanParent(el, elements) {
|
|
436
|
-
let best
|
|
437
|
-
for (const e of elements) if (e !== el && e.start <= el.start && e.end >= el.end && (!best || e.start >= best.start)) best = e
|
|
438
|
-
return best
|
|
439
|
-
}
|
package/src/diag.mjs
CHANGED
|
@@ -13,8 +13,8 @@ export function positions(text) {
|
|
|
13
13
|
const ascii = bytes.length === text.length
|
|
14
14
|
const lineStarts = [0]
|
|
15
15
|
for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 10) lineStarts.push(i + 1)
|
|
16
|
-
const fromByte = b => (ascii ? b : bytes.subarray(0, b).toString('utf8').length)
|
|
17
|
-
const lineCol = index => {
|
|
16
|
+
const fromByte = (b) => (ascii ? b : bytes.subarray(0, b).toString('utf8').length)
|
|
17
|
+
const lineCol = (index) => {
|
|
18
18
|
let lo = 0
|
|
19
19
|
let hi = lineStarts.length - 1
|
|
20
20
|
while (lo < hi) {
|
|
@@ -28,7 +28,18 @@ export function positions(text) {
|
|
|
28
28
|
return { fromByte, lineCol, span }
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
export function diagnostic({
|
|
31
|
+
export function diagnostic({
|
|
32
|
+
code,
|
|
33
|
+
severity,
|
|
34
|
+
file,
|
|
35
|
+
span = [1, 1, 1, 1],
|
|
36
|
+
offset,
|
|
37
|
+
why,
|
|
38
|
+
help,
|
|
39
|
+
fix,
|
|
40
|
+
related,
|
|
41
|
+
owner = 'core',
|
|
42
|
+
}) {
|
|
32
43
|
const known = entry(code)
|
|
33
44
|
const d = { code, severity: severity ?? known?.severity ?? 'error', owner, file, span }
|
|
34
45
|
if (offset) d.offset = offset
|
|
@@ -43,7 +54,11 @@ export function diagnostic({ code, severity, file, span = [1, 1, 1, 1], offset,
|
|
|
43
54
|
export function locate(text, needle) {
|
|
44
55
|
if (!needle || !text) return { span: [1, 1, 1, 1] }
|
|
45
56
|
const esc = needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
46
|
-
for (const re of [
|
|
57
|
+
for (const re of [
|
|
58
|
+
new RegExp(`(?<![\\w$.-])${esc}(?=\\s*:)`),
|
|
59
|
+
new RegExp(`(['"\`])${esc}\\1`),
|
|
60
|
+
new RegExp(esc),
|
|
61
|
+
]) {
|
|
47
62
|
const m = re.exec(text)
|
|
48
63
|
if (!m) continue
|
|
49
64
|
const at = m.index + (m[0].length - needle.length) / 2
|
|
@@ -56,19 +71,28 @@ export function summarize(diagnostics) {
|
|
|
56
71
|
const byCode = {}
|
|
57
72
|
for (const d of diagnostics) byCode[d.code] = (byCode[d.code] ?? 0) + 1
|
|
58
73
|
return {
|
|
59
|
-
errors: diagnostics.filter(d => d.severity === 'error').length,
|
|
60
|
-
warnings: diagnostics.filter(d => d.severity === 'warning').length,
|
|
74
|
+
errors: diagnostics.filter((d) => d.severity === 'error').length,
|
|
75
|
+
warnings: diagnostics.filter((d) => d.severity === 'warning').length,
|
|
61
76
|
byCode,
|
|
62
77
|
}
|
|
63
78
|
}
|
|
64
79
|
|
|
65
80
|
export function sortDiagnostics(diagnostics) {
|
|
66
|
-
return diagnostics.sort(
|
|
81
|
+
return diagnostics.sort(
|
|
82
|
+
(a, b) =>
|
|
83
|
+
a.file.localeCompare(b.file) ||
|
|
84
|
+
a.span[0] - b.span[0] ||
|
|
85
|
+
a.span[1] - b.span[1] ||
|
|
86
|
+
a.code.localeCompare(b.code),
|
|
87
|
+
)
|
|
67
88
|
}
|
|
68
89
|
|
|
69
90
|
/** Human text for stderr: `file:line:col CODE severity why` then the fix. */
|
|
70
91
|
export function formatHuman(diagnostics) {
|
|
71
92
|
return diagnostics
|
|
72
|
-
.map(
|
|
93
|
+
.map(
|
|
94
|
+
(d) =>
|
|
95
|
+
`${d.file}:${d.span[0]}:${d.span[1]} ${d.code} ${d.severity} ${d.why}\n fix: ${d.fix.text}${d.fix.edits ? ' (autofix)' : ''}`,
|
|
96
|
+
)
|
|
73
97
|
.join('\n')
|
|
74
98
|
}
|