From 8b4d889d689ad35eac92cf2a13264cafda713e08 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Fri, 11 Sep 2026 18:10:29 -0600 Subject: [PATCH 1/2] fix: read local docs directories through the worker bridge --- src/utils/documents.server.ts | 15 ++++++++- src/utils/local-docs-tree.server.ts | 51 +++++++++++++++++++++++++++++ tests/local-docs-tree.test.ts | 34 +++++++++++++++++++ vite.config.ts | 23 +++++++++---- 4 files changed, 116 insertions(+), 7 deletions(-) create mode 100644 src/utils/local-docs-tree.server.ts create mode 100644 tests/local-docs-tree.test.ts 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..e36b6894d --- /dev/null +++ b/src/utils/local-docs-tree.server.ts @@ -0,0 +1,51 @@ +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.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..a35ef94e7 --- /dev/null +++ b/tests/local-docs-tree.test.ts @@ -0,0 +1,34 @@ +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) + 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..be5ce87e2 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,6 +63,7 @@ function localDocsDevFiles(): PluginOption { const repo = url.searchParams.get('repo') const filepath = url.searchParams.get('path') + const isTree = url.searchParams.get('kind') === 'tree' if ( !repo || @@ -87,7 +89,7 @@ function localDocsDevFiles(): PluginOption { ]), ) - const localFilePath = repoDirs + const localEntry = repoDirs .map((repoDir) => ({ filepath: path.resolve(repoDir, filepath), repoDir, @@ -96,20 +98,29 @@ function localDocsDevFiles(): PluginOption { (candidate) => isPathInside(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) From e2036b7a6c738b8090ee3a17c8ed76d6ff6e19ce Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Fri, 11 Sep 2026 18:19:56 -0600 Subject: [PATCH 2/2] fix: allow local repository root directory reads --- src/utils/local-docs-tree.server.ts | 6 +++++- tests/local-docs-tree.test.ts | 11 +++++++++++ vite.config.ts | 6 ++++-- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/utils/local-docs-tree.server.ts b/src/utils/local-docs-tree.server.ts index e36b6894d..af413ace5 100644 --- a/src/utils/local-docs-tree.server.ts +++ b/src/utils/local-docs-tree.server.ts @@ -22,7 +22,11 @@ export async function readLocalDocsTree( 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.isAbsolute(relative)) + 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()) diff --git a/tests/local-docs-tree.test.ts b/tests/local-docs-tree.test.ts index a35ef94e7..aaf14421f 100644 --- a/tests/local-docs-tree.test.ts +++ b/tests/local-docs-tree.test.ts @@ -24,6 +24,17 @@ test('local tree reads nested docs, omits generated directories and symlinks, an ) 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/, diff --git a/vite.config.ts b/vite.config.ts index be5ce87e2..7222fcc3c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -68,7 +68,8 @@ function localDocsDevFiles(): PluginOption { if ( !repo || !/^[a-zA-Z0-9._-]+$/.test(repo) || - !filepath || + filepath === null || + (!isTree && !filepath) || !isContainedRepoPath(filepath) ) { response.statusCode = 400 @@ -96,7 +97,8 @@ function localDocsDevFiles(): PluginOption { })) .find( (candidate) => - isPathInside(candidate.repoDir, candidate.filepath) && + (isPathInside(candidate.repoDir, candidate.filepath) || + (isTree && candidate.repoDir === candidate.filepath)) && fs.existsSync(candidate.filepath) && (isTree ? fs.statSync(candidate.filepath).isDirectory()