@mikestools/usefilesystem 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +349 -118
- package/dist/index.d.ts +0 -200
- package/dist/usefilesystem.js +199 -416
- package/dist/usefilesystem.umd.cjs +1 -1
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -1,187 +1,418 @@
|
|
|
1
1
|
# @mikestools/usefilesystem
|
|
2
2
|
|
|
3
|
-
Vue 3
|
|
3
|
+
Vue 3 composable for an in-memory virtual filesystem with reactive state and familiar Node.js-like API.
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
- **
|
|
9
|
-
- **
|
|
10
|
-
- **
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
7
|
+
- 🎯 **Composable-First** - Pure Vue 3 Composition API, no components
|
|
8
|
+
- 📦 **TypeScript First** - Full type safety with IntelliSense
|
|
9
|
+
- 🗂️ **Auto Directories** - Parent directories created automatically on file write
|
|
10
|
+
- ✨ **Reactive State** - File counts and sizes update automatically
|
|
11
|
+
|
|
12
|
+
### Complete Feature Set
|
|
13
|
+
|
|
14
|
+
| Feature | Description |
|
|
15
|
+
|---------------------------|---------------------------------------------|
|
|
16
|
+
| 📁 **File Operations** | Write, read, copy, move, delete files |
|
|
17
|
+
| 📂 **Directory Ops** | Create, list, remove directories |
|
|
18
|
+
| 🔍 **Glob Search** | Find files with patterns like `**/*.ts` |
|
|
19
|
+
| 👀 **File Watchers** | Subscribe to create, modify, delete events |
|
|
20
|
+
| ⚡ **Reactive Stats** | Auto-updating file count and total size |
|
|
21
|
+
| 💾 **Serialization** | Export/import filesystem state as JSON |
|
|
22
|
+
| 🗄️ **Persistence** | Storage adapter support with auto-persist |
|
|
23
|
+
| 📝 **MIME Types** | Automatic inference from file extensions |
|
|
24
|
+
| 🔄 **Path Normalization** | Handles mixed separators and relative paths |
|
|
14
25
|
|
|
15
26
|
## Installation
|
|
16
27
|
|
|
17
28
|
```bash
|
|
18
|
-
npm install @mikestools/usefilesystem
|
|
29
|
+
npm install @mikestools/usefilesystem vue
|
|
19
30
|
```
|
|
20
31
|
|
|
21
32
|
## Quick Start
|
|
22
33
|
|
|
23
34
|
```typescript
|
|
24
|
-
import {
|
|
35
|
+
import { ref, onMounted } from 'vue'
|
|
36
|
+
import { useFileSystem } from '@mikestools/usefilesystem'
|
|
25
37
|
|
|
26
|
-
|
|
38
|
+
onMounted(() => {
|
|
39
|
+
const fs = useFileSystem()
|
|
40
|
+
|
|
41
|
+
// Write files - directories auto-created
|
|
42
|
+
fs.writeFile('/src/index.ts', 'export const version = "1.0.0"')
|
|
43
|
+
fs.writeFile('/readme.md', '# My Project')
|
|
44
|
+
|
|
45
|
+
// Reactive stats (update automatically)
|
|
46
|
+
console.log(fs.fileCount.value) // 2
|
|
47
|
+
console.log(fs.totalSize.value) // bytes
|
|
27
48
|
|
|
28
|
-
//
|
|
29
|
-
fs.
|
|
30
|
-
fs.writeFile('/readme.md', '# My Project')
|
|
49
|
+
// Read content
|
|
50
|
+
const content = fs.readFile('/src/index.ts')
|
|
31
51
|
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
console.log(fs.totalSize.value) // bytes
|
|
52
|
+
// Search with glob patterns
|
|
53
|
+
const tsFiles = fs.find('/src/**/*.ts')
|
|
35
54
|
|
|
36
|
-
//
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
)
|
|
55
|
+
// Watch for changes
|
|
56
|
+
const unsubscribe = fs.watch((event, path, type) => {
|
|
57
|
+
console.log(`${event} ${type}: ${path}`)
|
|
58
|
+
})
|
|
59
|
+
})
|
|
42
60
|
```
|
|
43
61
|
|
|
44
|
-
##
|
|
62
|
+
## API Reference
|
|
63
|
+
|
|
64
|
+
### Core Properties
|
|
45
65
|
|
|
46
|
-
|
|
66
|
+
| Property | Type | Description |
|
|
67
|
+
|--------------|---------------------------------|--------------------------------|
|
|
68
|
+
| `stats` | `ComputedRef<FileSystemStats>` | Reactive stats object |
|
|
69
|
+
| `fileCount` | `ComputedRef<number>` | Number of files |
|
|
70
|
+
| `totalSize` | `ComputedRef<number>` | Total size in bytes |
|
|
47
71
|
|
|
72
|
+
### File Methods
|
|
73
|
+
|
|
74
|
+
#### Write Operations
|
|
48
75
|
```typescript
|
|
49
|
-
|
|
76
|
+
fs.writeFile('/path/file.txt', 'content')
|
|
77
|
+
fs.writeFile('/path/file.txt', 'content', { mimeType: 'text/plain', overwrite: false })
|
|
78
|
+
fs.appendFile('/path/file.txt', 'more content')
|
|
79
|
+
```
|
|
50
80
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
fs.
|
|
54
|
-
const
|
|
55
|
-
const
|
|
56
|
-
|
|
81
|
+
#### Read Operations
|
|
82
|
+
```typescript
|
|
83
|
+
const text = fs.readFile('/path/file.txt') // Returns string
|
|
84
|
+
const binary = fs.readFile('/image.png', { encoding: 'binary' }) // Returns Uint8Array
|
|
85
|
+
const meta = fs.stat('/path/file.txt') // Returns FileMetadata
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
#### File Management
|
|
89
|
+
```typescript
|
|
57
90
|
fs.copy('/source.txt', '/dest.txt')
|
|
91
|
+
fs.copy('/source.txt', '/dest.txt', { overwrite: false })
|
|
58
92
|
fs.move('/old.txt', '/new.txt')
|
|
93
|
+
fs.move('/old.txt', '/new.txt', { overwrite: false })
|
|
94
|
+
fs.rename('/path/old.txt', 'new.txt')
|
|
95
|
+
fs.remove('/path/file.txt')
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
#### Path Checks
|
|
99
|
+
```typescript
|
|
100
|
+
fs.exists('/path') // true if file or directory exists
|
|
101
|
+
fs.isFile('/path') // true if path is a file
|
|
102
|
+
fs.isDirectory('/path') // true if path is a directory
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Directory Methods
|
|
59
106
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
fs.
|
|
63
|
-
fs.
|
|
64
|
-
|
|
107
|
+
#### Directory Operations
|
|
108
|
+
```typescript
|
|
109
|
+
fs.mkdir('/a/b/c') // Creates all parent directories
|
|
110
|
+
fs.rmdir('/empty') // Removes empty directory only
|
|
111
|
+
fs.rmdirRecursive('/folder') // Removes directory and all contents
|
|
112
|
+
const meta = fs.statDirectory('/path') // Returns DirectoryMetadata
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
#### List Contents
|
|
116
|
+
```typescript
|
|
117
|
+
const entries = fs.list('/')
|
|
118
|
+
const recursive = fs.list('/', { recursive: true })
|
|
119
|
+
const filesOnly = fs.list('/', { filesOnly: true })
|
|
120
|
+
const dirsOnly = fs.list('/', { directoriesOnly: true })
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Search Methods
|
|
124
|
+
|
|
125
|
+
#### Glob Patterns
|
|
126
|
+
```typescript
|
|
127
|
+
const tsFiles = fs.find('/src/**/*.ts') // Recursive TypeScript files
|
|
128
|
+
const configs = fs.find('/**/config.*') // Any config file
|
|
129
|
+
const rootFiles = fs.find('/*.txt') // Root-level text files
|
|
130
|
+
```
|
|
65
131
|
|
|
66
|
-
|
|
67
|
-
|
|
132
|
+
#### Extension Search
|
|
133
|
+
```typescript
|
|
68
134
|
const jsonFiles = fs.findByExtension('.json')
|
|
135
|
+
const images = fs.findByExtension('png') // Works with or without dot
|
|
136
|
+
```
|
|
69
137
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
fs.
|
|
73
|
-
fs.
|
|
74
|
-
fs.
|
|
138
|
+
#### Bulk Access
|
|
139
|
+
```typescript
|
|
140
|
+
const allFiles = fs.getAllFiles() // Returns VirtualFile[]
|
|
141
|
+
const allPaths = fs.getAllPaths() // Returns string[]
|
|
142
|
+
const stats = fs.getStats() // Returns FileSystemStats
|
|
143
|
+
```
|
|
75
144
|
|
|
76
|
-
|
|
77
|
-
console.log(fs.stats.value) // { totalFiles, totalDirectories, totalSize }
|
|
78
|
-
console.log(fs.fileCount.value)
|
|
79
|
-
console.log(fs.totalSize.value)
|
|
145
|
+
### Watcher Methods
|
|
80
146
|
|
|
81
|
-
|
|
147
|
+
```typescript
|
|
148
|
+
// Watch for changes
|
|
82
149
|
const unsubscribe = fs.watch((event, path, type) => {
|
|
83
150
|
console.log(`${event} ${type}: ${path}`)
|
|
151
|
+
// event: 'create' | 'modify' | 'delete'
|
|
152
|
+
// type: 'file' | 'directory'
|
|
84
153
|
})
|
|
85
154
|
|
|
86
|
-
//
|
|
155
|
+
// Triggers: "create file: /new.txt"
|
|
156
|
+
fs.writeFile('/new.txt', 'content')
|
|
157
|
+
|
|
158
|
+
// Triggers: "modify file: /new.txt"
|
|
159
|
+
fs.writeFile('/new.txt', 'updated')
|
|
160
|
+
|
|
161
|
+
// Triggers: "delete file: /new.txt"
|
|
162
|
+
fs.remove('/new.txt')
|
|
163
|
+
|
|
164
|
+
// Stop watching
|
|
165
|
+
unsubscribe()
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Serialization Methods
|
|
169
|
+
|
|
170
|
+
```typescript
|
|
171
|
+
// Export to JSON (base64 encoded content)
|
|
87
172
|
const json = fs.toJSON()
|
|
88
|
-
|
|
173
|
+
localStorage.setItem('filesystem', JSON.stringify(json))
|
|
174
|
+
|
|
175
|
+
// Import from JSON
|
|
176
|
+
const data = JSON.parse(localStorage.getItem('filesystem'))
|
|
177
|
+
fs.fromJSON(data)
|
|
178
|
+
|
|
179
|
+
// Clear all files and directories
|
|
89
180
|
fs.clear()
|
|
90
181
|
```
|
|
91
182
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
ZIP archive operations with reactive state.
|
|
183
|
+
### Persistence Methods
|
|
95
184
|
|
|
96
185
|
```typescript
|
|
97
|
-
|
|
186
|
+
import { useFileSystem, type StorageAdapter } from '@mikestools/usefilesystem'
|
|
187
|
+
|
|
188
|
+
// Create custom adapter
|
|
189
|
+
const adapter: StorageAdapter = {
|
|
190
|
+
async save(key, data) { /* save Uint8Array */ },
|
|
191
|
+
async load(key) { /* return Uint8Array | undefined */ },
|
|
192
|
+
async remove(key) { /* delete key */ },
|
|
193
|
+
async list() { /* return string[] of keys */ },
|
|
194
|
+
async clear() { /* clear all */ }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Use with composable
|
|
198
|
+
const fs = useFileSystem({
|
|
199
|
+
adapter,
|
|
200
|
+
autoPersist: true // Auto-save on every change
|
|
201
|
+
})
|
|
98
202
|
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
])
|
|
203
|
+
// Manual persist/restore
|
|
204
|
+
await fs.persist()
|
|
205
|
+
await fs.restore()
|
|
206
|
+
```
|
|
104
207
|
|
|
105
|
-
|
|
106
|
-
await zip.download(files, 'archive.zip')
|
|
208
|
+
### Standalone Function
|
|
107
209
|
|
|
108
|
-
|
|
109
|
-
const entries = await zip.extract(zipBlob)
|
|
110
|
-
const filtered = await zip.extract(zipBlob, {
|
|
111
|
-
filter: entry => entry.path.endsWith('.txt')
|
|
112
|
-
})
|
|
210
|
+
For use without Vue reactivity:
|
|
113
211
|
|
|
114
|
-
|
|
115
|
-
|
|
212
|
+
```typescript
|
|
213
|
+
import { createFileSystem } from '@mikestools/usefilesystem'
|
|
116
214
|
|
|
117
|
-
//
|
|
118
|
-
const
|
|
215
|
+
// Pure filesystem (no Vue reactivity)
|
|
216
|
+
const fs = createFileSystem()
|
|
217
|
+
fs.writeFile('/file.txt', 'Hello')
|
|
218
|
+
const content = fs.readFile('/file.txt')
|
|
219
|
+
```
|
|
119
220
|
|
|
120
|
-
|
|
121
|
-
console.log(zip.isProcessing.value) // boolean
|
|
122
|
-
console.log(zip.progress.value) // 0-100
|
|
123
|
-
console.log(zip.error.value) // string | undefined
|
|
221
|
+
### Types
|
|
124
222
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
223
|
+
```typescript
|
|
224
|
+
// File types
|
|
225
|
+
interface VirtualFile {
|
|
226
|
+
readonly name: string
|
|
227
|
+
readonly path: string
|
|
228
|
+
readonly content: Uint8Array
|
|
229
|
+
readonly size: number
|
|
230
|
+
readonly mimeType: string
|
|
231
|
+
readonly createdAt: number
|
|
232
|
+
readonly modifiedAt: number
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
interface FileMetadata {
|
|
236
|
+
readonly name: string
|
|
237
|
+
readonly path: string
|
|
238
|
+
readonly size: number
|
|
239
|
+
readonly mimeType: string
|
|
240
|
+
readonly createdAt: number
|
|
241
|
+
readonly modifiedAt: number
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Directory types
|
|
245
|
+
interface DirectoryMetadata {
|
|
246
|
+
readonly name: string
|
|
247
|
+
readonly path: string
|
|
248
|
+
readonly createdAt: number
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
interface DirectoryEntry {
|
|
252
|
+
readonly name: string
|
|
253
|
+
readonly path: string
|
|
254
|
+
readonly type: 'file' | 'directory'
|
|
255
|
+
readonly size?: number // Only for files
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Operation result
|
|
259
|
+
interface FileSystemResult {
|
|
260
|
+
readonly success: boolean
|
|
261
|
+
readonly error?: string
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Stats
|
|
265
|
+
interface FileSystemStats {
|
|
266
|
+
readonly totalFiles: number
|
|
267
|
+
readonly totalDirectories: number
|
|
268
|
+
readonly totalSize: number
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Watcher callback
|
|
272
|
+
type FileSystemWatcher = (
|
|
273
|
+
event: 'create' | 'modify' | 'delete',
|
|
274
|
+
path: string,
|
|
275
|
+
type: 'file' | 'directory'
|
|
276
|
+
) => void
|
|
277
|
+
|
|
278
|
+
// Options
|
|
279
|
+
interface WriteFileOptions {
|
|
280
|
+
readonly mimeType?: string
|
|
281
|
+
readonly overwrite?: boolean
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
interface ReadFileOptions {
|
|
285
|
+
readonly encoding?: 'utf8' | 'binary'
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
interface ListOptions {
|
|
289
|
+
readonly recursive?: boolean
|
|
290
|
+
readonly filesOnly?: boolean
|
|
291
|
+
readonly directoriesOnly?: boolean
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
interface CopyOptions {
|
|
295
|
+
readonly overwrite?: boolean
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
interface MoveOptions {
|
|
299
|
+
readonly overwrite?: boolean
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Persistence
|
|
303
|
+
interface StorageAdapter {
|
|
304
|
+
save(key: string, data: Uint8Array): Promise<void>
|
|
305
|
+
load(key: string): Promise<Uint8Array | undefined>
|
|
306
|
+
remove(key: string): Promise<void>
|
|
307
|
+
list(): Promise<readonly string[]>
|
|
308
|
+
clear(): Promise<void>
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
interface UseFileSystemOptions {
|
|
312
|
+
readonly adapter?: StorageAdapter
|
|
313
|
+
readonly autoPersist?: boolean
|
|
314
|
+
}
|
|
129
315
|
```
|
|
130
316
|
|
|
131
|
-
##
|
|
317
|
+
## Examples
|
|
132
318
|
|
|
133
|
-
|
|
319
|
+
### Project Scaffolding
|
|
134
320
|
|
|
135
321
|
```typescript
|
|
136
|
-
|
|
137
|
-
createFileSystem,
|
|
138
|
-
createZip,
|
|
139
|
-
extractZip,
|
|
140
|
-
listZip,
|
|
141
|
-
compress,
|
|
142
|
-
decompress,
|
|
143
|
-
computeCrc32,
|
|
144
|
-
downloadBlob,
|
|
145
|
-
downloadAsZip
|
|
146
|
-
} from '@mikestools/usefilesystem'
|
|
322
|
+
const fs = useFileSystem()
|
|
147
323
|
|
|
148
|
-
//
|
|
149
|
-
|
|
324
|
+
// Generate project structure
|
|
325
|
+
fs.writeFile('/package.json', JSON.stringify({
|
|
326
|
+
name: 'my-app',
|
|
327
|
+
version: '1.0.0'
|
|
328
|
+
}, null, 2))
|
|
329
|
+
|
|
330
|
+
fs.writeFile('/src/index.ts', 'export const app = () => console.log("Hello!")')
|
|
331
|
+
fs.writeFile('/src/utils/helpers.ts', 'export const add = (a: number, b: number) => a + b')
|
|
332
|
+
fs.writeFile('/readme.md', '# My App\n\nGenerated project.')
|
|
333
|
+
|
|
334
|
+
console.log(fs.stats.value)
|
|
335
|
+
// { totalFiles: 4, totalDirectories: 2, totalSize: ... }
|
|
336
|
+
```
|
|
150
337
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
338
|
+
### File Browser
|
|
339
|
+
|
|
340
|
+
```typescript
|
|
341
|
+
const fs = useFileSystem()
|
|
342
|
+
const currentPath = ref('/')
|
|
343
|
+
|
|
344
|
+
// Navigate directories
|
|
345
|
+
function navigate(path: string) {
|
|
346
|
+
if (fs.isDirectory(path)) {
|
|
347
|
+
currentPath.value = path
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Get current directory contents
|
|
352
|
+
const entries = computed(() => {
|
|
353
|
+
return fs.list(currentPath.value).sort((a, b) => {
|
|
354
|
+
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1
|
|
355
|
+
return a.name.localeCompare(b.name)
|
|
356
|
+
})
|
|
357
|
+
})
|
|
154
358
|
```
|
|
155
359
|
|
|
156
|
-
|
|
360
|
+
### Auto-Save with localStorage
|
|
157
361
|
|
|
158
|
-
|
|
362
|
+
```typescript
|
|
363
|
+
const adapter: StorageAdapter = {
|
|
364
|
+
async save(key, data) {
|
|
365
|
+
localStorage.setItem(key, btoa(String.fromCharCode(...data)))
|
|
366
|
+
},
|
|
367
|
+
async load(key) {
|
|
368
|
+
const base64 = localStorage.getItem(key)
|
|
369
|
+
if (!base64) return undefined
|
|
370
|
+
return Uint8Array.from(atob(base64), c => c.charCodeAt(0))
|
|
371
|
+
},
|
|
372
|
+
async remove(key) {
|
|
373
|
+
localStorage.removeItem(key)
|
|
374
|
+
},
|
|
375
|
+
async list() {
|
|
376
|
+
return Object.keys(localStorage)
|
|
377
|
+
},
|
|
378
|
+
async clear() {
|
|
379
|
+
localStorage.clear()
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const fs = useFileSystem({ adapter, autoPersist: true })
|
|
384
|
+
|
|
385
|
+
// Restore on startup
|
|
386
|
+
await fs.restore()
|
|
387
|
+
|
|
388
|
+
// All changes now auto-save!
|
|
389
|
+
fs.writeFile('/notes.txt', 'This will persist')
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
### Change Tracking
|
|
159
393
|
|
|
160
394
|
```typescript
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
395
|
+
const fs = useFileSystem()
|
|
396
|
+
const changes = ref<string[]>([])
|
|
397
|
+
|
|
398
|
+
fs.watch((event, path, type) => {
|
|
399
|
+
changes.value.push(`${new Date().toISOString()}: ${event} ${type} ${path}`)
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
// Track all operations
|
|
403
|
+
fs.writeFile('/file.txt', 'content')
|
|
404
|
+
fs.writeFile('/file.txt', 'updated')
|
|
405
|
+
fs.remove('/file.txt')
|
|
406
|
+
|
|
407
|
+
console.log(changes.value)
|
|
408
|
+
// ["...: create file /file.txt", "...: modify file /file.txt", "...: delete file /file.txt"]
|
|
175
409
|
```
|
|
176
410
|
|
|
177
411
|
## Browser Support
|
|
178
412
|
|
|
179
|
-
|
|
180
|
-
- CompressionStream/DecompressionStream (Chrome 80+, Firefox 113+, Safari 16.4+)
|
|
181
|
-
- TextEncoder/TextDecoder
|
|
182
|
-
- Blob, ArrayBuffer, Uint8Array
|
|
413
|
+
Works in all modern browsers that support ES2020+.
|
|
183
414
|
|
|
184
415
|
## License
|
|
185
416
|
|
|
186
|
-
MIT
|
|
417
|
+
MIT © Mike Garcia
|
|
187
418
|
|