@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,48 @@
|
|
|
1
|
+
import { NATIVE, NATIVE_TYPES, addImport } from './rules.mjs'
|
|
2
|
+
|
|
3
|
+
export function checkNativeControls({ el, f, report, localComponents }) {
|
|
4
|
+
const typed = (el.name === 'input' || el.name === 'Input') && NATIVE_TYPES[el.attr('type')?.value]
|
|
5
|
+
if (typed && !el.attr('hidden') && !el.attr('data-part'))
|
|
6
|
+
report(
|
|
7
|
+
'D005',
|
|
8
|
+
el.start,
|
|
9
|
+
el.tagEnd,
|
|
10
|
+
`\`type="${el.attr('type').value}"\` is painted by the browser: <${typed}> is warp's.`,
|
|
11
|
+
)
|
|
12
|
+
if (/^[a-z]/.test(el.name) && el.name !== 'iframe' && el.attr('title'))
|
|
13
|
+
report(
|
|
14
|
+
'D005',
|
|
15
|
+
el.start,
|
|
16
|
+
el.tagEnd,
|
|
17
|
+
'A `title` tooltip: the browser paints it, late, and never on touch or keyboard. Use <Tooltip>, or `aria-label` for a name.',
|
|
18
|
+
)
|
|
19
|
+
if (NATIVE[el.name] && !localComponents.has(NATIVE[el.name])) {
|
|
20
|
+
const swap =
|
|
21
|
+
el.name === 'button' &&
|
|
22
|
+
!el.attr('class') &&
|
|
23
|
+
!el.attr('className') &&
|
|
24
|
+
!el.attr('style') &&
|
|
25
|
+
f.text.slice(el.start, el.end).includes('</button')
|
|
26
|
+
report(
|
|
27
|
+
'D005',
|
|
28
|
+
el.start,
|
|
29
|
+
el.tagEnd,
|
|
30
|
+
`Native <${el.name}> in a warp app: <${NATIVE[el.name]}> brings the focus ring, the sweep, the tones and the touch target.`,
|
|
31
|
+
swap
|
|
32
|
+
? {
|
|
33
|
+
description: 'Use <Button>',
|
|
34
|
+
safe: false,
|
|
35
|
+
edits: [
|
|
36
|
+
{ start: el.start + 1, end: el.tagEnd, text: 'Button' },
|
|
37
|
+
{
|
|
38
|
+
start: f.text.lastIndexOf('</', el.end - 1) + 2,
|
|
39
|
+
end: el.end - 1,
|
|
40
|
+
text: 'Button',
|
|
41
|
+
},
|
|
42
|
+
...addImport(f.text, ['Button']),
|
|
43
|
+
].sort((a, b) => b.start - a.start),
|
|
44
|
+
}
|
|
45
|
+
: undefined,
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { scanParent } from './rules.mjs'
|
|
2
|
+
|
|
3
|
+
export function checkOutline({ el, warpName, headings, solids, scan }) {
|
|
4
|
+
// -- outline --
|
|
5
|
+
const h = /^h([1-6])$/.exec(el.name)
|
|
6
|
+
const level = h
|
|
7
|
+
? Number(h[1])
|
|
8
|
+
: el.name === 'Heading' && warpName('Heading')
|
|
9
|
+
? Number(el.attr('level')?.value ?? /^\d$/.exec(el.attr('level')?.expr ?? '')?.[0] ?? 2)
|
|
10
|
+
: 0
|
|
11
|
+
if (level) headings.push({ level, el })
|
|
12
|
+
|
|
13
|
+
// -- one primary action per region --
|
|
14
|
+
if (
|
|
15
|
+
el.name === 'Button' &&
|
|
16
|
+
warpName('Button') &&
|
|
17
|
+
(!el.attr('emphasis') || el.attr('emphasis')?.value === 'solid') &&
|
|
18
|
+
!el.attr('tone')
|
|
19
|
+
) {
|
|
20
|
+
const region =
|
|
21
|
+
el.ancestors.findLast((a) =>
|
|
22
|
+
[
|
|
23
|
+
'Card',
|
|
24
|
+
'Modal',
|
|
25
|
+
'Drawer',
|
|
26
|
+
'Popover',
|
|
27
|
+
'section',
|
|
28
|
+
'form',
|
|
29
|
+
'header',
|
|
30
|
+
'footer',
|
|
31
|
+
'main',
|
|
32
|
+
'aside',
|
|
33
|
+
'Alert',
|
|
34
|
+
].includes(a),
|
|
35
|
+
) ?? ''
|
|
36
|
+
const key = `${region}:${el.ancestors.length}:${scanParent(el, scan.elements)?.start ?? 0}`
|
|
37
|
+
solids.set(key, [...(solids.get(key) ?? []), el])
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { diagnostic, positions } from '../diag.mjs'
|
|
2
|
+
import { allowed } from './rules.mjs'
|
|
3
|
+
|
|
4
|
+
export function makeReporter(out) {
|
|
5
|
+
const reporter = (file) => {
|
|
6
|
+
const pos = positions(file.text)
|
|
7
|
+
const allow = allowed(file.text)
|
|
8
|
+
const make = (severity) => (code, start, end, why, fix) => {
|
|
9
|
+
if (allow.has(`${code}:${pos.lineCol(start)[0]}`)) return
|
|
10
|
+
const edits =
|
|
11
|
+
fix &&
|
|
12
|
+
(
|
|
13
|
+
fix.edits ?? [{ start: fix.at?.[0] ?? start, end: fix.at?.[1] ?? end, text: fix.text }]
|
|
14
|
+
).map((e) => ({
|
|
15
|
+
file: file.rel,
|
|
16
|
+
span: pos.span(e.start, e.end),
|
|
17
|
+
offset: [e.start, e.end],
|
|
18
|
+
text: e.text,
|
|
19
|
+
}))
|
|
20
|
+
out.push(
|
|
21
|
+
diagnostic({
|
|
22
|
+
code,
|
|
23
|
+
severity,
|
|
24
|
+
file: file.rel,
|
|
25
|
+
span: pos.span(start, end),
|
|
26
|
+
offset: [start, end],
|
|
27
|
+
why,
|
|
28
|
+
fix: fix && { description: fix.description, safe: fix.safe, edits },
|
|
29
|
+
}),
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
const report = make()
|
|
33
|
+
report.warning = make('warning')
|
|
34
|
+
return report
|
|
35
|
+
}
|
|
36
|
+
return reporter
|
|
37
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { WARP, HEADING_ROLE } from './values.mjs'
|
|
2
|
+
|
|
3
|
+
export const NATIVE = { button: 'Button', select: 'Select', textarea: 'Textarea' }
|
|
4
|
+
// the input types a browser paints, and the warp control for each; `Input` passes `type` through, so it counts too
|
|
5
|
+
export const NATIVE_TYPES = {
|
|
6
|
+
date: 'DateField',
|
|
7
|
+
'datetime-local': 'DateTimeField',
|
|
8
|
+
time: 'TimeField',
|
|
9
|
+
month: 'MonthPicker',
|
|
10
|
+
week: 'DateField',
|
|
11
|
+
number: 'NumberField',
|
|
12
|
+
range: 'Slider',
|
|
13
|
+
color: 'ColorField',
|
|
14
|
+
checkbox: 'Checkbox',
|
|
15
|
+
radio: 'Radio',
|
|
16
|
+
file: 'a dropzone',
|
|
17
|
+
}
|
|
18
|
+
export const INTERACTIVE = new Set([
|
|
19
|
+
'a',
|
|
20
|
+
'button',
|
|
21
|
+
'input',
|
|
22
|
+
'select',
|
|
23
|
+
'textarea',
|
|
24
|
+
'summary',
|
|
25
|
+
'Button',
|
|
26
|
+
'Toggle',
|
|
27
|
+
'Input',
|
|
28
|
+
'Search',
|
|
29
|
+
'Textarea',
|
|
30
|
+
'Select',
|
|
31
|
+
'Checkbox',
|
|
32
|
+
'Switch',
|
|
33
|
+
'Chip',
|
|
34
|
+
'Link',
|
|
35
|
+
])
|
|
36
|
+
export const STATIC = new Set([
|
|
37
|
+
'div',
|
|
38
|
+
'span',
|
|
39
|
+
'p',
|
|
40
|
+
'li',
|
|
41
|
+
'section',
|
|
42
|
+
'article',
|
|
43
|
+
'td',
|
|
44
|
+
'tr',
|
|
45
|
+
'img',
|
|
46
|
+
'h1',
|
|
47
|
+
'h2',
|
|
48
|
+
'h3',
|
|
49
|
+
'h4',
|
|
50
|
+
'h5',
|
|
51
|
+
'h6',
|
|
52
|
+
'ul',
|
|
53
|
+
'label',
|
|
54
|
+
])
|
|
55
|
+
|
|
56
|
+
const NAMED = ['aria-label', 'aria-labelledby', 'title']
|
|
57
|
+
/** Elements whose implicit role (generic, paragraph, emphasis…) may not be named, and the explicit roles that can't be either. */
|
|
58
|
+
export const GENERIC = new Set([
|
|
59
|
+
'div',
|
|
60
|
+
'span',
|
|
61
|
+
'p',
|
|
62
|
+
'b',
|
|
63
|
+
'i',
|
|
64
|
+
'em',
|
|
65
|
+
'strong',
|
|
66
|
+
'small',
|
|
67
|
+
'code',
|
|
68
|
+
's',
|
|
69
|
+
'u',
|
|
70
|
+
'sub',
|
|
71
|
+
'sup',
|
|
72
|
+
'mark',
|
|
73
|
+
'del',
|
|
74
|
+
'ins',
|
|
75
|
+
'kbd',
|
|
76
|
+
'samp',
|
|
77
|
+
'var',
|
|
78
|
+
])
|
|
79
|
+
export const UNNAMED = new Set([
|
|
80
|
+
'generic',
|
|
81
|
+
'presentation',
|
|
82
|
+
'none',
|
|
83
|
+
'paragraph',
|
|
84
|
+
'caption',
|
|
85
|
+
'code',
|
|
86
|
+
'deletion',
|
|
87
|
+
'emphasis',
|
|
88
|
+
'insertion',
|
|
89
|
+
'strong',
|
|
90
|
+
'subscript',
|
|
91
|
+
'superscript',
|
|
92
|
+
'mark',
|
|
93
|
+
'time',
|
|
94
|
+
])
|
|
95
|
+
export const named = (el) =>
|
|
96
|
+
NAMED.some((n) => el.attr(n)) || el.attrs.some((a) => a.kind === 'spread')
|
|
97
|
+
/** Children with something a screen reader can read: text, an expression, a component, an image with alt. */
|
|
98
|
+
export const hasName = (children) => {
|
|
99
|
+
if (/<img\b[^>]*\balt=(["'])[^"']+\1/.test(children)) return true
|
|
100
|
+
const stripped = children
|
|
101
|
+
.replace(/<svg[\s\S]*?<\/svg>/g, '')
|
|
102
|
+
.replace(/<(Icon|[A-Z]\w*Icon)\b[^>]*\/>/g, '')
|
|
103
|
+
.replace(/<[^>]*aria-hidden[^>]*>[\s\S]*?<\/[^>]+>/g, '')
|
|
104
|
+
return /[^\s<>{}]/.test(stripped.replace(/<\/?[a-z][^>]*>/g, ''))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Parse `#rgb`, `#rrggbb`, `rgb(r, g, b)`, `white` and `black`, or null. */
|
|
108
|
+
export function literalColor(v) {
|
|
109
|
+
const s = v.trim().toLowerCase()
|
|
110
|
+
if (s === 'white') return [255, 255, 255]
|
|
111
|
+
if (s === 'black') return [0, 0, 0]
|
|
112
|
+
const h = /^#([0-9a-f]{3}|[0-9a-f]{6})$/.exec(s)?.[1]
|
|
113
|
+
if (h)
|
|
114
|
+
return (h.length === 3 ? Array.from(h, (c) => c + c) : h.match(/../g)).map((x) =>
|
|
115
|
+
parseInt(x, 16),
|
|
116
|
+
)
|
|
117
|
+
const m = /^rgb\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)\s*\)$/.exec(s)
|
|
118
|
+
return m ? m.slice(1).map(Number) : null
|
|
119
|
+
}
|
|
120
|
+
const lum = (c) => {
|
|
121
|
+
const [r, g, b] = c.map((x) => {
|
|
122
|
+
const s = x / 255
|
|
123
|
+
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
|
|
124
|
+
})
|
|
125
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
|
126
|
+
}
|
|
127
|
+
export const ratio = (a, b) => {
|
|
128
|
+
const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p)
|
|
129
|
+
return (x + 0.05) / (y + 0.05)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Lines an allow comment covers: `// weft-allow D001: reason` (or `{/* … *\/}`) on the line above or at the line's end. */
|
|
133
|
+
export function allowed(text) {
|
|
134
|
+
const out = new Map()
|
|
135
|
+
text.split('\n').forEach((line, i) => {
|
|
136
|
+
const m = /weft-allow\s+((?:D\d{3}[\s,]*)+):\s*\S/.exec(line)
|
|
137
|
+
if (!m) return
|
|
138
|
+
for (const code of m[1].match(/D\d{3}/g))
|
|
139
|
+
for (const n of [i + 1, i + 2]) out.set(`${code}:${n}`, true)
|
|
140
|
+
})
|
|
141
|
+
return out
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The edit that imports `names` from warp: into the file's existing import, or a new first line. */
|
|
145
|
+
export function addImport(text, names) {
|
|
146
|
+
const m = new RegExp(`import\\s*\\{([^}]*)\\}\\s*from\\s*['"]${WARP}['"]`).exec(text)
|
|
147
|
+
if (!m) return [{ start: 0, end: 0, text: `import { ${names.join(', ')} } from '${WARP}'\n` }]
|
|
148
|
+
const have = m[1]
|
|
149
|
+
.split(',')
|
|
150
|
+
.map((s) => s.trim())
|
|
151
|
+
.filter(Boolean)
|
|
152
|
+
const missing = names.filter((n) => !have.includes(n))
|
|
153
|
+
if (!missing.length) return []
|
|
154
|
+
const at = m.index + m[0].indexOf(m[1]) + m[1].trimEnd().length
|
|
155
|
+
return [{ start: at, end: at, text: `, ${missing.join(', ')}` }]
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Does the app have warp's tokens? */
|
|
159
|
+
/** Where a host keeps response headers, outside the source. */
|
|
160
|
+
export const HOST_HEADERS = ['public/_headers', '_headers', 'netlify.toml', 'vercel.json']
|
|
161
|
+
const CSP_DIRECTIVE = /\b((?:default|style|script)-src(?:-attr|-elem)?)\b([^;\n"`]*)/gi
|
|
162
|
+
|
|
163
|
+
/** Every source list a file gives each directive, wherever in it the policy is written. */
|
|
164
|
+
export function cspDirectives(text) {
|
|
165
|
+
const out = new Map()
|
|
166
|
+
for (const m of text.matchAll(CSP_DIRECTIVE)) {
|
|
167
|
+
const name = m[1].toLowerCase()
|
|
168
|
+
out.set(name, [...(out.get(name) ?? []), m[2]])
|
|
169
|
+
}
|
|
170
|
+
return out
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The first directive named governs, as in a browser's fallback; a nonce or a hash beside `unsafe-inline` cancels it. */
|
|
174
|
+
export function inlineAllowed(policy, ...names) {
|
|
175
|
+
const lists = policy.get(names.find((n) => policy.has(n)))
|
|
176
|
+
return !lists || lists.every((l) => /'unsafe-inline'/i.test(l) && !/'(?:nonce|sha\d+)-/i.test(l))
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Can any script of the page's own run? The client entry can switch on what an inline script was refused. */
|
|
180
|
+
export function scriptsRun(policy) {
|
|
181
|
+
const lists = policy.get(
|
|
182
|
+
['script-src-elem', 'script-src', 'default-src'].find((n) => policy.has(n)),
|
|
183
|
+
)
|
|
184
|
+
// Past `strict-dynamic` a browser ignores hosts, 'self' and 'unsafe-inline', and weft's scripts carry no nonce.
|
|
185
|
+
return (
|
|
186
|
+
!lists ||
|
|
187
|
+
lists.every(
|
|
188
|
+
(l) =>
|
|
189
|
+
!/'strict-dynamic'/i.test(l) &&
|
|
190
|
+
(/'self'/i.test(l) ||
|
|
191
|
+
l.replace(/'[^']*'/g, '').trim() !== '' ||
|
|
192
|
+
(/'unsafe-inline'/i.test(l) && !/'(?:nonce|sha\d+)-/i.test(l))),
|
|
193
|
+
)
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** 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>`. */
|
|
198
|
+
export function headingSite(el, bare) {
|
|
199
|
+
if (bare) return { level: Number(el.name[1]), role: null }
|
|
200
|
+
const lv = el.attr('level')
|
|
201
|
+
const level = !lv ? 2 : Number(lv.value ?? /^\d$/.exec(lv.expr ?? '')?.[0])
|
|
202
|
+
if (!HEADING_ROLE[level]) return null
|
|
203
|
+
const attr = el.attr('role')
|
|
204
|
+
if (attr && attr.kind !== 'string') return null
|
|
205
|
+
return { level, role: attr?.value ?? HEADING_ROLE[level], set: attr }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** 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. */
|
|
209
|
+
export function roleDrift(level, sites) {
|
|
210
|
+
const counts = new Map()
|
|
211
|
+
for (const s of sites) if (s.role) counts.set(s.role, (counts.get(s.role) ?? 0) + 1)
|
|
212
|
+
if (!counts.size || new Set(sites.map((s) => s.role)).size < 2) return
|
|
213
|
+
const fallback = HEADING_ROLE[level]
|
|
214
|
+
const top = Math.max(...counts.values())
|
|
215
|
+
const role = counts.get(fallback) === top ? fallback : [...counts].find(([, n]) => n === top)[0]
|
|
216
|
+
const prop = role === fallback ? '' : ` role="${role}"`
|
|
217
|
+
const all = [...counts.keys()].join(', ')
|
|
218
|
+
for (const { el, text, report, role: has, set } of sites) {
|
|
219
|
+
if (has === role) continue
|
|
220
|
+
if (has === null) {
|
|
221
|
+
const swap =
|
|
222
|
+
!el.attr('class') &&
|
|
223
|
+
!el.attr('className') &&
|
|
224
|
+
!el.attr('style') &&
|
|
225
|
+
text.slice(el.start, el.end).includes(`</${el.name}`)
|
|
226
|
+
report(
|
|
227
|
+
'D006',
|
|
228
|
+
el.start,
|
|
229
|
+
el.tagEnd,
|
|
230
|
+
`A bare <h${level}> beside <Heading level={${level}}>: this one renders at the browser's size, the rest at \`${role}\`.`,
|
|
231
|
+
swap
|
|
232
|
+
? {
|
|
233
|
+
description: `Use <Heading level={${level}}>`,
|
|
234
|
+
safe: false,
|
|
235
|
+
edits: [
|
|
236
|
+
{ start: el.start + 1, end: el.tagEnd, text: `Heading level={${level}}${prop}` },
|
|
237
|
+
{ start: text.lastIndexOf('</', el.end - 1) + 2, end: el.end - 1, text: 'Heading' },
|
|
238
|
+
...addImport(text, ['Heading']),
|
|
239
|
+
].sort((a, b) => b.start - a.start),
|
|
240
|
+
}
|
|
241
|
+
: undefined,
|
|
242
|
+
)
|
|
243
|
+
continue
|
|
244
|
+
}
|
|
245
|
+
const fix = set
|
|
246
|
+
? {
|
|
247
|
+
description:
|
|
248
|
+
role === fallback ? `Drop \`role\`: h${level} is \`${role}\`` : `Use role="${role}"`,
|
|
249
|
+
safe: false,
|
|
250
|
+
edits: [
|
|
251
|
+
role === fallback
|
|
252
|
+
? { start: text.slice(0, set.start).trimEnd().length, end: set.end, text: '' }
|
|
253
|
+
: { start: set.valueStart, end: set.valueEnd, text: `"${role}"` },
|
|
254
|
+
],
|
|
255
|
+
}
|
|
256
|
+
: { description: `Use role="${role}"`, safe: false, text: prop, at: [el.tagEnd, el.tagEnd] }
|
|
257
|
+
report(
|
|
258
|
+
'D006',
|
|
259
|
+
el.start,
|
|
260
|
+
el.tagEnd,
|
|
261
|
+
`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.`,
|
|
262
|
+
fix,
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** The nearest element that contains `el`. */
|
|
268
|
+
export function scanParent(el, elements) {
|
|
269
|
+
let best
|
|
270
|
+
for (const e of elements)
|
|
271
|
+
if (e !== el && e.start <= el.start && e.end >= el.end && (!best || e.start >= best.start))
|
|
272
|
+
best = e
|
|
273
|
+
return best
|
|
274
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/** warp's spacing scale at the default density, px → token (warp.css). */
|
|
2
|
+
const SPACE = new Map([
|
|
3
|
+
[2, 'hair'],
|
|
4
|
+
[4, 'tight'],
|
|
5
|
+
[8, 'related'],
|
|
6
|
+
[16, 'group'],
|
|
7
|
+
])
|
|
8
|
+
const SPACE_NAMES = 'hair tight related group section page'.split(' ')
|
|
9
|
+
/** warp's radii at the quiet stop, where a raw radius is most likely to have been copied from. */
|
|
10
|
+
const RADIUS = new Map([
|
|
11
|
+
[4, 'mark'],
|
|
12
|
+
[8, 'control'],
|
|
13
|
+
[12, 'surface'],
|
|
14
|
+
])
|
|
15
|
+
export const WARP = '@weft-js/warp'
|
|
16
|
+
/** The role each heading level takes by default: the same table as warp's `Heading` (packages/warp/src/Type.tsx). */
|
|
17
|
+
export const HEADING_ROLE = {
|
|
18
|
+
1: 'title',
|
|
19
|
+
2: 'heading',
|
|
20
|
+
3: 'heading',
|
|
21
|
+
4: 'label',
|
|
22
|
+
5: 'label',
|
|
23
|
+
6: 'label',
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const COLOR_EXPECTED =
|
|
27
|
+
'var(--warp-color-text), -text-subtle, -surface, -surface-raised, -accent-solid, -border, or a `tone` on the component'
|
|
28
|
+
const COLOR =
|
|
29
|
+
/#[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
|
|
30
|
+
const COLOR_PROPS =
|
|
31
|
+
/^(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)$/
|
|
32
|
+
const SPACE_PROPS =
|
|
33
|
+
/^(gap|row-gap|column-gap|padding|margin)(-(top|right|bottom|left|block|inline)(-(start|end))?)?$/
|
|
34
|
+
const TYPE_PROPS = /^(font-size|font-weight|line-height|letter-spacing)$/
|
|
35
|
+
/** Sizes that scale with the text, the container or the viewport: nothing on a fixed scale is what they mean. */
|
|
36
|
+
const FLUID =
|
|
37
|
+
/\b(?:clamp|calc|min|max)\(|\d(?:em|ex|lh|rlh|ch|cap|ic|vw|vh|vi|vb|vmin|vmax|[dsl]v[wh]|cq[iwhb]|cqmin|cqmax)\b|%/
|
|
38
|
+
|
|
39
|
+
const kebab = (s) => s.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)
|
|
40
|
+
|
|
41
|
+
/** `16px 8px` → the same with tokens, or null if any part isn't exactly on the scale. */
|
|
42
|
+
function scaled(value) {
|
|
43
|
+
const parts = value.trim().split(/\s+/)
|
|
44
|
+
const out = parts.map((p) => {
|
|
45
|
+
if (p === '0' || p === '0px' || p === 'auto') return p
|
|
46
|
+
const m = /^(\d+(?:\.\d+)?)(px|rem)?$/.exec(p)
|
|
47
|
+
if (!m) return null
|
|
48
|
+
const px = m[2] === 'rem' ? Number(m[1]) * 16 : Number(m[1])
|
|
49
|
+
return SPACE.has(px) ? `var(--warp-space-${SPACE.get(px)})` : null
|
|
50
|
+
})
|
|
51
|
+
return out.every(Boolean) ? out.join(' ') : null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Declarations in a `style` attribute: `{ prop, value, numeric, start, end }` with offsets into the file. */
|
|
55
|
+
export function styleDecls(attr) {
|
|
56
|
+
if (attr.kind === 'string') {
|
|
57
|
+
const at = attr.valueStart + 1
|
|
58
|
+
return [...attr.value.matchAll(/([\w-]+)\s*:\s*([^;]+)/g)].map((m) => {
|
|
59
|
+
const start = at + m.index + m[0].indexOf(m[2], m[1].length + 1)
|
|
60
|
+
return {
|
|
61
|
+
prop: m[1].toLowerCase(),
|
|
62
|
+
value: m[2].trim(),
|
|
63
|
+
start,
|
|
64
|
+
end: start + m[2].trimEnd().length,
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
const out = []
|
|
69
|
+
for (const m of (attr.expr ?? '').matchAll(
|
|
70
|
+
/([\w$]+|['"][\w-]+['"])\s*:\s*(?:(['"`])([^'"`$]*)\2|(\d+(?:\.\d+)?)(?=\s*[,}\s]))/g,
|
|
71
|
+
)) {
|
|
72
|
+
const value = m[3] ?? m[4]
|
|
73
|
+
const inner = m[0].lastIndexOf(value)
|
|
74
|
+
const start = attr.valueStart + m.index + inner - (m[3] !== undefined ? 1 : 0)
|
|
75
|
+
out.push({
|
|
76
|
+
prop: kebab(m[1].replace(/['"]/g, '')),
|
|
77
|
+
value,
|
|
78
|
+
numeric: m[3] === undefined,
|
|
79
|
+
start,
|
|
80
|
+
end: start + value.length + (m[3] !== undefined ? 2 : 0),
|
|
81
|
+
quote: m[2],
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
return out
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The D-code for one raw declaration, or null. `report` takes (code, start, end, why, fix). */
|
|
88
|
+
export function declaration({ prop, value, numeric, start, end, quote }, report) {
|
|
89
|
+
const css = (numeric && SPACE_PROPS.test(prop) ? `${value}px` : value).replace(
|
|
90
|
+
/\s*!important\s*$/i,
|
|
91
|
+
'',
|
|
92
|
+
)
|
|
93
|
+
if (/var\(|^(inherit|initial|unset|transparent|currentcolor|none)$/i.test(css.trim())) return
|
|
94
|
+
if (COLOR_PROPS.test(prop) && COLOR.test(css.replace(/url\([^)]*\)/gi, '')))
|
|
95
|
+
return report(
|
|
96
|
+
'D001',
|
|
97
|
+
start,
|
|
98
|
+
end,
|
|
99
|
+
`Raw colour \`${prop}: ${css}\`: it won't follow the theme, the dials or dark mode. Use ${COLOR_EXPECTED}, or promote it to a token in \`warp({ tokens })\`.`,
|
|
100
|
+
)
|
|
101
|
+
if (SPACE_PROPS.test(prop)) {
|
|
102
|
+
if (/^(0(px)?|auto)$/.test(css.trim()) || FLUID.test(css)) return
|
|
103
|
+
const token = scaled(css)
|
|
104
|
+
const text = token && (quote ? `${quote}${token}${quote}` : numeric ? `'${token}'` : token)
|
|
105
|
+
return report(
|
|
106
|
+
'D002',
|
|
107
|
+
start,
|
|
108
|
+
end,
|
|
109
|
+
`\`${prop}: ${css}\` is ${token ? 'a scale step written by hand' : 'off the spacing scale'}: gaps are relationships (${SPACE_NAMES.join(', ')}).`,
|
|
110
|
+
token ? { description: `Use ${token}`, safe: true, text } : undefined,
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
if (TYPE_PROPS.test(prop))
|
|
114
|
+
return report(
|
|
115
|
+
'D003',
|
|
116
|
+
start,
|
|
117
|
+
end,
|
|
118
|
+
`Raw \`${prop}: ${css}\`: type comes from roles, not sizes. Use \`<Heading role>\` or \`<Text role>\` (display title heading body label caption eyebrow code).`,
|
|
119
|
+
)
|
|
120
|
+
if (prop === 'border-radius') {
|
|
121
|
+
if (/^(0(px)?|\d+%)$/.test(css.trim())) return
|
|
122
|
+
const m = /^(\d+)(?:px)?$/.exec(css.trim())
|
|
123
|
+
const name = m && RADIUS.get(Number(m[1]))
|
|
124
|
+
const text = name && `var(--warp-radius-${name})`
|
|
125
|
+
return report(
|
|
126
|
+
'D003',
|
|
127
|
+
start,
|
|
128
|
+
end,
|
|
129
|
+
`Raw \`border-radius: ${css}\`: radii come from the expression dial (mark, control, surface, round).`,
|
|
130
|
+
name
|
|
131
|
+
? {
|
|
132
|
+
description: `Use ${text}`,
|
|
133
|
+
safe: true,
|
|
134
|
+
text: quote ? `${quote}${text}${quote}` : numeric ? `'${text}'` : text,
|
|
135
|
+
}
|
|
136
|
+
: undefined,
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
if (prop === 'box-shadow' && !/^none$/i.test(css.trim()))
|
|
140
|
+
return report(
|
|
141
|
+
'D003',
|
|
142
|
+
start,
|
|
143
|
+
end,
|
|
144
|
+
'A raw shadow: elevation comes from `--warp-elevation-0…3`, which follow the dials and the theme.',
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// -- Tailwind classes, for an app that has warp's tokens too --
|
|
149
|
+
|
|
150
|
+
const TW_SPACE = new Map([
|
|
151
|
+
['0.5', 'hair'],
|
|
152
|
+
['1', 'tight'],
|
|
153
|
+
['2', 'related'],
|
|
154
|
+
['4', 'group'],
|
|
155
|
+
])
|
|
156
|
+
const TW_PALETTE =
|
|
157
|
+
'slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose'
|
|
158
|
+
const TW_PX = 4
|
|
159
|
+
|
|
160
|
+
/** D001–D003 for one class; returns `[code, why, replacement?]` or null. */
|
|
161
|
+
export function utility(cls) {
|
|
162
|
+
const bare = cls
|
|
163
|
+
.slice(cls.lastIndexOf(':') + 1)
|
|
164
|
+
.replace(/^-/, '')
|
|
165
|
+
.replace(/!$/, '')
|
|
166
|
+
const variant = cls.slice(0, cls.length - cls.slice(cls.lastIndexOf(':') + 1).length)
|
|
167
|
+
const space =
|
|
168
|
+
/^(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(
|
|
169
|
+
bare,
|
|
170
|
+
)
|
|
171
|
+
if (space) {
|
|
172
|
+
const [, util, v] = space
|
|
173
|
+
if (['auto', 'px', '0'].includes(v) || SPACE_NAMES.includes(v)) return null
|
|
174
|
+
const arb = /^\[(.+)\]$/.exec(v)?.[1]
|
|
175
|
+
const px = arb
|
|
176
|
+
? (/^(\d+(?:\.\d+)?)(px|rem)$/.exec(arb) ?? [])[1] * (arb.endsWith('rem') ? 16 : 1)
|
|
177
|
+
: Number(v) * TW_PX
|
|
178
|
+
const name = arb ? SPACE.get(px) : TW_SPACE.get(v)
|
|
179
|
+
if (arb || !Number.isNaN(Number(v)))
|
|
180
|
+
return [
|
|
181
|
+
'D002',
|
|
182
|
+
`\`${cls}\` is ${name ? 'a scale step under another name' : 'off the spacing scale'}.`,
|
|
183
|
+
name && `${variant}${util}-${name}`,
|
|
184
|
+
]
|
|
185
|
+
return null
|
|
186
|
+
}
|
|
187
|
+
if (
|
|
188
|
+
new RegExp(
|
|
189
|
+
`^(bg|text|border|ring|fill|stroke|from|to|via|divide|outline|decoration|accent|caret|shadow)-(${TW_PALETTE})-\\d{2,3}(/\\d+)?$`,
|
|
190
|
+
).test(bare) ||
|
|
191
|
+
/^(bg|text|border|ring|fill|stroke)-(white|black)$/.test(bare)
|
|
192
|
+
)
|
|
193
|
+
return [
|
|
194
|
+
'D001',
|
|
195
|
+
`\`${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\`.`,
|
|
196
|
+
]
|
|
197
|
+
if (/^(bg|text|border|ring|fill|stroke|from|to|via|outline)-\[(#|rgb|hsl|oklch)/.test(bare))
|
|
198
|
+
return ['D001', `\`${cls}\` is a raw colour: it won't follow the theme or the dials.`]
|
|
199
|
+
const radius = /^rounded(-[a-z]+)?-\[(\d+)px\]$/.exec(bare)
|
|
200
|
+
if (radius)
|
|
201
|
+
return [
|
|
202
|
+
'D003',
|
|
203
|
+
`\`${cls}\` is a raw radius.`,
|
|
204
|
+
RADIUS.has(Number(radius[2])) && !radius[1]
|
|
205
|
+
? `${variant}rounded-${RADIUS.get(Number(radius[2]))}`
|
|
206
|
+
: undefined,
|
|
207
|
+
]
|
|
208
|
+
if (
|
|
209
|
+
/^(text|leading|tracking|font|shadow)-\[/.test(bare) ||
|
|
210
|
+
/^(text-(xs|sm|base|lg|[2-9]?xl)|font-(thin|light|normal|medium|semibold|bold|extrabold|black))$/.test(
|
|
211
|
+
bare,
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
return [
|
|
215
|
+
'D003',
|
|
216
|
+
`\`${cls}\`: type and elevation come from roles, not sizes. Use \`<Heading role>\` or \`<Text role>\`.`,
|
|
217
|
+
]
|
|
218
|
+
return null
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** `<div class="flex flex-col gap-4">` and `style={{ display: 'flex', … }}`: the primitive it spells out, or null. */
|
|
222
|
+
export function layoutOf(el) {
|
|
223
|
+
const cls = el.attr('class') ?? el.attr('className')
|
|
224
|
+
if (cls?.kind === 'string') {
|
|
225
|
+
const c = cls.value.split(/\s+/).filter(Boolean)
|
|
226
|
+
if (!c.includes('flex') && !c.includes('grid')) return null
|
|
227
|
+
const dir = c.includes('flex-col') ? 'Stack' : c.includes('flex') ? 'Row' : null
|
|
228
|
+
if (!dir) return null
|
|
229
|
+
const gap = c.find((x) => x.startsWith('gap-'))?.slice(4)
|
|
230
|
+
const name = SPACE_NAMES.includes(gap) ? gap : TW_SPACE.get(gap)
|
|
231
|
+
return {
|
|
232
|
+
primitive: dir,
|
|
233
|
+
gap: name,
|
|
234
|
+
attr: cls,
|
|
235
|
+
exact:
|
|
236
|
+
c.every(
|
|
237
|
+
(x) => ['flex', 'flex-col', 'flex-row', 'flex-wrap'].includes(x) || x.startsWith('gap-'),
|
|
238
|
+
) &&
|
|
239
|
+
(!gap || !!name),
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const style = el.attr('style')
|
|
243
|
+
if (!style) return null
|
|
244
|
+
const decls = styleDecls(style)
|
|
245
|
+
if (!decls.some((d) => d.prop === 'display' && d.value === 'flex')) return null
|
|
246
|
+
const dir = decls.find((d) => d.prop === 'flex-direction')?.value
|
|
247
|
+
const gap = decls.find((d) => d.prop === 'gap')
|
|
248
|
+
const px = gap && Number(gap.value.replace('px', ''))
|
|
249
|
+
const name = gap && SPACE.get(px)
|
|
250
|
+
const only = decls.every((d) => ['display', 'flex-direction', 'gap'].includes(d.prop))
|
|
251
|
+
return {
|
|
252
|
+
primitive: dir === 'column' ? 'Stack' : 'Row',
|
|
253
|
+
gap: name,
|
|
254
|
+
attr: style,
|
|
255
|
+
exact:
|
|
256
|
+
only &&
|
|
257
|
+
(!gap || !!name) &&
|
|
258
|
+
style.kind === 'expr' &&
|
|
259
|
+
!/\.\.\./.test(style.expr) &&
|
|
260
|
+
(dir === 'column' || dir === undefined ? true : dir === 'row'),
|
|
261
|
+
}
|
|
262
|
+
}
|