diff --git a/src/utils/documents.server.ts b/src/utils/documents.server.ts index d7828d009..6341fec9d 100644 --- a/src/utils/documents.server.ts +++ b/src/utils/documents.server.ts @@ -300,7 +300,11 @@ async function fetchFs(repo: string, filepath: string) { return null } -async function fetchFsFromDevServer(repo: string, filepath: string) { +async function fetchFsFromDevServer( + repo: string, + filepath: string, + tree = false, +) { let request: Request try { @@ -316,6 +320,7 @@ async function fetchFsFromDevServer(repo: string, filepath: string) { const url = new URL(localDocsDevPath, request.url) url.searchParams.set('repo', repo) url.searchParams.set('path', filepath) + if (tree) url.searchParams.set('kind', 'tree') const response = await fetch(url, { headers: { @@ -1429,6 +1434,14 @@ async function fetchApiContentsFs( startingPath: string, ): Promise | null> { const [_, repo] = repoPair.split('/') + if (isIsolateRuntime()) { + const text = await fetchFsFromDevServer(repo, startingPath, true) + if (text === null) return null + const tree: unknown = JSON.parse(text) + if (!isGitHubFileNodeArray(tree)) + throw new Error('Invalid local docs directory response') + return tree + } const base = getLocalRepoBaseDirs(repo).find((candidate) => fs.existsSync(path.join(candidate, removeLeadingSlash(startingPath))), diff --git a/src/utils/local-docs-tree.server.ts b/src/utils/local-docs-tree.server.ts new file mode 100644 index 000000000..af413ace5 --- /dev/null +++ b/src/utils/local-docs-tree.server.ts @@ -0,0 +1,55 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import type { GitHubFileNode } from './documents.server' + +const ignored = new Set([ + 'node_modules', + '.git', + 'dist', + 'test-results', + '.output', + '.netlify', + '.vercel', + '.DS_Store', + '.nitro', +]) + +export async function readLocalDocsTree( + repoDir: string, + directory: string, + depth = 0, +): Promise> { + const root = await fs.realpath(repoDir) + const resolved = await fs.realpath(path.resolve(repoDir, directory)) + const relative = path.relative(root, resolved) + if ( + relative === '..' || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) + ) + throw new Error('Directory is outside the repository') + const entries = (await fs.readdir(resolved, { withFileTypes: true })) + .filter((entry) => !ignored.has(entry.name) && !entry.isSymbolicLink()) + .sort( + (a, b) => + Number(b.isDirectory()) - Number(a.isDirectory()) || + Number(b.name.startsWith('.')) - Number(a.name.startsWith('.')) || + a.name.localeCompare(b.name), + ) + return Promise.all( + entries.map(async (entry) => { + const filePath = path.posix.join(directory, entry.name) + return { + name: entry.name, + path: filePath, + type: entry.isDirectory() ? 'dir' : 'file', + depth, + parentPath: directory, + _links: { self: filePath }, + ...(entry.isDirectory() && depth <= 3 + ? { children: await readLocalDocsTree(repoDir, filePath, depth + 1) } + : {}), + } + }), + ) +} diff --git a/tests/local-docs-tree.test.ts b/tests/local-docs-tree.test.ts new file mode 100644 index 000000000..aaf14421f --- /dev/null +++ b/tests/local-docs-tree.test.ts @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict' +import { mkdtemp, mkdir, writeFile, symlink, rm } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { test } from 'node:test' +import { readLocalDocsTree } from '../src/utils/local-docs-tree.server' + +test('local tree reads nested docs, omits generated directories and symlinks, and stays inside the repo', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'docs-tree-')) + try { + const repo = path.join(root, 'repo') + await mkdir(path.join(repo, 'docs', 'guide'), { recursive: true }) + await mkdir(path.join(repo, 'docs', 'node_modules')) + await writeFile(path.join(repo, 'docs', 'guide', 'one.md'), '# One') + await writeFile(path.join(root, 'outside.md'), 'outside') + await symlink( + path.join(root, 'outside.md'), + path.join(repo, 'docs', 'linked.md'), + ) + const tree = await readLocalDocsTree(repo, 'docs') + assert.deepEqual( + tree.map((entry) => entry.path), + ['docs/guide'], + ) + assert.equal(tree[0].children?.[0].path, 'docs/guide/one.md') + assert.equal(tree[0].children?.[0].depth, 1) + const rootTree = await readLocalDocsTree(repo, '') + assert.deepEqual( + rootTree.map((entry) => entry.path), + ['docs'], + ) + await mkdir(path.join(repo, '..docs')) + await writeFile(path.join(repo, '..docs', 'valid.md'), '# Valid') + assert.equal( + (await readLocalDocsTree(repo, '..docs'))[0].path, + '..docs/valid.md', + ) + await assert.rejects( + readLocalDocsTree(repo, '..'), + /outside the repository/, + ) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) diff --git a/vite.config.ts b/vite.config.ts index 8ae2ecb23..7222fcc3c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,3 +1,4 @@ +import { readLocalDocsTree } from './src/utils/local-docs-tree.server' import { sentryTanstackStart } from '@sentry/tanstackstart-react/vite' import { defineConfig } from 'vite' import type { PluginOption } from 'vite' @@ -62,11 +63,13 @@ function localDocsDevFiles(): PluginOption { const repo = url.searchParams.get('repo') const filepath = url.searchParams.get('path') + const isTree = url.searchParams.get('kind') === 'tree' if ( !repo || !/^[a-zA-Z0-9._-]+$/.test(repo) || - !filepath || + filepath === null || + (!isTree && !filepath) || !isContainedRepoPath(filepath) ) { response.statusCode = 400 @@ -87,29 +90,39 @@ function localDocsDevFiles(): PluginOption { ]), ) - const localFilePath = repoDirs + const localEntry = repoDirs .map((repoDir) => ({ filepath: path.resolve(repoDir, filepath), repoDir, })) .find( (candidate) => - isPathInside(candidate.repoDir, candidate.filepath) && + (isPathInside(candidate.repoDir, candidate.filepath) || + (isTree && candidate.repoDir === candidate.filepath)) && fs.existsSync(candidate.filepath) && - fs.statSync(candidate.filepath).isFile(), - )?.filepath + (isTree + ? fs.statSync(candidate.filepath).isDirectory() + : fs.statSync(candidate.filepath).isFile()), + ) - if (!localFilePath) { + if (!localEntry) { response.statusCode = 404 response.end() return } try { - const content = await fs.promises.readFile(localFilePath) + const content = isTree + ? JSON.stringify( + await readLocalDocsTree(localEntry.repoDir, filepath), + ) + : await fs.promises.readFile(localEntry.filepath) response.statusCode = 200 response.setHeader('Cache-Control', 'no-store') - response.setHeader('Content-Type', 'text/plain; charset=utf-8') + response.setHeader( + 'Content-Type', + isTree ? 'application/json' : 'text/plain; charset=utf-8', + ) response.end(content) } catch (error) { next(error)