Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion src/utils/documents.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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: {
Expand Down Expand Up @@ -1429,6 +1434,14 @@ async function fetchApiContentsFs(
startingPath: string,
): Promise<Array<GitHubFileNode> | 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))),
Expand Down
55 changes: 55 additions & 0 deletions src/utils/local-docs-tree.server.ts
Original file line number Diff line number Diff line change
@@ -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<Array<GitHubFileNode>> {
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) }
: {}),
}
}),
)
}
45 changes: 45 additions & 0 deletions tests/local-docs-tree.test.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
})
29 changes: 21 additions & 8 deletions vite.config.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
Loading