├── .eslintrc ├── .gitignore ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── gobblefile.js ├── package.json ├── src ├── async.js ├── shared.js ├── spelunk.js └── sync.js └── test ├── test.js └── tests ├── array-of-directories ├── config.js ├── expected.json └── files │ ├── 00 │ └── info.json │ └── 01 │ └── info.json ├── array ├── config.js ├── expected.json └── files │ ├── 0.txt │ ├── 1.txt │ └── 2.txt ├── ignore-files ├── config.js ├── expected.json └── files │ ├── README.md │ └── someText.txt ├── object-with-array ├── config.js ├── expected.json └── files │ ├── array │ ├── 0.txt │ ├── 1.txt │ ├── 2.txt │ ├── 3.txt │ └── README.md │ └── someText.txt ├── object-with-two-properties ├── config.js ├── expected.json └── files │ ├── someJson.json │ └── someText.txt ├── retain-file-extensions ├── config.js ├── expected.json └── files │ ├── object │ ├── 0.txt │ ├── 1.txt │ ├── 2.txt │ └── 3.txt │ └── someText.txt ├── simple-object ├── config.js ├── expected.json └── files │ └── someText.txt └── valid-but-empty ├── config.js ├── expected.json └── files └── .gitkeep /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "indent": [ 2, "tab", { "SwitchCase": 1 } ], 4 | "quotes": [ 2, "single" ], 5 | "linebreak-style": [ 2, "unix" ], 6 | "semi": [ 2, "always" ], 7 | "space-after-keywords": [ 2, "always" ], 8 | "space-before-blocks": [ 2, "always" ], 9 | "space-before-function-paren": [ 2, "always" ], 10 | "no-mixed-spaces-and-tabs": [ 2, "smart-tabs" ], 11 | "no-cond-assign": [ 0 ] 12 | }, 13 | "env": { 14 | "es6": true, 15 | "browser": true, 16 | "mocha": true, 17 | "node": true 18 | }, 19 | "extends": "eslint:recommended", 20 | "ecmaFeatures": { 21 | "modules": true 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | .gobbl* 4 | dist 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # changelog 2 | 3 | ## 0.5.0 4 | 5 | * Support arrays of directories 6 | * ES6-ified 7 | 8 | ## 0.4.0 9 | 10 | * Added `spelunk.sync` 11 | * Internal refactor 12 | * Started maintaining a changelog 13 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Rich Harris 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # spelunk.js 2 | 3 | ``` 4 | $ npm install spelunk 5 | ``` 6 | 7 | **spelunk.js** turns a folder into an object. This folder... 8 | 9 | ``` 10 | data 11 | |- config.json 12 | |- tables 13 | |- population.csv 14 | |- growth.csv 15 | |- slides 16 | |- 0.txt 17 | |- 1.txt 18 | |- 2.txt 19 | |- 3.txt 20 | |- i18n 21 | |- en-GB.json 22 | |- en-US.json 23 | |- fr.json 24 | |- de.json 25 | |- ... 26 | ``` 27 | 28 | ...becomes this object: 29 | 30 | 31 | ```js 32 | { 33 | config: { }, // parsed as JSON 34 | tables: { 35 | population: // as a string 36 | growth: 37 | }, 38 | slides: [ 39 | , // because these files have 40 | , // numeric names, `slides` is 41 | , // an array, not an object 42 | 43 | ], 44 | i18n: { 45 | "en-GB": , 46 | "en-US": , 47 | "fr": , 48 | "de": , 49 | ... 50 | } 51 | } 52 | ``` 53 | 54 | 55 | If a file contains JSON, it is parsed as JSON; if not, it is treated as text. If a folder only contains items with numeric filenames (as in the case of the `slides` folder above), it will become an array rather than an object. 56 | 57 | 58 | ## Usage 59 | 60 | spelunk.js uses the standard Node callback pattern... 61 | 62 | ```js 63 | callback = function ( error, result ) { 64 | if ( error ) { 65 | return handleError( error ); 66 | } 67 | 68 | doSomething( result ); 69 | }; 70 | 71 | spelunk( 'myFolder', options, callback ); // you can omit options 72 | ``` 73 | 74 | ...but it also returns a promise, because this is 2015 dammit and callbacks are a lousy flow control mechanism: 75 | 76 | ```js 77 | spelunk( 'myFolder', options ).then( doSomething, handleError ); 78 | ``` 79 | 80 | ### Synchronous usage 81 | 82 | ```js 83 | var result = spelunk.sync( 'myFolder', options ); 84 | ``` 85 | 86 | 87 | ## Options 88 | 89 | ### options.exclude 90 | 91 | Exclude files that match a certain pattern (this uses [minimatch](https://github.com/isaacs/minimatch) syntax): 92 | 93 | ```js 94 | spelunk( 'myFolder', { exclude: '**/README.md' }).then( doSomething ); 95 | ``` 96 | 97 | The value of `exclude` can be an string, or an array of strings. 98 | 99 | ### options.keepExtensions 100 | 101 | If you have multiple files with the same name but different extensions, they'll conflict. This option allows you to keep their extensions, e.g. `result['config.json']` instead of `result.config` (but really, you're better off keeping your filenames distinct). 102 | 103 | ```js 104 | spelunk( 'myFolder', { keepExtensions: true }).then( doSomething ); 105 | ``` 106 | 107 | 108 | ## Why the name? 109 | 110 | Because traversing a folder tree and mapping all its nooks and crannies feels a bit like spelunking. Plus it's fun to say. 111 | 112 | ## Testing 113 | 114 | ``` 115 | $ npm run test 116 | ``` 117 | 118 | ## License 119 | 120 | [MIT](LICENSE.md), copyright 2014 [@Rich_Harris](http://twitter.com/Rich_Harris) 121 | -------------------------------------------------------------------------------- /gobblefile.js: -------------------------------------------------------------------------------- 1 | var gobble = require( 'gobble' ); 2 | 3 | module.exports = gobble( 'src' ).transform( 'rollup-babel', { 4 | entry: 'spelunk.js', 5 | format: 'cjs', 6 | external: [ 'fs', 'path', 'minimatch', 'es6-promise' ] 7 | }); 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spelunk", 3 | "description": "Traverse a folder in node, turning its contents into an object for easy consumption", 4 | "version": "0.5.0", 5 | "homepage": "https://github.com/Rich-Harris/spelunk", 6 | "author": { 7 | "name": "Rich Harris", 8 | "email": "richard.a.harris@gmail.com", 9 | "url": "http://rich-harris.co.uk" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git://github.com/Rich-Harris/spelunk.git" 14 | }, 15 | "bugs": { 16 | "url": "https://github.com/Rich-Harris/spelunk/issues" 17 | }, 18 | "licenses": [ 19 | { 20 | "type": "MIT", 21 | "url": "https://github.com/Rich-Harris/spelunk/blob/master/LICENSE.md" 22 | } 23 | ], 24 | "main": "dist/spelunk.js", 25 | "files": [ 26 | "src", 27 | "dist", 28 | "README.md" 29 | ], 30 | "dependencies": { 31 | "graceful-fs": "~2.0.1", 32 | "minimatch": "~0.2.14", 33 | "es6-promise": "^1.0.0" 34 | }, 35 | "scripts": { 36 | "test": "mocha test/test.js", 37 | "pretest": "npm run build", 38 | "build": "gobble build -f dist", 39 | "prepublish": "npm test" 40 | }, 41 | "devDependencies": { 42 | "console-group": "^0.1.2", 43 | "eslint": "^1.6.0", 44 | "gobble": "^0.10.2", 45 | "gobble-cli": "^0.4.4", 46 | "gobble-rollup-babel": "^0.4.0", 47 | "mocha": "^2.3.3", 48 | "source-map-support": "^0.3.2" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/async.js: -------------------------------------------------------------------------------- 1 | import { readdir, readFile, stat } from 'fs'; 2 | import { join, relative } from 'path'; 3 | import { filterExclusions, getKey, toArray } from './shared'; 4 | 5 | export default function getDir ( root, dir, options, gotDir ) { 6 | const rel = relative( root, dir ); 7 | 8 | readdir( dir, ( err, files ) => { 9 | if ( err ) { 10 | gotDir( err ); 11 | return; 12 | } 13 | 14 | let result = {}; 15 | 16 | const contents = filterExclusions( files, rel, options.exclude ); 17 | 18 | if ( !contents.length ) { 19 | gotDir( null, result ); 20 | return; 21 | } 22 | 23 | let keysAreNumeric = true; // assume we need to create an array, until we don't 24 | let remaining = contents.length; 25 | 26 | function check () { 27 | if ( !--remaining ) { 28 | if ( keysAreNumeric ) { 29 | result = toArray( result ); 30 | } 31 | 32 | gotDir( null, result ); 33 | } 34 | } 35 | 36 | contents.forEach( fileName => { 37 | const filePath = join( dir, fileName ); 38 | let key; 39 | 40 | function gotFile ( err, data ) { 41 | if ( err ) { 42 | gotDir( err, null ); 43 | } else if ( result[ key ] !== undefined ) { 44 | gotDir( 'You cannot have multiple files in the same folder with the same name (disregarding extensions) - failed at ' + filePath ); 45 | } else { 46 | result[ key ] = data; 47 | check(); 48 | } 49 | } 50 | 51 | stat( filePath, ( err, stats ) => { 52 | if ( err ) { 53 | gotDir( err, null ); 54 | return; 55 | } 56 | 57 | if ( stats.isDirectory() ) { 58 | key = fileName; 59 | getDir( root, filePath, options, gotFile ); 60 | } else { 61 | key = getKey( fileName, options ); 62 | getFile( filePath, gotFile ); 63 | } 64 | 65 | if ( isNaN( +key ) ) { 66 | keysAreNumeric = false; 67 | } 68 | }); 69 | }); 70 | }); 71 | } 72 | 73 | function getFile ( filePath, gotFile ) { 74 | readFile( filePath, function ( err, result ) { 75 | var data; 76 | 77 | if ( err ) { 78 | gotFile( err, null ); 79 | } else { 80 | data = result.toString(); 81 | 82 | try { 83 | data = JSON.parse( data ); 84 | } catch ( e ) { 85 | // treat as text 86 | } 87 | 88 | gotFile( null, data ); 89 | } 90 | }); 91 | } 92 | -------------------------------------------------------------------------------- /src/shared.js: -------------------------------------------------------------------------------- 1 | import { join } from 'path'; 2 | import minimatch from 'minimatch'; 3 | 4 | export function normaliseOptions ( options = {} ) { 5 | // Exclude .DS_Store, Thumbs.db and any other gubbins specified by the user 6 | if ( !options.exclude ) { 7 | options.exclude = []; 8 | } else if ( typeof options.exclude === 'string' ) { 9 | options.exclude = [ options.exclude ]; 10 | } 11 | 12 | options.exclude.push( '**/.DS_Store', '**/Thumbs.db', '**/.gitkeep' ); 13 | return options; 14 | } 15 | 16 | export function filterExclusions ( files, relative, exclusions ) { 17 | if ( !exclusions ) return files; 18 | 19 | return files.filter( fileName => { 20 | const filePath = join( relative, fileName ); 21 | 22 | let i = exclusions.length; 23 | while ( i-- ) { 24 | if ( minimatch( filePath, exclusions[i] ) ) return false; 25 | } 26 | 27 | return true; 28 | }); 29 | } 30 | 31 | // Get key from path, e.g. 'project/data/config.json' -> 'config' 32 | export function getKey ( fileName, options ) { 33 | var lastDotIndex = fileName.lastIndexOf( '.' ); 34 | 35 | if ( lastDotIndex > 0 && !options.keepExtensions ) { 36 | return fileName.substr( 0, lastDotIndex ); 37 | } 38 | 39 | return fileName; 40 | } 41 | 42 | export function toArray ( object ) { 43 | var array = [], key; 44 | 45 | for ( key in object ) { 46 | if ( object.hasOwnProperty( key ) ) { 47 | array[ +key ] = object[ key ]; 48 | } 49 | } 50 | 51 | return array; 52 | } 53 | 54 | export function isNumeric ( key ) { 55 | return !isNaN( +key ); 56 | } 57 | -------------------------------------------------------------------------------- /src/spelunk.js: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path'; 2 | import promise from 'es6-promise'; 3 | 4 | import sync from './sync'; 5 | import async from './async'; 6 | import { normaliseOptions } from './shared'; 7 | 8 | const { Promise } = promise; 9 | 10 | export default function spelunk ( root, options, callback ) { 11 | const promise = new Promise( ( fulfil, reject ) => { 12 | if ( typeof options === 'function' ) { 13 | callback = options; 14 | options = {}; 15 | } 16 | 17 | options = normaliseOptions( options ); 18 | root = resolve( root ); 19 | 20 | // Get the specified folder, then done 21 | async( root, root, options, ( err, result ) => { 22 | if ( err ) return reject( err ); 23 | fulfil( result ); 24 | }); 25 | }); 26 | 27 | if ( callback ) { 28 | promise 29 | .then( result => callback( null, result ) ) 30 | .catch( callback ); 31 | } 32 | 33 | return promise; 34 | } 35 | 36 | spelunk.sync = function ( root, options ) { 37 | root = resolve( root ); 38 | return sync( root, root, normaliseOptions( options ) ); 39 | }; 40 | -------------------------------------------------------------------------------- /src/sync.js: -------------------------------------------------------------------------------- 1 | import { readdirSync, readFileSync, statSync } from 'fs'; 2 | import { join, relative } from 'path'; 3 | import { getKey, filterExclusions, isNumeric } from './shared'; 4 | 5 | export default function getDir ( root, dir, options ) { 6 | const rel = relative( root, dir ); 7 | 8 | let files = readdirSync( dir ); 9 | files = filterExclusions( files, rel, options.exclude ); 10 | 11 | if ( !files.length ) return {}; 12 | 13 | const keysAreNumeric = files.every( isNumeric ); 14 | let result = keysAreNumeric ? [] : {}; 15 | 16 | files.forEach( fileName => { 17 | const filePath = join( dir, fileName ); 18 | const isDir = statSync( filePath ).isDirectory(); 19 | 20 | const key = isDir ? fileName : getKey( fileName, options ); 21 | 22 | if ( key in result ) { 23 | throw new Error( 'You cannot have multiple files in the same folder with the same name (disregarding extensions) - failed at ' + filePath ); 24 | } 25 | 26 | result[ keysAreNumeric ? +key : key ] = isDir ? getDir( root, filePath, options ) : getFile( filePath ); 27 | }); 28 | 29 | return result; 30 | } 31 | 32 | function getFile ( filePath ) { 33 | let data = readFileSync( filePath, 'utf-8' ); 34 | 35 | try { 36 | data = JSON.parse( data ); 37 | } catch ( e ) { 38 | // treat as text 39 | } 40 | 41 | return data; 42 | } 43 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | require( 'source-map-support' ).install(); 2 | require( 'console-group' ).install(); 3 | 4 | var spelunk = require( '..' ); 5 | var fs = require( 'fs' ); 6 | var path = require( 'path' ); 7 | var assert = require( 'assert' ); 8 | 9 | var TESTS = path.resolve( __dirname, 'tests' ); 10 | 11 | describe( 'spelunk', function () { 12 | var filtered = []; 13 | 14 | var tests = fs.readdirSync( TESTS ).map( function ( id ) { 15 | var test = { 16 | id: id, 17 | options: require( path.join( TESTS, id, 'config.js' ) ), 18 | expected: require( path.resolve( TESTS, id, 'expected.json' ) ) 19 | }; 20 | 21 | if ( test.options.solo ) filtered.push( test ); 22 | return test; 23 | }); 24 | 25 | if ( filtered.length ) tests = filtered; 26 | 27 | describe( 'sync', function () { 28 | tests.forEach( function ( test ) { 29 | it( test.id, function () { 30 | var actual = spelunk.sync( path.join( TESTS, test.id, 'files' ), test.options ); 31 | assert.deepEqual( actual, test.expected ); 32 | }); 33 | }); 34 | }); 35 | 36 | describe( 'async', function () { 37 | tests.forEach( function ( test ) { 38 | ( test.solo ? it.only : it )( test.id, function () { 39 | return spelunk( path.resolve( TESTS, test.id, 'files' ), test.options ).then( function ( actual ) { 40 | assert.deepEqual( actual, test.expected ); 41 | }); 42 | }); 43 | }); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /test/tests/array-of-directories/config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | solo: true 3 | }; 4 | -------------------------------------------------------------------------------- /test/tests/array-of-directories/expected.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "info": { 4 | "name": "one" 5 | } 6 | }, 7 | { 8 | "info": { 9 | "name": "two" 10 | } 11 | } 12 | ] 13 | -------------------------------------------------------------------------------- /test/tests/array-of-directories/files/00/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "one" 3 | } 4 | -------------------------------------------------------------------------------- /test/tests/array-of-directories/files/01/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "two" 3 | } 4 | -------------------------------------------------------------------------------- /test/tests/array/config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | }; 4 | -------------------------------------------------------------------------------- /test/tests/array/expected.json: -------------------------------------------------------------------------------- 1 | ["zero","one","two"] -------------------------------------------------------------------------------- /test/tests/array/files/0.txt: -------------------------------------------------------------------------------- 1 | zero -------------------------------------------------------------------------------- /test/tests/array/files/1.txt: -------------------------------------------------------------------------------- 1 | one -------------------------------------------------------------------------------- /test/tests/array/files/2.txt: -------------------------------------------------------------------------------- 1 | two -------------------------------------------------------------------------------- /test/tests/ignore-files/config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | exclude: '**/README.md' 3 | }; 4 | -------------------------------------------------------------------------------- /test/tests/ignore-files/expected.json: -------------------------------------------------------------------------------- 1 | {"someText":"here is some text"} -------------------------------------------------------------------------------- /test/tests/ignore-files/files/README.md: -------------------------------------------------------------------------------- 1 | This file should be ignored -------------------------------------------------------------------------------- /test/tests/ignore-files/files/someText.txt: -------------------------------------------------------------------------------- 1 | here is some text -------------------------------------------------------------------------------- /test/tests/object-with-array/config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | exclude: '**/README.md' 3 | }; 4 | -------------------------------------------------------------------------------- /test/tests/object-with-array/expected.json: -------------------------------------------------------------------------------- 1 | { 2 | "someText": "here is some text", 3 | "array": [ 4 | "zero", 5 | "one", 6 | "two", 7 | "three" 8 | ] 9 | } -------------------------------------------------------------------------------- /test/tests/object-with-array/files/array/0.txt: -------------------------------------------------------------------------------- 1 | zero -------------------------------------------------------------------------------- /test/tests/object-with-array/files/array/1.txt: -------------------------------------------------------------------------------- 1 | one -------------------------------------------------------------------------------- /test/tests/object-with-array/files/array/2.txt: -------------------------------------------------------------------------------- 1 | two -------------------------------------------------------------------------------- /test/tests/object-with-array/files/array/3.txt: -------------------------------------------------------------------------------- 1 | three -------------------------------------------------------------------------------- /test/tests/object-with-array/files/array/README.md: -------------------------------------------------------------------------------- 1 | This file should not prevent the 'array' folder from being treated as an array, because it should be excluded. -------------------------------------------------------------------------------- /test/tests/object-with-array/files/someText.txt: -------------------------------------------------------------------------------- 1 | here is some text -------------------------------------------------------------------------------- /test/tests/object-with-two-properties/config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | }; 4 | -------------------------------------------------------------------------------- /test/tests/object-with-two-properties/expected.json: -------------------------------------------------------------------------------- 1 | {"someText":"here is some text","someJson":{"message":"here is some JSON"}} -------------------------------------------------------------------------------- /test/tests/object-with-two-properties/files/someJson.json: -------------------------------------------------------------------------------- 1 | { 2 | "message": "here is some JSON" 3 | } -------------------------------------------------------------------------------- /test/tests/object-with-two-properties/files/someText.txt: -------------------------------------------------------------------------------- 1 | here is some text -------------------------------------------------------------------------------- /test/tests/retain-file-extensions/config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | keepExtensions: true 3 | }; 4 | -------------------------------------------------------------------------------- /test/tests/retain-file-extensions/expected.json: -------------------------------------------------------------------------------- 1 | { 2 | "someText.txt": "here is some text", 3 | "object": { 4 | "0.txt": "zero", 5 | "1.txt": "one", 6 | "2.txt": "two", 7 | "3.txt": "three" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /test/tests/retain-file-extensions/files/object/0.txt: -------------------------------------------------------------------------------- 1 | zero -------------------------------------------------------------------------------- /test/tests/retain-file-extensions/files/object/1.txt: -------------------------------------------------------------------------------- 1 | one -------------------------------------------------------------------------------- /test/tests/retain-file-extensions/files/object/2.txt: -------------------------------------------------------------------------------- 1 | two -------------------------------------------------------------------------------- /test/tests/retain-file-extensions/files/object/3.txt: -------------------------------------------------------------------------------- 1 | three -------------------------------------------------------------------------------- /test/tests/retain-file-extensions/files/someText.txt: -------------------------------------------------------------------------------- 1 | here is some text -------------------------------------------------------------------------------- /test/tests/simple-object/config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | }; 4 | -------------------------------------------------------------------------------- /test/tests/simple-object/expected.json: -------------------------------------------------------------------------------- 1 | {"someText":"here is some text"} -------------------------------------------------------------------------------- /test/tests/simple-object/files/someText.txt: -------------------------------------------------------------------------------- 1 | here is some text -------------------------------------------------------------------------------- /test/tests/valid-but-empty/config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | }; 4 | -------------------------------------------------------------------------------- /test/tests/valid-but-empty/expected.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /test/tests/valid-but-empty/files/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rich-Harris/spelunk/43c92e3e7e3f497466c9939b1a573183ea09df87/test/tests/valid-but-empty/files/.gitkeep --------------------------------------------------------------------------------