@weft-js/cli 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +16 -0
- package/README.md +74 -2
- package/bin/weft.mjs +8 -0
- package/docs/async.md +40 -0
- package/docs/browser.md +39 -0
- package/docs/config.md +93 -0
- package/docs/design.md +97 -0
- package/docs/fonts.md +70 -0
- package/docs/icons.md +102 -0
- package/docs/lists.md +23 -0
- package/docs/meta.md +82 -0
- package/docs/plugins.md +143 -0
- package/docs/props.md +77 -0
- package/docs/rules.md +32 -0
- package/docs/rust.md +223 -0
- package/docs/start-short.md +8 -0
- package/docs/start.md +422 -0
- package/docs/state.md +96 -0
- package/docs/style.md +59 -0
- package/docs/surface.md +111 -0
- package/docs/testing.md +71 -0
- package/docs/ui-state.md +92 -0
- package/docs/verify.md +48 -0
- package/package.json +39 -4
- package/src/add-rust.mjs +123 -0
- package/src/add.mjs +358 -0
- package/src/agents-md.mjs +134 -0
- package/src/alias.mjs +59 -0
- package/src/audit.mjs +333 -0
- package/src/browser/cli.mjs +34 -0
- package/src/browser/client.mjs +132 -0
- package/src/browser/commands.mjs +143 -0
- package/src/catalog.mjs +479 -0
- package/src/check.mjs +328 -0
- package/src/cli.mjs +332 -0
- package/src/config.d.ts +1 -0
- package/src/config.mjs +5 -0
- package/src/contributions.mjs +69 -0
- package/src/db-layer.mjs +33 -0
- package/src/design-checks.mjs +439 -0
- package/src/diag.mjs +74 -0
- package/src/docs.mjs +131 -0
- package/src/doctor.mjs +30 -0
- package/src/files.mjs +47 -0
- package/src/fix.mjs +40 -0
- package/src/gate.mjs +276 -0
- package/src/index.mjs +9 -0
- package/src/infer/engine.cjs +525 -0
- package/src/infer/facts.cjs +252 -0
- package/src/infer/hosts.cjs +114 -0
- package/src/infer/lsp.mjs +131 -0
- package/src/infer/session.cjs +105 -0
- package/src/infer/shadow.cjs +46 -0
- package/src/infer/worker.cjs +97 -0
- package/src/jsx-scan.mjs +86 -0
- package/src/load-config.mjs +274 -0
- package/src/map.mjs +110 -0
- package/src/mcp.mjs +195 -0
- package/src/meta-table.mjs +28 -0
- package/src/mutate.mjs +140 -0
- package/src/plugin-checks.mjs +89 -0
- package/src/plugins.mjs +172 -0
- package/src/project.mjs +219 -0
- package/src/props-checks.mjs +46 -0
- package/src/props-listener.mjs +38 -0
- package/src/react-checks.mjs +36 -0
- package/src/share-checks.mjs +74 -0
- package/src/site.mjs +19 -0
- package/src/slots.mjs +96 -0
- package/src/start.mjs +292 -0
- package/src/state-checks.mjs +213 -0
- package/src/style.mjs +320 -0
- package/src/surface-checks.mjs +218 -0
- package/src/tailwind.mjs +22 -0
- package/src/tasks.mjs +99 -0
- package/src/testdiff.mjs +157 -0
- package/src/tests.mjs +572 -0
- package/src/text-checks.mjs +172 -0
- package/src/tokens.mjs +24 -0
- package/src/trust.mjs +282 -0
- package/src/weft.mjs +66 -0
- package/src/workspace.mjs +105 -0
- package/ts-plugin/index.js +137 -0
- package/ts-plugin/package.json +1 -0
package/src/add.mjs
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
// `weft add`: what the wizard offers, on an app that already exists (research 34 §7). It installs a plugin and adds it to
|
|
2
|
+
// the config, whichever format that is. It only ever inserts, and stops rather than guess at a config it can't read.
|
|
3
|
+
|
|
4
|
+
import { execFileSync } from 'node:child_process'
|
|
5
|
+
import { randomBytes } from 'node:crypto'
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
|
|
7
|
+
import { createRequire } from 'node:module'
|
|
8
|
+
import { pathToFileURL } from 'node:url'
|
|
9
|
+
import { dirname, join, relative, resolve } from 'node:path'
|
|
10
|
+
import ts from 'typescript'
|
|
11
|
+
import { addRust } from './add-rust.mjs'
|
|
12
|
+
import { writeAgentsMd } from './agents-md.mjs'
|
|
13
|
+
import { findConfig, loadConfig, pluginFactory, pluginName } from './load-config.mjs'
|
|
14
|
+
import { sync } from './plugins.mjs'
|
|
15
|
+
import { cssEntry, packages, viteConfig } from './tailwind.mjs'
|
|
16
|
+
import { database } from './map.mjs'
|
|
17
|
+
import { dbLayer } from './db-layer.mjs'
|
|
18
|
+
import { packageOf, trustStatus } from './trust.mjs'
|
|
19
|
+
|
|
20
|
+
const usageError = message => Object.assign(new Error(message), { code: 'E_USAGE' })
|
|
21
|
+
const refused = message => Object.assign(new Error(message), { code: 'E_ADD' })
|
|
22
|
+
|
|
23
|
+
const ALIASES = { warp: '@weft-js/warp', icons: '@weft-js/warp-icons', text: '@weft-js/text', media: '@weft-js/plugin-media', 'media-ffmpeg': '@weft-js/plugin-media-ffmpeg', 'surface-keel': '@weft-js/plugin-surface-keel', redis: '@weft-js/realtime-redis', nats: '@weft-js/realtime-nats', pay: '@weft-js/pay', 'pay-stripe': '@weft-js/pay/stripe' }
|
|
24
|
+
|
|
25
|
+
/** Add-ons `weft add` knows by the name after the scope, as `weft add realtime-redis`. */
|
|
26
|
+
const ADDONS = new Set(['@weft-js/text', '@weft-js/pay', '@weft-js/plugin-media', '@weft-js/plugin-media-ffmpeg', '@weft-js/plugin-surface-keel', '@weft-js/multiplayer', '@weft-js/realtime-durable-object', '@weft-js/realtime-livekit', '@weft-js/realtime-nats', '@weft-js/realtime-redis', '@weft-js/realtime-socketio', '@weft-js/realtime-webrtc'])
|
|
27
|
+
|
|
28
|
+
const INSTALL = { 'pnpm-lock.yaml': ['pnpm', 'add'], 'bun.lock': ['bun', 'add'], 'yarn.lock': ['yarn', 'add'], 'deno.lock': ['deno', 'add'], 'package-lock.json': ['npm', 'install'] }
|
|
29
|
+
|
|
30
|
+
function installer(root) {
|
|
31
|
+
const lock = Object.keys(INSTALL).find(f => existsSync(join(root, f)))
|
|
32
|
+
return INSTALL[lock] ?? (process.versions.deno ? INSTALL['deno.lock'] : process.versions.bun ? INSTALL['bun.lock'] : INSTALL['package-lock.json'])
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** What to install: for an app on local tarballs (`local.mjs`) those go straight in `package.json`, since npm refuses any
|
|
36
|
+
* spec for them that isn't spelled exactly as their override is. */
|
|
37
|
+
export function pinned(root, specs) {
|
|
38
|
+
const file = join(root, 'package.json')
|
|
39
|
+
const pkg = JSON.parse(readFileSync(file, 'utf8'))
|
|
40
|
+
const local = {}
|
|
41
|
+
const rest = specs.filter(spec => {
|
|
42
|
+
const name = spec.replace(/(?<=.)@.*$/, '')
|
|
43
|
+
const override = pkg.overrides?.[name]
|
|
44
|
+
if (typeof override !== 'string' || !override.startsWith('file:')) return true
|
|
45
|
+
local[name] = override
|
|
46
|
+
return false
|
|
47
|
+
})
|
|
48
|
+
if (Object.keys(local).length) writeFileSync(file, JSON.stringify({ ...pkg, dependencies: { ...pkg.dependencies, ...local } }, null, 2))
|
|
49
|
+
return rest
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function installSpecs(root, manager, verb, specs) {
|
|
53
|
+
const rest = pinned(root, specs)
|
|
54
|
+
execFileSync(manager, [verb, ...(manager === 'deno' ? rest.map(spec => `npm:${spec}`) : rest)], { cwd: root, stdio: ['ignore', 'ignore', 'inherit'], shell: process.platform === 'win32' }) // npm is npm.cmd there
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The import to write: `<pkg>/plugin` when the package has one, and however it exports its factory. */
|
|
58
|
+
function pluginImport(root, pkg) {
|
|
59
|
+
const require = createRequire(join(root, 'package.json'))
|
|
60
|
+
const name = pluginName(pkg)
|
|
61
|
+
for (const spec of [`${pkg}/plugin`, pkg]) {
|
|
62
|
+
let path
|
|
63
|
+
try {
|
|
64
|
+
path = require.resolve(spec)
|
|
65
|
+
} catch {
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
const factory = pluginFactory(createRequire(import.meta.url)(path), pkg)
|
|
69
|
+
if (!factory) throw refused(`${spec} exports no plugin factory to call, so nothing was changed.`)
|
|
70
|
+
return { name: factory.name, line: `import ${factory.named ? `{ ${factory.name} }` : factory.name} from '${spec}'` }
|
|
71
|
+
}
|
|
72
|
+
return { name, line: `import ${name} from '${pkg}'` } // not installed yet: only a dry run gets this far
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The object literal `export default` gives, directly or through `defineConfig(…)`. */
|
|
76
|
+
function configObject(sf) {
|
|
77
|
+
for (const st of sf.statements) {
|
|
78
|
+
if (!ts.isExportAssignment(st)) continue
|
|
79
|
+
const value = ts.isCallExpression(st.expression) ? st.expression.arguments[0] : st.expression
|
|
80
|
+
if (value && ts.isObjectLiteralExpression(value)) return value
|
|
81
|
+
}
|
|
82
|
+
return null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function addToTs(root, file, text, pkg) {
|
|
86
|
+
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true)
|
|
87
|
+
const object = configObject(sf)
|
|
88
|
+
if (!object) throw refused(`${file} doesn't export a plain object, so \`weft add\` can't edit it. Add \`${pkg}\` to \`plugins\` by hand.`)
|
|
89
|
+
const { name, line } = pluginImport(root, pkg)
|
|
90
|
+
const prop = object.properties.find(p => p.name?.getText(sf) === 'plugins')
|
|
91
|
+
if (prop && !(ts.isPropertyAssignment(prop) && ts.isArrayLiteralExpression(prop.initializer))) throw refused(`\`plugins\` in ${file} isn't a plain array, so \`weft add\` can't edit it. Add \`${pkg}\` by hand.`)
|
|
92
|
+
const edits = []
|
|
93
|
+
const imports = sf.statements.filter(ts.isImportDeclaration)
|
|
94
|
+
const after = imports.at(-1)?.end ?? 0
|
|
95
|
+
edits.push([after, `${imports.length ? '\n' : ''}${line}${imports.length ? '' : '\n'}`])
|
|
96
|
+
if (prop) {
|
|
97
|
+
const list = prop.initializer
|
|
98
|
+
const last = list.elements.at(-1)
|
|
99
|
+
edits.push(last ? [last.end, `, ${name}()`] : [list.getStart(sf) + 1, `${name}()`])
|
|
100
|
+
} else {
|
|
101
|
+
const last = object.properties.at(-1)
|
|
102
|
+
edits.push(last ? [object.properties.end, `${object.properties.hasTrailingComma ? '' : ','}\n plugins: [${name}()],`] : [object.getStart(sf) + 1, `\n plugins: [${name}()],\n`])
|
|
103
|
+
}
|
|
104
|
+
return edits.toSorted((a, b) => b[0] - a[0]).reduce((t, [at, insert]) => t.slice(0, at) + insert + t.slice(at), text)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function addToData(file, text, pkg) {
|
|
108
|
+
if (file.endsWith('.json')) {
|
|
109
|
+
const data = JSON.parse(text)
|
|
110
|
+
data.plugins = [...(data.plugins ?? []), pkg]
|
|
111
|
+
return `${JSON.stringify(data, null, 2)}\n`
|
|
112
|
+
}
|
|
113
|
+
const { parseDocument } = createRequire(import.meta.url)('yaml')
|
|
114
|
+
const doc = parseDocument(text)
|
|
115
|
+
if (doc.has('plugins')) doc.addIn(['plugins'], pkg)
|
|
116
|
+
else doc.set('plugins', [pkg])
|
|
117
|
+
return doc.toString()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const listed = (config, pkg) => (config.plugins ?? []).some(p => p.name === pkg)
|
|
121
|
+
|
|
122
|
+
/** The config names the package already (imported or listed), which a read can tell without loading it. */
|
|
123
|
+
const names = (text, pkg) => new RegExp(`['"]${pkg.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')}(/[\\w-]+)?['"]`).test(text)
|
|
124
|
+
|
|
125
|
+
const VITE = ['vite.config.ts', 'vite.config.mts', 'vite.config.js', 'vite.config.mjs']
|
|
126
|
+
|
|
127
|
+
/** The app's Vite config with Tailwind in it. Stops rather than guess at one it can't place the plugin in. */
|
|
128
|
+
function viteWithTailwind(file, text) {
|
|
129
|
+
if (text.includes('@tailwindcss/vite')) return text
|
|
130
|
+
if (!/plugins:\s*\[/.test(text)) throw refused(`${file} has no \`plugins: [\` to add Tailwind to. Add \`tailwindcss()\` from @tailwindcss/vite there, then run this again. Nothing was changed.`)
|
|
131
|
+
return `import tailwindcss from '@tailwindcss/vite'\n${text.replace(/plugins:\s*\[/, '$&tailwindcss(), ')}`
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Tailwind's packages, the Vite plugin, and the top of src/app.css, imported first by the root layout. */
|
|
135
|
+
function addTailwind(root, { dryRun, install }) {
|
|
136
|
+
if (!existsSync(join(root, 'package.json'))) throw refused('`weft add tailwind` needs a package.json. Nothing was changed.')
|
|
137
|
+
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
|
|
138
|
+
const cssFile = join(root, 'src/app.css')
|
|
139
|
+
const css = existsSync(cssFile) ? readFileSync(cssFile, 'utf8') : ''
|
|
140
|
+
const viteName = VITE.find(f => existsSync(join(root, f)))
|
|
141
|
+
const vite = viteName ? viteWithTailwind(viteName, readFileSync(join(root, viteName), 'utf8')) : viteConfig
|
|
142
|
+
const layoutFile = join(root, 'src/routes/_layout.tsx')
|
|
143
|
+
const layout = existsSync(layoutFile) ? readFileSync(layoutFile, 'utf8') : null
|
|
144
|
+
const has = /['"]tailwindcss['"]|tailwindcss\/utilities/.test(css)
|
|
145
|
+
if (has && pkg.dependencies?.tailwindcss) return { ok: true, already: true, package: 'tailwindcss', actions: [] }
|
|
146
|
+
|
|
147
|
+
const actions = []
|
|
148
|
+
const [manager, verb] = installer(root)
|
|
149
|
+
const specs = Object.entries(packages).map(([name, version]) => `${name}@${version}`)
|
|
150
|
+
if (install) {
|
|
151
|
+
actions.push(`install ${specs.join(' ')} with ${manager}`)
|
|
152
|
+
if (!dryRun) installSpecs(root, manager, verb, specs)
|
|
153
|
+
}
|
|
154
|
+
const warp = '@weft-js/warp' in { ...pkg.dependencies, ...pkg.devDependencies }
|
|
155
|
+
const edits = [[join(root, viteName ?? 'vite.config.ts'), vite], ...(has ? [] : [[cssFile, `${cssEntry(warp)}${css && '\n'}${css}`]])]
|
|
156
|
+
actions.push(`add Tailwind to ${viteName ?? 'vite.config.ts'}`, ...(has ? [] : [`add Tailwind to src/app.css${warp ? ' (without its reset, which would override warp)' : ''}`]))
|
|
157
|
+
if (layout === null) actions.push('import src/app.css from your root layout: there is no src/routes/_layout.tsx')
|
|
158
|
+
else if (!/['"][^'"]*app\.css['"]/.test(layout)) {
|
|
159
|
+
edits.push([layoutFile, `import '../app.css'\n${layout}`])
|
|
160
|
+
actions.push('import src/app.css first in src/routes/_layout.tsx')
|
|
161
|
+
}
|
|
162
|
+
if (dryRun) return { ok: true, package: 'tailwindcss', actions, dryRun: true }
|
|
163
|
+
mkdirSync(join(root, 'src'), { recursive: true })
|
|
164
|
+
for (const [file, text] of edits) writeFileSync(file, text)
|
|
165
|
+
return { ok: true, package: 'tailwindcss', actions }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const ENGINES = ['sqlite', 'postgres', 'mysql']
|
|
169
|
+
const LAYER_PLUGIN = { prisma: '@weft-js/db/plugin-prisma', native: '@weft-js/db/plugin-native' }
|
|
170
|
+
|
|
171
|
+
/** A database on an app that has none: the wiring a scaffold writes, with no todo app. Never touches what's there. */
|
|
172
|
+
async function addDb(root, engine = 'sqlite', { layer = 'drizzle', dryRun, install }) {
|
|
173
|
+
if (!ENGINES.includes(engine)) throw usageError(`weft add db takes ${ENGINES.join(', ')}, not \`${engine}\``)
|
|
174
|
+
if (!['drizzle', ...Object.keys(LAYER_PLUGIN)].includes(layer)) throw usageError(`--layer takes drizzle, prisma or native, not \`${layer}\``)
|
|
175
|
+
if (await dbLayer(root)) throw refused('This app already has a database. Changing engine or layer changes your queries, so it\'s yours to do. Nothing was changed.')
|
|
176
|
+
const { stackDeps, stackFiles } = await import('@weft-js/create/stack')
|
|
177
|
+
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
|
|
178
|
+
const name = String(pkg.name ?? 'app').replace(/^@.*\//, '')
|
|
179
|
+
const files = stackFiles({ db: engine, layer, auth: false, name, secret: '' })
|
|
180
|
+
delete files['src/routes/todos/index.tsx']
|
|
181
|
+
delete files['src/server/todos.ts']
|
|
182
|
+
const taken = Object.keys(files).filter(f => f !== '.env' && f !== '.env.example' && existsSync(join(root, f)))
|
|
183
|
+
if (taken.length) throw refused(`${taken.join(', ')} already exist, so this can't add a database without overwriting them. Nothing was changed.`)
|
|
184
|
+
|
|
185
|
+
const { deps, dev } = stackDeps({ db: engine, layer, auth: false })
|
|
186
|
+
const actions = []
|
|
187
|
+
const [manager, verb] = installer(root)
|
|
188
|
+
if (install) {
|
|
189
|
+
const specs = Object.entries({ ...deps, ...dev }).map(([n, v]) => `${n}@${v}`)
|
|
190
|
+
actions.push(`install ${specs.join(' ')} with ${manager}`)
|
|
191
|
+
if (!dryRun) installSpecs(root, manager, verb, specs)
|
|
192
|
+
}
|
|
193
|
+
actions.push(...Object.keys(files).map(f => `write ${f}`))
|
|
194
|
+
if (dryRun) return { ok: true, package: `${layer} on ${engine}`, actions, dryRun: true }
|
|
195
|
+
for (const [file, text] of Object.entries(files)) {
|
|
196
|
+
const path = join(root, file)
|
|
197
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
198
|
+
if (file.startsWith('.env') && existsSync(path)) writeFileSync(path, `${readFileSync(path, 'utf8').trimEnd()}\n${text}`)
|
|
199
|
+
else writeFileSync(path, text)
|
|
200
|
+
}
|
|
201
|
+
if (LAYER_PLUGIN[layer]) actions.push(...(await addCommand(root, LAYER_PLUGIN[layer], { install: false })).actions)
|
|
202
|
+
actions.push('next: `weft db generate`')
|
|
203
|
+
return { ok: true, package: `${layer} on ${engine}`, actions }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Better Auth on an app with a database. Its tables go beside yours, and your own routes stay as they are. */
|
|
207
|
+
async function addAuth(root, { dryRun, install }) {
|
|
208
|
+
const layer = await dbLayer(root)
|
|
209
|
+
if (!layer) throw refused('Better Auth keeps users in a database: run `weft add db` first. Nothing was changed.')
|
|
210
|
+
if (existsSync(join(root, 'src/auth.server.ts'))) throw refused('src/auth.server.ts already exists, so this app has an auth provider. Nothing was changed.')
|
|
211
|
+
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
|
|
212
|
+
const db = database(root, layer).engine
|
|
213
|
+
const ui = '@weft-js/warp' in { ...pkg.dependencies, ...pkg.devDependencies } ? 'warp' : 'none'
|
|
214
|
+
const { authFiles, stackDeps } = await import('@weft-js/create/stack')
|
|
215
|
+
const { files, context, tables } = authFiles({ db, layer: layer.layer, ui })
|
|
216
|
+
const contextFile = join(root, 'src/context.server.ts')
|
|
217
|
+
if (existsSync(contextFile) && !/export default \(\) => \(\{ db \}\)/.test(readFileSync(contextFile, 'utf8'))) throw refused('src/context.server.ts has been changed. Spread `session(auth, request)` from @weft-js/start/auth into it, then run this again. Nothing was changed.')
|
|
218
|
+
const taken = Object.keys(files).filter(f => existsSync(join(root, f)))
|
|
219
|
+
if (taken.length) throw refused(`${taken.join(', ')} already exist. Nothing was changed.`)
|
|
220
|
+
const envModule = join(root, 'src/env.server.ts')
|
|
221
|
+
const envText = existsSync(envModule) ? readFileSync(envModule, 'utf8') : ''
|
|
222
|
+
if (!envText.includes('BETTER_AUTH_SECRET') && !/server: \{\n/.test(envText)) throw refused('src/env.server.ts has no `server: {` block to add BETTER_AUTH_SECRET to. Nothing was changed.')
|
|
223
|
+
|
|
224
|
+
const actions = []
|
|
225
|
+
const [manager, verb] = installer(root)
|
|
226
|
+
const wanted = stackDeps({ db, layer: layer.layer, auth: true }).deps
|
|
227
|
+
const specs = Object.entries(wanted).filter(([name]) => !(name in (pkg.dependencies ?? {}))).map(([n, v]) => `${n}@${v}`)
|
|
228
|
+
if (install && specs.length) {
|
|
229
|
+
actions.push(`install ${specs.join(' ')} with ${manager}`)
|
|
230
|
+
if (!dryRun) installSpecs(root, manager, verb, specs)
|
|
231
|
+
}
|
|
232
|
+
const edits = { ...files, 'src/context.server.ts': context }
|
|
233
|
+
if (!envText.includes('BETTER_AUTH_SECRET')) edits['src/env.server.ts'] = envText.replace('server: {\n', 'server: {\n BETTER_AUTH_SECRET: z.string().min(32),\n')
|
|
234
|
+
if (layer.layer === 'drizzle') {
|
|
235
|
+
edits['src/db/auth.schema.ts'] = tables
|
|
236
|
+
edits['src/db/schema.ts'] = `${readFileSync(join(root, 'src/db/schema.ts'), 'utf8').trimEnd()}\n\nexport * from './auth.schema'\n`
|
|
237
|
+
} else if (layer.layer === 'prisma') {
|
|
238
|
+
edits['prisma/schema.prisma'] = `${readFileSync(join(root, 'prisma/schema.prisma'), 'utf8').trimEnd()}\n${tables}`
|
|
239
|
+
} else {
|
|
240
|
+
const next = existsSync(join(root, 'migrations')) ? readdirSync(join(root, 'migrations')).filter(f => f.endsWith('.sql')).length : 0
|
|
241
|
+
edits[`migrations/${String(next).padStart(4, '0')}_auth.sql`] = tables
|
|
242
|
+
}
|
|
243
|
+
actions.push(...Object.keys(edits).map(f => `write ${f}`))
|
|
244
|
+
if (!dryRun) {
|
|
245
|
+
for (const [file, text] of Object.entries(edits)) {
|
|
246
|
+
mkdirSync(dirname(join(root, file)), { recursive: true })
|
|
247
|
+
writeFileSync(join(root, file), text)
|
|
248
|
+
}
|
|
249
|
+
const env = join(root, '.env')
|
|
250
|
+
if (!(existsSync(env) && readFileSync(env, 'utf8').includes('BETTER_AUTH_SECRET'))) writeFileSync(env, `${existsSync(env) ? `${readFileSync(env, 'utf8').trimEnd()}\n` : ''}BETTER_AUTH_SECRET=${randomBytes(32).toString('base64')}\nBETTER_AUTH_URL=http://localhost:5173\n`)
|
|
251
|
+
}
|
|
252
|
+
actions.push('add BETTER_AUTH_SECRET to .env', ...(layer.schema ? ['next: `weft db generate`'] : []), 'next: reload the app: the migration applies on the next request', "next: add `auth: 'user'` to what needs a user, and link to /login")
|
|
253
|
+
return { ok: true, package: `Better Auth on ${layer.layer}`, actions, ...(dryRun && { dryRun: true }) }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** The plugin's `setup`, once the package is installed: a plain import, since the package can't run anything by being read. */
|
|
257
|
+
async function setupOf(root, pkg) {
|
|
258
|
+
let path
|
|
259
|
+
try {
|
|
260
|
+
path = createRequire(join(root, 'package.json')).resolve(`${pkg}/plugin`)
|
|
261
|
+
} catch {
|
|
262
|
+
return undefined // no plugin entry, or a dry run before the install
|
|
263
|
+
}
|
|
264
|
+
const mod = await import(pathToFileURL(path).href)
|
|
265
|
+
const make = mod.default ?? Object.values(mod).find(v => typeof v === 'function')
|
|
266
|
+
return make?.().contribute({ command: 'check', mode: 'development', root }).setup
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** What `setup` wants, checked in full before anything is written, so a refusal changes nothing. */
|
|
270
|
+
async function planSetup(root, setup) {
|
|
271
|
+
const layer = await dbLayer(root)
|
|
272
|
+
if (setup.needs?.includes('db') && !layer) throw refused('This needs a database: run `weft add db` first. Nothing was changed.')
|
|
273
|
+
if (setup.needs?.includes('auth') && !existsSync(join(root, 'src/auth.server.ts'))) throw refused('This needs users: run `weft add auth` first. Nothing was changed.')
|
|
274
|
+
const read = rel => (existsSync(join(root, rel)) ? readFileSync(join(root, rel), 'utf8') : undefined)
|
|
275
|
+
let plan
|
|
276
|
+
try {
|
|
277
|
+
plan = setup.run({ engine: layer ? database(root, layer).engine : null, layer: layer?.layer ?? null, read })
|
|
278
|
+
} catch (error) {
|
|
279
|
+
throw refused(`${error.message}. Nothing was changed.`)
|
|
280
|
+
}
|
|
281
|
+
const taken = Object.keys(plan.files ?? {}).filter(f => existsSync(join(root, f)))
|
|
282
|
+
if (taken.length) throw refused(`${taken.join(', ')} already exist. Nothing was changed.`)
|
|
283
|
+
return plan
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function applySetup(root, plan) {
|
|
287
|
+
for (const [file, text] of Object.entries({ ...plan.files, ...plan.edits })) {
|
|
288
|
+
mkdirSync(dirname(join(root, file)), { recursive: true })
|
|
289
|
+
writeFileSync(join(root, file), text)
|
|
290
|
+
}
|
|
291
|
+
for (const name of ['.env', '.env.example']) {
|
|
292
|
+
const file = join(root, name)
|
|
293
|
+
const had = existsSync(file) ? readFileSync(file, 'utf8') : ''
|
|
294
|
+
const lines = Object.entries(plan.env ?? {}).filter(([k]) => !had.includes(`${k}=`)).map(([k, v]) => `${k}=${v}\n`)
|
|
295
|
+
if (lines.length) writeFileSync(file, `${had && `${had.trimEnd()}\n`}${lines.join('')}`)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const setupActions = plan => [...Object.keys({ ...plan.files, ...plan.edits }).map(f => `write ${f}`), ...(plan.env ? [`add ${Object.keys(plan.env).join(', ')} to .env and .env.example`] : []), ...(plan.next ?? []).map(n => `next: ${n}`)]
|
|
300
|
+
|
|
301
|
+
export async function addCommand(root, feature, { dryRun = false, install = true, engine, layer, remote = false } = {}) {
|
|
302
|
+
root = resolve(root)
|
|
303
|
+
if (!feature) throw usageError(`weft add needs a feature: db, auth, tailwind, rust, ${Object.keys(ALIASES).join(', ')}, or a package name`)
|
|
304
|
+
if (feature === 'rust') {
|
|
305
|
+
const [manager, verb] = installer(root)
|
|
306
|
+
return addRust(root, engine, { remote, dryRun, install, installSpecs: specs => installSpecs(root, manager, verb, specs) })
|
|
307
|
+
}
|
|
308
|
+
if (feature === 'tailwind') return addTailwind(root, { dryRun, install })
|
|
309
|
+
if (feature === 'db') return addDb(root, engine, { layer, dryRun, install })
|
|
310
|
+
if (feature === 'auth') return addAuth(root, { dryRun, install })
|
|
311
|
+
const pkg = ALIASES[feature] ?? (ADDONS.has(`@weft-js/${feature}`) ? `@weft-js/${feature}` : feature)
|
|
312
|
+
const file = findConfig(root) ?? join(root, 'weft.config.ts')
|
|
313
|
+
const config = relative(root, file)
|
|
314
|
+
const text = existsSync(file) ? readFileSync(file, 'utf8') : "import { defineConfig } from '@weft-js/core/config'\n\nexport default defineConfig({})\n"
|
|
315
|
+
const named = names(text, pkg)
|
|
316
|
+
// A config can name a package that isn't installed yet, and importing it then would leave this process's module
|
|
317
|
+
// resolver sure the package has no `exports`, so the load after the install would fail too. Load it once it's there.
|
|
318
|
+
const present = existsSync(join(root, 'node_modules', packageOf(pkg), 'package.json'))
|
|
319
|
+
if (present && (named || listed((await loadConfig(root)).config, pkg))) return { ok: true, already: true, package: pkg, config, actions: [] }
|
|
320
|
+
|
|
321
|
+
const home = packageOf(pkg)
|
|
322
|
+
|
|
323
|
+
const actions = []
|
|
324
|
+
const [manager, verb] = installer(root)
|
|
325
|
+
if (install) {
|
|
326
|
+
actions.push(`install ${home} with ${manager}`)
|
|
327
|
+
if (!dryRun) installSpecs(root, manager, verb, [home])
|
|
328
|
+
}
|
|
329
|
+
const setup = await setupOf(root, pkg)
|
|
330
|
+
const plan = setup && await planSetup(root, setup)
|
|
331
|
+
const next = named ? text : /\.(json|ya?ml)$/.test(file) ? addToData(file, text, pkg) : addToTs(root, file, text, pkg)
|
|
332
|
+
if (!named) actions.push(`add ${pkg} to ${config}`)
|
|
333
|
+
if (dryRun) return { ok: true, package: pkg, config, actions: [...actions, ...(plan ? setupActions(plan) : [])], dryRun: true }
|
|
334
|
+
|
|
335
|
+
writeFileSync(file, next)
|
|
336
|
+
const after = await loadConfig(root)
|
|
337
|
+
const failed = after.diagnostics.filter(d => d.severity === 'error')
|
|
338
|
+
if (failed.length) {
|
|
339
|
+
writeFileSync(file, text)
|
|
340
|
+
throw refused(`${config} doesn't load with ${pkg} in it${install ? ', though the package installed' : ''}, so it's back as it was: ${failed[0].code} ${failed[0].why}`)
|
|
341
|
+
}
|
|
342
|
+
sync(root, after)
|
|
343
|
+
actions.push('regenerate .weft/slots')
|
|
344
|
+
if (plan) {
|
|
345
|
+
for (const other of setup.with ?? []) actions.push(...(await addCommand(root, other, { install })).actions)
|
|
346
|
+
applySetup(root, plan)
|
|
347
|
+
actions.push(...setupActions(plan))
|
|
348
|
+
}
|
|
349
|
+
// Never approved here: installing a plugin isn't having read what it asks for.
|
|
350
|
+
const trust = trustStatus(root, after).plugins.find(p => p.name === pkg)
|
|
351
|
+
if (existsSync(join(root, 'AGENTS.md'))) {
|
|
352
|
+
await writeAgentsMd(root)
|
|
353
|
+
actions.push('regenerate AGENTS.md')
|
|
354
|
+
}
|
|
355
|
+
actions.push(...(after.contributions?.find(c => c.name === pkg)?.manifest?.notes ?? []))
|
|
356
|
+
if (trust?.blocked) actions.push(`${pkg} asks for ${[...trust.added, ...trust.hidden.map(n => `${n} (undeclared)`)].join(', ') || 'a code change'}; \`weft check\` stops until you run \`weft plugins approve ${pkg}\``)
|
|
357
|
+
return { ok: true, package: pkg, config, actions, ...(trust?.blocked && { approve: { added: trust.added, undeclared: trust.hidden } }) }
|
|
358
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// `weft agents-md`: AGENTS.md generated from the installed weft (exports, the codes its compiler reports) and this
|
|
2
|
+
// project's tasks. Under 8 KB (PLAN §7). Never edited by hand: `--check` fails when it is stale.
|
|
3
|
+
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
5
|
+
import { join, resolve } from 'node:path'
|
|
6
|
+
import { sourceAlias } from './alias.mjs'
|
|
7
|
+
import { catalog } from './catalog.mjs'
|
|
8
|
+
import { commandsPage, installed, readDoc } from './docs.mjs'
|
|
9
|
+
import { loadConfig } from './load-config.mjs'
|
|
10
|
+
import { agentsSection } from './contributions.mjs'
|
|
11
|
+
import { tasks } from './tasks.mjs'
|
|
12
|
+
|
|
13
|
+
export const limit = 8 * 1024
|
|
14
|
+
|
|
15
|
+
/** `T001, T002, T003, P007` → `T001–T003, P007`: runs of consecutive codes as ranges (three or more). */
|
|
16
|
+
export function ranges(codes) {
|
|
17
|
+
const out = []
|
|
18
|
+
for (let i = 0; i < codes.length; ) {
|
|
19
|
+
let j = i
|
|
20
|
+
const num = c => Number(c.slice(1))
|
|
21
|
+
while (j + 1 < codes.length && codes[j + 1][0] === codes[i][0] && num(codes[j + 1]) === num(codes[j]) + 1) j++
|
|
22
|
+
out.push(j - i >= 2 ? `${codes[i]}–${codes[j]}` : codes.slice(i, j + 1).join(', '))
|
|
23
|
+
i = j + 1
|
|
24
|
+
}
|
|
25
|
+
return out.join(', ')
|
|
26
|
+
}
|
|
27
|
+
/** PLAN §3.1 primitives an agent may reach for; listed as missing when the installed weft doesn't export them. */
|
|
28
|
+
const planned = ['state', 'derived', 'resource', 'Boundary', 'watch', 'onMount', 'onCleanup', 'context', 'defineState', 'flush', 'untrack', 'batch']
|
|
29
|
+
/** Commands AGENTS.md leaves to `weft tasks --json`: rarely run by hand, or taught in their own section. */
|
|
30
|
+
const elsewhere = new Set(['agents-md', 'doctor', 'mcp', 'plugins', 'start', 'style', 'surfaces', 'tasks', 'tokens'])
|
|
31
|
+
/** The app-facing part of subpaths whose other exports are helpers compiled code calls. */
|
|
32
|
+
const entryPoints = { '@weft-js/core/dom': ['render', 'hydrate'], '@weft-js/core/ssr': ['renderToString', 'renderToStringAsync'], '@weft-js/core/browser': ['online', 'visible', 'lifecycle', 'media', 'on', 'interval'] }
|
|
33
|
+
|
|
34
|
+
/** The Full stack section, with this app's alias for its source. */
|
|
35
|
+
function fullStack(root) {
|
|
36
|
+
const alias = sourceAlias(root)
|
|
37
|
+
const text = readDoc('start-short')
|
|
38
|
+
return alias ? text.replaceAll('#/', alias) : text.replace(/ Import your own code as [^(]*\(`src\/server\/todos`\); generated files are/, ' Generated files are')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** This project's AGENTS.md, written from what the installed weft reports: the text, where it goes, and whether weft's own part is over the limit. */
|
|
42
|
+
export async function agentsMd(root) {
|
|
43
|
+
root = resolve(root)
|
|
44
|
+
const info = installed(root)
|
|
45
|
+
const list = await tasks(root)
|
|
46
|
+
const sections = (await loadConfig(root)).contributions.filter(c => c.manifest.agents).map(c => agentsSection(c.manifest.agents))
|
|
47
|
+
const core = info.exports['@weft-js/core'] ?? []
|
|
48
|
+
const missing = planned.filter(n => !core.includes(n))
|
|
49
|
+
|
|
50
|
+
// Style-group codes (D0xx, W060) and `weft style` belong to the Styling section, which only a weft with @weft-js/core/style gets.
|
|
51
|
+
const styled = info.exports['@weft-js/core/style']?.includes('group')
|
|
52
|
+
const dcodes = catalog.filter(e => e.code.startsWith('D0') && (e.by === 'cli' || info.status[e.code]?.implemented)).map(e => e.code).sort()
|
|
53
|
+
const startApp = existsSync(join(root, 'src', 'routes'))
|
|
54
|
+
const tcodes = catalog.filter(e => e.code.startsWith('T') && info.status[e.code]?.implemented).map(e => e.code)
|
|
55
|
+
|
|
56
|
+
// with plugins: the `weft plugins` command and their sections; without, neither
|
|
57
|
+
const page = plugins => [
|
|
58
|
+
`# AGENTS.md`,
|
|
59
|
+
'',
|
|
60
|
+
`<!-- Generated by \`weft agents-md\` from weft@${info.weft.version}. Do not edit: change weft.config.ts and regenerate. -->`,
|
|
61
|
+
'',
|
|
62
|
+
`This is a **weft** app. It is not React: components run once, state is a box, and`,
|
|
63
|
+
`React hooks don't exist. Run \`weft check --json\` after every change; each diagnostic gives code, location, why and fix.`,
|
|
64
|
+
'',
|
|
65
|
+
'Start with `.weft/map.md` (`weft map`): what this app has and what to call.',
|
|
66
|
+
'',
|
|
67
|
+
'## Rules',
|
|
68
|
+
'',
|
|
69
|
+
readDoc('rules'),
|
|
70
|
+
...(core.includes('resource')
|
|
71
|
+
? ['10. **Async data is `resource(signal => fetch(…))`** (`.value .pending .error .refresh()`), never `derived(async …)` (W012).',
|
|
72
|
+
core.includes('Boundary') ? ' Loading and errors: `<Boundary pending={…} error={(e, retry) => …}>`. More: `weft docs async`.' : '']
|
|
73
|
+
: []),
|
|
74
|
+
'',
|
|
75
|
+
'**Leave props untyped**: `weft docs props`.',
|
|
76
|
+
'',
|
|
77
|
+
`## API in weft@${info.weft.version}`,
|
|
78
|
+
'',
|
|
79
|
+
`- \`@weft-js/core\`: ${core.filter(n => planned.includes(n)).map(n => `\`${n}\``).join(', ')}` + (core.includes('state') ? ', `state.linked`' : ''),
|
|
80
|
+
...['@weft-js/core/dom', '@weft-js/core/ssr'].map((mod, i, all) => {
|
|
81
|
+
const names = entryPoints[mod].filter(n => info.exports[mod]?.includes(n))
|
|
82
|
+
const last = all.slice(i + 1).every(m => !entryPoints[m].some(n => info.exports[m]?.includes(n)))
|
|
83
|
+
return names.length ? `- \`${mod}\`: ${names.map(n => `\`${n}\``).join(', ')}.${last ? ' Never import the rest.' : ''}` : ''
|
|
84
|
+
}).filter(Boolean),
|
|
85
|
+
info.exports['@weft-js/core/browser'] ? `- \`@weft-js/core/browser\`: ${entryPoints['@weft-js/core/browser'].filter(n => info.exports['@weft-js/core/browser'].includes(n)).map(n => `\`${n}\``).join(', ')}. Create them in setup (W051); server-rendered \`media\` needs \`ssr\` (W054). More: \`weft docs browser\`; hover and press as state: \`weft docs ui-state\`.` : '',
|
|
86
|
+
info.exports['@weft-js/core/surface'] ? '- `@weft-js/core/surface`: canvas and WebGL, never raw `<canvas>`: `weft docs surface`.' : '',
|
|
87
|
+
info.exports['@weft-js/core/test'] ? `- \`@weft-js/core/test\`: ${info.exports['@weft-js/core/test'].filter(n => !['canvasCalls', 'cleanup', 'fakeMeasure', 'leaks'].includes(n)).map(n => `\`${n}\``).join(', ')}, and \`expect(fn).toTouchAtMost(n, { runs })\`. Query by role/label/text; \`user\` actions and \`clock.advance\` end in \`flush()\`, so never sleep or \`waitFor\`.` : '',
|
|
88
|
+
missing.length ? `- Not in this version yet: ${missing.map(n => `\`${n}\``).join(', ')}. Don't import them.` : '',
|
|
89
|
+
'',
|
|
90
|
+
'## Diagnostics',
|
|
91
|
+
'',
|
|
92
|
+
'`weft docs <code>` explains one. Also flagged: raw `addEventListener`, `setInterval` or sockets with no cleanup (W050), and a `watch` that folds a stream (W052) or copies a live value into `state` (W056).',
|
|
93
|
+
'',
|
|
94
|
+
tcodes.length ? `Test checks (\`weft check\` on test files): ${ranges(tcodes)}. No assertion, tautologies, sleeps, \`container\`/selectors/test ids, mocking weft, snapshots, conditional \`expect\`, \`.only\`/\`.skip\`. To allow one: \`// weft-allow T00x: <reason>\`.` : '',
|
|
95
|
+
'',
|
|
96
|
+
'Before a change is done, see it work (`weft browser snapshot|act|assert`, or `__weft__.agent` in your own browser) and run `weft test --gate`: new tests must fail on the old code and kill a mutant (T010, T011, T020). `weft build` ends by auditing the pages it built at a phone\'s and a desktop\'s width (L-codes: names, contrast, console, the head, sideways scroll, layout shift) and fails on an error; `weft audit --url <deploy>` checks a live site. More: `weft docs verify`.',
|
|
97
|
+
'',
|
|
98
|
+
...(startApp
|
|
99
|
+
? ['## Full stack', '', fullStack(root), '']
|
|
100
|
+
: []),
|
|
101
|
+
...(styled
|
|
102
|
+
? ['## Styling',
|
|
103
|
+
'',
|
|
104
|
+
`**Run \`weft style find <what it is>\` first**, then reuse a group or add an axis value; never class lists. New: \`const card = group('card', { base: { pad: 'group' }, axes: { status: { late: { tone: 'critical' } } } })\` from \`@weft-js/core/style\`, \`<li {...card({ status })}>\`. Tokens only. ${ranges(dcodes)}, W060: \`weft docs style\`, \`weft docs design\`; \`weft fix\` applies the safe fixes.`,
|
|
105
|
+
'']
|
|
106
|
+
: []),
|
|
107
|
+
'## Commands',
|
|
108
|
+
'',
|
|
109
|
+
commandsPage({ ...list, tasks: list.tasks.filter(t => !elsewhere.has(t.name)) }, { planned: false }),
|
|
110
|
+
'',
|
|
111
|
+
'`weft tasks --json` lists the rest.',
|
|
112
|
+
'',
|
|
113
|
+
...(plugins.length ? ['## Plugins', '', ...plugins] : []),
|
|
114
|
+
'## More',
|
|
115
|
+
'',
|
|
116
|
+
`\`weft docs <topic>\` or \`weft mcp\`: \`rules\`, \`props\`, \`lists\`, \`async\`, \`testing\`, \`browser\`, \`verify\`, ${startApp ? '`start`, ' : ''}${info.exports['@weft-js/core/style'] ? '`style`, ' : ''}${info.exports['@weft-js/core/surface'] ? '`surface`, ' : ''}\`config\`, \`diagnostics\`, \`commands\`.`,
|
|
117
|
+
'',
|
|
118
|
+
]
|
|
119
|
+
.filter((line, i, all) => !(line === '' && all[i - 1] === ''))
|
|
120
|
+
.join('\n')
|
|
121
|
+
|
|
122
|
+
const text = page(sections)
|
|
123
|
+
const bytes = Buffer.byteLength(text)
|
|
124
|
+
// the limit is weft's own text: plugin sections have their own 1 KB each (P112) on top
|
|
125
|
+
return { path: join(root, 'AGENTS.md'), text, bytes, overLimit: Buffer.byteLength(page([])) > limit }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Write AGENTS.md, or with `check` only compare. Returns whether the file on disk was current. */
|
|
129
|
+
export async function writeAgentsMd(root, { check = false } = {}) {
|
|
130
|
+
const out = await agentsMd(root)
|
|
131
|
+
const current = existsSync(out.path) && readFileSync(out.path, 'utf8') === out.text
|
|
132
|
+
if (!check && !current) writeFileSync(out.path, out.text)
|
|
133
|
+
return { ...out, current, written: !check && !current }
|
|
134
|
+
}
|
package/src/alias.mjs
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// An app's import aliases are its own: tsconfig `paths` (which Vite follows too), plus package.json `imports` if it has
|
|
2
|
+
// them. `#/*` is only what create-weft writes. `local` is how the checks follow a project import, so an aliased file
|
|
3
|
+
// gets the same analysis as a relative one.
|
|
4
|
+
|
|
5
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
6
|
+
import { dirname, join, resolve } from 'node:path'
|
|
7
|
+
import ts from 'typescript'
|
|
8
|
+
|
|
9
|
+
const cache = new Map()
|
|
10
|
+
|
|
11
|
+
/** `{ prefix, suffix, target }` for each alias declared in `dir`, longest prefix first; `target` has a `*` to fill. */
|
|
12
|
+
function rulesIn(dir) {
|
|
13
|
+
const rules = []
|
|
14
|
+
const add = (pattern, target) => {
|
|
15
|
+
const [prefix, suffix = ''] = pattern.split('*')
|
|
16
|
+
rules.push({ prefix, suffix, exact: !pattern.includes('*'), target })
|
|
17
|
+
}
|
|
18
|
+
const tsconfig = join(dir, 'tsconfig.json')
|
|
19
|
+
if (existsSync(tsconfig)) {
|
|
20
|
+
const host = { ...ts.sys, readDirectory: () => [] }
|
|
21
|
+
const { options } = ts.parseJsonConfigFileContent(ts.readConfigFile(tsconfig, path => ts.sys.readFile(path)).config, host, dir)
|
|
22
|
+
const base = options.pathsBasePath ?? options.baseUrl ?? dir
|
|
23
|
+
for (const [pattern, [target]] of Object.entries(options.paths ?? {})) add(pattern, resolve(base, target))
|
|
24
|
+
}
|
|
25
|
+
const pkg = join(dir, 'package.json')
|
|
26
|
+
if (existsSync(pkg)) {
|
|
27
|
+
for (const [pattern, target] of Object.entries(JSON.parse(readFileSync(pkg, 'utf8')).imports ?? {})) {
|
|
28
|
+
if (typeof target === 'string' && target.startsWith('.')) add(pattern, resolve(dir, target))
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return rules.sort((a, b) => b.prefix.length - a.prefix.length)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The rules of the nearest project root above `dir`. */
|
|
35
|
+
function rulesFor(dir) {
|
|
36
|
+
if (cache.has(dir)) return cache.get(dir)
|
|
37
|
+
const parent = dirname(dir)
|
|
38
|
+
const isRoot = existsSync(join(dir, 'tsconfig.json')) || existsSync(join(dir, 'package.json'))
|
|
39
|
+
const rules = isRoot ? rulesIn(dir) : parent === dir ? [] : rulesFor(parent)
|
|
40
|
+
cache.set(dir, rules)
|
|
41
|
+
return rules
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The absolute path a project import points at, before extensions; null for a package. */
|
|
45
|
+
export function local(fromAbs, spec) {
|
|
46
|
+
if (spec.startsWith('.')) return join(dirname(fromAbs), spec)
|
|
47
|
+
for (const { prefix, suffix, exact, target } of rulesFor(dirname(fromAbs))) {
|
|
48
|
+
if (exact ? spec === prefix : spec.startsWith(prefix) && spec.endsWith(suffix)) {
|
|
49
|
+
return exact ? target : target.replace('*', spec.slice(prefix.length, spec.length - suffix.length))
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The prefix that maps to `<root>/src/`, like `#/`; null when the app has none. */
|
|
56
|
+
export function sourceAlias(root) {
|
|
57
|
+
const rule = rulesFor(resolve(root)).find(r => r.target === join(resolve(root), 'src', '*') && !r.suffix)
|
|
58
|
+
return rule?.prefix ?? null
|
|
59
|
+
}
|