@weft-js/cli 0.0.0-stage → 0.1.0
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/LICENSE +16 -0
- package/README.md +74 -2
- package/bin/weft.mjs +8 -0
- package/docs/async.md +40 -0
- package/docs/browser.md +39 -0
- package/docs/config.md +93 -0
- package/docs/design.md +97 -0
- package/docs/fonts.md +70 -0
- package/docs/icons.md +102 -0
- package/docs/lists.md +23 -0
- package/docs/meta.md +82 -0
- package/docs/plugins.md +143 -0
- package/docs/props.md +77 -0
- package/docs/rules.md +32 -0
- package/docs/rust.md +223 -0
- package/docs/start-short.md +8 -0
- package/docs/start.md +422 -0
- package/docs/state.md +96 -0
- package/docs/style.md +59 -0
- package/docs/surface.md +111 -0
- package/docs/testing.md +71 -0
- package/docs/ui-state.md +92 -0
- package/docs/verify.md +48 -0
- package/package.json +39 -4
- package/src/add-rust.mjs +123 -0
- package/src/add.mjs +358 -0
- package/src/agents-md.mjs +134 -0
- package/src/alias.mjs +59 -0
- package/src/audit.mjs +333 -0
- package/src/browser/cli.mjs +34 -0
- package/src/browser/client.mjs +132 -0
- package/src/browser/commands.mjs +143 -0
- package/src/catalog.mjs +479 -0
- package/src/check.mjs +328 -0
- package/src/cli.mjs +332 -0
- package/src/config.d.ts +1 -0
- package/src/config.mjs +5 -0
- package/src/contributions.mjs +69 -0
- package/src/db-layer.mjs +33 -0
- package/src/design-checks.mjs +439 -0
- package/src/diag.mjs +74 -0
- package/src/docs.mjs +131 -0
- package/src/doctor.mjs +30 -0
- package/src/files.mjs +47 -0
- package/src/fix.mjs +40 -0
- package/src/gate.mjs +276 -0
- package/src/index.mjs +9 -0
- package/src/infer/engine.cjs +525 -0
- package/src/infer/facts.cjs +252 -0
- package/src/infer/hosts.cjs +114 -0
- package/src/infer/lsp.mjs +131 -0
- package/src/infer/session.cjs +105 -0
- package/src/infer/shadow.cjs +46 -0
- package/src/infer/worker.cjs +97 -0
- package/src/jsx-scan.mjs +86 -0
- package/src/load-config.mjs +274 -0
- package/src/map.mjs +110 -0
- package/src/mcp.mjs +195 -0
- package/src/meta-table.mjs +28 -0
- package/src/mutate.mjs +140 -0
- package/src/plugin-checks.mjs +89 -0
- package/src/plugins.mjs +172 -0
- package/src/project.mjs +219 -0
- package/src/props-checks.mjs +46 -0
- package/src/props-listener.mjs +38 -0
- package/src/react-checks.mjs +36 -0
- package/src/share-checks.mjs +74 -0
- package/src/site.mjs +19 -0
- package/src/slots.mjs +96 -0
- package/src/start.mjs +292 -0
- package/src/state-checks.mjs +213 -0
- package/src/style.mjs +320 -0
- package/src/surface-checks.mjs +218 -0
- package/src/tailwind.mjs +22 -0
- package/src/tasks.mjs +99 -0
- package/src/testdiff.mjs +157 -0
- package/src/tests.mjs +572 -0
- package/src/text-checks.mjs +172 -0
- package/src/tokens.mjs +24 -0
- package/src/trust.mjs +282 -0
- package/src/weft.mjs +66 -0
- package/src/workspace.mjs +105 -0
- package/ts-plugin/index.js +137 -0
- package/ts-plugin/package.json +1 -0
package/src/style.mjs
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// `weft style find|explain|matrix` and the style D-codes (research 23 §2.4, §3). The matrix comes from the installed
|
|
2
|
+
// weft (`@weft-js/core/style/matrix`): the project is scanned with its compiler, so the CLI, the MCP tools and the Vite
|
|
3
|
+
// plugin's `.weft/styles.json` agree. D020 (class soup) is found here, on the source text: groups never write a class.
|
|
4
|
+
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
import { pathToFileURL } from 'node:url'
|
|
8
|
+
import { diagnostic, locate, positions } from './diag.mjs'
|
|
9
|
+
import { discover } from './files.mjs'
|
|
10
|
+
import { loadConfig } from './load-config.mjs'
|
|
11
|
+
import { isTestFile } from './tests.mjs'
|
|
12
|
+
import { loadCompiler, resolveWeft } from './weft.mjs'
|
|
13
|
+
|
|
14
|
+
const usageError = message => Object.assign(new Error(message), { code: 'E_USAGE' })
|
|
15
|
+
|
|
16
|
+
/** The installed weft's matrix module, or null when this weft has no style groups. */
|
|
17
|
+
export async function styleModule(weft) {
|
|
18
|
+
const target = weft.pkg.exports?.['./style/matrix']
|
|
19
|
+
if (typeof target !== 'string') return null
|
|
20
|
+
return import(pathToFileURL(join(weft.dir, target)).href)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Source files that can define or use groups: components and plain modules, not tests. */
|
|
24
|
+
export function styleFiles(root, config = {}) {
|
|
25
|
+
const exclude = [...(config.check?.exclude ?? []), ...(config.scope?.forbid ?? [])]
|
|
26
|
+
return discover(root, { include: ['**/*.{ts,tsx,jsx,mts}'], exclude }).filter(f => !isTestFile(f) && !f.endsWith('.d.ts'))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Scan the project and build the matrix. `write`: also write `.weft/styles.json` (when the project has groups, or the
|
|
31
|
+
* file is already there).
|
|
32
|
+
*/
|
|
33
|
+
export async function styleMatrix(root, { write = false } = {}) {
|
|
34
|
+
const weft = resolveWeft(root)
|
|
35
|
+
const mod = await styleModule(weft)
|
|
36
|
+
if (!mod) throw Object.assign(new Error(`weft@${weft.version} has no style groups (@weft-js/core/style/matrix)`), { code: 'E_NO_STYLE' })
|
|
37
|
+
const { config } = await loadConfig(root)
|
|
38
|
+
const compiler = loadCompiler(weft)
|
|
39
|
+
const scan = mod.scanProject(root, styleFiles(root, config), (...a) => compiler.transform(...a))
|
|
40
|
+
const matrix = mod.buildMatrix(scan.groups, scan.uses)
|
|
41
|
+
const file = join(root, '.weft', 'styles.json')
|
|
42
|
+
if (write && (scan.groups.length || existsSync(file))) writeMatrixFile(root, matrix)
|
|
43
|
+
return { matrix, scan, mod, file }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function writeMatrixFile(root, matrix) {
|
|
47
|
+
const file = join(root, '.weft', 'styles.json')
|
|
48
|
+
const text = `${JSON.stringify(matrix, null, 2)}\n`
|
|
49
|
+
if (existsSync(file) && readFileSync(file, 'utf8') === text) return false
|
|
50
|
+
mkdirSync(join(root, '.weft'), { recursive: true })
|
|
51
|
+
writeFileSync(file, text)
|
|
52
|
+
return true
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const words = s => s.toLowerCase().split(/[\s\-_.:/=,]+|(?<=[a-z])(?=[A-Z])/).filter(Boolean)
|
|
56
|
+
|
|
57
|
+
/** Edit distance with adjacent swaps as one edit (`ordre` → `order`). */
|
|
58
|
+
function distance(a, b) {
|
|
59
|
+
const d = Array.from({ length: a.length + 1 }, (_, i) => Array.from({ length: b.length + 1 }, (__, j) => (i ? (j ? 0 : i) : j)))
|
|
60
|
+
for (let i = 1; i <= a.length; i++)
|
|
61
|
+
for (let j = 1; j <= b.length; j++) {
|
|
62
|
+
d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1))
|
|
63
|
+
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1)
|
|
64
|
+
}
|
|
65
|
+
return d[a.length][b.length]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The names closest to `word` (edit distance within a third of its length, or containing it). */
|
|
69
|
+
export function closest(word, names, n = 3) {
|
|
70
|
+
const w = word.toLowerCase()
|
|
71
|
+
const limit = Math.max(1, Math.floor(w.length / 3))
|
|
72
|
+
return names
|
|
73
|
+
.map(x => [distance(w, x.toLowerCase()), x])
|
|
74
|
+
.filter(([d, x]) => d <= limit || x.toLowerCase().includes(w) || w.includes(x.toLowerCase()))
|
|
75
|
+
.sort((a, b) => a[0] - b[0])
|
|
76
|
+
.slice(0, n)
|
|
77
|
+
.map(([, x]) => x)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* `weft style find <query>`: the groups that fit, best first. Every word of the query must hit the group's name,
|
|
82
|
+
* doc, parts, axes, values, states or tokens (a name word one typo off counts); with no such group, groups that hit
|
|
83
|
+
* any word.
|
|
84
|
+
*/
|
|
85
|
+
export function findGroups(matrix, query) {
|
|
86
|
+
const terms = words(query ?? '')
|
|
87
|
+
if (!terms.length) throw usageError('weft style find <query>: say what the thing is (`card`, `status badge`, `critical`)')
|
|
88
|
+
const score = (name, g) => {
|
|
89
|
+
const fields = [
|
|
90
|
+
[words(name), 30],
|
|
91
|
+
[words(g.doc ?? ''), 10],
|
|
92
|
+
[[...g.parts, ...g.states, ...Object.keys(g.axes)].flatMap(words), 8],
|
|
93
|
+
[Object.values(g.axes).flatMap(v => (Array.isArray(v) ? v : [])).flatMap(words), 6],
|
|
94
|
+
[g.tokens.flatMap(words), 4],
|
|
95
|
+
]
|
|
96
|
+
let total = name === query ? 100 : 0
|
|
97
|
+
const matched = []
|
|
98
|
+
let hits = 0
|
|
99
|
+
for (const t of terms) {
|
|
100
|
+
let best = 0
|
|
101
|
+
let why = null
|
|
102
|
+
for (const [ws, weight] of fields) {
|
|
103
|
+
for (const w of ws) {
|
|
104
|
+
const s = w === t ? weight : w.startsWith(t) || (t.length > 3 && w.includes(t)) ? weight * 0.7 : weight >= 30 && t.length > 3 && distance(w, t) === 1 ? weight * 0.5 : 0
|
|
105
|
+
if (s > best) [best, why] = [s, w]
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (best) {
|
|
109
|
+
hits++
|
|
110
|
+
matched.push(why)
|
|
111
|
+
}
|
|
112
|
+
total += best
|
|
113
|
+
}
|
|
114
|
+
return { total, hits, matched: [...new Set(matched)] }
|
|
115
|
+
}
|
|
116
|
+
const all = Object.entries(matrix.groups).map(([name, g]) => ({ name, g, ...score(name, g) })).filter(x => x.total > 0)
|
|
117
|
+
const full = all.filter(x => x.hits === terms.length)
|
|
118
|
+
const ranked = (full.length ? full : all).sort((a, b) => b.total - a.total || a.name.localeCompare(b.name))
|
|
119
|
+
// A name or doc hit outranks groups that only share a token (`row` → product-row, not every `layout: row`).
|
|
120
|
+
const picked = ranked.filter(x => x.total >= (ranked[0]?.total ?? 0) / 4)
|
|
121
|
+
return {
|
|
122
|
+
query,
|
|
123
|
+
groups: picked.map(({ name, g, total, matched }) => ({
|
|
124
|
+
name,
|
|
125
|
+
score: Math.round(total),
|
|
126
|
+
matched,
|
|
127
|
+
doc: g.doc,
|
|
128
|
+
file: g.file,
|
|
129
|
+
import: g.binding && g.exported ? { name: g.binding, from: g.file.replace(/:\d+$/, '') } : null,
|
|
130
|
+
axes: g.axes,
|
|
131
|
+
defaults: g.defaults,
|
|
132
|
+
parts: g.parts,
|
|
133
|
+
states: g.states,
|
|
134
|
+
uses: g.used.length,
|
|
135
|
+
similar: matrix.similar.filter(s => s[0] === name || s[1] === name).map(s => (s[0] === name ? s[1] : s[0])),
|
|
136
|
+
})),
|
|
137
|
+
partial: !full.length && all.length > 0,
|
|
138
|
+
total: Object.keys(matrix.groups).length,
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const ARIA = new Set(['selected', 'pressed', 'checked', 'expanded', 'current', 'invalid', 'busy'])
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Read `order-card`, `order-card.title`, `order-card[status=late][selected]`, or a DOM selector
|
|
146
|
+
* (`[data-g="order-card"][data-status="late"] [data-part="title"]`, `[aria-selected="true"]`, `[data-accent~="radius:round"]`).
|
|
147
|
+
*/
|
|
148
|
+
export function parseTarget(target) {
|
|
149
|
+
const t = (target ?? '').trim()
|
|
150
|
+
if (!t) throw usageError('weft style explain <group|selector>: a group name (`order-card`), `order-card.title`, or a selector')
|
|
151
|
+
const out = { group: null, part: null, axes: {}, states: [], accent: {} }
|
|
152
|
+
const head = /^([a-z][\w-]*)(?:\.([\w-]+))?/i.exec(t)
|
|
153
|
+
if (head && !t.startsWith('[')) {
|
|
154
|
+
out.group = head[1]
|
|
155
|
+
out.part = head[2] ?? null
|
|
156
|
+
}
|
|
157
|
+
for (const m of t.matchAll(/\[\s*([\w-]+)\s*(?:[~|^$*]?=\s*(?:"([^"]*)"|'([^']*)'|([^\]\s]*)))?\s*\]/g)) {
|
|
158
|
+
const [, attr, ...vals] = m
|
|
159
|
+
const value = vals.find(v => v !== undefined)
|
|
160
|
+
if (attr === 'data-g') out.group = value
|
|
161
|
+
else if (attr === 'data-part') out.part = value
|
|
162
|
+
else if (attr === 'data-accent') for (const w of (value ?? '').split(/\s+/).filter(Boolean)) { const [k, v] = w.split(':'); out.accent[k] = v }
|
|
163
|
+
else if (attr.startsWith('aria-')) { if (value !== 'false') out.states.push(attr.slice(5)) }
|
|
164
|
+
else {
|
|
165
|
+
const name = attr.replace(/^data-/, '')
|
|
166
|
+
if (value === undefined || value === '') out.states.push(name)
|
|
167
|
+
else out.axes[name] = value
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (!out.group) throw usageError(`weft style explain: no group in \`${t}\``)
|
|
171
|
+
return out
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* `weft style explain <target>`: the group, and for the selection (defaults filled in) every declaration on the root
|
|
176
|
+
* and its parts in cascade order, with where each came from, plus the CSS that selection emits.
|
|
177
|
+
* @param {{ axes?: Record<string, string>, states?: string[], accent?: Record<string, string>, part?: string }} [extra]
|
|
178
|
+
*/
|
|
179
|
+
export function explainTarget({ matrix, scan, mod }, target, extra = {}) {
|
|
180
|
+
const t = parseTarget(target)
|
|
181
|
+
const g = matrix.groups[t.group]
|
|
182
|
+
if (!g) {
|
|
183
|
+
const near = closest(t.group, Object.keys(matrix.groups))
|
|
184
|
+
throw Object.assign(new Error(`D025 no group \`${t.group}\`${near.length ? `; did you mean ${near.map(n => `\`${n}\``).join(' or ')}?` : ''} (\`weft style find\` searches by what it is)`), { code: 'E_UNKNOWN_GROUP' })
|
|
185
|
+
}
|
|
186
|
+
const source = scan.groups.find(s => s.name === t.group)
|
|
187
|
+
// A state written as data-x="…" in a selector, or an axis passed as --x: sort them by what the group has.
|
|
188
|
+
const axes = { ...t.axes, ...extra.axes }
|
|
189
|
+
const states = [...t.states, ...(extra.states ?? [])]
|
|
190
|
+
for (const [k, v] of Object.entries(axes)) if (!(k in g.axes) && g.states.includes(k) && (v === 'true' || v === '')) { delete axes[k]; states.push(k) }
|
|
191
|
+
const selection = { axes, states: [...new Set(states)],accent: { ...t.accent, ...extra.accent }, part: extra.part ?? t.part ?? undefined }
|
|
192
|
+
let explained
|
|
193
|
+
try {
|
|
194
|
+
explained = mod.explainGroup(source, selection)
|
|
195
|
+
} catch (e) {
|
|
196
|
+
if (e.constructor?.name === 'GroupError') throw Object.assign(new Error(`D025 ${e.message}`), { code: 'E_UNKNOWN_NAME' })
|
|
197
|
+
throw e
|
|
198
|
+
}
|
|
199
|
+
const sel = explained.selection
|
|
200
|
+
const sites = g.used.filter(u => !u.part && Object.entries(sel.axes).every(([a, v]) => !u.axes[a] || u.axes[a] === 'dynamic' || u.axes[a].includes(v)))
|
|
201
|
+
return {
|
|
202
|
+
group: t.group,
|
|
203
|
+
doc: g.doc,
|
|
204
|
+
file: g.file,
|
|
205
|
+
axes: g.axes,
|
|
206
|
+
defaults: g.defaults,
|
|
207
|
+
parts: g.parts,
|
|
208
|
+
states: g.states,
|
|
209
|
+
contextTone: g.contextTone,
|
|
210
|
+
selection: sel,
|
|
211
|
+
attributes: attributesOf(t.group, sel, selection.part),
|
|
212
|
+
elements: explained.elements,
|
|
213
|
+
css: explained.css,
|
|
214
|
+
sites,
|
|
215
|
+
accents: g.accents,
|
|
216
|
+
similar: matrix.similar.filter(s => s[0] === t.group || s[1] === t.group),
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** What the selection looks like in the DOM, as a person would find it in devtools. */
|
|
221
|
+
function attributesOf(group, sel, part) {
|
|
222
|
+
const root = [`data-g="${group}"`, ...Object.entries(sel.axes).map(([a, v]) => `data-${a}="${v}"`), ...sel.states.map(s => (ARIA.has(s) ? `aria-${s}="true"` : `data-${s}`))]
|
|
223
|
+
const accent = Object.entries(sel.accent).sort(([a], [b]) => (a < b ? -1 : 1)).map(([k, v]) => `${k}:${v}`).join(' ')
|
|
224
|
+
if (accent) root.push(`data-accent="${accent}"`)
|
|
225
|
+
return { root: root.join(' '), ...(part ? { part: `data-part="${part}"` } : {}) }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function formatFind(r) {
|
|
229
|
+
if (!r.groups.length) return `no group fits "${r.query}" (${r.total} groups). Define one with group() from @weft-js/core/style; \`weft docs style\` has the rules.`
|
|
230
|
+
const lines = r.groups.map(g => {
|
|
231
|
+
const axes = Object.entries(g.axes).map(([a, v]) => `${a}: ${Array.isArray(v) ? v.join('|') : v}`).join(' · ')
|
|
232
|
+
return `${g.name.padEnd(18)} ${g.file}${g.doc ? `\n ${g.doc}` : ''}\n ${[axes, g.parts.length ? `parts: ${g.parts.join(', ')}` : '', g.states.length ? `states: ${g.states.join(', ')}` : '', `${g.uses} uses`].filter(Boolean).join(' · ')}`
|
|
233
|
+
})
|
|
234
|
+
return (r.partial ? `no group matches every word of "${r.query}"; closest:\n` : '') + lines.join('\n')
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function formatExplain(r) {
|
|
238
|
+
const out = [`${r.group} ${r.file}${r.doc ? ` — ${r.doc}` : ''}`, ` <el ${r.attributes.root}>${r.attributes.part ? ` part: <el ${r.attributes.part}>` : ''}`]
|
|
239
|
+
for (const el of r.elements) {
|
|
240
|
+
if (!el.decls.length) continue
|
|
241
|
+
out.push(` ${el.element}:`)
|
|
242
|
+
for (const d of el.decls) out.push(` ${d.overridden ? '~' : ' '} ${`${d.prop}: ${d.value}`.padEnd(64)} ← ${d.from} (${d.at})${d.overridden ? ', overridden' : ''}`)
|
|
243
|
+
}
|
|
244
|
+
out.push('', r.css.trimEnd())
|
|
245
|
+
if (r.contextTone) out.push('', 'Reads its tone from the nearest data-tone (warp context tone).')
|
|
246
|
+
return out.join('\n')
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Long class lists are how Tailwind is written, so D020 stays out of an app that chose it. */
|
|
250
|
+
export function usesTailwind(root) {
|
|
251
|
+
const file = join(root, 'package.json')
|
|
252
|
+
if (!existsSync(file)) return false
|
|
253
|
+
const { dependencies, devDependencies } = JSON.parse(readFileSync(file, 'utf8'))
|
|
254
|
+
return 'tailwindcss' in { ...dependencies, ...devDependencies }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* D020: class soup. A static `class`/`className` with more than 8 classes, or one class list (4+ classes, any order)
|
|
259
|
+
* in two or more places. The fix names a group after the component and element.
|
|
260
|
+
* @param {{ rel: string, text: string }[]} files
|
|
261
|
+
*/
|
|
262
|
+
export function classSoup(files) {
|
|
263
|
+
const out = []
|
|
264
|
+
const lists = new Map()
|
|
265
|
+
const attr = /\b(class|className)\s*=\s*(?:"([^"]*)"|'([^']*)'|\{\s*(["'`])([^"'`$]*)\4\s*\})/g
|
|
266
|
+
for (const { rel, text } of files) {
|
|
267
|
+
const pos = positions(text)
|
|
268
|
+
const named = new Map()
|
|
269
|
+
for (const m of text.matchAll(attr)) {
|
|
270
|
+
const value = m[2] ?? m[3] ?? m[5] ?? ''
|
|
271
|
+
const classes = value.split(/\s+/).filter(Boolean)
|
|
272
|
+
if (!classes.length) continue
|
|
273
|
+
const before = text.slice(0, m.index)
|
|
274
|
+
const component = [...before.matchAll(/(?:function\s+([A-Z]\w*)|(?:const|let)\s+([A-Z]\w*)\s*=)/g)].at(-1)
|
|
275
|
+
const tag = [...before.matchAll(/<([a-z][\w-]*)\b/g)].at(-1)?.[1] ?? 'div'
|
|
276
|
+
const base = component ? (component[1] ?? component[2]).replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase() : rel.replace(/^.*\//, '').replace(/\.\w+$/, '').toLowerCase()
|
|
277
|
+
const n = (named.get(base) ?? 0) + 1
|
|
278
|
+
named.set(base, n)
|
|
279
|
+
const name = n === 1 ? base : `${base}-${tag}`
|
|
280
|
+
const at = { span: pos.span(m.index, m.index + m[0].length), offset: [m.index, m.index + m[0].length] }
|
|
281
|
+
const site = { rel, at, name, count: classes.length }
|
|
282
|
+
if (classes.length > 8) out.push(diagnostic({ code: 'D020', severity: 'warning', file: rel, ...at, why: `<${tag}> carries ${classes.length} classes: nobody can read what it is or which variation it's in.`, help: groupHelp(name) }))
|
|
283
|
+
if (classes.length >= 4) {
|
|
284
|
+
const key = [...classes].sort().join(' ')
|
|
285
|
+
lists.set(key, [...(lists.get(key) ?? []), site])
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
for (const sites of lists.values()) {
|
|
290
|
+
if (sites.length < 2) continue
|
|
291
|
+
for (const s of sites.slice(1)) {
|
|
292
|
+
const first = sites[0]
|
|
293
|
+
out.push(diagnostic({
|
|
294
|
+
code: 'D020', severity: 'warning', file: s.rel, ...s.at,
|
|
295
|
+
why: `The same ${s.count} classes are written in ${sites.length} places (first at ${first.rel}:${first.at.span[0]}): one look, copied by hand.`,
|
|
296
|
+
help: groupHelp(first.name),
|
|
297
|
+
related: sites.filter(x => x !== s).map(x => ({ file: x.rel, span: x.at.span })),
|
|
298
|
+
}))
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return out
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const groupHelp = name => `Extract a group: \`export const ${name.replace(/-(\w)/g, (_, c) => c.toUpperCase())} = group('${name}', { base: { … } })\` and spread it (\`{...${name.replace(/-(\w)/g, (_, c) => c.toUpperCase())}()}\`). \`weft style find\` first: one may already fit.`
|
|
305
|
+
|
|
306
|
+
/** Matrix diagnostics (D021–D025) as `weft check` diagnostics, located on the group or the site. */
|
|
307
|
+
export function matrixDiagnostics(root, matrix) {
|
|
308
|
+
const texts = new Map()
|
|
309
|
+
const text = file => {
|
|
310
|
+
if (!texts.has(file)) texts.set(file, existsSync(join(root, file)) ? readFileSync(join(root, file), 'utf8') : '')
|
|
311
|
+
return texts.get(file)
|
|
312
|
+
}
|
|
313
|
+
return matrix.diagnostics.map(d => {
|
|
314
|
+
const lines = text(d.file).split('\n')
|
|
315
|
+
const line = Math.max(1, Math.min(d.line, lines.length))
|
|
316
|
+
const col = (lines[line - 1]?.search(/\S/) ?? 0) + 1
|
|
317
|
+
const loc = d.line ? { span: [line, col, line, (lines[line - 1]?.length ?? 0) + 1] } : locate(text(d.file), d.group)
|
|
318
|
+
return diagnostic({ code: d.code, severity: d.severity, file: d.file, ...loc, why: d.message, help: d.help })
|
|
319
|
+
})
|
|
320
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// Surfaces in `weft check` (research 33 §8): X002 and X009–X012 for surfaces and their text, X040 and X041 for per-glyph
|
|
2
|
+
// markup, and P101 for a surface that needs a slot nothing provides. X001, X003 and X004 need the live mirror, so
|
|
3
|
+
// @weft-js/core/surface reports those in dev. `listSurfaces` is the catalog behind `weft surfaces` and the MCP tool.
|
|
4
|
+
|
|
5
|
+
import { readFileSync } from 'node:fs'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
import ts from 'typescript'
|
|
8
|
+
import { diagnostic, positions } from './diag.mjs'
|
|
9
|
+
import { discover } from './files.mjs'
|
|
10
|
+
import { scanJsx } from './jsx-scan.mjs'
|
|
11
|
+
|
|
12
|
+
const CODE = /\.[cm]?[jt]sx?$/
|
|
13
|
+
const SYSTEM = /(^|,)\s*["']?(system-ui|-apple-system|BlinkMacSystemFont)["']?\s*(,|$)/i
|
|
14
|
+
const GENERIC_ONLY = /^\s*(serif|sans-serif|monospace|cursive|fantasy)\s*$/i
|
|
15
|
+
const GLYPHS = 200
|
|
16
|
+
|
|
17
|
+
const parse = (rel, text) => ts.createSourceFile(rel, text, ts.ScriptTarget.Latest, true, /\.[jt]sx$/.test(rel) ? ts.ScriptKind.TSX : ts.ScriptKind.TS)
|
|
18
|
+
|
|
19
|
+
const prop = (object, name) => object.properties.find(p => (ts.isPropertyAssignment(p) || ts.isMethodDeclaration(p) || ts.isShorthandPropertyAssignment(p)) && p.name.getText() === name)
|
|
20
|
+
const stringOf = node => (node && (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) ? node.text : undefined)
|
|
21
|
+
|
|
22
|
+
function walk(node, visit) {
|
|
23
|
+
visit(node)
|
|
24
|
+
ts.forEachChild(node, c => walk(c, visit))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Every `defineSurface({ ... })` call in a file: the call, its options and the name of `setup`'s `s`. */
|
|
28
|
+
function surfaceCalls(sf) {
|
|
29
|
+
const out = []
|
|
30
|
+
walk(sf, node => {
|
|
31
|
+
if (!ts.isCallExpression(node) || node.expression.getText() !== 'defineSurface') return
|
|
32
|
+
const def = node.arguments[0]
|
|
33
|
+
if (!def || !ts.isObjectLiteralExpression(def)) return
|
|
34
|
+
const setup = prop(def, 'setup')
|
|
35
|
+
const fn = setup && (ts.isMethodDeclaration(setup) ? setup : setup.initializer)
|
|
36
|
+
out.push({ node, def, fn, param: fn?.parameters?.[0]?.name.getText() })
|
|
37
|
+
})
|
|
38
|
+
return out
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function propNames(call) {
|
|
42
|
+
const schema = prop(call.def, 'props')?.initializer
|
|
43
|
+
if (schema && ts.isCallExpression(schema) && schema.expression.getText().endsWith('.object') && ts.isObjectLiteralExpression(schema.arguments[0] ?? schema)) return schema.arguments[0].properties.map(p => p.name.getText())
|
|
44
|
+
const type = call.node.typeArguments?.[0]
|
|
45
|
+
if (type && ts.isTypeLiteralNode(type)) return type.members.map(m => m.name?.getText()).filter(Boolean)
|
|
46
|
+
return []
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** What the project's `defineSurface` calls declare, read statically: nothing needs to build. */
|
|
50
|
+
export function listSurfaces(root, { exclude = [] } = {}) {
|
|
51
|
+
const out = []
|
|
52
|
+
for (const rel of discover(root, { include: ['src/**/*.{ts,tsx,js,jsx}'], exclude })) {
|
|
53
|
+
const text = readFileSync(join(root, rel), 'utf8')
|
|
54
|
+
if (!text.includes('defineSurface')) continue
|
|
55
|
+
const sf = parse(rel, text)
|
|
56
|
+
for (const call of surfaceCalls(sf)) {
|
|
57
|
+
const line = sf.getLineAndCharacterOfPosition(call.node.getStart(sf)).line + 1
|
|
58
|
+
out.push({ name: stringOf(prop(call.def, 'name')?.initializer) ?? null, kind: stringOf(prop(call.def, 'kind')?.initializer) ?? null, props: propNames(call), file: rel, line, ...(prop(call.def, 'pixel') ? { pixel: true } : {}) })
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return out.sort((a, b) => String(a.name).localeCompare(String(b.name)))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** `[...x]`, `x.split('')` or `Array.from(x)`: the string being spread, or undefined. */
|
|
65
|
+
function glyphSource(node) {
|
|
66
|
+
if (ts.isArrayLiteralExpression(node) && node.elements.length === 1 && ts.isSpreadElement(node.elements[0])) return node.elements[0].expression
|
|
67
|
+
if (!ts.isCallExpression(node)) return undefined
|
|
68
|
+
const callee = node.expression.getText()
|
|
69
|
+
if (callee === 'Array.from' && node.arguments.length === 1 && !ts.isObjectLiteralExpression(node.arguments[0])) return node.arguments[0]
|
|
70
|
+
if (ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'split' && stringOf(node.arguments[0]) === '') return node.expression.expression
|
|
71
|
+
return undefined
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The first `<span>` a `.map` callback returns. */
|
|
75
|
+
function returnedSpan(fn) {
|
|
76
|
+
let span
|
|
77
|
+
walk(fn.body, n => {
|
|
78
|
+
if (span) return
|
|
79
|
+
const open = ts.isJsxElement(n) ? n.openingElement : ts.isJsxSelfClosingElement(n) ? n : undefined
|
|
80
|
+
if (open?.tagName.getText() === 'span') span = open
|
|
81
|
+
})
|
|
82
|
+
return span
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const attrOf = (open, name) => open.attributes.properties.some(p => !ts.isJsxSpreadAttribute(p) && p.name.getText() === name)
|
|
86
|
+
const spreads = open => open.attributes.properties.some(ts.isJsxSpreadAttribute)
|
|
87
|
+
|
|
88
|
+
/** The JSX element a child expression sits in. */
|
|
89
|
+
function jsxParent(node) {
|
|
90
|
+
for (let n = node.parent; n; n = n.parent) if (ts.isJsxElement(n)) return n.openingElement
|
|
91
|
+
return undefined
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A string literal, or a `const` in this file holding one. */
|
|
95
|
+
function knownString(sf, node) {
|
|
96
|
+
const direct = stringOf(node)
|
|
97
|
+
if (direct !== undefined) return direct
|
|
98
|
+
if (!ts.isIdentifier(node)) return undefined
|
|
99
|
+
let found
|
|
100
|
+
walk(sf, n => {
|
|
101
|
+
if (ts.isVariableDeclaration(n) && n.name.getText() === node.text && ts.isVariableDeclarationList(n.parent) && n.parent.flags & ts.NodeFlags.Const) found ??= stringOf(n.initializer)
|
|
102
|
+
})
|
|
103
|
+
return found
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function glyphChecks(sf, report) {
|
|
107
|
+
walk(sf, node => {
|
|
108
|
+
if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression) || node.expression.name.text !== 'map') return
|
|
109
|
+
const source = glyphSource(node.expression.expression)
|
|
110
|
+
const fn = node.arguments[0]
|
|
111
|
+
if (!source || !fn || !(ts.isArrowFunction(fn) || ts.isFunctionExpression(fn))) return
|
|
112
|
+
const span = returnedSpan(fn)
|
|
113
|
+
if (!span) return
|
|
114
|
+
const text = knownString(sf, source)
|
|
115
|
+
const count = text === undefined ? 0 : Array.from(text).length
|
|
116
|
+
if (count > GLYPHS) report('X040', node, `Per-grapheme spans over ${count} graphemes: each is a DOM node, a style and a composited layer once it animates. Use word-level motion or a Surface.`)
|
|
117
|
+
const hidden = attrOf(span, 'aria-hidden') || spreads(span)
|
|
118
|
+
const parent = jsxParent(node)
|
|
119
|
+
const named = !parent || attrOf(parent, 'aria-label') || attrOf(parent, 'aria-labelledby') || spreads(parent)
|
|
120
|
+
if (!hidden || !named) report('X041', span, `Per-character spans read as separate letters to a screen reader${hidden ? '' : ': add `aria-hidden` to each span'}${named ? '' : `${hidden ? ':' : ', and'} give the parent an \`aria-label\` with the whole text`}.`)
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function surfaceFileChecks(sf, slots, report) {
|
|
125
|
+
const calls = surfaceCalls(sf)
|
|
126
|
+
if (!calls.length) return
|
|
127
|
+
const decorative = new Set()
|
|
128
|
+
walk(sf, n => {
|
|
129
|
+
if (ts.isCallExpression(n) && ts.isPropertyAccessExpression(n.expression) && n.expression.name.text === 'decorative') decorative.add(n)
|
|
130
|
+
})
|
|
131
|
+
const inside = n => { for (let p = n.parent; p; p = p.parent) if (decorative.has(p)) return true; return false }
|
|
132
|
+
walk(sf, n => {
|
|
133
|
+
if (!ts.isCallExpression(n) || !ts.isPropertyAccessExpression(n.expression)) return
|
|
134
|
+
const method = n.expression.name.text
|
|
135
|
+
if ((method === 'fillText' || method === 'strokeText') && !inside(n)) report('X010', n, `\`${method}\` in a surface draws text only the canvas knows about: no selection, find, translation or screen reader. Use \`s.text()\`, or \`s.decorative()\` for text that is truly decoration.`)
|
|
136
|
+
if (method === 'setAttribute' && stringOf(n.arguments[0]) === 'aria-hidden' && n.expression.expression.getText().endsWith('.el')) report('X011', n, 'This hides the surface or its text mirror from assistive tech. Leave the mirror to weft.')
|
|
137
|
+
})
|
|
138
|
+
for (const call of calls) {
|
|
139
|
+
const kindNode = prop(call.def, 'kind')?.initializer
|
|
140
|
+
const kind = stringOf(kindNode)
|
|
141
|
+
if (kind && slots && slots['surface.renderer'] && !slots['surface.renderer'][kind]) report('P101', kindNode, `Surface kind \`${kind}\` has no provider: add the plugin that provides it to \`plugins\` in weft.config.ts.`)
|
|
142
|
+
if (!call.param || !call.fn) continue
|
|
143
|
+
walk(call.fn, n => {
|
|
144
|
+
if (!ts.isCallExpression(n) || n.expression.getText() !== `${call.param}.text`) return
|
|
145
|
+
const options = n.arguments[1]
|
|
146
|
+
const font = options && ts.isObjectLiteralExpression(options) ? stringOf(prop(options, 'font')?.initializer) : undefined
|
|
147
|
+
const family = font?.replace(/^.*?\d[\d.]*px(\/\S+)?\s*/, '')
|
|
148
|
+
if (family !== undefined && (SYSTEM.test(family) || GENERIC_ONLY.test(family))) report('X002', n.arguments[1], `\`${font}\` can't be measured safely: \`system-ui\`, \`-apple-system\` and generic-only families resolve differently in canvas and CSS. Name a family.`)
|
|
149
|
+
if (kind === 'canvas2d' && slots && slots['text.measure'] === null) report('P101', n, 'Canvas `s.text()` needs `text.measure`, and nothing provides it: add `text()` from `@weft-js/text` to `plugins` in weft.config.ts.')
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const HIDES = /(?:display\s*:\s*none|visibility\s*:\s*hidden)/
|
|
155
|
+
|
|
156
|
+
/** X011/X012 in a stylesheet: rules aimed at the host or the mirror. */
|
|
157
|
+
function cssChecks(file, report) {
|
|
158
|
+
const css = file.text.replace(/\/\*[\s\S]*?\*\//g, m => m.replace(/[^\n]/g, ' '))
|
|
159
|
+
const rule = /([^{}]*\[data-surface(-text)?\b[^{}]*)\{([^}]*)\}/g
|
|
160
|
+
for (const m of css.matchAll(rule)) {
|
|
161
|
+
const at = m.index + m[0].indexOf('[data-surface')
|
|
162
|
+
const before = css.slice(0, m.index)
|
|
163
|
+
const open = before.lastIndexOf('@media')
|
|
164
|
+
const inPrint = open >= 0 && /^@media[^{]*\bprint\b/.test(before.slice(open)) && (before.slice(open).match(/\{/g) ?? []).length > (before.slice(open).match(/\}/g) ?? []).length
|
|
165
|
+
if (/forced-color-adjust\s*:\s*none/.test(m[3])) report('X012', at, at + 13, '`forced-color-adjust: none` on a surface keeps its author colours in forced-colours mode, where the mirror must take over.')
|
|
166
|
+
else if (inPrint && HIDES.test(m[3])) report('X012', at, at + 13, 'This hides surface text when printing, where the mirror is the only copy that prints sharply.')
|
|
167
|
+
else if (m[2] && HIDES.test(m[3])) report('X011', at, at + 18, 'This hides the text mirror from assistive tech, and it is the only copy a screen reader can read. Leave the mirror to weft.')
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* @param {string} root
|
|
173
|
+
* @param {{ files: { rel: string, text: string }[], exclude?: string[], slots?: Record<string, any> }} options
|
|
174
|
+
*/
|
|
175
|
+
export function surfaceChecks(root, { files, exclude = [], slots }) {
|
|
176
|
+
const out = []
|
|
177
|
+
const reporter = file => {
|
|
178
|
+
const pos = positions(file.text)
|
|
179
|
+
return (code, start, end, why) => out.push(diagnostic({ code, file: file.rel, span: pos.span(start, end), offset: [start, end], why }))
|
|
180
|
+
}
|
|
181
|
+
const sources = new Map(files.filter(f => CODE.test(f.rel)).map(f => [f.rel, f]))
|
|
182
|
+
for (const rel of discover(root, { include: ['src/**/*.{ts,tsx,js,jsx}'], exclude })) {
|
|
183
|
+
if (!sources.has(rel)) sources.set(rel, { rel, text: readFileSync(join(root, rel), 'utf8') })
|
|
184
|
+
}
|
|
185
|
+
for (const f of sources.values()) {
|
|
186
|
+
const glyphs = /\.[jt]sx$/.test(f.rel) && /\.map\(/.test(f.text) && /<span\b/.test(f.text)
|
|
187
|
+
const surfaces = f.text.includes('@weft-js/core/surface')
|
|
188
|
+
if (!glyphs && !surfaces) continue
|
|
189
|
+
let sf
|
|
190
|
+
try {
|
|
191
|
+
sf = parse(f.rel, f.text)
|
|
192
|
+
} catch {
|
|
193
|
+
continue
|
|
194
|
+
}
|
|
195
|
+
const raw = reporter(f)
|
|
196
|
+
const report = (code, node, why) => raw(code, node.getStart(sf), node.end, why)
|
|
197
|
+
if (glyphs) glyphChecks(sf, report)
|
|
198
|
+
if (!surfaces) continue
|
|
199
|
+
surfaceFileChecks(sf, slots, report)
|
|
200
|
+
if (!/\.[jt]sx$/.test(f.rel)) continue
|
|
201
|
+
const scan = scanJsx(f.text, f.rel)
|
|
202
|
+
const local = scan.imports.find(i => i.source === '@weft-js/core/surface' && i.imported === 'Surface')?.local
|
|
203
|
+
for (const el of scan.elements) {
|
|
204
|
+
if (el.name !== local) continue
|
|
205
|
+
const spread = el.attrs.some(a => a.kind === 'spread')
|
|
206
|
+
if (!el.attr('label') && !el.attr('decorative') && !spread) raw('X009', el.start, el.tagEnd, `<${el.name}> has no accessible name: a screen reader finds an unnamed image. Add \`label\`, or \`decorative\` if it shows nothing a reader needs.`)
|
|
207
|
+
const hidden = el.attr('aria-hidden')
|
|
208
|
+
if (hidden && hidden.value !== 'false') raw('X011', hidden.start, hidden.end, 'This hides the surface and its text mirror from assistive tech. For a surface with nothing to read use `decorative`, which leaves mirrors alone.')
|
|
209
|
+
const style = el.attr('style')
|
|
210
|
+
if (style && /forced-color-adjust\s*:\s*none|forcedColorAdjust\s*:\s*['"]none/.test(style.value ?? style.expr ?? '')) raw('X012', style.start, style.end, '`forced-color-adjust: none` on a surface keeps its author colours in forced-colours mode, where the mirror must take over.')
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
for (const rel of discover(root, { include: ['**/*.css'], exclude })) {
|
|
214
|
+
const text = readFileSync(join(root, rel), 'utf8')
|
|
215
|
+
if (text.includes('data-surface')) cssChecks({ rel, text }, reporter({ rel, text }))
|
|
216
|
+
}
|
|
217
|
+
return out
|
|
218
|
+
}
|
package/src/tailwind.mjs
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Tailwind v4 for a weft app (research 34 §5). `create-weft --ui tailwind` and `weft add tailwind` write the same files,
|
|
2
|
+
// and a create-weft test holds them equal, because create-weft can't depend on the CLI.
|
|
3
|
+
|
|
4
|
+
export const packages = { tailwindcss: '4.3.3', '@tailwindcss/vite': '4.3.3' }
|
|
5
|
+
|
|
6
|
+
export const viteConfig = `import tailwindcss from '@tailwindcss/vite'
|
|
7
|
+
|
|
8
|
+
export default { plugins: [tailwindcss()] }
|
|
9
|
+
`
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The top of the app's CSS. With warp, Tailwind's reset stays out: its `base` layer comes after `warp`, so it would
|
|
13
|
+
* beat every warp component. warp brings its own base. Layer order has to be declared before any import.
|
|
14
|
+
*/
|
|
15
|
+
export const cssEntry = warp => (warp
|
|
16
|
+
? `@layer warp, theme, base, components, utilities;
|
|
17
|
+
@import "tailwindcss/theme.css" layer(theme);
|
|
18
|
+
@import "tailwindcss/utilities.css" layer(utilities);
|
|
19
|
+
@import "@weft-js/warp/tailwind.css";
|
|
20
|
+
`
|
|
21
|
+
: `@import "tailwindcss";
|
|
22
|
+
`)
|
package/src/tasks.mjs
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// `weft tasks`: the single list of what can be run (PLAN §7b). Built-ins first, then the project's `tasks`.
|
|
2
|
+
|
|
3
|
+
import { spawn } from 'node:child_process'
|
|
4
|
+
import { existsSync } from 'node:fs'
|
|
5
|
+
import { join, relative, resolve } from 'node:path'
|
|
6
|
+
import { z } from 'zod'
|
|
7
|
+
import { loadConfig } from './load-config.mjs'
|
|
8
|
+
|
|
9
|
+
const files = { type: 'array', items: { type: 'string' }, description: 'Files relative to the project root; default: the whole project' }
|
|
10
|
+
|
|
11
|
+
/** `available: false` marks commands PLAN names that this CLI does not ship yet; `stack` ones ship for apps with src/routes. */
|
|
12
|
+
export const builtins = [
|
|
13
|
+
{ name: 'check', description: 'Compile every component in check mode and report W- and P-diagnostics with fixes', args: { type: 'object', properties: { files } }, sideEffects: [], available: true },
|
|
14
|
+
{ name: 'tasks', description: 'List every command and project task with args, side effects and environments', args: { type: 'object', properties: {} }, sideEffects: [], available: true },
|
|
15
|
+
{ name: 'docs', description: 'Print a docs topic or a diagnostic code (`weft docs W014`)', args: { type: 'object', properties: { topic: { type: 'string' } } }, sideEffects: [], available: true },
|
|
16
|
+
{ name: 'agents-md', description: 'Regenerate AGENTS.md (`--check` fails if stale)', args: { type: 'object', properties: { check: { type: 'boolean' } } }, sideEffects: ['fs:AGENTS.md'], available: true },
|
|
17
|
+
{ name: 'mcp', description: 'These commands as MCP tools (stdio)', args: { type: 'object', properties: {} }, sideEffects: [], available: true, longRunning: true },
|
|
18
|
+
{ name: 'style', description: 'Style groups: `find <query>`, `explain <group|selector>`, `matrix` (writes .weft/styles.json)', args: { type: 'object', properties: { subcommand: { type: 'string', enum: ['find', 'explain', 'matrix'] }, query: { type: 'string' }, target: { type: 'string' } } }, sideEffects: ['fs:.weft/styles.json'], available: true },
|
|
19
|
+
{ name: 'map', description: 'Write `.weft/map.md`: a matrix of what to call in this app', args: { type: 'object', properties: {} }, sideEffects: ['fs:.weft/map.*'], available: true },
|
|
20
|
+
{ name: 'add', description: 'Install a plugin and wire it into the config (`--dry-run` shows the changes)', args: { type: 'object', properties: { plugin: { type: 'string' }, 'dry-run': { type: 'boolean' } } }, sideEffects: ['fs:weft.config.*', 'fs:package.json', 'network:install'], available: true },
|
|
21
|
+
{ name: 'dev', description: 'Dev server', sideEffects: ['network:listen'], available: false, stack: true, longRunning: true },
|
|
22
|
+
{ name: 'build', description: 'Production build and contract', sideEffects: ['fs:.output/**'], available: false, stack: true },
|
|
23
|
+
{ name: 'start', description: 'Serve the build', sideEffects: ['network:listen'], available: false, stack: true, longRunning: true },
|
|
24
|
+
{ name: 'db', description: '`generate`: new migrations', sideEffects: ['fs:drizzle/**', 'fs:migrations/**', 'fs:prisma/**'], available: false, stack: true },
|
|
25
|
+
{ name: 'test', description: 'The test gate on the diff (`--gate`)', args: { type: 'object', properties: { gate: { type: 'boolean' }, base: { type: 'string' }, budget: { type: 'number' }, refactor: { type: 'boolean' } } }, sideEffects: ['fs:.weft/gate'], available: true },
|
|
26
|
+
{ name: 'fix', description: 'Apply the safe autofixes from `weft check`', args: { type: 'object', properties: { files } }, sideEffects: ['fs:scope.write'], available: true },
|
|
27
|
+
{ name: 'tokens', description: "warp's resolved tokens as DTCG 2025.10 in `.weft/tokens/` (Figma, Style Dictionary, Terrazzo)", args: { type: 'object', properties: {} }, sideEffects: ['fs:.weft/tokens/**'], available: true },
|
|
28
|
+
{ name: 'browser', description: 'Drive the running app in any open browser by role/label/text (`weft browser help`)', args: { type: 'object', properties: { command: { type: 'string' } } }, sideEffects: ['network:dev-server', 'fs:.weft/browser'], available: true },
|
|
29
|
+
{ name: 'run', description: 'Run a project task by name', args: { type: 'object', properties: { name: { type: 'string' } } }, sideEffects: ['task'], available: true },
|
|
30
|
+
{ name: 'create', description: 'Scaffold a weft app', sideEffects: ['fs'], available: false },
|
|
31
|
+
{ name: 'surfaces', description: "List the project's surfaces: name, kind, props and file", sideEffects: [], available: true },
|
|
32
|
+
{ name: 'plugins', description: 'List and explain plugins and slot bindings; `sync` regenerates .weft/slots and plugin tokens; `audit` and `approve` check and write the permissions lock', args: { type: 'object', properties: { subcommand: { type: 'string', enum: ['list', 'explain', 'sync', 'audit', 'approve'] }, slot: { type: 'string' }, all: { type: 'boolean' } } }, sideEffects: ['fs:.weft/**', 'fs:.gitignore'], available: true },
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
function jsonSchema(args) {
|
|
36
|
+
if (!args) return { type: 'object', properties: {} }
|
|
37
|
+
if ('_zod' in args || '~standard' in args) {
|
|
38
|
+
try {
|
|
39
|
+
return z.toJSONSchema(args)
|
|
40
|
+
} catch {
|
|
41
|
+
return { type: 'object', description: 'Zod schema that has no JSON Schema form' }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return args
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** `weft run <name>`: the task's command in a shell at the project root, its output yours. Resolves with its exit code. */
|
|
48
|
+
export async function runTask(root, name) {
|
|
49
|
+
root = resolve(root)
|
|
50
|
+
const { config } = await loadConfig(root)
|
|
51
|
+
const task = config.tasks?.[name]
|
|
52
|
+
if (!task) throw Object.assign(new Error(`no task "${name}" (tasks: ${Object.keys(config.tasks ?? {}).join(', ') || 'none'})`), { code: 'E_USAGE' })
|
|
53
|
+
return new Promise((ok, fail) => {
|
|
54
|
+
const child = spawn(task.run, { cwd: root, shell: true, stdio: 'inherit' })
|
|
55
|
+
child.on('error', fail)
|
|
56
|
+
child.on('close', code => ok(code ?? 1))
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Everything `weft` can run here: the built-ins and the project's own tasks, with the config's diagnostics. */
|
|
61
|
+
export async function tasks(root) {
|
|
62
|
+
root = resolve(root)
|
|
63
|
+
const { file, config, diagnostics } = await loadConfig(root)
|
|
64
|
+
const environments = Object.keys(config.environments ?? {})
|
|
65
|
+
const stack = existsSync(join(root, 'src', 'routes'))
|
|
66
|
+
const list = builtins.map(b => ({
|
|
67
|
+
name: b.name,
|
|
68
|
+
kind: 'builtin',
|
|
69
|
+
command: { check: 'weft check --json', tasks: 'weft tasks --json', docs: 'weft docs <topic>' }[b.name] ?? `weft ${b.name}`,
|
|
70
|
+
description: b.description,
|
|
71
|
+
args: b.args ?? { type: 'object', properties: {} },
|
|
72
|
+
sideEffects: b.sideEffects,
|
|
73
|
+
env: environments,
|
|
74
|
+
available: b.available || (b.stack && stack),
|
|
75
|
+
...(b.longRunning && { longRunning: true }),
|
|
76
|
+
}))
|
|
77
|
+
for (const [name, t] of Object.entries(config.tasks ?? {})) {
|
|
78
|
+
list.push({
|
|
79
|
+
name,
|
|
80
|
+
kind: 'project',
|
|
81
|
+
command: `weft run ${name}`,
|
|
82
|
+
description: t.description ?? null,
|
|
83
|
+
args: jsonSchema(t.args),
|
|
84
|
+
sideEffects: t.sideEffects ?? null,
|
|
85
|
+
env: t.env ?? environments,
|
|
86
|
+
available: true,
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
version: 1,
|
|
91
|
+
ok: !diagnostics.some(d => d.severity === 'error'),
|
|
92
|
+
root,
|
|
93
|
+
config: file ? relative(root, file).split('\\').join('/') : null,
|
|
94
|
+
tasks: list,
|
|
95
|
+
scope: { write: config.scope?.write ?? null, read: config.scope?.read ?? null, forbid: config.scope?.forbid ?? [] },
|
|
96
|
+
environments,
|
|
97
|
+
diagnostics: diagnostics.filter(d => ['P003', 'P004', 'P007'].includes(d.code)),
|
|
98
|
+
}
|
|
99
|
+
}
|