@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.
Files changed (91) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +74 -2
  3. package/bin/weft.mjs +8 -0
  4. package/docs/async.md +40 -0
  5. package/docs/browser.md +39 -0
  6. package/docs/config.md +93 -0
  7. package/docs/deploy.md +83 -0
  8. package/docs/design.md +97 -0
  9. package/docs/fonts.md +70 -0
  10. package/docs/icons.md +102 -0
  11. package/docs/lists.md +23 -0
  12. package/docs/meta.md +82 -0
  13. package/docs/plugins.md +143 -0
  14. package/docs/props.md +77 -0
  15. package/docs/rules.md +32 -0
  16. package/docs/rust.md +223 -0
  17. package/docs/start-short.md +8 -0
  18. package/docs/start.md +422 -0
  19. package/docs/state.md +96 -0
  20. package/docs/style.md +59 -0
  21. package/docs/surface.md +111 -0
  22. package/docs/testing.md +71 -0
  23. package/docs/ui-state.md +92 -0
  24. package/docs/verify.md +48 -0
  25. package/package.json +39 -4
  26. package/src/add-rust.mjs +123 -0
  27. package/src/add.mjs +358 -0
  28. package/src/agents-md.mjs +134 -0
  29. package/src/alias.mjs +59 -0
  30. package/src/audit.mjs +366 -0
  31. package/src/browser/cli.mjs +34 -0
  32. package/src/browser/client.mjs +132 -0
  33. package/src/browser/commands.mjs +143 -0
  34. package/src/catalog.mjs +496 -0
  35. package/src/check.mjs +330 -0
  36. package/src/cli.mjs +364 -0
  37. package/src/config.d.ts +1 -0
  38. package/src/config.mjs +5 -0
  39. package/src/contributions.mjs +69 -0
  40. package/src/db-layer.mjs +33 -0
  41. package/src/deploy/cdn.mjs +64 -0
  42. package/src/deploy/files.mjs +187 -0
  43. package/src/deploy/fit.mjs +62 -0
  44. package/src/deploy/hosts.mjs +56 -0
  45. package/src/deploy/index.mjs +273 -0
  46. package/src/deploy/needs.mjs +92 -0
  47. package/src/design-checks.mjs +439 -0
  48. package/src/diag.mjs +74 -0
  49. package/src/docs.mjs +132 -0
  50. package/src/doctor.mjs +30 -0
  51. package/src/files.mjs +47 -0
  52. package/src/fix.mjs +40 -0
  53. package/src/gate.mjs +276 -0
  54. package/src/index.mjs +9 -0
  55. package/src/infer/engine.cjs +525 -0
  56. package/src/infer/facts.cjs +252 -0
  57. package/src/infer/hosts.cjs +114 -0
  58. package/src/infer/lsp.mjs +131 -0
  59. package/src/infer/session.cjs +105 -0
  60. package/src/infer/shadow.cjs +46 -0
  61. package/src/infer/worker.cjs +97 -0
  62. package/src/jsx-scan.mjs +86 -0
  63. package/src/load-config.mjs +303 -0
  64. package/src/map.mjs +110 -0
  65. package/src/mcp.mjs +195 -0
  66. package/src/meta-table.mjs +28 -0
  67. package/src/mutate.mjs +140 -0
  68. package/src/plugin-checks.mjs +89 -0
  69. package/src/plugins.mjs +172 -0
  70. package/src/project.mjs +219 -0
  71. package/src/props-checks.mjs +46 -0
  72. package/src/props-listener.mjs +38 -0
  73. package/src/react-checks.mjs +36 -0
  74. package/src/share-checks.mjs +74 -0
  75. package/src/site.mjs +19 -0
  76. package/src/slots.mjs +96 -0
  77. package/src/start.mjs +296 -0
  78. package/src/state-checks.mjs +213 -0
  79. package/src/style.mjs +320 -0
  80. package/src/surface-checks.mjs +218 -0
  81. package/src/tailwind.mjs +22 -0
  82. package/src/tasks.mjs +99 -0
  83. package/src/testdiff.mjs +157 -0
  84. package/src/tests.mjs +572 -0
  85. package/src/text-checks.mjs +172 -0
  86. package/src/tokens.mjs +24 -0
  87. package/src/trust.mjs +282 -0
  88. package/src/weft.mjs +66 -0
  89. package/src/workspace.mjs +105 -0
  90. package/ts-plugin/index.js +137 -0
  91. package/ts-plugin/package.json +1 -0
@@ -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
+ }