Skip to content

Commit f2dbd8c

Browse files
committed
feat(docs): add nuxt docs command
1 parent ed7c524 commit f2dbd8c

19 files changed

Lines changed: 970 additions & 14 deletions

‎packages/nuxt-cli/package.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,17 @@
3434
"test:dist": "node --experimental-strip-types ../../scripts/check-dist.ts && node --experimental-strip-types ../../scripts/check-youch.ts"
3535
},
3636
"peerDependencies": {
37+
"@nuxt/docs": "^4.0.0",
3738
"@nuxt/schema": "^4.5.2",
3839
"jiti": "^2.7.0",
3940
"oxc-parser": ">=0.143.0",
4041
"rolldown": "^1.0.0-rc.16 || ^1.0.0",
4142
"serve": "^14.2.6"
4243
},
4344
"peerDependenciesMeta": {
45+
"@nuxt/docs": {
46+
"optional": true
47+
},
4448
"@nuxt/schema": {
4549
"optional": true
4650
},

‎packages/nuxt-cli/src/commands/_utils.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@ export const nuxiCommands = [
77
'analyze',
88
'build',
99
'cleanup',
10-
'completion',
1110
'_dev',
1211
'dev',
1312
'devtools',
13+
'docs',
1414
'generate',
1515
'info',
1616
'init',
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
importtype{DocsEntry,DocsIndex}from'../utils/docs-index'
2+
3+
importprocessfrom'node:process'
4+
import{styleText}from'node:util'
5+
6+
import{cancel,isCancel,select}from'@clack/prompts'
7+
import{defineCommand}from'citty'
8+
9+
import{openBrowser}from'../dev/listen'
10+
import{releaseStdin,withDirectStdout}from'../utils/console'
11+
import{DOCS_BASE_URL,DOCS_PATH,resolveDocsIndex}from'../utils/docs-index'
12+
import{logger}from'../utils/logger'
13+
import{resolveRootDir}from'../utils/paths'
14+
import{withSpinner}from'../utils/spinner'
15+
import{isInteractive}from'../utils/stdout'
16+
import{rootDirArgs}from'./_shared'
17+
18+
constMAX_RESULTS=8
19+
20+
exportdefaultdefineCommand({
21+
meta: {
22+
name: 'docs',
23+
description: 'Search or open the Nuxt documentation',
24+
},
25+
args: {
26+
query: {
27+
type: 'positional',
28+
description: 'Words to search the documentation for',
29+
required: false,
30+
},
31+
...rootDirArgs,
32+
open: {
33+
type: 'boolean',
34+
description: 'Open the best match in a browser',
35+
default: true,
36+
},
37+
},
38+
asyncrun(ctx){
39+
constquery=ctx.args._.slice(0,!ctx.args._.includes('--') ? undefined : ctx.args._.indexOf('--')).join(' ').trim()
40+
if(!query){
41+
returnvisit(DOCS_PATH,ctx.args.open)
42+
}
43+
44+
constfound=awaitwithSpinner(`Searching the Nuxt documentation for ${styleText('cyan',query)}`,async(spinner)=>{
45+
constindex=awaitresolveDocsIndex(resolveRootDir(ctx.args),{
46+
onDownload: version=>spinner.update(`Downloading the Nuxt ${version} documentation`),
47+
onIndex: version=>spinner.update(`Indexing the Nuxt ${version} documentation`),
48+
})
49+
if(!index){
50+
returnundefined
51+
}
52+
spinner.update(`Searching the Nuxt ${index.version} documentation for ${styleText('cyan',query)}`)
53+
constresults=awaitsearch(index,query)
54+
spinner.done(`Searched ${index.entries.length} pages of the Nuxt ${index.version} documentation`)
55+
return{ index, results }
56+
})
57+
58+
if(!found){
59+
logger.warn(`Could not read the Nuxt documentation for this project. Run again with ${styleText('cyan','DEBUG=nuxi*')} to see why.`)
60+
returnvisit(DOCS_PATH,ctx.args.open)
61+
}
62+
63+
const{ index, results }=found
64+
if(results.length===0){
65+
logger.warn(`Nothing in the Nuxt ${index.version} documentation matches ${styleText('cyan',query)}.`)
66+
returnvisit(DOCS_PATH,ctx.args.open)
67+
}
68+
69+
if(results.length>1&&ctx.args.open&&isInteractive()){
70+
constchoice=awaitwithDirectStdout(()=>select<string>({
71+
message: `Which page would you like to open?`,
72+
initialValue: results[0]!.path,
73+
options: results.map(({ title, description, path })=>({
74+
value: path,
75+
label: title,
76+
hint: description||path,
77+
})),
78+
}))
79+
releaseStdin()
80+
if(isCancel(choice)){
81+
cancel(`Nuxt documentation: ${DOCS_BASE_URL}${index.base}${results[0]!.path}`)
82+
return
83+
}
84+
returnvisit(index.base+choice,true)
85+
}
86+
87+
constwidth=Math.max(...results.map(entry=>entry.title.length))
88+
constlines=results.map(({ title, description, path },position)=>{
89+
constlabel=` ${position===0 ? styleText('green','>') : ' '}${styleText('bold',title.padEnd(width))}`
90+
return`${label}${styleText('gray',description||path)}`
91+
})
92+
process.stdout.write(`${lines.join('\n')}\n`)
93+
94+
returnvisit(index.base+results[0]!.path,ctx.args.open)
95+
},
96+
})
97+
98+
functionvisit(path: string,open: boolean): void{
99+
consturl=DOCS_BASE_URL+path
100+
logger.info(`${open ? 'Opening' : 'Nuxt documentation:'}${styleText('cyan',url)}`)
101+
if(open){
102+
openBrowser(url)
103+
}
104+
}
105+
106+
/**
107+
* Rank pages by title first, then by their section headings, then by description.
108+
* A page whose heading names the query is usually a better answer than one that
109+
* merely mentions it in prose, so headings are scored rather than full text.
110+
*/
111+
asyncfunctionsearch(index: DocsIndex,query: string): Promise<DocsEntry[]>{
112+
const{default: fuzzysort}=awaitimport('fuzzysort')
113+
constscored=index.entries.map((entry)=>{
114+
consttitle=fuzzysort.single(query,entry.title)?.score??0
115+
constheading=Math.max(0, ...entry.headings.map(value=>fuzzysort.single(query,value)?.score??0))
116+
constdescription=fuzzysort.single(query,entry.description||'')?.score??0
117+
constpath=fuzzysort.single(query,entry.path)?.score??0
118+
return{ entry,score: Math.max(title,heading*0.9,path*0.8,description*0.6)}
119+
})
120+
121+
returnscored
122+
.filter(result=>result.score>0.3)
123+
.sort((a,b)=>b.score-a.score)
124+
.slice(0,MAX_RESULTS)
125+
.map(result=>result.entry)
126+
}

‎packages/nuxt-cli/src/commands/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const commandLoaders = {
1212
'_dev': ()=>import('./dev-child').then(_rDefault),
1313
'dev': ()=>import('./dev').then(_rDefault),
1414
'devtools': ()=>import('./devtools').then(_rDefault),
15+
'docs': ()=>import('./docs').then(_rDefault),
1516
'generate': ()=>import('./generate').then(_rDefault),
1617
'info': ()=>import('./info').then(_rDefault),
1718
'init': ()=>import('./init').then(_rDefault),

‎packages/nuxt-cli/src/dev/binaries.ts‎

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import{Buffer}from'node:buffer'
22
import{execFileSync}from'node:child_process'
3-
import{chmodSync,existsSync,mkdirSync,mkdtempSync,renameSync,rmSync,writeFileSync}from'node:fs'
4-
import{homedir}from'node:os'
3+
import{chmodSync,existsSync,mkdtempSync,renameSync,rmSync,writeFileSync}from'node:fs'
54

65
importprocessfrom'node:process'
76

87
import{confirm,isCancel,progress,spinner}from'@clack/prompts'
98
import{basename,dirname,join}from'pathe'
109
import{readUser,updateUser}from'rc9'
1110

11+
import{getCacheDir}from'../utils/cache'
1212
import{restoreRawMode,withDirectStdout}from'../utils/console'
1313
import{debug,logger}from'../utils/logger'
1414
import{logNetworkError}from'../utils/network'
@@ -96,13 +96,6 @@ async function confirmToolInstall(options: ConsentOptions): Promise<boolean> {
9696

9797
constRESPONSE_TIMEOUT_MS=30_000
9898

99-
exportfunctiongetCacheDir(...segments: string[]): string{
100-
constbase=process.env.XDG_CACHE_HOME||join(homedir(),'.cache')
101-
constdir=join(base,'nuxt', ...segments)
102-
mkdirSync(dir,{recursive: true})
103-
returndir
104-
}
105-
10699
asyncfunctiondownloadBinary(url: string,destination: string,options: {archive?: boolean,name?: string}={}): Promise<string|undefined>{
107100
constlabel=options.name||url
108101
try{

‎packages/nuxt-cli/src/dev/cert.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@ import process from 'node:process'
99

1010
import{join}from'pathe'
1111

12+
import{getCacheDir}from'../utils/cache'
1213
import{ActionableError}from'../utils/errors'
1314
import{debug,logger}from'../utils/logger'
1415
import{findInPath}from'../utils/path-env'
15-
import{getCacheDir,resolveTool}from'./binaries'
16+
import{resolveTool}from'./binaries'
1617

1718
exportinterfaceHTTPSOptions{
1819
cert?: string

‎packages/nuxt-cli/src/dev/listen.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,8 @@ function resolveOpenURL(target: string, baseURL: string): string {
504504
}
505505
}
506506

507+
constDEFAULT_LAUNCHERS=newSet(['open','xdg-open','start','cmd','cmd.exe'])
508+
507509
/**
508510
* Resolve the command that opens `url`, honouring the de facto `BROWSER` and
509511
* `BROWSER_ARGS` environment variables (`BROWSER=none` disables opening).
@@ -518,7 +520,10 @@ export function resolveOpenCommand(
518520
return
519521
}
520522

521-
if(browser){
523+
// A `BROWSER` naming the platform launcher itself is asking for the default
524+
// behaviour, not for a browser called `open`/`xdg-open` (`open -a open <url>`
525+
// fails).
526+
if(browser&&!DEFAULT_LAUNCHERS.has(browser)){
522527
constbrowserArgs=env.BROWSER_ARGS?.trim().split(/\s+/).filter(Boolean)??[]
523528
returnplatform==='darwin'&&!browser.includes('/')
524529
? ['open',['-a',browser,url, ...(browserArgs.length>0 ? ['--args', ...browserArgs] : [])]]
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import{mkdirSync}from'node:fs'
2+
import{homedir}from'node:os'
3+
importprocessfrom'node:process'
4+
5+
import{join}from'pathe'
6+
7+
/**
8+
* A directory under the user's cache home (`XDG_CACHE_HOME`, else `~/.cache`)
9+
* for Nuxt to keep downloaded artefacts in, created if it does not exist.
10+
*/
11+
exportfunctiongetCacheDir(...segments: string[]): string{
12+
constbase=process.env.XDG_CACHE_HOME||join(homedir(),'.cache')
13+
constdir=join(base,'nuxt', ...segments)
14+
mkdirSync(dir,{recursive: true})
15+
returndir
16+
}

‎packages/nuxt-cli/src/utils/console.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,21 @@ export function restoreRawMode(): void {
6767
}
6868
}
6969

70+
/**
71+
* Give up `process.stdin` after a prompt in a command that is about to finish.
72+
*
73+
* A resumed stdin is an active handle, so a one-shot command would otherwise sit
74+
* there with nothing left to do until the terminal closes.
75+
*/
76+
exportfunctionreleaseStdin(): void{
77+
restoreRawMode()
78+
if(!process.stdin.isTTY){
79+
return
80+
}
81+
process.stdin.pause()
82+
process.stdin.unref()
83+
}
84+
7085
/**
7186
* Run `fn` with `process.stdout` and `process.stderr` writing straight to the
7287
* terminal again.

0 commit comments

Comments
 (0)