@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
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// The dev listener's thread (research 44 §7). It takes file names, waits out a burst of saves, and works out what they
|
|
2
|
+
// changed. HMR never waits on it: the main thread only posts a name here, and TypeScript runs in its own process.
|
|
3
|
+
|
|
4
|
+
const { createHash } = require('node:crypto')
|
|
5
|
+
const { mkdirSync, readFileSync, renameSync, writeFileSync } = require('node:fs')
|
|
6
|
+
const { join, relative } = require('node:path')
|
|
7
|
+
const { parentPort, workerData } = require('node:worker_threads')
|
|
8
|
+
const ts = require('typescript')
|
|
9
|
+
const { Session } = require('./session.cjs')
|
|
10
|
+
|
|
11
|
+
const { root, write, debounce = 25, settle = 400, rest = 120_000 } = workerData
|
|
12
|
+
const session = new Session(ts, root)
|
|
13
|
+
const pending = new Set()
|
|
14
|
+
let timer = null, running = false, last = '', writes = null, resting = null
|
|
15
|
+
|
|
16
|
+
function publish(list) {
|
|
17
|
+
const text = JSON.stringify(list)
|
|
18
|
+
if (text === last) return false
|
|
19
|
+
last = text
|
|
20
|
+
mkdirSync(join(root, '.weft'), { recursive: true })
|
|
21
|
+
const file = join(root, '.weft', 'props.json')
|
|
22
|
+
writeFileSync(`${file}.tmp`, `${JSON.stringify({ diagnostics: list }, null, 2)}\n`)
|
|
23
|
+
renameSync(`${file}.tmp`, file)
|
|
24
|
+
return true
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const applyEdits = (text, edits) => [...edits].sort((a, b) => b.start - a.start).reduce((t, e) => t.slice(0, e.start) + e.text + t.slice(e.end), text)
|
|
28
|
+
|
|
29
|
+
/** Exported types go into the source once the file has been quiet, and only if it's still the text they were made from. */
|
|
30
|
+
function scheduleWrites(findings) {
|
|
31
|
+
clearTimeout(writes)
|
|
32
|
+
if (!write) return
|
|
33
|
+
const jobs = [...findings].flatMap(([file, list]) => { const edits = list.filter(f => f.safe && f.edits).flatMap(f => f.edits); return edits.length ? [{ file, edits, hash: session.engine.facts.get(file)?.hash, codes: list.map(f => f.code) }] : [] })
|
|
34
|
+
if (!jobs.length) return
|
|
35
|
+
writes = setTimeout(() => {
|
|
36
|
+
for (const job of jobs) {
|
|
37
|
+
const text = readFileSync(job.file, 'utf8')
|
|
38
|
+
if (createHash('sha1').update(text).digest('base64url').slice(0, 16) !== job.hash) continue
|
|
39
|
+
writeFileSync(job.file, applyEdits(text, job.edits))
|
|
40
|
+
parentPort.postMessage({ type: 'wrote', file: relative(root, job.file) })
|
|
41
|
+
}
|
|
42
|
+
}, settle)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The checker holds a few hundred MB, so it goes after two quiet minutes. The next save starts it again. */
|
|
46
|
+
function rested() {
|
|
47
|
+
clearTimeout(resting)
|
|
48
|
+
resting = setTimeout(() => session.close(), rest)
|
|
49
|
+
resting.unref()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function first() {
|
|
53
|
+
running = true
|
|
54
|
+
try { await report(performance.now()) } catch (e) { parentPort.postMessage({ type: 'failed', message: e.stack }) } finally { running = false }
|
|
55
|
+
await drain()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function report(t, files) {
|
|
59
|
+
const { findings, errors } = await session.analyze(files)
|
|
60
|
+
const flat = []
|
|
61
|
+
for (const [file, list] of [...findings, ...errors]) for (const f of list) flat.push({ file: relative(root, file), line: session.engine.texts.get(file).slice(0, f.start).split('\n').length, code: f.code, severity: f.severity ?? 'error', why: f.why, help: f.help })
|
|
62
|
+
scheduleWrites(findings)
|
|
63
|
+
const changed = publish(flat)
|
|
64
|
+
rested()
|
|
65
|
+
parentPort.postMessage({ type: 'done', ms: performance.now() - t, at: Date.now(), changed, diagnostics: flat })
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function drain() {
|
|
69
|
+
if (running) return
|
|
70
|
+
running = true
|
|
71
|
+
try {
|
|
72
|
+
while (pending.size) {
|
|
73
|
+
const files = [...pending]
|
|
74
|
+
pending.clear()
|
|
75
|
+
const t = performance.now()
|
|
76
|
+
let changed = false
|
|
77
|
+
for (const f of files) if (session.engine.set(f)) changed = true
|
|
78
|
+
if (session.host) session.touch(files)
|
|
79
|
+
if (!changed || !files.some(f => session.engine.relevant(f))) { parentPort.postMessage({ type: 'idle', ms: performance.now() - t }); continue }
|
|
80
|
+
await report(t, files)
|
|
81
|
+
}
|
|
82
|
+
} catch (e) {
|
|
83
|
+
parentPort.postMessage({ type: 'failed', message: e.stack })
|
|
84
|
+
} finally {
|
|
85
|
+
running = false
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
parentPort.on('message', msg => {
|
|
90
|
+
if (msg === 'close') { clearTimeout(timer); clearTimeout(writes); session.close(); process.exit(0) }
|
|
91
|
+
pending.add(msg)
|
|
92
|
+
clearTimeout(timer)
|
|
93
|
+
timer = setTimeout(() => void drain(), debounce)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
parentPort.postMessage({ type: 'ready', needed: session.engine.needed() })
|
|
97
|
+
if (session.engine.needed()) void first()
|
package/src/jsx-scan.mjs
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// A small JSX view of one .tsx/.jsx file (research 26 §2.5, §7), shared by the X-code pass and plugin `check` hooks:
|
|
2
|
+
// imports, every element with its attributes and ancestors, and the file's function bodies by name. TypeScript's
|
|
3
|
+
// parser only, no type checking.
|
|
4
|
+
|
|
5
|
+
import ts from 'typescript'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {{ name: string, kind: 'string' | 'expr' | 'bool' | 'spread', value?: string, expr?: string, start: number, end: number, valueStart?: number, valueEnd?: number }} Attr
|
|
9
|
+
* @typedef {{ name: string, attrs: Attr[], attr: (n: string) => Attr | undefined, ancestors: string[], start: number, end: number, tagEnd: number, children: string }} Element
|
|
10
|
+
* @typedef {{ local: string, imported?: string, source: string, start: number, end: number }} Import
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {string} text
|
|
15
|
+
* @param {string} [file]
|
|
16
|
+
* @returns {{ imports: Import[], elements: Element[], functions: Map<string, string> }}
|
|
17
|
+
*/
|
|
18
|
+
export function scanJsx(text, file = 'file.tsx') {
|
|
19
|
+
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, file.endsWith('.jsx') ? ts.ScriptKind.JSX : ts.ScriptKind.TSX)
|
|
20
|
+
const imports = []
|
|
21
|
+
const elements = []
|
|
22
|
+
const functions = new Map()
|
|
23
|
+
const stack = []
|
|
24
|
+
|
|
25
|
+
const attrsOf = attributes =>
|
|
26
|
+
attributes.properties.map(p => {
|
|
27
|
+
if (ts.isJsxSpreadAttribute(p)) return { name: '...', kind: 'spread', expr: p.expression.getText(sf), start: p.getStart(sf), end: p.end }
|
|
28
|
+
const name = p.name.getText(sf)
|
|
29
|
+
const base = { name, start: p.getStart(sf), end: p.end }
|
|
30
|
+
if (!p.initializer) return { ...base, kind: 'bool' }
|
|
31
|
+
if (ts.isStringLiteral(p.initializer)) return { ...base, kind: 'string', value: p.initializer.text, valueStart: p.initializer.getStart(sf), valueEnd: p.initializer.end }
|
|
32
|
+
const e = p.initializer.expression
|
|
33
|
+
if (e && (ts.isStringLiteral(e) || ts.isNoSubstitutionTemplateLiteral(e))) return { ...base, kind: 'string', value: e.text, valueStart: e.getStart(sf), valueEnd: e.end }
|
|
34
|
+
return { ...base, kind: 'expr', expr: e ? e.getText(sf) : '', valueStart: e?.getStart(sf), valueEnd: e?.end }
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const record = (open, node) => {
|
|
38
|
+
const attrs = attrsOf(open.attributes)
|
|
39
|
+
const el = {
|
|
40
|
+
name: open.tagName.getText(sf),
|
|
41
|
+
attrs,
|
|
42
|
+
attr: n => attrs.find(a => a.name === n),
|
|
43
|
+
ancestors: [...stack],
|
|
44
|
+
start: node.getStart(sf),
|
|
45
|
+
end: node.end,
|
|
46
|
+
tagEnd: open.tagName.end,
|
|
47
|
+
children: ts.isJsxElement(node) ? node.children.map(c => c.getText(sf)).join('') : '',
|
|
48
|
+
}
|
|
49
|
+
elements.push(el)
|
|
50
|
+
return el
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const visit = node => {
|
|
54
|
+
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
|
|
55
|
+
const source = node.moduleSpecifier.text
|
|
56
|
+
const c = node.importClause
|
|
57
|
+
const at = { source, start: node.getStart(sf), end: node.end }
|
|
58
|
+
if (c?.name) imports.push({ local: c.name.text, ...at })
|
|
59
|
+
if (c?.namedBindings && ts.isNamedImports(c.namedBindings)) for (const s of c.namedBindings.elements) imports.push({ local: s.name.text, imported: s.propertyName?.text ?? s.name.text, ...at })
|
|
60
|
+
if (!c) imports.push({ local: '', ...at })
|
|
61
|
+
}
|
|
62
|
+
if (ts.isFunctionDeclaration(node) && node.name && node.body) functions.set(node.name.text, node.body.getText(sf))
|
|
63
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && (ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer))) {
|
|
64
|
+
functions.set(node.name.text, node.initializer.body.getText(sf))
|
|
65
|
+
}
|
|
66
|
+
if (ts.isJsxSelfClosingElement(node)) {
|
|
67
|
+
record(node, node)
|
|
68
|
+
} else if (ts.isJsxElement(node)) {
|
|
69
|
+
const el = record(node.openingElement, node)
|
|
70
|
+
// attribute expressions (a `pending={<X />}`) see this element as an ancestor too
|
|
71
|
+
stack.push(el.name)
|
|
72
|
+
ts.forEachChild(node, visit)
|
|
73
|
+
stack.pop()
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
ts.forEachChild(node, visit)
|
|
77
|
+
}
|
|
78
|
+
visit(sf)
|
|
79
|
+
return { imports, elements, functions }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The import an identifier in an expression (`{hero}`, `{hero.src}`) comes from, or undefined. */
|
|
83
|
+
export function importOf(scan, expr) {
|
|
84
|
+
const id = /^\s*([A-Za-z_$][\w$]*)/.exec(expr ?? '')?.[1]
|
|
85
|
+
return id ? scan.imports.find(i => i.local === id) : undefined
|
|
86
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// Find, load and validate weft.config (TypeScript, JSON or YAML, one schema: research 34 §2). Plugins run only their pure
|
|
2
|
+
// `contribute()` (plugins.mjs); `slots` is data.
|
|
3
|
+
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
5
|
+
import nodeModule, { createRequire } from 'node:module'
|
|
6
|
+
import { join, relative } from 'node:path'
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
8
|
+
import ts from 'typescript'
|
|
9
|
+
import { z } from 'zod'
|
|
10
|
+
import { diagnostic, locate } from './diag.mjs'
|
|
11
|
+
import { checkSite } from './site.mjs'
|
|
12
|
+
import { contributions } from './contributions.mjs'
|
|
13
|
+
import { bindSlots } from './slots.mjs'
|
|
14
|
+
|
|
15
|
+
export const configNames = ['weft.config.ts', 'weft.config.mts', 'weft.config.js', 'weft.config.mjs', 'weft.config.json', 'weft.config.yaml', 'weft.config.yml']
|
|
16
|
+
export const builtinNames = ['add', 'map', 'dev', 'build', 'start', 'db', 'test', 'check', 'doctor', 'fix', 'tokens', 'browser', 'mcp', 'tasks', 'run', 'create', 'docs', 'agents-md', 'plugins', 'deploy']
|
|
17
|
+
|
|
18
|
+
const configModule = pathToFileURL(join(fileURLToPath(new URL('.', import.meta.url)), 'config.mjs')).href
|
|
19
|
+
const nativeRequire = createRequire(import.meta.url)
|
|
20
|
+
/** Node's sync module hooks. Bun has none, and Deno's break `.node` requires, like Rolldown's; both read TypeScript without help (`importConfig`). */
|
|
21
|
+
const registerHooks = process.versions.bun || process.versions.deno ? null : nodeModule.registerHooks
|
|
22
|
+
/** `weft.config.ts.weft-load-<n>`: a fresh module id per load, so a long-lived `weft mcp` sees edits. */
|
|
23
|
+
const tag = /\.weft-load-\d+$/
|
|
24
|
+
let hooked = false
|
|
25
|
+
|
|
26
|
+
const transpile = path =>
|
|
27
|
+
ts.transpileModule(readFileSync(path, 'utf8'), {
|
|
28
|
+
fileName: path,
|
|
29
|
+
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
|
|
30
|
+
}).outputText
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Sync module hooks, so loading is Node's own `require(esm)` and works inside test runners' vm contexts too.
|
|
34
|
+
* `@weft-js/core/config` resolves to the project's weft when it ships one, else to ours; `.ts` is transpiled, not stripped.
|
|
35
|
+
*/
|
|
36
|
+
function hook() {
|
|
37
|
+
if (hooked) return
|
|
38
|
+
hooked = true
|
|
39
|
+
registerHooks({
|
|
40
|
+
resolve(specifier, context, next) {
|
|
41
|
+
if (tag.test(specifier)) return { url: pathToFileURL(specifier).href, format: 'module', shortCircuit: true }
|
|
42
|
+
if (specifier === '@weft-js/core/config' || specifier === '@weft-js/cli/config') {
|
|
43
|
+
try {
|
|
44
|
+
return next(specifier, context)
|
|
45
|
+
} catch {
|
|
46
|
+
return { url: configModule, shortCircuit: true }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// the app's own imports are bundler-style (`./src/expression`): try what Vite would before failing
|
|
50
|
+
if (/^\.{1,2}\//.test(specifier) && !/\.[cm]?[jt]sx?$|\.json$/.test(specifier)) {
|
|
51
|
+
for (const end of ['', '.ts', '.tsx', '.mts', '.js', '/index.ts', '/index.tsx', '/index.js']) {
|
|
52
|
+
try {
|
|
53
|
+
return next(specifier + end, context)
|
|
54
|
+
} catch {}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return next(specifier, context)
|
|
58
|
+
},
|
|
59
|
+
load(url, context, next) {
|
|
60
|
+
if (!url.startsWith('file:')) return next(url, context)
|
|
61
|
+
const path = fileURLToPath(url)
|
|
62
|
+
if (tag.test(path)) {
|
|
63
|
+
const real = path.replace(tag, '')
|
|
64
|
+
return { format: 'module', source: /\.[mc]?ts$/.test(real) ? transpile(real) : readFileSync(real, 'utf8'), shortCircuit: true }
|
|
65
|
+
}
|
|
66
|
+
if (/\.[mc]?ts$/.test(path) && !path.includes('/node_modules/')) return { format: 'module', source: transpile(path), shortCircuit: true }
|
|
67
|
+
return next(url, context)
|
|
68
|
+
},
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The config on Bun and Deno: their own TypeScript, with a query for a fresh module per load. When the project has no
|
|
74
|
+
* `@weft-js/core/config` of its own, a copy next to the config, so its relative imports still resolve, imports ours instead.
|
|
75
|
+
*/
|
|
76
|
+
async function importConfig(file) {
|
|
77
|
+
try {
|
|
78
|
+
return (await import(`${pathToFileURL(file).href}?weft-load=${generation++}`)).default ?? {}
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (!/weft\/config|@weft-js\/cli\/config/.test(error.message)) throw error
|
|
81
|
+
}
|
|
82
|
+
const copy = `${file}.weft-load-${generation++}.mjs`
|
|
83
|
+
writeFileSync(copy, transpile(file).replace(/(['"])(?:weft\/config|@weft-js\/cli\/config)\1/g, JSON.stringify(configModule)))
|
|
84
|
+
try {
|
|
85
|
+
return (await import(pathToFileURL(copy).href)).default ?? {}
|
|
86
|
+
} finally {
|
|
87
|
+
rmSync(copy, { force: true })
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const zodSchema = z.custom(v => v != null && typeof v === 'object' && ('_zod' in v || '~standard' in v))
|
|
92
|
+
const globs = z.array(z.string())
|
|
93
|
+
|
|
94
|
+
const task = z.strictObject({
|
|
95
|
+
run: z.string().min(1),
|
|
96
|
+
description: z.string().optional(),
|
|
97
|
+
sideEffects: z.array(z.string()).optional(),
|
|
98
|
+
env: z.array(z.string()).optional(),
|
|
99
|
+
args: z.union([zodSchema, z.record(z.string(), z.unknown())]).optional(),
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
const pluginEntry = z.looseObject({
|
|
103
|
+
name: z.string().min(1),
|
|
104
|
+
version: z.string().optional(),
|
|
105
|
+
provides: z.array(z.union([z.string(), z.strictObject({ slot: z.string(), key: z.string() })])).optional(),
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
const image = z.strictObject({ url: z.string().min(1), width: z.int().positive().optional(), height: z.int().positive().optional(), alt: z.string().optional() })
|
|
109
|
+
|
|
110
|
+
const hex = z.string().regex(/^#[0-9a-fA-F]{6}$/, { error: 'must be #rrggbb' })
|
|
111
|
+
const colors = { background: hex.optional(), text: hex.optional(), subtle: hex.optional(), accent: hex.optional() }
|
|
112
|
+
const cardFont = z.strictObject({ family: z.string().regex(/^[\w -]+$/, { error: 'must be a font family name' }), src: z.string().min(1), weight: z.number().optional() })
|
|
113
|
+
const card = z.strictObject({ ...colors, scheme: z.enum(['dark', 'light']).optional(), dark: z.strictObject(colors).optional(), light: z.strictObject(colors).optional(), fonts: z.strictObject({ display: cardFont.optional(), body: cardFont.optional() }).optional() })
|
|
114
|
+
|
|
115
|
+
const site = z.strictObject({
|
|
116
|
+
headers: z.record(z.string(), z.record(z.string(), z.string())).optional(),
|
|
117
|
+
name: z.string().min(1).optional(),
|
|
118
|
+
url: z.url({ protocol: /^https?$/, error: 'must be an absolute http(s) URL' }).optional(),
|
|
119
|
+
title: z.string().includes('%s', { error: 'must contain `%s` (the page title)' }).optional(),
|
|
120
|
+
description: z.string().optional(),
|
|
121
|
+
locale: z.string().regex(/^[a-z]{2,3}(_[A-Z]{2})?$/, { error: 'must look like `en_US`' }).optional(),
|
|
122
|
+
inlineCss: z.boolean().optional(),
|
|
123
|
+
criticalCss: z.boolean().optional(),
|
|
124
|
+
criticalKeep: z.array(z.string()).optional(),
|
|
125
|
+
image: z.union([z.literal('auto'), z.literal(false), z.string().min(1), image]).optional(),
|
|
126
|
+
card: card.optional(),
|
|
127
|
+
head: z.string().optional(),
|
|
128
|
+
twitter: z
|
|
129
|
+
.strictObject({ card: z.enum(['summary', 'summary_large_image', 'app', 'player']).optional(), site: z.string().optional(), creator: z.string().optional() })
|
|
130
|
+
.optional(),
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
export const configSchema = z.strictObject({
|
|
134
|
+
scope: z.strictObject({ write: globs.optional(), read: globs.optional(), forbid: globs.optional() }).optional(),
|
|
135
|
+
tasks: z.record(z.string(), task).optional(),
|
|
136
|
+
environments: z.record(z.string(), z.looseObject({ url: z.string().optional() })).optional(),
|
|
137
|
+
test: z
|
|
138
|
+
.strictObject({
|
|
139
|
+
include: globs.optional(),
|
|
140
|
+
lanes: z.array(z.enum(['fast', 'browser', 'e2e'])).optional(),
|
|
141
|
+
gate: z.strictObject({ mutationBudgetSec: z.number().positive().optional(), maxMutants: z.number().int().positive().optional() }).optional(),
|
|
142
|
+
})
|
|
143
|
+
.optional(),
|
|
144
|
+
build: z.strictObject({
|
|
145
|
+
audit: z.union([z.boolean(), z.strictObject({ routes: z.array(z.string()).optional(), all: z.boolean().optional(), max: z.number().int().positive().optional() })]).optional(),
|
|
146
|
+
unsafeAllow: z.record(z.string().regex(/^(all|[A-Z]|[A-Z]+\d{3})$/, 'a code (R002), a family (L) or all'), z.string().min(1, 'say why')).optional(),
|
|
147
|
+
}).optional(),
|
|
148
|
+
check: z.strictObject({ include: globs.optional(), exclude: globs.optional(), design: z.boolean().optional(), routes: z.boolean().optional(), props: z.union([z.boolean(), z.literal('manual')]).optional() }).optional(),
|
|
149
|
+
plugins: z.array(pluginEntry).optional(),
|
|
150
|
+
slots: z.record(z.string(), z.union([z.string(), z.record(z.string(), z.string())])).optional(),
|
|
151
|
+
site: site.optional(),
|
|
152
|
+
runtime: z.enum(['node', 'bun', 'deno']).optional(),
|
|
153
|
+
hydration: z.enum(['lazy', 'partial', 'full']).optional(),
|
|
154
|
+
rust: z.strictObject({
|
|
155
|
+
unions: z.literal('allow').optional(),
|
|
156
|
+
services: z.record(z.string(), z.strictObject({ crate: z.string().optional(), external: z.boolean().optional(), wasm: z.literal(false).optional() })).optional(),
|
|
157
|
+
}).optional(),
|
|
158
|
+
/** `weft deploy` (research 46): names, regions and sizes. Credentials come from the environment, never here (E008). */
|
|
159
|
+
deploy: z.strictObject({
|
|
160
|
+
target: z.enum(['netlify', 'vercel', 'cloudflare', 'fly', 'render']).optional(),
|
|
161
|
+
netlify: z.strictObject({ site: z.string().min(1).optional() }).optional(),
|
|
162
|
+
vercel: z.strictObject({ project: z.string().regex(/^[a-z0-9][a-z0-9-]{0,62}$/i, 'a name: letters, digits and dashes').optional() }).optional(),
|
|
163
|
+
cloudflare: z.strictObject({ name: z.string().regex(/^[a-z0-9][a-z0-9-]{0,62}$/i, 'a name: letters, digits and dashes').optional() }).optional(),
|
|
164
|
+
fly: z.strictObject({
|
|
165
|
+
app: z.string().regex(/^[a-z0-9][a-z0-9-]{0,62}$/i, 'a name: letters, digits and dashes').optional(), region: z.string().regex(/^[a-z]{3}$/, 'a Fly region code, like `iad`').optional(), memory: z.string().regex(/^\d+(mb|gb)$/, 'like `512mb`').optional(),
|
|
166
|
+
volume: z.strictObject({ name: z.string().regex(/^[a-z0-9_]+$/, 'lowercase letters, digits and underscores').optional(), path: z.string().startsWith('/').optional(), sizeGb: z.number().int().positive().optional() }).optional(),
|
|
167
|
+
}).optional(),
|
|
168
|
+
render: z.strictObject({
|
|
169
|
+
service: z.string().regex(/^[a-z0-9][a-z0-9-]{0,62}$/i, 'a name: letters, digits and dashes').optional(), plan: z.string().optional(), region: z.string().optional(),
|
|
170
|
+
disk: z.strictObject({ path: z.string().startsWith('/').optional(), sizeGb: z.number().int().positive().optional() }).optional(),
|
|
171
|
+
}).optional(),
|
|
172
|
+
ci: z.union([z.literal('github'), z.literal(false)]).optional(),
|
|
173
|
+
branch: z.string().min(1).optional(),
|
|
174
|
+
cdn: z.strictObject({
|
|
175
|
+
proxy: z.literal('cloudflare').optional(),
|
|
176
|
+
images: z.strictObject({ provider: z.literal('cloudinary'), cloud: z.string().regex(/^[\w-]+$/, "the cloud's name, not its URL").optional() }).optional(),
|
|
177
|
+
}).optional(),
|
|
178
|
+
}).optional(),
|
|
179
|
+
/** Named targets for a query's `live: { verify: 'staging' }`. `db` is an env var's name. */
|
|
180
|
+
live: z.strictObject({
|
|
181
|
+
targets: z.record(z.string(), z.strictObject({ url: z.string().optional(), db: z.string().optional(), production: z.boolean().optional() })).optional(),
|
|
182
|
+
}).optional(),
|
|
183
|
+
trust: z.enum(['enforce', 'warn']).optional(),
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
/** What a JSON or YAML file may hold: data, so a plugin is `name` or `[name, options]`. Also the editor schema. */
|
|
187
|
+
const dataSchema = configSchema.extend({
|
|
188
|
+
$schema: z.string().optional(),
|
|
189
|
+
plugins: z.array(z.union([z.string(), z.tuple([z.string(), z.unknown()]), pluginEntry])).optional(),
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
export const isDataConfig = file => /\.(json|ya?ml)$/.test(file)
|
|
193
|
+
|
|
194
|
+
const configFiles = root => configNames.map(name => join(root, name)).filter(existsSync)
|
|
195
|
+
|
|
196
|
+
export function findConfig(root) {
|
|
197
|
+
return configFiles(root)[0] ?? null
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The schema `$schema` points at, for completion in `weft.config.json` and `.yaml`. */
|
|
201
|
+
export function writeConfigSchema(root) {
|
|
202
|
+
mkdirSync(join(root, '.weft'), { recursive: true })
|
|
203
|
+
writeFileSync(join(root, '.weft', 'config.schema.json'), `${JSON.stringify(z.toJSONSchema(dataSchema, { unrepresentable: 'any' }), null, 2)}\n`)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** `@weft-js/text` → `text`, `@weft-js/db/plugin-native` → `native`: the name a plugin's named export goes by. */
|
|
207
|
+
export const pluginName = pkg => pkg.replace(/^@[^/]+\/(?:[^/]+\/)?/, '').replace(/^plugin-/, '').replace(/[^a-zA-Z0-9]+(.)?/g, (_, c = '') => c.toUpperCase())
|
|
208
|
+
|
|
209
|
+
/** A plugin module's factory, and its import name: the default, or a named export (`surface-keel` may export `keel`). */
|
|
210
|
+
export function pluginFactory(mod, pkg) {
|
|
211
|
+
if (typeof mod.default === 'function') return { value: mod.default, name: pluginName(pkg), named: false }
|
|
212
|
+
const name = [pluginName(pkg), pkg.split('-').at(-1)].find(n => typeof mod[n] === 'function')
|
|
213
|
+
return name ? { value: mod[name], name, named: true } : null
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** A data plugin from the project's own install: `<name>/plugin` when the package has one, else its main entry. */
|
|
217
|
+
function loadPlugin(root, entry) {
|
|
218
|
+
if (typeof entry !== 'string' && !Array.isArray(entry)) return entry
|
|
219
|
+
const [name, options] = [entry].flat()
|
|
220
|
+
const require = createRequire(join(root, 'package.json'))
|
|
221
|
+
for (const spec of [`${name}/plugin`, name]) {
|
|
222
|
+
let path
|
|
223
|
+
try {
|
|
224
|
+
path = require.resolve(spec)
|
|
225
|
+
} catch {
|
|
226
|
+
continue
|
|
227
|
+
}
|
|
228
|
+
const factory = pluginFactory(nativeRequire(path), name)
|
|
229
|
+
if (!factory) throw new Error(`plugin \`${name}\` exports no factory to call`)
|
|
230
|
+
return factory.value(options)
|
|
231
|
+
}
|
|
232
|
+
throw new Error(`plugin \`${name}\` isn't installed`)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** A JSON or YAML config as what a `.ts` one evaluates to. */
|
|
236
|
+
function readDataConfig(root, file, text) {
|
|
237
|
+
const data = file.endsWith('.json') ? JSON.parse(text) : nativeRequire('yaml').parse(text)
|
|
238
|
+
const { $schema: _schema, ...config } = data ?? {}
|
|
239
|
+
if (Array.isArray(config.plugins)) config.plugins = config.plugins.map(p => loadPlugin(root, p))
|
|
240
|
+
return config
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
let generation = 0
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* @param {string} root project root
|
|
247
|
+
* @returns {Promise<{ file: string | null, config: import('./config').WeftConfig, diagnostics: object[], slots: object, contributions: ReturnType<typeof contributions> }>}
|
|
248
|
+
*/
|
|
249
|
+
export async function loadConfig(root) {
|
|
250
|
+
const file = findConfig(root)
|
|
251
|
+
const diagnostics = []
|
|
252
|
+
const rel = file ? relative(root, file).split('\\').join('/') : 'weft.config.ts'
|
|
253
|
+
const text = file ? readFileSync(file, 'utf8') : ''
|
|
254
|
+
const report = (code, why, near, extra) => diagnostics.push(diagnostic({ code, file: rel, why, ...locate(text, near), ...extra }))
|
|
255
|
+
const others = configFiles(root).slice(1).map(f => relative(root, f))
|
|
256
|
+
if (others.length) report('P010', `\`${rel}\` is the config in use, and \`${others.join('`, `')}\` would be ignored.`, null)
|
|
257
|
+
|
|
258
|
+
let config = {}
|
|
259
|
+
if (file) {
|
|
260
|
+
if (registerHooks) hook()
|
|
261
|
+
try {
|
|
262
|
+
config = isDataConfig(file) ? readDataConfig(root, file, text) : registerHooks ? (nativeRequire(`${file}.weft-load-${generation++}`).default ?? {}) : await importConfig(file)
|
|
263
|
+
} catch (error) {
|
|
264
|
+
// A plugin factory rejecting its options throws while the config module evaluates (@weft-js/core/plugin).
|
|
265
|
+
if (error?.code === 'P115') report('P115', error.message.split('\n')[0], error.message.match(/^`([^`]+)`/)?.[1] ?? null)
|
|
266
|
+
else report('P007', `weft.config could not be loaded: ${error.message.split('\n')[0]}`, null)
|
|
267
|
+
return { file, config: {}, diagnostics, slots: bindSlots({}, () => {}), contributions: [] }
|
|
268
|
+
}
|
|
269
|
+
const parsed = configSchema.safeParse(config)
|
|
270
|
+
if (!parsed.success) {
|
|
271
|
+
for (const issue of parsed.error.issues) {
|
|
272
|
+
const path = issue.path.join('.')
|
|
273
|
+
report('P007', `\`${path || '(root)'}\`: ${issue.message}`, String(issue.path.at(-1) ?? ''), { help: 'Fix the key at the path shown (see `weft docs config`).' })
|
|
274
|
+
}
|
|
275
|
+
return { file, config, diagnostics, slots: bindSlots({}, () => {}), contributions: [] }
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const tasks = config.tasks ?? {}
|
|
280
|
+
const commands = new Map()
|
|
281
|
+
for (const [name, t] of Object.entries(tasks)) {
|
|
282
|
+
if (builtinNames.includes(name)) report('P003', `Task \`${name}\` has the same name as the built-in \`weft ${name}\`.`, name)
|
|
283
|
+
const twin = commands.get(t.run.trim())
|
|
284
|
+
if (twin) report('P003', `Task \`${name}\` runs the same command as \`${twin}\` (\`${t.run}\`).`, name)
|
|
285
|
+
else commands.set(t.run.trim(), name)
|
|
286
|
+
const missing = [!t.description?.trim() && '`description`', !t.sideEffects && '`sideEffects`'].filter(Boolean)
|
|
287
|
+
if (missing.length) report('P004', `Task \`${name}\` has no ${missing.join(' or ')}.`, name)
|
|
288
|
+
for (const env of t.env ?? []) {
|
|
289
|
+
if (config.environments && !(env in config.environments)) report('P007', `Task \`${name}\` allows environment \`${env}\`, which \`environments\` does not define.`, name)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const seen = new Set()
|
|
294
|
+
for (const p of config.plugins ?? []) {
|
|
295
|
+
if (seen.has(p.name)) report('P116', `Plugin \`${p.name}\` is listed twice in \`plugins\`.`, p.name)
|
|
296
|
+
seen.add(p.name)
|
|
297
|
+
}
|
|
298
|
+
const unique = (config.plugins ?? []).filter((p, i, all) => all.findIndex(q => q.name === p.name) === i)
|
|
299
|
+
const contributed = contributions(unique, report, { root })
|
|
300
|
+
const slots = bindSlots({ ...config, plugins: unique }, report)
|
|
301
|
+
if (config.site) checkSite(config.site, report)
|
|
302
|
+
return { file, config, diagnostics, slots, contributions: contributed }
|
|
303
|
+
}
|
package/src/map.mjs
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// `weft map`: a matrix of what to call in this app, generated from its config and code (research 34 §8). It runs on every
|
|
2
|
+
// `weft check`, so it reads files and never starts Vite. Rows point at where an answer lives instead of copying it, and
|
|
3
|
+
// a system the app doesn't have gets no row.
|
|
4
|
+
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
|
|
6
|
+
import { join, resolve } from 'node:path'
|
|
7
|
+
import { discover } from './files.mjs'
|
|
8
|
+
import { loadConfig } from './load-config.mjs'
|
|
9
|
+
import { dbLayer } from './db-layer.mjs'
|
|
10
|
+
import { listSurfaces } from './surface-checks.mjs'
|
|
11
|
+
|
|
12
|
+
const read = (root, rel) => (existsSync(join(root, rel)) ? readFileSync(join(root, rel), 'utf8') : '')
|
|
13
|
+
const names = (text, re) => [...text.matchAll(re)].map(m => m[1])
|
|
14
|
+
/** Up to `max` names: enough to recognise the app, while the row's call gives the whole list. */
|
|
15
|
+
const sample = (list, max = 6) => (list.length > max ? `${list.slice(0, max).join(', ')}, +${list.length - max}` : list.join(', '))
|
|
16
|
+
|
|
17
|
+
function serverFunctions(root) {
|
|
18
|
+
const files = discover(root, { include: ['src/server/**/*.{ts,mts}', 'src/**/*.server.{ts,mts}'], exclude: [] })
|
|
19
|
+
const out = { functions: [], channels: [] }
|
|
20
|
+
for (const rel of files) {
|
|
21
|
+
const found = [...read(root, rel).matchAll(/export\s+const\s+([A-Za-z_$][\w$]*)\s*=\s*(query|mutation|stream|channel)\s*\(/g)]
|
|
22
|
+
const fns = found.filter(m => m[2] !== 'channel').map(m => m[1])
|
|
23
|
+
if (fns.length) out.functions.push(`\`${rel}\`: ${fns.join(', ')}`)
|
|
24
|
+
out.channels.push(...found.filter(m => m[2] === 'channel').map(m => m[1]))
|
|
25
|
+
}
|
|
26
|
+
return out
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const sqlText = (root, dir) => (existsSync(join(root, dir)) ? readdirSync(join(root, dir)).filter(f => f.endsWith('.sql')).map(f => read(root, join(dir, f))).join('\n') : '')
|
|
30
|
+
const ENGINE = { PrismaLibSql: 'sqlite', PrismaPg: 'postgres', PrismaMariaDb: 'mysql' }
|
|
31
|
+
|
|
32
|
+
export function database(root, layer) {
|
|
33
|
+
if (!layer) return null
|
|
34
|
+
const entry = read(root, 'src/db/index.server.ts')
|
|
35
|
+
const engine = entry.match(/@weft-js\/(?:start\/db|db\/plugin-native)\/(\w+)/)?.[1] ?? ENGINE[entry.match(/Prisma(?:LibSql|Pg|MariaDb)/)?.[0]] ?? 'unknown'
|
|
36
|
+
const tables = layer.layer === 'prisma' ? names(read(root, layer.schema), /^model\s+(\w+)/gm)
|
|
37
|
+
: layer.layer === 'native' ? names(sqlText(root, layer.migrations), /create\s+table\s+(?:if\s+not\s+exists\s+)?["`]?(\w+)/gi)
|
|
38
|
+
: names(read(root, 'src/db/schema.ts'), /export\s+const\s+([A-Za-z_$][\w$]*)\s*=\s*\w+Table\s*\(/g)
|
|
39
|
+
return { engine, layer: layer.layer, tables }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function routes(root) {
|
|
43
|
+
const files = discover(root, { include: ['src/routes/**/*.{ts,tsx}'], exclude: [] })
|
|
44
|
+
return files.map(f => f.replace(/^src\/routes\//, '').replace(/\.[jt]sx?$/, '')).filter(f => !/(^|\/)_/.test(f))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The map as data. */
|
|
48
|
+
export async function agentMap(root) {
|
|
49
|
+
root = resolve(root)
|
|
50
|
+
const loaded = await loadConfig(root)
|
|
51
|
+
const { config } = loaded
|
|
52
|
+
const pages = routes(root)
|
|
53
|
+
const fns = serverFunctions(root)
|
|
54
|
+
const env = ['src/env.ts', 'src/env.server.ts'].find(f => existsSync(join(root, f)))
|
|
55
|
+
const layer = await dbLayer(root, loaded)
|
|
56
|
+
const db = database(root, layer)
|
|
57
|
+
const authFile = read(root, 'src/auth.server.ts')
|
|
58
|
+
const auth = authFile && (authFile.includes('better-auth') ? 'better-auth' : 'custom')
|
|
59
|
+
const surfaces = listSurfaces(root).map(s => s.name)
|
|
60
|
+
const styled = existsSync(join(root, '.weft/styles.json')) ? Object.keys(JSON.parse(read(root, '.weft/styles.json')).groups ?? {}) : []
|
|
61
|
+
const bound = Object.entries(loaded.slots ?? {}).filter(([, v]) => typeof v === 'string' && !v.startsWith('weft:default')).map(([slot]) => slot)
|
|
62
|
+
const tasks = Object.keys(config.tasks ?? {})
|
|
63
|
+
const row = (want, call, have) => ({ want, call, ...(have && { have }) })
|
|
64
|
+
|
|
65
|
+
const rows = [
|
|
66
|
+
pages.length && row('Pages, their data and metadata', '`weft routes --meta`', sample(pages)),
|
|
67
|
+
fns.functions.length && row('Server functions', 'The file named here; signatures in `.weft/contract.json` after a build', fns.functions.join('; ')),
|
|
68
|
+
fns.channels.length && row('Channels', '`weft docs start`', sample(fns.channels)),
|
|
69
|
+
db && row('Tables and migrations', layer.schema ? `\`${layer.schema}\`, then \`weft db generate\`` : `\`weft db generate <name>\`, then SQL in \`${layer.migrations}/\``, sample(db.tables)),
|
|
70
|
+
auth && row('Who the user is', '`ctx.user` on the server; `src/auth.server.ts`', auth),
|
|
71
|
+
row('Styles', 'Before writing one: `weft style find <what it is>`', styled.length ? sample(styled) : undefined),
|
|
72
|
+
env && row('Environment variables', `\`${env}\`; values in \`.env\``),
|
|
73
|
+
surfaces.length && row('Canvas and WebGL', '`weft surfaces`', sample(surfaces)),
|
|
74
|
+
...loaded.contributions.flatMap(c => c.manifest.map ?? []).map(r => row(r.want, r.call, r.have)),
|
|
75
|
+
(config.plugins?.length || bound.length) && row('Plugins and slots', '`weft plugins`, `weft plugins explain <slot>`', sample(bound)),
|
|
76
|
+
tasks.length && row('Project tasks', '`weft tasks --json`', sample(tasks)),
|
|
77
|
+
row('A rule or a diagnostic', '`weft docs <topic or code>`'),
|
|
78
|
+
row('The running app', '`weft browser snapshot`'),
|
|
79
|
+
].filter(Boolean)
|
|
80
|
+
|
|
81
|
+
return { version: 1, app: config.site?.name ?? null, stack: { runtime: config.runtime ?? 'node', ui: config.plugins?.map(p => p.name) ?? [], db, auth: auth || null }, rows }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function formatMap(map) {
|
|
85
|
+
const { stack } = map
|
|
86
|
+
const parts = [`runtime ${stack.runtime}`, stack.db && `${stack.db.layer} on ${stack.db.engine}`, stack.auth, ...stack.ui].filter(Boolean)
|
|
87
|
+
const table = map.rows.map(r => `| ${r.want} | ${r.call} | ${r.have ?? ''} |`)
|
|
88
|
+
return [
|
|
89
|
+
`# ${map.app ?? 'This app'}: agent map`,
|
|
90
|
+
'',
|
|
91
|
+
'<!-- Generated by `weft map` from this app\'s config and code. Do not edit. -->',
|
|
92
|
+
'',
|
|
93
|
+
`Stack: ${parts.join(', ')}.`,
|
|
94
|
+
'',
|
|
95
|
+
'| To find or do | Call | In this app |',
|
|
96
|
+
'|---|---|---|',
|
|
97
|
+
...table,
|
|
98
|
+
'',
|
|
99
|
+
].join('\n')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Write `.weft/map.md` and `.weft/map.json`. */
|
|
103
|
+
export async function writeMap(root) {
|
|
104
|
+
root = resolve(root)
|
|
105
|
+
const map = await agentMap(root)
|
|
106
|
+
mkdirSync(join(root, '.weft'), { recursive: true })
|
|
107
|
+
writeFileSync(join(root, '.weft/map.md'), formatMap(map))
|
|
108
|
+
writeFileSync(join(root, '.weft/map.json'), `${JSON.stringify(map, null, 2)}\n`)
|
|
109
|
+
return map
|
|
110
|
+
}
|