|
| 1 | +import fs from 'fs'; |
| 2 | +import { join, resolve, dirname, basename } from 'path'; |
| 3 | + |
| 4 | +export interface Options { |
| 5 | + relativePath?: string; |
| 6 | + extensions?: string[]; |
| 7 | + include?: RegExp | ((path: string, kind: 'dir' | 'file', filename: string) => boolean); |
| 8 | + exclude?: RegExp | ((path: string, kind: 'dir' | 'file', filename: string) => boolean); |
| 9 | +} |
| 10 | + |
| 11 | +type AstNodeKinds = 'rootType' | 'dir' | 'file'; |
| 12 | + |
| 13 | +interface AstBaseNode { |
| 14 | + kind: AstNodeKinds; |
| 15 | + name: string; |
| 16 | + absPath: string; |
| 17 | +} |
| 18 | + |
| 19 | +export interface AstRootTypeNode extends AstBaseNode { |
| 20 | + kind: 'rootType'; |
| 21 | + children: { |
| 22 | + [key: string]: AstDirNode | AstFileNode; |
| 23 | + }; |
| 24 | +} |
| 25 | + |
| 26 | +export interface AstDirNode extends AstBaseNode { |
| 27 | + kind: 'dir'; |
| 28 | + children: { |
| 29 | + [key: string]: AstDirNode | AstFileNode; |
| 30 | + }; |
| 31 | +} |
| 32 | + |
| 33 | +export interface AstFileNode extends AstBaseNode { |
| 34 | + kind: 'file'; |
| 35 | + code: { |
| 36 | + default?: any; |
| 37 | + }; |
| 38 | +} |
| 39 | + |
| 40 | +export interface AstResult { |
| 41 | + query?: AstRootTypeNode; |
| 42 | + mutation?: AstRootTypeNode; |
| 43 | + subscription?: AstRootTypeNode; |
| 44 | +} |
| 45 | + |
| 46 | +export const defaultOptions: Options = { |
| 47 | + extensions: ['js', 'ts'], |
| 48 | +}; |
| 49 | + |
| 50 | +export function directoryToAst(m: NodeModule, options: Options = defaultOptions): AstResult { |
| 51 | + // if no path was passed in, assume the equivelant of __dirname from caller |
| 52 | + // otherwise, resolve path relative to the equivalent of __dirname |
| 53 | + const schemaPath = options?.relativePath |
| 54 | + ? resolve(dirname(m.filename), options.relativePath) |
| 55 | + : dirname(m.filename); |
| 56 | + |
| 57 | + // setup default options |
| 58 | + Object.keys(defaultOptions).forEach((prop) => { |
| 59 | + if (typeof (options as any)[prop] === 'undefined') { |
| 60 | + (options as any)[prop] = (defaultOptions as any)[prop]; |
| 61 | + } |
| 62 | + }); |
| 63 | + |
| 64 | + const result = {} as AstResult; |
| 65 | + |
| 66 | + fs.readdirSync(schemaPath).forEach((filename) => { |
| 67 | + const absPath = join(schemaPath, filename); |
| 68 | + |
| 69 | + if (fs.statSync(absPath).isDirectory()) { |
| 70 | + const dirName = filename; |
| 71 | + const re = /^(query|mutation|subscription)(\.(.*))?$/i; |
| 72 | + const found = dirName.match(re); |
| 73 | + if (found) { |
| 74 | + const opType = found[1].toLowerCase() as keyof AstResult; |
| 75 | + let rootTypeAst = result[opType]; |
| 76 | + if (!rootTypeAst) |
| 77 | + rootTypeAst = { |
| 78 | + kind: 'rootType', |
| 79 | + name: opType, |
| 80 | + absPath, |
| 81 | + children: {}, |
| 82 | + } as AstRootTypeNode; |
| 83 | + |
| 84 | + const astDir = getAstForDir(m, absPath, options); |
| 85 | + if (astDir) { |
| 86 | + const subField = found[3]; // any part after dot (eg for `query.me` will be `me`) |
| 87 | + if (subField) { |
| 88 | + rootTypeAst.children[subField] = { |
| 89 | + ...astDir, |
| 90 | + name: subField, |
| 91 | + absPath, |
| 92 | + }; |
| 93 | + } else { |
| 94 | + rootTypeAst.children = astDir.children; |
| 95 | + } |
| 96 | + result[opType] = rootTypeAst; |
| 97 | + } |
| 98 | + } |
| 99 | + } |
| 100 | + }); |
| 101 | + |
| 102 | + return result; |
| 103 | +} |
| 104 | + |
| 105 | +export function getAstForDir( |
| 106 | + m: NodeModule, |
| 107 | + absPath: string, |
| 108 | + options: Options = defaultOptions |
| 109 | +): AstDirNode | void { |
| 110 | + const name = basename(absPath); |
| 111 | + |
| 112 | + if (!checkInclusion(absPath, 'dir', name, options)) return; |
| 113 | + |
| 114 | + const result: AstDirNode = { |
| 115 | + kind: 'dir', |
| 116 | + absPath, |
| 117 | + name, |
| 118 | + children: {}, |
| 119 | + }; |
| 120 | + |
| 121 | + // get the path of each file in specified directory, append to current tree node, recurse |
| 122 | + fs.readdirSync(absPath).forEach((filename) => { |
| 123 | + const absFilePath = join(absPath, filename); |
| 124 | + |
| 125 | + const stat = fs.statSync(absFilePath); |
| 126 | + if (stat.isDirectory()) { |
| 127 | + // this node is a directory; recurse |
| 128 | + if (result.children[filename]) { |
| 129 | + throw new Error( |
| 130 | + `You have a folder and file with same name "${filename}" by the following path ${absPath}. Please remove one of them.` |
| 131 | + ); |
| 132 | + } |
| 133 | + const astDir = getAstForDir(m, absFilePath, options); |
| 134 | + if (astDir) { |
| 135 | + result.children[filename] = astDir; |
| 136 | + } |
| 137 | + } else if (stat.isFile()) { |
| 138 | + // this node is a file |
| 139 | + const fileAst = getAstForFile(m, absFilePath, options); |
| 140 | + if (fileAst) { |
| 141 | + if (result.children[fileAst.name]) { |
| 142 | + throw new Error( |
| 143 | + `You have a folder and file with same name "${fileAst.name}" by the following path ${absPath}. Please remove one of them.` |
| 144 | + ); |
| 145 | + } else { |
| 146 | + result.children[fileAst.name] = fileAst; |
| 147 | + } |
| 148 | + } |
| 149 | + } |
| 150 | + }); |
| 151 | + |
| 152 | + return result; |
| 153 | +} |
| 154 | + |
| 155 | +export function getAstForFile( |
| 156 | + m: NodeModule, |
| 157 | + absPath: string, |
| 158 | + options: Options = defaultOptions |
| 159 | +): AstFileNode | void { |
| 160 | + const filename = basename(absPath); |
| 161 | + if (absPath !== m.filename && checkInclusion(absPath, 'file', filename, options)) { |
| 162 | + // hash node key shouldn't include file extension |
| 163 | + const moduleName = filename.substring(0, filename.lastIndexOf('.')); |
| 164 | + return { |
| 165 | + kind: 'file', |
| 166 | + name: moduleName, |
| 167 | + absPath, |
| 168 | + code: m.require(absPath), |
| 169 | + }; |
| 170 | + } |
| 171 | +} |
| 172 | + |
| 173 | +function checkInclusion( |
| 174 | + absPath: string, |
| 175 | + kind: 'dir' | 'file', |
| 176 | + filename: string, |
| 177 | + options: Options |
| 178 | +): boolean { |
| 179 | + // Skip dir/files started from double underscore |
| 180 | + if (/^__.*/i.test(filename)) { |
| 181 | + return false; |
| 182 | + } |
| 183 | + |
| 184 | + if (kind === 'file') { |
| 185 | + if ( |
| 186 | + // Verify file has valid extension |
| 187 | + !new RegExp('\\.(' + (options?.extensions || ['js', 'ts']).join('|') + ')$', 'i').test( |
| 188 | + filename |
| 189 | + ) || |
| 190 | + // Hardcoded skip file extensions |
| 191 | + new RegExp('(\\.d\\.ts)$', 'i').test(filename) |
| 192 | + ) |
| 193 | + return false; |
| 194 | + } |
| 195 | + |
| 196 | + if (options.include) { |
| 197 | + if (options.include instanceof RegExp) { |
| 198 | + // if options.include is a RegExp, evaluate it and make sure the path passes |
| 199 | + if (!options.include.test(absPath)) return false; |
| 200 | + } else if (typeof options.include === 'function') { |
| 201 | + // if options.include is a function, evaluate it and make sure the path passes |
| 202 | + if (!options.include(absPath, kind, filename)) return false; |
| 203 | + } |
| 204 | + } |
| 205 | + |
| 206 | + if (options.exclude) { |
| 207 | + if (options.exclude instanceof RegExp) { |
| 208 | + // if options.exclude is a RegExp, evaluate it and make sure the path doesn't pass |
| 209 | + if (options.exclude.test(absPath)) return false; |
| 210 | + } else if (typeof options.exclude === 'function') { |
| 211 | + // if options.exclude is a function, evaluate it and make sure the path doesn't pass |
| 212 | + if (options.exclude(absPath, kind, filename)) return false; |
| 213 | + } |
| 214 | + } |
| 215 | + |
| 216 | + return true; |
| 217 | +} |
0 commit comments