diff --git a/.changeset/modern-tools-wink.md b/.changeset/modern-tools-wink.md new file mode 100644 index 000000000..df6dfb06c --- /dev/null +++ b/.changeset/modern-tools-wink.md @@ -0,0 +1,54 @@ +--- +'renoun': minor +--- + +Adds an `EntryGroup` utility to `renoun/file-system` that provides an interface for querying and navigating a group of entries: + +```ts +import { Directory, EntryGroup } from 'renoun/file-system' + +interface FrontMatter { + title: string + description?: string + date: string + tags?: string[] +} + +interface MDXType { + frontmatter: FrontMatter +} + +const posts = new Directory<{ mdx: MDXType }>({ + path: 'posts', +}) +const docs = new Directory<{ mdx: MDXType }>({ + path: 'docs', +}) +const group = new EntryGroup({ + entries: [posts, docs], +}) +const entries = await group.getEntries() +``` + +This also adds `getHasEntry` and `getHasFile` methods to `Directory` which can be used to check if an entry or file exists in an `EntryGroup`: + +```ts +type MDXTypes = { metadata: { title: string } } +type TSXTypes = { title: string } + +const directoryA = new Directory<{ mdx: MDXTypes }>({ + fileSystem: new VirtualFileSystem({ 'Button.mdx': '' }), +}) +const directoryB = new Directory<{ tsx: TSXTypes }>({ + path: 'fixtures/components', +}) +const group = new EntryGroup({ + entries: [directoryA, directoryB], +}) +const entry = await group.getEntryOrThrow('Button') +const hasFile = await directoryA.getHasFile(entry) + +if (hasFile(entry, 'mdx')) { + entry // JavaScriptFile +} +``` diff --git a/packages/renoun/src/file-system/index.test.ts b/packages/renoun/src/file-system/index.test.ts index 00ad32417..dd06a1d50 100644 --- a/packages/renoun/src/file-system/index.test.ts +++ b/packages/renoun/src/file-system/index.test.ts @@ -5,13 +5,14 @@ import { z } from 'zod' import { NodeFileSystem } from './NodeFileSystem' import { VirtualFileSystem } from './VirtualFileSystem' import { - isFile, - isFileWithExtension, + type FileSystemEntry, File, Directory, JavaScriptFile, JavaScriptFileExport, - type FileSystemEntry, + EntryGroup, + isFile, + isFileWithExtension, } from './index' describe('file system', () => { @@ -237,6 +238,17 @@ describe('file system', () => { expect(nestedDirectory).toBeInstanceOf(Directory) }) + test('duplicate directory', async () => { + const fixtures = new Directory<{ ts: { title: string } }>({ + path: 'fixtures', + }) + const duplicate = fixtures.duplicate() + + expect(duplicate).toBeInstanceOf(Directory) + expect(duplicate).not.toBe(fixtures) + expect(duplicate.getRelativePath()).toBe(fixtures.getRelativePath()) + }) + test('file', async () => { const rootDirectory = new Directory() const file = await rootDirectory.getFile('tsconfig', 'json') @@ -680,4 +692,86 @@ describe('file system', () => { expectTypeOf(file).toMatchTypeOf>() } }) + + test('entry group', async () => { + const memoryFileSystem = new VirtualFileSystem({ + 'posts/building-a-button-component.mdx': '# Building a Button Component', + 'posts/meta.js': 'export default { "title": "Posts" }', + }) + type FrontMatter = { frontmatter: { title: string } } + const posts = new Directory<{ mdx: FrontMatter }>({ + path: 'posts', + fileSystem: memoryFileSystem, + }) + const docs = new Directory<{ mdx: FrontMatter }>({ + path: 'fixtures/docs', + }) + const group = new EntryGroup({ + entries: [posts, docs], + }) + const entries = await group.getEntries() + + expect(entries).toHaveLength(2) + expect(entries[1].getName()).toBe('docs') + + const entry = await group.getEntry('posts/building-a-button-component') + + expect(entry).toBeInstanceOf(File) + expect(entry?.getName()).toBe('building-a-button-component') + + const directory = await group.getDirectory('docs') + + expect(directory).toBeInstanceOf(Directory) + + const jsFile = await group.getFileOrThrow('posts/meta', 'js') + + expect(jsFile).toBeInstanceOf(JavaScriptFile) + expectTypeOf(jsFile).toMatchTypeOf>() + + const mdxFile = await group.getFileOrThrow( + 'posts/building-a-button-component', + 'mdx' + ) + + expect(mdxFile).toBeInstanceOf(JavaScriptFile) + expectTypeOf(mdxFile).toMatchTypeOf>() + + const file = await group.getFileOrThrow('meta', 'js') + const [previousEntry, nextEntry] = await file.getSiblings() + + expect(previousEntry?.getName()).toBe('building-a-button-component') + expect(nextEntry?.getName()).toBe('docs') + }) + + test('has entry', async () => { + type MDXTypes = { metadata: { title: string } } + type TSXTypes = { title: string } + + const directoryA = new Directory<{ mdx: MDXTypes }>({ + fileSystem: new VirtualFileSystem({ 'Button.mdx': '' }), + }) + const directoryB = new Directory<{ tsx: TSXTypes }>({ + path: 'fixtures/components', + }) + const group = new EntryGroup({ + entries: [directoryA, directoryB], + }) + const file = await group.getFileOrThrow('Button', 'mdx') + + expectTypeOf(file).toMatchTypeOf>() + + const entry = await group.getEntryOrThrow('Button') + const hasEntry = await directoryA.getHasEntry(entry) + + expect(hasEntry(entry)).toBe(true) + expectTypeOf(entry).toMatchTypeOf>() + + const hasFile = await directoryA.getHasFile(entry) + + expect(hasFile(entry, 'mdx')).toBe(true) + + if (hasFile(entry, 'mdx')) { + expectTypeOf(entry).toMatchTypeOf>() + } + }) }) diff --git a/packages/renoun/src/file-system/index.tsx b/packages/renoun/src/file-system/index.tsx index 61928ded1..ca7fd4ec1 100644 --- a/packages/renoun/src/file-system/index.tsx +++ b/packages/renoun/src/file-system/index.tsx @@ -26,18 +26,32 @@ export type FileSystemEntry = | File interface FileOptions { - directory: Directory path: string + directory: Directory + entryGroup?: EntryGroup[]> } /** A file in the file system. */ export class File { - #directory: Directory #path: string + #directory: Directory + #entryGroup?: EntryGroup[]> constructor(options: FileOptions) { - this.#directory = options.directory this.#path = options.path + this.#directory = options.directory + this.#entryGroup = options.entryGroup + } + + /** Duplicate the file with the same initial options. */ + duplicate(options?: { + entryGroup: EntryGroup[]> + }): File { + return new File({ + directory: this.#directory, + path: this.#path, + ...options, + }) } /** Get the directory containing this file. */ @@ -145,7 +159,9 @@ export class File { return this.#directory.getSiblings() } - const entries = await this.#directory.getEntries() + const entries = await (this.#entryGroup + ? this.#entryGroup.getEntries({ recursive: true }) + : this.#directory.getEntries()) const index = entries.findIndex((file) => { return file.getRelativePath() === this.getRelativePath() }) @@ -462,9 +478,10 @@ type ExtensionSchemas = { interface DirectoryOptions { path?: string basePath?: string - fileSystem?: FileSystem schema?: ExtensionSchemas getModule?: (path: string) => Promise + fileSystem?: FileSystem + entryGroup?: EntryGroup[]> } /** A directory containing files and subdirectories in the file system. */ @@ -475,6 +492,7 @@ export class Directory< #path: string #basePath?: string #fileSystem: FileSystem | undefined + #entryGroup?: EntryGroup[]> | undefined #directory?: Directory #schema?: ExtensionSchemas #getModule?: (path: string) => Promise @@ -490,9 +508,24 @@ export class Directory< : join('.', options.path) : '.' this.#basePath = options.basePath - this.#fileSystem = options.fileSystem this.#schema = options.schema this.#getModule = options.getModule + this.#fileSystem = options.fileSystem + this.#entryGroup = options.entryGroup + } + + /** Duplicate the directory with the same initial options. */ + duplicate = FileSystemEntry>( + options?: DirectoryOptions + ): Directory { + return new Directory({ + path: this.#path, + basePath: this.#basePath, + fileSystem: this.#fileSystem, + schema: this.#schema, + getModule: this.#getModule, + ...options, + }) } getFileSystem() { @@ -574,7 +607,7 @@ export class Directory< } /** Get a file at the specified `path` and optional extensions. */ - async getFile( + async getFile( path: string | string[], extension?: Extension | Extension[] ): Promise< @@ -585,7 +618,9 @@ export class Directory< : File) | undefined > { - const segments = Array.isArray(path) ? path.slice(0) : path.split('/') + const segments = Array.isArray(path) + ? path.slice(0) + : path.split('/').filter(Boolean) let currentDirectory: Directory = this as Directory let entry: FileSystemEntry | undefined @@ -664,12 +699,18 @@ export class Directory< : File > { const file = await this.getFile(path, extension) + if (!file) { const normalizedPath = Array.isArray(path) ? join(...path) : path + const normalizedExtension = Array.isArray(extension) + ? extension + : [extension] + throw new Error( - `[renoun] File not found at path "${normalizedPath}" with extension "${extension}"` + `[renoun] File not found at path "${normalizedPath}" with extension${normalizedExtension.length > 1 ? 's' : ''}: ${normalizedExtension.join(',')}` ) } + return file as any } @@ -686,7 +727,9 @@ export class Directory< return this.#directory } - const segments = Array.isArray(path) ? path.slice(0) : path.split('/') + const segments = Array.isArray(path) + ? path.slice(0) + : path.split('/').filter(Boolean) let currentDirectory: Directory = this as Directory while (segments.length > 0) { @@ -722,6 +765,7 @@ export class Directory< path?: string | string[] ): Promise> { const directory = await this.getDirectory(path) + if (!directory) { throw new Error( path @@ -729,13 +773,14 @@ export class Directory< : `[renoun] Parent directory not found` ) } + return directory } /** Get a file or directory at the specified `path`. Files will be prioritized over directories. */ async getEntry( path: string | string[] - ): Promise | undefined> { + ): Promise | undefined> { const file = await this.getFile(path) if (file) { @@ -754,7 +799,7 @@ export class Directory< /** Get a file or directory at the specified `path`. An error will be thrown if the entry is not found. */ async getEntryOrThrow( path: string | string[] - ): Promise> { + ): Promise> { const entry = await this.getEntry(path) if (!entry) { @@ -796,6 +841,7 @@ export class Directory< const directory = new Directory>({ fileSystem, path: entry.path, + entryGroup: this.#entryGroup, schema: this.#schema, getModule: this.#getModule, }) @@ -828,15 +874,17 @@ export class Directory< const extension = extensionName(entry.name).slice(1) const file = isJavaScriptLikeExtension(extension) ? new JavaScriptFile({ - directory: this as Directory, path: entry.path, + directory: this as Directory, + entryGroup: this.#entryGroup, schema: this.#schema, getModule: this.#getModule, isVirtualFileSystem: fileSystem instanceof VirtualFileSystem, }) : new File({ - directory: this as Directory, path: entry.path, + directory: this as Directory, + entryGroup: this.#entryGroup, }) if ( @@ -894,7 +942,9 @@ export class Directory< return [undefined, undefined] } - const entries = await this.#directory.getEntries() + const entries = await (this.#entryGroup + ? this.#entryGroup.getEntries({ recursive: true }) + : this.#directory.getEntries()) const index = entries.findIndex((entryToCompare) => { return entryToCompare.getRelativePath() === this.getRelativePath() }) @@ -959,6 +1009,267 @@ export class Directory< const gitMetadata = await getGitMetadata(this.#path) return gitMetadata.authors } + + /** Returns a type guard that checks if this directory contains the provided entry. */ + async getHasEntry(entry: FileSystemEntry | undefined) { + let exists = false + + if (entry) { + const path = entry.getPath() + const directoryEntry = await this.getEntry(path) + + if (directoryEntry) { + exists = true + } + } + + function hasEntry(entry: FileSystemEntry): entry is Entry { + return exists + } + + return hasEntry + } + + /** Returns a type guard that check if this directory contains the provided file with a specific extension. */ + async getHasFile(entry: FileSystemEntry) { + const hasEntry = await this.getHasEntry(entry) + + function hasFileWith< + Type extends keyof Types | (string & {}), + const Extension extends Type | Type[], + >( + entry: FileSystemEntry, + extension?: Extension + ): entry is FileWithExtension { + const extensions = Array.isArray(extension) ? extension : [extension] + + if (hasEntry(entry) && entry instanceof File) { + if (extension) { + for (const fileExtension of extensions) { + if (entry.getExtension() === fileExtension) { + return true + } + } + } else { + return true + } + } + + return false + } + + return hasFileWith + } +} + +type InferExtensionTypes[]> = + Entries extends readonly [infer First, ...infer Rest] + ? First extends FileSystemEntry + ? Rest extends readonly FileSystemEntry[] + ? Types & InferExtensionTypes + : Types + : never + : {} + +interface EntryGroupOptions[]> { + entries: Entries +} + +/** A group of file system entries. */ +export class EntryGroup< + const Entries extends FileSystemEntry[] = FileSystemEntry[], + Types extends ExtensionTypes = InferExtensionTypes, +> { + #entries: Entries + + constructor(options: EntryGroupOptions) { + this.#entries = options.entries.map((entry) => + entry.duplicate({ entryGroup: this }) + ) as Entries + } + + /** Get all entries in the group. */ + async getEntries(options?: { + recursive?: boolean + includeIndexAndReadme?: boolean + }): Promise { + const allEntries: FileSystemEntry[] = [] + + async function findEntries(entries: FileSystemEntry[]) { + for (const entry of entries) { + const lowerCaseBaseName = entry.getBaseName().toLowerCase() + const shouldSkipIndexOrReadme = options?.includeIndexAndReadme + ? false + : ['index', 'readme'].some((name) => + lowerCaseBaseName.startsWith(name) + ) + + if (shouldSkipIndexOrReadme) { + continue + } + + allEntries.push(entry) + + if (options?.recursive && entry instanceof Directory) { + allEntries.push(...(await entry.getEntries(options))) + } + } + } + + await findEntries(this.#entries) + + return allEntries as Entries + } + + /** Get an entry in the group by its path. */ + async getEntry( + path: string | string[] + ): Promise | undefined> { + const segments = Array.isArray(path) + ? path + : path.split('/').filter(Boolean) + const [targetSegment, ...remainingSegments] = segments + + for (const entry of this.#entries) { + if (entry instanceof Directory) { + const entryBaseName = entry.getBaseName() + + if (entryBaseName === targetSegment) { + if (remainingSegments.length === 0) { + return entry + } + if (entry instanceof Directory) { + return entry.getEntry(remainingSegments) + } + return undefined + } + + const childEntries = await entry.getEntries() + const childEntry = childEntries.find((childEntry) => { + return childEntry.getBaseName() === targetSegment + }) + + if (childEntry) { + if (remainingSegments.length === 0) { + return childEntry + } + if (childEntry instanceof Directory) { + return childEntry.getEntry(remainingSegments) + } + } + } else { + if (entry.getBaseName() === targetSegment) { + if (remainingSegments.length === 0) { + return entry + } + if (isDirectory(entry)) { + return entry.getEntry(remainingSegments) + } + } + } + } + + return undefined + } + + /** Get an entry in the group by its path or throw an error if not found. */ + async getEntryOrThrow( + path: string | string[] + ): Promise> { + const entry = await this.getEntry(path) + + if (!entry) { + throw new Error(`[renoun] Entry not found at path: ${path}`) + } + + return entry + } + + /** Get a file at the specified path and optional extension(s). */ + async getFile( + path: string | string[], + extension?: Extension | Extension[] + ): Promise< + | (Extension extends string + ? IsJavaScriptLikeExtension extends true + ? JavaScriptFile + : File + : File) + | undefined + > { + const entry = await this.getEntry(path) + + if (entry instanceof File) { + if (extension) { + const fileExtensions = Array.isArray(extension) + ? extension + : [extension] + + for (const fileExtension of fileExtensions) { + if (entry.getExtension() === fileExtension) { + return entry as any + } + } + + return undefined + } + + return entry as any + } + + return undefined + } + + /** Get a file at the specified path and optional extension(s), or throw an error if not found. */ + async getFileOrThrow( + path: string | string[], + extension?: Extension | Extension[] + ): Promise< + Extension extends string + ? IsJavaScriptLikeExtension extends true + ? JavaScriptFile + : File + : File + > { + const file = await this.getFile(path, extension) + + if (!file) { + const normalizedPath = Array.isArray(path) ? join(...path) : path + const normalizedExtension = Array.isArray(extension) + ? extension + : [extension] + + throw new Error( + `[renoun] File not found at path "${normalizedPath}" with extension${normalizedExtension.length > 1 ? 's' : ''}: ${normalizedExtension.join(',')}` + ) + } + + return file as any + } + + /** Get a directory at the specified path. */ + async getDirectory( + path: string | string[] + ): Promise | undefined> { + const entry = await this.getEntry(path) + + if (entry instanceof Directory) { + return entry + } + } + + /** Get a directory at the specified path or throw an error if not found. */ + async getDirectoryOrThrow( + path: string | string[] + ): Promise> { + const directory = await this.getDirectory(path) + + if (!directory) { + throw new Error(`[renoun] Directory not found at path: ${path}`) + } + + return directory + } } /** Determines if a `FileSystemEntry` is a `Directory`. */