Skip to content

Commit d8fc5d0

Browse files
committed
fix(docs): scope cache to registry and strip control characters
1 parent a2a2e0e commit d8fc5d0

2 files changed

Lines changed: 56 additions & 30 deletions

File tree

packages/nuxt-cli/src/utils/docs-index.ts

Lines changed: 36 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
import type { RegistryMeta } from './registry'
2+
13
import { Buffer } from 'node:buffer'
24
import { execFileSync } from 'node:child_process'
5+
import { createHash } from 'node:crypto'
36
import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
47

58
import { parseYAML } from 'confbox/yaml'
@@ -41,6 +44,8 @@ const MARKDOWN_EXTENSION_RE = /\.md$/
4144
const MAJOR_VERSION_RE = /^\d+$/
4245
const EXACT_VERSION_RE = /^\d+\.\d+\.\d+(?:-[\w.]+)?$/
4346
const MARKDOWN_SYNTAX_RE = /[`*_[\]]|\{[^}]*\}/g
47+
// eslint-disable-next-line no-control-regex
48+
const CONTROL_CHARS_RE = /[\u0000-\u001F\u007F-\u009F]/g
4449

4550
export const DOCS_BASE_URL = 'https://nuxt.com'
4651
export const DOCS_PATH = '/docs'
@@ -67,20 +72,24 @@ const UNSAFE_KEY_RE = /[^\w.+-]/g
6772
*/
6873
export async function resolveDocsIndex(cwd: string, options: DocsIndexProgress = {}): Promise<DocsIndex | undefined> {
6974
const nuxtVersion = await getNuxtVersion(cwd).catch(() => undefined)
70-
const cached = readCache(nuxtVersion)
75+
76+
const local = localDocs(cwd)
77+
if (local && (!nuxtVersion || sameMajor(local.version, nuxtVersion))) {
78+
return indexLocal(local, options)
79+
}
80+
81+
const source = await detectNpmRegistry('@nuxt', cwd)
82+
const cached = readCache(nuxtVersion, source.registry)
7183
if (cached) {
7284
return cached
7385
}
7486

75-
const local = localDocs(cwd)
76-
const index = local && (!nuxtVersion || sameMajor(local.version, nuxtVersion))
77-
? indexLocal(local, options)
78-
: await fetchIndex(cwd, nuxtVersion, options) ?? (local && indexLocal(local, options))
79-
87+
const index = await fetchIndex(source, nuxtVersion, options)
8088
if (index) {
81-
writeCache(nuxtVersion, index)
89+
writeCache(nuxtVersion, source.registry, index)
90+
return index
8291
}
83-
return index || undefined
92+
return local ? indexLocal(local, options) : undefined
8493
}
8594

8695
export interface DocsIndexProgress {
@@ -119,14 +128,14 @@ function localDocs(cwd: string): { root: string, version: string } | undefined {
119128
* predate the package or be a nightly, so the highest release sharing its major
120129
* (then whatever is latest) is accepted as a stand-in.
121130
*/
122-
async function fetchIndex(cwd: string, nuxtVersion: string | undefined, options: DocsIndexProgress): Promise<DocsIndex | undefined> {
131+
async function fetchIndex(source: RegistryMeta, nuxtVersion: string | undefined, options: DocsIndexProgress): Promise<DocsIndex | undefined> {
123132
const attempted = new Set<string>()
124133
for await (const version of candidateVersions(nuxtVersion)) {
125134
if (attempted.has(version)) {
126135
continue
127136
}
128137
attempted.add(version)
129-
const index = await downloadIndex(cwd, version, options)
138+
const index = await downloadIndex(source, version, options)
130139
if (index) {
131140
return index
132141
}
@@ -148,11 +157,11 @@ async function* candidateVersions(nuxtVersion: string | undefined): AsyncGenerat
148157
}
149158
}
150159

151-
async function downloadIndex(cwd: string, version: string, options: DocsIndexProgress): Promise<DocsIndex | undefined> {
160+
async function downloadIndex(source: RegistryMeta, version: string, options: DocsIndexProgress): Promise<DocsIndex | undefined> {
152161
options.onDownload?.(version)
153162
const staging = mkdtempSync(join(getCacheDir(CACHE_DIR), '.staging-'))
154163
try {
155-
const tarball = await downloadTarball(cwd, version)
164+
const tarball = await downloadTarball(source, version)
156165
if (!tarball) {
157166
return undefined
158167
}
@@ -197,8 +206,7 @@ function assertSafeArchiveMembers(archive: string): void {
197206
* itself: the docs are a public package, so a proxy that rejects this process is
198207
* no reason to give up on them.
199208
*/
200-
async function downloadTarball(cwd: string, version: string): Promise<Buffer | undefined> {
201-
const { registry, authorization } = await detectNpmRegistry('@nuxt', cwd)
209+
async function downloadTarball({ registry, authorization }: RegistryMeta, version: string): Promise<Buffer | undefined> {
202210
const sources: [registry: string, authorization: string | null][] = [[registry, authorization]]
203211
if (registry !== PUBLIC_REGISTRY) {
204212
sources.push([PUBLIC_REGISTRY, null])
@@ -268,28 +276,31 @@ function buildIndex(root: string, version: string): DocsIndex | undefined {
268276
/**
269277
* Keyed by the Nuxt version asked about rather than the docs version resolved for
270278
* it, so a project on a Nuxt release with no docs of its own does not repeat the
271-
* registry lookup that found the stand-in.
279+
* registry lookup that found the stand-in. The registry is part of the key too:
280+
* a project can name its own in `.npmrc`, and what that serves should not be
281+
* offered as the docs in projects that use a different one.
272282
*/
273-
function cacheFile(nuxtVersion: string | undefined): string {
283+
function cacheFile(nuxtVersion: string | undefined, registry: string): string {
274284
// A version can be anything a `package.json` or a dependency specifier holds, so
275285
// it is reduced to a file name rather than trusted as one.
276286
const key = (nuxtVersion || 'latest').replace(UNSAFE_KEY_RE, '_')
277-
return join(getCacheDir(CACHE_DIR), `index-${key}.json`)
287+
const suffix = registry === PUBLIC_REGISTRY ? '' : `-${createHash('sha256').update(registry).digest('hex').slice(0, 12)}`
288+
return join(getCacheDir(CACHE_DIR), `index-${key}${suffix}.json`)
278289
}
279290

280-
function readCache(nuxtVersion: string | undefined): DocsIndex | undefined {
291+
function readCache(nuxtVersion: string | undefined, registry: string): DocsIndex | undefined {
281292
try {
282-
const cached = JSON.parse(readFileSync(cacheFile(nuxtVersion), 'utf8')) as DocsIndex
293+
const cached = JSON.parse(readFileSync(cacheFile(nuxtVersion, registry), 'utf8')) as DocsIndex
283294
return cached.entries?.length > 0 ? cached : undefined
284295
}
285296
catch {
286297
return undefined
287298
}
288299
}
289300

290-
function writeCache(nuxtVersion: string | undefined, index: DocsIndex): void {
301+
function writeCache(nuxtVersion: string | undefined, registry: string, index: DocsIndex): void {
291302
try {
292-
writeFileSync(cacheFile(nuxtVersion), JSON.stringify(index), 'utf8')
303+
writeFileSync(cacheFile(nuxtVersion, registry), JSON.stringify(index), 'utf8')
293304
}
294305
catch (error) {
295306
debug('Could not cache the documentation index:', error)
@@ -323,11 +334,12 @@ function readEntry(root: string, file: string): DocsEntry | undefined {
323334
return undefined
324335
}
325336

326-
const path = toSitePath(relative(root, file))
327-
const title = data.title?.trim() || path.split('/').pop() || path
337+
// Everything indexed ends up on the terminal, so escape sequences are dropped.
338+
const path = toSitePath(relative(root, file)).replace(CONTROL_CHARS_RE, '')
339+
const title = data.title?.replace(CONTROL_CHARS_RE, '').trim() || path.split('/').pop() || path
328340
return {
329341
title,
330-
description: data.description?.trim(),
342+
description: data.description?.replace(CONTROL_CHARS_RE, '').trim(),
331343
path,
332344
headings: [...source.matchAll(HEADING_RE)].map(match => clean(match[1]!)),
333345
}

packages/nuxt-cli/test/unit/utils/docs-index.spec.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -145,19 +145,22 @@ describe('resolveDocsIndex', () => {
145145
expect(index?.entries.map(entry => entry.title)).toEqual(['Guide'])
146146
})
147147

148-
it('should reuse a cached index until the Nuxt version changes', async () => {
148+
it('should read the installed docs afresh rather than from the cache', async () => {
149149
const project = createProject('4.5.2', { 'guide.md': page('Guide') })
150150
expect((await resolveDocsIndex(project))?.entries.map(entry => entry.title)).toEqual(['Guide'])
151151

152152
writeFileSync(join(project, 'node_modules/@nuxt/docs/guide.md'), page('Renamed'))
153-
expect((await resolveDocsIndex(project))?.entries.map(entry => entry.title)).toEqual(['Guide'])
154-
155-
for (const pkg of ['nuxt', '@nuxt/docs']) {
156-
writeFileSync(join(project, 'node_modules', pkg, 'package.json'), JSON.stringify({ name: pkg, version: '4.5.3', exports: { './*': './*' } }))
157-
}
158153
expect((await resolveDocsIndex(project))?.entries.map(entry => entry.title)).toEqual(['Renamed'])
159154
})
160155

156+
it('should strip control characters from indexed titles and descriptions', async () => {
157+
const index = await resolveDocsIndex(createProject('4.5.2', {
158+
'guide.md': '---\ntitle: "Guide\\e[31m"\ndescription: "Read\\a me"\n---\n',
159+
}))
160+
161+
expect(index?.entries[0]).toMatchObject({ title: 'Guide[31m', description: 'Read me' })
162+
})
163+
161164
it('should fetch the docs published for the project\'s Nuxt version when none are installed', async () => {
162165
cwd = mkdtempSync(join(tmpdir(), 'nuxt-docs-index-'))
163166
writeFileSync(join(cwd, 'package.json'), JSON.stringify({ name: 'project', dependencies: { nuxt: '4.5.2' } }))
@@ -177,6 +180,17 @@ describe('resolveDocsIndex', () => {
177180
fetchSpy.mockClear()
178181
expect((await resolveDocsIndex(cwd))?.entries.map(entry => entry.title)).toEqual(['Fetched'])
179182
expect(fetchSpy).not.toHaveBeenCalled()
183+
184+
// A project configuring another registry gets its own cache entry.
185+
writeFileSync(join(cwd, '.npmrc'), 'registry=https://proxy.example.com/npm/\n')
186+
fetchSpy.mockResolvedValue(new Response(createTarball({ 'guide.md': page('Proxied') })))
187+
expect((await resolveDocsIndex(cwd))?.entries.map(entry => entry.title)).toEqual(['Proxied'])
188+
expect(fetchSpy).toHaveBeenCalledTimes(1)
189+
190+
rmSync(join(cwd, '.npmrc'))
191+
fetchSpy.mockClear()
192+
expect((await resolveDocsIndex(cwd))?.entries.map(entry => entry.title)).toEqual(['Fetched'])
193+
expect(fetchSpy).not.toHaveBeenCalled()
180194
})
181195

182196
it('should download from npm when the configured registry rejects the request', async () => {

0 commit comments

Comments
 (0)