|
| 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 | +} |
0 commit comments