@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.
Files changed (171) hide show
  1. package/README.md +38 -14
  2. package/bin/weft.mjs +8 -1
  3. package/docs/async.md +16 -6
  4. package/docs/browser.md +22 -8
  5. package/docs/config.md +39 -9
  6. package/docs/core.md +30 -0
  7. package/docs/deploy.md +33 -8
  8. package/docs/design.md +12 -3
  9. package/docs/entrypoints.md +79 -0
  10. package/docs/fonts.md +12 -2
  11. package/docs/icons.md +13 -7
  12. package/docs/lists.md +3 -1
  13. package/docs/meta.md +13 -3
  14. package/docs/plugins.md +32 -9
  15. package/docs/props.md +13 -2
  16. package/docs/reactivity.md +26 -0
  17. package/docs/rules.md +19 -12
  18. package/docs/rust.md +47 -17
  19. package/docs/start-short.md +3 -2
  20. package/docs/start.md +226 -52
  21. package/docs/state.md +33 -7
  22. package/docs/style.md +31 -8
  23. package/docs/surface.md +61 -15
  24. package/docs/testing.md +15 -5
  25. package/docs/theme.md +182 -0
  26. package/docs/ui-state.md +19 -6
  27. package/docs/verify.md +13 -0
  28. package/package.json +13 -4
  29. package/src/add/config.mjs +93 -0
  30. package/src/add/errors.mjs +2 -0
  31. package/src/add/setup.mjs +88 -0
  32. package/src/add-rust.mjs +82 -27
  33. package/src/add.mjs +209 -181
  34. package/src/agents-md.mjs +144 -43
  35. package/src/alias.mjs +16 -5
  36. package/src/audit/page.mjs +313 -0
  37. package/src/audit/report.mjs +69 -0
  38. package/src/audit/utils.mjs +5 -0
  39. package/src/audit.mjs +159 -206
  40. package/src/browser/cli.mjs +24 -5
  41. package/src/browser/client.mjs +189 -25
  42. package/src/browser/commands.mjs +166 -51
  43. package/src/catalog/audit.mjs +129 -0
  44. package/src/catalog/auth.mjs +40 -0
  45. package/src/catalog/compiler.mjs +337 -0
  46. package/src/catalog/contracts.mjs +18 -0
  47. package/src/catalog/database.mjs +52 -0
  48. package/src/catalog/deploy.mjs +117 -0
  49. package/src/catalog/design.mjs +159 -0
  50. package/src/catalog/forms.mjs +25 -0
  51. package/src/catalog/hydration.mjs +29 -0
  52. package/src/catalog/icons.mjs +24 -0
  53. package/src/catalog/media.mjs +163 -0
  54. package/src/catalog/metadata.mjs +116 -0
  55. package/src/catalog/plugins.mjs +228 -0
  56. package/src/catalog/realtime.mjs +195 -0
  57. package/src/catalog/rust.mjs +75 -0
  58. package/src/catalog/server-functions.mjs +92 -0
  59. package/src/catalog/state.mjs +39 -0
  60. package/src/catalog/style-groups.mjs +54 -0
  61. package/src/catalog/tests.mjs +127 -0
  62. package/src/catalog/text.mjs +200 -0
  63. package/src/catalog/ui-state.mjs +42 -0
  64. package/src/catalog.mjs +67 -460
  65. package/src/check/runtime.mjs +280 -0
  66. package/src/check.mjs +198 -208
  67. package/src/cli/authoring.mjs +213 -0
  68. package/src/cli/project.mjs +302 -0
  69. package/src/cli.mjs +222 -250
  70. package/src/config.mjs +1 -1
  71. package/src/contributions.mjs +75 -25
  72. package/src/db-layer.mjs +13 -5
  73. package/src/deploy/cdn.mjs +94 -16
  74. package/src/deploy/files.mjs +100 -21
  75. package/src/deploy/fit.mjs +127 -23
  76. package/src/deploy/hosts.mjs +90 -20
  77. package/src/deploy/index/cli.mjs +20 -0
  78. package/src/deploy/index/environment.mjs +69 -0
  79. package/src/deploy/index/steps.mjs +87 -0
  80. package/src/deploy/index.mjs +228 -114
  81. package/src/deploy/needs.mjs +88 -31
  82. package/src/design-checks/accessible-names.mjs +62 -0
  83. package/src/design-checks/contrast.mjs +43 -0
  84. package/src/design-checks/csp.mjs +60 -0
  85. package/src/design-checks/files.mjs +59 -0
  86. package/src/design-checks/heading-links.mjs +61 -0
  87. package/src/design-checks/interaction.mjs +65 -0
  88. package/src/design-checks/jsx.mjs +67 -0
  89. package/src/design-checks/layout-values.mjs +68 -0
  90. package/src/design-checks/native-controls.mjs +48 -0
  91. package/src/design-checks/outline.mjs +39 -0
  92. package/src/design-checks/reporter.mjs +37 -0
  93. package/src/design-checks/rules.mjs +274 -0
  94. package/src/design-checks/values.mjs +262 -0
  95. package/src/design-checks.mjs +10 -400
  96. package/src/diag.mjs +32 -8
  97. package/src/docs.mjs +138 -43
  98. package/src/doctor.mjs +20 -8
  99. package/src/files.mjs +18 -5
  100. package/src/fix.mjs +10 -2
  101. package/src/gate/after.mjs +40 -0
  102. package/src/gate/before.mjs +54 -0
  103. package/src/gate/classify.mjs +72 -0
  104. package/src/gate/mutation.mjs +209 -0
  105. package/src/gate/resolve-cases.mjs +22 -0
  106. package/src/gate/result.mjs +63 -0
  107. package/src/gate/run.mjs +75 -0
  108. package/src/gate.mjs +104 -202
  109. package/src/infer/box-props.cjs +140 -0
  110. package/src/infer/core-boxes.cjs +46 -0
  111. package/src/infer/derived-writes.cjs +95 -0
  112. package/src/infer/engine/checks.cjs +193 -0
  113. package/src/infer/engine/flow.cjs +293 -0
  114. package/src/infer/engine/report.cjs +158 -0
  115. package/src/infer/engine/utils.cjs +15 -0
  116. package/src/infer/engine.cjs +108 -368
  117. package/src/infer/facts/sites.cjs +114 -0
  118. package/src/infer/facts.cjs +210 -117
  119. package/src/infer/hosts.cjs +69 -22
  120. package/src/infer/lsp.mjs +149 -40
  121. package/src/infer/session.cjs +73 -17
  122. package/src/infer/shadow.cjs +13 -5
  123. package/src/infer/worker.cjs +57 -9
  124. package/src/jsx-scan.mjs +63 -21
  125. package/src/load-config/schema.mjs +281 -0
  126. package/src/load-config.mjs +137 -138
  127. package/src/map.mjs +93 -26
  128. package/src/mcp/browser-tools.mjs +155 -0
  129. package/src/mcp.mjs +147 -69
  130. package/src/meta-table.mjs +43 -8
  131. package/src/mutate.mjs +120 -36
  132. package/src/plugin-checks.mjs +52 -23
  133. package/src/plugins.mjs +125 -39
  134. package/src/project.mjs +170 -79
  135. package/src/props-checks.mjs +24 -6
  136. package/src/props-listener.mjs +27 -9
  137. package/src/react-checks.mjs +35 -4
  138. package/src/server-state-checks.mjs +34 -0
  139. package/src/share-checks.mjs +121 -37
  140. package/src/site.mjs +33 -6
  141. package/src/slots.mjs +104 -19
  142. package/src/start/build.mjs +116 -0
  143. package/src/start/routes.mjs +38 -0
  144. package/src/start.mjs +193 -95
  145. package/src/state-checks/core-state.mjs +223 -0
  146. package/src/state-checks/modules.mjs +17 -0
  147. package/src/state-checks/scan.mjs +232 -0
  148. package/src/state-checks/server-graph.mjs +65 -0
  149. package/src/state-checks.mjs +109 -133
  150. package/src/style/find.mjs +121 -0
  151. package/src/style.mjs +133 -127
  152. package/src/surface-checks.mjs +212 -46
  153. package/src/tailwind.mjs +5 -4
  154. package/src/tasks.mjs +189 -27
  155. package/src/test-rules/context.mjs +109 -0
  156. package/src/test-rules/file-rules.mjs +279 -0
  157. package/src/test-rules/rule-helpers.mjs +244 -0
  158. package/src/test-rules/syntax.mjs +223 -0
  159. package/src/test-rules/test-rules.mjs +127 -0
  160. package/src/testdiff.mjs +94 -32
  161. package/src/tests.mjs +31 -483
  162. package/src/text-checks.mjs +229 -69
  163. package/src/tokens.mjs +12 -4
  164. package/src/trust/scan.mjs +95 -0
  165. package/src/trust.mjs +214 -102
  166. package/src/weft.mjs +41 -9
  167. package/src/workspace.mjs +96 -19
  168. package/ts-plugin/context.js +57 -0
  169. package/ts-plugin/index.js +4 -124
  170. package/ts-plugin/mapping.js +65 -0
  171. package/ts-plugin/proxy.js +144 -0
@@ -0,0 +1,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
+ }