返回 slidev
resolver.ts
根目录 / packages / slidev / node / resolver.ts
1 import type { RootsInfo } from '@slidev/types'
2 import { existsSync, lstatSync, readdirSync, readlinkSync } from 'node:fs'
3 import { copyFile, readFile } from 'node:fs/promises'
4 import process from 'node:process'
5 import { fileURLToPath, pathToFileURL } from 'node:url'
6 import { parseNi, run } from '@antfu/ni'
7 import { ensurePrefix, slash } from '@antfu/utils'
8 import { underline, yellow } from 'ansis'
9 import globalDirs from 'global-directory'
10 import { resolve as resolveModuleUrl, resolvePath } from 'mlly'
11 import { dirname, join, relative, resolve, sep } from 'pathe'
12 import prompts from 'prompts'
13 import { resolveGlobal } from 'resolve-global'
14 import { findClosestPkgJsonPath, findDepPkgJsonPath } from 'vitefu'
15
16 const RE_PATH_SEPARATOR = /[/\\]/
17 const RE_SAFE_PKG_NAME = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/
18
19 const cliRoot = fileURLToPath(new URL('..', import.meta.url))
20
21 /**
22 * Detect the `node_modules/` directory the running slidev binary was
23 * dispatched from. The simple `findDepPkgJsonPath(name, cliRoot)` walk fails
24 * in pnpm v11+ global installs because `cliRoot` is the realpath inside the
25 * content-addressable store, with no path back to the install directory. The
26 * invocation path (`process.argv[1]`), however, is one of:
27 *
28 * - `{PNPM_HOME}/v11/{hash}/node_modules/@slidev/cli/bin/slidev.mjs` — pnpm
29 * v11 globals dispatch through a shell shim in `{PNPM_HOME}/bin/` that exec's
30 * `node` with this argv[1].
31 * - `{globals}/node_modules/@slidev/cli/bin/slidev.mjs` — npm/yarn globals,
32 * where the bin is a fs symlink and the resolved script path keeps the
33 * `node_modules` segment.
34 * - `./node_modules/.bin/slidev` — local project, where Node resolves the
35 * `.bin` symlink before populating argv[1].
36 *
37 * Returns the trailing `node_modules` directory of that path, or `undefined`
38 * if `argv1` doesn't pass through one.
39 *
40 * Exported for tests.
41 */
42 export function findInvocationNodeModulesPath(argv1: string | undefined): string | undefined {
43 if (!argv1)
44 return undefined
45 // Direct hit on the literal path passed to Node.
46 const direct = resolve(argv1)
47 const segment = `${sep}node_modules${sep}`
48 const directIdx = direct.lastIndexOf(segment)
49 if (directIdx >= 0)
50 return direct.slice(0, directIdx + 1 + 'node_modules'.length)
51 // Fall back to following an fs symlink chain (covers the legacy `.bin`
52 // symlink layout where argv[1] is a path *to* the symlink itself).
53 let current = direct
54 for (let i = 0; i < 16; i++) {
55 let stat
56 try {
57 stat = lstatSync(current)
58 }
59 catch {
60 return undefined
61 }
62 if (!stat.isSymbolicLink())
63 return undefined
64 const target = readlinkSync(current)
65 // `readlinkSync` returns the target verbatim — possibly with the *wrong*
66 // separator on Windows (e.g. a target written with `/` by a cross-platform
67 // tool). Resolve through the symlink's directory in every case so the
68 // separator-sensitive `node_modules` scan below sees a platform-normalized
69 // path.
70 current = resolve(dirname(current), target)
71 const idx = current.lastIndexOf(segment)
72 if (idx >= 0)
73 return current.slice(0, idx + 1 + 'node_modules'.length)
74 }
75 return undefined
76 }
77
78 /**
79 * Candidate `node_modules` directories to search when slidev is installed
80 * globally. Always includes the invocation's own `node_modules` (the install
81 * group `@slidev/cli` lives in), plus every sibling install group's
82 * `node_modules` so cross-group lookups can succeed.
83 *
84 * pnpm v11 lays globals out as `{root}/v11/{hash}/node_modules/...`; each
85 * `pnpm add -g <pkg>` invocation creates its own `{hash}` directory even
86 * when multiple packages are listed in one call. Walking the parent of the
87 * cli's install group lets us discover packages that landed in sibling
88 * groups (e.g. a theme installed alongside `@slidev/cli`).
89 *
90 * Exported for tests.
91 */
92 export function computeInvocationSearchPaths(ownNodeModules: string | undefined): string[] {
93 if (!ownNodeModules)
94 return []
95 const paths = [ownNodeModules]
96 const installDir = dirname(ownNodeModules)
97 const globalRoot = dirname(installDir)
98 if (!globalRoot || globalRoot === installDir)
99 return paths
100 let siblings: string[]
101 try {
102 siblings = readdirSync(globalRoot)
103 }
104 catch {
105 return paths
106 }
107 for (const sib of siblings) {
108 const sibInstall = join(globalRoot, sib)
109 if (sibInstall === installDir)
110 continue
111 const sibNm = join(sibInstall, 'node_modules')
112 try {
113 if (lstatSync(sibNm).isDirectory())
114 paths.push(sibNm)
115 }
116 catch { }
117 }
118 return paths
119 }
120
121 const invocationNodeModules = findInvocationNodeModulesPath(process.argv[1])
122 const invocationSearchPaths = computeInvocationSearchPaths(invocationNodeModules)
123
124 export const isInstalledGlobally: { value?: boolean } = {}
125
126 /**
127 * Resolve path for import url on Vite client side
128 */
129 export async function resolveImportUrl(id: string) {
130 return toAtFS(await resolveImportPath(id, true))
131 }
132
133 export function toAtFS(path: string) {
134 return `/@fs${ensurePrefix('/', slash(path))}`
135 }
136
137 /**
138 * Find the actual path of the import. If Slidev is installed globally, it will also search globally.
139 */
140 export async function resolveImportPath(importName: string, ensure: true): Promise<string>
141 export async function resolveImportPath(importName: string, ensure?: boolean): Promise<string | undefined>
142 export async function resolveImportPath(importName: string, ensure = false) {
143 try {
144 return await resolvePath(importName, {
145 url: import.meta.url,
146 })
147 }
148 catch { }
149
150 if (isInstalledGlobally.value) {
151 for (const nm of invocationSearchPaths) {
152 try {
153 return await resolvePath(importName, {
154 url: pathToFileURL(`${nm}${sep}`),
155 })
156 }
157 catch { }
158 }
159 try {
160 return resolveGlobal(importName)
161 }
162 catch { }
163 }
164
165 if (ensure)
166 throw new Error(`Failed to resolve package "${importName}"`)
167 }
168
169 /**
170 * Import an optional dependency (typically an optional peer dependency such as
171 * `vite-plugin-pwa`) that the user may or may not have installed. Resolution is
172 * attempted, in order, from the user's project root, the workspace root, the
173 * global registry (when Slidev runs globally), and finally the cli's own
174 * dependencies. Returns `undefined` when the package can't be resolved anywhere.
175 */
176 export async function importOptionalDependency<T = any>(name: string): Promise<T | undefined> {
177 const roots: string[] = []
178 try {
179 const { userRoot, userWorkspaceRoot } = await getRoots()
180 roots.push(userRoot)
181 if (userWorkspaceRoot !== userRoot)
182 roots.push(userWorkspaceRoot)
183 }
184 catch { }
185
186 for (const root of roots) {
187 try {
188 return await import(await resolveModuleUrl(name, { url: pathToFileURL(`${root}${sep}`).href }))
189 }
190 catch { }
191 }
192
193 if (isInstalledGlobally.value) {
194 try {
195 return await import(resolveGlobal(name))
196 }
197 catch { }
198 }
199
200 try {
201 return await import(name)
202 }
203 catch { }
204
205 return undefined
206 }
207
208 /**
209 * Prompt the user to install a missing optional dependency, then install it
210 * with the detected package manager. Exits the process when the user declines
211 * or when stdin isn't interactive (so it can't prompt).
212 *
213 * `purpose` describes why the package is needed, e.g. `The "pwa" option`.
214 */
215 export async function promptForOptionalInstallation(pkgName: string, purpose: string): Promise<void> {
216 // Check if stdin is available for prompts (i.e., is a TTY)
217 if (!process.stdin.isTTY) {
218 console.error(
219 `${purpose} requires the "${pkgName}" package, which is not installed, and cannot prompt for installation. `
220 + `Install it with \`npm i -D ${pkgName}\` (or your package manager's equivalent) and try again.`,
221 )
222 process.exit(1)
223 }
224
225 const { confirm } = await prompts({
226 name: 'confirm',
227 initial: 'Y',
228 type: 'confirm',
229 message: `${purpose} requires the "${yellow(pkgName)}" package, which is not installed ${underline(isInstalledGlobally.value ? 'globally' : 'in your project')}. Install it now?`,
230 })
231
232 if (!confirm)
233 process.exit(1)
234
235 if (isInstalledGlobally.value)
236 await run(parseNi, ['-g', pkgName])
237 else
238 await run(parseNi, [pkgName])
239 }
240
241 /**
242 * Find the root of the package. If Slidev is installed globally, it will also search globally.
243 */
244 export async function findPkgRoot(dep: string, parent: string, ensure: true): Promise<string>
245 export async function findPkgRoot(dep: string, parent: string, ensure?: boolean): Promise<string | undefined>
246 export async function findPkgRoot(dep: string, parent: string, ensure = false) {
247 const pkgJsonPath = await findDepPkgJsonPath(dep, parent)
248 const path = pkgJsonPath ? dirname(pkgJsonPath) : isInstalledGlobally.value ? await findGlobalPkgRoot(dep, false) : undefined
249 if (ensure && !path)
250 throw new Error(`Failed to resolve package "${dep}"`)
251 return path
252 }
253
254 export async function findGlobalPkgRoot(name: string, ensure: true): Promise<string>
255 export async function findGlobalPkgRoot(name: string, ensure?: boolean): Promise<string | undefined>
256 export async function findGlobalPkgRoot(name: string, ensure = false) {
257 const localPath = await findDepPkgJsonPath(name, cliRoot)
258 if (localPath)
259 return dirname(localPath)
260 for (const nm of invocationSearchPaths) {
261 const direct = join(nm, ...name.split('/'), 'package.json')
262 if (existsSync(direct))
263 return dirname(direct)
264 const walked = await findDepPkgJsonPath(name, nm)
265 if (walked)
266 return dirname(walked)
267 }
268 const yarnPath = join(globalDirs.yarn.packages, name)
269 if (existsSync(`${yarnPath}/package.json`))
270 return yarnPath
271 const npmPath = join(globalDirs.npm.packages, name)
272 if (existsSync(`${npmPath}/package.json`))
273 return npmPath
274 if (ensure)
275 throw new Error(`Failed to resolve global package "${name}"`)
276 }
277
278 export async function resolveEntry(entryRaw: string) {
279 if (!existsSync(entryRaw) && !entryRaw.endsWith('.md') && !RE_PATH_SEPARATOR.test(entryRaw))
280 entryRaw += '.md'
281 const entry = resolve(entryRaw)
282 if (!existsSync(entry)) {
283 // Check if stdin is available for prompts (i.e., is a TTY)
284 if (!process.stdin.isTTY) {
285 console.error(`Entry file "${entry}" does not exist and cannot prompt for confirmation`)
286 process.exit(1)
287 }
288 const { create } = await prompts({
289 name: 'create',
290 type: 'confirm',
291 initial: 'Y',
292 message: `Entry file ${yellow(`"${entry}"`)} does not exist, do you want to create it?`,
293 })
294 if (create)
295 await copyFile(resolve(cliRoot, 'template.md'), entry)
296 else
297 process.exit(0)
298 }
299 return slash(entry)
300 }
301
302 /**
303 * Create a resolver for theme or addon
304 */
305 export function createResolver(type: 'theme' | 'addon', officials: Record<string, string>) {
306 async function promptForInstallation(pkgName: string) {
307 // Check if stdin is available for prompts (i.e., is a TTY)
308 if (!process.stdin.isTTY) {
309 console.error(`The ${type} "${pkgName}" was not found and cannot prompt for installation`)
310 process.exit(1)
311 }
312
313 const { confirm } = await prompts({
314 name: 'confirm',
315 initial: 'Y',
316 type: 'confirm',
317 message: `The ${type} "${pkgName}" was not found ${underline(isInstalledGlobally.value ? 'globally' : 'in your project')}, do you want to install it now?`,
318 })
319
320 if (!confirm)
321 process.exit(1)
322
323 if (isInstalledGlobally.value)
324 await run(parseNi, ['-g', pkgName])
325 else
326 await run(parseNi, [pkgName])
327 }
328
329 return async function (name: string, importer: string): Promise<[name: string, root: string | null]> {
330 const { userRoot } = await getRoots()
331
332 if (name === 'none')
333 return ['', null]
334
335 // local path
336 if (name[0] === '/')
337 return [name, name]
338 if (name.startsWith('@/'))
339 return [name, resolve(userRoot, name.slice(2))]
340 if (name[0] === '.' || (name[0] !== '@' && name.includes('/')))
341 return [name, resolve(dirname(importer), name)]
342
343 // Validate that the name is a safe npm package name before resolving
344 if (!RE_SAFE_PKG_NAME.test(name))
345 throw new Error(`Invalid ${type} name "${name}". Only valid npm package names are allowed.`)
346
347 // search for local packages first
348 {
349 const possiblePkgNames = [name]
350
351 if (!name.includes('/') && !name.startsWith('@')) {
352 possiblePkgNames.unshift(
353 `@slidev/${type}-${name}`,
354 `slidev-${type}-${name}`,
355 )
356 }
357
358 for (const pkgName of possiblePkgNames) {
359 const pkgRoot = await findPkgRoot(pkgName, importer)
360 if (pkgRoot)
361 return [pkgName, pkgRoot]
362 }
363 }
364
365 // fallback to prompt install
366 const pkgName = officials[name] ?? (name[0] === '@' ? name : `slidev-${type}-${name}`)
367 await promptForInstallation(pkgName)
368 return [pkgName, await findPkgRoot(pkgName, importer, true)]
369 }
370 }
371
372 async function getUserPkgJson(userRoot: string) {
373 const path = resolve(userRoot, 'package.json')
374 if (existsSync(path))
375 return JSON.parse(await readFile(path, 'utf-8')) as Record<string, any>
376 return {}
377 }
378
379 // npm: https://docs.npmjs.com/cli/v7/using-npm/workspaces#installing-workspaces
380 // yarn: https://classic.yarnpkg.com/en/docs/workspaces/#toc-how-to-use-it
381 async function hasWorkspacePackageJSON(root: string): Promise<boolean> {
382 const path = join(root, 'package.json')
383 if (!existsSync(path))
384 return false
385 const content = JSON.parse(await readFile(path, 'utf-8')) || {}
386 return !!content.workspaces
387 }
388
389 function hasRootFile(root: string): boolean {
390 // https://github.com/vitejs/vite/issues/2820#issuecomment-812495079
391 const ROOT_FILES = [
392 // '.git',
393
394 // https://pnpm.js.org/workspaces/
395 'pnpm-workspace.yaml',
396
397 // https://rushjs.io/pages/advanced/config_files/
398 // 'rush.json',
399
400 // https://nx.dev/latest/react/getting-started/nx-setup
401 // 'workspace.json',
402 // 'nx.json'
403 ]
404
405 return ROOT_FILES.some(file => existsSync(join(root, file)))
406 }
407
408 /**
409 * Search up for the nearest workspace root
410 */
411 async function searchForWorkspaceRoot(
412 current: string,
413 root = current,
414 ): Promise<string> {
415 if (hasRootFile(current))
416 return current
417 if (await hasWorkspacePackageJSON(current))
418 return current
419
420 const dir = dirname(current)
421 // reach the fs root
422 if (!dir || dir === current)
423 return root
424
425 return searchForWorkspaceRoot(dir, root)
426 }
427
428 let rootsInfo: RootsInfo | null = null
429
430 export async function getRoots(entry?: string): Promise<RootsInfo> {
431 if (rootsInfo)
432 return rootsInfo
433 if (!entry)
434 throw new Error('[slidev] Cannot find roots without entry')
435 const userRoot = dirname(entry)
436 isInstalledGlobally.value
437 = slash(relative(userRoot, process.argv[1])).includes('/.pnpm/')
438 // pnpm v11 isolated globals don't expose a `.pnpm/` segment in argv[1]
439 // and aren't detected by `is-installed-globally` (which only knows npm
440 // and yarn). The cli's bin is symlinked into an install-group
441 // `node_modules/` that's outside the user's workspace, so use that as
442 // the global-mode signal.
443 || (invocationNodeModules != null
444 && slash(relative(userRoot, invocationNodeModules)).startsWith('..'))
445 || (await import('is-installed-globally')).default
446 const clientRoot = await findPkgRoot('@slidev/client', cliRoot, true)
447 const closestPkgRoot = dirname(await findClosestPkgJsonPath(userRoot) || userRoot)
448 const userPkgJson = await getUserPkgJson(closestPkgRoot)
449 const userWorkspaceRoot = await searchForWorkspaceRoot(closestPkgRoot)
450 rootsInfo = {
451 cliRoot,
452 clientRoot,
453 userRoot,
454 userPkgJson,
455 userWorkspaceRoot,
456 }
457 return rootsInfo
458 }
459
460 export function resolveSourceFiles(
461 roots: string[],
462 subpath: string,
463 extensions = ['.mjs', '.js', '.mts', '.ts'], // The same order as https://vite.dev/config/shared-options#resolve-extensions
464 ) {
465 const results: string[] = []
466 for (const root of roots) {
467 for (const ext of extensions) {
468 const fullPath = join(root, subpath + ext)
469 if (existsSync(fullPath)) {
470 results.push(fullPath)
471 break
472 }
473 }
474 }
475 return results
476 }
477
477 lines TYPESCRIPT