@weft-js/cli 0.0.0-stage → 0.1.1
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/deploy.md +83 -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 +366 -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 +496 -0
- package/src/check.mjs +330 -0
- package/src/cli.mjs +364 -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/deploy/cdn.mjs +64 -0
- package/src/deploy/files.mjs +187 -0
- package/src/deploy/fit.mjs +62 -0
- package/src/deploy/hosts.mjs +56 -0
- package/src/deploy/index.mjs +273 -0
- package/src/deploy/needs.mjs +92 -0
- package/src/design-checks.mjs +439 -0
- package/src/diag.mjs +74 -0
- package/src/docs.mjs +132 -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 +303 -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 +296 -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/project.mjs
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// Project view for `weft check`: what one file can't see. Today that is cross-file W014.
|
|
2
|
+
//
|
|
3
|
+
// The compiler reports W014 as a warning when `xs.value = xs.value.map(p => ({ ...p, … }))` feeds a box that leaves
|
|
4
|
+
// the file. Here we follow that box through JSX props (`<List items={xs.value} />`), into the component's props,
|
|
5
|
+
// through `derived` and further JSX props, until a JSX `.map` renders it as a keyed list. Found: the warning becomes
|
|
6
|
+
// an error with the list's location in `related`. The parse is syntax only (TypeScript's parser, no type checker).
|
|
7
|
+
|
|
8
|
+
import { relative } from 'node:path'
|
|
9
|
+
import ts from 'typescript'
|
|
10
|
+
import { local } from './alias.mjs'
|
|
11
|
+
|
|
12
|
+
const exts = ['', '.tsx', '.ts', '.jsx', '.js', '/index.tsx', '/index.ts', '/index.jsx', '/index.js']
|
|
13
|
+
const isComponentName = name => /^[A-Z]/.test(name)
|
|
14
|
+
|
|
15
|
+
/** `a.b.c(…).d` → ['a', 'b']: the root identifier and the first member after it. */
|
|
16
|
+
function chainRoot(expr) {
|
|
17
|
+
let node = expr
|
|
18
|
+
let member
|
|
19
|
+
for (;;) {
|
|
20
|
+
if (ts.isParenthesizedExpression(node) || ts.isNonNullExpression(node) || ts.isAsExpression(node)) node = node.expression
|
|
21
|
+
else if (ts.isPropertyAccessExpression(node)) {
|
|
22
|
+
member = node.name.text
|
|
23
|
+
node = node.expression
|
|
24
|
+
} else if (ts.isElementAccessExpression(node) || ts.isCallExpression(node)) {
|
|
25
|
+
member = undefined
|
|
26
|
+
node = node.expression
|
|
27
|
+
} else break
|
|
28
|
+
}
|
|
29
|
+
return ts.isIdentifier(node) ? [node.text, member] : null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isInJsx(node, stop) {
|
|
33
|
+
for (let n = node.parent; n && n !== stop; n = n.parent) if (ts.isJsxExpression(n)) return true
|
|
34
|
+
return false
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Identifiers an expression reads (not property names); `props.x` is kept as 'props.x'. */
|
|
38
|
+
function identifiersIn(node) {
|
|
39
|
+
const names = new Set()
|
|
40
|
+
const visit = n => {
|
|
41
|
+
if (ts.isPropertyAccessExpression(n) && ts.isIdentifier(n.expression)) names.add(`${n.expression.text}.${n.name.text}`)
|
|
42
|
+
if (ts.isIdentifier(n) && !(ts.isPropertyAccessExpression(n.parent) && n.parent.name === n)) names.add(n.text)
|
|
43
|
+
ts.forEachChild(n, visit)
|
|
44
|
+
}
|
|
45
|
+
visit(node)
|
|
46
|
+
return names
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function componentsOf(sf) {
|
|
50
|
+
const comps = []
|
|
51
|
+
const add = (name, fn) => {
|
|
52
|
+
const param = fn.parameters[0]
|
|
53
|
+
const props = new Map()
|
|
54
|
+
let propsIdent = null
|
|
55
|
+
if (param && ts.isObjectBindingPattern(param.name)) {
|
|
56
|
+
for (const el of param.name.elements) {
|
|
57
|
+
if (el.dotDotDotToken || !ts.isIdentifier(el.name)) continue
|
|
58
|
+
const key = el.propertyName && ts.isIdentifier(el.propertyName) ? el.propertyName.text : el.name.text
|
|
59
|
+
props.set(el.name.text, key)
|
|
60
|
+
}
|
|
61
|
+
} else if (param && ts.isIdentifier(param.name)) propsIdent = param.name.text
|
|
62
|
+
comps.push({ name, fn, props, propsIdent, derives: new Map(), lists: [], edges: [] })
|
|
63
|
+
}
|
|
64
|
+
for (const st of sf.statements) {
|
|
65
|
+
if (ts.isFunctionDeclaration(st) && st.name && isComponentName(st.name.text)) add(st.name.text, st)
|
|
66
|
+
if (ts.isFunctionDeclaration(st) && !st.name && st.modifiers?.some(m => m.kind === ts.SyntaxKind.DefaultKeyword)) add('default', st)
|
|
67
|
+
if (ts.isVariableStatement(st)) {
|
|
68
|
+
for (const d of st.declarationList.declarations) {
|
|
69
|
+
if (ts.isIdentifier(d.name) && isComponentName(d.name.text) && d.initializer && (ts.isArrowFunction(d.initializer) || ts.isFunctionExpression(d.initializer))) add(d.name.text, d.initializer)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
for (const c of comps) {
|
|
74
|
+
const visit = n => {
|
|
75
|
+
if (ts.isVariableDeclaration(n) && ts.isIdentifier(n.name) && n.initializer && ts.isCallExpression(n.initializer)) {
|
|
76
|
+
const callee = n.initializer.expression.getText()
|
|
77
|
+
if (callee === 'derived' || callee === 'state.linked') c.derives.set(n.name.text, identifiersIn(n.initializer))
|
|
78
|
+
}
|
|
79
|
+
if (ts.isCallExpression(n) && ts.isPropertyAccessExpression(n.expression) && n.expression.name.text === 'map' && isInJsx(n, c.fn)) {
|
|
80
|
+
const cb = n.arguments[0]
|
|
81
|
+
const root = chainRoot(n.expression.expression)
|
|
82
|
+
if (root && cb && (ts.isArrowFunction(cb) || ts.isFunctionExpression(cb))) c.lists.push({ root, at: n.getStart() })
|
|
83
|
+
}
|
|
84
|
+
if ((ts.isJsxSelfClosingElement(n) || ts.isJsxOpeningElement(n)) && ts.isIdentifier(n.tagName) && isComponentName(n.tagName.text)) {
|
|
85
|
+
for (const attr of n.attributes.properties) {
|
|
86
|
+
if (!ts.isJsxAttribute(attr) || !attr.initializer || !ts.isJsxExpression(attr.initializer) || !attr.initializer.expression) continue
|
|
87
|
+
const root = chainRoot(attr.initializer.expression)
|
|
88
|
+
if (root) c.edges.push({ tag: n.tagName.text, prop: attr.name.getText(), root, at: attr.getStart() })
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
ts.forEachChild(n, visit)
|
|
92
|
+
}
|
|
93
|
+
visit(c.fn.body ?? c.fn)
|
|
94
|
+
}
|
|
95
|
+
return comps
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @param {Array<{ abs: string, rel: string, text: string, diagnostics: object[] }>} files
|
|
100
|
+
* @param {(file: string) => { span(start: number, end: number): number[] }} pos
|
|
101
|
+
*/
|
|
102
|
+
export function projectView(files, pos) {
|
|
103
|
+
// Only a W014 warning needs the project view, so most runs parse nothing here.
|
|
104
|
+
if (!files.some(f => f.diagnostics.some(d => d.code === 'W014' && d.severity === 'warning'))) return
|
|
105
|
+
const byAbs = new Map(files.map(f => [f.abs, f]))
|
|
106
|
+
for (const f of files) {
|
|
107
|
+
f.sf = ts.createSourceFile(f.abs, f.text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
|
108
|
+
f.comps = componentsOf(f.sf)
|
|
109
|
+
f.imports = new Map()
|
|
110
|
+
for (const st of f.sf.statements) {
|
|
111
|
+
if (!ts.isImportDeclaration(st) || !st.importClause || !ts.isStringLiteral(st.moduleSpecifier)) continue
|
|
112
|
+
const spec = st.moduleSpecifier.text
|
|
113
|
+
const base = local(f.abs, spec)
|
|
114
|
+
if (!base) continue
|
|
115
|
+
const target = exts.map(e => base + e).find(p => byAbs.has(p))
|
|
116
|
+
if (!target) continue
|
|
117
|
+
if (st.importClause.name) f.imports.set(st.importClause.name.text, { file: target, name: 'default' })
|
|
118
|
+
const named = st.importClause.namedBindings
|
|
119
|
+
if (named && ts.isNamedImports(named)) for (const el of named.elements) f.imports.set(el.name.text, { file: target, name: (el.propertyName ?? el.name).text })
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const lookup = (file, tag) => {
|
|
124
|
+
const own = file.comps.find(c => c.name === tag)
|
|
125
|
+
if (own) return { file, comp: own }
|
|
126
|
+
const imp = file.imports.get(tag)
|
|
127
|
+
const target = imp && byAbs.get(imp.file)
|
|
128
|
+
const comp = target?.comps.find(c => c.name === imp.name)
|
|
129
|
+
return comp ? { file: target, comp } : null
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Prop names (of `comp`) that an expression rooted at `root` reads, following `derived` locals. */
|
|
133
|
+
const propsRead = (comp, [name, member], seen = new Set()) => {
|
|
134
|
+
if (seen.has(name)) return new Set()
|
|
135
|
+
seen.add(name)
|
|
136
|
+
if (comp.props.has(name)) return new Set([comp.props.get(name)])
|
|
137
|
+
if (name === comp.propsIdent && member) return new Set([member])
|
|
138
|
+
const out = new Set()
|
|
139
|
+
for (const dep of comp.derives.get(name) ?? []) for (const p of propsRead(comp, dep.split('.'), seen)) out.add(p)
|
|
140
|
+
return out
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Where `comp` (transitively) renders prop `prop` as a JSX `.map`, with the JSX path that gets there. */
|
|
144
|
+
const reachesList = (file, comp, prop, seen = new Set()) => {
|
|
145
|
+
const id = `${file.abs}#${comp.name}#${prop}`
|
|
146
|
+
if (seen.has(id)) return null
|
|
147
|
+
seen.add(id)
|
|
148
|
+
for (const list of comp.lists) if (propsRead(comp, list.root).has(prop)) return { file, at: list.at, via: [] }
|
|
149
|
+
for (const edge of comp.edges) {
|
|
150
|
+
if (!propsRead(comp, edge.root).has(prop)) continue
|
|
151
|
+
const next = lookup(file, edge.tag)
|
|
152
|
+
const hit = next && reachesList(next.file, next.comp, edge.prop, seen)
|
|
153
|
+
if (hit) return { ...hit, via: [{ file, at: edge.at, tag: edge.tag, prop: edge.prop }, ...hit.via] }
|
|
154
|
+
}
|
|
155
|
+
return null
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Locals of `comp` that read `box`, following `derived`. */
|
|
159
|
+
const readsBox = (comp, [name], box, seen = new Set()) => {
|
|
160
|
+
if (name === box) return true
|
|
161
|
+
if (seen.has(name)) return false
|
|
162
|
+
seen.add(name)
|
|
163
|
+
return [...(comp?.derives.get(name) ?? [])].some(dep => readsBox(comp, dep.split('.'), box, seen))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
for (const f of files) {
|
|
167
|
+
for (const d of f.diagnostics) {
|
|
168
|
+
if (d.code !== 'W014' || d.severity !== 'warning' || !d.offset) continue
|
|
169
|
+
const box = boxOf(f.sf, d.offset[0])
|
|
170
|
+
if (!box) continue
|
|
171
|
+
const owners = f.comps.filter(c => c.fn.pos <= d.offset[0] && d.offset[0] < c.fn.end)
|
|
172
|
+
for (const comp of owners.length ? owners : f.comps) {
|
|
173
|
+
let hit = null
|
|
174
|
+
let edge
|
|
175
|
+
for (edge of comp.edges) {
|
|
176
|
+
if (!readsBox(comp, edge.root, box)) continue
|
|
177
|
+
const next = lookup(f, edge.tag)
|
|
178
|
+
hit = next && reachesList(next.file, next.comp, edge.prop)
|
|
179
|
+
if (hit) break
|
|
180
|
+
}
|
|
181
|
+
if (!hit) continue
|
|
182
|
+
const listPos = pos(hit.file).span(hit.at, hit.at)
|
|
183
|
+
const path = [{ file: f, at: edge.at, tag: edge.tag, prop: edge.prop }, ...hit.via]
|
|
184
|
+
d.severity = 'error'
|
|
185
|
+
d.project = true
|
|
186
|
+
d.why =
|
|
187
|
+
`\`.map(p => ({ ...p, … }))\` gives every item a new identity on each update, and \`${box}\` reaches a keyed list in ` +
|
|
188
|
+
`${hit.file.rel}:${listPos[0]} through ${path.map(p => `<${p.tag} ${p.prop}>`).join(' → ')}: every row re-runs on every change.`
|
|
189
|
+
d.related = [
|
|
190
|
+
...path.map(p => ({ file: p.file.rel, span: pos(p.file).span(p.at, p.at), why: `passed as \`<${p.tag} ${p.prop}>\`` })),
|
|
191
|
+
{ file: hit.file.rel, span: listPos, why: 'rendered here as a keyed list' },
|
|
192
|
+
]
|
|
193
|
+
break
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The box a flagged `.map` call writes: `box.value = …map(…)` or `const box = …map(…)`. */
|
|
200
|
+
function boxOf(sf, offset) {
|
|
201
|
+
let found
|
|
202
|
+
const visit = n => {
|
|
203
|
+
if (found) return
|
|
204
|
+
if (ts.isCallExpression(n) && n.getStart() === offset) found = n
|
|
205
|
+
else if (n.pos <= offset && offset < n.end) ts.forEachChild(n, visit)
|
|
206
|
+
}
|
|
207
|
+
visit(sf)
|
|
208
|
+
for (let n = found; n; n = n.parent) {
|
|
209
|
+
if (ts.isBinaryExpression(n) && n.operatorToken.kind === ts.SyntaxKind.EqualsToken) {
|
|
210
|
+
const left = n.left
|
|
211
|
+
if (ts.isPropertyAccessExpression(left) && left.name.text === 'value' && ts.isIdentifier(left.expression)) return left.expression.text
|
|
212
|
+
return null
|
|
213
|
+
}
|
|
214
|
+
if (ts.isVariableDeclaration(n)) return ts.isIdentifier(n.name) ? n.name.text : null
|
|
215
|
+
}
|
|
216
|
+
return null
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export const relPath = (root, abs) => relative(root, abs).split('\\').join('/')
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Prop inference in `weft check` and `weft tsc` (research 44). `W080`–`W085` come from the engine; a `TS` code is the
|
|
2
|
+
// compiler's own error, reported at the source position with the inferred types applied.
|
|
3
|
+
|
|
4
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
5
|
+
import { createRequire } from 'node:module'
|
|
6
|
+
import { join, relative } from 'node:path'
|
|
7
|
+
import ts from 'typescript'
|
|
8
|
+
import { diagnostic, positions } from './diag.mjs'
|
|
9
|
+
|
|
10
|
+
const require = createRequire(import.meta.url)
|
|
11
|
+
const { Session } = require('./infer/session.cjs')
|
|
12
|
+
|
|
13
|
+
const posix = p => p.split('\\').join('/')
|
|
14
|
+
|
|
15
|
+
export function toDiagnostics(root, list, file) {
|
|
16
|
+
const rel = posix(relative(root, file))
|
|
17
|
+
const text = readFileSync(file, 'utf8')
|
|
18
|
+
const pos = positions(text)
|
|
19
|
+
return list.map(f => diagnostic({
|
|
20
|
+
code: f.code, severity: f.severity, file: rel, span: pos.span(f.start, f.end), offset: [f.start, f.end], why: f.why, help: f.help,
|
|
21
|
+
fix: f.edits && { description: f.help, safe: f.safe, edits: f.edits.map(e => ({ file: rel, span: pos.span(e.start, e.end), offset: [e.start, e.end], text: e.text })) },
|
|
22
|
+
}))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The checks `weft check` adds. A project with nothing to infer parses its `.tsx` files once and starts nothing. */
|
|
26
|
+
export async function propChecks(root, config) {
|
|
27
|
+
if (config.check?.props === false || !existsSync(join(root, 'tsconfig.json'))) return []
|
|
28
|
+
const session = new Session(ts, root)
|
|
29
|
+
try {
|
|
30
|
+
const { findings, errors } = await session.analyze()
|
|
31
|
+
return [...findings, ...errors].flatMap(([file, list]) => toDiagnostics(root, list, file))
|
|
32
|
+
} finally {
|
|
33
|
+
session.close()
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `weft tsc`: the whole project, as `tsc --noEmit` sees it with inference applied. */
|
|
38
|
+
export async function propTsc(root) {
|
|
39
|
+
const session = new Session(ts, root)
|
|
40
|
+
try {
|
|
41
|
+
const { findings, errors } = await session.all()
|
|
42
|
+
return [...findings, ...errors].flatMap(([file, list]) => toDiagnostics(root, list, file))
|
|
43
|
+
} finally {
|
|
44
|
+
session.close()
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Prop inference while `weft dev` runs. A listener of its own, hooked at file-change: it posts the changed name to a
|
|
2
|
+
// worker and goes back to serving, so nothing here is on the HMR or request path (research 44 §7).
|
|
3
|
+
|
|
4
|
+
import { Worker } from 'node:worker_threads'
|
|
5
|
+
|
|
6
|
+
const SOURCE = /\.tsx?$/
|
|
7
|
+
const SKIP = /[\\/](node_modules|\.weft|\.output|dist)[\\/]/
|
|
8
|
+
|
|
9
|
+
/** @param {{ watcher: any, ws: any, config: any, close: () => Promise<void> }} server */
|
|
10
|
+
export function attachProps(server, root, config) {
|
|
11
|
+
const setting = config?.check?.props
|
|
12
|
+
if (setting === false || process.env.WEFT_PROPS === 'off') return null
|
|
13
|
+
const worker = new Worker(new URL('./infer/worker.cjs', import.meta.url), { workerData: { root, write: setting !== 'manual' }, stdout: false })
|
|
14
|
+
worker.unref()
|
|
15
|
+
const stats = { saves: 0, ms: [] }
|
|
16
|
+
const post = file => { if (SOURCE.test(file) && !SKIP.test(file)) worker.postMessage(file) }
|
|
17
|
+
for (const event of ['change', 'add', 'unlink']) server.watcher.on(event, post)
|
|
18
|
+
|
|
19
|
+
const logger = server.config?.logger ?? console
|
|
20
|
+
worker.on('message', msg => {
|
|
21
|
+
if (msg.type === 'failed') return logger.warn?.(`weft props: ${msg.message}`)
|
|
22
|
+
if (msg.type === 'wrote') return logger.info?.(`weft props: wrote the inferred type in ${msg.file}`)
|
|
23
|
+
if (msg.type !== 'done') return
|
|
24
|
+
stats.saves++
|
|
25
|
+
stats.ms.push(msg.ms)
|
|
26
|
+
if (!msg.changed) return
|
|
27
|
+
const errors = msg.diagnostics.filter(d => d.severity === 'error')
|
|
28
|
+
for (const d of msg.diagnostics) logger.warn?.(`${d.file}:${d.line} ${d.code} ${d.severity} ${d.why}\n fix: ${d.help}`)
|
|
29
|
+
server.ws.send({ type: 'custom', event: 'weft:props', data: msg.diagnostics })
|
|
30
|
+
// A save's HMR update has cleared any earlier overlay already, so this only ever shows the current errors.
|
|
31
|
+
if (errors.length) server.ws.send({ type: 'error', err: { message: errors.map(d => `${d.file}:${d.line} ${d.code}: ${d.why}`).join('\n'), stack: '', plugin: 'weft:props' } })
|
|
32
|
+
})
|
|
33
|
+
worker.on('error', e => logger.warn?.(`weft props stopped: ${e.message}`))
|
|
34
|
+
|
|
35
|
+
const close = server.close.bind(server)
|
|
36
|
+
server.close = async () => { worker.postMessage('close'); return close() }
|
|
37
|
+
return { worker, stats }
|
|
38
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// W043 and W044 in `weft check`. W043: a React habit that compiles and renders something else. `data-x={true}` is a literal, so it can
|
|
2
|
+
// be told from weft's own `data-open={open}`, whose presence form `[data-open]` selectors depend on (docs/react-props.md).
|
|
3
|
+
|
|
4
|
+
import { diagnostic, positions } from './diag.mjs'
|
|
5
|
+
import { scanJsx } from './jsx-scan.mjs'
|
|
6
|
+
|
|
7
|
+
const NATIVE = /^[a-z][\w-]*$/
|
|
8
|
+
const LITERAL = /\bdata-[\w-]+\s*=\s*\{\s*(?:true|false)\s*\}/
|
|
9
|
+
const RANDOM = /\b(?:crypto\.randomUUID|Date\.now|Math\.random|performance\.now)\s*\(/
|
|
10
|
+
|
|
11
|
+
/** @param {{ files: { rel: string, text: string }[] }} options the checked .tsx/.jsx files */
|
|
12
|
+
export function reactChecks({ files }) {
|
|
13
|
+
const out = []
|
|
14
|
+
for (const { rel, text } of files) {
|
|
15
|
+
if (!LITERAL.test(text) && !RANDOM.test(text)) continue
|
|
16
|
+
const pos = positions(text)
|
|
17
|
+
for (const el of scanJsx(text, rel).elements) {
|
|
18
|
+
for (const a of el.attrs) {
|
|
19
|
+
if (a.name === 'key' && a.kind === 'expr' && RANDOM.test(a.expr)) out.push(diagnostic({ code: 'W044', file: rel, span: pos.span(a.start, a.end), offset: [a.start, a.end], why: `\`key={${a.expr}}\` differs between the server and the client, so this row can't hydrate and is built again, and it differs on every render.` }))
|
|
20
|
+
}
|
|
21
|
+
if (!NATIVE.test(el.name)) continue
|
|
22
|
+
for (const a of el.attrs) {
|
|
23
|
+
if (!a.name.startsWith('data-') || a.kind !== 'expr' || (a.expr !== 'true' && a.expr !== 'false')) continue
|
|
24
|
+
const on = a.expr === 'true'
|
|
25
|
+
// Removing the attribute takes the space before it too.
|
|
26
|
+
const [from, to] = on ? [a.start, a.end] : [text.slice(0, a.start).trimEnd().length, a.end]
|
|
27
|
+
const why = on
|
|
28
|
+
? `\`${a.name}={true}\` writes \`${a.name}=""\`, not React's \`"true"\`, so \`[${a.name}="true"]\` won't match. Write \`${a.name}\` for a presence flag, or \`${a.name}="true"\` for the word.`
|
|
29
|
+
: `\`${a.name}={false}\` writes no attribute, not React's \`"false"\`. Drop it, or write \`${a.name}="false"\` for the word.`
|
|
30
|
+
const fix = { description: on ? `Write \`${a.name}\`, which renders the same` : `Remove \`${a.name}\`, which renders the same`, safe: true, edits: [{ file: rel, span: pos.span(from, to), offset: [from, to], text: on ? a.name : '' }] }
|
|
31
|
+
out.push(diagnostic({ code: 'W043', file: rel, span: pos.span(a.start, a.end), offset: [a.start, a.end], why, fix }))
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return out
|
|
36
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// U001–U005 in `weft check` (research 45 §10): `interaction({ share })` and `ui`. TypeScript's parser only, no type
|
|
2
|
+
// checking: what's read is the call's own text, and a name counts as an interaction row only if this file made it.
|
|
3
|
+
|
|
4
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import ts from 'typescript'
|
|
7
|
+
import { diagnostic, positions } from './diag.mjs'
|
|
8
|
+
|
|
9
|
+
const UNSTABLE = /\b(?:Math\.random|Date\.now|crypto\.randomUUID|performance\.now)\b/
|
|
10
|
+
const USER_TYPED = /\b(?:location\.|URLSearchParams|useSearchParams|searchParams|params\.get)\b/
|
|
11
|
+
/** warp components that take `ui`. Anything else would write `ui="[object Object]"` on its element. */
|
|
12
|
+
const TAKES_UI = new Set(['Button', 'Checkbox', 'Switch', 'Radio', 'Toggle', 'Chip', 'Card'])
|
|
13
|
+
|
|
14
|
+
const rowsOf = sf => {
|
|
15
|
+
const rows = new Set()
|
|
16
|
+
const visit = n => {
|
|
17
|
+
if (ts.isVariableDeclaration(n) && ts.isIdentifier(n.name) && n.initializer && ts.isCallExpression(n.initializer) && n.initializer.expression.getText?.(sf) === 'interaction') rows.add(n.name.text)
|
|
18
|
+
ts.forEachChild(n, visit)
|
|
19
|
+
}
|
|
20
|
+
visit(sf)
|
|
21
|
+
return rows
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const warpNames = sf => {
|
|
25
|
+
const names = new Map()
|
|
26
|
+
for (const n of sf.statements) {
|
|
27
|
+
if (!ts.isImportDeclaration(n) || !n.moduleSpecifier.text.startsWith('@weft-js/warp') || !n.importClause?.namedBindings || !ts.isNamedImports(n.importClause.namedBindings)) continue
|
|
28
|
+
for (const s of n.importClause.namedBindings.elements) names.set(s.name.text, (s.propertyName ?? s.name).text)
|
|
29
|
+
}
|
|
30
|
+
return names
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function shareChecks(root, { files }) {
|
|
34
|
+
const out = []
|
|
35
|
+
let sharesIn
|
|
36
|
+
for (const { rel, text } of files) {
|
|
37
|
+
const usesRow = text.includes('@weft-js/core/interaction')
|
|
38
|
+
if (!usesRow && !/\bui=\{/.test(text)) continue
|
|
39
|
+
const sf = ts.createSourceFile(rel, text, ts.ScriptTarget.Latest, true, /\.[jt]sx$/.test(rel) ? ts.ScriptKind.TSX : ts.ScriptKind.TS)
|
|
40
|
+
const pos = positions(text)
|
|
41
|
+
const report = (code, n, why) => out.push(diagnostic({ code, file: rel, span: pos.span(n.getStart(sf), n.end), offset: [n.getStart(sf), n.end], why }))
|
|
42
|
+
const rows = rowsOf(sf), warp = warpNames(sf)
|
|
43
|
+
const visit = n => {
|
|
44
|
+
if (ts.isCallExpression(n) && ts.isIdentifier(n.expression) && n.expression.text === 'interaction' && usesRow) {
|
|
45
|
+
const share = n.arguments[0] && ts.isObjectLiteralExpression(n.arguments[0]) ? n.arguments[0].properties.find(p => p.name?.getText(sf) === 'share') : undefined
|
|
46
|
+
if (share) {
|
|
47
|
+
sharesIn ??= rel
|
|
48
|
+
const value = share.getText(sf)
|
|
49
|
+
if (UNSTABLE.test(value)) report('U001', share, 'A `share` key that changes on every load never meets another tab or client.')
|
|
50
|
+
else if (USER_TYPED.test(value)) report('U002', share, 'This `share` key comes from what the user typed or the URL, so `realtime({ share })` is authorising a string they chose.')
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (ts.isBinaryExpression(n) && n.operatorToken.kind === ts.SyntaxKind.EqualsToken) {
|
|
54
|
+
const l = n.left
|
|
55
|
+
const target = ts.isPropertyAccessExpression(l) && l.name.text === 'value' ? l.expression : l
|
|
56
|
+
if (ts.isPropertyAccessExpression(target) && target.name.text === 'remote' && ts.isIdentifier(target.expression) && rows.has(target.expression.text))
|
|
57
|
+
report('U004', n, '`remote` is what others are doing: only the network writes it.')
|
|
58
|
+
}
|
|
59
|
+
if (ts.isJsxSelfClosingElement(n) || ts.isJsxOpeningElement(n)) {
|
|
60
|
+
const tag = n.tagName.getText(sf), ui = n.attributes.properties.find(a => ts.isJsxAttribute(a) && a.name.getText(sf) === 'ui')
|
|
61
|
+
if (ui && warp.has(tag) && !TAKES_UI.has(warp.get(tag))) report('U005', ui, `\`${warp.get(tag)}\` doesn't take \`ui\` yet, so this writes an attribute and draws nothing.`)
|
|
62
|
+
}
|
|
63
|
+
ts.forEachChild(n, visit)
|
|
64
|
+
}
|
|
65
|
+
visit(sf)
|
|
66
|
+
}
|
|
67
|
+
if (sharesIn && existsSync(join(root, 'src/routes'))) {
|
|
68
|
+
const file = ['src/realtime.server.ts', 'src/realtime.server.js'].map(f => join(root, f)).find(existsSync)
|
|
69
|
+
if (!file || !/\bshare\s*:/.test(readFileSync(file, 'utf8'))) {
|
|
70
|
+
out.push(diagnostic({ code: 'U003', file: sharesIn, span: { start: { line: 1, column: 1 }, end: { line: 1, column: 1 } }, offset: [0, 0], why: 'This app shares UI state and no `realtime({ share })` says who may: tabs work, and the socket refuses every key.' }))
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out
|
|
74
|
+
}
|
package/src/site.mjs
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Config-level page-metadata checks (research 20 §6). Without routing they see only `site`: the defaults every page
|
|
2
|
+
// starts from. Route metas (Phase 3) will run the same checks on each route's merged result.
|
|
3
|
+
|
|
4
|
+
const absolute = /^(https?:)?\/\//
|
|
5
|
+
|
|
6
|
+
/** @param {import('./config').SiteConfig} site @param {(code: string, why: string, near: string | null) => void} report */
|
|
7
|
+
export function checkSite(site, report) {
|
|
8
|
+
const image = typeof site.image === 'object' ? site.image.url : site.image
|
|
9
|
+
if (image && !site.url && !absolute.test(image)) {
|
|
10
|
+
const what = image === 'auto' ? 'The generated card (`image: \'auto\'`) is served from a relative URL' : `\`site.image\` (\`${image}\`) is relative`
|
|
11
|
+
report('H001', `${what} and \`site.url\` is not set, so og:image (and canonical, og:url) can't be absolute. Crawlers don't resolve relative URLs.`, 'image')
|
|
12
|
+
}
|
|
13
|
+
if (!site.description?.trim()) report('H003', 'Pages have no description: `site.description` is not set and no route overrides it yet.', 'site')
|
|
14
|
+
// The default page title is `name`; a template's fixed part is in every other title.
|
|
15
|
+
const fixed = site.title ? site.title.replace('%s', '').length : 0
|
|
16
|
+
if (site.name && site.name.length > 60) report('H004', `The default title \`${site.name}\` is ${site.name.length} characters (over 60).`, 'name')
|
|
17
|
+
if (fixed > 40) report('H004', `The title template's fixed part is ${fixed} characters, leaving under 20 of 60 for the page title.`, 'title')
|
|
18
|
+
if (site.description && site.description.length > 160) report('H004', `\`site.description\` is ${site.description.length} characters (over 160).`, 'description')
|
|
19
|
+
}
|
package/src/slots.mjs
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Slot table (research 13 §2.2) and the binding rules, resolved from config data alone. Phase 1 executes no plugin.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Every slot a plugin can fill: how many providers it takes, where it runs, and what's used when none does.
|
|
5
|
+
* @type {{ id: string, version: number, cardinality: string, sides: string[], default: string | string[] | null, phase: string }[]}
|
|
6
|
+
*/
|
|
7
|
+
export const slotTable = [
|
|
8
|
+
{ id: 'font.build', version: 1, cardinality: 'one', sides: ['build'], default: null, phase: '2' },
|
|
9
|
+
{ id: 'media.build', version: 1, cardinality: 'one', sides: ['build'], default: null, phase: '2' },
|
|
10
|
+
{ id: 'media.encode', version: 1, cardinality: 'keyed', sides: ['build', 'server'], default: null, phase: '3c' },
|
|
11
|
+
{ id: 'media.server', version: 1, cardinality: 'one', sides: ['server'], default: null, phase: '3' },
|
|
12
|
+
{ id: 'text.measure', version: 1, cardinality: 'one', sides: ['client', 'worker'], default: null, phase: '2 (Surfaces)' },
|
|
13
|
+
{ id: 'surface.renderer', version: 1, cardinality: 'keyed', sides: ['client'], default: ['canvas2d', 'webgl2'], phase: '2 (Surfaces)' },
|
|
14
|
+
{ id: 'realtime.bus', version: 1, cardinality: 'one', sides: ['server'], default: 'memory', phase: '3' },
|
|
15
|
+
{ id: 'db.adapter', version: 1, cardinality: 'keyed', sides: ['server'], default: ['drizzle'], phase: '3c' },
|
|
16
|
+
{ id: 'server.extension', version: 1, cardinality: 'keyed', sides: ['server'], default: null, phase: '6c' },
|
|
17
|
+
{ id: 'pay.rail', version: 1, cardinality: 'keyed', sides: ['server', 'client', 'native'], default: null, phase: '6c' },
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
export function slotDef(id) {
|
|
21
|
+
const def = slotTable.find(s => s.id === id)
|
|
22
|
+
if (def) return def
|
|
23
|
+
if (/^device\.[a-z][\w-]*$/.test(id)) return { id, version: 1, cardinality: 'keyed', sides: ['native', 'client'], default: null, phase: '6b' }
|
|
24
|
+
return undefined
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {{ plugins?: Array<{ name: string, provides?: Array<string | { slot: string, key: string }> }>, slots?: Record<string, unknown> }} config
|
|
29
|
+
* @param {(code: string, why: string, near: string) => void} report
|
|
30
|
+
* @returns {Record<string, unknown>} the `slots` block of `weft check --json`
|
|
31
|
+
*/
|
|
32
|
+
export function bindSlots(config, report) {
|
|
33
|
+
const plugins = config.plugins ?? []
|
|
34
|
+
const chosen = config.slots ?? {}
|
|
35
|
+
const names = new Set(plugins.map(p => p.name))
|
|
36
|
+
|
|
37
|
+
/** slot id → key ('' for `one`) → provider names */
|
|
38
|
+
const offers = new Map()
|
|
39
|
+
for (const p of [...plugins].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
40
|
+
for (const item of p.provides ?? []) {
|
|
41
|
+
const { slot, key = '' } = typeof item === 'string' ? { slot: item } : item
|
|
42
|
+
if (!slotDef(slot) && !slot.startsWith(`${p.name}.`)) {
|
|
43
|
+
report('P008', `\`${p.name}\` provides \`${slot}\`, which is not a slot weft defines.`, p.name)
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
const byKey = offers.get(slot) ?? new Map()
|
|
47
|
+
byKey.set(key, [...(byKey.get(key) ?? []), p.name])
|
|
48
|
+
offers.set(slot, byKey)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (const [id, pick] of Object.entries(chosen)) {
|
|
53
|
+
if (!slotDef(id) && !offers.has(id)) {
|
|
54
|
+
report('P008', `\`slots\` names \`${id}\`, which is not a slot weft defines.`, id)
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
const picks = typeof pick === 'string' ? { '': pick } : pick
|
|
58
|
+
for (const [key, name] of Object.entries(picks)) {
|
|
59
|
+
if (name.startsWith('weft:default')) continue
|
|
60
|
+
if (!names.has(name)) report('P009', `\`slots['${id}']\` picks \`${name}\`, which is not in \`plugins\`.`, id)
|
|
61
|
+
else if (!(offers.get(id)?.get(key) ?? []).includes(name)) report('P009', `\`slots['${id}']\` picks \`${name}\`, which does not provide it.`, id)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** @type {Set<string>} */
|
|
66
|
+
const known = new Set([...slotTable.map(s => s.id), ...offers.keys()])
|
|
67
|
+
const ids = [...known].sort()
|
|
68
|
+
const out = {}
|
|
69
|
+
for (const id of ids) {
|
|
70
|
+
const def = slotDef(id) ?? { cardinality: 'one', default: null }
|
|
71
|
+
const byKey = offers.get(id) ?? new Map()
|
|
72
|
+
const pick = chosen[id]
|
|
73
|
+
const resolve = (key, fallback) => {
|
|
74
|
+
const providers = byKey.get(key) ?? []
|
|
75
|
+
const named = typeof pick === 'string' ? (key === '' ? pick : undefined) : pick?.[key]
|
|
76
|
+
if (named && (named.startsWith('weft:default') || providers.includes(named))) return named
|
|
77
|
+
if (providers.length === 1) return providers[0]
|
|
78
|
+
if (providers.length > 1) {
|
|
79
|
+
const where = key ? `\`${id}\` key \`${key}\`` : `\`${id}\``
|
|
80
|
+
report('P100', `${where} has ${providers.length} providers: ${providers.join(', ')}. Array order never decides.`, providers[0])
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
83
|
+
return fallback
|
|
84
|
+
}
|
|
85
|
+
if (def.cardinality === 'one') {
|
|
86
|
+
out[id] = resolve('', typeof def.default === 'string' ? `weft:default:${def.default}` : null)
|
|
87
|
+
} else {
|
|
88
|
+
/** @type {Set<string>} */
|
|
89
|
+
const keys = new Set([...(Array.isArray(def.default) ? def.default : []), ...[...byKey.keys()].filter(Boolean)])
|
|
90
|
+
const block = {}
|
|
91
|
+
for (const key of [...keys].sort()) block[key] = resolve(key, def.default?.includes(key) ? 'weft:default' : null)
|
|
92
|
+
out[id] = block
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return out
|
|
96
|
+
}
|