UNPKG

@stackbit/sdk

Version:
363 lines (327 loc) 14 kB
import path from 'path'; import fse from 'fs-extra'; import _ from 'lodash'; import micromatch from 'micromatch'; import { Octokit } from '@octokit/rest'; import { readDirRecursively, parseDataByFilePath, reducePromise } from '@stackbit/utils'; import { DATA_FILE_EXTENSIONS, MARKDOWN_FILE_EXTENSIONS } from '../consts'; export const EXCLUDED_LIST_FILES = ['**/node_modules/**', '**/.git/**', '.idea/**']; export interface FileResult { filePath: string; isFile: boolean; isDirectory: boolean; } export interface ListFilesOptions { includePattern?: string | string[]; excludePattern?: string | string[]; } export interface FileBrowserAdapterInterface { listFiles(listFilesOptions: ListFilesOptions): Promise<FileResult[]>; readFile(filePath: string): Promise<string>; } export interface FileSystemFileBrowserAdapterOptions { dirPath: string; } export class FileSystemFileBrowserAdapter implements FileBrowserAdapterInterface { private readonly dirPath: string; constructor({ dirPath }: FileSystemFileBrowserAdapterOptions) { this.dirPath = dirPath; } async listFiles({ includePattern, excludePattern }: ListFilesOptions): Promise<FileResult[]> { const readDirResult = await readDirRecursively(this.dirPath, { includeDirs: true, includeStats: true, filter: (filePath) => { const isIncluded = !includePattern || micromatch.isMatch(filePath, includePattern); excludePattern = excludePattern || EXCLUDED_LIST_FILES; const isExcluded = micromatch.isMatch(filePath, excludePattern); return isIncluded && !isExcluded; } }); // TODO: order files alphabetically so both Git and FileSystem adapters will result same analyze results return readDirResult.map((fileResult) => ({ filePath: fileResult.filePath, isFile: fileResult.stats.isFile(), isDirectory: fileResult.stats.isDirectory() })); } async readFile(filePath: string): Promise<string> { const absPath = path.join(this.dirPath, filePath); return fse.readFile(absPath, 'utf8'); } } export interface GitHubFileBrowserAdapterBaseOptions { branch: string; auth?: string; } export interface GitHubFileBrowserAdapterOwnerRepoOptions { owner: string; repo: string; } export interface GitHubFileBrowserAdapterRepoUrlOptions { repoUrl: string; } export type GitHubFileBrowserAdapterOptions = GitHubFileBrowserAdapterBaseOptions & (GitHubFileBrowserAdapterRepoUrlOptions | GitHubFileBrowserAdapterOwnerRepoOptions); interface OctokitTreeNode { path?: string; mode?: string; type?: string; sha?: string; size?: number; url?: string; } export class GitHubFileBrowserAdapter implements FileBrowserAdapterInterface { private readonly octokit: Octokit; private readonly owner: string; private readonly repo: string; private readonly branch: string; constructor(options: GitHubFileBrowserAdapterBaseOptions & GitHubFileBrowserAdapterRepoUrlOptions); constructor(options: GitHubFileBrowserAdapterBaseOptions & GitHubFileBrowserAdapterOwnerRepoOptions); constructor(options: GitHubFileBrowserAdapterOptions) { if ('repoUrl' in options) { const parsedRepoUrl = this.parseGitHubUrl(options.repoUrl); if (!parsedRepoUrl) { throw new Error(`repository URL '${options.repoUrl}' cannot be parsed, please use standard github URL`); } this.owner = parsedRepoUrl.owner; this.repo = parsedRepoUrl.repo; } else { this.owner = options.owner; this.repo = options.repo; } this.branch = options.branch; this.octokit = new Octokit({ auth: options.auth }); } async listFiles(listFilesOptions: ListFilesOptions): Promise<FileResult[]> { // const branchResponse = await this.octokit.repos.getBranch({ // owner: this.owner, // repo: this.repo, // branch: this.branch // }); // const treeSha = branchResponse.data.commit.commit.tree.sha; const branchResponse = await this.octokit.repos.listBranches({ owner: this.owner, repo: this.repo }); const branch = _.find(branchResponse.data, { name: this.branch }); if (!branch) { throw new Error(`branch ${this.branch} not found`); } const treeSha = branch.commit.sha; const treeResponse = await this.octokit.git.getTree({ owner: this.owner, repo: this.repo, tree_sha: treeSha, recursive: 'true' }); let tree; if (!treeResponse.data.truncated) { const { includePattern, excludePattern } = listFilesOptions; tree = treeResponse.data.tree; tree = tree.filter((node) => { if (!node.path || !(node.type === 'blob' || node.type === 'tree')) { return false; } const isIncluded = !includePattern || micromatch.isMatch(node.path, includePattern); const isExcluded = micromatch.isMatch(node.path, excludePattern || EXCLUDED_LIST_FILES); return isIncluded && !isExcluded; }); } else { tree = await this.listFilesRecursively(treeResponse.data.sha, listFilesOptions, ''); } // TODO: order files alphabetically so both Git and FileSystem adapters will result same analyze results return tree.map((node) => ({ filePath: node.path!, isFile: node.type === 'blob', isDirectory: node.type === 'tree' })); } async listFilesRecursively(treeSha: string, listFilesOptions: ListFilesOptions, parentPath: string): Promise<OctokitTreeNode[]> { const treeResponse = await this.octokit.git.getTree({ owner: this.owner, repo: this.repo, tree_sha: treeSha }); const { includePattern, excludePattern } = listFilesOptions; const { blob: files, tree: folders } = _.groupBy(treeResponse.data.tree, 'type'); const filter = (fullPath: string) => { const isIncluded = !includePattern || micromatch.isMatch(fullPath, includePattern); const isExcluded = micromatch.isMatch(fullPath, excludePattern || EXCLUDED_LIST_FILES); return isIncluded && !isExcluded; }; const filteredFolders = (folders || []).reduce((accum: OctokitTreeNode[], treeNode) => { if (!treeNode.path || !treeNode.sha) { return accum; } const fullPath = (parentPath ? parentPath + '/' : '') + treeNode.path; if (!filter(fullPath)) { return accum; } return accum.concat(Object.assign(treeNode, { path: fullPath })); }, []); const filteredFiles = (files || []).reduce((accum: OctokitTreeNode[], fileNode) => { if (!fileNode.path) { return accum; } const fullPath = (parentPath ? parentPath + '/' : '') + fileNode.path; if (!filter(fullPath)) { return accum; } return accum.concat(Object.assign(fileNode, { path: fullPath })); }, []); const folderResults = await reducePromise( filteredFolders, async (accum: OctokitTreeNode[], treeNode) => { const results = await this.listFilesRecursively(treeNode.sha!, listFilesOptions, treeNode.path!); return accum.concat(treeNode, results); }, [] ); return folderResults.concat(filteredFiles); } async readFile(filePath: string): Promise<string> { const contentResponse = await this.octokit.repos.getContent({ owner: this.owner, repo: this.repo, path: filePath }); if ('content' in contentResponse.data) { const base64Content = contentResponse.data.content; return Buffer.from(base64Content, 'base64').toString(); } return ''; } parseGitHubUrl(repoUrl: string): GitHubFileBrowserAdapterOwnerRepoOptions | null { const match = repoUrl.match(/github\.com[/:](.+?)\/(.+?)(\.git)?$/); if (!match) { return null; } const owner = match[1]!; const repo = match[2]!; return { owner, repo }; } } export type GetFileBrowserOptions = { fileBrowser: FileBrowser } | { fileBrowserAdapter: FileBrowserAdapterInterface }; export function getFileBrowserFromOptions(options: GetFileBrowserOptions): FileBrowser { if ('fileBrowser' in options) { return options.fileBrowser; } if (!('fileBrowserAdapter' in options)) { throw new Error('either fileBrowser or fileBrowserAdapter must be provided to SSG matcher'); } return new FileBrowser({ fileBrowserAdapter: options.fileBrowserAdapter }); } export interface FileBrowserOptions { fileBrowserAdapter: FileBrowserAdapterInterface; } interface TreeNode { [key: string]: true | TreeNode; } export class FileBrowser { private readonly fileBrowserAdapter: FileBrowserAdapterInterface; private readonly filePathMap: Record<string, boolean>; private readonly directoryPathsMap: Record<string, boolean>; private readonly filePathsByFileName: Record<string, string[]>; private readonly fileData: Record<string, any>; private readonly filePaths: string[]; private readonly fileTree: TreeNode; private files: FileResult[] = []; constructor({ fileBrowserAdapter }: FileBrowserOptions) { this.fileBrowserAdapter = fileBrowserAdapter; this.filePaths = []; this.filePathMap = {}; this.directoryPathsMap = {}; this.filePathsByFileName = {}; this.fileTree = {}; this.fileData = {}; } async listFiles({ includePattern, excludePattern }: { includePattern?: string | string[]; excludePattern?: string | string[] } = {}) { if (this.files.length > 0) { return; } this.files = await this.fileBrowserAdapter.listFiles({ includePattern, excludePattern }); // create maps to find files by names or paths quickly _.forEach(this.files, (fileReadResult) => { const filePath = fileReadResult.filePath; const filePathArr = filePath.split(path.sep); if (fileReadResult.isFile) { _.set(this.fileTree, filePathArr, true); const pathObject = path.parse(filePath); const fileName = pathObject.base; if (!(fileName in this.filePathsByFileName)) { this.filePathsByFileName[fileName] = []; } this.filePathsByFileName[fileName]!.push(filePath); this.filePaths.push(filePath); this.filePathMap[filePath] = true; } else if (fileReadResult.isDirectory) { if (!_.has(this.fileTree, filePathArr)) { _.set(this.fileTree, filePathArr, {}); } this.directoryPathsMap[filePath] = true; } }); } filePathExists(filePath: string) { return _.has(this.filePathMap, filePath); } fileNameExists(fileName: string) { return _.has(this.filePathsByFileName, fileName); } getFilePathsForFileName(fileName: string): string[] { return _.get(this.filePathsByFileName, fileName, []); } directoryPathExists(dirPath: string) { return _.has(this.directoryPathsMap, dirPath); } findFiles(pattern: string | string[]) { return micromatch(this.filePaths, pattern); } readFilesRecursively(dirPath: string, { filter, includeDirs }: { filter?: (fileResult: FileResult) => boolean; includeDirs?: boolean }): string[] { const reduceTreeNode = (treeNode: TreeNode, parentPath: string) => { return _.reduce( treeNode, (result: string[], value, name) => { const filePath = path.join(parentPath, name); const isFile = value === true; if (filter && !filter({ filePath, isFile: isFile, isDirectory: !isFile })) { return result; } if (value !== true) { const childFilePaths = reduceTreeNode(value, filePath); result = includeDirs ? result.concat(filePath, childFilePaths) : result.concat(childFilePaths); } else { result = result.concat(filePath); } return result; }, [] ); }; const treeNode = dirPath === '' ? this.fileTree : _.get(this.fileTree, dirPath.split(path.sep)); return reduceTreeNode(treeNode, ''); } async getFileData(filePath: string): Promise<any> { if (!this.filePathExists(filePath)) { return null; } if (!(filePath in this.fileData)) { const extension = path.extname(filePath).substring(1); const data = await this.fileBrowserAdapter.readFile(filePath); if ([...DATA_FILE_EXTENSIONS, ...MARKDOWN_FILE_EXTENSIONS].includes(extension)) { try { this.fileData[filePath] = parseDataByFilePath(data, filePath); } catch (error) { console.warn(`error parsing file: ${filePath}`); this.fileData[filePath] = null; } } else { this.fileData[filePath] = data; } } return this.fileData[filePath]; } }